HTML 77.2%
TypeScript 10.5%
Python 9.6%
JavaScript 2.5%
1'use client';2import { forceCenter, forceCollide, forceLink, forceManyBody, forceSimulation, type SimulationLinkDatum, type SimulationNodeDatum } from 'd3-force';3import { Crosshair, Minus, Plus } from 'lucide-react';4import { useCallback, useEffect, useMemo, useRef, useState } from 'react';5import { cn } from '@/lib/cn';6import { predicateLabel, TYPE_COLOR_KEY, typeLabel } from '@/lib/site';7import type { ExploreEdge, ExploreNode } from '@/lib/types';89/*10 Force graph on a plain SVG (no WebGL, no d3-zoom dependency): d3-force computes the layout synchronously after mount,11 positions are kept across merges (progressive expansion), pan / wheel-zoom / pinch / node drag are pointer events on the12 <svg>, labels are budgeted by degree and zoom level, click selects, double-click (or Shift+Enter) expands.13 Server render = the frame only, so the markup is stable between SSR and hydration.14*/1516export type SimNode = SimulationNodeDatum & ExploreNode & { degree: number; isRoot: boolean };17type SimLink = SimulationLinkDatum<SimNode> & { predicate: string; tier?: number | null };18type Transform = { x: number; y: number; k: number };1920const W = 960;21const H = 640;22const K_MIN = 0.25;23const K_MAX = 6;2425export function colorOf(type: string): string {26 return `var(--type-${TYPE_COLOR_KEY[type] ?? 'tool'})`;27}28function short(s: string, n = 24): string {29 return s.length > n ? `${s.slice(0, n - 1)}…` : s;30}31function radiusOf(n: { degree: number; isRoot: boolean; level?: number }): number {32 if (n.isRoot) return 13;33 return 4.5 + Math.min(7, Math.sqrt(n.degree) * 1.6);34}3536export function GraphCanvas({37 nodes,38 edges,39 rootId,40 selectedId,41 onSelect,42 onExpand,43 expanded,44 loadingId,45 hiddenTypes,46 className,47 tall = false,48}: {49 nodes: ExploreNode[];50 edges: ExploreEdge[];51 rootId: string;52 selectedId: string | null;53 onSelect: (id: string | null) => void;54 onExpand?: (id: string) => void;55 expanded: Set<string>;56 loadingId?: string | null;57 hiddenTypes: Set<string>;58 className?: string;59 /** Full-height canvas (mobile full-screen / flagship page). */60 tall?: boolean;61}) {62 const svgRef = useRef<SVGSVGElement>(null);63 const posRef = useRef<Map<string, { x: number; y: number }>>(new Map());64 const [mounted, setMounted] = useState(false);65 const [hover, setHover] = useState<string | null>(null);66 const [t, setT] = useState<Transform>({ x: 0, y: 0, k: 1 });67 const [, bump] = useState(0);68 useEffect(() => setMounted(true), []);6970 const visible = useMemo(() => nodes.filter((n) => !hiddenTypes.has(n.entity_type) || n.id === rootId), [nodes, hiddenTypes, rootId]);71 const signature = useMemo(() => `${visible.map((n) => n.id).join('|')}#${edges.length}`, [visible, edges.length]);7273 const layout = useMemo(() => {74 if (!mounted) return null;75 const ids = new Set(visible.map((n) => n.id));76 const degree = new Map<string, number>();77 const links: SimLink[] = [];78 const seen = new Set<string>();79 for (const e of edges) {80 if (!ids.has(e.source) || !ids.has(e.target) || e.source === e.target) continue;81 const key = `${e.source}>${e.target}:${e.predicate}`;82 if (seen.has(key)) continue;83 seen.add(key);84 degree.set(e.source, (degree.get(e.source) ?? 0) + 1);85 degree.set(e.target, (degree.get(e.target) ?? 0) + 1);86 links.push({ source: e.source, target: e.target, predicate: e.predicate, tier: e.tier ?? null });87 }88 const pos = posRef.current;89 // neighbour lookup for seeding new nodes next to an already-placed neighbour90 const nb = new Map<string, string[]>();91 for (const l of links) {92 const a = l.source as string;93 const b = l.target as string;94 (nb.get(a) ?? nb.set(a, []).get(a)!).push(b);95 (nb.get(b) ?? nb.set(b, []).get(b)!).push(a);96 }97 const sim: SimNode[] = visible.map((n, i) => {98 const isRoot = n.id === rootId;99 const known = pos.get(n.id);100 let x: number;101 let y: number;102 if (known) ({ x, y } = known);103 else if (isRoot) [x, y] = [W / 2, H / 2];104 else {105 const anchor = (nb.get(n.id) ?? []).map((id) => pos.get(id)).find(Boolean);106 const ang = (i / Math.max(1, visible.length)) * Math.PI * 2 + (n.level ?? 1) * 0.7;107 if (anchor) [x, y] = [anchor.x + Math.cos(ang) * 60, anchor.y + Math.sin(ang) * 60];108 else [x, y] = [W / 2 + Math.cos(ang) * (150 + 90 * (n.level ?? 1)), H / 2 + Math.sin(ang) * (110 + 70 * (n.level ?? 1))];109 }110 return { ...n, degree: degree.get(n.id) ?? 0, isRoot, x, y, fx: isRoot && !known ? W / 2 : undefined, fy: isRoot && !known ? H / 2 : undefined };111 });112 const s = forceSimulation<SimNode>(sim)113 .force(114 'link',115 forceLink<SimNode, SimLink>(links)116 .id((d) => d.id)117 .distance((l) => {118 const a = l.source as SimNode;119 const b = l.target as SimNode;120 const hub = Math.max(a.degree, b.degree);121 return 55 + Math.min(80, hub * 2.5) + (a.isRoot || b.isRoot ? 45 : 0);122 })123 .strength(0.55),124 )125 .force('charge', forceManyBody<SimNode>().strength((d) => (d.isRoot ? -700 : -160 - d.degree * 8)).distanceMax(420))126 .force('center', forceCenter(W / 2, H / 2).strength(0.04))127 .force('collide', forceCollide<SimNode>().radius((d) => radiusOf(d) + 10).iterations(2))128 .stop();129 const ticks = sim.length > 120 ? 220 : 300;130 for (let i = 0; i < ticks; i++) s.tick();131 for (const n of sim) {132 n.fx = undefined;133 n.fy = undefined;134 pos.set(n.id, { x: n.x ?? W / 2, y: n.y ?? H / 2 });135 }136 const byId = new Map(sim.map((n) => [n.id, n]));137 const neighbours = new Map<string, Set<string>>();138 for (const l of links) {139 const a = (l.source as SimNode).id;140 const b = (l.target as SimNode).id;141 (neighbours.get(a) ?? neighbours.set(a, new Set()).get(a)!).add(b);142 (neighbours.get(b) ?? neighbours.set(b, new Set()).get(b)!).add(a);143 }144 return { sim, links, byId, neighbours };145 // eslint-disable-next-line react-hooks/exhaustive-deps146 }, [mounted, signature, rootId]);147148 /* ------------------------------------------------------------------------------------------------ coordinates */149 const toSvg = useCallback((clientX: number, clientY: number): { x: number; y: number } => {150 const svg = svgRef.current;151 if (!svg) return { x: 0, y: 0 };152 const ctm = svg.getScreenCTM();153 if (!ctm) return { x: 0, y: 0 };154 const p = svg.createSVGPoint();155 p.x = clientX;156 p.y = clientY;157 const q = p.matrixTransform(ctm.inverse());158 return { x: q.x, y: q.y };159 }, []);160 const zoomAt = useCallback((factor: number, cx: number, cy: number) => {161 setT((cur) => {162 const k = Math.max(K_MIN, Math.min(K_MAX, cur.k * factor));163 const r = k / cur.k;164 return { k, x: cx - (cx - cur.x) * r, y: cy - (cy - cur.y) * r };165 });166 }, []);167 const fit = useCallback(() => {168 if (!layout || !layout.sim.length) return setT({ x: 0, y: 0, k: 1 });169 const xs = layout.sim.map((n) => n.x ?? 0);170 const ys = layout.sim.map((n) => n.y ?? 0);171 const minX = Math.min(...xs) - 40;172 const maxX = Math.max(...xs) + 40;173 const minY = Math.min(...ys) - 40;174 const maxY = Math.max(...ys) + 40;175 const k = Math.max(K_MIN, Math.min(K_MAX, Math.min(W / (maxX - minX), H / (maxY - minY))));176 setT({ k, x: (W - (minX + maxX) * k) / 2, y: (H - (minY + maxY) * k) / 2 });177 }, [layout]);178 useEffect(() => {179 if (layout) fit();180 // eslint-disable-next-line react-hooks/exhaustive-deps181 }, [layout]);182183 /* ------------------------------------------------------------------------------------------------ pointer events */184 const pointers = useRef<Map<number, { x: number; y: number }>>(new Map());185 const gesture = useRef<{ kind: 'pan' | 'node' | 'pinch'; id?: string; start: { x: number; y: number }; moved: boolean; t0: Transform; dist?: number; mid?: { x: number; y: number } } | null>(null);186187 const onPointerDown = (e: React.PointerEvent<SVGSVGElement>) => {188 const svg = svgRef.current;189 if (!svg) return;190 svg.setPointerCapture(e.pointerId);191 const p = toSvg(e.clientX, e.clientY);192 pointers.current.set(e.pointerId, p);193 if (pointers.current.size === 2) {194 const [a, b] = [...pointers.current.values()] as [{ x: number; y: number }, { x: number; y: number }];195 gesture.current = { kind: 'pinch', start: p, moved: true, t0: t, dist: Math.hypot(a.x - b.x, a.y - b.y), mid: { x: (a.x + b.x) / 2, y: (a.y + b.y) / 2 } };196 return;197 }198 const target = (e.target as Element).closest('[data-node]') as SVGGElement | null;199 if (target?.dataset.node) gesture.current = { kind: 'node', id: target.dataset.node, start: p, moved: false, t0: t };200 else gesture.current = { kind: 'pan', start: p, moved: false, t0: t };201 };202 const onPointerMove = (e: React.PointerEvent<SVGSVGElement>) => {203 const g = gesture.current;204 if (!g) return;205 const p = toSvg(e.clientX, e.clientY);206 pointers.current.set(e.pointerId, p);207 if (g.kind === 'pinch' && pointers.current.size >= 2) {208 const [a, b] = [...pointers.current.values()] as [{ x: number; y: number }, { x: number; y: number }];209 const dist = Math.hypot(a.x - b.x, a.y - b.y);210 const mid = { x: (a.x + b.x) / 2, y: (a.y + b.y) / 2 };211 const factor = dist / (g.dist || dist);212 const k = Math.max(K_MIN, Math.min(K_MAX, g.t0.k * factor));213 const r = k / g.t0.k;214 const m0 = g.mid ?? mid;215 setT({ k, x: mid.x - (m0.x - g.t0.x) * r, y: mid.y - (m0.y - g.t0.y) * r });216 return;217 }218 const dx = p.x - g.start.x;219 const dy = p.y - g.start.y;220 if (!g.moved && Math.hypot(dx, dy) > 3) g.moved = true;221 if (!g.moved) return;222 if (g.kind === 'pan') setT({ ...g.t0, x: g.t0.x + dx, y: g.t0.y + dy });223 else if (g.kind === 'node' && g.id && layout) {224 const n = layout.byId.get(g.id);225 if (n) {226 n.x = (p.x - t.x) / t.k;227 n.y = (p.y - t.y) / t.k;228 posRef.current.set(n.id, { x: n.x, y: n.y });229 bump((v) => v + 1);230 }231 }232 };233 const onPointerUp = (e: React.PointerEvent<SVGSVGElement>) => {234 pointers.current.delete(e.pointerId);235 const g = gesture.current;236 if (!g) return;237 if (pointers.current.size > 0 && g.kind === 'pinch') return;238 gesture.current = null;239 if (!g.moved) {240 if (g.kind === 'node' && g.id) onSelect(g.id);241 else if (g.kind === 'pan') onSelect(null);242 }243 };244 const onWheel = (e: React.WheelEvent<SVGSVGElement>) => {245 const p = toSvg(e.clientX, e.clientY);246 zoomAt(Math.exp(-e.deltaY * 0.0018), p.x, p.y);247 };248 // Block page scroll while the pointer is over the graph (wheel must zoom, not scroll).249 useEffect(() => {250 const svg = svgRef.current;251 if (!svg) return;252 const stop = (ev: WheelEvent) => ev.preventDefault();253 svg.addEventListener('wheel', stop, { passive: false });254 return () => svg.removeEventListener('wheel', stop);255 }, []);256257 /* ------------------------------------------------------------------------------------------------ label policy */258 const labelSet = useMemo(() => {259 if (!layout) return new Set<string>();260 const out = new Set<string>();261 const total = layout.sim.length;262 const budget = total <= 36 ? total : Math.max(8, Math.min(total, Math.round(18 * t.k * t.k)));263 [...layout.sim].sort((a, b) => b.degree - a.degree).slice(0, budget).forEach((n) => out.add(n.id));264 out.add(rootId);265 const focus = hover ?? selectedId;266 if (focus) {267 out.add(focus);268 layout.neighbours.get(focus)?.forEach((id) => out.add(id));269 }270 return out;271 }, [layout, t.k, hover, selectedId, rootId]);272273 const focus = hover ?? selectedId;274 const isOn = (id: string) => !focus || focus === id || (layout?.neighbours.get(focus)?.has(id) ?? false);275 const fs = Math.max(6.5, Math.min(12, 10 / t.k));276 const sw = 1 / t.k;277 const showEdgeLabels = (layout?.links.length ?? 0) <= 30 && t.k >= 0.9;278279 return (280 <div className={cn('relative', className)} data-graph-canvas>281 <svg282 ref={svgRef}283 viewBox={`0 0 ${W} ${H}`}284 preserveAspectRatio="xMidYMid meet"285 className={cn('block w-full touch-none select-none border border-rule bg-surface', tall ? 'h-[min(78dvh,calc(100dvh-var(--header-h)-7.5rem))] min-h-[22rem]' : 'h-[26rem] md:h-[34rem]')}286 role="img"287 aria-label="Knowledge graph"288 onPointerDown={onPointerDown}289 onPointerMove={onPointerMove}290 onPointerUp={onPointerUp}291 onPointerCancel={onPointerUp}292 onWheel={onWheel}293 onMouseLeave={() => setHover(null)}294 style={{ cursor: gesture.current?.kind === 'pan' && gesture.current.moved ? 'grabbing' : 'default' }}295 >296 <defs>297 <pattern id="graph-grid" width={40} height={40} patternUnits="userSpaceOnUse">298 <path d="M 40 0 L 0 0 0 40" fill="none" stroke="var(--rule)" strokeWidth={0.5} />299 </pattern>300 </defs>301 <rect width={W} height={H} fill="url(#graph-grid)" opacity={0.6} />302 {!layout && (303 <text x={W / 2} y={H / 2} textAnchor="middle" fontSize={14} fill="var(--ink-3)">304 {mounted ? `Laying out ${visible.length} nodes…` : 'Graph loads after hydration'}305 </text>306 )}307 {layout && (308 <g transform={`translate(${t.x},${t.y}) scale(${t.k})`}>309 <g>310 {layout.links.map((l, i) => {311 const a = l.source as SimNode;312 const b = l.target as SimNode;313 const active = !focus || focus === a.id || focus === b.id;314 const label = (showEdgeLabels || (focus && active)) && active;315 const mx = ((a.x ?? 0) + (b.x ?? 0)) / 2;316 const my = ((a.y ?? 0) + (b.y ?? 0)) / 2;317 return (318 <g key={i} opacity={active ? 1 : 0.1}>319 <line x1={a.x} y1={a.y} x2={b.x} y2={b.y} stroke={focus && active ? 'var(--accent)' : 'var(--rule-strong)'} strokeWidth={(focus && active ? 1.5 : 1) * sw} strokeDasharray={l.tier && l.tier >= 3 ? `${3 * sw} ${3 * sw}` : undefined} />320 {label && (321 <text x={mx} y={my - 2.5 * sw} textAnchor="middle" fontSize={fs * 0.8} fill="var(--ink-3)" className="pointer-events-none select-none" style={{ paintOrder: 'stroke', stroke: 'var(--surface)', strokeWidth: 3 * sw }}>322 {predicateLabel(l.predicate, 'out')}323 </text>324 )}325 </g>326 );327 })}328 </g>329 <g>330 {layout.sim.map((n) => {331 const r = radiusOf(n);332 const on = isOn(n.id);333 const sel = selectedId === n.id;334 const color = colorOf(n.entity_type);335 const showLabel = labelSet.has(n.id);336 const canExpand = !!onExpand && !n.isRoot && !expanded.has(n.id);337 return (338 <g339 key={n.id}340 data-node={n.id}341 transform={`translate(${n.x},${n.y})`}342 opacity={on ? 1 : 0.18}343 tabIndex={0}344 role="button"345 aria-pressed={sel}346 aria-label={`${n.name} (${typeLabel(n.entity_type)})${canExpand ? ' — Shift+Enter expands' : ''}`}347 className="cursor-pointer outline-none focus-visible:[&>circle:nth-child(2)]:stroke-accent"348 onMouseEnter={() => setHover(n.id)}349 onFocus={() => setHover(n.id)}350 onBlur={() => setHover(null)}351 onDoubleClick={(e) => {352 e.stopPropagation();353 onExpand?.(n.id);354 }}355 onKeyDown={(e) => {356 if (e.key === 'Enter' || e.key === ' ') {357 e.preventDefault();358 if (e.shiftKey) onExpand?.(n.id);359 else onSelect(n.id);360 }361 }}362 >363 <circle r={Math.max(14, r + 9) / Math.min(1.5, t.k)} fill="transparent" />364 {(n.isRoot || sel) && <circle r={r + 5 * sw} fill="none" stroke={sel ? 'var(--ink)' : color} strokeWidth={1.2 * sw} opacity={0.75} />}365 {loadingId === n.id && <circle r={r + 9 * sw} fill="none" stroke="var(--accent)" strokeWidth={sw} strokeDasharray={`${4 * sw} ${4 * sw}`} className="animate-spin [animation-duration:2s]" style={{ transformOrigin: '0 0' }} />}366 <circle r={r} fill={color} stroke="var(--canvas)" strokeWidth={1.5 * sw} />367 {n.artifact_kind && <circle r={r * 0.4} fill="var(--canvas)" />}368 {expanded.has(n.id) && !n.isRoot && <circle r={r * 0.35} fill="var(--canvas)" opacity={0.9} />}369 {showLabel && (370 <text x={0} y={r + fs + 1.5} textAnchor="middle" fontSize={n.isRoot ? fs * 1.2 : fs} fontWeight={n.isRoot || sel ? 600 : 400} fill={n.isRoot || sel ? 'var(--ink)' : 'var(--ink-2)'} className="pointer-events-none select-none" style={{ paintOrder: 'stroke', stroke: 'var(--surface)', strokeWidth: 3 * sw }}>371 {short(n.name, n.isRoot ? 40 : 26)}372 </text>373 )}374 </g>375 );376 })}377 </g>378 </g>379 )}380 </svg>381382 {/* hover readout */}383 {layout && hover && layout.byId.get(hover) && (384 <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" role="status">385 <span className="font-medium text-ink">{layout.byId.get(hover)!.name}</span> · {typeLabel(layout.byId.get(hover)!.entity_type)}386 {layout.byId.get(hover)!.org ? ` · ${layout.byId.get(hover)!.org}` : ''} · {layout.byId.get(hover)!.degree} link{layout.byId.get(hover)!.degree === 1 ? '' : 's'}387 </p>388 )}389390 {/* zoom controls */}391 <div className="absolute bottom-2 right-2 flex flex-col border border-rule bg-canvas/90 backdrop-blur" role="group" aria-label="Zoom">392 <button type="button" onClick={() => zoomAt(1.35, W / 2, H / 2)} className="flex size-11 items-center justify-center text-ink-2 hover:bg-surface-2 hover:text-ink" aria-label="Zoom in">393 <Plus className="size-4" aria-hidden />394 </button>395 <button type="button" onClick={() => zoomAt(1 / 1.35, W / 2, H / 2)} className="flex size-11 items-center justify-center border-t border-rule text-ink-2 hover:bg-surface-2 hover:text-ink" aria-label="Zoom out">396 <Minus className="size-4" aria-hidden />397 </button>398 <button type="button" onClick={fit} className="flex size-11 items-center justify-center border-t border-rule text-ink-2 hover:bg-surface-2 hover:text-ink" aria-label="Fit graph to view">399 <Crosshair className="size-4" aria-hidden />400 </button>401 </div>402 <p className="tnum pointer-events-none absolute bottom-2 left-2 bg-canvas/80 px-1.5 py-0.5 text-[10px] text-ink-3 backdrop-blur">{Math.round(t.k * 100)}% · drag to pan · wheel or pinch to zoom · double-click a node to expand</p>403 </div>404 );405}406