Files
next-ai-draw-io/lib/diagram-engine/types.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

305 lines
9.2 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"
import type { Role } from "./theme"
export type { Direction } from "./markers"
export type { Role } from "./theme"
/**
* 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
/** What this node IS in the information hierarchy; the theme decides how that looks. */
role?: Role
/** Semantic zone name; every node sharing a group gets the same hue ramp. */
group?: string
/** 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
/** Section role; a themed panel for its children. */
role?: Role
/** Semantic zone name; the panel takes this hue's tint. */
group?: 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
}