'use client'; import { forceCenter, forceCollide, forceLink, forceManyBody, forceSimulation, type SimulationLinkDatum, type SimulationNodeDatum } from 'd3-force'; import { Crosshair, Minus, Plus } from 'lucide-react'; import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { cn } from '@/lib/cn'; import { predicateLabel, TYPE_COLOR_KEY, typeLabel } from '@/lib/site'; import type { ExploreEdge, ExploreNode } from '@/lib/types'; /* Force graph on a plain SVG (no WebGL, no d3-zoom dependency): d3-force computes the layout synchronously after mount, positions are kept across merges (progressive expansion), pan / wheel-zoom / pinch / node drag are pointer events on the , labels are budgeted by degree and zoom level, click selects, double-click (or Shift+Enter) expands. Server render = the frame only, so the markup is stable between SSR and hydration. */ export type SimNode = SimulationNodeDatum & ExploreNode & { degree: number; isRoot: boolean }; type SimLink = SimulationLinkDatum & { predicate: string; tier?: number | null }; type Transform = { x: number; y: number; k: number }; const W = 960; const H = 640; const K_MIN = 0.25; const K_MAX = 6; export function colorOf(type: string): string { return `var(--type-${TYPE_COLOR_KEY[type] ?? 'tool'})`; } function short(s: string, n = 24): string { return s.length > n ? `${s.slice(0, n - 1)}…` : s; } function radiusOf(n: { degree: number; isRoot: boolean; level?: number }): number { if (n.isRoot) return 13; return 4.5 + Math.min(7, Math.sqrt(n.degree) * 1.6); } export function GraphCanvas({ nodes, edges, rootId, selectedId, onSelect, onExpand, expanded, loadingId, hiddenTypes, className, tall = false, }: { nodes: ExploreNode[]; edges: ExploreEdge[]; rootId: string; selectedId: string | null; onSelect: (id: string | null) => void; onExpand?: (id: string) => void; expanded: Set; loadingId?: string | null; hiddenTypes: Set; className?: string; /** Full-height canvas (mobile full-screen / flagship page). */ tall?: boolean; }) { const svgRef = useRef(null); const posRef = useRef>(new Map()); const [mounted, setMounted] = useState(false); const [hover, setHover] = useState(null); const [t, setT] = useState({ x: 0, y: 0, k: 1 }); const [, bump] = useState(0); useEffect(() => setMounted(true), []); const visible = useMemo(() => nodes.filter((n) => !hiddenTypes.has(n.entity_type) || n.id === rootId), [nodes, hiddenTypes, rootId]); const signature = useMemo(() => `${visible.map((n) => n.id).join('|')}#${edges.length}`, [visible, edges.length]); const layout = useMemo(() => { if (!mounted) return null; const ids = new Set(visible.map((n) => n.id)); const degree = new Map(); const links: SimLink[] = []; const seen = new Set(); for (const e of edges) { if (!ids.has(e.source) || !ids.has(e.target) || e.source === e.target) continue; const key = `${e.source}>${e.target}:${e.predicate}`; if (seen.has(key)) continue; seen.add(key); 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, tier: e.tier ?? null }); } const pos = posRef.current; // neighbour lookup for seeding new nodes next to an already-placed neighbour const nb = new Map(); for (const l of links) { const a = l.source as string; const b = l.target as string; (nb.get(a) ?? nb.set(a, []).get(a)!).push(b); (nb.get(b) ?? nb.set(b, []).get(b)!).push(a); } const sim: SimNode[] = visible.map((n, i) => { const isRoot = n.id === rootId; const known = pos.get(n.id); let x: number; let y: number; if (known) ({ x, y } = known); else if (isRoot) [x, y] = [W / 2, H / 2]; else { const anchor = (nb.get(n.id) ?? []).map((id) => pos.get(id)).find(Boolean); const ang = (i / Math.max(1, visible.length)) * Math.PI * 2 + (n.level ?? 1) * 0.7; if (anchor) [x, y] = [anchor.x + Math.cos(ang) * 60, anchor.y + Math.sin(ang) * 60]; else [x, y] = [W / 2 + Math.cos(ang) * (150 + 90 * (n.level ?? 1)), H / 2 + Math.sin(ang) * (110 + 70 * (n.level ?? 1))]; } return { ...n, degree: degree.get(n.id) ?? 0, isRoot, x, y, fx: isRoot && !known ? W / 2 : undefined, fy: isRoot && !known ? H / 2 : undefined }; }); 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 55 + Math.min(80, hub * 2.5) + (a.isRoot || b.isRoot ? 45 : 0); }) .strength(0.55), ) .force('charge', forceManyBody().strength((d) => (d.isRoot ? -700 : -160 - d.degree * 8)).distanceMax(420)) .force('center', forceCenter(W / 2, H / 2).strength(0.04)) .force('collide', forceCollide().radius((d) => radiusOf(d) + 10).iterations(2)) .stop(); const ticks = sim.length > 120 ? 220 : 300; for (let i = 0; i < ticks; i++) s.tick(); for (const n of sim) { n.fx = undefined; n.fy = undefined; pos.set(n.id, { x: n.x ?? W / 2, y: n.y ?? H / 2 }); } const byId = new Map(sim.map((n) => [n.id, n])); 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, neighbours }; // eslint-disable-next-line react-hooks/exhaustive-deps }, [mounted, signature, rootId]); /* ------------------------------------------------------------------------------------------------ coordinates */ const toSvg = useCallback((clientX: number, clientY: number): { x: number; y: number } => { const svg = svgRef.current; if (!svg) return { x: 0, y: 0 }; const ctm = svg.getScreenCTM(); if (!ctm) return { x: 0, y: 0 }; const p = svg.createSVGPoint(); p.x = clientX; p.y = clientY; const q = p.matrixTransform(ctm.inverse()); return { x: q.x, y: q.y }; }, []); const zoomAt = useCallback((factor: number, cx: number, cy: number) => { setT((cur) => { const k = Math.max(K_MIN, Math.min(K_MAX, cur.k * factor)); const r = k / cur.k; return { k, x: cx - (cx - cur.x) * r, y: cy - (cy - cur.y) * r }; }); }, []); const fit = useCallback(() => { if (!layout || !layout.sim.length) return setT({ x: 0, y: 0, k: 1 }); const xs = layout.sim.map((n) => n.x ?? 0); const ys = layout.sim.map((n) => n.y ?? 0); const minX = Math.min(...xs) - 40; const maxX = Math.max(...xs) + 40; const minY = Math.min(...ys) - 40; const maxY = Math.max(...ys) + 40; const k = Math.max(K_MIN, Math.min(K_MAX, Math.min(W / (maxX - minX), H / (maxY - minY)))); setT({ k, x: (W - (minX + maxX) * k) / 2, y: (H - (minY + maxY) * k) / 2 }); }, [layout]); useEffect(() => { if (layout) fit(); // eslint-disable-next-line react-hooks/exhaustive-deps }, [layout]); /* ------------------------------------------------------------------------------------------------ pointer events */ const pointers = useRef>(new Map()); 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); const onPointerDown = (e: React.PointerEvent) => { const svg = svgRef.current; if (!svg) return; svg.setPointerCapture(e.pointerId); const p = toSvg(e.clientX, e.clientY); pointers.current.set(e.pointerId, p); if (pointers.current.size === 2) { const [a, b] = [...pointers.current.values()] as [{ x: number; y: number }, { x: number; y: number }]; 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 } }; return; } const target = (e.target as Element).closest('[data-node]') as SVGGElement | null; if (target?.dataset.node) gesture.current = { kind: 'node', id: target.dataset.node, start: p, moved: false, t0: t }; else gesture.current = { kind: 'pan', start: p, moved: false, t0: t }; }; const onPointerMove = (e: React.PointerEvent) => { const g = gesture.current; if (!g) return; const p = toSvg(e.clientX, e.clientY); pointers.current.set(e.pointerId, p); if (g.kind === 'pinch' && pointers.current.size >= 2) { const [a, b] = [...pointers.current.values()] as [{ x: number; y: number }, { x: number; y: number }]; const dist = Math.hypot(a.x - b.x, a.y - b.y); const mid = { x: (a.x + b.x) / 2, y: (a.y + b.y) / 2 }; const factor = dist / (g.dist || dist); const k = Math.max(K_MIN, Math.min(K_MAX, g.t0.k * factor)); const r = k / g.t0.k; const m0 = g.mid ?? mid; setT({ k, x: mid.x - (m0.x - g.t0.x) * r, y: mid.y - (m0.y - g.t0.y) * r }); return; } const dx = p.x - g.start.x; const dy = p.y - g.start.y; if (!g.moved && Math.hypot(dx, dy) > 3) g.moved = true; if (!g.moved) return; if (g.kind === 'pan') setT({ ...g.t0, x: g.t0.x + dx, y: g.t0.y + dy }); else if (g.kind === 'node' && g.id && layout) { const n = layout.byId.get(g.id); if (n) { n.x = (p.x - t.x) / t.k; n.y = (p.y - t.y) / t.k; posRef.current.set(n.id, { x: n.x, y: n.y }); bump((v) => v + 1); } } }; const onPointerUp = (e: React.PointerEvent) => { pointers.current.delete(e.pointerId); const g = gesture.current; if (!g) return; if (pointers.current.size > 0 && g.kind === 'pinch') return; gesture.current = null; if (!g.moved) { if (g.kind === 'node' && g.id) onSelect(g.id); else if (g.kind === 'pan') onSelect(null); } }; const onWheel = (e: React.WheelEvent) => { const p = toSvg(e.clientX, e.clientY); zoomAt(Math.exp(-e.deltaY * 0.0018), p.x, p.y); }; // Block page scroll while the pointer is over the graph (wheel must zoom, not scroll). useEffect(() => { const svg = svgRef.current; if (!svg) return; const stop = (ev: WheelEvent) => ev.preventDefault(); svg.addEventListener('wheel', stop, { passive: false }); return () => svg.removeEventListener('wheel', stop); }, []); /* ------------------------------------------------------------------------------------------------ label policy */ const labelSet = useMemo(() => { if (!layout) return new Set(); const out = new Set(); const total = layout.sim.length; const budget = total <= 36 ? total : Math.max(8, Math.min(total, Math.round(18 * t.k * t.k))); [...layout.sim].sort((a, b) => b.degree - a.degree).slice(0, budget).forEach((n) => out.add(n.id)); out.add(rootId); const focus = hover ?? selectedId; if (focus) { out.add(focus); layout.neighbours.get(focus)?.forEach((id) => out.add(id)); } return out; }, [layout, t.k, hover, selectedId, rootId]); const focus = hover ?? selectedId; const isOn = (id: string) => !focus || focus === id || (layout?.neighbours.get(focus)?.has(id) ?? false); const fs = Math.max(6.5, Math.min(12, 10 / t.k)); const sw = 1 / t.k; const showEdgeLabels = (layout?.links.length ?? 0) <= 30 && t.k >= 0.9; return (
setHover(null)} style={{ cursor: gesture.current?.kind === 'pan' && gesture.current.moved ? 'grabbing' : 'default' }} > {!layout && ( {mounted ? `Laying out ${visible.length} nodes…` : 'Graph loads after hydration'} )} {layout && ( {layout.links.map((l, i) => { const a = l.source as SimNode; const b = l.target as SimNode; const active = !focus || focus === a.id || focus === b.id; const label = (showEdgeLabels || (focus && active)) && active; const mx = ((a.x ?? 0) + (b.x ?? 0)) / 2; const my = ((a.y ?? 0) + (b.y ?? 0)) / 2; return ( = 3 ? `${3 * sw} ${3 * sw}` : undefined} /> {label && ( {predicateLabel(l.predicate, 'out')} )} ); })} {layout.sim.map((n) => { const r = radiusOf(n); const on = isOn(n.id); const sel = selectedId === n.id; const color = colorOf(n.entity_type); const showLabel = labelSet.has(n.id); const canExpand = !!onExpand && !n.isRoot && !expanded.has(n.id); return ( setHover(n.id)} onFocus={() => setHover(n.id)} onBlur={() => setHover(null)} onDoubleClick={(e) => { e.stopPropagation(); onExpand?.(n.id); }} onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); if (e.shiftKey) onExpand?.(n.id); else onSelect(n.id); } }} > {(n.isRoot || sel) && } {loadingId === n.id && } {n.artifact_kind && } {expanded.has(n.id) && !n.isRoot && } {showLabel && ( {short(n.name, n.isRoot ? 40 : 26)} )} ); })} )} {/* hover readout */} {layout && hover && layout.byId.get(hover) && (

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

)} {/* zoom controls */}

{Math.round(t.k * 100)}% · drag to pan · wheel or pinch to zoom · double-click a node to expand

); }