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.
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.