SPB Git forge

spb/ai-atlas

Public
41commits 1branches 0releases
4.6 MBsize
maindefault branch
12 days agolast push
HTML 77.2% TypeScript 10.5% Python 9.6% JavaScript 2.5%
19.0 KB · 406 lines tsx
Raw Blame History
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