feat(diagram-engine): add_graph — arrow-ordered layout as a container

The layout vocabulary's biggest gap, after D2/TALA's 'containers are
first-class at every layout stage': hierarchical zones and arrow-ordered
graphs could not mix. draw_graph did whole-page flowcharts, containers did
nesting, and 'an architecture zone whose contents follow the data flow' was
inexpressible.

add_graph is a macro operation: nodes+edges go through the existing layered
pass (graph.ts — cycle breaking, longest-path layering, barycentre crossing
reduction) which emits ordinary container/box/link operations, and the
resulting block participates in the outer flexbox like any node. dir col/row
transposes the flow. No new layout code — the coordinate work was always
generic; what was missing was the entry point below page level.

Synthetic layer ids are namespaced by the graph's own id (g1__layer0), fixing
the collision that previously made a second graph per page impossible.
graph.ts gains parent/prefix/rootId options; draw_graph keeps its behaviour
as the page-level case of the same code path.

4 new tests: embedding in a flexbox column, two graphs per page, dir
transposition, unknown-endpoint errors. 565 unit tests green, 8 engine e2e
green. Acceptance: three-zone architecture diagram (person/cloud zone,
arrow-ordered pipeline zone with decision branches and a bold arrow,
cylinder/queue storage zone, cross-zone links) verified in the real editor.
This commit is contained in:
dayuan.jiang
2026-08-09 22:08:43 +09:00
parent d9cdfba3e1
commit 78e31ebb2a
5 changed files with 222 additions and 2 deletions

View File

@@ -726,6 +726,8 @@ add_container: children stacked along one axis. dir "row" side by side, "col" on
add_grid: packs children into cols columns. Use it to pack 3-8 related icons into one labelled area rather than giving each its own frame.
add_graph: an ARROW-ORDERED zone inside a nested diagram. Takes nodes+edges like draw_graph; the edges decide layering and ordering, and the resulting block joins the outer layout like any node. Use it when one region's contents follow a flow — a pipeline zone in an architecture diagram, a small flowchart in a poster column. dir: "col" (default) flows down, "row" flows right.
add_pool: a SWIMLANE diagram. lanes are the roles, top to bottom. Set orientation to "vertical" for vertical swimlanes, where the lanes become columns and the flow runs downwards. Each step is an add_box with lane (which role owns it) and col (which step of the process it is); columns advance left to right and an empty cell means that role does nothing at that point. Two steps with the same col happen at the same time. phases optionally labels groups of columns.
{"operations":[
{"op":"add_pool","id":"p","label":"Expense claim","lanes":["Employee","Manager","Finance"],"phases":["Submit","Review","Pay"]},

View File

@@ -61,6 +61,16 @@ export interface GraphEdge {
export interface GraphOptions {
/** "col" (default): layers stack downwards. "row": layers run left to right. */
flow?: "col" | "row"
/** Container to embed the graph in; absent means the page. */
parent?: string
/**
* Namespace for the synthetic layer-container ids. Without one, two graphs on one
* page would both emit `__layers`/`__layer0` and the second would be rejected as a
* duplicate id.
*/
prefix?: string
/** Id for the outer container itself; defaults to `${prefix}__layers`. */
rootId?: string
}
/** Distance between layers. */
@@ -305,12 +315,14 @@ export function graphToOperations(
// The flow axis is the OUTER container's direction; a layer runs across it.
const outerDir = flow
const layerDir = flow === "col" ? "row" : "col"
const root = `${LAYER_ID}s`
const ns = opts.prefix ?? ""
const root = opts.rootId ?? `${ns}${LAYER_ID}s`
const operations: Operation[] = [
{
op: "add_container",
id: root,
...(opts.parent ? { parent: opts.parent } : {}),
label: "",
dir: outerDir,
gap: LAYER_GAP,
@@ -344,7 +356,7 @@ export function graphToOperations(
operations.push(add(members[0], root))
return
}
const band = `${LAYER_ID}${i}`
const band = `${ns}${LAYER_ID}${i}`
operations.push({
op: "add_container",
id: band,

View File

@@ -11,6 +11,8 @@
*/
import { z } from "zod"
// A runtime import while graph.ts imports only TYPES from here — no cycle at runtime.
import { graphToOperations } from "./graph"
import {
type ContainerNode,
type DiagramNode,
@@ -173,6 +175,62 @@ export const OperationSchema = z.discriminatedUnion("op", [
),
after: z.string().optional(),
}),
z.object({
op: z.literal("add_graph"),
id: z.string(),
parent: z
.string()
.optional()
.describe("Container to embed the graph in; omit for top level"),
label: z.string().optional().describe("Frame title; omit for none"),
dir: z
.enum(["col", "row"])
.optional()
.describe(
"Flow direction: col (default) downwards, row rightwards",
),
nodes: z
.array(
z.object({
id: z.string(),
label: z.string(),
shape: z.string().optional(),
icon: z.string().optional(),
group: z.string().optional(),
role: z
.enum([
"banner",
"heading",
"body",
"callout",
"good",
"bad",
"metric",
"muted",
])
.optional(),
}),
)
.describe("The graph's nodes"),
edges: z
.array(
z.object({
source: z.string(),
target: z.string(),
label: z.string().optional(),
dashed: z.boolean().optional(),
bold: z.boolean().optional(),
head: z.string().optional(),
tail: z.string().optional(),
headFill: z.boolean().optional(),
tailFill: z.boolean().optional(),
}),
)
.describe(
"The arrows. THEY decide the node positions — layering and ordering are computed from them",
),
after: z.string().optional(),
}),
z.object({
op: z.literal("add_grid"),
id: z.string(),
@@ -459,7 +517,40 @@ export function applyOperations(
const exists = (id: string) => findNode(tree, id) !== null
// add_graph is a macro: the layered-graph pass (graph.ts) decides which layer each
// node belongs to and who stands beside whom, and emits ordinary container/box/link
// operations. Expanding it HERE — rather than treating graphs as a special page-level
// tool — is what lets a graph sit inside a poster column or an architecture zone and
// still participate in the outer flexbox like any other node.
const expanded: Operation[] = []
for (const op of ops) {
if (op.op !== "add_graph") {
expanded.push(op)
continue
}
const g = graphToOperations(op.nodes, op.edges, {
flow: op.dir ?? "col",
parent: op.parent,
// The graph's own id namespaces the synthetic layer containers, so two
// graphs on one page cannot collide on `__layer0`.
prefix: op.id,
rootId: op.id,
})
if (g.unknownEndpoints.length)
errors.push(
`add_graph "${op.id}": edge endpoint(s) not in nodes: ${g.unknownEndpoints.join(", ")}`,
)
if (op.label || op.after) {
const root = g.operations[0]
if (root?.op === "add_container") {
if (op.label) root.label = op.label
if (op.after) root.after = op.after
}
}
expanded.push(...g.operations)
}
for (const op of expanded) {
switch (op.op) {
case "add_icon":
case "add_box":

View File

@@ -96,6 +96,12 @@ Use draw_graph when the diagram is boxes joined by arrows and the arrows define
them — a flowchart written as XML or as nested containers comes out as one column, which forces
every branch to jump over the step beside it.
Use restructure_diagram's add_graph when ONE ZONE of a nested diagram is arrow-ordered:
an architecture diagram where a zone's contents follow the data flow, a poster column with
a small flowchart in it. add_graph takes nodes+edges like draw_graph, lays them out inside
its container, and the container joins the outer layout like any node (dir: col flows
down, row flows right).
Use restructure_diagram when the diagram's meaning is in NESTING or in a fixed frame:
- Cloud architecture (AWS/Azure/GCP/Kubernetes): things inside things. Call search_stencils first.
- Swimlane and BPMN diagrams: add_pool with one lane per role, then add_box with lane and col.

View File

@@ -0,0 +1,109 @@
import { describe, expect, it } from "vitest"
import { restructureDiagram } from "@/lib/diagram-engine"
/**
* add_graph: arrow-driven layout as a CONTAINER, not just a page-level tool.
*
* D2/TALA's core claim is that containers are first-class at every layout stage —
* hierarchical zones and arrow-ordered graphs mix in one diagram. Before this, the
* engine was split: draw_graph did whole-page flowcharts, containers did nesting, and
* "a poster column with a small flowchart in it" was inexpressible.
*/
const rectOf = (xml: string, id: string) => {
const m = xml.match(
new RegExp(
`id="${id}"[^>]*>\\s*<mxGeometry[^>]*width="([\\d.]+)" height="([\\d.]+)"`,
),
)
if (!m) throw new Error(`no geometry for ${id}`)
return { w: Number(m[1]), h: Number(m[2]) }
}
const FLOW = {
nodes: [
{ id: "a", label: "request" },
{ id: "b", label: "validate", shape: "decision" },
{ id: "c", label: "process" },
{ id: "d", label: "reject" },
],
edges: [
{ source: "a", target: "b" },
{ source: "b", target: "c", label: "ok" },
{ source: "b", target: "d", label: "no" },
],
}
describe("add_graph", () => {
it("embeds an arrow-ordered graph inside a flexbox column", () => {
const r = restructureDiagram("", [
{ op: "add_container", id: "page", label: "", dir: "row", gap: 20 },
{
op: "add_container",
id: "left",
parent: "page",
label: "",
dir: "col",
gap: 12,
},
{
op: "add_box",
id: "intro",
parent: "left",
label: "How requests flow:",
},
{ op: "add_graph", id: "flow", parent: "left", ...FLOW },
{ op: "add_box", id: "right", parent: "page", label: "Notes" },
])
expect(r.errors).toEqual([])
const xml = r.xml as string
// The decision's two branches share a layer — arrow-driven, not declaration order.
const c = rectOf(xml, "c")
const d = rectOf(xml, "d")
expect(c).toBeTruthy()
expect(d).toBeTruthy()
// And the graph sits inside the column: the outline nests flow under left.
expect(r.outline).toMatch(/left:[\s\S]*flow:/)
})
it("two graphs on one page do not collide on synthetic layer ids", () => {
const r = restructureDiagram("", [
{ op: "add_graph", id: "g1", ...FLOW },
{
op: "add_graph",
id: "g2",
nodes: [
{ id: "x", label: "start" },
{ id: "y", label: "end" },
],
edges: [{ source: "x", target: "y" }],
},
])
expect(r.errors).toEqual([])
expect(r.xml).toContain('id="g1__layer')
expect(r.xml).toContain('id="g2"')
})
it("dir=row transposes the flow", () => {
const make = (dir: "col" | "row") =>
restructureDiagram("", [{ op: "add_graph", id: "g", dir, ...FLOW }])
.xml as string
const down = rectOf(make("col"), "g")
const right = rectOf(make("row"), "g")
// Four layers tall vs four layers wide.
expect(down.h).toBeGreaterThan(down.w * 0.8)
expect(right.w).toBeGreaterThan(right.h)
})
it("reports unknown edge endpoints as an error", () => {
const r = restructureDiagram("", [
{
op: "add_graph",
id: "g",
nodes: [{ id: "a", label: "a" }],
edges: [{ source: "a", target: "ghost" }],
},
])
expect(r.errors.join(" ")).toContain("ghost")
})
})