// Auto-layout the pipeline graph with dagre, so adding a node in the backend // graph needs no manual coordinates here — the map redraws itself. import dagre from "@dagrejs/dagre"; import type { Edge, Node } from "reactflow"; import type { Topology, TopoNode } from "./api"; export const NODE_W = 184; export const NODE_H = 72; export interface RFData { meta: TopoNode; state: "idle" | "active" | "awaiting_human" | "parked"; count: number; dimmed: boolean; onPath: boolean; // Loop-back targets this node can send work back to (e.g. review -> ["plan"]). // Drives the small ↺ affordance on the node instead of an always-on back-arc. loopBackTo: string[]; } const FORWARD = new Set(["spine", "branch"]); // Compute laid-out React Flow nodes + edges from the topology and live state. // `onPath` marks the nodes a selected task has visited (highlighted); `dimmed` // fades nodes outside the active task's path when one is selected. // // Loop-back edges (review->plan, verify->build) are NOT drawn by default — they // overlapped the forward edge and read as ugly circles. Instead the source node // shows a ↺ chip (loopBackTo), and an individual loop-back arc is drawn only when // the selected task actually traversed it (its id is in `loopedEdges`). export function layout( topo: Topology, liveNodes: Record, pathNodeIds: Set | null, loopedEdges: Set | null = null, ): { nodes: Node[]; edges: Edge[] } { const g = new dagre.graphlib.Graph(); g.setGraph({ rankdir: "LR", nodesep: 36, ranksep: 72, marginx: 24, marginy: 24 }); g.setDefaultEdgeLabel(() => ({})); // Lay out using ONLY forward edges, so the backward loop-backs don't distort // the left-to-right ranking. for (const n of topo.nodes) g.setNode(n.id, { width: NODE_W, height: NODE_H }); for (const e of topo.edges) { if (FORWARD.has(e.kind)) g.setEdge(e.from, e.to); } dagre.layout(g); // node id -> loop-back targets, from the topology's loopback edges. const loopBackTo: Record = {}; for (const e of topo.edges) { if (e.kind === "loopback") (loopBackTo[e.from] ??= []).push(e.to); } const nodes: Node[] = topo.nodes.map((meta) => { const pos = g.node(meta.id); const live = liveNodes[meta.id]; const onPath = pathNodeIds ? pathNodeIds.has(meta.id) : false; return { id: meta.id, type: "pipeline", position: { x: pos.x - NODE_W / 2, y: pos.y - NODE_H / 2 }, data: { meta, state: live?.state ?? "idle", count: live?.count ?? 0, dimmed: pathNodeIds != null && !onPath, onPath, loopBackTo: loopBackTo[meta.id] ?? [], }, }; }); const edges: Edge[] = []; for (const e of topo.edges) { const id = `${e.from}->${e.to}`; if (e.kind === "loopback") { // Only draw a loop-back arc the selected task actually traversed. if (!loopedEdges || !loopedEdges.has(id)) continue; edges.push({ id, source: e.from, target: e.to, animated: true, style: { stroke: "#7a5cff", strokeWidth: 2, strokeDasharray: "5 4" }, label: e.to === "plan" ? "↺ revised" : "↺ retried", labelStyle: { fill: "#9b8bff", fontWeight: 700 }, type: "smoothstep", zIndex: 5, }); continue; } edges.push({ id, source: e.from, target: e.to, animated: false, style: { stroke: e.kind === "branch" ? "#3f7fd8" : "#39414f", strokeWidth: 1.5, }, type: "smoothstep", }); } return { nodes, edges }; }