import Link from 'next/link'; import { predicateLabel } from '@/lib/site'; /* Compact layered SVG graph for lineage edges among a known set of nodes (family members): roots left, derived models to the right. Server-rendered, links on nodes, accessible edge list. For the interactive explorer use /graph. */ export type MiniNode = { id: string; label: string; href?: string; sub?: string }; export type MiniEdge = { source: string; target: string; predicate: string }; const W = 720; const BOX_W = 168; const BOX_H = 26; const ROW = 32; const PAD = 8; const trunc = (s: string, n: number) => (s.length > n ? `${s.slice(0, n - 1)}…` : s); export function MiniGraph({ nodes, edges, className, title = 'Lineage' }: { nodes: MiniNode[]; edges: MiniEdge[]; className?: string; title?: string }) { const ids = new Set(nodes.map((n) => n.id)); const E = edges.filter((e) => ids.has(e.source) && ids.has(e.target)); if (!E.length) return

No lineage relation recorded among these members.

; const involved = new Set(E.flatMap((e) => [e.source, e.target])); const N = nodes.filter((n) => involved.has(n.id)); // depth = longest chain from a root; edges point source (derived) → target (base): the base is upstream, so depth(source) = depth(target) + 1 const depth = new Map(); const upstream = new Map(); for (const e of E) upstream.set(e.source, [...(upstream.get(e.source) ?? []), e.target]); const visit = (id: string, seen: Set): number => { if (depth.has(id)) return depth.get(id) as number; if (seen.has(id)) return 0; seen.add(id); const ups = upstream.get(id) ?? []; const d = ups.length ? 1 + Math.max(...ups.map((u) => visit(u, seen))) : 0; depth.set(id, d); return d; }; for (const n of N) visit(n.id, new Set()); const maxD = Math.max(...N.map((n) => depth.get(n.id) ?? 0)); const cols = Array.from({ length: maxD + 1 }, () => [] as MiniNode[]); for (const n of N) cols[depth.get(n.id) ?? 0]!.push(n); const rows = Math.max(...cols.map((c) => c.length)); const H = PAD * 2 + rows * ROW; const colX = (d: number) => (maxD === 0 ? W / 2 - BOX_W / 2 : PAD + (d * (W - PAD * 2 - BOX_W)) / maxD); const pos = new Map(); cols.forEach((c, d) => c.forEach((n, i) => pos.set(n.id, { x: colX(d), y: PAD + ((H - PAD * 2) / Math.max(1, c.length)) * (i + 0.5) - BOX_H / 2 }))); const tooMany = N.length > 40; return (
{title} {E.map((e, i) => { const a = pos.get(e.target); const b = pos.get(e.source); if (!a || !b) return null; const x1 = a.x + BOX_W; const y1 = a.y + BOX_H / 2; const x2 = b.x; const y2 = b.y + BOX_H / 2; return ( {`${nodes.find((n) => n.id === e.source)?.label} ${predicateLabel(e.predicate, 'out').toLowerCase()} ${nodes.find((n) => n.id === e.target)?.label}`} ); })} {N.map((n) => { const p = pos.get(n.id)!; const body = ( {trunc(n.label, 26)} {n.sub && ( {trunc(n.sub, 30)} )} {n.label} ); return n.href ? ( {body} ) : ( {body} ); })} {tooMany &&

Large family — open the interactive graph for the full picture.

}
    {E.map((e, i) => (
  • n.id === e.source)?.href ?? '#'}>{nodes.find((n) => n.id === e.source)?.label} {predicateLabel(e.predicate, 'out').toLowerCase()} n.id === e.target)?.href ?? '#'}>{nodes.find((n) => n.id === e.target)?.label}
  • ))}
); }