mirror of
https://github.com/DayuanJiang/next-ai-draw-io.git
synced 2026-09-01 17:10:24 +08:00
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.
851 lines
30 KiB
TypeScript
851 lines
30 KiB
TypeScript
/**
|
||
* 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
|
||
}
|