Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

WIP graph explorer #1105

Draft
wants to merge 1 commit into
base: master
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
Loading