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 (
+