@formicoidea/labre-framework-uml 0.42.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 (114) hide show
  1. package/LICENSE +384 -0
  2. package/dist/actions.d.ts +430 -0
  3. package/dist/actions.js +724 -0
  4. package/dist/background.d.ts +153 -0
  5. package/dist/background.js +557 -0
  6. package/dist/board-hit.d.ts +137 -0
  7. package/dist/board-hit.js +191 -0
  8. package/dist/commands-manifest.d.ts +19 -0
  9. package/dist/commands-manifest.js +571 -0
  10. package/dist/commands.d.ts +4 -0
  11. package/dist/commands.js +983 -0
  12. package/dist/component.d.ts +521 -0
  13. package/dist/component.js +708 -0
  14. package/dist/consts.d.ts +365 -0
  15. package/dist/consts.js +463 -0
  16. package/dist/descriptor.d.ts +12 -0
  17. package/dist/descriptor.js +10 -0
  18. package/dist/drawio-decode.d.ts +32 -0
  19. package/dist/drawio-decode.js +96 -0
  20. package/dist/drawio-import.d.ts +133 -0
  21. package/dist/drawio-import.js +885 -0
  22. package/dist/edge-morph.d.ts +154 -0
  23. package/dist/edge-morph.js +153 -0
  24. package/dist/edge-styles.d.ts +78 -0
  25. package/dist/edge-styles.js +210 -0
  26. package/dist/effects.d.ts +9 -0
  27. package/dist/effects.js +6 -0
  28. package/dist/element-renderer.d.ts +64 -0
  29. package/dist/element-renderer.js +114 -0
  30. package/dist/element-view.d.ts +234 -0
  31. package/dist/element-view.js +430 -0
  32. package/dist/export.d.ts +61 -0
  33. package/dist/export.js +41 -0
  34. package/dist/filename.d.ts +17 -0
  35. package/dist/filename.js +26 -0
  36. package/dist/frame-tag.d.ts +70 -0
  37. package/dist/frame-tag.js +78 -0
  38. package/dist/grammar.d.ts +695 -0
  39. package/dist/grammar.js +1436 -0
  40. package/dist/import.d.ts +306 -0
  41. package/dist/import.js +1270 -0
  42. package/dist/index.d.ts +47 -0
  43. package/dist/index.js +67 -0
  44. package/dist/interchange.d.ts +125 -0
  45. package/dist/interchange.js +372 -0
  46. package/dist/keywords.d.ts +204 -0
  47. package/dist/keywords.js +361 -0
  48. package/dist/kinds.d.ts +73 -0
  49. package/dist/kinds.js +91 -0
  50. package/dist/legend.d.ts +30 -0
  51. package/dist/legend.js +255 -0
  52. package/dist/model.d.ts +862 -0
  53. package/dist/model.js +1316 -0
  54. package/dist/morph.d.ts +178 -0
  55. package/dist/morph.js +496 -0
  56. package/dist/node/act-glyphs.d.ts +30 -0
  57. package/dist/node/act-glyphs.js +142 -0
  58. package/dist/node/compartment-watcher.d.ts +107 -0
  59. package/dist/node/compartment-watcher.js +163 -0
  60. package/dist/node/fit.d.ts +85 -0
  61. package/dist/node/fit.js +129 -0
  62. package/dist/node/node-renderer.d.ts +49 -0
  63. package/dist/node/node-renderer.js +611 -0
  64. package/dist/node/node-view.d.ts +51 -0
  65. package/dist/node/node-view.js +89 -0
  66. package/dist/node/paint.d.ts +84 -0
  67. package/dist/node/paint.js +143 -0
  68. package/dist/node/sd-glyphs.d.ts +36 -0
  69. package/dist/node/sd-glyphs.js +79 -0
  70. package/dist/node/stm-glyphs.d.ts +22 -0
  71. package/dist/node/stm-glyphs.js +111 -0
  72. package/dist/node/tier-metrics.d.ts +38 -0
  73. package/dist/node/tier-metrics.js +45 -0
  74. package/dist/operators.d.ts +45 -0
  75. package/dist/operators.js +75 -0
  76. package/dist/plantuml-import.d.ts +113 -0
  77. package/dist/plantuml-import.js +1723 -0
  78. package/dist/plantuml.d.ts +94 -0
  79. package/dist/plantuml.js +985 -0
  80. package/dist/presets.d.ts +149 -0
  81. package/dist/presets.js +302 -0
  82. package/dist/profiles.d.ts +2 -0
  83. package/dist/profiles.js +358 -0
  84. package/dist/reading.d.ts +160 -0
  85. package/dist/reading.js +648 -0
  86. package/dist/roles.d.ts +257 -0
  87. package/dist/roles.js +997 -0
  88. package/dist/rules.d.ts +267 -0
  89. package/dist/rules.js +3090 -0
  90. package/dist/templates.d.ts +2 -0
  91. package/dist/templates.js +197 -0
  92. package/dist/toolbar/config.d.ts +228 -0
  93. package/dist/toolbar/config.js +833 -0
  94. package/dist/toolbar/icons.d.ts +440 -0
  95. package/dist/toolbar/icons.js +711 -0
  96. package/dist/toolbar/senior-tool.d.ts +11 -0
  97. package/dist/toolbar/senior-tool.js +21 -0
  98. package/dist/toolbar/uml-menu.d.ts +17 -0
  99. package/dist/toolbar/uml-menu.js +20 -0
  100. package/dist/toolbar/uml-senior-button.d.ts +18 -0
  101. package/dist/toolbar/uml-senior-button.js +22 -0
  102. package/dist/translations.d.ts +27 -0
  103. package/dist/translations.js +117 -0
  104. package/dist/view.d.ts +33 -0
  105. package/dist/view.js +236 -0
  106. package/dist/xmi-import.d.ts +174 -0
  107. package/dist/xmi-import.js +2268 -0
  108. package/dist/xmi.d.ts +61 -0
  109. package/dist/xmi.js +1860 -0
  110. package/dist/xml-reader.d.ts +157 -0
  111. package/dist/xml-reader.js +430 -0
  112. package/dist/xml.d.ts +72 -0
  113. package/dist/xml.js +96 -0
  114. package/package.json +38 -0
package/dist/model.js ADDED
@@ -0,0 +1,1316 @@
1
+ import { UML_DIAGRAM_KIND_TAG, UML_FRAGMENT_BAND, UML_FRAME_BAND_HEIGHT, UML_LIFELINE_HEAD, } from '@formicoidea/labre-core/model';
2
+ import { umlComponentSiblings, umlGroupOf, umlTierText, } from './component.js';
3
+ import { parseActivityEdge, parseCompartment, parseEndLabel, parseLifelineIdent, parseOperation, parseProperty, parseStateBehavior, parseTransition, } from './grammar.js';
4
+ import { stereotypesOf } from './keywords.js';
5
+ import { UML_ROLE } from './roles.js';
6
+ /**
7
+ * The relationships whose end labels §11.5.4 gives a MEANING to.
8
+ *
9
+ * An Association and its two aggregation flavours, plus the CommunicationPath
10
+ * §19.4.3 defines as "an Association between two DeploymentTargets" — which is
11
+ * why it is in the set: it is an Association, so its ends are Properties and
12
+ * take a multiplicity like any other.
13
+ */
14
+ export const ADORNED_RELATION_KINDS = new Set([
15
+ 'association',
16
+ 'aggregation',
17
+ 'composition',
18
+ 'communication-path',
19
+ ]);
20
+ /* ── Roles ────────────────────────────────────────────────────────────── */
21
+ /**
22
+ * The connector role that states each relationship — C1's vocabulary, read
23
+ * rather than re-spelled.
24
+ *
25
+ * `Record<UmlRelationKind, string>` and therefore compile-total in both
26
+ * directions: a relation kind with no role fails here, and a role renamed in
27
+ * `roles.ts` fails here too rather than silently exporting nothing.
28
+ */
29
+ const RELATION_ROLE = {
30
+ association: UML_ROLE.association,
31
+ aggregation: UML_ROLE.aggregation,
32
+ composition: UML_ROLE.composition,
33
+ generalization: UML_ROLE.generalization,
34
+ realization: UML_ROLE.realization,
35
+ dependency: UML_ROLE.dependency,
36
+ anchor: UML_ROLE.anchor,
37
+ include: UML_ROLE.include,
38
+ extend: UML_ROLE.extend,
39
+ deploy: UML_ROLE.deploy,
40
+ manifest: UML_ROLE.manifest,
41
+ 'communication-path': UML_ROLE['communication-path'],
42
+ 'control-flow': UML_ROLE['control-flow'],
43
+ 'object-flow': UML_ROLE['object-flow'],
44
+ transition: UML_ROLE.transition,
45
+ 'message-sync': UML_ROLE['message-sync'],
46
+ 'message-async': UML_ROLE['message-async'],
47
+ 'message-reply': UML_ROLE['message-reply'],
48
+ 'message-create': UML_ROLE['message-create'],
49
+ 'message-delete': UML_ROLE['message-delete'],
50
+ };
51
+ /** The five message kinds, as a set — what tells a message from a relation. */
52
+ const MESSAGE_KINDS = new Set([
53
+ 'message-sync',
54
+ 'message-async',
55
+ 'message-reply',
56
+ 'message-create',
57
+ 'message-delete',
58
+ ]);
59
+ const RELATION_OF_ROLE = new Map(Object.entries(RELATION_ROLE).map(([kind, role]) => [
60
+ role,
61
+ kind,
62
+ ]));
63
+ /* ── Geometry ─────────────────────────────────────────────────────────── */
64
+ /**
65
+ * An element's box, from whichever of the three spellings it carries.
66
+ *
67
+ * `deserializedXYWH` first because that is what a live element answers with;
68
+ * `elementBound` because that is what the shared canvas fixtures build; the
69
+ * serialized `xywh` last, for a record that carries only what the store holds.
70
+ */
71
+ export function umlBoundsOf(element) {
72
+ const xywh = element.deserializedXYWH;
73
+ if (xywh && xywh.length >= 4) {
74
+ return { x: xywh[0], y: xywh[1], w: xywh[2], h: xywh[3] };
75
+ }
76
+ const bound = element.elementBound;
77
+ if (bound && typeof bound.x === 'number') {
78
+ return { x: bound.x, y: bound.y, w: bound.w, h: bound.h };
79
+ }
80
+ if (typeof element.xywh === 'string') {
81
+ const parts = element.xywh
82
+ .replace(/^\[/, '')
83
+ .replace(/\]$/, '')
84
+ .split(',')
85
+ .map(Number);
86
+ if (parts.length >= 4 && parts.every(Number.isFinite)) {
87
+ return { x: parts[0], y: parts[1], w: parts[2], h: parts[3] };
88
+ }
89
+ }
90
+ return undefined;
91
+ }
92
+ /** Inclusive containment of a box's CENTRE in another box. */
93
+ export function umlCentreInside(inner, outer) {
94
+ const cx = inner.x + inner.w / 2;
95
+ const cy = inner.y + inner.h / 2;
96
+ return (cx >= outer.x &&
97
+ cx <= outer.x + outer.w &&
98
+ cy >= outer.y &&
99
+ cy <= outer.y + outer.h);
100
+ }
101
+ /**
102
+ * How near a glyph has to be drawn to count as attached to a box, in model
103
+ * units.
104
+ *
105
+ * The one number this file invents, and the reason it is small: §10.4.4 and
106
+ * §11.3.4 both describe a symbol TOUCHING a rectangle — a lollipop "attached by
107
+ * a solid line" to the classifier that realizes it, a port "overlapping the
108
+ * boundary" of the box it is on. Touching is a gap of zero, and a hand drawing
109
+ * on a canvas misses by a few units, so the tolerance is a couple of grid steps
110
+ * and not a neighbourhood: an interface glyph parked halfway across the sheet is
111
+ * not this component's, and a reading that guessed would put an interface in the
112
+ * file that nobody drew.
113
+ *
114
+ * Read by {@link umlHostOf} and by {@link umlBoxGap}'s callers alike, so the
115
+ * port's owner and the interface's component are decided by the same distance.
116
+ */
117
+ export const UML_ATTACH_TOLERANCE = 24;
118
+ /**
119
+ * The GAP between two boxes — zero when they touch or overlap, otherwise the
120
+ * shortest distance from edge to edge.
121
+ *
122
+ * Edges, never centres: a lollipop is thirty units across and a component two
123
+ * hundred, so "nearest centre" would hand a glyph to whichever box happens to be
124
+ * squattest rather than to the one it is drawn against. The same argument
125
+ * `gapSquared` makes in the validation engine, one package over.
126
+ */
127
+ export function umlBoxGap(a, b) {
128
+ const dx = Math.max(0, Math.max(a.x - (b.x + b.w), b.x - (a.x + a.w)));
129
+ const dy = Math.max(0, Math.max(a.y - (b.y + b.h), b.y - (a.y + a.h)));
130
+ return Math.hypot(dx, dy);
131
+ }
132
+ /**
133
+ * The DRAWING area of a diagram frame — the frame minus its heading band.
134
+ *
135
+ * The same carve-out `c4/export.ts` makes with `backgroundPlot`, arrived at from
136
+ * the MODEL's own constant so this module owes the framework declaration
137
+ * nothing: the top margin is where the frame writes its own heading
138
+ * (`UmlDiagramElementModel`), and an element laid over the heading is on the
139
+ * sheet's chrome rather than on its drawing area.
140
+ */
141
+ export function umlSheetOf(frame) {
142
+ const band = Math.min(UML_FRAME_BAND_HEIGHT, frame.h);
143
+ return { x: frame.x, y: frame.y + band, w: frame.w, h: frame.h - band };
144
+ }
145
+ /**
146
+ * Which box a glyph is drawn AGAINST — the one reading that gives a port its
147
+ * owner and a lollipop its component.
148
+ *
149
+ * The nearest host within {@link UML_ATTACH_TOLERANCE}, and `undefined` when
150
+ * nothing is that near: a port drawn in the middle of the sheet belongs to
151
+ * nothing, and inventing an owner for it would put a `uml:Port` on a component
152
+ * the author never drew it on.
153
+ *
154
+ * ## The tie-breaks, and why there are two of them
155
+ *
156
+ * A component nested inside another (§11.6.4's white-box view) puts a port on
157
+ * the boundary of BOTH, at a gap of zero from each. The smaller box wins,
158
+ * because the most-nested container is the one the author was aiming at — the
159
+ * same reading `containerOf` makes in the two writers. And an exact tie on both
160
+ * gap and area is broken by the smaller id, never by the order the surface
161
+ * happened to be walked in: this decides what a FILE says, and a Y.Map rebuilt
162
+ * on every load does not promise an order.
163
+ */
164
+ export function umlHostOf(glyph, hosts, tolerance = UML_ATTACH_TOLERANCE) {
165
+ if (!glyph)
166
+ return undefined;
167
+ let best;
168
+ let bestGap = Number.POSITIVE_INFINITY;
169
+ let bestArea = Number.POSITIVE_INFINITY;
170
+ for (const host of hosts) {
171
+ if (!host.bounds)
172
+ continue;
173
+ const gap = umlBoxGap(glyph, host.bounds);
174
+ if (gap > tolerance)
175
+ continue;
176
+ const area = Math.max(0, host.bounds.w) * Math.max(0, host.bounds.h);
177
+ const better = gap < bestGap ||
178
+ (gap === bestGap &&
179
+ (area < bestArea ||
180
+ (area === bestArea && best !== undefined && host.id < best.id)));
181
+ if (better) {
182
+ best = host;
183
+ bestGap = gap;
184
+ bestArea = area;
185
+ }
186
+ }
187
+ return best;
188
+ }
189
+ /* ── The sequence sheet's own geometry (§17.2.4) ──────────────────────── */
190
+ /**
191
+ * The x of a lifeline's SPINE — the dashed line everything on a sequence sheet
192
+ * is measured against.
193
+ *
194
+ * The centre of the element's own box, which is the column and not the head:
195
+ * `UmlNodeElementModel` makes a lifeline a narrow tall column so that the
196
+ * connector layer's native anchors land on the spine, and paints the 160-wide
197
+ * head over the top of it. So the spine is the column's own centre line, and
198
+ * this function exists so that the four readers below cannot each decide that
199
+ * differently.
200
+ */
201
+ export function umlSpineX(lifeline) {
202
+ return lifeline.x + lifeline.w / 2;
203
+ }
204
+ /**
205
+ * How far off a spine a bar or a cross may be drawn and still be ON it.
206
+ *
207
+ * Half the HEAD's width, and it is the notation's own number rather than a
208
+ * guess: §17.2.4 draws an ExecutionSpecification as a bar centred on the spine
209
+ * and a destruction as a cross centred on it, so the honest tolerance is "nearer
210
+ * to this spine than to any other", and lifelines laid out at the 200-unit
211
+ * spacing of an invented sequence layout are 200 apart. 80 is inside that by a
212
+ * comfortable margin, so a bar dropped a little off centre finds its own
213
+ * lifeline and a bar dropped between two finds neither.
214
+ */
215
+ export const UML_SPINE_TOLERANCE = UML_LIFELINE_HEAD.w / 2;
216
+ /**
217
+ * Which lifeline a bar or a cross is drawn ON — {@link umlHostOf}'s twin for
218
+ * §17.2.4's two glyphs.
219
+ *
220
+ * A different reading from `umlHostOf` and deliberately so: a port is attached
221
+ * to a box by TOUCHING it, and a bar is attached to a spine by SITTING ON it —
222
+ * one is an edge-to-edge gap, the other a distance from one vertical line. The
223
+ * nearest spine within {@link UML_SPINE_TOLERANCE} wins; an exact tie is broken
224
+ * by the smaller id, never by the order the surface happened to be walked in,
225
+ * for the reason `umlHostOf` gives: this decides what a FILE says.
226
+ *
227
+ * `undefined` when nothing is that near — a bar drawn between two spines belongs
228
+ * to neither, and inventing an owner for it would put an execution on a
229
+ * participant the author never drew it on.
230
+ */
231
+ export function umlLifelineAt(glyph, lifelines) {
232
+ if (!glyph)
233
+ return undefined;
234
+ const cx = glyph.x + glyph.w / 2;
235
+ let best;
236
+ let bestGap = Number.POSITIVE_INFINITY;
237
+ for (const lifeline of lifelines) {
238
+ if (!lifeline.bounds)
239
+ continue;
240
+ const gap = Math.abs(cx - umlSpineX(lifeline.bounds));
241
+ if (gap > UML_SPINE_TOLERANCE)
242
+ continue;
243
+ if (gap < bestGap || (gap === bestGap && best && lifeline.id < best.id)) {
244
+ best = lifeline;
245
+ bestGap = gap;
246
+ }
247
+ }
248
+ return best;
249
+ }
250
+ /**
251
+ * The operand bands of a combined fragment, as boxes — §17.6.4's horizontal
252
+ * slices, separated by dashed lines.
253
+ *
254
+ * The operator BAND is carved off the top first (`UML_FRAGMENT_BAND`), exactly
255
+ * as `umlSheetOf` carves the frame's heading off: the pentagon is the
256
+ * fragment's own chrome, and a guard written over it is on the furniture rather
257
+ * than in an operand.
258
+ *
259
+ * `size` is a relative WEIGHT (`UmlFragmentOperand`), so the bands are the body
260
+ * divided in proportion — a fragment dragged taller keeps its operands' shares.
261
+ * A fragment that declares no operands is ONE operand, which is what every
262
+ * `opt`, every `loop` and every `ref` is, and its guard is the fragment's own
263
+ * `name`.
264
+ */
265
+ /**
266
+ * A guard as the IR spells it — §17.6.4's condition with ONE surrounding pair
267
+ * of brackets taken off.
268
+ *
269
+ * The canvas text is exactly what the author types, and §17.6.4.4 prints the
270
+ * condition **in brackets**: `[x > 0]`, `[else]`. So the brackets are stored,
271
+ * the renderer paints them verbatim, and the delimiter is stripped HERE, once,
272
+ * for both writers — PlantUML writes its own `[…]` back round the condition and
273
+ * XMI writes a bare `LiteralString`, and neither should have to parse the
274
+ * model it is given.
275
+ *
276
+ * Tolerant in both directions, because a guard is free text an author types:
277
+ * `x > 0` typed without brackets is the same condition and exports identically,
278
+ * and `[[x > 0]]` gives up exactly one pair. Only a pair that actually
279
+ * SURROUNDS the condition is taken — `[a] or [b]` opens and closes twice, and
280
+ * dropping its outer characters would turn a sentence inside out.
281
+ */
282
+ export function umlGuardText(written) {
283
+ const text = (written ?? '').trim();
284
+ if (text.length < 2 || !text.startsWith('[') || !text.endsWith(']')) {
285
+ return text;
286
+ }
287
+ let depth = 0;
288
+ for (const [index, character] of [...text].entries()) {
289
+ if (character === '[')
290
+ depth += 1;
291
+ else if (character === ']') {
292
+ depth -= 1;
293
+ if (depth === 0 && index < text.length - 1)
294
+ return text;
295
+ }
296
+ }
297
+ return depth === 0 ? text.slice(1, -1).trim() : text;
298
+ }
299
+ export function umlOperandBands(box, operands, name) {
300
+ const band = Math.min(UML_FRAGMENT_BAND, box.h);
301
+ const top = box.y + band;
302
+ const body = Math.max(0, box.h - band);
303
+ const guardOf = (index, written) => {
304
+ // …and the brackets come OFF here (see {@link umlGuardText}): the canvas
305
+ // holds `[x > 0]` because that is what §17.6.4 draws and what the author
306
+ // typed, and the IR holds the condition.
307
+ const guard = umlGuardText(written ?? (index === 0 ? name : ''));
308
+ return guard ? { guard } : {};
309
+ };
310
+ if (!operands || operands.length === 0) {
311
+ return [{ ...guardOf(0, undefined), y0: top, y1: top + body }];
312
+ }
313
+ const weights = operands.map(operand => Number.isFinite(operand.size) && operand.size > 0 ? operand.size : 1);
314
+ const total = weights.reduce((sum, weight) => sum + weight, 0);
315
+ const bands = [];
316
+ let y = top;
317
+ for (const [index, operand] of operands.entries()) {
318
+ // The LAST band is closed on the box rather than on the running sum, so
319
+ // rounding cannot leave a sliver of the fragment in no operand at all.
320
+ const next = index === operands.length - 1
321
+ ? top + body
322
+ : y + (body * weights[index]) / total;
323
+ bands.push({ ...guardOf(index, operand.name), y0: y, y1: next });
324
+ y = next;
325
+ }
326
+ return bands;
327
+ }
328
+ /** The classifier kinds — the four that carry compartments. */
329
+ const CLASSIFIER_KINDS = new Set([
330
+ 'class',
331
+ 'interface',
332
+ 'enumeration',
333
+ 'object',
334
+ ]);
335
+ /** The glyphs only an ACTIVITY draws — §15.3.4, §15.4.4, §16.3.4, §16.10.4. */
336
+ const ACTIVITY_ONLY_KINDS = new Set([
337
+ 'action',
338
+ 'activity-final',
339
+ 'flow-final',
340
+ 'decision',
341
+ 'object-node',
342
+ 'send-signal',
343
+ 'accept-event',
344
+ 'time-event',
345
+ ]);
346
+ /** The pseudostates only a STATE MACHINE draws — §14.2.4.6. */
347
+ const PSEUDOSTATE_ONLY_KINDS = new Set([
348
+ 'choice',
349
+ 'junction',
350
+ 'shallow-history',
351
+ 'deep-history',
352
+ 'entry-point',
353
+ 'exit-point',
354
+ 'terminate',
355
+ ]);
356
+ /**
357
+ * The two glyphs BOTH behaviour sheets draw, and the one ambiguity this file
358
+ * has to resolve rather than report.
359
+ *
360
+ * §15.3.4 and §14.2.4 draw the same filled disc and the same solid bar and mean
361
+ * the same thing by each — a beginning, a split or a join — which is why
362
+ * `roles.ts` gives each ONE role instead of two. A role is a statement about
363
+ * meaning and the meaning is genuinely the same; an EXPORT, though, has to put
364
+ * the disc in an `uml:Activity` or in a `uml:StateMachine`, and those are two
365
+ * different metaclasses in two different packages.
366
+ *
367
+ * So the sheet's own heading decides: on an `stm` frame the disc is a
368
+ * `Pseudostate kind="initial"` and the bar a `Pseudostate kind="fork"`, and
369
+ * everywhere else they are an `InitialNode` and a `ForkNode`. Annex A makes that
370
+ * heading a required part of the frame (`kinds.ts`), so the statement is always
371
+ * there to read — and it is the author's own, which is the only thing that could
372
+ * honestly settle this.
373
+ */
374
+ const UML_SHARED_BEHAVIOUR_KINDS = new Set(['initial', 'fork']);
375
+ /**
376
+ * One diagram, read off a flat element list.
377
+ *
378
+ * ## What belongs to it
379
+ *
380
+ * Geometry, exactly as every other framework in this library attributes: an
381
+ * element is on this sheet when its CENTRE is inside the frame's drawing area.
382
+ * Selecting a frame therefore selects what is drawn on it, and a second frame
383
+ * beside it is a second diagram — the reading `c4/export.ts` argues for at
384
+ * length, and the reason UML ships four diagram kinds on one canvas (ADR 0017).
385
+ *
386
+ * ## How an end finds its artefact
387
+ *
388
+ * A UML node is drawn as a GROUP — a shape plus its written tiers — and every
389
+ * part of that group is connectable. An arrow dragged onto a class records the
390
+ * id of the group, of the name tier or of the attribute tier about as often as
391
+ * the shape's, and all four look identical to the person drawing it. So each
392
+ * part answers for its shape, and only where the group holds exactly ONE UML
393
+ * node: a lasso somebody drew round two classes points at neither in
394
+ * particular, and guessing there would put a relationship in the file that
395
+ * nobody drew.
396
+ *
397
+ * ## Two things geometry decides, and nothing else does
398
+ *
399
+ * Phase 2 added the only readings in this file that measure one element against
400
+ * ANOTHER element: which component a PORT sits on (§11.3.4) and which component
401
+ * a lollipop or a socket is drawn against (§10.4.4, §11.6.4). Both are the
402
+ * notation's own statement — the specification describes a small symbol placed
403
+ * on or touching a rectangle, and a whiteboard holds no second link to read — so
404
+ * the reading is {@link umlHostOf} and the tolerance is
405
+ * {@link UML_ATTACH_TOLERANCE}. A glyph that touches nothing is kept on the
406
+ * drawing and written into no file, which is the same call the rest of this
407
+ * module makes about anything it cannot attribute.
408
+ *
409
+ * ## What is warned about
410
+ *
411
+ * Two things, and both are a line the author DREW that the file cannot carry: a
412
+ * typed relationship with an end this diagram cannot resolve, and a connector
413
+ * between two UML nodes carrying no role at all. The second is not an error —
414
+ * `docs/adr/0010` is explicit that the role IS the statement, so a bare
415
+ * connector relates nothing — but a user who drew a line between two classes
416
+ * and gets a file without it is owed the sentence.
417
+ *
418
+ * Neither is said about a connector that is not this sheet's. A line belongs to
419
+ * this diagram when at least one of its ends resolves to an artefact of it, or —
420
+ * for a line attached to nothing — when its own centre is inside the sheet.
421
+ * Anything else is another frame's drawing, and warning about it would mean
422
+ * every export of a two-frame board complaining about lines that are perfectly
423
+ * drawn next door.
424
+ */
425
+ export function umlModelFrom(diagram, elements, options = {}) {
426
+ const siblingsOf = options.siblingsOf ?? umlComponentSiblings;
427
+ const warnings = [];
428
+ const frame = umlBoundsOf(diagram);
429
+ const sheet = frame ? umlSheetOf(frame) : undefined;
430
+ const onSheet = (element) => {
431
+ if (!sheet)
432
+ return true;
433
+ const bounds = umlBoundsOf(element);
434
+ return bounds ? umlCentreInside(bounds, sheet) : false;
435
+ };
436
+ const groups = [];
437
+ const nodes = [];
438
+ const subjects = [];
439
+ const connectors = [];
440
+ /** §15.6.4's swimlanes and §14.2.4's composite states — the two new frames. */
441
+ const partitionElements = [];
442
+ const regionElements = [];
443
+ /** §17.6.4's combined fragments — phase 3's frame. */
444
+ const fragmentElements = [];
445
+ /** Every element's own box, by its own id — what a message's height is read
446
+ * off (a connector's end names the ELEMENT it touches, which may be a tier or
447
+ * a group rather than the node the end resolves to). */
448
+ const boxOfElement = new Map();
449
+ for (const element of elements) {
450
+ if (Array.isArray(element.childIds)) {
451
+ groups.push({ id: element.id, childIds: element.childIds });
452
+ }
453
+ const box = umlBoundsOf(element);
454
+ if (box)
455
+ boxOfElement.set(element.id, box);
456
+ if (element.type === 'umlNode')
457
+ nodes.push(element);
458
+ else if (element.type === 'umlSubject')
459
+ subjects.push(element);
460
+ else if (element.type === 'umlPartition')
461
+ partitionElements.push(element);
462
+ else if (element.type === 'umlRegion')
463
+ regionElements.push(element);
464
+ else if (element.type === 'umlFragment')
465
+ fragmentElements.push(element);
466
+ else if (element.type === 'connector')
467
+ connectors.push(element);
468
+ }
469
+ /** What one node's group says, by tier. */
470
+ const tiersOf = (node) => {
471
+ const group = umlGroupOf(node.id, groups);
472
+ const component = group ? siblingsOf(group, elements) : {};
473
+ // `label` is the actor's and the use case's tier, `name` the classifier's
474
+ // and `ident` §17.3.4's lifeline head — one of the three, never two
475
+ // (`component.ts`). The head is read here rather than anywhere special
476
+ // because a lifeline's name IS its head: `parseLifelineIdent` takes the
477
+ // line this returns and splits it into the participant and its classifier.
478
+ const named = component.name ?? component.label ?? component.ident;
479
+ return {
480
+ // The shape's own inner text is a FALLBACK and not a source: a node whose
481
+ // group was released, or whose tiers were deleted, still states its name
482
+ // if the shape carries one. The test is EXISTENCE of the tier — a name an
483
+ // author deliberately cleared has been cleared.
484
+ name: named ? umlTierText(named.text) : umlTierText(node.text),
485
+ attributes: umlTierText(component.attributes?.text),
486
+ operations: umlTierText(component.operations?.text),
487
+ };
488
+ };
489
+ /**
490
+ * What the frame SAYS it draws — read before the nodes, because two glyphs
491
+ * are shared between the behaviour sheets and the sheet's own heading is what
492
+ * tells them apart (see {@link UML_SHARED_BEHAVIOUR_KINDS}).
493
+ */
494
+ const diagramKind = diagram.kind ?? 'class';
495
+ const readsAsStateMachine = diagramKind === 'stm';
496
+ const classifiers = [];
497
+ const packages = [];
498
+ const actors = [];
499
+ const useCases = [];
500
+ const notes = [];
501
+ const subjectBoxes = [];
502
+ const components = [];
503
+ const ports = [];
504
+ const artifacts = [];
505
+ const deploymentNodes = [];
506
+ const activityNodes = [];
507
+ const lifelines = [];
508
+ const executions = [];
509
+ const destructions = [];
510
+ const states = [];
511
+ const finalStates = [];
512
+ const pseudostates = [];
513
+ /** The lollipops and the sockets, held until every box is known. */
514
+ const providedGlyphs = [];
515
+ const requiredGlyphs = [];
516
+ /** Every id that answers for an artefact of this diagram. */
517
+ const artefactOf = new Map();
518
+ for (const node of nodes) {
519
+ if (!onSheet(node))
520
+ continue;
521
+ const kind = node.kind;
522
+ if (!kind)
523
+ continue;
524
+ const tiers = tiersOf(node);
525
+ const stated = stereotypesOf(tiers.name);
526
+ const bounds = umlBoundsOf(node);
527
+ const base = {
528
+ id: node.id,
529
+ name: stated.name,
530
+ keywords: stated.keywords,
531
+ isAbstract: stated.isAbstract,
532
+ ...(bounds ? { bounds } : {}),
533
+ };
534
+ if (CLASSIFIER_KINDS.has(kind)) {
535
+ const classifierKind = kind;
536
+ const attributeLines = parseCompartment(tiers.attributes);
537
+ const operationLines = classifierKind === 'object' ? [] : parseCompartment(tiers.operations);
538
+ const classifier = {
539
+ ...base,
540
+ kind: classifierKind,
541
+ // An object has no attribute compartment: §11.6.4 gives it SLOTS, and
542
+ // the same lines are read as one or the other, never as both.
543
+ attributes: classifierKind === 'object' ? [] : attributeLines.map(parseProperty),
544
+ operations: operationLines.map(parseOperation),
545
+ slots: classifierKind === 'object' ? attributeLines.map(slotOf) : [],
546
+ lines: { attributes: attributeLines, operations: operationLines },
547
+ };
548
+ if (classifierKind === 'object') {
549
+ // `object : Class` (§11.6.4) — the instance name and the Classifier it
550
+ // instantiates, which is what XMI's `classifier` reference needs.
551
+ const colon = base.name.indexOf(':');
552
+ if (colon >= 0) {
553
+ classifier.name = base.name.slice(0, colon).trim();
554
+ const named = base.name.slice(colon + 1).trim();
555
+ if (named)
556
+ classifier.instanceOf = named;
557
+ }
558
+ }
559
+ classifiers.push(classifier);
560
+ }
561
+ else if (kind === 'package') {
562
+ packages.push(base);
563
+ }
564
+ else if (kind === 'actor') {
565
+ actors.push(base);
566
+ }
567
+ else if (kind === 'use-case') {
568
+ useCases.push(base);
569
+ }
570
+ else if (kind === 'note') {
571
+ // A note is PROSE: no keyword is lifted out of it and no `{abstract}` is
572
+ // read off it, because a note that mentions «create» is a note that
573
+ // mentions it.
574
+ notes.push({ ...base, name: '', keywords: [], body: tiers.name.trim() });
575
+ }
576
+ else if (kind === 'component') {
577
+ // The ports and the interface names are filled in below, once every box
578
+ // on the sheet is known: they are read by GEOMETRY, and a glyph drawn
579
+ // before its component would otherwise find nothing to attach to.
580
+ components.push({ ...base, ports: [], provided: [], required: [] });
581
+ }
582
+ else if (kind === 'port') {
583
+ ports.push(base);
584
+ }
585
+ else if (kind === 'provided-interface' || kind === 'required-interface') {
586
+ // The lollipop and the socket are the only drawn artefacts that are NOT
587
+ // elements of the model: §10.4.4 draws each as a notation for a
588
+ // relationship — an InterfaceRealization, a Usage — between the interface
589
+ // it names and the classifier it touches, and both writers turn it into
590
+ // exactly that. So it answers for nothing, which is why this branch does
591
+ // not reach the `artefactOf` line below: a connector dropped on a lollipop
592
+ // resolves to no artefact, and the warning says the end is not on the
593
+ // diagram rather than the file carrying a reference to a circle.
594
+ (kind === 'provided-interface' ? providedGlyphs : requiredGlyphs).push(base);
595
+ continue;
596
+ }
597
+ else if (kind === 'artifact') {
598
+ artifacts.push(base);
599
+ }
600
+ else if (kind === 'node' ||
601
+ kind === 'device' ||
602
+ kind === 'execution-environment') {
603
+ deploymentNodes.push({ ...base, kind });
604
+ }
605
+ else if (ACTIVITY_ONLY_KINDS.has(kind) ||
606
+ (UML_SHARED_BEHAVIOUR_KINDS.has(kind) && !readsAsStateMachine)) {
607
+ activityNodes.push({ ...base, kind: kind });
608
+ }
609
+ else if (kind === 'lifeline') {
610
+ // §17.3.4's `<name> : <Type>` — the one compartment a lifeline has, and
611
+ // the same grammar §11.6.4 gives an instance. `keywords` and `isAbstract`
612
+ // are `stereotypesOf`'s, read off the head like any other name tier.
613
+ const ident = parseLifelineIdent(base.name);
614
+ lifelines.push({
615
+ ...base,
616
+ // The `[<selector>]` §17.3.4 allows between the name and the colon is
617
+ // kept ON the name rather than given a field of its own: it is part of
618
+ // WHICH participant this is — `customers[3]` is one of a set — and
619
+ // neither writer has a slot for it (`Lifeline::selector` is a
620
+ // ValueSpecification, and a whiteboard has drawn no expression). Folded
621
+ // back here, the head survives out and in; split out, it would be a
622
+ // third field two writers would drop.
623
+ name: ident.selector
624
+ ? `${ident.name ?? ''}[${ident.selector}]`
625
+ : (ident.name ?? ''),
626
+ ...(ident.type ? { type: ident.type } : {}),
627
+ });
628
+ }
629
+ else if (kind === 'execution') {
630
+ // The bar's own extent IS the pair of occurrences it runs between
631
+ // (§17.2.4): the top is the start, the bottom the finish. The lifeline it
632
+ // sits on is measured below, once every spine on the sheet is known.
633
+ executions.push({
634
+ ...base,
635
+ y0: bounds ? bounds.y : 0,
636
+ y1: bounds ? bounds.y + bounds.h : 0,
637
+ });
638
+ }
639
+ else if (kind === 'destruction') {
640
+ destructions.push({
641
+ ...base,
642
+ y: bounds ? bounds.y + bounds.h / 2 : 0,
643
+ });
644
+ }
645
+ else if (kind === 'state') {
646
+ // §14.2.4.4's internal activities compartment. It is the `uml:attributes`
647
+ // tier — the same tier a class writes its properties in, reused because a
648
+ // state's compartment is the second one down and the creation site seeds
649
+ // it with `entry / …`. Every line that is NOT one of the three labels
650
+ // stays in `lines`, so an internal transition somebody wrote survives.
651
+ const entry = [];
652
+ const doActivity = [];
653
+ const exit = [];
654
+ const lines = [];
655
+ for (const line of parseCompartment(tiers.attributes)) {
656
+ const behavior = parseStateBehavior(line);
657
+ if (!behavior) {
658
+ lines.push(line);
659
+ continue;
660
+ }
661
+ // A label with nothing after it is a line somebody is halfway through,
662
+ // and an empty behaviour expression is not a behaviour: it is kept as a
663
+ // line of the compartment rather than written into a file as a Behavior
664
+ // with no body.
665
+ if (!behavior.expression) {
666
+ lines.push(line);
667
+ continue;
668
+ }
669
+ if (behavior.kind === 'entry')
670
+ entry.push(behavior.expression);
671
+ else if (behavior.kind === 'do')
672
+ doActivity.push(behavior.expression);
673
+ else
674
+ exit.push(behavior.expression);
675
+ }
676
+ states.push({ ...base, entry, doActivity, exit, lines });
677
+ }
678
+ else if (kind === 'final-state') {
679
+ finalStates.push(base);
680
+ }
681
+ else if (PSEUDOSTATE_ONLY_KINDS.has(kind) ||
682
+ (UML_SHARED_BEHAVIOUR_KINDS.has(kind) && readsAsStateMachine)) {
683
+ pseudostates.push({ ...base, kind: kind });
684
+ }
685
+ else {
686
+ // A kind this build does not draw yet (a later phase widens the union):
687
+ // it is on the sheet and it answers for itself, but nothing writes it
688
+ // down.
689
+ continue;
690
+ }
691
+ artefactOf.set(node.id, node.id);
692
+ }
693
+ // ── The structural attachments, once every box on the sheet is known ──
694
+ //
695
+ // Two geometric readings, and the only two in this file that are about one
696
+ // element's position relative to ANOTHER element rather than to a frame.
697
+ // Neither is a fallback for a link the store could have held: §11.3.4 and
698
+ // §10.4.4 describe a small symbol drawn ON or AGAINST a rectangle, and on a
699
+ // whiteboard that is the whole of what the author stated.
700
+ /** A port's host: the component or the cube its square sits on (§11.3.4). */
701
+ const portHosts = [...components, ...deploymentNodes];
702
+ const componentById = new Map(components.map(component => [component.id, component]));
703
+ for (const port of ports) {
704
+ const host = umlHostOf(port.bounds, portHosts);
705
+ if (!host)
706
+ continue;
707
+ port.ownerId = host.id;
708
+ // Only a COMPONENT owns a port in the file: `uml:Port` is an
709
+ // `ownedAttribute` of an EncapsulatedClassifier, and a port drawn on a cube
710
+ // is a drawing this pack keeps and writes nowhere.
711
+ componentById.get(host.id)?.ports.push(port);
712
+ }
713
+ /**
714
+ * An interface glyph's component: the box it touches, or the component that
715
+ * owns the PORT it touches — §11.3.4's "A provided Interface may be shown
716
+ * using the lollipop notation attached to the Port".
717
+ */
718
+ const glyphHosts = [...components, ...ports];
719
+ const attachInterface = (glyph, provided) => {
720
+ const name = glyph.name.trim();
721
+ if (!name)
722
+ return;
723
+ const host = umlHostOf(glyph.bounds, glyphHosts);
724
+ if (!host)
725
+ return;
726
+ const owner = componentById.get(host.id) ??
727
+ componentById.get(ports.find(port => port.id === host.id)?.ownerId ?? '');
728
+ if (!owner)
729
+ return;
730
+ const list = provided ? owner.provided : owner.required;
731
+ // §11.3.4 lists several interfaces on one lollipop, separated by commas —
732
+ // and a component that provides the same interface twice says it once.
733
+ for (const each of name.split(',')) {
734
+ const trimmed = each.trim();
735
+ if (trimmed && !list.includes(trimmed))
736
+ list.push(trimmed);
737
+ }
738
+ };
739
+ for (const glyph of providedGlyphs)
740
+ attachInterface(glyph, true);
741
+ for (const glyph of requiredGlyphs)
742
+ attachInterface(glyph, false);
743
+ for (const subject of subjects) {
744
+ if (!onSheet(subject))
745
+ continue;
746
+ const bounds = umlBoundsOf(subject);
747
+ subjectBoxes.push({
748
+ id: subject.id,
749
+ name: umlTierText(subject.name),
750
+ keywords: [],
751
+ isAbstract: false,
752
+ ...(bounds ? { bounds } : {}),
753
+ });
754
+ artefactOf.set(subject.id, subject.id);
755
+ }
756
+ // ── The two behaviour FRAMES, and what they hold ──────────────────────
757
+ //
758
+ // Neither is registered in `artefactOf`, and that is the lollipop's precedent
759
+ // rather than an omission: a swimlane and a composite state are furniture the
760
+ // author draws responsibility and nesting with, so a flow dropped on one
761
+ // relates nothing. The warning an author gets for a control flow ending on a
762
+ // lane is then the right one — an end that is not on the diagram — instead of
763
+ // a file carrying an arrow into a band.
764
+ const partitions = [];
765
+ for (const partition of partitionElements) {
766
+ if (!onSheet(partition))
767
+ continue;
768
+ const bounds = umlBoundsOf(partition);
769
+ partitions.push({
770
+ id: partition.id,
771
+ name: umlTierText(partition.name),
772
+ keywords: [],
773
+ isAbstract: false,
774
+ // The model defaults it, and a fixture that states nothing means the
775
+ // default rather than an absent band.
776
+ orientation: partition.orientation === 'horizontal' ? 'horizontal' : 'vertical',
777
+ nodeIds: [],
778
+ ...(bounds ? { bounds } : {}),
779
+ });
780
+ }
781
+ const regions = [];
782
+ for (const region of regionElements) {
783
+ if (!onSheet(region))
784
+ continue;
785
+ const bounds = umlBoundsOf(region);
786
+ regions.push({
787
+ id: region.id,
788
+ name: umlTierText(region.name),
789
+ keywords: [],
790
+ isAbstract: false,
791
+ ...(bounds ? { bounds } : {}),
792
+ });
793
+ }
794
+ // ── The interaction's geometry (§17.2.4, §17.6.4) ─────────────────────
795
+ //
796
+ // Two readings, both by MEASURING, and both for the reason §11.3.4's port and
797
+ // §10.4.4's lollipop are read that way: the notation draws a mark ON a line
798
+ // and a rectangle AROUND a group of lines, and a whiteboard holds no second
799
+ // statement about either. A bar off every spine and a fragment over no
800
+ // lifeline are kept on the drawing and written into no file.
801
+ for (const execution of executions) {
802
+ const lifeline = umlLifelineAt(execution.bounds, lifelines);
803
+ if (lifeline)
804
+ execution.lifelineId = lifeline.id;
805
+ }
806
+ for (const destruction of destructions) {
807
+ const lifeline = umlLifelineAt(destruction.bounds, lifelines);
808
+ if (lifeline)
809
+ destruction.lifelineId = lifeline.id;
810
+ }
811
+ const fragments = [];
812
+ for (const fragment of fragmentElements) {
813
+ if (!onSheet(fragment))
814
+ continue;
815
+ const box = umlBoundsOf(fragment);
816
+ const written = umlTierText(fragment.name);
817
+ const operator = fragment.operator ?? 'alt';
818
+ // A fragment COVERS the lifelines whose spine runs through it (§17.6.4),
819
+ // ordered left to right — which is the order `ref over a, b` is written in
820
+ // and the order a reader's eye takes them in.
821
+ const covered = box
822
+ ? lifelines
823
+ .filter(lifeline => {
824
+ const spine = lifeline.bounds
825
+ ? umlSpineX(lifeline.bounds)
826
+ : undefined;
827
+ return (spine !== undefined && spine >= box.x && spine <= box.x + box.w);
828
+ })
829
+ .sort((a, b) => {
830
+ const gap = umlSpineX(a.bounds) - umlSpineX(b.bounds);
831
+ return gap !== 0 ? gap : a.id < b.id ? -1 : 1;
832
+ })
833
+ .map(lifeline => lifeline.id)
834
+ : [];
835
+ fragments.push({
836
+ id: fragment.id,
837
+ name: written,
838
+ keywords: [],
839
+ isAbstract: false,
840
+ ...(box ? { bounds: box } : {}),
841
+ // The model defaults it, and a fixture that states nothing means the
842
+ // default rather than a fragment with no word in its pentagon.
843
+ operator,
844
+ // A `ref`'s `name` is the INTERACTION it refers to (§17.7.4), never a
845
+ // guard, so it is not handed to the operand it would otherwise become the
846
+ // condition of. Every other operator's `name` is the first operand's
847
+ // guard, which is what `UmlFragmentElementModel.name` says it is.
848
+ operands: box
849
+ ? umlOperandBands(box, fragment.operands, operator === 'ref' ? '' : written)
850
+ : [{ y0: 0, y1: 0 }],
851
+ coveredLifelineIds: covered,
852
+ });
853
+ }
854
+ /** The smallest frame whose box holds a centre — most-nested wins. */
855
+ const frameOf = (element, frames, strictlyLarger = false) => {
856
+ const bounds = element.bounds;
857
+ if (!bounds)
858
+ return undefined;
859
+ const own = Math.max(0, bounds.w) * Math.max(0, bounds.h);
860
+ let best;
861
+ let bestArea = Number.POSITIVE_INFINITY;
862
+ for (const candidate of frames) {
863
+ const box = candidate.bounds;
864
+ if (candidate.id === element.id || !box)
865
+ continue;
866
+ if (!umlCentreInside(bounds, box))
867
+ continue;
868
+ const area = Math.max(0, box.w) * Math.max(0, box.h);
869
+ if (strictlyLarger && area <= own)
870
+ continue;
871
+ if (area < bestArea) {
872
+ best = candidate;
873
+ bestArea = area;
874
+ }
875
+ }
876
+ return best;
877
+ };
878
+ const partitionById = new Map(partitions.map(lane => [lane.id, lane]));
879
+ for (const node of activityNodes) {
880
+ const lane = frameOf(node, partitions);
881
+ if (!lane)
882
+ continue;
883
+ node.partitionId = lane.id;
884
+ partitionById.get(lane.id)?.nodeIds.push(node.id);
885
+ }
886
+ // §14.2.4's nesting: a composite state drawn inside a composite state. The
887
+ // strict-area test is what makes it an order rather than a cycle — the same
888
+ // guard `containerOf` holds for a package inside a package.
889
+ for (const region of regions) {
890
+ const parent = frameOf(region, regions, true);
891
+ if (parent)
892
+ region.parentId = parent.id;
893
+ }
894
+ for (const vertex of [...states, ...finalStates, ...pseudostates]) {
895
+ const region = frameOf(vertex, regions);
896
+ if (region)
897
+ vertex.regionId = region.id;
898
+ }
899
+ // Each part of a node's group answers for the node — but only where the group
900
+ // holds exactly one. See the docblock.
901
+ for (const group of groups) {
902
+ const inside = group.childIds.filter(id => artefactOf.has(id));
903
+ if (inside.length !== 1)
904
+ continue;
905
+ const artefact = artefactOf.get(inside[0]);
906
+ artefactOf.set(group.id, artefact);
907
+ for (const childId of group.childIds) {
908
+ if (!artefactOf.has(childId))
909
+ artefactOf.set(childId, artefact);
910
+ }
911
+ }
912
+ const relations = [];
913
+ /** §17.4's arrows, with the height each was drawn at. See {@link UmlMessage}. */
914
+ const messages = [];
915
+ for (const connector of connectors) {
916
+ const from = connector.source?.id;
917
+ const to = connector.target?.id;
918
+ const source = from ? artefactOf.get(from) : undefined;
919
+ const target = to ? artefactOf.get(to) : undefined;
920
+ // ── Is this line on THIS sheet at all? ───────────────────────────────
921
+ //
922
+ // The element list is the whole surface, not one frame's worth of it, so
923
+ // every connector of every diagram arrives here. A connector has no centre
924
+ // of its own until the router has run — its bound is derived from the path
925
+ // between its two ends, and a freshly loaded document answers `[0,0,0,0]` —
926
+ // so the reading that decides membership is its ENDS: a line belongs to this
927
+ // sheet when at least one of them resolves to an artefact of it. Only where
928
+ // neither does is the connector's own box asked, which catches the one case
929
+ // the ends cannot — a line the author drew on this sheet and attached to
930
+ // nothing.
931
+ //
932
+ // Without this test a board with two frames warned twice on every export
933
+ // about connectors that are perfectly drawn on the OTHER sheet (the
934
+ // tranche-F recette's finding): the ends resolved on neither diagram's
935
+ // `artefactOf`, so each export claimed the other's lines were dangling.
936
+ if (!source && !target && !onSheet(connector))
937
+ continue;
938
+ const kind = connector.role
939
+ ? RELATION_OF_ROLE.get(connector.role)
940
+ : undefined;
941
+ if (!kind) {
942
+ // A connector the author drew between two UML artefacts and never typed.
943
+ if (source && target) {
944
+ warnings.push('A connector between two UML elements carries no relationship type, so it is not in the file. Redraw it with one of the UML relationship tools.');
945
+ }
946
+ continue;
947
+ }
948
+ if (!source || !target) {
949
+ warnings.push(`A ${kind} could not be written: one of its ends is not on this diagram.`);
950
+ continue;
951
+ }
952
+ const label = umlTierText(connector.text);
953
+ // §11.5.4's adornments, read off the two per-end labels ADR 0020 added to
954
+ // the connector. Parsed only where the clause applies; kept verbatim
955
+ // everywhere else — see {@link UmlRelation.sourceEnd}.
956
+ const endOf = (text) => {
957
+ const raw = umlTierText(text);
958
+ if (!raw)
959
+ return undefined;
960
+ return ADORNED_RELATION_KINDS.has(kind) ? parseEndLabel(raw) : { raw };
961
+ };
962
+ const sourceEnd = endOf(connector.sourceLabel);
963
+ const targetEnd = endOf(connector.targetLabel);
964
+ relations.push({
965
+ kind,
966
+ sourceId: source,
967
+ targetId: target,
968
+ ...(label ? { label } : {}),
969
+ ...(sourceEnd ? { sourceEnd } : {}),
970
+ ...(targetEnd ? { targetEnd } : {}),
971
+ });
972
+ if (MESSAGE_KINDS.has(kind)) {
973
+ messages.push({
974
+ id: connector.id,
975
+ kind: kind,
976
+ sourceId: source,
977
+ targetId: target,
978
+ ...(label ? { label } : {}),
979
+ y: umlMessageHeight(connector, from ? boxOfElement.get(from) : undefined, to ? boxOfElement.get(to) : undefined),
980
+ });
981
+ }
982
+ }
983
+ // §17.4.4: "every line fragment is either horizontal or downwards" — the
984
+ // vertical axis is time, so the ORDER is what every writer needs and the
985
+ // height is what the canvas stated. Sorted once, here, with document order
986
+ // breaking a tie: two messages drawn at exactly the same height are two the
987
+ // author did not order, and a sort that reordered them on every export would
988
+ // make a golden file flap.
989
+ messages.sort((a, b) => a.y - b.y);
990
+ const kind = diagramKind;
991
+ const name = umlTierText(diagram.name);
992
+ const heading = typeof diagram.heading === 'string' && diagram.heading
993
+ ? diagram.heading
994
+ : `${UML_DIAGRAM_KIND_TAG[kind] ?? kind} ${name}`.trim();
995
+ // ── The two behaviours, assembled ─────────────────────────────────────
996
+ //
997
+ // The edges are PROJECTED from `relations` rather than read again: one pass
998
+ // over the connectors resolved every end, applied every attribution rule and
999
+ // raised every warning, and a second pass could only disagree with it. What is
1000
+ // added here is the LABEL grammar — §15.2.4's guard and weight, §14.2.4.8's
1001
+ // triggers, guard and effect — which is the one thing a flow says that a
1002
+ // structural relationship does not.
1003
+ const activityEdges = [];
1004
+ const transitions = [];
1005
+ for (const relation of relations) {
1006
+ if (relation.kind === 'control-flow' || relation.kind === 'object-flow') {
1007
+ const annotations = parseActivityEdge(relation.label);
1008
+ activityEdges.push({
1009
+ kind: relation.kind,
1010
+ sourceId: relation.sourceId,
1011
+ targetId: relation.targetId,
1012
+ ...annotations,
1013
+ });
1014
+ }
1015
+ else if (relation.kind === 'transition') {
1016
+ const label = parseTransition(relation.label);
1017
+ transitions.push({
1018
+ sourceId: relation.sourceId,
1019
+ targetId: relation.targetId,
1020
+ ...label,
1021
+ });
1022
+ }
1023
+ }
1024
+ // A sheet holds a behaviour when it holds a GLYPH of it or a LINE of it: an
1025
+ // author who has drawn two arrows and no boxes yet has still said something,
1026
+ // and a writer that dropped it would lose the drawing rather than summarise
1027
+ // it. A sheet holding neither contributes an empty list and neither writer
1028
+ // emits a word.
1029
+ const activities = activityNodes.length > 0 ||
1030
+ activityEdges.length > 0 ||
1031
+ partitions.length > 0
1032
+ ? [
1033
+ {
1034
+ id: diagram.id,
1035
+ name,
1036
+ nodes: activityNodes,
1037
+ edges: activityEdges,
1038
+ partitions,
1039
+ },
1040
+ ]
1041
+ : [];
1042
+ const interactions = lifelines.length > 0 ||
1043
+ messages.length > 0 ||
1044
+ fragments.length > 0 ||
1045
+ executions.length > 0 ||
1046
+ destructions.length > 0
1047
+ ? [
1048
+ {
1049
+ id: diagram.id,
1050
+ name,
1051
+ lifelines,
1052
+ messages,
1053
+ fragments,
1054
+ executions,
1055
+ destructions,
1056
+ },
1057
+ ]
1058
+ : [];
1059
+ const stateMachines = states.length > 0 ||
1060
+ finalStates.length > 0 ||
1061
+ pseudostates.length > 0 ||
1062
+ transitions.length > 0 ||
1063
+ regions.length > 0
1064
+ ? [
1065
+ {
1066
+ id: diagram.id,
1067
+ name,
1068
+ regions,
1069
+ states,
1070
+ finalStates,
1071
+ pseudostates,
1072
+ transitions,
1073
+ },
1074
+ ]
1075
+ : [];
1076
+ return {
1077
+ diagram: {
1078
+ id: diagram.id,
1079
+ kind,
1080
+ name,
1081
+ heading,
1082
+ ...(frame ? { bounds: frame } : {}),
1083
+ },
1084
+ classifiers,
1085
+ packages,
1086
+ actors,
1087
+ useCases,
1088
+ subjects: subjectBoxes,
1089
+ notes,
1090
+ components,
1091
+ ports,
1092
+ artifacts,
1093
+ nodes: deploymentNodes,
1094
+ activities,
1095
+ stateMachines,
1096
+ interactions,
1097
+ relations,
1098
+ warnings,
1099
+ };
1100
+ }
1101
+ /**
1102
+ * The HEIGHT one message was drawn at — §17.4.4's whole statement about time.
1103
+ *
1104
+ * Read off the connector's stored END POSITIONS first, and that is the point of
1105
+ * the function: a connector's own bound is DERIVED by the router from the path
1106
+ * between its ends, and a freshly loaded document answers `[0,0,0,0]` until
1107
+ * something paints. A `position` is in the store, so an export run against a
1108
+ * document nobody has opened orders the conversation exactly as an export run
1109
+ * against a board on screen does.
1110
+ *
1111
+ * Both ends when both are anchored, because §17.4.4 draws a message as a
1112
+ * horizontal line and the two ends agree; the midpoint is what a message drawn
1113
+ * slightly sloping means. One end when only one is anchored. The connector's own
1114
+ * box when neither is — a message the author dragged onto empty canvas — and
1115
+ * zero when there is nothing at all to read, which puts it first and says so by
1116
+ * being obviously at the top.
1117
+ */
1118
+ function umlMessageHeight(connector, from, to) {
1119
+ const at = (end, box) => {
1120
+ const t = end?.position?.[1];
1121
+ if (!box || typeof t !== 'number' || !Number.isFinite(t))
1122
+ return undefined;
1123
+ return box.y + t * box.h;
1124
+ };
1125
+ const head = at(connector.source, from);
1126
+ const tail = at(connector.target, to);
1127
+ if (head !== undefined && tail !== undefined)
1128
+ return (head + tail) / 2;
1129
+ if (head !== undefined)
1130
+ return head;
1131
+ if (tail !== undefined)
1132
+ return tail;
1133
+ const bounds = umlBoundsOf(connector);
1134
+ if (bounds && (bounds.w !== 0 || bounds.h !== 0)) {
1135
+ return bounds.y + bounds.h / 2;
1136
+ }
1137
+ return 0;
1138
+ }
1139
+ /**
1140
+ * Which entry comes first when two were drawn at exactly the same height.
1141
+ *
1142
+ * Only a hand-drawn board ever ties — an invented layout gives every event a
1143
+ * slot of its own — and the order is the one a reader would write the lines in:
1144
+ * the fragment that CONTAINS the moment opens first, then the arrow, then the
1145
+ * bar it starts, then the bar it ends, and last the cross that ends the
1146
+ * participant.
1147
+ */
1148
+ const TIMELINE_RANK = {
1149
+ fragment: 0,
1150
+ message: 1,
1151
+ 'execution-start': 2,
1152
+ 'execution-finish': 3,
1153
+ destruction: 4,
1154
+ };
1155
+ /**
1156
+ * One Interaction as the ordered, nested account of what happens on it — the
1157
+ * reading BOTH writers take, so that a `.puml` and an XMI file can never
1158
+ * disagree about the order of a conversation.
1159
+ *
1160
+ * ## What decides that something is INSIDE a fragment
1161
+ *
1162
+ * Two things, and both are what §17.6.4 draws: the moment falls inside the
1163
+ * fragment's box VERTICALLY, and the fragment COVERS the lifeline it happens on
1164
+ * ({@link UmlCombinedFragment.coveredLifelineIds}). The second is what stops an
1165
+ * `alt` drawn over two of five participants from swallowing a message exchanged
1166
+ * between the other three at the same height — which a y-only test would, and
1167
+ * which would put an arrow in a branch nobody drew it in.
1168
+ *
1169
+ * The innermost matching fragment wins, measured by AREA, exactly as `umlHostOf`
1170
+ * and `containerOf` decide every other containment in this framework.
1171
+ *
1172
+ * Pure and total: every message, bar and cross of the interaction comes back
1173
+ * exactly once, whether it landed in a fragment or at the top level.
1174
+ */
1175
+ export function umlInteractionTimeline(interaction) {
1176
+ /**
1177
+ * The spine an end of a message is on.
1178
+ *
1179
+ * A message end names a LIFELINE, an execution bar or a destruction cross
1180
+ * (§17.4.4 attaches a MessageEnd to an occurrence, and the canvas draws all
1181
+ * three), and only a lifeline id is in a fragment's `coveredLifelineIds`. So
1182
+ * the other two are resolved to the spine they sit on before containment is
1183
+ * measured — without it a delete message is read as happening on a spine no
1184
+ * fragment covers, and every `destroy` drawn inside an `opt` is written
1185
+ * outside it, leaving the fragment empty.
1186
+ */
1187
+ const lifelineOfMark = new Map([
1188
+ ...interaction.executions.map(execution => [execution.id, execution.lifelineId]),
1189
+ ...interaction.destructions.map(destruction => [destruction.id, destruction.lifelineId]),
1190
+ ]);
1191
+ const spineOf = (id) => lifelineOfMark.has(id) ? lifelineOfMark.get(id) : id;
1192
+ const covers = new Map(interaction.fragments.map(fragment => [
1193
+ fragment.id,
1194
+ new Set(fragment.coveredLifelineIds),
1195
+ ]));
1196
+ const areaOf = (fragment) => fragment.bounds
1197
+ ? Math.max(0, fragment.bounds.w) * Math.max(0, fragment.bounds.h)
1198
+ : Number.POSITIVE_INFINITY;
1199
+ /**
1200
+ * The innermost fragment a moment on these spines falls in.
1201
+ *
1202
+ * `exclude` is the fragment being placed itself, so that a fragment cannot
1203
+ * claim to contain itself; a fragment nested in another is placed against the
1204
+ * outer one by its own top edge.
1205
+ */
1206
+ const fragmentAt = (y, spines, exclude) => {
1207
+ let best;
1208
+ let bestArea = Number.POSITIVE_INFINITY;
1209
+ for (const fragment of interaction.fragments) {
1210
+ if (fragment === exclude || !fragment.bounds)
1211
+ continue;
1212
+ const box = fragment.bounds;
1213
+ if (y < box.y || y > box.y + box.h)
1214
+ continue;
1215
+ if (exclude?.bounds && areaOf(fragment) <= areaOf(exclude))
1216
+ continue;
1217
+ const covered = covers.get(fragment.id);
1218
+ const named = spines.filter((id) => Boolean(id));
1219
+ // A fragment covering NO lifeline of this moment is a rectangle drawn
1220
+ // beside the conversation, not round it.
1221
+ if (named.length > 0 && !named.every(id => covered.has(id)))
1222
+ continue;
1223
+ const area = areaOf(fragment);
1224
+ if (area < bestArea ||
1225
+ (area === bestArea && best && fragment.id < best.id)) {
1226
+ best = fragment;
1227
+ bestArea = area;
1228
+ }
1229
+ }
1230
+ return best;
1231
+ };
1232
+ /** Which operand band of a fragment holds this height — the last that opens. */
1233
+ const bandAt = (fragment, y) => {
1234
+ let index = 0;
1235
+ for (const [at, operand] of fragment.operands.entries()) {
1236
+ if (y >= operand.y0)
1237
+ index = at;
1238
+ }
1239
+ return index;
1240
+ };
1241
+ const built = new Map();
1242
+ for (const fragment of interaction.fragments) {
1243
+ built.set(fragment.id, {
1244
+ at: 'fragment',
1245
+ y: fragment.bounds ? fragment.bounds.y : 0,
1246
+ fragment,
1247
+ operands: fragment.operands.map(operand => ({ operand, entries: [] })),
1248
+ });
1249
+ }
1250
+ const root = [];
1251
+ const place = (entry, spines, self) => {
1252
+ const host = fragmentAt(entry.y, spines, self);
1253
+ if (!host) {
1254
+ root.push(entry);
1255
+ return;
1256
+ }
1257
+ const container = built.get(host.id);
1258
+ const band = container.operands[bandAt(host, entry.y)];
1259
+ if (band)
1260
+ band.entries.push(entry);
1261
+ else
1262
+ root.push(entry);
1263
+ };
1264
+ // Outermost first, so a nested fragment's own container already exists when
1265
+ // the fragments inside it are placed. Ordering by area is what makes that
1266
+ // true whatever order the canvas was walked in.
1267
+ for (const fragment of [...interaction.fragments].sort((a, b) => areaOf(b) - areaOf(a))) {
1268
+ place(built.get(fragment.id), fragment.coveredLifelineIds, fragment);
1269
+ }
1270
+ for (const message of interaction.messages) {
1271
+ place({ at: 'message', y: message.y, message }, [
1272
+ spineOf(message.sourceId),
1273
+ spineOf(message.targetId),
1274
+ ]);
1275
+ }
1276
+ for (const execution of interaction.executions) {
1277
+ place({ at: 'execution-start', y: execution.y0, execution }, [
1278
+ execution.lifelineId,
1279
+ ]);
1280
+ place({ at: 'execution-finish', y: execution.y1, execution }, [
1281
+ execution.lifelineId,
1282
+ ]);
1283
+ }
1284
+ for (const destruction of interaction.destructions) {
1285
+ place({ at: 'destruction', y: destruction.y, destruction }, [
1286
+ destruction.lifelineId,
1287
+ ]);
1288
+ }
1289
+ const order = (entries) => {
1290
+ const sorted = [...entries].sort((a, b) => a.y - b.y || TIMELINE_RANK[a.at] - TIMELINE_RANK[b.at]);
1291
+ for (const entry of sorted) {
1292
+ if (entry.at !== 'fragment')
1293
+ continue;
1294
+ for (const band of entry.operands)
1295
+ band.entries = order(band.entries);
1296
+ }
1297
+ return sorted;
1298
+ };
1299
+ return order(root);
1300
+ }
1301
+ /**
1302
+ * One line of an object's compartment as a SLOT.
1303
+ *
1304
+ * Read through {@link parseProperty} rather than by splitting on `=`, so that
1305
+ * `stock : Integer = 3` — a slot an author wrote the feature's type on — gives
1306
+ * the same `stock = 3` a bare `stock = 3` does. The type is dropped because a
1307
+ * slot does not declare one: §11.6.4's compartment gives VALUES to features the
1308
+ * Classifier already typed.
1309
+ */
1310
+ function slotOf(line) {
1311
+ const property = parseProperty(line);
1312
+ return {
1313
+ name: property.name,
1314
+ ...(property.defaultValue ? { value: property.defaultValue } : {}),
1315
+ };
1316
+ }