mirror of
https://github.com/DayuanJiang/next-ai-draw-io.git
synced 2026-09-01 17:10:24 +08:00
The expressiveness gap between engine output and hand-written XML came down to two missing capabilities, both generic: - Block layout inside a box: nested containers already existed, but there was no way to split space by weight, pin a child to an edge, or tighten padding. Added grow (flex-grow over the parent's leftover flow-axis space, TeX's glue), align (start/center/end/stretch on the cross axis) and pad (per-group interior padding). All three round-trip via dai_grow/dai_align/ dai_pad markers. - Inline rich text: labels already render HTML (html=1 on every style, esc() entities decode back), but the measure pass counted markup as text. The visibleText() strip makes autoBoxSize measure what draw.io draws: <br> is a line, other inline tags are invisible. Graphviz (HTML-like table labels), D2 (grid containers + markdown-in-shape) and TeX (box+glue) converged on exactly this design: nested boxes for block structure, proportional glue, a small inline set for text — never full HTML. Prompts teach the composition with a comparison-card recipe; verified by rebuilding the CoT poster end to end in the real editor.
321 lines
9.9 KiB
TypeScript
321 lines
9.9 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
|
|
}
|
|
|
|
/**
|
|
* Cross-axis behaviour of a child inside a row/col group, CSS's align-items per child:
|
|
* pin to either edge, centre (the default), or stretch to fill the axis.
|
|
*/
|
|
export type Align = "start" | "center" | "end" | "stretch"
|
|
|
|
/**
|
|
* 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
|
|
/** Share of the parent's leftover flow-axis space, like flex-grow. 0/absent = natural size. */
|
|
grow?: number
|
|
/** Cross-axis behaviour within the parent. Absent = center; stretch = fill it. */
|
|
align?: Align
|
|
/** 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
|
|
/** Share of the parent's leftover flow-axis space, like flex-grow. */
|
|
grow?: number
|
|
/** Cross-axis behaviour within the parent. Absent = center; stretch = fill it. */
|
|
align?: Align
|
|
/** Interior padding, px. Absent = the default (24). */
|
|
pad?: number
|
|
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
|
|
}
|