@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.
- package/LICENSE +384 -0
- package/dist/actions.d.ts +430 -0
- package/dist/actions.js +724 -0
- package/dist/background.d.ts +153 -0
- package/dist/background.js +557 -0
- package/dist/board-hit.d.ts +137 -0
- package/dist/board-hit.js +191 -0
- package/dist/commands-manifest.d.ts +19 -0
- package/dist/commands-manifest.js +571 -0
- package/dist/commands.d.ts +4 -0
- package/dist/commands.js +983 -0
- package/dist/component.d.ts +521 -0
- package/dist/component.js +708 -0
- package/dist/consts.d.ts +365 -0
- package/dist/consts.js +463 -0
- package/dist/descriptor.d.ts +12 -0
- package/dist/descriptor.js +10 -0
- package/dist/drawio-decode.d.ts +32 -0
- package/dist/drawio-decode.js +96 -0
- package/dist/drawio-import.d.ts +133 -0
- package/dist/drawio-import.js +885 -0
- package/dist/edge-morph.d.ts +154 -0
- package/dist/edge-morph.js +153 -0
- package/dist/edge-styles.d.ts +78 -0
- package/dist/edge-styles.js +210 -0
- package/dist/effects.d.ts +9 -0
- package/dist/effects.js +6 -0
- package/dist/element-renderer.d.ts +64 -0
- package/dist/element-renderer.js +114 -0
- package/dist/element-view.d.ts +234 -0
- package/dist/element-view.js +430 -0
- package/dist/export.d.ts +61 -0
- package/dist/export.js +41 -0
- package/dist/filename.d.ts +17 -0
- package/dist/filename.js +26 -0
- package/dist/frame-tag.d.ts +70 -0
- package/dist/frame-tag.js +78 -0
- package/dist/grammar.d.ts +695 -0
- package/dist/grammar.js +1436 -0
- package/dist/import.d.ts +306 -0
- package/dist/import.js +1270 -0
- package/dist/index.d.ts +47 -0
- package/dist/index.js +67 -0
- package/dist/interchange.d.ts +125 -0
- package/dist/interchange.js +372 -0
- package/dist/keywords.d.ts +204 -0
- package/dist/keywords.js +361 -0
- package/dist/kinds.d.ts +73 -0
- package/dist/kinds.js +91 -0
- package/dist/legend.d.ts +30 -0
- package/dist/legend.js +255 -0
- package/dist/model.d.ts +862 -0
- package/dist/model.js +1316 -0
- package/dist/morph.d.ts +178 -0
- package/dist/morph.js +496 -0
- package/dist/node/act-glyphs.d.ts +30 -0
- package/dist/node/act-glyphs.js +142 -0
- package/dist/node/compartment-watcher.d.ts +107 -0
- package/dist/node/compartment-watcher.js +163 -0
- package/dist/node/fit.d.ts +85 -0
- package/dist/node/fit.js +129 -0
- package/dist/node/node-renderer.d.ts +49 -0
- package/dist/node/node-renderer.js +611 -0
- package/dist/node/node-view.d.ts +51 -0
- package/dist/node/node-view.js +89 -0
- package/dist/node/paint.d.ts +84 -0
- package/dist/node/paint.js +143 -0
- package/dist/node/sd-glyphs.d.ts +36 -0
- package/dist/node/sd-glyphs.js +79 -0
- package/dist/node/stm-glyphs.d.ts +22 -0
- package/dist/node/stm-glyphs.js +111 -0
- package/dist/node/tier-metrics.d.ts +38 -0
- package/dist/node/tier-metrics.js +45 -0
- package/dist/operators.d.ts +45 -0
- package/dist/operators.js +75 -0
- package/dist/plantuml-import.d.ts +113 -0
- package/dist/plantuml-import.js +1723 -0
- package/dist/plantuml.d.ts +94 -0
- package/dist/plantuml.js +985 -0
- package/dist/presets.d.ts +149 -0
- package/dist/presets.js +302 -0
- package/dist/profiles.d.ts +2 -0
- package/dist/profiles.js +358 -0
- package/dist/reading.d.ts +160 -0
- package/dist/reading.js +648 -0
- package/dist/roles.d.ts +257 -0
- package/dist/roles.js +997 -0
- package/dist/rules.d.ts +267 -0
- package/dist/rules.js +3090 -0
- package/dist/templates.d.ts +2 -0
- package/dist/templates.js +197 -0
- package/dist/toolbar/config.d.ts +228 -0
- package/dist/toolbar/config.js +833 -0
- package/dist/toolbar/icons.d.ts +440 -0
- package/dist/toolbar/icons.js +711 -0
- package/dist/toolbar/senior-tool.d.ts +11 -0
- package/dist/toolbar/senior-tool.js +21 -0
- package/dist/toolbar/uml-menu.d.ts +17 -0
- package/dist/toolbar/uml-menu.js +20 -0
- package/dist/toolbar/uml-senior-button.d.ts +18 -0
- package/dist/toolbar/uml-senior-button.js +22 -0
- package/dist/translations.d.ts +27 -0
- package/dist/translations.js +117 -0
- package/dist/view.d.ts +33 -0
- package/dist/view.js +236 -0
- package/dist/xmi-import.d.ts +174 -0
- package/dist/xmi-import.js +2268 -0
- package/dist/xmi.d.ts +61 -0
- package/dist/xmi.js +1860 -0
- package/dist/xml-reader.d.ts +157 -0
- package/dist/xml-reader.js +430 -0
- package/dist/xml.d.ts +72 -0
- package/dist/xml.js +96 -0
- 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
|
+
}
|