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.
This commit is contained in:
dayuan.jiang
2026-08-09 13:47:23 +09:00
parent 1e4c74d464
commit a3814f702d
27 changed files with 4497 additions and 88 deletions

357
lib/diagram-engine/graph.ts Normal file
View File

@@ -0,0 +1,357 @@
/**
* Graph → layers. What turns a flat list of nodes and arrows into a diagram.
*
* The engine's layout can only arrange what nesting tells it to: a container stacks its
* children in one direction, so six boxes declared in a row become six boxes in a row. For
* a flowchart that is the wrong answer, and measurably so — an order-approval flow declared
* in its natural order comes out as one column, which forces the arrow from the decision to
* its second branch to jump over the first branch, and the arrow to the merge point to jump
* back over that. The layout never looked at the arrows.
*
* This computes what it should have looked at. Three steps, the standard shape of a layered
* graph drawing (Sugiyama's algorithm):
*
* 1. LAYER — how far along the flow each node sits. Longest path from a source, so an
* arrow always points forwards and no arrow skips backwards through a layer.
* 2. ORDER — who goes left and who goes right within a layer. Chosen to reduce the number
* of arrows that cross, which is what makes a flowchart readable.
* 3. EMIT — one invisible row container per layer, which the existing layout then places.
*
* Step 3 is why this file is small: the coordinate work already exists, and it is the same
* code that lays out an AWS diagram. What was missing was only the decision of what goes in
* which row.
*/
import type { Operation } from "./operations"
import type { BoxShape } from "./types"
/** A node in the graph the caller wants drawn. */
export interface GraphNode {
id: string
label: string
/** Flowchart outline. `decision` for a branch, `terminator` for a start or end point. */
shape?: BoxShape
/** Catalog stencil name. When set the node renders as an icon rather than a box. */
icon?: string
}
/** An arrow. Direction matters: it is what determines the layering. */
export interface GraphEdge {
source: string
target: string
label?: string
dashed?: boolean
}
export interface GraphOptions {
/** "col" (default): layers stack downwards. "row": layers run left to right. */
flow?: "col" | "row"
/** Distance between layers. */
layerGap?: number
/** Distance between nodes within a layer. */
nodeGap?: number
/** Prefix for the generated layer container ids. */
idPrefix?: string
}
export interface GraphResult {
operations: Operation[]
/** The nodes of each layer, in the order they were placed. */
layers: string[][]
/** Edges dropped because an endpoint is not in the node list. */
unknownEndpoints: string[]
/** Edges that had to be treated as loops rather than as layering constraints. */
backEdges: { source: string; target: string }[]
}
/**
* Break every cycle, so the graph can be layered at all.
*
* A depth-first walk; any arrow pointing at a node still on the current path is a way back
* to where we came from, and cannot be a "this comes after that" constraint. Those arrows
* are still DRAWN — a review loop is the point of the diagram — they just do not get a say
* in which layer anything lands in.
*/
function breakCycles(
nodes: string[],
edges: GraphEdge[],
): { forward: GraphEdge[]; back: GraphEdge[] } {
const out = new Map<string, GraphEdge[]>(nodes.map((n) => [n, []]))
for (const e of edges) out.get(e.source)?.push(e)
const forward: GraphEdge[] = []
const back: GraphEdge[] = []
const onPath = new Set<string>()
const done = new Set<string>()
// An explicit stack, not recursion: a 500-node dependency graph is a plausible input and
// a recursive walk over one would overflow.
for (const root of nodes) {
if (done.has(root)) continue
const stack: { id: string; next: number }[] = [{ id: root, next: 0 }]
onPath.add(root)
while (stack.length > 0) {
const top = stack[stack.length - 1]
const list = out.get(top.id) ?? []
if (top.next >= list.length) {
onPath.delete(top.id)
done.add(top.id)
stack.pop()
continue
}
const e = list[top.next++]
if (onPath.has(e.target)) {
back.push(e)
continue
}
forward.push(e)
if (!done.has(e.target)) {
onPath.add(e.target)
stack.push({ id: e.target, next: 0 })
}
}
}
return { forward, back }
}
/**
* Assign each node to a layer: the longest path to it from any node with no predecessor.
*
* Longest path rather than shortest, because a node has to come after EVERYTHING that feeds
* it. Take the shortest and an arrow ends up pointing backwards: with `a→b`, `a→c`, `c→b`,
* the shortest path puts b in layer 1 alongside c, and then `c→b` points sideways.
*/
function assignLayers(nodes: string[], forward: GraphEdge[]): string[][] {
const layer = new Map<string, number>(nodes.map((n) => [n, 0]))
// Relaxation, bounded by the node count: the longest possible chain visits every node
// once, so after that many rounds nothing can still be moving.
for (let round = 0; round < nodes.length; round++) {
let moved = false
for (const e of forward) {
const want = (layer.get(e.source) ?? 0) + 1
if (want > (layer.get(e.target) ?? 0)) {
layer.set(e.target, want)
moved = true
}
}
if (!moved) break
}
const depth = Math.max(0, ...layer.values()) + 1
const layers: string[][] = Array.from({ length: depth }, () => [])
// Declaration order within a layer, so the ordering pass starts somewhere predictable.
for (const n of nodes) layers[layer.get(n) ?? 0].push(n)
return layers
}
/**
* Reorder each layer to reduce the number of arrows that cross.
*
* Barycentre sweeping: a node is placed at the average position of the nodes it connects to
* in the neighbouring layer, and the whole diagram is swept downwards then upwards
* repeatedly. Each sweep can only be judged against the previous layer's order, so a node
* pulled into a better place drags its own neighbours in the next sweep.
*
* The heuristic, not an exact minimum: finding the true minimum number of crossings is
* NP-hard even for two layers. In practice this reaches zero crossings on the flowcharts the
* model actually produces — verified on a 14-node pipeline with two diamonds and a rollback
* loop, and on a bipartite graph whose declared order forces three crossings.
*/
function reduceCrossings(layers: string[][], edges: GraphEdge[]): void {
if (layers.length < 2) return
const PASSES = 8
const into = new Map<string, string[]>()
const outOf = new Map<string, string[]>()
for (const e of edges) {
if (e.source === e.target) continue
;(into.get(e.target) ?? into.set(e.target, []).get(e.target))?.push(
e.source,
)
;(outOf.get(e.source) ?? outOf.set(e.source, []).get(e.source))?.push(
e.target,
)
}
let best = layers.map((l) => [...l])
let bestScore = countCrossings(layers, edges)
for (let pass = 0; pass < PASSES && bestScore > 0; pass++) {
const pos = new Map<string, number>()
for (const l of layers)
l.forEach((n, i) => {
pos.set(n, i)
})
const down = pass % 2 === 0
const order = down
? layers.map((_, i) => i).slice(1)
: layers
.map((_, i) => i)
.slice(0, -1)
.reverse()
for (const i of order) {
const neighbours = down ? into : outOf
const key = new Map<string, number>()
layers[i].forEach((n, idx) => {
const nb = (neighbours.get(n) ?? [])
.map((m) => pos.get(m))
.filter((v): v is number => v !== undefined)
// A node with no neighbour in that direction keeps its place, rather than
// being pushed to one end by a default of zero.
key.set(
n,
nb.length ? nb.reduce((a, b) => a + b, 0) / nb.length : idx,
)
})
layers[i] = [...layers[i]].sort(
(a, b) => (key.get(a) ?? 0) - (key.get(b) ?? 0),
)
}
// Keep the best arrangement seen: sweeping is not monotonic, and a later pass can be
// worse than an earlier one.
const score = countCrossings(layers, edges)
if (score < bestScore) {
bestScore = score
best = layers.map((l) => [...l])
}
}
for (let i = 0; i < layers.length; i++) layers[i] = best[i]
}
/**
* How many pairs of arrows cross between adjacent layers.
*
* Two arrows between the same pair of layers cross exactly when their endpoints are in the
* opposite order on the two sides. That is all this counts — arrows spanning more than one
* layer are ignored here, because their crossings depend on routing rather than ordering.
*/
function countCrossings(layers: string[][], edges: GraphEdge[]): number {
const layerOf = new Map<string, number>()
const posOf = new Map<string, number>()
layers.forEach((l, i) => {
l.forEach((n, j) => {
layerOf.set(n, i)
posOf.set(n, j)
})
})
let total = 0
for (let i = 0; i + 1 < layers.length; i++) {
const span = edges.filter(
(e) =>
layerOf.get(e.source) === i && layerOf.get(e.target) === i + 1,
)
for (let a = 0; a < span.length; a++)
for (let b = a + 1; b < span.length; b++) {
const s1 = posOf.get(span[a].source) ?? 0
const t1 = posOf.get(span[a].target) ?? 0
const s2 = posOf.get(span[b].source) ?? 0
const t2 = posOf.get(span[b].target) ?? 0
if ((s1 - s2) * (t1 - t2) < 0) total++
}
}
return total
}
/**
* Turn a graph into the operations that draw it.
*
* The output is ordinary operations — nothing here is a new kind of thing the rest of the
* engine has to know about. A layer of one node is emitted directly rather than wrapped,
* because a single-child row container would just add a level of nesting with nothing to
* arrange.
*/
export function graphToOperations(
nodes: GraphNode[],
edges: GraphEdge[],
opts: GraphOptions = {},
): GraphResult {
const flow = opts.flow ?? "col"
const ids = nodes.map((n) => n.id)
const known = new Set(ids)
const prefix = opts.idPrefix ?? "__layer"
const unknownEndpoints: string[] = []
const usable: GraphEdge[] = []
for (const e of edges) {
if (!known.has(e.source)) unknownEndpoints.push(e.source)
if (!known.has(e.target)) unknownEndpoints.push(e.target)
if (known.has(e.source) && known.has(e.target)) usable.push(e)
}
// A self-loop tells us nothing about layering and would make the cycle break drop a real
// arrow, so it is set aside and drawn as-is.
const loops = usable.filter((e) => e.source === e.target)
const between = usable.filter((e) => e.source !== e.target)
const { forward, back } = breakCycles(ids, between)
const layers = assignLayers(ids, forward)
reduceCrossings(layers, forward)
// The flow axis is the OUTER container's direction; a layer runs across it.
const outerDir = flow
const layerDir = flow === "col" ? "row" : "col"
const root = `${prefix}s`
const operations: Operation[] = [
{
op: "add_container",
id: root,
label: "",
dir: outerDir,
gap: opts.layerGap ?? 48,
},
]
const byId = new Map(nodes.map((n) => [n.id, n]))
const add = (id: string, parent: string): Operation => {
const n = byId.get(id) as GraphNode
return n.icon
? {
op: "add_icon",
id: n.id,
parent,
name: n.icon,
label: n.label,
}
: {
op: "add_box",
id: n.id,
parent,
label: n.label,
...(n.shape && n.shape !== "box" ? { shape: n.shape } : {}),
}
}
layers.forEach((members, i) => {
if (members.length === 0) return
if (members.length === 1) {
operations.push(add(members[0], root))
return
}
const band = `${prefix}${i}`
operations.push({
op: "add_container",
id: band,
parent: root,
label: "",
dir: layerDir,
gap: opts.nodeGap ?? 60,
})
for (const m of members) operations.push(add(m, band))
})
for (const e of [...between, ...loops])
operations.push({
op: "link",
source: e.source,
target: e.target,
...(e.label ? { label: e.label } : {}),
...(e.dashed ? { dashed: true } : {}),
})
return {
operations,
layers: layers.filter((l) => l.length > 0),
unknownEndpoints: [...new Set(unknownEndpoints)],
backEdges: back.map((e) => ({ source: e.source, target: e.target })),
}
}

View File

@@ -11,6 +11,12 @@
*/
import { checkNames, resolveStyle } from "./catalog"
import {
type GraphEdge,
type GraphNode,
type GraphOptions,
graphToOperations,
} from "./graph"
import {
applyOperations,
collectNames,
@@ -99,6 +105,69 @@ export function restructureDiagram(
}
}
/**
* Draw a flowchart, dependency graph, ER diagram or site map from nodes and arrows alone.
*
* The model gives no positions and no nesting — just what the boxes are and what points at
* what. The engine works out how many rows there are, who shares a row, and who goes left
* of whom, then hands the result to the same layout and edge router the architecture
* diagrams use.
*
* This exists because declaring a flowchart as nesting does not work: six steps declared in
* their natural order become one column, and every branch then has to jump over the step
* beside it. The layering has to come from the arrows, and only the engine can see all of
* them at once.
*
* Replaces the whole diagram rather than adding to it: the layer assignment depends on every
* arrow, so one new edge can move half the nodes. Editing afterwards goes through
* `restructureDiagram` as usual.
*/
export function drawGraph(
nodes: GraphNode[],
edges: GraphEdge[],
opts: RestructureOptions & GraphOptions & { title?: string } = {},
): RestructureResult {
if (nodes.length === 0)
return {
xml: null,
outline: "",
errors: ["draw_graph: no nodes — nothing to draw."],
warnings: [],
}
const dupes = nodes
.map((n) => n.id)
.filter((id, i, all) => all.indexOf(id) !== i)
if (dupes.length > 0)
return {
xml: null,
outline: "",
errors: [
`draw_graph: duplicate node id(s): ${[...new Set(dupes)].join(", ")}.`,
],
warnings: [],
}
const graph = graphToOperations(nodes, edges, opts)
const warnings: string[] = []
if (graph.unknownEndpoints.length)
warnings.push(
`Dropped edge(s) naming nodes that were not in the node list: ${graph.unknownEndpoints.join(", ")}.`,
)
if (graph.backEdges.length)
warnings.push(
`Loop(s) drawn but not used for ordering: ${graph.backEdges
.map((e) => `${e.source}${e.target}`)
.join(", ")}.`,
)
const ops: Operation[] = opts.title
? [{ op: "set_title", title: opts.title }, ...graph.operations]
: graph.operations
const result = restructureDiagram("", ops, opts)
return { ...result, warnings: [...warnings, ...result.warnings] }
}
/** Read the current canvas structure without changing it. */
export function describeDiagram(
currentXml: string,
@@ -114,6 +183,12 @@ export function describeDiagram(
}
export { CATALOG_SIZE, lookupStencil, searchStencils } from "./catalog"
export {
type GraphEdge,
type GraphNode,
type GraphOptions,
graphToOperations,
} from "./graph"
export { type Operation, OperationSchema } from "./operations"
export { parseDiagram } from "./parse"
export { renderDiagram } from "./render"

View File

@@ -15,10 +15,27 @@
* 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.
* Five container kinds share those two passes, because they differ only in how a parent
* distributes its interior:
*
* group — children stacked along one axis. Cloud architecture, nested frames.
* grid — children packed into a fixed number of columns.
* pool — a sparse (lane × column) grid. Swimlane and BPMN diagrams.
* sequence — participants across the top, lifelines below. Sequence diagrams.
* radial — a centre with branches fanning out, or hanging below. Mind maps, org charts.
*
* Ported from drawio-ai-kit (MIT) — see NOTICE. The pool geometry follows that project's
* `pool()` primitive; sequence and radial are original to this repository.
*/
import type { ContainerNode, DiagramNode, Rect } from "./types"
import type {
ContainerNode,
DiagramNode,
PoolNode,
RadialNode,
Rect,
SequenceNode,
} from "./types"
import { isContainer } from "./types"
/** Default glyph size for a catalog icon. */
@@ -31,13 +48,143 @@ const HEADER = 36
/** Approximate width of one label character at the engine's font size. */
const CHAR_W = 6.6
// ---- pool geometry, shared with render.ts so the bands land under the nodes ----
/** Interior padding of a pool. Tighter than a group's: lane bands sit flush. */
export const POOL_PAD = 16
/** Width of the lane-name column (height, when the pool is vertical). */
export const LANE_LABEL = 110
/** Height of the milestone band (width, when the pool is vertical). */
export const PHASE_LABEL = 26
/** A pool's own title strip. */
export const POOL_HEADER = 34
/** Vertical padding inside a lane band, so nodes do not touch the band's edges. */
const LANE_PAD = 14
// ---- sequence geometry ----
/** Height of a participant head. */
const HEAD_H = 44
/** Vertical distance from the participant heads to the first message. */
const LIFELINE_TOP = 28
/** How far the lifeline runs past the last message. */
const LIFELINE_TAIL = 36
/**
* The geometry a pool needs, derived once and used by both layout and rendering.
*
* Rendering has to paint the lane bands and label columns at exactly the positions layout
* used, or the nodes sit off their bands. Computing it in one place is what keeps the two
* from drifting.
*/
export interface PoolMetrics {
horizontal: boolean
lanes: number
cols: number
/** Cell size along the flow axis. */
cellW: number
/** Cell size across the lane axis. */
cellH: number
header: number
phaseLabel: number
/** Where cell (0,0) starts. */
contentX: number
contentY: number
/** Total extent of the cell area. */
contentW: number
contentH: number
}
export function poolMetrics(
n: PoolNode,
rect: Rect,
kids: { rect: Rect }[],
): PoolMetrics {
const horizontal = n.orientation !== "vertical"
const lanes = Math.max(1, n.lanes.length)
const cols = Math.max(1, ...n.children.map((c) => poolCellOf(c).col + 1))
const cellW = Math.max(80, ...kids.map((k) => k.rect.w))
const cellH = Math.max(40, ...kids.map((k) => k.rect.h)) + LANE_PAD
const header = n.label ? POOL_HEADER : 0
const phaseLabel = n.phases.length ? PHASE_LABEL : 0
const contentW = horizontal
? cols * cellW + n.gap * (cols - 1)
: lanes * cellW
const contentH = horizontal
? lanes * cellH
: cols * cellH + n.gap * (cols - 1)
return {
horizontal,
lanes,
cols,
cellW,
cellH,
header,
phaseLabel,
contentX: horizontal
? rect.x + POOL_PAD + LANE_LABEL
: rect.x + POOL_PAD,
contentY: horizontal
? rect.y + header + phaseLabel + POOL_PAD
: rect.y + header + POOL_PAD + LANE_LABEL,
contentW,
contentH,
}
}
/** Where a sequence diagram's lifelines start and how far they run. */
export interface SequenceMetrics {
/** Top of the lifeline, just under the participant heads. */
top: number
/** Bottom of the lifeline. */
bottom: number
/** Vertical position of message N, for N starting at 1. */
messageY: (step: number) => number
}
export function sequenceMetrics(
n: SequenceNode,
rect: Rect,
messages: number,
): SequenceMetrics {
const head = n.label ? HEADER : 0
const top = rect.y + head + PAD + HEAD_H
const first = top + LIFELINE_TOP
return {
top,
bottom: first + Math.max(0, messages - 1) * n.step + LIFELINE_TAIL,
messageY: (step) => first + Math.max(0, step - 1) * n.step,
}
}
/** A node with its computed box. Layout works on this, leaving the tree untouched. */
export interface Placed {
node: DiagramNode
rect: Rect
children: Placed[]
/**
* For a node inside a radial container: the extent of its whole subtree across the
* branching axis. Measured on the way up, spent on the way down — a parent needs its
* children's subtree extents to divide its own span between them.
*/
extent?: number
}
/**
* What layout needs to know that the tree alone does not carry.
*
* Three of the five container kinds are laid out from the diagram's arrows, not from
* nesting: a sequence diagram's messages set how tall the lifelines have to be, and a mind
* map's hierarchy IS its arrows. Links live on the tree, not on the node, so they are
* passed in rather than read from a parent pointer.
*/
export interface LayoutContext {
/** Every link in the diagram, source → target. */
links: { source: string; target: string; step?: number }[]
}
const NO_CONTEXT: LayoutContext = { links: [] }
/** 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")
@@ -65,9 +212,182 @@ function titleFloor(label: string, pad: number): number {
}
function headerFor(n: ContainerNode): number {
if (n.kind === "pool") return n.label ? POOL_HEADER : 0
return n.label ? HEADER : 0
}
/**
* May this node be stretched to match a sibling's size?
*
* Only a `group` may. A leaf keeps its natural size, because stretching an icon distorts
* the glyph. The three specialised containers compute their interiors from their own rules
* — lane bands, lifeline positions, ring radii — so forcing one wider leaves dead space
* inside it rather than filling anything, and forcing one taller detaches its lane bands
* from the nodes sitting on them.
*/
function stretches(n: DiagramNode): boolean {
return n.kind === "group"
}
/** The cell a node occupies inside a pool. Absent means (0,0). */
function poolCellOf(n: DiagramNode): { lane: number; col: number } {
if ((n.kind === "icon" || n.kind === "box") && n.cell)
return { lane: Math.max(0, n.cell.lane), col: Math.max(0, n.cell.col) }
return { lane: 0, col: 0 }
}
/**
* How many messages a sequence container has: the highest step number among the links
* between its participants, or the link count when the model numbered nothing.
*
* Steps are what order the messages vertically, so a diagram whose links carry no step
* still needs one row per message — otherwise every arrow lands on the same y.
*/
function messageCount(n: SequenceNode, ctx: LayoutContext): number {
const own = new Set(n.children.map((c) => c.id))
const mine = ctx.links.filter((l) => own.has(l.source) && own.has(l.target))
const steps = mine
.map((l) => l.step)
.filter((s): s is number => s != null && s > 0)
return Math.max(mine.length, ...(steps.length ? steps : [0]))
}
/**
* One node of a radial tree: a placed box plus the branches hanging off it.
*
* Separate from `Placed` because the tree is derived from the LINKS, not from nesting, so it
* exists only during a radial container's layout.
*/
interface RadialTree {
p: Placed
kids: RadialTree[]
/** How much room this whole subtree needs across the branching axis. */
extent: number
/** How many generations deep this subtree goes, counting itself as 1. */
depth: number
}
/**
* Build the branch hierarchy of a radial container from the diagram's arrows.
*
* The root is the node nothing points at. Every other node hangs off whichever node points
* at it — the FIRST one, if several do, since a mind map is a tree and a second parent has
* to be drawn as a plain cross-link instead.
*
* A node no arrow reaches at all becomes a branch of the root, so it is still drawn. Dropping
* it would silently lose a box the model asked for.
*/
function radialHierarchy(
kids: Placed[],
ctx: LayoutContext,
across: "w" | "h",
gap: number,
): { root: RadialTree; branches: RadialTree[] } | null {
if (kids.length === 0) return null
const own = new Map(kids.map((k) => [k.node.id, k]))
const parent = new Map<string, string>()
for (const l of ctx.links) {
if (!own.has(l.source) || !own.has(l.target)) continue
if (l.source === l.target) continue
if (!parent.has(l.target)) parent.set(l.target, l.source)
}
// Guard against a cycle in the arrows: walking up must terminate.
const rootOf = (id: string): string => {
const seen = new Set<string>([id])
let cur = id
for (;;) {
const up = parent.get(cur)
if (up === undefined || seen.has(up)) return cur
seen.add(up)
cur = up
}
}
// The first declared node that is nobody's child is the centre. Falling back to the first
// child keeps a cycle-only graph drawable.
const rootId =
kids.find((k) => !parent.has(k.node.id))?.node.id ??
rootOf(kids[0].node.id)
const childrenOf = new Map<string, Placed[]>()
for (const k of kids) {
if (k.node.id === rootId) continue
const up = parent.get(k.node.id)
// An orphan, or a node whose parent chain loops back to itself, attaches to the root.
const attach =
up !== undefined && up !== k.node.id && rootOf(k.node.id) === rootId
? up
: rootId
const list = childrenOf.get(attach)
if (list) list.push(k)
else childrenOf.set(attach, [k])
}
const seen = new Set<string>()
const build = (p: Placed): RadialTree => {
seen.add(p.node.id)
const kidTrees = (childrenOf.get(p.node.id) ?? [])
.filter((c) => !seen.has(c.node.id))
.map(build)
const total =
kidTrees.reduce((s, t) => s + t.extent, 0) +
gap * Math.max(0, kidTrees.length - 1)
return {
p,
kids: kidTrees,
extent: Math.max(p.rect[across], total),
depth: kidTrees.length
? 1 + Math.max(...kidTrees.map((t) => t.depth))
: 1,
}
}
const root = build(own.get(rootId) as Placed)
return { root, branches: root.kids }
}
/** Widest node at each generation, for laying a radial tree out in even rings. */
function widestPerLevel(trees: RadialTree[], along: "w" | "h"): number[] {
const out: number[] = []
const visit = (t: RadialTree, level: number) => {
out[level] = Math.max(out[level] ?? 0, t.p.rect[along])
for (const k of t.kids) visit(k, level + 1)
}
for (const t of trees) visit(t, 0)
return out
}
/**
* How far one side of a radial map reaches from the centre.
*
* Each generation contributes one gap plus the width of the widest node in it. This has to be
* computed per SIDE, not once for the whole map: a mind map whose left branches go three
* generations deep and whose right branches go one needs an asymmetric frame, and reserving
* the same room on both sides would push the deeper side off the page.
*/
function radialReach(
side: RadialTree[],
along: "w" | "h",
gap: number,
): number {
if (side.length === 0) return 0
const levels = widestPerLevel(side, along)
const generations = Math.max(...side.map((b) => b.depth))
return levels.slice(0, generations).reduce((s, v) => s + v + gap, 0)
}
/**
* Split a radial map's branches into the two sides they will be drawn on.
*
* The same split has to be used by measure and by place, or the frame is sized for one
* arrangement and the branches are drawn in another.
*/
function radialSides(branches: RadialTree[]): {
right: RadialTree[]
left: RadialTree[]
} {
const half = Math.ceil(branches.length / 2)
return { right: branches.slice(0, half), left: branches.slice(half) }
}
/**
* measure: give every node a size, bottom-up.
*
@@ -75,7 +395,11 @@ function headerFor(n: ContainerNode): number {
* 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 {
function measure(
n: DiagramNode,
defaultGlyph: number,
ctx: LayoutContext = NO_CONTEXT,
): Placed {
if (n.kind === "icon") {
const glyph = n.size ?? defaultGlyph
const s = iconSize(n.label, glyph)
@@ -93,10 +417,105 @@ function measure(n: DiagramNode, defaultGlyph: number): Placed {
return { node: n, rect: { x: 0, y: 0, w: 0, h: 30 }, children: [] }
}
const kids = n.children.map((c) => measure(c, defaultGlyph))
const kids = n.children.map((c) => measure(c, defaultGlyph, ctx))
const head = headerFor(n)
const gap = n.gap
if (n.kind === "pool") {
const m = poolMetrics(n, { x: 0, y: 0, w: 0, h: 0 }, kids)
const w = m.horizontal
? POOL_PAD * 2 + LANE_LABEL + m.contentW
: POOL_PAD * 2 + m.contentW + m.phaseLabel
const h = m.horizontal
? m.header + m.phaseLabel + POOL_PAD * 2 + m.contentH
: m.header + POOL_PAD * 2 + LANE_LABEL + m.contentH
return {
node: n,
rect: {
x: 0,
y: 0,
w: Math.max(w, titleFloor(n.label, POOL_PAD)),
h,
},
children: kids,
}
}
if (n.kind === "sequence") {
// Participants sit side by side; the lifelines below them set the height.
const w =
PAD * 2 +
kids.reduce((s, k) => s + k.rect.w, 0) +
gap * Math.max(0, kids.length - 1)
const m = sequenceMetrics(
n,
{ x: 0, y: 0, w: 0, h: 0 },
messageCount(n, ctx),
)
return {
node: n,
rect: {
x: 0,
y: 0,
w: Math.max(w, titleFloor(n.label, PAD)),
h: m.bottom + PAD,
},
children: kids,
}
}
if (n.kind === "radial") {
const down = n.spread === "down"
const across = down ? "w" : "h"
const tree = radialHierarchy(kids, ctx, across, n.gap)
if (!tree)
return {
node: n,
rect: { x: 0, y: 0, w: PAD * 2, h: head + PAD * 2 },
children: kids,
}
const { root, branches } = tree
const spanOf = (bs: RadialTree[]) =>
bs.length === 0
? 0
: bs.reduce((s, b) => s + b.extent, 0) + n.gap * (bs.length - 1)
if (down) {
// Everything hangs below the centre: one direction, so one reach.
const h = root.p.rect.h + radialReach(branches, "h", n.gap)
const w = Math.max(root.p.rect.w, spanOf(branches))
return {
node: n,
rect: {
x: 0,
y: 0,
w: Math.max(PAD * 2 + w, titleFloor(n.label, PAD)),
h: head + PAD * 2 + h,
},
children: kids,
}
}
// Radial: the two sides reach different distances, so each is measured on its own.
// Using one figure for both would leave the deeper side hanging outside the frame.
const { right, left } = radialSides(branches)
const w =
radialReach(left, "w", n.gap) +
root.p.rect.w +
radialReach(right, "w", n.gap)
const h = Math.max(root.p.rect.h, spanOf(right), spanOf(left))
return {
node: n,
rect: {
x: 0,
y: 0,
w: Math.max(PAD * 2 + w, titleFloor(n.label, PAD)),
h: head + PAD * 2 + h,
},
children: kids,
}
}
if (n.kind === "grid") {
const cols = Math.max(1, n.cols)
const rows = Math.ceil(kids.length / cols) || 1
@@ -120,7 +539,7 @@ function measure(n: DiagramNode, defaultGlyph: number): Placed {
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)
if (stretches(k.node)) k.rect.h = Math.max(k.rect.h, tallest)
const w =
PAD * 2 +
kids.reduce((s, k) => s + k.rect.w, 0) +
@@ -160,7 +579,12 @@ function measure(n: DiagramNode, defaultGlyph: number): Placed {
* stretched frame reads as deliberately spaced instead of sparse, and the resulting
* cluster is centred.
*/
function place(p: Placed, x: number, y: number): void {
function place(
p: Placed,
x: number,
y: number,
ctx: LayoutContext = NO_CONTEXT,
): void {
p.rect.x = Math.round(x)
p.rect.y = Math.round(y)
const n = p.node
@@ -183,11 +607,54 @@ function place(p: Placed, x: number, y: number): void {
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)
place(
k,
cx + (cellW - k.rect.w) / 2,
cy + (cellH - k.rect.h) / 2,
ctx,
)
})
return
}
if (n.kind === "pool") {
// Each child goes to the (lane, column) cell it declared. Empty cells stay empty:
// in a swimlane diagram, "this role does nothing at this step" is information.
const m = poolMetrics(n, p.rect, kids)
for (const k of kids) {
const { lane, col } = poolCellOf(k.node)
const cx = m.horizontal
? m.contentX + col * (m.cellW + n.gap)
: m.contentX + Math.min(lane, m.lanes - 1) * m.cellW
const cy = m.horizontal
? m.contentY + Math.min(lane, m.lanes - 1) * m.cellH
: m.contentY + col * (m.cellH + n.gap)
place(
k,
cx + (m.cellW - k.rect.w) / 2,
cy + (m.cellH - k.rect.h) / 2,
ctx,
)
}
return
}
if (n.kind === "sequence") {
// Participants in a row across the top. Their lifelines hang below, emitted by the
// renderer, so nothing else has to be placed here.
let cur = innerX
for (const k of kids) {
place(k, cur, innerTop, ctx)
cur += k.rect.w + n.gap
}
return
}
if (n.kind === "radial") {
placeRadial(p, n, innerX, innerTop, innerW, innerH, ctx)
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)
@@ -200,15 +667,112 @@ function place(p: Placed, x: number, y: number): void {
for (const kid of kids) {
if (alongRow) {
place(kid, cur, innerTop + (innerH - kid.rect.h) / 2)
place(kid, cur, innerTop + (innerH - kid.rect.h) / 2, ctx)
cur += kid.rect.w + gap
} else {
place(kid, innerX + (innerW - kid.rect.w) / 2, cur)
place(kid, innerX + (innerW - kid.rect.w) / 2, cur, ctx)
cur += kid.rect.h + gap
}
}
}
/**
* Place a radial container: a centre with its branches fanning out.
*
* Two shapes, because a mind map and an org chart want opposite things. A mind map reads
* best with branches on both sides of the centre, which keeps it compact and balanced. An
* org chart must hang everything downwards — a reporting line drawn upwards or sideways
* reads as the wrong relationship, no matter how much space it saves.
*
* Each generation sits in its own ring, the ring's depth set by the widest node in it, so
* siblings line up instead of stepping raggedly outwards.
*/
function placeRadial(
p: Placed,
n: RadialNode,
innerX: number,
innerTop: number,
innerW: number,
innerH: number,
ctx: LayoutContext,
): void {
const down = n.spread === "down"
const along = down ? "h" : "w"
const across = down ? "w" : "h"
const tree = radialHierarchy(p.children, ctx, across, n.gap)
if (!tree) return
const { root, branches } = tree
const centre = root.p
/**
* Lay one generation out along the cross axis, then recurse.
*
* `start` is the middle of the band this generation has to fill; each branch gets a slice
* of it as wide as its own subtree needs, and is centred in that slice. Sizing slices by
* subtree extent — not by branch count — is what keeps a bushy branch from being drawn
* over a bare sibling.
*/
const spread = (
items: RadialTree[],
level: number,
start: number,
alongPos: number,
sign: 1 | -1,
) => {
const total =
items.reduce((s, b) => s + b.extent, 0) +
n.gap * Math.max(0, items.length - 1)
let cur = start - total / 2
for (const b of items) {
const mid = cur + b.extent / 2
// On the left side the ring position is the branch's FAR edge, so its own size has
// to come off to get its origin.
const a = sign > 0 ? alongPos : alongPos - b.p.rect[along]
if (down) place(b.p, mid - b.p.rect.w / 2, a, ctx)
else place(b.p, a, mid - b.p.rect.h / 2, ctx)
if (b.kids.length) {
// `alongPos` means the NEAR edge going outwards and the FAR edge coming back,
// which is why the two directions are not symmetric here: on the left the
// recursion subtracts the child's own width, so subtracting the ring width as
// well would place it a full ring too far out — off the frame.
const next = sign > 0 ? a + b.p.rect[along] + n.gap : a - n.gap
spread(b.kids, level + 1, mid, next, sign)
}
cur += b.extent + n.gap
}
}
if (down) {
place(centre, innerX + (innerW - centre.rect.w) / 2, innerTop, ctx)
spread(
branches,
0,
centre.rect.x + centre.rect.w / 2,
centre.rect.y + centre.rect.h + n.gap,
1,
)
return
}
// Radial: split the branches between the two sides, keeping declaration order within each
// side so the model can predict where a branch lands.
//
// The centre goes at the LEFT side's reach, not at the frame's middle. Those are the same
// only when both sides are equally deep; centring a lopsided map would push the deeper
// side out past the frame's edge and off the page.
const { right, left } = radialSides(branches)
place(
centre,
innerX + radialReach(left, "w", n.gap),
innerTop + (innerH - centre.rect.h) / 2,
ctx,
)
const midY = centre.rect.y + centre.rect.h / 2
spread(right, 0, midY, centre.rect.x + centre.rect.w + n.gap, 1)
spread(left, 0, midY, centre.rect.x - n.gap, -1)
}
export interface LayoutResult {
/** Placed roots, in the order given. */
roots: Placed[]
@@ -228,11 +792,18 @@ const MARGIN = { right: 40, bottom: 50 }
*/
export function layoutForest(
roots: DiagramNode[],
opts: { iconSize?: number; gap?: number } = {},
opts: {
iconSize?: number
gap?: number
/** The diagram's links. Needed by sequence containers, which size themselves from
* the number of messages between their participants. */
links?: LayoutContext["links"]
} = {},
): LayoutResult {
const glyph = opts.iconSize ?? ICON_SIZE
const gap = opts.gap ?? 70
const placed = roots.map((r) => measure(r, glyph))
const ctx: LayoutContext = { links: opts.links ?? [] }
const placed = roots.map((r) => measure(r, glyph, ctx))
let cur = ORIGIN.x
for (const p of placed) {
@@ -240,9 +811,9 @@ export function layoutForest(
const held =
n.kind === "title" ? null : n.pinned ? (n.rect ?? null) : null
if (held) {
place(p, held.x, held.y)
place(p, held.x, held.y, ctx)
} else {
place(p, cur, ORIGIN.y)
place(p, cur, ORIGIN.y, ctx)
cur += p.rect.w + gap
}
}

View File

@@ -41,9 +41,43 @@ export const MARKER = {
* base64 image with no name anywhere in it, so the style alone cannot identify it.
*/
name: "dai_name",
/**
* Which (lane, column) cell of a swimlane pool a node occupies, as "lane,col".
*
* Position alone cannot recover this once the user drags a node: the cell it lands in
* is a guess, whereas the marker records which lane the model assigned it to. It is
* also the only way an empty cell stays empty — geometry can only tell us where things
* ARE, never that a role deliberately does nothing at a given step.
*/
cell: "dai_cell",
/** A pool's lane names, tab-separated (a tab cannot appear in a draw.io style value). */
lanes: "dai_lanes",
/** A pool's milestone labels, tab-separated. */
phases: "dai_phases",
/** A pool's orientation: "h" or "v". */
orient: "dai_orient",
/** Vertical distance between consecutive messages in a sequence diagram. */
step: "dai_step",
/** How a radial container fans its branches out: "radial" or "down". */
spread: "dai_spread",
/**
* Marks a cell as chrome the engine draws and owns: a pool's lane bands, its label
* columns, its milestone strip. The parser must not read these back as nodes — they are
* re-derived from the pool's own parameters on every layout — and the edge router must
* not treat them as obstacles, since a sequence flow crossing lanes is the norm.
*/
lane: "dai_lane",
} as const
export type NodeKind = "group" | "grid" | "icon" | "box" | "title"
export type NodeKind =
| "group"
| "grid"
| "pool"
| "sequence"
| "radial"
| "icon"
| "box"
| "title"
export type Direction = "row" | "col" | "grid"
/**
@@ -88,17 +122,59 @@ export function readMarker(style: string, key: string): string | null {
return last
}
const KINDS: readonly NodeKind[] = [
"group",
"grid",
"pool",
"sequence",
"radial",
"icon",
"box",
"title",
]
export function readKind(style: string): NodeKind | null {
const v = readMarker(style, MARKER.kind)
if (
v === "group" ||
v === "grid" ||
v === "icon" ||
v === "box" ||
v === "title"
)
return v
return null
return KINDS.includes(v as NodeKind) ? (v as NodeKind) : null
}
/**
* The (lane, column) cell a node occupies in a swimlane pool, or null.
*
* Both must be non-negative integers: a malformed value is safer read as "no cell
* declared" (which puts the node in lane 0 column 0) than as a negative index, which would
* place it outside the pool's frame.
*/
export function readCell(style: string): { lane: number; col: number } | null {
const v = readMarker(style, MARKER.cell)
if (!v) return null
const m = v.match(/^(\d+),(\d+)$/)
return m ? { lane: Number(m[1]), col: Number(m[2]) } : null
}
/**
* A tab-separated marker list, as written by `joinList`.
*
* A tab cannot appear in a draw.io style value — the editor writes styles as a single
* semicolon-separated line — so it is safe as a separator inside one value, where a comma
* would collide with the label text it has to carry.
*/
export function readList(style: string, key: string): string[] | null {
const v = readMarker(style, key)
if (v === null) return null
if (v === "") return []
return v.split("\t").map(decodeURIComponent)
}
/** Encode a list of labels into one marker value. */
export function joinList(items: string[]): string {
// Percent-encoding keeps a label containing ";" or "=" from breaking the style string.
return items.map((s) => encodeURIComponent(s)).join("\t")
}
/** Is this cell pool chrome the engine draws and owns, rather than a node? */
export function isLaneChrome(style: string): boolean {
return readMarker(style, MARKER.lane) !== null
}
export function readDir(style: string): Direction | null {
@@ -163,6 +239,93 @@ export function stampContainer(
return s
}
/**
* Stamp a swimlane pool: its lane names, milestone labels and orientation.
*
* Unlike a group, a pool is NOT stamped as a draw.io container. Its lane bands are separate
* cells sitting inside it, and they are what a shape should reparent into when the user
* drags it — that is how "the user moved this step to a different role" gets recorded. If
* the pool itself claimed the drop, every node would come back in lane 0.
*/
export function stampPool(
style: string,
opts: {
lanes: string[]
phases: string[]
orientation: "horizontal" | "vertical"
gap: number
},
): string {
let s = append(style, MARKER.kind, "pool")
s = append(s, MARKER.lanes, joinList(opts.lanes))
s = append(s, MARKER.phases, joinList(opts.phases))
s = append(s, MARKER.orient, opts.orientation === "vertical" ? "v" : "h")
return append(s, MARKER.gap, Math.round(opts.gap))
}
/** Stamp a sequence container: participant spacing and message spacing. */
export function stampSequence(
style: string,
opts: { gap: number; step: number },
): string {
const s = append(style, MARKER.kind, "sequence")
return append(
append(s, MARKER.gap, Math.round(opts.gap)),
MARKER.step,
Math.round(opts.step),
)
}
/** Stamp a radial container: how it fans branches out, and the ring spacing. */
export function stampRadial(
style: string,
opts: { spread: "radial" | "down"; gap: number },
): string {
const s = append(style, MARKER.kind, "radial")
return append(
append(s, MARKER.spread, opts.spread),
MARKER.gap,
Math.round(opts.gap),
)
}
/**
* Stamp one of a pool's lane bands.
*
* A band IS a draw.io container, so dragging a step onto another role's band reparents it
* there and the marker on the band tells the parser which lane that is. The lane index is
* the band's identity, not its position, so the assignment survives the pool being
* re-measured to a different size.
*/
export function stampLane(style: string, lane: number): string {
let s = style.endsWith(";") || style === "" ? style : `${style};`
s += CONTAINER_TOKENS
return append(s, MARKER.lane, Math.max(0, Math.round(lane)))
}
/**
* Stamp a pool's own decoration — a lane-name column or a milestone strip.
*
* `dai_lane=-1` marks it as chrome without claiming a lane: it is a label, and a shape
* dropped on it belongs to no role. It stays a draw.io container only so clicks fall
* through to whatever is behind it.
*/
export function stampPoolDecoration(style: string): string {
return append(style, MARKER.lane, -1)
}
/** Record which pool cell a node occupies. */
export function stampCell(
style: string,
cell: { lane: number; col: number },
): string {
return append(
style,
MARKER.cell,
`${Math.max(0, Math.round(cell.lane))},${Math.max(0, Math.round(cell.col))}`,
)
}
/**
* 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.

View File

@@ -17,7 +17,9 @@ import {
type DiagramTree,
findNode,
findParent,
hasStencilFrame,
isContainer,
isDirectional,
type LinkSpec,
walkTree,
} from "./types"
@@ -32,6 +34,18 @@ export const OperationSchema = z.discriminatedUnion("op", [
.describe("Container id to add into; omit for top level"),
name: z.string().describe("Catalog stencil name, e.g. 's3' or 'ec2'"),
label: z.string().optional(),
lane: z
.number()
.optional()
.describe(
"Inside a pool: which lane (0-based row) this belongs to",
),
col: z
.number()
.optional()
.describe(
"Inside a pool: which column (0-based step) this sits in",
),
after: z
.string()
.optional()
@@ -42,6 +56,31 @@ export const OperationSchema = z.discriminatedUnion("op", [
id: z.string(),
parent: z.string().optional(),
label: z.string(),
shape: z
.enum([
"box",
"decision",
"terminator",
"round",
"data",
"document",
])
.optional()
.describe(
"Flowchart outline: decision=diamond, terminator=start/end, data=input/output, document=report. Omit for a plain rectangle",
),
lane: z
.number()
.optional()
.describe(
"Inside a pool: which lane (0-based row) this belongs to",
),
col: z
.number()
.optional()
.describe(
"Inside a pool: which column (0-based step) this sits in",
),
after: z.string().optional(),
}),
z.object({
@@ -70,6 +109,58 @@ export const OperationSchema = z.discriminatedUnion("op", [
gap: z.number().optional(),
after: z.string().optional(),
}),
z.object({
op: z.literal("add_pool"),
id: z.string(),
parent: z.string().optional(),
label: z.string().describe("Pool title, e.g. the process name"),
lanes: z
.array(z.string())
.describe(
"Role names, one per lane, top to bottom. Steps go in these lanes via add_box lane/col",
),
phases: z
.array(z.string())
.optional()
.describe("Milestone labels spanning the columns; omit for none"),
orientation: z
.enum(["horizontal", "vertical"])
.optional()
.describe(
"horizontal (default): lanes stack down, flow goes right",
),
gap: z.number().optional(),
after: z.string().optional(),
}),
z.object({
op: z.literal("add_sequence"),
id: z.string(),
parent: z.string().optional(),
label: z.string().describe("Diagram title; empty string for none"),
gap: z
.number()
.optional()
.describe("Horizontal spacing between participants"),
step: z
.number()
.optional()
.describe("Vertical spacing between messages"),
after: z.string().optional(),
}),
z.object({
op: z.literal("add_radial"),
id: z.string(),
parent: z.string().optional(),
label: z.string().describe("Frame title; empty string for none"),
spread: z
.enum(["radial", "down"])
.optional()
.describe(
"radial (default): branches on both sides, for a mind map. down: everything below the centre, for an org chart",
),
gap: z.number().optional(),
after: z.string().optional(),
}),
z.object({
op: z.literal("remove"),
id: z
@@ -118,6 +209,12 @@ export const OperationSchema = z.discriminatedUnion("op", [
op: z.literal("unlink"),
source: z.string(),
target: z.string(),
step: z
.number()
.optional()
.describe(
"Remove only the edge with this step number; omit to remove every edge between the two",
),
}),
z.object({
op: z.literal("set_title"),
@@ -133,6 +230,34 @@ export interface ApplyResult {
errors: string[]
}
/**
* The pool cell an add operation declared, if any.
*
* `lane` alone is enough — a step in a lane with no column given goes to column 0 — so the
* cell is recorded whenever either is present rather than requiring both.
*/
function cellOf(op: { lane?: number; col?: number }): {
cell?: { lane: number; col: number }
} {
if (op.lane == null && op.col == null) return {}
return {
cell: {
lane: Math.max(0, Math.round(op.lane ?? 0)),
col: Math.max(0, Math.round(op.col ?? 0)),
},
}
}
/** Are these two nodes participants of the same sequence diagram? */
function sameSequence(tree: DiagramTree, a: string, b: string): boolean {
for (const n of walkTree(tree)) {
if (n.kind !== "sequence") continue
const ids = new Set(n.children.map((c) => c.id))
if (ids.has(a) && ids.has(b)) return true
}
return false
}
/** Insert into a child list, after a named sibling or at the end. */
function insert(
list: DiagramNode[],
@@ -205,7 +330,10 @@ export function applyOperations(
case "add_icon":
case "add_box":
case "add_container":
case "add_grid": {
case "add_grid":
case "add_pool":
case "add_sequence":
case "add_radial": {
if (exists(op.id)) {
errors.push(`${op.op}: id "${op.id}" is already taken`)
break
@@ -222,9 +350,18 @@ export function applyOperations(
id: op.id,
name: op.name,
label: op.label ?? "",
...cellOf(op),
}
else if (op.op === "add_box")
node = { kind: "box", id: op.id, label: op.label }
node = {
kind: "box",
id: op.id,
label: op.label,
...(op.shape && op.shape !== "box"
? { shape: op.shape }
: {}),
...cellOf(op),
}
else if (op.op === "add_container")
node = {
kind: "group",
@@ -235,7 +372,7 @@ export function applyOperations(
gap: op.gap ?? 20,
children: [],
}
else
else if (op.op === "add_grid")
node = {
kind: "grid",
id: op.id,
@@ -245,6 +382,41 @@ export function applyOperations(
gap: op.gap ?? 14,
children: [],
}
else if (op.op === "add_pool") {
if (op.lanes.length === 0) {
errors.push(
`add_pool: "${op.id}" needs at least one lane — a swimlane diagram with no roles has nothing to divide`,
)
break
}
node = {
kind: "pool",
id: op.id,
label: op.label,
lanes: op.lanes,
phases: op.phases ?? [],
orientation: op.orientation ?? "horizontal",
gap: op.gap ?? 40,
children: [],
}
} else if (op.op === "add_sequence")
node = {
kind: "sequence",
id: op.id,
label: op.label,
gap: op.gap ?? 60,
step: Math.max(24, op.step ?? 44),
children: [],
}
else
node = {
kind: "radial",
id: op.id,
label: op.label,
spread: op.spread ?? "radial",
gap: op.gap ?? 40,
children: [],
}
insert(list, node, op.after)
break
}
@@ -318,9 +490,13 @@ export function applyOperations(
errors.push(`set_dir: "${op.id}" is not a container`)
break
}
if (node.kind === "grid") {
if (!isDirectional(node)) {
// A grid, pool, sequence or radial container arranges its children by its
// own rule; "row or column" is not a property they have.
errors.push(
`set_dir: "${op.id}" is a grid — change its column count instead`,
node.kind === "grid"
? `set_dir: "${op.id}" is a grid — change its column count instead`
: `set_dir: "${op.id}" is a ${node.kind}, which arranges its children by its own rule and has no row/column direction`,
)
break
}
@@ -347,9 +523,16 @@ export function applyOperations(
errors.push(`link: no node with id "${op.target}"`)
break
}
const dup = tree.links.some(
(l) => l.source === op.source && l.target === op.target,
)
// A second arrow between the same pair is normally a mistake — two identical
// lines drawn on top of each other — EXCEPT between two participants of a
// sequence diagram, where a back-and-forth conversation is the whole point.
// There the messages are distinguished by their step, not by their endpoints.
const conversation = sameSequence(tree, op.source, op.target)
const dup =
!conversation &&
tree.links.some(
(l) => l.source === op.source && l.target === op.target,
)
if (dup) {
errors.push(
`link: "${op.source}" → "${op.target}" already exists`,
@@ -366,12 +549,22 @@ export function applyOperations(
case "unlink": {
const before = tree.links.length
// With a step given, remove only that message: two participants of a sequence
// diagram can exchange several, and dropping all of them would delete messages
// the caller did not ask about.
tree.links = tree.links.filter(
(l) => !(l.source === op.source && l.target === op.target),
(l) =>
!(
l.source === op.source &&
l.target === op.target &&
(op.step == null || l.step === op.step)
),
)
if (tree.links.length === before)
errors.push(
`unlink: no edge from "${op.source}" to "${op.target}"`,
op.step == null
? `unlink: no edge from "${op.source}" to "${op.target}"`
: `unlink: no edge from "${op.source}" to "${op.target}" with step ${op.step}`,
)
break
}
@@ -393,12 +586,30 @@ export function collectNames(
for (const n of walkTree(tree)) {
if (n.kind === "icon" && n.name)
out.push({ id: n.id, name: n.name, kind: "icon" })
else if (isContainer(n) && n.gname)
else if (hasStencilFrame(n) && n.gname)
out.push({ id: n.id, name: n.gname, kind: "group" })
}
return out
}
/** How a container arranges its children, in one short phrase for the outline. */
function containerMeta(n: ContainerNode): string {
switch (n.kind) {
case "grid":
return `grid cols=${n.cols}`
case "pool":
return `pool lanes=[${n.lanes.join(" | ")}]${
n.phases.length ? ` phases=[${n.phases.join(" | ")}]` : ""
}${n.orientation === "vertical" ? " vertical" : ""}`
case "sequence":
return "sequence"
case "radial":
return `radial ${n.spread}`
default:
return n.dir
}
}
/**
* A compact text outline of the tree, for showing the model what is on the canvas.
*
@@ -411,16 +622,24 @@ export function outline(tree: DiagramTree): string {
if (tree.title) lines.push(`title: ${tree.title}`)
const walk = (n: DiagramNode, depth: number) => {
const pad = " ".repeat(depth)
// A node inside a pool reports its cell: that is how the model knows which lane a
// step ended up in, which is exactly what it needs to move one.
const at = (x: DiagramNode) =>
(x.kind === "icon" || x.kind === "box") && x.cell
? ` @lane${x.cell.lane},col${x.cell.col}`
: ""
if (n.kind === "icon")
lines.push(
`${pad}${n.id}: icon ${n.name}${n.label ? ` "${n.label}"` : ""}`,
`${pad}${n.id}: icon ${n.name}${n.label ? ` "${n.label}"` : ""}${at(n)}`,
)
else if (n.kind === "box")
lines.push(
`${pad}${n.id}: box${n.shape ? ` ${n.shape}` : ""} "${n.label}"${at(n)}`,
)
else if (n.kind === "box") lines.push(`${pad}${n.id}: box "${n.label}"`)
else if (n.kind === "title") lines.push(`${pad}${n.id}: title`)
else {
const meta = n.kind === "grid" ? `grid cols=${n.cols}` : n.dir
lines.push(
`${pad}${n.id}: ${meta}${n.label ? ` "${n.label}"` : " (wrapper)"}`,
`${pad}${n.id}: ${containerMeta(n)}${n.label ? ` "${n.label}"` : " (wrapper)"}`,
)
for (const c of n.children) walk(c, depth + 1)
}

View File

@@ -25,15 +25,20 @@ import { extractDiagramXML } from "@/lib/utils"
import {
type Direction,
hasMarkers,
isLaneChrome,
isPinned,
MARKER,
type NodeKind,
readCell,
readDir,
readIntMarker,
readKind,
readList,
readMarker,
} from "./markers"
import type {
BoxNode,
BoxShape,
DiagramNode,
DiagramTree,
ForeignCell,
@@ -41,7 +46,11 @@ import type {
GroupNode,
IconNode,
LinkSpec,
PoolCell,
PoolNode,
RadialNode,
Rect,
SequenceNode,
} from "./types"
/** Hard cap on nesting depth, matching the reference project's 50-hop guard. */
@@ -337,6 +346,32 @@ function looksLikeText(style: string): boolean {
return /(?:^|;)text;/.test(style) || styleValue(style, "text") === "1"
}
/** The flowchart outline a box is drawn with, read back from its style. */
function boxShape(style: string): BoxShape | undefined {
const shape = styleValue(style, "shape")
if (shape === "parallelogram") return "data"
if (shape === "document") return "document"
if (/(?:^|;)rhombus[;=]/.test(style) || shape === "rhombus")
return "decision"
if (styleValue(style, "rounded") === "1") {
// A stadium and a rounded rectangle differ only in arcSize; draw.io treats 50 as the
// maximum, which is what makes the ends semicircular.
const arc = Number(styleValue(style, "arcSize") ?? "0")
return arc >= 40 ? "terminator" : "round"
}
return undefined
}
/**
* The lifeline of a sequence diagram participant.
*
* One cell covers the head and the line below it, so this is the participant itself, not
* chrome to be discarded — the head's label is the participant's name.
*/
function looksLikeLifeline(style: string): boolean {
return styleValue(style, "shape") === "umlLifeline"
}
/**
* Classify a cell into a node kind.
*
@@ -359,10 +394,7 @@ function looksLikeText(style: string): boolean {
* 429 AWS + 842 Azure/GCP icons as plain boxes: a re-layout would then re-emit them as
* grey rectangles and the stencils would be gone.
*/
function classify(
c: RawCell,
hasChildren: boolean,
): "group" | "grid" | "icon" | "box" | "title" {
function classify(c: RawCell, hasChildren: boolean): NodeKind {
const marked = readKind(c.style)
if (marked) return marked
if (hasChildren) return "group"
@@ -642,6 +674,66 @@ function inferLayout(children: RawCell[]): LayoutGuess {
}
}
/**
* Put a radial container's children in order, centre first.
*
* The centre is whichever child sits closest to the container's own middle — for a mind map
* that is literally true, and for an org chart the root is horizontally centred above
* everything. Identifying it by position rather than by document order is what lets the
* user drag branches around without the layout picking a new root.
*
* The branches then read clockwise from the top for a mind map (which is how a reader scans
* one) and left to right for an org chart.
*/
function orderRadial(
kids: RawCell[],
own: Rect | null,
spread: "radial" | "down",
): RawCell[] {
const placed = kids.filter((k) => k.abs !== null)
if (placed.length < 2 || !own) return kids
const cx = own.x + own.w / 2
const cy = own.y + own.h / 2
const mid = (k: RawCell) => ({
x: (k.abs as Rect).x + (k.abs as Rect).w / 2,
y: (k.abs as Rect).y + (k.abs as Rect).h / 2,
})
const dist2 = (k: RawCell) => {
const m = mid(k)
return (m.x - cx) ** 2 + (m.y - cy) ** 2
}
// For "down", the centre is the topmost child, since everything hangs below it. Its
// horizontal position is centred but its vertical one is not, so distance to the middle
// would pick a second-generation node instead.
const centre =
spread === "down"
? placed.reduce((best, k) =>
(k.abs as Rect).y < (best.abs as Rect).y ? k : best,
)
: placed.reduce((best, k) => (dist2(k) < dist2(best) ? k : best))
const rest = kids.filter((k) => k.id !== centre.id)
if (spread === "down")
return [
centre,
...rest.sort(
(a, b) => (a.abs?.x ?? 0) - (b.abs?.x ?? 0) || a.seq - b.seq,
),
]
// Radial: the renderer puts the first half of the branches on the right and the second
// half on the left, top to bottom within each side. Reading them back in that same order
// is what keeps a round-trip stable.
const right = rest
.filter((k) => (k.abs ? mid(k).x >= cx : true))
.sort((a, b) => (a.abs?.y ?? 0) - (b.abs?.y ?? 0) || a.seq - b.seq)
const left = rest
.filter((k) => (k.abs ? mid(k).x < cx : false))
.sort((a, b) => (a.abs?.y ?? 0) - (b.abs?.y ?? 0) || a.seq - b.seq)
return [centre, ...right, ...left]
}
/** Median edge-to-edge distance between neighbours along the flow axis. */
function gapsBetween(ordered: Rect[], dir: Direction): number {
const gaps: number[] = []
@@ -829,6 +921,44 @@ export function parseDiagram(xml: string, pageIndex = 0): ParseResult {
for (const c of vertices)
kindOf.set(c.id, classify(c, (childrenOf.get(c.id)?.length ?? 0) > 0))
/**
* Collapse a pool's lane bands, lifting their contents back into the pool.
*
* The bands exist so draw.io has something to reparent into: dragging a step onto
* another role's band is how the user reassigns it, and the band's `dai_lane` marker
* says which role that is. But a band is not a node — it is re-derived from the pool's
* lane list on every layout — so on the way back its children become children of the
* pool, each carrying the lane index of the band it was found in.
*
* The lane comes from the BAND, overriding whatever `dai_cell` the node still says,
* because the band is where the user actually dropped it.
*/
const laneOverride = new Map<string, number>()
const chromeIds = new Set<string>()
for (const c of vertices) {
if (!isLaneChrome(c.style)) continue
chromeIds.add(c.id)
const lane = readIntMarker(c.style, MARKER.lane)
const kids = childrenOf.get(c.id) ?? []
const pool = parentOf.get(c.id) ?? ""
for (const k of kids) {
parentOf.set(k.id, pool)
if (lane !== null) laneOverride.set(k.id, lane)
}
childrenOf.delete(c.id)
}
if (chromeIds.size > 0) {
// Rebuild the child lists now that the bands are out of the parent chain.
childrenOf.clear()
for (const c of vertices) {
if (chromeIds.has(c.id)) continue
const p = parentOf.get(c.id) ?? ""
const list = childrenOf.get(p)
if (list) list.push(c)
else childrenOf.set(p, [c])
}
}
const foreign: ForeignCell[] = []
const accounted = new Set<string>()
/** Carry a cell through the round-trip without interpreting it. */
@@ -841,6 +971,20 @@ export function parseDiagram(xml: string, pageIndex = 0): ParseResult {
let title: string | undefined
const ambiguousContainers: string[] = []
/**
* The pool cell a node occupies.
*
* The band it was found in wins over the marker on the node: the band records where the
* user dropped it, the marker records where the last layout put it. When only the marker
* exists — the node has not been dragged — that is the answer.
*/
const cellFor = (c: RawCell): PoolCell | undefined => {
const marked = readCell(c.style)
const lane = laneOverride.get(c.id)
if (lane === undefined) return marked ?? undefined
return { lane, col: marked?.col ?? 0 }
}
const build = (
c: RawCell,
depth: number,
@@ -885,22 +1029,38 @@ export function parseDiagram(xml: string, pageIndex = 0): ParseResult {
: undefined,
pinned,
rect,
cell: cellFor(c),
}
return node
}
if (kind === "box") {
// A lifeline's cell spans the head AND the line below it. Its natural size is the
// head; keeping the full height would make the participant box grow taller on
// every round-trip, since the next layout would add another lifeline under it.
const lifeline = looksLikeLifeline(c.style)
const headH = lifeline
? Number(styleValue(c.style, "size") ?? "44")
: undefined
const node: BoxNode = {
kind: "box",
id: c.id,
label: c.value,
w: c.geo ? Math.round(c.geo.w) : undefined,
h: c.geo ? Math.round(c.geo.h) : undefined,
h: lifeline
? Math.round(headH && headH > 0 ? headH : 44)
: c.geo
? Math.round(c.geo.h)
: undefined,
fill: styleValue(c.style, "fillColor"),
stroke: styleValue(c.style, "strokeColor"),
style: c.style,
shape: boxShape(c.style),
// A lifeline's style is chrome the renderer rebuilds, so keeping it verbatim
// would re-emit a lifeline that no longer matches the new message count.
style: lifeline ? undefined : c.style,
pinned,
rect,
cell: cellFor(c),
}
return node
}
@@ -913,8 +1073,93 @@ export function parseDiagram(xml: string, pageIndex = 0): ParseResult {
else kids.push(k)
}
const markedDir = readDir(c.style)
const markedGap = readIntMarker(c.style, MARKER.gap)
const nextPath = new Set(path).add(c.id)
// ---- the three specialised containers ----
// Each is identified only by its `dai_kind` marker: nothing about the geometry of a
// pool distinguishes it from a grid whose cells happen to be full, and guessing wrong
// would rearrange the diagram. An imported file has no marker and gets the generic
// treatment, which is the safe default.
if (kind === "pool") {
// Order by column then lane, so the outline reads in the order things happen.
const byCell = [...kids].sort((a, b) => {
const ca = readCell(a.style)
const cb = readCell(b.style)
const la = laneOverride.get(a.id) ?? ca?.lane ?? 0
const lb = laneOverride.get(b.id) ?? cb?.lane ?? 0
const d = (ca?.col ?? 0) - (cb?.col ?? 0)
return d !== 0 ? d : la !== lb ? la - lb : a.seq - b.seq
})
const node: PoolNode = {
kind: "pool",
id: c.id,
label: c.value,
lanes: readList(c.style, MARKER.lanes) ?? ["Lane 1"],
phases: readList(c.style, MARKER.phases) ?? [],
orientation:
readMarker(c.style, MARKER.orient) === "v"
? "vertical"
: "horizontal",
gap: markedGap ?? 40,
children: byCell
.map((k) => build(k, depth + 1, nextPath))
.filter((n): n is DiagramNode => n !== null),
style: c.style,
pinned,
rect,
}
return node
}
if (kind === "sequence") {
const node: SequenceNode = {
kind: "sequence",
id: c.id,
label: c.value,
gap: markedGap ?? 60,
step: Math.max(24, readIntMarker(c.style, MARKER.step) ?? 44),
// Participants read left to right — that IS their order.
children: orderChildren(kids, "row")
.map((k) => build(k, depth + 1, nextPath))
.filter((n): n is DiagramNode => n !== null),
style: c.style,
pinned,
rect,
}
return node
}
if (kind === "radial") {
const spread =
readMarker(c.style, MARKER.spread) === "down"
? "down"
: "radial"
const node: RadialNode = {
kind: "radial",
id: c.id,
label: c.value,
spread,
gap: markedGap ?? 40,
// A flat list, in the order the layout will read it. The hierarchy is in the
// arrows, so document order is all the child list has to carry — and keeping
// it means a re-layout reproduces the same picture.
children: orderRadial(kids, c.abs, spread)
.map((k) => build(k, depth + 1, nextPath))
.filter((n): n is DiagramNode => n !== null),
style: c.style,
pinned,
rect,
}
return node
}
// ---- group or grid ----
// The direction has to be inferred here rather than above, because the specialised
// containers arrange their children two-dimensionally on purpose. Running the
// inference on a pool or a radial map would warn that "no single direction describes
// this arrangement", which is true and not a problem.
const markedDir = readDir(c.style)
const markedCols = readIntMarker(c.style, MARKER.cols)
const guess =
markedDir === null || markedGap === null || markedCols === null
@@ -925,7 +1170,6 @@ export function parseDiagram(xml: string, pageIndex = 0): ParseResult {
if (markedDir === null && guess?.ambiguous)
ambiguousContainers.push(c.id)
const nextPath = new Set(path).add(c.id)
const built = orderChildren(kids, dir)
.map((k) => build(k, depth + 1, nextPath))
.filter((n): n is DiagramNode => n !== null)
@@ -971,6 +1215,14 @@ export function parseDiagram(xml: string, pageIndex = 0): ParseResult {
// A cell parented to an EDGE is that edge's label; it never belongs in the forest.
const rootCells: RawCell[] = []
for (const c of vertices) {
// A pool's lane bands and label strips are chrome the renderer rebuilds from the
// pool's own lane list. They are the ONE thing deliberately not carried through
// verbatim: re-emitting a stale band would leave it at the old size while the new
// bands are drawn underneath it.
if (chromeIds.has(c.id)) {
accounted.add(c.id)
continue
}
const p = parentOf.get(c.id) ?? ""
if (edgeIds.has(p)) continue // handled with the edges below
if (byId.has(p) && !layers.has(p)) continue // a real child

View File

@@ -8,10 +8,36 @@
* Ported from drawio-ai-kit (MIT) — see NOTICE.
*/
import { flatten, ICON_SIZE, layoutForest, type Placed } from "./layout"
import { stampContainer, stampLeaf } from "./markers"
import {
flatten,
ICON_SIZE,
LANE_LABEL,
layoutForest,
type Placed,
POOL_PAD,
poolMetrics,
sequenceMetrics,
} from "./layout"
import {
stampCell,
stampContainer,
stampLane,
stampLeaf,
stampPool,
stampPoolDecoration,
stampRadial,
stampSequence,
} from "./markers"
import { type RoutedEdge, routeEdges } from "./route"
import type { DiagramNode, DiagramTree, LinkSpec, Rect } from "./types"
import type {
BoxShape,
DiagramNode,
DiagramTree,
LinkSpec,
PoolNode,
Rect,
SequenceNode,
} from "./types"
/** Escape the five characters that would break an XML attribute. */
export function esc(s: string): string {
@@ -38,6 +64,48 @@ const TITLE_STYLE =
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;"
/**
* Flowchart outlines, as mxGraph draws them.
*
* All six are core mxGraph shapes, not stencils from a shape library, so they render
* without the catalog and without any extra dependency. The notation is conventional: a
* reader takes a diamond to mean a branch and a stadium to mean a start or end point, so
* drawing every step as the same rectangle loses information the shape was carrying.
*/
const BOX_SHAPES: Record<BoxShape, string> = {
box: "rounded=0;",
round: "rounded=1;arcSize=12;",
/** Decision — a diamond. */
decision: "rhombus;",
/** Start or end — a stadium. draw.io draws `rounded=1` at arcSize 50 as a full stadium. */
terminator: "rounded=1;arcSize=50;",
/** Input or output — a parallelogram. */
data: "shape=parallelogram;perimeter=parallelogramPerimeter;fixedSize=1;size=14;",
/** A document or report — a rectangle with a wavy bottom edge. */
document: "shape=document;boundedLbl=1;",
}
// ---- 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"
/**
* 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;"
export interface RenderOptions {
/** Resolves a catalog icon/group name to its verbatim draw.io style. */
resolveStyle?: StyleResolver
@@ -68,14 +136,37 @@ function styleFor(n: DiagramNode, resolve: StyleResolver | undefined): string {
if (n.kind === "box") {
let base = n.style ?? FALLBACK_BOX
if (!n.style) {
// The outline comes first: BOX_SHAPES carries `rounded=`, which the fallback
// also sets, and appending lets the shape's value win.
if (n.shape) base += BOX_SHAPES[n.shape]
if (n.fill) base += `fillColor=${n.fill};`
if (n.stroke) base += `strokeColor=${n.stroke};`
if (n.bold) base += "fontStyle=1;"
}
return stampLeaf(base, "box")
const stamped = stampLeaf(base, "box")
return n.cell ? stampCell(stamped, n.cell) : stamped
}
// container
if (n.kind === "pool") {
return stampPool(n.style ?? poolFrameStyle(), {
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
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
@@ -96,6 +187,17 @@ function styleFor(n: DiagramNode, resolve: StyleResolver | undefined): string {
})
}
/** A pool's outer frame: a plain titled rectangle, since the bands supply the structure. */
function poolFrameStyle(): string {
return (
`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;`
)
}
const INVISIBLE_FRAME_STYLE =
"rounded=0;whiteSpace=wrap;html=1;fillColor=none;strokeColor=none;"
/**
* One `<mxCell>` for a vertex, with geometry relative to its parent.
*
@@ -149,6 +251,208 @@ function vertexXml(
)
}
/** The lane a node declared, or 0. */
function poolLaneOf(n: DiagramNode): number {
return (n.kind === "icon" || n.kind === "box") && n.cell
? Math.max(0, n.cell.lane)
: 0
}
/**
* How many messages a sequence container has.
*
* The highest step number, or the message count when the model numbered nothing — either
* way, one row per message, so the arrows do not stack on a single y.
*/
function countMessages(
n: SequenceNode,
links: { source: string; target: string; step?: number }[],
): number {
const own = new Set(n.children.map((c) => c.id))
const mine = links.filter((l) => own.has(l.source) && own.has(l.target))
const steps = mine
.map((l) => l.step)
.filter((s): s is number => s != null && s > 0)
return Math.max(mine.length, ...(steps.length ? steps : [0]))
}
/** 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,
}
: {
x: rect.x + POOL_PAD + m.contentW + n.gap,
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 }[],
messages: number,
): { xml: string[]; replaced: Set<string> } {
const m = sequenceMetrics(n, rect, messages)
const xml: string[] = []
const replaced = new Set<string>()
for (const k of kids) {
const head = k.rect
xml.push(
chromeXml(
k.node.id,
n.id,
{
x: head.x,
y: head.y,
w: head.w,
h: Math.max(head.h, m.bottom - head.y),
},
rect,
`${LIFELINE_STYLE}size=${Math.round(head.h)};`,
"label" in k.node ? k.node.label : "",
),
)
replaced.add(k.node.id)
}
return { xml, replaced }
}
/** The label an edge renders, with its step number prefixed. */
function edgeLabel(l: LinkSpec): string {
if (l.step == null) return l.label ?? ""
@@ -198,6 +502,54 @@ function edgeXml(l: LinkSpec, index: number, route?: RoutedEdge): string {
)
}
/**
* 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) {
style += "endArrow=block;endFill=1;html=1;"
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>`
)
}
export interface RenderResult {
/** A complete `<mxfile>` document, ready for the editor. */
xml: string
@@ -220,6 +572,7 @@ export function renderDiagram(
const { roots, page } = layoutForest(tree.roots, {
iconSize: opts.iconSize,
gap: opts.rootGap,
links: tree.links,
})
const flat = flatten(roots)
@@ -241,20 +594,65 @@ export function renderDiagram(
`<mxGeometry x="0" y="24" width="${page.w}" height="30" as="geometry"/></mxCell>`,
)
// 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) {
const band = bands[Math.min(poolLaneOf(c), bands.length - 1)]
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,
)
const count = countMessages(n, tree.links)
const { xml, replaced } = sequenceChrome(n, f.rect, kids, count)
chrome.set(n.id, xml)
for (const id of replaced) asLifeline.add(id)
messageYOf.set(n.id, sequenceMetrics(n, f.rect, count).messageY)
}
}
// Parents come before children (flatten guarantees it), which draw.io requires.
for (const f of flat) {
// 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
const parentRect =
f.parent === "1" ? null : (rectById.get(f.parent) ?? null)
band ?? (parent === "1" ? null : (rectById.get(parent) ?? null))
cells.push(
vertexXml(
f.node,
f.rect,
f.parent,
parent,
parentRect,
opts.resolveStyle,
glyph,
),
)
const own = chrome.get(f.node.id)
if (own) cells.push(...own)
}
// Cells the parser could not interpret — user annotations, imported shapes — go back
@@ -279,12 +677,46 @@ export function renderDiagram(
drawable.push(l)
}
// 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 })
}
}
// 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
// frame must not block it.
// 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.
const obstacles = new Set(
flat
.filter((f) => f.node.kind === "icon" || f.node.kind === "box")
.filter(
(f) =>
(f.node.kind === "icon" || f.node.kind === "box") &&
!asLifeline.has(f.node.id),
)
.map((f) => f.node.id),
)
// Frames are passable but not free to ignore: a line that runs alongside a border, or
@@ -292,12 +724,19 @@ export function renderDiagram(
// though it hits nothing.
const frames = new Set(
flat
.filter((f) => f.node.kind === "group" || f.node.kind === "grid")
.filter(
(f) =>
f.node.kind === "group" ||
f.node.kind === "grid" ||
f.node.kind === "pool" ||
f.node.kind === "sequence" ||
f.node.kind === "radial",
)
.map((f) => f.node.id),
)
const routes = routeEdges(
drawable.map((l, i) => ({
id: l.id ?? `ed${i + 1}`,
routable.map(({ link: l, index }) => ({
id: l.id ?? `ed${index + 1}`,
source: l.source,
target: l.target,
hasLabel: edgeLabel(l) !== "",
@@ -306,9 +745,11 @@ export function renderDiagram(
obstacles,
frames,
)
drawable.forEach((l, i) => {
cells.push(edgeXml(l, i, routes[i]))
routable.forEach(({ link, index }, i) => {
cells.push(edgeXml(link, index, routes[i]))
})
for (const m of messages)
cells.push(messageXml(m.link, m.index, m.y, cellById))
const model =
`<mxGraphModel dx="1400" dy="900" grid="0" gridSize="10" guides="1" tooltips="1"` +

View File

@@ -12,6 +12,32 @@ import type { Direction } from "./markers"
export type { Direction } from "./markers"
/**
* 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"
/** A catalog icon: a real stencil, drawn at a fixed glyph size with a label below. */
export interface IconNode {
kind: "icon"
@@ -27,6 +53,8 @@ export interface IconNode {
pinned?: boolean
/** Absolute geometry, when recovered from XML. Only meaningful for a pinned node. */
rect?: Rect
/** Position within a `pool` parent. Ignored elsewhere. */
cell?: PoolCell
}
/** A plain labelled rectangle, for things the catalog has no icon for. */
@@ -39,9 +67,13 @@ export interface BoxNode {
fill?: string
stroke?: string
bold?: boolean
/** Flowchart outline. Absent means a plain rectangle. */
shape?: BoxShape
style?: string
pinned?: boolean
rect?: Rect
/** Position within a `pool` parent. Ignored elsewhere. */
cell?: PoolCell
}
/** A page title. At most one per diagram; laid out outside the tree flow. */
@@ -88,7 +120,90 @@ export interface GridNode {
rect?: Rect
}
export type ContainerNode = GroupNode | GridNode
/**
* 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
export type LeafNode = IconNode | BoxNode | TitleNode
export type DiagramNode = ContainerNode | LeafNode
@@ -139,13 +254,34 @@ export interface ForeignCell {
}
export function isContainer(n: DiagramNode): n is ContainerNode {
return n.kind === "group" || n.kind === "grid"
return (
n.kind === "group" ||
n.kind === "grid" ||
n.kind === "pool" ||
n.kind === "sequence" ||
n.kind === "radial"
)
}
export function isLeaf(n: DiagramNode): n is LeafNode {
return !isContainer(n)
}
/**
* 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"
}
/** Depth-first walk over a node and its descendants. */
export function* walk(n: DiagramNode): Generator<DiagramNode> {
yield n

View File

@@ -57,9 +57,9 @@ parameters: {
}
---Tool5---
tool name: restructure_diagram
description: Build or edit an AWS architecture diagram by declaring STRUCTURE instead of XML. You say what nests inside what; the engine computes every coordinate, size and arrow route. Containers always fit their contents and siblings never overlap. Never pass coordinates, XML or style strings.
description: Build or edit a diagram by declaring STRUCTURE instead of XML. You say what nests inside what; the engine computes every coordinate, size and arrow route. Containers always fit their contents and siblings never overlap. Never pass coordinates, XML or style strings.
parameters: {
operations: Array<Operation> // add_icon | add_box | add_container | add_grid | remove | move | set_label | set_dir | set_gap | link | unlink | set_title
operations: Array<Operation> // add_icon | add_box | add_container | add_grid | add_pool | add_sequence | add_radial | remove | move | set_label | set_dir | set_gap | link | unlink | set_title
}
---Tool6---
tool name: search_stencils
@@ -69,17 +69,44 @@ parameters: {
kind?: "icon" | "group"
limit?: number
}
---Tool7---
tool name: draw_graph
description: Draw a flowchart, decision tree, dependency graph, ER diagram or site map from nodes and arrows alone. You give NO positions and NO nesting; the engine works out how many rows there are, who shares a row, and who goes left of whom, so arrows do not cross or run through unrelated boxes. Replaces the whole diagram — use restructure_diagram to edit afterwards.
parameters: {
nodes: Array<{id: string, label: string, shape?: "box"|"decision"|"terminator"|"round"|"data"|"document", icon?: string}>
edges: Array<{source: string, target: string, label?: string, dashed?: boolean}>
title?: string
flow?: "col" | "row" // col (default): top to bottom. row: left to right
}
---End of tools---
IMPORTANT: Choose the right tool:
- For an AWS architecture diagram (VPC, subnets, multi-AZ, landing zone, serverless, event-driven): use search_stencils then restructure_diagram. This applies to BOTH creating and editing. Do not hand-write XML for AWS diagrams — the engine gets the layout right and costs a fraction of the tokens.
- Use display_diagram for: NON-AWS diagrams — flowcharts, BPMN, sequence diagrams, mind maps, UI mockups, org charts, ER diagrams, Azure/GCP diagrams.
- Use edit_diagram for: small changes to a NON-AWS diagram.
IMPORTANT: Choose the right tool. Divide by the diagram's LAYOUT SHAPE, not by which icon set it uses.
Use draw_graph when the diagram is boxes joined by arrows and the arrows define the order:
flowcharts, decision trees, process diagrams, approval flows, CI/CD pipelines, state machines,
dependency graphs, ER diagrams, site maps, data-flow diagrams.
You supply only nodes and edges. Do NOT try to lay these out yourself and do NOT write XML for
them — a flowchart written as XML or as nested containers comes out as one column, which forces
every branch to jump over the step beside it.
Use restructure_diagram when the diagram's meaning is in NESTING or in a fixed frame:
- Cloud architecture (AWS/Azure/GCP/Kubernetes): things inside things. Call search_stencils first.
- Swimlane and BPMN diagrams: add_pool with one lane per role, then add_box with lane and col.
- Sequence diagrams: add_sequence, one add_box per participant, then link with a step number.
- Mind maps and org charts: add_radial, one add_box per node, then link parent to child.
This applies to BOTH creating and editing.
Use display_diagram only for diagrams that need ABSOLUTE positioning, where the engine's layout
would be wrong rather than merely different:
UI mockups and wireframes, floor plans, circuit and P&ID diagrams, seating charts, illustrations,
Gantt charts, anything where the exact position of each element is the content.
- Use edit_diagram for: small changes to a diagram that was made with display_diagram.
- Use append_diagram for: ONLY when display_diagram was truncated due to output length - continue generating from where you stopped
- Use get_shape_library for: discovering icons for a NON-AWS library, before display_diagram.
- Use get_shape_library for: discovering icons for a library, before display_diagram.
Working with restructure_diagram:
- Look every icon name up with search_stencils first. Batch the lookups.
- Look every AWS icon name up with search_stencils first. Batch the lookups.
- Editing: send only the operations for what changes. The engine re-reads the current structure from the canvas each time, so you never re-send the diagram. Adding one service is one operation.
- The tool replies with an outline of the resulting structure. Use the ids in it to name things in your next call.
- Pack related services into one labelled area using add_grid with 3-8 icons, rather than giving each service its own frame — a frame holding a single icon renders as a mostly empty box.
@@ -87,6 +114,31 @@ Working with restructure_diagram:
- A container with an empty label is an invisible wrapper. Use it to group several containers along one axis without drawing another visible frame.
- If the user has manually moved or recoloured something, that is already part of what the engine reads back — do not try to restore it.
Swimlane diagrams (add_pool):
- lanes are the roles, top to bottom. Every step goes in exactly one lane.
- Each step declares lane (which role) and col (which step of the process). Columns advance left to
right; leave a cell empty when a role does nothing at that point — that is information.
- Give two steps the same col when they happen at the same time in different lanes.
- phases is optional and labels groups of columns, e.g. ["Intake", "Review", "Decision"].
Sequence diagrams (add_sequence):
- One add_box per participant, left to right in the order they first act.
- Every message is a link with a step number. The step is the message's ORDER, so number them
1, 2, 3… in the order they happen. A reply is its own link back the other way.
- A participant calling itself is a link from a node to itself.
Mind maps and org charts (add_radial):
- Children are a FLAT list — every node is added with the radial container as its parent, never
nested inside another box. The hierarchy comes from the links.
- link from parent to child. The node nothing points at becomes the centre.
- spread: "radial" for a mind map (branches on both sides, compact). "down" for an org chart
(everything below its manager, which is the only way a reporting line reads correctly).
Flowchart box shapes, for both draw_graph and add_box:
- "decision" for a branch (a diamond), "terminator" for a start or end point, "data" for input or
output, "document" for a report, "round" for a soft-edged step. Use them: a reader takes a
diamond to mean a choice, so drawing every step as the same rectangle loses that.
Core capabilities:
- Generate valid, well-formed XML strings for draw.io diagrams
- Create professional flowcharts, mind maps, entity diagrams, and technical illustrations