@dxos/diagram 0.11.1 → 0.13.0

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 (171) hide show
  1. package/dist/lib/chunk-parse.mjs +9396 -0
  2. package/dist/lib/chunk-parse.mjs.map +1 -0
  3. package/dist/lib/extension.mjs +204 -0
  4. package/dist/lib/extension.mjs.map +1 -0
  5. package/dist/lib/index.mjs +497 -2846
  6. package/dist/lib/index.mjs.map +1 -1
  7. package/dist/types/src/aesthetics.d.ts +12 -0
  8. package/dist/types/src/aesthetics.d.ts.map +1 -0
  9. package/dist/types/src/aesthetics.test.d.ts +2 -0
  10. package/dist/types/src/aesthetics.test.d.ts.map +1 -0
  11. package/dist/types/src/appeal.d.ts +55 -0
  12. package/dist/types/src/appeal.d.ts.map +1 -0
  13. package/dist/types/src/appeal.test.d.ts +2 -0
  14. package/dist/types/src/appeal.test.d.ts.map +1 -0
  15. package/dist/types/src/architecture.d.ts +86 -0
  16. package/dist/types/src/architecture.d.ts.map +1 -0
  17. package/dist/types/src/architecture.test.d.ts +2 -0
  18. package/dist/types/src/architecture.test.d.ts.map +1 -0
  19. package/dist/types/src/compact.d.ts +39 -0
  20. package/dist/types/src/compact.d.ts.map +1 -0
  21. package/dist/types/src/compact.test.d.ts +2 -0
  22. package/dist/types/src/compact.test.d.ts.map +1 -0
  23. package/dist/types/src/diagnostics.d.ts +70 -3
  24. package/dist/types/src/diagnostics.d.ts.map +1 -1
  25. package/dist/types/src/dsl/cli.d.ts +2 -0
  26. package/dist/types/src/dsl/cli.d.ts.map +1 -0
  27. package/dist/types/src/dsl/compile.d.ts +12 -0
  28. package/dist/types/src/dsl/compile.d.ts.map +1 -0
  29. package/dist/types/src/dsl/convert.d.ts +45 -0
  30. package/dist/types/src/dsl/convert.d.ts.map +1 -0
  31. package/dist/types/src/dsl/convert.test.d.ts +2 -0
  32. package/dist/types/src/dsl/convert.test.d.ts.map +1 -0
  33. package/dist/types/src/dsl/dsl.test.d.ts +2 -0
  34. package/dist/types/src/dsl/dsl.test.d.ts.map +1 -0
  35. package/dist/types/src/dsl/gen/diagram.d.ts +3 -0
  36. package/dist/types/src/dsl/gen/diagram.d.ts.map +1 -0
  37. package/dist/types/src/dsl/index.d.ts +7 -0
  38. package/dist/types/src/dsl/index.d.ts.map +1 -0
  39. package/dist/types/src/dsl/parse.d.ts +49 -0
  40. package/dist/types/src/dsl/parse.d.ts.map +1 -0
  41. package/dist/types/src/dsl/print.d.ts +19 -0
  42. package/dist/types/src/dsl/print.d.ts.map +1 -0
  43. package/dist/types/src/dsl/semantic.test.d.ts +2 -0
  44. package/dist/types/src/dsl/semantic.test.d.ts.map +1 -0
  45. package/dist/types/src/dsl/vocabulary.d.ts +61 -0
  46. package/dist/types/src/dsl/vocabulary.d.ts.map +1 -0
  47. package/dist/types/src/dx-svg.d.ts +12 -0
  48. package/dist/types/src/dx-svg.d.ts.map +1 -0
  49. package/dist/types/src/dx-svg.test.d.ts +2 -0
  50. package/dist/types/src/dx-svg.test.d.ts.map +1 -0
  51. package/dist/types/src/extension/complete.d.ts +3 -0
  52. package/dist/types/src/extension/complete.d.ts.map +1 -0
  53. package/dist/types/src/extension/extension.test.d.ts +2 -0
  54. package/dist/types/src/extension/extension.test.d.ts.map +1 -0
  55. package/dist/types/src/extension/highlight.d.ts +14 -0
  56. package/dist/types/src/extension/highlight.d.ts.map +1 -0
  57. package/dist/types/src/extension/index.d.ts +6 -0
  58. package/dist/types/src/extension/index.d.ts.map +1 -0
  59. package/dist/types/src/extension/language.d.ts +13 -0
  60. package/dist/types/src/extension/language.d.ts.map +1 -0
  61. package/dist/types/src/extension/lint.d.ts +21 -0
  62. package/dist/types/src/extension/lint.d.ts.map +1 -0
  63. package/dist/types/src/extension/syntax.d.ts +3 -0
  64. package/dist/types/src/extension/syntax.d.ts.map +1 -0
  65. package/dist/types/src/index.d.ts +14 -0
  66. package/dist/types/src/index.d.ts.map +1 -1
  67. package/dist/types/src/mermaid-engine.d.ts +40 -1
  68. package/dist/types/src/mermaid-engine.d.ts.map +1 -1
  69. package/dist/types/src/mermaid.d.ts +3 -3
  70. package/dist/types/src/mermaid.d.ts.map +1 -1
  71. package/dist/types/src/nudge.d.ts +30 -0
  72. package/dist/types/src/nudge.d.ts.map +1 -0
  73. package/dist/types/src/nudge.test.d.ts +2 -0
  74. package/dist/types/src/nudge.test.d.ts.map +1 -0
  75. package/dist/types/src/objective.d.ts +4 -0
  76. package/dist/types/src/objective.d.ts.map +1 -1
  77. package/dist/types/src/ortho-router.d.ts +13 -1
  78. package/dist/types/src/ortho-router.d.ts.map +1 -1
  79. package/dist/types/src/ports.d.ts +40 -0
  80. package/dist/types/src/ports.d.ts.map +1 -0
  81. package/dist/types/src/ports.test.d.ts +2 -0
  82. package/dist/types/src/ports.test.d.ts.map +1 -0
  83. package/dist/types/src/relations.test.d.ts +2 -0
  84. package/dist/types/src/relations.test.d.ts.map +1 -0
  85. package/dist/types/src/rules.d.ts +30 -0
  86. package/dist/types/src/rules.d.ts.map +1 -0
  87. package/dist/types/src/scene.d.ts +99 -65
  88. package/dist/types/src/scene.d.ts.map +1 -1
  89. package/dist/types/src/score.d.ts +54 -0
  90. package/dist/types/src/score.d.ts.map +1 -0
  91. package/dist/types/src/score.test.d.ts +2 -0
  92. package/dist/types/src/score.test.d.ts.map +1 -0
  93. package/dist/types/src/semantic-engine.d.ts +41 -0
  94. package/dist/types/src/semantic-engine.d.ts.map +1 -0
  95. package/dist/types/src/semantic-engine.test.d.ts +2 -0
  96. package/dist/types/src/semantic-engine.test.d.ts.map +1 -0
  97. package/dist/types/src/semantic-place.d.ts +81 -0
  98. package/dist/types/src/semantic-place.d.ts.map +1 -0
  99. package/dist/types/src/semantic-route.d.ts +133 -0
  100. package/dist/types/src/semantic-route.d.ts.map +1 -0
  101. package/dist/types/src/semantic.d.ts +164 -0
  102. package/dist/types/src/semantic.d.ts.map +1 -0
  103. package/dist/types/src/uml-grid.d.ts.map +1 -1
  104. package/dist/types/src/uml.d.ts +3 -5
  105. package/dist/types/src/uml.d.ts.map +1 -1
  106. package/dist/types/src/view.d.ts +53 -0
  107. package/dist/types/src/view.d.ts.map +1 -0
  108. package/dist/types/src/view.test.d.ts +2 -0
  109. package/dist/types/src/view.test.d.ts.map +1 -0
  110. package/dist/types/tsconfig.tsbuildinfo +1 -1
  111. package/package.json +23 -5
  112. package/rules/DIAGRAM.mdl +388 -0
  113. package/src/aesthetics.test.ts +89 -0
  114. package/src/aesthetics.ts +148 -0
  115. package/src/appeal.test.ts +144 -0
  116. package/src/appeal.ts +586 -0
  117. package/src/architecture.test.ts +94 -0
  118. package/src/architecture.ts +215 -0
  119. package/src/compact.test.ts +67 -0
  120. package/src/compact.ts +219 -0
  121. package/src/diagnostics.test.ts +207 -1
  122. package/src/diagnostics.ts +481 -37
  123. package/src/dsl/cli.ts +46 -0
  124. package/src/dsl/compile.ts +28 -0
  125. package/src/dsl/convert.test.ts +51 -0
  126. package/src/dsl/convert.ts +98 -0
  127. package/src/dsl/diagram.grammar +195 -0
  128. package/src/dsl/dsl.test.ts +286 -0
  129. package/src/dsl/gen/diagram.ts +22 -0
  130. package/src/dsl/index.ts +44 -0
  131. package/src/dsl/parse.ts +1054 -0
  132. package/src/dsl/print.ts +198 -0
  133. package/src/dsl/semantic.test.ts +200 -0
  134. package/src/dsl/vocabulary.ts +150 -0
  135. package/src/dx-svg.test.ts +44 -0
  136. package/src/dx-svg.ts +77 -0
  137. package/src/extension/complete.ts +105 -0
  138. package/src/extension/extension.test.ts +135 -0
  139. package/src/extension/highlight.ts +23 -0
  140. package/src/extension/index.ts +9 -0
  141. package/src/extension/language.ts +31 -0
  142. package/src/extension/lint.ts +60 -0
  143. package/src/extension/syntax.ts +62 -0
  144. package/src/index.ts +14 -0
  145. package/src/mermaid-engine.test.ts +6 -6
  146. package/src/mermaid-engine.ts +569 -119
  147. package/src/mermaid.test.ts +17 -1
  148. package/src/mermaid.ts +30 -18
  149. package/src/nudge.test.ts +155 -0
  150. package/src/nudge.ts +385 -0
  151. package/src/objective.test.ts +2 -0
  152. package/src/objective.ts +17 -1
  153. package/src/ortho-router.ts +240 -80
  154. package/src/ports.test.ts +157 -0
  155. package/src/ports.ts +186 -0
  156. package/src/relations.test.ts +98 -0
  157. package/src/rules.ts +117 -0
  158. package/src/scene.ts +64 -3
  159. package/src/score.test.ts +64 -0
  160. package/src/score.ts +130 -0
  161. package/src/semantic-engine.test.ts +635 -0
  162. package/src/semantic-engine.ts +888 -0
  163. package/src/semantic-place.ts +1508 -0
  164. package/src/semantic-route.ts +1136 -0
  165. package/src/semantic.ts +278 -0
  166. package/src/typings.d.ts +8 -0
  167. package/src/uml-grid.ts +6 -1
  168. package/src/uml.test.ts +5 -1
  169. package/src/uml.ts +24 -11
  170. package/src/view.test.ts +139 -0
  171. package/src/view.ts +376 -0
@@ -0,0 +1,278 @@
1
+ //
2
+ // Copyright 2026 DXOS.org
3
+ //
4
+
5
+ //
6
+ // The semantic diagram: what the DSL's `diagram`/`group`/`node`/`edge` statements say, before any
7
+ // coordinate exists. Every hint is optional — a pin, a relation, an allowed side, a waypoint — and
8
+ // `semantic-engine.ts` fills whatever is left open. Source ranges ride along so the engine can
9
+ // report a contradiction against the statement that caused it.
10
+ //
11
+
12
+ import type * as Scene from './scene.ts';
13
+
14
+ export type Range = { from: number; to: number };
15
+
16
+ /** A problem the engine found in the intent, with the source range it belongs to. */
17
+ export type Issue = Range & { severity: 'error' | 'warning'; message: string };
18
+
19
+ export type Side = 'top' | 'bottom' | 'left' | 'right';
20
+ export const SIDES: readonly Side[] = ['top', 'bottom', 'left', 'right'];
21
+
22
+ /** Which way edges should read; the placement prefers targets on this side of their sources. */
23
+ export type Flow = 'down' | 'up' | 'right' | 'left';
24
+ export const FLOWS: readonly Flow[] = ['down', 'up', 'right', 'left'];
25
+
26
+ export type RelationKind = 'right-of' | 'left-of' | 'above' | 'below' | 'same-row' | 'same-col';
27
+ export const RELATION_KINDS: readonly RelationKind[] = [
28
+ 'right-of',
29
+ 'left-of',
30
+ 'above',
31
+ 'below',
32
+ 'same-row',
33
+ 'same-col',
34
+ ];
35
+
36
+ /** `right-of X`: same row as X and to its right; `~` makes it a preference instead of a rule. */
37
+ export type Relation = { kind: RelationKind; target: string; soft: boolean; range: Range };
38
+
39
+ /** `@ x,y` pins the box's top-left exactly, in scene units; `@cell(c,r)` pins its grid cell. */
40
+ export type Pin = ({ kind: 'point'; x: number; y: number } | { kind: 'cell'; col: number; row: number }) & {
41
+ range: Range;
42
+ };
43
+
44
+ export type Node = {
45
+ id: string;
46
+ label: string;
47
+ group?: string;
48
+ ref?: string;
49
+ pin?: Pin;
50
+ relations: Relation[];
51
+ shape?: Scene.BoxKind;
52
+ color?: Scene.Color;
53
+ fill?: Scene.Fill;
54
+ stroke?: Scene.Stroke;
55
+ range: Range;
56
+ };
57
+
58
+ export type Group = {
59
+ id: string;
60
+ label: string;
61
+ relations: Relation[];
62
+ /** Extra clear space, in scene units, between this group and the groups it is placed relative to. */
63
+ gap?: number;
64
+ /** Most columns the group may span. */
65
+ maxWidth?: number;
66
+ /** Keep the group near-square rather than a strip. */
67
+ compact?: boolean;
68
+ color?: Scene.Color;
69
+ range: Range;
70
+ };
71
+
72
+ /** One coordinate or both; `cell` units are grid cells, where `n.5` is the channel after cell `n`. */
73
+ export type Waypoint = { x?: number; y?: number; unit: 'scene' | 'cell'; range: Range };
74
+
75
+ /** One end of an edge; `soft` makes its sides a preference the router weighs instead of a rule. */
76
+ export type End = { node: string; sides?: readonly Side[]; soft?: boolean; range: Range };
77
+
78
+ /**
79
+ * Relationship words an edge may use in place of `->`, each naming the scene relation it draws.
80
+ * The left end is the subtype, the whole, the owner, or the one side.
81
+ */
82
+ export const RELATIONSHIPS: Readonly<Record<string, Scene.Relation>> = {
83
+ 'extends': 'inheritance',
84
+ 'implements': 'implementation',
85
+ 'composes': 'composition',
86
+ 'owns': 'aggregation',
87
+ 'one-to-many': 'one-to-many',
88
+ 'many-to-many': 'many-to-many',
89
+ 'depends-on': 'dependency',
90
+ };
91
+
92
+ /** The id of the scene object that holds every connector, so no node or group may take it. */
93
+ export const CONNECTORS = 'edges';
94
+
95
+ /** Relations whose target is the abstraction, which reads best above its subtypes. */
96
+ export const pointsUp = (relation: Scene.Relation | undefined): boolean =>
97
+ relation === 'inheritance' || relation === 'implementation';
98
+
99
+ /**
100
+ * How strongly a connector's relation shapes the reading of a diagram, highest first; on a box face
101
+ * shared by several connectors the most significant takes the centre.
102
+ */
103
+ export const SIGNIFICANCE: Readonly<Record<Scene.Relation, number>> = {
104
+ 'inheritance': 4,
105
+ 'implementation': 4,
106
+ 'composition': 3,
107
+ 'aggregation': 2,
108
+ 'one-to-many': 2,
109
+ 'many-to-many': 2,
110
+ 'association': 1,
111
+ 'dependency': 0,
112
+ };
113
+
114
+ /** The significance of an edge's relation; an untyped dashed edge reads as a dependency. */
115
+ export const significance = (relation: Scene.Relation | undefined, stroke?: Scene.Stroke): number =>
116
+ SIGNIFICANCE[relation ?? (stroke === 'dashed' ? 'dependency' : 'association')];
117
+
118
+ export type EdgeStyle = {
119
+ relation?: Scene.Relation;
120
+ head?: Scene.ArrowHead;
121
+ tail?: Scene.ArrowTail;
122
+ stroke?: Scene.Stroke;
123
+ color?: Scene.Color;
124
+ };
125
+
126
+ export type Edge = EdgeStyle & {
127
+ /** Scene id of the connector, unique in the diagram. */
128
+ id: string;
129
+ from: End;
130
+ to: End;
131
+ label?: string;
132
+ via: readonly Waypoint[];
133
+ /** Edges with the same bus key and hub share one trunk. */
134
+ bus?: string;
135
+ range: Range;
136
+ };
137
+
138
+ /**
139
+ * Edges merged into one trunk: the hub end is shared, each spoke ends at its own node. `out` fans
140
+ * from the hub to the spokes, `in` gathers the spokes into the hub.
141
+ */
142
+ export type Bus = {
143
+ id: string;
144
+ hub: End;
145
+ direction: 'out' | 'in';
146
+ edges: readonly Edge[];
147
+ /** Text on the trunk, from a multi-target statement's label. */
148
+ label?: string;
149
+ /** Gathered by relation rather than by a `bus` statement, so the router may still split it. */
150
+ implicit?: boolean;
151
+ };
152
+
153
+ export type Size = { w: number; h: number };
154
+
155
+ export type Diagram = {
156
+ flow: Flow;
157
+ origin: Scene.Point;
158
+ /** Cell pitch (box plus gutter), in scene units; measured from the boxes when absent. */
159
+ grid?: Size;
160
+ /** Box size; measured from the longest label when absent. */
161
+ box?: Size;
162
+ /** Preferred width over height of the whole drawing. */
163
+ aspect?: number;
164
+ nodes: Node[];
165
+ groups: Group[];
166
+ edges: Edge[];
167
+ /** Trunk labels by bus key (`<hub>|<name>|<direction>`), from multi-target statements. */
168
+ busLabels: Map<string, string>;
169
+ /** Whether any statement said how to place or route — when none did, the mermaid engine's search is also tried. */
170
+ hinted: boolean;
171
+ };
172
+
173
+ export const empty = (): Diagram => ({
174
+ flow: 'down',
175
+ origin: { x: 0, y: 0 },
176
+ nodes: [],
177
+ groups: [],
178
+ edges: [],
179
+ busLabels: new Map(),
180
+ hinted: false,
181
+ });
182
+
183
+ /** The key edges share a trunk under. */
184
+ export const busKey = (hub: string, name: string, direction: Bus['direction']): string => `${hub}|${name}|${direction}`;
185
+
186
+ /**
187
+ * The implicit bus key of an inheritance edge: subtypes of one abstraction share a trunk into it,
188
+ * unless the author routed the edge by hand.
189
+ */
190
+ export const inheritanceKey = (edge: Edge): string | undefined =>
191
+ edge.bus === undefined &&
192
+ pointsUp(edge.relation) &&
193
+ edge.via.length === 0 &&
194
+ edge.from.sides === undefined &&
195
+ edge.to.sides === undefined &&
196
+ edge.from.node !== edge.to.node
197
+ ? `${edge.to.node}|${edge.relation}|in`
198
+ : undefined;
199
+
200
+ /** The edges grouped into buses (two or more edges with one key) and the rest. */
201
+ export const buses = (diagram: Diagram): { buses: Bus[]; single: Edge[] } => {
202
+ const byKey = new Map<string, { hub: End; direction: Bus['direction']; edges: Edge[]; implicit: boolean }>();
203
+ for (const edge of diagram.edges) {
204
+ const implicit = inheritanceKey(edge);
205
+ const key = edge.bus ?? (implicit === undefined ? undefined : `~${implicit}`);
206
+ if (key === undefined) {
207
+ continue;
208
+ }
209
+ const [hub, direction] = key.endsWith('|in') ? [edge.to, 'in' as const] : [edge.from, 'out' as const];
210
+ const entry = byKey.get(key) ?? { hub, direction, edges: [], implicit: edge.bus === undefined };
211
+ entry.edges.push(edge);
212
+ byKey.set(key, entry);
213
+ }
214
+ const result: Bus[] = [];
215
+ const merged = new Set<Edge>();
216
+ for (const [key, { hub, direction, edges, implicit }] of byKey) {
217
+ if (edges.length < 2) {
218
+ continue;
219
+ }
220
+ edges.forEach((edge) => merged.add(edge));
221
+ result.push({
222
+ id: `${hub.node}-bus-${result.length}`,
223
+ hub,
224
+ direction,
225
+ edges,
226
+ ...(diagram.busLabels.has(key) ? { label: diagram.busLabels.get(key) } : {}),
227
+ ...(implicit ? { implicit } : {}),
228
+ });
229
+ }
230
+ return { buses: result, single: diagram.edges.filter((edge) => !merged.has(edge)) };
231
+ };
232
+
233
+ /** The mermaid dialect's token per relation, so its layering ranks abstractions as ours does. */
234
+ const MERMAID_TOKEN: Record<Scene.Relation, string> = {
235
+ 'association': '-->',
236
+ 'dependency': '-.->',
237
+ 'inheritance': '--|>',
238
+ 'implementation': '..|>',
239
+ 'composition': 'o-->',
240
+ 'aggregation': 'o-->',
241
+ 'one-to-many': '--{',
242
+ 'many-to-many': '-->',
243
+ };
244
+
245
+ const MERMAID_ID = /^[A-Za-z0-9_-]+$/;
246
+ const MERMAID_TEXT = /^[^[\]|"\n]*$/;
247
+
248
+ /**
249
+ * The diagram as a mermaid flowchart, for the engine's own search when nothing was hinted; undefined
250
+ * when an id or label cannot be written in the mermaid subset.
251
+ */
252
+ export const toMermaid = (diagram: Diagram): string | undefined => {
253
+ const direction = { down: 'TB', up: 'BT', right: 'LR', left: 'RL' }[diagram.flow];
254
+ const node = (entry: Node) => `${entry.id}[${entry.label}]`;
255
+ const writable =
256
+ diagram.nodes.every((entry) => MERMAID_ID.test(entry.id) && MERMAID_TEXT.test(entry.label)) &&
257
+ diagram.groups.every((group) => MERMAID_ID.test(group.id) && MERMAID_TEXT.test(group.label)) &&
258
+ diagram.edges.every((edge) => edge.label === undefined || MERMAID_TEXT.test(edge.label));
259
+ if (!writable) {
260
+ return undefined;
261
+ }
262
+ const lines = [`flowchart ${direction}`];
263
+ for (const group of diagram.groups) {
264
+ lines.push(` subgraph ${group.id} [${group.label}]`);
265
+ diagram.nodes.filter((entry) => entry.group === group.id).forEach((entry) => lines.push(` ${node(entry)}`));
266
+ lines.push(' end');
267
+ }
268
+ diagram.nodes.filter((entry) => entry.group === undefined).forEach((entry) => lines.push(` ${node(entry)}`));
269
+ for (const edge of diagram.edges) {
270
+ lines.push(
271
+ ` ${edge.from.node} ${MERMAID_TOKEN[edge.relation ?? 'association']}${edge.label ? `|${edge.label}|` : ''} ${edge.to.node}`,
272
+ );
273
+ }
274
+ diagram.nodes.forEach(
275
+ (entry) => entry.ref && !/\s/.test(entry.ref) && lines.push(` %% ref ${entry.id} ${entry.ref}`),
276
+ );
277
+ return lines.join('\n');
278
+ };
@@ -0,0 +1,8 @@
1
+ //
2
+ // Copyright 2026 DXOS.org
3
+ //
4
+
5
+ declare module '*.mdl?raw' {
6
+ const text: string;
7
+ export default text;
8
+ }
package/src/uml-grid.ts CHANGED
@@ -528,7 +528,12 @@ export const emit = (
528
528
  const style = relationStyle(relation.kind);
529
529
  // The final segment renders as an arrow for the head; earlier waypoints as a polyline.
530
530
  if (points.length > 2) {
531
- elements.push({ kind: 'line', id: `${id}-path`, points: points.slice(0, -1), ...style });
531
+ elements.push({
532
+ kind: 'line',
533
+ id: `${id}-path`,
534
+ points: points.slice(0, -1),
535
+ ...(style.stroke ? { stroke: style.stroke } : {}),
536
+ });
532
537
  }
533
538
  elements.push({
534
539
  kind: 'arrow',
package/src/uml.test.ts CHANGED
@@ -113,15 +113,19 @@ describe('uml', () => {
113
113
  // one, so the visible segment stays in the rank gap instead of crossing Animal's members.
114
114
  const inheritance = arrows.find((arrow) => arrow.from === 'Dog/title' && arrow.to === 'Animal/methods')!;
115
115
  expect(inheritance.stroke).toBeUndefined();
116
+ expect(inheritance.relation).toBe('inheritance');
116
117
  const realization = arrows.find((arrow) => arrow.to === 'Serializable/methods')!;
117
118
  expect(realization.stroke).toBe('dashed');
118
119
  const aggregation = arrows.find((arrow) => arrow.from === 'Owner/title')!;
119
120
  expect(aggregation.to).toBe('Dog/title');
120
121
  expect(aggregation.text).toBe('1 owns *');
122
+ expect(aggregation.relation).toBe('aggregation');
121
123
  // Memberless classes below Dog bind to their only compartment; Dog faces them with its last.
122
124
  const composition = arrows.find((arrow) => arrow.to === 'Leg/title')!;
123
125
  expect(composition.from).toBe('Dog/methods');
124
- expect(composition.text).toBe('◆');
126
+ // The diamond is the relation's marker at the whole's end, not a glyph in the label.
127
+ expect(composition.relation).toBe('composition');
128
+ expect(composition.text).toBeUndefined();
125
129
  });
126
130
 
127
131
  test('wraps long member lines into taller compartments, capped at maxWidth', ({ expect }) => {
package/src/uml.ts CHANGED
@@ -87,7 +87,7 @@ export const parse = (source: string): UmlModel => {
87
87
  let direction: Direction = 'TB';
88
88
  let open: UmlClass | undefined;
89
89
 
90
- const declare = (id: string): UmlClass => {
90
+ const declareClass = (id: string): UmlClass => {
91
91
  let entry = classes.get(id);
92
92
  if (!entry) {
93
93
  entry = { id, label: genericLabel(id), attributes: [], methods: [] };
@@ -134,7 +134,7 @@ export const parse = (source: string): UmlModel => {
134
134
  const cls = CLASS.exec(line);
135
135
  if (cls) {
136
136
  const [, id, label, brace] = cls;
137
- const entry = declare(id);
137
+ const entry = declareClass(id);
138
138
  if (label) {
139
139
  entry.label = label;
140
140
  }
@@ -147,8 +147,8 @@ export const parse = (source: string): UmlModel => {
147
147
  const relation = RELATION.exec(line);
148
148
  if (relation) {
149
149
  const [, left, leftCardinality, token, rightCardinality, right, label] = relation;
150
- declare(left);
151
- declare(right);
150
+ declareClass(left);
151
+ declareClass(right);
152
152
  const [, kind, leftIsTarget] = RELATIONS.find(([candidate]) => candidate === token)!;
153
153
  const [from, to] = leftIsTarget ? [right, left] : [left, right];
154
154
  const [fromCardinality, toCardinality] = leftIsTarget
@@ -168,7 +168,7 @@ export const parse = (source: string): UmlModel => {
168
168
  const inline = INLINE_MEMBER.exec(line);
169
169
  if (inline) {
170
170
  const [, id, member] = inline;
171
- addMember(declare(id), member);
171
+ addMember(declareClass(id), member);
172
172
  }
173
173
  }
174
174
 
@@ -218,14 +218,27 @@ const measure = (entry: UmlClass, maxWidth: number): ClassBox => {
218
218
  return { entry, w, h: titleH + attributesH + methodsH, titleH, attributesH, methodsH };
219
219
  };
220
220
 
221
- /** Arrow styling per relation kind; dependency and realization render dashed, as in UML. */
222
- export const relationStyle = (kind: RelationKind): { stroke?: Scene.Stroke } =>
223
- kind === 'dependency' || kind === 'realization' ? { stroke: 'dashed' } : {};
221
+ const SCENE_RELATION: Record<RelationKind, Scene.Relation | undefined> = {
222
+ inheritance: 'inheritance',
223
+ realization: 'implementation',
224
+ composition: 'composition',
225
+ aggregation: 'aggregation',
226
+ association: undefined,
227
+ dependency: 'dependency',
228
+ };
229
+
230
+ /** Arrow styling per relation kind: the scene relation sets the markers; dependency and realization dash, as in UML. */
231
+ export const relationStyle = (kind: RelationKind): Pick<Scene.Arrow, 'relation' | 'stroke'> => {
232
+ const relation = SCENE_RELATION[kind];
233
+ return {
234
+ ...(relation ? { relation } : {}),
235
+ ...(kind === 'dependency' || kind === 'realization' ? { stroke: 'dashed' as const } : {}),
236
+ };
237
+ };
224
238
 
225
- /** Mid-arrow label: cardinalities and the diamond glyph UML puts at the whole end. */
239
+ /** Mid-arrow label: the cardinalities around the label; the diamond at the whole end is a marker now. */
226
240
  export const relationText = (relation: UmlRelation): string | undefined => {
227
- const glyph = relation.kind === 'composition' ? '◆' : relation.kind === 'aggregation' ? '◇' : undefined;
228
- const text = [relation.fromCardinality, relation.label ?? glyph, relation.toCardinality].filter(Boolean).join(' ');
241
+ const text = [relation.fromCardinality, relation.label, relation.toCardinality].filter(Boolean).join(' ');
229
242
  return text || undefined;
230
243
  };
231
244
 
@@ -0,0 +1,139 @@
1
+ //
2
+ // Copyright 2026 DXOS.org
3
+ //
4
+
5
+ import { describe, test } from 'vitest';
6
+
7
+ import type * as Scene from './scene.ts';
8
+ import { ascii, coordinates, extract, rows } from './view.ts';
9
+
10
+ const node = (id: string, label: string, x: number, y: number): Scene.WorldObject => ({
11
+ id,
12
+ origin: { x, y },
13
+ elements: [{ kind: 'rect', id: 'frame', x: 0, y: 0, w: 128, h: 64, text: label }],
14
+ });
15
+
16
+ // Two boxes side by side, one below the first, and connectors A → B (right) and A → C (down).
17
+ const SCENE: Scene.WorldObject[] = [
18
+ node('a', 'Alpha', 0, 0),
19
+ node('b', 'Beta', 256, 0),
20
+ node('c', 'Gamma', 0, 192),
21
+ {
22
+ id: 'ab',
23
+ elements: [
24
+ {
25
+ kind: 'line',
26
+ id: 'ab-path',
27
+ points: [
28
+ { x: 128, y: 32 },
29
+ { x: 200, y: 32 },
30
+ ],
31
+ },
32
+ { kind: 'arrow', id: 'ab', start: { x: 200, y: 32 }, end: { x: 256, y: 32 } },
33
+ { kind: 'text', id: 'ab-label', x: 180, y: 16, text: 'calls' },
34
+ ],
35
+ },
36
+ {
37
+ id: 'ac',
38
+ elements: [{ kind: 'arrow', id: 'ac', start: { x: 64, y: 64 }, end: { x: 64, y: 192 } }],
39
+ },
40
+ ];
41
+
42
+ describe('view', () => {
43
+ test('extract resolves each connector to the boxes at its ends', ({ expect }) => {
44
+ const { boxes, paths, bounds } = extract(SCENE);
45
+ expect(boxes.map(({ label }) => label)).toEqual(['Alpha', 'Beta', 'Gamma']);
46
+ expect(paths.map(({ from, to, label }) => [from?.label, to?.label, label])).toEqual([
47
+ ['Alpha', 'Beta', 'calls'],
48
+ ['Alpha', 'Gamma', undefined],
49
+ ]);
50
+ expect(bounds).toEqual({ x: 0, y: 0, w: 384, h: 256 });
51
+ });
52
+
53
+ test('coordinates lists positions in grid cells', ({ expect }) => {
54
+ const text = coordinates(SCENE);
55
+ expect(text).toContain('canvas 12 × 8 cells');
56
+ expect(text).toContain('box "Gamma": top-left (0, 6), size 4 × 2');
57
+ expect(text).toContain('arrow "Alpha" → "Beta" labelled "calls": (4, 1) → (6.3, 1) → (8, 1)');
58
+ });
59
+
60
+ test('ascii draws boxes, labels and arrowheads where a reader expects them', ({ expect }) => {
61
+ const lines = ascii(SCENE).split('\n');
62
+ const [alpha] = lines.flatMap((line, row) => (line.includes('Alpha') ? [row] : []));
63
+ expect(lines[alpha]).toMatch(/Alpha.*▶.*Beta/);
64
+ const gamma = lines.findIndex((line) => line.includes('Gamma'));
65
+ expect(gamma).toBeGreaterThan(alpha);
66
+ expect(lines.slice(alpha, gamma).some((line) => line.includes('▼'))).toBe(true);
67
+ });
68
+
69
+ test('a connector routed outside every box stays on the canvas', ({ expect }) => {
70
+ const around: Scene.WorldObject = {
71
+ id: 'ba',
72
+ elements: [
73
+ {
74
+ kind: 'line',
75
+ id: 'ba-path',
76
+ points: [
77
+ { x: 320, y: 0 },
78
+ { x: 320, y: -64 },
79
+ { x: 64, y: -64 },
80
+ ],
81
+ },
82
+ { kind: 'arrow', id: 'ba', start: { x: 64, y: -64 }, end: { x: 64, y: 0 } },
83
+ ],
84
+ };
85
+ const scene = [...SCENE, around];
86
+ expect(extract(scene).bounds.y).toBe(-64);
87
+ expect(() => ascii(scene)).not.toThrow();
88
+ expect(coordinates(scene)).not.toMatch(/\(-/);
89
+ });
90
+
91
+ test('connectors with the same element id in different objects stay distinct', ({ expect }) => {
92
+ const edge = (id: string, from: Scene.Point, to: Scene.Point, text: string): Scene.WorldObject => ({
93
+ id,
94
+ elements: [
95
+ { kind: 'arrow', id: 'edge', start: from, end: to },
96
+ { kind: 'text', id: 'edge-label', x: 0, y: 0, text },
97
+ ],
98
+ });
99
+ const scene = [
100
+ ...SCENE.slice(0, 3),
101
+ edge('ab', { x: 128, y: 32 }, { x: 256, y: 32 }, 'calls'),
102
+ edge('ac', { x: 64, y: 64 }, { x: 64, y: 192 }, 'owns'),
103
+ ];
104
+ expect(extract(scene).paths.map(({ ref, to, label }) => [ref, to?.label, label])).toEqual([
105
+ ['ab/edge', 'Beta', 'calls'],
106
+ ['ac/edge', 'Gamma', 'owns'],
107
+ ]);
108
+ });
109
+
110
+ test("rows names an arrow's markers and line style", ({ expect }) => {
111
+ const [a, b] = SCENE;
112
+ const implementsArrow: Scene.WorldObject = {
113
+ id: 'ba',
114
+ elements: [
115
+ {
116
+ kind: 'arrow',
117
+ id: 'ba',
118
+ start: { x: 256, y: 32 },
119
+ end: { x: 128, y: 32 },
120
+ head: 'triangle',
121
+ stroke: 'dashed',
122
+ },
123
+ ],
124
+ };
125
+ expect(rows([a, b, implementsArrow])).toContain(
126
+ 'arrow "Beta" → "Alpha" (dashed, hollow triangle head) runs left, row 1 to row 1',
127
+ );
128
+ });
129
+
130
+ test('rows reads boxes top to bottom and says which way each arrow runs', ({ expect }) => {
131
+ expect(rows(SCENE).split('\n')).toEqual([
132
+ 'row 1 (top), left to right: "Alpha", "Beta"',
133
+ 'row 2 (bottom), left to right: "Gamma"',
134
+ 'arrow "Alpha" → "Beta" labelled "calls" runs right, row 1 to row 1, 1 bends',
135
+ 'arrow "Alpha" → "Gamma" runs down, row 1 to row 2',
136
+ 'no arrows cross',
137
+ ]);
138
+ });
139
+ });