import React, { useCallback, useEffect, useImperativeHandle, useLayoutEffect, useMemo, useRef, useState, } from 'react'; import { cn } from '../../shared/utils'; import { useTokenColors, withAlpha } from '../../shared/token-color'; import type { ColorToken } from '../../shared/color-token'; import { DEFAULT_CAMERA, fitDistance, hitTest, nodeRadiusScale, orbit, zoom, type GraphCamera, } from './camera'; import { renderGraph, type RenderPalette } from './graph-renderer'; import { seedPositions, tick, runToSettle, type ForceSettings } from './force-simulation'; import type { GraphEdge, GraphFeatureRef, GraphLabelMode, GraphLayout, GraphNode, GraphNodeState, } from './graph.types'; /** Default palette for node and relation types, in declaration order. */ const TYPE_TOKENS: ColorToken[] = [ '--chart-1', '--chart-2', '--chart-3', '--chart-4', '--chart-5', '--primary', '--destructive', ]; const CHROME_TOKENS: ColorToken[] = [ '--background', '--foreground', '--muted-foreground', '--border', '--ring', ]; /** Imperative handle, exposed through `controlRef`. */ export interface NetworkGraphControl { /** Frames every node. */ fitView(): void; /** Frames one node and selects it. */ focusNode(id: string): void; resetCamera(): void; /** Re-runs the layout from the current positions. */ restartLayout(): void; /** Current node positions, for persisting a hand-arranged diagram. */ getPositions(): Record; } export interface NetworkGraphProps extends Omit, 'onSelect'> { nodes: GraphNode[]; edges: GraphEdge[]; /** @default '3d' */ layout?: GraphLayout; /** @default '480px' */ height?: string; /** Controlled selection. */ selectedId?: string | null; onSelect?: (id: string | null, feature: GraphFeatureRef | null) => void; onHover?: (id: string | null, feature: GraphFeatureRef | null) => void; /** * Dims everything outside the selected node's immediate neighbourhood. * The only practical way to read a dense ontology. * @default 'neighbors' */ focusMode?: 'neighbors' | 'none'; /** @default 'auto' — labels appear once a node is large enough on screen. */ labels?: GraphLabelMode; /** Draws arrowheads on every edge that does not say otherwise. @default false */ directed?: boolean; /** Base radius in layout units. @default 6 */ nodeRadius?: number; /** * Ceiling on the largest node's on-screen radius, in pixels. * * Radii are in layout units, so a sparse graph framed to fill the canvas would * otherwise draw discs big enough to swallow the edges between them. One * factor is applied to every node, so relative weight survives the cap. * @default 11 */ maxNodeRadiusPx?: number; /** * Frames the whole graph once the layout settles, unless the viewer has * already moved the camera. @default true */ autoFit?: boolean; /** Overrides for the physics. */ physics?: ForceSettings; /** Slowly rotates while the pointer is away. Suppressed under reduced motion. */ autoRotate?: boolean; /** Colors assigned to node/relation types, in order of first appearance. */ typeTokens?: ColorToken[]; /** @default "Relationship graph" */ ariaLabel?: string; /** Fires once the layout comes to rest. */ onLayoutSettled?: () => void; controlRef?: React.Ref; /** Replaces the built-in empty state. */ emptyState?: React.ReactNode; /** @default "No nodes to display" */ emptyLabel?: string; } /** * Three-dimensional graph for ontologies and any information with relationships. * * @description * A force-directed layout rendered to a canvas: Barnes-Hut repulsion, springs * along the edges, and a perspective projection with depth cueing. Nothing is * pulled in to do it — a graph draws discs and lines, so the WebGL a 3D library * provides would replace the easy half and leave the layout, which has to be * written either way. * * The layout is deterministic: the same graph always arranges the same way, so * "the node on the left" keeps meaning something between sessions. * * @ai-rules * 1. Give every node a stable `id` — selection, focus and layout seeding all key off it. * 2. Use `type` on nodes and `relation` on edges rather than per-item colors; the palette and legend follow from them. * 3. Pair it with `` — a canvas is not reachable by screen reader on its own. * 4. Keep `focusMode="neighbors"` for anything denser than a few dozen nodes. */ export const NetworkGraph = React.forwardRef( ( { nodes, edges, layout = '3d', height = '480px', selectedId, onSelect, onHover, focusMode = 'neighbors', labels = 'auto', directed = false, nodeRadius = 6, maxNodeRadiusPx = 11, autoFit = true, physics, autoRotate = false, typeTokens = TYPE_TOKENS, ariaLabel = 'Relationship graph', onLayoutSettled, controlRef, emptyState, emptyLabel = 'No nodes to display', className, ...divProps }, ref ) => { const containerRef = useRef(null); const canvasRef = useRef(null); const [size, setSize] = useState({ width: 0, height: 0 }); const [camera, setCamera] = useState(DEFAULT_CAMERA); const cameraRef = useRef(camera); cameraRef.current = camera; const [hoveredIndex, setHoveredIndex] = useState(null); const [internalSelectedId, setInternalSelectedId] = useState(null); const prefersReducedMotion = useMemo(() => { if (typeof window === 'undefined' || typeof window.matchMedia !== 'function') return false; return window.matchMedia('(prefers-reduced-motion: reduce)').matches; }, []); /* --- Identity and colors ------------------------------------------------ */ const indexById = useMemo(() => new Map(nodes.map((node, index) => [node.id, index])), [nodes]); /** Types in order of first appearance, so colors are stable across renders. */ const typeOrder = useMemo(() => { const seen: string[] = []; nodes.forEach(node => { if (node.type && !seen.includes(node.type)) seen.push(node.type); }); return seen; }, [nodes]); const relationOrder = useMemo(() => { const seen: string[] = []; edges.forEach(edge => { if (edge.relation && !seen.includes(edge.relation)) seen.push(edge.relation); }); return seen; }, [edges]); const tokens = useMemo(() => { const set = new Set([...CHROME_TOKENS, ...typeTokens]); nodes.forEach(node => node.colorToken && set.add(node.colorToken)); edges.forEach(edge => edge.colorToken && set.add(edge.colorToken)); return Array.from(set); }, [nodes, edges, typeTokens]); const colors = useTokenColors(tokens); const tokenForType = useCallback( (type: string | undefined, order: string[]): ColorToken => { if (!type) return typeTokens[0]; const position = order.indexOf(type); return typeTokens[(position < 0 ? 0 : position) % typeTokens.length]; }, [typeTokens] ); /* --- Simulation ---------------------------------------------------------- */ const statesRef = useRef([]); const alphaRef = useRef(1); const frameRef = useRef(null); const settledNotifiedRef = useRef(false); // Larger graphs cool faster: the per-tick cost grows, so spending the same // number of ticks would mean a visibly slow settle on a big ontology. const resolvedPhysics = useMemo( () => ({ alphaDecay: nodes.length > 1200 ? 0.06 : nodes.length > 400 ? 0.04 : 0.022, ...physics, }), [nodes.length, physics] ); useEffect(() => { statesRef.current = seedPositions(nodes, layout); alphaRef.current = 1; settledNotifiedRef.current = false; /* * Reduced motion means the diagram must appear already arranged rather * than visibly settling, so the layout is run to rest synchronously. */ if (prefersReducedMotion) { runToSettle(statesRef.current, edges, indexById, layout, resolvedPhysics); alphaRef.current = 0; } // eslint-disable-next-line react-hooks/exhaustive-deps }, [nodes, edges, layout, indexById, prefersReducedMotion]); /* --- Geometry per node --------------------------------------------------- */ const radii = useMemo( () => nodes.map(node => node.radiusPx ?? nodeRadius * Math.sqrt(node.weight ?? 1)), [nodes, nodeRadius] ); const nodeLabels = useMemo(() => nodes.map(node => node.label ?? node.id), [nodes]); const resolvedSelectedId = selectedId === undefined ? internalSelectedId : selectedId; const selectedIndex = resolvedSelectedId ? (indexById.get(resolvedSelectedId) ?? null) : null; /** Immediate neighbours of the selection, for the focus dimming. */ const neighborIndices = useMemo(() => { if (focusMode === 'none' || selectedIndex === null) return null; const set = new Set([selectedIndex]); const selected = nodes[selectedIndex]; edges.forEach(edge => { if (edge.source === selected.id) { const target = indexById.get(edge.target); if (target !== undefined) set.add(target); } else if (edge.target === selected.id) { const source = indexById.get(edge.source); if (source !== undefined) set.add(source); } }); return set; }, [focusMode, selectedIndex, nodes, edges, indexById]); /* --- Palette -------------------------------------------------------------- */ const palette = useMemo( () => ({ background: colors['--background'] || '#ffffff', foreground: colors['--foreground'] || '#111111', mutedForeground: colors['--muted-foreground'] || '#666666', border: colors['--border'] || '#dddddd', ring: colors['--ring'] || '#888888', nodeColors: nodes.map( node => colors[node.colorToken ?? tokenForType(node.type, typeOrder)] || '#888888' ), edgeColors: edges.map( edge => withAlpha( colors[edge.colorToken ?? tokenForType(edge.relation, relationOrder)] || '#888888', edge.relation || edge.colorToken ? 0.9 : 0.5 ) || '#888888' ), }), [colors, nodes, edges, tokenForType, typeOrder, relationOrder] ); /* --- Measurement ---------------------------------------------------------- */ useLayoutEffect(() => { const element = containerRef.current; if (!element) return; const measure = () => setSize({ width: element.clientWidth, height: element.clientHeight }); measure(); if (typeof ResizeObserver === 'undefined') return; const observer = new ResizeObserver(measure); observer.observe(element); return () => observer.disconnect(); }, []); /* --- Draw and animate ------------------------------------------------------ */ const paletteRef = useRef(palette); paletteRef.current = palette; const draw = useCallback(() => { const canvas = canvasRef.current; const ctx = canvas?.getContext('2d'); if (!canvas || !ctx || size.width === 0) return; renderGraph(ctx, { states: statesRef.current, edges, indexById, radii, labels: nodeLabels, camera: cameraRef.current, width: size.width, height: size.height, palette: paletteRef.current, selectedIndex, hoveredIndex, neighborIndices, showLabels: labels !== 'none', labelMinRadiusPx: labels === 'always' ? 0 : 5, maxNodeRadiusPx, directed, devicePixelRatio: typeof window === 'undefined' ? 1 : window.devicePixelRatio || 1, }); }, [ edges, indexById, radii, nodeLabels, size, selectedIndex, hoveredIndex, neighborIndices, labels, maxNodeRadiusPx, directed, ]); useEffect(() => { if (size.width === 0) return; let cancelled = false; const frame = () => { if (cancelled) return; // Time budget: keep simulating only while there is frame left. A big // graph then settles over more frames instead of freezing the page. const deadline = performance.now() + 9; while (alphaRef.current > 0 && performance.now() < deadline) { const result = tick( statesRef.current, edges, indexById, layout, resolvedPhysics, alphaRef.current ); alphaRef.current = result.settled ? 0 : result.alpha; if (result.settled && !settledNotifiedRef.current) { settledNotifiedRef.current = true; /* * The layout's extent depends on node and edge count, so a fixed * camera distance leaves a small graph lost in the middle and lets a * large one spill off the canvas. */ if (autoFit && !userMovedCameraRef.current) { setCamera(current => ({ ...current, panX: 0, panY: 0, distance: fitDistance(statesRef.current, current, size.width, size.height), })); } onLayoutSettled?.(); } } if (autoRotate && !prefersReducedMotion) { setCamera(current => ({ ...current, yaw: current.yaw + 0.0015 })); } draw(); // Keep the loop alive only while there is something to animate. if (alphaRef.current > 0 || (autoRotate && !prefersReducedMotion)) { frameRef.current = requestAnimationFrame(frame); } else { frameRef.current = null; } }; frameRef.current = requestAnimationFrame(frame); return () => { cancelled = true; if (frameRef.current !== null) cancelAnimationFrame(frameRef.current); frameRef.current = null; }; }, [ size.width, size.height, edges, indexById, layout, resolvedPhysics, autoRotate, prefersReducedMotion, draw, onLayoutSettled, autoFit, ]); // Selection, hover and theme changes repaint a settled graph, which the // animation loop is no longer running for. useEffect(() => { if (alphaRef.current === 0) draw(); }, [draw]); /* --- Interaction ------------------------------------------------------------ */ const dragRef = useRef<{ x: number; y: number; moved: boolean } | null>(null); // Once the viewer moves the camera, the component stops reframing for them. const userMovedCameraRef = useRef(false); const emitSelect = useCallback( (index: number | null) => { const node = index === null ? null : nodes[index]; setInternalSelectedId(node?.id ?? null); onSelect?.(node?.id ?? null, node ? { kind: 'node', id: node.id, data: node.data } : null); }, [nodes, onSelect] ); const pointerToIndex = useCallback( (event: React.PointerEvent | React.MouseEvent) => { const canvas = canvasRef.current; if (!canvas) return -1; const rect = canvas.getBoundingClientRect(); // Same cap the renderer applied, or the target would not match the disc. const scale = nodeRadiusScale(radii, cameraRef.current, maxNodeRadiusPx); return hitTest( statesRef.current, radii.map(radius => radius * scale), cameraRef.current, size.width, size.height, event.clientX - rect.left, event.clientY - rect.top ); }, [radii, size, maxNodeRadiusPx] ); const handlePointerDown = (event: React.PointerEvent) => { event.currentTarget.setPointerCapture(event.pointerId); dragRef.current = { x: event.clientX, y: event.clientY, moved: false }; }; const handlePointerMove = (event: React.PointerEvent) => { const drag = dragRef.current; if (drag) { const dx = event.clientX - drag.x; const dy = event.clientY - drag.y; // A few pixels of slop, so a click with a shaky hand is still a click. if (Math.abs(dx) + Math.abs(dy) > 3) drag.moved = true; if (drag.moved) { userMovedCameraRef.current = true; setCamera(current => layout === '2d' ? { ...current, panX: current.panX + dx, panY: current.panY + dy } : orbit(current, dx, dy) ); drag.x = event.clientX; drag.y = event.clientY; draw(); } return; } const index = pointerToIndex(event); if (index === hoveredIndex) return; setHoveredIndex(index === -1 ? null : index); const node = index === -1 ? null : nodes[index]; onHover?.(node?.id ?? null, node ? { kind: 'node', id: node.id, data: node.data } : null); }; const handlePointerUp = (event: React.PointerEvent) => { const drag = dragRef.current; dragRef.current = null; if (drag && !drag.moved) { const index = pointerToIndex(event); emitSelect(index === -1 ? null : index); } }; const handleWheel = useCallback((event: WheelEvent) => { event.preventDefault(); userMovedCameraRef.current = true; setCamera(current => zoom(current, event.deltaY)); }, []); // Registered natively: React's onWheel is passive, so it cannot preventDefault // and the page would scroll while the user zooms. useEffect(() => { const canvas = canvasRef.current; if (!canvas) return; canvas.addEventListener('wheel', handleWheel, { passive: false }); return () => canvas.removeEventListener('wheel', handleWheel); }, [handleWheel]); useEffect(() => { if (alphaRef.current === 0) draw(); }, [camera, draw]); /* --- Keyboard --------------------------------------------------------------- */ const handleKeyDown = (event: React.KeyboardEvent) => { const step = event.shiftKey ? 0.25 : 0.08; switch (event.key) { case 'ArrowLeft': case 'ArrowRight': { event.preventDefault(); const direction = event.key === 'ArrowLeft' ? -1 : 1; setCamera(current => ({ ...current, yaw: current.yaw + direction * step })); break; } case 'ArrowUp': case 'ArrowDown': { event.preventDefault(); const direction = event.key === 'ArrowUp' ? -1 : 1; setCamera(current => orbit(current, 0, direction * step * 120)); break; } case '+': case '=': event.preventDefault(); setCamera(current => zoom(current, -240)); break; case '-': event.preventDefault(); setCamera(current => zoom(current, 240)); break; case 'Tab': { // Cycles the selection through the nodes, so the graph is operable // without a pointer even before reaching the companion list. if (nodes.length === 0) break; event.preventDefault(); const current = selectedIndex ?? -1; const next = (current + (event.shiftKey ? -1 : 1) + nodes.length) % nodes.length; emitSelect(next); break; } case 'Escape': event.preventDefault(); emitSelect(null); break; default: break; } }; /* --- Imperative handle -------------------------------------------------------- */ useImperativeHandle( controlRef, () => ({ fitView() { userMovedCameraRef.current = false; setCamera(current => ({ ...current, panX: 0, panY: 0, distance: fitDistance(statesRef.current, current, size.width, size.height), })); }, focusNode(id: string) { const index = indexById.get(id); if (index === undefined) return; emitSelect(index); }, resetCamera() { userMovedCameraRef.current = false; setCamera(DEFAULT_CAMERA); }, restartLayout() { alphaRef.current = 1; settledNotifiedRef.current = false; if (frameRef.current === null) setCamera(current => ({ ...current })); }, getPositions() { const positions: Record = {}; statesRef.current.forEach(state => { positions[state.id] = { x: state.x, y: state.y, z: state.z }; }); return positions; }, }), [indexById, emitSelect, size] ); /* --- Render -------------------------------------------------------------------- */ const dpr = typeof window === 'undefined' ? 1 : window.devicePixelRatio || 1; if (nodes.length === 0) { return (
{emptyState ??

{emptyLabel}

}
); } return (
{ (containerRef as React.MutableRefObject).current = node; if (typeof ref === 'function') ref(node); else if (ref) (ref as React.MutableRefObject).current = node; }} className={cn( 'relative overflow-hidden rounded-[var(--radius-card)] border border-border bg-background', className )} style={{ height }} {...divProps} > { setHoveredIndex(null); onHover?.(null, null); }} onKeyDown={handleKeyDown} />
); } ); NetworkGraph.displayName = 'NetworkGraph';