@dxos/diagram 0.11.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (100) hide show
  1. package/LICENSE +105 -0
  2. package/README.md +10 -0
  3. package/dist/lib/index.mjs +3975 -0
  4. package/dist/lib/index.mjs.map +1 -0
  5. package/dist/lib/testing.mjs +68 -0
  6. package/dist/lib/testing.mjs.map +1 -0
  7. package/dist/types/src/content.d.ts +60 -0
  8. package/dist/types/src/content.d.ts.map +1 -0
  9. package/dist/types/src/diagnostics.d.ts +56 -0
  10. package/dist/types/src/diagnostics.d.ts.map +1 -0
  11. package/dist/types/src/diagnostics.test.d.ts +2 -0
  12. package/dist/types/src/diagnostics.test.d.ts.map +1 -0
  13. package/dist/types/src/dialect.d.ts +19 -0
  14. package/dist/types/src/dialect.d.ts.map +1 -0
  15. package/dist/types/src/index.d.ts +16 -0
  16. package/dist/types/src/index.d.ts.map +1 -0
  17. package/dist/types/src/layout.d.ts +26 -0
  18. package/dist/types/src/layout.d.ts.map +1 -0
  19. package/dist/types/src/mermaid-engine.d.ts +83 -0
  20. package/dist/types/src/mermaid-engine.d.ts.map +1 -0
  21. package/dist/types/src/mermaid-engine.test.d.ts +2 -0
  22. package/dist/types/src/mermaid-engine.test.d.ts.map +1 -0
  23. package/dist/types/src/mermaid.d.ts +56 -0
  24. package/dist/types/src/mermaid.d.ts.map +1 -0
  25. package/dist/types/src/mermaid.test.d.ts +3 -0
  26. package/dist/types/src/mermaid.test.d.ts.map +1 -0
  27. package/dist/types/src/objective.d.ts +75 -0
  28. package/dist/types/src/objective.d.ts.map +1 -0
  29. package/dist/types/src/objective.test.d.ts +2 -0
  30. package/dist/types/src/objective.test.d.ts.map +1 -0
  31. package/dist/types/src/ortho-router.d.ts +13 -0
  32. package/dist/types/src/ortho-router.d.ts.map +1 -0
  33. package/dist/types/src/scene.d.ts +1006 -0
  34. package/dist/types/src/scene.d.ts.map +1 -0
  35. package/dist/types/src/scene.test.d.ts +2 -0
  36. package/dist/types/src/scene.test.d.ts.map +1 -0
  37. package/dist/types/src/svg-handler.d.ts +5 -0
  38. package/dist/types/src/svg-handler.d.ts.map +1 -0
  39. package/dist/types/src/svg-handler.test.d.ts +2 -0
  40. package/dist/types/src/svg-handler.test.d.ts.map +1 -0
  41. package/dist/types/src/testing.d.ts +11 -0
  42. package/dist/types/src/testing.d.ts.map +1 -0
  43. package/dist/types/src/ui.d.ts +67 -0
  44. package/dist/types/src/ui.d.ts.map +1 -0
  45. package/dist/types/src/ui.test.d.ts +2 -0
  46. package/dist/types/src/ui.test.d.ts.map +1 -0
  47. package/dist/types/src/uml-engine.d.ts +14 -0
  48. package/dist/types/src/uml-engine.d.ts.map +1 -0
  49. package/dist/types/src/uml-engine.test.d.ts +2 -0
  50. package/dist/types/src/uml-engine.test.d.ts.map +1 -0
  51. package/dist/types/src/uml-grid.d.ts +99 -0
  52. package/dist/types/src/uml-grid.d.ts.map +1 -0
  53. package/dist/types/src/uml-grid.test.d.ts +2 -0
  54. package/dist/types/src/uml-grid.test.d.ts.map +1 -0
  55. package/dist/types/src/uml-rules.d.ts +56 -0
  56. package/dist/types/src/uml-rules.d.ts.map +1 -0
  57. package/dist/types/src/uml-rules.test.d.ts +2 -0
  58. package/dist/types/src/uml-rules.test.d.ts.map +1 -0
  59. package/dist/types/src/uml-search.d.ts +26 -0
  60. package/dist/types/src/uml-search.d.ts.map +1 -0
  61. package/dist/types/src/uml-search.test.d.ts +2 -0
  62. package/dist/types/src/uml-search.test.d.ts.map +1 -0
  63. package/dist/types/src/uml.d.ts +60 -0
  64. package/dist/types/src/uml.d.ts.map +1 -0
  65. package/dist/types/src/uml.test.d.ts +2 -0
  66. package/dist/types/src/uml.test.d.ts.map +1 -0
  67. package/dist/types/tsconfig.tsbuildinfo +1 -0
  68. package/package.json +48 -0
  69. package/src/__snapshots__/diagnostics.test.ts.snap +36 -0
  70. package/src/__snapshots__/mermaid-engine.test.ts.snap +22 -0
  71. package/src/content.ts +180 -0
  72. package/src/diagnostics.test.ts +173 -0
  73. package/src/diagnostics.ts +393 -0
  74. package/src/dialect.ts +53 -0
  75. package/src/index.ts +19 -0
  76. package/src/layout.ts +99 -0
  77. package/src/mermaid-engine.test.ts +244 -0
  78. package/src/mermaid-engine.ts +863 -0
  79. package/src/mermaid.test.ts +138 -0
  80. package/src/mermaid.ts +326 -0
  81. package/src/objective.test.ts +82 -0
  82. package/src/objective.ts +157 -0
  83. package/src/ortho-router.ts +372 -0
  84. package/src/scene.test.ts +36 -0
  85. package/src/scene.ts +281 -0
  86. package/src/svg-handler.test.ts +75 -0
  87. package/src/svg-handler.ts +119 -0
  88. package/src/testing.ts +68 -0
  89. package/src/ui.test.ts +183 -0
  90. package/src/ui.ts +398 -0
  91. package/src/uml-engine.test.ts +63 -0
  92. package/src/uml-engine.ts +146 -0
  93. package/src/uml-grid.test.ts +175 -0
  94. package/src/uml-grid.ts +559 -0
  95. package/src/uml-rules.test.ts +109 -0
  96. package/src/uml-rules.ts +319 -0
  97. package/src/uml-search.test.ts +108 -0
  98. package/src/uml-search.ts +266 -0
  99. package/src/uml.test.ts +172 -0
  100. package/src/uml.ts +390 -0
@@ -0,0 +1,863 @@
1
+ //
2
+ // Copyright 2026 DXOS.org
3
+ //
4
+
5
+ //
6
+ // Engine-backed mermaid flowchart dialect. ELK lays out the graph as a compound graph (subgraphs
7
+ // are hierarchical nodes), node origins are quantized to a lattice of the cell size so neighbours
8
+ // align exactly, and connectors take the shared obstacle-avoiding router with straightened ports.
9
+ // Placement and emission are candidate generators — lattice × sibling order × inheritance bus —
10
+ // and the `Objective` picks the layout. Parsing stays in `mermaid.ts`; see `docs/DESIGN.md`.
11
+ //
12
+
13
+ import ELK, { type ElkNode } from 'elkjs/lib/elk.bundled.js';
14
+
15
+ import { invariant } from '@dxos/invariant';
16
+
17
+ import * as Diagnostics from './diagnostics.ts';
18
+ import * as Layout from './layout.ts';
19
+ import { type Direction, type MermaidEdge, type MermaidGraph, markers, parse } from './mermaid.ts';
20
+ import * as Objective from './objective.ts';
21
+ import { makeAvoidingRouter } from './ortho-router.ts';
22
+ import type * as Scene from './scene.ts';
23
+ import { GRID, type Rect, type Router, zRouter } from './uml-grid.ts';
24
+
25
+ const MIN_W = GRID * 3;
26
+ const MAX_W = GRID * 6;
27
+ const MIN_H = GRID * 2;
28
+ const PAD_X = GRID;
29
+ const PAD_Y = GRID / 2;
30
+ /** Frame inset around a subgraph's members; the top also holds the frame label. */
31
+ const FRAME_PAD = GRID;
32
+ const FRAME_LABEL_H = GRID;
33
+ /** Clear space between two frames, so packages read as separate even when their nodes are adjacent. */
34
+ const FRAME_GAP = GRID;
35
+ const GRID_FINE = GRID / 2;
36
+
37
+ const FONT = Layout.FONT_METRICS.m;
38
+ const LABEL_FONT = Layout.FONT_METRICS.s;
39
+
40
+ const ELK_DIRECTION: Record<Direction, string> = { TB: 'DOWN', BT: 'UP', LR: 'RIGHT', RL: 'LEFT' };
41
+
42
+ const snapUp = (value: number) => Math.ceil(value / GRID) * GRID;
43
+ const fineSnap = (value: number) => Math.round(value / GRID_FINE) * GRID_FINE;
44
+
45
+ /** How ELK orders siblings within a layer. */
46
+ export type Order = 'model' | 'free';
47
+
48
+ /**
49
+ * How groups relate: `layered` lays the whole hierarchy out in one flow (groups stack along it);
50
+ * `columns` lays each group out in the flow direction and arranges the groups side by side
51
+ * across it, so cross-package edges run sideways instead of stacking packages.
52
+ */
53
+ export type Arrangement = 'layered' | 'columns';
54
+
55
+ /**
56
+ * How a plain reference ranks its ends. Inheritance always ranks the base above, has-many and
57
+ * containment the owner above; a reference has no such convention, so it is a candidate axis:
58
+ * `down` — the referenced sits below, ELK's reading of an arrow; `up` — the referenced sits above,
59
+ * as the abstraction does, so a node many others point at heads its package; `free` — a reference
60
+ * ranks nothing and its ends may share a row. Between packages a reference always orders the packages.
61
+ */
62
+ export type Layering = 'down' | 'up' | 'free';
63
+
64
+ /**
65
+ * How packages sit along the flow in `columns`: `none` keeps ELK's placement; `edges` shifts each
66
+ * package by whole rows to where its cross-package edges meet their partners' rows, so those edges
67
+ * run straight instead of jogging between a short package and a tall one.
68
+ */
69
+ export type Alignment = 'none' | 'edges';
70
+
71
+ const LATTICES: readonly number[] = [1.5, 2];
72
+ const ORDERS: readonly Order[] = ['model', 'free'];
73
+ const ARRANGEMENTS: readonly Arrangement[] = ['layered', 'columns'];
74
+ const LAYERINGS: readonly Layering[] = ['down', 'up', 'free'];
75
+ const ALIGNMENTS: readonly Alignment[] = ['none', 'edges'];
76
+
77
+ /** The direction perpendicular to the flow, for arranging groups across it. */
78
+ const ACROSS: Record<Direction, string> = { TB: 'RIGHT', BT: 'RIGHT', LR: 'DOWN', RL: 'DOWN' };
79
+
80
+ export type CompileOptions = {
81
+ /** Canvas position of the diagram's top-left, in canvas px. */
82
+ origin?: Scene.Point;
83
+ /** Canvas px per scene unit. */
84
+ scale?: number;
85
+ /**
86
+ * Pitch of the placement lattice as a multiple of the cell size. Node origins snap to lattice
87
+ * points, so nodes that ELK placed roughly in line share an axis exactly and their connectors
88
+ * run straight; the gutter between neighbours is `(lattice − 1) × cell`. A list names the
89
+ * candidates the objective chooses among (default `[1.5, 2]`); a number fixes it.
90
+ */
91
+ lattice?: number | readonly number[];
92
+ /** ELK sibling ordering candidates (default both): declaration order, or free to minimize crossings. */
93
+ order?: readonly Order[];
94
+ /** Group arrangement candidates (default both when the graph has two or more groups). */
95
+ arrangement?: readonly Arrangement[];
96
+ /** How references rank their ends (default all three candidates). */
97
+ layering?: readonly Layering[];
98
+ /** Package alignment candidates along the flow, in `columns` (default both). */
99
+ alignment?: readonly Alignment[];
100
+ /**
101
+ * Inheritance bus candidates (default both): with `true`, subtypes on one row that share a base
102
+ * connect through one horizontal bus and a single triangle-headed trunk instead of parallel arrows.
103
+ */
104
+ bus?: readonly boolean[];
105
+ /** Objective that picks among candidates (default `Objective.DEFAULT`). */
106
+ objective?: Objective.Objective;
107
+ /** Maximum cell width, in scene units (default GRID × 6); longer labels wrap. */
108
+ maxWidth?: number;
109
+ /** Connector router (default: obstacle-avoiding A* with the Z-router as fallback). */
110
+ route?: Router;
111
+ };
112
+
113
+ /** One generated layout with the objective's verdict on it. */
114
+ export type Candidate = {
115
+ lattice: number;
116
+ order: Order;
117
+ arrangement: Arrangement;
118
+ layering: Layering;
119
+ alignment: Alignment;
120
+ bus: boolean;
121
+ commands: Scene.Command[];
122
+ layout: Objective.Layout;
123
+ };
124
+
125
+ export type Result = {
126
+ commands: Scene.Command[];
127
+ chosen: Objective.Ranked<Candidate>;
128
+ /** Every candidate, best first. */
129
+ ranked: readonly Objective.Ranked<Candidate>[];
130
+ };
131
+
132
+ type Cell = { w: number; h: number };
133
+ type Pitch = { x: number; y: number };
134
+
135
+ /** One cell size for every node, sized to the longest label wrapped at `maxWidth`. */
136
+ const measureCell = (graph: MermaidGraph, maxWidth: number): Cell => {
137
+ const longest = Math.max(0, ...graph.nodes.map((node) => node.label.length));
138
+ const w = snapUp(Math.min(maxWidth, Math.max(MIN_W, longest * FONT.charW + PAD_X * 2)));
139
+ const perLine = Math.max(1, Math.floor((w - PAD_X) / FONT.charW));
140
+ const lines = Math.max(1, ...graph.nodes.map((node) => Math.ceil(node.label.length / perLine)));
141
+ const h = snapUp(Math.max(MIN_H, (lines - 1) * FONT.lineH + FONT.lineH / 1.35 + PAD_Y * 2));
142
+ return { w, h };
143
+ };
144
+
145
+ /**
146
+ * Lattice pitch for a cell. With groups, the gutter between lattice rows must also hold two frame
147
+ * borders, a label band, and clear space between the frames, or adjacent frames touch or overlap
148
+ * after quantization — so that clearance is a floor on the pitch.
149
+ */
150
+ const pitchFor = (graph: MermaidGraph, cell: Cell, lattice: number): Pitch => {
151
+ const framed = graph.groups.some((group) => group.children.length > 0);
152
+ return {
153
+ x: snapUp(Math.max(cell.w * lattice, framed ? cell.w + FRAME_PAD * 2 + FRAME_GAP : 0)),
154
+ y: snapUp(Math.max(cell.h * lattice, framed ? cell.h + FRAME_PAD * 2 + FRAME_LABEL_H + FRAME_GAP : 0)),
155
+ };
156
+ };
157
+
158
+ type Placement = {
159
+ nodes: Map<string, Rect>;
160
+ frames: Map<string, Rect>;
161
+ };
162
+
163
+ /**
164
+ * Quantize ELK's positions to the lattice. Each node takes the nearest lattice point; when two
165
+ * nodes claim the same point, the later one slides along the cross axis to the nearest free slot,
166
+ * so quantization can never stack nodes.
167
+ */
168
+ const quantize = (positions: Map<string, Scene.Point>, pitch: Pitch, horizontal: boolean): Map<string, Scene.Point> => {
169
+ const taken = new Set<string>();
170
+ const result = new Map<string, Scene.Point>();
171
+ // Place in reading order so a slide pushes the later node, not an earlier one.
172
+ const ordered = [...positions.entries()].sort(([, a], [, b]) =>
173
+ horizontal ? a.x - b.x || a.y - b.y : a.y - b.y || a.x - b.x,
174
+ );
175
+ for (const [id, point] of ordered) {
176
+ const col = Math.round(point.x / pitch.x);
177
+ const row = Math.round(point.y / pitch.y);
178
+ for (let step = 0; ; step++) {
179
+ // Alternate sides so the node stays as close as possible to where ELK wanted it.
180
+ const offset = step === 0 ? 0 : (step % 2 ? 1 : -1) * Math.ceil(step / 2);
181
+ const cell = horizontal ? { col, row: row + offset } : { col: col + offset, row };
182
+ const key = `${cell.col}:${cell.row}`;
183
+ if (!taken.has(key)) {
184
+ taken.add(key);
185
+ result.set(id, { x: cell.col * pitch.x, y: cell.row * pitch.y });
186
+ break;
187
+ }
188
+ }
189
+ }
190
+ return result;
191
+ };
192
+
193
+ /**
194
+ * Close surplus gutters between groups along the cross axis. ELK spaces compound nodes by their
195
+ * own padded extents, so after quantization two groups can sit a whole lattice pitch further apart
196
+ * than the frame clearance needs while another pair sits at the minimum — the uneven gaps a reader
197
+ * notices first. Groups are walked in cross-axis order and each, with everything beyond it, is
198
+ * pulled back by whole pitches while its gap to the previous group stays above the clearance, so
199
+ * every node remains on the lattice and cross-group connectors stay straight.
200
+ */
201
+ const compactGroups = (
202
+ graph: MermaidGraph,
203
+ positions: Map<string, Scene.Point>,
204
+ cell: Cell,
205
+ pitch: Pitch,
206
+ horizontal: boolean,
207
+ ): Map<string, Scene.Point> => {
208
+ const axis = horizontal ? 'y' : 'x';
209
+ const step = horizontal ? pitch.y : pitch.x;
210
+ const size = horizontal ? cell.h : cell.w;
211
+ const groups = graph.groups
212
+ .filter((group) => group.children.some((id) => positions.has(id)))
213
+ .map((group) => {
214
+ const coords = group.children.flatMap((id) => (positions.has(id) ? [positions.get(id)![axis]] : []));
215
+ return { ids: group.children, start: Math.min(...coords), end: Math.max(...coords) };
216
+ })
217
+ .sort((left, right) => left.start - right.start);
218
+
219
+ const result = new Map(positions);
220
+ for (let index = 1; index < groups.length; index++) {
221
+ const previous = groups[index - 1];
222
+ const current = groups[index];
223
+ // Gap between the two frames: last member's far edge plus pad, to first member's near edge less pad.
224
+ const gap = current.start - FRAME_PAD - (previous.end + size + FRAME_PAD);
225
+ let surplus = Math.floor((gap - FRAME_GAP) / step) * step;
226
+ if (surplus <= 0) {
227
+ continue;
228
+ }
229
+ // Everything at or beyond this group's start shifts together, so relative order is preserved —
230
+ // but only onto free lattice points: an ungrouped node moving with the block must not land on
231
+ // a node that stays put.
232
+ const moving = [...result].filter(([, point]) => point[axis] >= current.start);
233
+ const staying = new Set(
234
+ [...result].filter(([, point]) => point[axis] < current.start).map(([, point]) => `${point.x}:${point.y}`),
235
+ );
236
+ const collides = (shift: number) =>
237
+ moving.some(([, point]) =>
238
+ staying.has(`${axis === 'x' ? point.x - shift : point.x}:${axis === 'y' ? point.y - shift : point.y}`),
239
+ );
240
+ while (surplus > 0 && collides(surplus)) {
241
+ surplus -= step;
242
+ }
243
+ if (surplus <= 0) {
244
+ continue;
245
+ }
246
+ for (const [id, point] of moving) {
247
+ result.set(id, { ...point, [axis]: point[axis] - surplus });
248
+ }
249
+ for (const later of groups.slice(index)) {
250
+ later.start -= surplus;
251
+ later.end -= surplus;
252
+ }
253
+ }
254
+ return result;
255
+ };
256
+
257
+ type ElkEdge = { id: string; sources: string[]; targets: string[] };
258
+
259
+ /**
260
+ * Edges for ELK's layering. Inheritance points at the abstraction, which ranks ABOVE its
261
+ * subtypes — so those edges are reversed, as `relationRanks` does for class diagrams; has-many
262
+ * and containment already flow owner-above-owned. In `columns`, the root lays out groups without
263
+ * seeing inside them (`SEPARATE_CHILDREN`), so every edge is lifted to its endpoints' root-level
264
+ * representatives — the group, or the node itself when ungrouped — and the groups fall into
265
+ * dependency order; edges within one group stay where they are for that group's own layout.
266
+ * A reference between packages always orders the packages; within one it ranks per `layering`.
267
+ */
268
+ const layeringEdges = (graph: MermaidGraph, arrangement: Arrangement, layering: Layering): ElkEdge[] => {
269
+ const groupOf = new Map(graph.groups.flatMap((group) => group.children.map((id) => [id, group.id] as const)));
270
+ const betweenGroups = (edge: MermaidEdge) =>
271
+ groupOf.has(edge.from) && groupOf.has(edge.to) && groupOf.get(edge.from) !== groupOf.get(edge.to);
272
+ const oriented = graph.edges.flatMap((edge) => {
273
+ if (edge.kind === 'inheritance') {
274
+ return [{ from: edge.to, to: edge.from }];
275
+ }
276
+ if (edge.kind !== 'reference' || layering === 'down' || betweenGroups(edge)) {
277
+ return [{ from: edge.from, to: edge.to }];
278
+ }
279
+ return layering === 'up' ? [{ from: edge.to, to: edge.from }] : [];
280
+ });
281
+ if (arrangement === 'layered') {
282
+ return oriented.map((edge, index) => ({ id: `edge-${index}`, sources: [edge.from], targets: [edge.to] }));
283
+ }
284
+ const edges: ElkEdge[] = [];
285
+ const seen = new Set<string>();
286
+ oriented.forEach((edge, index) => {
287
+ const from = groupOf.get(edge.from);
288
+ const to = groupOf.get(edge.to);
289
+ if (from !== undefined && from === to) {
290
+ edges.push({ id: `edge-${index}`, sources: [edge.from], targets: [edge.to] });
291
+ return;
292
+ }
293
+ const lifted = { from: from ?? edge.from, to: to ?? edge.to };
294
+ const key = `${lifted.from}->${lifted.to}`;
295
+ if (lifted.from !== lifted.to && !seen.has(key)) {
296
+ seen.add(key);
297
+ edges.push({ id: `group-edge-${index}`, sources: [lifted.from], targets: [lifted.to] });
298
+ }
299
+ });
300
+ return edges;
301
+ };
302
+
303
+ /**
304
+ * ELK compound layout. Groups become hierarchical nodes whose children ELK lays out inside
305
+ * them; positions come back parent-relative and are flattened here. Node origins are then
306
+ * quantized to the lattice and frames recomputed from their quantized members, so quantization
307
+ * can never break containment.
308
+ */
309
+ const place = async (
310
+ graph: MermaidGraph,
311
+ cell: Cell,
312
+ pitch: Pitch,
313
+ order: Order,
314
+ arrangement: Arrangement,
315
+ layering: Layering,
316
+ ): Promise<Map<string, Scene.Point>> => {
317
+ const horizontal = graph.direction === 'LR' || graph.direction === 'RL';
318
+ const grouped = new Set(graph.groups.flatMap((group) => group.children));
319
+ const leaf = (id: string): ElkNode => ({ id, width: cell.w, height: cell.h });
320
+ // Spacing is per compound node, not inherited: left unset, ELK packs a group's members at its
321
+ // 20px default — inside the router's clearance — and every route through that group is fenced.
322
+ // Matching the lattice gutters keeps ELK's output close to lattice points before quantization.
323
+ const spacing = {
324
+ 'elk.layered.spacing.nodeNodeBetweenLayers': String(horizontal ? pitch.x - cell.w : pitch.y - cell.h),
325
+ 'elk.spacing.nodeNode': String(horizontal ? pitch.y - cell.h : pitch.x - cell.w),
326
+ };
327
+ const flow = {
328
+ 'elk.algorithm': 'layered',
329
+ 'elk.direction': ELK_DIRECTION[graph.direction],
330
+ 'elk.layered.nodePlacement.strategy': 'BRANDES_KOEPF',
331
+ 'elk.layered.considerModelOrder.strategy': order === 'model' ? 'PREFER_NODES' : 'NONE',
332
+ };
333
+ const children: ElkNode[] = [
334
+ ...graph.nodes.filter((node) => !grouped.has(node.id)).map((node) => leaf(node.id)),
335
+ ...graph.groups
336
+ .filter((group) => group.children.length > 0)
337
+ .map((group) => ({
338
+ id: group.id,
339
+ layoutOptions: {
340
+ ...spacing,
341
+ // Columns: each group is its own flow; the root only arranges the groups.
342
+ ...(arrangement === 'columns' ? flow : {}),
343
+ 'elk.padding': `[top=${FRAME_PAD + FRAME_LABEL_H},left=${FRAME_PAD},bottom=${FRAME_PAD},right=${FRAME_PAD}]`,
344
+ },
345
+ children: group.children.map(leaf),
346
+ })),
347
+ ];
348
+
349
+ const elk = new ELK();
350
+ const result = await elk.layout({
351
+ id: 'root',
352
+ layoutOptions:
353
+ arrangement === 'columns'
354
+ ? {
355
+ // Groups side by side across the flow, each laid out internally first.
356
+ ...flow,
357
+ 'elk.direction': ACROSS[graph.direction],
358
+ 'elk.hierarchyHandling': 'SEPARATE_CHILDREN',
359
+ ...spacing,
360
+ }
361
+ : {
362
+ ...flow,
363
+ // Lay out the whole hierarchy in one pass so edges crossing group borders still order layers.
364
+ 'elk.hierarchyHandling': 'INCLUDE_CHILDREN',
365
+ ...spacing,
366
+ },
367
+ children,
368
+ edges: layeringEdges(graph, arrangement, layering),
369
+ });
370
+
371
+ const raw = new Map<string, Scene.Point>();
372
+ const visit = (node: ElkNode, offset: Scene.Point) => {
373
+ for (const child of node.children ?? []) {
374
+ const x = offset.x + (child.x ?? 0);
375
+ const y = offset.y + (child.y ?? 0);
376
+ if (child.children?.length) {
377
+ visit(child, { x, y });
378
+ } else {
379
+ raw.set(child.id, { x, y });
380
+ }
381
+ }
382
+ };
383
+ visit(result, { x: 0, y: 0 });
384
+
385
+ return compactGroups(graph, quantize(raw, pitch, horizontal), cell, pitch, horizontal);
386
+ };
387
+
388
+ /**
389
+ * Align each package to its cross-package edges: among the whole-row shifts that put one of its
390
+ * members on the row of that member's partner, take the one leaving its edges the least total row
391
+ * offset (nearest to no shift on a tie), onto free lattice points only. Two sweeps, since a partner
392
+ * may itself move in the first.
393
+ */
394
+ const alignGroups = (
395
+ graph: MermaidGraph,
396
+ positions: Map<string, Scene.Point>,
397
+ direction: Direction,
398
+ alignment: Alignment,
399
+ ): Map<string, Scene.Point> => {
400
+ if (alignment === 'none') {
401
+ return positions;
402
+ }
403
+ const axis = direction === 'LR' || direction === 'RL' ? 'x' : 'y';
404
+ const groupOf = new Map(graph.groups.flatMap((group) => group.children.map((id) => [id, group.id] as const)));
405
+ const result = new Map(positions);
406
+ for (let sweep = 0; sweep < 2; sweep++) {
407
+ for (const group of graph.groups) {
408
+ const members = new Set(group.children.filter((id) => result.has(id)));
409
+ // Each cross-package edge, as the member's coordinate and the partner's along the flow.
410
+ const pairs = graph.edges.flatMap((edge) => {
411
+ const [member, partner] = members.has(edge.from) ? [edge.from, edge.to] : [edge.to, edge.from];
412
+ const here = result.get(member);
413
+ const there = result.get(partner);
414
+ return members.has(member) && !members.has(partner) && groupOf.has(partner) && here && there
415
+ ? [{ here: here[axis], there: there[axis] }]
416
+ : [];
417
+ });
418
+ if (pairs.length === 0) {
419
+ continue;
420
+ }
421
+ const offset = (shift: number) => pairs.reduce((sum, pair) => sum + Math.abs(pair.here + shift - pair.there), 0);
422
+ const shifts = [...new Set([0, ...pairs.map((pair) => pair.there - pair.here)])].sort(
423
+ (left, right) => offset(left) - offset(right) || Math.abs(left) - Math.abs(right),
424
+ );
425
+ const others = new Set(
426
+ [...result].filter(([id]) => !members.has(id)).map(([, point]) => `${point.x}:${point.y}`),
427
+ );
428
+ const shift = shifts.find(
429
+ (candidate) =>
430
+ ![...members].some((id) => {
431
+ const point = result.get(id);
432
+ return (
433
+ point &&
434
+ others.has(
435
+ `${axis === 'x' ? point.x + candidate : point.x}:${axis === 'y' ? point.y + candidate : point.y}`,
436
+ )
437
+ );
438
+ }),
439
+ );
440
+ if (!shift) {
441
+ continue;
442
+ }
443
+ for (const id of members) {
444
+ const point = result.get(id);
445
+ if (point) {
446
+ result.set(id, { ...point, [axis]: point[axis] + shift });
447
+ }
448
+ }
449
+ }
450
+ }
451
+ return result;
452
+ };
453
+
454
+ /** Node rects at the origin plus the frame around each group's members. */
455
+ const frame = (graph: MermaidGraph, positions: Map<string, Scene.Point>, cell: Cell): Placement => {
456
+ const minX = Math.min(0, ...[...positions.values()].map((point) => point.x));
457
+ const minY = Math.min(0, ...[...positions.values()].map((point) => point.y));
458
+ const nodes = new Map<string, Rect>(
459
+ [...positions].map(([id, point]) => [id, { x: point.x - minX, y: point.y - minY, w: cell.w, h: cell.h }]),
460
+ );
461
+
462
+ const frames = new Map<string, Rect>();
463
+ for (const group of graph.groups) {
464
+ const members = group.children.flatMap((id) => nodes.get(id) ?? []);
465
+ if (members.length === 0) {
466
+ continue;
467
+ }
468
+ const x = Math.min(...members.map((rect) => rect.x)) - FRAME_PAD;
469
+ const y = Math.min(...members.map((rect) => rect.y)) - FRAME_PAD - FRAME_LABEL_H;
470
+ const right = Math.max(...members.map((rect) => rect.x + rect.w)) + FRAME_PAD;
471
+ const bottom = Math.max(...members.map((rect) => rect.y + rect.h)) + FRAME_PAD;
472
+ frames.set(group.id, { x, y, w: right - x, h: bottom - y });
473
+ }
474
+
475
+ return { nodes, frames };
476
+ };
477
+
478
+ type Ports = { start?: number; end?: number };
479
+
480
+ /**
481
+ * Straightened ports: when an edge's nodes overlap on the cross axis, both terminals take one
482
+ * shared coordinate inside the overlap, so the connector is a single straight run instead of a
483
+ * jog between nearly aligned nodes. Distinct edges leaving the same node side take distinct
484
+ * coordinates. Mirrors the straightening pass in the UML emitter.
485
+ */
486
+ const straighten = (
487
+ edges: readonly MermaidEdge[],
488
+ nodes: Map<string, Rect>,
489
+ isHorizontal: (edge: MermaidEdge) => boolean,
490
+ ): Map<MermaidEdge, Ports> => {
491
+ const ports = new Map<MermaidEdge, Ports>();
492
+ const taken = new Map<string, number[]>();
493
+ for (const edge of edges) {
494
+ const from = nodes.get(edge.from);
495
+ const to = nodes.get(edge.to);
496
+ if (!from || !to) {
497
+ continue;
498
+ }
499
+ const horizontal = isHorizontal(edge);
500
+ const sameLane = horizontal ? from.x === to.x : from.y === to.y;
501
+ const alongY = horizontal ? sameLane : !sameLane;
502
+ const lo = (alongY ? Math.max(from.x, to.x) : Math.max(from.y, to.y)) + GRID_FINE;
503
+ const hi = (alongY ? Math.min(from.x + from.w, to.x + to.w) : Math.min(from.y + from.h, to.y + to.h)) - GRID_FINE;
504
+ if (lo > hi) {
505
+ continue;
506
+ }
507
+ const sides = alongY
508
+ ? [`${edge.from}:${to.y >= from.y ? 'bottom' : 'top'}`, `${edge.to}:${to.y >= from.y ? 'top' : 'bottom'}`]
509
+ : [`${edge.from}:${to.x >= from.x ? 'right' : 'left'}`, `${edge.to}:${to.x >= from.x ? 'left' : 'right'}`];
510
+ const center = alongY ? (from.x + from.w / 2 + to.x + to.w / 2) / 2 : (from.y + from.h / 2 + to.y + to.h / 2) / 2;
511
+ const base = Math.min(hi, Math.max(lo, fineSnap(center)));
512
+ const isFree = (coord: number) =>
513
+ sides.every((side) => (taken.get(side) ?? []).every((used) => Math.abs(used - coord) >= GRID_FINE));
514
+ let chosen: number | undefined;
515
+ for (let step = 0; step <= Math.ceil((hi - lo) / GRID_FINE) && chosen === undefined; step++) {
516
+ for (const candidate of step === 0 ? [base] : [base - step * GRID_FINE, base + step * GRID_FINE]) {
517
+ if (candidate >= lo && candidate <= hi && isFree(candidate)) {
518
+ chosen = candidate;
519
+ break;
520
+ }
521
+ }
522
+ }
523
+ if (chosen === undefined) {
524
+ continue;
525
+ }
526
+ ports.set(edge, { start: chosen, end: chosen });
527
+ for (const side of sides) {
528
+ taken.set(side, [...(taken.get(side) ?? []), chosen]);
529
+ }
530
+ }
531
+ return ports;
532
+ };
533
+
534
+ /**
535
+ * Inheritance bus (TB only): subtypes of one base that sit on one row above which the base sits
536
+ * connect through a shared horizontal bus and a single triangle-headed trunk, the UML tree
537
+ * connector. Returns the elements and the edges it consumed; edges that do not qualify are left
538
+ * to the per-edge router.
539
+ */
540
+ const inheritanceBuses = (
541
+ edges: readonly MermaidEdge[],
542
+ nodes: Map<string, Rect>,
543
+ ): { elements: Scene.Element[]; consumed: Set<MermaidEdge> } => {
544
+ const elements: Scene.Element[] = [];
545
+ const consumed = new Set<MermaidEdge>();
546
+ const byBase = new Map<string, MermaidEdge[]>();
547
+ for (const edge of edges) {
548
+ // A labelled edge keeps its own connector: the bus has no place to put its text.
549
+ if (edge.kind === 'inheritance' && !edge.label) {
550
+ byBase.set(edge.to, [...(byBase.get(edge.to) ?? []), edge]);
551
+ }
552
+ }
553
+ for (const [baseId, group] of byBase) {
554
+ const base = nodes.get(baseId);
555
+ const subs = group.map((edge) => nodes.get(edge.from));
556
+ if (!base || group.length < 2 || subs.some((sub) => !sub)) {
557
+ continue;
558
+ }
559
+ const rects = subs as Rect[];
560
+ const row = rects[0].y;
561
+ if (rects.some((sub) => sub.y !== row) || base.y + base.h >= row) {
562
+ continue;
563
+ }
564
+ const busY = fineSnap((base.y + base.h + row) / 2);
565
+ const trunkX = fineSnap(base.x + base.w / 2);
566
+ const centers = rects.map((sub) => fineSnap(sub.x + sub.w / 2));
567
+ const span = [Math.min(trunkX, ...centers), Math.max(trunkX, ...centers)];
568
+ elements.push({
569
+ kind: 'line',
570
+ id: `${baseId}-bus`,
571
+ points: [
572
+ { x: span[0], y: busY },
573
+ { x: span[1], y: busY },
574
+ ],
575
+ });
576
+ group.forEach((edge, index) => {
577
+ elements.push({
578
+ kind: 'line',
579
+ id: `${edge.from}-${baseId}-stub-${index}`,
580
+ points: [
581
+ { x: centers[index], y: row },
582
+ { x: centers[index], y: busY },
583
+ ],
584
+ });
585
+ consumed.add(edge);
586
+ });
587
+ elements.push({
588
+ kind: 'arrow',
589
+ id: `${baseId}-inherit`,
590
+ start: { x: trunkX, y: busY },
591
+ end: { x: trunkX, y: base.y + base.h },
592
+ head: 'triangle',
593
+ });
594
+ }
595
+ return { elements, consumed };
596
+ };
597
+
598
+ type EmitOptions = {
599
+ origin: Scene.Point;
600
+ scale: number;
601
+ bus: boolean;
602
+ arrangement: Arrangement;
603
+ route?: Router;
604
+ };
605
+
606
+ /**
607
+ * Scene commands for a placement: one world object per subgraph frame (painted first) and per
608
+ * node, plus an `edges` object of connectors.
609
+ */
610
+ const emit = (
611
+ graph: MermaidGraph,
612
+ cell: Cell,
613
+ { nodes, frames }: Placement,
614
+ { origin, scale, bus, arrangement, route }: EmitOptions,
615
+ ): Scene.Command[] => {
616
+ const horizontal = graph.direction === 'LR' || graph.direction === 'RL';
617
+ const at = (rect: Rect): Scene.Point => ({ x: origin.x + rect.x * scale, y: origin.y + rect.y * scale });
618
+ const commands: Scene.Command[] = [];
619
+
620
+ for (const group of graph.groups) {
621
+ const frame = frames.get(group.id);
622
+ if (!frame) {
623
+ continue;
624
+ }
625
+ commands.push({
626
+ op: 'upsert-object',
627
+ object: {
628
+ id: group.id,
629
+ origin: at(frame),
630
+ scale,
631
+ elements: [
632
+ { kind: 'rect', id: 'frame', x: 0, y: 0, w: frame.w, h: frame.h, stroke: 'dashed', color: 'grey' },
633
+ // The label sits in the frame's top band rather than centered, where members would cover it.
634
+ ...(group.label.trim()
635
+ ? [
636
+ {
637
+ kind: 'text',
638
+ id: 'label',
639
+ x: FRAME_PAD / 2,
640
+ y: (FRAME_LABEL_H - LABEL_FONT.lineH) / 2,
641
+ text: group.label,
642
+ weight: 's',
643
+ color: 'grey',
644
+ } satisfies Scene.Text,
645
+ ]
646
+ : []),
647
+ ],
648
+ },
649
+ });
650
+ }
651
+
652
+ for (const node of graph.nodes) {
653
+ const rect = nodes.get(node.id);
654
+ if (!rect) {
655
+ continue;
656
+ }
657
+ commands.push({
658
+ op: 'upsert-object',
659
+ object: {
660
+ id: node.id,
661
+ origin: at(rect),
662
+ scale,
663
+ ...(node.ref ? { ref: node.ref } : {}),
664
+ elements: [{ kind: 'rect', id: 'box', x: 0, y: 0, w: cell.w, h: cell.h, text: node.label }],
665
+ },
666
+ });
667
+ }
668
+
669
+ if (graph.edges.length > 0) {
670
+ // Frames are containers, not obstacles: only node rects block routes.
671
+ const router = route ?? makeAvoidingRouter([...nodes.values()], zRouter, { step: GRID / 4 });
672
+ const buses = bus && !horizontal ? inheritanceBuses(graph.edges, nodes) : { elements: [], consumed: new Set() };
673
+ const routed = graph.edges.filter((edge) => !buses.consumed.has(edge));
674
+ // In columns the root level runs across the flow, so an edge between two of its members — groups
675
+ // or ungrouped nodes — runs across it too.
676
+ const groupOf = new Map(graph.groups.flatMap((group) => group.children.map((id) => [id, group.id] as const)));
677
+ const rootOf = (id: string) => groupOf.get(id) ?? id;
678
+ const isHorizontal = (edge: MermaidEdge) =>
679
+ arrangement === 'columns' && rootOf(edge.from) !== rootOf(edge.to) ? !horizontal : horizontal;
680
+ const ports = straighten(routed, nodes, isHorizontal);
681
+ const elements: Scene.Element[] = [...buses.elements];
682
+ // Terminals already placed, per node, with their role. Edges are routed independently (and an
683
+ // internal edge and a cross-group edge may even route in different modes), so a later edge can
684
+ // land its terminal on an earlier one. Only an exit on an entry is a defect — it reads as a
685
+ // crossing — so a slot is taken by the opposite role alone: two exits (a fork) or two entries (a
686
+ // merge) share a trunk, as a bus does. Such an edge is re-routed with the port nudged to a free
687
+ // slot on that side, preferring the nudge that keeps the route straightest.
688
+ type Role = 'exit' | 'entry';
689
+ const terminals = new Map<string, { point: Scene.Point; role: Role }[]>();
690
+ const taken = (nodeId: string, point: Scene.Point, role: Role) =>
691
+ (terminals.get(nodeId) ?? []).some(
692
+ (other) =>
693
+ other.role !== role &&
694
+ Math.abs(other.point.x - point.x) < GRID_FINE &&
695
+ Math.abs(other.point.y - point.y) < GRID_FINE,
696
+ );
697
+ // Paired nudges first: both ends move together, so a straight edge stays straight; single-ended
698
+ // ones for when only one end is crowded.
699
+ const NUDGES: [number, number][] = [
700
+ [0, 0],
701
+ [1, 1],
702
+ [-1, -1],
703
+ [2, 2],
704
+ [-2, -2],
705
+ [1, 0],
706
+ [-1, 0],
707
+ [0, 1],
708
+ [0, -1],
709
+ ];
710
+ const routeEdge = (edge: MermaidEdge, from: Rect, to: Rect): Scene.Point[] => {
711
+ const horizontal = isHorizontal(edge);
712
+ const sameLane = horizontal ? from.x === to.x : from.y === to.y;
713
+ const alongY = horizontal ? sameLane : !sameLane;
714
+ const base = ports.get(edge) ?? {};
715
+ const center = (rect: Rect) => (alongY ? rect.x + rect.w / 2 : rect.y + rect.h / 2);
716
+ const within = (rect: Rect, value: number) =>
717
+ alongY
718
+ ? Math.min(rect.x + rect.w - GRID_FINE, Math.max(rect.x + GRID_FINE, value))
719
+ : Math.min(rect.y + rect.h - GRID_FINE, Math.max(rect.y + GRID_FINE, value));
720
+ const route = ([startNudge, endNudge]: [number, number]) =>
721
+ router({
722
+ relation: edge,
723
+ from,
724
+ to,
725
+ horizontal,
726
+ offset: 0,
727
+ ports: {
728
+ start: within(from, (base.start ?? center(from)) + startNudge * GRID_FINE),
729
+ end: within(to, (base.end ?? center(to)) + endNudge * GRID_FINE),
730
+ },
731
+ });
732
+ const free = (points: Scene.Point[]) =>
733
+ !taken(edge.from, points[0], 'exit') && !taken(edge.to, points[points.length - 1], 'entry');
734
+ const direct = route(NUDGES[0]);
735
+ if (free(direct)) {
736
+ return direct;
737
+ }
738
+ let best: Scene.Point[] | undefined;
739
+ for (const nudge of NUDGES.slice(1)) {
740
+ const attempt = route(nudge);
741
+ if (free(attempt) && (!best || attempt.length < best.length)) {
742
+ best = attempt;
743
+ }
744
+ }
745
+ return best ?? direct;
746
+ };
747
+ routed.forEach((edge, index) => {
748
+ const from = nodes.get(edge.from);
749
+ const to = nodes.get(edge.to);
750
+ if (!from || !to) {
751
+ return;
752
+ }
753
+ const points = routeEdge(edge, from, to);
754
+ terminals.set(edge.from, [...(terminals.get(edge.from) ?? []), { point: points[0], role: 'exit' }]);
755
+ terminals.set(edge.to, [...(terminals.get(edge.to) ?? []), { point: points[points.length - 1], role: 'entry' }]);
756
+ const id = `${edge.from}-${edge.to}-${index}`;
757
+ if (points.length > 2) {
758
+ elements.push({ kind: 'line', id: `${id}-path`, points: points.slice(0, -1) });
759
+ }
760
+ elements.push({
761
+ kind: 'arrow',
762
+ id,
763
+ start: points[points.length - 2],
764
+ end: points[points.length - 1],
765
+ ...markers(edge.kind),
766
+ });
767
+ if (edge.label) {
768
+ const head = points[Math.floor(points.length / 2) - 1];
769
+ const tail = points[Math.floor(points.length / 2)];
770
+ elements.push({
771
+ kind: 'text',
772
+ id: `${id}-label`,
773
+ x: (head.x + tail.x) / 2 + GRID / 4,
774
+ y: (head.y + tail.y) / 2 - LABEL_FONT.lineH,
775
+ text: edge.label,
776
+ weight: 's',
777
+ });
778
+ }
779
+ });
780
+ commands.push({ op: 'upsert-object', object: { id: 'edges', origin, scale, elements } });
781
+ }
782
+
783
+ return commands;
784
+ };
785
+
786
+ const objectsOf = (commands: readonly Scene.Command[]): Scene.WorldObject[] =>
787
+ commands.flatMap((command) => (command.op === 'upsert-object' ? [command.object] : []));
788
+
789
+ /**
790
+ * Lay out a mermaid flowchart: place once per lattice × ordering, emit with and without the
791
+ * inheritance bus, analyze every candidate, and let the objective choose. All candidates come back
792
+ * ranked so a bench can show what lost and why. Async because ELK is.
793
+ */
794
+ export const layout = async (source: string, options: CompileOptions = {}): Promise<Result> => {
795
+ const graph = parse(source);
796
+ const { origin = { x: 0, y: 0 }, scale = 1, maxWidth = MAX_W, objective = Objective.DEFAULT, route } = options;
797
+ const cell = measureCell(graph, maxWidth);
798
+ const lattices = typeof options.lattice === 'number' ? [options.lattice] : (options.lattice ?? LATTICES);
799
+ const orders = options.order ?? ORDERS;
800
+ const buses = options.bus ?? [true, false];
801
+ // Columns only mean something with two or more groups to arrange.
802
+ const grouped = graph.groups.filter((group) => group.children.length > 0).length;
803
+ const arrangements = options.arrangement ?? (grouped >= 2 ? ARRANGEMENTS : ['layered']);
804
+ const layerings = options.layering ?? LAYERINGS;
805
+ const alignments = options.alignment ?? ALIGNMENTS;
806
+ invariant(
807
+ lattices.length > 0 &&
808
+ orders.length > 0 &&
809
+ buses.length > 0 &&
810
+ arrangements.length > 0 &&
811
+ layerings.length > 0 &&
812
+ alignments.length > 0,
813
+ 'every candidate axis needs a value',
814
+ );
815
+
816
+ const candidates: Candidate[] = [];
817
+ // Knobs often reach the same placement (a graph with no in-package references layers the same
818
+ // either way; equal-height packages align the same every way); each is routed and graded once.
819
+ const seen = new Set<string>();
820
+ for (const lattice of lattices) {
821
+ for (const order of orders) {
822
+ for (const arrangement of arrangements) {
823
+ for (const layering of layerings) {
824
+ const pitch = pitchFor(graph, cell, lattice);
825
+ const positions = await place(graph, cell, pitch, order, arrangement, layering);
826
+ // Alignment moves packages relative to each other, which only columns set side by side.
827
+ for (const alignment of arrangement === 'columns' ? alignments : (['none'] as const)) {
828
+ const placement = frame(graph, alignGroups(graph, positions, graph.direction, alignment), cell);
829
+ const key = [...placement.nodes]
830
+ .map(([id, rect]) => `${id}:${rect.x}:${rect.y}`)
831
+ .sort()
832
+ .join(' ');
833
+ for (const bus of buses) {
834
+ if (seen.has(`${arrangement}|${bus}|${key}`)) {
835
+ continue;
836
+ }
837
+ seen.add(`${arrangement}|${bus}|${key}`);
838
+ const commands = emit(graph, cell, placement, { origin, scale, bus, arrangement, route });
839
+ const objects = objectsOf(commands);
840
+ candidates.push({
841
+ lattice,
842
+ order,
843
+ arrangement,
844
+ layering,
845
+ alignment,
846
+ bus,
847
+ commands,
848
+ layout: { objects, report: Diagnostics.analyze(objects) },
849
+ });
850
+ }
851
+ }
852
+ }
853
+ }
854
+ }
855
+ }
856
+
857
+ const { chosen, ranked } = Objective.select(objective, candidates);
858
+ return { commands: chosen.candidate.commands, chosen, ranked };
859
+ };
860
+
861
+ /** The chosen layout's scene commands; see {@link layout} for the candidates and verdicts. */
862
+ export const compile = async (source: string, options: CompileOptions = {}): Promise<Scene.Command[]> =>
863
+ (await layout(source, options)).commands;