spb/social-runtime-crawler
Public
TypeScript 91.8%
HTML 3.2%
JavaScript 3%
SQL 1.4%
CSS 0.7%
1"use client";23import { useEffect, useMemo, useRef, useState } from "react";4import { forceCenter, forceCollide, forceLink, forceManyBody, forceSimulation, forceX, forceY, type SimulationLinkDatum, type SimulationNodeDatum } from "d3-force";5import { TYPE_COLORS, truncate } from "@/lib/format";67export interface GraphNode {8 fingerprint: string;9 type: string;10 name?: string | null;11 url?: string | null;12 visited?: boolean;13 seen_count?: number;14 surfaces?: string[];15}16export interface GraphEdge {17 from: string;18 to: string;19 type: string;20}2122interface SimNode extends SimulationNodeDatum, GraphNode {23 r: number;24}25type SimLink = SimulationLinkDatum<SimNode> & { type: string };2627/**28 * Social World Model (§23) rendered on a canvas with a d3-force layout.29 * Node size = how often the entity was observed; ring = visited; colour = entity type.30 */31export function WorldGraph({ nodes, edges, height = 520, onSelect }: { nodes: GraphNode[]; edges: GraphEdge[]; height?: number; onSelect?: (n: GraphNode | null) => void }) {32 const canvasRef = useRef<HTMLCanvasElement>(null);33 const [hover, setHover] = useState<SimNode | null>(null);34 const [selected, setSelected] = useState<SimNode | null>(null);35 const simRef = useRef<ReturnType<typeof forceSimulation<SimNode>> | null>(null);36 const transform = useRef({ x: 0, y: 0, k: 1 });3738 const data = useMemo(() => {39 const limit = 600;40 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) }));41 const ids = new Set(ns.map((n) => n.fingerprint));42 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 }));43 return { ns, ls };44 }, [nodes, edges]);4546 useEffect(() => {47 const canvas = canvasRef.current;48 if (!canvas) return;49 const parent = canvas.parentElement!;50 const dpr = window.devicePixelRatio || 1;51 const W = parent.clientWidth;52 const H = height;53 canvas.width = W * dpr;54 canvas.height = H * dpr;55 canvas.style.width = `${W}px`;56 canvas.style.height = `${H}px`;57 const ctx = canvas.getContext("2d")!;58 ctx.scale(dpr, dpr);5960 const sim = forceSimulation<SimNode>(data.ns)61 .force("link", forceLink<SimNode, SimLink>(data.ls).id((d) => d.fingerprint).distance(38).strength(0.4))62 .force("charge", forceManyBody().strength(-45))63 .force("center", forceCenter(W / 2, H / 2))64 .force("x", forceX(W / 2).strength(0.03))65 .force("y", forceY(H / 2).strength(0.03))66 .force("collide", forceCollide<SimNode>((d) => d.r + 2))67 .alphaDecay(0.03);68 simRef.current = sim;6970 const draw = () => {71 const { x: tx, y: ty, k } = transform.current;72 ctx.clearRect(0, 0, W, H);73 ctx.save();74 ctx.translate(tx, ty);75 ctx.scale(k, k);76 ctx.lineWidth = 0.6 / k;77 for (const l of data.ls) {78 const s = l.source as SimNode;79 const t = l.target as SimNode;80 if (s.x === undefined || t.x === undefined) continue;81 ctx.strokeStyle = l.type === "AUTHORED" ? "rgba(157,123,255,0.35)" : "rgba(255,255,255,0.08)";82 ctx.beginPath();83 ctx.moveTo(s.x!, s.y!);84 ctx.lineTo(t.x!, t.y!);85 ctx.stroke();86 }87 for (const n of data.ns) {88 if (n.x === undefined) continue;89 const color = TYPE_COLORS[n.type] ?? "#6b7a8c";90 const isSel = selected?.fingerprint === n.fingerprint || hover?.fingerprint === n.fingerprint;91 ctx.beginPath();92 ctx.arc(n.x!, n.y!, n.r, 0, Math.PI * 2);93 ctx.fillStyle = isSel ? color : `${color}cc`;94 ctx.fill();95 if (n.visited) {96 ctx.beginPath();97 ctx.arc(n.x!, n.y!, n.r + 3, 0, Math.PI * 2);98 ctx.strokeStyle = "#ffb347";99 ctx.lineWidth = 1.2 / k;100 ctx.stroke();101 }102 if (isSel || (n.seen_count ?? 0) > 4 || n.visited) {103 ctx.fillStyle = isSel ? "#e6edf5" : "rgba(230,237,245,0.7)";104 ctx.font = `${11 / k}px ui-monospace, monospace`;105 ctx.fillText(truncate(n.name ?? n.fingerprint.split(":").pop() ?? "", 34), n.x! + n.r + 4, n.y! + 3);106 }107 }108 ctx.restore();109 };110 sim.on("tick", draw);111 draw();112113 const pick = (ev: MouseEvent): SimNode | null => {114 const rect = canvas.getBoundingClientRect();115 const { x: tx, y: ty, k } = transform.current;116 const mx = (ev.clientX - rect.left - tx) / k;117 const my = (ev.clientY - rect.top - ty) / k;118 let best: SimNode | null = null;119 let bd = Infinity;120 for (const n of data.ns) {121 if (n.x === undefined) continue;122 const d = Math.hypot(n.x! - mx, n.y! - my);123 if (d < n.r + 4 && d < bd) {124 bd = d;125 best = n;126 }127 }128 return best;129 };130 let dragging: SimNode | null = null;131 let panning = false;132 let last = { x: 0, y: 0 };133 const onMove = (ev: MouseEvent) => {134 if (dragging) {135 const rect = canvas.getBoundingClientRect();136 const { x: tx, y: ty, k } = transform.current;137 dragging.fx = (ev.clientX - rect.left - tx) / k;138 dragging.fy = (ev.clientY - rect.top - ty) / k;139 sim.alpha(0.3).restart();140 return;141 }142 if (panning) {143 transform.current.x += ev.clientX - last.x;144 transform.current.y += ev.clientY - last.y;145 last = { x: ev.clientX, y: ev.clientY };146 draw();147 return;148 }149 const h = pick(ev);150 setHover(h);151 canvas.style.cursor = h ? "pointer" : "grab";152 draw();153 };154 const onDown = (ev: MouseEvent) => {155 const h = pick(ev);156 if (h) {157 dragging = h;158 } else {159 panning = true;160 last = { x: ev.clientX, y: ev.clientY };161 }162 };163 const onUp = (ev: MouseEvent) => {164 if (dragging) {165 dragging.fx = null;166 dragging.fy = null;167 setSelected(dragging);168 onSelect?.(dragging);169 dragging = null;170 } else if (panning) {171 panning = false;172 if (Math.hypot(ev.clientX - last.x, ev.clientY - last.y) < 3) {173 setSelected(null);174 onSelect?.(null);175 }176 }177 };178 const onWheel = (ev: WheelEvent) => {179 ev.preventDefault();180 const rect = canvas.getBoundingClientRect();181 const mx = ev.clientX - rect.left;182 const my = ev.clientY - rect.top;183 const k0 = transform.current.k;184 const k1 = Math.max(0.3, Math.min(4, k0 * (ev.deltaY < 0 ? 1.1 : 0.9)));185 transform.current.x = mx - ((mx - transform.current.x) * k1) / k0;186 transform.current.y = my - ((my - transform.current.y) * k1) / k0;187 transform.current.k = k1;188 draw();189 };190 canvas.addEventListener("mousemove", onMove);191 canvas.addEventListener("mousedown", onDown);192 window.addEventListener("mouseup", onUp);193 canvas.addEventListener("wheel", onWheel, { passive: false });194 return () => {195 sim.stop();196 canvas.removeEventListener("mousemove", onMove);197 canvas.removeEventListener("mousedown", onDown);198 window.removeEventListener("mouseup", onUp);199 canvas.removeEventListener("wheel", onWheel);200 };201 // eslint-disable-next-line react-hooks/exhaustive-deps202 }, [data, height]);203204 const legend = useMemo(() => {205 const c: Record<string, number> = {};206 for (const n of nodes) c[n.type] = (c[n.type] ?? 0) + 1;207 return Object.entries(c).sort((a, b) => b[1] - a[1]);208 }, [nodes]);209210 return (211 <div className="relative">212 <canvas ref={canvasRef} className="w-full rounded-lg bg-bg-2/60" style={{ height }} />213 <div className="absolute left-3 top-3 flex flex-wrap gap-2 text-[10.5px] mono">214 {legend.map(([t, n]) => (215 <span key={t} className="inline-flex items-center gap-1 rounded bg-bg/70 px-1.5 py-0.5 border border-line">216 <span className="h-2 w-2 rounded-full" style={{ background: TYPE_COLORS[t] ?? "#6b7a8c" }} /> {t} {n}217 </span>218 ))}219 <span className="inline-flex items-center gap-1 rounded bg-bg/70 px-1.5 py-0.5 border border-line">220 <span className="h-2 w-2 rounded-full border border-amber" /> visited221 </span>222 </div>223 {(hover || selected) && (224 <div className="absolute right-3 bottom-3 max-w-xs panel p-3 text-xs space-y-1">225 <div className="text-fg font-medium truncate">{(hover ?? selected)!.name ?? (hover ?? selected)!.fingerprint}</div>226 <div className="text-dim mono">{(hover ?? selected)!.type} · seen {(hover ?? selected)!.seen_count ?? 1}× · {(hover ?? selected)!.surfaces?.join("+") ?? ""}</div>227 {(hover ?? selected)!.url && (228 <a className="text-cyan truncate block" href={(hover ?? selected)!.url!} target="_blank" rel="noreferrer">229 {(hover ?? selected)!.url}230 </a>231 )}232 </div>233 )}234 <div className="absolute right-3 top-3 text-[10.5px] text-dim mono">{nodes.length} nodes · {edges.length} edges · scroll to zoom · drag to move</div>235 </div>236 );237}238