'use client'; import { ExternalLink, Focus, GitFork, Maximize2 } from 'lucide-react'; import Link from 'next/link'; import { useRouter } from 'next/navigation'; import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { CompareButton } from '@/components/compare/compare-button'; import { TerminalLayout } from '@/components/layout/terminal'; import { EntityBadge } from '@/components/ui/badges'; import { keyAttributes } from '@/components/ui/entity'; import { Hint } from '@/components/ui/hint'; import { Sheet } from '@/components/ui/sheet'; import { WatchButton } from '@/components/watchlist/watch-button'; import { cn } from '@/lib/cn'; import { clientGraphExplore } from '@/lib/client-api'; import { fmtInt } from '@/lib/format'; import { predicateLabel, routes, typeLabel } from '@/lib/site'; import type { EntitySummary, ExploreEdge, ExploreNode, GraphExploreMode, GraphExplorePayload, Suggestion } from '@/lib/types'; import { colorOf, GraphCanvas } from './graph-canvas'; import { GRAPH_MODES, graphHref, graphSlugHref } from './modes'; import { NodeSearch } from './node-search'; /* Graph workbench (client): TerminalLayout with the mode/depth/type filters in the rail, the canvas in the middle, the selected node in the inspector. Mode and depth changes re-fetch `/graph/explore` client-side and mirror the URL with `history.replaceState`; a new root navigates (server render, shareable URL). Expansion merges `/graph/explore?depth=1` for the clicked node into the current graph. On small screens the inspector opens as a bottom sheet on selection. */ type Graph = { nodes: ExploreNode[]; edges: ExploreEdge[]; truncated: boolean; counts: GraphExplorePayload['counts']; predicates: string[] }; function mergeGraph(base: Graph, add: GraphExplorePayload, hasLevel: (id: string) => number | undefined, viaLevel: number): Graph { const nodes = [...base.nodes]; const seen = new Set(nodes.map((n) => n.id)); for (const n of add.nodes) { if (seen.has(n.id)) continue; seen.add(n.id); nodes.push({ ...n, level: viaLevel + n.level }); } const ek = new Set(base.edges.map((e) => `${e.source}>${e.target}:${e.predicate}`)); const edges = [...base.edges]; for (const e of add.edges) { const k = `${e.source}>${e.target}:${e.predicate}`; if (ek.has(k)) continue; ek.add(k); edges.push(e); } void hasLevel; 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])] }; } function countTypes(nodes: ExploreNode[]): Record { const out: Record = {}; for (const n of nodes) out[n.entity_type] = (out[n.entity_type] ?? 0) + 1; return out; } function asSummary(n: ExploreNode): EntitySummary { 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: '' }; } export 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/?… */ urlStyle?: 'query' | 'path'; /** `/graph/[slug]`: shorter chrome, the page renders its own header. */ embedded?: boolean }) { const router = useRouter(); const hrefFor = urlStyle === 'path' ? graphSlugHref : graphHref; const [mode, setMode] = useState(initialMode); const [depth, setDepth] = useState<1 | 2>(initialDepth); const [graph, setGraph] = useState({ nodes: initial.nodes, edges: initial.edges, truncated: initial.truncated, counts: initial.counts, predicates: initial.predicates }); const [expanded, setExpanded] = useState>(() => new Set([initial.root])); const [selected, setSelected] = useState(null); const [hidden, setHidden] = useState>(new Set()); const [pending, setPending] = useState(null); // node id being expanded, or '*' for a full reload const [error, setError] = useState(null); const [sheet, setSheet] = useState(false); const rootId = initial.root; const abort = useRef(null); // reset when the server gives a new initial payload (new root) useEffect(() => { setGraph({ nodes: initial.nodes, edges: initial.edges, truncated: initial.truncated, counts: initial.counts, predicates: initial.predicates }); setExpanded(new Set([initial.root])); setSelected(null); setMode(initialMode); setDepth(initialDepth); }, [initial, initialMode, initialDepth]); const reload = useCallback( async (m: GraphExploreMode, d: 1 | 2) => { abort.current?.abort(); const ctl = new AbortController(); abort.current = ctl; setPending('*'); setError(null); try { const g = await clientGraphExplore(root.slug, m, d, 150, ctl.signal); setGraph({ nodes: g.nodes, edges: g.edges, truncated: g.truncated, counts: g.counts, predicates: g.predicates }); setExpanded(new Set([g.root])); setSelected(null); window.history.replaceState(null, '', hrefFor(root.slug, m, d)); } catch (e) { if ((e as Error).name !== 'AbortError') setError('The graph service did not answer for this mode.'); } finally { setPending(null); } }, [root.slug, hrefFor], ); const changeMode = (m: GraphExploreMode) => { setMode(m); void reload(m, depth); }; const changeDepth = (d: 1 | 2) => { setDepth(d); void reload(mode, d); }; const byId = useMemo(() => new Map(graph.nodes.map((n) => [n.id, n])), [graph.nodes]); const expand = useCallback( async (id: string) => { const n = byId.get(id); if (!n || expanded.has(id) || pending) return; setPending(id); setError(null); try { const g = await clientGraphExplore(n.slug, mode, 1, 80); setGraph((cur) => mergeGraph(cur, g, (x) => byId.get(x)?.level, n.level)); setExpanded((cur) => new Set([...cur, id])); } catch { setError(`Could not expand ${n.name}.`); } finally { setPending(null); } }, [byId, expanded, mode, pending], ); const pick = (s: Suggestion) => router.push(hrefFor(s.slug, GRAPH_MODES.find((m) => m.rootTypes.includes(s.entity_type))?.mode ?? mode, depth)); const select = (id: string | null) => { setSelected(id); if (id && typeof window !== 'undefined' && window.matchMedia('(max-width: 1023px)').matches) setSheet(true); }; const types = useMemo(() => Object.entries(countTypes(graph.nodes)).sort((a, b) => b[1] - a[1]), [graph.nodes]); const predicateCounts = useMemo(() => [...graph.edges.reduce((m, e) => m.set(e.predicate, (m.get(e.predicate) ?? 0) + 1), new Map()).entries()].sort((a, b) => b[1] - a[1]), [graph.edges]); const shown = graph.nodes.filter((n) => !hidden.has(n.entity_type) || n.id === rootId).length; const apiNodes = Number(graph.counts?.nodes ?? graph.nodes.length); const current = (selected ? byId.get(selected) : null) ?? byId.get(rootId) ?? null; const filters = (

Root

{root.name}

Mode

    {GRAPH_MODES.map((m) => { const on = m.mode === mode; return (
  • ); })}

Depth

{([1, 2] as const).map((d) => ( ))}

Node types

    {types.map(([t, n]) => { const off = hidden.has(t); return (
  • ); })}

Edge predicates

    {predicateCounts.map(([p, n]) => (
  • {predicateLabel(p, 'out')} {fmtInt(n)}
  • ))} {predicateCounts.length === 0 &&
  • No edges in this view.
  • }

Dashed edges come from tier 3–4 sources.

); const inspector = current ? ( router.push(hrefFor(n.slug, mode, depth))} /> ) : (

Select a node to see its facts and links.

); return (
{fmtInt(shown)}{shown !== graph.nodes.length ? ` of ${fmtInt(graph.nodes.length)}` : ''} nodes · {fmtInt(graph.edges.length)} edges {graph.truncated && ( truncated — {fmtInt(apiNodes)} drawn, the neighbourhood is larger )} {pending === '*' && Loading…} {error && {error}} {!embedded && ( Open {root.name} → )}
{/* accessible text fallback */}
Every node, as a list ({fmtInt(graph.nodes.length)})
    {graph.nodes.map((n) => (
  • {n.name} {n.org && {n.org}}
  • ))}
setSheet(false)} side="bottom" eyebrow="Node" title={current?.name ?? 'Node'}> {inspector}
); } /* -------------------------------------------------------------------------------------------------------- inspector */ function Inspector({ n, rootId, graph, byId, expanded, pending, onExpand, onSelect, onFocus }: { n: ExploreNode; rootId: string; graph: Graph; byId: Map; expanded: Set; pending: string | null; onExpand: (id: string) => void; onSelect: (id: string) => void; onFocus: (n: ExploreNode) => void }) { const summary = asSummary(n); const attrs = keyAttributes(summary); const groups = new Map(); for (const e of graph.edges) { if (e.source !== n.id && e.target !== n.id) continue; const out = e.source === n.id; const other = byId.get(out ? e.target : e.source); if (!other) continue; const key = `${e.predicate}|${out ? 'out' : 'in'}`; (groups.get(key) ?? groups.set(key, []).get(key)!).push({ node: other, dir: out ? 'out' : 'in' }); } const degree = [...groups.values()].reduce((s, g) => s + g.length, 0); const isRoot = n.id === rootId; const canExpand = !isRoot && !expanded.has(n.id); return (
{n.artifact_kind && {n.artifact_kind}} {isRoot ? root : {n.level} hop{n.level === 1 ? '' : 's'} from root}

{n.name}

{n.org && n.org_slug && ( {n.org} )}
{attrs.length > 0 && (
{attrs.map((a) => (
{a.label}
{a.value}
))}
)}
Open page {!isRoot && ( )}

Links in view {fmtInt(degree)}

{groups.size === 0 ? (

No edges to this node in the current view{canExpand ? ' — expand it to load its neighbourhood' : ''}.

) : (
{[...groups.entries()].map(([key, items]) => { const [pred, dir] = key.split('|') as [string, 'out' | 'in']; return (
{predicateLabel(pred, dir)} {items.length}
{items.slice(0, 12).map(({ node }) => ( ))} {items.length > 12 && +{items.length - 12}}
); })}
)}

Edges are stated relations only (developer, provider, benchmark, paper…) — nothing is inferred.

); }