@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/import.js ADDED
@@ -0,0 +1,1270 @@
1
+ import { connectorEndLabelBox, ConnectorMode, TextAlign, UML_FRAME_BAND_HEIGHT, UML_LIFELINE_HEAD, } from '@formicoidea/labre-core/model';
2
+ import { Bound } from '@formicoidea/labre-core/global/gfx';
3
+ import { umlCompartmentBoxes, umlLabelRoleOf, } from './component.js';
4
+ import { UML_BODY_FONT_SIZE, UML_DIAGRAM_BOX, UML_EDGE_WIDTH, UML_FRAGMENT_BOX, UML_INK, UML_NAME_FONT_SIZE, UML_NODE_BOX, UML_PARTITION_BOX, UML_REGION_BOX, UML_SUBJECT_BOX, } from './consts.js';
5
+ import { UML_EDGE_STYLE } from './edge-styles.js';
6
+ import { formatActivityEdgeLabel, formatEndLabel, formatTransitionLabel, } from './grammar.js';
7
+ import { guillemets, UML_UNLABELLED_KINDS } from './keywords.js';
8
+ import { umlGuardText, } from './model.js';
9
+ import { umlNodeProps, umlTextProps } from './presets.js';
10
+ import { UML_ROLE } from './roles.js';
11
+ /**
12
+ * The **one materializer** every UML importer ends on — a model in, the
13
+ * serialized props of a board out (`docs/adr/0012`, P3).
14
+ *
15
+ * ## Why the pipeline forks at the PARSER and nowhere else
16
+ *
17
+ * Three formats read UML into Labre — PlantUML, XMI 2.5.1 and draw.io — and the
18
+ * only thing they disagree about is how a file spells a class. What a class IS
19
+ * on this canvas is five elements written in painting order, with three seeded
20
+ * compartments, one role per tier and a group over them (`actions.ts`), and that
21
+ * answer belongs to the pack rather than to any file format. Written three
22
+ * times it would be wrong in three different ways, and a class imported from a
23
+ * `.drawio` would not be the class the toolbox draws.
24
+ *
25
+ * So each parser produces a {@link UmlModel} — the same IR the two WRITERS
26
+ * already take — and this file turns it into elements. The pivot is the phase-1
27
+ * model and not a shape of its own, which is what makes the round trip
28
+ * checkable: read our own export, materialize it, read the board back with
29
+ * `umlModelFrom`, and the model is the one we started with.
30
+ *
31
+ * ## Never a size, never a colour, never a seed
32
+ *
33
+ * Every prop below comes out of {@link umlNodeProps}, {@link umlTextProps} and
34
+ * {@link umlCompartmentBoxes} — the very three functions the creation site calls
35
+ * (R17). Not one measurement is restated here, so an imported class and a drawn
36
+ * one are the same element down to the stroke width, and a restyle that moves
37
+ * the pack moves the import with it.
38
+ *
39
+ * ## The two names an element carries, and they are not the same name
40
+ *
41
+ * - `interchange[formatId].id` is the SOURCE file's id, verbatim: D3's fixed
42
+ * point, what an export prefers back, and what a support thread quotes.
43
+ * - `id` is this reader's OWN provisional name — `uml-import-<n>`, minted in
44
+ * document order. `surface.addElement` overwrites it with a nanoid and is
45
+ * documented to, so it reaches no document: it exists so that a connector can
46
+ * say which shape it runs to and a group can say which tiers it holds, before
47
+ * the surface has minted an id for either. `materializeInterchangeImport`
48
+ * resolves both.
49
+ *
50
+ * A source id is NOT usable for the second job: a tier and a group are elements
51
+ * Labre mints and no file ever named, two sheets may spell one class the same
52
+ * way, and PlantUML's own identifiers are aliases rather than ids. Minting is
53
+ * the only answer that is always available and always unique.
54
+ *
55
+ * ## Layout (D4)
56
+ *
57
+ * Geometry when the source carried some — `opts.layout.boxes` for a format that
58
+ * reads coordinates out of the file (draw.io's `mxGeometry`, XMI's `umldi`), or
59
+ * the `bounds` a parser already wrote onto the IR. Otherwise a layered layout is
60
+ * INVENTED, in rows by generalization depth, and the caller says so with an
61
+ * `invented-layout` note: D4 forbids claiming a position came from a file that
62
+ * never had one.
63
+ *
64
+ * ## Pure
65
+ *
66
+ * Models in, props out. No `std`, no surface, no DOM, no clock, no randomness —
67
+ * every id and every coordinate is a function of document order alone, so the
68
+ * same file always lands the same board.
69
+ */
70
+ /* ── The invented grid ────────────────────────────────────────────────── */
71
+ /**
72
+ * One cell of the layered layout — the module a diagram nobody positioned is
73
+ * measured in.
74
+ *
75
+ * Wider and shallower than a class box (200 × 120) on purpose: the gap has to
76
+ * leave room for the name of the relationship drawn across it, and a row of
77
+ * boxes packed edge to edge reads as one band rather than as five classifiers.
78
+ */
79
+ export const UML_IMPORT_SLOT = { w: 240, h: 140 };
80
+ /** The space between two slots, in both directions. */
81
+ export const UML_IMPORT_GUTTER = 60;
82
+ /** The margin between the drawing and the frame drawn round it. */
83
+ export const UML_IMPORT_MARGIN = 60;
84
+ /** The padding inside a container — a package, a subject, a lane, a region. */
85
+ const UML_IMPORT_CONTAINER_INSET = 40;
86
+ /**
87
+ * The extra space a container keeps at the TOP, above whatever it holds.
88
+ *
89
+ * A package writes its name under a tab (§12.2.4), a subject writes its own in
90
+ * the top-left corner (§18.1.4), a region writes it in a band (§14.2.4): all
91
+ * three put words where a contained artefact would otherwise be drawn, and a
92
+ * class laid over a package's tab is a class the reader cannot attribute.
93
+ */
94
+ const UML_IMPORT_CONTAINER_HEADER = 48;
95
+ /* ── The invented SEQUENCE layout (§17.2.4) ───────────────────────────── */
96
+ /**
97
+ * The distance between two lifeline SPINES on a sheet nobody positioned.
98
+ *
99
+ * Wide enough that a 160-unit head has 40 units of air on either side of it, and
100
+ * that a message label written over the gap is legible. It is also comfortably
101
+ * more than twice {@link UML_SPINE_TOLERANCE}, which is what stops a bar dropped
102
+ * on one spine from being claimed by its neighbour.
103
+ */
104
+ export const UML_SD_COLUMN_GAP = 200;
105
+ /**
106
+ * The height of ONE SLOT — the vertical distance between two consecutive things
107
+ * that happen.
108
+ *
109
+ * A slot per EVENT and not per message, and that is the whole trick of the
110
+ * sequence importers: a `.puml` and an XMI fragment list state an ORDER and no
111
+ * heights, the canvas states heights and no order, and `model.ts` reads the
112
+ * order back off the heights. Giving every event — an arrow, the start of a bar,
113
+ * the end of one, an `alt`, an `else`, an `end` — a slot of its own is what makes
114
+ * that round trip exact: no two events tie, so nothing can come back reordered.
115
+ */
116
+ export const UML_SD_EVENT_STEP = 40;
117
+ /** Where the first event sits: one slot below the bottom of the heads. */
118
+ export const UML_SD_FIRST_EVENT = UML_LIFELINE_HEAD.h + UML_SD_EVENT_STEP;
119
+ /** How far outside the spines it covers a combined fragment is drawn. */
120
+ export const UML_SD_FRAGMENT_PADDING = 20;
121
+ /**
122
+ * A guard as the CANVAS spells it — §17.6.4's condition in the brackets the
123
+ * notation prints it in.
124
+ *
125
+ * The mirror of `model.ts`'s {@link umlGuardText}, and the only place the three
126
+ * readers add them: every parser produces a bare condition (`x > 0`), the board
127
+ * holds the words the author would have typed (`[x > 0]`), and the reader back
128
+ * off the board takes the pair off again. An unguarded operand stays unguarded —
129
+ * a lone `[]` would be a condition nobody wrote.
130
+ */
131
+ function umlGuardLabel(guard) {
132
+ const condition = umlGuardText(guard);
133
+ return condition ? `[${condition}]` : '';
134
+ }
135
+ /** The height of the nth slot of an invented sequence layout. */
136
+ export function umlSequenceSlot(index) {
137
+ return UML_SD_FIRST_EVENT + index * UML_SD_EVENT_STEP;
138
+ }
139
+ /**
140
+ * The COLUMN box of the nth lifeline — the narrow tall element, not the head.
141
+ *
142
+ * `UmlNodeElementModel` makes a lifeline the spine and paints the head across
143
+ * the top of it, so what is laid out here is the spine: its centre is the line
144
+ * the conversation is measured against, and the head overflows it on both sides
145
+ * by the same amount.
146
+ */
147
+ export function umlSequenceColumn(index, height) {
148
+ const { w } = UML_NODE_BOX.lifeline;
149
+ return {
150
+ x: index * UML_SD_COLUMN_GAP - w / 2,
151
+ y: 0,
152
+ w,
153
+ h: Math.max(UML_NODE_BOX.lifeline.h, height),
154
+ };
155
+ }
156
+ /** The bar §17.2.4 draws on a spine between two occurrences. */
157
+ export function umlSequenceExecution(column, y0, y1) {
158
+ const { w } = UML_NODE_BOX.execution;
159
+ // At least ONE slot tall: a bar that opened and closed on the same event is
160
+ // still a bar the author drew, and a zero-height rectangle is invisible and
161
+ // unclickable.
162
+ return {
163
+ x: column.x + column.w / 2 - w / 2,
164
+ y: y0,
165
+ w,
166
+ h: Math.max(UML_SD_EVENT_STEP, y1 - y0),
167
+ };
168
+ }
169
+ /** The cross §17.2.4 ends a lifeline with, centred on the spine. */
170
+ export function umlSequenceDestruction(column, y) {
171
+ const { w, h } = UML_NODE_BOX.destruction;
172
+ return { x: column.x + column.w / 2 - w / 2, y: y - h / 2, w, h };
173
+ }
174
+ /**
175
+ * The rectangle §17.6.4 draws round the spines a fragment covers.
176
+ *
177
+ * {@link UML_SD_FRAGMENT_PADDING} outside the outermost two, which is what makes
178
+ * the box cover the spines rather than end on them — `model.ts` reads coverage
179
+ * as "the spine runs through the box", and a rectangle whose edge lands exactly
180
+ * on a spine is a coin toss.
181
+ */
182
+ export function umlSequenceFragment(columns, y0, y1) {
183
+ if (columns.length === 0) {
184
+ return { x: 0, y: y0, w: UML_FRAGMENT_BOX.w, h: Math.max(1, y1 - y0) };
185
+ }
186
+ const centres = columns.map(column => column.x + column.w / 2);
187
+ const left = Math.min(...centres) - UML_SD_FRAGMENT_PADDING;
188
+ const right = Math.max(...centres) + UML_SD_FRAGMENT_PADDING;
189
+ return { x: left, y: y0, w: right - left, h: Math.max(1, y1 - y0) };
190
+ }
191
+ /**
192
+ * A layered layout for a diagram that carried no geometry (D4).
193
+ *
194
+ * ## Rows by generalization depth
195
+ *
196
+ * Because that is the one ordering a class diagram has of its own: §9.2.4 draws
197
+ * the general classifier above the specific one, so a hierarchy read top to
198
+ * bottom is a hierarchy drawn top to bottom, and a sheet laid out this way is
199
+ * legible the moment it opens instead of being a grid somebody has to untangle.
200
+ * A classifier that specializes nothing is a root and sits in the first row;
201
+ * everything else sits one row below the deepest thing it specializes.
202
+ *
203
+ * Containers are laid out RECURSIVELY and sized to fit, because containment on
204
+ * this canvas IS geometry: a class is in a package when it is drawn inside it
205
+ * (`model.ts`, `plantuml.ts`, `xmi.ts` all read it that way), so a layout that
206
+ * placed a package's contents outside it would lose the nesting the file stated.
207
+ *
208
+ * Deterministic by construction: document order within a row, document order
209
+ * inside a container, and no measurement that depends on anything but the sizes
210
+ * handed in.
211
+ */
212
+ export function umlInventLayout(nodes, relations = []) {
213
+ const byId = new Map(nodes.map(node => [node.id, node]));
214
+ const contained = new Set();
215
+ for (const node of nodes) {
216
+ for (const child of node.children ?? []) {
217
+ if (byId.has(child) && child !== node.id)
218
+ contained.add(child);
219
+ }
220
+ }
221
+ /** The children a node really has — unknown ids and cycles dropped. */
222
+ const childrenOf = (node) => (node.children ?? [])
223
+ .filter(child => byId.has(child) && child !== node.id)
224
+ .map(child => byId.get(child));
225
+ /** How wide and deep a node is once everything inside it is measured. */
226
+ const measured = new Map();
227
+ const measure = (node) => {
228
+ const known = measured.get(node.id);
229
+ if (known)
230
+ return known;
231
+ // Written BEFORE the recursion so a container that somehow contains itself
232
+ // measures as its own box rather than looping forever.
233
+ measured.set(node.id, node.size);
234
+ const children = childrenOf(node);
235
+ if (children.length === 0)
236
+ return node.size;
237
+ const inner = children.map(measure);
238
+ const columns = Math.ceil(Math.sqrt(children.length));
239
+ const rows = Math.ceil(children.length / columns);
240
+ const cellW = Math.max(...inner.map(box => box.w));
241
+ const cellH = Math.max(...inner.map(box => box.h));
242
+ const size = {
243
+ w: Math.max(node.size.w, UML_IMPORT_CONTAINER_INSET * 2 +
244
+ columns * cellW +
245
+ (columns - 1) * UML_IMPORT_GUTTER),
246
+ h: Math.max(node.size.h, UML_IMPORT_CONTAINER_HEADER +
247
+ UML_IMPORT_CONTAINER_INSET +
248
+ rows * cellH +
249
+ (rows - 1) * UML_IMPORT_GUTTER),
250
+ };
251
+ measured.set(node.id, size);
252
+ return size;
253
+ };
254
+ for (const node of nodes)
255
+ measure(node);
256
+ /** How many generalizations deep this artefact is. */
257
+ const depth = new Map(nodes.map(node => [node.id, 0]));
258
+ const rises = relations.filter(relation => relation.kind === 'generalization' || relation.kind === 'realization');
259
+ // One pass per node is enough for any acyclic hierarchy, and the cap is what
260
+ // makes a cyclic one terminate instead of hanging the import.
261
+ for (let round = 0; round < nodes.length; round++) {
262
+ let moved = false;
263
+ for (const relation of rises) {
264
+ // The role table makes the SOURCE the specific classifier: it sits one
265
+ // row below the general one it points at.
266
+ const above = depth.get(relation.targetId);
267
+ const below = depth.get(relation.sourceId);
268
+ if (above === undefined || below === undefined)
269
+ continue;
270
+ if (below <= above) {
271
+ depth.set(relation.sourceId, above + 1);
272
+ moved = true;
273
+ }
274
+ }
275
+ if (!moved)
276
+ break;
277
+ }
278
+ const boxes = new Map();
279
+ const place = (node, x, y) => {
280
+ const size = measured.get(node.id) ?? node.size;
281
+ boxes.set(node.id, { x, y, w: size.w, h: size.h });
282
+ const children = childrenOf(node);
283
+ if (children.length === 0)
284
+ return;
285
+ const columns = Math.ceil(Math.sqrt(children.length));
286
+ const cellW = Math.max(...children.map(child => (measured.get(child.id) ?? child.size).w));
287
+ const cellH = Math.max(...children.map(child => (measured.get(child.id) ?? child.size).h));
288
+ children.forEach((child, index) => {
289
+ const column = index % columns;
290
+ const row = Math.floor(index / columns);
291
+ place(child, x + UML_IMPORT_CONTAINER_INSET + column * (cellW + UML_IMPORT_GUTTER), y + UML_IMPORT_CONTAINER_HEADER + row * (cellH + UML_IMPORT_GUTTER));
292
+ });
293
+ };
294
+ const top = nodes.filter(node => !contained.has(node.id));
295
+ const levels = [...new Set(top.map(node => depth.get(node.id) ?? 0))].sort((a, b) => a - b);
296
+ let y = 0;
297
+ for (const level of levels) {
298
+ const row = top.filter(node => (depth.get(node.id) ?? 0) === level);
299
+ let x = 0;
300
+ let tallest = UML_IMPORT_SLOT.h;
301
+ for (const node of row) {
302
+ const size = measured.get(node.id) ?? node.size;
303
+ const cellW = Math.max(UML_IMPORT_SLOT.w, size.w);
304
+ // Centred in its slot, so a 16-unit port and a 220-unit cube in the same
305
+ // row still line up on one axis.
306
+ place(node, x + (cellW - size.w) / 2, y);
307
+ x += cellW + UML_IMPORT_GUTTER;
308
+ tallest = Math.max(tallest, size.h);
309
+ }
310
+ y += tallest + UML_IMPORT_GUTTER;
311
+ }
312
+ return boxes;
313
+ }
314
+ /* ── The words a tier carries ─────────────────────────────────────────── */
315
+ /**
316
+ * A name compartment, written back the way the grammar reads it.
317
+ *
318
+ * The exact inverse of `stereotypesOf`: the Annex C keywords on their own line
319
+ * above the name — which is where §9.2.4 draws them and what the stencils seed —
320
+ * and §9.2.4's `{abstract}` modifier after it, which is the alternative that
321
+ * same clause allows a canvas with no italic name compartment. Read back, the
322
+ * three facts come out exactly as they went in.
323
+ */
324
+ export function umlNameTierText(node) {
325
+ const lines = [];
326
+ if (node.keywords.length > 0) {
327
+ lines.push(node.keywords.map(guillemets).join(' '));
328
+ }
329
+ const named = node.isAbstract ? `${node.name} {abstract}`.trim() : node.name;
330
+ if (named)
331
+ lines.push(named);
332
+ return lines.join('\n');
333
+ }
334
+ /** An instance is labelled `name : Type` — §11.6.4's underlined form. */
335
+ function classifierNameText(classifier) {
336
+ if (classifier.kind !== 'object' || !classifier.instanceOf) {
337
+ return umlNameTierText(classifier);
338
+ }
339
+ // Composed into a local exactly as `umlLifelineHeadText` composes a
340
+ // lifeline's head, and for the same reason: both halves are the FILE's own
341
+ // words and the colon between them is §11.6.4's notation, so there is no
342
+ // sentence here for a catalogue to translate.
343
+ const stated = `${classifier.name} : ${classifier.instanceOf}`;
344
+ return umlNameTierText({ ...classifier, name: stated });
345
+ }
346
+ /** A state's second compartment — §14.2.4.4's internal activities, in order. */
347
+ function stateBodyText(state) {
348
+ return [
349
+ ...state.entry.map(body => `entry / ${body}`),
350
+ ...state.doActivity.map(body => `do / ${body}`),
351
+ ...state.exit.map(body => `exit / ${body}`),
352
+ ...state.lines,
353
+ ].join('\n');
354
+ }
355
+ /* ── What a draft is ──────────────────────────────────────────────────── */
356
+ /**
357
+ * The glyph kinds whose one tier is a `uml:name` rather than a `uml:label`.
358
+ *
359
+ * A transcription of `actions.ts`'s own `NAMED_GLYPHS`, kept here rather than
360
+ * imported because that module reaches for the editor (`BlockStdScope`, the gfx
361
+ * controller, the surface) and a reader must not: P3 is what lets labre-mcp call
362
+ * this file with no DI in sight. `import.unit.spec.ts` re-runs the creation
363
+ * commands against a recording surface and compares the tier roles, so the two
364
+ * statements cannot drift in silence.
365
+ */
366
+ const NAMED_GLYPH_KINDS = new Set([
367
+ 'package',
368
+ 'note',
369
+ 'node',
370
+ 'device',
371
+ 'execution-environment',
372
+ ]);
373
+ /** The four background frames, and the box each is created at. */
374
+ const FRAME_BOX = {
375
+ umlSubject: UML_SUBJECT_BOX,
376
+ umlPartition: UML_PARTITION_BOX,
377
+ umlRegion: UML_REGION_BOX,
378
+ umlFragment: UML_FRAGMENT_BOX,
379
+ };
380
+ /** The role each background frame carries — one table, read once below. */
381
+ const FRAME_ROLE = {
382
+ umlSubject: UML_ROLE.subject,
383
+ umlPartition: UML_ROLE.partition,
384
+ umlRegion: UML_ROLE.region,
385
+ umlFragment: UML_ROLE.fragment,
386
+ };
387
+ /** Every artefact one model draws, in the order a reader should meet them. */
388
+ function draftsOf(model) {
389
+ const drafts = [];
390
+ const node = (record, kind, tiers) => {
391
+ drafts.push({
392
+ sourceId: record.id,
393
+ element: 'umlNode',
394
+ kind,
395
+ tiers,
396
+ size: UML_NODE_BOX[kind],
397
+ ...(record.bounds ? { bounds: record.bounds } : {}),
398
+ });
399
+ };
400
+ const frame = (record, element, orientation) => {
401
+ drafts.push({
402
+ sourceId: record.id,
403
+ element,
404
+ name: record.name,
405
+ ...(orientation ? { orientation } : {}),
406
+ size: FRAME_BOX[element],
407
+ ...(record.bounds ? { bounds: record.bounds } : {}),
408
+ });
409
+ };
410
+ for (const pkg of model.packages) {
411
+ node(pkg, 'package', { name: umlNameTierText(pkg) });
412
+ }
413
+ for (const classifier of model.classifiers) {
414
+ node(classifier, classifier.kind, {
415
+ name: classifierNameText(classifier),
416
+ attributes: classifier.lines.attributes.join('\n'),
417
+ operations: classifier.lines.operations.join('\n'),
418
+ });
419
+ }
420
+ for (const subject of model.subjects)
421
+ frame(subject, 'umlSubject');
422
+ for (const actor of model.actors) {
423
+ node(actor, 'actor', { name: umlNameTierText(actor) });
424
+ }
425
+ for (const useCase of model.useCases) {
426
+ node(useCase, 'use-case', { name: umlNameTierText(useCase) });
427
+ }
428
+ for (const component of model.components) {
429
+ node(component, 'component', {
430
+ name: umlNameTierText(component),
431
+ // §11.6.4's body compartment: a component's parts, which this IR does not
432
+ // model. Emitted EMPTY rather than skipped — the layout rules the
433
+ // separator off whatever is written in it, and a component with three
434
+ // elements where the toolbox draws four would not be the same artefact.
435
+ attributes: '',
436
+ });
437
+ // ── The contracts, as the two marks §10.4.4 draws them ────────────
438
+ //
439
+ // `provided` and `required` are NAMES on the IR rather than references,
440
+ // because on this canvas the glyph IS the interface: `model.ts` reads a
441
+ // component's contracts by measuring which lollipops and sockets touch it.
442
+ // So a materializer that wrote the names nowhere would import a component
443
+ // diagram that exports as a row of bare boxes — the file said which
444
+ // contracts the component offers, and the board would not.
445
+ //
446
+ // The ball on the LEFT and the socket on the RIGHT, stacked: §10.4.4 draws
447
+ // each on a stub off the classifier's boundary and gives no side, and one
448
+ // side per direction is what makes a wired-up sheet readable at a glance.
449
+ for (const [names, kind, side] of [
450
+ [component.provided, 'provided-interface', 'left'],
451
+ [component.required, 'required-interface', 'right'],
452
+ ]) {
453
+ names.forEach((name, index) => {
454
+ drafts.push({
455
+ // Minted, not the file's: an interface NAME is all the IR carries,
456
+ // and `payload()` writes nothing for an empty source id — which is
457
+ // right, because no element of the file became this mark.
458
+ sourceId: '',
459
+ element: 'umlNode',
460
+ kind,
461
+ tiers: { name },
462
+ size: UML_NODE_BOX[kind],
463
+ connectable: false,
464
+ anchor: {
465
+ ownerId: component.id,
466
+ side,
467
+ index,
468
+ count: names.length,
469
+ },
470
+ });
471
+ });
472
+ }
473
+ }
474
+ // The ports of one box, spread along its top edge rather than stacked on one
475
+ // corner: §11.3.4 draws several ports on one boundary and a component with
476
+ // three of them in the same 16-unit square is a drawing nobody can click.
477
+ const portsOf = new Map();
478
+ for (const port of model.ports) {
479
+ if (port.ownerId) {
480
+ portsOf.set(port.ownerId, (portsOf.get(port.ownerId) ?? 0) + 1);
481
+ }
482
+ }
483
+ const portSeat = new Map();
484
+ for (const port of model.ports) {
485
+ const owner = port.ownerId;
486
+ const seat = owner ? (portSeat.get(owner) ?? 0) : 0;
487
+ if (owner)
488
+ portSeat.set(owner, seat + 1);
489
+ drafts.push({
490
+ sourceId: port.id,
491
+ element: 'umlNode',
492
+ kind: 'port',
493
+ tiers: { name: umlNameTierText(port) },
494
+ size: UML_NODE_BOX.port,
495
+ ...(port.bounds ? { bounds: port.bounds } : {}),
496
+ // §11.3.4: "overlapping the boundary of the rectangle symbol denoting
497
+ // that EncapsulatedClassifier". The square has to STRADDLE the border, or
498
+ // the export — which reads ownership as a gap of at most
499
+ // `UML_ATTACH_TOLERANCE` — gives the port back to nobody.
500
+ ...(owner
501
+ ? {
502
+ anchor: {
503
+ ownerId: owner,
504
+ side: 'top',
505
+ index: seat,
506
+ count: portsOf.get(owner) ?? 1,
507
+ },
508
+ }
509
+ : {}),
510
+ });
511
+ }
512
+ for (const artifact of model.artifacts) {
513
+ node(artifact, 'artifact', {
514
+ name: umlNameTierText(artifact),
515
+ attributes: '',
516
+ });
517
+ }
518
+ for (const cube of model.nodes) {
519
+ node(cube, cube.kind, { name: umlNameTierText(cube) });
520
+ }
521
+ for (const activity of model.activities) {
522
+ for (const partition of activity.partitions) {
523
+ frame(partition, 'umlPartition', partition.orientation);
524
+ }
525
+ for (const step of activity.nodes) {
526
+ node(step, step.kind, { name: umlNameTierText(step) });
527
+ }
528
+ }
529
+ for (const machine of model.stateMachines) {
530
+ for (const region of machine.regions)
531
+ frame(region, 'umlRegion');
532
+ for (const state of machine.states) {
533
+ node(state, 'state', {
534
+ name: umlNameTierText(state),
535
+ attributes: stateBodyText(state),
536
+ });
537
+ }
538
+ for (const final of machine.finalStates) {
539
+ node(final, 'final-state', { name: umlNameTierText(final) });
540
+ }
541
+ for (const pseudo of machine.pseudostates) {
542
+ node(pseudo, pseudo.kind, { name: umlNameTierText(pseudo) });
543
+ }
544
+ }
545
+ // §17 — the sequence sheet. The FRAGMENTS come first, so they are written
546
+ // under the conversation they qualify: a combined fragment is transparent and
547
+ // drawn over the lifelines, and painting order is what decides which of two
548
+ // overlapping backgrounds a click lands on.
549
+ for (const interaction of model.interactions) {
550
+ for (const fragment of interaction.fragments) {
551
+ const split = fragment.operands.length > 1;
552
+ drafts.push({
553
+ sourceId: fragment.id,
554
+ element: 'umlFragment',
555
+ // §17.7.4's `ref` names an INTERACTION and not a condition, so it is
556
+ // written as it is read. Every other operator's `name` is the guard of
557
+ // the fragment's one operand, and the canvas spells a guard the way
558
+ // §17.6.4 prints it — in brackets (`umlGuardText`).
559
+ //
560
+ // A SPLIT fragment writes no `name` at all: its guards live in
561
+ // `operands` and the declared label is anchored in the very corner the
562
+ // first band's is (`background.ts`), so writing both would paint the
563
+ // condition twice.
564
+ name: fragment.operator === 'ref'
565
+ ? fragment.name
566
+ : split
567
+ ? ''
568
+ : umlGuardLabel(fragment.operands[0]?.guard ?? fragment.name),
569
+ operator: fragment.operator,
570
+ // ONE operand is the model's `undefined` — see {@link UmlDraft.operands}.
571
+ // The weights are the bands' own heights, which is what keeps a
572
+ // re-export's operand boundaries where the file drew them.
573
+ ...(split
574
+ ? {
575
+ operands: fragment.operands.map((operand, index) => ({
576
+ id: `${fragment.id}-operand-${index + 1}`,
577
+ ...(operand.guard
578
+ ? { name: umlGuardLabel(operand.guard) }
579
+ : {}),
580
+ size: Math.max(1, operand.y1 - operand.y0),
581
+ })),
582
+ }
583
+ : {}),
584
+ size: FRAME_BOX.umlFragment,
585
+ ...(fragment.bounds ? { bounds: fragment.bounds } : {}),
586
+ });
587
+ }
588
+ for (const lifeline of interaction.lifelines) {
589
+ node(lifeline, 'lifeline', { name: umlLifelineHeadText(lifeline) });
590
+ }
591
+ // The bar and the cross carry no words (`UML_UNLABELLED_KINDS`), so each is
592
+ // a shape and nothing else — no tier, and therefore no group.
593
+ for (const execution of interaction.executions) {
594
+ node(execution, 'execution', undefined);
595
+ }
596
+ for (const destruction of interaction.destructions) {
597
+ node(destruction, 'destruction', undefined);
598
+ }
599
+ }
600
+ for (const note of model.notes) {
601
+ node(note, 'note', { name: note.body });
602
+ }
603
+ return drafts;
604
+ }
605
+ /**
606
+ * §17.3.4's `<name> [: <Type>]` — the one compartment a lifeline's head holds.
607
+ *
608
+ * The same line `keywords.ts` seeds a fresh head with and `parseLifelineIdent`
609
+ * reads back, so a lifeline imported from a file and one drawn from the toolbox
610
+ * hold the same text in the same tier.
611
+ */
612
+ function umlLifelineHeadText(lifeline) {
613
+ const stated = lifeline.type
614
+ ? `${lifeline.name} : ${lifeline.type}`
615
+ : lifeline.name;
616
+ return umlNameTierText({ ...lifeline, name: stated });
617
+ }
618
+ /* ── What is drawn inside what ────────────────────────────────────────── */
619
+ /**
620
+ * Which container each artefact is drawn inside — the IR's own statements,
621
+ * plus whatever the caller read out of the file.
622
+ *
623
+ * Containment on this canvas IS geometry: `model.ts`, `plantuml.ts` and
624
+ * `xmi.ts` all decide "is this class in that package" by asking whether the
625
+ * centre is inside the box. A file that states the nesting some other way — an
626
+ * XMI `packagedElement`, an `ownedUseCase` — therefore has to have it turned
627
+ * back into a POSITION, and the invented layout is the only thing that can.
628
+ * Anything laid out flat loses the statement on the first export.
629
+ *
630
+ * Three of the four readings are on the IR already and are taken from it rather
631
+ * than asked of the caller: a partition lists the nodes drawn in it
632
+ * ({@link UmlPartition.nodeIds}), a vertex names its composite state
633
+ * ({@link UmlState.regionId}) and a region names the one it is nested in
634
+ * ({@link UmlRegion.parentId}). The fourth — a package's members, a subject's
635
+ * cases — has no slot in the IR, which is why {@link UmlLayoutHints.containment}
636
+ * exists.
637
+ */
638
+ function containmentOf(model, hint) {
639
+ const parent = new Map();
640
+ for (const [child, container] of Object.entries(hint ?? {})) {
641
+ if (child && container && child !== container)
642
+ parent.set(child, container);
643
+ }
644
+ for (const activity of model.activities) {
645
+ for (const lane of activity.partitions) {
646
+ for (const nodeId of lane.nodeIds)
647
+ parent.set(nodeId, lane.id);
648
+ }
649
+ }
650
+ for (const machine of model.stateMachines) {
651
+ for (const region of machine.regions) {
652
+ if (region.parentId)
653
+ parent.set(region.id, region.parentId);
654
+ }
655
+ for (const vertex of [
656
+ ...machine.states,
657
+ ...machine.finalStates,
658
+ ...machine.pseudostates,
659
+ ]) {
660
+ if (vertex.regionId)
661
+ parent.set(vertex.id, vertex.regionId);
662
+ }
663
+ }
664
+ return parent;
665
+ }
666
+ /* ── The edges a model draws ──────────────────────────────────────────── */
667
+ /**
668
+ * The relationship kinds a BEHAVIOUR sheet is wired with — the three flows, and
669
+ * phase 3's five messages.
670
+ *
671
+ * All eight are in the set for one reason: each is read off the canvas into
672
+ * BOTH `relations` and the behaviour that owns it, and is read out of a file
673
+ * into the behaviour alone. The dedupe below is what lets {@link umlDrawnEdges}
674
+ * walk both without drawing anything twice.
675
+ */
676
+ const BEHAVIOUR_KINDS = new Set([
677
+ 'control-flow',
678
+ 'object-flow',
679
+ 'transition',
680
+ 'message-sync',
681
+ 'message-async',
682
+ 'message-reply',
683
+ 'message-create',
684
+ 'message-delete',
685
+ ]);
686
+ /**
687
+ * Every line one model draws — `relations`, plus the behaviour edges that live
688
+ * on the Activity and the StateMachine.
689
+ *
690
+ * ## Why there are two places to look, and neither can be dropped
691
+ *
692
+ * `umlModelFrom` reads a canvas and fills BOTH: a transition is a relation (one
693
+ * pass over the connectors resolves every end) and it is projected onto the
694
+ * machine, where the label grammar is parsed. The PlantUML reader mirrors that.
695
+ * The XMI reader cannot: `<edge>` and `<transition>` are children of the
696
+ * Activity and the StateMachine in the file, so it fills
697
+ * {@link UmlActivity.edges} and {@link UmlStateMachine.transitions} and leaves
698
+ * `relations` for the structural arrows. A materializer that walked only
699
+ * `relations` imported every XMI activity and every state machine with ZERO
700
+ * arrows on it — the defect this function exists to close.
701
+ *
702
+ * So: walk both, and DEDUPE, because a model may legitimately hold a line in
703
+ * each. The dedupe is a count per (kind, source, target) rather than a set, so
704
+ * two parallel transitions between one pair stay two — `relations` claims the
705
+ * first, the machine's second one is drawn, and nothing is drawn twice.
706
+ *
707
+ * The labels are printed with `grammar.ts`'s own printers, which is what the
708
+ * PlantUML writer uses: a guard imported from XMI and one exported to a `.puml`
709
+ * are then spelled identically, and `parseTransition` reads back exactly what
710
+ * was written.
711
+ */
712
+ export function umlDrawnEdges(model) {
713
+ const spelled = (end) => end ? formatEndLabel(end) : '';
714
+ const drawn = model.relations.map(relation => {
715
+ const sourceEnd = spelled(relation.sourceEnd);
716
+ const targetEnd = spelled(relation.targetEnd);
717
+ return {
718
+ kind: relation.kind,
719
+ sourceId: relation.sourceId,
720
+ targetId: relation.targetId,
721
+ ...(relation.label ? { label: relation.label } : {}),
722
+ ...(sourceEnd ? { sourceEnd } : {}),
723
+ ...(targetEnd ? { targetEnd } : {}),
724
+ };
725
+ });
726
+ const key = (kind, source, target) => `${kind}${source}${target}`;
727
+ const claimed = new Map();
728
+ for (const relation of model.relations) {
729
+ if (!BEHAVIOUR_KINDS.has(relation.kind))
730
+ continue;
731
+ const at = key(relation.kind, relation.sourceId, relation.targetId);
732
+ claimed.set(at, (claimed.get(at) ?? 0) + 1);
733
+ }
734
+ /** Has `relations` already drawn this one? Consumes the claim if so. */
735
+ const alreadyDrawn = (at) => {
736
+ const outstanding = claimed.get(at) ?? 0;
737
+ if (outstanding === 0)
738
+ return false;
739
+ claimed.set(at, outstanding - 1);
740
+ return true;
741
+ };
742
+ for (const activity of model.activities) {
743
+ for (const edge of activity.edges) {
744
+ if (alreadyDrawn(key(edge.kind, edge.sourceId, edge.targetId)))
745
+ continue;
746
+ const label = formatActivityEdgeLabel(edge);
747
+ drawn.push({
748
+ kind: edge.kind,
749
+ sourceId: edge.sourceId,
750
+ targetId: edge.targetId,
751
+ ...(label ? { label } : {}),
752
+ });
753
+ }
754
+ }
755
+ for (const machine of model.stateMachines) {
756
+ for (const transition of machine.transitions) {
757
+ const at = key('transition', transition.sourceId, transition.targetId);
758
+ if (alreadyDrawn(at))
759
+ continue;
760
+ const label = formatTransitionLabel(transition);
761
+ drawn.push({
762
+ kind: 'transition',
763
+ sourceId: transition.sourceId,
764
+ targetId: transition.targetId,
765
+ ...(label ? { label } : {}),
766
+ });
767
+ }
768
+ }
769
+ for (const interaction of model.interactions) {
770
+ for (const message of interaction.messages) {
771
+ const at = key(message.kind, message.sourceId, message.targetId);
772
+ // A message read off the CANVAS is in `relations` as well, exactly as a
773
+ // transition is; one read out of a file is on the interaction alone. The
774
+ // claim is consumed either way, so the arrow is drawn once.
775
+ const claimedAlready = alreadyDrawn(at);
776
+ const already = claimedAlready
777
+ ? drawn.find(entry => entry.kind === message.kind &&
778
+ entry.sourceId === message.sourceId &&
779
+ entry.targetId === message.targetId &&
780
+ entry.y === undefined)
781
+ : undefined;
782
+ if (already) {
783
+ // The relation pass drew it with no height. The height is the one thing
784
+ // a message has and a relation does not, so it is filled in here rather
785
+ // than the arrow being drawn a second time.
786
+ already.y = message.y;
787
+ continue;
788
+ }
789
+ if (claimedAlready)
790
+ continue;
791
+ drawn.push({
792
+ kind: message.kind,
793
+ sourceId: message.sourceId,
794
+ targetId: message.targetId,
795
+ ...(message.label ? { label: message.label } : {}),
796
+ y: message.y,
797
+ });
798
+ }
799
+ }
800
+ return drawn;
801
+ }
802
+ /**
803
+ * Where a message's two ends attach — §17.4.4's horizontal line at the height it
804
+ * happens.
805
+ *
806
+ * On the FACING sides of the two columns, at the same relative height on each:
807
+ * the arrow leaves the right edge of the participant on the left and lands on
808
+ * the left edge of the one on the right, which is what every tool draws and what
809
+ * keeps the line horizontal rather than cutting diagonally through a head.
810
+ *
811
+ * A self-message — an end that resolves to the same column, or one whose box is
812
+ * unknown — leaves and lands on the same side, which is the loop §17.4.4 draws
813
+ * back onto the sender's own lifeline.
814
+ *
815
+ * `y` and the two boxes are in ONE space — the sheet's, after the drawing has
816
+ * been translated onto it. The caller does the translating; a height left in
817
+ * the source's own space would divide against a column that had already moved.
818
+ */
819
+ function messageAnchors(y, from, to) {
820
+ /** Where on this box's own height the arrow sits, clamped to it. */
821
+ const level = (box) => {
822
+ if (!box || !(box.h > 0))
823
+ return 0.5;
824
+ return Math.min(1, Math.max(0, (y - box.y) / box.h));
825
+ };
826
+ const leftward = from && to ? from.x + from.w / 2 > to.x + to.w / 2 : false;
827
+ const sameColumn = !from || !to || Math.abs(from.x + from.w / 2 - (to.x + to.w / 2)) < 1;
828
+ if (sameColumn) {
829
+ return { source: [1, level(from)], target: [1, level(to)] };
830
+ }
831
+ return leftward
832
+ ? { source: [0, level(from)], target: [1, level(to)] }
833
+ : { source: [1, level(from)], target: [0, level(to)] };
834
+ }
835
+ /** The provisional names this reader mints. See the module docblock. */
836
+ const importId = (n) => `uml-import-${n}`;
837
+ /**
838
+ * The MATERIALIZER's own two remarks, as `[key, English]` pairs — raised by
839
+ * {@link umlElementsFromModel} whichever reader produced the model, which is
840
+ * why they are declared here rather than in any one reader's table.
841
+ *
842
+ * Same shape and same reasons as `UML_PLANTUML_REMARKS`
843
+ * (`plantuml-import.ts`, where the contract is written out): one entry per
844
+ * SHAPE of sentence, the `{{count}}` / `{{heading}}` holes filled at the call
845
+ * site by `InterchangeNote.messageParams`, the English kept here as the
846
+ * fallback so a report drawn with no host catalogue reads exactly as before.
847
+ */
848
+ export const UML_LAYOUT_REMARKS = {
849
+ allInvented: [
850
+ 'com.labre.uml.import.layout.all-invented',
851
+ 'This source carries no coordinates, so the {{count}} artefacts of "{{heading}}" were laid out by Labre, in rows by generalization depth. The positions are ours, not the file\'s.',
852
+ ],
853
+ someInvented: [
854
+ 'com.labre.uml.import.layout.some-invented',
855
+ '{{count}} of the artefacts of "{{heading}}" were drawn nowhere in the source, so Labre laid them out below the rest. Their positions are ours, not the file\'s.',
856
+ ],
857
+ };
858
+ /**
859
+ * Every model as the elements of a board — one `umlDiagram` frame per model,
860
+ * the artefacts drawn on it, and the connectors between them.
861
+ *
862
+ * The frames are stacked down the canvas rather than overlaid, because a UML
863
+ * document with four diagrams in it is four sheets (ADR 0017) and two frames
864
+ * sharing a box is two diagrams neither of which can be attributed.
865
+ */
866
+ export function umlElementsFromModel(models, options) {
867
+ const { formatId, layout } = options;
868
+ const origin = options.origin ?? { x: 0, y: 0 };
869
+ const elements = [];
870
+ const notes = [];
871
+ let minted = 0;
872
+ const mint = () => importId(++minted);
873
+ let frameTop = origin.y;
874
+ for (const model of models) {
875
+ const drafts = draftsOf(model);
876
+ const invented = placeDrafts(drafts, model, layout);
877
+ if (invented > 0) {
878
+ notes.push({
879
+ kind: 'invented-layout',
880
+ sourceId: model.diagram.id,
881
+ // Two SHAPES of sentence, so two keys — never one key whose wording
882
+ // depends on a branch the host cannot see.
883
+ messageKey: invented === drafts.length
884
+ ? UML_LAYOUT_REMARKS.allInvented[0]
885
+ : UML_LAYOUT_REMARKS.someInvented[0],
886
+ message: invented === drafts.length
887
+ ? `This source carries no coordinates, so the ${invented} artefacts of "${model.diagram.heading}" were laid out by Labre, in rows by generalization depth. The positions are ours, not the file's.`
888
+ : `${invented} of the artefacts of "${model.diagram.heading}" were drawn nowhere in the source, so Labre laid them out below the rest. Their positions are ours, not the file's.`,
889
+ messageParams: {
890
+ count: invented,
891
+ heading: model.diagram.heading,
892
+ },
893
+ });
894
+ }
895
+ // The frame is the union of what is drawn on it, which is the only size
896
+ // that keeps every artefact ATTRIBUTABLE: `umlModelFrom` reads membership as
897
+ // a centre inside the frame's plot, so a sheet smaller than its own drawing
898
+ // would export less than it shows.
899
+ const drawn = drafts
900
+ .map(draft => draft.bounds)
901
+ .filter((box) => box !== undefined);
902
+ const left = drawn.length > 0 ? Math.min(...drawn.map(box => box.x)) : 0;
903
+ const top = drawn.length > 0 ? Math.min(...drawn.map(box => box.y)) : 0;
904
+ const right = drawn.length > 0 ? Math.max(...drawn.map(box => box.x + box.w)) : 0;
905
+ const bottom = drawn.length > 0 ? Math.max(...drawn.map(box => box.y + box.h)) : 0;
906
+ const frame = {
907
+ x: origin.x,
908
+ y: frameTop,
909
+ w: Math.max(UML_DIAGRAM_BOX.w, right - left + UML_IMPORT_MARGIN * 2),
910
+ h: Math.max(UML_DIAGRAM_BOX.h, bottom -
911
+ top +
912
+ UML_FRAME_BAND_HEIGHT +
913
+ UML_IMPORT_CONTAINER_INSET +
914
+ UML_IMPORT_MARGIN),
915
+ };
916
+ // What moves the whole drawing onto the sheet: the plot origin is under the
917
+ // heading band, never over it (`umlSheetOf`).
918
+ const dx = frame.x + UML_IMPORT_MARGIN - left;
919
+ const dy = frame.y + UML_FRAME_BAND_HEIGHT + UML_IMPORT_CONTAINER_INSET - top;
920
+ elements.push({
921
+ type: 'umlDiagram',
922
+ id: mint(),
923
+ role: UML_ROLE.diagram,
924
+ name: model.diagram.name,
925
+ kind: model.diagram.kind,
926
+ xywh: new Bound(frame.x, frame.y, frame.w, frame.h).serialize(),
927
+ ...payload(formatId, model.diagram.id),
928
+ });
929
+ /** The provisional name a relation's end resolves to. */
930
+ const shapeOf = new Map();
931
+ /** Where that shape ended up — what an end label's box is measured off. */
932
+ const boxOf = new Map();
933
+ for (const draft of drafts) {
934
+ const box = draft.bounds ?? { x: 0, y: 0, ...draft.size };
935
+ const placed = {
936
+ x: box.x + dx,
937
+ y: box.y + dy,
938
+ w: box.w,
939
+ h: box.h,
940
+ };
941
+ const xywh = new Bound(placed.x, placed.y, placed.w, placed.h).serialize();
942
+ if (draft.element !== 'umlNode') {
943
+ const id = mint();
944
+ if (draft.sourceId) {
945
+ shapeOf.set(draft.sourceId, id);
946
+ boxOf.set(draft.sourceId, placed);
947
+ }
948
+ elements.push({
949
+ type: draft.element,
950
+ id,
951
+ role: FRAME_ROLE[draft.element],
952
+ name: draft.name ?? '',
953
+ // The model defaults a band to vertical, and a creation that restated
954
+ // the default would be a second place for it to be changed.
955
+ ...(draft.orientation === 'horizontal'
956
+ ? { orientation: 'horizontal' }
957
+ : {}),
958
+ // §17.6.4's operator and its operand bands. `alt` is the model's own
959
+ // default and is written all the same: unlike an orientation, the
960
+ // operator IS the fragment, and a document that left it out would be
961
+ // one whose pentagon depends on a default nobody can see.
962
+ ...(draft.operator ? { operator: draft.operator } : {}),
963
+ ...(draft.operands ? { operands: draft.operands } : {}),
964
+ xywh,
965
+ ...payload(formatId, draft.sourceId),
966
+ });
967
+ continue;
968
+ }
969
+ const kind = draft.kind;
970
+ const shapeId = mint();
971
+ // An interface MARK answers for nothing: §10.4.4 draws it as the notation
972
+ // for a relationship rather than as an element, which is exactly why
973
+ // `model.ts` keeps it out of `artefactOf`. A connector must never resolve
974
+ // to a lollipop.
975
+ if (draft.sourceId && draft.connectable !== false) {
976
+ shapeOf.set(draft.sourceId, shapeId);
977
+ boxOf.set(draft.sourceId, placed);
978
+ }
979
+ elements.push({
980
+ ...umlNodeProps(kind, { xywh }),
981
+ id: shapeId,
982
+ ...payload(formatId, draft.sourceId),
983
+ });
984
+ const children = [shapeId];
985
+ const boxes = umlCompartmentBoxes(kind, placed.x, placed.y, placed.w, placed.h);
986
+ const tiers = draft.tiers ?? { name: '' };
987
+ // A mark the notation draws no words on is the SHAPE and nothing else —
988
+ // and therefore no group either, because a group of one element is a
989
+ // wrapper a user would have to descend through (`actions.ts`).
990
+ if (!UML_UNLABELLED_KINDS.has(kind)) {
991
+ const compartmented = boxes.attributes !== undefined;
992
+ const tierId = mint();
993
+ children.push(tierId);
994
+ elements.push({
995
+ ...umlTextProps(compartmented || NAMED_GLYPH_KINDS.has(kind)
996
+ ? UML_ROLE.name
997
+ : // A lifeline's head is `uml:lifeline-ident`, every other single
998
+ // word is `uml:label` — the creation path's own table, read
999
+ // rather than restated (`component.ts`).
1000
+ umlLabelRoleOf(kind), boxes.name, {
1001
+ fontSize: UML_NAME_FONT_SIZE,
1002
+ // A note holds a sentence rather than a title (Annex A), and a
1003
+ // centred paragraph is the one thing on a UML diagram that reads
1004
+ // as a poem.
1005
+ align: kind === 'note' ? TextAlign.Left : TextAlign.Center,
1006
+ // The classifier's name is the heading of a divided box; a
1007
+ // glyph's one word is not.
1008
+ ...(compartmented ? { bold: true } : {}),
1009
+ }),
1010
+ id: tierId,
1011
+ text: tiers.name,
1012
+ });
1013
+ for (const [role, box, words] of [
1014
+ [UML_ROLE.attributes, boxes.attributes, tiers.attributes],
1015
+ [UML_ROLE.operations, boxes.operations, tiers.operations],
1016
+ ]) {
1017
+ if (!box)
1018
+ continue;
1019
+ const bodyId = mint();
1020
+ children.push(bodyId);
1021
+ elements.push({
1022
+ ...umlTextProps(role, box, {
1023
+ fontSize: UML_BODY_FONT_SIZE,
1024
+ align: TextAlign.Left,
1025
+ }),
1026
+ id: bodyId,
1027
+ text: words ?? '',
1028
+ });
1029
+ }
1030
+ }
1031
+ if (children.length > 1) {
1032
+ // LAST, so it is written after everything it holds — and so the caller's
1033
+ // map already answers for every child by the time this row is created.
1034
+ elements.push({
1035
+ type: 'group',
1036
+ id: mint(),
1037
+ children: Object.fromEntries(children.map(child => [child, true])),
1038
+ });
1039
+ }
1040
+ }
1041
+ // `relations` AND the behaviour edges that live on the Activity and the
1042
+ // StateMachine — see {@link umlDrawnEdges} for why there are two places to
1043
+ // look and why walking one of them imported every XMI flow as a blank sheet.
1044
+ for (const edge of umlDrawnEdges(model)) {
1045
+ const source = shapeOf.get(edge.sourceId);
1046
+ const target = shapeOf.get(edge.targetId);
1047
+ // An end this sheet holds no artefact for: there is nothing to draw
1048
+ // between. The parser has already said so in a note of its own.
1049
+ if (!source || !target)
1050
+ continue;
1051
+ // §17.4.4: a message is drawn at the height it happens, on the facing
1052
+ // edges of the two columns. Everything else is anchored at the centre of
1053
+ // the two boxes, which is what a structural relationship means.
1054
+ //
1055
+ // TRANSLATED, by the very `dy` every box above was placed with: `boxOf`
1056
+ // holds the columns as they were PUT ON THE SHEET, and a height still in
1057
+ // the source's own space would be measured against them — putting every
1058
+ // message endpoint `dy` above the arrow the author is shown, and handing
1059
+ // a re-export a conversation in a different order.
1060
+ const anchors = edge.y === undefined
1061
+ ? undefined
1062
+ : messageAnchors(edge.y + dy, boxOf.get(edge.sourceId), boxOf.get(edge.targetId));
1063
+ elements.push({
1064
+ type: 'connector',
1065
+ id: mint(),
1066
+ role: UML_ROLE[edge.kind],
1067
+ // STRAIGHT, always: UML's lines state a relationship between two
1068
+ // classifiers, and the elbows of an orthogonal router would read as a
1069
+ // route through the diagram that nobody drew (`actions.ts`).
1070
+ mode: ConnectorMode.Straight,
1071
+ stroke: UML_INK,
1072
+ strokeWidth: UML_EDGE_WIDTH,
1073
+ ...UML_EDGE_STYLE[edge.kind],
1074
+ source: { id: source, position: anchors?.source ?? [0.5, 0.5] },
1075
+ target: { id: target, position: anchors?.target ?? [0.5, 0.5] },
1076
+ ...(edge.label ? { text: edge.label } : {}),
1077
+ ...endLabelProps(edge, boxOf.get(edge.sourceId), boxOf.get(edge.targetId)),
1078
+ });
1079
+ }
1080
+ frameTop = frame.y + frame.h + UML_IMPORT_GUTTER;
1081
+ }
1082
+ return { elements, notes };
1083
+ }
1084
+ /* ── The two end labels a connector is created with (ADR 0020) ─────────── */
1085
+ /**
1086
+ * The box an imported end label is created with — one short line of text.
1087
+ *
1088
+ * A SIZE and not a measurement: nothing here can measure text (no DOM, and the
1089
+ * module says so), and an end label is `0..*`, `1` or a short role name. The
1090
+ * renderer re-measures the moment it paints, and the author drags it the moment
1091
+ * they disagree.
1092
+ */
1093
+ const UML_END_LABEL_SIZE = { w: 60, h: 24 };
1094
+ /**
1095
+ * `sourceLabel` / `targetLabel` and their boxes, for a connector being created.
1096
+ *
1097
+ * ## Why a box has to be computed at all
1098
+ *
1099
+ * The connector's label boxes are PERSISTED (`labelXYWH` and the two ADR 0020
1100
+ * siblings): the model holds where the author dragged the label to, and the
1101
+ * renderer draws it there. A connector created with a label and no box would
1102
+ * have to be laid out by something, and nothing here is in a position to run the
1103
+ * router — the element does not exist yet, so it has no path, and the path is
1104
+ * what an end label is positioned along.
1105
+ *
1106
+ * ## The approximation, and why it is honest
1107
+ *
1108
+ * The straight segment between the two node CENTRES, handed to the SAME
1109
+ * function the renderer and the editor anchor an end label with
1110
+ * ({@link connectorEndLabelBox}) — so an imported label starts exactly where a
1111
+ * label created by hand on the same line would.
1112
+ *
1113
+ * The segment is an approximation of the routed path and a close one: every UML
1114
+ * connector this materializer creates is `ConnectorMode.Straight` (see the call
1115
+ * site's own note) with both ends attached at `[0.5, 0.5]`, so the path the
1116
+ * router produces is that segment clipped to the two boxes' perimeters — the
1117
+ * same line, shorter at each end. The box therefore lands a little further in
1118
+ * from the endpoint than the router would put it, which §11.5.4 still calls
1119
+ * "near the end of the line", and which the first re-route corrects.
1120
+ *
1121
+ * No box is written when either node has no placed box — an end whose artefact
1122
+ * this sheet never drew. The label text is still written, but a label with no
1123
+ * box is not painted (`hasEndLabel` wants both) until the first edit seeds
1124
+ * one; today the branch is unreachable, since an edge whose ends do not
1125
+ * resolve is skipped before this point.
1126
+ */
1127
+ function endLabelProps(edge, from, to) {
1128
+ if (!edge.sourceEnd && !edge.targetEnd)
1129
+ return {};
1130
+ const centre = (box) => [box.x + box.w / 2, box.y + box.h / 2];
1131
+ const path = from && to ? [centre(from), centre(to)] : undefined;
1132
+ const boxAt = (end) => path ? connectorEndLabelBox(path, end, UML_END_LABEL_SIZE) : undefined;
1133
+ const sourceBox = edge.sourceEnd ? boxAt('source') : undefined;
1134
+ const targetBox = edge.targetEnd ? boxAt('target') : undefined;
1135
+ return {
1136
+ ...(edge.sourceEnd ? { sourceLabel: edge.sourceEnd } : {}),
1137
+ ...(sourceBox ? { sourceLabelXYWH: sourceBox } : {}),
1138
+ ...(edge.targetEnd ? { targetLabel: edge.targetEnd } : {}),
1139
+ ...(targetBox ? { targetLabelXYWH: targetBox } : {}),
1140
+ };
1141
+ }
1142
+ /** The foreign payload one element carries: its source id, and no more (D3). */
1143
+ function payload(formatId, sourceId) {
1144
+ if (!sourceId)
1145
+ return {};
1146
+ return { interchange: { [formatId]: { id: sourceId } } };
1147
+ }
1148
+ /** How far a lollipop or a socket sits from the box it names a contract of. */
1149
+ const UML_IMPORT_MARK_GAP = 12;
1150
+ /**
1151
+ * Give every draft a box, and say how many of them Labre had to invent.
1152
+ *
1153
+ * Three sources, in order of authority: the caller's hints (a format that reads
1154
+ * coordinates out of the file), the `bounds` a parser already wrote onto the IR,
1155
+ * and — for what neither answered — the layered layout, laid out BELOW
1156
+ * everything the file did place so that nothing an author drew is covered by
1157
+ * something they did not.
1158
+ *
1159
+ * ANCHORED glyphs are none of the three: a port and the two interface marks are
1160
+ * drawn ON or AGAINST another artefact (§11.3.4, §10.4.4), so they are placed
1161
+ * last, out of the box their owner ended up in — see {@link UmlAnchor}.
1162
+ */
1163
+ function placeDrafts(drafts, model, layout) {
1164
+ const hinted = layout?.boxes;
1165
+ for (const draft of drafts) {
1166
+ // `Object.hasOwn`, because the id is the FILE's: a drawing that names a
1167
+ // shape `toString` would otherwise be handed a function as its box, and
1168
+ // every coordinate computed off it is `NaN`. The readers hand over a bag
1169
+ // with no prototype; this is what holds for a caller that does not.
1170
+ const hint = draft.sourceId && hinted && Object.hasOwn(hinted, draft.sourceId)
1171
+ ? hinted[draft.sourceId]
1172
+ : undefined;
1173
+ if (hint)
1174
+ draft.bounds = hint;
1175
+ }
1176
+ const anchored = drafts.filter(draft => draft.anchor !== undefined && draft.bounds === undefined);
1177
+ const loose = drafts.filter(draft => draft.bounds === undefined && draft.anchor === undefined);
1178
+ if (loose.length > 0) {
1179
+ const placed = drafts
1180
+ .map(draft => draft.bounds)
1181
+ .filter((box) => box !== undefined);
1182
+ const below = placed.length > 0
1183
+ ? Math.max(...placed.map(box => box.y + box.h)) + UML_IMPORT_GUTTER
1184
+ : 0;
1185
+ // Containment, so the invented layout draws the nesting the file stated:
1186
+ // a class in its package, a case in its subject, an action in its lane, a
1187
+ // state in its composite state. Only the drafts that are actually being laid
1188
+ // out here take part — a child the file DID place stays where the file put
1189
+ // it, and its container is sized by the same file.
1190
+ const inLayout = new Set(loose.map(draft => draft.sourceId));
1191
+ const children = new Map();
1192
+ for (const [child, container] of containmentOf(model, layout?.containment)) {
1193
+ if (!inLayout.has(child) || !inLayout.has(container))
1194
+ continue;
1195
+ const held = children.get(container);
1196
+ if (held)
1197
+ held.push(child);
1198
+ else
1199
+ children.set(container, [child]);
1200
+ }
1201
+ const boxes = umlInventLayout(loose.map(draft => {
1202
+ const held = children.get(draft.sourceId);
1203
+ return {
1204
+ id: draft.sourceId,
1205
+ size: draft.size,
1206
+ ...(held ? { children: held } : {}),
1207
+ };
1208
+ }), model.relations);
1209
+ for (const draft of loose) {
1210
+ const box = boxes.get(draft.sourceId);
1211
+ draft.bounds = box
1212
+ ? { x: box.x, y: box.y + below, w: box.w, h: box.h }
1213
+ : { x: 0, y: below, ...draft.size };
1214
+ }
1215
+ }
1216
+ const owners = new Map();
1217
+ for (const draft of drafts) {
1218
+ if (draft.sourceId && draft.bounds)
1219
+ owners.set(draft.sourceId, draft.bounds);
1220
+ }
1221
+ for (const draft of anchored) {
1222
+ draft.bounds = anchoredBox(draft, owners.get(draft.anchor.ownerId));
1223
+ }
1224
+ return loose.length + anchored.length;
1225
+ }
1226
+ /**
1227
+ * Where a glyph drawn against another artefact goes.
1228
+ *
1229
+ * The numbers are the notation's, and the tolerance is the export's: `model.ts`
1230
+ * gives a port its owner and a mark its component by measuring an edge-to-edge
1231
+ * GAP of at most {@link UML_ATTACH_TOLERANCE}, so a square that missed the
1232
+ * border by more than that would import a component whose ports belong to
1233
+ * nobody. A port therefore STRADDLES the top edge (§11.3.4's own preferred
1234
+ * drawing, gap zero), and the two marks sit one {@link UML_IMPORT_MARK_GAP} off
1235
+ * the side, which is well inside the tolerance and still reads as a stub.
1236
+ *
1237
+ * An owner that was never placed — a file naming a component it does not
1238
+ * declare — leaves the glyph at the origin of the sheet rather than nowhere:
1239
+ * it is on the drawing, it names nothing, and that is what the file said.
1240
+ */
1241
+ function anchoredBox(draft, owner) {
1242
+ const { w, h } = draft.size;
1243
+ if (!owner)
1244
+ return { x: 0, y: 0, w, h };
1245
+ const { index, count, side } = draft.anchor;
1246
+ if (side === 'top') {
1247
+ // Spread along the width, each at the centre of its own share of it, and
1248
+ // half the square above the line: §11.3.4 draws the port overlapping the
1249
+ // boundary rather than beside it.
1250
+ const step = owner.w / (count + 1);
1251
+ return {
1252
+ x: owner.x + step * (index + 1) - w / 2,
1253
+ y: owner.y - h / 2,
1254
+ w,
1255
+ h,
1256
+ };
1257
+ }
1258
+ // Stacked down the side, starting at the top of the box: the ball on the
1259
+ // left, the socket on the right (§10.4.4 draws each on a stub and names no
1260
+ // side; one side per direction is what makes a wired sheet readable).
1261
+ const stack = index * (h + UML_IMPORT_MARK_GAP);
1262
+ return {
1263
+ x: side === 'left'
1264
+ ? owner.x - w - UML_IMPORT_MARK_GAP
1265
+ : owner.x + owner.w + UML_IMPORT_MARK_GAP,
1266
+ y: owner.y + stack,
1267
+ w,
1268
+ h,
1269
+ };
1270
+ }