/** * @module model * * Input-agnostic diagram model. Two top-level kinds: * * - **`Diagram`** — component / deployment / use-case style * (planes, subplanes, boxes, connections). * - **`SequenceDiagram`** — lifelines + messages + notes. * * Layout and renderer dispatch on the model class. Anything that * can be expressed as one of these two shapes flows through the * pipeline; the parser is just one possible source. Callers can * also build a `Diagram` programmatically and feed it to * `renderDiagram()`. */ /** * Anchor sides for box ports / connection routing. * @public */ export const SIDES = ["top", "right", "bottom", "left"]; /** * Logical box shapes the parser may attach to a {@link Box}. The renderer * turns each into the corresponding Excalidraw primitive. * @public */ export const SHAPES = [ "rectangle", "component", "actor", "usecase", "database", "queue", "node", "cloud", "interface", "entity", "class", "enum", "object", "map", "diamond", "note", "state", "start", "end", "choice", "fork", "join", "history", "history_deep", ]; /** * Generic arrow endpoint head kinds. These are model-level semantics; * renderers map them to native primitives or generated SVG marker shapes. * @public */ export const ARROW_HEADS = [ "none", "filled", "open", "circle", "cross", "partialTop", "partialBottom", "triangleOutline", "diamond", "diamondOutline", "dot", "bar", ]; /** * Generic arrow endpoint anchors. Component diagrams use `node`/`port`, * sequence diagrams use `participant`, and boundary arrows use diagram/short * anchors. * @public */ export const ARROW_ANCHORS = [ "node", "port", "point", "participant", "diagramLeft", "diagramRight", "shortLeft", "shortRight", ]; /** * Generic arrow direction semantics. * @public */ export const ARROW_DIRECTIONS = [ "right", "left", "bidirectional", "self", "incoming", "outgoing", "orthogonal", ]; /** * Generic arrow line styles. * @public */ export const ARROW_LINE_STYLES = ["solid", "dashed", "dotted"]; /** Sequence-arrow endpoint head kinds. @public */ export const SEQUENCE_ARROW_HEADS = ARROW_HEADS; /** Sequence-arrow endpoint anchors. @public */ export const SEQUENCE_ARROW_ANCHORS = ARROW_ANCHORS.filter((anchor) => anchor !== "node"); /** Sequence-arrow direction semantics. @public */ export const SEQUENCE_ARROW_DIRECTIONS = ARROW_DIRECTIONS.filter( (direction) => direction !== "orthogonal", ); /** Sequence-arrow line styles. @public */ export const SEQUENCE_ARROW_LINE_STYLES = ARROW_LINE_STYLES.filter((style) => style !== "dotted"); /** Default visual endpoint glyph size in px. @public */ export const DEFAULT_ARROW_HEAD_SIZE = 20; /** * One endpoint of a reusable diagram arrow. * @public */ export class ArrowEndpoint { /** * @param {object} [spec] * @param {string} [spec.head] Model-level head kind. * @param {string} [spec.anchor] node | port | point | participant | diagramLeft | diagramRight | shortLeft | shortRight. * @param {string|null} [spec.excalidrawArrowhead] Closest Excalidraw arrowhead primitive. * @param {string} [spec.label] Optional text rendered near this endpoint. * @param {number} [spec.size] Visual endpoint glyph size in px. * @param {string} [spec.direction] Direction hint used by renderers. */ constructor({ head = "none", anchor = "node", excalidrawArrowhead = null, label = "", size = DEFAULT_ARROW_HEAD_SIZE, direction = "auto", } = {}) { this.head = head; this.anchor = anchor; /** @type {string|null} */ this.excalidrawArrowhead = excalidrawArrowhead; this.label = label; this.size = size; this.direction = direction; this.wrappedLabel = label; this.labelWidth = 0; this.labelHeight = 0; this.labelFontSize = 0; } } /** * Reusable visual line segment for arrows. * @public */ export class ArrowLine { /** * @param {object} [spec] * @param {string} [spec.style] solid | dashed | dotted. * @param {string} [spec.color] Optional line colour token. * @param {number} [spec.slant] Optional y-offset for slanted sequence arrows. * @param {string} [spec.route] straight | orthogonal | polyline. * @param {Array<{x:number,y:number}>} [spec.points] Routed points for non-sequence diagrams. */ constructor({ style = "solid", color = "", slant = 0, route = "straight", points = [] } = {}) { this.style = style; this.color = color; this.slant = slant; this.route = route; /** @type {Array<{x:number,y:number}>} */ this.points = points; } /** @returns {boolean} `true` when the line should render dashed. */ get dashed() { return this.style === "dashed" || this.style === "dotted"; } } /** * Label attached to an arrow line or endpoint. * @public */ export class ArrowLabel { /** * @param {object} [spec] * @param {string} [spec.text] Display text. * @param {string} [spec.placement] center | start | end | segment. * @param {number|null} [spec.segmentIndex] Routed segment index for component/class diagrams. */ constructor({ text = "", placement = "center", segmentIndex = null } = {}) { this.text = text; this.placement = placement; /** @type {number|null} */ this.segmentIndex = segmentIndex; this.wrappedText = text; this.width = 0; this.height = 0; this.fontSize = 0; } } /** * Diagram-agnostic arrow: start endpoint, line, end endpoint, plus labels. * @public */ export class DiagramArrow { /** * @param {object} [spec] * @param {ArrowEndpoint|object} [spec.start] Start endpoint. * @param {ArrowEndpoint|object} [spec.end] End endpoint. * @param {ArrowLine|object} [spec.line] Line properties. * @param {Array} [spec.labels] Line labels. * @param {string} [spec.source] Original source token. * @param {string} [spec.direction] Direction semantics. */ constructor({ start = {}, end = {}, line = {}, labels = [], source = "", direction = "right", } = {}) { this.start = start instanceof ArrowEndpoint ? start : new ArrowEndpoint(start); this.end = end instanceof ArrowEndpoint ? end : new ArrowEndpoint(end); this.line = line instanceof ArrowLine ? line : new ArrowLine(line); this.labels = labels.map((label) => label instanceof ArrowLabel ? label : new ArrowLabel(label), ); this.source = source; this.direction = direction; } } /** * A single component-style node in the diagram model. * * Boxes carry their own geometry once the layout pass has run; before * layout, the geometry fields are zero. * @public */ export class Box { /** * @param {object} spec * @param {string} spec.id Stable, unique identifier for routing & lookup. * @param {string} spec.title Human-readable label rendered inside the box. * @param {string} [spec.description] Optional secondary text shown below the title. * @param {string} [spec.shape] One of {@link SHAPES} — chooses the Excalidraw primitive. * @param {string} [spec.stereotype] PlantUML `<>` (e.g. `service`). * @param {string[]} [spec.members] Class members for `class` shape (one per line). * @param {string} [spec.link] Optional sanitized hyperlink for the visible label. * @param {string} [spec.tooltip] Optional tooltip text for the visible label. */ constructor({ id, title, description = "", shape = "rectangle", stereotype = "", members = [], link = "", tooltip = "", }) { this.id = id; this.title = title; this.description = description; this.shape = shape; this.stereotype = stereotype; // <>, <>, … this.members = members; // class members (string[]) this.link = link; this.tooltip = tooltip; /** @type {Plane | Subplane | null} */ this.parent = null; /** @type {Connection[]} */ this.connections = []; /** @type {{ top: any[], right: any[], bottom: any[], left: any[] }} */ this.ports = { top: [], right: [], bottom: [], left: [] }; this.x = 0; this.y = 0; this.width = 0; this.height = 0; /** * Title and description after the sizing pass has wrapped them to * fit the box. Set by `sizeDiagram` in the layout pipeline; the * renderer prefers these over the raw fields when present. * @type {string|undefined} */ this._wrappedTitle = undefined; /** @type {string|undefined} */ this._wrappedDescription = undefined; /** * Auto-shrunk font sizes computed by the sizing pass when a long * unbreakable token would otherwise overflow the box. Defaults to * the active style's `font.sizeTitle` / `font.sizeDescription`. * @type {number|undefined} */ this._wrappedTitleFontSize = undefined; /** @type {number|undefined} */ this._wrappedDescriptionFontSize = undefined; /** * Per-member wrapped lines computed by the sizing pass for class / * interface / enum boxes. Each entry corresponds to one logical * member from `box.members` and contains 1+ visual lines that * already fit inside the box width. Used by the renderer to draw * long method signatures across multiple lines instead of letting * them bleed past the right edge. * @type {string[][]|undefined} */ this._wrappedMembers = undefined; } /** @returns {Plane | null} The owning plane (direct or via a {@link Subplane} parent). */ get plane() { return this.parent instanceof Plane ? this.parent : this.parent?.parent || null; } /** @returns {{x:number,y:number}} The centre point of the box in absolute coords. */ centre() { return { x: this.x + this.width / 2, y: this.y + this.height / 2 }; } } /** * A nested grouping inside a {@link Plane} (e.g. PlantUML * `frame`, `folder`, `together`). Subplanes only contain boxes; * arbitrary nesting is intentionally not supported. * @public */ export class Subplane { /** * @param {object} spec * @param {string} spec.id Stable identifier referenced by connections. * @param {string} spec.title Title shown on the subplane's header tab. * @param {string} [spec.kind] Visual variant: subplane | frame | folder | rectangle | together. */ constructor({ id, title, kind = "subplane" }) { this.id = id; this.title = title; this.kind = kind; // subplane | frame | folder | rectangle | together /** @type {Plane | null} */ this.parent = null; /** @type {Box[]} */ this.boxes = []; /** @type {{ stroke: string, fill: string, titleFill: string } | null} */ this.color = null; this.x = 0; this.y = 0; this.width = 0; this.height = 0; } /** * Attach `box` to this subplane and back-link its parent. * @param {Box} box The box to add. * @returns {Box} The same box (for chaining). */ addBox(box) { box.parent = this; this.boxes.push(box); return box; } } /** * A top-level container in a component-style {@link Diagram} * (PlantUML `package`, `frame`, `node` …). Holds boxes and * subplanes. * @public */ export class Plane { /** * @param {object} spec * @param {string} spec.id Stable identifier (PlantUML alias or generated slug). * @param {string} spec.title Title shown on the plane's header tab. * @param {{ stroke: string, fill: string, titleFill: string } | null} [spec.color] Pre-computed colour triple, or `null` to derive on demand. * @param {string} [spec.kind] Visual variant: package | frame | folder | rectangle | node | together. */ constructor({ id, title, color = null, kind = "package" }) { this.id = id; this.title = title; this.kind = kind; // package | frame | folder | rectangle | node | together this.color = color; /** @type {Array} */ this.children = []; this.x = 0; this.y = 0; this.width = 0; this.height = 0; this.gridRow = 0; this.gridCol = 0; } /** * Attach a {@link Subplane} as a child of this plane. * @param {Subplane} subplane The subplane to add. * @returns {Subplane} The same subplane (for chaining). */ addSubplane(subplane) { subplane.parent = this; this.children.push(subplane); return subplane; } /** * Attach a {@link Box} directly as a child (bypassing any subplane). * @param {Box} box The box to add. * @returns {Box} The same box (for chaining). */ addBox(box) { box.parent = this; this.children.push(box); return box; } /** @returns {Subplane[]} All direct subplane children, in declaration order. */ get subplanes() { return /** @type {Subplane[]} */ (this.children.filter((c) => c instanceof Subplane)); } /** @returns {Box[]} Boxes attached directly to this plane (no subplane). */ get directBoxes() { return /** @type {Box[]} */ (this.children.filter((c) => c instanceof Box)); } /** @returns {Box[]} Every box, flattened across direct children and subplanes. */ get allBoxes() { /** @type {Box[]} */ const out = []; for (const c of this.children) { if (c instanceof Box) out.push(c); else if (c instanceof Subplane) out.push(...c.boxes); } return out; } } /** * Directed connection between two {@link Box}es. * * Arrowhead values map directly onto Excalidraw's start/end * arrowhead strings: `arrow | triangle | triangle_outline | diamond | * diamond_outline | dot | bar | circle | circle_outline | null`. * @public */ export class Connection { /** * @param {object} spec * @param {string} spec.id Unique connection identifier. * @param {Box} spec.from Source box (arrow tail). * @param {Box} spec.to Target box (arrow head). * @param {string} [spec.label] Optional edge label. * @param {string} [spec.kind] default | inheritance | composition | aggregation | realization | dependency. * @param {boolean} [spec.dashed] Render as a dashed line. * @param {string|null} [spec.startArrowhead] Excalidraw arrowhead at the source side. * @param {string|null} [spec.endArrowhead] Excalidraw arrowhead at the target side. * @param {string|null} [spec.directionHint] Layout hint: up | down | left | right. * @param {string} [spec.fromMul] Multiplicity label rendered next to the source endpoint. * @param {string} [spec.toMul] Multiplicity label rendered next to the target endpoint. * @param {DiagramArrow|object|null} [spec.arrow] Structured reusable arrow model. * @param {string} [spec.link] Optional sanitized hyperlink for the edge label. * @param {string} [spec.tooltip] Optional tooltip text for the edge label. */ constructor({ id, from, to, label = "", kind = "default", dashed = false, startArrowhead = null, endArrowhead = "arrow", directionHint = null, fromMul = "", toMul = "", arrow = null, link = "", tooltip = "", }) { this.id = id; this.from = from; this.to = to; this.label = label; this.link = link; this.tooltip = tooltip; this.kind = kind; // default | inheritance | composition | aggregation | realization | dependency this.dashed = dashed; this.arrow = arrow instanceof DiagramArrow ? arrow : new DiagramArrow({ start: { head: arrowHeadKindFromExcalidraw(startArrowhead), anchor: "node", excalidrawArrowhead: startArrowhead, label: fromMul, }, end: { head: arrowHeadKindFromExcalidraw(endArrowhead), anchor: "node", excalidrawArrowhead: endArrowhead, label: toMul, }, line: { style: dashed ? "dashed" : "solid", route: "orthogonal" }, labels: label ? [{ text: label, placement: "center" }] : [], direction: directionHint || "orthogonal", }); /** @type {string|null} */ this.startArrowhead = this.arrow.start.excalidrawArrowhead; /** @type {string|null} */ this.endArrowhead = this.arrow.end.excalidrawArrowhead; /** @type {string|null} */ this.directionHint = directionHint; // up|down|left|right|null /** * Multiplicity label rendered next to the source endpoint (e.g. `1`). * Empty when no multiplicity was declared. * @type {string} */ this.fromMul = fromMul; /** * Multiplicity label rendered next to the target endpoint (e.g. `0..*`). * Empty when no multiplicity was declared. * @type {string} */ this.toMul = toMul; /** @type {string|null} */ this.fromSide = null; /** @type {string|null} */ this.toSide = null; /** @type {Array<{x:number,y:number}>} */ this.path = []; } /** @returns {boolean} `true` when both endpoints sit in the same plane. */ get internal() { return this.from.plane !== null && this.from.plane === this.to.plane; } } /** * Convert Excalidraw arrowhead names into generic model head kinds. * @param {string|null|undefined} arrowhead Excalidraw arrowhead name. * @returns {string} Generic model head kind. */ function arrowHeadKindFromExcalidraw(arrowhead) { switch (arrowhead) { case "arrow": return "open"; case "triangle": return "filled"; case "triangle_outline": return "triangleOutline"; case "diamond": return "diamond"; case "diamond_outline": return "diamondOutline"; case "circle": case "circle_outline": return "circle"; case "dot": return "dot"; case "bar": return "bar"; default: return "none"; } } /** * Component / deployment / use-case style diagram. Top-level container * for {@link Plane}s and inter-plane {@link Connection}s. * @public */ export class Diagram { constructor() { this.title = ""; /** @type {string} Optional parser family hint such as `class` or `component`. */ this.kind = "graph"; /** @type {boolean} Whether `hide empty members` was requested by graph syntax. */ this.hideEmptyMembers = false; /** @type {string} Optional ELK direction override such as RIGHT or DOWN. */ this.layoutDirection = ""; /** @type {string} Optional graph caption metadata. */ this.caption = ""; /** @type {string} Optional graph header metadata. */ this.header = ""; /** @type {string} Optional graph footer metadata. */ this.footer = ""; /** @type {string} Optional graph legend metadata. */ this.legend = ""; /** @type {string} Optional graph mainframe metadata. */ this.mainframe = ""; /** * Parsed graph-level style hints from supported PlantUML skinparams * and `