import { useEffect, useRef, useState, type ReactNode } from 'react'; import { forceCollide, forceLink, forceManyBody, forceSimulation, forceX, forceY, type Simulation, type SimulationLinkDatum, type SimulationNodeDatum, } from 'd3-force'; import { Tooltip, useWidth, type TipState } from './charts'; export interface FGNode { id: string; r: number; fill: string; /** nodes sharing a group are enclosed by a shaded circle */ group?: string | number; ring?: boolean; } export interface FGLink { source: string; target: string; distance: number; color: string; width?: number; opacity?: number; /** drawn only while one of its endpoints is hovered */ hidden?: boolean; } export interface FGGroup { key: string | number; color: string; label?: string; } type SimNode = FGNode & SimulationNodeDatum & { phase: number }; type SimLink = SimulationLinkDatum & FGLink; const reducedMotion = () => typeof window !== 'undefined' && window.matchMedia?.('(prefers-reduced-motion: reduce)').matches; /** * Live force-directed graph: nodes keep drifting gently and can be dragged. * Positions are written to the DOM on every tick (no React re-render per frame). */ export function ForceGraph({ nodes, links, groups = [], height = 460, charge = -40, linkStrength, onNodeClick, nodeTip, ariaLabel, }: { nodes: FGNode[]; links: FGLink[]; groups?: FGGroup[]; height?: number; charge?: number; linkStrength?: number; onNodeClick?: (id: string) => void; nodeTip?: (id: string) => ReactNode; ariaLabel: string; }) { const [wrapRef, width] = useWidth(); const svgRef = useRef(null); const nodeEls = useRef(new Map()); const linkEls = useRef>([]); const hullEls = useRef(new Map()); const simRef = useRef | null>(null); const simNodes = useRef([]); const positions = useRef(new Map()); const [hover, setHover] = useState(null); const [tip, setTip] = useState(null); const drag = useRef<{ id: string; moved: boolean; x0: number; y0: number } | null>(null); const W = Math.max(280, width); const H = height; useEffect(() => { if (!width) return; const keep = positions.current; const sn: SimNode[] = nodes.map((n, i) => { const p = keep.get(n.id); const a = (i / Math.max(1, nodes.length)) * Math.PI * 2; return { ...n, phase: Math.random() * Math.PI * 2, x: p?.x ?? W / 2 + Math.cos(a) * 80, y: p?.y ?? H / 2 + Math.sin(a) * 80 }; }); simNodes.current = sn; const sl: SimLink[] = links.map((l) => ({ ...l })); const still = reducedMotion(); let t = 0; // slow, per-node sinusoidal nudge keeps the layout alive const wander = () => { if (still) return; t += 1; for (const n of sn) { if (n.fx != null) continue; n.vx = (n.vx ?? 0) + Math.cos(n.phase + t * 0.013) * 0.035; n.vy = (n.vy ?? 0) + Math.sin(n.phase * 1.3 + t * 0.011) * 0.035; } }; // keep each group compact and push overlapping group circles apart const hasGroups = groups.length > 0; const hullR = new Map(); const cohesion = () => { if (!hasGroups) return; const acc = new Map(); for (const n of sn) { if (n.group == null) continue; const a = acc.get(n.group) ?? { x: 0, y: 0, n: 0, members: [] }; a.x += n.x!; a.y += n.y!; a.n += 1; a.members.push(n); acc.set(n.group, a); } const cs = [...acc.entries()].map(([k, a]) => ({ k, x: a.x / a.n, y: a.y / a.n, members: a.members })); for (const c of cs) { for (const n of c.members) { if (n.fx != null) continue; n.vx = (n.vx ?? 0) + (c.x - n.x!) * 0.035; n.vy = (n.vy ?? 0) + (c.y - n.y!) * 0.035; } } for (let i = 0; i < cs.length; i++) { for (let j = i + 1; j < cs.length; j++) { const a = cs[i]; const b = cs[j]; const dx = b.x - a.x; const dy = b.y - a.y; const d = Math.hypot(dx, dy) || 1; const overlap = (hullR.get(a.k) ?? 10) + (hullR.get(b.k) ?? 10) + 10 - d; if (overlap <= 0) continue; const push = Math.min(overlap, 30) * 0.02; const ux = (dx / d) * push; const uy = (dy / d) * push; for (const n of a.members) { if (n.fx != null) continue; n.vx = (n.vx ?? 0) - ux; n.vy = (n.vy ?? 0) - uy; } for (const n of b.members) { if (n.fx != null) continue; n.vx = (n.vx ?? 0) + ux; n.vy = (n.vy ?? 0) + uy; } } } }; const strength = linkStrength ?? 0.7 / Math.max(1, Math.sqrt(sn.length)); const sim = forceSimulation(sn) .force('link', forceLink(sl).id((d) => d.id).distance((l) => l.distance).strength(strength)) .force('charge', forceManyBody().strength(charge)) .force('x', forceX(W / 2).strength(0.035)) .force('y', forceY(H / 2).strength(0.055)) .force('collide', forceCollide((d) => d.r + 2.5)) .force('wander', wander) .force('cohesion', cohesion) .velocityDecay(0.32) .alphaTarget(still ? 0 : 0.02); const draw = () => { for (const n of sn) { n.x = Math.max(n.r + 4, Math.min(W - n.r - 4, n.x!)); n.y = Math.max(n.r + 4, Math.min(H - n.r - 4, n.y!)); keep.set(n.id, { x: n.x, y: n.y }); const el = nodeEls.current.get(n.id); if (el) { el.setAttribute('cx', n.x.toFixed(1)); el.setAttribute('cy', n.y.toFixed(1)); } } sl.forEach((l, i) => { const el = linkEls.current[i]; if (!el) return; const s = l.source as SimNode; const d = l.target as SimNode; el.setAttribute('x1', s.x!.toFixed(1)); el.setAttribute('y1', s.y!.toFixed(1)); el.setAttribute('x2', d.x!.toFixed(1)); el.setAttribute('y2', d.y!.toFixed(1)); }); if (hullEls.current.size) { const byGroup = new Map(); for (const n of sn) { if (n.group == null) continue; const arr = byGroup.get(n.group) ?? []; arr.push(n); byGroup.set(n.group, arr); } for (const [key, members] of byGroup) { const g = hullEls.current.get(key); if (!g) continue; const cx = members.reduce((s, n) => s + n.x!, 0) / members.length; const cy = members.reduce((s, n) => s + n.y!, 0) / members.length; const r = Math.max(...members.map((n) => Math.hypot(n.x! - cx, n.y! - cy) + n.r)) + 9; hullR.set(key, r); const c = g.firstElementChild as SVGCircleElement; c.setAttribute('cx', cx.toFixed(1)); c.setAttribute('cy', cy.toFixed(1)); c.setAttribute('r', r.toFixed(1)); const label = g.lastElementChild as SVGTextElement | null; if (label && label.tagName === 'text') { label.setAttribute('x', cx.toFixed(1)); label.setAttribute('y', (cy - r - 4).toFixed(1)); } } } }; sim.on('tick', draw); if (still) sim.tick(200); draw(); simRef.current = sim; return () => { sim.stop(); simRef.current = null; }; }, [nodes, links, groups, W, H, width, charge, linkStrength]); const toLocal = (e: React.PointerEvent) => { const r = svgRef.current!.getBoundingClientRect(); return { x: e.clientX - r.left, y: e.clientY - r.top }; }; const onPointerDown = (id: string) => (e: React.PointerEvent) => { const n = simNodes.current.find((m) => m.id === id); if (!n) return; (e.target as Element).setPointerCapture(e.pointerId); const p = toLocal(e); drag.current = { id, moved: false, x0: p.x, y0: p.y }; n.fx = n.x; n.fy = n.y; simRef.current?.alphaTarget(0.25).restart(); setTip(null); }; const onPointerMove = (e: React.PointerEvent) => { const d = drag.current; if (!d) return; const p = toLocal(e); if (Math.hypot(p.x - d.x0, p.y - d.y0) > 3) d.moved = true; const n = simNodes.current.find((m) => m.id === d.id); if (n) { n.fx = Math.max(n.r, Math.min(W - n.r, p.x)); n.fy = Math.max(n.r, Math.min(H - n.r, p.y)); } }; const onPointerUp = () => { const d = drag.current; if (!d) return; const n = simNodes.current.find((m) => m.id === d.id); if (n) { n.fx = null; n.fy = null; } simRef.current?.alphaTarget(reducedMotion() ? 0 : 0.02); drag.current = null; if (!d.moved) onNodeClick?.(d.id); }; const showTip = (id: string, e: React.PointerEvent | React.MouseEvent) => { if (drag.current || !nodeTip) return; const r = svgRef.current!.getBoundingClientRect(); setTip({ x: e.clientX - r.left, y: e.clientY - r.top, content: nodeTip(id) }); }; return (
{width > 0 && ( {groups.length > 0 && ( {groups.map((g) => ( { if (el) hullEls.current.set(g.key, el); else hullEls.current.delete(g.key); }} > {g.label && ( {g.label} )} ))} )} {links.map((l, i) => { const hot = hover != null && (l.source === hover || l.target === hover); const visible = !l.hidden || hot; return ( { linkEls.current[i] = el; }} stroke={l.color} strokeWidth={hot ? (l.width ?? 1) + 0.8 : l.width ?? 1} strokeOpacity={!visible ? 0 : hover == null ? l.opacity ?? 0.4 : hot ? 0.95 : 0.05} /> ); })} {nodes.map((n) => ( { if (el) nodeEls.current.set(n.id, el); else nodeEls.current.delete(n.id); }} r={hover === n.id ? n.r + 2 : n.r} fill={n.fill} className={`fg__node ${n.ring ? 'is-ring' : ''}`} onPointerDown={onPointerDown(n.id)} onPointerEnter={(e) => { setHover(n.id); showTip(n.id, e); }} onPointerMove={(e) => showTip(n.id, e)} onPointerLeave={() => { setHover(null); setTip(null); }} /> ))} )}
); }