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
|
|
|
|
/**
|
|
|
|
|
|
* tree → XML. Takes a laid-out forest and writes the mxCell elements draw.io reads.
|
|
|
|
|
|
*
|
|
|
|
|
|
* Every cell it emits carries `container=1` on containers and `dai_*` markers recording
|
|
|
|
|
|
* the layout parameters, so parse.ts can read the structure back. That round-trip is
|
|
|
|
|
|
* what lets the canvas stay the single source of truth.
|
|
|
|
|
|
*
|
|
|
|
|
|
* Ported from drawio-ai-kit (MIT) — see NOTICE.
|
|
|
|
|
|
*/
|
|
|
|
|
|
|
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
|
|
|
|
import {
|
|
|
|
|
|
flatten,
|
|
|
|
|
|
ICON_SIZE,
|
|
|
|
|
|
LANE_LABEL,
|
|
|
|
|
|
layoutForest,
|
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
|
|
|
|
messageCount,
|
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
|
|
|
|
type Placed,
|
|
|
|
|
|
POOL_PAD,
|
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
|
|
|
|
poolCellOf,
|
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
|
|
|
|
poolMetrics,
|
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
|
|
|
|
type SequenceMetrics,
|
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
|
|
|
|
sequenceMetrics,
|
|
|
|
|
|
} from "./layout"
|
|
|
|
|
|
import {
|
fix(diagram-engine): eliminate arrows drawn through boxes, complete the route search
A user's approval-workflow flowchart came out with the return arrow drawn
straight through two unrelated steps, and a second report showed arrows
leaving a box and bending straight back across it. Measured over 250
generated flowcharts (2722 edges): 347 arrows crossed an unrelated box and
215 waypoints landed inside a shape. Four defects, each measured in
isolation:
1. The invisible layer containers draw_graph emits were handed to the
router as frames, so every clean return path was rejected for
"trespassing" on a border that is not drawn, and the fallback cut
through two boxes. Excluding invisible containers: 347 -> 218 crossing
arrows, no diagram made worse.
2. The router chose the horizontal-vs-vertical axis BEFORE searching, so
when the only clean corridor ran along the other axis it was never
looked at. A complete two-bend candidate generator that tries both
trunk axes, all four sides at each end, and the port fractions:
218 -> 27. (An independent ablation measured the axis pre-choice alone
at a 40% per-edge failure rate.)
3. Nothing stopped a route's first leg from turning back across its own
source shape - the obstacle test exempts an edge's own endpoints, and
must, since the line has to touch them. A terminal-leg rule refuses
such routes outright: 215 -> 4 hooks.
4. A two-bend search cannot express the staircase needed when a box sits
directly between two vertically aligned nodes (21 of the last 27
crossings). Added the orthogonal visibility graph + A* from Wybrow,
Marriott & Stuckey, "Orthogonal Connector Routing" (GD 2009) - the
libavoid algorithm - as the backstop when the candidate search finds
nothing. The interesting-points grid is provably sufficient: any valid
route shrinks onto it without getting longer or gaining bends. The A*
state is (point, incoming direction) with libavoid's bend cost of 10,
and the admissible bends-remaining heuristic, so it returns a cheapest
route, not merely a route. Implemented from the paper, not ported.
After all four: 0 crossing arrows and 0 hooks over the same 250 diagrams,
page area unchanged (494k px^2 mean), 260ms for the whole corpus, mean
0.82 bends per edge. The shape ladder still runs first, so routes that
were already clean are byte-identical.
Also post-nudge validation now checks the whole path (the nudge pass only
reverts the single segment it moved, judged in isolation) and restores the
search's route if nudging made it dirty.
2026-08-09 18:18:54 +09:00
|
|
|
|
isInvisible,
|
feat(diagram-engine): open shape vocabulary — catalog + pass-through, structured style merge
The expressiveness gap traced to vocabulary: draw.io has hundreds of shapes,
the declarative layer allowed six. Opening it, with the failure modes from
design review handled:
- shapes.ts: a ~20-entry curated catalog (full style fragment incl. the
matching perimeter — required or edges connect to the bounding box; a
text-scale factor verified in the real editor — the same sentence overflows
a 1.0x rhombus and fits a 1.5x one; labelOutside+glyph for umlActor-style
figures). Any other token passes through verbatim: draw.io degrades unknown
shapes to rectangles safely (verified). Injection-capable tokens (;/=) are
rejected outright.
- Pass-through emits a WARNING with a nearest-catalog hint (bounded edit
distance), so a typo'd 'cyclinder' is a one-turn fix instead of a silently
rectangular node forever. set_shape/set_role/set_group operations make the
fix possible without remove+re-add (which would drop links).
- mergeStyle(): style fragments merge per-key (later wins, bare shape classes
displace each other) instead of string concatenation. This is what makes
shape and theme composable by rule — shape owns geometry keys, theme owns
colour/type keys, and an overlap resolves by order instead of emitting
contradictory duplicates.
- dai_shape marker carries the declared token through the round trip:
appearance-based reverse mapping cannot distinguish aliases (diamond vs
decision) or a rotated queue from a cylinder.
- dai_auto marker separates engine-measured size from user-fixed size: parsed
boxes no longer freeze the first layout's numbers, so changing a label
re-measures. Pinned nodes keep everything, as before.
- draw_graph's closed shape enum opened to match (first instance of the
schema-drift problem the review predicted).
14 new tests: merge ownership, injection rejection, near-match hints,
alias-preserving round trip, re-measure on label change, mxgraph.* tokens
staying boxes with role/group intact. 556 unit tests green; acceptance
diagram (person/hexagon/cylinder/queue/cloud/decision/callout) verified in
the real editor.
2026-08-09 21:56:09 +09:00
|
|
|
|
stampAuto,
|
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,
|
|
|
|
|
|
stampContainer,
|
feat(diagram-engine): flex knobs (grow/align/pad) + inline rich-text labels
The expressiveness gap between engine output and hand-written XML came down
to two missing capabilities, both generic:
- Block layout inside a box: nested containers already existed, but there was
no way to split space by weight, pin a child to an edge, or tighten padding.
Added grow (flex-grow over the parent's leftover flow-axis space, TeX's
glue), align (start/center/end/stretch on the cross axis) and pad
(per-group interior padding). All three round-trip via dai_grow/dai_align/
dai_pad markers.
- Inline rich text: labels already render HTML (html=1 on every style, esc()
entities decode back), but the measure pass counted markup as text. The
visibleText() strip makes autoBoxSize measure what draw.io draws: <br> is a
line, other inline tags are invisible.
Graphviz (HTML-like table labels), D2 (grid containers + markdown-in-shape)
and TeX (box+glue) converged on exactly this design: nested boxes for block
structure, proportional glue, a small inline set for text — never full HTML.
Prompts teach the composition with a comparison-card recipe; verified by
rebuilding the CoT poster end to end in the real editor.
2026-08-09 20:55:12 +09:00
|
|
|
|
stampFlex,
|
feat(diagram-engine): design tokens + role/group composition, engine-wide theming
Paper-summary posters previously required hand-written XML: every engine
box rendered identically (white, 11px), so anything whose meaning lives
in visual hierarchy came out flat. This makes presentation a first-class,
generalised part of the declaration - not a poster feature.
Structure/presentation separation, the same split HTML and CSS settled on:
- ROLE says what a node IS: banner, heading, body, callout, good, bad,
metric, muted. Maps to a type scale and an emphasis (filled / tinted /
outlined / ghost), never to a colour.
- GROUP says which semantic zone a node belongs to. Each distinct group
name gets one hue ramp (tint / base / dark), assigned in document
order. Promoted from a draw_graph-only field to BoxNode and GroupNode,
round-tripped via dai_group.
- themedStyle(role, hue, kind) composes the two by rule - there is no
per-combination table to extend, so a new diagram kind gets full
theming by tagging nodes. The model never sees a hex value.
A heading container plus a group yields the tinted section panel with a
dark title; a grouped body box takes its zone's tint; verdict roles stay
green/red regardless of zone; the banner is the page's one dark field.
Also fixed, found while building the acceptance poster:
- autoBoxSize only counted explicit newlines, so a long single-line label
wrapped to six lines in draw.io but got a one-line-tall box, and the
text overflowed the cell.
- Marker stamping appended without replacing, so every render of a
recovered style grew it by one duplicate dai_* token per key -
unnoticed because draw.io resolves duplicates last-wins. dai_* keys
are now replaced in place; mxGraph keys still append, because
last-wins is load-bearing for container=1 normalisation.
- Banner/heading/metric roles stretch across their container's cross
axis, the way a masthead spans its page.
- Prompt: a poster's banner IS its title (no set_title alongside), and
sections get their colour by naming groups.
537 unit tests, 250-flowchart corpus still zero crossing arrows, 5 e2e
tests in a real browser. Verified visually: the Transformer-paper poster
renders with a navy masthead, three hue-coded section panels, metric,
verdict and callout boxes - all engine-computed geometry.
2026-08-09 19:48:01 +09:00
|
|
|
|
stampGroup,
|
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,
|
|
|
|
|
|
stampLeaf,
|
|
|
|
|
|
stampPool,
|
|
|
|
|
|
stampPoolDecoration,
|
|
|
|
|
|
stampRadial,
|
feat(diagram-engine): design tokens + role/group composition, engine-wide theming
Paper-summary posters previously required hand-written XML: every engine
box rendered identically (white, 11px), so anything whose meaning lives
in visual hierarchy came out flat. This makes presentation a first-class,
generalised part of the declaration - not a poster feature.
Structure/presentation separation, the same split HTML and CSS settled on:
- ROLE says what a node IS: banner, heading, body, callout, good, bad,
metric, muted. Maps to a type scale and an emphasis (filled / tinted /
outlined / ghost), never to a colour.
- GROUP says which semantic zone a node belongs to. Each distinct group
name gets one hue ramp (tint / base / dark), assigned in document
order. Promoted from a draw_graph-only field to BoxNode and GroupNode,
round-tripped via dai_group.
- themedStyle(role, hue, kind) composes the two by rule - there is no
per-combination table to extend, so a new diagram kind gets full
theming by tagging nodes. The model never sees a hex value.
A heading container plus a group yields the tinted section panel with a
dark title; a grouped body box takes its zone's tint; verdict roles stay
green/red regardless of zone; the banner is the page's one dark field.
Also fixed, found while building the acceptance poster:
- autoBoxSize only counted explicit newlines, so a long single-line label
wrapped to six lines in draw.io but got a one-line-tall box, and the
text overflowed the cell.
- Marker stamping appended without replacing, so every render of a
recovered style grew it by one duplicate dai_* token per key -
unnoticed because draw.io resolves duplicates last-wins. dai_* keys
are now replaced in place; mxGraph keys still append, because
last-wins is load-bearing for container=1 normalisation.
- Banner/heading/metric roles stretch across their container's cross
axis, the way a masthead spans its page.
- Prompt: a poster's banner IS its title (no set_title alongside), and
sections get their colour by naming groups.
537 unit tests, 250-flowchart corpus still zero crossing arrows, 5 e2e
tests in a real browser. Verified visually: the Transformer-paper poster
renders with a navy masthead, three hue-coded section panels, metric,
verdict and callout boxes - all engine-computed geometry.
2026-08-09 19:48:01 +09:00
|
|
|
|
stampRole,
|
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
|
|
|
|
stampSequence,
|
feat(diagram-engine): open shape vocabulary — catalog + pass-through, structured style merge
The expressiveness gap traced to vocabulary: draw.io has hundreds of shapes,
the declarative layer allowed six. Opening it, with the failure modes from
design review handled:
- shapes.ts: a ~20-entry curated catalog (full style fragment incl. the
matching perimeter — required or edges connect to the bounding box; a
text-scale factor verified in the real editor — the same sentence overflows
a 1.0x rhombus and fits a 1.5x one; labelOutside+glyph for umlActor-style
figures). Any other token passes through verbatim: draw.io degrades unknown
shapes to rectangles safely (verified). Injection-capable tokens (;/=) are
rejected outright.
- Pass-through emits a WARNING with a nearest-catalog hint (bounded edit
distance), so a typo'd 'cyclinder' is a one-turn fix instead of a silently
rectangular node forever. set_shape/set_role/set_group operations make the
fix possible without remove+re-add (which would drop links).
- mergeStyle(): style fragments merge per-key (later wins, bare shape classes
displace each other) instead of string concatenation. This is what makes
shape and theme composable by rule — shape owns geometry keys, theme owns
colour/type keys, and an overlap resolves by order instead of emitting
contradictory duplicates.
- dai_shape marker carries the declared token through the round trip:
appearance-based reverse mapping cannot distinguish aliases (diamond vs
decision) or a rotated queue from a cylinder.
- dai_auto marker separates engine-measured size from user-fixed size: parsed
boxes no longer freeze the first layout's numbers, so changing a label
re-measures. Pinned nodes keep everything, as before.
- draw_graph's closed shape enum opened to match (first instance of the
schema-drift problem the review predicted).
14 new tests: merge ownership, injection rejection, near-match hints,
alias-preserving round trip, re-measure on label change, mxgraph.* tokens
staying boxes with role/group intact. 556 unit tests green; acceptance
diagram (person/hexagon/cylinder/queue/cloud/decision/callout) verified in
the real editor.
2026-08-09 21:56:09 +09:00
|
|
|
|
stampShape,
|
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
|
|
|
|
} from "./markers"
|
feat(diagram-engine): edge router — pinned ports, obstacle avoidance, cost search
Fixes the reported problem: arrows overlapped on top of icons and ran through shapes
they had nothing to do with.
The cause was a missing layer, not a bug. render.ts emitted only source and target, so
draw.io routed every edge itself — and its router sees the two terminals' bounds and
nothing else, not where the other icons are. It therefore ran lines straight through
whatever was in between and left several edges leaving one node at the same point.
Ported from drawio-ai-kit's router (MIT, see NOTICE):
- Port de-collision. Edges leaving the same side of the same node spread along it,
ordered by where their far end sits so they do not cross on the way out. An edge
with a clean straight shot keeps the centre.
- Obstacle avoidance. Candidate shapes in order of directness — straight, a Z through
the gap, an L, a two-bend detour — each tested against every icon on the page.
- Frame placement. A frame is passable (an edge into a VPC must cross its border) but
not free: running alongside a border, or cutting through a frame that holds only one
of the two endpoints, is penalised.
- Port snapping. On a bent route, each port moves to the side its leg actually arrives
from. Without this the terminal segment can pierce the icon to reach a far-side port.
- Lane claiming. Each routed edge records the lanes it occupies so later edges avoid
them, rather than colliding and being pulled apart afterwards.
- Global nudge, three passes, reverting any move that makes a path worse.
Two things I got wrong on the way, both caught by looking at real geometry:
- The Z corridor was computed as min/max of both nodes' edges, which spans the whole
distance between them — including anything parked in between. So the lane sweep
would place the detour's middle leg on top of the very icon it was avoiding. It has
to be the gap: trailing edge of the first node to the leading edge of the other.
- The router was fed layout's slot rectangles, but an icon's cell is the glyph square
centred in a slot roughly twice as wide. Collision tests against slots both missed
real overlaps and invented false ones. Extracted cellRect() so the router and the
emitted XML cannot diverge.
Accept-or-reject was not enough on its own. When one endpoint is inside a VPC and the
other outside, EVERY path trespasses on that frame, so the strict rule always fails and
the relaxed pass took whatever it tried first — which is how a line ended up cutting
across a whole VPC. Candidates are now scored (frame offences 500, lane sharing 700,
bends 80, length 1) and the cheapest wins, so an edge that must trespass still gets the
least-bad route.
Waypoints are still written only when load-bearing — a labelled bend or a deliberate
detour — so an unobstructed edge stays drag-friendly.
455 unit tests (27 new, asserting produced geometry rather than algorithm shape).
Verified by rendering the reported diagram in the real editor: the two arrows that
overlapped on the EC2 icon now leave from different sides, and the load balancer's
fan-out leaves from three distinct points.
2026-08-09 12:46:44 +09:00
|
|
|
|
import { type RoutedEdge, routeEdges } from "./route"
|
feat(diagram-engine): open shape vocabulary — catalog + pass-through, structured style merge
The expressiveness gap traced to vocabulary: draw.io has hundreds of shapes,
the declarative layer allowed six. Opening it, with the failure modes from
design review handled:
- shapes.ts: a ~20-entry curated catalog (full style fragment incl. the
matching perimeter — required or edges connect to the bounding box; a
text-scale factor verified in the real editor — the same sentence overflows
a 1.0x rhombus and fits a 1.5x one; labelOutside+glyph for umlActor-style
figures). Any other token passes through verbatim: draw.io degrades unknown
shapes to rectangles safely (verified). Injection-capable tokens (;/=) are
rejected outright.
- Pass-through emits a WARNING with a nearest-catalog hint (bounded edit
distance), so a typo'd 'cyclinder' is a one-turn fix instead of a silently
rectangular node forever. set_shape/set_role/set_group operations make the
fix possible without remove+re-add (which would drop links).
- mergeStyle(): style fragments merge per-key (later wins, bare shape classes
displace each other) instead of string concatenation. This is what makes
shape and theme composable by rule — shape owns geometry keys, theme owns
colour/type keys, and an overlap resolves by order instead of emitting
contradictory duplicates.
- dai_shape marker carries the declared token through the round trip:
appearance-based reverse mapping cannot distinguish aliases (diamond vs
decision) or a rotated queue from a cylinder.
- dai_auto marker separates engine-measured size from user-fixed size: parsed
boxes no longer freeze the first layout's numbers, so changing a label
re-measures. Pinned nodes keep everything, as before.
- draw_graph's closed shape enum opened to match (first instance of the
schema-drift problem the review predicted).
14 new tests: merge ownership, injection rejection, near-match hints,
alias-preserving round trip, re-measure on label change, mxgraph.* tokens
staying boxes with role/group intact. 556 unit tests green; acceptance
diagram (person/hexagon/cylinder/queue/cloud/decision/callout) verified in
the real editor.
2026-08-09 21:56:09 +09:00
|
|
|
|
import { mergeStyle, resolveShape } from "./shapes"
|
feat(diagram-engine): flex knobs (grow/align/pad) + inline rich-text labels
The expressiveness gap between engine output and hand-written XML came down
to two missing capabilities, both generic:
- Block layout inside a box: nested containers already existed, but there was
no way to split space by weight, pin a child to an edge, or tighten padding.
Added grow (flex-grow over the parent's leftover flow-axis space, TeX's
glue), align (start/center/end/stretch on the cross axis) and pad
(per-group interior padding). All three round-trip via dai_grow/dai_align/
dai_pad markers.
- Inline rich text: labels already render HTML (html=1 on every style, esc()
entities decode back), but the measure pass counted markup as text. The
visibleText() strip makes autoBoxSize measure what draw.io draws: <br> is a
line, other inline tags are invisible.
Graphviz (HTML-like table labels), D2 (grid containers + markdown-in-shape)
and TeX (box+glue) converged on exactly this design: nested boxes for block
structure, proportional glue, a small inline set for text — never full HTML.
Prompts teach the composition with a comparison-card recipe; verified by
rebuilding the CoT poster end to end in the real editor.
2026-08-09 20:55:12 +09:00
|
|
|
|
import { hueOf, NEUTRAL, themedStyle } from "./theme"
|
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
|
|
|
|
import {
|
|
|
|
|
|
type DiagramNode,
|
|
|
|
|
|
type DiagramTree,
|
|
|
|
|
|
isContainer,
|
|
|
|
|
|
type LinkSpec,
|
|
|
|
|
|
type PoolNode,
|
|
|
|
|
|
type Rect,
|
|
|
|
|
|
type SequenceNode,
|
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
|
|
|
|
} from "./types"
|
feat(diagram-engine): label avoidance, paired opposite edges, semantic group colours
A git-workflow flowchart rendered with no overlaps but read poorly. Three
distinct causes, each fixed and measured:
1. Edge labels sat on boxes and on each other (4 collisions on the
reported diagram; 280 across 250 generated flowcharts). The router
keeps LINES off the boxes but a label renders at its edge's midpoint,
which on a long edge is beside exactly the things the line was routed
around. placeLabels slides each label along its own edge to a clear
spot — longest edges first, midpoint-outward tries — written as the
geometry's relative x, which draw.io natively supports. Corpus: 280
label collisions -> 8.
2. A->B and B->A were routed independently, so "git add" ran straight
while "git reset" wandered through a different corridor with a kink.
Opposite edges that agree on axis now get two absolute parallel tracks
in the strip where the two boxes overlap, a constant 24px apart,
converted back to port fractions. Zero crossing regressions.
3. All boxes rendered the same white, because the render layer's
fill/stroke support was never reachable: neither add_box's schema nor
draw_graph's nodes exposed it. Rather than exposing raw hex (the model
picks mismatched saturations, differently every time), nodes take a
semantic group name and the engine maps groups to a fixed palette of
six paired fill/strokes in order of first appearance. The model names
the zones - remote vs local vs temp - and never touches a colour.
532 unit tests pass; the 5 diagram e2e tests pass in a real browser.
2026-08-09 18:59:34 +09:00
|
|
|
|
import type { Point } from "./visgraph"
|
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
|
|
|
|
|
|
|
|
|
|
/** Escape the five characters that would break an XML attribute. */
|
|
|
|
|
|
export function esc(s: string): string {
|
|
|
|
|
|
return String(s ?? "")
|
|
|
|
|
|
.replace(/&/g, "&")
|
|
|
|
|
|
.replace(/</g, "<")
|
|
|
|
|
|
.replace(/>/g, ">")
|
|
|
|
|
|
.replace(/"/g, """)
|
|
|
|
|
|
.replace(/'/g, "'")
|
|
|
|
|
|
}
|
|
|
|
|
|
|
feat(diagram-engine): flex knobs (grow/align/pad) + inline rich-text labels
The expressiveness gap between engine output and hand-written XML came down
to two missing capabilities, both generic:
- Block layout inside a box: nested containers already existed, but there was
no way to split space by weight, pin a child to an edge, or tighten padding.
Added grow (flex-grow over the parent's leftover flow-axis space, TeX's
glue), align (start/center/end/stretch on the cross axis) and pad
(per-group interior padding). All three round-trip via dai_grow/dai_align/
dai_pad markers.
- Inline rich text: labels already render HTML (html=1 on every style, esc()
entities decode back), but the measure pass counted markup as text. The
visibleText() strip makes autoBoxSize measure what draw.io draws: <br> is a
line, other inline tags are invisible.
Graphviz (HTML-like table labels), D2 (grid containers + markdown-in-shape)
and TeX (box+glue) converged on exactly this design: nested boxes for block
structure, proportional glue, a small inline set for text — never full HTML.
Prompts teach the composition with a comparison-card recipe; verified by
rebuilding the CoT poster end to end in the real editor.
2026-08-09 20:55:12 +09:00
|
|
|
|
// NOTE ON RICH TEXT: a label may carry inline HTML — <b>, <i>, <font color>, <br>,
|
|
|
|
|
|
// <span> — and needs no special handling here. `esc()` writes it into the value
|
|
|
|
|
|
// attribute as entities, the XML parser decodes them back, and because every style
|
|
|
|
|
|
// carries `html=1` draw.io renders the tags. That is exactly how hand-written rich
|
|
|
|
|
|
// labels have always worked; the editor sanitises HTML labels itself. The one place
|
|
|
|
|
|
// tags DO need handling is the measure pass (layout.ts autoBoxSize), which must not
|
|
|
|
|
|
// count markup as text.
|
|
|
|
|
|
|
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
|
|
|
|
/** Resolve a catalog name to a style. Injected so the engine does not own the catalog. */
|
|
|
|
|
|
export type StyleResolver = (
|
|
|
|
|
|
name: string,
|
|
|
|
|
|
kind: "icon" | "group",
|
|
|
|
|
|
) => string | null
|
|
|
|
|
|
|
|
|
|
|
|
const FALLBACK_BOX =
|
|
|
|
|
|
"rounded=0;whiteSpace=wrap;html=1;fillColor=#FFFFFF;strokeColor=#5A6B7B;fontColor=#1A1A1A;fontSize=11;verticalAlign=middle;"
|
feat(diagram-engine): label avoidance, paired opposite edges, semantic group colours
A git-workflow flowchart rendered with no overlaps but read poorly. Three
distinct causes, each fixed and measured:
1. Edge labels sat on boxes and on each other (4 collisions on the
reported diagram; 280 across 250 generated flowcharts). The router
keeps LINES off the boxes but a label renders at its edge's midpoint,
which on a long edge is beside exactly the things the line was routed
around. placeLabels slides each label along its own edge to a clear
spot — longest edges first, midpoint-outward tries — written as the
geometry's relative x, which draw.io natively supports. Corpus: 280
label collisions -> 8.
2. A->B and B->A were routed independently, so "git add" ran straight
while "git reset" wandered through a different corridor with a kink.
Opposite edges that agree on axis now get two absolute parallel tracks
in the strip where the two boxes overlap, a constant 24px apart,
converted back to port fractions. Zero crossing regressions.
3. All boxes rendered the same white, because the render layer's
fill/stroke support was never reachable: neither add_box's schema nor
draw_graph's nodes exposed it. Rather than exposing raw hex (the model
picks mismatched saturations, differently every time), nodes take a
semantic group name and the engine maps groups to a fixed palette of
six paired fill/strokes in order of first appearance. The model names
the zones - remote vs local vs temp - and never touches a colour.
532 unit tests pass; the 5 diagram e2e tests pass in a real browser.
2026-08-09 18:59:34 +09:00
|
|
|
|
|
feat(diagram-engine): design tokens + role/group composition, engine-wide theming
Paper-summary posters previously required hand-written XML: every engine
box rendered identically (white, 11px), so anything whose meaning lives
in visual hierarchy came out flat. This makes presentation a first-class,
generalised part of the declaration - not a poster feature.
Structure/presentation separation, the same split HTML and CSS settled on:
- ROLE says what a node IS: banner, heading, body, callout, good, bad,
metric, muted. Maps to a type scale and an emphasis (filled / tinted /
outlined / ghost), never to a colour.
- GROUP says which semantic zone a node belongs to. Each distinct group
name gets one hue ramp (tint / base / dark), assigned in document
order. Promoted from a draw_graph-only field to BoxNode and GroupNode,
round-tripped via dai_group.
- themedStyle(role, hue, kind) composes the two by rule - there is no
per-combination table to extend, so a new diagram kind gets full
theming by tagging nodes. The model never sees a hex value.
A heading container plus a group yields the tinted section panel with a
dark title; a grouped body box takes its zone's tint; verdict roles stay
green/red regardless of zone; the banner is the page's one dark field.
Also fixed, found while building the acceptance poster:
- autoBoxSize only counted explicit newlines, so a long single-line label
wrapped to six lines in draw.io but got a one-line-tall box, and the
text overflowed the cell.
- Marker stamping appended without replacing, so every render of a
recovered style grew it by one duplicate dai_* token per key -
unnoticed because draw.io resolves duplicates last-wins. dai_* keys
are now replaced in place; mxGraph keys still append, because
last-wins is load-bearing for container=1 normalisation.
- Banner/heading/metric roles stretch across their container's cross
axis, the way a masthead spans its page.
- Prompt: a poster's banner IS its title (no set_title alongside), and
sections get their colour by naming groups.
537 unit tests, 250-flowchart corpus still zero crossing arrows, 5 e2e
tests in a real browser. Verified visually: the Transformer-paper poster
renders with a navy masthead, three hue-coded section panels, metric,
verdict and callout boxes - all engine-computed geometry.
2026-08-09 19:48:01 +09:00
|
|
|
|
/** fill/stroke for the n-th distinct group — the theme's hue ramp, tint and base steps. */
|
feat(diagram-engine): label avoidance, paired opposite edges, semantic group colours
A git-workflow flowchart rendered with no overlaps but read poorly. Three
distinct causes, each fixed and measured:
1. Edge labels sat on boxes and on each other (4 collisions on the
reported diagram; 280 across 250 generated flowcharts). The router
keeps LINES off the boxes but a label renders at its edge's midpoint,
which on a long edge is beside exactly the things the line was routed
around. placeLabels slides each label along its own edge to a clear
spot — longest edges first, midpoint-outward tries — written as the
geometry's relative x, which draw.io natively supports. Corpus: 280
label collisions -> 8.
2. A->B and B->A were routed independently, so "git add" ran straight
while "git reset" wandered through a different corridor with a kink.
Opposite edges that agree on axis now get two absolute parallel tracks
in the strip where the two boxes overlap, a constant 24px apart,
converted back to port fractions. Zero crossing regressions.
3. All boxes rendered the same white, because the render layer's
fill/stroke support was never reachable: neither add_box's schema nor
draw_graph's nodes exposed it. Rather than exposing raw hex (the model
picks mismatched saturations, differently every time), nodes take a
semantic group name and the engine maps groups to a fixed palette of
six paired fill/strokes in order of first appearance. The model names
the zones - remote vs local vs temp - and never touches a colour.
532 unit tests pass; the 5 diagram e2e tests pass in a real browser.
2026-08-09 18:59:34 +09:00
|
|
|
|
export function groupColour(index: number): { fill: string; stroke: string } {
|
feat(diagram-engine): design tokens + role/group composition, engine-wide theming
Paper-summary posters previously required hand-written XML: every engine
box rendered identically (white, 11px), so anything whose meaning lives
in visual hierarchy came out flat. This makes presentation a first-class,
generalised part of the declaration - not a poster feature.
Structure/presentation separation, the same split HTML and CSS settled on:
- ROLE says what a node IS: banner, heading, body, callout, good, bad,
metric, muted. Maps to a type scale and an emphasis (filled / tinted /
outlined / ghost), never to a colour.
- GROUP says which semantic zone a node belongs to. Each distinct group
name gets one hue ramp (tint / base / dark), assigned in document
order. Promoted from a draw_graph-only field to BoxNode and GroupNode,
round-tripped via dai_group.
- themedStyle(role, hue, kind) composes the two by rule - there is no
per-combination table to extend, so a new diagram kind gets full
theming by tagging nodes. The model never sees a hex value.
A heading container plus a group yields the tinted section panel with a
dark title; a grouped body box takes its zone's tint; verdict roles stay
green/red regardless of zone; the banner is the page's one dark field.
Also fixed, found while building the acceptance poster:
- autoBoxSize only counted explicit newlines, so a long single-line label
wrapped to six lines in draw.io but got a one-line-tall box, and the
text overflowed the cell.
- Marker stamping appended without replacing, so every render of a
recovered style grew it by one duplicate dai_* token per key -
unnoticed because draw.io resolves duplicates last-wins. dai_* keys
are now replaced in place; mxGraph keys still append, because
last-wins is load-bearing for container=1 normalisation.
- Banner/heading/metric roles stretch across their container's cross
axis, the way a masthead spans its page.
- Prompt: a poster's banner IS its title (no set_title alongside), and
sections get their colour by naming groups.
537 unit tests, 250-flowchart corpus still zero crossing arrows, 5 e2e
tests in a real browser. Verified visually: the Transformer-paper poster
renders with a navy masthead, three hue-coded section panels, metric,
verdict and callout boxes - all engine-computed geometry.
2026-08-09 19:48:01 +09:00
|
|
|
|
const h = hueOf(index)
|
|
|
|
|
|
return { fill: h.tint, stroke: h.base }
|
feat(diagram-engine): label avoidance, paired opposite edges, semantic group colours
A git-workflow flowchart rendered with no overlaps but read poorly. Three
distinct causes, each fixed and measured:
1. Edge labels sat on boxes and on each other (4 collisions on the
reported diagram; 280 across 250 generated flowcharts). The router
keeps LINES off the boxes but a label renders at its edge's midpoint,
which on a long edge is beside exactly the things the line was routed
around. placeLabels slides each label along its own edge to a clear
spot — longest edges first, midpoint-outward tries — written as the
geometry's relative x, which draw.io natively supports. Corpus: 280
label collisions -> 8.
2. A->B and B->A were routed independently, so "git add" ran straight
while "git reset" wandered through a different corridor with a kink.
Opposite edges that agree on axis now get two absolute parallel tracks
in the strip where the two boxes overlap, a constant 24px apart,
converted back to port fractions. Zero crossing regressions.
3. All boxes rendered the same white, because the render layer's
fill/stroke support was never reachable: neither add_box's schema nor
draw_graph's nodes exposed it. Rather than exposing raw hex (the model
picks mismatched saturations, differently every time), nodes take a
semantic group name and the engine maps groups to a fixed palette of
six paired fill/strokes in order of first appearance. The model names
the zones - remote vs local vs temp - and never touches a colour.
532 unit tests pass; the 5 diagram e2e tests pass in a real browser.
2026-08-09 18:59:34 +09:00
|
|
|
|
}
|
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 FALLBACK_FRAME =
|
|
|
|
|
|
"rounded=0;whiteSpace=wrap;html=1;fillColor=#FFFFFF;strokeColor=#999999;fontColor=#1A1A1A;fontSize=12;fontStyle=1;verticalAlign=top;align=left;spacingLeft=8;spacingTop=4;"
|
|
|
|
|
|
const TITLE_STYLE =
|
|
|
|
|
|
"text;html=1;align=center;fontStyle=1;fontSize=14;fontColor=light-dark(#232F3E,#E8E8E8);"
|
|
|
|
|
|
const EDGE_STYLE =
|
|
|
|
|
|
"edgeStyle=orthogonalEdgeStyle;html=1;rounded=0;jettySize=auto;orthogonalLoop=1;fontSize=10;fontColor=light-dark(#1B2733,#CFE0F0);strokeColor=light-dark(#1A1A1A,#E0E0E0);strokeWidth=1;"
|
|
|
|
|
|
|
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
|
|
|
|
// ---- swimlane pool chrome ----
|
|
|
|
|
|
|
|
|
|
|
|
/** Hairline between lane bands: present, but quieter than the shapes sitting on it. */
|
|
|
|
|
|
const POOL_HAIR = "#D8E0E8"
|
|
|
|
|
|
/** Alternating band tint, so a reader can follow one lane across a wide diagram. */
|
|
|
|
|
|
const POOL_BAND_ALT = "#F5F8FB"
|
|
|
|
|
|
/** Lane-name column, slightly darker than the bands so it reads as a header. */
|
|
|
|
|
|
const POOL_LABEL_FILL = "#EEF2F7"
|
|
|
|
|
|
const POOL_FILL = "#FFFFFF"
|
|
|
|
|
|
const POOL_STROKE = "#5A6B7B"
|
|
|
|
|
|
|
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
|
|
|
|
/** A pool's outer frame: a plain titled rectangle, since the bands supply the structure. */
|
|
|
|
|
|
const POOL_FRAME_STYLE =
|
|
|
|
|
|
`rounded=0;whiteSpace=wrap;html=1;fillColor=${POOL_FILL};strokeColor=${POOL_STROKE};` +
|
|
|
|
|
|
`fontColor=#1A1A1A;fontSize=13;fontStyle=1;verticalAlign=top;align=left;spacingLeft=8;spacingTop=4;`
|
|
|
|
|
|
|
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
|
|
|
|
/**
|
|
|
|
|
|
* A participant head in a sequence diagram: the box at the top of a lifeline.
|
|
|
|
|
|
*
|
|
|
|
|
|
* `umlLifeline` is a core mxGraph shape whose cell covers the head AND the line below it,
|
|
|
|
|
|
* with `size` giving the head's height. Emitting head and line as one cell is what makes
|
|
|
|
|
|
* draw.io keep them together when the user drags the participant sideways.
|
|
|
|
|
|
*/
|
|
|
|
|
|
const LIFELINE_STYLE =
|
|
|
|
|
|
"shape=umlLifeline;perimeter=lifelinePerimeter;whiteSpace=wrap;html=1;container=0;collapsible=0;recursiveResize=0;outlineConnect=0;fillColor=#FFFFFF;strokeColor=#5A6B7B;fontColor=#1A1A1A;fontSize=11;fontStyle=1;"
|
|
|
|
|
|
|
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
|
|
|
|
export interface RenderOptions {
|
|
|
|
|
|
/** Resolves a catalog icon/group name to its verbatim draw.io style. */
|
|
|
|
|
|
resolveStyle?: StyleResolver
|
|
|
|
|
|
/** Diagram-wide glyph size. */
|
|
|
|
|
|
iconSize?: number
|
|
|
|
|
|
/** Gap between top-level roots. */
|
|
|
|
|
|
rootGap?: number
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* Build the style for one node.
|
|
|
|
|
|
*
|
|
|
|
|
|
* A style recovered from XML is preferred over re-resolving the catalog name: it is
|
|
|
|
|
|
* what is already on the canvas, including any colour the user changed by hand. We only
|
|
|
|
|
|
* re-stamp the markers on top, so layout parameters stay current.
|
|
|
|
|
|
*/
|
feat(diagram-engine): design tokens + role/group composition, engine-wide theming
Paper-summary posters previously required hand-written XML: every engine
box rendered identically (white, 11px), so anything whose meaning lives
in visual hierarchy came out flat. This makes presentation a first-class,
generalised part of the declaration - not a poster feature.
Structure/presentation separation, the same split HTML and CSS settled on:
- ROLE says what a node IS: banner, heading, body, callout, good, bad,
metric, muted. Maps to a type scale and an emphasis (filled / tinted /
outlined / ghost), never to a colour.
- GROUP says which semantic zone a node belongs to. Each distinct group
name gets one hue ramp (tint / base / dark), assigned in document
order. Promoted from a draw_graph-only field to BoxNode and GroupNode,
round-tripped via dai_group.
- themedStyle(role, hue, kind) composes the two by rule - there is no
per-combination table to extend, so a new diagram kind gets full
theming by tagging nodes. The model never sees a hex value.
A heading container plus a group yields the tinted section panel with a
dark title; a grouped body box takes its zone's tint; verdict roles stay
green/red regardless of zone; the banner is the page's one dark field.
Also fixed, found while building the acceptance poster:
- autoBoxSize only counted explicit newlines, so a long single-line label
wrapped to six lines in draw.io but got a one-line-tall box, and the
text overflowed the cell.
- Marker stamping appended without replacing, so every render of a
recovered style grew it by one duplicate dai_* token per key -
unnoticed because draw.io resolves duplicates last-wins. dai_* keys
are now replaced in place; mxGraph keys still append, because
last-wins is load-bearing for container=1 normalisation.
- Banner/heading/metric roles stretch across their container's cross
axis, the way a masthead spans its page.
- Prompt: a poster's banner IS its title (no set_title alongside), and
sections get their colour by naming groups.
537 unit tests, 250-flowchart corpus still zero crossing arrows, 5 e2e
tests in a real browser. Verified visually: the Transformer-paper poster
renders with a navy masthead, three hue-coded section panels, metric,
verdict and callout boxes - all engine-computed geometry.
2026-08-09 19:48:01 +09:00
|
|
|
|
/** The hue ramp for a node's group, or the neutral ramp. Assigned in document order. */
|
|
|
|
|
|
export type HueResolver = (
|
|
|
|
|
|
group: string | undefined,
|
|
|
|
|
|
) => ReturnType<typeof hueOf>
|
|
|
|
|
|
|
|
|
|
|
|
function styleFor(
|
|
|
|
|
|
n: DiagramNode,
|
|
|
|
|
|
resolve: StyleResolver | undefined,
|
|
|
|
|
|
hue: HueResolver = () => NEUTRAL,
|
|
|
|
|
|
): 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
|
|
|
|
if (n.kind === "title") return TITLE_STYLE
|
|
|
|
|
|
|
|
|
|
|
|
if (n.kind === "icon") {
|
|
|
|
|
|
const base =
|
|
|
|
|
|
n.style ??
|
|
|
|
|
|
(n.name ? resolve?.(n.name, "icon") : null) ??
|
|
|
|
|
|
FALLBACK_BOX
|
|
|
|
|
|
return stampLeaf(base, "icon", { name: n.name })
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if (n.kind === "box") {
|
feat(diagram-engine): open shape vocabulary — catalog + pass-through, structured style merge
The expressiveness gap traced to vocabulary: draw.io has hundreds of shapes,
the declarative layer allowed six. Opening it, with the failure modes from
design review handled:
- shapes.ts: a ~20-entry curated catalog (full style fragment incl. the
matching perimeter — required or edges connect to the bounding box; a
text-scale factor verified in the real editor — the same sentence overflows
a 1.0x rhombus and fits a 1.5x one; labelOutside+glyph for umlActor-style
figures). Any other token passes through verbatim: draw.io degrades unknown
shapes to rectangles safely (verified). Injection-capable tokens (;/=) are
rejected outright.
- Pass-through emits a WARNING with a nearest-catalog hint (bounded edit
distance), so a typo'd 'cyclinder' is a one-turn fix instead of a silently
rectangular node forever. set_shape/set_role/set_group operations make the
fix possible without remove+re-add (which would drop links).
- mergeStyle(): style fragments merge per-key (later wins, bare shape classes
displace each other) instead of string concatenation. This is what makes
shape and theme composable by rule — shape owns geometry keys, theme owns
colour/type keys, and an overlap resolves by order instead of emitting
contradictory duplicates.
- dai_shape marker carries the declared token through the round trip:
appearance-based reverse mapping cannot distinguish aliases (diamond vs
decision) or a rotated queue from a cylinder.
- dai_auto marker separates engine-measured size from user-fixed size: parsed
boxes no longer freeze the first layout's numbers, so changing a label
re-measures. Pinned nodes keep everything, as before.
- draw_graph's closed shape enum opened to match (first instance of the
schema-drift problem the review predicted).
14 new tests: merge ownership, injection rejection, near-match hints,
alias-preserving round trip, re-measure on label change, mxgraph.* tokens
staying boxes with role/group intact. 556 unit tests green; acceptance
diagram (person/hexagon/cylinder/queue/cloud/decision/callout) verified in
the real editor.
2026-08-09 21:56:09 +09:00
|
|
|
|
let base: string
|
|
|
|
|
|
if (n.style) {
|
|
|
|
|
|
base = n.style
|
|
|
|
|
|
} else {
|
|
|
|
|
|
// Structured merge, ownership by fragment order: the fallback's neutral
|
|
|
|
|
|
// look, the shape's geometry keys, the theme's colour/type keys, explicit
|
|
|
|
|
|
// colours last. Each key ends up in the style exactly once — a theme that
|
|
|
|
|
|
// says rounded=1 cannot leave a contradictory duplicate on a rhombus.
|
|
|
|
|
|
const shape = n.shape ? resolveShape(n.shape) : null
|
|
|
|
|
|
base = mergeStyle(
|
|
|
|
|
|
FALLBACK_BOX,
|
|
|
|
|
|
shape?.spec.style,
|
|
|
|
|
|
n.role || n.group
|
|
|
|
|
|
? themedStyle(n.role ?? "body", hue(n.group), "leaf")
|
|
|
|
|
|
: undefined,
|
|
|
|
|
|
n.fill ? `fillColor=${n.fill};` : undefined,
|
|
|
|
|
|
n.stroke ? `strokeColor=${n.stroke};` : undefined,
|
|
|
|
|
|
n.bold ? "fontStyle=1;" : undefined,
|
|
|
|
|
|
)
|
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
|
|
|
|
}
|
feat(diagram-engine): design tokens + role/group composition, engine-wide theming
Paper-summary posters previously required hand-written XML: every engine
box rendered identically (white, 11px), so anything whose meaning lives
in visual hierarchy came out flat. This makes presentation a first-class,
generalised part of the declaration - not a poster feature.
Structure/presentation separation, the same split HTML and CSS settled on:
- ROLE says what a node IS: banner, heading, body, callout, good, bad,
metric, muted. Maps to a type scale and an emphasis (filled / tinted /
outlined / ghost), never to a colour.
- GROUP says which semantic zone a node belongs to. Each distinct group
name gets one hue ramp (tint / base / dark), assigned in document
order. Promoted from a draw_graph-only field to BoxNode and GroupNode,
round-tripped via dai_group.
- themedStyle(role, hue, kind) composes the two by rule - there is no
per-combination table to extend, so a new diagram kind gets full
theming by tagging nodes. The model never sees a hex value.
A heading container plus a group yields the tinted section panel with a
dark title; a grouped body box takes its zone's tint; verdict roles stay
green/red regardless of zone; the banner is the page's one dark field.
Also fixed, found while building the acceptance poster:
- autoBoxSize only counted explicit newlines, so a long single-line label
wrapped to six lines in draw.io but got a one-line-tall box, and the
text overflowed the cell.
- Marker stamping appended without replacing, so every render of a
recovered style grew it by one duplicate dai_* token per key -
unnoticed because draw.io resolves duplicates last-wins. dai_* keys
are now replaced in place; mxGraph keys still append, because
last-wins is load-bearing for container=1 normalisation.
- Banner/heading/metric roles stretch across their container's cross
axis, the way a masthead spans its page.
- Prompt: a poster's banner IS its title (no set_title alongside), and
sections get their colour by naming groups.
537 unit tests, 250-flowchart corpus still zero crossing arrows, 5 e2e
tests in a real browser. Verified visually: the Transformer-paper poster
renders with a navy masthead, three hue-coded section panels, metric,
verdict and callout boxes - all engine-computed geometry.
2026-08-09 19:48:01 +09:00
|
|
|
|
let stamped = stampLeaf(base, "box")
|
feat(diagram-engine): open shape vocabulary — catalog + pass-through, structured style merge
The expressiveness gap traced to vocabulary: draw.io has hundreds of shapes,
the declarative layer allowed six. Opening it, with the failure modes from
design review handled:
- shapes.ts: a ~20-entry curated catalog (full style fragment incl. the
matching perimeter — required or edges connect to the bounding box; a
text-scale factor verified in the real editor — the same sentence overflows
a 1.0x rhombus and fits a 1.5x one; labelOutside+glyph for umlActor-style
figures). Any other token passes through verbatim: draw.io degrades unknown
shapes to rectangles safely (verified). Injection-capable tokens (;/=) are
rejected outright.
- Pass-through emits a WARNING with a nearest-catalog hint (bounded edit
distance), so a typo'd 'cyclinder' is a one-turn fix instead of a silently
rectangular node forever. set_shape/set_role/set_group operations make the
fix possible without remove+re-add (which would drop links).
- mergeStyle(): style fragments merge per-key (later wins, bare shape classes
displace each other) instead of string concatenation. This is what makes
shape and theme composable by rule — shape owns geometry keys, theme owns
colour/type keys, and an overlap resolves by order instead of emitting
contradictory duplicates.
- dai_shape marker carries the declared token through the round trip:
appearance-based reverse mapping cannot distinguish aliases (diamond vs
decision) or a rotated queue from a cylinder.
- dai_auto marker separates engine-measured size from user-fixed size: parsed
boxes no longer freeze the first layout's numbers, so changing a label
re-measures. Pinned nodes keep everything, as before.
- draw_graph's closed shape enum opened to match (first instance of the
schema-drift problem the review predicted).
14 new tests: merge ownership, injection rejection, near-match hints,
alias-preserving round trip, re-measure on label change, mxgraph.* tokens
staying boxes with role/group intact. 556 unit tests green; acceptance
diagram (person/hexagon/cylinder/queue/cloud/decision/callout) verified in
the real editor.
2026-08-09 21:56:09 +09:00
|
|
|
|
if (n.shape && n.shape !== "box") stamped = stampShape(stamped, n.shape)
|
feat(diagram-engine): design tokens + role/group composition, engine-wide theming
Paper-summary posters previously required hand-written XML: every engine
box rendered identically (white, 11px), so anything whose meaning lives
in visual hierarchy came out flat. This makes presentation a first-class,
generalised part of the declaration - not a poster feature.
Structure/presentation separation, the same split HTML and CSS settled on:
- ROLE says what a node IS: banner, heading, body, callout, good, bad,
metric, muted. Maps to a type scale and an emphasis (filled / tinted /
outlined / ghost), never to a colour.
- GROUP says which semantic zone a node belongs to. Each distinct group
name gets one hue ramp (tint / base / dark), assigned in document
order. Promoted from a draw_graph-only field to BoxNode and GroupNode,
round-tripped via dai_group.
- themedStyle(role, hue, kind) composes the two by rule - there is no
per-combination table to extend, so a new diagram kind gets full
theming by tagging nodes. The model never sees a hex value.
A heading container plus a group yields the tinted section panel with a
dark title; a grouped body box takes its zone's tint; verdict roles stay
green/red regardless of zone; the banner is the page's one dark field.
Also fixed, found while building the acceptance poster:
- autoBoxSize only counted explicit newlines, so a long single-line label
wrapped to six lines in draw.io but got a one-line-tall box, and the
text overflowed the cell.
- Marker stamping appended without replacing, so every render of a
recovered style grew it by one duplicate dai_* token per key -
unnoticed because draw.io resolves duplicates last-wins. dai_* keys
are now replaced in place; mxGraph keys still append, because
last-wins is load-bearing for container=1 normalisation.
- Banner/heading/metric roles stretch across their container's cross
axis, the way a masthead spans its page.
- Prompt: a poster's banner IS its title (no set_title alongside), and
sections get their colour by naming groups.
537 unit tests, 250-flowchart corpus still zero crossing arrows, 5 e2e
tests in a real browser. Verified visually: the Transformer-paper poster
renders with a navy masthead, three hue-coded section panels, metric,
verdict and callout boxes - all engine-computed geometry.
2026-08-09 19:48:01 +09:00
|
|
|
|
if (n.role && n.role !== "body") stamped = stampRole(stamped, n.role)
|
|
|
|
|
|
if (n.group) stamped = stampGroup(stamped, n.group)
|
feat(diagram-engine): flex knobs (grow/align/pad) + inline rich-text labels
The expressiveness gap between engine output and hand-written XML came down
to two missing capabilities, both generic:
- Block layout inside a box: nested containers already existed, but there was
no way to split space by weight, pin a child to an edge, or tighten padding.
Added grow (flex-grow over the parent's leftover flow-axis space, TeX's
glue), align (start/center/end/stretch on the cross axis) and pad
(per-group interior padding). All three round-trip via dai_grow/dai_align/
dai_pad markers.
- Inline rich text: labels already render HTML (html=1 on every style, esc()
entities decode back), but the measure pass counted markup as text. The
visibleText() strip makes autoBoxSize measure what draw.io draws: <br> is a
line, other inline tags are invisible.
Graphviz (HTML-like table labels), D2 (grid containers + markdown-in-shape)
and TeX (box+glue) converged on exactly this design: nested boxes for block
structure, proportional glue, a small inline set for text — never full HTML.
Prompts teach the composition with a comparison-card recipe; verified by
rebuilding the CoT poster end to end in the real editor.
2026-08-09 20:55:12 +09:00
|
|
|
|
stamped = stampFlex(stamped, { grow: n.grow, align: n.align })
|
feat(diagram-engine): open shape vocabulary — catalog + pass-through, structured style merge
The expressiveness gap traced to vocabulary: draw.io has hundreds of shapes,
the declarative layer allowed six. Opening it, with the failure modes from
design review handled:
- shapes.ts: a ~20-entry curated catalog (full style fragment incl. the
matching perimeter — required or edges connect to the bounding box; a
text-scale factor verified in the real editor — the same sentence overflows
a 1.0x rhombus and fits a 1.5x one; labelOutside+glyph for umlActor-style
figures). Any other token passes through verbatim: draw.io degrades unknown
shapes to rectangles safely (verified). Injection-capable tokens (;/=) are
rejected outright.
- Pass-through emits a WARNING with a nearest-catalog hint (bounded edit
distance), so a typo'd 'cyclinder' is a one-turn fix instead of a silently
rectangular node forever. set_shape/set_role/set_group operations make the
fix possible without remove+re-add (which would drop links).
- mergeStyle(): style fragments merge per-key (later wins, bare shape classes
displace each other) instead of string concatenation. This is what makes
shape and theme composable by rule — shape owns geometry keys, theme owns
colour/type keys, and an overlap resolves by order instead of emitting
contradictory duplicates.
- dai_shape marker carries the declared token through the round trip:
appearance-based reverse mapping cannot distinguish aliases (diamond vs
decision) or a rotated queue from a cylinder.
- dai_auto marker separates engine-measured size from user-fixed size: parsed
boxes no longer freeze the first layout's numbers, so changing a label
re-measures. Pinned nodes keep everything, as before.
- draw_graph's closed shape enum opened to match (first instance of the
schema-drift problem the review predicted).
14 new tests: merge ownership, injection rejection, near-match hints,
alias-preserving round trip, re-measure on label change, mxgraph.* tokens
staying boxes with role/group intact. 556 unit tests green; acceptance
diagram (person/hexagon/cylinder/queue/cloud/decision/callout) verified in
the real editor.
2026-08-09 21:56:09 +09:00
|
|
|
|
// Engine-measured (no explicit w/h): mark it, so the parser re-measures next
|
|
|
|
|
|
// time instead of freezing this layout's numbers as a fixed size.
|
|
|
|
|
|
if (n.w == null && n.h == null) stamped = stampAuto(stamped)
|
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 n.cell ? stampCell(stamped, n.cell) : stamped
|
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
|
|
|
|
}
|
|
|
|
|
|
|
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
|
|
|
|
if (n.kind === "pool") {
|
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
|
|
|
|
return stampPool(n.style ?? POOL_FRAME_STYLE, {
|
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
|
|
|
|
lanes: n.lanes,
|
|
|
|
|
|
phases: n.phases,
|
|
|
|
|
|
orientation: n.orientation,
|
|
|
|
|
|
gap: n.gap,
|
|
|
|
|
|
})
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if (n.kind === "sequence" || n.kind === "radial") {
|
|
|
|
|
|
// Both draw their own contents — lifelines, branch arrows — so the container itself
|
|
|
|
|
|
// is a frame only when the model labelled it, and invisible otherwise.
|
|
|
|
|
|
const base =
|
|
|
|
|
|
n.style ?? (n.label ? FALLBACK_FRAME : INVISIBLE_FRAME_STYLE)
|
|
|
|
|
|
return n.kind === "sequence"
|
|
|
|
|
|
? stampSequence(base, { gap: n.gap, step: n.step })
|
|
|
|
|
|
: stampRadial(base, { spread: n.spread, gap: n.gap })
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// group or grid
|
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 fromCatalog = n.gname ? resolve?.(n.gname, "group") : null
|
|
|
|
|
|
// An unlabelled frame with no stencil is a layout-only wrapper: emit a real cell so
|
|
|
|
|
|
// the structure survives a round-trip, but draw nothing. This replaces the
|
|
|
|
|
|
// reference project's "phantom", which emitted no cell and therefore lost the
|
|
|
|
|
|
// wrapper's direction and grouping on the way back.
|
feat(diagram-engine): design tokens + role/group composition, engine-wide theming
Paper-summary posters previously required hand-written XML: every engine
box rendered identically (white, 11px), so anything whose meaning lives
in visual hierarchy came out flat. This makes presentation a first-class,
generalised part of the declaration - not a poster feature.
Structure/presentation separation, the same split HTML and CSS settled on:
- ROLE says what a node IS: banner, heading, body, callout, good, bad,
metric, muted. Maps to a type scale and an emphasis (filled / tinted /
outlined / ghost), never to a colour.
- GROUP says which semantic zone a node belongs to. Each distinct group
name gets one hue ramp (tint / base / dark), assigned in document
order. Promoted from a draw_graph-only field to BoxNode and GroupNode,
round-tripped via dai_group.
- themedStyle(role, hue, kind) composes the two by rule - there is no
per-combination table to extend, so a new diagram kind gets full
theming by tagging nodes. The model never sees a hex value.
A heading container plus a group yields the tinted section panel with a
dark title; a grouped body box takes its zone's tint; verdict roles stay
green/red regardless of zone; the banner is the page's one dark field.
Also fixed, found while building the acceptance poster:
- autoBoxSize only counted explicit newlines, so a long single-line label
wrapped to six lines in draw.io but got a one-line-tall box, and the
text overflowed the cell.
- Marker stamping appended without replacing, so every render of a
recovered style grew it by one duplicate dai_* token per key -
unnoticed because draw.io resolves duplicates last-wins. dai_* keys
are now replaced in place; mxGraph keys still append, because
last-wins is load-bearing for container=1 normalisation.
- Banner/heading/metric roles stretch across their container's cross
axis, the way a masthead spans its page.
- Prompt: a poster's banner IS its title (no set_title alongside), and
sections get their colour by naming groups.
537 unit tests, 250-flowchart corpus still zero crossing arrows, 5 e2e
tests in a real browser. Verified visually: the Transformer-paper poster
renders with a navy masthead, three hue-coded section panels, metric,
verdict and callout boxes - all engine-computed geometry.
2026-08-09 19:48:01 +09:00
|
|
|
|
const groupRole = n.kind === "group" ? n.role : undefined
|
|
|
|
|
|
const zone = n.kind === "group" ? n.group : undefined
|
|
|
|
|
|
const invisible =
|
|
|
|
|
|
!n.gname && !n.label && !n.fill && !n.stroke && !groupRole && !zone
|
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
|
|
|
|
let base = n.style ?? fromCatalog ?? FALLBACK_FRAME
|
|
|
|
|
|
if (!n.style && !fromCatalog) {
|
feat(diagram-engine): design tokens + role/group composition, engine-wide theming
Paper-summary posters previously required hand-written XML: every engine
box rendered identically (white, 11px), so anything whose meaning lives
in visual hierarchy came out flat. This makes presentation a first-class,
generalised part of the declaration - not a poster feature.
Structure/presentation separation, the same split HTML and CSS settled on:
- ROLE says what a node IS: banner, heading, body, callout, good, bad,
metric, muted. Maps to a type scale and an emphasis (filled / tinted /
outlined / ghost), never to a colour.
- GROUP says which semantic zone a node belongs to. Each distinct group
name gets one hue ramp (tint / base / dark), assigned in document
order. Promoted from a draw_graph-only field to BoxNode and GroupNode,
round-tripped via dai_group.
- themedStyle(role, hue, kind) composes the two by rule - there is no
per-combination table to extend, so a new diagram kind gets full
theming by tagging nodes. The model never sees a hex value.
A heading container plus a group yields the tinted section panel with a
dark title; a grouped body box takes its zone's tint; verdict roles stay
green/red regardless of zone; the banner is the page's one dark field.
Also fixed, found while building the acceptance poster:
- autoBoxSize only counted explicit newlines, so a long single-line label
wrapped to six lines in draw.io but got a one-line-tall box, and the
text overflowed the cell.
- Marker stamping appended without replacing, so every render of a
recovered style grew it by one duplicate dai_* token per key -
unnoticed because draw.io resolves duplicates last-wins. dai_* keys
are now replaced in place; mxGraph keys still append, because
last-wins is load-bearing for container=1 normalisation.
- Banner/heading/metric roles stretch across their container's cross
axis, the way a masthead spans its page.
- Prompt: a poster's banner IS its title (no set_title alongside), and
sections get their colour by naming groups.
537 unit tests, 250-flowchart corpus still zero crossing arrows, 5 e2e
tests in a real browser. Verified visually: the Transformer-paper poster
renders with a navy masthead, three hue-coded section panels, metric,
verdict and callout boxes - all engine-computed geometry.
2026-08-09 19:48:01 +09:00
|
|
|
|
if (groupRole || zone)
|
|
|
|
|
|
base += themedStyle(groupRole ?? "heading", hue(zone), "container")
|
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 (n.fill) base += `fillColor=${n.fill};`
|
|
|
|
|
|
if (n.stroke) base += `strokeColor=${n.stroke};`
|
|
|
|
|
|
}
|
feat(diagram-engine): design tokens + role/group composition, engine-wide theming
Paper-summary posters previously required hand-written XML: every engine
box rendered identically (white, 11px), so anything whose meaning lives
in visual hierarchy came out flat. This makes presentation a first-class,
generalised part of the declaration - not a poster feature.
Structure/presentation separation, the same split HTML and CSS settled on:
- ROLE says what a node IS: banner, heading, body, callout, good, bad,
metric, muted. Maps to a type scale and an emphasis (filled / tinted /
outlined / ghost), never to a colour.
- GROUP says which semantic zone a node belongs to. Each distinct group
name gets one hue ramp (tint / base / dark), assigned in document
order. Promoted from a draw_graph-only field to BoxNode and GroupNode,
round-tripped via dai_group.
- themedStyle(role, hue, kind) composes the two by rule - there is no
per-combination table to extend, so a new diagram kind gets full
theming by tagging nodes. The model never sees a hex value.
A heading container plus a group yields the tinted section panel with a
dark title; a grouped body box takes its zone's tint; verdict roles stay
green/red regardless of zone; the banner is the page's one dark field.
Also fixed, found while building the acceptance poster:
- autoBoxSize only counted explicit newlines, so a long single-line label
wrapped to six lines in draw.io but got a one-line-tall box, and the
text overflowed the cell.
- Marker stamping appended without replacing, so every render of a
recovered style grew it by one duplicate dai_* token per key -
unnoticed because draw.io resolves duplicates last-wins. dai_* keys
are now replaced in place; mxGraph keys still append, because
last-wins is load-bearing for container=1 normalisation.
- Banner/heading/metric roles stretch across their container's cross
axis, the way a masthead spans its page.
- Prompt: a poster's banner IS its title (no set_title alongside), and
sections get their colour by naming groups.
537 unit tests, 250-flowchart corpus still zero crossing arrows, 5 e2e
tests in a real browser. Verified visually: the Transformer-paper poster
renders with a navy masthead, three hue-coded section panels, metric,
verdict and callout boxes - all engine-computed geometry.
2026-08-09 19:48:01 +09:00
|
|
|
|
if (groupRole && groupRole !== "body") base = stampRole(base, groupRole)
|
|
|
|
|
|
if (zone) base = stampGroup(base, zone)
|
feat(diagram-engine): flex knobs (grow/align/pad) + inline rich-text labels
The expressiveness gap between engine output and hand-written XML came down
to two missing capabilities, both generic:
- Block layout inside a box: nested containers already existed, but there was
no way to split space by weight, pin a child to an edge, or tighten padding.
Added grow (flex-grow over the parent's leftover flow-axis space, TeX's
glue), align (start/center/end/stretch on the cross axis) and pad
(per-group interior padding). All three round-trip via dai_grow/dai_align/
dai_pad markers.
- Inline rich text: labels already render HTML (html=1 on every style, esc()
entities decode back), but the measure pass counted markup as text. The
visibleText() strip makes autoBoxSize measure what draw.io draws: <br> is a
line, other inline tags are invisible.
Graphviz (HTML-like table labels), D2 (grid containers + markdown-in-shape)
and TeX (box+glue) converged on exactly this design: nested boxes for block
structure, proportional glue, a small inline set for text — never full HTML.
Prompts teach the composition with a comparison-card recipe; verified by
rebuilding the CoT poster end to end in the real editor.
2026-08-09 20:55:12 +09:00
|
|
|
|
if (n.kind === "group")
|
|
|
|
|
|
base = stampFlex(base, { grow: n.grow, align: n.align, pad: n.pad })
|
feat(diagram-engine): layout + XML renderer, verified end to end in draw.io
Completes the tree → coordinates → XML direction, so the model can declare nesting
and never write a coordinate or an mxCell again.
layout.ts — measure bottom-up, place top-down, the same shape as flexbox. A
container sums its children along the flow axis and adds padding, so "child spills
out of its frame" and "siblings overlap" cannot happen by construction rather than
being caught afterwards. Slack from sibling equalisation is shared between children
instead of left as dead margin, capped at one gap so a stretched frame reads as
spaced rather than sparse.
render.ts — writes the mxCells, stamping container=1 and the dai_* markers so
parse.ts can read the structure back. Edges carry no waypoints: draw.io's own router
recomputes the route on every edit, so a user who moves a node never has to re-link
an arrow. Cells the parser could not interpret are re-emitted verbatim, so a
re-layout never deletes a user's annotations.
Phantoms are gone (task #5). The reference project's layout-only wrapper emits no
cell, which makes the round-trip lossy by construction — measured on its own
build_vpc.mjs, a phantom erased a container's "col" direction for good. An
unlabelled frame here emits a real cell with fillColor/strokeColor=none instead:
invisible, but present in the XML and therefore recoverable.
Two bugs the round-trip test caught, both real:
- An icon's cell was being emitted at its measured slot size, which includes room
for the label underneath. Parsing read that width back as the glyph size, so the
icon grew on every round-trip. The cell is now the glyph square and the label
renders outside it via verticalLabelPosition, as the reference does.
- An Azure or GCP icon is an embedded base64 image whose style contains no name
anywhere, so the catalog name was unrecoverable. Added a dai_name marker.
Verified in a real browser (3 Playwright tests, not mocks): engine output renders in
draw.io; dragging a shape into a frame makes draw.io rewrite its parent and the
engine reads the new structure back; re-laying out from that structure PRESERVES the
user's move instead of undoing it, and leaves untouched nodes alone; and the
re-laid-out XML still renders.
That last point is the whole design: there is no second copy of the state, so a
manual edit is an input to the next layout rather than a conflict to reconcile.
304 unit tests + 3 e2e.
2026-08-09 11:56:08 +09:00
|
|
|
|
return stampContainer(base, {
|
|
|
|
|
|
kind: n.kind,
|
|
|
|
|
|
dir: n.kind === "grid" ? "grid" : n.dir,
|
|
|
|
|
|
gap: n.gap,
|
|
|
|
|
|
cols: n.kind === "grid" ? n.cols : undefined,
|
|
|
|
|
|
invisible,
|
|
|
|
|
|
})
|
|
|
|
|
|
}
|
|
|
|
|
|
|
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 INVISIBLE_FRAME_STYLE =
|
|
|
|
|
|
"rounded=0;whiteSpace=wrap;html=1;fillColor=none;strokeColor=none;"
|
|
|
|
|
|
|
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
|
|
|
|
/**
|
|
|
|
|
|
* One `<mxCell>` for a vertex, with geometry relative to its parent.
|
|
|
|
|
|
*
|
|
|
|
|
|
* An icon's cell is the glyph square, not the measured slot. Layout reserves a wider,
|
|
|
|
|
|
* taller slot so the label underneath has room, but the cell itself must stay square:
|
|
|
|
|
|
* the stencil scales to the cell, and `verticalLabelPosition=bottom` renders the label
|
|
|
|
|
|
* outside it. Emitting the padded slot would both stretch the glyph and — because the
|
|
|
|
|
|
* padding depends on the label length — make the size grow on every round-trip.
|
|
|
|
|
|
*/
|
feat(diagram-engine): edge router — pinned ports, obstacle avoidance, cost search
Fixes the reported problem: arrows overlapped on top of icons and ran through shapes
they had nothing to do with.
The cause was a missing layer, not a bug. render.ts emitted only source and target, so
draw.io routed every edge itself — and its router sees the two terminals' bounds and
nothing else, not where the other icons are. It therefore ran lines straight through
whatever was in between and left several edges leaving one node at the same point.
Ported from drawio-ai-kit's router (MIT, see NOTICE):
- Port de-collision. Edges leaving the same side of the same node spread along it,
ordered by where their far end sits so they do not cross on the way out. An edge
with a clean straight shot keeps the centre.
- Obstacle avoidance. Candidate shapes in order of directness — straight, a Z through
the gap, an L, a two-bend detour — each tested against every icon on the page.
- Frame placement. A frame is passable (an edge into a VPC must cross its border) but
not free: running alongside a border, or cutting through a frame that holds only one
of the two endpoints, is penalised.
- Port snapping. On a bent route, each port moves to the side its leg actually arrives
from. Without this the terminal segment can pierce the icon to reach a far-side port.
- Lane claiming. Each routed edge records the lanes it occupies so later edges avoid
them, rather than colliding and being pulled apart afterwards.
- Global nudge, three passes, reverting any move that makes a path worse.
Two things I got wrong on the way, both caught by looking at real geometry:
- The Z corridor was computed as min/max of both nodes' edges, which spans the whole
distance between them — including anything parked in between. So the lane sweep
would place the detour's middle leg on top of the very icon it was avoiding. It has
to be the gap: trailing edge of the first node to the leading edge of the other.
- The router was fed layout's slot rectangles, but an icon's cell is the glyph square
centred in a slot roughly twice as wide. Collision tests against slots both missed
real overlaps and invented false ones. Extracted cellRect() so the router and the
emitted XML cannot diverge.
Accept-or-reject was not enough on its own. When one endpoint is inside a VPC and the
other outside, EVERY path trespasses on that frame, so the strict rule always fails and
the relaxed pass took whatever it tried first — which is how a line ended up cutting
across a whole VPC. Candidates are now scored (frame offences 500, lane sharing 700,
bends 80, length 1) and the cheapest wins, so an edge that must trespass still gets the
least-bad route.
Waypoints are still written only when load-bearing — a labelled bend or a deliberate
detour — so an unobstructed edge stays drag-friendly.
455 unit tests (27 new, asserting produced geometry rather than algorithm shape).
Verified by rendering the reported diagram in the real editor: the two arrows that
overlapped on the EC2 icon now leave from different sides, and the load balancer's
fan-out leaves from three distinct points.
2026-08-09 12:46:44 +09:00
|
|
|
|
/**
|
|
|
|
|
|
* The rectangle a node actually occupies in the XML.
|
|
|
|
|
|
*
|
|
|
|
|
|
* For everything except an icon this is the slot layout measured. An icon's slot is wider
|
|
|
|
|
|
* and taller than the glyph, to leave room for the label underneath, but the cell itself
|
|
|
|
|
|
* is the glyph square centred in that slot.
|
|
|
|
|
|
*
|
|
|
|
|
|
* The router has to use this, not the slot: a slot is roughly twice the glyph's width, so
|
|
|
|
|
|
* collision tests against slots both miss real overlaps and invent false ones.
|
|
|
|
|
|
*/
|
|
|
|
|
|
export function cellRect(
|
|
|
|
|
|
n: DiagramNode,
|
|
|
|
|
|
slot: Rect,
|
|
|
|
|
|
defaultGlyph: number,
|
|
|
|
|
|
): Rect {
|
|
|
|
|
|
if (n.kind !== "icon") return slot
|
|
|
|
|
|
const glyph = n.size ?? defaultGlyph
|
|
|
|
|
|
return {
|
|
|
|
|
|
x: Math.round(slot.x + (slot.w - glyph) / 2),
|
|
|
|
|
|
y: slot.y,
|
|
|
|
|
|
w: glyph,
|
|
|
|
|
|
h: glyph,
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
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
|
|
|
|
function vertexXml(
|
|
|
|
|
|
n: DiagramNode,
|
|
|
|
|
|
rect: Rect,
|
|
|
|
|
|
parent: string,
|
|
|
|
|
|
parentRect: Rect | null,
|
|
|
|
|
|
resolve: StyleResolver | undefined,
|
|
|
|
|
|
defaultGlyph: number,
|
feat(diagram-engine): design tokens + role/group composition, engine-wide theming
Paper-summary posters previously required hand-written XML: every engine
box rendered identically (white, 11px), so anything whose meaning lives
in visual hierarchy came out flat. This makes presentation a first-class,
generalised part of the declaration - not a poster feature.
Structure/presentation separation, the same split HTML and CSS settled on:
- ROLE says what a node IS: banner, heading, body, callout, good, bad,
metric, muted. Maps to a type scale and an emphasis (filled / tinted /
outlined / ghost), never to a colour.
- GROUP says which semantic zone a node belongs to. Each distinct group
name gets one hue ramp (tint / base / dark), assigned in document
order. Promoted from a draw_graph-only field to BoxNode and GroupNode,
round-tripped via dai_group.
- themedStyle(role, hue, kind) composes the two by rule - there is no
per-combination table to extend, so a new diagram kind gets full
theming by tagging nodes. The model never sees a hex value.
A heading container plus a group yields the tinted section panel with a
dark title; a grouped body box takes its zone's tint; verdict roles stay
green/red regardless of zone; the banner is the page's one dark field.
Also fixed, found while building the acceptance poster:
- autoBoxSize only counted explicit newlines, so a long single-line label
wrapped to six lines in draw.io but got a one-line-tall box, and the
text overflowed the cell.
- Marker stamping appended without replacing, so every render of a
recovered style grew it by one duplicate dai_* token per key -
unnoticed because draw.io resolves duplicates last-wins. dai_* keys
are now replaced in place; mxGraph keys still append, because
last-wins is load-bearing for container=1 normalisation.
- Banner/heading/metric roles stretch across their container's cross
axis, the way a masthead spans its page.
- Prompt: a poster's banner IS its title (no set_title alongside), and
sections get their colour by naming groups.
537 unit tests, 250-flowchart corpus still zero crossing arrows, 5 e2e
tests in a real browser. Verified visually: the Transformer-paper poster
renders with a navy masthead, three hue-coded section panels, metric,
verdict and callout boxes - all engine-computed geometry.
2026-08-09 19:48:01 +09:00
|
|
|
|
hue: HueResolver = () => NEUTRAL,
|
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
|
|
|
|
): string {
|
|
|
|
|
|
const ox = parentRect?.x ?? 0
|
|
|
|
|
|
const oy = parentRect?.y ?? 0
|
feat(diagram-engine): edge router — pinned ports, obstacle avoidance, cost search
Fixes the reported problem: arrows overlapped on top of icons and ran through shapes
they had nothing to do with.
The cause was a missing layer, not a bug. render.ts emitted only source and target, so
draw.io routed every edge itself — and its router sees the two terminals' bounds and
nothing else, not where the other icons are. It therefore ran lines straight through
whatever was in between and left several edges leaving one node at the same point.
Ported from drawio-ai-kit's router (MIT, see NOTICE):
- Port de-collision. Edges leaving the same side of the same node spread along it,
ordered by where their far end sits so they do not cross on the way out. An edge
with a clean straight shot keeps the centre.
- Obstacle avoidance. Candidate shapes in order of directness — straight, a Z through
the gap, an L, a two-bend detour — each tested against every icon on the page.
- Frame placement. A frame is passable (an edge into a VPC must cross its border) but
not free: running alongside a border, or cutting through a frame that holds only one
of the two endpoints, is penalised.
- Port snapping. On a bent route, each port moves to the side its leg actually arrives
from. Without this the terminal segment can pierce the icon to reach a far-side port.
- Lane claiming. Each routed edge records the lanes it occupies so later edges avoid
them, rather than colliding and being pulled apart afterwards.
- Global nudge, three passes, reverting any move that makes a path worse.
Two things I got wrong on the way, both caught by looking at real geometry:
- The Z corridor was computed as min/max of both nodes' edges, which spans the whole
distance between them — including anything parked in between. So the lane sweep
would place the detour's middle leg on top of the very icon it was avoiding. It has
to be the gap: trailing edge of the first node to the leading edge of the other.
- The router was fed layout's slot rectangles, but an icon's cell is the glyph square
centred in a slot roughly twice as wide. Collision tests against slots both missed
real overlaps and invented false ones. Extracted cellRect() so the router and the
emitted XML cannot diverge.
Accept-or-reject was not enough on its own. When one endpoint is inside a VPC and the
other outside, EVERY path trespasses on that frame, so the strict rule always fails and
the relaxed pass took whatever it tried first — which is how a line ended up cutting
across a whole VPC. Candidates are now scored (frame offences 500, lane sharing 700,
bends 80, length 1) and the cheapest wins, so an edge that must trespass still gets the
least-bad route.
Waypoints are still written only when load-bearing — a labelled bend or a deliberate
detour — so an unobstructed edge stays drag-friendly.
455 unit tests (27 new, asserting produced geometry rather than algorithm shape).
Verified by rendering the reported diagram in the real editor: the two arrows that
overlapped on the EC2 icon now leave from different sides, and the load balancer's
fan-out leaves from three distinct points.
2026-08-09 12:46:44 +09:00
|
|
|
|
const box = cellRect(n, rect, defaultGlyph)
|
feat(diagram-engine): layout + XML renderer, verified end to end in draw.io
Completes the tree → coordinates → XML direction, so the model can declare nesting
and never write a coordinate or an mxCell again.
layout.ts — measure bottom-up, place top-down, the same shape as flexbox. A
container sums its children along the flow axis and adds padding, so "child spills
out of its frame" and "siblings overlap" cannot happen by construction rather than
being caught afterwards. Slack from sibling equalisation is shared between children
instead of left as dead margin, capped at one gap so a stretched frame reads as
spaced rather than sparse.
render.ts — writes the mxCells, stamping container=1 and the dai_* markers so
parse.ts can read the structure back. Edges carry no waypoints: draw.io's own router
recomputes the route on every edit, so a user who moves a node never has to re-link
an arrow. Cells the parser could not interpret are re-emitted verbatim, so a
re-layout never deletes a user's annotations.
Phantoms are gone (task #5). The reference project's layout-only wrapper emits no
cell, which makes the round-trip lossy by construction — measured on its own
build_vpc.mjs, a phantom erased a container's "col" direction for good. An
unlabelled frame here emits a real cell with fillColor/strokeColor=none instead:
invisible, but present in the XML and therefore recoverable.
Two bugs the round-trip test caught, both real:
- An icon's cell was being emitted at its measured slot size, which includes room
for the label underneath. Parsing read that width back as the glyph size, so the
icon grew on every round-trip. The cell is now the glyph square and the label
renders outside it via verticalLabelPosition, as the reference does.
- An Azure or GCP icon is an embedded base64 image whose style contains no name
anywhere, so the catalog name was unrecoverable. Added a dai_name marker.
Verified in a real browser (3 Playwright tests, not mocks): engine output renders in
draw.io; dragging a shape into a frame makes draw.io rewrite its parent and the
engine reads the new structure back; re-laying out from that structure PRESERVES the
user's move instead of undoing it, and leaves untouched nodes alone; and the
re-laid-out XML still renders.
That last point is the whole design: there is no second copy of the state, so a
manual edit is an input to the next layout rather than a conflict to reconcile.
304 unit tests + 3 e2e.
2026-08-09 11:56:08 +09:00
|
|
|
|
return (
|
|
|
|
|
|
`<mxCell id="${esc(n.id)}" value="${esc("label" in n ? n.label : "")}"` +
|
feat(diagram-engine): design tokens + role/group composition, engine-wide theming
Paper-summary posters previously required hand-written XML: every engine
box rendered identically (white, 11px), so anything whose meaning lives
in visual hierarchy came out flat. This makes presentation a first-class,
generalised part of the declaration - not a poster feature.
Structure/presentation separation, the same split HTML and CSS settled on:
- ROLE says what a node IS: banner, heading, body, callout, good, bad,
metric, muted. Maps to a type scale and an emphasis (filled / tinted /
outlined / ghost), never to a colour.
- GROUP says which semantic zone a node belongs to. Each distinct group
name gets one hue ramp (tint / base / dark), assigned in document
order. Promoted from a draw_graph-only field to BoxNode and GroupNode,
round-tripped via dai_group.
- themedStyle(role, hue, kind) composes the two by rule - there is no
per-combination table to extend, so a new diagram kind gets full
theming by tagging nodes. The model never sees a hex value.
A heading container plus a group yields the tinted section panel with a
dark title; a grouped body box takes its zone's tint; verdict roles stay
green/red regardless of zone; the banner is the page's one dark field.
Also fixed, found while building the acceptance poster:
- autoBoxSize only counted explicit newlines, so a long single-line label
wrapped to six lines in draw.io but got a one-line-tall box, and the
text overflowed the cell.
- Marker stamping appended without replacing, so every render of a
recovered style grew it by one duplicate dai_* token per key -
unnoticed because draw.io resolves duplicates last-wins. dai_* keys
are now replaced in place; mxGraph keys still append, because
last-wins is load-bearing for container=1 normalisation.
- Banner/heading/metric roles stretch across their container's cross
axis, the way a masthead spans its page.
- Prompt: a poster's banner IS its title (no set_title alongside), and
sections get their colour by naming groups.
537 unit tests, 250-flowchart corpus still zero crossing arrows, 5 e2e
tests in a real browser. Verified visually: the Transformer-paper poster
renders with a navy masthead, three hue-coded section panels, metric,
verdict and callout boxes - all engine-computed geometry.
2026-08-09 19:48:01 +09:00
|
|
|
|
` style="${styleFor(n, resolve, hue)}" vertex="1" parent="${esc(parent)}">` +
|
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
|
|
|
|
`<mxGeometry x="${box.x - ox}" y="${box.y - oy}" width="${box.w}" height="${box.h}" as="geometry"/>` +
|
|
|
|
|
|
`</mxCell>`
|
|
|
|
|
|
)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
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
|
|
|
|
/** One chrome cell: a lane band, a label column, a milestone strip, a lifeline. */
|
|
|
|
|
|
function chromeXml(
|
|
|
|
|
|
id: string,
|
|
|
|
|
|
parent: string,
|
|
|
|
|
|
rect: Rect,
|
|
|
|
|
|
parentRect: Rect | null,
|
|
|
|
|
|
style: string,
|
|
|
|
|
|
label: string,
|
|
|
|
|
|
): string {
|
|
|
|
|
|
const ox = parentRect?.x ?? 0
|
|
|
|
|
|
const oy = parentRect?.y ?? 0
|
|
|
|
|
|
return (
|
|
|
|
|
|
`<mxCell id="${esc(id)}" value="${esc(label)}" style="${style}" vertex="1" parent="${esc(parent)}">` +
|
|
|
|
|
|
`<mxGeometry x="${Math.round(rect.x - ox)}" y="${Math.round(rect.y - oy)}"` +
|
|
|
|
|
|
` width="${Math.round(rect.w)}" height="${Math.round(rect.h)}" as="geometry"/></mxCell>`
|
|
|
|
|
|
)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* The lane bands, role-name column and milestone strip of a swimlane pool.
|
|
|
|
|
|
*
|
|
|
|
|
|
* Emitted BEFORE the pool's children so the nodes render on top of the bands, and derived
|
|
|
|
|
|
* from the same `poolMetrics` layout used, so a band cannot end up offset from the nodes
|
|
|
|
|
|
* sitting on it.
|
|
|
|
|
|
*
|
|
|
|
|
|
* The bands are draw.io containers and the nodes are their children. That is what makes a
|
|
|
|
|
|
* user dragging a step onto another role's band record the change: draw.io rewrites the
|
|
|
|
|
|
* node's `parent` to that band, and the band's `dai_lane` marker says which lane it is.
|
|
|
|
|
|
*/
|
|
|
|
|
|
function poolChrome(
|
|
|
|
|
|
n: PoolNode,
|
|
|
|
|
|
rect: Rect,
|
|
|
|
|
|
kids: { rect: Rect }[],
|
|
|
|
|
|
): { xml: string[]; bands: { id: string; rect: Rect }[] } {
|
|
|
|
|
|
const m = poolMetrics(n, rect, kids)
|
|
|
|
|
|
const xml: string[] = []
|
|
|
|
|
|
const bands: { id: string; rect: Rect }[] = []
|
|
|
|
|
|
|
|
|
|
|
|
for (let i = 0; i < m.lanes; i++) {
|
|
|
|
|
|
const band: Rect = m.horizontal
|
|
|
|
|
|
? {
|
|
|
|
|
|
x: m.contentX,
|
|
|
|
|
|
y: m.contentY + i * m.cellH,
|
|
|
|
|
|
w: m.contentW,
|
|
|
|
|
|
h: m.cellH,
|
|
|
|
|
|
}
|
|
|
|
|
|
: {
|
|
|
|
|
|
x: rect.x + POOL_PAD + i * m.cellW,
|
|
|
|
|
|
y: m.contentY,
|
|
|
|
|
|
w: m.cellW,
|
|
|
|
|
|
h: m.contentH,
|
|
|
|
|
|
}
|
|
|
|
|
|
const tint = i % 2 ? POOL_BAND_ALT : POOL_FILL
|
|
|
|
|
|
xml.push(
|
|
|
|
|
|
chromeXml(
|
|
|
|
|
|
`${n.id}__band${i}`,
|
|
|
|
|
|
n.id,
|
|
|
|
|
|
band,
|
|
|
|
|
|
rect,
|
|
|
|
|
|
stampLane(
|
|
|
|
|
|
`rounded=0;whiteSpace=wrap;html=1;fillColor=${tint};strokeColor=${POOL_HAIR};`,
|
|
|
|
|
|
i,
|
|
|
|
|
|
),
|
|
|
|
|
|
"",
|
|
|
|
|
|
),
|
|
|
|
|
|
)
|
|
|
|
|
|
bands.push({ id: `${n.id}__band${i}`, rect: band })
|
|
|
|
|
|
|
|
|
|
|
|
// The role name, in its own column beside the band.
|
|
|
|
|
|
const label: Rect = m.horizontal
|
|
|
|
|
|
? {
|
|
|
|
|
|
x: rect.x + POOL_PAD,
|
|
|
|
|
|
y: m.contentY + i * m.cellH,
|
|
|
|
|
|
w: LANE_LABEL,
|
|
|
|
|
|
h: m.cellH,
|
|
|
|
|
|
}
|
|
|
|
|
|
: {
|
|
|
|
|
|
x: rect.x + POOL_PAD + i * m.cellW,
|
|
|
|
|
|
y: rect.y + m.header + POOL_PAD,
|
|
|
|
|
|
w: m.cellW,
|
|
|
|
|
|
h: LANE_LABEL,
|
|
|
|
|
|
}
|
|
|
|
|
|
xml.push(
|
|
|
|
|
|
chromeXml(
|
|
|
|
|
|
`${n.id}__lane${i}`,
|
|
|
|
|
|
n.id,
|
|
|
|
|
|
label,
|
|
|
|
|
|
rect,
|
|
|
|
|
|
stampPoolDecoration(
|
|
|
|
|
|
`rounded=0;whiteSpace=wrap;html=1;fillColor=${POOL_LABEL_FILL};strokeColor=${POOL_HAIR};` +
|
|
|
|
|
|
`verticalAlign=middle;align=center;fontStyle=1;fontSize=11;${m.horizontal ? "" : "horizontal=1;"}`,
|
|
|
|
|
|
),
|
|
|
|
|
|
n.lanes[i] ?? "",
|
|
|
|
|
|
),
|
|
|
|
|
|
)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Milestone labels, each spanning its even share of the columns.
|
|
|
|
|
|
for (let j = 0; j < n.phases.length; j++) {
|
|
|
|
|
|
const count = n.phases.length
|
|
|
|
|
|
const from = Math.floor((j * m.cols) / count)
|
|
|
|
|
|
const to = Math.floor(((j + 1) * m.cols) / count)
|
|
|
|
|
|
const last = j === count - 1
|
|
|
|
|
|
const span = (to - from) * (m.cellW + n.gap) - (last ? n.gap : 0)
|
|
|
|
|
|
const strip: Rect = m.horizontal
|
|
|
|
|
|
? {
|
|
|
|
|
|
x: m.contentX + from * (m.cellW + n.gap),
|
|
|
|
|
|
y: rect.y + m.header,
|
|
|
|
|
|
w: Math.max(0, span),
|
|
|
|
|
|
h: m.phaseLabel,
|
|
|
|
|
|
}
|
|
|
|
|
|
: {
|
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
|
|
|
|
// Flush against the content, because that is what the measure pass
|
|
|
|
|
|
// reserved: the pool's width is padding + content + this strip, with no
|
|
|
|
|
|
// gap between the two. Adding one here pushed the strip outside the frame.
|
|
|
|
|
|
x: rect.x + POOL_PAD + m.contentW,
|
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
|
|
|
|
y: m.contentY + from * (m.cellH + n.gap),
|
|
|
|
|
|
w: m.phaseLabel,
|
|
|
|
|
|
h: Math.max(
|
|
|
|
|
|
0,
|
|
|
|
|
|
(to - from) * (m.cellH + n.gap) - (last ? n.gap : 0),
|
|
|
|
|
|
),
|
|
|
|
|
|
}
|
|
|
|
|
|
xml.push(
|
|
|
|
|
|
chromeXml(
|
|
|
|
|
|
`${n.id}__phase${j}`,
|
|
|
|
|
|
n.id,
|
|
|
|
|
|
strip,
|
|
|
|
|
|
rect,
|
|
|
|
|
|
stampPoolDecoration(
|
|
|
|
|
|
`rounded=0;whiteSpace=wrap;html=1;fillColor=${POOL_FILL};strokeColor=${POOL_HAIR};` +
|
|
|
|
|
|
`verticalAlign=middle;align=center;fontStyle=1;fontSize=11;`,
|
|
|
|
|
|
),
|
|
|
|
|
|
n.phases[j] ?? "",
|
|
|
|
|
|
),
|
|
|
|
|
|
)
|
|
|
|
|
|
}
|
|
|
|
|
|
return { xml, bands }
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* The lifelines of a sequence diagram: one per participant, hanging from its head.
|
|
|
|
|
|
*
|
|
|
|
|
|
* Head and line are ONE cell, using mxGraph's `umlLifeline` shape with `size` set to the
|
|
|
|
|
|
* head's height. That is what keeps them together when the user drags a participant
|
|
|
|
|
|
* sideways — two separate cells would come apart, and the line would be left behind.
|
|
|
|
|
|
*
|
|
|
|
|
|
* The participant node itself is therefore not emitted as its own cell: this replaces it.
|
|
|
|
|
|
*/
|
|
|
|
|
|
function sequenceChrome(
|
|
|
|
|
|
n: SequenceNode,
|
|
|
|
|
|
rect: Rect,
|
|
|
|
|
|
kids: { node: DiagramNode; rect: Rect }[],
|
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
|
|
|
|
metrics: SequenceMetrics,
|
|
|
|
|
|
): string[] {
|
|
|
|
|
|
return kids.map((k) => {
|
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 head = k.rect
|
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
|
|
|
|
return chromeXml(
|
|
|
|
|
|
k.node.id,
|
|
|
|
|
|
n.id,
|
|
|
|
|
|
{
|
|
|
|
|
|
x: head.x,
|
|
|
|
|
|
y: head.y,
|
|
|
|
|
|
w: head.w,
|
|
|
|
|
|
h: Math.max(head.h, metrics.bottom - head.y),
|
|
|
|
|
|
},
|
|
|
|
|
|
rect,
|
|
|
|
|
|
`${LIFELINE_STYLE}size=${Math.round(head.h)};`,
|
|
|
|
|
|
"label" in k.node ? k.node.label : "",
|
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
|
|
|
|
)
|
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
|
|
|
|
})
|
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
|
|
|
|
}
|
|
|
|
|
|
|
feat(diagram-engine): edge router — pinned ports, obstacle avoidance, cost search
Fixes the reported problem: arrows overlapped on top of icons and ran through shapes
they had nothing to do with.
The cause was a missing layer, not a bug. render.ts emitted only source and target, so
draw.io routed every edge itself — and its router sees the two terminals' bounds and
nothing else, not where the other icons are. It therefore ran lines straight through
whatever was in between and left several edges leaving one node at the same point.
Ported from drawio-ai-kit's router (MIT, see NOTICE):
- Port de-collision. Edges leaving the same side of the same node spread along it,
ordered by where their far end sits so they do not cross on the way out. An edge
with a clean straight shot keeps the centre.
- Obstacle avoidance. Candidate shapes in order of directness — straight, a Z through
the gap, an L, a two-bend detour — each tested against every icon on the page.
- Frame placement. A frame is passable (an edge into a VPC must cross its border) but
not free: running alongside a border, or cutting through a frame that holds only one
of the two endpoints, is penalised.
- Port snapping. On a bent route, each port moves to the side its leg actually arrives
from. Without this the terminal segment can pierce the icon to reach a far-side port.
- Lane claiming. Each routed edge records the lanes it occupies so later edges avoid
them, rather than colliding and being pulled apart afterwards.
- Global nudge, three passes, reverting any move that makes a path worse.
Two things I got wrong on the way, both caught by looking at real geometry:
- The Z corridor was computed as min/max of both nodes' edges, which spans the whole
distance between them — including anything parked in between. So the lane sweep
would place the detour's middle leg on top of the very icon it was avoiding. It has
to be the gap: trailing edge of the first node to the leading edge of the other.
- The router was fed layout's slot rectangles, but an icon's cell is the glyph square
centred in a slot roughly twice as wide. Collision tests against slots both missed
real overlaps and invented false ones. Extracted cellRect() so the router and the
emitted XML cannot diverge.
Accept-or-reject was not enough on its own. When one endpoint is inside a VPC and the
other outside, EVERY path trespasses on that frame, so the strict rule always fails and
the relaxed pass took whatever it tried first — which is how a line ended up cutting
across a whole VPC. Candidates are now scored (frame offences 500, lane sharing 700,
bends 80, length 1) and the cheapest wins, so an edge that must trespass still gets the
least-bad route.
Waypoints are still written only when load-bearing — a labelled bend or a deliberate
detour — so an unobstructed edge stays drag-friendly.
455 unit tests (27 new, asserting produced geometry rather than algorithm shape).
Verified by rendering the reported diagram in the real editor: the two arrows that
overlapped on the EC2 icon now leave from different sides, and the load balancer's
fan-out leaves from three distinct points.
2026-08-09 12:46:44 +09:00
|
|
|
|
/** The label an edge renders, with its step number prefixed. */
|
|
|
|
|
|
function edgeLabel(l: LinkSpec): string {
|
|
|
|
|
|
if (l.step == null) return l.label ?? ""
|
|
|
|
|
|
return l.label ? `${l.step}. ${l.label}` : `${l.step}.`
|
|
|
|
|
|
}
|
|
|
|
|
|
|
feat(diagram-engine): label avoidance, paired opposite edges, semantic group colours
A git-workflow flowchart rendered with no overlaps but read poorly. Three
distinct causes, each fixed and measured:
1. Edge labels sat on boxes and on each other (4 collisions on the
reported diagram; 280 across 250 generated flowcharts). The router
keeps LINES off the boxes but a label renders at its edge's midpoint,
which on a long edge is beside exactly the things the line was routed
around. placeLabels slides each label along its own edge to a clear
spot — longest edges first, midpoint-outward tries — written as the
geometry's relative x, which draw.io natively supports. Corpus: 280
label collisions -> 8.
2. A->B and B->A were routed independently, so "git add" ran straight
while "git reset" wandered through a different corridor with a kink.
Opposite edges that agree on axis now get two absolute parallel tracks
in the strip where the two boxes overlap, a constant 24px apart,
converted back to port fractions. Zero crossing regressions.
3. All boxes rendered the same white, because the render layer's
fill/stroke support was never reachable: neither add_box's schema nor
draw_graph's nodes exposed it. Rather than exposing raw hex (the model
picks mismatched saturations, differently every time), nodes take a
semantic group name and the engine maps groups to a fixed palette of
six paired fill/strokes in order of first appearance. The model names
the zones - remote vs local vs temp - and never touches a colour.
532 unit tests pass; the 5 diagram e2e tests pass in a real browser.
2026-08-09 18:59:34 +09:00
|
|
|
|
/**
|
|
|
|
|
|
* Slide each edge label along its own edge to a spot where it covers nothing.
|
|
|
|
|
|
*
|
|
|
|
|
|
* A label renders centred on the path midpoint, and on a long edge that midpoint is
|
|
|
|
|
|
* frequently on top of something — the edge was routed AROUND the boxes, so its middle
|
|
|
|
|
|
* passes exactly the things it avoided, and the router has never known labels exist.
|
|
|
|
|
|
* Measured on a git-workflow diagram: four labels sat on unrelated boxes or on each other.
|
|
|
|
|
|
*
|
|
|
|
|
|
* For each labelled edge, in order of path length (longest first, since they have the
|
|
|
|
|
|
* fewest clear spots), positions along the path are tried from the middle outwards; the
|
|
|
|
|
|
* first where the label's rectangle overlaps no box and no already-placed label wins.
|
|
|
|
|
|
* draw.io expresses the position as the geometry's relative x: −1 at the source, 0 at the
|
|
|
|
|
|
* midpoint, +1 at the target.
|
|
|
|
|
|
*
|
|
|
|
|
|
* The label's size is an estimate (7px per character, one line). That is fine here: the
|
|
|
|
|
|
* goal is to stop labels sitting ON things, and a near miss by a few pixels still reads
|
|
|
|
|
|
* clearly, where the current midpoint placement puts them dead centre on a box.
|
|
|
|
|
|
*/
|
|
|
|
|
|
function placeLabels(
|
|
|
|
|
|
edges: { id: string; label: string; path: Point[] }[],
|
|
|
|
|
|
boxes: Rect[],
|
|
|
|
|
|
): Map<string, number> {
|
|
|
|
|
|
const placed: Rect[] = []
|
|
|
|
|
|
const out = new Map<string, number>()
|
|
|
|
|
|
|
|
|
|
|
|
const measure = (label: string): { w: number; h: number } => ({
|
|
|
|
|
|
w: Math.min(160, label.length * 7 + 8),
|
|
|
|
|
|
h: 16,
|
|
|
|
|
|
})
|
|
|
|
|
|
const pointAt = (path: Point[], t: number): Point => {
|
|
|
|
|
|
let total = 0
|
|
|
|
|
|
const segs = path.slice(0, -1).map((p, i) => {
|
|
|
|
|
|
const len =
|
|
|
|
|
|
Math.abs(path[i + 1].x - p.x) + Math.abs(path[i + 1].y - p.y)
|
|
|
|
|
|
total += len
|
|
|
|
|
|
return { a: p, b: path[i + 1], len }
|
|
|
|
|
|
})
|
|
|
|
|
|
let at = total * t
|
|
|
|
|
|
for (const s of segs) {
|
|
|
|
|
|
if (at <= s.len || s === segs[segs.length - 1]) {
|
|
|
|
|
|
const f = s.len ? Math.min(1, at / s.len) : 0
|
|
|
|
|
|
return {
|
|
|
|
|
|
x: s.a.x + (s.b.x - s.a.x) * f,
|
|
|
|
|
|
y: s.a.y + (s.b.y - s.a.y) * f,
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
at -= s.len
|
|
|
|
|
|
}
|
|
|
|
|
|
return path[0]
|
|
|
|
|
|
}
|
|
|
|
|
|
const overlaps = (r: Rect, list: Rect[]) =>
|
|
|
|
|
|
list.some(
|
|
|
|
|
|
(o) =>
|
|
|
|
|
|
r.x < o.x + o.w &&
|
|
|
|
|
|
o.x < r.x + r.w &&
|
|
|
|
|
|
r.y < o.y + o.h &&
|
|
|
|
|
|
o.y < r.y + r.h,
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
const byLength = [...edges].sort((p, q) => {
|
|
|
|
|
|
const len = (e: { path: Point[] }) =>
|
|
|
|
|
|
e.path.reduce(
|
|
|
|
|
|
(s, pt, i) =>
|
|
|
|
|
|
i === 0
|
|
|
|
|
|
? 0
|
|
|
|
|
|
: s +
|
|
|
|
|
|
Math.abs(pt.x - e.path[i - 1].x) +
|
|
|
|
|
|
Math.abs(pt.y - e.path[i - 1].y),
|
|
|
|
|
|
0,
|
|
|
|
|
|
)
|
|
|
|
|
|
return len(q) - len(p)
|
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
|
// The midpoint first — it is where a reader expects the label — then nearby spots,
|
|
|
|
|
|
// preferring the source half slightly: a label near the arrow's origin still reads as
|
|
|
|
|
|
// naming the action.
|
|
|
|
|
|
const TRIES = [0.5, 0.42, 0.58, 0.34, 0.66, 0.26, 0.74, 0.18, 0.82]
|
|
|
|
|
|
for (const e of byLength) {
|
|
|
|
|
|
const { w, h } = measure(e.label)
|
|
|
|
|
|
let chosen = 0.5
|
|
|
|
|
|
for (const t of TRIES) {
|
|
|
|
|
|
const c = pointAt(e.path, t)
|
|
|
|
|
|
const rect = { x: c.x - w / 2, y: c.y - h / 2, w, h }
|
|
|
|
|
|
if (!overlaps(rect, boxes) && !overlaps(rect, placed)) {
|
|
|
|
|
|
chosen = t
|
|
|
|
|
|
break
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
const c = pointAt(e.path, chosen)
|
|
|
|
|
|
placed.push({ x: c.x - w / 2, y: c.y - h / 2, w, h })
|
|
|
|
|
|
// Even a spot that still overlaps is recorded, so the NEXT label avoids stacking
|
|
|
|
|
|
// on top of it — two labels on one point is strictly worse than one on a box.
|
|
|
|
|
|
if (chosen !== 0.5) out.set(e.id, chosen * 2 - 1)
|
|
|
|
|
|
}
|
|
|
|
|
|
return out
|
|
|
|
|
|
}
|
|
|
|
|
|
|
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
|
|
|
|
/**
|
feat(diagram-engine): edge router — pinned ports, obstacle avoidance, cost search
Fixes the reported problem: arrows overlapped on top of icons and ran through shapes
they had nothing to do with.
The cause was a missing layer, not a bug. render.ts emitted only source and target, so
draw.io routed every edge itself — and its router sees the two terminals' bounds and
nothing else, not where the other icons are. It therefore ran lines straight through
whatever was in between and left several edges leaving one node at the same point.
Ported from drawio-ai-kit's router (MIT, see NOTICE):
- Port de-collision. Edges leaving the same side of the same node spread along it,
ordered by where their far end sits so they do not cross on the way out. An edge
with a clean straight shot keeps the centre.
- Obstacle avoidance. Candidate shapes in order of directness — straight, a Z through
the gap, an L, a two-bend detour — each tested against every icon on the page.
- Frame placement. A frame is passable (an edge into a VPC must cross its border) but
not free: running alongside a border, or cutting through a frame that holds only one
of the two endpoints, is penalised.
- Port snapping. On a bent route, each port moves to the side its leg actually arrives
from. Without this the terminal segment can pierce the icon to reach a far-side port.
- Lane claiming. Each routed edge records the lanes it occupies so later edges avoid
them, rather than colliding and being pulled apart afterwards.
- Global nudge, three passes, reverting any move that makes a path worse.
Two things I got wrong on the way, both caught by looking at real geometry:
- The Z corridor was computed as min/max of both nodes' edges, which spans the whole
distance between them — including anything parked in between. So the lane sweep
would place the detour's middle leg on top of the very icon it was avoiding. It has
to be the gap: trailing edge of the first node to the leading edge of the other.
- The router was fed layout's slot rectangles, but an icon's cell is the glyph square
centred in a slot roughly twice as wide. Collision tests against slots both missed
real overlaps and invented false ones. Extracted cellRect() so the router and the
emitted XML cannot diverge.
Accept-or-reject was not enough on its own. When one endpoint is inside a VPC and the
other outside, EVERY path trespasses on that frame, so the strict rule always fails and
the relaxed pass took whatever it tried first — which is how a line ended up cutting
across a whole VPC. Candidates are now scored (frame offences 500, lane sharing 700,
bends 80, length 1) and the cheapest wins, so an edge that must trespass still gets the
least-bad route.
Waypoints are still written only when load-bearing — a labelled bend or a deliberate
detour — so an unobstructed edge stays drag-friendly.
455 unit tests (27 new, asserting produced geometry rather than algorithm shape).
Verified by rendering the reported diagram in the real editor: the two arrows that
overlapped on the EC2 icon now leave from different sides, and the load balancer's
fan-out leaves from three distinct points.
2026-08-09 12:46:44 +09:00
|
|
|
|
* One `<mxCell>` for an edge, carrying the route the router computed.
|
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
|
|
|
|
*
|
feat(diagram-engine): edge router — pinned ports, obstacle avoidance, cost search
Fixes the reported problem: arrows overlapped on top of icons and ran through shapes
they had nothing to do with.
The cause was a missing layer, not a bug. render.ts emitted only source and target, so
draw.io routed every edge itself — and its router sees the two terminals' bounds and
nothing else, not where the other icons are. It therefore ran lines straight through
whatever was in between and left several edges leaving one node at the same point.
Ported from drawio-ai-kit's router (MIT, see NOTICE):
- Port de-collision. Edges leaving the same side of the same node spread along it,
ordered by where their far end sits so they do not cross on the way out. An edge
with a clean straight shot keeps the centre.
- Obstacle avoidance. Candidate shapes in order of directness — straight, a Z through
the gap, an L, a two-bend detour — each tested against every icon on the page.
- Frame placement. A frame is passable (an edge into a VPC must cross its border) but
not free: running alongside a border, or cutting through a frame that holds only one
of the two endpoints, is penalised.
- Port snapping. On a bent route, each port moves to the side its leg actually arrives
from. Without this the terminal segment can pierce the icon to reach a far-side port.
- Lane claiming. Each routed edge records the lanes it occupies so later edges avoid
them, rather than colliding and being pulled apart afterwards.
- Global nudge, three passes, reverting any move that makes a path worse.
Two things I got wrong on the way, both caught by looking at real geometry:
- The Z corridor was computed as min/max of both nodes' edges, which spans the whole
distance between them — including anything parked in between. So the lane sweep
would place the detour's middle leg on top of the very icon it was avoiding. It has
to be the gap: trailing edge of the first node to the leading edge of the other.
- The router was fed layout's slot rectangles, but an icon's cell is the glyph square
centred in a slot roughly twice as wide. Collision tests against slots both missed
real overlaps and invented false ones. Extracted cellRect() so the router and the
emitted XML cannot diverge.
Accept-or-reject was not enough on its own. When one endpoint is inside a VPC and the
other outside, EVERY path trespasses on that frame, so the strict rule always fails and
the relaxed pass took whatever it tried first — which is how a line ended up cutting
across a whole VPC. Candidates are now scored (frame offences 500, lane sharing 700,
bends 80, length 1) and the cheapest wins, so an edge that must trespass still gets the
least-bad route.
Waypoints are still written only when load-bearing — a labelled bend or a deliberate
detour — so an unobstructed edge stays drag-friendly.
455 unit tests (27 new, asserting produced geometry rather than algorithm shape).
Verified by rendering the reported diagram in the real editor: the two arrows that
overlapped on the EC2 icon now leave from different sides, and the load balancer's
fan-out leaves from three distinct points.
2026-08-09 12:46:44 +09:00
|
|
|
|
* Connection points are always written. They are fractions of the terminal's bounds, so
|
|
|
|
|
|
* draw.io recomputes them from live geometry on every edit — they follow a node when the
|
|
|
|
|
|
* user drags it. Without them draw.io picks the side itself, knowing only the two
|
|
|
|
|
|
* terminals and nothing about the other icons, which is how arrows end up running through
|
|
|
|
|
|
* unrelated shapes and stacking several on one point.
|
|
|
|
|
|
*
|
|
|
|
|
|
* Waypoints are absolute, so draw.io keeps them after a drag and the route deforms. They
|
|
|
|
|
|
* are written only when the router says they are load-bearing: a labelled bend (the label
|
|
|
|
|
|
* sits at the path midpoint and needs a straight segment under it) or a deliberate detour
|
|
|
|
|
|
* around something a straight line would have hit.
|
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
|
|
|
|
*/
|
feat(diagram-engine): label avoidance, paired opposite edges, semantic group colours
A git-workflow flowchart rendered with no overlaps but read poorly. Three
distinct causes, each fixed and measured:
1. Edge labels sat on boxes and on each other (4 collisions on the
reported diagram; 280 across 250 generated flowcharts). The router
keeps LINES off the boxes but a label renders at its edge's midpoint,
which on a long edge is beside exactly the things the line was routed
around. placeLabels slides each label along its own edge to a clear
spot — longest edges first, midpoint-outward tries — written as the
geometry's relative x, which draw.io natively supports. Corpus: 280
label collisions -> 8.
2. A->B and B->A were routed independently, so "git add" ran straight
while "git reset" wandered through a different corridor with a kink.
Opposite edges that agree on axis now get two absolute parallel tracks
in the strip where the two boxes overlap, a constant 24px apart,
converted back to port fractions. Zero crossing regressions.
3. All boxes rendered the same white, because the render layer's
fill/stroke support was never reachable: neither add_box's schema nor
draw_graph's nodes exposed it. Rather than exposing raw hex (the model
picks mismatched saturations, differently every time), nodes take a
semantic group name and the engine maps groups to a fixed palette of
six paired fill/strokes in order of first appearance. The model names
the zones - remote vs local vs temp - and never touches a colour.
532 unit tests pass; the 5 diagram e2e tests pass in a real browser.
2026-08-09 18:59:34 +09:00
|
|
|
|
function edgeXml(
|
|
|
|
|
|
l: LinkSpec,
|
|
|
|
|
|
index: number,
|
|
|
|
|
|
route?: RoutedEdge,
|
|
|
|
|
|
labelAt?: number,
|
|
|
|
|
|
): string {
|
feat(diagram-engine): edge router — pinned ports, obstacle avoidance, cost search
Fixes the reported problem: arrows overlapped on top of icons and ran through shapes
they had nothing to do with.
The cause was a missing layer, not a bug. render.ts emitted only source and target, so
draw.io routed every edge itself — and its router sees the two terminals' bounds and
nothing else, not where the other icons are. It therefore ran lines straight through
whatever was in between and left several edges leaving one node at the same point.
Ported from drawio-ai-kit's router (MIT, see NOTICE):
- Port de-collision. Edges leaving the same side of the same node spread along it,
ordered by where their far end sits so they do not cross on the way out. An edge
with a clean straight shot keeps the centre.
- Obstacle avoidance. Candidate shapes in order of directness — straight, a Z through
the gap, an L, a two-bend detour — each tested against every icon on the page.
- Frame placement. A frame is passable (an edge into a VPC must cross its border) but
not free: running alongside a border, or cutting through a frame that holds only one
of the two endpoints, is penalised.
- Port snapping. On a bent route, each port moves to the side its leg actually arrives
from. Without this the terminal segment can pierce the icon to reach a far-side port.
- Lane claiming. Each routed edge records the lanes it occupies so later edges avoid
them, rather than colliding and being pulled apart afterwards.
- Global nudge, three passes, reverting any move that makes a path worse.
Two things I got wrong on the way, both caught by looking at real geometry:
- The Z corridor was computed as min/max of both nodes' edges, which spans the whole
distance between them — including anything parked in between. So the lane sweep
would place the detour's middle leg on top of the very icon it was avoiding. It has
to be the gap: trailing edge of the first node to the leading edge of the other.
- The router was fed layout's slot rectangles, but an icon's cell is the glyph square
centred in a slot roughly twice as wide. Collision tests against slots both missed
real overlaps and invented false ones. Extracted cellRect() so the router and the
emitted XML cannot diverge.
Accept-or-reject was not enough on its own. When one endpoint is inside a VPC and the
other outside, EVERY path trespasses on that frame, so the strict rule always fails and
the relaxed pass took whatever it tried first — which is how a line ended up cutting
across a whole VPC. Candidates are now scored (frame offences 500, lane sharing 700,
bends 80, length 1) and the cheapest wins, so an edge that must trespass still gets the
least-bad route.
Waypoints are still written only when load-bearing — a labelled bend or a deliberate
detour — so an unobstructed edge stays drag-friendly.
455 unit tests (27 new, asserting produced geometry rather than algorithm shape).
Verified by rendering the reported diagram in the real editor: the two arrows that
overlapped on the EC2 icon now leave from different sides, and the load balancer's
fan-out leaves from three distinct points.
2026-08-09 12:46:44 +09:00
|
|
|
|
const label = edgeLabel(l)
|
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
|
|
|
|
let style = l.style ?? EDGE_STYLE
|
|
|
|
|
|
if (!l.style) {
|
|
|
|
|
|
if (l.dashed) style += "dashed=1;"
|
feat(diagram-engine): connection vocabulary — arrowheads, parallel edges, edge ids
Arrowheads carry meaning: a crow's foot IS one-to-many, a hollow diamond IS
aggregation. The engine allowed exactly one arrowhead; this opens the
vocabulary the same way shapes were opened:
- LinkSpec gains head/tail (pass-through to endArrow/startArrow, charset-
gated against style injection) and headFill/tailFill — fill is written
explicitly whenever a head is declared, because UML composition and
aggregation differ ONLY by fill and draw.io's per-head default would flip
the meaning. bold (4px amber, for THE key relationship) included.
- Parallel edges: a second link between the same pair is allowed when it
carries an id (ER's 'places' and 'cancels' between the same two entities);
without one it stays an error, since two identical overlapping lines is a
mistake. Edge ids are also what later operations address.
- Sequence messages respect a declared head (an async message's open arrow
is UML notation) while defaulting to the solid block as before.
- draw_graph's edge schema extended to match; GraphEdge passes the new
fields through to the link operations it generates.
5 new tests: crow's foot style emission, hollow-vs-filled round trip,
injection rejection, parallel-edge gating, bold round trip. 561 unit tests
green. ER+UML acceptance diagram (crow's foot, zero-to-one, hollow
inheritance triangle, filled composition diamond) verified in the real
editor.
2026-08-09 22:01:39 +09:00
|
|
|
|
// A bold link is a visual element, not a connector: thick amber with a filled
|
|
|
|
|
|
// block head — the "this becomes that" arrow of a comparison.
|
|
|
|
|
|
if (l.bold)
|
|
|
|
|
|
style +=
|
|
|
|
|
|
"strokeWidth=4;strokeColor=#D79B00;endArrow=block;endFill=1;endSize=6;"
|
|
|
|
|
|
// Arrowhead vocabulary, passed through to draw.io. Fill is written whenever
|
|
|
|
|
|
// the head is: UML composition vs aggregation differ ONLY by fill, so leaving
|
|
|
|
|
|
// it to draw.io's per-head default would flip the meaning.
|
|
|
|
|
|
if (l.head !== undefined)
|
|
|
|
|
|
style += `endArrow=${l.head};endFill=${l.headFill ? 1 : 0};`
|
|
|
|
|
|
if (l.tail !== undefined)
|
|
|
|
|
|
style += `startArrow=${l.tail};startFill=${l.tailFill ? 1 : 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
|
|
|
|
if (label) style += "labelBackgroundColor=light-dark(#FFFFFF,#0B0F14);"
|
|
|
|
|
|
}
|
feat(diagram-engine): edge router — pinned ports, obstacle avoidance, cost search
Fixes the reported problem: arrows overlapped on top of icons and ran through shapes
they had nothing to do with.
The cause was a missing layer, not a bug. render.ts emitted only source and target, so
draw.io routed every edge itself — and its router sees the two terminals' bounds and
nothing else, not where the other icons are. It therefore ran lines straight through
whatever was in between and left several edges leaving one node at the same point.
Ported from drawio-ai-kit's router (MIT, see NOTICE):
- Port de-collision. Edges leaving the same side of the same node spread along it,
ordered by where their far end sits so they do not cross on the way out. An edge
with a clean straight shot keeps the centre.
- Obstacle avoidance. Candidate shapes in order of directness — straight, a Z through
the gap, an L, a two-bend detour — each tested against every icon on the page.
- Frame placement. A frame is passable (an edge into a VPC must cross its border) but
not free: running alongside a border, or cutting through a frame that holds only one
of the two endpoints, is penalised.
- Port snapping. On a bent route, each port moves to the side its leg actually arrives
from. Without this the terminal segment can pierce the icon to reach a far-side port.
- Lane claiming. Each routed edge records the lanes it occupies so later edges avoid
them, rather than colliding and being pulled apart afterwards.
- Global nudge, three passes, reverting any move that makes a path worse.
Two things I got wrong on the way, both caught by looking at real geometry:
- The Z corridor was computed as min/max of both nodes' edges, which spans the whole
distance between them — including anything parked in between. So the lane sweep
would place the detour's middle leg on top of the very icon it was avoiding. It has
to be the gap: trailing edge of the first node to the leading edge of the other.
- The router was fed layout's slot rectangles, but an icon's cell is the glyph square
centred in a slot roughly twice as wide. Collision tests against slots both missed
real overlaps and invented false ones. Extracted cellRect() so the router and the
emitted XML cannot diverge.
Accept-or-reject was not enough on its own. When one endpoint is inside a VPC and the
other outside, EVERY path trespasses on that frame, so the strict rule always fails and
the relaxed pass took whatever it tried first — which is how a line ended up cutting
across a whole VPC. Candidates are now scored (frame offences 500, lane sharing 700,
bends 80, length 1) and the cheapest wins, so an edge that must trespass still gets the
least-bad route.
Waypoints are still written only when load-bearing — a labelled bend or a deliberate
detour — so an unobstructed edge stays drag-friendly.
455 unit tests (27 new, asserting produced geometry rather than algorithm shape).
Verified by rendering the reported diagram in the real editor: the two arrows that
overlapped on the EC2 icon now leave from different sides, and the load balancer's
fan-out leaves from three distinct points.
2026-08-09 12:46:44 +09:00
|
|
|
|
if (route)
|
|
|
|
|
|
style +=
|
|
|
|
|
|
`exitX=${route.exit.x};exitY=${route.exit.y};exitDx=0;exitDy=0;` +
|
|
|
|
|
|
`entryX=${route.entry.x};entryY=${route.entry.y};entryDx=0;entryDy=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
|
|
|
|
const id = l.id ?? `ed${index + 1}`
|
feat(diagram-engine): edge router — pinned ports, obstacle avoidance, cost search
Fixes the reported problem: arrows overlapped on top of icons and ran through shapes
they had nothing to do with.
The cause was a missing layer, not a bug. render.ts emitted only source and target, so
draw.io routed every edge itself — and its router sees the two terminals' bounds and
nothing else, not where the other icons are. It therefore ran lines straight through
whatever was in between and left several edges leaving one node at the same point.
Ported from drawio-ai-kit's router (MIT, see NOTICE):
- Port de-collision. Edges leaving the same side of the same node spread along it,
ordered by where their far end sits so they do not cross on the way out. An edge
with a clean straight shot keeps the centre.
- Obstacle avoidance. Candidate shapes in order of directness — straight, a Z through
the gap, an L, a two-bend detour — each tested against every icon on the page.
- Frame placement. A frame is passable (an edge into a VPC must cross its border) but
not free: running alongside a border, or cutting through a frame that holds only one
of the two endpoints, is penalised.
- Port snapping. On a bent route, each port moves to the side its leg actually arrives
from. Without this the terminal segment can pierce the icon to reach a far-side port.
- Lane claiming. Each routed edge records the lanes it occupies so later edges avoid
them, rather than colliding and being pulled apart afterwards.
- Global nudge, three passes, reverting any move that makes a path worse.
Two things I got wrong on the way, both caught by looking at real geometry:
- The Z corridor was computed as min/max of both nodes' edges, which spans the whole
distance between them — including anything parked in between. So the lane sweep
would place the detour's middle leg on top of the very icon it was avoiding. It has
to be the gap: trailing edge of the first node to the leading edge of the other.
- The router was fed layout's slot rectangles, but an icon's cell is the glyph square
centred in a slot roughly twice as wide. Collision tests against slots both missed
real overlaps and invented false ones. Extracted cellRect() so the router and the
emitted XML cannot diverge.
Accept-or-reject was not enough on its own. When one endpoint is inside a VPC and the
other outside, EVERY path trespasses on that frame, so the strict rule always fails and
the relaxed pass took whatever it tried first — which is how a line ended up cutting
across a whole VPC. Candidates are now scored (frame offences 500, lane sharing 700,
bends 80, length 1) and the cheapest wins, so an edge that must trespass still gets the
least-bad route.
Waypoints are still written only when load-bearing — a labelled bend or a deliberate
detour — so an unobstructed edge stays drag-friendly.
455 unit tests (27 new, asserting produced geometry rather than algorithm shape).
Verified by rendering the reported diagram in the real editor: the two arrows that
overlapped on the EC2 icon now leave from different sides, and the load balancer's
fan-out leaves from three distinct points.
2026-08-09 12:46:44 +09:00
|
|
|
|
const points =
|
|
|
|
|
|
route?.freeze && route.waypoints.length
|
|
|
|
|
|
? `<Array as="points">${route.waypoints
|
|
|
|
|
|
.map(
|
|
|
|
|
|
(p) =>
|
|
|
|
|
|
`<mxPoint x="${Math.round(p.x)}" y="${Math.round(p.y)}"/>`,
|
|
|
|
|
|
)
|
|
|
|
|
|
.join("")}</Array>`
|
|
|
|
|
|
: ""
|
feat(diagram-engine): label avoidance, paired opposite edges, semantic group colours
A git-workflow flowchart rendered with no overlaps but read poorly. Three
distinct causes, each fixed and measured:
1. Edge labels sat on boxes and on each other (4 collisions on the
reported diagram; 280 across 250 generated flowcharts). The router
keeps LINES off the boxes but a label renders at its edge's midpoint,
which on a long edge is beside exactly the things the line was routed
around. placeLabels slides each label along its own edge to a clear
spot — longest edges first, midpoint-outward tries — written as the
geometry's relative x, which draw.io natively supports. Corpus: 280
label collisions -> 8.
2. A->B and B->A were routed independently, so "git add" ran straight
while "git reset" wandered through a different corridor with a kink.
Opposite edges that agree on axis now get two absolute parallel tracks
in the strip where the two boxes overlap, a constant 24px apart,
converted back to port fractions. Zero crossing regressions.
3. All boxes rendered the same white, because the render layer's
fill/stroke support was never reachable: neither add_box's schema nor
draw_graph's nodes exposed it. Rather than exposing raw hex (the model
picks mismatched saturations, differently every time), nodes take a
semantic group name and the engine maps groups to a fixed palette of
six paired fill/strokes in order of first appearance. The model names
the zones - remote vs local vs temp - and never touches a colour.
532 unit tests pass; the 5 diagram e2e tests pass in a real browser.
2026-08-09 18:59:34 +09:00
|
|
|
|
// The geometry's x is the label's position along the path: −1 source, 0 middle, +1
|
|
|
|
|
|
// target. Written only when the label had to move off the midpoint to cover nothing.
|
|
|
|
|
|
const geo =
|
|
|
|
|
|
labelAt !== undefined
|
|
|
|
|
|
? `<mxGeometry x="${labelAt.toFixed(2)}" relative="1" as="geometry">${points}</mxGeometry>`
|
|
|
|
|
|
: `<mxGeometry relative="1" as="geometry">${points}</mxGeometry>`
|
feat(diagram-engine): layout + XML renderer, verified end to end in draw.io
Completes the tree → coordinates → XML direction, so the model can declare nesting
and never write a coordinate or an mxCell again.
layout.ts — measure bottom-up, place top-down, the same shape as flexbox. A
container sums its children along the flow axis and adds padding, so "child spills
out of its frame" and "siblings overlap" cannot happen by construction rather than
being caught afterwards. Slack from sibling equalisation is shared between children
instead of left as dead margin, capped at one gap so a stretched frame reads as
spaced rather than sparse.
render.ts — writes the mxCells, stamping container=1 and the dai_* markers so
parse.ts can read the structure back. Edges carry no waypoints: draw.io's own router
recomputes the route on every edit, so a user who moves a node never has to re-link
an arrow. Cells the parser could not interpret are re-emitted verbatim, so a
re-layout never deletes a user's annotations.
Phantoms are gone (task #5). The reference project's layout-only wrapper emits no
cell, which makes the round-trip lossy by construction — measured on its own
build_vpc.mjs, a phantom erased a container's "col" direction for good. An
unlabelled frame here emits a real cell with fillColor/strokeColor=none instead:
invisible, but present in the XML and therefore recoverable.
Two bugs the round-trip test caught, both real:
- An icon's cell was being emitted at its measured slot size, which includes room
for the label underneath. Parsing read that width back as the glyph size, so the
icon grew on every round-trip. The cell is now the glyph square and the label
renders outside it via verticalLabelPosition, as the reference does.
- An Azure or GCP icon is an embedded base64 image whose style contains no name
anywhere, so the catalog name was unrecoverable. Added a dai_name marker.
Verified in a real browser (3 Playwright tests, not mocks): engine output renders in
draw.io; dragging a shape into a frame makes draw.io rewrite its parent and the
engine reads the new structure back; re-laying out from that structure PRESERVES the
user's move instead of undoing it, and leaves untouched nodes alone; and the
re-laid-out XML still renders.
That last point is the whole design: there is no second copy of the state, so a
manual edit is an input to the next layout rather than a conflict to reconcile.
304 unit tests + 3 e2e.
2026-08-09 11:56:08 +09:00
|
|
|
|
return (
|
|
|
|
|
|
`<mxCell id="${esc(id)}" value="${esc(label)}" style="${style}" edge="1" parent="1"` +
|
|
|
|
|
|
` source="${esc(l.source)}" target="${esc(l.target)}">` +
|
feat(diagram-engine): label avoidance, paired opposite edges, semantic group colours
A git-workflow flowchart rendered with no overlaps but read poorly. Three
distinct causes, each fixed and measured:
1. Edge labels sat on boxes and on each other (4 collisions on the
reported diagram; 280 across 250 generated flowcharts). The router
keeps LINES off the boxes but a label renders at its edge's midpoint,
which on a long edge is beside exactly the things the line was routed
around. placeLabels slides each label along its own edge to a clear
spot — longest edges first, midpoint-outward tries — written as the
geometry's relative x, which draw.io natively supports. Corpus: 280
label collisions -> 8.
2. A->B and B->A were routed independently, so "git add" ran straight
while "git reset" wandered through a different corridor with a kink.
Opposite edges that agree on axis now get two absolute parallel tracks
in the strip where the two boxes overlap, a constant 24px apart,
converted back to port fractions. Zero crossing regressions.
3. All boxes rendered the same white, because the render layer's
fill/stroke support was never reachable: neither add_box's schema nor
draw_graph's nodes exposed it. Rather than exposing raw hex (the model
picks mismatched saturations, differently every time), nodes take a
semantic group name and the engine maps groups to a fixed palette of
six paired fill/strokes in order of first appearance. The model names
the zones - remote vs local vs temp - and never touches a colour.
532 unit tests pass; the 5 diagram e2e tests pass in a real browser.
2026-08-09 18:59:34 +09:00
|
|
|
|
geo +
|
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
|
|
|
|
`</mxCell>`
|
|
|
|
|
|
)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
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
|
|
|
|
/**
|
|
|
|
|
|
* One message of a sequence diagram: a horizontal arrow between two lifelines.
|
|
|
|
|
|
*
|
|
|
|
|
|
* Written with absolute endpoints rather than terminal references, because that is the only
|
|
|
|
|
|
* way to control the HEIGHT. A message's vertical position is its position in the
|
|
|
|
|
|
* conversation; if draw.io picked it, the reading order would be whatever the geometry
|
|
|
|
|
|
* happened to give. The source and target are still recorded, so the arrow follows a
|
|
|
|
|
|
* participant the user drags sideways and the parser can read the message back.
|
|
|
|
|
|
*
|
|
|
|
|
|
* A self-message — an object calling itself — cannot be a straight line, so it steps out to
|
|
|
|
|
|
* the right and comes back one row lower.
|
|
|
|
|
|
*/
|
|
|
|
|
|
function messageXml(
|
|
|
|
|
|
l: LinkSpec,
|
|
|
|
|
|
index: number,
|
|
|
|
|
|
y: number,
|
|
|
|
|
|
rects: Map<string, Rect>,
|
|
|
|
|
|
): string {
|
|
|
|
|
|
const a = rects.get(l.source)
|
|
|
|
|
|
const b = rects.get(l.target)
|
|
|
|
|
|
const centre = (r: Rect | undefined) => (r ? r.x + r.w / 2 : 0)
|
|
|
|
|
|
const from = centre(a)
|
|
|
|
|
|
const to = centre(b)
|
|
|
|
|
|
const self = l.source === l.target
|
|
|
|
|
|
let style = l.style ?? EDGE_STYLE
|
|
|
|
|
|
if (!l.style) {
|
feat(diagram-engine): connection vocabulary — arrowheads, parallel edges, edge ids
Arrowheads carry meaning: a crow's foot IS one-to-many, a hollow diamond IS
aggregation. The engine allowed exactly one arrowhead; this opens the
vocabulary the same way shapes were opened:
- LinkSpec gains head/tail (pass-through to endArrow/startArrow, charset-
gated against style injection) and headFill/tailFill — fill is written
explicitly whenever a head is declared, because UML composition and
aggregation differ ONLY by fill and draw.io's per-head default would flip
the meaning. bold (4px amber, for THE key relationship) included.
- Parallel edges: a second link between the same pair is allowed when it
carries an id (ER's 'places' and 'cancels' between the same two entities);
without one it stays an error, since two identical overlapping lines is a
mistake. Edge ids are also what later operations address.
- Sequence messages respect a declared head (an async message's open arrow
is UML notation) while defaulting to the solid block as before.
- draw_graph's edge schema extended to match; GraphEdge passes the new
fields through to the link operations it generates.
5 new tests: crow's foot style emission, hollow-vs-filled round trip,
injection rejection, parallel-edge gating, bold round trip. 561 unit tests
green. ER+UML acceptance diagram (crow's foot, zero-to-one, hollow
inheritance triangle, filled composition diamond) verified in the real
editor.
2026-08-09 22:01:39 +09:00
|
|
|
|
// The declared head wins over the sequence default: an async message drawn
|
|
|
|
|
|
// with an open arrow is UML notation, not decoration.
|
|
|
|
|
|
style +=
|
|
|
|
|
|
l.head !== undefined
|
|
|
|
|
|
? `endArrow=${l.head};endFill=${l.headFill ? 1 : 0};html=1;`
|
|
|
|
|
|
: "endArrow=block;endFill=1;html=1;"
|
|
|
|
|
|
if (l.tail !== undefined)
|
|
|
|
|
|
style += `startArrow=${l.tail};startFill=${l.tailFill ? 1 : 0};`
|
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
|
|
|
|
if (l.dashed) style += "dashed=1;"
|
|
|
|
|
|
style += "labelBackgroundColor=light-dark(#FFFFFF,#0B0F14);"
|
|
|
|
|
|
style += self ? "edgeStyle=orthogonalEdgeStyle;" : "edgeStyle=none;"
|
|
|
|
|
|
}
|
|
|
|
|
|
const id = l.id ?? `ed${index + 1}`
|
|
|
|
|
|
// A self-message loops out 40px and drops half a row, so it reads as one call and return.
|
|
|
|
|
|
const points = self
|
|
|
|
|
|
? `<Array as="points"><mxPoint x="${Math.round(from + 40)}" y="${Math.round(y)}"/>` +
|
|
|
|
|
|
`<mxPoint x="${Math.round(from + 40)}" y="${Math.round(y + 22)}"/></Array>`
|
|
|
|
|
|
: ""
|
|
|
|
|
|
const endY = self ? y + 22 : y
|
|
|
|
|
|
return (
|
|
|
|
|
|
`<mxCell id="${esc(id)}" value="${esc(edgeLabel(l))}" style="${style}" edge="1" parent="1"` +
|
|
|
|
|
|
` source="${esc(l.source)}" target="${esc(l.target)}">` +
|
|
|
|
|
|
`<mxGeometry relative="1" as="geometry">${points}` +
|
|
|
|
|
|
`<mxPoint x="${Math.round(from)}" y="${Math.round(y)}" as="sourcePoint"/>` +
|
|
|
|
|
|
`<mxPoint x="${Math.round(self ? from : to)}" y="${Math.round(endY)}" as="targetPoint"/>` +
|
|
|
|
|
|
`</mxGeometry></mxCell>`
|
|
|
|
|
|
)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
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
|
|
|
|
export interface RenderResult {
|
|
|
|
|
|
/** A complete `<mxfile>` document, ready for the editor. */
|
|
|
|
|
|
xml: string
|
|
|
|
|
|
page: { w: number; h: number }
|
|
|
|
|
|
/** Ids the links referenced that no node provides — these edges were dropped. */
|
|
|
|
|
|
danglingLinks: string[]
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* Render a tree to a complete draw.io document.
|
|
|
|
|
|
*
|
|
|
|
|
|
* Links whose endpoints do not exist are dropped rather than emitted: draw.io renders a
|
|
|
|
|
|
* dangling edge as an arrow floating in space, which looks like a bug in the diagram.
|
|
|
|
|
|
* The dropped ids are reported so the caller can tell the model what happened.
|
|
|
|
|
|
*/
|
|
|
|
|
|
export function renderDiagram(
|
|
|
|
|
|
tree: DiagramTree,
|
|
|
|
|
|
opts: RenderOptions = {},
|
|
|
|
|
|
): RenderResult {
|
|
|
|
|
|
const { roots, page } = layoutForest(tree.roots, {
|
|
|
|
|
|
iconSize: opts.iconSize,
|
|
|
|
|
|
gap: opts.rootGap,
|
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
|
|
|
|
links: tree.links,
|
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 flat = flatten(roots)
|
feat(diagram-engine): edge router — pinned ports, obstacle avoidance, cost search
Fixes the reported problem: arrows overlapped on top of icons and ran through shapes
they had nothing to do with.
The cause was a missing layer, not a bug. render.ts emitted only source and target, so
draw.io routed every edge itself — and its router sees the two terminals' bounds and
nothing else, not where the other icons are. It therefore ran lines straight through
whatever was in between and left several edges leaving one node at the same point.
Ported from drawio-ai-kit's router (MIT, see NOTICE):
- Port de-collision. Edges leaving the same side of the same node spread along it,
ordered by where their far end sits so they do not cross on the way out. An edge
with a clean straight shot keeps the centre.
- Obstacle avoidance. Candidate shapes in order of directness — straight, a Z through
the gap, an L, a two-bend detour — each tested against every icon on the page.
- Frame placement. A frame is passable (an edge into a VPC must cross its border) but
not free: running alongside a border, or cutting through a frame that holds only one
of the two endpoints, is penalised.
- Port snapping. On a bent route, each port moves to the side its leg actually arrives
from. Without this the terminal segment can pierce the icon to reach a far-side port.
- Lane claiming. Each routed edge records the lanes it occupies so later edges avoid
them, rather than colliding and being pulled apart afterwards.
- Global nudge, three passes, reverting any move that makes a path worse.
Two things I got wrong on the way, both caught by looking at real geometry:
- The Z corridor was computed as min/max of both nodes' edges, which spans the whole
distance between them — including anything parked in between. So the lane sweep
would place the detour's middle leg on top of the very icon it was avoiding. It has
to be the gap: trailing edge of the first node to the leading edge of the other.
- The router was fed layout's slot rectangles, but an icon's cell is the glyph square
centred in a slot roughly twice as wide. Collision tests against slots both missed
real overlaps and invented false ones. Extracted cellRect() so the router and the
emitted XML cannot diverge.
Accept-or-reject was not enough on its own. When one endpoint is inside a VPC and the
other outside, EVERY path trespasses on that frame, so the strict rule always fails and
the relaxed pass took whatever it tried first — which is how a line ended up cutting
across a whole VPC. Candidates are now scored (frame offences 500, lane sharing 700,
bends 80, length 1) and the cheapest wins, so an edge that must trespass still gets the
least-bad route.
Waypoints are still written only when load-bearing — a labelled bend or a deliberate
detour — so an unobstructed edge stays drag-friendly.
455 unit tests (27 new, asserting produced geometry rather than algorithm shape).
Verified by rendering the reported diagram in the real editor: the two arrows that
overlapped on the EC2 icon now leave from different sides, and the load balancer's
fan-out leaves from three distinct points.
2026-08-09 12:46:44 +09:00
|
|
|
|
const glyph = opts.iconSize ?? ICON_SIZE
|
|
|
|
|
|
// Slot rectangles, for positioning children relative to their parent.
|
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 rectById = new Map<string, Rect>()
|
|
|
|
|
|
for (const f of flat) rectById.set(f.node.id, f.rect)
|
feat(diagram-engine): edge router — pinned ports, obstacle avoidance, cost search
Fixes the reported problem: arrows overlapped on top of icons and ran through shapes
they had nothing to do with.
The cause was a missing layer, not a bug. render.ts emitted only source and target, so
draw.io routed every edge itself — and its router sees the two terminals' bounds and
nothing else, not where the other icons are. It therefore ran lines straight through
whatever was in between and left several edges leaving one node at the same point.
Ported from drawio-ai-kit's router (MIT, see NOTICE):
- Port de-collision. Edges leaving the same side of the same node spread along it,
ordered by where their far end sits so they do not cross on the way out. An edge
with a clean straight shot keeps the centre.
- Obstacle avoidance. Candidate shapes in order of directness — straight, a Z through
the gap, an L, a two-bend detour — each tested against every icon on the page.
- Frame placement. A frame is passable (an edge into a VPC must cross its border) but
not free: running alongside a border, or cutting through a frame that holds only one
of the two endpoints, is penalised.
- Port snapping. On a bent route, each port moves to the side its leg actually arrives
from. Without this the terminal segment can pierce the icon to reach a far-side port.
- Lane claiming. Each routed edge records the lanes it occupies so later edges avoid
them, rather than colliding and being pulled apart afterwards.
- Global nudge, three passes, reverting any move that makes a path worse.
Two things I got wrong on the way, both caught by looking at real geometry:
- The Z corridor was computed as min/max of both nodes' edges, which spans the whole
distance between them — including anything parked in between. So the lane sweep
would place the detour's middle leg on top of the very icon it was avoiding. It has
to be the gap: trailing edge of the first node to the leading edge of the other.
- The router was fed layout's slot rectangles, but an icon's cell is the glyph square
centred in a slot roughly twice as wide. Collision tests against slots both missed
real overlaps and invented false ones. Extracted cellRect() so the router and the
emitted XML cannot diverge.
Accept-or-reject was not enough on its own. When one endpoint is inside a VPC and the
other outside, EVERY path trespasses on that frame, so the strict rule always fails and
the relaxed pass took whatever it tried first — which is how a line ended up cutting
across a whole VPC. Candidates are now scored (frame offences 500, lane sharing 700,
bends 80, length 1) and the cheapest wins, so an edge that must trespass still gets the
least-bad route.
Waypoints are still written only when load-bearing — a labelled bend or a deliberate
detour — so an unobstructed edge stays drag-friendly.
455 unit tests (27 new, asserting produced geometry rather than algorithm shape).
Verified by rendering the reported diagram in the real editor: the two arrows that
overlapped on the EC2 icon now leave from different sides, and the load balancer's
fan-out leaves from three distinct points.
2026-08-09 12:46:44 +09:00
|
|
|
|
// Emitted-cell rectangles, which is what the router must see.
|
|
|
|
|
|
const cellById = new Map<string, Rect>()
|
|
|
|
|
|
for (const f of flat)
|
|
|
|
|
|
cellById.set(f.node.id, cellRect(f.node, f.rect, glyph))
|
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 cells: string[] = []
|
|
|
|
|
|
|
|
|
|
|
|
// Title spans the page width, above the content.
|
|
|
|
|
|
if (tree.title)
|
|
|
|
|
|
cells.push(
|
|
|
|
|
|
`<mxCell id="__title" value="${esc(tree.title)}" style="${TITLE_STYLE}" vertex="1" parent="1">` +
|
|
|
|
|
|
`<mxGeometry x="0" y="24" width="${page.w}" height="30" as="geometry"/></mxCell>`,
|
|
|
|
|
|
)
|
|
|
|
|
|
|
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
|
|
|
|
// A pool's children are parented to its lane BANDS, not to the pool: that is what
|
|
|
|
|
|
// records the role assignment when the user drags a step to another lane.
|
|
|
|
|
|
const bandOf = new Map<string, Rect & { id: string }>()
|
|
|
|
|
|
// Participants a sequence container emits as lifelines instead of ordinary cells.
|
|
|
|
|
|
const asLifeline = new Set<string>()
|
|
|
|
|
|
// Message y-positions per sequence container, so its arrows can be pinned to a height.
|
|
|
|
|
|
const messageYOf = new Map<string, (step: number) => number>()
|
|
|
|
|
|
// Chrome cells, keyed by the container they belong to so they can be emitted just after
|
|
|
|
|
|
// it — a band has to exist before the node that names it as parent.
|
|
|
|
|
|
const chrome = new Map<string, string[]>()
|
|
|
|
|
|
|
|
|
|
|
|
for (const f of flat) {
|
|
|
|
|
|
const n = f.node
|
|
|
|
|
|
if (n.kind === "pool") {
|
|
|
|
|
|
const kids = n.children
|
|
|
|
|
|
.map((c) => rectById.get(c.id))
|
|
|
|
|
|
.filter((r): r is Rect => r !== undefined)
|
|
|
|
|
|
.map((rect) => ({ rect }))
|
|
|
|
|
|
const { xml, bands } = poolChrome(n, f.rect, kids)
|
|
|
|
|
|
chrome.set(n.id, xml)
|
|
|
|
|
|
for (const c of n.children) {
|
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
|
|
|
|
const band =
|
|
|
|
|
|
bands[Math.min(poolCellOf(c).lane, bands.length - 1)]
|
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
|
|
|
|
if (band) bandOf.set(c.id, { ...band.rect, id: band.id })
|
|
|
|
|
|
}
|
|
|
|
|
|
} else if (n.kind === "sequence") {
|
|
|
|
|
|
const kids = n.children
|
|
|
|
|
|
.map((c) => ({ node: c, rect: rectById.get(c.id) }))
|
|
|
|
|
|
.filter(
|
|
|
|
|
|
(k): k is { node: DiagramNode; rect: Rect } =>
|
|
|
|
|
|
k.rect !== undefined,
|
|
|
|
|
|
)
|
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
|
|
|
|
// One metrics call for both the lifeline heights and the message positions:
|
|
|
|
|
|
// computing it twice is how the two would drift apart.
|
|
|
|
|
|
const metrics = sequenceMetrics(
|
|
|
|
|
|
n,
|
|
|
|
|
|
f.rect,
|
|
|
|
|
|
messageCount(n, tree.links),
|
|
|
|
|
|
)
|
|
|
|
|
|
chrome.set(n.id, sequenceChrome(n, f.rect, kids, metrics))
|
|
|
|
|
|
for (const k of kids) asLifeline.add(k.node.id)
|
|
|
|
|
|
messageYOf.set(n.id, metrics.messageY)
|
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
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
feat(diagram-engine): design tokens + role/group composition, engine-wide theming
Paper-summary posters previously required hand-written XML: every engine
box rendered identically (white, 11px), so anything whose meaning lives
in visual hierarchy came out flat. This makes presentation a first-class,
generalised part of the declaration - not a poster feature.
Structure/presentation separation, the same split HTML and CSS settled on:
- ROLE says what a node IS: banner, heading, body, callout, good, bad,
metric, muted. Maps to a type scale and an emphasis (filled / tinted /
outlined / ghost), never to a colour.
- GROUP says which semantic zone a node belongs to. Each distinct group
name gets one hue ramp (tint / base / dark), assigned in document
order. Promoted from a draw_graph-only field to BoxNode and GroupNode,
round-tripped via dai_group.
- themedStyle(role, hue, kind) composes the two by rule - there is no
per-combination table to extend, so a new diagram kind gets full
theming by tagging nodes. The model never sees a hex value.
A heading container plus a group yields the tinted section panel with a
dark title; a grouped body box takes its zone's tint; verdict roles stay
green/red regardless of zone; the banner is the page's one dark field.
Also fixed, found while building the acceptance poster:
- autoBoxSize only counted explicit newlines, so a long single-line label
wrapped to six lines in draw.io but got a one-line-tall box, and the
text overflowed the cell.
- Marker stamping appended without replacing, so every render of a
recovered style grew it by one duplicate dai_* token per key -
unnoticed because draw.io resolves duplicates last-wins. dai_* keys
are now replaced in place; mxGraph keys still append, because
last-wins is load-bearing for container=1 normalisation.
- Banner/heading/metric roles stretch across their container's cross
axis, the way a masthead spans its page.
- Prompt: a poster's banner IS its title (no set_title alongside), and
sections get their colour by naming groups.
537 unit tests, 250-flowchart corpus still zero crossing arrows, 5 e2e
tests in a real browser. Verified visually: the Transformer-paper poster
renders with a navy masthead, three hue-coded section panels, metric,
verdict and callout boxes - all engine-computed geometry.
2026-08-09 19:48:01 +09:00
|
|
|
|
// Groups become hues here, in document order, so "the second zone named is green"
|
|
|
|
|
|
// holds for every diagram the engine draws. The caller only ever names zones.
|
|
|
|
|
|
const groupIndex = new Map<string, number>()
|
|
|
|
|
|
for (const f of flat) {
|
|
|
|
|
|
const g =
|
|
|
|
|
|
f.node.kind === "box" || f.node.kind === "group"
|
|
|
|
|
|
? f.node.group
|
|
|
|
|
|
: undefined
|
|
|
|
|
|
if (g && !groupIndex.has(g)) groupIndex.set(g, groupIndex.size)
|
|
|
|
|
|
}
|
|
|
|
|
|
const hue: HueResolver = (g) =>
|
|
|
|
|
|
g !== undefined && groupIndex.has(g)
|
|
|
|
|
|
? hueOf(groupIndex.get(g) as number)
|
|
|
|
|
|
: NEUTRAL
|
|
|
|
|
|
|
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
|
|
|
|
// Parents come before children (flatten guarantees it), which draw.io requires.
|
|
|
|
|
|
for (const f of flat) {
|
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
|
|
|
|
// A lifeline cell already carries its participant's label and geometry.
|
|
|
|
|
|
if (asLifeline.has(f.node.id)) continue
|
|
|
|
|
|
const band = bandOf.get(f.node.id)
|
|
|
|
|
|
const parent = band?.id ?? f.parent
|
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 parentRect =
|
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
|
|
|
|
band ?? (parent === "1" ? null : (rectById.get(parent) ?? null))
|
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
|
|
|
|
cells.push(
|
|
|
|
|
|
vertexXml(
|
|
|
|
|
|
f.node,
|
|
|
|
|
|
f.rect,
|
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
|
|
|
|
parent,
|
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
|
|
|
|
parentRect,
|
|
|
|
|
|
opts.resolveStyle,
|
|
|
|
|
|
glyph,
|
feat(diagram-engine): design tokens + role/group composition, engine-wide theming
Paper-summary posters previously required hand-written XML: every engine
box rendered identically (white, 11px), so anything whose meaning lives
in visual hierarchy came out flat. This makes presentation a first-class,
generalised part of the declaration - not a poster feature.
Structure/presentation separation, the same split HTML and CSS settled on:
- ROLE says what a node IS: banner, heading, body, callout, good, bad,
metric, muted. Maps to a type scale and an emphasis (filled / tinted /
outlined / ghost), never to a colour.
- GROUP says which semantic zone a node belongs to. Each distinct group
name gets one hue ramp (tint / base / dark), assigned in document
order. Promoted from a draw_graph-only field to BoxNode and GroupNode,
round-tripped via dai_group.
- themedStyle(role, hue, kind) composes the two by rule - there is no
per-combination table to extend, so a new diagram kind gets full
theming by tagging nodes. The model never sees a hex value.
A heading container plus a group yields the tinted section panel with a
dark title; a grouped body box takes its zone's tint; verdict roles stay
green/red regardless of zone; the banner is the page's one dark field.
Also fixed, found while building the acceptance poster:
- autoBoxSize only counted explicit newlines, so a long single-line label
wrapped to six lines in draw.io but got a one-line-tall box, and the
text overflowed the cell.
- Marker stamping appended without replacing, so every render of a
recovered style grew it by one duplicate dai_* token per key -
unnoticed because draw.io resolves duplicates last-wins. dai_* keys
are now replaced in place; mxGraph keys still append, because
last-wins is load-bearing for container=1 normalisation.
- Banner/heading/metric roles stretch across their container's cross
axis, the way a masthead spans its page.
- Prompt: a poster's banner IS its title (no set_title alongside), and
sections get their colour by naming groups.
537 unit tests, 250-flowchart corpus still zero crossing arrows, 5 e2e
tests in a real browser. Verified visually: the Transformer-paper poster
renders with a navy masthead, three hue-coded section panels, metric,
verdict and callout boxes - all engine-computed geometry.
2026-08-09 19:48:01 +09:00
|
|
|
|
hue,
|
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
|
|
|
|
),
|
|
|
|
|
|
)
|
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 own = chrome.get(f.node.id)
|
|
|
|
|
|
if (own) cells.push(...own)
|
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
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Cells the parser could not interpret — user annotations, imported shapes — go back
|
|
|
|
|
|
// verbatim. A re-layout must not delete work the engine does not understand.
|
|
|
|
|
|
const foreignLayer = tree.foreign.some((c) => c.parent === "boundaries")
|
|
|
|
|
|
if (foreignLayer)
|
|
|
|
|
|
cells.push(
|
|
|
|
|
|
`<mxCell id="boundaries" value="Boundaries (locked)" parent="0" style="locked=1;"/>`,
|
|
|
|
|
|
)
|
|
|
|
|
|
for (const c of tree.foreign) cells.push(c.xml)
|
|
|
|
|
|
|
|
|
|
|
|
const known = new Set(flat.map((f) => f.node.id))
|
|
|
|
|
|
for (const c of tree.foreign) known.add(c.id)
|
|
|
|
|
|
const dangling: string[] = []
|
feat(diagram-engine): edge router — pinned ports, obstacle avoidance, cost search
Fixes the reported problem: arrows overlapped on top of icons and ran through shapes
they had nothing to do with.
The cause was a missing layer, not a bug. render.ts emitted only source and target, so
draw.io routed every edge itself — and its router sees the two terminals' bounds and
nothing else, not where the other icons are. It therefore ran lines straight through
whatever was in between and left several edges leaving one node at the same point.
Ported from drawio-ai-kit's router (MIT, see NOTICE):
- Port de-collision. Edges leaving the same side of the same node spread along it,
ordered by where their far end sits so they do not cross on the way out. An edge
with a clean straight shot keeps the centre.
- Obstacle avoidance. Candidate shapes in order of directness — straight, a Z through
the gap, an L, a two-bend detour — each tested against every icon on the page.
- Frame placement. A frame is passable (an edge into a VPC must cross its border) but
not free: running alongside a border, or cutting through a frame that holds only one
of the two endpoints, is penalised.
- Port snapping. On a bent route, each port moves to the side its leg actually arrives
from. Without this the terminal segment can pierce the icon to reach a far-side port.
- Lane claiming. Each routed edge records the lanes it occupies so later edges avoid
them, rather than colliding and being pulled apart afterwards.
- Global nudge, three passes, reverting any move that makes a path worse.
Two things I got wrong on the way, both caught by looking at real geometry:
- The Z corridor was computed as min/max of both nodes' edges, which spans the whole
distance between them — including anything parked in between. So the lane sweep
would place the detour's middle leg on top of the very icon it was avoiding. It has
to be the gap: trailing edge of the first node to the leading edge of the other.
- The router was fed layout's slot rectangles, but an icon's cell is the glyph square
centred in a slot roughly twice as wide. Collision tests against slots both missed
real overlaps and invented false ones. Extracted cellRect() so the router and the
emitted XML cannot diverge.
Accept-or-reject was not enough on its own. When one endpoint is inside a VPC and the
other outside, EVERY path trespasses on that frame, so the strict rule always fails and
the relaxed pass took whatever it tried first — which is how a line ended up cutting
across a whole VPC. Candidates are now scored (frame offences 500, lane sharing 700,
bends 80, length 1) and the cheapest wins, so an edge that must trespass still gets the
least-bad route.
Waypoints are still written only when load-bearing — a labelled bend or a deliberate
detour — so an unobstructed edge stays drag-friendly.
455 unit tests (27 new, asserting produced geometry rather than algorithm shape).
Verified by rendering the reported diagram in the real editor: the two arrows that
overlapped on the EC2 icon now leave from different sides, and the load balancer's
fan-out leaves from three distinct points.
2026-08-09 12:46:44 +09:00
|
|
|
|
const drawable: LinkSpec[] = []
|
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
|
|
|
|
for (const l of tree.links) {
|
|
|
|
|
|
if (!known.has(l.source) || !known.has(l.target)) {
|
|
|
|
|
|
if (!known.has(l.source)) dangling.push(l.source)
|
|
|
|
|
|
if (!known.has(l.target)) dangling.push(l.target)
|
|
|
|
|
|
continue
|
|
|
|
|
|
}
|
feat(diagram-engine): edge router — pinned ports, obstacle avoidance, cost search
Fixes the reported problem: arrows overlapped on top of icons and ran through shapes
they had nothing to do with.
The cause was a missing layer, not a bug. render.ts emitted only source and target, so
draw.io routed every edge itself — and its router sees the two terminals' bounds and
nothing else, not where the other icons are. It therefore ran lines straight through
whatever was in between and left several edges leaving one node at the same point.
Ported from drawio-ai-kit's router (MIT, see NOTICE):
- Port de-collision. Edges leaving the same side of the same node spread along it,
ordered by where their far end sits so they do not cross on the way out. An edge
with a clean straight shot keeps the centre.
- Obstacle avoidance. Candidate shapes in order of directness — straight, a Z through
the gap, an L, a two-bend detour — each tested against every icon on the page.
- Frame placement. A frame is passable (an edge into a VPC must cross its border) but
not free: running alongside a border, or cutting through a frame that holds only one
of the two endpoints, is penalised.
- Port snapping. On a bent route, each port moves to the side its leg actually arrives
from. Without this the terminal segment can pierce the icon to reach a far-side port.
- Lane claiming. Each routed edge records the lanes it occupies so later edges avoid
them, rather than colliding and being pulled apart afterwards.
- Global nudge, three passes, reverting any move that makes a path worse.
Two things I got wrong on the way, both caught by looking at real geometry:
- The Z corridor was computed as min/max of both nodes' edges, which spans the whole
distance between them — including anything parked in between. So the lane sweep
would place the detour's middle leg on top of the very icon it was avoiding. It has
to be the gap: trailing edge of the first node to the leading edge of the other.
- The router was fed layout's slot rectangles, but an icon's cell is the glyph square
centred in a slot roughly twice as wide. Collision tests against slots both missed
real overlaps and invented false ones. Extracted cellRect() so the router and the
emitted XML cannot diverge.
Accept-or-reject was not enough on its own. When one endpoint is inside a VPC and the
other outside, EVERY path trespasses on that frame, so the strict rule always fails and
the relaxed pass took whatever it tried first — which is how a line ended up cutting
across a whole VPC. Candidates are now scored (frame offences 500, lane sharing 700,
bends 80, length 1) and the cheapest wins, so an edge that must trespass still gets the
least-bad route.
Waypoints are still written only when load-bearing — a labelled bend or a deliberate
detour — so an unobstructed edge stays drag-friendly.
455 unit tests (27 new, asserting produced geometry rather than algorithm shape).
Verified by rendering the reported diagram in the real editor: the two arrows that
overlapped on the EC2 icon now leave from different sides, and the load balancer's
fan-out leaves from three distinct points.
2026-08-09 12:46:44 +09:00
|
|
|
|
drawable.push(l)
|
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
|
|
|
|
}
|
|
|
|
|
|
|
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
|
|
|
|
// A message between two participants of the same sequence container is a horizontal
|
|
|
|
|
|
// arrow at a fixed height, so it bypasses the router entirely: there is nothing to route
|
|
|
|
|
|
// around, and the height is the message's ORDER, which a router is not allowed to move.
|
|
|
|
|
|
const seqOwner = new Map<string, string>()
|
|
|
|
|
|
for (const f of flat)
|
|
|
|
|
|
if (f.node.kind === "sequence")
|
|
|
|
|
|
for (const c of f.node.children) seqOwner.set(c.id, f.node.id)
|
|
|
|
|
|
|
|
|
|
|
|
const messages: { link: LinkSpec; index: number; y: number }[] = []
|
|
|
|
|
|
const routable: { link: LinkSpec; index: number }[] = []
|
|
|
|
|
|
// Fallback numbering is per container: a page with two sequence diagrams on it must not
|
|
|
|
|
|
// have the second one's messages continue the first one's count, which would push them
|
|
|
|
|
|
// below the bottom of their own lifelines.
|
|
|
|
|
|
const autoStep = new Map<string, number>()
|
|
|
|
|
|
for (const [i, l] of drawable.entries()) {
|
|
|
|
|
|
const owner = seqOwner.get(l.source)
|
|
|
|
|
|
const yOf =
|
|
|
|
|
|
owner && owner === seqOwner.get(l.target)
|
|
|
|
|
|
? messageYOf.get(owner)
|
|
|
|
|
|
: undefined
|
|
|
|
|
|
if (yOf && owner) {
|
|
|
|
|
|
const next = (autoStep.get(owner) ?? 0) + 1
|
|
|
|
|
|
autoStep.set(owner, next)
|
|
|
|
|
|
messages.push({ link: l, index: i, y: yOf(l.step ?? next) })
|
|
|
|
|
|
} else {
|
|
|
|
|
|
routable.push({ link: l, index: i })
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
feat(diagram-engine): edge router — pinned ports, obstacle avoidance, cost search
Fixes the reported problem: arrows overlapped on top of icons and ran through shapes
they had nothing to do with.
The cause was a missing layer, not a bug. render.ts emitted only source and target, so
draw.io routed every edge itself — and its router sees the two terminals' bounds and
nothing else, not where the other icons are. It therefore ran lines straight through
whatever was in between and left several edges leaving one node at the same point.
Ported from drawio-ai-kit's router (MIT, see NOTICE):
- Port de-collision. Edges leaving the same side of the same node spread along it,
ordered by where their far end sits so they do not cross on the way out. An edge
with a clean straight shot keeps the centre.
- Obstacle avoidance. Candidate shapes in order of directness — straight, a Z through
the gap, an L, a two-bend detour — each tested against every icon on the page.
- Frame placement. A frame is passable (an edge into a VPC must cross its border) but
not free: running alongside a border, or cutting through a frame that holds only one
of the two endpoints, is penalised.
- Port snapping. On a bent route, each port moves to the side its leg actually arrives
from. Without this the terminal segment can pierce the icon to reach a far-side port.
- Lane claiming. Each routed edge records the lanes it occupies so later edges avoid
them, rather than colliding and being pulled apart afterwards.
- Global nudge, three passes, reverting any move that makes a path worse.
Two things I got wrong on the way, both caught by looking at real geometry:
- The Z corridor was computed as min/max of both nodes' edges, which spans the whole
distance between them — including anything parked in between. So the lane sweep
would place the detour's middle leg on top of the very icon it was avoiding. It has
to be the gap: trailing edge of the first node to the leading edge of the other.
- The router was fed layout's slot rectangles, but an icon's cell is the glyph square
centred in a slot roughly twice as wide. Collision tests against slots both missed
real overlaps and invented false ones. Extracted cellRect() so the router and the
emitted XML cannot diverge.
Accept-or-reject was not enough on its own. When one endpoint is inside a VPC and the
other outside, EVERY path trespasses on that frame, so the strict rule always fails and
the relaxed pass took whatever it tried first — which is how a line ended up cutting
across a whole VPC. Candidates are now scored (frame offences 500, lane sharing 700,
bends 80, length 1) and the cheapest wins, so an edge that must trespass still gets the
least-bad route.
Waypoints are still written only when load-bearing — a labelled bend or a deliberate
detour — so an unobstructed edge stays drag-friendly.
455 unit tests (27 new, asserting produced geometry rather than algorithm shape).
Verified by rendering the reported diagram in the real editor: the two arrows that
overlapped on the EC2 icon now leave from different sides, and the load balancer's
fan-out leaves from three distinct points.
2026-08-09 12:46:44 +09:00
|
|
|
|
// Route with the whole page in view. Only leaf shapes are obstacles: an edge from
|
|
|
|
|
|
// outside a VPC to something inside it has to cross the VPC's border, so a container
|
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
|
|
|
|
// frame must not block it. Lifelines are excluded too: a message's whole job is to run
|
|
|
|
|
|
// from one lifeline to another, and every message crosses whatever lifelines lie between.
|
feat(diagram-engine): edge router — pinned ports, obstacle avoidance, cost search
Fixes the reported problem: arrows overlapped on top of icons and ran through shapes
they had nothing to do with.
The cause was a missing layer, not a bug. render.ts emitted only source and target, so
draw.io routed every edge itself — and its router sees the two terminals' bounds and
nothing else, not where the other icons are. It therefore ran lines straight through
whatever was in between and left several edges leaving one node at the same point.
Ported from drawio-ai-kit's router (MIT, see NOTICE):
- Port de-collision. Edges leaving the same side of the same node spread along it,
ordered by where their far end sits so they do not cross on the way out. An edge
with a clean straight shot keeps the centre.
- Obstacle avoidance. Candidate shapes in order of directness — straight, a Z through
the gap, an L, a two-bend detour — each tested against every icon on the page.
- Frame placement. A frame is passable (an edge into a VPC must cross its border) but
not free: running alongside a border, or cutting through a frame that holds only one
of the two endpoints, is penalised.
- Port snapping. On a bent route, each port moves to the side its leg actually arrives
from. Without this the terminal segment can pierce the icon to reach a far-side port.
- Lane claiming. Each routed edge records the lanes it occupies so later edges avoid
them, rather than colliding and being pulled apart afterwards.
- Global nudge, three passes, reverting any move that makes a path worse.
Two things I got wrong on the way, both caught by looking at real geometry:
- The Z corridor was computed as min/max of both nodes' edges, which spans the whole
distance between them — including anything parked in between. So the lane sweep
would place the detour's middle leg on top of the very icon it was avoiding. It has
to be the gap: trailing edge of the first node to the leading edge of the other.
- The router was fed layout's slot rectangles, but an icon's cell is the glyph square
centred in a slot roughly twice as wide. Collision tests against slots both missed
real overlaps and invented false ones. Extracted cellRect() so the router and the
emitted XML cannot diverge.
Accept-or-reject was not enough on its own. When one endpoint is inside a VPC and the
other outside, EVERY path trespasses on that frame, so the strict rule always fails and
the relaxed pass took whatever it tried first — which is how a line ended up cutting
across a whole VPC. Candidates are now scored (frame offences 500, lane sharing 700,
bends 80, length 1) and the cheapest wins, so an edge that must trespass still gets the
least-bad route.
Waypoints are still written only when load-bearing — a labelled bend or a deliberate
detour — so an unobstructed edge stays drag-friendly.
455 unit tests (27 new, asserting produced geometry rather than algorithm shape).
Verified by rendering the reported diagram in the real editor: the two arrows that
overlapped on the EC2 icon now leave from different sides, and the load balancer's
fan-out leaves from three distinct points.
2026-08-09 12:46:44 +09:00
|
|
|
|
const obstacles = new Set(
|
|
|
|
|
|
flat
|
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
|
|
|
|
.filter(
|
|
|
|
|
|
(f) =>
|
|
|
|
|
|
(f.node.kind === "icon" || f.node.kind === "box") &&
|
|
|
|
|
|
!asLifeline.has(f.node.id),
|
|
|
|
|
|
)
|
feat(diagram-engine): edge router — pinned ports, obstacle avoidance, cost search
Fixes the reported problem: arrows overlapped on top of icons and ran through shapes
they had nothing to do with.
The cause was a missing layer, not a bug. render.ts emitted only source and target, so
draw.io routed every edge itself — and its router sees the two terminals' bounds and
nothing else, not where the other icons are. It therefore ran lines straight through
whatever was in between and left several edges leaving one node at the same point.
Ported from drawio-ai-kit's router (MIT, see NOTICE):
- Port de-collision. Edges leaving the same side of the same node spread along it,
ordered by where their far end sits so they do not cross on the way out. An edge
with a clean straight shot keeps the centre.
- Obstacle avoidance. Candidate shapes in order of directness — straight, a Z through
the gap, an L, a two-bend detour — each tested against every icon on the page.
- Frame placement. A frame is passable (an edge into a VPC must cross its border) but
not free: running alongside a border, or cutting through a frame that holds only one
of the two endpoints, is penalised.
- Port snapping. On a bent route, each port moves to the side its leg actually arrives
from. Without this the terminal segment can pierce the icon to reach a far-side port.
- Lane claiming. Each routed edge records the lanes it occupies so later edges avoid
them, rather than colliding and being pulled apart afterwards.
- Global nudge, three passes, reverting any move that makes a path worse.
Two things I got wrong on the way, both caught by looking at real geometry:
- The Z corridor was computed as min/max of both nodes' edges, which spans the whole
distance between them — including anything parked in between. So the lane sweep
would place the detour's middle leg on top of the very icon it was avoiding. It has
to be the gap: trailing edge of the first node to the leading edge of the other.
- The router was fed layout's slot rectangles, but an icon's cell is the glyph square
centred in a slot roughly twice as wide. Collision tests against slots both missed
real overlaps and invented false ones. Extracted cellRect() so the router and the
emitted XML cannot diverge.
Accept-or-reject was not enough on its own. When one endpoint is inside a VPC and the
other outside, EVERY path trespasses on that frame, so the strict rule always fails and
the relaxed pass took whatever it tried first — which is how a line ended up cutting
across a whole VPC. Candidates are now scored (frame offences 500, lane sharing 700,
bends 80, length 1) and the cheapest wins, so an edge that must trespass still gets the
least-bad route.
Waypoints are still written only when load-bearing — a labelled bend or a deliberate
detour — so an unobstructed edge stays drag-friendly.
455 unit tests (27 new, asserting produced geometry rather than algorithm shape).
Verified by rendering the reported diagram in the real editor: the two arrows that
overlapped on the EC2 icon now leave from different sides, and the load balancer's
fan-out leaves from three distinct points.
2026-08-09 12:46:44 +09:00
|
|
|
|
.map((f) => f.node.id),
|
|
|
|
|
|
)
|
|
|
|
|
|
// Frames are passable but not free to ignore: a line that runs alongside a border, or
|
|
|
|
|
|
// cuts through a frame only one of its endpoints belongs to, reads as a mistake even
|
|
|
|
|
|
// though it hits nothing.
|
fix(diagram-engine): eliminate arrows drawn through boxes, complete the route search
A user's approval-workflow flowchart came out with the return arrow drawn
straight through two unrelated steps, and a second report showed arrows
leaving a box and bending straight back across it. Measured over 250
generated flowcharts (2722 edges): 347 arrows crossed an unrelated box and
215 waypoints landed inside a shape. Four defects, each measured in
isolation:
1. The invisible layer containers draw_graph emits were handed to the
router as frames, so every clean return path was rejected for
"trespassing" on a border that is not drawn, and the fallback cut
through two boxes. Excluding invisible containers: 347 -> 218 crossing
arrows, no diagram made worse.
2. The router chose the horizontal-vs-vertical axis BEFORE searching, so
when the only clean corridor ran along the other axis it was never
looked at. A complete two-bend candidate generator that tries both
trunk axes, all four sides at each end, and the port fractions:
218 -> 27. (An independent ablation measured the axis pre-choice alone
at a 40% per-edge failure rate.)
3. Nothing stopped a route's first leg from turning back across its own
source shape - the obstacle test exempts an edge's own endpoints, and
must, since the line has to touch them. A terminal-leg rule refuses
such routes outright: 215 -> 4 hooks.
4. A two-bend search cannot express the staircase needed when a box sits
directly between two vertically aligned nodes (21 of the last 27
crossings). Added the orthogonal visibility graph + A* from Wybrow,
Marriott & Stuckey, "Orthogonal Connector Routing" (GD 2009) - the
libavoid algorithm - as the backstop when the candidate search finds
nothing. The interesting-points grid is provably sufficient: any valid
route shrinks onto it without getting longer or gaining bends. The A*
state is (point, incoming direction) with libavoid's bend cost of 10,
and the admissible bends-remaining heuristic, so it returns a cheapest
route, not merely a route. Implemented from the paper, not ported.
After all four: 0 crossing arrows and 0 hooks over the same 250 diagrams,
page area unchanged (494k px^2 mean), 260ms for the whole corpus, mean
0.82 bends per edge. The shape ladder still runs first, so routes that
were already clean are byte-identical.
Also post-nudge validation now checks the whole path (the nudge pass only
reverts the single segment it moved, judged in isolation) and restores the
search's route if nudging made it dirty.
2026-08-09 18:18:54 +09:00
|
|
|
|
//
|
|
|
|
|
|
// An INVISIBLE container is excluded, because both of those judgements are about what a
|
|
|
|
|
|
// reader sees, and there is no border on screen to run alongside or to trespass across.
|
|
|
|
|
|
// A layer band in a flowchart is exactly that: `draw_graph` wraps each row of the graph
|
|
|
|
|
|
// in an unlabelled, unstroked container purely to stack them. Counting those as frames
|
|
|
|
|
|
// measurably ruined the arrows — a back edge such as "return for correction" → "submit"
|
|
|
|
|
|
// leaves its own band, so every clean route was rejected for trespassing on a frame that
|
|
|
|
|
|
// is not drawn, and the router fell back to one that cut straight through two boxes.
|
|
|
|
|
|
// Measured over 161 generated flowcharts: 319 crossing edges before, 151 after, and not
|
|
|
|
|
|
// one diagram made worse.
|
feat(diagram-engine): edge router — pinned ports, obstacle avoidance, cost search
Fixes the reported problem: arrows overlapped on top of icons and ran through shapes
they had nothing to do with.
The cause was a missing layer, not a bug. render.ts emitted only source and target, so
draw.io routed every edge itself — and its router sees the two terminals' bounds and
nothing else, not where the other icons are. It therefore ran lines straight through
whatever was in between and left several edges leaving one node at the same point.
Ported from drawio-ai-kit's router (MIT, see NOTICE):
- Port de-collision. Edges leaving the same side of the same node spread along it,
ordered by where their far end sits so they do not cross on the way out. An edge
with a clean straight shot keeps the centre.
- Obstacle avoidance. Candidate shapes in order of directness — straight, a Z through
the gap, an L, a two-bend detour — each tested against every icon on the page.
- Frame placement. A frame is passable (an edge into a VPC must cross its border) but
not free: running alongside a border, or cutting through a frame that holds only one
of the two endpoints, is penalised.
- Port snapping. On a bent route, each port moves to the side its leg actually arrives
from. Without this the terminal segment can pierce the icon to reach a far-side port.
- Lane claiming. Each routed edge records the lanes it occupies so later edges avoid
them, rather than colliding and being pulled apart afterwards.
- Global nudge, three passes, reverting any move that makes a path worse.
Two things I got wrong on the way, both caught by looking at real geometry:
- The Z corridor was computed as min/max of both nodes' edges, which spans the whole
distance between them — including anything parked in between. So the lane sweep
would place the detour's middle leg on top of the very icon it was avoiding. It has
to be the gap: trailing edge of the first node to the leading edge of the other.
- The router was fed layout's slot rectangles, but an icon's cell is the glyph square
centred in a slot roughly twice as wide. Collision tests against slots both missed
real overlaps and invented false ones. Extracted cellRect() so the router and the
emitted XML cannot diverge.
Accept-or-reject was not enough on its own. When one endpoint is inside a VPC and the
other outside, EVERY path trespasses on that frame, so the strict rule always fails and
the relaxed pass took whatever it tried first — which is how a line ended up cutting
across a whole VPC. Candidates are now scored (frame offences 500, lane sharing 700,
bends 80, length 1) and the cheapest wins, so an edge that must trespass still gets the
least-bad route.
Waypoints are still written only when load-bearing — a labelled bend or a deliberate
detour — so an unobstructed edge stays drag-friendly.
455 unit tests (27 new, asserting produced geometry rather than algorithm shape).
Verified by rendering the reported diagram in the real editor: the two arrows that
overlapped on the EC2 icon now leave from different sides, and the load balancer's
fan-out leaves from three distinct points.
2026-08-09 12:46:44 +09:00
|
|
|
|
const frames = new Set(
|
fix(diagram-engine): eliminate arrows drawn through boxes, complete the route search
A user's approval-workflow flowchart came out with the return arrow drawn
straight through two unrelated steps, and a second report showed arrows
leaving a box and bending straight back across it. Measured over 250
generated flowcharts (2722 edges): 347 arrows crossed an unrelated box and
215 waypoints landed inside a shape. Four defects, each measured in
isolation:
1. The invisible layer containers draw_graph emits were handed to the
router as frames, so every clean return path was rejected for
"trespassing" on a border that is not drawn, and the fallback cut
through two boxes. Excluding invisible containers: 347 -> 218 crossing
arrows, no diagram made worse.
2. The router chose the horizontal-vs-vertical axis BEFORE searching, so
when the only clean corridor ran along the other axis it was never
looked at. A complete two-bend candidate generator that tries both
trunk axes, all four sides at each end, and the port fractions:
218 -> 27. (An independent ablation measured the axis pre-choice alone
at a 40% per-edge failure rate.)
3. Nothing stopped a route's first leg from turning back across its own
source shape - the obstacle test exempts an edge's own endpoints, and
must, since the line has to touch them. A terminal-leg rule refuses
such routes outright: 215 -> 4 hooks.
4. A two-bend search cannot express the staircase needed when a box sits
directly between two vertically aligned nodes (21 of the last 27
crossings). Added the orthogonal visibility graph + A* from Wybrow,
Marriott & Stuckey, "Orthogonal Connector Routing" (GD 2009) - the
libavoid algorithm - as the backstop when the candidate search finds
nothing. The interesting-points grid is provably sufficient: any valid
route shrinks onto it without getting longer or gaining bends. The A*
state is (point, incoming direction) with libavoid's bend cost of 10,
and the admissible bends-remaining heuristic, so it returns a cheapest
route, not merely a route. Implemented from the paper, not ported.
After all four: 0 crossing arrows and 0 hooks over the same 250 diagrams,
page area unchanged (494k px^2 mean), 260ms for the whole corpus, mean
0.82 bends per edge. The shape ladder still runs first, so routes that
were already clean are byte-identical.
Also post-nudge validation now checks the whole path (the nudge pass only
reverts the single segment it moved, judged in isolation) and restores the
search's route if nudging made it dirty.
2026-08-09 18:18:54 +09:00
|
|
|
|
flat
|
|
|
|
|
|
.filter(
|
|
|
|
|
|
(f) =>
|
|
|
|
|
|
isContainer(f.node) &&
|
|
|
|
|
|
!isInvisible(styleFor(f.node, opts.resolveStyle)),
|
|
|
|
|
|
)
|
|
|
|
|
|
.map((f) => f.node.id),
|
feat(diagram-engine): edge router — pinned ports, obstacle avoidance, cost search
Fixes the reported problem: arrows overlapped on top of icons and ran through shapes
they had nothing to do with.
The cause was a missing layer, not a bug. render.ts emitted only source and target, so
draw.io routed every edge itself — and its router sees the two terminals' bounds and
nothing else, not where the other icons are. It therefore ran lines straight through
whatever was in between and left several edges leaving one node at the same point.
Ported from drawio-ai-kit's router (MIT, see NOTICE):
- Port de-collision. Edges leaving the same side of the same node spread along it,
ordered by where their far end sits so they do not cross on the way out. An edge
with a clean straight shot keeps the centre.
- Obstacle avoidance. Candidate shapes in order of directness — straight, a Z through
the gap, an L, a two-bend detour — each tested against every icon on the page.
- Frame placement. A frame is passable (an edge into a VPC must cross its border) but
not free: running alongside a border, or cutting through a frame that holds only one
of the two endpoints, is penalised.
- Port snapping. On a bent route, each port moves to the side its leg actually arrives
from. Without this the terminal segment can pierce the icon to reach a far-side port.
- Lane claiming. Each routed edge records the lanes it occupies so later edges avoid
them, rather than colliding and being pulled apart afterwards.
- Global nudge, three passes, reverting any move that makes a path worse.
Two things I got wrong on the way, both caught by looking at real geometry:
- The Z corridor was computed as min/max of both nodes' edges, which spans the whole
distance between them — including anything parked in between. So the lane sweep
would place the detour's middle leg on top of the very icon it was avoiding. It has
to be the gap: trailing edge of the first node to the leading edge of the other.
- The router was fed layout's slot rectangles, but an icon's cell is the glyph square
centred in a slot roughly twice as wide. Collision tests against slots both missed
real overlaps and invented false ones. Extracted cellRect() so the router and the
emitted XML cannot diverge.
Accept-or-reject was not enough on its own. When one endpoint is inside a VPC and the
other outside, EVERY path trespasses on that frame, so the strict rule always fails and
the relaxed pass took whatever it tried first — which is how a line ended up cutting
across a whole VPC. Candidates are now scored (frame offences 500, lane sharing 700,
bends 80, length 1) and the cheapest wins, so an edge that must trespass still gets the
least-bad route.
Waypoints are still written only when load-bearing — a labelled bend or a deliberate
detour — so an unobstructed edge stays drag-friendly.
455 unit tests (27 new, asserting produced geometry rather than algorithm shape).
Verified by rendering the reported diagram in the real editor: the two arrows that
overlapped on the EC2 icon now leave from different sides, and the load balancer's
fan-out leaves from three distinct points.
2026-08-09 12:46:44 +09:00
|
|
|
|
)
|
|
|
|
|
|
const routes = routeEdges(
|
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
|
|
|
|
routable.map(({ link: l, index }) => ({
|
|
|
|
|
|
id: l.id ?? `ed${index + 1}`,
|
feat(diagram-engine): edge router — pinned ports, obstacle avoidance, cost search
Fixes the reported problem: arrows overlapped on top of icons and ran through shapes
they had nothing to do with.
The cause was a missing layer, not a bug. render.ts emitted only source and target, so
draw.io routed every edge itself — and its router sees the two terminals' bounds and
nothing else, not where the other icons are. It therefore ran lines straight through
whatever was in between and left several edges leaving one node at the same point.
Ported from drawio-ai-kit's router (MIT, see NOTICE):
- Port de-collision. Edges leaving the same side of the same node spread along it,
ordered by where their far end sits so they do not cross on the way out. An edge
with a clean straight shot keeps the centre.
- Obstacle avoidance. Candidate shapes in order of directness — straight, a Z through
the gap, an L, a two-bend detour — each tested against every icon on the page.
- Frame placement. A frame is passable (an edge into a VPC must cross its border) but
not free: running alongside a border, or cutting through a frame that holds only one
of the two endpoints, is penalised.
- Port snapping. On a bent route, each port moves to the side its leg actually arrives
from. Without this the terminal segment can pierce the icon to reach a far-side port.
- Lane claiming. Each routed edge records the lanes it occupies so later edges avoid
them, rather than colliding and being pulled apart afterwards.
- Global nudge, three passes, reverting any move that makes a path worse.
Two things I got wrong on the way, both caught by looking at real geometry:
- The Z corridor was computed as min/max of both nodes' edges, which spans the whole
distance between them — including anything parked in between. So the lane sweep
would place the detour's middle leg on top of the very icon it was avoiding. It has
to be the gap: trailing edge of the first node to the leading edge of the other.
- The router was fed layout's slot rectangles, but an icon's cell is the glyph square
centred in a slot roughly twice as wide. Collision tests against slots both missed
real overlaps and invented false ones. Extracted cellRect() so the router and the
emitted XML cannot diverge.
Accept-or-reject was not enough on its own. When one endpoint is inside a VPC and the
other outside, EVERY path trespasses on that frame, so the strict rule always fails and
the relaxed pass took whatever it tried first — which is how a line ended up cutting
across a whole VPC. Candidates are now scored (frame offences 500, lane sharing 700,
bends 80, length 1) and the cheapest wins, so an edge that must trespass still gets the
least-bad route.
Waypoints are still written only when load-bearing — a labelled bend or a deliberate
detour — so an unobstructed edge stays drag-friendly.
455 unit tests (27 new, asserting produced geometry rather than algorithm shape).
Verified by rendering the reported diagram in the real editor: the two arrows that
overlapped on the EC2 icon now leave from different sides, and the load balancer's
fan-out leaves from three distinct points.
2026-08-09 12:46:44 +09:00
|
|
|
|
source: l.source,
|
|
|
|
|
|
target: l.target,
|
|
|
|
|
|
hasLabel: edgeLabel(l) !== "",
|
|
|
|
|
|
})),
|
|
|
|
|
|
cellById,
|
|
|
|
|
|
obstacles,
|
|
|
|
|
|
frames,
|
|
|
|
|
|
)
|
feat(diagram-engine): label avoidance, paired opposite edges, semantic group colours
A git-workflow flowchart rendered with no overlaps but read poorly. Three
distinct causes, each fixed and measured:
1. Edge labels sat on boxes and on each other (4 collisions on the
reported diagram; 280 across 250 generated flowcharts). The router
keeps LINES off the boxes but a label renders at its edge's midpoint,
which on a long edge is beside exactly the things the line was routed
around. placeLabels slides each label along its own edge to a clear
spot — longest edges first, midpoint-outward tries — written as the
geometry's relative x, which draw.io natively supports. Corpus: 280
label collisions -> 8.
2. A->B and B->A were routed independently, so "git add" ran straight
while "git reset" wandered through a different corridor with a kink.
Opposite edges that agree on axis now get two absolute parallel tracks
in the strip where the two boxes overlap, a constant 24px apart,
converted back to port fractions. Zero crossing regressions.
3. All boxes rendered the same white, because the render layer's
fill/stroke support was never reachable: neither add_box's schema nor
draw_graph's nodes exposed it. Rather than exposing raw hex (the model
picks mismatched saturations, differently every time), nodes take a
semantic group name and the engine maps groups to a fixed palette of
six paired fill/strokes in order of first appearance. The model names
the zones - remote vs local vs temp - and never touches a colour.
532 unit tests pass; the 5 diagram e2e tests pass in a real browser.
2026-08-09 18:59:34 +09:00
|
|
|
|
// Where each label goes along its edge. The router only kept LINES off the boxes; a
|
|
|
|
|
|
// label sits at the path midpoint, which on a long edge is exactly beside the things
|
|
|
|
|
|
// the line was routed around.
|
|
|
|
|
|
const labelled = routable
|
|
|
|
|
|
.map(({ link: l, index }, i) => {
|
|
|
|
|
|
const label = edgeLabel(l)
|
|
|
|
|
|
if (!label) return null
|
|
|
|
|
|
const a = cellById.get(l.source)
|
|
|
|
|
|
const b = cellById.get(l.target)
|
|
|
|
|
|
const r = routes[i]
|
|
|
|
|
|
if (!a || !b || !r) return null
|
|
|
|
|
|
const sp = {
|
|
|
|
|
|
x: a.x + r.exit.x * a.w,
|
|
|
|
|
|
y: a.y + r.exit.y * a.h,
|
|
|
|
|
|
}
|
|
|
|
|
|
const ep = {
|
|
|
|
|
|
x: b.x + r.entry.x * b.w,
|
|
|
|
|
|
y: b.y + r.entry.y * b.h,
|
|
|
|
|
|
}
|
|
|
|
|
|
return {
|
|
|
|
|
|
id: l.id ?? `ed${index + 1}`,
|
|
|
|
|
|
label,
|
|
|
|
|
|
path: [sp, ...r.waypoints, ep],
|
|
|
|
|
|
}
|
|
|
|
|
|
})
|
|
|
|
|
|
.filter((e): e is { id: string; label: string; path: Point[] } =>
|
|
|
|
|
|
Boolean(e),
|
|
|
|
|
|
)
|
|
|
|
|
|
const labelBoxes = [...obstacles]
|
|
|
|
|
|
.map((id) => cellById.get(id))
|
|
|
|
|
|
.filter((r): r is Rect => Boolean(r))
|
|
|
|
|
|
const labelAt = placeLabels(labelled, labelBoxes)
|
|
|
|
|
|
|
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
|
|
|
|
routable.forEach(({ link, index }, i) => {
|
feat(diagram-engine): label avoidance, paired opposite edges, semantic group colours
A git-workflow flowchart rendered with no overlaps but read poorly. Three
distinct causes, each fixed and measured:
1. Edge labels sat on boxes and on each other (4 collisions on the
reported diagram; 280 across 250 generated flowcharts). The router
keeps LINES off the boxes but a label renders at its edge's midpoint,
which on a long edge is beside exactly the things the line was routed
around. placeLabels slides each label along its own edge to a clear
spot — longest edges first, midpoint-outward tries — written as the
geometry's relative x, which draw.io natively supports. Corpus: 280
label collisions -> 8.
2. A->B and B->A were routed independently, so "git add" ran straight
while "git reset" wandered through a different corridor with a kink.
Opposite edges that agree on axis now get two absolute parallel tracks
in the strip where the two boxes overlap, a constant 24px apart,
converted back to port fractions. Zero crossing regressions.
3. All boxes rendered the same white, because the render layer's
fill/stroke support was never reachable: neither add_box's schema nor
draw_graph's nodes exposed it. Rather than exposing raw hex (the model
picks mismatched saturations, differently every time), nodes take a
semantic group name and the engine maps groups to a fixed palette of
six paired fill/strokes in order of first appearance. The model names
the zones - remote vs local vs temp - and never touches a colour.
532 unit tests pass; the 5 diagram e2e tests pass in a real browser.
2026-08-09 18:59:34 +09:00
|
|
|
|
const id = link.id ?? `ed${index + 1}`
|
|
|
|
|
|
cells.push(edgeXml(link, index, routes[i], labelAt.get(id)))
|
feat(diagram-engine): edge router — pinned ports, obstacle avoidance, cost search
Fixes the reported problem: arrows overlapped on top of icons and ran through shapes
they had nothing to do with.
The cause was a missing layer, not a bug. render.ts emitted only source and target, so
draw.io routed every edge itself — and its router sees the two terminals' bounds and
nothing else, not where the other icons are. It therefore ran lines straight through
whatever was in between and left several edges leaving one node at the same point.
Ported from drawio-ai-kit's router (MIT, see NOTICE):
- Port de-collision. Edges leaving the same side of the same node spread along it,
ordered by where their far end sits so they do not cross on the way out. An edge
with a clean straight shot keeps the centre.
- Obstacle avoidance. Candidate shapes in order of directness — straight, a Z through
the gap, an L, a two-bend detour — each tested against every icon on the page.
- Frame placement. A frame is passable (an edge into a VPC must cross its border) but
not free: running alongside a border, or cutting through a frame that holds only one
of the two endpoints, is penalised.
- Port snapping. On a bent route, each port moves to the side its leg actually arrives
from. Without this the terminal segment can pierce the icon to reach a far-side port.
- Lane claiming. Each routed edge records the lanes it occupies so later edges avoid
them, rather than colliding and being pulled apart afterwards.
- Global nudge, three passes, reverting any move that makes a path worse.
Two things I got wrong on the way, both caught by looking at real geometry:
- The Z corridor was computed as min/max of both nodes' edges, which spans the whole
distance between them — including anything parked in between. So the lane sweep
would place the detour's middle leg on top of the very icon it was avoiding. It has
to be the gap: trailing edge of the first node to the leading edge of the other.
- The router was fed layout's slot rectangles, but an icon's cell is the glyph square
centred in a slot roughly twice as wide. Collision tests against slots both missed
real overlaps and invented false ones. Extracted cellRect() so the router and the
emitted XML cannot diverge.
Accept-or-reject was not enough on its own. When one endpoint is inside a VPC and the
other outside, EVERY path trespasses on that frame, so the strict rule always fails and
the relaxed pass took whatever it tried first — which is how a line ended up cutting
across a whole VPC. Candidates are now scored (frame offences 500, lane sharing 700,
bends 80, length 1) and the cheapest wins, so an edge that must trespass still gets the
least-bad route.
Waypoints are still written only when load-bearing — a labelled bend or a deliberate
detour — so an unobstructed edge stays drag-friendly.
455 unit tests (27 new, asserting produced geometry rather than algorithm shape).
Verified by rendering the reported diagram in the real editor: the two arrows that
overlapped on the EC2 icon now leave from different sides, and the load balancer's
fan-out leaves from three distinct points.
2026-08-09 12:46:44 +09:00
|
|
|
|
})
|
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
|
|
|
|
for (const m of messages)
|
|
|
|
|
|
cells.push(messageXml(m.link, m.index, m.y, cellById))
|
feat(diagram-engine): edge router — pinned ports, obstacle avoidance, cost search
Fixes the reported problem: arrows overlapped on top of icons and ran through shapes
they had nothing to do with.
The cause was a missing layer, not a bug. render.ts emitted only source and target, so
draw.io routed every edge itself — and its router sees the two terminals' bounds and
nothing else, not where the other icons are. It therefore ran lines straight through
whatever was in between and left several edges leaving one node at the same point.
Ported from drawio-ai-kit's router (MIT, see NOTICE):
- Port de-collision. Edges leaving the same side of the same node spread along it,
ordered by where their far end sits so they do not cross on the way out. An edge
with a clean straight shot keeps the centre.
- Obstacle avoidance. Candidate shapes in order of directness — straight, a Z through
the gap, an L, a two-bend detour — each tested against every icon on the page.
- Frame placement. A frame is passable (an edge into a VPC must cross its border) but
not free: running alongside a border, or cutting through a frame that holds only one
of the two endpoints, is penalised.
- Port snapping. On a bent route, each port moves to the side its leg actually arrives
from. Without this the terminal segment can pierce the icon to reach a far-side port.
- Lane claiming. Each routed edge records the lanes it occupies so later edges avoid
them, rather than colliding and being pulled apart afterwards.
- Global nudge, three passes, reverting any move that makes a path worse.
Two things I got wrong on the way, both caught by looking at real geometry:
- The Z corridor was computed as min/max of both nodes' edges, which spans the whole
distance between them — including anything parked in between. So the lane sweep
would place the detour's middle leg on top of the very icon it was avoiding. It has
to be the gap: trailing edge of the first node to the leading edge of the other.
- The router was fed layout's slot rectangles, but an icon's cell is the glyph square
centred in a slot roughly twice as wide. Collision tests against slots both missed
real overlaps and invented false ones. Extracted cellRect() so the router and the
emitted XML cannot diverge.
Accept-or-reject was not enough on its own. When one endpoint is inside a VPC and the
other outside, EVERY path trespasses on that frame, so the strict rule always fails and
the relaxed pass took whatever it tried first — which is how a line ended up cutting
across a whole VPC. Candidates are now scored (frame offences 500, lane sharing 700,
bends 80, length 1) and the cheapest wins, so an edge that must trespass still gets the
least-bad route.
Waypoints are still written only when load-bearing — a labelled bend or a deliberate
detour — so an unobstructed edge stays drag-friendly.
455 unit tests (27 new, asserting produced geometry rather than algorithm shape).
Verified by rendering the reported diagram in the real editor: the two arrows that
overlapped on the EC2 icon now leave from different sides, and the load balancer's
fan-out leaves from three distinct points.
2026-08-09 12:46:44 +09:00
|
|
|
|
|
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 model =
|
|
|
|
|
|
`<mxGraphModel dx="1400" dy="900" grid="0" gridSize="10" guides="1" tooltips="1"` +
|
|
|
|
|
|
` connect="1" arrows="1" fold="1" page="1" pageScale="1" pageWidth="${page.w}"` +
|
|
|
|
|
|
` pageHeight="${page.h}" math="0" shadow="0"><root><mxCell id="0"/>` +
|
|
|
|
|
|
`<mxCell id="1" parent="0"/>${cells.join("")}</root></mxGraphModel>`
|
|
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
|
xml: `<mxfile host="app.diagrams.net"><diagram name="Page-1" id="page-1">${model}</diagram></mxfile>`,
|
|
|
|
|
|
page,
|
|
|
|
|
|
danglingLinks: [...new Set(dangling)],
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/** Re-export so callers can lay out without rendering. */
|
|
|
|
|
|
export type { Placed }
|