Files
next-ai-draw-io/lib/diagram-engine/types.ts

310 lines
9.4 KiB
TypeScript
Raw Normal View History

feat(diagram-engine): style markers + XML→tree reverse parser Groundwork for a declarative diagram engine where the model declares nesting and the engine computes every coordinate, instead of the model emitting raw mxCell XML. The design keeps the canvas as the SINGLE source of truth: the node tree is never persisted, it is re-derived from the current canvas XML whenever needed. A user's manual edits are therefore an input to the next re-layout, not a second copy of the state that has to be reconciled. Two behaviours this relies on, both verified against the real embedded editor with a Playwright mouse drag (reading the editor's own autosave payload): 1. An AWS group stencil WITHOUT container=1 does not get a dragged shape reparented — parent stays "1" and geometry stays absolute. WITH container=1 it does: parent becomes the frame, geometry becomes parent-relative. So the engine must stamp container=1 on every container it emits. 2. draw.io preserves style keys it does not understand, and resolves a duplicate key last-wins. So dai_* markers survive a user edit, and container=1 can be appended to a catalog style without first parsing out an existing value — which matters because the AWS catalog is inconsistent about it (group_vpc, group_region, group_subnet ship without it; group_account ships with it). markers.ts — dai_kind / dai_dir / dai_gap / dai_cols / dai_pin, and the container token normalisation. types.ts — the node tree contract, plus a `foreign` bucket so cells the engine does not understand (user annotations, imported shapes) round-trip verbatim rather than being destroyed by a re-layout. parse.ts — XML → tree. Handles all four icon encodings (resIcon=, bare shape=, shape=image data URI, grIcon=), resolves nesting from parent with a geometry fallback for frames that lack container=1, recovers layout direction from the marker or infers it from child positions, and survives cycles, compressed files and multi-page decks. 75 unit tests, including a round-trip against real output from the reference project's build_vpc.mjs. One finding worth recording: the reference project's "phantom" node (a wrapper that participates in layout but emits no cell) makes the round-trip lossy by construction. In build_vpc.mjs a phantom erased a container's col direction — its children were reparented onto the grandparent, leaving a 2-D arrangement the parser can only read as a grid. 26 of the reference project's 31 examples use phantoms, so our engine needs an invisible-but-real container instead. Tracked separately.
2026-08-09 11:35:32 +09:00
/**
* The declarative node tree the layout engine works on.
*
* The model never writes coordinates. It declares nesting and direction; the engine
* computes every x/y/width/height. The tree is not persisted anywhere it is
* re-derived from the canvas XML whenever it is needed (see parse.ts), so the canvas
* stays the single source of truth and a user's manual edits are an input, never
* something to be reconciled against a second copy of the state.
*/
import type { Direction } from "./markers"
export type { Direction } from "./markers"
feat(diagram-engine): flowcharts, swimlanes, sequence diagrams and mind maps Extends the declarative engine past cloud architecture. The tool routing was divided by icon library — AWS through the engine, everything else hand-written XML — which is the wrong axis. What matters is the LAYOUT SHAPE. Measured first: a six-step approval flow declared in its natural order comes out as one column, because the layout only arranges what nesting tells it to and never looked at the arrows. That forces the arrow from the decision to its second branch to jump over the first branch. graph.ts computes what the layout should have looked at: layer assignment by longest path, cycle breaking so a loop is drawn without setting the order, and barycentre sweeping to cut edge crossings. It emits ordinary container operations, so layout, routing and round-tripping are unchanged — reaching zero arrows-through-boxes on a 14-node pipeline and zero crossings on a bipartite graph whose declared order forces three. Three new container kinds, each because one layout rule cannot serve them all: pool — swimlanes. Lanes are real cells and each step is parented to its band, so dragging a step to another role records the change. sequence — participants across the top, one lifeline cell per participant so head and line stay together on a drag. Messages bypass the router: a message's height IS its order. radial — mind maps and org charts. Children are a flat list and the hierarchy comes from the links, because a branch is a box and a box cannot hold children. Flowchart box shapes (diamond, stadium, parallelogram, document) so a reader can tell a branch from a step. Two bugs the new tests caught: the duplicate-link guard blocked a sequence diagram from having two messages between the same pair, and the fallback message numbering was shared across containers, pushing a second diagram's messages off its own lifelines. 523 unit tests and 17 diagram e2e tests pass. Every kind verified round-trip stable to a fixed point, and rendered in a real browser — draw.io keeps the lifeline shape and the lane markers.
2026-08-09 13:47:23 +09:00
/**
* Which cell of a swimlane pool a node sits in.
*
* `lane` indexes the role band, `col` the position along the flow. Cells are sparse:
* nothing has to fill lane 1 column 3 for lane 2 column 3 to exist.
*/
export interface PoolCell {
lane: number
col: number
}
/**
* The outline a flowchart box is drawn with.
*
* Flowchart notation is conventional, not decorative: a reader takes a diamond to mean a
* branch and a stadium to mean an entry or exit point. Rendering every step as the same
* rectangle throws that away.
*/
export type BoxShape =
| "box"
| "decision"
| "terminator"
| "round"
| "data"
| "document"
feat(diagram-engine): style markers + XML→tree reverse parser Groundwork for a declarative diagram engine where the model declares nesting and the engine computes every coordinate, instead of the model emitting raw mxCell XML. The design keeps the canvas as the SINGLE source of truth: the node tree is never persisted, it is re-derived from the current canvas XML whenever needed. A user's manual edits are therefore an input to the next re-layout, not a second copy of the state that has to be reconciled. Two behaviours this relies on, both verified against the real embedded editor with a Playwright mouse drag (reading the editor's own autosave payload): 1. An AWS group stencil WITHOUT container=1 does not get a dragged shape reparented — parent stays "1" and geometry stays absolute. WITH container=1 it does: parent becomes the frame, geometry becomes parent-relative. So the engine must stamp container=1 on every container it emits. 2. draw.io preserves style keys it does not understand, and resolves a duplicate key last-wins. So dai_* markers survive a user edit, and container=1 can be appended to a catalog style without first parsing out an existing value — which matters because the AWS catalog is inconsistent about it (group_vpc, group_region, group_subnet ship without it; group_account ships with it). markers.ts — dai_kind / dai_dir / dai_gap / dai_cols / dai_pin, and the container token normalisation. types.ts — the node tree contract, plus a `foreign` bucket so cells the engine does not understand (user annotations, imported shapes) round-trip verbatim rather than being destroyed by a re-layout. parse.ts — XML → tree. Handles all four icon encodings (resIcon=, bare shape=, shape=image data URI, grIcon=), resolves nesting from parent with a geometry fallback for frames that lack container=1, recovers layout direction from the marker or infers it from child positions, and survives cycles, compressed files and multi-page decks. 75 unit tests, including a round-trip against real output from the reference project's build_vpc.mjs. One finding worth recording: the reference project's "phantom" node (a wrapper that participates in layout but emits no cell) makes the round-trip lossy by construction. In build_vpc.mjs a phantom erased a container's col direction — its children were reparented onto the grandparent, leaving a 2-D arrangement the parser can only read as a grid. 26 of the reference project's 31 examples use phantoms, so our engine needs an invisible-but-real container instead. Tracked separately.
2026-08-09 11:35:32 +09:00
/** A catalog icon: a real stencil, drawn at a fixed glyph size with a label below. */
export interface IconNode {
kind: "icon"
id: string
/** Catalog name, e.g. "s3" or "azure_virtual_machine". Resolved to a style by the catalog. */
name: string
label: string
/** Glyph size in px. Defaults to the diagram's icon size. */
size?: number
/** Verbatim style, when recovered from XML. Preferred over re-resolving `name`. */
style?: string
/** User froze this node's position — the engine must not move it. */
pinned?: boolean
/** Absolute geometry, when recovered from XML. Only meaningful for a pinned node. */
rect?: 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
/** Position within a `pool` parent. Ignored elsewhere. */
cell?: PoolCell
feat(diagram-engine): style markers + XML→tree reverse parser Groundwork for a declarative diagram engine where the model declares nesting and the engine computes every coordinate, instead of the model emitting raw mxCell XML. The design keeps the canvas as the SINGLE source of truth: the node tree is never persisted, it is re-derived from the current canvas XML whenever needed. A user's manual edits are therefore an input to the next re-layout, not a second copy of the state that has to be reconciled. Two behaviours this relies on, both verified against the real embedded editor with a Playwright mouse drag (reading the editor's own autosave payload): 1. An AWS group stencil WITHOUT container=1 does not get a dragged shape reparented — parent stays "1" and geometry stays absolute. WITH container=1 it does: parent becomes the frame, geometry becomes parent-relative. So the engine must stamp container=1 on every container it emits. 2. draw.io preserves style keys it does not understand, and resolves a duplicate key last-wins. So dai_* markers survive a user edit, and container=1 can be appended to a catalog style without first parsing out an existing value — which matters because the AWS catalog is inconsistent about it (group_vpc, group_region, group_subnet ship without it; group_account ships with it). markers.ts — dai_kind / dai_dir / dai_gap / dai_cols / dai_pin, and the container token normalisation. types.ts — the node tree contract, plus a `foreign` bucket so cells the engine does not understand (user annotations, imported shapes) round-trip verbatim rather than being destroyed by a re-layout. parse.ts — XML → tree. Handles all four icon encodings (resIcon=, bare shape=, shape=image data URI, grIcon=), resolves nesting from parent with a geometry fallback for frames that lack container=1, recovers layout direction from the marker or infers it from child positions, and survives cycles, compressed files and multi-page decks. 75 unit tests, including a round-trip against real output from the reference project's build_vpc.mjs. One finding worth recording: the reference project's "phantom" node (a wrapper that participates in layout but emits no cell) makes the round-trip lossy by construction. In build_vpc.mjs a phantom erased a container's col direction — its children were reparented onto the grandparent, leaving a 2-D arrangement the parser can only read as a grid. 26 of the reference project's 31 examples use phantoms, so our engine needs an invisible-but-real container instead. Tracked separately.
2026-08-09 11:35:32 +09:00
}
/** A plain labelled rectangle, for things the catalog has no icon for. */
export interface BoxNode {
kind: "box"
id: string
label: string
w?: number
h?: number
fill?: string
stroke?: string
bold?: boolean
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
/** Flowchart outline. Absent means a plain rectangle. */
shape?: BoxShape
feat(diagram-engine): style markers + XML→tree reverse parser Groundwork for a declarative diagram engine where the model declares nesting and the engine computes every coordinate, instead of the model emitting raw mxCell XML. The design keeps the canvas as the SINGLE source of truth: the node tree is never persisted, it is re-derived from the current canvas XML whenever needed. A user's manual edits are therefore an input to the next re-layout, not a second copy of the state that has to be reconciled. Two behaviours this relies on, both verified against the real embedded editor with a Playwright mouse drag (reading the editor's own autosave payload): 1. An AWS group stencil WITHOUT container=1 does not get a dragged shape reparented — parent stays "1" and geometry stays absolute. WITH container=1 it does: parent becomes the frame, geometry becomes parent-relative. So the engine must stamp container=1 on every container it emits. 2. draw.io preserves style keys it does not understand, and resolves a duplicate key last-wins. So dai_* markers survive a user edit, and container=1 can be appended to a catalog style without first parsing out an existing value — which matters because the AWS catalog is inconsistent about it (group_vpc, group_region, group_subnet ship without it; group_account ships with it). markers.ts — dai_kind / dai_dir / dai_gap / dai_cols / dai_pin, and the container token normalisation. types.ts — the node tree contract, plus a `foreign` bucket so cells the engine does not understand (user annotations, imported shapes) round-trip verbatim rather than being destroyed by a re-layout. parse.ts — XML → tree. Handles all four icon encodings (resIcon=, bare shape=, shape=image data URI, grIcon=), resolves nesting from parent with a geometry fallback for frames that lack container=1, recovers layout direction from the marker or infers it from child positions, and survives cycles, compressed files and multi-page decks. 75 unit tests, including a round-trip against real output from the reference project's build_vpc.mjs. One finding worth recording: the reference project's "phantom" node (a wrapper that participates in layout but emits no cell) makes the round-trip lossy by construction. In build_vpc.mjs a phantom erased a container's col direction — its children were reparented onto the grandparent, leaving a 2-D arrangement the parser can only read as a grid. 26 of the reference project's 31 examples use phantoms, so our engine needs an invisible-but-real container instead. Tracked separately.
2026-08-09 11:35:32 +09:00
style?: string
pinned?: boolean
rect?: 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
/** Position within a `pool` parent. Ignored elsewhere. */
cell?: PoolCell
feat(diagram-engine): style markers + XML→tree reverse parser Groundwork for a declarative diagram engine where the model declares nesting and the engine computes every coordinate, instead of the model emitting raw mxCell XML. The design keeps the canvas as the SINGLE source of truth: the node tree is never persisted, it is re-derived from the current canvas XML whenever needed. A user's manual edits are therefore an input to the next re-layout, not a second copy of the state that has to be reconciled. Two behaviours this relies on, both verified against the real embedded editor with a Playwright mouse drag (reading the editor's own autosave payload): 1. An AWS group stencil WITHOUT container=1 does not get a dragged shape reparented — parent stays "1" and geometry stays absolute. WITH container=1 it does: parent becomes the frame, geometry becomes parent-relative. So the engine must stamp container=1 on every container it emits. 2. draw.io preserves style keys it does not understand, and resolves a duplicate key last-wins. So dai_* markers survive a user edit, and container=1 can be appended to a catalog style without first parsing out an existing value — which matters because the AWS catalog is inconsistent about it (group_vpc, group_region, group_subnet ship without it; group_account ships with it). markers.ts — dai_kind / dai_dir / dai_gap / dai_cols / dai_pin, and the container token normalisation. types.ts — the node tree contract, plus a `foreign` bucket so cells the engine does not understand (user annotations, imported shapes) round-trip verbatim rather than being destroyed by a re-layout. parse.ts — XML → tree. Handles all four icon encodings (resIcon=, bare shape=, shape=image data URI, grIcon=), resolves nesting from parent with a geometry fallback for frames that lack container=1, recovers layout direction from the marker or infers it from child positions, and survives cycles, compressed files and multi-page decks. 75 unit tests, including a round-trip against real output from the reference project's build_vpc.mjs. One finding worth recording: the reference project's "phantom" node (a wrapper that participates in layout but emits no cell) makes the round-trip lossy by construction. In build_vpc.mjs a phantom erased a container's col direction — its children were reparented onto the grandparent, leaving a 2-D arrangement the parser can only read as a grid. 26 of the reference project's 31 examples use phantoms, so our engine needs an invisible-but-real container instead. Tracked separately.
2026-08-09 11:35:32 +09:00
}
/** A page title. At most one per diagram; laid out outside the tree flow. */
export interface TitleNode {
kind: "title"
id: string
label: string
}
/**
* A container that stacks its children in one direction.
*
* `gname` is the catalog group stencil (group_vpc, group_region, ). When null the
* container renders as a plain frame a labelled rectangle with a border.
*/
export interface GroupNode {
kind: "group"
id: string
gname: string | null
label: string
dir: Extract<Direction, "row" | "col">
gap: number
children: DiagramNode[]
fill?: string
stroke?: string
style?: string
pinned?: boolean
rect?: Rect
}
/** A container that packs its children into a fixed number of columns. */
export interface GridNode {
kind: "grid"
id: string
gname: string | null
label: string
cols: number
gap: number
children: DiagramNode[]
fill?: string
stroke?: string
style?: string
pinned?: boolean
rect?: 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
/**
* A swimlane pool: a sparse grid of (lane, column) cells.
*
* `lanes` names the role bands. Each child declares which cell it occupies, and empty
* cells stay empty that is the whole point of a swimlane diagram, where a step belongs
* to exactly one role and the columns show the order things happen in.
*
* `phases` is an optional band of milestone labels above the columns.
*/
export interface PoolNode {
kind: "pool"
id: string
label: string
/** Role names, one per band. */
lanes: string[]
/** Milestone labels spanning the columns. Empty means no milestone band. */
phases: string[]
/** "horizontal": lanes stack downwards, flow left to right. "vertical": the mirror. */
orientation: "horizontal" | "vertical"
gap: number
children: DiagramNode[]
style?: string
pinned?: boolean
rect?: Rect
}
/**
* A sequence diagram: participants across the top, lifelines hanging below them.
*
* Children are the participant heads, in left-to-right order. The messages are ordinary
* links whose `step` gives the vertical order so the same `link` operation that draws
* an arrow in a flowchart draws a message here.
*
* The engine emits the lifelines as separate cells; they are not nodes, because nothing
* ever attaches to a lifeline directly.
*/
export interface SequenceNode {
kind: "sequence"
id: string
label: string
/** Horizontal distance between participant centres. */
gap: number
/** Vertical distance between consecutive messages. */
step: number
children: DiagramNode[]
style?: string
pinned?: boolean
rect?: Rect
}
/**
* A mind map or org chart: a root with branches radiating from it.
*
* Children are a FLAT list of every node in the map. The hierarchy comes from the links
* an arrow from A to B means B is a branch of A not from nesting.
*
* That is not a shortcut, it is the only thing that works: a branch of a mind map is a
* labelled box that also has sub-branches, and a box cannot hold children. Reading the
* hierarchy from the arrows also matches what the diagram means, since in a mind map or an
* org chart the arrows ARE the structure.
*
* `spread: "radial"` fans branches out on both sides of the centre, which is what a mind
* map wants. `spread: "down"` puts every branch below the centre, which is what an org
* chart wants: a reporting line only reads correctly downwards.
*/
export interface RadialNode {
kind: "radial"
id: string
label: string
spread: "radial" | "down"
/** Distance from a parent's edge to its children. */
gap: number
children: DiagramNode[]
style?: string
pinned?: boolean
rect?: Rect
}
export type ContainerNode =
| GroupNode
| GridNode
| PoolNode
| SequenceNode
| RadialNode
feat(diagram-engine): style markers + XML→tree reverse parser Groundwork for a declarative diagram engine where the model declares nesting and the engine computes every coordinate, instead of the model emitting raw mxCell XML. The design keeps the canvas as the SINGLE source of truth: the node tree is never persisted, it is re-derived from the current canvas XML whenever needed. A user's manual edits are therefore an input to the next re-layout, not a second copy of the state that has to be reconciled. Two behaviours this relies on, both verified against the real embedded editor with a Playwright mouse drag (reading the editor's own autosave payload): 1. An AWS group stencil WITHOUT container=1 does not get a dragged shape reparented — parent stays "1" and geometry stays absolute. WITH container=1 it does: parent becomes the frame, geometry becomes parent-relative. So the engine must stamp container=1 on every container it emits. 2. draw.io preserves style keys it does not understand, and resolves a duplicate key last-wins. So dai_* markers survive a user edit, and container=1 can be appended to a catalog style without first parsing out an existing value — which matters because the AWS catalog is inconsistent about it (group_vpc, group_region, group_subnet ship without it; group_account ships with it). markers.ts — dai_kind / dai_dir / dai_gap / dai_cols / dai_pin, and the container token normalisation. types.ts — the node tree contract, plus a `foreign` bucket so cells the engine does not understand (user annotations, imported shapes) round-trip verbatim rather than being destroyed by a re-layout. parse.ts — XML → tree. Handles all four icon encodings (resIcon=, bare shape=, shape=image data URI, grIcon=), resolves nesting from parent with a geometry fallback for frames that lack container=1, recovers layout direction from the marker or infers it from child positions, and survives cycles, compressed files and multi-page decks. 75 unit tests, including a round-trip against real output from the reference project's build_vpc.mjs. One finding worth recording: the reference project's "phantom" node (a wrapper that participates in layout but emits no cell) makes the round-trip lossy by construction. In build_vpc.mjs a phantom erased a container's col direction — its children were reparented onto the grandparent, leaving a 2-D arrangement the parser can only read as a grid. 26 of the reference project's 31 examples use phantoms, so our engine needs an invisible-but-real container instead. Tracked separately.
2026-08-09 11:35:32 +09:00
export type LeafNode = IconNode | BoxNode | TitleNode
export type DiagramNode = ContainerNode | LeafNode
export interface Rect {
x: number
y: number
w: number
h: number
}
/** An arrow. Routing is the engine's business; the model only says what connects to what. */
export interface LinkSpec {
/** Cell id, so an existing edge can be addressed by later operations. */
id?: string
source: string
target: string
label?: string
/** Dashed line — replication, sync, policy, lineage. */
dashed?: boolean
/** Step number, rendered as an "N. " prefix on the label. */
step?: number
/** Verbatim style, when recovered from XML. */
style?: string
}
/** A whole diagram page: the node forest plus its arrows. */
export interface DiagramTree {
/** Top-level nodes, in layout order. */
roots: DiagramNode[]
links: LinkSpec[]
/** Page title, if the diagram has one. */
title?: string
/**
* Cells the parser could not fit into the tree a user's own annotation boxes, a
* legend, shapes from an imported file. Kept verbatim and re-emitted untouched so
* a re-layout never destroys work the engine does not understand.
*/
foreign: ForeignCell[]
}
/** A cell carried through the round-trip without interpretation. */
export interface ForeignCell {
id: string
/** The cell's own serialised XML, verbatim. */
xml: string
/** Parent id at parse time, so it can be re-attached. */
parent: string
}
export function isContainer(n: DiagramNode): n is ContainerNode {
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.kind === "group" ||
n.kind === "grid" ||
n.kind === "pool" ||
n.kind === "sequence" ||
n.kind === "radial"
)
feat(diagram-engine): style markers + XML→tree reverse parser Groundwork for a declarative diagram engine where the model declares nesting and the engine computes every coordinate, instead of the model emitting raw mxCell XML. The design keeps the canvas as the SINGLE source of truth: the node tree is never persisted, it is re-derived from the current canvas XML whenever needed. A user's manual edits are therefore an input to the next re-layout, not a second copy of the state that has to be reconciled. Two behaviours this relies on, both verified against the real embedded editor with a Playwright mouse drag (reading the editor's own autosave payload): 1. An AWS group stencil WITHOUT container=1 does not get a dragged shape reparented — parent stays "1" and geometry stays absolute. WITH container=1 it does: parent becomes the frame, geometry becomes parent-relative. So the engine must stamp container=1 on every container it emits. 2. draw.io preserves style keys it does not understand, and resolves a duplicate key last-wins. So dai_* markers survive a user edit, and container=1 can be appended to a catalog style without first parsing out an existing value — which matters because the AWS catalog is inconsistent about it (group_vpc, group_region, group_subnet ship without it; group_account ships with it). markers.ts — dai_kind / dai_dir / dai_gap / dai_cols / dai_pin, and the container token normalisation. types.ts — the node tree contract, plus a `foreign` bucket so cells the engine does not understand (user annotations, imported shapes) round-trip verbatim rather than being destroyed by a re-layout. parse.ts — XML → tree. Handles all four icon encodings (resIcon=, bare shape=, shape=image data URI, grIcon=), resolves nesting from parent with a geometry fallback for frames that lack container=1, recovers layout direction from the marker or infers it from child positions, and survives cycles, compressed files and multi-page decks. 75 unit tests, including a round-trip against real output from the reference project's build_vpc.mjs. One finding worth recording: the reference project's "phantom" node (a wrapper that participates in layout but emits no cell) makes the round-trip lossy by construction. In build_vpc.mjs a phantom erased a container's col direction — its children were reparented onto the grandparent, leaving a 2-D arrangement the parser can only read as a grid. 26 of the reference project's 31 examples use phantoms, so our engine needs an invisible-but-real container instead. Tracked separately.
2026-08-09 11:35:32 +09:00
}
export function isLeaf(n: DiagramNode): n is LeafNode {
return !isContainer(n)
}
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 container that can carry a catalog group stencil and a hand-set fill or stroke.
*
* The specialised containers draw their own chrome a pool paints lane bands, a sequence
* paints lifelines so a stencil frame or an arbitrary fill would fight what they emit.
*/
export function hasStencilFrame(n: DiagramNode): n is GroupNode | GridNode {
return n.kind === "group" || n.kind === "grid"
}
/** A container whose children stack along one axis, so `dir` is meaningful. */
export function isDirectional(n: DiagramNode): n is GroupNode {
return n.kind === "group"
}
feat(diagram-engine): style markers + XML→tree reverse parser Groundwork for a declarative diagram engine where the model declares nesting and the engine computes every coordinate, instead of the model emitting raw mxCell XML. The design keeps the canvas as the SINGLE source of truth: the node tree is never persisted, it is re-derived from the current canvas XML whenever needed. A user's manual edits are therefore an input to the next re-layout, not a second copy of the state that has to be reconciled. Two behaviours this relies on, both verified against the real embedded editor with a Playwright mouse drag (reading the editor's own autosave payload): 1. An AWS group stencil WITHOUT container=1 does not get a dragged shape reparented — parent stays "1" and geometry stays absolute. WITH container=1 it does: parent becomes the frame, geometry becomes parent-relative. So the engine must stamp container=1 on every container it emits. 2. draw.io preserves style keys it does not understand, and resolves a duplicate key last-wins. So dai_* markers survive a user edit, and container=1 can be appended to a catalog style without first parsing out an existing value — which matters because the AWS catalog is inconsistent about it (group_vpc, group_region, group_subnet ship without it; group_account ships with it). markers.ts — dai_kind / dai_dir / dai_gap / dai_cols / dai_pin, and the container token normalisation. types.ts — the node tree contract, plus a `foreign` bucket so cells the engine does not understand (user annotations, imported shapes) round-trip verbatim rather than being destroyed by a re-layout. parse.ts — XML → tree. Handles all four icon encodings (resIcon=, bare shape=, shape=image data URI, grIcon=), resolves nesting from parent with a geometry fallback for frames that lack container=1, recovers layout direction from the marker or infers it from child positions, and survives cycles, compressed files and multi-page decks. 75 unit tests, including a round-trip against real output from the reference project's build_vpc.mjs. One finding worth recording: the reference project's "phantom" node (a wrapper that participates in layout but emits no cell) makes the round-trip lossy by construction. In build_vpc.mjs a phantom erased a container's col direction — its children were reparented onto the grandparent, leaving a 2-D arrangement the parser can only read as a grid. 26 of the reference project's 31 examples use phantoms, so our engine needs an invisible-but-real container instead. Tracked separately.
2026-08-09 11:35:32 +09:00
/** Depth-first walk over a node and its descendants. */
export function* walk(n: DiagramNode): Generator<DiagramNode> {
yield n
if (isContainer(n)) for (const c of n.children) yield* walk(c)
}
/** Every node in a tree, in document order. */
export function* walkTree(t: DiagramTree): Generator<DiagramNode> {
for (const r of t.roots) yield* walk(r)
}
/** Find a node by id, or null. */
export function findNode(t: DiagramTree, id: string): DiagramNode | null {
for (const n of walkTree(t)) if (n.id === id) return n
return null
}
/** The container holding `id`, or null when it is a root or absent. */
export function findParent(t: DiagramTree, id: string): ContainerNode | null {
for (const n of walkTree(t)) {
if (!isContainer(n)) continue
if (n.children.some((c) => c.id === id)) return n
}
return null
}