SPB Git

spb/ai-atlas

Public
8.2 KB · 168 lines tsx
1'use client';2import { forceCenter, forceCollide, forceLink, forceManyBody, forceSimulation, type SimulationLinkDatum, type SimulationNodeDatum } from 'd3-force';3import { useRouter } from 'next/navigation';4import { useEffect, useMemo, useState } from 'react';5import { cn } from '@/lib/cn';6import { predicateLabel, routes, TYPE_COLOR_KEY, typeLabel } from '@/lib/site';7import type { GraphEdge, GraphNode } from '@/lib/types';89type SimNode = SimulationNodeDatum & GraphNode & { degree: number; isRoot: boolean };10type SimLink = SimulationLinkDatum<SimNode> & { predicate: string };1112const W = 960;13const H = 640;14const LABEL_LIMIT = 40;15const EDGE_LABEL_LIMIT = 40;1617function colorOf(type: string): string {18  return `var(--type-${TYPE_COLOR_KEY[type] ?? 'tool'})`;19}20function short(s: string, n = 26): string {21  return s.length > n ? `${s.slice(0, n - 1)}…` : s;22}2324/**25 * Force-directed neighbourhood graph (d3-force run for a fixed number of ticks, deterministic seed, no animation).26 * Server render: the SVG frame + legend only; the layout is computed on the client after mount so markup stays stable.27 * Click / Enter → the entity page; hover / focus highlights the neighbourhood and reveals edge labels.28 */29export function GraphExplorer({ nodes, edges, rootId }: { nodes: GraphNode[]; edges: GraphEdge[]; rootId: string }) {30  const router = useRouter();31  const [mounted, setMounted] = useState(false);32  const [hover, setHover] = useState<string | null>(null);33  useEffect(() => setMounted(true), []);3435  const layout = useMemo(() => {36    if (!mounted) return null;37    const ids = new Set(nodes.map((n) => n.id));38    const degree = new Map<string, number>();39    const links: SimLink[] = [];40    for (const e of edges) {41      if (!ids.has(e.source) || !ids.has(e.target)) continue;42      degree.set(e.source, (degree.get(e.source) ?? 0) + 1);43      degree.set(e.target, (degree.get(e.target) ?? 0) + 1);44      links.push({ source: e.source, target: e.target, predicate: e.predicate });45    }46    // Deterministic seed: nodes on a ring ordered by type, root at the centre.47    const sim: SimNode[] = nodes.map((n, i) => {48      const ang = (i / Math.max(1, nodes.length)) * Math.PI * 2;49      const isRoot = n.id === rootId;50      return { ...n, degree: degree.get(n.id) ?? 0, isRoot, x: isRoot ? W / 2 : W / 2 + Math.cos(ang) * 220, y: isRoot ? H / 2 : H / 2 + Math.sin(ang) * 220, fx: isRoot ? W / 2 : undefined, fy: isRoot ? H / 2 : undefined };51    });52    const byId = new Map(sim.map((n) => [n.id, n]));53    const radius = (n: SimNode) => (n.isRoot ? 14 : 5 + Math.min(6, Math.sqrt(n.degree)));54    const s = forceSimulation<SimNode>(sim)55      .force('link', forceLink<SimNode, SimLink>(links).id((d) => d.id).distance((l) => {56        const a = l.source as SimNode;57        const b = l.target as SimNode;58        const hub = Math.max(a.degree, b.degree);59        return 70 + Math.min(90, hub * 3) + (a.isRoot || b.isRoot ? 60 : 0);60      }).strength(0.6))61      .force('charge', forceManyBody<SimNode>().strength((d) => (d.isRoot ? -900 : -220)))62      .force('center', forceCenter(W / 2, H / 2))63      .force('collide', forceCollide<SimNode>().radius((d) => radius(d) + 14).iterations(2))64      .stop();65    for (let i = 0; i < 300; i++) s.tick();66    // Clamp into the viewBox.67    for (const n of sim) {68      n.x = Math.max(24, Math.min(W - 24, n.x ?? W / 2));69      n.y = Math.max(24, Math.min(H - 24, n.y ?? H / 2));70    }71    const neighbours = new Map<string, Set<string>>();72    for (const l of links) {73      const a = (l.source as SimNode).id;74      const b = (l.target as SimNode).id;75      (neighbours.get(a) ?? neighbours.set(a, new Set()).get(a)!).add(b);76      (neighbours.get(b) ?? neighbours.set(b, new Set()).get(b)!).add(a);77    }78    return { sim, links, byId, radius, neighbours };79  }, [mounted, nodes, edges, rootId]);8081  const showAllLabels = nodes.length <= LABEL_LIMIT;82  const showAllEdgeLabels = edges.length <= EDGE_LABEL_LIMIT;8384  const go = (n: GraphNode) => router.push(routes.entity(n));85  const active = (id: string) => !hover || hover === id || (layout?.neighbours.get(hover)?.has(id) ?? false);8687  return (88    <div className="relative">89      <svg viewBox={`0 0 ${W} ${H}`} className="block h-auto w-full border border-rule bg-surface" role="img" aria-label="Relationship graph" onMouseLeave={() => setHover(null)}>90        {!layout && (91          <text x={W / 2} y={H / 2} textAnchor="middle" fontSize={14} fill="var(--ink-3)">92            Laying out {nodes.length} nodes…93          </text>94        )}95        {layout && (96          <>97            <g>98              {layout.links.map((l, i) => {99                const a = l.source as SimNode;100                const b = l.target as SimNode;101                const on = !hover || hover === a.id || hover === b.id;102                const label = showAllEdgeLabels || (hover && (hover === a.id || hover === b.id));103                const mx = ((a.x ?? 0) + (b.x ?? 0)) / 2;104                const my = ((a.y ?? 0) + (b.y ?? 0)) / 2;105                return (106                  <g key={i} opacity={on ? 1 : 0.12}>107                    <line x1={a.x} y1={a.y} x2={b.x} y2={b.y} stroke={on && hover ? 'var(--accent)' : 'var(--rule-strong)'} strokeWidth={on && hover ? 1.4 : 1} />108                    {label && (109                      <text x={mx} y={my - 3} textAnchor="middle" fontSize={9} fill="var(--ink-3)" className="pointer-events-none select-none">110                        {predicateLabel(l.predicate, 'out')}111                      </text>112                    )}113                  </g>114                );115              })}116            </g>117            <g>118              {layout.sim.map((n) => {119                const r = layout.radius(n);120                const on = active(n.id);121                const label = n.isRoot || showAllLabels || (hover && (hover === n.id || layout.neighbours.get(hover)?.has(n.id)));122                const color = colorOf(n.entity_type);123                return (124                  <g125                    key={n.id}126                    transform={`translate(${n.x},${n.y})`}127                    opacity={on ? 1 : 0.2}128                    tabIndex={0}129                    role="link"130                    aria-label={`${n.name} (${typeLabel(n.entity_type)})`}131                    className="cursor-pointer outline-none focus-visible:[&>circle]:stroke-accent"132                    onMouseEnter={() => setHover(n.id)}133                    onFocus={() => setHover(n.id)}134                    onBlur={() => setHover(null)}135                    onClick={() => go(n)}136                    onKeyDown={(e) => {137                      if (e.key === 'Enter' || e.key === ' ') {138                        e.preventDefault();139                        go(n);140                      }141                    }}142                  >143                    <circle r={Math.max(16, r + 10)} fill="transparent" data-hit="" />144                    {n.isRoot && <circle r={r + 6} fill="none" stroke={color} strokeWidth={1} opacity={0.5} />}145                    <circle r={r} fill={color} stroke="var(--canvas)" strokeWidth={1.5} />146                    {label && (147                      <text x={0} y={r + 11} textAnchor="middle" fontSize={n.isRoot ? 12 : 10} fontWeight={n.isRoot ? 600 : 400} fill={n.isRoot ? 'var(--ink)' : 'var(--ink-2)'} className="pointer-events-none select-none" style={{ paintOrder: 'stroke', stroke: 'var(--surface)', strokeWidth: 3 }}>148                        {short(n.name, n.isRoot ? 40 : 26)}149                      </text>150                    )}151                  </g>152                );153              })}154            </g>155          </>156        )}157      </svg>158      {layout && hover && layout.byId.get(hover) && (159        <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">160          <span className="font-medium text-ink">{layout.byId.get(hover)!.name}</span> · {typeLabel(layout.byId.get(hover)!.entity_type)}161          {layout.byId.get(hover)!.organization_name ? ` · ${layout.byId.get(hover)!.organization_name}` : ''} · {layout.byId.get(hover)!.degree} link{layout.byId.get(hover)!.degree === 1 ? '' : 's'}162        </p>163      )}164      {!showAllLabels && <p className={cn('mt-1 text-xs text-ink-3')}>Labels are shown on hover or focus beyond {LABEL_LIMIT} nodes; the list below names every node.</p>}165    </div>166  );167}168
Files · 7456f4abc11ceb761f1f60861a5d96e6ddc93ef4
Menu

Theme