feat(diagram-engine): layout + XML renderer, verified end to end in draw.io
Completes the tree → coordinates → XML direction, so the model can declare nesting
and never write a coordinate or an mxCell again.
layout.ts — measure bottom-up, place top-down, the same shape as flexbox. A
container sums its children along the flow axis and adds padding, so "child spills
out of its frame" and "siblings overlap" cannot happen by construction rather than
being caught afterwards. Slack from sibling equalisation is shared between children
instead of left as dead margin, capped at one gap so a stretched frame reads as
spaced rather than sparse.
render.ts — writes the mxCells, stamping container=1 and the dai_* markers so
parse.ts can read the structure back. Edges carry no waypoints: draw.io's own router
recomputes the route on every edit, so a user who moves a node never has to re-link
an arrow. Cells the parser could not interpret are re-emitted verbatim, so a
re-layout never deletes a user's annotations.
Phantoms are gone (task #5). The reference project's layout-only wrapper emits no
cell, which makes the round-trip lossy by construction — measured on its own
build_vpc.mjs, a phantom erased a container's "col" direction for good. An
unlabelled frame here emits a real cell with fillColor/strokeColor=none instead:
invisible, but present in the XML and therefore recoverable.
Two bugs the round-trip test caught, both real:
- An icon's cell was being emitted at its measured slot size, which includes room
for the label underneath. Parsing read that width back as the glyph size, so the
icon grew on every round-trip. The cell is now the glyph square and the label
renders outside it via verticalLabelPosition, as the reference does.
- An Azure or GCP icon is an embedded base64 image whose style contains no name
anywhere, so the catalog name was unrecoverable. Added a dai_name marker.
Verified in a real browser (3 Playwright tests, not mocks): engine output renders in
draw.io; dragging a shape into a frame makes draw.io rewrite its parent and the
engine reads the new structure back; re-laying out from that structure PRESERVES the
user's move instead of undoing it, and leaves untouched nodes alone; and the
re-laid-out XML still renders.
That last point is the whole design: there is no second copy of the state, so a
manual edit is an input to the next layout rather than a conflict to reconcile.
304 unit tests + 3 e2e.
2026-08-09 11:56:08 +09:00
|
|
|
/**
|
|
|
|
|
* Round-trip: tree → XML → tree.
|
|
|
|
|
*
|
|
|
|
|
* This is the test the whole design rests on. If structure does not survive a trip
|
|
|
|
|
* through draw.io XML, then the canvas cannot be the single source of truth and we are
|
|
|
|
|
* back to keeping a second copy of the state in sync.
|
|
|
|
|
*/
|
|
|
|
|
import { describe, expect, it } from "vitest"
|
|
|
|
|
import { parseDiagram } from "@/lib/diagram-engine/parse"
|
|
|
|
|
import { renderDiagram } from "@/lib/diagram-engine/render"
|
|
|
|
|
import {
|
|
|
|
|
type BoxNode,
|
|
|
|
|
type ContainerNode,
|
|
|
|
|
type DiagramNode,
|
|
|
|
|
type DiagramTree,
|
|
|
|
|
findNode,
|
|
|
|
|
findParent,
|
|
|
|
|
type GridNode,
|
|
|
|
|
type GroupNode,
|
|
|
|
|
type IconNode,
|
|
|
|
|
isContainer,
|
|
|
|
|
walkTree,
|
|
|
|
|
} from "@/lib/diagram-engine/types"
|
|
|
|
|
|
|
|
|
|
const icon = (id: string, name = "ec2", label = ""): IconNode => ({
|
|
|
|
|
kind: "icon",
|
|
|
|
|
id,
|
|
|
|
|
name,
|
|
|
|
|
label,
|
|
|
|
|
})
|
|
|
|
|
const box = (id: string, label = ""): BoxNode => ({ kind: "box", id, label })
|
|
|
|
|
const group = (
|
|
|
|
|
id: string,
|
|
|
|
|
dir: "row" | "col",
|
|
|
|
|
children: DiagramNode[],
|
|
|
|
|
label = "",
|
|
|
|
|
gname: string | null = null,
|
|
|
|
|
gap = 20,
|
|
|
|
|
): GroupNode => ({ kind: "group", id, gname, label, dir, gap, children })
|
|
|
|
|
const grid = (
|
|
|
|
|
id: string,
|
|
|
|
|
cols: number,
|
|
|
|
|
children: DiagramNode[],
|
|
|
|
|
label = "",
|
|
|
|
|
gap = 14,
|
|
|
|
|
): GridNode => ({ kind: "grid", id, gname: null, label, cols, gap, children })
|
|
|
|
|
|
|
|
|
|
const tree = (
|
|
|
|
|
roots: DiagramNode[],
|
|
|
|
|
extra: Partial<DiagramTree> = {},
|
|
|
|
|
): DiagramTree => ({
|
|
|
|
|
roots,
|
|
|
|
|
links: [],
|
|
|
|
|
foreign: [],
|
|
|
|
|
...extra,
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
/** Structural signature: nesting, kinds, directions and order — everything but coordinates. */
|
|
|
|
|
function signature(t: DiagramTree): string {
|
|
|
|
|
const line = (n: DiagramNode, depth: number): string[] => {
|
|
|
|
|
const pad = " ".repeat(depth)
|
|
|
|
|
if (!isContainer(n)) return [`${pad}${n.kind} ${n.id}`]
|
feat(diagram-engine): flowcharts, swimlanes, sequence diagrams and mind maps
Extends the declarative engine past cloud architecture. The tool routing was
divided by icon library — AWS through the engine, everything else hand-written
XML — which is the wrong axis. What matters is the LAYOUT SHAPE.
Measured first: a six-step approval flow declared in its natural order comes out
as one column, because the layout only arranges what nesting tells it to and
never looked at the arrows. That forces the arrow from the decision to its second
branch to jump over the first branch.
graph.ts computes what the layout should have looked at: layer assignment by
longest path, cycle breaking so a loop is drawn without setting the order, and
barycentre sweeping to cut edge crossings. It emits ordinary container
operations, so layout, routing and round-tripping are unchanged — reaching zero
arrows-through-boxes on a 14-node pipeline and zero crossings on a bipartite
graph whose declared order forces three.
Three new container kinds, each because one layout rule cannot serve them all:
pool — swimlanes. Lanes are real cells and each step is parented to its
band, so dragging a step to another role records the change.
sequence — participants across the top, one lifeline cell per participant so
head and line stay together on a drag. Messages bypass the router:
a message's height IS its order.
radial — mind maps and org charts. Children are a flat list and the
hierarchy comes from the links, because a branch is a box and a box
cannot hold children.
Flowchart box shapes (diamond, stadium, parallelogram, document) so a reader can
tell a branch from a step.
Two bugs the new tests caught: the duplicate-link guard blocked a sequence
diagram from having two messages between the same pair, and the fallback message
numbering was shared across containers, pushing a second diagram's messages off
its own lifelines.
523 unit tests and 17 diagram e2e tests pass. Every kind verified round-trip
stable to a fixed point, and rendered in a real browser — draw.io keeps the
lifeline shape and the lane markers.
2026-08-09 13:47:23 +09:00
|
|
|
const meta =
|
|
|
|
|
n.kind === "grid"
|
|
|
|
|
? `cols=${n.cols}`
|
|
|
|
|
: n.kind === "group"
|
|
|
|
|
? `dir=${n.dir}`
|
|
|
|
|
: n.kind
|
feat(diagram-engine): layout + XML renderer, verified end to end in draw.io
Completes the tree → coordinates → XML direction, so the model can declare nesting
and never write a coordinate or an mxCell again.
layout.ts — measure bottom-up, place top-down, the same shape as flexbox. A
container sums its children along the flow axis and adds padding, so "child spills
out of its frame" and "siblings overlap" cannot happen by construction rather than
being caught afterwards. Slack from sibling equalisation is shared between children
instead of left as dead margin, capped at one gap so a stretched frame reads as
spaced rather than sparse.
render.ts — writes the mxCells, stamping container=1 and the dai_* markers so
parse.ts can read the structure back. Edges carry no waypoints: draw.io's own router
recomputes the route on every edit, so a user who moves a node never has to re-link
an arrow. Cells the parser could not interpret are re-emitted verbatim, so a
re-layout never deletes a user's annotations.
Phantoms are gone (task #5). The reference project's layout-only wrapper emits no
cell, which makes the round-trip lossy by construction — measured on its own
build_vpc.mjs, a phantom erased a container's "col" direction for good. An
unlabelled frame here emits a real cell with fillColor/strokeColor=none instead:
invisible, but present in the XML and therefore recoverable.
Two bugs the round-trip test caught, both real:
- An icon's cell was being emitted at its measured slot size, which includes room
for the label underneath. Parsing read that width back as the glyph size, so the
icon grew on every round-trip. The cell is now the glyph square and the label
renders outside it via verticalLabelPosition, as the reference does.
- An Azure or GCP icon is an embedded base64 image whose style contains no name
anywhere, so the catalog name was unrecoverable. Added a dai_name marker.
Verified in a real browser (3 Playwright tests, not mocks): engine output renders in
draw.io; dragging a shape into a frame makes draw.io rewrite its parent and the
engine reads the new structure back; re-laying out from that structure PRESERVES the
user's move instead of undoing it, and leaves untouched nodes alone; and the
re-laid-out XML still renders.
That last point is the whole design: there is no second copy of the state, so a
manual edit is an input to the next layout rather than a conflict to reconcile.
304 unit tests + 3 e2e.
2026-08-09 11:56:08 +09:00
|
|
|
return [
|
|
|
|
|
`${pad}${n.kind} ${n.id} ${meta} gap=${n.gap}`,
|
|
|
|
|
...n.children.flatMap((c) => line(c, depth + 1)),
|
|
|
|
|
]
|
|
|
|
|
}
|
|
|
|
|
return t.roots.flatMap((r) => line(r, 0)).join("\n")
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/** Render then parse, returning the recovered tree. */
|
|
|
|
|
function roundTrip(t: DiagramTree) {
|
|
|
|
|
const { xml } = renderDiagram(t)
|
|
|
|
|
return { xml, ...parseDiagram(xml) }
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
describe("structure survives a round-trip", () => {
|
|
|
|
|
it("recovers a flat row", () => {
|
|
|
|
|
const t = tree([group("f", "row", [icon("a"), icon("b")], "Frame")])
|
|
|
|
|
expect(signature(roundTrip(t).tree)).toBe(signature(t))
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it("recovers a column", () => {
|
|
|
|
|
const t = tree([group("f", "col", [icon("a"), icon("b")], "Frame")])
|
|
|
|
|
expect(signature(roundTrip(t).tree)).toBe(signature(t))
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it("recovers a grid with its column count", () => {
|
|
|
|
|
const t = tree([
|
|
|
|
|
grid("g", 3, [icon("a"), icon("b"), icon("c"), icon("d")], "G"),
|
|
|
|
|
])
|
|
|
|
|
expect(signature(roundTrip(t).tree)).toBe(signature(t))
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it("recovers a deep cloud-architecture nesting", () => {
|
|
|
|
|
const t = tree([
|
|
|
|
|
group(
|
|
|
|
|
"region",
|
|
|
|
|
"row",
|
|
|
|
|
[
|
|
|
|
|
group(
|
|
|
|
|
"vpc",
|
|
|
|
|
"col",
|
|
|
|
|
[
|
|
|
|
|
icon("igw", "internet_gateway", "IGW"),
|
|
|
|
|
group(
|
|
|
|
|
"az_a",
|
|
|
|
|
"col",
|
|
|
|
|
[
|
|
|
|
|
group(
|
|
|
|
|
"pub_a",
|
|
|
|
|
"col",
|
|
|
|
|
[icon("nat_a", "nat_gateway", "NAT")],
|
|
|
|
|
"Public Subnet",
|
|
|
|
|
"group_subnet",
|
|
|
|
|
),
|
|
|
|
|
group(
|
|
|
|
|
"app_a",
|
|
|
|
|
"col",
|
|
|
|
|
[icon("ec2_a", "ec2", "EC2")],
|
|
|
|
|
"Private Subnet",
|
|
|
|
|
"group_subnet",
|
|
|
|
|
),
|
|
|
|
|
],
|
|
|
|
|
"AZ-a",
|
|
|
|
|
"group_availability_zone",
|
|
|
|
|
),
|
|
|
|
|
],
|
|
|
|
|
"VPC",
|
|
|
|
|
"group_vpc",
|
|
|
|
|
),
|
|
|
|
|
],
|
|
|
|
|
"Region",
|
|
|
|
|
"group_region",
|
|
|
|
|
),
|
|
|
|
|
])
|
|
|
|
|
expect(signature(roundTrip(t).tree)).toBe(signature(t))
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it("recovers several roots in order", () => {
|
|
|
|
|
const t = tree([
|
|
|
|
|
box("users", "Users"),
|
|
|
|
|
group("cloud", "row", [icon("a")], "Cloud"),
|
|
|
|
|
box("consumers", "Consumers"),
|
|
|
|
|
])
|
|
|
|
|
const back = roundTrip(t).tree
|
|
|
|
|
expect(back.roots.map((r) => r.id)).toEqual([
|
|
|
|
|
"users",
|
|
|
|
|
"cloud",
|
|
|
|
|
"consumers",
|
|
|
|
|
])
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it("recovers the direction of an UNLABELLED wrapper — the phantom problem, fixed", () => {
|
|
|
|
|
// The reference project would use a phantom here, which emits no cell: its two
|
|
|
|
|
// children would be reparented onto vpc, and the wrapper's "row" direction would
|
|
|
|
|
// be gone from the XML for good. We emit a real but invisible cell instead.
|
|
|
|
|
const t = tree([
|
|
|
|
|
group(
|
|
|
|
|
"vpc",
|
|
|
|
|
"col",
|
|
|
|
|
[
|
|
|
|
|
icon("igw", "internet_gateway", "IGW"),
|
|
|
|
|
group(
|
|
|
|
|
"azs",
|
|
|
|
|
"row",
|
|
|
|
|
[
|
|
|
|
|
group("az_a", "col", [icon("ec2_a")], "AZ-a"),
|
|
|
|
|
group("az_b", "col", [icon("ec2_b")], "AZ-b"),
|
|
|
|
|
],
|
|
|
|
|
"", // no label — a layout-only wrapper
|
|
|
|
|
),
|
|
|
|
|
],
|
|
|
|
|
"VPC",
|
|
|
|
|
"group_vpc",
|
|
|
|
|
),
|
|
|
|
|
])
|
|
|
|
|
const back = roundTrip(t).tree
|
|
|
|
|
// the wrapper is still there, still a row, still holding both AZs
|
|
|
|
|
const azs = findNode(back, "azs")
|
|
|
|
|
expect(azs).not.toBeNull()
|
|
|
|
|
expect((azs as GroupNode).dir).toBe("row")
|
|
|
|
|
expect(findParent(back, "az_a")?.id).toBe("azs")
|
|
|
|
|
expect(findParent(back, "azs")?.id).toBe("vpc")
|
|
|
|
|
// and vpc kept its own direction instead of collapsing into a grid
|
|
|
|
|
expect((findNode(back, "vpc") as GroupNode).dir).toBe("col")
|
|
|
|
|
expect(signature(back)).toBe(signature(t))
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it("keeps the wrapper invisible", () => {
|
|
|
|
|
const t = tree([
|
|
|
|
|
group("w", "row", [icon("a"), icon("b")], ""), // unlabelled → invisible
|
|
|
|
|
])
|
|
|
|
|
const { xml } = renderDiagram(t)
|
|
|
|
|
const cell = xml.match(/<mxCell id="w"[^>]*style="([^"]*)"/)?.[1] ?? ""
|
|
|
|
|
expect(cell).toContain("fillColor=none")
|
|
|
|
|
expect(cell).toContain("strokeColor=none")
|
|
|
|
|
// ...but it is still a real container, so draw.io reparents into it
|
|
|
|
|
expect(cell).toContain("container=1")
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it("keeps a labelled frame visible", () => {
|
|
|
|
|
const t = tree([group("f", "row", [icon("a")], "Visible")])
|
|
|
|
|
const { xml } = renderDiagram(t)
|
|
|
|
|
const style = xml.match(/<mxCell id="f"[^>]*style="([^"]*)"/)?.[1] ?? ""
|
|
|
|
|
expect(style).not.toContain("strokeColor=none")
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it("survives repeated round-trips without drift", () => {
|
|
|
|
|
const t = tree([
|
|
|
|
|
group(
|
|
|
|
|
"outer",
|
|
|
|
|
"row",
|
|
|
|
|
[
|
|
|
|
|
group("a", "col", [icon("x"), icon("y")], "A"),
|
|
|
|
|
grid("g", 2, [icon("p"), icon("q"), icon("r")], "G"),
|
|
|
|
|
],
|
|
|
|
|
"Outer",
|
|
|
|
|
),
|
|
|
|
|
])
|
|
|
|
|
const once = roundTrip(t).tree
|
|
|
|
|
const twice = roundTrip(once).tree
|
|
|
|
|
const thrice = roundTrip(twice).tree
|
|
|
|
|
expect(signature(twice)).toBe(signature(once))
|
|
|
|
|
expect(signature(thrice)).toBe(signature(once))
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it("keeps geometry stable across repeated round-trips", () => {
|
|
|
|
|
const t = tree([group("f", "row", [icon("a"), icon("b")], "F")])
|
|
|
|
|
const first = renderDiagram(t)
|
|
|
|
|
const second = renderDiagram(parseDiagram(first.xml).tree)
|
|
|
|
|
expect(second.page).toEqual(first.page)
|
|
|
|
|
})
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
describe("labels and content survive", () => {
|
|
|
|
|
it("recovers labels on containers and leaves", () => {
|
|
|
|
|
const t = tree([
|
|
|
|
|
group(
|
|
|
|
|
"f",
|
|
|
|
|
"row",
|
|
|
|
|
[icon("a", "s3", "My Bucket"), box("b", "A Box")],
|
|
|
|
|
"My Frame",
|
|
|
|
|
),
|
|
|
|
|
])
|
|
|
|
|
const back = roundTrip(t).tree
|
|
|
|
|
expect((findNode(back, "f") as ContainerNode).label).toBe("My Frame")
|
|
|
|
|
expect((findNode(back, "a") as IconNode).label).toBe("My Bucket")
|
|
|
|
|
expect((findNode(back, "b") as BoxNode).label).toBe("A Box")
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it("recovers a label containing XML metacharacters", () => {
|
|
|
|
|
const nasty = "A & B <tag> \"quoted\" 'apostrophe'"
|
|
|
|
|
const t = tree([group("f", "row", [box("b", nasty)], nasty)])
|
|
|
|
|
const back = roundTrip(t).tree
|
|
|
|
|
expect((findNode(back, "b") as BoxNode).label).toBe(nasty)
|
|
|
|
|
expect((findNode(back, "f") as ContainerNode).label).toBe(nasty)
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it("recovers the page title", () => {
|
|
|
|
|
const t = tree([group("f", "row", [icon("a")], "F")], {
|
|
|
|
|
title: "My Architecture",
|
|
|
|
|
})
|
|
|
|
|
expect(roundTrip(t).tree.title).toBe("My Architecture")
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it("recovers a gap that is not the default", () => {
|
|
|
|
|
const t = tree([
|
|
|
|
|
group("f", "row", [icon("a"), icon("b")], "F", null, 47),
|
|
|
|
|
])
|
|
|
|
|
expect((findNode(roundTrip(t).tree, "f") as GroupNode).gap).toBe(47)
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it("recovers an icon's catalog name", () => {
|
|
|
|
|
const t = tree([
|
|
|
|
|
group(
|
|
|
|
|
"f",
|
|
|
|
|
"row",
|
|
|
|
|
[icon("a", "nat_gateway"), icon("b", "rds")],
|
|
|
|
|
"F",
|
|
|
|
|
),
|
|
|
|
|
])
|
|
|
|
|
const back = roundTrip(t).tree
|
|
|
|
|
expect((findNode(back, "a") as IconNode).name).toBe("nat_gateway")
|
|
|
|
|
expect((findNode(back, "b") as IconNode).name).toBe("rds")
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it("recovers a group stencil name", () => {
|
|
|
|
|
const t = tree([group("v", "col", [icon("a")], "VPC", "group_vpc")])
|
|
|
|
|
const { xml } = renderDiagram(t, {
|
|
|
|
|
resolveStyle: (name, kind) =>
|
|
|
|
|
kind === "group"
|
|
|
|
|
? `shape=mxgraph.aws4.group;grIcon=mxgraph.aws4.${name};fillColor=none;strokeColor=#8C4FFF;verticalAlign=top;align=left;`
|
|
|
|
|
: null,
|
|
|
|
|
})
|
feat(diagram-engine): flowcharts, swimlanes, sequence diagrams and mind maps
Extends the declarative engine past cloud architecture. The tool routing was
divided by icon library — AWS through the engine, everything else hand-written
XML — which is the wrong axis. What matters is the LAYOUT SHAPE.
Measured first: a six-step approval flow declared in its natural order comes out
as one column, because the layout only arranges what nesting tells it to and
never looked at the arrows. That forces the arrow from the decision to its second
branch to jump over the first branch.
graph.ts computes what the layout should have looked at: layer assignment by
longest path, cycle breaking so a loop is drawn without setting the order, and
barycentre sweeping to cut edge crossings. It emits ordinary container
operations, so layout, routing and round-tripping are unchanged — reaching zero
arrows-through-boxes on a 14-node pipeline and zero crossings on a bipartite
graph whose declared order forces three.
Three new container kinds, each because one layout rule cannot serve them all:
pool — swimlanes. Lanes are real cells and each step is parented to its
band, so dragging a step to another role records the change.
sequence — participants across the top, one lifeline cell per participant so
head and line stay together on a drag. Messages bypass the router:
a message's height IS its order.
radial — mind maps and org charts. Children are a flat list and the
hierarchy comes from the links, because a branch is a box and a box
cannot hold children.
Flowchart box shapes (diamond, stadium, parallelogram, document) so a reader can
tell a branch from a step.
Two bugs the new tests caught: the duplicate-link guard blocked a sequence
diagram from having two messages between the same pair, and the fallback message
numbering was shared across containers, pushing a second diagram's messages off
its own lifelines.
523 unit tests and 17 diagram e2e tests pass. Every kind verified round-trip
stable to a fixed point, and rendered in a real browser — draw.io keeps the
lifeline shape and the lane markers.
2026-08-09 13:47:23 +09:00
|
|
|
expect((findNode(parseDiagram(xml).tree, "v") as GroupNode).gname).toBe(
|
|
|
|
|
"group_vpc",
|
|
|
|
|
)
|
feat(diagram-engine): layout + XML renderer, verified end to end in draw.io
Completes the tree → coordinates → XML direction, so the model can declare nesting
and never write a coordinate or an mxCell again.
layout.ts — measure bottom-up, place top-down, the same shape as flexbox. A
container sums its children along the flow axis and adds padding, so "child spills
out of its frame" and "siblings overlap" cannot happen by construction rather than
being caught afterwards. Slack from sibling equalisation is shared between children
instead of left as dead margin, capped at one gap so a stretched frame reads as
spaced rather than sparse.
render.ts — writes the mxCells, stamping container=1 and the dai_* markers so
parse.ts can read the structure back. Edges carry no waypoints: draw.io's own router
recomputes the route on every edit, so a user who moves a node never has to re-link
an arrow. Cells the parser could not interpret are re-emitted verbatim, so a
re-layout never deletes a user's annotations.
Phantoms are gone (task #5). The reference project's layout-only wrapper emits no
cell, which makes the round-trip lossy by construction — measured on its own
build_vpc.mjs, a phantom erased a container's "col" direction for good. An
unlabelled frame here emits a real cell with fillColor/strokeColor=none instead:
invisible, but present in the XML and therefore recoverable.
Two bugs the round-trip test caught, both real:
- An icon's cell was being emitted at its measured slot size, which includes room
for the label underneath. Parsing read that width back as the glyph size, so the
icon grew on every round-trip. The cell is now the glyph square and the label
renders outside it via verticalLabelPosition, as the reference does.
- An Azure or GCP icon is an embedded base64 image whose style contains no name
anywhere, so the catalog name was unrecoverable. Added a dai_name marker.
Verified in a real browser (3 Playwright tests, not mocks): engine output renders in
draw.io; dragging a shape into a frame makes draw.io rewrite its parent and the
engine reads the new structure back; re-laying out from that structure PRESERVES the
user's move instead of undoing it, and leaves untouched nodes alone; and the
re-laid-out XML still renders.
That last point is the whole design: there is no second copy of the state, so a
manual edit is an input to the next layout rather than a conflict to reconcile.
304 unit tests + 3 e2e.
2026-08-09 11:56:08 +09:00
|
|
|
})
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
describe("edges survive", () => {
|
|
|
|
|
it("recovers source, target and label", () => {
|
|
|
|
|
const t = tree([group("f", "row", [icon("a"), icon("b")], "F")], {
|
|
|
|
|
links: [{ source: "a", target: "b", label: "flows to" }],
|
|
|
|
|
})
|
|
|
|
|
const back = roundTrip(t).tree
|
|
|
|
|
expect(back.links).toHaveLength(1)
|
|
|
|
|
expect(back.links[0].source).toBe("a")
|
|
|
|
|
expect(back.links[0].target).toBe("b")
|
|
|
|
|
expect(back.links[0].label).toBe("flows to")
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it("recovers a step number and keeps it out of the label", () => {
|
|
|
|
|
const t = tree([group("f", "row", [icon("a"), icon("b")], "F")], {
|
|
|
|
|
links: [{ source: "a", target: "b", label: "HTTPS", step: 1 }],
|
|
|
|
|
})
|
|
|
|
|
const back = roundTrip(t).tree
|
|
|
|
|
expect(back.links[0].step).toBe(1)
|
|
|
|
|
expect(back.links[0].label).toBe("HTTPS")
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it("recovers a dashed edge", () => {
|
|
|
|
|
const t = tree([group("f", "row", [icon("a"), icon("b")], "F")], {
|
|
|
|
|
links: [{ source: "a", target: "b", dashed: true }],
|
|
|
|
|
})
|
|
|
|
|
expect(roundTrip(t).tree.links[0].dashed).toBe(true)
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it("drops an edge with a missing endpoint and reports it", () => {
|
|
|
|
|
const t = tree([group("f", "row", [icon("a")], "F")], {
|
|
|
|
|
links: [{ source: "a", target: "ghost" }],
|
|
|
|
|
})
|
|
|
|
|
const r = renderDiagram(t)
|
|
|
|
|
expect(r.danglingLinks).toEqual(["ghost"])
|
|
|
|
|
expect(r.xml).not.toContain('target="ghost"')
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it("emits no waypoints, so draw.io re-routes when the user moves a node", () => {
|
|
|
|
|
const t = tree([group("f", "row", [icon("a"), icon("b")], "F")], {
|
|
|
|
|
links: [{ source: "a", target: "b", label: "x" }],
|
|
|
|
|
})
|
|
|
|
|
expect(renderDiagram(t).xml).not.toContain('as="points"')
|
|
|
|
|
})
|
feat(diagram-engine): corners, borderless fills, shadows and strikethrough
Four more Tailwind classes, all four verified against draw.io's own source in
public/drawio rather than against a prose reference — which is how three earlier
exclusions turned out to be wrong:
rounded-* mxShape.js:1172-1189 — absoluteArcSize=1 switches arcSize to
absolute pixels and halves it, so the same class is the same
corner on every box. Previously excluded as 'percentage only'
shadow-sm..xl mxShape.js:505-535 — getShadowStyle reads five independent
params, not one flag, so Tailwind's offset+blur rungs map one
to one. Previously excluded as 'six sizes collapse to one'
line-through mxConstants.js:2054 FONT_STRIKETHROUGH: 8, read at both
mxText.js:723 and :1040. The bitmask has four bits, not three
border-none the only one of the four that adds something previously
inexpressible: a fill with no outline
Also fixes an edge style growing 76 characters per re-layout, without bound. The
router recomputes ports on every pass, and appending them to a style recovered
from the canvas — which already carried the previous pass's eight port keys —
grew the string forever. draw.io resolves duplicates last-wins so the arrow
always looked right; a byte-identity check is what caught it.
Two traps found while wiring the readback, both the same shape: a value the
THEME emits being recorded as one the model asked for. strokeColor=none from a
filled or ghost role, and rounded=0 from the fallback style. Either one would
outlive a set_role, since that clears style but keeps text.
Deliberately not included, with reasons in tw.ts: per-side borders and per-corner
radius (both would take the shape slot, and what a node IS matters more than
which of its edges show), per-side padding (draw.io's keys pad the label, not the
room left for children), text-shadow (a bare flag with no offset or blur),
opacity (Tailwind's is any integer, not a scale), tracking/uppercase/leading
(absent from draw.io — zero grep hits, not merely coarse).
615 tests, 90 new. Browser-verified: four shadow rungs visibly differ, radius is
real pixels, terminator + rounded-lg becomes a small-cornered rounded rect while
an untouched terminator stays a stadium.
2026-08-11 09:09:45 +09:00
|
|
|
|
|
|
|
|
it("an edge's style does not grow across repeated re-layouts", () => {
|
|
|
|
|
// The router recomputes the connection points every pass, and an edge recovered from
|
|
|
|
|
// the canvas already carries the previous pass's set. Appending them added 76
|
|
|
|
|
// characters per round-trip forever. draw.io resolves duplicate keys last-wins, so
|
|
|
|
|
// the arrow always LOOKED right — only measuring the string catches it.
|
|
|
|
|
let t = tree([group("f", "row", [icon("a"), icon("b")], "F")], {
|
|
|
|
|
links: [{ source: "a", target: "b" }],
|
|
|
|
|
})
|
|
|
|
|
const styleOfEdge = (xml: string) =>
|
|
|
|
|
/<mxCell id="ed1"[^>]*style="([^"]*)"/.exec(xml)?.[1] ?? ""
|
|
|
|
|
|
|
|
|
|
const lengths: number[] = []
|
|
|
|
|
const portCounts: number[] = []
|
|
|
|
|
let xml = ""
|
|
|
|
|
for (let pass = 0; pass < 4; pass++) {
|
|
|
|
|
xml = renderDiagram(t).xml
|
|
|
|
|
const s = styleOfEdge(xml)
|
|
|
|
|
lengths.push(s.length)
|
|
|
|
|
portCounts.push((s.match(/exitX=/g) ?? []).length)
|
|
|
|
|
t = parseDiagram(xml).tree
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Same length every pass, and the port keys stated once rather than accumulating.
|
|
|
|
|
expect(new Set(lengths).size).toBe(1)
|
|
|
|
|
expect(portCounts).toEqual([1, 1, 1, 1])
|
|
|
|
|
// Which is what lets the XML itself reach a fixed point.
|
|
|
|
|
expect(renderDiagram(parseDiagram(xml).tree).xml).toBe(xml)
|
|
|
|
|
})
|
feat(diagram-engine): layout + XML renderer, verified end to end in draw.io
Completes the tree → coordinates → XML direction, so the model can declare nesting
and never write a coordinate or an mxCell again.
layout.ts — measure bottom-up, place top-down, the same shape as flexbox. A
container sums its children along the flow axis and adds padding, so "child spills
out of its frame" and "siblings overlap" cannot happen by construction rather than
being caught afterwards. Slack from sibling equalisation is shared between children
instead of left as dead margin, capped at one gap so a stretched frame reads as
spaced rather than sparse.
render.ts — writes the mxCells, stamping container=1 and the dai_* markers so
parse.ts can read the structure back. Edges carry no waypoints: draw.io's own router
recomputes the route on every edit, so a user who moves a node never has to re-link
an arrow. Cells the parser could not interpret are re-emitted verbatim, so a
re-layout never deletes a user's annotations.
Phantoms are gone (task #5). The reference project's layout-only wrapper emits no
cell, which makes the round-trip lossy by construction — measured on its own
build_vpc.mjs, a phantom erased a container's "col" direction for good. An
unlabelled frame here emits a real cell with fillColor/strokeColor=none instead:
invisible, but present in the XML and therefore recoverable.
Two bugs the round-trip test caught, both real:
- An icon's cell was being emitted at its measured slot size, which includes room
for the label underneath. Parsing read that width back as the glyph size, so the
icon grew on every round-trip. The cell is now the glyph square and the label
renders outside it via verticalLabelPosition, as the reference does.
- An Azure or GCP icon is an embedded base64 image whose style contains no name
anywhere, so the catalog name was unrecoverable. Added a dai_name marker.
Verified in a real browser (3 Playwright tests, not mocks): engine output renders in
draw.io; dragging a shape into a frame makes draw.io rewrite its parent and the
engine reads the new structure back; re-laying out from that structure PRESERVES the
user's move instead of undoing it, and leaves untouched nodes alone; and the
re-laid-out XML still renders.
That last point is the whole design: there is no second copy of the state, so a
manual edit is an input to the next layout rather than a conflict to reconcile.
304 unit tests + 3 e2e.
2026-08-09 11:56:08 +09:00
|
|
|
})
|
|
|
|
|
|
|
|
|
|
describe("foreign cells survive", () => {
|
|
|
|
|
it("re-emits an unrecognised cell verbatim", () => {
|
|
|
|
|
const custom =
|
|
|
|
|
'<mxCell id="note" value="hand-written note" style="shape=note;whiteSpace=wrap;html=1;fillColor=#FFF2CC;" vertex="1" parent="1"><mxGeometry x="900" y="40" width="160" height="80" as="geometry"/></mxCell>'
|
|
|
|
|
const t = tree([group("f", "row", [icon("a")], "F")], {
|
|
|
|
|
foreign: [{ id: "note", xml: custom, parent: "1" }],
|
|
|
|
|
})
|
|
|
|
|
expect(renderDiagram(t).xml).toContain(custom)
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it("re-creates the boundaries layer when a foreign cell needs it", () => {
|
|
|
|
|
const t = tree([group("f", "row", [icon("a")], "F")], {
|
|
|
|
|
foreign: [
|
|
|
|
|
{
|
|
|
|
|
id: "cluster",
|
|
|
|
|
xml: '<mxCell id="cluster" value="EKS" style="dashed=1;fillColor=none;" vertex="1" parent="boundaries"><mxGeometry x="0" y="0" width="100" height="50" as="geometry"/></mxCell>',
|
|
|
|
|
parent: "boundaries",
|
|
|
|
|
},
|
|
|
|
|
],
|
|
|
|
|
})
|
|
|
|
|
const { xml } = renderDiagram(t)
|
|
|
|
|
expect(xml).toContain('<mxCell id="boundaries"')
|
|
|
|
|
expect(xml.indexOf('id="boundaries"')).toBeLessThan(
|
|
|
|
|
xml.indexOf('parent="boundaries"'),
|
|
|
|
|
)
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it("lets an edge anchor to a foreign cell", () => {
|
|
|
|
|
const t = tree([group("f", "row", [icon("a")], "F")], {
|
|
|
|
|
foreign: [
|
|
|
|
|
{
|
|
|
|
|
id: "legend",
|
|
|
|
|
xml: '<mxCell id="legend" value="L" style="rounded=0;" vertex="1" parent="1"><mxGeometry x="0" y="0" width="50" height="50" as="geometry"/></mxCell>',
|
|
|
|
|
parent: "1",
|
|
|
|
|
},
|
|
|
|
|
],
|
|
|
|
|
links: [{ source: "a", target: "legend" }],
|
|
|
|
|
})
|
|
|
|
|
const r = renderDiagram(t)
|
|
|
|
|
expect(r.danglingLinks).toEqual([])
|
|
|
|
|
expect(r.xml).toContain('target="legend"')
|
|
|
|
|
})
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
describe("the emitted XML is well formed for draw.io", () => {
|
|
|
|
|
const t = tree(
|
|
|
|
|
[
|
|
|
|
|
group(
|
|
|
|
|
"region",
|
|
|
|
|
"row",
|
|
|
|
|
[
|
|
|
|
|
group(
|
|
|
|
|
"vpc",
|
|
|
|
|
"col",
|
|
|
|
|
[icon("a"), icon("b")],
|
|
|
|
|
"VPC",
|
|
|
|
|
"group_vpc",
|
|
|
|
|
),
|
|
|
|
|
],
|
|
|
|
|
"Region",
|
|
|
|
|
"group_region",
|
|
|
|
|
),
|
|
|
|
|
],
|
|
|
|
|
{ title: "T", links: [{ source: "a", target: "b" }] },
|
|
|
|
|
)
|
|
|
|
|
const { xml } = renderDiagram(t)
|
|
|
|
|
|
|
|
|
|
it("wraps the model in mxfile/diagram", () => {
|
|
|
|
|
expect(xml.startsWith("<mxfile")).toBe(true)
|
|
|
|
|
expect(xml).toContain("<diagram")
|
|
|
|
|
expect(xml).toContain("<mxGraphModel")
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it("includes the two root cells draw.io requires", () => {
|
|
|
|
|
expect(xml).toContain('<mxCell id="0"/>')
|
|
|
|
|
expect(xml).toContain('<mxCell id="1" parent="0"/>')
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it("declares a parent before any cell that references it", () => {
|
|
|
|
|
expect(xml.indexOf('id="region"')).toBeLessThan(
|
|
|
|
|
xml.indexOf('parent="region"'),
|
|
|
|
|
)
|
|
|
|
|
expect(xml.indexOf('id="vpc"')).toBeLessThan(
|
|
|
|
|
xml.indexOf('parent="vpc"'),
|
|
|
|
|
)
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it("gives every cell a unique id", () => {
|
|
|
|
|
const ids = [...xml.matchAll(/<mxCell id="([^"]+)"/g)].map((m) => m[1])
|
|
|
|
|
expect(new Set(ids).size).toBe(ids.length)
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it("sets the page size from the content", () => {
|
|
|
|
|
const w = Number(xml.match(/pageWidth="(\d+)"/)?.[1])
|
|
|
|
|
const h = Number(xml.match(/pageHeight="(\d+)"/)?.[1])
|
|
|
|
|
expect(w).toBeGreaterThan(0)
|
|
|
|
|
expect(h).toBeGreaterThan(0)
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it("writes nested geometry relative to the parent, as draw.io expects", () => {
|
|
|
|
|
// vpc sits inside region, so its x must be small — an absolute x would push it
|
|
|
|
|
// outside the frame when draw.io adds the parent offset.
|
|
|
|
|
const vpcGeo = xml.match(
|
|
|
|
|
/<mxCell id="vpc"[^>]*>\s*<mxGeometry x="(-?\d+)"/,
|
|
|
|
|
)?.[1]
|
|
|
|
|
expect(Number(vpcGeo)).toBeLessThan(100)
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it("stamps container=1 on containers so drag-and-drop reparents correctly", () => {
|
|
|
|
|
// Verified in-browser: without this, a shape dragged into the frame keeps
|
|
|
|
|
// parent="1" and the nesting is lost.
|
|
|
|
|
const regionStyle =
|
|
|
|
|
xml.match(/<mxCell id="region"[^>]*style="([^"]*)"/)?.[1] ?? ""
|
|
|
|
|
expect(regionStyle).toContain("container=1")
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it("does not stamp container=1 on a leaf", () => {
|
|
|
|
|
const iconStyle =
|
|
|
|
|
xml.match(/<mxCell id="a"[^>]*style="([^"]*)"/)?.[1] ?? ""
|
|
|
|
|
expect(iconStyle).not.toContain("container=1")
|
|
|
|
|
})
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
describe("user edits are inputs, not conflicts", () => {
|
|
|
|
|
it("keeps a hand-changed fill through a re-layout", () => {
|
|
|
|
|
// The user recoloured a box in draw.io. Re-deriving the tree picks up the style
|
|
|
|
|
// verbatim, so re-rendering preserves the colour rather than resetting it.
|
|
|
|
|
const t = tree([group("f", "row", [icon("a"), box("b", "Box")], "F")])
|
|
|
|
|
const first = renderDiagram(t).xml
|
|
|
|
|
const edited = first.replace(
|
|
|
|
|
/(<mxCell id="b"[^>]*style=")([^"]*)"/,
|
|
|
|
|
'$1$2fillColor=#FF0000;"',
|
|
|
|
|
)
|
|
|
|
|
const back = parseDiagram(edited).tree
|
|
|
|
|
expect(renderDiagram(back).xml).toContain("fillColor=#FF0000")
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it("re-lays-out around a node the user dragged into a different frame", () => {
|
|
|
|
|
// Two frames; the user moves icon "b" from f1 into f2. draw.io rewrites the
|
|
|
|
|
// parent attribute (container=1 is in place), so the next layout puts it inside
|
|
|
|
|
// f2 — no reconciliation step, the canvas simply says where things are.
|
|
|
|
|
const t = tree([
|
|
|
|
|
group(
|
|
|
|
|
"root",
|
|
|
|
|
"row",
|
|
|
|
|
[
|
|
|
|
|
group("f1", "col", [icon("a"), icon("b")], "F1"),
|
|
|
|
|
group("f2", "col", [icon("c")], "F2"),
|
|
|
|
|
],
|
|
|
|
|
"Root",
|
|
|
|
|
),
|
|
|
|
|
])
|
|
|
|
|
const first = renderDiagram(t).xml
|
|
|
|
|
const moved = first.replace(
|
|
|
|
|
/(<mxCell id="b"[^>]*)parent="f1"/,
|
|
|
|
|
'$1parent="f2"',
|
|
|
|
|
)
|
|
|
|
|
const back = parseDiagram(moved).tree
|
|
|
|
|
expect(findParent(back, "b")?.id).toBe("f2")
|
|
|
|
|
// and the re-render keeps it there, sized to fit
|
|
|
|
|
const again = parseDiagram(renderDiagram(back).xml).tree
|
|
|
|
|
expect(findParent(again, "b")?.id).toBe("f2")
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it("honours a pin the user added by hand in Edit Style", () => {
|
|
|
|
|
const t = tree([box("pin", "Pinned"), box("flow", "Flow")])
|
|
|
|
|
const first = renderDiagram(t).xml
|
|
|
|
|
const pinned = first.replace(
|
|
|
|
|
/(<mxCell id="pin"[^>]*style="[^"]*)"/,
|
|
|
|
|
'$1dai_pin=1;"',
|
|
|
|
|
)
|
|
|
|
|
const back = parseDiagram(pinned).tree
|
|
|
|
|
const node = findNode(back, "pin") as BoxNode
|
|
|
|
|
expect(node.pinned).toBe(true)
|
|
|
|
|
const pos = node.rect
|
|
|
|
|
// re-rendering leaves the pinned node exactly where it was
|
|
|
|
|
const again = parseDiagram(renderDiagram(back).xml).tree
|
|
|
|
|
expect((findNode(again, "pin") as BoxNode).rect).toEqual(pos)
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it("does not lose a cell the user added by hand", () => {
|
|
|
|
|
const t = tree([group("f", "row", [icon("a")], "F")])
|
|
|
|
|
const first = renderDiagram(t).xml
|
|
|
|
|
// user drops a new shape on the canvas
|
|
|
|
|
const withNew = first.replace(
|
|
|
|
|
"</root>",
|
|
|
|
|
'<mxCell id="userbox" value="Mine" style="rounded=1;whiteSpace=wrap;html=1;" vertex="1" parent="1"><mxGeometry x="800" y="400" width="120" height="60" as="geometry"/></mxCell></root>',
|
|
|
|
|
)
|
|
|
|
|
const back = parseDiagram(withNew).tree
|
|
|
|
|
const ids = [...walkTree(back)].map((n) => n.id)
|
|
|
|
|
expect(ids).toContain("userbox")
|
|
|
|
|
expect(renderDiagram(back).xml).toContain('id="userbox"')
|
|
|
|
|
})
|
|
|
|
|
})
|