mirror of
https://github.com/DayuanJiang/next-ai-draw-io.git
synced 2026-09-01 17:10:24 +08:00
Arrowheads carry meaning: a crow's foot IS one-to-many, a hollow diamond IS aggregation. The engine allowed exactly one arrowhead; this opens the vocabulary the same way shapes were opened: - LinkSpec gains head/tail (pass-through to endArrow/startArrow, charset- gated against style injection) and headFill/tailFill — fill is written explicitly whenever a head is declared, because UML composition and aggregation differ ONLY by fill and draw.io's per-head default would flip the meaning. bold (4px amber, for THE key relationship) included. - Parallel edges: a second link between the same pair is allowed when it carries an id (ER's 'places' and 'cancels' between the same two entities); without one it stays an error, since two identical overlapping lines is a mistake. Edge ids are also what later operations address. - Sequence messages respect a declared head (an async message's open arrow is UML notation) while defaulting to the solid block as before. - draw_graph's edge schema extended to match; GraphEdge passes the new fields through to the link operations it generates. 5 new tests: crow's foot style emission, hollow-vs-filled round trip, injection rejection, parallel-edge gating, bold round trip. 561 unit tests green. ER+UML acceptance diagram (crow's foot, zero-to-one, hollow inheritance triangle, filled composition diamond) verified in the real editor.
332 lines
11 KiB
TypeScript
332 lines
11 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"
|
|
|
|
/**
|
|
* What a box IS, drawn as its conventional outline.
|
|
*
|
|
* Open vocabulary: catalog names ("cylinder", "decision", "person"…) get full engine
|
|
* support — correct perimeter, text sized to fit the outline. Any other draw.io shape
|
|
* token passes through verbatim and degrades to a rectangle if the editor does not
|
|
* know it. See shapes.ts.
|
|
*/
|
|
export type BoxShape = string
|
|
|
|
/** 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
|
|
/**
|
|
* A bold arrow: the relationship IS the point — a transformation, the main flow.
|
|
* Thick and coloured, a visual element rather than a hairline connector.
|
|
*/
|
|
bold?: boolean
|
|
/**
|
|
* Arrowhead at the target / at the source. draw.io endArrow/startArrow tokens:
|
|
* block, open, diamond, diamondThin, oval, cross, ERone, ERmany, ERoneToMany,
|
|
* ERzeroToMany, ERzeroToOne, none… Unset means the default (classic at the target,
|
|
* nothing at the source). `headFill`/`tailFill` distinguish UML composition
|
|
* (filled diamond) from aggregation (hollow) — conventions where fill IS meaning.
|
|
*/
|
|
head?: string
|
|
tail?: string
|
|
headFill?: boolean
|
|
tailFill?: 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
|
|
}
|