Plan-graph round 8 (owner): placement now runs on dagre — mermaid's engine

The owner chose: adopt the same layout engine regular mermaid uses, keep
our SVG skin. That engine is dagre (Sugiyama-style layered layout) — and
mermaid 11 ships dagre-d3-es 7.0.14, so we import exactly that fork, via
the same deep subpath mermaid itself uses. No new code shipped; pinned
verbatim in package.json (lane touch — flagged to master).

blockLayout keeps its signature ({ x, y, nodeH, width, height }, all
consumers unchanged): it feeds dagre our geometry (NODE_W boxes, GAP_X/
GAP_Y separations, zero margin, per-node grown heights from round 7),
runs the layout (deterministic, synchronous, DOM-free — SSR and render
tests stay exact), and converts dagre's node centers back to top-lefts.
Dangling edges are ignored defensively (a ghost endpoint would spawn an
unmeasured node and break the measurer).

topoFor stays exported for order/depth semantics (tests); placement no
longer calls it. dagre's network-simplex ranker + crossing minimization
now handle multi-parent DAGs, so future plan shapes route like mermaid
diagrams do. On the existing tree fixtures dagre's numbers agree exactly
with the old hand-rolled layout — every render pin survives verbatim.
This commit is contained in:
amosblomqvist
2026-09-14 07:30:53 +03:00
parent 7c766753cc
commit 4d98525338
3 changed files with 53 additions and 37 deletions
+48 -33
View File
@@ -3,6 +3,12 @@
import { useEffect, useId, useMemo, useRef, useState } from "react"; import { useEffect, useId, useMemo, useRef, useState } from "react";
import { MaximizeIcon, MinusIcon, PlusIcon, XIcon } from "lucide-react"; import { MaximizeIcon, MinusIcon, PlusIcon, XIcon } from "lucide-react";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
// The placement engine — literally mermaid's: dagre-d3-es, the fork
// mermaid 11 ships with (owner, plan-graph round 8: same engine as the
// mermaid graphs, our SVG skin stays). The deep subpath is how mermaid
// itself imports it.
import { graphlib } from "dagre-d3-es";
import { layout as dagreLayout } from "dagre-d3-es/src/dagre/index.js";
import type { PlanBlockData, PlanNodeStatus, PlanTree } from "@/src/core"; import type { PlanBlockData, PlanNodeStatus, PlanTree } from "@/src/core";
import { derivedNodeStatus, derivePlanStatuses } from "@/src/core"; import { derivedNodeStatus, derivePlanStatuses } from "@/src/core";
@@ -21,9 +27,10 @@ import { derivedNodeStatus, derivePlanStatuses } from "@/src/core";
* are frozen snapshots rendered exactly as carried (never re-derived); * are frozen snapshots rendered exactly as carried (never re-derived);
* the popup is the live view (user decision, 2026-09). * the popup is the live view (user decision, 2026-09).
* *
* Hand-built SVG everywhere (user decision): a deterministic layered * Hand-built SVG everywhere (user decision): placement runs on dagre
* layout fills exactly and stays SSR-testable — no force simulation, no * (mermaid's engine — deterministic, synchronous, DOM-free, so SSR and
* coupling to the graph stream's sigma internals. Pan/zoom in the popup * render tests stay exact); the skin is ours — no coupling to the graph
* stream's internals. Pan/zoom in the popup
* is harvested from the retired tree panel's graph (fit-floor semantics: * is harvested from the retired tree panel's graph (fit-floor semantics:
* fully zoomed out IS the fit; panning locks at the floor). * fully zoomed out IS the fit; panning locks at the floor).
*/ */
@@ -43,7 +50,9 @@ export interface LayoutNode {
title: string; title: string;
} }
/** Topological order + longest-path depth (Kahn, authoring tie-break). */ /** Topological order + longest-path depth (Kahn, authoring tie-break) —
* order + depth semantics kept for tests; placement itself runs on dagre
* now (owner, round 8). */
export function topoFor(nodes: readonly LayoutNode[], edges: ReadonlyArray<{ from: string; to: string }>) { export function topoFor(nodes: readonly LayoutNode[], edges: ReadonlyArray<{ from: string; to: string }>) {
const index = new Map(nodes.map((n, i) => [n.id, i] as const)); const index = new Map(nodes.map((n, i) => [n.id, i] as const));
const indegree = new Map<string, number>(nodes.map((n) => [n.id, 0] as const)); const indegree = new Map<string, number>(nodes.map((n) => [n.id, 0] as const));
@@ -82,42 +91,48 @@ export function topoFor(nodes: readonly LayoutNode[], edges: ReadonlyArray<{ fro
return { order, depth }; return { order, depth };
} }
/** Layered positions: depth = row (top-down), authoring order within the /** Placement — dagre (mermaid's engine, owner round 8): Sugiyama-style
* row, each row centered on the widest one. */ * layered layout, TB, with our geometry (NODE_W boxes, GAP_X/GAP_Y
* separations, zero margin — the caller adds GRAPH_PAD/POPUP_PAD).
* Node sizes are fed in (boxes grow with their wrapped content, round
* 7); dagre returns node CENTERS, we hand back top-lefts like before,
* so every consumer (diagram, popup fit, plan window) is unchanged.
* Deterministic + synchronous + DOM-free: SSR and render tests stay
* exact. Dangling edges are ignored defensively — a ghost endpoint
* would spawn an unmeasured node and break the measurer. */
export function blockLayout(nodes: readonly LayoutNode[], edges: ReadonlyArray<{ from: string; to: string }>) { export function blockLayout(nodes: readonly LayoutNode[], edges: ReadonlyArray<{ from: string; to: string }>) {
const { order, depth } = topoFor(nodes, edges);
const layers: LayoutNode[][] = [];
for (const n of order) {
const d = depth.get(n.id) ?? 0;
if (!layers[d]) layers[d] = [];
layers[d].push(n);
}
const rowWidth = (l: LayoutNode[]) => l.length * NODE_W + (l.length - 1) * GAP_X;
let maxWidth = 0;
for (const l of layers) maxWidth = Math.max(maxWidth, rowWidth(l));
const x = new Map<string, number>(); const x = new Map<string, number>();
const y = new Map<string, number>(); const y = new Map<string, number>();
// Per-node heights: boxes grow to show the FULL wrapped content —
// nothing is ever truncated (owner, plan-graph round 7).
const nodeH = new Map<string, number>(); const nodeH = new Map<string, number>();
let cursor = 0; if (nodes.length === 0) return { x, y, nodeH, width: 0, height: 0 };
layers.forEach((l) => { const ids = new Set(nodes.map((n) => n.id));
// The row's height = its tallest node. const g = new graphlib.Graph({ compound: false, multigraph: true });
let rowH = NODE_H; g.setGraph({ rankdir: "TB", nodesep: GAP_X, ranksep: GAP_Y, marginx: 0, marginy: 0 });
for (const n of l) { for (const n of nodes) {
const h = NODE_H + Math.max(0, wrapTitle(n.title).length - 2) * LINE_H; const h = NODE_H + Math.max(0, wrapTitle(n.title).length - 2) * LINE_H;
nodeH.set(n.id, h); nodeH.set(n.id, h);
rowH = Math.max(rowH, h); g.setNode(n.id, { width: NODE_W, height: h });
} }
const start = (maxWidth - rowWidth(l)) / 2; for (const e of edges) {
l.forEach((n, i) => { if (ids.has(e.from) && ids.has(e.to)) g.setEdge(e.from, e.to, { label: "", width: 0, height: 0 });
x.set(n.id, start + i * (NODE_W + GAP_X)); }
y.set(n.id, cursor); // (the d.ts marks opts required; the runtime ignores it)
}); dagreLayout(g, undefined);
cursor += rowH + GAP_Y; for (const n of nodes) {
}); const pos = g.node(n.id);
const height = layers.length > 0 ? cursor - GAP_Y : 0; const h = nodeH.get(n.id) ?? NODE_H;
return { x, y, nodeH, width: maxWidth, height }; if (pos) {
x.set(n.id, pos.x - NODE_W / 2);
y.set(n.id, pos.y - h / 2);
}
}
return {
x,
y,
nodeH,
width: Math.max(g.graph().width ?? 0, 1),
height: Math.max(g.graph().height ?? 0, 1),
};
} }
/** Greedy word wrap to lines of ≤ maxChars — NOTHING is dropped: the /** Greedy word wrap to lines of ≤ maxChars — NOTHING is dropped: the
+2 -2
View File
@@ -1,7 +1,7 @@
/// <reference types="next" /> /// <reference types="next" />
/// <reference types="next/image-types/global" /> /// <reference types="next/image-types/global" />
import "./.next/dev/types/routes.d.ts"; import "./.next/types/routes.d.ts";
import "./.next/dev/types/root-params.d.ts"; import "./.next/types/root-params.d.ts";
// NOTE: This file should not be edited // NOTE: This file should not be edited
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information. // see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
+1
View File
@@ -29,6 +29,7 @@
"class-variance-authority": "^0.7.1", "class-variance-authority": "^0.7.1",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"cmdk": "^1.1.1", "cmdk": "^1.1.1",
"dagre-d3-es": "7.0.14",
"embla-carousel-react": "^8.6.0", "embla-carousel-react": "^8.6.0",
"graphology": "^0.26.0", "graphology": "^0.26.0",
"graphology-layout-forceatlas2": "^0.10.1", "graphology-layout-forceatlas2": "^0.10.1",