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
|
|
|
/**
|
|
|
|
|
* Style markers — how layout structure survives a round-trip through draw.io.
|
|
|
|
|
*
|
|
|
|
|
* The layout engine's tree carries information plain draw.io XML does not: which
|
|
|
|
|
* direction a container stacks its children, the gap between them, and whether the
|
|
|
|
|
* user has pinned a node's position. We encode that as extra `key=value` tokens in
|
|
|
|
|
* the cell's style string.
|
|
|
|
|
*
|
|
|
|
|
* Two behaviours this relies on, both verified in a real browser (Playwright drag
|
|
|
|
|
* against the embedded editor, reading the editor's own autosave payload):
|
|
|
|
|
*
|
|
|
|
|
* 1. draw.io PRESERVES style keys it does not understand. After a user drags a
|
|
|
|
|
* shape and the editor saves, `dai_kind=group;dai_dir=col;dai_gap=22;` came
|
|
|
|
|
* back byte-identical.
|
|
|
|
|
* 2. On a DUPLICATE key, the LAST value wins. A style ending in
|
|
|
|
|
* `container=0;pointerEvents=0;container=1;` behaved as a container: a shape
|
|
|
|
|
* dragged into it was reparented. So we can append a normalising token without
|
|
|
|
|
* first parsing out the old one.
|
|
|
|
|
*
|
|
|
|
|
* (2) matters because the AWS catalog is inconsistent: group_region, group_vpc,
|
|
|
|
|
* group_subnet, group_availability_zone, group_aws_cloud and group_on_premise ship
|
|
|
|
|
* WITHOUT container=1, while group_account, group_aws_cloud_alt, group_vpc2,
|
|
|
|
|
* group_security_group and group_corporate_data_center ship WITH it. Appending
|
|
|
|
|
* unconditionally normalises all of them.
|
|
|
|
|
*/
|
|
|
|
|
|
|
|
|
|
/** Marker keys. Namespaced with `dai_` so they cannot collide with mxGraph keys. */
|
|
|
|
|
export const MARKER = {
|
|
|
|
|
/** Node kind, so the parser does not have to re-guess it from the shape. */
|
|
|
|
|
kind: "dai_kind",
|
|
|
|
|
/** Child stacking direction of a container: "row" | "col" | "grid". */
|
|
|
|
|
dir: "dai_dir",
|
|
|
|
|
/** Gap between children, in px. */
|
|
|
|
|
gap: "dai_gap",
|
|
|
|
|
/** Column count, for grid containers. */
|
|
|
|
|
cols: "dai_cols",
|
|
|
|
|
/** Set by the user to freeze a node's position across re-layouts. */
|
|
|
|
|
pin: "dai_pin",
|
feat(diagram-engine): layout + XML renderer, verified end to end in draw.io
Completes the tree → coordinates → XML direction, so the model can declare nesting
and never write a coordinate or an mxCell again.
layout.ts — measure bottom-up, place top-down, the same shape as flexbox. A
container sums its children along the flow axis and adds padding, so "child spills
out of its frame" and "siblings overlap" cannot happen by construction rather than
being caught afterwards. Slack from sibling equalisation is shared between children
instead of left as dead margin, capped at one gap so a stretched frame reads as
spaced rather than sparse.
render.ts — writes the mxCells, stamping container=1 and the dai_* markers so
parse.ts can read the structure back. Edges carry no waypoints: draw.io's own router
recomputes the route on every edit, so a user who moves a node never has to re-link
an arrow. Cells the parser could not interpret are re-emitted verbatim, so a
re-layout never deletes a user's annotations.
Phantoms are gone (task #5). The reference project's layout-only wrapper emits no
cell, which makes the round-trip lossy by construction — measured on its own
build_vpc.mjs, a phantom erased a container's "col" direction for good. An
unlabelled frame here emits a real cell with fillColor/strokeColor=none instead:
invisible, but present in the XML and therefore recoverable.
Two bugs the round-trip test caught, both real:
- An icon's cell was being emitted at its measured slot size, which includes room
for the label underneath. Parsing read that width back as the glyph size, so the
icon grew on every round-trip. The cell is now the glyph square and the label
renders outside it via verticalLabelPosition, as the reference does.
- An Azure or GCP icon is an embedded base64 image whose style contains no name
anywhere, so the catalog name was unrecoverable. Added a dai_name marker.
Verified in a real browser (3 Playwright tests, not mocks): engine output renders in
draw.io; dragging a shape into a frame makes draw.io rewrite its parent and the
engine reads the new structure back; re-laying out from that structure PRESERVES the
user's move instead of undoing it, and leaves untouched nodes alone; and the
re-laid-out XML still renders.
That last point is the whole design: there is no second copy of the state, so a
manual edit is an input to the next layout rather than a conflict to reconcile.
304 unit tests + 3 e2e.
2026-08-09 11:56:08 +09:00
|
|
|
/**
|
|
|
|
|
* A catalog icon's name. Needed because an Azure or GCP icon's style is an embedded
|
|
|
|
|
* base64 image with no name anywhere in it, so the style alone cannot identify it.
|
|
|
|
|
*/
|
|
|
|
|
name: "dai_name",
|
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
|
|
|
/**
|
|
|
|
|
* Which (lane, column) cell of a swimlane pool a node occupies, as "lane,col".
|
|
|
|
|
*
|
|
|
|
|
* Position alone cannot recover this once the user drags a node: the cell it lands in
|
|
|
|
|
* is a guess, whereas the marker records which lane the model assigned it to. It is
|
|
|
|
|
* also the only way an empty cell stays empty — geometry can only tell us where things
|
|
|
|
|
* ARE, never that a role deliberately does nothing at a given step.
|
|
|
|
|
*/
|
|
|
|
|
cell: "dai_cell",
|
|
|
|
|
/** A pool's lane names, tab-separated (a tab cannot appear in a draw.io style value). */
|
|
|
|
|
lanes: "dai_lanes",
|
|
|
|
|
/** A pool's milestone labels, tab-separated. */
|
|
|
|
|
phases: "dai_phases",
|
|
|
|
|
/** A pool's orientation: "h" or "v". */
|
|
|
|
|
orient: "dai_orient",
|
|
|
|
|
/** Vertical distance between consecutive messages in a sequence diagram. */
|
|
|
|
|
step: "dai_step",
|
|
|
|
|
/** How a radial container fans its branches out: "radial" or "down". */
|
|
|
|
|
spread: "dai_spread",
|
|
|
|
|
/**
|
|
|
|
|
* Marks a cell as chrome the engine draws and owns: a pool's lane bands, its label
|
|
|
|
|
* columns, its milestone strip. The parser must not read these back as nodes — they are
|
|
|
|
|
* re-derived from the pool's own parameters on every layout — and the edge router must
|
|
|
|
|
* not treat them as obstacles, since a sequence flow crossing lanes is the norm.
|
|
|
|
|
*/
|
|
|
|
|
lane: "dai_lane",
|
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
|
|
|
} as const
|
|
|
|
|
|
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
|
|
|
export type NodeKind =
|
|
|
|
|
| "group"
|
|
|
|
|
| "grid"
|
|
|
|
|
| "pool"
|
|
|
|
|
| "sequence"
|
|
|
|
|
| "radial"
|
|
|
|
|
| "icon"
|
|
|
|
|
| "box"
|
|
|
|
|
| "title"
|
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
|
|
|
export type Direction = "row" | "col" | "grid"
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Tokens that make a shape behave as a container in draw.io: it accepts a shape
|
|
|
|
|
* dragged into it and reparents that shape (setting `parent` and switching the
|
|
|
|
|
* child's geometry to parent-relative).
|
|
|
|
|
*
|
|
|
|
|
* `pointerEvents=0` keeps clicks falling through to the children — without it the
|
|
|
|
|
* frame swallows them and the user cannot select what is inside. `collapsible=0`
|
|
|
|
|
* hides the fold arrow. `recursiveResize=0` stops children from being scaled when
|
|
|
|
|
* the frame is resized, which would fight the layout engine.
|
|
|
|
|
*/
|
|
|
|
|
const CONTAINER_TOKENS =
|
|
|
|
|
"container=1;pointerEvents=0;collapsible=0;recursiveResize=0;"
|
|
|
|
|
|
feat(diagram-engine): layout + XML renderer, verified end to end in draw.io
Completes the tree → coordinates → XML direction, so the model can declare nesting
and never write a coordinate or an mxCell again.
layout.ts — measure bottom-up, place top-down, the same shape as flexbox. A
container sums its children along the flow axis and adds padding, so "child spills
out of its frame" and "siblings overlap" cannot happen by construction rather than
being caught afterwards. Slack from sibling equalisation is shared between children
instead of left as dead margin, capped at one gap so a stretched frame reads as
spaced rather than sparse.
render.ts — writes the mxCells, stamping container=1 and the dai_* markers so
parse.ts can read the structure back. Edges carry no waypoints: draw.io's own router
recomputes the route on every edit, so a user who moves a node never has to re-link
an arrow. Cells the parser could not interpret are re-emitted verbatim, so a
re-layout never deletes a user's annotations.
Phantoms are gone (task #5). The reference project's layout-only wrapper emits no
cell, which makes the round-trip lossy by construction — measured on its own
build_vpc.mjs, a phantom erased a container's "col" direction for good. An
unlabelled frame here emits a real cell with fillColor/strokeColor=none instead:
invisible, but present in the XML and therefore recoverable.
Two bugs the round-trip test caught, both real:
- An icon's cell was being emitted at its measured slot size, which includes room
for the label underneath. Parsing read that width back as the glyph size, so the
icon grew on every round-trip. The cell is now the glyph square and the label
renders outside it via verticalLabelPosition, as the reference does.
- An Azure or GCP icon is an embedded base64 image whose style contains no name
anywhere, so the catalog name was unrecoverable. Added a dai_name marker.
Verified in a real browser (3 Playwright tests, not mocks): engine output renders in
draw.io; dragging a shape into a frame makes draw.io rewrite its parent and the
engine reads the new structure back; re-laying out from that structure PRESERVES the
user's move instead of undoing it, and leaves untouched nodes alone; and the
re-laid-out XML still renders.
That last point is the whole design: there is no second copy of the state, so a
manual edit is an input to the next layout rather than a conflict to reconcile.
304 unit tests + 3 e2e.
2026-08-09 11:56:08 +09:00
|
|
|
/**
|
|
|
|
|
* A container that groups children for layout but should not be visible.
|
|
|
|
|
*
|
|
|
|
|
* The reference project solves this with a "phantom": a wrapper that participates in
|
|
|
|
|
* layout and then emits NO cell, reparenting its children onto the nearest visible
|
|
|
|
|
* ancestor. That makes the round-trip lossy by construction — the wrapper's direction
|
|
|
|
|
* and grouping are simply absent from the XML, so re-deriving the tree cannot recover
|
|
|
|
|
* them. Measured on the reference project's own build_vpc.mjs: a phantom erased a
|
|
|
|
|
* container's "col" direction, leaving children in a 2-D arrangement that can only be
|
|
|
|
|
* read back as a grid.
|
|
|
|
|
*
|
|
|
|
|
* So we emit a real cell and make it invisible instead. One extra cell per wrapper,
|
|
|
|
|
* in exchange for structure that survives being read back.
|
|
|
|
|
*/
|
|
|
|
|
const INVISIBLE_TOKENS = "fillColor=none;strokeColor=none;"
|
|
|
|
|
|
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
|
|
|
/** Read a marker's raw value out of a style string. Last occurrence wins, as draw.io does. */
|
|
|
|
|
export function readMarker(style: string, key: string): string | null {
|
|
|
|
|
// Scan all matches and keep the last, mirroring draw.io's duplicate-key resolution.
|
|
|
|
|
const re = new RegExp(`(?:^|;)${key}=([^;]*)`, "g")
|
|
|
|
|
let last: string | null = null
|
|
|
|
|
let m = re.exec(style)
|
|
|
|
|
while (m !== null) {
|
|
|
|
|
last = m[1]
|
|
|
|
|
m = re.exec(style)
|
|
|
|
|
}
|
|
|
|
|
return last
|
|
|
|
|
}
|
|
|
|
|
|
feat(diagram-engine): flowcharts, swimlanes, sequence diagrams and mind maps
Extends the declarative engine past cloud architecture. The tool routing was
divided by icon library — AWS through the engine, everything else hand-written
XML — which is the wrong axis. What matters is the LAYOUT SHAPE.
Measured first: a six-step approval flow declared in its natural order comes out
as one column, because the layout only arranges what nesting tells it to and
never looked at the arrows. That forces the arrow from the decision to its second
branch to jump over the first branch.
graph.ts computes what the layout should have looked at: layer assignment by
longest path, cycle breaking so a loop is drawn without setting the order, and
barycentre sweeping to cut edge crossings. It emits ordinary container
operations, so layout, routing and round-tripping are unchanged — reaching zero
arrows-through-boxes on a 14-node pipeline and zero crossings on a bipartite
graph whose declared order forces three.
Three new container kinds, each because one layout rule cannot serve them all:
pool — swimlanes. Lanes are real cells and each step is parented to its
band, so dragging a step to another role records the change.
sequence — participants across the top, one lifeline cell per participant so
head and line stay together on a drag. Messages bypass the router:
a message's height IS its order.
radial — mind maps and org charts. Children are a flat list and the
hierarchy comes from the links, because a branch is a box and a box
cannot hold children.
Flowchart box shapes (diamond, stadium, parallelogram, document) so a reader can
tell a branch from a step.
Two bugs the new tests caught: the duplicate-link guard blocked a sequence
diagram from having two messages between the same pair, and the fallback message
numbering was shared across containers, pushing a second diagram's messages off
its own lifelines.
523 unit tests and 17 diagram e2e tests pass. Every kind verified round-trip
stable to a fixed point, and rendered in a real browser — draw.io keeps the
lifeline shape and the lane markers.
2026-08-09 13:47:23 +09:00
|
|
|
const KINDS: readonly NodeKind[] = [
|
|
|
|
|
"group",
|
|
|
|
|
"grid",
|
|
|
|
|
"pool",
|
|
|
|
|
"sequence",
|
|
|
|
|
"radial",
|
|
|
|
|
"icon",
|
|
|
|
|
"box",
|
|
|
|
|
"title",
|
|
|
|
|
]
|
|
|
|
|
|
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
|
|
|
export function readKind(style: string): NodeKind | null {
|
|
|
|
|
const v = readMarker(style, MARKER.kind)
|
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
|
|
|
return KINDS.includes(v as NodeKind) ? (v as NodeKind) : null
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* The (lane, column) cell a node occupies in a swimlane pool, or null.
|
|
|
|
|
*
|
|
|
|
|
* Both must be non-negative integers: a malformed value is safer read as "no cell
|
|
|
|
|
* declared" (which puts the node in lane 0 column 0) than as a negative index, which would
|
|
|
|
|
* place it outside the pool's frame.
|
|
|
|
|
*/
|
|
|
|
|
export function readCell(style: string): { lane: number; col: number } | null {
|
|
|
|
|
const v = readMarker(style, MARKER.cell)
|
|
|
|
|
if (!v) return null
|
|
|
|
|
const m = v.match(/^(\d+),(\d+)$/)
|
|
|
|
|
return m ? { lane: Number(m[1]), col: Number(m[2]) } : null
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* A tab-separated marker list, as written by `joinList`.
|
|
|
|
|
*
|
|
|
|
|
* A tab cannot appear in a draw.io style value — the editor writes styles as a single
|
|
|
|
|
* semicolon-separated line — so it is safe as a separator inside one value, where a comma
|
|
|
|
|
* would collide with the label text it has to carry.
|
|
|
|
|
*/
|
|
|
|
|
export function readList(style: string, key: string): string[] | null {
|
|
|
|
|
const v = readMarker(style, key)
|
|
|
|
|
if (v === null) return null
|
|
|
|
|
if (v === "") return []
|
|
|
|
|
return v.split("\t").map(decodeURIComponent)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/** Encode a list of labels into one marker value. */
|
|
|
|
|
export function joinList(items: string[]): string {
|
|
|
|
|
// Percent-encoding keeps a label containing ";" or "=" from breaking the style string.
|
|
|
|
|
return items.map((s) => encodeURIComponent(s)).join("\t")
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/** Is this cell pool chrome the engine draws and owns, rather than a node? */
|
|
|
|
|
export function isLaneChrome(style: string): boolean {
|
|
|
|
|
return readMarker(style, MARKER.lane) !== null
|
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
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export function readDir(style: string): Direction | null {
|
|
|
|
|
const v = readMarker(style, MARKER.dir)
|
|
|
|
|
if (v === "row" || v === "col" || v === "grid") return v
|
|
|
|
|
return null
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/** Read a positive integer marker (gap, cols). Returns null when absent or malformed. */
|
|
|
|
|
export function readIntMarker(style: string, key: string): number | null {
|
|
|
|
|
const v = readMarker(style, key)
|
|
|
|
|
if (v === null) return null
|
|
|
|
|
const n = Number(v)
|
|
|
|
|
return Number.isFinite(n) && n >= 0 ? Math.round(n) : null
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Has the user pinned this node? Any value other than "0"/""/"false" counts as
|
|
|
|
|
* pinned, so a user typing `dai_pin=1` (or just `dai_pin=yes`) in draw.io's
|
|
|
|
|
* "Edit Style" dialog gets what they expect.
|
|
|
|
|
*/
|
|
|
|
|
export function isPinned(style: string): boolean {
|
|
|
|
|
const v = readMarker(style, MARKER.pin)
|
|
|
|
|
if (v === null) return false
|
|
|
|
|
const s = v.trim().toLowerCase()
|
|
|
|
|
return s !== "" && s !== "0" && s !== "false"
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/** Append `key=value;`, ensuring the style ends with a separator first. */
|
|
|
|
|
function append(style: string, key: string, value: string | number): string {
|
|
|
|
|
const base = style.endsWith(";") || style === "" ? style : `${style};`
|
|
|
|
|
return `${base}${key}=${value};`
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Stamp a container's style: make it a real draw.io container and record its
|
|
|
|
|
* layout parameters.
|
|
|
|
|
*
|
|
|
|
|
* Appends rather than rewrites. Duplicate keys are legal and the last one wins, so
|
|
|
|
|
* a catalog style that already says `container=1` is unharmed, and one that says
|
|
|
|
|
* nothing (or `container=0`) is corrected.
|
|
|
|
|
*/
|
|
|
|
|
export function stampContainer(
|
|
|
|
|
style: string,
|
|
|
|
|
opts: {
|
|
|
|
|
kind: "group" | "grid"
|
|
|
|
|
dir: Direction
|
|
|
|
|
gap: number
|
|
|
|
|
cols?: number
|
feat(diagram-engine): layout + XML renderer, verified end to end in draw.io
Completes the tree → coordinates → XML direction, so the model can declare nesting
and never write a coordinate or an mxCell again.
layout.ts — measure bottom-up, place top-down, the same shape as flexbox. A
container sums its children along the flow axis and adds padding, so "child spills
out of its frame" and "siblings overlap" cannot happen by construction rather than
being caught afterwards. Slack from sibling equalisation is shared between children
instead of left as dead margin, capped at one gap so a stretched frame reads as
spaced rather than sparse.
render.ts — writes the mxCells, stamping container=1 and the dai_* markers so
parse.ts can read the structure back. Edges carry no waypoints: draw.io's own router
recomputes the route on every edit, so a user who moves a node never has to re-link
an arrow. Cells the parser could not interpret are re-emitted verbatim, so a
re-layout never deletes a user's annotations.
Phantoms are gone (task #5). The reference project's layout-only wrapper emits no
cell, which makes the round-trip lossy by construction — measured on its own
build_vpc.mjs, a phantom erased a container's "col" direction for good. An
unlabelled frame here emits a real cell with fillColor/strokeColor=none instead:
invisible, but present in the XML and therefore recoverable.
Two bugs the round-trip test caught, both real:
- An icon's cell was being emitted at its measured slot size, which includes room
for the label underneath. Parsing read that width back as the glyph size, so the
icon grew on every round-trip. The cell is now the glyph square and the label
renders outside it via verticalLabelPosition, as the reference does.
- An Azure or GCP icon is an embedded base64 image whose style contains no name
anywhere, so the catalog name was unrecoverable. Added a dai_name marker.
Verified in a real browser (3 Playwright tests, not mocks): engine output renders in
draw.io; dragging a shape into a frame makes draw.io rewrite its parent and the
engine reads the new structure back; re-laying out from that structure PRESERVES the
user's move instead of undoing it, and leaves untouched nodes alone; and the
re-laid-out XML still renders.
That last point is the whole design: there is no second copy of the state, so a
manual edit is an input to the next layout rather than a conflict to reconcile.
304 unit tests + 3 e2e.
2026-08-09 11:56:08 +09:00
|
|
|
/** Layout-only wrapper: emit a real cell, but draw nothing. */
|
|
|
|
|
invisible?: boolean
|
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
|
|
|
},
|
|
|
|
|
): string {
|
|
|
|
|
let s = style.endsWith(";") || style === "" ? style : `${style};`
|
|
|
|
|
s += CONTAINER_TOKENS
|
feat(diagram-engine): layout + XML renderer, verified end to end in draw.io
Completes the tree → coordinates → XML direction, so the model can declare nesting
and never write a coordinate or an mxCell again.
layout.ts — measure bottom-up, place top-down, the same shape as flexbox. A
container sums its children along the flow axis and adds padding, so "child spills
out of its frame" and "siblings overlap" cannot happen by construction rather than
being caught afterwards. Slack from sibling equalisation is shared between children
instead of left as dead margin, capped at one gap so a stretched frame reads as
spaced rather than sparse.
render.ts — writes the mxCells, stamping container=1 and the dai_* markers so
parse.ts can read the structure back. Edges carry no waypoints: draw.io's own router
recomputes the route on every edit, so a user who moves a node never has to re-link
an arrow. Cells the parser could not interpret are re-emitted verbatim, so a
re-layout never deletes a user's annotations.
Phantoms are gone (task #5). The reference project's layout-only wrapper emits no
cell, which makes the round-trip lossy by construction — measured on its own
build_vpc.mjs, a phantom erased a container's "col" direction for good. An
unlabelled frame here emits a real cell with fillColor/strokeColor=none instead:
invisible, but present in the XML and therefore recoverable.
Two bugs the round-trip test caught, both real:
- An icon's cell was being emitted at its measured slot size, which includes room
for the label underneath. Parsing read that width back as the glyph size, so the
icon grew on every round-trip. The cell is now the glyph square and the label
renders outside it via verticalLabelPosition, as the reference does.
- An Azure or GCP icon is an embedded base64 image whose style contains no name
anywhere, so the catalog name was unrecoverable. Added a dai_name marker.
Verified in a real browser (3 Playwright tests, not mocks): engine output renders in
draw.io; dragging a shape into a frame makes draw.io rewrite its parent and the
engine reads the new structure back; re-laying out from that structure PRESERVES the
user's move instead of undoing it, and leaves untouched nodes alone; and the
re-laid-out XML still renders.
That last point is the whole design: there is no second copy of the state, so a
manual edit is an input to the next layout rather than a conflict to reconcile.
304 unit tests + 3 e2e.
2026-08-09 11:56:08 +09:00
|
|
|
if (opts.invisible) s += INVISIBLE_TOKENS
|
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
|
|
|
s = append(s, MARKER.kind, opts.kind)
|
|
|
|
|
s = append(s, MARKER.dir, opts.dir)
|
|
|
|
|
s = append(s, MARKER.gap, Math.round(opts.gap))
|
|
|
|
|
if (opts.kind === "grid" && opts.cols != null)
|
|
|
|
|
s = append(s, MARKER.cols, Math.max(1, Math.round(opts.cols)))
|
|
|
|
|
return s
|
|
|
|
|
}
|
|
|
|
|
|
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
|
|
|
/**
|
|
|
|
|
* Stamp a swimlane pool: its lane names, milestone labels and orientation.
|
|
|
|
|
*
|
|
|
|
|
* Unlike a group, a pool is NOT stamped as a draw.io container. Its lane bands are separate
|
|
|
|
|
* cells sitting inside it, and they are what a shape should reparent into when the user
|
|
|
|
|
* drags it — that is how "the user moved this step to a different role" gets recorded. If
|
|
|
|
|
* the pool itself claimed the drop, every node would come back in lane 0.
|
|
|
|
|
*/
|
|
|
|
|
export function stampPool(
|
|
|
|
|
style: string,
|
|
|
|
|
opts: {
|
|
|
|
|
lanes: string[]
|
|
|
|
|
phases: string[]
|
|
|
|
|
orientation: "horizontal" | "vertical"
|
|
|
|
|
gap: number
|
|
|
|
|
},
|
|
|
|
|
): string {
|
|
|
|
|
let s = append(style, MARKER.kind, "pool")
|
|
|
|
|
s = append(s, MARKER.lanes, joinList(opts.lanes))
|
|
|
|
|
s = append(s, MARKER.phases, joinList(opts.phases))
|
|
|
|
|
s = append(s, MARKER.orient, opts.orientation === "vertical" ? "v" : "h")
|
|
|
|
|
return append(s, MARKER.gap, Math.round(opts.gap))
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/** Stamp a sequence container: participant spacing and message spacing. */
|
|
|
|
|
export function stampSequence(
|
|
|
|
|
style: string,
|
|
|
|
|
opts: { gap: number; step: number },
|
|
|
|
|
): string {
|
|
|
|
|
const s = append(style, MARKER.kind, "sequence")
|
|
|
|
|
return append(
|
|
|
|
|
append(s, MARKER.gap, Math.round(opts.gap)),
|
|
|
|
|
MARKER.step,
|
|
|
|
|
Math.round(opts.step),
|
|
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/** Stamp a radial container: how it fans branches out, and the ring spacing. */
|
|
|
|
|
export function stampRadial(
|
|
|
|
|
style: string,
|
|
|
|
|
opts: { spread: "radial" | "down"; gap: number },
|
|
|
|
|
): string {
|
|
|
|
|
const s = append(style, MARKER.kind, "radial")
|
|
|
|
|
return append(
|
|
|
|
|
append(s, MARKER.spread, opts.spread),
|
|
|
|
|
MARKER.gap,
|
|
|
|
|
Math.round(opts.gap),
|
|
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Stamp one of a pool's lane bands.
|
|
|
|
|
*
|
|
|
|
|
* A band IS a draw.io container, so dragging a step onto another role's band reparents it
|
|
|
|
|
* there and the marker on the band tells the parser which lane that is. The lane index is
|
|
|
|
|
* the band's identity, not its position, so the assignment survives the pool being
|
|
|
|
|
* re-measured to a different size.
|
|
|
|
|
*/
|
|
|
|
|
export function stampLane(style: string, lane: number): string {
|
|
|
|
|
let s = style.endsWith(";") || style === "" ? style : `${style};`
|
|
|
|
|
s += CONTAINER_TOKENS
|
|
|
|
|
return append(s, MARKER.lane, Math.max(0, Math.round(lane)))
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Stamp a pool's own decoration — a lane-name column or a milestone strip.
|
|
|
|
|
*
|
refactor(diagram-engine): apply review findings, fix vertical pool phases
Four reviewers went over the previous commit (three Claude, one Codex). Their
findings, verified independently before applying:
A REAL BUG. A vertical pool with milestone labels drew the label strip outside
the pool frame. The measure pass reserves width as padding + content + strip with
no gap between the last two; the renderer placed the strip one gap further out.
No test caught it because every vertical case omitted phases and every phases
case was horizontal — both regression cases added.
Duplicated logic, now single-sourced:
- messageCount existed byte-identically in layout.ts and render.ts. Two copies
that had to agree or the lifelines stop reaching the last message.
- sequenceMetrics was called twice per sequence container, once inside the
chrome builder and again for the message positions. Same drift hazard, in the
file whose own comment warns about it.
Dead code, each verified unreachable rather than assumed:
- Placed.extent: declared and documented, never written or read. Every .extent
access belongs to RadialTree.
- SequenceMetrics.top: computed, returned, no reader.
- spread()'s level parameter: threaded through the recursion, never used.
- radialReach's .slice(0, generations): widestPerLevel writes one entry per
generation, so its length IS the depth. Confirmed over 20,000 random trees;
removing it made RadialTree.depth dead too.
- Two of three cycle guards in radialHierarchy: self-links are already skipped
when the parent map is built, and that map holds one parent per node, so the
structure is a forest and the visited-set filter cannot fire. The rootOf
guard does fire and stays.
- GraphOptions.layerGap/nodeGap/idPrefix: no caller, not in the tool schema.
Simplifications:
- LayoutContext wrapped a single field; the link array now passes directly,
which also removes the NO_CONTEXT default no call site ever took.
- stretches() and the mirror-image check five lines below it expressed one rule
two ways; unified, with the rationale stated once.
- hasStencilFrame/isDirectional: one caller each, and isDirectional's name
contradicted its body, which the guarded branch then re-discriminated anyway.
- poolFrameStyle() took no arguments and had one caller.
- poolCellOf clamped a value already clamped at the model boundary and
unreachable-by-construction from the parser.
- A comment on stampPoolDecoration described container behaviour the function
does not implement.
Kept deliberately, with evidence:
- The best-arrangement tracking in the crossing reducer. Two reviewers
suspected it was dead weight. Measured: barycentre sweeping regressed below
its own running best in 180 of 500 random graphs, so without it a third of
flowcharts would keep a worse arrangement than one already found.
- Vertical pools. Two reviewers recommended deleting the feature as
undiscoverable. The bug was one line, and vertical swimlanes are a real
convention — documented to the model instead, which is what was actually
missing.
- styleValue duplicating readMarker, isLeaf, findPageIndex: all genuinely
redundant, all predating this branch. Left alone to keep the diff scoped.
525 unit tests and 11 diagram e2e tests pass.
2026-08-09 14:47:46 +09:00
|
|
|
* `dai_lane=-1` marks it as chrome the renderer rebuilds, so the parser drops it rather
|
|
|
|
|
* than reading it back as a node. Unlike a lane band it is deliberately NOT a draw.io
|
|
|
|
|
* container: a step dropped on a label column belongs to no role, and letting it reparent
|
|
|
|
|
* there would lose the step's lane.
|
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
|
|
|
*/
|
|
|
|
|
export function stampPoolDecoration(style: string): string {
|
|
|
|
|
return append(style, MARKER.lane, -1)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/** Record which pool cell a node occupies. */
|
|
|
|
|
export function stampCell(
|
|
|
|
|
style: string,
|
|
|
|
|
cell: { lane: number; col: number },
|
|
|
|
|
): string {
|
|
|
|
|
return append(
|
|
|
|
|
style,
|
|
|
|
|
MARKER.cell,
|
|
|
|
|
`${Math.max(0, Math.round(cell.lane))},${Math.max(0, Math.round(cell.col))}`,
|
|
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
|
feat(diagram-engine): layout + XML renderer, verified end to end in draw.io
Completes the tree → coordinates → XML direction, so the model can declare nesting
and never write a coordinate or an mxCell again.
layout.ts — measure bottom-up, place top-down, the same shape as flexbox. A
container sums its children along the flow axis and adds padding, so "child spills
out of its frame" and "siblings overlap" cannot happen by construction rather than
being caught afterwards. Slack from sibling equalisation is shared between children
instead of left as dead margin, capped at one gap so a stretched frame reads as
spaced rather than sparse.
render.ts — writes the mxCells, stamping container=1 and the dai_* markers so
parse.ts can read the structure back. Edges carry no waypoints: draw.io's own router
recomputes the route on every edit, so a user who moves a node never has to re-link
an arrow. Cells the parser could not interpret are re-emitted verbatim, so a
re-layout never deletes a user's annotations.
Phantoms are gone (task #5). The reference project's layout-only wrapper emits no
cell, which makes the round-trip lossy by construction — measured on its own
build_vpc.mjs, a phantom erased a container's "col" direction for good. An
unlabelled frame here emits a real cell with fillColor/strokeColor=none instead:
invisible, but present in the XML and therefore recoverable.
Two bugs the round-trip test caught, both real:
- An icon's cell was being emitted at its measured slot size, which includes room
for the label underneath. Parsing read that width back as the glyph size, so the
icon grew on every round-trip. The cell is now the glyph square and the label
renders outside it via verticalLabelPosition, as the reference does.
- An Azure or GCP icon is an embedded base64 image whose style contains no name
anywhere, so the catalog name was unrecoverable. Added a dai_name marker.
Verified in a real browser (3 Playwright tests, not mocks): engine output renders in
draw.io; dragging a shape into a frame makes draw.io rewrite its parent and the
engine reads the new structure back; re-laying out from that structure PRESERVES the
user's move instead of undoing it, and leaves untouched nodes alone; and the
re-laid-out XML still renders.
That last point is the whole design: there is no second copy of the state, so a
manual edit is an input to the next layout rather than a conflict to reconcile.
304 unit tests + 3 e2e.
2026-08-09 11:56:08 +09:00
|
|
|
/**
|
|
|
|
|
* Is this an invisible layout wrapper? Both colours set to `none` and no group
|
|
|
|
|
* stencil — a visible frame always has a stroke or a stencil.
|
|
|
|
|
*/
|
|
|
|
|
export function isInvisible(style: string): boolean {
|
|
|
|
|
if (/grIcon=/.test(style)) return false
|
|
|
|
|
const fill = readMarker(style, "fillColor")
|
|
|
|
|
const stroke = readMarker(style, "strokeColor")
|
|
|
|
|
return fill === "none" && stroke === "none"
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Stamp a leaf with its kind, so the parser need not infer it.
|
|
|
|
|
*
|
|
|
|
|
* For an icon, also record the catalog name: an Azure or GCP icon's style is an embedded
|
|
|
|
|
* base64 image with no name in it, so the style alone cannot identify which icon it is.
|
|
|
|
|
*/
|
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
|
|
|
export function stampLeaf(
|
|
|
|
|
style: string,
|
|
|
|
|
kind: "icon" | "box" | "title",
|
feat(diagram-engine): layout + XML renderer, verified end to end in draw.io
Completes the tree → coordinates → XML direction, so the model can declare nesting
and never write a coordinate or an mxCell again.
layout.ts — measure bottom-up, place top-down, the same shape as flexbox. A
container sums its children along the flow axis and adds padding, so "child spills
out of its frame" and "siblings overlap" cannot happen by construction rather than
being caught afterwards. Slack from sibling equalisation is shared between children
instead of left as dead margin, capped at one gap so a stretched frame reads as
spaced rather than sparse.
render.ts — writes the mxCells, stamping container=1 and the dai_* markers so
parse.ts can read the structure back. Edges carry no waypoints: draw.io's own router
recomputes the route on every edit, so a user who moves a node never has to re-link
an arrow. Cells the parser could not interpret are re-emitted verbatim, so a
re-layout never deletes a user's annotations.
Phantoms are gone (task #5). The reference project's layout-only wrapper emits no
cell, which makes the round-trip lossy by construction — measured on its own
build_vpc.mjs, a phantom erased a container's "col" direction for good. An
unlabelled frame here emits a real cell with fillColor/strokeColor=none instead:
invisible, but present in the XML and therefore recoverable.
Two bugs the round-trip test caught, both real:
- An icon's cell was being emitted at its measured slot size, which includes room
for the label underneath. Parsing read that width back as the glyph size, so the
icon grew on every round-trip. The cell is now the glyph square and the label
renders outside it via verticalLabelPosition, as the reference does.
- An Azure or GCP icon is an embedded base64 image whose style contains no name
anywhere, so the catalog name was unrecoverable. Added a dai_name marker.
Verified in a real browser (3 Playwright tests, not mocks): engine output renders in
draw.io; dragging a shape into a frame makes draw.io rewrite its parent and the
engine reads the new structure back; re-laying out from that structure PRESERVES the
user's move instead of undoing it, and leaves untouched nodes alone; and the
re-laid-out XML still renders.
That last point is the whole design: there is no second copy of the state, so a
manual edit is an input to the next layout rather than a conflict to reconcile.
304 unit tests + 3 e2e.
2026-08-09 11:56:08 +09:00
|
|
|
opts: { name?: string } = {},
|
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
|
|
|
): string {
|
feat(diagram-engine): layout + XML renderer, verified end to end in draw.io
Completes the tree → coordinates → XML direction, so the model can declare nesting
and never write a coordinate or an mxCell again.
layout.ts — measure bottom-up, place top-down, the same shape as flexbox. A
container sums its children along the flow axis and adds padding, so "child spills
out of its frame" and "siblings overlap" cannot happen by construction rather than
being caught afterwards. Slack from sibling equalisation is shared between children
instead of left as dead margin, capped at one gap so a stretched frame reads as
spaced rather than sparse.
render.ts — writes the mxCells, stamping container=1 and the dai_* markers so
parse.ts can read the structure back. Edges carry no waypoints: draw.io's own router
recomputes the route on every edit, so a user who moves a node never has to re-link
an arrow. Cells the parser could not interpret are re-emitted verbatim, so a
re-layout never deletes a user's annotations.
Phantoms are gone (task #5). The reference project's layout-only wrapper emits no
cell, which makes the round-trip lossy by construction — measured on its own
build_vpc.mjs, a phantom erased a container's "col" direction for good. An
unlabelled frame here emits a real cell with fillColor/strokeColor=none instead:
invisible, but present in the XML and therefore recoverable.
Two bugs the round-trip test caught, both real:
- An icon's cell was being emitted at its measured slot size, which includes room
for the label underneath. Parsing read that width back as the glyph size, so the
icon grew on every round-trip. The cell is now the glyph square and the label
renders outside it via verticalLabelPosition, as the reference does.
- An Azure or GCP icon is an embedded base64 image whose style contains no name
anywhere, so the catalog name was unrecoverable. Added a dai_name marker.
Verified in a real browser (3 Playwright tests, not mocks): engine output renders in
draw.io; dragging a shape into a frame makes draw.io rewrite its parent and the
engine reads the new structure back; re-laying out from that structure PRESERVES the
user's move instead of undoing it, and leaves untouched nodes alone; and the
re-laid-out XML still renders.
That last point is the whole design: there is no second copy of the state, so a
manual edit is an input to the next layout rather than a conflict to reconcile.
304 unit tests + 3 e2e.
2026-08-09 11:56:08 +09:00
|
|
|
const s = append(style, MARKER.kind, kind)
|
|
|
|
|
return opts.name ? append(s, MARKER.name, opts.name) : s
|
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
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/** Strip every `dai_*` marker — for exporting a clean file, or comparing styles. */
|
|
|
|
|
export function stripMarkers(style: string): string {
|
|
|
|
|
return style
|
|
|
|
|
.split(";")
|
|
|
|
|
.filter((tok) => tok !== "" && !tok.startsWith("dai_"))
|
|
|
|
|
.join(";")
|
|
|
|
|
.concat(";")
|
|
|
|
|
.replace(/^;$/, "")
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/** Does this style carry any engine marker? Used to tell engine output from imported files. */
|
|
|
|
|
export function hasMarkers(style: string): boolean {
|
|
|
|
|
return /(?:^|;)dai_[a-z]+=/.test(style)
|
|
|
|
|
}
|