/** * Layout: tree → coordinates. * * Two passes, the same shape as a flexbox implementation: * * measure — bottom-up. A leaf reports its intrinsic size; a container sums its * children along the flow axis, takes the maximum across it, and adds * padding and its title strip. A container therefore always ends up big * enough to hold what is inside it, which is why "child spills out of its * frame" cannot happen by construction. * * place — top-down. Each container distributes its now-known interior among its * children. * * The model never supplies a coordinate. It declares nesting, direction and gap; every * x/y/width/height comes from here. * * 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 { resolveShape } from "./shapes" import { type Role, roleMetrics } from "./theme" import type { ContainerNode, DiagramNode, PoolNode, RadialNode, Rect, SequenceNode, } from "./types" import { isContainer } from "./types" /** Default glyph size for a catalog icon. */ export const ICON_SIZE = 48 /** Interior padding of a container. */ const PAD = 24 /** A group's interior padding: its own `pad` when declared, the default otherwise. */ function padOf(n: ContainerNode): number { return n.kind === "group" && n.pad != null ? Math.max(0, n.pad) : PAD } /** The flex-grow weight a node declared, 0 when none. */ function growOf(n: DiagramNode): number { const g = (n.kind === "box" || n.kind === "group") && n.grow return typeof g === "number" && g > 0 ? g : 0 } /** The cross-axis alignment a node declared. */ function alignOf(n: DiagramNode): "start" | "center" | "end" | "stretch" { const a = (n.kind === "box" || n.kind === "group") && n.align return a === "start" || a === "end" || a === "stretch" ? a : "center" } /** Height of a container's title strip. Zero when it has no label — an empty strip * reads as a dead band at the top of the frame. */ const HEADER = 36 /** Approximate width of one label character at the engine's font size. */ const CHAR_W = 6.6 // ---- 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). */ const PHASE_LABEL = 26 /** A pool's own title strip. */ 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, } } /** How far a sequence diagram's lifelines run, and where each message sits. */ export interface SequenceMetrics { /** 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 // The first message hangs a fixed distance below the participant heads. const first = rect.y + head + PAD + HEAD_H + LIFELINE_TOP return { 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[] } /** * 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 * 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 down rather than read from a parent pointer. */ export type LayoutLinks = { source: string; target: string; step?: number }[] /** * Reduce a label to the text draw.io will actually lay out. * * Labels may carry inline HTML (every style has `html=1`): a
is a line break, any * other tag is invisible markup around visible text. Measuring the raw string counted * `` as thirty characters of text, making rich boxes twice as * wide as their content. */ function visibleText(label: string): string { return String(label ?? "") .replace(//gi, "\n") .replace(/<\/?(?:b|i|u|s|sub|sup|font|span|div)(?:\s[^<>]*)?>/gi, "") } /** * Intrinsic size of a text box: widest wrapped line by line count. * * The role scales the estimate: a banner sets 20px type and a footnote 9px, and layout has * to reserve what render will draw or the text overflows its cell. */ export function autoBoxSize( label: string, role?: Role, shape?: string, ): { w: number; h: number } { const spec = shape ? resolveShape(shape)?.spec : undefined // A glyph shape (umlActor…) has a fixed figure with the label below it: the slot is // the figure plus a line of text, and the text length does not scale the figure. if (spec?.labelOutside && spec.glyph) { const text = visibleText(label) return { w: Math.max(spec.glyph.w + 20, Math.min(160, text.length * 7 + 16)), h: spec.glyph.h + 22, } } const r = roleMetrics(role) const maxW = Math.round(260 * Math.max(1, r.charScale)) const explicit = visibleText(label).split("\n") const longest = Math.max(1, ...explicit.map((l) => l.length)) const w = Math.min( maxW, Math.max(120, Math.round(longest * CHAR_W * r.charScale + 28)), ) // Count the lines the text ACTUALLY occupies: draw.io wraps at the box width, so a // long line becomes several. Estimating by explicit newlines alone left the box one // line tall while the text wrapped to six — and overflowed straight out of it. const charsPerLine = Math.max( 8, Math.floor((w - 28) / (CHAR_W * r.charScale)), ) const lines = explicit.reduce( (sum, l) => sum + Math.max(1, Math.ceil(l.length / charsPerLine)), 0, ) const lineH = Math.round(r.fontSize * 1.6) const h = Math.max(r.minH, lines * lineH + 26) // A non-rectangular outline inscribes a smaller text area than its bounding box — // a rhombus exactly half — so the box grows by the shape's measured factor. // Verified in the real editor: the same sentence overflows a 1.0× rhombus and fits // a 1.5× one. const s = spec?.textScale ?? 1 return { w: Math.round(w * s), h: Math.round(h * s) } } /** * Intrinsic size of an icon cell: the glyph, plus room for the label underneath, and * wide enough that a long label does not overflow the cell it is centred in. */ function iconSize(label: string, glyph: number): { w: number; h: number } { return { w: Math.max(96, glyph + 20, Math.min(200, label.length * 7 + 24)), h: glyph + 34, } } /** A container is never narrower than its own title. */ function titleFloor(label: string, pad: number): number { return label ? Math.ceil(label.length * CHAR_W) + pad * 2 : 0 } function headerFor(n: ContainerNode): number { if (n.kind === "pool") return n.label ? POOL_HEADER : 0 return n.label ? HEADER : 0 } /** * The cell a node occupies inside a pool. Absent means (0,0). * * No clamping needed: `add_icon`/`add_box` clamp at the boundary where the model's numbers * arrive, and the only other way a cell gets set is the parser, whose `dai_cell` pattern * matches digits only. So by here it is already non-negative. */ export function poolCellOf(n: DiagramNode): { lane: number; col: number } { if ((n.kind === "icon" || n.kind === "box") && n.cell) return n.cell 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. */ export function messageCount(n: SequenceNode, links: LayoutLinks): 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 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 } /** * 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[], links: LayoutLinks, 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() for (const l of 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([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() 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 on itself, attaches to the root. // `up === k.node.id` cannot happen: self-links are skipped when `parent` is built. const attach = up !== undefined && rootOf(k.node.id) === rootId ? up : rootId const list = childrenOf.get(attach) if (list) list.push(k) else childrenOf.set(attach, [k]) } // 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 kidTrees = (childrenOf.get(p.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), } } 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 { // widestPerLevel writes one entry per generation that exists, so its length IS the depth // of the deepest branch on this side. An empty side yields an empty list, and reducing // that from 0 already gives 0. return widestPerLevel(side, along).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. * * Siblings are equalised across the cross axis — frames in a row share a bottom edge, * frames in a column share left and right edges. Only containers stretch; a leaf keeps * its natural size, because stretching an icon would distort the glyph. */ function measure( n: DiagramNode, defaultGlyph: number, links: LayoutLinks, ): Placed { if (n.kind === "icon") { const glyph = n.size ?? defaultGlyph const s = iconSize(n.label, glyph) return { node: n, rect: { x: 0, y: 0, ...s }, children: [] } } if (n.kind === "box") { const auto = autoBoxSize(n.label, n.role, n.shape) return { node: n, rect: { x: 0, y: 0, w: n.w ?? auto.w, h: n.h ?? auto.h }, children: [], } } if (n.kind === "title") { return { node: n, rect: { x: 0, y: 0, w: 0, h: 30 }, children: [] } } const kids = n.children.map((c) => measure(c, defaultGlyph, links)) 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, links), ) 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, links, 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 const cellW = Math.max(0, ...kids.map((k) => k.rect.w)) const cellH = Math.max(0, ...kids.map((k) => k.rect.h)) const w = PAD * 2 + cols * cellW + gap * (cols - 1) const h = head + PAD * 2 + rows * cellH + gap * (rows - 1) return { node: n, rect: { x: 0, y: 0, w: Math.max(w, titleFloor(n.label, PAD)), h, }, children: kids, } } // group: row or col const pad = padOf(n) if (n.dir === "row") { 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) if (k.node.kind === "group") k.rect.h = Math.max(k.rect.h, tallest) const w = pad * 2 + kids.reduce((s, k) => s + k.rect.w, 0) + gap * Math.max(0, kids.length - 1) const h = head + pad * 2 + Math.max(0, ...kids.map((k) => k.rect.h)) return { node: n, rect: { x: 0, y: 0, w: Math.max(w, titleFloor(n.label, pad)), h }, children: kids, } } const widest = Math.max(0, ...kids.map((k) => k.rect.w)) // Only a group stretches — same reasoning as the row branch above. for (const k of kids) if (k.node.kind === "group") k.rect.w = Math.max(k.rect.w, widest) const w = pad * 2 + Math.max(0, ...kids.map((k) => k.rect.w)) const h = head + pad * 2 + kids.reduce((s, k) => s + k.rect.h, 0) + gap * Math.max(0, kids.length - 1) return { node: n, rect: { x: 0, y: 0, w: Math.max(w, titleFloor(n.label, pad)), h }, children: kids, } } /** * place: assign absolute positions, top-down. * * When a container ended up larger than its content — because a sibling forced it * wider, or its own title did — the slack is shared between the children rather than * left as dead margin on one side. The extra spacing is capped at one base gap so a * stretched frame reads as deliberately spaced instead of sparse, and the resulting * cluster is centred. */ function place(p: Placed, x: number, y: number, links: LayoutLinks): void { p.rect.x = Math.round(x) p.rect.y = Math.round(y) const n = p.node if (!isContainer(n)) return const head = headerFor(n) const pad = n.kind === "group" ? padOf(n) : PAD const innerX = p.rect.x + pad const innerTop = p.rect.y + head + pad const innerW = p.rect.w - pad * 2 const innerH = p.rect.h - head - pad * 2 const kids = p.children if (n.kind === "grid") { const cols = Math.max(1, n.cols) const cellW = Math.max(0, ...kids.map((k) => k.rect.w)) const cellH = Math.max(0, ...kids.map((k) => k.rect.h)) kids.forEach((k, i) => { const r = Math.floor(i / cols) const c = i % cols const cx = innerX + c * (cellW + n.gap) const cy = innerTop + r * (cellH + n.gap) // centre each child in its cell so a short label does not sit off-axis place( k, cx + (cellW - k.rect.w) / 2, cy + (cellH - k.rect.h) / 2, links, ) }) 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, links, ) } 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, links) cur += k.rect.w + n.gap } return } if (n.kind === "radial") { placeRadial(p, n, innerX, innerTop, innerW, innerH, links) return } const alongRow = n.dir === "row" const sizes = kids.map((k) => (alongRow ? k.rect.w : k.rect.h)) const content = sizes.reduce((s, v) => s + v, 0) const extent = alongRow ? innerW : innerH const k = kids.length let slack = Math.max(0, extent - content - n.gap * (k - 1)) // flex-grow: children with a weight split the leftover space between them, TeX's // glue. This runs before the gap stretch below — declared weights are a statement // about where the slack should go, and padding it into the gaps instead would // silently override that statement. const weights = kids.map((kid) => growOf(kid.node)) const totalWeight = weights.reduce((s, v) => s + v, 0) if (totalWeight > 0 && slack > 0) { kids.forEach((kid, i) => { const extra = (slack * weights[i]) / totalWeight if (alongRow) kid.rect.w += extra else kid.rect.h += extra }) slack = 0 } const gap = k > 1 ? n.gap + Math.min(n.gap, slack / (k - 1)) : n.gap const span = kids.reduce((s, kid) => s + (alongRow ? kid.rect.w : kid.rect.h), 0) + gap * Math.max(0, k - 1) let cur = (alongRow ? innerX : innerTop) + Math.max(0, (extent - span) / 2) for (const kid of kids) { // A stretching role fills the cross axis: a masthead spans its page, a section // heading spans its column. Measured at its text width, then widened here — the // container's size still comes from the widest ordinary child. const kn = kid.node const a = alignOf(kn) const stretches = a === "stretch" || (kn.kind === "box" && kn.role && roleMetrics(kn.role).stretch) // Cross-axis position: centred unless the child asked for an edge. const cross = (room: number, size: number): number => { if (a === "start") return 0 if (a === "end") return Math.max(0, room - size) return (room - size) / 2 } if (alongRow) { if (stretches) kid.rect.h = innerH place(kid, cur, innerTop + cross(innerH, kid.rect.h), links) cur += kid.rect.w + gap } else { if (stretches) kid.rect.w = innerW place(kid, innerX + cross(innerW, kid.rect.w), cur, links) 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, links: LayoutLinks, ): void { const down = n.spread === "down" const along = down ? "h" : "w" const across = down ? "w" : "h" const tree = radialHierarchy(p.children, links, 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[], 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, links) else place(b.p, a, mid - b.p.rect.h / 2, links) 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, mid, next, sign) } cur += b.extent + n.gap } } if (down) { place(centre, innerX + (innerW - centre.rect.w) / 2, innerTop, links) spread( branches, 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, links, ) const midY = centre.rect.y + centre.rect.h / 2 spread(right, midY, centre.rect.x + centre.rect.w + n.gap, 1) spread(left, midY, centre.rect.x - n.gap, -1) } export interface LayoutResult { /** Placed roots, in the order given. */ roots: Placed[] /** Page size that fits everything, with a margin. */ page: { w: number; h: number } } /** Where the tree starts on the page. Leaves room for a title above it. */ const ORIGIN = { x: 40, y: 90 } const MARGIN = { right: 40, bottom: 50 } /** * Lay out a forest of roots side by side and report the page size that fits them. * * A pinned node keeps the position it already had: the user moved it deliberately, and * the whole point of the pin is that a re-layout does not undo that. */ export function layoutForest( roots: DiagramNode[], opts: { iconSize?: number gap?: number /** The diagram's links. Needed by sequence containers, which size themselves from * the number of messages between their participants. */ links?: LayoutLinks } = {}, ): LayoutResult { const glyph = opts.iconSize ?? ICON_SIZE const gap = opts.gap ?? 70 const links: LayoutLinks = opts.links ?? [] const placed = roots.map((r) => measure(r, glyph, links)) let cur = ORIGIN.x for (const p of placed) { const n = p.node const held = n.kind === "title" ? null : n.pinned ? (n.rect ?? null) : null if (held) { place(p, held.x, held.y, links) } else { place(p, cur, ORIGIN.y, links) cur += p.rect.w + gap } } let maxX = 0 let maxY = 0 const visit = (p: Placed) => { maxX = Math.max(maxX, p.rect.x + p.rect.w) maxY = Math.max(maxY, p.rect.y + p.rect.h) p.children.forEach(visit) } placed.forEach(visit) return { roots: placed, page: { w: Math.round(maxX + MARGIN.right), h: Math.round(maxY + MARGIN.bottom), }, } } /** Flatten a placed forest into (node, rect, parentId) triples in document order. */ export function flatten( roots: Placed[], ): { node: DiagramNode; rect: Rect; parent: string }[] { const out: { node: DiagramNode; rect: Rect; parent: string }[] = [] const walk = (p: Placed, parent: string) => { out.push({ node: p.node, rect: p.rect, parent }) for (const c of p.children) walk(c, p.node.id) } for (const r of roots) walk(r, "1") return out }