1'use client';2import { forceCenter, forceCollide, forceLink, forceManyBody, forceSimulation, type SimulationLinkDatum, type SimulationNodeDatum } from 'd3-force';3import { useRouter } from 'next/navigation';4import { useEffect, useMemo, useState } from 'react';5import { cn } from '@/lib/cn';6import { predicateLabel, routes, TYPE_COLOR_KEY, typeLabel } from '@/lib/site';7import type { GraphEdge, GraphNode } from '@/lib/types';89type SimNode = SimulationNodeDatum & GraphNode & { degree: number; isRoot: boolean };10type SimLink = SimulationLinkDatum<SimNode> & { predicate: string };1112const W = 960;13const H = 640;14const LABEL_LIMIT = 40;15const EDGE_LABEL_LIMIT = 40;1617function colorOf(type: string): string {18 return `var(--type-${TYPE_COLOR_KEY[type] ?? 'tool'})`;19}20function short(s: string, n = 26): string {21 return s.length > n ? `${s.slice(0, n - 1)}…` : s;22}2324/**25 * Force-directed neighbourhood graph (d3-force run for a fixed number of ticks, deterministic seed, no animation).26 * Server render: the SVG frame + legend only; the layout is computed on the client after mount so markup stays stable.27 * Click / Enter → the entity page; hover / focus highlights the neighbourhood and reveals edge labels.28 */29export function GraphExplorer({ nodes, edges, rootId }: { nodes: GraphNode[]; edges: GraphEdge[]; rootId: string }) {30 const router = useRouter();31 const [mounted, setMounted] = useState(false);32 const [hover, setHover] = useState<string | null>(null);33 useEffect(() => setMounted(true), []);3435 const layout = useMemo(() => {36 if (!mounted) return null;37 const ids = new Set(nodes.map((n) => n.id));38 const degree = new Map<string, number>();39 const links: SimLink[] = [];40 for (const e of edges) {41 if (!ids.has(e.source) || !ids.has(e.target)) continue;42 degree.set(e.source, (degree.get(e.source) ?? 0) + 1);43 degree.set(e.target, (degree.get(e.target) ?? 0) + 1);44 links.push({ source: e.source, target: e.target, predicate: e.predicate });45 }46 // Deterministic seed: nodes on a ring ordered by type, root at the centre.47 const sim: SimNode[] = nodes.map((n, i) => {48 const ang = (i / Math.max(1, nodes.length)) * Math.PI * 2;49 const isRoot = n.id === rootId;50 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 };51 });52 const byId = new Map(sim.map((n) => [n.id, n]));53 const radius = (n: SimNode) => (n.isRoot ? 14 : 5 + Math.min(6, Math.sqrt(n.degree)));54 const s = forceSimulation<SimNode>(sim)55 .force('link', forceLink<SimNode, SimLink>(links).id((d) => d.id).distance((l) => {56 const a = l.source as SimNode;57 const b = l.target as SimNode;58 const hub = Math.max(a.degree, b.degree);59 return 70 + Math.min(90, hub * 3) + (a.isRoot || b.isRoot ? 60 : 0);60 }).strength(0.6))61 .force('charge', forceManyBody<SimNode>().strength((d) => (d.isRoot ? -900 : -220)))62 .force('center', forceCenter(W / 2, H / 2))63 .force('collide', forceCollide<SimNode>().radius((d) => radius(d) + 14).iterations(2))64 .stop();65 for (let i = 0; i < 300; i++) s.tick();66 // Clamp into the viewBox.67 for (const n of sim) {68 n.x = Math.max(24, Math.min(W - 24, n.x ?? W / 2));69 n.y = Math.max(24, Math.min(H - 24, n.y ?? H / 2));70 }71 const neighbours = new Map<string, Set<string>>();72 for (const l of links) {73 const a = (l.source as SimNode).id;74 const b = (l.target as SimNode).id;75 (neighbours.get(a) ?? neighbours.set(a, new Set()).get(a)!).add(b);76 (neighbours.get(b) ?? neighbours.set(b, new Set()).get(b)!).add(a);77 }78 return { sim, links, byId, radius, neighbours };79 }, [mounted, nodes, edges, rootId]);8081 const showAllLabels = nodes.length <= LABEL_LIMIT;82 const showAllEdgeLabels = edges.length <= EDGE_LABEL_LIMIT;8384 const go = (n: GraphNode) => router.push(routes.entity(n));85 const active = (id: string) => !hover || hover === id || (layout?.neighbours.get(hover)?.has(id) ?? false);8687 return (88 <div className="relative">89 <svg viewBox={`0 0 ${W} ${H}`} className="block h-auto w-full border border-rule bg-surface" role="img" aria-label="Relationship graph" onMouseLeave={() => setHover(null)}>90 {!layout && (91 <text x={W / 2} y={H / 2} textAnchor="middle" fontSize={14} fill="var(--ink-3)">92 Laying out {nodes.length} nodes…93 </text>94 )}95 {layout && (96 <>97 <g>98 {layout.links.map((l, i) => {99 const a = l.source as SimNode;100 const b = l.target as SimNode;101 const on = !hover || hover === a.id || hover === b.id;102 const label = showAllEdgeLabels || (hover && (hover === a.id || hover === b.id));103 const mx = ((a.x ?? 0) + (b.x ?? 0)) / 2;104 const my = ((a.y ?? 0) + (b.y ?? 0)) / 2;105 return (106 <g key={i} opacity={on ? 1 : 0.12}>107 <line x1={a.x} y1={a.y} x2={b.x} y2={b.y} stroke={on && hover ? 'var(--accent)' : 'var(--rule-strong)'} strokeWidth={on && hover ? 1.4 : 1} />108 {label && (109 <text x={mx} y={my - 3} textAnchor="middle" fontSize={9} fill="var(--ink-3)" className="pointer-events-none select-none">110 {predicateLabel(l.predicate, 'out')}111 </text>112 )}113 </g>114 );115 })}116 </g>117 <g>118 {layout.sim.map((n) => {119 const r = layout.radius(n);120 const on = active(n.id);121 const label = n.isRoot || showAllLabels || (hover && (hover === n.id || layout.neighbours.get(hover)?.has(n.id)));122 const color = colorOf(n.entity_type);123 return (124 <g125 key={n.id}126 transform={`translate(${n.x},${n.y})`}127 opacity={on ? 1 : 0.2}128 tabIndex={0}129 role="link"130 aria-label={`${n.name} (${typeLabel(n.entity_type)})`}131 className="cursor-pointer outline-none focus-visible:[&>circle]:stroke-accent"132 onMouseEnter={() => setHover(n.id)}133 onFocus={() => setHover(n.id)}134 onBlur={() => setHover(null)}135 onClick={() => go(n)}136 onKeyDown={(e) => {137 if (e.key === 'Enter' || e.key === ' ') {138 e.preventDefault();139 go(n);140 }141 }}142 >143 <circle r={Math.max(16, r + 10)} fill="transparent" data-hit="" />144 {n.isRoot && <circle r={r + 6} fill="none" stroke={color} strokeWidth={1} opacity={0.5} />}145 <circle r={r} fill={color} stroke="var(--canvas)" strokeWidth={1.5} />146 {label && (147 <text x={0} y={r + 11} textAnchor="middle" fontSize={n.isRoot ? 12 : 10} fontWeight={n.isRoot ? 600 : 400} fill={n.isRoot ? 'var(--ink)' : 'var(--ink-2)'} className="pointer-events-none select-none" style={{ paintOrder: 'stroke', stroke: 'var(--surface)', strokeWidth: 3 }}>148 {short(n.name, n.isRoot ? 40 : 26)}149 </text>150 )}151 </g>152 );153 })}154 </g>155 </>156 )}157 </svg>158 {layout && hover && layout.byId.get(hover) && (159 <p className="pointer-events-none absolute left-2 top-2 max-w-[70%] truncate bg-canvas/90 px-2 py-1 text-xs text-ink-2 backdrop-blur">160 <span className="font-medium text-ink">{layout.byId.get(hover)!.name}</span> · {typeLabel(layout.byId.get(hover)!.entity_type)}161 {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'}162 </p>163 )}164 {!showAllLabels && <p className={cn('mt-1 text-xs text-ink-3')}>Labels are shown on hover or focus beyond {LABEL_LIMIT} nodes; the list below names every node.</p>}165 </div>166 );167}168