feat(diagram-engine): style markers + XML→tree reverse parser
Groundwork for a declarative diagram engine where the model declares nesting and
the engine computes every coordinate, instead of the model emitting raw mxCell XML.
The design keeps the canvas as the SINGLE source of truth: the node tree is never
persisted, it is re-derived from the current canvas XML whenever needed. A user's
manual edits are therefore an input to the next re-layout, not a second copy of
the state that has to be reconciled.
Two behaviours this relies on, both verified against the real embedded editor with
a Playwright mouse drag (reading the editor's own autosave payload):
1. An AWS group stencil WITHOUT container=1 does not get a dragged shape
reparented — parent stays "1" and geometry stays absolute. WITH container=1
it does: parent becomes the frame, geometry becomes parent-relative. So the
engine must stamp container=1 on every container it emits.
2. draw.io preserves style keys it does not understand, and resolves a duplicate
key last-wins. So dai_* markers survive a user edit, and container=1 can be
appended to a catalog style without first parsing out an existing value —
which matters because the AWS catalog is inconsistent about it (group_vpc,
group_region, group_subnet ship without it; group_account ships with it).
markers.ts — dai_kind / dai_dir / dai_gap / dai_cols / dai_pin, and the container
token normalisation.
types.ts — the node tree contract, plus a `foreign` bucket so cells the engine
does not understand (user annotations, imported shapes) round-trip
verbatim rather than being destroyed by a re-layout.
parse.ts — XML → tree. Handles all four icon encodings (resIcon=, bare shape=,
shape=image data URI, grIcon=), resolves nesting from parent with a
geometry fallback for frames that lack container=1, recovers layout
direction from the marker or infers it from child positions, and
survives cycles, compressed files and multi-page decks.
75 unit tests, including a round-trip against real output from the reference
project's build_vpc.mjs.
One finding worth recording: the reference project's "phantom" node (a wrapper that
participates in layout but emits no cell) makes the round-trip lossy by
construction. In build_vpc.mjs a phantom erased a container's col direction — its
children were reparented onto the grandparent, leaving a 2-D arrangement the parser
can only read as a grid. 26 of the reference project's 31 examples use phantoms, so
our engine needs an invisible-but-real container instead. Tracked separately.
2026-08-09 11:35:32 +09:00
|
|
|
import { describe, expect, it } from "vitest"
|
|
|
|
|
import {
|
|
|
|
|
hasMarkers,
|
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
|
|
|
isLaneChrome,
|
feat(diagram-engine): style markers + XML→tree reverse parser
Groundwork for a declarative diagram engine where the model declares nesting and
the engine computes every coordinate, instead of the model emitting raw mxCell XML.
The design keeps the canvas as the SINGLE source of truth: the node tree is never
persisted, it is re-derived from the current canvas XML whenever needed. A user's
manual edits are therefore an input to the next re-layout, not a second copy of
the state that has to be reconciled.
Two behaviours this relies on, both verified against the real embedded editor with
a Playwright mouse drag (reading the editor's own autosave payload):
1. An AWS group stencil WITHOUT container=1 does not get a dragged shape
reparented — parent stays "1" and geometry stays absolute. WITH container=1
it does: parent becomes the frame, geometry becomes parent-relative. So the
engine must stamp container=1 on every container it emits.
2. draw.io preserves style keys it does not understand, and resolves a duplicate
key last-wins. So dai_* markers survive a user edit, and container=1 can be
appended to a catalog style without first parsing out an existing value —
which matters because the AWS catalog is inconsistent about it (group_vpc,
group_region, group_subnet ship without it; group_account ships with it).
markers.ts — dai_kind / dai_dir / dai_gap / dai_cols / dai_pin, and the container
token normalisation.
types.ts — the node tree contract, plus a `foreign` bucket so cells the engine
does not understand (user annotations, imported shapes) round-trip
verbatim rather than being destroyed by a re-layout.
parse.ts — XML → tree. Handles all four icon encodings (resIcon=, bare shape=,
shape=image data URI, grIcon=), resolves nesting from parent with a
geometry fallback for frames that lack container=1, recovers layout
direction from the marker or infers it from child positions, and
survives cycles, compressed files and multi-page decks.
75 unit tests, including a round-trip against real output from the reference
project's build_vpc.mjs.
One finding worth recording: the reference project's "phantom" node (a wrapper that
participates in layout but emits no cell) makes the round-trip lossy by
construction. In build_vpc.mjs a phantom erased a container's col direction — its
children were reparented onto the grandparent, leaving a 2-D arrangement the parser
can only read as a grid. 26 of the reference project's 31 examples use phantoms, so
our engine needs an invisible-but-real container instead. Tracked separately.
2026-08-09 11:35:32 +09:00
|
|
|
isPinned,
|
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
|
|
|
MARKER,
|
|
|
|
|
readCell,
|
feat(diagram-engine): style markers + XML→tree reverse parser
Groundwork for a declarative diagram engine where the model declares nesting and
the engine computes every coordinate, instead of the model emitting raw mxCell XML.
The design keeps the canvas as the SINGLE source of truth: the node tree is never
persisted, it is re-derived from the current canvas XML whenever needed. A user's
manual edits are therefore an input to the next re-layout, not a second copy of
the state that has to be reconciled.
Two behaviours this relies on, both verified against the real embedded editor with
a Playwright mouse drag (reading the editor's own autosave payload):
1. An AWS group stencil WITHOUT container=1 does not get a dragged shape
reparented — parent stays "1" and geometry stays absolute. WITH container=1
it does: parent becomes the frame, geometry becomes parent-relative. So the
engine must stamp container=1 on every container it emits.
2. draw.io preserves style keys it does not understand, and resolves a duplicate
key last-wins. So dai_* markers survive a user edit, and container=1 can be
appended to a catalog style without first parsing out an existing value —
which matters because the AWS catalog is inconsistent about it (group_vpc,
group_region, group_subnet ship without it; group_account ships with it).
markers.ts — dai_kind / dai_dir / dai_gap / dai_cols / dai_pin, and the container
token normalisation.
types.ts — the node tree contract, plus a `foreign` bucket so cells the engine
does not understand (user annotations, imported shapes) round-trip
verbatim rather than being destroyed by a re-layout.
parse.ts — XML → tree. Handles all four icon encodings (resIcon=, bare shape=,
shape=image data URI, grIcon=), resolves nesting from parent with a
geometry fallback for frames that lack container=1, recovers layout
direction from the marker or infers it from child positions, and
survives cycles, compressed files and multi-page decks.
75 unit tests, including a round-trip against real output from the reference
project's build_vpc.mjs.
One finding worth recording: the reference project's "phantom" node (a wrapper that
participates in layout but emits no cell) makes the round-trip lossy by
construction. In build_vpc.mjs a phantom erased a container's col direction — its
children were reparented onto the grandparent, leaving a 2-D arrangement the parser
can only read as a grid. 26 of the reference project's 31 examples use phantoms, so
our engine needs an invisible-but-real container instead. Tracked separately.
2026-08-09 11:35:32 +09:00
|
|
|
readDir,
|
|
|
|
|
readIntMarker,
|
|
|
|
|
readKind,
|
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
|
|
|
readList,
|
feat(diagram-engine): style markers + XML→tree reverse parser
Groundwork for a declarative diagram engine where the model declares nesting and
the engine computes every coordinate, instead of the model emitting raw mxCell XML.
The design keeps the canvas as the SINGLE source of truth: the node tree is never
persisted, it is re-derived from the current canvas XML whenever needed. A user's
manual edits are therefore an input to the next re-layout, not a second copy of
the state that has to be reconciled.
Two behaviours this relies on, both verified against the real embedded editor with
a Playwright mouse drag (reading the editor's own autosave payload):
1. An AWS group stencil WITHOUT container=1 does not get a dragged shape
reparented — parent stays "1" and geometry stays absolute. WITH container=1
it does: parent becomes the frame, geometry becomes parent-relative. So the
engine must stamp container=1 on every container it emits.
2. draw.io preserves style keys it does not understand, and resolves a duplicate
key last-wins. So dai_* markers survive a user edit, and container=1 can be
appended to a catalog style without first parsing out an existing value —
which matters because the AWS catalog is inconsistent about it (group_vpc,
group_region, group_subnet ship without it; group_account ships with it).
markers.ts — dai_kind / dai_dir / dai_gap / dai_cols / dai_pin, and the container
token normalisation.
types.ts — the node tree contract, plus a `foreign` bucket so cells the engine
does not understand (user annotations, imported shapes) round-trip
verbatim rather than being destroyed by a re-layout.
parse.ts — XML → tree. Handles all four icon encodings (resIcon=, bare shape=,
shape=image data URI, grIcon=), resolves nesting from parent with a
geometry fallback for frames that lack container=1, recovers layout
direction from the marker or infers it from child positions, and
survives cycles, compressed files and multi-page decks.
75 unit tests, including a round-trip against real output from the reference
project's build_vpc.mjs.
One finding worth recording: the reference project's "phantom" node (a wrapper that
participates in layout but emits no cell) makes the round-trip lossy by
construction. In build_vpc.mjs a phantom erased a container's col direction — its
children were reparented onto the grandparent, leaving a 2-D arrangement the parser
can only read as a grid. 26 of the reference project's 31 examples use phantoms, so
our engine needs an invisible-but-real container instead. Tracked separately.
2026-08-09 11:35:32 +09:00
|
|
|
readMarker,
|
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
|
|
|
stampCell,
|
feat(diagram-engine): style markers + XML→tree reverse parser
Groundwork for a declarative diagram engine where the model declares nesting and
the engine computes every coordinate, instead of the model emitting raw mxCell XML.
The design keeps the canvas as the SINGLE source of truth: the node tree is never
persisted, it is re-derived from the current canvas XML whenever needed. A user's
manual edits are therefore an input to the next re-layout, not a second copy of
the state that has to be reconciled.
Two behaviours this relies on, both verified against the real embedded editor with
a Playwright mouse drag (reading the editor's own autosave payload):
1. An AWS group stencil WITHOUT container=1 does not get a dragged shape
reparented — parent stays "1" and geometry stays absolute. WITH container=1
it does: parent becomes the frame, geometry becomes parent-relative. So the
engine must stamp container=1 on every container it emits.
2. draw.io preserves style keys it does not understand, and resolves a duplicate
key last-wins. So dai_* markers survive a user edit, and container=1 can be
appended to a catalog style without first parsing out an existing value —
which matters because the AWS catalog is inconsistent about it (group_vpc,
group_region, group_subnet ship without it; group_account ships with it).
markers.ts — dai_kind / dai_dir / dai_gap / dai_cols / dai_pin, and the container
token normalisation.
types.ts — the node tree contract, plus a `foreign` bucket so cells the engine
does not understand (user annotations, imported shapes) round-trip
verbatim rather than being destroyed by a re-layout.
parse.ts — XML → tree. Handles all four icon encodings (resIcon=, bare shape=,
shape=image data URI, grIcon=), resolves nesting from parent with a
geometry fallback for frames that lack container=1, recovers layout
direction from the marker or infers it from child positions, and
survives cycles, compressed files and multi-page decks.
75 unit tests, including a round-trip against real output from the reference
project's build_vpc.mjs.
One finding worth recording: the reference project's "phantom" node (a wrapper that
participates in layout but emits no cell) makes the round-trip lossy by
construction. In build_vpc.mjs a phantom erased a container's col direction — its
children were reparented onto the grandparent, leaving a 2-D arrangement the parser
can only read as a grid. 26 of the reference project's 31 examples use phantoms, so
our engine needs an invisible-but-real container instead. Tracked separately.
2026-08-09 11:35:32 +09:00
|
|
|
stampContainer,
|
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
|
|
|
stampLane,
|
feat(diagram-engine): style markers + XML→tree reverse parser
Groundwork for a declarative diagram engine where the model declares nesting and
the engine computes every coordinate, instead of the model emitting raw mxCell XML.
The design keeps the canvas as the SINGLE source of truth: the node tree is never
persisted, it is re-derived from the current canvas XML whenever needed. A user's
manual edits are therefore an input to the next re-layout, not a second copy of
the state that has to be reconciled.
Two behaviours this relies on, both verified against the real embedded editor with
a Playwright mouse drag (reading the editor's own autosave payload):
1. An AWS group stencil WITHOUT container=1 does not get a dragged shape
reparented — parent stays "1" and geometry stays absolute. WITH container=1
it does: parent becomes the frame, geometry becomes parent-relative. So the
engine must stamp container=1 on every container it emits.
2. draw.io preserves style keys it does not understand, and resolves a duplicate
key last-wins. So dai_* markers survive a user edit, and container=1 can be
appended to a catalog style without first parsing out an existing value —
which matters because the AWS catalog is inconsistent about it (group_vpc,
group_region, group_subnet ship without it; group_account ships with it).
markers.ts — dai_kind / dai_dir / dai_gap / dai_cols / dai_pin, and the container
token normalisation.
types.ts — the node tree contract, plus a `foreign` bucket so cells the engine
does not understand (user annotations, imported shapes) round-trip
verbatim rather than being destroyed by a re-layout.
parse.ts — XML → tree. Handles all four icon encodings (resIcon=, bare shape=,
shape=image data URI, grIcon=), resolves nesting from parent with a
geometry fallback for frames that lack container=1, recovers layout
direction from the marker or infers it from child positions, and
survives cycles, compressed files and multi-page decks.
75 unit tests, including a round-trip against real output from the reference
project's build_vpc.mjs.
One finding worth recording: the reference project's "phantom" node (a wrapper that
participates in layout but emits no cell) makes the round-trip lossy by
construction. In build_vpc.mjs a phantom erased a container's col direction — its
children were reparented onto the grandparent, leaving a 2-D arrangement the parser
can only read as a grid. 26 of the reference project's 31 examples use phantoms, so
our engine needs an invisible-but-real container instead. Tracked separately.
2026-08-09 11:35:32 +09:00
|
|
|
stampLeaf,
|
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
|
|
|
stampPool,
|
|
|
|
|
stampPoolDecoration,
|
|
|
|
|
stampRadial,
|
|
|
|
|
stampSequence,
|
feat(diagram-engine): style markers + XML→tree reverse parser
Groundwork for a declarative diagram engine where the model declares nesting and
the engine computes every coordinate, instead of the model emitting raw mxCell XML.
The design keeps the canvas as the SINGLE source of truth: the node tree is never
persisted, it is re-derived from the current canvas XML whenever needed. A user's
manual edits are therefore an input to the next re-layout, not a second copy of
the state that has to be reconciled.
Two behaviours this relies on, both verified against the real embedded editor with
a Playwright mouse drag (reading the editor's own autosave payload):
1. An AWS group stencil WITHOUT container=1 does not get a dragged shape
reparented — parent stays "1" and geometry stays absolute. WITH container=1
it does: parent becomes the frame, geometry becomes parent-relative. So the
engine must stamp container=1 on every container it emits.
2. draw.io preserves style keys it does not understand, and resolves a duplicate
key last-wins. So dai_* markers survive a user edit, and container=1 can be
appended to a catalog style without first parsing out an existing value —
which matters because the AWS catalog is inconsistent about it (group_vpc,
group_region, group_subnet ship without it; group_account ships with it).
markers.ts — dai_kind / dai_dir / dai_gap / dai_cols / dai_pin, and the container
token normalisation.
types.ts — the node tree contract, plus a `foreign` bucket so cells the engine
does not understand (user annotations, imported shapes) round-trip
verbatim rather than being destroyed by a re-layout.
parse.ts — XML → tree. Handles all four icon encodings (resIcon=, bare shape=,
shape=image data URI, grIcon=), resolves nesting from parent with a
geometry fallback for frames that lack container=1, recovers layout
direction from the marker or infers it from child positions, and
survives cycles, compressed files and multi-page decks.
75 unit tests, including a round-trip against real output from the reference
project's build_vpc.mjs.
One finding worth recording: the reference project's "phantom" node (a wrapper that
participates in layout but emits no cell) makes the round-trip lossy by
construction. In build_vpc.mjs a phantom erased a container's col direction — its
children were reparented onto the grandparent, leaving a 2-D arrangement the parser
can only read as a grid. 26 of the reference project's 31 examples use phantoms, so
our engine needs an invisible-but-real container instead. Tracked separately.
2026-08-09 11:35:32 +09:00
|
|
|
stripMarkers,
|
|
|
|
|
} from "@/lib/diagram-engine/markers"
|
|
|
|
|
|
|
|
|
|
// Real catalog styles from drawio-ai-kit's catalog/aws.json, verbatim. group_vpc
|
|
|
|
|
// ships WITHOUT container=1; group_account ships WITH it. The engine has to handle both.
|
|
|
|
|
const VPC_STYLE =
|
|
|
|
|
"sketch=0;outlineConnect=0;gradientColor=none;html=1;whiteSpace=wrap;fontSize=12;fontStyle=0;shape=mxgraph.aws4.group;grIcon=mxgraph.aws4.group_vpc;strokeColor=#879196;fillColor=none;verticalAlign=top;align=left;spacingLeft=30;fontColor=#879196;dashed=0;"
|
|
|
|
|
const ACCOUNT_STYLE =
|
|
|
|
|
"points=[[0,0],[0.25,0],[0.5,0]];outlineConnect=0;gradientColor=none;html=1;whiteSpace=wrap;fontSize=12;fontStyle=0;container=1;pointerEvents=0;collapsible=0;recursiveResize=0;shape=mxgraph.aws4.group;grIcon=mxgraph.aws4.group_account;strokeColor=#CD2264;fillColor=none;verticalAlign=top;align=left;spacingLeft=30;fontColor=#CD2264;dashed=0;"
|
|
|
|
|
|
|
|
|
|
describe("stampContainer", () => {
|
|
|
|
|
it("adds container tokens to a catalog style that lacks them", () => {
|
|
|
|
|
const s = stampContainer(VPC_STYLE, {
|
|
|
|
|
kind: "group",
|
|
|
|
|
dir: "col",
|
|
|
|
|
gap: 22,
|
|
|
|
|
})
|
|
|
|
|
expect(s).toContain("container=1;")
|
|
|
|
|
expect(s).toContain("pointerEvents=0;")
|
|
|
|
|
expect(s).toContain("collapsible=0;")
|
|
|
|
|
expect(s).toContain("recursiveResize=0;")
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it("is safe on a style that already declares container=1", () => {
|
|
|
|
|
const s = stampContainer(ACCOUNT_STYLE, {
|
|
|
|
|
kind: "group",
|
|
|
|
|
dir: "row",
|
|
|
|
|
gap: 30,
|
|
|
|
|
})
|
|
|
|
|
// Duplicate keys are legal in draw.io and the LAST wins (verified in-browser),
|
|
|
|
|
// so appending a second container=1 keeps the shape a container.
|
|
|
|
|
expect(s.match(/container=1/g)?.length).toBe(2)
|
|
|
|
|
expect(readDir(s)).toBe("row")
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it("records kind, dir and gap so the parser need not guess", () => {
|
|
|
|
|
const s = stampContainer(VPC_STYLE, {
|
|
|
|
|
kind: "group",
|
|
|
|
|
dir: "col",
|
|
|
|
|
gap: 22,
|
|
|
|
|
})
|
|
|
|
|
expect(readKind(s)).toBe("group")
|
|
|
|
|
expect(readDir(s)).toBe("col")
|
|
|
|
|
expect(readIntMarker(s, "dai_gap")).toBe(22)
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it("records cols only for a grid", () => {
|
|
|
|
|
const grid = stampContainer(VPC_STYLE, {
|
|
|
|
|
kind: "grid",
|
|
|
|
|
dir: "grid",
|
|
|
|
|
gap: 14,
|
|
|
|
|
cols: 3,
|
|
|
|
|
})
|
|
|
|
|
expect(readIntMarker(grid, "dai_cols")).toBe(3)
|
|
|
|
|
|
|
|
|
|
const group = stampContainer(VPC_STYLE, {
|
|
|
|
|
kind: "group",
|
|
|
|
|
dir: "row",
|
|
|
|
|
gap: 14,
|
|
|
|
|
cols: 3,
|
|
|
|
|
})
|
|
|
|
|
expect(readIntMarker(group, "dai_cols")).toBeNull()
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it("rounds a fractional gap", () => {
|
|
|
|
|
const s = stampContainer(VPC_STYLE, {
|
|
|
|
|
kind: "group",
|
|
|
|
|
dir: "col",
|
|
|
|
|
gap: 21.6,
|
|
|
|
|
})
|
|
|
|
|
expect(readIntMarker(s, "dai_gap")).toBe(22)
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it("adds the missing separator when a style does not end in ;", () => {
|
|
|
|
|
const s = stampContainer("rounded=0;fillColor=#FFF", {
|
|
|
|
|
kind: "group",
|
|
|
|
|
dir: "row",
|
|
|
|
|
gap: 10,
|
|
|
|
|
})
|
|
|
|
|
expect(s).not.toContain("#FFFcontainer")
|
|
|
|
|
expect(s).toContain("#FFF;container=1;")
|
|
|
|
|
})
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
describe("readMarker duplicate handling", () => {
|
|
|
|
|
it("returns the LAST value, mirroring how draw.io resolves duplicate keys", () => {
|
|
|
|
|
// Verified in-browser: container=0;...;container=1; behaves as a container.
|
|
|
|
|
expect(readMarker("a=1;dai_dir=row;b=2;dai_dir=col;", "dai_dir")).toBe(
|
|
|
|
|
"col",
|
|
|
|
|
)
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it("does not match a key that is only a suffix of another key", () => {
|
|
|
|
|
expect(readMarker("xdai_dir=row;", "dai_dir")).toBeNull()
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it("returns null for an absent key", () => {
|
|
|
|
|
expect(readMarker(VPC_STYLE, "dai_dir")).toBeNull()
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it("reads a marker at the very start of the style", () => {
|
|
|
|
|
expect(readMarker("dai_kind=box;rounded=0;", "dai_kind")).toBe("box")
|
|
|
|
|
})
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
describe("readKind / readDir reject unknown values", () => {
|
|
|
|
|
it("rejects a kind that is not in the union", () => {
|
|
|
|
|
expect(readKind("dai_kind=wormhole;")).toBeNull()
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it("rejects a direction that is not in the union", () => {
|
|
|
|
|
expect(readDir("dai_dir=diagonal;")).toBeNull()
|
|
|
|
|
})
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
describe("readIntMarker", () => {
|
|
|
|
|
it("rejects a non-numeric value rather than returning NaN", () => {
|
|
|
|
|
expect(readIntMarker("dai_gap=wide;", "dai_gap")).toBeNull()
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it("rejects a negative value", () => {
|
|
|
|
|
expect(readIntMarker("dai_gap=-5;", "dai_gap")).toBeNull()
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it("accepts zero", () => {
|
|
|
|
|
expect(readIntMarker("dai_gap=0;", "dai_gap")).toBe(0)
|
|
|
|
|
})
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
describe("isPinned", () => {
|
|
|
|
|
it("is false when the marker is absent", () => {
|
|
|
|
|
expect(isPinned(VPC_STYLE)).toBe(false)
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it("is true for the value the engine writes", () => {
|
|
|
|
|
expect(isPinned("dai_pin=1;")).toBe(true)
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it("accepts what a user might hand-type in draw.io's Edit Style dialog", () => {
|
|
|
|
|
expect(isPinned("dai_pin=yes;")).toBe(true)
|
|
|
|
|
expect(isPinned("dai_pin=true;")).toBe(true)
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it("treats 0 / false / empty as not pinned, so a user can unpin by editing", () => {
|
|
|
|
|
expect(isPinned("dai_pin=0;")).toBe(false)
|
|
|
|
|
expect(isPinned("dai_pin=false;")).toBe(false)
|
|
|
|
|
expect(isPinned("dai_pin=;")).toBe(false)
|
|
|
|
|
})
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
describe("stampLeaf", () => {
|
|
|
|
|
it("marks an icon", () => {
|
|
|
|
|
expect(readKind(stampLeaf("shape=mxgraph.aws4.ec2;", "icon"))).toBe(
|
|
|
|
|
"icon",
|
|
|
|
|
)
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it("marks a box", () => {
|
|
|
|
|
expect(readKind(stampLeaf("rounded=0;", "box"))).toBe("box")
|
|
|
|
|
})
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
describe("stripMarkers", () => {
|
|
|
|
|
it("removes every dai_ token and keeps the rest intact", () => {
|
|
|
|
|
const stamped = stampContainer(VPC_STYLE, {
|
|
|
|
|
kind: "group",
|
|
|
|
|
dir: "col",
|
|
|
|
|
gap: 22,
|
|
|
|
|
})
|
|
|
|
|
const stripped = stripMarkers(stamped)
|
|
|
|
|
expect(stripped).not.toContain("dai_")
|
|
|
|
|
// the real style tokens survive
|
|
|
|
|
expect(stripped).toContain("grIcon=mxgraph.aws4.group_vpc")
|
|
|
|
|
expect(stripped).toContain("container=1")
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it("leaves a marker-free style semantically unchanged", () => {
|
|
|
|
|
expect(stripMarkers(VPC_STYLE)).toBe(VPC_STYLE)
|
|
|
|
|
})
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
describe("hasMarkers", () => {
|
|
|
|
|
it("is false for a plain catalog style", () => {
|
|
|
|
|
expect(hasMarkers(VPC_STYLE)).toBe(false)
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it("is true for engine output", () => {
|
|
|
|
|
expect(
|
|
|
|
|
hasMarkers(
|
|
|
|
|
stampContainer(VPC_STYLE, {
|
|
|
|
|
kind: "group",
|
|
|
|
|
dir: "row",
|
|
|
|
|
gap: 8,
|
|
|
|
|
}),
|
|
|
|
|
),
|
|
|
|
|
).toBe(true)
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it("is not fooled by a key that merely contains dai_", () => {
|
|
|
|
|
expect(hasMarkers("mydai_dir=row;")).toBe(false)
|
|
|
|
|
})
|
|
|
|
|
})
|
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
|
|
|
|
|
|
|
|
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)
|
|
|
|
|
})
|
|
|
|
|
})
|