Skip to content

Commit

Permalink
WIP graph explorer
Browse files Browse the repository at this point in the history
  • Loading branch information
alexfauquette committed Dec 13, 2024
1 parent dfe21a1 commit a4a93bc
Show file tree
Hide file tree
Showing 6 changed files with 732 additions and 2 deletions.
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
27 changes: 27 additions & 0 deletions src/pages/taxonomyWalk/ForceGraph.tsx
Original file line number Diff line number Diff line change
@@ -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 <canvas ref={containerRef} style={{ width: 500, height: 500 }} />;
}
128 changes: 128 additions & 0 deletions src/pages/taxonomyWalk/forceGraphGenerator.ts
Original file line number Diff line number Diff line change
@@ -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();
},
};
}
84 changes: 84 additions & 0 deletions src/pages/taxonomyWalk/generateGraph.ts
Original file line number Diff line number Diff line change
@@ -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<string, "loading" | "failed" | CategoryType>,
) {
const nodeDepth: Record<string, number> = { [rootId]: 0 };
const links: LinkType[] = [];
const seen = new Set<string>();

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,
})),
};
}
Loading

0 comments on commit a4a93bc

Please sign in to comment.