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.
This commit is contained in:
dayuan.jiang
2026-08-09 11:56:08 +09:00
parent 8765dfb96c
commit a2f892ca82
8 changed files with 2525 additions and 222 deletions

View File

@@ -135,9 +135,17 @@ describe("parseDiagram on real engine output", () => {
//
// This is why our engine must not have phantoms: a wrapper that emits no cell
// makes the round-trip lossy by construction. See task #5.
expect(findNode(tree, "vpc")?.kind).toBe("grid")
//
// The parser does not guess a direction here. It keeps the container and warns
// that the arrangement is two-dimensional, so the caller knows a re-layout will
// move these children rather than discovering it afterwards.
expect(findParent(tree, "az_a")?.id).toBe("vpc")
expect(findNode(tree, "azs")).toBeNull()
expect(
warnings.some(
(w) => w.includes("vpc") && w.includes("two dimensions"),
),
).toBe(true)
})
it("classifies resourceIcon cells as icons and recovers their catalog name", () => {
@@ -186,8 +194,11 @@ describe("parseDiagram on real engine output", () => {
expect(needsAdoption).toBe(true)
})
it("parses without warnings on a well-formed single page", () => {
expect(warnings).toEqual([])
it("warns only about the phantom-flattened container, nothing else", () => {
// The single warning is the 2-D arrangement the phantom left behind; a
// well-formed page produces no other complaint.
expect(warnings).toHaveLength(1)
expect(warnings[0]).toContain("two dimensions")
})
it("assigns every cell exactly once — no duplicates, nothing lost", () => {