umlimator 0.18.0 → 0.19.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.
@@ -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. Empty in
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="${map ? 'group' : 'img'}" aria-label="${esc(opts.title || 'Diagram')}" xmlns="http://www.w3.org/2000/svg" preserveAspectRatio="xMidYMid meet">
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
- if (map) { // each line's focus target: after the cards, so lines follow them in the Tab order (#43), under the labels; the mouse still hits the line
793
- s += '</g><g class="keys">';
794
- edges.forEach((e, i) => { const said = e.label ? `: ${e.label}${e.sublabel ? ` (${e.sublabel})` : ''}` : '';
795
- const name = `${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('; ')}` : ''}`;
796
- s += `<g class="um-key line-key${e.weight > 1 ? ' wt-' + e.weight : ''}" data-i="${i}" tabindex="0" role="button" aria-pressed="false" aria-label="${esc(name)}"><path d="${roundPath(drawn[i])}" class="key-ring"/></g>`; });
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 = {}, 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);
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
- const familyDefs = dg.containers || [], tagged = n => map && (n.author || n.k === 'm-ext'); // tagged: a name tag hangs under the card
1020
- const framesNow = () => { const cache = {};
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 => ({x:n.x - 36,y:n.y - 14,w:n.w + 72,h:n.h + 44 + (tagged(n) ? 14 : 0)})), ...children];
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
- return cache[f.id] = {...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};
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 (map && !dg.abs && familyDefs.length) for (let pass = 0, BOXGAP = 32; pass < 4; pass++) {
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]; want(wantX, Math.floor(L.s.c1), L.x + L.w + BOXGAP - R.x); }
1051
- else if (a.r1 < b.r0 || b.r1 < a.r0) { const [T, U] = a.r1 < b.r0 ? [A, B] : [B, A]; want(wantY, Math.floor(T.s.r1), T.y + T.h + BOXGAP - U.y); }
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 boxEnd = map ? 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,
1097
- decoT: [[f.x + 8, Math.min(f.w - 16, textW(f.label || f.id, 11) + 16)], ...(f.tabs || []).map(t => [t.x - 6, t.w + 12])], decoB: [] }])) : {};
1098
- const end = id => byId[id] || boxEnd[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;
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
- const sp = vertical ? [n.y + 40, n.y + n.h - 16] : sideSpan(n, side) || [n.x, n.x + n.w];
1167
- let lo = vertical ? sp[0] : Math.max(sp[0], n.x + 20), hi = vertical ? sp[1] : Math.min(sp[1], n.x + n.w - 20); if (hi < lo) lo = hi = (lo + hi) / 2;
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
- if (map) 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); });
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 (map) { const far = (v, bs) => keep.has(v) || bs.every(u => Math.abs(v - u) >= 8);
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
- if (map) families.forEach(f => { const h = { x: f.x + 8, y: f.y + 3, w: Math.min(f.w - 16, textW(f.label || f.id, 11) + 16), h: 22 }; reserved.push(h); placed.push(h);
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()];
@@ -2023,7 +2049,7 @@ function mapContext(model, opts = {}) {
2023
2049
  /* a layout may place only some cards: they keep their slots, the rest take their auto-layout slot or the next free
2024
2050
  row below it (a complete layout is used as it is) */
2025
2051
  function fillLayout(vid, L) {
2026
- const ids = (V[vid].nodeIds || []).filter(i => N[i]);
2052
+ 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
2053
  if (ids.every(i => Array.isArray(L[i]))) return L;
2028
2054
  const auto = autoPos(vid), out = {};
2029
2055
  ids.forEach(i => { if (Array.isArray(L[i])) out[i] = L[i]; });
@@ -2038,8 +2064,9 @@ function mapContext(model, opts = {}) {
2038
2064
  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
2065
  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
2066
  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
- function blockPos(vid) {
2042
- const v = V[vid], ids = (v.nodeIds || []).filter(i => N[i]), lp = loopsOf(v), boxes = (v.containers || []).filter(c => c && c.id);
2067
+ // A typed view whose type gives boxes (notes/stage-2.md §3.2) passes them, T: { boxes, flip }; their ids are not cards
2068
+ function blockPos(vid, T) {
2069
+ 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
2070
  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
2071
  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
2072
  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 +2122,10 @@ function mapContext(model, opts = {}) {
2095
2122
  }
2096
2123
  // 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
2124
  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
- const autoPos = vid => ownLayout(vid) ? flowPos(vid, { flip: ownLayout(vid).rankFlip }) : ((V[vid] || {}).containers || []).length ? blockPos(vid) : bestFlow(vid);
2125
+ // its boxes (registry.js): the type's, from the model; a map's are its containers
2126
+ const boxesOf = vid => { const T = ownLayout(vid); return T && T.boxes ? arrOf(T.boxes(D, V[vid])).filter(b => b && b.id != null) : []; };
2127
+ const autoPos = vid => { const T = ownLayout(vid), bx = boxesOf(vid);
2128
+ 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
2129
  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
2130
  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
2131
  /* A small plain view is routed a few ways and keeps the one the layout check scores best: cards level with their
@@ -2115,8 +2145,9 @@ function mapContext(model, opts = {}) {
2115
2145
  let scouting = false;
2116
2146
  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
2147
  const drawn = {}; // the way bestFlow kept, routed already: the view's next buildGeo takes it once instead of routing it again
2148
+ 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
2149
  function flowPos(vid, o = {}) {
2119
- const v = o.flip ? { ...V[vid], edges: viewEdges(V[vid]).map(e => o.flip(e) ? { ...e, from: e.to, to: e.from } : e) } : V[vid];
2150
+ const v = flipView(vid, o.flip);
2120
2151
  const lp = loopsOf(v), fwd = lp.edges.filter((e, k) => !lp.back.has(k) && e.from !== e.to);
2121
2152
  // 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
2153
  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 +2224,7 @@ function mapContext(model, opts = {}) {
2193
2224
  if (T) { // the type's layout (registry.js), in the host's measure (R2)
2194
2225
  const L = T.layout, memo = memos[vid] ||= {}, k = !override && L.key ? L.key(D, v) : null;
2195
2226
  if (k != null && memo.runKey === k) return memo.runGeo;
2196
- const pos = override || (v.layout && L.pins !== false ? fillLayout(vid, v.layout) : (layoutCache[vid] ||= autoPos(vid)));
2227
+ 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
2228
  const g = L.run ? L.run(D, v, { measure: M, pos, memo }) : L({ model: D, view: v, pos, memo });
2198
2229
  if (k != null) Object.assign(memo, { runKey: k, runGeo: g });
2199
2230
  return g;
@@ -2318,7 +2349,7 @@ function mapContext(model, opts = {}) {
2318
2349
  const viewKey = vid => {
2319
2350
  const v = V[vid], ids = (v.nodeIds || []).filter(i => N[i]), ns = ids.map(i => nodeIn(i, vid));
2320
2351
  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
2352
+ 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
2353
  };
2323
2354
  /* ---------- unfolding in place (#71): a stacked card opens into a box on the view it is on ----------
2324
2355
  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 +2783,8 @@ export function diff(before, after) {
2752
2783
  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
2784
  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
2785
  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');
2786
+ 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;
2787
+ 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
2788
  said.push((keys.includes('label') ? `renames ${o.label || o.id} to ${x.label || x.id}` : `changes ${name(n.id)}`) + (rest.length ? ` (${rest.join(', ')})` : '')); } });
2757
2789
  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
2790
  const ends = `${name(e.from)} to ${name(e.to)}`, lab = e.label ? ` (${e.label})` : '';
@@ -3465,7 +3497,10 @@ class Camera {
3465
3497
  core.Node and core.Line: a typed view's lines are typed, or validate() refuses them.
3466
3498
 
3467
3499
  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. */
3500
+ Alt + arrows move the card's pin, Delete hides it from this view, Shift + Delete deletes it from the model. A box the type
3501
+ gives (registry.js boxes) is selected as a container and takes F2, Delete and the panel as a card does; a card dropped in
3502
+ another box, or the Owner field, gives it a new owner (reparent). L on a line starts at its far end, with edits.connect;
3503
+ layout.pins false: no pin. src/typed/edit.js takes gestures over (TYPED.edit, registry.js). */
3469
3504
 
3470
3505
  const GENERIC_NODE = { type: 'core.Node', label: 'Node' }, GENERIC_LINE = { type: 'core.Line', label: 'Line' };
3471
3506
  const isRec = x => x !== null && typeof x === 'object' && !Array.isArray(x);
@@ -3475,7 +3510,7 @@ function elementsOf(m) {
3475
3510
  const at = new Map(), put = (x, kind, owner) => { if (!isRec(x) || typeof x.id !== 'string' || at.has(x.id)) return;
3476
3511
  at.set(x.id, { x, kind, owner }); arrOf(x.members).forEach(y => put(y, 'member', x)); };
3477
3512
  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')); } });
3513
+ 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
3514
  return at;
3480
3515
  }
3481
3516
 
@@ -3507,18 +3542,26 @@ function typedEditor(api) {
3507
3542
  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
3543
  const typeOf = x => typeof x.type === 'string' && x.type ? x.type : null; // a model may hold anything in type
3509
3544
  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
3545
+ 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
3546
  const selId = (at = elementsOf(model())) => { const id = idsOf(api.sel())[0]; return id && at.has(id) ? id : null; };
3512
3547
  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
3548
 
3514
3549
  /* the gestures */
3515
3550
  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
- const drop = (n, hint) => (hint.col !== n.col || hint.row !== n.row) && pin(n.id, hint.col, hint.row);
3518
- function remove(fromModel) { // Delete: out of this view; Shift + Delete: out of the model (risks.md E5). One transaction for the selection (E2)
3519
- const ids = [...new Set(api.picked().flatMap(idsOf))]; if (!can('delete') || !ids.length) return false;
3551
+ 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] }]);
3552
+ // 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
3553
+ // 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
3554
+ // picture before would land elsewhere and stretch a box over cards it does not hold: no pin, and a pin it had is removed.
3555
+ // into, from: the box under the drop, the box it was in
3556
+ // a re-own, by a drop or the Owner field: reparent, and the removal of a pin it had
3557
+ 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 }] : [])]; };
3558
+ 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)
3559
+ : can('move') && emit(reown(n.id, into ?? view().subject ?? null));
3560
+ const picks = () => [...new Set(api.picked().flatMap(idsOf))];
3561
+ 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)
3562
+ if (!can('delete') || !ids.length) return false;
3520
3563
  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 = [];
3564
+ 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
3565
  ids.forEach(id => { const e = at.get(id);
3523
3566
  if (own.has(id)) out.push({ op: 'delete', id }); // lives in this view only: hiding it is deleting it
3524
3567
  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 +3573,12 @@ function typedEditor(api) {
3530
3573
  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
3574
  }
3532
3575
  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 (a slot): stage 3's
3534
- emit(c ? arrOf(c(model(), view(), from, to, null, t)) : [{ op: 'add', element: { ...typed(t, 'line'), from, to, ...(isRec(t.props) ? { props: structuredClone(t.props) } : {}) }, view: api.view() }]); };
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;
3576
+ 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)
3577
+ 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 }
3578
+ function connect(from, to, ev, at = null) { // the relationship picker, when the type has more than one kind of line
3579
+ if (!can('add') || !from || !to || from === to && !I.self) return;
3537
3580
  const types = lineTypes();
3538
- if (types.length === 1) addLine(from, to, types[0]); else picker(to, types, t => addLine(from, to, t));
3581
+ if (types.length === 1) addLine(from, to, types[0], at); else picker(to, types, t => addLine(from, to, t, at));
3539
3582
  }
3540
3583
  function closeMenu(back) { const m = menu; if (!m) return false; menu = null; m.remove(); if (back) api.focus(); api.touch(); return true; }
3541
3584
  function picker(at, types, done) {
@@ -3571,10 +3614,10 @@ function typedEditor(api) {
3571
3614
  function linkKey(e) {
3572
3615
  const K = e.key;
3573
3616
  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; }
3617
+ 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
3618
  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
3619
  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 hit = api.geo().nodes.find(n => n.id !== link.from && String(api.nameOf(n.id)).toLowerCase().startsWith(link.find));
3620
+ 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
3621
  if (hit) { link.to = hit.id; mark(); } return true; }
3579
3622
  return false;
3580
3623
  }
@@ -3584,11 +3627,12 @@ function typedEditor(api) {
3584
3627
  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
3628
  if (!d || !n) return false; if (Number.isFinite(n.col)) pin(n.id, n.col + d[0], n.row + d[1]); return true; }
3586
3629
  if (K === 'F2' && s && can('rename')) { api.rename(); return true; }
3587
- if ((K === 'n' || K === 'N') && can('add')) { addNode(0); return true; }
3588
- if ((K === 'l' || K === 'L') && can('add') && s && s.type === 'node') {
3589
- link = { from: s.id, to: null, find: '', t: 0 }; api.touch();
3590
- say(`Line from ${api.nameOf(s.id)}: arrows or type a name, Enter to choose, Escape to stop`); return true; }
3591
- if (K === 'Delete' && s) { remove(e.shiftKey); return true; }
3630
+ if ((K === 'n' || K === 'N') && can('add')) { I.addNode(0); return true; }
3631
+ const from = s && (s.type === 'node' ? s.id : s.type === 'edge' && hook('connect') && (api.geo().edges.find(x => idsOf(s).includes(x._id)) || {}).to);
3632
+ if ((K === 'l' || K === 'L') && can('add') && from) {
3633
+ link = { from, to: null, find: '', t: 0 }; api.touch();
3634
+ say(`Line from ${api.nameOf(from)}: arrows or type a name, Enter to choose, Escape to stop`); return true; }
3635
+ if (K === 'Delete' && s) { I.remove(e.shiftKey); return true; }
3592
3636
  return false;
3593
3637
  }
3594
3638
 
@@ -3619,6 +3663,12 @@ function typedEditor(api) {
3619
3663
  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
3664
  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
3665
  else h += text('p:' + f.key, f.kind === 'list' ? arrOf(v).join(', ') : v ?? '', name, key + (f.kind === 'list' ? ' data-list' : '')); });
3666
+ // the Owner field (notes/stage-2.md §8): the owners the type offers, less the element and what it holds, and none
3667
+ 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; };
3668
+ // 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
3669
+ const offer = Array.isArray(os) && [{ id: '', label: 'none' }, ...os.filter(o => isRec(o) && o.id != null && !under(o.id))];
3670
+ 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 });
3671
+ 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
3672
  const rest = schema.length ? [] : Object.entries(P); // no schema: what it holds, to read
3623
3673
  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
3674
  const M = def().member;
@@ -3657,6 +3707,7 @@ function typedEditor(api) {
3657
3707
  const r = M && M.add(value, x.x); if (r && r.error) return bad(r.error);
3658
3708
  const prev = arrOf(x.x.members).filter(isRec).pop(); t.value = ''; t.removeAttribute('aria-invalid');
3659
3709
  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 }]); }
3710
+ 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
3711
  if (!can('rename') || (!id && d.m == null)) return;
3661
3712
  let value = t.type === 'checkbox' ? t.checked : t.value.trim();
3662
3713
  if (t.type !== 'checkbox') { if (t.value === d.was) return;
@@ -3680,12 +3731,12 @@ function typedEditor(api) {
3680
3731
  panel.className = 'um-editor'; panel.setAttribute('aria-label', 'Edit this diagram');
3681
3732
  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
3733
  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);
3734
+ if (d.add != null) I.addNode(+d.add);
3684
3735
  else if (d.go != null) { if (!d.go || !api.selectId(top(d.go))) api.status('Not drawn in this view'); }
3685
3736
  else if (d.show != null) show(d.show);
3686
- else if (d.del != null) { if (can('delete')) emit([{ op: 'delete', id: d.del }]); }
3737
+ else if (d.del != null) I.remove(true, [d.del]);
3687
3738
  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);
3739
+ else if (d.hide != null || d.kill != null) I.remove(d.kill != null);
3689
3740
  else if (d.orphans != null) { orphans = b.checked; put('.um-els', outline()); } });
3690
3741
  panel.addEventListener('change', e => { if (e.target.dataset.orphans == null) commit(e.target); });
3691
3742
  panel.addEventListener('keydown', e => { e.stopPropagation(); const t = e.target, K = e.key; // the panel's keys stay in it
@@ -3702,12 +3753,14 @@ function typedEditor(api) {
3702
3753
  refresh();
3703
3754
  }
3704
3755
 
3705
- return { on, key, label, drop, connect, mount, refresh,
3756
+ const I = { on, key, label, drop, connect, mount, refresh, addNode, remove,
3706
3757
  paint: () => { if (panel && on()) put('.um-props', props()); },
3707
- add: () => addNode(0),
3758
+ add: () => I.addNode(0),
3708
3759
  cancel: () => { const a = endLink(), b = closeMenu(false); return a || b; },
3709
3760
  linking: () => !!link || !!menu,
3710
3761
  width: () => panel ? panel.offsetWidth + 8 : 0 };
3762
+ if (TYPED.edit) TYPED.edit(I, { api, model, view, def, hook, emit, typed, nodeTypes, picker, picks });
3763
+ return I;
3711
3764
  }
3712
3765
  const h = (html) => { const t = document.createElement('template'); t.innerHTML = html.trim(); return t.content.firstElementChild; };
3713
3766
 
@@ -3802,14 +3855,15 @@ export function render(container, model, options = {}) {
3802
3855
  const upOf = () => (P.upTarget ? P.upTarget() : trail.length > 1 ? trail[trail.length - 2] : (ctx.V[cur] || {}).parent) || null;
3803
3856
  const nodeRef = id => ({ type: 'node', id, view: cur });
3804
3857
  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(nodeRef), copyRef(sel)] : []; // every selected thing, the latest last (more: other selected cards)
3858
+ const picked = () => sel ? [...more, sel].map(copyRef) : []; // every selected thing, the latest last (more: the others' refs)
3806
3859
  const say = t => { const l = root.querySelector('.um-live'); if (l) l.textContent = t; };
3860
+ 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
3861
  const nameOf = id => ctx.nodeIn(id, cur).label || ((geo.families || []).find(f => f.id === id) || {}).label || id;
3808
3862
  let said = '[]'; // the selection last reported: a select event means it changed, or a click asked again (again)
3809
3863
  const emitSel = (again = false) => {
3810
3864
  const now = JSON.stringify(picked()); if (now === said && !again) return; said = now;
3811
- say(!sel ? 'Nothing selected' : sel.type === 'node' ? nameOf(sel.id) + (more.length ? `, ${more.length + 1} cards selected` : ', selected')
3812
- : sel.type === 'edge' ? (e => e ? `Line from ${nameOf(e.from)} to ${nameOf(e.to)}, selected` : 'Line selected')(geo.edges[edgeIndex(sel)]) : `Box ${(svg.querySelector(`.family[data-family="${CSS.escape(sel.id)}"] .family-title`) || {}).textContent || sel.id}, selected`);
3865
+ 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)])
3866
+ : `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
3867
  emit('select', { target: copyRef(sel), targets: picked() });
3814
3868
  };
3815
3869
  const edgeRef = e => ({ type: 'edge', ids: edgeRefIds(e), view: cur });
@@ -3948,25 +4002,25 @@ export function render(container, model, options = {}) {
3948
4002
  if (can('add') && sel && sel.type === 'node' && geo.byId[sel.id]) { const n = geo.byId[sel.id];
3949
4003
  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
4004
  svg.querySelectorAll('.sel,.nb,.hot').forEach(g => g.classList.remove('sel', 'nb', 'hot')); stage.classList.remove('focusing');
3951
- svg.querySelectorAll('.node').forEach(g => g.setAttribute('aria-pressed', !!sel && sel.type === 'node' && (g.dataset.id === sel.id || more.includes(g.dataset.id))));
3952
- const li = sel && sel.type === 'edge' ? edgeIndex(sel) : -1; svg.querySelectorAll('.line-key').forEach(k => k.setAttribute('aria-pressed', +k.dataset.i === li));
4005
+ const pk = picked(), ns = pk.filter(r => r.type === 'node').map(r => r.id), li = pk.filter(r => r.type === 'edge').map(edgeIndex);
4006
+ 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
4007
  if (!sel) return;
3954
4008
  if (sel.type === 'node') {
3955
4009
  const nid = sel.id, ups = new Set(), downs = new Set();
3956
4010
  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', id === nid || more.includes(id)); g.classList.toggle('nb', ups.has(id) || downs.has(id)); });
4011
+ 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
4012
  svg.querySelectorAll('.edge-g').forEach(g => g.classList.toggle('hot', g.dataset.from === nid || g.dataset.to === nid));
3959
4013
  svg.querySelectorAll('.e-lab').forEach(g => { const e = geo.edges[g.dataset.i]; g.classList.toggle('hot', e.from === nid || e.to === nid); });
3960
4014
  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
4015
  stage.classList.add('focusing');
3962
4016
  } else if (sel.type === 'edge') {
3963
- const i = edgeIndex(sel), e = geo.edges[i];
3964
- svg.querySelectorAll(`.edge-g[data-i="${i}"], .e-lab[data-i="${i}"]`).forEach(g => g.classList.add('hot'));
3965
- svg.querySelectorAll('.node').forEach(g => g.classList.toggle('nb', g.dataset.id === e.from || g.dataset.id === e.to));
4017
+ const e = geo.edges[edgeIndex(sel)];
4018
+ 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
4019
  stage.classList.add('focusing');
3967
4020
  } else if (sel.type === 'container') {
3968
4021
  const f = svg.querySelector(`.family[data-family="${CSS.escape(sel.id)}"]`); if (f) f.classList.add('sel');
3969
4022
  }
4023
+ 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
4024
  }
3971
4025
  function select(ref, o = {}) {
3972
4026
  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 +4044,14 @@ export function render(container, model, options = {}) {
3990
4044
  return best;
3991
4045
  }
3992
4046
 
3993
- /* several cards: Shift (or Cmd / Ctrl) + click adds a card to the selection, or takes it out */
3994
- function toggle(id) {
3995
- if (id === sel.id) { if (!more.length) return select(null); sel = nodeRef(more.pop()); }
3996
- else if (more.includes(id)) more = more.filter(x => x !== id);
3997
- else { more.push(sel.id); sel = nodeRef(id); }
4047
+ /* 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
4048
+ Shift + Enter on its ring (notes/stage-3.md §5.1). ref: a nodeRef or edgeRef */
4049
+ const kinds = () => typed() ? ['node', 'edge'] : ['node'], refKey = r => JSON.stringify([r.type, r.id, r.ids]);
4050
+ function toggle(ref) {
4051
+ const k = refKey(ref);
4052
+ if (k === refKey(sel)) { if (!more.length) return select(null); sel = more.pop(); }
4053
+ else if (more.some(r => refKey(r) === k)) more = more.filter(r => refKey(r) !== k);
4054
+ else { more.push(sel.type === 'node' ? nodeRef(sel.id) : sel); sel = ref; }
3998
4055
  sub = null; selectNew = false; paint(); emitSel();
3999
4056
  }
4000
4057
 
@@ -4121,7 +4178,7 @@ export function render(container, model, options = {}) {
4121
4178
  if (cur && ctx.geoCache[cur] && ctx.geoCache[cur].shapes !== noteFor(cur)) {
4122
4179
  const keep = sel, keepMore = more, touched = cam.touched;
4123
4180
  mount(cur); cam.set({}); cam.touched = touched;
4124
- if (keep && exists(keep)) { sel = keep; more = keep.type === 'node' ? keepMore.filter(id => geo.byId[id]) : []; paint(); }
4181
+ if (keep && exists(keep)) { sel = keep; more = keepMore.filter(exists); paint(); }
4125
4182
  emitSel(); }
4126
4183
  touch(); return true;
4127
4184
  }
@@ -4137,7 +4194,7 @@ export function render(container, model, options = {}) {
4137
4194
 
4138
4195
  /* keys on the canvas; the page forwards the same keys when the focus is elsewhere */
4139
4196
  function key(e) {
4140
- const k = e.key, g = e.target && e.target.closest && e.target.closest('.node');
4197
+ 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
4198
  if (k === 'Escape' && cancelGesture()) return true; // Escape stops a gesture in progress before anything else
4142
4199
  if (story) { const r = storyKey(e); if (r != null) return r; } // a story's keys first (#69)
4143
4200
  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 +4202,8 @@ export function render(container, model, options = {}) {
4145
4202
  if (e.altKey) return false;
4146
4203
  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
4204
  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
- if (k === 'Enter' && lk && geo.edges[+lk.dataset.i]) select(edgeRef(geo.edges[+lk.dataset.i]), { again: true }); // as a click on the line (#43)
4205
+ const add = k === 'Enter' && e.shiftKey && sel && kinds().includes(sel.type) && typed(); // Shift + Enter: into the selection, or out of it
4206
+ 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
4207
  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
4208
  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
4209
  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 +4213,11 @@ export function render(container, model, options = {}) {
4155
4213
  else if (k === '1') cam.actual();
4156
4214
  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
4215
  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 }); }
4216
+ 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
4217
  else if (k === 'Escape' || k === 'Backspace') { if (sel) select(null); else up(); }
4160
4218
  else if ((k === 'f' || k === 'F') && !reducedMotion()) setFlow(!stage.classList.contains('flowing')); // under reduced motion no flow is drawn, and ≋ isn't offered
4161
4219
  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
- const tg = svg.querySelector(`.node[data-id="${CSS.escape(to.id)}"]`); tg.focus({ preventScroll: true }); cam.reveal({ x: to.x, y: to.y, w: to.w, h: to.h }); }
4220
+ (ringOf(to.id) || nodeG(to.id)).focus({ preventScroll: true }); cam.reveal({ x: to.x, y: to.y, w: to.w, h: to.h }); }
4163
4221
  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
4222
  else return false;
4165
4223
  readerMoved(); // a key the map acted on: a story playing waits
@@ -4221,14 +4279,16 @@ export function render(container, model, options = {}) {
4221
4279
  function hintAt(x, y) { // a point in the view, and on a column view its column and row
4222
4280
  const h = { x: Math.round(x), y: Math.round(y) };
4223
4281
  if (!gridView()) return h;
4282
+ 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
4283
+ if (T) [x, y] = [y, x];
4224
4284
  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.x + n.w / 2, 0) / ns.length; };
4285
+ 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
4286
  const first = cols[0], last = cols[cols.length - 1], step = cols.length > 1 ? (mid(last) - mid(first)) / (last - first) : 260;
4227
4287
  let col = first, best = Infinity; cols.forEach(c => { const d = Math.abs(mid(c) - x); if (d < best) { best = d; col = c; } });
4228
4288
  if (x > mid(last) + step / 2) col = last + Math.round((x - mid(last)) / step);
4229
4289
  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.y - a.y) / (b.row - a.row) : 108;
4231
- h.col = col; h.row = Math.max(0, Math.round((y - (a.y - a.row * pitch) - 32) / pitch));
4290
+ 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;
4291
+ h.col = col; h.row = Math.max(0, Math.round((y - (Y(a) - a.row * pitch) - 32) / pitch));
4232
4292
  return h;
4233
4293
  }
4234
4294
  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 +4362,7 @@ export function render(container, model, options = {}) {
4302
4362
  ['pointerup', 'pointercancel'].forEach(t => stage.addEventListener(t, () => { pressed = false; }, true));
4303
4363
  function focusMap(ref = sel, reveal = true) {
4304
4364
  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 && [...svg.querySelectorAll('.node')].find(x => x.dataset.id === id); // no CSS.escape: jsdom has none
4365
+ const id = ref && (ref.type === 'node' ? ref.id : ref.node), g = id && svg && (ringOf(id) || nodeG(id));
4306
4366
  if (!g) return stage.focus({ preventScroll: true });
4307
4367
  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
4368
  }
@@ -4338,7 +4398,7 @@ export function render(container, model, options = {}) {
4338
4398
  if (stale) { stale = false; swallow = performance.now() + 400; return; } // the release of a gesture Escape or a new picture cancelled
4339
4399
  if (conn) { const c = conn; conn = null; if (c.path) c.path.remove(); swallow = performance.now() + 400;
4340
4400
  const g = root.elementFromPoint(e.clientX, e.clientY), t = g && g.closest && g.closest('.node');
4341
- if (t && t.dataset.id !== c.from && geo.byId[t.dataset.id]) typed() ? tx.connect(c.from, t.dataset.id) : change({ op: 'add', kind: 'edge', from: c.from, to: t.dataset.id });
4401
+ 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
4402
  return; }
4343
4403
  if (!drag) return;
4344
4404
  const d = drag; drag = null;
@@ -4349,7 +4409,7 @@ export function render(container, model, options = {}) {
4349
4409
  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
4410
  const fromId = from ? from.id : null, toId = into ? into.id : null;
4351
4411
  let ev = null;
4352
- if (typed()) ev = tx.drop(n, hint); // a typed view: the card's pin
4412
+ 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
4413
  else if (toId !== fromId && can('move')) ev = change({ op: 'move', target: nodeRef(n.id), to: toId, hint });
4354
4414
  else if (gridView() && hint.col === n.col && can('reorder')) {
4355
4415
  const sib = siblings(n), at = sib.indexOf(n), i = sib.filter(m => m !== n && m.y + m.h / 2 < y).length;
@@ -4362,14 +4422,20 @@ export function render(container, model, options = {}) {
4362
4422
  stage.addEventListener('pointerup', endGesture);
4363
4423
  stage.addEventListener('pointercancel', () => { if (drag && drag.ghost) drag.ghost.remove(); if (conn && conn.path) conn.path.remove(); drag = conn = null; stale = false; });
4364
4424
  /* 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
- function withPlaces(m, use = null) { // use: an edit the update applied, { view, hv: id -> its place, ex: a reordered group's places }
4425
+ 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
4426
+ that changed the box an element shown before and after is drawn in lays the view out again, so every box holds exactly its
4427
+ contents (notes/stage-2.md §3.2); hiding or deleting a card keeps the places */
4428
+ 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) : []; };
4429
+ 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
4430
+ 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
4431
  if (options.stableLayout === false) return m;
4368
4432
  const out = structuredClone(m);
4369
4433
  (out.views || []).forEach(v => {
4370
- const was = placed[v.id], { hv = {}, ex = {} } = use && use.view === v.id ? use : {};
4434
+ const bx = boxesIn(out, v), pv = viewIn(prev, v.id), A = ownersOn(boxesIn(prev, pv)), B = ownersOn(bx);
4435
+ 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));
4436
+ const was = again ? null : placed[v.id], { hv = {}, ex = {} } = use && use.view === v.id && !again ? use : {};
4371
4437
  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];
4438
+ 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
4439
  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
4440
  ids.forEach(id => { if (Array.isArray(mine[id])) { L[id] = mine[id]; taken.add(key(mine[id])); } }); // the host's own layout
4375
4441
  ids.forEach(id => { if (!L[id] && ex[id]) put(id, ex[id]); }); // a reordered column group
@@ -4399,7 +4465,7 @@ export function render(container, model, options = {}) {
4399
4465
  sub = null;
4400
4466
  if (ref) {
4401
4467
  if (!activate(ref, e)) return; // the host took the click (a chip as a link, say): the selection stays as it was
4402
- if (ref.type === 'node' && (e.shiftKey || e.metaKey || e.ctrlKey) && sel && sel.type === 'node') return toggle(ref.id); // several cards
4468
+ if ((e.shiftKey || e.metaKey || e.ctrlKey) && sel && kinds().includes(ref.type) && kinds().includes(sel.type)) return toggle(ref); // several cards, and lines
4403
4469
  return select(ref, { again: ref.type === 'node' }); }
4404
4470
  select(null);
4405
4471
  if (P.onBackground) P.onBackground();
@@ -4507,10 +4573,10 @@ export function render(container, model, options = {}) {
4507
4573
  // a card E or Alt + click asked to unfold before its view was loaded unfolds once a model brings that view (#71)
4508
4574
  const brought = []; Object.entries(asked).forEach(([vid, m]) => m.forEach((c, nid) => { if (!arrOf(model2.views).some(v => v && v.id === c)) return;
4509
4575
  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;
4576
+ const old = ctx, keys = {}, wantNew = selectNew && !own, before = current;
4511
4577
  old.views.forEach(v => { keys[v.id] = relayout ? null : old.viewKey(v.id); });
4512
4578
  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 });
4579
+ 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
4580
  Object.keys(expanded).forEach(v => { expanded[v] = ctx.unfolded[v] || []; }); // a card or inner view the model no longer has folds
4515
4581
  if (!own) selectNew = false;
4516
4582
  ctx.views.forEach(v => { const id = v.id; if (keys[id] == null || keys[id] !== ctx.viewKey(id)) return;
@@ -4535,7 +4601,7 @@ export function render(container, model, options = {}) {
4535
4601
  if (box) unfolded(box, true); // a card asked to unfold earlier: its box is selected
4536
4602
  else if (wantNew && use && p.op === 'add' && geo.byId[card]) select(nodeRef(card), { reveal: false }); // the card added, as the reader named it
4537
4603
  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 = keep.type === 'node' ? keepMore.filter(id => geo.byId[id]) : []; paint(); }
4604
+ if (keep && exists(keep)) { sel = keep; more = keepMore.filter(exists); paint(); }
4539
4605
  if (!unfolding) emitSel(); }
4540
4606
  if (!relayout) easeIn(...prev); // a font that came in late moves nothing on purpose: no ease
4541
4607
  touch();
@@ -5669,9 +5735,10 @@ export function mountPage(container, model, options = {}) {
5669
5735
  - The cover: the title, the date, the model's file, a summary line and the contents, each view with its page.
5670
5736
  - Each view, from a page of its own: a header (the view's path and its caption), its picture (toSVG()'s, in vectors)
5671
5737
  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. They fill two columns below the picture, or one column beside a picture too tall to leave room
5673
- below. A table that runs out of room goes on in the next column, under its heading and header row again; a row is
5674
- never cut, and a heading never ends a column.
5738
+ lines and boxes (on a typed view: each element's type and members, as its type prints them, and each line's kind).
5739
+ They fill two columns below the picture, or one column beside a picture too tall to leave room below. A table that
5740
+ runs out of room goes on in the next column, under its heading and header row again; a row is never cut, and a
5741
+ heading never ends a column.
5675
5742
  - The rules, when the model has any: each holding or broken in the views printed, and what breaks it. */
5676
5743
  function printDoc(X, model, Q) {
5677
5744
  const { D, views, V, N, chain, nodeIn, viewEdges, geoCache } = X.ctx();
@@ -5761,6 +5828,8 @@ function printDoc(X, model, Q) {
5761
5828
  shown.forEach(([v, svg], vi) => {
5762
5829
  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
5830
  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);
5831
+ 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"
5832
+ const asText = (diagramOf(v)?.member || {}).print || (m => m.label ?? m.id); // a member as its type writes it, else its name
5764
5833
  const up = chain(v.id).slice(0, -1).map(id => `<span class="up">${T(V[id])} › </span>`).join('');
5765
5834
  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
5835
  const body = sheet(head(false), vi);
@@ -5775,11 +5844,12 @@ function printDoc(X, model, Q) {
5775
5844
  const lay = (f, more) => { s.setAttribute('width', b.width * k * f); s.setAttribute('height', b.height * k * f); body.querySelector('.cols')?.remove();
5776
5845
  const first = cols(body, side ? 1 : 2); if (side) first[0].append(lg); return pour(paging(more && head, 2, first), tables); };
5777
5846
  const tables = [
5778
- { title: `Cards <span>${ids.length}</span>`, cls: 'cards', head: '<th>Card</th><th>Kind</th><th>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;
5847
+ { 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
5848
  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><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>`; }) },
5781
- { title: `Lines <span>${es.length}</span>`, cls: 'lines', head: `${seq || stepped ? '<th class="n">#</th>' : ''}<th>From → to</th><th>Label</th><th>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';
5782
- 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) : `<span class="up">${w}</span>`}</td><td>${esc(e.sublabel || '')}</td></tr>`; }) },
5849
+ + (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>`
5850
+ : `<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>`); }) },
5851
+ { 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';
5852
+ 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
5853
  { 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
5854
  ].filter(x => x.rows.length);
5785
5855
  [1, .9, .8, .7].some(f => k * f >= Math.min(k, .6) && lay(f)) || lay(1, true);
@@ -5809,7 +5879,7 @@ function printDoc(X, model, Q) {
5809
5879
 
5810
5880
  // 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
5881
  // 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'];
5882
+ 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
5883
  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
5884
  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
5885
 
@@ -5817,7 +5887,7 @@ const geometryKey = dg => JSON.stringify([VIEW_GEO.map(k => dg[k]), (dg.nodes ||
5817
5887
  const SIDE_T = { L: 'T', T: 'L', R: 'B', B: 'R' }, swapXY = p => [p[1], p[0]];
5818
5888
  const flipBox = r => { if (r) [r.x, r.y, r.w, r.h] = [r.y, r.x, r.h, r.w]; };
5819
5889
  function transpose(g) {
5820
- [...g.nodes, ...(g.families || []), g.bounds].forEach(flipBox);
5890
+ [...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
5891
  (g.groups || []).forEach(r => { [r.x, r.y, r.x2, r.y2] = [r.y, r.x, r.y2, r.x2]; });
5822
5892
  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
5893
  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 +5905,8 @@ function repaint(g, was, dg) {
5835
5905
 
5836
5906
  /* dg: layoutGraph's input, with `direction` ("right", the default, or "down"). memo: an object kept for the view between
5837
5907
  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. */
5908
+ routed. memo.runs counts the layouts. dg.reroute false: lines that had to cross are not routed again in a new order (the
5909
+ rip-up and reroute pass, layered.js), for a type whose edits must be quick (risks.md P1); a map never sets it. */
5839
5910
  function layoutTyped(dg, memo) {
5840
5911
  const key = memo && geometryKey(dg);
5841
5912
  if (memo && memo.key === key) { const g = repaint(memo.g, memo.dg, dg); memo.dg = dg; return g; }
@@ -5912,7 +5983,7 @@ TYPED.fan = (free, { simplify, through, STUB, SEP }) => { const pairs = new Map(
5912
5983
 
5913
5984
  // ---- 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
5985
  // 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
5986
+ // 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
5987
  // ('attributes', 'operations', 'literals'), each member in the model's envelope ({ type, label, props }); collapsed
5917
5988
  // [compartment names], hidden with no divider; rows: at most that many rows in a compartment, then "+k more" (stage 1 note §5)
5918
5989
  const CL = { pad: 8, name: 13, row: 11, st: 14, nl: 18, rl: 15, gap: 5, empty: 10, minW: 96, maxW: 320 };
@@ -5944,7 +6015,7 @@ function classSvg(n, c, f) { // its outline, its head, then each compartment und
5944
6015
  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
6016
  let ty = y + CL.gap; const hd = [];
5946
6017
  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>`);
6018
+ 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
6019
  out.push(`<g class="c-head" data-band="${r2(y)} ${r2(y + head)}">${hd.join('')}</g>`);
5949
6020
  let top = y + head;
5950
6021
  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 +6083,260 @@ TYPED.lines = ({ id, edges, drawn, lstyle, MC }) => {
6012
6083
  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
6084
  };
6014
6085
  };
6086
+ /* 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
6087
+ (risks.md C5): it fills TYPED.edit (registry.js), which typedEditor (src/canvas/edit.js) calls with its gestures, I, to take
6088
+ some over, and c, what they need of its insides. Without it a typed view is edited as in 0.17.0.
6089
+ Each hook of the type's `edits` returns commands (one transaction, emitted as an `edit` event), { commands, say } (emitted,
6090
+ and `say` said on the hint line), null to leave the gesture to the default, or { error }, said on the hint line, with
6091
+ nothing emitted (S5):
6092
+ - connect(model, view, from, to, at, line): a line drawn by pointer (at: the slot nearest the release) or by L (the slot
6093
+ after the element selected, else the last). A line from an element to itself is drawn too, by pointer or by typing its
6094
+ own name after L (a self-message, a reflexive association). A press on a lifeline's stem (SHAPE.lifeline's .ll-hit)
6095
+ draws a line too, released on another stem or its own.
6096
+ - moveTo(model, view, id, at): a card or line dropped (the slot nearest the drop: a line's pointer, a card's edge that
6097
+ 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
6098
+ cards (nodeIds) are drawn left of pt, measured on the canvas in the host's font), Alt + arrows ({ by: [dx, dy] }), or a
6099
+ member's ↑ and ↓ in the panel and Alt + Up/Down on it ({ by: [0, ±1] }; null: the member list's own move). Null: the
6100
+ card's pin, as before, unless the type has layout.pins false.
6101
+ - wrap(model, view, ids, kind): W, then a picker of the type's `wraps` (strings, or entries with a label).
6102
+ - add(model, view, entry, label, at): the palette and N, once the name is typed; at as for L, with sel, the ids selected.
6103
+ - remove(model, view, ids, fromModel): Delete (fromModel false), Shift + Delete, the panel's buttons and the outline's ✕.
6104
+ A slot is an entry of the geometry's `slots` ({ index, y, parent, after }, src/layout/timeaxis.js; x too where a type has
6105
+ one); with none, at is null. A view's fragments (view.fragments) are elements too: selected, they show their properties and
6106
+ their members (operands) in the panel, as a card does; a double-click on a selected member drawn as a card (an operand's
6107
+ guard), on a selected lifeline's name, or on a selected line, renames it, as F2 does. */
6108
+
6109
+ const DIR = { ArrowLeft: [-1, 0], ArrowRight: [1, 0], ArrowUp: [0, -1], ArrowDown: [0, 1] };
6110
+
6111
+ TYPED.edit = (I, c) => {
6112
+ const { api, model, view, hook, emit } = c, base = { ...I }, can = api.can;
6113
+ I.self = true;
6114
+ const slots = () => arrOf((api.geo() || {}).slots).filter(isRec);
6115
+ const near = (x, y) => { let best = null, bd = Infinity; // the slot nearest a point of the picture
6116
+ 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; };
6117
+ 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; };
6118
+ const world = ev => { const r = api.stage.getBoundingClientRect(); return api.cam().toWorld(ev.clientX - r.left, ev.clientY - r.top); };
6119
+ // what a hook returned: null, the default; { error }, said; else its commands
6120
+ const ran = (r, dflt) => { if (r == null) return dflt();
6121
+ if (isRec(r) && r.error != null) { api.status(String(r.error)); api.say(String(r.error)); return false; }
6122
+ 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); }
6123
+ return emit(arrOf(r)); };
6124
+ I.ran = ran; // connect's result, read by the core's addLine
6125
+
6126
+ I.connect = (from, to, ev, at) => base.connect(from, to, ev, at !== undefined ? at : ev ? near(...world(ev)) : keyAt());
6127
+ 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);
6128
+ 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;
6129
+ 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(); };
6130
+ I.addNode = i => { const h = hook('add'), t = c.nodeTypes()[i], cam = api.cam(); if (!h || !t || !can('add') || !cam) return base.addNode(i);
6131
+ const s = keyAt(), at = s && { ...s, sel: c.picks() }; // where the selection is now, before the name field takes the focus
6132
+ api.editor({ x: cam.s.cx - 80, y: cam.s.cy - 13, w: 160, h: 26 }, '', label => ran(h(model(), view(), t, label, at),
6133
+ () => emit([{ op: 'add', element: { ...c.typed(t, 'node'), label }, view: api.view() }]))); };
6134
+ I.remove = (fromModel, ids = c.picks()) => { const h = hook('remove'), dflt = () => base.remove(fromModel, ids);
6135
+ return h && can('delete') && ids.length ? ran(h(model(), view(), ids, !!fromModel), dflt) : dflt(); };
6136
+ 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
6137
+ const h = hook('wrap'), list = arrOf(c.def().wraps), opts = list.map(w => isRec(w) ? w : { label: String(w) }), g = api.geo();
6138
+ const at = ids.find(id => Object.hasOwn(g.byId || {}, id)) ?? (g.edges.find(x => ids.includes(x._id)) || {}).to;
6139
+ if (!opts.length || at == null) return api.status('Nothing to wrap it in');
6140
+ c.picker(at, opts, t => ran(h(model(), view(), ids, list[opts.indexOf(t)]), () => true));
6141
+ const m = api.stage.querySelector('.um-pick'); if (m) m.setAttribute('aria-label', 'Wrap in');
6142
+ }
6143
+ I.key = e => {
6144
+ if (I.linking()) return base.key(e);
6145
+ const K = e.key, ids = c.picks(), mv = hook('moveTo');
6146
+ 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; }
6147
+ if ((K === 'w' || K === 'W') && !e.altKey && hook('wrap') && can('add') && ids.length) { wrap(ids); return true; }
6148
+ return base.key(e);
6149
+ };
6150
+
6151
+ /* a line dragged, when the type has moveTo: a ghost follows the pointer, and the drop asks moveTo with the slot nearest it.
6152
+ A press on a lifeline's stem, when the type has connect: a line follows the pointer from the press, and the release on
6153
+ a stem connects the two (I.connect, at the slot nearest the release). Either press is the gesture's, not the camera's
6154
+ nor a card drag's; the click after the drag selects nothing */
6155
+ let drag = null, swallow = false;
6156
+ const stop = () => { const d = drag; drag = null; if (d && d.ghost) d.ghost.remove(); return !!(d && d.on); };
6157
+ api.stage.addEventListener('pointerdown', e => { // a stem, before the card's own drag (capture)
6158
+ swallow = false; // a new press: the click a drag left behind is not coming
6159
+ 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');
6160
+ if (!g || !api.geo().byId[g.dataset.id]) return;
6161
+ e.stopPropagation();
6162
+ drag = { from: g.dataset.id, p: world(e), x0: e.clientX, y0: e.clientY, on: false, pid: e.pointerId };
6163
+ }, true);
6164
+ api.stage.addEventListener('pointerdown', e => {
6165
+ 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];
6166
+ if (!x || x.id == null) return;
6167
+ e.stopImmediatePropagation();
6168
+ drag = { id: x.id, g, x0: e.clientX, y0: e.clientY, on: false, pid: e.pointerId };
6169
+ });
6170
+ api.stage.addEventListener('pointermove', e => {
6171
+ if (!drag) return;
6172
+ if (!drag.on) { if (Math.hypot(e.clientX - drag.x0, e.clientY - drag.y0) < 5) return;
6173
+ drag.on = true; try { api.stage.setPointerCapture(drag.pid); } catch (_) { /* a synthetic press holds no pointer */ }
6174
+ if (drag.from != null) { api.svg().insertAdjacentHTML('beforeend', '<path class="um-edit um-link"/>'); drag.ghost = api.svg().lastElementChild; }
6175
+ else { drag.ghost = drag.g.cloneNode(true); drag.ghost.classList.add('um-ghost'); api.svg().appendChild(drag.ghost); }
6176
+ api.touch(); }
6177
+ if (drag.from != null) { const [x, y] = world(e); drag.ghost.setAttribute('d', `M ${drag.p[0]} ${drag.p[1]} L ${x} ${y}`); return; }
6178
+ const k = api.cam().s.k; drag.ghost.setAttribute('transform', `translate(${(e.clientX - drag.x0) / k} ${(e.clientY - drag.y0) / k})`);
6179
+ });
6180
+ api.stage.addEventListener('pointerup', e => { const d = drag, h = hook('moveTo'); if (!stop()) return; swallow = true;
6181
+ if (d.from != null) { const t = api.root.elementFromPoint(e.clientX, e.clientY), g = t && t.closest && t.closest('.node'), [x, y] = world(e);
6182
+ // 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
6183
+ // message's row, a bar, a frame or a use): the lifeline whose stem it is
6184
+ 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;
6185
+ if (to != null) I.connect(d.from, to, e); }
6186
+ else if (h) ran(h(model(), view(), d.id, near(...world(e))), () => true);
6187
+ api.touch(); });
6188
+ api.stage.addEventListener('pointercancel', stop);
6189
+ api.stage.addEventListener('click', e => { if (swallow) { swallow = false; e.stopPropagation(); e.preventDefault(); } }, true);
6190
+ I.cancel = () => { const a = stop(); if (a) swallow = true; return base.cancel() || a; };
6191
+ I.mount = () => { stop(); base.mount(); };
6192
+
6193
+ // 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)
6194
+ api.stage.addEventListener('dblclick', e => {
6195
+ const s = api.sel(), g = I.on() && can('rename') && s && e.target.closest && (e.target.closest('.node') || e.target.closest('.edge-g, .e-lab'));
6196
+ if (!g) return;
6197
+ 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
6198
+ const member = mine && arrOf((view() || {}).fragments).some(f => isRec(f) && arrOf(f.members).some(m => isRec(m) && m.id === s.id));
6199
+ if (!head && !member && !(s.type === 'edge' && !g.classList.contains('node'))) return;
6200
+ e.preventDefault(); e.stopPropagation(); api.rename();
6201
+ }, true);
6202
+ /* a member's ↑ and ↓ in the panel, and Alt + Up/Down on it, ask moveTo first: an operand's occurrences move with it */
6203
+ const memberMove = (id, by, e) => { const h = hook('moveTo'); if (!h || !can('reorder') || !I.on()) return;
6204
+ const r = h(model(), view(), id, { by: [0, by] }); if (r == null) return;
6205
+ e.preventDefault(); e.stopPropagation(); ran(r); };
6206
+ api.root.addEventListener('click', e => { const b = e.target.closest && e.target.closest('.um-editor [data-up], .um-editor [data-down]');
6207
+ if (b) memberMove(b.dataset.up ?? b.dataset.down, b.dataset.up != null ? -1 : 1, e); }, true);
6208
+ api.root.addEventListener('keydown', e => { const t = e.target;
6209
+ 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);
6210
+ };
6211
+ /* ---------- owners on the picture (work package 2.2, notes/stage-2.md §4 to §7) ----------
6212
+ What the class, package and object views share: which shown package is a box and what it holds (§4.1), the names drawn
6213
+ with the owners the view does not show (§6), the package's shape, open and closed (§5), and the rules about owners and
6214
+ boxes (§7, §10.3). Only lib/umlimator-all.js holds this file. */
6215
+
6216
+ const OWN_OBJ = x => x !== null && typeof x === 'object' && !Array.isArray(x), OWN_ARR = x => (Array.isArray(x) ? x : []);
6217
+ const OWN_BOXES = ['uml.Package']; // the node types that may be a box (§4.1, 1)
6218
+ // the types of rows 1 to 3 whose owner is a package (rule owner-kinds); comments are not checked
6219
+ const OWNED = ['uml.Class', 'uml.Interface', 'uml.Enumeration', 'uml.DataType', 'uml.PrimitiveType', 'uml.Package', 'uml.InstanceSpecification'];
6220
+ const ownName = x => String(x.label ?? x.id);
6221
+ const stereoOf = x => OWN_ARR(OWN_OBJ(x.props) ? x.props.stereotypes : null).filter(t => t != null && t !== '').map(String);
6222
+
6223
+ // every node of a model by id (the first of an id), and the chain of its owners, nearest first. An ownership cycle (the
6224
+ // 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
6225
+ function ownIndex(model) {
6226
+ 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); });
6227
+ const chain = id => { const out = [], seen = new Set([id]);
6228
+ 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); }
6229
+ return out; };
6230
+ return { at, chain };
6231
+ }
6232
+
6233
+ /* 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
6234
+ 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
6235
+ whose owner on the picture o is (null: the top), in the view's order; boxes: the open packages, in
6236
+ the registry's format (registry.js boxes), each holding exactly the shown elements whose owner on the picture it is, and
6237
+ the boxes nested in it by `parent`; name(id): the drawn name, with the owners between it and its owner on the picture,
6238
+ 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 */
6239
+ function ownersOn(model, view) {
6240
+ const { at, chain } = ownIndex(model), ids = OWN_ARR(OWN_OBJ(view) ? view.nodeIds : null).filter(id => at.has(id)), shown = new Set(ids);
6241
+ 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;
6242
+ 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('::'); };
6243
+ const full = id => upTo(id, null), subject = OWN_OBJ(view) ? view.subject : null;
6244
+ const name = id => { const o = owner(id); return upTo(id, o != null ? o : chain(id).includes(subject) ? subject : null); };
6245
+ 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
6246
+ const holds = o => by.get(o) || [], open = new Set([...by.keys()].filter(x => x != null));
6247
+ 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);
6248
+ 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',
6249
+ aria: `Package ${nm}${st.length ? ` (${st.join(', ')})` : ''}, holding ${held.length} element${held.length === 1 ? '' : 's'}` }; });
6250
+ return { ids, owner, holds, boxes, name, full, open, chain, at };
6251
+ }
6252
+
6253
+ // the reading order of a view's elements (§5): each box, then what it holds, depth first; the rest in the view's order
6254
+ function readingOrder(pic) {
6255
+ const out = [], put = o => pic.holds(o).forEach(id => { out.push(id); if (pic.open.has(id)) put(id); });
6256
+ put(null);
6257
+ return out;
6258
+ }
6259
+
6260
+ // a line between a package and something it holds, at any depth, or from a box to itself (§7): a, b node ids
6261
+ 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));
6262
+
6263
+ // the Owner field (registry.js owners): every package of the model by its name from the top, for an element a package may own
6264
+ function ownerChoices(model, x) {
6265
+ if (!OWN_OBJ(x) || !(OWNED.includes(x.type) || x.type === 'uml.Comment') || x.from != null) return null;
6266
+ const { at, chain } = ownIndex(model), full = id => [...chain(id).reverse(), id].map(k => ownName(at.get(k))).join('::');
6267
+ return [...at.values()].filter(n => n.type === 'uml.Package').map(n => ({ id: n.id, label: full(n.id) }));
6268
+ }
6269
+
6270
+ /* ---- the rules about owners and boxes (§9.3, §10.3). Each problem: { level, rule, path, message, ids } ---- */
6271
+ const OWNER_RULES = {
6272
+ 'owner-kinds': 'a classifier, package or instance is owned by a package',
6273
+ 'line-inside': 'a line between a package and something it holds, or from an open package to itself, is not drawn',
6274
+ 'own-boxes': 'a view\'s hand-written containers are ignored where the type gives boxes',
6275
+ };
6276
+ // model: the owner-kinds rule; view (the type's view): line-inside and own-boxes for it, lines as `drawn(model, view)` gives
6277
+ // them ({ e, from, to }, the ends as nodes, before a line between a package and what it holds is left out)
6278
+ function ownerProblems(model, view, lines) {
6279
+ const out = [], say = (rule, path, ids, msg) => out.push({ level: 'warning', rule, path, ids, message: `${rule}: ${msg}` });
6280
+ 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')); } };
6281
+ OWN_ARR(model.nodes).forEach(n => put(n, 'node')); const q = id => JSON.stringify(ownName((all.get(id) || { x: { id } }).x));
6282
+ OWN_ARR(model.nodes).forEach((n, i) => { if (!OWN_OBJ(n) || !OWNED.includes(n.type) || n.parent == null || !all.has(n.parent)) return;
6283
+ 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`); });
6284
+ if (!view) return out;
6285
+ const vi = OWN_ARR(model.views).indexOf(view), pic = ownersOn(model, view);
6286
+ lines.forEach(({ e, from, to }) => { if (holdsLine(pic, from, to)) say('line-inside', `views[${vi}]`, from === to ? [e.id, from] : [e.id, from, to],
6287
+ `${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)}`); });
6288
+ 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`);
6289
+ return out;
6290
+ }
6291
+
6292
+ /* ---- the package's shape (§5): SHAPE.package, closed as a node, open as a box's frame ---- */
6293
+ 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));
6294
+ const pkTexts = n => [...OWN_ARR(n.stereotypes).map(t => [`«${t}»`, PK.st]), [n.label ?? '', PK.name]];
6295
+ SHAPE.package = {
6296
+ // 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
6297
+ // centred in the body; as wide as its widest text, within 96 and 320 px (a longer one is cut)
6298
+ 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 }),
6299
+ out: SHAPE.classifier.out, face: n => [n.y, n.y + n.h], inset: () => 0, ports: SHAPE.classifier.ports,
6300
+ draw(n, c, f) {
6301
+ 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;
6302
+ 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; });
6303
+ 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>`);
6304
+ 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 ?? ''))}">`
6305
+ + `<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"/>`
6306
+ + `<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>`;
6307
+ },
6308
+ // 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,
6309
+ // holding the stereotypes and the name (the box's title, cut to its width). The border has class family-box, which the
6310
+ // canvas outlines on selection and under a drag; the title keeps class family-title, which the audit and the canvas read
6311
+ frame(f) {
6312
+ const hd = Math.min(f.w - 16, textW(f.title, 11) + 16), tr = r2(f.x + 8 + hd);
6313
+ 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"/>`
6314
+ + `<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>`;
6315
+ },
6316
+ };
6317
+
6318
+ /* 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
6319
+ 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
6320
+ 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
6321
+ the same one, changed once */
6322
+ const TABBED = new WeakSet();
6323
+ function placeTabs(g) {
6324
+ if (TABBED.has(g)) return g; TABBED.add(g);
6325
+ g.nodes.filter(n => n.shape === 'package').forEach(n => {
6326
+ 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);
6327
+ if (!ends.length) return;
6328
+ 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);
6329
+ const [tx] = [n.x, ...xs.flatMap(p => [p + 7, p - 7 - tw])].filter(t => t >= n.x && t <= n.x + n.w - tw)
6330
+ .sort((a, b) => near(a) - near(b) || over(a) - over(b) || a - b);
6331
+ if (tx !== n.x) n.tabX = tx;
6332
+ ends.forEach(([e, k]) => { const p = e.pts[k][0]; if (p >= tx && p <= tx + tw) e.pts[k] = [p, n.y - PK.tab]; });
6333
+ });
6334
+ return g;
6335
+ }
6015
6336
  /* ---------- the class diagram, uml.class (work package 1.9) ----------
6016
6337
  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 (drawn as a node until stage 2 draws ownership);
6338
+ operations in UML's text form; stereotypes as text; packages as owners, a package holding what it owns that the view
6339
+ 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
6340
  association with both ends, generalization, interface realization, dependency; comments. A view whose `diagram` is
6019
6341
  'uml.class' is laid out by layoutTyped (src/typed/layout.js) and drawn with the classifier shape and the line-end table
6020
6342
  (src/typed/draw.js). Ships in lib/umlimator-all.js only (stage 1 note §2.5). */
@@ -6185,16 +6507,20 @@ function lineLook(e) {
6185
6507
  case 'uml.InterfaceRealization': return { from: 'none', to: 'triangle', dashed: true };
6186
6508
  case 'uml.Dependency': return { from: 'none', to: 'open', dashed: true };
6187
6509
  case 'uml.CommentLink': return { from: 'none', to: 'none', dashed: true };
6510
+ case 'uml.PackageImport': case 'uml.PackageMerge': return { from: 'none', to: 'open', dashed: true };
6188
6511
  case 'uml.Association': { const E = endsOf(e), lab = w => ({ role: (E[w] || {}).role, mult: (E[w] || {}).multiplicity });
6189
6512
  return { from: endOfLine(e, 'from'), to: endOfLine(e, 'to'), labels: { from: lab('from'), to: lab('to') } }; }
6190
6513
  default: return { from: 'none', to: 'arrow' };
6191
6514
  }
6192
6515
  }
6193
6516
  const stereoText = x => UML_ARR(UML_OBJ(x.props) ? x.props.stereotypes : null).filter(UML_HAS).map(t => `«${t}»`).join(' ');
6194
- const lineLabel = e => [stereoText(e), e.label].filter(UML_HAS).join(' ');
6517
+ // a package import says «import», or «access» when private; a merge «merge» (notes/stage-2.md §10.2)
6518
+ const keyword = e => e.type === 'uml.PackageImport' ? ((UML_OBJ(e.props) ? e.props.visibility : null) === 'private' ? '«access»' : '«import»') : e.type === 'uml.PackageMerge' ? '«merge»' : '';
6519
+ const lineLabel = e => [keyword(e), stereoText(e), e.label].filter(UML_HAS).join(' ');
6195
6520
  const LINE_WORDS = { 'uml.Association': ['Association', 'a link between instances'], 'uml.Generalization': ['Generalization', 'is a kind of the one it points to'],
6196
6521
  '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'] };
6522
+ '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'],
6523
+ 'uml.PackageMerge': ['Package merge', 'takes in what the one it points to holds'] };
6198
6524
  const LEGEND_ENDS = { // small copies of src/typed/draw.js's ends, pointing right with the tip at x 26
6199
6525
  none: '', open: '<path d="M17 4L26 8L17 12" fill="none"/>', triangle: '<path d="M16 3L26 8L16 13Z" fill="var(--um-panel)"/>',
6200
6526
  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 +6546,61 @@ function classIndex(model) {
6220
6546
  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
6547
  const nameOfEl = x => String(x.label ?? x.id);
6222
6548
  const plural = (k, w) => `${k} ${k === 1 ? w : w + 's'}`;
6223
- // an element's accessible name: "Class Order (entity, abstract), 5 attributes, 3 operations"
6224
- function nodeName(n) {
6549
+ /* A type's look (notes/stage-2.md §10.2): the class, package and object views share classGeo's code. members: whether a
6550
+ classifier shows its compartments; own: the line types a view with no `edges` list draws (none: every type); word: the
6551
+ diagram's name in describe() */
6552
+ const CLASS_LOOK = { members: true, own: null, word: 'class diagram' };
6553
+ // an element's accessible name: "Class Order (entity, abstract), 5 attributes, 3 operations", by its drawn name (pic: the
6554
+ // view's owners, owners.js); "Package sales, holding 4 elements"; "Package payments (closed)"
6555
+ function nodeName(n, pic, look = CLASS_LOOK) {
6225
6556
  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 counts = compartmentsOf(n).map(c => plural(c.members.length, c.name.replace(/s$/, '')));
6228
- return [`${typeWord(n.type)} ${nameOfEl(n)}${why.length ? ` (${why.join(', ')})` : ''}`, ...counts].join(', ');
6557
+ 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);
6558
+ 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(', ')})`; }
6559
+ const counts = look.members ? compartmentsOf(n).map(c => plural(c.members.length, c.name.replace(/s$/, ''))) : [];
6560
+ return [`${typeWord(n.type)} ${nm}${why.length ? ` (${why.join(', ')})` : ''}`, ...counts].join(', ');
6229
6561
  }
6230
- function lineName(e, at) {
6231
- const nm = id => { const r = at.get(id); return r ? nameOfEl(r.owner || r.x) + (r.owner ? '.' + nameOfEl(r.x) : '') : id; };
6562
+ function lineName(e, at, pic) {
6563
+ 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
6564
+ 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
6565
  const [word] = UML_OWN(LINE_WORDS, e.type) || ['Line'], said = lineLabel(e), E = endsOf(e);
6233
6566
  const end = w => { const t = [(E[w] || {}).role, (E[w] || {}).multiplicity].filter(UML_HAS); return t.length ? ` (${t.join(', ')})` : ''; };
6234
6567
  return `${word} from ${nm(e.from)}${end('from')} to ${nm(e.to)}${end('to')}${said ? ': ' + said : ''}`;
6235
6568
  }
6236
6569
 
6237
- /* the class view's geometry: { model, view, pos (col, row by id, the layered layout's ranking or the view's pins), memo,
6238
- direction }. A line that ends on a member is drawn to the member's owner; a line between two nodes the view does not
6239
- both show is not drawn */
6240
- function classGeo({ model, view, pos, memo }) {
6241
- const at = classIndex(model), present = UML_OBJ(view.present) ? view.present : {}, ids = UML_ARR(view.nodeIds).filter(id => (at.get(id) || {}).kind === 'node');
6242
- const shown = new Set(ids), top = id => { const r = at.get(id); return r && r.owner ? top(r.owner.id) : id; };
6243
- const nodes = ids.map(id => { const n = at.get(id).x, pr = UML_OBJ(UML_OWN(present, id)) ? present[id] : {}, p = UML_OBJ(n.props) ? n.props : {};
6244
- const look = n.type === 'uml.Comment' ? { shape: 'note' } : { shape: 'classifier', stereotypes: [...(UML_OWN(IMPLIED, n.type) ? [IMPLIED[n.type]] : []), ...UML_ARR(p.stereotypes).filter(UML_HAS)],
6245
- abstract: !!p.isAbstract, compartments: compartmentsOf(n), collapsed: UML_ARR(pr.collapsed), ...(Number.isFinite(pr.rows) ? { rows: pr.rows } : {}) };
6246
- const d = { id, label: nameOfEl(n), type: n.type || 'uml.Class', kind: 'file', ...look, aria: nodeName(n), col: (UML_OWN(pos, id) || [0, 0])[0], row: (UML_OWN(pos, id) || [0, 0])[1] };
6570
+ /* a view's lines: { at, pic, lines: [{ e, from, to }] }, each line the view lists (or, with no list, each model line of the
6571
+ 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
6572
+ holds is among them: it is drawn by none (holdsLine), and rule line-inside warns of it */
6573
+ function drawnLines(model, view, own) {
6574
+ 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; };
6575
+ const list = Array.isArray(view.edges) ? view.edges : UML_ARR(model.edges).filter(e => !own || (UML_OBJ(e) && own.includes(e.type)));
6576
+ const lines = list.map(e => (typeof e === 'string' ? (at.get(e) || {}).x : e)).filter(e => UML_OBJ(e) && typeof e.id === 'string')
6577
+ .map(e => ({ e, from: top(e.from), to: top(e.to) })).filter(({ from, to }) => shown.has(from) && shown.has(to));
6578
+ return { at, pic, lines };
6579
+ }
6580
+
6581
+ /* 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
6582
+ pins), memo, direction }. A line that ends on a member is drawn to the member's owner; a line between two nodes the view
6583
+ does not both show is not drawn. An open package is a box (its id takes no slot), passed to layoutTyped as a container */
6584
+ const typeGeo = look => ({ model, view, pos, memo }) => {
6585
+ const { at, pic, lines } = drawnLines(model, view, look.own), present = UML_OBJ(view.present) ? view.present : {};
6586
+ 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 : {};
6587
+ const st = UML_ARR(p.stereotypes).filter(UML_HAS);
6588
+ const shape = n.type === 'uml.Comment' ? { shape: 'note' } : n.type === 'uml.Package' ? { shape: 'package', stereotypes: st }
6589
+ : { shape: 'classifier', stereotypes: [...(UML_OWN(IMPLIED, n.type) ? [IMPLIED[n.type]] : []), ...st], abstract: !!p.isAbstract, compartments: look.members ? compartmentsOf(n) : [],
6590
+ collapsed: UML_ARR(pr.collapsed), ...(Number.isFinite(pr.rows) ? { rows: pr.rows } : {}) };
6591
+ 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
6592
  return { ...d, ...SHAPE[d.shape].size(d) }; });
6248
- const list = Array.isArray(view.edges) ? view.edges : UML_ARR(model.edges);
6249
- const edges = list.map(e => (typeof e === 'string' ? (at.get(e) || {}).x : e)).filter(e => UML_OBJ(e) && typeof e.id === 'string')
6250
- .map(e => ({ e, from: top(e.from), to: top(e.to) })).filter(({ from, to }) => shown.has(from) && shown.has(to))
6251
- .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), ...(UML_OBJ(e.style) || typeof e.style === 'string' ? { style: e.style } : {}) }));
6252
- const g = layoutTyped({ nodes, edges, direction: view.direction === 'down' ? 'down' : 'right', gap: 96, rgap: 56 }, memo);
6593
+ const edges = lines.filter(({ from, to }) => !holdsLine(pic, from, to))
6594
+ .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 } : {}) }));
6595
+ // 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
6596
+ // its label, which is then not drawn: laid out left to right, a view with boxes and labelled lines spreads its columns by its
6597
+ // widest label and 40 px for its stubs (laid out down, a label sits across its line, in the gap between rows)
6598
+ const down = view.direction === 'down', wide = !down && pic.boxes.length ? Math.max(0, ...edges.filter(e => e.label).map(labelW)) : 0;
6599
+ 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
6600
  g.legend = legendOf(g.edges);
6254
- return g;
6255
- }
6601
+ return placeTabs(g);
6602
+ };
6603
+ const classGeo = typeGeo(CLASS_LOOK);
6256
6604
 
6257
6605
  /* ---- validators (proposal.md §8.3). Each problem: { level, rule, path, message, ids }; the message names the rule and
6258
6606
  the elements. Ownership cycles and references that do not resolve are the core's checks (src/core/model.js) ---- */
@@ -6314,28 +6662,30 @@ function classProblems(model) {
6314
6662
  return out;
6315
6663
  }
6316
6664
 
6317
- /* ---- describe(): the view in words, per element: "Class Order (entity), 5 attributes, 3 operations; associated with
6318
- OrderLine (lines, 1..*)" ---- */
6319
- function classDescribe(model, viewId) {
6665
+ /* ---- describe(): the view in words, per element, each box then what it holds: "Class Order (entity), 5 attributes, 3
6666
+ operations; associated with OrderLine (lines, 1..*)"; "Package sales, holding 4 elements; imports catalog" ---- */
6667
+ const describeAs = look => (model, viewId) => {
6320
6668
  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 = classIndex(model), top = id => { const r = at.get(id); return r && r.owner ? top(r.owner.id) : id; };
6322
- const ids = UML_ARR(v.nodeIds).filter(id => (at.get(id) || {}).kind === 'node'), shown = new Set(ids), nm = id => nameOfEl(at.get(id).x);
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)));
6669
+ const { at, pic, lines: all } = drawnLines(model, v, look.own), lines = all.filter(({ from, to }) => !holdsLine(pic, from, to));
6670
+ const nm = id => (at.get(id).x.type === 'uml.Comment' ? nameOfEl(at.get(id).x) : pic.name(id));
6325
6671
  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 class diagram of ${plural(ids.length, 'element')} and ${plural(lines.length, 'line')}.`];
6327
- ids.forEach(id => { const said = [];
6328
- lines.forEach(e => { const f = top(e.from), t = top(e.to), E = endsOf(e);
6672
+ const out = [`${v.title || v.id}: a ${look.word} of ${plural(pic.ids.length, 'element')} and ${plural(lines.length, 'line')}.`];
6673
+ readingOrder(pic).forEach(id => { const said = [];
6674
+ lines.forEach(({ e, from: f, to: t }) => { const E = endsOf(e);
6329
6675
  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
6676
  const verb = agg === 'composite' ? 'composed of' : agg === 'shared' ? 'aggregates' : whole === 'composite' ? 'part of' : whole === 'shared' ? 'held by' : 'associated with';
6331
6677
  said.push(`${verb} ${nm(far === 'to' ? t : f)}${end(e, far)}${UML_HAS(e.label) ? ` ("${e.label}")` : ''}`); }
6332
- else if (f === id && e.type === 'uml.Generalization') said.push(`a kind of ${nm(t)}`);
6333
- else if (f === id && e.type === 'uml.InterfaceRealization') said.push(`realizes ${nm(t)}`);
6334
- else if (f === id && e.type === 'uml.Dependency') said.push(`depends on ${nm(t)}${stereoText(e) ? ' ' + stereoText(e) : ''}`);
6335
- else if (f === id && e.type === 'uml.CommentLink') said.push(`about ${nm(t)}`); });
6336
- out.push(nodeName(at.get(id).x) + (said.length ? '; ' + said.join('; ') : '') + '.'); });
6678
+ else if (f !== id) return;
6679
+ else if (e.type === 'uml.Generalization') said.push(`a kind of ${nm(t)}`);
6680
+ else if (e.type === 'uml.InterfaceRealization') said.push(`realizes ${nm(t)}`);
6681
+ else if (e.type === 'uml.Dependency') said.push(`depends on ${nm(t)}${stereoText(e) ? ' ' + stereoText(e) : ''}`);
6682
+ else if (e.type === 'uml.PackageImport') said.push(`${keyword(e) === '«access»' ? 'accesses' : 'imports'} ${nm(t)}`);
6683
+ else if (e.type === 'uml.PackageMerge') said.push(`merges ${nm(t)}`);
6684
+ else if (e.type === 'uml.CommentLink') said.push(`about ${nm(t)}`); });
6685
+ out.push(nodeName(at.get(id).x, pic, look) + (said.length ? '; ' + said.join('; ') : '') + '.'); });
6337
6686
  return out.join('\n');
6338
- }
6687
+ };
6688
+ const classDescribe = describeAs(CLASS_LOOK);
6339
6689
 
6340
6690
  /* ---- the type ---- */
6341
6691
  const CLASS_TYPE = registerDiagramType('uml.class', {
@@ -6344,11 +6694,210 @@ const CLASS_TYPE = registerDiagramType('uml.class', {
6344
6694
  // general one comes first: above its specializations in a view laid out down (risks.md L6)
6345
6695
  rankFlip: e => e.type === 'uml.Generalization' || e.type === 'uml.InterfaceRealization',
6346
6696
  layout: classGeo,
6697
+ // the view's open packages, the boxes the layout draws (notes/stage-2.md §4), and the Owner field's packages (§8)
6698
+ boxes: (model, view) => ownersOn(model, view).boxes,
6699
+ owners: ownerChoices,
6347
6700
  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,
6701
+ validate: (model, view) => [...classProblems(model), ...ownerProblems(model, view, drawnLines(model, view, null).lines)],
6702
+ rules: { ...RULES, ...OWNER_RULES },
6350
6703
  describe: classDescribe,
6351
6704
  });
6705
+ /* ---------- the package diagram, uml.package (work package 2.2, notes/stage-2.md §10.2, §10.3) ----------
6706
+ Packages and the classifiers of the class diagram, drawn by the class view's code with their names only: a package holding
6707
+ what it owns that the view shows is a box (src/uml/owners.js); package imports («import», or «access» when private),
6708
+ merges («merge») and dependencies, dashed with an open arrow; comments. A view with no `edges` list draws these line
6709
+ types only. Ships in lib/umlimator-all.js only. */
6710
+
6711
+ const PACKAGE_LINES = ['uml.PackageImport', 'uml.PackageMerge', 'uml.Dependency', 'uml.CommentLink'];
6712
+ const PACKAGE_LOOK = { members: false, own: PACKAGE_LINES, word: 'package diagram' };
6713
+ const PACKAGE_RULES = {
6714
+ 'import-ends': 'a package import or a package merge runs from a package to another package',
6715
+ 'package-names': 'packages in one owner have different names',
6716
+ };
6717
+
6718
+ // the package type's own rules (the class type's and the owners' run too): each problem { level, rule, path, message, ids }
6719
+ function packageProblems(model) {
6720
+ const out = [], nodes = (Array.isArray(model.nodes) ? model.nodes : []).filter(n => n && typeof n === 'object' && typeof n.id === 'string');
6721
+ const at = new Map(nodes.map(n => [n.id, n])), q = id => JSON.stringify(String((at.get(id) || {}).label ?? id));
6722
+ const say = (rule, path, ids, msg) => out.push({ level: 'error', rule, path, ids, message: `${rule}: ${msg}` });
6723
+ (Array.isArray(model.edges) ? model.edges : []).forEach((e, i) => { if (!e || typeof e !== 'object' || (e.type !== 'uml.PackageImport' && e.type !== 'uml.PackageMerge')) return;
6724
+ const pk = id => (at.get(id) || {}).type === 'uml.Package';
6725
+ 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`); });
6726
+ const seen = new Map();
6727
+ (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);
6728
+ 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); });
6729
+ return out;
6730
+ }
6731
+
6732
+ const PACKAGE_TYPE = registerDiagramType('uml.package', {
6733
+ title: 'package diagram',
6734
+ elements: [['Package', 'Package'], ['Class', 'Class'], ['Interface', 'Interface'], ['Enumeration', 'Enumeration'], ['DataType', 'Data type'], ['PrimitiveType', 'Primitive type'], ['Comment', 'Comment']]
6735
+ .map(([t, label]) => ({ type: 'uml.' + t, label })),
6736
+ lines: [['PackageImport', 'Import'], ['PackageMerge', 'Merge'], ['Dependency', 'Dependency'], ['CommentLink', 'Comment link']].map(([t, label]) => ({ type: 'uml.' + t, label })),
6737
+ props: { ...CLASS_DEF.props, 'uml.PackageImport': [{ key: 'visibility', label: 'Visibility', kind: 'enum', options: ['public', 'private'] }], 'uml.PackageMerge': [] },
6738
+ rankFlip: e => e.type === 'uml.Generalization' || e.type === 'uml.InterfaceRealization',
6739
+ layout: typeGeo(PACKAGE_LOOK),
6740
+ boxes: (model, view) => ownersOn(model, view).boxes,
6741
+ owners: ownerChoices,
6742
+ member: { holds: () => false }, // a classifier is drawn by its name here; its members are edited in a class view
6743
+ validate: (model, view) => [...classProblems(model), ...packageProblems(model), ...ownerProblems(model, view, drawnLines(model, view, PACKAGE_LINES).lines)],
6744
+ rules: { ...RULES, ...OWNER_RULES, ...PACKAGE_RULES },
6745
+ describe: describeAs(PACKAGE_LOOK),
6746
+ });
6747
+ /* ---------- the object diagram, uml.object (work package 2.3, notes/stage-2.md §9) ----------
6748
+ Instance specifications drawn with the classifier shape, the head underlined, `name : Classifier` (or `: Classifier`
6749
+ for an anonymous one, or `name` with no classifier), and one compartment of slots, `feature = value`; links drawn as an
6750
+ association with no end marks, their name (or their association) on the line and each end's role by its end; comments.
6751
+ 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
6752
+ with no `edges` list draws links and comment links only. Ships in lib/umlimator-all.js only. */
6753
+
6754
+ const OB_OBJ = x => x !== null && typeof x === 'object' && !Array.isArray(x), OB_ARR = x => (Array.isArray(x) ? x : []);
6755
+ const OB_HAS = x => x != null && x !== '';
6756
+ const OB_OWN = (o, k) => (o != null && Object.hasOwn(o, k) ? o[k] : undefined);
6757
+ const OB_PROPS = x => (OB_OBJ(x.props) ? x.props : {});
6758
+ const OBJECT_LINES = ['uml.Link', 'uml.CommentLink'];
6759
+ const OB_CLASSIFIERS = ['uml.Class', 'uml.Interface', 'uml.Enumeration', 'uml.DataType', 'uml.PrimitiveType'];
6760
+
6761
+ /* ---- the slot micro-syntax (§9.2, risks.md E3): `feature = value`, or `feature`. The feature is a name as parseMember
6762
+ reads one, the value the rest of the line, trimmed; print(parse(x)) is stable ---- */
6763
+ const SLOT_NAME = /^[\p{L}_$][\p{L}\p{M}\p{N}_$]*/u;
6764
+ function parseSlot(text) {
6765
+ const s = String(text ?? '').trim(), fail = why => ({ error: `"${s}" is not a slot: ${why}` }), nm = SLOT_NAME.exec(s);
6766
+ if (!nm) return fail(s ? 'a feature\'s name comes first' : 'it is empty');
6767
+ const rest = s.slice(nm[0].length).trim();
6768
+ if (!rest) return { type: 'uml.Slot', label: nm[0], props: {} };
6769
+ if (rest[0] !== '=') return fail(`"${rest}" is left over: write feature = value`);
6770
+ const value = rest.slice(1).trim();
6771
+ return value ? { type: 'uml.Slot', label: nm[0], props: { value } } : fail('a value follows =');
6772
+ }
6773
+ const printSlot = m => String((m && m.label) ?? '') + (OB_HAS(OB_PROPS(m || {}).value) ? ' = ' + OB_PROPS(m).value : '');
6774
+ // a slot edited as text: one transaction; props the syntax does not write are kept
6775
+ function slotEdit(m, text) {
6776
+ const r = parseSlot(text); if (r.error) return r;
6777
+ const { value: _, ...keep } = OB_PROPS(m);
6778
+ return { commands: [{ op: 'set', id: m.id, path: 'label', value: r.label }, { op: 'set', id: m.id, path: 'props', value: { ...keep, ...r.props } }] };
6779
+ }
6780
+
6781
+ /* ---- names ---- */
6782
+ const slotsOf = n => OB_ARR(n.members).filter(m => OB_OBJ(m) && m.type === 'uml.Slot');
6783
+ // an instance's head: "order1042 : Order", ": Order" (anonymous), "order1042" (no classifier); name: its drawn name (owners.js)
6784
+ const headOf = (n, name) => { const c = OB_PROPS(n).classifier, named = OB_HAS(n.label) || !OB_HAS(c);
6785
+ return [named ? name : '', OB_HAS(c) ? ': ' + c : ''].filter(Boolean).join(' '); };
6786
+ const obStereo = x => OB_ARR(OB_PROPS(x).stereotypes).filter(OB_HAS).map(String);
6787
+ /* 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
6788
+ not underlined, its keyword over it, its compartments; "Class Order, 1 attribute, 0 operations".
6789
+ ponytail: mirrors class.js compartmentsOf/nodeName/IMPLIED, which it does not export; share them when class.js is next opened */
6790
+ const OB_IMPLIED = { 'uml.Interface': 'interface', 'uml.Enumeration': 'enumeration', 'uml.DataType': 'dataType', 'uml.PrimitiveType': 'primitive' };
6791
+ const OB_KINDS = { attributes: 'uml.Property', operations: 'uml.Operation', literals: 'uml.EnumerationLiteral' };
6792
+ const isInstance = n => !OB_CLASSIFIERS.includes(n.type); // anything else shown here is drawn as an instance, as before
6793
+ function classifierBoxes(n) {
6794
+ const c = name => ({ name, members: OB_ARR(n.members).filter(m => OB_OBJ(m) && m.type === OB_KINDS[name]) });
6795
+ return n.type === 'uml.Enumeration' ? [c('literals'), ...[c('attributes'), c('operations')].filter(x => x.members.length)] : [c('attributes'), c('operations')];
6796
+ }
6797
+ const obWord = t => t.split('.').pop().replace(/([a-z])([A-Z])/g, (_, a, b) => a + ' ' + b.toLowerCase());
6798
+ // an element's accessible name, as the classifier shape writes it: "Instance specification order1042 : Order, 2 slots"
6799
+ function obNodeName(n, pic) {
6800
+ if (n.type === 'uml.Comment') return 'Comment: ' + String(n.label ?? n.id);
6801
+ const st = obStereo(n), why = st.length ? ` (${st.join(', ')})` : '';
6802
+ 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(', ')})`; }
6803
+ if (!isInstance(n)) { const w = [...st, ...(OB_PROPS(n).isAbstract ? ['abstract'] : [])];
6804
+ 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(', '); }
6805
+ const k = slotsOf(n).length;
6806
+ return `Instance specification ${headOf(n, pic.name(n.id))}${why}${k ? `, ${k} slot${k === 1 ? '' : 's'}` : ''}`;
6807
+ }
6808
+ const linkText = e => [obStereo(e).map(t => `«${t}»`).join(' '), OB_HAS(e.label) ? e.label : OB_PROPS(e).association].filter(OB_HAS).join(' ');
6809
+ 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; };
6810
+
6811
+ /* ---- the view laid out (layoutTyped) ---- */
6812
+ 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']];
6813
+ 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>`);
6814
+ function objectGeo({ model, view, pos, memo }) {
6815
+ 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));
6816
+ 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);
6817
+ const shape = n.type === 'uml.Comment' ? { shape: 'note' } : n.type === 'uml.Package' ? { shape: 'package', stereotypes: obStereo(n) }
6818
+ : !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),
6819
+ collapsed: OB_ARR(pr.collapsed), ...(Number.isFinite(pr.rows) ? { rows: pr.rows } : {}) }
6820
+ // memberText draws props.default after " = ": a slot passed as { label: feature, props: { default: value } } (§9.1)
6821
+ : { 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 } })) }] : [],
6822
+ collapsed: OB_ARR(pr.collapsed), ...(Number.isFinite(pr.rows) ? { rows: pr.rows } : {}) };
6823
+ 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] };
6824
+ return { ...d, ...SHAPE[d.shape].size(d) }; });
6825
+ 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)})` : '');
6826
+ 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') } } };
6827
+ 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 : ''}`,
6828
+ ...(OB_OBJ(e.style) || typeof e.style === 'string' ? { style: e.style } : {}) }; });
6829
+ // as the class view (class.js typeGeo): laid out right with boxes, the columns spread by the widest label
6830
+ const down = view.direction === 'down', wide = !down && pic.boxes.length ? Math.max(0, ...edges.filter(e => e.label).map(labelW)) : 0;
6831
+ 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);
6832
+ g.legend = obLegend(g.edges);
6833
+ return placeTabs(g);
6834
+ }
6835
+
6836
+ /* ---- validators (§9.3). Each problem: { level, rule, path, message, ids } ---- */
6837
+ const OBJECT_RULES = {
6838
+ 'slot-kinds': 'an instance holds slots only; a slot belongs to an instance',
6839
+ 'link-ends': 'a link joins two instance specifications',
6840
+ 'instance-of': 'an instance\'s slots name attributes of its classifier, when the classifier is in the model',
6841
+ };
6842
+ function objectProblems(model) {
6843
+ 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}` });
6844
+ const at = new Map(); nodes.forEach(n => { if (OB_OBJ(n) && typeof n.id === 'string' && !at.has(n.id)) at.set(n.id, n); });
6845
+ const q = id => JSON.stringify(String((at.get(id) || {}).label ?? id)), isInst = id => (at.get(id) || {}).type === 'uml.InstanceSpecification';
6846
+ // the model's classifiers by name and by qualified name; a classifier's attributes, with its generals' (a cycle is walked once)
6847
+ const full = ownersOn(model, null).full, named = new Map(), add = (k, id) => named.set(k, [...(named.get(k) || []), id]);
6848
+ 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); } });
6849
+ 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]); });
6850
+ const attrs = id => { const seen = new Set(), names = new Set(), walk = c => { if (seen.has(c) || !at.has(c)) return; seen.add(c);
6851
+ 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); };
6852
+ walk(id); return names; };
6853
+ nodes.forEach((n, i) => { if (!OB_OBJ(n)) return; const ms = OB_ARR(n.members);
6854
+ 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`);
6855
+ ms.forEach((m, j) => { if (!OB_OBJ(m)) return; const inst = n.type === 'uml.InstanceSpecification';
6856
+ 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`); });
6857
+ const c = OB_PROPS(n).classifier, hits = n.type === 'uml.InstanceSpecification' && OB_HAS(c) ? [...new Set(named.get(String(c)) || [])] : [];
6858
+ if (hits.length !== 1) return; // no classifier, or one the model does not hold once: types are text (M8)
6859
+ const has = attrs(hits[0]);
6860
+ 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})`); }); });
6861
+ OB_ARR(model.edges).forEach((e, i) => { if (OB_OBJ(e) && e.type === 'uml.Link' && !(isInst(e.from) && isInst(e.to)))
6862
+ 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`); });
6863
+ return out;
6864
+ }
6865
+
6866
+ /* ---- describe(): the view in words, each box then what it holds: "Instance specification order1042 : Order, 2 slots:
6867
+ status = placed, total = 34.00 EUR; linked to line1 : OrderLine (lines)" ---- */
6868
+ function objectDescribe(model, viewId) {
6869
+ 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)}`);
6870
+ const { at, pic, lines: all } = drawnLines(model, v, OBJECT_LINES), lines = all.filter(({ from, to }) => !holdsLine(pic, from, to));
6871
+ 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); };
6872
+ const k = (n, w) => `${n} ${n === 1 ? w : w + 's'}`;
6873
+ const out = [`${v.title || v.id}: an object diagram of ${k(pic.ids.length, 'element')} and ${k(lines.length, 'line')}.`];
6874
+ readingOrder(pic).forEach(id => { const x = at.get(id).x, said = [], slots = x.type === 'uml.InstanceSpecification' ? slotsOf(x).map(printSlot) : [];
6875
+ lines.forEach(({ e, from: f, to: t }) => {
6876
+ if (e.type === 'uml.CommentLink') { if (f === id) said.push(`about ${nm(t)}`); return; }
6877
+ if (f !== id && t !== id) return; const far = f === id ? 'to' : 'from', r = roleOf(e, far), l = linkText(e);
6878
+ said.push(`linked to ${nm(far === 'to' ? t : f)}${r ? ` (${r})` : ''}${l ? ` ("${l}")` : ''}`); });
6879
+ out.push(obNodeName(x, pic) + (slots.length ? ': ' + slots.join(', ') : '') + (said.length ? '; ' + said.join('; ') : '') + '.'); });
6880
+ return out.join('\n');
6881
+ }
6882
+
6883
+ /* ---- the type ---- */
6884
+ const OB_STEREO = { key: 'stereotypes', label: 'Stereotypes', kind: 'list' };
6885
+ const obRole = w => ({ key: `ends.${w}.role`, path: ['ends', w, 'role'], label: `${w === 'from' ? 'From' : 'To'} role`, kind: 'text' });
6886
+ const OBJECT_TYPE = registerDiagramType('uml.object', {
6887
+ title: 'object diagram',
6888
+ elements: [{ type: 'uml.InstanceSpecification', label: 'Instance' }, { type: 'uml.Comment', label: 'Comment' }],
6889
+ lines: [{ type: 'uml.Link', label: 'Link' }, { type: 'uml.CommentLink', label: 'Comment link' }],
6890
+ props: { 'uml.InstanceSpecification': [{ key: 'classifier', label: 'Classifier', kind: 'text' }, OB_STEREO], 'uml.Package': [OB_STEREO],
6891
+ 'uml.Link': [{ key: 'association', label: 'Association', kind: 'text' }, obRole('from'), obRole('to'), OB_STEREO], 'uml.CommentLink': [] },
6892
+ layout: objectGeo,
6893
+ boxes: (model, view) => ownersOn(model, view).boxes,
6894
+ owners: ownerChoices,
6895
+ // a slot is a member row, `feature = value` (§9.2); a comment and a package hold none
6896
+ member: { print: printSlot, edit: slotEdit, add: parseSlot, holds: n => n.type === 'uml.InstanceSpecification' },
6897
+ validate: (model, view) => [...classProblems(model), ...objectProblems(model), ...ownerProblems(model, view, drawnLines(model, view, OBJECT_LINES).lines)],
6898
+ rules: { ...RULES, ...OBJECT_RULES, ...OWNER_RULES }, // member-names: two slots for one feature
6899
+ describe: objectDescribe,
6900
+ });
6352
6901
  /* ---------- Mermaid classDiagram: a reader and a writer (plan.md 1.10; risks.md R1 to R4, R7) ----------
6353
6902
  Written against Mermaid 12.3.0's class diagram page (docs/syntax/classDiagram.md, read 11 Nov 2026). Internal: the CLI
6354
6903
  (`umlimator.py import`) imports this file through Node; a host gets it once the registry is public.
@@ -6363,8 +6912,9 @@ const CLASS_TYPE = registerDiagramType('uml.class', {
6363
6912
  writeMermaidClass(model, viewId?) -> text: one class view as Mermaid. What Mermaid cannot carry is kept in its comments:
6364
6913
  an id the reader would not derive, as `%% id: <id>` (and `%% link-id:` for a note's line) above its statement; an
6365
6914
  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 class's parent that is not a namespace, an
6367
- unknown visibility, a stereotype the reader would read as something else). A member whose text has a line break is not
6915
+ `%% not written: <id>: <what>` (navigable on an end with a diamond, a parent the view does not show or that is not a
6916
+ namespace, a note's parent, an unknown visibility, a stereotype the reader would read as something else). A namespace
6917
+ 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
6918
  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
6919
  the text is one `%% not written:` line. A view's layout and present are not written.
6370
6920
 
@@ -6381,9 +6931,17 @@ const CLASS_TYPE = registerDiagramType('uml.class', {
6381
6931
  are the multiplicity *, reported;
6382
6932
  - a member named twice in one class (an operation with the same parameters' types) is read once, the other reported;
6383
6933
  an interface that realizes another specializes it, reported;
6384
- - namespaces are packages, notes comments, `note for` a comment with its line; `direction TB` is down (the default),
6934
+ - namespaces are packages, dotted (`A.B`) or nested by braces. Mermaid 12.3.0 draws nested braces as nested
6935
+ namespaces (docs/syntax/classDiagram.md, "Nested Namespaces (v11.15.0+)"; ClassDB.addNamespace, which names one
6936
+ inside P as P.<name>), so a namespace is known by its parent and its name here too. The writer nests by braces, and
6937
+ writes dotted, at the top, a namespace whose id is its parent's dotted path (what the reader makes of `A.B`). Mermaid
6938
+ splits any namespace's name at its dots, in backticks too, so a dotted id written otherwise (in braces, or at the top
6939
+ under a parent the view does not show, which its `%% not written:` line then says Mermaid draws) gives Mermaid a
6940
+ namespace the reader does not;
6941
+ - notes are comments, `note for` a comment with its line; `direction TB` is down (the default),
6385
6942
  `LR` right; a %%{ ... }%% directive, on one line or several, is passed over. */
6386
6943
  const MC_LIMIT = 1 << 20; // characters; a class diagram a person writes is a few kilobytes
6944
+ 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
6945
  const mcTable = o => Object.assign(Object.create(null), o); // a lookup table: "constructor" or "__proto__" finds nothing in it
6388
6946
  const MC_VIS = mcTable({ '+': 'public', '-': 'private', '#': 'protected', '~': 'package' });
6389
6947
  const MC_TYPES = mcTable({ interface: 'uml.Interface', enumeration: 'uml.Enumeration', enum: 'uml.Enumeration', datatype: 'uml.DataType', primitive: 'uml.PrimitiveType', primitivetype: 'uml.PrimitiveType' });
@@ -6567,7 +7125,7 @@ function readMermaidClass(text) {
6567
7125
  i = j + 1;
6568
7126
  }
6569
7127
 
6570
- const classes = new Map(), packages = new Map(), order = [], relations = [], notes = [], stack = [];
7128
+ const classes = new Map(), packages = [], topNs = new Map(), order = [], relations = [], notes = [], stack = [];
6571
7129
  let viewId, direction = 'down', started = false, hint = {}, block = null;
6572
7130
  // classes and namespaces by their names, their ids given once all are read (a name that is not an id needs the others)
6573
7131
  const cls = (name, no) => {
@@ -6644,16 +7202,20 @@ function readMermaidClass(text) {
6644
7202
  if (!n) { say(no, 'skipped', 'a namespace with no name'); hint = {}; continue; }
6645
7203
  let k = mcSkip(s, n[1]), label;
6646
7204
  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
- const segs = n[2] ? [n[0]] : n[0].split('.');
6648
- let parent = where();
6649
- segs.forEach((seg, j) => {
6650
- const path = segs.slice(0, j + 1).join('.');
6651
- if (!packages.has(path)) {
7205
+ let segs = n[2] ? [n[0]] : n[0].split('.');
7206
+ if (segs.length > MC_DOTS) { say(no, 'approximated', `namespace ${n[0]}: over ${MC_DOTS} dotted parts, so read as one name`); segs = [n[0]]; }
7207
+ // 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
7208
+ // the name as written, a dotted path from its first part
7209
+ let parent = where(), path = '';
7210
+ for (const seg of segs) {
7211
+ path = path ? `${path}.${seg}` : seg;
7212
+ const kids = parent ? (parent.kids ??= new Map()) : topNs;
7213
+ if (!kids.has(seg)) {
6652
7214
  const rec = { kind: 'package', name: path, no, pkg: parent, node: { id: undefined, type: 'uml.Package', label: seg } };
6653
- packages.set(path, rec); order.push(rec);
7215
+ kids.set(seg, rec); packages.push(rec); order.push(rec);
6654
7216
  }
6655
- parent = packages.get(path);
6656
- });
7217
+ parent = kids.get(seg);
7218
+ }
6657
7219
  if (label) parent.node.label = label;
6658
7220
  if (s[k] === '{') { if (s.slice(k + 1).trim() !== '}') stack.push(parent); }
6659
7221
  else say(no, 'skipped', `namespace ${n[0]} has no {: what follows is not inside it`);
@@ -6662,7 +7224,11 @@ function readMermaidClass(text) {
6662
7224
  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
7225
  const q = mcQuoted(s, k);
6664
7226
  if (!q) say(no, 'skipped', 'a note with no "text"');
6665
- else { const rec = { kind: 'note', node: { type: 'uml.Comment', label: mcText(q[0]) }, target, hint: hint.id, linkHint: hint.link }; notes.push(rec); order.push(rec); }
7227
+ else {
7228
+ if (where()) say(no, 'approximated', `a note in namespace ${where().name} is read outside it`);
7229
+ const rec = { kind: 'note', node: { type: 'uml.Comment', label: mcText(q[0]) }, target, hint: hint.id, linkHint: hint.link };
7230
+ notes.push(rec); order.push(rec);
7231
+ }
6666
7232
  } else if (s.startsWith('<<')) {
6667
7233
  const e = s.indexOf('>>'), n = e < 0 ? null : mcName(s, mcSkip(s, e + 2));
6668
7234
  if (n) annotate(cls(n[0], no), s); else say(no, 'skipped', `${s}: an annotation names no class`);
@@ -6684,15 +7250,17 @@ function readMermaidClass(text) {
6684
7250
  if (!started) { say(1, 'skipped', 'not a Mermaid classDiagram: nothing read'); return empty(); }
6685
7251
  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
7252
 
6687
- // ids: a class's name, then a namespace's; a namespace with a class's name is <name>-namespace, and a name the id rule
6688
- // changes (no "->") is made unique, both reported. Later members, relations and notes, in the order they come, made unique
7253
+ // ids: a class's name, then a namespace's; a namespace with a class's name is <name>-namespace, and one with another
7254
+ // namespace's name (inside another parent) or a name the id rule changes (no "->") is made unique, each reported. Later
7255
+ // members, relations and notes, in the order they come, made unique
6689
7256
  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.values()];
7257
+ const named = [...classes.values(), ...packages];
6691
7258
  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
7259
  for (const rec of named) if (rec.node.id === undefined) {
6693
- const kept = mcId(rec.name) === rec.name, what = `${rec.kind === 'class' ? 'class' : 'namespace'} ${rec.name}`;
6694
- rec.node.id = uniq(kept ? rec.name + '-namespace' : rec.name);
6695
- say(rec.no, 'approximated', kept ? `${what} has a class's name: it is ${rec.node.id}` : `${what}: an id holds no "->", so it is ${rec.node.id}`);
7260
+ const kept = mcId(rec.name) === rec.name, cls = kept && classes.has(rec.name);
7261
+ const what = rec.kind === 'class' ? `class ${rec.name}` : `namespace ${rec.name}${rec.pkg ? ' in ' + rec.pkg.node.id : ''}`;
7262
+ rec.node.id = uniq(cls ? rec.name + '-namespace' : rec.name);
7263
+ 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
7264
  }
6697
7265
  for (const rec of named) if (rec.pkg) rec.node.parent = rec.pkg.node.id;
6698
7266
 
@@ -6813,20 +7381,36 @@ function writeMermaidClass(model, viewId) {
6813
7381
 
6814
7382
  // what is written, in the reader's order: namespaces and classes as a tree, then relations, then notes
6815
7383
  const shownSet = new Set(shown), isPkg = n => n.type === 'uml.Package';
6816
- const kids = new Map(), roots = [], notes = [], tree = [], boxes = [];
7384
+ const kids = new Map(), roots = [], notes = [], tree = [], boxes = [], splits = [];
6817
7385
  for (const id of shown) {
6818
7386
  const n = byId.get(id), up = shownSet.has(n.parent) ? byId.get(n.parent) : null;
6819
- if (n.type === 'uml.Comment') { if (nl(n.id)) skip(n); else notes.push(n); }
6820
- 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
7387
+ // an owner the view does not show is not written, and the element is written at the top (notes/stage-2.md §11)
7388
+ const hidden = has(n.parent) && !up && `its parent ${n.parent}, which the ${byId.has(n.parent) ? 'view does not show' : 'model does not have'}`;
7389
+ if (n.type === 'uml.Comment') {
7390
+ if (nl(n.id)) skip(n);
7391
+ else { notes.push(n); if (has(n.parent)) lose(n.id, hidden || `its parent ${n.parent}: a note is written outside namespaces`); }
7392
+ } 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
7393
  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 { if (up) lose(n.id, `its parent ${n.parent}, a ${up.type}, which only a namespace can be`); roots.push(n); }
7394
+ else {
7395
+ // Mermaid splits a namespace's name at its dots, in backticks too, so one whose id is its hidden parent's dotted path
7396
+ // has that parent drawn all the same; the line says so once the namespace is known to be written
7397
+ if (hidden && isPkg(n) && String(n.id).startsWith(`${n.parent}.`)) splits.push([notWritten.length, n]);
7398
+ if (up || hidden) lose(n.id, hidden || `its parent ${n.parent}, a ${up.type}, which only a namespace can be`);
7399
+ roots.push(n);
7400
+ }
6823
7401
  }
6824
- const walk = n => { tree.push(n); for (const k of kids.get(n.id) || []) walk(k); };
6825
- roots.forEach(walk);
7402
+ // parents first, by a stack: a model nested thousands deep is written, not a call stack overflowed
7403
+ 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
7404
  const inTree = new Set(tree);
6827
7405
  for (const n of boxes) if (!inTree.has(n)) skip(n); // under a namespace that is not written, or in a cycle of parents
7406
+ // Mermaid reads no empty namespace (its grammar wants a statement between the braces): one with no class in it, at any
7407
+ // depth, is not written. The tree is parents first, so read backwards a namespace's contents come before it
7408
+ const full = new Set();
7409
+ for (let k = tree.length - 1; k >= 0; k--) if (!isPkg(tree[k]) || full.has(tree[k].id)) full.add(tree[k].parent);
7410
+ for (const n of tree) if (isPkg(n) && !full.has(n.id)) skip(n);
7411
+ 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
7412
  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();
7413
+ const used = new Set(tree.filter(n => !isPkg(n) || full.has(n.id)).map(n => n.id)), next = new Map();
6830
7414
  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
7415
 
6832
7416
  const heads = new Map(); // a class's annotations and member rows
@@ -6887,22 +7471,1730 @@ function writeMermaidClass(model, viewId) {
6887
7471
  });
6888
7472
  links.filter(e => !notes.some(n => n.id === e.from)).forEach(skip);
6889
7473
 
6890
- const labelled = n => (has(n.label) && n.label !== n.id ? `["${enc(n.label)}"]` : '');
6891
- const emit = (n, pad) => {
6892
- if (isPkg(n)) {
6893
- out.push(`${pad}namespace ${name(n.id)}${labelled(n)} {`);
6894
- for (const k of kids.get(n.id) || []) emit(k, pad + ' ');
6895
- return out.push(`${pad}}`);
7474
+ const labelled = (n, as = n.id) => (has(n.label) && n.label !== as ? `["${enc(n.label)}"]` : '');
7475
+ // Mermaid names a namespace inside P as P.<name>, and splits a name at its dots, in backticks too. So a namespace whose
7476
+ // 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
7477
+ // by that dotted id, which Mermaid and the reader both read as that nesting with those ids; and one whose block would
7478
+ // hold only such namespaces is written by them alone
7479
+ // a written namespace's name clashes only with a written namespace beside it: the reader, as Mermaid, keys namespaces
7480
+ // by their parent and their name, apart from classes
7481
+ const plain = id => /^[\p{L}\p{N}_]+(-[\p{L}\p{N}_]+)*$/u.test(id), top = new Set(roots), dotted = new Set();
7482
+ 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)))]));
7483
+ for (const n of tree) if (isPkg(n) && full.has(n.id) && !top.has(n)) {
7484
+ 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
7485
+ 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);
7486
+ }
7487
+ // by a stack, as the tree is walked: a namespace's opening line, what it holds, then its } (a string on the stack)
7488
+ const emit = (first, pad0) => {
7489
+ for (const st = [[first, pad0]]; st.length;) {
7490
+ const [n, pad] = st.pop();
7491
+ if (typeof n === 'string') { out.push(n); continue; }
7492
+ if (isPkg(n)) {
7493
+ const inner = (kids.get(n.id) || []).filter(k => !isPkg(k) || (full.has(k.id) && !dotted.has(k.id)));
7494
+ const as = dotted.has(n.id) ? String(n.id).slice(String(n.parent).length + 1) : n.id; // the name the reader gives its label
7495
+ if (!full.has(n.id)) continue;
7496
+ 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; }
7497
+ out.push(`${pad}namespace ${dotted.has(n.id) ? n.id : name(n.id)}${labelled(n, as)} {`);
7498
+ st.push([`${pad}}`]);
7499
+ const in2 = pad.length < 64 ? pad + ' ' : pad; // indented 32 deep at most, so a deep model's text is not deep * deep long
7500
+ for (let k = inner.length - 1; k >= 0; k--) st.push([inner[k], in2]);
7501
+ continue;
7502
+ }
7503
+ const [anns, rows] = heads.get(n.id), head = `${pad}class ${name(n.id)}${labelled(n)}`;
7504
+ if (!anns.length && !rows.length) { out.push(head); continue; }
7505
+ out.push(head + ' {', ...anns.map(a => `${pad} <<${a}>>`));
7506
+ for (const [hint, text] of rows) out.push(...(hint ? [`${pad} %% id: ${hint}`] : []), `${pad} ${text}`);
7507
+ out.push(`${pad}}`);
6896
7508
  }
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
7509
  };
6903
7510
  roots.forEach(n => emit(n, ' '));
7511
+ for (const n of tree) if (dotted.has(n.id)) emit(n, ' ');
6904
7512
  for (const [hint, text] of relations) out.push(...(hint ? [` %% id: ${hint}`] : []), ` ${text}`);
6905
7513
  for (const [hint, link, text] of noteLines) out.push(...(hint ? [` %% id: ${hint}`] : []), ...(link ? [` %% link-id: ${link}`] : []), ` ${text}`);
6906
7514
  for (const x of notWritten) out.push(` %% not written: ${x}`);
6907
7515
  return out.join('\n') + '\n';
6908
7516
  }
7517
+ /* ---------- the time-axis layout (work package 3.2, notes/stage-3.md §3.2) ----------
7518
+ layoutTimeAxis(input) -> out, the geometry of a sequence (and later of an interaction overview's inline interaction): no
7519
+ DOM, no model. Sizes come in measured (the type measures inside the host's measure; src/uml/frame.js gives the sizes of
7520
+ what its shapes draw). Only lib/umlimator-all.js holds this file.
7521
+
7522
+ input = {
7523
+ heads: [{ id, w, h, born, ends }], lifelines, left to right; born, ends: the row that creates it, deletes it
7524
+ rows: [{ id, kind, a, b, w, h, parent }], occurrences in time order (an index here is an index into view.edges).
7525
+ kind 'line' (head a to head b), 'self' (a), 'span' (heads a to b: an
7526
+ interaction use); w, h: its label's box (a use's: frame.js useSize);
7527
+ parent: its operand, or none
7528
+ groups: [{ id, parent, tag: { w, h }, parts: [{ id, guard: { w, h } }] }], fragments, outermost first; parts: operands
7529
+ bars: [{ head, from, to, depth }], activations by row (src/uml/interaction.js); depth 1 is the outermost
7530
+ }
7531
+ out = { heads: [{ id, x, y, w, h }], stems: [{ head, x, y1, y2, cross, gaps: [{ y1, y2 }] }],
7532
+ bars: [{ head, depth, x, y, w, h }], frames: [{ id, x, y, w, h, dividers: [{ part, y }], parts: [{ id, x, y, w, h, gx, gy }] }],
7533
+ spans: [{ id, x, y, w, h }], lines: [{ id, pts, lbl }], slots: [{ index, y, parent, after }], bounds, problems }
7534
+ 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.
7535
+ Problems: { level, message, ids }, never warn(). */
7536
+
7537
+ const TA = {
7538
+ top: 16, head: 16, // the heads' top; from the tallest head to the first row
7539
+ band: 8, // a slot's band: every slot gets its own, so no two share a y
7540
+ above: 4, after: 6, // a label's bottom to its arrow; an arrow to its row's end
7541
+ drop: 16, loop: 28, // a self-message's loop: how far down (at least its label's height), how far right of its bar
7542
+ 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
7543
+ gap: 24, room: 6, // between two heads; between a label and what is beside it
7544
+ 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
7545
+ 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
7546
+ 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
7547
+ };
7548
+
7549
+ const taArr = x => (Array.isArray(x) ? x : []);
7550
+ const taNum = x => (Number.isFinite(x) ? x : 0);
7551
+
7552
+ function layoutTimeAxis(input = {}) {
7553
+ const heads = taArr(input.heads), rows = taArr(input.rows), groups = taArr(input.groups), problems = [];
7554
+ const say = (level, message, ids) => problems.push({ level, message, ids });
7555
+ 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));
7556
+ const rowOk = i => Number.isInteger(i) && i >= 0 && i < rows.length;
7557
+ 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)));
7558
+ 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)));
7559
+
7560
+ // operands: each one's fragment and place in it
7561
+ const opOf = new Map();
7562
+ groups.forEach(g => taArr(g.parts).forEach((p, k) => opOf.set(p.id, { g, k })));
7563
+ // each fragment's parent, checked once: one that is not an operand, or that lies inside the fragment itself, is reported
7564
+ // on the fragment, which is then drawn at the top
7565
+ const upOf = new Map(groups.map(g => [g.id, g.parent ?? null]));
7566
+ groups.forEach(g => { const p = upOf.get(g.id);
7567
+ 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); } });
7568
+ groups.forEach(g => { for (let p = upOf.get(g.id), n = 0; p != null && n <= groups.length; n++) { const o = opOf.get(p).g;
7569
+ 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; }
7570
+ p = upOf.get(o.id); } });
7571
+ const chainOf = (parent, who) => { const out = [], seen = new Set();
7572
+ for (let p = parent; p != null;) { const o = opOf.get(p);
7573
+ if (!o) { say('error', `${who}: its parent ${p} is not an operand: drawn outside it`, [who]); break; }
7574
+ if (seen.has(o.g.id)) break; seen.add(o.g.id); out.unshift(o); p = upOf.get(o.g.id); }
7575
+ return out; };
7576
+ // 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
7577
+ const full = new Set();
7578
+ 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); } });
7579
+
7580
+ // ---- down the page: rows, frames, slots
7581
+ const R = [], frames = [], slots = [], open = [], placed = new Set(), seenFrame = new Set();
7582
+ let y = TA.top + Math.max(0, ...hh.filter((_, j) => born[j] == null)) + TA.head, ri = 0, topN = 0;
7583
+ 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; };
7584
+ const partOf = L => (L ? L.g.parts[L.k].id : null);
7585
+ const item = L => { if (L ? L.n : topN) slot(partOf(L)); if (L) L.n++; else topN++; };
7586
+ const guard = (g, k) => (g.parts[k] && g.parts[k].guard) || { w: 0, h: 0 };
7587
+ const startPart = L => { L.n = 0; slot(partOf(L)); };
7588
+ const endPart = L => { orphans(partOf(L)); if (!L.n) y += TA.empty; slot(partOf(L)); L.fr.parts[L.k].bottom = y; };
7589
+ const divider = L => { L.k++; const gd = guard(L.g, L.k); L.fr.dividers.push({ part: L.g.parts[L.k].id, y });
7590
+ 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; };
7591
+ const advance = (L, k) => { while (L.k < k) { endPart(L); divider(L); startPart(L); } };
7592
+ function openFrag(g, k) {
7593
+ const L0 = open[open.length - 1] || null; item(L0);
7594
+ 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]);
7595
+ seenFrame.add(g.id); placed.add(g.id);
7596
+ const tag = g.tag || { w: 0, h: 0 }, g0 = guard(g, 0), head = Math.max(tag.h, g0.h) + TA.hdr;
7597
+ 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 }] };
7598
+ if (fr.up) fr.up.kids.push(fr);
7599
+ frames.push(fr); y += head;
7600
+ const L = { g, k: 0, fr, n: 0 }; open.push(L); startPart(L); advance(L, k);
7601
+ }
7602
+ 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(); }
7603
+ function orphans(parent) { // the fragments of this operand (or of the top) with nothing in them: at its end, each with a warning
7604
+ groups.filter(g => !full.has(g.id) && !placed.has(g.id) && taArr(g.parts).length && upOf.get(g.id) === parent).forEach(g => {
7605
+ 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]);
7606
+ openFrag(g, 0); closeTop(); });
7607
+ }
7608
+ groups.filter(g => !taArr(g.parts).length).forEach(g => say('warning', `fragment ${g.id} has no operand: not drawn`, [g.id]));
7609
+
7610
+ slot(null);
7611
+ for (; ri < rows.length;) {
7612
+ const r = rows[ri], C = chainOf(r.parent, r.id).filter(o => taArr(o.g.parts).length);
7613
+ let m = 0; while (m < open.length && m < C.length && open[m].g === C[m].g && open[m].k === C[m].k) m++;
7614
+ 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++; }
7615
+ else while (open.length > m) closeTop();
7616
+ for (; m < C.length; m++) openFrag(C[m].g, C[m].k);
7617
+ 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);
7618
+ if (ia == null || ib == null || !['line', 'self', 'span'].includes(r.kind)) {
7619
+ 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]);
7620
+ R.push(null); ri++; continue; }
7621
+ item(L);
7622
+ 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 };
7623
+ if (it.kind === 'span') { it.y = y; it.yb = y + h; y += h; }
7624
+ else if (it.kind === 'self') { it.y = y + TA.above; it.yb = it.y + Math.max(TA.drop, h); y = it.yb + TA.after; }
7625
+ 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); }
7626
+ open.forEach(O => { O.fr.lo = Math.min(O.fr.lo, it.a); O.fr.hi = Math.max(O.fr.hi, it.b); });
7627
+ it.up = L ? L.fr : null; if (it.up && it.kind === 'span') it.up.kids.push(it);
7628
+ R.push(it); ri++;
7629
+ }
7630
+ while (open.length) closeTop();
7631
+ orphans(null);
7632
+ slot(null);
7633
+ const end = y + TA.tail;
7634
+
7635
+ // ---- across: each head's reach either side of its stem, then the columns
7636
+ 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 => {
7637
+ const ok = b.j != null && rowOk(b.from) && rowOk(b.to) && R[b.from] && R[b.to] && b.to >= b.from;
7638
+ 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]);
7639
+ return ok; });
7640
+ const depth = heads.map((_, j) => Math.max(1, ...bars.filter(b => b.j === j).map(b => b.d)));
7641
+ const solidL = heads.map((_, j) => Math.max(TA.bar / 2, born[j] != null ? cw[j] / 2 : 0));
7642
+ const solidR = heads.map((_, j) => Math.max(TA.bar / 2 + (depth[j] - 1) * TA.step, born[j] != null ? cw[j] / 2 : 0));
7643
+ 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)));
7644
+ const adj = heads.map((_, j) => (j ? Math.max((cw[j - 1] + cw[j]) / 2 + TA.gap, reachR[j - 1] + TA.room + solidL[j]) : 0));
7645
+ const far = []; // [i, k, need]: X[k] - X[i] >= need
7646
+ 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]); };
7647
+ 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]); } });
7648
+ // frames and uses: their edges, from the inside out (a frame's kids come after it in frames, so backwards)
7649
+ const spans = R.filter(it => it && it.kind === 'span').map(it => ({ lo: it.a, hi: it.b, kids: [], need: it.w, span: true, it }));
7650
+ frames.forEach(fr => { fr.kids = fr.kids.map(k => (k.r ? spans.find(s => s.it === k) : k)); });
7651
+ const edgeOf = b => {
7652
+ if (b.span) { b.n = 0; } else {
7653
+ b.n = Math.max(0, ...b.kids.map(k => k.n + 1));
7654
+ const tag = b.g.tag || { w: 0, h: 0 };
7655
+ 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);
7656
+ }
7657
+ if (!heads.length) { b.L = b.R = 0; return; }
7658
+ 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;
7659
+ 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); });
7660
+ 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);
7661
+ need(b.lo - 1, b.lo, solidR[b.lo - 1] + TA.room + b.L);
7662
+ need(b.hi, b.hi + 1, b.R + TA.room + solidL[b.hi + 1]);
7663
+ if (b.lo !== b.hi) need(b.lo, b.hi + 1, b.need - b.L + TA.room + solidL[b.hi + 1]);
7664
+ };
7665
+ 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
7666
+ spans.forEach(edgeOf); frames.slice().reverse().forEach(edgeOf);
7667
+ const left0 = heads.length ? Math.max(cw[0] / 2, solidL[0], ...[...spans, ...frames].filter(b => b.lo === 0).map(b => b.L)) : 0;
7668
+ const X = [];
7669
+ 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); }); });
7670
+
7671
+ // ---- the pieces
7672
+ 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; };
7673
+ 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);
7674
+ 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);
7675
+ const active = (j, at) => outBars.filter(b => b.j === j && b.y0 <= at + .5 && at - .5 <= b.y1).pop() || null; // the deepest
7676
+ const side = (j, b, dir) => (b ? X[j] + (b.depth - 1) * TA.step + dir * TA.bar / 2 : X[j]);
7677
+ 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]) }));
7678
+ const lines = [];
7679
+ R.forEach(it => { if (!it || it.kind === 'span') return;
7680
+ 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;
7681
+ 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 }; }
7682
+ else { const dir = Math.sign(it.to - it.from), sx = side(it.from, active(it.from, it.y), dir);
7683
+ const ex = born[it.to] === it.ri ? X[it.to] - dir * cw[it.to] / 2 : side(it.to, active(it.to, it.y), -dir);
7684
+ const n = it.from + dir, near = n === it.to ? ex : dir > 0 ? X[n] - solidL[n] : X[n] + solidR[n], cx = (sx + near) / 2;
7685
+ 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 }; }
7686
+ 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) } }); });
7687
+ 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]);
7688
+ 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) }; });
7689
+ const outFrames = frames.map(fr => { const [x0, x1] = boxX(fr);
7690
+ 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) })),
7691
+ 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) })) }; });
7692
+
7693
+ // 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
7694
+ const reach = it => { if (it.kind === 'span') return [it.box[0], it.box[1], it.y, it.yb];
7695
+ const xs = [...it.pts.map(p => p[0]), it.lbl.x, it.lbl.x + it.w];
7696
+ 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]; };
7697
+ const bands = R.filter(Boolean).map(reach);
7698
+ 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 = [];
7699
+ 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])
7700
+ .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]); });
7701
+ 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) })) }; });
7702
+
7703
+ 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]),
7704
+ ...lines.flatMap(l => [...l.pts.map(p => p[0]), l.lbl.x, l.lbl.x + l.lbl.w])];
7705
+ const x0 = Math.min(0, ...xs.map(x => x - TA.edge)), x1 = Math.max(TA.edge, ...xs.map(x => x + TA.edge));
7706
+ return {
7707
+ heads: outHeads, stems, lines, spans: outSpans, frames: outFrames, slots,
7708
+ 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) })),
7709
+ bounds: { x: r2(x0), y: 0, w: r2(x1 - x0), h: r2(end + TA.edge) }, problems,
7710
+ };
7711
+ }
7712
+ /* ---------- frames and lifelines (work package 3.2, notes/stage-3.md §4.2) ----------
7713
+ The shapes a time-axis view draws as nodes, so the canvas selects, names and focuses them as it does any node:
7714
+ - lifeline: the classifier with no compartment ("name : Type", «actor» over it), and inside its group a transparent strip
7715
+ 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
7716
+ lifeline and one on a message, its arrowhead or a loop lands on the message (S3);
7717
+ - frame: a combined fragment's pentagon tag (n.label, the operator) and a transparent outline to hit;
7718
+ - operand: its guard, "[label]", at (n.gx, n.gy), on a backing;
7719
+ - use: an interaction use's "ref" tag and its name, centred, on a backing, over a transparent box to hit.
7720
+ Outlines, dividers and a use's box are not the nodes': frame() draws them, in the type's `under` layer, before the bars.
7721
+ 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
7722
+ are tagSize(), guardSize() and useSize(), which the type measures with and gives layoutTimeAxis. Only lib/umlimator-all.js
7723
+ holds this file. */
7724
+
7725
+ 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
7726
+ const FR_LINE = 'var(--um-muted)'; // 3:1 or more on the stage in both themes, as a typed view's lines (1.11)
7727
+ const tagSize = t => ({ w: Math.ceil(textW(t, FR.tag) + 14), h: FR.tagH });
7728
+ const guardSize = t => (t == null || t === '' ? { w: 0, h: 0 } : { w: Math.ceil(textW(`[${t}]`, FR.guard) + 6), h: FR.guardH });
7729
+ const useSize = t => ({ w: Math.ceil(2 * (tagSize('ref').w + TA.gx) + textW(t, FR.name) + 6), h: 2 * FR.tagH + 4 });
7730
+ 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>`;
7731
+
7732
+ // UML's frame without its tag: the outline and a dashed divider at each y, for the `under` layer. Stage 4's inline
7733
+ // interactions, state regions and partitions draw theirs with it too
7734
+ function frame(x, y, w, h, dividers = []) {
7735
+ 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"/>`
7736
+ + 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>';
7737
+ }
7738
+ // the pentagon tag at a frame's top left corner, max: the room it may take (its text is cut to it)
7739
+ function frameTag(x, y, t, max = Infinity) {
7740
+ const s = tagSize(t), w = Math.min(s.w, max), h = s.h;
7741
+ 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}"/>`
7742
+ + frText(x + 6, y + 12.5, fit(t, FR.tag, w - 12), FR.tag);
7743
+ }
7744
+ 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>`;
7745
+
7746
+ SHAPE.lifeline = {
7747
+ size: n => SHAPE.classifier.size({ ...n, compartments: [] }),
7748
+ draw(n, c, f) {
7749
+ const head = SHAPE.classifier.draw({ ...n, compartments: [], type: n.type || 'uml.Lifeline' }, c, f), S = n.stem, cuts = [];
7750
+ 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); }); }
7751
+ const strip = cuts.length ? `<path d="${cuts.join('')}" class="ll-hit" fill="none" stroke="transparent" stroke-width="${2 * TA.hit}"/>` : '';
7752
+ // the classifier's group, named a lifeline, with the strip inside it
7753
+ return head.replace('class="node classifier"', 'class="node lifeline"').replace('data-shape="classifier"', 'data-shape="lifeline"')
7754
+ .replace(/aria-label="[^"]*"/, m => (n.aria ? `aria-label="${esc(n.aria)}"` : m)).replace(/<\/g>$/, strip + '</g>');
7755
+ },
7756
+ };
7757
+ SHAPE.frame = {
7758
+ 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)}`),
7759
+ };
7760
+ SHAPE.operand = {
7761
+ 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);
7762
+ 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'}"/>`
7763
+ + (t ? frText(n.gx + 3, n.gy + 10.5, fit(t, FR.guard, w - 6), FR.guard, 500) : '')); },
7764
+ };
7765
+ SHAPE.use = {
7766
+ 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);
7767
+ 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')}`
7768
+ + `<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')}`); },
7769
+ };
7770
+ /* ---------- interaction semantics, shared by uml.sequence and uml.communication (work package 3.1) ----------
7771
+ 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):
7772
+ the occurrences in time order with the operands they lie in, the activation bars by the five rules of
7773
+ notes/spike-sequence.md §2.3, the message numbers (S8), the call a new reply answers, and the rules both types check.
7774
+ A sequence view's `edges` holds its occurrences inline; a communication view's names model edges, connectors first,
7775
+ which the walk passes over. Ships in lib/umlimator-all.js only. */
7776
+
7777
+ const IA_ARR = x => (Array.isArray(x) ? x : []), IA_OBJ = x => x !== null && typeof x === 'object' && !Array.isArray(x);
7778
+ const SORTS = ['call', 'async', 'reply', 'create', 'delete'];
7779
+
7780
+ /* The occurrence walk: { occ, ops }. occ, in time order: { id, i, r, path, el, message, from, to, sort, replyTo, parent },
7781
+ where i is the entry's index in view.edges (a slot's index), r its row among the occurrences, path where validate()
7782
+ finds it, sort one of SORTS (any other is a call) and null on an interaction use. ops: operand id → { id, el, fragment } */
7783
+ function interactionWalk(model, view) {
7784
+ 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);
7785
+ const byId = new Map(), ops = new Map(), occ = [];
7786
+ IA_ARR(model && model.edges).forEach((e, k) => { if (IA_OBJ(e) && !byId.has(e.id)) byId.set(e.id, [e, `edges[${k}]`]); });
7787
+ 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 }); }); });
7788
+ IA_ARR(view && view.edges).forEach((x, i) => {
7789
+ const [el, path] = typeof x === 'string' ? byId.get(x) || [] : [x, `views[${vi}].edges[${i}]`];
7790
+ 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)
7791
+ if (!IA_OBJ(el) || el.id == null || (message ? el.from == null || el.to == null : el.type !== 'uml.InteractionUse')) return;
7792
+ const p = IA_OBJ(el.props) ? el.props : {};
7793
+ occ.push({ id: el.id, i, r: occ.length, path, el, message, from: el.from, to: el.to,
7794
+ sort: message ? (SORTS.includes(p.sort) ? p.sort : 'call') : null, replyTo: p.replyTo ?? null, parent: el.parent ?? null });
7795
+ });
7796
+ return { occ, ops };
7797
+ }
7798
+
7799
+ // an operand and every operand around it, innermost first (a cycle in `parent` stops the walk)
7800
+ function operandChain(w, id) {
7801
+ const out = []; while (id != null && w.ops.has(id) && !out.includes(id)) { out.push(id); id = w.ops.get(id).fragment.parent; }
7802
+ return out;
7803
+ }
7804
+ // whether what lies in operand p and what lies in operand q are in different operands of one alt (null: the top level)
7805
+ const apartInAlt = (w, p, q) => { const cq = operandChain(w, q);
7806
+ 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); }); };
7807
+
7808
+ /* Activations, derived by intervals (notes/spike-sequence.md §2.3): { head, call, to, a, b, depth }, a and b the rows of
7809
+ the call that opens it and the message that closes it */
7810
+ function barsOf(w) {
7811
+ const ms = w.occ.filter(o => o.message), bars = [];
7812
+ ms.forEach(c => { if (c.sort !== 'call') return; // 1: a call opens one on its receiver at its row; 4: nothing else opens one
7813
+ 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
7814
+ 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
7815
+ const next = ms.find(m => m.r > c.r && m.from === c.from), until = next ? next.r : Infinity;
7816
+ end = ms.filter(m => m.r > c.r && m.r < until && (m.from === c.to || m.to === c.to)).pop(); }
7817
+ 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 }); });
7818
+ /* 5: one level deeper for each bar of its lifeline it starts inside (for bars that nest, the ones that contain it). A bar
7819
+ that opens on the row another closes on (rule 3 closes one on an incoming call) only touches it, unless it is a
7820
+ self-call: that one is sent from inside the bar and comes back into it */
7821
+ 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; });
7822
+ return bars;
7823
+ }
7824
+
7825
+ /* The bars of a view, time-axis input (notes/stage-3.md §3.2): [{ head, from, to, depth }], from and to occurrence ids,
7826
+ depth 1 for a bar inside no other */
7827
+ const activationBars = (model, view) => barsOf(interactionWalk(model, view)).map(({ head, call, to, depth }) => ({ head, from: call, to, depth }));
7828
+
7829
+ /* Message numbers (S8): a message's place among the messages sent in the activation it is sent from, replies included,
7830
+ after that activation's call's number; 1, 2 … at the top level. Map: message id → '1.1.2' */
7831
+ function messageNumbers(model, view) {
7832
+ const w = interactionWalk(model, view), bars = barsOf(w), num = new Map(), count = new Map();
7833
+ w.occ.forEach(m => { if (!m.message) return;
7834
+ 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);
7835
+ const k = act ? act.call : null, n = (count.get(k) || 0) + 1; count.set(k, n);
7836
+ num.set(m.id, act ? `${num.get(act.call)}.${n}` : `${n}`); });
7837
+ return num;
7838
+ }
7839
+
7840
+ /* The call a new reply from `from` to `to` answers (§5.2): the latest call from its receiver to its sender before the
7841
+ slot `at` ({ index, parent }; absent: the end) that no reply answers, where a reply, or a call, in another operand of
7842
+ an alt than the slot's does not count. Its id, or null */
7843
+ function replyCallFor(model, view, from, to, at) {
7844
+ 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;
7845
+ for (let k = ms.length - 1; k >= 0; k--) { const c = ms[k];
7846
+ if (c.i < idx && c.sort === 'call' && c.from === to && c.to === from && !apartInAlt(w, c.parent, where)
7847
+ && !ms.some(r => r.r > c.r && r.sort === 'reply' && r.replyTo === c.id && !apartInAlt(w, r.parent, where))) return c.id; }
7848
+ return null;
7849
+ }
7850
+
7851
+ /* ---- the shared validators (notes/stage-3.md §4.3, §6.4). Each problem: { level, rule, path, message, ids }, as in
7852
+ src/uml/class.js. In a communication view there are no operands, so reply-once holds a call to one reply ---- */
7853
+ const INTERACTION_RULES = {
7854
+ 'reply-call': 'a reply names an earlier call of the view in replyTo, and goes back along it',
7855
+ 'reply-once': 'two replies to one call lie in different operands of one alt',
7856
+ 'create-once': 'a lifeline is created at most once, by the first message to or from it',
7857
+ 'delete-once': 'a lifeline is deleted at most once, by the last message to or from it',
7858
+ sort: 'a message\'s sort is call, async, reply, create or delete; any other is drawn as a call',
7859
+ };
7860
+ function interactionProblems(model, view) {
7861
+ 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 = [];
7862
+ const nm = m => `${m.id}${m.el.label != null && m.el.label !== '' ? ` ("${m.el.label}")` : ''}`, touches = (m, t) => m.from === t || m.to === t;
7863
+ 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}` });
7864
+ ms.forEach(m => {
7865
+ const raw = IA_OBJ(m.el.props) ? m.el.props.sort : undefined, c = byId.get(m.replyTo);
7866
+ 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');
7867
+ if (m.sort === 'reply') {
7868
+ if (m.replyTo == null) say('reply-call', m, '.props', [m.id], `${nm(m)} names no call in replyTo`, 'warning');
7869
+ 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`);
7870
+ 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})`);
7871
+ 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));
7872
+ 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`);
7873
+ replies.set(c.id, [...was, m]); } }
7874
+ if (m.sort === 'create' && m.to != null) { const e = ms.find(o => o.r < m.r && touches(o, m.to));
7875
+ 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`); }
7876
+ if (m.sort === 'delete' && m.to != null) { const e = ms.find(o => o.r > m.r && touches(o, m.to));
7877
+ 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`); }
7878
+ });
7879
+ return out;
7880
+ }
7881
+ /* ---------- the sequence diagram, uml.sequence (work package 3.3, notes/stage-3.md §4 and §5) ----------
7882
+ The subset of notes/stage-3.md §4.1: lifelines (`represents`, «actor» as text), messages inline in the view's `edges`
7883
+ (call, async, reply, create, delete), interaction uses inline among them, combined fragments in `fragments` (alt, opt,
7884
+ loop, par, break) with their operands as members. The meaning (occurrences, bars, reply matching, the shared rules) is
7885
+ src/uml/interaction.js; the geometry is src/layout/timeaxis.js; the shapes are src/uml/frame.js. This file joins them:
7886
+ the view as layoutTimeAxis's input, its output as svgOf's geometry with names and a reading order (geo.order, S13), the
7887
+ type's layers, its validators, describe(), the legend, and the gesture hooks of §5.1, each a pure function that returns
7888
+ commands for apply(), null, or { error }. Ships in lib/umlimator-all.js only. */
7889
+
7890
+ const SQ_ARR = x => (Array.isArray(x) ? x : []), SQ_OBJ = x => x !== null && typeof x === 'object' && !Array.isArray(x);
7891
+ const SQ_HAS = x => x != null && x !== '';
7892
+ const SQ_SORTS = ['call', 'async', 'reply', 'create', 'delete'], SQ_OPS = ['alt', 'opt', 'loop', 'par', 'break'];
7893
+ 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
7894
+ // how each sort is drawn (src/typed/draw.js's ENDS): a call filled, async, reply and create open, reply and create dashed
7895
+ const SQ_END = { call: { from: 'none', to: 'arrow' }, async: { from: 'none', to: 'open' }, reply: { from: 'none', to: 'open', dashed: true },
7896
+ create: { from: 'none', to: 'open', dashed: true }, delete: { from: 'none', to: 'arrow' } };
7897
+
7898
+ const sqView = (model, view) => (SQ_OBJ(view) ? view : SQ_ARR(model && model.views).find(v => SQ_OBJ(v) && v.id === view)) || null;
7899
+ const sqProps = x => (SQ_OBJ(x && x.props) ? x.props : {});
7900
+ // the view's lifelines by id: a model node, or one of the view's own
7901
+ function sqHeads(model, view) {
7902
+ 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); });
7903
+ return SQ_ARR(view.nodeIds).filter(id => at.has(id)).map(id => at.get(id));
7904
+ }
7905
+ const sqName = n => String(n.label ?? n.id);
7906
+ const sqHeadText = n => sqName(n) + (SQ_HAS(sqProps(n).represents) ? ' : ' + sqProps(n).represents : ''); // "api : ShopApi"
7907
+ const sqStereo = n => SQ_ARR(sqProps(n).stereotypes).filter(SQ_HAS).map(String);
7908
+ const sqOperator = f => String(sqProps(f).operator ?? '');
7909
+
7910
+ /* The view's structure, read once: the walk (interaction.js), each operand's fragment, each fragment's parent and its
7911
+ operands, which operand chain each occurrence lies in, and each fragment's block of occurrences (rows lo to hi) */
7912
+ function sqIndex(model, view) {
7913
+ const w = interactionWalk(model, view), frags = SQ_ARR(view.fragments).filter(f => SQ_OBJ(f) && typeof f.id === 'string');
7914
+ const F = new Map(frags.map(f => [f.id, f])), opOf = new Map(); // operand id → its fragment
7915
+ 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); }));
7916
+ // the fragments an occurrence or a fragment lies in, from the innermost out, each with the operand it lies in
7917
+ const chain = parent => { const out = [], seen = new Set();
7918
+ 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; }
7919
+ return out; };
7920
+ const occ = w.occ.map(o => ({ ...o, chain: chain(o.parent) }));
7921
+ const block = new Map(); // fragment id → the occurrences it holds, in time order
7922
+ occ.forEach(o => o.chain.forEach(({ f }) => { if (!block.has(f.id)) block.set(f.id, []); block.get(f.id).push(o); }));
7923
+ return { w, occ, frags, F, opOf, chain, block, byId: new Map(occ.map(o => [o.id, o])) };
7924
+ }
7925
+
7926
+ /* ---- layout: the view as layoutTimeAxis's input (notes/stage-3.md §3.2), measured with textW in the host's measure ---- */
7927
+ function sqInput(model, view, X = sqIndex(model, view)) {
7928
+ 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]));
7929
+ const rows = entries.map((x, i) => { const o = byI.get(i);
7930
+ 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)
7931
+ 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
7932
+ const ends = cs.length ? [cs[0], cs[cs.length - 1]] : heads.length ? [heads[0].id, heads[heads.length - 1].id] : [null, null];
7933
+ return { id: o.id, kind: 'span', a: ends[0], b: ends[1], parent: o.parent, ...useSize(String(o.el.label ?? '')) }; }
7934
+ const t = SQ_HAS(o.el.label) ? String(o.el.label) : '';
7935
+ 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 }) }; });
7936
+ 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; };
7937
+ const born = first('create'), ends = first('delete');
7938
+ const depth = f => X.chain(f.parent).length;
7939
+ 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)),
7940
+ parts: SQ_ARR(f.members).filter(o => SQ_OBJ(o) && typeof o.id === 'string').map(o => ({ id: o.id, guard: guardSize(o.label) })) }));
7941
+ 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 }));
7942
+ 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 };
7943
+ }
7944
+
7945
+ /* run(model, view): svgOf's geometry. Nodes: the heads (shape lifeline, with the stem's hit strip), the frames, operands and
7946
+ uses (frame.js's shapes); edges: the messages, with their ends and labels; ta: layoutTimeAxis's output, for draw(); slots
7947
+ and problems from it; order (S13): heads left to right, then fragments, operands and occurrences in time order */
7948
+ function sqRun(model, view) {
7949
+ 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]));
7950
+ const msgs = X.occ.filter(m => m.message), nth = new Map(msgs.map((m, k) => [m.id, k + 1]));
7951
+ 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) || {};
7952
+ return `, in ${sqOperator(c.f) || 'fragment'}${SQ_HAS(op.label) ? ', ' + op.label : ''}`; };
7953
+ const direct = id => X.occ.filter(m => m.parent === id).length;
7954
+ const nodes = [
7955
+ ...o.heads.map((h, j) => { const n = H.get(h.id), st = sqStereo(n);
7956
+ 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(', ')})` : ''}` }; }),
7957
+ ...o.frames.flatMap(fr => { const f = X.F.get(fr.id), op = sqOperator(f), ms = SQ_ARR(f.members).filter(SQ_OBJ);
7958
+ return [{ id: fr.id, shape: 'frame', type: 'uml.CombinedFragment', label: op, k: 'file', x: fr.x, y: fr.y, w: fr.w, h: fr.h, aria: `Combined fragment ${op}, ${ms.length} ${ms.length === 1 ? 'operand' : 'operands'}` },
7959
+ ...fr.parts.map(p => { const m = ms.find(x => x.id === p.id) || {}, k = direct(p.id);
7960
+ return { ...p, shape: 'operand', type: 'uml.InteractionOperand', label: m.label, k: 'file', aria: `Operand ${SQ_HAS(m.label) ? m.label + ' ' : ''}of ${op}, ${k} ${k === 1 ? 'occurrence' : 'occurrences'}` }; })]; }),
7961
+ ...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;
7962
+ return { ...s, shape: 'use', type: 'uml.InteractionUse', label: String(u.el.label ?? ''), k: 'file',
7963
+ 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])) : ''}` : ''}` }; }),
7964
+ ];
7965
+ const byId = Object.fromEntries(nodes.map(n => [n.id, n]));
7966
+ const edges = o.lines.map(l => { const m = X.byId.get(l.id), t = SQ_HAS(m.el.label) ? String(m.el.label) : '';
7967
+ 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,
7968
+ ...(t ? { lbl: { ...l.lbl, text: t }, label: t } : {}),
7969
+ 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)}` }; });
7970
+ // reading order: by height on the page; at one height a frame before its first operand, both before what they hold
7971
+ 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])];
7972
+ const order = [...o.heads.map(h => h.id), ...at.sort((a, b) => a[1] - b[1] || a[2] - b[2]).map(x => x[0])];
7973
+ return { nodes, edges, groups: [], byId, bounds: o.bounds, map: false, order, slots: o.slots, problems: o.problems, ta: o, legend: sqLegend(edges) };
7974
+ }
7975
+
7976
+ /* draw, all under the lines (notes/stage-3.md §4.2): frame outlines and dividers, then the stems, then each use's box
7977
+ (filled, so stems pass behind it), then the bars, deepest last, then a deleted lifeline's cross */
7978
+ function sqDraw(g, kit) {
7979
+ const o = g.ta, r2 = kit.r2;
7980
+ if (!o) return { under: '', over: '' };
7981
+ 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('')
7982
+ + 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('')
7983
+ + 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('')
7984
+ + 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('')
7985
+ + 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>';
7986
+ return { under, over: '' };
7987
+ }
7988
+
7989
+ /* the legend (pieces.js shows it): each sort of message the view draws */
7990
+ const SQ_WORDS = { call: ['Call', 'the sender waits for it'], async: ['Async', 'the sender goes on'], reply: ['Reply', 'the answer to a call'],
7991
+ create: ['Create', 'creates the lifeline it points to'], delete: ['Delete', 'ends the lifeline it points to (×)'] };
7992
+ const SQ_TIP = { arrow: '<path d="M17 4L26 8L17 12Z" fill="var(--um-muted)"/>', open: '<path d="M17 4L26 8L17 12" fill="none"/>' };
7993
+ function sqLegend(edges) {
7994
+ const has = new Set(edges.map(e => e.sort));
7995
+ return SQ_SORTS.filter(s => has.has(s)).map(s => { const E = SQ_END[s], [name, says] = SQ_WORDS[s];
7996
+ 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>`; });
7997
+ }
7998
+
7999
+ /* ---- validators (notes/stage-3.md §4.3). Each problem: { level, rule, path, message, ids }; the shared ones are
8000
+ interaction.js's ---- */
8001
+ const SQ_RULES = {
8002
+ 'parent-operand': 'an occurrence\'s or a fragment\'s parent is an operand of this view, and no fragment lies inside itself',
8003
+ contiguous: 'a fragment\'s occurrences, nested ones included, are one run in edges, operand by operand in members order',
8004
+ 'message-ends': 'a message joins two lifelines of the view',
8005
+ ...INTERACTION_RULES,
8006
+ covers: 'an interaction use covers lifelines of the view that are next to each other in nodeIds',
8007
+ operator: 'a fragment\'s operator is alt, opt, loop, par or break; opt, loop and break have one operand',
8008
+ empty: 'no operand or fragment is empty',
8009
+ view: 'a sequence view has no layout pins and is drawn down',
8010
+ };
8011
+ function sqProblems(model, view) {
8012
+ const out = [], V = sqView(model, view); if (!SQ_OBJ(model) || !V) return out;
8013
+ 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]));
8014
+ const say = (rule, path, who, msg, level = 'error') => out.push({ level, rule, path, ids: who.filter(x => x != null), message: `${rule}: ${msg}` });
8015
+ const fPath = f => `${at}.fragments[${SQ_ARR(V.fragments).indexOf(f)}]`;
8016
+ // parent-operand: an occurrence's or a fragment's parent; a fragment inside itself
8017
+ 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}`); });
8018
+ X.frags.forEach(f => { if (f.parent == null) return;
8019
+ 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}`);
8020
+ 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; } });
8021
+ // contiguous: each fragment's occurrences one run, operand by operand
8022
+ X.frags.forEach(f => { const os = X.block.get(f.id) || [], ops = SQ_ARR(f.members).map(m => SQ_OBJ(m) && m.id);
8023
+ const k = o => ops.indexOf(o.chain.find(c => c.f === f).op);
8024
+ 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]));
8025
+ 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'}`); });
8026
+ // message-ends
8027
+ X.occ.filter(o => o.message).forEach(o => { const off = [o.from, o.to].filter(id => !col.has(id));
8028
+ 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}`); });
8029
+ out.push(...interactionProblems(model, V));
8030
+ // covers
8031
+ X.occ.filter(o => !o.message).forEach(o => { const cs = SQ_ARR(sqProps(o.el).covers);
8032
+ if (!cs.length) return say('covers', o.path + '.props', [o.id], `${o.id} covers no lifeline: drawn across all of them`, 'warning');
8033
+ 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);
8034
+ 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}`);
8035
+ 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`); });
8036
+ // operator, empty
8037
+ const holds = id => X.occ.some(o => o.chain.some(c => c.op === id));
8038
+ X.frags.forEach(f => { const op = sqProps(f).operator, ms = SQ_ARR(f.members).filter(SQ_OBJ);
8039
+ 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');
8040
+ 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');
8041
+ 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');
8042
+ 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'); }); });
8043
+ // view
8044
+ 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');
8045
+ 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');
8046
+ return out;
8047
+ }
8048
+
8049
+ /* ---- describe(): the view in words, in time order, nested as it is drawn ---- */
8050
+ const SQ_VERB = { call: 'calls', async: 'sends to', reply: 'replies to', create: 'creates', delete: 'deletes' };
8051
+ function sqDescribe(model, viewId) {
8052
+ 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)}`);
8053
+ 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));
8054
+ 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]));
8055
+ const count = (k, w) => `${k} ${k === 1 ? w : w + 's'}`;
8056
+ 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')}.`];
8057
+ if (heads.length) out.push(`Lifelines, left to right: ${heads.map(h => sqHeadText(h) + (sqStereo(h).length ? ` (${sqStereo(h).join(', ')})` : '')).join('; ')}.`);
8058
+ let open = [];
8059
+ 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++;
8060
+ open = open.slice(0, k);
8061
+ for (; k < c.length; k++) { const { f, op } = c[k], m = SQ_ARR(f.members).find(x => SQ_OBJ(x) && x.id === op) || {};
8062
+ out.push(`${' '.repeat(k)}${sqOperator(f) || 'fragment'}${SQ_HAS(m.label) ? ` [${m.label}]` : ''}:`); open.push(c[k]); }
8063
+ const pad = ' '.repeat(c.length), t = SQ_HAS(o.el.label) ? `: ${o.el.label}` : '';
8064
+ 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; }
8065
+ const to = o.from === o.to ? 'itself' : nm(o.to), call = o.sort === 'reply' && n.has(o.replyTo) ? ` (answers ${n.get(o.replyTo)})` : '';
8066
+ out.push(`${pad}${n.get(o.id)}. ${nm(o.from)} ${SQ_VERB[o.sort]} ${to}${call}${t}.`); });
8067
+ return out.join('\n');
8068
+ }
8069
+
8070
+ /* ---- the gesture hooks (notes/stage-3.md §5.1, §5.2). Each takes the model and the view (or its id) and returns the
8071
+ commands of one transaction, null to leave the gesture to the canvas's default, or { error } for the hint line. A
8072
+ transaction that would add an error the model did not have is never returned: { error } names the rule (S5) ---- */
8073
+ function sqNewId(model, base, more = []) { // the base and the first number free in the model: 'm11', 'f3'
8074
+ const used = new Set(more); JSON.stringify(model, (k, v) => { if (k === 'id' && typeof v === 'string') used.add(v); return v; });
8075
+ 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;
8076
+ }
8077
+ const sqErr = message => ({ error: message });
8078
+ const sqErrKey = p => `${p.rule}|${[...p.ids].sort().join()}`;
8079
+ // the commands, unless they throw or bring an error the view did not have
8080
+ function sqGuard(model, V, commands) {
8081
+ if (!Array.isArray(commands) || !commands.length) return commands;
8082
+ let next; try { next = apply(model, commands); } catch (x) { return sqErr(String(x && x.message || x)); }
8083
+ const was = new Set(sqProblems(model, V).filter(p => p.level === 'error').map(sqErrKey));
8084
+ const bad = sqProblems(next, sqView(next, V.id)).find(p => p.level === 'error' && !was.has(sqErrKey(p)));
8085
+ return bad ? sqErr(bad.message) : commands;
8086
+ }
8087
+ // the slots of a view (layoutTimeAxis's): their index and parent do not hang on any size
8088
+ const sqSlots = (model, V, X) => layoutTimeAxis(sqInput(model, V, X)).slots;
8089
+ // an occurrence's or a fragment's run in edges: { lo, hi, ids, parent, inside (the operands in it) }, or { error }
8090
+ function sqRunOf(X, V, id) {
8091
+ 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() };
8092
+ 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}`);
8093
+ const os = X.block.get(f.id) || [], inside = new Set([...X.opOf.keys()].filter(op => X.chain(op).some(c => c.f === f)));
8094
+ 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`);
8095
+ 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 };
8096
+ }
8097
+ // the commands that put a run at a slot { index, parent }: one move per occurrence it holds, in order, then a reparent
8098
+ function sqPut(V, id, R, at, el) {
8099
+ if (R.inside.has(at.parent)) return sqErr(`${id} cannot move into itself`);
8100
+ const t = Number.isInteger(at.index) ? Math.max(0, Math.min(SQ_ARR(V.edges).length, at.index)) : SQ_ARR(V.edges).length, out = [];
8101
+ if (R.lo != null) {
8102
+ if (t > R.lo && t <= R.hi) return sqErr(`${id} cannot move into itself`);
8103
+ if (t > R.hi + 1) R.ids.forEach(x => out.push({ op: 'move', id: x, view: V.id, index: t - 1 }));
8104
+ else if (t < R.lo) R.ids.forEach((x, k) => out.push({ op: 'move', id: x, view: V.id, index: t + k }));
8105
+ }
8106
+ if ((el.parent ?? null) !== (at.parent ?? null)) out.push({ op: 'reparent', id, parent: at.parent ?? null });
8107
+ return out;
8108
+ }
8109
+
8110
+ /* connect(model, view, from, to, at, line): a new message from `from` to `to` (the same lifeline: a self-message) at the
8111
+ slot `at` ({ index, parent }; null: the end), with the picked entry's props. A reply answers the latest unanswered call
8112
+ back along it (interaction.js's replyCallFor) */
8113
+ function sqConnect(model, view, from, to, at, line) {
8114
+ const V = sqView(model, view); if (!V) return null;
8115
+ const ids = SQ_ARR(V.nodeIds), props = { ...(SQ_OBJ(line && line.props) ? structuredClone(line.props) : {}) };
8116
+ if (!ids.includes(from) || !ids.includes(to)) return sqErr(`a message joins two lifelines of view ${V.id}`);
8117
+ const sort = SQ_SORTS.includes(props.sort) ? props.sort : (props.sort = 'call');
8118
+ if (from === to && (sort === 'create' || sort === 'delete')) return sqErr(`a lifeline does not ${sort} itself`);
8119
+ const slot = SQ_OBJ(at) ? at : { index: SQ_ARR(V.edges).length, parent: null };
8120
+ if (sort === 'reply') { const c = replyCallFor(model, V, from, to, slot); if (c != null) props.replyTo = c; }
8121
+ 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 };
8122
+ return sqGuard(model, V, [{ op: 'add', element, view: V.id, index: slot.index ?? SQ_ARR(V.edges).length, in: 'view' }]);
8123
+ }
8124
+
8125
+ // a lifeline dropped at x: its place in nodeIds, among the other heads as laid out in the default measure. The canvas passes
8126
+ // 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
8127
+ const sqColAt = (model, V, id, x) => layoutTimeAxis(sqInput(model, V)).heads.filter(h => h.id !== id && h.x + h.w / 2 < x).length;
8128
+
8129
+ /* 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] }
8130
+ (Alt + Up/Down); a lifeline one place left or right with { by: [±1, 0] } (Alt + Left/Right), or to its place in nodeIds
8131
+ with { index }, or to the place nearest a drop's point { pt: [x, y], col } (a drag sideways; col, the canvas's count of
8132
+ heads drawn left of pt, when given); an operand one place up or down
8133
+ among its fragment's (the member list's Alt + Up/Down), or to its place with { index }: a move of the member, then one
8134
+ 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
8135
+ never read as a place in nodeIds or members. Anything else: null */
8136
+ function sqMoveTo(model, view, id, at) {
8137
+ const V = sqView(model, view); if (!V || !SQ_OBJ(at)) return null;
8138
+ 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);
8139
+ const place = () => (!slot0 && Number.isInteger(at.index) ? at.index : null);
8140
+ 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();
8141
+ if (j == null) return null; if (j < 0 || j >= ids.length || j === i) return [];
8142
+ return sqGuard(model, V, [{ op: 'move', id, view: V.id, index: j }]); }
8143
+ const X = sqIndex(model, V);
8144
+ if (X.opOf.has(id)) { // an operand among its fragment's operands: { by: [0, ±1] } or { index }; edges follows (contiguous)
8145
+ const f = X.opOf.get(id), ops = SQ_ARR(f.members).filter(SQ_OBJ).map(o => o.id), k = ops.indexOf(id);
8146
+ const j = by ? (by[0] ? null : k + Math.sign(by[1])) : place();
8147
+ if (j == null) return null; if (j < 0 || j >= ops.length || j === k) return [];
8148
+ const R = sqRunOf(X, V, f.id); if (R.error) return R;
8149
+ const mine = o => (X.block.get(f.id) || []).filter(x => x.chain.some(c => c.op === o)).map(x => x.i), now = mine(id);
8150
+ 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
8151
+ if (now.length && past.length) { const ids = now.map(i => SQ_ARR(V.edges)[i].id);
8152
+ if (j < k) ids.forEach((x, p) => out.push({ op: 'move', id: x, view: V.id, index: past[0] + p }));
8153
+ else ids.forEach(x => out.push({ op: 'move', id: x, view: V.id, index: past[past.length - 1] })); }
8154
+ return sqGuard(model, V, out); }
8155
+ const R = sqRunOf(X, V, id); if (R.error) return R;
8156
+ const el = X.byId.has(id) ? X.byId.get(id).el : X.F.get(id);
8157
+ let slot = at;
8158
+ if (by) { if (!by[1] || by[0]) return null; // sideways: nothing for an occurrence
8159
+ if (R.lo == null) return sqErr(`fragment ${id} holds nothing: it has no place in time to move from`);
8160
+ // 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.
8161
+ // A fragment that holds nothing but the run is skipped: once the run leaves, it has no place in time (it is drawn at
8162
+ // its parent's end), so stepping into it would only step back out
8163
+ const all = sqSlots(model, V, X), P = R.parent, mine = new Set(R.ids), gone = new Set();
8164
+ 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)); });
8165
+ 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);
8166
+ slot = undefined; if (k < 0) return [];
8167
+ for (let j = k + Math.sign(by[1]); j >= 0 && j < all.length; j += Math.sign(by[1])) { const s = all[j];
8168
+ if (!gone.has(s.parent) && !(s.parent === P && s.index === here)) { slot = s; break; } }
8169
+ if (!slot) return []; }
8170
+ return sqGuard(model, V, sqPut(V, id, R, slot, el));
8171
+ }
8172
+
8173
+ /* wrap(model, view, ids, kind): a new fragment of operator `kind` round a run of occurrences (and fragments) that share a
8174
+ parent and lie next to each other in time: it is added with one operand (an alt with two, the second "else"), and each
8175
+ item of the run moves into its first operand */
8176
+ function sqWrap(model, view, ids, kind) {
8177
+ const V = sqView(model, view); if (!V) return null;
8178
+ if (!SQ_OPS.includes(kind)) return sqErr(`a fragment's operator is one of ${SQ_OPS.join(', ')}, not ${JSON.stringify(kind)}`);
8179
+ const X = sqIndex(model, V), sel = [...new Set(SQ_ARR(ids))]; if (!sel.length) return sqErr('select the messages to wrap first');
8180
+ const runs = sel.map(id => [id, sqRunOf(X, V, id)]), bad = runs.find(([, r]) => r.error); if (bad) return bad[1];
8181
+ 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');
8182
+ const lo = Math.min(...runs.map(([, r]) => r.lo ?? Infinity)), hi = Math.max(...runs.map(([, r]) => r.hi ?? -Infinity)), items = new Set(sel);
8183
+ // each occurrence of the run is selected, or lies in a fragment of P that lies in the run
8184
+ for (const o of X.occ.filter(x => x.i >= lo && x.i <= hi)) {
8185
+ 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; }
8186
+ 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`);
8187
+ 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`);
8188
+ items.add(c.f.id); }
8189
+ const fid = sqNewId(model, 'f'), members = [{ id: `${fid}.1`, type: 'uml.InteractionOperand' }, ...(kind === 'alt' ? [{ id: `${fid}.2`, type: 'uml.InteractionOperand', label: 'else' }] : [])];
8190
+ const element = { id: fid, type: 'uml.CombinedFragment', ...(P != null ? { parent: P } : {}), props: { operator: kind }, members };
8191
+ return sqGuard(model, V, [{ op: 'add', element, view: V.id, in: 'view' }, ...[...items].map(id => ({ op: 'reparent', id, parent: members[0].id }))]);
8192
+ }
8193
+
8194
+ /* add(model, view, entry, label, at): the palette and N. A lifeline is a model node, placed after the last lifeline selected
8195
+ (at.sel, the ids the canvas has selected) or the one `at.after` names, else last, and each use that covers both lifelines
8196
+ beside it covers it too; an interaction use goes in the view at the slot `at`, covering the lifelines selected and those
8197
+ between them, else `at.covers`, else every lifeline */
8198
+ function sqAdd(model, view, entry, label, at) {
8199
+ const V = sqView(model, view), type = SQ_OBJ(entry) ? entry.type : null; if (!V) return null;
8200
+ 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);
8201
+ 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');
8202
+ // a use that covers both its neighbours covers it too, so covers stays next to each other
8203
+ 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)))
8204
+ .map(o => ({ op: 'set', id: o.id, path: 'props.covers', value: sqProps(o.el).covers.flatMap(x => (x === ids[i] ? [x, id] : [x])) }));
8205
+ return sqGuard(model, V, [{ op: 'add', element: { id, type, ...(t ? { label: t } : {}) }, view: V.id, index: i < 0 ? ids.length : i + 1 }, ...grow]); }
8206
+ if (type === 'uml.InteractionUse') { const slot = SQ_OBJ(at) && Number.isInteger(at.index) ? at : { index: SQ_ARR(V.edges).length, parent: null };
8207
+ const covers = js.length ? ids.slice(Math.min(...js), Math.max(...js) + 1) : SQ_ARR(SQ_OBJ(at) && at.covers).length ? [...at.covers] : [...ids];
8208
+ 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' }]); }
8209
+ return null;
8210
+ }
8211
+
8212
+ /* remove(model, view, ids, fromModel): Delete (fromModel false) takes a lifeline out of this view (hide, which takes its
8213
+ messages with it, after a set that drops it from each use's covers); Shift + Delete deletes it from the model. A message,
8214
+ 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
8215
+ operand's content moves to just after its fragment before it goes (S6); the last operand, or all of them at once, takes
8216
+ its fragment. With a lifeline among what goes, the commands come as { commands, say }: say, for the hint line, counts the
8217
+ messages that go with it */
8218
+ function sqRemove(model, view, ids, fromModel) {
8219
+ const V = sqView(model, view); if (!V) return null;
8220
+ const X = sqIndex(model, V), lifelines = SQ_ARR(V.nodeIds), out = [], sel = new Set(SQ_ARR(ids));
8221
+ // every operand of a fragment selected: the fragment goes instead (its content stays, as an unwrap)
8222
+ 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; };
8223
+ for (const id of new Set([...sel].map(whole))) {
8224
+ 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
8225
+ 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) }));
8226
+ out.push({ op: 'hide', id, view: V.id }); continue; }
8227
+ const f = X.opOf.get(id);
8228
+ if (f && SQ_ARR(f.members).filter(SQ_OBJ).length > 1) { const R = sqRunOf(X, V, f.id); if (R.error) return R;
8229
+ const mine = (X.block.get(f.id) || []).filter(o => o.chain.some(c => c.op === id)).map(o => o.id);
8230
+ 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 }));
8231
+ out.push({ op: 'delete', id }); continue; }
8232
+ out.push({ op: 'delete', id: f ? f.id : id });
8233
+ }
8234
+ const r = sqGuard(model, V, out), heads = sqHeads(model, V).filter(n => sel.has(n.id)), hs = new Set(heads.map(n => n.id));
8235
+ if (!Array.isArray(r) || !r.length || !heads.length) return r;
8236
+ const n = X.occ.filter(o => o.message && (hs.has(o.from) || hs.has(o.to))).length;
8237
+ 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'}` };
8238
+ }
8239
+
8240
+ /* operands are a fragment's members, edited in the properties panel's member list (src/canvas/edit.js): a guard as text */
8241
+ const SQ_MEMBER = {
8242
+ print: m => String(m.label ?? ''),
8243
+ edit: (m, text) => ({ commands: [{ op: 'set', id: m.id, path: 'label', value: String(text).trim() || undefined }] }),
8244
+ add: text => ({ type: 'uml.InteractionOperand', label: String(text).trim(), props: {} }),
8245
+ holds: n => n.type === 'uml.CombinedFragment',
8246
+ };
8247
+
8248
+ /* ---- the type ---- */
8249
+ const SEQ_TYPE = registerDiagramType('uml.sequence', {
8250
+ title: 'sequence diagram',
8251
+ elements: [{ type: 'uml.Lifeline', label: 'Lifeline' }, { type: 'uml.InteractionUse', label: 'Interaction use' }],
8252
+ lines: [['Call', 'call'], ['Async', 'async'], ['Reply', 'reply'], ['Create', 'create'], ['Delete', 'delete']].map(([label, sort]) => ({ type: 'uml.Message', label, props: { sort } })),
8253
+ items: ['uml.InteractionUse'],
8254
+ wraps: SQ_OPS,
8255
+ refs: { 'uml.Message': { 'props.replyTo': 'cascade' }, 'uml.InteractionUse': { 'props.covers': 'drop' } },
8256
+ props: {
8257
+ 'uml.Lifeline': [{ key: 'represents', label: 'Represents', kind: 'text' }, { key: 'stereotypes', label: 'Stereotypes', kind: 'list' }],
8258
+ 'uml.Message': [{ key: 'sort', label: 'Sort', kind: 'enum', options: SQ_SORTS }, { key: 'replyTo', label: 'Reply to', kind: 'text' }],
8259
+ 'uml.InteractionUse': [{ key: 'refersTo', label: 'Refers to', kind: 'text' }, { key: 'covers', label: 'Covers', kind: 'list' }],
8260
+ 'uml.CombinedFragment': [{ key: 'operator', label: 'Operator', kind: 'enum', options: SQ_OPS }],
8261
+ },
8262
+ layout: { run: sqRun, pins: false },
8263
+ draw: sqDraw,
8264
+ validate: sqProblems,
8265
+ rules: SQ_RULES,
8266
+ describe: sqDescribe,
8267
+ member: SQ_MEMBER,
8268
+ edits: { connect: sqConnect, moveTo: sqMoveTo, wrap: sqWrap, add: sqAdd, remove: sqRemove },
8269
+ });
8270
+ /* ---------- the communication diagram, uml.communication (work package 3.5, notes/stage-3.md §6) ----------
8271
+ Lifelines joined by connectors; messages are model edges, and the view's `edges` lists the connectors, then the messages
8272
+ in time order (model.md row 12). A view is laid out by layoutTyped, like a class view: lifelines are classifier heads,
8273
+ connectors the lines, a self connector a loop. The messages on one connector are a stack (S7): a node of the layered
8274
+ graph, as wide as its widest row and 16 px tall per message, with its connector split in two through it; after the
8275
+ layout the two halves are one connector again and the stack is one `message-row` node per message, its number (S8, from
8276
+ src/uml/interaction.js), its text and a small arrow toward its receiver. Ships in lib/umlimator-all.js only. */
8277
+
8278
+ const CM_OBJ = x => x !== null && typeof x === 'object' && !Array.isArray(x), CM_ARR = x => (Array.isArray(x) ? x : []);
8279
+ const CM_HAS = x => x != null && x !== '';
8280
+ const CM_SORTS = ['call', 'async', 'reply', 'create', 'delete'];
8281
+ 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
8282
+ const CM_STACK = '\u0000'; // a stack's id and its connector's halves': the connector's id after it, never a model id
8283
+
8284
+ /* ---- the view read: lifelines, connectors and messages, each message on the connector that carries it ---- */
8285
+ function commIndex(model, view) {
8286
+ const N = new Map(), E = new Map();
8287
+ CM_ARR(model && model.nodes).forEach(n => { if (CM_OBJ(n) && typeof n.id === 'string' && !N.has(n.id)) N.set(n.id, n); });
8288
+ CM_ARR(model && model.edges).forEach(e => { if (CM_OBJ(e) && typeof e.id === 'string' && !E.has(e.id)) E.set(e.id, e); });
8289
+ const ids = [...new Set(CM_ARR(view && view.nodeIds).filter(id => N.has(id)))], shown = new Set(ids);
8290
+ 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');
8291
+ const pair = (a, b) => [a, b].sort().join(CM_STACK), joins = e => shown.has(e.from) && shown.has(e.to);
8292
+ const connectors = list.filter(e => e.type === 'uml.Connector' && joins(e)), carrier = new Map(), on = new Map();
8293
+ connectors.forEach(c => { const k = pair(c.from, c.to); if (!carrier.has(k)) carrier.set(k, c); on.set(c.id, []); });
8294
+ const messages = list.filter(e => e.type === 'uml.Message');
8295
+ messages.forEach(m => { const c = joins(m) && carrier.get(pair(m.from, m.to)); if (c) on.get(c.id).push(m); });
8296
+ return { N, E, ids, shown, connectors, messages, on, carrierOf: (a, b) => carrier.get(pair(a, b)) || null };
8297
+ }
8298
+
8299
+ const cmNameOf = x => String(x && x.label != null ? x.label : x ? x.id : '');
8300
+ const cmPropsOf = x => (CM_OBJ(x && x.props) ? x.props : {});
8301
+ const cmSortOf = m => (CM_SORTS.includes(cmPropsOf(m).sort) ? cmPropsOf(m).sort : 'call'); // any other is drawn as a call (the shared sort rule)
8302
+ const cmStereoOf = n => CM_ARR(cmPropsOf(n).stereotypes).filter(CM_HAS).map(String);
8303
+ // a lifeline's head text: "api : ShopApi", or the name alone
8304
+ const cmHeadText = n => cmNameOf(n) + (CM_HAS(cmPropsOf(n).represents) ? ' : ' + cmPropsOf(n).represents : '');
8305
+ const CM_KEYWORD = { create: '«create»', delete: '«destroy»' };
8306
+ 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
8307
+ const cmRowW = t => Math.ceil(Math.min(CM_ROW.max, textW(t, CM_ROW.px) + CM_ROW.arrow + 2 * CM_ROW.pad + 2));
8308
+ const cmPlural = (k, w) => `${k} ${k === 1 ? w : w + 's'}`;
8309
+ // numbers in their order: 1 < 1.1 < 1.1.2 < 1.2 < 2
8310
+ const cmByNumber = (a, b) => { const p = String(a).split('.').map(Number), q = String(b).split('.').map(Number);
8311
+ 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; };
8312
+
8313
+ /* ---- a message row: its number and text, and an arrow toward the receiver (filled for a call or a delete, open for async
8314
+ and create, dashed and open for a reply). n.dir: right, left, down or up ---- */
8315
+ const CM_ARROW_TURN = { right: 0, down: 90, left: 180, up: 270 };
8316
+ function cmArrowOf(sort, cx, cy, dir) {
8317
+ 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)"/>`
8318
+ : `<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"/>`;
8319
+ 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)})">`
8320
+ + `<path d="M${r2(cx - 6)} ${r2(cy)}H${r2(cx + 5)}"${sort === 'reply' ? ' stroke-dasharray="2.2 1.8"' : ''}/>${head}</g>`;
8321
+ }
8322
+ SHAPE['message-row'] = {
8323
+ draw(n) {
8324
+ const { x, y, w, h } = n, cy = y + h / 2, t = String(n.label ?? '');
8325
+ 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)}">`
8326
+ + `<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)}`
8327
+ + `<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>`;
8328
+ },
8329
+ };
8330
+
8331
+ /* ---- the legend: the connector, then each kind of message the view draws ---- */
8332
+ const CM_LEGEND = [['call', 'Call', 'waits for its reply'], ['async', 'Async', 'sent without waiting'], ['reply', 'Reply', 'the answer to a call'],
8333
+ ['create', 'Create', '«create»: makes its receiver'], ['delete', 'Delete', '«destroy»: ends its receiver']];
8334
+ 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>`;
8335
+ const cmLegendOf = sorts => [cmLegendItem('<path d="M2 8H26" stroke="var(--um-muted)" stroke-width="1.4"/>', 'Connector', 'a link messages are sent along'),
8336
+ ...CM_LEGEND.filter(([s]) => sorts.has(s)).map(([s, name, says]) => cmLegendItem(cmArrowOf(s, 14, 8, 'right'), name, says))];
8337
+
8338
+ /* ---- the view laid out: { model, view, memo } ----
8339
+ The lifelines and the stacks are ranked as the map ranks its cards (longest paths, cycles broken depth first, columns
8340
+ ordered by untangle and rows by rowsFor, src/layout/layered.js): the lifelines are ranked first, without the stacks, and
8341
+ then each link runs from its earlier lifeline to its later one, a stack between the two, so a connector's halves leave
8342
+ 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
8343
+ pins (`layout`, by lifeline) win, in the same columns and rows, which a drag or Alt + arrows on a lifeline writes; a
8344
+ node a pin would cover moves down. The host's ranking (`pos`) is not used: it has no place for a stack */
8345
+ // each node's column: the longest path to it over the links, cycles broken depth first; fwd, the links kept
8346
+ function cmRank(ids, links) {
8347
+ const outs = new Map(ids.map(id => [id, []])), back = new Set(), st = new Map();
8348
+ links.forEach((l, k) => outs.get(l[0]).push(k));
8349
+ 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); };
8350
+ [...ids.filter(id => !links.some(l => l[1] === id)), ...ids].forEach(id => { if (!st.has(id)) dfs(id); });
8351
+ const fwd = links.filter((l, k) => !back.has(k)), rank = Object.create(null);
8352
+ ids.forEach(id => { rank[id] = 0; });
8353
+ 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; } }); }
8354
+ return { rank, fwd };
8355
+ }
8356
+ function cmPlace(ids, links, pins) {
8357
+ const { rank, fwd } = cmRank(ids, links), lined = new Set(links.flatMap(l => l));
8358
+ const seen = Object.create(null); links.forEach(l => l.forEach(id => { seen[id] ??= Object.keys(seen).length; }));
8359
+ const at = Object.create(null), cols = [];
8360
+ ids.filter(id => lined.has(id)).forEach(id => (cols[rank[id]] ||= []).push(id));
8361
+ const mains = cols.filter(Boolean).map(c => c.sort((a, b) => seen[a] - seen[b])), ranks = mains.map(c => rank[c[0]]);
8362
+ untangle(mains, fwd);
8363
+ const row = rowsFor(mains, fwd);
8364
+ mains.forEach((c, k) => c.forEach(id => { at[id] = [ranks[k], row[id]]; }));
8365
+ 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;
8366
+ loose.forEach((id, k) => { at[id] = [k % per, under + Math.floor(k / per)]; });
8367
+ if (pins.size) { // pinned first, then each other node at its place or the nearest free one below it
8368
+ const taken = [...pins.values()], free = (c, r) => !taken.some(([c2, r2]) => c2 === c && Math.abs(r2 - r) < 1);
8369
+ 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]); });
8370
+ }
8371
+ return at;
8372
+ }
8373
+ function commGeo({ model, view, memo }) {
8374
+ const I = commIndex(model, view), nums = messageNumbers(model, view), L = CM_OBJ(view.layout) ? view.layout : {};
8375
+ 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]]));
8376
+ 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),
8377
+ aria: `Lifeline ${cmHeadText(n)}${cmStereoOf(n).length ? ` (${cmStereoOf(n).join(', ')})` : ''}` };
8378
+ return { ...d, ...SHAPE.lifeline.size(d) }; });
8379
+ // each link's way, from the lifelines ranked alone: a stack ranked after both its lifelines leaves both halves on one side,
8380
+ // its arrows the same way and one half back across the picture
8381
+ 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]; }));
8382
+ const onward = (a, b) => (rank[a] <= rank[b] ? [a, b] : [b, a]);
8383
+ 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')}`;
8384
+ I.connectors.forEach(c => {
8385
+ 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) };
8386
+ if (!ms.length) { edges.push(plain); if (c.from !== c.to) links.push(onward(c.from, c.to)); return; }
8387
+ const [s, t] = onward(c.from, c.to), texts = ms.map(m => cmRowText(m, nums.get(m.id))), id = CM_STACK + c.id;
8388
+ const st = { id, label: '', kind: 'file', w: Math.max(...texts.map(cmRowW)), h: ms.length * CM_ROW.h };
8389
+ nodes.push(st); stacks.set(c.id, { st, ms, texts, s, t });
8390
+ links.push([s, id], ...(s === t ? [] : [[id, t]]));
8391
+ edges.push({ ...plain, id: id + CM_STACK + 'a', from: s, to: id }, { ...plain, id: id + CM_STACK + 'b', from: id, to: t });
8392
+ });
8393
+ const at = cmPlace(nodes.map(n => n.id), links, pins);
8394
+ nodes.forEach(n => { [n.col, n.row] = at[n.id]; });
8395
+ // a self connector's loop through its stack, which the router would leave crossing itself: in the same row, straight into
8396
+ // the stack's near side and back over the top into the lifeline's; else from the lifeline's top or bottom into the
8397
+ // 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
8398
+ // axes, which a view laid out down turns)
8399
+ stacks.forEach(({ st, s, t }) => { const [c, r] = at[s], [c2, r2] = at[st.id]; if (s !== t || c === c2) return;
8400
+ const [near, far] = c2 > c ? ['left', 'right'] : ['right', 'left'], [up, down] = r2 < r ? ['top', 'bottom'] : ['bottom', 'top'];
8401
+ const a = edges.find(e => e.to === st.id), b = edges.find(e => e.from === st.id);
8402
+ Object.assign(a, Math.abs(r2 - r) < 1 ? { fromSide: far, toSide: near } : { fromSide: up, toSide: near });
8403
+ Object.assign(b, Math.abs(r2 - r) < 1 ? { fromSide: 'top', toSide: 'top' } : { fromSide: down, toSide: far }); });
8404
+ // 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
8405
+ 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
8406
+ return cmFinish(g, I, stacks, nums, view);
8407
+ }
8408
+
8409
+ // a line's two runs crossing (not meeting end to end), axis-aligned
8410
+ 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];
8411
+ if (h === (c[1] === d[1])) return false;
8412
+ const [A, B, C, D] = h ? [a, b, c, d] : [c, d, a, b];
8413
+ 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]); }));
8414
+ // 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
8415
+ 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];
8416
+ if (P[1][v] !== x || P[n - 2][v] !== y) return P;
8417
+ Q[0][v] = Q[1][v] = y; Q[n - 1][v] = Q[n - 2][v] = x; return cmCrosses(Q) ? P : Q; };
8418
+
8419
+ /* The laid-out graph as it is drawn, a new object (layoutTyped's memo keeps the one it returned): each stack replaced by its
8420
+ rows, each connector's halves joined through its stack, geo.order, the slots and the legend */
8421
+ function cmFinish(g, I, stacks, nums, view) {
8422
+ const byId = Object.create(null), order = [...I.ids];
8423
+ g.nodes.forEach(n => { if (n.id[0] !== CM_STACK) byId[n.id] = { ...n }; });
8424
+ 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]));
8425
+ I.connectors.forEach(c => {
8426
+ const S = stacks.get(c.id);
8427
+ if (!S) { const e = E.get(c.id); if (e) edges.push({ ...e, s: byId[e.from], t: byId[e.to] }); return; }
8428
+ 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];
8429
+ if (!a || !b || !box) return;
8430
+ const p = a.pts[a.pts.length - 1], q = b.pts[0], across = a.ts === 'L' || a.ts === 'R';
8431
+ 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]];
8432
+ let pts = [...a.pts, ...mid, ...b.pts];
8433
+ 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
8434
+ const turn = S.s !== c.from; // drawn from the connector's own from to its to
8435
+ 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,
8436
+ fs: turn ? b.ts : a.fs, ts: turn ? a.fs : b.ts, lbl: null, stack: S.ms.map(m => m.id) });
8437
+ // a row's arrow points the way its connector leaves the stack toward the message's receiver
8438
+ 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'; };
8439
+ 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()));
8440
+ 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);
8441
+ 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,
8442
+ 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,
8443
+ 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) : ''}` };
8444
+ byId[row.id] = row; });
8445
+ });
8446
+ // reading order (S13): lifelines in the layout's order, left to right and top to bottom, then the message rows by number.
8447
+ // The nodes are drawn in it too, so a screen reader that reads the picture in document order reads it so. geo.order, the
8448
+ // key rings' order and so Tab's, has the connectors between the lifelines and the rows: a connector is selected by its ring
8449
+ order.sort((a, b) => byId[a].x - byId[b].x || byId[a].y - byId[b].y);
8450
+ order.push(...I.messages.filter(m => byId[m.id]).map(m => m.id).sort((a, b) => cmByNumber(nums.get(a), nums.get(b))));
8451
+ const heads = order.slice(0, I.ids.length), mid = n => ({ x: n.x + n.w / 2, y: n.y + n.h / 2 });
8452
+ // the slots (src/typed/edit.js): one after each lifeline, its index in nodeIds (N adds there), and one after each row,
8453
+ // named by `after` (L from a message adds after it; a row dropped on a row takes its place)
8454
+ const slots = [...heads.map(id => ({ index: CM_ARR(view.nodeIds).indexOf(id) + 1, after: id, parent: null, lifeline: true, ...mid(byId[id]) })),
8455
+ ...order.slice(I.ids.length).map(id => ({ after: id, parent: null, ...mid(byId[id]) }))];
8456
+ return { ...g, nodes: order.map(id => byId[id]), edges, byId, order: [...heads, ...edges.map(e => e.id), ...order.slice(I.ids.length)], slots,
8457
+ legend: cmLegendOf(new Set(I.messages.filter(m => byId[m.id]).map(cmSortOf))) };
8458
+ }
8459
+
8460
+ /* ---- validators: message-link, and the rules both interaction types share (src/uml/interaction.js). Each problem:
8461
+ { level, rule, path, message, ids } ---- */
8462
+ 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 };
8463
+ function commProblems(model, view) {
8464
+ if (!CM_OBJ(view)) return [];
8465
+ const I = commIndex(model, view), w = interactionWalk(model, view), out = [];
8466
+ w.occ.forEach(o => { if (!o.message || I.carrierOf(o.from, o.to) && I.shown.has(o.from) && I.shown.has(o.to)) return;
8467
+ const gone = [o.from, o.to].filter(id => !I.shown.has(id)), nm = `${o.id}${CM_HAS(o.el.label) ? ` ("${o.el.label}")` : ''}`;
8468
+ 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}, `
8469
+ + (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`) }); });
8470
+ return [...out, ...interactionProblems(model, view)];
8471
+ }
8472
+
8473
+ /* ---- describe(): the view in words, each lifeline with its links, then the messages by number ---- */
8474
+ function commDescribe(model, viewId) {
8475
+ 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)}`);
8476
+ const I = commIndex(model, v), nums = messageNumbers(model, v), nm = id => cmNameOf(I.N.get(id) || { id });
8477
+ const drawn = I.messages.filter(m => I.carrierOf(m.from, m.to)).sort((a, b) => cmByNumber(nums.get(a.id), nums.get(b.id)));
8478
+ 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')}.`];
8479
+ 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))))];
8480
+ out.push(`Lifeline ${cmHeadText(n)}${st.length ? ` (${st.join(', ')})` : ''}${links.length ? '; linked to ' + links.join(', ') : '; linked to nothing'}.`); });
8481
+ drawn.forEach(m => { const s = cmSortOf(m), r = cmPropsOf(m).replyTo;
8482
+ 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) : ''}.`); });
8483
+ return out.join('\n');
8484
+ }
8485
+
8486
+ /* ---- editing (notes/stage-3.md §5.1, §6.4): each hook returns commands, null to leave the gesture to the default, or
8487
+ { error } for the hint line, and never a transaction that breaks a rule (S5) ---- */
8488
+ // an id no element of the model has: the base, else the base and the first number free ('m10', 'api-payments-2')
8489
+ function cmFreshId(model, base) {
8490
+ const used = new Set(); JSON.stringify(model, (k, v) => { if (k === 'id' && typeof v === 'string') used.add(v); return v; });
8491
+ const b = String(base).replace(/->/g, '-') || 'el', next = k => (b === 'm' ? b + k : k === 1 ? b : `${b}-${k}`); // ids never hold "->" (M2)
8492
+ let k = 1; while (used.has(next(k))) k++;
8493
+ return next(k);
8494
+ }
8495
+ // where `at` puts a message in view.edges: { index }, { after: id }, or none or a lifeline's slot (the end); never among the
8496
+ // connectors
8497
+ function cmMessageIndex(view, at) {
8498
+ const list = CM_ARR(view.edges), idOf = x => (typeof x === 'string' ? x : CM_OBJ(x) ? x.id : null);
8499
+ let i = list.length;
8500
+ if (CM_OBJ(at) && at.lifeline) return i;
8501
+ if (CM_OBJ(at) && Number.isInteger(at.index)) i = at.index;
8502
+ else if (CM_OBJ(at) && at.after != null && list.some(x => idOf(x) === at.after)) i = list.findIndex(x => idOf(x) === at.after) + 1;
8503
+ return Math.max(0, Math.min(list.length, i));
8504
+ }
8505
+
8506
+ // the commands, or { error } naming the first rule they would break that the view does not break already (S5); all: in every
8507
+ // communication view, for commands that delete from the model
8508
+ function cmGuard(model, view, commands, all) {
8509
+ const key = p => p.view + ' ' + p.rule + ' ' + p.ids.join(' ');
8510
+ const errs = m => CM_ARR(m.views).filter(v => CM_OBJ(v) && (v.id === view.id || all && v.diagram === 'uml.communication'))
8511
+ .flatMap(v => commProblems(m, v).filter(p => p.level === 'error').map(p => ({ ...p, view: v.id })));
8512
+ let now; try { now = apply(model, commands); } catch (e) { return { error: String(e.message) }; }
8513
+ const was = new Set(errs(model).map(key)), bad = errs(now).find(p => !was.has(key(p)));
8514
+ return bad ? { error: bad.message } : commands;
8515
+ }
8516
+
8517
+ /* connect(model, view, from, to, at, line): line, the picked entry of `lines`. A connector is added among the connectors;
8518
+ a message at `at`, with a connector in the same transaction when none joins its lifelines, and a reply answers the
8519
+ latest unanswered call from its receiver to its sender */
8520
+ function commConnect(model, view, from, to, at, line) {
8521
+ if (!CM_OBJ(view)) return null;
8522
+ const I = commIndex(model, view), type = CM_OBJ(line) && line.type === 'uml.Message' ? 'uml.Message' : 'uml.Connector';
8523
+ if (!I.shown.has(from) || !I.shown.has(to)) return { error: 'Connect two lifelines of this view' };
8524
+ 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
8525
+ const link = id => ({ op: 'add', element: { id, type: 'uml.Connector', from, to }, view: view.id, index: cons });
8526
+ 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}`))];
8527
+ const props = { ...(CM_OBJ(line.props) ? structuredClone(line.props) : {}) }, i = Math.max(cons, cmMessageIndex(view, at));
8528
+ if (props.sort === 'reply') { const c = replyCallFor(model, view, from, to, { index: i }); if (c) props.replyTo = c; }
8529
+ 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 }];
8530
+ 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
8531
+ return cmGuard(model, view, out); // a message before its lifeline's create, or after its delete
8532
+ }
8533
+
8534
+ /* moveTo(model, view, id, at): a message one place earlier or later in time ({ by: [dx, dy] }: Alt + Up/Down), or onto
8535
+ another row ({ id }, or a row's slot: the row it was dropped on, whose place it takes; or { index } in view.edges; a
8536
+ 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 */
8537
+ function commMoveTo(model, view, id, at) {
8538
+ if (!CM_OBJ(view)) return null;
8539
+ const I = commIndex(model, view), list = CM_ARR(view.edges), idOf = x => (typeof x === 'string' ? x : CM_OBJ(x) ? x.id : null);
8540
+ const ms = list.map((x, i) => [idOf(x), i]).filter(([x]) => I.messages.some(m => m.id === x)), k = ms.findIndex(([x]) => x === id);
8541
+ if (k < 0) return null;
8542
+ let to = k;
8543
+ if (CM_OBJ(at) && at.lifeline) return [];
8544
+ if (CM_OBJ(at) && at.after != null && at.id == null) at = { id: at.after }; // a row's slot
8545
+ if (CM_OBJ(at) && Array.isArray(at.by)) to = k + Math.sign(+at.by[1] || 0);
8546
+ else if (CM_OBJ(at) && at.id != null) to = ms.findIndex(([x]) => x === at.id);
8547
+ else if (CM_OBJ(at) && Number.isInteger(at.index)) { to = ms.filter(([, i]) => i < at.index).length; if (to > k) to--; }
8548
+ if (to < 0 || to >= ms.length || to === k) return [];
8549
+ 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
8550
+ }
8551
+
8552
+ /* add(model, view, entry, label, at): the palette's lifeline, at `at.index` in nodeIds (a lifeline's slot: after the selected
8553
+ lifeline), else last (no slot, or a row's, which has no index) */
8554
+ function commAdd(model, view, entry, label, at) {
8555
+ if (!CM_OBJ(view) || !CM_OBJ(entry) || entry.type !== 'uml.Lifeline') return null;
8556
+ const id = cmFreshId(model, String(label ?? '').trim().toLowerCase().replace(/[^\p{L}\p{N}_$-]+/gu, '-').replace(/^-+|-+$/g, '') || 'lifeline');
8557
+ return [{ op: 'add', element: { id, type: 'uml.Lifeline', label: String(label ?? '') }, view: view.id, ...(CM_OBJ(at) && Number.isInteger(at.index) ? { index: at.index } : {}) }];
8558
+ }
8559
+
8560
+ /* remove(model, view, ids, fromModel): Delete hides from this view, Shift + Delete deletes from the model. A connector takes
8561
+ the messages it carries with it, deleted from every communication view that has no other connector for them; a hidden
8562
+ call takes its replies in this view (a deleted one does by refs). A lifeline takes its connectors and messages (apply()
8563
+ does). { error } when what is left breaks a rule */
8564
+ function commRemove(model, view, ids, fromModel) {
8565
+ if (!CM_OBJ(view)) return null;
8566
+ const I = commIndex(model, view), out = [], seen = new Set(), idOf = x => (typeof x === 'string' ? x : CM_OBJ(x) ? x.id : null);
8567
+ const views = fromModel ? CM_ARR(model && model.views).filter(v => CM_OBJ(v) && v.diagram === 'uml.communication') : [view];
8568
+ const go = id => { if (seen.has(id)) return; seen.add(id);
8569
+ out.push(fromModel ? { op: 'delete', id } : { op: 'hide', id, view: view.id });
8570
+ 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;
8571
+ const left = commIndex(model, { ...v, edges: CM_ARR(v.edges).filter(x => !seen.has(idOf(x))) });
8572
+ on.forEach(m => { if (v === view && !fromModel || !left.carrierOf(m.from, m.to)) go(m.id); }); });
8573
+ if (!fromModel) I.messages.filter(m => cmPropsOf(m).replyTo === id).forEach(m => go(m.id)); };
8574
+ CM_ARR(ids).forEach(id => { if (I.shown.has(id) || I.E.has(id)) go(id); });
8575
+ return fromModel ? cmGuard(model, view, out, true) : out;
8576
+ }
8577
+
8578
+ /* ---- the type ---- */
8579
+ const CM_SORT = { key: 'sort', label: 'Sort', kind: 'enum', options: CM_SORTS };
8580
+ const COMM_TYPE = registerDiagramType('uml.communication', {
8581
+ title: 'communication diagram',
8582
+ elements: [{ type: 'uml.Lifeline', label: 'Lifeline' }],
8583
+ lines: [{ type: 'uml.Connector', label: 'Connector' }, ...[['call', 'Call'], ['async', 'Async'], ['reply', 'Reply'], ['create', 'Create'], ['delete', 'Delete']]
8584
+ .map(([sort, label]) => ({ type: 'uml.Message', label, props: { sort } }))],
8585
+ props: {
8586
+ 'uml.Lifeline': [{ key: 'represents', label: 'Represents', kind: 'text' }, { key: 'stereotypes', label: 'Stereotypes', kind: 'list' }],
8587
+ 'uml.Message': [CM_SORT, { key: 'replyTo', label: 'Reply to', kind: 'text' }],
8588
+ 'uml.Connector': [],
8589
+ },
8590
+ refs: { 'uml.Message': { 'props.replyTo': 'cascade' } }, // a reply goes with its call
8591
+ layout: Object.assign(commGeo, { rank: false }), // commGeo ranks its own lifelines: the host passes only the pins
8592
+ // the host ranks a typed view's lifelines before commGeo runs (context.js autoPos), and commGeo ranks its own, with the
8593
+ // stacks. Connectors turned round make the messages along them close cycles, which that ranking passes over: a third of
8594
+ // its time on the stress fixture; layout.rank false (above) now skips that ranking
8595
+ rankFlip: e => e.type === 'uml.Connector',
8596
+ validate: commProblems,
8597
+ rules: CM_RULES,
8598
+ describe: commDescribe,
8599
+ edits: { connect: commConnect, moveTo: commMoveTo, add: commAdd, remove: commRemove },
8600
+ });
8601
+ /* ---------- helpers: a numbered view, a sequence view or a trace as a UML diagram (work package 3.7, notes/stage-3.md §7) ----------
8602
+ C7 keeps `render: "sequence"` a numbered view; these turn one into a UML diagram. Each returns { model, report }, the
8603
+ model a version-2 copy (the one passed in is not changed) and the report a reader's: [{ id, status, message }], status
8604
+ `skipped` or `approximated` (R1). Ids are derived (R3), never a source line's: a lifeline is <view>.<card>, a message
8605
+ <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
8606
+ (options.view, default <source>.sequence or <source>.communication; options.title) takes the place of the view with its
8607
+ id, with that view's lifelines and model messages and connectors that no other view lists, else comes last.
8608
+ - sequenceFromView(model, viewId, options): a numbered view as a uml.sequence view; its lines become calls in the
8609
+ order the core numbers them, and a hand-written `step` other than that number is reported.
8610
+ - communicationFromView(model, viewId, options): a numbered view or a uml.sequence view as a uml.communication view.
8611
+ options.operands { <fragment>: <operand> } picks each alt's operand (default its first); opt, loop and break are
8612
+ taken once, par's operands one after another (reported); interaction uses are left out (reported).
8613
+ - sequenceFromSpans(model, spans, options): a trace through sequenceFromTrace()'s numbered view (options.view, default
8614
+ "trace", replaced by the sequence view), so the bars follow rule 3; calls that overlapped are said in the caption.
8615
+ Internal (§7 (a)): in lib/umlimator-all.js with no export; the CLI and tests import this file. */
8616
+
8617
+ const CV_ARR = x => (Array.isArray(x) ? x : []), CV_OBJ = x => x !== null && typeof x === 'object' && !Array.isArray(x);
8618
+ const cvId = s => String(s).replaceAll('->', '-'); // M2: a derived id never names a line by position
8619
+ // ids made unique among those one helper derives (and `used`): <id>, else <id>~2, <id>~3, ... (card ids may hold '-' or be m<n>)
8620
+ 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; }; };
8621
+
8622
+ function cvSource(model, viewId) {
8623
+ const D = structuredClone(migrate(model) || {}), v = CV_ARR(D.views).find(x => CV_OBJ(x) && x.id === viewId);
8624
+ if (!v) throw new Error(`convert: the model has no view ${JSON.stringify(viewId)}`);
8625
+ return { D, v };
8626
+ }
8627
+
8628
+ // a numbered view's lines as calls of a sequence view `id`: { nodes (new lifelines), view, report }
8629
+ function cvNumbered(model, v, id, title) {
8630
+ if (v.render !== 'sequence') throw new Error(`convert: view ${JSON.stringify(v.id)} is not a numbered view (render: "sequence")`);
8631
+ const ctx = mapContext(model), lines = ctx.viewEdges(ctx.V[v.id]), report = [], fresh = cvFresh();
8632
+ 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);
8633
+ const lifeOf = new Map(cards.map(c => [c, fresh(`${id}.${cvId(c)}`)])), life = c => lifeOf.get(c);
8634
+ const boxes = new Map(CV_ARR(v.containers).filter(CV_OBJ).map(b => [b.id, b]));
8635
+ const nodes = cards.map(c => ({ id: life(c), type: 'uml.Lifeline', label: String((ctx.N[c] || boxes.get(c) || {}).label ?? c) }));
8636
+ 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);
8637
+ 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` });
8638
+ 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' } }; });
8639
+ return { nodes, report, view: { id, diagram: 'uml.sequence', title: title || v.title || v.id, ...(v.caption ? { caption: v.caption } : {}),
8640
+ parent: v.parent ?? null, nodeIds: nodes.map(n => n.id), edges } };
8641
+ }
8642
+
8643
+ // a sequence view as a communication view `id`: { edges (model edges: connectors, then messages), view, report }
8644
+ function cvCommunication(model, v, id, options) {
8645
+ const w = interactionWalk(model, v), report = [], picks = CV_OBJ(options.operands) ? options.operands : {}, taken = new Set(), chosen = [];
8646
+ 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;
8647
+ 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; }
8648
+ const want = Object.hasOwn(picks, f.id) ? picks[f.id] : undefined; let pick = ops.find(o => o.id === want);
8649
+ 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` });
8650
+ 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)]); } });
8651
+ 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; };
8652
+ const kept = w.occ.filter(o => { if (o.message) return inside(o);
8653
+ if (inside(o)) report.push({ id: o.id, status: 'skipped', message: `interaction use ${o.id} is left out` }); return false; });
8654
+ const fresh = cvFresh(CV_ARR(v.nodeIds)), ids = new Map(kept.map((o, k) => [o.id, fresh(`${id}.m${k + 1}`)])), links = new Map();
8655
+ const end = x => cvId(String(x).startsWith(id + '.') ? String(x).slice(id.length + 1) : x); // a lifeline this helper named: by its card
8656
+ const messages = kept.map(o => { const { parent, ...el } = o.el, props = { ...(CV_OBJ(el.props) ? el.props : {}) };
8657
+ if (props.replyTo != null) { if (ids.has(props.replyTo)) props.replyTo = ids.get(props.replyTo); else { delete props.replyTo;
8658
+ report.push({ id: ids.get(o.id), status: 'approximated', message: `${o.id} answers ${o.replyTo}, which is not taken: it names no call` }); } }
8659
+ 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 });
8660
+ return { ...el, id: ids.get(o.id), props }; });
8661
+ const connectors = [...links.values()], title = options.title || [v.title || v.id, chosen.filter(([f]) => inside(f)).map(c => c[1]).join(', ')].filter(Boolean).join(': ');
8662
+ return { report, edges: [...connectors, ...messages], view: { id, diagram: 'uml.communication', title, ...(v.caption ? { caption: v.caption } : {}),
8663
+ parent: v.parent ?? null, nodeIds: [...CV_ARR(v.nodeIds)], edges: [...connectors, ...messages].map(e => e.id) } };
8664
+ }
8665
+
8666
+ // the model with `view` in place of the view with its id (and what only that one listed), else last; schema version 2
8667
+ function cvPlace(D, view, nodes = [], edges = []) {
8668
+ const views = CV_ARR(D.views), at = views.findIndex(x => CV_OBJ(x) && x.id === view.id), old = at < 0 ? null : views[at];
8669
+ 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));
8670
+ 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)) : []);
8671
+ const newN = new Set(nodes.map(n => n.id)), newE = new Set(edges.map(e => e.id));
8672
+ D.nodes = [...CV_ARR(D.nodes).filter(n => !(CV_OBJ(n) && (newN.has(n.id) || (goneN.has(n.id) && n.type === 'uml.Lifeline')))), ...nodes];
8673
+ 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];
8674
+ D.views = at < 0 ? [...views, view] : views.map((x, k) => (k === at ? view : x));
8675
+ D.schemaVersion = 2;
8676
+ return D;
8677
+ }
8678
+
8679
+ function sequenceFromView(model, viewId, options = {}) {
8680
+ const { D, v } = cvSource(model, viewId), r = cvNumbered(D, v, String(options.view ?? `${viewId}.sequence`), options.title);
8681
+ return { model: cvPlace(D, r.view, r.nodes), report: r.report };
8682
+ }
8683
+
8684
+ function communicationFromView(model, viewId, options = {}) {
8685
+ const { D, v } = cvSource(model, viewId), id = String(options.view ?? `${viewId}.communication`);
8686
+ if (v.diagram === 'uml.sequence') { const r = cvCommunication(D, v, id, options); return { model: cvPlace(D, r.view, [], r.edges), report: r.report }; }
8687
+ const s = cvNumbered(D, v, id, options.title), r = cvCommunication(D, s.view, id, { title: s.view.title });
8688
+ return { model: cvPlace(D, r.view, s.nodes, r.edges), report: [...s.report, ...r.report] };
8689
+ }
8690
+
8691
+ // how many of a trace's calls between cards overlapped one that is neither inside nor around it (as sequenceFromTrace reads them)
8692
+ function cvOverlaps(model, spans, match) {
8693
+ 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);
8694
+ 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]));
8695
+ const cardOf = cardMatcher(cards, match), up = s => (s.parent != null ? span.get(String(s.parent)) : null);
8696
+ const calls = list.filter(s => { const to = cardOf(s.service), p = up(s), from = p && cardOf(p.service); return to && from && from !== to; });
8697
+ 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; };
8698
+ return calls.filter(a => calls.some(b => a !== b && +a.start < +b.end && +b.start < +a.end && !under(a, b) && !under(b, a))).length;
8699
+ }
8700
+
8701
+ function sequenceFromSpans(model, spans, options = {}) {
8702
+ const id = String(options.view ?? 'trace'), m = sequenceFromTrace(migrate(model), spans, { ...options, view: id }), r = sequenceFromView(m, id, { ...options, view: id });
8703
+ const n = cvOverlaps(model, spans, options.match), v = r.model.views.find(x => x.id === id);
8704
+ if (n) v.caption = `${v.caption ? v.caption + ' ' : ''}${n} calls overlapped in the trace and are drawn one after another.`;
8705
+ return r;
8706
+ }
8707
+ /* ---------- Mermaid sequenceDiagram: a reader and a writer (notes/stage-3.md §8, S10; risks.md R1 to R4, R7) ----------
8708
+ Written against Mermaid 12.3.0's sequence diagram page (docs/syntax/sequenceDiagram.md, read 12 Nov 2026), as the class
8709
+ reader is against its class page. Internal: the CLI (`umlimator.py import`) imports this file through Node; a host
8710
+ gets it once the registry is public. Its helpers are its own (ms...): mermaid-class.js exports only its two functions,
8711
+ and both files share one scope in lib/umlimator-all.js.
8712
+
8713
+ readMermaidSequence(text) -> { model, report }: a version-2 model with one `uml.sequence` view, in model.md row 11's
8714
+ vocabulary, and a report of what was not read as written, each item { line, status, message }, status `skipped` or
8715
+ `approximated` (R1). The reader is hand-written and linear, and refuses text over MS_LIMIT characters (R2). Ids come
8716
+ from the source's names (R3): a lifeline's is its name; a message is <from>-<to>, then -2, -3 ... for each pair, so
8717
+ inserting one renames only the later messages of its own pair; a fragment <operator>-<n>, an operand <fragment>.<k>.
8718
+ `%% id: <id>` above a statement gives that element its id instead (`%% operand-id:` the first operand of a fragment's
8719
+ line); `%% reply-to: <id>` above a reply names its call, when that is not the one found below; `%% represents: <type>`
8720
+ above a participant is its `represents`. It never throws.
8721
+
8722
+ writeMermaidSequence(model, viewId?) -> text: one sequence view as Mermaid. What Mermaid cannot carry is kept in its
8723
+ comments, as the class writer does: an id the reader would not derive as `%% id:`, a reply's call it would not find as
8724
+ `%% reply-to:`, a lifeline's `represents` as `%% represents:` (and in its alias, `as <label> : <type>`, for Mermaid to
8725
+ show); an interaction use as `%% use: <id> refersTo=<id>` above `Note over <first>,<last>: ref <label>`; a space at
8726
+ 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
8727
+ `%% not written: <id> (<type>)`, and a property Mermaid has no place for as `%% not written: <id>: <what>`.
8728
+
8729
+ How UML maps onto Mermaid's text, both ways:
8730
+ - `participant A`, `actor A` (stereotype actor), `as <label>`: the alias is the label, as Mermaid shows it; under a
8731
+ `%% represents:` comment it is `<label> : <type>`, split at its first colon (an encoded one, #58;, stays in the
8732
+ label). `@{ "type": ... }` is read as a stereotype, approximated;
8733
+ - `->>` call, `-)` async, `-->>` reply, its `replyTo` the latest unanswered call from its receiver to its sender (a
8734
+ call answered only in another operand of an alt is unanswered); `create participant B` makes the next message to
8735
+ B a create, `destroy B` the next message to B a delete. Other arrows are read as the nearest, approximated;
8736
+ - `+`, `-`, `activate`, `deactivate`: activations are derived, so nothing is read; reported where they disagree;
8737
+ - `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
8738
+ `else` is the guard else). `critical`/`option` and `rect` are read as their content; `box` as its participants;
8739
+ - `;` ends a statement as a line does (an entity such as #59; does not); `<br>` is a line break in text. */
8740
+ const MS_LIMIT = 1 << 20; // characters; a sequence diagram a person writes is a few kilobytes
8741
+ const MS_VIEW = 'sequence-diagram'; // the view's id when the source does not name it
8742
+ const MS_OPS = ['alt', 'opt', 'loop', 'par', 'break'];
8743
+ // arrows, a longer one before its prefix: [text, sort, what is not drawn]
8744
+ const MS_ARROWS = [['<<-->>', 'reply', 'its arrowhead at the sender is not drawn'], ['<<->>', 'call', 'its arrowhead at the sender is not drawn'],
8745
+ ['-->>', 'reply'], ['->>', 'call'], ['--)', 'async', 'its dotted line is drawn solid'], ['-)', 'async'],
8746
+ ['--x', 'async', 'its dotted line and its cross are not drawn'], ['-x', 'async', 'its cross is not drawn'],
8747
+ ['-->', 'reply', 'it has no arrowhead, and is drawn with one'], ['->', 'call', 'it has no arrowhead, and is drawn with one']];
8748
+ // Mermaid 12.3.0's lexer keywords (sequenceDiagram.jison; case-insensitive, whole words): a lifeline's name the writer gives never starts with one
8749
+ const MS_KEYWORDS = new Set(['participant', 'actor', 'create', 'destroy', 'activate', 'deactivate', 'note', 'alt', 'else', 'opt', 'loop', 'par', 'par_over', 'and',
8750
+ 'break', 'critical', 'option', 'rect', 'box', 'end', 'autonumber', 'title', 'acctitle', 'accdescr', 'link', 'links', 'properties', 'details',
8751
+ 'left', 'right', 'over', 'as', 'off', 'sequencediagram']);
8752
+ const msId = name => String(name).replace(/->+/g, '-'); // an id never holds "->" (model.md rule 5)
8753
+
8754
+ // the first of base, base-2, base-3 ... not in used, resuming where the last ask for base stopped (R2)
8755
+ function msFree(used, next, base) {
8756
+ let k = next.get(base) || 1, id = k > 1 ? `${base}-${k}` : base;
8757
+ while (used.has(id)) id = `${base}-${++k}`;
8758
+ next.set(base, k);
8759
+ return id;
8760
+ }
8761
+
8762
+ // text: Mermaid's line breaks and entity codes (#quot; #lt; #59; ...); a named one Mermaid knows and this does not stays
8763
+ function msDecode(s) {
8764
+ return s.replace(/<br\s?\/?>|\\n/gi, '\n').replace(/#(quot|lt|gt|amp|\d{1,6});/g, (_, e) =>
8765
+ ({ quot: '"', lt: '<', gt: '>', amp: '&' })[e] ?? String.fromCodePoint(Math.min(Number(e), 0x10ffff)));
8766
+ }
8767
+
8768
+ // a line's statements: `;` ends one, unless it closes an entity such as #59;
8769
+ function msStatements(line) {
8770
+ const out = [];
8771
+ let from = 0, ent = -1;
8772
+ for (let k = 0; k < line.length; k++) {
8773
+ const c = line[k];
8774
+ if (c === '#') ent = k;
8775
+ else if (c === ';') { if (!(ent >= 0 && k - ent > 1)) { out.push(line.slice(from, k)); from = k + 1; } ent = -1; }
8776
+ else if (!/\w/.test(c)) ent = -1;
8777
+ }
8778
+ out.push(line.slice(from));
8779
+ return out.map(x => x.trim()).filter(Boolean);
8780
+ }
8781
+
8782
+ // 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)
8783
+ function msWord(s) {
8784
+ let e = 0;
8785
+ while (e < s.length && /[A-Za-z_]/.test(s[e])) e++;
8786
+ return e && !/\w/.test(s[e] ?? ' ') ? s.slice(0, e).toLowerCase() : '';
8787
+ }
8788
+
8789
+ // a message: A [()] arrow [+|-] [()] B : text, or null
8790
+ function msMessage(s) {
8791
+ let k = 0, arrow;
8792
+ for (; k < s.length && s[k] !== ':'; k++) if ((s[k] === '-' || s[k] === '<') && (arrow = MS_ARROWS.find(a => s.startsWith(a[0], k)))) break;
8793
+ if (!arrow) return null;
8794
+ let a = s.slice(0, k).trim(), j = k + arrow[0].length, central = false, plus = false, minus = false;
8795
+ if (a.endsWith('()')) { central = true; a = a.slice(0, -2).trim(); }
8796
+ for (;; j++) {
8797
+ while (s[j] === ' ' || s[j] === '\t') j++;
8798
+ if (s[j] === '+') plus = true; else if (s[j] === '-') minus = true; else if (s.startsWith('()', j)) { central = true; j++; } else break;
8799
+ }
8800
+ const colon = s.indexOf(':', j), b = (colon < 0 ? s.slice(j) : s.slice(j, colon)).trim();
8801
+ return a && b ? { a, b, arrow, central, plus, minus, text: colon < 0 ? '' : s.slice(colon + 1).trim() } : null;
8802
+ }
8803
+
8804
+ // participant NAME [@{ ... }] [as ALIAS]: { name, type, alias }
8805
+ function msParticipant(rest) {
8806
+ let name = rest, type, alias, after = '';
8807
+ const at = rest.indexOf('@{');
8808
+ if (at >= 0) {
8809
+ const close = rest.indexOf('}', at), cfg = rest.slice(at + 2, close < 0 ? rest.length : close);
8810
+ name = rest.slice(0, at); after = close < 0 ? '' : rest.slice(close + 1).trim();
8811
+ type = /"type"\s*:\s*"([^"]*)"/.exec(cfg)?.[1];
8812
+ alias = /"alias"\s*:\s*"([^"]*)"/.exec(cfg)?.[1];
8813
+ if (after.startsWith('as ')) alias = after.slice(3);
8814
+ } else {
8815
+ const m = /\sas\s/.exec(rest);
8816
+ if (m) { name = rest.slice(0, m.index); alias = rest.slice(m.index + m[0].length); }
8817
+ }
8818
+ return { name: name.trim(), type, alias: alias?.trim() };
8819
+ }
8820
+
8821
+ // which call each reply answers, the same for the reader and the writer: the latest call from the reply's receiver to its
8822
+ // sender that has no reply yet, where a reply in another operand of an alt does not count; or the call its `want` names,
8823
+ // when that is an earlier call the reply goes back along (the writer's `%% reply-to:`). occ: [{ x, id, from, to, sort,
8824
+ // parent, want }] in time order; fragments: [{ parent, props: { operator }, members: [{ id }] }]. Returns
8825
+ // [Map reply -> call, Set of the replies (their x) whose call is their want and not the latest unanswered one].
8826
+ // ponytail: an operand's chain stops at 64 levels and one reply looks at 256 earlier answers at most, so hostile text
8827
+ // stays linear (R2); past that a reply finds no call, which the reader reports. Raise both if real diagrams need it
8828
+ function msMatch(occ, fragments) {
8829
+ const fragOf = new Map();
8830
+ for (const f of fragments) for (const o of f.members) fragOf.set(o.id, f);
8831
+ 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; };
8832
+ // 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
8833
+ // with path halving), so a reply passes each closed call once, wherever it lies in the stack (R2)
8834
+ const stacks = new Map(), byId = new Map(), out = new Map(), forced = new Set();
8835
+ 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; };
8836
+ for (const x of occ) {
8837
+ if (x.sort === 'call') {
8838
+ const key = `${x.from}\0${x.to}`;
8839
+ if (!stacks.has(key)) stacks.set(key, { calls: [], up: [] });
8840
+ const st = stacks.get(key), call = { x, st, k: st.calls.length, key, chain: new Set(chain(x.parent)), answers: [] };
8841
+ st.calls.push(call); st.up.push(call.k);
8842
+ if (x.id != null) byId.set(x.id, call);
8843
+ } else if (x.sort === 'reply') {
8844
+ 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]));
8845
+ // two answers are apart when they lie in different operands of one alt
8846
+ const apart = a => a.some(o => { const f = fragOf.get(o), y = at.get(f); return y !== undefined && y !== o && f.props?.operator === 'alt'; });
8847
+ let budget = 256, found;
8848
+ if (st) for (let k = open(st, st.calls.length - 1); k >= 0 && budget > 0; k = open(st, k - 1)) {
8849
+ const call = st.calls[k];
8850
+ if (call.answers.some(a => --budget < 0 || !apart(a))) continue;
8851
+ found = call; break;
8852
+ }
8853
+ const want = x.want != null ? byId.get(x.want) : undefined;
8854
+ if (want && want.key === key && want !== found) { found = want; forced.add(x.x); }
8855
+ if (!found) continue;
8856
+ out.set(x, found.x); found.answers.push(c);
8857
+ 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
8858
+ }
8859
+ }
8860
+ return [out, forced];
8861
+ }
8862
+
8863
+ function readMermaidSequence(text) {
8864
+ const report = [], say = (line, status, message) => report.push({ line, status, message });
8865
+ const empty = () => ({ model: { schemaVersion: 2, root: MS_VIEW, nodes: [], edges: [], views: [{ id: MS_VIEW, diagram: 'uml.sequence', nodeIds: [], edges: [] }] }, report });
8866
+ if (typeof text !== 'string') { say(1, 'skipped', 'the input is not text: nothing read'); return empty(); }
8867
+ if (text.length > MS_LIMIT) { say(1, 'skipped', `the input is ${text.length} characters, over the limit of ${MS_LIMIT}: nothing read`); return empty(); }
8868
+ const lines = text.split('\n').map(l => l.replace(/\r$/, ''));
8869
+ let i = 0, title;
8870
+
8871
+ // front matter: its title; Mermaid's configuration is not read
8872
+ while (i < lines.length && !lines[i].trim()) i++;
8873
+ if (lines[i] && lines[i].trim() === '---') {
8874
+ let j = i + 1;
8875
+ for (; j < lines.length && lines[j].trim() !== '---'; j++) {
8876
+ const l = lines[j];
8877
+ if (!l.trim() || l.trim().startsWith('#') || /^\s/.test(l)) continue;
8878
+ const c = l.indexOf(':'), key = (c < 0 ? l : l.slice(0, c)).trim(), val = c < 0 ? '' : l.slice(c + 1).trim();
8879
+ if (key === 'title') {
8880
+ if (val.startsWith('"')) { try { title = JSON.parse(val); } catch { title = val.slice(1, -1); } }
8881
+ else title = val.startsWith("'") ? val.slice(1, -1).replaceAll("''", "'") : val;
8882
+ } else say(j + 1, 'skipped', `front matter ${key}: Mermaid's configuration is not read`);
8883
+ }
8884
+ if (j >= lines.length) { say(i + 1, 'skipped', 'the front matter is never closed with ---: nothing read'); return empty(); }
8885
+ i = j + 1;
8886
+ }
8887
+
8888
+ const lifelines = new Map(), order = [], items = [], stack = [], creating = new Map(), destroying = new Map();
8889
+ let viewId, started = false, hint = {}, block = false, last = null;
8890
+ const life = (name, no) => {
8891
+ if (!lifelines.has(name)) { const rec = { name, no, node: { id: undefined, type: 'uml.Lifeline', label: name } }; lifelines.set(name, rec); order.push(rec); }
8892
+ return lifelines.get(name);
8893
+ };
8894
+ const cur = () => (stack.length ? stack[stack.length - 1].cur : null); // the operand an element lies in
8895
+ const declare = (rest, actor, no, created) => {
8896
+ const p = msParticipant(rest);
8897
+ if (!p.name) return say(no, 'skipped', `a ${actor ? 'actor' : 'participant'} with no name`);
8898
+ 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`);
8899
+ const rec = life(p.name, no), n = rec.node, stereotypes = [...(actor ? ['actor'] : []), ...(p.type ? [p.type] : [])];
8900
+ if (hint.id != null) rec.hint = hint.id;
8901
+ if (p.type) say(no, 'approximated', `${p.name}'s type ${p.type} is read as a stereotype, shown as text`);
8902
+ 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()); }
8903
+ if (hint.represents) n.props = { ...n.props, represents: msDecode(hint.represents) };
8904
+ if (stereotypes.length) n.props = { ...n.props, stereotypes };
8905
+ if (created) creating.set(p.name, no);
8906
+ };
8907
+ const open = (operator, guard, no) => {
8908
+ const frag = { kind: 'frag', operator, no, hint: hint.id, parent: cur(), ops: [] };
8909
+ const op = { kind: 'op', frag, k: 1, label: guard ? msDecode(guard) : undefined, hint: hint.operand };
8910
+ frag.ops.push(op); items.push(frag, op); stack.push({ frag, cur: op, no });
8911
+ };
8912
+
8913
+ for (; i < lines.length; i++) {
8914
+ const no = i + 1, line = lines[i].trim();
8915
+ if (!line) continue;
8916
+ if (block) { if (line.includes('}')) block = false; continue; } // inside accDescr { ... }
8917
+ if (line.startsWith('%%')) {
8918
+ const c = line.slice(2).trim();
8919
+ if (c.startsWith('{')) {
8920
+ say(no, 'skipped', 'a %%{init}%% directive: Mermaid\'s configuration is not read');
8921
+ if (!line.includes('}%%')) while (i + 1 < lines.length && !lines[++i].includes('}%%')); // over several lines: to its }%%
8922
+ }
8923
+ else if (c.startsWith('id:')) hint.id = c.slice(3).trim();
8924
+ else if (c.startsWith('operand-id:')) hint.operand = c.slice(11).trim();
8925
+ else if (c.startsWith('represents:')) hint.represents = c.slice(11).trim();
8926
+ else if (c.startsWith('reply-to:')) hint.replyTo = c.slice(9).trim();
8927
+ else if (c.startsWith('use:')) {
8928
+ const u = c.slice(4).trim(), r = u.indexOf(' refersTo=');
8929
+ hint.use = r < 0 ? { id: u } : { id: u.slice(0, r).trim(), refersTo: u.slice(r + 10).trim() };
8930
+ }
8931
+ continue;
8932
+ }
8933
+ for (const s of msStatements(line)) {
8934
+ if (!started) {
8935
+ if (s !== 'sequenceDiagram') { say(no, 'skipped', 'not a Mermaid sequenceDiagram: nothing read'); return empty(); }
8936
+ started = true; viewId = hint.id; hint = {}; continue;
8937
+ }
8938
+ const word = msWord(s), rest = s.slice(word.length).trim(), was = last, top = stack[stack.length - 1];
8939
+ last = null;
8940
+ if (word === 'participant' || word === 'actor') declare(rest, word === 'actor', no, false);
8941
+ else if (word === 'create') {
8942
+ const w = msWord(rest);
8943
+ if (w === 'participant' || w === 'actor') declare(rest.slice(w.length).trim(), w === 'actor', no, true);
8944
+ else say(no, 'skipped', `create ${rest}: not a participant or an actor`);
8945
+ } else if (word === 'destroy') { if (rest) destroying.set(rest, no); else say(no, 'skipped', 'destroy names no participant'); }
8946
+ else if (word === 'activate' || word === 'deactivate') {
8947
+ const on = was && (word === 'activate' ? was.to : was.from).name === rest;
8948
+ if (on) { was[word === 'activate' ? 'plus' : 'minus'] = true; last = was; }
8949
+ else say(no, 'approximated', `${word} ${rest}: activations are derived from the messages, so it is not read`);
8950
+ } else if (MS_OPS.includes(word)) open(word, rest, no);
8951
+ 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)`); }
8952
+ else if (word === 'else' || word === 'and') {
8953
+ if (top && top.frag && top.frag.operator === (word === 'else' ? 'alt' : 'par')) {
8954
+ const op = { kind: 'op', frag: top.frag, k: top.frag.ops.length + 1, label: rest ? msDecode(rest) : word === 'else' ? 'else' : undefined, hint: hint.id };
8955
+ top.frag.ops.push(op); items.push(op); top.cur = op;
8956
+ } else say(no, 'skipped', `${word} outside ${word === 'else' ? 'an alt' : 'a par'}: not read`);
8957
+ } 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)`); }
8958
+ else if (word === 'option') {
8959
+ if (top && top.what === 'critical') say(no, 'approximated', `option ${rest}: read as the critical region's content, with no frame`);
8960
+ else say(no, 'skipped', 'option outside a critical region: not read');
8961
+ } 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`); }
8962
+ 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`); }
8963
+ else if (word === 'end') { if (top) stack.pop(); else say(no, 'skipped', 'an end that closes nothing'); }
8964
+ else if (word === 'note') {
8965
+ const c = rest.indexOf(':'), where = (c < 0 ? rest : rest.slice(0, c)).trim(), body = c < 0 ? '' : rest.slice(c + 1).trim();
8966
+ const names = where.replace(/^(over|left of|right of)\s+/i, '').split(',').map(x => x.trim()).filter(Boolean);
8967
+ if (hint.use && names.length) {
8968
+ const t = body === 'ref' ? '' : body.startsWith('ref ') ? body.slice(4).trim() : body, label = msDecode(t);
8969
+ items.push({ kind: 'use', no, id: hint.use.id, refersTo: hint.use.refersTo, label, covers: names.map(x => life(x, no)), parent: cur() });
8970
+ } else say(no, 'skipped', 'a note is not read (notes are not in the subset)');
8971
+ } else if (word === 'title') title = msDecode(rest.replace(/^:/, '').trim());
8972
+ else if (word === 'autonumber') say(no, 'skipped', 'autonumber: message numbers are not drawn in a sequence view');
8973
+ else if (['link', 'links', 'properties', 'details'].includes(word)) say(no, 'skipped', `${word}: actor menus are not read`);
8974
+ else if (word === 'acctitle' || word === 'accdescr') {
8975
+ say(no, 'skipped', `${word === 'acctitle' ? 'accTitle' : 'accDescr'}: accessibility text is not read`);
8976
+ if (word === 'accdescr' && s.includes('{') && !s.includes('}')) block = true;
8977
+ } else {
8978
+ const m = msMessage(s), what = m && `${m.a}${m.arrow[0]}${m.b}`;
8979
+ if (!m) {
8980
+ const half = /-(\|[\\/]|\\\\|\/\/)|(\/\||\/\/|\\\\)-/.test(s); // Mermaid's half arrows (v11.12.3+)
8981
+ 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;
8982
+ }
8983
+ const from = life(m.a, no), to = life(m.b, no);
8984
+ let sort = m.arrow[1];
8985
+ 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); }
8986
+ if (creating.has(to.name)) { sort = 'create'; creating.delete(to.name); }
8987
+ if (destroying.has(to.name)) { sort = 'delete'; destroying.delete(to.name); }
8988
+ 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); }
8989
+ if (sort === m.arrow[1] && m.arrow[2]) say(no, 'approximated', `${what}: read as ${sort === 'async' ? 'an' : 'a'} ${sort}; ${m.arrow[2]}`);
8990
+ if (m.central) say(no, 'approximated', `${what}: the central connection is not drawn`);
8991
+ 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 };
8992
+ items.push(msg); last = msg;
8993
+ }
8994
+ hint = {};
8995
+ }
8996
+ }
8997
+ if (!started) { say(1, 'skipped', 'not a Mermaid sequenceDiagram: nothing read'); return empty(); }
8998
+ for (const o of stack) say(o.no, 'approximated', `${o.frag ? o.frag.operator : o.what} is never closed: read to the end`);
8999
+ for (const [name, no] of creating) say(no, 'skipped', `create ${name}: no message to it follows`);
9000
+ for (const [name, no] of destroying) say(no, 'skipped', `destroy ${name}: no message to it follows`);
9001
+
9002
+ // ids: lifelines first, by name; then, in the order they come, messages, uses, fragments and operands
9003
+ const used = new Set(), next = new Map(), uniq = name => { const id = msFree(used, next, msId(name)); used.add(id); return id; };
9004
+ for (const rec of order) {
9005
+ rec.node.id = uniq(rec.hint ?? rec.name);
9006
+ 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}`);
9007
+ }
9008
+ const seen = new Map();
9009
+ for (const x of items) {
9010
+ if (x.kind === 'msg') x.id = uniq(x.hint ?? `${x.from.node.id}-${x.to.node.id}`);
9011
+ else if (x.kind === 'use') x.id = uniq(x.id || 'use');
9012
+ 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}`); }
9013
+ else x.id = uniq(x.hint ?? `${x.frag.id}.${x.k}`);
9014
+ }
9015
+
9016
+ const nodes = order.map(r => r.node), at = new Map(nodes.map((n, k) => [n.id, k]));
9017
+ 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 },
9018
+ members: f.ops.map(o => ({ id: o.id, type: 'uml.InteractionOperand', ...(o.label != null ? { label: o.label } : {}) })) }));
9019
+ const occ = items.filter(x => x.kind === 'msg' || x.kind === 'use');
9020
+ 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);
9021
+ const answer = new Map([...replies].map(([r, c]) => [r.x, c.x])), lastReply = new Map();
9022
+ for (const [r, c] of answer) lastReply.set(c, r);
9023
+ const edges = occ.map(x => {
9024
+ if (x.kind === 'use') {
9025
+ 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));
9026
+ return { id: x.id, type: 'uml.InteractionUse', ...(x.label ? { label: x.label } : {}), ...(x.parent ? { parent: x.parent.id } : {}),
9027
+ props: { covers: nodes.slice(lo, hi + 1).map(n => n.id), ...(x.refersTo ? { refersTo: x.refersTo } : {}) } };
9028
+ }
9029
+ const call = answer.get(x);
9030
+ 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`);
9031
+ 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`);
9032
+ 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`);
9033
+ 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`);
9034
+ 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 } : {}),
9035
+ props: { sort: x.sort, ...(call ? { replyTo: call.id } : {}) } };
9036
+ });
9037
+
9038
+ const view = { id: viewId || MS_VIEW, diagram: 'uml.sequence', ...(title ? { title } : {}), nodeIds: nodes.map(n => n.id), edges, ...(frags.length ? { fragments: frags } : {}) };
9039
+ report.sort((a, b) => a.line - b.line); // stable: one line's items keep their order
9040
+ return { model: { schemaVersion: 2, ...(title ? { title } : {}), root: view.id, nodes, edges: [], views: [view] }, report };
9041
+ }
9042
+
9043
+ function writeMermaidSequence(model, viewId) {
9044
+ const m = model && typeof model === 'object' ? model : {}, arr = x => (Array.isArray(x) ? x : []), isObj = x => !!x && typeof x === 'object';
9045
+ const flat = s => String(s).replace(/\r\n|\r|\n/g, '\\n'), nl = s => /[\r\n]/.test(String(s)), has = x => x != null && x !== '';
9046
+ const views = arr(m.views).filter(isObj);
9047
+ const view = viewId != null ? views.find(v => v.id === viewId) : views.find(v => v.diagram === 'uml.sequence');
9048
+ if (viewId != null && !view) return `%% not written: view ${flat(viewId)}, which the model does not have\n`;
9049
+ if (!view) return '%% not written: the model, which has no uml.sequence view\n';
9050
+ if (view.diagram !== 'uml.sequence') return `%% not written: view ${flat(view.id)}, a ${flat(view.diagram)} view, not a uml.sequence one\n`;
9051
+ // 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
9052
+ const enc = s => String(s).replace(/[#;<>\\]/g, c => ({ '#': '#35;', ';': '#59;', '<': '#lt;', '>': '#gt;', '\\': '#92;' })[c]).replace(/\r\n|\r|\n/g, '<br>')
9053
+ .replace(/^\s|\s$/g, c => `#${c.codePointAt(0)};`);
9054
+ const encA = s => enc(s).replace(/:/g, '#58;'); // an alias: under %% represents:, its first colon ends the label
9055
+ const out = [], notWritten = [], skip = x => notWritten.push(`${flat(x.id)} (${flat(x.type)})`), lose = (id, what) => notWritten.push(`${flat(id)}: ${flat(what)}`);
9056
+ const title = has(view.title) ? view.title : m.title;
9057
+ if (has(title)) out.push('---', `title: ${/^[\p{L}\p{N}][\p{L}\p{N} _.,()-]*$/u.test(title) && !title.endsWith(' ') ? title : JSON.stringify(String(title))}`, '---');
9058
+ 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}`); }
9059
+ out.push('sequenceDiagram');
9060
+
9061
+ // what is written: lifelines, occurrences (messages and uses) and fragments of the view
9062
+ const byId = new Map(arr(m.nodes).filter(isObj).map(n => [n.id, n]));
9063
+ const lifes = [];
9064
+ 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); }
9065
+ const isLife = new Set(lifes.map(n => n.id));
9066
+ const edgeById = new Map();
9067
+ for (const x of arr(m.edges)) if (isObj(x) && !edgeById.has(x.id)) edgeById.set(x.id, x);
9068
+ const edgeOf = e => (typeof e === 'string' ? edgeById.get(e) : e);
9069
+ const occ = [];
9070
+ for (const x of arr(view.edges).map(edgeOf).filter(isObj)) {
9071
+ const ok = !nl(x.id) && (x.type === 'uml.Message' ? isLife.has(x.from) && isLife.has(x.to)
9072
+ : x.type === 'uml.InteractionUse' && arr(x.props?.covers).length > 0 && arr(x.props.covers).every(c => isLife.has(c)) && !nl(x.props.refersTo ?? ''));
9073
+ if (ok) occ.push(x); else skip(x);
9074
+ }
9075
+ const SORTS = ['call', 'async', 'reply', 'create', 'delete'];
9076
+ const frags = [], opOf = new Map();
9077
+ for (const f of arr(view.fragments).filter(isObj)) {
9078
+ const ops = arr(f.members).filter(isObj), op = f.props?.operator;
9079
+ 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; }
9080
+ if (has(f.label)) lose(f.id, `label ${f.label}`);
9081
+ frags.push(f); ops.forEach((o, i) => opOf.set(o.id, { f, i }));
9082
+ }
9083
+ // fragments with an occurrence in them, and the empty ones by the operand they end (null: the top)
9084
+ const full = new Set(), kids = new Map();
9085
+ 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);
9086
+ 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); }
9087
+
9088
+ // the sorts as written
9089
+ const sortOf = new Map(), touched = new Set(), created = [];
9090
+ for (const x of occ) {
9091
+ if (x.type !== 'uml.Message') { arr(x.props.covers).forEach(c => touched.add(c)); continue; }
9092
+ let s = x.props?.sort;
9093
+ if (!SORTS.includes(s)) { lose(x.id, has(s) ? `sort ${s}, written as a call` : 'no sort, written as a call'); s = 'call'; }
9094
+ 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'; }
9095
+ if (s === 'create') created.push(x.to);
9096
+ touched.add(x.from); touched.add(x.to); sortOf.set(x, s);
9097
+ }
9098
+ const replySlot = notWritten.length; // a reply's call is found once the fragments are placed; what is lost about it goes here
9099
+
9100
+ // lifelines: those not created up front, in the view's order, then the created ones where they are created
9101
+ const isCreated = new Set(created), written = [...lifes.filter(n => !isCreated.has(n.id)), ...created.map(id => byId.get(id))];
9102
+ 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');
9103
+ const used = new Set(), next = new Map(), names = new Map(), taken = new Set(lifes.map(n => n.id)), takenNext = new Map();
9104
+ 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
9105
+ const safe = id => /^[\p{L}\p{N}_]+(?:[.-][\p{L}\p{N}_]+)*$/u.test(id) && !/-x/i.test(id) && !MS_KEYWORDS.has(msWord(id));
9106
+ const heads = new Map();
9107
+ for (const n of written) {
9108
+ let name = String(n.id);
9109
+ 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); }
9110
+ names.set(n.id, name);
9111
+ const p = isObj(n.props) ? n.props : {}, st = arr(p.stereotypes), actor = st[0] === 'actor', rep = has(p.represents);
9112
+ st.slice(actor ? 1 : 0).forEach(s => lose(n.id, `stereotype ${s}`));
9113
+ // the reader takes the alias as the label, and splits it at its first colon only under a %% represents: comment
9114
+ const label = has(n.label) ? String(n.label) : name, alias = label !== name || rep ? encA(label) + (rep ? ' : ' + encA(p.represents) : '') : '';
9115
+ if (!has(n.label)) lose(n.id, 'no label, read as its name');
9116
+ heads.set(n.id, [...(idOf(n.id, name) != null ? [`%% id: ${n.id}`] : []), ...(rep ? [`%% represents: ${enc(p.represents)}`] : []),
9117
+ `${actor ? 'actor' : 'participant'} ${name}${alias ? ' as ' + alias : ''}`]);
9118
+ }
9119
+ for (const n of lifes) if (!isCreated.has(n.id)) out.push(...heads.get(n.id).map(l => ' ' + l));
9120
+
9121
+ // the occurrences in time order, opening and closing fragments around them; an empty fragment ends its operand
9122
+ const stack = [], on = new Map(), done = new Set(), broken = new Set(), count = new Map(), parentOf = new Map(); // on: fragment -> its place in the stack
9123
+ const put = (depth, ...xs) => out.push(...xs.map(x => ' '.repeat(depth + 1) + x));
9124
+ const top = () => (stack.length ? stack.at(-1).f.members[stack.at(-1).i].id : undefined); // the operand being written
9125
+ const empty = (x, what) => { if (x.label === '') lose(x.id, `an empty ${what}, read as none`); };
9126
+ const guard = o => (has(o.label) ? ' ' + enc(o.label) : '');
9127
+ const begin = f => {
9128
+ 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`);
9129
+ count.set(op, n); empty(o, 'guard');
9130
+ put(stack.length, ...(fh ? [`%% id: ${fh}`] : []), ...(oh ? [`%% operand-id: ${oh}`] : []), op + guard(o));
9131
+ parentOf.set(f, top()); on.set(f, stack.length); stack.push({ f, i: 0 }); done.add(f);
9132
+ };
9133
+ const ends = o => { for (const k of kids.get(o.id) || []) if (!done.has(k)) { begin(k); finish(); } };
9134
+ const advance = () => {
9135
+ const t = stack.at(-1), f = t.f;
9136
+ ends(f.members[t.i]);
9137
+ const o = f.members[++t.i], h = idOf(o.id, `${f.id}.${t.i + 1}`), alt = f.props.operator === 'alt';
9138
+ if (alt && !has(o.label)) lose(o.id, 'no guard, read as else'); else empty(o, 'guard');
9139
+ put(stack.length - 1, ...(h ? [`%% id: ${h}`] : []), alt ? (o.label === 'else' ? 'else' : 'else' + guard(o)) : 'and' + guard(o));
9140
+ };
9141
+ 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'); };
9142
+ 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
9143
+ // ponytail: an occurrence walks up from its operand to the first fragment open on the stack, so a model written in order
9144
+ // costs its size; one whose fragments are not one run (named below) can cost its depth per occurrence
9145
+ for (const x of occ) {
9146
+ const path = [], seen = new Set(); // the operands to open, innermost first
9147
+ let p = x.parent, keep = 0, later = false;
9148
+ while (opOf.has(p) && !seen.has(p)) {
9149
+ seen.add(p);
9150
+ const o = opOf.get(p), s = on.get(o.f);
9151
+ if (s !== undefined) { keep = s; if (o.i === stack[s].i) keep++; else { later = o.i > stack[s].i; path.push(o); } break; }
9152
+ path.push(o); p = o.f.parent;
9153
+ }
9154
+ while (stack.length > keep + (later ? 1 : 0)) finish();
9155
+ if (later) { const o = path.pop(); while (stack.at(-1).i < o.i) advance(); }
9156
+ for (let j = path.length - 1; j >= 0; j--) {
9157
+ const o = path[j];
9158
+ 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; }
9159
+ begin(o.f);
9160
+ while (stack.at(-1).i < o.i) advance();
9161
+ }
9162
+ const d = stack.length;
9163
+ if (x.type === 'uml.InteractionUse') {
9164
+ 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));
9165
+ 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`);
9166
+ idOf(x.id, x.id); empty(x, 'label');
9167
+ const ends2 = lo === hi ? names.get(written[lo].id) : `${names.get(written[lo].id)},${names.get(written[hi].id)}`;
9168
+ put(d, `%% use: ${x.id}${has(x.props.refersTo) ? ' refersTo=' + x.props.refersTo : ''}`, `Note over ${ends2}: ref${has(x.label) ? ' ' + enc(x.label) : ''}`);
9169
+ continue;
9170
+ }
9171
+ const s = sortOf.get(x);
9172
+ if (s === 'create') put(d, ...heads.get(x.to).map((l, k, a) => (k === a.length - 1 ? 'create ' + l : l)));
9173
+ if (s === 'delete') put(d, `destroy ${names.get(x.to)}`);
9174
+ empty(x, 'label');
9175
+ msgAt.set(x, { line: out.length, pad: ' '.repeat(d + 1), h: idOf(x.id, `${x.from}-${x.to}`), parent: top() });
9176
+ out.push(null);
9177
+ }
9178
+ while (stack.length) finish();
9179
+ for (const f of kids.get(null) || []) if (!done.has(f)) { begin(f); finish(); }
9180
+ for (const f of frags) if (!done.has(f)) { skip(f); f.members.forEach(skip); } // inside a fragment that is not one run
9181
+
9182
+ // the replies' calls as the reader will find them, from where the messages are written
9183
+ const msgs = [...msgAt.keys()], placed = frags.filter(f => done.has(f)).map(f => ({ ...f, parent: parentOf.get(f) }));
9184
+ 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,
9185
+ want: sortOf.get(x) === 'reply' && has(x.props?.replyTo) ? x.props.replyTo : undefined })), placed);
9186
+ const callOf = new Map([...found].map(([r, c]) => [r.x, c.x])), lastReply = new Map(), lost = [];
9187
+ for (const [r, c] of callOf) lastReply.set(c, r);
9188
+ const arrows = { call: '->>', async: '-)', reply: '-->>', create: '->>', delete: '-x' };
9189
+ for (const x of msgs) {
9190
+ 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 ? '-' : '';
9191
+ if (s === 'reply' && (callOf.get(x)?.id ?? undefined) !== (x.props?.replyTo ?? undefined)) {
9192
+ 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')}`);
9193
+ }
9194
+ out[line] = [...(h ? [`%% id: ${h}`] : []), ...(forced.has(x) ? [`%% reply-to: ${callOf.get(x).id}`] : []),
9195
+ `${names.get(x.from)}${arrows[s]}${plus}${minus}${names.get(x.to)}:${has(x.label) ? ' ' + enc(x.label) : ''}`].map(l => pad + l).join('\n');
9196
+ }
9197
+ notWritten.splice(replySlot, 0, ...lost);
9198
+ for (const x of notWritten) out.push(` %% not written: ${x}`);
9199
+ return out.join('\n') + '\n';
9200
+ }