@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/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
|
+
}
|