type FlowTone = "graph" | "text" | "compiler" | "human"; type FlowNodeSpec = { id: string; label: string; x: number; y: number; tone?: FlowTone; }; type FlowEdgeSpec = { id?: string; source: string; target: string; label?: string; }; export type FlowChartSpec = { type: "flow"; title?: string; height?: number; nodes: FlowNodeSpec[]; edges: FlowEdgeSpec[]; }; // x/y in the spec are the top-left of each node, in px. const NODE_W = 224; const NODE_H = 72; const PAD = 80; const TONE_CLASS: Record = { // faint, "old" loop text: "border border-border bg-bg text-muted", // the enforcer: strong inverted compiler: "bg-fg text-bg", // the good path: clean and present graph: "border border-fg/40 bg-surface text-fg", // human review human: "border border-border bg-surface-muted text-fg", }; export function FlowChart({ spec }: { spec: FlowChartSpec }) { const byId = new Map(spec.nodes.map((n) => [n.id, n])); const maxX = Math.max(0, ...spec.nodes.map((n) => n.x)); const maxY = Math.max(0, ...spec.nodes.map((n) => n.y)); const width = PAD * 2 + maxX + NODE_W; const height = Math.max(spec.height ?? 0, PAD * 2 + maxY + NODE_H); const cx = (n: FlowNodeSpec) => PAD + n.x + NODE_W / 2; const topY = (n: FlowNodeSpec) => PAD + n.y; const bottomY = (n: FlowNodeSpec) => PAD + n.y + NODE_H; const edges = spec.edges .map((edge, i) => { const s = byId.get(edge.source); const t = byId.get(edge.target); if (!s || !t) return null; const loopBack = t.y <= s.y; let path: string; let labelX: number; let labelY: number; if (loopBack) { const leftEdge = Math.min(PAD + s.x, PAD + t.x); const sy = PAD + s.y + NODE_H / 2; const ty = PAD + t.y + NODE_H / 2; const bulge = leftEdge - 40; path = `M ${leftEdge} ${sy} C ${bulge} ${sy}, ${bulge} ${ty}, ${leftEdge} ${ty}`; labelX = bulge; labelY = (sy + ty) / 2; } else { const sx = cx(s); const sy = bottomY(s); const tx = cx(t); const ty = topY(t); const mid = (sy + ty) / 2; path = `M ${sx} ${sy} C ${sx} ${mid}, ${tx} ${mid}, ${tx} ${ty}`; labelX = (sx + tx) / 2; labelY = mid; } return { key: edge.id ?? `${edge.source}-${edge.target}-${i}`, path, label: edge.label, labelX, labelY, }; }) .filter((e): e is NonNullable => e !== null); // Only show a connection dot where an edge actually attaches. Normal edges // attach to the source's bottom and the target's top; loop-backs attach to // the side, so they do not light up a top/bottom dot. const topConn = new Set(); const bottomConn = new Set(); for (const edge of spec.edges) { const s = byId.get(edge.source); const t = byId.get(edge.target); if (!s || !t || t.y <= s.y) continue; bottomConn.add(s.id); topConn.add(t.id); } const stroke = "color-mix(in srgb, var(--color-fg) 28%, transparent)"; const dot = "color-mix(in srgb, var(--color-fg) 40%, transparent)"; return (
{spec.title ? (
{spec.title}
) : null}
{/* Scales down to fit the column; never upscales past its natural size. */} {edges.map((e) => ( ))} {spec.nodes.map((n) => ( {topConn.has(n.id) && } {bottomConn.has(n.id) && } ))} {edges .filter((e) => e.label) .map((e) => (
{e.label}
))} {spec.nodes.map((n) => (
{n.label}
))}
); }