@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,319 @@
1
+ //
2
+ // Copyright 2026 DXOS.org
3
+ //
4
+
5
+ //
6
+ // Rule-based pluggable layout: prioritized rules claim disjoint node GROUPS and lay each out in
7
+ // group-local coordinates; groups then pack as super-nodes (ranked by their cross-group edges)
8
+ // with non-overlapping borders, and the shared router connects nodes across them. Rules capture
9
+ // domain shape the generic engines cannot: an inheritance tree reads as a tree, a call chain as
10
+ // a column. See `docs/DESIGN.md`.
11
+ //
12
+
13
+ import * as Layout from './layout.ts';
14
+ import type * as Scene from './scene.ts';
15
+ import {
16
+ type Cell,
17
+ GRID,
18
+ type CompileOptions as GridCompileOptions,
19
+ type Rect,
20
+ emit,
21
+ measureCell,
22
+ } from './uml-grid.ts';
23
+ import { type UmlModel, parse, relationRanks } from './uml.ts';
24
+
25
+ const GAP = GRID * 2;
26
+ const GROUP_GAP = GRID * 3;
27
+ const FRAME_PAD = GRID;
28
+
29
+ const snap = (value: number) => Math.round(value / GRID) * GRID;
30
+
31
+ /** A laid-out group: member rects in group-local coordinates plus the packed bounds. */
32
+ export type Group = {
33
+ id: string;
34
+ /** Rule that claimed the group (frame label; singletons carry none). */
35
+ rule?: string;
36
+ rects: Map<string, Rect>;
37
+ w: number;
38
+ h: number;
39
+ };
40
+
41
+ /**
42
+ * A layout rule: claims disjoint groups from the still-unclaimed nodes and lays each out
43
+ * internally. Rules run in priority order; unclaimed nodes fall through to singleton groups.
44
+ */
45
+ export type GroupRule = {
46
+ id: string;
47
+ apply: (model: UmlModel, unclaimed: Set<string>, cell: Cell) => Group[];
48
+ };
49
+
50
+ /** Relations oriented so the target should sit ABOVE the source (arrows read upward). */
51
+ const upEdges = (model: UmlModel): { from: string; to: string }[] =>
52
+ model.relations.map((relation) =>
53
+ relation.kind === 'inheritance' || relation.kind === 'realization' || relation.kind === 'dependency'
54
+ ? { from: relation.from, to: relation.to }
55
+ : { from: relation.to, to: relation.from },
56
+ );
57
+
58
+ /**
59
+ * Inheritance trees: each supertype with claimable subtypes forms a group — the hierarchy stacks
60
+ * vertically (root on top, arrows up) and peers share a horizontal axis, packed as a tidy tree
61
+ * (each parent centered over its subtree).
62
+ */
63
+ export const inheritanceTreeRule: GroupRule = {
64
+ id: 'inheritance',
65
+ apply: (model, unclaimed, cell) => {
66
+ const children = new Map<string, string[]>();
67
+ const hasParent = new Set<string>();
68
+ for (const relation of model.relations) {
69
+ if (
70
+ (relation.kind === 'inheritance' || relation.kind === 'realization') &&
71
+ unclaimed.has(relation.from) &&
72
+ unclaimed.has(relation.to)
73
+ ) {
74
+ // relation: subtype → supertype.
75
+ children.set(relation.to, [...(children.get(relation.to) ?? []), relation.from]);
76
+ hasParent.add(relation.from);
77
+ }
78
+ }
79
+
80
+ const groups: Group[] = [];
81
+ for (const [root] of children) {
82
+ if (hasParent.has(root)) {
83
+ continue;
84
+ }
85
+ const rects = new Map<string, Rect>();
86
+ // Cyclic or diamond hierarchies (LLM/user input) must not recurse forever; each node
87
+ // renders once, on first visit.
88
+ const claimable = (id: string) => unclaimed.has(id) && !rects.has(id);
89
+ const widths = new Map<string, number>();
90
+ // Tidy-tree: a subtree is as wide as its children row (or one cell); parent centers over it.
91
+ const layoutSubtree = (id: string, x: number, depth: number): number => {
92
+ rects.set(id, { x: 0, y: 0, w: cell.w, h: cell.h });
93
+ const kids = (children.get(id) ?? []).filter(claimable);
94
+ const kidWidths = kids.map((kid) => subtreeWidth(kid));
95
+ const rowWidth = kidWidths.reduce((sum, w) => sum + w, 0) + Math.max(0, kids.length - 1) * GAP;
96
+ const width = Math.max(cell.w, rowWidth);
97
+ let childX = x + (width - rowWidth) / 2;
98
+ for (const [index, kid] of kids.entries()) {
99
+ layoutSubtree(kid, childX, depth + 1);
100
+ childX += kidWidths[index] + GAP;
101
+ }
102
+ rects.set(id, { x: x + (width - cell.w) / 2, y: depth * (cell.h + GAP), w: cell.w, h: cell.h });
103
+ return width;
104
+ };
105
+ const subtreeWidth = (id: string, seen: Set<string> = new Set()): number => {
106
+ const cached = widths.get(id);
107
+ if (cached !== undefined) {
108
+ return cached;
109
+ }
110
+ if (seen.has(id)) {
111
+ return cell.w;
112
+ }
113
+ seen.add(id);
114
+ const kids = (children.get(id) ?? []).filter((kid) => unclaimed.has(kid) && !rects.has(kid) && !seen.has(kid));
115
+ const width =
116
+ kids.length === 0
117
+ ? cell.w
118
+ : Math.max(cell.w, kids.reduce((sum, kid) => sum + subtreeWidth(kid, seen), 0) + (kids.length - 1) * GAP);
119
+ widths.set(id, width);
120
+ return width;
121
+ };
122
+ const width = layoutSubtree(root, 0, 0);
123
+ const depth = Math.max(...[...rects.values()].map((rect) => rect.y)) + cell.h;
124
+ if (rects.size < 2) {
125
+ continue;
126
+ }
127
+ for (const id of rects.keys()) {
128
+ unclaimed.delete(id);
129
+ }
130
+ groups.push({ id: `tree:${root}`, rule: 'inheritance', rects, w: width, h: depth });
131
+ }
132
+ return groups;
133
+ },
134
+ };
135
+
136
+ /**
137
+ * Longest linear chain: the longest path through the still-unclaimed up-oriented relation graph
138
+ * renders left to right (each target directly right of its source).
139
+ */
140
+ export const linearChainRule: GroupRule = {
141
+ id: 'chain',
142
+ apply: (model, unclaimed, cell) => {
143
+ const edges = upEdges(model).filter((edge) => unclaimed.has(edge.from) && unclaimed.has(edge.to));
144
+ const back = Layout.backEdges([...unclaimed], edges);
145
+ const forward = edges.filter((edge) => !back.has(edge));
146
+ const next = new Map<string, string[]>();
147
+ for (const edge of forward) {
148
+ next.set(edge.from, [...(next.get(edge.from) ?? []), edge.to]);
149
+ }
150
+
151
+ // Longest path by DFS memo (the forward graph is acyclic after back-edge removal).
152
+ const memo = new Map<string, string[]>();
153
+ const longestFrom = (id: string): string[] => {
154
+ const cached = memo.get(id);
155
+ if (cached) {
156
+ return cached;
157
+ }
158
+ let best: string[] = [];
159
+ for (const target of next.get(id) ?? []) {
160
+ const path = longestFrom(target);
161
+ if (path.length > best.length) {
162
+ best = path;
163
+ }
164
+ }
165
+ const result = [id, ...best];
166
+ memo.set(id, result);
167
+ return result;
168
+ };
169
+
170
+ let chain: string[] = [];
171
+ for (const id of unclaimed) {
172
+ const path = longestFrom(id);
173
+ if (path.length > chain.length) {
174
+ chain = path;
175
+ }
176
+ }
177
+ if (chain.length < 3) {
178
+ return [];
179
+ }
180
+
181
+ const rects = new Map<string, Rect>();
182
+ // Chain runs source → target, reading left to right.
183
+ chain.forEach((id, index) => {
184
+ rects.set(id, { x: index * (cell.w + GAP), y: 0, w: cell.w, h: cell.h });
185
+ unclaimed.delete(id);
186
+ });
187
+ return [
188
+ { id: `chain:${chain[0]}`, rule: 'chain', rects, w: chain.length * cell.w + (chain.length - 1) * GAP, h: cell.h },
189
+ ];
190
+ },
191
+ };
192
+
193
+ export type CompileOptions = Omit<GridCompileOptions, 'route'> & {
194
+ /** Grouping rules, highest priority first. */
195
+ rules?: GroupRule[];
196
+ };
197
+
198
+ /** Run the rules over the model; leftovers become singleton groups. */
199
+ export const buildGroups = (model: UmlModel, cell: Cell, rules: GroupRule[]): Group[] => {
200
+ const unclaimed = new Set(model.classes.map((entry) => entry.id));
201
+ const groups: Group[] = [];
202
+ for (const rule of rules) {
203
+ groups.push(...rule.apply(model, unclaimed, cell));
204
+ }
205
+ for (const id of unclaimed) {
206
+ groups.push({
207
+ id: `node:${id}`,
208
+ rects: new Map([[id, { x: 0, y: 0, w: cell.w, h: cell.h }]]),
209
+ w: cell.w,
210
+ h: cell.h,
211
+ });
212
+ }
213
+ return groups;
214
+ };
215
+
216
+ /**
217
+ * Pack groups as super-nodes: rank by up-oriented cross-group edges (target group above), then
218
+ * lay ranks out as rows, bottom rank last — mirroring the node-level convention.
219
+ */
220
+ export const packGroups = (model: UmlModel, groups: Group[]): Map<string, Scene.Point> => {
221
+ const groupOf = new Map<string, Group>();
222
+ for (const group of groups) {
223
+ for (const id of group.rects.keys()) {
224
+ groupOf.set(id, group);
225
+ }
226
+ }
227
+ const crossEdges = upEdges(model)
228
+ .map((edge) => ({ from: groupOf.get(edge.from)!.id, to: groupOf.get(edge.to)!.id }))
229
+ .filter((edge) => edge.from !== edge.to);
230
+ const groupRanks = Layout.rank(
231
+ groups.map((group) => group.id),
232
+ // Rank grows downward from sources; cross edges point up, so invert.
233
+ crossEdges.map((edge) => ({ from: edge.to, to: edge.from })),
234
+ );
235
+
236
+ const lanes = new Map<number, Group[]>();
237
+ for (const group of groups) {
238
+ const rank = groupRanks.get(group.id) ?? 0;
239
+ lanes.set(rank, [...(lanes.get(rank) ?? []), group]);
240
+ }
241
+ const origins = new Map<string, Scene.Point>();
242
+ let y = 0;
243
+ for (const rank of [...lanes.keys()].sort((left, right) => left - right)) {
244
+ const members = lanes.get(rank)!;
245
+ let x = 0;
246
+ for (const group of members) {
247
+ origins.set(group.id, { x: snap(x), y: snap(y) });
248
+ x += group.w + GROUP_GAP;
249
+ }
250
+ y += Math.max(...members.map((group) => group.h)) + GROUP_GAP;
251
+ }
252
+ return origins;
253
+ };
254
+
255
+ /** Absolute node rects for a (possibly partial) group placement. */
256
+ export const resolveRects = (groups: Group[], origins: Map<string, Scene.Point>): Map<string, Rect> => {
257
+ const rects = new Map<string, Rect>();
258
+ for (const group of groups) {
259
+ const at = origins.get(group.id);
260
+ if (!at) {
261
+ continue;
262
+ }
263
+ for (const [id, rect] of group.rects) {
264
+ rects.set(id, { ...rect, x: at.x + rect.x, y: at.y + rect.y });
265
+ }
266
+ }
267
+ return rects;
268
+ };
269
+
270
+ /** Dashed frames behind multi-node groups. */
271
+ export const frameCommands = (
272
+ groups: Group[],
273
+ origins: Map<string, Scene.Point>,
274
+ { origin = { x: 0, y: 0 }, scale = 1 }: { origin?: Scene.Point; scale?: number } = {},
275
+ ): Scene.Command[] =>
276
+ groups
277
+ .filter((group) => group.rects.size > 1 && origins.has(group.id))
278
+ .map((group) => {
279
+ const at = origins.get(group.id)!;
280
+ return {
281
+ op: 'upsert-object',
282
+ object: {
283
+ id: `group:${group.id}`,
284
+ origin: { x: origin.x + (at.x - FRAME_PAD) * scale, y: origin.y + (at.y - FRAME_PAD) * scale },
285
+ scale,
286
+ elements: [
287
+ {
288
+ kind: 'rect',
289
+ id: 'frame',
290
+ x: 0,
291
+ y: 0,
292
+ w: group.w + FRAME_PAD * 2,
293
+ h: group.h + FRAME_PAD * 2,
294
+ stroke: 'dashed',
295
+ color: 'grey',
296
+ },
297
+ ],
298
+ },
299
+ };
300
+ });
301
+
302
+ /**
303
+ * Compile with rule-based grouping: rules claim groups, leftovers become singletons, groups pack
304
+ * as super-nodes ranked by their cross-group edges (lanes advance vertically; no borders overlap
305
+ * by construction), and multi-node groups render a dashed frame behind their members.
306
+ */
307
+ export const compile = (source: string, options: CompileOptions = {}): Scene.Command[] => {
308
+ const { origin = { x: 0, y: 0 }, scale = 1, maxWidth = GRID * 6 } = options;
309
+ const rules = options.rules ?? [inheritanceTreeRule, linearChainRule];
310
+ const model = parse(source);
311
+ const cell = measureCell(model, { maxWidth, cell: options.cell, titleHeight: options.titleHeight });
312
+ const groups = buildGroups(model, cell, rules);
313
+ const origins = packGroups(model, groups);
314
+ const rects = resolveRects(groups, origins);
315
+ return [
316
+ ...frameCommands(groups, origins, { origin, scale }),
317
+ ...emit({ model, cell, rects, ranks: relationRanks(model) }, { origin, scale }),
318
+ ];
319
+ };
@@ -0,0 +1,108 @@
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 { buildGroups, inheritanceTreeRule, linearChainRule, packGroups, resolveRects } from './uml-rules.ts';
12
+ import { compile, scoreLayout, searchPlacement } from './uml-search.ts';
13
+ import { parse } from './uml.ts';
14
+
15
+ /** The reference shape: a chain A→B→C→D plus a hierarchy Z <|- X, Z <|- Y linked into it. */
16
+ const SOURCE = trim`
17
+ classDiagram
18
+ class A
19
+ class B
20
+ class C
21
+ class D
22
+ class X
23
+ class Y
24
+ class Z
25
+
26
+ A ..> B
27
+ B ..> C
28
+ C ..> D
29
+ Z <|-- X
30
+ Z <|-- Y
31
+ X ..> B
32
+ `;
33
+
34
+ describe('uml-search', () => {
35
+ test('score rewards straight lines and punishes crossings', ({ expect }) => {
36
+ const model = parse(['classDiagram', 'class A', 'class B', 'A ..> B'].join('\n'));
37
+ const straight = new Map([
38
+ ['A', { x: 0, y: 200, w: 100, h: 50 }],
39
+ ['B', { x: 0, y: 0, w: 100, h: 50 }],
40
+ ]);
41
+ const jogged = new Map([
42
+ ['A', { x: 300, y: 200, w: 100, h: 50 }],
43
+ ['B', { x: 0, y: 0, w: 100, h: 50 }],
44
+ ]);
45
+ expect(scoreLayout(model, straight)).toBe(1);
46
+ expect(scoreLayout(model, jogged)).toBe(0);
47
+
48
+ // Two edges forced across each other score −2 relative to two straight columns.
49
+ const crossing = parse(
50
+ ['classDiagram', 'class A', 'class B', 'class C', 'class D', 'A ..> B', 'C ..> D'].join('\n'),
51
+ );
52
+ const crossed = new Map([
53
+ ['A', { x: 0, y: 200, w: 100, h: 50 }],
54
+ ['B', { x: 300, y: 0, w: 100, h: 50 }],
55
+ ['C', { x: 300, y: 200, w: 100, h: 50 }],
56
+ ['D', { x: 0, y: 0, w: 100, h: 50 }],
57
+ ]);
58
+ expect(scoreLayout(crossing, crossed)).toBeLessThan(0);
59
+ });
60
+
61
+ test('search starts from the chain and compile keeps the higher-scoring layout', ({ expect }) => {
62
+ const model = parse(SOURCE);
63
+ const cell = measureCell(model, { maxWidth: 192 });
64
+ const groups = buildGroups(model, cell, [inheritanceTreeRule, linearChainRule]);
65
+
66
+ const searched = searchPlacement(model, groups);
67
+ const packed = packGroups(model, groups);
68
+ const searchedScore = scoreLayout(model, resolveRects(groups, searched));
69
+ const packedScore = scoreLayout(model, resolveRects(groups, packed));
70
+
71
+ // The guarantee is the SELECTION, not search superiority (the search is greedy): the compiled
72
+ // layout scores at least as high as either candidate.
73
+ const compiled = objectsOf(compile(SOURCE)).filter(
74
+ (object) => !object.id.startsWith('group:') && object.id !== 'edges',
75
+ );
76
+ const rects = new Map(
77
+ compiled.map((object) => {
78
+ const frame = object.elements[0] as Scene.Box;
79
+ return [object.id, { x: object.origin!.x, y: object.origin!.y, w: frame.w, h: frame.h }];
80
+ }),
81
+ );
82
+ expect(scoreLayout(model, rects)).toBeGreaterThanOrEqual(Math.max(searchedScore, packedScore));
83
+
84
+ // The chain group anchors the layout; the hierarchy sits axis-aligned next to it.
85
+ const chain = groups.find((group) => group.rule === 'chain')!;
86
+ expect([...chain.rects.keys()]).toEqual(['A', 'B', 'C', 'D']);
87
+ expect(searched.has(chain.id)).toBe(true);
88
+ });
89
+
90
+ test('compiles every node and both group frames without overlap', ({ expect }) => {
91
+ const objects = objectsOf(compile(SOURCE));
92
+ const nodes = objects.filter((object) => !object.id.startsWith('group:') && object.id !== 'edges');
93
+ expect(nodes.length).toBe(7);
94
+
95
+ const frames = objects.filter((object) => object.id.startsWith('group:'));
96
+ expect(frames.length).toBe(2);
97
+ const bounds = frames.map((frame) => {
98
+ const rect = frame.elements[0] as Scene.Box;
99
+ return { x: frame.origin!.x, y: frame.origin!.y, w: rect.w, h: rect.h };
100
+ });
101
+ const [a, b] = bounds;
102
+ 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;
103
+ expect(overlap).toBe(false);
104
+ });
105
+ });
106
+
107
+ const objectsOf = (commands: Scene.Command[]) =>
108
+ commands.flatMap((command) => (command.op === 'upsert-object' ? [command.object] : []));
@@ -0,0 +1,266 @@
1
+ //
2
+ // Copyright 2026 DXOS.org
3
+ //
4
+
5
+ //
6
+ // Scored placement search: the longest chain anchors the center; a greedy tree search walks the
7
+ // relation graph outward, trying candidate positions for each still-unplaced group next to its
8
+ // placed neighbour (above/below preferred, connecting nodes axis-aligned) and keeping the
9
+ // highest-scoring one. The final layout is the better of the search and the rank packing, under
10
+ // one score: +1 per straight connector, −1 per crossing. Entirely custom by necessity — ELK and
11
+ // dagre are one-shot global optimizers with no scoring callback or incremental placement API —
12
+ // but either can still lay out a group's interior or contribute a candidate to the same scorer.
13
+ // See `docs/DESIGN.md`.
14
+ //
15
+
16
+ import type * as Scene from './scene.ts';
17
+ import { GRID, type CompileOptions as GridCompileOptions, type Rect, emit, measureCell, zRouter } from './uml-grid.ts';
18
+ import {
19
+ type Group,
20
+ type GroupRule,
21
+ buildGroups,
22
+ frameCommands,
23
+ inheritanceTreeRule,
24
+ linearChainRule,
25
+ packGroups,
26
+ resolveRects,
27
+ } from './uml-rules.ts';
28
+ import { type UmlModel, parse, relationRanks } from './uml.ts';
29
+
30
+ const GAP = GRID * 3;
31
+
32
+ const snap = (value: number) => Math.round(value / GRID) * GRID;
33
+
34
+ type Point = Scene.Point;
35
+ type Segment = [Point, Point];
36
+
37
+ const segmentsOf = (points: readonly Point[]): Segment[] =>
38
+ points.slice(0, -1).map((point, index) => [point, points[index + 1]]);
39
+
40
+ /** Orthogonal segment crossing: proper intersections plus collinear overlaps. */
41
+ const crosses = ([a1, a2]: Segment, [b1, b2]: Segment): boolean => {
42
+ const aVertical = a1.x === a2.x;
43
+ const bVertical = b1.x === b2.x;
44
+ if (aVertical !== bVertical) {
45
+ const [v1, v2] = aVertical ? [a1, a2] : [b1, b2];
46
+ const [h1, h2] = aVertical ? [b1, b2] : [a1, a2];
47
+ return (
48
+ v1.x > Math.min(h1.x, h2.x) &&
49
+ v1.x < Math.max(h1.x, h2.x) &&
50
+ h1.y > Math.min(v1.y, v2.y) &&
51
+ h1.y < Math.max(v1.y, v2.y)
52
+ );
53
+ }
54
+ if (aVertical) {
55
+ return a1.x === b1.x && Math.min(a1.y, a2.y) < Math.max(b1.y, b2.y) && Math.min(b1.y, b2.y) < Math.max(a1.y, a2.y);
56
+ }
57
+ return a1.y === b1.y && Math.min(a1.x, a2.x) < Math.max(b1.x, b2.x) && Math.min(b1.x, b2.x) < Math.max(a1.x, a2.x);
58
+ };
59
+
60
+ /** Direction changes along a polyline, ignoring zero-length segments. */
61
+ const bends = (route: readonly Point[]): number => {
62
+ const points = route.filter(
63
+ (point, index) => index === 0 || point.x !== route[index - 1].x || point.y !== route[index - 1].y,
64
+ );
65
+ let count = 0;
66
+ for (let index = 2; index < points.length; index++) {
67
+ const [a, b, c] = [points[index - 2], points[index - 1], points[index]];
68
+ if (!((a.x === b.x && b.x === c.x) || (a.y === b.y && b.y === c.y))) {
69
+ count++;
70
+ }
71
+ }
72
+ return count;
73
+ };
74
+
75
+ /**
76
+ * Layout score over the placed subset: +1 per straight or single-bend (L) route, −1 per crossing
77
+ * pair. Routes come from the cheap Z-router — the score ranks candidates, the A* router draws
78
+ * the final picture.
79
+ */
80
+ export const scoreLayout = (model: UmlModel, rects: Map<string, Rect>): number => {
81
+ const horizontal = model.direction === 'LR' || model.direction === 'RL';
82
+ const routes: Point[][] = [];
83
+ for (const relation of model.relations) {
84
+ const from = rects.get(relation.from);
85
+ const to = rects.get(relation.to);
86
+ if (!from || !to) {
87
+ continue;
88
+ }
89
+ routes.push(zRouter({ relation, from, to, horizontal, offset: 0 }));
90
+ }
91
+
92
+ let score = 0;
93
+ for (const route of routes) {
94
+ if (bends(route) <= 1) {
95
+ score += 1;
96
+ }
97
+ }
98
+ for (let a = 0; a < routes.length; a++) {
99
+ for (let b = a + 1; b < routes.length; b++) {
100
+ const pairCrosses = segmentsOf(routes[a]).some((left) =>
101
+ segmentsOf(routes[b]).some((right) => crosses(left, right)),
102
+ );
103
+ if (pairCrosses) {
104
+ score -= 1;
105
+ }
106
+ }
107
+ }
108
+ return score;
109
+ };
110
+
111
+ const overlaps = (a: Rect, b: Rect): boolean =>
112
+ a.x < b.x + b.w + GAP && b.x < a.x + a.w + GAP && a.y < b.y + b.h + GAP && b.y < a.y + a.h + GAP;
113
+
114
+ /**
115
+ * Greedy tree search: place the chain (or largest) group first, then repeatedly place the
116
+ * unplaced group connected to the earliest-placed node, trying axis-aligned candidates around
117
+ * the anchor (below and above preferred, then right/left, sliding off collisions) and keeping
118
+ * the best-scoring one.
119
+ */
120
+ export const searchPlacement = (model: UmlModel, groups: Group[]): Map<string, Scene.Point> => {
121
+ const groupOf = new Map<string, Group>();
122
+ for (const group of groups) {
123
+ for (const id of group.rects.keys()) {
124
+ groupOf.set(id, group);
125
+ }
126
+ }
127
+
128
+ const seed =
129
+ groups.find((group) => group.rule === 'chain') ??
130
+ [...groups].sort((left, right) => right.rects.size - left.rects.size)[0];
131
+ // An empty or unparseable source yields no groups; compile then emits an empty scene.
132
+ if (!seed) {
133
+ return new Map();
134
+ }
135
+
136
+ const origins = new Map<string, Point>();
137
+ origins.set(seed.id, { x: 0, y: 0 });
138
+ const placedNodes: string[] = [...seed.rects.keys()];
139
+
140
+ const absolute = (id: string): Rect | undefined => {
141
+ const group = groupOf.get(id)!;
142
+ const at = origins.get(group.id);
143
+ if (!at) {
144
+ return undefined;
145
+ }
146
+ const local = group.rects.get(id)!;
147
+ return { ...local, x: at.x + local.x, y: at.y + local.y };
148
+ };
149
+ const placedBounds = (): Rect[] =>
150
+ groups.flatMap((group) => {
151
+ const at = origins.get(group.id);
152
+ return at ? [{ x: at.x, y: at.y, w: group.w, h: group.h }] : [];
153
+ });
154
+
155
+ while (origins.size < groups.length) {
156
+ // Frontier: the earliest-placed node with a relation into an unplaced group.
157
+ let anchorId: string | undefined;
158
+ let connection: { group: Group; node: string } | undefined;
159
+ for (const placed of placedNodes) {
160
+ for (const relation of model.relations) {
161
+ const other = relation.from === placed ? relation.to : relation.to === placed ? relation.from : undefined;
162
+ if (other && !origins.has(groupOf.get(other)!.id)) {
163
+ anchorId = placed;
164
+ connection = { group: groupOf.get(other)!, node: other };
165
+ break;
166
+ }
167
+ }
168
+ if (connection) {
169
+ break;
170
+ }
171
+ }
172
+ // Disconnected remainder: park it right of everything placed so far.
173
+ if (!connection || !anchorId) {
174
+ const group = groups.find((candidate) => !origins.has(candidate.id))!;
175
+ const rightmost = Math.max(...placedBounds().map((rect) => rect.x + rect.w), 0);
176
+ origins.set(group.id, { x: snap(rightmost + GAP), y: 0 });
177
+ placedNodes.push(...group.rects.keys());
178
+ continue;
179
+ }
180
+
181
+ const anchor = absolute(anchorId)!;
182
+ const local = connection.group.rects.get(connection.node)!;
183
+ const anchorCenterX = anchor.x + anchor.w / 2;
184
+ const anchorCenterY = anchor.y + anchor.h / 2;
185
+ // Candidates align the connecting node's axis with the anchor's, above/below preferred.
186
+ const candidates: Point[] = [
187
+ { x: anchorCenterX - local.x - local.w / 2, y: anchor.y + anchor.h + GAP },
188
+ { x: anchorCenterX - local.x - local.w / 2, y: anchor.y - GAP - connection.group.h },
189
+ { x: anchor.x + anchor.w + GAP, y: anchorCenterY - local.y - local.h / 2 },
190
+ { x: anchor.x - GAP - connection.group.w, y: anchorCenterY - local.y - local.h / 2 },
191
+ ];
192
+
193
+ let best: { at: Point; score: number } | undefined;
194
+ for (const [candidateIndex, candidate] of candidates.entries()) {
195
+ // Slide along the placement axis (in grid steps) until the group's border clears others.
196
+ const at = { x: snap(candidate.x), y: snap(candidate.y) };
197
+ const bounds = placedBounds();
198
+ let tries = 0;
199
+ // The first two candidates are the below/above placements; index, not coordinate
200
+ // coincidence, decides the slide axis.
201
+ const vertical = candidateIndex < 2;
202
+ while (
203
+ bounds.some((rect) => overlaps(rect, { x: at.x, y: at.y, w: connection.group.w, h: connection.group.h })) &&
204
+ tries < 24
205
+ ) {
206
+ if (vertical) {
207
+ at.x += GRID * 2;
208
+ } else {
209
+ at.y += GRID * 2;
210
+ }
211
+ tries++;
212
+ }
213
+ if (tries >= 24) {
214
+ continue;
215
+ }
216
+ origins.set(connection.group.id, at);
217
+ const score = scoreLayout(model, resolveRects(groups, originsSubset(groups, origins)));
218
+ origins.delete(connection.group.id);
219
+ // Strict >, so earlier (preferred: below, then above) candidates win ties.
220
+ if (!best || score > best.score) {
221
+ best = { at, score };
222
+ }
223
+ }
224
+
225
+ const at = best?.at ?? { x: snap(Math.max(...placedBounds().map((rect) => rect.x + rect.w)) + GAP), y: 0 };
226
+ origins.set(connection.group.id, at);
227
+ placedNodes.push(...connection.group.rects.keys());
228
+ }
229
+
230
+ // Normalize the top-left to (0, 0).
231
+ const minX = Math.min(...[...origins.values()].map((point) => point.x));
232
+ const minY = Math.min(...[...origins.values()].map((point) => point.y));
233
+ return new Map([...origins].map(([id, point]) => [id, { x: point.x - minX, y: point.y - minY }]));
234
+ };
235
+
236
+ /** Placement restricted to placed groups, so partial layouts score without phantom rects. */
237
+ const originsSubset = (groups: Group[], origins: Map<string, Point>): Map<string, Point> =>
238
+ new Map(groups.filter((group) => origins.has(group.id)).map((group) => [group.id, origins.get(group.id)!]));
239
+
240
+ export type CompileOptions = Omit<GridCompileOptions, 'route'> & {
241
+ rules?: GroupRule[];
242
+ };
243
+
244
+ /**
245
+ * Compile via the scored search, falling back to whichever of {search, rank packing} scores
246
+ * higher — the search is greedy, so the packing occasionally beats it.
247
+ */
248
+ export const compile = (source: string, options: CompileOptions = {}): Scene.Command[] => {
249
+ const { origin = { x: 0, y: 0 }, scale = 1, maxWidth = GRID * 6 } = options;
250
+ const rules = options.rules ?? [inheritanceTreeRule, linearChainRule];
251
+ const model = parse(source);
252
+ const cell = measureCell(model, { maxWidth, cell: options.cell, titleHeight: options.titleHeight });
253
+ const groups = buildGroups(model, cell, rules);
254
+
255
+ const searched = searchPlacement(model, groups);
256
+ const packed = packGroups(model, groups);
257
+ const searchedScore = scoreLayout(model, resolveRects(groups, searched));
258
+ const packedScore = scoreLayout(model, resolveRects(groups, packed));
259
+ const origins = searchedScore >= packedScore ? searched : packed;
260
+
261
+ const rects = resolveRects(groups, origins);
262
+ return [
263
+ ...frameCommands(groups, origins, { origin, scale }),
264
+ ...emit({ model, cell, rects, ranks: relationRanks(model) }, { origin, scale }),
265
+ ];
266
+ };