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

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"` +