Files
next-ai-draw-io/lib/diagram-engine/layout.ts
dayuan.jiang e78322ca52 feat(diagram-engine): design tokens + role/group composition, engine-wide theming
Paper-summary posters previously required hand-written XML: every engine
box rendered identically (white, 11px), so anything whose meaning lives
in visual hierarchy came out flat. This makes presentation a first-class,
generalised part of the declaration - not a poster feature.

Structure/presentation separation, the same split HTML and CSS settled on:

- ROLE says what a node IS: banner, heading, body, callout, good, bad,
  metric, muted. Maps to a type scale and an emphasis (filled / tinted /
  outlined / ghost), never to a colour.
- GROUP says which semantic zone a node belongs to. Each distinct group
  name gets one hue ramp (tint / base / dark), assigned in document
  order. Promoted from a draw_graph-only field to BoxNode and GroupNode,
  round-tripped via dai_group.
- themedStyle(role, hue, kind) composes the two by rule - there is no
  per-combination table to extend, so a new diagram kind gets full
  theming by tagging nodes. The model never sees a hex value.

A heading container plus a group yields the tinted section panel with a
dark title; a grouped body box takes its zone's tint; verdict roles stay
green/red regardless of zone; the banner is the page's one dark field.

Also fixed, found while building the acceptance poster:

- autoBoxSize only counted explicit newlines, so a long single-line label
  wrapped to six lines in draw.io but got a one-line-tall box, and the
  text overflowed the cell.
- Marker stamping appended without replacing, so every render of a
  recovered style grew it by one duplicate dai_* token per key -
  unnoticed because draw.io resolves duplicates last-wins. dai_* keys
  are now replaced in place; mxGraph keys still append, because
  last-wins is load-bearing for container=1 normalisation.
- Banner/heading/metric roles stretch across their container's cross
  axis, the way a masthead spans its page.
- Prompt: a poster's banner IS its title (no set_title alongside), and
  sections get their colour by naming groups.

537 unit tests, 250-flowchart corpus still zero crossing arrows, 5 e2e
tests in a real browser. Verified visually: the Transformer-paper poster
renders with a navy masthead, three hue-coded section panels, metric,
verdict and callout boxes - all engine-computed geometry.
2026-08-09 19:48:01 +09:00

851 lines
30 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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 { 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
/** 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 }[]
/**
* 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,
): { w: number; h: number } {
const r = roleMetrics(role)
const maxW = Math.round(260 * Math.max(1, r.charScale))
const explicit = String(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)
return { w, h: Math.max(r.minH, lines * lineH + 26) }
}
/**
* 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<string, string>()
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<string>([id])
let cur = id
for (;;) {
const up = parent.get(cur)
if (up === undefined || seen.has(up)) return cur
seen.add(up)
cur = up
}
}
// The first declared node that is nobody's child is the centre. Falling back to the first
// child keeps a cycle-only graph drawable.
const rootId =
kids.find((k) => !parent.has(k.node.id))?.node.id ??
rootOf(kids[0].node.id)
const childrenOf = new Map<string, Placed[]>()
for (const k of kids) {
if (k.node.id === rootId) continue
const up = parent.get(k.node.id)
// An orphan, or a node whose parent chain loops back 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)
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
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 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
const slack = Math.max(0, extent - content - n.gap * (k - 1))
const gap = k > 1 ? n.gap + Math.min(n.gap, slack / (k - 1)) : n.gap
const span = content + 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 stretches =
kn.kind === "box" && kn.role && roleMetrics(kn.role).stretch
if (alongRow) {
if (stretches) kid.rect.h = innerH
place(kid, cur, innerTop + (innerH - kid.rect.h) / 2, links)
cur += kid.rect.w + gap
} else {
if (stretches) kid.rect.w = innerW
place(kid, innerX + (innerW - kid.rect.w) / 2, 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
}