@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,559 @@
1
+ //
2
+ // Copyright 2026 DXOS.org
3
+ //
4
+
5
+ //
6
+ // Grid variant of the UML class-diagram dialect: every class renders as an equal-size node
7
+ // (uniform cell sized to the largest class) placed on a fixed grid, and relations render as
8
+ // orthogonally routed connectors. Routing is pluggable via `CompileOptions.route` so callers
9
+ // can substitute channel assignment or full obstacle-avoiding routers later.
10
+ //
11
+
12
+ import * as Layout from './layout.ts';
13
+ import { makeAvoidingRouter } from './ortho-router.ts';
14
+ import type * as Scene from './scene.ts';
15
+ import { type UmlModel, type UmlRelation, parse, relationRanks, relationStyle, relationText } from './uml.ts';
16
+
17
+ /**
18
+ * Snap unit for cells, gaps, and positions, chosen so node edges land on the grid lines tldraw
19
+ * renders at working zoom (it draws lines at power-of-two multiples of its document gridSize).
20
+ */
21
+ export const GRID = 32;
22
+
23
+ /** Half-cell minor grid, used for sub-cell nudges (ports, channel separation, label offsets). */
24
+ const GRID_FINE = GRID / 2;
25
+
26
+ const MIN_W = GRID * 2;
27
+ const MAX_W = GRID * 6;
28
+
29
+ const MIN_H = GRID * 2;
30
+ const MAX_H = GRID * 5;
31
+
32
+ const MIN_TITLE_H = GRID;
33
+ const MAX_TITLE_H = GRID * 2;
34
+
35
+ const TITLE_PAD = GRID / 2;
36
+ const TEXT_PAD = GRID_FINE * 1;
37
+ const PAD_X = GRID * 2;
38
+ const SECTION_PAD = GRID / 2;
39
+ const GAP_MAIN = GRID * 4;
40
+ const GAP_CROSS = GRID * 4;
41
+
42
+ const TITLE_FONT = Layout.FONT_METRICS.m;
43
+ const MEMBER_FONT = Layout.FONT_METRICS.s;
44
+
45
+ const snap = (value: number) => Math.ceil(value / GRID) * GRID;
46
+
47
+ /** Node rect in scene units. */
48
+ export type Rect = {
49
+ x: number;
50
+ y: number;
51
+ w: number;
52
+ h: number;
53
+ };
54
+
55
+ export type RoutedRelation = {
56
+ /** The edge being routed; routers read only its endpoints, so any dialect's edge type fits. */
57
+ relation: Layout.LayoutEdge;
58
+ from: Rect;
59
+ to: Rect;
60
+ /** True when the diagram flows LR/RL, so ranks stack along x. */
61
+ horizontal: boolean;
62
+ /** Channel slot among relations sharing a gutter, centered on it (shift = offset × GRID_FINE). */
63
+ offset: number;
64
+ /** Terminal cross-axis coordinates (port positions); node-edge centers when omitted. */
65
+ ports?: { start?: number; end?: number };
66
+ };
67
+
68
+ /**
69
+ * Produces the connector waypoints for one relation, in scene units. The returned polyline is
70
+ * rendered verbatim (last segment carries the arrowhead), so a router fully controls the path.
71
+ */
72
+ export type Router = (edge: RoutedRelation) => Scene.Point[];
73
+
74
+ /** Z route along y: leave the facing horizontal edge, run a channel at mid, enter the peer's. */
75
+ const routeAlongY = (from: Rect, to: Rect, shift: number, ports?: RoutedRelation['ports']): Scene.Point[] => {
76
+ const down = to.y >= from.y;
77
+ const start = { x: ports?.start ?? from.x + from.w / 2, y: down ? from.y + from.h : from.y };
78
+ const end = { x: ports?.end ?? to.x + to.w / 2, y: down ? to.y : to.y + to.h };
79
+ if (start.x === end.x) {
80
+ // Aligned terminals: a straight line, shifted sideways so parallel edges stay distinct.
81
+ return [
82
+ { x: start.x + shift, y: start.y },
83
+ { x: end.x + shift, y: end.y },
84
+ ];
85
+ }
86
+ const mid = snap((start.y + end.y) / 2) + shift;
87
+ return [start, { x: start.x, y: mid }, { x: end.x, y: mid }, end];
88
+ };
89
+
90
+ /** Z route along x; mirror of `routeAlongY`. */
91
+ const routeAlongX = (from: Rect, to: Rect, shift: number, ports?: RoutedRelation['ports']): Scene.Point[] => {
92
+ const right = to.x >= from.x;
93
+ const start = { x: right ? from.x + from.w : from.x, y: ports?.start ?? from.y + from.h / 2 };
94
+ const end = { x: right ? to.x : to.x + to.w, y: ports?.end ?? to.y + to.h / 2 };
95
+ if (start.y === end.y) {
96
+ return [
97
+ { x: start.x, y: start.y + shift },
98
+ { x: end.x, y: end.y + shift },
99
+ ];
100
+ }
101
+ const mid = snap((start.x + end.x) / 2) + shift;
102
+ return [start, { x: mid, y: start.y }, { x: mid, y: end.y }, end];
103
+ };
104
+
105
+ /**
106
+ * Default router: an orthogonal Z along the flow axis — rank-crossing edges route through the
107
+ * inter-rank gutter (channel shifted per parallel edge); same-rank edges route out the side.
108
+ */
109
+ export const zRouter: Router = ({ from, to, offset, horizontal, ports }) => {
110
+ const sameLane = horizontal ? from.x === to.x : from.y === to.y;
111
+ const alongY = horizontal ? sameLane : !sameLane;
112
+ return (alongY ? routeAlongY : routeAlongX)(from, to, offset * GRID_FINE, ports);
113
+ };
114
+
115
+ /**
116
+ * Pull strength per relation kind when aligning columns: parts stick to their whole's column
117
+ * before subtypes, which in turn beat loose dependencies/associations.
118
+ */
119
+ const COLUMN_WEIGHT: Record<UmlRelation['kind'], number> = {
120
+ composition: 4,
121
+ aggregation: 3,
122
+ inheritance: 2,
123
+ realization: 2,
124
+ dependency: 1,
125
+ association: 1,
126
+ };
127
+
128
+ /**
129
+ * Assign each node an integer column: the first lane keeps declaration order, later lanes pull
130
+ * each node toward the weighted mean column of its already-placed neighbours. The strongest-tied
131
+ * node picks its column first, so a part claims the column under its whole even when weaker
132
+ * relations compete for it; collisions take the free column nearest the desired mean. Aligned
133
+ * columns turn rank-crossing connectors into straight lines.
134
+ */
135
+ const assignColumns = (
136
+ model: UmlModel,
137
+ ranks: Map<string, number>,
138
+ lanes: Map<number, string[]>,
139
+ ): Map<string, number> => {
140
+ type Link = { peer: string; weight: number };
141
+ const neighbors = new Map<string, Link[]>();
142
+ for (const relation of model.relations) {
143
+ const weight = COLUMN_WEIGHT[relation.kind];
144
+ neighbors.set(relation.from, [...(neighbors.get(relation.from) ?? []), { peer: relation.to, weight }]);
145
+ neighbors.set(relation.to, [...(neighbors.get(relation.to) ?? []), { peer: relation.from, weight }]);
146
+ }
147
+
148
+ const columns = new Map<string, number>();
149
+ const laneOrder = [...lanes.keys()].sort((left, right) => left - right);
150
+ laneOrder.forEach((lane, position) => {
151
+ const members = lanes.get(lane)!;
152
+ const desired = members.map((id, index) => {
153
+ const placed = (neighbors.get(id) ?? []).filter((link) => columns.has(link.peer));
154
+ const pull = placed.reduce((sum, link) => sum + link.weight, 0);
155
+ const mean =
156
+ position > 0 && pull > 0
157
+ ? placed.reduce((sum, link) => sum + link.weight * columns.get(link.peer)!, 0) / pull
158
+ : index;
159
+ return { id, mean, pull, index };
160
+ });
161
+ desired.sort((left, right) => right.pull - left.pull || left.index - right.index);
162
+ const used = new Set<number>();
163
+ for (const entry of desired) {
164
+ // Round half toward the left so a node between two equal parents aligns with the first;
165
+ // on collision take the free column nearest the desired mean.
166
+ const base = Math.ceil(entry.mean - 0.5);
167
+ let column = base;
168
+ for (let step = 1; used.has(column); step++) {
169
+ const left = base - step;
170
+ const right = base + step;
171
+ if (!used.has(left) && (used.has(right) || Math.abs(left - entry.mean) <= Math.abs(right - entry.mean))) {
172
+ column = left;
173
+ } else if (!used.has(right)) {
174
+ column = right;
175
+ }
176
+ }
177
+ used.add(column);
178
+ columns.set(entry.id, column);
179
+ }
180
+ });
181
+
182
+ // Normalize so the leftmost column is 0.
183
+ const min = Math.min(...columns.values(), 0);
184
+ return new Map([...columns].map(([id, column]) => [id, column - min]));
185
+ };
186
+
187
+ /** Fixed cell size, in scene units; omitted dimensions are measured from content. */
188
+ export type CellSize = { w?: number; h?: number };
189
+
190
+ export type Cell = { w: number; h: number; titleH: number };
191
+
192
+ export type MeasureOptions = {
193
+ maxWidth: number;
194
+ cell?: CellSize;
195
+ titleHeight?: number;
196
+ };
197
+
198
+ /** One cell fits the largest class: widest line (wrapped at maxWidth) and tallest member list. */
199
+ export const measureCell = (model: UmlModel, { maxWidth, cell: override = {}, titleHeight }: MeasureOptions): Cell => {
200
+ const memberLines = (entry: UmlModel['classes'][number]) => [...entry.attributes, ...entry.methods];
201
+ const titleW =
202
+ Math.max(0, ...model.classes.map((entry) => Math.max(entry.label.length, (entry.stereotype?.length ?? 0) + 2))) *
203
+ TITLE_FONT.charW;
204
+ const memberW =
205
+ Math.max(0, ...model.classes.flatMap((entry) => memberLines(entry).map((line) => line.length))) * MEMBER_FONT.charW;
206
+ const w = snap(override.w ?? Math.min(maxWidth, Math.max(MIN_W, Math.ceil(Math.max(titleW, memberW)) + PAD_X)));
207
+ const wrapped = (length: number, font: Layout.FontMetrics) =>
208
+ Math.max(1, Math.ceil((length * font.charW) / (w - TEXT_PAD * 2)));
209
+ const titleLines = Math.max(
210
+ 1,
211
+ ...model.classes.map((entry) => wrapped(entry.label.length, TITLE_FONT) + (entry.stereotype ? 1 : 0)),
212
+ );
213
+ const bodyLines = Math.max(
214
+ 1,
215
+ ...model.classes.map(
216
+ (entry) =>
217
+ memberLines(entry).reduce((sum, line) => sum + wrapped(line.length, MEMBER_FONT), 0) +
218
+ // The emitted body inserts a blank separator line between attributes and methods.
219
+ (entry.attributes.length && entry.methods.length ? 1 : 0),
220
+ ),
221
+ );
222
+ // A fixed header snaps to the fine grid so it can sit below one GRID cell; the measured
223
+ // default clamps to the GRID-derived title bounds.
224
+ const titleH =
225
+ titleHeight !== undefined
226
+ ? Math.ceil(titleHeight / GRID_FINE) * GRID_FINE
227
+ : Math.min(MAX_TITLE_H, Math.max(MIN_TITLE_H, snap(titleLines * TITLE_FONT.lineH + TITLE_PAD)));
228
+ const bodyH = snap(bodyLines * MEMBER_FONT.lineH + SECTION_PAD);
229
+ // Measured height clamps to the GRID-derived bounds (very large classes cap at MAX_H and may
230
+ // elide content); an explicit override wins, still reserving the title bar.
231
+ const measured = Math.min(MAX_H, Math.max(MIN_H, titleH + bodyH));
232
+ const h = override.h !== undefined ? Math.max(snap(override.h), titleH + GRID) : measured;
233
+ return { w, h, titleH };
234
+ };
235
+
236
+ export type CompileOptions = {
237
+ /** Canvas position of the diagram's top-left, in canvas px. */
238
+ origin?: Scene.Point;
239
+ /** Canvas px per scene unit. */
240
+ scale?: number;
241
+ /** Gap between ranks along the flow direction, in scene units (default GRID × 4, snapped). */
242
+ gapMain?: number;
243
+ /** Gap between classes within a rank, in scene units (default GRID × 4, snapped). */
244
+ gapCross?: number;
245
+ /** Maximum cell width, in scene units (default GRID × 6); longer lines wrap. */
246
+ maxWidth?: number;
247
+ /** Fixed cell size (snapped to GRID); overrides measurement per dimension. */
248
+ cell?: CellSize;
249
+ /** Fixed header height, in scene units (snapped to the fine grid); measured when unset. */
250
+ titleHeight?: number;
251
+ /** Connector routing (default `zRouter`). */
252
+ route?: Router;
253
+ };
254
+
255
+ /** A placed diagram: the parsed model plus per-class node rects (scene units). */
256
+ export type Placement = {
257
+ model: UmlModel;
258
+ cell: Cell;
259
+ rects: Map<string, Rect>;
260
+ ranks: Map<string, number>;
261
+ };
262
+
263
+ export type EmitOptions = {
264
+ origin?: Scene.Point;
265
+ scale?: number;
266
+ route?: Router;
267
+ };
268
+
269
+ /**
270
+ * Compile a mermaid class diagram into scene commands with equal-size grid-aligned nodes: per
271
+ * class a `title` bar plus a `body` compartment filling the cell, and an `edges` object holding
272
+ * orthogonal connector paths (polyline waypoints, an arrowhead segment, and a floating label).
273
+ */
274
+ export const compile = (source: string, options: CompileOptions = {}): Scene.Command[] => {
275
+ const model = parse(source);
276
+ const { origin = { x: 0, y: 0 }, scale = 1, maxWidth = MAX_W, route } = options;
277
+ const gapMain = snap(options.gapMain ?? GAP_MAIN);
278
+ const gapCross = snap(options.gapCross ?? GAP_CROSS);
279
+ const horizontal = model.direction === 'LR' || model.direction === 'RL';
280
+ const cell = measureCell(model, { maxWidth, cell: options.cell, titleHeight: options.titleHeight });
281
+ const ranks = relationRanks(model);
282
+
283
+ const lanes = new Map<number, string[]>();
284
+ for (const entry of model.classes) {
285
+ const value = ranks.get(entry.id) ?? 0;
286
+ lanes.set(value, [...(lanes.get(value) ?? []), entry.id]);
287
+ }
288
+
289
+ // Uniform cells make lanes a plain grid: nodes take shared integer columns (aligned via their
290
+ // neighbours) so as many connectors as possible run straight through the gutters.
291
+ const cross = horizontal ? cell.h : cell.w;
292
+ const main = horizontal ? cell.w : cell.h;
293
+ const columns = assignColumns(model, ranks, lanes);
294
+ const rects = new Map<string, Rect>();
295
+ for (const [lane, members] of lanes) {
296
+ for (const id of members) {
297
+ const crossOffset = columns.get(id)! * (cross + gapCross);
298
+ const mainOffset = lane * (main + gapMain);
299
+ rects.set(id, {
300
+ x: horizontal ? mainOffset : crossOffset,
301
+ y: horizontal ? crossOffset : mainOffset,
302
+ w: cell.w,
303
+ h: cell.h,
304
+ });
305
+ }
306
+ }
307
+
308
+ return emit({ model, cell, rects, ranks }, { origin, scale, route });
309
+ };
310
+
311
+ /**
312
+ * Emit scene commands from a placed model — the shared back half of `compile`, also used by the
313
+ * engine-backed dialects (`uml-engine.ts`) which supply their own placement.
314
+ */
315
+ export const emit = (
316
+ { model, cell, rects, ranks }: Placement,
317
+ { origin = { x: 0, y: 0 }, scale = 1, route }: EmitOptions = {},
318
+ ): Scene.Command[] => {
319
+ const horizontal = model.direction === 'LR' || model.direction === 'RL';
320
+ // Default routing avoids every node (fewest turns), falling back to the Z-router per edge.
321
+ const router = route ?? makeAvoidingRouter([...rects.values()], zRouter, { step: GRID / 4 });
322
+ const commands: Scene.Command[] = [];
323
+
324
+ for (const entry of model.classes) {
325
+ const rect = rects.get(entry.id)!;
326
+ const title = entry.stereotype ? `«${entry.stereotype}»\n${entry.label}` : entry.label;
327
+ const members = [
328
+ ...entry.attributes,
329
+ ...(entry.attributes.length && entry.methods.length ? [''] : []),
330
+ ...entry.methods,
331
+ ].join('\n');
332
+ commands.push({
333
+ op: 'upsert-object',
334
+ object: {
335
+ id: entry.id,
336
+ origin: { x: origin.x + rect.x * scale, y: origin.y + rect.y * scale },
337
+ scale,
338
+ // One rounded frame encapsulates the node; the header rounds only its top corners so it
339
+ // sits flush against the frame, and members render as free left-aligned text below it.
340
+ elements: [
341
+ { kind: 'rect', id: 'frame', x: 0, y: 0, w: cell.w, h: cell.h },
342
+ {
343
+ kind: 'rect',
344
+ id: 'title',
345
+ x: 0,
346
+ y: 0,
347
+ w: cell.w,
348
+ h: cell.titleH,
349
+ text: title,
350
+ fill: 'solid',
351
+ corners: 'top',
352
+ },
353
+ ...(members
354
+ ? [
355
+ {
356
+ kind: 'text',
357
+ id: 'body',
358
+ x: TEXT_PAD,
359
+ y: cell.titleH + SECTION_PAD / 2,
360
+ w: cell.w - TEXT_PAD * 2,
361
+ text: members,
362
+ weight: 's',
363
+ } satisfies Scene.Text,
364
+ ]
365
+ : []),
366
+ ],
367
+ },
368
+ });
369
+ }
370
+
371
+ if (model.relations.length > 0) {
372
+ // A route's channel run sits on the gutter line at the midpoint between its terminals, so ALL
373
+ // edges sharing that line — not just those between the same rank pair — must take distinct
374
+ // channels or their runs overlap. Straight edges (aligned terminals) share the column/row line
375
+ // instead, and the router shifts them sideways. Offsets center each block on its line.
376
+ const gutterOf = (relation: UmlRelation): string => {
377
+ const from = rects.get(relation.from)!;
378
+ const to = rects.get(relation.to)!;
379
+ const sameLane = horizontal ? from.x === to.x : from.y === to.y;
380
+ const alongY = horizontal ? sameLane : !sameLane;
381
+ if (alongY) {
382
+ if (from.x + from.w / 2 === to.x + to.w / 2) {
383
+ return `v:${from.x + from.w / 2}`;
384
+ }
385
+ const startY = to.y >= from.y ? from.y + from.h : from.y;
386
+ const endY = to.y >= from.y ? to.y : to.y + to.h;
387
+ return `y:${snap((startY + endY) / 2)}`;
388
+ }
389
+ if (from.y + from.h / 2 === to.y + to.h / 2) {
390
+ return `h:${from.y + from.h / 2}`;
391
+ }
392
+ const startX = to.x >= from.x ? from.x + from.w : from.x;
393
+ const endX = to.x >= from.x ? to.x : to.x + to.w;
394
+ return `x:${snap((startX + endX) / 2)}`;
395
+ };
396
+
397
+ const gutters = new Map<string, number>();
398
+ for (const relation of model.relations) {
399
+ const gutter = gutterOf(relation);
400
+ gutters.set(gutter, (gutters.get(gutter) ?? 0) + 1);
401
+ }
402
+
403
+ // Port distribution: terminals attaching to the same node side spread across it (ordered by
404
+ // peer position) instead of stacking on the center — the final approach segments of distinct
405
+ // relations would otherwise coincide exactly.
406
+ type PortSlot = { relation: UmlRelation; end: boolean; order: number };
407
+ const portGroups = new Map<string, PortSlot[]>();
408
+ const addSlot = (key: string, slot: PortSlot) => portGroups.set(key, [...(portGroups.get(key) ?? []), slot]);
409
+ for (const relation of model.relations) {
410
+ const from = rects.get(relation.from)!;
411
+ const to = rects.get(relation.to)!;
412
+ const sameLane = horizontal ? from.x === to.x : from.y === to.y;
413
+ const alongY = horizontal ? sameLane : !sameLane;
414
+ if (alongY) {
415
+ const down = to.y >= from.y;
416
+ addSlot(`${relation.from}:${down ? 'bottom' : 'top'}`, { relation, end: false, order: to.x + to.w / 2 });
417
+ addSlot(`${relation.to}:${down ? 'top' : 'bottom'}`, { relation, end: true, order: from.x + from.w / 2 });
418
+ } else {
419
+ const right = to.x >= from.x;
420
+ addSlot(`${relation.from}:${right ? 'right' : 'left'}`, { relation, end: false, order: to.y + to.h / 2 });
421
+ addSlot(`${relation.to}:${right ? 'left' : 'right'}`, { relation, end: true, order: from.y + from.h / 2 });
422
+ }
423
+ }
424
+ const ports = new Map<UmlRelation, { start?: number; end?: number }>();
425
+ for (const [key, slots] of portGroups) {
426
+ if (slots.length < 2) {
427
+ continue;
428
+ }
429
+ const nodeId = key.slice(0, key.lastIndexOf(':'));
430
+ const rect = rects.get(nodeId)!;
431
+ const across = key.endsWith(':top') || key.endsWith(':bottom');
432
+ slots.sort((left, right) => left.order - right.order);
433
+ // Dense sides can round two slots onto the same minor-grid line; fall back to the exact
434
+ // fractional coordinate so ports stay collision-free (and monotone in slot order).
435
+ const taken = new Set<number>();
436
+ slots.forEach((slot, index) => {
437
+ const fraction = (index + 1) / (slots.length + 1);
438
+ const exact = across ? rect.x + rect.w * fraction : rect.y + rect.h * fraction;
439
+ const rounded = Math.round(exact / GRID_FINE) * GRID_FINE;
440
+ const coord = taken.has(rounded) ? exact : rounded;
441
+ taken.add(coord);
442
+ const entry = ports.get(slot.relation) ?? {};
443
+ entry[slot.end ? 'end' : 'start'] = coord;
444
+ ports.set(slot.relation, entry);
445
+ });
446
+ }
447
+
448
+ // Straightening: when a relation's nodes overlap on the cross axis, snap both terminals to a
449
+ // shared free coordinate so the connector runs straight — a jog between stacked (or abreast)
450
+ // nodes reads as noise. The spread ports above are the collision set; singles sit on centers.
451
+ const takenBySide = new Map<string, Map<UmlRelation, number>>();
452
+ for (const [key, slots] of portGroups) {
453
+ const nodeId = key.slice(0, key.lastIndexOf(':'));
454
+ const rect = rects.get(nodeId)!;
455
+ const across = key.endsWith(':top') || key.endsWith(':bottom');
456
+ const coords = new Map<UmlRelation, number>();
457
+ for (const slot of slots) {
458
+ const port = ports.get(slot.relation)?.[slot.end ? 'end' : 'start'];
459
+ coords.set(slot.relation, port ?? (across ? rect.x + rect.w / 2 : rect.y + rect.h / 2));
460
+ }
461
+ takenBySide.set(key, coords);
462
+ }
463
+ const fineSnap = (value: number) => Math.round(value / GRID_FINE) * GRID_FINE;
464
+ for (const relation of model.relations) {
465
+ const from = rects.get(relation.from)!;
466
+ const to = rects.get(relation.to)!;
467
+ const sameLane = horizontal ? from.x === to.x : from.y === to.y;
468
+ const alongY = horizontal ? sameLane : !sameLane;
469
+ const lo = alongY ? Math.max(from.x, to.x) + GRID_FINE : Math.max(from.y, to.y) + GRID_FINE;
470
+ const hi = alongY
471
+ ? Math.min(from.x + from.w, to.x + to.w) - GRID_FINE
472
+ : Math.min(from.y + from.h, to.y + to.h) - GRID_FINE;
473
+ if (lo > hi) {
474
+ continue;
475
+ }
476
+ const startKey = alongY
477
+ ? `${relation.from}:${to.y >= from.y ? 'bottom' : 'top'}`
478
+ : `${relation.from}:${to.x >= from.x ? 'right' : 'left'}`;
479
+ const endKey = alongY
480
+ ? `${relation.to}:${to.y >= from.y ? 'top' : 'bottom'}`
481
+ : `${relation.to}:${to.x >= from.x ? 'left' : 'right'}`;
482
+ const entry = ports.get(relation) ?? {};
483
+ const startCoord = entry.start ?? (alongY ? from.x + from.w / 2 : from.y + from.h / 2);
484
+ const endCoord = entry.end ?? (alongY ? to.x + to.w / 2 : to.y + to.h / 2);
485
+ const isFree = (coord: number) =>
486
+ ![startKey, endKey].some((side) =>
487
+ [...(takenBySide.get(side) ?? new Map<UmlRelation, number>())].some(
488
+ ([peer, taken]) => peer !== relation && Math.abs(taken - coord) < GRID_FINE,
489
+ ),
490
+ );
491
+ const base = Math.min(hi, Math.max(lo, fineSnap((startCoord + endCoord) / 2)));
492
+ let chosen: number | undefined;
493
+ for (let step = 0; step <= Math.ceil((hi - lo) / GRID_FINE) && chosen === undefined; step++) {
494
+ for (const candidate of step === 0 ? [base] : [base - step * GRID_FINE, base + step * GRID_FINE]) {
495
+ if (candidate >= lo && candidate <= hi && isFree(candidate)) {
496
+ chosen = candidate;
497
+ break;
498
+ }
499
+ }
500
+ }
501
+ if (chosen === undefined) {
502
+ continue;
503
+ }
504
+ entry.start = chosen;
505
+ entry.end = chosen;
506
+ ports.set(relation, entry);
507
+ takenBySide.get(startKey)?.set(relation, chosen);
508
+ takenBySide.get(endKey)?.set(relation, chosen);
509
+ }
510
+
511
+ const assigned = new Map<string, number>();
512
+ const elements: Scene.Element[] = [];
513
+ model.relations.forEach((relation, index) => {
514
+ const gutter = gutterOf(relation);
515
+ const position = assigned.get(gutter) ?? 0;
516
+ assigned.set(gutter, position + 1);
517
+ // Integer slots so every jog stays on the minor grid (shift = offset × GRID_FINE).
518
+ const offset = position - Math.floor((gutters.get(gutter)! - 1) / 2);
519
+ const points = router({
520
+ relation,
521
+ from: rects.get(relation.from)!,
522
+ to: rects.get(relation.to)!,
523
+ horizontal,
524
+ offset,
525
+ ports: ports.get(relation),
526
+ });
527
+ const id = `${relation.from}-${relation.to}-${index}`;
528
+ const style = relationStyle(relation.kind);
529
+ // The final segment renders as an arrow for the head; earlier waypoints as a polyline.
530
+ if (points.length > 2) {
531
+ elements.push({ kind: 'line', id: `${id}-path`, points: points.slice(0, -1), ...style });
532
+ }
533
+ elements.push({
534
+ kind: 'arrow',
535
+ id,
536
+ start: points[points.length - 2],
537
+ end: points[points.length - 1],
538
+ ...style,
539
+ });
540
+ const text = relationText(relation);
541
+ if (text) {
542
+ // Label sits just above the middle segment (the channel run on a routed path).
543
+ const head = points[Math.floor(points.length / 2) - 1];
544
+ const tail = points[Math.floor(points.length / 2)];
545
+ elements.push({
546
+ kind: 'text',
547
+ id: `${id}-label`,
548
+ x: (head.x + tail.x) / 2 + GRID_FINE / 2,
549
+ y: (head.y + tail.y) / 2 - MEMBER_FONT.lineH,
550
+ text,
551
+ weight: 's',
552
+ });
553
+ }
554
+ });
555
+ commands.push({ op: 'upsert-object', object: { id: 'edges', origin, scale, elements } });
556
+ }
557
+
558
+ return commands;
559
+ };
@@ -0,0 +1,109 @@
1
+ //
2
+ // Copyright 2026 DXOS.org
3
+ //
4
+
5
+ import { describe, test } from 'vitest';
6
+
7
+ import { trim } from '@dxos/util';
8
+
9
+ import type * as Scene from './scene.ts';
10
+ import { measureCell } from './uml-grid.ts';
11
+ import { compile, inheritanceTreeRule, linearChainRule } from './uml-rules.ts';
12
+ import { parse } from './uml.ts';
13
+
14
+ const SOURCE = trim`
15
+ classDiagram
16
+ class Node {
17
+ <<abstract>>
18
+ +id: string
19
+ }
20
+ class Container
21
+ class Leaf
22
+ class Registry
23
+ class Index
24
+ class Query
25
+ class Store
26
+ class Cache
27
+ class Codec
28
+
29
+ Node <|-- Container
30
+ Node <|-- Leaf
31
+ Container *-- Node : children
32
+ Registry o-- Node
33
+ Index ..> Registry
34
+ Query ..> Index
35
+ Query ..> Store
36
+ Store *-- Cache
37
+ Store ..> Codec
38
+ Registry ..> Codec
39
+ Container ..> Store
40
+ `;
41
+
42
+ describe('uml-rules', () => {
43
+ test('inheritance rule claims the tree: root on top, peers on one horizontal axis', ({ expect }) => {
44
+ const model = parse(SOURCE);
45
+ const cell = measureCell(model, { maxWidth: 192 });
46
+ const unclaimed = new Set(model.classes.map((entry) => entry.id));
47
+ const [group] = inheritanceTreeRule.apply(model, unclaimed, cell);
48
+
49
+ expect([...group.rects.keys()].sort()).toEqual(['Container', 'Leaf', 'Node']);
50
+ expect(unclaimed.has('Node')).toBe(false);
51
+ const node = group.rects.get('Node')!;
52
+ const container = group.rects.get('Container')!;
53
+ const leaf = group.rects.get('Leaf')!;
54
+ expect(node.y).toBeLessThan(container.y);
55
+ expect(container.y).toBe(leaf.y);
56
+ expect(container.x).not.toBe(leaf.x);
57
+ });
58
+
59
+ test('chain rule claims the longest linear relation and renders it left to right', ({ expect }) => {
60
+ const model = parse(SOURCE);
61
+ const cell = measureCell(model, { maxWidth: 192 });
62
+ const unclaimed = new Set(model.classes.map((entry) => entry.id));
63
+ inheritanceTreeRule.apply(model, unclaimed, cell);
64
+ const [group] = linearChainRule.apply(model, unclaimed, cell);
65
+
66
+ // Query ..> Index ..> Registry ..> Codec is the longest chain; targets sit to the right.
67
+ expect([...group.rects.keys()].sort()).toEqual(['Codec', 'Index', 'Query', 'Registry']);
68
+ const x = (id: string) => group.rects.get(id)!.x;
69
+ expect(x('Query')).toBeLessThan(x('Index'));
70
+ expect(x('Index')).toBeLessThan(x('Registry'));
71
+ expect(x('Registry')).toBeLessThan(x('Codec'));
72
+ // A row: shared y.
73
+ expect(new Set([...group.rects.values()].map((rect) => rect.y)).size).toBe(1);
74
+ });
75
+
76
+ test('tolerates a cyclic hierarchy without recursing forever', ({ expect }) => {
77
+ const source = ['classDiagram', 'Root <|-- A', 'A <|-- B', 'B <|-- A'].join('\n');
78
+ const model = parse(source);
79
+ const cell = measureCell(model, { maxWidth: 192 });
80
+ const unclaimed = new Set(model.classes.map((entry) => entry.id));
81
+
82
+ const groups = inheritanceTreeRule.apply(model, unclaimed, cell);
83
+ const claimed = new Set(groups.flatMap((group) => [...group.rects.keys()]));
84
+ expect(claimed.has('Root')).toBe(true);
85
+ // Every node renders at most once.
86
+ for (const group of groups) {
87
+ expect(group.rects.size).toBe(new Set(group.rects.keys()).size);
88
+ }
89
+ });
90
+
91
+ test('compiles all nodes with non-overlapping group frames', ({ expect }) => {
92
+ const objects = objectsOf(compile(SOURCE));
93
+ const nodes = objects.filter((object) => !object.id.startsWith('group:') && object.id !== 'edges');
94
+ expect(nodes.length).toBe(9);
95
+
96
+ const frames = objects.filter((object) => object.id.startsWith('group:'));
97
+ expect(frames.length).toBe(2);
98
+ const bounds = frames.map((frame) => {
99
+ const rect = frame.elements[0] as Scene.Box;
100
+ return { x: frame.origin!.x, y: frame.origin!.y, w: rect.w, h: rect.h };
101
+ });
102
+ const [a, b] = bounds;
103
+ const overlap = a.x < b.x + b.w && b.x < a.x + a.w && a.y < b.y + b.h && b.y < a.y + a.h;
104
+ expect(overlap).toBe(false);
105
+ });
106
+ });
107
+
108
+ const objectsOf = (commands: Scene.Command[]) =>
109
+ commands.flatMap((command) => (command.op === 'upsert-object' ? [command.object] : []));