'use client'; import { forceCenter, forceCollide, forceLink, forceManyBody, forceSimulation, type SimulationLinkDatum, type SimulationNodeDatum } from 'd3-force'; import { useRouter } from 'next/navigation'; import { useEffect, useMemo, useState } from 'react'; import { cn } from '@/lib/cn'; import { predicateLabel, routes, TYPE_COLOR_KEY, typeLabel } from '@/lib/site'; import type { GraphEdge, GraphNode } from '@/lib/types'; type SimNode = SimulationNodeDatum & GraphNode & { degree: number; isRoot: boolean }; type SimLink = SimulationLinkDatum & { predicate: string }; const W = 960; const H = 640; const LABEL_LIMIT = 40; const EDGE_LABEL_LIMIT = 40; function colorOf(type: string): string { return `var(--type-${TYPE_COLOR_KEY[type] ?? 'tool'})`; } function short(s: string, n = 26): string { return s.length > n ? `${s.slice(0, n - 1)}…` : s; } /** * Force-directed neighbourhood graph (d3-force run for a fixed number of ticks, deterministic seed, no animation). * Server render: the SVG frame + legend only; the layout is computed on the client after mount so markup stays stable. * Click / Enter → the entity page; hover / focus highlights the neighbourhood and reveals edge labels. */ export function GraphExplorer({ nodes, edges, rootId }: { nodes: GraphNode[]; edges: GraphEdge[]; rootId: string }) { const router = useRouter(); const [mounted, setMounted] = useState(false); const [hover, setHover] = useState(null); useEffect(() => setMounted(true), []); const layout = useMemo(() => { if (!mounted) return null; const ids = new Set(nodes.map((n) => n.id)); const degree = new Map(); const links: SimLink[] = []; for (const e of edges) { if (!ids.has(e.source) || !ids.has(e.target)) continue; degree.set(e.source, (degree.get(e.source) ?? 0) + 1); degree.set(e.target, (degree.get(e.target) ?? 0) + 1); links.push({ source: e.source, target: e.target, predicate: e.predicate }); } // Deterministic seed: nodes on a ring ordered by type, root at the centre. const sim: SimNode[] = nodes.map((n, i) => { const ang = (i / Math.max(1, nodes.length)) * Math.PI * 2; const isRoot = n.id === rootId; return { ...n, degree: degree.get(n.id) ?? 0, isRoot, x: isRoot ? W / 2 : W / 2 + Math.cos(ang) * 220, y: isRoot ? H / 2 : H / 2 + Math.sin(ang) * 220, fx: isRoot ? W / 2 : undefined, fy: isRoot ? H / 2 : undefined }; }); const byId = new Map(sim.map((n) => [n.id, n])); const radius = (n: SimNode) => (n.isRoot ? 14 : 5 + Math.min(6, Math.sqrt(n.degree))); const s = forceSimulation(sim) .force('link', forceLink(links).id((d) => d.id).distance((l) => { const a = l.source as SimNode; const b = l.target as SimNode; const hub = Math.max(a.degree, b.degree); return 70 + Math.min(90, hub * 3) + (a.isRoot || b.isRoot ? 60 : 0); }).strength(0.6)) .force('charge', forceManyBody().strength((d) => (d.isRoot ? -900 : -220))) .force('center', forceCenter(W / 2, H / 2)) .force('collide', forceCollide().radius((d) => radius(d) + 14).iterations(2)) .stop(); for (let i = 0; i < 300; i++) s.tick(); // Clamp into the viewBox. for (const n of sim) { n.x = Math.max(24, Math.min(W - 24, n.x ?? W / 2)); n.y = Math.max(24, Math.min(H - 24, n.y ?? H / 2)); } const neighbours = new Map>(); for (const l of links) { const a = (l.source as SimNode).id; const b = (l.target as SimNode).id; (neighbours.get(a) ?? neighbours.set(a, new Set()).get(a)!).add(b); (neighbours.get(b) ?? neighbours.set(b, new Set()).get(b)!).add(a); } return { sim, links, byId, radius, neighbours }; }, [mounted, nodes, edges, rootId]); const showAllLabels = nodes.length <= LABEL_LIMIT; const showAllEdgeLabels = edges.length <= EDGE_LABEL_LIMIT; const go = (n: GraphNode) => router.push(routes.entity(n)); const active = (id: string) => !hover || hover === id || (layout?.neighbours.get(hover)?.has(id) ?? false); return (
setHover(null)}> {!layout && ( Laying out {nodes.length} nodes… )} {layout && ( <> {layout.links.map((l, i) => { const a = l.source as SimNode; const b = l.target as SimNode; const on = !hover || hover === a.id || hover === b.id; const label = showAllEdgeLabels || (hover && (hover === a.id || hover === b.id)); const mx = ((a.x ?? 0) + (b.x ?? 0)) / 2; const my = ((a.y ?? 0) + (b.y ?? 0)) / 2; return ( {label && ( {predicateLabel(l.predicate, 'out')} )} ); })} {layout.sim.map((n) => { const r = layout.radius(n); const on = active(n.id); const label = n.isRoot || showAllLabels || (hover && (hover === n.id || layout.neighbours.get(hover)?.has(n.id))); const color = colorOf(n.entity_type); return ( setHover(n.id)} onFocus={() => setHover(n.id)} onBlur={() => setHover(null)} onClick={() => go(n)} onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); go(n); } }} > {n.isRoot && } {label && ( {short(n.name, n.isRoot ? 40 : 26)} )} ); })} )} {layout && hover && layout.byId.get(hover) && (

{layout.byId.get(hover)!.name} · {typeLabel(layout.byId.get(hover)!.entity_type)} {layout.byId.get(hover)!.organization_name ? ` · ${layout.byId.get(hover)!.organization_name}` : ''} · {layout.byId.get(hover)!.degree} link{layout.byId.get(hover)!.degree === 1 ? '' : 's'}

)} {!showAllLabels &&

Labels are shown on hover or focus beyond {LABEL_LIMIT} nodes; the list below names every node.

}
); }