SPB Git forge
7commits 1branches 0releases
229.0 KBsize
maindefault branch
12 days agolast push
TypeScript 91.8% HTML 3.2% JavaScript 3% SQL 1.4% CSS 0.7%
8.9 KB · 238 lines tsx
Raw Blame History
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