diff --git a/package.json b/package.json index a2833baba6..bdc9136bd4 100644 --- a/package.json +++ b/package.json @@ -13,6 +13,7 @@ "@mui/x-data-grid": "^6.18.0", "@reduxjs/toolkit": "^1.9.7", "axios": "^0.27.2", + "d3": "^7.9.0", "i18next": "^23.6.0", "lodash.isequal": "^4.5.0", "react": "^18.2.0", diff --git a/src/pages/taxonomyWalk/ForceGraph.tsx b/src/pages/taxonomyWalk/ForceGraph.tsx new file mode 100644 index 0000000000..c1b556efd9 --- /dev/null +++ b/src/pages/taxonomyWalk/ForceGraph.tsx @@ -0,0 +1,27 @@ +import * as React from "react"; +import { runForceGraph } from "./forceGraphGenerator"; + +export default function ForceGraph({ linksData, nodesData }) { + const containerRef = React.useRef(null); + const graphRef = React.useRef(null); + + React.useEffect(() => { + if (containerRef.current) { + try { + graphRef.current = runForceGraph( + containerRef.current, + linksData, + nodesData, + ); + } catch (e) { + console.error(e); + } + } + + return () => { + graphRef.current?.destroy?.(); + }; + }, [linksData, nodesData]); + + return ; +} diff --git a/src/pages/taxonomyWalk/forceGraphGenerator.ts b/src/pages/taxonomyWalk/forceGraphGenerator.ts new file mode 100644 index 0000000000..c4fcac6a81 --- /dev/null +++ b/src/pages/taxonomyWalk/forceGraphGenerator.ts @@ -0,0 +1,128 @@ +import * as d3 from "d3"; +// import "@fortawesome/fontawesome-free/css/all.min.css"; + +export function runForceGraph( + container, + linksData, + nodesData, + // nodeHoverTooltip, +) { + // Specify the dimensions of the chart. + const width = 500; + const height = 500; + + // Specify the color scale. + const color = d3.scaleOrdinal(d3.schemeCategory10); + + // The force simulation mutates links and nodes, so create a copy + // so that re-evaluating this cell produces the same result. + const links = linksData.map((d) => ({ ...d })); + const nodes = nodesData.map((d) => ({ ...d, fy: 250 + 50 * d.depth })); + + // Create a simulation with several forces. + const simulation = d3 + .forceSimulation(nodes) + .force( + "link", + d3.forceLink(links).id((d) => d.id), + ) + .force("charge", d3.forceManyBody()) + .force("center", d3.forceCenter(width / 2, height / 2)) + .on("tick", draw); + + // Create the canvas. + const dpi = devicePixelRatio; // _e.g._, 2 for retina screens + const canvas = d3 + .select(container) + .attr("width", dpi * width) + .attr("height", dpi * height) + .attr("style", `width: ${width}px; max-width: 100%; height: auto;`) + .node(); + + const context = canvas.getContext("2d"); + context.scale(dpi, dpi); + + function draw() { + context.clearRect(0, 0, width, height); + + context.save(); + context.globalAlpha = 0.6; + context.strokeStyle = "#999"; + context.beginPath(); + links.forEach(drawLink); + context.stroke(); + context.restore(); + + context.save(); + context.strokeStyle = "#fff"; + context.globalAlpha = 1; + nodes.forEach((node) => { + context.beginPath(); + drawNode(node); + context.fillStyle = color(node.depth % 10); + context.strokeStyle = "#fff"; + context.fill(); + context.stroke(); + }); + context.restore(); + } + + function drawLink(d) { + context.moveTo(d.source.x, d.source.y); + context.lineTo(d.target.x, d.target.y); + } + + function drawNode(d) { + context.moveTo(d.x + 5, d.y); + context.arc(d.x, d.y, 5, 0, 2 * Math.PI); + } + + // Add a drag behavior. The _subject_ identifies the closest node to the pointer, + // conditional on the distance being less than 20 pixels. + d3.select(canvas).call( + d3 + .drag() + .subject((event) => { + const [px, py] = d3.pointer(event, canvas); + return d3.least(nodes, ({ x, y }) => { + const dist2 = (x - px) ** 2 + (y - py) ** 2; + if (dist2 < 400) return dist2; + }); + }) + .on("start", dragstarted) + .on("drag", dragged) + .on("end", dragended), + ); + + // Reheat the simulation when drag starts, and fix the subject position. + function dragstarted(event) { + if (!event.active) simulation.alphaTarget(0.3).restart(); + event.subject.fx = event.subject.x; + // event.subject.fy = event.subject.y; + } + + // Update the subject (dragged node) position during drag. + function dragged(event) { + event.subject.fx = event.x; + // event.subject.fy = event.y; + } + + // Restore the target alpha so the simulation cools after dragging ends. + // Unfix the subject position now that it’s no longer being dragged. + function dragended(event) { + if (!event.active) simulation.alphaTarget(0); + event.subject.fx = null; + // event.subject.fy = null; + } + + // When this cell is re-run, stop the previous simulation. (This doesn’t + // really matter since the target alpha is zero and the simulation will + // stop naturally, but it’s a good practice.) + // invalidation.then(() => simulation.stop()); + + return { + destroy: () => { + simulation.stop(); + }, + }; +} diff --git a/src/pages/taxonomyWalk/generateGraph.ts b/src/pages/taxonomyWalk/generateGraph.ts new file mode 100644 index 0000000000..12cdf03ee1 --- /dev/null +++ b/src/pages/taxonomyWalk/generateGraph.ts @@ -0,0 +1,84 @@ +import { CategoryType } from "./ItemCard"; + +type NodeType = { + id: string; + /** + * length of the shortest path with the item. Can be negative + */ + depth: number; +}; + +type LinkType = { + child: string; + parent: string; +}; + +function itemOrUndefined( + item: "loading" | "failed" | CategoryType, +): CategoryType | undefined { + if (typeof item === "string") { + return undefined; + } + return item; +} + +export function generateGraph( + rootId: string, + taxoLookup: Record, +) { + const nodeDepth: Record = { [rootId]: 0 }; + const links: LinkType[] = []; + const seen = new Set(); + + const parentsLinksFIFO = + itemOrUndefined(taxoLookup[rootId])?.parents?.map((parent) => ({ + child: rootId, + parent, + })) ?? []; + const childLinksFIFO = + itemOrUndefined(taxoLookup[rootId])?.children?.map((child) => ({ + child, + parent: rootId, + })) ?? []; + + while (parentsLinksFIFO.length > 0) { + const link = parentsLinksFIFO.shift(); + const node = link.parent; + if (!seen.has(node)) { + seen.add(node); + links.push(link); + nodeDepth[node] = nodeDepth[link.child] - 1; + + itemOrUndefined(taxoLookup[node]) + ?.parents?.map((parent) => ({ child: node, parent })) + ?.forEach((link) => parentsLinksFIFO.push(link)); + } + } + + while (childLinksFIFO.length > 0) { + const link = childLinksFIFO.shift(); + const node = link.child; + if (!seen.has(node)) { + seen.add(node); + links.push(link); + nodeDepth[node] = nodeDepth[link.parent] + 1; + + itemOrUndefined(taxoLookup[node]) + ?.children?.map((child) => ({ child, parent: node })) + ?.forEach((link) => childLinksFIFO.push(link)); + } + } + + const nodes: NodeType[] = Object.entries(nodeDepth).map(([id, depth]) => ({ + id, + depth, + })); + + return { + nodes, + links: links?.map(({ child, parent }) => ({ + source: child, + target: parent, + })), + }; +} diff --git a/src/pages/taxonomyWalk/index.tsx b/src/pages/taxonomyWalk/index.tsx index d0c8509d21..fce86b2da2 100644 --- a/src/pages/taxonomyWalk/index.tsx +++ b/src/pages/taxonomyWalk/index.tsx @@ -5,6 +5,8 @@ import getTaxonomy from "../../offTaxonomy"; import Stack from "@mui/material/Stack"; import { ErrorBoundary } from "./Error"; import { Button, Typography } from "@mui/material"; +import ForceGraph from "./ForceGraph"; +import { generateGraph } from "./generateGraph"; export default function TaxonomyWalk() { const [initialItem, setInitialItem] = React.useState(null); - const [currentId, setCurrentId] = React.useState("en:carrots"); + const [currentId, setCurrentId] = React.useState( + "en:meals", + // "en:carrots" + ); const [taxonomyLookup, setTaxonomyLookup] = React.useState< Record @@ -33,6 +38,133 @@ export default function TaxonomyWalk() { name: { en: "Carrots", fr: "Carottes" }, parents: ["en:vegetables"], }, + "en:meals": { + children: [ + "en:acras", + "en:aligots", + "en:banh-bao", + "en:basque-style-piperade", + "en:bean-dishes", + "en:boiled-meat-with-vegetables", + "en:braised-european-hake", + "en:buckwheat-crepe-filled-with-cheese-ham-and-mushrooms", + "en:buckwheat-crepe-with-mushrooms", + "en:bulgur-dishes", + "en:canned-meals", + "en:chinese-dumplings", + "en:chop-suey", + "en:combination-meals", + "en:cooked-egg-yolk", + "en:cooked-steamed-shrimp-dumplings", + "en:cooked-unsalted-couscous", + "en:couscous", + "en:crepe-filled-with-cheese", + "en:crepe-filled-with-egg-ham-and-cheese", + "en:crepe-filled-with-scallops", + "en:crepes-filled-with-fish", + "en:crepes-filled-with-seafood", + "en:dough-based-meals-ready", + "en:dough-based-meals-variety-packs", + "en:dried-meals", + "en:egg-white-cooked", + "en:endives-with-ham", + "en:fajitas", + "en:filled-buckwheat-crepes", + "en:filled-fritter-garnished-with-shrimps-and-vegetables-and-poultry-and-meat", + "en:filled-fritters", + "en:filled-pinsa", + "en:filled-steamed-buns", + "en:focaccia", + "en:fresh-meals", + "en:fried-egg", + "en:frozen-dough-based-meals-to-prepare", + "en:frozen-grain-based-meals", + "en:frozen-ready-made-meals", + "en:grain-based-meals", + "en:gratins", + "en:hotpots", + "en:irish-stews", + "en:khatfa", + "en:lentil-dishes", + "en:low-fat-prepared-meals", + "en:meal-replacements", + "en:meals-with-falafels", + "en:meals-with-fish", + "en:meals-with-meat", + "en:meals-with-shellfish", + "en:microwave-meals", + "en:moussaka", + "en:nems", + "en:non-frozen-dough-based-meals-to-prepare", + "en:omelettes", + "en:pad-thai", + "en:pan-fried-dishes", + "en:pasta-dishes", + "en:pizzas-pies-and-quiches", + "en:plant-based-meals", + "en:poached-eggs", + "en:pork-sausage-stew-with-cabbage-carrots-and-potatoes", + "en:potato-dishes", + "en:prepared-lentils", + "en:prepared-salads", + "en:puff-pastry-meals", + "en:quinoa-dishes", + "en:ratatouille", + "en:refrigerated-dough-based-meals-ready", + "en:refrigerated-meals", + "en:rice-dishes", + "en:samosas", + "en:sauerkraut-with-garnish", + "en:savory-semolina-dishes", + "en:scrambled-egg", + "en:soft-boiled-eggs", + "en:soups", + "en:spanish-omelettes", + "en:spring-rolls", + "en:stews", + "en:stuffed-cabbage", + "en:stuffed-vine-leaves", + "en:sushi-and-maki", + "en:tabbouleh", + "en:tajine", + "en:truffades", + "en:vegetarian-meals", + "en:waterzooi", + "fr:cereales-preparees", + "fr:choucroutes-de-la-mer", + "fr:crepes-au-jambon", + "fr:fondues", + "fr:quenelles", + ], + food_groups: { + en: "en:one-dish-meals", + }, + incompatible_with: { + en: "categories:en:beverages", + }, + name: { + en: "Meals", + fr: "Plats préparés", + }, + nova: { + en: "3", + }, + pnns_group_2: { + en: "One-dish meals", + }, + synonyms: { + en: ["Meals", "Prepared meals", "Prepared dishes"], + fr: [ + "Plats préparés", + "plat préparé", + "plats cuisinés", + "plat cuisiné", + ], + }, + wikidata: { + en: "Q3391775", + }, + }, }); const fetchItem = React.useCallback(async (tag: string) => { @@ -67,9 +199,11 @@ export default function TaxonomyWalk() { }); }, [taxonomyLookup, currentItem]); + const { nodes, links } = generateGraph("en:meals", taxonomyLookup); return (
+