-
-
Notifications
You must be signed in to change notification settings - Fork 39
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
- Loading branch information
1 parent
dfe21a1
commit a4a93bc
Showing
6 changed files
with
732 additions
and
2 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 }} />; | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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(); | ||
}, | ||
}; | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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, | ||
})), | ||
}; | ||
} |
Oops, something went wrong.