refactor(diagram-engine): apply review findings, fix vertical pool phases

Four reviewers went over the previous commit (three Claude, one Codex). Their
findings, verified independently before applying:

A REAL BUG. A vertical pool with milestone labels drew the label strip outside
the pool frame. The measure pass reserves width as padding + content + strip with
no gap between the last two; the renderer placed the strip one gap further out.
No test caught it because every vertical case omitted phases and every phases
case was horizontal — both regression cases added.

Duplicated logic, now single-sourced:
  - messageCount existed byte-identically in layout.ts and render.ts. Two copies
    that had to agree or the lifelines stop reaching the last message.
  - sequenceMetrics was called twice per sequence container, once inside the
    chrome builder and again for the message positions. Same drift hazard, in the
    file whose own comment warns about it.

Dead code, each verified unreachable rather than assumed:
  - Placed.extent: declared and documented, never written or read. Every .extent
    access belongs to RadialTree.
  - SequenceMetrics.top: computed, returned, no reader.
  - spread()'s level parameter: threaded through the recursion, never used.
  - radialReach's .slice(0, generations): widestPerLevel writes one entry per
    generation, so its length IS the depth. Confirmed over 20,000 random trees;
    removing it made RadialTree.depth dead too.
  - Two of three cycle guards in radialHierarchy: self-links are already skipped
    when the parent map is built, and that map holds one parent per node, so the
    structure is a forest and the visited-set filter cannot fire. The rootOf
    guard does fire and stays.
  - GraphOptions.layerGap/nodeGap/idPrefix: no caller, not in the tool schema.

Simplifications:
  - LayoutContext wrapped a single field; the link array now passes directly,
    which also removes the NO_CONTEXT default no call site ever took.
  - stretches() and the mirror-image check five lines below it expressed one rule
    two ways; unified, with the rationale stated once.
  - hasStencilFrame/isDirectional: one caller each, and isDirectional's name
    contradicted its body, which the guarded branch then re-discriminated anyway.
  - poolFrameStyle() took no arguments and had one caller.
  - poolCellOf clamped a value already clamped at the model boundary and
    unreachable-by-construction from the parser.
  - A comment on stampPoolDecoration described container behaviour the function
    does not implement.

Kept deliberately, with evidence:
  - The best-arrangement tracking in the crossing reducer. Two reviewers
    suspected it was dead weight. Measured: barycentre sweeping regressed below
    its own running best in 180 of 500 random graphs, so without it a third of
    flowcharts would keep a worse arrangement than one already found.
  - Vertical pools. Two reviewers recommended deleting the feature as
    undiscoverable. The bug was one line, and vertical swimlanes are a real
    convention — documented to the model instead, which is what was actually
    missing.
  - styleValue duplicating readMarker, isLeaf, findPageIndex: all genuinely
    redundant, all predating this branch. Left alone to keep the diff scoped.

525 unit tests and 11 diagram e2e tests pass.
This commit is contained in:
dayuan.jiang
2026-08-09 14:47:46 +09:00
parent a3814f702d
commit 526f1e14e7
10 changed files with 217 additions and 210 deletions

View File

@@ -722,7 +722,7 @@ add_container: children stacked along one axis. dir "row" side by side, "col" on
add_grid: packs children into cols columns. Use it to pack 3-8 related icons into one labelled area rather than giving each its own frame. add_grid: packs children into cols columns. Use it to pack 3-8 related icons into one labelled area rather than giving each its own frame.
add_pool: a SWIMLANE diagram. lanes are the roles, top to bottom. Each step is an add_box with lane (which role owns it) and col (which step of the process it is); columns advance left to right and an empty cell means that role does nothing at that point. Two steps with the same col happen at the same time. phases optionally labels groups of columns. add_pool: a SWIMLANE diagram. lanes are the roles, top to bottom. Set orientation to "vertical" for vertical swimlanes, where the lanes become columns and the flow runs downwards. Each step is an add_box with lane (which role owns it) and col (which step of the process it is); columns advance left to right and an empty cell means that role does nothing at that point. Two steps with the same col happen at the same time. phases optionally labels groups of columns.
{"operations":[ {"operations":[
{"op":"add_pool","id":"p","label":"Expense claim","lanes":["Employee","Manager","Finance"],"phases":["Submit","Review","Pay"]}, {"op":"add_pool","id":"p","label":"Expense claim","lanes":["Employee","Manager","Finance"],"phases":["Submit","Review","Pay"]},
{"op":"add_box","id":"fill","parent":"p","label":"Fill form","lane":0,"col":0,"shape":"terminator"}, {"op":"add_box","id":"fill","parent":"p","label":"Fill form","lane":0,"col":0,"shape":"terminator"},

View File

@@ -46,14 +46,15 @@ export interface GraphEdge {
export interface GraphOptions { export interface GraphOptions {
/** "col" (default): layers stack downwards. "row": layers run left to right. */ /** "col" (default): layers stack downwards. "row": layers run left to right. */
flow?: "col" | "row" 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
} }
/** Distance between layers. */
const LAYER_GAP = 48
/** Distance between nodes within a layer. */
const NODE_GAP = 60
/** Prefix for the generated layer container ids. */
const LAYER_ID = "__layer"
export interface GraphResult { export interface GraphResult {
operations: Operation[] operations: Operation[]
/** The nodes of each layer, in the order they were placed. */ /** The nodes of each layer, in the order they were placed. */
@@ -268,7 +269,6 @@ export function graphToOperations(
const flow = opts.flow ?? "col" const flow = opts.flow ?? "col"
const ids = nodes.map((n) => n.id) const ids = nodes.map((n) => n.id)
const known = new Set(ids) const known = new Set(ids)
const prefix = opts.idPrefix ?? "__layer"
const unknownEndpoints: string[] = [] const unknownEndpoints: string[] = []
const usable: GraphEdge[] = [] const usable: GraphEdge[] = []
@@ -290,7 +290,7 @@ export function graphToOperations(
// The flow axis is the OUTER container's direction; a layer runs across it. // The flow axis is the OUTER container's direction; a layer runs across it.
const outerDir = flow const outerDir = flow
const layerDir = flow === "col" ? "row" : "col" const layerDir = flow === "col" ? "row" : "col"
const root = `${prefix}s` const root = `${LAYER_ID}s`
const operations: Operation[] = [ const operations: Operation[] = [
{ {
@@ -298,7 +298,7 @@ export function graphToOperations(
id: root, id: root,
label: "", label: "",
dir: outerDir, dir: outerDir,
gap: opts.layerGap ?? 48, gap: LAYER_GAP,
}, },
] ]
const byId = new Map(nodes.map((n) => [n.id, n])) const byId = new Map(nodes.map((n) => [n.id, n]))
@@ -327,14 +327,14 @@ export function graphToOperations(
operations.push(add(members[0], root)) operations.push(add(members[0], root))
return return
} }
const band = `${prefix}${i}` const band = `${LAYER_ID}${i}`
operations.push({ operations.push({
op: "add_container", op: "add_container",
id: band, id: band,
parent: root, parent: root,
label: "", label: "",
dir: layerDir, dir: layerDir,
gap: opts.nodeGap ?? 60, gap: NODE_GAP,
}) })
for (const m of members) operations.push(add(m, band)) for (const m of members) operations.push(add(m, band))
}) })

View File

@@ -55,9 +55,9 @@ export const POOL_PAD = 16
/** Width of the lane-name column (height, when the pool is vertical). */ /** Width of the lane-name column (height, when the pool is vertical). */
export const LANE_LABEL = 110 export const LANE_LABEL = 110
/** Height of the milestone band (width, when the pool is vertical). */ /** Height of the milestone band (width, when the pool is vertical). */
export const PHASE_LABEL = 26 const PHASE_LABEL = 26
/** A pool's own title strip. */ /** A pool's own title strip. */
export const POOL_HEADER = 34 const POOL_HEADER = 34
/** Vertical padding inside a lane band, so nodes do not touch the band's edges. */ /** Vertical padding inside a lane band, so nodes do not touch the band's edges. */
const LANE_PAD = 14 const LANE_PAD = 14
@@ -132,10 +132,8 @@ export function poolMetrics(
} }
} }
/** Where a sequence diagram's lifelines start and how far they run. */ /** How far a sequence diagram's lifelines run, and where each message sits. */
export interface SequenceMetrics { export interface SequenceMetrics {
/** Top of the lifeline, just under the participant heads. */
top: number
/** Bottom of the lifeline. */ /** Bottom of the lifeline. */
bottom: number bottom: number
/** Vertical position of message N, for N starting at 1. */ /** Vertical position of message N, for N starting at 1. */
@@ -148,10 +146,9 @@ export function sequenceMetrics(
messages: number, messages: number,
): SequenceMetrics { ): SequenceMetrics {
const head = n.label ? HEADER : 0 const head = n.label ? HEADER : 0
const top = rect.y + head + PAD + HEAD_H // The first message hangs a fixed distance below the participant heads.
const first = top + LIFELINE_TOP const first = rect.y + head + PAD + HEAD_H + LIFELINE_TOP
return { return {
top,
bottom: first + Math.max(0, messages - 1) * n.step + LIFELINE_TAIL, bottom: first + Math.max(0, messages - 1) * n.step + LIFELINE_TAIL,
messageY: (step) => first + Math.max(0, step - 1) * n.step, messageY: (step) => first + Math.max(0, step - 1) * n.step,
} }
@@ -162,28 +159,17 @@ export interface Placed {
node: DiagramNode node: DiagramNode
rect: Rect rect: Rect
children: Placed[] 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. * The arrows layout needs, which the node tree alone does not carry.
* *
* Three of the five container kinds are laid out from the diagram's arrows, not from * 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 * 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 * 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. * passed down rather than read from a parent pointer.
*/ */
export interface LayoutContext { export type LayoutLinks = { source: string; target: string; step?: number }[]
/** 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. */ /** Intrinsic size of a text box: widest wrapped line by line count. */
export function autoBoxSize(label: string): { w: number; h: number } { export function autoBoxSize(label: string): { w: number; h: number } {
@@ -217,22 +203,14 @@ function headerFor(n: ContainerNode): number {
} }
/** /**
* May this node be stretched to match a sibling's size? * The cell a node occupies inside a pool. Absent means (0,0).
* *
* Only a `group` may. A leaf keeps its natural size, because stretching an icon distorts * No clamping needed: `add_icon`/`add_box` clamp at the boundary where the model's numbers
* the glyph. The three specialised containers compute their interiors from their own rules * arrive, and the only other way a cell gets set is the parser, whose `dai_cell` pattern
* — lane bands, lifeline positions, ring radii — so forcing one wider leaves dead space * matches digits only. So by here it is already non-negative.
* 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 { export function poolCellOf(n: DiagramNode): { lane: number; col: number } {
return n.kind === "group" if ((n.kind === "icon" || n.kind === "box") && n.cell) return n.cell
}
/** 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 } return { lane: 0, col: 0 }
} }
@@ -243,9 +221,9 @@ function poolCellOf(n: DiagramNode): { lane: number; col: number } {
* Steps are what order the messages vertically, so a diagram whose links carry no step * 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. * still needs one row per message — otherwise every arrow lands on the same y.
*/ */
function messageCount(n: SequenceNode, ctx: LayoutContext): number { export function messageCount(n: SequenceNode, links: LayoutLinks): number {
const own = new Set(n.children.map((c) => c.id)) 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 mine = links.filter((l) => own.has(l.source) && own.has(l.target))
const steps = mine const steps = mine
.map((l) => l.step) .map((l) => l.step)
.filter((s): s is number => s != null && s > 0) .filter((s): s is number => s != null && s > 0)
@@ -263,8 +241,6 @@ interface RadialTree {
kids: RadialTree[] kids: RadialTree[]
/** How much room this whole subtree needs across the branching axis. */ /** How much room this whole subtree needs across the branching axis. */
extent: number extent: number
/** How many generations deep this subtree goes, counting itself as 1. */
depth: number
} }
/** /**
@@ -279,14 +255,14 @@ interface RadialTree {
*/ */
function radialHierarchy( function radialHierarchy(
kids: Placed[], kids: Placed[],
ctx: LayoutContext, links: LayoutLinks,
across: "w" | "h", across: "w" | "h",
gap: number, gap: number,
): { root: RadialTree; branches: RadialTree[] } | null { ): { root: RadialTree; branches: RadialTree[] } | null {
if (kids.length === 0) return null if (kids.length === 0) return null
const own = new Map(kids.map((k) => [k.node.id, k])) const own = new Map(kids.map((k) => [k.node.id, k]))
const parent = new Map<string, string>() const parent = new Map<string, string>()
for (const l of ctx.links) { for (const l of links) {
if (!own.has(l.source) || !own.has(l.target)) continue if (!own.has(l.source) || !own.has(l.target)) continue
if (l.source === l.target) continue if (l.source === l.target) continue
if (!parent.has(l.target)) parent.set(l.target, l.source) if (!parent.has(l.target)) parent.set(l.target, l.source)
@@ -312,22 +288,20 @@ function radialHierarchy(
for (const k of kids) { for (const k of kids) {
if (k.node.id === rootId) continue if (k.node.id === rootId) continue
const up = parent.get(k.node.id) const up = parent.get(k.node.id)
// An orphan, or a node whose parent chain loops back to itself, attaches to the root. // An orphan, or a node whose parent chain loops back on itself, attaches to the root.
// `up === k.node.id` cannot happen: self-links are skipped when `parent` is built.
const attach = const attach =
up !== undefined && up !== k.node.id && rootOf(k.node.id) === rootId up !== undefined && rootOf(k.node.id) === rootId ? up : rootId
? up
: rootId
const list = childrenOf.get(attach) const list = childrenOf.get(attach)
if (list) list.push(k) if (list) list.push(k)
else childrenOf.set(attach, [k]) else childrenOf.set(attach, [k])
} }
const seen = new Set<string>() // No visited-set needed: `parent` records at most one parent per node, so `childrenOf`
// is a forest by construction, and `rootOf` above already reattached anything whose
// parent chain looped. The recursion cannot revisit a node.
const build = (p: Placed): RadialTree => { const build = (p: Placed): RadialTree => {
seen.add(p.node.id) const kidTrees = (childrenOf.get(p.node.id) ?? []).map(build)
const kidTrees = (childrenOf.get(p.node.id) ?? [])
.filter((c) => !seen.has(c.node.id))
.map(build)
const total = const total =
kidTrees.reduce((s, t) => s + t.extent, 0) + kidTrees.reduce((s, t) => s + t.extent, 0) +
gap * Math.max(0, kidTrees.length - 1) gap * Math.max(0, kidTrees.length - 1)
@@ -335,9 +309,6 @@ function radialHierarchy(
p, p,
kids: kidTrees, kids: kidTrees,
extent: Math.max(p.rect[across], total), 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) const root = build(own.get(rootId) as Placed)
@@ -368,10 +339,10 @@ function radialReach(
along: "w" | "h", along: "w" | "h",
gap: number, gap: number,
): number { ): number {
if (side.length === 0) return 0 // widestPerLevel writes one entry per generation that exists, so its length IS the depth
const levels = widestPerLevel(side, along) // of the deepest branch on this side. An empty side yields an empty list, and reducing
const generations = Math.max(...side.map((b) => b.depth)) // that from 0 already gives 0.
return levels.slice(0, generations).reduce((s, v) => s + v + gap, 0) return widestPerLevel(side, along).reduce((s, v) => s + v + gap, 0)
} }
/** /**
@@ -398,7 +369,7 @@ function radialSides(branches: RadialTree[]): {
function measure( function measure(
n: DiagramNode, n: DiagramNode,
defaultGlyph: number, defaultGlyph: number,
ctx: LayoutContext = NO_CONTEXT, links: LayoutLinks,
): Placed { ): Placed {
if (n.kind === "icon") { if (n.kind === "icon") {
const glyph = n.size ?? defaultGlyph const glyph = n.size ?? defaultGlyph
@@ -417,7 +388,7 @@ function measure(
return { node: n, rect: { x: 0, y: 0, w: 0, h: 30 }, children: [] } return { node: n, rect: { x: 0, y: 0, w: 0, h: 30 }, children: [] }
} }
const kids = n.children.map((c) => measure(c, defaultGlyph, ctx)) const kids = n.children.map((c) => measure(c, defaultGlyph, links))
const head = headerFor(n) const head = headerFor(n)
const gap = n.gap const gap = n.gap
@@ -450,7 +421,7 @@ function measure(
const m = sequenceMetrics( const m = sequenceMetrics(
n, n,
{ x: 0, y: 0, w: 0, h: 0 }, { x: 0, y: 0, w: 0, h: 0 },
messageCount(n, ctx), messageCount(n, links),
) )
return { return {
node: n, node: n,
@@ -467,7 +438,7 @@ function measure(
if (n.kind === "radial") { if (n.kind === "radial") {
const down = n.spread === "down" const down = n.spread === "down"
const across = down ? "w" : "h" const across = down ? "w" : "h"
const tree = radialHierarchy(kids, ctx, across, n.gap) const tree = radialHierarchy(kids, links, across, n.gap)
if (!tree) if (!tree)
return { return {
node: n, node: n,
@@ -538,8 +509,13 @@ function measure(
// group: row or col // group: row or col
if (n.dir === "row") { if (n.dir === "row") {
const tallest = Math.max(0, ...kids.map((k) => k.rect.h)) const tallest = Math.max(0, ...kids.map((k) => k.rect.h))
// Only a group stretches to match its siblings. A leaf keeps its natural size,
// because stretching an icon distorts the glyph; and a grid, pool, sequence or
// radial computes its interior from its own rule, so forcing one bigger leaves dead
// space inside rather than filling anything — and for a pool it would detach the
// lane bands from the nodes sitting on them.
for (const k of kids) for (const k of kids)
if (stretches(k.node)) k.rect.h = Math.max(k.rect.h, tallest) if (k.node.kind === "group") k.rect.h = Math.max(k.rect.h, tallest)
const w = const w =
PAD * 2 + PAD * 2 +
kids.reduce((s, k) => s + k.rect.w, 0) + kids.reduce((s, k) => s + k.rect.w, 0) +
@@ -553,8 +529,7 @@ function measure(
} }
const widest = Math.max(0, ...kids.map((k) => k.rect.w)) const widest = Math.max(0, ...kids.map((k) => k.rect.w))
// Only groups stretch: a grid computes its own interior, so forcing it wider would // Only a group stretches — same reasoning as the row branch above.
// leave a gap inside it rather than filling the space.
for (const k of kids) for (const k of kids)
if (k.node.kind === "group") k.rect.w = Math.max(k.rect.w, widest) if (k.node.kind === "group") k.rect.w = Math.max(k.rect.w, widest)
const w = PAD * 2 + Math.max(0, ...kids.map((k) => k.rect.w)) const w = PAD * 2 + Math.max(0, ...kids.map((k) => k.rect.w))
@@ -579,12 +554,7 @@ function measure(
* stretched frame reads as deliberately spaced instead of sparse, and the resulting * stretched frame reads as deliberately spaced instead of sparse, and the resulting
* cluster is centred. * cluster is centred.
*/ */
function place( function place(p: Placed, x: number, y: number, links: LayoutLinks): void {
p: Placed,
x: number,
y: number,
ctx: LayoutContext = NO_CONTEXT,
): void {
p.rect.x = Math.round(x) p.rect.x = Math.round(x)
p.rect.y = Math.round(y) p.rect.y = Math.round(y)
const n = p.node const n = p.node
@@ -611,7 +581,7 @@ function place(
k, k,
cx + (cellW - k.rect.w) / 2, cx + (cellW - k.rect.w) / 2,
cy + (cellH - k.rect.h) / 2, cy + (cellH - k.rect.h) / 2,
ctx, links,
) )
}) })
return return
@@ -633,7 +603,7 @@ function place(
k, k,
cx + (m.cellW - k.rect.w) / 2, cx + (m.cellW - k.rect.w) / 2,
cy + (m.cellH - k.rect.h) / 2, cy + (m.cellH - k.rect.h) / 2,
ctx, links,
) )
} }
return return
@@ -644,14 +614,14 @@ function place(
// renderer, so nothing else has to be placed here. // renderer, so nothing else has to be placed here.
let cur = innerX let cur = innerX
for (const k of kids) { for (const k of kids) {
place(k, cur, innerTop, ctx) place(k, cur, innerTop, links)
cur += k.rect.w + n.gap cur += k.rect.w + n.gap
} }
return return
} }
if (n.kind === "radial") { if (n.kind === "radial") {
placeRadial(p, n, innerX, innerTop, innerW, innerH, ctx) placeRadial(p, n, innerX, innerTop, innerW, innerH, links)
return return
} }
@@ -667,10 +637,10 @@ function place(
for (const kid of kids) { for (const kid of kids) {
if (alongRow) { if (alongRow) {
place(kid, cur, innerTop + (innerH - kid.rect.h) / 2, ctx) place(kid, cur, innerTop + (innerH - kid.rect.h) / 2, links)
cur += kid.rect.w + gap cur += kid.rect.w + gap
} else { } else {
place(kid, innerX + (innerW - kid.rect.w) / 2, cur, ctx) place(kid, innerX + (innerW - kid.rect.w) / 2, cur, links)
cur += kid.rect.h + gap cur += kid.rect.h + gap
} }
} }
@@ -694,12 +664,12 @@ function placeRadial(
innerTop: number, innerTop: number,
innerW: number, innerW: number,
innerH: number, innerH: number,
ctx: LayoutContext, links: LayoutLinks,
): void { ): void {
const down = n.spread === "down" const down = n.spread === "down"
const along = down ? "h" : "w" const along = down ? "h" : "w"
const across = down ? "w" : "h" const across = down ? "w" : "h"
const tree = radialHierarchy(p.children, ctx, across, n.gap) const tree = radialHierarchy(p.children, links, across, n.gap)
if (!tree) return if (!tree) return
const { root, branches } = tree const { root, branches } = tree
const centre = root.p const centre = root.p
@@ -714,7 +684,6 @@ function placeRadial(
*/ */
const spread = ( const spread = (
items: RadialTree[], items: RadialTree[],
level: number,
start: number, start: number,
alongPos: number, alongPos: number,
sign: 1 | -1, sign: 1 | -1,
@@ -728,8 +697,8 @@ function placeRadial(
// On the left side the ring position is the branch's FAR edge, so its own size has // 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. // to come off to get its origin.
const a = sign > 0 ? alongPos : alongPos - b.p.rect[along] const a = sign > 0 ? alongPos : alongPos - b.p.rect[along]
if (down) place(b.p, mid - b.p.rect.w / 2, a, ctx) if (down) place(b.p, mid - b.p.rect.w / 2, a, links)
else place(b.p, a, mid - b.p.rect.h / 2, ctx) else place(b.p, a, mid - b.p.rect.h / 2, links)
if (b.kids.length) { if (b.kids.length) {
// `alongPos` means the NEAR edge going outwards and the FAR edge coming back, // `alongPos` means the NEAR edge going outwards and the FAR edge coming back,
@@ -737,17 +706,16 @@ function placeRadial(
// recursion subtracts the child's own width, so subtracting the ring width as // 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. // 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 const next = sign > 0 ? a + b.p.rect[along] + n.gap : a - n.gap
spread(b.kids, level + 1, mid, next, sign) spread(b.kids, mid, next, sign)
} }
cur += b.extent + n.gap cur += b.extent + n.gap
} }
} }
if (down) { if (down) {
place(centre, innerX + (innerW - centre.rect.w) / 2, innerTop, ctx) place(centre, innerX + (innerW - centre.rect.w) / 2, innerTop, links)
spread( spread(
branches, branches,
0,
centre.rect.x + centre.rect.w / 2, centre.rect.x + centre.rect.w / 2,
centre.rect.y + centre.rect.h + n.gap, centre.rect.y + centre.rect.h + n.gap,
1, 1,
@@ -766,11 +734,11 @@ function placeRadial(
centre, centre,
innerX + radialReach(left, "w", n.gap), innerX + radialReach(left, "w", n.gap),
innerTop + (innerH - centre.rect.h) / 2, innerTop + (innerH - centre.rect.h) / 2,
ctx, links,
) )
const midY = centre.rect.y + centre.rect.h / 2 const midY = centre.rect.y + centre.rect.h / 2
spread(right, 0, midY, centre.rect.x + centre.rect.w + n.gap, 1) spread(right, midY, centre.rect.x + centre.rect.w + n.gap, 1)
spread(left, 0, midY, centre.rect.x - n.gap, -1) spread(left, midY, centre.rect.x - n.gap, -1)
} }
export interface LayoutResult { export interface LayoutResult {
@@ -797,13 +765,13 @@ export function layoutForest(
gap?: number gap?: number
/** The diagram's links. Needed by sequence containers, which size themselves from /** The diagram's links. Needed by sequence containers, which size themselves from
* the number of messages between their participants. */ * the number of messages between their participants. */
links?: LayoutContext["links"] links?: LayoutLinks
} = {}, } = {},
): LayoutResult { ): LayoutResult {
const glyph = opts.iconSize ?? ICON_SIZE const glyph = opts.iconSize ?? ICON_SIZE
const gap = opts.gap ?? 70 const gap = opts.gap ?? 70
const ctx: LayoutContext = { links: opts.links ?? [] } const links: LayoutLinks = opts.links ?? []
const placed = roots.map((r) => measure(r, glyph, ctx)) const placed = roots.map((r) => measure(r, glyph, links))
let cur = ORIGIN.x let cur = ORIGIN.x
for (const p of placed) { for (const p of placed) {
@@ -811,9 +779,9 @@ export function layoutForest(
const held = const held =
n.kind === "title" ? null : n.pinned ? (n.rect ?? null) : null n.kind === "title" ? null : n.pinned ? (n.rect ?? null) : null
if (held) { if (held) {
place(p, held.x, held.y, ctx) place(p, held.x, held.y, links)
} else { } else {
place(p, cur, ORIGIN.y, ctx) place(p, cur, ORIGIN.y, links)
cur += p.rect.w + gap cur += p.rect.w + gap
} }
} }

View File

@@ -306,9 +306,10 @@ export function stampLane(style: string, lane: number): string {
/** /**
* Stamp a pool's own decoration — a lane-name column or a milestone strip. * 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 * `dai_lane=-1` marks it as chrome the renderer rebuilds, so the parser drops it rather
* dropped on it belongs to no role. It stays a draw.io container only so clicks fall * than reading it back as a node. Unlike a lane band it is deliberately NOT a draw.io
* through to whatever is behind it. * container: a step dropped on a label column belongs to no role, and letting it reparent
* there would lose the step's lane.
*/ */
export function stampPoolDecoration(style: string): string { export function stampPoolDecoration(style: string): string {
return append(style, MARKER.lane, -1) return append(style, MARKER.lane, -1)

View File

@@ -17,9 +17,7 @@ import {
type DiagramTree, type DiagramTree,
findNode, findNode,
findParent, findParent,
hasStencilFrame,
isContainer, isContainer,
isDirectional,
type LinkSpec, type LinkSpec,
walkTree, walkTree,
} from "./types" } from "./types"
@@ -490,7 +488,7 @@ export function applyOperations(
errors.push(`set_dir: "${op.id}" is not a container`) errors.push(`set_dir: "${op.id}" is not a container`)
break break
} }
if (!isDirectional(node)) { if (node.kind !== "group") {
// A grid, pool, sequence or radial container arranges its children by its // A grid, pool, sequence or radial container arranges its children by its
// own rule; "row or column" is not a property they have. // own rule; "row or column" is not a property they have.
errors.push( errors.push(
@@ -586,7 +584,7 @@ export function collectNames(
for (const n of walkTree(tree)) { for (const n of walkTree(tree)) {
if (n.kind === "icon" && n.name) if (n.kind === "icon" && n.name)
out.push({ id: n.id, name: n.name, kind: "icon" }) out.push({ id: n.id, name: n.name, kind: "icon" })
else if (hasStencilFrame(n) && n.gname) else if ((n.kind === "group" || n.kind === "grid") && n.gname)
out.push({ id: n.id, name: n.gname, kind: "group" }) out.push({ id: n.id, name: n.gname, kind: "group" })
} }
return out return out

View File

@@ -13,9 +13,12 @@ import {
ICON_SIZE, ICON_SIZE,
LANE_LABEL, LANE_LABEL,
layoutForest, layoutForest,
messageCount,
type Placed, type Placed,
POOL_PAD, POOL_PAD,
poolCellOf,
poolMetrics, poolMetrics,
type SequenceMetrics,
sequenceMetrics, sequenceMetrics,
} from "./layout" } from "./layout"
import { import {
@@ -29,14 +32,15 @@ import {
stampSequence, stampSequence,
} from "./markers" } from "./markers"
import { type RoutedEdge, routeEdges } from "./route" import { type RoutedEdge, routeEdges } from "./route"
import type { import {
BoxShape, type BoxShape,
DiagramNode, type DiagramNode,
DiagramTree, type DiagramTree,
LinkSpec, isContainer,
PoolNode, type LinkSpec,
Rect, type PoolNode,
SequenceNode, type Rect,
type SequenceNode,
} from "./types" } from "./types"
/** Escape the five characters that would break an XML attribute. */ /** Escape the five characters that would break an XML attribute. */
@@ -96,6 +100,11 @@ const POOL_LABEL_FILL = "#EEF2F7"
const POOL_FILL = "#FFFFFF" const POOL_FILL = "#FFFFFF"
const POOL_STROKE = "#5A6B7B" const POOL_STROKE = "#5A6B7B"
/** A pool's outer frame: a plain titled rectangle, since the bands supply the structure. */
const POOL_FRAME_STYLE =
`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;`
/** /**
* A participant head in a sequence diagram: the box at the top of a lifeline. * A participant head in a sequence diagram: the box at the top of a lifeline.
* *
@@ -148,7 +157,7 @@ function styleFor(n: DiagramNode, resolve: StyleResolver | undefined): string {
} }
if (n.kind === "pool") { if (n.kind === "pool") {
return stampPool(n.style ?? poolFrameStyle(), { return stampPool(n.style ?? POOL_FRAME_STYLE, {
lanes: n.lanes, lanes: n.lanes,
phases: n.phases, phases: n.phases,
orientation: n.orientation, orientation: n.orientation,
@@ -187,14 +196,6 @@ 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 = const INVISIBLE_FRAME_STYLE =
"rounded=0;whiteSpace=wrap;html=1;fillColor=none;strokeColor=none;" "rounded=0;whiteSpace=wrap;html=1;fillColor=none;strokeColor=none;"
@@ -251,31 +252,6 @@ 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. */ /** One chrome cell: a lane band, a label column, a milestone strip, a lifeline. */
function chromeXml( function chromeXml(
id: string, id: string,
@@ -388,7 +364,10 @@ function poolChrome(
h: m.phaseLabel, h: m.phaseLabel,
} }
: { : {
x: rect.x + POOL_PAD + m.contentW + n.gap, // Flush against the content, because that is what the measure pass
// reserved: the pool's width is padding + content + this strip, with no
// gap between the two. Adding one here pushed the strip outside the frame.
x: rect.x + POOL_PAD + m.contentW,
y: m.contentY + from * (m.cellH + n.gap), y: m.contentY + from * (m.cellH + n.gap),
w: m.phaseLabel, w: m.phaseLabel,
h: Math.max( h: Math.max(
@@ -426,31 +405,24 @@ function sequenceChrome(
n: SequenceNode, n: SequenceNode,
rect: Rect, rect: Rect,
kids: { node: DiagramNode; rect: Rect }[], kids: { node: DiagramNode; rect: Rect }[],
messages: number, metrics: SequenceMetrics,
): { xml: string[]; replaced: Set<string> } { ): string[] {
const m = sequenceMetrics(n, rect, messages) return kids.map((k) => {
const xml: string[] = []
const replaced = new Set<string>()
for (const k of kids) {
const head = k.rect const head = k.rect
xml.push( return chromeXml(
chromeXml( k.node.id,
k.node.id, n.id,
n.id, {
{ x: head.x,
x: head.x, y: head.y,
y: head.y, w: head.w,
w: head.w, h: Math.max(head.h, metrics.bottom - head.y),
h: Math.max(head.h, m.bottom - head.y), },
}, rect,
rect, `${LIFELINE_STYLE}size=${Math.round(head.h)};`,
`${LIFELINE_STYLE}size=${Math.round(head.h)};`, "label" in k.node ? k.node.label : "",
"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. */ /** The label an edge renders, with its step number prefixed. */
@@ -615,7 +587,8 @@ export function renderDiagram(
const { xml, bands } = poolChrome(n, f.rect, kids) const { xml, bands } = poolChrome(n, f.rect, kids)
chrome.set(n.id, xml) chrome.set(n.id, xml)
for (const c of n.children) { for (const c of n.children) {
const band = bands[Math.min(poolLaneOf(c), bands.length - 1)] const band =
bands[Math.min(poolCellOf(c).lane, bands.length - 1)]
if (band) bandOf.set(c.id, { ...band.rect, id: band.id }) if (band) bandOf.set(c.id, { ...band.rect, id: band.id })
} }
} else if (n.kind === "sequence") { } else if (n.kind === "sequence") {
@@ -625,11 +598,16 @@ export function renderDiagram(
(k): k is { node: DiagramNode; rect: Rect } => (k): k is { node: DiagramNode; rect: Rect } =>
k.rect !== undefined, k.rect !== undefined,
) )
const count = countMessages(n, tree.links) // One metrics call for both the lifeline heights and the message positions:
const { xml, replaced } = sequenceChrome(n, f.rect, kids, count) // computing it twice is how the two would drift apart.
chrome.set(n.id, xml) const metrics = sequenceMetrics(
for (const id of replaced) asLifeline.add(id) n,
messageYOf.set(n.id, sequenceMetrics(n, f.rect, count).messageY) f.rect,
messageCount(n, tree.links),
)
chrome.set(n.id, sequenceChrome(n, f.rect, kids, metrics))
for (const k of kids) asLifeline.add(k.node.id)
messageYOf.set(n.id, metrics.messageY)
} }
} }
@@ -723,16 +701,7 @@ export function renderDiagram(
// cuts through a frame only one of its endpoints belongs to, reads as a mistake even // cuts through a frame only one of its endpoints belongs to, reads as a mistake even
// though it hits nothing. // though it hits nothing.
const frames = new Set( const frames = new Set(
flat flat.filter((f) => isContainer(f.node)).map((f) => f.node.id),
.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( const routes = routeEdges(
routable.map(({ link: l, index }) => ({ routable.map(({ link: l, index }) => ({

View File

@@ -267,21 +267,6 @@ export function isLeaf(n: DiagramNode): n is LeafNode {
return !isContainer(n) 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. */ /** Depth-first walk over a node and its descendants. */
export function* walk(n: DiagramNode): Generator<DiagramNode> { export function* walk(n: DiagramNode): Generator<DiagramNode> {
yield n yield n

View File

@@ -120,6 +120,9 @@ Swimlane diagrams (add_pool):
right; leave a cell empty when a role does nothing at that point — that is information. 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. - 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"]. - phases is optional and labels groups of columns, e.g. ["Intake", "Review", "Decision"].
- orientation defaults to horizontal (lanes stacked down, flow left to right). Set it to
"vertical" when the user asks for vertical swimlanes: lanes become columns and the flow
runs downwards.
Sequence diagrams (add_sequence): Sequence diagrams (add_sequence):
- One add_box per participant, left to right in the order they first act. - One add_box per participant, left to right in the order they first act.

View File

@@ -216,6 +216,89 @@ describe("swimlane pool: layout", () => {
expect(escapesParent(r.xml as string)).toEqual([]) expect(escapesParent(r.xml as string)).toEqual([])
}) })
it("keeps the milestone strip inside a VERTICAL pool", () => {
// The measure pass reserves the pool's width as padding + content + strip, with no
// gap between content and strip. Rendering the strip one gap further out put it
// outside the frame — and no earlier test caught it, because every vertical case
// omitted phases and every phases case was horizontal.
const r = restructureDiagram("", [
{
op: "add_pool",
id: "p",
label: "V",
lanes: ["A", "B"],
phases: ["P1", "P2"],
orientation: "vertical",
},
{
op: "add_box",
id: "x",
parent: "p",
label: "X",
lane: 0,
col: 0,
},
{
op: "add_box",
id: "y",
parent: "p",
label: "Y",
lane: 1,
col: 0,
},
{
op: "add_box",
id: "z",
parent: "p",
label: "Z",
lane: 0,
col: 1,
},
])
expect(r.errors).toEqual([])
expect(escapesParent(r.xml as string)).toEqual([])
expect(outsidePage(r.xml as string, ["__title"])).toEqual([])
})
it("keeps the milestone strip inside a HORIZONTAL pool", () => {
const r = restructureDiagram("", [
{
op: "add_pool",
id: "p",
label: "H",
lanes: ["A", "B"],
phases: ["P1", "P2", "P3"],
},
{
op: "add_box",
id: "x",
parent: "p",
label: "X",
lane: 0,
col: 0,
},
{
op: "add_box",
id: "y",
parent: "p",
label: "Y",
lane: 1,
col: 1,
},
{
op: "add_box",
id: "z",
parent: "p",
label: "Z",
lane: 0,
col: 2,
},
])
expect(r.errors).toEqual([])
expect(escapesParent(r.xml as string)).toEqual([])
expect(outsidePage(r.xml as string, ["__title"])).toEqual([])
})
it("refuses a pool with no lanes", () => { it("refuses a pool with no lanes", () => {
const r = restructureDiagram("", [ const r = restructureDiagram("", [
{ op: "add_pool", id: "p", label: "x", lanes: [] }, { op: "add_pool", id: "p", label: "x", lanes: [] },

View File

@@ -79,7 +79,7 @@ export function rectOf(rects: Map<string, Rect>, id: string): Rect {
} }
/** The page size the renderer declared. */ /** The page size the renderer declared. */
export function pageSize(xml: string): { w: number; h: number } { function pageSize(xml: string): { w: number; h: number } {
const m = xml.match(/pageWidth="(\d+)" pageHeight="(\d+)"/) const m = xml.match(/pageWidth="(\d+)" pageHeight="(\d+)"/)
return { w: Number(m?.[1] ?? 0), h: Number(m?.[2] ?? 0) } return { w: Number(m?.[1] ?? 0), h: Number(m?.[2] ?? 0) }
} }