"use client"; import { useEffect, useMemo, useRef, useState } from "react"; import { forceCenter, forceCollide, forceLink, forceManyBody, forceSimulation, forceX, forceY, type SimulationLinkDatum, type SimulationNodeDatum } from "d3-force"; import { TYPE_COLORS, truncate } from "@/lib/format"; export interface GraphNode { fingerprint: string; type: string; name?: string | null; url?: string | null; visited?: boolean; seen_count?: number; surfaces?: string[]; } export interface GraphEdge { from: string; to: string; type: string; } interface SimNode extends SimulationNodeDatum, GraphNode { r: number; } type SimLink = SimulationLinkDatum & { type: string }; /** * Social World Model (§23) rendered on a canvas with a d3-force layout. * Node size = how often the entity was observed; ring = visited; colour = entity type. */ export function WorldGraph({ nodes, edges, height = 520, onSelect }: { nodes: GraphNode[]; edges: GraphEdge[]; height?: number; onSelect?: (n: GraphNode | null) => void }) { const canvasRef = useRef(null); const [hover, setHover] = useState(null); const [selected, setSelected] = useState(null); const simRef = useRef> | null>(null); const transform = useRef({ x: 0, y: 0, k: 1 }); const data = useMemo(() => { const limit = 600; const ns: SimNode[] = nodes.slice(0, limit).map((n) => ({ ...n, r: 3 + Math.min(9, Math.sqrt(n.seen_count ?? 1) * 1.8) + (n.visited ? 2 : 0) })); const ids = new Set(ns.map((n) => n.fingerprint)); const ls: SimLink[] = edges.filter((e) => ids.has(e.from) && ids.has(e.to)).map((e) => ({ source: e.from, target: e.to, type: e.type })); return { ns, ls }; }, [nodes, edges]); useEffect(() => { const canvas = canvasRef.current; if (!canvas) return; const parent = canvas.parentElement!; const dpr = window.devicePixelRatio || 1; const W = parent.clientWidth; const H = height; canvas.width = W * dpr; canvas.height = H * dpr; canvas.style.width = `${W}px`; canvas.style.height = `${H}px`; const ctx = canvas.getContext("2d")!; ctx.scale(dpr, dpr); const sim = forceSimulation(data.ns) .force("link", forceLink(data.ls).id((d) => d.fingerprint).distance(38).strength(0.4)) .force("charge", forceManyBody().strength(-45)) .force("center", forceCenter(W / 2, H / 2)) .force("x", forceX(W / 2).strength(0.03)) .force("y", forceY(H / 2).strength(0.03)) .force("collide", forceCollide((d) => d.r + 2)) .alphaDecay(0.03); simRef.current = sim; const draw = () => { const { x: tx, y: ty, k } = transform.current; ctx.clearRect(0, 0, W, H); ctx.save(); ctx.translate(tx, ty); ctx.scale(k, k); ctx.lineWidth = 0.6 / k; for (const l of data.ls) { const s = l.source as SimNode; const t = l.target as SimNode; if (s.x === undefined || t.x === undefined) continue; ctx.strokeStyle = l.type === "AUTHORED" ? "rgba(157,123,255,0.35)" : "rgba(255,255,255,0.08)"; ctx.beginPath(); ctx.moveTo(s.x!, s.y!); ctx.lineTo(t.x!, t.y!); ctx.stroke(); } for (const n of data.ns) { if (n.x === undefined) continue; const color = TYPE_COLORS[n.type] ?? "#6b7a8c"; const isSel = selected?.fingerprint === n.fingerprint || hover?.fingerprint === n.fingerprint; ctx.beginPath(); ctx.arc(n.x!, n.y!, n.r, 0, Math.PI * 2); ctx.fillStyle = isSel ? color : `${color}cc`; ctx.fill(); if (n.visited) { ctx.beginPath(); ctx.arc(n.x!, n.y!, n.r + 3, 0, Math.PI * 2); ctx.strokeStyle = "#ffb347"; ctx.lineWidth = 1.2 / k; ctx.stroke(); } if (isSel || (n.seen_count ?? 0) > 4 || n.visited) { ctx.fillStyle = isSel ? "#e6edf5" : "rgba(230,237,245,0.7)"; ctx.font = `${11 / k}px ui-monospace, monospace`; ctx.fillText(truncate(n.name ?? n.fingerprint.split(":").pop() ?? "", 34), n.x! + n.r + 4, n.y! + 3); } } ctx.restore(); }; sim.on("tick", draw); draw(); const pick = (ev: MouseEvent): SimNode | null => { const rect = canvas.getBoundingClientRect(); const { x: tx, y: ty, k } = transform.current; const mx = (ev.clientX - rect.left - tx) / k; const my = (ev.clientY - rect.top - ty) / k; let best: SimNode | null = null; let bd = Infinity; for (const n of data.ns) { if (n.x === undefined) continue; const d = Math.hypot(n.x! - mx, n.y! - my); if (d < n.r + 4 && d < bd) { bd = d; best = n; } } return best; }; let dragging: SimNode | null = null; let panning = false; let last = { x: 0, y: 0 }; const onMove = (ev: MouseEvent) => { if (dragging) { const rect = canvas.getBoundingClientRect(); const { x: tx, y: ty, k } = transform.current; dragging.fx = (ev.clientX - rect.left - tx) / k; dragging.fy = (ev.clientY - rect.top - ty) / k; sim.alpha(0.3).restart(); return; } if (panning) { transform.current.x += ev.clientX - last.x; transform.current.y += ev.clientY - last.y; last = { x: ev.clientX, y: ev.clientY }; draw(); return; } const h = pick(ev); setHover(h); canvas.style.cursor = h ? "pointer" : "grab"; draw(); }; const onDown = (ev: MouseEvent) => { const h = pick(ev); if (h) { dragging = h; } else { panning = true; last = { x: ev.clientX, y: ev.clientY }; } }; const onUp = (ev: MouseEvent) => { if (dragging) { dragging.fx = null; dragging.fy = null; setSelected(dragging); onSelect?.(dragging); dragging = null; } else if (panning) { panning = false; if (Math.hypot(ev.clientX - last.x, ev.clientY - last.y) < 3) { setSelected(null); onSelect?.(null); } } }; const onWheel = (ev: WheelEvent) => { ev.preventDefault(); const rect = canvas.getBoundingClientRect(); const mx = ev.clientX - rect.left; const my = ev.clientY - rect.top; const k0 = transform.current.k; const k1 = Math.max(0.3, Math.min(4, k0 * (ev.deltaY < 0 ? 1.1 : 0.9))); transform.current.x = mx - ((mx - transform.current.x) * k1) / k0; transform.current.y = my - ((my - transform.current.y) * k1) / k0; transform.current.k = k1; draw(); }; canvas.addEventListener("mousemove", onMove); canvas.addEventListener("mousedown", onDown); window.addEventListener("mouseup", onUp); canvas.addEventListener("wheel", onWheel, { passive: false }); return () => { sim.stop(); canvas.removeEventListener("mousemove", onMove); canvas.removeEventListener("mousedown", onDown); window.removeEventListener("mouseup", onUp); canvas.removeEventListener("wheel", onWheel); }; // eslint-disable-next-line react-hooks/exhaustive-deps }, [data, height]); const legend = useMemo(() => { const c: Record = {}; for (const n of nodes) c[n.type] = (c[n.type] ?? 0) + 1; return Object.entries(c).sort((a, b) => b[1] - a[1]); }, [nodes]); return (
{legend.map(([t, n]) => ( {t} {n} ))} visited
{(hover || selected) && (
{(hover ?? selected)!.name ?? (hover ?? selected)!.fingerprint}
{(hover ?? selected)!.type} · seen {(hover ?? selected)!.seen_count ?? 1}× · {(hover ?? selected)!.surfaces?.join("+") ?? ""}
{(hover ?? selected)!.url && ( {(hover ?? selected)!.url} )}
)}
{nodes.length} nodes · {edges.length} edges · scroll to zoom · drag to move
); }