feat(diagram-engine): flowcharts, swimlanes, sequence diagrams and mind maps

Extends the declarative engine past cloud architecture. The tool routing was
divided by icon library — AWS through the engine, everything else hand-written
XML — which is the wrong axis. What matters is the LAYOUT SHAPE.

Measured first: a six-step approval flow declared in its natural order comes out
as one column, because the layout only arranges what nesting tells it to and
never looked at the arrows. That forces the arrow from the decision to its second
branch to jump over the first branch.

graph.ts computes what the layout should have looked at: layer assignment by
longest path, cycle breaking so a loop is drawn without setting the order, and
barycentre sweeping to cut edge crossings. It emits ordinary container
operations, so layout, routing and round-tripping are unchanged — reaching zero
arrows-through-boxes on a 14-node pipeline and zero crossings on a bipartite
graph whose declared order forces three.

Three new container kinds, each because one layout rule cannot serve them all:

  pool     — swimlanes. Lanes are real cells and each step is parented to its
             band, so dragging a step to another role records the change.
  sequence — participants across the top, one lifeline cell per participant so
             head and line stay together on a drag. Messages bypass the router:
             a message's height IS its order.
  radial   — mind maps and org charts. Children are a flat list and the
             hierarchy comes from the links, because a branch is a box and a box
             cannot hold children.

Flowchart box shapes (diamond, stadium, parallelogram, document) so a reader can
tell a branch from a step.

Two bugs the new tests caught: the duplicate-link guard blocked a sequence
diagram from having two messages between the same pair, and the fallback message
numbering was shared across containers, pushing a second diagram's messages off
its own lifelines.

523 unit tests and 17 diagram e2e tests pass. Every kind verified round-trip
stable to a fixed point, and rendered in a real browser — draw.io keeps the
lifeline shape and the lane markers.
This commit is contained in:
dayuan.jiang
2026-08-09 13:47:23 +09:00
parent 1e4c74d464
commit a3814f702d
27 changed files with 4497 additions and 88 deletions

21
NOTICE
View File

@@ -8,17 +8,26 @@ lib/diagram-engine/ — layout and rendering
-------------------------------------------------------------------------------- --------------------------------------------------------------------------------
The declarative layout algorithm (bottom-up measure, top-down place, sibling The declarative layout algorithm (bottom-up measure, top-down place, sibling
size equalisation) and the mxCell/style emission in `lib/diagram-engine/layout.ts` size equalisation), the swimlane-pool geometry, and the mxCell/style emission in
and `lib/diagram-engine/render.ts` are derived from drawio-ai-kit: `lib/diagram-engine/layout.ts` and `lib/diagram-engine/render.ts` are derived
from drawio-ai-kit:
https://github.com/sparklabx/drawio-ai-kit https://github.com/sparklabx/drawio-ai-kit
Copyright (c) sparklabx Copyright (c) sparklabx
Licensed under the MIT License Licensed under the MIT License
The XML→tree reverse parser (`lib/diagram-engine/parse.ts`), the style-marker Original to this repository:
scheme (`markers.ts`), the structural-operations layer (`operations.ts`) and the
invisible-container approach that replaces that project's "phantom" nodes are - the XML→tree reverse parser (`parse.ts`), which that project does not have
original to this repository. - the style-marker scheme (`markers.ts`) that lets structure survive a
round-trip through the draw.io editor
- the structural-operations layer (`operations.ts`)
- the invisible-container approach that replaces that project's "phantom" nodes
- the edge router (`route.ts`)
- the graph→layers pass (`graph.ts`): layer assignment, cycle breaking and
barycentre crossing reduction, which turn a flat node/arrow list into a
flowchart
- the sequence-diagram and radial (mind map / org chart) layouts
-------------------------------------------------------------------------------- --------------------------------------------------------------------------------
lib/diagram-engine/data/aws-stencils.json — stencil catalog lib/diagram-engine/data/aws-stencils.json — stencil catalog

View File

@@ -99,6 +99,7 @@ Here are some example prompts and their generated diagrams:
- **Diagram History**: Comprehensive version control that tracks all changes, allowing you to view and restore previous versions of your diagrams before the AI editing. - **Diagram History**: Comprehensive version control that tracks all changes, allowing you to view and restore previous versions of your diagrams before the AI editing.
- **Interactive Chat Interface**: Communicate with AI to refine your diagrams in real-time - **Interactive Chat Interface**: Communicate with AI to refine your diagrams in real-time
- **Cloud Architecture Diagram Support**: Specialized support for generating cloud architecture diagrams (AWS, GCP, Azure) - **Cloud Architecture Diagram Support**: Specialized support for generating cloud architecture diagrams (AWS, GCP, Azure)
- **Computed Layout**: For architecture diagrams, flowcharts, swimlane/BPMN diagrams, sequence diagrams, mind maps and org charts, the AI declares only the structure — what contains what, or what points at what — and the app computes every coordinate, size and arrow route. Containers always fit their contents, siblings never overlap, and arrows are routed around the shapes they would otherwise cross. Anything you then move or recolour by hand is read back as part of the diagram, so a later edit does not undo it.
- **Animated Connectors**: Create dynamic and animated connectors between diagram elements for better visualization - **Animated Connectors**: Create dynamic and animated connectors between diagram elements for better visualization
## MCP Server ## MCP Server

View File

@@ -160,6 +160,11 @@ export default function AboutCN() {
<strong>AWS架构图支持</strong> <strong>AWS架构图支持</strong>
AWS架构图 AWS架构图
</li> </li>
<li>
<strong></strong>
AI
线线穿
</li>
<li> <li>
<strong></strong> <strong></strong>

View File

@@ -153,6 +153,11 @@ export default function AboutJA() {
</strong> </strong>
AWSアーキテクチャダイアグラムの生成を専門的にサポート AWSアーキテクチャダイアグラムの生成を専門的にサポート
</li> </li>
<li>
<strong></strong>
AI
</li>
<li> <li>
<strong></strong> <strong></strong>

View File

@@ -165,6 +165,15 @@ export default function About() {
Specialized support for generating AWS architecture Specialized support for generating AWS architecture
diagrams diagrams
</li> </li>
<li>
<strong>Computed Layout</strong>: For architecture
diagrams, flowcharts, swimlane diagrams, sequence
diagrams, mind maps and org charts, the AI declares
only the structure and the app computes every
coordinate, size and arrow route so containers fit
their contents, shapes never overlap, and arrows are
routed around what they would otherwise cross
</li>
<li> <li>
<strong>Animated Connectors</strong>: Create dynamic <strong>Animated Connectors</strong>: Create dynamic
and animated connectors between diagram elements for and animated connectors between diagram elements for

View File

@@ -700,11 +700,11 @@ Example: If previous output ended with '<mxCell id="x" style="rounded=1', contin
}), }),
}, },
restructure_diagram: { restructure_diagram: {
description: `Build or edit a CLOUD ARCHITECTURE diagram (AWS) by declaring STRUCTURE. The engine computes every coordinate. description: `Build or edit a diagram by declaring STRUCTURE. The engine computes every coordinate.
PREFER THIS over display_diagram/edit_diagram for AWS architecture diagrams. You declare what nests inside what; layout, sizing, alignment and arrow routing are computed. Containers always fit their contents and siblings never overlap, so the usual layout problems cannot occur. PREFER THIS over display_diagram/edit_diagram whenever the diagram's meaning is in nesting or in a fixed frame: cloud architecture, swimlane/BPMN, sequence diagrams, mind maps, org charts. You declare what contains what; layout, sizing, alignment and arrow routing are computed. Containers always fit their contents and siblings never overlap, so the usual layout problems cannot occur.
Never write coordinates, mxCell XML, or style strings. Look icon names up with search_stencils first — an invented name is rejected with suggestions. Never write coordinates, mxCell XML, or style strings. Look AWS icon names up with search_stencils first — an invented name is rejected with suggestions.
Operations are applied in order, so you can add a container and fill it in the same call: Operations are applied in order, so you can add a container and fill it in the same call:
{"operations":[ {"operations":[
@@ -716,13 +716,123 @@ Operations are applied in order, so you can add a container and fill it in the s
Editing an existing diagram: the structure is re-read from the canvas each time, INCLUDING anything the user moved or recoloured by hand. To add one service, send one operation — do not re-send the diagram. Editing an existing diagram: the structure is re-read from the canvas each time, INCLUDING anything the user moved or recoloured by hand. To add one service, send one operation — do not re-send the diagram.
Containers: dir "row" puts children side by side, "col" stacks them. An empty label makes an invisible grouping wrapper (use it to group columns without drawing another frame). gname is an AWS group stencil (group_region, group_vpc, group_availability_zone, group_subnet, group_account) — omit it for a plain titled frame. add_grid packs children into cols columns; use it to pack 3-8 related icons into one labelled area box rather than giving each its own frame.`, CONTAINERS — pick by what the diagram means:
add_container: children stacked along one axis. dir "row" side by side, "col" one above the next. An empty label makes an invisible grouping wrapper (use it to group columns without drawing another frame). gname is an AWS group stencil (group_region, group_vpc, group_availability_zone, group_subnet, group_account) — omit it for a plain titled frame.
add_grid: packs children into cols columns. Use it to pack 3-8 related icons into one labelled area rather than giving each its own frame.
add_pool: a SWIMLANE diagram. lanes are the roles, top to bottom. Each step is an add_box with lane (which role owns it) and col (which step of the process it is); columns advance left to right and an empty cell means that role does nothing at that point. Two steps with the same col happen at the same time. phases optionally labels groups of columns.
{"operations":[
{"op":"add_pool","id":"p","label":"Expense claim","lanes":["Employee","Manager","Finance"],"phases":["Submit","Review","Pay"]},
{"op":"add_box","id":"fill","parent":"p","label":"Fill form","lane":0,"col":0,"shape":"terminator"},
{"op":"add_box","id":"rev","parent":"p","label":"Review","lane":1,"col":1},
{"op":"add_box","id":"ok","parent":"p","label":"Approved?","lane":1,"col":2,"shape":"decision"},
{"op":"add_box","id":"pay","parent":"p","label":"Pay out","lane":2,"col":3},
{"op":"link","source":"fill","target":"rev"},{"op":"link","source":"rev","target":"ok"},
{"op":"link","source":"ok","target":"pay","label":"yes"}
]}
add_sequence: a SEQUENCE diagram. One add_box per participant, left to right in the order they first act; the engine draws each one's lifeline. Every message is a link with a step number giving its order — number them 1, 2, 3… as they happen, and make a reply its own link back. A participant calling itself is a link from a node to itself.
{"operations":[
{"op":"add_sequence","id":"s","label":"Login flow"},
{"op":"add_box","id":"u","parent":"s","label":"User"},
{"op":"add_box","id":"api","parent":"s","label":"API"},
{"op":"add_box","id":"db","parent":"s","label":"Database"},
{"op":"link","source":"u","target":"api","label":"POST /login","step":1},
{"op":"link","source":"api","target":"db","label":"find user","step":2},
{"op":"link","source":"db","target":"api","label":"user record","step":3},
{"op":"link","source":"api","target":"u","label":"JWT","step":4}
]}
add_radial: a MIND MAP or ORG CHART. Add every node with the radial container as its parent — a FLAT list, never nested inside another box — and let the links carry the hierarchy: link parent to child. The node nothing points at becomes the centre. spread "radial" fans branches out both sides (a mind map); "down" hangs everything below its parent (an org chart, where a reporting line only reads correctly downwards).
{"operations":[
{"op":"add_radial","id":"o","label":"","spread":"down"},
{"op":"add_box","id":"ceo","parent":"o","label":"CEO"},
{"op":"add_box","id":"cto","parent":"o","label":"CTO"},
{"op":"add_box","id":"lead","parent":"o","label":"Platform Lead"},
{"op":"link","source":"ceo","target":"cto"},{"op":"link","source":"cto","target":"lead"}
]}
BOX SHAPES: add_box takes shape — "decision" for a branch (diamond), "terminator" for a start/end point, "data" for input or output, "document" for a report, "round" for a soft-edged step. Use them; a reader takes a diamond to mean a choice.`,
inputSchema: z.object({ inputSchema: z.object({
operations: z operations: z
.array(OperationSchema) .array(OperationSchema)
.describe("Structural operations, applied in order"), .describe("Structural operations, applied in order"),
}), }),
}, },
draw_graph: {
description: `Draw a FLOWCHART or other arrow-driven diagram from nodes and arrows alone. Give NO positions and NO nesting.
USE THIS FOR: flowcharts, decision trees, process and approval flows, CI/CD pipelines, state machines, dependency graphs, ER diagrams, site maps, data-flow diagrams.
The engine reads the arrows to work out how many rows the diagram has, which nodes share a row, and who goes left of whom — chosen to keep arrows from crossing each other or running through unrelated boxes. Do NOT lay these out yourself with nested containers or XML: declaring a flowchart as nesting puts every step in one column, so each branch has to jump over the step beside it.
Loops are fine — an arrow back to an earlier step is drawn as a loop. So are arrows that skip ahead several steps.
{"nodes":[
{"id":"start","label":"Order received","shape":"terminator"},
{"id":"check","label":"Amount > $1000?","shape":"decision"},
{"id":"mgr","label":"Manager approval"},
{"id":"auto","label":"Auto-approve"},
{"id":"ship","label":"Ship order"}
],"edges":[
{"source":"start","target":"check"},
{"source":"check","target":"mgr","label":"yes"},
{"source":"check","target":"auto","label":"no"},
{"source":"mgr","target":"ship"},
{"source":"auto","target":"ship"}
],"title":"Order Approval"}
Replaces the whole diagram, because one new arrow can change which row several nodes belong in. To edit afterwards, use restructure_diagram with the ids from the outline this returns.
Shapes: "decision" for a branch (diamond), "terminator" for a start or end point, "data" for input or output, "document" for a report, "round" for a soft-edged step, "box" (default) for a plain step. Set icon instead of shape to draw a node as a catalog icon — look the name up with search_stencils first.`,
inputSchema: z.object({
nodes: z
.array(
z.object({
id: z.string(),
label: z.string(),
shape: z
.enum([
"box",
"decision",
"terminator",
"round",
"data",
"document",
])
.optional(),
icon: z
.string()
.optional()
.describe(
"Catalog stencil name; draws this node as an icon",
),
}),
)
.describe("Every box in the diagram"),
edges: z
.array(
z.object({
source: z.string(),
target: z.string(),
label: z.string().optional(),
dashed: z.boolean().optional(),
}),
)
.describe(
"Arrows. Direction matters — it sets the order of the diagram",
),
title: z.string().optional(),
flow: z
.enum(["col", "row"])
.optional()
.describe(
"col (default): top to bottom. row: left to right",
),
}),
},
search_stencils: { search_stencils: {
description: `Find AWS stencil names for restructure_diagram. Returns names and official colours — call this before naming an icon, and batch the whole diagram's lookups into as few calls as possible.`, description: `Find AWS stencil names for restructure_diagram. Returns names and official colours — call this before naming an icon, and batch the whole diagram's lookups into as few calls as possible.`,
inputSchema: z.object({ inputSchema: z.object({

View File

@@ -96,6 +96,7 @@ https://github.com/user-attachments/assets/b2eef5f3-b335-4e71-a755-dc2e80931979
- **图表历史记录**全面的版本控制跟踪所有更改允许您查看和恢复AI编辑前的图表版本 - **图表历史记录**全面的版本控制跟踪所有更改允许您查看和恢复AI编辑前的图表版本
- **交互式聊天界面**与AI实时对话来完善您的图表 - **交互式聊天界面**与AI实时对话来完善您的图表
- **云架构图支持**专门支持生成云架构图AWS、GCP、Azure - **云架构图支持**专门支持生成云架构图AWS、GCP、Azure
- **自动计算布局**:画架构图、流程图、泳道图/BPMN、时序图、思维导图和组织架构图时AI 只描述结构——谁包含谁,或者谁指向谁——所有坐标、尺寸和连线路径都由程序计算。容器一定装得下里面的内容,同层元素不会重叠,连线会绕开本来会穿过的图形。之后您手动移动或改色的部分会被当作图表的一部分读回来,所以后续修改不会覆盖掉您的调整。
- **动画连接器**:在图表元素之间创建动态动画连接器,实现更好的可视化效果 - **动画连接器**:在图表元素之间创建动态动画连接器,实现更好的可视化效果
## MCP服务器 ## MCP服务器

View File

@@ -94,6 +94,7 @@ https://github.com/user-attachments/assets/b2eef5f3-b335-4e71-a755-dc2e80931979
- **ダイアグラム履歴**すべての変更を追跡する包括的なバージョン管理。AI編集前のダイアグラムの以前のバージョンを表示・復元可能 - **ダイアグラム履歴**すべての変更を追跡する包括的なバージョン管理。AI編集前のダイアグラムの以前のバージョンを表示・復元可能
- **インタラクティブなチャットインターフェース**AIとリアルタイムでコミュニケーションしてダイアグラムを改善 - **インタラクティブなチャットインターフェース**AIとリアルタイムでコミュニケーションしてダイアグラムを改善
- **クラウドアーキテクチャダイアグラムサポート**クラウドアーキテクチャダイアグラムの生成を専門的にサポートAWS、GCP、Azure - **クラウドアーキテクチャダイアグラムサポート**クラウドアーキテクチャダイアグラムの生成を専門的にサポートAWS、GCP、Azure
- **レイアウトの自動計算**アーキテクチャ図、フローチャート、スイムレーン図BPMN、シーケンス図、マインドマップ、組織図では、AI は構造だけ——何が何を含むか、何が何を指すか——を指定し、座標・サイズ・矢印の経路はすべてアプリが計算します。コンテナは必ず中身が収まるサイズになり、同じ階層の要素が重なることはなく、矢印は本来通り抜けてしまう図形を避けて引かれます。その後に手で動かしたり色を変えた部分は図の一部として読み戻されるため、次の編集で元に戻されることはありません。
- **アニメーションコネクタ**:より良い可視化のためにダイアグラム要素間に動的でアニメーション化されたコネクタを作成 - **アニメーションコネクタ**:より良い可視化のためにダイアグラム要素間に動的でアニメーション化されたコネクタを作成
## MCPサーバー ## MCPサーバー

View File

@@ -6,7 +6,12 @@ import type {
ValidationStatus, ValidationStatus,
} from "@/components/chat/ValidationCard" } from "@/components/chat/ValidationCard"
import type { Operation } from "@/lib/diagram-engine" import type { Operation } from "@/lib/diagram-engine"
import { restructureDiagram } from "@/lib/diagram-engine" import {
drawGraph,
type GraphEdge,
type GraphNode,
restructureDiagram,
} from "@/lib/diagram-engine"
import type { ValidationResult } from "@/lib/diagram-validator" import type { ValidationResult } from "@/lib/diagram-validator"
import { formatValidationFeedback } from "@/lib/diagram-validator" import { formatValidationFeedback } from "@/lib/diagram-validator"
import { isMxCellXmlComplete, isRealDiagram, wrapWithMxFile } from "@/lib/utils" import { isMxCellXmlComplete, isRealDiagram, wrapWithMxFile } from "@/lib/utils"
@@ -124,6 +129,8 @@ export function useDiagramToolHandlers({
handleAppendDiagram(toolCall, addToolOutput) handleAppendDiagram(toolCall, addToolOutput)
} else if (toolCall.toolName === "restructure_diagram") { } else if (toolCall.toolName === "restructure_diagram") {
await handleRestructureDiagram(toolCall, addToolOutput) await handleRestructureDiagram(toolCall, addToolOutput)
} else if (toolCall.toolName === "draw_graph") {
await handleDrawGraph(toolCall, addToolOutput)
} }
} }
@@ -646,5 +653,60 @@ Fix the operations and call restructure_diagram again.`,
}) })
} }
/**
* draw_graph: build a flowchart from nodes and arrows, with no positions given.
*
* The current canvas is deliberately NOT read. Which row a node belongs in depends on
* every arrow in the graph, so one new edge can move half the diagram — there is no
* meaningful way to merge a new graph into an existing layout. The model edits afterwards
* through restructure_diagram, which does read the canvas.
*/
const handleDrawGraph = async (
toolCall: ToolCall,
addToolOutput: AddToolOutputFn,
) => {
const { nodes, edges, title, flow } = toolCall.input as {
nodes: GraphNode[]
edges: GraphEdge[]
title?: string
flow?: "col" | "row"
}
const result = drawGraph(nodes ?? [], edges ?? [], { title, flow })
if (result.errors.length > 0 || !result.xml) {
addToolOutput({
tool: "draw_graph",
toolCallId: toolCall.toolCallId,
state: "output-error",
errorText: `Could not draw the graph:
${result.errors.map((e) => `- ${e}`).join("\n")}
Fix the nodes or edges and call draw_graph again.`,
})
return
}
const loadError = onDisplayChart(result.xml)
if (loadError) {
addToolOutput({
tool: "draw_graph",
toolCallId: toolCall.toolCallId,
state: "output-error",
errorText: `The diagram was built but draw.io rejected it: ${loadError}`,
})
return
}
const notes = result.warnings.length
? `\n\nNotes:\n${result.warnings.map((w) => `- ${w}`).join("\n")}`
: ""
addToolOutput({
tool: "draw_graph",
toolCallId: toolCall.toolCallId,
output: `Diagram updated.\n\n${result.outline}${notes}`,
})
}
return { handleToolCall } return { handleToolCall }
} }

357
lib/diagram-engine/graph.ts Normal file
View File

@@ -0,0 +1,357 @@
/**
* Graph → layers. What turns a flat list of nodes and arrows into a diagram.
*
* The engine's layout can only arrange what nesting tells it to: a container stacks its
* children in one direction, so six boxes declared in a row become six boxes in a row. For
* a flowchart that is the wrong answer, and measurably so — an order-approval flow declared
* in its natural order comes out as one column, which forces the arrow from the decision to
* its second branch to jump over the first branch, and the arrow to the merge point to jump
* back over that. The layout never looked at the arrows.
*
* This computes what it should have looked at. Three steps, the standard shape of a layered
* graph drawing (Sugiyama's algorithm):
*
* 1. LAYER — how far along the flow each node sits. Longest path from a source, so an
* arrow always points forwards and no arrow skips backwards through a layer.
* 2. ORDER — who goes left and who goes right within a layer. Chosen to reduce the number
* of arrows that cross, which is what makes a flowchart readable.
* 3. EMIT — one invisible row container per layer, which the existing layout then places.
*
* Step 3 is why this file is small: the coordinate work already exists, and it is the same
* code that lays out an AWS diagram. What was missing was only the decision of what goes in
* which row.
*/
import type { Operation } from "./operations"
import type { BoxShape } from "./types"
/** A node in the graph the caller wants drawn. */
export interface GraphNode {
id: string
label: string
/** Flowchart outline. `decision` for a branch, `terminator` for a start or end point. */
shape?: BoxShape
/** Catalog stencil name. When set the node renders as an icon rather than a box. */
icon?: string
}
/** An arrow. Direction matters: it is what determines the layering. */
export interface GraphEdge {
source: string
target: string
label?: string
dashed?: boolean
}
export interface GraphOptions {
/** "col" (default): layers stack downwards. "row": layers run left to right. */
flow?: "col" | "row"
/** Distance between layers. */
layerGap?: number
/** Distance between nodes within a layer. */
nodeGap?: number
/** Prefix for the generated layer container ids. */
idPrefix?: string
}
export interface GraphResult {
operations: Operation[]
/** The nodes of each layer, in the order they were placed. */
layers: string[][]
/** Edges dropped because an endpoint is not in the node list. */
unknownEndpoints: string[]
/** Edges that had to be treated as loops rather than as layering constraints. */
backEdges: { source: string; target: string }[]
}
/**
* Break every cycle, so the graph can be layered at all.
*
* A depth-first walk; any arrow pointing at a node still on the current path is a way back
* to where we came from, and cannot be a "this comes after that" constraint. Those arrows
* are still DRAWN — a review loop is the point of the diagram — they just do not get a say
* in which layer anything lands in.
*/
function breakCycles(
nodes: string[],
edges: GraphEdge[],
): { forward: GraphEdge[]; back: GraphEdge[] } {
const out = new Map<string, GraphEdge[]>(nodes.map((n) => [n, []]))
for (const e of edges) out.get(e.source)?.push(e)
const forward: GraphEdge[] = []
const back: GraphEdge[] = []
const onPath = new Set<string>()
const done = new Set<string>()
// An explicit stack, not recursion: a 500-node dependency graph is a plausible input and
// a recursive walk over one would overflow.
for (const root of nodes) {
if (done.has(root)) continue
const stack: { id: string; next: number }[] = [{ id: root, next: 0 }]
onPath.add(root)
while (stack.length > 0) {
const top = stack[stack.length - 1]
const list = out.get(top.id) ?? []
if (top.next >= list.length) {
onPath.delete(top.id)
done.add(top.id)
stack.pop()
continue
}
const e = list[top.next++]
if (onPath.has(e.target)) {
back.push(e)
continue
}
forward.push(e)
if (!done.has(e.target)) {
onPath.add(e.target)
stack.push({ id: e.target, next: 0 })
}
}
}
return { forward, back }
}
/**
* Assign each node to a layer: the longest path to it from any node with no predecessor.
*
* Longest path rather than shortest, because a node has to come after EVERYTHING that feeds
* it. Take the shortest and an arrow ends up pointing backwards: with `a→b`, `a→c`, `c→b`,
* the shortest path puts b in layer 1 alongside c, and then `c→b` points sideways.
*/
function assignLayers(nodes: string[], forward: GraphEdge[]): string[][] {
const layer = new Map<string, number>(nodes.map((n) => [n, 0]))
// Relaxation, bounded by the node count: the longest possible chain visits every node
// once, so after that many rounds nothing can still be moving.
for (let round = 0; round < nodes.length; round++) {
let moved = false
for (const e of forward) {
const want = (layer.get(e.source) ?? 0) + 1
if (want > (layer.get(e.target) ?? 0)) {
layer.set(e.target, want)
moved = true
}
}
if (!moved) break
}
const depth = Math.max(0, ...layer.values()) + 1
const layers: string[][] = Array.from({ length: depth }, () => [])
// Declaration order within a layer, so the ordering pass starts somewhere predictable.
for (const n of nodes) layers[layer.get(n) ?? 0].push(n)
return layers
}
/**
* Reorder each layer to reduce the number of arrows that cross.
*
* Barycentre sweeping: a node is placed at the average position of the nodes it connects to
* in the neighbouring layer, and the whole diagram is swept downwards then upwards
* repeatedly. Each sweep can only be judged against the previous layer's order, so a node
* pulled into a better place drags its own neighbours in the next sweep.
*
* The heuristic, not an exact minimum: finding the true minimum number of crossings is
* NP-hard even for two layers. In practice this reaches zero crossings on the flowcharts the
* model actually produces — verified on a 14-node pipeline with two diamonds and a rollback
* loop, and on a bipartite graph whose declared order forces three crossings.
*/
function reduceCrossings(layers: string[][], edges: GraphEdge[]): void {
if (layers.length < 2) return
const PASSES = 8
const into = new Map<string, string[]>()
const outOf = new Map<string, string[]>()
for (const e of edges) {
if (e.source === e.target) continue
;(into.get(e.target) ?? into.set(e.target, []).get(e.target))?.push(
e.source,
)
;(outOf.get(e.source) ?? outOf.set(e.source, []).get(e.source))?.push(
e.target,
)
}
let best = layers.map((l) => [...l])
let bestScore = countCrossings(layers, edges)
for (let pass = 0; pass < PASSES && bestScore > 0; pass++) {
const pos = new Map<string, number>()
for (const l of layers)
l.forEach((n, i) => {
pos.set(n, i)
})
const down = pass % 2 === 0
const order = down
? layers.map((_, i) => i).slice(1)
: layers
.map((_, i) => i)
.slice(0, -1)
.reverse()
for (const i of order) {
const neighbours = down ? into : outOf
const key = new Map<string, number>()
layers[i].forEach((n, idx) => {
const nb = (neighbours.get(n) ?? [])
.map((m) => pos.get(m))
.filter((v): v is number => v !== undefined)
// A node with no neighbour in that direction keeps its place, rather than
// being pushed to one end by a default of zero.
key.set(
n,
nb.length ? nb.reduce((a, b) => a + b, 0) / nb.length : idx,
)
})
layers[i] = [...layers[i]].sort(
(a, b) => (key.get(a) ?? 0) - (key.get(b) ?? 0),
)
}
// Keep the best arrangement seen: sweeping is not monotonic, and a later pass can be
// worse than an earlier one.
const score = countCrossings(layers, edges)
if (score < bestScore) {
bestScore = score
best = layers.map((l) => [...l])
}
}
for (let i = 0; i < layers.length; i++) layers[i] = best[i]
}
/**
* How many pairs of arrows cross between adjacent layers.
*
* Two arrows between the same pair of layers cross exactly when their endpoints are in the
* opposite order on the two sides. That is all this counts — arrows spanning more than one
* layer are ignored here, because their crossings depend on routing rather than ordering.
*/
function countCrossings(layers: string[][], edges: GraphEdge[]): number {
const layerOf = new Map<string, number>()
const posOf = new Map<string, number>()
layers.forEach((l, i) => {
l.forEach((n, j) => {
layerOf.set(n, i)
posOf.set(n, j)
})
})
let total = 0
for (let i = 0; i + 1 < layers.length; i++) {
const span = edges.filter(
(e) =>
layerOf.get(e.source) === i && layerOf.get(e.target) === i + 1,
)
for (let a = 0; a < span.length; a++)
for (let b = a + 1; b < span.length; b++) {
const s1 = posOf.get(span[a].source) ?? 0
const t1 = posOf.get(span[a].target) ?? 0
const s2 = posOf.get(span[b].source) ?? 0
const t2 = posOf.get(span[b].target) ?? 0
if ((s1 - s2) * (t1 - t2) < 0) total++
}
}
return total
}
/**
* Turn a graph into the operations that draw it.
*
* The output is ordinary operations — nothing here is a new kind of thing the rest of the
* engine has to know about. A layer of one node is emitted directly rather than wrapped,
* because a single-child row container would just add a level of nesting with nothing to
* arrange.
*/
export function graphToOperations(
nodes: GraphNode[],
edges: GraphEdge[],
opts: GraphOptions = {},
): GraphResult {
const flow = opts.flow ?? "col"
const ids = nodes.map((n) => n.id)
const known = new Set(ids)
const prefix = opts.idPrefix ?? "__layer"
const unknownEndpoints: string[] = []
const usable: GraphEdge[] = []
for (const e of edges) {
if (!known.has(e.source)) unknownEndpoints.push(e.source)
if (!known.has(e.target)) unknownEndpoints.push(e.target)
if (known.has(e.source) && known.has(e.target)) usable.push(e)
}
// A self-loop tells us nothing about layering and would make the cycle break drop a real
// arrow, so it is set aside and drawn as-is.
const loops = usable.filter((e) => e.source === e.target)
const between = usable.filter((e) => e.source !== e.target)
const { forward, back } = breakCycles(ids, between)
const layers = assignLayers(ids, forward)
reduceCrossings(layers, forward)
// The flow axis is the OUTER container's direction; a layer runs across it.
const outerDir = flow
const layerDir = flow === "col" ? "row" : "col"
const root = `${prefix}s`
const operations: Operation[] = [
{
op: "add_container",
id: root,
label: "",
dir: outerDir,
gap: opts.layerGap ?? 48,
},
]
const byId = new Map(nodes.map((n) => [n.id, n]))
const add = (id: string, parent: string): Operation => {
const n = byId.get(id) as GraphNode
return n.icon
? {
op: "add_icon",
id: n.id,
parent,
name: n.icon,
label: n.label,
}
: {
op: "add_box",
id: n.id,
parent,
label: n.label,
...(n.shape && n.shape !== "box" ? { shape: n.shape } : {}),
}
}
layers.forEach((members, i) => {
if (members.length === 0) return
if (members.length === 1) {
operations.push(add(members[0], root))
return
}
const band = `${prefix}${i}`
operations.push({
op: "add_container",
id: band,
parent: root,
label: "",
dir: layerDir,
gap: opts.nodeGap ?? 60,
})
for (const m of members) operations.push(add(m, band))
})
for (const e of [...between, ...loops])
operations.push({
op: "link",
source: e.source,
target: e.target,
...(e.label ? { label: e.label } : {}),
...(e.dashed ? { dashed: true } : {}),
})
return {
operations,
layers: layers.filter((l) => l.length > 0),
unknownEndpoints: [...new Set(unknownEndpoints)],
backEdges: back.map((e) => ({ source: e.source, target: e.target })),
}
}

View File

@@ -11,6 +11,12 @@
*/ */
import { checkNames, resolveStyle } from "./catalog" import { checkNames, resolveStyle } from "./catalog"
import {
type GraphEdge,
type GraphNode,
type GraphOptions,
graphToOperations,
} from "./graph"
import { import {
applyOperations, applyOperations,
collectNames, collectNames,
@@ -99,6 +105,69 @@ export function restructureDiagram(
} }
} }
/**
* Draw a flowchart, dependency graph, ER diagram or site map from nodes and arrows alone.
*
* The model gives no positions and no nesting — just what the boxes are and what points at
* what. The engine works out how many rows there are, who shares a row, and who goes left
* of whom, then hands the result to the same layout and edge router the architecture
* diagrams use.
*
* This exists because declaring a flowchart as nesting does not work: six steps declared in
* their natural order become one column, and every branch then has to jump over the step
* beside it. The layering has to come from the arrows, and only the engine can see all of
* them at once.
*
* Replaces the whole diagram rather than adding to it: the layer assignment depends on every
* arrow, so one new edge can move half the nodes. Editing afterwards goes through
* `restructureDiagram` as usual.
*/
export function drawGraph(
nodes: GraphNode[],
edges: GraphEdge[],
opts: RestructureOptions & GraphOptions & { title?: string } = {},
): RestructureResult {
if (nodes.length === 0)
return {
xml: null,
outline: "",
errors: ["draw_graph: no nodes — nothing to draw."],
warnings: [],
}
const dupes = nodes
.map((n) => n.id)
.filter((id, i, all) => all.indexOf(id) !== i)
if (dupes.length > 0)
return {
xml: null,
outline: "",
errors: [
`draw_graph: duplicate node id(s): ${[...new Set(dupes)].join(", ")}.`,
],
warnings: [],
}
const graph = graphToOperations(nodes, edges, opts)
const warnings: string[] = []
if (graph.unknownEndpoints.length)
warnings.push(
`Dropped edge(s) naming nodes that were not in the node list: ${graph.unknownEndpoints.join(", ")}.`,
)
if (graph.backEdges.length)
warnings.push(
`Loop(s) drawn but not used for ordering: ${graph.backEdges
.map((e) => `${e.source}${e.target}`)
.join(", ")}.`,
)
const ops: Operation[] = opts.title
? [{ op: "set_title", title: opts.title }, ...graph.operations]
: graph.operations
const result = restructureDiagram("", ops, opts)
return { ...result, warnings: [...warnings, ...result.warnings] }
}
/** Read the current canvas structure without changing it. */ /** Read the current canvas structure without changing it. */
export function describeDiagram( export function describeDiagram(
currentXml: string, currentXml: string,
@@ -114,6 +183,12 @@ export function describeDiagram(
} }
export { CATALOG_SIZE, lookupStencil, searchStencils } from "./catalog" export { CATALOG_SIZE, lookupStencil, searchStencils } from "./catalog"
export {
type GraphEdge,
type GraphNode,
type GraphOptions,
graphToOperations,
} from "./graph"
export { type Operation, OperationSchema } from "./operations" export { type Operation, OperationSchema } from "./operations"
export { parseDiagram } from "./parse" export { parseDiagram } from "./parse"
export { renderDiagram } from "./render" export { renderDiagram } from "./render"

View File

@@ -15,10 +15,27 @@
* The model never supplies a coordinate. It declares nesting, direction and gap; every * The model never supplies a coordinate. It declares nesting, direction and gap; every
* x/y/width/height comes from here. * x/y/width/height comes from here.
* *
* Ported from drawio-ai-kit (MIT) — see NOTICE. * Five container kinds share those two passes, because they differ only in how a parent
* distributes its interior:
*
* group — children stacked along one axis. Cloud architecture, nested frames.
* grid — children packed into a fixed number of columns.
* pool — a sparse (lane × column) grid. Swimlane and BPMN diagrams.
* sequence — participants across the top, lifelines below. Sequence diagrams.
* radial — a centre with branches fanning out, or hanging below. Mind maps, org charts.
*
* Ported from drawio-ai-kit (MIT) — see NOTICE. The pool geometry follows that project's
* `pool()` primitive; sequence and radial are original to this repository.
*/ */
import type { ContainerNode, DiagramNode, Rect } from "./types" import type {
ContainerNode,
DiagramNode,
PoolNode,
RadialNode,
Rect,
SequenceNode,
} from "./types"
import { isContainer } from "./types" import { isContainer } from "./types"
/** Default glyph size for a catalog icon. */ /** Default glyph size for a catalog icon. */
@@ -31,13 +48,143 @@ const HEADER = 36
/** Approximate width of one label character at the engine's font size. */ /** Approximate width of one label character at the engine's font size. */
const CHAR_W = 6.6 const CHAR_W = 6.6
// ---- pool geometry, shared with render.ts so the bands land under the nodes ----
/** Interior padding of a pool. Tighter than a group's: lane bands sit flush. */
export const POOL_PAD = 16
/** Width of the lane-name column (height, when the pool is vertical). */
export const LANE_LABEL = 110
/** Height of the milestone band (width, when the pool is vertical). */
export const PHASE_LABEL = 26
/** A pool's own title strip. */
export const POOL_HEADER = 34
/** Vertical padding inside a lane band, so nodes do not touch the band's edges. */
const LANE_PAD = 14
// ---- sequence geometry ----
/** Height of a participant head. */
const HEAD_H = 44
/** Vertical distance from the participant heads to the first message. */
const LIFELINE_TOP = 28
/** How far the lifeline runs past the last message. */
const LIFELINE_TAIL = 36
/**
* The geometry a pool needs, derived once and used by both layout and rendering.
*
* Rendering has to paint the lane bands and label columns at exactly the positions layout
* used, or the nodes sit off their bands. Computing it in one place is what keeps the two
* from drifting.
*/
export interface PoolMetrics {
horizontal: boolean
lanes: number
cols: number
/** Cell size along the flow axis. */
cellW: number
/** Cell size across the lane axis. */
cellH: number
header: number
phaseLabel: number
/** Where cell (0,0) starts. */
contentX: number
contentY: number
/** Total extent of the cell area. */
contentW: number
contentH: number
}
export function poolMetrics(
n: PoolNode,
rect: Rect,
kids: { rect: Rect }[],
): PoolMetrics {
const horizontal = n.orientation !== "vertical"
const lanes = Math.max(1, n.lanes.length)
const cols = Math.max(1, ...n.children.map((c) => poolCellOf(c).col + 1))
const cellW = Math.max(80, ...kids.map((k) => k.rect.w))
const cellH = Math.max(40, ...kids.map((k) => k.rect.h)) + LANE_PAD
const header = n.label ? POOL_HEADER : 0
const phaseLabel = n.phases.length ? PHASE_LABEL : 0
const contentW = horizontal
? cols * cellW + n.gap * (cols - 1)
: lanes * cellW
const contentH = horizontal
? lanes * cellH
: cols * cellH + n.gap * (cols - 1)
return {
horizontal,
lanes,
cols,
cellW,
cellH,
header,
phaseLabel,
contentX: horizontal
? rect.x + POOL_PAD + LANE_LABEL
: rect.x + POOL_PAD,
contentY: horizontal
? rect.y + header + phaseLabel + POOL_PAD
: rect.y + header + POOL_PAD + LANE_LABEL,
contentW,
contentH,
}
}
/** Where a sequence diagram's lifelines start and how far they run. */
export interface SequenceMetrics {
/** Top of the lifeline, just under the participant heads. */
top: number
/** Bottom of the lifeline. */
bottom: number
/** Vertical position of message N, for N starting at 1. */
messageY: (step: number) => number
}
export function sequenceMetrics(
n: SequenceNode,
rect: Rect,
messages: number,
): SequenceMetrics {
const head = n.label ? HEADER : 0
const top = rect.y + head + PAD + HEAD_H
const first = top + LIFELINE_TOP
return {
top,
bottom: first + Math.max(0, messages - 1) * n.step + LIFELINE_TAIL,
messageY: (step) => first + Math.max(0, step - 1) * n.step,
}
}
/** A node with its computed box. Layout works on this, leaving the tree untouched. */ /** A node with its computed box. Layout works on this, leaving the tree untouched. */
export interface Placed { export interface Placed {
node: DiagramNode node: DiagramNode
rect: Rect rect: Rect
children: Placed[] children: Placed[]
/**
* For a node inside a radial container: the extent of its whole subtree across the
* branching axis. Measured on the way up, spent on the way down — a parent needs its
* children's subtree extents to divide its own span between them.
*/
extent?: number
} }
/**
* What layout needs to know that the tree alone does not carry.
*
* Three of the five container kinds are laid out from the diagram's arrows, not from
* nesting: a sequence diagram's messages set how tall the lifelines have to be, and a mind
* map's hierarchy IS its arrows. Links live on the tree, not on the node, so they are
* passed in rather than read from a parent pointer.
*/
export interface LayoutContext {
/** Every link in the diagram, source → target. */
links: { source: string; target: string; step?: number }[]
}
const NO_CONTEXT: LayoutContext = { links: [] }
/** Intrinsic size of a text box: widest wrapped line by line count. */ /** Intrinsic size of a text box: widest wrapped line by line count. */
export function autoBoxSize(label: string): { w: number; h: number } { export function autoBoxSize(label: string): { w: number; h: number } {
const lines = String(label ?? "").split("\n") const lines = String(label ?? "").split("\n")
@@ -65,9 +212,182 @@ function titleFloor(label: string, pad: number): number {
} }
function headerFor(n: ContainerNode): number { function headerFor(n: ContainerNode): number {
if (n.kind === "pool") return n.label ? POOL_HEADER : 0
return n.label ? HEADER : 0 return n.label ? HEADER : 0
} }
/**
* May this node be stretched to match a sibling's size?
*
* Only a `group` may. A leaf keeps its natural size, because stretching an icon distorts
* the glyph. The three specialised containers compute their interiors from their own rules
* — lane bands, lifeline positions, ring radii — so forcing one wider leaves dead space
* inside it rather than filling anything, and forcing one taller detaches its lane bands
* from the nodes sitting on them.
*/
function stretches(n: DiagramNode): boolean {
return n.kind === "group"
}
/** The cell a node occupies inside a pool. Absent means (0,0). */
function poolCellOf(n: DiagramNode): { lane: number; col: number } {
if ((n.kind === "icon" || n.kind === "box") && n.cell)
return { lane: Math.max(0, n.cell.lane), col: Math.max(0, n.cell.col) }
return { lane: 0, col: 0 }
}
/**
* How many messages a sequence container has: the highest step number among the links
* between its participants, or the link count when the model numbered nothing.
*
* Steps are what order the messages vertically, so a diagram whose links carry no step
* still needs one row per message — otherwise every arrow lands on the same y.
*/
function messageCount(n: SequenceNode, ctx: LayoutContext): number {
const own = new Set(n.children.map((c) => c.id))
const mine = ctx.links.filter((l) => own.has(l.source) && own.has(l.target))
const steps = mine
.map((l) => l.step)
.filter((s): s is number => s != null && s > 0)
return Math.max(mine.length, ...(steps.length ? steps : [0]))
}
/**
* One node of a radial tree: a placed box plus the branches hanging off it.
*
* Separate from `Placed` because the tree is derived from the LINKS, not from nesting, so it
* exists only during a radial container's layout.
*/
interface RadialTree {
p: Placed
kids: RadialTree[]
/** How much room this whole subtree needs across the branching axis. */
extent: number
/** How many generations deep this subtree goes, counting itself as 1. */
depth: number
}
/**
* Build the branch hierarchy of a radial container from the diagram's arrows.
*
* The root is the node nothing points at. Every other node hangs off whichever node points
* at it — the FIRST one, if several do, since a mind map is a tree and a second parent has
* to be drawn as a plain cross-link instead.
*
* A node no arrow reaches at all becomes a branch of the root, so it is still drawn. Dropping
* it would silently lose a box the model asked for.
*/
function radialHierarchy(
kids: Placed[],
ctx: LayoutContext,
across: "w" | "h",
gap: number,
): { root: RadialTree; branches: RadialTree[] } | null {
if (kids.length === 0) return null
const own = new Map(kids.map((k) => [k.node.id, k]))
const parent = new Map<string, string>()
for (const l of ctx.links) {
if (!own.has(l.source) || !own.has(l.target)) continue
if (l.source === l.target) continue
if (!parent.has(l.target)) parent.set(l.target, l.source)
}
// Guard against a cycle in the arrows: walking up must terminate.
const rootOf = (id: string): string => {
const seen = new Set<string>([id])
let cur = id
for (;;) {
const up = parent.get(cur)
if (up === undefined || seen.has(up)) return cur
seen.add(up)
cur = up
}
}
// The first declared node that is nobody's child is the centre. Falling back to the first
// child keeps a cycle-only graph drawable.
const rootId =
kids.find((k) => !parent.has(k.node.id))?.node.id ??
rootOf(kids[0].node.id)
const childrenOf = new Map<string, Placed[]>()
for (const k of kids) {
if (k.node.id === rootId) continue
const up = parent.get(k.node.id)
// An orphan, or a node whose parent chain loops back to itself, attaches to the root.
const attach =
up !== undefined && up !== k.node.id && rootOf(k.node.id) === rootId
? up
: rootId
const list = childrenOf.get(attach)
if (list) list.push(k)
else childrenOf.set(attach, [k])
}
const seen = new Set<string>()
const build = (p: Placed): RadialTree => {
seen.add(p.node.id)
const kidTrees = (childrenOf.get(p.node.id) ?? [])
.filter((c) => !seen.has(c.node.id))
.map(build)
const total =
kidTrees.reduce((s, t) => s + t.extent, 0) +
gap * Math.max(0, kidTrees.length - 1)
return {
p,
kids: kidTrees,
extent: Math.max(p.rect[across], total),
depth: kidTrees.length
? 1 + Math.max(...kidTrees.map((t) => t.depth))
: 1,
}
}
const root = build(own.get(rootId) as Placed)
return { root, branches: root.kids }
}
/** Widest node at each generation, for laying a radial tree out in even rings. */
function widestPerLevel(trees: RadialTree[], along: "w" | "h"): number[] {
const out: number[] = []
const visit = (t: RadialTree, level: number) => {
out[level] = Math.max(out[level] ?? 0, t.p.rect[along])
for (const k of t.kids) visit(k, level + 1)
}
for (const t of trees) visit(t, 0)
return out
}
/**
* How far one side of a radial map reaches from the centre.
*
* Each generation contributes one gap plus the width of the widest node in it. This has to be
* computed per SIDE, not once for the whole map: a mind map whose left branches go three
* generations deep and whose right branches go one needs an asymmetric frame, and reserving
* the same room on both sides would push the deeper side off the page.
*/
function radialReach(
side: RadialTree[],
along: "w" | "h",
gap: number,
): number {
if (side.length === 0) return 0
const levels = widestPerLevel(side, along)
const generations = Math.max(...side.map((b) => b.depth))
return levels.slice(0, generations).reduce((s, v) => s + v + gap, 0)
}
/**
* Split a radial map's branches into the two sides they will be drawn on.
*
* The same split has to be used by measure and by place, or the frame is sized for one
* arrangement and the branches are drawn in another.
*/
function radialSides(branches: RadialTree[]): {
right: RadialTree[]
left: RadialTree[]
} {
const half = Math.ceil(branches.length / 2)
return { right: branches.slice(0, half), left: branches.slice(half) }
}
/** /**
* measure: give every node a size, bottom-up. * measure: give every node a size, bottom-up.
* *
@@ -75,7 +395,11 @@ function headerFor(n: ContainerNode): number {
* frames in a column share left and right edges. Only containers stretch; a leaf keeps * frames in a column share left and right edges. Only containers stretch; a leaf keeps
* its natural size, because stretching an icon would distort the glyph. * its natural size, because stretching an icon would distort the glyph.
*/ */
function measure(n: DiagramNode, defaultGlyph: number): Placed { function measure(
n: DiagramNode,
defaultGlyph: number,
ctx: LayoutContext = NO_CONTEXT,
): Placed {
if (n.kind === "icon") { if (n.kind === "icon") {
const glyph = n.size ?? defaultGlyph const glyph = n.size ?? defaultGlyph
const s = iconSize(n.label, glyph) const s = iconSize(n.label, glyph)
@@ -93,10 +417,105 @@ function measure(n: DiagramNode, defaultGlyph: number): Placed {
return { node: n, rect: { x: 0, y: 0, w: 0, h: 30 }, children: [] } return { node: n, rect: { x: 0, y: 0, w: 0, h: 30 }, children: [] }
} }
const kids = n.children.map((c) => measure(c, defaultGlyph)) const kids = n.children.map((c) => measure(c, defaultGlyph, ctx))
const head = headerFor(n) const head = headerFor(n)
const gap = n.gap const gap = n.gap
if (n.kind === "pool") {
const m = poolMetrics(n, { x: 0, y: 0, w: 0, h: 0 }, kids)
const w = m.horizontal
? POOL_PAD * 2 + LANE_LABEL + m.contentW
: POOL_PAD * 2 + m.contentW + m.phaseLabel
const h = m.horizontal
? m.header + m.phaseLabel + POOL_PAD * 2 + m.contentH
: m.header + POOL_PAD * 2 + LANE_LABEL + m.contentH
return {
node: n,
rect: {
x: 0,
y: 0,
w: Math.max(w, titleFloor(n.label, POOL_PAD)),
h,
},
children: kids,
}
}
if (n.kind === "sequence") {
// Participants sit side by side; the lifelines below them set the height.
const w =
PAD * 2 +
kids.reduce((s, k) => s + k.rect.w, 0) +
gap * Math.max(0, kids.length - 1)
const m = sequenceMetrics(
n,
{ x: 0, y: 0, w: 0, h: 0 },
messageCount(n, ctx),
)
return {
node: n,
rect: {
x: 0,
y: 0,
w: Math.max(w, titleFloor(n.label, PAD)),
h: m.bottom + PAD,
},
children: kids,
}
}
if (n.kind === "radial") {
const down = n.spread === "down"
const across = down ? "w" : "h"
const tree = radialHierarchy(kids, ctx, across, n.gap)
if (!tree)
return {
node: n,
rect: { x: 0, y: 0, w: PAD * 2, h: head + PAD * 2 },
children: kids,
}
const { root, branches } = tree
const spanOf = (bs: RadialTree[]) =>
bs.length === 0
? 0
: bs.reduce((s, b) => s + b.extent, 0) + n.gap * (bs.length - 1)
if (down) {
// Everything hangs below the centre: one direction, so one reach.
const h = root.p.rect.h + radialReach(branches, "h", n.gap)
const w = Math.max(root.p.rect.w, spanOf(branches))
return {
node: n,
rect: {
x: 0,
y: 0,
w: Math.max(PAD * 2 + w, titleFloor(n.label, PAD)),
h: head + PAD * 2 + h,
},
children: kids,
}
}
// Radial: the two sides reach different distances, so each is measured on its own.
// Using one figure for both would leave the deeper side hanging outside the frame.
const { right, left } = radialSides(branches)
const w =
radialReach(left, "w", n.gap) +
root.p.rect.w +
radialReach(right, "w", n.gap)
const h = Math.max(root.p.rect.h, spanOf(right), spanOf(left))
return {
node: n,
rect: {
x: 0,
y: 0,
w: Math.max(PAD * 2 + w, titleFloor(n.label, PAD)),
h: head + PAD * 2 + h,
},
children: kids,
}
}
if (n.kind === "grid") { if (n.kind === "grid") {
const cols = Math.max(1, n.cols) const cols = Math.max(1, n.cols)
const rows = Math.ceil(kids.length / cols) || 1 const rows = Math.ceil(kids.length / cols) || 1
@@ -120,7 +539,7 @@ function measure(n: DiagramNode, defaultGlyph: number): Placed {
if (n.dir === "row") { if (n.dir === "row") {
const tallest = Math.max(0, ...kids.map((k) => k.rect.h)) const tallest = Math.max(0, ...kids.map((k) => k.rect.h))
for (const k of kids) for (const k of kids)
if (isContainer(k.node)) k.rect.h = Math.max(k.rect.h, tallest) if (stretches(k.node)) k.rect.h = Math.max(k.rect.h, tallest)
const w = const w =
PAD * 2 + PAD * 2 +
kids.reduce((s, k) => s + k.rect.w, 0) + kids.reduce((s, k) => s + k.rect.w, 0) +
@@ -160,7 +579,12 @@ function measure(n: DiagramNode, defaultGlyph: number): Placed {
* stretched frame reads as deliberately spaced instead of sparse, and the resulting * stretched frame reads as deliberately spaced instead of sparse, and the resulting
* cluster is centred. * cluster is centred.
*/ */
function place(p: Placed, x: number, y: number): void { function place(
p: Placed,
x: number,
y: number,
ctx: LayoutContext = NO_CONTEXT,
): void {
p.rect.x = Math.round(x) p.rect.x = Math.round(x)
p.rect.y = Math.round(y) p.rect.y = Math.round(y)
const n = p.node const n = p.node
@@ -183,11 +607,54 @@ function place(p: Placed, x: number, y: number): void {
const cx = innerX + c * (cellW + n.gap) const cx = innerX + c * (cellW + n.gap)
const cy = innerTop + r * (cellH + n.gap) const cy = innerTop + r * (cellH + n.gap)
// centre each child in its cell so a short label does not sit off-axis // centre each child in its cell so a short label does not sit off-axis
place(k, cx + (cellW - k.rect.w) / 2, cy + (cellH - k.rect.h) / 2) place(
k,
cx + (cellW - k.rect.w) / 2,
cy + (cellH - k.rect.h) / 2,
ctx,
)
}) })
return return
} }
if (n.kind === "pool") {
// Each child goes to the (lane, column) cell it declared. Empty cells stay empty:
// in a swimlane diagram, "this role does nothing at this step" is information.
const m = poolMetrics(n, p.rect, kids)
for (const k of kids) {
const { lane, col } = poolCellOf(k.node)
const cx = m.horizontal
? m.contentX + col * (m.cellW + n.gap)
: m.contentX + Math.min(lane, m.lanes - 1) * m.cellW
const cy = m.horizontal
? m.contentY + Math.min(lane, m.lanes - 1) * m.cellH
: m.contentY + col * (m.cellH + n.gap)
place(
k,
cx + (m.cellW - k.rect.w) / 2,
cy + (m.cellH - k.rect.h) / 2,
ctx,
)
}
return
}
if (n.kind === "sequence") {
// Participants in a row across the top. Their lifelines hang below, emitted by the
// renderer, so nothing else has to be placed here.
let cur = innerX
for (const k of kids) {
place(k, cur, innerTop, ctx)
cur += k.rect.w + n.gap
}
return
}
if (n.kind === "radial") {
placeRadial(p, n, innerX, innerTop, innerW, innerH, ctx)
return
}
const alongRow = n.dir === "row" const alongRow = n.dir === "row"
const sizes = kids.map((k) => (alongRow ? k.rect.w : k.rect.h)) const sizes = kids.map((k) => (alongRow ? k.rect.w : k.rect.h))
const content = sizes.reduce((s, v) => s + v, 0) const content = sizes.reduce((s, v) => s + v, 0)
@@ -200,15 +667,112 @@ function place(p: Placed, x: number, y: number): void {
for (const kid of kids) { for (const kid of kids) {
if (alongRow) { if (alongRow) {
place(kid, cur, innerTop + (innerH - kid.rect.h) / 2) place(kid, cur, innerTop + (innerH - kid.rect.h) / 2, ctx)
cur += kid.rect.w + gap cur += kid.rect.w + gap
} else { } else {
place(kid, innerX + (innerW - kid.rect.w) / 2, cur) place(kid, innerX + (innerW - kid.rect.w) / 2, cur, ctx)
cur += kid.rect.h + gap cur += kid.rect.h + gap
} }
} }
} }
/**
* Place a radial container: a centre with its branches fanning out.
*
* Two shapes, because a mind map and an org chart want opposite things. A mind map reads
* best with branches on both sides of the centre, which keeps it compact and balanced. An
* org chart must hang everything downwards — a reporting line drawn upwards or sideways
* reads as the wrong relationship, no matter how much space it saves.
*
* Each generation sits in its own ring, the ring's depth set by the widest node in it, so
* siblings line up instead of stepping raggedly outwards.
*/
function placeRadial(
p: Placed,
n: RadialNode,
innerX: number,
innerTop: number,
innerW: number,
innerH: number,
ctx: LayoutContext,
): void {
const down = n.spread === "down"
const along = down ? "h" : "w"
const across = down ? "w" : "h"
const tree = radialHierarchy(p.children, ctx, across, n.gap)
if (!tree) return
const { root, branches } = tree
const centre = root.p
/**
* Lay one generation out along the cross axis, then recurse.
*
* `start` is the middle of the band this generation has to fill; each branch gets a slice
* of it as wide as its own subtree needs, and is centred in that slice. Sizing slices by
* subtree extent — not by branch count — is what keeps a bushy branch from being drawn
* over a bare sibling.
*/
const spread = (
items: RadialTree[],
level: number,
start: number,
alongPos: number,
sign: 1 | -1,
) => {
const total =
items.reduce((s, b) => s + b.extent, 0) +
n.gap * Math.max(0, items.length - 1)
let cur = start - total / 2
for (const b of items) {
const mid = cur + b.extent / 2
// On the left side the ring position is the branch's FAR edge, so its own size has
// to come off to get its origin.
const a = sign > 0 ? alongPos : alongPos - b.p.rect[along]
if (down) place(b.p, mid - b.p.rect.w / 2, a, ctx)
else place(b.p, a, mid - b.p.rect.h / 2, ctx)
if (b.kids.length) {
// `alongPos` means the NEAR edge going outwards and the FAR edge coming back,
// which is why the two directions are not symmetric here: on the left the
// recursion subtracts the child's own width, so subtracting the ring width as
// well would place it a full ring too far out — off the frame.
const next = sign > 0 ? a + b.p.rect[along] + n.gap : a - n.gap
spread(b.kids, level + 1, mid, next, sign)
}
cur += b.extent + n.gap
}
}
if (down) {
place(centre, innerX + (innerW - centre.rect.w) / 2, innerTop, ctx)
spread(
branches,
0,
centre.rect.x + centre.rect.w / 2,
centre.rect.y + centre.rect.h + n.gap,
1,
)
return
}
// Radial: split the branches between the two sides, keeping declaration order within each
// side so the model can predict where a branch lands.
//
// The centre goes at the LEFT side's reach, not at the frame's middle. Those are the same
// only when both sides are equally deep; centring a lopsided map would push the deeper
// side out past the frame's edge and off the page.
const { right, left } = radialSides(branches)
place(
centre,
innerX + radialReach(left, "w", n.gap),
innerTop + (innerH - centre.rect.h) / 2,
ctx,
)
const midY = centre.rect.y + centre.rect.h / 2
spread(right, 0, midY, centre.rect.x + centre.rect.w + n.gap, 1)
spread(left, 0, midY, centre.rect.x - n.gap, -1)
}
export interface LayoutResult { export interface LayoutResult {
/** Placed roots, in the order given. */ /** Placed roots, in the order given. */
roots: Placed[] roots: Placed[]
@@ -228,11 +792,18 @@ const MARGIN = { right: 40, bottom: 50 }
*/ */
export function layoutForest( export function layoutForest(
roots: DiagramNode[], roots: DiagramNode[],
opts: { iconSize?: number; gap?: number } = {}, opts: {
iconSize?: number
gap?: number
/** The diagram's links. Needed by sequence containers, which size themselves from
* the number of messages between their participants. */
links?: LayoutContext["links"]
} = {},
): LayoutResult { ): LayoutResult {
const glyph = opts.iconSize ?? ICON_SIZE const glyph = opts.iconSize ?? ICON_SIZE
const gap = opts.gap ?? 70 const gap = opts.gap ?? 70
const placed = roots.map((r) => measure(r, glyph)) const ctx: LayoutContext = { links: opts.links ?? [] }
const placed = roots.map((r) => measure(r, glyph, ctx))
let cur = ORIGIN.x let cur = ORIGIN.x
for (const p of placed) { for (const p of placed) {
@@ -240,9 +811,9 @@ export function layoutForest(
const held = const held =
n.kind === "title" ? null : n.pinned ? (n.rect ?? null) : null n.kind === "title" ? null : n.pinned ? (n.rect ?? null) : null
if (held) { if (held) {
place(p, held.x, held.y) place(p, held.x, held.y, ctx)
} else { } else {
place(p, cur, ORIGIN.y) place(p, cur, ORIGIN.y, ctx)
cur += p.rect.w + gap cur += p.rect.w + gap
} }
} }

View File

@@ -41,9 +41,43 @@ export const MARKER = {
* base64 image with no name anywhere in it, so the style alone cannot identify it. * base64 image with no name anywhere in it, so the style alone cannot identify it.
*/ */
name: "dai_name", name: "dai_name",
/**
* Which (lane, column) cell of a swimlane pool a node occupies, as "lane,col".
*
* Position alone cannot recover this once the user drags a node: the cell it lands in
* is a guess, whereas the marker records which lane the model assigned it to. It is
* also the only way an empty cell stays empty — geometry can only tell us where things
* ARE, never that a role deliberately does nothing at a given step.
*/
cell: "dai_cell",
/** A pool's lane names, tab-separated (a tab cannot appear in a draw.io style value). */
lanes: "dai_lanes",
/** A pool's milestone labels, tab-separated. */
phases: "dai_phases",
/** A pool's orientation: "h" or "v". */
orient: "dai_orient",
/** Vertical distance between consecutive messages in a sequence diagram. */
step: "dai_step",
/** How a radial container fans its branches out: "radial" or "down". */
spread: "dai_spread",
/**
* Marks a cell as chrome the engine draws and owns: a pool's lane bands, its label
* columns, its milestone strip. The parser must not read these back as nodes — they are
* re-derived from the pool's own parameters on every layout — and the edge router must
* not treat them as obstacles, since a sequence flow crossing lanes is the norm.
*/
lane: "dai_lane",
} as const } as const
export type NodeKind = "group" | "grid" | "icon" | "box" | "title" export type NodeKind =
| "group"
| "grid"
| "pool"
| "sequence"
| "radial"
| "icon"
| "box"
| "title"
export type Direction = "row" | "col" | "grid" export type Direction = "row" | "col" | "grid"
/** /**
@@ -88,17 +122,59 @@ export function readMarker(style: string, key: string): string | null {
return last return last
} }
const KINDS: readonly NodeKind[] = [
"group",
"grid",
"pool",
"sequence",
"radial",
"icon",
"box",
"title",
]
export function readKind(style: string): NodeKind | null { export function readKind(style: string): NodeKind | null {
const v = readMarker(style, MARKER.kind) const v = readMarker(style, MARKER.kind)
if ( return KINDS.includes(v as NodeKind) ? (v as NodeKind) : null
v === "group" || }
v === "grid" ||
v === "icon" || /**
v === "box" || * The (lane, column) cell a node occupies in a swimlane pool, or null.
v === "title" *
) * Both must be non-negative integers: a malformed value is safer read as "no cell
return v * declared" (which puts the node in lane 0 column 0) than as a negative index, which would
return null * place it outside the pool's frame.
*/
export function readCell(style: string): { lane: number; col: number } | null {
const v = readMarker(style, MARKER.cell)
if (!v) return null
const m = v.match(/^(\d+),(\d+)$/)
return m ? { lane: Number(m[1]), col: Number(m[2]) } : null
}
/**
* A tab-separated marker list, as written by `joinList`.
*
* A tab cannot appear in a draw.io style value — the editor writes styles as a single
* semicolon-separated line — so it is safe as a separator inside one value, where a comma
* would collide with the label text it has to carry.
*/
export function readList(style: string, key: string): string[] | null {
const v = readMarker(style, key)
if (v === null) return null
if (v === "") return []
return v.split("\t").map(decodeURIComponent)
}
/** Encode a list of labels into one marker value. */
export function joinList(items: string[]): string {
// Percent-encoding keeps a label containing ";" or "=" from breaking the style string.
return items.map((s) => encodeURIComponent(s)).join("\t")
}
/** Is this cell pool chrome the engine draws and owns, rather than a node? */
export function isLaneChrome(style: string): boolean {
return readMarker(style, MARKER.lane) !== null
} }
export function readDir(style: string): Direction | null { export function readDir(style: string): Direction | null {
@@ -163,6 +239,93 @@ export function stampContainer(
return s return s
} }
/**
* Stamp a swimlane pool: its lane names, milestone labels and orientation.
*
* Unlike a group, a pool is NOT stamped as a draw.io container. Its lane bands are separate
* cells sitting inside it, and they are what a shape should reparent into when the user
* drags it — that is how "the user moved this step to a different role" gets recorded. If
* the pool itself claimed the drop, every node would come back in lane 0.
*/
export function stampPool(
style: string,
opts: {
lanes: string[]
phases: string[]
orientation: "horizontal" | "vertical"
gap: number
},
): string {
let s = append(style, MARKER.kind, "pool")
s = append(s, MARKER.lanes, joinList(opts.lanes))
s = append(s, MARKER.phases, joinList(opts.phases))
s = append(s, MARKER.orient, opts.orientation === "vertical" ? "v" : "h")
return append(s, MARKER.gap, Math.round(opts.gap))
}
/** Stamp a sequence container: participant spacing and message spacing. */
export function stampSequence(
style: string,
opts: { gap: number; step: number },
): string {
const s = append(style, MARKER.kind, "sequence")
return append(
append(s, MARKER.gap, Math.round(opts.gap)),
MARKER.step,
Math.round(opts.step),
)
}
/** Stamp a radial container: how it fans branches out, and the ring spacing. */
export function stampRadial(
style: string,
opts: { spread: "radial" | "down"; gap: number },
): string {
const s = append(style, MARKER.kind, "radial")
return append(
append(s, MARKER.spread, opts.spread),
MARKER.gap,
Math.round(opts.gap),
)
}
/**
* Stamp one of a pool's lane bands.
*
* A band IS a draw.io container, so dragging a step onto another role's band reparents it
* there and the marker on the band tells the parser which lane that is. The lane index is
* the band's identity, not its position, so the assignment survives the pool being
* re-measured to a different size.
*/
export function stampLane(style: string, lane: number): string {
let s = style.endsWith(";") || style === "" ? style : `${style};`
s += CONTAINER_TOKENS
return append(s, MARKER.lane, Math.max(0, Math.round(lane)))
}
/**
* Stamp a pool's own decoration — a lane-name column or a milestone strip.
*
* `dai_lane=-1` marks it as chrome without claiming a lane: it is a label, and a shape
* dropped on it belongs to no role. It stays a draw.io container only so clicks fall
* through to whatever is behind it.
*/
export function stampPoolDecoration(style: string): string {
return append(style, MARKER.lane, -1)
}
/** Record which pool cell a node occupies. */
export function stampCell(
style: string,
cell: { lane: number; col: number },
): string {
return append(
style,
MARKER.cell,
`${Math.max(0, Math.round(cell.lane))},${Math.max(0, Math.round(cell.col))}`,
)
}
/** /**
* Is this an invisible layout wrapper? Both colours set to `none` and no group * Is this an invisible layout wrapper? Both colours set to `none` and no group
* stencil — a visible frame always has a stroke or a stencil. * stencil — a visible frame always has a stroke or a stencil.

View File

@@ -17,7 +17,9 @@ import {
type DiagramTree, type DiagramTree,
findNode, findNode,
findParent, findParent,
hasStencilFrame,
isContainer, isContainer,
isDirectional,
type LinkSpec, type LinkSpec,
walkTree, walkTree,
} from "./types" } from "./types"
@@ -32,6 +34,18 @@ export const OperationSchema = z.discriminatedUnion("op", [
.describe("Container id to add into; omit for top level"), .describe("Container id to add into; omit for top level"),
name: z.string().describe("Catalog stencil name, e.g. 's3' or 'ec2'"), name: z.string().describe("Catalog stencil name, e.g. 's3' or 'ec2'"),
label: z.string().optional(), label: z.string().optional(),
lane: z
.number()
.optional()
.describe(
"Inside a pool: which lane (0-based row) this belongs to",
),
col: z
.number()
.optional()
.describe(
"Inside a pool: which column (0-based step) this sits in",
),
after: z after: z
.string() .string()
.optional() .optional()
@@ -42,6 +56,31 @@ export const OperationSchema = z.discriminatedUnion("op", [
id: z.string(), id: z.string(),
parent: z.string().optional(), parent: z.string().optional(),
label: z.string(), label: z.string(),
shape: z
.enum([
"box",
"decision",
"terminator",
"round",
"data",
"document",
])
.optional()
.describe(
"Flowchart outline: decision=diamond, terminator=start/end, data=input/output, document=report. Omit for a plain rectangle",
),
lane: z
.number()
.optional()
.describe(
"Inside a pool: which lane (0-based row) this belongs to",
),
col: z
.number()
.optional()
.describe(
"Inside a pool: which column (0-based step) this sits in",
),
after: z.string().optional(), after: z.string().optional(),
}), }),
z.object({ z.object({
@@ -70,6 +109,58 @@ export const OperationSchema = z.discriminatedUnion("op", [
gap: z.number().optional(), gap: z.number().optional(),
after: z.string().optional(), after: z.string().optional(),
}), }),
z.object({
op: z.literal("add_pool"),
id: z.string(),
parent: z.string().optional(),
label: z.string().describe("Pool title, e.g. the process name"),
lanes: z
.array(z.string())
.describe(
"Role names, one per lane, top to bottom. Steps go in these lanes via add_box lane/col",
),
phases: z
.array(z.string())
.optional()
.describe("Milestone labels spanning the columns; omit for none"),
orientation: z
.enum(["horizontal", "vertical"])
.optional()
.describe(
"horizontal (default): lanes stack down, flow goes right",
),
gap: z.number().optional(),
after: z.string().optional(),
}),
z.object({
op: z.literal("add_sequence"),
id: z.string(),
parent: z.string().optional(),
label: z.string().describe("Diagram title; empty string for none"),
gap: z
.number()
.optional()
.describe("Horizontal spacing between participants"),
step: z
.number()
.optional()
.describe("Vertical spacing between messages"),
after: z.string().optional(),
}),
z.object({
op: z.literal("add_radial"),
id: z.string(),
parent: z.string().optional(),
label: z.string().describe("Frame title; empty string for none"),
spread: z
.enum(["radial", "down"])
.optional()
.describe(
"radial (default): branches on both sides, for a mind map. down: everything below the centre, for an org chart",
),
gap: z.number().optional(),
after: z.string().optional(),
}),
z.object({ z.object({
op: z.literal("remove"), op: z.literal("remove"),
id: z id: z
@@ -118,6 +209,12 @@ export const OperationSchema = z.discriminatedUnion("op", [
op: z.literal("unlink"), op: z.literal("unlink"),
source: z.string(), source: z.string(),
target: z.string(), target: z.string(),
step: z
.number()
.optional()
.describe(
"Remove only the edge with this step number; omit to remove every edge between the two",
),
}), }),
z.object({ z.object({
op: z.literal("set_title"), op: z.literal("set_title"),
@@ -133,6 +230,34 @@ export interface ApplyResult {
errors: string[] errors: string[]
} }
/**
* The pool cell an add operation declared, if any.
*
* `lane` alone is enough — a step in a lane with no column given goes to column 0 — so the
* cell is recorded whenever either is present rather than requiring both.
*/
function cellOf(op: { lane?: number; col?: number }): {
cell?: { lane: number; col: number }
} {
if (op.lane == null && op.col == null) return {}
return {
cell: {
lane: Math.max(0, Math.round(op.lane ?? 0)),
col: Math.max(0, Math.round(op.col ?? 0)),
},
}
}
/** Are these two nodes participants of the same sequence diagram? */
function sameSequence(tree: DiagramTree, a: string, b: string): boolean {
for (const n of walkTree(tree)) {
if (n.kind !== "sequence") continue
const ids = new Set(n.children.map((c) => c.id))
if (ids.has(a) && ids.has(b)) return true
}
return false
}
/** Insert into a child list, after a named sibling or at the end. */ /** Insert into a child list, after a named sibling or at the end. */
function insert( function insert(
list: DiagramNode[], list: DiagramNode[],
@@ -205,7 +330,10 @@ export function applyOperations(
case "add_icon": case "add_icon":
case "add_box": case "add_box":
case "add_container": case "add_container":
case "add_grid": { case "add_grid":
case "add_pool":
case "add_sequence":
case "add_radial": {
if (exists(op.id)) { if (exists(op.id)) {
errors.push(`${op.op}: id "${op.id}" is already taken`) errors.push(`${op.op}: id "${op.id}" is already taken`)
break break
@@ -222,9 +350,18 @@ export function applyOperations(
id: op.id, id: op.id,
name: op.name, name: op.name,
label: op.label ?? "", label: op.label ?? "",
...cellOf(op),
} }
else if (op.op === "add_box") else if (op.op === "add_box")
node = { kind: "box", id: op.id, label: op.label } node = {
kind: "box",
id: op.id,
label: op.label,
...(op.shape && op.shape !== "box"
? { shape: op.shape }
: {}),
...cellOf(op),
}
else if (op.op === "add_container") else if (op.op === "add_container")
node = { node = {
kind: "group", kind: "group",
@@ -235,7 +372,7 @@ export function applyOperations(
gap: op.gap ?? 20, gap: op.gap ?? 20,
children: [], children: [],
} }
else else if (op.op === "add_grid")
node = { node = {
kind: "grid", kind: "grid",
id: op.id, id: op.id,
@@ -245,6 +382,41 @@ export function applyOperations(
gap: op.gap ?? 14, gap: op.gap ?? 14,
children: [], children: [],
} }
else if (op.op === "add_pool") {
if (op.lanes.length === 0) {
errors.push(
`add_pool: "${op.id}" needs at least one lane — a swimlane diagram with no roles has nothing to divide`,
)
break
}
node = {
kind: "pool",
id: op.id,
label: op.label,
lanes: op.lanes,
phases: op.phases ?? [],
orientation: op.orientation ?? "horizontal",
gap: op.gap ?? 40,
children: [],
}
} else if (op.op === "add_sequence")
node = {
kind: "sequence",
id: op.id,
label: op.label,
gap: op.gap ?? 60,
step: Math.max(24, op.step ?? 44),
children: [],
}
else
node = {
kind: "radial",
id: op.id,
label: op.label,
spread: op.spread ?? "radial",
gap: op.gap ?? 40,
children: [],
}
insert(list, node, op.after) insert(list, node, op.after)
break break
} }
@@ -318,9 +490,13 @@ export function applyOperations(
errors.push(`set_dir: "${op.id}" is not a container`) errors.push(`set_dir: "${op.id}" is not a container`)
break break
} }
if (node.kind === "grid") { if (!isDirectional(node)) {
// A grid, pool, sequence or radial container arranges its children by its
// own rule; "row or column" is not a property they have.
errors.push( errors.push(
`set_dir: "${op.id}" is a grid — change its column count instead`, node.kind === "grid"
? `set_dir: "${op.id}" is a grid — change its column count instead`
: `set_dir: "${op.id}" is a ${node.kind}, which arranges its children by its own rule and has no row/column direction`,
) )
break break
} }
@@ -347,9 +523,16 @@ export function applyOperations(
errors.push(`link: no node with id "${op.target}"`) errors.push(`link: no node with id "${op.target}"`)
break break
} }
const dup = tree.links.some( // A second arrow between the same pair is normally a mistake — two identical
(l) => l.source === op.source && l.target === op.target, // lines drawn on top of each other — EXCEPT between two participants of a
) // sequence diagram, where a back-and-forth conversation is the whole point.
// There the messages are distinguished by their step, not by their endpoints.
const conversation = sameSequence(tree, op.source, op.target)
const dup =
!conversation &&
tree.links.some(
(l) => l.source === op.source && l.target === op.target,
)
if (dup) { if (dup) {
errors.push( errors.push(
`link: "${op.source}" → "${op.target}" already exists`, `link: "${op.source}" → "${op.target}" already exists`,
@@ -366,12 +549,22 @@ export function applyOperations(
case "unlink": { case "unlink": {
const before = tree.links.length const before = tree.links.length
// With a step given, remove only that message: two participants of a sequence
// diagram can exchange several, and dropping all of them would delete messages
// the caller did not ask about.
tree.links = tree.links.filter( tree.links = tree.links.filter(
(l) => !(l.source === op.source && l.target === op.target), (l) =>
!(
l.source === op.source &&
l.target === op.target &&
(op.step == null || l.step === op.step)
),
) )
if (tree.links.length === before) if (tree.links.length === before)
errors.push( errors.push(
`unlink: no edge from "${op.source}" to "${op.target}"`, op.step == null
? `unlink: no edge from "${op.source}" to "${op.target}"`
: `unlink: no edge from "${op.source}" to "${op.target}" with step ${op.step}`,
) )
break break
} }
@@ -393,12 +586,30 @@ export function collectNames(
for (const n of walkTree(tree)) { for (const n of walkTree(tree)) {
if (n.kind === "icon" && n.name) if (n.kind === "icon" && n.name)
out.push({ id: n.id, name: n.name, kind: "icon" }) out.push({ id: n.id, name: n.name, kind: "icon" })
else if (isContainer(n) && n.gname) else if (hasStencilFrame(n) && n.gname)
out.push({ id: n.id, name: n.gname, kind: "group" }) out.push({ id: n.id, name: n.gname, kind: "group" })
} }
return out return out
} }
/** How a container arranges its children, in one short phrase for the outline. */
function containerMeta(n: ContainerNode): string {
switch (n.kind) {
case "grid":
return `grid cols=${n.cols}`
case "pool":
return `pool lanes=[${n.lanes.join(" | ")}]${
n.phases.length ? ` phases=[${n.phases.join(" | ")}]` : ""
}${n.orientation === "vertical" ? " vertical" : ""}`
case "sequence":
return "sequence"
case "radial":
return `radial ${n.spread}`
default:
return n.dir
}
}
/** /**
* A compact text outline of the tree, for showing the model what is on the canvas. * A compact text outline of the tree, for showing the model what is on the canvas.
* *
@@ -411,16 +622,24 @@ export function outline(tree: DiagramTree): string {
if (tree.title) lines.push(`title: ${tree.title}`) if (tree.title) lines.push(`title: ${tree.title}`)
const walk = (n: DiagramNode, depth: number) => { const walk = (n: DiagramNode, depth: number) => {
const pad = " ".repeat(depth) const pad = " ".repeat(depth)
// A node inside a pool reports its cell: that is how the model knows which lane a
// step ended up in, which is exactly what it needs to move one.
const at = (x: DiagramNode) =>
(x.kind === "icon" || x.kind === "box") && x.cell
? ` @lane${x.cell.lane},col${x.cell.col}`
: ""
if (n.kind === "icon") if (n.kind === "icon")
lines.push( lines.push(
`${pad}${n.id}: icon ${n.name}${n.label ? ` "${n.label}"` : ""}`, `${pad}${n.id}: icon ${n.name}${n.label ? ` "${n.label}"` : ""}${at(n)}`,
)
else if (n.kind === "box")
lines.push(
`${pad}${n.id}: box${n.shape ? ` ${n.shape}` : ""} "${n.label}"${at(n)}`,
) )
else if (n.kind === "box") lines.push(`${pad}${n.id}: box "${n.label}"`)
else if (n.kind === "title") lines.push(`${pad}${n.id}: title`) else if (n.kind === "title") lines.push(`${pad}${n.id}: title`)
else { else {
const meta = n.kind === "grid" ? `grid cols=${n.cols}` : n.dir
lines.push( lines.push(
`${pad}${n.id}: ${meta}${n.label ? ` "${n.label}"` : " (wrapper)"}`, `${pad}${n.id}: ${containerMeta(n)}${n.label ? ` "${n.label}"` : " (wrapper)"}`,
) )
for (const c of n.children) walk(c, depth + 1) for (const c of n.children) walk(c, depth + 1)
} }

View File

@@ -25,15 +25,20 @@ import { extractDiagramXML } from "@/lib/utils"
import { import {
type Direction, type Direction,
hasMarkers, hasMarkers,
isLaneChrome,
isPinned, isPinned,
MARKER, MARKER,
type NodeKind,
readCell,
readDir, readDir,
readIntMarker, readIntMarker,
readKind, readKind,
readList,
readMarker, readMarker,
} from "./markers" } from "./markers"
import type { import type {
BoxNode, BoxNode,
BoxShape,
DiagramNode, DiagramNode,
DiagramTree, DiagramTree,
ForeignCell, ForeignCell,
@@ -41,7 +46,11 @@ import type {
GroupNode, GroupNode,
IconNode, IconNode,
LinkSpec, LinkSpec,
PoolCell,
PoolNode,
RadialNode,
Rect, Rect,
SequenceNode,
} from "./types" } from "./types"
/** Hard cap on nesting depth, matching the reference project's 50-hop guard. */ /** Hard cap on nesting depth, matching the reference project's 50-hop guard. */
@@ -337,6 +346,32 @@ function looksLikeText(style: string): boolean {
return /(?:^|;)text;/.test(style) || styleValue(style, "text") === "1" return /(?:^|;)text;/.test(style) || styleValue(style, "text") === "1"
} }
/** The flowchart outline a box is drawn with, read back from its style. */
function boxShape(style: string): BoxShape | undefined {
const shape = styleValue(style, "shape")
if (shape === "parallelogram") return "data"
if (shape === "document") return "document"
if (/(?:^|;)rhombus[;=]/.test(style) || shape === "rhombus")
return "decision"
if (styleValue(style, "rounded") === "1") {
// A stadium and a rounded rectangle differ only in arcSize; draw.io treats 50 as the
// maximum, which is what makes the ends semicircular.
const arc = Number(styleValue(style, "arcSize") ?? "0")
return arc >= 40 ? "terminator" : "round"
}
return undefined
}
/**
* The lifeline of a sequence diagram participant.
*
* One cell covers the head and the line below it, so this is the participant itself, not
* chrome to be discarded — the head's label is the participant's name.
*/
function looksLikeLifeline(style: string): boolean {
return styleValue(style, "shape") === "umlLifeline"
}
/** /**
* Classify a cell into a node kind. * Classify a cell into a node kind.
* *
@@ -359,10 +394,7 @@ function looksLikeText(style: string): boolean {
* 429 AWS + 842 Azure/GCP icons as plain boxes: a re-layout would then re-emit them as * 429 AWS + 842 Azure/GCP icons as plain boxes: a re-layout would then re-emit them as
* grey rectangles and the stencils would be gone. * grey rectangles and the stencils would be gone.
*/ */
function classify( function classify(c: RawCell, hasChildren: boolean): NodeKind {
c: RawCell,
hasChildren: boolean,
): "group" | "grid" | "icon" | "box" | "title" {
const marked = readKind(c.style) const marked = readKind(c.style)
if (marked) return marked if (marked) return marked
if (hasChildren) return "group" if (hasChildren) return "group"
@@ -642,6 +674,66 @@ function inferLayout(children: RawCell[]): LayoutGuess {
} }
} }
/**
* Put a radial container's children in order, centre first.
*
* The centre is whichever child sits closest to the container's own middle — for a mind map
* that is literally true, and for an org chart the root is horizontally centred above
* everything. Identifying it by position rather than by document order is what lets the
* user drag branches around without the layout picking a new root.
*
* The branches then read clockwise from the top for a mind map (which is how a reader scans
* one) and left to right for an org chart.
*/
function orderRadial(
kids: RawCell[],
own: Rect | null,
spread: "radial" | "down",
): RawCell[] {
const placed = kids.filter((k) => k.abs !== null)
if (placed.length < 2 || !own) return kids
const cx = own.x + own.w / 2
const cy = own.y + own.h / 2
const mid = (k: RawCell) => ({
x: (k.abs as Rect).x + (k.abs as Rect).w / 2,
y: (k.abs as Rect).y + (k.abs as Rect).h / 2,
})
const dist2 = (k: RawCell) => {
const m = mid(k)
return (m.x - cx) ** 2 + (m.y - cy) ** 2
}
// For "down", the centre is the topmost child, since everything hangs below it. Its
// horizontal position is centred but its vertical one is not, so distance to the middle
// would pick a second-generation node instead.
const centre =
spread === "down"
? placed.reduce((best, k) =>
(k.abs as Rect).y < (best.abs as Rect).y ? k : best,
)
: placed.reduce((best, k) => (dist2(k) < dist2(best) ? k : best))
const rest = kids.filter((k) => k.id !== centre.id)
if (spread === "down")
return [
centre,
...rest.sort(
(a, b) => (a.abs?.x ?? 0) - (b.abs?.x ?? 0) || a.seq - b.seq,
),
]
// Radial: the renderer puts the first half of the branches on the right and the second
// half on the left, top to bottom within each side. Reading them back in that same order
// is what keeps a round-trip stable.
const right = rest
.filter((k) => (k.abs ? mid(k).x >= cx : true))
.sort((a, b) => (a.abs?.y ?? 0) - (b.abs?.y ?? 0) || a.seq - b.seq)
const left = rest
.filter((k) => (k.abs ? mid(k).x < cx : false))
.sort((a, b) => (a.abs?.y ?? 0) - (b.abs?.y ?? 0) || a.seq - b.seq)
return [centre, ...right, ...left]
}
/** Median edge-to-edge distance between neighbours along the flow axis. */ /** Median edge-to-edge distance between neighbours along the flow axis. */
function gapsBetween(ordered: Rect[], dir: Direction): number { function gapsBetween(ordered: Rect[], dir: Direction): number {
const gaps: number[] = [] const gaps: number[] = []
@@ -829,6 +921,44 @@ export function parseDiagram(xml: string, pageIndex = 0): ParseResult {
for (const c of vertices) for (const c of vertices)
kindOf.set(c.id, classify(c, (childrenOf.get(c.id)?.length ?? 0) > 0)) kindOf.set(c.id, classify(c, (childrenOf.get(c.id)?.length ?? 0) > 0))
/**
* Collapse a pool's lane bands, lifting their contents back into the pool.
*
* The bands exist so draw.io has something to reparent into: dragging a step onto
* another role's band is how the user reassigns it, and the band's `dai_lane` marker
* says which role that is. But a band is not a node — it is re-derived from the pool's
* lane list on every layout — so on the way back its children become children of the
* pool, each carrying the lane index of the band it was found in.
*
* The lane comes from the BAND, overriding whatever `dai_cell` the node still says,
* because the band is where the user actually dropped it.
*/
const laneOverride = new Map<string, number>()
const chromeIds = new Set<string>()
for (const c of vertices) {
if (!isLaneChrome(c.style)) continue
chromeIds.add(c.id)
const lane = readIntMarker(c.style, MARKER.lane)
const kids = childrenOf.get(c.id) ?? []
const pool = parentOf.get(c.id) ?? ""
for (const k of kids) {
parentOf.set(k.id, pool)
if (lane !== null) laneOverride.set(k.id, lane)
}
childrenOf.delete(c.id)
}
if (chromeIds.size > 0) {
// Rebuild the child lists now that the bands are out of the parent chain.
childrenOf.clear()
for (const c of vertices) {
if (chromeIds.has(c.id)) continue
const p = parentOf.get(c.id) ?? ""
const list = childrenOf.get(p)
if (list) list.push(c)
else childrenOf.set(p, [c])
}
}
const foreign: ForeignCell[] = [] const foreign: ForeignCell[] = []
const accounted = new Set<string>() const accounted = new Set<string>()
/** Carry a cell through the round-trip without interpreting it. */ /** Carry a cell through the round-trip without interpreting it. */
@@ -841,6 +971,20 @@ export function parseDiagram(xml: string, pageIndex = 0): ParseResult {
let title: string | undefined let title: string | undefined
const ambiguousContainers: string[] = [] const ambiguousContainers: string[] = []
/**
* The pool cell a node occupies.
*
* The band it was found in wins over the marker on the node: the band records where the
* user dropped it, the marker records where the last layout put it. When only the marker
* exists — the node has not been dragged — that is the answer.
*/
const cellFor = (c: RawCell): PoolCell | undefined => {
const marked = readCell(c.style)
const lane = laneOverride.get(c.id)
if (lane === undefined) return marked ?? undefined
return { lane, col: marked?.col ?? 0 }
}
const build = ( const build = (
c: RawCell, c: RawCell,
depth: number, depth: number,
@@ -885,22 +1029,38 @@ export function parseDiagram(xml: string, pageIndex = 0): ParseResult {
: undefined, : undefined,
pinned, pinned,
rect, rect,
cell: cellFor(c),
} }
return node return node
} }
if (kind === "box") { if (kind === "box") {
// A lifeline's cell spans the head AND the line below it. Its natural size is the
// head; keeping the full height would make the participant box grow taller on
// every round-trip, since the next layout would add another lifeline under it.
const lifeline = looksLikeLifeline(c.style)
const headH = lifeline
? Number(styleValue(c.style, "size") ?? "44")
: undefined
const node: BoxNode = { const node: BoxNode = {
kind: "box", kind: "box",
id: c.id, id: c.id,
label: c.value, label: c.value,
w: c.geo ? Math.round(c.geo.w) : undefined, w: c.geo ? Math.round(c.geo.w) : undefined,
h: c.geo ? Math.round(c.geo.h) : undefined, h: lifeline
? Math.round(headH && headH > 0 ? headH : 44)
: c.geo
? Math.round(c.geo.h)
: undefined,
fill: styleValue(c.style, "fillColor"), fill: styleValue(c.style, "fillColor"),
stroke: styleValue(c.style, "strokeColor"), stroke: styleValue(c.style, "strokeColor"),
style: c.style, shape: boxShape(c.style),
// A lifeline's style is chrome the renderer rebuilds, so keeping it verbatim
// would re-emit a lifeline that no longer matches the new message count.
style: lifeline ? undefined : c.style,
pinned, pinned,
rect, rect,
cell: cellFor(c),
} }
return node return node
} }
@@ -913,8 +1073,93 @@ export function parseDiagram(xml: string, pageIndex = 0): ParseResult {
else kids.push(k) else kids.push(k)
} }
const markedDir = readDir(c.style)
const markedGap = readIntMarker(c.style, MARKER.gap) const markedGap = readIntMarker(c.style, MARKER.gap)
const nextPath = new Set(path).add(c.id)
// ---- the three specialised containers ----
// Each is identified only by its `dai_kind` marker: nothing about the geometry of a
// pool distinguishes it from a grid whose cells happen to be full, and guessing wrong
// would rearrange the diagram. An imported file has no marker and gets the generic
// treatment, which is the safe default.
if (kind === "pool") {
// Order by column then lane, so the outline reads in the order things happen.
const byCell = [...kids].sort((a, b) => {
const ca = readCell(a.style)
const cb = readCell(b.style)
const la = laneOverride.get(a.id) ?? ca?.lane ?? 0
const lb = laneOverride.get(b.id) ?? cb?.lane ?? 0
const d = (ca?.col ?? 0) - (cb?.col ?? 0)
return d !== 0 ? d : la !== lb ? la - lb : a.seq - b.seq
})
const node: PoolNode = {
kind: "pool",
id: c.id,
label: c.value,
lanes: readList(c.style, MARKER.lanes) ?? ["Lane 1"],
phases: readList(c.style, MARKER.phases) ?? [],
orientation:
readMarker(c.style, MARKER.orient) === "v"
? "vertical"
: "horizontal",
gap: markedGap ?? 40,
children: byCell
.map((k) => build(k, depth + 1, nextPath))
.filter((n): n is DiagramNode => n !== null),
style: c.style,
pinned,
rect,
}
return node
}
if (kind === "sequence") {
const node: SequenceNode = {
kind: "sequence",
id: c.id,
label: c.value,
gap: markedGap ?? 60,
step: Math.max(24, readIntMarker(c.style, MARKER.step) ?? 44),
// Participants read left to right — that IS their order.
children: orderChildren(kids, "row")
.map((k) => build(k, depth + 1, nextPath))
.filter((n): n is DiagramNode => n !== null),
style: c.style,
pinned,
rect,
}
return node
}
if (kind === "radial") {
const spread =
readMarker(c.style, MARKER.spread) === "down"
? "down"
: "radial"
const node: RadialNode = {
kind: "radial",
id: c.id,
label: c.value,
spread,
gap: markedGap ?? 40,
// A flat list, in the order the layout will read it. The hierarchy is in the
// arrows, so document order is all the child list has to carry — and keeping
// it means a re-layout reproduces the same picture.
children: orderRadial(kids, c.abs, spread)
.map((k) => build(k, depth + 1, nextPath))
.filter((n): n is DiagramNode => n !== null),
style: c.style,
pinned,
rect,
}
return node
}
// ---- group or grid ----
// The direction has to be inferred here rather than above, because the specialised
// containers arrange their children two-dimensionally on purpose. Running the
// inference on a pool or a radial map would warn that "no single direction describes
// this arrangement", which is true and not a problem.
const markedDir = readDir(c.style)
const markedCols = readIntMarker(c.style, MARKER.cols) const markedCols = readIntMarker(c.style, MARKER.cols)
const guess = const guess =
markedDir === null || markedGap === null || markedCols === null markedDir === null || markedGap === null || markedCols === null
@@ -925,7 +1170,6 @@ export function parseDiagram(xml: string, pageIndex = 0): ParseResult {
if (markedDir === null && guess?.ambiguous) if (markedDir === null && guess?.ambiguous)
ambiguousContainers.push(c.id) ambiguousContainers.push(c.id)
const nextPath = new Set(path).add(c.id)
const built = orderChildren(kids, dir) const built = orderChildren(kids, dir)
.map((k) => build(k, depth + 1, nextPath)) .map((k) => build(k, depth + 1, nextPath))
.filter((n): n is DiagramNode => n !== null) .filter((n): n is DiagramNode => n !== null)
@@ -971,6 +1215,14 @@ export function parseDiagram(xml: string, pageIndex = 0): ParseResult {
// A cell parented to an EDGE is that edge's label; it never belongs in the forest. // A cell parented to an EDGE is that edge's label; it never belongs in the forest.
const rootCells: RawCell[] = [] const rootCells: RawCell[] = []
for (const c of vertices) { for (const c of vertices) {
// A pool's lane bands and label strips are chrome the renderer rebuilds from the
// pool's own lane list. They are the ONE thing deliberately not carried through
// verbatim: re-emitting a stale band would leave it at the old size while the new
// bands are drawn underneath it.
if (chromeIds.has(c.id)) {
accounted.add(c.id)
continue
}
const p = parentOf.get(c.id) ?? "" const p = parentOf.get(c.id) ?? ""
if (edgeIds.has(p)) continue // handled with the edges below if (edgeIds.has(p)) continue // handled with the edges below
if (byId.has(p) && !layers.has(p)) continue // a real child if (byId.has(p) && !layers.has(p)) continue // a real child

View File

@@ -8,10 +8,36 @@
* Ported from drawio-ai-kit (MIT) — see NOTICE. * Ported from drawio-ai-kit (MIT) — see NOTICE.
*/ */
import { flatten, ICON_SIZE, layoutForest, type Placed } from "./layout" import {
import { stampContainer, stampLeaf } from "./markers" flatten,
ICON_SIZE,
LANE_LABEL,
layoutForest,
type Placed,
POOL_PAD,
poolMetrics,
sequenceMetrics,
} from "./layout"
import {
stampCell,
stampContainer,
stampLane,
stampLeaf,
stampPool,
stampPoolDecoration,
stampRadial,
stampSequence,
} from "./markers"
import { type RoutedEdge, routeEdges } from "./route" import { type RoutedEdge, routeEdges } from "./route"
import type { DiagramNode, DiagramTree, LinkSpec, Rect } from "./types" import type {
BoxShape,
DiagramNode,
DiagramTree,
LinkSpec,
PoolNode,
Rect,
SequenceNode,
} from "./types"
/** Escape the five characters that would break an XML attribute. */ /** Escape the five characters that would break an XML attribute. */
export function esc(s: string): string { export function esc(s: string): string {
@@ -38,6 +64,48 @@ const TITLE_STYLE =
const EDGE_STYLE = const EDGE_STYLE =
"edgeStyle=orthogonalEdgeStyle;html=1;rounded=0;jettySize=auto;orthogonalLoop=1;fontSize=10;fontColor=light-dark(#1B2733,#CFE0F0);strokeColor=light-dark(#1A1A1A,#E0E0E0);strokeWidth=1;" "edgeStyle=orthogonalEdgeStyle;html=1;rounded=0;jettySize=auto;orthogonalLoop=1;fontSize=10;fontColor=light-dark(#1B2733,#CFE0F0);strokeColor=light-dark(#1A1A1A,#E0E0E0);strokeWidth=1;"
/**
* Flowchart outlines, as mxGraph draws them.
*
* All six are core mxGraph shapes, not stencils from a shape library, so they render
* without the catalog and without any extra dependency. The notation is conventional: a
* reader takes a diamond to mean a branch and a stadium to mean a start or end point, so
* drawing every step as the same rectangle loses information the shape was carrying.
*/
const BOX_SHAPES: Record<BoxShape, string> = {
box: "rounded=0;",
round: "rounded=1;arcSize=12;",
/** Decision — a diamond. */
decision: "rhombus;",
/** Start or end — a stadium. draw.io draws `rounded=1` at arcSize 50 as a full stadium. */
terminator: "rounded=1;arcSize=50;",
/** Input or output — a parallelogram. */
data: "shape=parallelogram;perimeter=parallelogramPerimeter;fixedSize=1;size=14;",
/** A document or report — a rectangle with a wavy bottom edge. */
document: "shape=document;boundedLbl=1;",
}
// ---- swimlane pool chrome ----
/** Hairline between lane bands: present, but quieter than the shapes sitting on it. */
const POOL_HAIR = "#D8E0E8"
/** Alternating band tint, so a reader can follow one lane across a wide diagram. */
const POOL_BAND_ALT = "#F5F8FB"
/** Lane-name column, slightly darker than the bands so it reads as a header. */
const POOL_LABEL_FILL = "#EEF2F7"
const POOL_FILL = "#FFFFFF"
const POOL_STROKE = "#5A6B7B"
/**
* A participant head in a sequence diagram: the box at the top of a lifeline.
*
* `umlLifeline` is a core mxGraph shape whose cell covers the head AND the line below it,
* with `size` giving the head's height. Emitting head and line as one cell is what makes
* draw.io keep them together when the user drags the participant sideways.
*/
const LIFELINE_STYLE =
"shape=umlLifeline;perimeter=lifelinePerimeter;whiteSpace=wrap;html=1;container=0;collapsible=0;recursiveResize=0;outlineConnect=0;fillColor=#FFFFFF;strokeColor=#5A6B7B;fontColor=#1A1A1A;fontSize=11;fontStyle=1;"
export interface RenderOptions { export interface RenderOptions {
/** Resolves a catalog icon/group name to its verbatim draw.io style. */ /** Resolves a catalog icon/group name to its verbatim draw.io style. */
resolveStyle?: StyleResolver resolveStyle?: StyleResolver
@@ -68,14 +136,37 @@ function styleFor(n: DiagramNode, resolve: StyleResolver | undefined): string {
if (n.kind === "box") { if (n.kind === "box") {
let base = n.style ?? FALLBACK_BOX let base = n.style ?? FALLBACK_BOX
if (!n.style) { if (!n.style) {
// The outline comes first: BOX_SHAPES carries `rounded=`, which the fallback
// also sets, and appending lets the shape's value win.
if (n.shape) base += BOX_SHAPES[n.shape]
if (n.fill) base += `fillColor=${n.fill};` if (n.fill) base += `fillColor=${n.fill};`
if (n.stroke) base += `strokeColor=${n.stroke};` if (n.stroke) base += `strokeColor=${n.stroke};`
if (n.bold) base += "fontStyle=1;" if (n.bold) base += "fontStyle=1;"
} }
return stampLeaf(base, "box") const stamped = stampLeaf(base, "box")
return n.cell ? stampCell(stamped, n.cell) : stamped
} }
// container if (n.kind === "pool") {
return stampPool(n.style ?? poolFrameStyle(), {
lanes: n.lanes,
phases: n.phases,
orientation: n.orientation,
gap: n.gap,
})
}
if (n.kind === "sequence" || n.kind === "radial") {
// Both draw their own contents — lifelines, branch arrows — so the container itself
// is a frame only when the model labelled it, and invisible otherwise.
const base =
n.style ?? (n.label ? FALLBACK_FRAME : INVISIBLE_FRAME_STYLE)
return n.kind === "sequence"
? stampSequence(base, { gap: n.gap, step: n.step })
: stampRadial(base, { spread: n.spread, gap: n.gap })
}
// group or grid
const fromCatalog = n.gname ? resolve?.(n.gname, "group") : null const fromCatalog = n.gname ? resolve?.(n.gname, "group") : null
// An unlabelled frame with no stencil is a layout-only wrapper: emit a real cell so // An unlabelled frame with no stencil is a layout-only wrapper: emit a real cell so
// the structure survives a round-trip, but draw nothing. This replaces the // the structure survives a round-trip, but draw nothing. This replaces the
@@ -96,6 +187,17 @@ function styleFor(n: DiagramNode, resolve: StyleResolver | undefined): string {
}) })
} }
/** A pool's outer frame: a plain titled rectangle, since the bands supply the structure. */
function poolFrameStyle(): string {
return (
`rounded=0;whiteSpace=wrap;html=1;fillColor=${POOL_FILL};strokeColor=${POOL_STROKE};` +
`fontColor=#1A1A1A;fontSize=13;fontStyle=1;verticalAlign=top;align=left;spacingLeft=8;spacingTop=4;`
)
}
const INVISIBLE_FRAME_STYLE =
"rounded=0;whiteSpace=wrap;html=1;fillColor=none;strokeColor=none;"
/** /**
* One `<mxCell>` for a vertex, with geometry relative to its parent. * One `<mxCell>` for a vertex, with geometry relative to its parent.
* *
@@ -149,6 +251,208 @@ function vertexXml(
) )
} }
/** The lane a node declared, or 0. */
function poolLaneOf(n: DiagramNode): number {
return (n.kind === "icon" || n.kind === "box") && n.cell
? Math.max(0, n.cell.lane)
: 0
}
/**
* How many messages a sequence container has.
*
* The highest step number, or the message count when the model numbered nothing — either
* way, one row per message, so the arrows do not stack on a single y.
*/
function countMessages(
n: SequenceNode,
links: { source: string; target: string; step?: number }[],
): number {
const own = new Set(n.children.map((c) => c.id))
const mine = links.filter((l) => own.has(l.source) && own.has(l.target))
const steps = mine
.map((l) => l.step)
.filter((s): s is number => s != null && s > 0)
return Math.max(mine.length, ...(steps.length ? steps : [0]))
}
/** One chrome cell: a lane band, a label column, a milestone strip, a lifeline. */
function chromeXml(
id: string,
parent: string,
rect: Rect,
parentRect: Rect | null,
style: string,
label: string,
): string {
const ox = parentRect?.x ?? 0
const oy = parentRect?.y ?? 0
return (
`<mxCell id="${esc(id)}" value="${esc(label)}" style="${style}" vertex="1" parent="${esc(parent)}">` +
`<mxGeometry x="${Math.round(rect.x - ox)}" y="${Math.round(rect.y - oy)}"` +
` width="${Math.round(rect.w)}" height="${Math.round(rect.h)}" as="geometry"/></mxCell>`
)
}
/**
* The lane bands, role-name column and milestone strip of a swimlane pool.
*
* Emitted BEFORE the pool's children so the nodes render on top of the bands, and derived
* from the same `poolMetrics` layout used, so a band cannot end up offset from the nodes
* sitting on it.
*
* The bands are draw.io containers and the nodes are their children. That is what makes a
* user dragging a step onto another role's band record the change: draw.io rewrites the
* node's `parent` to that band, and the band's `dai_lane` marker says which lane it is.
*/
function poolChrome(
n: PoolNode,
rect: Rect,
kids: { rect: Rect }[],
): { xml: string[]; bands: { id: string; rect: Rect }[] } {
const m = poolMetrics(n, rect, kids)
const xml: string[] = []
const bands: { id: string; rect: Rect }[] = []
for (let i = 0; i < m.lanes; i++) {
const band: Rect = m.horizontal
? {
x: m.contentX,
y: m.contentY + i * m.cellH,
w: m.contentW,
h: m.cellH,
}
: {
x: rect.x + POOL_PAD + i * m.cellW,
y: m.contentY,
w: m.cellW,
h: m.contentH,
}
const tint = i % 2 ? POOL_BAND_ALT : POOL_FILL
xml.push(
chromeXml(
`${n.id}__band${i}`,
n.id,
band,
rect,
stampLane(
`rounded=0;whiteSpace=wrap;html=1;fillColor=${tint};strokeColor=${POOL_HAIR};`,
i,
),
"",
),
)
bands.push({ id: `${n.id}__band${i}`, rect: band })
// The role name, in its own column beside the band.
const label: Rect = m.horizontal
? {
x: rect.x + POOL_PAD,
y: m.contentY + i * m.cellH,
w: LANE_LABEL,
h: m.cellH,
}
: {
x: rect.x + POOL_PAD + i * m.cellW,
y: rect.y + m.header + POOL_PAD,
w: m.cellW,
h: LANE_LABEL,
}
xml.push(
chromeXml(
`${n.id}__lane${i}`,
n.id,
label,
rect,
stampPoolDecoration(
`rounded=0;whiteSpace=wrap;html=1;fillColor=${POOL_LABEL_FILL};strokeColor=${POOL_HAIR};` +
`verticalAlign=middle;align=center;fontStyle=1;fontSize=11;${m.horizontal ? "" : "horizontal=1;"}`,
),
n.lanes[i] ?? "",
),
)
}
// Milestone labels, each spanning its even share of the columns.
for (let j = 0; j < n.phases.length; j++) {
const count = n.phases.length
const from = Math.floor((j * m.cols) / count)
const to = Math.floor(((j + 1) * m.cols) / count)
const last = j === count - 1
const span = (to - from) * (m.cellW + n.gap) - (last ? n.gap : 0)
const strip: Rect = m.horizontal
? {
x: m.contentX + from * (m.cellW + n.gap),
y: rect.y + m.header,
w: Math.max(0, span),
h: m.phaseLabel,
}
: {
x: rect.x + POOL_PAD + m.contentW + n.gap,
y: m.contentY + from * (m.cellH + n.gap),
w: m.phaseLabel,
h: Math.max(
0,
(to - from) * (m.cellH + n.gap) - (last ? n.gap : 0),
),
}
xml.push(
chromeXml(
`${n.id}__phase${j}`,
n.id,
strip,
rect,
stampPoolDecoration(
`rounded=0;whiteSpace=wrap;html=1;fillColor=${POOL_FILL};strokeColor=${POOL_HAIR};` +
`verticalAlign=middle;align=center;fontStyle=1;fontSize=11;`,
),
n.phases[j] ?? "",
),
)
}
return { xml, bands }
}
/**
* The lifelines of a sequence diagram: one per participant, hanging from its head.
*
* Head and line are ONE cell, using mxGraph's `umlLifeline` shape with `size` set to the
* head's height. That is what keeps them together when the user drags a participant
* sideways — two separate cells would come apart, and the line would be left behind.
*
* The participant node itself is therefore not emitted as its own cell: this replaces it.
*/
function sequenceChrome(
n: SequenceNode,
rect: Rect,
kids: { node: DiagramNode; rect: Rect }[],
messages: number,
): { xml: string[]; replaced: Set<string> } {
const m = sequenceMetrics(n, rect, messages)
const xml: string[] = []
const replaced = new Set<string>()
for (const k of kids) {
const head = k.rect
xml.push(
chromeXml(
k.node.id,
n.id,
{
x: head.x,
y: head.y,
w: head.w,
h: Math.max(head.h, m.bottom - head.y),
},
rect,
`${LIFELINE_STYLE}size=${Math.round(head.h)};`,
"label" in k.node ? k.node.label : "",
),
)
replaced.add(k.node.id)
}
return { xml, replaced }
}
/** The label an edge renders, with its step number prefixed. */ /** The label an edge renders, with its step number prefixed. */
function edgeLabel(l: LinkSpec): string { function edgeLabel(l: LinkSpec): string {
if (l.step == null) return l.label ?? "" if (l.step == null) return l.label ?? ""
@@ -198,6 +502,54 @@ function edgeXml(l: LinkSpec, index: number, route?: RoutedEdge): string {
) )
} }
/**
* One message of a sequence diagram: a horizontal arrow between two lifelines.
*
* Written with absolute endpoints rather than terminal references, because that is the only
* way to control the HEIGHT. A message's vertical position is its position in the
* conversation; if draw.io picked it, the reading order would be whatever the geometry
* happened to give. The source and target are still recorded, so the arrow follows a
* participant the user drags sideways and the parser can read the message back.
*
* A self-message — an object calling itself — cannot be a straight line, so it steps out to
* the right and comes back one row lower.
*/
function messageXml(
l: LinkSpec,
index: number,
y: number,
rects: Map<string, Rect>,
): string {
const a = rects.get(l.source)
const b = rects.get(l.target)
const centre = (r: Rect | undefined) => (r ? r.x + r.w / 2 : 0)
const from = centre(a)
const to = centre(b)
const self = l.source === l.target
let style = l.style ?? EDGE_STYLE
if (!l.style) {
style += "endArrow=block;endFill=1;html=1;"
if (l.dashed) style += "dashed=1;"
style += "labelBackgroundColor=light-dark(#FFFFFF,#0B0F14);"
style += self ? "edgeStyle=orthogonalEdgeStyle;" : "edgeStyle=none;"
}
const id = l.id ?? `ed${index + 1}`
// A self-message loops out 40px and drops half a row, so it reads as one call and return.
const points = self
? `<Array as="points"><mxPoint x="${Math.round(from + 40)}" y="${Math.round(y)}"/>` +
`<mxPoint x="${Math.round(from + 40)}" y="${Math.round(y + 22)}"/></Array>`
: ""
const endY = self ? y + 22 : y
return (
`<mxCell id="${esc(id)}" value="${esc(edgeLabel(l))}" style="${style}" edge="1" parent="1"` +
` source="${esc(l.source)}" target="${esc(l.target)}">` +
`<mxGeometry relative="1" as="geometry">${points}` +
`<mxPoint x="${Math.round(from)}" y="${Math.round(y)}" as="sourcePoint"/>` +
`<mxPoint x="${Math.round(self ? from : to)}" y="${Math.round(endY)}" as="targetPoint"/>` +
`</mxGeometry></mxCell>`
)
}
export interface RenderResult { export interface RenderResult {
/** A complete `<mxfile>` document, ready for the editor. */ /** A complete `<mxfile>` document, ready for the editor. */
xml: string xml: string
@@ -220,6 +572,7 @@ export function renderDiagram(
const { roots, page } = layoutForest(tree.roots, { const { roots, page } = layoutForest(tree.roots, {
iconSize: opts.iconSize, iconSize: opts.iconSize,
gap: opts.rootGap, gap: opts.rootGap,
links: tree.links,
}) })
const flat = flatten(roots) const flat = flatten(roots)
@@ -241,20 +594,65 @@ export function renderDiagram(
`<mxGeometry x="0" y="24" width="${page.w}" height="30" as="geometry"/></mxCell>`, `<mxGeometry x="0" y="24" width="${page.w}" height="30" as="geometry"/></mxCell>`,
) )
// A pool's children are parented to its lane BANDS, not to the pool: that is what
// records the role assignment when the user drags a step to another lane.
const bandOf = new Map<string, Rect & { id: string }>()
// Participants a sequence container emits as lifelines instead of ordinary cells.
const asLifeline = new Set<string>()
// Message y-positions per sequence container, so its arrows can be pinned to a height.
const messageYOf = new Map<string, (step: number) => number>()
// Chrome cells, keyed by the container they belong to so they can be emitted just after
// it — a band has to exist before the node that names it as parent.
const chrome = new Map<string, string[]>()
for (const f of flat) {
const n = f.node
if (n.kind === "pool") {
const kids = n.children
.map((c) => rectById.get(c.id))
.filter((r): r is Rect => r !== undefined)
.map((rect) => ({ rect }))
const { xml, bands } = poolChrome(n, f.rect, kids)
chrome.set(n.id, xml)
for (const c of n.children) {
const band = bands[Math.min(poolLaneOf(c), bands.length - 1)]
if (band) bandOf.set(c.id, { ...band.rect, id: band.id })
}
} else if (n.kind === "sequence") {
const kids = n.children
.map((c) => ({ node: c, rect: rectById.get(c.id) }))
.filter(
(k): k is { node: DiagramNode; rect: Rect } =>
k.rect !== undefined,
)
const count = countMessages(n, tree.links)
const { xml, replaced } = sequenceChrome(n, f.rect, kids, count)
chrome.set(n.id, xml)
for (const id of replaced) asLifeline.add(id)
messageYOf.set(n.id, sequenceMetrics(n, f.rect, count).messageY)
}
}
// Parents come before children (flatten guarantees it), which draw.io requires. // Parents come before children (flatten guarantees it), which draw.io requires.
for (const f of flat) { for (const f of flat) {
// A lifeline cell already carries its participant's label and geometry.
if (asLifeline.has(f.node.id)) continue
const band = bandOf.get(f.node.id)
const parent = band?.id ?? f.parent
const parentRect = const parentRect =
f.parent === "1" ? null : (rectById.get(f.parent) ?? null) band ?? (parent === "1" ? null : (rectById.get(parent) ?? null))
cells.push( cells.push(
vertexXml( vertexXml(
f.node, f.node,
f.rect, f.rect,
f.parent, parent,
parentRect, parentRect,
opts.resolveStyle, opts.resolveStyle,
glyph, glyph,
), ),
) )
const own = chrome.get(f.node.id)
if (own) cells.push(...own)
} }
// Cells the parser could not interpret — user annotations, imported shapes — go back // Cells the parser could not interpret — user annotations, imported shapes — go back
@@ -279,12 +677,46 @@ export function renderDiagram(
drawable.push(l) drawable.push(l)
} }
// A message between two participants of the same sequence container is a horizontal
// arrow at a fixed height, so it bypasses the router entirely: there is nothing to route
// around, and the height is the message's ORDER, which a router is not allowed to move.
const seqOwner = new Map<string, string>()
for (const f of flat)
if (f.node.kind === "sequence")
for (const c of f.node.children) seqOwner.set(c.id, f.node.id)
const messages: { link: LinkSpec; index: number; y: number }[] = []
const routable: { link: LinkSpec; index: number }[] = []
// Fallback numbering is per container: a page with two sequence diagrams on it must not
// have the second one's messages continue the first one's count, which would push them
// below the bottom of their own lifelines.
const autoStep = new Map<string, number>()
for (const [i, l] of drawable.entries()) {
const owner = seqOwner.get(l.source)
const yOf =
owner && owner === seqOwner.get(l.target)
? messageYOf.get(owner)
: undefined
if (yOf && owner) {
const next = (autoStep.get(owner) ?? 0) + 1
autoStep.set(owner, next)
messages.push({ link: l, index: i, y: yOf(l.step ?? next) })
} else {
routable.push({ link: l, index: i })
}
}
// Route with the whole page in view. Only leaf shapes are obstacles: an edge from // Route with the whole page in view. Only leaf shapes are obstacles: an edge from
// outside a VPC to something inside it has to cross the VPC's border, so a container // outside a VPC to something inside it has to cross the VPC's border, so a container
// frame must not block it. // frame must not block it. Lifelines are excluded too: a message's whole job is to run
// from one lifeline to another, and every message crosses whatever lifelines lie between.
const obstacles = new Set( const obstacles = new Set(
flat flat
.filter((f) => f.node.kind === "icon" || f.node.kind === "box") .filter(
(f) =>
(f.node.kind === "icon" || f.node.kind === "box") &&
!asLifeline.has(f.node.id),
)
.map((f) => f.node.id), .map((f) => f.node.id),
) )
// Frames are passable but not free to ignore: a line that runs alongside a border, or // Frames are passable but not free to ignore: a line that runs alongside a border, or
@@ -292,12 +724,19 @@ export function renderDiagram(
// though it hits nothing. // though it hits nothing.
const frames = new Set( const frames = new Set(
flat flat
.filter((f) => f.node.kind === "group" || f.node.kind === "grid") .filter(
(f) =>
f.node.kind === "group" ||
f.node.kind === "grid" ||
f.node.kind === "pool" ||
f.node.kind === "sequence" ||
f.node.kind === "radial",
)
.map((f) => f.node.id), .map((f) => f.node.id),
) )
const routes = routeEdges( const routes = routeEdges(
drawable.map((l, i) => ({ routable.map(({ link: l, index }) => ({
id: l.id ?? `ed${i + 1}`, id: l.id ?? `ed${index + 1}`,
source: l.source, source: l.source,
target: l.target, target: l.target,
hasLabel: edgeLabel(l) !== "", hasLabel: edgeLabel(l) !== "",
@@ -306,9 +745,11 @@ export function renderDiagram(
obstacles, obstacles,
frames, frames,
) )
drawable.forEach((l, i) => { routable.forEach(({ link, index }, i) => {
cells.push(edgeXml(l, i, routes[i])) cells.push(edgeXml(link, index, routes[i]))
}) })
for (const m of messages)
cells.push(messageXml(m.link, m.index, m.y, cellById))
const model = const model =
`<mxGraphModel dx="1400" dy="900" grid="0" gridSize="10" guides="1" tooltips="1"` + `<mxGraphModel dx="1400" dy="900" grid="0" gridSize="10" guides="1" tooltips="1"` +

View File

@@ -12,6 +12,32 @@ import type { Direction } from "./markers"
export 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. */ /** A catalog icon: a real stencil, drawn at a fixed glyph size with a label below. */
export interface IconNode { export interface IconNode {
kind: "icon" kind: "icon"
@@ -27,6 +53,8 @@ export interface IconNode {
pinned?: boolean pinned?: boolean
/** Absolute geometry, when recovered from XML. Only meaningful for a pinned node. */ /** Absolute geometry, when recovered from XML. Only meaningful for a pinned node. */
rect?: Rect rect?: Rect
/** Position within a `pool` parent. Ignored elsewhere. */
cell?: PoolCell
} }
/** A plain labelled rectangle, for things the catalog has no icon for. */ /** A plain labelled rectangle, for things the catalog has no icon for. */
@@ -39,9 +67,13 @@ export interface BoxNode {
fill?: string fill?: string
stroke?: string stroke?: string
bold?: boolean bold?: boolean
/** Flowchart outline. Absent means a plain rectangle. */
shape?: BoxShape
style?: string style?: string
pinned?: boolean pinned?: boolean
rect?: Rect 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. */ /** A page title. At most one per diagram; laid out outside the tree flow. */
@@ -88,7 +120,90 @@ export interface GridNode {
rect?: Rect rect?: Rect
} }
export type ContainerNode = GroupNode | GridNode /**
* 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 LeafNode = IconNode | BoxNode | TitleNode
export type DiagramNode = ContainerNode | LeafNode export type DiagramNode = ContainerNode | LeafNode
@@ -139,13 +254,34 @@ export interface ForeignCell {
} }
export function isContainer(n: DiagramNode): n is ContainerNode { export function isContainer(n: DiagramNode): n is ContainerNode {
return n.kind === "group" || n.kind === "grid" return (
n.kind === "group" ||
n.kind === "grid" ||
n.kind === "pool" ||
n.kind === "sequence" ||
n.kind === "radial"
)
} }
export function isLeaf(n: DiagramNode): n is LeafNode { export function isLeaf(n: DiagramNode): n is LeafNode {
return !isContainer(n) return !isContainer(n)
} }
/**
* A container that can carry a catalog group stencil and a hand-set fill or stroke.
*
* The specialised containers draw their own chrome — a pool paints lane bands, a sequence
* paints lifelines — so a stencil frame or an arbitrary fill would fight what they emit.
*/
export function hasStencilFrame(n: DiagramNode): n is GroupNode | GridNode {
return n.kind === "group" || n.kind === "grid"
}
/** A container whose children stack along one axis, so `dir` is meaningful. */
export function isDirectional(n: DiagramNode): n is GroupNode {
return n.kind === "group"
}
/** Depth-first walk over a node and its descendants. */ /** Depth-first walk over a node and its descendants. */
export function* walk(n: DiagramNode): Generator<DiagramNode> { export function* walk(n: DiagramNode): Generator<DiagramNode> {
yield n yield n

View File

@@ -57,9 +57,9 @@ parameters: {
} }
---Tool5--- ---Tool5---
tool name: restructure_diagram tool name: restructure_diagram
description: Build or edit an AWS architecture diagram by declaring STRUCTURE instead of XML. You say what nests inside what; the engine computes every coordinate, size and arrow route. Containers always fit their contents and siblings never overlap. Never pass coordinates, XML or style strings. description: Build or edit a diagram by declaring STRUCTURE instead of XML. You say what nests inside what; the engine computes every coordinate, size and arrow route. Containers always fit their contents and siblings never overlap. Never pass coordinates, XML or style strings.
parameters: { parameters: {
operations: Array<Operation> // add_icon | add_box | add_container | add_grid | remove | move | set_label | set_dir | set_gap | link | unlink | set_title operations: Array<Operation> // add_icon | add_box | add_container | add_grid | add_pool | add_sequence | add_radial | remove | move | set_label | set_dir | set_gap | link | unlink | set_title
} }
---Tool6--- ---Tool6---
tool name: search_stencils tool name: search_stencils
@@ -69,17 +69,44 @@ parameters: {
kind?: "icon" | "group" kind?: "icon" | "group"
limit?: number limit?: number
} }
---Tool7---
tool name: draw_graph
description: Draw a flowchart, decision tree, dependency graph, ER diagram or site map from nodes and arrows alone. You give NO positions and NO nesting; the engine works out how many rows there are, who shares a row, and who goes left of whom, so arrows do not cross or run through unrelated boxes. Replaces the whole diagram — use restructure_diagram to edit afterwards.
parameters: {
nodes: Array<{id: string, label: string, shape?: "box"|"decision"|"terminator"|"round"|"data"|"document", icon?: string}>
edges: Array<{source: string, target: string, label?: string, dashed?: boolean}>
title?: string
flow?: "col" | "row" // col (default): top to bottom. row: left to right
}
---End of tools--- ---End of tools---
IMPORTANT: Choose the right tool: IMPORTANT: Choose the right tool. Divide by the diagram's LAYOUT SHAPE, not by which icon set it uses.
- For an AWS architecture diagram (VPC, subnets, multi-AZ, landing zone, serverless, event-driven): use search_stencils then restructure_diagram. This applies to BOTH creating and editing. Do not hand-write XML for AWS diagrams — the engine gets the layout right and costs a fraction of the tokens.
- Use display_diagram for: NON-AWS diagrams — flowcharts, BPMN, sequence diagrams, mind maps, UI mockups, org charts, ER diagrams, Azure/GCP diagrams. Use draw_graph when the diagram is boxes joined by arrows and the arrows define the order:
- Use edit_diagram for: small changes to a NON-AWS diagram. flowcharts, decision trees, process diagrams, approval flows, CI/CD pipelines, state machines,
dependency graphs, ER diagrams, site maps, data-flow diagrams.
You supply only nodes and edges. Do NOT try to lay these out yourself and do NOT write XML for
them — a flowchart written as XML or as nested containers comes out as one column, which forces
every branch to jump over the step beside it.
Use restructure_diagram when the diagram's meaning is in NESTING or in a fixed frame:
- Cloud architecture (AWS/Azure/GCP/Kubernetes): things inside things. Call search_stencils first.
- Swimlane and BPMN diagrams: add_pool with one lane per role, then add_box with lane and col.
- Sequence diagrams: add_sequence, one add_box per participant, then link with a step number.
- Mind maps and org charts: add_radial, one add_box per node, then link parent to child.
This applies to BOTH creating and editing.
Use display_diagram only for diagrams that need ABSOLUTE positioning, where the engine's layout
would be wrong rather than merely different:
UI mockups and wireframes, floor plans, circuit and P&ID diagrams, seating charts, illustrations,
Gantt charts, anything where the exact position of each element is the content.
- Use edit_diagram for: small changes to a diagram that was made with display_diagram.
- Use append_diagram for: ONLY when display_diagram was truncated due to output length - continue generating from where you stopped - Use append_diagram for: ONLY when display_diagram was truncated due to output length - continue generating from where you stopped
- Use get_shape_library for: discovering icons for a NON-AWS library, before display_diagram. - Use get_shape_library for: discovering icons for a library, before display_diagram.
Working with restructure_diagram: Working with restructure_diagram:
- Look every icon name up with search_stencils first. Batch the lookups. - Look every AWS icon name up with search_stencils first. Batch the lookups.
- Editing: send only the operations for what changes. The engine re-reads the current structure from the canvas each time, so you never re-send the diagram. Adding one service is one operation. - Editing: send only the operations for what changes. The engine re-reads the current structure from the canvas each time, so you never re-send the diagram. Adding one service is one operation.
- The tool replies with an outline of the resulting structure. Use the ids in it to name things in your next call. - The tool replies with an outline of the resulting structure. Use the ids in it to name things in your next call.
- Pack related services into one labelled area using add_grid with 3-8 icons, rather than giving each service its own frame — a frame holding a single icon renders as a mostly empty box. - Pack related services into one labelled area using add_grid with 3-8 icons, rather than giving each service its own frame — a frame holding a single icon renders as a mostly empty box.
@@ -87,6 +114,31 @@ Working with restructure_diagram:
- A container with an empty label is an invisible wrapper. Use it to group several containers along one axis without drawing another visible frame. - A container with an empty label is an invisible wrapper. Use it to group several containers along one axis without drawing another visible frame.
- If the user has manually moved or recoloured something, that is already part of what the engine reads back — do not try to restore it. - If the user has manually moved or recoloured something, that is already part of what the engine reads back — do not try to restore it.
Swimlane diagrams (add_pool):
- lanes are the roles, top to bottom. Every step goes in exactly one lane.
- Each step declares lane (which role) and col (which step of the process). Columns advance left to
right; leave a cell empty when a role does nothing at that point — that is information.
- Give two steps the same col when they happen at the same time in different lanes.
- phases is optional and labels groups of columns, e.g. ["Intake", "Review", "Decision"].
Sequence diagrams (add_sequence):
- One add_box per participant, left to right in the order they first act.
- Every message is a link with a step number. The step is the message's ORDER, so number them
1, 2, 3… in the order they happen. A reply is its own link back the other way.
- A participant calling itself is a link from a node to itself.
Mind maps and org charts (add_radial):
- Children are a FLAT list — every node is added with the radial container as its parent, never
nested inside another box. The hierarchy comes from the links.
- link from parent to child. The node nothing points at becomes the centre.
- spread: "radial" for a mind map (branches on both sides, compact). "down" for an org chart
(everything below its manager, which is the only way a reporting line reads correctly).
Flowchart box shapes, for both draw_graph and add_box:
- "decision" for a branch (a diamond), "terminator" for a start or end point, "data" for input or
output, "document" for a report, "round" for a soft-edged step. Use them: a reader takes a
diamond to mean a choice, so drawing every step as the same rectangle loses that.
Core capabilities: Core capabilities:
- Generate valid, well-formed XML strings for draw.io diagrams - Generate valid, well-formed XML strings for draw.io diagrams
- Create professional flowcharts, mind maps, entity diagrams, and technical illustrations - Create professional flowcharts, mind maps, entity diagrams, and technical illustrations

View File

@@ -0,0 +1,402 @@
/**
* The four non-architecture diagram kinds, in the real editor.
*
* The unit tests prove the engine computes the right coordinates. This proves draw.io
* accepts what it emits: that the shapes render, the labels appear, and — the part no unit
* test can check — that the styles the engine writes are ones the editor actually
* understands. A `umlLifeline` with the wrong token set parses fine and draws nothing.
*/
import { expect, test } from "@playwright/test"
import * as pako from "pako"
import { sendMessage } from "./lib/fixtures"
import { createMockToolResponse } from "./lib/helpers"
/**
* A cell's y, read from an EXPORTED document.
*
* draw.io reorders attributes when it serialises, so the export has `height` and `width`
* before `x` and `y` rather than in the order the engine wrote them. Matching a fixed order
* finds nothing, which reads as a layout failure when the layout is fine.
*/
function cellY(xml: string, id: string): number {
const cell = xml.match(
new RegExp(`<mxCell id="${id}"[\\s\\S]*?<\\/mxCell>`),
)?.[0]
const y = cell?.match(/<mxGeometry[^>]*\by="(-?[\d.]+)"/)?.[1]
expect(y, `no y geometry for "${id}"`).toBeTruthy()
return Number(y)
}
/** Undo draw.io's export compression: URI-encoded, raw-deflated, base64'd. */
function inflateDiagram(xml: string): string | null {
if (xml.includes("<mxCell")) return xml
const body = xml.match(/<diagram[^>]*>([^<]+)<\/diagram>/)?.[1]
if (!body) return null
try {
const bin = Buffer.from(body, "base64")
const out = pako.inflate(new Uint8Array(bin), { windowBits: -15 })
return decodeURIComponent(new TextDecoder("utf-8").decode(out))
} catch {
return null
}
}
/** Ask the editor for its current document rather than waiting for an autosave. */
async function exportXml(page: import("@playwright/test").Page) {
const raw = await page.evaluate(
() =>
new Promise<string | null>((resolve) => {
const iframe = document.querySelector(
"iframe",
) as HTMLIFrameElement
const onMsg = (e: MessageEvent) => {
if (typeof e.data !== "string") return
try {
const m = JSON.parse(e.data)
if (m.event === "export" && m.xml) {
window.removeEventListener("message", onMsg)
resolve(m.xml as string)
}
} catch {
/* not our message */
}
}
window.addEventListener("message", onMsg)
iframe.contentWindow?.postMessage(
JSON.stringify({
action: "export",
format: "xmlsvg",
xml: 1,
}),
"*",
)
setTimeout(() => {
window.removeEventListener("message", onMsg)
resolve(null)
}, 10000)
}),
)
expect(raw, "editor did not return the document").toBeTruthy()
const xml = inflateDiagram(raw as string)
expect(xml, "could not read the exported document").toBeTruthy()
return xml as string
}
/** Serve one mocked tool call and open the app on it. */
async function runTool(
page: import("@playwright/test").Page,
tool: string,
input: unknown,
prompt: string,
) {
await page.route("**/api/chat", async (route) => {
await route.fulfill({
status: 200,
contentType: "text/event-stream",
body: createMockToolResponse(tool, input, "Drawing it."),
})
})
await page.goto("/", { waitUntil: "networkidle" })
await page.locator("iframe").waitFor({ state: "visible", timeout: 60000 })
await page.waitForTimeout(6000)
await sendMessage(page, prompt)
return page.frameLocator("iframe")
}
test.describe("draw_graph", () => {
test("renders a decision flowchart with the right shapes", async ({
page,
}) => {
test.setTimeout(180000)
const canvas = await runTool(
page,
"draw_graph",
{
title: "Order Approval",
nodes: [
{
id: "start",
label: "Order received",
shape: "terminator",
},
{
id: "check",
label: "Amount over 1000",
shape: "decision",
},
{ id: "mgr", label: "Manager approval" },
{ id: "auto", label: "Auto approve" },
{ id: "ship", label: "Ship order" },
],
edges: [
{ source: "start", target: "check" },
{ source: "check", target: "mgr", label: "yes" },
{ source: "check", target: "auto", label: "no" },
{ source: "mgr", target: "ship" },
{ source: "auto", target: "ship" },
],
},
"Draw the order approval flow",
)
for (const label of [
"Order Approval",
"Order received",
"Amount over 1000",
"Manager approval",
"Auto approve",
"Ship order",
"yes",
])
await expect(
canvas.getByText(label, { exact: true }).first(),
`"${label}" should be on the canvas`,
).toBeVisible({ timeout: 30000 })
const xml = await exportXml(page)
// The decision is a diamond and the start is a stadium, not two plain rectangles.
expect(xml).toMatch(/id="check"[^>]*rhombus/)
expect(xml).toMatch(/id="start"[^>]*arcSize=50/)
// Both branches sit on the same row, which is the whole point of the layering.
expect(cellY(xml, "mgr")).toBe(cellY(xml, "auto"))
})
})
test.describe("swimlane pool", () => {
test("renders lane bands with each step in its own lane", async ({
page,
}) => {
test.setTimeout(180000)
const canvas = await runTool(
page,
"restructure_diagram",
{
operations: [
{
op: "add_pool",
id: "p",
label: "Expense claim",
lanes: ["Employee", "Manager", "Finance"],
phases: ["Submit", "Review", "Pay"],
},
{
op: "add_box",
id: "fill",
parent: "p",
label: "Fill form",
lane: 0,
col: 0,
},
{
op: "add_box",
id: "rev",
parent: "p",
label: "Review claim",
lane: 1,
col: 1,
},
{
op: "add_box",
id: "pay",
parent: "p",
label: "Pay out",
lane: 2,
col: 2,
},
{ op: "link", source: "fill", target: "rev" },
{ op: "link", source: "rev", target: "pay" },
],
},
"Draw the expense approval swimlane",
)
for (const label of [
"Expense claim",
"Employee",
"Manager",
"Finance",
"Submit",
"Fill form",
"Review claim",
"Pay out",
])
await expect(
canvas.getByText(label, { exact: true }).first(),
`"${label}" should be on the canvas`,
).toBeVisible({ timeout: 30000 })
const xml = await exportXml(page)
// A step is parented to its lane band, which is what records a role change when the
// user drags it to another lane.
expect(xml).toMatch(/id="fill"[^>]*parent="p__band0"/)
expect(xml).toMatch(/id="pay"[^>]*parent="p__band2"/)
expect(xml).toContain("dai_lanes=")
})
})
test.describe("sequence diagram", () => {
test("renders lifelines with messages in step order", async ({ page }) => {
test.setTimeout(180000)
const canvas = await runTool(
page,
"restructure_diagram",
{
operations: [
{ op: "add_sequence", id: "s", label: "Login flow" },
{ op: "add_box", id: "u", parent: "s", label: "User" },
{ op: "add_box", id: "api", parent: "s", label: "API" },
{ op: "add_box", id: "db", parent: "s", label: "Database" },
{
op: "link",
source: "u",
target: "api",
label: "log in",
step: 1,
},
{
op: "link",
source: "api",
target: "db",
label: "find user",
step: 2,
},
{
op: "link",
source: "db",
target: "api",
label: "record",
step: 3,
},
{
op: "link",
source: "api",
target: "u",
label: "token",
step: 4,
},
],
},
"Draw the login sequence",
)
for (const label of [
"Login flow",
"User",
"API",
"Database",
"1. log in",
"4. token",
])
await expect(
canvas.getByText(label, { exact: true }).first(),
`"${label}" should be on the canvas`,
).toBeVisible({ timeout: 30000 })
const xml = await exportXml(page)
// draw.io kept the lifeline shape — a style it did not understand would come back
// stripped or as a plain rectangle.
expect(xml).toMatch(/id="u"[^>]*shape=umlLifeline/)
expect((xml.match(/shape=umlLifeline/g) ?? []).length).toBe(3)
})
})
test.describe("mind map and org chart", () => {
test("renders a mind map with branches on both sides", async ({ page }) => {
test.setTimeout(180000)
const canvas = await runTool(
page,
"restructure_diagram",
{
operations: [
{ op: "add_radial", id: "m", label: "", spread: "radial" },
{
op: "add_box",
id: "root",
parent: "m",
label: "Product Launch",
},
{
op: "add_box",
id: "eng",
parent: "m",
label: "Engineering",
},
{
op: "add_box",
id: "api",
parent: "m",
label: "API work",
},
{
op: "add_box",
id: "mkt",
parent: "m",
label: "Marketing",
},
{ op: "add_box", id: "legal", parent: "m", label: "Legal" },
{ op: "link", source: "root", target: "eng" },
{ op: "link", source: "root", target: "mkt" },
{ op: "link", source: "root", target: "legal" },
{ op: "link", source: "eng", target: "api" },
],
},
"Draw a product launch mind map",
)
for (const label of [
"Product Launch",
"Engineering",
"API work",
"Marketing",
"Legal",
])
await expect(
canvas.getByText(label, { exact: true }).first(),
`"${label}" should be on the canvas`,
).toBeVisible({ timeout: 30000 })
const xml = await exportXml(page)
expect(xml).toContain("dai_spread=radial")
})
test("renders an org chart hanging downwards", async ({ page }) => {
test.setTimeout(180000)
const canvas = await runTool(
page,
"restructure_diagram",
{
operations: [
{ op: "add_radial", id: "o", label: "", spread: "down" },
{ op: "add_box", id: "ceo", parent: "o", label: "CEO" },
{ op: "add_box", id: "cto", parent: "o", label: "CTO" },
{
op: "add_box",
id: "lead",
parent: "o",
label: "Platform Lead",
},
{ op: "add_box", id: "cfo", parent: "o", label: "CFO" },
{ op: "link", source: "ceo", target: "cto" },
{ op: "link", source: "ceo", target: "cfo" },
{ op: "link", source: "cto", target: "lead" },
],
},
"Draw the org chart",
)
for (const label of ["CEO", "CTO", "Platform Lead", "CFO"])
await expect(
canvas.getByText(label, { exact: true }).first(),
`"${label}" should be on the canvas`,
).toBeVisible({ timeout: 30000 })
const xml = await exportXml(page)
expect(xml).toContain("dai_spread=down")
// Each level strictly below the one above — a reporting line drawn any other way
// reads as the wrong relationship. Geometry is parent-relative and all four share the
// same parent, so the values are directly comparable.
expect(cellY(xml, "ceo")).toBeLessThan(cellY(xml, "cto"))
expect(cellY(xml, "cto")).toBeLessThan(cellY(xml, "lead"))
expect(cellY(xml, "cto")).toBe(cellY(xml, "cfo"))
})
})

View File

@@ -0,0 +1,304 @@
import { describe, expect, it } from "vitest"
import {
drawGraph,
type GraphEdge,
type GraphNode,
graphToOperations,
} from "@/lib/diagram-engine"
import {
absoluteRects,
edgePaths,
nodeCollisions,
rectOf,
} from "./fixtures/geometry"
const n = (id: string, label = id): GraphNode => ({ id, label })
const e = (source: string, target: string, label?: string): GraphEdge => ({
source,
target,
...(label ? { label } : {}),
})
describe("graphToOperations: layering", () => {
it("puts a chain in one node per layer", () => {
const { layers } = graphToOperations(
[n("a"), n("b"), n("c")],
[e("a", "b"), e("b", "c")],
)
expect(layers).toEqual([["a"], ["b"], ["c"]])
})
it("puts the branches of a decision in the same layer", () => {
const { layers } = graphToOperations(
[n("q"), n("yes"), n("no")],
[e("q", "yes"), e("q", "no")],
)
expect(layers[0]).toEqual(["q"])
expect(layers[1].sort()).toEqual(["no", "yes"])
})
it("uses the LONGEST path, so no arrow points sideways", () => {
// a→b, a→c, c→b. The shortest path would put b in layer 1 beside c, leaving c→b
// pointing sideways. b has to come after c.
const { layers } = graphToOperations(
[n("a"), n("b"), n("c")],
[e("a", "b"), e("a", "c"), e("c", "b")],
)
expect(layers).toEqual([["a"], ["c"], ["b"]])
})
it("keeps a node with no arrows at all", () => {
const { layers } = graphToOperations(
[n("a"), n("b"), n("island")],
[e("a", "b")],
)
expect(layers.flat().sort()).toEqual(["a", "b", "island"])
})
it("draws a loop but does not let it set the layering", () => {
const r = graphToOperations(
[n("a"), n("b"), n("c")],
[e("a", "b"), e("b", "c"), e("c", "a")],
)
expect(r.layers).toEqual([["a"], ["b"], ["c"]])
expect(r.backEdges).toEqual([{ source: "c", target: "a" }])
// The loop is still drawn.
expect(r.operations.filter((o) => o.op === "link").length).toBe(3)
})
it("draws a self-loop and keeps it out of the layering", () => {
const r = graphToOperations(
[n("a"), n("b")],
[e("a", "b"), e("a", "a")],
)
expect(r.layers).toEqual([["a"], ["b"]])
expect(r.operations.filter((o) => o.op === "link").length).toBe(2)
})
it("reports an edge naming a node that does not exist", () => {
const r = graphToOperations([n("a")], [e("a", "ghost")])
expect(r.unknownEndpoints).toEqual(["ghost"])
expect(r.operations.filter((o) => o.op === "link")).toEqual([])
})
it("survives a graph that is nothing but a cycle", () => {
const r = graphToOperations(
[n("a"), n("b")],
[e("a", "b"), e("b", "a")],
)
expect(r.layers.flat().sort()).toEqual(["a", "b"])
})
})
describe("graphToOperations: within-layer ordering", () => {
it("reverses a layer when that removes the crossings", () => {
// a→z, b→y, c→x. Declared order would make all three cross.
const { layers } = graphToOperations(
[n("a"), n("b"), n("c"), n("x"), n("y"), n("z")],
[e("a", "z"), e("b", "y"), e("c", "x")],
)
expect(layers[0]).toEqual(["a", "b", "c"])
expect(layers[1]).toEqual(["z", "y", "x"])
})
it("leaves an already-good order alone", () => {
const { layers } = graphToOperations(
[n("a"), n("b"), n("x"), n("y")],
[e("a", "x"), e("b", "y")],
)
expect(layers[1]).toEqual(["x", "y"])
})
})
describe("graphToOperations: emitted operations", () => {
it("does not wrap a layer holding one node", () => {
const { operations } = graphToOperations(
[n("a"), n("b")],
[e("a", "b")],
)
const containers = operations.filter((o) => o.op === "add_container")
// Only the outer flow container: neither single-node layer needs a wrapper.
expect(containers.length).toBe(1)
})
it("wraps a layer holding several nodes", () => {
const { operations } = graphToOperations(
[n("q"), n("yes"), n("no")],
[e("q", "yes"), e("q", "no")],
)
const containers = operations.filter((o) => o.op === "add_container")
expect(containers.length).toBe(2)
// The layer band runs ACROSS the flow.
const band = containers.find((c) => c.id !== "__layers")
expect(band?.dir).toBe("row")
})
it("flips both axes when the flow runs left to right", () => {
const { operations } = graphToOperations(
[n("q"), n("yes"), n("no")],
[e("q", "yes"), e("q", "no")],
{ flow: "row" },
)
const containers = operations.filter((o) => o.op === "add_container")
expect(containers.find((c) => c.id === "__layers")?.dir).toBe("row")
expect(containers.find((c) => c.id !== "__layers")?.dir).toBe("col")
})
it("carries shapes and labels through", () => {
const { operations } = graphToOperations(
[
{ id: "s", label: "Start", shape: "terminator" },
{ id: "q", label: "OK?", shape: "decision" },
],
[e("s", "q", "go")],
)
const boxes = operations.filter((o) => o.op === "add_box")
expect(boxes.map((b) => b.shape)).toEqual(["terminator", "decision"])
expect(operations.find((o) => o.op === "link")?.label).toBe("go")
})
it("emits an icon node as an icon", () => {
const { operations } = graphToOperations(
[{ id: "s3", label: "Bucket", icon: "s3" }],
[],
)
const icon = operations.find((o) => o.op === "add_icon")
expect(icon).toMatchObject({ id: "s3", name: "s3", label: "Bucket" })
})
it("does not emit a plain box shape as an explicit shape", () => {
const { operations } = graphToOperations(
[{ id: "a", label: "A", shape: "box" }],
[],
)
expect(operations.find((o) => o.op === "add_box")).not.toHaveProperty(
"shape",
)
})
})
describe("drawGraph: the whole pipeline", () => {
it("draws a decision flow with no arrow hitting an unrelated box", () => {
const ids = ["start", "check", "mgr", "auto", "ship", "reject"]
const r = drawGraph(
[
{ id: "start", label: "Order received", shape: "terminator" },
{ id: "check", label: "Amount > $1000?", shape: "decision" },
n("mgr", "Manager approval"),
n("auto", "Auto-approve"),
n("ship", "Ship order"),
{ id: "reject", label: "Reject", shape: "terminator" },
],
[
e("start", "check"),
e("check", "mgr", "yes"),
e("check", "auto", "no"),
e("mgr", "ship", "approved"),
e("mgr", "reject", "denied"),
e("auto", "ship"),
],
{ title: "Order Approval" },
)
expect(r.errors).toEqual([])
const xml = r.xml as string
const rects = absoluteRects(xml)
expect(nodeCollisions(edgePaths(xml, rects), rects, ids)).toEqual([])
// Layers descend in flow order.
expect(rectOf(rects, "start").y).toBeLessThan(rectOf(rects, "check").y)
expect(rectOf(rects, "check").y).toBeLessThan(rectOf(rects, "mgr").y)
expect(rectOf(rects, "mgr").y).toBe(rectOf(rects, "auto").y)
expect(xml).toContain("Order Approval")
})
it("renders a decision as a diamond and a terminator as a stadium", () => {
const r = drawGraph(
[
{ id: "s", label: "Start", shape: "terminator" },
{ id: "q", label: "OK?", shape: "decision" },
{ id: "d", label: "Report", shape: "document" },
{ id: "i", label: "Input", shape: "data" },
],
[e("s", "q"), e("q", "d"), e("q", "i")],
)
expect(r.errors).toEqual([])
const xml = r.xml as string
expect(xml).toMatch(/id="q"[^>]*rhombus/)
expect(xml).toMatch(/id="s"[^>]*arcSize=50/)
expect(xml).toMatch(/id="d"[^>]*shape=document/)
expect(xml).toMatch(/id="i"[^>]*shape=parallelogram/)
})
it("keeps a 14-node pipeline free of arrows through boxes", () => {
const ids = [
"commit",
"lint",
"unit",
"build",
"itest",
"sec",
"stage",
"smoke",
"approve",
"prod",
"canary",
"monitor",
"alert",
"rollback",
]
const r = drawGraph(
ids.map((id) => n(id)),
[
e("commit", "lint"),
e("commit", "unit"),
e("lint", "build"),
e("unit", "build"),
e("build", "itest"),
e("build", "sec"),
e("itest", "stage"),
e("sec", "stage"),
e("stage", "smoke"),
e("smoke", "approve"),
e("approve", "prod"),
e("prod", "canary"),
e("canary", "monitor"),
e("monitor", "alert"),
e("alert", "rollback"),
e("rollback", "stage"),
],
)
expect(r.errors).toEqual([])
const rects = absoluteRects(r.xml as string)
expect(
nodeCollisions(edgePaths(r.xml as string, rects), rects, ids),
).toEqual([])
})
it("rejects an empty node list rather than drawing nothing", () => {
const r = drawGraph([], [])
expect(r.xml).toBeNull()
expect(r.errors[0]).toContain("no nodes")
})
it("rejects a duplicate id instead of silently dropping one", () => {
const r = drawGraph([n("a"), n("a")], [])
expect(r.xml).toBeNull()
expect(r.errors[0]).toContain("duplicate")
})
it("warns about an edge naming a node that is not there", () => {
const r = drawGraph([n("a")], [e("a", "ghost")])
expect(r.errors).toEqual([])
expect(r.warnings.join(" ")).toContain("ghost")
})
it("warns which arrows were treated as loops", () => {
const r = drawGraph(
[n("a"), n("b")],
[e("a", "b"), e("b", "a", "retry")],
)
expect(r.errors).toEqual([])
expect(r.warnings.join(" ")).toContain("b→a")
})
})

View File

@@ -1,13 +1,23 @@
import { describe, expect, it } from "vitest" import { describe, expect, it } from "vitest"
import { import {
hasMarkers, hasMarkers,
isLaneChrome,
isPinned, isPinned,
MARKER,
readCell,
readDir, readDir,
readIntMarker, readIntMarker,
readKind, readKind,
readList,
readMarker, readMarker,
stampCell,
stampContainer, stampContainer,
stampLane,
stampLeaf, stampLeaf,
stampPool,
stampPoolDecoration,
stampRadial,
stampSequence,
stripMarkers, stripMarkers,
} from "@/lib/diagram-engine/markers" } from "@/lib/diagram-engine/markers"
@@ -210,3 +220,127 @@ describe("hasMarkers", () => {
expect(hasMarkers("mydai_dir=row;")).toBe(false) expect(hasMarkers("mydai_dir=row;")).toBe(false)
}) })
}) })
describe("pool, sequence and radial markers", () => {
it("records a pool's lanes, phases and orientation", () => {
const s = stampPool("", {
lanes: ["Employee", "Manager"],
phases: ["Submit", "Pay"],
orientation: "horizontal",
gap: 40,
})
expect(readKind(s)).toBe("pool")
expect(readList(s, MARKER.lanes)).toEqual(["Employee", "Manager"])
expect(readList(s, MARKER.phases)).toEqual(["Submit", "Pay"])
expect(readMarker(s, MARKER.orient)).toBe("h")
expect(readIntMarker(s, MARKER.gap)).toBe(40)
})
it("marks a vertical pool", () => {
const s = stampPool("", {
lanes: ["A"],
phases: [],
orientation: "vertical",
gap: 30,
})
expect(readMarker(s, MARKER.orient)).toBe("v")
expect(readList(s, MARKER.phases)).toEqual([])
})
it("survives a lane name holding a semicolon or an equals sign", () => {
// Both delimit a draw.io style string, so an unencoded label would break the cell.
const s = stampPool("", {
lanes: ["a;b", "c=d", "e\tf"],
phases: [],
orientation: "horizontal",
gap: 10,
})
expect(readList(s, MARKER.lanes)).toEqual(["a;b", "c=d", "e\tf"])
// The style itself must still be one flat token list.
expect(s.split(";").filter((t) => t.includes("dai_lanes")).length).toBe(
1,
)
})
it("does not confuse an empty lane list with no marker at all", () => {
const s = stampPool("", {
lanes: [],
phases: [],
orientation: "horizontal",
gap: 10,
})
expect(readList(s, MARKER.lanes)).toEqual([])
expect(readList(s, "dai_absent")).toBeNull()
})
it("records a sequence's participant and message spacing", () => {
const s = stampSequence("", { gap: 60, step: 44 })
expect(readKind(s)).toBe("sequence")
expect(readIntMarker(s, MARKER.gap)).toBe(60)
expect(readIntMarker(s, MARKER.step)).toBe(44)
})
it("records how a radial container spreads its branches", () => {
expect(
readMarker(
stampRadial("", { spread: "down", gap: 40 }),
MARKER.spread,
),
).toBe("down")
expect(
readMarker(
stampRadial("", { spread: "radial", gap: 40 }),
MARKER.spread,
),
).toBe("radial")
expect(readKind(stampRadial("", { spread: "down", gap: 40 }))).toBe(
"radial",
)
})
it("records which pool cell a node sits in", () => {
expect(readCell(stampCell("", { lane: 2, col: 5 }))).toEqual({
lane: 2,
col: 5,
})
})
it("clamps a negative cell index rather than writing it", () => {
expect(readCell(stampCell("", { lane: -1, col: -3 }))).toEqual({
lane: 0,
col: 0,
})
})
it("reads no cell from a style that has none, or a malformed one", () => {
expect(readCell(VPC_STYLE)).toBeNull()
expect(readCell("dai_cell=notacell;")).toBeNull()
expect(readCell("dai_cell=1;")).toBeNull()
})
it("makes a lane band a container so a dragged step reparents into it", () => {
const s = stampLane("", 1)
expect(s).toContain("container=1")
expect(isLaneChrome(s)).toBe(true)
expect(readIntMarker(s, MARKER.lane)).toBe(1)
})
it("marks a pool's label strip as chrome that claims no lane", () => {
const s = stampPoolDecoration("")
expect(isLaneChrome(s)).toBe(true)
expect(readIntMarker(s, MARKER.lane)).toBeNull()
})
it("does not mistake an ordinary cell for pool chrome", () => {
expect(isLaneChrome(VPC_STYLE)).toBe(false)
expect(
isLaneChrome(
stampContainer(VPC_STYLE, {
kind: "group",
dir: "row",
gap: 8,
}),
),
).toBe(false)
})
})

View File

@@ -12,6 +12,7 @@ import {
type DiagramNode, type DiagramNode,
findNode, findNode,
findParent, findParent,
type GroupNode,
isContainer, isContainer,
walkTree, walkTree,
} from "@/lib/diagram-engine/types" } from "@/lib/diagram-engine/types"
@@ -117,11 +118,11 @@ describe("parseDiagram on real engine output", () => {
it("classifies AWS group stencils as containers and keeps their stencil name", () => { it("classifies AWS group stencils as containers and keeps their stencil name", () => {
const vpc = findNode(tree, "vpc") const vpc = findNode(tree, "vpc")
expect(isContainer(vpc as DiagramNode)).toBe(true) expect(isContainer(vpc as DiagramNode)).toBe(true)
expect((vpc as ContainerNode).gname).toBe("group_vpc") expect((vpc as GroupNode).gname).toBe("group_vpc")
expect((findNode(tree, "az_a") as ContainerNode).gname).toBe( expect((findNode(tree, "az_a") as GroupNode).gname).toBe(
"group_availability_zone", "group_availability_zone",
) )
expect((findNode(tree, "pub_a") as ContainerNode).gname).toBe( expect((findNode(tree, "pub_a") as GroupNode).gname).toBe(
"group_subnet", "group_subnet",
) )
}) })

View File

@@ -0,0 +1,328 @@
import { describe, expect, it } from "vitest"
import { type Operation, restructureDiagram } from "@/lib/diagram-engine"
import { parseDiagram } from "@/lib/diagram-engine/parse"
import type { PoolNode } from "@/lib/diagram-engine/types"
import { findNode } from "@/lib/diagram-engine/types"
import {
absoluteRects,
escapesParent,
outsidePage,
overlaps,
parentOf,
rectOf,
} from "./fixtures/geometry"
/** An expense-approval swimlane: three roles, five steps, three milestone labels. */
const EXPENSE: Operation[] = [
{ op: "set_title", title: "Expense Approval" },
{
op: "add_pool",
id: "p",
label: "Expense claim",
lanes: ["Employee", "Manager", "Finance"],
phases: ["Submit", "Review", "Pay"],
},
{
op: "add_box",
id: "fill",
parent: "p",
label: "Fill form",
lane: 0,
col: 0,
shape: "terminator",
},
{
op: "add_box",
id: "send",
parent: "p",
label: "Submit claim",
lane: 0,
col: 1,
},
{ op: "add_box", id: "rev", parent: "p", label: "Review", lane: 1, col: 2 },
{
op: "add_box",
id: "ok",
parent: "p",
label: "Approved?",
lane: 1,
col: 3,
shape: "decision",
},
{
op: "add_box",
id: "pay",
parent: "p",
label: "Pay out",
lane: 2,
col: 4,
},
{ op: "link", source: "fill", target: "send" },
{ op: "link", source: "send", target: "rev" },
{ op: "link", source: "rev", target: "ok" },
{ op: "link", source: "ok", target: "pay", label: "yes" },
]
const STEPS = ["fill", "send", "rev", "ok", "pay"]
describe("swimlane pool: layout", () => {
const result = restructureDiagram("", EXPENSE)
const xml = result.xml as string
const rects = absoluteRects(xml)
it("builds without errors", () => {
expect(result.errors).toEqual([])
})
it("stacks the lanes in the order they were declared", () => {
expect(rectOf(rects, "fill").y).toBeLessThan(rectOf(rects, "rev").y)
expect(rectOf(rects, "rev").y).toBeLessThan(rectOf(rects, "pay").y)
})
it("advances the columns left to right", () => {
expect(rectOf(rects, "fill").x).toBeLessThan(rectOf(rects, "send").x)
expect(rectOf(rects, "send").x).toBeLessThan(rectOf(rects, "rev").x)
expect(rectOf(rects, "rev").x).toBeLessThan(rectOf(rects, "ok").x)
})
it("puts two steps with the same column at the same x", () => {
const r = restructureDiagram("", [
{ op: "add_pool", id: "p", label: "", lanes: ["A", "B"] },
{
op: "add_box",
id: "x",
parent: "p",
label: "X",
lane: 0,
col: 1,
},
{
op: "add_box",
id: "y",
parent: "p",
label: "Y",
lane: 1,
col: 1,
},
])
const rr = absoluteRects(r.xml as string)
expect(rectOf(rr, "x").x).toBe(rectOf(rr, "y").x)
expect(rectOf(rr, "x").y).not.toBe(rectOf(rr, "y").y)
})
it("draws one band per lane, with the role names beside them", () => {
expect([...xml.matchAll(/id="p__band(\d)"/g)].map((m) => m[1])).toEqual(
["0", "1", "2"],
)
expect(
[...xml.matchAll(/id="p__lane\d" value="([^"]*)"/g)].map(
(m) => m[1],
),
).toEqual(["Employee", "Manager", "Finance"])
})
it("draws the milestone labels", () => {
expect(
[...xml.matchAll(/id="p__phase\d" value="([^"]*)"/g)].map(
(m) => m[1],
),
).toEqual(["Submit", "Review", "Pay"])
})
it("omits the milestone band when no phases were given", () => {
const r = restructureDiagram("", [
{ op: "add_pool", id: "p", label: "", lanes: ["A"] },
{
op: "add_box",
id: "x",
parent: "p",
label: "X",
lane: 0,
col: 0,
},
])
expect(r.xml).not.toContain("p__phase")
})
it("parents each step to its lane band, not to the pool", () => {
// This is what records a role change when the user drags a step to another lane:
// draw.io rewrites `parent` to the band it was dropped on.
expect(parentOf(xml, "fill")).toBe("p__band0")
expect(parentOf(xml, "rev")).toBe("p__band1")
expect(parentOf(xml, "pay")).toBe("p__band2")
})
it("keeps everything inside the page and inside its parent", () => {
expect(outsidePage(xml, ["__title"])).toEqual([])
expect(escapesParent(xml)).toEqual([])
expect(overlaps(rects, STEPS)).toEqual([])
})
it("clamps a lane index past the last lane instead of drawing off the pool", () => {
const r = restructureDiagram("", [
{ op: "add_pool", id: "p", label: "", lanes: ["only"] },
{
op: "add_box",
id: "x",
parent: "p",
label: "X",
lane: 9,
col: 0,
},
])
expect(r.errors).toEqual([])
expect(escapesParent(r.xml as string)).toEqual([])
})
it("lays a vertical pool out with the lanes as columns", () => {
const r = restructureDiagram("", [
{
op: "add_pool",
id: "p",
label: "",
lanes: ["A", "B"],
orientation: "vertical",
},
{
op: "add_box",
id: "x",
parent: "p",
label: "X",
lane: 0,
col: 0,
},
{
op: "add_box",
id: "y",
parent: "p",
label: "Y",
lane: 1,
col: 0,
},
{
op: "add_box",
id: "z",
parent: "p",
label: "Z",
lane: 0,
col: 1,
},
])
expect(r.errors).toEqual([])
const rr = absoluteRects(r.xml as string)
// Lanes side by side, the flow running downwards.
expect(rectOf(rr, "x").x).toBeLessThan(rectOf(rr, "y").x)
expect(rectOf(rr, "x").y).toBeLessThan(rectOf(rr, "z").y)
expect(escapesParent(r.xml as string)).toEqual([])
})
it("refuses a pool with no lanes", () => {
const r = restructureDiagram("", [
{ op: "add_pool", id: "p", label: "x", lanes: [] },
])
expect(r.xml).toBeNull()
expect(r.errors[0]).toContain("at least one lane")
})
})
describe("swimlane pool: round-trip", () => {
it("comes back with the same lanes, phases and cells", () => {
const first = restructureDiagram("", EXPENSE)
const { tree, warnings } = parseDiagram(first.xml as string)
expect(warnings).toEqual([])
const pool = findNode(tree, "p") as PoolNode
expect(pool.kind).toBe("pool")
expect(pool.lanes).toEqual(["Employee", "Manager", "Finance"])
expect(pool.phases).toEqual(["Submit", "Review", "Pay"])
expect(pool.orientation).toBe("horizontal")
expect(pool.children.map((c) => c.id)).toEqual(STEPS)
})
it("does not move anything on a re-layout", () => {
const first = restructureDiagram("", EXPENSE)
const second = restructureDiagram(first.xml as string, [])
expect(second.errors).toEqual([])
expect(second.warnings).toEqual([])
const a = absoluteRects(first.xml as string)
const b = absoluteRects(second.xml as string)
for (const id of STEPS) expect(b.get(id)).toEqual(a.get(id))
})
it("does not accumulate lane bands across round-trips", () => {
// The bands are chrome the renderer rebuilds. Preserving them verbatim would leave a
// stale set behind the new ones on every pass.
const first = restructureDiagram("", EXPENSE)
const second = restructureDiagram(first.xml as string, [])
const third = restructureDiagram(second.xml as string, [])
const count = (xml: string) => (xml.match(/__band\d/g) ?? []).length
expect(count(third.xml as string)).toBe(count(first.xml as string))
})
it("reads a step's new lane from the band the user dropped it on", () => {
const first = restructureDiagram("", EXPENSE)
// Simulate the drag: draw.io rewrites the cell's parent to the new band.
const moved = (first.xml as string).replace(
/(<mxCell id="pay"[^>]*parent=")p__band2(")/,
"$1p__band0$2",
)
expect(moved).not.toBe(first.xml)
const pool = findNode(parseDiagram(moved).tree, "p") as PoolNode
const pay = pool.children.find((c) => c.id === "pay")
expect(pay).toBeDefined()
// The band wins over the stale marker still on the cell.
expect((pay as { cell?: { lane: number } }).cell?.lane).toBe(0)
})
it("keeps a vertical pool vertical", () => {
const first = restructureDiagram("", [
{
op: "add_pool",
id: "p",
label: "V",
lanes: ["A", "B"],
orientation: "vertical",
},
{
op: "add_box",
id: "x",
parent: "p",
label: "X",
lane: 1,
col: 0,
},
])
const pool = findNode(
parseDiagram(first.xml as string).tree,
"p",
) as PoolNode
expect(pool.orientation).toBe("vertical")
expect(pool.lanes).toEqual(["A", "B"])
})
it("survives a lane name containing a semicolon or an equals sign", () => {
// Those two characters delimit a draw.io style string, so a naive marker would break
// the whole cell.
const first = restructureDiagram("", [
{
op: "add_pool",
id: "p",
label: "",
lanes: ["a;b", "c=d", "plain"],
},
{
op: "add_box",
id: "x",
parent: "p",
label: "X",
lane: 0,
col: 0,
},
])
expect(first.errors).toEqual([])
const pool = findNode(
parseDiagram(first.xml as string).tree,
"p",
) as PoolNode
expect(pool.lanes).toEqual(["a;b", "c=d", "plain"])
})
})

View File

@@ -0,0 +1,286 @@
import { describe, expect, it } from "vitest"
import { type Operation, restructureDiagram } from "@/lib/diagram-engine"
import { parseDiagram } from "@/lib/diagram-engine/parse"
import type { RadialNode } from "@/lib/diagram-engine/types"
import { findNode } from "@/lib/diagram-engine/types"
import {
absoluteRects,
escapesParent,
outsidePage,
overlaps,
rectOf,
} from "./fixtures/geometry"
/** A mind map. Children are a FLAT list; the links carry the hierarchy. */
const MINDMAP: Operation[] = [
{ op: "add_radial", id: "m", label: "", spread: "radial" },
{ op: "add_box", id: "root", parent: "m", label: "Product Launch" },
{ op: "add_box", id: "eng", parent: "m", label: "Engineering" },
{ op: "add_box", id: "api", parent: "m", label: "API" },
{ op: "add_box", id: "ui", parent: "m", label: "UI" },
{ op: "add_box", id: "mkt", parent: "m", label: "Marketing" },
{ op: "add_box", id: "legal", parent: "m", label: "Legal" },
{ op: "add_box", id: "ops", parent: "m", label: "Ops" },
{ op: "add_box", id: "sre", parent: "m", label: "SRE" },
{ op: "link", source: "root", target: "eng" },
{ op: "link", source: "root", target: "mkt" },
{ op: "link", source: "root", target: "legal" },
{ op: "link", source: "root", target: "ops" },
{ op: "link", source: "eng", target: "api" },
{ op: "link", source: "eng", target: "ui" },
{ op: "link", source: "ops", target: "sre" },
]
const MIND_IDS = ["root", "eng", "api", "ui", "mkt", "legal", "ops", "sre"]
const ORGCHART: Operation[] = [
{ op: "add_radial", id: "o", label: "", spread: "down" },
{ op: "add_box", id: "ceo", parent: "o", label: "CEO" },
{ op: "add_box", id: "cto", parent: "o", label: "CTO" },
{ op: "add_box", id: "eng1", parent: "o", label: "Platform Lead" },
{ op: "add_box", id: "eng2", parent: "o", label: "Mobile Lead" },
{ op: "add_box", id: "cfo", parent: "o", label: "CFO" },
{ op: "add_box", id: "acct", parent: "o", label: "Accounting" },
{ op: "link", source: "ceo", target: "cto" },
{ op: "link", source: "ceo", target: "cfo" },
{ op: "link", source: "cto", target: "eng1" },
{ op: "link", source: "cto", target: "eng2" },
{ op: "link", source: "cfo", target: "acct" },
]
const ORG_IDS = ["ceo", "cto", "eng1", "eng2", "cfo", "acct"]
describe("mind map: radial spread", () => {
const result = restructureDiagram("", MINDMAP)
const xml = result.xml as string
const rects = absoluteRects(xml)
it("builds without errors", () => {
expect(result.errors).toEqual([])
})
it("makes the node nothing points at the centre", () => {
const centre = rectOf(rects, "root")
const mid = centre.x + centre.w / 2
const sides = ["eng", "mkt", "legal", "ops"].map((id) =>
rectOf(rects, id).x > mid ? "right" : "left",
)
// Branches on both sides, which is what keeps a mind map compact.
expect(sides).toContain("right")
expect(sides).toContain("left")
})
it("puts a sub-branch further from the centre than its parent", () => {
const centre = rectOf(rects, "root")
const mid = centre.x + centre.w / 2
const far = (id: string) =>
Math.abs(rectOf(rects, id).x + rectOf(rects, id).w / 2 - mid)
expect(far("api")).toBeGreaterThan(far("eng"))
expect(far("ui")).toBeGreaterThan(far("eng"))
expect(far("sre")).toBeGreaterThan(far("ops"))
})
it("puts siblings of the same generation at the same distance out", () => {
expect(rectOf(rects, "api").x).toBe(rectOf(rects, "ui").x)
})
it("draws nothing on top of anything else", () => {
expect(overlaps(rects, MIND_IDS)).toEqual([])
})
it("keeps everything inside the page and inside the frame", () => {
expect(outsidePage(xml, ["__title"])).toEqual([])
expect(escapesParent(xml)).toEqual([])
})
it("fits a map whose two sides are different depths", () => {
// Reserving the same room on both sides would push the deeper side off the page.
const r = restructureDiagram("", [
{ op: "add_radial", id: "m", label: "", spread: "radial" },
{ op: "add_box", id: "root", parent: "m", label: "Root" },
{ op: "add_box", id: "shallow", parent: "m", label: "A" },
{ op: "add_box", id: "b", parent: "m", label: "B" },
{ op: "add_box", id: "b1", parent: "m", label: "B1" },
{ op: "add_box", id: "b2", parent: "m", label: "B2" },
{ op: "add_box", id: "b3", parent: "m", label: "B3" },
{ op: "link", source: "root", target: "shallow" },
{ op: "link", source: "root", target: "b" },
{ op: "link", source: "b", target: "b1" },
{ op: "link", source: "b1", target: "b2" },
{ op: "link", source: "b2", target: "b3" },
])
expect(r.errors).toEqual([])
expect(outsidePage(r.xml as string, ["__title"])).toEqual([])
expect(escapesParent(r.xml as string)).toEqual([])
})
it("still draws a node no arrow reaches", () => {
const r = restructureDiagram("", [
{ op: "add_radial", id: "m", label: "", spread: "radial" },
{ op: "add_box", id: "root", parent: "m", label: "Root" },
{ op: "add_box", id: "a", parent: "m", label: "A" },
{ op: "add_box", id: "orphan", parent: "m", label: "Orphan" },
{ op: "link", source: "root", target: "a" },
])
expect(r.errors).toEqual([])
const rr = absoluteRects(r.xml as string)
expect(rr.has("orphan")).toBe(true)
expect(overlaps(rr, ["root", "a", "orphan"])).toEqual([])
expect(escapesParent(r.xml as string)).toEqual([])
})
it("survives arrows that form a cycle", () => {
const r = restructureDiagram("", [
{ op: "add_radial", id: "m", label: "", spread: "radial" },
{ op: "add_box", id: "x", parent: "m", label: "X" },
{ op: "add_box", id: "y", parent: "m", label: "Y" },
{ op: "add_box", id: "z", parent: "m", label: "Z" },
{ op: "link", source: "x", target: "y" },
{ op: "link", source: "y", target: "z" },
{ op: "link", source: "z", target: "x" },
])
expect(r.errors).toEqual([])
expect(
overlaps(absoluteRects(r.xml as string), ["x", "y", "z"]),
).toEqual([])
expect(escapesParent(r.xml as string)).toEqual([])
})
it("handles a radial container holding one node", () => {
const r = restructureDiagram("", [
{ op: "add_radial", id: "m", label: "", spread: "radial" },
{ op: "add_box", id: "only", parent: "m", label: "Only" },
])
expect(r.errors).toEqual([])
expect(escapesParent(r.xml as string)).toEqual([])
})
it("handles an empty radial container", () => {
const r = restructureDiagram("", [
{ op: "add_radial", id: "m", label: "", spread: "radial" },
])
expect(r.errors).toEqual([])
expect(r.xml).toContain('id="m"')
})
})
describe("org chart: downward spread", () => {
const result = restructureDiagram("", ORGCHART)
const xml = result.xml as string
const rects = absoluteRects(xml)
it("builds without errors", () => {
expect(result.errors).toEqual([])
})
it("hangs every level strictly below the one above", () => {
// A reporting line only reads correctly downwards, which is the whole reason this
// spread exists separately from the radial one.
expect(rectOf(rects, "ceo").y).toBeLessThan(rectOf(rects, "cto").y)
expect(rectOf(rects, "cto").y).toBeLessThan(rectOf(rects, "eng1").y)
expect(rectOf(rects, "cfo").y).toBeLessThan(rectOf(rects, "acct").y)
})
it("puts peers on the same line", () => {
expect(rectOf(rects, "cto").y).toBe(rectOf(rects, "cfo").y)
expect(rectOf(rects, "eng1").y).toBe(rectOf(rects, "eng2").y)
expect(rectOf(rects, "eng1").y).toBe(rectOf(rects, "acct").y)
})
it("keeps one manager's reports clear of another's", () => {
// Sizing each slice by its whole subtree, not by the number of direct reports, is what
// stops a manager with two reports from overrunning the next manager's column.
expect(overlaps(rects, ORG_IDS)).toEqual([])
const eng2 = rectOf(rects, "eng2")
expect(eng2.x + eng2.w).toBeLessThanOrEqual(rectOf(rects, "acct").x)
})
it("keeps everything inside the page and inside the frame", () => {
expect(outsidePage(xml, ["__title"])).toEqual([])
expect(escapesParent(xml)).toEqual([])
})
it("fits a chain five levels deep", () => {
const r = restructureDiagram("", [
{ op: "add_radial", id: "o", label: "", spread: "down" },
...["a", "b", "c", "d", "e"].map(
(id) =>
({
op: "add_box",
id,
parent: "o",
label: id.toUpperCase(),
}) as Operation,
),
{ op: "link", source: "a", target: "b" },
{ op: "link", source: "b", target: "c" },
{ op: "link", source: "c", target: "d" },
{ op: "link", source: "d", target: "e" },
])
expect(r.errors).toEqual([])
const rr = absoluteRects(r.xml as string)
const ys = ["a", "b", "c", "d", "e"].map((id) => rectOf(rr, id).y)
expect(ys).toEqual([...ys].sort((x, y) => x - y))
expect(outsidePage(r.xml as string, ["__title"])).toEqual([])
})
})
describe("radial: round-trip", () => {
it("comes back as a radial container with the same spread", () => {
const first = restructureDiagram("", MINDMAP)
const { tree, warnings } = parseDiagram(first.xml as string)
expect(warnings).toEqual([])
const radial = findNode(tree, "m") as RadialNode
expect(radial.kind).toBe("radial")
expect(radial.spread).toBe("radial")
expect(radial.children.map((c) => c.id).sort()).toEqual(
[...MIND_IDS].sort(),
)
})
it("keeps an org chart pointing downwards", () => {
const first = restructureDiagram("", ORGCHART)
const radial = findNode(
parseDiagram(first.xml as string).tree,
"o",
) as RadialNode
expect(radial.spread).toBe("down")
})
it("reaches a fixed point: a mind map does not drift on re-layout", () => {
const first = restructureDiagram("", MINDMAP)
const second = restructureDiagram(first.xml as string, [])
const third = restructureDiagram(second.xml as string, [])
expect(second.errors).toEqual([])
expect(second.warnings).toEqual([])
const a = absoluteRects(first.xml as string)
const b = absoluteRects(second.xml as string)
const c = absoluteRects(third.xml as string)
for (const id of MIND_IDS) {
expect(b.get(id)).toEqual(a.get(id))
expect(c.get(id)).toEqual(a.get(id))
}
})
it("reaches a fixed point: an org chart does not drift on re-layout", () => {
const first = restructureDiagram("", ORGCHART)
const second = restructureDiagram(first.xml as string, [])
const third = restructureDiagram(second.xml as string, [])
const a = absoluteRects(first.xml as string)
const c = absoluteRects(third.xml as string)
for (const id of ORG_IDS) expect(c.get(id)).toEqual(a.get(id))
})
it("adds a branch to an existing map without redrawing it", () => {
const first = restructureDiagram("", MINDMAP)
const second = restructureDiagram(first.xml as string, [
{ op: "add_box", id: "docs", parent: "m", label: "Docs" },
{ op: "link", source: "root", target: "docs" },
])
expect(second.errors).toEqual([])
const rects = absoluteRects(second.xml as string)
expect(rects.has("docs")).toBe(true)
expect(overlaps(rects, [...MIND_IDS, "docs"])).toEqual([])
expect(escapesParent(second.xml as string)).toEqual([])
})
})

View File

@@ -60,7 +60,12 @@ function signature(t: DiagramTree): string {
const line = (n: DiagramNode, depth: number): string[] => { const line = (n: DiagramNode, depth: number): string[] => {
const pad = " ".repeat(depth) const pad = " ".repeat(depth)
if (!isContainer(n)) return [`${pad}${n.kind} ${n.id}`] if (!isContainer(n)) return [`${pad}${n.kind} ${n.id}`]
const meta = n.kind === "grid" ? `cols=${n.cols}` : `dir=${n.dir}` const meta =
n.kind === "grid"
? `cols=${n.cols}`
: n.kind === "group"
? `dir=${n.dir}`
: n.kind
return [ return [
`${pad}${n.kind} ${n.id} ${meta} gap=${n.gap}`, `${pad}${n.kind} ${n.id} ${meta} gap=${n.gap}`,
...n.children.flatMap((c) => line(c, depth + 1)), ...n.children.flatMap((c) => line(c, depth + 1)),
@@ -294,9 +299,9 @@ describe("labels and content survive", () => {
? `shape=mxgraph.aws4.group;grIcon=mxgraph.aws4.${name};fillColor=none;strokeColor=#8C4FFF;verticalAlign=top;align=left;` ? `shape=mxgraph.aws4.group;grIcon=mxgraph.aws4.${name};fillColor=none;strokeColor=#8C4FFF;verticalAlign=top;align=left;`
: null, : null,
}) })
expect( expect((findNode(parseDiagram(xml).tree, "v") as GroupNode).gname).toBe(
(findNode(parseDiagram(xml).tree, "v") as ContainerNode).gname, "group_vpc",
).toBe("group_vpc") )
}) })
}) })

View File

@@ -0,0 +1,254 @@
import { describe, expect, it } from "vitest"
import { type Operation, restructureDiagram } from "@/lib/diagram-engine"
import { parseDiagram } from "@/lib/diagram-engine/parse"
import type { SequenceNode } from "@/lib/diagram-engine/types"
import { findNode } from "@/lib/diagram-engine/types"
import {
absoluteRects,
escapesParent,
outsidePage,
rectOf,
} from "./fixtures/geometry"
const LOGIN: Operation[] = [
{ op: "add_sequence", id: "s", label: "Login flow" },
{ op: "add_box", id: "u", parent: "s", label: "User" },
{ op: "add_box", id: "web", parent: "s", label: "Web App" },
{ op: "add_box", id: "auth", parent: "s", label: "Auth Service" },
{ op: "add_box", id: "db", parent: "s", label: "Database" },
{ op: "link", source: "u", target: "web", label: "credentials", step: 1 },
{
op: "link",
source: "web",
target: "auth",
label: "POST /login",
step: 2,
},
{ op: "link", source: "auth", target: "db", label: "find user", step: 3 },
{ op: "link", source: "db", target: "auth", label: "user record", step: 4 },
{
op: "link",
source: "auth",
target: "auth",
label: "sign token",
step: 5,
},
{ op: "link", source: "auth", target: "web", label: "JWT", step: 6 },
{ op: "link", source: "web", target: "u", label: "redirect", step: 7 },
]
const PARTICIPANTS = ["u", "web", "auth", "db"]
/** Each message's y, in the order the cells were written. */
function messageYs(xml: string): { id: string; from: number; to: number }[] {
return [
...xml.matchAll(
/<mxCell id="(ed\d+)"[\s\S]*?<mxPoint x="-?[\d.]+" y="(-?[\d.]+)" as="sourcePoint"\/><mxPoint x="-?[\d.]+" y="(-?[\d.]+)" as="targetPoint"\/>/g,
),
].map((m) => ({ id: m[1], from: Number(m[2]), to: Number(m[3]) }))
}
describe("sequence diagram: layout", () => {
const result = restructureDiagram("", LOGIN)
const xml = result.xml as string
const rects = absoluteRects(xml)
it("builds without errors", () => {
expect(result.errors).toEqual([])
})
it("puts the participants in a row, in declaration order", () => {
const xs = PARTICIPANTS.map((id) => rectOf(rects, id).x)
expect(xs).toEqual([...xs].sort((a, b) => a - b))
// All the heads share a top edge.
const ys = PARTICIPANTS.map((id) => rectOf(rects, id).y)
expect(new Set(ys).size).toBe(1)
})
it("draws each participant as a lifeline, head and line in one cell", () => {
// One cell so draw.io keeps them together when the user drags the participant.
for (const id of PARTICIPANTS)
expect(xml).toMatch(
new RegExp(`id="${id}"[^>]*shape=umlLifeline[^>]*size=\\d+`),
)
})
it("makes the lifelines long enough for every message", () => {
const lowest = Math.max(...messageYs(xml).map((m) => m.to))
for (const id of PARTICIPANTS) {
const r = rects.get(id) as { y: number; h: number }
expect(r.y + r.h).toBeGreaterThan(lowest)
}
})
it("orders the messages down the page by step number", () => {
const ys = messageYs(xml)
expect(ys.length).toBe(7)
const tops = ys.map((m) => Math.min(m.from, m.to))
expect(tops).toEqual([...tops].sort((a, b) => a - b))
// No two messages on the same line.
expect(new Set(tops).size).toBe(7)
})
it("draws a message as a horizontal line between two lifelines", () => {
const ys = messageYs(xml)
// Every message except the self-call is level.
const level = ys.filter((m) => m.from === m.to)
expect(level.length).toBe(6)
})
it("steps a self-message out and back a row lower", () => {
const self = messageYs(xml).find((m) => m.from !== m.to)
expect(self).toBeDefined()
expect((self as { to: number }).to).toBeGreaterThan(
(self as { from: number }).from,
)
// Two waypoints take it out to the side and back.
expect(xml).toMatch(
/id="ed5"[\s\S]*?<Array as="points"><mxPoint[^>]*\/><mxPoint[^>]*\/><\/Array>/,
)
})
it("keeps everything inside the page and inside its parent", () => {
expect(outsidePage(xml, ["__title"])).toEqual([])
expect(escapesParent(xml)).toEqual([])
})
it("numbers unnumbered messages in declaration order", () => {
const r = restructureDiagram("", [
{ op: "add_sequence", id: "s", label: "" },
{ op: "add_box", id: "a", parent: "s", label: "A" },
{ op: "add_box", id: "b", parent: "s", label: "B" },
{ op: "link", source: "a", target: "b", label: "first" },
{ op: "link", source: "b", target: "a", label: "second" },
])
expect(r.errors).toEqual([])
const ys = messageYs(r.xml as string)
expect(ys.length).toBe(2)
expect(ys[0].from).toBeLessThan(ys[1].from)
})
it("allows several messages between the same two participants", () => {
// A back-and-forth conversation is the norm here, so the duplicate-edge guard that
// protects other diagram kinds must not apply.
const r = restructureDiagram("", [
{ op: "add_sequence", id: "s", label: "" },
{ op: "add_box", id: "a", parent: "s", label: "A" },
{ op: "add_box", id: "b", parent: "s", label: "B" },
{ op: "link", source: "a", target: "b", label: "ask", step: 1 },
{
op: "link",
source: "a",
target: "b",
label: "ask again",
step: 2,
},
])
expect(r.errors).toEqual([])
expect(messageYs(r.xml as string).length).toBe(2)
})
it("still rejects a duplicate edge outside a sequence diagram", () => {
const r = restructureDiagram("", [
{ op: "add_box", id: "a", label: "A" },
{ op: "add_box", id: "b", label: "B" },
{ op: "link", source: "a", target: "b" },
{ op: "link", source: "a", target: "b" },
])
expect(r.errors[0]).toContain("already exists")
})
it("removes one message by step, leaving the others", () => {
const r = restructureDiagram("", [
{ op: "add_sequence", id: "s", label: "" },
{ op: "add_box", id: "a", parent: "s", label: "A" },
{ op: "add_box", id: "b", parent: "s", label: "B" },
{ op: "link", source: "a", target: "b", label: "one", step: 1 },
{ op: "link", source: "a", target: "b", label: "two", step: 2 },
{ op: "unlink", source: "a", target: "b", step: 1 },
])
expect(r.errors).toEqual([])
expect(r.outline).toContain("two")
expect(r.outline).not.toContain("one")
})
it("keeps two sequence diagrams on one page independent", () => {
// The fallback numbering has to restart per container, or the second diagram's
// messages continue the first one's count and fall below its own lifelines.
const r = restructureDiagram("", [
{ op: "add_sequence", id: "s1", label: "First" },
{ op: "add_box", id: "a", parent: "s1", label: "A" },
{ op: "add_box", id: "b", parent: "s1", label: "B" },
{ op: "link", source: "a", target: "b" },
{ op: "add_sequence", id: "s2", label: "Second" },
{ op: "add_box", id: "c", parent: "s2", label: "C" },
{ op: "add_box", id: "d", parent: "s2", label: "D" },
{ op: "link", source: "c", target: "d" },
])
expect(r.errors).toEqual([])
expect(escapesParent(r.xml as string)).toEqual([])
expect(outsidePage(r.xml as string, ["__title"])).toEqual([])
})
})
describe("sequence diagram: round-trip", () => {
it("comes back as a sequence with the same participants", () => {
const first = restructureDiagram("", LOGIN)
const { tree, warnings } = parseDiagram(first.xml as string)
expect(warnings).toEqual([])
const seq = findNode(tree, "s") as SequenceNode
expect(seq.kind).toBe("sequence")
expect(seq.children.map((c) => c.id)).toEqual(PARTICIPANTS)
expect(seq.label).toBe("Login flow")
})
it("keeps every message, including the repeat pair and the self-call", () => {
const first = restructureDiagram("", LOGIN)
const { tree } = parseDiagram(first.xml as string)
expect(tree.links.length).toBe(7)
expect(
tree.links.some((l) => l.source === "auth" && l.target === "auth"),
).toBe(true)
})
it("does not move anything on a re-layout", () => {
const first = restructureDiagram("", LOGIN)
const second = restructureDiagram(first.xml as string, [])
expect(second.errors).toEqual([])
expect(second.warnings).toEqual([])
const a = absoluteRects(first.xml as string)
const b = absoluteRects(second.xml as string)
for (const id of PARTICIPANTS) expect(b.get(id)).toEqual(a.get(id))
})
it("does not let the lifelines grow on every pass", () => {
// A lifeline's cell covers the head AND the line, so reading its full height back as
// the participant's own size would make it taller each time.
const first = restructureDiagram("", LOGIN)
const second = restructureDiagram(first.xml as string, [])
const third = restructureDiagram(second.xml as string, [])
const h = (xml: string) => rectOf(absoluteRects(xml), "u").h
expect(h(second.xml as string)).toBe(h(first.xml as string))
expect(h(third.xml as string)).toBe(h(first.xml as string))
})
it("adds a participant to an existing diagram without redrawing it", () => {
const first = restructureDiagram("", LOGIN)
const second = restructureDiagram(first.xml as string, [
{ op: "add_box", id: "cache", parent: "s", label: "Cache" },
{
op: "link",
source: "auth",
target: "cache",
label: "check",
step: 8,
},
])
expect(second.errors).toEqual([])
const rects = absoluteRects(second.xml as string)
expect(rects.has("cache")).toBe(true)
// The new participant joins the row rather than landing on top of another.
expect(rectOf(rects, "cache").x).toBeGreaterThan(rectOf(rects, "db").x)
expect(escapesParent(second.xml as string)).toEqual([])
})
})

View File

@@ -0,0 +1,226 @@
/**
* Geometry checks on rendered draw.io XML.
*
* These assert what a reader would notice: an arrow running through a box that has nothing
* to do with it, two shapes drawn on top of each other, a node outside the frame that is
* supposed to contain it. Asserting on exact coordinates instead would break on every
* spacing change while still passing on a diagram that looks wrong.
*/
export interface Rect {
x: number
y: number
w: number
h: number
}
export interface Point {
x: number
y: number
}
/** A rendered edge, resolved to the points it actually passes through. */
export interface EdgePath {
id: string
source: string
target: string
label: string
points: Point[]
}
/**
* Every vertex's rectangle in PAGE coordinates.
*
* The XML stores a nested cell's geometry relative to its parent, so the offsets have to be
* added up through the parent chain. Resolved lazily and memoised, since a parent may appear
* after its child in document order.
*/
export function absoluteRects(xml: string): Map<string, Rect> {
const raw = new Map<string, Rect & { parent: string }>()
for (const m of xml.matchAll(
/<mxCell id="([^"]+)"[^>]*vertex="1" parent="([^"]+)"><mxGeometry x="(-?[\d.]+)" y="(-?[\d.]+)" width="(-?[\d.]+)" height="(-?[\d.]+)"/g,
))
raw.set(m[1], {
parent: m[2],
x: Number(m[3]),
y: Number(m[4]),
w: Number(m[5]),
h: Number(m[6]),
})
const abs = new Map<string, Rect>()
const resolve = (id: string, seen = new Set<string>()): Rect => {
const hit = abs.get(id)
if (hit) return hit
const r = raw.get(id) as Rect & { parent: string }
// A malformed cycle must not hang the test run.
const base =
r.parent === "1" || !raw.has(r.parent) || seen.has(r.parent)
? { x: 0, y: 0 }
: resolve(r.parent, new Set(seen).add(id))
const out = { x: r.x + base.x, y: r.y + base.y, w: r.w, h: r.h }
abs.set(id, out)
return out
}
for (const id of raw.keys()) resolve(id)
return abs
}
/**
* One node's rectangle, or a failure naming the id that was missing.
*
* A missing id here almost always means the renderer dropped a cell, and "cannot read
* property y of undefined" does not say which one.
*/
export function rectOf(rects: Map<string, Rect>, id: string): Rect {
const r = rects.get(id)
if (!r) throw new Error(`no cell was rendered for "${id}"`)
return r
}
/** The page size the renderer declared. */
export function pageSize(xml: string): { w: number; h: number } {
const m = xml.match(/pageWidth="(\d+)" pageHeight="(\d+)"/)
return { w: Number(m?.[1] ?? 0), h: Number(m?.[2] ?? 0) }
}
/**
* The path each edge takes: its two connection points plus any waypoints between them.
*
* A connection point is a fraction of the terminal's bounds, so it is resolved against that
* terminal's rectangle. Without one, draw.io picks the side itself and the centre is the best
* available guess.
*/
export function edgePaths(xml: string, rects: Map<string, Rect>): EdgePath[] {
const out: EdgePath[] = []
for (const m of xml.matchAll(
/<mxCell id="([^"]+)" value="([^"]*)" style="([^"]*)" edge="1"[^>]*source="([^"]+)" target="([^"]+)">([\s\S]*?)<\/mxCell>/g,
)) {
const a = rects.get(m[4])
const b = rects.get(m[5])
if (!a || !b) continue
const exit = m[3].match(/exitX=([\d.]+);exitY=([\d.]+)/)
const entry = m[3].match(/entryX=([\d.]+);entryY=([\d.]+)/)
const start = exit
? { x: a.x + Number(exit[1]) * a.w, y: a.y + Number(exit[2]) * a.h }
: { x: a.x + a.w / 2, y: a.y + a.h / 2 }
const end = entry
? {
x: b.x + Number(entry[1]) * b.w,
y: b.y + Number(entry[2]) * b.h,
}
: { x: b.x + b.w / 2, y: b.y + b.h / 2 }
const waypoints = [
...m[6].matchAll(/<mxPoint x="(-?[\d.]+)" y="(-?[\d.]+)"\/>/g),
].map((p) => ({ x: Number(p[1]), y: Number(p[2]) }))
out.push({
id: m[1],
source: m[4],
target: m[5],
label: m[2],
points: [start, ...waypoints, end],
})
}
return out
}
/**
* Edge segments that pass through a node that is neither of their endpoints.
*
* A 2px tolerance, so a line grazing a border on its way past does not count — that is the
* router deliberately hugging a shape, not an arrow drawn over it.
*/
export function nodeCollisions(
paths: EdgePath[],
rects: Map<string, Rect>,
nodeIds: string[],
): string[] {
const bad = new Set<string>()
for (const p of paths)
for (let i = 0; i + 1 < p.points.length; i++) {
const a = p.points[i]
const b = p.points[i + 1]
const lo = { x: Math.min(a.x, b.x), y: Math.min(a.y, b.y) }
const hi = { x: Math.max(a.x, b.x), y: Math.max(a.y, b.y) }
for (const id of nodeIds) {
if (id === p.source || id === p.target) continue
const r = rects.get(id)
if (!r) continue
if (
lo.x < r.x + r.w - 2 &&
hi.x > r.x + 2 &&
lo.y < r.y + r.h - 2 &&
hi.y > r.y + 2
)
bad.add(`${p.id} (${p.source}${p.target}) crosses ${id}`)
}
}
return [...bad]
}
/** Pairs of nodes drawn on top of each other. */
export function overlaps(
rects: Map<string, Rect>,
nodeIds: string[],
): string[] {
const bad: string[] = []
for (let i = 0; i < nodeIds.length; i++)
for (let j = i + 1; j < nodeIds.length; j++) {
const a = rects.get(nodeIds[i])
const b = rects.get(nodeIds[j])
if (!a || !b) continue
if (
a.x < b.x + b.w &&
b.x < a.x + a.w &&
a.y < b.y + b.h &&
b.y < a.y + a.h
)
bad.push(`${nodeIds[i]} overlaps ${nodeIds[j]}`)
}
return bad
}
/** Cells that fall outside the page the renderer declared. */
export function outsidePage(xml: string, skip: string[] = []): string[] {
const page = pageSize(xml)
const bad: string[] = []
for (const [id, r] of absoluteRects(xml)) {
if (skip.includes(id)) continue
if (r.x < 0 || r.y < 0 || r.x + r.w > page.w || r.y + r.h > page.h)
bad.push(
`${id} at ${r.x},${r.y} ${r.w}x${r.h} is outside the ${page.w}x${page.h} page`,
)
}
return bad
}
/**
* Cells that stick out of the parent they declare.
*
* 1px of slack absorbs the rounding the renderer does on each coordinate independently.
*/
export function escapesParent(xml: string): string[] {
const rects = absoluteRects(xml)
const bad = new Set<string>()
for (const m of xml.matchAll(
/<mxCell id="([^"]+)"[^>]*vertex="1" parent="([^"]+)"/g,
)) {
const child = rects.get(m[1])
const parent = rects.get(m[2])
if (!child || !parent) continue
if (
child.x < parent.x - 1 ||
child.y < parent.y - 1 ||
child.x + child.w > parent.x + parent.w + 1 ||
child.y + child.h > parent.y + parent.h + 1
)
bad.add(`${m[1]} sticks out of ${m[2]}`)
}
return [...bad]
}
/** The `parent` attribute of one cell. */
export function parentOf(xml: string, id: string): string | null {
const m = xml.match(new RegExp(`<mxCell id="${id}"[^>]*parent="([^"]+)"`))
return m ? m[1] : null
}