/** * 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 { Align, ContainerNode, DiagramNode, Justify, 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 ends up with: its own `align`, else the parent's * `alignItems`, else centred. Same cascade as CSS, where align-self overrides the * container's align-items. */ function alignOf(n: DiagramNode, parent?: ContainerNode): Align { const own = (n.kind === "box" || n.kind === "group") && n.align if ( own === "start" || own === "end" || own === "stretch" || own === "center" ) return own const inherited = parent?.kind === "group" ? parent.alignItems : undefined if ( inherited === "start" || inherited === "end" || inherited === "stretch" || inherited === "center" ) return inherited return "center" } /** * The main-axis distribution a container declared, or null when it declared none. * * Null matters: it selects the engine's original per-axis defaults rather than any value * in this vocabulary. A row centred its children and padded their gaps, a column packed to * the top — neither is expressible as one `Justify`, and both are what every diagram built * before this existed relies on. Declaring `justify` opts out of them. */ function justifyOf(n: ContainerNode): Justify | null { const j = n.kind === "group" ? n.justify : undefined return j === "start" || j === "center" || j === "end" || j === "between" || j === "around" || j === "evenly" ? j : null } /** The width cap a node declared, or Infinity. */ function maxWOf(n: DiagramNode): number { const m = (n.kind === "box" || n.kind === "group") && n.maxW return typeof m === "number" && m > 0 ? m : Number.POSITIVE_INFINITY } /** * Divide `room` among weighted children, honouring each one's floor and ceiling. * * The naive version — give each child `room * weight / total` and never go below its * content width — overflows: a child whose content is wider than its share keeps the * wider figure, and the total then exceeds what there was to divide, so the last child * hangs out of the frame. * * CSS resolves this by FREEZING any item that cannot take its share and re-dividing the * rest among those that still can, repeating until nothing changes. That is what this * does. It terminates because every round either freezes at least one child or stops. * * Returns the width for each child, in order; a child with no weight keeps its size. */ function shareOut( sizes: number[], weights: number[], caps: number[], floors: number[], room: number, ): number[] { const out = [...sizes] const frozen = sizes.map((_, i) => weights[i] <= 0) for (;;) { const liveTotal = weights.reduce( (s, w, i) => s + (frozen[i] ? 0 : w), 0, ) if (liveTotal <= 0) return out // What is left once everything already settled has taken its width. const rest = room - out.reduce((s, v, i) => s + (frozen[i] ? v : 0), 0) let changed = false for (let i = 0; i < out.length; i++) { if (frozen[i]) continue const share = (rest * weights[i]) / liveTotal // A child cannot go below its own content, and cannot pass a declared cap. // Either way it settles here and the others divide what is left. if (share < floors[i]) { out[i] = floors[i] frozen[i] = true changed = true } else if (share > caps[i]) { out[i] = caps[i] frozen[i] = true changed = true } } if (changed) continue for (let i = 0; i < out.length; i++) if (!frozen[i]) out[i] = (rest * weights[i]) / liveTotal return out } } /** * The narrowest a node may be squeezed to when weights divide a row. * * Its own content width, unless it opted out with `minW0` (CSS's `min-width: 0`), in which * case a weight may take it below that. Matching CSS here is deliberate: `min-width` * defaults to `auto`, so in a browser too a `flex: 2` column stops shrinking at its text * and a declared 2:1 comes out closer to 1.4:1 — surprising, but it is what everyone * writing flexbox already works with. */ function floorOf(n: DiagramNode, contentW: number): number { const opted = (n.kind === "box" || n.kind === "group") && n.minW0 return opted ? 0 : contentW } /** * Does this node need the full width of its parent to mean what it says? * * A row whose children carry `grow` weights does: the weights are shares of the row's * width, so if the row is only as wide as its own content there is nothing to share and * every declared proportion silently comes out 1:1. * * This is where CSS and this engine disagree, and the disagreement is why it has to be * inferred. CSS and Yoga default `align-items` to `stretch`, so a row inside a column * fills that column's width for free. This engine defaults to `center`, which is the * better default for diagrams — a lone icon in a wide frame should sit in the middle, not * be smeared across it — but it means a row of weighted columns gets no width unless * something asks. Declaring weights IS the ask. */ function needsFullWidth(n: DiagramNode): boolean { return ( n.kind === "group" && n.dir === "row" && n.children.some((c) => growOf(c) > 0) ) } /** * Where the children start, and how much goes between them — CSS's justify-content. * * `slack` is what is left after the children and their base gaps. Returning both the * leading offset and the per-gap addition covers all six values in one place, so the * row and column branches no longer need their own contradictory policies. */ function distribute( justify: Justify, slack: number, count: number, ): { lead: number; extraGap: number } { if (slack <= 0 || count === 0) return { lead: 0, extraGap: 0 } switch (justify) { case "center": return { lead: slack / 2, extraGap: 0 } case "end": return { lead: slack, extraGap: 0 } case "between": return count > 1 ? { lead: 0, extraGap: slack / (count - 1) } : { lead: 0, extraGap: 0 } case "around": { // Half a share before the first child and after the last, a full share between. const share = slack / count return { lead: share / 2, extraGap: share } } case "evenly": { const share = slack / (count + 1) return { lead: share, extraGap: share } } default: return { lead: 0, extraGap: 0 } } } /** 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[] } /** One line of a wrapped row: which children sit on it, and how big it is. */ interface WrapLine { items: Placed[] width: number height: number } /** * Break a row of children into lines that each fit `room`. * * Greedy, the same rule as a text line-breaker and as CSS flex-wrap: keep adding to the * current line while it fits, otherwise start a new one. A single child wider than the * whole row still gets its own line rather than being dropped. * * Shared by measure and place so both agree on where the breaks fall — computing them * twice from the same input is cheap, keeping two copies in sync is not. */ function wrapLines(kids: Placed[], room: number, gap: number): WrapLine[] { const lines: WrapLine[] = [] let cur: WrapLine | null = null for (const k of kids) { const next = cur ? cur.width + gap + k.rect.w : k.rect.w if (cur && next > room && cur.items.length > 0) { lines.push(cur) cur = null } if (!cur) cur = { items: [k], width: k.rect.w, height: k.rect.h } else { cur.items.push(k) cur.width = next cur.height = Math.max(cur.height, k.rect.h) } } if (cur) lines.push(cur) return lines } /** * 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. * * `atWidth` is the width the box will ACTUALLY be drawn at, when that is already known * (see the reflow pass in `layoutForest`). Height is then counted for that width while * the reported width stays intrinsic — which is what a browser does, and what this was * missing: a paragraph measured at its 260px natural width needs eight lines, the same * paragraph stretched to 750px needs three, and reserving the eight-line height left * every panel with a slab of dead space under its text. */ export function autoBoxSize( label: string, role?: Role, shape?: string, atWidth?: number, maxW?: number, ): { 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) // Two caps: the role's own default, and whatever the model declared. The declared one // is allowed to go BELOW the floor of 120 — capping a box at 90px has to mean 90px, // or the cap silently does nothing on short labels. const roleCap = Math.round(260 * Math.max(1, r.charScale)) const declared = maxW != null && maxW > 0 ? maxW : Number.POSITIVE_INFINITY const explicit = visibleText(label).split("\n") const longest = Math.max(1, ...explicit.map((l) => l.length)) const natural = Math.max( 120, Math.round(longest * CHAR_W * r.charScale + 28), ) const w = Math.min(declared, roleCap, natural) const s = spec?.textScale ?? 1 // 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. // // Wrapping happens at the DRAWN width, which for a stretched box is wider than the // intrinsic one. `atWidth` carries it; the shape factor is divided back out because // it is applied to the final height below. // A declared cap also bounds the reflow hint: a box capped at 200 never gets to count // its lines as if it had been drawn at 600, however wide its parent turned out. const textW = Math.min( declared, Math.max(w, atWidth != null ? atWidth / s : 0), ) const charsPerLine = Math.max( 8, Math.floor((textW - 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. 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, widthHints?: Map, ): 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") { // The hint is the width this box was drawn at last pass; its text rewraps to // that width, so its height has to be counted there. const auto = autoBoxSize( n.label, n.role, n.shape, widthHints?.get(n.id), n.maxW, ) 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, widthHints), ) 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) // A declared cap wins over the measured content, so a row of six cards capped at 900 // reports 900 and the place pass below has real negative slack to shrink into. const cap = maxWOf(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) // A capped row wraps into as many lines as it takes, so the cap is a real limit // rather than something the content silently overflows. Sized here and positioned // by the same line-breaking in `place`, so measure and place cannot disagree. if (cap < Number.POSITIVE_INFINITY) { const lines = wrapLines(kids, cap - pad * 2, gap) const h = head + pad * 2 + lines.reduce((s, l) => s + l.height, 0) + gap * Math.max(0, lines.length - 1) const widestLine = Math.max(0, ...lines.map((l) => l.width)) return { node: n, rect: { x: 0, y: 0, w: Math.max( Math.min(cap, pad * 2 + widestLine), titleFloor(n.label, pad), ), h, }, children: kids, } } 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) // A row of weighted columns divides a width it does not have yet (see needsFullWidth). // Its share of the extra has to be handed out during MEASURE: `place` runs top-down, so // a child widened there leaves this container already sized for the narrow version, and // the child then sticks out of the frame that is supposed to contain it. // // Distributed by weight rather than to the full interior, because that is the answer // `place` will independently arrive at — the two passes have to agree or the frame is // sized for one arrangement and drawn as another. for (const k of kids) { if (!needsFullWidth(k.node) || k.node.kind !== "group") continue const kp = padOf(k.node) const room = widest - kp * 2 - k.node.gap * Math.max(0, k.children.length - 1) const weights = k.children.map((c) => growOf(c.node)) // Unweighted children keep their size and take their width off the top; the rest is // what the weights divide. const fixed = k.children.reduce( (s, c, i) => s + (weights[i] ? 0 : c.rect.w), 0, ) const widths = shareOut( k.children.map((c) => c.rect.w), weights, k.children.map((c) => maxWOf(c.node)), k.children.map((c) => floorOf(c.node, c.rect.w)), room - fixed, ) k.children.forEach((c, i) => { if (weights[i]) c.rect.w = widths[i] }) k.rect.w = Math.max( k.rect.w, kp * 2 + k.children.reduce((s, c) => s + c.rect.w, 0) + k.node.gap * Math.max(0, k.children.length - 1), ) } 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.min(cap, 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, /** * True when this container's width was decided from outside its own content — the page * aspect gave the top level a width, or an ancestor stretched it. Only then do `grow` * weights read as absolute proportions ("3:1"), because only then is there a total to * take a share OF. It passes down through stretched children: a full-width column that * inherited its width hands that same certainty to the row inside it. */ definiteWidth = false, ): 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" // A capped row that had to WRAP lays each line out like its own row, so the two passes // cannot disagree about where the breaks fall. A capped row that still fits on one line // falls through to the ordinary path below — it is an ordinary row, and skipping that // path would skip `grow`, leaving declared proportions unapplied. const wrapped = alongRow && maxWOf(n) < Number.POSITIVE_INFINITY && kids.length > 0 ? wrapLines(kids, innerW, n.gap) : null if (wrapped && wrapped.length > 1) { const lines = wrapped let lineTop = innerTop for (const line of lines) { const used = line.items.reduce((s, k) => s + k.rect.w, 0) const room = Math.max( 0, innerW - used - n.gap * (line.items.length - 1), ) // A wrapped line follows the same row default as an unwrapped one: centred, // with its gaps padded by up to one extra gap. const declared = justifyOf(n) const { lead, extraGap } = declared ? distribute(declared, room, line.items.length) : line.items.length > 1 ? (() => { const e = Math.min( n.gap, room / (line.items.length - 1), ) return { lead: Math.max( 0, (room - e * (line.items.length - 1)) / 2, ), extraGap: e, } })() : { lead: room / 2, extraGap: 0 } let x = innerX + lead for (const kid of line.items) { const a = alignOf(kid.node, n) const off = a === "start" ? 0 : a === "end" ? line.height - kid.rect.h : a === "stretch" ? 0 : (line.height - kid.rect.h) / 2 if (a === "stretch") kid.rect.h = line.height place(kid, x, lineTop + Math.max(0, off), links) x += kid.rect.w + n.gap + extraGap } lineTop += line.height + n.gap } return } 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 justify distribution below — declared weights are a // statement about where the slack should go, and spreading it into the gaps instead // would silently override that statement. // // A LEAF box never grows along a column: growing its height just inflates a text // box around its own text — the giant hollow panels of an early poster. Along a row // it stays legal (two bars splitting a card's width is real layout), and containers // grow on either axis, since they distribute the space onwards. const weights = kids.map((kid) => !alongRow && kid.node.kind === "box" ? 0 : growOf(kid.node), ) const totalWeight = weights.reduce((s, v) => s + v, 0) if (totalWeight > 0 && slack > 0) { // Along a ROW, when the container's width was set from OUTSIDE (a declared page // aspect, or its own cap), the weights divide the whole track: `grow: 3` beside // `grow: 1` then really is three times as wide, which is what writing those numbers // means and what CSS's `flex: 3` shorthand does by zeroing flex-basis. // // Otherwise only the slack is divided, which is the older contract: the width came // from the content itself, so treating the weights as absolute proportions would // shrink a column below the text already in it. const proportional = alongRow && (definiteWidth || needsFullWidth(n) || maxWOf(n) < Number.POSITIVE_INFINITY) if (proportional) { // The weights divide the whole track. shareOut settles anyone who cannot take // their share — too wide already, or capped — and re-divides among the rest, so // the total never exceeds the room available and no child spills out. const fixed = kids.reduce( (s, kid, i) => s + (weights[i] ? 0 : kid.rect.w), 0, ) const widths = shareOut( kids.map((kid) => kid.rect.w), weights, kids.map((kid) => maxWOf(kid.node)), kids.map((kid) => floorOf(kid.node, kid.rect.w)), extent - n.gap * (k - 1) - fixed, ) kids.forEach((kid, i) => { if (weights[i]) kid.rect.w = widths[i] }) } else { kids.forEach((kid, i) => { if (!weights[i]) return const share = (slack * weights[i]) / totalWeight // Never past a declared cap: min/max outranks grow, Yoga's rule too. if (alongRow) kid.rect.w = Math.min(maxWOf(kid.node), kid.rect.w + share) else kid.rect.h += share }) } // Recompute: a child clamped by its cap or its content refused part of its share, // and that remainder is still free space the distribution below has to place. const used = kids.reduce( (s, kid) => s + (alongRow ? kid.rect.w : kid.rect.h), 0, ) slack = Math.max(0, extent - used - n.gap * (k - 1)) } // How the remaining slack is spread. A declared `justify` decides it; otherwise the // engine's original per-axis defaults stand, because they are what every diagram built // before `justify` existed was laid out with: // // ROW — spread the gaps by up to one extra gap, then centre the result. A flowchart // layer reads as a pyramid, and dead space at the right edge of a row looks like a // mistake. // COLUMN — pack to the top and leave the slack at the bottom. A column is usually // tall because a SIBLING made it tall, and stretching its gaps turns every panel into // a big frame with three lines floating in the middle. const declared = justifyOf(n) let lead: number let extraGap: number if (declared) { ;({ lead, extraGap } = distribute(declared, slack, k)) } else if (alongRow && k > 1) { extraGap = Math.min(n.gap, slack / (k - 1)) lead = Math.max(0, (slack - extraGap * (k - 1)) / 2) } else { lead = 0 extraGap = 0 } const gap = n.gap + extraGap let cur = (alongRow ? innerX : innerTop) + lead 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, n) const stretches = a === "stretch" || (kn.kind === "box" && kn.role && roleMetrics(kn.role).stretch) || // A row of weighted columns fills this column, whatever the alignment default // says — see needsFullWidth. Only along a column: across a row the cross axis // is height, and a row does not hand its height out proportionally. (!alongRow && needsFullWidth(kn)) // 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 // A row's child got its width from the weights above, so if this row's own // width was definite the child's is too. place( kid, cur, innerTop + cross(innerH, kid.rect.h), links, definiteWidth && growOf(kn) > 0, ) cur += kid.rect.w + gap } else { // Stretching along a column still respects a declared cap. if (stretches) kid.rect.w = Math.min(maxWOf(kn), innerW) // A stretched child fills a width this column already knew, so it inherits // that certainty; an unstretched one is still sized by its own content. place( kid, innerX + cross(innerW, kid.rect.w), cur, links, definiteWidth && stretches, ) 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 } /** * Area of draw.io's default page (A4 at 850x1100), the yardstick a declared aspect ratio * is measured against. Using the editor's own page size means aspect 1 lands on a square * about one page in area, rather than on some number invented here. */ const PAGE_AREA = 850 * 1100 /** * 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 /** * Target width : height for the page. When set, the top level is given a width * that lands near it, which is the only way a proportional rule has anything to * divide: without a definite width there is no leftover space, so every `grow` * weight resolves to zero. Yoga's own docs say the same — a container distributes * "any remaining space" among its children, so some space has to remain. */ aspect?: number } = {}, ): LayoutResult { const glyph = opts.iconSize ?? ICON_SIZE const gap = opts.gap ?? 70 const links: LayoutLinks = opts.links ?? [] const run = (hints?: Map, target?: number): Placed[] => { // A target page width narrower than the content is a request to WRAP, and wrapping // has to happen during measure — the line breaks change every height. So the cap is // pushed onto the root group before measuring, unless it declared its own. const rootCap = new Map() if (target) { for (const r of roots) if ( r.kind === "group" && r.dir === "row" && !r.pinned && r.maxW == null ) { rootCap.set(r.id, target) r.maxW = target } } const placed = roots.map((r) => measure(r, glyph, links, hints)) // Widen the roots to the target width when they came out narrower. Only a group can // absorb it — a grid, pool, sequence or radial computes its interior from its own // rule, so forcing one wider just adds dead space inside it. if (target) { const own = placed.filter( (p) => p.node.kind !== "title" && !p.node.pinned, ) const spread = gap * Math.max(0, own.length - 1) const share = (target - spread) / Math.max(1, own.length) for (const p of own) if (p.node.kind === "group") p.rect.w = Math.min( maxWOf(p.node), Math.max(p.rect.w, share), ) } 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 { // A target width makes the top level's width definite, which is what lets // grow weights inside it read as proportions of a whole. place(p, cur, ORIGIN.y, links, Boolean(target)) cur += p.rect.w + gap } } // Undo only now: `place` needs the cap to break lines in the same places `measure` // did, but `roots` is the caller's tree and must come back exactly as it went in. for (const r of roots) if (rootCap.has(r.id) && r.kind === "group") r.maxW = undefined return placed } const extent = (placed: Placed[]) => { 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 { maxX, maxY } } let placed = run() if (opts.aspect && opts.aspect > 0) { // The target width has to come from OUTSIDE the content, or it cannot create the // spare space that proportional rules divide: deriving it from the area the content // already occupies just returns that content's own width back, leaving nothing over. // draw.io's page is the natural external reference — one A4 at 850x1100 — so // width = sqrt(pageArea x aspect) is the first guess. let want = Math.round(Math.sqrt(PAGE_AREA * opts.aspect)) // Then iterate, because width and height are not independent: widening the page // makes every paragraph rewrap to fewer lines, which SHORTENS it, which changes the // ratio that was being aimed at. One pass therefore lands wide of the mark — asking // for 0.8 gave 1.13. Each round measures what the last width actually produced and // corrects toward the target; three is enough to get inside a few percent, and the // loop stops early once the correction is negligible. // // The hint map is what makes the correction real: it carries the width each box was // drawn at, so its text is re-counted at that width instead of at its intrinsic one. for (let pass = 0; pass < 4; pass++) { placed = run(undefined, want) const hints = new Map() const collect = (p: Placed) => { if (p.node.kind === "box") hints.set(p.node.id, p.rect.w) p.children.forEach(collect) } placed.forEach(collect) placed = run(hints, want) const { maxX, maxY } = extent(placed) const w = maxX + MARGIN.right const h = maxY + MARGIN.bottom const err = w / h / opts.aspect if (Math.abs(err - 1) < 0.04) break // Geometric correction: to move the ratio by a factor, move the width by its // square root, since shrinking the width lengthens the page and vice versa. const next = Math.round(want / Math.sqrt(err)) if (next === want) break want = next } } const { maxX, maxY } = extent(placed) 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 }