import { spawn } from "node:child_process"; import type { GraphIR } from "./graph-ir"; /** * Layout-position export — node coordinates a custom painter consumes (the * rackattack pattern: an engine lays out, the painter draws). The engine is used * for LAYOUT ONLY; any rendering it can do is discarded. See issue #497 / epic * #492, and #509 for the size-aware redesign. * * The engine takes an **engine-neutral {@link LayoutInput}** — sized nodes plus * edges — not a DOT string, so the painter's real node sizes drive spacing * (without sizes a layout engine packs for tiny default boxes and big cards * collide). Two engines implement it: * * - {@link DagreLayout} (default) — pure JS, size-aware, **no native dependency**. * - {@link GraphvizLayout} — opt-in; shells `dot`. Honours `groups` as clusters. * * Coordinate convention (both engines): **y grows up, origin bottom-left**, as * `dot -Tjson` reports. A painter flips y to read top-to-bottom. */ /** A laid-out position in the engine's coordinate space (y-up). */ export interface Point { x: number; y: number; } /** Node positions plus the overall canvas size a painter needs. Centres are * y-up (origin bottom-left). `w`/`h` echo the footprint the engine laid out * with, so a painter can route edges to card borders rather than centres. */ export interface Layout { /** Canvas width in the engine's coordinate space. */ width: number; /** Canvas height in the engine's coordinate space. */ height: number; /** Each node's centre position (+ footprint), ordered by id for determinism. */ nodes: Array<{ id: string; w?: number; h?: number } & Point>; } /** A node's painted footprint, in layout units (≈ px / points). */ export interface NodeSize { w: number; h: number; } /** Engine-neutral layout input: sized nodes + edges (+ optional groups). */ export interface LayoutInput { nodes: Array<{ id: string } & NodeSize>; edges: Array<{ from: string; to: string }>; /** Group name → member ids, for cluster-aware engines (graphviz subgraphs). */ groups?: Record; } /** Turns sized nodes + edges into node positions. The painter consumes the * result; it never asks the engine to paint. */ export interface LayoutEngine { readonly name: string; layout(input: LayoutInput): Promise; } /** Graphviz's default node box, in layout units — the fallback for a node the * painter didn't measure, so a size-less call still lays out as it always did. */ export const DEFAULT_NODE_SIZE: NodeSize = { w: 54, h: 36 }; /** Spacing between adjacent nodes / ranks for the JS engine, in layout units. */ const NODE_SEP = 28; const RANK_SEP = 44; /** Build engine input from an IR and a painter-measured size map (id → {w,h}). * Nodes absent from the map fall back to {@link DEFAULT_NODE_SIZE}. Pure. */ export function toLayoutInput(ir: GraphIR, sizes: Record = {}): LayoutInput { return { nodes: ir.nodes.map((n) => ({ id: n.id, ...(sizes[n.id] ?? DEFAULT_NODE_SIZE) })), edges: ir.edges.map((e) => ({ from: e.from, to: e.to })), groups: ir.groups?.byLexicon, }; } /** Drop duplicate / self edges — layout cares about structure, not multiplicity. */ function layoutEdges(input: LayoutInput): Array<{ from: string; to: string }> { const seen = new Set(); const out: Array<{ from: string; to: string }> = []; for (const e of input.edges) { if (e.from === e.to) continue; const key = `${e.from}${e.to}`; if (seen.has(key)) continue; seen.add(key); out.push(e); } out.sort((a, b) => a.from.localeCompare(b.from) || a.to.localeCompare(b.to)); return out; } /** * Layout via dagre — pure JS, size-aware, no native dependency. The default. * Fed nodes/edges in id order so the result is deterministic. dagre's space is * y-down; we flip to y-up so the {@link Layout} contract matches Graphviz. */ export class DagreLayout implements LayoutEngine { readonly name = "dagre"; async layout(input: LayoutInput): Promise { const dagre = (await import("@dagrejs/dagre")).default; const g = new dagre.graphlib.Graph(); g.setGraph({ rankdir: "TB", nodesep: NODE_SEP, ranksep: RANK_SEP }); g.setDefaultEdgeLabel(() => ({})); const nodes = [...input.nodes].sort((a, b) => a.id.localeCompare(b.id)); for (const n of nodes) g.setNode(n.id, { width: n.w, height: n.h }); for (const e of layoutEdges(input)) { if (g.hasNode(e.from) && g.hasNode(e.to)) g.setEdge(e.from, e.to); } dagre.layout(g); const gg = g.graph(); const width = gg.width ?? 0; const height = gg.height ?? 0; if (width === 0 || height === 0) throw new Error("zero graph bounds"); const out = g.nodes().map((id) => { const n = g.node(id); return { id, x: n.x, y: height - n.y, w: n.width, h: n.height }; }); out.sort((a, b) => a.id.localeCompare(b.id)); return { width, height, nodes: out }; } } /** Layout via `dot -Tjson`. Opt-in; requires Graphviz (`brew install graphviz`). * Lays out with real node sizes (`fixedsize`) and honours `groups` as clusters. */ export class GraphvizLayout implements LayoutEngine { readonly name = "graphviz"; async layout(input: LayoutInput): Promise { return parseDotJson(await runDot(inputToDot(input))); } } /** Resolve a layout engine by name. Defaults to dagre (no native dependency). */ export function getLayoutEngine(name?: string): LayoutEngine { if (name === "graphviz") return new GraphvizLayout(); if (name && name !== "dagre") { throw new Error(`unknown layout engine "${name}". Available: dagre (default), graphviz.`); } return new DagreLayout(); } /** Build the DOT for a layout pass: real node footprints via `fixedsize`, so * `dot` spaces for the painted size rather than its tiny default box. */ function inputToDot(input: LayoutInput): string { const lines: string[] = ["digraph chant {", " rankdir=TB;", " node [shape=box, fixedsize=true];"]; const byId = new Map(input.nodes.map((n) => [n.id, n])); const grouped = new Set(); if (input.groups) { for (const [group, members] of Object.entries(input.groups)) { lines.push(` subgraph ${q(`cluster_${group}`)} {`); for (const id of members) { const n = byId.get(id); if (!n) continue; grouped.add(id); lines.push(` ${dotNode(n)}`); } lines.push(" }"); } } for (const n of input.nodes) { if (!grouped.has(n.id)) lines.push(` ${dotNode(n)}`); } for (const e of layoutEdges(input)) lines.push(` ${q(e.from)} -> ${q(e.to)};`); lines.push("}"); return lines.join("\n") + "\n"; } /** A DOT node line. `dot` sizes are inches; layout units are points (72/inch). */ function dotNode(n: { id: string } & NodeSize): string { return `${q(n.id)} [width=${(n.w / 72).toFixed(4)}, height=${(n.h / 72).toFixed(4)}];`; } function q(s: string): string { return `"${s.replace(/\\/g, "\\\\").replace(/"/g, '\\"')}"`; } function runDot(dot: string): Promise { return new Promise((resolve, reject) => { let proc; try { proc = spawn("dot", ["-Tjson"], { stdio: ["pipe", "pipe", "pipe"] }); } catch (err) { reject(installHint(err)); return; } let out = ""; let errOut = ""; proc.stdout.on("data", (d) => (out += d)); proc.stderr.on("data", (d) => (errOut += d)); proc.on("error", (err) => reject(installHint(err))); proc.on("close", (code) => { if (code !== 0) reject(new Error(`dot exited ${code}: ${errOut.trim()}`)); else resolve(out); }); proc.stdin.write(dot); proc.stdin.end(); }); } function installHint(err: unknown): Error { const msg = err instanceof Error ? err.message : String(err); return new Error( `could not run 'dot' (${msg}). Graphviz is required for --layout-engine graphviz — ` + `install it with 'brew install graphviz', or drop the flag to use the default dagre engine ` + `(no native dependency).`, ); } interface DotJson { bb?: string; objects?: Array<{ name?: string; pos?: string; width?: string; height?: string }>; } /** Parse `dot -Tjson` output into a {@link Layout}. Pure; exported for testing. */ export function parseDotJson(json: string): Layout { const parsed = JSON.parse(json) as DotJson; const bb = (parsed.bb ?? "").split(","); if (bb.length !== 4) throw new Error(`bad bounding box ${parsed.bb}`); const width = num(bb[2]); const height = num(bb[3]); if (width === 0 || height === 0) throw new Error("zero graph bounds"); const nodes: Array<{ id: string; w?: number; h?: number } & Point> = []; for (const o of parsed.objects ?? []) { if (!o.name || !o.pos) continue; const p = o.pos.split(","); if (p.length !== 2) continue; const node: { id: string; w?: number; h?: number } & Point = { id: o.name, x: num(p[0]), y: num(p[1]) }; // dot reports width/height in inches; echo as layout units (points). if (o.width) node.w = num(o.width) * 72; if (o.height) node.h = num(o.height) * 72; nodes.push(node); } nodes.sort((a, b) => a.id.localeCompare(b.id)); return { width, height, nodes }; } function num(s: string): number { const f = Number.parseFloat(s.trim()); return Number.isFinite(f) ? f : 0; }