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.
This commit is contained in:
dayuan.jiang
2026-08-09 13:47:23 +09:00
parent 1e4c74d464
commit a3814f702d
27 changed files with 4497 additions and 88 deletions

View File

@@ -0,0 +1,304 @@
import { describe, expect, it } from "vitest"
import {
drawGraph,
type GraphEdge,
type GraphNode,
graphToOperations,
} from "@/lib/diagram-engine"
import {
absoluteRects,
edgePaths,
nodeCollisions,
rectOf,
} from "./fixtures/geometry"
const n = (id: string, label = id): GraphNode => ({ id, label })
const e = (source: string, target: string, label?: string): GraphEdge => ({
source,
target,
...(label ? { label } : {}),
})
describe("graphToOperations: layering", () => {
it("puts a chain in one node per layer", () => {
const { layers } = graphToOperations(
[n("a"), n("b"), n("c")],
[e("a", "b"), e("b", "c")],
)
expect(layers).toEqual([["a"], ["b"], ["c"]])
})
it("puts the branches of a decision in the same layer", () => {
const { layers } = graphToOperations(
[n("q"), n("yes"), n("no")],
[e("q", "yes"), e("q", "no")],
)
expect(layers[0]).toEqual(["q"])
expect(layers[1].sort()).toEqual(["no", "yes"])
})
it("uses the LONGEST path, so no arrow points sideways", () => {
// a→b, a→c, c→b. The shortest path would put b in layer 1 beside c, leaving c→b
// pointing sideways. b has to come after c.
const { layers } = graphToOperations(
[n("a"), n("b"), n("c")],
[e("a", "b"), e("a", "c"), e("c", "b")],
)
expect(layers).toEqual([["a"], ["c"], ["b"]])
})
it("keeps a node with no arrows at all", () => {
const { layers } = graphToOperations(
[n("a"), n("b"), n("island")],
[e("a", "b")],
)
expect(layers.flat().sort()).toEqual(["a", "b", "island"])
})
it("draws a loop but does not let it set the layering", () => {
const r = graphToOperations(
[n("a"), n("b"), n("c")],
[e("a", "b"), e("b", "c"), e("c", "a")],
)
expect(r.layers).toEqual([["a"], ["b"], ["c"]])
expect(r.backEdges).toEqual([{ source: "c", target: "a" }])
// The loop is still drawn.
expect(r.operations.filter((o) => o.op === "link").length).toBe(3)
})
it("draws a self-loop and keeps it out of the layering", () => {
const r = graphToOperations(
[n("a"), n("b")],
[e("a", "b"), e("a", "a")],
)
expect(r.layers).toEqual([["a"], ["b"]])
expect(r.operations.filter((o) => o.op === "link").length).toBe(2)
})
it("reports an edge naming a node that does not exist", () => {
const r = graphToOperations([n("a")], [e("a", "ghost")])
expect(r.unknownEndpoints).toEqual(["ghost"])
expect(r.operations.filter((o) => o.op === "link")).toEqual([])
})
it("survives a graph that is nothing but a cycle", () => {
const r = graphToOperations(
[n("a"), n("b")],
[e("a", "b"), e("b", "a")],
)
expect(r.layers.flat().sort()).toEqual(["a", "b"])
})
})
describe("graphToOperations: within-layer ordering", () => {
it("reverses a layer when that removes the crossings", () => {
// a→z, b→y, c→x. Declared order would make all three cross.
const { layers } = graphToOperations(
[n("a"), n("b"), n("c"), n("x"), n("y"), n("z")],
[e("a", "z"), e("b", "y"), e("c", "x")],
)
expect(layers[0]).toEqual(["a", "b", "c"])
expect(layers[1]).toEqual(["z", "y", "x"])
})
it("leaves an already-good order alone", () => {
const { layers } = graphToOperations(
[n("a"), n("b"), n("x"), n("y")],
[e("a", "x"), e("b", "y")],
)
expect(layers[1]).toEqual(["x", "y"])
})
})
describe("graphToOperations: emitted operations", () => {
it("does not wrap a layer holding one node", () => {
const { operations } = graphToOperations(
[n("a"), n("b")],
[e("a", "b")],
)
const containers = operations.filter((o) => o.op === "add_container")
// Only the outer flow container: neither single-node layer needs a wrapper.
expect(containers.length).toBe(1)
})
it("wraps a layer holding several nodes", () => {
const { operations } = graphToOperations(
[n("q"), n("yes"), n("no")],
[e("q", "yes"), e("q", "no")],
)
const containers = operations.filter((o) => o.op === "add_container")
expect(containers.length).toBe(2)
// The layer band runs ACROSS the flow.
const band = containers.find((c) => c.id !== "__layers")
expect(band?.dir).toBe("row")
})
it("flips both axes when the flow runs left to right", () => {
const { operations } = graphToOperations(
[n("q"), n("yes"), n("no")],
[e("q", "yes"), e("q", "no")],
{ flow: "row" },
)
const containers = operations.filter((o) => o.op === "add_container")
expect(containers.find((c) => c.id === "__layers")?.dir).toBe("row")
expect(containers.find((c) => c.id !== "__layers")?.dir).toBe("col")
})
it("carries shapes and labels through", () => {
const { operations } = graphToOperations(
[
{ id: "s", label: "Start", shape: "terminator" },
{ id: "q", label: "OK?", shape: "decision" },
],
[e("s", "q", "go")],
)
const boxes = operations.filter((o) => o.op === "add_box")
expect(boxes.map((b) => b.shape)).toEqual(["terminator", "decision"])
expect(operations.find((o) => o.op === "link")?.label).toBe("go")
})
it("emits an icon node as an icon", () => {
const { operations } = graphToOperations(
[{ id: "s3", label: "Bucket", icon: "s3" }],
[],
)
const icon = operations.find((o) => o.op === "add_icon")
expect(icon).toMatchObject({ id: "s3", name: "s3", label: "Bucket" })
})
it("does not emit a plain box shape as an explicit shape", () => {
const { operations } = graphToOperations(
[{ id: "a", label: "A", shape: "box" }],
[],
)
expect(operations.find((o) => o.op === "add_box")).not.toHaveProperty(
"shape",
)
})
})
describe("drawGraph: the whole pipeline", () => {
it("draws a decision flow with no arrow hitting an unrelated box", () => {
const ids = ["start", "check", "mgr", "auto", "ship", "reject"]
const r = drawGraph(
[
{ id: "start", label: "Order received", shape: "terminator" },
{ id: "check", label: "Amount > $1000?", shape: "decision" },
n("mgr", "Manager approval"),
n("auto", "Auto-approve"),
n("ship", "Ship order"),
{ id: "reject", label: "Reject", shape: "terminator" },
],
[
e("start", "check"),
e("check", "mgr", "yes"),
e("check", "auto", "no"),
e("mgr", "ship", "approved"),
e("mgr", "reject", "denied"),
e("auto", "ship"),
],
{ title: "Order Approval" },
)
expect(r.errors).toEqual([])
const xml = r.xml as string
const rects = absoluteRects(xml)
expect(nodeCollisions(edgePaths(xml, rects), rects, ids)).toEqual([])
// Layers descend in flow order.
expect(rectOf(rects, "start").y).toBeLessThan(rectOf(rects, "check").y)
expect(rectOf(rects, "check").y).toBeLessThan(rectOf(rects, "mgr").y)
expect(rectOf(rects, "mgr").y).toBe(rectOf(rects, "auto").y)
expect(xml).toContain("Order Approval")
})
it("renders a decision as a diamond and a terminator as a stadium", () => {
const r = drawGraph(
[
{ id: "s", label: "Start", shape: "terminator" },
{ id: "q", label: "OK?", shape: "decision" },
{ id: "d", label: "Report", shape: "document" },
{ id: "i", label: "Input", shape: "data" },
],
[e("s", "q"), e("q", "d"), e("q", "i")],
)
expect(r.errors).toEqual([])
const xml = r.xml as string
expect(xml).toMatch(/id="q"[^>]*rhombus/)
expect(xml).toMatch(/id="s"[^>]*arcSize=50/)
expect(xml).toMatch(/id="d"[^>]*shape=document/)
expect(xml).toMatch(/id="i"[^>]*shape=parallelogram/)
})
it("keeps a 14-node pipeline free of arrows through boxes", () => {
const ids = [
"commit",
"lint",
"unit",
"build",
"itest",
"sec",
"stage",
"smoke",
"approve",
"prod",
"canary",
"monitor",
"alert",
"rollback",
]
const r = drawGraph(
ids.map((id) => n(id)),
[
e("commit", "lint"),
e("commit", "unit"),
e("lint", "build"),
e("unit", "build"),
e("build", "itest"),
e("build", "sec"),
e("itest", "stage"),
e("sec", "stage"),
e("stage", "smoke"),
e("smoke", "approve"),
e("approve", "prod"),
e("prod", "canary"),
e("canary", "monitor"),
e("monitor", "alert"),
e("alert", "rollback"),
e("rollback", "stage"),
],
)
expect(r.errors).toEqual([])
const rects = absoluteRects(r.xml as string)
expect(
nodeCollisions(edgePaths(r.xml as string, rects), rects, ids),
).toEqual([])
})
it("rejects an empty node list rather than drawing nothing", () => {
const r = drawGraph([], [])
expect(r.xml).toBeNull()
expect(r.errors[0]).toContain("no nodes")
})
it("rejects a duplicate id instead of silently dropping one", () => {
const r = drawGraph([n("a"), n("a")], [])
expect(r.xml).toBeNull()
expect(r.errors[0]).toContain("duplicate")
})
it("warns about an edge naming a node that is not there", () => {
const r = drawGraph([n("a")], [e("a", "ghost")])
expect(r.errors).toEqual([])
expect(r.warnings.join(" ")).toContain("ghost")
})
it("warns which arrows were treated as loops", () => {
const r = drawGraph(
[n("a"), n("b")],
[e("a", "b"), e("b", "a", "retry")],
)
expect(r.errors).toEqual([])
expect(r.warnings.join(" ")).toContain("b→a")
})
})

View File

@@ -1,13 +1,23 @@
import { describe, expect, it } from "vitest"
import {
hasMarkers,
isLaneChrome,
isPinned,
MARKER,
readCell,
readDir,
readIntMarker,
readKind,
readList,
readMarker,
stampCell,
stampContainer,
stampLane,
stampLeaf,
stampPool,
stampPoolDecoration,
stampRadial,
stampSequence,
stripMarkers,
} from "@/lib/diagram-engine/markers"
@@ -210,3 +220,127 @@ describe("hasMarkers", () => {
expect(hasMarkers("mydai_dir=row;")).toBe(false)
})
})
describe("pool, sequence and radial markers", () => {
it("records a pool's lanes, phases and orientation", () => {
const s = stampPool("", {
lanes: ["Employee", "Manager"],
phases: ["Submit", "Pay"],
orientation: "horizontal",
gap: 40,
})
expect(readKind(s)).toBe("pool")
expect(readList(s, MARKER.lanes)).toEqual(["Employee", "Manager"])
expect(readList(s, MARKER.phases)).toEqual(["Submit", "Pay"])
expect(readMarker(s, MARKER.orient)).toBe("h")
expect(readIntMarker(s, MARKER.gap)).toBe(40)
})
it("marks a vertical pool", () => {
const s = stampPool("", {
lanes: ["A"],
phases: [],
orientation: "vertical",
gap: 30,
})
expect(readMarker(s, MARKER.orient)).toBe("v")
expect(readList(s, MARKER.phases)).toEqual([])
})
it("survives a lane name holding a semicolon or an equals sign", () => {
// Both delimit a draw.io style string, so an unencoded label would break the cell.
const s = stampPool("", {
lanes: ["a;b", "c=d", "e\tf"],
phases: [],
orientation: "horizontal",
gap: 10,
})
expect(readList(s, MARKER.lanes)).toEqual(["a;b", "c=d", "e\tf"])
// The style itself must still be one flat token list.
expect(s.split(";").filter((t) => t.includes("dai_lanes")).length).toBe(
1,
)
})
it("does not confuse an empty lane list with no marker at all", () => {
const s = stampPool("", {
lanes: [],
phases: [],
orientation: "horizontal",
gap: 10,
})
expect(readList(s, MARKER.lanes)).toEqual([])
expect(readList(s, "dai_absent")).toBeNull()
})
it("records a sequence's participant and message spacing", () => {
const s = stampSequence("", { gap: 60, step: 44 })
expect(readKind(s)).toBe("sequence")
expect(readIntMarker(s, MARKER.gap)).toBe(60)
expect(readIntMarker(s, MARKER.step)).toBe(44)
})
it("records how a radial container spreads its branches", () => {
expect(
readMarker(
stampRadial("", { spread: "down", gap: 40 }),
MARKER.spread,
),
).toBe("down")
expect(
readMarker(
stampRadial("", { spread: "radial", gap: 40 }),
MARKER.spread,
),
).toBe("radial")
expect(readKind(stampRadial("", { spread: "down", gap: 40 }))).toBe(
"radial",
)
})
it("records which pool cell a node sits in", () => {
expect(readCell(stampCell("", { lane: 2, col: 5 }))).toEqual({
lane: 2,
col: 5,
})
})
it("clamps a negative cell index rather than writing it", () => {
expect(readCell(stampCell("", { lane: -1, col: -3 }))).toEqual({
lane: 0,
col: 0,
})
})
it("reads no cell from a style that has none, or a malformed one", () => {
expect(readCell(VPC_STYLE)).toBeNull()
expect(readCell("dai_cell=notacell;")).toBeNull()
expect(readCell("dai_cell=1;")).toBeNull()
})
it("makes a lane band a container so a dragged step reparents into it", () => {
const s = stampLane("", 1)
expect(s).toContain("container=1")
expect(isLaneChrome(s)).toBe(true)
expect(readIntMarker(s, MARKER.lane)).toBe(1)
})
it("marks a pool's label strip as chrome that claims no lane", () => {
const s = stampPoolDecoration("")
expect(isLaneChrome(s)).toBe(true)
expect(readIntMarker(s, MARKER.lane)).toBeNull()
})
it("does not mistake an ordinary cell for pool chrome", () => {
expect(isLaneChrome(VPC_STYLE)).toBe(false)
expect(
isLaneChrome(
stampContainer(VPC_STYLE, {
kind: "group",
dir: "row",
gap: 8,
}),
),
).toBe(false)
})
})

View File

@@ -12,6 +12,7 @@ import {
type DiagramNode,
findNode,
findParent,
type GroupNode,
isContainer,
walkTree,
} from "@/lib/diagram-engine/types"
@@ -117,11 +118,11 @@ describe("parseDiagram on real engine output", () => {
it("classifies AWS group stencils as containers and keeps their stencil name", () => {
const vpc = findNode(tree, "vpc")
expect(isContainer(vpc as DiagramNode)).toBe(true)
expect((vpc as ContainerNode).gname).toBe("group_vpc")
expect((findNode(tree, "az_a") as ContainerNode).gname).toBe(
expect((vpc as GroupNode).gname).toBe("group_vpc")
expect((findNode(tree, "az_a") as GroupNode).gname).toBe(
"group_availability_zone",
)
expect((findNode(tree, "pub_a") as ContainerNode).gname).toBe(
expect((findNode(tree, "pub_a") as GroupNode).gname).toBe(
"group_subnet",
)
})

View File

@@ -0,0 +1,328 @@
import { describe, expect, it } from "vitest"
import { type Operation, restructureDiagram } from "@/lib/diagram-engine"
import { parseDiagram } from "@/lib/diagram-engine/parse"
import type { PoolNode } from "@/lib/diagram-engine/types"
import { findNode } from "@/lib/diagram-engine/types"
import {
absoluteRects,
escapesParent,
outsidePage,
overlaps,
parentOf,
rectOf,
} from "./fixtures/geometry"
/** An expense-approval swimlane: three roles, five steps, three milestone labels. */
const EXPENSE: Operation[] = [
{ op: "set_title", title: "Expense Approval" },
{
op: "add_pool",
id: "p",
label: "Expense claim",
lanes: ["Employee", "Manager", "Finance"],
phases: ["Submit", "Review", "Pay"],
},
{
op: "add_box",
id: "fill",
parent: "p",
label: "Fill form",
lane: 0,
col: 0,
shape: "terminator",
},
{
op: "add_box",
id: "send",
parent: "p",
label: "Submit claim",
lane: 0,
col: 1,
},
{ op: "add_box", id: "rev", parent: "p", label: "Review", lane: 1, col: 2 },
{
op: "add_box",
id: "ok",
parent: "p",
label: "Approved?",
lane: 1,
col: 3,
shape: "decision",
},
{
op: "add_box",
id: "pay",
parent: "p",
label: "Pay out",
lane: 2,
col: 4,
},
{ op: "link", source: "fill", target: "send" },
{ op: "link", source: "send", target: "rev" },
{ op: "link", source: "rev", target: "ok" },
{ op: "link", source: "ok", target: "pay", label: "yes" },
]
const STEPS = ["fill", "send", "rev", "ok", "pay"]
describe("swimlane pool: layout", () => {
const result = restructureDiagram("", EXPENSE)
const xml = result.xml as string
const rects = absoluteRects(xml)
it("builds without errors", () => {
expect(result.errors).toEqual([])
})
it("stacks the lanes in the order they were declared", () => {
expect(rectOf(rects, "fill").y).toBeLessThan(rectOf(rects, "rev").y)
expect(rectOf(rects, "rev").y).toBeLessThan(rectOf(rects, "pay").y)
})
it("advances the columns left to right", () => {
expect(rectOf(rects, "fill").x).toBeLessThan(rectOf(rects, "send").x)
expect(rectOf(rects, "send").x).toBeLessThan(rectOf(rects, "rev").x)
expect(rectOf(rects, "rev").x).toBeLessThan(rectOf(rects, "ok").x)
})
it("puts two steps with the same column at the same x", () => {
const r = restructureDiagram("", [
{ op: "add_pool", id: "p", label: "", lanes: ["A", "B"] },
{
op: "add_box",
id: "x",
parent: "p",
label: "X",
lane: 0,
col: 1,
},
{
op: "add_box",
id: "y",
parent: "p",
label: "Y",
lane: 1,
col: 1,
},
])
const rr = absoluteRects(r.xml as string)
expect(rectOf(rr, "x").x).toBe(rectOf(rr, "y").x)
expect(rectOf(rr, "x").y).not.toBe(rectOf(rr, "y").y)
})
it("draws one band per lane, with the role names beside them", () => {
expect([...xml.matchAll(/id="p__band(\d)"/g)].map((m) => m[1])).toEqual(
["0", "1", "2"],
)
expect(
[...xml.matchAll(/id="p__lane\d" value="([^"]*)"/g)].map(
(m) => m[1],
),
).toEqual(["Employee", "Manager", "Finance"])
})
it("draws the milestone labels", () => {
expect(
[...xml.matchAll(/id="p__phase\d" value="([^"]*)"/g)].map(
(m) => m[1],
),
).toEqual(["Submit", "Review", "Pay"])
})
it("omits the milestone band when no phases were given", () => {
const r = restructureDiagram("", [
{ op: "add_pool", id: "p", label: "", lanes: ["A"] },
{
op: "add_box",
id: "x",
parent: "p",
label: "X",
lane: 0,
col: 0,
},
])
expect(r.xml).not.toContain("p__phase")
})
it("parents each step to its lane band, not to the pool", () => {
// This is what records a role change when the user drags a step to another lane:
// draw.io rewrites `parent` to the band it was dropped on.
expect(parentOf(xml, "fill")).toBe("p__band0")
expect(parentOf(xml, "rev")).toBe("p__band1")
expect(parentOf(xml, "pay")).toBe("p__band2")
})
it("keeps everything inside the page and inside its parent", () => {
expect(outsidePage(xml, ["__title"])).toEqual([])
expect(escapesParent(xml)).toEqual([])
expect(overlaps(rects, STEPS)).toEqual([])
})
it("clamps a lane index past the last lane instead of drawing off the pool", () => {
const r = restructureDiagram("", [
{ op: "add_pool", id: "p", label: "", lanes: ["only"] },
{
op: "add_box",
id: "x",
parent: "p",
label: "X",
lane: 9,
col: 0,
},
])
expect(r.errors).toEqual([])
expect(escapesParent(r.xml as string)).toEqual([])
})
it("lays a vertical pool out with the lanes as columns", () => {
const r = restructureDiagram("", [
{
op: "add_pool",
id: "p",
label: "",
lanes: ["A", "B"],
orientation: "vertical",
},
{
op: "add_box",
id: "x",
parent: "p",
label: "X",
lane: 0,
col: 0,
},
{
op: "add_box",
id: "y",
parent: "p",
label: "Y",
lane: 1,
col: 0,
},
{
op: "add_box",
id: "z",
parent: "p",
label: "Z",
lane: 0,
col: 1,
},
])
expect(r.errors).toEqual([])
const rr = absoluteRects(r.xml as string)
// Lanes side by side, the flow running downwards.
expect(rectOf(rr, "x").x).toBeLessThan(rectOf(rr, "y").x)
expect(rectOf(rr, "x").y).toBeLessThan(rectOf(rr, "z").y)
expect(escapesParent(r.xml as string)).toEqual([])
})
it("refuses a pool with no lanes", () => {
const r = restructureDiagram("", [
{ op: "add_pool", id: "p", label: "x", lanes: [] },
])
expect(r.xml).toBeNull()
expect(r.errors[0]).toContain("at least one lane")
})
})
describe("swimlane pool: round-trip", () => {
it("comes back with the same lanes, phases and cells", () => {
const first = restructureDiagram("", EXPENSE)
const { tree, warnings } = parseDiagram(first.xml as string)
expect(warnings).toEqual([])
const pool = findNode(tree, "p") as PoolNode
expect(pool.kind).toBe("pool")
expect(pool.lanes).toEqual(["Employee", "Manager", "Finance"])
expect(pool.phases).toEqual(["Submit", "Review", "Pay"])
expect(pool.orientation).toBe("horizontal")
expect(pool.children.map((c) => c.id)).toEqual(STEPS)
})
it("does not move anything on a re-layout", () => {
const first = restructureDiagram("", EXPENSE)
const second = restructureDiagram(first.xml as string, [])
expect(second.errors).toEqual([])
expect(second.warnings).toEqual([])
const a = absoluteRects(first.xml as string)
const b = absoluteRects(second.xml as string)
for (const id of STEPS) expect(b.get(id)).toEqual(a.get(id))
})
it("does not accumulate lane bands across round-trips", () => {
// The bands are chrome the renderer rebuilds. Preserving them verbatim would leave a
// stale set behind the new ones on every pass.
const first = restructureDiagram("", EXPENSE)
const second = restructureDiagram(first.xml as string, [])
const third = restructureDiagram(second.xml as string, [])
const count = (xml: string) => (xml.match(/__band\d/g) ?? []).length
expect(count(third.xml as string)).toBe(count(first.xml as string))
})
it("reads a step's new lane from the band the user dropped it on", () => {
const first = restructureDiagram("", EXPENSE)
// Simulate the drag: draw.io rewrites the cell's parent to the new band.
const moved = (first.xml as string).replace(
/(<mxCell id="pay"[^>]*parent=")p__band2(")/,
"$1p__band0$2",
)
expect(moved).not.toBe(first.xml)
const pool = findNode(parseDiagram(moved).tree, "p") as PoolNode
const pay = pool.children.find((c) => c.id === "pay")
expect(pay).toBeDefined()
// The band wins over the stale marker still on the cell.
expect((pay as { cell?: { lane: number } }).cell?.lane).toBe(0)
})
it("keeps a vertical pool vertical", () => {
const first = restructureDiagram("", [
{
op: "add_pool",
id: "p",
label: "V",
lanes: ["A", "B"],
orientation: "vertical",
},
{
op: "add_box",
id: "x",
parent: "p",
label: "X",
lane: 1,
col: 0,
},
])
const pool = findNode(
parseDiagram(first.xml as string).tree,
"p",
) as PoolNode
expect(pool.orientation).toBe("vertical")
expect(pool.lanes).toEqual(["A", "B"])
})
it("survives a lane name containing a semicolon or an equals sign", () => {
// Those two characters delimit a draw.io style string, so a naive marker would break
// the whole cell.
const first = restructureDiagram("", [
{
op: "add_pool",
id: "p",
label: "",
lanes: ["a;b", "c=d", "plain"],
},
{
op: "add_box",
id: "x",
parent: "p",
label: "X",
lane: 0,
col: 0,
},
])
expect(first.errors).toEqual([])
const pool = findNode(
parseDiagram(first.xml as string).tree,
"p",
) as PoolNode
expect(pool.lanes).toEqual(["a;b", "c=d", "plain"])
})
})

View File

@@ -0,0 +1,286 @@
import { describe, expect, it } from "vitest"
import { type Operation, restructureDiagram } from "@/lib/diagram-engine"
import { parseDiagram } from "@/lib/diagram-engine/parse"
import type { RadialNode } from "@/lib/diagram-engine/types"
import { findNode } from "@/lib/diagram-engine/types"
import {
absoluteRects,
escapesParent,
outsidePage,
overlaps,
rectOf,
} from "./fixtures/geometry"
/** A mind map. Children are a FLAT list; the links carry the hierarchy. */
const MINDMAP: Operation[] = [
{ op: "add_radial", id: "m", label: "", spread: "radial" },
{ op: "add_box", id: "root", parent: "m", label: "Product Launch" },
{ op: "add_box", id: "eng", parent: "m", label: "Engineering" },
{ op: "add_box", id: "api", parent: "m", label: "API" },
{ op: "add_box", id: "ui", parent: "m", label: "UI" },
{ op: "add_box", id: "mkt", parent: "m", label: "Marketing" },
{ op: "add_box", id: "legal", parent: "m", label: "Legal" },
{ op: "add_box", id: "ops", parent: "m", label: "Ops" },
{ op: "add_box", id: "sre", parent: "m", label: "SRE" },
{ op: "link", source: "root", target: "eng" },
{ op: "link", source: "root", target: "mkt" },
{ op: "link", source: "root", target: "legal" },
{ op: "link", source: "root", target: "ops" },
{ op: "link", source: "eng", target: "api" },
{ op: "link", source: "eng", target: "ui" },
{ op: "link", source: "ops", target: "sre" },
]
const MIND_IDS = ["root", "eng", "api", "ui", "mkt", "legal", "ops", "sre"]
const ORGCHART: Operation[] = [
{ op: "add_radial", id: "o", label: "", spread: "down" },
{ op: "add_box", id: "ceo", parent: "o", label: "CEO" },
{ op: "add_box", id: "cto", parent: "o", label: "CTO" },
{ op: "add_box", id: "eng1", parent: "o", label: "Platform Lead" },
{ op: "add_box", id: "eng2", parent: "o", label: "Mobile Lead" },
{ op: "add_box", id: "cfo", parent: "o", label: "CFO" },
{ op: "add_box", id: "acct", parent: "o", label: "Accounting" },
{ op: "link", source: "ceo", target: "cto" },
{ op: "link", source: "ceo", target: "cfo" },
{ op: "link", source: "cto", target: "eng1" },
{ op: "link", source: "cto", target: "eng2" },
{ op: "link", source: "cfo", target: "acct" },
]
const ORG_IDS = ["ceo", "cto", "eng1", "eng2", "cfo", "acct"]
describe("mind map: radial spread", () => {
const result = restructureDiagram("", MINDMAP)
const xml = result.xml as string
const rects = absoluteRects(xml)
it("builds without errors", () => {
expect(result.errors).toEqual([])
})
it("makes the node nothing points at the centre", () => {
const centre = rectOf(rects, "root")
const mid = centre.x + centre.w / 2
const sides = ["eng", "mkt", "legal", "ops"].map((id) =>
rectOf(rects, id).x > mid ? "right" : "left",
)
// Branches on both sides, which is what keeps a mind map compact.
expect(sides).toContain("right")
expect(sides).toContain("left")
})
it("puts a sub-branch further from the centre than its parent", () => {
const centre = rectOf(rects, "root")
const mid = centre.x + centre.w / 2
const far = (id: string) =>
Math.abs(rectOf(rects, id).x + rectOf(rects, id).w / 2 - mid)
expect(far("api")).toBeGreaterThan(far("eng"))
expect(far("ui")).toBeGreaterThan(far("eng"))
expect(far("sre")).toBeGreaterThan(far("ops"))
})
it("puts siblings of the same generation at the same distance out", () => {
expect(rectOf(rects, "api").x).toBe(rectOf(rects, "ui").x)
})
it("draws nothing on top of anything else", () => {
expect(overlaps(rects, MIND_IDS)).toEqual([])
})
it("keeps everything inside the page and inside the frame", () => {
expect(outsidePage(xml, ["__title"])).toEqual([])
expect(escapesParent(xml)).toEqual([])
})
it("fits a map whose two sides are different depths", () => {
// Reserving the same room on both sides would push the deeper side off the page.
const r = restructureDiagram("", [
{ op: "add_radial", id: "m", label: "", spread: "radial" },
{ op: "add_box", id: "root", parent: "m", label: "Root" },
{ op: "add_box", id: "shallow", parent: "m", label: "A" },
{ op: "add_box", id: "b", parent: "m", label: "B" },
{ op: "add_box", id: "b1", parent: "m", label: "B1" },
{ op: "add_box", id: "b2", parent: "m", label: "B2" },
{ op: "add_box", id: "b3", parent: "m", label: "B3" },
{ op: "link", source: "root", target: "shallow" },
{ op: "link", source: "root", target: "b" },
{ op: "link", source: "b", target: "b1" },
{ op: "link", source: "b1", target: "b2" },
{ op: "link", source: "b2", target: "b3" },
])
expect(r.errors).toEqual([])
expect(outsidePage(r.xml as string, ["__title"])).toEqual([])
expect(escapesParent(r.xml as string)).toEqual([])
})
it("still draws a node no arrow reaches", () => {
const r = restructureDiagram("", [
{ op: "add_radial", id: "m", label: "", spread: "radial" },
{ op: "add_box", id: "root", parent: "m", label: "Root" },
{ op: "add_box", id: "a", parent: "m", label: "A" },
{ op: "add_box", id: "orphan", parent: "m", label: "Orphan" },
{ op: "link", source: "root", target: "a" },
])
expect(r.errors).toEqual([])
const rr = absoluteRects(r.xml as string)
expect(rr.has("orphan")).toBe(true)
expect(overlaps(rr, ["root", "a", "orphan"])).toEqual([])
expect(escapesParent(r.xml as string)).toEqual([])
})
it("survives arrows that form a cycle", () => {
const r = restructureDiagram("", [
{ op: "add_radial", id: "m", label: "", spread: "radial" },
{ op: "add_box", id: "x", parent: "m", label: "X" },
{ op: "add_box", id: "y", parent: "m", label: "Y" },
{ op: "add_box", id: "z", parent: "m", label: "Z" },
{ op: "link", source: "x", target: "y" },
{ op: "link", source: "y", target: "z" },
{ op: "link", source: "z", target: "x" },
])
expect(r.errors).toEqual([])
expect(
overlaps(absoluteRects(r.xml as string), ["x", "y", "z"]),
).toEqual([])
expect(escapesParent(r.xml as string)).toEqual([])
})
it("handles a radial container holding one node", () => {
const r = restructureDiagram("", [
{ op: "add_radial", id: "m", label: "", spread: "radial" },
{ op: "add_box", id: "only", parent: "m", label: "Only" },
])
expect(r.errors).toEqual([])
expect(escapesParent(r.xml as string)).toEqual([])
})
it("handles an empty radial container", () => {
const r = restructureDiagram("", [
{ op: "add_radial", id: "m", label: "", spread: "radial" },
])
expect(r.errors).toEqual([])
expect(r.xml).toContain('id="m"')
})
})
describe("org chart: downward spread", () => {
const result = restructureDiagram("", ORGCHART)
const xml = result.xml as string
const rects = absoluteRects(xml)
it("builds without errors", () => {
expect(result.errors).toEqual([])
})
it("hangs every level strictly below the one above", () => {
// A reporting line only reads correctly downwards, which is the whole reason this
// spread exists separately from the radial one.
expect(rectOf(rects, "ceo").y).toBeLessThan(rectOf(rects, "cto").y)
expect(rectOf(rects, "cto").y).toBeLessThan(rectOf(rects, "eng1").y)
expect(rectOf(rects, "cfo").y).toBeLessThan(rectOf(rects, "acct").y)
})
it("puts peers on the same line", () => {
expect(rectOf(rects, "cto").y).toBe(rectOf(rects, "cfo").y)
expect(rectOf(rects, "eng1").y).toBe(rectOf(rects, "eng2").y)
expect(rectOf(rects, "eng1").y).toBe(rectOf(rects, "acct").y)
})
it("keeps one manager's reports clear of another's", () => {
// Sizing each slice by its whole subtree, not by the number of direct reports, is what
// stops a manager with two reports from overrunning the next manager's column.
expect(overlaps(rects, ORG_IDS)).toEqual([])
const eng2 = rectOf(rects, "eng2")
expect(eng2.x + eng2.w).toBeLessThanOrEqual(rectOf(rects, "acct").x)
})
it("keeps everything inside the page and inside the frame", () => {
expect(outsidePage(xml, ["__title"])).toEqual([])
expect(escapesParent(xml)).toEqual([])
})
it("fits a chain five levels deep", () => {
const r = restructureDiagram("", [
{ op: "add_radial", id: "o", label: "", spread: "down" },
...["a", "b", "c", "d", "e"].map(
(id) =>
({
op: "add_box",
id,
parent: "o",
label: id.toUpperCase(),
}) as Operation,
),
{ op: "link", source: "a", target: "b" },
{ op: "link", source: "b", target: "c" },
{ op: "link", source: "c", target: "d" },
{ op: "link", source: "d", target: "e" },
])
expect(r.errors).toEqual([])
const rr = absoluteRects(r.xml as string)
const ys = ["a", "b", "c", "d", "e"].map((id) => rectOf(rr, id).y)
expect(ys).toEqual([...ys].sort((x, y) => x - y))
expect(outsidePage(r.xml as string, ["__title"])).toEqual([])
})
})
describe("radial: round-trip", () => {
it("comes back as a radial container with the same spread", () => {
const first = restructureDiagram("", MINDMAP)
const { tree, warnings } = parseDiagram(first.xml as string)
expect(warnings).toEqual([])
const radial = findNode(tree, "m") as RadialNode
expect(radial.kind).toBe("radial")
expect(radial.spread).toBe("radial")
expect(radial.children.map((c) => c.id).sort()).toEqual(
[...MIND_IDS].sort(),
)
})
it("keeps an org chart pointing downwards", () => {
const first = restructureDiagram("", ORGCHART)
const radial = findNode(
parseDiagram(first.xml as string).tree,
"o",
) as RadialNode
expect(radial.spread).toBe("down")
})
it("reaches a fixed point: a mind map does not drift on re-layout", () => {
const first = restructureDiagram("", MINDMAP)
const second = restructureDiagram(first.xml as string, [])
const third = restructureDiagram(second.xml as string, [])
expect(second.errors).toEqual([])
expect(second.warnings).toEqual([])
const a = absoluteRects(first.xml as string)
const b = absoluteRects(second.xml as string)
const c = absoluteRects(third.xml as string)
for (const id of MIND_IDS) {
expect(b.get(id)).toEqual(a.get(id))
expect(c.get(id)).toEqual(a.get(id))
}
})
it("reaches a fixed point: an org chart does not drift on re-layout", () => {
const first = restructureDiagram("", ORGCHART)
const second = restructureDiagram(first.xml as string, [])
const third = restructureDiagram(second.xml as string, [])
const a = absoluteRects(first.xml as string)
const c = absoluteRects(third.xml as string)
for (const id of ORG_IDS) expect(c.get(id)).toEqual(a.get(id))
})
it("adds a branch to an existing map without redrawing it", () => {
const first = restructureDiagram("", MINDMAP)
const second = restructureDiagram(first.xml as string, [
{ op: "add_box", id: "docs", parent: "m", label: "Docs" },
{ op: "link", source: "root", target: "docs" },
])
expect(second.errors).toEqual([])
const rects = absoluteRects(second.xml as string)
expect(rects.has("docs")).toBe(true)
expect(overlaps(rects, [...MIND_IDS, "docs"])).toEqual([])
expect(escapesParent(second.xml as string)).toEqual([])
})
})

View File

@@ -60,7 +60,12 @@ 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}`]
const meta = n.kind === "grid" ? `cols=${n.cols}` : `dir=${n.dir}`
const meta =
n.kind === "grid"
? `cols=${n.cols}`
: n.kind === "group"
? `dir=${n.dir}`
: n.kind
return [
`${pad}${n.kind} ${n.id} ${meta} gap=${n.gap}`,
...n.children.flatMap((c) => line(c, depth + 1)),
@@ -294,9 +299,9 @@ describe("labels and content survive", () => {
? `shape=mxgraph.aws4.group;grIcon=mxgraph.aws4.${name};fillColor=none;strokeColor=#8C4FFF;verticalAlign=top;align=left;`
: null,
})
expect(
(findNode(parseDiagram(xml).tree, "v") as ContainerNode).gname,
).toBe("group_vpc")
expect((findNode(parseDiagram(xml).tree, "v") as GroupNode).gname).toBe(
"group_vpc",
)
})
})

View File

@@ -0,0 +1,254 @@
import { describe, expect, it } from "vitest"
import { type Operation, restructureDiagram } from "@/lib/diagram-engine"
import { parseDiagram } from "@/lib/diagram-engine/parse"
import type { SequenceNode } from "@/lib/diagram-engine/types"
import { findNode } from "@/lib/diagram-engine/types"
import {
absoluteRects,
escapesParent,
outsidePage,
rectOf,
} from "./fixtures/geometry"
const LOGIN: Operation[] = [
{ op: "add_sequence", id: "s", label: "Login flow" },
{ op: "add_box", id: "u", parent: "s", label: "User" },
{ op: "add_box", id: "web", parent: "s", label: "Web App" },
{ op: "add_box", id: "auth", parent: "s", label: "Auth Service" },
{ op: "add_box", id: "db", parent: "s", label: "Database" },
{ op: "link", source: "u", target: "web", label: "credentials", step: 1 },
{
op: "link",
source: "web",
target: "auth",
label: "POST /login",
step: 2,
},
{ op: "link", source: "auth", target: "db", label: "find user", step: 3 },
{ op: "link", source: "db", target: "auth", label: "user record", step: 4 },
{
op: "link",
source: "auth",
target: "auth",
label: "sign token",
step: 5,
},
{ op: "link", source: "auth", target: "web", label: "JWT", step: 6 },
{ op: "link", source: "web", target: "u", label: "redirect", step: 7 },
]
const PARTICIPANTS = ["u", "web", "auth", "db"]
/** Each message's y, in the order the cells were written. */
function messageYs(xml: string): { id: string; from: number; to: number }[] {
return [
...xml.matchAll(
/<mxCell id="(ed\d+)"[\s\S]*?<mxPoint x="-?[\d.]+" y="(-?[\d.]+)" as="sourcePoint"\/><mxPoint x="-?[\d.]+" y="(-?[\d.]+)" as="targetPoint"\/>/g,
),
].map((m) => ({ id: m[1], from: Number(m[2]), to: Number(m[3]) }))
}
describe("sequence diagram: layout", () => {
const result = restructureDiagram("", LOGIN)
const xml = result.xml as string
const rects = absoluteRects(xml)
it("builds without errors", () => {
expect(result.errors).toEqual([])
})
it("puts the participants in a row, in declaration order", () => {
const xs = PARTICIPANTS.map((id) => rectOf(rects, id).x)
expect(xs).toEqual([...xs].sort((a, b) => a - b))
// All the heads share a top edge.
const ys = PARTICIPANTS.map((id) => rectOf(rects, id).y)
expect(new Set(ys).size).toBe(1)
})
it("draws each participant as a lifeline, head and line in one cell", () => {
// One cell so draw.io keeps them together when the user drags the participant.
for (const id of PARTICIPANTS)
expect(xml).toMatch(
new RegExp(`id="${id}"[^>]*shape=umlLifeline[^>]*size=\\d+`),
)
})
it("makes the lifelines long enough for every message", () => {
const lowest = Math.max(...messageYs(xml).map((m) => m.to))
for (const id of PARTICIPANTS) {
const r = rects.get(id) as { y: number; h: number }
expect(r.y + r.h).toBeGreaterThan(lowest)
}
})
it("orders the messages down the page by step number", () => {
const ys = messageYs(xml)
expect(ys.length).toBe(7)
const tops = ys.map((m) => Math.min(m.from, m.to))
expect(tops).toEqual([...tops].sort((a, b) => a - b))
// No two messages on the same line.
expect(new Set(tops).size).toBe(7)
})
it("draws a message as a horizontal line between two lifelines", () => {
const ys = messageYs(xml)
// Every message except the self-call is level.
const level = ys.filter((m) => m.from === m.to)
expect(level.length).toBe(6)
})
it("steps a self-message out and back a row lower", () => {
const self = messageYs(xml).find((m) => m.from !== m.to)
expect(self).toBeDefined()
expect((self as { to: number }).to).toBeGreaterThan(
(self as { from: number }).from,
)
// Two waypoints take it out to the side and back.
expect(xml).toMatch(
/id="ed5"[\s\S]*?<Array as="points"><mxPoint[^>]*\/><mxPoint[^>]*\/><\/Array>/,
)
})
it("keeps everything inside the page and inside its parent", () => {
expect(outsidePage(xml, ["__title"])).toEqual([])
expect(escapesParent(xml)).toEqual([])
})
it("numbers unnumbered messages in declaration order", () => {
const r = restructureDiagram("", [
{ op: "add_sequence", id: "s", label: "" },
{ op: "add_box", id: "a", parent: "s", label: "A" },
{ op: "add_box", id: "b", parent: "s", label: "B" },
{ op: "link", source: "a", target: "b", label: "first" },
{ op: "link", source: "b", target: "a", label: "second" },
])
expect(r.errors).toEqual([])
const ys = messageYs(r.xml as string)
expect(ys.length).toBe(2)
expect(ys[0].from).toBeLessThan(ys[1].from)
})
it("allows several messages between the same two participants", () => {
// A back-and-forth conversation is the norm here, so the duplicate-edge guard that
// protects other diagram kinds must not apply.
const r = restructureDiagram("", [
{ op: "add_sequence", id: "s", label: "" },
{ op: "add_box", id: "a", parent: "s", label: "A" },
{ op: "add_box", id: "b", parent: "s", label: "B" },
{ op: "link", source: "a", target: "b", label: "ask", step: 1 },
{
op: "link",
source: "a",
target: "b",
label: "ask again",
step: 2,
},
])
expect(r.errors).toEqual([])
expect(messageYs(r.xml as string).length).toBe(2)
})
it("still rejects a duplicate edge outside a sequence diagram", () => {
const r = restructureDiagram("", [
{ op: "add_box", id: "a", label: "A" },
{ op: "add_box", id: "b", label: "B" },
{ op: "link", source: "a", target: "b" },
{ op: "link", source: "a", target: "b" },
])
expect(r.errors[0]).toContain("already exists")
})
it("removes one message by step, leaving the others", () => {
const r = restructureDiagram("", [
{ op: "add_sequence", id: "s", label: "" },
{ op: "add_box", id: "a", parent: "s", label: "A" },
{ op: "add_box", id: "b", parent: "s", label: "B" },
{ op: "link", source: "a", target: "b", label: "one", step: 1 },
{ op: "link", source: "a", target: "b", label: "two", step: 2 },
{ op: "unlink", source: "a", target: "b", step: 1 },
])
expect(r.errors).toEqual([])
expect(r.outline).toContain("two")
expect(r.outline).not.toContain("one")
})
it("keeps two sequence diagrams on one page independent", () => {
// The fallback numbering has to restart per container, or the second diagram's
// messages continue the first one's count and fall below its own lifelines.
const r = restructureDiagram("", [
{ op: "add_sequence", id: "s1", label: "First" },
{ op: "add_box", id: "a", parent: "s1", label: "A" },
{ op: "add_box", id: "b", parent: "s1", label: "B" },
{ op: "link", source: "a", target: "b" },
{ op: "add_sequence", id: "s2", label: "Second" },
{ op: "add_box", id: "c", parent: "s2", label: "C" },
{ op: "add_box", id: "d", parent: "s2", label: "D" },
{ op: "link", source: "c", target: "d" },
])
expect(r.errors).toEqual([])
expect(escapesParent(r.xml as string)).toEqual([])
expect(outsidePage(r.xml as string, ["__title"])).toEqual([])
})
})
describe("sequence diagram: round-trip", () => {
it("comes back as a sequence with the same participants", () => {
const first = restructureDiagram("", LOGIN)
const { tree, warnings } = parseDiagram(first.xml as string)
expect(warnings).toEqual([])
const seq = findNode(tree, "s") as SequenceNode
expect(seq.kind).toBe("sequence")
expect(seq.children.map((c) => c.id)).toEqual(PARTICIPANTS)
expect(seq.label).toBe("Login flow")
})
it("keeps every message, including the repeat pair and the self-call", () => {
const first = restructureDiagram("", LOGIN)
const { tree } = parseDiagram(first.xml as string)
expect(tree.links.length).toBe(7)
expect(
tree.links.some((l) => l.source === "auth" && l.target === "auth"),
).toBe(true)
})
it("does not move anything on a re-layout", () => {
const first = restructureDiagram("", LOGIN)
const second = restructureDiagram(first.xml as string, [])
expect(second.errors).toEqual([])
expect(second.warnings).toEqual([])
const a = absoluteRects(first.xml as string)
const b = absoluteRects(second.xml as string)
for (const id of PARTICIPANTS) expect(b.get(id)).toEqual(a.get(id))
})
it("does not let the lifelines grow on every pass", () => {
// A lifeline's cell covers the head AND the line, so reading its full height back as
// the participant's own size would make it taller each time.
const first = restructureDiagram("", LOGIN)
const second = restructureDiagram(first.xml as string, [])
const third = restructureDiagram(second.xml as string, [])
const h = (xml: string) => rectOf(absoluteRects(xml), "u").h
expect(h(second.xml as string)).toBe(h(first.xml as string))
expect(h(third.xml as string)).toBe(h(first.xml as string))
})
it("adds a participant to an existing diagram without redrawing it", () => {
const first = restructureDiagram("", LOGIN)
const second = restructureDiagram(first.xml as string, [
{ op: "add_box", id: "cache", parent: "s", label: "Cache" },
{
op: "link",
source: "auth",
target: "cache",
label: "check",
step: 8,
},
])
expect(second.errors).toEqual([])
const rects = absoluteRects(second.xml as string)
expect(rects.has("cache")).toBe(true)
// The new participant joins the row rather than landing on top of another.
expect(rectOf(rects, "cache").x).toBeGreaterThan(rectOf(rects, "db").x)
expect(escapesParent(second.xml as string)).toEqual([])
})
})

View File

@@ -0,0 +1,226 @@
/**
* Geometry checks on rendered draw.io XML.
*
* These assert what a reader would notice: an arrow running through a box that has nothing
* to do with it, two shapes drawn on top of each other, a node outside the frame that is
* supposed to contain it. Asserting on exact coordinates instead would break on every
* spacing change while still passing on a diagram that looks wrong.
*/
export interface Rect {
x: number
y: number
w: number
h: number
}
export interface Point {
x: number
y: number
}
/** A rendered edge, resolved to the points it actually passes through. */
export interface EdgePath {
id: string
source: string
target: string
label: string
points: Point[]
}
/**
* Every vertex's rectangle in PAGE coordinates.
*
* The XML stores a nested cell's geometry relative to its parent, so the offsets have to be
* added up through the parent chain. Resolved lazily and memoised, since a parent may appear
* after its child in document order.
*/
export function absoluteRects(xml: string): Map<string, Rect> {
const raw = new Map<string, Rect & { parent: string }>()
for (const m of xml.matchAll(
/<mxCell id="([^"]+)"[^>]*vertex="1" parent="([^"]+)"><mxGeometry x="(-?[\d.]+)" y="(-?[\d.]+)" width="(-?[\d.]+)" height="(-?[\d.]+)"/g,
))
raw.set(m[1], {
parent: m[2],
x: Number(m[3]),
y: Number(m[4]),
w: Number(m[5]),
h: Number(m[6]),
})
const abs = new Map<string, Rect>()
const resolve = (id: string, seen = new Set<string>()): Rect => {
const hit = abs.get(id)
if (hit) return hit
const r = raw.get(id) as Rect & { parent: string }
// A malformed cycle must not hang the test run.
const base =
r.parent === "1" || !raw.has(r.parent) || seen.has(r.parent)
? { x: 0, y: 0 }
: resolve(r.parent, new Set(seen).add(id))
const out = { x: r.x + base.x, y: r.y + base.y, w: r.w, h: r.h }
abs.set(id, out)
return out
}
for (const id of raw.keys()) resolve(id)
return abs
}
/**
* One node's rectangle, or a failure naming the id that was missing.
*
* A missing id here almost always means the renderer dropped a cell, and "cannot read
* property y of undefined" does not say which one.
*/
export function rectOf(rects: Map<string, Rect>, id: string): Rect {
const r = rects.get(id)
if (!r) throw new Error(`no cell was rendered for "${id}"`)
return r
}
/** The page size the renderer declared. */
export function pageSize(xml: string): { w: number; h: number } {
const m = xml.match(/pageWidth="(\d+)" pageHeight="(\d+)"/)
return { w: Number(m?.[1] ?? 0), h: Number(m?.[2] ?? 0) }
}
/**
* The path each edge takes: its two connection points plus any waypoints between them.
*
* A connection point is a fraction of the terminal's bounds, so it is resolved against that
* terminal's rectangle. Without one, draw.io picks the side itself and the centre is the best
* available guess.
*/
export function edgePaths(xml: string, rects: Map<string, Rect>): EdgePath[] {
const out: EdgePath[] = []
for (const m of xml.matchAll(
/<mxCell id="([^"]+)" value="([^"]*)" style="([^"]*)" edge="1"[^>]*source="([^"]+)" target="([^"]+)">([\s\S]*?)<\/mxCell>/g,
)) {
const a = rects.get(m[4])
const b = rects.get(m[5])
if (!a || !b) continue
const exit = m[3].match(/exitX=([\d.]+);exitY=([\d.]+)/)
const entry = m[3].match(/entryX=([\d.]+);entryY=([\d.]+)/)
const start = exit
? { x: a.x + Number(exit[1]) * a.w, y: a.y + Number(exit[2]) * a.h }
: { x: a.x + a.w / 2, y: a.y + a.h / 2 }
const end = entry
? {
x: b.x + Number(entry[1]) * b.w,
y: b.y + Number(entry[2]) * b.h,
}
: { x: b.x + b.w / 2, y: b.y + b.h / 2 }
const waypoints = [
...m[6].matchAll(/<mxPoint x="(-?[\d.]+)" y="(-?[\d.]+)"\/>/g),
].map((p) => ({ x: Number(p[1]), y: Number(p[2]) }))
out.push({
id: m[1],
source: m[4],
target: m[5],
label: m[2],
points: [start, ...waypoints, end],
})
}
return out
}
/**
* Edge segments that pass through a node that is neither of their endpoints.
*
* A 2px tolerance, so a line grazing a border on its way past does not count — that is the
* router deliberately hugging a shape, not an arrow drawn over it.
*/
export function nodeCollisions(
paths: EdgePath[],
rects: Map<string, Rect>,
nodeIds: string[],
): string[] {
const bad = new Set<string>()
for (const p of paths)
for (let i = 0; i + 1 < p.points.length; i++) {
const a = p.points[i]
const b = p.points[i + 1]
const lo = { x: Math.min(a.x, b.x), y: Math.min(a.y, b.y) }
const hi = { x: Math.max(a.x, b.x), y: Math.max(a.y, b.y) }
for (const id of nodeIds) {
if (id === p.source || id === p.target) continue
const r = rects.get(id)
if (!r) continue
if (
lo.x < r.x + r.w - 2 &&
hi.x > r.x + 2 &&
lo.y < r.y + r.h - 2 &&
hi.y > r.y + 2
)
bad.add(`${p.id} (${p.source}${p.target}) crosses ${id}`)
}
}
return [...bad]
}
/** Pairs of nodes drawn on top of each other. */
export function overlaps(
rects: Map<string, Rect>,
nodeIds: string[],
): string[] {
const bad: string[] = []
for (let i = 0; i < nodeIds.length; i++)
for (let j = i + 1; j < nodeIds.length; j++) {
const a = rects.get(nodeIds[i])
const b = rects.get(nodeIds[j])
if (!a || !b) continue
if (
a.x < b.x + b.w &&
b.x < a.x + a.w &&
a.y < b.y + b.h &&
b.y < a.y + a.h
)
bad.push(`${nodeIds[i]} overlaps ${nodeIds[j]}`)
}
return bad
}
/** Cells that fall outside the page the renderer declared. */
export function outsidePage(xml: string, skip: string[] = []): string[] {
const page = pageSize(xml)
const bad: string[] = []
for (const [id, r] of absoluteRects(xml)) {
if (skip.includes(id)) continue
if (r.x < 0 || r.y < 0 || r.x + r.w > page.w || r.y + r.h > page.h)
bad.push(
`${id} at ${r.x},${r.y} ${r.w}x${r.h} is outside the ${page.w}x${page.h} page`,
)
}
return bad
}
/**
* Cells that stick out of the parent they declare.
*
* 1px of slack absorbs the rounding the renderer does on each coordinate independently.
*/
export function escapesParent(xml: string): string[] {
const rects = absoluteRects(xml)
const bad = new Set<string>()
for (const m of xml.matchAll(
/<mxCell id="([^"]+)"[^>]*vertex="1" parent="([^"]+)"/g,
)) {
const child = rects.get(m[1])
const parent = rects.get(m[2])
if (!child || !parent) continue
if (
child.x < parent.x - 1 ||
child.y < parent.y - 1 ||
child.x + child.w > parent.x + parent.w + 1 ||
child.y + child.h > parent.y + parent.h + 1
)
bad.add(`${m[1]} sticks out of ${m[2]}`)
}
return [...bad]
}
/** The `parent` attribute of one cell. */
export function parentOf(xml: string, id: string): string | null {
const m = xml.match(new RegExp(`<mxCell id="${id}"[^>]*parent="([^"]+)"`))
return m ? m[1] : null
}