@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,393 @@
1
+ //
2
+ // Copyright 2026 DXOS.org
3
+ //
4
+
5
+ //
6
+ // Layout diagnostics: a pure analysis of an emitted scene, independent of the dialect and the
7
+ // placement strategy that produced it. Everything measured here is geometry already present in
8
+ // the scene, so one report covers every dialect, every placement engine, a hand-authored scene,
9
+ // and a scene read back from a renderer after the user dragged it. See `docs/DESIGN.md`.
10
+ //
11
+
12
+ import * as Schema from 'effect/Schema';
13
+
14
+ import * as Layout from './layout.ts';
15
+ import type * as Scene from './scene.ts';
16
+
17
+ export const Severity = Schema.Literals(['error', 'warning']);
18
+ export type Severity = Schema.Schema.Type<typeof Severity>;
19
+
20
+ /**
21
+ * `error` codes are objective defects — the picture is wrong, not merely busy — and gate CI.
22
+ * `warning` codes are quality metrics that trend rather than pass/fail; they are golden-filed so
23
+ * a layout change reads as a reviewable diff instead of a threshold argument.
24
+ */
25
+ export const Code = Schema.Literals([
26
+ 'node-overlap',
27
+ 'route-through-node',
28
+ 'label-overflow',
29
+ 'edge-crossing',
30
+ 'excessive-bends',
31
+ ]);
32
+ export type Code = Schema.Schema.Type<typeof Code>;
33
+
34
+ export const SEVERITY: Record<Code, Severity> = {
35
+ 'node-overlap': 'error',
36
+ 'route-through-node': 'error',
37
+ 'label-overflow': 'error',
38
+ 'edge-crossing': 'warning',
39
+ 'excessive-bends': 'warning',
40
+ };
41
+
42
+ /** A schema so operations can return diagnostics to the agent — the repair loop. */
43
+ export const Diagnostic = Schema.Struct({
44
+ code: Code,
45
+ severity: Severity,
46
+ message: Schema.String,
47
+ refs: Schema.Array(Schema.String).annotate({
48
+ description: 'Scene refs implicated: `objectId` or `objectId/elementId`, as `Arrow.from`/`to` spell them.',
49
+ }),
50
+ });
51
+ export type Diagnostic = Schema.Schema.Type<typeof Diagnostic>;
52
+
53
+ /** Aggregate counts; the soft ones are the score a layout change is reviewed against. */
54
+ export type Metrics = {
55
+ nodes: number;
56
+ connectors: number;
57
+ overlaps: number;
58
+ routesThroughNodes: number;
59
+ labelOverflows: number;
60
+ crossings: number;
61
+ bends: number;
62
+ /** Total connector length in scene units; long edges are hard to follow. */
63
+ length: number;
64
+ width: number;
65
+ height: number;
66
+ /** Boxes that enclose another object's box (subgraph / group frames). */
67
+ containers: number;
68
+ /** Smallest gap from a container to its nearest container (Infinity with fewer than two). */
69
+ frameGapMin: number;
70
+ /** Spread (max − min) of each container's nearest-neighbour gap; 0 when gutters are uniform. */
71
+ frameGapSpread: number;
72
+ };
73
+
74
+ export type Report = {
75
+ diagnostics: readonly Diagnostic[];
76
+ metrics: Metrics;
77
+ };
78
+
79
+ export type Options = {
80
+ /** Bends per connector above which `excessive-bends` is reported (default 3). */
81
+ maxBends?: number;
82
+ };
83
+
84
+ type Point = Scene.Point;
85
+ type Rect = { x: number; y: number; w: number; h: number };
86
+ type Segment = [Point, Point];
87
+
88
+ /** Tolerance in scene units; ports land on a half-grid, so this only absorbs float error. */
89
+ const EPSILON = 0.5;
90
+
91
+ const place = (object: Scene.WorldObject, point: Point): Point => {
92
+ const { x = 0, y = 0 } = object.origin ?? {};
93
+ const scale = object.scale ?? 1;
94
+ return { x: x + point.x * scale, y: y + point.y * scale };
95
+ };
96
+
97
+ const isBox = (element: Scene.Element): element is Scene.Box =>
98
+ element.kind === 'rect' || element.kind === 'ellipse' || element.kind === 'diamond' || element.kind === 'triangle';
99
+
100
+ type Node = { ref: string; rect: Rect; text?: string; weight: Scene.Weight };
101
+
102
+ /** Absolute rects for every closed shape, keyed by scene ref. */
103
+ const nodes = (objects: readonly Scene.WorldObject[]): Node[] =>
104
+ objects.flatMap((object) =>
105
+ object.elements.filter(isBox).map((element) => {
106
+ const origin = place(object, { x: element.x, y: element.y });
107
+ const scale = object.scale ?? 1;
108
+ return {
109
+ ref: `${object.id}/${element.id}`,
110
+ rect: { x: origin.x, y: origin.y, w: element.w * scale, h: element.h * scale },
111
+ text: element.text,
112
+ weight: element.weight ?? 'm',
113
+ };
114
+ }),
115
+ );
116
+
117
+ type Connector = { ref: string; points: Point[] };
118
+
119
+ /**
120
+ * Connector paths, rejoining the `line` prefix and `arrow` head the emitters split a routed edge
121
+ * into (`<id>-path` + `<id>`); a plain arrow is a one-segment path.
122
+ */
123
+ const connectors = (objects: readonly Scene.WorldObject[]): Connector[] => {
124
+ const paths = new Map<string, Connector>();
125
+ for (const object of objects) {
126
+ for (const element of object.elements) {
127
+ if (element.kind === 'line') {
128
+ const id = element.id.replace(/-path$/, '');
129
+ const ref = `${object.id}/${id}`;
130
+ paths.set(ref, { ref, points: element.points.map((point) => place(object, point)) });
131
+ }
132
+ }
133
+ for (const element of object.elements) {
134
+ if (element.kind === 'arrow' && element.start && element.end) {
135
+ const ref = `${object.id}/${element.id}`;
136
+ const head = [place(object, element.start), place(object, element.end)];
137
+ const existing = paths.get(ref);
138
+ // The head's start repeats the polyline's last waypoint; keep one copy.
139
+ paths.set(ref, { ref, points: existing ? [...existing.points.slice(0, -1), ...head] : head });
140
+ }
141
+ }
142
+ }
143
+ return [...paths.values()];
144
+ };
145
+
146
+ const segmentsOf = ({ points }: Connector): Segment[] =>
147
+ points.slice(0, -1).map((point, index): Segment => [point, points[index + 1]]);
148
+
149
+ const orientation = (origin: Point, a: Point, b: Point) =>
150
+ (a.x - origin.x) * (b.y - origin.y) - (a.y - origin.y) * (b.x - origin.x);
151
+
152
+ const coincident = (a: Point, b: Point) => Math.abs(a.x - b.x) < EPSILON && Math.abs(a.y - b.y) < EPSILON;
153
+
154
+ /** Proper crossing: the segments intersect away from all four endpoints (shared ports do not count). */
155
+ const properlyCrosses = ([a1, a2]: Segment, [b1, b2]: Segment): boolean => {
156
+ if ([a1, a2].some((a) => [b1, b2].some((b) => coincident(a, b)))) {
157
+ return false;
158
+ }
159
+ const d1 = orientation(a1, a2, b1);
160
+ const d2 = orientation(a1, a2, b2);
161
+ const d3 = orientation(b1, b2, a1);
162
+ const d4 = orientation(b1, b2, a2);
163
+ return d1 > 0 !== d2 > 0 && d3 > 0 !== d4 > 0;
164
+ };
165
+
166
+ /** Overlap area of two rects; 0 when they merely touch. */
167
+ const overlapArea = (a: Rect, b: Rect): number => {
168
+ const w = Math.min(a.x + a.w, b.x + b.w) - Math.max(a.x, b.x);
169
+ const h = Math.min(a.y + a.h, b.y + b.h) - Math.max(a.y, b.y);
170
+ return w > EPSILON && h > EPSILON ? w * h : 0;
171
+ };
172
+
173
+ /**
174
+ * Strict enclosure: the inner rect fits inside AND is smaller. Two identical rects are stacked
175
+ * nodes — the strongest overlap — not a container and its member.
176
+ */
177
+ const contains = (outer: Rect, inner: Rect): boolean =>
178
+ outer.x - EPSILON <= inner.x &&
179
+ outer.y - EPSILON <= inner.y &&
180
+ outer.x + outer.w + EPSILON >= inner.x + inner.w &&
181
+ outer.y + outer.h + EPSILON >= inner.y + inner.h &&
182
+ (inner.w < outer.w - EPSILON || inner.h < outer.h - EPSILON);
183
+
184
+ /**
185
+ * Length of the segment's run through the rect's INTERIOR (Liang-Barsky). A connector legitimately
186
+ * terminating at a node only touches its border, so an interior run identifies a route drawn
187
+ * across a node it does not belong to — no knowledge of the edge's terminals required.
188
+ */
189
+ const interiorRun = ([start, end]: Segment, rect: Rect): number => {
190
+ const dx = end.x - start.x;
191
+ const dy = end.y - start.y;
192
+ // Inset so a route running flush along a border, or into a port, is not counted.
193
+ const box = { x: rect.x + EPSILON, y: rect.y + EPSILON, w: rect.w - EPSILON * 2, h: rect.h - EPSILON * 2 };
194
+ if (box.w <= 0 || box.h <= 0) {
195
+ return 0;
196
+ }
197
+ let enter = 0;
198
+ let exit = 1;
199
+ for (const [p, q] of [
200
+ [-dx, start.x - box.x],
201
+ [dx, box.x + box.w - start.x],
202
+ [-dy, start.y - box.y],
203
+ [dy, box.y + box.h - start.y],
204
+ ]) {
205
+ if (p === 0) {
206
+ if (q < 0) {
207
+ return 0;
208
+ }
209
+ continue;
210
+ }
211
+ const r = q / p;
212
+ if (p < 0) {
213
+ enter = Math.max(enter, r);
214
+ } else {
215
+ exit = Math.min(exit, r);
216
+ }
217
+ }
218
+ return enter >= exit ? 0 : Math.hypot(dx, dy) * (exit - enter);
219
+ };
220
+
221
+ /** Bends in an orthogonal path: waypoints where the run changes axis. */
222
+ const bendCount = ({ points }: Connector): number => {
223
+ let bends = 0;
224
+ for (let index = 1; index < points.length - 1; index++) {
225
+ const [previous, point, next] = [points[index - 1], points[index], points[index + 1]];
226
+ const collinear =
227
+ (Math.abs(previous.x - point.x) < EPSILON && Math.abs(point.x - next.x) < EPSILON) ||
228
+ (Math.abs(previous.y - point.y) < EPSILON && Math.abs(point.y - next.y) < EPSILON);
229
+ if (!collinear) {
230
+ bends++;
231
+ }
232
+ }
233
+ return bends;
234
+ };
235
+
236
+ /**
237
+ * Estimated bounds of a wrapped label, using the shared font metrics the dialects size boxes by.
238
+ * The last line carries no trailing leading: `lineH ≈ 1.35em` (see `FONT_METRICS`), so the glyph
239
+ * block is `(lines − 1) · lineH + em`.
240
+ */
241
+ const labelFits = ({ rect, text, weight }: Node): boolean => {
242
+ if (!text) {
243
+ return true;
244
+ }
245
+ const font = Layout.FONT_METRICS[weight];
246
+ const perLine = Math.max(1, Math.floor(rect.w / font.charW));
247
+ const lines = text.split('\n').reduce((total, line) => total + Math.max(1, Math.ceil(line.length / perLine)), 0);
248
+ return (lines - 1) * font.lineH + font.lineH / 1.35 <= rect.h + EPSILON;
249
+ };
250
+
251
+ /**
252
+ * Analyzes an emitted scene for layout defects and quality metrics. Pure: the same scene always
253
+ * yields the same report, so the soft metrics can be golden-filed and the hard codes asserted.
254
+ */
255
+ export const analyze = (objects: readonly Scene.WorldObject[], { maxBends = 3 }: Options = {}): Report => {
256
+ const diagnostics: Diagnostic[] = [];
257
+ const allNodes = nodes(objects);
258
+ const allConnectors = connectors(objects);
259
+ const ownerOf = (ref: string) => ref.slice(0, ref.indexOf('/'));
260
+
261
+ // A box enclosing another object's box is a container (subgraph or group frame): connectors
262
+ // cross its border by design, so it is never an obstacle.
263
+ const containers = new Set(
264
+ allNodes
265
+ .filter((outer) =>
266
+ allNodes.some((inner) => ownerOf(inner.ref) !== ownerOf(outer.ref) && contains(outer.rect, inner.rect)),
267
+ )
268
+ .map(({ ref }) => ref),
269
+ );
270
+
271
+ // Overlap is compared across objects only: an object's own shapes are composed deliberately
272
+ // (a UML frame behind its title bar), and a container legitimately encloses its members.
273
+ for (let i = 0; i < allNodes.length; i++) {
274
+ for (let j = i + 1; j < allNodes.length; j++) {
275
+ const [left, right] = [allNodes[i], allNodes[j]];
276
+ if (ownerOf(left.ref) === ownerOf(right.ref)) {
277
+ continue;
278
+ }
279
+ if (contains(left.rect, right.rect) || contains(right.rect, left.rect)) {
280
+ continue;
281
+ }
282
+ if (overlapArea(left.rect, right.rect) > 0) {
283
+ diagnostics.push({
284
+ code: 'node-overlap',
285
+ severity: SEVERITY['node-overlap'],
286
+ message: `Nodes "${left.ref}" and "${right.ref}" overlap.`,
287
+ refs: [left.ref, right.ref],
288
+ });
289
+ }
290
+ }
291
+ }
292
+
293
+ for (const connector of allConnectors) {
294
+ for (const node of allNodes) {
295
+ // Shapes in the connector's own object are its decoration, not obstacles.
296
+ if (ownerOf(node.ref) === ownerOf(connector.ref) || containers.has(node.ref)) {
297
+ continue;
298
+ }
299
+ const run = segmentsOf(connector).reduce((total, segment) => total + interiorRun(segment, node.rect), 0);
300
+ if (run > EPSILON) {
301
+ diagnostics.push({
302
+ code: 'route-through-node',
303
+ severity: SEVERITY['route-through-node'],
304
+ message: `Connector "${connector.ref}" is drawn across node "${node.ref}".`,
305
+ refs: [connector.ref, node.ref],
306
+ });
307
+ }
308
+ }
309
+ }
310
+
311
+ for (const node of allNodes) {
312
+ if (!labelFits(node)) {
313
+ diagnostics.push({
314
+ code: 'label-overflow',
315
+ severity: SEVERITY['label-overflow'],
316
+ message: `Label of "${node.ref}" does not fit its shape.`,
317
+ refs: [node.ref],
318
+ });
319
+ }
320
+ }
321
+
322
+ let crossings = 0;
323
+ for (let i = 0; i < allConnectors.length; i++) {
324
+ for (let j = i + 1; j < allConnectors.length; j++) {
325
+ const [left, right] = [allConnectors[i], allConnectors[j]];
326
+ const crossed = segmentsOf(left).some((a) => segmentsOf(right).some((b) => properlyCrosses(a, b)));
327
+ if (crossed) {
328
+ crossings++;
329
+ diagnostics.push({
330
+ code: 'edge-crossing',
331
+ severity: SEVERITY['edge-crossing'],
332
+ message: `Connectors "${left.ref}" and "${right.ref}" cross.`,
333
+ refs: [left.ref, right.ref],
334
+ });
335
+ }
336
+ }
337
+ }
338
+
339
+ let bends = 0;
340
+ let length = 0;
341
+ for (const connector of allConnectors) {
342
+ length += segmentsOf(connector).reduce((total, [a, b]) => total + Math.hypot(b.x - a.x, b.y - a.y), 0);
343
+ const count = bendCount(connector);
344
+ bends += count;
345
+ if (count > maxBends) {
346
+ diagnostics.push({
347
+ code: 'excessive-bends',
348
+ severity: SEVERITY['excessive-bends'],
349
+ message: `Connector "${connector.ref}" takes ${count} bends.`,
350
+ refs: [connector.ref],
351
+ });
352
+ }
353
+ }
354
+
355
+ const xs = allNodes.flatMap(({ rect }) => [rect.x, rect.x + rect.w]);
356
+ const ys = allNodes.flatMap(({ rect }) => [rect.y, rect.y + rect.h]);
357
+
358
+ // Gutter uniformity between containers: each frame's gap to its nearest frame. Nearest-neighbour
359
+ // (rather than all pairs) so a row of three is judged on its two gutters, not the span across.
360
+ const frameRects = allNodes.filter(({ ref }) => containers.has(ref)).map(({ rect }) => rect);
361
+ const gapBetween = (a: Rect, b: Rect) =>
362
+ Math.max(a.x - (b.x + b.w), b.x - (a.x + a.w), a.y - (b.y + b.h), b.y - (a.y + a.h));
363
+ const nearestGaps = frameRects.flatMap((frame, index) => {
364
+ const gaps = frameRects
365
+ .filter((_, other) => other !== index)
366
+ .map((other) => gapBetween(frame, other))
367
+ .filter((gap) => gap >= 0);
368
+ return gaps.length ? [Math.min(...gaps)] : [];
369
+ });
370
+
371
+ return {
372
+ diagnostics,
373
+ metrics: {
374
+ nodes: allNodes.length,
375
+ connectors: allConnectors.length,
376
+ overlaps: diagnostics.filter(({ code }) => code === 'node-overlap').length,
377
+ routesThroughNodes: diagnostics.filter(({ code }) => code === 'route-through-node').length,
378
+ labelOverflows: diagnostics.filter(({ code }) => code === 'label-overflow').length,
379
+ crossings,
380
+ bends,
381
+ length,
382
+ width: xs.length ? Math.max(...xs) - Math.min(...xs) : 0,
383
+ height: ys.length ? Math.max(...ys) - Math.min(...ys) : 0,
384
+ containers: frameRects.length,
385
+ frameGapMin: nearestGaps.length ? Math.min(...nearestGaps) : Infinity,
386
+ frameGapSpread: nearestGaps.length >= 2 ? Math.max(...nearestGaps) - Math.min(...nearestGaps) : 0,
387
+ },
388
+ };
389
+ };
390
+
391
+ /** Errors only — the set that gates CI and that an agent must fix before the diagram is usable. */
392
+ export const errors = (report: Report): readonly Diagnostic[] =>
393
+ report.diagnostics.filter(({ severity }) => severity === 'error');
package/src/dialect.ts ADDED
@@ -0,0 +1,53 @@
1
+ //
2
+ // Copyright 2026 DXOS.org
3
+ //
4
+
5
+ //
6
+ // Extension point for higher-level diagram languages (mermaid, d2, sequence, uml-class, …).
7
+ // A dialect compiles its input into scene edit commands — owning any layout it needs — so its
8
+ // output lands as ordinary world objects and stays editable by id. Mirrors the architecture
9
+ // shared by Mermaid/D2/Kroki: dialect → intermediate positioned model → renderer.
10
+ //
11
+
12
+ import * as Effect from 'effect/Effect';
13
+ import * as Schema from 'effect/Schema';
14
+
15
+ import { invariant } from '@dxos/invariant';
16
+
17
+ import * as Scene from './scene.ts';
18
+
19
+ export type Dialect<Input = any> = {
20
+ id: string;
21
+ description: string;
22
+ input: Schema.Codec<Input, any>;
23
+ compile: (input: Input) => Effect.Effect<readonly Scene.Command[]>;
24
+ };
25
+
26
+ /** The core dialect: input is already scene edit commands (identity compile). */
27
+ export const sceneDialect: Dialect<readonly Scene.Command[]> = {
28
+ id: 'scene',
29
+ description: 'Positioned world objects and elements; the agent does the layout.',
30
+ input: Schema.Array(Scene.Command),
31
+ compile: Effect.succeed,
32
+ };
33
+
34
+ export class DialectRegistry {
35
+ readonly #dialects = new Map<string, Dialect>();
36
+
37
+ constructor(dialects: Dialect[] = [sceneDialect]) {
38
+ dialects.forEach((dialect) => this.register(dialect));
39
+ }
40
+
41
+ register(dialect: Dialect): void {
42
+ invariant(!this.#dialects.has(dialect.id), `duplicate dialect: ${dialect.id}`);
43
+ this.#dialects.set(dialect.id, dialect);
44
+ }
45
+
46
+ get(id: string): Dialect | undefined {
47
+ return this.#dialects.get(id);
48
+ }
49
+
50
+ list(): Dialect[] {
51
+ return [...this.#dialects.values()];
52
+ }
53
+ }
package/src/index.ts ADDED
@@ -0,0 +1,19 @@
1
+ //
2
+ // Copyright 2026 DXOS.org
3
+ //
4
+
5
+ export * from './content.ts';
6
+ export * from './dialect.ts';
7
+ export * as Diagnostics from './diagnostics.ts';
8
+ export * as Layout from './layout.ts';
9
+ export * as Mermaid from './mermaid.ts';
10
+ export * as MermaidEngine from './mermaid-engine.ts';
11
+ export * as Objective from './objective.ts';
12
+ export * as Scene from './scene.ts';
13
+ export * from './svg-handler.ts';
14
+ export * as Ui from './ui.ts';
15
+ export * as Uml from './uml.ts';
16
+ export * as UmlEngine from './uml-engine.ts';
17
+ export * as UmlGrid from './uml-grid.ts';
18
+ export * as UmlRules from './uml-rules.ts';
19
+ export * as UmlSearch from './uml-search.ts';
package/src/layout.ts ADDED
@@ -0,0 +1,99 @@
1
+ //
2
+ // Copyright 2026 DXOS.org
3
+ //
4
+
5
+ //
6
+ // Shared layered-layout helpers for dialects that compile edge lists without coordinates
7
+ // (mermaid flowchart, UML class). Ranking is longest-path over the forward edges; cycles are
8
+ // broken by ignoring DFS back edges so a loop cannot push a node below its own successors.
9
+ //
10
+
11
+ import type * as Scene from './scene.ts';
12
+
13
+ export type LayoutEdge = {
14
+ from: string;
15
+ to: string;
16
+ };
17
+
18
+ export type FontMetrics = {
19
+ /** Average glyph advance, in scene units at scale 1. */
20
+ charW: number;
21
+ /** Line height, in scene units at scale 1. */
22
+ lineH: number;
23
+ };
24
+
25
+ /**
26
+ * Empirical text metrics per scene weight, calibrated to tldraw's draw font (`size` s/m/l/xl runs
27
+ * ~18/25/38/48 px): charW ≈ 0.6–0.67 em, lineH ≈ 1.35 em. Dialects size boxes from these so text
28
+ * fits without the renderer growing shapes over their neighbours.
29
+ */
30
+ export const FONT_METRICS: Record<Scene.Weight, FontMetrics> = {
31
+ s: { charW: 12, lineH: 26 },
32
+ m: { charW: 15, lineH: 34 },
33
+ l: { charW: 23, lineH: 51 },
34
+ xl: { charW: 29, lineH: 65 },
35
+ };
36
+
37
+ /**
38
+ * Edges that close a cycle, found by DFS: an edge into a node still on the stack points backwards.
39
+ * Ranking ignores them, so `C --> Y --> C` ranks C by its forward predecessors alone instead of
40
+ * chasing the loop.
41
+ */
42
+ export const backEdges = <Edge extends LayoutEdge>(nodeIds: readonly string[], edges: readonly Edge[]): Set<Edge> => {
43
+ const outgoing = new Map<string, Edge[]>();
44
+ for (const edge of edges) {
45
+ outgoing.set(edge.from, [...(outgoing.get(edge.from) ?? []), edge]);
46
+ }
47
+
48
+ const back = new Set<Edge>();
49
+ const done = new Set<string>();
50
+ const onStack = new Set<string>();
51
+
52
+ const visit = (id: string) => {
53
+ onStack.add(id);
54
+ for (const edge of outgoing.get(id) ?? []) {
55
+ if (onStack.has(edge.to)) {
56
+ back.add(edge);
57
+ } else if (!done.has(edge.to)) {
58
+ visit(edge.to);
59
+ }
60
+ }
61
+ onStack.delete(id);
62
+ done.add(id);
63
+ };
64
+
65
+ // Roots first so the DFS classifies edges along the natural reading order of the diagram.
66
+ const targets = new Set(edges.map((edge) => edge.to));
67
+ for (const id of nodeIds) {
68
+ if (!targets.has(id) && !done.has(id)) {
69
+ visit(id);
70
+ }
71
+ }
72
+ for (const id of nodeIds) {
73
+ if (!done.has(id)) {
74
+ visit(id);
75
+ }
76
+ }
77
+ return back;
78
+ };
79
+
80
+ /** Assign each node a rank one past its deepest forward predecessor. */
81
+ export const rank = (nodeIds: readonly string[], edges: readonly LayoutEdge[]): Map<string, number> => {
82
+ const back = backEdges(nodeIds, edges);
83
+ const forward = edges.filter((edge) => !back.has(edge));
84
+ const ranks = new Map(nodeIds.map((id) => [id, 0]));
85
+ for (let pass = 0; pass < nodeIds.length; pass++) {
86
+ let changed = false;
87
+ for (const edge of forward) {
88
+ const next = (ranks.get(edge.from) ?? 0) + 1;
89
+ if (next > (ranks.get(edge.to) ?? 0)) {
90
+ ranks.set(edge.to, next);
91
+ changed = true;
92
+ }
93
+ }
94
+ if (!changed) {
95
+ break;
96
+ }
97
+ }
98
+ return ranks;
99
+ };