Files
next-ai-draw-io/lib/diagram-engine/types.ts
dayuan.jiang 526f1e14e7 refactor(diagram-engine): apply review findings, fix vertical pool phases
Four reviewers went over the previous commit (three Claude, one Codex). Their
findings, verified independently before applying:

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

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

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

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

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

525 unit tests and 11 diagram e2e tests pass.
2026-08-09 14:47:46 +09:00

295 lines
8.8 KiB
TypeScript

/**
* The declarative node tree the layout engine works on.
*
* The model never writes coordinates. It declares nesting and direction; the engine
* computes every x/y/width/height. The tree is not persisted anywhere — it is
* re-derived from the canvas XML whenever it is needed (see parse.ts), so the canvas
* stays the single source of truth and a user's manual edits are an input, never
* something to be reconciled against a second copy of the state.
*/
import type { Direction } from "./markers"
export type { Direction } from "./markers"
/**
* Which cell of a swimlane pool a node sits in.
*
* `lane` indexes the role band, `col` the position along the flow. Cells are sparse:
* nothing has to fill lane 1 column 3 for lane 2 column 3 to exist.
*/
export interface PoolCell {
lane: number
col: number
}
/**
* The outline a flowchart box is drawn with.
*
* Flowchart notation is conventional, not decorative: a reader takes a diamond to mean a
* branch and a stadium to mean an entry or exit point. Rendering every step as the same
* rectangle throws that away.
*/
export type BoxShape =
| "box"
| "decision"
| "terminator"
| "round"
| "data"
| "document"
/** A catalog icon: a real stencil, drawn at a fixed glyph size with a label below. */
export interface IconNode {
kind: "icon"
id: string
/** Catalog name, e.g. "s3" or "azure_virtual_machine". Resolved to a style by the catalog. */
name: string
label: string
/** Glyph size in px. Defaults to the diagram's icon size. */
size?: number
/** Verbatim style, when recovered from XML. Preferred over re-resolving `name`. */
style?: string
/** User froze this node's position — the engine must not move it. */
pinned?: boolean
/** Absolute geometry, when recovered from XML. Only meaningful for a pinned node. */
rect?: Rect
/** Position within a `pool` parent. Ignored elsewhere. */
cell?: PoolCell
}
/** A plain labelled rectangle, for things the catalog has no icon for. */
export interface BoxNode {
kind: "box"
id: string
label: string
w?: number
h?: number
fill?: string
stroke?: string
bold?: boolean
/** Flowchart outline. Absent means a plain rectangle. */
shape?: BoxShape
style?: string
pinned?: boolean
rect?: Rect
/** Position within a `pool` parent. Ignored elsewhere. */
cell?: PoolCell
}
/** A page title. At most one per diagram; laid out outside the tree flow. */
export interface TitleNode {
kind: "title"
id: string
label: string
}
/**
* A container that stacks its children in one direction.
*
* `gname` is the catalog group stencil (group_vpc, group_region, …). When null the
* container renders as a plain frame — a labelled rectangle with a border.
*/
export interface GroupNode {
kind: "group"
id: string
gname: string | null
label: string
dir: Extract<Direction, "row" | "col">
gap: number
children: DiagramNode[]
fill?: string
stroke?: string
style?: string
pinned?: boolean
rect?: Rect
}
/** A container that packs its children into a fixed number of columns. */
export interface GridNode {
kind: "grid"
id: string
gname: string | null
label: string
cols: number
gap: number
children: DiagramNode[]
fill?: string
stroke?: string
style?: string
pinned?: boolean
rect?: Rect
}
/**
* A swimlane pool: a sparse grid of (lane, column) cells.
*
* `lanes` names the role bands. Each child declares which cell it occupies, and empty
* cells stay empty — that is the whole point of a swimlane diagram, where a step belongs
* to exactly one role and the columns show the order things happen in.
*
* `phases` is an optional band of milestone labels above the columns.
*/
export interface PoolNode {
kind: "pool"
id: string
label: string
/** Role names, one per band. */
lanes: string[]
/** Milestone labels spanning the columns. Empty means no milestone band. */
phases: string[]
/** "horizontal": lanes stack downwards, flow left to right. "vertical": the mirror. */
orientation: "horizontal" | "vertical"
gap: number
children: DiagramNode[]
style?: string
pinned?: boolean
rect?: Rect
}
/**
* A sequence diagram: participants across the top, lifelines hanging below them.
*
* Children are the participant heads, in left-to-right order. The messages are ordinary
* links whose `step` gives the vertical order — so the same `link` operation that draws
* an arrow in a flowchart draws a message here.
*
* The engine emits the lifelines as separate cells; they are not nodes, because nothing
* ever attaches to a lifeline directly.
*/
export interface SequenceNode {
kind: "sequence"
id: string
label: string
/** Horizontal distance between participant centres. */
gap: number
/** Vertical distance between consecutive messages. */
step: number
children: DiagramNode[]
style?: string
pinned?: boolean
rect?: Rect
}
/**
* A mind map or org chart: a root with branches radiating from it.
*
* Children are a FLAT list of every node in the map. The hierarchy comes from the links —
* an arrow from A to B means B is a branch of A — not from nesting.
*
* That is not a shortcut, it is the only thing that works: a branch of a mind map is a
* labelled box that also has sub-branches, and a box cannot hold children. Reading the
* hierarchy from the arrows also matches what the diagram means, since in a mind map or an
* org chart the arrows ARE the structure.
*
* `spread: "radial"` fans branches out on both sides of the centre, which is what a mind
* map wants. `spread: "down"` puts every branch below the centre, which is what an org
* chart wants: a reporting line only reads correctly downwards.
*/
export interface RadialNode {
kind: "radial"
id: string
label: string
spread: "radial" | "down"
/** Distance from a parent's edge to its children. */
gap: number
children: DiagramNode[]
style?: string
pinned?: boolean
rect?: Rect
}
export type ContainerNode =
| GroupNode
| GridNode
| PoolNode
| SequenceNode
| RadialNode
export type LeafNode = IconNode | BoxNode | TitleNode
export type DiagramNode = ContainerNode | LeafNode
export interface Rect {
x: number
y: number
w: number
h: number
}
/** An arrow. Routing is the engine's business; the model only says what connects to what. */
export interface LinkSpec {
/** Cell id, so an existing edge can be addressed by later operations. */
id?: string
source: string
target: string
label?: string
/** Dashed line — replication, sync, policy, lineage. */
dashed?: boolean
/** Step number, rendered as an "N. " prefix on the label. */
step?: number
/** Verbatim style, when recovered from XML. */
style?: string
}
/** A whole diagram page: the node forest plus its arrows. */
export interface DiagramTree {
/** Top-level nodes, in layout order. */
roots: DiagramNode[]
links: LinkSpec[]
/** Page title, if the diagram has one. */
title?: string
/**
* Cells the parser could not fit into the tree — a user's own annotation boxes, a
* legend, shapes from an imported file. Kept verbatim and re-emitted untouched so
* a re-layout never destroys work the engine does not understand.
*/
foreign: ForeignCell[]
}
/** A cell carried through the round-trip without interpretation. */
export interface ForeignCell {
id: string
/** The cell's own serialised XML, verbatim. */
xml: string
/** Parent id at parse time, so it can be re-attached. */
parent: string
}
export function isContainer(n: DiagramNode): n is ContainerNode {
return (
n.kind === "group" ||
n.kind === "grid" ||
n.kind === "pool" ||
n.kind === "sequence" ||
n.kind === "radial"
)
}
export function isLeaf(n: DiagramNode): n is LeafNode {
return !isContainer(n)
}
/** Depth-first walk over a node and its descendants. */
export function* walk(n: DiagramNode): Generator<DiagramNode> {
yield n
if (isContainer(n)) for (const c of n.children) yield* walk(c)
}
/** Every node in a tree, in document order. */
export function* walkTree(t: DiagramTree): Generator<DiagramNode> {
for (const r of t.roots) yield* walk(r)
}
/** Find a node by id, or null. */
export function findNode(t: DiagramTree, id: string): DiagramNode | null {
for (const n of walkTree(t)) if (n.id === id) return n
return null
}
/** The container holding `id`, or null when it is a root or absent. */
export function findParent(t: DiagramTree, id: string): ContainerNode | null {
for (const n of walkTree(t)) {
if (!isContainer(n)) continue
if (n.children.some((c) => c.id === id)) return n
}
return null
}