"use client"; import { useMemo } from "react"; import ReactFlow, { Edge, Handle, MarkerType, Node, NodeProps, Position, ReactFlowProvider, } from "reactflow"; import "reactflow/dist/style.css"; import "./ProgramGraph.css"; import { ReprNode, parseProgramRepr } from "../lib/programRepr"; // Palette tokens (must match tailwind.config.ts). const C = { bg: "#FAFAF7", card: "#FCFBF8", ink: "#23303A", muted: "#6E7F8C", border: "#ECEAE4", accent: "#3A6B7E", highlight: "#BC6B2E", matrix: "#F1EFEA", score: "#FBF1E6", }; export type GenePill = { id: string; symbol?: string | null; matched?: boolean }; interface ProgramGraphProps { programRepr: string; outputLabel?: string; /** opaque_id -> { symbol, matched } once revealed. */ reveal?: Record; } // ---------- custom node components ---------------------------------------- function DataPillNode({ data }: NodeProps<{ label: string }>) { return (
{data.label}
); } function ScorePillNode({ data }: NodeProps<{ label: string }>) { return (
{data.label}
); } function VerbNode({ data, }: NodeProps<{ label: string; sub?: string; pills?: GenePill[] }>) { return (
{data.label}
{data.sub && (
{data.sub}
)} {data.pills && data.pills.length > 0 && (
{data.pills.map((p) => { const display = p.symbol ?? p.id; const matched = p.matched; return ( {display} ); })}
)}
); } function OutputNode({ data }: NodeProps<{ label: string }>) { return (
{data.label}
); } function TierGroupNode({ data, }: NodeProps<{ label: string; variant: "tier1" | "tier2"; width: number; height: number }>) { const isTier2 = data.variant === "tier2"; return (
{data.label}
); } const nodeTypes = { dataPill: DataPillNode, scorePill: ScorePillNode, verb: VerbNode, output: OutputNode, tierGroup: TierGroupNode, }; // ---------- layout -------------------------------------------------------- const COL_W = 200; // horizontal spacing between layers const ROW_H = 95; // vertical spacing between sibling lanes interface Built { nodes: Node[]; edges: Edge[]; width: number; height: number; } interface Counter { id: number; } function nid(c: Counter, prefix: string): string { c.id += 1; return `${prefix}${c.id}`; } function pillsFor( features: string[], reveal?: Record, ): GenePill[] { return features.map((id) => { const r = reveal?.[id]; return r ? { id, symbol: r.symbol, matched: r.matched } : { id }; }); } /** Recursively build nodes/edges for a subtree rooted at ``tree``. * Returns the SubLayout — its own width / height bounding box and the * (x,y) of its right-output anchor (where the parent connects). */ type SubLayout = { rootId: string; width: number; // span in x height: number; // span in y anchorY: number; // vertical centre relative to the layout }; function buildSubtree( tree: ReprNode, reveal: ProgramGraphProps["reveal"], c: Counter, out: { nodes: Node[]; edges: Edge[] }, xOffset: number, yOffset: number, ): SubLayout { if (tree.kind === "M") { const id = nid(c, "M"); out.nodes.push({ id, type: "dataPill", position: { x: xOffset, y: yOffset }, data: { label: "Expression matrix" }, draggable: false, }); return { rootId: id, width: COL_W, height: ROW_H, anchorY: yOffset + 18 }; } if (tree.kind === "Select") { const child = buildSubtree(tree.matrix, reveal, c, out, xOffset, yOffset); const id = nid(c, "Sel"); const x = xOffset + child.width; const y = child.anchorY - 18; out.nodes.push({ id, type: "verb", position: { x, y }, data: { label: "Select", sub: `${tree.features.length} gene${tree.features.length === 1 ? "" : "s"}`, pills: pillsFor(tree.features, reveal), }, draggable: false, }); out.edges.push(edge(`${child.rootId}->${id}`, child.rootId, id)); const width = child.width + COL_W; return { rootId: id, width, height: Math.max(child.height, ROW_H), anchorY: child.anchorY, }; } if (tree.kind === "Reduce") { const child = buildSubtree(tree.matrix, reveal, c, out, xOffset, yOffset); const id = nid(c, "Red"); const x = xOffset + child.width; const y = child.anchorY - 18; out.nodes.push({ id, type: "verb", position: { x, y }, data: { label: "Reduce", sub: tree.agg }, draggable: false, }); out.edges.push(edge(`${child.rootId}->${id}`, child.rootId, id)); return { rootId: id, width: child.width + COL_W, height: Math.max(child.height, ROW_H), anchorY: child.anchorY, }; } if (tree.kind === "Combine") { const left = buildSubtree(tree.left, reveal, c, out, xOffset, yOffset); const right = buildSubtree( tree.right, reveal, c, out, xOffset, yOffset + left.height + 20, ); const id = nid(c, "Cmb"); const maxChildWidth = Math.max(left.width, right.width); const x = xOffset + maxChildWidth; const centre = (left.anchorY + right.anchorY) / 2; out.nodes.push({ id, type: "verb", position: { x, y: centre - 18 }, data: { label: "Combine", sub: tree.op }, draggable: false, }); out.edges.push(edge(`${left.rootId}->${id}`, left.rootId, id)); out.edges.push(edge(`${right.rootId}->${id}`, right.rootId, id)); return { rootId: id, width: maxChildWidth + COL_W, height: left.height + right.height + 20, anchorY: centre, }; } if (tree.kind === "Search") { const child = buildSubtree(tree.matrix, reveal, c, out, xOffset, yOffset); const id = nid(c, "Srch"); const x = xOffset + child.width; const y = child.anchorY - 18; out.nodes.push({ id, type: "verb", position: { x, y }, data: { label: "Search", sub: `top-${tree.k} (bounded)` }, draggable: false, }); out.edges.push(edge(`${child.rootId}->${id}`, child.rootId, id)); return { rootId: id, width: child.width + COL_W, height: Math.max(child.height, ROW_H), anchorY: child.anchorY, }; } if (tree.kind === "Split") { const child = buildSubtree(tree.inner, reveal, c, out, xOffset, yOffset); const id = nid(c, "Splt"); const x = xOffset + child.width; const y = child.anchorY - 18; out.nodes.push({ id, type: "verb", position: { x, y }, data: { label: "Split", sub: tree.predicate }, draggable: false, }); out.edges.push(edge(`${child.rootId}->${id}`, child.rootId, id)); return { rootId: id, width: child.width + COL_W, height: Math.max(child.height, ROW_H), anchorY: child.anchorY, }; } if (tree.kind === "FitApply") { const child = buildSubtree(tree.inner, reveal, c, out, xOffset, yOffset); const id = nid(c, "FitA"); const x = xOffset + child.width; const y = child.anchorY - 18; out.nodes.push({ id, type: "verb", position: { x, y }, data: { label: "Fit → Apply", sub: `target: ${tree.target}` }, draggable: false, }); out.edges.push(edge(`${child.rootId}->${id}`, child.rootId, id)); return { rootId: id, width: child.width + COL_W, height: Math.max(child.height, ROW_H), anchorY: child.anchorY, }; } if (tree.kind === "Associate") { const child = buildSubtree(tree.inner, reveal, c, out, xOffset, yOffset); const id = nid(c, "Assoc"); const x = xOffset + child.width; const y = child.anchorY - 18; out.nodes.push({ id, type: "verb", position: { x, y }, data: { label: "Associate", sub: `${tree.assocKind} · target: ${tree.target}`, }, draggable: false, }); out.edges.push(edge(`${child.rootId}->${id}`, child.rootId, id)); return { rootId: id, width: child.width + COL_W, height: Math.max(child.height, ROW_H), anchorY: child.anchorY, }; } if (tree.kind === "Effect") { const child = buildSubtree(tree.inner, reveal, c, out, xOffset, yOffset); const id = nid(c, "Eff"); const x = xOffset + child.width; const y = child.anchorY - 18; out.nodes.push({ id, type: "verb", position: { x, y }, data: { label: "Effect", sub: `${tree.kind} · adjust: stage, age`, }, draggable: false, }); out.edges.push(edge(`${child.rootId}->${id}`, child.rootId, id)); return { rootId: id, width: child.width + COL_W, height: Math.max(child.height, ROW_H), anchorY: child.anchorY, }; } if (tree.kind === "Fit") { // Legacy v1 fallback: render each child subtree, then a "Fit" verb, // then the output node. const stacks: SubLayout[] = []; let yCursor = yOffset; for (const child of tree.children) { const lay = buildSubtree(child, reveal, c, out, xOffset, yCursor); stacks.push(lay); yCursor += lay.height + 20; } const childMaxWidth = Math.max(...stacks.map((s) => s.width)); const fitId = nid(c, "Fit"); const centre = stacks.reduce((a, s) => a + s.anchorY, 0) / Math.max(stacks.length, 1); out.nodes.push({ id: fitId, type: "verb", position: { x: xOffset + childMaxWidth, y: centre - 18 }, data: { label: "Fit", sub: tree.output || "target" }, draggable: false, }); for (const s of stacks) { out.edges.push(edge(`${s.rootId}->${fitId}`, s.rootId, fitId)); } return { rootId: fitId, width: childMaxWidth + COL_W, height: stacks.reduce((a, s) => a + s.height + 20, 0), anchorY: centre, }; } // Unknown — render as a muted box. const id = nid(c, "U"); out.nodes.push({ id, type: "verb", position: { x: xOffset, y: yOffset }, data: { label: "?", sub: tree.text.slice(0, 32) }, draggable: false, }); return { rootId: id, width: COL_W, height: ROW_H, anchorY: yOffset + 18 }; } function edge(id: string, source: string, target: string): Edge { return { id, source, target, type: "smoothstep", style: { stroke: "#B9B6AE", strokeWidth: 1.4 }, markerEnd: { type: MarkerType.ArrowClosed, color: "#B9B6AE", width: 14, height: 14, }, }; } function buildGraph(props: ProgramGraphProps): Built { const out = { nodes: [] as Node[], edges: [] as Edge[] }; const c: Counter = { id: 0 }; let tree: ReprNode; try { tree = parseProgramRepr(props.programRepr); } catch (e) { out.nodes.push({ id: "err", type: "verb", position: { x: 20, y: 20 }, data: { label: "couldn't parse program", sub: String(e) }, draggable: false, }); return { ...out, width: 320, height: 120 }; } if (tree.kind === "Combine") { return buildCombineRoot(tree, props, c, out); } const lay = buildSubtree(tree, props.reveal, c, out, 20, 20); // Append a terminal output pill for typed (engine_v2) trees that end in // a Vector but have no explicit output node. The legacy Fit root path // already adds a "Fit" verb labelled with the output, so we skip there. if (tree.kind !== "Fit") { const id = nid(c, "Out"); const x = lay.width + 40; const y = lay.anchorY - 14; out.nodes.push({ id, type: "output", position: { x, y }, data: { label: props.outputLabel ?? "score" }, draggable: false, }); out.edges.push(edge(`${lay.rootId}->${id}`, lay.rootId, id)); return { ...out, width: lay.width + COL_W + 40, height: lay.height + 60 }; } return { ...out, width: lay.width + 40, height: lay.height + 60 }; } // ---------- Combine root: Tier-1 boxes per side + Tier-2 dashed wrap ----- const SUBTREE_X = 40; // left padding inside Tier-2 wrapper const SUBTREE_Y0 = 44; // top padding (room for Tier-2 label) const TIER_GAP = 56; // vertical gap between Tier-1 groups const TIER_PAD = 18; // padding around node bboxes inside Tier-1 const TIER_LABEL_H = 22; // visual room for "Tier-1" label function buildCombineRoot( tree: ReprNode & { kind: "Combine" }, props: ProgramGraphProps, c: Counter, out: { nodes: Node[]; edges: Edge[] }, ): Built { const leftStart = out.nodes.length; const left = buildSubtree(tree.left, props.reveal, c, out, SUBTREE_X, SUBTREE_Y0); const leftRange: [number, number] = [leftStart, out.nodes.length]; const rightStart = out.nodes.length; const rightY = SUBTREE_Y0 + left.height + TIER_GAP; const right = buildSubtree(tree.right, props.reveal, c, out, SUBTREE_X, rightY); const rightRange: [number, number] = [rightStart, out.nodes.length]; const leftBbox = bboxFromNodes(out.nodes, leftRange); const rightBbox = bboxFromNodes(out.nodes, rightRange); // Tier-1 boxes are positioned to contain each side's bbox. const leftTier = boxForBbox(leftBbox, "Tier-1", "tier1"); const rightTier = boxForBbox(rightBbox, "Tier-1", "tier1"); // Place Combine + Output to the right of the wider subtree. const subtreeMaxRight = Math.max(leftTier.maxX, rightTier.maxX); const cmbX = subtreeMaxRight + 30; const centreY = (left.anchorY + right.anchorY) / 2; const cmbId = nid(c, "Cmb"); out.nodes.push({ id: cmbId, type: "verb", position: { x: cmbX, y: centreY - 18 }, data: { label: "Combine", sub: tree.op }, draggable: false, }); out.edges.push(edge(`${left.rootId}->${cmbId}`, left.rootId, cmbId)); out.edges.push(edge(`${right.rootId}->${cmbId}`, right.rootId, cmbId)); const outX = cmbX + COL_W; const outId = nid(c, "Out"); out.nodes.push({ id: outId, type: "output", position: { x: outX, y: centreY - 14 }, data: { label: props.outputLabel ?? "score" }, draggable: false, }); out.edges.push(edge(`${cmbId}->${outId}`, cmbId, outId)); // Tier-2 dashed wrap around EVERYTHING (left + right + Combine + Output). const allBbox = unionBboxes([ leftTier, rightTier, { minX: cmbX, minY: centreY - 22, maxX: outX + 170, maxY: centreY + 22 }, ]); const tier2 = boxForBbox(allBbox, "Tier-2 program", "tier2"); // Insert tier-group nodes at the START so they render BEHIND everything. out.nodes.unshift(tierGroupNode(c, leftTier, "Tier-1", "tier1")); out.nodes.unshift(tierGroupNode(c, rightTier, "Tier-1", "tier1")); out.nodes.unshift(tierGroupNode(c, tier2, "Tier-2 program", "tier2")); const totalWidth = tier2.maxX + 24; const totalHeight = tier2.maxY + 24; return { ...out, width: totalWidth, height: totalHeight }; } interface Bbox { minX: number; minY: number; maxX: number; maxY: number; } function bboxFromNodes(nodes: Node[], [start, end]: [number, number]): Bbox { let minX = Infinity; let minY = Infinity; let maxX = -Infinity; let maxY = -Infinity; for (let i = start; i < end; i++) { const n = nodes[i]; const { x, y } = n.position; const { w, h } = approxSize(n); if (x < minX) minX = x; if (y < minY) minY = y; if (x + w > maxX) maxX = x + w; if (y + h > maxY) maxY = y + h; } return { minX, minY, maxX, maxY }; } function approxSize(n: Node): { w: number; h: number } { // Conservative bounds for each node type. Verb nodes with many gene // pills get taller — estimate from the data payload. const t = n.type; if (t === "dataPill" || t === "scorePill" || t === "output") { return { w: 170, h: 38 }; } if (t === "verb") { const pills: unknown = (n.data as { pills?: unknown })?.pills; const pillCount = Array.isArray(pills) ? pills.length : 0; const pillRows = pillCount > 0 ? Math.ceil(pillCount / 4) : 0; return { w: 220, h: 56 + pillRows * 22 }; } return { w: 170, h: 56 }; } function unionBboxes(list: Bbox[]): Bbox { return { minX: Math.min(...list.map((b) => b.minX)), minY: Math.min(...list.map((b) => b.minY)), maxX: Math.max(...list.map((b) => b.maxX)), maxY: Math.max(...list.map((b) => b.maxY)), }; } function boxForBbox(b: Bbox, _label: string, _variant: "tier1" | "tier2"): Bbox { // Same shape; bbox-with-padding used both for the visual tier rect and // for downstream layout. return { minX: b.minX - TIER_PAD, minY: b.minY - TIER_LABEL_H - 6, maxX: b.maxX + TIER_PAD, maxY: b.maxY + TIER_PAD, }; } function tierGroupNode( c: Counter, b: Bbox, label: string, variant: "tier1" | "tier2", ): Node { return { id: nid(c, "tier-"), type: "tierGroup", position: { x: b.minX, y: b.minY }, data: { label, variant, width: b.maxX - b.minX, height: b.maxY - b.minY, }, draggable: false, selectable: false, style: { zIndex: 0 }, }; } export default function ProgramGraph(props: ProgramGraphProps) { const { nodes, edges, width, height } = useMemo( () => buildGraph(props), [props], ); const containerHeight = Math.max(220, height); // Remount-key so ReactFlow's one-shot `fitView` runs again every // time the laid-out program changes (winner ↔ candidate, pasted, // dataset swap). Without this a larger Tier-2 graph stays at the // previous zoom and clips on the right/bottom. const fitKey = `${width}x${height}:${nodes.length}:${edges.length}`; return (
); }