HTML 77.2%
TypeScript 10.5%
Python 9.6%
JavaScript 2.5%
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