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

@@ -0,0 +1,279 @@
/**
* Layout: tree → coordinates.
*
* Two passes, the same shape as a flexbox implementation:
*
* measure — bottom-up. A leaf reports its intrinsic size; a container sums its
* children along the flow axis, takes the maximum across it, and adds
* padding and its title strip. A container therefore always ends up big
* enough to hold what is inside it, which is why "child spills out of its
* frame" cannot happen by construction.
*
* place — top-down. Each container distributes its now-known interior among its
* children.
*
* The model never supplies a coordinate. It declares nesting, direction and gap; every
* x/y/width/height comes from here.
*
* Ported from drawio-ai-kit (MIT) — see NOTICE.
*/
import type { ContainerNode, DiagramNode, Rect } from "./types"
import { isContainer } from "./types"
/** Default glyph size for a catalog icon. */
export const ICON_SIZE = 48
/** Interior padding of a container. */
const PAD = 24
/** Height of a container's title strip. Zero when it has no label — an empty strip
* reads as a dead band at the top of the frame. */
const HEADER = 36
/** Approximate width of one label character at the engine's font size. */
const CHAR_W = 6.6
/** A node with its computed box. Layout works on this, leaving the tree untouched. */
export interface Placed {
node: DiagramNode
rect: Rect
children: Placed[]
}
/** Intrinsic size of a text box: widest wrapped line by line count. */
export function autoBoxSize(label: string): { w: number; h: number } {
const lines = String(label ?? "").split("\n")
const longest = Math.max(1, ...lines.map((l) => l.length))
return {
w: Math.min(260, Math.max(120, Math.round(longest * CHAR_W + 28))),
h: Math.max(44, lines.length * 18 + 26),
}
}
/**
* Intrinsic size of an icon cell: the glyph, plus room for the label underneath, and
* wide enough that a long label does not overflow the cell it is centred in.
*/
function iconSize(label: string, glyph: number): { w: number; h: number } {
return {
w: Math.max(96, glyph + 20, Math.min(200, label.length * 7 + 24)),
h: glyph + 34,
}
}
/** A container is never narrower than its own title. */
function titleFloor(label: string, pad: number): number {
return label ? Math.ceil(label.length * CHAR_W) + pad * 2 : 0
}
function headerFor(n: ContainerNode): number {
return n.label ? HEADER : 0
}
/**
* measure: give every node a size, bottom-up.
*
* Siblings are equalised across the cross axis — frames in a row share a bottom edge,
* frames in a column share left and right edges. Only containers stretch; a leaf keeps
* its natural size, because stretching an icon would distort the glyph.
*/
function measure(n: DiagramNode, defaultGlyph: number): Placed {
if (n.kind === "icon") {
const glyph = n.size ?? defaultGlyph
const s = iconSize(n.label, glyph)
return { node: n, rect: { x: 0, y: 0, ...s }, children: [] }
}
if (n.kind === "box") {
const auto = autoBoxSize(n.label)
return {
node: n,
rect: { x: 0, y: 0, w: n.w ?? auto.w, h: n.h ?? auto.h },
children: [],
}
}
if (n.kind === "title") {
return { node: n, rect: { x: 0, y: 0, w: 0, h: 30 }, children: [] }
}
const kids = n.children.map((c) => measure(c, defaultGlyph))
const head = headerFor(n)
const gap = n.gap
if (n.kind === "grid") {
const cols = Math.max(1, n.cols)
const rows = Math.ceil(kids.length / cols) || 1
const cellW = Math.max(0, ...kids.map((k) => k.rect.w))
const cellH = Math.max(0, ...kids.map((k) => k.rect.h))
const w = PAD * 2 + cols * cellW + gap * (cols - 1)
const h = head + PAD * 2 + rows * cellH + gap * (rows - 1)
return {
node: n,
rect: {
x: 0,
y: 0,
w: Math.max(w, titleFloor(n.label, PAD)),
h,
},
children: kids,
}
}
// group: row or col
if (n.dir === "row") {
const tallest = Math.max(0, ...kids.map((k) => k.rect.h))
for (const k of kids)
if (isContainer(k.node)) k.rect.h = Math.max(k.rect.h, tallest)
const w =
PAD * 2 +
kids.reduce((s, k) => s + k.rect.w, 0) +
gap * Math.max(0, kids.length - 1)
const h = head + PAD * 2 + Math.max(0, ...kids.map((k) => k.rect.h))
return {
node: n,
rect: { x: 0, y: 0, w: Math.max(w, titleFloor(n.label, PAD)), h },
children: kids,
}
}
const widest = Math.max(0, ...kids.map((k) => k.rect.w))
// Only groups stretch: a grid computes its own interior, so forcing it wider would
// leave a gap inside it rather than filling the space.
for (const k of kids)
if (k.node.kind === "group") k.rect.w = Math.max(k.rect.w, widest)
const w = PAD * 2 + Math.max(0, ...kids.map((k) => k.rect.w))
const h =
head +
PAD * 2 +
kids.reduce((s, k) => s + k.rect.h, 0) +
gap * Math.max(0, kids.length - 1)
return {
node: n,
rect: { x: 0, y: 0, w: Math.max(w, titleFloor(n.label, PAD)), h },
children: kids,
}
}
/**
* place: assign absolute positions, top-down.
*
* When a container ended up larger than its content — because a sibling forced it
* wider, or its own title did — the slack is shared between the children rather than
* left as dead margin on one side. The extra spacing is capped at one base gap so a
* stretched frame reads as deliberately spaced instead of sparse, and the resulting
* cluster is centred.
*/
function place(p: Placed, x: number, y: number): void {
p.rect.x = Math.round(x)
p.rect.y = Math.round(y)
const n = p.node
if (!isContainer(n)) return
const head = headerFor(n)
const innerX = p.rect.x + PAD
const innerTop = p.rect.y + head + PAD
const innerW = p.rect.w - PAD * 2
const innerH = p.rect.h - head - PAD * 2
const kids = p.children
if (n.kind === "grid") {
const cols = Math.max(1, n.cols)
const cellW = Math.max(0, ...kids.map((k) => k.rect.w))
const cellH = Math.max(0, ...kids.map((k) => k.rect.h))
kids.forEach((k, i) => {
const r = Math.floor(i / cols)
const c = i % cols
const cx = innerX + c * (cellW + n.gap)
const cy = innerTop + r * (cellH + n.gap)
// centre each child in its cell so a short label does not sit off-axis
place(k, cx + (cellW - k.rect.w) / 2, cy + (cellH - k.rect.h) / 2)
})
return
}
const alongRow = n.dir === "row"
const sizes = kids.map((k) => (alongRow ? k.rect.w : k.rect.h))
const content = sizes.reduce((s, v) => s + v, 0)
const extent = alongRow ? innerW : innerH
const k = kids.length
const slack = Math.max(0, extent - content - n.gap * (k - 1))
const gap = k > 1 ? n.gap + Math.min(n.gap, slack / (k - 1)) : n.gap
const span = content + gap * Math.max(0, k - 1)
let cur = (alongRow ? innerX : innerTop) + Math.max(0, (extent - span) / 2)
for (const kid of kids) {
if (alongRow) {
place(kid, cur, innerTop + (innerH - kid.rect.h) / 2)
cur += kid.rect.w + gap
} else {
place(kid, innerX + (innerW - kid.rect.w) / 2, cur)
cur += kid.rect.h + gap
}
}
}
export interface LayoutResult {
/** Placed roots, in the order given. */
roots: Placed[]
/** Page size that fits everything, with a margin. */
page: { w: number; h: number }
}
/** Where the tree starts on the page. Leaves room for a title above it. */
const ORIGIN = { x: 40, y: 90 }
const MARGIN = { right: 40, bottom: 50 }
/**
* Lay out a forest of roots side by side and report the page size that fits them.
*
* A pinned node keeps the position it already had: the user moved it deliberately, and
* the whole point of the pin is that a re-layout does not undo that.
*/
export function layoutForest(
roots: DiagramNode[],
opts: { iconSize?: number; gap?: number } = {},
): LayoutResult {
const glyph = opts.iconSize ?? ICON_SIZE
const gap = opts.gap ?? 70
const placed = roots.map((r) => measure(r, glyph))
let cur = ORIGIN.x
for (const p of placed) {
const n = p.node
const held =
n.kind === "title" ? null : n.pinned ? (n.rect ?? null) : null
if (held) {
place(p, held.x, held.y)
} else {
place(p, cur, ORIGIN.y)
cur += p.rect.w + gap
}
}
let maxX = 0
let maxY = 0
const visit = (p: Placed) => {
maxX = Math.max(maxX, p.rect.x + p.rect.w)
maxY = Math.max(maxY, p.rect.y + p.rect.h)
p.children.forEach(visit)
}
placed.forEach(visit)
return {
roots: placed,
page: {
w: Math.round(maxX + MARGIN.right),
h: Math.round(maxY + MARGIN.bottom),
},
}
}
/** Flatten a placed forest into (node, rect, parentId) triples in document order. */
export function flatten(
roots: Placed[],
): { node: DiagramNode; rect: Rect; parent: string }[] {
const out: { node: DiagramNode; rect: Rect; parent: string }[] = []
const walk = (p: Placed, parent: string) => {
out.push({ node: p.node, rect: p.rect, parent })
for (const c of p.children) walk(c, p.node.id)
}
for (const r of roots) walk(r, "1")
return out
}

View File

@@ -36,6 +36,11 @@ export const MARKER = {
cols: "dai_cols",
/** Set by the user to freeze a node's position across re-layouts. */
pin: "dai_pin",
/**
* A catalog icon's name. Needed because an Azure or GCP icon's style is an embedded
* base64 image with no name anywhere in it, so the style alone cannot identify it.
*/
name: "dai_name",
} as const
export type NodeKind = "group" | "grid" | "icon" | "box" | "title"
@@ -54,6 +59,22 @@ export type Direction = "row" | "col" | "grid"
const CONTAINER_TOKENS =
"container=1;pointerEvents=0;collapsible=0;recursiveResize=0;"
/**
* A container that groups children for layout but should not be visible.
*
* The reference project solves this with a "phantom": a wrapper that participates in
* layout and then emits NO cell, reparenting its children onto the nearest visible
* ancestor. That makes the round-trip lossy by construction — the wrapper's direction
* and grouping are simply absent from the XML, so re-deriving the tree cannot recover
* them. Measured on the reference project's own build_vpc.mjs: a phantom erased a
* container's "col" direction, leaving children in a 2-D arrangement that can only be
* read back as a grid.
*
* So we emit a real cell and make it invisible instead. One extra cell per wrapper,
* in exchange for structure that survives being read back.
*/
const INVISIBLE_TOKENS = "fillColor=none;strokeColor=none;"
/** Read a marker's raw value out of a style string. Last occurrence wins, as draw.io does. */
export function readMarker(style: string, key: string): string | null {
// Scan all matches and keep the last, mirroring draw.io's duplicate-key resolution.
@@ -127,10 +148,13 @@ export function stampContainer(
dir: Direction
gap: number
cols?: number
/** Layout-only wrapper: emit a real cell, but draw nothing. */
invisible?: boolean
},
): string {
let s = style.endsWith(";") || style === "" ? style : `${style};`
s += CONTAINER_TOKENS
if (opts.invisible) s += INVISIBLE_TOKENS
s = append(s, MARKER.kind, opts.kind)
s = append(s, MARKER.dir, opts.dir)
s = append(s, MARKER.gap, Math.round(opts.gap))
@@ -139,12 +163,30 @@ export function stampContainer(
return s
}
/** Stamp a leaf (icon or box) with its kind, so the parser need not infer it. */
/**
* Is this an invisible layout wrapper? Both colours set to `none` and no group
* stencil — a visible frame always has a stroke or a stencil.
*/
export function isInvisible(style: string): boolean {
if (/grIcon=/.test(style)) return false
const fill = readMarker(style, "fillColor")
const stroke = readMarker(style, "strokeColor")
return fill === "none" && stroke === "none"
}
/**
* Stamp a leaf with its kind, so the parser need not infer it.
*
* For an icon, also record the catalog name: an Azure or GCP icon's style is an embedded
* base64 image with no name in it, so the style alone cannot identify which icon it is.
*/
export function stampLeaf(
style: string,
kind: "icon" | "box" | "title",
opts: { name?: string } = {},
): string {
return append(style, MARKER.kind, kind)
const s = append(style, MARKER.kind, kind)
return opts.name ? append(s, MARKER.name, opts.name) : s
}
/** Strip every `dai_*` marker — for exporting a clean file, or comparing styles. */

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,254 @@
/**
* 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.
*/
import { flatten, ICON_SIZE, layoutForest, type Placed } from "./layout"
import { stampContainer, stampLeaf } from "./markers"
import type { DiagramNode, DiagramTree, LinkSpec, Rect } from "./types"
/** Escape the five characters that would break an XML attribute. */
export function esc(s: string): string {
return String(s ?? "")
.replace(/&/g, "&")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#39;")
}
/** 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;"
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;"
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.
*/
function styleFor(n: DiagramNode, resolve: StyleResolver | undefined): string {
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") {
let base = n.style ?? FALLBACK_BOX
if (!n.style) {
if (n.fill) base += `fillColor=${n.fill};`
if (n.stroke) base += `strokeColor=${n.stroke};`
if (n.bold) base += "fontStyle=1;"
}
return stampLeaf(base, "box")
}
// container
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.
const invisible = !n.gname && !n.label && !n.fill && !n.stroke
let base = n.style ?? fromCatalog ?? FALLBACK_FRAME
if (!n.style && !fromCatalog) {
if (n.fill) base += `fillColor=${n.fill};`
if (n.stroke) base += `strokeColor=${n.stroke};`
}
return stampContainer(base, {
kind: n.kind,
dir: n.kind === "grid" ? "grid" : n.dir,
gap: n.gap,
cols: n.kind === "grid" ? n.cols : undefined,
invisible,
})
}
/**
* 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.
*/
function vertexXml(
n: DiagramNode,
rect: Rect,
parent: string,
parentRect: Rect | null,
resolve: StyleResolver | undefined,
defaultGlyph: number,
): string {
const ox = parentRect?.x ?? 0
const oy = parentRect?.y ?? 0
let box = rect
if (n.kind === "icon") {
const glyph = n.size ?? defaultGlyph
box = {
x: Math.round(rect.x + (rect.w - glyph) / 2),
y: rect.y,
w: glyph,
h: glyph,
}
}
return (
`<mxCell id="${esc(n.id)}" value="${esc("label" in n ? n.label : "")}"` +
` style="${styleFor(n, resolve)}" vertex="1" parent="${esc(parent)}">` +
`<mxGeometry x="${box.x - ox}" y="${box.y - oy}" width="${box.w}" height="${box.h}" as="geometry"/>` +
`</mxCell>`
)
}
/**
* One `<mxCell>` for an edge.
*
* No waypoints: draw.io's own orthogonal router recomputes the route from the terminals
* on every edit, so a user who moves a node never has to re-link an arrow. Freezing a
* pre-computed route would look better on first open and then deform the moment anyone
* touched the diagram — the wrong trade for an editor.
*/
function edgeXml(l: LinkSpec, index: number): string {
const label =
l.step != null
? l.label
? `${l.step}. ${l.label}`
: `${l.step}.`
: (l.label ?? "")
let style = l.style ?? EDGE_STYLE
if (!l.style) {
if (l.dashed) style += "dashed=1;"
if (label) style += "labelBackgroundColor=light-dark(#FFFFFF,#0B0F14);"
}
const id = l.id ?? `ed${index + 1}`
return (
`<mxCell id="${esc(id)}" value="${esc(label)}" style="${style}" edge="1" parent="1"` +
` source="${esc(l.source)}" target="${esc(l.target)}">` +
`<mxGeometry relative="1" as="geometry"/>` +
`</mxCell>`
)
}
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,
})
const flat = flatten(roots)
const rectById = new Map<string, Rect>()
for (const f of flat) rectById.set(f.node.id, f.rect)
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>`,
)
// Parents come before children (flatten guarantees it), which draw.io requires.
const glyph = opts.iconSize ?? ICON_SIZE
for (const f of flat) {
const parentRect =
f.parent === "1" ? null : (rectById.get(f.parent) ?? null)
cells.push(
vertexXml(
f.node,
f.rect,
f.parent,
parentRect,
opts.resolveStyle,
glyph,
),
)
}
// 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[] = []
let emitted = 0
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
}
cells.push(edgeXml(l, emitted++))
}
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 }