From 482a4b6a93e1e58d5390345136ac24dacabe413b Mon Sep 17 00:00:00 2001 From: Vincent Rubinetti Date: Tue, 10 Sep 2024 14:36:03 -0400 Subject: [PATCH] make basic network viz --- app/src/components/Network.module.css | 18 ++ app/src/components/Network.tsx | 430 ++++++++++++++++++++++++++ app/src/pages/Testbed.tsx | 32 +- app/src/util/color.ts | 93 ++++++ app/src/util/math.ts | 13 + 5 files changed, 585 insertions(+), 1 deletion(-) create mode 100644 app/src/components/Network.module.css create mode 100644 app/src/components/Network.tsx create mode 100644 app/src/util/color.ts create mode 100644 app/src/util/math.ts diff --git a/app/src/components/Network.module.css b/app/src/components/Network.module.css new file mode 100644 index 0000000..a01b6e7 --- /dev/null +++ b/app/src/components/Network.module.css @@ -0,0 +1,18 @@ +.svg { + height: min(800px, 75vh); + background: var(--white); + box-shadow: var(--shadow); +} + +.node { + cursor: pointer; +} + +.node:focus { + outline: none; +} + +.label { + pointer-events: none; + user-select: none; +} diff --git a/app/src/components/Network.tsx b/app/src/components/Network.tsx new file mode 100644 index 0000000..211a2fd --- /dev/null +++ b/app/src/components/Network.tsx @@ -0,0 +1,430 @@ +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { FaDownload } from "react-icons/fa6"; +import clsx from "clsx"; +import * as d3 from "d3"; +import { clamp, cloneDeep } from "lodash"; +import { useElementSize } from "@reactuses/core"; +import Button from "@/components/Button"; +import CheckBox from "@/components/CheckBox"; +import Flex from "@/components/Flex"; +import { getColorMap } from "@/util/color"; +import { downloadSvg } from "@/util/download"; +import { lerp } from "@/util/math"; +import classes from "./Network.module.css"; + +/** min zoom (scale factor) */ +const minZoom = 0.2; +/** lower zoom (scale factor) */ +const maxZoom = 10; +/** empty space to leave around view when fitting (in svg units) */ +const fitPadding = 20; +/** minimum node/link size, in svg units */ +const minSize = 10; +/** maximum node/link size, in svg units */ +const maxSize = 20; +/** color for selected nodes/links */ +const selectedColor = "#000000"; +/** how quickly simulation comes to rest */ +const simulationDecay = 0.2; +/** how much nodes attract each other */ +const attractionStrength = 1; +/** + * equilibrium distance between nodes, as a factor of source radius + target + * radius + */ +const distFactor = 2; + +type Props = { + nodes: { + id: string; + label?: string; + size?: number; + type?: string; + }[]; + links: { + source: string; + target: string; + direction?: -1 | 0 | 1; + size?: number; + type?: string; + }[]; +}; + +type Node = Props["nodes"][number]; +type Link = Props["links"][number]; +type NodeDatum = d3.SimulationNodeDatum & Node; +type LinkDatum = d3.SimulationLinkDatum; + +/** get func to lerp size of node/link */ +const lerpSize = (items: { size?: number }[]) => { + const [min = minSize, max = maxSize] = d3.extent( + items.map((item) => item.size).filter((size) => size !== undefined), + ); + return (size?: number) => lerp(size ?? minSize, min, max, minSize, maxSize); +}; + +const Network = ({ nodes, links }: Props) => { + /** element refs */ + const svgRef = useRef(null); + const zoomRef = useRef(null); + const legendRef = useRef(null); + const linkRefs = useRef>(new Map()); + const circleRefs = useRef>(new Map()); + const labelRefs = useRef>(new Map()); + + /** whether to auto-fit camera to contents every frame */ + const [autoFit, setAutoFit] = useState(false); + + /** selected node */ + const [selectedNode, setSelectedNode] = useState(); + + /** get color maps */ + const nodeColors = getColorMap( + nodes.map((node) => node.type ?? ""), + "light", + ); + const linkColors = getColorMap( + links.map((link) => link.type ?? ""), + "light", + ); + + /** get size ranges */ + const getNodeSize = useMemo(() => lerpSize(nodes), [nodes]); + const getLinkSize = useMemo(() => lerpSize(links), [links]); + + /** camera zoom handler */ + const zoom = useMemo( + () => + d3 + .zoom() + .scaleExtent([minZoom, maxZoom]) + /** on zoom/pan */ + .on("zoom", (event) => { + /** if due to direct user interaction (wheel, drag, pinch, etc) */ + if (event.sourceEvent) setAutoFit(false); + /** update zoom camera transform */ + zoomRef.current?.setAttribute( + "transform", + event.transform.toString(), + ); + }) + /** prevent zoom over legend */ + .filter((event) => !legendRef.current?.contains(event.target)), + [], + ); + + /** fit zoom camera to contents of svg */ + const fitZoom = useCallback(() => { + if (!svgRef.current || !zoomRef.current) return; + + /** get svg size */ + const container = svgRef.current.getBoundingClientRect(); + /** get size of svg contents */ + const contents = zoomRef.current.getBBox(); + + /** get center point of contents */ + const midX = contents.x + contents.width / 2; + const midY = contents.y + contents.height / 2; + + /** determine scale up/down to contain */ + const fromWidth = contents.width / (container.width - fitPadding * 2); + const fromHeight = contents.height / (container.height - fitPadding * 2); + let scale = 1 / Math.max(fromWidth, fromHeight); + + /** limit scale */ + scale = clamp(scale, minZoom, maxZoom); + + /** determine center position */ + const translateX = container.width / 2 - scale * midX; + const translateY = container.height / 2 - scale * midY; + + /** set new camera */ + zoom.transform( + d3.select(svgRef.current), + d3.zoomIdentity.translate(translateX, translateY).scale(scale), + ); + }, [zoom]); + + /** force nodes apart when overlapping */ + const collide = useMemo( + () => + d3 + .forceCollide() + .radius( + (node) => + (node.index === undefined + ? minSize + : getNodeSize(nodes[node.index]?.size ?? minSize)) * distFactor, + ), + [nodes, getNodeSize], + ); + + /** pull nodes toward a center like gravity */ + const attraction = useMemo( + () => + d3 + .forceManyBody() + .strength(attractionStrength) + .distanceMin(minSize) + .distanceMax(maxSize), + [], + ); + + /** push/pull nodes together based on links */ + const spring = useMemo( + () => + d3 + .forceLink() + .distance( + (d) => + ((typeof d.source === "object" + ? getNodeSize(d.source.size) + : minSize) + + (typeof d.target === "object" + ? getNodeSize(d.target.size) + : minSize)) * + distFactor, + ) + .id((d) => d.id), + [getNodeSize], + ); + + /** physics simulation */ + const latestAutoFit = useRef(true); + latestAutoFit.current = autoFit; + const simulation = useMemo( + () => + d3 + .forceSimulation() + .alphaDecay(simulationDecay) + .force("collide", collide) + .force("attraction", attraction) + .force("spring", spring) + .on("tick", () => { + /** position nodes */ + simulation.nodes().forEach((node, index) => { + const circle = circleRefs.current.get(index); + circle?.setAttribute("cx", String(node.x)); + circle?.setAttribute("cy", String(node.y)); + const label = labelRefs.current.get(index); + label?.setAttribute("x", String(node.x)); + label?.setAttribute("y", String(node.y)); + }); + + /** position links */ + (simulation.force("spring") as typeof spring) + .links() + .forEach((link, index) => { + const line = linkRefs.current.get(index); + const { source, target } = link; + if (typeof source === "object") { + line?.setAttribute("x1", String(source.x)); + line?.setAttribute("y1", String(source.y)); + } + if (typeof target === "object") { + line?.setAttribute("x2", String(target.x)); + line?.setAttribute("y2", String(target.y)); + } + }); + + /** fit every tick */ + if (latestAutoFit.current) fitZoom(); + }), + [fitZoom, collide, attraction, spring], + ); + + /** node drag handler */ + const drag = useMemo( + () => + d3 + .drag() + .on("drag", (event, d) => { + /** get node being dragged from datum index */ + const node = simulation.nodes().at(d); + if (!node) return; + /** pin position while dragging */ + node.fx = event.x; + node.fy = event.y; + /** reheat */ + simulation.alpha(1).restart(); + }) + .on("end", (event, d) => { + /** get node being dragged from datum index */ + const node = simulation.nodes().at(d); + if (!node) return; + /** unpin position */ + node.fx = null; + node.fy = null; + }), + [simulation], + ); + + /** update simulation to be in-sync with declarative nodes/links */ + useEffect(() => { + /** update nodes */ + const d3nodeLookup = Object.fromEntries( + simulation.nodes().map((node) => [node.id, node]), + ); + simulation.nodes( + nodes.map((node) => d3nodeLookup[node.id] ?? { id: node.id }), + ); + + /** update links */ + (simulation.force("spring") as typeof spring).links(cloneDeep(links)); + + /** reheat */ + simulation.alpha(1).restart(); + }, [nodes, links, simulation]); + + /** turn on auto-fit any-time # of nodes grows or shrinks */ + useEffect(() => { + setAutoFit(true); + }, [nodes.length, setAutoFit]); + + /** + * fit zoom any time svg resizes (on window resize, but also when hidden -> + * visible) + */ + const svgSize = useElementSize(svgRef); + const svgSizeDeep = JSON.stringify(svgSize); + useEffect(() => { + fitZoom(); + setAutoFit(true); + }, [svgSizeDeep, fitZoom]); + + return ( + <> + {/* svg viz */} + { + svgRef.current = el; + if (el) { + const svg = d3.select(el); + /** attach zoom behavior */ + zoom(d3.select(el)); + svg + /** auto-fit on dbl click */ + .on("dblclick.zoom", () => { + fitZoom(); + setAutoFit(true); + }); + } + }} + className={clsx("expanded", classes.svg)} + onClick={(event) => { + /** clear selected if svg was direct click target */ + if (event.target === event.currentTarget) setSelectedNode(undefined); + }} + > + {/* zoom camera */} + + {/* links */} + + {links.map((link, index) => ( + { + if (el) linkRefs.current.set(index, el); + else linkRefs.current.delete(index); + }} + key={index} + stroke={ + link.source === selectedNode?.id || + link.target === selectedNode?.id + ? selectedColor + : linkColors[link.type ?? ""] + } + strokeWidth={getLinkSize(link.size) / 4} + /> + ))} + + + {/* node circles */} + + {nodes.map((node, index) => ( + { + if (el) { + circleRefs.current.set(index, el); + /** attach drag behavior */ + drag(d3.select(el).data([index])); + } else circleRefs.current.delete(index); + }} + className={classes.node} + r={getNodeSize(node.size)} + fill={nodeColors[node.type ?? ""]} + stroke={ + selectedNode?.id === node.id + ? selectedColor + : nodeColors[node.type ?? ""] + } + strokeWidth={minSize / 5} + tabIndex={0} + onClick={() => setSelectedNode(node)} + onKeyDown={(event) => { + if (event.key === "Enter") setSelectedNode(node); + }} + aria-label={node.label || node.id} + /> + ))} + + + {/* node labels */} + + {nodes.map((node, index) => ( + { + if (el) labelRefs.current.set(index, el); + else labelRefs.current.delete(index); + }} + stroke={nodeColors[node.type ?? ""]} + > + {node.label ?? node.id} + + ))} + + + + + {/* controls */} + + { + if (value) fitZoom(); + setAutoFit(value); + }} + tooltip="Or double-click network background" + /> +