Skip to content

Commit a4a93bc

Browse files
committed
WIP graph explorer
1 parent dfe21a1 commit a4a93bc

File tree

6 files changed

+732
-2
lines changed

6 files changed

+732
-2
lines changed

package.json

+1
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313
"@mui/x-data-grid": "^6.18.0",
1414
"@reduxjs/toolkit": "^1.9.7",
1515
"axios": "^0.27.2",
16+
"d3": "^7.9.0",
1617
"i18next": "^23.6.0",
1718
"lodash.isequal": "^4.5.0",
1819
"react": "^18.2.0",

src/pages/taxonomyWalk/ForceGraph.tsx

+27
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
import * as React from "react";
2+
import { runForceGraph } from "./forceGraphGenerator";
3+
4+
export default function ForceGraph({ linksData, nodesData }) {
5+
const containerRef = React.useRef(null);
6+
const graphRef = React.useRef(null);
7+
8+
React.useEffect(() => {
9+
if (containerRef.current) {
10+
try {
11+
graphRef.current = runForceGraph(
12+
containerRef.current,
13+
linksData,
14+
nodesData,
15+
);
16+
} catch (e) {
17+
console.error(e);
18+
}
19+
}
20+
21+
return () => {
22+
graphRef.current?.destroy?.();
23+
};
24+
}, [linksData, nodesData]);
25+
26+
return <canvas ref={containerRef} style={{ width: 500, height: 500 }} />;
27+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,128 @@
1+
import * as d3 from "d3";
2+
// import "@fortawesome/fontawesome-free/css/all.min.css";
3+
4+
export function runForceGraph(
5+
container,
6+
linksData,
7+
nodesData,
8+
// nodeHoverTooltip,
9+
) {
10+
// Specify the dimensions of the chart.
11+
const width = 500;
12+
const height = 500;
13+
14+
// Specify the color scale.
15+
const color = d3.scaleOrdinal(d3.schemeCategory10);
16+
17+
// The force simulation mutates links and nodes, so create a copy
18+
// so that re-evaluating this cell produces the same result.
19+
const links = linksData.map((d) => ({ ...d }));
20+
const nodes = nodesData.map((d) => ({ ...d, fy: 250 + 50 * d.depth }));
21+
22+
// Create a simulation with several forces.
23+
const simulation = d3
24+
.forceSimulation(nodes)
25+
.force(
26+
"link",
27+
d3.forceLink(links).id((d) => d.id),
28+
)
29+
.force("charge", d3.forceManyBody())
30+
.force("center", d3.forceCenter(width / 2, height / 2))
31+
.on("tick", draw);
32+
33+
// Create the canvas.
34+
const dpi = devicePixelRatio; // _e.g._, 2 for retina screens
35+
const canvas = d3
36+
.select(container)
37+
.attr("width", dpi * width)
38+
.attr("height", dpi * height)
39+
.attr("style", `width: ${width}px; max-width: 100%; height: auto;`)
40+
.node();
41+
42+
const context = canvas.getContext("2d");
43+
context.scale(dpi, dpi);
44+
45+
function draw() {
46+
context.clearRect(0, 0, width, height);
47+
48+
context.save();
49+
context.globalAlpha = 0.6;
50+
context.strokeStyle = "#999";
51+
context.beginPath();
52+
links.forEach(drawLink);
53+
context.stroke();
54+
context.restore();
55+
56+
context.save();
57+
context.strokeStyle = "#fff";
58+
context.globalAlpha = 1;
59+
nodes.forEach((node) => {
60+
context.beginPath();
61+
drawNode(node);
62+
context.fillStyle = color(node.depth % 10);
63+
context.strokeStyle = "#fff";
64+
context.fill();
65+
context.stroke();
66+
});
67+
context.restore();
68+
}
69+
70+
function drawLink(d) {
71+
context.moveTo(d.source.x, d.source.y);
72+
context.lineTo(d.target.x, d.target.y);
73+
}
74+
75+
function drawNode(d) {
76+
context.moveTo(d.x + 5, d.y);
77+
context.arc(d.x, d.y, 5, 0, 2 * Math.PI);
78+
}
79+
80+
// Add a drag behavior. The _subject_ identifies the closest node to the pointer,
81+
// conditional on the distance being less than 20 pixels.
82+
d3.select(canvas).call(
83+
d3
84+
.drag()
85+
.subject((event) => {
86+
const [px, py] = d3.pointer(event, canvas);
87+
return d3.least(nodes, ({ x, y }) => {
88+
const dist2 = (x - px) ** 2 + (y - py) ** 2;
89+
if (dist2 < 400) return dist2;
90+
});
91+
})
92+
.on("start", dragstarted)
93+
.on("drag", dragged)
94+
.on("end", dragended),
95+
);
96+
97+
// Reheat the simulation when drag starts, and fix the subject position.
98+
function dragstarted(event) {
99+
if (!event.active) simulation.alphaTarget(0.3).restart();
100+
event.subject.fx = event.subject.x;
101+
// event.subject.fy = event.subject.y;
102+
}
103+
104+
// Update the subject (dragged node) position during drag.
105+
function dragged(event) {
106+
event.subject.fx = event.x;
107+
// event.subject.fy = event.y;
108+
}
109+
110+
// Restore the target alpha so the simulation cools after dragging ends.
111+
// Unfix the subject position now that it’s no longer being dragged.
112+
function dragended(event) {
113+
if (!event.active) simulation.alphaTarget(0);
114+
event.subject.fx = null;
115+
// event.subject.fy = null;
116+
}
117+
118+
// When this cell is re-run, stop the previous simulation. (This doesn’t
119+
// really matter since the target alpha is zero and the simulation will
120+
// stop naturally, but it’s a good practice.)
121+
// invalidation.then(() => simulation.stop());
122+
123+
return {
124+
destroy: () => {
125+
simulation.stop();
126+
},
127+
};
128+
}
+84
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
1+
import { CategoryType } from "./ItemCard";
2+
3+
type NodeType = {
4+
id: string;
5+
/**
6+
* length of the shortest path with the item. Can be negative
7+
*/
8+
depth: number;
9+
};
10+
11+
type LinkType = {
12+
child: string;
13+
parent: string;
14+
};
15+
16+
function itemOrUndefined(
17+
item: "loading" | "failed" | CategoryType,
18+
): CategoryType | undefined {
19+
if (typeof item === "string") {
20+
return undefined;
21+
}
22+
return item;
23+
}
24+
25+
export function generateGraph(
26+
rootId: string,
27+
taxoLookup: Record<string, "loading" | "failed" | CategoryType>,
28+
) {
29+
const nodeDepth: Record<string, number> = { [rootId]: 0 };
30+
const links: LinkType[] = [];
31+
const seen = new Set<string>();
32+
33+
const parentsLinksFIFO =
34+
itemOrUndefined(taxoLookup[rootId])?.parents?.map((parent) => ({
35+
child: rootId,
36+
parent,
37+
})) ?? [];
38+
const childLinksFIFO =
39+
itemOrUndefined(taxoLookup[rootId])?.children?.map((child) => ({
40+
child,
41+
parent: rootId,
42+
})) ?? [];
43+
44+
while (parentsLinksFIFO.length > 0) {
45+
const link = parentsLinksFIFO.shift();
46+
const node = link.parent;
47+
if (!seen.has(node)) {
48+
seen.add(node);
49+
links.push(link);
50+
nodeDepth[node] = nodeDepth[link.child] - 1;
51+
52+
itemOrUndefined(taxoLookup[node])
53+
?.parents?.map((parent) => ({ child: node, parent }))
54+
?.forEach((link) => parentsLinksFIFO.push(link));
55+
}
56+
}
57+
58+
while (childLinksFIFO.length > 0) {
59+
const link = childLinksFIFO.shift();
60+
const node = link.child;
61+
if (!seen.has(node)) {
62+
seen.add(node);
63+
links.push(link);
64+
nodeDepth[node] = nodeDepth[link.parent] + 1;
65+
66+
itemOrUndefined(taxoLookup[node])
67+
?.children?.map((child) => ({ child, parent: node }))
68+
?.forEach((link) => childLinksFIFO.push(link));
69+
}
70+
}
71+
72+
const nodes: NodeType[] = Object.entries(nodeDepth).map(([id, depth]) => ({
73+
id,
74+
depth,
75+
}));
76+
77+
return {
78+
nodes,
79+
links: links?.map(({ child, parent }) => ({
80+
source: child,
81+
target: parent,
82+
})),
83+
};
84+
}

0 commit comments

Comments
 (0)