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%
18.6 KB · 366 lines tsx
Raw Blame History
1'use client';2import { ExternalLink, Focus, GitFork, Maximize2 } from 'lucide-react';3import Link from 'next/link';4import { useRouter } from 'next/navigation';5import { useCallback, useEffect, useMemo, useRef, useState } from 'react';6import { CompareButton } from '@/components/compare/compare-button';7import { TerminalLayout } from '@/components/layout/terminal';8import { EntityBadge } from '@/components/ui/badges';9import { keyAttributes } from '@/components/ui/entity';10import { Hint } from '@/components/ui/hint';11import { Sheet } from '@/components/ui/sheet';12import { WatchButton } from '@/components/watchlist/watch-button';13import { cn } from '@/lib/cn';14import { clientGraphExplore } from '@/lib/client-api';15import { fmtInt } from '@/lib/format';16import { predicateLabel, routes, typeLabel } from '@/lib/site';17import type { EntitySummary, ExploreEdge, ExploreNode, GraphExploreMode, GraphExplorePayload, Suggestion } from '@/lib/types';18import { colorOf, GraphCanvas } from './graph-canvas';19import { GRAPH_MODES, graphHref, graphSlugHref } from './modes';20import { NodeSearch } from './node-search';2122/*23  Graph workbench (client): TerminalLayout with the mode/depth/type filters in the rail, the canvas in the middle, the24  selected node in the inspector. Mode and depth changes re-fetch `/graph/explore` client-side and mirror the URL with25  `history.replaceState`; a new root navigates (server render, shareable URL). Expansion merges `/graph/explore?depth=1`26  for the clicked node into the current graph. On small screens the inspector opens as a bottom sheet on selection.27*/2829type Graph = { nodes: ExploreNode[]; edges: ExploreEdge[]; truncated: boolean; counts: GraphExplorePayload['counts']; predicates: string[] };3031function mergeGraph(base: Graph, add: GraphExplorePayload, hasLevel: (id: string) => number | undefined, viaLevel: number): Graph {32  const nodes = [...base.nodes];33  const seen = new Set(nodes.map((n) => n.id));34  for (const n of add.nodes) {35    if (seen.has(n.id)) continue;36    seen.add(n.id);37    nodes.push({ ...n, level: viaLevel + n.level });38  }39  const ek = new Set(base.edges.map((e) => `${e.source}>${e.target}:${e.predicate}`));40  const edges = [...base.edges];41  for (const e of add.edges) {42    const k = `${e.source}>${e.target}:${e.predicate}`;43    if (ek.has(k)) continue;44    ek.add(k);45    edges.push(e);46  }47  void hasLevel;48  return { nodes, edges, truncated: base.truncated || add.truncated, counts: { nodes: nodes.length, edges: edges.length, by_type: countTypes(nodes) }, predicates: [...new Set([...base.predicates, ...add.predicates])] };49}50function countTypes(nodes: ExploreNode[]): Record<string, number> {51  const out: Record<string, number> = {};52  for (const n of nodes) out[n.entity_type] = (out[n.entity_type] ?? 0) + 1;53  return out;54}55function asSummary(n: ExploreNode): EntitySummary {56  return { id: n.id, entity_type: n.entity_type, slug: n.slug, name: n.name, description: null, status: 'active', organization: n.org && n.org_slug ? { id: '', slug: n.org_slug, name: n.org } : null, attributes: n.attributes ?? {}, quality: {}, counts: {}, first_seen_at: '', last_seen_at: '', updated_at: '' };57}5859export function GraphWorkbench({ initial, root, mode: initialMode, depth: initialDepth, urlStyle = 'query', embedded = false }: { initial: GraphExplorePayload; root: { slug: string; name: string; entity_type: string }; mode: GraphExploreMode; depth: 1 | 2; /** `query` → /graph?node=…, `path` → /graph/<slug>?… */ urlStyle?: 'query' | 'path'; /** `/graph/[slug]`: shorter chrome, the page renders its own header. */ embedded?: boolean }) {60  const router = useRouter();61  const hrefFor = urlStyle === 'path' ? graphSlugHref : graphHref;62  const [mode, setMode] = useState<GraphExploreMode>(initialMode);63  const [depth, setDepth] = useState<1 | 2>(initialDepth);64  const [graph, setGraph] = useState<Graph>({ nodes: initial.nodes, edges: initial.edges, truncated: initial.truncated, counts: initial.counts, predicates: initial.predicates });65  const [expanded, setExpanded] = useState<Set<string>>(() => new Set([initial.root]));66  const [selected, setSelected] = useState<string | null>(null);67  const [hidden, setHidden] = useState<Set<string>>(new Set());68  const [pending, setPending] = useState<string | null>(null); // node id being expanded, or '*' for a full reload69  const [error, setError] = useState<string | null>(null);70  const [sheet, setSheet] = useState(false);71  const rootId = initial.root;72  const abort = useRef<AbortController | null>(null);7374  // reset when the server gives a new initial payload (new root)75  useEffect(() => {76    setGraph({ nodes: initial.nodes, edges: initial.edges, truncated: initial.truncated, counts: initial.counts, predicates: initial.predicates });77    setExpanded(new Set([initial.root]));78    setSelected(null);79    setMode(initialMode);80    setDepth(initialDepth);81  }, [initial, initialMode, initialDepth]);8283  const reload = useCallback(84    async (m: GraphExploreMode, d: 1 | 2) => {85      abort.current?.abort();86      const ctl = new AbortController();87      abort.current = ctl;88      setPending('*');89      setError(null);90      try {91        const g = await clientGraphExplore(root.slug, m, d, 150, ctl.signal);92        setGraph({ nodes: g.nodes, edges: g.edges, truncated: g.truncated, counts: g.counts, predicates: g.predicates });93        setExpanded(new Set([g.root]));94        setSelected(null);95        window.history.replaceState(null, '', hrefFor(root.slug, m, d));96      } catch (e) {97        if ((e as Error).name !== 'AbortError') setError('The graph service did not answer for this mode.');98      } finally {99        setPending(null);100      }101    },102    [root.slug, hrefFor],103  );104  const changeMode = (m: GraphExploreMode) => {105    setMode(m);106    void reload(m, depth);107  };108  const changeDepth = (d: 1 | 2) => {109    setDepth(d);110    void reload(mode, d);111  };112  const byId = useMemo(() => new Map(graph.nodes.map((n) => [n.id, n])), [graph.nodes]);113  const expand = useCallback(114    async (id: string) => {115      const n = byId.get(id);116      if (!n || expanded.has(id) || pending) return;117      setPending(id);118      setError(null);119      try {120        const g = await clientGraphExplore(n.slug, mode, 1, 80);121        setGraph((cur) => mergeGraph(cur, g, (x) => byId.get(x)?.level, n.level));122        setExpanded((cur) => new Set([...cur, id]));123      } catch {124        setError(`Could not expand ${n.name}.`);125      } finally {126        setPending(null);127      }128    },129    [byId, expanded, mode, pending],130  );131  const pick = (s: Suggestion) => router.push(hrefFor(s.slug, GRAPH_MODES.find((m) => m.rootTypes.includes(s.entity_type))?.mode ?? mode, depth));132  const select = (id: string | null) => {133    setSelected(id);134    if (id && typeof window !== 'undefined' && window.matchMedia('(max-width: 1023px)').matches) setSheet(true);135  };136137  const types = useMemo(() => Object.entries(countTypes(graph.nodes)).sort((a, b) => b[1] - a[1]), [graph.nodes]);138  const predicateCounts = useMemo(() => [...graph.edges.reduce((m, e) => m.set(e.predicate, (m.get(e.predicate) ?? 0) + 1), new Map<string, number>()).entries()].sort((a, b) => b[1] - a[1]), [graph.edges]);139  const shown = graph.nodes.filter((n) => !hidden.has(n.entity_type) || n.id === rootId).length;140  const apiNodes = Number(graph.counts?.nodes ?? graph.nodes.length);141  const current = (selected ? byId.get(selected) : null) ?? byId.get(rootId) ?? null;142143  const filters = (144    <div className="space-y-5 text-sm">145      <div>146        <p className="eyebrow mb-1.5">Root</p>147        <NodeSearch onPick={pick} />148        <p className="mt-1.5 flex items-center gap-1.5 text-xs text-ink-3">149          <EntityBadge type={root.entity_type} small /> <span className="truncate text-ink-2">{root.name}</span>150        </p>151      </div>152      <div>153        <p className="eyebrow mb-1.5">Mode</p>154        <ul className="space-y-px" role="radiogroup" aria-label="Graph mode">155          {GRAPH_MODES.map((m) => {156            const on = m.mode === mode;157            return (158              <li key={m.mode}>159                <button type="button" role="radio" aria-checked={on} onClick={() => changeMode(m.mode)} className={cn('flex min-h-9 w-full items-center justify-between gap-2 px-1.5 text-left text-[13px] uppercase tracking-wide', on ? 'bg-ink text-canvas' : 'text-ink-2 hover:bg-surface-2 hover:text-ink')} title={m.hint} data-graph-mode={m.mode}>160                  <span>{m.label}</span>161                </button>162              </li>163            );164          })}165        </ul>166      </div>167      <div>168        <p className="eyebrow mb-1.5">Depth</p>169        <div className="inline-flex border border-rule" role="group" aria-label="Depth">170          {([1, 2] as const).map((d) => (171            <button key={d} type="button" onClick={() => changeDepth(d)} aria-pressed={depth === d} className={cn('inline-flex h-10 items-center px-3 text-sm', d === 2 && 'border-l border-rule', depth === d ? 'bg-ink text-canvas' : 'text-ink-2 hover:text-ink')}>172              {d} hop{d === 2 ? 's' : ''}173            </button>174          ))}175        </div>176      </div>177      <div>178        <p className="eyebrow mb-1.5">Node types</p>179        <ul className="space-y-px">180          {types.map(([t, n]) => {181            const off = hidden.has(t);182            return (183              <li key={t}>184                <button185                  type="button"186                  aria-pressed={!off}187                  onClick={() => setHidden((cur) => {188                    const next = new Set(cur);189                    if (next.has(t)) next.delete(t);190                    else next.add(t);191                    return next;192                  })}193                  className={cn('flex min-h-9 w-full items-center justify-between gap-2 px-1.5 text-sm hover:bg-surface-2', off && 'opacity-50')}194                >195                  <span className="flex items-center gap-2">196                    <span className="inline-block size-2.5 rounded-full" style={{ background: colorOf(t) }} aria-hidden />197                    {typeLabel(t, n !== 1)}198                  </span>199                  <span className="tnum text-xs text-ink-3">{fmtInt(n)}</span>200                </button>201              </li>202            );203          })}204        </ul>205      </div>206      <div>207        <p className="eyebrow mb-1.5">Edge predicates</p>208        <ul className="space-y-0.5 text-xs">209          {predicateCounts.map(([p, n]) => (210            <li key={p} className="flex items-center justify-between gap-2">211              <span className="text-ink-2">{predicateLabel(p, 'out')}</span>212              <span className="tnum text-ink-3">{fmtInt(n)}</span>213            </li>214          ))}215          {predicateCounts.length === 0 && <li className="text-ink-3">No edges in this view.</li>}216        </ul>217        <p className="mt-1.5 text-[11px] text-ink-3">Dashed edges come from tier 3–4 sources.</p>218      </div>219    </div>220  );221222  const inspector = current ? (223    <Inspector n={current} rootId={rootId} graph={graph} byId={byId} expanded={expanded} pending={pending} onExpand={expand} onSelect={select} onFocus={(n) => router.push(hrefFor(n.slug, mode, depth))} />224  ) : (225    <p className="text-sm text-ink-3">Select a node to see its facts and links.</p>226  );227228  return (229    <TerminalLayout filters={filters} inspector={inspector} filtersTitle="Graph" inspectorTitle="Node" storageKey="aia-graph-inspector" filterCount={hidden.size}>230      <div className="space-y-2" data-graph-workbench>231        <div className="flex flex-wrap items-center gap-x-3 gap-y-1 text-xs text-ink-3">232          <span className="tnum" data-graph-count>233            {fmtInt(shown)}{shown !== graph.nodes.length ? ` of ${fmtInt(graph.nodes.length)}` : ''} nodes · {fmtInt(graph.edges.length)} edges234          </span>235          {graph.truncated && (236            <span className="text-warning">237              truncated — {fmtInt(apiNodes)} drawn, the neighbourhood is larger <Hint align="right" text="The API stops at the node limit (150) and reports truncated: true. Expand a node or narrow the mode to see the rest." />238            </span>239          )}240          {pending === '*' && <span className="text-accent">Loading…</span>}241          {error && <span className="text-danger">{error}</span>}242          {!embedded && (243            <span className="ml-auto hidden md:inline">244              <Link href={routes.entity(root)} className="link">245                Open {root.name} →246              </Link>247            </span>248          )}249        </div>250        <GraphCanvas nodes={graph.nodes} edges={graph.edges} rootId={rootId} selectedId={selected} onSelect={select} onExpand={expand} expanded={expanded} loadingId={pending && pending !== '*' ? pending : null} hiddenTypes={hidden} tall />251        {/* accessible text fallback */}252        <details className="text-sm">253          <summary className="cursor-pointer py-2 text-xs text-ink-3 hover:text-ink">Every node, as a list ({fmtInt(graph.nodes.length)})</summary>254          <ul className="grid gap-x-6 sm:grid-cols-2 lg:grid-cols-3" data-graph-list>255            {graph.nodes.map((n) => (256              <li key={n.id} className="flex items-center gap-2 border-b border-rule py-1.5">257                <EntityBadge type={n.entity_type} small />258                <Link href={routes.entity(n)} className="truncate text-ink hover:text-accent">259                  {n.name}260                </Link>261                {n.org && <span className="ml-auto shrink-0 text-xs text-ink-3">{n.org}</span>}262              </li>263            ))}264          </ul>265        </details>266      </div>267      <Sheet open={sheet} onClose={() => setSheet(false)} side="bottom" eyebrow="Node" title={current?.name ?? 'Node'}>268        {inspector}269      </Sheet>270    </TerminalLayout>271  );272}273274/* -------------------------------------------------------------------------------------------------------- inspector */275function Inspector({ n, rootId, graph, byId, expanded, pending, onExpand, onSelect, onFocus }: { n: ExploreNode; rootId: string; graph: Graph; byId: Map<string, ExploreNode>; expanded: Set<string>; pending: string | null; onExpand: (id: string) => void; onSelect: (id: string) => void; onFocus: (n: ExploreNode) => void }) {276  const summary = asSummary(n);277  const attrs = keyAttributes(summary);278  const groups = new Map<string, { node: ExploreNode; dir: 'out' | 'in' }[]>();279  for (const e of graph.edges) {280    if (e.source !== n.id && e.target !== n.id) continue;281    const out = e.source === n.id;282    const other = byId.get(out ? e.target : e.source);283    if (!other) continue;284    const key = `${e.predicate}|${out ? 'out' : 'in'}`;285    (groups.get(key) ?? groups.set(key, []).get(key)!).push({ node: other, dir: out ? 'out' : 'in' });286  }287  const degree = [...groups.values()].reduce((s, g) => s + g.length, 0);288  const isRoot = n.id === rootId;289  const canExpand = !isRoot && !expanded.has(n.id);290  return (291    <div className="space-y-4 text-sm" data-graph-inspector>292      <div>293        <div className="flex flex-wrap items-center gap-1.5">294          <EntityBadge type={n.entity_type} small />295          {n.artifact_kind && <span className="text-[11px] uppercase tracking-wide text-ink-3">{n.artifact_kind}</span>}296          {isRoot ? <span className="text-[11px] uppercase tracking-wide text-ink-3">root</span> : <span className="tnum text-[11px] text-ink-3">{n.level} hop{n.level === 1 ? '' : 's'} from root</span>}297        </div>298        <p className="mt-1 text-base font-semibold leading-tight text-ink">{n.name}</p>299        {n.org && n.org_slug && (300          <Link href={routes.entity({ entity_type: 'company', slug: n.org_slug })} className="text-xs text-ink-2 hover:text-accent">301            {n.org}302          </Link>303        )}304      </div>305      {attrs.length > 0 && (306        <dl className="kv [&>div]:py-1">307          {attrs.map((a) => (308            <div key={a.label}>309              <dt className="capitalize">{a.label}</dt>310              <dd className="tnum text-ink">{a.value}</dd>311            </div>312          ))}313        </dl>314      )}315      <div className="flex flex-wrap gap-1.5">316        <Link href={routes.entity(n)} className="inline-flex h-9 items-center gap-1.5 border border-rule px-2.5 text-sm text-ink-2 hover:border-rule-strong hover:text-ink">317          <ExternalLink className="size-3.5" aria-hidden /> Open page318        </Link>319        <button type="button" onClick={() => onExpand(n.id)} disabled={!canExpand || !!pending} className="inline-flex h-9 items-center gap-1.5 border border-rule px-2.5 text-sm text-ink-2 hover:border-rule-strong hover:text-ink disabled:opacity-40" data-graph-expand>320          <Maximize2 className="size-3.5" aria-hidden /> {expanded.has(n.id) ? 'Expanded' : pending === n.id ? 'Expanding…' : 'Expand'}321        </button>322        {!isRoot && (323          <button type="button" onClick={() => onFocus(n)} className="inline-flex h-9 items-center gap-1.5 border border-rule px-2.5 text-sm text-ink-2 hover:border-rule-strong hover:text-ink">324            <Focus className="size-3.5" aria-hidden /> Make root325          </button>326        )}327        <CompareButton e={summary} size="sm" />328        <WatchButton e={summary} size="sm" />329      </div>330      <div>331        <p className="eyebrow mb-1.5">332          Links in view <span className="tnum normal-case tracking-normal text-ink-3">{fmtInt(degree)}</span>333        </p>334        {groups.size === 0 ? (335          <p className="text-xs text-ink-3">No edges to this node in the current view{canExpand ? ' — expand it to load its neighbourhood' : ''}.</p>336        ) : (337          <dl className="kv [&>div]:py-1">338            {[...groups.entries()].map(([key, items]) => {339              const [pred, dir] = key.split('|') as [string, 'out' | 'in'];340              return (341                <div key={key}>342                  <dt>343                    {predicateLabel(pred, dir)} <span className="tnum text-ink-3">{items.length}</span>344                  </dt>345                  <dd className="flex flex-wrap gap-x-2 gap-y-0.5">346                    {items.slice(0, 12).map(({ node }) => (347                      <button key={node.id} type="button" onClick={() => onSelect(node.id)} className="inline-flex items-center gap-1 text-left text-ink hover:text-accent">348                        <span className="inline-block size-2 rounded-full" style={{ background: colorOf(node.entity_type) }} aria-hidden />349                        <span className="truncate">{node.name}</span>350                      </button>351                    ))}352                    {items.length > 12 && <span className="text-xs text-ink-3">+{items.length - 12}</span>}353                  </dd>354                </div>355              );356            })}357          </dl>358        )}359      </div>360      <p className="flex items-center gap-1 text-[11px] text-ink-3">361        <GitFork className="size-3" aria-hidden /> Edges are stated relations only (developer, provider, benchmark, paper…) — nothing is inferred.362      </p>363    </div>364  );365}366