umlimator 0.18.0 → 0.20.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/README.md +50 -10
- package/lib/umlimator-all.js +2486 -192
- package/lib/umlimator.d.ts +51 -2
- package/lib/umlimator.js +177 -105
- package/lib/umlimator.schema.json +141 -18
- package/package.json +1 -1
package/lib/umlimator-all.js
CHANGED
|
@@ -109,14 +109,26 @@ function registerDiagramType(name, def = {}) {
|
|
|
109
109
|
}
|
|
110
110
|
|
|
111
111
|
/* A definition's fields are optional and provisional; absent, things go as before. notes/spike-sequence.md §1 says what
|
|
112
|
-
each does (validate(model) with no view runs once a type, not once a view); 1.9's: rankFlip, member, rules, describe.
|
|
112
|
+
each does (validate(model) with no view runs once a type, not once a view); 1.9's: rankFlip, member, rules, describe.
|
|
113
|
+
2.1's (notes/stage-2.md §3, §4.2, §8):
|
|
114
|
+
- boxes(model, view) -> [{ id, label, title, nodeIds, parent, shape, aria }]: the view's boxes, a map's container format plus
|
|
115
|
+
the drawn title, the shape whose frame(f) draws it (SHAPE, src/draw/shapes.js: its border with class family-box, its title
|
|
116
|
+
with class family-title, as svg.js says) and its accessible name. A box's id is in the view's nodeIds but takes no slot:
|
|
117
|
+
the layout context places its nodeIds as a block (blockPos), and the type's layout passes the same boxes to layoutTyped as
|
|
118
|
+
containers. A drop into, out of or between boxes emits reparent, and removes the node's pin; an edit that changes the box
|
|
119
|
+
a shown element is drawn in lays the view out again (render.js withPlaces). None: no boxes, as before.
|
|
120
|
+
- owners(model, element) -> [{ id, label }]: the properties panel's Owner field for that element, a select of these and
|
|
121
|
+
"none", which emits reparent (the element itself and what it holds are left out; its owner is added when not offered).
|
|
122
|
+
None, or not a list: no Owner field.
|
|
123
|
+
- layout.pins false: pos is {}, no ranking; layout.rank false: pos is the view's pins alone, no ranking (risks.md P1).
|
|
124
|
+
reroute is no registry field: reroute: false goes in the dg the type's own layout passes to layoutTyped (src/typed/layout.js). */
|
|
113
125
|
|
|
114
126
|
/* A view's type: the map for a view with no diagram, the registered type, or null for one this release does not draw */
|
|
115
127
|
const diagramOf = view => TYPES.get(view && view.diagram != null ? view.diagram : 'map') || null;
|
|
116
128
|
|
|
117
129
|
/* What only a registered type's geometry uses, installed at load time by the files under src/typed/, which only
|
|
118
|
-
lib/umlimator-all.js holds (tools/build.mjs): rows, rooms and routes in layoutPass, a typed line's ends in svgOf
|
|
119
|
-
umlimator.js, where no view is laid out but as a map (risks.md C5) */
|
|
130
|
+
lib/umlimator-all.js holds (tools/build.mjs): rows, rooms and routes in layoutPass, a typed line's ends in svgOf, the
|
|
131
|
+
gestures of edit.js it takes over (edit). Empty in umlimator.js, where no view is laid out but as a map (risks.md C5) */
|
|
120
132
|
const TYPED = {};
|
|
121
133
|
|
|
122
134
|
const propRefs = type => Object.assign({}, ...[...TYPES.values()].map(T => T.refs && Object.hasOwn(T.refs, type) && T.refs[type]));
|
|
@@ -620,12 +632,16 @@ function svgOf(geo, opts = {}) {
|
|
|
620
632
|
: nodes.map(n => [n, n.shape != null && cardLook(n, 'box', false, null)]).filter(x => x[1]));
|
|
621
633
|
const drawn = edges.map(e => map && looks.size ? drawnPts(e, n => looks.get(n) || null) : e.pts);
|
|
622
634
|
const T = !map && TYPED.lines ? TYPED.lines({ id, edges, drawn, lstyle, MC }) : null; // a typed view's line ends and end labels (src/typed/draw.js)
|
|
623
|
-
let s = `<svg viewBox="${B.x} ${B.y} ${B.w} ${B.h}" data-w="${B.w}" data-h="${B.h}" role="
|
|
635
|
+
let s = `<svg viewBox="${B.x} ${B.y} ${B.w} ${B.h}" data-w="${B.w}" data-h="${B.h}" role="group" aria-label="${esc(opts.title || 'Diagram')}" xmlns="http://www.w3.org/2000/svg" preserveAspectRatio="xMidYMid meet">
|
|
624
636
|
<defs>${marker('d', LINE)}${marker('g', 'var(--um-green)')}${marker('r', 'var(--um-rose)')}${marker('v', 'var(--um-violet)')}${marker('h', 'var(--um-cyan)')}${map ? WIRES.map(w => marker(w[0], `var(--um-w-${w[0]})`)).join('') : ''}${heavy.map(m => { const [c, k] = m.split('-w'); return marker(m, MC[c], 1 + (k - 1) * .35); }).join('')}${T ? T.defs : ''}</defs>`;
|
|
625
637
|
const families = geo.families || [], familyDepth = f => f.parent ? 1 + familyDepth(families.find(p => p.id === f.parent) || {}) : 0;
|
|
626
638
|
families.slice().sort((a,b) => familyDepth(a)-familyDepth(b)).forEach(f => { // in c4 every box is a boundary, dashed as Structurizr draws one (a toned box is dashed in any notation)
|
|
639
|
+
// a typed view's box whose shape draws a frame (notes/stage-2.md §5): the shape's markup, in the group the canvas selects. It keeps
|
|
640
|
+
// .family-title (the audit, the canvas's "Box …, selected") and draws its border with class family-box: the canvas outlines a
|
|
641
|
+
// selected box and the box under a drag by .family-box alone (src/canvas/styles.js)
|
|
642
|
+
if (!map && hasKey(SHAPE, f.shape) && SHAPE[f.shape].frame) { s += `<g class="family" data-family="${esc(f.id)}"${f.parent ? ` data-parent="${esc(f.parent)}"` : ''}${f.aria ? ` role="img" aria-label="${esc(f.aria)}"` : ''}>${SHAPE[f.shape].frame(f)}</g>`; return; }
|
|
627
643
|
s += `<g class="family${f.tone ? ' toned' : ''}" data-family="${esc(f.id)}"${f.parent ? ` data-parent="${esc(f.parent)}"` : ''}${f.tone ? ` style="--um-tone:${toneOf(f)}"` : ''}><rect class="family-box" x="${f.x}" y="${f.y}" width="${f.w}" height="${f.h}" rx="14" stroke="var(--um-${familyDepth(f) ? 'cyan' : 'border-2'})"${notation === 'c4' ? ' stroke-dasharray="7 5"' : ''}/>
|
|
628
|
-
<text class="family-title" x="${f.x+12}" y="${f.y+18}" fill="var(--um-muted)" data-t="ft" data-o="${esc(f.id)}">${esc(f.label || f.id)}</text>${(f.tabs || []).map(t => t.fold ? `
|
|
644
|
+
<text class="family-title" x="${f.x+12}" y="${f.y+18}" fill="var(--um-muted)" data-t="ft" data-o="${esc(f.id)}">${esc(!map && f.title || f.label || f.id)}</text>${(f.tabs || []).map(t => t.fold ? `
|
|
629
645
|
<g class="fold-hint"><title>Fold back into one card (E, or Alt + click a card in it)</title><rect x="${t.x}" y="${f.y - 8}" width="${t.w}" height="16" rx="8" class="n-mask" stroke="var(--um-accent)"/><text x="${t.x + t.w / 2}" y="${f.y + 4}" text-anchor="middle" class="badge-tx" fill="var(--um-accent)">⤡</text></g>` : `
|
|
630
646
|
<g class="pub-tab" data-badge="${t.badge}"><rect x="${t.x}" y="${f.y - 8}" width="${t.w}" height="15" rx="4" fill="${t.fill}"/><text x="${t.x + t.w / 2}" y="${f.y + 3}" text-anchor="middle" class="tab-tx">${esc(t.text)}</text></g>`).join('')}</g>`;
|
|
631
647
|
});
|
|
@@ -789,12 +805,12 @@ return I && I.src ? `<rect x="${x - M * 3 / 28}" y="${y - M * 3 / 28}" width="${
|
|
|
789
805
|
${parts.join('\n')}
|
|
790
806
|
</g>`;
|
|
791
807
|
});
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
|
|
808
|
+
// focus targets after the cards (#43), under the labels: each line's (a typed one's named by e.aria), or one per id of geo.order (notes/stage-3.md S13)
|
|
809
|
+
const ring = (a, name, d) => `<g class="um-key line-key${a}" tabindex="0" role="button" aria-pressed="false" aria-label="${esc(name)}"><path d="${d}" class="key-ring"/></g>`;
|
|
810
|
+
const line = (e, i, said = e.label ? `: ${e.label}${e.sublabel ? ` (${e.sublabel})` : ''}` : '') => ring(`${e.weight > 1 ? ' wt-' + e.weight : ''}" data-i="${i}`, !map && e.aria != null ? e.aria
|
|
811
|
+
: `${e.step != null ? `Step ${e.step}: ` : ''}Line ${e.both ? `between ${e.s.label} and ${e.t.label}, both ways` : `from ${e.s.label} to ${e.t.label}`}${said}${broke(e).length ? `, breaks rule ${broke(e).join('; ')}` : ''}`, roundPath(drawn[i]));
|
|
812
|
+
s += '</g><g class="keys">' + (!map && Array.isArray(geo.order) ? geo.order.map(id => { const n = hasKey(geo.byId || {}, id) && geo.byId[id], i = edges.findIndex(e => e.id === id);
|
|
813
|
+
return n ? ring(` node-key" data-node="${esc(id)}`, n.aria || n.label || id, `M${n.x} ${n.y}h${n.w}v${n.h}h${-n.w}z`) : i < 0 ? '' : line(edges[i], i); }) : edges.map((e, i) => line(e, i))).join('');
|
|
798
814
|
s += '</g>' + (opts.over || '') + '<g class="labels depth-read">';
|
|
799
815
|
edges.forEach((e, i) => {
|
|
800
816
|
if (T) s += T.labels(i);
|
|
@@ -983,7 +999,7 @@ function layoutPass(dg, map, room, last) { // room: { x: { col: px }, y: { row:
|
|
|
983
999
|
cols.forEach(c => { const ns = nodes.filter(n => n.col === c && !n.aside); if (ns.length) colW[c] = Math.max(...ns.map(n => n.w)); });
|
|
984
1000
|
// a labelled line between neighbouring columns gets a gap wide enough for its label and 27 px of line on each side (a
|
|
985
1001
|
// numbered line with no label: for its step mark). A typed view's too, so lines between one pair keep their own labels (risks.md F2)
|
|
986
|
-
const gapAfter = {}, colOf =
|
|
1002
|
+
const gapAfter = {}, colOf = Object.create(null), boxOf = id => ((dg.containers || []).filter(f => (f.nodeIds || []).includes(id)).pop() || {}).id; if ((map || dg.typed) && !dg.abs) { nodes.forEach(n => colOf[n.id] = n.col);
|
|
987
1003
|
// labelled lines that leave (or reach) one card side by side run 16 px apart, too close for their pills to stack,
|
|
988
1004
|
// so the pills sit next to each other: the gap fits them all, with 6 px between pills
|
|
989
1005
|
const sum = {};
|
|
@@ -1016,18 +1032,23 @@ function layoutPass(dg, map, room, last) { // room: { x: { col: px }, y: { row:
|
|
|
1016
1032
|
else place();
|
|
1017
1033
|
// Family frames enclose ordinary cards, so clicks, ports and focus keep their existing owners.
|
|
1018
1034
|
// Children contribute their entire frame to their parent's bounds, rather than just their cards.
|
|
1019
|
-
|
|
1020
|
-
|
|
1035
|
+
// A typed view's boxes (notes/stage-2.md §3.2) take the same code as a map's: their title is the type's (`title`), and a box
|
|
1036
|
+
// grows to hold it; laid out turned (direction down), the 24 px header room goes left, the side that becomes the top
|
|
1037
|
+
const familyDefs = dg.containers || [], tagged = n => map && (n.author || n.k === 'm-ext'), TB = !map && dg.turned; // tagged: a name tag hangs under the card
|
|
1038
|
+
const headW = f => textW(!map && f.title || f.label || f.id, 11) + 16; // a box's title, as the audit measures it
|
|
1039
|
+
const framesNow = () => { const cache = Object.create(null); // a box may be named 'constructor'
|
|
1021
1040
|
const frameOf = (f, trail = new Set()) => {
|
|
1022
1041
|
if (cache[f.id]) return cache[f.id];
|
|
1023
1042
|
if (trail.has(f.id)) return null;
|
|
1024
1043
|
const next = new Set([...trail, f.id]);
|
|
1025
1044
|
const children = familyDefs.filter(c => c.parent === f.id).map(c => frameOf(c, next)).filter(Boolean);
|
|
1026
1045
|
const members = (f.nodeIds || []).map(id => byId[id]).filter(Boolean);
|
|
1027
|
-
const rects = [...members.map(n =>
|
|
1046
|
+
const rects = [...members.map(n => TB ? { x: n.x - 14, y: n.y - 36, w: n.w + 44, h: n.h + 72 } : {x:n.x - 36,y:n.y - 14,w:n.w + 72,h:n.h + 44 + (tagged(n) ? 14 : 0)}), ...children];
|
|
1028
1047
|
if (!rects.length) return null;
|
|
1029
|
-
const p = 12, x = Math.min(...rects.map(r => r.x)) - p, y = Math.min(...rects.map(r => r.y)) - p - 24;
|
|
1030
|
-
|
|
1048
|
+
const p = 12, x = Math.min(...rects.map(r => r.x)) - p - (TB ? 24 : 0), y = Math.min(...rects.map(r => r.y)) - p - (TB ? 0 : 24);
|
|
1049
|
+
const b = {...f, x, y, w:Math.max(...rects.map(r => r.x+r.w)) + p - x, h:Math.max(...rects.map(r => r.y+r.h)) + p - y};
|
|
1050
|
+
if (!map) b[TB ? 'h' : 'w'] = Math.max(b[TB ? 'h' : 'w'], headW(f) + 16);
|
|
1051
|
+
return cache[f.id] = b;
|
|
1031
1052
|
};
|
|
1032
1053
|
const out = familyDefs.map(f => frameOf(f)).filter(Boolean);
|
|
1033
1054
|
if (map) out.forEach(f => { // the box fits its title and its badges, which are tabs on its top border, at the right
|
|
@@ -1038,17 +1059,19 @@ function layoutPass(dg, map, room, last) { // room: { x: { col: px }, y: { row:
|
|
|
1038
1059
|
};
|
|
1039
1060
|
// Boxes keep apart: a column (row) gap widens wherever a box would come within 32 px of another box, or of a card outside
|
|
1040
1061
|
// it, so their borders never touch and a line can run between them. Frames are measured again until nothing is too close.
|
|
1041
|
-
if (
|
|
1062
|
+
if (!dg.abs && familyDefs.length) for (let pass = 0, BOXGAP = 32; pass < 4; pass++) {
|
|
1042
1063
|
const within = (a, b) => a.x >= b.x && a.y >= b.y && a.x + a.w <= b.x + b.w && a.y + a.h <= b.y + b.h;
|
|
1043
1064
|
const span = ns => ns.length ? { c0: Math.min(...ns.map(n => n.col)), c1: Math.max(...ns.map(n => n.col)), r0: Math.min(...ns.map(n => n.row)), r1: Math.max(...ns.map(n => n.row)) } : null;
|
|
1044
1065
|
const fr = framesNow().map(f => ({ ...f, s: span(nodes.filter(n => within(n, f))) })).filter(f => f.s);
|
|
1045
1066
|
const cards = nodes.map(n => ({ x: n.x, y: n.y, w: n.w, h: n.h + (tagged(n) ? 14 : 0), s: span([n]) }));
|
|
1046
1067
|
const wantX = {}, wantY = {}, want = (o, k, v) => { if (v > 0) o[k] = Math.max(o[k] || 0, Math.ceil(v)); };
|
|
1068
|
+
const at = v => map ? Math.floor(v) : v; // the room goes after that row; a typed view's: after a half row too, so a card on row .5 leaves a box on 1.5
|
|
1069
|
+
// a column's room goes after a whole column k (gap(k) reads no other), and moves a column c in (k, k + 1) by c - k of it
|
|
1047
1070
|
fr.forEach((A, i) => [...fr.slice(i + 1), ...cards].forEach(B => {
|
|
1048
1071
|
if (within(A, B) || within(B, A) || !hit(A, B, BOXGAP)) return;
|
|
1049
1072
|
const a = A.s, b = B.s;
|
|
1050
|
-
if (a.c1 < b.c0 || b.c1 < a.c0) { const [L, R] = a.c1 < b.c0 ? [A, B] : [B, A]
|
|
1051
|
-
else if (a.r1 < b.r0 || b.r1 < a.r0) { const [T, U] = a.r1 < b.r0 ? [A, B] : [B, A]; want(wantY,
|
|
1073
|
+
if (a.c1 < b.c0 || b.c1 < a.c0) { const [L, R] = a.c1 < b.c0 ? [A, B] : [B, A], k = Math.floor(L.s.c1); want(wantX, k, (L.x + L.w + BOXGAP - R.x) / (Math.min(1, R.s.c0 - k) - L.s.c1 + k)); }
|
|
1074
|
+
else if (a.r1 < b.r0 || b.r1 < a.r0) { const [T, U] = a.r1 < b.r0 ? [A, B] : [B, A]; want(wantY, at(T.s.r1), T.y + T.h + BOXGAP - U.y); }
|
|
1052
1075
|
}));
|
|
1053
1076
|
if (!Object.keys(wantX).length && !Object.keys(wantY).length) break;
|
|
1054
1077
|
Object.entries(wantX).forEach(([k, v]) => { extraX[k] = (extraX[k] || 0) + v; }); Object.entries(wantY).forEach(([k, v]) => { extraY[k] = (extraY[k] || 0) + v; });
|
|
@@ -1093,9 +1116,10 @@ function layoutPass(dg, map, room, last) { // room: { x: { col: px }, y: { row:
|
|
|
1093
1116
|
route exists; earlier labels are obstacles, so later routes keep clear of them. */
|
|
1094
1117
|
// an end may be a container: the line ends on its box's border, whose title the ports keep clear of (not a box and
|
|
1095
1118
|
// something inside it: that has no line)
|
|
1096
|
-
const
|
|
1097
|
-
|
|
1098
|
-
|
|
1119
|
+
const headOf = f => Math.min((TB ? f.h : f.w) - 16, headW(f)); // the length of a box's title along its header
|
|
1120
|
+
const boxEnd = Object.fromEntries(families.map(f => [f.id, { id: f.id, label: f.label || f.id, box: true, x: f.x, y: f.y, w: f.w, h: f.h, hd: TB ? headOf(f) : 0,
|
|
1121
|
+
decoT: TB ? [] : [[f.x + 8, headOf(f)], ...(f.tabs || []).map(t => [t.x - 6, t.w + 12])], decoB: [] }]));
|
|
1122
|
+
const endOf = Object.assign(Object.create(null), boxEnd, byId), end = id => endOf[id], holds = (b, n) => b.box && n !== b && n.x >= b.x && n.y >= b.y && n.x + n.w <= b.x + b.w && n.y + n.h <= b.y + b.h;
|
|
1099
1123
|
const edges = (dg.edges || []).filter(e => { const s = end(e.from), t = end(e.to); if (!s || !t || (e.from === e.to && !loops[e.from])) return false;
|
|
1100
1124
|
if (holds(s, t) || holds(t, s)) { warn(`no line for edge ${e.id ?? e.from + '->' + e.to}: it joins the box "${(holds(s, t) ? s : t).id}" and something inside it`); return false; }
|
|
1101
1125
|
return true; }).map(e => ({ ...e, s: end(e.from), t: end(e.to) }));
|
|
@@ -1163,8 +1187,10 @@ function layoutPass(dg, map, room, last) { // room: { x: { col: px }, y: { row:
|
|
|
1163
1187
|
if (n.box) { // each port in line with its line's other end where the border allows (a straight line): below the title on
|
|
1164
1188
|
// a side, off the title, tabs and corners on top or bottom; in that order, 16 (26) px apart
|
|
1165
1189
|
const want = it => { const q = it.e.s === n ? it.e.p2 : it.e.p1, o = it.other; return q ? q[vertical ? 1 : 0] : vertical ? o.y + o.h / 2 : o.x + o.w / 2; };
|
|
1166
|
-
|
|
1167
|
-
|
|
1190
|
+
// (turned: the title runs down the left side, and the sides across the header room are the top and bottom)
|
|
1191
|
+
const across = TB ? !vertical : vertical, a0 = vertical ? n.y : n.x, len = vertical ? n.h : n.w;
|
|
1192
|
+
const sp = across ? [a0 + 40, a0 + len - 16] : TB ? [side === 'L' ? n.y + n.hd + 16 : n.y, n.y + n.h] : sideSpan(n, side) || [n.x, n.x + n.w];
|
|
1193
|
+
let lo = across ? sp[0] : Math.max(sp[0], a0 + 20), hi = across ? sp[1] : Math.min(sp[1], a0 + len - 20); if (hi < lo) lo = hi = (lo + hi) / 2;
|
|
1168
1194
|
const ws = list.map(want), order = list.map((_, i) => i).sort((a, b) => ws[a] - ws[b]), gap = Math.min(vertical ? 16 : 26, (hi - lo) * 0.84 / list.length);
|
|
1169
1195
|
const v = order.map(i => Math.min(hi, Math.max(lo, ws[i])));
|
|
1170
1196
|
for (let k = 1; k < v.length; k++) v[k] = Math.max(v[k], v[k - 1] + gap);
|
|
@@ -1238,9 +1264,9 @@ function layoutPass(dg, map, room, last) { // room: { x: { col: px }, y: { row:
|
|
|
1238
1264
|
const keep = new Set([st[0], en[0], st[1], en[1]]);
|
|
1239
1265
|
avoid.forEach(([a, b]) => { if (a[0] === b[0]) keep.add(a[0]); else keep.add(a[1]); }); // a route's own line is never merged away
|
|
1240
1266
|
const co = vals => vals.filter(v => !W || keep.has(v) || true).sort((a, b) => a - b).filter((v, i, s) => i === 0 || keep.has(v) || v - s[i - 1] >= 5);
|
|
1241
|
-
|
|
1267
|
+
families.forEach(f => { xs.add(f.x - 16); xs.add(f.x + f.w + 16); ys.add(f.y - 16); ys.add(f.y + f.h + 16); });
|
|
1242
1268
|
let X = co([...xs]), Y = co([...ys]);
|
|
1243
|
-
if (
|
|
1269
|
+
if (families.length) { const far = (v, bs) => keep.has(v) || bs.every(u => Math.abs(v - u) >= 8);
|
|
1244
1270
|
X = X.filter(v => far(v, families.flatMap(f => [f.x, f.x + f.w]))); Y = Y.filter(v => far(v, families.flatMap(f => [f.y, f.y + f.h]))); }
|
|
1245
1271
|
if (W) { X = X.filter(v => keep.has(v) || (v >= W.x && v <= W.x2)); Y = Y.filter(v => keep.has(v) || (v >= W.y && v <= W.y2)); }
|
|
1246
1272
|
const nx = X.length, ny = Y.length, N = nx * ny;
|
|
@@ -1424,7 +1450,7 @@ function layoutPass(dg, map, room, last) { // room: { x: { col: px }, y: { row:
|
|
|
1424
1450
|
const runRouting = order => {
|
|
1425
1451
|
routed.length = 0; reserved.length = 0; placed.length = 0; stats = {};
|
|
1426
1452
|
(dg.blocks || []).forEach(b => { reserved.push(b); placed.push(b); });
|
|
1427
|
-
|
|
1453
|
+
families.forEach(f => { const h = TB ? { x: f.x + 3, y: f.y + 8, w: 22, h: headOf(f) } : { x: f.x + 8, y: f.y + 3, w: headOf(f), h: 22 }; reserved.push(h); placed.push(h);
|
|
1428
1454
|
placed.push(...[{ x: f.x, y: f.y - 3, w: f.w, h: 6 }, { x: f.x, y: f.y + f.h - 3, w: f.w, h: 6 }, { x: f.x - 3, y: f.y, w: 6, h: f.h }, { x: f.x + f.w - 3, y: f.y, w: 6, h: f.h }].map(r => ({ ...r, band: true }))); }); // lane headers and arrows stay clear
|
|
1429
1455
|
edges.filter(e => e.fixed).forEach(e => { routed.push(e); if (e.under || e[OWN_ROUTE]) { placeLabel(e); stats[e.how] = (stats[e.how] || 0) + 1; return; } if (e.lbl) { placed.push(e.lbl); reserved.push({ x: e.lbl.x - 3, y: e.lbl.y - 3, w: e.lbl.w + 6, h: e.lbl.h + 6 }); } stats.comb = (stats.comb || 0) + 1; });
|
|
1430
1456
|
order.forEach(e => {
|
|
@@ -1453,8 +1479,8 @@ function layoutPass(dg, map, room, last) { // room: { x: { col: px }, y: { row:
|
|
|
1453
1479
|
let bad = runRouting(order);
|
|
1454
1480
|
// rip-up and reroute: an edge that had to cross goes first next time; keep the best attempt (not when the view is laid
|
|
1455
1481
|
// out again with room for lines: that layout's tries seldom gained anything, and they cost a third of its time; with room for
|
|
1456
|
-
// a label it tries, as the layout it may replace did)
|
|
1457
|
-
for (let tries = 0; bad && (!last || room.lx) && tries < 4; tries++) {
|
|
1482
|
+
// a label it tries, as the layout it may replace did; none with reroute: false)
|
|
1483
|
+
for (let tries = 0; bad && dg.reroute !== false && (!last || room.lx) && tries < 4; tries++) {
|
|
1458
1484
|
const stuck = order.filter(e => e.how !== 'grid');
|
|
1459
1485
|
const next = [...stuck, ...order.filter(e => e.how === 'grid')];
|
|
1460
1486
|
const snap = order.map(e => [e, e.pts, e.lbl, e.how]), snapStats = stats, snapR = [routed.slice(), reserved.slice(), placed.slice()];
|
|
@@ -1675,7 +1701,9 @@ function routeAudit(geo, where, only, refs) { // only: a set of edges; counts ju
|
|
|
1675
1701
|
const box = n => ({ x: n.x, y: n.y, w: n.w, h: n.h });
|
|
1676
1702
|
function overlapIssues(geo, where, refs) {
|
|
1677
1703
|
const issues = [], nodes = geo.nodes, say = teller(issues, refs);
|
|
1678
|
-
|
|
1704
|
+
const inside = (a, b) => a.x >= b.x - 1 && a.y >= b.y - 1 && a.x + a.w <= b.x + b.w + 1 && a.y + a.h <= b.y + b.h + 1;
|
|
1705
|
+
const holds = (a, b) => (a.holder && inside(b, a)) || (b.holder && inside(a, b)); // a holder (a sequence's frame) may hold what it meets, not cross it
|
|
1706
|
+
for (let i = 0; i < nodes.length; i++) for (let j = i + 1; j < nodes.length; j++) if (hit(box(nodes[i]), box(nodes[j]), -1) && !holds(nodes[i], nodes[j])) say(`${where}: nodes "${nodes[i].label}" and "${nodes[j].label}" overlap`, nodes[i], nodes[j]);
|
|
1679
1707
|
return issues;
|
|
1680
1708
|
}
|
|
1681
1709
|
function leaveIssues(geo, where, refs) {
|
|
@@ -1698,7 +1726,7 @@ function leaveIssues(geo, where, refs) {
|
|
|
1698
1726
|
function auditSvg(svg, geo, where) {
|
|
1699
1727
|
const issues = overlapIssues(geo, where), R = box;
|
|
1700
1728
|
const T = [...svg.querySelectorAll('text[data-t]')].map(t => { let b; try { b = t.getBBox(); } catch (_) { return null; } return { t: t.dataset.t, o: t.dataset.o, txt: t.textContent, b: { x: b.x, y: b.y, w: b.width, h: b.height } }; }).filter(x => x && x.b.w);
|
|
1701
|
-
const nodes = geo.nodes, faces = Object.fromEntries([...svg.querySelectorAll('.node[data-face]')].map(g => [g.dataset.id, g.dataset.face.split(' ').map(Number)])); // a shape's face (0.15.0)
|
|
1729
|
+
const nodes = geo.nodes.filter(n => !n.holder), faces = Object.fromEntries([...svg.querySelectorAll('.node[data-face]')].map(g => [g.dataset.id, g.dataset.face.split(' ').map(Number)])); // a shape's face (0.15.0)
|
|
1702
1730
|
T.forEach(a => {
|
|
1703
1731
|
if (a.t === 'lt' || a.t === 'ft') {
|
|
1704
1732
|
nodes.forEach(n => { if (hit(a.b, R(n), -1)) issues.push(`${where}: lane "${a.txt}": header covers node "${n.label}"`); });
|
|
@@ -2023,7 +2051,7 @@ function mapContext(model, opts = {}) {
|
|
|
2023
2051
|
/* a layout may place only some cards: they keep their slots, the rest take their auto-layout slot or the next free
|
|
2024
2052
|
row below it (a complete layout is used as it is) */
|
|
2025
2053
|
function fillLayout(vid, L) {
|
|
2026
|
-
const ids = (V[vid].nodeIds || []).filter(i => N[i]);
|
|
2054
|
+
const bx = boxesOf(vid), ids = (V[vid].nodeIds || []).filter(i => N[i] && !bx.some(b => b.id === i)); // a box takes its place from what it holds
|
|
2027
2055
|
if (ids.every(i => Array.isArray(L[i]))) return L;
|
|
2028
2056
|
const auto = autoPos(vid), out = {};
|
|
2029
2057
|
ids.forEach(i => { if (Array.isArray(L[i])) out[i] = L[i]; });
|
|
@@ -2038,8 +2066,9 @@ function mapContext(model, opts = {}) {
|
|
|
2038
2066
|
lines come from and go to outside it too, where the pass before put them; then ordered by those lines as well. Its far
|
|
2039
2067
|
ends are items at their rows, in a column before its first (a line from the left) or after its last. A box's cards were
|
|
2040
2068
|
placed by its own lines alone, so a box fed from outside, and the lines that pass it, ran into the cards beside it. */
|
|
2041
|
-
|
|
2042
|
-
|
|
2069
|
+
// A typed view whose type gives boxes (notes/stage-2.md §3.2) passes them, T: { boxes, flip }; their ids are not cards
|
|
2070
|
+
function blockPos(vid, T) {
|
|
2071
|
+
const v = T ? flipView(vid, T.flip) : V[vid], boxes = T ? T.boxes : (v.containers || []).filter(c => c && c.id), ids = (v.nodeIds || []).filter(i => N[i] && !(T && boxes.some(b => b.id === i))), lp = loopsOf(v);
|
|
2043
2072
|
const fwd = lp.edges.filter((e, k) => !lp.back.has(k) && e.from !== e.to), byBox = Object.fromEntries(boxes.map(b => [b.id, b]));
|
|
2044
2073
|
const home = Object.fromEntries(ids.map(i => [i, (boxes.filter(b => (b.nodeIds || []).includes(i)).pop() || {}).id ?? null])); // the box a card is drawn in
|
|
2045
2074
|
const up = x => ids.includes(x) ? home[x] : byBox[x] && byBox[byBox[x].parent] ? byBox[x].parent : null; // the box a card or box sits in
|
|
@@ -2095,7 +2124,10 @@ function mapContext(model, opts = {}) {
|
|
|
2095
2124
|
}
|
|
2096
2125
|
// a typed view whose type lays it out (1.9): the plain flow's ranking, with the lines the type turns round for it
|
|
2097
2126
|
const ownLayout = vid => { if (D.schemaVersion !== 2) return null; const T = diagramOf(V[vid]); return T && T.layout && (T.layout.run || typeof T.layout === 'function') ? T : null; }; // version 1: a map
|
|
2098
|
-
|
|
2127
|
+
// its boxes (registry.js): the type's, from the model; a map's are its containers
|
|
2128
|
+
const boxesOf = vid => { const T = ownLayout(vid); return T && T.boxes ? arrOf(T.boxes(D, V[vid])).filter(b => b && b.id != null) : []; };
|
|
2129
|
+
const autoPos = vid => { const T = ownLayout(vid), bx = boxesOf(vid);
|
|
2130
|
+
return T ? bx.length ? blockPos(vid, { boxes: bx, flip: T.rankFlip }) : flowPos(vid, { flip: T.rankFlip }) : ((V[vid] || {}).containers || []).length ? blockPos(vid) : bestFlow(vid); };
|
|
2099
2131
|
const memos = opts.memos || Object.create(null); // per view, layoutTyped's memo, kept across update()s: an edit that moves nothing lays out nothing
|
|
2100
2132
|
const level = e => !/^(top|bottom)$/.test(e.fromSide) && !/^(top|bottom)$/.test(e.toSide); // a line into a card's top or bottom asks for no row
|
|
2101
2133
|
/* A small plain view is routed a few ways and keeps the one the layout check scores best: cards level with their
|
|
@@ -2115,8 +2147,9 @@ function mapContext(model, opts = {}) {
|
|
|
2115
2147
|
let scouting = false;
|
|
2116
2148
|
const fanIn = vid => { const k = {}; return viewEdges(V[vid] || {}).some(e => e.from !== e.to && (k[e.to] = (k[e.to] || 0) + 1) >= 6); };
|
|
2117
2149
|
const drawn = {}; // the way bestFlow kept, routed already: the view's next buildGeo takes it once instead of routing it again
|
|
2150
|
+
const flipView = (vid, flip) => flip ? { ...V[vid], edges: viewEdges(V[vid]).map(e => flip(e) ? { ...e, from: e.to, to: e.from } : e) } : V[vid];
|
|
2118
2151
|
function flowPos(vid, o = {}) {
|
|
2119
|
-
const v =
|
|
2152
|
+
const v = flipView(vid, o.flip);
|
|
2120
2153
|
const lp = loopsOf(v), fwd = lp.edges.filter((e, k) => !lp.back.has(k) && e.from !== e.to);
|
|
2121
2154
|
// cards with no line go in rows of their own under the view (#46), as wide as its columns; they stacked with the sources
|
|
2122
2155
|
const all = (v.nodeIds || []).filter(i => N[i]), lined = new Set(lp.edges.filter(e => e.from !== e.to).flatMap(e => [e.from, e.to]));
|
|
@@ -2193,7 +2226,7 @@ function mapContext(model, opts = {}) {
|
|
|
2193
2226
|
if (T) { // the type's layout (registry.js), in the host's measure (R2)
|
|
2194
2227
|
const L = T.layout, memo = memos[vid] ||= {}, k = !override && L.key ? L.key(D, v) : null;
|
|
2195
2228
|
if (k != null && memo.runKey === k) return memo.runGeo;
|
|
2196
|
-
const pos = override || (
|
|
2229
|
+
const pos = override || (L.pins === false ? {} : L.rank === false ? v.layout || {} : v.layout ? fillLayout(vid, v.layout) : (layoutCache[vid] ||= autoPos(vid))); // pins: false, no ranking either (notes/stage-3.md Q9)
|
|
2197
2230
|
const g = L.run ? L.run(D, v, { measure: M, pos, memo }) : L({ model: D, view: v, pos, memo });
|
|
2198
2231
|
if (k != null) Object.assign(memo, { runKey: k, runGeo: g });
|
|
2199
2232
|
return g;
|
|
@@ -2318,7 +2351,7 @@ function mapContext(model, opts = {}) {
|
|
|
2318
2351
|
const viewKey = vid => {
|
|
2319
2352
|
const v = V[vid], ids = (v.nodeIds || []).filter(i => N[i]), ns = ids.map(i => nodeIn(i, vid));
|
|
2320
2353
|
const icons = [...new Set(ns.map(n => n.icon).filter(Boolean))].map(k => (D.icons || {})[k]), who = [...new Set(ns.map(n => n.author).filter(Boolean))].map(k => (D.authors || {})[k]);
|
|
2321
|
-
return JSON.stringify([{ ...v, shapes: undefined }, ns, viewEdges(v), ids.map(i => [kidCount(i, vid), opensOf(i, vid)]), icons, who]); // a view's notation lays nothing out
|
|
2354
|
+
return JSON.stringify([{ ...v, shapes: undefined }, ns, viewEdges(v), ids.map(i => [kidCount(i, vid), opensOf(i, vid)]), icons, who, boxesOf(vid)]); // a view's notation lays nothing out; a type's boxes do
|
|
2322
2355
|
};
|
|
2323
2356
|
/* ---------- unfolding in place (#71): a stacked card opens into a box on the view it is on ----------
|
|
2324
2357
|
The card becomes a box with its id and name, holding its inner view's own cards: those not on this view already (the
|
|
@@ -2752,7 +2785,8 @@ export function diff(before, after) {
|
|
|
2752
2785
|
if (h === 'added') { const ins = lines.filter(e => e.to === n.id && how.get(e) === 'added').map(e => name(e.from)), outs = lines.filter(e => e.from === n.id && how.get(e) === 'added').map(e => name(e.to));
|
|
2753
2786
|
said.push(`adds ${name(n.id)}` + (ins.length === 1 && outs.length === 1 ? ` between ${ins[0]} and ${outs[0]}` : (ins.length ? `, from ${andList(ins)}` : '') + (outs.length ? `, to ${andList(outs)}` : ''))); }
|
|
2754
2787
|
if (h === 'removed') said.push(`removes ${name(n.id)}`);
|
|
2755
|
-
if (h === 'changed') { const [o, x] = was.get(n), keys = diffKeys([o, x]), rest = keys.filter(k => k !== 'label');
|
|
2788
|
+
if (h === 'changed') { const [o, x] = was.get(n), keys = diffKeys([o, x]), rest = keys.filter(k => k !== 'label'), p0 = o.parent ?? null, p1 = x.parent ?? null;
|
|
2789
|
+
if (keys.join() === 'parent' && p0 !== p1 && A.schemaVersion === 2 && out.schemaVersion === 2) return said.push(`moves ${name(n.id)} ${p1 != null ? 'into ' + name(p1) : 'out of ' + name(p0)}`); // a re-own (schema version 2; absent or null is no owner)
|
|
2756
2790
|
said.push((keys.includes('label') ? `renames ${o.label || o.id} to ${x.label || x.id}` : `changes ${name(n.id)}`) + (rest.length ? ` (${rest.join(', ')})` : '')); } });
|
|
2757
2791
|
lines.forEach(e => { const h = how.get(e); if (!h || cardHow.get(e.from) === h && h !== 'changed' || cardHow.get(e.to) === h && h !== 'changed') return;
|
|
2758
2792
|
const ends = `${name(e.from)} to ${name(e.to)}`, lab = e.label ? ` (${e.label})` : '';
|
|
@@ -3465,7 +3499,10 @@ class Camera {
|
|
|
3465
3499
|
core.Node and core.Line: a typed view's lines are typed, or validate() refuses them.
|
|
3466
3500
|
|
|
3467
3501
|
Keys on a typed view: F2 renames, N adds the palette's first element, L connects (arrows or type a name, Enter),
|
|
3468
|
-
Alt + arrows move the card's pin, Delete hides it from this view, Shift + Delete deletes it from the model.
|
|
3502
|
+
Alt + arrows move the card's pin, Delete hides it from this view, Shift + Delete deletes it from the model. A box the type
|
|
3503
|
+
gives (registry.js boxes) is selected as a container and takes F2, Delete and the panel as a card does; a card dropped in
|
|
3504
|
+
another box, or the Owner field, gives it a new owner (reparent). L on a line starts at its far end, with edits.connect;
|
|
3505
|
+
layout.pins false: no pin. src/typed/edit.js takes gestures over (TYPED.edit, registry.js). */
|
|
3469
3506
|
|
|
3470
3507
|
const GENERIC_NODE = { type: 'core.Node', label: 'Node' }, GENERIC_LINE = { type: 'core.Line', label: 'Line' };
|
|
3471
3508
|
const isRec = x => x !== null && typeof x === 'object' && !Array.isArray(x);
|
|
@@ -3475,7 +3512,7 @@ function elementsOf(m) {
|
|
|
3475
3512
|
const at = new Map(), put = (x, kind, owner) => { if (!isRec(x) || typeof x.id !== 'string' || at.has(x.id)) return;
|
|
3476
3513
|
at.set(x.id, { x, kind, owner }); arrOf(x.members).forEach(y => put(y, 'member', x)); };
|
|
3477
3514
|
arrOf(m && m.nodes).forEach(n => put(n, 'node')); arrOf(m && m.edges).forEach(e => put(e, 'edge'));
|
|
3478
|
-
arrOf(m && m.views).forEach(v => { if (isRec(v)) { arrOf(v.nodes).forEach(n => put(n, 'node')); arrOf(v.edges).forEach(e => put(e, 'edge')); } });
|
|
3515
|
+
arrOf(m && m.views).forEach(v => { if (isRec(v)) { [...arrOf(v.nodes), ...arrOf(TYPED.edit && v.fragments)].forEach(n => put(n, 'node')); arrOf(v.edges).forEach(e => put(e, 'edge')); } });
|
|
3479
3516
|
return at;
|
|
3480
3517
|
}
|
|
3481
3518
|
|
|
@@ -3507,18 +3544,26 @@ function typedEditor(api) {
|
|
|
3507
3544
|
const b = String(base).toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '') || 'el'; let k = 1; while (used.has(b + k)) k++; return b + k; };
|
|
3508
3545
|
const typeOf = x => typeof x.type === 'string' && x.type ? x.type : null; // a model may hold anything in type
|
|
3509
3546
|
const typed = (t, base) => { const ty = typeOf(t); return { id: newId(ty ? ty.split('.').pop() : base), ...(ty ? { type: ty } : {}) }; };
|
|
3510
|
-
const idsOf = r => !r ? [] : r.type === 'node' ? [r.id] : r.type === 'edge' ? arrOf(r.ids).filter(x => !String(x).includes('->')) : []; // not a positional line id
|
|
3547
|
+
const idsOf = r => !r ? [] : r.type === 'node' || r.type === 'container' && elementsOf(model()).has(r.id) ? [r.id] : r.type === 'edge' ? arrOf(r.ids).filter(x => !String(x).includes('->')) : []; // not a positional line id
|
|
3511
3548
|
const selId = (at = elementsOf(model())) => { const id = idsOf(api.sel())[0]; return id && at.has(id) ? id : null; };
|
|
3512
3549
|
const top = (id, at = elementsOf(model())) => { let e = at.get(id); while (e && e.owner) e = at.get(e.owner.id); return e ? e.x.id : id; }; // a member: its node
|
|
3513
3550
|
|
|
3514
3551
|
/* the gestures */
|
|
3515
3552
|
const label = (ref, value) => { const at = elementsOf(model()), id = idsOf(ref).find(x => at.has(x)); if (id && can('rename')) emit([{ op: 'set', id, path: 'label', value }]); };
|
|
3516
|
-
const pin = (id, c, r) => can('move') && Number.isFinite(c) && Number.isFinite(r) && c >= 0 && r >= 0 && emit([{ op: 'set', view: api.view(), path: ['layout', id], value: [c, r] }]);
|
|
3517
|
-
|
|
3518
|
-
|
|
3519
|
-
|
|
3553
|
+
const pin = (id, c, r) => can('move') && (def().layout || {}).pins !== false && Number.isFinite(c) && Number.isFinite(r) && c >= 0 && r >= 0 && emit([{ op: 'set', view: api.view(), path: ['layout', id], value: [c, r] }]);
|
|
3554
|
+
// a drop (notes/stage-2.md §8): in the box it is drawn in, its pin; into another box, or out of every box, a new owner (the
|
|
3555
|
+
// box's, else the view's subject or none), in one transaction. The view is then laid out again (§3.2), so a slot read off the
|
|
3556
|
+
// picture before would land elsewhere and stretch a box over cards it does not hold: no pin, and a pin it had is removed.
|
|
3557
|
+
// into, from: the box under the drop, the box it was in
|
|
3558
|
+
// a re-own, by a drop or the Owner field: reparent, and the removal of a pin it had
|
|
3559
|
+
const reown = (id, parent) => { const L = view().layout; return [{ op: 'reparent', id, parent }, ...(isRec(L) && Object.hasOwn(L, id) ? [{ op: 'set', view: api.view(), path: ['layout', id], value: undefined }] : [])]; };
|
|
3560
|
+
const drop = (n, hint, into = null, from = null) => into === from ? (hint.col !== n.col || hint.row !== n.row) && pin(n.id, hint.col, hint.row)
|
|
3561
|
+
: can('move') && emit(reown(n.id, into ?? view().subject ?? null));
|
|
3562
|
+
const picks = () => [...new Set(api.picked().flatMap(idsOf))];
|
|
3563
|
+
function remove(fromModel, ids = picks()) { // Delete: out of this view; Shift + Delete: out of the model (risks.md E5). One transaction for the selection (E2)
|
|
3564
|
+
if (!can('delete') || !ids.length) return false;
|
|
3520
3565
|
if (fromModel) return emit(ids.map(id => ({ op: 'delete', id })));
|
|
3521
|
-
const v = view(), own = new Set([...arrOf(v.nodes), ...arrOf(v.edges)].filter(isRec).map(x => x.id)), at = elementsOf(model()), out = [];
|
|
3566
|
+
const v = view(), own = new Set([...arrOf(v.nodes), ...arrOf(v.edges), ...arrOf(TYPED.edit && v.fragments)].filter(isRec).map(x => x.id)), at = elementsOf(model()), out = [];
|
|
3522
3567
|
ids.forEach(id => { const e = at.get(id);
|
|
3523
3568
|
if (own.has(id)) out.push({ op: 'delete', id }); // lives in this view only: hiding it is deleting it
|
|
3524
3569
|
else if (e && e.kind === 'edge' && !Array.isArray(v.edges)) api.status('This view shows every line between its cards: Shift + Delete deletes the line');
|
|
@@ -3530,12 +3575,12 @@ function typedEditor(api) {
|
|
|
3530
3575
|
api.editor({ x: cam.s.cx - 80, y: cam.s.cy - 13, w: 160, h: 26 }, '', value => emit([{ op: 'add', element: { ...typed(t, 'node'), label: value }, view: api.view() }]));
|
|
3531
3576
|
}
|
|
3532
3577
|
const hook = k => { const E = def().edits; return isRec(E) && typeof E[k] === 'function' ? E[k] : null; }; // registry.js edits
|
|
3533
|
-
const addLine = (from, to, t) => { const c = hook('connect'); // at
|
|
3534
|
-
|
|
3535
|
-
function connect(from, to) { // the relationship picker, when the type has more than one kind of line
|
|
3536
|
-
if (!can('add') || !from || !to || from === to) return;
|
|
3578
|
+
const addLine = (from, to, t, at) => { const c = hook('connect'), dflt = () => emit([{ op: 'add', element: { ...typed(t, 'line'), from, to, ...(isRec(t.props) ? { props: structuredClone(t.props) } : {}) }, view: api.view() }]); // at: a slot (src/typed/edit.js)
|
|
3579
|
+
return c ? (I.ran || (r => emit(arrOf(r))))(c(model(), view(), from, to, at, t), dflt) : dflt(); }; // I.ran: src/typed/edit.js's null and { error }
|
|
3580
|
+
function connect(from, to, ev, at = null) { // the relationship picker, when the type has more than one kind of line
|
|
3581
|
+
if (!can('add') || !from || !to || from === to && !I.self) return;
|
|
3537
3582
|
const types = lineTypes();
|
|
3538
|
-
if (types.length === 1) addLine(from, to, types[0]); else picker(to, types, t => addLine(from, to, t));
|
|
3583
|
+
if (types.length === 1) addLine(from, to, types[0], at); else picker(to, types, t => addLine(from, to, t, at));
|
|
3539
3584
|
}
|
|
3540
3585
|
function closeMenu(back) { const m = menu; if (!m) return false; menu = null; m.remove(); if (back) api.focus(); api.touch(); return true; }
|
|
3541
3586
|
function picker(at, types, done) {
|
|
@@ -3571,10 +3616,10 @@ function typedEditor(api) {
|
|
|
3571
3616
|
function linkKey(e) {
|
|
3572
3617
|
const K = e.key;
|
|
3573
3618
|
if (K === 'Escape') { endLink(); say('No line added'); return true; }
|
|
3574
|
-
if (K === 'Enter') { const { from, to } = link; if (!to || to === from) { say('Pick another card first'); return true; } endLink(); connect(from, to); return true; }
|
|
3619
|
+
if (K === 'Enter') { const { from, to } = link; if (!to || to === from && !I.self) { say('Pick another card first'); return true; } endLink(); I.connect(from, to); return true; }
|
|
3575
3620
|
if (K.startsWith('Arrow')) { const g = api.geo(), n = api.nearest(g.byId[link.to || link.from], K); if (n) { link.to = n.id; mark(); } return true; }
|
|
3576
3621
|
if (K.length === 1 && !e.altKey) { const now = performance.now(); link.find = (now - link.t < 900 ? link.find : '') + K.toLowerCase(); link.t = now;
|
|
3577
|
-
const
|
|
3622
|
+
const ns = api.geo().nodes.filter(n => String(api.nameOf(n.id)).toLowerCase().startsWith(link.find)), hit = ns.find(n => n.id !== link.from) || I.self && ns[0];
|
|
3578
3623
|
if (hit) { link.to = hit.id; mark(); } return true; }
|
|
3579
3624
|
return false;
|
|
3580
3625
|
}
|
|
@@ -3584,11 +3629,12 @@ function typedEditor(api) {
|
|
|
3584
3629
|
if (e.altKey) { const d = { ArrowLeft: [-1, 0], ArrowRight: [1, 0], ArrowUp: [0, -1], ArrowDown: [0, 1] }[K], n = s && s.type === 'node' && api.geo().byId[s.id];
|
|
3585
3630
|
if (!d || !n) return false; if (Number.isFinite(n.col)) pin(n.id, n.col + d[0], n.row + d[1]); return true; }
|
|
3586
3631
|
if (K === 'F2' && s && can('rename')) { api.rename(); return true; }
|
|
3587
|
-
if ((K === 'n' || K === 'N') && can('add')) { addNode(0); return true; }
|
|
3588
|
-
|
|
3589
|
-
|
|
3590
|
-
|
|
3591
|
-
|
|
3632
|
+
if ((K === 'n' || K === 'N') && can('add')) { I.addNode(0); return true; }
|
|
3633
|
+
const from = s && (s.type === 'node' ? s.id : s.type === 'edge' && hook('connect') && (api.geo().edges.find(x => idsOf(s).includes(x._id)) || {}).to);
|
|
3634
|
+
if ((K === 'l' || K === 'L') && can('add') && from) {
|
|
3635
|
+
link = { from, to: null, find: '', t: 0 }; api.touch();
|
|
3636
|
+
say(`Line from ${api.nameOf(from)}: arrows or type a name, Enter to choose, Escape to stop`); return true; }
|
|
3637
|
+
if (K === 'Delete' && s) { I.remove(e.shiftKey); return true; }
|
|
3592
3638
|
return false;
|
|
3593
3639
|
}
|
|
3594
3640
|
|
|
@@ -3619,6 +3665,12 @@ function typedEditor(api) {
|
|
|
3619
3665
|
if (f.kind === 'bool') h += `<label class="um-check"><input type="checkbox" data-f="p:${esc(f.key)}"${key}${v ? ' checked' : ''}${off}>${esc(name)}</label>`;
|
|
3620
3666
|
else if (f.kind === 'enum') h += `<label>${esc(name)}<select data-f="p:${esc(f.key)}"${key}${off}>${['', ...arrOf(f.options)].map(o => `<option value="${esc(o)}"${o === v ? ' selected' : ''}>${esc(o)}</option>`).join('')}</select></label>`;
|
|
3621
3667
|
else h += text('p:' + f.key, f.kind === 'list' ? arrOf(v).join(', ') : v ?? '', name, key + (f.kind === 'list' ? ' data-list' : '')); });
|
|
3668
|
+
// the Owner field (notes/stage-2.md §8): the owners the type offers, less the element and what it holds, and none
|
|
3669
|
+
const O = def().owners, os = e.kind !== 'member' && typeof O === 'function' ? O(model(), x) : null, under = id => { for (let k = 0, p = id; p != null && k < 64; k++, p = (at.get(p) || { x: {} }).x.parent) if (p === x.id) return true; return false; };
|
|
3670
|
+
// its owner is offered too when the type does not offer it (a class in a class), so the field says it and "none" is a change
|
|
3671
|
+
const offer = Array.isArray(os) && [{ id: '', label: 'none' }, ...os.filter(o => isRec(o) && o.id != null && !under(o.id))];
|
|
3672
|
+
if (offer && x.parent != null && !offer.some(o => o.id === x.parent)) offer.push({ id: x.parent, label: (at.get(x.parent) || { x: {} }).x.label });
|
|
3673
|
+
if (offer) h += `<label>Owner<select data-f="owner" data-owner${can('move') ? '' : ' disabled'}>${offer.map(o => `<option value="${esc(o.id)}"${o.id === (x.parent ?? '') ? ' selected' : ''}>${esc(o.label ?? o.id)}</option>`).join('')}</select></label>`;
|
|
3622
3674
|
const rest = schema.length ? [] : Object.entries(P); // no schema: what it holds, to read
|
|
3623
3675
|
if (rest.length) h += '<dl>' + rest.map(([k, v]) => `<dt>${esc(k)}</dt><dd>${esc(typeof v === 'string' ? v : JSON.stringify(v))}</dd>`).join('') + '</dl>';
|
|
3624
3676
|
const M = def().member;
|
|
@@ -3657,6 +3709,7 @@ function typedEditor(api) {
|
|
|
3657
3709
|
const r = M && M.add(value, x.x); if (r && r.error) return bad(r.error);
|
|
3658
3710
|
const prev = arrOf(x.x.members).filter(isRec).pop(); t.value = ''; t.removeAttribute('aria-invalid');
|
|
3659
3711
|
return emit([{ op: 'add', element: r ? { ...typed(r, 'member'), label: r.label, ...(Object.keys(r.props).length ? { props: r.props } : {}) } : { ...typed(prev || {}, 'member'), label: value }, owner: id }]); }
|
|
3712
|
+
if (d.owner != null) { const p = t.value || null; return id && can('move') && p !== (at.get(id).x.parent ?? null) && emit(reown(id, p)); }
|
|
3660
3713
|
if (!can('rename') || (!id && d.m == null)) return;
|
|
3661
3714
|
let value = t.type === 'checkbox' ? t.checked : t.value.trim();
|
|
3662
3715
|
if (t.type !== 'checkbox') { if (t.value === d.was) return;
|
|
@@ -3680,12 +3733,12 @@ function typedEditor(api) {
|
|
|
3680
3733
|
panel.className = 'um-editor'; panel.setAttribute('aria-label', 'Edit this diagram');
|
|
3681
3734
|
panel.innerHTML = '<section class="um-pal" aria-label="Palette"></section><section class="um-props" aria-label="Properties"></section><section class="um-probs" aria-label="Problems"></section><section class="um-els" aria-label="Elements"></section>';
|
|
3682
3735
|
panel.addEventListener('click', e => { const b = e.target.closest('button, [data-orphans]'); if (!b) return; const d = b.dataset;
|
|
3683
|
-
if (d.add != null) addNode(+d.add);
|
|
3736
|
+
if (d.add != null) I.addNode(+d.add);
|
|
3684
3737
|
else if (d.go != null) { if (!d.go || !api.selectId(top(d.go))) api.status('Not drawn in this view'); }
|
|
3685
3738
|
else if (d.show != null) show(d.show);
|
|
3686
|
-
else if (d.del != null)
|
|
3739
|
+
else if (d.del != null) I.remove(true, [d.del]);
|
|
3687
3740
|
else if (d.up != null || d.down != null) moveMember(d.up ?? d.down, d.up != null ? -1 : 1);
|
|
3688
|
-
else if (d.hide != null || d.kill != null) remove(d.kill != null);
|
|
3741
|
+
else if (d.hide != null || d.kill != null) I.remove(d.kill != null);
|
|
3689
3742
|
else if (d.orphans != null) { orphans = b.checked; put('.um-els', outline()); } });
|
|
3690
3743
|
panel.addEventListener('change', e => { if (e.target.dataset.orphans == null) commit(e.target); });
|
|
3691
3744
|
panel.addEventListener('keydown', e => { e.stopPropagation(); const t = e.target, K = e.key; // the panel's keys stay in it
|
|
@@ -3702,12 +3755,14 @@ function typedEditor(api) {
|
|
|
3702
3755
|
refresh();
|
|
3703
3756
|
}
|
|
3704
3757
|
|
|
3705
|
-
|
|
3758
|
+
const I = { on, key, label, drop, connect, mount, refresh, addNode, remove,
|
|
3706
3759
|
paint: () => { if (panel && on()) put('.um-props', props()); },
|
|
3707
|
-
add: () => addNode(0),
|
|
3760
|
+
add: () => I.addNode(0),
|
|
3708
3761
|
cancel: () => { const a = endLink(), b = closeMenu(false); return a || b; },
|
|
3709
3762
|
linking: () => !!link || !!menu,
|
|
3710
3763
|
width: () => panel ? panel.offsetWidth + 8 : 0 };
|
|
3764
|
+
if (TYPED.edit) TYPED.edit(I, { api, model, view, def, hook, emit, typed, nodeTypes, picker, picks });
|
|
3765
|
+
return I;
|
|
3711
3766
|
}
|
|
3712
3767
|
const h = (html) => { const t = document.createElement('template'); t.innerHTML = html.trim(); return t.content.firstElementChild; };
|
|
3713
3768
|
|
|
@@ -3802,14 +3857,15 @@ export function render(container, model, options = {}) {
|
|
|
3802
3857
|
const upOf = () => (P.upTarget ? P.upTarget() : trail.length > 1 ? trail[trail.length - 2] : (ctx.V[cur] || {}).parent) || null;
|
|
3803
3858
|
const nodeRef = id => ({ type: 'node', id, view: cur });
|
|
3804
3859
|
const copyRef = r => r && { ...r, ...(Array.isArray(r.ids) ? { ids: [...r.ids] } : {}) }; // a host gets copies: the map's Refs stay its own
|
|
3805
|
-
const picked = () => sel ? [...more.map(
|
|
3860
|
+
const picked = () => sel ? [...more, sel].map(copyRef) : []; // every selected thing, the latest last (more: the others' refs)
|
|
3806
3861
|
const say = t => { const l = root.querySelector('.um-live'); if (l) l.textContent = t; };
|
|
3862
|
+
const nodeG = id => [...svg.querySelectorAll('.node')].find(x => x.dataset.id === id), ringOf = id => [...svg.querySelectorAll('.node-key')].find(x => x.dataset.node === id); // no CSS.escape: jsdom has none
|
|
3807
3863
|
const nameOf = id => ctx.nodeIn(id, cur).label || ((geo.families || []).find(f => f.id === id) || {}).label || id;
|
|
3808
3864
|
let said = '[]'; // the selection last reported: a select event means it changed, or a click asked again (again)
|
|
3809
3865
|
const emitSel = (again = false) => {
|
|
3810
3866
|
const now = JSON.stringify(picked()); if (now === said && !again) return; said = now;
|
|
3811
|
-
say(!sel ? 'Nothing selected' : sel.type === 'node' ? nameOf(sel.id)
|
|
3812
|
-
:
|
|
3867
|
+
say(!sel ? 'Nothing selected' : (sel.type === 'node' ? nameOf(sel.id) : sel.type === 'edge' ? (e => e ? `Line from ${nameOf(e.from)} to ${nameOf(e.to)}` : 'Line')(geo.edges[edgeIndex(sel)])
|
|
3868
|
+
: `Box ${(svg.querySelector(`.family[data-family="${CSS.escape(sel.id)}"] .family-title`) || {}).textContent || sel.id}`) + (more.length ? `, ${more.length + 1} ${picked().every(r => r.type === 'node') ? 'cards ' : ''}selected` : ', selected'));
|
|
3813
3869
|
emit('select', { target: copyRef(sel), targets: picked() });
|
|
3814
3870
|
};
|
|
3815
3871
|
const edgeRef = e => ({ type: 'edge', ids: edgeRefIds(e), view: cur });
|
|
@@ -3948,25 +4004,25 @@ export function render(container, model, options = {}) {
|
|
|
3948
4004
|
if (can('add') && sel && sel.type === 'node' && geo.byId[sel.id]) { const n = geo.byId[sel.id];
|
|
3949
4005
|
svg.insertAdjacentHTML('beforeend', `<g class="um-edit um-handle" data-from="${esc(n.id)}"><title>Drag to another card to add a line</title><circle cx="${n.x + n.w + 12}" cy="${n.y + n.h / 2}" r="6"/></g>`); }
|
|
3950
4006
|
svg.querySelectorAll('.sel,.nb,.hot').forEach(g => g.classList.remove('sel', 'nb', 'hot')); stage.classList.remove('focusing');
|
|
3951
|
-
|
|
3952
|
-
|
|
4007
|
+
const pk = picked(), ns = pk.filter(r => r.type === 'node').map(r => r.id), li = pk.filter(r => r.type === 'edge').map(edgeIndex);
|
|
4008
|
+
svg.querySelectorAll('.line-key').forEach(k => k.setAttribute('aria-pressed', li.includes(+k.dataset.i))); svg.querySelectorAll('.node, .node-key').forEach(g => g.setAttribute('aria-pressed', ns.includes(g.dataset.id ?? g.dataset.node)));
|
|
3953
4009
|
if (!sel) return;
|
|
3954
4010
|
if (sel.type === 'node') {
|
|
3955
4011
|
const nid = sel.id, ups = new Set(), downs = new Set();
|
|
3956
4012
|
geo.edges.forEach(e => { if (e.to === nid) ups.add(e.from); if (e.from === nid) downs.add(e.to); });
|
|
3957
|
-
svg.querySelectorAll('.node').forEach(g => { const id = g.dataset.id; g.classList.toggle('sel',
|
|
4013
|
+
svg.querySelectorAll('.node').forEach(g => { const id = g.dataset.id; g.classList.toggle('sel', ns.includes(id)); g.classList.toggle('nb', ups.has(id) || downs.has(id)); });
|
|
3958
4014
|
svg.querySelectorAll('.edge-g').forEach(g => g.classList.toggle('hot', g.dataset.from === nid || g.dataset.to === nid));
|
|
3959
4015
|
svg.querySelectorAll('.e-lab').forEach(g => { const e = geo.edges[g.dataset.i]; g.classList.toggle('hot', e.from === nid || e.to === nid); });
|
|
3960
4016
|
svg.querySelectorAll('.n-port[data-edges]').forEach(p => p.classList.toggle('hot', p.dataset.edges.split(',').some(i => geo.edges[i] && (geo.edges[i].from === nid || geo.edges[i].to === nid)))); // not an ease's fading copy (#67)
|
|
3961
4017
|
stage.classList.add('focusing');
|
|
3962
4018
|
} else if (sel.type === 'edge') {
|
|
3963
|
-
const
|
|
3964
|
-
svg.querySelectorAll(
|
|
3965
|
-
svg.querySelectorAll('.node').forEach(g => g.classList.toggle('nb', g.dataset.id === e.from || g.dataset.id === e.to));
|
|
4019
|
+
const e = geo.edges[edgeIndex(sel)];
|
|
4020
|
+
svg.querySelectorAll('.node').forEach(g => { g.classList.toggle('sel', ns.includes(g.dataset.id)); g.classList.toggle('nb', g.dataset.id === e.from || g.dataset.id === e.to); }); // a card selected before the line stays selected
|
|
3966
4021
|
stage.classList.add('focusing');
|
|
3967
4022
|
} else if (sel.type === 'container') {
|
|
3968
4023
|
const f = svg.querySelector(`.family[data-family="${CSS.escape(sel.id)}"]`); if (f) f.classList.add('sel');
|
|
3969
4024
|
}
|
|
4025
|
+
li.forEach(i => svg.querySelectorAll(`.edge-g[data-i="${i}"], .e-lab[data-i="${i}"]`).forEach(g => g.classList.add('hot'))); // the lines selected
|
|
3970
4026
|
}
|
|
3971
4027
|
function select(ref, o = {}) {
|
|
3972
4028
|
if (ref && (ref.type === 'chip' || ref.type === 'badge')) ref = ref.container != null ? { type: 'container', id: ref.container } : nodeRef(ref.node); // a chip or badge: its card (box)
|
|
@@ -3990,11 +4046,14 @@ export function render(container, model, options = {}) {
|
|
|
3990
4046
|
return best;
|
|
3991
4047
|
}
|
|
3992
4048
|
|
|
3993
|
-
/* several cards: Shift (or Cmd / Ctrl) + click adds a card to the selection, or takes it out
|
|
3994
|
-
|
|
3995
|
-
|
|
3996
|
-
|
|
3997
|
-
|
|
4049
|
+
/* several cards: Shift (or Cmd / Ctrl) + click adds a card to the selection, or takes it out; on a typed view a line too, and
|
|
4050
|
+
Shift + Enter on its ring (notes/stage-3.md §5.1). ref: a nodeRef or edgeRef */
|
|
4051
|
+
const kinds = () => typed() ? ['node', 'edge'] : ['node'], refKey = r => JSON.stringify([r.type, r.id, r.ids]);
|
|
4052
|
+
function toggle(ref) {
|
|
4053
|
+
const k = refKey(ref);
|
|
4054
|
+
if (k === refKey(sel)) { if (!more.length) return select(null); sel = more.pop(); }
|
|
4055
|
+
else if (more.some(r => refKey(r) === k)) more = more.filter(r => refKey(r) !== k);
|
|
4056
|
+
else { more.push(sel.type === 'node' ? nodeRef(sel.id) : sel); sel = ref; }
|
|
3998
4057
|
sub = null; selectNew = false; paint(); emitSel();
|
|
3999
4058
|
}
|
|
4000
4059
|
|
|
@@ -4121,7 +4180,7 @@ export function render(container, model, options = {}) {
|
|
|
4121
4180
|
if (cur && ctx.geoCache[cur] && ctx.geoCache[cur].shapes !== noteFor(cur)) {
|
|
4122
4181
|
const keep = sel, keepMore = more, touched = cam.touched;
|
|
4123
4182
|
mount(cur); cam.set({}); cam.touched = touched;
|
|
4124
|
-
if (keep && exists(keep)) { sel = keep; more =
|
|
4183
|
+
if (keep && exists(keep)) { sel = keep; more = keepMore.filter(exists); paint(); }
|
|
4125
4184
|
emitSel(); }
|
|
4126
4185
|
touch(); return true;
|
|
4127
4186
|
}
|
|
@@ -4137,7 +4196,7 @@ export function render(container, model, options = {}) {
|
|
|
4137
4196
|
|
|
4138
4197
|
/* keys on the canvas; the page forwards the same keys when the focus is elsewhere */
|
|
4139
4198
|
function key(e) {
|
|
4140
|
-
const k = e.key, g = e.target && e.target.closest && e.target.closest('.node');
|
|
4199
|
+
const k = e.key, nk = e.target && e.target.closest && e.target.closest('.node-key'), g = nk ? nodeG(nk.dataset.node) : e.target && e.target.closest && e.target.closest('.node'); // a node's ring (svg.js): the node
|
|
4141
4200
|
if (k === 'Escape' && cancelGesture()) return true; // Escape stops a gesture in progress before anything else
|
|
4142
4201
|
if (story) { const r = storyKey(e); if (r != null) return r; } // a story's keys first (#69)
|
|
4143
4202
|
if (k === 'Escape' && trip && trip.kind) { const was = trip.kind; endTrip(); say(was === 'replay' ? 'Replay stopped' : 'Tour stopped'); return true; } // Esc ends a tour or a replay first
|
|
@@ -4145,7 +4204,8 @@ export function render(container, model, options = {}) {
|
|
|
4145
4204
|
if (e.altKey) return false;
|
|
4146
4205
|
const lk = e.target && e.target.closest && e.target.closest('.line-key'), tk = e.target && e.target.closest && e.target.closest('.tab-key, .family [data-badge]');
|
|
4147
4206
|
const ck = e.target && e.target.closest && e.target.closest('.chip-key'), card = ck && [...svg.querySelectorAll('.node')].find(x => x.dataset.id === ck.dataset.node);
|
|
4148
|
-
|
|
4207
|
+
const add = k === 'Enter' && e.shiftKey && sel && kinds().includes(sel.type) && typed(); // Shift + Enter: into the selection, or out of it
|
|
4208
|
+
if (k === 'Enter' && lk && geo.edges[+lk.dataset.i]) add ? toggle(edgeRef(geo.edges[+lk.dataset.i])) : select(edgeRef(geo.edges[+lk.dataset.i]), { again: true }); // as a click on the line (#43)
|
|
4149
4209
|
else if (k === 'Enter' && tk) { const fam = tk.closest('.family'), ref = badgeRef(tk.dataset.node, fam ? fam.dataset.family : null, +tk.dataset.badge); if (activate(ref, e)) select(ref); }
|
|
4150
4210
|
else if (k === 'Enter' && card) { const ref = chipRef(card, +ck.dataset.chip); if (activate(ref, e)) select(ref); } // as a click on the chip
|
|
4151
4211
|
else if (k === 'e' || k === 'E') return gesture(g ? g.dataset.id : sel && (sel.type === 'node' || sel.type === 'container') ? sel.id : null, 'key'); // unfold here, or fold back (#71)
|
|
@@ -4155,11 +4215,11 @@ export function render(container, model, options = {}) {
|
|
|
4155
4215
|
else if (k === '1') cam.actual();
|
|
4156
4216
|
else if (k === 'Enter' && (g || (sel && sel.type === 'node'))) { // a selected card opens its inner view; without one it is selected again, as a click does
|
|
4157
4217
|
const id = g ? g.dataset.id : sel.id;
|
|
4158
|
-
if (sel && sel.type === 'node' && sel.id === id && ctx.opensOf(id, cur)) open(id); else select(nodeRef(id), { again: true }); }
|
|
4218
|
+
if (add) toggle(nodeRef(id)); else if (sel && sel.type === 'node' && sel.id === id && ctx.opensOf(id, cur)) open(id); else select(nodeRef(id), { again: true }); }
|
|
4159
4219
|
else if (k === 'Escape' || k === 'Backspace') { if (sel) select(null); else up(); }
|
|
4160
4220
|
else if ((k === 'f' || k === 'F') && !reducedMotion()) setFlow(!stage.classList.contains('flowing')); // under reduced motion no flow is drawn, and ≋ isn't offered
|
|
4161
4221
|
else if (k.startsWith('Arrow') && g && geo.byId[g.dataset.id]) { const to = nearest(geo.byId[g.dataset.id], k); if (!to) return false;
|
|
4162
|
-
|
|
4222
|
+
(ringOf(to.id) || nodeG(to.id)).focus({ preventScroll: true }); cam.reveal({ x: to.x, y: to.y, w: to.w, h: to.h }); }
|
|
4163
4223
|
else if (k.startsWith('Arrow')) { const d = 80, m = { ArrowLeft: [d, 0], ArrowRight: [-d, 0], ArrowUp: [0, d], ArrowDown: [0, -d] }[k]; if (!m) return false; cam.pan(m[0], m[1], true); }
|
|
4164
4224
|
else return false;
|
|
4165
4225
|
readerMoved(); // a key the map acted on: a story playing waits
|
|
@@ -4221,14 +4281,16 @@ export function render(container, model, options = {}) {
|
|
|
4221
4281
|
function hintAt(x, y) { // a point in the view, and on a column view its column and row
|
|
4222
4282
|
const h = { x: Math.round(x), y: Math.round(y) };
|
|
4223
4283
|
if (!gridView()) return h;
|
|
4284
|
+
const T = typed() && (ctx.V[cur] || {}).direction === 'down', X = n => T ? n.y + n.h / 2 : n.x + n.w / 2, Y = n => T ? n.x : n.y; // a typed view laid out down: its columns run down
|
|
4285
|
+
if (T) [x, y] = [y, x];
|
|
4224
4286
|
const cols = [...new Set(geo.nodes.filter(n => !n.aside).map(n => n.col))].sort((a, b) => a - b); // the row of cards with no line keeps off the columns
|
|
4225
|
-
const mid = c => { const ns = geo.nodes.filter(n => n.col === c && !n.aside); return ns.reduce((a, n) => a + n
|
|
4287
|
+
const mid = c => { const ns = geo.nodes.filter(n => n.col === c && !n.aside); return ns.reduce((a, n) => a + X(n), 0) / ns.length; };
|
|
4226
4288
|
const first = cols[0], last = cols[cols.length - 1], step = cols.length > 1 ? (mid(last) - mid(first)) / (last - first) : 260;
|
|
4227
4289
|
let col = first, best = Infinity; cols.forEach(c => { const d = Math.abs(mid(c) - x); if (d < best) { best = d; col = c; } });
|
|
4228
4290
|
if (x > mid(last) + step / 2) col = last + Math.round((x - mid(last)) / step);
|
|
4229
4291
|
else if (x < mid(first) - step / 2) col = Math.max(0, first - Math.round((mid(first) - x) / step));
|
|
4230
|
-
const a = geo.nodes[0], b = geo.nodes.find(n => n.row !== a.row), pitch = b ? (b
|
|
4231
|
-
h.col = col; h.row = Math.max(0, Math.round((y - (a
|
|
4292
|
+
const a = geo.nodes[0], b = geo.nodes.find(n => n.row !== a.row), pitch = b ? (Y(b) - Y(a)) / (b.row - a.row) : 108;
|
|
4293
|
+
h.col = col; h.row = Math.max(0, Math.round((y - (Y(a) - a.row * pitch) - 32) / pitch));
|
|
4232
4294
|
return h;
|
|
4233
4295
|
}
|
|
4234
4296
|
const siblings = n => { const b = boxOf(n.id); return geo.nodes.filter(m => m.col === n.col && !m.aside === !n.aside && boxOf(m.id) === b).sort((p, q) => p.y - q.y); };
|
|
@@ -4302,7 +4364,7 @@ export function render(container, model, options = {}) {
|
|
|
4302
4364
|
['pointerup', 'pointercancel'].forEach(t => stage.addEventListener(t, () => { pressed = false; }, true));
|
|
4303
4365
|
function focusMap(ref = sel, reveal = true) {
|
|
4304
4366
|
const line = ref && ref.type === 'edge' && svg && svg.querySelector(`.line-key[data-i="${edgeIndex(ref)}"]`); if (line) return line.focus({ preventScroll: true }); // a line (#43)
|
|
4305
|
-
const id = ref && (ref.type === 'node' ? ref.id : ref.node), g = id && svg &&
|
|
4367
|
+
const id = ref && (ref.type === 'node' ? ref.id : ref.node), g = id && svg && (ringOf(id) || nodeG(id));
|
|
4306
4368
|
if (!g) return stage.focus({ preventScroll: true });
|
|
4307
4369
|
g.focus({ preventScroll: true }); const n = geo.byId[id]; if (n && reveal) cam.reveal({ x: n.x, y: n.y, w: n.w, h: n.h });
|
|
4308
4370
|
}
|
|
@@ -4338,7 +4400,7 @@ export function render(container, model, options = {}) {
|
|
|
4338
4400
|
if (stale) { stale = false; swallow = performance.now() + 400; return; } // the release of a gesture Escape or a new picture cancelled
|
|
4339
4401
|
if (conn) { const c = conn; conn = null; if (c.path) c.path.remove(); swallow = performance.now() + 400;
|
|
4340
4402
|
const g = root.elementFromPoint(e.clientX, e.clientY), t = g && g.closest && g.closest('.node');
|
|
4341
|
-
if (t &&
|
|
4403
|
+
if (t && geo.byId[t.dataset.id]) typed() ? tx.connect(c.from, t.dataset.id, e) : t.dataset.id !== c.from && change({ op: 'add', kind: 'edge', from: c.from, to: t.dataset.id }); // a typed view: maybe to itself (I.self, edit.js)
|
|
4342
4404
|
return; }
|
|
4343
4405
|
if (!drag) return;
|
|
4344
4406
|
const d = drag; drag = null;
|
|
@@ -4349,7 +4411,7 @@ export function render(container, model, options = {}) {
|
|
|
4349
4411
|
const x = n.x + n.w / 2 + dx, y = n.y + n.h / 2 + dy, hint = hintAt(x, y), from = boxOf(n.id), into = boxAt(x, y);
|
|
4350
4412
|
const fromId = from ? from.id : null, toId = into ? into.id : null;
|
|
4351
4413
|
let ev = null;
|
|
4352
|
-
if (typed()) ev = tx.drop(n, hint); // a typed view: the card's pin
|
|
4414
|
+
if (typed()) ev = tx.drop(n, hint, toId, fromId); // a typed view: the card's pin, and its owner when it left its box (notes/stage-2.md §8)
|
|
4353
4415
|
else if (toId !== fromId && can('move')) ev = change({ op: 'move', target: nodeRef(n.id), to: toId, hint });
|
|
4354
4416
|
else if (gridView() && hint.col === n.col && can('reorder')) {
|
|
4355
4417
|
const sib = siblings(n), at = sib.indexOf(n), i = sib.filter(m => m !== n && m.y + m.h / 2 < y).length;
|
|
@@ -4362,14 +4424,20 @@ export function render(container, model, options = {}) {
|
|
|
4362
4424
|
stage.addEventListener('pointerup', endGesture);
|
|
4363
4425
|
stage.addEventListener('pointercancel', () => { if (drag && drag.ghost) drag.ghost.remove(); if (conn && conn.path) conn.path.remove(); drag = conn = null; stale = false; });
|
|
4364
4426
|
/* the model to draw after an update: cards keep the slots they had (the host's own layout wins), a moved or added card
|
|
4365
|
-
takes its hint; kept in this handle only, never written to the host's model
|
|
4366
|
-
|
|
4427
|
+
takes its hint; kept in this handle only, never written to the host's model. A typed view's boxes take no slot, and an edit
|
|
4428
|
+
that changed the box an element shown before and after is drawn in lays the view out again, so every box holds exactly its
|
|
4429
|
+
contents (notes/stage-2.md §3.2); hiding or deleting a card keeps the places */
|
|
4430
|
+
const boxesIn = (m, v) => { const T = m && m.schemaVersion === 2 && v && v.diagram != null && diagramOf(v); return T && T.boxes ? arrOf(T.boxes(m, v)).filter(b => b && b.id != null) : []; };
|
|
4431
|
+
const ownersOn = bs => new Map(bs.flatMap(b => [[b.id, b.parent ?? null], ...arrOf(b.nodeIds).map(id => [id, b.id])])); // id -> the box it is drawn in
|
|
4432
|
+
function withPlaces(m, use = null, prev = null) { // use: an edit the update applied, { view, hv: id -> its place, ex: a reordered group's places }; prev: the model before
|
|
4367
4433
|
if (options.stableLayout === false) return m;
|
|
4368
4434
|
const out = structuredClone(m);
|
|
4369
4435
|
(out.views || []).forEach(v => {
|
|
4370
|
-
const
|
|
4436
|
+
const bx = boxesIn(out, v), pv = viewIn(prev, v.id), A = ownersOn(boxesIn(prev, pv)), B = ownersOn(bx);
|
|
4437
|
+
const shown = new Set(arrOf(pv.nodeIds)), again = A.size + B.size > 0 && arrOf(v.nodeIds).some(id => shown.has(id) && (A.get(id) ?? null) !== (B.get(id) ?? null));
|
|
4438
|
+
const was = again ? null : placed[v.id], { hv = {}, ex = {} } = use && use.view === v.id && !again ? use : {};
|
|
4371
4439
|
if (!was && !Object.keys(hv).length && !Object.keys(ex).length) return;
|
|
4372
|
-
const ids = (v.nodeIds || []).filter(id => typeof id === 'string'), mine = v.layout || {}, L = {}, taken = new Set(), key = p => p[0] + ',' + p[1];
|
|
4440
|
+
const ids = (v.nodeIds || []).filter(id => typeof id === 'string' && !bx.some(b => b.id === id)), mine = v.layout || {}, L = {}, taken = new Set(), key = p => p[0] + ',' + p[1];
|
|
4373
4441
|
const put = (id, p) => { let [c, r] = p; while (taken.has(key([c, r]))) r++; L[id] = [c, r]; taken.add(key([c, r])); };
|
|
4374
4442
|
ids.forEach(id => { if (Array.isArray(mine[id])) { L[id] = mine[id]; taken.add(key(mine[id])); } }); // the host's own layout
|
|
4375
4443
|
ids.forEach(id => { if (!L[id] && ex[id]) put(id, ex[id]); }); // a reordered column group
|
|
@@ -4399,7 +4467,7 @@ export function render(container, model, options = {}) {
|
|
|
4399
4467
|
sub = null;
|
|
4400
4468
|
if (ref) {
|
|
4401
4469
|
if (!activate(ref, e)) return; // the host took the click (a chip as a link, say): the selection stays as it was
|
|
4402
|
-
if (
|
|
4470
|
+
if ((e.shiftKey || e.metaKey || e.ctrlKey) && sel && kinds().includes(ref.type) && kinds().includes(sel.type)) return toggle(ref); // several cards, and lines
|
|
4403
4471
|
return select(ref, { again: ref.type === 'node' }); }
|
|
4404
4472
|
select(null);
|
|
4405
4473
|
if (P.onBackground) P.onBackground();
|
|
@@ -4507,10 +4575,10 @@ export function render(container, model, options = {}) {
|
|
|
4507
4575
|
// a card E or Alt + click asked to unfold before its view was loaded unfolds once a model brings that view (#71)
|
|
4508
4576
|
const brought = []; Object.entries(asked).forEach(([vid, m]) => m.forEach((c, nid) => { if (!arrOf(model2.views).some(v => v && v.id === c)) return;
|
|
4509
4577
|
m.delete(nid); const l = expanded[vid] ||= []; if (!l.includes(nid)) { l.push(nid); if (vid === cur) brought.push(nid); } }));
|
|
4510
|
-
const old = ctx, keys = {}, wantNew = selectNew && !own;
|
|
4578
|
+
const old = ctx, keys = {}, wantNew = selectNew && !own, before = current;
|
|
4511
4579
|
old.views.forEach(v => { keys[v.id] = relayout ? null : old.viewKey(v.id); });
|
|
4512
4580
|
current = model2;
|
|
4513
|
-
ctx = mapContext(withPlaces(model2, use), { measure, kinds: !!P.kinds, prev: options.stableLayout === false ? null : old.planCache, expand: expanded, memos: relayout ? null : old.memos });
|
|
4581
|
+
ctx = mapContext(withPlaces(model2, use, before), { measure, kinds: !!P.kinds, prev: options.stableLayout === false ? null : old.planCache, expand: expanded, memos: relayout ? null : old.memos });
|
|
4514
4582
|
Object.keys(expanded).forEach(v => { expanded[v] = ctx.unfolded[v] || []; }); // a card or inner view the model no longer has folds
|
|
4515
4583
|
if (!own) selectNew = false;
|
|
4516
4584
|
ctx.views.forEach(v => { const id = v.id; if (keys[id] == null || keys[id] !== ctx.viewKey(id)) return;
|
|
@@ -4535,7 +4603,7 @@ export function render(container, model, options = {}) {
|
|
|
4535
4603
|
if (box) unfolded(box, true); // a card asked to unfold earlier: its box is selected
|
|
4536
4604
|
else if (wantNew && use && p.op === 'add' && geo.byId[card]) select(nodeRef(card), { reveal: false }); // the card added, as the reader named it
|
|
4537
4605
|
else { // the selection that is still drawn stays, and a select event comes only if it changed (unfolding: the caller says)
|
|
4538
|
-
if (keep && exists(keep)) { sel = keep; more =
|
|
4606
|
+
if (keep && exists(keep)) { sel = keep; more = keepMore.filter(exists); paint(); }
|
|
4539
4607
|
if (!unfolding) emitSel(); }
|
|
4540
4608
|
if (!relayout) easeIn(...prev); // a font that came in late moves nothing on purpose: no ease
|
|
4541
4609
|
touch();
|
|
@@ -5669,9 +5737,10 @@ export function mountPage(container, model, options = {}) {
|
|
|
5669
5737
|
- The cover: the title, the date, the model's file, a summary line and the contents, each view with its page.
|
|
5670
5738
|
- Each view, from a page of its own: a header (the view's path and its caption), its picture (toSVG()'s, in vectors)
|
|
5671
5739
|
as large as the page allows and never larger than drawn, the legend of what it draws, then tables of its cards,
|
|
5672
|
-
lines and boxes
|
|
5673
|
-
|
|
5674
|
-
|
|
5740
|
+
lines and boxes (on a typed view: each element's type and members, as its type prints them, and each line's kind).
|
|
5741
|
+
They fill two columns below the picture, or one column beside a picture too tall to leave room below. A table that
|
|
5742
|
+
runs out of room goes on in the next column, under its heading and header row again; a row is never cut, and a
|
|
5743
|
+
heading never ends a column.
|
|
5675
5744
|
- The rules, when the model has any: each holding or broken in the views printed, and what breaks it. */
|
|
5676
5745
|
function printDoc(X, model, Q) {
|
|
5677
5746
|
const { D, views, V, N, chain, nodeIn, viewEdges, geoCache } = X.ctx();
|
|
@@ -5761,6 +5830,8 @@ function printDoc(X, model, Q) {
|
|
|
5761
5830
|
shown.forEach(([v, svg], vi) => {
|
|
5762
5831
|
const g = geoCache[v.id].g, ids = cards(v), es = lines(v), seq = v.render === 'sequence', stepped = es.some(e => e.step != null), at = Object.fromEntries(g.nodes.map(n => [n.id, n]));
|
|
5763
5832
|
const wk = new Map(g.edges.flatMap(d => edgeRefIds(d).map(id => [id, d.wk]))), held = boxes(v).map(c => [c, arrOf(c.nodeIds).filter(i => ids.includes(i))]).filter(([, ins]) => ins.length);
|
|
5833
|
+
const typed = (v.diagram ?? 'map') !== 'map', word = t => String(t || '').split('.').pop().replace(/([a-z])([A-Z])/g, (_, a, b) => a + ' ' + b.toLowerCase()); // 'uml.DataType': "Data type"
|
|
5834
|
+
const asText = (diagramOf(v)?.member || {}).print || (m => m.label ?? m.id); // a member as its type writes it, else its name
|
|
5764
5835
|
const up = chain(v.id).slice(0, -1).map(id => `<span class="up">${T(V[id])} › </span>`).join('');
|
|
5765
5836
|
const head = more => `<header>${more ? `<div class="h2">${up}${T(v)} <span class="up">(continued)</span></div>` : `<h2>${up}${T(v)}</h2>`}<span class="meta">${[count(ids.length, 'card'), count(es.length, 'line'), ...(held.length ? [count(held.length, 'box', 'boxes')] : [])].join(' · ')}</span></header>${!more && v.caption ? `<p class="cap">${esc(v.caption)}</p>` : ''}`;
|
|
5766
5837
|
const body = sheet(head(false), vi);
|
|
@@ -5775,11 +5846,12 @@ function printDoc(X, model, Q) {
|
|
|
5775
5846
|
const lay = (f, more) => { s.setAttribute('width', b.width * k * f); s.setAttribute('height', b.height * k * f); body.querySelector('.cols')?.remove();
|
|
5776
5847
|
const first = cols(body, side ? 1 : 2); if (side) first[0].append(lg); return pour(paging(more && head, 2, first), tables); };
|
|
5777
5848
|
const tables = [
|
|
5778
|
-
{ title: `Cards <span>${ids.length}</span>`, cls: 'cards', head:
|
|
5849
|
+
{ title: `Cards <span>${ids.length}</span>`, cls: 'cards', head: `<th>Card</th><th>Kind</th><th>${typed ? 'Members' : 'What it is'}</th>`, rows: ids.map(id => { const c = nodeIn(id, v.id), t = KIND[kindOf(c.kind, true)], d = at[id] || {}, sub = c.sublabel ?? c.sub;
|
|
5779
5850
|
return `<tr><td>${d.step ? `<i class="num">${esc(d.step)}</i>` : ''}<b>${esc(c.label || id)}</b>${d.kids || d.opens ? ' ⤢' : ''}${sub ? `<small>${esc(sub)}</small>` : ''}</td>`
|
|
5780
|
-
+ `<td
|
|
5781
|
-
|
|
5782
|
-
|
|
5851
|
+
+ (typed ? `<td>${esc(word(c.type || 'uml.Class'))}</td><td>${arrOf(c.members).filter(m => m && typeof m === 'object').map(m => `<p>${esc(asText(m))}</p>`).join('')}</td></tr>`
|
|
5852
|
+
: `<td><i class="sw" style="border-color:var(--um-${t[0]});background:var(--um-${t[0]}-f)"></i>${t[1]}</td><td>${c.what ? md(c.what) : ''}</td></tr>`); }) },
|
|
5853
|
+
{ title: `Lines <span>${es.length}</span>`, cls: 'lines', head: `${seq || stepped ? '<th class="n">#</th>' : ''}<th>From → to</th><th>Label</th><th>${typed ? 'Kind' : 'Technology'}</th>`, rows: (stepped && !seq ? [...es].sort((a, b) => stepOrder(a.step, b.step)) : es).map((e, i) => { const w = wk.get(e._id) || 'calls';
|
|
5854
|
+
return `<tr>${seq ? `<td>${i + 1}</td>` : stepped ? `<td>${esc(e.step ?? '')}</td>` : ''}<td><i class="w" style="border-color:var(--um-w-${w})"></i>${name(v, e.from)} → ${name(v, e.to)}</td><td>${e.label ? esc(e.label) : typed ? '' : `<span class="up">${w}</span>`}</td><td>${esc(typed ? word(e.type) : e.sublabel || '')}</td></tr>`; }) },
|
|
5783
5855
|
{ title: `Boxes <span>${held.length}</span>`, cls: 'boxes', head: '<th>Box</th><th>Holds</th>', rows: held.map(([c, ins]) => `<tr><td><b>${esc(c.label || c.id)}</b></td><td>${andList(ins.map(i => name(v, i)))}</td></tr>`) },
|
|
5784
5856
|
].filter(x => x.rows.length);
|
|
5785
5857
|
[1, .9, .8, .7].some(f => k * f >= Math.min(k, .6) && lay(f)) || lay(1, true);
|
|
@@ -5809,7 +5881,7 @@ function printDoc(X, model, Q) {
|
|
|
5809
5881
|
|
|
5810
5882
|
// what moves a typed view's geometry: every field of the view layoutGraph reads but VIEW_PAINT (a test holds the two lists to
|
|
5811
5883
|
// layered.js), and these of a node and a line. Anything else (a colour, a `props` field) only repaints
|
|
5812
|
-
const VIEW_GEO = ['abs', 'authors', 'blocks', 'containers', 'direction', 'fan', 'flipped', 'gap', 'groups', 'hub', 'intake', 'laneGap', 'lanes', 'mainSteps', 'nodeH', 'quick', 'repair', 'rgap', 'turned', 'typed'];
|
|
5884
|
+
const VIEW_GEO = ['abs', 'authors', 'blocks', 'containers', 'direction', 'fan', 'flipped', 'gap', 'groups', 'hub', 'intake', 'laneGap', 'lanes', 'mainSteps', 'nodeH', 'quick', 'repair', 'reroute', 'rgap', 'turned', 'typed'];
|
|
5813
5885
|
const VIEW_PAINT = ['tour', 'subAndChips'], NODE_GEO = ['id', 'col', 'row', 'w', 'h', 'ax', 'ay'], EDGE_GEO = ['id', 'from', 'to', 'label', 'step', 'fromSide', 'toSide', 'back', 'fixed', 'sublabel'];
|
|
5814
5886
|
const geometryKey = dg => JSON.stringify([VIEW_GEO.map(k => dg[k]), (dg.nodes || []).map(n => NODE_GEO.map(k => n[k])), (dg.edges || []).map(e => EDGE_GEO.map(k => e[k]))]);
|
|
5815
5887
|
|
|
@@ -5817,7 +5889,7 @@ const geometryKey = dg => JSON.stringify([VIEW_GEO.map(k => dg[k]), (dg.nodes ||
|
|
|
5817
5889
|
const SIDE_T = { L: 'T', T: 'L', R: 'B', B: 'R' }, swapXY = p => [p[1], p[0]];
|
|
5818
5890
|
const flipBox = r => { if (r) [r.x, r.y, r.w, r.h] = [r.y, r.x, r.h, r.w]; };
|
|
5819
5891
|
function transpose(g) {
|
|
5820
|
-
[...g.nodes, ...(g.families || []), g.bounds].forEach(flipBox);
|
|
5892
|
+
[...g.nodes, ...(g.families || []), ...new Set(g.edges.flatMap(e => [e.s, e.t]).filter(n => n && n.box)), g.bounds].forEach(flipBox); // a line's end on a box too
|
|
5821
5893
|
(g.groups || []).forEach(r => { [r.x, r.y, r.x2, r.y2] = [r.y, r.x, r.y2, r.x2]; });
|
|
5822
5894
|
g.edges.forEach(e => { e.pts = e.pts.map(swapXY); if (e.fixed) e.fixed = e.pts; e.p1 = swapXY(e.p1); e.p2 = swapXY(e.p2);
|
|
5823
5895
|
e.fs = SIDE_T[e.fs]; e.ts = SIDE_T[e.ts]; e.stubs = (e.stubs || []).map(s => s.map(swapXY)); flipBox(e.lbl); });
|
|
@@ -5835,7 +5907,8 @@ function repaint(g, was, dg) {
|
|
|
5835
5907
|
|
|
5836
5908
|
/* dg: layoutGraph's input, with `direction` ("right", the default, or "down"). memo: an object kept for the view between
|
|
5837
5909
|
calls; while the geometry key is the same, the geometry it holds is repainted and returned, and nothing is laid out or
|
|
5838
|
-
routed. memo.runs counts the layouts.
|
|
5910
|
+
routed. memo.runs counts the layouts. dg.reroute false: lines that had to cross are not routed again in a new order (the
|
|
5911
|
+
rip-up and reroute pass, layered.js), for a type whose edits must be quick (risks.md P1); a map never sets it. */
|
|
5839
5912
|
function layoutTyped(dg, memo) {
|
|
5840
5913
|
const key = memo && geometryKey(dg);
|
|
5841
5914
|
if (memo && memo.key === key) { const g = repaint(memo.g, memo.dg, dg); memo.dg = dg; return g; }
|
|
@@ -5912,7 +5985,7 @@ TYPED.fan = (free, { simplify, through, STUB, SEP }) => { const pairs = new Map(
|
|
|
5912
5985
|
|
|
5913
5986
|
// ---- the classifier (1.5). Its drawing input, on a typed view's node (not the model: 1.9 fills it from the model and the
|
|
5914
5987
|
// view's present; a map never draws it): shape 'classifier'; label; type ('uml.Class', named in its accessible name);
|
|
5915
|
-
// stereotypes [text], shown «…» over the name; abstract (the name in italics); compartments [{ name, members }] in order
|
|
5988
|
+
// stereotypes [text], shown «…» over the name; abstract (the name in italics); underline (the name underlined: an instance, notes/stage-2.md §9.1); compartments [{ name, members }] in order
|
|
5916
5989
|
// ('attributes', 'operations', 'literals'), each member in the model's envelope ({ type, label, props }); collapsed
|
|
5917
5990
|
// [compartment names], hidden with no divider; rows: at most that many rows in a compartment, then "+k more" (stage 1 note §5)
|
|
5918
5991
|
const CL = { pad: 8, name: 13, row: 11, st: 14, nl: 18, rl: 15, gap: 5, empty: 10, minW: 96, maxW: 320 };
|
|
@@ -5944,7 +6017,7 @@ function classSvg(n, c, f) { // its outline, its head, then each compartment und
|
|
|
5944
6017
|
const out = [`<rect x="${x}" y="${y}" width="${w}" height="${h}" class="n-mask"/><rect x="${x}" y="${y}" width="${w}" height="${h}" class="n-body" fill="${f}" stroke="${c}"/>`];
|
|
5945
6018
|
let ty = y + CL.gap; const hd = [];
|
|
5946
6019
|
st.forEach(t => { hd.push(`<text x="${cx}" y="${r2(ty + 11)}" text-anchor="middle" class="c-st" fill="var(--um-muted)" ${font(CL.row, 400)}>${esc(fit(t, CL.row, max))}</text>`); ty += CL.st; });
|
|
5947
|
-
hd.push(`<text x="${cx}" y="${r2(ty + 14)}" text-anchor="middle" class="c-name" fill="var(--um-text)" ${font(CL.name, 600, n.abstract)} data-o="${esc(n.id)}">${esc(fit(n.label ?? '', CL.name, max / slant))}</text>`);
|
|
6020
|
+
hd.push(`<text x="${cx}" y="${r2(ty + 14)}" text-anchor="middle" class="c-name" fill="var(--um-text)" ${font(CL.name, 600, n.abstract)}${n.underline ? ' text-decoration="underline"' : ''} data-o="${esc(n.id)}">${esc(fit(n.label ?? '', CL.name, max / slant))}</text>`);
|
|
5948
6021
|
out.push(`<g class="c-head" data-band="${r2(y)} ${r2(y + head)}">${hd.join('')}</g>`);
|
|
5949
6022
|
let top = y + head;
|
|
5950
6023
|
comps.forEach((C, i) => { const bh = i === comps.length - 1 ? Math.max(y + h - top, 0) : tall(C), at = j => top + CL.gap - 1 + j * CL.rl + 11.5;
|
|
@@ -6012,9 +6085,260 @@ TYPED.lines = ({ id, edges, drawn, lstyle, MC }) => {
|
|
|
6012
6085
|
labels: i => edges[i].end ? endLabs(i).map(b => `<text x="${r2(b.x + b.w / 2)}" y="${r2(b.y + 10.5)}" text-anchor="middle" class="end-lab" data-i="${i}" data-end="${b.end}" data-box="${b.x} ${b.y} ${b.w} ${b.h}" fill="var(--um-muted)" style="font:500 10px var(--um-sans)">${esc(b.text)}</text>`).join('') : '',
|
|
6013
6086
|
};
|
|
6014
6087
|
};
|
|
6088
|
+
/* A type's editing hooks on the canvas (work package 3.4, notes/stage-3.md §5.1). Only lib/umlimator-all.js holds this file
|
|
6089
|
+
(risks.md C5): it fills TYPED.edit (registry.js), which typedEditor (src/canvas/edit.js) calls with its gestures, I, to take
|
|
6090
|
+
some over, and c, what they need of its insides. Without it a typed view is edited as in 0.17.0.
|
|
6091
|
+
Each hook of the type's `edits` returns commands (one transaction, emitted as an `edit` event), { commands, say } (emitted,
|
|
6092
|
+
and `say` said on the hint line), null to leave the gesture to the default, or { error }, said on the hint line, with
|
|
6093
|
+
nothing emitted (S5):
|
|
6094
|
+
- connect(model, view, from, to, at, line): a line drawn by pointer (at: the slot nearest the release) or by L (the slot
|
|
6095
|
+
after the element selected, else the last). A line from an element to itself is drawn too, by pointer or by typing its
|
|
6096
|
+
own name after L (a self-message, a reflexive association). A press on a lifeline's stem (SHAPE.lifeline's .ll-hit)
|
|
6097
|
+
draws a line too, released on another stem or its own.
|
|
6098
|
+
- moveTo(model, view, id, at): a card or line dropped (the slot nearest the drop: a line's pointer, a card's edge that
|
|
6099
|
+
leads the move; a card's at also holds pt, the point its centre was dropped at, and col, how many of the view's other
|
|
6100
|
+
cards (nodeIds) are drawn left of pt, measured on the canvas in the host's font), Alt + arrows ({ by: [dx, dy] }), or a
|
|
6101
|
+
member's ↑ and ↓ in the panel and Alt + Up/Down on it ({ by: [0, ±1] }; null: the member list's own move). Null: the
|
|
6102
|
+
card's pin, as before, unless the type has layout.pins false.
|
|
6103
|
+
- wrap(model, view, ids, kind): W, then a picker of the type's `wraps` (strings, or entries with a label).
|
|
6104
|
+
- add(model, view, entry, label, at): the palette and N, once the name is typed; at as for L, with sel, the ids selected.
|
|
6105
|
+
- remove(model, view, ids, fromModel): Delete (fromModel false), Shift + Delete, the panel's buttons and the outline's ✕.
|
|
6106
|
+
A slot is an entry of the geometry's `slots` ({ index, y, parent, after }, src/layout/timeaxis.js; x too where a type has
|
|
6107
|
+
one); with none, at is null. A view's fragments (view.fragments) are elements too: selected, they show their properties and
|
|
6108
|
+
their members (operands) in the panel, as a card does; a double-click on a selected member drawn as a card (an operand's
|
|
6109
|
+
guard), on a selected lifeline's name, or on a selected line, renames it, as F2 does. */
|
|
6110
|
+
|
|
6111
|
+
const DIR = { ArrowLeft: [-1, 0], ArrowRight: [1, 0], ArrowUp: [0, -1], ArrowDown: [0, 1] };
|
|
6112
|
+
|
|
6113
|
+
TYPED.edit = (I, c) => {
|
|
6114
|
+
const { api, model, view, hook, emit } = c, base = { ...I }, can = api.can;
|
|
6115
|
+
I.self = true;
|
|
6116
|
+
const slots = () => arrOf((api.geo() || {}).slots).filter(isRec);
|
|
6117
|
+
const near = (x, y) => { let best = null, bd = Infinity; // the slot nearest a point of the picture
|
|
6118
|
+
slots().forEach(s => { const d = Math.abs(s.y - y) + (Number.isFinite(s.x) ? Math.abs(s.x - x) : 0); if (d < bd) { bd = d; best = s; } }); return best; };
|
|
6119
|
+
const keyAt = () => { const S = slots(), s = api.sel(), id = s && (s.id ?? arrOf(s.ids)[0]); return S.find(x => x.after != null && x.after === id) || S.at(-1) || null; };
|
|
6120
|
+
const world = ev => { const r = api.stage.getBoundingClientRect(); return api.cam().toWorld(ev.clientX - r.left, ev.clientY - r.top); };
|
|
6121
|
+
// what a hook returned: null, the default; { error }, said; else its commands
|
|
6122
|
+
const ran = (r, dflt) => { if (r == null) return dflt();
|
|
6123
|
+
if (isRec(r) && r.error != null) { api.status(String(r.error)); api.say(String(r.error)); return false; }
|
|
6124
|
+
if (isRec(r) && Array.isArray(r.commands)) { if (r.say != null) { api.status(String(r.say)); api.say(String(r.say)); } return emit(r.commands); }
|
|
6125
|
+
return emit(arrOf(r)); };
|
|
6126
|
+
I.ran = ran; // connect's result, read by the core's addLine
|
|
6127
|
+
|
|
6128
|
+
I.connect = (from, to, ev, at) => base.connect(from, to, ev, at !== undefined ? at : ev ? near(...world(ev)) : keyAt());
|
|
6129
|
+
I.drop = (n, hint, into, from) => { const h = hook('moveTo'), dflt = () => base.drop(n, hint, into, from), dy = hint.y - (n.y + n.h / 2);
|
|
6130
|
+
const g = api.geo(), col = arrOf((view() || {}).nodeIds).filter(id => id !== n.id && isRec(g.byId[id]) && g.byId[id].x + g.byId[id].w / 2 < hint.x).length;
|
|
6131
|
+
return h && can('move') ? ran(h(model(), view(), n.id, { ...near(hint.x, hint.y + Math.sign(dy) * n.h / 2), pt: [hint.x, hint.y], col }), dflt) : dflt(); };
|
|
6132
|
+
I.addNode = i => { const h = hook('add'), t = c.nodeTypes()[i], cam = api.cam(); if (!h || !t || !can('add') || !cam) return base.addNode(i);
|
|
6133
|
+
const s = keyAt(), at = s && { ...s, sel: c.picks() }; // where the selection is now, before the name field takes the focus
|
|
6134
|
+
api.editor({ x: cam.s.cx - 80, y: cam.s.cy - 13, w: 160, h: 26 }, '', label => ran(h(model(), view(), t, label, at),
|
|
6135
|
+
() => emit([{ op: 'add', element: { ...c.typed(t, 'node'), label }, view: api.view() }]))); };
|
|
6136
|
+
I.remove = (fromModel, ids = c.picks()) => { const h = hook('remove'), dflt = () => base.remove(fromModel, ids);
|
|
6137
|
+
return h && can('delete') && ids.length ? ran(h(model(), view(), ids, !!fromModel), dflt) : dflt(); };
|
|
6138
|
+
function wrap(ids) { // W: the picker of the type's wraps, under the first card selected, else under the far end of the first line
|
|
6139
|
+
const h = hook('wrap'), list = arrOf(c.def().wraps), opts = list.map(w => isRec(w) ? w : { label: String(w) }), g = api.geo();
|
|
6140
|
+
const at = ids.find(id => Object.hasOwn(g.byId || {}, id)) ?? (g.edges.find(x => ids.includes(x._id)) || {}).to;
|
|
6141
|
+
if (!opts.length || at == null) return api.status('Nothing to wrap it in');
|
|
6142
|
+
c.picker(at, opts, t => ran(h(model(), view(), ids, list[opts.indexOf(t)]), () => true));
|
|
6143
|
+
const m = api.stage.querySelector('.um-pick'); if (m) m.setAttribute('aria-label', 'Wrap in');
|
|
6144
|
+
}
|
|
6145
|
+
I.key = e => {
|
|
6146
|
+
if (I.linking()) return base.key(e);
|
|
6147
|
+
const K = e.key, ids = c.picks(), mv = hook('moveTo');
|
|
6148
|
+
if (e.altKey && DIR[K] && mv && ids.length && can('move')) { const r = mv(model(), view(), ids.at(-1), { by: DIR[K] }); if (r == null) return base.key(e); ran(r); return true; }
|
|
6149
|
+
if ((K === 'w' || K === 'W') && !e.altKey && hook('wrap') && can('add') && ids.length) { wrap(ids); return true; }
|
|
6150
|
+
return base.key(e);
|
|
6151
|
+
};
|
|
6152
|
+
|
|
6153
|
+
/* a line dragged, when the type has moveTo: a ghost follows the pointer, and the drop asks moveTo with the slot nearest it.
|
|
6154
|
+
A press on a lifeline's stem, when the type has connect: a line follows the pointer from the press, and the release on
|
|
6155
|
+
a stem connects the two (I.connect, at the slot nearest the release). Either press is the gesture's, not the camera's
|
|
6156
|
+
nor a card drag's; the click after the drag selects nothing */
|
|
6157
|
+
let drag = null, swallow = false;
|
|
6158
|
+
const stop = () => { const d = drag; drag = null; if (d && d.ghost) d.ghost.remove(); return !!(d && d.on); };
|
|
6159
|
+
api.stage.addEventListener('pointerdown', e => { // a stem, before the card's own drag (capture)
|
|
6160
|
+
swallow = false; // a new press: the click a drag left behind is not coming
|
|
6161
|
+
const s = e.button === 0 && !e.altKey && I.on() && hook('connect') && can('add') && e.target.closest && e.target.closest('.ll-hit'), g = s && s.closest('.node');
|
|
6162
|
+
if (!g || !api.geo().byId[g.dataset.id]) return;
|
|
6163
|
+
e.stopPropagation();
|
|
6164
|
+
drag = { from: g.dataset.id, p: world(e), x0: e.clientX, y0: e.clientY, on: false, pid: e.pointerId };
|
|
6165
|
+
}, true);
|
|
6166
|
+
api.stage.addEventListener('pointerdown', e => {
|
|
6167
|
+
const g = !drag && e.button === 0 && !e.altKey && I.on() && hook('moveTo') && can('move') && e.target.closest && e.target.closest('.edge-g'), x = g && api.geo().edges[+g.dataset.i];
|
|
6168
|
+
if (!x || x.id == null) return;
|
|
6169
|
+
e.stopImmediatePropagation();
|
|
6170
|
+
drag = { id: x.id, g, x0: e.clientX, y0: e.clientY, on: false, pid: e.pointerId };
|
|
6171
|
+
});
|
|
6172
|
+
api.stage.addEventListener('pointermove', e => {
|
|
6173
|
+
if (!drag) return;
|
|
6174
|
+
if (!drag.on) { if (Math.hypot(e.clientX - drag.x0, e.clientY - drag.y0) < 5) return;
|
|
6175
|
+
drag.on = true; try { api.stage.setPointerCapture(drag.pid); } catch (_) { /* a synthetic press holds no pointer */ }
|
|
6176
|
+
if (drag.from != null) { api.svg().insertAdjacentHTML('beforeend', '<path class="um-edit um-link"/>'); drag.ghost = api.svg().lastElementChild; }
|
|
6177
|
+
else { drag.ghost = drag.g.cloneNode(true); drag.ghost.classList.add('um-ghost'); api.svg().appendChild(drag.ghost); }
|
|
6178
|
+
api.touch(); }
|
|
6179
|
+
if (drag.from != null) { const [x, y] = world(e); drag.ghost.setAttribute('d', `M ${drag.p[0]} ${drag.p[1]} L ${x} ${y}`); return; }
|
|
6180
|
+
const k = api.cam().s.k; drag.ghost.setAttribute('transform', `translate(${(e.clientX - drag.x0) / k} ${(e.clientY - drag.y0) / k})`);
|
|
6181
|
+
});
|
|
6182
|
+
api.stage.addEventListener('pointerup', e => { const d = drag, h = hook('moveTo'); if (!stop()) return; swallow = true;
|
|
6183
|
+
if (d.from != null) { const t = api.root.elementFromPoint(e.clientX, e.clientY), g = t && t.closest && t.closest('.node'), [x, y] = world(e);
|
|
6184
|
+
// on a card of the view's (not a frame's tag, a guard or a use over a stem), else on a stem where its strip is cut (a
|
|
6185
|
+
// message's row, a bar, a frame or a use): the lifeline whose stem it is
|
|
6186
|
+
const to = g && api.geo().byId[g.dataset.id] && arrOf((view() || {}).nodeIds).includes(g.dataset.id) ? g.dataset.id : (api.geo().nodes.find(n => isRec(n.stem) && Math.abs(n.stem.x - x) <= 12 && y >= n.stem.y1 && y <= n.stem.y2) || {}).id;
|
|
6187
|
+
if (to != null) I.connect(d.from, to, e); }
|
|
6188
|
+
else if (h) ran(h(model(), view(), d.id, near(...world(e))), () => true);
|
|
6189
|
+
api.touch(); });
|
|
6190
|
+
api.stage.addEventListener('pointercancel', stop);
|
|
6191
|
+
api.stage.addEventListener('click', e => { if (swallow) { swallow = false; e.stopPropagation(); e.preventDefault(); } }, true);
|
|
6192
|
+
I.cancel = () => { const a = stop(); if (a) swallow = true; return base.cancel() || a; };
|
|
6193
|
+
I.mount = () => { stop(); base.mount(); };
|
|
6194
|
+
|
|
6195
|
+
// a double-click on a selected operand (a member drawn as a card), a selected lifeline's name, or a line: its name, as F2 (before the card's open)
|
|
6196
|
+
api.stage.addEventListener('dblclick', e => {
|
|
6197
|
+
const s = api.sel(), g = I.on() && can('rename') && s && e.target.closest && (e.target.closest('.node') || e.target.closest('.edge-g, .e-lab'));
|
|
6198
|
+
if (!g) return;
|
|
6199
|
+
const mine = s.type === 'node' && g.dataset.id === s.id, head = mine && g.classList.contains('lifeline') && e.target.closest('.c-name'); // its name: .c-name, not the core's .n-label
|
|
6200
|
+
const member = mine && arrOf((view() || {}).fragments).some(f => isRec(f) && arrOf(f.members).some(m => isRec(m) && m.id === s.id));
|
|
6201
|
+
if (!head && !member && !(s.type === 'edge' && !g.classList.contains('node'))) return;
|
|
6202
|
+
e.preventDefault(); e.stopPropagation(); api.rename();
|
|
6203
|
+
}, true);
|
|
6204
|
+
/* a member's ↑ and ↓ in the panel, and Alt + Up/Down on it, ask moveTo first: an operand's occurrences move with it */
|
|
6205
|
+
const memberMove = (id, by, e) => { const h = hook('moveTo'); if (!h || !can('reorder') || !I.on()) return;
|
|
6206
|
+
const r = h(model(), view(), id, { by: [0, by] }); if (r == null) return;
|
|
6207
|
+
e.preventDefault(); e.stopPropagation(); ran(r); };
|
|
6208
|
+
api.root.addEventListener('click', e => { const b = e.target.closest && e.target.closest('.um-editor [data-up], .um-editor [data-down]');
|
|
6209
|
+
if (b) memberMove(b.dataset.up ?? b.dataset.down, b.dataset.up != null ? -1 : 1, e); }, true);
|
|
6210
|
+
api.root.addEventListener('keydown', e => { const t = e.target;
|
|
6211
|
+
if (e.altKey && t && t.closest && t.closest('.um-editor') && t.dataset.m != null && (e.key === 'ArrowUp' || e.key === 'ArrowDown')) memberMove(t.dataset.m, e.key === 'ArrowUp' ? -1 : 1, e); }, true);
|
|
6212
|
+
};
|
|
6213
|
+
/* ---------- owners on the picture (work package 2.2, notes/stage-2.md §4 to §7) ----------
|
|
6214
|
+
What the class, package and object views share: which shown package is a box and what it holds (§4.1), the names drawn
|
|
6215
|
+
with the owners the view does not show (§6), the package's shape, open and closed (§5), and the rules about owners and
|
|
6216
|
+
boxes (§7, §10.3). Only lib/umlimator-all.js holds this file. */
|
|
6217
|
+
|
|
6218
|
+
const OWN_OBJ = x => x !== null && typeof x === 'object' && !Array.isArray(x), OWN_ARR = x => (Array.isArray(x) ? x : []);
|
|
6219
|
+
const OWN_BOXES = ['uml.Package']; // the node types that may be a box (§4.1, 1)
|
|
6220
|
+
// the types of rows 1 to 3 whose owner is a package (rule owner-kinds); comments are not checked
|
|
6221
|
+
const OWNED = ['uml.Class', 'uml.Interface', 'uml.Enumeration', 'uml.DataType', 'uml.PrimitiveType', 'uml.Package', 'uml.InstanceSpecification'];
|
|
6222
|
+
const ownName = x => String(x.label ?? x.id);
|
|
6223
|
+
const stereoOf = x => OWN_ARR(OWN_OBJ(x.props) ? x.props.stereotypes : null).filter(t => t != null && t !== '').map(String);
|
|
6224
|
+
|
|
6225
|
+
// every node of a model by id (the first of an id), and the chain of its owners, nearest first. An ownership cycle (the
|
|
6226
|
+
// core's error) is broken: an element on one has no owner, and one under one stops at the first element of it it meets
|
|
6227
|
+
function ownIndex(model) {
|
|
6228
|
+
const at = new Map(); OWN_ARR(OWN_OBJ(model) ? model.nodes : null).forEach(n => { if (OWN_OBJ(n) && typeof n.id === 'string' && !at.has(n.id)) at.set(n.id, n); });
|
|
6229
|
+
const chain = id => { const out = [], seen = new Set([id]);
|
|
6230
|
+
for (let p = (at.get(id) || {}).parent; p != null && at.has(p); p = at.get(p).parent) { if (seen.has(p)) return p === id ? [] : out.slice(0, out.indexOf(p) + 1); seen.add(p); out.push(p); }
|
|
6231
|
+
return out; };
|
|
6232
|
+
return { at, chain };
|
|
6233
|
+
}
|
|
6234
|
+
|
|
6235
|
+
/* A view's picture of its owners: { ids, owner(id), holds(o), boxes, name(id), full(id) }. ids: the nodes it shows; owner(id): an
|
|
6236
|
+
element's owner on the picture, its nearest shown owner that may be a box, or null (§4.1, 2); holds(o): the shown elements
|
|
6237
|
+
whose owner on the picture o is (null: the top), in the view's order; boxes: the open packages, in
|
|
6238
|
+
the registry's format (registry.js boxes), each holding exactly the shown elements whose owner on the picture it is, and
|
|
6239
|
+
the boxes nested in it by `parent`; name(id): the drawn name, with the owners between it and its owner on the picture,
|
|
6240
|
+
or with none from the view's subject when that is one of its owners, else from the top (§6); full(id): from the top */
|
|
6241
|
+
function ownersOn(model, view) {
|
|
6242
|
+
const { at, chain } = ownIndex(model), ids = OWN_ARR(OWN_OBJ(view) ? view.nodeIds : null).filter(id => at.has(id)), shown = new Set(ids);
|
|
6243
|
+
const ow = new Map(ids.map(id => [id, chain(id).find(p => shown.has(p) && OWN_BOXES.includes(at.get(p).type)) ?? null])), owner = id => ow.get(id) ?? null;
|
|
6244
|
+
const upTo = (id, stop) => { const c = chain(id), k = c.indexOf(stop); return [...(k < 0 ? c : c.slice(0, k)).reverse(), id].map(x => ownName(at.get(x))).join('::'); };
|
|
6245
|
+
const full = id => upTo(id, null), subject = OWN_OBJ(view) ? view.subject : null;
|
|
6246
|
+
const name = id => { const o = owner(id); return upTo(id, o != null ? o : chain(id).includes(subject) ? subject : null); };
|
|
6247
|
+
const by = new Map(); ids.forEach(id => { const o = owner(id); if (!by.has(o)) by.set(o, []); by.get(o).push(id); }); // grouped once: a view of 10,000 elements
|
|
6248
|
+
const holds = o => by.get(o) || [], open = new Set([...by.keys()].filter(x => x != null));
|
|
6249
|
+
const boxes = ids.filter(id => open.has(id)).map(id => { const p = at.get(id), st = stereoOf(p), held = holds(id), o = owner(id), nm = name(id);
|
|
6250
|
+
return { id, label: ownName(p), title: [...st.map(t => `«${t}»`), nm].join(' '), nodeIds: held.filter(x => !open.has(x)), ...(o != null ? { parent: o } : {}), shape: 'package',
|
|
6251
|
+
aria: `Package ${nm}${st.length ? ` (${st.join(', ')})` : ''}, holding ${held.length} element${held.length === 1 ? '' : 's'}` }; });
|
|
6252
|
+
return { ids, owner, holds, boxes, name, full, open, chain, at };
|
|
6253
|
+
}
|
|
6254
|
+
|
|
6255
|
+
// the reading order of a view's elements (§5): each box, then what it holds, depth first; the rest in the view's order
|
|
6256
|
+
function readingOrder(pic) {
|
|
6257
|
+
const out = [], put = o => pic.holds(o).forEach(id => { out.push(id); if (pic.open.has(id)) put(id); });
|
|
6258
|
+
put(null);
|
|
6259
|
+
return out;
|
|
6260
|
+
}
|
|
6261
|
+
|
|
6262
|
+
// a line between a package and something it holds, at any depth, or from a box to itself (§7): a, b node ids
|
|
6263
|
+
const holdsLine = (pic, a, b) => (a === b && pic.open.has(a)) || [[a, b], [b, a]].some(([p, x]) => (pic.at.get(p) || {}).type === 'uml.Package' && pic.chain(x).includes(p));
|
|
6264
|
+
|
|
6265
|
+
// the Owner field (registry.js owners): every package of the model by its name from the top, for an element a package may own
|
|
6266
|
+
function ownerChoices(model, x) {
|
|
6267
|
+
if (!OWN_OBJ(x) || !(OWNED.includes(x.type) || x.type === 'uml.Comment') || x.from != null) return null;
|
|
6268
|
+
const { at, chain } = ownIndex(model), full = id => [...chain(id).reverse(), id].map(k => ownName(at.get(k))).join('::');
|
|
6269
|
+
return [...at.values()].filter(n => n.type === 'uml.Package').map(n => ({ id: n.id, label: full(n.id) }));
|
|
6270
|
+
}
|
|
6271
|
+
|
|
6272
|
+
/* ---- the rules about owners and boxes (§9.3, §10.3). Each problem: { level, rule, path, message, ids } ---- */
|
|
6273
|
+
const OWNER_RULES = {
|
|
6274
|
+
'owner-kinds': 'a classifier, package or instance is owned by a package',
|
|
6275
|
+
'line-inside': 'a line between a package and something it holds, or from an open package to itself, is not drawn',
|
|
6276
|
+
'own-boxes': 'a view\'s hand-written containers are ignored where the type gives boxes',
|
|
6277
|
+
};
|
|
6278
|
+
// model: the owner-kinds rule; view (the type's view): line-inside and own-boxes for it, lines as `drawn(model, view)` gives
|
|
6279
|
+
// them ({ e, from, to }, the ends as nodes, before a line between a package and what it holds is left out)
|
|
6280
|
+
function ownerProblems(model, view, lines) {
|
|
6281
|
+
const out = [], say = (rule, path, ids, msg) => out.push({ level: 'warning', rule, path, ids, message: `${rule}: ${msg}` });
|
|
6282
|
+
const all = new Map(), put = (x, kind) => { if (OWN_OBJ(x) && typeof x.id === 'string' && !all.has(x.id)) { all.set(x.id, { x, kind }); OWN_ARR(x.members).forEach(m => put(m, 'member')); } };
|
|
6283
|
+
OWN_ARR(model.nodes).forEach(n => put(n, 'node')); const q = id => JSON.stringify(ownName((all.get(id) || { x: { id } }).x));
|
|
6284
|
+
OWN_ARR(model.nodes).forEach((n, i) => { if (!OWN_OBJ(n) || !OWNED.includes(n.type) || n.parent == null || !all.has(n.parent)) return;
|
|
6285
|
+
const p = all.get(n.parent).x; if (p.type !== 'uml.Package') say('owner-kinds', `nodes[${i}].parent`, [n.id, p.id], `${q(n.id)} is owned by ${q(p.id)}, a ${String(p.type).split('.').pop()}, not a package`); });
|
|
6286
|
+
if (!view) return out;
|
|
6287
|
+
const vi = OWN_ARR(model.views).indexOf(view), pic = ownersOn(model, view);
|
|
6288
|
+
lines.forEach(({ e, from, to }) => { if (holdsLine(pic, from, to)) say('line-inside', `views[${vi}]`, from === to ? [e.id, from] : [e.id, from, to],
|
|
6289
|
+
`${e.id}, ${from === to ? `from ${q(from)} to itself, a box,` : `between ${q(from)} and ${q(to)}, which one holds,`} is not drawn in view ${JSON.stringify(view.id)}`); });
|
|
6290
|
+
if (OWN_ARR(view.containers).length) say('own-boxes', `views[${vi}].containers`, [view.id], `view ${JSON.stringify(view.id)} draws its packages as boxes; its own containers are ignored`);
|
|
6291
|
+
return out;
|
|
6292
|
+
}
|
|
6293
|
+
|
|
6294
|
+
/* ---- the package's shape (§5): SHAPE.package, closed as a node, open as a box's frame ---- */
|
|
6295
|
+
const PK = { pad: 12, gap: 10, name: 13, st: 11, sth: 14, nl: 18, tab: 8, minW: 96, maxW: 320 }, pkTabW = n => Math.min(48, Math.round(n.w * .4));
|
|
6296
|
+
const pkTexts = n => [...OWN_ARR(n.stereotypes).map(t => [`«${t}»`, PK.st]), [n.label ?? '', PK.name]];
|
|
6297
|
+
SHAPE.package = {
|
|
6298
|
+
// closed: a folder, its body the node's box and its empty tab above it (UML 2.5.1, 12.2.4), the stereotypes and the name
|
|
6299
|
+
// centred in the body; as wide as its widest text, within 96 and 320 px (a longer one is cut)
|
|
6300
|
+
size: n => ({ w: Math.ceil(Math.min(PK.maxW, Math.max(PK.minW, ...pkTexts(n).map(([t, px]) => textW(t, px) + 2 * PK.pad)))), h: 2 * PK.gap + OWN_ARR(n.stereotypes).length * PK.sth + PK.nl }),
|
|
6301
|
+
out: SHAPE.classifier.out, face: n => [n.y, n.y + n.h], inset: () => 0, ports: SHAPE.classifier.ports,
|
|
6302
|
+
draw(n, c, f) {
|
|
6303
|
+
const { x, y, w, h } = n, tw = pkTabW(n), tabX = n.tabX ?? x, cx = r2(x + w / 2), st = OWN_ARR(n.stereotypes); let ty = y + PK.gap;
|
|
6304
|
+
const tx = st.map(t => { const s = `<text x="${cx}" y="${r2(ty + 11)}" text-anchor="middle" class="c-st" fill="var(--um-muted)" style="font:400 ${PK.st}px var(--um-sans)">${esc(fit(`«${t}»`, PK.st, w - 2 * PK.pad))}</text>`; ty += PK.sth; return s; });
|
|
6305
|
+
tx.push(`<text x="${cx}" y="${r2(ty + 14)}" text-anchor="middle" class="c-name" fill="var(--um-text)" style="font:600 ${PK.name}px var(--um-sans)" data-o="${esc(n.id)}">${esc(fit(n.label ?? '', PK.name, w - 2 * PK.pad))}</text>`);
|
|
6306
|
+
return `<g class="node package" data-id="${esc(n.id)}" data-shape="package" tabindex="-1" role="img" aria-label="${esc(n.aria || 'Package ' + (n.label ?? ''))}">`
|
|
6307
|
+
+ `<path d="M${tabX} ${y}V${y - PK.tab}H${r2(tabX + tw)}V${y}" class="n-det" fill="${f}" stroke="${c}"/><rect x="${x}" y="${y}" width="${w}" height="${h}" class="n-mask"/>`
|
|
6308
|
+
+ `<rect x="${x}" y="${y}" width="${w}" height="${h}" class="n-body" fill="${f}" stroke="${c}"/><g class="c-head" data-band="${r2(y)} ${r2(y + h)}">${tx.join('')}</g></g>`;
|
|
6309
|
+
},
|
|
6310
|
+
// open: the box's border, and a tab in its header room at the top left, closed by a line under it and one on its right,
|
|
6311
|
+
// holding the stereotypes and the name (the box's title, cut to its width). The border has class family-box, which the
|
|
6312
|
+
// canvas outlines on selection and under a drag; the title keeps class family-title, which the audit and the canvas read
|
|
6313
|
+
frame(f) {
|
|
6314
|
+
const hd = Math.min(f.w - 16, textW(f.title, 11) + 16), tr = r2(f.x + 8 + hd);
|
|
6315
|
+
return `<rect class="family-box" x="${f.x}" y="${f.y}" width="${f.w}" height="${f.h}" stroke="var(--um-muted)"/><path d="M${f.x} ${r2(f.y + 24)}H${tr}V${f.y}" class="pk-tab" fill="none" stroke="var(--um-muted)" stroke-width="1.2"/>`
|
|
6316
|
+
+ `<text class="family-title" x="${r2(f.x + 12)}" y="${r2(f.y + 16)}" fill="var(--um-text)" style="font:600 11px var(--um-sans)" data-t="ft" data-o="${esc(f.id)}">${esc(fit(f.title ?? f.label ?? f.id, 11, hd - 4))}</text>`;
|
|
6317
|
+
},
|
|
6318
|
+
};
|
|
6319
|
+
|
|
6320
|
+
/* A closed package's tab sits above its box, where the router's ports do not know it is: laid out, the tab moves to where no
|
|
6321
|
+
line ends on the top keep 6 px off its edges (an arrowhead's half width and a pixel), over the fewest ends and nearest the
|
|
6322
|
+
left corner (n.tabX, when it moves), and a line ending under it ends on the tab's top. g: a laid-out view; a memo hands back
|
|
6323
|
+
the same one, changed once */
|
|
6324
|
+
const TABBED = new WeakSet();
|
|
6325
|
+
function placeTabs(g) {
|
|
6326
|
+
if (TABBED.has(g)) return g; TABBED.add(g);
|
|
6327
|
+
g.nodes.filter(n => n.shape === 'package').forEach(n => {
|
|
6328
|
+
const ends = g.edges.flatMap(e => [[e, 0, e.from], [e, e.pts.length - 1, e.to]]).filter(([e, k, id]) => id === n.id && e.pts[k][1] === n.y), tw = pkTabW(n);
|
|
6329
|
+
if (!ends.length) return;
|
|
6330
|
+
const xs = ends.map(([e, k]) => e.pts[k][0]), over = t => xs.filter(p => p >= t && p <= t + tw).length, near = t => xs.filter(p => p > t - 6 && p < t + tw + 6).length - over(t);
|
|
6331
|
+
const [tx] = [n.x, ...xs.flatMap(p => [p + 7, p - 7 - tw])].filter(t => t >= n.x && t <= n.x + n.w - tw)
|
|
6332
|
+
.sort((a, b) => near(a) - near(b) || over(a) - over(b) || a - b);
|
|
6333
|
+
if (tx !== n.x) n.tabX = tx;
|
|
6334
|
+
ends.forEach(([e, k]) => { const p = e.pts[k][0]; if (p >= tx && p <= tx + tw) e.pts[k] = [p, n.y - PK.tab]; });
|
|
6335
|
+
});
|
|
6336
|
+
return g;
|
|
6337
|
+
}
|
|
6015
6338
|
/* ---------- the class diagram, uml.class (work package 1.9) ----------
|
|
6016
6339
|
The subset of proposal.md §8.2: class, interface, enumeration with literals, data type, primitive type; attributes and
|
|
6017
|
-
operations in UML's text form; stereotypes as text; packages as owners
|
|
6340
|
+
operations in UML's text form; stereotypes as text; packages as owners, a package holding what it owns that the view
|
|
6341
|
+
shows as a box, and names qualified by the owners it does not show (2.2, notes/stage-2.md §4 to §7, src/uml/owners.js);
|
|
6018
6342
|
association with both ends, generalization, interface realization, dependency; comments. A view whose `diagram` is
|
|
6019
6343
|
'uml.class' is laid out by layoutTyped (src/typed/layout.js) and drawn with the classifier shape and the line-end table
|
|
6020
6344
|
(src/typed/draw.js). Ships in lib/umlimator-all.js only (stage 1 note §2.5). */
|
|
@@ -6185,16 +6509,20 @@ function lineLook(e) {
|
|
|
6185
6509
|
case 'uml.InterfaceRealization': return { from: 'none', to: 'triangle', dashed: true };
|
|
6186
6510
|
case 'uml.Dependency': return { from: 'none', to: 'open', dashed: true };
|
|
6187
6511
|
case 'uml.CommentLink': return { from: 'none', to: 'none', dashed: true };
|
|
6512
|
+
case 'uml.PackageImport': case 'uml.PackageMerge': return { from: 'none', to: 'open', dashed: true };
|
|
6188
6513
|
case 'uml.Association': { const E = endsOf(e), lab = w => ({ role: (E[w] || {}).role, mult: (E[w] || {}).multiplicity });
|
|
6189
6514
|
return { from: endOfLine(e, 'from'), to: endOfLine(e, 'to'), labels: { from: lab('from'), to: lab('to') } }; }
|
|
6190
6515
|
default: return { from: 'none', to: 'arrow' };
|
|
6191
6516
|
}
|
|
6192
6517
|
}
|
|
6193
6518
|
const stereoText = x => UML_ARR(UML_OBJ(x.props) ? x.props.stereotypes : null).filter(UML_HAS).map(t => `«${t}»`).join(' ');
|
|
6194
|
-
|
|
6519
|
+
// a package import says «import», or «access» when private; a merge «merge» (notes/stage-2.md §10.2)
|
|
6520
|
+
const keyword = e => e.type === 'uml.PackageImport' ? ((UML_OBJ(e.props) ? e.props.visibility : null) === 'private' ? '«access»' : '«import»') : e.type === 'uml.PackageMerge' ? '«merge»' : '';
|
|
6521
|
+
const lineLabel = e => [keyword(e), stereoText(e), e.label].filter(UML_HAS).join(' ');
|
|
6195
6522
|
const LINE_WORDS = { 'uml.Association': ['Association', 'a link between instances'], 'uml.Generalization': ['Generalization', 'is a kind of the one it points to'],
|
|
6196
6523
|
'uml.InterfaceRealization': ['Realization', 'implements the interface it points to'], 'uml.Dependency': ['Dependency', 'uses the one it points to'],
|
|
6197
|
-
'uml.CommentLink': ['Comment link', 'what a comment is about']
|
|
6524
|
+
'uml.CommentLink': ['Comment link', 'what a comment is about'], 'uml.PackageImport': ['Package import', 'sees the names of the one it points to: «import», or «access» kept private'],
|
|
6525
|
+
'uml.PackageMerge': ['Package merge', 'takes in what the one it points to holds'] };
|
|
6198
6526
|
const LEGEND_ENDS = { // small copies of src/typed/draw.js's ends, pointing right with the tip at x 26
|
|
6199
6527
|
none: '', open: '<path d="M17 4L26 8L17 12" fill="none"/>', triangle: '<path d="M16 3L26 8L16 13Z" fill="var(--um-panel)"/>',
|
|
6200
6528
|
diamond: '<path d="M12 8L19 4L26 8L19 12Z" fill="var(--um-panel)"/>', 'filled-diamond': '<path d="M12 8L19 4L26 8L19 12Z" fill="var(--um-muted)"/>' };
|
|
@@ -6220,39 +6548,61 @@ function classIndex(model) {
|
|
|
6220
6548
|
const typeWord = t => String(t || 'uml.Class').split('.').pop().replace(/([a-z])([A-Z])/g, (_, a, b) => a + ' ' + b.toLowerCase()); // 'uml.DataType': "Data type"
|
|
6221
6549
|
const nameOfEl = x => String(x.label ?? x.id);
|
|
6222
6550
|
const plural = (k, w) => `${k} ${k === 1 ? w : w + 's'}`;
|
|
6223
|
-
|
|
6224
|
-
|
|
6551
|
+
/* A type's look (notes/stage-2.md §10.2): the class, package and object views share classGeo's code. members: whether a
|
|
6552
|
+
classifier shows its compartments; own: the line types a view with no `edges` list draws (none: every type); word: the
|
|
6553
|
+
diagram's name in describe() */
|
|
6554
|
+
const CLASS_LOOK = { members: true, own: null, word: 'class diagram' };
|
|
6555
|
+
// an element's accessible name: "Class Order (entity, abstract), 5 attributes, 3 operations", by its drawn name (pic: the
|
|
6556
|
+
// view's owners, owners.js); "Package sales, holding 4 elements"; "Package payments (closed)"
|
|
6557
|
+
function nodeName(n, pic, look = CLASS_LOOK) {
|
|
6225
6558
|
if (n.type === 'uml.Comment') return 'Comment: ' + nameOfEl(n);
|
|
6226
|
-
const p = UML_OBJ(n.props) ? n.props : {}, why = [...UML_ARR(p.stereotypes).filter(UML_HAS), ...(p.isAbstract ? ['abstract'] : [])];
|
|
6227
|
-
const
|
|
6228
|
-
|
|
6559
|
+
const p = UML_OBJ(n.props) ? n.props : {}, why = [...UML_ARR(p.stereotypes).filter(UML_HAS), ...(p.isAbstract ? ['abstract'] : [])], nm = pic ? pic.name(n.id) : nameOfEl(n);
|
|
6560
|
+
if (n.type === 'uml.Package') { const b = pic && pic.boxes.find(x => x.id === n.id); return b ? b.aria : `Package ${nm} (${[...why, 'closed'].join(', ')})`; }
|
|
6561
|
+
const counts = look.members ? compartmentsOf(n).map(c => plural(c.members.length, c.name.replace(/s$/, ''))) : [];
|
|
6562
|
+
return [`${typeWord(n.type)} ${nm}${why.length ? ` (${why.join(', ')})` : ''}`, ...counts].join(', ');
|
|
6229
6563
|
}
|
|
6230
|
-
function lineName(e, at) {
|
|
6231
|
-
const
|
|
6564
|
+
function lineName(e, at, pic) {
|
|
6565
|
+
const drawn = x => (pic && pic.at.get(x.id) === x ? pic.name(x.id) : nameOfEl(x)); // a node's drawn name; a member's owner's too
|
|
6566
|
+
const nm = id => { const r = at.get(id); return !r ? id : r.owner ? drawn(r.owner) + '.' + nameOfEl(r.x) : r.kind === 'node' ? drawn(r.x) : nameOfEl(r.x); };
|
|
6232
6567
|
const [word] = UML_OWN(LINE_WORDS, e.type) || ['Line'], said = lineLabel(e), E = endsOf(e);
|
|
6233
6568
|
const end = w => { const t = [(E[w] || {}).role, (E[w] || {}).multiplicity].filter(UML_HAS); return t.length ? ` (${t.join(', ')})` : ''; };
|
|
6234
6569
|
return `${word} from ${nm(e.from)}${end('from')} to ${nm(e.to)}${end('to')}${said ? ': ' + said : ''}`;
|
|
6235
6570
|
}
|
|
6236
6571
|
|
|
6237
|
-
/*
|
|
6238
|
-
|
|
6239
|
-
|
|
6240
|
-
function
|
|
6241
|
-
const at = classIndex(model),
|
|
6242
|
-
const
|
|
6243
|
-
const
|
|
6244
|
-
|
|
6245
|
-
|
|
6246
|
-
|
|
6572
|
+
/* a view's lines: { at, pic, lines: [{ e, from, to }] }, each line the view lists (or, with no list, each model line of the
|
|
6573
|
+
look's own types) whose ends, a member's taken to its owner, the view both shows. A line between a package and something it
|
|
6574
|
+
holds is among them: it is drawn by none (holdsLine), and rule line-inside warns of it */
|
|
6575
|
+
function drawnLines(model, view, own) {
|
|
6576
|
+
const at = classIndex(model), pic = ownersOn(model, view), shown = new Set(pic.ids), top = id => { const r = at.get(id); return r && r.owner ? top(r.owner.id) : id; };
|
|
6577
|
+
const list = Array.isArray(view.edges) ? view.edges : UML_ARR(model.edges).filter(e => !own || (UML_OBJ(e) && own.includes(e.type)));
|
|
6578
|
+
const lines = list.map(e => (typeof e === 'string' ? (at.get(e) || {}).x : e)).filter(e => UML_OBJ(e) && typeof e.id === 'string')
|
|
6579
|
+
.map(e => ({ e, from: top(e.from), to: top(e.to) })).filter(({ from, to }) => shown.has(from) && shown.has(to));
|
|
6580
|
+
return { at, pic, lines };
|
|
6581
|
+
}
|
|
6582
|
+
|
|
6583
|
+
/* the view's geometry, for a type's look: { model, view, pos (col, row by id, the layered layout's ranking or the view's
|
|
6584
|
+
pins), memo, direction }. A line that ends on a member is drawn to the member's owner; a line between two nodes the view
|
|
6585
|
+
does not both show is not drawn. An open package is a box (its id takes no slot), passed to layoutTyped as a container */
|
|
6586
|
+
const typeGeo = look => ({ model, view, pos, memo }) => {
|
|
6587
|
+
const { at, pic, lines } = drawnLines(model, view, look.own), present = UML_OBJ(view.present) ? view.present : {};
|
|
6588
|
+
const nodes = pic.ids.filter(id => !pic.open.has(id)).map(id => { const n = at.get(id).x, pr = UML_OBJ(UML_OWN(present, id)) ? present[id] : {}, p = UML_OBJ(n.props) ? n.props : {};
|
|
6589
|
+
const st = UML_ARR(p.stereotypes).filter(UML_HAS);
|
|
6590
|
+
const shape = n.type === 'uml.Comment' ? { shape: 'note' } : n.type === 'uml.Package' ? { shape: 'package', stereotypes: st }
|
|
6591
|
+
: { shape: 'classifier', stereotypes: [...(UML_OWN(IMPLIED, n.type) ? [IMPLIED[n.type]] : []), ...st], abstract: !!p.isAbstract, compartments: look.members ? compartmentsOf(n) : [],
|
|
6592
|
+
collapsed: UML_ARR(pr.collapsed), ...(Number.isFinite(pr.rows) ? { rows: pr.rows } : {}) };
|
|
6593
|
+
const d = { id, label: n.type === 'uml.Comment' ? nameOfEl(n) : pic.name(id), type: n.type || 'uml.Class', kind: 'file', ...shape, aria: nodeName(n, pic, look), col: (UML_OWN(pos, id) || [0, 0])[0], row: (UML_OWN(pos, id) || [0, 0])[1] };
|
|
6247
6594
|
return { ...d, ...SHAPE[d.shape].size(d) }; });
|
|
6248
|
-
const
|
|
6249
|
-
|
|
6250
|
-
|
|
6251
|
-
|
|
6252
|
-
|
|
6595
|
+
const edges = lines.filter(({ from, to }) => !holdsLine(pic, from, to))
|
|
6596
|
+
.map(({ e, from, to }) => ({ id: e.id, _id: e.id, type: e.type, from, to, label: lineLabel(e) || undefined, end: lineLook(e), aria: lineName(e, at, pic), ...(UML_OBJ(e.style) || typeof e.style === 'string' ? { style: e.style } : {}) }));
|
|
6597
|
+
// a box's border and padding take 48 px of each side of the gap between columns, so a line from box to box has none left for
|
|
6598
|
+
// its label, which is then not drawn: laid out left to right, a view with boxes and labelled lines spreads its columns by its
|
|
6599
|
+
// widest label and 40 px for its stubs (laid out down, a label sits across its line, in the gap between rows)
|
|
6600
|
+
const down = view.direction === 'down', wide = !down && pic.boxes.length ? Math.max(0, ...edges.filter(e => e.label).map(labelW)) : 0;
|
|
6601
|
+
const g = layoutTyped({ nodes, edges, ...(pic.boxes.length ? { containers: pic.boxes } : {}), direction: down ? 'down' : 'right', gap: 96 + (wide ? Math.ceil(wide) + 40 : 0), rgap: 56 }, memo);
|
|
6253
6602
|
g.legend = legendOf(g.edges);
|
|
6254
|
-
return g;
|
|
6255
|
-
}
|
|
6603
|
+
return placeTabs(g);
|
|
6604
|
+
};
|
|
6605
|
+
const classGeo = typeGeo(CLASS_LOOK);
|
|
6256
6606
|
|
|
6257
6607
|
/* ---- validators (proposal.md §8.3). Each problem: { level, rule, path, message, ids }; the message names the rule and
|
|
6258
6608
|
the elements. Ownership cycles and references that do not resolve are the core's checks (src/core/model.js) ---- */
|
|
@@ -6314,28 +6664,30 @@ function classProblems(model) {
|
|
|
6314
6664
|
return out;
|
|
6315
6665
|
}
|
|
6316
6666
|
|
|
6317
|
-
/* ---- describe(): the view in words, per element: "Class Order (entity), 5 attributes, 3
|
|
6318
|
-
OrderLine (lines, 1..*)" ---- */
|
|
6319
|
-
|
|
6667
|
+
/* ---- describe(): the view in words, per element, each box then what it holds: "Class Order (entity), 5 attributes, 3
|
|
6668
|
+
operations; associated with OrderLine (lines, 1..*)"; "Package sales, holding 4 elements; imports catalog" ---- */
|
|
6669
|
+
const describeAs = look => (model, viewId) => {
|
|
6320
6670
|
const v = UML_ARR(model && model.views).find(x => UML_OBJ(x) && x.id === viewId); if (!v) throw new Error(`umlimator: no view ${JSON.stringify(viewId)}`);
|
|
6321
|
-
const at =
|
|
6322
|
-
const
|
|
6323
|
-
const lines = (Array.isArray(v.edges) ? v.edges : UML_ARR(model.edges)).map(e => (typeof e === 'string' ? (at.get(e) || {}).x : e))
|
|
6324
|
-
.filter(e => UML_OBJ(e) && shown.has(top(e.from)) && shown.has(top(e.to)));
|
|
6671
|
+
const { at, pic, lines: all } = drawnLines(model, v, look.own), lines = all.filter(({ from, to }) => !holdsLine(pic, from, to));
|
|
6672
|
+
const nm = id => (at.get(id).x.type === 'uml.Comment' ? nameOfEl(at.get(id).x) : pic.name(id));
|
|
6325
6673
|
const end = (e, w) => { const E = endsOf(e)[w] || {}, t = [E.role, E.multiplicity].filter(UML_HAS); return t.length ? ` (${t.join(', ')})` : ''; };
|
|
6326
|
-
const out = [`${v.title || v.id}: a
|
|
6327
|
-
|
|
6328
|
-
lines.forEach(e
|
|
6674
|
+
const out = [`${v.title || v.id}: a ${look.word} of ${plural(pic.ids.length, 'element')} and ${plural(lines.length, 'line')}.`];
|
|
6675
|
+
readingOrder(pic).forEach(id => { const said = [];
|
|
6676
|
+
lines.forEach(({ e, from: f, to: t }) => { const E = endsOf(e);
|
|
6329
6677
|
if (e.type === 'uml.Association' && (f === id || t === id)) { const [me, far] = f === id ? ['from', 'to'] : ['to', 'from'], agg = (E[me] || {}).aggregation, whole = (E[far] || {}).aggregation;
|
|
6330
6678
|
const verb = agg === 'composite' ? 'composed of' : agg === 'shared' ? 'aggregates' : whole === 'composite' ? 'part of' : whole === 'shared' ? 'held by' : 'associated with';
|
|
6331
6679
|
said.push(`${verb} ${nm(far === 'to' ? t : f)}${end(e, far)}${UML_HAS(e.label) ? ` ("${e.label}")` : ''}`); }
|
|
6332
|
-
else if (f
|
|
6333
|
-
else if (
|
|
6334
|
-
else if (
|
|
6335
|
-
else if (
|
|
6336
|
-
|
|
6680
|
+
else if (f !== id) return;
|
|
6681
|
+
else if (e.type === 'uml.Generalization') said.push(`a kind of ${nm(t)}`);
|
|
6682
|
+
else if (e.type === 'uml.InterfaceRealization') said.push(`realizes ${nm(t)}`);
|
|
6683
|
+
else if (e.type === 'uml.Dependency') said.push(`depends on ${nm(t)}${stereoText(e) ? ' ' + stereoText(e) : ''}`);
|
|
6684
|
+
else if (e.type === 'uml.PackageImport') said.push(`${keyword(e) === '«access»' ? 'accesses' : 'imports'} ${nm(t)}`);
|
|
6685
|
+
else if (e.type === 'uml.PackageMerge') said.push(`merges ${nm(t)}`);
|
|
6686
|
+
else if (e.type === 'uml.CommentLink') said.push(`about ${nm(t)}`); });
|
|
6687
|
+
out.push(nodeName(at.get(id).x, pic, look) + (said.length ? '; ' + said.join('; ') : '') + '.'); });
|
|
6337
6688
|
return out.join('\n');
|
|
6338
|
-
}
|
|
6689
|
+
};
|
|
6690
|
+
const classDescribe = describeAs(CLASS_LOOK);
|
|
6339
6691
|
|
|
6340
6692
|
/* ---- the type ---- */
|
|
6341
6693
|
const CLASS_TYPE = registerDiagramType('uml.class', {
|
|
@@ -6344,11 +6696,210 @@ const CLASS_TYPE = registerDiagramType('uml.class', {
|
|
|
6344
6696
|
// general one comes first: above its specializations in a view laid out down (risks.md L6)
|
|
6345
6697
|
rankFlip: e => e.type === 'uml.Generalization' || e.type === 'uml.InterfaceRealization',
|
|
6346
6698
|
layout: classGeo,
|
|
6699
|
+
// the view's open packages, the boxes the layout draws (notes/stage-2.md §4), and the Owner field's packages (§8)
|
|
6700
|
+
boxes: (model, view) => ownersOn(model, view).boxes,
|
|
6701
|
+
owners: ownerChoices,
|
|
6347
6702
|
member: { print: printMember, edit: memberEdit, add: memberAdd, holds: n => compartmentsOf(n).length > 0 }, // holds: the panel offers members (not on a comment or a package)
|
|
6348
|
-
validate: classProblems,
|
|
6349
|
-
rules: RULES,
|
|
6703
|
+
validate: (model, view) => [...classProblems(model), ...ownerProblems(model, view, drawnLines(model, view, null).lines)],
|
|
6704
|
+
rules: { ...RULES, ...OWNER_RULES },
|
|
6350
6705
|
describe: classDescribe,
|
|
6351
6706
|
});
|
|
6707
|
+
/* ---------- the package diagram, uml.package (work package 2.2, notes/stage-2.md §10.2, §10.3) ----------
|
|
6708
|
+
Packages and the classifiers of the class diagram, drawn by the class view's code with their names only: a package holding
|
|
6709
|
+
what it owns that the view shows is a box (src/uml/owners.js); package imports («import», or «access» when private),
|
|
6710
|
+
merges («merge») and dependencies, dashed with an open arrow; comments. A view with no `edges` list draws these line
|
|
6711
|
+
types only. Ships in lib/umlimator-all.js only. */
|
|
6712
|
+
|
|
6713
|
+
const PACKAGE_LINES = ['uml.PackageImport', 'uml.PackageMerge', 'uml.Dependency', 'uml.CommentLink'];
|
|
6714
|
+
const PACKAGE_LOOK = { members: false, own: PACKAGE_LINES, word: 'package diagram' };
|
|
6715
|
+
const PACKAGE_RULES = {
|
|
6716
|
+
'import-ends': 'a package import or a package merge runs from a package to another package',
|
|
6717
|
+
'package-names': 'packages in one owner have different names',
|
|
6718
|
+
};
|
|
6719
|
+
|
|
6720
|
+
// the package type's own rules (the class type's and the owners' run too): each problem { level, rule, path, message, ids }
|
|
6721
|
+
function packageProblems(model) {
|
|
6722
|
+
const out = [], nodes = (Array.isArray(model.nodes) ? model.nodes : []).filter(n => n && typeof n === 'object' && typeof n.id === 'string');
|
|
6723
|
+
const at = new Map(nodes.map(n => [n.id, n])), q = id => JSON.stringify(String((at.get(id) || {}).label ?? id));
|
|
6724
|
+
const say = (rule, path, ids, msg) => out.push({ level: 'error', rule, path, ids, message: `${rule}: ${msg}` });
|
|
6725
|
+
(Array.isArray(model.edges) ? model.edges : []).forEach((e, i) => { if (!e || typeof e !== 'object' || (e.type !== 'uml.PackageImport' && e.type !== 'uml.PackageMerge')) return;
|
|
6726
|
+
const pk = id => (at.get(id) || {}).type === 'uml.Package';
|
|
6727
|
+
if (!pk(e.from) || !pk(e.to) || e.from === e.to) say('import-ends', `edges[${i}]`, [e.id, e.from, e.to].filter(x => x != null), `${e.id} runs from ${q(e.from)} to ${q(e.to)}: from a package to another package`); });
|
|
6728
|
+
const seen = new Map();
|
|
6729
|
+
(Array.isArray(model.nodes) ? model.nodes : []).forEach((n, i) => { if (!nodes.includes(n) || n.type !== 'uml.Package') return; const k = (n.parent ?? '') + '\u0000' + String(n.label ?? n.id), was = seen.get(k);
|
|
6730
|
+
if (was) say('package-names', `nodes[${i}]`, [was, n.id], `${q(n.id)} is named twice in ${n.parent != null ? q(n.parent) : 'the model'} (${was}, ${n.id})`); else seen.set(k, n.id); });
|
|
6731
|
+
return out;
|
|
6732
|
+
}
|
|
6733
|
+
|
|
6734
|
+
const PACKAGE_TYPE = registerDiagramType('uml.package', {
|
|
6735
|
+
title: 'package diagram',
|
|
6736
|
+
elements: [['Package', 'Package'], ['Class', 'Class'], ['Interface', 'Interface'], ['Enumeration', 'Enumeration'], ['DataType', 'Data type'], ['PrimitiveType', 'Primitive type'], ['Comment', 'Comment']]
|
|
6737
|
+
.map(([t, label]) => ({ type: 'uml.' + t, label })),
|
|
6738
|
+
lines: [['PackageImport', 'Import'], ['PackageMerge', 'Merge'], ['Dependency', 'Dependency'], ['CommentLink', 'Comment link']].map(([t, label]) => ({ type: 'uml.' + t, label })),
|
|
6739
|
+
props: { ...CLASS_DEF.props, 'uml.PackageImport': [{ key: 'visibility', label: 'Visibility', kind: 'enum', options: ['public', 'private'] }], 'uml.PackageMerge': [] },
|
|
6740
|
+
rankFlip: e => e.type === 'uml.Generalization' || e.type === 'uml.InterfaceRealization',
|
|
6741
|
+
layout: typeGeo(PACKAGE_LOOK),
|
|
6742
|
+
boxes: (model, view) => ownersOn(model, view).boxes,
|
|
6743
|
+
owners: ownerChoices,
|
|
6744
|
+
member: { holds: () => false }, // a classifier is drawn by its name here; its members are edited in a class view
|
|
6745
|
+
validate: (model, view) => [...classProblems(model), ...packageProblems(model), ...ownerProblems(model, view, drawnLines(model, view, PACKAGE_LINES).lines)],
|
|
6746
|
+
rules: { ...RULES, ...OWNER_RULES, ...PACKAGE_RULES },
|
|
6747
|
+
describe: describeAs(PACKAGE_LOOK),
|
|
6748
|
+
});
|
|
6749
|
+
/* ---------- the object diagram, uml.object (work package 2.3, notes/stage-2.md §9) ----------
|
|
6750
|
+
Instance specifications drawn with the classifier shape, the head underlined, `name : Classifier` (or `: Classifier`
|
|
6751
|
+
for an anonymous one, or `name` with no classifier), and one compartment of slots, `feature = value`; links drawn as an
|
|
6752
|
+
association with no end marks, their name (or their association) on the line and each end's role by its end; comments.
|
|
6753
|
+
A package that owns a shown instance is a box, and names are qualified, as in the class view (src/uml/owners.js). A view
|
|
6754
|
+
with no `edges` list draws links and comment links only. Ships in lib/umlimator-all.js only. */
|
|
6755
|
+
|
|
6756
|
+
const OB_OBJ = x => x !== null && typeof x === 'object' && !Array.isArray(x), OB_ARR = x => (Array.isArray(x) ? x : []);
|
|
6757
|
+
const OB_HAS = x => x != null && x !== '';
|
|
6758
|
+
const OB_OWN = (o, k) => (o != null && Object.hasOwn(o, k) ? o[k] : undefined);
|
|
6759
|
+
const OB_PROPS = x => (OB_OBJ(x.props) ? x.props : {});
|
|
6760
|
+
const OBJECT_LINES = ['uml.Link', 'uml.CommentLink'];
|
|
6761
|
+
const OB_CLASSIFIERS = ['uml.Class', 'uml.Interface', 'uml.Enumeration', 'uml.DataType', 'uml.PrimitiveType'];
|
|
6762
|
+
|
|
6763
|
+
/* ---- the slot micro-syntax (§9.2, risks.md E3): `feature = value`, or `feature`. The feature is a name as parseMember
|
|
6764
|
+
reads one, the value the rest of the line, trimmed; print(parse(x)) is stable ---- */
|
|
6765
|
+
const SLOT_NAME = /^[\p{L}_$][\p{L}\p{M}\p{N}_$]*/u;
|
|
6766
|
+
function parseSlot(text) {
|
|
6767
|
+
const s = String(text ?? '').trim(), fail = why => ({ error: `"${s}" is not a slot: ${why}` }), nm = SLOT_NAME.exec(s);
|
|
6768
|
+
if (!nm) return fail(s ? 'a feature\'s name comes first' : 'it is empty');
|
|
6769
|
+
const rest = s.slice(nm[0].length).trim();
|
|
6770
|
+
if (!rest) return { type: 'uml.Slot', label: nm[0], props: {} };
|
|
6771
|
+
if (rest[0] !== '=') return fail(`"${rest}" is left over: write feature = value`);
|
|
6772
|
+
const value = rest.slice(1).trim();
|
|
6773
|
+
return value ? { type: 'uml.Slot', label: nm[0], props: { value } } : fail('a value follows =');
|
|
6774
|
+
}
|
|
6775
|
+
const printSlot = m => String((m && m.label) ?? '') + (OB_HAS(OB_PROPS(m || {}).value) ? ' = ' + OB_PROPS(m).value : '');
|
|
6776
|
+
// a slot edited as text: one transaction; props the syntax does not write are kept
|
|
6777
|
+
function slotEdit(m, text) {
|
|
6778
|
+
const r = parseSlot(text); if (r.error) return r;
|
|
6779
|
+
const { value: _, ...keep } = OB_PROPS(m);
|
|
6780
|
+
return { commands: [{ op: 'set', id: m.id, path: 'label', value: r.label }, { op: 'set', id: m.id, path: 'props', value: { ...keep, ...r.props } }] };
|
|
6781
|
+
}
|
|
6782
|
+
|
|
6783
|
+
/* ---- names ---- */
|
|
6784
|
+
const slotsOf = n => OB_ARR(n.members).filter(m => OB_OBJ(m) && m.type === 'uml.Slot');
|
|
6785
|
+
// an instance's head: "order1042 : Order", ": Order" (anonymous), "order1042" (no classifier); name: its drawn name (owners.js)
|
|
6786
|
+
const headOf = (n, name) => { const c = OB_PROPS(n).classifier, named = OB_HAS(n.label) || !OB_HAS(c);
|
|
6787
|
+
return [named ? name : '', OB_HAS(c) ? ': ' + c : ''].filter(Boolean).join(' '); };
|
|
6788
|
+
const obStereo = x => OB_ARR(OB_PROPS(x).stereotypes).filter(OB_HAS).map(String);
|
|
6789
|
+
/* a classifier the view shows (the outline's Show adds any model node) is drawn and named as the class view draws it: its name
|
|
6790
|
+
not underlined, its keyword over it, its compartments; "Class Order, 1 attribute, 0 operations".
|
|
6791
|
+
ponytail: mirrors class.js compartmentsOf/nodeName/IMPLIED, which it does not export; share them when class.js is next opened */
|
|
6792
|
+
const OB_IMPLIED = { 'uml.Interface': 'interface', 'uml.Enumeration': 'enumeration', 'uml.DataType': 'dataType', 'uml.PrimitiveType': 'primitive' };
|
|
6793
|
+
const OB_KINDS = { attributes: 'uml.Property', operations: 'uml.Operation', literals: 'uml.EnumerationLiteral' };
|
|
6794
|
+
const isInstance = n => !OB_CLASSIFIERS.includes(n.type); // anything else shown here is drawn as an instance, as before
|
|
6795
|
+
function classifierBoxes(n) {
|
|
6796
|
+
const c = name => ({ name, members: OB_ARR(n.members).filter(m => OB_OBJ(m) && m.type === OB_KINDS[name]) });
|
|
6797
|
+
return n.type === 'uml.Enumeration' ? [c('literals'), ...[c('attributes'), c('operations')].filter(x => x.members.length)] : [c('attributes'), c('operations')];
|
|
6798
|
+
}
|
|
6799
|
+
const obWord = t => t.split('.').pop().replace(/([a-z])([A-Z])/g, (_, a, b) => a + ' ' + b.toLowerCase());
|
|
6800
|
+
// an element's accessible name, as the classifier shape writes it: "Instance specification order1042 : Order, 2 slots"
|
|
6801
|
+
function obNodeName(n, pic) {
|
|
6802
|
+
if (n.type === 'uml.Comment') return 'Comment: ' + String(n.label ?? n.id);
|
|
6803
|
+
const st = obStereo(n), why = st.length ? ` (${st.join(', ')})` : '';
|
|
6804
|
+
if (n.type === 'uml.Package') { const b = pic.boxes.find(x => x.id === n.id); return b ? b.aria : `Package ${pic.name(n.id)} (${[...st, 'closed'].join(', ')})`; }
|
|
6805
|
+
if (!isInstance(n)) { const w = [...st, ...(OB_PROPS(n).isAbstract ? ['abstract'] : [])];
|
|
6806
|
+
return [`${obWord(n.type)} ${pic.name(n.id)}${w.length ? ` (${w.join(', ')})` : ''}`, ...classifierBoxes(n).map(c => { const k = c.members.length; return `${k} ${c.name.replace(/s$/, '')}${k === 1 ? '' : 's'}`; })].join(', '); }
|
|
6807
|
+
const k = slotsOf(n).length;
|
|
6808
|
+
return `Instance specification ${headOf(n, pic.name(n.id))}${why}${k ? `, ${k} slot${k === 1 ? '' : 's'}` : ''}`;
|
|
6809
|
+
}
|
|
6810
|
+
const linkText = e => [obStereo(e).map(t => `«${t}»`).join(' '), OB_HAS(e.label) ? e.label : OB_PROPS(e).association].filter(OB_HAS).join(' ');
|
|
6811
|
+
const roleOf = (e, w) => { const E = OB_PROPS(e).ends, r = OB_OBJ(E) && OB_OBJ(E[w]) ? E[w].role : null; return OB_HAS(r) ? String(r) : null; };
|
|
6812
|
+
|
|
6813
|
+
/* ---- the view laid out (layoutTyped) ---- */
|
|
6814
|
+
const OB_LEGEND = [['uml.Link', false, 'Link', 'joins two instances, an instance of an association'], ['uml.CommentLink', true, 'Comment link', 'what a comment is about']];
|
|
6815
|
+
const obLegend = edges => OB_LEGEND.filter(([t]) => edges.some(e => e.type === t)).map(([, dashed, name, says]) => `<span class="lgi" title="${esc(name + ' — ' + says)}"><svg class="lgs" viewBox="0 0 28 16" aria-hidden="true"><g stroke="var(--um-muted)" stroke-width="1.4"><path d="M2 8H26"${dashed ? ' stroke-dasharray="3 2"' : ''}/></g></svg><b>${esc(name)}</b><span>${esc(says)}</span></span>`);
|
|
6816
|
+
function objectGeo({ model, view, pos, memo }) {
|
|
6817
|
+
const { at, pic, lines } = drawnLines(model, view, OBJECT_LINES), present = OB_OBJ(view.present) ? view.present : {}, head = id => (at.get(id).x.type === 'uml.InstanceSpecification' ? headOf(at.get(id).x, pic.name(id)) : pic.name(id));
|
|
6818
|
+
const nodes = pic.ids.filter(id => !pic.open.has(id)).map(id => { const n = at.get(id).x, pr = OB_OBJ(OB_OWN(present, id)) ? present[id] : {}, slots = slotsOf(n);
|
|
6819
|
+
const shape = n.type === 'uml.Comment' ? { shape: 'note' } : n.type === 'uml.Package' ? { shape: 'package', stereotypes: obStereo(n) }
|
|
6820
|
+
: !isInstance(n) ? { shape: 'classifier', stereotypes: [...(OB_OWN(OB_IMPLIED, n.type) ? [OB_IMPLIED[n.type]] : []), ...obStereo(n)], abstract: !!OB_PROPS(n).isAbstract, compartments: classifierBoxes(n),
|
|
6821
|
+
collapsed: OB_ARR(pr.collapsed), ...(Number.isFinite(pr.rows) ? { rows: pr.rows } : {}) }
|
|
6822
|
+
// memberText draws props.default after " = ": a slot passed as { label: feature, props: { default: value } } (§9.1)
|
|
6823
|
+
: { shape: 'classifier', underline: true, stereotypes: obStereo(n), compartments: slots.length ? [{ name: 'slots', members: slots.map(m => ({ id: m.id, type: 'uml.Slot', label: m.label, props: { default: OB_PROPS(m).value } })) }] : [],
|
|
6824
|
+
collapsed: OB_ARR(pr.collapsed), ...(Number.isFinite(pr.rows) ? { rows: pr.rows } : {}) };
|
|
6825
|
+
const d = { id, label: n.type === 'uml.Comment' ? String(n.label ?? n.id) : head(id), type: n.type || 'uml.InstanceSpecification', kind: 'file', ...shape, aria: obNodeName(n, pic), col: (OB_OWN(pos, id) || [0, 0])[0], row: (OB_OWN(pos, id) || [0, 0])[1] };
|
|
6826
|
+
return { ...d, ...SHAPE[d.shape].size(d) }; });
|
|
6827
|
+
const edges = lines.filter(({ from, to }) => !holdsLine(pic, from, to)).map(({ e, from, to }) => { const said = linkText(e), r = w => roleOf(e, w), end = w => (r(w) ? ` (${r(w)})` : '');
|
|
6828
|
+
const look = e.type === 'uml.CommentLink' ? { from: 'none', to: 'none', dashed: true } : { from: 'none', to: 'none', labels: { from: { role: r('from') }, to: { role: r('to') } } };
|
|
6829
|
+
return { id: e.id, _id: e.id, type: e.type, from, to, label: said || undefined, end: look, aria: `${e.type === 'uml.CommentLink' ? 'Comment link' : 'Link'} from ${head(from)}${end('from')} to ${head(to)}${end('to')}${said ? ': ' + said : ''}`,
|
|
6830
|
+
...(OB_OBJ(e.style) || typeof e.style === 'string' ? { style: e.style } : {}) }; });
|
|
6831
|
+
// as the class view (class.js typeGeo): laid out right with boxes, the columns spread by the widest label
|
|
6832
|
+
const down = view.direction === 'down', wide = !down && pic.boxes.length ? Math.max(0, ...edges.filter(e => e.label).map(labelW)) : 0;
|
|
6833
|
+
const g = layoutTyped({ nodes, edges, ...(pic.boxes.length ? { containers: pic.boxes } : {}), direction: down ? 'down' : 'right', gap: 96 + (wide ? Math.ceil(wide) + 40 : 0), rgap: 56 }, memo);
|
|
6834
|
+
g.legend = obLegend(g.edges);
|
|
6835
|
+
return placeTabs(g);
|
|
6836
|
+
}
|
|
6837
|
+
|
|
6838
|
+
/* ---- validators (§9.3). Each problem: { level, rule, path, message, ids } ---- */
|
|
6839
|
+
const OBJECT_RULES = {
|
|
6840
|
+
'slot-kinds': 'an instance holds slots only; a slot belongs to an instance',
|
|
6841
|
+
'link-ends': 'a link joins two instance specifications',
|
|
6842
|
+
'instance-of': 'an instance\'s slots name attributes of its classifier, when the classifier is in the model',
|
|
6843
|
+
};
|
|
6844
|
+
function objectProblems(model) {
|
|
6845
|
+
const out = [], nodes = OB_ARR(model.nodes), say = (level, rule, path, ids, msg) => out.push({ level, rule, path, ids: ids.filter(x => x != null), message: `${rule}: ${msg}` });
|
|
6846
|
+
const at = new Map(); nodes.forEach(n => { if (OB_OBJ(n) && typeof n.id === 'string' && !at.has(n.id)) at.set(n.id, n); });
|
|
6847
|
+
const q = id => JSON.stringify(String((at.get(id) || {}).label ?? id)), isInst = id => (at.get(id) || {}).type === 'uml.InstanceSpecification';
|
|
6848
|
+
// the model's classifiers by name and by qualified name; a classifier's attributes, with its generals' (a cycle is walked once)
|
|
6849
|
+
const full = ownersOn(model, null).full, named = new Map(), add = (k, id) => named.set(k, [...(named.get(k) || []), id]);
|
|
6850
|
+
at.forEach(n => { if (OB_CLASSIFIERS.includes(n.type)) { add(String(n.label ?? n.id), n.id); const f = full(n.id); if (f !== String(n.label ?? n.id)) add(f, n.id); } });
|
|
6851
|
+
const up = new Map(); OB_ARR(model.edges).forEach(e => { if (OB_OBJ(e) && e.type === 'uml.Generalization') up.set(e.from, [...(up.get(e.from) || []), e.to]); });
|
|
6852
|
+
const attrs = id => { const seen = new Set(), names = new Set(), walk = c => { if (seen.has(c) || !at.has(c)) return; seen.add(c);
|
|
6853
|
+
OB_ARR(at.get(c).members).forEach(m => { if (OB_OBJ(m) && m.type === 'uml.Property') names.add(String(m.label)); }); (up.get(c) || []).forEach(walk); };
|
|
6854
|
+
walk(id); return names; };
|
|
6855
|
+
nodes.forEach((n, i) => { if (!OB_OBJ(n)) return; const ms = OB_ARR(n.members);
|
|
6856
|
+
if (n.type === 'uml.Slot') say('error', 'slot-kinds', `nodes[${i}]`, [n.id], `slot ${q(n.id)} is a node: a slot belongs to an instance`);
|
|
6857
|
+
ms.forEach((m, j) => { if (!OB_OBJ(m)) return; const inst = n.type === 'uml.InstanceSpecification';
|
|
6858
|
+
if (inst !== (m.type === 'uml.Slot')) say('error', 'slot-kinds', `nodes[${i}].members[${j}]`, [n.id, m.id], inst ? `${q(n.id)} holds ${m.id}, a ${String(m.type).split('.').pop()}: an instance holds slots only` : `slot "${m.label ?? m.id}" is in ${q(n.id)}, which is not an instance`); });
|
|
6859
|
+
const c = OB_PROPS(n).classifier, hits = n.type === 'uml.InstanceSpecification' && OB_HAS(c) ? [...new Set(named.get(String(c)) || [])] : [];
|
|
6860
|
+
if (hits.length !== 1) return; // no classifier, or one the model does not hold once: types are text (M8)
|
|
6861
|
+
const has = attrs(hits[0]);
|
|
6862
|
+
ms.forEach((m, j) => { if (OB_OBJ(m) && m.type === 'uml.Slot' && !has.has(String(m.label))) say('warning', 'instance-of', `nodes[${i}].members[${j}]`, [n.id, m.id, hits[0]], `${q(n.id)} is an instance of ${q(hits[0])}, which has no attribute "${m.label}" (${m.id})`); }); });
|
|
6863
|
+
OB_ARR(model.edges).forEach((e, i) => { if (OB_OBJ(e) && e.type === 'uml.Link' && !(isInst(e.from) && isInst(e.to)))
|
|
6864
|
+
say('error', 'link-ends', `edges[${i}]`, [e.id, e.from, e.to], `${e.id} runs from ${q(e.from)} to ${q(e.to)}: a link joins two instance specifications`); });
|
|
6865
|
+
return out;
|
|
6866
|
+
}
|
|
6867
|
+
|
|
6868
|
+
/* ---- describe(): the view in words, each box then what it holds: "Instance specification order1042 : Order, 2 slots:
|
|
6869
|
+
status = placed, total = 34.00 EUR; linked to line1 : OrderLine (lines)" ---- */
|
|
6870
|
+
function objectDescribe(model, viewId) {
|
|
6871
|
+
const v = OB_ARR(model && model.views).find(x => OB_OBJ(x) && x.id === viewId); if (!v) throw new Error(`umlimator: no view ${JSON.stringify(viewId)}`);
|
|
6872
|
+
const { at, pic, lines: all } = drawnLines(model, v, OBJECT_LINES), lines = all.filter(({ from, to }) => !holdsLine(pic, from, to));
|
|
6873
|
+
const nm = id => { const x = at.get(id).x; return x.type === 'uml.Comment' ? String(x.label ?? x.id) : x.type === 'uml.InstanceSpecification' ? headOf(x, pic.name(id)) : pic.name(id); };
|
|
6874
|
+
const k = (n, w) => `${n} ${n === 1 ? w : w + 's'}`;
|
|
6875
|
+
const out = [`${v.title || v.id}: an object diagram of ${k(pic.ids.length, 'element')} and ${k(lines.length, 'line')}.`];
|
|
6876
|
+
readingOrder(pic).forEach(id => { const x = at.get(id).x, said = [], slots = x.type === 'uml.InstanceSpecification' ? slotsOf(x).map(printSlot) : [];
|
|
6877
|
+
lines.forEach(({ e, from: f, to: t }) => {
|
|
6878
|
+
if (e.type === 'uml.CommentLink') { if (f === id) said.push(`about ${nm(t)}`); return; }
|
|
6879
|
+
if (f !== id && t !== id) return; const far = f === id ? 'to' : 'from', r = roleOf(e, far), l = linkText(e);
|
|
6880
|
+
said.push(`linked to ${nm(far === 'to' ? t : f)}${r ? ` (${r})` : ''}${l ? ` ("${l}")` : ''}`); });
|
|
6881
|
+
out.push(obNodeName(x, pic) + (slots.length ? ': ' + slots.join(', ') : '') + (said.length ? '; ' + said.join('; ') : '') + '.'); });
|
|
6882
|
+
return out.join('\n');
|
|
6883
|
+
}
|
|
6884
|
+
|
|
6885
|
+
/* ---- the type ---- */
|
|
6886
|
+
const OB_STEREO = { key: 'stereotypes', label: 'Stereotypes', kind: 'list' };
|
|
6887
|
+
const obRole = w => ({ key: `ends.${w}.role`, path: ['ends', w, 'role'], label: `${w === 'from' ? 'From' : 'To'} role`, kind: 'text' });
|
|
6888
|
+
const OBJECT_TYPE = registerDiagramType('uml.object', {
|
|
6889
|
+
title: 'object diagram',
|
|
6890
|
+
elements: [{ type: 'uml.InstanceSpecification', label: 'Instance' }, { type: 'uml.Comment', label: 'Comment' }],
|
|
6891
|
+
lines: [{ type: 'uml.Link', label: 'Link' }, { type: 'uml.CommentLink', label: 'Comment link' }],
|
|
6892
|
+
props: { 'uml.InstanceSpecification': [{ key: 'classifier', label: 'Classifier', kind: 'text' }, OB_STEREO], 'uml.Package': [OB_STEREO],
|
|
6893
|
+
'uml.Link': [{ key: 'association', label: 'Association', kind: 'text' }, obRole('from'), obRole('to'), OB_STEREO], 'uml.CommentLink': [] },
|
|
6894
|
+
layout: objectGeo,
|
|
6895
|
+
boxes: (model, view) => ownersOn(model, view).boxes,
|
|
6896
|
+
owners: ownerChoices,
|
|
6897
|
+
// a slot is a member row, `feature = value` (§9.2); a comment and a package hold none
|
|
6898
|
+
member: { print: printSlot, edit: slotEdit, add: parseSlot, holds: n => n.type === 'uml.InstanceSpecification' },
|
|
6899
|
+
validate: (model, view) => [...classProblems(model), ...objectProblems(model), ...ownerProblems(model, view, drawnLines(model, view, OBJECT_LINES).lines)],
|
|
6900
|
+
rules: { ...RULES, ...OBJECT_RULES, ...OWNER_RULES }, // member-names: two slots for one feature
|
|
6901
|
+
describe: objectDescribe,
|
|
6902
|
+
});
|
|
6352
6903
|
/* ---------- Mermaid classDiagram: a reader and a writer (plan.md 1.10; risks.md R1 to R4, R7) ----------
|
|
6353
6904
|
Written against Mermaid 12.3.0's class diagram page (docs/syntax/classDiagram.md, read 11 Nov 2026). Internal: the CLI
|
|
6354
6905
|
(`umlimator.py import`) imports this file through Node; a host gets it once the registry is public.
|
|
@@ -6363,8 +6914,9 @@ const CLASS_TYPE = registerDiagramType('uml.class', {
|
|
|
6363
6914
|
writeMermaidClass(model, viewId?) -> text: one class view as Mermaid. What Mermaid cannot carry is kept in its comments:
|
|
6364
6915
|
an id the reader would not derive, as `%% id: <id>` (and `%% link-id:` for a note's line) above its statement; an
|
|
6365
6916
|
element the subset leaves out, as `%% not written: <id> (<type>)`, and a property of one Mermaid has no place for, as
|
|
6366
|
-
`%% not written: <id>: <what>` (navigable on an end with a diamond, a
|
|
6367
|
-
unknown visibility, a stereotype the reader would read as something else). A
|
|
6917
|
+
`%% not written: <id>: <what>` (navigable on an end with a diamond, a parent the view does not show or that is not a
|
|
6918
|
+
namespace, a note's parent, an unknown visibility, a stereotype the reader would read as something else). A namespace
|
|
6919
|
+
with no class in it is not written, as Mermaid reads no empty one. A member whose text has a line break is not
|
|
6368
6920
|
written, nor a line that ends on a member. A model with views is written through its uml.class view; with none of them,
|
|
6369
6921
|
the text is one `%% not written:` line. A view's layout and present are not written.
|
|
6370
6922
|
|
|
@@ -6381,9 +6933,17 @@ const CLASS_TYPE = registerDiagramType('uml.class', {
|
|
|
6381
6933
|
are the multiplicity *, reported;
|
|
6382
6934
|
- a member named twice in one class (an operation with the same parameters' types) is read once, the other reported;
|
|
6383
6935
|
an interface that realizes another specializes it, reported;
|
|
6384
|
-
- namespaces are packages,
|
|
6936
|
+
- namespaces are packages, dotted (`A.B`) or nested by braces. Mermaid 12.3.0 draws nested braces as nested
|
|
6937
|
+
namespaces (docs/syntax/classDiagram.md, "Nested Namespaces (v11.15.0+)"; ClassDB.addNamespace, which names one
|
|
6938
|
+
inside P as P.<name>), so a namespace is known by its parent and its name here too. The writer nests by braces, and
|
|
6939
|
+
writes dotted, at the top, a namespace whose id is its parent's dotted path (what the reader makes of `A.B`). Mermaid
|
|
6940
|
+
splits any namespace's name at its dots, in backticks too, so a dotted id written otherwise (in braces, or at the top
|
|
6941
|
+
under a parent the view does not show, which its `%% not written:` line then says Mermaid draws) gives Mermaid a
|
|
6942
|
+
namespace the reader does not;
|
|
6943
|
+
- notes are comments, `note for` a comment with its line; `direction TB` is down (the default),
|
|
6385
6944
|
`LR` right; a %%{ ... }%% directive, on one line or several, is passed over. */
|
|
6386
6945
|
const MC_LIMIT = 1 << 20; // characters; a class diagram a person writes is a few kilobytes
|
|
6946
|
+
const MC_DOTS = 32; // a dotted namespace name's parts: each is a namespace whose id is its path, so a name of n parts costs n * n (R2)
|
|
6387
6947
|
const mcTable = o => Object.assign(Object.create(null), o); // a lookup table: "constructor" or "__proto__" finds nothing in it
|
|
6388
6948
|
const MC_VIS = mcTable({ '+': 'public', '-': 'private', '#': 'protected', '~': 'package' });
|
|
6389
6949
|
const MC_TYPES = mcTable({ interface: 'uml.Interface', enumeration: 'uml.Enumeration', enum: 'uml.Enumeration', datatype: 'uml.DataType', primitive: 'uml.PrimitiveType', primitivetype: 'uml.PrimitiveType' });
|
|
@@ -6567,7 +7127,7 @@ function readMermaidClass(text) {
|
|
|
6567
7127
|
i = j + 1;
|
|
6568
7128
|
}
|
|
6569
7129
|
|
|
6570
|
-
const classes = new Map(), packages = new Map(), order = [], relations = [], notes = [], stack = [];
|
|
7130
|
+
const classes = new Map(), packages = [], topNs = new Map(), order = [], relations = [], notes = [], stack = [];
|
|
6571
7131
|
let viewId, direction = 'down', started = false, hint = {}, block = null;
|
|
6572
7132
|
// classes and namespaces by their names, their ids given once all are read (a name that is not an id needs the others)
|
|
6573
7133
|
const cls = (name, no) => {
|
|
@@ -6644,16 +7204,20 @@ function readMermaidClass(text) {
|
|
|
6644
7204
|
if (!n) { say(no, 'skipped', 'a namespace with no name'); hint = {}; continue; }
|
|
6645
7205
|
let k = mcSkip(s, n[1]), label;
|
|
6646
7206
|
if (s.startsWith('["', k)) { const q = mcQuoted(s, k + 1); if (q && s[q[1]] === ']') { label = mcText(q[0]); k = mcSkip(s, q[1] + 1); } }
|
|
6647
|
-
|
|
6648
|
-
|
|
6649
|
-
|
|
6650
|
-
|
|
6651
|
-
|
|
7207
|
+
let segs = n[2] ? [n[0]] : n[0].split('.');
|
|
7208
|
+
if (segs.length > MC_DOTS) { say(no, 'approximated', `namespace ${n[0]}: over ${MC_DOTS} dotted parts, so read as one name`); segs = [n[0]]; }
|
|
7209
|
+
// a namespace is known by its parent and its name, as Mermaid's own qualified id (A inside P is P.A); its id is
|
|
7210
|
+
// the name as written, a dotted path from its first part
|
|
7211
|
+
let parent = where(), path = '';
|
|
7212
|
+
for (const seg of segs) {
|
|
7213
|
+
path = path ? `${path}.${seg}` : seg;
|
|
7214
|
+
const kids = parent ? (parent.kids ??= new Map()) : topNs;
|
|
7215
|
+
if (!kids.has(seg)) {
|
|
6652
7216
|
const rec = { kind: 'package', name: path, no, pkg: parent, node: { id: undefined, type: 'uml.Package', label: seg } };
|
|
6653
|
-
|
|
7217
|
+
kids.set(seg, rec); packages.push(rec); order.push(rec);
|
|
6654
7218
|
}
|
|
6655
|
-
parent =
|
|
6656
|
-
}
|
|
7219
|
+
parent = kids.get(seg);
|
|
7220
|
+
}
|
|
6657
7221
|
if (label) parent.node.label = label;
|
|
6658
7222
|
if (s[k] === '{') { if (s.slice(k + 1).trim() !== '}') stack.push(parent); }
|
|
6659
7223
|
else say(no, 'skipped', `namespace ${n[0]} has no {: what follows is not inside it`);
|
|
@@ -6662,7 +7226,11 @@ function readMermaidClass(text) {
|
|
|
6662
7226
|
if (s.startsWith('for ', k)) { const n = mcName(s, mcSkip(s, k + 4)); if (n) { target = cls(n[0], no); k = mcSkip(s, n[1]); } }
|
|
6663
7227
|
const q = mcQuoted(s, k);
|
|
6664
7228
|
if (!q) say(no, 'skipped', 'a note with no "text"');
|
|
6665
|
-
else {
|
|
7229
|
+
else {
|
|
7230
|
+
if (where()) say(no, 'approximated', `a note in namespace ${where().name} is read outside it`);
|
|
7231
|
+
const rec = { kind: 'note', node: { type: 'uml.Comment', label: mcText(q[0]) }, target, hint: hint.id, linkHint: hint.link };
|
|
7232
|
+
notes.push(rec); order.push(rec);
|
|
7233
|
+
}
|
|
6666
7234
|
} else if (s.startsWith('<<')) {
|
|
6667
7235
|
const e = s.indexOf('>>'), n = e < 0 ? null : mcName(s, mcSkip(s, e + 2));
|
|
6668
7236
|
if (n) annotate(cls(n[0], no), s); else say(no, 'skipped', `${s}: an annotation names no class`);
|
|
@@ -6684,15 +7252,17 @@ function readMermaidClass(text) {
|
|
|
6684
7252
|
if (!started) { say(1, 'skipped', 'not a Mermaid classDiagram: nothing read'); return empty(); }
|
|
6685
7253
|
for (const open of stack) say(lines.length, 'approximated', `${open.kind === 'class' ? 'class ' + open.rec.name : 'namespace ' + open.name} is never closed: read to the end`);
|
|
6686
7254
|
|
|
6687
|
-
// ids: a class's name, then a namespace's; a namespace with a class's name is <name>-namespace, and
|
|
6688
|
-
// changes (no "->") is made unique,
|
|
7255
|
+
// ids: a class's name, then a namespace's; a namespace with a class's name is <name>-namespace, and one with another
|
|
7256
|
+
// namespace's name (inside another parent) or a name the id rule changes (no "->") is made unique, each reported. Later
|
|
7257
|
+
// members, relations and notes, in the order they come, made unique
|
|
6689
7258
|
const used = new Set(), next = new Map(), uniq = name => { const id = mcFree(used, next, mcId(name)); used.add(id); return id; };
|
|
6690
|
-
const named = [...classes.values(), ...packages
|
|
7259
|
+
const named = [...classes.values(), ...packages];
|
|
6691
7260
|
for (const rec of named) if (mcId(rec.name) === rec.name && !used.has(rec.name)) { rec.node.id = rec.name; used.add(rec.name); }
|
|
6692
7261
|
for (const rec of named) if (rec.node.id === undefined) {
|
|
6693
|
-
const kept = mcId(rec.name) === rec.name,
|
|
6694
|
-
rec.
|
|
6695
|
-
|
|
7262
|
+
const kept = mcId(rec.name) === rec.name, cls = kept && classes.has(rec.name);
|
|
7263
|
+
const what = rec.kind === 'class' ? `class ${rec.name}` : `namespace ${rec.name}${rec.pkg ? ' in ' + rec.pkg.node.id : ''}`;
|
|
7264
|
+
rec.node.id = uniq(cls ? rec.name + '-namespace' : rec.name);
|
|
7265
|
+
say(rec.no, 'approximated', cls ? `${what} has a class's name: it is ${rec.node.id}` : kept ? `${what} has another namespace's name: it is ${rec.node.id}` : `${what}: an id holds no "->", so it is ${rec.node.id}`);
|
|
6696
7266
|
}
|
|
6697
7267
|
for (const rec of named) if (rec.pkg) rec.node.parent = rec.pkg.node.id;
|
|
6698
7268
|
|
|
@@ -6813,20 +7383,36 @@ function writeMermaidClass(model, viewId) {
|
|
|
6813
7383
|
|
|
6814
7384
|
// what is written, in the reader's order: namespaces and classes as a tree, then relations, then notes
|
|
6815
7385
|
const shownSet = new Set(shown), isPkg = n => n.type === 'uml.Package';
|
|
6816
|
-
const kids = new Map(), roots = [], notes = [], tree = [], boxes = [];
|
|
7386
|
+
const kids = new Map(), roots = [], notes = [], tree = [], boxes = [], splits = [];
|
|
6817
7387
|
for (const id of shown) {
|
|
6818
7388
|
const n = byId.get(id), up = shownSet.has(n.parent) ? byId.get(n.parent) : null;
|
|
6819
|
-
|
|
6820
|
-
|
|
7389
|
+
// an owner the view does not show is not written, and the element is written at the top (notes/stage-2.md §11)
|
|
7390
|
+
const hidden = has(n.parent) && !up && `its parent ${n.parent}, which the ${byId.has(n.parent) ? 'view does not show' : 'model does not have'}`;
|
|
7391
|
+
if (n.type === 'uml.Comment') {
|
|
7392
|
+
if (nl(n.id)) skip(n);
|
|
7393
|
+
else { notes.push(n); if (has(n.parent)) lose(n.id, hidden || `its parent ${n.parent}: a note is written outside namespaces`); }
|
|
7394
|
+
} else if ((!isPkg(n) && !(n.type in KIND)) || nl(n.id) || String(n.id).includes('`')) skip(n); // a name is one line, in `...` at most
|
|
6821
7395
|
else if (up && isPkg(up)) { boxes.push(n); if (!kids.has(n.parent)) kids.set(n.parent, []); kids.get(n.parent).push(n); }
|
|
6822
|
-
else {
|
|
7396
|
+
else {
|
|
7397
|
+
// Mermaid splits a namespace's name at its dots, in backticks too, so one whose id is its hidden parent's dotted path
|
|
7398
|
+
// has that parent drawn all the same; the line says so once the namespace is known to be written
|
|
7399
|
+
if (hidden && isPkg(n) && String(n.id).startsWith(`${n.parent}.`)) splits.push([notWritten.length, n]);
|
|
7400
|
+
if (up || hidden) lose(n.id, hidden || `its parent ${n.parent}, a ${up.type}, which only a namespace can be`);
|
|
7401
|
+
roots.push(n);
|
|
7402
|
+
}
|
|
6823
7403
|
}
|
|
6824
|
-
|
|
6825
|
-
roots.
|
|
7404
|
+
// parents first, by a stack: a model nested thousands deep is written, not a call stack overflowed
|
|
7405
|
+
for (const st = [...roots].reverse(); st.length;) { const n = st.pop(), ks = kids.get(n.id) || []; tree.push(n); for (let k = ks.length - 1; k >= 0; k--) st.push(ks[k]); }
|
|
6826
7406
|
const inTree = new Set(tree);
|
|
6827
7407
|
for (const n of boxes) if (!inTree.has(n)) skip(n); // under a namespace that is not written, or in a cycle of parents
|
|
7408
|
+
// Mermaid reads no empty namespace (its grammar wants a statement between the braces): one with no class in it, at any
|
|
7409
|
+
// depth, is not written. The tree is parents first, so read backwards a namespace's contents come before it
|
|
7410
|
+
const full = new Set();
|
|
7411
|
+
for (let k = tree.length - 1; k >= 0; k--) if (!isPkg(tree[k]) || full.has(tree[k].id)) full.add(tree[k].parent);
|
|
7412
|
+
for (const n of tree) if (isPkg(n) && !full.has(n.id)) skip(n);
|
|
7413
|
+
for (const [at, n] of splits) if (full.has(n.id)) notWritten[at] += `, though Mermaid draws ${flat(n.parent)}, as it splits ${flat(n.id)} at its dots`;
|
|
6828
7414
|
const written = new Set(tree.filter(n => !isPkg(n)).map(n => n.id));
|
|
6829
|
-
const used = new Set(tree.map(n => n.id)), next = new Map();
|
|
7415
|
+
const used = new Set(tree.filter(n => !isPkg(n) || full.has(n.id)).map(n => n.id)), next = new Map();
|
|
6830
7416
|
const idOf = (id, base) => { const d = mcFree(used, next, mcId(base)); used.add(id); return d === id ? null : id; }; // the hint, if one is needed
|
|
6831
7417
|
|
|
6832
7418
|
const heads = new Map(); // a class's annotations and member rows
|
|
@@ -6887,22 +7473,1730 @@ function writeMermaidClass(model, viewId) {
|
|
|
6887
7473
|
});
|
|
6888
7474
|
links.filter(e => !notes.some(n => n.id === e.from)).forEach(skip);
|
|
6889
7475
|
|
|
6890
|
-
const labelled = n => (has(n.label) && n.label !==
|
|
6891
|
-
|
|
6892
|
-
|
|
6893
|
-
|
|
6894
|
-
|
|
6895
|
-
|
|
7476
|
+
const labelled = (n, as = n.id) => (has(n.label) && n.label !== as ? `["${enc(n.label)}"]` : '');
|
|
7477
|
+
// Mermaid names a namespace inside P as P.<name>, and splits a name at its dots, in backticks too. So a namespace whose
|
|
7478
|
+
// id is its parent's, a dot and a plain name, under a parent written so too or plain at the top, is written at the top
|
|
7479
|
+
// by that dotted id, which Mermaid and the reader both read as that nesting with those ids; and one whose block would
|
|
7480
|
+
// hold only such namespaces is written by them alone
|
|
7481
|
+
// a written namespace's name clashes only with a written namespace beside it: the reader, as Mermaid, keys namespaces
|
|
7482
|
+
// by their parent and their name, apart from classes
|
|
7483
|
+
const plain = id => /^[\p{L}\p{N}_]+(-[\p{L}\p{N}_]+)*$/u.test(id), top = new Set(roots), dotted = new Set();
|
|
7484
|
+
const nsKids = new Map([...kids].map(([p, ks]) => [p, new Set(ks.filter(k => isPkg(k) && full.has(k.id)).map(k => String(k.id)))]));
|
|
7485
|
+
for (const n of tree) if (isPkg(n) && full.has(n.id) && !top.has(n)) {
|
|
7486
|
+
const id = String(n.id), up = String(n.parent), seg = id.slice(up.length + 1), parts = id.split('.').length; // the reader reads MC_DOTS at most
|
|
7487
|
+
if (id === `${up}.${seg}` && plain(seg) && parts <= MC_DOTS && (dotted.has(n.parent) || (top.has(byId.get(n.parent)) && plain(up))) && !nsKids.get(n.parent).has(seg)) dotted.add(n.id);
|
|
7488
|
+
}
|
|
7489
|
+
// by a stack, as the tree is walked: a namespace's opening line, what it holds, then its } (a string on the stack)
|
|
7490
|
+
const emit = (first, pad0) => {
|
|
7491
|
+
for (const st = [[first, pad0]]; st.length;) {
|
|
7492
|
+
const [n, pad] = st.pop();
|
|
7493
|
+
if (typeof n === 'string') { out.push(n); continue; }
|
|
7494
|
+
if (isPkg(n)) {
|
|
7495
|
+
const inner = (kids.get(n.id) || []).filter(k => !isPkg(k) || (full.has(k.id) && !dotted.has(k.id)));
|
|
7496
|
+
const as = dotted.has(n.id) ? String(n.id).slice(String(n.parent).length + 1) : n.id; // the name the reader gives its label
|
|
7497
|
+
if (!full.has(n.id)) continue;
|
|
7498
|
+
if (!inner.length) { if (has(n.label) && n.label !== as) lose(n.id, `its label ${n.label}: it holds only namespaces, written by their dotted names`); continue; }
|
|
7499
|
+
out.push(`${pad}namespace ${dotted.has(n.id) ? n.id : name(n.id)}${labelled(n, as)} {`);
|
|
7500
|
+
st.push([`${pad}}`]);
|
|
7501
|
+
const in2 = pad.length < 64 ? pad + ' ' : pad; // indented 32 deep at most, so a deep model's text is not deep * deep long
|
|
7502
|
+
for (let k = inner.length - 1; k >= 0; k--) st.push([inner[k], in2]);
|
|
7503
|
+
continue;
|
|
7504
|
+
}
|
|
7505
|
+
const [anns, rows] = heads.get(n.id), head = `${pad}class ${name(n.id)}${labelled(n)}`;
|
|
7506
|
+
if (!anns.length && !rows.length) { out.push(head); continue; }
|
|
7507
|
+
out.push(head + ' {', ...anns.map(a => `${pad} <<${a}>>`));
|
|
7508
|
+
for (const [hint, text] of rows) out.push(...(hint ? [`${pad} %% id: ${hint}`] : []), `${pad} ${text}`);
|
|
7509
|
+
out.push(`${pad}}`);
|
|
6896
7510
|
}
|
|
6897
|
-
const [anns, rows] = heads.get(n.id), head = `${pad}class ${name(n.id)}${labelled(n)}`;
|
|
6898
|
-
if (!anns.length && !rows.length) return out.push(head);
|
|
6899
|
-
out.push(head + ' {', ...anns.map(a => `${pad} <<${a}>>`));
|
|
6900
|
-
for (const [hint, text] of rows) out.push(...(hint ? [`${pad} %% id: ${hint}`] : []), `${pad} ${text}`);
|
|
6901
|
-
out.push(`${pad}}`);
|
|
6902
7511
|
};
|
|
6903
7512
|
roots.forEach(n => emit(n, ' '));
|
|
7513
|
+
for (const n of tree) if (dotted.has(n.id)) emit(n, ' ');
|
|
6904
7514
|
for (const [hint, text] of relations) out.push(...(hint ? [` %% id: ${hint}`] : []), ` ${text}`);
|
|
6905
7515
|
for (const [hint, link, text] of noteLines) out.push(...(hint ? [` %% id: ${hint}`] : []), ...(link ? [` %% link-id: ${link}`] : []), ` ${text}`);
|
|
6906
7516
|
for (const x of notWritten) out.push(` %% not written: ${x}`);
|
|
6907
7517
|
return out.join('\n') + '\n';
|
|
6908
7518
|
}
|
|
7519
|
+
/* ---------- the time-axis layout (work package 3.2, notes/stage-3.md §3.2) ----------
|
|
7520
|
+
layoutTimeAxis(input) -> out, the geometry of a sequence (and later of an interaction overview's inline interaction): no
|
|
7521
|
+
DOM, no model. Sizes come in measured (the type measures inside the host's measure; src/uml/frame.js gives the sizes of
|
|
7522
|
+
what its shapes draw). Only lib/umlimator-all.js holds this file.
|
|
7523
|
+
|
|
7524
|
+
input = {
|
|
7525
|
+
heads: [{ id, w, h, born, ends }], lifelines, left to right; born, ends: the row that creates it, deletes it
|
|
7526
|
+
rows: [{ id, kind, a, b, w, h, parent }], occurrences in time order (an index here is an index into view.edges).
|
|
7527
|
+
kind 'line' (head a to head b), 'self' (a), 'span' (heads a to b: an
|
|
7528
|
+
interaction use); w, h: its label's box (a use's: frame.js useSize);
|
|
7529
|
+
parent: its operand, or none
|
|
7530
|
+
groups: [{ id, parent, tag: { w, h }, parts: [{ id, guard: { w, h } }] }], fragments, outermost first; parts: operands
|
|
7531
|
+
bars: [{ head, from, to, depth }], activations by row (src/uml/interaction.js); depth 1 is the outermost
|
|
7532
|
+
}
|
|
7533
|
+
out = { heads: [{ id, x, y, w, h }], stems: [{ head, x, y1, y2, cross, gaps: [{ y1, y2 }] }],
|
|
7534
|
+
bars: [{ head, depth, x, y, w, h }], frames: [{ id, x, y, w, h, dividers: [{ part, y }], parts: [{ id, x, y, w, h, gx, gy }] }],
|
|
7535
|
+
spans: [{ id, x, y, w, h }], lines: [{ id, pts, lbl }], slots: [{ index, y, parent, after }], bounds, problems }
|
|
7536
|
+
A frame's tag is drawn at its top left corner; a part's guard at (gx, gy). Bars come deepest last, to be drawn in order.
|
|
7537
|
+
Problems: { level, message, ids }, never warn(). */
|
|
7538
|
+
|
|
7539
|
+
const TA = {
|
|
7540
|
+
top: 16, head: 16, // the heads' top; from the tallest head to the first row
|
|
7541
|
+
band: 8, // a slot's band: every slot gets its own, so no two share a y
|
|
7542
|
+
above: 4, after: 6, // a label's bottom to its arrow; an arrow to its row's end
|
|
7543
|
+
drop: 16, loop: 28, // a self-message's loop: how far down (at least its label's height), how far right of its bar
|
|
7544
|
+
bar: 10, step: 6, short: 14, // a bar's width; each level of depth to the right; a bar with no end of its own
|
|
7545
|
+
gap: 24, room: 6, // between two heads; between a label and what is beside it
|
|
7546
|
+
pad: 10, nest: 10, // a frame's or a use's edge outside the bars of its lifelines; each frame or use nested inside, 10 px more
|
|
7547
|
+
gx: 8, hdr: 4, div: 3, empty: 14, close: 8, // a guard's inset; the header under its tag; a divider to its guard; an empty operand; a frame's end
|
|
7548
|
+
cut: 320, hit: 12, edge: 16, tail: 24, // the widest head; half the stem's hit strip, and a gap's reach round a row; the margin; the stems past the last row
|
|
7549
|
+
};
|
|
7550
|
+
|
|
7551
|
+
const taArr = x => (Array.isArray(x) ? x : []);
|
|
7552
|
+
const taNum = x => (Number.isFinite(x) ? x : 0);
|
|
7553
|
+
|
|
7554
|
+
function layoutTimeAxis(input = {}) {
|
|
7555
|
+
const heads = taArr(input.heads), rows = taArr(input.rows), groups = taArr(input.groups), problems = [];
|
|
7556
|
+
const say = (level, message, ids) => problems.push({ level, message, ids });
|
|
7557
|
+
const col = new Map(heads.map((h, j) => [h.id, j])), cw = heads.map(h => Math.min(TA.cut, taNum(h.w))), hh = heads.map(h => taNum(h.h));
|
|
7558
|
+
const rowOk = i => Number.isInteger(i) && i >= 0 && i < rows.length;
|
|
7559
|
+
const born = heads.map(h => (h.born == null ? null : rowOk(h.born) ? h.born : (say('warning', `lifeline ${h.id}: born at row ${h.born}, which is not a row: drawn at the top`, [h.id]), null)));
|
|
7560
|
+
const ends = heads.map(h => (h.ends == null ? null : rowOk(h.ends) ? h.ends : (say('warning', `lifeline ${h.id}: ends at row ${h.ends}, which is not a row: its stem runs on`, [h.id]), null)));
|
|
7561
|
+
|
|
7562
|
+
// operands: each one's fragment and place in it
|
|
7563
|
+
const opOf = new Map();
|
|
7564
|
+
groups.forEach(g => taArr(g.parts).forEach((p, k) => opOf.set(p.id, { g, k })));
|
|
7565
|
+
// each fragment's parent, checked once: one that is not an operand, or that lies inside the fragment itself, is reported
|
|
7566
|
+
// on the fragment, which is then drawn at the top
|
|
7567
|
+
const upOf = new Map(groups.map(g => [g.id, g.parent ?? null]));
|
|
7568
|
+
groups.forEach(g => { const p = upOf.get(g.id);
|
|
7569
|
+
if (p != null && !opOf.has(p)) { say('error', `fragment ${g.id}: its parent ${p} is not an operand: drawn at the top`, [g.id]); upOf.set(g.id, null); } });
|
|
7570
|
+
groups.forEach(g => { for (let p = upOf.get(g.id), n = 0; p != null && n <= groups.length; n++) { const o = opOf.get(p).g;
|
|
7571
|
+
if (o === g) { say('error', `fragment ${g.id}: its parent ${g.parent} lies inside it: drawn at the top`, [g.id]); upOf.set(g.id, null); break; }
|
|
7572
|
+
p = upOf.get(o.id); } });
|
|
7573
|
+
const chainOf = (parent, who) => { const out = [], seen = new Set();
|
|
7574
|
+
for (let p = parent; p != null;) { const o = opOf.get(p);
|
|
7575
|
+
if (!o) { say('error', `${who}: its parent ${p} is not an operand: drawn outside it`, [who]); break; }
|
|
7576
|
+
if (seen.has(o.g.id)) break; seen.add(o.g.id); out.unshift(o); p = upOf.get(o.g.id); }
|
|
7577
|
+
return out; };
|
|
7578
|
+
// a fragment with content holds a row; one with none has no place in time and goes at the end of its parent's content
|
|
7579
|
+
const full = new Set();
|
|
7580
|
+
rows.forEach(r => { for (let p = r.parent, n = 0; p != null && n < groups.length; n++) { const o = opOf.get(p); if (!o) break; full.add(o.g.id); p = upOf.get(o.g.id); } });
|
|
7581
|
+
|
|
7582
|
+
// ---- down the page: rows, frames, slots
|
|
7583
|
+
const R = [], frames = [], slots = [], open = [], placed = new Set(), seenFrame = new Set();
|
|
7584
|
+
let y = TA.top + Math.max(0, ...hh.filter((_, j) => born[j] == null)) + TA.head, ri = 0, topN = 0;
|
|
7585
|
+
const slot = parent => { slots.push({ index: ri, y: r2(y + TA.band / 2), parent: parent ?? null, after: ri ? rows[ri - 1].id : null }); y += TA.band; };
|
|
7586
|
+
const partOf = L => (L ? L.g.parts[L.k].id : null);
|
|
7587
|
+
const item = L => { if (L ? L.n : topN) slot(partOf(L)); if (L) L.n++; else topN++; };
|
|
7588
|
+
const guard = (g, k) => (g.parts[k] && g.parts[k].guard) || { w: 0, h: 0 };
|
|
7589
|
+
const startPart = L => { L.n = 0; slot(partOf(L)); };
|
|
7590
|
+
const endPart = L => { orphans(partOf(L)); if (!L.n) y += TA.empty; slot(partOf(L)); L.fr.parts[L.k].bottom = y; };
|
|
7591
|
+
const divider = L => { L.k++; const gd = guard(L.g, L.k); L.fr.dividers.push({ part: L.g.parts[L.k].id, y });
|
|
7592
|
+
L.fr.parts.push({ id: L.g.parts[L.k].id, top: y, gy: y + TA.div, gw: gd.w }); y += 2 * TA.div + gd.h; };
|
|
7593
|
+
const advance = (L, k) => { while (L.k < k) { endPart(L); divider(L); startPart(L); } };
|
|
7594
|
+
function openFrag(g, k) {
|
|
7595
|
+
const L0 = open[open.length - 1] || null; item(L0);
|
|
7596
|
+
if (seenFrame.has(g.id)) say('warning', `fragment ${g.id}: its occurrences are not one run in time, operand by operand: drawn as more than one frame`, [g.id]);
|
|
7597
|
+
seenFrame.add(g.id); placed.add(g.id);
|
|
7598
|
+
const tag = g.tag || { w: 0, h: 0 }, g0 = guard(g, 0), head = Math.max(tag.h, g0.h) + TA.hdr;
|
|
7599
|
+
const fr = { id: g.id, g, top: y, lo: Infinity, hi: -Infinity, up: L0 ? L0.fr : null, kids: [], dividers: [], parts: [{ id: g.parts[0].id, top: y, gy: y + (tag.h - g0.h) / 2, gw: g0.w, first: true }] };
|
|
7600
|
+
if (fr.up) fr.up.kids.push(fr);
|
|
7601
|
+
frames.push(fr); y += head;
|
|
7602
|
+
const L = { g, k: 0, fr, n: 0 }; open.push(L); startPart(L); advance(L, k);
|
|
7603
|
+
}
|
|
7604
|
+
function closeTop() { const L = open[open.length - 1]; advance(L, L.g.parts.length - 1); endPart(L); y += TA.close; L.fr.bottom = y; open.pop(); }
|
|
7605
|
+
function orphans(parent) { // the fragments of this operand (or of the top) with nothing in them: at its end, each with a warning
|
|
7606
|
+
groups.filter(g => !full.has(g.id) && !placed.has(g.id) && taArr(g.parts).length && upOf.get(g.id) === parent).forEach(g => {
|
|
7607
|
+
say('warning', `fragment ${g.id} holds no occurrence: it has no place in time, so it is drawn at the end of ${parent == null ? 'the interaction' : 'operand ' + parent}`, [g.id]);
|
|
7608
|
+
openFrag(g, 0); closeTop(); });
|
|
7609
|
+
}
|
|
7610
|
+
groups.filter(g => !taArr(g.parts).length).forEach(g => say('warning', `fragment ${g.id} has no operand: not drawn`, [g.id]));
|
|
7611
|
+
|
|
7612
|
+
slot(null);
|
|
7613
|
+
for (; ri < rows.length;) {
|
|
7614
|
+
const r = rows[ri], C = chainOf(r.parent, r.id).filter(o => taArr(o.g.parts).length);
|
|
7615
|
+
let m = 0; while (m < open.length && m < C.length && open[m].g === C[m].g && open[m].k === C[m].k) m++;
|
|
7616
|
+
if (m < open.length && m < C.length && open[m].g === C[m].g && C[m].k > open[m].k) { while (open.length > m + 1) closeTop(); advance(open[m], C[m].k); m++; }
|
|
7617
|
+
else while (open.length > m) closeTop();
|
|
7618
|
+
for (; m < C.length; m++) openFrag(C[m].g, C[m].k);
|
|
7619
|
+
const L = open[open.length - 1] || null, ia = col.get(r.a), self = r.kind === 'self' || (r.kind === 'line' && r.a === r.b), ib = self ? ia : col.get(r.b);
|
|
7620
|
+
if (ia == null || ib == null || !['line', 'self', 'span'].includes(r.kind)) {
|
|
7621
|
+
say('error', ia == null || ib == null ? `${r.id}: ${ia == null ? r.a : r.b} is not a lifeline here: not drawn` : `${r.id}: kind "${r.kind}" is not line, self or span: not drawn`, [r.id]);
|
|
7622
|
+
R.push(null); ri++; continue; }
|
|
7623
|
+
item(L);
|
|
7624
|
+
const w = taNum(r.w), h = taNum(r.h), it = { r, ri, kind: r.kind === 'span' ? 'span' : self ? 'self' : 'line', a: Math.min(ia, ib), b: Math.max(ia, ib), from: ia, to: ib, w, h, top: y };
|
|
7625
|
+
if (it.kind === 'span') { it.y = y; it.yb = y + h; y += h; }
|
|
7626
|
+
else if (it.kind === 'self') { it.y = y + TA.above; it.yb = it.y + Math.max(TA.drop, h); y = it.yb + TA.after; }
|
|
7627
|
+
else { const nb = born[ib] === ri ? hh[ib] / 2 : 0; it.y = Math.max(y + h + TA.above, y + nb); it.yb = it.y; y = Math.max(it.y + TA.after, it.y + nb); }
|
|
7628
|
+
open.forEach(O => { O.fr.lo = Math.min(O.fr.lo, it.a); O.fr.hi = Math.max(O.fr.hi, it.b); });
|
|
7629
|
+
it.up = L ? L.fr : null; if (it.up && it.kind === 'span') it.up.kids.push(it);
|
|
7630
|
+
R.push(it); ri++;
|
|
7631
|
+
}
|
|
7632
|
+
while (open.length) closeTop();
|
|
7633
|
+
orphans(null);
|
|
7634
|
+
slot(null);
|
|
7635
|
+
const end = y + TA.tail;
|
|
7636
|
+
|
|
7637
|
+
// ---- across: each head's reach either side of its stem, then the columns
|
|
7638
|
+
const bars = taArr(input.bars).map(b => ({ ...b, j: col.get(b.head), d: Math.max(1, Math.floor(taNum(b.depth)) || 1) })).filter(b => {
|
|
7639
|
+
const ok = b.j != null && rowOk(b.from) && rowOk(b.to) && R[b.from] && R[b.to] && b.to >= b.from;
|
|
7640
|
+
if (!ok) say('warning', `a bar on ${b.head} from row ${b.from} to row ${b.to}: ${b.j == null ? 'not a lifeline here' : 'its rows are not drawn, or end before they start'}: not drawn`, [b.head]);
|
|
7641
|
+
return ok; });
|
|
7642
|
+
const depth = heads.map((_, j) => Math.max(1, ...bars.filter(b => b.j === j).map(b => b.d)));
|
|
7643
|
+
const solidL = heads.map((_, j) => Math.max(TA.bar / 2, born[j] != null ? cw[j] / 2 : 0));
|
|
7644
|
+
const solidR = heads.map((_, j) => Math.max(TA.bar / 2 + (depth[j] - 1) * TA.step, born[j] != null ? cw[j] / 2 : 0));
|
|
7645
|
+
const reachR = solidR.map((s, j) => Math.max(s, ...R.filter(it => it && it.kind === 'self' && it.a === j).map(it => s + TA.loop + TA.room + it.w)));
|
|
7646
|
+
const adj = heads.map((_, j) => (j ? Math.max((cw[j - 1] + cw[j]) / 2 + TA.gap, reachR[j - 1] + TA.room + solidL[j]) : 0));
|
|
7647
|
+
const far = []; // [i, k, need]: X[k] - X[i] >= need
|
|
7648
|
+
const need = (i, k, d) => { if (i < 0 || k >= heads.length) return; if (k === i + 1) adj[k] = Math.max(adj[k], d); else far.push([i, k, d]); };
|
|
7649
|
+
R.forEach(it => { if (it && it.kind === 'line') { const n = it.from + Math.sign(it.to - it.from), i = Math.min(it.from, n); need(i, i + 1, solidR[i] + 2 * TA.room + it.w + solidL[i + 1]); } });
|
|
7650
|
+
// frames and uses: their edges, from the inside out (a frame's kids come after it in frames, so backwards)
|
|
7651
|
+
const spans = R.filter(it => it && it.kind === 'span').map(it => ({ lo: it.a, hi: it.b, kids: [], need: it.w, span: true, it }));
|
|
7652
|
+
frames.forEach(fr => { fr.kids = fr.kids.map(k => (k.r ? spans.find(s => s.it === k) : k)); });
|
|
7653
|
+
const edgeOf = b => {
|
|
7654
|
+
if (b.span) { b.n = 0; } else {
|
|
7655
|
+
b.n = Math.max(0, ...b.kids.map(k => k.n + 1));
|
|
7656
|
+
const tag = b.g.tag || { w: 0, h: 0 };
|
|
7657
|
+
b.need = Math.max(tag.w + TA.gx + b.parts[0].gw + TA.gx, ...b.parts.slice(1).map(p => 2 * TA.gx + p.gw), 2 * TA.pad);
|
|
7658
|
+
}
|
|
7659
|
+
if (!heads.length) { b.L = b.R = 0; return; }
|
|
7660
|
+
b.L = solidL[b.lo] + TA.pad + b.n * TA.nest; b.R = (b.span ? solidR[b.hi] : reachR[b.hi]) + TA.pad + b.n * TA.nest;
|
|
7661
|
+
b.kids.forEach(k => { if (k.lo === b.lo) b.L = Math.max(b.L, k.L + TA.nest); if (k.hi === b.hi) b.R = Math.max(b.R, k.R + TA.nest); });
|
|
7662
|
+
if (b.lo === b.hi) b.R = Math.max(b.R, b.need - b.L); else need(b.lo, b.hi, b.need - b.L - b.R);
|
|
7663
|
+
need(b.lo - 1, b.lo, solidR[b.lo - 1] + TA.room + b.L);
|
|
7664
|
+
need(b.hi, b.hi + 1, b.R + TA.room + solidL[b.hi + 1]);
|
|
7665
|
+
if (b.lo !== b.hi) need(b.lo, b.hi + 1, b.need - b.L + TA.room + solidL[b.hi + 1]);
|
|
7666
|
+
};
|
|
7667
|
+
frames.forEach(fr => { if (!(fr.lo <= fr.hi)) [fr.lo, fr.hi] = fr.up ? [fr.up.lo, fr.up.hi] : [0, heads.length - 1]; }); // nothing in it: its parent's lifelines
|
|
7668
|
+
spans.forEach(edgeOf); frames.slice().reverse().forEach(edgeOf);
|
|
7669
|
+
const left0 = heads.length ? Math.max(cw[0] / 2, solidL[0], ...[...spans, ...frames].filter(b => b.lo === 0).map(b => b.L)) : 0;
|
|
7670
|
+
const X = [];
|
|
7671
|
+
heads.forEach((_, j) => { X[j] = j ? X[j - 1] + adj[j] : TA.edge + left0; far.forEach(([i, k, d]) => { if (k === j) X[j] = Math.max(X[j], X[i] + d); }); });
|
|
7672
|
+
|
|
7673
|
+
// ---- the pieces
|
|
7674
|
+
const yOf = i => R[i].y, endOf = (i, j) => { const it = R[i]; return it.kind === 'self' && it.a === j ? it.yb + TA.short : it.kind === 'span' ? it.yb : it.y; };
|
|
7675
|
+
const outBars = bars.map(b => { const it = R[b.from], y0 = it.kind === 'self' && it.a === b.j ? it.yb : yOf(b.from), y1 = Math.max(b.to === b.from ? y0 + TA.short : endOf(b.to, b.j), y0 + TA.short);
|
|
7676
|
+
return { head: b.head, depth: b.d, j: b.j, y0, y1, x: X[b.j] + (b.d - 1) * TA.step - TA.bar / 2 }; }).sort((a, b) => a.depth - b.depth);
|
|
7677
|
+
const active = (j, at) => outBars.filter(b => b.j === j && b.y0 <= at + .5 && at - .5 <= b.y1).pop() || null; // the deepest
|
|
7678
|
+
const side = (j, b, dir) => (b ? X[j] + (b.depth - 1) * TA.step + dir * TA.bar / 2 : X[j]);
|
|
7679
|
+
const outHeads = heads.map((h, j) => ({ id: h.id, x: r2(X[j] - cw[j] / 2), y: r2(born[j] != null && R[born[j]] ? yOf(born[j]) - hh[j] / 2 : TA.top), w: r2(cw[j]), h: r2(hh[j]) }));
|
|
7680
|
+
const lines = [];
|
|
7681
|
+
R.forEach(it => { if (!it || it.kind === 'span') return;
|
|
7682
|
+
if (it.kind === 'self') { const j = it.a, sx = side(j, active(j, it.y), 1), ex = side(j, active(j, it.yb), 1), lx = Math.max(sx, ex) + TA.loop;
|
|
7683
|
+
it.pts = [[sx, it.y], [lx, it.y], [lx, it.yb], [ex, it.yb]]; it.lbl = { x: lx + TA.room, y: it.y + (it.yb - it.y - it.h) / 2, w: it.w, h: it.h }; }
|
|
7684
|
+
else { const dir = Math.sign(it.to - it.from), sx = side(it.from, active(it.from, it.y), dir);
|
|
7685
|
+
const ex = born[it.to] === it.ri ? X[it.to] - dir * cw[it.to] / 2 : side(it.to, active(it.to, it.y), -dir);
|
|
7686
|
+
const n = it.from + dir, near = n === it.to ? ex : dir > 0 ? X[n] - solidL[n] : X[n] + solidR[n], cx = (sx + near) / 2;
|
|
7687
|
+
it.pts = [[sx, it.y], [ex, it.y]]; it.lbl = { x: cx - it.w / 2, y: it.y - TA.above - it.h, w: it.w, h: it.h }; }
|
|
7688
|
+
lines.push({ id: it.r.id, pts: it.pts.map(p => p.map(r2)), lbl: { x: r2(it.lbl.x), y: r2(it.lbl.y), w: r2(it.w), h: r2(it.h) } }); });
|
|
7689
|
+
const boxX = b => (heads.length ? [X[b.lo] - b.L, Math.max(X[b.hi] + b.R, X[b.lo] - b.L + b.need)] : [TA.edge, TA.edge + b.need]);
|
|
7690
|
+
const outSpans = spans.map(s => { const [x0, x1] = boxX(s); s.it.box = [x0, x1]; return { id: s.it.r.id, x: r2(x0), y: r2(s.it.y), w: r2(x1 - x0), h: r2(s.it.h) }; });
|
|
7691
|
+
const outFrames = frames.map(fr => { const [x0, x1] = boxX(fr);
|
|
7692
|
+
return { id: fr.id, x: r2(x0), y: r2(fr.top), w: r2(x1 - x0), h: r2(fr.bottom - fr.top), dividers: fr.dividers.map(d => ({ part: d.part, y: r2(d.y) })),
|
|
7693
|
+
parts: fr.parts.map(p => ({ id: p.id, x: r2(x0), y: r2(p.top), w: r2(x1 - x0), h: r2(p.bottom - p.top), gx: r2(x0 + (p.first ? (fr.g.tag || { w: 0 }).w + TA.gx : TA.gx)), gy: r2(p.gy) })) }; });
|
|
7694
|
+
|
|
7695
|
+
// stems, each cut round every row it lies under (S3): a row's label and arrow (a loop's too), 12 px above and below
|
|
7696
|
+
const reach = it => { if (it.kind === 'span') return [it.box[0], it.box[1], it.y, it.yb];
|
|
7697
|
+
const xs = [...it.pts.map(p => p[0]), it.lbl.x, it.lbl.x + it.w];
|
|
7698
|
+
return [Math.min(...xs), Math.max(...xs), Math.min(it.lbl.y, it.y - TA.hit), Math.max(it.yb, it.lbl.y + it.h) + TA.hit]; };
|
|
7699
|
+
const bands = R.filter(Boolean).map(reach);
|
|
7700
|
+
const stems = heads.map((h, j) => { const y1 = outHeads[j].y + hh[j], y2 = Math.max(y1, ends[j] != null && R[ends[j]] ? yOf(ends[j]) : end), gaps = [];
|
|
7701
|
+
bands.filter(([a, b]) => X[j] + TA.hit > a && X[j] - TA.hit < b).map(([, , t, u]) => [Math.max(t, y1), Math.min(u, y2)]).filter(([t, u]) => u > t).sort((p, q) => p[0] - q[0])
|
|
7702
|
+
.forEach(([t, u]) => { const g = gaps[gaps.length - 1]; if (g && t <= g[1]) g[1] = Math.max(g[1], u); else gaps.push([t, u]); });
|
|
7703
|
+
return { head: h.id, x: r2(X[j]), y1: r2(y1), y2: r2(y2), cross: ends[j] != null && !!R[ends[j]], gaps: gaps.map(([t, u]) => ({ y1: r2(t), y2: r2(u) })) }; });
|
|
7704
|
+
|
|
7705
|
+
const xs = [...outHeads.flatMap(h => [h.x, h.x + h.w]), ...outFrames.flatMap(f => [f.x, f.x + f.w]), ...outSpans.flatMap(s => [s.x, s.x + s.w]),
|
|
7706
|
+
...lines.flatMap(l => [...l.pts.map(p => p[0]), l.lbl.x, l.lbl.x + l.lbl.w])];
|
|
7707
|
+
const x0 = Math.min(0, ...xs.map(x => x - TA.edge)), x1 = Math.max(TA.edge, ...xs.map(x => x + TA.edge));
|
|
7708
|
+
return {
|
|
7709
|
+
heads: outHeads, stems, lines, spans: outSpans, frames: outFrames, slots,
|
|
7710
|
+
bars: outBars.map(b => ({ head: b.head, depth: b.depth, x: r2(b.x), y: r2(b.y0), w: TA.bar, h: r2(b.y1 - b.y0) })),
|
|
7711
|
+
bounds: { x: r2(x0), y: 0, w: r2(x1 - x0), h: r2(end + TA.edge) }, problems,
|
|
7712
|
+
};
|
|
7713
|
+
}
|
|
7714
|
+
/* ---------- frames and lifelines (work package 3.2, notes/stage-3.md §4.2) ----------
|
|
7715
|
+
The shapes a time-axis view draws as nodes, so the canvas selects, names and focuses them as it does any node:
|
|
7716
|
+
- lifeline: the classifier with no compartment ("name : Type", «actor» over it), and inside its group a transparent strip
|
|
7717
|
+
24 px wide down its stem (n.stem: layoutTimeAxis's stem), cut at the stem's gaps, so a click on the stem lands on the
|
|
7718
|
+
lifeline and one on a message, its arrowhead or a loop lands on the message (S3);
|
|
7719
|
+
- frame: a combined fragment's pentagon tag (n.label, the operator) and a transparent outline to hit;
|
|
7720
|
+
- operand: its guard, "[label]", at (n.gx, n.gy), on a backing;
|
|
7721
|
+
- use: an interaction use's "ref" tag and its name, centred, on a backing, over a transparent box to hit.
|
|
7722
|
+
Outlines, dividers and a use's box are not the nodes': frame() draws them, in the type's `under` layer, before the bars.
|
|
7723
|
+
Every text sits on a backing in the stage's colour, so a bar under it never hides it (spike R1). The sizes these draw
|
|
7724
|
+
are tagSize(), guardSize() and useSize(), which the type measures with and gives layoutTimeAxis. Only lib/umlimator-all.js
|
|
7725
|
+
holds this file. */
|
|
7726
|
+
|
|
7727
|
+
const FR = { tag: 11, guard: 10.5, name: 11, tagH: 17, guardH: 14, cut: 5 }; // font sizes; the tag's and the guard's height; the tag's cut corner
|
|
7728
|
+
const FR_LINE = 'var(--um-muted)'; // 3:1 or more on the stage in both themes, as a typed view's lines (1.11)
|
|
7729
|
+
const tagSize = t => ({ w: Math.ceil(textW(t, FR.tag) + 14), h: FR.tagH });
|
|
7730
|
+
const guardSize = t => (t == null || t === '' ? { w: 0, h: 0 } : { w: Math.ceil(textW(`[${t}]`, FR.guard) + 6), h: FR.guardH });
|
|
7731
|
+
const useSize = t => ({ w: Math.ceil(2 * (tagSize('ref').w + TA.gx) + textW(t, FR.name) + 6), h: 2 * FR.tagH + 4 });
|
|
7732
|
+
const frText = (x, y, t, px, w = 600, a = 'start') => `<text x="${r2(x)}" y="${r2(y)}" text-anchor="${a}" fill="var(--um-text)" style="font:${w} ${px}px var(--um-sans)">${esc(t)}</text>`;
|
|
7733
|
+
|
|
7734
|
+
// UML's frame without its tag: the outline and a dashed divider at each y, for the `under` layer. Stage 4's inline
|
|
7735
|
+
// interactions, state regions and partitions draw theirs with it too
|
|
7736
|
+
function frame(x, y, w, h, dividers = []) {
|
|
7737
|
+
return `<g class="frame-box"><rect x="${r2(x)}" y="${r2(y)}" width="${r2(w)}" height="${r2(h)}" fill="none" stroke="${FR_LINE}" stroke-width="1.2"/>`
|
|
7738
|
+
+ dividers.map(d => `<path d="M${r2(x)} ${r2(d)}H${r2(x + w)}" stroke="${FR_LINE}" stroke-width="1.2" stroke-dasharray="5 4"/>`).join('') + '</g>';
|
|
7739
|
+
}
|
|
7740
|
+
// the pentagon tag at a frame's top left corner, max: the room it may take (its text is cut to it)
|
|
7741
|
+
function frameTag(x, y, t, max = Infinity) {
|
|
7742
|
+
const s = tagSize(t), w = Math.min(s.w, max), h = s.h;
|
|
7743
|
+
return `<path d="M${r2(x)} ${r2(y)}H${r2(x + w)}V${r2(y + h - FR.cut)}L${r2(x + w - FR.cut)} ${r2(y + h)}H${r2(x)}Z" class="n-body f-tag" fill="var(--um-stage-bg)" stroke="${FR_LINE}"/>`
|
|
7744
|
+
+ frText(x + 6, y + 12.5, fit(t, FR.tag, w - 12), FR.tag);
|
|
7745
|
+
}
|
|
7746
|
+
const frameNode = (n, shape, said, inner) => `<g class="node ${shape}" data-id="${esc(n.id)}" data-shape="${shape}" tabindex="-1" role="img" aria-label="${esc(n.aria || said)}">${inner}</g>`;
|
|
7747
|
+
|
|
7748
|
+
SHAPE.lifeline = {
|
|
7749
|
+
size: n => SHAPE.classifier.size({ ...n, compartments: [] }),
|
|
7750
|
+
draw(n, c, f) {
|
|
7751
|
+
const head = SHAPE.classifier.draw({ ...n, compartments: [], type: n.type || 'uml.Lifeline' }, c, f), S = n.stem, cuts = [];
|
|
7752
|
+
if (S) { let at = S.y1; [...(S.gaps || []), { y1: S.y2, y2: S.y2 }].forEach(g => { if (g.y1 > at) cuts.push(`M${r2(S.x)} ${r2(at)}V${r2(g.y1)}`); at = Math.max(at, g.y2); }); }
|
|
7753
|
+
const strip = cuts.length ? `<path d="${cuts.join('')}" class="ll-hit" fill="none" stroke="transparent" stroke-width="${2 * TA.hit}"/>` : '';
|
|
7754
|
+
// the classifier's group, named a lifeline, with the strip inside it
|
|
7755
|
+
return head.replace('class="node classifier"', 'class="node lifeline"').replace('data-shape="classifier"', 'data-shape="lifeline"')
|
|
7756
|
+
.replace(/aria-label="[^"]*"/, m => (n.aria ? `aria-label="${esc(n.aria)}"` : m)).replace(/<\/g>$/, strip + '</g>');
|
|
7757
|
+
},
|
|
7758
|
+
};
|
|
7759
|
+
SHAPE.frame = {
|
|
7760
|
+
draw: n => frameNode(n, 'frame', `Combined fragment ${n.label ?? ''}`, `<rect x="${n.x}" y="${n.y}" width="${n.w}" height="${n.h}" class="f-hit" fill="none" stroke="transparent" stroke-width="8"/>${frameTag(n.x, n.y, String(n.label ?? ''), n.w)}`),
|
|
7761
|
+
};
|
|
7762
|
+
SHAPE.operand = {
|
|
7763
|
+
draw(n) { const t = n.label == null || n.label === '' ? '' : `[${n.label}]`, s = guardSize(n.label), w = Math.min(s.w, n.x + n.w - n.gx - 2);
|
|
7764
|
+
return frameNode(n, 'operand', `Operand ${n.label ?? ''}`, `<rect x="${r2(n.gx)}" y="${r2(n.gy)}" width="${r2(Math.max(w, 24))}" height="${FR.guardH}" class="f-guard" fill="${t ? 'var(--um-stage-bg)' : 'transparent'}"/>`
|
|
7765
|
+
+ (t ? frText(n.gx + 3, n.gy + 10.5, fit(t, FR.guard, w - 6), FR.guard, 500) : '')); },
|
|
7766
|
+
};
|
|
7767
|
+
SHAPE.use = {
|
|
7768
|
+
draw(n) { const t = String(n.label ?? ''), cx = n.x + n.w / 2, cy = n.y + n.h / 2, room = n.w - 2 * (tagSize('ref').w + TA.gx), nw = Math.min(textW(t, FR.name), room);
|
|
7769
|
+
return frameNode(n, 'use', `Interaction use ${t}`, `<rect x="${n.x}" y="${n.y}" width="${n.w}" height="${n.h}" class="f-hit" fill="transparent"/>${frameTag(n.x, n.y, 'ref')}`
|
|
7770
|
+
+ `<rect x="${r2(cx - nw / 2 - 3)}" y="${r2(cy - 8)}" width="${r2(nw + 6)}" height="16" class="f-name" fill="var(--um-stage-bg)"/>${frText(cx, cy + 4, fit(t, FR.name, room), FR.name, 600, 'middle')}`); },
|
|
7771
|
+
};
|
|
7772
|
+
/* ---------- interaction semantics, shared by uml.sequence and uml.communication (work package 3.1) ----------
|
|
7773
|
+
What a view's messages mean, with no layout and no DOM (notes/stage-3.md §3.2, §5.2, §6.3; model.md rows 11 and 12):
|
|
7774
|
+
the occurrences in time order with the operands they lie in, the activation bars by the five rules of
|
|
7775
|
+
notes/spike-sequence.md §2.3, the message numbers (S8), the call a new reply answers, and the rules both types check.
|
|
7776
|
+
A sequence view's `edges` holds its occurrences inline; a communication view's names model edges, connectors first,
|
|
7777
|
+
which the walk passes over. Ships in lib/umlimator-all.js only. */
|
|
7778
|
+
|
|
7779
|
+
const IA_ARR = x => (Array.isArray(x) ? x : []), IA_OBJ = x => x !== null && typeof x === 'object' && !Array.isArray(x);
|
|
7780
|
+
const SORTS = ['call', 'async', 'reply', 'create', 'delete'];
|
|
7781
|
+
|
|
7782
|
+
/* The occurrence walk: { occ, ops }. occ, in time order: { id, i, r, path, el, message, from, to, sort, replyTo, parent },
|
|
7783
|
+
where i is the entry's index in view.edges (a slot's index), r its row among the occurrences, path where validate()
|
|
7784
|
+
finds it, sort one of SORTS (any other is a call) and null on an interaction use. ops: operand id → { id, el, fragment } */
|
|
7785
|
+
function interactionWalk(model, view) {
|
|
7786
|
+
const views = IA_ARR(model && model.views), vi = views.includes(view) ? views.indexOf(view) : views.findIndex(v => IA_OBJ(v) && IA_OBJ(view) && v.id === view.id);
|
|
7787
|
+
const byId = new Map(), ops = new Map(), occ = [];
|
|
7788
|
+
IA_ARR(model && model.edges).forEach((e, k) => { if (IA_OBJ(e) && !byId.has(e.id)) byId.set(e.id, [e, `edges[${k}]`]); });
|
|
7789
|
+
IA_ARR(view && view.fragments).forEach(f => { if (IA_OBJ(f)) IA_ARR(f.members).forEach(o => { if (IA_OBJ(o) && !ops.has(o.id)) ops.set(o.id, { id: o.id, el: o, fragment: f }); }); });
|
|
7790
|
+
IA_ARR(view && view.edges).forEach((x, i) => {
|
|
7791
|
+
const [el, path] = typeof x === 'string' ? byId.get(x) || [] : [x, `views[${vi}].edges[${i}]`];
|
|
7792
|
+
const message = IA_OBJ(el) && el.type === 'uml.Message'; // one with no id, or a message with no ends, is the core's error (src/core/model.js)
|
|
7793
|
+
if (!IA_OBJ(el) || el.id == null || (message ? el.from == null || el.to == null : el.type !== 'uml.InteractionUse')) return;
|
|
7794
|
+
const p = IA_OBJ(el.props) ? el.props : {};
|
|
7795
|
+
occ.push({ id: el.id, i, r: occ.length, path, el, message, from: el.from, to: el.to,
|
|
7796
|
+
sort: message ? (SORTS.includes(p.sort) ? p.sort : 'call') : null, replyTo: p.replyTo ?? null, parent: el.parent ?? null });
|
|
7797
|
+
});
|
|
7798
|
+
return { occ, ops };
|
|
7799
|
+
}
|
|
7800
|
+
|
|
7801
|
+
// an operand and every operand around it, innermost first (a cycle in `parent` stops the walk)
|
|
7802
|
+
function operandChain(w, id) {
|
|
7803
|
+
const out = []; while (id != null && w.ops.has(id) && !out.includes(id)) { out.push(id); id = w.ops.get(id).fragment.parent; }
|
|
7804
|
+
return out;
|
|
7805
|
+
}
|
|
7806
|
+
// whether what lies in operand p and what lies in operand q are in different operands of one alt (null: the top level)
|
|
7807
|
+
const apartInAlt = (w, p, q) => { const cq = operandChain(w, q);
|
|
7808
|
+
return operandChain(w, p).some(a => { const F = w.ops.get(a).fragment; return IA_OBJ(F.props) && F.props.operator === 'alt' && cq.some(b => b !== a && w.ops.get(b).fragment === F); }); };
|
|
7809
|
+
|
|
7810
|
+
/* Activations, derived by intervals (notes/spike-sequence.md §2.3): { head, call, to, a, b, depth }, a and b the rows of
|
|
7811
|
+
the call that opens it and the message that closes it */
|
|
7812
|
+
function barsOf(w) {
|
|
7813
|
+
const ms = w.occ.filter(o => o.message), bars = [];
|
|
7814
|
+
ms.forEach(c => { if (c.sort !== 'call') return; // 1: a call opens one on its receiver at its row; 4: nothing else opens one
|
|
7815
|
+
let end = ms.filter(m => m.r > c.r && m.sort === 'reply' && m.replyTo === c.id).pop(); // 2: the last reply naming it, in any operand
|
|
7816
|
+
if (!end && c.from !== c.to) { // 3: back when its caller next sends, after the receiver's own last message before that; a self-call at once
|
|
7817
|
+
const next = ms.find(m => m.r > c.r && m.from === c.from), until = next ? next.r : Infinity;
|
|
7818
|
+
end = ms.filter(m => m.r > c.r && m.r < until && (m.from === c.to || m.to === c.to)).pop(); }
|
|
7819
|
+
end = end || c; bars.push({ head: c.to, call: c.id, to: end.id, a: c.r, b: end.r, self: c.from === c.to }); });
|
|
7820
|
+
/* 5: one level deeper for each bar of its lifeline it starts inside (for bars that nest, the ones that contain it). A bar
|
|
7821
|
+
that opens on the row another closes on (rule 3 closes one on an incoming call) only touches it, unless it is a
|
|
7822
|
+
self-call: that one is sent from inside the bar and comes back into it */
|
|
7823
|
+
bars.forEach(x => { x.depth = 1 + bars.filter(o => o !== x && o.head === x.head && o.a < x.a && (x.a < o.b || (x.a === o.b && x.self))).length; });
|
|
7824
|
+
return bars;
|
|
7825
|
+
}
|
|
7826
|
+
|
|
7827
|
+
/* The bars of a view, time-axis input (notes/stage-3.md §3.2): [{ head, from, to, depth }], from and to occurrence ids,
|
|
7828
|
+
depth 1 for a bar inside no other */
|
|
7829
|
+
const activationBars = (model, view) => barsOf(interactionWalk(model, view)).map(({ head, call, to, depth }) => ({ head, from: call, to, depth }));
|
|
7830
|
+
|
|
7831
|
+
/* Message numbers (S8): a message's place among the messages sent in the activation it is sent from, replies included,
|
|
7832
|
+
after that activation's call's number; 1, 2 … at the top level. Map: message id → '1.1.2' */
|
|
7833
|
+
function messageNumbers(model, view) {
|
|
7834
|
+
const w = interactionWalk(model, view), bars = barsOf(w), num = new Map(), count = new Map();
|
|
7835
|
+
w.occ.forEach(m => { if (!m.message) return;
|
|
7836
|
+
const act = bars.filter(x => x.head === m.from && x.call !== m.id && x.a <= m.r && m.r <= x.b).reduce((a, x) => (!a || x.a > a.a ? x : a), null);
|
|
7837
|
+
const k = act ? act.call : null, n = (count.get(k) || 0) + 1; count.set(k, n);
|
|
7838
|
+
num.set(m.id, act ? `${num.get(act.call)}.${n}` : `${n}`); });
|
|
7839
|
+
return num;
|
|
7840
|
+
}
|
|
7841
|
+
|
|
7842
|
+
/* The call a new reply from `from` to `to` answers (§5.2): the latest call from its receiver to its sender before the
|
|
7843
|
+
slot `at` ({ index, parent }; absent: the end) that no reply answers, where a reply, or a call, in another operand of
|
|
7844
|
+
an alt than the slot's does not count. Its id, or null */
|
|
7845
|
+
function replyCallFor(model, view, from, to, at) {
|
|
7846
|
+
const w = interactionWalk(model, view), ms = w.occ.filter(o => o.message), idx = at && at.index != null ? at.index : Infinity, where = at ? at.parent ?? null : null;
|
|
7847
|
+
for (let k = ms.length - 1; k >= 0; k--) { const c = ms[k];
|
|
7848
|
+
if (c.i < idx && c.sort === 'call' && c.from === to && c.to === from && !apartInAlt(w, c.parent, where)
|
|
7849
|
+
&& !ms.some(r => r.r > c.r && r.sort === 'reply' && r.replyTo === c.id && !apartInAlt(w, r.parent, where))) return c.id; }
|
|
7850
|
+
return null;
|
|
7851
|
+
}
|
|
7852
|
+
|
|
7853
|
+
/* ---- the shared validators (notes/stage-3.md §4.3, §6.4). Each problem: { level, rule, path, message, ids }, as in
|
|
7854
|
+
src/uml/class.js. In a communication view there are no operands, so reply-once holds a call to one reply ---- */
|
|
7855
|
+
const INTERACTION_RULES = {
|
|
7856
|
+
'reply-call': 'a reply names an earlier call of the view in replyTo, and goes back along it',
|
|
7857
|
+
'reply-once': 'two replies to one call lie in different operands of one alt',
|
|
7858
|
+
'create-once': 'a lifeline is created at most once, by the first message to or from it',
|
|
7859
|
+
'delete-once': 'a lifeline is deleted at most once, by the last message to or from it',
|
|
7860
|
+
sort: 'a message\'s sort is call, async, reply, create or delete; any other is drawn as a call',
|
|
7861
|
+
};
|
|
7862
|
+
function interactionProblems(model, view) {
|
|
7863
|
+
const w = interactionWalk(model, view), ms = w.occ.filter(o => o.message), byId = new Map(ms.map(m => [m.id, m])), replies = new Map(), out = [];
|
|
7864
|
+
const nm = m => `${m.id}${m.el.label != null && m.el.label !== '' ? ` ("${m.el.label}")` : ''}`, touches = (m, t) => m.from === t || m.to === t;
|
|
7865
|
+
const say = (rule, m, at, ids, msg, level = 'error') => out.push({ level, rule, path: m.path + at, ids: ids.filter(x => x != null), message: `${rule}: ${msg}` });
|
|
7866
|
+
ms.forEach(m => {
|
|
7867
|
+
const raw = IA_OBJ(m.el.props) ? m.el.props.sort : undefined, c = byId.get(m.replyTo);
|
|
7868
|
+
if (!SORTS.includes(raw)) say('sort', m, '.props.sort', [m.id], `${nm(m)} has ${raw === undefined ? 'no sort' : `sort ${JSON.stringify(raw)}`}, not one of ${SORTS.join(', ')}: it is drawn as a call`, 'warning');
|
|
7869
|
+
if (m.sort === 'reply') {
|
|
7870
|
+
if (m.replyTo == null) say('reply-call', m, '.props', [m.id], `${nm(m)} names no call in replyTo`, 'warning');
|
|
7871
|
+
else if (!c || c.r > m.r || c.sort !== 'call') say('reply-call', m, '.props.replyTo', [m.id, c && c.id], `${nm(m)} answers ${JSON.stringify(m.replyTo)}, which is not an earlier call of this view`);
|
|
7872
|
+
else if (m.from !== c.to || m.to !== c.from) say('reply-call', m, '', [m.id, c.id], `${nm(m)} runs from ${m.from} to ${m.to}, not back along ${nm(c)} (${c.from} to ${c.to})`);
|
|
7873
|
+
if (c && c.sort === 'call' && c.r < m.r) { const was = replies.get(c.id) || [], e = was.find(o => !apartInAlt(w, o.parent, m.parent));
|
|
7874
|
+
if (e) say('reply-once', m, '.props.replyTo', [c.id, e.id, m.id], `${nm(e)} and ${nm(m)} both answer ${nm(c)}, and do not lie in different operands of one alt`);
|
|
7875
|
+
replies.set(c.id, [...was, m]); } }
|
|
7876
|
+
if (m.sort === 'create' && m.to != null) { const e = ms.find(o => o.r < m.r && touches(o, m.to));
|
|
7877
|
+
if (e) say('create-once', m, '', [m.id, m.to, e.id], e.sort === 'create' && e.to === m.to ? `${m.to} is created twice, by ${nm(e)} and ${nm(m)}` : `${nm(m)} creates ${m.to}, but ${nm(e)} reaches it first`); }
|
|
7878
|
+
if (m.sort === 'delete' && m.to != null) { const e = ms.find(o => o.r > m.r && touches(o, m.to));
|
|
7879
|
+
if (e) say('delete-once', m, '', [m.id, m.to, e.id], e.sort === 'delete' && e.to === m.to ? `${m.to} is deleted twice, by ${nm(m)} and ${nm(e)}` : `${nm(m)} deletes ${m.to}, but ${nm(e)} reaches it later`); }
|
|
7880
|
+
});
|
|
7881
|
+
return out;
|
|
7882
|
+
}
|
|
7883
|
+
/* ---------- the sequence diagram, uml.sequence (work package 3.3, notes/stage-3.md §4 and §5) ----------
|
|
7884
|
+
The subset of notes/stage-3.md §4.1: lifelines (`represents`, «actor» as text), messages inline in the view's `edges`
|
|
7885
|
+
(call, async, reply, create, delete), interaction uses inline among them, combined fragments in `fragments` (alt, opt,
|
|
7886
|
+
loop, par, break) with their operands as members. The meaning (occurrences, bars, reply matching, the shared rules) is
|
|
7887
|
+
src/uml/interaction.js; the geometry is src/layout/timeaxis.js; the shapes are src/uml/frame.js. This file joins them:
|
|
7888
|
+
the view as layoutTimeAxis's input, its output as svgOf's geometry with names and a reading order (geo.order, S13), the
|
|
7889
|
+
type's layers, its validators, describe(), the legend, and the gesture hooks of §5.1, each a pure function that returns
|
|
7890
|
+
commands for apply(), null, or { error }. Ships in lib/umlimator-all.js only. */
|
|
7891
|
+
|
|
7892
|
+
const SQ_ARR = x => (Array.isArray(x) ? x : []), SQ_OBJ = x => x !== null && typeof x === 'object' && !Array.isArray(x);
|
|
7893
|
+
const SQ_HAS = x => x != null && x !== '';
|
|
7894
|
+
const SQ_SORTS = ['call', 'async', 'reply', 'create', 'delete'], SQ_OPS = ['alt', 'opt', 'loop', 'par', 'break'];
|
|
7895
|
+
const SQ_LABEL = { px: 10.5, pad: 12, h: 16 }; // a message's label as svgOf draws a typed line's: 10.5 px text on a backing
|
|
7896
|
+
// how each sort is drawn (src/typed/draw.js's ENDS): a call filled, async, reply and create open, reply and create dashed
|
|
7897
|
+
const SQ_END = { call: { from: 'none', to: 'arrow' }, async: { from: 'none', to: 'open' }, reply: { from: 'none', to: 'open', dashed: true },
|
|
7898
|
+
create: { from: 'none', to: 'open', dashed: true }, delete: { from: 'none', to: 'arrow' } };
|
|
7899
|
+
|
|
7900
|
+
const sqView = (model, view) => (SQ_OBJ(view) ? view : SQ_ARR(model && model.views).find(v => SQ_OBJ(v) && v.id === view)) || null;
|
|
7901
|
+
const sqProps = x => (SQ_OBJ(x && x.props) ? x.props : {});
|
|
7902
|
+
// the view's lifelines by id: a model node, or one of the view's own
|
|
7903
|
+
function sqHeads(model, view) {
|
|
7904
|
+
const at = new Map(); [...SQ_ARR(view.nodes), ...SQ_ARR(model.nodes)].forEach(n => { if (SQ_OBJ(n) && typeof n.id === 'string' && !at.has(n.id)) at.set(n.id, n); });
|
|
7905
|
+
return SQ_ARR(view.nodeIds).filter(id => at.has(id)).map(id => at.get(id));
|
|
7906
|
+
}
|
|
7907
|
+
const sqName = n => String(n.label ?? n.id);
|
|
7908
|
+
const sqHeadText = n => sqName(n) + (SQ_HAS(sqProps(n).represents) ? ' : ' + sqProps(n).represents : ''); // "api : ShopApi"
|
|
7909
|
+
const sqStereo = n => SQ_ARR(sqProps(n).stereotypes).filter(SQ_HAS).map(String);
|
|
7910
|
+
const sqOperator = f => String(sqProps(f).operator ?? '');
|
|
7911
|
+
|
|
7912
|
+
/* The view's structure, read once: the walk (interaction.js), each operand's fragment, each fragment's parent and its
|
|
7913
|
+
operands, which operand chain each occurrence lies in, and each fragment's block of occurrences (rows lo to hi) */
|
|
7914
|
+
function sqIndex(model, view) {
|
|
7915
|
+
const w = interactionWalk(model, view), frags = SQ_ARR(view.fragments).filter(f => SQ_OBJ(f) && typeof f.id === 'string');
|
|
7916
|
+
const F = new Map(frags.map(f => [f.id, f])), opOf = new Map(); // operand id → its fragment
|
|
7917
|
+
frags.forEach(f => SQ_ARR(f.members).forEach(o => { if (SQ_OBJ(o) && typeof o.id === 'string' && !opOf.has(o.id)) opOf.set(o.id, f); }));
|
|
7918
|
+
// the fragments an occurrence or a fragment lies in, from the innermost out, each with the operand it lies in
|
|
7919
|
+
const chain = parent => { const out = [], seen = new Set();
|
|
7920
|
+
for (let p = parent; p != null && opOf.has(p);) { const f = opOf.get(p); if (seen.has(f.id)) break; seen.add(f.id); out.push({ f, op: p }); p = f.parent; }
|
|
7921
|
+
return out; };
|
|
7922
|
+
const occ = w.occ.map(o => ({ ...o, chain: chain(o.parent) }));
|
|
7923
|
+
const block = new Map(); // fragment id → the occurrences it holds, in time order
|
|
7924
|
+
occ.forEach(o => o.chain.forEach(({ f }) => { if (!block.has(f.id)) block.set(f.id, []); block.get(f.id).push(o); }));
|
|
7925
|
+
return { w, occ, frags, F, opOf, chain, block, byId: new Map(occ.map(o => [o.id, o])) };
|
|
7926
|
+
}
|
|
7927
|
+
|
|
7928
|
+
/* ---- layout: the view as layoutTimeAxis's input (notes/stage-3.md §3.2), measured with textW in the host's measure ---- */
|
|
7929
|
+
function sqInput(model, view, X = sqIndex(model, view)) {
|
|
7930
|
+
const heads = sqHeads(model, view), entries = SQ_ARR(view.edges), byI = new Map(X.occ.map(o => [o.i, o])), col = new Map(heads.map((h, j) => [h.id, j]));
|
|
7931
|
+
const rows = entries.map((x, i) => { const o = byI.get(i);
|
|
7932
|
+
if (!o) return { id: SQ_OBJ(x) ? String(x.id) : String(x), kind: 'none', a: null, b: null, w: 0, h: 0, parent: null }; // the core reports it (src/core/model.js)
|
|
7933
|
+
if (!o.message) { const cs = SQ_ARR(sqProps(o.el).covers).filter(id => col.has(id)).sort((a, b) => col.get(a) - col.get(b)); // a use: over what it covers, else every lifeline
|
|
7934
|
+
const ends = cs.length ? [cs[0], cs[cs.length - 1]] : heads.length ? [heads[0].id, heads[heads.length - 1].id] : [null, null];
|
|
7935
|
+
return { id: o.id, kind: 'span', a: ends[0], b: ends[1], parent: o.parent, ...useSize(String(o.el.label ?? '')) }; }
|
|
7936
|
+
const t = SQ_HAS(o.el.label) ? String(o.el.label) : '';
|
|
7937
|
+
return { id: o.id, kind: o.from === o.to ? 'self' : 'line', a: o.from, b: o.to, parent: o.parent, ...(t ? { w: Math.ceil(textW(t, SQ_LABEL.px) + SQ_LABEL.pad), h: SQ_LABEL.h } : { w: 0, h: 0 }) }; });
|
|
7938
|
+
const first = sort => { const at = new Map(); X.occ.forEach(o => { if (o.message && o.sort === sort && !at.has(o.to)) at.set(o.to, o.i); }); return at; };
|
|
7939
|
+
const born = first('create'), ends = first('delete');
|
|
7940
|
+
const depth = f => X.chain(f.parent).length;
|
|
7941
|
+
const groups = X.frags.map((f, k) => [f, depth(f), k]).sort((a, b) => a[1] - b[1] || a[2] - b[2]).map(([f]) => ({ id: f.id, parent: f.parent ?? null, tag: tagSize(sqOperator(f)),
|
|
7942
|
+
parts: SQ_ARR(f.members).filter(o => SQ_OBJ(o) && typeof o.id === 'string').map(o => ({ id: o.id, guard: guardSize(o.label) })) }));
|
|
7943
|
+
const bars = activationBars(model, view).map(b => ({ head: b.head, from: (X.byId.get(b.from) || {}).i, to: (X.byId.get(b.to) || {}).i, depth: b.depth }));
|
|
7944
|
+
return { heads: heads.map(n => ({ id: n.id, ...SHAPE.lifeline.size({ label: sqHeadText(n), stereotypes: sqStereo(n) }), born: born.get(n.id) ?? null, ends: ends.get(n.id) ?? null })), rows, groups, bars };
|
|
7945
|
+
}
|
|
7946
|
+
|
|
7947
|
+
/* run(model, view): svgOf's geometry. Nodes: the heads (shape lifeline, with the stem's hit strip), the frames, operands and
|
|
7948
|
+
uses (frame.js's shapes); edges: the messages, with their ends and labels; ta: layoutTimeAxis's output, for draw(); slots
|
|
7949
|
+
and problems from it; order (S13): heads left to right, then fragments, operands and occurrences in time order */
|
|
7950
|
+
function sqRun(model, view) {
|
|
7951
|
+
const X = sqIndex(model, view), inp = sqInput(model, view, X), o = layoutTimeAxis(inp), heads = sqHeads(model, view), H = new Map(heads.map(n => [n.id, n]));
|
|
7952
|
+
const msgs = X.occ.filter(m => m.message), nth = new Map(msgs.map((m, k) => [m.id, k + 1]));
|
|
7953
|
+
const inWhat = parent => { const c = X.chain(parent)[0]; if (!c) return ''; const op = SQ_ARR(c.f.members).find(m => SQ_OBJ(m) && m.id === c.op) || {};
|
|
7954
|
+
return `, in ${sqOperator(c.f) || 'fragment'}${SQ_HAS(op.label) ? ', ' + op.label : ''}`; };
|
|
7955
|
+
const direct = id => X.occ.filter(m => m.parent === id).length;
|
|
7956
|
+
const nodes = [
|
|
7957
|
+
...o.heads.map((h, j) => { const n = H.get(h.id), st = sqStereo(n);
|
|
7958
|
+
return { ...h, shape: 'lifeline', type: 'uml.Lifeline', label: sqHeadText(n), stereotypes: st, k: 'file', stem: o.stems[j], aria: `Lifeline ${sqHeadText(n)}${st.length ? ` (${st.join(', ')})` : ''}` }; }),
|
|
7959
|
+
...o.frames.flatMap(fr => { const f = X.F.get(fr.id), op = sqOperator(f), ms = SQ_ARR(f.members).filter(SQ_OBJ);
|
|
7960
|
+
return [{ id: fr.id, shape: 'frame', type: 'uml.CombinedFragment', label: op, k: 'file', holder: true, x: fr.x, y: fr.y, w: fr.w, h: fr.h, aria: `Combined fragment ${op}, ${ms.length} ${ms.length === 1 ? 'operand' : 'operands'}` },
|
|
7961
|
+
...fr.parts.map(p => { const m = ms.find(x => x.id === p.id) || {}, k = direct(p.id);
|
|
7962
|
+
return { ...p, shape: 'operand', type: 'uml.InteractionOperand', label: m.label, k: 'file', holder: true, aria: `Operand ${SQ_HAS(m.label) ? m.label + ' ' : ''}of ${op}, ${k} ${k === 1 ? 'occurrence' : 'occurrences'}` }; })]; }),
|
|
7963
|
+
...o.spans.map(s => { const u = X.byId.get(s.id), cs = SQ_ARR(sqProps(u.el).covers).filter(id => H.has(id)), all = heads.map(n => n.id), ids = cs.length ? all.filter(id => cs.includes(id)) : all;
|
|
7964
|
+
return { ...s, shape: 'use', type: 'uml.InteractionUse', label: String(u.el.label ?? ''), k: 'file', holder: true,
|
|
7965
|
+
aria: `Interaction use ${u.el.label ?? ''}${ids.length ? `, over ${sqName(H.get(ids[0]))}${ids.length > 1 ? ' to ' + sqName(H.get(ids[ids.length - 1])) : ''}` : ''}` }; }),
|
|
7966
|
+
];
|
|
7967
|
+
const byId = Object.fromEntries(nodes.map(n => [n.id, n]));
|
|
7968
|
+
const edges = o.lines.map(l => { const m = X.byId.get(l.id), t = SQ_HAS(m.el.label) ? String(m.el.label) : '';
|
|
7969
|
+
return { id: m.id, _id: m.id, type: 'uml.Message', from: m.from, to: m.to, s: byId[m.from], t: byId[m.to], pts: l.pts, end: SQ_END[m.sort], sort: m.sort,
|
|
7970
|
+
...(t ? { lbl: { ...l.lbl, text: t }, label: t } : {}),
|
|
7971
|
+
aria: `Message ${nth.get(m.id)} of ${msgs.length}: ${sqName(H.get(m.from))} to ${sqName(H.get(m.to))}${t ? ', ' + t : ''}, ${m.sort}${inWhat(m.parent)}` }; });
|
|
7972
|
+
// reading order: by height on the page; at one height a frame before its first operand, both before what they hold
|
|
7973
|
+
const rank = { frame: 0, operand: 1 }, at = [...nodes.filter(n => n.shape !== 'lifeline').map(n => [n.id, n.y, rank[n.shape] ?? 2]), ...edges.map(e => [e.id, e.pts[0][1], 2])];
|
|
7974
|
+
const order = [...o.heads.map(h => h.id), ...at.sort((a, b) => a[1] - b[1] || a[2] - b[2]).map(x => x[0])];
|
|
7975
|
+
return { nodes, edges, groups: [], byId, bounds: o.bounds, map: false, order, slots: o.slots, problems: o.problems, ta: o, legend: sqLegend(edges) };
|
|
7976
|
+
}
|
|
7977
|
+
|
|
7978
|
+
/* draw, all under the lines (notes/stage-3.md §4.2): frame outlines and dividers, then the stems, then each use's box
|
|
7979
|
+
(filled, so stems pass behind it), then the bars, deepest last, then a deleted lifeline's cross */
|
|
7980
|
+
function sqDraw(g, kit) {
|
|
7981
|
+
const o = g.ta, r2 = kit.r2;
|
|
7982
|
+
if (!o) return { under: '', over: '' };
|
|
7983
|
+
const under = '<g class="seq-under">' + o.frames.map(f => frame(f.x, f.y, f.w, f.h, f.dividers.map(d => d.y))).join('')
|
|
7984
|
+
+ o.stems.map(s => `<path class="seq-stem" d="M${r2(s.x)} ${r2(s.y1)}V${r2(s.y2)}" stroke="var(--um-muted)" stroke-width="1.2" stroke-dasharray="5 4"/>`).join('')
|
|
7985
|
+
+ o.spans.map(s => `<rect class="seq-use" x="${r2(s.x)}" y="${r2(s.y)}" width="${r2(s.w)}" height="${r2(s.h)}" fill="var(--um-stage-bg)"/>${frame(s.x, s.y, s.w, s.h)}`).join('')
|
|
7986
|
+
+ o.bars.map(b => `<rect class="seq-bar" x="${r2(b.x)}" y="${r2(b.y)}" width="${r2(b.w)}" height="${r2(b.h)}" fill="var(--um-stage-bg)" stroke="var(--um-text)" stroke-width="1.2"/>`).join('')
|
|
7987
|
+
+ o.stems.filter(s => s.cross).map(s => `<path class="seq-cross" d="M${r2(s.x - 8)} ${r2(s.y2 - 8)}l16 16m0 -16l-16 16" stroke="var(--um-text)" stroke-width="2"/>`).join('') + '</g>';
|
|
7988
|
+
return { under, over: '' };
|
|
7989
|
+
}
|
|
7990
|
+
|
|
7991
|
+
/* the legend (pieces.js shows it): each sort of message the view draws */
|
|
7992
|
+
const SQ_WORDS = { call: ['Call', 'the sender waits for it'], async: ['Async', 'the sender goes on'], reply: ['Reply', 'the answer to a call'],
|
|
7993
|
+
create: ['Create', 'creates the lifeline it points to'], delete: ['Delete', 'ends the lifeline it points to (×)'] };
|
|
7994
|
+
const SQ_TIP = { arrow: '<path d="M17 4L26 8L17 12Z" fill="var(--um-muted)"/>', open: '<path d="M17 4L26 8L17 12" fill="none"/>' };
|
|
7995
|
+
function sqLegend(edges) {
|
|
7996
|
+
const has = new Set(edges.map(e => e.sort));
|
|
7997
|
+
return SQ_SORTS.filter(s => has.has(s)).map(s => { const E = SQ_END[s], [name, says] = SQ_WORDS[s];
|
|
7998
|
+
return `<span class="lgi" title="${esc(name + ' — ' + says)}"><svg class="lgs" viewBox="0 0 28 16" aria-hidden="true"><g stroke="var(--um-muted)" stroke-width="1.4" stroke-linejoin="round"><path d="M2 8H17"${E.dashed ? ' stroke-dasharray="3 2"' : ''}/>${SQ_TIP[E.to]}</g></svg><b>${esc(name)}</b><span>${esc(says)}</span></span>`; });
|
|
7999
|
+
}
|
|
8000
|
+
|
|
8001
|
+
/* ---- validators (notes/stage-3.md §4.3). Each problem: { level, rule, path, message, ids }; the shared ones are
|
|
8002
|
+
interaction.js's ---- */
|
|
8003
|
+
const SQ_RULES = {
|
|
8004
|
+
'parent-operand': 'an occurrence\'s or a fragment\'s parent is an operand of this view, and no fragment lies inside itself',
|
|
8005
|
+
contiguous: 'a fragment\'s occurrences, nested ones included, are one run in edges, operand by operand in members order',
|
|
8006
|
+
'message-ends': 'a message joins two lifelines of the view',
|
|
8007
|
+
...INTERACTION_RULES,
|
|
8008
|
+
covers: 'an interaction use covers lifelines of the view that are next to each other in nodeIds',
|
|
8009
|
+
operator: 'a fragment\'s operator is alt, opt, loop, par or break; opt, loop and break have one operand',
|
|
8010
|
+
empty: 'no operand or fragment is empty',
|
|
8011
|
+
view: 'a sequence view has no layout pins and is drawn down',
|
|
8012
|
+
};
|
|
8013
|
+
function sqProblems(model, view) {
|
|
8014
|
+
const out = [], V = sqView(model, view); if (!SQ_OBJ(model) || !V) return out;
|
|
8015
|
+
const vi = SQ_ARR(model.views).indexOf(V), at = `views[${vi}]`, X = sqIndex(model, V), ids = SQ_ARR(V.nodeIds), col = new Map(ids.map((id, j) => [id, j]));
|
|
8016
|
+
const say = (rule, path, who, msg, level = 'error') => out.push({ level, rule, path, ids: who.filter(x => x != null), message: `${rule}: ${msg}` });
|
|
8017
|
+
const fPath = f => `${at}.fragments[${SQ_ARR(V.fragments).indexOf(f)}]`;
|
|
8018
|
+
// parent-operand: an occurrence's or a fragment's parent; a fragment inside itself
|
|
8019
|
+
X.occ.forEach(o => { if (o.parent != null && !X.opOf.has(o.parent)) say('parent-operand', o.path + '.parent', [o.id], `${o.id}'s parent ${JSON.stringify(o.parent)} is not an operand of view ${V.id}`); });
|
|
8020
|
+
X.frags.forEach(f => { if (f.parent == null) return;
|
|
8021
|
+
if (!X.opOf.has(f.parent)) return say('parent-operand', fPath(f) + '.parent', [f.id], `fragment ${f.id}'s parent ${JSON.stringify(f.parent)} is not an operand of view ${V.id}`);
|
|
8022
|
+
const seen = new Set(); for (let p = f.parent; p != null && X.opOf.has(p);) { const g = X.opOf.get(p); if (g === f) { say('parent-operand', fPath(f) + '.parent', [f.id, f.parent], `fragment ${f.id} lies inside itself (its parent ${f.parent})`); break; } if (seen.has(g)) break; seen.add(g); p = g.parent; } });
|
|
8023
|
+
// contiguous: each fragment's occurrences one run, operand by operand
|
|
8024
|
+
X.frags.forEach(f => { const os = X.block.get(f.id) || [], ops = SQ_ARR(f.members).map(m => SQ_OBJ(m) && m.id);
|
|
8025
|
+
const k = o => ops.indexOf(o.chain.find(c => c.f === f).op);
|
|
8026
|
+
const split = os.some((o, j) => j && o.r !== os[j - 1].r + 1), back = os.some((o, j) => j && k(o) < k(os[j - 1]));
|
|
8027
|
+
if (split || back) say('contiguous', fPath(f), [f.id, ...os.map(o => o.id)], `fragment ${f.id}'s occurrences (${os.map(o => o.id).join(', ')}) are ${split ? 'not one run in edges' : 'not in the order of its operands'}`); });
|
|
8028
|
+
// message-ends
|
|
8029
|
+
X.occ.filter(o => o.message).forEach(o => { const off = [o.from, o.to].filter(id => !col.has(id));
|
|
8030
|
+
if (off.length) say('message-ends', o.path, [o.id, ...off], `${o.id} runs from ${o.from} to ${o.to}, and ${[...new Set(off)].join(' and ')} ${new Set(off).size > 1 ? 'are not lifelines' : 'is not a lifeline'} of view ${V.id}`); });
|
|
8031
|
+
out.push(...interactionProblems(model, V));
|
|
8032
|
+
// covers
|
|
8033
|
+
X.occ.filter(o => !o.message).forEach(o => { const cs = SQ_ARR(sqProps(o.el).covers);
|
|
8034
|
+
if (!cs.length) return say('covers', o.path + '.props', [o.id], `${o.id} covers no lifeline: drawn across all of them`, 'warning');
|
|
8035
|
+
const off = cs.filter(id => !col.has(id)), js = cs.filter(id => col.has(id)).map(id => col.get(id)).sort((a, b) => a - b);
|
|
8036
|
+
if (off.length) say('covers', o.path + '.props.covers', [o.id, ...off], `${o.id} covers ${off.join(', ')}, not ${off.length > 1 ? 'lifelines' : 'a lifeline'} of view ${V.id}`);
|
|
8037
|
+
else if (js[js.length - 1] - js[0] + 1 !== new Set(js).size) say('covers', o.path + '.props.covers', [o.id, ...cs], `${o.id} covers ${cs.join(', ')}, which are not next to each other in nodeIds`); });
|
|
8038
|
+
// operator, empty
|
|
8039
|
+
const holds = id => X.occ.some(o => o.chain.some(c => c.op === id));
|
|
8040
|
+
X.frags.forEach(f => { const op = sqProps(f).operator, ms = SQ_ARR(f.members).filter(SQ_OBJ);
|
|
8041
|
+
if (!SQ_OPS.includes(op)) say('operator', fPath(f) + '.props.operator', [f.id], `fragment ${f.id} has ${op === undefined ? 'no operator' : `operator ${JSON.stringify(op)}`}, not one of ${SQ_OPS.join(', ')}: it is drawn by its name`, 'warning');
|
|
8042
|
+
else if (['opt', 'loop', 'break'].includes(op) && ms.length !== 1) say('operator', fPath(f) + '.members', [f.id], `fragment ${f.id} is ${op}, which has one operand, not ${ms.length}`, 'warning');
|
|
8043
|
+
if (!ms.length || !ms.some(m => holds(m.id))) say('empty', fPath(f), [f.id], `fragment ${f.id} holds nothing: it has no place in time and is drawn at the end of what holds it`, 'warning');
|
|
8044
|
+
else ms.forEach((m, j) => { if (!holds(m.id)) say('empty', `${fPath(f)}.members[${j}]`, [m.id, f.id], `operand ${m.id} of ${f.id} holds nothing`, 'warning'); }); });
|
|
8045
|
+
// view
|
|
8046
|
+
if (SQ_OBJ(V.layout) && Object.keys(V.layout).length) say('view', at + '.layout', Object.keys(V.layout), `view ${V.id} pins ${Object.keys(V.layout).join(', ')}: a sequence places its lifelines in nodeIds order, so pins mean nothing`, 'warning');
|
|
8047
|
+
if (V.direction != null && V.direction !== 'down') say('view', at + '.direction', [], `view ${V.id} has direction ${JSON.stringify(V.direction)}: a sequence is drawn down`, 'warning');
|
|
8048
|
+
return out;
|
|
8049
|
+
}
|
|
8050
|
+
|
|
8051
|
+
/* ---- describe(): the view in words, in time order, nested as it is drawn ---- */
|
|
8052
|
+
const SQ_VERB = { call: 'calls', async: 'sends to', reply: 'replies to', create: 'creates', delete: 'deletes' };
|
|
8053
|
+
function sqDescribe(model, viewId) {
|
|
8054
|
+
const V = SQ_ARR(model && model.views).find(x => SQ_OBJ(x) && x.id === viewId); if (!V) throw new Error(`umlimator: no view ${JSON.stringify(viewId)}`);
|
|
8055
|
+
const X = sqIndex(model, V), heads = sqHeads(model, V), H = new Map(heads.map(n => [n.id, n])), nm = id => (H.has(id) ? sqName(H.get(id)) : String(id));
|
|
8056
|
+
const ms = X.occ.filter(o => o.message), uses = X.occ.length - ms.length, n = new Map(ms.map((m, k) => [m.id, k + 1]));
|
|
8057
|
+
const count = (k, w) => `${k} ${k === 1 ? w : w + 's'}`;
|
|
8058
|
+
const out = [`${V.title || V.id}: a sequence diagram of ${count(heads.length, 'lifeline')}, ${count(ms.length, 'message')}${uses ? `, ${count(uses, 'interaction use')}` : ''} and ${count(X.frags.length, 'combined fragment')}.`];
|
|
8059
|
+
if (heads.length) out.push(`Lifelines, left to right: ${heads.map(h => sqHeadText(h) + (sqStereo(h).length ? ` (${sqStereo(h).join(', ')})` : '')).join('; ')}.`);
|
|
8060
|
+
let open = [];
|
|
8061
|
+
X.occ.forEach(o => { const c = o.chain.slice().reverse(); let k = 0; while (k < open.length && k < c.length && open[k].op === c[k].op) k++;
|
|
8062
|
+
open = open.slice(0, k);
|
|
8063
|
+
for (; k < c.length; k++) { const { f, op } = c[k], m = SQ_ARR(f.members).find(x => SQ_OBJ(x) && x.id === op) || {};
|
|
8064
|
+
out.push(`${' '.repeat(k)}${sqOperator(f) || 'fragment'}${SQ_HAS(m.label) ? ` [${m.label}]` : ''}:`); open.push(c[k]); }
|
|
8065
|
+
const pad = ' '.repeat(c.length), t = SQ_HAS(o.el.label) ? `: ${o.el.label}` : '';
|
|
8066
|
+
if (!o.message) { const cs = SQ_ARR(sqProps(o.el).covers); out.push(`${pad}ref ${JSON.stringify(String(o.el.label ?? ''))}${cs.length ? ` over ${cs.map(nm).join(', ')}` : ''}.`); return; }
|
|
8067
|
+
const to = o.from === o.to ? 'itself' : nm(o.to), call = o.sort === 'reply' && n.has(o.replyTo) ? ` (answers ${n.get(o.replyTo)})` : '';
|
|
8068
|
+
out.push(`${pad}${n.get(o.id)}. ${nm(o.from)} ${SQ_VERB[o.sort]} ${to}${call}${t}.`); });
|
|
8069
|
+
return out.join('\n');
|
|
8070
|
+
}
|
|
8071
|
+
|
|
8072
|
+
/* ---- the gesture hooks (notes/stage-3.md §5.1, §5.2). Each takes the model and the view (or its id) and returns the
|
|
8073
|
+
commands of one transaction, null to leave the gesture to the canvas's default, or { error } for the hint line. A
|
|
8074
|
+
transaction that would add an error the model did not have is never returned: { error } names the rule (S5) ---- */
|
|
8075
|
+
function sqNewId(model, base, more = []) { // the base and the first number free in the model: 'm11', 'f3'
|
|
8076
|
+
const used = new Set(more); JSON.stringify(model, (k, v) => { if (k === 'id' && typeof v === 'string') used.add(v); return v; });
|
|
8077
|
+
const b = String(base).toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '') || 'el'; let k = 1; while (used.has(b + k)) k++; return b + k;
|
|
8078
|
+
}
|
|
8079
|
+
const sqErr = message => ({ error: message });
|
|
8080
|
+
const sqErrKey = p => `${p.rule}|${[...p.ids].sort().join()}`;
|
|
8081
|
+
// the commands, unless they throw or bring an error the view did not have
|
|
8082
|
+
function sqGuard(model, V, commands) {
|
|
8083
|
+
if (!Array.isArray(commands) || !commands.length) return commands;
|
|
8084
|
+
let next; try { next = apply(model, commands); } catch (x) { return sqErr(String(x && x.message || x)); }
|
|
8085
|
+
const was = new Set(sqProblems(model, V).filter(p => p.level === 'error').map(sqErrKey));
|
|
8086
|
+
const bad = sqProblems(next, sqView(next, V.id)).find(p => p.level === 'error' && !was.has(sqErrKey(p)));
|
|
8087
|
+
return bad ? sqErr(bad.message) : commands;
|
|
8088
|
+
}
|
|
8089
|
+
// the slots of a view (layoutTimeAxis's): their index and parent do not hang on any size
|
|
8090
|
+
const sqSlots = (model, V, X) => layoutTimeAxis(sqInput(model, V, X)).slots;
|
|
8091
|
+
// an occurrence's or a fragment's run in edges: { lo, hi, ids, parent, inside (the operands in it) }, or { error }
|
|
8092
|
+
function sqRunOf(X, V, id) {
|
|
8093
|
+
const o = X.byId.get(id); if (o) return { lo: o.i, hi: o.i, ids: [o.id], parent: o.parent ?? null, inside: new Set() };
|
|
8094
|
+
const f = X.F.get(id); if (!f) return sqErr(`${id} is not a message, an interaction use or a fragment of view ${V.id}`);
|
|
8095
|
+
const os = X.block.get(f.id) || [], inside = new Set([...X.opOf.keys()].filter(op => X.chain(op).some(c => c.f === f)));
|
|
8096
|
+
if (os.some((x, j) => j && x.i !== os[j - 1].i + 1)) return sqErr(`fragment ${f.id}'s occurrences are not one run in time: move them together first`);
|
|
8097
|
+
return { lo: os.length ? os[0].i : null, hi: os.length ? os[os.length - 1].i : null, ids: os.map(x => x.id), parent: f.parent ?? null, inside };
|
|
8098
|
+
}
|
|
8099
|
+
// the commands that put a run at a slot { index, parent }: one move per occurrence it holds, in order, then a reparent
|
|
8100
|
+
function sqPut(V, id, R, at, el) {
|
|
8101
|
+
if (R.inside.has(at.parent)) return sqErr(`${id} cannot move into itself`);
|
|
8102
|
+
const t = Number.isInteger(at.index) ? Math.max(0, Math.min(SQ_ARR(V.edges).length, at.index)) : SQ_ARR(V.edges).length, out = [];
|
|
8103
|
+
if (R.lo != null) {
|
|
8104
|
+
if (t > R.lo && t <= R.hi) return sqErr(`${id} cannot move into itself`);
|
|
8105
|
+
if (t > R.hi + 1) R.ids.forEach(x => out.push({ op: 'move', id: x, view: V.id, index: t - 1 }));
|
|
8106
|
+
else if (t < R.lo) R.ids.forEach((x, k) => out.push({ op: 'move', id: x, view: V.id, index: t + k }));
|
|
8107
|
+
}
|
|
8108
|
+
if ((el.parent ?? null) !== (at.parent ?? null)) out.push({ op: 'reparent', id, parent: at.parent ?? null });
|
|
8109
|
+
return out;
|
|
8110
|
+
}
|
|
8111
|
+
|
|
8112
|
+
/* connect(model, view, from, to, at, line): a new message from `from` to `to` (the same lifeline: a self-message) at the
|
|
8113
|
+
slot `at` ({ index, parent }; null: the end), with the picked entry's props. A reply answers the latest unanswered call
|
|
8114
|
+
back along it (interaction.js's replyCallFor) */
|
|
8115
|
+
function sqConnect(model, view, from, to, at, line) {
|
|
8116
|
+
const V = sqView(model, view); if (!V) return null;
|
|
8117
|
+
const ids = SQ_ARR(V.nodeIds), props = { ...(SQ_OBJ(line && line.props) ? structuredClone(line.props) : {}) };
|
|
8118
|
+
if (!ids.includes(from) || !ids.includes(to)) return sqErr(`a message joins two lifelines of view ${V.id}`);
|
|
8119
|
+
const sort = SQ_SORTS.includes(props.sort) ? props.sort : (props.sort = 'call');
|
|
8120
|
+
if (from === to && (sort === 'create' || sort === 'delete')) return sqErr(`a lifeline does not ${sort} itself`);
|
|
8121
|
+
const slot = SQ_OBJ(at) ? at : { index: SQ_ARR(V.edges).length, parent: null };
|
|
8122
|
+
if (sort === 'reply') { const c = replyCallFor(model, V, from, to, slot); if (c != null) props.replyTo = c; }
|
|
8123
|
+
const element = { id: sqNewId(model, 'm'), type: (line && typeof line.type === 'string' && line.type) || 'uml.Message', from, to, ...(slot.parent != null ? { parent: slot.parent } : {}), props };
|
|
8124
|
+
return sqGuard(model, V, [{ op: 'add', element, view: V.id, index: slot.index ?? SQ_ARR(V.edges).length, in: 'view' }]);
|
|
8125
|
+
}
|
|
8126
|
+
|
|
8127
|
+
// a lifeline dropped at x: its place in nodeIds, among the other heads as laid out in the default measure. The canvas passes
|
|
8128
|
+
// col, its count of heads drawn left of the drop in the host's font, which wins: the two layouts can differ by more than a gap
|
|
8129
|
+
const sqColAt = (model, V, id, x) => layoutTimeAxis(sqInput(model, V)).heads.filter(h => h.id !== id && h.x + h.w / 2 < x).length;
|
|
8130
|
+
|
|
8131
|
+
/* moveTo(model, view, id, at): a message, a use or a fragment to the slot `at`, or one slot up or down with at = { by: [0, ±1] }
|
|
8132
|
+
(Alt + Up/Down); a lifeline one place left or right with { by: [±1, 0] } (Alt + Left/Right), or to its place in nodeIds
|
|
8133
|
+
with { index }, or to the place nearest a drop's point { pt: [x, y], col } (a drag sideways; col, the canvas's count of
|
|
8134
|
+
heads drawn left of pt, when given); an operand one place up or down
|
|
8135
|
+
among its fragment's (the member list's Alt + Up/Down), or to its place with { index }: a move of the member, then one
|
|
8136
|
+
move per occurrence it holds, so edges follows the new order. A slot the canvas passes (it has a y, or a drop's pt) is
|
|
8137
|
+
never read as a place in nodeIds or members. Anything else: null */
|
|
8138
|
+
function sqMoveTo(model, view, id, at) {
|
|
8139
|
+
const V = sqView(model, view); if (!V || !SQ_OBJ(at)) return null;
|
|
8140
|
+
const by = Array.isArray(at.by) ? at.by : null, ids = SQ_ARR(V.nodeIds), pt = Array.isArray(at.pt) ? at.pt : null, slot0 = !!pt || Number.isFinite(at.y);
|
|
8141
|
+
const place = () => (!slot0 && Number.isInteger(at.index) ? at.index : null);
|
|
8142
|
+
if (ids.includes(id)) { const i = ids.indexOf(id), j = by ? (by[1] ? null : i + Math.sign(by[0])) : pt ? (Number.isInteger(at.col) ? at.col : sqColAt(model, V, id, +pt[0])) : place();
|
|
8143
|
+
if (j == null) return null; if (j < 0 || j >= ids.length || j === i) return [];
|
|
8144
|
+
return sqGuard(model, V, [{ op: 'move', id, view: V.id, index: j }]); }
|
|
8145
|
+
const X = sqIndex(model, V);
|
|
8146
|
+
if (X.opOf.has(id)) { // an operand among its fragment's operands: { by: [0, ±1] } or { index }; edges follows (contiguous)
|
|
8147
|
+
const f = X.opOf.get(id), ops = SQ_ARR(f.members).filter(SQ_OBJ).map(o => o.id), k = ops.indexOf(id);
|
|
8148
|
+
const j = by ? (by[0] ? null : k + Math.sign(by[1])) : place();
|
|
8149
|
+
if (j == null) return null; if (j < 0 || j >= ops.length || j === k) return [];
|
|
8150
|
+
const R = sqRunOf(X, V, f.id); if (R.error) return R;
|
|
8151
|
+
const mine = o => (X.block.get(f.id) || []).filter(x => x.chain.some(c => c.op === o)).map(x => x.i), now = mine(id);
|
|
8152
|
+
const out = [{ op: 'move', id, index: j }], past = ops.slice(Math.min(j, k + 1), Math.max(j + 1, k)).flatMap(mine); // what it passes
|
|
8153
|
+
if (now.length && past.length) { const ids = now.map(i => SQ_ARR(V.edges)[i].id);
|
|
8154
|
+
if (j < k) ids.forEach((x, p) => out.push({ op: 'move', id: x, view: V.id, index: past[0] + p }));
|
|
8155
|
+
else ids.forEach(x => out.push({ op: 'move', id: x, view: V.id, index: past[past.length - 1] })); }
|
|
8156
|
+
return sqGuard(model, V, out); }
|
|
8157
|
+
const R = sqRunOf(X, V, id); if (R.error) return R;
|
|
8158
|
+
const el = X.byId.has(id) ? X.byId.get(id).el : X.F.get(id);
|
|
8159
|
+
let slot = at;
|
|
8160
|
+
if (by) { if (!by[1] || by[0]) return null; // sideways: nothing for an occurrence
|
|
8161
|
+
if (R.lo == null) return sqErr(`fragment ${id} holds nothing: it has no place in time to move from`);
|
|
8162
|
+
// from the run's own slot in its parent (before it going up, after it going down) to the next one that is another place.
|
|
8163
|
+
// A fragment that holds nothing but the run is skipped: once the run leaves, it has no place in time (it is drawn at
|
|
8164
|
+
// its parent's end), so stepping into it would only step back out
|
|
8165
|
+
const all = sqSlots(model, V, X), P = R.parent, mine = new Set(R.ids), gone = new Set();
|
|
8166
|
+
X.frags.forEach(f => { if ((X.block.get(f.id) || []).every(o => mine.has(o.id))) SQ_ARR(f.members).forEach(m => SQ_OBJ(m) && gone.add(m.id)); });
|
|
8167
|
+
const here = by[1] < 0 ? R.lo : R.hi + 1, k = by[1] < 0 ? all.findIndex(s => s.parent === P && s.index === here) : all.findLastIndex(s => s.parent === P && s.index === here);
|
|
8168
|
+
slot = undefined; if (k < 0) return [];
|
|
8169
|
+
for (let j = k + Math.sign(by[1]); j >= 0 && j < all.length; j += Math.sign(by[1])) { const s = all[j];
|
|
8170
|
+
if (!gone.has(s.parent) && !(s.parent === P && s.index === here)) { slot = s; break; } }
|
|
8171
|
+
if (!slot) return []; }
|
|
8172
|
+
return sqGuard(model, V, sqPut(V, id, R, slot, el));
|
|
8173
|
+
}
|
|
8174
|
+
|
|
8175
|
+
/* wrap(model, view, ids, kind): a new fragment of operator `kind` round a run of occurrences (and fragments) that share a
|
|
8176
|
+
parent and lie next to each other in time: it is added with one operand (an alt with two, the second "else"), and each
|
|
8177
|
+
item of the run moves into its first operand */
|
|
8178
|
+
function sqWrap(model, view, ids, kind) {
|
|
8179
|
+
const V = sqView(model, view); if (!V) return null;
|
|
8180
|
+
if (!SQ_OPS.includes(kind)) return sqErr(`a fragment's operator is one of ${SQ_OPS.join(', ')}, not ${JSON.stringify(kind)}`);
|
|
8181
|
+
const X = sqIndex(model, V), sel = [...new Set(SQ_ARR(ids))]; if (!sel.length) return sqErr('select the messages to wrap first');
|
|
8182
|
+
const runs = sel.map(id => [id, sqRunOf(X, V, id)]), bad = runs.find(([, r]) => r.error); if (bad) return bad[1];
|
|
8183
|
+
const P = runs[0][1].parent; if (runs.some(([, r]) => r.parent !== P)) return sqErr('what is wrapped lies in one place: these lie in different operands');
|
|
8184
|
+
const lo = Math.min(...runs.map(([, r]) => r.lo ?? Infinity)), hi = Math.max(...runs.map(([, r]) => r.hi ?? -Infinity)), items = new Set(sel);
|
|
8185
|
+
// each occurrence of the run is selected, or lies in a fragment of P that lies in the run
|
|
8186
|
+
for (const o of X.occ.filter(x => x.i >= lo && x.i <= hi)) {
|
|
8187
|
+
if (o.parent === P) { if (!items.has(o.id)) return sqErr(`${o.id} lies between what is selected: select it too, or a run with nothing between`); continue; }
|
|
8188
|
+
const c = o.chain.find(x => (x.f.parent ?? null) === P); if (!c) return sqErr(`${o.id} lies between what is selected, in another place`);
|
|
8189
|
+
const r = sqRunOf(X, V, c.f.id); if (r.error || r.lo < lo || r.hi > hi) return sqErr(`fragment ${c.f.id} lies only partly in the run`);
|
|
8190
|
+
items.add(c.f.id); }
|
|
8191
|
+
const fid = sqNewId(model, 'f'), members = [{ id: `${fid}.1`, type: 'uml.InteractionOperand' }, ...(kind === 'alt' ? [{ id: `${fid}.2`, type: 'uml.InteractionOperand', label: 'else' }] : [])];
|
|
8192
|
+
const element = { id: fid, type: 'uml.CombinedFragment', ...(P != null ? { parent: P } : {}), props: { operator: kind }, members };
|
|
8193
|
+
return sqGuard(model, V, [{ op: 'add', element, view: V.id, in: 'view' }, ...[...items].map(id => ({ op: 'reparent', id, parent: members[0].id }))]);
|
|
8194
|
+
}
|
|
8195
|
+
|
|
8196
|
+
/* add(model, view, entry, label, at): the palette and N. A lifeline is a model node, placed after the last lifeline selected
|
|
8197
|
+
(at.sel, the ids the canvas has selected) or the one `at.after` names, else last, and each use that covers both lifelines
|
|
8198
|
+
beside it covers it too; an interaction use goes in the view at the slot `at`, covering the lifelines selected and those
|
|
8199
|
+
between them, else `at.covers`, else every lifeline */
|
|
8200
|
+
function sqAdd(model, view, entry, label, at) {
|
|
8201
|
+
const V = sqView(model, view), type = SQ_OBJ(entry) ? entry.type : null; if (!V) return null;
|
|
8202
|
+
const ids = SQ_ARR(V.nodeIds), t = SQ_HAS(label) ? String(label) : null, js = SQ_ARR(SQ_OBJ(at) && at.sel).map(x => ids.indexOf(x)).filter(j => j >= 0);
|
|
8203
|
+
if (type === 'uml.Lifeline') { const i = js.length ? js[js.length - 1] : SQ_OBJ(at) ? ids.indexOf(at.after) : -1, id = sqNewId(model, t || 'lifeline');
|
|
8204
|
+
// a use that covers both its neighbours covers it too, so covers stays next to each other
|
|
8205
|
+
const grow = i < 0 || i + 1 >= ids.length ? [] : sqIndex(model, V).occ.filter(o => !o.message && [ids[i], ids[i + 1]].every(x => SQ_ARR(sqProps(o.el).covers).includes(x)))
|
|
8206
|
+
.map(o => ({ op: 'set', id: o.id, path: 'props.covers', value: sqProps(o.el).covers.flatMap(x => (x === ids[i] ? [x, id] : [x])) }));
|
|
8207
|
+
return sqGuard(model, V, [{ op: 'add', element: { id, type, ...(t ? { label: t } : {}) }, view: V.id, index: i < 0 ? ids.length : i + 1 }, ...grow]); }
|
|
8208
|
+
if (type === 'uml.InteractionUse') { const slot = SQ_OBJ(at) && Number.isInteger(at.index) ? at : { index: SQ_ARR(V.edges).length, parent: null };
|
|
8209
|
+
const covers = js.length ? ids.slice(Math.min(...js), Math.max(...js) + 1) : SQ_ARR(SQ_OBJ(at) && at.covers).length ? [...at.covers] : [...ids];
|
|
8210
|
+
return sqGuard(model, V, [{ op: 'add', element: { id: sqNewId(model, 'ref'), type, ...(t ? { label: t } : {}), ...(slot.parent != null ? { parent: slot.parent } : {}), props: { covers } }, view: V.id, index: slot.index, in: 'view' }]); }
|
|
8211
|
+
return null;
|
|
8212
|
+
}
|
|
8213
|
+
|
|
8214
|
+
/* remove(model, view, ids, fromModel): Delete (fromModel false) takes a lifeline out of this view (hide, which takes its
|
|
8215
|
+
messages with it, after a set that drops it from each use's covers); Shift + Delete deletes it from the model. A message,
|
|
8216
|
+
a use or a fragment lives in the view: either deletes it (a call's replies go with it; a fragment's content stays). An
|
|
8217
|
+
operand's content moves to just after its fragment before it goes (S6); the last operand, or all of them at once, takes
|
|
8218
|
+
its fragment. With a lifeline among what goes, the commands come as { commands, say }: say, for the hint line, counts the
|
|
8219
|
+
messages that go with it */
|
|
8220
|
+
function sqRemove(model, view, ids, fromModel) {
|
|
8221
|
+
const V = sqView(model, view); if (!V) return null;
|
|
8222
|
+
const X = sqIndex(model, V), lifelines = SQ_ARR(V.nodeIds), out = [], sel = new Set(SQ_ARR(ids));
|
|
8223
|
+
// every operand of a fragment selected: the fragment goes instead (its content stays, as an unwrap)
|
|
8224
|
+
const whole = id => { const f = X.opOf.get(id); return f && SQ_ARR(f.members).filter(SQ_OBJ).every(m => sel.has(m.id)) ? f.id : id; };
|
|
8225
|
+
for (const id of new Set([...sel].map(whole))) {
|
|
8226
|
+
if (lifelines.includes(id) && !fromModel && !SQ_ARR(V.nodes).some(n => SQ_OBJ(n) && n.id === id)) { // one of the view's own goes by delete
|
|
8227
|
+
X.occ.filter(o => !o.message && SQ_ARR(sqProps(o.el).covers).includes(id)).forEach(o => out.push({ op: 'set', id: o.id, path: 'props.covers', value: sqProps(o.el).covers.filter(x => x !== id) }));
|
|
8228
|
+
out.push({ op: 'hide', id, view: V.id }); continue; }
|
|
8229
|
+
const f = X.opOf.get(id);
|
|
8230
|
+
if (f && SQ_ARR(f.members).filter(SQ_OBJ).length > 1) { const R = sqRunOf(X, V, f.id); if (R.error) return R;
|
|
8231
|
+
const mine = (X.block.get(f.id) || []).filter(o => o.chain.some(c => c.op === id)).map(o => o.id);
|
|
8232
|
+
if (mine.length && X.byId.get(mine[mine.length - 1]).i !== R.hi) mine.forEach(x => out.push({ op: 'move', id: x, view: V.id, index: R.hi }));
|
|
8233
|
+
out.push({ op: 'delete', id }); continue; }
|
|
8234
|
+
out.push({ op: 'delete', id: f ? f.id : id });
|
|
8235
|
+
}
|
|
8236
|
+
const r = sqGuard(model, V, out), heads = sqHeads(model, V).filter(n => sel.has(n.id)), hs = new Set(heads.map(n => n.id));
|
|
8237
|
+
if (!Array.isArray(r) || !r.length || !heads.length) return r;
|
|
8238
|
+
const n = X.occ.filter(o => o.message && (hs.has(o.from) || hs.has(o.to))).length;
|
|
8239
|
+
return { commands: r, say: `${fromModel ? 'Deleted' : 'Removed'} ${heads.map(sqName).join(', ')}${fromModel ? '' : ' from the view'}, and ${n ? `${n} ${n === 1 ? 'message' : 'messages'}` : 'no message'} with ${heads.length > 1 ? 'them' : 'it'}` };
|
|
8240
|
+
}
|
|
8241
|
+
|
|
8242
|
+
/* operands are a fragment's members, edited in the properties panel's member list (src/canvas/edit.js): a guard as text */
|
|
8243
|
+
const SQ_MEMBER = {
|
|
8244
|
+
print: m => String(m.label ?? ''),
|
|
8245
|
+
edit: (m, text) => ({ commands: [{ op: 'set', id: m.id, path: 'label', value: String(text).trim() || undefined }] }),
|
|
8246
|
+
add: text => ({ type: 'uml.InteractionOperand', label: String(text).trim(), props: {} }),
|
|
8247
|
+
holds: n => n.type === 'uml.CombinedFragment',
|
|
8248
|
+
};
|
|
8249
|
+
|
|
8250
|
+
/* ---- the type ---- */
|
|
8251
|
+
const SEQ_TYPE = registerDiagramType('uml.sequence', {
|
|
8252
|
+
title: 'sequence diagram',
|
|
8253
|
+
elements: [{ type: 'uml.Lifeline', label: 'Lifeline' }, { type: 'uml.InteractionUse', label: 'Interaction use' }],
|
|
8254
|
+
lines: [['Call', 'call'], ['Async', 'async'], ['Reply', 'reply'], ['Create', 'create'], ['Delete', 'delete']].map(([label, sort]) => ({ type: 'uml.Message', label, props: { sort } })),
|
|
8255
|
+
items: ['uml.InteractionUse'],
|
|
8256
|
+
wraps: SQ_OPS,
|
|
8257
|
+
refs: { 'uml.Message': { 'props.replyTo': 'cascade' }, 'uml.InteractionUse': { 'props.covers': 'drop' } },
|
|
8258
|
+
props: {
|
|
8259
|
+
'uml.Lifeline': [{ key: 'represents', label: 'Represents', kind: 'text' }, { key: 'stereotypes', label: 'Stereotypes', kind: 'list' }],
|
|
8260
|
+
'uml.Message': [{ key: 'sort', label: 'Sort', kind: 'enum', options: SQ_SORTS }, { key: 'replyTo', label: 'Reply to', kind: 'text' }],
|
|
8261
|
+
'uml.InteractionUse': [{ key: 'refersTo', label: 'Refers to', kind: 'text' }, { key: 'covers', label: 'Covers', kind: 'list' }],
|
|
8262
|
+
'uml.CombinedFragment': [{ key: 'operator', label: 'Operator', kind: 'enum', options: SQ_OPS }],
|
|
8263
|
+
},
|
|
8264
|
+
layout: { run: sqRun, pins: false },
|
|
8265
|
+
draw: sqDraw,
|
|
8266
|
+
validate: sqProblems,
|
|
8267
|
+
rules: SQ_RULES,
|
|
8268
|
+
describe: sqDescribe,
|
|
8269
|
+
member: SQ_MEMBER,
|
|
8270
|
+
edits: { connect: sqConnect, moveTo: sqMoveTo, wrap: sqWrap, add: sqAdd, remove: sqRemove },
|
|
8271
|
+
});
|
|
8272
|
+
/* ---------- the communication diagram, uml.communication (work package 3.5, notes/stage-3.md §6) ----------
|
|
8273
|
+
Lifelines joined by connectors; messages are model edges, and the view's `edges` lists the connectors, then the messages
|
|
8274
|
+
in time order (model.md row 12). A view is laid out by layoutTyped, like a class view: lifelines are classifier heads,
|
|
8275
|
+
connectors the lines, a self connector a loop. The messages on one connector are a stack (S7): a node of the layered
|
|
8276
|
+
graph, as wide as its widest row and 16 px tall per message, with its connector split in two through it; after the
|
|
8277
|
+
layout the two halves are one connector again and the stack is one `message-row` node per message, its number (S8, from
|
|
8278
|
+
src/uml/interaction.js), its text and a small arrow toward its receiver. Ships in lib/umlimator-all.js only. */
|
|
8279
|
+
|
|
8280
|
+
const CM_OBJ = x => x !== null && typeof x === 'object' && !Array.isArray(x), CM_ARR = x => (Array.isArray(x) ? x : []);
|
|
8281
|
+
const CM_HAS = x => x != null && x !== '';
|
|
8282
|
+
const CM_SORTS = ['call', 'async', 'reply', 'create', 'delete'];
|
|
8283
|
+
const CM_ROW = { h: 16, px: 11, arrow: 16, pad: 5, max: 280 }; // a message row: its height, font, arrow's room, padding, widest stack
|
|
8284
|
+
const CM_STACK = '\u0000'; // a stack's id and its connector's halves': the connector's id after it, never a model id
|
|
8285
|
+
|
|
8286
|
+
/* ---- the view read: lifelines, connectors and messages, each message on the connector that carries it ---- */
|
|
8287
|
+
function commIndex(model, view) {
|
|
8288
|
+
const N = new Map(), E = new Map();
|
|
8289
|
+
CM_ARR(model && model.nodes).forEach(n => { if (CM_OBJ(n) && typeof n.id === 'string' && !N.has(n.id)) N.set(n.id, n); });
|
|
8290
|
+
CM_ARR(model && model.edges).forEach(e => { if (CM_OBJ(e) && typeof e.id === 'string' && !E.has(e.id)) E.set(e.id, e); });
|
|
8291
|
+
const ids = [...new Set(CM_ARR(view && view.nodeIds).filter(id => N.has(id)))], shown = new Set(ids);
|
|
8292
|
+
const list = CM_ARR(view && view.edges).map(x => (typeof x === 'string' ? E.get(x) : x)).filter(e => CM_OBJ(e) && typeof e.id === 'string');
|
|
8293
|
+
const pair = (a, b) => [a, b].sort().join(CM_STACK), joins = e => shown.has(e.from) && shown.has(e.to);
|
|
8294
|
+
const connectors = list.filter(e => e.type === 'uml.Connector' && joins(e)), carrier = new Map(), on = new Map();
|
|
8295
|
+
connectors.forEach(c => { const k = pair(c.from, c.to); if (!carrier.has(k)) carrier.set(k, c); on.set(c.id, []); });
|
|
8296
|
+
const messages = list.filter(e => e.type === 'uml.Message');
|
|
8297
|
+
messages.forEach(m => { const c = joins(m) && carrier.get(pair(m.from, m.to)); if (c) on.get(c.id).push(m); });
|
|
8298
|
+
return { N, E, ids, shown, connectors, messages, on, carrierOf: (a, b) => carrier.get(pair(a, b)) || null };
|
|
8299
|
+
}
|
|
8300
|
+
|
|
8301
|
+
const cmNameOf = x => String(x && x.label != null ? x.label : x ? x.id : '');
|
|
8302
|
+
const cmPropsOf = x => (CM_OBJ(x && x.props) ? x.props : {});
|
|
8303
|
+
const cmSortOf = m => (CM_SORTS.includes(cmPropsOf(m).sort) ? cmPropsOf(m).sort : 'call'); // any other is drawn as a call (the shared sort rule)
|
|
8304
|
+
const cmStereoOf = n => CM_ARR(cmPropsOf(n).stereotypes).filter(CM_HAS).map(String);
|
|
8305
|
+
// a lifeline's head text: "api : ShopApi", or the name alone
|
|
8306
|
+
const cmHeadText = n => cmNameOf(n) + (CM_HAS(cmPropsOf(n).represents) ? ' : ' + cmPropsOf(n).represents : '');
|
|
8307
|
+
const CM_KEYWORD = { create: '«create»', delete: '«destroy»' };
|
|
8308
|
+
const cmRowText = (m, num) => { const t = [CM_KEYWORD[cmSortOf(m)], m.label].filter(CM_HAS).join(' '); return `${num ?? '?'}${t ? ': ' + t : ''}`; }; // "1.1.3: «create» new(order)"; a message with no text, its number
|
|
8309
|
+
const cmRowW = t => Math.ceil(Math.min(CM_ROW.max, textW(t, CM_ROW.px) + CM_ROW.arrow + 2 * CM_ROW.pad + 2));
|
|
8310
|
+
const cmPlural = (k, w) => `${k} ${k === 1 ? w : w + 's'}`;
|
|
8311
|
+
// numbers in their order: 1 < 1.1 < 1.1.2 < 1.2 < 2
|
|
8312
|
+
const cmByNumber = (a, b) => { const p = String(a).split('.').map(Number), q = String(b).split('.').map(Number);
|
|
8313
|
+
for (let i = 0; i < Math.max(p.length, q.length); i++) { const d = (p[i] ?? -1) - (q[i] ?? -1); if (d) return d; } return 0; };
|
|
8314
|
+
|
|
8315
|
+
/* ---- a message row: its number and text, and an arrow toward the receiver (filled for a call or a delete, open for async
|
|
8316
|
+
and create, dashed and open for a reply). n.dir: right, left, down or up ---- */
|
|
8317
|
+
const CM_ARROW_TURN = { right: 0, down: 90, left: 180, up: 270 };
|
|
8318
|
+
function cmArrowOf(sort, cx, cy, dir) {
|
|
8319
|
+
const head = sort === 'call' || sort === 'delete' ? `<path d="M${r2(cx + 1)} ${r2(cy - 3.5)}L${r2(cx + 6)} ${r2(cy)}L${r2(cx + 1)} ${r2(cy + 3.5)}Z" fill="var(--um-text)"/>`
|
|
8320
|
+
: `<path d="M${r2(cx + 1.5)} ${r2(cy - 3.5)}L${r2(cx + 6)} ${r2(cy)}L${r2(cx + 1.5)} ${r2(cy + 3.5)}" fill="none"/>`;
|
|
8321
|
+
return `<g class="mr-arrow" stroke="var(--um-text)" stroke-width="1.3" stroke-linejoin="round" stroke-linecap="round" transform="rotate(${CM_ARROW_TURN[dir] || 0} ${r2(cx)} ${r2(cy)})">`
|
|
8322
|
+
+ `<path d="M${r2(cx - 6)} ${r2(cy)}H${r2(cx + 5)}"${sort === 'reply' ? ' stroke-dasharray="2.2 1.8"' : ''}/>${head}</g>`;
|
|
8323
|
+
}
|
|
8324
|
+
SHAPE['message-row'] = {
|
|
8325
|
+
draw(n) {
|
|
8326
|
+
const { x, y, w, h } = n, cy = y + h / 2, t = String(n.label ?? '');
|
|
8327
|
+
return `<g class="node message-row" data-id="${esc(n.id)}" data-shape="message-row" data-sort="${esc(n.sort)}" tabindex="-1" role="img" aria-label="${esc(n.aria || 'Message ' + t)}">`
|
|
8328
|
+
+ `<rect x="${r2(x)}" y="${r2(y)}" width="${r2(w)}" height="${r2(h)}" class="mr-back" fill="var(--um-stage-bg)"/>${cmArrowOf(n.sort, x + CM_ROW.pad + 6, cy, n.dir)}`
|
|
8329
|
+
+ `<text x="${r2(x + CM_ROW.pad + CM_ROW.arrow)}" y="${r2(cy + 3.8)}" fill="var(--um-text)" style="font:500 ${CM_ROW.px}px var(--um-sans)">${esc(fit(t, CM_ROW.px, w - CM_ROW.arrow - 2 * CM_ROW.pad))}</text></g>`;
|
|
8330
|
+
},
|
|
8331
|
+
};
|
|
8332
|
+
|
|
8333
|
+
/* ---- the legend: the connector, then each kind of message the view draws ---- */
|
|
8334
|
+
const CM_LEGEND = [['call', 'Call', 'waits for its reply'], ['async', 'Async', 'sent without waiting'], ['reply', 'Reply', 'the answer to a call'],
|
|
8335
|
+
['create', 'Create', '«create»: makes its receiver'], ['delete', 'Delete', '«destroy»: ends its receiver']];
|
|
8336
|
+
const cmLegendItem = (svg, name, says) => `<span class="lgi" title="${esc(name + ' — ' + says)}"><svg class="lgs" viewBox="0 0 28 16" aria-hidden="true">${svg}</svg><b>${esc(name)}</b><span>${esc(says)}</span></span>`;
|
|
8337
|
+
const cmLegendOf = sorts => [cmLegendItem('<path d="M2 8H26" stroke="var(--um-muted)" stroke-width="1.4"/>', 'Connector', 'a link messages are sent along'),
|
|
8338
|
+
...CM_LEGEND.filter(([s]) => sorts.has(s)).map(([s, name, says]) => cmLegendItem(cmArrowOf(s, 14, 8, 'right'), name, says))];
|
|
8339
|
+
|
|
8340
|
+
/* ---- the view laid out: { model, view, memo } ----
|
|
8341
|
+
The lifelines and the stacks are ranked as the map ranks its cards (longest paths, cycles broken depth first, columns
|
|
8342
|
+
ordered by untangle and rows by rowsFor, src/layout/layered.js): the lifelines are ranked first, without the stacks, and
|
|
8343
|
+
then each link runs from its earlier lifeline to its later one, a stack between the two, so a connector's halves leave
|
|
8344
|
+
its stack on opposite sides and its rows' arrows point both ways. A lifeline no connector reaches goes in a row under the rest. A view's
|
|
8345
|
+
pins (`layout`, by lifeline) win, in the same columns and rows, which a drag or Alt + arrows on a lifeline writes; a
|
|
8346
|
+
node a pin would cover moves down. The host's ranking (`pos`) is not used: it has no place for a stack */
|
|
8347
|
+
// each node's column: the longest path to it over the links, cycles broken depth first; fwd, the links kept
|
|
8348
|
+
function cmRank(ids, links) {
|
|
8349
|
+
const outs = new Map(ids.map(id => [id, []])), back = new Set(), st = new Map();
|
|
8350
|
+
links.forEach((l, k) => outs.get(l[0]).push(k));
|
|
8351
|
+
const dfs = u => { st.set(u, 1); outs.get(u).forEach(k => { const v = links[k][1]; if (st.get(v) === 1) back.add(k); else if (!st.has(v)) dfs(v); }); st.set(u, 2); };
|
|
8352
|
+
[...ids.filter(id => !links.some(l => l[1] === id)), ...ids].forEach(id => { if (!st.has(id)) dfs(id); });
|
|
8353
|
+
const fwd = links.filter((l, k) => !back.has(k)), rank = Object.create(null);
|
|
8354
|
+
ids.forEach(id => { rank[id] = 0; });
|
|
8355
|
+
for (let it = 0, more = true; more && it <= ids.length; it++) { more = false; fwd.forEach(([a, b]) => { if (rank[b] < rank[a] + 1) { rank[b] = rank[a] + 1; more = true; } }); }
|
|
8356
|
+
return { rank, fwd };
|
|
8357
|
+
}
|
|
8358
|
+
function cmPlace(ids, links, pins) {
|
|
8359
|
+
const { rank, fwd } = cmRank(ids, links), lined = new Set(links.flatMap(l => l));
|
|
8360
|
+
const seen = Object.create(null); links.forEach(l => l.forEach(id => { seen[id] ??= Object.keys(seen).length; }));
|
|
8361
|
+
const at = Object.create(null), cols = [];
|
|
8362
|
+
ids.filter(id => lined.has(id)).forEach(id => (cols[rank[id]] ||= []).push(id));
|
|
8363
|
+
const mains = cols.filter(Boolean).map(c => c.sort((a, b) => seen[a] - seen[b])), ranks = mains.map(c => rank[c[0]]);
|
|
8364
|
+
untangle(mains, fwd);
|
|
8365
|
+
const row = rowsFor(mains, fwd);
|
|
8366
|
+
mains.forEach((c, k) => c.forEach(id => { at[id] = [ranks[k], row[id]]; }));
|
|
8367
|
+
const loose = ids.filter(id => !lined.has(id)), per = Math.max(2, mains.length), under = Math.max(-1, ...Object.values(at).map(p => p[1])) + 1;
|
|
8368
|
+
loose.forEach((id, k) => { at[id] = [k % per, under + Math.floor(k / per)]; });
|
|
8369
|
+
if (pins.size) { // pinned first, then each other node at its place or the nearest free one below it
|
|
8370
|
+
const taken = [...pins.values()], free = (c, r) => !taken.some(([c2, r2]) => c2 === c && Math.abs(r2 - r) < 1);
|
|
8371
|
+
ids.forEach(id => { if (pins.has(id)) { at[id] = pins.get(id); return; } let [c, r] = at[id]; while (!free(c, r)) r++; at[id] = [c, r]; taken.push([c, r]); });
|
|
8372
|
+
}
|
|
8373
|
+
return at;
|
|
8374
|
+
}
|
|
8375
|
+
function commGeo({ model, view, memo }) {
|
|
8376
|
+
const I = commIndex(model, view), nums = messageNumbers(model, view), L = CM_OBJ(view.layout) ? view.layout : {};
|
|
8377
|
+
const pins = new Map(I.ids.filter(id => Object.hasOwn(L, id) && Array.isArray(L[id]) && L[id].length === 2 && L[id].every(Number.isFinite)).map(id => [id, L[id]]));
|
|
8378
|
+
const nodes = I.ids.map(id => { const n = I.N.get(id), d = { id, label: cmHeadText(n), type: n.type || 'uml.Lifeline', kind: 'file', shape: 'lifeline', stereotypes: cmStereoOf(n),
|
|
8379
|
+
aria: `Lifeline ${cmHeadText(n)}${cmStereoOf(n).length ? ` (${cmStereoOf(n).join(', ')})` : ''}` };
|
|
8380
|
+
return { ...d, ...SHAPE.lifeline.size(d) }; });
|
|
8381
|
+
// each link's way, from the lifelines ranked alone: a stack ranked after both its lifelines leaves both halves on one side,
|
|
8382
|
+
// its arrows the same way and one half back across the picture
|
|
8383
|
+
const { rank } = cmRank(I.ids, I.connectors.filter(c => c.from !== c.to).map(c => { const f = (I.on.get(c.id)[0] || c).from; return [f, f === c.from ? c.to : c.from]; }));
|
|
8384
|
+
const onward = (a, b) => (rank[a] <= rank[b] ? [a, b] : [b, a]);
|
|
8385
|
+
const edges = [], links = [], stacks = new Map(), conAria = c => `Connector ${c.from === c.to ? `from ${cmNameOf(I.N.get(c.from))} to itself` : `between ${cmNameOf(I.N.get(c.from))} and ${cmNameOf(I.N.get(c.to))}`}, ${cmPlural(I.on.get(c.id).length, 'message')}`;
|
|
8386
|
+
I.connectors.forEach(c => {
|
|
8387
|
+
const ms = I.on.get(c.id), plain = { id: c.id, _id: c.id, type: c.type, from: c.from, to: c.to, end: { from: 'none', to: 'none' }, aria: conAria(c) };
|
|
8388
|
+
if (!ms.length) { edges.push(plain); if (c.from !== c.to) links.push(onward(c.from, c.to)); return; }
|
|
8389
|
+
const [s, t] = onward(c.from, c.to), texts = ms.map(m => cmRowText(m, nums.get(m.id))), id = CM_STACK + c.id;
|
|
8390
|
+
const st = { id, label: '', kind: 'file', w: Math.max(...texts.map(cmRowW)), h: ms.length * CM_ROW.h };
|
|
8391
|
+
nodes.push(st); stacks.set(c.id, { st, ms, texts, s, t });
|
|
8392
|
+
links.push([s, id], ...(s === t ? [] : [[id, t]]));
|
|
8393
|
+
edges.push({ ...plain, id: id + CM_STACK + 'a', from: s, to: id }, { ...plain, id: id + CM_STACK + 'b', from: id, to: t });
|
|
8394
|
+
});
|
|
8395
|
+
const at = cmPlace(nodes.map(n => n.id), links, pins);
|
|
8396
|
+
nodes.forEach(n => { [n.col, n.row] = at[n.id]; });
|
|
8397
|
+
// a self connector's loop through its stack, which the router would leave crossing itself: in the same row, straight into
|
|
8398
|
+
// the stack's near side and back over the top into the lifeline's; else from the lifeline's top or bottom into the
|
|
8399
|
+
// stack's near side, and from the stack's side facing the lifeline into the lifeline's far one (sides in the layout's own
|
|
8400
|
+
// axes, which a view laid out down turns)
|
|
8401
|
+
stacks.forEach(({ st, s, t }) => { const [c, r] = at[s], [c2, r2] = at[st.id]; if (s !== t || c === c2) return;
|
|
8402
|
+
const [near, far] = c2 > c ? ['left', 'right'] : ['right', 'left'], [up, down] = r2 < r ? ['top', 'bottom'] : ['bottom', 'top'];
|
|
8403
|
+
const a = edges.find(e => e.to === st.id), b = edges.find(e => e.from === st.id);
|
|
8404
|
+
Object.assign(a, Math.abs(r2 - r) < 1 ? { fromSide: far, toSide: near } : { fromSide: up, toSide: near });
|
|
8405
|
+
Object.assign(b, Math.abs(r2 - r) < 1 ? { fromSide: 'top', toSide: 'top' } : { fromSide: down, toSide: far }); });
|
|
8406
|
+
// no port repair: on the stress fixture it saves a fifth of a layout, which a message edit pays (§10), for 13 crossings, not 11
|
|
8407
|
+
const g = layoutTyped({ nodes, edges, direction: view.direction === 'down' ? 'down' : 'right', gap: 64, rgap: 40, repair: false, reroute: false }, memo); // no rip-up pass: P1 on a shared CI runner
|
|
8408
|
+
return cmFinish(g, I, stacks, nums, view);
|
|
8409
|
+
}
|
|
8410
|
+
|
|
8411
|
+
// a line's two runs crossing (not meeting end to end), axis-aligned
|
|
8412
|
+
const cmCrosses = P => P.slice(1).some((b, i) => P.slice(i + 3).some((d, k) => { const a = P[i], c = P[i + k + 2], h = a[1] === b[1];
|
|
8413
|
+
if (h === (c[1] === d[1])) return false;
|
|
8414
|
+
const [A, B, C, D] = h ? [a, b, c, d] : [c, d, a, b];
|
|
8415
|
+
return C[0] > Math.min(A[0], B[0]) && C[0] < Math.max(A[0], B[0]) && A[1] > Math.min(C[1], D[1]) && A[1] < Math.max(C[1], D[1]); }));
|
|
8416
|
+
// a loop's two ends on one side of its node, each with the first run along it, change places: x on a top or bottom, y on a side
|
|
8417
|
+
const cmSwapEnds = (P, side) => { const v = side === 'T' || side === 'B' ? 0 : 1, n = P.length, Q = P.map(r => [...r]), x = P[0][v], y = P[n - 1][v];
|
|
8418
|
+
if (P[1][v] !== x || P[n - 2][v] !== y) return P;
|
|
8419
|
+
Q[0][v] = Q[1][v] = y; Q[n - 1][v] = Q[n - 2][v] = x; return cmCrosses(Q) ? P : Q; };
|
|
8420
|
+
|
|
8421
|
+
/* The laid-out graph as it is drawn, a new object (layoutTyped's memo keeps the one it returned): each stack replaced by its
|
|
8422
|
+
rows, each connector's halves joined through its stack, geo.order, the slots and the legend */
|
|
8423
|
+
function cmFinish(g, I, stacks, nums, view) {
|
|
8424
|
+
const byId = Object.create(null), order = [...I.ids];
|
|
8425
|
+
g.nodes.forEach(n => { if (n.id[0] !== CM_STACK) byId[n.id] = { ...n }; });
|
|
8426
|
+
const E = new Map(g.edges.map(e => [e.id, e])), edges = [], total = I.messages.filter(m => nums.has(m.id)).length, place = new Map(I.messages.map((m, k) => [m.id, k + 1]));
|
|
8427
|
+
I.connectors.forEach(c => {
|
|
8428
|
+
const S = stacks.get(c.id);
|
|
8429
|
+
if (!S) { const e = E.get(c.id); if (e) edges.push({ ...e, s: byId[e.from], t: byId[e.to] }); return; }
|
|
8430
|
+
const a = E.get(CM_STACK + c.id + CM_STACK + 'a'), b = E.get(CM_STACK + c.id + CM_STACK + 'b'), box = g.byId[S.st.id];
|
|
8431
|
+
if (!a || !b || !box) return;
|
|
8432
|
+
const p = a.pts[a.pts.length - 1], q = b.pts[0], across = a.ts === 'L' || a.ts === 'R';
|
|
8433
|
+
const mid = p[0] === q[0] || p[1] === q[1] ? [] : across ? [[box.x + box.w / 2, p[1]], [box.x + box.w / 2, q[1]]] : [[p[0], box.y + box.h / 2], [q[0], box.y + box.h / 2]];
|
|
8434
|
+
let pts = [...a.pts, ...mid, ...b.pts];
|
|
8435
|
+
if (S.s === S.t && a.fs === b.ts && cmCrosses(pts)) pts = cmSwapEnds(pts, a.fs); // a self connector's loop that crosses itself: its two ports swapped
|
|
8436
|
+
const turn = S.s !== c.from; // drawn from the connector's own from to its to
|
|
8437
|
+
edges.push({ ...a, id: c.id, _id: c.id, from: c.from, to: c.to, s: byId[c.from], t: byId[c.to], pts: turn ? pts.reverse() : pts, p1: turn ? b.p2 : a.p1, p2: turn ? a.p1 : b.p2,
|
|
8438
|
+
fs: turn ? b.ts : a.fs, ts: turn ? a.fs : b.ts, lbl: null, stack: S.ms.map(m => m.id) });
|
|
8439
|
+
// a row's arrow points the way its connector leaves the stack toward the message's receiver
|
|
8440
|
+
const way = (u, v) => { const dx = v[0] - u[0], dy = v[1] - u[1]; return Math.abs(dx) >= Math.abs(dy) ? (dx < 0 ? 'left' : 'right') : dy < 0 ? 'up' : 'down'; };
|
|
8441
|
+
const out = pts => pts.find(r => r[0] !== pts[0][0] || r[1] !== pts[0][1]) || pts[0], fwd = way(q, out(b.pts)), bwd = way(p, out([...a.pts].reverse()));
|
|
8442
|
+
S.ms.forEach((m, k) => { const from = cmNameOf(I.N.get(m.from)), to = cmNameOf(I.N.get(m.to)), num = nums.get(m.id), sort = cmSortOf(m);
|
|
8443
|
+
const row = { id: m.id, type: 'uml.Message', shape: 'message-row', kind: 'file', k: box.k, label: S.texts[k], num, sort, dir: m.from === S.s ? fwd : bwd,
|
|
8444
|
+
x: box.x, y: box.y + k * CM_ROW.h, w: box.w, h: CM_ROW.h, col: box.col, row: box.row + k / S.ms.length,
|
|
8445
|
+
aria: `Message ${place.get(m.id)} of ${total}, ${num}: ${from} to ${to}${CM_HAS(m.label) ? ', ' + m.label : ''}, ${sort}${sort === 'reply' && CM_HAS(cmPropsOf(m).replyTo) && nums.has(cmPropsOf(m).replyTo) ? ' to ' + nums.get(cmPropsOf(m).replyTo) : ''}` };
|
|
8446
|
+
byId[row.id] = row; });
|
|
8447
|
+
});
|
|
8448
|
+
// reading order (S13): lifelines in the layout's order, left to right and top to bottom, then the message rows by number.
|
|
8449
|
+
// The nodes are drawn in it too, so a screen reader that reads the picture in document order reads it so. geo.order, the
|
|
8450
|
+
// key rings' order and so Tab's, has the connectors between the lifelines and the rows: a connector is selected by its ring
|
|
8451
|
+
order.sort((a, b) => byId[a].x - byId[b].x || byId[a].y - byId[b].y);
|
|
8452
|
+
order.push(...I.messages.filter(m => byId[m.id]).map(m => m.id).sort((a, b) => cmByNumber(nums.get(a), nums.get(b))));
|
|
8453
|
+
const heads = order.slice(0, I.ids.length), mid = n => ({ x: n.x + n.w / 2, y: n.y + n.h / 2 });
|
|
8454
|
+
// the slots (src/typed/edit.js): one after each lifeline, its index in nodeIds (N adds there), and one after each row,
|
|
8455
|
+
// named by `after` (L from a message adds after it; a row dropped on a row takes its place)
|
|
8456
|
+
const slots = [...heads.map(id => ({ index: CM_ARR(view.nodeIds).indexOf(id) + 1, after: id, parent: null, lifeline: true, ...mid(byId[id]) })),
|
|
8457
|
+
...order.slice(I.ids.length).map(id => ({ after: id, parent: null, ...mid(byId[id]) }))];
|
|
8458
|
+
return { ...g, nodes: order.map(id => byId[id]), edges, byId, order: [...heads, ...edges.map(e => e.id), ...order.slice(I.ids.length)], slots,
|
|
8459
|
+
legend: cmLegendOf(new Set(I.messages.filter(m => byId[m.id]).map(cmSortOf))) };
|
|
8460
|
+
}
|
|
8461
|
+
|
|
8462
|
+
/* ---- validators: message-link, and the rules both interaction types share (src/uml/interaction.js). Each problem:
|
|
8463
|
+
{ level, rule, path, message, ids } ---- */
|
|
8464
|
+
const CM_RULES = { 'message-link': 'a connector of the view joins a message\'s two lifelines; a self-message needs a self connector', ...INTERACTION_RULES };
|
|
8465
|
+
function commProblems(model, view) {
|
|
8466
|
+
if (!CM_OBJ(view)) return [];
|
|
8467
|
+
const I = commIndex(model, view), w = interactionWalk(model, view), out = [];
|
|
8468
|
+
w.occ.forEach(o => { if (!o.message || I.carrierOf(o.from, o.to) && I.shown.has(o.from) && I.shown.has(o.to)) return;
|
|
8469
|
+
const gone = [o.from, o.to].filter(id => !I.shown.has(id)), nm = `${o.id}${CM_HAS(o.el.label) ? ` ("${o.el.label}")` : ''}`;
|
|
8470
|
+
out.push({ level: 'error', rule: 'message-link', path: o.path, ids: [o.id, o.from, o.to], message: `message-link: ${nm} runs from ${o.from} to ${o.to}, `
|
|
8471
|
+
+ (gone.length ? `and ${gone.join(' and ')} ${gone.length > 1 ? 'are' : 'is'} not a lifeline of view ${view.id}` : `and no connector of view ${view.id} joins them`) }); });
|
|
8472
|
+
return [...out, ...interactionProblems(model, view)];
|
|
8473
|
+
}
|
|
8474
|
+
|
|
8475
|
+
/* ---- describe(): the view in words, each lifeline with its links, then the messages by number ---- */
|
|
8476
|
+
function commDescribe(model, viewId) {
|
|
8477
|
+
const v = CM_ARR(model && model.views).find(x => CM_OBJ(x) && x.id === viewId); if (!v) throw new Error(`umlimator: no view ${JSON.stringify(viewId)}`);
|
|
8478
|
+
const I = commIndex(model, v), nums = messageNumbers(model, v), nm = id => cmNameOf(I.N.get(id) || { id });
|
|
8479
|
+
const drawn = I.messages.filter(m => I.carrierOf(m.from, m.to)).sort((a, b) => cmByNumber(nums.get(a.id), nums.get(b.id)));
|
|
8480
|
+
const out = [`${v.title || v.id}: a communication diagram of ${cmPlural(I.ids.length, 'lifeline')}, ${cmPlural(I.connectors.length, 'connector')} and ${cmPlural(drawn.length, 'message')}.`];
|
|
8481
|
+
I.ids.forEach(id => { const n = I.N.get(id), st = cmStereoOf(n), links = [...new Set(I.connectors.filter(c => c.from === id || c.to === id).map(c => (c.from === c.to ? 'itself' : nm(c.from === id ? c.to : c.from))))];
|
|
8482
|
+
out.push(`Lifeline ${cmHeadText(n)}${st.length ? ` (${st.join(', ')})` : ''}${links.length ? '; linked to ' + links.join(', ') : '; linked to nothing'}.`); });
|
|
8483
|
+
drawn.forEach(m => { const s = cmSortOf(m), r = cmPropsOf(m).replyTo;
|
|
8484
|
+
out.push(`${nums.get(m.id)}: ${nm(m.from)} to ${nm(m.to)}${CM_HAS(m.label) ? ', ' + m.label : ''}, ${s}${s === 'reply' && nums.has(r) ? ' to ' + nums.get(r) : ''}.`); });
|
|
8485
|
+
return out.join('\n');
|
|
8486
|
+
}
|
|
8487
|
+
|
|
8488
|
+
/* ---- editing (notes/stage-3.md §5.1, §6.4): each hook returns commands, null to leave the gesture to the default, or
|
|
8489
|
+
{ error } for the hint line, and never a transaction that breaks a rule (S5) ---- */
|
|
8490
|
+
// an id no element of the model has: the base, else the base and the first number free ('m10', 'api-payments-2')
|
|
8491
|
+
function cmFreshId(model, base) {
|
|
8492
|
+
const used = new Set(); JSON.stringify(model, (k, v) => { if (k === 'id' && typeof v === 'string') used.add(v); return v; });
|
|
8493
|
+
const b = String(base).replace(/->/g, '-') || 'el', next = k => (b === 'm' ? b + k : k === 1 ? b : `${b}-${k}`); // ids never hold "->" (M2)
|
|
8494
|
+
let k = 1; while (used.has(next(k))) k++;
|
|
8495
|
+
return next(k);
|
|
8496
|
+
}
|
|
8497
|
+
// where `at` puts a message in view.edges: { index }, { after: id }, or none or a lifeline's slot (the end); never among the
|
|
8498
|
+
// connectors
|
|
8499
|
+
function cmMessageIndex(view, at) {
|
|
8500
|
+
const list = CM_ARR(view.edges), idOf = x => (typeof x === 'string' ? x : CM_OBJ(x) ? x.id : null);
|
|
8501
|
+
let i = list.length;
|
|
8502
|
+
if (CM_OBJ(at) && at.lifeline) return i;
|
|
8503
|
+
if (CM_OBJ(at) && Number.isInteger(at.index)) i = at.index;
|
|
8504
|
+
else if (CM_OBJ(at) && at.after != null && list.some(x => idOf(x) === at.after)) i = list.findIndex(x => idOf(x) === at.after) + 1;
|
|
8505
|
+
return Math.max(0, Math.min(list.length, i));
|
|
8506
|
+
}
|
|
8507
|
+
|
|
8508
|
+
// the commands, or { error } naming the first rule they would break that the view does not break already (S5); all: in every
|
|
8509
|
+
// communication view, for commands that delete from the model
|
|
8510
|
+
function cmGuard(model, view, commands, all) {
|
|
8511
|
+
const key = p => p.view + ' ' + p.rule + ' ' + p.ids.join(' ');
|
|
8512
|
+
const errs = m => CM_ARR(m.views).filter(v => CM_OBJ(v) && (v.id === view.id || all && v.diagram === 'uml.communication'))
|
|
8513
|
+
.flatMap(v => commProblems(m, v).filter(p => p.level === 'error').map(p => ({ ...p, view: v.id })));
|
|
8514
|
+
let now; try { now = apply(model, commands); } catch (e) { return { error: String(e.message) }; }
|
|
8515
|
+
const was = new Set(errs(model).map(key)), bad = errs(now).find(p => !was.has(key(p)));
|
|
8516
|
+
return bad ? { error: bad.message } : commands;
|
|
8517
|
+
}
|
|
8518
|
+
|
|
8519
|
+
/* connect(model, view, from, to, at, line): line, the picked entry of `lines`. A connector is added among the connectors;
|
|
8520
|
+
a message at `at`, with a connector in the same transaction when none joins its lifelines, and a reply answers the
|
|
8521
|
+
latest unanswered call from its receiver to its sender */
|
|
8522
|
+
function commConnect(model, view, from, to, at, line) {
|
|
8523
|
+
if (!CM_OBJ(view)) return null;
|
|
8524
|
+
const I = commIndex(model, view), type = CM_OBJ(line) && line.type === 'uml.Message' ? 'uml.Message' : 'uml.Connector';
|
|
8525
|
+
if (!I.shown.has(from) || !I.shown.has(to)) return { error: 'Connect two lifelines of this view' };
|
|
8526
|
+
const cons = CM_ARR(view.edges).filter(x => { const e = typeof x === 'string' ? I.E.get(x) : x; return CM_OBJ(e) && e.type === 'uml.Connector'; }).length; // connectors first, then messages
|
|
8527
|
+
const link = id => ({ op: 'add', element: { id, type: 'uml.Connector', from, to }, view: view.id, index: cons });
|
|
8528
|
+
if (type === 'uml.Connector') return I.carrierOf(from, to) ? { error: `${from === to ? `${from} is linked to itself` : `${from} and ${to} are linked`} already` } : [link(cmFreshId(model, from === to ? `${from}-self` : `${from}-${to}`))];
|
|
8529
|
+
const props = { ...(CM_OBJ(line.props) ? structuredClone(line.props) : {}) }, i = Math.max(cons, cmMessageIndex(view, at));
|
|
8530
|
+
if (props.sort === 'reply') { const c = replyCallFor(model, view, from, to, { index: i }); if (c) props.replyTo = c; }
|
|
8531
|
+
const id = cmFreshId(model, 'm'), out = [{ op: 'add', element: { id, type: 'uml.Message', from, to, ...(Object.keys(props).length ? { props } : {}) }, view: view.id, index: i }];
|
|
8532
|
+
if (!I.carrierOf(from, to)) out.push(link(cmFreshId(model, from === to ? `${from}-self` : `${from}-${to}`))); // added after, at the connectors' end: the message moves one down with it
|
|
8533
|
+
return cmGuard(model, view, out); // a message before its lifeline's create, or after its delete
|
|
8534
|
+
}
|
|
8535
|
+
|
|
8536
|
+
/* moveTo(model, view, id, at): a message one place earlier or later in time ({ by: [dx, dy] }: Alt + Up/Down), or onto
|
|
8537
|
+
another row ({ id }, or a row's slot: the row it was dropped on, whose place it takes; or { index } in view.edges; a
|
|
8538
|
+
lifeline's slot, nothing); { error } when the new order breaks a rule. On a lifeline: null, and the gesture sets a pin as on a class view */
|
|
8539
|
+
function commMoveTo(model, view, id, at) {
|
|
8540
|
+
if (!CM_OBJ(view)) return null;
|
|
8541
|
+
const I = commIndex(model, view), list = CM_ARR(view.edges), idOf = x => (typeof x === 'string' ? x : CM_OBJ(x) ? x.id : null);
|
|
8542
|
+
const ms = list.map((x, i) => [idOf(x), i]).filter(([x]) => I.messages.some(m => m.id === x)), k = ms.findIndex(([x]) => x === id);
|
|
8543
|
+
if (k < 0) return null;
|
|
8544
|
+
let to = k;
|
|
8545
|
+
if (CM_OBJ(at) && at.lifeline) return [];
|
|
8546
|
+
if (CM_OBJ(at) && at.after != null && at.id == null) at = { id: at.after }; // a row's slot
|
|
8547
|
+
if (CM_OBJ(at) && Array.isArray(at.by)) to = k + Math.sign(+at.by[1] || 0);
|
|
8548
|
+
else if (CM_OBJ(at) && at.id != null) to = ms.findIndex(([x]) => x === at.id);
|
|
8549
|
+
else if (CM_OBJ(at) && Number.isInteger(at.index)) { to = ms.filter(([, i]) => i < at.index).length; if (to > k) to--; }
|
|
8550
|
+
if (to < 0 || to >= ms.length || to === k) return [];
|
|
8551
|
+
return cmGuard(model, view, [{ op: 'move', id, view: view.id, index: ms[to][1] }]); // a reply before its call, a create or delete out of its place
|
|
8552
|
+
}
|
|
8553
|
+
|
|
8554
|
+
/* add(model, view, entry, label, at): the palette's lifeline, at `at.index` in nodeIds (a lifeline's slot: after the selected
|
|
8555
|
+
lifeline), else last (no slot, or a row's, which has no index) */
|
|
8556
|
+
function commAdd(model, view, entry, label, at) {
|
|
8557
|
+
if (!CM_OBJ(view) || !CM_OBJ(entry) || entry.type !== 'uml.Lifeline') return null;
|
|
8558
|
+
const id = cmFreshId(model, String(label ?? '').trim().toLowerCase().replace(/[^\p{L}\p{N}_$-]+/gu, '-').replace(/^-+|-+$/g, '') || 'lifeline');
|
|
8559
|
+
return [{ op: 'add', element: { id, type: 'uml.Lifeline', label: String(label ?? '') }, view: view.id, ...(CM_OBJ(at) && Number.isInteger(at.index) ? { index: at.index } : {}) }];
|
|
8560
|
+
}
|
|
8561
|
+
|
|
8562
|
+
/* remove(model, view, ids, fromModel): Delete hides from this view, Shift + Delete deletes from the model. A connector takes
|
|
8563
|
+
the messages it carries with it, deleted from every communication view that has no other connector for them; a hidden
|
|
8564
|
+
call takes its replies in this view (a deleted one does by refs). A lifeline takes its connectors and messages (apply()
|
|
8565
|
+
does). { error } when what is left breaks a rule */
|
|
8566
|
+
function commRemove(model, view, ids, fromModel) {
|
|
8567
|
+
if (!CM_OBJ(view)) return null;
|
|
8568
|
+
const I = commIndex(model, view), out = [], seen = new Set(), idOf = x => (typeof x === 'string' ? x : CM_OBJ(x) ? x.id : null);
|
|
8569
|
+
const views = fromModel ? CM_ARR(model && model.views).filter(v => CM_OBJ(v) && v.diagram === 'uml.communication') : [view];
|
|
8570
|
+
const go = id => { if (seen.has(id)) return; seen.add(id);
|
|
8571
|
+
out.push(fromModel ? { op: 'delete', id } : { op: 'hide', id, view: view.id });
|
|
8572
|
+
if (I.E.has(id) && I.E.get(id).type === 'uml.Connector') views.forEach(v => { const on = commIndex(model, v).on.get(id); if (!on) return;
|
|
8573
|
+
const left = commIndex(model, { ...v, edges: CM_ARR(v.edges).filter(x => !seen.has(idOf(x))) });
|
|
8574
|
+
on.forEach(m => { if (v === view && !fromModel || !left.carrierOf(m.from, m.to)) go(m.id); }); });
|
|
8575
|
+
if (!fromModel) I.messages.filter(m => cmPropsOf(m).replyTo === id).forEach(m => go(m.id)); };
|
|
8576
|
+
CM_ARR(ids).forEach(id => { if (I.shown.has(id) || I.E.has(id)) go(id); });
|
|
8577
|
+
return fromModel ? cmGuard(model, view, out, true) : out;
|
|
8578
|
+
}
|
|
8579
|
+
|
|
8580
|
+
/* ---- the type ---- */
|
|
8581
|
+
const CM_SORT = { key: 'sort', label: 'Sort', kind: 'enum', options: CM_SORTS };
|
|
8582
|
+
const COMM_TYPE = registerDiagramType('uml.communication', {
|
|
8583
|
+
title: 'communication diagram',
|
|
8584
|
+
elements: [{ type: 'uml.Lifeline', label: 'Lifeline' }],
|
|
8585
|
+
lines: [{ type: 'uml.Connector', label: 'Connector' }, ...[['call', 'Call'], ['async', 'Async'], ['reply', 'Reply'], ['create', 'Create'], ['delete', 'Delete']]
|
|
8586
|
+
.map(([sort, label]) => ({ type: 'uml.Message', label, props: { sort } }))],
|
|
8587
|
+
props: {
|
|
8588
|
+
'uml.Lifeline': [{ key: 'represents', label: 'Represents', kind: 'text' }, { key: 'stereotypes', label: 'Stereotypes', kind: 'list' }],
|
|
8589
|
+
'uml.Message': [CM_SORT, { key: 'replyTo', label: 'Reply to', kind: 'text' }],
|
|
8590
|
+
'uml.Connector': [],
|
|
8591
|
+
},
|
|
8592
|
+
refs: { 'uml.Message': { 'props.replyTo': 'cascade' } }, // a reply goes with its call
|
|
8593
|
+
layout: Object.assign(commGeo, { rank: false }), // commGeo ranks its own lifelines: the host passes only the pins
|
|
8594
|
+
// the host ranks a typed view's lifelines before commGeo runs (context.js autoPos), and commGeo ranks its own, with the
|
|
8595
|
+
// stacks. Connectors turned round make the messages along them close cycles, which that ranking passes over: a third of
|
|
8596
|
+
// its time on the stress fixture; layout.rank false (above) now skips that ranking
|
|
8597
|
+
rankFlip: e => e.type === 'uml.Connector',
|
|
8598
|
+
validate: commProblems,
|
|
8599
|
+
rules: CM_RULES,
|
|
8600
|
+
describe: commDescribe,
|
|
8601
|
+
edits: { connect: commConnect, moveTo: commMoveTo, add: commAdd, remove: commRemove },
|
|
8602
|
+
});
|
|
8603
|
+
/* ---------- helpers: a numbered view, a sequence view or a trace as a UML diagram (work package 3.7, notes/stage-3.md §7) ----------
|
|
8604
|
+
C7 keeps `render: "sequence"` a numbered view; these turn one into a UML diagram. Each returns { model, report }, the
|
|
8605
|
+
model a version-2 copy (the one passed in is not changed) and the report a reader's: [{ id, status, message }], status
|
|
8606
|
+
`skipped` or `approximated` (R1). Ids are derived (R3), never a source line's: a lifeline is <view>.<card>, a message
|
|
8607
|
+
<view>.m<n> by its place in the source's order, a connector <view>.<a>-<b>, each made unique by ~2, ~3 if it would repeat one; none contains "->" (M2). The new view
|
|
8608
|
+
(options.view, default <source>.sequence or <source>.communication; options.title) takes the place of the view with its
|
|
8609
|
+
id, with that view's lifelines and model messages and connectors that no other view lists, else comes last.
|
|
8610
|
+
- sequenceFromView(model, viewId, options): a numbered view as a uml.sequence view; its lines become calls in the
|
|
8611
|
+
order the core numbers them, and a hand-written `step` other than that number is reported.
|
|
8612
|
+
- communicationFromView(model, viewId, options): a numbered view or a uml.sequence view as a uml.communication view.
|
|
8613
|
+
options.operands { <fragment>: <operand> } picks each alt's operand (default its first); opt, loop and break are
|
|
8614
|
+
taken once, par's operands one after another (reported); interaction uses are left out (reported).
|
|
8615
|
+
- sequenceFromSpans(model, spans, options): a trace through sequenceFromTrace()'s numbered view (options.view, default
|
|
8616
|
+
"trace", replaced by the sequence view), so the bars follow rule 3; calls that overlapped are said in the caption.
|
|
8617
|
+
Internal (§7 (a)): in lib/umlimator-all.js with no export; the CLI and tests import this file. */
|
|
8618
|
+
|
|
8619
|
+
const CV_ARR = x => (Array.isArray(x) ? x : []), CV_OBJ = x => x !== null && typeof x === 'object' && !Array.isArray(x);
|
|
8620
|
+
const cvId = s => String(s).replaceAll('->', '-'); // M2: a derived id never names a line by position
|
|
8621
|
+
// ids made unique among those one helper derives (and `used`): <id>, else <id>~2, <id>~3, ... (card ids may hold '-' or be m<n>)
|
|
8622
|
+
const cvFresh = (used = []) => { const seen = new Set(used); return want => { let s = want; for (let k = 2; seen.has(s); k++) s = `${want}~${k}`; seen.add(s); return s; }; };
|
|
8623
|
+
|
|
8624
|
+
function cvSource(model, viewId) {
|
|
8625
|
+
const D = structuredClone(migrate(model) || {}), v = CV_ARR(D.views).find(x => CV_OBJ(x) && x.id === viewId);
|
|
8626
|
+
if (!v) throw new Error(`convert: the model has no view ${JSON.stringify(viewId)}`);
|
|
8627
|
+
return { D, v };
|
|
8628
|
+
}
|
|
8629
|
+
|
|
8630
|
+
// a numbered view's lines as calls of a sequence view `id`: { nodes (new lifelines), view, report }
|
|
8631
|
+
function cvNumbered(model, v, id, title) {
|
|
8632
|
+
if (v.render !== 'sequence') throw new Error(`convert: view ${JSON.stringify(v.id)} is not a numbered view (render: "sequence")`);
|
|
8633
|
+
const ctx = mapContext(model), lines = ctx.viewEdges(ctx.V[v.id]), report = [], fresh = cvFresh();
|
|
8634
|
+
const cards = [...CV_ARR(v.nodeIds).filter(c => ctx.N[c]), ...lines.flatMap(e => [e.from, e.to])].filter((c, k, a) => a.indexOf(c) === k);
|
|
8635
|
+
const lifeOf = new Map(cards.map(c => [c, fresh(`${id}.${cvId(c)}`)])), life = c => lifeOf.get(c);
|
|
8636
|
+
const boxes = new Map(CV_ARR(v.containers).filter(CV_OBJ).map(b => [b.id, b]));
|
|
8637
|
+
const nodes = cards.map(c => ({ id: life(c), type: 'uml.Lifeline', label: String((ctx.N[c] || boxes.get(c) || {}).label ?? c) }));
|
|
8638
|
+
const edges = lines.map((e, i) => { const m = fresh(`${id}.m${i + 1}`), text = [e.label, e.sublabel].filter(x => x != null && x !== '').map(String);
|
|
8639
|
+
if (e.step != null && String(e.step) !== String(i + 1)) report.push({ id: m, status: 'approximated', message: `line ${e._id} has step ${JSON.stringify(e.step)}; it is step ${i + 1} in the view's order` });
|
|
8640
|
+
return { id: m, type: 'uml.Message', from: life(e.from), to: life(e.to), ...(text.length ? { label: text.length > 1 ? `${text[0]} (${text[1]})` : text[0] } : {}), props: { sort: 'call' } }; });
|
|
8641
|
+
return { nodes, report, view: { id, diagram: 'uml.sequence', title: title || v.title || v.id, ...(v.caption ? { caption: v.caption } : {}),
|
|
8642
|
+
parent: v.parent ?? null, nodeIds: nodes.map(n => n.id), edges } };
|
|
8643
|
+
}
|
|
8644
|
+
|
|
8645
|
+
// a sequence view as a communication view `id`: { edges (model edges: connectors, then messages), view, report }
|
|
8646
|
+
function cvCommunication(model, v, id, options) {
|
|
8647
|
+
const w = interactionWalk(model, v), report = [], picks = CV_OBJ(options.operands) ? options.operands : {}, taken = new Set(), chosen = [];
|
|
8648
|
+
CV_ARR(v.fragments).filter(CV_OBJ).forEach(f => { const ops = CV_ARR(f.members).filter(CV_OBJ), op = CV_OBJ(f.props) ? f.props.operator : undefined;
|
|
8649
|
+
if (op !== 'alt') { ops.forEach(o => taken.add(o.id)); if (op === 'par') report.push({ id: f.id, status: 'approximated', message: `par ${f.id}'s operands are taken one after another` }); return; }
|
|
8650
|
+
const want = Object.hasOwn(picks, f.id) ? picks[f.id] : undefined; let pick = ops.find(o => o.id === want);
|
|
8651
|
+
if (want != null && !pick) report.push({ id: f.id, status: 'approximated', message: `alt ${f.id} has no operand ${JSON.stringify(want)}: its first is taken` });
|
|
8652
|
+
pick = pick || ops[0]; if (pick) { taken.add(pick.id); if (ops.length > 1 && pick.label != null && pick.label !== '') chosen.push([f, String(pick.label)]); } });
|
|
8653
|
+
const inside = o => { for (let p = o.parent, seen = new Set(); p != null; p = w.ops.has(p) ? w.ops.get(p).fragment.parent : null) { if (!taken.has(p) || seen.has(p)) return false; seen.add(p); } return true; };
|
|
8654
|
+
const kept = w.occ.filter(o => { if (o.message) return inside(o);
|
|
8655
|
+
if (inside(o)) report.push({ id: o.id, status: 'skipped', message: `interaction use ${o.id} is left out` }); return false; });
|
|
8656
|
+
const fresh = cvFresh(CV_ARR(v.nodeIds)), ids = new Map(kept.map((o, k) => [o.id, fresh(`${id}.m${k + 1}`)])), links = new Map();
|
|
8657
|
+
const end = x => cvId(String(x).startsWith(id + '.') ? String(x).slice(id.length + 1) : x); // a lifeline this helper named: by its card
|
|
8658
|
+
const messages = kept.map(o => { const { parent, ...el } = o.el, props = { ...(CV_OBJ(el.props) ? el.props : {}) };
|
|
8659
|
+
if (props.replyTo != null) { if (ids.has(props.replyTo)) props.replyTo = ids.get(props.replyTo); else { delete props.replyTo;
|
|
8660
|
+
report.push({ id: ids.get(o.id), status: 'approximated', message: `${o.id} answers ${o.replyTo}, which is not taken: it names no call` }); } }
|
|
8661
|
+
const k = [o.from, o.to].sort().join('\n'); if (!links.has(k)) links.set(k, { id: fresh(`${id}.${end(o.from)}-${end(o.to)}`), type: 'uml.Connector', from: o.from, to: o.to });
|
|
8662
|
+
return { ...el, id: ids.get(o.id), props }; });
|
|
8663
|
+
const connectors = [...links.values()], title = options.title || [v.title || v.id, chosen.filter(([f]) => inside(f)).map(c => c[1]).join(', ')].filter(Boolean).join(': ');
|
|
8664
|
+
return { report, edges: [...connectors, ...messages], view: { id, diagram: 'uml.communication', title, ...(v.caption ? { caption: v.caption } : {}),
|
|
8665
|
+
parent: v.parent ?? null, nodeIds: [...CV_ARR(v.nodeIds)], edges: [...connectors, ...messages].map(e => e.id) } };
|
|
8666
|
+
}
|
|
8667
|
+
|
|
8668
|
+
// the model with `view` in place of the view with its id (and what only that one listed), else last; schema version 2
|
|
8669
|
+
function cvPlace(D, view, nodes = [], edges = []) {
|
|
8670
|
+
const views = CV_ARR(D.views), at = views.findIndex(x => CV_OBJ(x) && x.id === view.id), old = at < 0 ? null : views[at];
|
|
8671
|
+
const rest = views.filter((x, k) => k !== at).concat(view), lists = (key, id) => rest.some(x => CV_OBJ(x) && CV_ARR(x[key]).includes(id));
|
|
8672
|
+
const goneN = new Set(old ? CV_ARR(old.nodeIds).filter(n => !lists('nodeIds', n)) : []), goneE = new Set(old ? CV_ARR(old.edges).filter(e => typeof e === 'string' && !lists('edges', e)) : []);
|
|
8673
|
+
const newN = new Set(nodes.map(n => n.id)), newE = new Set(edges.map(e => e.id));
|
|
8674
|
+
D.nodes = [...CV_ARR(D.nodes).filter(n => !(CV_OBJ(n) && (newN.has(n.id) || (goneN.has(n.id) && n.type === 'uml.Lifeline')))), ...nodes];
|
|
8675
|
+
D.edges = [...CV_ARR(D.edges).filter(e => !(CV_OBJ(e) && (newE.has(e.id) || (goneE.has(e.id) && ['uml.Message', 'uml.Connector'].includes(e.type))))), ...edges];
|
|
8676
|
+
D.views = at < 0 ? [...views, view] : views.map((x, k) => (k === at ? view : x));
|
|
8677
|
+
D.schemaVersion = 2;
|
|
8678
|
+
return D;
|
|
8679
|
+
}
|
|
8680
|
+
|
|
8681
|
+
function sequenceFromView(model, viewId, options = {}) {
|
|
8682
|
+
const { D, v } = cvSource(model, viewId), r = cvNumbered(D, v, String(options.view ?? `${viewId}.sequence`), options.title);
|
|
8683
|
+
return { model: cvPlace(D, r.view, r.nodes), report: r.report };
|
|
8684
|
+
}
|
|
8685
|
+
|
|
8686
|
+
function communicationFromView(model, viewId, options = {}) {
|
|
8687
|
+
const { D, v } = cvSource(model, viewId), id = String(options.view ?? `${viewId}.communication`);
|
|
8688
|
+
if (v.diagram === 'uml.sequence') { const r = cvCommunication(D, v, id, options); return { model: cvPlace(D, r.view, [], r.edges), report: r.report }; }
|
|
8689
|
+
const s = cvNumbered(D, v, id, options.title), r = cvCommunication(D, s.view, id, { title: s.view.title });
|
|
8690
|
+
return { model: cvPlace(D, r.view, s.nodes, r.edges), report: [...s.report, ...r.report] };
|
|
8691
|
+
}
|
|
8692
|
+
|
|
8693
|
+
// how many of a trace's calls between cards overlapped one that is neither inside nor around it (as sequenceFromTrace reads them)
|
|
8694
|
+
function cvOverlaps(model, spans, match) {
|
|
8695
|
+
const cards = [...CV_ARR(model && model.nodes), ...CV_ARR(model && model.views).flatMap(v => CV_ARR(v && v.nodes))].filter(n => n && n.id != null);
|
|
8696
|
+
const list = [].concat(spans || []).filter(s => s && Number.isFinite(+s.start) && Number.isFinite(+s.end)), span = new Map(list.map(s => [String(s.id), s]));
|
|
8697
|
+
const cardOf = cardMatcher(cards, match), up = s => (s.parent != null ? span.get(String(s.parent)) : null);
|
|
8698
|
+
const calls = list.filter(s => { const to = cardOf(s.service), p = up(s), from = p && cardOf(p.service); return to && from && from !== to; });
|
|
8699
|
+
const under = (s, a) => { for (let p = s, seen = new Set(); p && !seen.has(p); seen.add(p), p = up(p)) if (p === a) return true; return false; };
|
|
8700
|
+
return calls.filter(a => calls.some(b => a !== b && +a.start < +b.end && +b.start < +a.end && !under(a, b) && !under(b, a))).length;
|
|
8701
|
+
}
|
|
8702
|
+
|
|
8703
|
+
function sequenceFromSpans(model, spans, options = {}) {
|
|
8704
|
+
const id = String(options.view ?? 'trace'), m = sequenceFromTrace(migrate(model), spans, { ...options, view: id }), r = sequenceFromView(m, id, { ...options, view: id });
|
|
8705
|
+
const n = cvOverlaps(model, spans, options.match), v = r.model.views.find(x => x.id === id);
|
|
8706
|
+
if (n) v.caption = `${v.caption ? v.caption + ' ' : ''}${n} calls overlapped in the trace and are drawn one after another.`;
|
|
8707
|
+
return r;
|
|
8708
|
+
}
|
|
8709
|
+
/* ---------- Mermaid sequenceDiagram: a reader and a writer (notes/stage-3.md §8, S10; risks.md R1 to R4, R7) ----------
|
|
8710
|
+
Written against Mermaid 12.3.0's sequence diagram page (docs/syntax/sequenceDiagram.md, read 12 Nov 2026), as the class
|
|
8711
|
+
reader is against its class page. Internal: the CLI (`umlimator.py import`) imports this file through Node; a host
|
|
8712
|
+
gets it once the registry is public. Its helpers are its own (ms...): mermaid-class.js exports only its two functions,
|
|
8713
|
+
and both files share one scope in lib/umlimator-all.js.
|
|
8714
|
+
|
|
8715
|
+
readMermaidSequence(text) -> { model, report }: a version-2 model with one `uml.sequence` view, in model.md row 11's
|
|
8716
|
+
vocabulary, and a report of what was not read as written, each item { line, status, message }, status `skipped` or
|
|
8717
|
+
`approximated` (R1). The reader is hand-written and linear, and refuses text over MS_LIMIT characters (R2). Ids come
|
|
8718
|
+
from the source's names (R3): a lifeline's is its name; a message is <from>-<to>, then -2, -3 ... for each pair, so
|
|
8719
|
+
inserting one renames only the later messages of its own pair; a fragment <operator>-<n>, an operand <fragment>.<k>.
|
|
8720
|
+
`%% id: <id>` above a statement gives that element its id instead (`%% operand-id:` the first operand of a fragment's
|
|
8721
|
+
line); `%% reply-to: <id>` above a reply names its call, when that is not the one found below; `%% represents: <type>`
|
|
8722
|
+
above a participant is its `represents`. It never throws.
|
|
8723
|
+
|
|
8724
|
+
writeMermaidSequence(model, viewId?) -> text: one sequence view as Mermaid. What Mermaid cannot carry is kept in its
|
|
8725
|
+
comments, as the class writer does: an id the reader would not derive as `%% id:`, a reply's call it would not find as
|
|
8726
|
+
`%% reply-to:`, a lifeline's `represents` as `%% represents:` (and in its alias, `as <label> : <type>`, for Mermaid to
|
|
8727
|
+
show); an interaction use as `%% use: <id> refersTo=<id>` above `Note over <first>,<last>: ref <label>`; a space at
|
|
8728
|
+
either end of a text as an entity (#32;), which Mermaid shows and the reader's trim keeps; an element the subset leaves out as
|
|
8729
|
+
`%% not written: <id> (<type>)`, and a property Mermaid has no place for as `%% not written: <id>: <what>`.
|
|
8730
|
+
|
|
8731
|
+
How UML maps onto Mermaid's text, both ways:
|
|
8732
|
+
- `participant A`, `actor A` (stereotype actor), `as <label>`: the alias is the label, as Mermaid shows it; under a
|
|
8733
|
+
`%% represents:` comment it is `<label> : <type>`, split at its first colon (an encoded one, #58;, stays in the
|
|
8734
|
+
label). `@{ "type": ... }` is read as a stereotype, approximated;
|
|
8735
|
+
- `->>` call, `-)` async, `-->>` reply, its `replyTo` the latest unanswered call from its receiver to its sender (a
|
|
8736
|
+
call answered only in another operand of an alt is unanswered); `create participant B` makes the next message to
|
|
8737
|
+
B a create, `destroy B` the next message to B a delete. Other arrows are read as the nearest, approximated;
|
|
8738
|
+
- `+`, `-`, `activate`, `deactivate`: activations are derived, so nothing is read; reported where they disagree;
|
|
8739
|
+
- `alt`/`else`, `opt`, `loop`, `par`/`and`, `break` ... `end` are fragments and operands (`par_over` is read as `par`, reported), the text their guard (a bare
|
|
8740
|
+
`else` is the guard else). `critical`/`option` and `rect` are read as their content; `box` as its participants;
|
|
8741
|
+
- `;` ends a statement as a line does (an entity such as #59; does not); `<br>` is a line break in text. */
|
|
8742
|
+
const MS_LIMIT = 1 << 20; // characters; a sequence diagram a person writes is a few kilobytes
|
|
8743
|
+
const MS_VIEW = 'sequence-diagram'; // the view's id when the source does not name it
|
|
8744
|
+
const MS_OPS = ['alt', 'opt', 'loop', 'par', 'break'];
|
|
8745
|
+
// arrows, a longer one before its prefix: [text, sort, what is not drawn]
|
|
8746
|
+
const MS_ARROWS = [['<<-->>', 'reply', 'its arrowhead at the sender is not drawn'], ['<<->>', 'call', 'its arrowhead at the sender is not drawn'],
|
|
8747
|
+
['-->>', 'reply'], ['->>', 'call'], ['--)', 'async', 'its dotted line is drawn solid'], ['-)', 'async'],
|
|
8748
|
+
['--x', 'async', 'its dotted line and its cross are not drawn'], ['-x', 'async', 'its cross is not drawn'],
|
|
8749
|
+
['-->', 'reply', 'it has no arrowhead, and is drawn with one'], ['->', 'call', 'it has no arrowhead, and is drawn with one']];
|
|
8750
|
+
// Mermaid 12.3.0's lexer keywords (sequenceDiagram.jison; case-insensitive, whole words): a lifeline's name the writer gives never starts with one
|
|
8751
|
+
const MS_KEYWORDS = new Set(['participant', 'actor', 'create', 'destroy', 'activate', 'deactivate', 'note', 'alt', 'else', 'opt', 'loop', 'par', 'par_over', 'and',
|
|
8752
|
+
'break', 'critical', 'option', 'rect', 'box', 'end', 'autonumber', 'title', 'acctitle', 'accdescr', 'link', 'links', 'properties', 'details',
|
|
8753
|
+
'left', 'right', 'over', 'as', 'off', 'sequencediagram']);
|
|
8754
|
+
const msId = name => String(name).replace(/->+/g, '-'); // an id never holds "->" (model.md rule 5)
|
|
8755
|
+
|
|
8756
|
+
// the first of base, base-2, base-3 ... not in used, resuming where the last ask for base stopped (R2)
|
|
8757
|
+
function msFree(used, next, base) {
|
|
8758
|
+
let k = next.get(base) || 1, id = k > 1 ? `${base}-${k}` : base;
|
|
8759
|
+
while (used.has(id)) id = `${base}-${++k}`;
|
|
8760
|
+
next.set(base, k);
|
|
8761
|
+
return id;
|
|
8762
|
+
}
|
|
8763
|
+
|
|
8764
|
+
// text: Mermaid's line breaks and entity codes (#quot; #lt; #59; ...); a named one Mermaid knows and this does not stays
|
|
8765
|
+
function msDecode(s) {
|
|
8766
|
+
return s.replace(/<br\s?\/?>|\\n/gi, '\n').replace(/#(quot|lt|gt|amp|\d{1,6});/g, (_, e) =>
|
|
8767
|
+
({ quot: '"', lt: '<', gt: '>', amp: '&' })[e] ?? String.fromCodePoint(Math.min(Number(e), 0x10ffff)));
|
|
8768
|
+
}
|
|
8769
|
+
|
|
8770
|
+
// a line's statements: `;` ends one, unless it closes an entity such as #59;
|
|
8771
|
+
function msStatements(line) {
|
|
8772
|
+
const out = [];
|
|
8773
|
+
let from = 0, ent = -1;
|
|
8774
|
+
for (let k = 0; k < line.length; k++) {
|
|
8775
|
+
const c = line[k];
|
|
8776
|
+
if (c === '#') ent = k;
|
|
8777
|
+
else if (c === ';') { if (!(ent >= 0 && k - ent > 1)) { out.push(line.slice(from, k)); from = k + 1; } ent = -1; }
|
|
8778
|
+
else if (!/\w/.test(c)) ent = -1;
|
|
8779
|
+
}
|
|
8780
|
+
out.push(line.slice(from));
|
|
8781
|
+
return out.map(x => x.trim()).filter(Boolean);
|
|
8782
|
+
}
|
|
8783
|
+
|
|
8784
|
+
// the keyword a statement starts with, lower-case, or '' (a keyword is a whole word, as Mermaid's lexer reads it: par_over is one)
|
|
8785
|
+
function msWord(s) {
|
|
8786
|
+
let e = 0;
|
|
8787
|
+
while (e < s.length && /[A-Za-z_]/.test(s[e])) e++;
|
|
8788
|
+
return e && !/\w/.test(s[e] ?? ' ') ? s.slice(0, e).toLowerCase() : '';
|
|
8789
|
+
}
|
|
8790
|
+
|
|
8791
|
+
// a message: A [()] arrow [+|-] [()] B : text, or null
|
|
8792
|
+
function msMessage(s) {
|
|
8793
|
+
let k = 0, arrow;
|
|
8794
|
+
for (; k < s.length && s[k] !== ':'; k++) if ((s[k] === '-' || s[k] === '<') && (arrow = MS_ARROWS.find(a => s.startsWith(a[0], k)))) break;
|
|
8795
|
+
if (!arrow) return null;
|
|
8796
|
+
let a = s.slice(0, k).trim(), j = k + arrow[0].length, central = false, plus = false, minus = false;
|
|
8797
|
+
if (a.endsWith('()')) { central = true; a = a.slice(0, -2).trim(); }
|
|
8798
|
+
for (;; j++) {
|
|
8799
|
+
while (s[j] === ' ' || s[j] === '\t') j++;
|
|
8800
|
+
if (s[j] === '+') plus = true; else if (s[j] === '-') minus = true; else if (s.startsWith('()', j)) { central = true; j++; } else break;
|
|
8801
|
+
}
|
|
8802
|
+
const colon = s.indexOf(':', j), b = (colon < 0 ? s.slice(j) : s.slice(j, colon)).trim();
|
|
8803
|
+
return a && b ? { a, b, arrow, central, plus, minus, text: colon < 0 ? '' : s.slice(colon + 1).trim() } : null;
|
|
8804
|
+
}
|
|
8805
|
+
|
|
8806
|
+
// participant NAME [@{ ... }] [as ALIAS]: { name, type, alias }
|
|
8807
|
+
function msParticipant(rest) {
|
|
8808
|
+
let name = rest, type, alias, after = '';
|
|
8809
|
+
const at = rest.indexOf('@{');
|
|
8810
|
+
if (at >= 0) {
|
|
8811
|
+
const close = rest.indexOf('}', at), cfg = rest.slice(at + 2, close < 0 ? rest.length : close);
|
|
8812
|
+
name = rest.slice(0, at); after = close < 0 ? '' : rest.slice(close + 1).trim();
|
|
8813
|
+
type = /"type"\s*:\s*"([^"]*)"/.exec(cfg)?.[1];
|
|
8814
|
+
alias = /"alias"\s*:\s*"([^"]*)"/.exec(cfg)?.[1];
|
|
8815
|
+
if (after.startsWith('as ')) alias = after.slice(3);
|
|
8816
|
+
} else {
|
|
8817
|
+
const m = /\sas\s/.exec(rest);
|
|
8818
|
+
if (m) { name = rest.slice(0, m.index); alias = rest.slice(m.index + m[0].length); }
|
|
8819
|
+
}
|
|
8820
|
+
return { name: name.trim(), type, alias: alias?.trim() };
|
|
8821
|
+
}
|
|
8822
|
+
|
|
8823
|
+
// which call each reply answers, the same for the reader and the writer: the latest call from the reply's receiver to its
|
|
8824
|
+
// sender that has no reply yet, where a reply in another operand of an alt does not count; or the call its `want` names,
|
|
8825
|
+
// when that is an earlier call the reply goes back along (the writer's `%% reply-to:`). occ: [{ x, id, from, to, sort,
|
|
8826
|
+
// parent, want }] in time order; fragments: [{ parent, props: { operator }, members: [{ id }] }]. Returns
|
|
8827
|
+
// [Map reply -> call, Set of the replies (their x) whose call is their want and not the latest unanswered one].
|
|
8828
|
+
// ponytail: an operand's chain stops at 64 levels and one reply looks at 256 earlier answers at most, so hostile text
|
|
8829
|
+
// stays linear (R2); past that a reply finds no call, which the reader reports. Raise both if real diagrams need it
|
|
8830
|
+
function msMatch(occ, fragments) {
|
|
8831
|
+
const fragOf = new Map();
|
|
8832
|
+
for (const f of fragments) for (const o of f.members) fragOf.set(o.id, f);
|
|
8833
|
+
const chain = p => { const c = [], seen = new Set(); while (fragOf.has(p) && !seen.has(p) && c.length < 64) { seen.add(p); c.push(p); p = fragOf.get(p).parent; } return c; };
|
|
8834
|
+
// each pair's calls in time order; up[k] is k while call k is open and a lower index once it is closed (a union-find
|
|
8835
|
+
// with path halving), so a reply passes each closed call once, wherever it lies in the stack (R2)
|
|
8836
|
+
const stacks = new Map(), byId = new Map(), out = new Map(), forced = new Set();
|
|
8837
|
+
const open = (st, k) => { const up = st.up; while (k >= 0 && up[k] !== k) { if (up[k] >= 0) up[k] = up[up[k]]; k = up[k]; } return k; };
|
|
8838
|
+
for (const x of occ) {
|
|
8839
|
+
if (x.sort === 'call') {
|
|
8840
|
+
const key = `${x.from}\0${x.to}`;
|
|
8841
|
+
if (!stacks.has(key)) stacks.set(key, { calls: [], up: [] });
|
|
8842
|
+
const st = stacks.get(key), call = { x, st, k: st.calls.length, key, chain: new Set(chain(x.parent)), answers: [] };
|
|
8843
|
+
st.calls.push(call); st.up.push(call.k);
|
|
8844
|
+
if (x.id != null) byId.set(x.id, call);
|
|
8845
|
+
} else if (x.sort === 'reply') {
|
|
8846
|
+
const key = `${x.to}\0${x.from}`, st = stacks.get(key), c = chain(x.parent), at = new Map(c.map(o => [fragOf.get(o), o]));
|
|
8847
|
+
// two answers are apart when they lie in different operands of one alt
|
|
8848
|
+
const apart = a => a.some(o => { const f = fragOf.get(o), y = at.get(f); return y !== undefined && y !== o && f.props?.operator === 'alt'; });
|
|
8849
|
+
let budget = 256, found;
|
|
8850
|
+
if (st) for (let k = open(st, st.calls.length - 1); k >= 0 && budget > 0; k = open(st, k - 1)) {
|
|
8851
|
+
const call = st.calls[k];
|
|
8852
|
+
if (call.answers.some(a => --budget < 0 || !apart(a))) continue;
|
|
8853
|
+
found = call; break;
|
|
8854
|
+
}
|
|
8855
|
+
const want = x.want != null ? byId.get(x.want) : undefined;
|
|
8856
|
+
if (want && want.key === key && want !== found) { found = want; forced.add(x.x); }
|
|
8857
|
+
if (!found) continue;
|
|
8858
|
+
out.set(x, found.x); found.answers.push(c);
|
|
8859
|
+
if (c.every(o => found.chain.has(o))) found.st.up[found.k] = found.k - 1; // answered where it was made, or outside it: no later reply sees it
|
|
8860
|
+
}
|
|
8861
|
+
}
|
|
8862
|
+
return [out, forced];
|
|
8863
|
+
}
|
|
8864
|
+
|
|
8865
|
+
function readMermaidSequence(text) {
|
|
8866
|
+
const report = [], say = (line, status, message) => report.push({ line, status, message });
|
|
8867
|
+
const empty = () => ({ model: { schemaVersion: 2, root: MS_VIEW, nodes: [], edges: [], views: [{ id: MS_VIEW, diagram: 'uml.sequence', nodeIds: [], edges: [] }] }, report });
|
|
8868
|
+
if (typeof text !== 'string') { say(1, 'skipped', 'the input is not text: nothing read'); return empty(); }
|
|
8869
|
+
if (text.length > MS_LIMIT) { say(1, 'skipped', `the input is ${text.length} characters, over the limit of ${MS_LIMIT}: nothing read`); return empty(); }
|
|
8870
|
+
const lines = text.split('\n').map(l => l.replace(/\r$/, ''));
|
|
8871
|
+
let i = 0, title;
|
|
8872
|
+
|
|
8873
|
+
// front matter: its title; Mermaid's configuration is not read
|
|
8874
|
+
while (i < lines.length && !lines[i].trim()) i++;
|
|
8875
|
+
if (lines[i] && lines[i].trim() === '---') {
|
|
8876
|
+
let j = i + 1;
|
|
8877
|
+
for (; j < lines.length && lines[j].trim() !== '---'; j++) {
|
|
8878
|
+
const l = lines[j];
|
|
8879
|
+
if (!l.trim() || l.trim().startsWith('#') || /^\s/.test(l)) continue;
|
|
8880
|
+
const c = l.indexOf(':'), key = (c < 0 ? l : l.slice(0, c)).trim(), val = c < 0 ? '' : l.slice(c + 1).trim();
|
|
8881
|
+
if (key === 'title') {
|
|
8882
|
+
if (val.startsWith('"')) { try { title = JSON.parse(val); } catch { title = val.slice(1, -1); } }
|
|
8883
|
+
else title = val.startsWith("'") ? val.slice(1, -1).replaceAll("''", "'") : val;
|
|
8884
|
+
} else say(j + 1, 'skipped', `front matter ${key}: Mermaid's configuration is not read`);
|
|
8885
|
+
}
|
|
8886
|
+
if (j >= lines.length) { say(i + 1, 'skipped', 'the front matter is never closed with ---: nothing read'); return empty(); }
|
|
8887
|
+
i = j + 1;
|
|
8888
|
+
}
|
|
8889
|
+
|
|
8890
|
+
const lifelines = new Map(), order = [], items = [], stack = [], creating = new Map(), destroying = new Map();
|
|
8891
|
+
let viewId, started = false, hint = {}, block = false, last = null;
|
|
8892
|
+
const life = (name, no) => {
|
|
8893
|
+
if (!lifelines.has(name)) { const rec = { name, no, node: { id: undefined, type: 'uml.Lifeline', label: name } }; lifelines.set(name, rec); order.push(rec); }
|
|
8894
|
+
return lifelines.get(name);
|
|
8895
|
+
};
|
|
8896
|
+
const cur = () => (stack.length ? stack[stack.length - 1].cur : null); // the operand an element lies in
|
|
8897
|
+
const declare = (rest, actor, no, created) => {
|
|
8898
|
+
const p = msParticipant(rest);
|
|
8899
|
+
if (!p.name) return say(no, 'skipped', `a ${actor ? 'actor' : 'participant'} with no name`);
|
|
8900
|
+
if (created && lifelines.has(p.name)) return say(no, 'skipped', `create ${p.name}: ${p.name} is in the diagram already, so it is not created`);
|
|
8901
|
+
const rec = life(p.name, no), n = rec.node, stereotypes = [...(actor ? ['actor'] : []), ...(p.type ? [p.type] : [])];
|
|
8902
|
+
if (hint.id != null) rec.hint = hint.id;
|
|
8903
|
+
if (p.type) say(no, 'approximated', `${p.name}'s type ${p.type} is read as a stereotype, shown as text`);
|
|
8904
|
+
if (p.alias != null) { const c = hint.represents ? p.alias.indexOf(':') : -1; n.label = msDecode((c < 0 ? p.alias : p.alias.slice(0, c)).trim()); }
|
|
8905
|
+
if (hint.represents) n.props = { ...n.props, represents: msDecode(hint.represents) };
|
|
8906
|
+
if (stereotypes.length) n.props = { ...n.props, stereotypes };
|
|
8907
|
+
if (created) creating.set(p.name, no);
|
|
8908
|
+
};
|
|
8909
|
+
const open = (operator, guard, no) => {
|
|
8910
|
+
const frag = { kind: 'frag', operator, no, hint: hint.id, parent: cur(), ops: [] };
|
|
8911
|
+
const op = { kind: 'op', frag, k: 1, label: guard ? msDecode(guard) : undefined, hint: hint.operand };
|
|
8912
|
+
frag.ops.push(op); items.push(frag, op); stack.push({ frag, cur: op, no });
|
|
8913
|
+
};
|
|
8914
|
+
|
|
8915
|
+
for (; i < lines.length; i++) {
|
|
8916
|
+
const no = i + 1, line = lines[i].trim();
|
|
8917
|
+
if (!line) continue;
|
|
8918
|
+
if (block) { if (line.includes('}')) block = false; continue; } // inside accDescr { ... }
|
|
8919
|
+
if (line.startsWith('%%')) {
|
|
8920
|
+
const c = line.slice(2).trim();
|
|
8921
|
+
if (c.startsWith('{')) {
|
|
8922
|
+
say(no, 'skipped', 'a %%{init}%% directive: Mermaid\'s configuration is not read');
|
|
8923
|
+
if (!line.includes('}%%')) while (i + 1 < lines.length && !lines[++i].includes('}%%')); // over several lines: to its }%%
|
|
8924
|
+
}
|
|
8925
|
+
else if (c.startsWith('id:')) hint.id = c.slice(3).trim();
|
|
8926
|
+
else if (c.startsWith('operand-id:')) hint.operand = c.slice(11).trim();
|
|
8927
|
+
else if (c.startsWith('represents:')) hint.represents = c.slice(11).trim();
|
|
8928
|
+
else if (c.startsWith('reply-to:')) hint.replyTo = c.slice(9).trim();
|
|
8929
|
+
else if (c.startsWith('use:')) {
|
|
8930
|
+
const u = c.slice(4).trim(), r = u.indexOf(' refersTo=');
|
|
8931
|
+
hint.use = r < 0 ? { id: u } : { id: u.slice(0, r).trim(), refersTo: u.slice(r + 10).trim() };
|
|
8932
|
+
}
|
|
8933
|
+
continue;
|
|
8934
|
+
}
|
|
8935
|
+
for (const s of msStatements(line)) {
|
|
8936
|
+
if (!started) {
|
|
8937
|
+
if (s !== 'sequenceDiagram') { say(no, 'skipped', 'not a Mermaid sequenceDiagram: nothing read'); return empty(); }
|
|
8938
|
+
started = true; viewId = hint.id; hint = {}; continue;
|
|
8939
|
+
}
|
|
8940
|
+
const word = msWord(s), rest = s.slice(word.length).trim(), was = last, top = stack[stack.length - 1];
|
|
8941
|
+
last = null;
|
|
8942
|
+
if (word === 'participant' || word === 'actor') declare(rest, word === 'actor', no, false);
|
|
8943
|
+
else if (word === 'create') {
|
|
8944
|
+
const w = msWord(rest);
|
|
8945
|
+
if (w === 'participant' || w === 'actor') declare(rest.slice(w.length).trim(), w === 'actor', no, true);
|
|
8946
|
+
else say(no, 'skipped', `create ${rest}: not a participant or an actor`);
|
|
8947
|
+
} else if (word === 'destroy') { if (rest) destroying.set(rest, no); else say(no, 'skipped', 'destroy names no participant'); }
|
|
8948
|
+
else if (word === 'activate' || word === 'deactivate') {
|
|
8949
|
+
const on = was && (word === 'activate' ? was.to : was.from).name === rest;
|
|
8950
|
+
if (on) { was[word === 'activate' ? 'plus' : 'minus'] = true; last = was; }
|
|
8951
|
+
else say(no, 'approximated', `${word} ${rest}: activations are derived from the messages, so it is not read`);
|
|
8952
|
+
} else if (MS_OPS.includes(word)) open(word, rest, no);
|
|
8953
|
+
else if (word === 'par_over') { open('par', rest, no); say(no, 'approximated', `par_over ${rest}: read as par (a par drawn over its participants only)`); }
|
|
8954
|
+
else if (word === 'else' || word === 'and') {
|
|
8955
|
+
if (top && top.frag && top.frag.operator === (word === 'else' ? 'alt' : 'par')) {
|
|
8956
|
+
const op = { kind: 'op', frag: top.frag, k: top.frag.ops.length + 1, label: rest ? msDecode(rest) : word === 'else' ? 'else' : undefined, hint: hint.id };
|
|
8957
|
+
top.frag.ops.push(op); items.push(op); top.cur = op;
|
|
8958
|
+
} else say(no, 'skipped', `${word} outside ${word === 'else' ? 'an alt' : 'a par'}: not read`);
|
|
8959
|
+
} else if (word === 'critical') { stack.push({ what: 'critical', cur: cur(), no }); say(no, 'approximated', `critical ${rest}: its content is read, with no frame (critical is not in the subset)`); }
|
|
8960
|
+
else if (word === 'option') {
|
|
8961
|
+
if (top && top.what === 'critical') say(no, 'approximated', `option ${rest}: read as the critical region's content, with no frame`);
|
|
8962
|
+
else say(no, 'skipped', 'option outside a critical region: not read');
|
|
8963
|
+
} else if (word === 'rect') { stack.push({ what: 'rect', cur: cur(), no }); say(no, 'skipped', `rect ${rest}: a background colour is not read; its content is`); }
|
|
8964
|
+
else if (word === 'box') { stack.push({ what: 'box', cur: cur(), no }); say(no, 'skipped', `box ${rest}: a group of participants is not read; the participants are`); }
|
|
8965
|
+
else if (word === 'end') { if (top) stack.pop(); else say(no, 'skipped', 'an end that closes nothing'); }
|
|
8966
|
+
else if (word === 'note') {
|
|
8967
|
+
const c = rest.indexOf(':'), where = (c < 0 ? rest : rest.slice(0, c)).trim(), body = c < 0 ? '' : rest.slice(c + 1).trim();
|
|
8968
|
+
const names = where.replace(/^(over|left of|right of)\s+/i, '').split(',').map(x => x.trim()).filter(Boolean);
|
|
8969
|
+
if (hint.use && names.length) {
|
|
8970
|
+
const t = body === 'ref' ? '' : body.startsWith('ref ') ? body.slice(4).trim() : body, label = msDecode(t);
|
|
8971
|
+
items.push({ kind: 'use', no, id: hint.use.id, refersTo: hint.use.refersTo, label, covers: names.map(x => life(x, no)), parent: cur() });
|
|
8972
|
+
} else say(no, 'skipped', 'a note is not read (notes are not in the subset)');
|
|
8973
|
+
} else if (word === 'title') title = msDecode(rest.replace(/^:/, '').trim());
|
|
8974
|
+
else if (word === 'autonumber') say(no, 'skipped', 'autonumber: message numbers are not drawn in a sequence view');
|
|
8975
|
+
else if (['link', 'links', 'properties', 'details'].includes(word)) say(no, 'skipped', `${word}: actor menus are not read`);
|
|
8976
|
+
else if (word === 'acctitle' || word === 'accdescr') {
|
|
8977
|
+
say(no, 'skipped', `${word === 'acctitle' ? 'accTitle' : 'accDescr'}: accessibility text is not read`);
|
|
8978
|
+
if (word === 'accdescr' && s.includes('{') && !s.includes('}')) block = true;
|
|
8979
|
+
} else {
|
|
8980
|
+
const m = msMessage(s), what = m && `${m.a}${m.arrow[0]}${m.b}`;
|
|
8981
|
+
if (!m) {
|
|
8982
|
+
const half = /-(\|[\\/]|\\\\|\/\/)|(\/\||\/\/|\\\\)-/.test(s); // Mermaid's half arrows (v11.12.3+)
|
|
8983
|
+
say(no, 'skipped', `"${s.length > 60 ? s.slice(0, 57) + '...' : s}" is not understood${half ? ': a half arrow is not in the subset' : ''}`); hint = {}; continue;
|
|
8984
|
+
}
|
|
8985
|
+
const from = life(m.a, no), to = life(m.b, no);
|
|
8986
|
+
let sort = m.arrow[1];
|
|
8987
|
+
if (creating.has(from.name) && from !== to) { say(creating.get(from.name), 'skipped', `create ${from.name}: the next message is from it, so nothing creates it`); creating.delete(from.name); }
|
|
8988
|
+
if (creating.has(to.name)) { sort = 'create'; creating.delete(to.name); }
|
|
8989
|
+
if (destroying.has(to.name)) { sort = 'delete'; destroying.delete(to.name); }
|
|
8990
|
+
else if (destroying.has(from.name)) { say(destroying.get(from.name), 'skipped', `destroy ${from.name}: the next message is from it, so its stem is not ended`); destroying.delete(from.name); }
|
|
8991
|
+
if (sort === m.arrow[1] && m.arrow[2]) say(no, 'approximated', `${what}: read as ${sort === 'async' ? 'an' : 'a'} ${sort}; ${m.arrow[2]}`);
|
|
8992
|
+
if (m.central) say(no, 'approximated', `${what}: the central connection is not drawn`);
|
|
8993
|
+
const msg = { kind: 'msg', no, what, from, to, sort, label: msDecode(m.text), hint: hint.id, want: hint.replyTo || undefined, parent: cur(), plus: m.plus, minus: m.minus };
|
|
8994
|
+
items.push(msg); last = msg;
|
|
8995
|
+
}
|
|
8996
|
+
hint = {};
|
|
8997
|
+
}
|
|
8998
|
+
}
|
|
8999
|
+
if (!started) { say(1, 'skipped', 'not a Mermaid sequenceDiagram: nothing read'); return empty(); }
|
|
9000
|
+
for (const o of stack) say(o.no, 'approximated', `${o.frag ? o.frag.operator : o.what} is never closed: read to the end`);
|
|
9001
|
+
for (const [name, no] of creating) say(no, 'skipped', `create ${name}: no message to it follows`);
|
|
9002
|
+
for (const [name, no] of destroying) say(no, 'skipped', `destroy ${name}: no message to it follows`);
|
|
9003
|
+
|
|
9004
|
+
// ids: lifelines first, by name; then, in the order they come, messages, uses, fragments and operands
|
|
9005
|
+
const used = new Set(), next = new Map(), uniq = name => { const id = msFree(used, next, msId(name)); used.add(id); return id; };
|
|
9006
|
+
for (const rec of order) {
|
|
9007
|
+
rec.node.id = uniq(rec.hint ?? rec.name);
|
|
9008
|
+
if (rec.hint == null && rec.node.id !== rec.name) say(rec.no, 'approximated', `participant ${rec.name}: an id holds no "->", so it is ${rec.node.id}`);
|
|
9009
|
+
}
|
|
9010
|
+
const seen = new Map();
|
|
9011
|
+
for (const x of items) {
|
|
9012
|
+
if (x.kind === 'msg') x.id = uniq(x.hint ?? `${x.from.node.id}-${x.to.node.id}`);
|
|
9013
|
+
else if (x.kind === 'use') x.id = uniq(x.id || 'use');
|
|
9014
|
+
else if (x.kind === 'frag') { const n = (seen.get(x.operator) || 0) + 1; seen.set(x.operator, n); x.id = uniq(x.hint ?? `${x.operator}-${n}`); }
|
|
9015
|
+
else x.id = uniq(x.hint ?? `${x.frag.id}.${x.k}`);
|
|
9016
|
+
}
|
|
9017
|
+
|
|
9018
|
+
const nodes = order.map(r => r.node), at = new Map(nodes.map((n, k) => [n.id, k]));
|
|
9019
|
+
const frags = items.filter(x => x.kind === 'frag').map(f => ({ id: f.id, type: 'uml.CombinedFragment', ...(f.parent ? { parent: f.parent.id } : {}), props: { operator: f.operator },
|
|
9020
|
+
members: f.ops.map(o => ({ id: o.id, type: 'uml.InteractionOperand', ...(o.label != null ? { label: o.label } : {}) })) }));
|
|
9021
|
+
const occ = items.filter(x => x.kind === 'msg' || x.kind === 'use');
|
|
9022
|
+
const [replies] = msMatch(occ.filter(x => x.kind === 'msg').map(x => ({ x, id: x.id, from: x.from.node.id, to: x.to.node.id, sort: x.sort, parent: x.parent?.id, want: x.want })), frags);
|
|
9023
|
+
const answer = new Map([...replies].map(([r, c]) => [r.x, c.x])), lastReply = new Map();
|
|
9024
|
+
for (const [r, c] of answer) lastReply.set(c, r);
|
|
9025
|
+
const edges = occ.map(x => {
|
|
9026
|
+
if (x.kind === 'use') {
|
|
9027
|
+
const ks = x.covers.map(r => at.get(r.node.id)), lo = ks.reduce((a, b) => Math.min(a, b)), hi = ks.reduce((a, b) => Math.max(a, b));
|
|
9028
|
+
return { id: x.id, type: 'uml.InteractionUse', ...(x.label ? { label: x.label } : {}), ...(x.parent ? { parent: x.parent.id } : {}),
|
|
9029
|
+
props: { covers: nodes.slice(lo, hi + 1).map(n => n.id), ...(x.refersTo ? { refersTo: x.refersTo } : {}) } };
|
|
9030
|
+
}
|
|
9031
|
+
const call = answer.get(x);
|
|
9032
|
+
if (x.sort === 'reply' && !call) say(x.no, 'approximated', `${x.what}: a reply with no call from ${x.to.name} to ${x.from.name} before it answers none`);
|
|
9033
|
+
if (x.want != null && call?.id !== x.want) say(x.no, 'approximated', `${x.what}: reply-to ${x.want} is not an earlier call from ${x.to.name} to ${x.from.name}, so it is not read`);
|
|
9034
|
+
if (x.plus && x.sort !== 'call') say(x.no, 'approximated', `${x.what}: its + is not read: activations are derived, and ${x.sort === 'async' ? 'an' : 'a'} ${x.sort} opens none`);
|
|
9035
|
+
if (x.minus && !(call && lastReply.get(call) === x)) say(x.no, 'approximated', `${x.what}: its - is not read: activations are derived, and a bar ends at the last reply to its call`);
|
|
9036
|
+
return { id: x.id, type: 'uml.Message', from: x.from.node.id, to: x.to.node.id, ...(x.label ? { label: x.label } : {}), ...(x.parent ? { parent: x.parent.id } : {}),
|
|
9037
|
+
props: { sort: x.sort, ...(call ? { replyTo: call.id } : {}) } };
|
|
9038
|
+
});
|
|
9039
|
+
|
|
9040
|
+
const view = { id: viewId || MS_VIEW, diagram: 'uml.sequence', ...(title ? { title } : {}), nodeIds: nodes.map(n => n.id), edges, ...(frags.length ? { fragments: frags } : {}) };
|
|
9041
|
+
report.sort((a, b) => a.line - b.line); // stable: one line's items keep their order
|
|
9042
|
+
return { model: { schemaVersion: 2, ...(title ? { title } : {}), root: view.id, nodes, edges: [], views: [view] }, report };
|
|
9043
|
+
}
|
|
9044
|
+
|
|
9045
|
+
function writeMermaidSequence(model, viewId) {
|
|
9046
|
+
const m = model && typeof model === 'object' ? model : {}, arr = x => (Array.isArray(x) ? x : []), isObj = x => !!x && typeof x === 'object';
|
|
9047
|
+
const flat = s => String(s).replace(/\r\n|\r|\n/g, '\\n'), nl = s => /[\r\n]/.test(String(s)), has = x => x != null && x !== '';
|
|
9048
|
+
const views = arr(m.views).filter(isObj);
|
|
9049
|
+
const view = viewId != null ? views.find(v => v.id === viewId) : views.find(v => v.diagram === 'uml.sequence');
|
|
9050
|
+
if (viewId != null && !view) return `%% not written: view ${flat(viewId)}, which the model does not have\n`;
|
|
9051
|
+
if (!view) return '%% not written: the model, which has no uml.sequence view\n';
|
|
9052
|
+
if (view.diagram !== 'uml.sequence') return `%% not written: view ${flat(view.id)}, a ${flat(view.diagram)} view, not a uml.sequence one\n`;
|
|
9053
|
+
// text: Mermaid's own characters as entities, a line break as <br>, and a space at either end as an entity, which the reader's trim keeps
|
|
9054
|
+
const enc = s => String(s).replace(/[#;<>\\]/g, c => ({ '#': '#35;', ';': '#59;', '<': '#lt;', '>': '#gt;', '\\': '#92;' })[c]).replace(/\r\n|\r|\n/g, '<br>')
|
|
9055
|
+
.replace(/^\s|\s$/g, c => `#${c.codePointAt(0)};`);
|
|
9056
|
+
const encA = s => enc(s).replace(/:/g, '#58;'); // an alias: under %% represents:, its first colon ends the label
|
|
9057
|
+
const out = [], notWritten = [], skip = x => notWritten.push(`${flat(x.id)} (${flat(x.type)})`), lose = (id, what) => notWritten.push(`${flat(id)}: ${flat(what)}`);
|
|
9058
|
+
const title = has(view.title) ? view.title : m.title;
|
|
9059
|
+
if (has(title)) out.push('---', `title: ${/^[\p{L}\p{N}][\p{L}\p{N} _.,()-]*$/u.test(title) && !title.endsWith(' ') ? title : JSON.stringify(String(title))}`, '---');
|
|
9060
|
+
if (view.id !== MS_VIEW) { if (nl(view.id)) lose(view.id, "the view's id, which has a line break"); else out.push(`%% id: ${view.id}`); }
|
|
9061
|
+
out.push('sequenceDiagram');
|
|
9062
|
+
|
|
9063
|
+
// what is written: lifelines, occurrences (messages and uses) and fragments of the view
|
|
9064
|
+
const byId = new Map(arr(m.nodes).filter(isObj).map(n => [n.id, n]));
|
|
9065
|
+
const lifes = [];
|
|
9066
|
+
for (const id of arr(view.nodeIds)) { const n = byId.get(id); if (!n) continue; if (n.type === 'uml.Lifeline' && !nl(n.id)) lifes.push(n); else skip(n); }
|
|
9067
|
+
const isLife = new Set(lifes.map(n => n.id));
|
|
9068
|
+
const edgeById = new Map();
|
|
9069
|
+
for (const x of arr(m.edges)) if (isObj(x) && !edgeById.has(x.id)) edgeById.set(x.id, x);
|
|
9070
|
+
const edgeOf = e => (typeof e === 'string' ? edgeById.get(e) : e);
|
|
9071
|
+
const occ = [];
|
|
9072
|
+
for (const x of arr(view.edges).map(edgeOf).filter(isObj)) {
|
|
9073
|
+
const ok = !nl(x.id) && (x.type === 'uml.Message' ? isLife.has(x.from) && isLife.has(x.to)
|
|
9074
|
+
: x.type === 'uml.InteractionUse' && arr(x.props?.covers).length > 0 && arr(x.props.covers).every(c => isLife.has(c)) && !nl(x.props.refersTo ?? ''));
|
|
9075
|
+
if (ok) occ.push(x); else skip(x);
|
|
9076
|
+
}
|
|
9077
|
+
const SORTS = ['call', 'async', 'reply', 'create', 'delete'];
|
|
9078
|
+
const frags = [], opOf = new Map();
|
|
9079
|
+
for (const f of arr(view.fragments).filter(isObj)) {
|
|
9080
|
+
const ops = arr(f.members).filter(isObj), op = f.props?.operator;
|
|
9081
|
+
if (!MS_OPS.includes(op) || !ops.length || (ops.length > 1 && op !== 'alt' && op !== 'par') || nl(f.id) || ops.some(o => nl(o.id))) { skip(f); ops.forEach(skip); continue; }
|
|
9082
|
+
if (has(f.label)) lose(f.id, `label ${f.label}`);
|
|
9083
|
+
frags.push(f); ops.forEach((o, i) => opOf.set(o.id, { f, i }));
|
|
9084
|
+
}
|
|
9085
|
+
// fragments with an occurrence in them, and the empty ones by the operand they end (null: the top)
|
|
9086
|
+
const full = new Set(), kids = new Map();
|
|
9087
|
+
for (const x of occ) for (let p = x.parent; opOf.has(p) && !full.has(opOf.get(p).f); p = opOf.get(p).f.parent) full.add(opOf.get(p).f);
|
|
9088
|
+
for (const f of frags) if (!full.has(f)) { const p = opOf.has(f.parent) ? f.parent : null; if (!kids.has(p)) kids.set(p, []); kids.get(p).push(f); }
|
|
9089
|
+
|
|
9090
|
+
// the sorts as written
|
|
9091
|
+
const sortOf = new Map(), touched = new Set(), created = [];
|
|
9092
|
+
for (const x of occ) {
|
|
9093
|
+
if (x.type !== 'uml.Message') { arr(x.props.covers).forEach(c => touched.add(c)); continue; }
|
|
9094
|
+
let s = x.props?.sort;
|
|
9095
|
+
if (!SORTS.includes(s)) { lose(x.id, has(s) ? `sort ${s}, written as a call` : 'no sort, written as a call'); s = 'call'; }
|
|
9096
|
+
if (s === 'create' && (touched.has(x.to) || x.from === x.to)) { lose(x.id, 'sort create: the lifeline is in the diagram before it, written as a call'); s = 'call'; }
|
|
9097
|
+
if (s === 'create') created.push(x.to);
|
|
9098
|
+
touched.add(x.from); touched.add(x.to); sortOf.set(x, s);
|
|
9099
|
+
}
|
|
9100
|
+
const replySlot = notWritten.length; // a reply's call is found once the fragments are placed; what is lost about it goes here
|
|
9101
|
+
|
|
9102
|
+
// lifelines: those not created up front, in the view's order, then the created ones where they are created
|
|
9103
|
+
const isCreated = new Set(created), written = [...lifes.filter(n => !isCreated.has(n.id)), ...created.map(id => byId.get(id))];
|
|
9104
|
+
if (written.some((n, k) => n !== lifes[k])) lose(view.id, 'the order of its lifelines: Mermaid places a lifeline a message creates after the others');
|
|
9105
|
+
const used = new Set(), next = new Map(), names = new Map(), taken = new Set(lifes.map(n => n.id)), takenNext = new Map();
|
|
9106
|
+
const idOf = (id, base) => { const d = msFree(used, next, msId(base)); used.add(id); return d === id ? null : id; }; // the hint, if one is needed
|
|
9107
|
+
const safe = id => /^[\p{L}\p{N}_]+(?:[.-][\p{L}\p{N}_]+)*$/u.test(id) && !/-x/i.test(id) && !MS_KEYWORDS.has(msWord(id));
|
|
9108
|
+
const heads = new Map();
|
|
9109
|
+
for (const n of written) {
|
|
9110
|
+
let name = String(n.id);
|
|
9111
|
+
if (!safe(name)) { let base = name.replace(/[^\p{L}\p{N}_]+/gu, '_').replace(/^_+|_+$/g, ''); if (!safe(base)) base = 'L_' + base; name = msFree(taken, takenNext, base); taken.add(name); }
|
|
9112
|
+
names.set(n.id, name);
|
|
9113
|
+
const p = isObj(n.props) ? n.props : {}, st = arr(p.stereotypes), actor = st[0] === 'actor', rep = has(p.represents);
|
|
9114
|
+
st.slice(actor ? 1 : 0).forEach(s => lose(n.id, `stereotype ${s}`));
|
|
9115
|
+
// the reader takes the alias as the label, and splits it at its first colon only under a %% represents: comment
|
|
9116
|
+
const label = has(n.label) ? String(n.label) : name, alias = label !== name || rep ? encA(label) + (rep ? ' : ' + encA(p.represents) : '') : '';
|
|
9117
|
+
if (!has(n.label)) lose(n.id, 'no label, read as its name');
|
|
9118
|
+
heads.set(n.id, [...(idOf(n.id, name) != null ? [`%% id: ${n.id}`] : []), ...(rep ? [`%% represents: ${enc(p.represents)}`] : []),
|
|
9119
|
+
`${actor ? 'actor' : 'participant'} ${name}${alias ? ' as ' + alias : ''}`]);
|
|
9120
|
+
}
|
|
9121
|
+
for (const n of lifes) if (!isCreated.has(n.id)) out.push(...heads.get(n.id).map(l => ' ' + l));
|
|
9122
|
+
|
|
9123
|
+
// the occurrences in time order, opening and closing fragments around them; an empty fragment ends its operand
|
|
9124
|
+
const stack = [], on = new Map(), done = new Set(), broken = new Set(), count = new Map(), parentOf = new Map(); // on: fragment -> its place in the stack
|
|
9125
|
+
const put = (depth, ...xs) => out.push(...xs.map(x => ' '.repeat(depth + 1) + x));
|
|
9126
|
+
const top = () => (stack.length ? stack.at(-1).f.members[stack.at(-1).i].id : undefined); // the operand being written
|
|
9127
|
+
const empty = (x, what) => { if (x.label === '') lose(x.id, `an empty ${what}, read as none`); };
|
|
9128
|
+
const guard = o => (has(o.label) ? ' ' + enc(o.label) : '');
|
|
9129
|
+
const begin = f => {
|
|
9130
|
+
const op = f.props.operator, n = (count.get(op) || 0) + 1, o = f.members[0], fh = idOf(f.id, `${op}-${n}`), oh = idOf(o.id, `${f.id}.1`);
|
|
9131
|
+
count.set(op, n); empty(o, 'guard');
|
|
9132
|
+
put(stack.length, ...(fh ? [`%% id: ${fh}`] : []), ...(oh ? [`%% operand-id: ${oh}`] : []), op + guard(o));
|
|
9133
|
+
parentOf.set(f, top()); on.set(f, stack.length); stack.push({ f, i: 0 }); done.add(f);
|
|
9134
|
+
};
|
|
9135
|
+
const ends = o => { for (const k of kids.get(o.id) || []) if (!done.has(k)) { begin(k); finish(); } };
|
|
9136
|
+
const advance = () => {
|
|
9137
|
+
const t = stack.at(-1), f = t.f;
|
|
9138
|
+
ends(f.members[t.i]);
|
|
9139
|
+
const o = f.members[++t.i], h = idOf(o.id, `${f.id}.${t.i + 1}`), alt = f.props.operator === 'alt';
|
|
9140
|
+
if (alt && !has(o.label)) lose(o.id, 'no guard, read as else'); else empty(o, 'guard');
|
|
9141
|
+
put(stack.length - 1, ...(h ? [`%% id: ${h}`] : []), alt ? (o.label === 'else' ? 'else' : 'else' + guard(o)) : 'and' + guard(o));
|
|
9142
|
+
};
|
|
9143
|
+
const finish = () => { const t = stack.at(-1); while (t.i < t.f.members.length - 1) advance(); ends(t.f.members[t.i]); stack.pop(); on.delete(t.f); put(stack.length, 'end'); };
|
|
9144
|
+
const at = new Map(written.map((n, k) => [n.id, k])), msgAt = new Map(); // msgAt: a message -> its line, written once its call is known
|
|
9145
|
+
// ponytail: an occurrence walks up from its operand to the first fragment open on the stack, so a model written in order
|
|
9146
|
+
// costs its size; one whose fragments are not one run (named below) can cost its depth per occurrence
|
|
9147
|
+
for (const x of occ) {
|
|
9148
|
+
const path = [], seen = new Set(); // the operands to open, innermost first
|
|
9149
|
+
let p = x.parent, keep = 0, later = false;
|
|
9150
|
+
while (opOf.has(p) && !seen.has(p)) {
|
|
9151
|
+
seen.add(p);
|
|
9152
|
+
const o = opOf.get(p), s = on.get(o.f);
|
|
9153
|
+
if (s !== undefined) { keep = s; if (o.i === stack[s].i) keep++; else { later = o.i > stack[s].i; path.push(o); } break; }
|
|
9154
|
+
path.push(o); p = o.f.parent;
|
|
9155
|
+
}
|
|
9156
|
+
while (stack.length > keep + (later ? 1 : 0)) finish();
|
|
9157
|
+
if (later) { const o = path.pop(); while (stack.at(-1).i < o.i) advance(); }
|
|
9158
|
+
for (let j = path.length - 1; j >= 0; j--) {
|
|
9159
|
+
const o = path[j];
|
|
9160
|
+
if (done.has(o.f)) { if (!broken.has(o.f)) { broken.add(o.f); lose(o.f.id, 'its occurrences are not one run in the view\'s edges'); } break; }
|
|
9161
|
+
begin(o.f);
|
|
9162
|
+
while (stack.at(-1).i < o.i) advance();
|
|
9163
|
+
}
|
|
9164
|
+
const d = stack.length;
|
|
9165
|
+
if (x.type === 'uml.InteractionUse') {
|
|
9166
|
+
const cov = x.props.covers, ks = cov.map(id => at.get(id)), lo = ks.reduce((a, b) => Math.min(a, b)), hi = ks.reduce((a, b) => Math.max(a, b));
|
|
9167
|
+
if (cov.length !== hi - lo + 1 || written.slice(lo, hi + 1).some((n, j) => n.id !== cov[j])) lose(x.id, `covers ${cov.join(', ')}, which are not lifelines next to each other in order`);
|
|
9168
|
+
idOf(x.id, x.id); empty(x, 'label');
|
|
9169
|
+
const ends2 = lo === hi ? names.get(written[lo].id) : `${names.get(written[lo].id)},${names.get(written[hi].id)}`;
|
|
9170
|
+
put(d, `%% use: ${x.id}${has(x.props.refersTo) ? ' refersTo=' + x.props.refersTo : ''}`, `Note over ${ends2}: ref${has(x.label) ? ' ' + enc(x.label) : ''}`);
|
|
9171
|
+
continue;
|
|
9172
|
+
}
|
|
9173
|
+
const s = sortOf.get(x);
|
|
9174
|
+
if (s === 'create') put(d, ...heads.get(x.to).map((l, k, a) => (k === a.length - 1 ? 'create ' + l : l)));
|
|
9175
|
+
if (s === 'delete') put(d, `destroy ${names.get(x.to)}`);
|
|
9176
|
+
empty(x, 'label');
|
|
9177
|
+
msgAt.set(x, { line: out.length, pad: ' '.repeat(d + 1), h: idOf(x.id, `${x.from}-${x.to}`), parent: top() });
|
|
9178
|
+
out.push(null);
|
|
9179
|
+
}
|
|
9180
|
+
while (stack.length) finish();
|
|
9181
|
+
for (const f of kids.get(null) || []) if (!done.has(f)) { begin(f); finish(); }
|
|
9182
|
+
for (const f of frags) if (!done.has(f)) { skip(f); f.members.forEach(skip); } // inside a fragment that is not one run
|
|
9183
|
+
|
|
9184
|
+
// the replies' calls as the reader will find them, from where the messages are written
|
|
9185
|
+
const msgs = [...msgAt.keys()], placed = frags.filter(f => done.has(f)).map(f => ({ ...f, parent: parentOf.get(f) }));
|
|
9186
|
+
const [found, forced] = msMatch(msgs.map(x => ({ x, id: x.id, from: x.from, to: x.to, sort: sortOf.get(x), parent: msgAt.get(x).parent,
|
|
9187
|
+
want: sortOf.get(x) === 'reply' && has(x.props?.replyTo) ? x.props.replyTo : undefined })), placed);
|
|
9188
|
+
const callOf = new Map([...found].map(([r, c]) => [r.x, c.x])), lastReply = new Map(), lost = [];
|
|
9189
|
+
for (const [r, c] of callOf) lastReply.set(c, r);
|
|
9190
|
+
const arrows = { call: '->>', async: '-)', reply: '-->>', create: '->>', delete: '-x' };
|
|
9191
|
+
for (const x of msgs) {
|
|
9192
|
+
const s = sortOf.get(x), { line, pad, h } = msgAt.get(x), plus = s === 'call' && lastReply.has(x) ? '+' : '', minus = s === 'reply' && lastReply.get(callOf.get(x)) === x ? '-' : '';
|
|
9193
|
+
if (s === 'reply' && (callOf.get(x)?.id ?? undefined) !== (x.props?.replyTo ?? undefined)) {
|
|
9194
|
+
lost.push(`${flat(x.id)}: ${flat(has(x.props?.replyTo) ? `replyTo ${x.props.replyTo}, which is not an earlier call the reply goes back along` : 'no replyTo, though the reader finds a call')}`);
|
|
9195
|
+
}
|
|
9196
|
+
out[line] = [...(h ? [`%% id: ${h}`] : []), ...(forced.has(x) ? [`%% reply-to: ${callOf.get(x).id}`] : []),
|
|
9197
|
+
`${names.get(x.from)}${arrows[s]}${plus}${minus}${names.get(x.to)}:${has(x.label) ? ' ' + enc(x.label) : ''}`].map(l => pad + l).join('\n');
|
|
9198
|
+
}
|
|
9199
|
+
notWritten.splice(replySlot, 0, ...lost);
|
|
9200
|
+
for (const x of notWritten) out.push(` %% not written: ${x}`);
|
|
9201
|
+
return out.join('\n') + '\n';
|
|
9202
|
+
}
|