umlimator 0.20.0 → 0.21.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.
@@ -121,7 +121,11 @@ function registerDiagramType(name, def = {}) {
121
121
  "none", which emits reparent (the element itself and what it holds are left out; its owner is added when not offered).
122
122
  None, or not a list: no Owner field.
123
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). */
124
+ reroute is no registry field: reroute: false goes in the dg the type's own layout passes to layoutTyped (src/typed/layout.js).
125
+ Stage 4's (notes/stage-4.md §12.1, §15): edits.rename(model, view, id, text); a hook's { pick, then }; name: false and
126
+ opens on a palette entry; a line's cut, its label's width (150); rows(model, view, id) and word(model, id), the PDF's.
127
+ Stage 5's: a line's fromPort, toPort, inner, portRank, a box's pad, round, a view's labelGaps (src/typed/layout.js);
128
+ edits.member, edits.key (src/typed/edit.js). */
125
129
 
126
130
  /* A view's type: the map for a view with no diagram, the registered type, or null for one this release does not draw */
127
131
  const diagramOf = view => TYPES.get(view && view.diagram != null ? view.diagram : 'map') || null;
@@ -524,14 +528,13 @@ const SHAPE = {
524
528
  // src/typed/draw.js: from its text), and ports(n, side, k), k line ends spread evenly along a side ('L', 'R', 'T', 'B'), each on the outline
525
529
  for (const S of Object.values(SHAPE)) { S.size ||= n => ({ w: mapNodeW(n), h: 64 }); S.ports = (n, side, k = 1) => portsOn(S, n, side, k); }
526
530
  const SHAPE_NAMES = ['box', 'person', 'browser', 'mobile', 'cylinder', 'pipe', 'document', 'hexagon', 'robot', 'terminal', 'window', 'folder', 'node']; // all 13
527
- // a card's shape in a notation: its own `shape` first; box draws every card as before (a hook stays a hexagon there too)
528
- const shapeOf = (n, notation) => { const sh = String(n.shape ?? '').toLowerCase(); if (SHAPE_NAMES.includes(sh)) return sh;
529
- const T = hasKey(NOTED, notation) && NOTED[notation], k = String(n.kind ?? '').toLowerCase(); return T ? (hasKey(T, k) && T[k]) || (hasKey(T, n.k) && T[n.k]) || 'box' : 'box'; };
530
- const noteOf = x => NOTATIONS.includes(x) ? x : null;
531
- // a card as its notation draws it (null for a box): its shape, and where its head, folder tab and title bar go, clear of its
531
+ // a card's shape: its own `shape` first, then (a map's card: byKind) the one its kind draws, else a box
532
+ const shapeOf = (n, byKind = true) => { const sh = String(n.shape ?? '').toLowerCase(); if (SHAPE_NAMES.includes(sh)) return sh;
533
+ const k = String(n.kind ?? '').toLowerCase(); return byKind && ((hasKey(NOTED, k) && NOTED[k]) || (hasKey(NOTED, n.k) && NOTED[n.k])) || 'box'; };
534
+ // a card as it is drawn (null for a box): its shape, and where its head, folder tab and title bar go, clear of its
532
535
  // tabs, ⤢ and change badge. Drawing only: layout never sees it. ports: the card's highest line ends on its sides (sidePorts)
533
- function cardLook(n, notation, hint, ports) {
534
- const sh = shapeOf(n, notation); if (sh === 'box') return null;
536
+ function cardLook(n, byKind, hint, ports) {
537
+ const sh = shapeOf(n, byKind); if (sh === 'box') return null;
535
538
  const S = SHAPE[sh], L = { ...n, sh, S, portTop: ports ? Math.min(ports.L, ports.R) : Infinity, portL: ports ? ports.L : Infinity }, tabs = mapTabs(n), spans = []; let tx = n.x + 12;
536
539
  // a step keeps 16 px above the highest line into the left side (svgOf): where that is above a person's or robot's shoulder,
537
540
  // the shoulder comes up under it, 5 px below its middle (it floated 4 px above the shoulder)
@@ -624,23 +627,23 @@ function svgOf(geo, opts = {}) {
624
627
  const marker = (n, c, k = 1) => `<marker id="${id}-${n}" markerWidth="${10 * k}" markerHeight="${8 * k}" refX="${9 * k}" refY="${4 * k}" orient="auto-start-reverse" markerUnits="userSpaceOnUse"><path d="M0 0 L${10 * k} ${4 * k} L0 ${8 * k} Z" fill="${c}"/></marker>`;
625
628
  const MC = { d: LINE, g: 'var(--um-green)', r: 'var(--um-rose)', v: 'var(--um-violet)', h: 'var(--um-cyan)', ...Object.fromEntries(WIRES.map(w => [w[0], `var(--um-w-${w[0]})`])) };
626
629
  const heavy = [...new Set(edges.filter(e => e.weight > 1).map(e => lstyle(e).m + '-w' + e.weight))]; // a heavier line's arrowhead grows with it
627
- // the notation (0.15.0): each card's look, and each line as drawn, its ends on the cards' outlines (a map's cards only)
628
- const notation = map ? noteOf(opts.shapes) || 'box' : 'box', kidsOf = n => n.kids != null ? n.kids : opts.childCount ? opts.childCount(n.id) : 0;
630
+ // each card's look (0.15.0), and each line as drawn, its ends on the cards' outlines (a map's cards only)
631
+ const kidsOf = n => n.kids != null ? n.kids : opts.childCount ? opts.childCount(n.id) : 0;
629
632
  // another view's node is drawn in its own shape, when it names one (1.5): its lines end where its ports() put them, on
630
633
  // its outline already, so they are drawn as given
631
- const ports = map ? sidePorts(geo) : null, looks = new Map(map ? nodes.map(n => [n, cardLook(n, notation, !!(kidsOf(n) || n.opens) && opts.openHint !== false, ports.get(n))])
632
- : nodes.map(n => [n, n.shape != null && cardLook(n, 'box', false, null)]).filter(x => x[1]));
634
+ const ports = map ? sidePorts(geo) : null, looks = new Map(map ? nodes.map(n => [n, cardLook(n, true, !!(kidsOf(n) || n.opens) && opts.openHint !== false, ports.get(n))])
635
+ : nodes.map(n => [n, n.shape != null && cardLook(n, false, false, null)]).filter(x => x[1]));
633
636
  const drawn = edges.map(e => map && looks.size ? drawnPts(e, n => looks.get(n) || null) : e.pts);
634
637
  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)
635
638
  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">
636
639
  <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>`;
637
640
  const families = geo.families || [], familyDepth = f => f.parent ? 1 + familyDepth(families.find(p => p.id === f.parent) || {}) : 0;
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)
641
+ families.slice().sort((a,b) => familyDepth(a)-familyDepth(b)).forEach(f => {
639
642
  // 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
643
  // .family-title (the audit, the canvas's "Box …, selected") and draws its border with class family-box: the canvas outlines a
641
644
  // selected box and the box under a drag by .family-box alone (src/canvas/styles.js)
642
645
  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; }
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"' : ''}/>
646
+ 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'})"/>
644
647
  <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 ? `
645
648
  <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>` : `
646
649
  <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>`;
@@ -701,8 +704,8 @@ function svgOf(geo, opts = {}) {
701
704
  if (!map && hasKey(SHAPE, n.shape) && SHAPE[n.shape].draw) { s += SHAPE[n.shape].draw(n, col(n.k), fill(n.k)); return; } // a typed view's own shape: a class box (1.5), a note (1.9)
702
705
  const c = col(n.k), f = fill(n.k), removed = n.change === 'removed', planned = n.change === 'planned';
703
706
  const L = looks.get(n) || null, S = L && L.S; // a shaped card (0.15.0); null: a box, drawn as before
704
- const pub = map && n.k === 'm-public', priv = map && n.k === 'm-script', ext = map && n.k === 'm-ext', verb = map && n.k === 'm-verb', hook = map && n.k === 'm-hook' && String(n.shape ?? '').toLowerCase() !== 'box';
705
- const dash = removed || ext || planned || (map && notation !== 'box' && n.k === 'external'); // an external card is dashed where a notation draws types (the legend says so in every notation)
707
+ const pub = map && n.k === 'm-public', priv = map && n.k === 'm-script', ext = map && n.k === 'm-ext', verb = map && n.k === 'm-verb';
708
+ const dash = removed || ext || planned || (map && n.k === 'external'); // an external card is dashed (the legend says so)
706
709
  const rx = verb ? n.h / 2 : map ? 10 : 9;
707
710
  const kids = kidsOf(n), stacked = !!(kids || n.opens); // opens a view, loaded or not (#47)
708
711
  const subject = opts.subject === n.id;
@@ -739,13 +742,11 @@ function svgOf(geo, opts = {}) {
739
742
  else {
740
743
  if (stacked) parts.push(`<rect x="${n.x + 8}" y="${n.y + 8}" width="${n.w}" height="${n.h}" rx="${rx}" class="n-stack" stroke="${c}"/><rect x="${n.x + 4}" y="${n.y + 4}" width="${n.w}" height="${n.h}" rx="${rx}" class="n-stack" stroke="${c}"/>`);
741
744
  if (pub) parts.push(`<rect x="${n.x - 4}" y="${n.y - 4}" width="${n.w + 8}" height="${n.h + 8}" rx="14" class="n-ring" stroke="${c}"/>`);
742
- if (hook) { const hx = Math.min(16, n.h / 3), pts = `${n.x + hx},${n.y} ${n.x + n.w - hx},${n.y} ${n.x + n.w},${n.y + n.h / 2} ${n.x + n.w - hx},${n.y + n.h} ${n.x + hx},${n.y + n.h} ${n.x},${n.y + n.h / 2}`;
743
- parts.push(`<polygon points="${pts}" class="n-mask"/><polygon points="${pts}" class="n-body" fill="${f}" stroke="${c}" stroke-linejoin="round"/>`); }
744
- else parts.push(`<rect x="${n.x}" y="${n.y}" width="${n.w}" height="${n.h}" rx="${rx}" class="n-mask"/>
745
+ parts.push(`<rect x="${n.x}" y="${n.y}" width="${n.w}" height="${n.h}" rx="${rx}" class="n-mask"/>
745
746
  <rect x="${n.x}" y="${n.y}" width="${n.w}" height="${n.h}" rx="${rx}" class="n-body" fill="${f}" stroke="${c}" ${dash ? 'stroke-dasharray="6 4"' : ''} ${removed ? 'opacity=".6"' : ''}/>`); }
746
- // the mark, centred on the face; in the uml notation a box's own sigil gives way to UML's component icon
747
- const glyph = L && SIGIL[L.sh] ? L.sh : notation === 'uml' && !L && !String(n.k).startsWith('m-') ? 'component' : n.k; // a person's, robot's, phone's, terminal's, folder's or node's own mark
748
- parts.push((() => { const I = (opts.icons || {})[n.icon], x = map ? n.x + 14 : n.x + (verb ? 14 : hook ? 17 : 9), y = map ? fy - 14 : n.y + (priv ? 8 : 11), M = map ? 28 : 16; // map: one 28 px mark, left, centred
747
+ // the mark, centred on the face
748
+ const glyph = L && SIGIL[L.sh] ? L.sh : n.k; // a person's, robot's, phone's, terminal's, folder's or node's own mark
749
+ parts.push((() => { const I = (opts.icons || {})[n.icon], x = map ? n.x + 14 : n.x + (verb ? 14 : 9), y = map ? fy - 14 : n.y + (priv ? 8 : 11), M = map ? 28 : 16; // map: one 28 px mark, left, centred
749
750
  return I && I.src ? `<rect x="${x - M * 3 / 28}" y="${y - M * 3 / 28}" width="${M * 34 / 28}" height="${M * 34 / 28}" rx="${M / 4}" class="n-logo${I.dark ? ' dark' : ''}"/><image href="${I.src}" x="${x}" y="${y}" width="${M}" height="${M}"/>`
750
751
  : I && I.g ? `<g transform="translate(${x} ${y}) scale(${M / 24})" class="n-sigil" fill="none" stroke="${c}" stroke-width="${1.75 * 24 / M}" stroke-linecap="round" stroke-linejoin="round">${I.g}</g>`
751
752
  : `<g transform="translate(${x} ${y}) scale(${M / 16})" class="n-sigil" fill="none" stroke="${c}" stroke-width="${1.75 * 16 / M}" stroke-linecap="round" stroke-linejoin="round">${SIGIL[glyph]}</g>`; })());
@@ -840,7 +841,7 @@ const hit = (a, b, pad = 0) => a.x < b.x + b.w + pad && b.x < a.x + a.w + pad &&
840
841
  // a line whose route layoutPass draws itself in a typed view: 'loop' (a node to itself) or 'fan'. A symbol, so no field of a
841
842
  // model's line can set it
842
843
  const OWN_ROUTE = Symbol('own route');
843
- const labelW = e => textW(fit((e.step != null ? e.step + ' · ' : '') + (e.label || ''), 10, 150), 10) + 12;
844
+ const labelW = e => textW(fit((e.step != null ? e.step + ' · ' : '') + (e.label || ''), 10, e.cut || 150), 10) + 12;
844
845
 
845
846
  /* Layered auto-layout for views that carry no col/row: longest-path layers (cycles broken by DFS),
846
847
  barycentre ordering sweeps, integer rows so row gaps stay free as routing channels. */
@@ -1048,7 +1049,7 @@ function layoutPass(dg, map, room, last) { // room: { x: { col: px }, y: { row:
1048
1049
  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
1050
  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
1051
  if (!map) b[TB ? 'h' : 'w'] = Math.max(b[TB ? 'h' : 'w'], headW(f) + 16);
1051
- return cache[f.id] = b;
1052
+ return cache[f.id] = dg.typed ? TYPED.frame(b) : b;
1052
1053
  };
1053
1054
  const out = familyDefs.map(f => frameOf(f)).filter(Boolean);
1054
1055
  if (map) out.forEach(f => { // the box fits its title and its badges, which are tabs on its top border, at the right
@@ -1115,13 +1116,13 @@ function layoutPass(dg, map, room, last) { // room: { x: { col: px }, y: { row:
1115
1116
  bend penalty. Earlier routes may never be overlapped and are crossed only when no clean
1116
1117
  route exists; earlier labels are obstacles, so later routes keep clear of them. */
1117
1118
  // 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
1118
- // something inside it: that has no line)
1119
+ // something inside it: that has no line, but a typed view's inner line)
1119
1120
  const headOf = f => Math.min((TB ? f.h : f.w) - 16, headW(f)); // the length of a box's title along its header
1120
1121
  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
1122
  decoT: TB ? [] : [[f.x + 8, headOf(f)], ...(f.tabs || []).map(t => [t.x - 6, t.w + 12])], decoB: [] }]));
1122
1123
  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;
1123
1124
  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;
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; }
1125
+ if (!(LP && e[e.inner + 'Port'] != null) && (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; }
1125
1126
  return true; }).map(e => ({ ...e, s: end(e.from), t: end(e.to) }));
1126
1127
  const segsOf = pts => pts.slice(0, -1).map((a, i) => [a, pts[i + 1]]);
1127
1128
  const segHitsRect = (a, b, r) => { const x1 = Math.min(a[0], b[0]), x2 = Math.max(a[0], b[0]), y1 = Math.min(a[1], b[1]), y2 = Math.max(a[1], b[1]); return x2 > r.x && x1 < r.x + r.w && y2 > r.y && y1 < r.y + r.h; };
@@ -1179,7 +1180,7 @@ function layoutPass(dg, map, room, last) { // room: { x: { col: px }, y: { row:
1179
1180
  });
1180
1181
  free.forEach(e => { pushSide(e.s, e.fs, e, e.t); pushSide(e.t, e.ts, e, e.s); });
1181
1182
  edges.filter(e => e.under > 0).forEach(e => pushSide(e.t, 'R', e, e.s)); // a loop under the boxes takes its port with the side's other lines
1182
- if (LP) LP.ports(pushSide);
1183
+ if (LP) LP.ports(pushSide, sides);
1183
1184
  const portAt = (it, side, p) => { if (it.n === it.e.s && it.e.fs === side) it.e.p1 = p; else it.e.p2 = p; };
1184
1185
  Object.entries(sides).sort(([, a], [, b]) => !!a[0].n.box - !!b[0].n.box).forEach(([key, list]) => { // cards first: a box's port lines up with them
1185
1186
  const side = key.split('|').pop(), vertical = side === 'L' || side === 'R';
@@ -1425,7 +1426,7 @@ function layoutPass(dg, map, room, last) { // room: { x: { col: px }, y: { row:
1425
1426
  const put = (e, s, full) => { placed.push(s.box); reserved.push(s.grow); e.lbl = { ...s.lbl, short: s.lbl.text !== full }; };
1426
1427
  const unput = e => { const L = e.lbl, same = m => r => r.x === L.x - m && r.y === L.y - m && r.w === L.w + 2 * m && r.h === L.h + 2 * m;
1427
1428
  const p = placed.findIndex(same(0)), q = reserved.findIndex(same(3)); if (p >= 0) placed.splice(p, 1); if (q >= 0) reserved.splice(q, 1); e.lbl = null; };
1428
- const fullOf = e => map ? fit(e.label || '', 10.5, e.under ? 320 : 440) : fit((e.step != null ? e.step + ' · ' : '') + (e.label || ''), 10, 150);
1429
+ const fullOf = e => map ? fit(e.label || '', 10.5, e.under ? 320 : 440) : fit((e.step != null ? e.step + ' · ' : '') + (e.label || ''), 10, e.cut || 150);
1429
1430
  function placeLabel(e) {
1430
1431
  if (!e.label && e.step == null) return;
1431
1432
  const full = fullOf(e);
@@ -1564,7 +1565,7 @@ function layoutPass(dg, map, room, last) { // room: { x: { col: px }, y: { row:
1564
1565
  if ((map || dg.typed) && dg.repair !== false) free.forEach(e => {
1565
1566
  const P = e.pts, ax = e.fs === 'L' || e.fs === 'R' ? 1 : 0, d = P[P.length - 1][ax] - P[0][ax];
1566
1567
  if (P.length !== 4 || !d || Math.abs(d) > 16 || OUT[e.fs][1 - ax] !== -OUT[e.ts][1 - ax]) return;
1567
- const lone = (n, side) => !n.box && sides[n.id + '|' + side].length === 1, [k, n, side, v] = lone(e.t, e.ts) ? ['p2', e.t, e.ts, P[0][ax]] : lone(e.s, e.fs) ? ['p1', e.s, e.fs, P[3][ax]] : [];
1568
+ const lone = (n, side) => !n.box && sides[n.id + '|' + side]?.length === 1, [k, n, side, v] = lone(e.t, e.ts) ? ['p2', e.t, e.ts, P[0][ax]] : lone(e.s, e.fs) ? ['p1', e.s, e.fs, P[3][ax]] : [];
1568
1569
  const sp = n && (ax ? [n.y + n.h * .08, n.y + n.h * .92] : sideSpan(n, side) || [n.x + n.w * .08, n.x + n.w * .92]);
1569
1570
  if (!k || v < sp[0] || v > sp[1]) return;
1570
1571
  const was = e[k], before = issueCount(new Set([e])), snap = [e.pts, e.lbl, e.how], snapR = [routed.slice(), reserved.slice(), placed.slice()];
@@ -1582,11 +1583,11 @@ function layoutPass(dg, map, room, last) { // room: { x: { col: px }, y: { row:
1582
1583
  // the label's width, else the row gap by its height and 14 px; a label in neither, or with no spot, the gap its line's longest
1583
1584
  // run crosses, a column's first. layoutGraph lays the view out again with it, and keeps that layout if it is better
1584
1585
  let labelRoom = null;
1585
- const tight = !last && !dg.quick && map && !dg.abs ? free.filter(e => (e.label || e.step != null) && (!e.lbl || e.lbl.short || families.some(f => onBorder(e.lbl, f)))) : [];
1586
+ const tight = !last && !dg.quick && (map || dg.labelGaps) && !dg.abs ? free.filter(e => (e.label || e.step != null) && (!e.lbl || e.lbl.short || families.some(f => onBorder(e.lbl, f)))) : [];
1586
1587
  if (tight.length) {
1587
1588
  const cs = Object.keys(colX).map(Number), rs = nodes.map(n => n.row), r0 = Math.floor(Math.min(...rs)), r1 = Math.ceil(Math.max(...rs)), need = { x: {}, y: {} };
1588
1589
  const gaps = [...cs.filter(c => colX[c + 1] != null).map(c => ['x', c, colX[c] + (colW[c] || NW), colX[c + 1]]),
1589
- ...Array.from({ length: Math.max(0, r1 - r0) }, (_, k) => ['y', r0 + k, rowY(r0 + k) + NH, rowY(r0 + k + 1)])];
1590
+ ...Array.from({ length: map && r1 - r0 }, (_, k) => ['y', r0 + k, rowY(r0 + k) + NH, rowY(r0 + k + 1)])];
1590
1591
  tight.forEach(e => { const { w, hgt: h } = sizeOf(e, fullOf(e)), L = e.lbl, c = L && { x: L.x + L.w / 2, y: L.y + L.h / 2 };
1591
1592
  let best = c && ['x', 'y'].map(k => gaps.find(([gk, g, p, q]) => gk === k && c[k] >= p && c[k] <= q)).find(Boolean);
1592
1593
  if (!best) segsOf(e.pts).forEach(([a, b]) => { const k = a[1] === b[1] ? 'x' : 'y', i = k === 'x' ? 0 : 1, lo = Math.min(a[i], b[i]), hi = Math.max(a[i], b[i]);
@@ -1773,7 +1774,7 @@ const ALIAS = { client: 'frontend', ui: 'frontend', web: 'frontend', page: 'fron
1773
1774
  user: 'external', human: 'external', thirdparty: 'external', vendor: 'external',
1774
1775
  doc: 'file', config: 'file', skill: 'file', md: 'file', template: 'file', output: 'file',
1775
1776
  agent: 'ai', llm: 'ai', model: 'ai' };
1776
- const hasKey = (o, k) => Object.hasOwn(o, k); // a table's own key: "constructor" or "__proto__" is no kind, shape or notation
1777
+ const hasKey = (o, k) => Object.hasOwn(o, k); // a table's own key: "constructor" or "__proto__" is no kind or shape
1777
1778
  const kindOf = (k, map) => { k = String(k || '').toLowerCase(); if (map && hasKey(MAPK, k)) return MAPK[k]; return hasKey(KIND, k) ? k : hasKey(ALIAS, k) ? ALIAS[k] : map ? 'm-script' : 'backend'; };
1778
1779
  const SIGIL = {
1779
1780
  frontend: '<rect x="2" y="3" width="12" height="8.5" rx="1.2"/><path d="M5.5 14h5M8 11.5V14"/>',
@@ -1800,25 +1801,17 @@ const SIGIL = {
1800
1801
  terminal: '<rect x="1.8" y="2.6" width="12.4" height="10.8" rx="1.6"/><path d="m4.6 6.2 2 1.8-2 1.8M8.4 10.2h3"/>',
1801
1802
  folder: '<path d="M2 4.3c0-.7.5-1.2 1.2-1.2h3l1.4 1.6h5.2c.7 0 1.2.5 1.2 1.2v6.2c0 .7-.5 1.2-1.2 1.2H3.2C2.5 13.3 2 12.8 2 12.1Z"/>',
1802
1803
  node: '<path d="M2.4 5.6 5 3h8.6v7.4L11 13H2.4Z"/><path d="M2.4 5.6H11V13M11 5.6 13.6 3"/>',
1803
- // UML's component icon (§11.6.4): a box's mark in the uml notation
1804
- component: '<path d="M4.5 4.5V2.3a.8.8 0 0 1 .8-.8h7.9a.8.8 0 0 1 .8.8v11.4a.8.8 0 0 1-.8.8H5.3a.8.8 0 0 1-.8-.8v-2.2M4.5 7v2"/><rect x="2" y="4.5" width="5" height="2.5" rx=".6"/><rect x="2" y="9" width="5" height="2.5" rx=".6"/>',
1805
- };
1806
- /* ================= card shapes (0.15.0) =================
1807
- A notation draws each card as a shape: box (the default: every card a rounded box, as before), c4 (people, browser apps,
1808
- phones, databases, queues and files as the C4 model's tools draw them) or uml (UML 2.5's actor, node, artifact, package
1809
- and data store, and the component icon as a box's mark). A card's own `shape` wins in any notation. Each shape is drawn
1810
- inside its card's own box, so layout, routing, widths and the layout check never see it: an outline (for the mask, the
1811
- body, the stack, rings, the minimap and the glow), a face (the rows its mark and text use), details drawn over the body
1812
- (one of them its accent, which lights as something arrives) and inset(side, t), how far inside the box's side the
1813
- outline runs at t, which slides a line's end from the box onto the outline. */
1814
- const NOTATIONS = ['box', 'c4', 'uml'];
1815
- // what a notation draws: by the kind word the model wrote, then by the kind it means (docs/module-api.md, 0.15.0)
1816
- const NOTED = {
1817
- c4: { person: 'person', user: 'person', human: 'person', mobile: 'mobile', agent: 'robot', cli: 'terminal',
1818
- frontend: 'browser', database: 'cylinder', queue: 'pipe', file: 'document', 'm-hook': 'hexagon' },
1819
- uml: { person: 'person', user: 'person', human: 'person', agent: 'robot', module: 'folder', package: 'folder',
1820
- cloud: 'node', database: 'cylinder', file: 'document', 'm-hook': 'hexagon' },
1821
1804
  };
1805
+ /* ================= card shapes (0.15.0; one style since 0.21.0) =================
1806
+ Maps draw each card as a shape (the union of what the C4 model's tools and UML 2.5 draw): people, browser apps, phones,
1807
+ terminals, queues, packages, hosts, robots, data stores, files and hooks, and a rounded box where no shape says more. A
1808
+ card's own `shape` wins. Each shape is drawn inside its card's own box, so layout, routing, widths and the layout check
1809
+ never see it: an outline (for the mask, the body, the stack, rings, the minimap and the glow), a face (the rows its mark
1810
+ and text use), details drawn over the body (one of them its accent, which lights as something arrives) and
1811
+ inset(side, t), how far inside the box's side the outline runs at t, which slides a line's end from the box onto the outline. */
1812
+ // the shape a card is drawn as: by the kind word the model wrote, then by the kind it means (docs/module-api.md)
1813
+ const NOTED = { person: 'person', user: 'person', human: 'person', agent: 'robot', mobile: 'mobile', cli: 'terminal', frontend: 'browser',
1814
+ queue: 'pipe', module: 'folder', package: 'folder', cloud: 'node', database: 'cylinder', file: 'document', 'm-hook': 'hexagon' };
1822
1815
  /* The map: today's notation, and the default diagram type. A view of a version-1 model, or a version-2 view with no
1823
1816
  `diagram`, is a map and takes exactly today's code path. */
1824
1817
  const MAP = registerDiagramType('map', { title: 'architecture map' });
@@ -2175,16 +2168,9 @@ function mapContext(model, opts = {}) {
2175
2168
  return pos;
2176
2169
  }
2177
2170
 
2178
- function buildView(vid, opts = {}) {
2179
- const g = buildGeo(vid);
2180
- return { g, ...drawView(vid, g, opts) };
2181
- }
2182
- // a view's notation (0.15.0): its own `shapes`, else the model's, else box; drawView: the picture of a geometry in one,
2183
- // with no layout (a change of notation draws the cards again and nothing else)
2184
- const notationOf = vid => noteOf((V[vid] || {}).shapes) || noteOf(D.shapes) || 'box';
2185
- function drawView(vid, g, opts = {}) {
2186
- const v = V[vid], shapes = noteOf(opts.shapes) || notationOf(vid), T = ownLayout(vid), L = T && T.draw && T.draw(g, { esc, r2, textW }); // its two layers only
2187
- return { markup: svgOf(g, { title: v.title, subject: subjectOf(v), icons: D.icons, broken: brokenIn(vid), ...opts, shapes, ...(L && { under: L.under, over: L.over }) }), shapes };
2171
+ function buildView(vid, opts = {}) { // a view's geometry and its picture
2172
+ const g = buildGeo(vid), v = V[vid], T = ownLayout(vid), L = T && T.draw && T.draw(g, { esc, r2, textW }); // its two layers only
2173
+ return { g, markup: svgOf(g, { title: v.title, subject: subjectOf(v), icons: D.icons, broken: brokenIn(vid), ...opts, ...(L && { under: L.under, over: L.over }) }) };
2188
2174
  }
2189
2175
  // the rules a view's lines break (#70), by model edge id: svgOf draws such a line rose, with a mark that names them
2190
2176
  let ruled = null;
@@ -2351,7 +2337,7 @@ function mapContext(model, opts = {}) {
2351
2337
  const viewKey = vid => {
2352
2338
  const v = V[vid], ids = (v.nodeIds || []).filter(i => N[i]), ns = ids.map(i => nodeIn(i, vid));
2353
2339
  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]);
2354
- return JSON.stringify([{ ...v, shapes: undefined }, ns, viewEdges(v), ids.map(i => [kidCount(i, vid), opensOf(i, vid)]), icons, who, boxesOf(vid)]); // a view's notation lays nothing out; a type's boxes do
2340
+ return JSON.stringify([v, ns, viewEdges(v), ids.map(i => [kidCount(i, vid), opensOf(i, vid)]), icons, who, boxesOf(vid)]); // a type's boxes lay it out
2355
2341
  };
2356
2342
  /* ---------- unfolding in place (#71): a stacked card opens into a box on the view it is on ----------
2357
2343
  The card becomes a box with its id and name, holding its inner view's own cards: those not on this view already (the
@@ -2362,11 +2348,11 @@ function mapContext(model, opts = {}) {
2362
2348
  the cards under it in its column move down by its height, so the rest of the picture keeps its places (a hub view, which
2363
2349
  has no columns, is laid out again). The inner view's boxes that hold its own cards come along inside, and a numbered
2364
2350
  (sequence) view's run order: its numbers, its loops back and its lanes (#82). Not in a numbered view: its own steps
2365
- would be numbered again; that card opens. */
2351
+ would be numbered again; that card opens. Nor into another kind of diagram: that card opens too. */
2366
2352
  // the inner view's own cards: those this view shows neither as cards nor as boxes (a card unfolded here already is one, #98)
2367
2353
  const ownOf = (v, C, nid) => (C.nodeIds || []).filter(i => N[i] && i !== nid && !(v.nodeIds || []).includes(i) && !(v.containers || []).some(b => b && b.id === i));
2368
2354
  const unfoldable = (nid, vid) => { const v = V[vid], ids = (v && v.nodeIds) || [], c = ids.includes(nid) && N[nid] && v.render !== 'sequence' ? childOf(nid, vid) : null;
2369
- if (!c || c === vid || chain(vid).includes(c) || (v.containers || []).some(b => b && b.id === nid)) return null;
2355
+ if (!c || c === vid || chain(vid).includes(c) || (v.containers || []).some(b => b && b.id === nid) || V[c].diagram != v.diagram) return null;
2370
2356
  return ownOf(v, V[c], nid).length ? c : null; };
2371
2357
  function unfold(vid, nid) {
2372
2358
  const v = V[vid], c = unfoldable(nid, vid); if (!c) return false;
@@ -2473,7 +2459,7 @@ function mapContext(model, opts = {}) {
2473
2459
  for (let more = true; more && todo.length;) { more = false; todo = todo.filter(nid => { if (!unfold(vid, nid)) return true; (unfolded[vid] ||= []).push(nid); return !(more = true); }); } }))();
2474
2460
  const geoCache = {};
2475
2461
  return { D, views, nodes, N, V, rootId, nodeIn, childOf, opensOf, subjectOf, viewsWith, kidsOf, chain, viewEdges, isState, planOf: withM(planOf), planCache,
2476
- layoutCache, geoCache, memos, buildGeo: withM(buildGeo), buildView: withM(buildView), drawView: withM(drawView), notationOf, optimize: withM(optimize), flowPos, kidCount, viewKey, brokenIn, unfoldable, unfolded };
2462
+ layoutCache, geoCache, memos, buildGeo: withM(buildGeo), buildView: withM(buildView), optimize: withM(optimize), flowPos, kidCount, viewKey, brokenIn, unfoldable, unfolded };
2477
2463
  }
2478
2464
  const edgeRefIds = e => [...new Set((e.all || [e]).map(x => x._id).filter(Boolean))];
2479
2465
 
@@ -2519,20 +2505,21 @@ export function validate(model) {
2519
2505
  if (n.owner != null && typeof n.owner !== 'string') warn(at + '.owner', 'an owner is a name: ignored');
2520
2506
  shapeIs(n, at);
2521
2507
  };
2522
- const shapeIs = (n, at) => { if (n && n.shape != null && !SHAPE_NAMES.includes(String(n.shape).toLowerCase())) warn(at + '.shape', `unknown shape "${n.shape}": the notation draws it by its kind (the shapes: ${SHAPE_NAMES.join(', ')})`); };
2523
- const notation = (x, at) => { if (x != null && !noteOf(x)) warn(at, `"${x}" is not a notation (${NOTATIONS.join(', ')}): drawn as box`); }; // card shapes (0.15.0)
2524
- notation(m.shapes, 'shapes');
2508
+ const shapeIs = (n, at) => { if (n && n.shape != null && !SHAPE_NAMES.includes(String(n.shape).toLowerCase())) warn(at + '.shape', `unknown shape "${n.shape}": drawn by its kind (the shapes: ${SHAPE_NAMES.join(', ')})`); };
2509
+ const gone = (x, at) => { if (x && x.shapes !== undefined) warn(at + 'shapes', 'shapes is no longer read: maps draw in one style'); }; // the box/c4/uml choice, removed in 0.21.0
2510
+ gone(m, '');
2525
2511
  arr(m.nodes).forEach((n, i) => card(n, `nodes[${i}]`));
2526
2512
  views.forEach((v, i) => arr(v.nodes).forEach((n, j) => card(n, `views[${i}].nodes[${j}]`)));
2527
2513
  views.forEach((v, i) => { if (typeof v.id !== 'string' || !v.id) err(`views[${i}].id`, 'a view needs an id'); else if (vids.has(v.id)) err(`views[${i}].id`, `view id "${v.id}" is used twice`); else vids.add(v.id); });
2528
2514
  if (m.root != null && !vids.has(m.root)) err('root', `root "${m.root}" is not a view`);
2529
2515
  const later = (x, at) => { if (x && x.opensView && !vids.has(x.opensView)) warn(at + '.opensView', `opens "${x.opensView}", a view the model does not have: fine if the host loads it when it is opened`); };
2530
2516
  arr(m.nodes).forEach((n, i) => later(n, `nodes[${i}]`));
2531
- views.forEach((v, i) => notation(v.shapes, `views[${i}].shapes`));
2532
- // c4 draws a frontend card as a browser: a person's icon on one says the model means a person (0.15.0)
2533
- const inC4 = new Set(views.filter(v => (noteOf(v.shapes) || noteOf(m.shapes)) === 'c4').flatMap(v => arr(v.nodeIds)));
2534
- arr(m.nodes).forEach((n, i) => { if (!n || !inC4.has(n.id) || n.shape != null || !['user', 'users', 'usercheck'].includes(n.icon)) return;
2535
- if (shapeOf({ ...n, k: kindOf(n.kind, true) }, 'c4') === 'browser') warn(`nodes[${i}].kind`, `"${n.label || n.id}" has a person's icon, but c4 draws a ${n.kind} card as a browser: kind "person" (or shape "person") draws a person`); });
2517
+ views.forEach((v, i) => gone(v, `views[${i}].`));
2518
+ // a frontend card is drawn as a browser: a person's icon on one says the model means a person (0.15.0); not said of a card
2519
+ // only typed views hold, which keep their own notation (0.21.0)
2520
+ const typedOnly = id => { const vs = views.filter(v => arr(v.nodeIds).includes(id)); return vs.length && vs.every(v => typedView(v) && diagramOf(v)); };
2521
+ arr(m.nodes).forEach((n, i) => { if (!n || n.shape != null || !['user', 'users', 'usercheck'].includes(n.icon) || typedOnly(n.id)) return;
2522
+ if (shapeOf({ ...n, k: kindOf(n.kind, true) }) === 'browser') warn(`nodes[${i}].kind`, `"${n.label || n.id}" has a person's icon, but a ${n.kind} card is drawn as a browser: kind "person" (or shape "person") draws a person`); });
2536
2523
  const line = (e, at, ends) => {
2537
2524
  if (!e || typeof e !== 'object') return err(at, 'a line is an object with from and to');
2538
2525
  ['from', 'to'].forEach(k => { if (!ends.has(e[k])) err(`${at}.${k}`, `"${e[k]}" is neither a card nor a box`); });
@@ -3823,7 +3810,7 @@ export function render(container, model, options = {}) {
3823
3810
  event says which of its keys changed, once a frame at most (on a 16 ms timer, so a page in a hidden tab still says it).
3824
3811
  The camera is the exception: a pan, zoom, glide or drag is one state event, once it comes to rest; until then the
3825
3812
  event's camera is where it last rested (handle.state reads it live) */
3826
- const KEYS = ['view', 'navigating', 'selection', 'expanded', 'lens', 'overlays', 'story', 'tour', 'replay', 'paused', 'camera', 'flow', 'shapes', 'motion', 'gesture', 'destroyed'];
3813
+ const KEYS = ['view', 'navigating', 'selection', 'expanded', 'lens', 'overlays', 'story', 'tour', 'replay', 'paused', 'camera', 'flow', 'motion', 'gesture', 'destroyed'];
3827
3814
  let lastState = null, stateT = 0, camRest = null, restT = 0; // the last state event's snapshot and the flush to come; the camera at rest, and its check to come
3828
3815
  const listensState = () => !!((options.on || {}).state || (subs.state || []).length);
3829
3816
  function touch() { if (!stateT && !dead && listensState()) stateT = setTimeout(flushState, 16); }
@@ -3834,18 +3821,10 @@ export function render(container, model, options = {}) {
3834
3821
  function camMoved() { clearTimeout(restT); restT = !dead && listensState() ? setTimeout(rested, 150) : 0; }
3835
3822
  function rested() { restT = 0; if (dead) return; if (cam && (cam.anim || cam.ptrs.size) || nav) return camMoved(); camRest = camNow(); touch(); }
3836
3823
  let cam = null, geo = null, svg = null, cur = null, sel = null, more = [], busy = false, paused = false, dead = false;
3837
- // the notation (0.15.0): the host's (render()'s shapes, then handle.shapes) for every view, else each view's own
3838
- if (options.shapes != null && !noteOf(options.shapes)) warn(`render(…, { shapes: ${JSON.stringify(options.shapes)} }): the notations are ${NOTATIONS.join(', ')}; each view draws its own`);
3839
- let shapesSet = noteOf(options.shapes);
3840
- const noteFor = vid => shapesSet || ctx.notationOf(vid);
3841
- const pic = vid => { // a view's picture in its notation: laid out once, drawn again only when the notation changes
3842
- const want = noteFor(vid), o = { tour: P.tour !== false, openHint: P.openHint !== false, shapes: want };
3843
- let c = ctx.geoCache[vid]; if (!c) c = ctx.geoCache[vid] = ctx.buildView(vid, o); else if (c.shapes !== want) Object.assign(c, ctx.drawView(vid, c.g, o));
3844
- return c; };
3845
- // each card's look in the notation shown, for what is drawn over the picture: rings, the minimap, a tour's or replay's dot
3846
- // (in box too: a card's own shape is drawn there)
3847
- const lookIn = (nt, g) => { const m = new Map(), ports = sidePorts(g); return n => { if (!m.has(n)) m.set(n, cardLook(n, nt, !!(n.kids || n.opens) && P.openHint !== false, ports.get(n))); return m.get(n); }; };
3848
- const looker = () => lookIn(noteFor(cur), geo);
3824
+ const pic = vid => ctx.geoCache[vid] ||= ctx.buildView(vid, { tour: P.tour !== false, openHint: P.openHint !== false }); // a view's picture: laid out once
3825
+ // each card's look, for what is drawn over the picture: rings, the minimap, a tour's or replay's dot
3826
+ const lookIn = g => { const m = new Map(), ports = sidePorts(g); return n => { if (!m.has(n)) m.set(n, g.map ? cardLook(n, true, !!(n.kids || n.opens) && P.openHint !== false, ports.get(n)) : cardLook(n, false, false, null)); return m.get(n); }; };
3827
+ const looker = () => lookIn(geo);
3849
3828
  const timers = new Set(), later = (f, ms) => { const t = setTimeout(() => { timers.delete(t); if (!dead) f(); }, ms); timers.add(t); return t; };
3850
3829
  const sleeping = new Set(); // a sleep resolves on its own, or when the map is destroyed: nothing that awaits one is left pending
3851
3830
  const sleep = ms => new Promise(r => { if (dead) return r(); const wake = () => { sleeping.delete(wake); r(); }; sleeping.add(wake); later(wake, still() ? 0 : ms); });
@@ -3949,9 +3928,9 @@ export function render(container, model, options = {}) {
3949
3928
  /* minimap */
3950
3929
  const mini = {};
3951
3930
  function buildMini() {
3952
- const b = geo.bounds, el = q('.minimap'), bw = Math.max(2, b.w / 300), c4 = noteFor(cur) === 'c4';
3953
- // a box's tone, as its region; and a box dashed in the picture (any box in c4, a toned box in any notation) dashed here
3954
- const rg = (geo.families || []).filter(f => f.tone || c4).map(f => `<rect x="${f.x}" y="${f.y}" width="${f.w}" height="${f.h}" rx="14" ${f.tone ? `style="--um-tone:${toneOf(f)}" ` : ''}fill="${f.tone ? 'var(--um-tone, var(--um-slate))' : 'none'}" fill-opacity=".14" stroke="${f.tone ? 'var(--um-tone, var(--um-slate))' : 'var(--um-border-2)'}" stroke-width="${bw}" stroke-dasharray="${bw * 4} ${bw * 3}"/>`).join('');
3931
+ const b = geo.bounds, el = q('.minimap'), bw = Math.max(2, b.w / 300);
3932
+ // a box's tone, as its region, dashed as in the picture
3933
+ const rg = (geo.families || []).filter(f => f.tone).map(f => `<rect x="${f.x}" y="${f.y}" width="${f.w}" height="${f.h}" rx="14" style="--um-tone:${toneOf(f)}" fill="var(--um-tone, var(--um-slate))" fill-opacity=".14" stroke="var(--um-tone, var(--um-slate))" stroke-width="${bw}" stroke-dasharray="${bw * 4} ${bw * 3}"/>`).join('');
3955
3934
  const ln = (geo.lanes || []).map(l => `<rect x="${l.x}" y="${l.y}" width="${l.w}" height="${l.h}" rx="14" fill="none" stroke="var(--um-border-2)" stroke-width="${Math.max(2, b.w / 300)}"/>`).join('');
3956
3935
  const look = looker(), r = geo.nodes.map(n => { const L = look(n), paint = `fill="var(--um-${KIND[n.k][0]})" opacity="${n.k === 'm-public' ? .95 : .55}"`;
3957
3936
  return L ? `<path d="${L.S.out(L)}" ${paint}/>` : `<rect x="${n.x}" y="${n.y}" width="${n.w}" height="${n.h}" rx="8" ${paint}/>`; }).join('');
@@ -4169,21 +4148,6 @@ export function render(container, model, options = {}) {
4169
4148
  return true;
4170
4149
  }
4171
4150
  function flash(nid) { const g = svg.querySelector(`.node[data-id="${CSS.escape(nid)}"]`); if (!g) return; g.classList.add('flash'); later(() => g.classList.remove('flash'), 1400); }
4172
- /* the notation every view is drawn in (0.15.0): the host's, a reader's (the page's toggle and link), or null for each
4173
- view's own. The view shown is drawn again from the geometry it has, with its camera, selection, lens and marks, and
4174
- nothing is laid out. False for a notation that isn't one (with a warning). */
4175
- function setShapes(x) {
4176
- if (dead) return false;
4177
- const want = x == null ? null : noteOf(x);
4178
- if (x != null && !want) { warn(`shapes = ${JSON.stringify(x)}: the notations are ${NOTATIONS.join(', ')}`); return false; }
4179
- shapesSet = want;
4180
- if (cur && ctx.geoCache[cur] && ctx.geoCache[cur].shapes !== noteFor(cur)) {
4181
- const keep = sel, keepMore = more, touched = cam.touched;
4182
- mount(cur); cam.set({}); cam.touched = touched;
4183
- if (keep && exists(keep)) { sel = keep; more = keepMore.filter(exists); paint(); }
4184
- emitSel(); }
4185
- touch(); return true;
4186
- }
4187
4151
  function setFlow(on, save = true) { // save: the page keeps it as the reader's choice
4188
4152
  stage.classList.toggle('flowing', on);
4189
4153
  const b = q('[data-z="flow"]'); if (b) { b.classList.toggle('on', on); b.setAttribute('aria-pressed', on); }
@@ -4592,7 +4556,7 @@ export function render(container, model, options = {}) {
4592
4556
  return; }
4593
4557
  if (!ctx.V[cur]) { // the view shown is gone: the root, or the empty card of a model with no views
4594
4558
  const from = cur; mount(ctx.rootId ?? null); cam.set(startView()); cam.touched = false; emitSel(); if (cur) emit('viewchange', { view: cur, from }); return; }
4595
- if (ctx.geoCache[cur] && keys[cur] === ctx.viewKey(cur) && ctx.geoCache[cur].shapes === noteFor(cur)) { if (lensId != null) { relens(); drawMarks(); } if (pc) pc.chrome(); if (P.onMount) P.onMount(inner); return; } // this view's picture did not change (its lens or the views may have; the page reads the new model, issue 133)
4559
+ if (ctx.geoCache[cur] && keys[cur] === ctx.viewKey(cur)) { if (lensId != null) { relens(); drawMarks(); } if (pc) pc.chrome(); if (P.onMount) P.onMount(inner); return; } // this view's picture did not change (its lens or the views may have; the page reads the new model, issue 133)
4596
4560
  const keep = sel, keepMore = more, touched = cam.touched, prev = [geo, svg];
4597
4561
  mount(cur);
4598
4562
  if (touched) cam.set({}); else cam.set(startView());
@@ -4658,7 +4622,7 @@ export function render(container, model, options = {}) {
4658
4622
  const tone = t => 'var(--um-' + String(t || 'accent').replace(/[^\w-]/g, '') + ')', op = m => m.level < 1 ? ` opacity="${m.level}"` : '';
4659
4623
  const cards = Object.fromEntries([...s.querySelectorAll('.node')].map(x => [x.dataset.id, x]));
4660
4624
  let under = '', over = '';
4661
- const look = lookIn(noteFor(vid), g);
4625
+ const look = lookIn(g);
4662
4626
  g.nodes.forEach(n => { const m = markOf2(L => (L.nodes || {})[n.id]), el = cards[n.id]; if (!m || !el) return;
4663
4627
  if (m.level < 1) el.setAttribute('opacity', m.level);
4664
4628
  const sh = (m.tone || m.pulse) && look(n); // a shape's ring: its outline, 5 px out (0.15.0)
@@ -4969,7 +4933,7 @@ export function render(container, model, options = {}) {
4969
4933
 
4970
4934
  const inner = { ctx: () => ctx, geo: () => geo, cam: () => cam, svg: () => svg, stage, host, go, open, up, flash, setFlow, pause, key, startView, standalone, play,
4971
4935
  select, busy: () => busy, flowing: () => stage.classList.contains('flowing'), view: () => cur, unfold: gesture, lens: setLens,
4972
- highlight, overlay, focus: focusMap, editable: !!E, shapes: () => shapesNow(), setShapes, override: () => shapesSet }; // a story's pointer is the pieces' own, from the story event (0.14.0)
4936
+ highlight, overlay, focus: focusMap, editable: !!E }; // a story's pointer is the pieces' own, from the story event (0.14.0)
4973
4937
 
4974
4938
  // the pieces a host asked for (#99): they follow the map's selection and views, and take their keys wherever the focus is
4975
4939
  // in the map (Enter and Space press their buttons; the map's other keys reach the canvas, as in the page)
@@ -4981,13 +4945,12 @@ export function render(container, model, options = {}) {
4981
4945
  if (options.nav) { q('.um-navbtn').addEventListener('click', () => pc.setTree(!frame.classList.contains('tree-open'))); pc.setTree(ctx.views.length > 1); }
4982
4946
  }
4983
4947
  const motionOf = () => instant ? 'instant' : reducedMotion() ? 'reduced' : 'full';
4984
- const shapesNow = () => cur ? noteFor(cur) : shapesSet || noteOf(ctx.D.shapes) || 'box';
4985
4948
  const camNow = () => { const r = (x, d) => Math.round(x * d) / d; return cam ? { x: r(cam.s.cx, 10), y: r(cam.s.cy, 10), zoom: r(cam.s.k, 1000) } : { x: 0, y: 0, zoom: 1 }; };
4986
4949
  function snapshot(event = false) { // MapState: built fresh on every read, frozen all the way down, plain JSON; event: the camera at rest
4987
4950
  const deep = o => { Object.values(o).forEach(v => v && typeof v === 'object' && deep(v)); return Object.freeze(o); }, st = story, t = trip;
4988
4951
  return deep({ view: cur, navigating: nav ? nav.to : null, selection: picked(), expanded: [...(expanded[cur] || [])], lens: lensId, overlays: [...layers.keys()],
4989
4952
  story: st ? { id: st.s.id, step: st.at, steps: st.steps.length, playing: !!st.auto } : null, tour: !!(t && t.kind === 'tour'), replay: t && t.kind === 'replay' ? { playing: !t.paused } : null, paused: !!paused,
4990
- camera: event && camRest ? { ...camRest } : camNow(), flow: stage.classList.contains('flowing'), shapes: shapesNow(), motion: motionOf(), gesture: gestureNow(), destroyed: dead });
4953
+ camera: event && camRest ? { ...camRest } : camNow(), flow: stage.classList.contains('flowing'), motion: motionOf(), gesture: gestureNow(), destroyed: dead });
4991
4954
  }
4992
4955
  const first = () => {
4993
4956
  if (dead) return;
@@ -5026,8 +4989,6 @@ export function render(container, model, options = {}) {
5026
4989
  get destroyed() { return dead; },
5027
4990
  get flow() { return stage.classList.contains('flowing'); }, // the animated flow along lines (≋, F)
5028
4991
  set flow(on) { if (!dead) setFlow(!!on, false); }, // a host's choice, not the reader's: the page saves only ≋ and F
5029
- get shapes() { return shapesNow(); }, // the notation the view shown is drawn in: 'box', 'c4' or 'uml' (0.15.0)
5030
- set shapes(x) { setShapes(x); }, // every view in this one (null: each view's own); the cards are drawn again, nothing is laid out
5031
4992
  get motion() { return motionOf(); }, // 'instant' (the page's #check, #still, #shot), 'reduced' (the reader's setting), else 'full'
5032
4993
  get story() { return story ? story.ctl : null; }, // the player of the story shown (play()'s, the page menu's or a link's), else null
5033
4994
  overlay: (name, layer) => overlay(name, layer),
@@ -5077,26 +5038,22 @@ function detailBullets(body) {
5077
5038
  const SHAPE_SAYS = { person: ['Person', 'someone who uses it'], browser: ['Browser app', 'runs in a web browser'], mobile: ['Mobile app', 'runs on a phone'],
5078
5039
  cylinder: ['Data store', 'a database or a store'], pipe: ['Queue', 'hands work on, in order'], document: ['File', 'a document or a config'],
5079
5040
  hexagon: ['Hexagon', 'a guard or a service'], robot: ['Agent', 'an AI agent'], terminal: ['Command line', 'a CLI or a script'],
5080
- window: ['Desktop app', 'runs in a window'], folder: ['Package', 'a module or a folder of code'], node: ['Host', 'a machine or runtime it runs on'],
5081
- component: ['Component', 'a part that runs (UML)'] };
5041
+ window: ['Desktop app', 'runs in a window'], folder: ['Package', 'a module or a folder of code'], node: ['Host', 'a machine or runtime it runs on'] };
5082
5042
  const swatchOf = (sh, k, c, f, dashed) => { const n = { x: 0, y: 0, w: 100, h: 64 }, S = SHAPE[sh]; // a legend's key: the card's outline, small
5083
5043
  return `<svg class="lgs" viewBox="-4 -4 108 72" aria-hidden="true"><path d="${S.out(n)}" fill="${f}" stroke="${c}"${dashed ? ' stroke-dasharray="3 2"' : ''}/>${S.det ? S.det(n, c, k) : ''}</svg>`; };
5084
- function legendItems(gs, opens = 'double-click to open', notes = [], icons = {}) { // what these pictures draw, each with what it means: the view's legend, and the PDF's (#93). notes: each picture's notation (0.15.0)
5044
+ function legendItems(gs, opens = 'double-click to open') { // what these pictures draw, each with what it means: the view's legend, and the PDF's (#93)
5085
5045
  const typedKeys = [...new Set(gs.flatMap(g => g.legend || []))]; // a typed view's type writes its own keys (1.9); its cards and lines are not a map's
5086
5046
  const ns = gs.flatMap(g => g.legend ? [] : g.nodes), es = gs.flatMap(g => g.legend ? [] : g.edges), used = [...new Set(ns.map(n => n.k))];
5087
- // each card as its picture's notation draws it: its shape, its dash and its mark (the legend draws what the map draws)
5088
- const cards = gs.flatMap((g, i) => { if (g.legend) return []; const nt = g.map ? noteOf(notes[i]) || 'box' : 'box';
5089
- return g.nodes.map(n => { const sh = shapeOf(n, nt), I = (icons || {})[n.icon]; // an icon the table lacks leaves the card the component mark, as svgOf draws it
5090
- return { k: n.k, sh, dash: nt !== 'box' && n.k === 'external', comp: nt === 'uml' && sh === 'box' && !(I && (I.src || I.g)) && !String(n.k).startsWith('m-') }; }); });
5047
+ // each card as its picture draws it: its shape and its dash (the legend draws what the map draws)
5048
+ const cards = gs.flatMap(g => g.legend ? [] : g.nodes.map(n => ({ k: n.k, sh: shapeOf(n, !!g.map), dash: !!g.map && n.k === 'external' })));
5091
5049
  const kindShape = k => { const s = [...new Set(cards.filter(d => d.k === k).map(d => d.sh))]; return s.length === 1 && s[0] !== 'box' && !(k === 'm-hook' && s[0] === 'hexagon') ? s[0] : null; };
5092
5050
  const dashOf = k => k === 'm-ext' || cards.some(d => d.k === k && d.dash); // an external card is dashed only where it is drawn so
5093
5051
  const order = ['m-public', 'm-step', 'm-lane', 'm-ext', 'm-verb', 'm-board', 'm-hook', 'm-script', 'frontend', 'backend', 'ai', 'queue', 'database', 'security', 'cloud', 'external', 'file'];
5094
5052
  const shape = k => k === 'm-verb' ? 'p' : dashOf(k) ? 'd' : k === 'm-hook' ? 'h' : '';
5095
5053
  const kinds = order.filter(k => used.includes(k)), own = new Set(kinds.map(kindShape).filter(Boolean)), tone = k => `var(--um-${KIND[k][0]})`;
5096
- // a shape the kinds' keys don't show already: in a neutral outline, as colour is the kinds' (and UML's component mark)
5054
+ // a shape the kinds' keys don't show already: in a neutral outline, as colour is the kinds'
5097
5055
  const shapes = SHAPE_NAMES.filter(sh => sh !== 'box' && !own.has(sh) && cards.some(d => d.sh === sh && !(sh === 'hexagon' && d.k === 'm-hook')))
5098
- .map(sh => `<span class="lgi" title="${esc(SHAPE_SAYS[sh].join(' — '))}">${swatchOf(sh, 'file', 'var(--um-muted)', 'var(--um-panel)')}<b>${SHAPE_SAYS[sh][0]}</b><span>${SHAPE_SAYS[sh][1]}</span></span>`)
5099
- .concat(cards.some(d => d.comp) ? [`<span class="lgi" title="${esc(SHAPE_SAYS.component.join(' — '))}"><svg class="lgs" viewBox="0 0 16 16" aria-hidden="true"><g fill="none" stroke="var(--um-muted)" stroke-linecap="round" stroke-linejoin="round">${SIGIL.component}</g></svg><b>${SHAPE_SAYS.component[0]}</b><span>${SHAPE_SAYS.component[1]}</span></span>`] : []);
5056
+ .map(sh => `<span class="lgi" title="${esc(SHAPE_SAYS[sh].join(' — '))}">${swatchOf(sh, 'file', 'var(--um-muted)', 'var(--um-panel)')}<b>${SHAPE_SAYS[sh][0]}</b><span>${SHAPE_SAYS[sh][1]}</span></span>`);
5100
5057
  return kinds.map(k => `<span class="lgi" title="${esc(KIND[k][1] + ' — ' + KIND[k][2])}">${kindShape(k) ? swatchOf(kindShape(k), k, tone(k), `var(--um-${KIND[k][0]}-f)`, dashOf(k)) : `<i class="${shape(k)}" style="border-color:${tone(k)};background:var(--um-${KIND[k][0]}-f)${k === 'm-hook' ? `;background:${tone(k)}` : ''}"></i>`}<b>${KIND[k][1]}</b><span>${KIND[k][2]}</span></span>`)
5101
5058
  .concat(shapes)
5102
5059
  .concat(ns.some(n => n.kids || n.opens) ? [`<span class="lgi"><i class="s"></i><b>⤢ Stacked</b><span>${opens}</span></span>`] : [])
@@ -5115,7 +5072,7 @@ function pieces(X, el, o) {
5115
5072
 
5116
5073
  /* the legend: the kinds of cards and lines the view draws, in a box that folds away */
5117
5074
  function legend() {
5118
- const items = legendItems([X().geo()], undefined, [X().shapes ? X().shapes() : 'box'], X().ctx().D.icons), back = el.legend.contains(el.legend.getRootNode().activeElement);
5075
+ const items = legendItems([X().geo()]), back = el.legend.contains(el.legend.getRootNode().activeElement);
5119
5076
  el.legend.hidden = !items.length;
5120
5077
  el.legend.classList.toggle('folded', folded);
5121
5078
  el.legend.innerHTML = `<div class="t"><span>LEGEND</span><button type="button" id="m-lfold" aria-label="${folded ? 'Show' : 'Hide'} legend" aria-expanded="${!folded}">${folded ? '+' : '–'}</button></div>${items.join('')}`;
@@ -5379,7 +5336,7 @@ export function mountPage(container, model, options = {}) {
5379
5336
  <span class="mbrand"><i></i><span>${esc(D.title || 'Map')}</span></span>
5380
5337
  <nav class="crumbs" id="m-crumbs" aria-label="Breadcrumb"></nav>
5381
5338
  <button class="findbtn" id="m-find" type="button" aria-label="Search nodes and views (${modK})"><span aria-hidden="true">⌕</span><span class="ft">Find a node or view…</span><span class="kbd">${modK}</span></button>
5382
- <button class="ibtn" id="m-theme" title="Light / dark" aria-label="Dark theme" aria-pressed="false">◐</button><button class="ibtn txt" id="m-shapes" type="button" title="Card shapes: box, c4 or uml">⬡<span class="lbl"></span></button>${(D.stories || []).length ? `
5339
+ <button class="ibtn" id="m-theme" title="Light / dark" aria-label="Dark theme" aria-pressed="false">◐</button>${(D.stories || []).length ? `
5383
5340
  <div class="menu"><button class="ibtn txt" id="m-stories" aria-haspopup="true" aria-expanded="false" title="Stories: walk-throughs of this map">▶<span class="lbl"> Stories</span></button>
5384
5341
  <div class="menu-pop" id="m-stories-pop" hidden>${D.stories.map(s => `<button data-story="${esc(s && s.id)}">${esc((s && (s.title || s.id)) || '')}</button>`).join('')}</div></div>` : ''}${arrOf(D.lenses).some(l => l && l.id) ? `
5385
5342
  <div class="menu"><button class="ibtn txt" id="m-lenses" aria-haspopup="true" aria-expanded="false" title="Lenses: ways to look at this map">◎<span class="lbl"> Lenses</span></button>
@@ -5444,7 +5401,6 @@ export function mountPage(container, model, options = {}) {
5444
5401
  const p = new URLSearchParams(); p.set('view', cur); if (sel) p.set('node', sel);
5445
5402
  if (told) { p.set('story', told.id); p.set('step', told.step + 1); } // the story shown, at its step (#69)
5446
5403
  if (lensOn) p.set('lens', lensOn); // the lens on the map (#94)
5447
- if (X.override()) p.set('shapes', X.override()); // the notation a reader or the link picked (0.15.0)
5448
5404
  if (Q.get('theme')) p.set('theme', document.documentElement.dataset.theme || Q.get('theme'));
5449
5405
  if (Q.get('reader')) p.set('reader', Q.get('reader')); // check, shot and still describe one run: never in the link a reader shares (issue 136)
5450
5406
  const h = '#' + p.toString();
@@ -5457,8 +5413,6 @@ export function mountPage(container, model, options = {}) {
5457
5413
  const idle = () => new Promise(r => { const t = () => X.busy() ? setTimeout(t, 30) : r(); t(); }); // once no move is under way
5458
5414
  async function follow(load = false) {
5459
5415
  const p = new URLSearchParams(location.hash.slice(1)), my = ++follows, lacks = [], stories = arrOf(D.stories);
5460
- if (noteOf(p.get('shapes')) && noteOf(p.get('shapes')) !== handle.shapes) handle.shapes = p.get('shapes'); // the link's card shapes (0.15.0)
5461
- else if (p.get('shapes') && !noteOf(p.get('shapes'))) lacks.push(`no card shapes “${p.get('shapes')}”`);
5462
5416
  const lens = p.get('lens') || null, sid = p.get('story'), at = Math.max(1, Math.floor(+p.get('step')) || 1) - 1;
5463
5417
  const def = sid ? stories.find(s => s && s.id === sid) : null, steps = def ? arrOf(def.steps).filter(s => s && typeof s === 'object') : [], step = steps[Math.min(at, steps.length - 1)];
5464
5418
  linking = { view: null };
@@ -5689,10 +5643,8 @@ export function mountPage(container, model, options = {}) {
5689
5643
  let flowPref = null; try { flowPref = reducedMotion() ? null : localStorage.getItem('visual-flow'); } catch (_) {} // the reader's ≋ and F; under reduced motion no flow is drawn
5690
5644
  const p0 = new URLSearchParams(location.hash.slice(1));
5691
5645
  const v0 = V[p0.get('view')] ? p0.get('view') : rootId;
5692
- const shapesKey = 'visual-shapes:' + location.pathname; // the reader's notation, kept for this page (0.15.0)
5693
- const shapes0 = noteOf(p0.get('shapes')) || (() => { try { return noteOf(localStorage.getItem(shapesKey)); } catch (_) { return null; } })() || noteOf(options.shapes);
5694
5646
  const handle = render($('m-canvas'), model, {
5695
- view: v0, minimap: true, flow: flowPref ? flowPref === '1' : undefined, shapes: shapes0 || undefined,
5647
+ view: v0, minimap: true, flow: flowPref ? flowPref === '1' : undefined,
5696
5648
  on: { story: e => { told = e.step == null ? null : { id: e.story, step: e.step }; app.classList.toggle('story', !!told); pc.story(e);
5697
5649
  const sv = told && stepView(told); if (X && !X.busy() && (!sv || sv === cur || !V[sv])) write(); } }, // a step on another view: in that view's entry, once it shows (#69)
5698
5650
  _page: {
@@ -5719,13 +5671,6 @@ export function mountPage(container, model, options = {}) {
5719
5671
  if (linking && linking.view === e.view) return; // a link's own move: its entry, written once the link has settled
5720
5672
  linking = null; write(true); }); // a new view, a new entry (a move the reader started while a link was followed, too)
5721
5673
  handle.on('lens', e => { lensOn = e.lens; showLens(); write(); });
5722
- // the header's card shapes (0.15.0): box, c4 and uml in turn, for every view; the page keeps the reader's choice
5723
- const showShapes = () => { const b = $('m-shapes'), n = handle.shapes, next = NOTATIONS[(NOTATIONS.indexOf(n) + 1) % NOTATIONS.length];
5724
- b.querySelector('.lbl').textContent = ' ' + n; b.setAttribute('aria-label', `Card shapes: ${n}. Show them as ${next}`); };
5725
- $('m-shapes').addEventListener('click', () => { const next = NOTATIONS[(NOTATIONS.indexOf(handle.shapes) + 1) % NOTATIONS.length];
5726
- handle.shapes = next; try { localStorage.setItem(shapesKey, next); } catch (_) {} showShapes(); write(); status(`Card shapes: ${next}`); });
5727
- handle.on('state', e => { if (e.changed.includes('shapes')) showShapes(); });
5728
- showShapes();
5729
5674
  // #pdf=1 (umlimator.py pdf, #93): the page becomes a document for Chrome to print (printDoc), once the fonts are in,
5730
5675
  // after the map's own redraw for them
5731
5676
  if (Q.get('pdf')) (document.fonts ? document.fonts.ready : Promise.resolve()).then(() => printDoc(X, model, Q));
@@ -5737,13 +5682,14 @@ export function mountPage(container, model, options = {}) {
5737
5682
  - The cover: the title, the date, the model's file, a summary line and the contents, each view with its page.
5738
5683
  - Each view, from a page of its own: a header (the view's path and its caption), its picture (toSVG()'s, in vectors)
5739
5684
  as large as the page allows and never larger than drawn, the legend of what it draws, then tables of its cards,
5740
- lines and boxes (on a typed view: each element's type and members, as its type prints them, and each line's kind).
5685
+ lines and boxes (on a typed view, "Elements": each element's type and members, or its type's word and rows, and each
5686
+ line's kind).
5741
5687
  They fill two columns below the picture, or one column beside a picture too tall to leave room below. A table that
5742
5688
  runs out of room goes on in the next column, under its heading and header row again; a row is never cut, and a
5743
5689
  heading never ends a column.
5744
5690
  - The rules, when the model has any: each holding or broken in the views printed, and what breaks it. */
5745
5691
  function printDoc(X, model, Q) {
5746
- const { D, views, V, N, chain, nodeIn, viewEdges, geoCache } = X.ctx();
5692
+ const { D, views, V, N, chain, viewEdges, geoCache } = X.ctx();
5747
5693
  const only = (Q.get('views') || '').split(',').filter(Boolean), want = v => !only.length || only.some(p => p.endsWith('*') ? v.id.startsWith(p.slice(0, -1)) : v.id === p);
5748
5694
  const shown = views.filter(want).map(v => [v, X.standalone(1, v.id)]).filter(([, s]) => s), on = new Set(shown.map(([v]) => v.id));
5749
5695
  const rs = shown.length ? arrOf(model.rules).filter(r => r && typeof r === 'object' && (r.from || r.to || r.noCycles)) : []; // as rules() reads them; none without a view
@@ -5751,7 +5697,6 @@ function printDoc(X, model, Q) {
5751
5697
  const [pw, ph] = Q.get('paper') === 'letter' ? [279.4, 215.9] : [297, 210], title = esc(D.title || 'Map'); // the paper across, in mm
5752
5698
  const count = (k, w, ws = w + 's') => `${k} ${k === 1 ? w : ws}`, T = v => esc(v.title || v.id), full = c => Object.assign(c, { full: true });
5753
5699
  const cards = v => arrOf(v.nodeIds).filter(i => N[i]), lines = v => viewEdges(v).filter(e => e.from !== e.to), boxes = v => arrOf(v.containers).filter(c => c && c.id); // as describe() reads a view
5754
- const name = (v, id) => esc(N[id] ? nodeIn(id, v.id).label || id : (boxes(v).find(c => c.id === id) || {}).label || id);
5755
5700
  document.title = D.title || 'Map'; document.body.removeAttribute('class');
5756
5701
  document.body.innerHTML = `<style>@page { size: ${pw}mm ${ph}mm; margin: 11mm 13mm 9mm; background: var(--um-bg); }
5757
5702
  html, body { -webkit-print-color-adjust: exact; print-color-adjust: exact; background: var(--um-bg); } body { margin: 0; background-image: none; }
@@ -5828,14 +5773,11 @@ function printDoc(X, model, Q) {
5828
5773
  ...(rs.length ? ['<tr><td>Rules</td><td class="pg" data-pg="rules"></td></tr>'] : [])] }]);
5829
5774
 
5830
5775
  shown.forEach(([v, svg], vi) => {
5831
- const g = geoCache[v.id].g, ids = cards(v), es = lines(v), seq = v.render === 'sequence', stepped = es.some(e => e.step != null), at = Object.fromEntries(g.nodes.map(n => [n.id, n]));
5832
- const wk = new Map(g.edges.flatMap(d => edgeRefIds(d).map(id => [id, d.wk]))), held = boxes(v).map(c => [c, arrOf(c.nodeIds).filter(i => ids.includes(i))]).filter(([, ins]) => ins.length);
5833
- const typed = (v.diagram ?? 'map') !== 'map', word = t => String(t || '').split('.').pop().replace(/([a-z])([A-Z])/g, (_, a, b) => a + ' ' + b.toLowerCase()); // 'uml.DataType': "Data type"
5834
- const asText = (diagramOf(v)?.member || {}).print || (m => m.label ?? m.id); // a member as its type writes it, else its name
5776
+ const g = geoCache[v.id].g, made = tablesOf(X.ctx(), v, g), [ids, es, held] = made.map(t => t.rows), tables = made.filter(x => x.rows.length);
5835
5777
  const up = chain(v.id).slice(0, -1).map(id => `<span class="up">${T(V[id])} › </span>`).join('');
5836
5778
  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>` : ''}`;
5837
5779
  const body = sheet(head(false), vi);
5838
- body.insertAdjacentHTML('beforeend', `<figure>${svg}</figure><div class="lg"><b>Legend</b>${legendItems([g], 'opens a view of its own', [geoCache[v.id].shapes], D.icons).join('')}</div>`);
5780
+ body.insertAdjacentHTML('beforeend', `<figure>${svg}</figure><div class="lg"><b>Legend</b>${legendItems([g], 'opens a view of its own').join('')}</div>`);
5839
5781
  // the picture as large as the page allows, never larger than drawn: below it the legend, then two columns of tables;
5840
5782
  // one too tall for that keeps the page's height, with the legend and one column beside it. Smaller (down to 70%, and
5841
5783
  // to a scale of 0.6 at least) when that keeps the tables on its page; else they go on over the next pages.
@@ -5845,15 +5787,6 @@ function printDoc(X, model, Q) {
5845
5787
  body.classList.toggle('side', side);
5846
5788
  const lay = (f, more) => { s.setAttribute('width', b.width * k * f); s.setAttribute('height', b.height * k * f); body.querySelector('.cols')?.remove();
5847
5789
  const first = cols(body, side ? 1 : 2); if (side) first[0].append(lg); return pour(paging(more && head, 2, first), tables); };
5848
- const tables = [
5849
- { title: `Cards <span>${ids.length}</span>`, cls: 'cards', head: `<th>Card</th><th>Kind</th><th>${typed ? 'Members' : 'What it is'}</th>`, rows: ids.map(id => { const c = nodeIn(id, v.id), t = KIND[kindOf(c.kind, true)], d = at[id] || {}, sub = c.sublabel ?? c.sub;
5850
- return `<tr><td>${d.step ? `<i class="num">${esc(d.step)}</i>` : ''}<b>${esc(c.label || id)}</b>${d.kids || d.opens ? ' ⤢' : ''}${sub ? `<small>${esc(sub)}</small>` : ''}</td>`
5851
- + (typed ? `<td>${esc(word(c.type || 'uml.Class'))}</td><td>${arrOf(c.members).filter(m => m && typeof m === 'object').map(m => `<p>${esc(asText(m))}</p>`).join('')}</td></tr>`
5852
- : `<td><i class="sw" style="border-color:var(--um-${t[0]});background:var(--um-${t[0]}-f)"></i>${t[1]}</td><td>${c.what ? md(c.what) : ''}</td></tr>`); }) },
5853
- { title: `Lines <span>${es.length}</span>`, cls: 'lines', head: `${seq || stepped ? '<th class="n">#</th>' : ''}<th>From → to</th><th>Label</th><th>${typed ? 'Kind' : 'Technology'}</th>`, rows: (stepped && !seq ? [...es].sort((a, b) => stepOrder(a.step, b.step)) : es).map((e, i) => { const w = wk.get(e._id) || 'calls';
5854
- return `<tr>${seq ? `<td>${i + 1}</td>` : stepped ? `<td>${esc(e.step ?? '')}</td>` : ''}<td><i class="w" style="border-color:var(--um-w-${w})"></i>${name(v, e.from)} → ${name(v, e.to)}</td><td>${e.label ? esc(e.label) : typed ? '' : `<span class="up">${w}</span>`}</td><td>${esc(typed ? word(e.type) : e.sublabel || '')}</td></tr>`; }) },
5855
- { title: `Boxes <span>${held.length}</span>`, cls: 'boxes', head: '<th>Box</th><th>Holds</th>', rows: held.map(([c, ins]) => `<tr><td><b>${esc(c.label || c.id)}</b></td><td>${andList(ins.map(i => name(v, i)))}</td></tr>`) },
5856
- ].filter(x => x.rows.length);
5857
5790
  [1, .9, .8, .7].some(f => k * f >= Math.min(k, .6) && lay(f)) || lay(1, true);
5858
5791
  });
5859
5792
 
@@ -5872,6 +5805,25 @@ function printDoc(X, model, Q) {
5872
5805
  all.forEach((p, i) => { p.querySelector('.pn').textContent = `page ${i + 1} of ${all.length}`; });
5873
5806
  doc.querySelectorAll('[data-pg]').forEach(td => { td.textContent = all.findIndex(p => p.dataset.key === td.dataset.pg) + 1; });
5874
5807
  }
5808
+
5809
+ // a view's tables, out of printDoc for Node's tests
5810
+ function tablesOf({ D, N, nodeIn, viewEdges }, v, g) {
5811
+ const cards = v => arrOf(v.nodeIds).filter(i => N[i]), boxes = v => arrOf(v.containers).filter(c => c && c.id);
5812
+ const name = (v, id) => esc(N[id] ? nodeIn(id, v.id).label || id : (boxes(v).find(c => c.id === id) || {}).label || id);
5813
+ const ids = cards(v), es = viewEdges(v).filter(e => e.from !== e.to), seq = v.render === 'sequence', stepped = es.some(e => e.step != null), at = Object.fromEntries(g.nodes.map(n => [n.id, n]));
5814
+ 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);
5815
+ 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"
5816
+ const T = diagramOf(v) || {}, asText = (T.member || {}).print || (m => m.label ?? m.id); // a member as its type writes it, else its name
5817
+ return [
5818
+ { title: `${typed ? 'Elements' : '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, rs = typed && T.rows?.(D, v, id);
5819
+ 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>`
5820
+ + (typed ? `<td>${esc(T.word?.(D, id) ?? word(c.type || 'uml.Class'))}</td><td>${(Array.isArray(rs) ? rs : arrOf(c.members).filter(m => m && typeof m === 'object').map(m => asText(m))).map(r => `<p>${esc(r)}</p>`).join('')}</td></tr>`
5821
+ : `<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>`); }) },
5822
+ { 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';
5823
+ 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>`; }) },
5824
+ { 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>`) },
5825
+ ];
5826
+ }
5875
5827
  /* A typed view's layout (work package 1.4): layoutGraph's plain layout with what a typed view needs, none of it on a map.
5876
5828
  The parts of layoutPass only a typed view takes are here too (TYPED, registry.js); only lib/umlimator-all.js holds this
5877
5829
  file, as a map never reaches it (risks.md C5).
@@ -5881,8 +5833,8 @@ function printDoc(X, model, Q) {
5881
5833
 
5882
5834
  // what moves a typed view's geometry: every field of the view layoutGraph reads but VIEW_PAINT (a test holds the two lists to
5883
5835
  // layered.js), and these of a node and a line. Anything else (a colour, a `props` field) only repaints
5884
- const VIEW_GEO = ['abs', 'authors', 'blocks', 'containers', 'direction', 'fan', 'flipped', 'gap', 'groups', 'hub', 'intake', 'laneGap', 'lanes', 'mainSteps', 'nodeH', 'quick', 'repair', 'reroute', 'rgap', 'turned', 'typed'];
5885
- const VIEW_PAINT = ['tour', 'subAndChips'], NODE_GEO = ['id', 'col', 'row', 'w', 'h', 'ax', 'ay'], EDGE_GEO = ['id', 'from', 'to', 'label', 'step', 'fromSide', 'toSide', 'back', 'fixed', 'sublabel'];
5836
+ const VIEW_GEO = ['abs', 'authors', 'blocks', 'containers', 'direction', 'fan', 'flipped', 'gap', 'groups', 'hub', 'intake', 'labelGaps', 'laneGap', 'lanes', 'mainSteps', 'nodeH', 'quick', 'repair', 'reroute', 'rgap', 'turned', 'typed'];
5837
+ 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', 'cut', 'fromPort', 'toPort', 'inner', 'portRank'];
5886
5838
  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]))]);
5887
5839
 
5888
5840
  // direction "down": laid out left to right with every box turned, then turned back, x for y and the sides L for T, R for B
@@ -5892,7 +5844,7 @@ function transpose(g) {
5892
5844
  [...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
5893
5845
  (g.groups || []).forEach(r => { [r.x, r.y, r.x2, r.y2] = [r.y, r.x, r.y2, r.x2]; });
5894
5846
  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);
5895
- e.fs = SIDE_T[e.fs]; e.ts = SIDE_T[e.ts]; e.stubs = (e.stubs || []).map(s => s.map(swapXY)); flipBox(e.lbl); });
5847
+ e.fs = SIDE_T[e.fs]; e.ts = SIDE_T[e.ts]; e.stubs = (e.stubs || []).map(s => s.map(swapXY)); flipBox(e.lbl); flipBox(e.fpb); flipBox(e.tpb); });
5896
5848
  return g;
5897
5849
  }
5898
5850
 
@@ -5919,6 +5871,67 @@ function layoutTyped(dg, memo) {
5919
5871
  return g;
5920
5872
  }
5921
5873
 
5874
+ /* Ports on borders and lines from a box's port inward (work package 5.1, notes/stage-5.md §4, §5, §7, §9.3). The fields a
5875
+ typed view's layout input may carry for them, optional and provisional, none of them read on a map (registry.js points here):
5876
+ - a line's fromPort and toPort: the port (any name, the type's id for it) its from or to end leaves by, on the border of the
5877
+ node or box it names. The type gives every line of a port the port's side as its fromSide or toSide (§4.2); the router
5878
+ never moves it (the side cap's move is undone here), and the ends of one port sit side by side on one square
5879
+ - a line's inner: 'from' or 'to', the end on a box that leaves the box's port inward, to something the box holds (a
5880
+ delegation, §5): the line is not refused for joining a box and what it holds; that end's fromSide or toSide is the way it
5881
+ leaves (a port on the left border leaves right) and its point is on the port's own border
5882
+ - a line's portRank: the port's place in its owner's members, breaking ties in the order of ports along a side; a number,
5883
+ or [from, to] for a line with a port at each end
5884
+ - a box's pad: its padding in px where it is more than stage 2's 12 (48 where it or a box it holds has ports with lines, §7);
5885
+ a box's round: true grows its frame by √2 about its centre, so the ellipse drawn in it holds its contents (§9.3)
5886
+ - a view's labelGaps: true lays the view out once more where a line's label found no spot, was cut short or sits on a box's
5887
+ border, with the column gap it needs widened by the label's width (a map's labelRoom, layered.js; column gaps only, as a
5888
+ typed view's rows are of mixed height; laid out down, the picture's row gaps, by the label's height, so fewer labels
5889
+ gain room); kept if it has fewer issues and labels cut short (§8). The component and
5890
+ deployment types set it (5.4, 5.5); a view without it lays out as before
5891
+ Out of the layout, each line with a port has e.fpb and e.tpb, its port's box at each end: 12 px across the border, centred
5892
+ on it, and 12 + 10 (k - 1) px along it for k lines on one face. A view with ports passes repair: false (risks.md P1): port
5893
+ repair would swap a port's end with another line's.
5894
+ placePorts(sides, turned), from LP.ports: every end that names a port leaves its side's list for the border its port is on,
5895
+ with the other ends on that border, each a group of one; the groups are ordered by the mean of their far ends along the
5896
+ border, ties by portRank, and spread over the border's free span (a box's: below its title, off its corners, as layoutPass
5897
+ spreads a box's ends), 8 px apart (down to 4 where they do not fit, and a warning where they still leave the side),
5898
+ centred; a port's ends 10 px apart on each face */
5899
+ const PORT_SIDE = { left: 'L', right: 'R', top: 'T', bottom: 'B' }, PORT_OPP = { L: 'R', R: 'L', T: 'B', B: 'T' };
5900
+ function placePorts(sides, turned) {
5901
+ const hot = new Map(), at = (n, side) => { const k = n.id + '|' + side; if (!hot.has(k)) hot.set(k, { n, side, ends: [] }); return hot.get(k).ends; };
5902
+ Object.entries(sides).forEach(([key, list]) => { sides[key] = list.filter(it => { const { e, n } = it, from = n === e.s && (e.s !== e.t || key.endsWith('|R')), port = from ? e.fromPort : e.toPort;
5903
+ if (port == null) return true;
5904
+ const leave = PORT_SIDE[from ? e.fromSide : e.toSide] || key.slice(-1), inner = e.inner === (from ? 'from' : 'to');
5905
+ e[from ? 'fs' : 'ts'] = leave; at(n, inner ? PORT_OPP[leave] : leave).push({ ...it, from, port, inner }); return false; }); });
5906
+ hot.forEach((H, k) => { (sides[k] || []).forEach(it => H.ends.push({ ...it, from: it.n === it.e.s && (it.e.s !== it.e.t || k.endsWith('|R')) })); delete sides[k]; });
5907
+ Object.keys(sides).forEach(k => { if (!sides[k].length) delete sides[k]; });
5908
+ hot.forEach(H => spreadPorts(H, turned));
5909
+ }
5910
+ function spreadPorts({ n, side, ends }, turned) {
5911
+ const v = side === 'L' || side === 'R', a0 = v ? n.y : n.x, len = v ? n.h : n.w, far = x => v ? x.other.y + x.other.h / 2 : x.other.x + x.other.w / 2;
5912
+ const rank = x => { const r = x.e.portRank; return (Array.isArray(r) ? r[x.from ? 0 : 1] : r) ?? 1e9; }, byFar = (a, b) => far(a) - far(b), groups = [];
5913
+ ends.forEach(x => { const g = x.port != null && groups.find(o => o.port === x.port); if (g) g.ends.push(x); else groups.push({ port: x.port, ends: [x] }); });
5914
+ groups.forEach(g => { g.faces = [g.ends.filter(x => !x.inner).sort(byFar), g.ends.filter(x => x.inner).sort(byFar)];
5915
+ g.at = g.ends.reduce((a, x) => a + far(x), 0) / g.ends.length; g.L = g.port == null ? 2 : 2 + 10 * Math.max(...g.faces.map(f => f.length)); });
5916
+ groups.sort((a, b) => a.at - b.at || rank(a.ends[0]) - rank(b.ends[0]));
5917
+ let lo = a0 + 8, hi = a0 + len - 8;
5918
+ if (n.box) { const across = turned ? !v : v; // as layoutPass spreads a box's ends: below the title on a side, off the title and corners elsewhere
5919
+ lo = across ? a0 + 40 : Math.max(a0 + 20, turned && side === 'L' ? n.y + n.hd + 16 : -Infinity, ...(side === 'T' ? n.decoT : []).map(([x, w]) => x + w + 8));
5920
+ hi = a0 + len - (across ? 16 : 20); }
5921
+ const sum = groups.reduce((a, g) => a + g.L, 0), k = groups.length - 1, gap = k ? Math.max(4, Math.min(8, (hi - lo - sum) / k)) : 0;
5922
+ if (sum + gap * k > (n.box ? hi - lo : len)) warn(`the ports on the ${Object.keys(PORT_SIDE).find(w => PORT_SIDE[w] === (turned ? SIDE_T[side] : side))} side of "${n.id}" do not fit it: they need ${Math.ceil(sum + gap * k)} px, it has ${Math.floor(n.box ? hi - lo : len)}`);
5923
+ const B = side === 'L' ? n.x : side === 'R' ? n.x + n.w : side === 'T' ? n.y : n.y + n.h;
5924
+ let s = (lo + hi - sum - gap * k) / 2;
5925
+ groups.forEach(g => { const box = v ? { x: B - 6, y: s, w: 12, h: g.L } : { x: s, y: B - 6, w: g.L, h: 12 };
5926
+ g.faces.forEach(face => face.forEach((x, j) => { const c = g.port == null ? s + 1 : s + 6 + 10 * j;
5927
+ x.e[x.from ? 'p1' : 'p2'] = v ? [B, c] : [c, B]; if (g.port != null) x.e[x.from ? 'fpb' : 'tpb'] = { ...box }; }));
5928
+ s += g.L + gap; });
5929
+ }
5930
+ // A typed view's box as frameOf measured it with stage 2's 12 px of padding: its pad widens that on every side, and round
5931
+ // grows it by √2 about its centre (above). Neither: the box as it was
5932
+ TYPED.frame = b => { const d = b.pad > 12 ? b.pad - 12 : 0, k = b.round ? Math.SQRT2 : 1; if (!d && k === 1) return b;
5933
+ const w = (b.w + 2 * d) * k, h = (b.h + 2 * d) * k; return Object.assign(b, { x: b.x + (b.w - w) / 2, y: b.y + (b.h - h) / 2, w, h }); };
5934
+
5922
5935
  // A typed view's lines from a node to itself (risks.md F1) are loops round its top right corner, nested, the first innermost:
5923
5936
  // each one's top run D px above the node, its label on that run clear of the node's stub room and of the loop inside; its
5924
5937
  // right run R px right of the node, far enough for the top run to hold the label. out: how far each loop's port on the
@@ -5928,20 +5941,32 @@ const loopDims = (dg, STUB, SEP) => (list, out) => { let D = 0, R = 0, half = 0;
5928
5941
  D = k ? D + half + bh / 2 + 10 : STUB + 6 + bh / 2; half = bh / 2;
5929
5942
  R = Math.max(k ? R + 2 * SEP : STUB + 4, bw + 12 - out[k]); return { D, R, top: D + half }; }); };
5930
5943
  // layoutPass's loops (LP): fills loops (by node id, the lines from it to itself) and makes room for them; then mark(edges),
5931
- // each such line routed here, not by the router; ports(pushSide): the top port of the node's right side and the right port
5932
- // of its top, the inner loop the nearer the corner; route(): each loop out of the right side, up, left and down into the
5933
- // top, and its corners, which the other routes keep SEP px off, so none touches it or turns where it does
5944
+ // each such line routed here, not by the router; ports(pushSide, sides): the top port of the node's right side and the right port
5945
+ // of its top, the inner loop the nearer the corner, then the ends of lines that name a port (placePorts, above); route(): each
5946
+ // loop out of the right side, up, left and down into the top, and its corners, which the other routes keep SEP px off, so none
5947
+ // touches it or turns where it does
5934
5948
  TYPED.loops = (loops, dg, { byId, extraX, extraY, STUB, RG, CG, SEP, gapAfter }) => {
5935
5949
  const dims = loopDims(dg, STUB, SEP); let edges = [];
5936
5950
  (dg.edges || []).forEach(e => { if (e.from === e.to && byId[e.from]) (loops[e.from] ||= []).push(e); });
5937
5951
  Object.entries(loops).forEach(([id, list]) => { const n = byId[id], d = dims(list, list.map(() => 0)).at(-1);
5938
5952
  extraY[n.row - .5] = Math.max(extraY[n.row - .5] || 0, d.top + STUB + 7 - RG);
5939
5953
  extraX[n.col] = Math.max(extraX[n.col] || 0, d.R + STUB + 6 - Math.max(CG, gapAfter[n.col] || 0)); });
5954
+ // a port with k lines on one face (5.1) needs k tracks between its stubs and the next column's (row's): room beside it. A
5955
+ // box's port: beside the column (row) of its cards its border faces, and its pad more
5956
+ const boxes = dg.containers || [], inBox = (id, seen = new Set()) => seen.has(id) ? [] : (seen.add(id), boxes.filter(f => f.id === id)
5957
+ .flatMap(f => [...(f.nodeIds || []).map(i => byId[i]).filter(Boolean), ...boxes.filter(c => c.parent === id).flatMap(c => inBox(c.id, seen))]));
5958
+ const busy = {}; (dg.edges || []).forEach(e => [[e.from, e.fromPort, e.fromSide, 'from'], [e.to, e.toPort, e.toSide, 'to']].forEach(([id, p, side, end]) => {
5959
+ if (p != null && e.inner !== end && PORT_SIDE[side]) { const k = id + '|' + PORT_SIDE[side] + '|' + p; busy[k] = (busy[k] || 0) + 1; } }));
5960
+ Object.entries(busy).forEach(([k, m]) => { const [id, side] = k.split('|'), ns = byId[id] ? [byId[id]] : inBox(id); if (m < 2 || !ns.length) return;
5961
+ const need = m * SEP + 2 * STUB + 8 + (byId[id] ? 0 : Math.max(12, ...boxes.filter(f => f.id === id).map(f => f.pad || 0))), at = x => ns.map(n => n[x]);
5962
+ if (side === 'R' || side === 'L') { const c = side === 'R' ? Math.floor(Math.max(...at('col'))) : Math.ceil(Math.min(...at('col'))) - 1; extraX[c] = Math.max(extraX[c] || 0, need - Math.max(CG, gapAfter[c] || 0)); }
5963
+ else { const r = side === 'B' ? Math.max(...at('row')) + .5 : Math.min(...at('row')) - .5; extraY[r] = Math.max(extraY[r] || 0, need - RG); } });
5940
5964
  const loopsOf = n => edges.filter(e => e[OWN_ROUTE] === 'loop' && e.s === n);
5941
5965
  return {
5942
5966
  mark(all) { edges = all; edges.forEach(e => { if (e.s === e.t) Object.assign(e, { [OWN_ROUTE]: 'loop', fixed: true, fs: 'R', ts: 'T', stubs: [], how: 'self' }); }); }, // its loop is drawn once its ports are known
5943
- ports: pushSide => edges.filter(e => e[OWN_ROUTE] === 'loop').forEach(e => { const n = e.s, k = loopsOf(n).indexOf(e);
5944
- pushSide(n, 'R', e, { x: n.x + n.w, y: -1e6 + k, w: 0, h: 0 }); pushSide(n, 'T', e, { x: 1e6 - k, y: n.y, w: 0, h: 0 }); }),
5967
+ ports(pushSide, sides) { edges.filter(e => e[OWN_ROUTE] === 'loop').forEach(e => { const n = e.s, k = loopsOf(n).indexOf(e);
5968
+ pushSide(n, 'R', e, { x: n.x + n.w, y: -1e6 + k, w: 0, h: 0 }); pushSide(n, 'T', e, { x: 1e6 - k, y: n.y, w: 0, h: 0 }); });
5969
+ if (sides) placePorts(sides, dg.turned); },
5945
5970
  route() { const corners = []; [...new Set(edges.filter(e => e[OWN_ROUTE] === 'loop').map(e => e.s))].forEach(n => { const list = loopsOf(n);
5946
5971
  dims(list, list.map(e => n.x + n.w - e.p2[0])).forEach(({ D, R }, k) => { const e = list[k], x = n.x + n.w + R, y = n.y - D;
5947
5972
  e.pts = e.fixed = [e.p1, [x, e.p1[1]], [x, y], [e.p2[0], y], e.p2];
@@ -6040,9 +6065,11 @@ SHAPE.classifier = CLASSIFIER;
6040
6065
  // and to name an end in this table, the line's ends as UML draws them (none, or a name it does not hold, draws no marker);
6041
6066
  // dashed: a dependency's or realization's line; labels: each end's role and multiplicity (endLabels). Each end: [its
6042
6067
  // length, half its height, its path pointing right with the tip at its length, its fill]. Its tip is where the line ends,
6043
- // on the node's outline. Hollow is the stage's background, filled the line's own colour (T2), in either theme
6068
+ // on the node's outline. Hollow is the stage's background, filled the line's own colour (T2), in either theme. The socket
6069
+ // (notes/stage-5.md §9.2) is a usage's end on an interface drawn as a 16 px ball: a half circle of radius 11 open towards the
6070
+ // ball, its middle on the line 3 px before the end (the ball's outline), its centre 8 px past it (the ball's)
6044
6071
  const ENDS = { none: null, arrow: [10, 4, 'M0 0L10 4L0 8Z', 'line'], open: [10, 5, 'M0 0L10 5L0 10', 'none'], triangle: [13, 7, 'M0 0L13 7L0 14Z', 'hollow'],
6045
- diamond: [16, 6, 'M0 6L8 0L16 6L8 12Z', 'hollow'], 'filled-diamond': [16, 6, 'M0 6L8 0L16 6L8 12Z', 'line'] };
6072
+ diamond: [16, 6, 'M0 6L8 0L16 6L8 12Z', 'hollow'], 'filled-diamond': [16, 6, 'M0 6L8 0L16 6L8 12Z', 'line'], socket: [3, 11, 'M11 0A11 11 0 0 0 11 22', 'none'] };
6046
6073
  const endOf = (E, w) => hasKey(ENDS, E[w]) && ENDS[E[w]] ? E[w] : null;
6047
6074
 
6048
6075
  // a typed line's end labels (1.5): each end's role and multiplicity (e.end.labels: { from: { role, mult }, to: { role, mult } }),
@@ -6069,8 +6096,14 @@ function endLabels(e, pts = e.pts, also = []) { // also: boxes to keep clear of
6069
6096
  }
6070
6097
 
6071
6098
  // svgOf's typed lines (TYPED.lines): { id, edges, drawn, lstyle, MC } -> defs, the markers of the ends drawn; edge(e, i), a
6072
- // line with ends: its style, its points trimmed for its flow, its markers; labels(i): its end labels' text
6099
+ // line with ends: its style, its points trimmed for its flow, its markers; labels(i): its end labels' text.
6100
+ // First each end on a node drawn by a typed shape with an inset (a circle, a diamond: notes/stage-4.md §4.2) is slid from
6101
+ // the node's box onto its outline, as a map's on its cards: drawn[i] becomes a copy (drawnPts), so the layout's points,
6102
+ // which a memo hands back (Q13), are never changed. Every typed shape with an inset gets the
6103
+ // copy, its ends rounded; where the inset is 0 (the classifier, a package, an object) the ends stay on the box
6104
+ const insetLook = n => { const S = n && hasKey(SHAPE, n.shape) ? SHAPE[n.shape] : null; return S && S.draw && S.inset ? { ...n, S } : null; };
6073
6105
  TYPED.lines = ({ id, edges, drawn, lstyle, MC }) => {
6106
+ edges.forEach((e, i) => { if (e.pts) drawn[i] = drawnPts(e, insetLook); });
6074
6107
  const ends = [...new Set(edges.flatMap(e => e.end ? ['from', 'to'].map(w => endOf(e.end, w)).filter(Boolean).map(k => k + '-' + lstyle(e).m) : []))];
6075
6108
  const endMark = km => { const k = km.slice(0, km.lastIndexOf('-')), m = km.slice(k.length + 1), [L, H, p, fl] = ENDS[k];
6076
6109
  return `<marker id="${id}-${km}" markerWidth="${L}" markerHeight="${2 * H}" refX="${L}" refY="${H}" orient="auto-start-reverse" markerUnits="userSpaceOnUse" overflow="visible"><path d="${p}" fill="${fl === 'line' ? MC[m] : fl === 'hollow' ? 'var(--um-stage-bg)' : 'none'}" stroke="${MC[m]}" stroke-width="1.5" stroke-linejoin="round" stroke-linecap="round"/></marker>`; };
@@ -6103,6 +6136,23 @@ TYPED.lines = ({ id, edges, drawn, lstyle, MC }) => {
6103
6136
  - wrap(model, view, ids, kind): W, then a picker of the type's `wraps` (strings, or entries with a label).
6104
6137
  - add(model, view, entry, label, at): the palette and N, once the name is typed; at as for L, with sel, the ids selected.
6105
6138
  - remove(model, view, ids, fromModel): Delete (fromModel false), Shift + Delete, the panel's buttons and the outline's ✕.
6139
+ - rename(model, view, id, text): F2 or a double-click on a selected element, Enter with the text typed (I.label). Null:
6140
+ set label, as before (notes/stage-4.md §12.1). An id may be a geometry id no element has (a port on a part,
6141
+ `<part>-><port>`, notes/stage-5.md §6): rename, remove and key hear it as drawn, and map it back themselves.
6142
+ - member(model, view, owner, id, text): Enter or a change on a row of the panel's Members (id: the member's; null for the
6143
+ New member field), owner the element selected, text trimmed, before the panel commits it (notes/stage-5.md §12.1, S13).
6144
+ Null: the type's member.add or member.edit, as before. An { error } also marks the row invalid; a row read by the hook
6145
+ is cleared (New member) or kept as typed, after a pick's choice. Not asked for an empty row (the panel's own).
6146
+ - key(model, view, ids, key): a key (e.key) with no Alt, Ctrl or Meta on a selection (ids, as remove's), before the
6147
+ canvas's own (S11). Null: the canvas's. Asked only when the host lets the view be renamed (B on an interface sets present).
6148
+ It hears every such key first, Escape, Tab, Enter (a focused ring's, before the ring selects) and the arrows too, and
6149
+ only e.key (Shift + Delete reads as Delete): return null for the keys the type does not own.
6150
+ Any hook may also return { pick: [label, …], then: i => result }: a picker of the labels opens under the element selected
6151
+ (else the view's first node), and the index chosen goes to then, whose result is read as the hook's; an empty pick is
6152
+ said and does nothing. A palette entry with name: false is added with no name field (add gets an empty label; with no
6153
+ add hook, the element has no label). moveTo's at, for a card dropped or Alt + arrows, holds geo, the canvas's geometry.
6154
+ V on a selected node whose palette entry has opens (a diagram kind, 'uml.sequence'), under rename: a picker of the
6155
+ model's views of that kind, then set opensView.
6106
6156
  A slot is an entry of the geometry's `slots` ({ index, y, parent, after }, src/layout/timeaxis.js; x too where a type has
6107
6157
  one); with none, at is null. A view's fragments (view.fragments) are elements too: selected, they show their properties and
6108
6158
  their members (operands) in the panel, as a card does; a double-click on a selected member drawn as a card (an operand's
@@ -6119,20 +6169,37 @@ TYPED.edit = (I, c) => {
6119
6169
  const keyAt = () => { const S = slots(), s = api.sel(), id = s && (s.id ?? arrOf(s.ids)[0]); return S.find(x => x.after != null && x.after === id) || S.at(-1) || null; };
6120
6170
  const world = ev => { const r = api.stage.getBoundingClientRect(); return api.cam().toWorld(ev.clientX - r.left, ev.clientY - r.top); };
6121
6171
  // what a hook returned: null, the default; { error }, said; else its commands
6122
- const ran = (r, dflt) => { if (r == null) return dflt();
6172
+ const ran = (r, dflt = () => true) => { if (r == null) return dflt();
6173
+ if (isRec(r) && Array.isArray(r.pick) && typeof r.then === 'function') return choose(r.pick, i => ran(r.then(i), dflt));
6123
6174
  if (isRec(r) && r.error != null) { api.status(String(r.error)); api.say(String(r.error)); return false; }
6124
6175
  if (isRec(r) && Array.isArray(r.commands)) { if (r.say != null) { api.status(String(r.say)); api.say(String(r.say)); } return emit(r.commands); }
6125
6176
  return emit(arrOf(r)); };
6126
6177
  I.ran = ran; // connect's result, read by the core's addLine
6178
+ // a picker of labels under the node at, else the element selected, else the view's first node, opened once the gesture is over (a name
6179
+ // field closing takes the focus back, which would close it). ponytail: a view with no node has nothing to hold it, so
6180
+ // nothing opens; c.picker taking a point would lift that
6181
+ function choose(labels, done, name = 'Choose', at) { const g = api.geo(), s = api.sel(), by = g.byId || {}; at ??= s && Object.hasOwn(by, s.id) ? s.id : (g.nodes[0] || {}).id;
6182
+ if (!labels.length || at == null || !Object.hasOwn(by, at) || !api.cam()) { api.status('Nothing to choose from'); api.say('Nothing to choose from'); return false; }
6183
+ const opts = labels.map(l => ({ label: String(l) }));
6184
+ queueMicrotask(() => { c.picker(at, opts, t => done(opts.indexOf(t))); const m = api.stage.querySelector('.um-pick'); if (m) m.setAttribute('aria-label', name); });
6185
+ return true; }
6127
6186
 
6128
6187
  I.connect = (from, to, ev, at) => base.connect(from, to, ev, at !== undefined ? at : ev ? near(...world(ev)) : keyAt());
6129
6188
  I.drop = (n, hint, into, from) => { const h = hook('moveTo'), dflt = () => base.drop(n, hint, into, from), dy = hint.y - (n.y + n.h / 2);
6130
6189
  const g = api.geo(), col = arrOf((view() || {}).nodeIds).filter(id => id !== n.id && isRec(g.byId[id]) && g.byId[id].x + g.byId[id].w / 2 < hint.x).length;
6131
- return h && can('move') ? ran(h(model(), view(), n.id, { ...near(hint.x, hint.y + Math.sign(dy) * n.h / 2), pt: [hint.x, hint.y], col }), dflt) : dflt(); };
6132
- I.addNode = i => { const h = hook('add'), t = c.nodeTypes()[i], cam = api.cam(); if (!h || !t || !can('add') || !cam) return base.addNode(i);
6190
+ 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, geo: g }), dflt) : dflt(); };
6191
+ I.addNode = i => { const h = hook('add'), t = c.nodeTypes()[i], cam = api.cam(); if (!t || !can('add') || !cam || (!h && t.name !== false)) return base.addNode(i);
6133
6192
  const s = keyAt(), at = s && { ...s, sel: c.picks() }; // where the selection is now, before the name field takes the focus
6134
- api.editor({ x: cam.s.cx - 80, y: cam.s.cy - 13, w: 160, h: 26 }, '', label => ran(h(model(), view(), t, label, at),
6135
- () => emit([{ op: 'add', element: { ...c.typed(t, 'node'), label }, view: api.view() }]))); };
6193
+ const go = label => ran(h && h(model(), view(), t, label, at), () => emit([{ op: 'add', element: { ...c.typed(t, 'node'), ...(label ? { label } : {}) }, view: api.view() }]));
6194
+ if (t.name === false) return go('');
6195
+ api.editor({ x: cam.s.cx - 80, y: cam.s.cy - 13, w: 160, h: 26 }, '', go); };
6196
+ I.label = (ref, text) => { const h = hook('rename'), id = ref && (ref.type === 'edge' ? arrOf(ref.ids).find(x => !String(x).includes('->')) : ref.id), dflt = () => base.label(ref, text);
6197
+ return h && can('rename') && id != null ? ran(h(model(), view(), id, text), dflt) : dflt(); };
6198
+ // V: the views a selected node may open, by its palette entry's opens; the one chosen is its opensView (notes/stage-4.md S18)
6199
+ function opens(id) { const x = arrOf(model().nodes).find(n => isRec(n) && n.id === id), t = x && c.nodeTypes().find(e => e.type === x.type && typeof e.opens === 'string');
6200
+ if (!t) return false;
6201
+ const vs = arrOf(model().views).filter(v => isRec(v) && v.diagram === t.opens);
6202
+ choose(vs.map(v => v.title ?? v.id), i => emit([{ op: 'set', id, path: 'opensView', value: vs[i].id }]), 'Opens view', id); return true; }
6136
6203
  I.remove = (fromModel, ids = c.picks()) => { const h = hook('remove'), dflt = () => base.remove(fromModel, ids);
6137
6204
  return h && can('delete') && ids.length ? ran(h(model(), view(), ids, !!fromModel), dflt) : dflt(); };
6138
6205
  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
@@ -6144,9 +6211,11 @@ TYPED.edit = (I, c) => {
6144
6211
  }
6145
6212
  I.key = e => {
6146
6213
  if (I.linking()) return base.key(e);
6147
- const K = e.key, ids = c.picks(), mv = hook('moveTo');
6148
- if (e.altKey && DIR[K] && mv && ids.length && can('move')) { const r = mv(model(), view(), ids.at(-1), { by: DIR[K] }); if (r == null) return base.key(e); ran(r); return true; }
6214
+ const K = e.key, ids = c.picks(), mv = hook('moveTo'), kh = hook('key');
6215
+ if (kh && ids.length && !e.altKey && !e.ctrlKey && !e.metaKey && can('rename')) { const r = kh(model(), view(), ids, K); if (r != null) { ran(r); return true; } }
6216
+ if (e.altKey && DIR[K] && mv && ids.length && can('move')) { const r = mv(model(), view(), ids.at(-1), { by: DIR[K], geo: api.geo() }); if (r == null) return base.key(e); ran(r, () => base.key(e)); return true; }
6149
6217
  if ((K === 'w' || K === 'W') && !e.altKey && hook('wrap') && can('add') && ids.length) { wrap(ids); return true; }
6218
+ if ((K === 'v' || K === 'V') && !e.altKey && !e.ctrlKey && !e.metaKey && can('rename') && ids.length && opens(ids.at(-1))) return true;
6150
6219
  return base.key(e);
6151
6220
  };
6152
6221
 
@@ -6209,6 +6278,22 @@ TYPED.edit = (I, c) => {
6209
6278
  if (b) memberMove(b.dataset.up ?? b.dataset.down, b.dataset.up != null ? -1 : 1, e); }, true);
6210
6279
  api.root.addEventListener('keydown', e => { const t = e.target;
6211
6280
  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);
6281
+ /* a member row, Enter or a change on it: edits.member first, before the panel's commit (which listens on the panel, under root) */
6282
+ const row = e => { const t = e.target, d = t && t.dataset, h = hook('member'), s = api.sel();
6283
+ if (!h || !d || (d.new == null && d.m == null) || !t.closest || !t.closest('.um-editor') || !I.on() || !s || s.id == null || !can(d.new != null ? 'add' : 'rename')) return;
6284
+ if (d.m != null && t.value === d.was) return;
6285
+ const text = t.value.trim(); if (!text) return; // empty: the panel's own (nothing added; a row put back)
6286
+ const r = h(model(), view(), s.id, d.m ?? null, text); if (r == null) return;
6287
+ e.preventDefault(); e.stopPropagation();
6288
+ // the row is settled by the result read, after a pick's choice (a cancelled pick keeps the row as typed)
6289
+ const settle = x => { if (x == null) return x;
6290
+ if (isRec(x) && Array.isArray(x.pick) && typeof x.then === 'function') return { ...x, then: i => settle(x.then(i)) };
6291
+ if (isRec(x) && x.error != null) t.setAttribute('aria-invalid', 'true');
6292
+ else { t.removeAttribute('aria-invalid'); if (d.new != null) t.value = ''; else d.was = t.value; }
6293
+ return x; };
6294
+ ran(settle(r)); };
6295
+ api.root.addEventListener('keydown', e => { if (e.key === 'Enter') row(e); }, true);
6296
+ api.root.addEventListener('change', row, true);
6212
6297
  };
6213
6298
  /* ---------- owners on the picture (work package 2.2, notes/stage-2.md §4 to §7) ----------
6214
6299
  What the class, package and object views share: which shown package is a box and what it holds (§4.1), the names drawn
@@ -6223,13 +6308,16 @@ const ownName = x => String(x.label ?? x.id);
6223
6308
  const stereoOf = x => OWN_ARR(OWN_OBJ(x.props) ? x.props.stereotypes : null).filter(t => t != null && t !== '').map(String);
6224
6309
 
6225
6310
  // every node of a model by id (the first of an id), and the chain of its owners, nearest first. An ownership cycle (the
6226
- // core's error) is broken: an element on one has no owner, and one under one stops at the first element of it it meets
6227
- function ownIndex(model) {
6228
- const at = new Map(); OWN_ARR(OWN_OBJ(model) ? model.nodes : null).forEach(n => { if (OWN_OBJ(n) && typeof n.id === 'string' && !at.has(n.id)) at.set(n.id, n); });
6311
+ // 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.
6312
+ // members: the member types indexed too (a state's regions, notes/stage-4.md §6.1), each owned by its node
6313
+ function ownIndex(model, members = []) {
6314
+ const at = new Map(), up = new Map(), mems = new Set(), put = (x, p) => { if (OWN_OBJ(x) && typeof x.id === 'string' && !at.has(x.id)) { at.set(x.id, x); up.set(x.id, p); return true; } };
6315
+ OWN_ARR(OWN_OBJ(model) ? model.nodes : null).forEach(n => put(n, n.parent));
6316
+ if (members.length) [...at.values()].forEach(n => OWN_ARR(n.members).forEach(m => { if (OWN_OBJ(m) && members.includes(m.type) && put(m, n.id)) mems.add(m.id); }));
6229
6317
  const chain = id => { const out = [], seen = new Set([id]);
6230
- for (let p = (at.get(id) || {}).parent; p != null && at.has(p); p = at.get(p).parent) { if (seen.has(p)) return p === id ? [] : out.slice(0, out.indexOf(p) + 1); seen.add(p); out.push(p); }
6318
+ for (let p = up.get(id); p != null && at.has(p); p = up.get(p)) { if (seen.has(p)) return p === id ? [] : out.slice(0, out.indexOf(p) + 1); seen.add(p); out.push(p); }
6231
6319
  return out; };
6232
- return { at, chain };
6320
+ return { at, chain, up, mems };
6233
6321
  }
6234
6322
 
6235
6323
  /* 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
@@ -6237,18 +6325,26 @@ function ownIndex(model) {
6237
6325
  whose owner on the picture o is (null: the top), in the view's order; boxes: the open packages, in
6238
6326
  the registry's format (registry.js boxes), each holding exactly the shown elements whose owner on the picture it is, and
6239
6327
  the boxes nested in it by `parent`; name(id): the drawn name, with the owners between it and its owner on the picture,
6240
- or with none from the view's subject when that is one of its owners, else from the top (§6); full(id): from the top */
6241
- function ownersOn(model, view) {
6242
- const { at, chain } = ownIndex(model), ids = OWN_ARR(OWN_OBJ(view) ? view.nodeIds : null).filter(id => at.has(id)), shown = new Set(ids);
6243
- const ow = new Map(ids.map(id => [id, chain(id).find(p => shown.has(p) && OWN_BOXES.includes(at.get(p).type)) ?? null])), owner = id => ow.get(id) ?? null;
6328
+ 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.
6329
+ look (notes/stage-4.md §8.2, §6.1), each field optional: boxTypes, the node types that may be a box (OWN_BOXES); qualify
6330
+ false, names drawn without their owners; members, member types that are boxes too (a state's regions): one is shown when
6331
+ its node is and it holds something shown, and is a box when its node has two or more in the model (with one, what it
6332
+ holds sits in the node's box); shape(x) and word(x), a box's shape and the word its accessible name starts with ('package', 'Package') */
6333
+ function ownersOn(model, view, look = {}) {
6334
+ const { boxTypes = OWN_BOXES, qualify = true, members = [], shape = () => 'package', word = () => 'Package' } = look;
6335
+ const { at, chain, up, mems } = ownIndex(model, members), ids = OWN_ARR(OWN_OBJ(view) ? view.nodeIds : null).filter(id => at.has(id) && !mems.has(id)), seen = new Set(ids);
6336
+ const live = new Set(ids.flatMap(id => chain(id).filter(p => mems.has(p) && seen.has(up.get(p))))), place = m => OWN_ARR(at.get(up.get(m)).members).indexOf(at.get(m)); // members holding something shown
6337
+ const many = m => OWN_ARR(at.get(up.get(m)).members).filter(x => OWN_OBJ(x) && members.includes(x.type)).length > 1; // §6.1, 2: two or more in the model
6338
+ const kept = [...live].filter(many).sort((a, b) => place(a) - place(b)), all = [...ids, ...kept], shown = new Set(all);
6339
+ const ow = new Map(all.map(id => [id, chain(id).find(p => shown.has(p) && (mems.has(p) || boxTypes.includes(at.get(p).type))) ?? null])), owner = id => ow.get(id) ?? null;
6244
6340
  const upTo = (id, stop) => { const c = chain(id), k = c.indexOf(stop); return [...(k < 0 ? c : c.slice(0, k)).reverse(), id].map(x => ownName(at.get(x))).join('::'); };
6245
6341
  const full = id => upTo(id, null), subject = OWN_OBJ(view) ? view.subject : null;
6246
- const name = id => { const o = owner(id); return upTo(id, o != null ? o : chain(id).includes(subject) ? subject : null); };
6247
- const by = new Map(); ids.forEach(id => { const o = owner(id); if (!by.has(o)) by.set(o, []); by.get(o).push(id); }); // grouped once: a view of 10,000 elements
6342
+ const name = id => { const o = owner(id); return !qualify ? ownName(at.get(id)) : upTo(id, o != null ? o : chain(id).includes(subject) ? subject : null); };
6343
+ const by = new Map(); all.forEach(id => { const o = owner(id); if (!by.has(o)) by.set(o, []); by.get(o).push(id); }); // grouped once: a view of 10,000 elements
6248
6344
  const holds = o => by.get(o) || [], open = new Set([...by.keys()].filter(x => x != null));
6249
- const boxes = ids.filter(id => open.has(id)).map(id => { const p = at.get(id), st = stereoOf(p), held = holds(id), o = owner(id), nm = name(id);
6250
- return { id, label: ownName(p), title: [...st.map(t => `«${t}»`), nm].join(' '), nodeIds: held.filter(x => !open.has(x)), ...(o != null ? { parent: o } : {}), shape: 'package',
6251
- aria: `Package ${nm}${st.length ? ` (${st.join(', ')})` : ''}, holding ${held.length} element${held.length === 1 ? '' : 's'}` }; });
6345
+ const boxes = all.filter(id => open.has(id)).map(id => { const p = at.get(id), st = stereoOf(p), held = holds(id), o = owner(id), nm = name(id);
6346
+ return { id, label: ownName(p), title: [...st.map(t => `«${t}»`), nm].join(' '), nodeIds: held.filter(x => !open.has(x)), ...(o != null ? { parent: o } : {}), shape: shape(p),
6347
+ aria: `${word(p)} ${nm}${st.length ? ` (${st.join(', ')})` : ''}, holding ${held.length} element${held.length === 1 ? '' : 's'}` }; });
6252
6348
  return { ids, owner, holds, boxes, name, full, open, chain, at };
6253
6349
  }
6254
6350
 
@@ -9200,3 +9296,3243 @@ function writeMermaidSequence(model, viewId) {
9200
9296
  for (const x of notWritten) out.push(` %% not written: ${x}`);
9201
9297
  return out.join('\n') + '\n';
9202
9298
  }
9299
+ /* ---------- the transition micro-syntax (work package 4.2, notes/stage-4.md §7) ----------
9300
+ UML's `trigger-list ['[' guard ']'] ['/' effect]`, shared by the state machine's transitions and internal transitions,
9301
+ the activity's and the overview's flow labels (src/uml/flow.js) and the Mermaid state reader:
9302
+ - the trigger runs to the first [ or / outside brackets and parentheses: evt(a/b), pay(amount[0]), after 30 days, a, b;
9303
+ - the guard runs from that [ to its matching ], brackets and parentheses nested in it (max(a, b[1]) > 2, x / y > 1);
9304
+ - the effect is everything after the first / outside the guard: any text.
9305
+ Each part is trimmed text, or undefined when absent or empty. Refused, as { error }: an unbalanced or crossed [, ], ( or
9306
+ ) in the trigger or the guard; a second guard, or any text between the guard and the /; a text over 4,096 characters.
9307
+ Hand-written and linear, no regular expression (risks.md R2, E3). Ships in lib/umlimator-all.js only. */
9308
+
9309
+ const TR_MAX = 4096, TR_OPEN = { '[': ']', '(': ')' }, TR_CLOSE = { ']': '[', ')': '(' };
9310
+ const trPart = x => { const s = x == null ? '' : String(x).trim(); return s === '' ? undefined : s; };
9311
+
9312
+ // the parts and where the effect's / is (-1 for none), so a caller can tell "a /" from "a / x"; form: what the error says the text should be
9313
+ function trScan(text, form = 'a transition is trigger [guard] / effect') {
9314
+ const s = String(text ?? ''), fail = why => ({ error: `${form}: ${why}` });
9315
+ if (s.length > TR_MAX) return fail(`the text is over ${TR_MAX} characters`);
9316
+ const stack = []; let i = 0, guardAt = -1, guardEnd = -1, slash = -1;
9317
+ for (; i < s.length && slash < 0; i++) { const c = s[i];
9318
+ if (guardEnd >= 0) { // after the guard: spaces, then the effect's /
9319
+ if (c === '/') slash = i; else if (c === '[') return fail('it has a second guard'); else if (c.trim()) return fail(`"${c}" after the guard, where only / effect may follow`);
9320
+ } else if (TR_OPEN[c]) { if (!stack.length && c === '[' && guardAt < 0) guardAt = i; stack.push(c); }
9321
+ else if (TR_CLOSE[c]) { const o = stack.pop(); if (o !== TR_CLOSE[c]) return fail(`the ${c} at ${i + 1} ${o ? `meets an open ${o}` : 'closes nothing'}`); if (!stack.length && guardAt >= 0) guardEnd = i; }
9322
+ else if (c === '/' && !stack.length) slash = i;
9323
+ }
9324
+ if (stack.length) return fail(`the ${stack[stack.length - 1]} it opens is not closed`);
9325
+ const tEnd = guardAt >= 0 ? guardAt : slash >= 0 ? slash : s.length;
9326
+ return { trigger: trPart(s.slice(0, tEnd)), guard: guardAt >= 0 ? trPart(s.slice(guardAt + 1, guardEnd)) : undefined, effect: slash >= 0 ? trPart(s.slice(slash + 1)) : undefined, slash };
9327
+ }
9328
+ function parseTransition(text, form) { const r = trScan(text, form); return r.error ? r : { trigger: r.trigger, guard: r.guard, effect: r.effect }; }
9329
+
9330
+ // the parts as drawn: "trigger [guard] / effect", each part left out when absent. A text the parser may read otherwise
9331
+ // (a host wrote "[" or "/" into a trigger) is drawn so too: printTransition says whether it reads back
9332
+ const transitionText = p => [trPart(p && p.trigger), trPart(p && p.guard) && `[${trPart(p.guard)}]`, trPart(p && p.effect) && `/ ${trPart(p.effect)}`].filter(Boolean).join(' ');
9333
+
9334
+ // the text that reads back as the same parts (trimmed, an empty part absent), or null when none does (§7.1): the line is
9335
+ // then drawn by transitionText, warned of by label-syntax, and edited part by part in the properties panel
9336
+ function printTransition(p) {
9337
+ const t = transitionText(p), r = parseTransition(t);
9338
+ return !r.error && ['trigger', 'guard', 'effect'].every(k => r[k] === trPart(p && p[k])) ? t : null;
9339
+ }
9340
+ /* ---------- control nodes, actions and flows: what the activity, state machine and interaction overview share (work
9341
+ package 4.2, notes/stage-4.md §4.1, §7.2, §9.3) ----------
9342
+ Six shapes, each with size(n), out(n, d), inset(n, side, t), face(n), ports(n, side, k) and draw(n, c, f), as SHAPE.note
9343
+ is (src/uml/class.js). The router reads none of them (Q14): a line ends on the node's box, and the typed line drawer
9344
+ slides each end onto the outline by inset (§4.2). Strokes are --um-muted, fills the stage's background or --um-text (T2),
9345
+ whatever the kind's colours c and f; every text is cut with fit, so none overflows. A node's accessible name is n.aria,
9346
+ which its type writes, else the shape's own word. Hit areas are 24 px at least (E8).
9347
+ - initial: a filled 20 px circle (initial node, initial pseudostate);
9348
+ - final: a ring round a filled circle, 22 px (activity final, final state);
9349
+ - flow-final: a circle with an X, 22 px;
9350
+ - diamond: 28 px with no text; with n.label, grown to hold it on at most two lines of 11 px, each cut at 160 px (S8);
9351
+ - bar: 6 px thick across the flow and max(48, 16 × (n.busy + 1)) long (n.busy: the lines on its busiest side), lying
9352
+ across (L × 6) when n.down, the view being laid out down, else 6 × L; its type sets every line's fromSide or toSide;
9353
+ - rounded: a rectangle with 10 px corners, n.stereotypes «…» over n.label, which wraps to three lines of 180 px and
9354
+ is then cut, 96 to 320 px wide (action, state; their types draw their glyphs and compartments over it).
9355
+ Then the flow rules both the activity and the overview check (§9.3, at S21's levels) and a flow's label (§7.3).
9356
+ Ships in lib/umlimator-all.js only. */
9357
+
9358
+ const FL_OBJ = x => x !== null && typeof x === 'object' && !Array.isArray(x), FL_ARR = x => (Array.isArray(x) ? x : []);
9359
+ const FL = { line: 'var(--um-muted)', ink: 'var(--um-text)', bg: 'var(--um-stage-bg)', hit: 12, px: 11, lh: 14, dmax: 160, name: 12, nlh: 16, nmax: 180, st: 11, sth: 14, padX: 10, padY: 8, minW: 96, maxW: 320, minH: 36 };
9360
+
9361
+ const flowNode = (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>`;
9362
+ const flowSvgText = (x, y, t, px, wt, fill, cls) => `<text x="${r2(x)}" y="${r2(y)}" text-anchor="middle" class="${cls}" fill="${fill}" style="font:${wt} ${px}px var(--um-sans)">${esc(t)}</text>`;
9363
+ const flowBody = (d, fill) => `<path d="${d}" class="n-mask"/><path d="${d}" class="n-body" fill="${fill}" stroke="${FL.line}"/>`;
9364
+
9365
+ // t wrapped at max px into at most k lines, the last holding the rest, each cut to max (a word longer than a line too)
9366
+ function flowWrap(t, px, max, k) {
9367
+ const out = []; let line = '';
9368
+ String(t ?? '').split(/\s+/).filter(Boolean).forEach(w => { const next = line ? line + ' ' + w : w;
9369
+ if (line && textW(next, px) > max) { out.push(line); line = w; } else line = next; });
9370
+ if (line) out.push(line);
9371
+ if (out.length > k) out.splice(k - 1, out.length, out.slice(k - 1).join(' '));
9372
+ return out.map(l => fit(l, px, max, true));
9373
+ }
9374
+
9375
+ /* ---- the circles: a filled one, a ring round a dot, one with an X ---- */
9376
+ const circleOut = (n, d = 0) => { const r = n.w / 2 + d, cx = n.x + n.w / 2, cy = n.y + n.h / 2;
9377
+ return `M${r2(cx - r)} ${r2(cy)}A${r2(r)} ${r2(r)} 0 1 0 ${r2(cx + r)} ${r2(cy)}A${r2(r)} ${r2(r)} 0 1 0 ${r2(cx - r)} ${r2(cy)}Z`; };
9378
+ // how far inside the box's side the circle is at t (beyond its span, at its centre line)
9379
+ const circleIn = (n, s, t) => { const r = n.w / 2, u = t - (s === 'L' || s === 'R' ? n.y + n.h / 2 : n.x + n.w / 2); return r * (1 - Math.sqrt(Math.max(0, 1 - (u / r) ** 2))); };
9380
+ const circleHit = n => `<circle cx="${r2(n.x + n.w / 2)}" cy="${r2(n.y + n.h / 2)}" r="${FL.hit}" class="fl-hit" fill="transparent"/>`;
9381
+ const circle = (shape, D, word, body) => ({ size: () => ({ w: D, h: D }), out: circleOut, inset: circleIn, draw: n => flowNode(n, shape, word, circleHit(n) + body(n)) });
9382
+ const dot = (n, r) => `<circle cx="${r2(n.x + n.w / 2)}" cy="${r2(n.y + n.h / 2)}" r="${r2(r)}" class="fl-dot" fill="${FL.ink}"/>`;
9383
+ SHAPE.initial = circle('initial', 20, 'Initial node', n => `<path d="${circleOut(n)}" class="n-mask"/><path d="${circleOut(n)}" class="n-body" fill="${FL.ink}" stroke="${FL.ink}"/>`);
9384
+ SHAPE.final = circle('final', 22, 'Final node', n => flowBody(circleOut(n), FL.bg) + dot(n, n.w / 2 - 5));
9385
+ SHAPE['flow-final'] = circle('flow-final', 22, 'Flow final', n => { const cx = n.x + n.w / 2, cy = n.y + n.h / 2, k = n.w / 2 * Math.SQRT1_2;
9386
+ return flowBody(circleOut(n), FL.bg) + `<path d="M${r2(cx - k)} ${r2(cy - k)}L${r2(cx + k)} ${r2(cy + k)}M${r2(cx + k)} ${r2(cy - k)}L${r2(cx - k)} ${r2(cy + k)}" class="n-det" stroke="${FL.line}"/>`; });
9387
+
9388
+ /* ---- the diamond: its text on at most two lines, the diamond twice the text's box, so the box fits inside it ---- */
9389
+ const diamondLines = n => { const t = String(n.label ?? '').trim(); return !t ? [] : textW(t, FL.px) <= 80 ? [fit(t, FL.px, FL.dmax)] : wrap2(t, FL.px, FL.dmax) || [fit(t, FL.px, FL.dmax)]; };
9390
+ SHAPE.diamond = {
9391
+ size(n) { const ls = diamondLines(n), tw = Math.max(0, ...ls.map(l => textW(l, FL.px)));
9392
+ return ls.length ? { w: Math.max(28, Math.ceil(2 * tw + 12)), h: Math.max(28, Math.ceil(2 * ls.length * FL.lh + 4)) } : { w: 28, h: 28 }; },
9393
+ out(n, d = 0) { const cx = n.x + n.w / 2, cy = n.y + n.h / 2, a = n.w / 2 + d, b = n.h / 2 + d;
9394
+ return `M${r2(cx)} ${r2(cy - b)}L${r2(cx + a)} ${r2(cy)}L${r2(cx)} ${r2(cy + b)}L${r2(cx - a)} ${r2(cy)}Z`; },
9395
+ inset(n, s, t) { const a = n.w / 2, b = n.h / 2;
9396
+ return s === 'L' || s === 'R' ? a * Math.min(1, Math.abs(t - n.y - b) / b) : b * Math.min(1, Math.abs(t - n.x - a) / a); },
9397
+ draw(n) { const ls = diamondLines(n), cx = n.x + n.w / 2, y0 = n.y + n.h / 2 - ls.length * FL.lh / 2;
9398
+ return flowNode(n, 'diamond', String(n.label ?? '').trim() ? `Decision ${String(n.label).trim()}` : 'Decision', flowBody(SHAPE.diamond.out(n), FL.bg)
9399
+ + ls.map((l, i) => flowSvgText(cx, y0 + i * FL.lh + 10.5, l, FL.px, 500, FL.ink, 'c-name')).join('')); },
9400
+ };
9401
+
9402
+ /* ---- the bar: a fork or a join, with a band 24 px thick to hit ---- */
9403
+ SHAPE.bar = {
9404
+ size(n) { const L = Math.max(48, 16 * ((Number.isFinite(n.busy) ? n.busy : 0) + 1)); return n.down ? { w: L, h: 6 } : { w: 6, h: L }; },
9405
+ out: (n, d = 0) => `M${r2(n.x - d)} ${r2(n.y - d)}H${r2(n.x + n.w + d)}V${r2(n.y + n.h + d)}H${r2(n.x - d)}Z`,
9406
+ inset: () => 0,
9407
+ draw(n) { const flat = n.w >= n.h, hit = flat ? [n.x, n.y + n.h / 2 - FL.hit, n.w, 2 * FL.hit] : [n.x + n.w / 2 - FL.hit, n.y, 2 * FL.hit, n.h];
9408
+ return flowNode(n, 'bar', 'Fork or join', `<rect x="${r2(hit[0])}" y="${r2(hit[1])}" width="${r2(hit[2])}" height="${r2(hit[3])}" class="fl-hit" fill="transparent"/>`
9409
+ + `<path d="${SHAPE.bar.out(n)}" class="n-mask"/><path d="${SHAPE.bar.out(n)}" class="n-body" fill="${FL.ink}" stroke="${FL.ink}"/>`); },
9410
+ };
9411
+
9412
+ /* ---- the rounded rectangle: stereotypes, then the name on up to three lines, centred ---- */
9413
+ const roundedParts = n => { const st = FL_ARR(n.stereotypes).filter(t => String(t ?? '').trim() !== '').map(t => fit(`«${t}»`, FL.st, FL.maxW - 2 * FL.padX)), ls = flowWrap(n.label, FL.name, FL.nmax, 3);
9414
+ return { st, ls, w: Math.ceil(Math.min(FL.maxW, Math.max(FL.minW, ...st.map(t => textW(t, FL.st) + 2 * FL.padX), ...ls.map(l => textW(l, FL.name) + 2 * FL.padX)))),
9415
+ h: Math.max(FL.minH, 2 * FL.padY + st.length * FL.sth + Math.max(1, ls.length) * FL.nlh) }; };
9416
+ SHAPE.rounded = {
9417
+ size: n => { const p = roundedParts(n); return { w: p.w, h: p.h }; },
9418
+ out: SHAPE.box.out, inset: SHAPE.box.inset,
9419
+ draw(n) { const { st, ls } = roundedParts(n), cx = n.x + n.w / 2, max = n.w - 2 * FL.padX;
9420
+ let y = n.y + (n.h - st.length * FL.sth - Math.max(1, ls.length) * FL.nlh) / 2;
9421
+ const tx = st.map(t => { const s = flowSvgText(cx, y + 11, fit(t, FL.st, max), FL.st, 400, FL.line, 'c-st'); y += FL.sth; return s; });
9422
+ ls.forEach(l => { tx.push(flowSvgText(cx, y + 12, fit(l, FL.name, max), FL.name, 600, FL.ink, 'c-name')); y += FL.nlh; });
9423
+ return flowNode(n, 'rounded', String(n.label ?? '').trim() ? `Action ${String(n.label).trim()}` : 'Action', flowBody(SHAPE.rounded.out(n), FL.bg) + tx.join('')); },
9424
+ };
9425
+ for (const k of ['initial', 'final', 'flow-final', 'diamond', 'bar', 'rounded']) { const S = SHAPE[k]; S.face = n => [n.y, n.y + n.h]; S.ports = (n, side, j = 1) => portsOn(S, n, side, j); }
9426
+
9427
+ /* ---- the flow rules (§9.3), at S21's levels: what UML forbids is an error, what is only unfinished a warning. Each
9428
+ problem is { level, rule, path, message, ids }, as in src/uml/class.js. flowProblems(model, view, ends) checks the
9429
+ model's flows that touch a node the view shows (all of them with no view), and the control nodes the view shows;
9430
+ ends: the node types a flow of the calling type may end on ---- */
9431
+ const FLOW_LINES = ['uml.ControlFlow', 'uml.ObjectFlow'];
9432
+ const FLOW_CONTROL = ['uml.InitialNode', 'uml.ActivityFinalNode', 'uml.FlowFinalNode', 'uml.DecisionNode', 'uml.MergeNode', 'uml.ForkNode', 'uml.JoinNode'];
9433
+ const FLOW_RULES = {
9434
+ 'flow-ends': 'a flow joins two of the diagram\'s control, action or object nodes, never a partition or a comment',
9435
+ 'initial-flows': 'an initial node has no incoming flow and one outgoing',
9436
+ 'final-flows': 'an activity final or a flow final has no outgoing flow',
9437
+ 'fork-join': 'a fork has one incoming flow and two or more outgoing; a join two or more incoming and one outgoing',
9438
+ 'decision-merge': 'a decision has one incoming flow and two or more outgoing; a merge two or more incoming and one outgoing',
9439
+ 'decision-guards': 'each flow out of a decision has a guard, and at most one is else',
9440
+ };
9441
+ const flowWord = t => String(t ?? '').split('.').pop().replace(/([a-z])([A-Z])/g, '$1 $2').toLowerCase(); // 'uml.ForkNode': "fork node"
9442
+ function flowProblems(model, view, ends) {
9443
+ const at = new Map(), out = [], shown = new Set(FL_ARR(view && view.nodeIds)), seen = id => !view || shown.has(id);
9444
+ FL_ARR(model && model.nodes).forEach((x, k) => { if (FL_OBJ(x) && !at.has(x.id)) at.set(x.id, [x, `nodes[${k}]`]); });
9445
+ const el = id => (at.get(id) || [])[0], nm = id => { const x = el(id); return x && x.label != null && x.label !== '' ? `${flowWord(x.type)} ${id} ("${x.label}")` : `${x ? flowWord(x.type) + ' ' : ''}${id}`; };
9446
+ const say = (rule, path, ids, msg, level = 'error') => out.push({ level, rule, path, ids: ids.filter(x => x != null), message: `${rule}: ${msg}` });
9447
+ const flows = FL_ARR(model && model.edges).map((e, k) => [e, `edges[${k}]`]).filter(([e]) => FL_OBJ(e) && FLOW_LINES.includes(e.type) && (seen(e.from) || seen(e.to)));
9448
+ const ins = new Map(), outs = new Map(), add = (m, id, e) => m.set(id, [...(m.get(id) || []), e]);
9449
+ flows.forEach(([e, path]) => { add(outs, e.from, e); add(ins, e.to, e);
9450
+ [e.from, e.to].forEach(id => { const x = el(id); if (x && !FL_ARR(ends).includes(x.type)) say('flow-ends', path, [e.id, id], `${e.id} ends on ${nm(id)}, which a flow may not end on`); }); });
9451
+ const list = es => es.map(e => e.id).join(', '), flowsN = (k, how) => `${k} ${how} flow${k === 1 ? '' : 's'}`;
9452
+ at.forEach(([x, path], id) => { if (!seen(id) || !FLOW_CONTROL.includes(x.type)) return;
9453
+ const i = ins.get(id) || [], o = outs.get(id) || [], ids = es => [id, ...es.map(e => e.id)];
9454
+ const many = (rule, es, how, most) => { if (es.length > most) say(rule, path, ids(es), `${nm(id)} has ${flowsN(es.length, how)} (${list(es)}); it may have ${most ? 'one' : 'none'}`); };
9455
+ const few = (rule, es, how) => { if (es.length < 2) say(rule, path, ids(es), `${nm(id)} has ${flowsN(es.length, how)}; it wants two or more`, 'warning'); };
9456
+ switch (x.type) {
9457
+ case 'uml.InitialNode': many('initial-flows', i, 'incoming', 0); many('initial-flows', o, 'outgoing', 1);
9458
+ if (!o.length) say('initial-flows', path, [id], `${nm(id)} has no outgoing flow yet`, 'warning'); break;
9459
+ case 'uml.ActivityFinalNode': case 'uml.FlowFinalNode': many('final-flows', o, 'outgoing', 0); break;
9460
+ case 'uml.ForkNode': many('fork-join', i, 'incoming', 1); few('fork-join', o, 'outgoing'); break;
9461
+ case 'uml.JoinNode': many('fork-join', o, 'outgoing', 1); few('fork-join', i, 'incoming'); break;
9462
+ case 'uml.MergeNode': many('decision-merge', o, 'outgoing', 1); few('decision-merge', i, 'incoming'); break;
9463
+ case 'uml.DecisionNode': { many('decision-merge', i, 'incoming', 1); few('decision-merge', o, 'outgoing');
9464
+ const g = e => (FL_OBJ(e.props) && e.props.guard != null ? String(e.props.guard).trim() : ''), bare = o.filter(e => !g(e)), elses = o.filter(e => g(e) === 'else');
9465
+ if (bare.length) say('decision-guards', path, ids(bare), `${flowsN(bare.length, 'outgoing')} of ${nm(id)} ${bare.length === 1 ? 'has' : 'have'} no guard (${list(bare)})`, 'warning');
9466
+ if (elses.length > 1) say('decision-guards', path, ids(elses), `${nm(id)} has ${elses.length} exits guarded else (${list(elses)}); at most one may be`, 'warning'); }
9467
+ }
9468
+ });
9469
+ return out;
9470
+ }
9471
+
9472
+ /* ---- a flow's label (§7.2, §7.3): "name [guard]", the name in the trigger's place; a guard alone "[guard]". F2's text is
9473
+ read by parseFlow, which refuses an effect, so a name holding / or a [ outside brackets is written in the properties
9474
+ panel; printFlow is null for such a name (it would not read back), and the line is drawn by flowText ---- */
9475
+ const flowText = (name, guard) => transitionText({ trigger: name, guard });
9476
+ const printFlow = (name, guard) => printTransition({ trigger: name, guard });
9477
+ function parseFlow(text) {
9478
+ const r = trScan(text, 'a flow is name [guard]');
9479
+ return r.error ? r : r.slash >= 0 ? { error: 'a flow is name [guard]: it has an effect; a name holding / is written in the properties panel' } : { name: r.trigger, guard: r.guard };
9480
+ }
9481
+ /* ---------- lanes (work package 4.3, notes/stage-4.md §5.2) ----------
9482
+ Bands across a layered flow, for the activity type's partitions (and later BPMN's lanes). No DOM, no model: a type
9483
+ gives the ranked positions and which lane each node is in, and gets rows back; after layoutTyped it gets the bands.
9484
+ Only lib/umlimator-all.js holds this file.
9485
+
9486
+ laneRows(pos, laneOf, lanes) -> pos' pos: { id: [col, row] } as ranked (pins included); laneOf: id -> its innermost
9487
+ lane's id, or null; lanes: [{ id, parent, order }] outermost first, siblings
9488
+ by `order`, those with none after them in list order. pos' is new: each lane a run of rows
9489
+ laneBands(g, lanes, laneOf, turned, rgap) -> { bands: [{ id, parent, depth, x, y, w, h, head: { x, y, w, h } }], shift, bounds }
9490
+ g: layoutTyped's geometry, read, never written (§2.4); turned: laid out down;
9491
+ rgap: the row gap it was laid out with (else the narrowest gap between two rows).
9492
+ bands are where they lie once the type has moved its copy of g by shift ({ x, y });
9493
+ bounds: g's bounds so moved and grown to hold the bands
9494
+ Rows: lanes in depth-first order, a lane's own nodes in the rows before its nested lanes'; in each column a lane's nodes
9495
+ keep their ranked order and take its rows from the first; a lane takes as many rows as it has nodes in its busiest
9496
+ column, an empty lane one. A pin's row is so an order inside its lane, and nodes in no lane come after every lane.
9497
+ Bands: along the flow, the whole diagram and a 22 px header strip per level of nesting at its start; across it, from the
9498
+ divider before a lane's first row to the one after its last, each 12 px past the router's line rgap / 2 before the later
9499
+ row (gapLinesY), toward it; the outer edges 12 px inside the router's line. An empty lane at an end takes a row that
9500
+ leaves its band 120 px across at least. */
9501
+
9502
+ const LN = { head: 22, off: 12, pad: 16, gap: 44, row: 64, min: 120 }; // a header level; a divider off the router's line; the bands' margin; the row gap when g has none to measure; an empty end row's least size; an empty end lane's least width
9503
+
9504
+ // the lanes in depth-first order, each { id, parent, depth }: a lane whose parent is no lane, or that lies on a cycle of
9505
+ // parents, is at the top
9506
+ function laneOrder(lanes) {
9507
+ const list = (Array.isArray(lanes) ? lanes : []).filter(l => l && l.id != null), ids = new Set(list.map(l => l.id)), kids = new Map(), out = [], seen = new Set();
9508
+ const has = l => Number.isFinite(l.order); // by order, those with none after them; a stable sort keeps list order in ties
9509
+ [...list].sort((a, b) => has(b) - has(a) || (has(a) && has(b) ? a.order - b.order : 0)).forEach(l => { const p = ids.has(l.parent) && l.parent !== l.id ? l.parent : null; if (!kids.has(p)) kids.set(p, []); kids.get(p).push(l); });
9510
+ const walk = (top) => { const stack = [[top, 0]]; // an explicit stack: nesting from an import can be deep
9511
+ while (stack.length) { const [l, depth] = stack.pop(); if (seen.has(l.id)) continue; seen.add(l.id); out.push({ id: l.id, parent: depth ? l.parent : null, depth });
9512
+ (kids.get(l.id) || []).slice().reverse().forEach(k => stack.push([k, depth + 1])); } };
9513
+ (kids.get(null) || []).forEach(walk);
9514
+ list.forEach(walk); // on a cycle: never reached from the top
9515
+ return out;
9516
+ }
9517
+
9518
+ // each node's segment (a lane's index in order, or order.length for no lane) and each segment's first row and row count
9519
+ function laneRuns(items, laneOf, order) { // items: [[id, col]]
9520
+ const at = new Map(order.map((l, k) => [l.id, k])), seg = new Map(), most = order.map(() => 0).concat(0), per = new Map();
9521
+ items.forEach(([id, col]) => { const s = at.get(laneOf(id)) ?? order.length, k = s + '|' + col, n = (per.get(k) || 0) + 1; seg.set(id, s); per.set(k, n); most[s] = Math.max(most[s], n); });
9522
+ const full = new Set(); // the lanes that hold a node or a nested lane; the others are empty
9523
+ order.forEach((l, s) => { if (most[s]) full.add(l.id); if (l.parent != null) full.add(l.parent); });
9524
+ const count = most.map((n, s) => (s < order.length && !full.has(order[s].id) ? 1 : n)), start = []; let r = 0; // an empty lane keeps one row
9525
+ count.forEach((n, s) => { start[s] = r; r += n; });
9526
+ return { seg, start, count, rows: r };
9527
+ }
9528
+
9529
+ function laneRows(pos, laneOf, lanes) {
9530
+ const order = laneOrder(lanes), ids = Object.keys(pos || {}).filter(id => Array.isArray(pos[id]));
9531
+ const { seg, start } = laneRuns(ids.map(id => [id, pos[id][0]]), laneOf, order), out = Object.create(null), cols = new Map(); // by id: no prototype (M3)
9532
+ ids.forEach((id, i) => { const c = pos[id][0]; if (!cols.has(c)) cols.set(c, []); cols.get(c).push([id, i]); });
9533
+ cols.forEach(list => { const next = new Map();
9534
+ list.sort((a, b) => seg.get(a[0]) - seg.get(b[0]) || pos[a[0]][1] - pos[b[0]][1] || a[1] - b[1]).forEach(([id]) => { const s = seg.get(id), k = next.get(s) || 0;
9535
+ out[id] = [pos[id][0], start[s] + k]; next.set(s, k + 1); }); });
9536
+ return out;
9537
+ }
9538
+
9539
+ function laneBands(g, lanes, laneOf, turned, rgap) {
9540
+ const order = laneOrder(lanes), nodes = (g && g.nodes) || [], b = (g && g.bounds) || { x: 0, y: 0, w: 0, h: 0 };
9541
+ const [A, S, L, T] = turned ? ['x', 'w', 'y', 'h'] : ['y', 'h', 'x', 'w']; // across the flow, its size; along it, its size
9542
+ const { start, count, rows } = laneRuns(nodes.map(n => [n.id, n.col]), laneOf, order);
9543
+ // each row's extent across the flow: from its nodes, else between its neighbours; a gap is the narrowest between two rows
9544
+ const lo = [], hi = [];
9545
+ nodes.forEach(n => { const r = n.row; if (!Number.isInteger(r)) return; lo[r] = Math.min(lo[r] ?? Infinity, n[A]); hi[r] = Math.max(hi[r] ?? -Infinity, n[A] + n[S]); });
9546
+ let gap = Infinity; for (let r = 0; r + 1 < rows; r++) if (lo[r] != null && lo[r + 1] != null) gap = Math.min(gap, lo[r + 1] - hi[r]);
9547
+ if (gap === Infinity) gap = LN.gap;
9548
+ if (Number.isFinite(rgap) && rgap > 0) gap = rgap;
9549
+ const end = Math.max(LN.row, LN.min + 2 * LN.off - gap); // an empty end row: its band, the row and a gap less two offsets, 120 px at least
9550
+ for (let r = 0; r < rows;) { if (lo[r] != null) { r++; continue; }
9551
+ let e = r; while (e < rows && lo[e] == null) e++; // rows r to e - 1 hold nothing
9552
+ if (r && e < rows) { const unit = (lo[e] - hi[r - 1] - gap) / (e - r); for (let k = r; k < e; k++) { lo[k] = hi[r - 1] + gap + (k - r) * unit; hi[k] = lo[k] + unit - gap; } }
9553
+ else if (e < rows) for (let k = e - 1; k >= r; k--) { hi[k] = lo[k + 1] - gap; lo[k] = hi[k] - end; }
9554
+ else for (let k = r; k < e; k++) { lo[k] = k ? hi[k - 1] + gap : 0; hi[k] = lo[k] + end; }
9555
+ r = e; }
9556
+ const before = r => lo[r] - gap / 2 + LN.off, after = r => (r + 1 < rows ? before(r + 1) : hi[r] + gap / 2 - LN.off); // off the router's lines, which lie rgap / 2 from each row
9557
+ // each lane's rows: its own and its nested lanes'
9558
+ const first = start.slice(0, order.length), last = order.map((l, s) => start[s] + count[s] - 1), at = new Map(order.map((l, s) => [l.id, s]));
9559
+ order.forEach((l, s) => { for (let p = order[at.get(l.parent)]; p; p = order[at.get(p.parent)]) { const q = at.get(p.id); first[q] = Math.min(first[q], first[s]); last[q] = Math.max(last[q], last[s]); } });
9560
+ const levels = order.length ? 1 + Math.max(...order.map(l => l.depth)) : 0, shift = { x: 0, y: 0, [L]: levels * LN.head };
9561
+ const a0 = b[L] + LN.pad, a1 = b[L] + b[T] + shift[L] - LN.pad;
9562
+ const bands = order.map((l, s) => { const c0 = before(first[s]), c1 = after(last[s]), s0 = a0 + l.depth * LN.head;
9563
+ const box = len => ({ [L]: r2(s0), [T]: r2(len), [A]: r2(c0), [S]: r2(c1 - c0) });
9564
+ return { id: l.id, parent: l.parent, depth: l.depth, ...box(a1 - s0), head: box(LN.head) }; });
9565
+ const x0 = Math.min(b.x + shift.x, ...bands.map(d => d.x - LN.pad)), y0 = Math.min(b.y + shift.y, ...bands.map(d => d.y - LN.pad));
9566
+ const x1 = Math.max(b.x + b.w + shift.x, ...bands.map(d => d.x + d.w + LN.pad)), y1 = Math.max(b.y + b.h + shift.y, ...bands.map(d => d.y + d.h + LN.pad));
9567
+ return { bands, shift, bounds: { x: r2(x0), y: r2(y0), w: r2(x1 - x0), h: r2(y1 - y0) } };
9568
+ }
9569
+ /* ---------- the use case diagram, uml.use-case (work package 4.4, notes/stage-4.md §8) ----------
9570
+ Actors (a stick figure), use cases (an ellipse, its extension points in a compartment under the name) and subjects: a
9571
+ subject with a shown use case is a box (src/uml/owners.js's look), else a rectangle with its name; associations, includes,
9572
+ extends (their points by name, then their condition) and generalizations; comments. Laid out by layoutTyped from the
9573
+ host's ranking, a generalization and an extend turned so the parent and the base come first; repair and reroute off
9574
+ (§17). Line ends are slid onto the ellipse and the figure's column when drawn (src/typed/draw.js). Ships in
9575
+ lib/umlimator-all.js only. */
9576
+
9577
+ const UC_OBJ = x => x !== null && typeof x === 'object' && !Array.isArray(x), UC_ARR = x => (Array.isArray(x) ? x : []);
9578
+ const UC_HAS = x => x != null && x !== '';
9579
+ const UC_PROPS = x => (UC_OBJ(x && x.props) ? x.props : {});
9580
+ const ucName = x => String(x.label ?? x.id);
9581
+ const ucStereo = x => UC_ARR(UC_PROPS(x).stereotypes).filter(UC_HAS).map(String);
9582
+ const ucPlural = (k, w) => `${k} ${k === 1 ? w : w + 's'}`;
9583
+ const UC_LINES = ['uml.Association', 'uml.Include', 'uml.Extend', 'uml.Generalization', 'uml.CommentLink'];
9584
+ const ucPointsOf = n => UC_ARR(n.members).filter(m => UC_OBJ(m) && m.type === 'uml.ExtensionPoint');
9585
+
9586
+ /* ---- the shapes (§4.1): ellipse and actor; a subject's frame ---- */
9587
+ const UC = { px: 12, lh: 16, max: 160, st: 10, sth: 13, ep: 11, eph: 15, head: 14, gap: 6, k: 1.42, minW: 96, minH: 44 };
9588
+ // a text in lines of at most max px, at most k lines: the last holds the rest, cut when drawn
9589
+ function ucWrap(t, px, max, k) {
9590
+ const out = []; String(t ?? '').split(/\s+/).filter(Boolean).forEach(w => { const l = out.length ? out[out.length - 1] : null;
9591
+ if (l != null && (out.length === k || textW(l + ' ' + w, px) <= max)) out[out.length - 1] = l + ' ' + w; else out.push(w); });
9592
+ return out.length ? out : [''];
9593
+ }
9594
+ // a use case's texts: its stereotypes, its name on at most three lines, then its extension points' heading and names
9595
+ function ucEllParts(n) {
9596
+ const st = UC_ARR(n.stereotypes).map(t => `«${t}»`), name = ucWrap(n.label, UC.px, UC.max, 3), eps = UC_ARR(n.points);
9597
+ const w = Math.min(UC.max, Math.max(...st.map(t => textW(t, UC.st)), ...name.map(l => textW(l, UC.px)), ...(eps.length ? [textW('extension points', UC.st), ...eps.map(p => textW(p, UC.ep))] : [])));
9598
+ const h = st.length * UC.sth + name.length * UC.lh + (eps.length ? UC.gap + UC.head + eps.length * UC.eph : 0);
9599
+ return { st, name, eps, w, h };
9600
+ }
9601
+ // an ellipse's depth inside its box's side at t (src/typed/draw.js slides a line's end by it)
9602
+ const ucEllDip = (n, s, t) => { const v = s === 'L' || s === 'R', a = v ? n.w / 2 : n.h / 2, r = v ? n.h / 2 : n.w / 2, u = t - (v ? n.y + n.h / 2 : n.x + n.w / 2);
9603
+ return a * (1 - Math.sqrt(Math.max(0, 1 - (u / r) ** 2))); };
9604
+ const ucEllPath = (n, d = 0) => { const rx = n.w / 2 + d, ry = n.h / 2 + d, cy = r2(n.y + n.h / 2);
9605
+ return `M${r2(n.x - d)} ${cy}A${r2(rx)} ${r2(ry)} 0 1 0 ${r2(n.x + n.w + d)} ${cy}A${r2(rx)} ${r2(ry)} 0 1 0 ${r2(n.x - d)} ${cy}Z`; };
9606
+ const ucText = (x, y, px, wt, fill, t, max, more = '') => `<text x="${r2(x)}" y="${r2(y)}" text-anchor="middle"${more} fill="${fill}" style="font:${wt} ${px}px var(--um-sans)">${esc(fit(t, px, max))}</text>`;
9607
+ const ucNodeG = (n, cls, inner) => `<g class="node ${cls}" data-id="${esc(n.id)}" data-shape="${n.shape}" tabindex="-1" role="img" aria-label="${esc(n.aria || n.label || '')}">${inner}</g>`;
9608
+ SHAPE.ellipse = {
9609
+ // the text block, 1.42 (about √2) times as wide and as tall: a box that size fits inside the ellipse at its corners
9610
+ size: n => { const p = ucEllParts(n); return { w: Math.ceil(Math.max(UC.minW, p.w * UC.k + 16)), h: Math.ceil(Math.max(UC.minH, p.h * UC.k + 12)) }; },
9611
+ out: (n, d = 0) => ucEllPath(n, d), face: n => [n.y, n.y + n.h], inset: ucEllDip,
9612
+ draw(n, c, f) {
9613
+ const p = ucEllParts(n), cx = n.x + n.w / 2, cy = n.y + n.h / 2, max = p.w + 1; let y = cy - p.h / 2;
9614
+ const out = [`<path d="${ucEllPath(n)}" class="n-mask"/><path d="${ucEllPath(n)}" class="n-body" fill="${f}" stroke="${c}"/>`], hd = [];
9615
+ p.st.forEach(t => { hd.push(ucText(cx, y + 10, UC.st, 400, 'var(--um-muted)', t, max, ' class="c-st"')); y += UC.sth; });
9616
+ p.name.forEach(l => { hd.push(ucText(cx, y + 12, UC.px, 500, 'var(--um-text)', l, max, ` class="c-name" data-o="${esc(n.id)}"`)); y += UC.lh; });
9617
+ out.push(`<g class="c-head" data-band="${r2(n.y)} ${r2(y)}">${hd.join('')}</g>`);
9618
+ if (p.eps.length) { const dy = y + UC.gap / 2, half = n.w / 2 * Math.sqrt(Math.max(0, 1 - ((dy - cy) / (n.h / 2)) ** 2)), rows = [];
9619
+ rows.push(ucText(cx, dy + UC.head - 2, UC.st, 400, 'var(--um-muted)', 'extension points', max, ' class="c-st"'));
9620
+ p.eps.forEach((t, i) => rows.push(ucText(cx, dy + UC.head + i * UC.eph + 12, UC.ep, 400, 'var(--um-text)', t, max, ' class="c-row"')));
9621
+ out.push(`<g class="c-comp" data-comp="extension points" data-band="${r2(dy)} ${r2(n.y + n.h)}"><path d="M${r2(cx - half)} ${r2(dy)}H${r2(cx + half)}" class="n-det" stroke="${c}"/>${rows.join('')}</g>`); }
9622
+ return ucNodeG(n, 'usecase', out.join(''));
9623
+ },
9624
+ };
9625
+ // an actor: a stick figure 24 × 44, its stereotypes and name (two lines of 160 px at most) under it; the node as wide as the wider
9626
+ const UC_AC = { fw: 24, fh: 44, gap: 4 };
9627
+ const ucActorParts = n => { const st = UC_ARR(n.stereotypes).map(t => `«${t}»`), name = ucWrap(n.label, UC.px, UC.max, 2);
9628
+ return { st, name, w: Math.min(UC.max, Math.max(UC_AC.fw, ...st.map(t => textW(t, UC.st)), ...name.map(l => textW(l, UC.px)))) }; };
9629
+ SHAPE.actor = {
9630
+ size: n => { const p = ucActorParts(n); return { w: Math.ceil(p.w + 4), h: UC_AC.fh + UC_AC.gap + p.st.length * UC.sth + p.name.length * UC.lh }; },
9631
+ out: (n, d = 0) => `M${r2(n.x - d)} ${r2(n.y - d)}H${r2(n.x + n.w + d)}V${r2(n.y + n.h + d)}H${r2(n.x - d)}Z`, face: n => [n.y, n.y + UC_AC.fh],
9632
+ inset: (n, s) => (s === 'L' || s === 'R' ? Math.max(0, (n.w - UC_AC.fw) / 2) : 0), // its box, narrowed to the figure's column on the left and right
9633
+ draw(n, c) {
9634
+ const p = ucActorParts(n), cx = r2(n.x + n.w / 2), y = n.y, max = n.w - 2; let ty = y + UC_AC.fh + UC_AC.gap; const hd = [];
9635
+ const fig = `<circle cx="${cx}" cy="${r2(y + 7)}" r="6.5"/><path d="M${cx} ${r2(y + 13.5)}V${r2(y + 29)}M${r2(cx - 11)} ${r2(y + 19)}H${r2(cx + 11)}M${r2(cx - 10)} ${r2(y + 43)}L${cx} ${r2(y + 29)}L${r2(cx + 10)} ${r2(y + 43)}"/>`;
9636
+ p.st.forEach(t => { hd.push(ucText(cx, ty + 10, UC.st, 400, 'var(--um-muted)', t, max, ' class="c-st"')); ty += UC.sth; });
9637
+ p.name.forEach(l => { hd.push(ucText(cx, ty + 12, UC.px, 500, 'var(--um-text)', l, max, ` class="c-name" data-o="${esc(n.id)}"`)); ty += UC.lh; });
9638
+ return ucNodeG(n, 'actor', `<rect x="${n.x}" y="${n.y}" width="${n.w}" height="${n.h}" class="n-mask" style="fill:transparent"/><g class="n-body" fill="none" stroke="${c}" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">${fig}</g>`
9639
+ + `<g class="c-head" data-band="${r2(y + UC_AC.fh)} ${r2(ty)}">${hd.join('')}</g>`);
9640
+ },
9641
+ };
9642
+ // a subject with something shown is a box: a rectangle on its border, its stereotypes and name in bold at the top left of its
9643
+ // header room (24 px). Closed, it is a classifier with its name only (src/typed/draw.js)
9644
+ SHAPE.subject = {
9645
+ frame: f => `<rect class="family-box" x="${f.x}" y="${f.y}" width="${f.w}" height="${f.h}" stroke="var(--um-muted)"/>`
9646
+ + `<text class="family-title" x="${r2(f.x + 12)}" y="${r2(f.y + 17)}" fill="var(--um-text)" style="font:600 12px var(--um-sans)" data-t="ft" data-o="${esc(f.id)}">${esc(fit(f.title ?? f.label ?? f.id, 12, f.w - 24))}</text>`,
9647
+ };
9648
+
9649
+ /* ---- the view read: its owners (§8.2), its lines, and every element's words ---- */
9650
+ const UC_LOOK = { boxTypes: ['uml.Subject', 'uml.Package'], qualify: false, shape: x => (x.type === 'uml.Subject' ? 'subject' : 'package'), word: x => (x.type === 'uml.Subject' ? 'Subject' : 'Package') };
9651
+ // the view's boxes, a subject's accessible name counting its use cases: "Subject Online bookshop, holding 8 use cases"
9652
+ function ucPic(model, view) {
9653
+ const pic = ownersOn(model, view, UC_LOOK);
9654
+ const boxes = pic.boxes.map(b => { const held = pic.holds(b.id), x = pic.at.get(b.id), st = ucStereo(x);
9655
+ return held.every(id => pic.at.get(id).type === 'uml.UseCase') ? { ...b, aria: `${UC_LOOK.word(x)} ${ucName(x)}${st.length ? ` (${st.join(', ')})` : ''}, holding ${ucPlural(held.length, 'use case')}` } : b; });
9656
+ return { ...pic, boxes };
9657
+ }
9658
+ // the lines drawn: those the view lists, or with no list the model's of this type's kinds, between two elements it shows; a
9659
+ // line from a box to what it holds is left out
9660
+ function ucLines(model, view, pic) {
9661
+ const E = new Map(); UC_ARR(model.edges).forEach(e => { if (UC_OBJ(e) && typeof e.id === 'string' && !E.has(e.id)) E.set(e.id, e); });
9662
+ const shown = new Set(pic.ids), list = Array.isArray(view.edges) ? view.edges.map(x => (typeof x === 'string' ? E.get(x) : x)) : [...E.values()].filter(e => UC_LINES.includes(e.type));
9663
+ return list.filter(e => UC_OBJ(e) && typeof e.id === 'string' && shown.has(e.from) && shown.has(e.to)
9664
+ && ![[e.from, e.to], [e.to, e.from]].some(([b, x]) => pic.open.has(b) && (b === x || pic.chain(x).includes(b))));
9665
+ }
9666
+ const UC_KEYWORD = { 'uml.Include': '«include»', 'uml.Extend': '«extend»' };
9667
+ // an extend's points by name and its condition: "(payment) [the shopper has a voucher code]"
9668
+ function ucExtendParts(model, e) {
9669
+ const to = UC_ARR(model.nodes).find(n => UC_OBJ(n) && n.id === e.to), by = new Map(ucPointsOf(to || {}).map(m => [m.id, ucName(m)]));
9670
+ return { points: UC_ARR(UC_PROPS(e).extensionPoints).map(id => by.get(id) ?? String(id)), cond: UC_HAS(UC_PROPS(e).condition) ? String(UC_PROPS(e).condition) : null };
9671
+ }
9672
+ function ucLineLabel(model, e) {
9673
+ const x = e.type === 'uml.Extend' ? ucExtendParts(model, e) : null;
9674
+ return [UC_KEYWORD[e.type], ...ucStereo(e).map(t => `«${t}»`), e.label, x && x.points.length ? `(${x.points.join(', ')})` : '', x && x.cond ? `[${x.cond}]` : ''].filter(UC_HAS).join(' ');
9675
+ }
9676
+ const UC_LINE_WORD = { 'uml.Association': 'Association', 'uml.Include': 'Include', 'uml.Extend': 'Extend', 'uml.Generalization': 'Generalization', 'uml.CommentLink': 'Comment link' };
9677
+ const UC_LINE_LOOK = { 'uml.Association': { from: 'none', to: 'none' }, 'uml.Include': { from: 'none', to: 'open', dashed: true }, 'uml.Extend': { from: 'none', to: 'open', dashed: true },
9678
+ 'uml.Generalization': { from: 'none', to: 'triangle' }, 'uml.CommentLink': { from: 'none', to: 'none', dashed: true } };
9679
+ const ucLookOf = e => (Object.hasOwn(UC_LINE_LOOK, e.type) ? UC_LINE_LOOK[e.type] : { from: 'none', to: 'arrow' });
9680
+ // accessible names (§14): "Actor Shopper"; "Use case Check out, in Online bookshop, 2 extension points"
9681
+ function ucNodeName(model, n, pic) {
9682
+ if (n.type === 'uml.Comment') return 'Comment: ' + ucName(n);
9683
+ const b = pic.boxes.find(x => x.id === n.id); if (b) return b.aria;
9684
+ const st = ucStereo(n), why = st.length ? ` (${st.join(', ')})` : '', up = n.parent != null && pic.at.get(n.parent), k = ucPointsOf(n).length;
9685
+ if (n.type === 'uml.UseCase') return `Use case ${ucName(n)}${why}${up ? `, in ${ucName(up)}` : ''}${k ? `, ${ucPlural(k, 'extension point')}` : ''}`;
9686
+ if (n.type === 'uml.Package') return `Package ${ucName(n)} (${[...st, 'closed'].join(', ')})`;
9687
+ return `${n.type === 'uml.Actor' ? 'Actor' : n.type === 'uml.Subject' ? 'Subject' : String(n.type).split('.').pop()} ${ucName(n)}${why}`;
9688
+ }
9689
+ // "Extend from Redeem a voucher to Check out, at payment, when the shopper has a voucher code"
9690
+ function ucLineName(model, e, pic) {
9691
+ const nm = id => ucName(pic.at.get(id)), word = Object.hasOwn(UC_LINE_WORD, e.type) ? UC_LINE_WORD[e.type] : 'Line';
9692
+ if (e.type !== 'uml.Extend') { const said = [...ucStereo(e).map(t => `«${t}»`), e.label].filter(UC_HAS).join(' '); return `${word} from ${nm(e.from)} to ${nm(e.to)}${said ? ': ' + said : ''}`; }
9693
+ const { points, cond } = ucExtendParts(model, e);
9694
+ return `Extend from ${nm(e.from)} to ${nm(e.to)}${points.length ? `, at ${points.join(', ')}` : ''}${cond ? `, when ${cond}` : ''}`;
9695
+ }
9696
+ // reading order (§14): actors, then each subject (or package) and what it holds, then the rest, then the lines
9697
+ function ucOrder(pic, lines) {
9698
+ const top = pic.holds(null), at = id => pic.at.get(id).type, out = [], put = id => { out.push(id); if (pic.open.has(id)) pic.holds(id).forEach(put); };
9699
+ [...top.filter(id => at(id) === 'uml.Actor'), ...top.filter(id => at(id) === 'uml.Subject' || at(id) === 'uml.Package'), ...top.filter(id => !['uml.Actor', 'uml.Subject', 'uml.Package'].includes(at(id)))].forEach(put);
9700
+ return [...out, ...lines.map(e => e.id)];
9701
+ }
9702
+
9703
+ /* ---- the legend: each line kind drawn. ponytail: copies class.js's legendItem and ends, which it does not export ---- */
9704
+ const UC_ENDS = { none: '', open: '<path d="M17 4L26 8L17 12" fill="none"/>', triangle: '<path d="M16 3L26 8L16 13Z" fill="var(--um-panel)"/>' };
9705
+ const UC_SAYS = { 'uml.Association': 'an actor takes part in the use case', 'uml.Include': 'the base always does the included use case',
9706
+ 'uml.Extend': 'adds to the base at its extension points, when the condition holds', 'uml.Generalization': 'is a kind of the one it points to', 'uml.CommentLink': 'what a comment is about' };
9707
+ const ucLegend = edges => Object.keys(UC_SAYS).filter(t => edges.some(e => e.type === t)).map(t => { const L = UC_LINE_LOOK[t], name = UC_LINE_WORD[t], says = UC_SAYS[t];
9708
+ 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 8H${L.to === 'none' ? 26 : 17}"${L.dashed ? ' stroke-dasharray="3 2"' : ''}/>${UC_ENDS[L.to]}</g></svg><b>${esc(name)}</b><span>${esc(says)}</span></span>`; });
9709
+
9710
+ /* ---- the view laid out (§8.1) ---- */
9711
+ // actors first: each actor the view shows outside a box goes to a column before the rest, in the order of the rows of the
9712
+ // use cases it is associated with, a specialization after its general; a pinned actor keeps its row. The rest keep the host's
9713
+ // places, so a pin, read in the host's ranking, is where it was drawn and moves no other node. pos is the host's ranking, kept
9714
+ // (a cache holds it): a new object is returned
9715
+ function ucActorsFirst(pos, view, pic, lines) {
9716
+ const A = pic.holds(null).filter(id => pic.at.get(id).type === 'uml.Actor' && Object.hasOwn(pos, id)), rest = Object.keys(pos).filter(id => !A.includes(id) && pic.ids.includes(id) && !pic.open.has(id));
9717
+ if (!A.length || !rest.length) return pos;
9718
+ const L = UC_OBJ(view.layout) ? view.layout : {}, pinned = A.filter(id => Array.isArray(L[id])), c = Math.min(...rest.map(id => pos[id][0])) - 1;
9719
+ const at = id => { const r = lines.filter(e => e.type === 'uml.Association' && (e.from === id || e.to === id)).map(e => (e.from === id ? e.to : e.from)).filter(x => Object.hasOwn(pos, x)).map(x => pos[x][1]); // a box takes no slot
9720
+ return r.length ? r.reduce((a, b) => a + b) / r.length : pos[id][1]; };
9721
+ const up = new Map(lines.filter(e => e.type === 'uml.Generalization' && A.includes(e.from) && A.includes(e.to) && e.from !== e.to).map(e => [e.from, e.to]));
9722
+ const order = [], put = (id, seen = new Set()) => { if (order.includes(id) || seen.has(id)) return; seen.add(id); if (up.has(id)) put(up.get(id), seen); order.push(id); };
9723
+ A.filter(id => !pinned.includes(id)).sort((a, b) => at(a) - at(b)).forEach(id => put(id));
9724
+ const out = Object.fromEntries([...rest.map(id => [id, pos[id]]), ...pinned.map(id => [id, [c, pos[id][1]]])]);
9725
+ order.filter(id => !pinned.includes(id)).reduce((r, id) => { let y = Math.max(r, at(id)); while (pinned.some(p => Math.abs(out[p][1] - y) < 1)) y++; out[id] = [c, y]; return y + 1; }, -Infinity);
9726
+ return out;
9727
+ }
9728
+ function ucGeo({ model, view, pos: ranked, memo }) {
9729
+ const pic = ucPic(model, view), lines = ucLines(model, view, pic), pos = ucActorsFirst(ranked || {}, view, pic, lines), P = id => (Object.hasOwn(pos, id) ? pos[id] : [0, 0]);
9730
+ const nodes = pic.ids.filter(id => !pic.open.has(id)).map(id => { const n = pic.at.get(id), st = ucStereo(n);
9731
+ const shape = n.type === 'uml.Comment' ? { shape: 'note' } : n.type === 'uml.Package' ? { shape: 'package', stereotypes: st } : n.type === 'uml.Actor' ? { shape: 'actor', stereotypes: st }
9732
+ : n.type === 'uml.UseCase' ? { shape: 'ellipse', stereotypes: st, points: ucPointsOf(n).map(ucName) } : { shape: 'classifier', stereotypes: st, compartments: [] }; // a closed subject, or another node
9733
+ const d = { id, label: ucName(n), type: n.type || 'uml.UseCase', kind: 'file', ...shape, aria: ucNodeName(model, n, pic), col: P(id)[0], row: P(id)[1] };
9734
+ return { ...d, ...SHAPE[d.shape].size(d) }; });
9735
+ const edges = lines.map(e => { const label = ucLineLabel(model, e);
9736
+ return { id: e.id, _id: e.id, type: e.type, from: e.from, to: e.to, label: label || undefined, end: ucLookOf(e), aria: ucLineName(model, e, pic),
9737
+ ...(e.type === 'uml.Extend' ? { cut: 280 } : {}), ...(UC_OBJ(e.style) || typeof e.style === 'string' ? { style: e.style } : {}) }; });
9738
+ // as the class view (class.js typeGeo): laid out right with boxes, the columns spread by the widest label
9739
+ const down = view.direction === 'down', wide = !down && pic.boxes.length ? Math.max(0, ...edges.filter(e => e.label).map(labelW)) : 0;
9740
+ const g = placeTabs(layoutTyped({ nodes, edges, ...(pic.boxes.length ? { containers: pic.boxes } : {}), direction: down ? 'down' : 'right',
9741
+ gap: 96 + (wide ? Math.ceil(wide) + 40 : 0), rgap: 56, repair: false, reroute: false }, memo));
9742
+ // the actors' column is -1 when the host put a node in column 0; the canvas pins no column under 0, and an actor's pinned column
9743
+ // is not read, so it says 0. ponytail: a drop then reads the actors' and that column's places as one column (render.js), and
9744
+ // an actor's pin can push a node of that column down a row (the host's fillLayout); a rank hook in the host would end both
9745
+ const ns = g.nodes.some(n => n.col < 0) ? g.nodes.map(n => (n.col < 0 ? { ...n, col: 0 } : n)) : null;
9746
+ return { ...g, ...(ns ? { nodes: ns, byId: Object.fromEntries(ns.map(n => [n.id, n])) } : {}), legend: ucLegend(g.edges), order: ucOrder(pic, lines) }; // a new object: what layoutTyped returned is never edited (§2.4)
9747
+ }
9748
+
9749
+ /* ---- validators (§8.3). Each problem: { level, rule, path, message, ids } ---- */
9750
+ const UC_RULES = {
9751
+ 'association-ends': 'an association joins an actor and a use case',
9752
+ 'include-ends': 'an include runs from a use case to another use case',
9753
+ 'extend-ends': 'an extend runs from a use case to another use case',
9754
+ 'extend-points': 'an extend names at least one extension point, each of the use case it extends',
9755
+ 'generalization-kinds': 'a generalization joins two actors or two use cases',
9756
+ 'use-case-owner': 'a use case is owned by a subject or a package, an actor by a package or nothing',
9757
+ 'include-cycle': 'no use case includes itself, through other includes or directly',
9758
+ named: 'every actor and use case has a name',
9759
+ 'point-names': 'a use case\'s extension points have different names',
9760
+ };
9761
+ const UC_KINDS = ['uml.Actor', 'uml.UseCase'];
9762
+ function ucProblems(model) {
9763
+ const out = [], at = new Map(), say = (level, rule, path, ids, msg) => out.push({ level, rule, path, ids: ids.filter(x => x != null), message: `${rule}: ${msg}` });
9764
+ UC_ARR(model && model.nodes).forEach(n => { if (UC_OBJ(n) && typeof n.id === 'string' && !at.has(n.id)) at.set(n.id, n); });
9765
+ const ty = id => (at.get(id) || {}).type, q = id => JSON.stringify(at.has(id) ? ucName(at.get(id)) : String(id)), uc = id => ty(id) === 'uml.UseCase';
9766
+ UC_ARR(model && model.nodes).forEach((n, i) => { if (!UC_OBJ(n) || !UC_KINDS.includes(n.type)) return;
9767
+ if (!UC_HAS(n.label) || !String(n.label).trim()) say('error', 'named', `nodes[${i}]`, [n.id], `${n.type === 'uml.Actor' ? 'actor' : 'use case'} ${n.id} has no name`);
9768
+ const p = n.parent != null && at.get(n.parent), ok = n.type === 'uml.UseCase' ? ['uml.Subject', 'uml.Package'] : ['uml.Package'];
9769
+ if (p && !ok.includes(p.type)) say('warning', 'use-case-owner', `nodes[${i}].parent`, [n.id, p.id], `${q(n.id)} is owned by ${q(p.id)}, a ${String(p.type).split('.').pop()}: ${n.type === 'uml.UseCase' ? 'a use case\'s owner is a subject or a package' : 'an actor\'s owner is a package, or none'}`);
9770
+ if (n.type !== 'uml.UseCase') return;
9771
+ const seen = new Map(); ucPointsOf(n).forEach(m => { const k = ucName(m), was = seen.get(k);
9772
+ if (was) say('warning', 'point-names', `nodes[${i}].members[${UC_ARR(n.members).indexOf(m)}]`, [n.id, was, m.id], `${q(n.id)} has two extension points named "${k}" (${was}, ${m.id})`); else seen.set(k, m.id); }); });
9773
+ const inc = new Map();
9774
+ UC_ARR(model && model.edges).forEach((e, i) => { if (!UC_OBJ(e)) return; const path = `edges[${i}]`, ends = [e.id, e.from, e.to];
9775
+ if (e.type === 'uml.Association' && (UC_KINDS.includes(ty(e.from)) || UC_KINDS.includes(ty(e.to))) && ![ty(e.from), ty(e.to)].sort().every((t, k) => t === UC_KINDS[k]))
9776
+ say('error', 'association-ends', path, ends, `${e.id} joins ${q(e.from)} and ${q(e.to)}: an association joins an actor and a use case`);
9777
+ if ((e.type === 'uml.Include' || e.type === 'uml.Extend') && (!uc(e.from) || !uc(e.to) || e.from === e.to))
9778
+ say('error', e.type === 'uml.Include' ? 'include-ends' : 'extend-ends', path, ends, `${e.id} runs from ${q(e.from)} to ${q(e.to)}: from a use case to another use case`);
9779
+ if (e.type === 'uml.Include' && uc(e.from) && uc(e.to)) inc.set(e.from, [...(inc.get(e.from) || []), [e.to, path, e.id]]);
9780
+ if (e.type === 'uml.Extend' && uc(e.to)) { const pts = UC_ARR(UC_PROPS(e).extensionPoints), mine = new Set(ucPointsOf(at.get(e.to)).map(m => m.id)), bad = pts.filter(p => !mine.has(p));
9781
+ if (!pts.length) say('error', 'extend-points', path, [e.id, e.to], `${e.id} names no extension point of ${q(e.to)}`);
9782
+ else if (bad.length) say('error', 'extend-points', `${path}.props.extensionPoints`, [e.id, e.to, ...bad.map(String)], `${e.id} names ${bad.map(p => JSON.stringify(String(p))).join(', ')}, not ${bad.length === 1 ? 'an extension point' : 'extension points'} of ${q(e.to)}`); }
9783
+ if (e.type === 'uml.Generalization' && (UC_KINDS.includes(ty(e.from)) || UC_KINDS.includes(ty(e.to))) && (ty(e.from) !== ty(e.to) || e.from === e.to))
9784
+ say('error', 'generalization-kinds', path, ends, `${e.id} runs from ${q(e.from)} to ${q(e.to)}: a generalization joins two actors or two use cases`); });
9785
+ // an include cycle: said once, at the include that closes it, naming every use case on it
9786
+ const state = new Map(), stack = [];
9787
+ const walk = id => { state.set(id, 1); stack.push(id);
9788
+ for (const [p, path, eid] of inc.get(id) || []) {
9789
+ if (state.get(p) === 1) { const loop = stack.slice(stack.indexOf(p)); say('error', 'include-cycle', path, [...loop, eid], `${[...loop, p].map(q).join(' → ')} includes itself (${eid})`); }
9790
+ else if (!state.has(p)) walk(p); }
9791
+ stack.pop(); state.set(id, 2); };
9792
+ [...inc.keys()].forEach(id => { if (!state.has(id)) walk(id); });
9793
+ return out;
9794
+ }
9795
+
9796
+ /* ---- describe(): the view in sentences, each element by its accessible name in reading order, then each line ---- */
9797
+ function ucDescribe(model, viewId) {
9798
+ const v = UC_ARR(model && model.views).find(x => UC_OBJ(x) && x.id === viewId); if (!v) throw new Error(`umlimator: no view ${JSON.stringify(viewId)}`);
9799
+ const pic = ucPic(model, v), lines = ucLines(model, v, pic), by = new Map(lines.map(e => [e.id, e]));
9800
+ return [`${v.title || v.id}: a use case diagram of ${ucPlural(pic.ids.length, 'element')} and ${ucPlural(lines.length, 'line')}.`,
9801
+ ...ucOrder(pic, lines).map(id => (by.has(id) ? ucLineName(model, by.get(id), pic) : ucNodeName(model, pic.at.get(id), pic)) + '.')].join('\n');
9802
+ }
9803
+
9804
+ /* ---- editing (§8.4) ---- */
9805
+ // the Owner field: a use case's subjects and packages, an actor's, a subject's or a comment's packages, each by its name
9806
+ function ucOwners(model, x) {
9807
+ if (!UC_OBJ(x) || x.from != null || !['uml.UseCase', 'uml.Actor', 'uml.Subject', 'uml.Comment'].includes(x.type)) return null;
9808
+ const kinds = x.type === 'uml.UseCase' ? ['uml.Subject', 'uml.Package'] : ['uml.Package'];
9809
+ return UC_ARR(model.nodes).filter(n => UC_OBJ(n) && kinds.includes(n.type)).map(n => ({ id: n.id, label: ucName(n) }));
9810
+ }
9811
+ // an extension point is a member row, by its name
9812
+ const ucPointText = t => { const s = String(t ?? '').trim(); return s ? { type: 'uml.ExtensionPoint', label: s, props: {} } : { error: 'An extension point has a name' }; };
9813
+ // connect: an association drawn from a use case to an actor is added from the actor, so that actors are ranked first (§8.1)
9814
+ function ucConnect(model, view, from, to, at, line) {
9815
+ const ty = id => (UC_ARR(model.nodes).find(n => UC_OBJ(n) && n.id === id) || {}).type;
9816
+ if (!UC_OBJ(line) || line.type !== 'uml.Association' || ty(from) !== 'uml.UseCase' || ty(to) !== 'uml.Actor') return null;
9817
+ const used = new Set(); JSON.stringify(model, (k, v) => { if (k === 'id' && typeof v === 'string') used.add(v); return v; });
9818
+ let k = 1; while (used.has('association' + k)) k++;
9819
+ return [{ op: 'add', element: { id: 'association' + k, type: 'uml.Association', from: to, to: from }, view: view.id }];
9820
+ }
9821
+
9822
+ const UC_STEREO = { key: 'stereotypes', label: 'Stereotypes', kind: 'list' };
9823
+ const UC_TYPE = registerDiagramType('uml.use-case', {
9824
+ title: 'use case diagram',
9825
+ elements: [['Actor', 'Actor'], ['UseCase', 'Use case'], ['Subject', 'Subject'], ['Comment', 'Comment']].map(([t, label]) => ({ type: 'uml.' + t, label })),
9826
+ lines: [['Association', 'Association'], ['Include', 'Include'], ['Extend', 'Extend'], ['Generalization', 'Generalization'], ['CommentLink', 'Comment link']].map(([t, label]) => ({ type: 'uml.' + t, label })),
9827
+ props: {
9828
+ 'uml.Actor': [UC_STEREO], 'uml.UseCase': [UC_STEREO], 'uml.Subject': [UC_STEREO], 'uml.Association': [UC_STEREO],
9829
+ 'uml.Extend': [{ key: 'condition', label: 'Condition', kind: 'text' }, { key: 'extensionPoints', label: 'Extension points (ids)', kind: 'list' }],
9830
+ 'uml.Include': [], 'uml.Generalization': [], 'uml.CommentLink': [],
9831
+ },
9832
+ refs: { 'uml.Extend': { 'props.extensionPoints': 'drop' } }, // a deleted extension point leaves every extend that names it
9833
+ rankFlip: e => e.type === 'uml.Generalization' || e.type === 'uml.Extend', // the parent and the base first
9834
+ layout: ucGeo,
9835
+ boxes: (model, view) => ucPic(model, view).boxes,
9836
+ owners: ucOwners,
9837
+ member: { print: m => String(m.label ?? ''), edit: (m, t) => { const r = ucPointText(t); return r.error ? r : { commands: [{ op: 'set', id: m.id, path: 'label', value: r.label }] }; }, add: ucPointText, holds: n => n.type === 'uml.UseCase' },
9838
+ validate: ucProblems,
9839
+ rules: UC_RULES,
9840
+ describe: ucDescribe,
9841
+ edits: { connect: ucConnect },
9842
+ });
9843
+ /* ---------- the activity diagram, uml.activity (work package 4.5, notes/stage-4.md §9) ----------
9844
+ Control nodes, actions and flows from src/uml/flow.js; the send and accept signals, the object node, the data store and
9845
+ the call behaviour action's rake here, each a look over flow.js's rounded box. Partitions are lanes (§5): each node's row
9846
+ is moved into its lane's run of rows (laneRows) before layoutTyped, and the bands are found after it (laneBands). A
9847
+ partition is left out of the layout and added back to a new geometry as a node on its band's header strip, so it is
9848
+ selected, renamed and deleted as a node; the bands are kept in geo.bands and drawn in the type's `under` layer. The
9849
+ header room laneBands asks for is made by moving the bands and the bounds back by its shift, not the nodes forward, so
9850
+ nothing layoutTyped returned is copied or changed (§2.4). Flows are "name [guard]" (§7.3), cut at 280 px, with an
9851
+ open arrowhead; laid out with repair: false and reroute: false (§17, §20 #13) and a row gap of 64 px (§5.2). Ships in
9852
+ lib/umlimator-all.js only. */
9853
+
9854
+ const AC_OBJ = x => x !== null && typeof x === 'object' && !Array.isArray(x), AC_ARR = x => (Array.isArray(x) ? x : []);
9855
+ const AC_HAS = x => x != null && String(x).trim() !== '', AC_PART = x => (AC_HAS(x) ? String(x).trim() : undefined);
9856
+ const AC = { rgap: 64, gap: 64, cut: 280, sig: 14, rake: 10, line: 'var(--um-muted)' };
9857
+ const AC_FLOWS = ['uml.ControlFlow', 'uml.ObjectFlow'], AC_LINES = [...AC_FLOWS, 'uml.CommentLink'];
9858
+ const AC_OBJECTS = ['uml.ObjectNode', 'uml.DataStoreNode'];
9859
+ // each node type: its shape and its word (accessible names, the PDF)
9860
+ const AC_LOOK = {
9861
+ 'uml.InitialNode': ['initial', 'Initial node'], 'uml.ActivityFinalNode': ['final', 'Activity final'], 'uml.FlowFinalNode': ['flow-final', 'Flow final'],
9862
+ 'uml.Action': ['rounded', 'Action'], 'uml.CallBehaviorAction': ['call', 'Call behaviour action'], 'uml.SendSignalAction': ['send', 'Send signal action'],
9863
+ 'uml.AcceptEventAction': ['accept', 'Accept event action'], 'uml.DecisionNode': ['diamond', 'Decision'], 'uml.MergeNode': ['diamond', 'Merge'],
9864
+ 'uml.ForkNode': ['bar', 'Fork'], 'uml.JoinNode': ['bar', 'Join'], 'uml.ObjectNode': ['object', 'Object node'], 'uml.DataStoreNode': ['object', 'Data store'],
9865
+ 'uml.ActivityPartition': ['partition', 'Partition'], 'uml.Comment': ['note', 'Comment'],
9866
+ };
9867
+ const AC_NODES = Object.keys(AC_LOOK).filter(t => t !== 'uml.ActivityPartition' && t !== 'uml.Comment'); // what a flow may end on
9868
+ const acLook = x => (Object.hasOwn(AC_LOOK, x.type) ? AC_LOOK[x.type] : ['rounded', String(x.type || 'Node').split('.').pop()]);
9869
+
9870
+ /* ---- the activity's own shapes, each a look over SHAPE.rounded (flow.js): its text drawn in `body` (part of the node's
9871
+ box), its outline replaced by `path`, and `extra` drawn over it ---- */
9872
+ const acOver = (shape, n, body, path, extra = '') => { const m = { ...n, ...body }, s = SHAPE.rounded.draw(m);
9873
+ return s.split(SHAPE.rounded.out(m)).join(path).replace('class="node rounded"', `class="node ${shape}"`).replace('data-shape="rounded"', `data-shape="${shape}"`).replace(/<\/g>$/, extra + '</g>'); };
9874
+ const acGrow = (n, d) => ({ x: n.x - d, y: n.y - d, w: n.w + 2 * d, h: n.h + 2 * d });
9875
+ // send: a convex pentagon, its point on the right; accept: a concave one, its notch on the left (UML 2.5.1 §16.3.4)
9876
+ const sigOut = concave => (n, d = 0) => { const { x, y, w, h } = acGrow(n, d), cy = y + h / 2, k = AC.sig;
9877
+ return concave ? `M${r2(x)} ${r2(y)}H${r2(x + w)}V${r2(y + h)}H${r2(x)}L${r2(x + k)} ${r2(cy)}Z` : `M${r2(x)} ${r2(y)}H${r2(x + w - k)}L${r2(x + w)} ${r2(cy)}L${r2(x + w - k)} ${r2(y + h)}H${r2(x)}Z`; };
9878
+ const sigIn = concave => (n, s, t) => { const k = AC.sig, b = n.h / 2, u = Math.min(1, Math.abs(t - n.y - b) / b);
9879
+ if (concave) return s === 'L' ? k * (1 - u) : 0;
9880
+ return s === 'R' ? k * u : (s === 'T' || s === 'B') && t > n.x + n.w - k ? b * Math.min(1, (t - n.x - n.w + k) / k) : 0; };
9881
+ const signal = concave => ({ size: n => { const s = SHAPE.rounded.size(n); return { w: s.w + AC.sig, h: s.h }; }, out: sigOut(concave), inset: sigIn(concave),
9882
+ draw: n => acOver(concave ? 'accept' : 'send', n, concave ? { x: n.x + AC.sig, w: n.w - AC.sig } : { w: n.w - AC.sig }, sigOut(concave)(n)) });
9883
+ SHAPE.send = signal(false); SHAPE.accept = signal(true);
9884
+ // an object node: a rectangle, its name (a data store's «datastore» over it)
9885
+ SHAPE.object = { size: n => SHAPE.rounded.size(n), out: boxOut(0), inset: () => 0, draw: n => acOver('object', n, {}, boxOut(0)(n)) };
9886
+ // a call behaviour action: the rounded box, its name above UML's rake at its bottom right
9887
+ SHAPE.call = { size: n => { const s = SHAPE.rounded.size(n); return { w: s.w, h: s.h + AC.rake }; }, out: SHAPE.rounded.out, inset: SHAPE.rounded.inset,
9888
+ draw(n) { const x = n.x + n.w - 14, y = n.y + n.h - 5;
9889
+ return acOver('call', n, { h: n.h - AC.rake }, SHAPE.rounded.out(n), `<path d="M${r2(x)} ${r2(y - 10)}V${r2(y)}M${r2(x - 5)} ${r2(y)}V${r2(y - 5)}H${r2(x + 5)}V${r2(y)}" class="n-det ac-rake" fill="none" stroke="${AC.line}" stroke-width="1.3"/>`); } };
9890
+ for (const k of ['send', 'accept', 'object', 'call']) { const S = SHAPE[k]; S.face = n => [n.y, n.y + n.h]; S.ports = (n, side, j = 1) => portsOn(S, n, side, j); }
9891
+ // a partition: its name on its band's header strip (n: the strip), over a transparent area 24 px across to hit
9892
+ SHAPE.partition = {
9893
+ draw(n) { const flat = n.w >= n.h, t = fit(String(n.label ?? ''), 11, (flat ? n.w : n.h) - 12), cx = n.x + n.w / 2, cy = n.y + n.h / 2;
9894
+ const hit = flat ? [n.x, n.y - 1, n.w, n.h + 2] : [n.x - 1, n.y, n.w + 2, n.h];
9895
+ return `<g class="node partition" data-id="${esc(n.id)}" data-shape="partition" tabindex="-1" role="img" aria-label="${esc(n.aria || 'Partition ' + (n.label ?? ''))}">`
9896
+ + `<rect x="${r2(hit[0])}" y="${r2(hit[1])}" width="${r2(hit[2])}" height="${r2(hit[3])}" class="fl-hit" fill="transparent"/>`
9897
+ + `<text x="${r2(cx)}" y="${r2(cy + 4)}" text-anchor="middle" class="c-name" fill="var(--um-text)" style="font:600 11px var(--um-sans)"${flat ? '' : ` transform="rotate(-90 ${r2(cx)} ${r2(cy)})"`}>${esc(t)}</text></g>`; },
9898
+ };
9899
+
9900
+ /* ---- the view read: its nodes, its partitions and the lane each node is drawn in, its lines ---- */
9901
+ function actIndex(model, view) {
9902
+ const N = new Map(), E = new Map(), path = new Map();
9903
+ AC_ARR(model && model.nodes).forEach((n, k) => { if (AC_OBJ(n) && typeof n.id === 'string' && !N.has(n.id)) { N.set(n.id, n); path.set(n.id, `nodes[${k}]`); } });
9904
+ AC_ARR(model && model.edges).forEach((e, k) => { if (AC_OBJ(e) && typeof e.id === 'string' && !E.has(e.id)) { E.set(e.id, e); path.set(e.id, `edges[${k}]`); } });
9905
+ const ids = [...new Set(AC_ARR(view && view.nodeIds).filter(id => N.has(id)))], shown = new Set(ids);
9906
+ const isPart = id => N.has(id) && N.get(id).type === 'uml.ActivityPartition', parts = ids.filter(isPart), lanes = new Set(parts);
9907
+ // the nearest shown partition above an element (§9.2), or null; a cycle of owners stops where it closes
9908
+ const up = id => { const seen = new Set([id]); for (let p = (N.get(id) || {}).parent; p != null && N.has(p) && !seen.has(p); p = N.get(p).parent) { if (lanes.has(p)) return p; seen.add(p); } return null; };
9909
+ // a lane's own owner lane, or null at the top: a lane whose chain of shown owners comes back to it is at the top (laneRows')
9910
+ const outer = id => { const seen = new Set(); for (let l = up(id); l != null && !seen.has(l); l = up(l)) { if (l === id) return null; seen.add(l); } return up(id); };
9911
+ const laneOf = id => (lanes.has(id) ? null : up(id)), drawn = ids.filter(id => !lanes.has(id)), on = new Set(drawn);
9912
+ const list = Array.isArray(view && view.edges) ? view.edges.map(x => (typeof x === 'string' ? E.get(x) : x)) : [...E.values()].filter(e => AC_LINES.includes(e.type));
9913
+ const lines = list.filter(e => AC_OBJ(e) && typeof e.id === 'string' && on.has(e.from) && on.has(e.to));
9914
+ const under = p => drawn.filter(id => { for (let l = laneOf(id); l != null; l = outer(l)) if (l === p) return true; return false; });
9915
+ return { N, E, path, ids, shown, parts, drawn, lines, up, laneOf, isPart, under,
9916
+ lanes: parts.map((id, order) => ({ id, parent: outer(id), order })) };
9917
+ }
9918
+ const acName = x => String(x.label ?? x.id);
9919
+ const acProps = x => (AC_OBJ(x && x.props) ? x.props : {});
9920
+ const acStereo = x => AC_ARR(acProps(x).stereotypes).filter(AC_HAS).map(String);
9921
+ const acPlural = (k, w, ws = w + 's') => `${k} ${k === 1 ? w : ws}`;
9922
+ // a node as a line's end names it: its label, else its word ("merge")
9923
+ const acCalled = x => (x ? (AC_HAS(x.label) ? String(x.label) : acLook(x)[1].toLowerCase()) : '?');
9924
+ const acViewName = (model, id) => { const v = AC_ARR(model && model.views).find(w => AC_OBJ(w) && w.id === id); return v ? String(v.title ?? v.id) : null; };
9925
+
9926
+ // accessible names (§14): "Action Take the next order from the queue, in Order worker"; "Decision All books in stock?, 2 exits"
9927
+ function acNodeName(model, I, x, lines) {
9928
+ const [, word] = acLook(x), st = acStereo(x), lane = I.laneOf(x.id), outs = lines.filter(e => e.from === x.id && AC_FLOWS.includes(e.type)).length, ins = lines.filter(e => e.to === x.id && AC_FLOWS.includes(e.type)).length;
9929
+ if (x.type === 'uml.Comment') return 'Comment: ' + acName(x);
9930
+ if (x.type === 'uml.ActivityPartition') return `Partition ${acName(x)}${st.length ? ` (${st.join(', ')})` : ''}, ${acPlural(I.under(x.id).length, 'node')}`;
9931
+ const named = !['initial', 'final', 'flow-final', 'bar'].includes(acLook(x)[0]) && AC_HAS(x.label) ? ' ' + String(x.label).trim() : '';
9932
+ const count = { 'uml.DecisionNode': acPlural(outs, 'exit'), 'uml.MergeNode': acPlural(ins, 'entry', 'entries'), 'uml.ForkNode': acPlural(outs, 'branch', 'branches'), 'uml.JoinNode': acPlural(ins, 'branch', 'branches') }[x.type];
9933
+ const opens = x.type === 'uml.CallBehaviorAction' ? `opens ${acViewName(model, x.opensView) ?? 'no view'}` : null;
9934
+ return [`${word}${named}${st.length ? ` (${st.join(', ')})` : ''}`, count, opens, lane != null ? `in ${acName(I.N.get(lane))}` : null].filter(Boolean).join(', ');
9935
+ }
9936
+ const AC_LINE_WORDS = { 'uml.ControlFlow': ['Control flow', 'the next step, once the one before ends'], 'uml.ObjectFlow': ['Object flow', 'an object or data passed on'], 'uml.CommentLink': ['Comment link', 'what a comment is about'] };
9937
+ function acLineName(I, e) {
9938
+ const [word] = Object.hasOwn(AC_LINE_WORDS, e.type) ? AC_LINE_WORDS[e.type] : ['Line'], g = AC_PART(acProps(e).guard), nm = AC_PART(e.label);
9939
+ return `${word} from ${acCalled(I.N.get(e.from))} to ${acCalled(I.N.get(e.to))}${nm ? `, named ${nm}` : ''}${g ? `, guard ${g}` : ''}`;
9940
+ }
9941
+
9942
+ /* ---- the legend: each line kind and each control glyph the view draws (1.9's legendItem form) ---- */
9943
+ const acKey = (svg, 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" stroke-linejoin="round" fill="none">${svg}</g></svg><b>${esc(name)}</b><span>${esc(says)}</span></span>`;
9944
+ const AC_ARROW = '<path d="M2 8H25"/><path d="M17 3L26 8L17 13"/>';
9945
+ const AC_KEYS = [
9946
+ ['uml.ControlFlow', AC_ARROW], ['uml.ObjectFlow', AC_ARROW], ['uml.CommentLink', '<path d="M2 8H26" stroke-dasharray="3 2"/>'],
9947
+ ['uml.InitialNode', '<circle cx="14" cy="8" r="5" fill="var(--um-text)" stroke="var(--um-text)"/>', 'Initial node', 'where the activity starts'],
9948
+ ['uml.ActivityFinalNode', '<circle cx="14" cy="8" r="6"/><circle cx="14" cy="8" r="3" fill="var(--um-text)" stroke="none"/>', 'Activity final', 'ends the whole activity'],
9949
+ ['uml.FlowFinalNode', '<circle cx="14" cy="8" r="6"/><path d="M10 4L18 12M18 4L10 12"/>', 'Flow final', 'ends its own branch only'],
9950
+ ['uml.ForkNode', '<path d="M14 2V14" stroke="var(--um-text)" stroke-width="4"/>', 'Fork', 'splits the flow into branches that run at once'],
9951
+ ['uml.JoinNode', '<path d="M14 2V14" stroke="var(--um-text)" stroke-width="4"/>', 'Join', 'waits for every branch, then goes on'],
9952
+ ['uml.DecisionNode', '<path d="M14 2L21 8L14 14L7 8Z"/>', 'Decision', 'takes the one exit whose guard holds'],
9953
+ ['uml.MergeNode', '<path d="M14 2L21 8L14 14L7 8Z"/>', 'Merge', 'goes on with whichever flow arrives'],
9954
+ ];
9955
+ const acLegend = types => AC_KEYS.filter(([t]) => types.has(t)).map(([t, svg, name, says]) => (AC_LINE_WORDS[t] ? acKey(svg, ...AC_LINE_WORDS[t]) : acKey(svg, name, says)));
9956
+
9957
+ /* ---- the view laid out: { model, view, pos, memo } (§9.1, §5) ---- */
9958
+ function actGeo({ model, view, pos, memo }) {
9959
+ const I = actIndex(model, view), down = view.direction === 'down';
9960
+ const at = id => (Object.hasOwn(pos || {}, id) && Array.isArray(pos[id]) ? pos[id] : [0, 0]);
9961
+ const P = I.lanes.length ? laneRows(Object.fromEntries(I.drawn.map(id => [id, at(id)])), I.laneOf, I.lanes) : null; // each lane a run of rows
9962
+ const bar = id => acLook(I.N.get(id))[0] === 'bar', side = (id, w) => I.lines.filter(e => e[w] === id).length;
9963
+ const nodes = I.drawn.map(id => { const x = I.N.get(id), [shape] = acLook(x), [col, row] = P ? P[id] : at(id), st = acStereo(x);
9964
+ const d = { id, label: ['initial', 'final', 'flow-final', 'bar'].includes(shape) ? '' : acName(x), type: x.type, kind: 'file', shape, aria: acNodeName(model, I, x, I.lines), col, row,
9965
+ stereotypes: x.type === 'uml.DataStoreNode' ? ['datastore', ...st] : st, ...(shape === 'bar' ? { busy: Math.max(side(id, 'from'), side(id, 'to')), down } : {}) };
9966
+ return { ...d, ...SHAPE[shape].size(d) }; });
9967
+ // a bar's lines enter on the side facing the flow's start and leave on the other (§4.1): left and right in the layout's
9968
+ // own axes, which a view laid out down turns to top and bottom
9969
+ const edges = I.lines.map(e => { const note = e.type === 'uml.CommentLink', label = note ? '' : flowText(e.label, acProps(e).guard);
9970
+ return { id: e.id, _id: e.id, type: e.type, from: e.from, to: e.to, ...(label ? { label, cut: AC.cut } : {}), end: note ? { from: 'none', to: 'none', dashed: true } : { from: 'none', to: 'open' },
9971
+ aria: acLineName(I, e), ...(bar(e.from) ? { fromSide: 'right' } : {}), ...(bar(e.to) ? { toSide: 'left' } : {}), ...(AC_OBJ(e.style) || typeof e.style === 'string' ? { style: e.style } : {}) }; });
9972
+ // a typed view's router makes no room for a label (labelRoom is a map's): with guards to draw, the columns are spread by the
9973
+ // widest label and 40 px for its stubs laid out right, as the class type does, and by 48 px laid out down, where a label
9974
+ // lies across its line. ponytail: one gap for every column; a gap per column needs labelRoom for typed views (the core's)
9975
+ const wide = edges.some(e => e.label) ? (down ? 48 : Math.ceil(Math.max(...edges.filter(e => e.label).map(labelW))) + 40) : 0;
9976
+ const g = layoutTyped({ nodes, edges, direction: down ? 'down' : 'right', gap: AC.gap + wide, rgap: AC.rgap, repair: false, reroute: false }, memo);
9977
+ return acFinish(model, g, I, down);
9978
+ }
9979
+
9980
+ /* The laid-out graph as it is drawn, a new object (layoutTyped's memo keeps the one it returned, §2.4): the bands, the
9981
+ partitions on their headers, geo.order, the slots and the legend */
9982
+ function acFinish(model, g, I, down) {
9983
+ let bands = [], bounds = g.bounds;
9984
+ if (I.lanes.length) { const L = laneBands(g, I.lanes, I.laneOf, down, AC.rgap), back = b => ({ ...b, x: r2(b.x - L.shift.x), y: r2(b.y - L.shift.y) });
9985
+ bands = L.bands.map(b => ({ ...back(b), head: back(b.head) })); bounds = back(L.bounds); }
9986
+ // a header is aside, off the columns, at the first node's column and row: the canvas reads a drop's column and row from the
9987
+ // nodes when every one has them (render.js gridView, hintAt)
9988
+ const byId = { ...g.byId }, { col = 0, row = 0 } = g.nodes[0] || {};
9989
+ const heads = bands.map(b => { const x = I.N.get(b.id); // k: the 'file' kind's, as layoutTyped gives the nodes, so a view of partitions only draws
9990
+ return (byId[b.id] = { id: b.id, label: acName(x), type: x.type, kind: 'file', k: 'file', shape: 'partition', aside: true, col, row, aria: acNodeName(model, I, x, I.lines), ...b.head }); });
9991
+ // reading order (§14): a partition, then its nested partitions, then its own nodes by rank; then the nodes in no lane by
9992
+ // rank; then the lines. The nodes are drawn in it, then the headers, so the canvas's first node is a laid-out one
9993
+ const rank = (a, b) => byId[a].col - byId[b].col || byId[a].row - byId[b].row, order = [];
9994
+ const put = p => { order.push(p); I.lanes.filter(l => l.parent === p).forEach(l => put(l.id)); order.push(...I.drawn.filter(id => I.laneOf(id) === p).sort(rank)); };
9995
+ I.lanes.filter(l => l.parent == null).forEach(l => put(l.id));
9996
+ order.push(...I.drawn.filter(id => I.laneOf(id) == null).sort(rank));
9997
+ const mid = n => ({ x: n.x + n.w / 2, y: n.y + n.h / 2 });
9998
+ const types = new Set([...g.edges.map(e => e.type), ...I.drawn.map(id => I.N.get(id).type)]);
9999
+ return { ...g, nodes: [...order.filter(id => !I.isPart(id)), ...I.parts].map(id => byId[id]), byId, bounds, bands, partitions: heads.map(h => h.id), order: [...order, ...g.edges.map(e => e.id)],
10000
+ // the slots (src/typed/edit.js): one per node and one per partition's header, so the palette's add hears what is selected
10001
+ slots: [...I.drawn.map(id => ({ after: id, parent: I.laneOf(id), ...mid(byId[id]) })), ...heads.map(h => ({ after: h.id, parent: h.id, ...mid(h) }))], legend: acLegend(types) };
10002
+ }
10003
+
10004
+ /* the bands (§5.3), under the lines: each band's outline and the line closing its header strip */
10005
+ function actDraw(g) {
10006
+ const bs = AC_ARR(g.bands);
10007
+ if (!bs.length) return { under: '', over: '' };
10008
+ return { under: '<g class="ac-lanes">' + bs.map(b => { const h = b.head, flat = h.w >= h.h;
10009
+ return frame(b.x, b.y, b.w, b.h) + `<path class="ac-head" d="${flat ? `M${r2(h.x)} ${r2(h.y + h.h)}H${r2(h.x + h.w)}` : `M${r2(h.x + h.w)} ${r2(h.y)}V${r2(h.y + h.h)}`}" stroke="${AC.line}" stroke-width="1.2"/>`; }).join('') + '</g>', over: '' };
10010
+ }
10011
+
10012
+ /* ---- validators (§9.3): the shared flow rules (flow.js), then the activity's own. Each problem: { level, rule, path,
10013
+ message, ids } ---- */
10014
+ const ACT_RULES = {
10015
+ ...FLOW_RULES,
10016
+ 'object-flow': 'an object flow has an object node or a data store at one end; a control flow has none at either',
10017
+ 'partition-owner': 'a partition\'s owner is a partition or none; a node\'s owner is a partition or none',
10018
+ 'in-lane': 'on a view that shows a partition, every node is in one',
10019
+ 'calls-view': 'a call behaviour action opens an activity view of the model',
10020
+ };
10021
+ function actProblems(model, view) {
10022
+ const I = actIndex(model, view), out = [...flowProblems(model, view, AC_NODES)], seen = id => !view || I.shown.has(id);
10023
+ const say = (rule, path, ids, msg, level = 'warning') => out.push({ level, rule, path, ids, message: `${rule}: ${msg}` });
10024
+ const q = id => { const x = I.N.get(id); return x && AC_HAS(x.label) ? `${id} ("${x.label}")` : id; };
10025
+ I.E.forEach((e, id) => { if (!AC_FLOWS.includes(e.type) || !(seen(e.from) || seen(e.to))) return;
10026
+ const objs = [e.from, e.to].filter(x => I.N.has(x) && AC_OBJECTS.includes(I.N.get(x).type));
10027
+ if (e.type === 'uml.ObjectFlow' && !objs.length) say('object-flow', I.path.get(id), [id, e.from, e.to], `object flow ${id} joins ${q(e.from)} and ${q(e.to)}, neither an object node nor a data store`);
10028
+ if (e.type === 'uml.ControlFlow' && objs.length) say('object-flow', I.path.get(id), [id, ...objs], `control flow ${id} ends on ${objs.map(q).join(' and ')}: a flow to or from an object node is an object flow`); });
10029
+ I.N.forEach((x, id) => { if (!seen(id) || !(AC_NODES.includes(x.type) || x.type === 'uml.ActivityPartition') || x.parent == null || !I.N.has(x.parent)) return;
10030
+ const p = I.N.get(x.parent); if (p.type !== 'uml.ActivityPartition') say('partition-owner', `${I.path.get(id)}.parent`, [id, p.id], `${q(id)} is owned by ${q(p.id)}, a ${String(p.type).split('.').pop()}, not a partition`, 'error'); });
10031
+ I.N.forEach((x, id) => { if (x.type !== 'uml.CallBehaviorAction' || !seen(id)) return;
10032
+ const v = AC_ARR(model.views).find(w => AC_OBJ(w) && w.id === x.opensView);
10033
+ if (!v || v.diagram !== 'uml.activity') say('calls-view', I.path.get(id), [id], `${q(id)} ${x.opensView == null ? 'opens no view' : `opens ${JSON.stringify(x.opensView)}, which is ${v ? 'not an activity view' : 'no view of the model'}`}`); });
10034
+ if (view && I.parts.length) { const vi = AC_ARR(model.views).indexOf(view);
10035
+ I.drawn.forEach(id => { if (I.N.get(id).type !== 'uml.Comment' && I.laneOf(id) == null) say('in-lane', `views[${vi}].nodeIds`, [id, view.id], `${q(id)} is in no partition view ${JSON.stringify(view.id)} shows: it is drawn after the lanes`); }); }
10036
+ return out;
10037
+ }
10038
+
10039
+ /* ---- describe(): the view in words: its partitions, then from each initial node each node once with the flows out of it,
10040
+ then the nodes no flow reaches ---- */
10041
+ function actDescribe(model, viewId) {
10042
+ const v = AC_ARR(model && model.views).find(x => AC_OBJ(x) && x.id === viewId); if (!v) throw new Error(`umlimator: no view ${JSON.stringify(viewId)}`);
10043
+ const I = actIndex(model, v), flows = I.lines.filter(e => AC_FLOWS.includes(e.type)), seen = new Set(), walk = [];
10044
+ const visit = s => { const q = [s]; while (q.length) { const id = q.shift(); if (seen.has(id)) continue; seen.add(id); walk.push(id); flows.filter(e => e.from === id).forEach(e => q.push(e.to)); } };
10045
+ I.drawn.filter(id => I.N.get(id).type === 'uml.InitialNode').forEach(visit); I.drawn.forEach(visit);
10046
+ const out = [`${v.title || v.id}: an activity diagram of ${acPlural(I.drawn.length, 'node')}, ${acPlural(flows.length, 'flow')} and ${acPlural(I.parts.length, 'partition')}.`];
10047
+ I.parts.forEach(p => out.push(acNodeName(model, I, I.N.get(p), I.lines) + '.'));
10048
+ walk.forEach(id => { const x = I.N.get(id), said = I.lines.filter(e => e.from === id).map(e => { const g = AC_PART(acProps(e).guard), nm = AC_PART(e.label);
10049
+ return e.type === 'uml.CommentLink' ? `about ${acCalled(I.N.get(e.to))}` : `then ${acCalled(I.N.get(e.to))}${nm ? ` (${nm})` : ''}${g ? ` [${g}]` : ''}${e.type === 'uml.ObjectFlow' ? ', an object flow' : ''}`; });
10050
+ out.push(acNodeName(model, I, x, I.lines) + (said.length ? '; ' + said.join('; ') : '') + '.'); });
10051
+ return out.join('\n');
10052
+ }
10053
+
10054
+ /* ---- editing (§9.4, §12.1): each hook returns commands, null to leave the gesture to the default, or { error } for the
10055
+ hint line, and never a transaction that breaks a rule (S5) ---- */
10056
+ function acFreshId(model, base) {
10057
+ const used = new Set(); JSON.stringify(model, (k, v) => { if (k === 'id' && typeof v === 'string') used.add(v); return v; });
10058
+ const b = String(base).toLowerCase().replace(/->/g, '-').replace(/[^\p{L}\p{N}_$-]+/gu, '-').replace(/^-+|-+$/g, '') || 'node'; // ids never hold "->" (M2)
10059
+ let k = 1; while (used.has(k === 1 ? b : `${b}-${k}`)) k++;
10060
+ return k === 1 ? b : `${b}-${k}`;
10061
+ }
10062
+ // the commands, or { error } naming the first error they bring that the view does not have already
10063
+ function acGuard(model, view, commands) {
10064
+ const key = p => p.rule + ' ' + p.ids.join(' '), errs = m => actProblems(m, AC_ARR(m.views).find(v => AC_OBJ(v) && v.id === view.id)).filter(p => p.level === 'error');
10065
+ let now; try { now = apply(model, commands); } catch (e) { return { error: String(e.message) }; }
10066
+ const was = new Set(errs(model).map(key)), bad = errs(now).find(p => !was.has(key(p)));
10067
+ return bad ? { error: bad.message } : commands;
10068
+ }
10069
+ // connect: a flow of the kind picked; a decision's second exit gets the guard else when neither has one
10070
+ function actConnect(model, view, from, to, at, line) {
10071
+ if (!AC_OBJ(view) || !AC_OBJ(line) || !AC_FLOWS.includes(line.type)) return null;
10072
+ const I = actIndex(model, view), x = I.N.get(from), outs = [...I.E.values()].filter(e => AC_FLOWS.includes(e.type) && e.from === from);
10073
+ const props = { ...(AC_OBJ(line.props) ? structuredClone(line.props) : {}) };
10074
+ if (x && x.type === 'uml.DecisionNode' && outs.length === 1 && !AC_HAS(props.guard) && AC_PART(acProps(outs[0]).guard) !== 'else') props.guard = 'else';
10075
+ return acGuard(model, view, [{ op: 'add', element: { id: acFreshId(model, `${from}-${to}`), type: line.type, from, to, ...(Object.keys(props).length ? { props } : {}) }, view: view.id }]);
10076
+ }
10077
+ // moveTo: a partition's header moves nothing (§5.3); a node dropped in another band changes lane, its pin removed; within
10078
+ // its own band, or by Alt + arrows, null: a pin, which is an order inside the lane (§5.2)
10079
+ function actMoveTo(model, view, id, at) {
10080
+ if (!AC_OBJ(view)) return null;
10081
+ const I = actIndex(model, view); if (I.isPart(id)) return [];
10082
+ if ((I.N.get(id) || {}).type === 'uml.Comment') return null; // a comment is in no lane (add, in-lane): a pin
10083
+ const bands = AC_ARR(AC_OBJ(at) && AC_OBJ(at.geo) ? at.geo.bands : null);
10084
+ if (!I.shown.has(id) || !Array.isArray(at && at.pt) || !bands.length) return null;
10085
+ const [x, y] = at.pt, hit = bands.filter(b => x >= b.x && x <= b.x + b.w && y >= b.y && y <= b.y + b.h).sort((a, b) => b.depth - a.depth)[0], to = hit ? hit.id : null;
10086
+ if (to === I.laneOf(id)) return null;
10087
+ const L = view.layout;
10088
+ return [{ op: 'reparent', id, parent: to }, ...(AC_OBJ(L) && Object.hasOwn(L, id) ? [{ op: 'set', view: view.id, path: ['layout', id], value: undefined }] : [])];
10089
+ }
10090
+ // add: a node in the partition selected, else in the lane of the node selected (its slot's parent); a call behaviour action asks which activity view it opens (S18)
10091
+ function actAdd(model, view, entry, label, at) {
10092
+ if (!AC_OBJ(view) || !AC_OBJ(entry) || !Object.hasOwn(AC_LOOK, entry.type)) return null;
10093
+ const N = new Map(AC_ARR(model.nodes).filter(AC_OBJ).map(n => [n.id, n])), part = AC_ARR(AC_OBJ(at) ? at.sel : null).find(id => (N.get(id) || {}).type === 'uml.ActivityPartition');
10094
+ const lane = part ?? (AC_OBJ(at) && at.parent != null && AC_ARR(at.sel).includes(at.after) ? at.parent : null);
10095
+ const t = AC_PART(label), el = { id: acFreshId(model, t || AC_LOOK[entry.type][1]), type: entry.type, ...(t ? { label: t } : {}), ...(lane != null && entry.type !== 'uml.Comment' ? { parent: lane } : {}) };
10096
+ const make = opens => [{ op: 'add', element: { ...el, ...(opens != null ? { opensView: opens } : {}) }, view: view.id }];
10097
+ if (entry.type !== 'uml.CallBehaviorAction') return make();
10098
+ const vs = AC_ARR(model.views).filter(v => AC_OBJ(v) && v.diagram === 'uml.activity');
10099
+ return { pick: vs.map(v => String(v.title ?? v.id)), then: i => make(vs[i].id) };
10100
+ }
10101
+ // remove: Shift + Delete on a partition keeps what it holds, which goes to the partition's own owner (S10)
10102
+ function actRemove(model, view, ids, fromModel) {
10103
+ if (!fromModel) return null;
10104
+ const N = new Map(AC_ARR(model.nodes).filter(AC_OBJ).map(n => [n.id, n])), gone = new Set(AC_ARR(ids)), parts = [...gone].filter(id => (N.get(id) || {}).type === 'uml.ActivityPartition');
10105
+ if (!parts.length) return null;
10106
+ const keep = p => { const seen = new Set(); while (p != null && gone.has(p) && !seen.has(p)) { seen.add(p); p = (N.get(p) || {}).parent; } return p != null && N.has(p) && !gone.has(p) ? p : null; };
10107
+ return [...[...N.values()].filter(n => !gone.has(n.id) && parts.includes(n.parent)).map(n => ({ op: 'reparent', id: n.id, parent: keep(n.parent) })), ...[...gone].map(id => ({ op: 'delete', id }))];
10108
+ }
10109
+ // rename: F2 on a flow reads "name [guard]" (§7.2) and sets each part that changed; on anything else, null (set label)
10110
+ function actRename(model, view, id, text) {
10111
+ const e = AC_ARR(model && model.edges).find(x => AC_OBJ(x) && x.id === id);
10112
+ if (!e || !AC_FLOWS.includes(e.type)) return null;
10113
+ const r = parseFlow(text); if (r.error) return r;
10114
+ return [...(r.name !== AC_PART(e.label) ? [{ op: 'set', id, path: 'label', value: r.name }] : []), ...(r.guard !== AC_PART(acProps(e).guard) ? [{ op: 'set', id, path: 'props.guard', value: r.guard }] : [])];
10115
+ }
10116
+ // the Owner field: every partition of the model, by its name from the top
10117
+ function actOwners(model, x) {
10118
+ if (!AC_OBJ(x) || x.from != null || !(AC_NODES.includes(x.type) || x.type === 'uml.ActivityPartition')) return null;
10119
+ const N = new Map(AC_ARR(model.nodes).filter(AC_OBJ).map(n => [n.id, n])), full = id => { const out = [], seen = new Set(); for (let p = id; p != null && N.has(p) && !seen.has(p); p = N.get(p).parent) { seen.add(p); out.unshift(acName(N.get(p))); } return out.join('::'); };
10120
+ return [...N.values()].filter(n => n.type === 'uml.ActivityPartition').map(n => ({ id: n.id, label: full(n.id) }));
10121
+ }
10122
+
10123
+ /* ---- the type ---- */
10124
+ const AC_ST = { key: 'stereotypes', label: 'Stereotypes', kind: 'list' }, AC_GUARD = { key: 'guard', label: 'Guard', kind: 'text' };
10125
+ const ACT_TYPE = registerDiagramType('uml.activity', {
10126
+ title: 'activity diagram',
10127
+ elements: [['Action', 'Action'], ['InitialNode', 'Initial', false], ['ActivityFinalNode', 'Activity final', false], ['FlowFinalNode', 'Flow final', false],
10128
+ ['DecisionNode', 'Decision'], ['MergeNode', 'Merge'], ['ForkNode', 'Fork', false], ['JoinNode', 'Join', false], ['CallBehaviorAction', 'Call behaviour', true, 'uml.activity'],
10129
+ ['SendSignalAction', 'Send signal'], ['AcceptEventAction', 'Accept event'], ['ObjectNode', 'Object node'], ['DataStoreNode', 'Data store'], ['ActivityPartition', 'Partition'], ['Comment', 'Comment']]
10130
+ .map(([t, label, name, opens]) => ({ type: 'uml.' + t, label, ...(name === false ? { name: false } : {}), ...(opens ? { opens } : {}) })),
10131
+ lines: [{ type: 'uml.ControlFlow', label: 'Control flow' }, { type: 'uml.ObjectFlow', label: 'Object flow' }, { type: 'uml.CommentLink', label: 'Comment link' }],
10132
+ props: { ...Object.fromEntries(Object.keys(AC_LOOK).map(t => [t, [AC_ST]])), 'uml.Comment': [], 'uml.ControlFlow': [AC_GUARD, AC_ST], 'uml.ObjectFlow': [AC_GUARD, AC_ST], 'uml.CommentLink': [] },
10133
+ layout: actGeo,
10134
+ draw: actDraw,
10135
+ owners: actOwners,
10136
+ validate: actProblems,
10137
+ rules: ACT_RULES,
10138
+ describe: actDescribe,
10139
+ edits: { connect: actConnect, moveTo: actMoveTo, add: actAdd, remove: actRemove, rename: actRename },
10140
+ // the PDF's tables (§15): a node's kind, and its partition
10141
+ word: (model, id) => { const x = AC_ARR(model && model.nodes).find(n => AC_OBJ(n) && n.id === id); return x && Object.hasOwn(AC_LOOK, x.type) ? AC_LOOK[x.type][1] : null; },
10142
+ rows: (model, view, id) => { const I = actIndex(model, view), l = I.N.has(id) ? I.laneOf(id) : null; return l != null ? [`in ${acName(I.N.get(l))}`] : null; },
10143
+ });
10144
+ /* ---------- the state machine diagram, uml.state-machine (work package 4.6, notes/stage-4.md §6, §10) ----------
10145
+ States (a rounded box, its name, then one compartment of rows: entry, do and exit, then each internal transition), a
10146
+ composite state with a shown substate as a box (src/uml/owners.js's look) and closed with UML's decomposition
10147
+ indicator, its regions nested boxes with no border when it has two or more (a dashed divider in the gap before each but
10148
+ the first, the regions stretched across their state after the layout, on a copy); pseudostates by kind (initial, choice,
10149
+ junction, fork, join, shallow and deep history, terminate) and the final state; transitions as lines with an open arrow,
10150
+ their label the printed `trigger [guard] / effect` (src/uml/transition.js) cut at 280 px; comments. Laid out by
10151
+ layoutTyped from the host's ranking, repair and reroute off (§17). Ships in lib/umlimator-all.js only. */
10152
+
10153
+ const ST_OBJ = x => x !== null && typeof x === 'object' && !Array.isArray(x), ST_ARR = x => (Array.isArray(x) ? x : []);
10154
+ const ST_HAS = x => x != null && String(x).trim() !== '';
10155
+ const ST_PROPS = x => (ST_OBJ(x && x.props) ? x.props : {});
10156
+ const ST_OWN = (o, k) => (o != null && Object.hasOwn(o, k) ? o[k] : undefined);
10157
+ const stName = x => String(x.label ?? x.id);
10158
+ const stStereo = x => ST_ARR(ST_PROPS(x).stereotypes).filter(ST_HAS).map(String);
10159
+ const stPlural = (k, w) => `${k} ${k === 1 ? w : w + 's'}`;
10160
+ const ST_VERTEX = ['uml.State', 'uml.Pseudostate', 'uml.FinalState'];
10161
+ const ST_LINES = ['uml.Transition', 'uml.CommentLink'];
10162
+ const ST_ACTS = ['entry', 'do', 'exit'];
10163
+ const ST_KINDS = ['initial', 'choice', 'junction', 'fork', 'join', 'shallowHistory', 'deepHistory', 'terminate'];
10164
+ const ST_KIND_WORD = { initial: 'Initial pseudostate', choice: 'Choice', junction: 'Junction', fork: 'Fork', join: 'Join', shallowHistory: 'Shallow history', deepHistory: 'Deep history', terminate: 'Terminate' };
10165
+ const ST_KIND_SHAPE = { initial: 'initial', choice: 'diamond', junction: 'junction', fork: 'bar', join: 'bar', shallowHistory: 'history', deepHistory: 'history', terminate: 'terminate' };
10166
+ const stKind = x => { const k = ST_PROPS(x).kind; return ST_KINDS.includes(k) ? k : null; };
10167
+ const stMembers = (x, t) => ST_ARR(x && x.members).filter(m => ST_OBJ(m) && m.type === t);
10168
+ // a transition's parts as text (§7): the printed form, or the parts joined when they would not read back
10169
+ const stPartsText = x => { const p = ST_PROPS(x); return printTransition(p) ?? transitionText(p); };
10170
+
10171
+ /* ---- the shapes (§4.1): junction, history, terminate, and the state's look and frames ---- */
10172
+ const ST = { line: 'var(--um-muted)', ink: 'var(--um-text)', bg: 'var(--um-stage-bg)', hit: 12, name: 12, nlh: 16, nmax: 180, st: 11, sth: 14, row: 11, rl: 15, padX: 10, padY: 8, minW: 96, maxW: 320, minH: 36, dec: 12 };
10173
+ const stNode = (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>`;
10174
+ // ponytail: flow.js's circle helpers, which it does not export
10175
+ const stCircle = (n, d = 0) => { const r = n.w / 2 + d, cx = n.x + n.w / 2, cy = n.y + n.h / 2;
10176
+ return `M${r2(cx - r)} ${r2(cy)}A${r2(r)} ${r2(r)} 0 1 0 ${r2(cx + r)} ${r2(cy)}A${r2(r)} ${r2(r)} 0 1 0 ${r2(cx - r)} ${r2(cy)}Z`; };
10177
+ const stCircleIn = (n, s, t) => { const r = n.w / 2, u = t - (s === 'L' || s === 'R' ? n.y + n.h / 2 : n.x + n.w / 2); return r * (1 - Math.sqrt(Math.max(0, 1 - (u / r) ** 2))); };
10178
+ const stHit = n => `<circle cx="${r2(n.x + n.w / 2)}" cy="${r2(n.y + n.h / 2)}" r="${ST.hit}" class="fl-hit" fill="transparent"/>`;
10179
+ const stBody = (d, fill, stroke = ST.line) => `<path d="${d}" class="n-mask"/><path d="${d}" class="n-body" fill="${fill}" stroke="${stroke}"/>`;
10180
+ SHAPE.junction = { size: () => ({ w: 12, h: 12 }), out: stCircle, inset: stCircleIn, draw: n => stNode(n, 'junction', 'Junction', stHit(n) + stBody(stCircle(n), ST.ink, ST.ink)) };
10181
+ SHAPE.history = { size: () => ({ w: 24, h: 24 }), out: stCircle, inset: stCircleIn,
10182
+ draw: n => stNode(n, 'history', n.deep ? 'Deep history' : 'Shallow history', stBody(stCircle(n), ST.bg)
10183
+ + `<text x="${r2(n.x + n.w / 2)}" y="${r2(n.y + n.h / 2 + 4)}" text-anchor="middle" class="c-name" fill="${ST.ink}" style="font:600 11px var(--um-sans)">${n.deep ? 'H*' : 'H'}</text>`) };
10184
+ SHAPE.terminate = { size: () => ({ w: 20, h: 20 }), out: (n, d = 0) => `M${r2(n.x - d)} ${r2(n.y - d)}H${r2(n.x + n.w + d)}V${r2(n.y + n.h + d)}H${r2(n.x - d)}Z`, inset: () => 0,
10185
+ draw: n => stNode(n, 'terminate', 'Terminate', stHit(n) + `<rect x="${n.x}" y="${n.y}" width="${n.w}" height="${n.h}" class="n-mask" style="fill:transparent"/>`
10186
+ + `<path d="M${n.x} ${n.y}L${n.x + n.w} ${n.y + n.h}M${n.x + n.w} ${n.y}L${n.x} ${n.y + n.h}" class="n-body" fill="none" stroke="${ST.ink}" stroke-width="2"/>`) };
10187
+
10188
+ // a state's texts: stereotypes, its name on up to three lines, then its compartment's rows (n.rows: [text]; n.collapsed holds
10189
+ // 'activities' to hide it; n.cap: at most that many, then "+k more"), and the decomposition indicator's strip (n.composite)
10190
+ function stParts(n) {
10191
+ const st = ST_ARR(n.stereotypes).filter(ST_HAS).map(t => `«${t}»`), ls = flowWrap(n.label, ST.name, ST.nmax, 3);
10192
+ const all = ST_ARR(n.acts), shown = ST_ARR(n.collapsed).includes('activities') ? null : all, cap = Number.isFinite(n.cap) ? Math.max(0, Math.floor(n.cap)) : Infinity;
10193
+ const rows = shown ? [...shown.slice(0, cap), ...(shown.length > cap ? [`+${shown.length - cap} more`] : [])] : [];
10194
+ const head = 2 * ST.padY + st.length * ST.sth + Math.max(1, ls.length) * ST.nlh, comp = rows.length ? 8 + rows.length * ST.rl : 0, dec = n.composite ? ST.dec : 0;
10195
+ const w = Math.ceil(Math.min(ST.maxW, Math.max(ST.minW, ...st.map(t => textW(t, ST.st) + 2 * ST.padX), ...ls.map(l => textW(l, ST.name) + 2 * ST.padX), ...rows.map(r => textW(r, ST.row) + 2 * ST.padX))));
10196
+ return { st, ls, rows, head, comp, dec, w, h: Math.max(ST.minH, head + comp + dec) };
10197
+ }
10198
+ SHAPE.state = {
10199
+ size: n => { const p = stParts(n); return { w: p.w, h: p.h }; },
10200
+ out: SHAPE.box.out, inset: SHAPE.box.inset,
10201
+ draw(n) {
10202
+ const p = stParts(n), { x, y, w, h } = n, cx = x + w / 2, max = w - 2 * ST.padX, text = (tx, ty, t, px, wt, fill, cls, more = '') => `<text x="${r2(tx)}" y="${r2(ty)}"${more} class="${cls}" fill="${fill}" style="font:${wt} ${px}px var(--um-sans)">${esc(fit(t, px, max))}</text>`;
10203
+ const hh = p.comp || p.dec ? p.head : h; let ty = y + (hh - p.st.length * ST.sth - Math.max(1, p.ls.length) * ST.nlh) / 2; const hd = [];
10204
+ p.st.forEach(t => { hd.push(text(cx, ty + 11, t, ST.st, 400, ST.line, 'c-st', ' text-anchor="middle"')); ty += ST.sth; });
10205
+ p.ls.forEach(l => { hd.push(text(cx, ty + 12, l, ST.name, 600, ST.ink, 'c-name', ` text-anchor="middle" data-o="${esc(n.id)}"`)); ty += ST.nlh; });
10206
+ const out = [stBody(SHAPE.box.out(n), ST.bg), `<g class="c-head" data-band="${r2(y)} ${r2(y + hh)}">${hd.join('')}</g>`];
10207
+ if (p.comp) { const top = y + p.head;
10208
+ out.push(`<g class="c-comp" data-comp="activities" data-band="${r2(top)} ${r2(top + p.comp)}"><path d="M${x} ${r2(top)}H${x + w}" class="n-det" stroke="${ST.line}"/>`
10209
+ + p.rows.map((r, j) => text(x + ST.padX, top + 4 + j * ST.rl + 11, r, ST.row, 400, j >= (n.cap ?? Infinity) ? ST.line : ST.ink, j >= (n.cap ?? Infinity) ? 'c-more' : 'c-row')).join('') + '</g>'); }
10210
+ // UML's decomposition indicator: two small rounded rectangles joined, at the bottom right
10211
+ if (p.dec) { const by = y + h - 9, bx = x + w - 27;
10212
+ out.push(`<g class="st-dec" fill="none" stroke="${ST.line}" stroke-width="1.2"><rect x="${r2(bx)}" y="${r2(by - 2.5)}" width="7" height="5" rx="2"/><rect x="${r2(bx + 12)}" y="${r2(by - 2.5)}" width="7" height="5" rx="2"/><path d="M${r2(bx + 7)} ${r2(by)}H${r2(bx + 12)}"/></g>`); }
10213
+ return stNode(n, 'state', `State ${n.label ?? ''}`, out.join(''));
10214
+ },
10215
+ // open: a rectangle with 10 px corners on the box's border, the name centred in the 24 px header room, a line under it
10216
+ frame: f => `<rect class="family-box" x="${f.x}" y="${f.y}" width="${f.w}" height="${f.h}" rx="10" stroke="var(--um-muted)"/>`
10217
+ + `<text class="family-title" x="${r2(f.x + f.w / 2)}" y="${r2(f.y + 16)}" text-anchor="middle" fill="var(--um-text)" style="font:600 12px var(--um-sans)" data-t="ft" data-o="${esc(f.id)}">${esc(fit(f.title ?? f.label ?? f.id, 12, f.w - 24))}</text>`
10218
+ + `<path d="M${f.x} ${r2(f.y + 24)}H${f.x + f.w}" class="st-head" stroke="var(--um-muted)"/>`,
10219
+ };
10220
+ // a region: no border (its box is there to select, drop into and outline), its name at its top left in 10 px, and the
10221
+ // dashed divider before it (f.div: [x1, y1, x2, y2]) when it is not its state's first
10222
+ SHAPE.region = {
10223
+ frame: f => `<rect class="family-box" x="${f.x}" y="${f.y}" width="${f.w}" height="${f.h}" stroke="transparent" style="fill-opacity:0"/>`
10224
+ + (f.div ? `<path d="M${r2(f.div[0])} ${r2(f.div[1])}L${r2(f.div[2])} ${r2(f.div[3])}" class="st-div" fill="none" stroke="var(--um-muted)" stroke-dasharray="6 4"/>` : '')
10225
+ + (ST_HAS(f.label) ? `<text class="family-title" x="${r2(f.x + 8)}" y="${r2(f.y + 14)}" fill="var(--um-muted)" style="font:400 10px var(--um-sans)" data-t="ft" data-o="${esc(f.id)}">${esc(fit(f.label, 10, f.w - 16))}</text>` : ''),
10226
+ };
10227
+ for (const k of ['junction', 'history', 'terminate', 'state']) { const S = SHAPE[k]; S.face = n => [n.y, n.y + n.h]; S.ports = (n, side, j = 1) => portsOn(S, n, side, j); }
10228
+
10229
+ /* ---- the view read: its owners (§6.1, §8.2), its lines, and every element's words ---- */
10230
+ const ST_LOOK = { boxTypes: ['uml.State'], qualify: false, members: ['uml.Region'], shape: x => (x.type === 'uml.Region' ? 'region' : 'state'), word: x => (x.type === 'uml.Region' ? 'Region' : 'Composite state') };
10231
+ // every node by id, every member with its owner
10232
+ function stIndex(model) {
10233
+ const N = new Map(), M = new Map();
10234
+ ST_ARR(model && model.nodes).forEach((n, i) => { if (!ST_OBJ(n) || typeof n.id !== 'string' || N.has(n.id)) return; N.set(n.id, { x: n, path: `nodes[${i}]` });
10235
+ ST_ARR(n.members).forEach((m, j) => { if (ST_OBJ(m) && typeof m.id === 'string' && !M.has(m.id)) M.set(m.id, { x: m, owner: n, path: `nodes[${i}].members[${j}]` }); }); });
10236
+ const kids = new Map(); N.forEach(({ x }) => { if (x.parent != null) kids.set(x.parent, [...(kids.get(x.parent) || []), x]); });
10237
+ // a state's substates in the model: those it owns, directly or through its regions
10238
+ const subs = id => [...(kids.get(id) || []), ...stMembers((N.get(id) || {}).x, 'uml.Region').flatMap(r => kids.get(r.id) || [])];
10239
+ return { N, M, kids, subs };
10240
+ }
10241
+ // an element's owner (a region's is its state), and whether id is anc or lies under it
10242
+ const stUp = (I, id) => { const r = I.M.get(id); return r ? r.owner.id : (I.N.get(id) || { x: {} }).x.parent; };
10243
+ const stWithin = (I, id, anc) => { for (let p = id, seen = new Set(); p != null && !seen.has(p); p = stUp(I, p)) { if (p === anc) return true; seen.add(p); } return false; };
10244
+ // the words of an element as its accessible name says them: "Initial pseudostate", "Final state", "Placed"
10245
+ const stWord = x => (x.type === 'uml.Pseudostate' ? (ST_OWN(ST_KIND_WORD, stKind(x)) || 'Pseudostate') + (ST_HAS(x.label) ? ' ' + x.label : '') : x.type === 'uml.FinalState' ? 'Final state' + (ST_HAS(x.label) ? ' ' + x.label : '') : stName(x));
10246
+ // a state's activities as its compartment and its name say them
10247
+ const stActs = x => ST_ACTS.filter(k => ST_HAS(ST_PROPS(x)[k])).map(k => [k, String(ST_PROPS(x)[k])]);
10248
+ const stRows = x => [...stActs(x).map(([k, t]) => `${k} / ${t}`), ...stMembers(x, 'uml.InternalTransition').map(stPartsText)];
10249
+ function stPic(model, view) {
10250
+ const pic = ownersOn(model, view, ST_LOOK), I = stIndex(model);
10251
+ const boxes = pic.boxes.map(b => { const x = pic.at.get(b.id);
10252
+ if (x.type === 'uml.Region') { const s = pic.at.get(b.parent), rs = stMembers(s, 'uml.Region'), k = rs.indexOf(x) + 1; // s: none when ownersOn broke a cycle
10253
+ return { ...b, aria: `Region ${ST_HAS(x.label) ? x.label : k} of ${s ? stName(s) : String(b.parent ?? '')}, ${stPlural(pic.holds(b.id).length, 'state')}` }; }
10254
+ return { ...b, aria: stNodeName(x, pic, I, true) }; });
10255
+ return { ...pic, I, boxes };
10256
+ }
10257
+ // accessible names (§14): "State Placed, entry reserve the books, …, 1 internal transition"; "Composite state Fulfilling,
10258
+ // 2 regions"; "Initial pseudostate in Parcel"; "Final state"
10259
+ function stNodeName(x, pic, I, open) {
10260
+ if (x.type === 'uml.Comment') return 'Comment: ' + stName(x);
10261
+ const st = stStereo(x), why = st.length ? ` (${st.join(', ')})` : '', up = x.parent != null && pic.at.get(x.parent), within = up && !open && x.type !== 'uml.State' ? ` in ${stName(up)}` : '';
10262
+ if (x.type !== 'uml.State') return stWord(x) + why + within;
10263
+ const it = stMembers(x, 'uml.InternalTransition').length, rs = stMembers(x, 'uml.Region').length, subs = I.subs(x.id).length;
10264
+ const parts = [...stActs(x).map(([k, t]) => `${k} ${t}`), ...(it ? [stPlural(it, 'internal transition')] : [])];
10265
+ if (!subs && !rs) return [`State ${stName(x)}${why}`, ...parts].join(', ');
10266
+ return [`Composite state ${stName(x)}${why}`, ...parts, rs ? stPlural(rs, 'region') : stPlural(subs, 'state'), ...(open ? [] : ['closed'])].join(', ');
10267
+ }
10268
+ // the lines drawn: those the view lists, or with no list the model's of this type's kinds, between two elements it shows
10269
+ function stLines(model, view, pic) {
10270
+ const E = new Map(); ST_ARR(model && model.edges).forEach(e => { if (ST_OBJ(e) && typeof e.id === 'string' && !E.has(e.id)) E.set(e.id, e); });
10271
+ const shown = new Set(pic.ids), list = Array.isArray(view.edges) ? view.edges.map(x => (typeof x === 'string' ? E.get(x) : x)) : [...E.values()];
10272
+ return list.filter(e => ST_OBJ(e) && typeof e.id === 'string' && ST_LINES.includes(e.type) && shown.has(e.from) && shown.has(e.to));
10273
+ }
10274
+ const stLineLabel = e => (e.type === 'uml.Transition' ? [...stStereo(e).map(t => `«${t}»`), stPartsText(e)].filter(ST_HAS).join(' ') : stStereo(e).map(t => `«${t}»`).join(' '));
10275
+ // "Transition from Capturing payment to itself: capture failed [attempts < 3] / count the attempt"
10276
+ function stLineName(e, pic) {
10277
+ const nm = id => stWord(pic.at.get(id)), said = stLineLabel(e), word = e.type === 'uml.CommentLink' ? 'Comment link' : 'Transition';
10278
+ return `${word} from ${nm(e.from)} to ${e.from === e.to ? 'itself' : nm(e.to)}${said ? ': ' + said : ''}`;
10279
+ }
10280
+ // reading order (§14): readingOrder (a box, then what it holds, depth first), the top initial pseudostates first; then the lines
10281
+ function stOrder(pic, lines) {
10282
+ const ids = readingOrder(pic), first = ids.filter(id => pic.owner(id) == null && stKind(pic.at.get(id)) === 'initial');
10283
+ return [...first, ...ids.filter(id => !first.includes(id)), ...lines.map(e => e.id)];
10284
+ }
10285
+
10286
+ /* ---- the legend: each line kind drawn, then each glyph ---- */
10287
+ const stLegendItem = (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>`;
10288
+ const ST_LEGEND = [
10289
+ ['uml.Transition', 'Transition', 'trigger [guard] / effect: from one state to the next', '<g stroke="var(--um-muted)" stroke-width="1.4" stroke-linejoin="round" fill="none"><path d="M2 8H26"/><path d="M17 4L26 8L17 12"/></g>'],
10290
+ ['uml.CommentLink', 'Comment link', 'what a comment is about', '<path d="M2 8H26" stroke="var(--um-muted)" stroke-width="1.4" stroke-dasharray="3 2"/>'],
10291
+ ['initial', 'Initial', 'where a region starts', '<circle cx="14" cy="8" r="5" fill="var(--um-text)"/>'],
10292
+ ['final', 'Final state', 'where a region ends', '<circle cx="14" cy="8" r="6" fill="none" stroke="var(--um-muted)" stroke-width="1.2"/><circle cx="14" cy="8" r="3.5" fill="var(--um-text)"/>'],
10293
+ ['diamond', 'Choice', 'one exit, by the guards', '<path d="M14 2L20 8L14 14L8 8Z" fill="none" stroke="var(--um-muted)" stroke-width="1.2"/>'],
10294
+ ['junction', 'Junction', 'transitions joined or split', '<circle cx="14" cy="8" r="3.5" fill="var(--um-text)"/>'],
10295
+ ['bar', 'Fork or join', 'into or out of regions at once', '<rect x="12.5" y="1" width="3" height="14" fill="var(--um-text)"/>'],
10296
+ ['history', 'History', 'H back to the last substate, H* at any depth', '<circle cx="14" cy="8" r="6.5" fill="none" stroke="var(--um-muted)" stroke-width="1.2"/><text x="14" y="11" text-anchor="middle" fill="var(--um-text)" style="font:600 8px var(--um-sans)">H</text>'],
10297
+ ['terminate', 'Terminate', 'the machine stops', '<path d="M9 3L19 13M19 3L9 13" stroke="var(--um-text)" stroke-width="1.6"/>'],
10298
+ ];
10299
+ const stLegend = (nodes, edges) => { const has = new Set([...edges.map(e => e.type), ...nodes.map(n => n.shape)]); return ST_LEGEND.filter(([k]) => has.has(k)).map(([, name, says, svg]) => stLegendItem(svg, name, says)); };
10300
+
10301
+ /* ---- the view laid out (§10.1) ---- */
10302
+ // after the layout, on a copy (§2.4): each state's regions stretched across it when they are stacked on one axis, and a
10303
+ // dashed divider in the gap before each but the first; regions that part on both axes (a line between them ranked one
10304
+ // before the other, region-cross) get a divider across the two only where they part on one
10305
+ function stRegions(g, pic) {
10306
+ const by = new Map((g.families || []).map(f => [f.id, f])), out = new Map();
10307
+ pic.boxes.filter(b => pic.at.get(b.id).type === 'uml.State').forEach(b => {
10308
+ const S = by.get(b.id), rs = pic.boxes.filter(r => r.parent === b.id && pic.at.get(r.id).type === 'uml.Region').map(r => by.get(r.id)).filter(Boolean);
10309
+ if (!S || rs.length < 2) return;
10310
+ const parts = (a, c, v) => (v ? c.y >= a.y + a.h : c.x >= a.x + a.w), meets = (a, c, v) => (v ? a.x < c.x + c.w && c.x < a.x + a.w : a.y < c.y + c.h && c.y < a.y + a.h);
10311
+ const along = v => { const s = rs.slice().sort((a, c) => (v ? a.y - c.y || a.x - c.x : a.x - c.x || a.y - c.y)); return s.slice(1).every((c, i) => parts(s[i], c, v) && meets(s[i], c, v)) ? s : null; };
10312
+ const V = along(true), H = !V && along(false), s = V || H;
10313
+ if (s) s.forEach((r, i) => { const p = s[i - 1], mid = p && (V ? (p.y + p.h + r.y) / 2 : (p.x + p.w + r.x) / 2);
10314
+ out.set(r.id, { ...r, ...(V ? { x: S.x + 6, w: S.w - 12 } : { y: S.y + 30, h: S.h - 36 }), ...(p ? { div: V ? [S.x, mid, S.x + S.w, mid] : [mid, S.y + 24, mid, S.y + S.h] } : {}) }); });
10315
+ else rs.slice().sort((a, c) => a.y - c.y || a.x - c.x).forEach((r, i, l) => { const p = l[i - 1]; if (!p) return;
10316
+ const v = parts(p, r, true) && meets(p, r, true), h = parts(p, r, false) && meets(p, r, false); if (!v && !h) return;
10317
+ const m = v ? (p.y + p.h + r.y) / 2 : (p.x + p.w + r.x) / 2;
10318
+ out.set(r.id, { ...r, div: v ? [Math.min(p.x, r.x), m, Math.max(p.x + p.w, r.x + r.w), m] : [m, Math.min(p.y, r.y), m, Math.max(p.y + p.h, r.y + r.h)] }); });
10319
+ });
10320
+ return out.size ? (g.families || []).map(f => out.get(f.id) || f) : g.families;
10321
+ }
10322
+ function stGeo({ model, view, pos, memo }) {
10323
+ const pic = stPic(model, view), I = pic.I, lines = stLines(model, view, pic), down = view.direction === 'down', present = ST_OBJ(view.present) ? view.present : {};
10324
+ const P = id => (ST_OWN(pos, id) || [0, 0]), bars = new Set(), busy = new Map();
10325
+ lines.forEach(e => { if (e.type !== 'uml.Transition') return; [['out', e.from], ['in', e.to]].forEach(([w, id]) => { const b = busy.get(id) || { in: 0, out: 0 }; b[w]++; busy.set(id, b); }); });
10326
+ const nodes = pic.ids.filter(id => !pic.open.has(id)).map(id => { const x = pic.at.get(id), st = stStereo(x), pr = ST_OBJ(ST_OWN(present, id)) ? present[id] : {};
10327
+ let look;
10328
+ if (x.type === 'uml.State') look = { shape: 'state', label: stName(x), stereotypes: st, acts: stRows(x), collapsed: ST_ARR(pr.collapsed), ...(Number.isFinite(pr.rows) ? { cap: pr.rows } : {}), composite: I.subs(id).length > 0 || stMembers(x, 'uml.Region').length > 0 };
10329
+ else if (x.type === 'uml.Pseudostate') { const k = stKind(x); look = { shape: k ? ST_KIND_SHAPE[k] : 'junction', label: '' };
10330
+ if (look.shape === 'bar') { const b = busy.get(id) || { in: 0, out: 0 }; bars.add(id); Object.assign(look, { busy: Math.max(b.in, b.out), down }); }
10331
+ if (k === 'deepHistory') look.deep = true; }
10332
+ else if (x.type === 'uml.FinalState') look = { shape: 'final', label: '' };
10333
+ else if (x.type === 'uml.Comment') look = { shape: 'note', label: stName(x) };
10334
+ else look = { shape: 'classifier', label: stName(x), stereotypes: st, compartments: [] };
10335
+ const d = { id, type: x.type || 'uml.State', kind: 'file', ...look, aria: stNodeName(x, pic, I, false), col: P(id)[0], row: P(id)[1] };
10336
+ return { ...d, ...SHAPE[d.shape].size(d) }; });
10337
+ const edges = lines.map(e => { const label = stLineLabel(e), T = e.type === 'uml.Transition';
10338
+ return { id: e.id, _id: e.id, type: e.type, from: e.from, to: e.to, label: label || undefined, end: T ? { from: 'none', to: 'open' } : { from: 'none', to: 'none', dashed: true }, aria: stLineName(e, pic),
10339
+ ...(T ? { cut: 280 } : {}), ...(bars.has(e.from) ? { fromSide: 'right' } : {}), ...(bars.has(e.to) ? { toSide: 'left' } : {}), // a bar's long sides, in the layout's own axes
10340
+ ...(ST_OBJ(e.style) || typeof e.style === 'string' ? { style: e.style } : {}) }; });
10341
+ // the columns are not spread by the widest label as a class view's are (class.js typeGeo): with transitions cut at 280 px
10342
+ // that made 10-state-machine.json 6,700 px wide; a label with no room between two boxes is left to its tooltip and name.
10343
+ // Gaps of 48 and 40 px: the router's grid is the picture's area, and on 50 states three composites deep they route a fifth
10344
+ // faster than 64 and 56 (P1), with fewer crossings and every label drawn in 10-state-machine.json laid out right
10345
+ const g = layoutTyped({ nodes, edges, ...(pic.boxes.length ? { containers: pic.boxes } : {}), direction: down ? 'down' : 'right',
10346
+ gap: 48, rgap: 40, repair: false, reroute: false }, memo);
10347
+ const families = stRegions(g, pic);
10348
+ // a slot on each node and box: src/typed/edit.js hands the add hook its `at`, with the ids selected (sel), only where the
10349
+ // geometry has slots; the Region entry reads the state selected there. No default gesture of this type reads a slot
10350
+ const slots = [...g.nodes, ...(families || [])].map(n => ({ after: n.id, parent: null, x: n.x + n.w / 2, y: n.y + n.h / 2 }));
10351
+ return { ...g, families, slots, legend: stLegend(g.nodes, g.edges), order: stOrder(pic, lines) }; // a new object: what layoutTyped returned is never edited (§2.4)
10352
+ }
10353
+
10354
+ /* ---- validators (§10.2). Each problem: { level, rule, path, message, ids } ---- */
10355
+ const ST_RULES = {
10356
+ 'transition-ends': 'a transition joins states, pseudostates or final states, never a region, a comment or a line',
10357
+ 'owner-kinds': 'a substate\'s or pseudostate\'s owner is a state or a region of one; a region is a member of a state',
10358
+ 'initial-one': 'a region, a state with no regions, or the top has at most one initial pseudostate',
10359
+ 'initial-out': 'an initial pseudostate has no incoming transition and one outgoing, with no trigger or guard',
10360
+ 'final-out': 'a final state and a terminate pseudostate have no outgoing transition',
10361
+ 'final-kind': 'a final state has no regions, no entry, do or exit, and no internal transitions',
10362
+ 'choice-guards': 'each transition out of a choice has a guard, and at most one is else',
10363
+ 'fork-join': 'a fork has one incoming transition and two or more outgoing into different regions of one state; a join the reverse; none with a trigger or a guard',
10364
+ 'history-owner': 'a history pseudostate\'s owner is a state or a region',
10365
+ 'history-one': 'a region or a state has at most one shallow and one deep history, and a history at most one outgoing transition',
10366
+ 'composite-entry': 'a transition onto a composite state\'s border finds an initial pseudostate in each of its regions',
10367
+ 'reserved-trigger': 'no internal transition\'s trigger is entry, do or exit',
10368
+ 'region-mixed': 'a state does not hold both regions and direct substates',
10369
+ 'region-cross': 'no transition joins the contents of two regions of one state',
10370
+ 'internal-trigger': 'an internal transition has a trigger',
10371
+ 'pseudo-kind': 'a pseudostate\'s kind is one of initial, choice, junction, fork, join, shallowHistory, deepHistory, terminate',
10372
+ 'label-syntax': 'a transition\'s trigger, guard and effect print as text that reads back as them',
10373
+ };
10374
+ function stProblems(model) {
10375
+ const out = [], I = stIndex(ST_OBJ(model) ? model : {}), { N, M } = I, say = (level, rule, path, ids, msg) => out.push({ level, rule, path, ids: ids.filter(x => x != null), message: `${rule}: ${msg}` });
10376
+ const el = id => (N.get(id) || M.get(id) || {}).x, q = id => { const x = el(id); return x ? JSON.stringify(x.type === 'uml.State' ? stName(x) : `${stWord(x)} ${id}`.trim()) : JSON.stringify(String(id)); };
10377
+ const ty = id => (el(id) || {}).type, vertex = id => N.has(id) && ST_VERTEX.includes(ty(id)), kind = id => (N.has(id) && ty(id) === 'uml.Pseudostate' ? stKind(el(id)) : null);
10378
+ // where an element sits, up through regions and states: [[state, region or null], …], nearest first
10379
+ const ups = id => { const out2 = [], seen = new Set(); let p = (el(id) || {}).parent;
10380
+ while (p != null && !seen.has(p)) { seen.add(p); const r = M.get(p);
10381
+ if (r && r.x.type === 'uml.Region') { out2.push([r.owner.id, p]); p = r.owner.parent; } else if (N.has(p)) { out2.push([p, null]); p = N.get(p).x.parent; } else break; }
10382
+ return out2; };
10383
+ const ts = ST_ARR(model && model.edges).map((e, i) => [e, `edges[${i}]`]).filter(([e]) => ST_OBJ(e) && e.type === 'uml.Transition');
10384
+ const ins = new Map(), outs = new Map(), add = (m, id, x) => m.set(id, [...(m.get(id) || []), x]);
10385
+ ts.forEach(([e, path]) => { add(outs, e.from, [e, path]); add(ins, e.to, [e, path]);
10386
+ const bad = [e.from, e.to].filter(id => !vertex(id));
10387
+ if (bad.length) say('error', 'transition-ends', path, [e.id, ...bad], `${e.id} ends on ${bad.map(id => `${q(id)}${el(id) ? `, a ${String(ty(id)).split('.').pop()}` : ', which is no element'}`).join(' and ')}: a transition joins states, pseudostates or final states`);
10388
+ if (printTransition(ST_PROPS(e)) == null) say('warning', 'label-syntax', `${path}.props`, [e.id], `${e.id}'s parts do not read back as "${transitionText(ST_PROPS(e))}": edit them in the properties panel`); });
10389
+ const has = (e, k) => ST_HAS(ST_PROPS(e)[k]), list = es => es.map(([e]) => e.id).join(', ');
10390
+ // nodes: owners, kinds, and the pseudostates' and final states' lines
10391
+ const inits = new Map(), hist = new Map();
10392
+ N.forEach(({ x, path }, id) => {
10393
+ if (ST_VERTEX.includes(x.type) && x.parent != null) { const r = M.get(x.parent), p = N.get(x.parent);
10394
+ if (!(r && r.x.type === 'uml.Region' && r.owner.type === 'uml.State') && !(p && p.x.type === 'uml.State')) say('error', 'owner-kinds', `${path}.parent`, [id, x.parent], `${q(id)} is owned by ${q(x.parent)}, which is not a state or a region of one`); }
10395
+ stMembers(x, 'uml.Region').forEach(m => { if (x.type !== 'uml.State') say('error', 'owner-kinds', M.get(m.id).path, [id, m.id], `region ${m.id} is a member of ${q(id)}, which is not a state`); });
10396
+ const i = ins.get(id) || [], o = outs.get(id) || [], ids = es => [id, ...es.map(([e]) => e.id)];
10397
+ if (x.type === 'uml.FinalState') {
10398
+ if (o.length) say('error', 'final-out', path, ids(o), `${q(id)} has ${stPlural(o.length, 'outgoing transition')} (${list(o)}); a final state has none`);
10399
+ const bad = [...stMembers(x, 'uml.Region'), ...stMembers(x, 'uml.InternalTransition')], acts = stActs(x).map(([k]) => k);
10400
+ if (bad.length || acts.length) say('error', 'final-kind', path, [id, ...bad.map(m => m.id)], `${q(id)} has ${[...(bad.length ? [stPlural(bad.length, 'region or internal transition')] : []), ...acts].join(', ')}; a final state has none`); }
10401
+ if (x.type === 'uml.State') { const rs = stMembers(x, 'uml.Region'), direct = I.kids.get(id) || [];
10402
+ if (rs.length && direct.length) say('warning', 'region-mixed', path, [id, ...direct.map(d => d.id)], `${q(id)} holds regions and also ${direct.map(d => q(d.id)).join(', ')} directly`);
10403
+ stMembers(x, 'uml.InternalTransition').forEach(m => { const t = ST_PROPS(m).trigger, mp = M.get(m.id).path;
10404
+ if (ST_ACTS.includes(String(t ?? '').trim())) say('error', 'reserved-trigger', `${mp}.props.trigger`, [id, m.id], `internal transition ${m.id} of ${q(id)} has the trigger "${String(t).trim()}", which names the state's ${String(t).trim()} activity`);
10405
+ else if (!ST_HAS(t)) say('warning', 'internal-trigger', mp, [id, m.id], `internal transition ${m.id} of ${q(id)} has no trigger`);
10406
+ if (printTransition(ST_PROPS(m)) == null) say('warning', 'label-syntax', `${mp}.props`, [m.id], `internal transition ${m.id}'s parts do not read back as "${transitionText(ST_PROPS(m))}"`); }); }
10407
+ if (x.type !== 'uml.Pseudostate') return;
10408
+ const k = stKind(x), where = x.parent ?? null;
10409
+ if (!k) say('warning', 'pseudo-kind', `${path}.props.kind`, [id], `${q(id)}'s kind is ${JSON.stringify(ST_PROPS(x).kind ?? null)}, not one of ${ST_KINDS.join(', ')}; drawn as a junction`);
10410
+ const guarded = es => es.filter(([e]) => has(e, 'trigger') || has(e, 'guard'));
10411
+ if (k === 'initial') { add(inits, where, id);
10412
+ if (i.length) say('error', 'initial-out', path, ids(i), `${q(id)} has ${stPlural(i.length, 'incoming transition')} (${list(i)}); it may have none`);
10413
+ if (o.length > 1) say('error', 'initial-out', path, ids(o), `${q(id)} has ${o.length} outgoing transitions (${list(o)}); it may have one`);
10414
+ if (guarded(o).length) say('error', 'initial-out', path, ids(guarded(o)), `the transition out of ${q(id)} (${list(guarded(o))}) has a trigger or a guard`);
10415
+ if (!o.length) say('warning', 'initial-out', path, [id], `${q(id)} has no outgoing transition yet`); }
10416
+ if (k === 'terminate' && o.length) say('error', 'final-out', path, ids(o), `${q(id)} has ${stPlural(o.length, 'outgoing transition')} (${list(o)}); a terminate pseudostate has none`);
10417
+ if (k === 'choice') { const g = ([e]) => String(ST_PROPS(e).guard ?? '').trim(), bare = o.filter(t => !g(t)), elses = o.filter(t => g(t) === 'else');
10418
+ if (bare.length) say('warning', 'choice-guards', path, ids(bare), `${stPlural(bare.length, 'transition')} out of ${q(id)} ${bare.length === 1 ? 'has' : 'have'} no guard (${list(bare)})`);
10419
+ if (elses.length > 1) say('warning', 'choice-guards', path, ids(elses), `${q(id)} has ${elses.length} exits guarded else (${list(elses)}); at most one may be`); }
10420
+ if (k === 'fork' || k === 'join') { const [one, many, w1, w2] = k === 'fork' ? [i, o, 'incoming', 'outgoing'] : [o, i, 'outgoing', 'incoming'];
10421
+ if (one.length > 1) say('error', 'fork-join', path, ids(one), `${q(id)} has ${one.length} ${w1} transitions (${list(one)}); it may have one`);
10422
+ if (guarded(many).length) say('error', 'fork-join', path, ids(guarded(many)), `the transitions ${k === 'fork' ? 'out of' : 'into'} ${q(id)} (${list(guarded(many))}) have a trigger or a guard`);
10423
+ if (many.length < 2) say('warning', 'fork-join', path, ids(many), `${q(id)} has ${stPlural(many.length, w2 + ' transition')}; it wants two or more`);
10424
+ else { // each end in a different region of one orthogonal state, at any depth inside those regions
10425
+ const far = many.map(([e]) => (k === 'fork' ? e.to : e.from)), maps = far.map(f => new Map(ups(f).filter(([, r]) => r).map(([s, r]) => [s, r])));
10426
+ const ok = [...maps[0].keys()].some(s => maps.every(m => m.has(s)) && new Set(maps.map(m => m.get(s))).size === far.length);
10427
+ if (!ok) say('error', 'fork-join', path, [id, ...far], `the ${k === 'fork' ? 'targets' : 'sources'} of ${q(id)} (${far.map(q).join(', ')}) do not lie in different regions of one state`); } }
10428
+ if (k === 'shallowHistory' || k === 'deepHistory') { add(hist, JSON.stringify([where, k]), id);
10429
+ if (where == null) say('error', 'history-owner', path, [id], `${q(id)} is at the top; a history is owned by a state or a region`);
10430
+ if (o.length > 1) say('error', 'history-one', path, ids(o), `${q(id)} has ${o.length} outgoing transitions (${list(o)}); a history has one default at most`); }
10431
+ });
10432
+ inits.forEach((ids, where) => { if (ids.length > 1) say('error', 'initial-one', N.has(ids[1]) ? N.get(ids[1]).path : 'nodes', [...ids, where], `${where == null ? 'the top' : q(where)} has ${ids.length} initial pseudostates (${ids.join(', ')})`); });
10433
+ hist.forEach((ids, k) => { if (ids.length > 1) { const [where, kind] = JSON.parse(k);
10434
+ say('error', 'history-one', N.get(ids[1]).path, [...ids, where], `${where == null ? 'the top' : q(where)} has ${ids.length} ${ST_KIND_WORD[kind].toLowerCase()} pseudostates (${ids.join(', ')})`); } });
10435
+ ts.forEach(([e, path]) => {
10436
+ // onto a composite's border: an initial pseudostate in each region (or in the state, with none)
10437
+ const to = N.get(e.to); if (to && to.x.type === 'uml.State' && I.subs(e.to).length) {
10438
+ const rs = stMembers(to.x, 'uml.Region'), places = rs.length ? rs.map(r => r.id) : [e.to], none = places.filter(p => !(I.kids.get(p) || []).some(c => kind(c.id) === 'initial'));
10439
+ if (none.length) say('warning', 'composite-entry', path, [e.id, e.to, ...(rs.length ? none : [])], `${e.id} ends on ${q(e.to)}'s border, and ${rs.length ? `${none.map(q).join(', ')} ${none.length === 1 ? 'has' : 'have'}` : 'it has'} no initial pseudostate`); }
10440
+ // between two regions of one state
10441
+ const a = new Map(ups(e.from).filter(([, r]) => r)), b = new Map(ups(e.to).filter(([, r]) => r)), s = [...a.keys()].find(k => b.has(k) && a.get(k) !== b.get(k));
10442
+ if (s != null) say('warning', 'region-cross', path, [e.id, a.get(s), b.get(s)], `${e.id} joins ${q(e.from)} in ${q(a.get(s))} and ${q(e.to)} in ${q(b.get(s))}, two regions of ${q(s)}`);
10443
+ });
10444
+ return out;
10445
+ }
10446
+
10447
+ /* ---- describe(): the view in sentences, starting at the top initial pseudostate, each element in reading order by its
10448
+ accessible name, then each transition ---- */
10449
+ function stDescribe(model, viewId) {
10450
+ const v = ST_ARR(model && model.views).find(x => ST_OBJ(x) && x.id === viewId); if (!v) throw new Error(`umlimator: no view ${JSON.stringify(viewId)}`);
10451
+ const pic = stPic(model, v), lines = stLines(model, v, pic), by = new Map(lines.map(e => [e.id, e])), box = new Map(pic.boxes.map(b => [b.id, b]));
10452
+ return [`${v.title || v.id}: a state machine diagram of ${stPlural(pic.ids.length, 'element')} and ${stPlural(lines.length, 'transition')}.`,
10453
+ ...stOrder(pic, lines).map(id => (by.has(id) ? stLineName(by.get(id), pic) : box.has(id) ? box.get(id).aria : stNodeName(pic.at.get(id), pic, pic.I, false)) + '.')].join('\n');
10454
+ }
10455
+
10456
+ /* ---- editing (§10.3): each hook returns commands, { commands, say }, null for the default, or { error } ---- */
10457
+ const stFresh = (model, base) => { const used = new Set(); JSON.stringify(model, (k, v) => { if (k === 'id' && typeof v === 'string') used.add(v); return v; });
10458
+ let k = 1; while (used.has(base + k)) k++; return base + k; };
10459
+ // add: a pseudostate with its entry's kind; a region into the state selected
10460
+ function stAdd(model, view, entry, label, at) {
10461
+ if (!ST_OBJ(entry)) return null;
10462
+ if (entry.type === 'uml.Pseudostate') { const k = ST_KINDS.includes(ST_PROPS(entry).kind) ? entry.props.kind : 'junction';
10463
+ return [{ op: 'add', element: { id: stFresh(model, k.replace(/[A-Z]/g, c => '-' + c.toLowerCase())), type: 'uml.Pseudostate', ...(ST_HAS(label) ? { label: String(label) } : {}), props: { kind: k } }, view: view.id }]; }
10464
+ if (entry.type !== 'uml.Region') return null;
10465
+ const s = ST_ARR(at && at.sel).map(id => ST_ARR(model.nodes).find(n => ST_OBJ(n) && n.id === id)).find(n => n && n.type === 'uml.State');
10466
+ if (!s) return { error: 'Select a state to add a region to' };
10467
+ return [{ op: 'add', element: { id: stFresh(model, `${s.id}.region`), type: 'uml.Region', ...(ST_HAS(label) ? { label: String(label) } : {}) }, owner: s.id }];
10468
+ }
10469
+ // the commands that give an element of `e`'s the parts `text` reads as, one set per part that changed (undefined clears one)
10470
+ const stSetParts = (x, r) => ['trigger', 'guard', 'effect'].filter(k => (ST_PROPS(x)[k] ?? undefined) !== r[k]).map(k => ({ op: 'set', id: x.id, path: 'props.' + k, value: r[k] }));
10471
+ // rename: F2 on a transition parses its text (§7.2)
10472
+ function stRename(model, view, id, text) {
10473
+ const e = ST_ARR(model && model.edges).find(x => ST_OBJ(x) && x.id === id); if (!e || e.type !== 'uml.Transition') return null;
10474
+ if (printTransition(ST_PROPS(e)) == null) return { error: `${id}'s text does not read back: edit its trigger, guard and effect in the properties panel` };
10475
+ // the text F2 opens is the drawn label, its «stereotypes» first: they are not parts
10476
+ const pre = stStereo(e).map(t => `«${t}»`).join(' '), t = String(text ?? '').trimStart(), r = parseTransition(pre && t.startsWith(pre) ? t.slice(pre.length) : t);
10477
+ return r.error ? r : stSetParts(e, r);
10478
+ }
10479
+ // a compartment row typed on a state: `entry / …`, `do / …` and `exit / …` set that activity (a second one replaces the
10480
+ // first, said); null for any other row, which is an internal transition or a region (member below). member: the row's id
10481
+ // when it edits one, which then goes; a region's row is never one (null: member.edit names the region)
10482
+ function stActivityRow(model, stateId, text, member) {
10483
+ const s = ST_ARR(model && model.nodes).find(n => ST_OBJ(n) && n.id === stateId), r = parseTransition(text);
10484
+ if (!s || s.type !== 'uml.State' || r.error || !ST_ACTS.includes(r.trigger)) return null;
10485
+ if (member != null && !stMembers(s, 'uml.InternalTransition').some(m => m.id === member)) return null;
10486
+ const k = r.trigger, was = ST_PROPS(s)[k];
10487
+ if (r.guard != null) return { error: `${k} / … is the state's ${k} activity: it takes no guard` };
10488
+ if (r.effect == null) return { error: `${k} / … is the state's ${k} activity: write it after the /` };
10489
+ const commands = [{ op: 'set', id: stateId, path: 'props.' + k, value: r.effect }, ...(member != null ? [{ op: 'delete', id: member }] : [])];
10490
+ return ST_HAS(was) && was !== r.effect ? { commands, say: `${k} / ${r.effect} replaces ${k} / ${was}` } : commands;
10491
+ }
10492
+ // the member rows (§10.3): an internal transition by its printed text; a region as `region <name>`
10493
+ const stRegionRow = t => { const m = /^\s*region\b\s*(.*)$/s.exec(String(t ?? '')); return m ? m[1].trim() : null; };
10494
+ const ST_MEMBER = {
10495
+ print: m => (m.type === 'uml.Region' ? `region ${m.label ?? ''}`.trim() : stPartsText(m)),
10496
+ // `region x / y` and `region x [y]` are internal transitions
10497
+ add(text) { const nm = stRegionRow(text); if (nm != null && !/[/[]/.test(nm)) return { type: 'uml.Region', label: nm || undefined, props: {} };
10498
+ const r = parseTransition(text, 'an internal transition is trigger [guard] / effect'); if (r.error) return r;
10499
+ if (ST_ACTS.includes(r.trigger)) return { error: `${r.trigger} / … is the state's ${r.trigger} activity: select the state and type it again` };
10500
+ return { type: 'uml.InternalTransition', props: Object.fromEntries(['trigger', 'guard', 'effect'].filter(k => r[k] != null).map(k => [k, r[k]])) }; },
10501
+ edit(m, text) {
10502
+ if (m.type === 'uml.Region') { const nm = stRegionRow(text) ?? String(text ?? '').trim(); return { commands: [{ op: 'set', id: m.id, path: 'label', value: nm || undefined }] }; }
10503
+ const r = parseTransition(text, 'an internal transition is trigger [guard] / effect'); if (r.error) return r;
10504
+ if (ST_ACTS.includes(r.trigger)) return { error: `${r.trigger} / … is the state's ${r.trigger} activity` };
10505
+ return { commands: stSetParts(m, r) }; },
10506
+ holds: n => n.type === 'uml.State',
10507
+ };
10508
+ /* moveTo: a node dropped into the box of a state with exactly one region goes to that region (§6.1, 3): the box is the
10509
+ state's, since a lone region is not drawn. Anything else: null, the default (a pin, or a drop into a box) */
10510
+ function stMoveTo(model, view, id, at) {
10511
+ if (!ST_OBJ(at) || !Array.isArray(at.pt) || !ST_OBJ(at.geo)) return null;
10512
+ const [x, y] = at.pt, f = ST_ARR(at.geo.families).filter(b => x >= b.x && x <= b.x + b.w && y >= b.y && y <= b.y + b.h).sort((a, b) => a.w * a.h - b.w * b.h)[0];
10513
+ const s = f && ST_ARR(model.nodes).find(n => ST_OBJ(n) && n.id === f.id), rs = s && s.type === 'uml.State' ? stMembers(s, 'uml.Region') : [], n = ST_ARR(model.nodes).find(m => ST_OBJ(m) && m.id === id);
10514
+ if (rs.length !== 1 || !n || n.parent === rs[0].id || stWithin(stIndex(model), s.id, id)) return null; // into itself: the default refuses it
10515
+ const L = view.layout; return [{ op: 'reparent', id, parent: rs[0].id }, ...(ST_OBJ(L) && Object.hasOwn(L, id) ? [{ op: 'set', view: view.id, path: ['layout', id], value: undefined }] : [])];
10516
+ }
10517
+ // the Owner field: every state and region of the model by its name from the top ("Fulfilling", "Fulfilling / Parcel"),
10518
+ // but the element itself and what lies under it
10519
+ function stOwners(model, x) {
10520
+ if (!ST_OBJ(x) || x.from != null || !ST_VERTEX.includes(x.type) && x.type !== 'uml.Comment') return null;
10521
+ const I = stIndex(model);
10522
+ const full = id => { const out = [], seen = new Set(); for (let p = id; p != null && !seen.has(p); p = stUp(I, p)) { seen.add(p); const e = (I.N.get(p) || I.M.get(p)).x; out.unshift(ST_HAS(e.label) ? e.label : p); } return out.join(' / '); };
10523
+ return [...I.N.values()].filter(({ x: n }) => n.type === 'uml.State').flatMap(({ x: n }) => [{ id: n.id, label: full(n.id) }, ...stMembers(n, 'uml.Region').map(r => ({ id: r.id, label: full(r.id) }))])
10524
+ .filter(o => !stWithin(I, o.id, x.id));
10525
+ }
10526
+ // remove: a region deleted from the model (Shift + Delete on its box, the member list's ✕) says how many elements it held (§10.3)
10527
+ function stRemove(model, view, ids, fromModel) {
10528
+ const I = stIndex(model), rs = fromModel ? ids.filter(id => I.M.has(id) && I.M.get(id).x.type === 'uml.Region') : [];
10529
+ if (!rs.length) return null;
10530
+ const held = [...I.N.keys()].filter(n => rs.some(r => stWithin(I, n, r))).length, x = I.M.get(rs[0]).x;
10531
+ return { commands: ids.map(id => ({ op: 'delete', id })), say: `Deleted ${rs.length === 1 ? 'region ' + (ST_HAS(x.label) ? x.label : x.id) : stPlural(rs.length, 'region')} and the ${stPlural(held, 'element')} ${rs.length === 1 ? 'it' : 'they'} held` };
10532
+ }
10533
+
10534
+ /* the panel's member rows (edits.member, src/typed/edit.js; notes/stage-5.md §12.1): a row `entry / …` typed on a selected
10535
+ state is read here first, before the panel commits it (§7.2, §10.3); any other row is the panel's (ST_MEMBER) */
10536
+ const stMemberRow = (model, view, owner, id, text) => stActivityRow(model, owner, text, id);
10537
+
10538
+ /* ---- the type ---- */
10539
+ const ST_STEREO = { key: 'stereotypes', label: 'Stereotypes', kind: 'list' };
10540
+ const ST_PARTS = [['trigger', 'Trigger'], ['guard', 'Guard'], ['effect', 'Effect']].map(([key, label]) => ({ key, label, kind: 'text' }));
10541
+ const ST_TYPE = registerDiagramType('uml.state-machine', {
10542
+ title: 'state machine diagram',
10543
+ elements: [{ type: 'uml.State', label: 'State' },
10544
+ ...[['initial', 'Initial'], ['finalState', 'Final state'], ['choice', 'Choice'], ['junction', 'Junction'], ['fork', 'Fork'], ['join', 'Join'], ['shallowHistory', 'Shallow history'], ['deepHistory', 'Deep history'], ['terminate', 'Terminate']]
10545
+ .map(([kind, label]) => (kind === 'finalState' ? { type: 'uml.FinalState', label, name: false } : { type: 'uml.Pseudostate', label, props: { kind }, name: false })),
10546
+ { type: 'uml.Region', label: 'Region', name: false }, { type: 'uml.Comment', label: 'Comment' }],
10547
+ lines: [{ type: 'uml.Transition', label: 'Transition' }, { type: 'uml.CommentLink', label: 'Comment link' }],
10548
+ props: {
10549
+ 'uml.State': [...ST_ACTS.map(k => ({ key: k, label: k[0].toUpperCase() + k.slice(1), kind: 'text' })), ST_STEREO],
10550
+ 'uml.Pseudostate': [{ key: 'kind', label: 'Kind', kind: 'enum', options: ST_KINDS }, ST_STEREO],
10551
+ 'uml.FinalState': [ST_STEREO], 'uml.Region': [ST_STEREO],
10552
+ 'uml.Transition': [...ST_PARTS, ST_STEREO], 'uml.InternalTransition': ST_PARTS, 'uml.CommentLink': [],
10553
+ },
10554
+ layout: stGeo,
10555
+ boxes: (model, view) => stPic(model, view).boxes,
10556
+ owners: stOwners,
10557
+ member: ST_MEMBER,
10558
+ validate: stProblems,
10559
+ rules: ST_RULES,
10560
+ describe: stDescribe,
10561
+ // the PDF's tables (§15, decision 18): an element's kind, and a state's rows: entry, do and exit, then its members as the
10562
+ // panel prints them (each internal transition's `trigger [guard] / effect`, each region); null: its members as before
10563
+ word: (model, id) => { const I = stIndex(model), x = (I.N.get(id) || {}).x; if (!x) return null;
10564
+ return x.type === 'uml.Pseudostate' ? ST_OWN(ST_KIND_WORD, stKind(x)) || 'Pseudostate' : x.type === 'uml.FinalState' ? 'Final state'
10565
+ : x.type === 'uml.State' ? (I.subs(id).length || stMembers(x, 'uml.Region').length ? 'Composite state' : 'State') : null; },
10566
+ rows: (model, view, id) => { const x = (stIndex(model).N.get(id) || {}).x;
10567
+ return x && x.type === 'uml.State' ? [...stActs(x).map(([k, t]) => `${k} / ${t}`), ...ST_ARR(x.members).filter(ST_OBJ).map(ST_MEMBER.print)] : null; },
10568
+ // member is not enumerable: Object.keys(edits) stays 4.6's four (tests/state.test.mjs), as notes/stage-5.md §12.1 (as built) says
10569
+ edits: Object.defineProperty({ add: stAdd, rename: stRename, moveTo: stMoveTo, remove: stRemove }, 'member', { value: stMemberRow }),
10570
+ });
10571
+ /* ---------- the interaction overview diagram, uml.interaction-overview (work package 4.7, notes/stage-4.md §11) ----------
10572
+ Activity notation whose nodes are interactions: the control nodes of src/uml/flow.js (flow final included), interaction
10573
+ uses (stage 3's SHAPE.use, "ref", with a look that gives it a size and a border) and inline interactions (a frame "sd
10574
+ name" holding its sequence view drawn at full size, read only, from sqRun and sqDraw, S11), joined by control flows drawn
10575
+ "name [guard]" and cut at 280 px; comments. Each use and inline interaction names its uml.sequence view in opensView,
10576
+ which Enter on it selected, or a double-click, opens (Q11; notes §11.3 as built), and V changes (S18). Laid out by layoutTyped from the host's ranking, with
10577
+ repair and reroute off (§17); a view with no `edges` list draws control flows and comment links. Ships in
10578
+ lib/umlimator-all.js only. */
10579
+
10580
+ const OV_OBJ = x => x !== null && typeof x === 'object' && !Array.isArray(x), OV_ARR = x => (Array.isArray(x) ? x : []);
10581
+ const OV_HAS = x => x != null && x !== '';
10582
+ const OV_PROPS = x => (OV_OBJ(x && x.props) ? x.props : {});
10583
+ const OV_ENDS = [...FLOW_CONTROL, 'uml.InteractionUse', 'uml.Interaction'], OV_NODES = [...OV_ENDS, 'uml.Comment']; // model.md row 13
10584
+ const OV_LINES = ['uml.ControlFlow', 'uml.CommentLink'];
10585
+ const OV_WORD = { 'uml.InitialNode': 'Initial node', 'uml.ActivityFinalNode': 'Activity final', 'uml.FlowFinalNode': 'Flow final', 'uml.DecisionNode': 'Decision', 'uml.MergeNode': 'Merge',
10586
+ 'uml.ForkNode': 'Fork', 'uml.JoinNode': 'Join', 'uml.InteractionUse': 'Interaction use', 'uml.Interaction': 'Inline interaction', 'uml.Comment': 'Comment' };
10587
+ const OV_SHAPE = { 'uml.InitialNode': 'initial', 'uml.ActivityFinalNode': 'final', 'uml.FlowFinalNode': 'flow-final', 'uml.DecisionNode': 'diamond', 'uml.MergeNode': 'diamond',
10588
+ 'uml.ForkNode': 'bar', 'uml.JoinNode': 'bar', 'uml.InteractionUse': 'ov-use', 'uml.Interaction': 'ov-inline', 'uml.Comment': 'note' }; // any other type: rounded, warned of
10589
+ const OV_MAX = { heads: 8, messages: 20 }; // an inline interaction past either is drawn as its frame and name (S11)
10590
+ const OV_GAP = 26; // a label's room past its own size on half a gap: a stub's 22 px and a little air
10591
+ const OV_SD = { pad: 10, line: 'var(--um-muted)', bg: 'var(--um-stage-bg)' };
10592
+ const ovOwn = (o, k) => (Object.hasOwn(o, k) ? o[k] : undefined); // a lookup by a model's type: 'constructor' is not Object's (risks.md M3)
10593
+ const ovWordOf = t => ovOwn(OV_WORD, t) || String(t ?? '').split('.').pop().replace(/([a-z])([A-Z])/g, (_, a, b) => a + ' ' + b.toLowerCase());
10594
+ const ovPlural = (k, w, ws = w + 's') => `${k} ${k === 1 ? w : ws}`;
10595
+ const ovStereo = x => OV_ARR(OV_PROPS(x).stereotypes).filter(OV_HAS).map(String);
10596
+
10597
+ /* ---- the view read: its nodes, its lines (each with both ends shown), the model's views by id ---- */
10598
+ function ovIndex(model, view) {
10599
+ const N = new Map(), E = new Map(), V = new Map();
10600
+ OV_ARR(model && model.nodes).forEach((n, i) => { if (OV_OBJ(n) && typeof n.id === 'string' && !N.has(n.id)) N.set(n.id, [n, `nodes[${i}]`]); });
10601
+ OV_ARR(model && model.edges).forEach(e => { if (OV_OBJ(e) && typeof e.id === 'string' && !E.has(e.id)) E.set(e.id, e); });
10602
+ OV_ARR(model && model.views).forEach(v => { if (OV_OBJ(v) && typeof v.id === 'string' && !V.has(v.id)) V.set(v.id, v); });
10603
+ const ids = [...new Set(OV_ARR(view && view.nodeIds).filter(id => N.has(id)))], shown = new Set(ids);
10604
+ const list = Array.isArray(view && view.edges) ? view.edges.map(x => (typeof x === 'string' ? E.get(x) : x)) : [...E.values()].filter(e => OV_LINES.includes(e.type));
10605
+ const both = list.filter(e => OV_OBJ(e) && typeof e.id === 'string' && shown.has(e.from) && shown.has(e.to));
10606
+ const lines = both.filter(e => OV_LINES.includes(e.type)), stray = both.filter(e => !OV_LINES.includes(e.type)); // stray: not drawn, warned of
10607
+ const el = id => (N.get(id) || [])[0];
10608
+ return { N, V, ids, shown, lines, stray, el, flows: lines.filter(e => e.type === 'uml.ControlFlow') };
10609
+ }
10610
+ // a node in a sentence: its name, else its kind ("merge", "initial node")
10611
+ const ovNm = (I, id) => { const x = I.el(id); return x && OV_HAS(x.label) ? String(x.label) : x ? ovWordOf(x.type).toLowerCase() : String(id); };
10612
+
10613
+ /* ---- the inline interaction (S11): its sequence view, counted, and laid out by the sequence type's own sqRun when it is
10614
+ small enough; { view, heads, messages, small, run } (run null when it is drawn as its frame only, or when lay is false: a
10615
+ rule or a sentence needs the counts only), or null with no sequence view */
10616
+ function ovInline(model, I, n, lay = true) {
10617
+ const sv = I.V.get(n.opensView);
10618
+ if (!sv || sv.diagram !== 'uml.sequence') return null;
10619
+ const own = new Set(OV_ARR(sv.nodes).filter(OV_OBJ).map(x => x.id)), heads = OV_ARR(sv.nodeIds).filter(id => I.N.has(id) || own.has(id)).length;
10620
+ const messages = interactionWalk(model, sv).occ.filter(o => o.message).length;
10621
+ const small = heads <= OV_MAX.heads && messages <= OV_MAX.messages;
10622
+ return { view: sv, heads, messages, small, run: small && lay ? sqRun(model, sv) : null };
10623
+ }
10624
+
10625
+ /* ---- shapes: an interaction use's look over stage 3's SHAPE.use (its border and a size, useSize), and the inline frame ---- */
10626
+ const ovBody = n => `<rect x="${r2(n.x)}" y="${r2(n.y)}" width="${r2(n.w)}" height="${r2(n.h)}" class="n-mask"/><rect x="${r2(n.x)}" y="${r2(n.y)}" width="${r2(n.w)}" height="${r2(n.h)}" class="n-body" fill="${OV_SD.bg}" stroke="${OV_SD.line}"/>`;
10627
+ const ovBox = { out: boxOut(0), inset: () => 0, face: n => [n.y, n.y + n.h] };
10628
+ SHAPE['ov-use'] = { ...ovBox, size: n => useSize(String(n.label ?? '')), draw: n => SHAPE.use.draw(n).replace(/^<g [^>]*>/, m => m + ovBody(n)) };
10629
+ // the inline view's markup with nothing the canvas reads as its own (§11.2): no .node, data-id, tabindex or role, no hit
10630
+ // strip (.ll-hit, from which src/typed/edit.js starts a connect) or frame hit (.f-hit); a body is drawn as one, but not
10631
+ // styled as the node's when the inline node is hovered or selected
10632
+ const ovQuiet = s => s.replace(/<(?:rect|path) [^>]*class="(?:ll-hit|f-hit)"[^>]*\/>/g, '').replace(/ class="node /g, ' class="')
10633
+ .replace(/ (?:data-id|data-o|tabindex|role|aria-label)="[^"]*"/g, '').replace(/class="n-mask"/g, 'fill="var(--um-mask)" class="ov-mask"').replace(/class="n-body/g, 'stroke-width="1.5" class="ov-body');
10634
+ // a message as the sequence view draws it, by hand: its path (dashed for a reply or a create), its arrowhead, its label
10635
+ const OV_TIP = { arrow: [10, 4, true], open: [10, 5, false] };
10636
+ function ovMessage(e) {
10637
+ const T = OV_TIP[e.end && e.end.to] || OV_TIP.open, P = e.pts, [p, q] = P.slice(-2), L = Math.hypot(q[0] - p[0], q[1] - p[1]) || 1, u = [(q[0] - p[0]) / L, (q[1] - p[1]) / L];
10638
+ const b = [q[0] - u[0] * T[0], q[1] - u[1] * T[0]], s = [-u[1] * T[1], u[0] * T[1]], pts = T[2] ? trimLine(P, 0, T[0]) : P, dash = e.end && e.end.dashed ? ' stroke-dasharray="6 4"' : '';
10639
+ const tip = `M${r2(b[0] + s[0])} ${r2(b[1] + s[1])}L${r2(q[0])} ${r2(q[1])}L${r2(b[0] - s[0])} ${r2(b[1] - s[1])}${T[2] ? 'Z' : ''}`, l = e.lbl;
10640
+ return `<g class="ov-msg" data-sort="${esc(e.sort)}"><path d="M${pts.map(c => `${r2(c[0])} ${r2(c[1])}`).join('L')}" fill="none" stroke="${OV_SD.line}" stroke-width="1.4"${dash}/>`
10641
+ + `<path d="${tip}" fill="${T[2] ? OV_SD.line : 'none'}" stroke="${OV_SD.line}" stroke-width="1.4" stroke-linejoin="round"/>`
10642
+ + (l && OV_HAS(l.text) ? `<g class="ov-lab"><rect x="${r2(l.x)}" y="${r2(l.y)}" width="${r2(l.w)}" height="${r2(l.h)}" fill="${OV_SD.bg}"/><text x="${r2(l.x + l.w / 2)}" y="${r2(l.y + 12)}" text-anchor="middle" fill="var(--um-text)" style="font:500 10.5px var(--um-sans)">${esc(fit(l.text, 10.5, l.w))}</text></g>` : '') + '</g>';
10643
+ }
10644
+ // the sequence's picture inside the frame: sqDraw's layer (frames, stems, uses' boxes, bars, crosses), then each head, each
10645
+ // fragment's tag, each operand's guard and each use's name by frame.js's shapes, then the messages
10646
+ function ovInner(run) {
10647
+ const by = s => run.nodes.filter(n => n.shape === s), C = 'var(--um-slate)', F = 'var(--um-slate-f)';
10648
+ return ovQuiet(sqDraw(run, { esc, r2, textW }).under + by('lifeline').map(n => SHAPE.lifeline.draw(n, C, F)).join('')
10649
+ + ['frame', 'operand', 'use'].map(s => by(s).map(n => SHAPE[s].draw(n, C, F)).join('')).join('') + run.edges.map(ovMessage).join(''));
10650
+ }
10651
+ const ovTagH = tagSize('sd').h;
10652
+ SHAPE['ov-inline'] = { ...ovBox,
10653
+ draw(n) {
10654
+ const S = n.sd, t = String(n.label ?? ''), b = S && S.bounds;
10655
+ const inner = b ? frameTag(n.x, n.y, `sd ${t}`, n.w) + `<g class="ov-sd" aria-hidden="true" transform="translate(${r2(n.x + (n.w - b.w) / 2 - b.x)} ${r2(n.y + ovTagH)})">${S.markup}</g>`
10656
+ : frameTag(n.x, n.y, 'sd', n.w) + `<text x="${r2(n.x + n.w / 2)}" y="${r2(n.y + n.h / 2 + 4)}" text-anchor="middle" fill="var(--um-text)" style="font:600 11px var(--um-sans)">${esc(fit(t, 11, n.w - 2 * (tagSize('sd').w + 8)))}</text>`;
10657
+ return `<g class="node ov-inline" data-id="${esc(n.id)}" data-shape="ov-inline" tabindex="-1" role="img" aria-label="${esc(n.aria || 'Inline interaction ' + t)}">${ovBody(n)}${inner}</g>`;
10658
+ },
10659
+ };
10660
+ for (const k of ['ov-use', 'ov-inline']) { const S = SHAPE[k]; S.ports = (n, side, j = 1) => portsOn(S, n, side, j); }
10661
+
10662
+ /* ---- names (A1, L13): "Decision Payment accepted?, 2 exits", "Interaction use Find a book, opens Find a book" ---- */
10663
+ function ovNodeName(model, I, x, ins, outs, sd) {
10664
+ const t = OV_HAS(x.label) ? String(x.label) : '', st = ovStereo(x), nm = `${ovWordOf(x.type)}${t ? ' ' + t : ''}${st.length ? ` (${st.join(', ')})` : ''}`;
10665
+ const opens = () => { const v = I.V.get(x.opensView); return v && v.diagram === 'uml.sequence' ? `, opens ${v.title ?? v.id}` : ', opens no sequence view'; };
10666
+ switch (x.type) {
10667
+ case 'uml.Comment': return 'Comment: ' + String(x.label ?? x.id);
10668
+ case 'uml.DecisionNode': return `${nm}, ${ovPlural(outs, 'exit')}`;
10669
+ case 'uml.MergeNode': return `${nm}, ${ovPlural(ins, 'entry', 'entries')}`;
10670
+ case 'uml.ForkNode': return `${nm}, ${ovPlural(outs, 'branch', 'branches')}`;
10671
+ case 'uml.JoinNode': return `${nm}, ${ovPlural(ins, 'branch', 'branches')}`;
10672
+ case 'uml.InteractionUse': return nm + opens();
10673
+ case 'uml.Interaction': return !sd ? nm + opens() : `${nm}, ${ovPlural(sd.heads, 'lifeline')}, ${ovPlural(sd.messages, 'message')}${sd.small ? '' : ', drawn as its frame only'}`;
10674
+ default: return nm;
10675
+ }
10676
+ }
10677
+ const ovFlowName = (I, e) => { const g = OV_PROPS(e).guard;
10678
+ return e.type === 'uml.CommentLink' ? `Comment link from ${ovNm(I, e.from)} to ${ovNm(I, e.to)}`
10679
+ : `Control flow from ${ovNm(I, e.from)} to ${ovNm(I, e.to)}${OV_HAS(e.label) ? ', ' + e.label : ''}${OV_HAS(g) ? ', guard ' + g : ''}`; };
10680
+
10681
+ /* ---- the legend: each line kind drawn, then each control glyph, by its node type (§14) ---- */
10682
+ const ovLegendItem = (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>`;
10683
+ const OV_DIAMOND = '<path d="M14 1L21 8L14 15L7 8Z" fill="var(--um-stage-bg)" stroke="var(--um-muted)" stroke-width="1.4"/>', OV_BAR = '<rect x="11.5" y="1" width="5" height="14" fill="var(--um-text)"/>';
10684
+ const OV_LEGEND = [
10685
+ ['uml.ControlFlow', '<g stroke="var(--um-muted)" stroke-width="1.4" stroke-linejoin="round" fill="none"><path d="M2 8H26"/><path d="M17 3L26 8L17 13"/></g>', 'Control flow', 'what runs next'],
10686
+ ['uml.CommentLink', '<path d="M2 8H26" stroke="var(--um-muted)" stroke-width="1.4" stroke-dasharray="3 2"/>', 'Comment link', 'what a comment is about'],
10687
+ ['uml.InitialNode', '<circle cx="14" cy="8" r="6" fill="var(--um-text)"/>', 'Initial node', 'where the flow starts'],
10688
+ ['uml.ActivityFinalNode', '<circle cx="14" cy="8" r="6.5" fill="var(--um-stage-bg)" stroke="var(--um-muted)" stroke-width="1.4"/><circle cx="14" cy="8" r="3.5" fill="var(--um-text)"/>', 'Activity final', 'ends the whole flow'],
10689
+ ['uml.FlowFinalNode', '<circle cx="14" cy="8" r="6.5" fill="var(--um-stage-bg)" stroke="var(--um-muted)" stroke-width="1.4"/><path d="M9.4 3.4L18.6 12.6M18.6 3.4L9.4 12.6" stroke="var(--um-muted)" stroke-width="1.4"/>', 'Flow final', 'ends one branch'],
10690
+ ...[['uml.DecisionNode', 'Decision', 'one way out, by its guards'], ['uml.MergeNode', 'Merge', 'where ways meet']].map(([k, name, says]) => [k, OV_DIAMOND, name, says]),
10691
+ ...[['uml.ForkNode', 'Fork', 'splits into branches that run together'], ['uml.JoinNode', 'Join', 'waits for all its branches']].map(([k, name, says]) => [k, OV_BAR, name, says]),
10692
+ ];
10693
+ const ovLegendOf = (edges, nodes) => { const has = new Set([...edges.map(e => e.type), ...nodes.map(n => n.type)]);
10694
+ return OV_LEGEND.filter(([k]) => has.has(k)).map(([, svg, name, says]) => ovLegendItem(svg, name, says)); };
10695
+
10696
+ /* ---- the view laid out: { model, view, pos, memo } ----
10697
+ Each node by its shape, sized before the layout: a bar by the lines on its busier side, every line of a bar on its long
10698
+ side (the router reads no shape, §4.1); an inline interaction by its sequence's bounds under the "sd" tag. The inline
10699
+ view is laid out (sqRun) and drawn here on every call, memo hit or not, so it is never stale; its markup rides on the node
10700
+ (n.sd), and a memo hit repaints it onto the kept geometry, saving only layoutTyped's layout and routing. Nothing after
10701
+ layoutTyped edits what it returned (§2.4): the result is a new object, its nodes a new array in reading order */
10702
+ function ovGeo({ model, view, pos, memo }) {
10703
+ const I = ovIndex(model, view), down = view.direction === 'down', at = id => ovOwn(pos || {}, id) || [0, 0];
10704
+ const ins = new Map(), outs = new Map(), bump = (m, id) => m.set(id, (m.get(id) || 0) + 1);
10705
+ I.flows.forEach(e => { bump(outs, e.from); bump(ins, e.to); });
10706
+ const bar = id => I.el(id).type === 'uml.ForkNode' || I.el(id).type === 'uml.JoinNode';
10707
+ const nodes = I.ids.map(id => { const x = I.el(id), shape = ovOwn(OV_SHAPE, x.type) || 'rounded', d = { id, label: OV_HAS(x.label) ? String(x.label) : '', type: x.type, kind: 'file', shape, col: at(id)[0], row: at(id)[1] };
10708
+ const sd = x.type === 'uml.Interaction' ? ovInline(model, I, x) : null;
10709
+ d.aria = ovNodeName(model, I, x, ins.get(id) || 0, outs.get(id) || 0, sd);
10710
+ if (shape === 'rounded') d.stereotypes = ovStereo(x);
10711
+ if (shape === 'bar') Object.assign(d, { busy: Math.max(ins.get(id) || 0, outs.get(id) || 0), down });
10712
+ if (shape === 'ov-inline') { const b = sd && sd.run && sd.run.bounds, tw = tagSize(`sd ${d.label}`).w + 2 * OV_SD.pad;
10713
+ return { ...d, ...(b ? { w: Math.ceil(Math.max(b.w + 2 * OV_SD.pad, tw)), h: Math.ceil(ovTagH + b.h), sd: { bounds: b, markup: ovInner(sd.run) } } : useSize(d.label)) }; }
10714
+ return { ...d, ...SHAPE[shape].size(d) }; });
10715
+ const edges = I.lines.map(e => ({ id: e.id, _id: e.id, type: e.type, from: e.from, to: e.to, aria: ovFlowName(I, e), ...(OV_OBJ(e.style) || typeof e.style === 'string' ? { style: e.style } : {}),
10716
+ ...(e.type === 'uml.CommentLink' ? { end: { from: 'none', to: 'none', dashed: true } }
10717
+ : { end: { from: 'none', to: 'open' }, label: flowText(e.label, OV_PROPS(e).guard) || undefined, cut: 280, ...(bar(e.from) ? { fromSide: 'right' } : {}), ...(bar(e.to) ? { toSide: 'left' } : {}) }) }));
10718
+ // a typed view makes no room for a label (layoutGraph's labelRoom is a map's). A row's nodes share its top, not their middle,
10719
+ // so a flow between two of different heights jogs halfway across the gap, and its label sits on one half, clear of the
10720
+ // nodes' stubs (22 px): the gap is twice that and the widest label laid out right, its 18 px height laid out down.
10721
+ // ponytail: one gap for every column, so one long guard widens them all (to ~612 px at the 280 px cut); room only where a
10722
+ // labelled flow runs needs a per-column gap from layoutGraph (it has gapAfter inside, no option for it). Less than twice drops guards
10723
+ const wide = Math.max(0, ...edges.filter(e => e.label).map(labelW)), gap = wide ? Math.ceil(Math.max(64, 2 * ((down ? 18 : wide) + OV_GAP))) : 64;
10724
+ const g = layoutTyped({ nodes, edges, direction: down ? 'down' : 'right', gap, rgap: 56, repair: false, reroute: false }, memo); // P1 on a shared CI runner (§17)
10725
+ // reading order (§14): by rank (the layout's column, then its row), then the lines
10726
+ const order = I.ids.filter(id => g.byId[id]).sort((a, b) => g.byId[a].col - g.byId[b].col || g.byId[a].row - g.byId[b].row);
10727
+ return { ...g, nodes: order.map(id => g.byId[id]), order: [...order, ...g.edges.map(e => e.id)], legend: ovLegendOf(g.edges, g.nodes) };
10728
+ }
10729
+
10730
+ /* ---- validators (§11.3): the shared flow rules (src/uml/flow.js), then the overview's own. Each problem:
10731
+ { level, rule, path, message, ids } ---- */
10732
+ const OV_RULES = {
10733
+ ...FLOW_RULES,
10734
+ 'opens-sequence': 'an interaction use or an inline interaction opens a sequence view of the model',
10735
+ 'overview-kinds': 'an interaction overview shows control nodes, interaction uses, inline interactions and comments only',
10736
+ 'comment-link': 'a comment link has a comment at one end',
10737
+ 'inline-size': `an inline interaction's view has at most ${OV_MAX.heads} lifelines and ${OV_MAX.messages} messages, else it is drawn as its frame only`,
10738
+ };
10739
+ function ovProblems(model, view) {
10740
+ if (!OV_OBJ(view)) return [];
10741
+ const I = ovIndex(model, view), out = [...flowProblems(model, view, OV_ENDS)], vi = OV_ARR(model.views).indexOf(view);
10742
+ const say = (level, rule, path, ids, msg) => out.push({ level, rule, path, ids, message: `${rule}: ${msg}` });
10743
+ I.ids.forEach(id => { const [x, path] = I.N.get(id), nm = `${ovWordOf(x.type).toLowerCase()} ${id}${OV_HAS(x.label) ? ` ("${x.label}")` : ''}`;
10744
+ if (!OV_NODES.includes(x.type)) say('warning', 'overview-kinds', `views[${vi}].nodeIds[${OV_ARR(view.nodeIds).indexOf(id)}]`, [id], `view ${view.id} shows ${nm}, which an interaction overview does not draw: drawn as an action`);
10745
+ if (x.type !== 'uml.InteractionUse' && x.type !== 'uml.Interaction') return;
10746
+ const v = I.V.get(x.opensView);
10747
+ if (!v || v.diagram !== 'uml.sequence') return say('error', 'opens-sequence', `${path}.opensView`, [id], `${nm} opens ${x.opensView == null ? 'no view' : !v ? `${JSON.stringify(x.opensView)}, which is not a view of the model` : `view ${v.id}, a ${v.diagram || 'map'}, not a sequence`}`);
10748
+ const sd = x.type === 'uml.Interaction' && ovInline(model, I, x, false);
10749
+ if (sd && !sd.small) say('warning', 'inline-size', `${path}.opensView`, [id, v.id], `${nm} shows view ${v.id}, of ${ovPlural(sd.heads, 'lifeline')} and ${ovPlural(sd.messages, 'message')}: over ${OV_MAX.heads} lifelines or ${OV_MAX.messages} messages it is drawn as its frame only`); });
10750
+ const eIdx = e => OV_ARR(view.edges).findIndex(x => x === e || x === e.id), ePath = e => (Array.isArray(view.edges) ? `views[${vi}].edges[${eIdx(e)}]` : `edges[${OV_ARR(model.edges).indexOf(e)}]`);
10751
+ I.stray.forEach(e => say('warning', 'overview-kinds', ePath(e), [e.id], `view ${view.id} shows ${ovWordOf(e.type).toLowerCase()} ${e.id}, a line an interaction overview does not draw: not drawn`));
10752
+ I.lines.filter(e => e.type === 'uml.CommentLink' && ![e.from, e.to].some(id => I.el(id).type === 'uml.Comment'))
10753
+ .forEach(e => say('warning', 'comment-link', ePath(e), [e.id, e.from, e.to], `${e.id} joins ${ovNm(I, e.from)} and ${ovNm(I, e.to)}, neither a comment`));
10754
+ return out;
10755
+ }
10756
+
10757
+ /* ---- describe(): the view in words: from the initial nodes along the flows, breadth first (a fork's branches side by side),
10758
+ each node once by its accessible name and where its flows go, each by "name [guard]"; then what no flow reaches; then the
10759
+ comments' links ---- */
10760
+ function ovDescribe(model, viewId) {
10761
+ const v = OV_ARR(model && model.views).find(x => OV_OBJ(x) && x.id === viewId); if (!v) throw new Error(`umlimator: no view ${JSON.stringify(viewId)}`);
10762
+ const I = ovIndex(model, v), seen = new Set(), walk = [], out = I.flows.reduce((m, e) => m.set(e.from, [...(m.get(e.from) || []), e]), new Map());
10763
+ const visit = (...q) => { for (const id of q) if (!seen.has(id)) { seen.add(id); walk.push(id); q.push(...(out.get(id) || []).map(e => e.to)); } };
10764
+ visit(...I.ids.filter(id => I.el(id).type === 'uml.InitialNode')); I.ids.forEach(id => visit(id));
10765
+ const count = id => I.flows.filter(e => e.to === id).length;
10766
+ const lines = [`${v.title || v.id}: an interaction overview diagram of ${ovPlural(I.ids.length, 'node')} and ${ovPlural(I.flows.length, 'flow')}.`];
10767
+ walk.forEach(id => { const x = I.el(id), next = (out.get(id) || []).map(e => { const t = flowText(e.label, OV_PROPS(e).guard); return `${ovNm(I, e.to)}${!t ? '' : OV_HAS(e.label) ? ' by ' + t : ' ' + t}`; });
10768
+ lines.push(`${ovNodeName(model, I, x, count(id), (out.get(id) || []).length, x.type === 'uml.Interaction' ? ovInline(model, I, x, false) : null)}${next.length ? '; then ' + next.join(', ') : ''}.`); });
10769
+ I.lines.filter(e => e.type === 'uml.CommentLink').forEach(e => lines.push(ovFlowName(I, e) + '.'));
10770
+ return lines.join('\n');
10771
+ }
10772
+
10773
+ /* ---- editing (§11.3, §12): each hook returns commands, { pick, then }, null for the default, or { error } ---- */
10774
+ function ovFreshId(model, base) {
10775
+ const used = new Set(); JSON.stringify(model, (k, v) => { if (k === 'id' && typeof v === 'string') used.add(v); return v; });
10776
+ const b = String(base).trim().toLowerCase().replace(/[^\p{L}\p{N}_$]+/gu, '-').replace(/^-+|-+$/g, '') || 'node';
10777
+ let k = 1; while (used.has(k === 1 ? b : `${b}-${k}`)) k++;
10778
+ return k === 1 ? b : `${b}-${k}`;
10779
+ }
10780
+ /* add(model, view, entry, label, at): the palette and N. An interaction use or an inline interaction (an entry that opens a
10781
+ sequence) asks for its view first, from the model's sequence views (S18), and is added with opensView set and, with no
10782
+ name typed, the view's title; with no sequence view, nothing is added. Anything else is added under its name, if any */
10783
+ function ovAdd(model, view, entry, label, at) {
10784
+ if (!OV_OBJ(view) || !OV_OBJ(entry) || typeof entry.type !== 'string') return null;
10785
+ const t = String(label ?? '').trim();
10786
+ const make = (name, more = {}) => [{ op: 'add', element: { id: ovFreshId(model, name || ovWordOf(entry.type)), type: entry.type, ...(name ? { label: name } : {}), ...more }, view: view.id }];
10787
+ if (entry.opens !== 'uml.sequence') return make(t);
10788
+ const vs = OV_ARR(model.views).filter(v => OV_OBJ(v) && v.diagram === 'uml.sequence');
10789
+ if (!vs.length) return { error: 'The model has no sequence view for it to open: add one first' };
10790
+ return { pick: vs.map(v => String(v.title ?? v.id)), then: i => (vs[i] ? make(t || String(vs[i].title ?? vs[i].id), { opensView: vs[i].id }) : []) };
10791
+ }
10792
+ /* rename(model, view, id, text): F2 on a control flow reads "name [guard]" (§7.2) and sets the parts that changed, an
10793
+ absent part cleared; a text the parser refuses is { error } (E3). Anything else: null, the default set label */
10794
+ function ovRename(model, view, id, text) {
10795
+ const e = OV_ARR(model && model.edges).find(x => OV_OBJ(x) && x.id === id);
10796
+ if (!e || e.type !== 'uml.ControlFlow') return null;
10797
+ const p = parseFlow(text); if (p.error) return p;
10798
+ const now = { label: OV_HAS(e.label) ? String(e.label) : undefined, 'props.guard': OV_HAS(OV_PROPS(e).guard) ? String(OV_PROPS(e).guard) : undefined };
10799
+ return [['label', p.name], ['props.guard', p.guard]].filter(([k, v]) => now[k] !== v).map(([path, value]) => ({ op: 'set', id, path, value }));
10800
+ }
10801
+
10802
+ /* ---- the type ---- */
10803
+ const OV_STEREO = { key: 'stereotypes', label: 'Stereotypes', kind: 'list' };
10804
+ const OVERVIEW_TYPE = registerDiagramType('uml.interaction-overview', {
10805
+ title: 'interaction overview diagram',
10806
+ // control nodes but the decision (its question, S8) are added with no name prompt (§12.1); a use and an inline
10807
+ // interaction ask for the view they open, V changes it
10808
+ elements: [['InitialNode', 'Initial', { name: false }], ['ActivityFinalNode', 'Final', { name: false }], ['FlowFinalNode', 'Flow final', { name: false }], ['DecisionNode', 'Decision'],
10809
+ ['MergeNode', 'Merge', { name: false }], ['ForkNode', 'Fork', { name: false }], ['JoinNode', 'Join', { name: false }], ['InteractionUse', 'Interaction use', { opens: 'uml.sequence' }],
10810
+ ['Interaction', 'Inline interaction', { opens: 'uml.sequence' }], ['Comment', 'Comment']].map(([t, label, more]) => ({ type: 'uml.' + t, label, ...more })),
10811
+ lines: [{ type: 'uml.ControlFlow', label: 'Control flow' }, { type: 'uml.CommentLink', label: 'Comment link' }],
10812
+ props: { ...Object.fromEntries(OV_NODES.map(t => [t, [OV_STEREO]])), 'uml.ControlFlow': [{ key: 'guard', label: 'Guard', kind: 'text' }, OV_STEREO], 'uml.CommentLink': [] },
10813
+ layout: ovGeo,
10814
+ member: { holds: () => false }, // nothing here has members
10815
+ validate: ovProblems,
10816
+ rules: OV_RULES,
10817
+ describe: ovDescribe,
10818
+ edits: { add: ovAdd, rename: ovRename },
10819
+ });
10820
+ /* ---------- Mermaid stateDiagram: a reader and a writer (notes/stage-4.md §13, S12, S22; risks.md R1 to R4, R7) ----------
10821
+ Written against Mermaid 12.3.0's state diagram page (docs/syntax/stateDiagram.md, read 13 Nov 2026) and its lexer, as
10822
+ the class and sequence readers are against theirs. Internal: the CLI (`umlimator.py import`) imports this file through
10823
+ Node; a host gets it once the registry is public. Its helpers are its own (mt...), since the readers share one scope in
10824
+ lib/umlimator-all.js.
10825
+
10826
+ readMermaidState(text) -> { model, report }: a version-2 model with one `uml.state-machine` view, in model.md row 10's
10827
+ vocabulary, and a report of what was not read as written, each item { line, status, message }, status `skipped` or
10828
+ `approximated` (R1). The reader is hand-written and linear, and refuses text over MT_LIMIT characters (R2). Ids come
10829
+ from the source's names (R3): states take their names first; then, in the order they come, a scope's initial
10830
+ pseudostate is <scope>.start (the top's `start`), its final state <scope>.end, a region <state>.r<k>, a transition
10831
+ <from>-<to>, an internal transition <state>.t<k>, a note note-<n> and its line <note>-link, each the next free of
10832
+ base, base-2, base-3 ... `%% id: <id>` above a statement gives that element its id instead (`%% link-id:` a note's
10833
+ line, `%% start-id:` and `%% end-id:` the scope's [*] first named below, `%% region: <id> [name]` the region it opens),
10834
+ and `%% kind: <kind>` above a state makes it a junction, a history, a terminate, an initial pseudostate or a final
10835
+ state. It never throws.
10836
+
10837
+ writeMermaidState(model, viewId?) -> text: one state machine view as Mermaid. What Mermaid cannot carry is kept in its
10838
+ comments, as the other writers do: an id the reader would not derive, as the hints above; a pseudostate Mermaid has
10839
+ no notation for under `%% kind:`, labelled ●, H, H*, ×, as is an initial pseudostate or a final state that is not the
10840
+ first of its scope, or whose lines would make [*] name another; `%% warning: Mermaid may not draw this` above a
10841
+ transition whose ends lie in different scopes (S22); an element the subset leaves out as `%% not written: <id> (<type>)`,
10842
+ and a property Mermaid has no place for as `%% not written: <id>: <what>`.
10843
+
10844
+ How UML maps onto Mermaid's text, both ways:
10845
+ - `s1`, `state "Long name" as s1` a state; `state s1 { ... }` a composite state, whose body's `--` divides it into
10846
+ regions. Mermaid's names are global: a state's parent is the first body that names it, even when the top named it
10847
+ first; a body that names it again, other than one around its own, is reported;
10848
+ - `[*] --> s1` the scope's initial pseudostate, `s1 --> [*]` its final state, one of each per scope (a region is a
10849
+ scope); `<<fork>>`, `<<join>>`, `<<choice>>` those pseudostates;
10850
+ - `s1 --> s2 : text` a transition, its text read as trigger [guard] / effect (§7); `s1 : entry / x` (do, exit) that
10851
+ activity; `s1 : evt [g] / act` an internal transition; any other `s1 : text` a comment linked to s1, marked
10852
+ ext.mermaid.description, written back as it came; `note right of s1` a comment and its line;
10853
+ - `direction LR` right, `TB` down (Mermaid's default); text is decoded from Mermaid's entities (#59; #quot;) and
10854
+ <br>, which the writer uses for ; : # " < > \ and line breaks, and for a space at either end. */
10855
+ const MT_LIMIT = 1 << 20; // characters; a state diagram a person writes is a few kilobytes
10856
+ const MT_VIEW = 'state-diagram'; // the view's id when the source does not name it
10857
+ const MT_DEEP = 64; // composites nested deeper are read flat from there (R2)
10858
+ const MT_WALK = 256; // the most scopes a check walks up; past it, nothing is moved (R2)
10859
+ const MT_KINDS = { junction: '●', shallowHistory: 'H', deepHistory: 'H*', terminate: '×', initial: '●', final: '◉' }; // %% kind:, and the label Mermaid shows
10860
+ const MT_FORKS = ['fork', 'join', 'choice'];
10861
+ const MT_ACTS = ['entry', 'do', 'exit'];
10862
+ // words a line starts with that Mermaid's lexer (stateDiagram.jison) or this reader take as a statement: no state name the writer gives is one
10863
+ const MT_KEYWORDS = new Set(['state', 'note', 'direction', 'title', 'classdef', 'class', 'style', 'scale', 'click', 'href', 'default', 'hide', 'acctitle', 'accdescr', 'statediagram']);
10864
+ const mtId = name => String(name).replace(/->+/g, '-'); // an id never holds "->" (model.md rule 5)
10865
+ const mtWord = s => { let e = 0; while (e < s.length && /[A-Za-z_]/.test(s[e])) e++; return e && !/\w/.test(s[e] ?? ' ') ? s.slice(0, e).toLowerCase() : ''; };
10866
+
10867
+ // the first of base, base-2, base-3 ... not in used, resuming where the last ask for base stopped (R2)
10868
+ function mtFree(used, next, base) {
10869
+ let k = next.get(base) || 1, id = k > 1 ? `${base}-${k}` : base;
10870
+ while (used.has(id)) id = `${base}-${++k}`;
10871
+ next.set(base, k);
10872
+ return id;
10873
+ }
10874
+
10875
+ // text: Mermaid's line breaks and entity codes (#quot; #lt; #59; ...); a named one Mermaid knows and this does not stays
10876
+ function mtDecode(s) {
10877
+ return s.replace(/<br\s?\/?>|\\n/gi, '\n').replace(/#(quot|lt|gt|amp|\d{1,6});/g, (_, e) =>
10878
+ ({ quot: '"', lt: '<', gt: '>', amp: '&' })[e] ?? String.fromCodePoint(Math.min(Number(e), 0x10ffff)));
10879
+ }
10880
+
10881
+ // trigger [guard] / effect is src/uml/transition.js's (notes/stage-4.md §7.1); its parts here, the absent ones left out
10882
+ const mtParts = p => { const o = {}; for (const k of ['trigger', 'guard', 'effect']) if (p[k] !== undefined) o[k] = p[k]; return o; };
10883
+ const mtSame = (a, b) => a.trigger === b.trigger && a.guard === b.guard && a.effect === b.effect;
10884
+ // the text of a transition's parts, or null when it would not read back as them exactly (printTransition trims them)
10885
+ function mtPrint(p) {
10886
+ const text = printTransition(p);
10887
+ return text !== null && mtSame(parseTransition(text), p) ? text : null;
10888
+ }
10889
+ // what `s1 : text` is (§7.2): an activity, an internal transition (a trigger and a /, maybe with nothing after it), or a description
10890
+ function mtLine(text) {
10891
+ const p = trScan(text);
10892
+ if (p.error || p.trigger === undefined || p.slash < 0) return { description: true };
10893
+ if (MT_ACTS.includes(p.trigger) && p.guard === undefined) return { activity: p.trigger, value: p.effect ?? '' };
10894
+ return { internal: mtParts(p) };
10895
+ }
10896
+
10897
+ function readMermaidState(text) {
10898
+ const report = [], say = (line, status, message) => report.push({ line, status, message });
10899
+ const empty = () => ({ model: { schemaVersion: 2, root: MT_VIEW, nodes: [], edges: [], views: [{ id: MT_VIEW, diagram: 'uml.state-machine', direction: 'down', nodeIds: [] }] }, report });
10900
+ if (typeof text !== 'string') { say(1, 'skipped', 'the input is not text: nothing read'); return empty(); }
10901
+ if (text.length > MT_LIMIT) { say(1, 'skipped', `the input is ${text.length} characters, over the limit of ${MT_LIMIT}: nothing read`); return empty(); }
10902
+ const lines = text.replace(/^\uFEFF/, '').split('\n').map(l => l.replace(/\r$/, ''));
10903
+ let i = 0, title;
10904
+
10905
+ // front matter: its title; Mermaid's configuration is not read
10906
+ while (i < lines.length && !lines[i].trim()) i++;
10907
+ if (lines[i] && lines[i].trim() === '---') {
10908
+ let j = i + 1;
10909
+ for (; j < lines.length && lines[j].trim() !== '---'; j++) {
10910
+ const l = lines[j];
10911
+ if (!l.trim() || l.trim().startsWith('#') || /^\s/.test(l)) continue;
10912
+ const c = l.indexOf(':'), key = (c < 0 ? l : l.slice(0, c)).trim(), val = c < 0 ? '' : l.slice(c + 1).trim();
10913
+ if (key === 'title') {
10914
+ if (val.startsWith('"')) { try { title = JSON.parse(val); } catch { title = val.slice(1, -1); } }
10915
+ else title = val.startsWith("'") ? val.slice(1, -1).replaceAll("''", "'") : val;
10916
+ } else say(j + 1, 'skipped', `front matter ${key}: Mermaid's configuration is not read`);
10917
+ }
10918
+ if (j >= lines.length) { say(i + 1, 'skipped', 'the front matter is never closed with ---: nothing read'); return empty(); }
10919
+ i = j + 1;
10920
+ }
10921
+
10922
+ // a scope is the top or one part of a composite's body: { owner, k, hint, start, end }; a state's body is the scope
10923
+ // that first named it (null: the top)
10924
+ const top = { owner: null }, states = new Map(), order = [], items = [], stack = [];
10925
+ let viewId, direction = 'down', started = false, hint = {}, block = false, note = null, flat = 0;
10926
+ const cur = () => (stack.length ? stack[stack.length - 1].cur : top);
10927
+ const up = s => s.owner.body || top;
10928
+ const inside = (s, a) => { for (let n = 0; n < MT_WALK; n++) { if (s === a) return true; if (s === top) return false; s = up(s); } return false; }; // a holds s
10929
+ const under = (s, rec) => { for (let n = 0; n < MT_WALK; n++) { if (s === top) return false; if (s.owner === rec) return true; s = up(s); } return true; }; // s is in rec's body
10930
+ const mention = (name, no) => {
10931
+ let rec = states.get(name);
10932
+ if (!rec) { rec = { name, no, node: { type: 'uml.State', label: name }, body: null, parts: null, internal: 0, acts: [] }; states.set(name, rec); order.push(rec); }
10933
+ const s = cur();
10934
+ if (s !== top && rec.body !== s) {
10935
+ if (!rec.body) { if (!under(s, rec)) rec.body = s; }
10936
+ else if (!inside(rec.body, s) && !under(s, rec)) say(no, 'approximated', `${name} is named in ${rec.body.owner.name}'s body and in ${s.owner.name}'s: it is kept in the first`);
10937
+ }
10938
+ return rec;
10939
+ };
10940
+ // a name as written: a style class after ::: is not read; null when it is not one word
10941
+ const nameOf = (raw, no, star) => { // star: [*] may stand for itself
10942
+ let s = raw.trim();
10943
+ const c = s.indexOf(':::');
10944
+ if (c >= 0) { say(no, 'skipped', `${s}: a style class is not read`); s = s.slice(0, c).trim(); }
10945
+ if (s === '[*]') return star ? s : null;
10946
+ return s && !/[\s"{}]/.test(s) ? s : null;
10947
+ };
10948
+ const open = (rec, no) => {
10949
+ if (stack.length >= MT_DEEP) {
10950
+ if (!flat++) say(no, 'approximated', `state ${rec.name} is nested over ${MT_DEEP} deep: what it holds is read flat from there`);
10951
+ stack.push({ flat: true, cur: cur(), no, rec });
10952
+ return;
10953
+ }
10954
+ if (rec.parts) say(no, 'approximated', `state ${rec.name} { is opened again: what it holds is read into its last region`);
10955
+ else { rec.parts = [{ owner: rec, k: 1 }]; rec.opened = no; items.push({ kind: 'region', part: rec.parts[0] }); }
10956
+ stack.push({ rec, cur: rec.parts.at(-1), no });
10957
+ };
10958
+ const kindOf = (rec, no) => {
10959
+ const k = hint.kind;
10960
+ if (k == null) return;
10961
+ if (!Object.hasOwn(MT_KINDS, k)) return say(no, 'skipped', `%% kind: ${k}: not a kind of pseudostate`);
10962
+ rec.node = k === 'final' ? { type: 'uml.FinalState' } : { type: 'uml.Pseudostate', props: { kind: k } };
10963
+ rec.symbol = MT_KINDS[k];
10964
+ };
10965
+ const declare = (rec) => { if (hint.id != null && rec.hint === undefined) rec.hint = hint.id; };
10966
+ // [*] in the current scope: its initial pseudostate as a source, its final state as a target
10967
+ const star = (from, no) => {
10968
+ const s = cur(), key = from ? 'start' : 'end';
10969
+ if (!s[key]) { s[key] = { kind: key, scope: s, no, hint: from ? hint.start : hint.end, node: from ? { type: 'uml.Pseudostate', props: { kind: 'initial' } } : { type: 'uml.FinalState' } }; items.push(s[key]); }
10970
+ return s[key];
10971
+ };
10972
+
10973
+ for (; i < lines.length; i++) {
10974
+ const no = i + 1;
10975
+ let s = lines[i].trim();
10976
+ if (note) { // a note's lines, to its end note
10977
+ if (/^end note\b/i.test(s)) { items.push(note); note = null; } else note.text.push(s);
10978
+ continue;
10979
+ }
10980
+ if (!s) continue;
10981
+ if (block) { if (s.includes('}')) block = false; continue; } // inside accDescr { ... }
10982
+ if (s.startsWith('%%')) {
10983
+ const c = s.slice(2).trim(), after = w => c.slice(w.length).trim();
10984
+ if (c.startsWith('row:') && started && /^[^:\s]+\s*:/.test(after('row:'))) s = after('row:'); // a composite state's text: Mermaid refuses it on the state
10985
+ else if (c.startsWith('{')) {
10986
+ say(no, 'skipped', 'a %%{init}%% directive: Mermaid\'s configuration is not read');
10987
+ if (!s.includes('}%%')) while (i + 1 < lines.length && !lines[++i].includes('}%%')); // over several lines: to its }%%
10988
+ } else if (c.startsWith('id:')) hint.id = after('id:');
10989
+ else if (c.startsWith('link-id:')) hint.link = after('link-id:');
10990
+ else if (c.startsWith('start-id:')) hint.start = after('start-id:');
10991
+ else if (c.startsWith('end-id:')) hint.end = after('end-id:');
10992
+ else if (c.startsWith('kind:')) hint.kind = after('kind:');
10993
+ else if (c.startsWith('region:')) {
10994
+ const r = after('region:'), sp = r.search(/\s/), part = cur();
10995
+ if (part === top || stack.at(-1).flat) say(no, 'skipped', '%% region: outside a composite state: not read');
10996
+ else part.hint = { id: sp < 0 ? r : r.slice(0, sp), name: sp < 0 ? undefined : mtDecode(r.slice(sp).trim()) };
10997
+ }
10998
+ if (s.startsWith('%%')) continue;
10999
+ }
11000
+ if (!started) {
11001
+ if (!/^stateDiagram(-v2)?$/i.test(s)) { say(no, 'skipped', 'not a Mermaid stateDiagram: nothing read'); return empty(); }
11002
+ started = true; viewId = hint.id; hint = {};
11003
+ if (viewId != null && mtId(viewId) !== viewId) { say(no, 'approximated', `%% id: ${viewId}: an id holds no "->", so the view is ${mtId(viewId)}`); viewId = mtId(viewId); }
11004
+ continue;
11005
+ }
11006
+ const word = mtWord(s), rest = s.slice(word.length).trim(), arrow = s.indexOf('-->'), colon = s.indexOf(':');
11007
+ if (s.startsWith('}')) {
11008
+ const o = stack.pop();
11009
+ if (!o) say(no, 'skipped', 'a } that closes nothing');
11010
+ else if (o.flat) flat--;
11011
+ } else if (s === '--') {
11012
+ const o = stack.at(-1);
11013
+ if (!o || o.flat) say(no, 'skipped', '-- outside a composite state: not read');
11014
+ else { const part = { owner: o.rec, k: o.rec.parts.length + 1 }; o.rec.parts.push(part); items.push({ kind: 'region', part }); o.cur = part; }
11015
+ } else if (word === 'state') {
11016
+ let label, k = 0, name;
11017
+ if (rest.startsWith('"')) {
11018
+ const q = rest.indexOf('"', 1), as = q < 0 ? null : /^\s*as\s+/i.exec(rest.slice(q + 1));
11019
+ if (!as) { say(no, 'skipped', `state ${rest}: a "description" with no as <name>`); hint = {}; continue; }
11020
+ label = mtDecode(rest.slice(1, q)); k = q + 1 + as[0].length;
11021
+ }
11022
+ let e = k;
11023
+ while (e < rest.length && !/[\s{]/.test(rest[e])) e++;
11024
+ name = nameOf(rest.slice(k, e), no);
11025
+ const tail = rest.slice(e).trim(), fork = /^(?:<<(fork|join|choice)>>|\[\[(fork|join|choice)\]\])$/i.exec(tail);
11026
+ if (!name || (tail && tail !== '{' && !fork)) { say(no, 'skipped', `state ${rest} is not understood`); hint = {}; continue; }
11027
+ const rec = mention(name, no);
11028
+ declare(rec);
11029
+ if (fork) rec.node = { type: 'uml.Pseudostate', props: { kind: (fork[1] || fork[2]).toLowerCase() } };
11030
+ else kindOf(rec, no);
11031
+ if (label !== undefined && rec.node.type === 'uml.State') rec.node.label = label;
11032
+ else if (label !== undefined && label !== rec.symbol) rec.node.label = label;
11033
+ if (tail === '{') open(rec, no);
11034
+ } else if (word === 'note') {
11035
+ const side = /^(left|right) of\s+/i.exec(rest);
11036
+ if (!side) { say(no, 'skipped', `note ${rest}: a note not beside a state is not read`); hint = {}; continue; }
11037
+ const after = rest.slice(side[0].length), c = after.indexOf(':'), name = nameOf(c < 0 ? after : after.slice(0, c), no);
11038
+ if (!name) { say(no, 'skipped', `note ${rest}: names no state`); hint = {}; continue; }
11039
+ if (side[1].toLowerCase() === 'left') say(no, 'approximated', `note left of ${name}: the side is not kept`);
11040
+ const rec = { kind: 'note', target: mention(name, no), no, hint: hint.id, link: hint.link, text: [] };
11041
+ if (c >= 0) { rec.text.push(after.slice(c + 1).trim()); items.push(rec); } else note = rec;
11042
+ } else if (word === 'direction') {
11043
+ const d = rest.toUpperCase();
11044
+ if (stack.length) say(no, 'approximated', `direction ${rest} inside a composite state is not kept: the view has one direction`);
11045
+ else if (d === 'TB' || d === 'TD') direction = 'down';
11046
+ else if (d === 'LR') direction = 'right';
11047
+ else if (d === 'BT' || d === 'RL') { direction = d === 'BT' ? 'down' : 'right'; say(no, 'approximated', `direction ${d} is drawn ${direction === 'down' ? 'top to bottom' : 'left to right'}`); }
11048
+ else say(no, 'skipped', `direction ${rest}: not a direction`);
11049
+ } else if (word === 'title') title = mtDecode(rest.replace(/^:/, '').trim());
11050
+ else if (['classdef', 'class', 'style'].includes(word)) say(no, 'skipped', `${word === 'classdef' ? 'classDef' : word} ${rest}: styles are not read`);
11051
+ else if (word === 'hide' || word === 'scale' || word === 'click') say(no, 'skipped', `${s}: not read`);
11052
+ else if (word === 'acctitle' || word === 'accdescr') {
11053
+ say(no, 'skipped', `${word === 'acctitle' ? 'accTitle' : 'accDescr'}: accessibility text is not read`);
11054
+ if (word === 'accdescr' && s.includes('{') && !s.includes('}')) block = true;
11055
+ } else if (arrow >= 0 && !(colon >= 0 && colon < arrow && s.slice(colon, colon + 3) !== ':::')) { // a transition: its label starts at the first : after the target
11056
+ const left = s.slice(0, arrow), after = s.slice(arrow + 3);
11057
+ let c = -1;
11058
+ for (let k = 0; k < after.length && c < 0; k++) if (after[k] === ':') { // past a :::class
11059
+ if (!after.startsWith(':::', k)) c = k;
11060
+ else for (k += 2; k + 1 < after.length && !/[\s:]/.test(after[k + 1]); k++);
11061
+ }
11062
+ let target = c < 0 ? after : after.slice(0, c);
11063
+ const pct = target.indexOf('%%');
11064
+ if (pct >= 0) target = target.slice(0, pct);
11065
+ const a = nameOf(left, no, true), b = nameOf(target, no, true);
11066
+ if (!a || !b) { say(no, 'skipped', `"${s.length > 60 ? s.slice(0, 57) + '...' : s}" is not understood`); hint = {}; continue; }
11067
+ const from = a === '[*]' ? star(true, no) : mention(a, no), to = b === '[*]' ? star(false, no) : mention(b, no);
11068
+ items.push({ kind: 'trans', no, from, to, what: `${a} --> ${b}`, text: c < 0 ? '' : mtDecode(after.slice(c + 1).trim()), hint: hint.id });
11069
+ } else if (colon > 0) { // s1 : text
11070
+ const name = nameOf(s.slice(0, colon), no), rawText = s.slice(colon + 1).trim(), t = mtDecode(rawText);
11071
+ if (!name) { say(no, 'skipped', `"${s.length > 60 ? s.slice(0, 57) + '...' : s}" is not understood`); hint = {}; continue; }
11072
+ const rec = mention(name, no), line = mtLine(t);
11073
+ if (line.activity) {
11074
+ if (rec.acts.some(a => a.activity === line.activity)) say(no, 'approximated', `${name} : ${line.activity}: a second ${line.activity} replaces the first`);
11075
+ rec.acts.push({ ...line, no });
11076
+ } else if (line.internal) items.push({ kind: 'internal', rec, no, props: line.internal, hint: hint.id });
11077
+ else items.push({ kind: 'note', target: rec, no, hint: hint.id, link: hint.link, text: [rawText], desc: true });
11078
+ } else {
11079
+ const pct = s.indexOf('%%'), name = nameOf(pct < 0 ? s : s.slice(0, pct), no);
11080
+ if (!name) say(no, 'skipped', `"${s.length > 60 ? s.slice(0, 57) + '...' : s}" is not understood`);
11081
+ else { const rec = mention(name, no); declare(rec); kindOf(rec, no); }
11082
+ }
11083
+ hint = {};
11084
+ }
11085
+ if (!started) { say(1, 'skipped', 'not a Mermaid stateDiagram: nothing read'); return empty(); }
11086
+ if (note) { say(note.no, 'approximated', 'a note with no end note: read to the end'); items.push(note); }
11087
+ for (const o of stack) if (!o.flat) say(o.no, 'approximated', `state ${o.rec.name} { is never closed: read to the end`);
11088
+ // only a state holds activities, internal transitions and a body: on a pseudostate or a final state they are reported,
11089
+ // and what its body holds is read into the scope around it
11090
+ const isState = rec => rec.node.type === 'uml.State', what = rec => (rec.node.type === 'uml.FinalState' ? 'a final state' : `${/^[aeiou]/.test(rec.node.props.kind) ? 'an' : 'a'} ${rec.node.props.kind} pseudostate`);
11091
+ for (const rec of order) {
11092
+ if (isState(rec)) { for (const a of rec.acts) rec.node.props = { ...rec.node.props, [a.activity]: a.value }; continue; }
11093
+ for (const a of rec.acts) say(a.no, 'approximated', `${rec.name} : ${a.activity}: ${rec.name} is ${what(rec)}, which has no activity: not read`);
11094
+ if (rec.opened) say(rec.opened, 'approximated', `state ${rec.name} {: ${rec.name} is ${what(rec)}, which holds nothing: what its body holds is read into the scope around it`);
11095
+ }
11096
+ const real = s => { for (let n = 0; n < MT_WALK && s !== top && !isState(s.owner); n++) s = up(s); return s === top || isState(s.owner) ? s : top; }; // the scope a body of a non-state stands for
11097
+
11098
+ // ids: states by their names first; then, in the order they come, regions, [*], transitions, internal transitions, notes
11099
+ const used = new Set(), next = new Map();
11100
+ const take = (h, base, no, what) => {
11101
+ if (h != null && h !== '') {
11102
+ const t = mtId(h);
11103
+ if (t !== h) say(no, 'approximated', `%% id: ${h}: an id holds no "->", so it is ${t}`);
11104
+ if (!used.has(t)) { used.add(t); return t; }
11105
+ const id = mtFree(used, next, base); used.add(id);
11106
+ say(no, 'approximated', `%% id: ${t} is taken, so ${what} is ${id}`);
11107
+ return id;
11108
+ }
11109
+ const id = mtFree(used, next, base); used.add(id);
11110
+ return id;
11111
+ };
11112
+ for (const rec of order) {
11113
+ rec.node.id = take(rec.hint, mtId(rec.name), rec.no, `state ${rec.name}`);
11114
+ if (rec.hint == null && rec.node.id !== rec.name) say(rec.no, 'approximated', `state ${rec.name}: ${mtId(rec.name) !== rec.name ? 'an id holds no "->"' : 'its name is another\'s id'}, so it is ${rec.node.id}`);
11115
+ }
11116
+ const regioned = rec => isState(rec) && rec.parts && (rec.parts.length > 1 || !!rec.parts[0].hint);
11117
+ const scopeId = s => (s = real(s), s === top ? '' : regioned(s.owner) ? s.id : s.owner.node.id);
11118
+ const nodes = order.map(r => r.node), edges = [];
11119
+ let notes = 0;
11120
+ for (const x of items) {
11121
+ if (x.kind === 'region') {
11122
+ if (!regioned(x.part.owner)) continue;
11123
+ const p = x.part, o = p.owner.node;
11124
+ p.id = take(p.hint?.id, `${o.id}.r${p.k}`, p.owner.no, `a region of ${o.id}`);
11125
+ (o.members ||= []).push({ id: p.id, type: 'uml.Region', ...(p.hint?.name ? { label: p.hint.name } : {}) });
11126
+ } else if (x.kind === 'start' || x.kind === 'end') {
11127
+ const sid = scopeId(x.scope);
11128
+ x.node.id = take(x.hint, sid ? `${sid}.${x.kind}` : x.kind, x.no, `the ${x.kind === 'start' ? 'initial' : 'final'} state of ${sid || 'the top'}`);
11129
+ if (sid) x.node.parent = sid;
11130
+ nodes.push(x.node);
11131
+ } else if (x.kind === 'trans') {
11132
+ const e = { id: take(x.hint, `${x.from.node.id}-${x.to.node.id}`, x.no, x.what), type: 'uml.Transition', from: x.from.node.id, to: x.to.node.id };
11133
+ if (x.text) {
11134
+ const p = parseTransition(x.text, 'its text is not trigger [guard] / effect');
11135
+ if (p.error) { e.props = { trigger: x.text }; e.ext = { mermaid: { label: x.text } }; say(x.no, 'approximated', `${x.what}: ${p.error}: it is kept as its trigger`); }
11136
+ else if (Object.keys(mtParts(p)).length) e.props = mtParts(p);
11137
+ }
11138
+ edges.push(e);
11139
+ } else if (x.kind === 'internal') {
11140
+ const o = x.rec.node;
11141
+ if (!isState(x.rec)) { say(x.no, 'approximated', `${x.rec.name} : ${mtPrint(x.props) ?? 'an internal transition'}: ${x.rec.name} is ${what(x.rec)}, which has no internal transition: not read`); continue; }
11142
+ (o.members ||= []).push({ id: take(x.hint, `${o.id}.t${++x.rec.internal}`, x.rec.no, `an internal transition of ${o.id}`), type: 'uml.InternalTransition', props: x.props });
11143
+ } else {
11144
+ const label = mtDecode(x.text.join('\n')), c = { id: take(x.hint, `note-${++notes}`, x.no, 'a note'), type: 'uml.Comment', ...(label ? { label } : {}), ...(x.desc ? { ext: { mermaid: { description: true } } } : {}) };
11145
+ nodes.push(c);
11146
+ edges.push({ id: take(x.link, `${c.id}-link`, x.no, "a note's line"), type: 'uml.CommentLink', from: c.id, to: x.target.node.id });
11147
+ }
11148
+ }
11149
+ for (const rec of order) if (rec.body && scopeId(rec.body)) rec.node.parent = scopeId(rec.body);
11150
+ for (const n of nodes) if (n.members) n.members.sort((a, b) => (a.type === b.type ? 0 : a.type === 'uml.Region' ? -1 : 1)); // regions, then internal transitions
11151
+
11152
+ const view = { id: viewId || MT_VIEW, diagram: 'uml.state-machine', ...(title ? { title } : {}), ...(direction === 'down' ? { direction } : {}), nodeIds: nodes.map(n => n.id) };
11153
+ report.sort((a, b) => a.line - b.line); // stable: one line's items keep their order
11154
+ return { model: { schemaVersion: 2, ...(title ? { title } : {}), root: view.id, nodes, edges, views: [view] }, report };
11155
+ }
11156
+
11157
+ function writeMermaidState(model, viewId) {
11158
+ const m = model && typeof model === 'object' ? model : {}, arr = x => (Array.isArray(x) ? x : []), isObj = x => !!x && typeof x === 'object';
11159
+ const flat = s => String(s).replace(/\r\n|\r|\n/g, '\\n'), has = x => x != null && x !== '', str = x => typeof x === 'string';
11160
+ const views = arr(m.views).filter(isObj);
11161
+ const view = viewId != null ? views.find(v => v.id === viewId) : views.find(v => v.diagram === 'uml.state-machine');
11162
+ if (viewId != null && !view) return `%% not written: view ${flat(viewId)}, which the model does not have\n`;
11163
+ if (!view) return '%% not written: the model, which has no uml.state-machine view\n';
11164
+ if (view.diagram !== 'uml.state-machine') return `%% not written: view ${flat(view.id)}, a ${flat(view.diagram)} view, not a uml.state-machine one\n`;
11165
+ // text: Mermaid's own characters as entities, a line break as <br>, a space at either end as an entity, which the
11166
+ // reader's trim keeps, and the space after "direction", which Mermaid's lexer takes anywhere in a line
11167
+ const ent = c => `#${c.codePointAt(0)};`;
11168
+ const enc = s => String(s).replace(/[#;:"<>\\]/g, ent).replace(/\r\n|\r|\n/g, '<br>').replace(/^\s|\s$/g, ent).replace(/(direction)(\s)/gi, (_, w, c) => w + ent(c));
11169
+ const okId = id => str(id) && id !== '' && id.trim() === id && !/[\r\n]/.test(id); // a hint keeps it
11170
+ const out = [], notWritten = [], skip = x => notWritten.push(`${flat(x.id)} (${flat(x.type)})`), lose = (id, what) => notWritten.push(`${flat(id)}: ${flat(what)}`);
11171
+ const title = String(has(view.title) ? view.title : m.title ?? '');
11172
+ if (has(title)) out.push('---', `title: ${/^[\p{L}\p{N}][\p{L}\p{N} _.,()-]*$/u.test(title) && !title.endsWith(' ') ? title : JSON.stringify(String(title))}`, '---');
11173
+ if (view.id !== MT_VIEW) { if (/[\r\n]/.test(view.id)) lose(view.id, "the view's id, which has a line break"); else out.push(`%% id: ${view.id}`); }
11174
+ out.push('stateDiagram-v2', view.direction === 'down' ? ' direction TB' : ' direction LR');
11175
+
11176
+ // what is written: the view's states, pseudostates, final states and comments
11177
+ const byId = new Map();
11178
+ for (const n of arr(m.nodes)) if (isObj(n) && !byId.has(n.id)) byId.set(n.id, n);
11179
+ const nodes = [], comments = [], shown = new Set();
11180
+ for (const id of arr(view.nodeIds)) {
11181
+ const n = byId.get(id);
11182
+ if (!n || shown.has(id)) continue;
11183
+ const kind = n.props?.kind, ok = okId(n.id) && (n.type === 'uml.State' || n.type === 'uml.FinalState' || n.type === 'uml.Comment'
11184
+ || (n.type === 'uml.Pseudostate' && (MT_FORKS.includes(kind) || (kind !== 'final' && Object.hasOwn(MT_KINDS, kind)))));
11185
+ if (!ok) { skip(n); continue; }
11186
+ shown.add(id); (n.type === 'uml.Comment' ? comments : nodes).push(n);
11187
+ }
11188
+ const isNode = new Set(nodes.map(n => n.id)), stateOf = new Map(nodes.filter(n => n.type === 'uml.State').map(n => [n.id, n]));
11189
+ const regionsOf = new Map(), regionAt = new Map(); // a state's regions; a region's id -> its state
11190
+ for (const st of stateOf.values()) {
11191
+ const rs = arr(st.members).filter(x => isObj(x) && x.type === 'uml.Region');
11192
+ for (const r of rs) if (!okId(r.id) || /\s/.test(r.id) || regionAt.has(r.id) || isNode.has(r.id)) { lose(st.id, `region ${r.id}, whose id the reader cannot carry`); } else regionAt.set(r.id, st);
11193
+ regionsOf.set(st.id, rs.filter(r => regionAt.get(r.id) === st));
11194
+ }
11195
+ // each node's scope: '' the top, a state's id (no regions), or a region's id; a parent the view does not show is the top
11196
+ const keyOf = new Map();
11197
+ for (const n of nodes) {
11198
+ let k = n.parent ?? '';
11199
+ if (regionAt.has(k)) { /* a region */ } else if (stateOf.has(k) && k !== n.id) { const rs = regionsOf.get(k); if (rs.length) { lose(n.id, `its parent ${k} has regions: written in the first`); k = rs[0].id; } }
11200
+ else { if (k !== '') lose(n.id, `parent ${k}, not a state in the view: written at the top`); k = ''; }
11201
+ keyOf.set(n.id, k);
11202
+ }
11203
+ const ownerOf = k => (regionAt.has(k) ? regionAt.get(k) : stateOf.get(k)); // the state a scope is in
11204
+ const outer = k => keyOf.get(ownerOf(k).id); // the scope around the state that holds k
11205
+ const depth = new Map([['', 0]]); // a scope's depth in composites, each worked out once; Infinity in a cycle of parents
11206
+ const depthOf = k0 => {
11207
+ const path = [], on = new Set();
11208
+ for (let k = k0; !depth.has(k); k = outer(k)) { if (on.has(k)) { depth.set(k, Infinity); break; } on.add(k); path.push(k); }
11209
+ for (let j = path.length - 1; j >= 0; j--) if (!depth.has(path[j])) depth.set(path[j], depth.get(outer(path[j])) + 1);
11210
+ return depth.get(k0);
11211
+ };
11212
+ for (const n of nodes) if (depthOf(keyOf.get(n.id)) > MT_DEEP) { // a cycle of parents, or a nest too deep for the reader, is written at the top
11213
+ lose(n.id, `parent ${n.parent}: it is nested over ${MT_DEEP} deep or in itself, so it is written at the top`); keyOf.set(n.id, '');
11214
+ }
11215
+ const chain = k => { const c = [k]; while (k !== '' && c.length <= MT_DEEP + 1) c.push(k = outer(k)); return c; };
11216
+ const kids = new Map();
11217
+ for (const n of nodes) { const k = keyOf.get(n.id); if (!kids.has(k)) kids.set(k, []); kids.get(k).push(n); }
11218
+
11219
+ // the transitions; each initial pseudostate and final state that can be its scope's [*]
11220
+ const edgeById = new Map(), trans = [], links = new Map();
11221
+ for (const x of arr(m.edges)) if (isObj(x) && !edgeById.has(x.id)) edgeById.set(x.id, x);
11222
+ for (const x of edgeById.values()) {
11223
+ if (x.type === 'uml.CommentLink' && shown.has(x.from) && shown.has(x.to)) { if (!links.has(x.from)) links.set(x.from, []); links.get(x.from).push(x); continue; }
11224
+ if (!(shown.has(x.from) || shown.has(x.to))) continue;
11225
+ if (x.type === 'uml.Transition' && isNode.has(x.from) && isNode.has(x.to) && okId(x.id)) trans.push(x); else skip(x);
11226
+ }
11227
+ const ins = new Map(), outs = new Map(), push = (map, k, x) => { if (!map.has(k)) map.set(k, []); map.get(k).push(x); };
11228
+ for (const t of trans) { push(outs, t.from, t); push(ins, t.to, t); }
11229
+ const stars = new Map(), scopeStar = new Set(); // node -> 'start' | 'end'; the scopes whose [*] is taken
11230
+ for (const pass of ['start', 'end']) for (const n of nodes) {
11231
+ const k = keyOf.get(n.id), initial = pass === 'start';
11232
+ if (!(initial ? n.type === 'uml.Pseudostate' && n.props.kind === 'initial' : n.type === 'uml.FinalState') || has(n.label) || n.members || Object.keys(n.props || {}).some(p => p !== 'kind')) continue;
11233
+ const mine = initial ? outs.get(n.id) : ins.get(n.id), others = initial ? ins.get(n.id) : outs.get(n.id), key = `${k}\0${pass}`;
11234
+ const near = t => { const o = initial ? t.to : t.from; return chain(keyOf.get(o)).includes(k) && (initial || stars.get(o) !== 'start' || keyOf.get(o) === k); };
11235
+ if (!mine || others || scopeStar.has(key) || !mine.every(near)) continue;
11236
+ stars.set(n.id, pass); scopeStar.add(key);
11237
+ }
11238
+ // each transition where the reader finds its ends: [*] in its own scope; any other in the scope around both ends (and
11239
+ // around a state whose regions it crosses), so it names no state in a body that is not around it
11240
+ const transAt = new Map();
11241
+ for (const t of trans) {
11242
+ let k;
11243
+ if (stars.get(t.from) === 'start') k = keyOf.get(t.from);
11244
+ else if (stars.get(t.to) === 'end') k = keyOf.get(t.to);
11245
+ else { const a = new Set(chain(keyOf.get(t.from))); k = chain(keyOf.get(t.to)).find(x => a.has(x)) ?? ''; }
11246
+ push(transAt, k, t);
11247
+ }
11248
+
11249
+ // names: an id Mermaid reads as one word, else one made of it (and its id as a hint)
11250
+ const safe = id => /^[\p{L}\p{N}_.]+$/u.test(id) && !MT_KEYWORDS.has(mtWord(id));
11251
+ const names = new Map(), taken = new Set(nodes.map(n => n.id).filter(safe));
11252
+ for (const n of nodes) {
11253
+ if (stars.has(n.id)) continue;
11254
+ if (safe(n.id)) { names.set(n.id, n.id); continue; }
11255
+ let base = n.id.replace(/[^\p{L}\p{N}_.]+/gu, '_').replace(/^[_.]+|[_.]+$/g, ''), name, k = 1;
11256
+ if (!safe(base)) base = 'S_' + base;
11257
+ for (name = base; taken.has(name); name = `${base}_${++k}`);
11258
+ taken.add(name); names.set(n.id, name);
11259
+ }
11260
+
11261
+ // the ids as the reader derives them, in the order it meets them: the hint, when one is needed
11262
+ const used = new Set(nodes.filter(n => !stars.has(n.id)).map(n => n.id)), next = new Map();
11263
+ const idOf = (id, base) => { const d = mtFree(used, next, base); used.add(id); return d === id ? null : id; };
11264
+ const put = (depth, ...xs) => out.push(...xs.map(x => ' '.repeat(depth + 1) + x));
11265
+ const internals = new Map(), seenStar = new Set();
11266
+ let notes = 0;
11267
+ const inners = new Set(); // composites, whose text Mermaid 12.3.0 refuses beside a label ("Group nodes can only have label"): written as %% row:
11268
+ const textLine = (n, text) => `${inners.has(n.id) ? '%% row: ' : ''}${names.get(n.id)} : ${enc(text)}`;
11269
+ const notesOn = new Map(); // a written node -> the comments on it: each with one line, from it
11270
+ for (const c of comments) {
11271
+ const ls = links.get(c.id) || [], to = ls[0]?.to;
11272
+ if (ls.length !== 1 || !okId(ls[0].id) || !isNode.has(to) || stars.has(to)) { skip(c); ls.forEach(skip); continue; }
11273
+ push(notesOn, to, c);
11274
+ }
11275
+ const writeComments = (n, d) => {
11276
+ for (const c of notesOn.get(n.id) || []) {
11277
+ const link = links.get(c.id)[0], ch = idOf(c.id, `note-${++notes}`), lh = idOf(link.id, `${c.id}-link`), desc = c.ext?.mermaid?.description === true;
11278
+ const heads = [...(ch ? [`%% id: ${ch}`] : []), ...(lh ? [`%% link-id: ${lh}`] : [])];
11279
+ if (desc && has(c.label) && mtLine(String(c.label)).description) put(d, ...heads, textLine(n, c.label));
11280
+ else {
11281
+ if (desc) lose(c.id, 'its text reads as an activity or a transition, so it is written as a note');
11282
+ if (!has(c.label)) lose(c.id, 'no text');
11283
+ put(d, ...heads, `note right of ${names.get(n.id)} : ${has(c.label) ? enc(c.label) : ''}`);
11284
+ }
11285
+ Object.keys(c).filter(p => !['id', 'type', 'label', 'ext'].includes(p)).forEach(p => lose(c.id, p));
11286
+ }
11287
+ };
11288
+ const writeNode = (n, d) => {
11289
+ const name = names.get(n.id), heads = n.id !== name ? [`%% id: ${n.id}`] : [], kind = n.props?.kind;
11290
+ if (n.type === 'uml.Pseudostate' && MT_FORKS.includes(kind)) {
11291
+ if (has(n.label)) lose(n.id, `label ${n.label}`);
11292
+ put(d, ...heads, `state ${name} <<${kind}>>`);
11293
+ } else if (n.type !== 'uml.State') {
11294
+ const k = n.type === 'uml.FinalState' ? 'final' : kind;
11295
+ if (has(n.label) && n.label === MT_KINDS[k]) lose(n.id, `label ${n.label}, read as none`);
11296
+ put(d, ...heads, `%% kind: ${k}`, `state "${enc(has(n.label) ? n.label : MT_KINDS[k])}" as ${name}`);
11297
+ } else {
11298
+ const rs = regionsOf.get(n.id), inner = rs.length > 0 || kids.has(n.id);
11299
+ if (inner) inners.add(n.id);
11300
+ if (!has(n.label)) lose(n.id, 'no label, read as its name');
11301
+ const head = has(n.label) && n.label !== name ? `state "${enc(n.label)}" as ${name}` : inner ? `state ${name}` : name;
11302
+ if (!inner) put(d, ...heads, head);
11303
+ else {
11304
+ put(d, ...heads, head + ' {');
11305
+ if (!rs.length) writeScope(n.id, d + 1);
11306
+ rs.forEach((r, k) => {
11307
+ if (k) put(d + 1, '--');
11308
+ const h = idOf(r.id, `${n.id}.r${k + 1}`);
11309
+ if (r.label === '') lose(r.id, 'an empty label');
11310
+ if (rs.length === 1 || h || has(r.label)) put(d + 1, `%% region: ${r.id}${has(r.label) ? ' ' + enc(r.label) : ''}`);
11311
+ writeScope(r.id, d + 1);
11312
+ });
11313
+ put(d, '}');
11314
+ }
11315
+ const p = isObj(n.props) ? n.props : {};
11316
+ for (const a of MT_ACTS) if (p[a] !== undefined) {
11317
+ const v = p[a], line = str(v) && mtLine(`${a} / ${v}`);
11318
+ if (line && line.activity === a && line.value === v) put(d, textLine(n, `${a} / ${v}`)); else lose(n.id, `${a} ${flat(v)}`);
11319
+ }
11320
+ Object.keys(p).filter(a => !MT_ACTS.includes(a)).forEach(a => lose(n.id, `property ${a}`));
11321
+ for (const x of arr(n.members)) {
11322
+ if (!isObj(x) || x.type === 'uml.Region') continue;
11323
+ const q = isObj(x.props) ? x.props : {}, parts = { trigger: q.trigger, guard: q.guard, effect: q.effect };
11324
+ const printed = x.type === 'uml.InternalTransition' && okId(x.id) && [q.trigger, q.guard, q.effect].every(v => v === undefined || str(v)) && mtPrint(parts);
11325
+ const text = printed && (q.effect === undefined ? printed + ' /' : printed), line = text && mtLine(text);
11326
+ if (!line?.internal || !mtSame(line.internal, parts)) { skip(x); continue; }
11327
+ const k = (internals.get(n.id) || 0) + 1, h = idOf(x.id, `${n.id}.t${k}`);
11328
+ internals.set(n.id, k);
11329
+ put(d, ...(h ? [`%% id: ${h}`] : []), textLine(n, text));
11330
+ }
11331
+ }
11332
+ if (n.type !== 'uml.State' && (n.members || Object.keys(n.props || {}).some(p => p !== 'kind'))) lose(n.id, 'its properties and members');
11333
+ writeComments(n, d);
11334
+ };
11335
+ const writeTrans = (t, d) => {
11336
+ const lines = [], end = x => {
11337
+ const s = stars.get(x);
11338
+ if (!s) return names.get(x);
11339
+ if (!seenStar.has(x)) { seenStar.add(x); const k = keyOf.get(x), h = idOf(x, k ? `${k}.${s}` : s); if (h) lines.push(`%% ${s}-id: ${h}`); }
11340
+ return '[*]';
11341
+ };
11342
+ const a = end(t.from), b = end(t.to), h = idOf(t.id, `${t.from}-${t.to}`), p = isObj(t.props) ? t.props : {};
11343
+ let label = '';
11344
+ const raw = t.ext?.mermaid?.label;
11345
+ if (str(raw) && raw === p.trigger && p.guard === undefined && p.effect === undefined && parseTransition(raw).error) label = raw;
11346
+ else if (Object.keys(p).length) {
11347
+ const parts = { trigger: p.trigger, guard: p.guard, effect: p.effect }, printed = [p.trigger, p.guard, p.effect].every(v => v === undefined || str(v)) && mtPrint(parts);
11348
+ if (printed) label = printed; else lose(t.id, `its label, which does not read back as trigger [guard] / effect`);
11349
+ Object.keys(p).filter(x => !['trigger', 'guard', 'effect'].includes(x)).forEach(x => lose(t.id, `property ${x}`));
11350
+ }
11351
+ if (keyOf.get(t.from) !== keyOf.get(t.to)) lines.unshift('%% warning: Mermaid may not draw this');
11352
+ put(d, ...lines, ...(h ? [`%% id: ${h}`] : []), `${a} --> ${b}${label ? ' : ' + enc(label) : ''}`);
11353
+ };
11354
+ function writeScope(k, d) {
11355
+ for (const n of kids.get(k) || []) if (!stars.has(n.id)) writeNode(n, d);
11356
+ for (const t of transAt.get(k) || []) writeTrans(t, d);
11357
+ }
11358
+ writeScope('', 0);
11359
+ for (const x of notWritten) out.push(` %% not written: ${x}`);
11360
+ return out.join('\n') + '\n';
11361
+ }
11362
+ /* ---------- structure notation: what the component, deployment and composite structure types share (work package 5.3,
11363
+ notes/stage-5.md §4, §9.1) ----------
11364
+ Four shapes, each with size(n), out(n, d), inset(n, side, t), face(n), ports(n, side, k) and draw(n), as src/uml/flow.js's;
11365
+ strokes --um-muted, fills the stage's background (T2), every text cut with fit; hit areas 24 px at least (E8):
11366
+ - port: a 12 px square centred on its owner's border, longer along it where the router gave it several lines (§4.1);
11367
+ its name, n.nameBox, beside it (portsDrawn);
11368
+ - component: a rectangle, UML's component icon (16 × 12) at the top right, «component» (n.keyword) and n.stereotypes
11369
+ over the name; as a box's frame (frame(f)), its name and icon in the header room;
11370
+ - ball: a 16 px circle, its name under it cut at 160 px; a line's end slides onto the circle (src/typed/draw.js);
11371
+ - artifact: a rectangle, UML's document icon (12 × 14) at the top right, «artifact» («deployment spec» for
11372
+ n.type uml.DeploymentSpecification) over the name.
11373
+ Then: each port's side, chosen before the layout from the host's ranking (portSides, §4.2), and the room its owner needs
11374
+ (portFit); after the layout, on a copy of the geometry (§2.4), the ports with no line, every port as a node and its name
11375
+ (portsDrawn, §4.3, §4.5); the shared rules (port-owner, manifest-ends) and the accessible names. Ships in
11376
+ lib/umlimator-all.js only. */
11377
+
11378
+ const SN_OBJ = x => x !== null && typeof x === 'object' && !Array.isArray(x), SN_ARR = x => (Array.isArray(x) ? x : []);
11379
+ const SN = { line: 'var(--um-muted)', ink: 'var(--um-text)', bg: 'var(--um-stage-bg)', port: 12, ball: 16, pad: 10, name: 12, nlh: 16, st: 11, sth: 14, minW: 96, maxW: 320, minH: 48, bmax: 160, bpx: 11 };
11380
+ const snPlural = (k, w) => `${k} ${k === 1 ? w : w + 's'}`;
11381
+ const snNode = (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>`;
11382
+ const snText = (x, y, t, px, wt, fill, cls) => `<text x="${r2(x)}" y="${r2(y)}" text-anchor="middle" class="${cls}" fill="${fill}" style="font:${wt} ${px}px var(--um-sans)">${esc(t)}</text>`;
11383
+ const snBody = d => `<path d="${d}" class="n-mask"/><path d="${d}" class="n-body" fill="${SN.bg}" stroke="${SN.line}"/>`;
11384
+ const snHit = (x, y, w, h) => `<rect x="${r2(x)}" y="${r2(y)}" width="${r2(w)}" height="${r2(h)}" class="fl-hit" fill="transparent"/>`;
11385
+ const snRect = boxOut(0);
11386
+
11387
+ /* ---- the accessible names (§10.1, §10.3): each look's, which the types write into n.aria ---- */
11388
+ const SN_SAY = {
11389
+ // "Component Shop API (service), 2 ports, holding 1 component, 2 artifacts"; holds: a word for each element held
11390
+ component(label, { stereotypes = [], ports = 0, holds = [] } = {}) { const k = new Map(); holds.forEach(w => k.set(w, (k.get(w) || 0) + 1));
11391
+ return [`Component ${label ?? ''}${stereotypes.length ? ` (${stereotypes.join(', ')})` : ''}`, ...(ports ? [snPlural(ports, 'port')] : []),
11392
+ ...(k.size ? ['holding ' + [...k].map(([w, c]) => snPlural(c, w)).join(', ')] : [])].join(', '); },
11393
+ // "Port orders : OrderApi of Shop API, provides Orders, requires Payments"
11394
+ port: (label, owner, { valueType, provides = [], requires = [] } = {}) => `Port ${label ?? ''}${valueType ? ' : ' + valueType : ''}${owner ? ' of ' + owner : ''}`
11395
+ + (provides.length ? ', provides ' + provides.join(', ') : '') + (requires.length ? ', requires ' + requires.join(', ') : ''),
11396
+ // "Interface Orders, as a ball, provided by Shop API, required by Storefront"
11397
+ iface: (label, { ball, providers = [], requirers = [] } = {}) => `Interface ${label ?? ''}${ball ? ', as a ball' : ''}`
11398
+ + (providers.length ? ', provided by ' + providers.join(', ') : '') + (requirers.length ? ', required by ' + requirers.join(', ') : ''),
11399
+ // "Artifact shop-api.jar, on Java 21 runtime, manifests Shop API"
11400
+ artifact: (label, { spec, on, manifests = [] } = {}) => `${spec ? 'Deployment specification' : 'Artifact'} ${label ?? ''}${on ? ', on ' + on : ''}${manifests.length ? ', manifests ' + manifests.join(', ') : ''}`,
11401
+ };
11402
+
11403
+ /* ---- the port: its square, a 24 px area to hit, and its name (n.nameBox, placed by portsDrawn) ---- */
11404
+ SHAPE.port = {
11405
+ size: () => ({ w: SN.port, h: SN.port }), out: snRect, inset: () => 0,
11406
+ draw(n) { const w = Math.max(24, n.w), h = Math.max(24, n.h), b = n.nameBox;
11407
+ return snNode(n, 'port', SN_SAY.port(n.label), snHit(n.x + n.w / 2 - w / 2, n.y + n.h / 2 - h / 2, w, h) + snBody(snRect(n))
11408
+ + (b ? `<text x="${r2(b.x + b.w / 2)}" y="${r2(b.y + 10.5)}" text-anchor="middle" class="port-name" data-box="${r2(b.x)} ${r2(b.y)} ${r2(b.w)} ${r2(b.h)}" fill="${SN.line}" style="font:500 10px var(--um-sans)">${esc(b.text)}</text>` : '')); },
11409
+ };
11410
+
11411
+ /* ---- the component and the artifact: an icon's band at the top, then the keywords and stereotypes, then the name ---- */
11412
+ const snIconBand = 24;
11413
+ const snParts = (n, kw) => { const st = [...(kw ? [kw] : []), ...SN_ARR(n.stereotypes).filter(t => String(t ?? '').trim() !== '')].map(t => `«${t}»`), name = String(n.label ?? '');
11414
+ return { st, name, w: Math.ceil(Math.min(SN.maxW, Math.max(SN.minW, ...st.map(t => textW(t, SN.st) + 2 * SN.pad), textW(name, SN.name) + 2 * SN.pad))),
11415
+ h: Math.max(SN.minH, snIconBand + st.length * SN.sth + SN.nlh + 8) }; };
11416
+ const snKeyword = n => (n.shape === 'artifact' ? (n.type === 'uml.DeploymentSpecification' ? 'deployment spec' : 'artifact') : n.keyword ? 'component' : null);
11417
+ // UML's component icon: a rectangle with two small tabs on its left; the document icon: a page with its corner folded
11418
+ const snCompIcon = (x, y) => `<path d="M${r2(x + 3)} ${r2(y)}H${r2(x + 16)}V${r2(y + 12)}H${r2(x + 3)}Z M${r2(x)} ${r2(y + 2)}H${r2(x + 6)}V${r2(y + 5)}H${r2(x)}Z M${r2(x)} ${r2(y + 7)}H${r2(x + 6)}V${r2(y + 10)}H${r2(x)}Z" class="n-det sn-icon" fill="${SN.bg}" stroke="${SN.line}"/>`;
11419
+ const snDocIcon = (x, y) => `<path d="M${r2(x)} ${r2(y)}H${r2(x + 8)}L${r2(x + 12)} ${r2(y + 4)}V${r2(y + 14)}H${r2(x)}Z M${r2(x + 8)} ${r2(y)}V${r2(y + 4)}H${r2(x + 12)}" class="n-det sn-icon" fill="${SN.bg}" stroke="${SN.line}"/>`;
11420
+ function snClassifier(shape, said, icon) {
11421
+ const S = {
11422
+ size: n => { const p = snParts(n, snKeyword({ ...n, shape })); return { w: p.w, h: p.h }; },
11423
+ out: snRect, inset: () => 0,
11424
+ draw(n) { const p = snParts(n, snKeyword({ ...n, shape })), cx = n.x + n.w / 2, max = n.w - 2 * SN.pad;
11425
+ let y = n.y + snIconBand + (n.h - snIconBand - p.st.length * SN.sth - SN.nlh - 8) / 2;
11426
+ const tx = p.st.map(t => { const s = snText(cx, y + 11, fit(t, SN.st, max), SN.st, 400, SN.line, 'c-st'); y += SN.sth; return s; });
11427
+ tx.push(snText(cx, y + 12, fit(p.name, SN.name, max), SN.name, 600, SN.ink, 'c-name').replace('<text ', `<text data-o="${esc(n.id)}" `));
11428
+ return snNode(n, shape, said(n), snBody(snRect(n)) + icon(n.x + n.w - 22, n.y + 6) + tx.join('')); },
11429
+ };
11430
+ return S;
11431
+ }
11432
+ SHAPE.component = snClassifier('component', n => SN_SAY.component(n.label), snCompIcon);
11433
+ SHAPE.artifact = snClassifier('artifact', n => SN_SAY.artifact(n.label, { spec: n.type === 'uml.DeploymentSpecification' }), (x, y) => snDocIcon(x + 4, y - 1));
11434
+ // a component that holds something shown is a box: its border, its name at the top left of the 24 px header room, its icon at the top right
11435
+ SHAPE.component.frame = f => `<rect class="family-box" x="${f.x}" y="${f.y}" width="${f.w}" height="${f.h}" stroke="${SN.line}"/>`
11436
+ + `<text class="family-title" x="${r2(f.x + 12)}" y="${r2(f.y + 17)}" fill="${SN.ink}" style="font:600 12px var(--um-sans)" data-t="ft" data-o="${esc(f.id)}">${esc(fit(f.title ?? f.label ?? f.id, 12, f.w - 44))}</text>`
11437
+ + snCompIcon(f.x + f.w - 26, f.y + 6);
11438
+
11439
+ /* ---- the ball: the circle at the top, centred, its name under it; a line's end slides onto the circle ---- */
11440
+ const snBallName = n => fit(String(n.label ?? ''), SN.bpx, SN.bmax);
11441
+ const snBallBox = n => ({ x: n.x + n.w / 2 - SN.ball / 2, y: n.y, w: SN.ball, h: SN.ball });
11442
+ const snCircleIn = (b, s, t) => { const r = b.w / 2, u = t - (s === 'L' || s === 'R' ? b.y + r : b.x + r); return r - Math.sqrt(Math.max(0, r * r - u * u)); };
11443
+ const snCircle = (b, d = 0) => { const r = b.w / 2 + d, cx = b.x + b.w / 2, cy = b.y + b.h / 2;
11444
+ return `M${r2(cx - r)} ${r2(cy)}A${r2(r)} ${r2(r)} 0 1 0 ${r2(cx + r)} ${r2(cy)}A${r2(r)} ${r2(r)} 0 1 0 ${r2(cx - r)} ${r2(cy)}Z`; };
11445
+ const SN_CIRCLE = { face: b => [b.y, b.y + b.h], inset: snCircleIn };
11446
+ SHAPE.ball = {
11447
+ size: n => { const t = snBallName(n); return t ? { w: Math.ceil(Math.max(SN.ball, textW(t, SN.bpx) + 4)), h: SN.ball + 4 + SN.sth } : { w: SN.ball, h: SN.ball }; },
11448
+ out: (n, d = 0) => snCircle(snBallBox(n), d), face: n => [n.y, n.y + SN.ball],
11449
+ // the box's side to the circle: its own offset in the box, then the circle's depth. ponytail: a line the router ends on the
11450
+ // left or right below the circle (beside the name) slides to the circle's centre line; the component type gives every line to
11451
+ // a ball its side (notes/stage-5.md §9.2), so none does
11452
+ inset(n, s, t) { const b = snBallBox(n); return (s === 'L' ? b.x - n.x : s === 'R' ? n.x + n.w - b.x - b.w : s === 'B' ? n.y + n.h - b.y - b.h : 0) + snCircleIn(b, s, t); },
11453
+ ports: (n, side, k = 1) => portsOn(SN_CIRCLE, snBallBox(n), side, k),
11454
+ draw(n) { const b = snBallBox(n), t = snBallName(n), cx = n.x + n.w / 2;
11455
+ return snNode(n, 'ball', SN_SAY.iface(n.label, { ball: true }), `<circle cx="${r2(cx)}" cy="${r2(b.y + 8)}" r="12" class="fl-hit" fill="transparent"/>` + snBody(snCircle(b))
11456
+ + (t ? snText(cx, b.y + SN.ball + 4 + 11, t, SN.bpx, 500, SN.ink, 'c-name') : '')); },
11457
+ };
11458
+ for (const k of ['port', 'component', 'artifact']) { const S = SHAPE[k]; S.face = n => [n.y, n.y + n.h]; S.ports = (n, side, j = 1) => portsOn(S, n, side, j); }
11459
+
11460
+ /* ---- a port's side, chosen before the layout (§4.2) ---- */
11461
+ const SN_SIDES = ['L', 'R', 'T', 'B'], SN_PIN = { left: 'L', right: 'R', top: 'T', bottom: 'B' }, SN_TURN = { L: 'T', R: 'B', T: 'L', B: 'R' };
11462
+ const SN_OUT = { L: [-1, 0], R: [1, 0], T: [0, -1], B: [0, 1] };
11463
+ const portLen = k => SN.port + 10 * (Math.max(1, k || 1) - 1); // a port with k lines on its face (§4.1)
11464
+ /* ports: [{ id, owner, pin, far }] in members order: pin, the view's present.<port>.side ('left', 'right', 'top', 'bottom');
11465
+ far: the ids of the far ends of its lines. pos: the ranking, id -> [col, row], a box's the mean of what it holds (the type's).
11466
+ A pinned port takes its pin; else the side facing most far ends, by column, then by row (down: a later column is below);
11467
+ a port with none takes its owner's side with fewest ports, left first. Returns { id: 'L' | 'R' | 'T' | 'B' } */
11468
+ function portSides(ports, pos, down = false) {
11469
+ const out = Object.create(null), count = Object.create(null), P = id => (SN_OBJ(pos) && Object.hasOwn(pos, id) && Array.isArray(pos[id]) ? pos[id] : null), later = [];
11470
+ const put = (p, s) => { out[p.id] = s; (count[p.owner] ||= { L: 0, R: 0, T: 0, B: 0 })[s]++; };
11471
+ SN_ARR(ports).forEach(p => { if (Object.hasOwn(SN_PIN, p.pin)) return put(p, SN_PIN[p.pin]);
11472
+ const o = P(p.owner), votes = { L: 0, R: 0, T: 0, B: 0 };
11473
+ SN_ARR(p.far).forEach(id => { const f = P(id); if (!o || !f) return; const dc = f[0] - o[0], dr = f[1] - o[1], s = dc ? (dc > 0 ? 'R' : 'L') : dr ? (dr > 0 ? 'B' : 'T') : null;
11474
+ if (s) votes[down ? SN_TURN[s] : s]++; });
11475
+ const best = SN_SIDES.reduce((a, s) => (votes[s] > votes[a] ? s : a), 'L');
11476
+ if (votes[best]) put(p, best); else later.push(p); });
11477
+ later.forEach(p => { const c = count[p.owner] || { L: 0, R: 0, T: 0, B: 0 }; put(p, SN_SIDES.reduce((a, s) => (c[s] < c[a] ? s : a), 'L')); });
11478
+ return { ...out };
11479
+ }
11480
+ // the least size of each port's owner (a node, not a box): on each side 16 + Σ (port length + 8) px; ports: [{ id, owner, lines }]
11481
+ function portFit(ports, sides) {
11482
+ const sum = Object.create(null); SN_ARR(ports).forEach(p => { const s = SN_OBJ(sides) && Object.hasOwn(sides, p.id) && sides[p.id]; if (Object.hasOwn(SN_OUT, s)) (sum[p.owner] ||= { L: 16, R: 16, T: 16, B: 16 })[s] += portLen(p.lines) + 8; });
11483
+ return Object.fromEntries(Object.entries(sum).map(([o, k]) => [o, { w: Math.max(k.T, k.B), h: Math.max(k.L, k.R) }]));
11484
+ }
11485
+
11486
+ /* ---- after the layout, on a copy (§4.3, §4.5) ----
11487
+ g: what layoutTyped returned, never edited. ports: [{ id, owner, side, label, valueType, aria }] in members order, side as
11488
+ portSides gave it; id is the port's geometry id (`<part>-><port>` on a part, §6), the one its lines name as fromPort or
11489
+ toPort. A port with lines is where the router's port hook put it (e.fpb, e.tpb: its box on that end; with neither, a 12 px
11490
+ square round the line's end); those with none go on their side in members order, 8 px clear of each other and of the
11491
+ ports with lines, within the side's free span (a node's less 8 px at each corner; a box's 40 px below its top and 16 px
11492
+ above its bottom, 20 px off its corners: stage 2's), or past its end where it cannot hold them. Each port's line ends move 6 px
11493
+ along the side they leave by, onto the square's outer face (its inner face, for a line inward). Each port becomes a node
11494
+ of the copy (shape 'port'), with its name `name : Type` placed by the end labels' search (endLabels) beside its first
11495
+ line out, or where a line straight out would be: clear of lines, nodes, ports, line labels and the names before it */
11496
+ function portsDrawn(g, ports) {
11497
+ const fams = SN_ARR(g.families), has = id => SN_OBJ(g.byId) && Object.hasOwn(g.byId, id), owner = id => (has(id) ? g.byId[id] : fams.find(f => f.id === id)), isBox = id => !has(id);
11498
+ const slide = (p, s) => [r2(p[0] + 6 * SN_OUT[s][0]), r2(p[1] + 6 * SN_OUT[s][1])];
11499
+ const edges = SN_ARR(g.edges).map(e => { if (!e.pts || !(e.fromPort || e.toPort)) return e; const pts = e.pts.map(p => p.slice());
11500
+ if (e.fromPort && SN_OUT[e.fs]) pts[0] = slide(pts[0], e.fs); if (e.toPort && SN_OUT[e.ts]) pts[pts.length - 1] = slide(pts[pts.length - 1], e.ts); return { ...e, pts }; });
11501
+ const union = (a, b) => { const x = Math.min(a.x, b.x), y = Math.min(a.y, b.y); return { x, y, w: Math.max(a.x + a.w, b.x + b.w) - x, h: Math.max(a.y + a.h, b.y + b.h) - y }; };
11502
+ const lined = p => { let r = null; // the union of the boxes its lines record: one port, longer for more lines on its face
11503
+ SN_ARR(g.edges).forEach(e => [[e.fromPort, e.fpb, e.pts && e.pts[0]], [e.toPort, e.tpb, e.pts && e.pts[e.pts.length - 1]]].forEach(([id, b, end]) => {
11504
+ const q = id !== p.id ? null : SN_OBJ(b) ? b : end ? { x: end[0] - 6, y: end[1] - 6, w: 12, h: 12 } : null; if (q) r = r ? union(r, q) : { ...q }; }));
11505
+ return r; };
11506
+ const at = new Map(), onSide = new Map(), boxesOn = (o, s) => onSide.get(o + '|' + s) || onSide.set(o + '|' + s, []).get(o + '|' + s); // a port's box; each owner's side's
11507
+ const seen = new Set(SN_OBJ(g.byId) ? Object.keys(g.byId) : []), list = SN_ARR(ports).filter(p => SN_OBJ(p) && owner(p.owner) && Object.hasOwn(SN_OUT, p.side) && !seen.has(p.id) && seen.add(p.id));
11508
+ list.forEach(p => { const r = lined(p); if (r) { at.set(p.id, r); boxesOn(p.owner, p.side).push(r); } });
11509
+ // the ports with no line, side by side: evenly over the span in members order, then pushed forwards and, at the span's end,
11510
+ // back, 20 px centre to centre (12 + 8), each past a port with lines it would come within 8 px of; a side too short for them
11511
+ // narrows the gaps down to 4 px (§4.4, step 3), and what still does not fit is held at the span's start, marked over
11512
+ [...new Set(list.filter(p => !at.has(p.id)).map(p => p.owner + '|' + p.side))].forEach(key => {
11513
+ const mine = list.filter(p => !at.has(p.id) && p.owner + '|' + p.side === key), o = owner(mine[0].owner), s = mine[0].side, v = s === 'L' || s === 'R', held = boxesOn(mine[0].owner, s);
11514
+ const [a, b] = isBox(mine[0].owner) ? (v ? [o.y + 40, o.y + o.h - 16] : [o.x + 20, o.x + o.w - 20]) : (v ? [o.y + 8, o.y + o.h - 8] : [o.x + 8, o.x + o.w - 8]);
11515
+ const end = r => (v ? r.y + r.h : r.x + r.w);
11516
+ let ts = [];
11517
+ for (let gap = 8; gap >= 4; gap--) { const d = 12 + gap, m = 6 + gap, hit = t => held.find(r => { const lo = v ? r.y : r.x, hi = lo + (v ? r.h : r.w); return t + m > lo + .01 && t - m < hi - .01; });
11518
+ ts = []; mine.forEach((p, i) => { let t = Math.max(a + (b - a) * (i + 1) / (mine.length + 1), a + 6, i ? ts[i - 1] + d : -Infinity), r; while ((r = hit(t))) t = end(r) + m; ts.push(t); });
11519
+ for (let i = mine.length - 1; i >= 0; i--) { let t = Math.min(ts[i], b - 6, i < mine.length - 1 ? ts[i + 1] - d : Infinity), r; while ((r = hit(t))) t = (v ? r.y : r.x) - m; ts[i] = t; }
11520
+ if (ts[0] >= a + 6 - .01) break; }
11521
+ const edge = s === 'L' ? o.x : s === 'R' ? o.x + o.w : s === 'T' ? o.y : o.y + o.h;
11522
+ mine.forEach((p, i) => { const t = Math.max(ts[i], a + 6), r = v ? { x: r2(edge - 6), y: r2(t - 6), w: 12, h: 12 } : { x: r2(t - 6), y: r2(edge - 6), w: 12, h: 12 };
11523
+ if (t > ts[i]) r.over = true; at.set(p.id, r); held.push(r); });
11524
+ });
11525
+ // the names: obstacles are the nodes, the ports, every box's border and 24 px header room, every line's segments and labels,
11526
+ // and the names placed before
11527
+ const segs = edges.filter(e => e.pts).flatMap(e => e.pts.slice(1).map((q, k) => { const p = e.pts[k]; return { x: Math.min(p[0], q[0]) - 3, y: Math.min(p[1], q[1]) - 3, w: Math.abs(q[0] - p[0]) + 6, h: Math.abs(q[1] - p[1]) + 6 }; }));
11528
+ const keep = [...SN_ARR(g.nodes).map(n => ({ x: n.x - 2, y: n.y - 2, w: n.w + 4, h: n.h + 4 })), ...[...at.values()].map(r => ({ x: r.x - 2, y: r.y - 2, w: r.w + 4, h: r.h + 4 })), ...segs, ...edges.filter(e => e.lbl).map(e => e.lbl),
11529
+ ...fams.flatMap(f => [{ x: f.x - 2, y: f.y - 2, w: f.w + 4, h: 26 }, { x: f.x - 2, y: f.y, w: 4, h: f.h }, { x: f.x + f.w - 2, y: f.y, w: 4, h: f.h }, { x: f.x - 2, y: f.y + f.h - 2, w: f.w + 4, h: 4 }])];
11530
+ const nodes = list.map(p => { const r = at.get(p.id), o = owner(p.owner), s = p.side, text = `${p.label ?? ''}${p.valueType ? ' : ' + p.valueType : ''}`;
11531
+ const out = edges.find(e => e.pts && ((e.fromPort === p.id && e.fs === s) || (e.toPort === p.id && e.ts === s)));
11532
+ const v = s === 'L' || s === 'R', face = [r.x + r.w / 2 + (v ? r.w / 2 * SN_OUT[s][0] : 0), r.y + r.h / 2 + (v ? 0 : r.h / 2 * SN_OUT[s][1])];
11533
+ const pts = out ? (out.fromPort === p.id && out.fs === s ? out.pts : out.pts.slice().reverse()) : [face, [face[0] + 30 * SN_OUT[s][0], face[1] + 30 * SN_OUT[s][1]]];
11534
+ const [nameBox] = text.trim() ? endLabels({ end: { labels: { from: { role: text } } }, s: o, t: null }, pts, keep) : [];
11535
+ if (nameBox) keep.push(nameBox);
11536
+ return { id: p.id, label: p.label, type: 'uml.Port', shape: 'port', kind: 'file', k: 'file', owner: p.owner, side: s, col: o.col ?? 0, row: o.row ?? 0, ...r, aria: p.aria || SN_SAY.port(p.label, o.label, { valueType: p.valueType }), ...(nameBox ? { nameBox } : {}) }; });
11537
+ const all = [g.bounds, ...nodes, ...nodes.map(n => n.nameBox)].filter(Boolean);
11538
+ return { ...g, nodes: [...SN_ARR(g.nodes), ...nodes], byId: { ...g.byId, ...Object.fromEntries(nodes.map(n => [n.id, n])) }, edges, ...(all.length ? { bounds: all.reduce(union) } : {}) };
11539
+ }
11540
+
11541
+ /* ---- the shared rules (§10.1, §10.3): each problem { level, rule, path, message, ids }, as src/uml/flow.js's; with a view,
11542
+ only a port whose owner it shows and a manifestation that touches what it shows ---- */
11543
+ const STRUCT_RULES = {
11544
+ 'port-owner': 'a port is a member of a component or a class',
11545
+ 'manifest-ends': 'a manifestation runs from an artifact (an error), to a component (a warning)',
11546
+ };
11547
+ const SN_ARTIFACTS = ['uml.Artifact', 'uml.DeploymentSpecification'];
11548
+ const snWord = t => String(t ?? '').split('.').pop().replace(/([a-z])([A-Z])/g, '$1 $2').toLowerCase(); // 'uml.ExecutionEnvironment': "execution environment"
11549
+ function structProblems(model, view) {
11550
+ const out = [], at = new Map(), shown = new Set(SN_ARR(view && view.nodeIds)), seen = id => !view || shown.has(id);
11551
+ SN_ARR(model && model.nodes).forEach(n => { if (SN_OBJ(n) && typeof n.id === 'string' && !at.has(n.id)) at.set(n.id, n); });
11552
+ const nm = x => (x ? `${snWord(x.type)} ${x.id}${x.label != null && x.label !== '' ? ` ("${x.label}")` : ''}` : 'nothing');
11553
+ const say = (level, rule, path, ids, msg) => out.push({ level, rule, path, ids: ids.filter(x => x != null), message: `${rule}: ${msg}` });
11554
+ SN_ARR(model && model.nodes).forEach((n, i) => { if (!SN_OBJ(n) || !seen(n.id)) return;
11555
+ if (n.type === 'uml.Port') say('error', 'port-owner', `nodes[${i}]`, [n.id], `${nm(n)} is a node; a port is a member of a component or a class`);
11556
+ SN_ARR(n.members).forEach((m, j) => { if (SN_OBJ(m) && m.type === 'uml.Port' && n.type !== 'uml.Component' && n.type !== 'uml.Class')
11557
+ say('error', 'port-owner', `nodes[${i}].members[${j}]`, [m.id, n.id], `${nm(m)} is a member of ${nm(n)}; a port is a member of a component or a class`); }); });
11558
+ SN_ARR(model && model.edges).forEach((e, i) => { if (!SN_OBJ(e) || e.type !== 'uml.Manifestation' || !(seen(e.from) || seen(e.to))) return;
11559
+ const f = at.get(e.from), t = at.get(e.to);
11560
+ if (!f || !SN_ARTIFACTS.includes(f.type)) say('error', 'manifest-ends', `edges[${i}]`, [e.id, e.from], `${e.id} runs from ${nm(f)}: a manifestation runs from an artifact`);
11561
+ if (!t || t.type !== 'uml.Component') say('warning', 'manifest-ends', `edges[${i}]`, [e.id, e.to], `${e.id} runs to ${nm(t)}: «manifest» is drawn to a component only`); });
11562
+ return out;
11563
+ }
11564
+ /* ---------- the component diagram, uml.component (work package 5.4, notes/stage-5.md §10.1) ----------
11565
+ Components (src/uml/structure.js's look), a component or package that holds something shown a box (src/uml/owners.js),
11566
+ its ports on its border (§4), interfaces as a ball or as the class type's rectangle (§9.2), artifacts, closed packages and
11567
+ comments; realizations, component realizations, usages (a socket at a ball, else «use»), assembly and delegation
11568
+ connectors (a delegation from a box's port inward is an inner line, §5), dependencies, manifestations and comment links.
11569
+ Laid out by layoutTyped from the type's own ranking (cpPlace), a usage of a ball turned so the ball comes before its requirers: gap 48, labelGaps
11570
+ and cut 280 (§8), a box with ports 48 px of padding (§7), repair and reroute off (§14). Ports on a box are nodes of the
11571
+ picture; ports on a node are drawn in the node's group (a node 'ported'), so the layout check, which measures every
11572
+ node against every other, does not take a port for an overlap of its own owner (§4.5, as built). Ships in
11573
+ lib/umlimator-all.js only. */
11574
+
11575
+ const CP_OBJ = x => x !== null && typeof x === 'object' && !Array.isArray(x), CP_ARR = x => (Array.isArray(x) ? x : []);
11576
+ const CP_HAS = x => x != null && x !== '';
11577
+ const CP_OWN = (o, k) => (CP_OBJ(o) && Object.hasOwn(o, k) ? o[k] : undefined); // a model's id as a key: 'constructor' is not Object's
11578
+ const cpProps = x => (CP_OBJ(x && x.props) ? x.props : {});
11579
+ const cpName = x => String(x.label ?? x.id);
11580
+ const cpStereo = x => CP_ARR(cpProps(x).stereotypes).filter(CP_HAS).map(String);
11581
+ const cpPlural = (k, w) => `${k} ${k === 1 ? w : w + 's'}`;
11582
+ const CP_LINES = ['uml.InterfaceRealization', 'uml.ComponentRealization', 'uml.Usage', 'uml.Connector', 'uml.Dependency', 'uml.Manifestation', 'uml.CommentLink'];
11583
+ const CP_SIDE = { L: 'left', R: 'right', T: 'top', B: 'bottom' }, CP_OPP = { L: 'R', R: 'L', T: 'B', B: 'T' }, CP_TURN = { L: 'T', T: 'L', R: 'B', B: 'R' };
11584
+ const cpPortsOf = n => CP_ARR(n && n.members).filter(m => CP_OBJ(m) && m.type === 'uml.Port' && typeof m.id === 'string');
11585
+
11586
+ /* ---- the model read: every node, every port and its owner, each node's owners up the chain ---- */
11587
+ function cpIndex(model) {
11588
+ const N = new Map(), P = new Map(); // P: port id -> { x, owner }
11589
+ CP_ARR(model && model.nodes).forEach(n => { if (CP_OBJ(n) && typeof n.id === 'string' && !N.has(n.id)) N.set(n.id, n); });
11590
+ N.forEach(n => cpPortsOf(n).forEach(p => { if (!P.has(p.id) && !N.has(p.id)) P.set(p.id, { x: p, owner: n.id }); }));
11591
+ const chain = id => { const out = []; for (let p = (N.get(id) || {}).parent; p != null && N.has(p) && !out.includes(p) && p !== id; p = N.get(p).parent) out.push(p); return out; };
11592
+ const comp = id => (P.has(id) ? P.get(id).owner : id); // a port's end: its owner
11593
+ const ty = id => (N.get(comp(id)) || {}).type;
11594
+ return { N, P, chain, comp, ty, name: id => (P.has(id) ? `${cpName(P.get(id).x)} of ${cpName(N.get(P.get(id).owner))}` : N.has(id) ? cpName(N.get(id)) : String(id)) };
11595
+ }
11596
+ const cpBall = (I, view, id) => (I.N.get(id) || {}).type === 'uml.Interface' && (CP_OWN(CP_OWN(view && view.present, id), 'as') === 'ball');
11597
+ // what a line is drawn as: its key in the look, the legend and the words
11598
+ function cpKind(I, view, e) {
11599
+ if (e.type === 'uml.InterfaceRealization') return cpBall(I, view, e.to) ? 'stem' : 'realization';
11600
+ if (e.type === 'uml.Usage') return cpBall(I, view, e.to) ? 'socket' : 'usage';
11601
+ if (e.type === 'uml.Connector') return cpProps(e).kind === 'delegation' ? 'delegation' : 'assembly';
11602
+ return { 'uml.ComponentRealization': 'component-realization', 'uml.Dependency': 'dependency', 'uml.Manifestation': 'manifestation', 'uml.CommentLink': 'comment-link' }[e.type] || 'dependency';
11603
+ }
11604
+ // each kind: its ends and stroke (src/typed/draw.js's ENDS), its keyword, its word, and what the legend says of it
11605
+ const CP_LOOK = {
11606
+ stem: [{ from: 'none', to: 'none' }, '', 'Realization', 'provides the interface drawn as a ball (its stem)'],
11607
+ socket: [{ from: 'none', to: 'socket' }, '', 'Usage', 'requires the interface: a socket round its ball'],
11608
+ realization: [{ from: 'none', to: 'triangle', dashed: true }, '', 'Realization', 'provides the interface it points to'],
11609
+ usage: [{ from: 'none', to: 'open', dashed: true }, '«use»', 'Usage', 'requires the one it points to'],
11610
+ 'component-realization': [{ from: 'none', to: 'triangle', dashed: true }, '', 'Component realization', 'realizes the component it points to'],
11611
+ assembly: [{ from: 'none', to: 'none' }, '', 'Assembly', 'joins what one part requires to what another provides'],
11612
+ delegation: [{ from: 'none', to: 'open' }, '', 'Delegation', 'passes on, inward or out, what a port takes or needs'],
11613
+ dependency: [{ from: 'none', to: 'open', dashed: true }, '', 'Dependency', 'uses the one it points to'],
11614
+ manifestation: [{ from: 'none', to: 'open', dashed: true }, '«manifest»', 'Manifestation', 'the artifact is the component\'s file'],
11615
+ 'comment-link': [{ from: 'none', to: 'none', dashed: true }, '', 'Comment link', 'what a comment is about'],
11616
+ };
11617
+
11618
+ /* ---- the view read: its boxes, its lines, every element's words ---- */
11619
+ const CP_BOX = { boxTypes: ['uml.Component', 'uml.Package'], shape: x => (x.type === 'uml.Component' ? 'component' : 'package'), word: x => (x.type === 'uml.Component' ? 'Component' : 'Package') };
11620
+ const CP_WORD = { 'uml.Component': 'component', 'uml.Interface': 'interface', 'uml.Artifact': 'artifact', 'uml.Package': 'package', 'uml.Comment': 'comment', 'uml.Port': 'port' };
11621
+ function cpPic(model, view) {
11622
+ const I = cpIndex(model), pic = ownersOn(model, view, CP_BOX), shown = new Set(pic.ids);
11623
+ // each line the view lists (or with no list each of this type's kinds) whose ends, a port taken to its owner, it shows
11624
+ const E = new Map(); CP_ARR(model && model.edges).forEach(e => { if (CP_OBJ(e) && typeof e.id === 'string' && !E.has(e.id)) E.set(e.id, e); });
11625
+ const list = Array.isArray(view.edges) ? view.edges.map(x => (typeof x === 'string' ? E.get(x) : x)) : [...E.values()].filter(e => CP_LINES.includes(e.type));
11626
+ const at = id => (shown.has(id) ? { at: id, port: null } : I.P.has(id) && shown.has(I.P.get(id).owner) ? { at: I.P.get(id).owner, port: id } : null);
11627
+ const holds = (b, x) => pic.open.has(b) && pic.chain(x).includes(b);
11628
+ const lines = []; list.forEach(e => { if (!CP_OBJ(e) || typeof e.id !== 'string') return; const f = at(e.from), t = at(e.to); if (!f || !t) return;
11629
+ // a line between a box and what it holds leaves the box's port inward (§5); without a port at the box's end it is not drawn
11630
+ const inner = holds(f.at, t.at) ? 'from' : holds(t.at, f.at) ? 'to' : null;
11631
+ if ((inner && !(inner === 'from' ? f : t).port) || (f.at === t.at && (f.port || t.port))) return;
11632
+ lines.push({ e, f, t, inner, kind: cpKind(I, view, e) }); });
11633
+ const boxes = pic.boxes.map(b => { const x = I.N.get(b.id); if (x.type !== 'uml.Component') return b;
11634
+ return { ...b, aria: SN_SAY.component(pic.name(b.id), { stereotypes: cpStereo(x), ports: cpPortsOf(x).length, holds: pic.holds(b.id).map(id => CP_WORD[(I.N.get(id) || {}).type] || 'element') }) }; });
11635
+ return { ...pic, boxes, I, lines };
11636
+ }
11637
+ // "Port orders of Shop API, provides Orders"; "Interface Orders, as a ball, provided by Shop API, required by Storefront"
11638
+ function cpNodeName(model, view, pic, id) {
11639
+ const { I, lines } = pic, x = I.N.get(id) || (I.P.get(id) || {}).x, of = k => lines.filter(l => l.e.type === k);
11640
+ if (!x) return String(id);
11641
+ if (I.P.has(id)) { const by = k => of(k).filter(l => l.e.from === id && I.ty(l.e.to) === 'uml.Interface').map(l => cpName(I.N.get(l.e.to)));
11642
+ return SN_SAY.port(cpName(x), pic.name(I.P.get(id).owner), { valueType: cpProps(x).valueType, provides: by('uml.InterfaceRealization'), requires: by('uml.Usage') }); }
11643
+ const b = pic.boxes.find(y => y.id === id); if (b) return b.aria;
11644
+ const st = cpStereo(x), owners = k => of(k).filter(l => l.e.to === id).map(l => pic.name(I.comp(l.e.from)));
11645
+ if (x.type === 'uml.Component') return SN_SAY.component(pic.name(id), { stereotypes: st, ports: cpPortsOf(x).length });
11646
+ if (x.type === 'uml.Interface') return SN_SAY.iface(pic.name(id), { ball: cpBall(I, view, id), providers: owners('uml.InterfaceRealization'), requirers: owners('uml.Usage') });
11647
+ if (x.type === 'uml.Artifact') return SN_SAY.artifact(pic.name(id), { manifests: of('uml.Manifestation').filter(l => l.e.from === id).map(l => pic.name(l.e.to)) });
11648
+ if (x.type === 'uml.Comment') return 'Comment: ' + cpName(x);
11649
+ return `${x.type === 'uml.Package' ? 'Package' : String(x.type).split('.').pop()} ${pic.name(id)}${st.length ? ` (${st.join(', ')})` : ''}`;
11650
+ }
11651
+ const cpLabel = (l, said) => [CP_LOOK[l.kind][1], ...cpStereo(l.e).map(t => `«${t}»`), said ? null : l.e.label].filter(CP_HAS).join(' ');
11652
+ // "Delegation from orders of Shop API to orders of Checkout"
11653
+ const cpLineName = (pic, l) => { const t = [...cpStereo(l.e).map(s => `«${s}»`), l.e.label].filter(CP_HAS).join(' ');
11654
+ return `${CP_LOOK[l.kind][2]} from ${pic.I.name(l.e.from)} to ${pic.I.name(l.e.to)}${t ? ': ' + t : ''}`; };
11655
+ // reading order (§10.1): each box and what it holds, each element's ports after it in members order, then the lines
11656
+ const cpOrder = pic => [...readingOrder(pic).flatMap(id => [id, ...cpPortsOf(pic.I.N.get(id)).map(p => p.id)]), ...pic.lines.map(l => l.e.id)];
11657
+
11658
+ /* ---- the legend: each line kind drawn. ponytail: copies class.js's legend ends, which it does not export ---- */
11659
+ const CP_ENDS = { none: '', open: '<path d="M17 4L26 8L17 12" fill="none"/>', triangle: '<path d="M16 3L26 8L16 13Z" fill="var(--um-panel)"/>',
11660
+ socket: '<path d="M26 2A6 6 0 0 0 26 14" fill="none"/>', ball: '<circle cx="21" cy="8" r="5" fill="var(--um-panel)"/>' };
11661
+ const cpLegend = kinds => Object.keys(CP_LOOK).filter(k => kinds.has(k)).map(k => { const [L, , name, says] = CP_LOOK[k], end = k === 'stem' ? 'ball' : L.to;
11662
+ 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 8H${end === 'none' ? 26 : end === 'ball' ? 16 : end === 'socket' ? 23 : 17}"${L.dashed ? ' stroke-dasharray="3 2"' : ''}/>${CP_ENDS[end]}</g></svg><b>${esc(name)}</b><span>${esc(says)}</span></span>`; });
11663
+
11664
+ /* ---- a node with ports drawn in its own group: its shape's, with each port's node after it ---- */
11665
+ SHAPE.ported = { size: n => SHAPE[n.base].size(n), out: (n, d) => SHAPE[n.base].out(n, d), inset: (n, s, t) => SHAPE[n.base].inset(n, s, t), face: n => SHAPE[n.base].face(n),
11666
+ ports: (n, s, k) => SHAPE[n.base].ports(n, s, k), draw: (n, c, f) => SHAPE[n.base].draw({ ...n, shape: n.base }, c, f).replace(/<\/g>$/, CP_ARR(n.held).map(p => SHAPE.port.draw(p)).join('') + '</g>') };
11667
+
11668
+ /* ---- the view laid out (§10.1) ----
11669
+ Ranked here, not by the host, whose ranking reads only lines between the view's nodes and so none from a port (Q11): as
11670
+ the host ranks a view with boxes (blockPos, src/map/context.js), in one pass. Each box's contents, then the top, are
11671
+ ranked by the lines between them (a line to what a box holds is the box's), longest paths with cycles broken depth first,
11672
+ a usage of a ball turned so the ball comes before its requirers (provider, ball, socket from left to right); a box is a block of its
11673
+ contents' columns and rows; columns ordered by untangle and rows by rowsFor (src/layout/layered.js); what no line reaches
11674
+ in rows under its block. A pin (the view's layout, by element) wins, and a node it would cover moves down */
11675
+ function cpRank(items, links) {
11676
+ const outs = new Map(items.map(id => [id, []])), st = new Map(), fwd = [];
11677
+ links.forEach(l => outs.get(l[0]).push(l));
11678
+ const dfs = u => { st.set(u, 1); outs.get(u).forEach(l => { if (st.get(l[1]) === 1) return; fwd.push(l); if (!st.has(l[1])) dfs(l[1]); }); st.set(u, 2); };
11679
+ [...items.filter(id => !links.some(l => l[1] === id)), ...items].forEach(id => { if (!st.has(id)) dfs(id); });
11680
+ const rank = Object.fromEntries(items.map(id => [id, 0]));
11681
+ for (let it = 0, more = true; more && it <= items.length; it++) { more = false; fwd.forEach(([a, b]) => { if (rank[b] < rank[a] + 1) { rank[b] = rank[a] + 1; more = true; } }); }
11682
+ return { rank, fwd };
11683
+ }
11684
+ function cpPlace(pic, lines, pins) {
11685
+ const links = lines.filter(l => !l.inner).map(l => (l.kind === 'socket' ? [l.t.at, l.f.at] : [l.f.at, l.t.at]));
11686
+ const itemIn = (x, g) => { for (let k = 0; x != null && k < 64; k++, x = pic.owner(x)) if (pic.owner(x) === g) return x; };
11687
+ const block = g => { const items = pic.holds(g), size = Object.create(null), pos = Object.create(null);
11688
+ items.forEach(x => { size[x] = pic.open.has(x) ? block(x) : { w: 1, h: 1, pos: { [x]: [0, 0] } }; });
11689
+ const L = links.map(([a, b]) => [itemIn(a, g), itemIn(b, g)]).filter(([a, b]) => a !== undefined && b !== undefined && a !== b), lined = new Set(L.flat());
11690
+ const { rank, fwd } = cpRank(items.filter(x => lined.has(x)), L), cols = [], hh = x => size[x].h;
11691
+ items.filter(x => lined.has(x)).forEach(x => (cols[rank[x]] ||= []).push(x));
11692
+ untangle(cols, fwd, hh); const row = rowsFor(cols, fwd, hh);
11693
+ let c0 = 0, h = 0; cols.forEach(list => { let r0 = 0, w = 0;
11694
+ list.forEach(x => { r0 = Math.max(r0, row[x] ?? 0); Object.entries(size[x].pos).forEach(([id, [c, r]]) => { pos[id] = [c0 + c, r0 + r]; }); r0 += size[x].h; w = Math.max(w, size[x].w); });
11695
+ c0 += w; h = Math.max(h, r0); });
11696
+ const loose = items.filter(x => !lined.has(x)), per = Math.max(2, c0);
11697
+ let c = 0, r = h, rh = 0; // packed by size in rows under the lined columns, a row as tall as its tallest
11698
+ loose.forEach(x => { if (c && c + size[x].w > per) { r += rh; c = 0; rh = 0; }
11699
+ Object.entries(size[x].pos).forEach(([id, [a, b]]) => { pos[id] = [c + a, r + b]; }); c += size[x].w; rh = Math.max(rh, size[x].h); c0 = Math.max(c0, c); });
11700
+ return { w: c0, h: r + rh, pos }; };
11701
+ const at = block(null).pos, P = Object.entries(CP_OBJ(pins) ? pins : {}).filter(([id, p]) => Object.hasOwn(at, id) && Array.isArray(p) && p.length === 2 && p.every(Number.isFinite));
11702
+ if (!P.length) return at;
11703
+ const taken = P.map(([, p]) => p), free = (c, r) => !taken.some(([c2, r2]) => c2 === c && Math.abs(r2 - r) < 1), pinned = new Set(P.map(([id]) => id));
11704
+ P.forEach(([id, p]) => { at[id] = p; });
11705
+ Object.keys(at).forEach(id => { if (pinned.has(id)) return; let [c, r] = at[id]; while (!free(c, r)) r++; at[id] = [c, r]; taken.push([c, r]); });
11706
+ return at;
11707
+ }
11708
+ function cpGeo({ model, view, pos: pins, memo }) {
11709
+ const pic = cpPic(model, view), { I, lines } = pic, down = view.direction === 'down', present = CP_OBJ(view.present) ? view.present : {}, pos = cpPlace(pic, lines, pins);
11710
+ const P = id => { const p = CP_OWN(pos, id); if (Array.isArray(p)) return p; const k = pic.open.has(id) ? pic.holds(id).map(P).filter(Boolean) : []; // a box: the mean of what it holds
11711
+ return k.length ? [k.reduce((a, q) => a + q[0], 0) / k.length, k.reduce((a, q) => a + q[1], 0) / k.length] : null; };
11712
+ const pos2 = Object.fromEntries(pic.ids.map(id => [id, P(id)]).filter(([, p]) => p));
11713
+ // each port's side (§4.2): pinned, else facing its lines' far ends outside it; a port whose only lines run to its owner's
11714
+ // owner's ports takes their side, so a delegation runs straight in, and their order: the mean place of their far ends
11715
+ const shownPorts = pic.ids.flatMap(id => cpPortsOf(I.N.get(id)).filter(p => I.P.has(p.id) && I.P.get(p.id).owner === id).map((p, k) => ({ p, owner: id, rank: k })));
11716
+ const ends = id => lines.flatMap(l => [[l, 'from', l.f, l.t], [l, 'to', l.t, l.f]].filter(([, , me]) => me.port === id));
11717
+ const far = id => ends(id).filter(([l, w, me, o]) => l.inner !== w && !pic.chain(me.at).includes(o.at)).map(([, , , o]) => o.at);
11718
+ const side = portSides(shownPorts.map(({ p, owner }) => ({ id: p.id, owner, pin: CP_OWN(CP_OWN(present, p.id), 'side'), far: far(p.id) })), pos2, down);
11719
+ const follow = Object.create(null); shownPorts.forEach(({ p }) => { if (CP_OWN(CP_OWN(present, p.id), 'side') != null || far(p.id).length) return;
11720
+ const up = ends(p.id).find(([l, w, me, o]) => o.port && pic.chain(me.at).includes(o.at)); if (up) { side[p.id] = side[up[3].port]; follow[p.id] = up[3].port; } });
11721
+ const lay = s => CP_SIDE[down ? CP_TURN[s] : s]; // a picture's side as layoutTyped takes it: laid out down, turned
11722
+ const k = id => Math.max(1, ...['out', 'in'].map(w => ends(id).filter(([l, wh]) => (l.inner === wh) === (w === 'in')).length)); // its busiest face
11723
+ const fit = portFit(shownPorts.map(({ p, owner }) => ({ id: p.id, owner, lines: k(p.id) })), side);
11724
+ const nodes = pic.ids.filter(id => !pic.open.has(id)).map(id => { const n = I.N.get(id), st = cpStereo(n), pr = CP_OWN(present, id) || {};
11725
+ const ops = CP_ARR(n.members).filter(m => CP_OBJ(m) && (m.type === 'uml.Operation' || m.type === 'uml.Property'));
11726
+ const look = n.type === 'uml.Comment' ? { shape: 'note' } : n.type === 'uml.Package' ? { shape: 'package', stereotypes: st } : n.type === 'uml.Artifact' ? { shape: 'artifact', stereotypes: st }
11727
+ : n.type === 'uml.Interface' ? (cpBall(I, view, id) ? { shape: 'ball' } : { shape: 'classifier', stereotypes: ['interface', ...st], compartments: ops.length ? [{ name: 'operations', members: ops }] : [] })
11728
+ : n.type === 'uml.Class' ? { shape: 'classifier', stereotypes: st } : { shape: 'component', stereotypes: st, keyword: !!CP_OWN(pr, 'keyword') };
11729
+ const d = { id, label: n.type === 'uml.Comment' ? cpName(n) : pic.name(id), type: n.type || 'uml.Component', kind: 'file', ...look, aria: cpNodeName(model, view, pic, id), col: (P(id) || [0, 0])[0], row: (P(id) || [0, 0])[1] };
11730
+ // a ball is laid out as its circle, so the router ends its lines level with the circle's centre; its name hangs under it in
11731
+ // the row gap. ponytail: the layout keeps no room for the name, which a line passing under the ball may cross (§17)
11732
+ const s = SHAPE[d.shape].size(d), f = CP_OWN(fit, id); return { ...d, w: Math.max(s.w, f ? f.w : 0), h: d.shape === 'ball' ? 16 : Math.max(s.h, f ? f.h : 0) }; });
11733
+ // a box whose ports have lines, or that holds one that does, takes 48 px of padding (§7)
11734
+ const lined = new Set(shownPorts.filter(({ p }) => ends(p.id).length).map(x => x.owner));
11735
+ const boxes = pic.boxes.map(b => ({ ...b, ...([b.id, ...pic.ids.filter(x => pic.chain(x).includes(b.id))].some(x => pic.open.has(x) && lined.has(x)) ? { pad: 48 } : {}) }));
11736
+ const ball = (l, w) => { const me = w === 'from' ? l.f : l.t, o = w === 'from' ? l.t : l.f; if (me.port || !cpBall(I, view, me.at)) return {};
11737
+ const a = P(me.at) || [0, 0], b = P(o.at) || [0, 0]; // a ball's line on its left or right by the rank ([col, row] holds it first either way) (§9.2); laid out down, a line from
11738
+ // before it comes in above and one from after it on the side it comes from, so none crosses the name under the circle
11739
+ return { [w + 'Side']: !down ? CP_SIDE[b[0] > a[0] ? 'R' : 'L'] : b[0] < a[0] ? 'left' : lay(b[1] > a[1] ? 'R' : 'L') }; };
11740
+ const end = (l, w) => { const me = w === 'from' ? l.f : l.t; if (!me.port) return ball(l, w); const s = side[me.port];
11741
+ return { [w + 'Port']: me.port, [w + 'Side']: lay(l.inner === w ? CP_OPP[s] : s) }; };
11742
+ const along = id => { const a = (side[id] === 'L' || side[id] === 'R') !== down ? 1 : 0, f = far(id).map(P).filter(Boolean); return f.length ? f.reduce((t, q) => t + q[a], 0) / f.length : 0; };
11743
+ const rank = id => (follow[id] ? along(follow[id]) : (shownPorts.find(x => x.p.id === id) || {}).rank);
11744
+ const edges = lines.map(l => { const label = cpLabel(l), r = [l.f.port, l.t.port].map(x => (x ? rank(x) : null));
11745
+ return { id: l.e.id, _id: l.e.id, type: l.e.type, from: l.f.at, to: l.t.at, ...end(l, 'from'), ...end(l, 'to'), ...(l.inner ? { inner: l.inner } : {}),
11746
+ ...(l.f.port || l.t.port ? { portRank: l.f.port && l.t.port ? r : r[l.f.port ? 0 : 1] } : {}), label: label || undefined, ...(label ? { cut: 280 } : {}),
11747
+ end: CP_LOOK[l.kind][0], aria: cpLineName(pic, l), ...(CP_OBJ(l.e.style) || typeof l.e.style === 'string' ? { style: l.e.style } : {}) }; });
11748
+ const g = placeTabs(layoutTyped({ nodes, edges, ...(boxes.length ? { containers: boxes } : {}), direction: down ? 'down' : 'right', gap: 48, rgap: 56, labelGaps: true, repair: false, reroute: false }, memo));
11749
+ // on a copy (stage 4 §2.4): every port, its name, and each port on a node put in that node's group
11750
+ const d = portsDrawn(g, shownPorts.map(({ p, owner }) => ({ id: p.id, owner, side: side[p.id], label: cpName(p), valueType: cpProps(p).valueType, aria: cpNodeName(model, view, pic, p.id) })));
11751
+ const held = new Map(); d.nodes.forEach(n => { if (n.shape === 'port' && Object.hasOwn(g.byId, n.owner)) held.set(n.owner, [...(held.get(n.owner) || []), n]); });
11752
+ const out = d.nodes.filter(n => !(n.shape === 'port' && held.has(n.owner))).map(n => (held.has(n.id) && n.shape !== 'port' ? { ...n, shape: 'ported', base: n.shape, held: held.get(n.id) } : n));
11753
+ // a slot after each element (src/typed/edit.js): the palette's add hears the selection through it (cpAdd, at.sel)
11754
+ const byId = { ...d.byId, ...Object.fromEntries(out.map(n => [n.id, n])) }, slots = Object.values(byId).map((n, index) => ({ index, x: n.x, y: n.y, after: n.id }));
11755
+ return { ...d, nodes: out, byId, slots, legend: cpLegend(new Set(lines.map(l => l.kind))), order: cpOrder(pic) };
11756
+ }
11757
+
11758
+ /* ---- validators (§10.1). Each problem: { level, rule, path, message, ids }. With a view, the lines and elements it shows ---- */
11759
+ const CP_RULES = {
11760
+ ...STRUCT_RULES,
11761
+ 'provided-ends': 'a realization runs from a component, a class or a port to an interface',
11762
+ 'required-ends': 'a usage from a component or a port runs to an interface (another is drawn as «use»)',
11763
+ 'ball-interface': 'a view draws only an interface as a ball',
11764
+ 'delegation-ends': 'a delegation joins a component\'s port with a component or port inside it, at any depth',
11765
+ 'assembly-ends': 'an assembly joins two components or their ports, neither inside the other',
11766
+ 'component-realization-ends': 'a component realization runs from a class or a component to a component',
11767
+ 'component-owner': 'a component\'s owner is a component or a package',
11768
+ };
11769
+ function cpProblems(model, view) {
11770
+ const out = [...structProblems(model, view)], I = cpIndex(model), say = (level, rule, path, ids, msg) => out.push({ level, rule, path, ids: ids.filter(x => x != null), message: `${rule}: ${msg}` });
11771
+ const shown = new Set(CP_ARR(view && view.nodeIds)), seen = id => !view || shown.has(I.comp(id)), listed = view && Array.isArray(view.edges) ? new Set(view.edges.map(x => (CP_OBJ(x) ? x.id : x))) : null;
11772
+ const q = id => JSON.stringify(I.name(id)), comp = id => I.ty(id) === 'uml.Component', within = (a, b) => I.chain(I.comp(a)).includes(I.comp(b));
11773
+ CP_ARR(model && model.nodes).forEach((n, i) => { if (!CP_OBJ(n) || n.type !== 'uml.Component' || !seen(n.id) || n.parent == null || !I.N.has(n.parent)) return;
11774
+ const p = I.N.get(n.parent); if (!['uml.Component', 'uml.Package'].includes(p.type)) say('warning', 'component-owner', `nodes[${i}].parent`, [n.id, p.id], `${q(n.id)} is owned by ${q(p.id)}, a ${String(p.type).split('.').pop()}: a component's owner is a component or a package`); });
11775
+ CP_ARR(model && model.edges).forEach((e, i) => { if (!CP_OBJ(e) || !(seen(e.from) || seen(e.to)) || (listed && !listed.has(e.id))) return;
11776
+ const path = `edges[${i}]`, ends = [e.id, e.from, e.to], from = I.P.has(e.from) ? 'port' : I.ty(e.from), kind = cpProps(e).kind === 'delegation' ? 'delegation' : 'assembly'; // drawn as an assembly without one (cpKind)
11777
+ if (e.type === 'uml.InterfaceRealization' && (!['uml.Component', 'uml.Class', 'port'].includes(from) || I.P.has(e.to) || I.ty(e.to) !== 'uml.Interface'))
11778
+ say('error', 'provided-ends', path, ends, `${e.id} runs from ${q(e.from)} to ${q(e.to)}: a realization runs from a component, a class or a port to an interface`);
11779
+ if (e.type === 'uml.Usage' && e.to != null && (from === 'port' || from === 'uml.Component') && (I.P.has(e.to) || I.ty(e.to) !== 'uml.Interface'))
11780
+ say('warning', 'required-ends', path, ends, `${e.id} runs from ${q(e.from)} to ${q(e.to)}, not an interface: it is drawn as «use»`);
11781
+ if (e.type === 'uml.Connector' && kind === 'delegation' && !(comp(e.from) && comp(e.to) && I.comp(e.from) !== I.comp(e.to) && (within(e.to, e.from) ? I.P.has(e.from) : within(e.from, e.to) && I.P.has(e.to))))
11782
+ say('error', 'delegation-ends', path, ends, `${e.id} joins ${q(e.from)} and ${q(e.to)}: a delegation joins a component's port with a component or port inside it`);
11783
+ if (e.type === 'uml.Connector' && kind === 'assembly' && !(comp(e.from) && comp(e.to) && I.comp(e.from) !== I.comp(e.to) && !within(e.from, e.to) && !within(e.to, e.from)))
11784
+ say('error', 'assembly-ends', path, ends, `${e.id} joins ${q(e.from)} and ${q(e.to)}: an assembly joins two components or their ports, neither inside the other`);
11785
+ if (e.type === 'uml.ComponentRealization' && (I.P.has(e.from) || I.P.has(e.to) || !['uml.Class', 'uml.Component'].includes(I.ty(e.from)) || I.ty(e.to) !== 'uml.Component'))
11786
+ say('error', 'component-realization-ends', path, ends, `${e.id} runs from ${q(e.from)} to ${q(e.to)}: a component realization runs from a class or a component to a component`); });
11787
+ if (view) { const vi = CP_ARR(model.views).indexOf(view);
11788
+ Object.entries(CP_OBJ(view.present) ? view.present : {}).forEach(([id, p]) => { if (CP_OWN(p, 'as') === 'ball' && (I.N.get(id) || {}).type !== 'uml.Interface')
11789
+ say('warning', 'ball-interface', `views[${vi}].present`, [id], `${q(id)} is not an interface: view ${JSON.stringify(view.id)} does not draw it as a ball`); }); }
11790
+ return out;
11791
+ }
11792
+
11793
+ /* ---- describe(): the view in sentences, each element by its accessible name in reading order, then each line ---- */
11794
+ function cpDescribe(model, viewId) {
11795
+ const v = CP_ARR(model && model.views).find(x => CP_OBJ(x) && x.id === viewId); if (!v) throw new Error(`umlimator: no view ${JSON.stringify(viewId)}`);
11796
+ const pic = cpPic(model, v), by = new Map(pic.lines.map(l => [l.e.id, l]));
11797
+ return [`${v.title || v.id}: a component diagram of ${cpPlural(pic.ids.length, 'element')} and ${cpPlural(pic.lines.length, 'line')}.`,
11798
+ ...cpOrder(pic).map(id => (by.has(id) ? cpLineName(pic, by.get(id)) : cpNodeName(model, v, pic, id)) + '.')].join('\n');
11799
+ }
11800
+
11801
+ /* ---- editing (§12) ---- */
11802
+ const cpNewId = (model, base) => { const used = new Set(); JSON.stringify(model, (k, v) => { if (k === 'id' && typeof v === 'string') used.add(v); return v; });
11803
+ let k = 1; while (used.has(base + k)) k++; return base + k; };
11804
+ // a port's row: `name` or `name : Type`
11805
+ const cpPortText = t => { const s = String(t ?? ''), k = s.indexOf(':'), [name, type] = (k < 0 ? [s] : [s.slice(0, k), s.slice(k + 1)]).map(x => x.trim()); return name ? { label: name, valueType: type || undefined } : { error: 'A port has a name: name or name : Type' }; };
11806
+ const cpPortPrint = m => `${m.label ?? ''}${CP_HAS(cpProps(m).valueType) ? ' : ' + cpProps(m).valueType : ''}`;
11807
+ // connect: a connector between a port (or component) and one inside its owner is a delegation, else an assembly
11808
+ function cpConnect(model, view, from, to, at, line) {
11809
+ if (!CP_OBJ(line) || line.type !== 'uml.Connector') return null;
11810
+ const I = cpIndex(model), within = (a, b) => I.chain(I.comp(a)).includes(I.comp(b));
11811
+ if (I.comp(from) === I.comp(to)) return { error: 'A connector joins two elements' };
11812
+ const kind = within(from, to) || within(to, from) ? 'delegation' : 'assembly';
11813
+ if (kind === 'delegation' && !I.P.has(within(to, from) ? from : to)) return { error: 'Delegate from a port of the box' }; // drawn only from its port (cpPic)
11814
+ return [{ op: 'add', element: { id: cpNewId(model, 'connector'), type: 'uml.Connector', from, to, props: { kind } }, view: view.id }];
11815
+ }
11816
+ // a port dropped, or Alt + arrows on it: its side, as present.<port>.side (§4.5); along its own side, nothing, as said
11817
+ function cpMoveTo(model, view, id, at) {
11818
+ const I = cpIndex(model); if (!I.P.has(id) || !CP_OBJ(at)) return null;
11819
+ const g = CP_OBJ(at.geo) ? at.geo : {}, me = CP_OWN(g.byId, id), o = CP_OWN(g.byId, I.P.get(id).owner) || CP_ARR(g.families).find(f => f.id === I.P.get(id).owner);
11820
+ let s = null;
11821
+ if (Array.isArray(at.by)) { const [dx, dy] = at.by, v = me && (me.side === 'L' || me.side === 'R');
11822
+ if (me && (v ? dy && !dx : dx && !dy)) return { commands: [], say: 'A port\'s order along its side follows its lines: Alt + arrows across it moves it to another side' };
11823
+ s = dx < 0 ? 'L' : dx > 0 ? 'R' : dy < 0 ? 'T' : dy > 0 ? 'B' : null; }
11824
+ else if (Array.isArray(at.pt) && o) { const [x, y] = at.pt, d = { L: Math.abs(x - o.x), R: Math.abs(x - o.x - o.w), T: Math.abs(y - o.y), B: Math.abs(y - o.y - o.h) };
11825
+ s = Object.keys(d).reduce((a, b) => (d[b] < d[a] ? b : a)); }
11826
+ return s ? [{ op: 'set', view: view.id, path: ['present', id, 'side'], value: CP_SIDE[s] }] : null;
11827
+ }
11828
+ // the palette: a port into the selected component; an interface as a ball or a rectangle, picked
11829
+ function cpAdd(model, view, entry, label, at) {
11830
+ const I = cpIndex(model), sel = CP_ARR(at && at.sel).map(id => (I.P.has(id) ? I.P.get(id).owner : id)).find(id => ['uml.Component', 'uml.Class'].includes((I.N.get(id) || {}).type));
11831
+ if (entry.type === 'uml.Port') return sel ? [{ op: 'add', element: { id: cpNewId(model, 'port'), type: 'uml.Port', ...(label ? { label } : {}) }, owner: sel }] : { error: 'Select a component to add a port to' };
11832
+ if (entry.type !== 'uml.Interface') return null;
11833
+ const id = cpNewId(model, 'interface'), add = { op: 'add', element: { id, type: 'uml.Interface', ...(label ? { label } : {}) }, view: view.id };
11834
+ return { pick: ['Ball', 'Rectangle'], then: i => (i === 0 ? [add, { op: 'set', view: view.id, path: ['present', id, 'as'], value: 'ball' }] : [add]) };
11835
+ }
11836
+ // the panel's member rows on a component: a port, `name` or `name : Type` (§12.1). A class has no rows here (member.holds): its ports come from the palette
11837
+ function cpMember(model, view, owner, id, text) {
11838
+ const I = cpIndex(model); if ((I.N.get(owner) || {}).type !== 'uml.Component') return null;
11839
+ const r = cpPortText(text); if (r.error) return r;
11840
+ if (id == null) return [{ op: 'add', element: { id: cpNewId(model, 'port'), type: 'uml.Port', label: r.label, ...(r.valueType ? { props: { valueType: r.valueType } } : {}) }, owner }];
11841
+ return [{ op: 'set', id, path: 'label', value: r.label }, { op: 'set', id, path: 'props.valueType', value: r.valueType }];
11842
+ }
11843
+ // B on an interface: a ball, or back to a rectangle (S11)
11844
+ function cpKey(model, view, ids, key) {
11845
+ if (key !== 'b' && key !== 'B') return null;
11846
+ const I = cpIndex(model), its = ids.filter(id => (I.N.get(id) || {}).type === 'uml.Interface'); if (!its.length) return null;
11847
+ return its.map(id => ({ op: 'set', view: view.id, path: ['present', id, 'as'], value: cpBall(I, view, id) ? undefined : 'ball' }));
11848
+ }
11849
+ // Delete on a port: a member is not hidden, it is deleted from the model, as said (§12.2, S20)
11850
+ // the rest of a mixed selection as the canvas's default (src/canvas/edit.js): hidden, or deleted when it lives in this view only
11851
+ function cpRemove(model, view, ids, fromModel) {
11852
+ const I = cpIndex(model), ps = ids.filter(id => I.P.has(id)); if (!ps.length) return null;
11853
+ const own = new Set([...CP_ARR(view.nodes), ...CP_ARR(view.edges)].filter(CP_OBJ).map(x => x.id)), lines = new Set(CP_ARR(model.edges).filter(CP_OBJ).map(e => e.id));
11854
+ const rest = ids.filter(id => !I.P.has(id) && (fromModel || own.has(id) || !lines.has(id) || Array.isArray(view.edges))); // a line of a view listing none: not hidden, as the default
11855
+ const hid = fromModel ? 0 : rest.filter(id => !own.has(id)).length;
11856
+ return { commands: [...ps, ...rest].map(id => (fromModel || own.has(id) || I.P.has(id) ? { op: 'delete', id } : { op: 'hide', id, view: view.id })),
11857
+ say: `Deleted ${ps.length === 1 ? 'port ' + cpName(I.P.get(ps[0]).x) : cpPlural(ps.length, 'port')} from the model${fromModel ? '' : ': a port is not hidden from a view'}${hid ? `; hid ${cpPlural(hid, 'element')}` : ''}` };
11858
+ }
11859
+ // the Owner field: a component's components and packages; an interface's, an artifact's or a comment's packages
11860
+ function cpOwners(model, x) {
11861
+ if (!CP_OBJ(x) || x.from != null || !['uml.Component', 'uml.Interface', 'uml.Artifact', 'uml.Package', 'uml.Comment'].includes(x.type)) return null;
11862
+ const kinds = x.type === 'uml.Component' ? ['uml.Component', 'uml.Package'] : ['uml.Package'];
11863
+ const I = cpIndex(model); // not itself nor what it holds: apply() refuses a move inside itself
11864
+ return CP_ARR(model.nodes).filter(n => CP_OBJ(n) && kinds.includes(n.type) && n.id !== x.id && !I.chain(n.id).includes(x.id)).map(n => ({ id: n.id, label: cpName(n) }));
11865
+ }
11866
+
11867
+ const CP_STEREO = { key: 'stereotypes', label: 'Stereotypes', kind: 'list' };
11868
+ const CP_TYPE = registerDiagramType('uml.component', {
11869
+ title: 'component diagram',
11870
+ elements: [['Component', 'Component'], ['Interface', 'Interface'], ['Artifact', 'Artifact'], ['Package', 'Package'], ['Port', 'Port'], ['Comment', 'Comment']].map(([t, label]) => ({ type: 'uml.' + t, label })),
11871
+ lines: [['InterfaceRealization', 'Realization'], ['ComponentRealization', 'Component realization'], ['Usage', 'Usage'], ['Connector', 'Assembly', 'assembly'], ['Connector', 'Delegation', 'delegation'],
11872
+ ['Dependency', 'Dependency'], ['Manifestation', 'Manifestation'], ['CommentLink', 'Comment link']].map(([t, label, kind]) => ({ type: 'uml.' + t, label, ...(kind ? { props: { kind } } : {}) })),
11873
+ props: {
11874
+ 'uml.Component': [CP_STEREO], 'uml.Interface': [CP_STEREO], 'uml.Artifact': [CP_STEREO], 'uml.Package': [CP_STEREO], 'uml.Port': [{ key: 'valueType', label: 'Type', kind: 'text' }, CP_STEREO],
11875
+ 'uml.Connector': [{ key: 'kind', label: 'Kind', kind: 'enum', options: ['assembly', 'delegation'] }, CP_STEREO],
11876
+ 'uml.InterfaceRealization': [CP_STEREO], 'uml.ComponentRealization': [CP_STEREO], 'uml.Usage': [CP_STEREO], 'uml.Dependency': [CP_STEREO], 'uml.Manifestation': [CP_STEREO], 'uml.CommentLink': [],
11877
+ },
11878
+ layout: Object.assign(cpGeo, { rank: false }), // cpGeo ranks the view itself (cpPlace): the host passes only the pins
11879
+ boxes: (model, view) => cpPic(model, view).boxes,
11880
+ owners: cpOwners,
11881
+ // a component's rows are its ports (edits.member reads them first); an interface's its operations, as the class type's
11882
+ member: { print: m => (m.type === 'uml.Port' ? cpPortPrint(m) : printMember(m)), edit: (m, t) => (m.type === 'uml.Port' ? { error: 'A port is edited on its component' } : memberEdit(m, t)), add: t => parseMember(String(t ?? '').trim()),
11883
+ holds: n => n.type === 'uml.Component' || n.type === 'uml.Interface' },
11884
+ validate: cpProblems,
11885
+ rules: CP_RULES,
11886
+ describe: cpDescribe,
11887
+ edits: { connect: cpConnect, moveTo: cpMoveTo, add: cpAdd, member: cpMember, key: cpKey, remove: cpRemove },
11888
+ // the PDF's tables (§11): a component's ports, each with what it provides and requires; an interface's operations; what an artifact manifests
11889
+ word: (model, id) => { const x = cpIndex(model).N.get(id); return x ? ({ 'uml.Component': 'Component', 'uml.Interface': 'Interface', 'uml.Artifact': 'Artifact', 'uml.Package': 'Package', 'uml.Comment': 'Comment' }[x.type] || null) : null; },
11890
+ rows: (model, view, id) => { const pic = cpPic(model, view), x = pic.I.N.get(id); if (!x) return null;
11891
+ if (x.type === 'uml.Component') return cpPortsOf(x).map(p => cpNodeName(model, view, pic, p.id).replace(/^Port /, '').replace(` of ${pic.name(id)}`, ''));
11892
+ if (x.type === 'uml.Interface') return CP_ARR(x.members).filter(CP_OBJ).map(printMember);
11893
+ if (x.type === 'uml.Artifact') return pic.lines.filter(l => l.e.type === 'uml.Manifestation' && l.e.from === id).map(l => `manifests ${pic.name(l.e.to)}`);
11894
+ return null; },
11895
+ });
11896
+ /* ---------- the deployment diagram, uml.deployment (work package 5.5, notes/stage-5.md §10.3) ----------
11897
+ Nodes, devices and execution environments (UML's cube: a front face with a top and a right face 10 px deep), each a box
11898
+ when it holds something shown, nested to any depth; artifacts and deployment specifications (src/uml/structure.js's
11899
+ artifact), deployed by nesting or by a «deploy» line; components, what an artifact manifests (the component look);
11900
+ communication paths with their labels, dependencies and comments. Laid out by layoutTyped from the host's ranking with a
11901
+ small gap, gapAfter's per-column widening, labelGaps and paths cut at 280 px (§8); repair and reroute off (§14). A cube
11902
+ box's frame holds its faces (10 px more padding), so the layout and check() see them; a line ending on its outline's
11903
+ slanted corner is drawn on to it, on a copy (§2.4). Ships in lib/umlimator-all.js only. */
11904
+
11905
+ const DP_OBJ = x => x !== null && typeof x === 'object' && !Array.isArray(x), DP_ARR = x => (Array.isArray(x) ? x : []);
11906
+ const DP_HAS = x => x != null && x !== '';
11907
+ const dpName = x => String(x.label ?? x.id);
11908
+ const dpStereo = x => DP_ARR(DP_OBJ(x.props) ? x.props.stereotypes : null).filter(DP_HAS).map(String);
11909
+ const dpPlural = (k, w) => `${k} ${k === 1 ? w : w + 's'}`;
11910
+ const DP_TARGETS = ['uml.Node', 'uml.Device', 'uml.ExecutionEnvironment'], DP_ARTIFACTS = ['uml.Artifact', 'uml.DeploymentSpecification'];
11911
+ const DP_LINES = ['uml.Deployment', 'uml.Manifestation', 'uml.CommunicationPath', 'uml.Dependency', 'uml.CommentLink'];
11912
+ const DP_WORD = { 'uml.Node': 'Node', 'uml.Device': 'Device', 'uml.ExecutionEnvironment': 'Execution environment', 'uml.Artifact': 'Artifact',
11913
+ 'uml.DeploymentSpecification': 'Deployment specification', 'uml.Component': 'Component', 'uml.Comment': 'Comment' };
11914
+ const dpWord = x => (Object.hasOwn(DP_WORD, x.type) ? DP_WORD[x.type] : String(x.type ?? '').split('.').pop());
11915
+ const DP_KW = { 'uml.Device': 'device', 'uml.ExecutionEnvironment': 'executionEnvironment' };
11916
+
11917
+ /* ---- the cube (§9.1): SHAPE.node's outline and inset, 10 px deep, in the typed look's paint; closed a node, open a frame ---- */
11918
+ const DP = { d: 10, pad: 10, name: 12, nlh: 16, st: 11, sth: 14, minW: 120, maxW: 320, minH: 60 };
11919
+ const dpParts = n => { const st = [...(DP_KW[n.type] ? [DP_KW[n.type]] : []), ...DP_ARR(n.stereotypes)].map(t => `«${t}»`), name = String(n.label ?? '');
11920
+ return { st, name, w: Math.ceil(Math.min(DP.maxW, Math.max(DP.minW, ...st.map(t => textW(t, DP.st) + 2 * DP.pad), textW(name, DP.name) + 2 * DP.pad)) + DP.d),
11921
+ h: Math.max(DP.minH, DP.d + 16 + st.length * DP.sth + DP.nlh) }; };
11922
+ const dpOut = (n, g = 0) => { const p = DP.d, x = n.x - g, y = n.y - g, w = n.w + 2 * g, h = n.h + 2 * g;
11923
+ return `M${r2(x)} ${r2(y + p)}L${r2(x + p)} ${r2(y)}H${r2(x + w)}V${r2(y + h - p)}L${r2(x + w - p)} ${r2(y + h)}H${r2(x)}Z`; };
11924
+ const dpText = (x, y, t, px, wt, fill, cls, more = '') => `<text x="${r2(x)}" y="${r2(y)}" text-anchor="middle" class="${cls}"${more} fill="${fill}" style="font:${wt} ${px}px var(--um-sans)">${esc(t)}</text>`;
11925
+ SHAPE.cube = {
11926
+ size: n => { const p = dpParts(n); return { w: p.w, h: p.h }; },
11927
+ out: dpOut, face: n => [n.y + DP.d, n.y + n.h],
11928
+ inset(n, s, t) { const p = DP.d; return s === 'L' ? Math.max(0, n.y + p - t) : s === 'R' ? Math.max(0, t - n.y - n.h + p) : s === 'T' ? Math.max(0, n.x + p - t) : Math.max(0, t - n.x - n.w + p); },
11929
+ draw(n) {
11930
+ const { x, y, w, h } = n, p = DP.d, P = dpParts(n), cx = x + (w - p) / 2, max = w - p - 2 * DP.pad;
11931
+ let ty = y + p + (h - p - P.st.length * DP.sth - DP.nlh) / 2;
11932
+ const tx = P.st.map(t => { const s = dpText(cx, ty + 11, fit(t, DP.st, max), DP.st, 400, 'var(--um-muted)', 'c-st'); ty += DP.sth; return s; });
11933
+ tx.push(dpText(cx, ty + 12, fit(P.name, DP.name, max), DP.name, 600, 'var(--um-text)', 'c-name', ` data-o="${esc(n.id)}"`));
11934
+ return `<g class="node cube" data-id="${esc(n.id)}" data-shape="cube" tabindex="-1" role="img" aria-label="${esc(n.aria || `${dpWord(n)} ${n.label ?? ''}`)}">`
11935
+ + `<path d="${dpOut(n)}" class="n-mask"/><path d="${dpOut(n)}" class="n-body" fill="var(--um-stage-bg)" stroke="var(--um-muted)"/>`
11936
+ + `<path d="M${r2(x)} ${r2(y + p)}H${r2(x + w - p)}V${r2(y + h)}M${r2(x + w - p)} ${r2(y + p)}L${r2(x + w)} ${r2(y)}" class="n-det" fill="none" stroke="var(--um-muted)"/>`
11937
+ + `<g class="c-head" data-band="${r2(y + p)} ${r2(y + h)}">${tx.join('')}</g></g>`;
11938
+ },
11939
+ // open: the frame holds the cube as a closed one does (its pad is 10 px more, dpPic), the front face its rect, the top and
11940
+ // right faces inside the frame; the keyword, stereotypes and name (the box's title) at the top left of its 24 px header room
11941
+ frame: f => { const p = DP.d, { x, y, w, h } = f;
11942
+ return `<rect class="family-box" x="${x}" y="${r2(y + p)}" width="${r2(w - p)}" height="${r2(h - p)}" stroke="var(--um-muted)"/>`
11943
+ + `<path d="M${r2(x)} ${r2(y + p)}L${r2(x + p)} ${r2(y)}H${r2(x + w)}V${r2(y + h - p)}L${r2(x + w - p)} ${r2(y + h)}M${r2(x + w - p)} ${r2(y + p)}L${r2(x + w)} ${r2(y)}" class="dp-face" fill="none" stroke="var(--um-muted)"/>`
11944
+ + `<text class="family-title" x="${r2(x + 12)}" y="${r2(y + p + 17)}" fill="var(--um-text)" style="font:600 12px var(--um-sans)" data-t="ft" data-o="${esc(f.id)}">${esc(fit(f.title ?? f.label ?? f.id, 12, w - p - 24))}</text>`; },
11945
+ };
11946
+
11947
+ /* ---- the view read: its boxes (§7), its lines, and every element's words ---- */
11948
+ const DP_LOOK = { boxTypes: DP_TARGETS, qualify: false, shape: () => 'cube', word: dpWord };
11949
+ function dpIndex(model) {
11950
+ const at = new Map(); DP_ARR(model && model.nodes).forEach(n => { if (DP_OBJ(n) && typeof n.id === 'string' && !at.has(n.id)) at.set(n.id, n); });
11951
+ const E = DP_ARR(model && model.edges).filter(e => DP_OBJ(e) && typeof e.id === 'string');
11952
+ const into = (t, id) => E.filter(e => e.type === t && e.to === id), outOf = (t, id) => E.filter(e => e.type === t && e.from === id);
11953
+ // what is deployed on a target: the artifacts it owns, then those a «deploy» line brings, each once
11954
+ const deployed = id => [...new Set([...[...at.values()].filter(n => n.parent === id && DP_ARTIFACTS.includes(n.type)).map(n => n.id), ...into('uml.Deployment', id).map(e => e.from).filter(x => at.has(x) && DP_ARTIFACTS.includes(at.get(x).type))])];
11955
+ // does a hold b, at any depth
11956
+ const holds = (a, b) => { for (let p = (at.get(b) || {}).parent, seen = new Set(); p != null && !seen.has(p); p = (at.get(p) || {}).parent) { if (p === a) return true; seen.add(p); } return false; };
11957
+ return { at, E, into, outOf, deployed, holds };
11958
+ }
11959
+ // the view's boxes, each titled with its keyword and stereotypes and named as §10.3 says
11960
+ function dpPic(model, view) {
11961
+ const pic = ownersOn(model, view, DP_LOOK), I = dpIndex(model);
11962
+ const boxes = pic.boxes.map(b => { const x = pic.at.get(b.id); return { ...b, title: [...[...(DP_KW[x.type] ? [DP_KW[x.type]] : []), ...dpStereo(x)].map(t => `«${t}»`), dpName(x)].join(' '), aria: dpNodeName(x, pic, I), pad: 12 + DP.d }; });
11963
+ return { ...pic, I, boxes };
11964
+ }
11965
+ // the lines drawn: those the view lists, or with no list the model's of this type's kinds, between two elements it shows; a
11966
+ // line from a box to what it holds is left out (the router refuses it)
11967
+ function dpLines(model, view, pic) {
11968
+ const E = new Map(pic.I.E.map(e => [e.id, e])), shown = new Set(pic.ids), list = Array.isArray(view.edges) ? view.edges.map(x => (typeof x === 'string' ? E.get(x) : x)) : pic.I.E.filter(e => DP_LINES.includes(e.type));
11969
+ return list.filter(e => DP_OBJ(e) && typeof e.id === 'string' && shown.has(e.from) && shown.has(e.to)
11970
+ && ![[e.from, e.to], [e.to, e.from]].some(([b, x]) => pic.open.has(b) && (b === x || pic.chain(x).includes(b))));
11971
+ }
11972
+ const DP_KEYWORD = { 'uml.Deployment': '«deploy»', 'uml.Manifestation': '«manifest»' };
11973
+ const dpLineLabel = e => [DP_KEYWORD[e.type], ...dpStereo(e).map(t => `«${t}»`), e.label].filter(DP_HAS).join(' ');
11974
+ const DP_LINE_LOOK = { 'uml.Deployment': { from: 'none', to: 'open', dashed: true }, 'uml.Manifestation': { from: 'none', to: 'open', dashed: true },
11975
+ 'uml.CommunicationPath': { from: 'none', to: 'none' }, 'uml.Dependency': { from: 'none', to: 'open', dashed: true }, 'uml.CommentLink': { from: 'none', to: 'none', dashed: true } };
11976
+ const DP_LINE_WORD = { 'uml.Deployment': 'Deployment', 'uml.Manifestation': 'Manifestation', 'uml.CommunicationPath': 'Communication path', 'uml.Dependency': 'Dependency', 'uml.CommentLink': 'Comment link' };
11977
+ // accessible names (§10.3): "Device Application server, holding 1 execution environment"; "Execution environment Java 21 runtime,
11978
+ // in Application server, 3 artifacts deployed"; "Artifact shop-api.jar, on Java 21 runtime, manifests Shop API"
11979
+ function dpNodeName(n, pic, I) {
11980
+ if (n.type === 'uml.Comment') return 'Comment: ' + dpName(n);
11981
+ const nm = id => dpName(I.at.get(id)), st = dpStereo(n), up = n.parent != null && I.at.get(n.parent);
11982
+ if (DP_ARTIFACTS.includes(n.type)) return SN_SAY.artifact(dpName(n), { spec: n.type === 'uml.DeploymentSpecification',
11983
+ on: [...new Set([...(up && DP_TARGETS.includes(up.type) ? [up.id] : []), ...I.outOf('uml.Deployment', n.id).map(e => e.to).filter(x => I.at.has(x))])].map(nm).join(', ') || null,
11984
+ manifests: I.outOf('uml.Manifestation', n.id).map(e => e.to).filter(x => I.at.has(x)).map(nm) });
11985
+ if (n.type === 'uml.Component') return SN_SAY.component(dpName(n), { stereotypes: st });
11986
+ const out = [`${dpWord(n)} ${dpName(n)}${st.length ? ` (${st.join(', ')})` : ''}`];
11987
+ if (!DP_TARGETS.includes(n.type)) return out[0];
11988
+ if (up && pic.ids.includes(up.id)) out.push(`in ${dpName(up)}`);
11989
+ const k = new Map(); pic.holds(n.id).map(id => I.at.get(id)).filter(x => !DP_ARTIFACTS.includes(x.type)).forEach(x => { const w = dpWord(x).toLowerCase(); k.set(w, (k.get(w) || 0) + 1); });
11990
+ if (k.size) out.push('holding ' + [...k].map(([w, c]) => dpPlural(c, w)).join(', '));
11991
+ const d = I.deployed(n.id).length; if (d) out.push(`${dpPlural(d, 'artifact')} deployed`);
11992
+ return out.join(', ');
11993
+ }
11994
+ // "Communication path HTTPS from Shopper's device to Edge proxy"; "Deployment from routes.conf to Edge proxy"
11995
+ function dpLineName(e, pic) {
11996
+ const nm = id => dpName(pic.at.get(id)), word = Object.hasOwn(DP_LINE_WORD, e.type) ? DP_LINE_WORD[e.type] : 'Line', said = [...dpStereo(e).map(t => `«${t}»`), e.label].filter(DP_HAS).join(' ');
11997
+ return e.type === 'uml.CommunicationPath' ? `${word} ${said ? said + ' ' : ''}from ${nm(e.from)} to ${nm(e.to)}` : `${word} from ${nm(e.from)} to ${nm(e.to)}${said ? ': ' + said : ''}`;
11998
+ }
11999
+
12000
+ /* ---- the legend: each line kind drawn. ponytail: copies class.js's legendItem and ends, which it does not export ---- */
12001
+ const DP_ENDS = { none: '', open: '<path d="M17 4L26 8L17 12" fill="none"/>' };
12002
+ const DP_SAYS = { 'uml.Deployment': '«deploy»: the artifact runs on the target', 'uml.Manifestation': '«manifest»: the artifact is built from the component',
12003
+ 'uml.CommunicationPath': 'the two exchange messages', 'uml.Dependency': 'uses the one it points to', 'uml.CommentLink': 'what a comment is about' };
12004
+ const dpLegend = edges => DP_LINES.filter(t => edges.some(e => e.type === t)).map(t => { const L = DP_LINE_LOOK[t], name = DP_LINE_WORD[t], says = DP_SAYS[t];
12005
+ 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 8H${L.to === 'none' ? 26 : 17}"${L.dashed ? ' stroke-dasharray="3 2"' : ''}/>${DP_ENDS[L.to]}</g></svg><b>${esc(name)}</b><span>${esc(says)}</span></span>`; });
12006
+
12007
+ /* ---- the view laid out (§10.3) ---- */
12008
+ // on a copy: a label left over a cube box's front face's top or right line (in the box's padding, where check() does not
12009
+ // look) slides along its line inward, clear of it, if its line runs on that far; families come outermost first
12010
+ function dpOffFace(e, fams) {
12011
+ if (!e.lbl || !e.pts) return e;
12012
+ let L = e.lbl;
12013
+ for (const f of fams) for (const k of [1, 0]) { // 1: the top line, y = f.y + 10; 0: the right line, x = f.x + f.w - 10
12014
+ const at = k ? f.y + DP.d : f.x + f.w - DP.d, o = 1 - k, lo = [L.x, L.y][k], len = [L.w, L.h][k], c = [L.x + L.w / 2, L.y + L.h / 2];
12015
+ if (!(lo < at && at < lo + len) || c[o] < (o ? f.y + DP.d : f.x) || c[o] > (o ? f.y + f.h : f.x + f.w - DP.d)) continue;
12016
+ const to = k ? at + 2 : at - 2 - len, nc = to + len / 2; // the label's new start along its line, and its centre
12017
+ const run = e.pts.slice(1).some((q, i) => { const p = e.pts[i]; return Math.abs(p[o] - c[o]) < .5 && Math.abs(q[o] - c[o]) < .5 && Math.min(p[k], q[k]) <= Math.min(c[k], nc) && Math.max(p[k], q[k]) >= Math.max(c[k], nc); });
12018
+ if (run) L = { ...L, [k ? 'y' : 'x']: to };
12019
+ }
12020
+ return L === e.lbl ? e : { ...e, lbl: L };
12021
+ }
12022
+ function dpGeo({ model, view, pos, memo }) {
12023
+ const pic = dpPic(model, view), lines = dpLines(model, view, pic), P = id => (DP_OBJ(pos) && Object.hasOwn(pos, id) ? pos[id] : [0, 0]);
12024
+ const nodes = pic.ids.filter(id => !pic.open.has(id)).map(id => { const n = pic.at.get(id), st = dpStereo(n);
12025
+ const shape = n.type === 'uml.Comment' ? 'note' : DP_TARGETS.includes(n.type) ? 'cube' : DP_ARTIFACTS.includes(n.type) ? 'artifact' : n.type === 'uml.Component' ? 'component' : 'classifier';
12026
+ const d = { id, label: dpName(n), type: n.type || 'uml.Node', kind: 'file', shape, stereotypes: st, ...(shape === 'classifier' ? { compartments: [] } : {}), aria: dpNodeName(n, pic, pic.I), col: P(id)[0], row: P(id)[1] };
12027
+ return { ...d, ...SHAPE[shape].size(d) }; });
12028
+ const edges = lines.map(e => { const label = dpLineLabel(e);
12029
+ return { id: e.id, _id: e.id, type: e.type, from: e.from, to: e.to, label: label || undefined, end: Object.hasOwn(DP_LINE_LOOK, e.type) ? DP_LINE_LOOK[e.type] : { from: 'none', to: 'arrow' }, aria: dpLineName(e, pic),
12030
+ ...(e.type === 'uml.CommunicationPath' ? { cut: 280 } : {}), ...(DP_OBJ(e.style) || typeof e.style === 'string' ? { style: e.style } : {}) }; });
12031
+ // a small gap with gapAfter's widening, and labelGaps for a label left with no spot (§8, decision 9); stage 2's 12 px padding:
12032
+ // a deployment view has no ports (§7)
12033
+ const g = layoutTyped({ nodes, edges, ...(pic.boxes.length ? { containers: pic.boxes } : {}), direction: view.direction === 'down' ? 'down' : 'right',
12034
+ gap: 48, rgap: 56, labelGaps: true, repair: false, reroute: false }, memo);
12035
+ // on a copy: a line's end on a cube box's slanted corner goes in on to it, as SHAPE.cube.inset does for a closed cube
12036
+ const fams = new Map((g.families || []).map(f => [f.id, f])), at = (f, s, p) => (f && s ? SHAPE.cube.inset(f, s, s === 'T' || s === 'B' ? p[0] : p[1]) : 0);
12037
+ const out = g.edges.map(e => { const da = e.pts && at(fams.get(e.from), e.fs, e.pts[0]), db = e.pts && at(fams.get(e.to), e.ts, e.pts.at(-1));
12038
+ if (!da && !db) return e;
12039
+ const pts = e.pts.map(p => p.slice()), mv = (p, s, k) => { p[s === 'T' || s === 'B' ? 1 : 0] += s === 'T' || s === 'L' ? k : -k; };
12040
+ if (da) mv(pts[0], e.fs, da); if (db) mv(pts[pts.length - 1], e.ts, db); return { ...e, pts }; }).map(e => dpOffFace(e, g.families || []));
12041
+ return { ...g, edges: out, legend: dpLegend(g.edges), order: [...readingOrder(pic), ...lines.map(e => e.id)] }; // a new object: what layoutTyped returned is never edited (§2.4)
12042
+ }
12043
+
12044
+ /* ---- validators (§10.3). Each problem: { level, rule, path, message, ids } ---- */
12045
+ const DP_RULES = {
12046
+ 'deploy-ends': 'a deployment runs from an artifact or deployment specification to a node, device or execution environment',
12047
+ 'deployed-twice': 'an artifact nested in one target is not also deployed by a line to another',
12048
+ 'nest-kinds': 'a node, device or execution environment is owned by one of the three (an error), in a usual pair (a warning): an execution environment in any, a device in a node or a device, a node in a node',
12049
+ 'path-ends': 'a communication path joins two nodes, devices or execution environments, neither holding the other',
12050
+ 'manifest-ends': 'a manifestation runs from an artifact (an error), to a component (a warning)',
12051
+ };
12052
+ const DP_USUAL = { 'uml.ExecutionEnvironment': DP_TARGETS, 'uml.Device': ['uml.Node', 'uml.Device'], 'uml.Node': ['uml.Node'] };
12053
+ function dpProblems(model, view) {
12054
+ const out = [], I = dpIndex(model), say = (level, rule, path, ids, msg) => out.push({ level, rule, path, ids: ids.filter(x => x != null), message: `${rule}: ${msg}` });
12055
+ const ty = id => (I.at.get(id) || {}).type, q = id => JSON.stringify(I.at.has(id) ? dpName(I.at.get(id)) : String(id)), kind = id => (I.at.has(id) ? dpWord(I.at.get(id)).toLowerCase() : 'nothing');
12056
+ const a = id => (/^[aeiou]/.test(kind(id)) ? 'an ' : 'a ') + kind(id);
12057
+ DP_ARR(model && model.nodes).forEach((n, i) => { if (!DP_OBJ(n) || !DP_TARGETS.includes(n.type) || n.parent == null || !I.at.has(n.parent)) return;
12058
+ const p = I.at.get(n.parent);
12059
+ if (!DP_TARGETS.includes(p.type)) say('error', 'nest-kinds', `nodes[${i}].parent`, [n.id, p.id], `${q(n.id)}, ${a(n.id)}, is owned by ${q(p.id)}, ${a(p.id)}: its owner is a node, a device or an execution environment`);
12060
+ else if (!DP_USUAL[n.type].includes(p.type)) say('warning', 'nest-kinds', `nodes[${i}].parent`, [n.id, p.id], `${q(n.id)}, ${a(n.id)}, is in ${q(p.id)}, ${a(p.id)}: an unusual pair`); });
12061
+ DP_ARR(model && model.edges).forEach((e, i) => { if (!DP_OBJ(e)) return; const path = `edges[${i}]`, ends = [e.id, e.from, e.to];
12062
+ if (e.type === 'uml.Deployment') {
12063
+ if (!DP_ARTIFACTS.includes(ty(e.from)) || !DP_TARGETS.includes(ty(e.to))) say('error', 'deploy-ends', path, ends, `${e.id} runs from ${q(e.from)} (${kind(e.from)}) to ${q(e.to)} (${kind(e.to)}): from an artifact or deployment specification to a node, device or execution environment`);
12064
+ else { const a = I.at.get(e.from); if (a.parent != null && DP_TARGETS.includes(ty(a.parent)) && a.parent !== e.to)
12065
+ say('warning', 'deployed-twice', path, [e.id, e.from, a.parent, e.to], `${q(e.from)} is nested in ${q(a.parent)} and deployed by ${e.id} to ${q(e.to)} as well`); } }
12066
+ if (e.type === 'uml.CommunicationPath' && (!DP_TARGETS.includes(ty(e.from)) || !DP_TARGETS.includes(ty(e.to)) || e.from === e.to || I.holds(e.from, e.to) || I.holds(e.to, e.from)))
12067
+ say('error', 'path-ends', path, ends, `${e.id} joins ${q(e.from)} (${kind(e.from)}) and ${q(e.to)} (${kind(e.to)}): a communication path joins two nodes, devices or execution environments`); });
12068
+ return [...out, ...structProblems(model, view).filter(p => p.rule === 'manifest-ends')];
12069
+ }
12070
+
12071
+ /* ---- describe(): the view in sentences, each element by its accessible name in reading order, then each line ---- */
12072
+ function dpDescribe(model, viewId) {
12073
+ const v = DP_ARR(model && model.views).find(x => DP_OBJ(x) && x.id === viewId); if (!v) throw new Error(`umlimator: no view ${JSON.stringify(viewId)}`);
12074
+ const pic = dpPic(model, v), lines = dpLines(model, v, pic), by = new Map(lines.map(e => [e.id, e]));
12075
+ return [`${v.title || v.id}: a deployment diagram of ${dpPlural(pic.ids.length, 'element')} and ${dpPlural(lines.length, 'line')}.`,
12076
+ ...[...readingOrder(pic), ...lines.map(e => e.id)].map(id => (by.has(id) ? dpLineName(by.get(id), pic) : dpNodeName(pic.at.get(id), pic, pic.I)) + '.')].join('\n');
12077
+ }
12078
+
12079
+ /* ---- editing (§12) ---- */
12080
+ // the Owner field: an artifact's, a deployment specification's or a target's: every node, device and execution environment by
12081
+ // its name (the core leaves out the element and what it holds)
12082
+ function dpOwners(model, x) {
12083
+ if (!DP_OBJ(x) || x.from != null || ![...DP_TARGETS, ...DP_ARTIFACTS].includes(x.type)) return null;
12084
+ return DP_ARR(model.nodes).filter(n => DP_OBJ(n) && DP_TARGETS.includes(n.type)).map(n => ({ id: n.id, label: dpName(n) }));
12085
+ }
12086
+ // connect: a deployment drawn from a target to an artifact, or a manifestation from a component to an artifact, is added from
12087
+ // the artifact; a deployment or a communication path between ends it cannot join, or any line between a box and what it holds
12088
+ // (never drawn, dpLines), is refused, said on the hint line
12089
+ function dpConnect(model, view, from, to, at, line) {
12090
+ const I = dpIndex(model), ty = id => (I.at.get(id) || {}).type, t = DP_OBJ(line) ? line.type : null;
12091
+ if (I.holds(from, to) || I.holds(to, from)) { const [o, x] = I.holds(from, to) ? [from, to] : [to, from], nm = id => dpName(I.at.get(id));
12092
+ return { error: t === 'uml.Deployment' && DP_ARTIFACTS.includes(ty(x)) ? `${nm(x)} is already deployed on ${nm(o)} by nesting` : `A ${(DP_LINE_WORD[t] || 'line').toLowerCase()} cannot join a node and what it holds` }; }
12093
+ const flip = (t === 'uml.Deployment' && DP_TARGETS.includes(ty(from)) && DP_ARTIFACTS.includes(ty(to))) || (t === 'uml.Manifestation' && ty(from) === 'uml.Component' && DP_ARTIFACTS.includes(ty(to)));
12094
+ if (t === 'uml.Deployment' && !flip && !(DP_ARTIFACTS.includes(ty(from)) && DP_TARGETS.includes(ty(to)))) return { error: 'A deployment runs from an artifact to a node, a device or an execution environment' };
12095
+ if (t === 'uml.CommunicationPath' && (!DP_TARGETS.includes(ty(from)) || !DP_TARGETS.includes(ty(to)) || from === to)) return { error: 'A communication path joins two nodes, devices or execution environments' };
12096
+ if (!flip) return null;
12097
+ const used = new Set(); JSON.stringify(model, (k, v) => { if (k === 'id' && typeof v === 'string') used.add(v); return v; });
12098
+ const base = t === 'uml.Deployment' ? 'deployment' : 'manifestation'; let k = 1; while (used.has(base + k)) k++;
12099
+ return [{ op: 'add', element: { id: base + k, type: t, from: to, to: from }, view: view.id }];
12100
+ }
12101
+ // remove: a target deleted from the model takes what it owns with it (apply()); the hint line counts what goes (§10.4)
12102
+ function dpRemove(model, view, ids, fromModel) {
12103
+ const I = dpIndex(model), ts = fromModel ? ids.filter(id => DP_TARGETS.includes((I.at.get(id) || {}).type)) : [];
12104
+ const under = id => { for (let p = (I.at.get(id) || {}).parent, seen = new Set(); p != null && !seen.has(p); p = (I.at.get(p) || {}).parent) { if (ts.includes(p)) return true; seen.add(p); } return false; };
12105
+ const held = [...I.at.keys()].filter(id => !ids.includes(id) && under(id)).length;
12106
+ if (!held) return null;
12107
+ return { commands: ids.map(id => ({ op: 'delete', id })), say: `Deleted ${ts.length === 1 ? `${dpWord(I.at.get(ts[0])).toLowerCase()} ${dpName(I.at.get(ts[0]))}` : dpPlural(ts.length, 'target')} and the ${dpPlural(held, 'element')} ${ts.length === 1 ? 'it' : 'they'} held` };
12108
+ }
12109
+
12110
+ /* ---- the type ---- */
12111
+ const DP_STEREO = { key: 'stereotypes', label: 'Stereotypes', kind: 'list' };
12112
+ const DP_TYPE = registerDiagramType('uml.deployment', {
12113
+ title: 'deployment diagram',
12114
+ elements: [['Node', 'Node'], ['Device', 'Device'], ['ExecutionEnvironment', 'Execution environment'], ['Artifact', 'Artifact'], ['DeploymentSpecification', 'Deployment specification'], ['Component', 'Component'], ['Comment', 'Comment']]
12115
+ .map(([t, label]) => ({ type: 'uml.' + t, label })),
12116
+ lines: [['Deployment', 'Deployment'], ['Manifestation', 'Manifestation'], ['CommunicationPath', 'Communication path'], ['Dependency', 'Dependency'], ['CommentLink', 'Comment link']].map(([t, label]) => ({ type: 'uml.' + t, label })),
12117
+ props: Object.fromEntries([...DP_TARGETS, ...DP_ARTIFACTS, 'uml.Component', ...DP_LINES.filter(t => t !== 'uml.CommentLink')].map(t => [t, [DP_STEREO]]).concat([['uml.CommentLink', []]])),
12118
+ layout: dpGeo,
12119
+ boxes: (model, view) => dpPic(model, view).boxes,
12120
+ owners: dpOwners,
12121
+ validate: dpProblems,
12122
+ rules: DP_RULES,
12123
+ describe: dpDescribe,
12124
+ // the PDF's tables (§11): an element's kind; a target's rows, what is deployed on it, by nesting or by a line; an artifact's, what it manifests
12125
+ word: (model, id) => { const x = dpIndex(model).at.get(id); return x && Object.hasOwn(DP_WORD, x.type) ? DP_WORD[x.type] : null; },
12126
+ rows: (model, view, id) => { const I = dpIndex(model), x = I.at.get(id), nm = k => dpName(I.at.get(k));
12127
+ if (x && DP_TARGETS.includes(x.type)) return I.deployed(id).map(a => (I.at.get(a).parent === id ? nm(a) : `${nm(a)}, by «deploy»`));
12128
+ if (x && DP_ARTIFACTS.includes(x.type)) return I.outOf('uml.Manifestation', id).filter(e => I.at.has(e.to)).map(e => `manifests ${nm(e.to)}`);
12129
+ return null; },
12130
+ edits: { connect: dpConnect, remove: dpRemove },
12131
+ });
12132
+ /* ---------- the composite structure diagram, uml.composite-structure (work package 5.6, notes/stage-5.md §6, §10.2) ----------
12133
+ The view's subject (a class, a component or a collaboration, listed in nodeIds) is a box holding its parts (its uml.Property
12134
+ members, drawn as nodes under their own ids) and its collaboration uses (shown nodes whose parent it is); its ports sit on the
12135
+ box's border. A part shows the ports of the class or component its valueType names by label, and every port a connector
12136
+ reaches through it (ends.<end>.part), each under the geometry id `<part>-><port>` (§6), its side under
12137
+ present.<part>.ports.<port>.side. A connector from the subject's port to something inside is an inner line (§5). An open
12138
+ collaboration is a dashed ellipse (a round box, §9.3) round its roles; role bindings run from a part to a collaboration use,
12139
+ the role's name as the line's label (labelGaps, §10.2 as built). Ranked by the type itself (layout.rank false: the host passes the pins alone), then
12140
+ laid out by layoutTyped with repair and reroute off (§14); the ports with no line, the port nodes and their names are put
12141
+ on a copy (portsDrawn, src/uml/structure.js). Ships in lib/umlimator-all.js only. */
12142
+
12143
+ const CS_OBJ = x => x !== null && typeof x === 'object' && !Array.isArray(x), CS_ARR = x => (Array.isArray(x) ? x : []);
12144
+ const CS_HAS = x => x != null && x !== '';
12145
+ const CS_PROPS = x => (CS_OBJ(x && x.props) ? x.props : {});
12146
+ const csName = x => String(x.label ?? x.id);
12147
+ const csPlural = (k, w) => `${k} ${k === 1 ? w : w + 's'}`;
12148
+ const csStereo = x => CS_ARR(CS_PROPS(x).stereotypes).filter(CS_HAS).map(String);
12149
+ const CS_SUBJECTS = ['uml.Class', 'uml.Component', 'uml.Collaboration'], CS_TYPED = ['uml.Class', 'uml.Component']; // a subject; what owns ports and types parts
12150
+ const CS_LINES = ['uml.Connector', 'uml.RoleBinding', 'uml.CommentLink'];
12151
+ const CS_WORD = { L: 'left', R: 'right', T: 'top', B: 'bottom' }, CS_SIDE = { left: 'L', right: 'R', top: 'T', bottom: 'B' }, CS_OPP = { L: 'R', R: 'L', T: 'B', B: 'T' }, CS_TURN = { L: 'T', T: 'L', R: 'B', B: 'R' };
12152
+ // a port on a part, by its geometry id (§6): no element id holds '->' (model.md rule 5)
12153
+ const csPortId = (part, port) => `${part}->${port}`;
12154
+ const csSplit = id => { const s = String(id), k = s.indexOf('->'); return k < 0 ? null : [s.slice(0, k), s.slice(k + 2)]; };
12155
+
12156
+ /* ---- a part's text, `name : Type [multiplicity]`, both ways; a member row ---- */
12157
+ const csPartText = m => { const p = CS_PROPS(m); return `${m.label ?? ''}${CS_HAS(p.valueType) ? ' : ' + p.valueType : ''}${CS_HAS(p.multiplicity) ? ` [${p.multiplicity}]` : ''}`.trim(); };
12158
+ const csPortText = m => `${m.label ?? ''}${CS_HAS(CS_PROPS(m).valueType) ? ' : ' + CS_PROPS(m).valueType : ''}`;
12159
+ function csParse(t) {
12160
+ const m = /^\s*([^:[\]]*?)\s*(?::\s*([^:[\]]*?)\s*)?(?:\[\s*([^\]]*?)\s*\])?\s*$/.exec(String(t ?? ''));
12161
+ return m && m[1] ? { label: m[1], valueType: m[2] || undefined, multiplicity: m[3] || undefined } : { error: 'A part is name : Type [multiplicity]' };
12162
+ }
12163
+ // a member row on owner (§12.1): `port name : Type` on a class or component is a port, any other row a part (on a collaboration,
12164
+ // a role); was: the member the row edits, whose kind it keeps. A member as the model writes it, or { error }
12165
+ function csRow(owner, text, was) {
12166
+ const t = String(text ?? '').trim(), port = was ? was.type === 'uml.Port' : /^port\s/i.test(t) && CS_TYPED.includes(owner && owner.type);
12167
+ const r = csParse(port ? t.replace(/^port\s+/i, '') : t);
12168
+ if (r.error || (port && r.multiplicity)) return { error: port ? 'A port is port name : Type' : r.error };
12169
+ return { type: port ? 'uml.Port' : 'uml.Property', label: r.label, props: { ...(r.valueType ? { valueType: r.valueType } : {}), ...(r.multiplicity ? { multiplicity: r.multiplicity } : {}) } };
12170
+ }
12171
+ // the sets that give x what r reads as: its name, its type and its multiplicity, one set each that changed (undefined clears one)
12172
+ const csSets = (x, r, keepType) => [['label', x.label, r.label], ['props.valueType', CS_PROPS(x).valueType, keepType ? CS_PROPS(x).valueType : r.props.valueType],
12173
+ ['props.multiplicity', CS_PROPS(x).multiplicity, x.type === 'uml.Port' ? CS_PROPS(x).multiplicity : r.props.multiplicity]]
12174
+ .filter(([, a, b]) => (a ?? undefined) !== b).map(([path, , value]) => ({ op: 'set', id: x.id, path, value }));
12175
+ // an id not yet in the model: base, else base-2, base-3, …
12176
+ function csFresh(model, base) {
12177
+ const used = new Set(); JSON.stringify(model, (k, v) => { if (k === 'id' && typeof v === 'string') used.add(v); return v; });
12178
+ const b = String(base).replace(/[^\w.-]+/g, '-').replace(/^-+|-+$/g, '') || 'element'; let id = b, k = 1; while (used.has(id)) id = `${b}-${++k}`;
12179
+ return id;
12180
+ }
12181
+
12182
+ /* ---- the shapes (§9.1): a part (and a role), a collaboration use, an open collaboration's frame ---- */
12183
+ const CS = { pad: 10, px: 12, minW: 96, maxW: 320, h: 48 };
12184
+ SHAPE.part = {
12185
+ size: n => ({ w: Math.ceil(Math.min(CS.maxW, Math.max(CS.minW, textW(n.label ?? '', CS.px) + 2 * CS.pad))), h: CS.h }),
12186
+ out: boxOut(0), face: n => [n.y, n.y + n.h], inset: () => 0,
12187
+ draw(n, c, f) { const { x, y, w, h } = n;
12188
+ return `<g class="node part" data-id="${esc(n.id)}" data-shape="part" tabindex="-1" role="img" aria-label="${esc(n.aria || 'Part ' + (n.label ?? ''))}">`
12189
+ + `<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}"${n.shared ? ' stroke-dasharray="6 4"' : ''}/>`
12190
+ + `<text x="${r2(x + w / 2)}" y="${r2(y + h / 2 + 4)}" text-anchor="middle" class="c-name" fill="var(--um-text)" style="font:500 ${CS.px}px var(--um-sans)" data-o="${esc(n.id)}">${esc(fit(n.label ?? '', CS.px, w - 2 * CS.pad))}</text></g>`; },
12191
+ };
12192
+ SHAPE.part.ports = (n, side, k = 1) => portsOn(SHAPE.part, n, side, k);
12193
+ // a collaboration use: the use case's ellipse (src/uml/usecase.js), dashed
12194
+ SHAPE['collab-use'] = { ...SHAPE.ellipse, draw: (n, c, f) => SHAPE.ellipse.draw(n, c, f).replace('class="node usecase"', 'class="node collab-use"').replace('class="n-body"', 'class="n-body" stroke-dasharray="6 4"') };
12195
+ // an open collaboration: a dashed ellipse in its frame, which TYPED.frame grew by √2 round the rectangle of its contents (round,
12196
+ // §9.3); its name in that rectangle's header room, over a dashed line across the ellipse
12197
+ SHAPE.collaboration = {
12198
+ frame(f) { const cx = f.x + f.w / 2, cy = f.y + f.h / 2, w0 = f.w / Math.SQRT2, top = cy - f.h / Math.SQRT2 / 2, y = top + 24, hw = f.w / 2 * Math.sqrt(Math.max(0, 1 - ((y - cy) / (f.h / 2)) ** 2));
12199
+ return `<ellipse class="family-box" cx="${r2(cx)}" cy="${r2(cy)}" rx="${r2(f.w / 2)}" ry="${r2(f.h / 2)}" stroke="var(--um-muted)" stroke-dasharray="6 4"/>`
12200
+ + `<text class="family-title" x="${r2(cx)}" y="${r2(top + 17)}" text-anchor="middle" fill="var(--um-text)" style="font:600 12px var(--um-sans)" data-t="ft" data-o="${esc(f.id)}">${esc(fit(f.title ?? f.label ?? f.id, 12, w0 - 24))}</text>`
12201
+ + `<path d="M${r2(cx - hw)} ${r2(y)}H${r2(cx + hw)}" class="cs-rule" stroke="var(--um-muted)" stroke-dasharray="4 3"/>`; },
12202
+ };
12203
+
12204
+ /* ---- the view read: its subject, parts, ports, collaboration uses, open collaborations, the rest, and its lines ---- */
12205
+ function csPic(model, view) {
12206
+ const at = new Map(), mem = new Map(); // nodes by id; members by id, with their owner (the first of an id)
12207
+ CS_ARR(model && model.nodes).forEach(n => { if (CS_OBJ(n) && typeof n.id === 'string' && !at.has(n.id)) at.set(n.id, n); });
12208
+ at.forEach(n => CS_ARR(n.members).forEach(m => { if (CS_OBJ(m) && typeof m.id === 'string' && !at.has(m.id) && !mem.has(m.id)) mem.set(m.id, { m, owner: n }); }));
12209
+ const kids = (x, t) => CS_ARR(x.members).filter(m => CS_OBJ(m) && m.type === t && mem.has(m.id) && mem.get(m.id).m === m);
12210
+ const ids = [...new Set(CS_ARR(view && view.nodeIds))].filter(id => at.has(id)), sid = view && view.subject;
12211
+ const S = ids.includes(sid) && CS_SUBJECTS.includes(at.get(sid).type) ? at.get(sid) : null;
12212
+ const parts = S ? kids(S, 'uml.Property') : [], sPorts = S && CS_TYPED.includes(S.type) ? kids(S, 'uml.Port') : [];
12213
+ const uses = S ? ids.filter(id => id !== sid && at.get(id).type === 'uml.CollaborationUse' && at.get(id).parent === sid) : [];
12214
+ const collabs = ids.filter(id => id !== sid && at.get(id).type === 'uml.Collaboration').map(id => ({ x: at.get(id), roles: kids(at.get(id), 'uml.Property') })).filter(c => c.roles.length);
12215
+ const boxed = new Set([...(S && (parts.length || uses.length) ? [sid] : []), ...collabs.map(c => c.x.id)]);
12216
+ const rest = ids.filter(id => !boxed.has(id) && !uses.includes(id));
12217
+ const partOf = new Map([...parts.map(m => [m.id, S]), ...collabs.flatMap(c => c.roles.map(m => [m.id, c.x]))]); // a part or role: its structure
12218
+ const classes = [...at.values()].filter(n => CS_TYPED.includes(n.type));
12219
+ const typeOf = p => { const vt = CS_PROPS(p).valueType, c = CS_HAS(vt) ? classes.filter(n => n.label === vt) : []; return c.length === 1 ? c[0] : null; };
12220
+ const el = id => at.get(id) || (mem.get(id) || {}).m || null;
12221
+ // a line's end as drawn: { node, port?, id (the model's) }; null where nothing drawn is it
12222
+ const end = (e, k) => { const x = e[k], p = (CS_PROPS(e).ends || {})[k], part = CS_OBJ(p) ? p.part : null;
12223
+ if (part != null) return partOf.has(part) && (mem.get(x) || {}).m && mem.get(x).m.type === 'uml.Port' ? { node: part, port: csPortId(part, x), id: x, part } : null;
12224
+ if (S && sPorts.some(q => q.id === x)) return { node: sid, port: x, id: x };
12225
+ return ids.includes(x) || partOf.has(x) ? { node: x, id: x } : null; };
12226
+ const within = (b, n) => b === sid ? n !== sid && (partOf.get(n) === S || uses.includes(n)) : collabs.some(c => c.x.id === b && partOf.get(n) === c.x);
12227
+ const E = new Map(); CS_ARR(model && model.edges).forEach(e => { if (CS_OBJ(e) && typeof e.id === 'string' && !E.has(e.id)) E.set(e.id, e); });
12228
+ const list = Array.isArray(view && view.edges) ? view.edges.map(x => (typeof x === 'string' ? E.get(x) : x)) : [...E.values()].filter(e => CS_LINES.includes(e.type));
12229
+ const loops = []; // connectors between two ports of one box: not drawn (the router has no line from a box to itself), warned
12230
+ const lines = list.filter(CS_OBJ).map(e => { const f = end(e, 'from'), t = end(e, 'to'); if (!f || !t) return null;
12231
+ if (boxed.has(f.node) && f.node === t.node) { loops.push(e.id); return null; }
12232
+ // a line from a box to what it holds is drawn only from the box's port, inward (§5)
12233
+ const inner = boxed.has(f.node) && within(f.node, t.node) ? 'from' : boxed.has(t.node) && within(t.node, f.node) ? 'to' : null;
12234
+ if (inner && !(inner === 'from' ? f : t).port) return null;
12235
+ return { e, f, t, inner }; }).filter(Boolean);
12236
+ // the ports drawn on each part: its type's, then those its connectors reach through it (§6)
12237
+ const partPorts = new Map([...partOf.keys()].map(p => { const T = typeOf(el(p)), own = T ? kids(T, 'uml.Port') : [], seen = new Set(own.map(m => m.id));
12238
+ lines.forEach(({ f, t }) => [f, t].forEach(x => { if (x.part === p && !seen.has(x.id)) { seen.add(x.id); own.push(mem.get(x.id).m); } }));
12239
+ return [p, own]; }));
12240
+ return { at, mem, el, ids, S, sid, parts, sPorts, uses, collabs, boxed, rest, partOf, typeOf, lines, loops, partPorts, kids };
12241
+ }
12242
+
12243
+ /* ---- names (§10.2, A1), reading order and the legend ---- */
12244
+ const csWord = t => String(t ?? '').split('.').pop().replace(/([a-z])([A-Z])/g, '$1 $2').replace(/ ./g, s => s.toLowerCase()); // 'uml.CollaborationUse': "Collaboration use"
12245
+ const csUseText = u => `${u.label ?? ''}${CS_HAS(CS_PROPS(u).collaboration) ? ' : ' + CS_PROPS(u).collaboration : ''}`;
12246
+ // role bindings to a collaboration use: "publisher to checkout"
12247
+ const csBindings = (P, u) => P.lines.filter(l => l.e.type === 'uml.RoleBinding' && l.e.to === u).map(l => `${CS_HAS(l.e.label) ? l.e.label : 'a role'} to ${csName(P.el(l.e.from))}`);
12248
+ function csNodeName(P, id) {
12249
+ const x = P.el(id), st = csStereo(x), why = st.length ? ` (${st.join(', ')})` : '';
12250
+ if (x.type === 'uml.Comment') return 'Comment: ' + csName(x);
12251
+ if (P.boxed.has(id)) { const held = id === P.sid ? [...P.parts.map(() => x.type === 'uml.Collaboration' ? 'role' : 'part'), ...P.uses.map(() => 'collaboration use')] : P.collabs.find(c => c.x.id === id).roles.map(() => 'role');
12252
+ return SN_SAY.component(csName(x), { stereotypes: st, ports: id === P.sid ? P.sPorts.length : 0, holds: held }).replace(/^Component/, csWord(x.type)); }
12253
+ if (P.partOf.has(id)) { const o = P.partOf.get(id), role = o.type === 'uml.Collaboration' && o !== P.S || P.S && P.S.type === 'uml.Collaboration', k = P.partPorts.get(id).length;
12254
+ return `${role ? 'Role' : 'Part'} ${csPartText(x)}${why}${!role && CS_PROPS(x).aggregation === 'shared' ? ', shared' : ''}, in ${csName(o)}${k ? ', ' + csPlural(k, 'port') : ''}`; }
12255
+ if (x.type === 'uml.CollaborationUse') { const b = csBindings(P, id); return `Collaboration use ${csUseText(x)}${why}${b.length ? ', binding ' + b.join(', ') : ''}`; }
12256
+ return `${csWord(x.type)} ${csName(x)}${why}${id === P.sid && P.sPorts.length ? ', ' + csPlural(P.sPorts.length, 'port') : ''}`;
12257
+ }
12258
+ const csEndName = (P, x) => (x.part != null ? `${csName(P.mem.get(x.id).m)} of ${csName(P.el(x.part))}` : x.port != null ? `${csName(P.mem.get(x.id).m)} of ${csName(P.S)}` : csName(P.el(x.id)));
12259
+ const CS_LINE_WORD = { 'uml.Connector': 'Connector', 'uml.RoleBinding': 'Role binding', 'uml.CommentLink': 'Comment link' };
12260
+ const csLineName = (P, { e, f, t }) => e.type === 'uml.RoleBinding' ? `Role binding from ${csEndName(P, f)} to ${csEndName(P, t)}${CS_HAS(e.label) ? ', as ' + e.label : ''}`
12261
+ : `${CS_LINE_WORD[e.type] || 'Line'} from ${csEndName(P, f)} to ${csEndName(P, t)}${CS_HAS(e.label) ? ': ' + e.label : ''}`;
12262
+ // the subject and its ports, each part and its ports, the collaboration uses, each open collaboration and its roles, the rest; then the lines
12263
+ function csOrder(P) {
12264
+ const out = [], part = m => { out.push(m.id); P.partPorts.get(m.id).forEach(q => out.push(csPortId(m.id, q.id))); };
12265
+ if (P.S && !P.rest.includes(P.sid)) { out.push(P.sid, ...P.sPorts.map(q => q.id)); P.parts.forEach(part); out.push(...P.uses); }
12266
+ P.collabs.forEach(c => { out.push(c.x.id); c.roles.forEach(part); });
12267
+ P.rest.forEach(id => { out.push(id); if (id === P.sid) out.push(...P.sPorts.map(q => q.id)); });
12268
+ return [...out, ...P.lines.map(l => l.e.id)];
12269
+ }
12270
+ const CS_LOOK = { 'uml.Connector': { from: 'none', to: 'none' }, 'uml.RoleBinding': { from: 'none', to: 'none', dashed: true }, 'uml.CommentLink': { from: 'none', to: 'none', dashed: true } };
12271
+ const CS_SAYS = { 'uml.Connector': 'a link between parts and ports at run time', 'uml.RoleBinding': 'the part plays the named role of the collaboration', 'uml.CommentLink': 'what a comment is about' };
12272
+ const csLegend = lines => Object.keys(CS_SAYS).filter(t => lines.some(l => l.e.type === t)).map(t => `<span class="lgi" title="${esc(CS_LINE_WORD[t] + ' — ' + CS_SAYS[t])}"><svg class="lgs" viewBox="0 0 28 16" aria-hidden="true">`
12273
+ + `<path d="M2 8H26" stroke="var(--um-muted)" stroke-width="1.4"${CS_LOOK[t].dashed ? ' stroke-dasharray="3 2"' : ''}/></svg><b>${esc(CS_LINE_WORD[t])}</b><span>${esc(CS_SAYS[t])}</span></span>`);
12274
+
12275
+ /* ---- the view laid out (§10.2) ---- */
12276
+ /* each id's [col, row]: the longest path over the links, cycles broken depth first, columns ordered by untangle and rows by
12277
+ rowsFor (src/layout/layered.js); an id no link reaches goes in rows under the rest. ponytail: the communication type's
12278
+ cmRank and cmPlace in short, which it does not export */
12279
+ function csRank(ids, links) {
12280
+ const outs = new Map(ids.map(id => [id, []])), st = new Map(), back = new Set(), at = Object.create(null);
12281
+ links.forEach((l, k) => outs.get(l[0]).push(k));
12282
+ 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); };
12283
+ ids.forEach(id => { if (!st.has(id)) dfs(id); });
12284
+ const fwd = links.filter((l, k) => !back.has(k)), rank = Object.create(null); ids.forEach(id => { rank[id] = 0; });
12285
+ for (let i = 0, more = true; more && i <= ids.length; i++) { more = false; fwd.forEach(([a, b]) => { if (rank[b] < rank[a] + 1) { rank[b] = rank[a] + 1; more = true; } }); }
12286
+ const lined = new Set(links.flatMap(l => l)), cols = []; ids.filter(id => lined.has(id)).forEach(id => (cols[rank[id]] ||= []).push(id));
12287
+ const mains = cols.filter(Boolean); untangle(mains, fwd); const row = rowsFor(mains, fwd);
12288
+ mains.forEach((c, k) => c.forEach(id => { at[id] = [k, row[id]]; }));
12289
+ 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;
12290
+ loose.forEach((id, k) => { at[id] = [k % per, under + Math.floor(k / per)]; });
12291
+ return at;
12292
+ }
12293
+ function csGeo({ model, view, pos: pins, memo }) {
12294
+ const P = csPic(model, view), down = view.direction === 'down', present = CS_OBJ(view.present) ? view.present : {};
12295
+ // the ranking: the subject's parts and uses, then each open collaboration's roles, then the rest, block after block to the right
12296
+ const pos = Object.create(null), sBox = P.boxed.has(P.sid), blocks = [], lineNode = l => [l.f.node, l.t.node];
12297
+ const links = ids => P.lines.filter(l => l.e.type !== 'uml.CommentLink').map(lineNode).filter(([a, b]) => a !== b && ids.includes(a) && ids.includes(b));
12298
+ if (sBox) { const ids = [...P.parts.map(m => m.id), ...P.uses]; blocks.push([ids, links(ids)]); }
12299
+ P.collabs.forEach(c => { const ids = c.roles.map(m => m.id); blocks.push([ids, links(ids)]); });
12300
+ if (P.rest.length) blocks.push([P.rest, []]);
12301
+ // a pin (a column and row of the whole picture) wins, kept to its block's first column at least, and the next block starts past
12302
+ // the furthest column this one holds, pins counted: no box reaches over another's. Each id on a cell taken goes down a row, pins first
12303
+ const pinOf = id => (CS_OBJ(pins) && Object.hasOwn(pins, id) && Array.isArray(pins[id]) && pins[id].length === 2 && pins[id].every(Number.isFinite) ? pins[id] : null);
12304
+ blocks.reduce((off, [ids, ls]) => { const at = csRank(ids, ls), taken = [], free = (c, r) => !taken.some(([c2, r2]) => c2 === c && Math.abs(r2 - r) < 1);
12305
+ [...ids.filter(pinOf), ...ids.filter(id => !pinOf(id))].forEach(id => { const p = pinOf(id); let [c, r] = p ? [Math.max(off, p[0]), p[1]] : [at[id][0] + off, at[id][1]];
12306
+ while (!free(c, r)) r++; pos[id] = [c, r]; taken.push([c, r]); });
12307
+ return Math.max(...ids.map(id => pos[id][0])) + 1; }, 0);
12308
+ // the ports' sides (§4.2): the subject's first, each box at the mean of what it holds; then the parts', a line to the subject's
12309
+ // port counting toward a place past the subject's border on that port's side
12310
+ const mean = ids => (ids.length ? [0, 1].map(k => ids.reduce((a, id) => a + pos[id][k], 0) / ids.length) : [0, 0]);
12311
+ if (sBox) pos[P.sid] = mean(blocks[0][0]);
12312
+ const farOf = gid => P.lines.flatMap(l => [[l.f, l.t], [l.t, l.f]].filter(([a]) => a.port === gid).map(([, b]) => b));
12313
+ const sides = portSides(P.sPorts.map(q => ({ id: q.id, owner: P.sid, pin: (present[q.id] || {}).side, far: farOf(q.id).map(b => b.node) })), pos, down);
12314
+ const inS = sBox ? blocks[0][0] : [], cs = inS.map(id => pos[id][0]), rs = inS.map(id => pos[id][1]), [mc, mr] = pos[P.sid] || [0, 0];
12315
+ const past = s => ({ L: [Math.min(...cs) - 1, mr], R: [Math.max(...cs) + 1, mr], T: [mc, Math.min(...rs) - 1], B: [mc, Math.max(...rs) + 1] })[down ? CS_TURN[s] : s];
12316
+ const pos2 = Object.assign(Object.create(null), pos), partPorts = [...P.partPorts].flatMap(([p, qs]) => qs.map(q => ({ id: csPortId(p, q.id), owner: p, q,
12317
+ pin: ((CS_OBJ(present[p]) && CS_OBJ(present[p].ports) ? present[p].ports : {})[q.id] || {}).side,
12318
+ far: farOf(csPortId(p, q.id)).map(b => { if (b.port == null || b.part != null || !sides[b.port]) return b.node; pos2['#' + b.port] = past(sides[b.port]); return '#' + b.port; }) })));
12319
+ Object.assign(sides, portSides(partPorts, pos2, down));
12320
+ // the nodes, each as big as its ports need (portFit)
12321
+ const count = {}; P.lines.forEach(({ f, t }) => [f, t].forEach(x => { if (x.port) count[x.port] = (count[x.port] || 0) + 1; }));
12322
+ const fit = portFit([...partPorts, ...(sBox ? [] : P.sPorts.map(q => ({ id: q.id, owner: P.sid })))].map(p => ({ ...p, lines: count[p.id] })), sides);
12323
+ const nodes = Object.keys(pos).filter(id => !P.boxed.has(id)).map(id => { const x = P.el(id), st = csStereo(x), part = P.partOf.has(id);
12324
+ const shape = part ? { shape: 'part', label: csPartText(x), shared: CS_PROPS(x).aggregation === 'shared' } : x.type === 'uml.Comment' ? { shape: 'note' }
12325
+ : x.type === 'uml.CollaborationUse' ? { shape: 'collab-use', label: csUseText(x), stereotypes: st } : x.type === 'uml.Collaboration' ? { shape: 'collab-use', stereotypes: st }
12326
+ : x.type === 'uml.Component' ? { shape: 'component', stereotypes: st } : { shape: 'classifier', stereotypes: st, compartments: [] };
12327
+ const d = { id, label: csName(x), type: x.type, kind: 'file', ...shape, aria: csNodeName(P, id), col: pos[id][0], row: pos[id][1] }, s = SHAPE[d.shape].size(d), m = fit[id] || { w: 0, h: 0 };
12328
+ return { ...d, w: Math.max(s.w, m.w), h: Math.max(s.h, m.h) }; });
12329
+ // each line: its ports, the side each leaves by (in the layout's own axes: a view laid out down is laid out turned), inner
12330
+ // ends leaving the other way (§5), and the port's place among its owner's ports (portRank)
12331
+ const rankOf = x => (x.part != null ? P.partPorts.get(x.part) : P.sPorts).findIndex(q => q.id === x.id);
12332
+ const sideOf = (x, inner) => { const s = sides[x.port], r = down ? CS_TURN[s] : s; return CS_WORD[inner ? CS_OPP[r] : r]; };
12333
+ const edges = P.lines.map(l => { const { e, f, t, inner } = l, rk = [f, t].map(x => (x.port ? rankOf(x) : null));
12334
+ return { id: e.id, _id: e.id, type: e.type, from: f.node, to: t.node, aria: csLineName(P, l),
12335
+ ...(f.port ? { fromPort: f.port, fromSide: sideOf(f, inner === 'from') } : {}), ...(t.port ? { toPort: t.port, toSide: sideOf(t, inner === 'to') } : {}), ...(inner ? { inner } : {}),
12336
+ ...(f.port || t.port ? { portRank: f.port && t.port ? rk : rk[f.port ? 0 : 1] } : {}),
12337
+ // a role binding's role is its line's label, which the router gives room and the port names keep clear of (portsDrawn); an
12338
+ // end label, placed when drawn (src/typed/draw.js), would not see them
12339
+ end: CS_LOOK[e.type] || { from: 'none', to: 'none' }, ...(e.type !== 'uml.CommentLink' && CS_HAS(e.label) ? { label: String(e.label), cut: 280 } : {}), ...(CS_OBJ(e.style) || typeof e.style === 'string' ? { style: e.style } : {}) }; });
12340
+ const portsWithLines = P.lines.some(l => l.f.node === P.sid && l.f.port || l.t.node === P.sid && l.t.port);
12341
+ const shape = { 'uml.Component': 'component', 'uml.Collaboration': 'collaboration' };
12342
+ const containers = [...(sBox ? [P.S] : []), ...P.collabs.map(c => c.x)].map(x => ({ id: x.id, label: csName(x), title: [...csStereo(x).map(t => `«${t}»`), csName(x)].join(' '),
12343
+ nodeIds: x === P.S ? blocks[0][0] : P.collabs.find(c => c.x === x).roles.map(m => m.id), shape: shape[x.type] || 'subject', aria: csNodeName(P, x.id),
12344
+ ...(x === P.S && portsWithLines ? { pad: 48 } : {}), ...(x.type === 'uml.Collaboration' ? { round: true } : {}) }));
12345
+ // labelGaps (§8, decision 9): the role names on role bindings, which span the subject's columns, else find no spot
12346
+ const g = layoutTyped({ nodes, edges, ...(containers.length ? { containers } : {}), direction: down ? 'down' : 'right', gap: 64, rgap: 56, labelGaps: true, repair: false, reroute: false }, memo);
12347
+ const ports = [...P.sPorts.map(q => ({ q, id: q.id, owner: P.sid })), ...partPorts].map(p => ({ id: p.id, owner: p.owner, side: sides[p.id], label: csName(p.q), valueType: CS_PROPS(p.q).valueType,
12348
+ aria: SN_SAY.port(csName(p.q), csName(P.el(p.owner)), { valueType: CS_PROPS(p.q).valueType }) }));
12349
+ const out = portsDrawn(g, ports), byId = out.byId; // a copy: what layoutTyped returned is never edited (§2.4)
12350
+ // the slots (src/typed/edit.js): one at each node and box, so the palette's add hears what is selected (state.js's)
12351
+ const slots = [...out.nodes, ...CS_ARR(out.families)].map(n => ({ after: n.id, parent: null, x: n.x + n.w / 2, y: n.y + n.h / 2 }));
12352
+ return { ...out, slots, legend: csLegend(P.lines), order: csOrder(P).filter(id => Object.hasOwn(byId, id) || CS_ARR(out.families).some(f => f.id === id) || out.edges.some(e => e.id === id)) };
12353
+ }
12354
+
12355
+ /* ---- validators (§10.2). Each problem: { level, rule, path, message, ids }; the shared port-owner and manifest-ends too ---- */
12356
+ const CS_RULES = {
12357
+ 'subject-shown': 'the view\'s subject is a class, a component or a collaboration listed in nodeIds',
12358
+ 'part-type': 'a part\'s valueType names exactly one class or component of the model by label',
12359
+ 'connector-part': 'an end\'s part is a part of the subject, and the end is a port of that part\'s type',
12360
+ 'connector-scope': 'a connector\'s ends lie in one structure: the subject\'s ports and parts; or one collaboration\'s roles',
12361
+ 'connector-drawn': 'a connector between two ports of the subject is not drawn: the box has no line to itself',
12362
+ 'binding-ends': 'a role binding runs from a part of the subject to a collaboration use of it',
12363
+ 'binding-role': 'a role binding\'s label names a role of the collaboration its use names',
12364
+ 'collaboration-named': 'a collaboration use\'s collaboration names a collaboration of the model',
12365
+ ...STRUCT_RULES,
12366
+ };
12367
+ const csCollab = (P, u) => [...P.at.values()].find(n => n.type === 'uml.Collaboration' && CS_HAS(CS_PROPS(u).collaboration) && n.label === CS_PROPS(u).collaboration) || null;
12368
+ function csProblems(model, view) {
12369
+ const out = [], P = csPic(model, view || {}), vi = CS_ARR(model && model.views).indexOf(view), S = P.S, say = (level, rule, path, ids, msg) => out.push({ level, rule, path, ids: ids.filter(x => x != null), message: `${rule}: ${msg}` });
12370
+ const q = id => JSON.stringify(P.el(id) ? csName(P.el(id)) : String(id));
12371
+ if (!view) return structProblems(model);
12372
+ if (!S) say('error', 'subject-shown', `views[${vi}].subject`, [view.id, view.subject], `view ${JSON.stringify(view.id)}'s subject ${JSON.stringify(view.subject ?? null)} is not a class, a component or a collaboration it lists in nodeIds`);
12373
+ const nodeAt = id => CS_ARR(model.nodes).indexOf(P.at.get(id)), memPath = id => { const o = P.mem.get(id).owner; return `nodes[${nodeAt(o.id)}].members[${o.members.indexOf(P.mem.get(id).m)}]`; };
12374
+ if (S && S.type !== 'uml.Collaboration') P.parts.forEach(m => { if (!P.typeOf(m)) say('warning', 'part-type', memPath(m.id) + '.props.valueType', [m.id, S.id],
12375
+ `part ${q(m.id)} of ${q(S.id)} is typed ${JSON.stringify(CS_PROPS(m).valueType ?? null)}, which names ${[...P.at.values()].some(n => CS_TYPED.includes(n.type) && CS_HAS(n.label) && n.label === CS_PROPS(m).valueType) ? 'more than one' : 'no'} class or component, so it shows only the ports its connectors reach through it`); });
12376
+ // where an end lies: the subject's structure ('S'), a collaboration's (its id), or nowhere (null)
12377
+ const owner = id => (P.mem.get(id) || {}).owner, inS = id => S && (id === S.id || owner(id) === S);
12378
+ const structOf = (x, part) => { if (part != null) return owner(part) === S && S ? 'S' : null; if (inS(x)) return 'S'; if (S && P.uses.includes(x)) return 'S';
12379
+ const o = owner(x); return o && o.type === 'uml.Collaboration' && (P.mem.get(x).m.type === 'uml.Property') ? o.id : null; };
12380
+ CS_ARR(model.edges).forEach((e, i) => { if (!CS_OBJ(e)) return; const ends = CS_OBJ(CS_PROPS(e).ends) ? CS_PROPS(e).ends : {}, path = `edges[${i}]`;
12381
+ if (e.type === 'uml.Connector') {
12382
+ const parts = ['from', 'to'].map(k => (CS_OBJ(ends[k]) ? ends[k].part : null)), touches = ['from', 'to'].some((k, j) => structOf(e[k], parts[j]) || P.collabs.some(c => owner(e[k]) === c.x) || (parts[j] != null && owner(parts[j]) === S));
12383
+ if (!touches) return;
12384
+ ['from', 'to'].forEach((k, j) => { const p = parts[j]; if (p == null) return;
12385
+ if (!S || owner(p) !== S || P.mem.get(p).m.type !== 'uml.Property') return say('error', 'connector-part', `${path}.props.ends.${k}.part`, [e.id, p], `${e.id}'s ${k} end goes through ${q(p)}, not a part of the subject`);
12386
+ const T = P.typeOf(P.mem.get(p).m); if (T && !P.kids(T, 'uml.Port').some(m => m.id === e[k])) say('error', 'connector-part', `${path}.${k}`, [e.id, e[k], p], `${e.id}'s ${k} end ${q(e[k])} is not a port of ${q(T.id)}, the type of ${q(p)}`); });
12387
+ const a = structOf(e.from, parts[0]), b = structOf(e.to, parts[1]), bare = ['from', 'to'].find((k, j) => S && e[k] === S.id && parts[j] == null);
12388
+ // UML 2.5.1 §11.2: a connector's end is a part or a port, never the structured classifier itself
12389
+ if (bare) say('error', 'connector-scope', `${path}.${bare}`, [e.id, S.id], `${e.id}'s ${bare} end is ${q(S.id)} itself: a connector joins the subject's ports and parts`);
12390
+ else if (!a || a !== b) say('error', 'connector-scope', path, [e.id, e.from, e.to], `${e.id} joins ${q(e.from)} and ${q(e.to)}, which lie in ${a && b ? 'two structures' : 'no structure of this view'}: its ends are the subject's ports and parts, or one collaboration's roles`);
12391
+ else if (P.loops.includes(e.id)) say('warning', 'connector-drawn', path, [e.id, S.id], `${e.id} joins two ports of ${q(S.id)}, which this view does not draw: the box has no line to itself`);
12392
+ }
12393
+ if (e.type === 'uml.RoleBinding' && (P.at.get(e.to) || {}).type === 'uml.CollaborationUse' && P.ids.includes(e.to) || e.type === 'uml.RoleBinding' && S && owner(e.from) === S) {
12394
+ const u = P.at.get(e.to);
12395
+ if (!(S && owner(e.from) === S && P.mem.get(e.from).m.type === 'uml.Property' && u && u.type === 'uml.CollaborationUse' && u.parent === S.id))
12396
+ return say('error', 'binding-ends', path, [e.id, e.from, e.to], `${e.id} runs from ${q(e.from)} to ${q(e.to)}: a role binding runs from a part of the subject to a collaboration use of it`);
12397
+ const C = csCollab(P, u);
12398
+ if (C && !P.kids(C, 'uml.Property').some(m => m.label === e.label)) say('warning', 'binding-role', `${path}.label`, [e.id, C.id], `${e.id} binds ${JSON.stringify(e.label ?? null)}, not a role of ${q(C.id)}`);
12399
+ } });
12400
+ P.uses.forEach(id => { const u = P.at.get(id); if (!csCollab(P, u)) say('warning', 'collaboration-named', `nodes[${nodeAt(id)}].props.collaboration`, [id],
12401
+ `collaboration use ${q(id)} names ${JSON.stringify(CS_PROPS(u).collaboration ?? null)}, not a collaboration of the model`); });
12402
+ return [...out, ...structProblems(model, view)];
12403
+ }
12404
+
12405
+ /* ---- describe(): the view in sentences, each element by its accessible name in reading order, then each line ---- */
12406
+ function csDescribe(model, viewId) {
12407
+ const v = CS_ARR(model && model.views).find(x => CS_OBJ(x) && x.id === viewId); if (!v) throw new Error(`umlimator: no view ${JSON.stringify(viewId)}`);
12408
+ const P = csPic(model, v), by = new Map(P.lines.map(l => [l.e.id, l])), els = csOrder(P).filter(id => !by.has(id));
12409
+ const name = id => { const s = csSplit(id), q = P.mem.get(s ? s[1] : id); if (q && q.m.type === 'uml.Port') return SN_SAY.port(csName(q.m), csName(P.el(s ? s[0] : P.sid)), { valueType: CS_PROPS(q.m).valueType }); return csNodeName(P, id); };
12410
+ return [`${v.title || v.id}: a composite structure diagram of ${csPlural(els.length, 'element')} and ${csPlural(P.lines.length, 'line')}.`,
12411
+ ...csOrder(P).map(id => (by.has(id) ? csLineName(P, by.get(id)) : name(id)) + '.')].join('\n');
12412
+ }
12413
+
12414
+ /* ---- editing (§6, §12) ---- */
12415
+ const csOwnerOf = (P, id) => { const s = csSplit(id); return s ? s[0] : id; };
12416
+ // connect: a connector from a port on a part keeps the part in ends.<end>.part; a role binding from a part to a collaboration
12417
+ // use, its role picked from the collaboration the use names. Anything else: the default
12418
+ function csConnect(model, view, from, to, at, line) {
12419
+ if (!CS_OBJ(line)) return null;
12420
+ const P = csPic(model, view), end = id => { const s = csSplit(id); return s ? { id: s[1], part: s[0] } : { id }; }, a = end(from), b = end(to);
12421
+ if (line.type === 'uml.RoleBinding') {
12422
+ const isPart = id => P.parts.some(m => m.id === id), isUse = id => P.uses.includes(id);
12423
+ let [p, u] = [a.part ?? a.id, b.part ?? b.id]; if (isUse(p) && isPart(u)) [p, u] = [u, p];
12424
+ if (!isPart(p) || !isUse(u)) return { error: 'A role binding runs from a part of the subject to a collaboration use' };
12425
+ const C = csCollab(P, P.at.get(u)), roles = C ? P.kids(C, 'uml.Property').map(csName) : [];
12426
+ const add = role => [{ op: 'add', element: { id: csFresh(model, 'binding'), type: 'uml.RoleBinding', from: p, to: u, ...(role != null ? { label: role } : {}) }, view: view.id }];
12427
+ return roles.length ? { pick: roles, then: i => add(roles[i]) } : add(null);
12428
+ }
12429
+ if (!a.part && !b.part) return null;
12430
+ if (line.type !== 'uml.Connector') return { error: `A ${String(line.label || 'line').toLowerCase()} joins the part, not a port on it` };
12431
+ const ends = Object.fromEntries([['from', a], ['to', b]].filter(([, x]) => x.part).map(([k, x]) => [k, { part: x.part }]));
12432
+ return [{ op: 'add', element: { id: csFresh(model, 'connector'), type: 'uml.Connector', from: a.id, to: b.id, props: { ends } }, view: view.id }];
12433
+ }
12434
+ // moveTo: a port dropped, or Alt + an arrow on it, goes to the side nearest the drop, or in the arrow's direction (§4.5); its place
12435
+ // along its side is not stored (S3). Anything else, and a member's ↑ and ↓ in the panel (no geometry): the default
12436
+ function csMoveTo(model, view, id, at) {
12437
+ const P = csPic(model, view), s = csSplit(id), mine = s ? P.partOf.has(s[0]) && CS_ARR(P.partPorts.get(s[0])).some(q => q.id === s[1]) : P.sPorts.some(q => q.id === id);
12438
+ if (!mine || !CS_OBJ(at) || !CS_OBJ(at.geo)) return null;
12439
+ const g = at.geo, n = CS_OBJ(g.byId) && Object.hasOwn(g.byId, id) ? g.byId[id] : null, o = n && (Object.hasOwn(g.byId, n.owner) ? g.byId[n.owner] : CS_ARR(g.families).find(f => f.id === n.owner));
12440
+ let side = null;
12441
+ if (Array.isArray(at.by)) { const [dx, dy] = at.by; side = dx ? (dx < 0 ? 'L' : 'R') : dy < 0 ? 'T' : 'B';
12442
+ if (n && (side === n.side || (side === 'L' || side === 'R') !== (n.side === 'L' || n.side === 'R')))
12443
+ return { error: side === n.side ? `The port is on its owner's ${CS_WORD[side]} side already` : 'A port\'s place along its side follows its lines and is not stored: Alt + an arrow across the side moves it to another side' }; }
12444
+ else if (Array.isArray(at.pt) && o) { const [x, y] = at.pt, d = { L: Math.abs(x - o.x), R: Math.abs(x - o.x - o.w), T: Math.abs(y - o.y), B: Math.abs(y - o.y - o.h) };
12445
+ side = Object.keys(d).reduce((a, k) => (d[k] < d[a] ? k : a), 'L'); }
12446
+ if (!side) return null;
12447
+ return [{ op: 'set', view: view.id, path: s ? ['present', s[0], 'ports', s[1], 'side'] : ['present', id, 'side'], value: CS_WORD[side] }];
12448
+ }
12449
+ // add: a part into the subject; a port into the selected subject, class or component, or a selected part's type (said); a role
12450
+ // into the selected collaboration; a collaboration use into the subject. Anything else: the default
12451
+ function csAdd(model, view, entry, label, at) {
12452
+ if (!CS_OBJ(entry)) return null;
12453
+ const P = csPic(model, view), sel = CS_ARR(at && at.sel).map(id => csOwnerOf(P, id)).map(id => P.el(id)).filter(Boolean)[0] || null, t = String(label ?? '').trim();
12454
+ const member = (o, row, say) => { const r = csRow(o, row); if (r.error) return r; const c = [{ op: 'add', element: { id: csFresh(model, `${o.id}.${r.label.toLowerCase()}`), ...r }, owner: o.id }]; return say ? { commands: c, say } : c; };
12455
+ if (entry.type === 'uml.Port') {
12456
+ if (sel && P.partOf.has(sel.id)) { const T = P.typeOf(sel); if (!T) return { error: `${csName(sel)}'s type, ${JSON.stringify(CS_PROPS(sel).valueType ?? null)}, names no one class or component to add a port to` };
12457
+ return member(T, 'port ' + t, `Added the port to ${csName(T)}, the type of ${csName(sel)}: every part of that type shows it`); }
12458
+ return sel && CS_TYPED.includes(sel.type) ? member(sel, 'port ' + t) : { error: 'Select the subject, a part, a class or a component to add a port to' };
12459
+ }
12460
+ if (entry.type === 'uml.Property' && entry.label === 'Role') return sel && sel.type === 'uml.Collaboration' ? member(sel, t) : { error: 'Select a collaboration to add a role to' };
12461
+ if (entry.type === 'uml.Property') return P.S ? member(P.S, t) : { error: 'This view has no subject to add a part to' };
12462
+ if (entry.type === 'uml.CollaborationUse') { if (!P.S || !CS_TYPED.includes(P.S.type)) return { error: 'A collaboration use goes in a class or component subject' };
12463
+ const r = csParse(t); if (r.error || r.multiplicity) return { error: 'A collaboration use is name : Collaboration' };
12464
+ return [{ op: 'add', element: { id: csFresh(model, r.label.toLowerCase()), type: 'uml.CollaborationUse', label: r.label, parent: P.sid, props: r.valueType ? { collaboration: r.valueType } : {} }, view: view.id }]; }
12465
+ return null;
12466
+ }
12467
+ /* remove: a part, a port or a role is a member, which a view cannot hide (S20): Delete deletes it from the model, as Shift +
12468
+ Delete does, said. Delete on a port on a part deletes the connectors through that part to it; Shift + Delete, the port from
12469
+ its class (§6). Other ids as the default */
12470
+ function csRemove(model, view, ids, fromModel) {
12471
+ const P = csPic(model, view); if (!CS_ARR(ids).some(id => csSplit(id) || P.mem.has(id))) return null;
12472
+ const out = [], hide = [], said = [], del = id => { if (!out.some(c => c.op === 'delete' && c.id === id)) out.push({ op: 'delete', id }); };
12473
+ ids.forEach(id => { const s = csSplit(id), x = P.mem.get(s ? s[1] : id);
12474
+ if (s && x && !fromModel) { const through = CS_ARR(model.edges).filter(e => CS_OBJ(e) && ['from', 'to'].some(k => e[k] === s[1] && CS_OBJ((CS_PROPS(e).ends || {})[k]) && CS_PROPS(e).ends[k].part === s[0]));
12475
+ through.forEach(e => del(e.id)); said.push(`the ${csPlural(through.length, 'connector')} through ${csName(P.el(s[0]))} to its port ${csName(x.m)}; Shift + Delete deletes the port from ${csName(x.owner)}`); }
12476
+ else if (x) { const lines = CS_ARR(model.edges).filter(e => CS_OBJ(e) && (e.from === x.m.id || e.to === x.m.id || ['from', 'to'].some(k => CS_OBJ((CS_PROPS(e).ends || {})[k]) && CS_PROPS(e).ends[k].part === x.m.id))).length;
12477
+ del(x.m.id); said.push(`${x.m.type === 'uml.Port' ? 'port' : x.owner.type === 'uml.Collaboration' ? 'role' : 'part'} ${csName(x.m)} of ${csName(x.owner)}${lines ? ` and its ${csPlural(lines, 'line')}` : ''}${fromModel ? '' : ' (a member is not hidden: it goes from the model)'}`); }
12478
+ else if (s) return;
12479
+ else if (fromModel) del(id);
12480
+ else { const e = CS_ARR(model.edges).find(x => CS_OBJ(x) && x.id === id); // a node or a line selected with them: as the canvas does
12481
+ if (e && !Array.isArray(view.edges)) said.push(`not the line ${csName(e)}: this view shows every line between its cards, and Shift + Delete deletes it`);
12482
+ else if (e || P.at.has(id)) hide.push({ op: 'hide', id, view: view.id }); } });
12483
+ // hidden first: a line hidden after the part it ends at is deleted would be gone with it
12484
+ if (!out.length && !hide.length) return { error: `Nothing to delete: ${said[0] || 'no connector'}` };
12485
+ return { commands: [...hide, ...out], say: 'Deleted ' + said.join('; ') };
12486
+ }
12487
+ // rename: F2 on a port on a part renames its class's port, for every part of that type (said); on a part or a role, its row
12488
+ // `name : Type [multiplicity]` sets its name, type and multiplicity. Anything else: the default
12489
+ function csRename(model, view, id, text) {
12490
+ const P = csPic(model, view), s = csSplit(id), x = P.mem.get(s ? s[1] : id);
12491
+ if (!x || (!s && x.m.type !== 'uml.Property')) return null;
12492
+ const r = csRow(x.owner, text, x.m); if (r.error) return r;
12493
+ const c = csSets(x.m, r, x.m.type === 'uml.Port' && !String(text).includes(':')); // a name alone keeps a port's type: F2 opens on its name
12494
+ return s ? { commands: c, say: `Renamed ${csName(x.owner)}'s port: every part of that type shows it` } : c;
12495
+ }
12496
+ // the panel's member rows (edits.member, §12.1): on the subject (or any class or component) a row `port name : Type` is a port
12497
+ // and `name : Type [multiplicity]` a part; on a collaboration, a role; added, or the member edited. Other members: the panel's
12498
+ function csMember(model, view, owner, id, text) {
12499
+ const P = csPic(model, view), o = P.el(owner); if (!o || !CS_SUBJECTS.includes(o.type)) return null;
12500
+ const was = id != null ? CS_ARR(o.members).find(m => CS_OBJ(m) && m.id === id) : null;
12501
+ if (id != null && (!was || !['uml.Port', 'uml.Property'].includes(was.type))) return null;
12502
+ const r = csRow(o, text, was); if (r.error) return r;
12503
+ return was ? csSets(was, r) : [{ op: 'add', element: { id: csFresh(model, `${o.id}.${r.label.toLowerCase()}`), ...r }, owner: o.id }];
12504
+ }
12505
+ const CS_MEMBER = {
12506
+ print: m => (m.type === 'uml.Port' ? `port ${csPortText(m)}` : m.type === 'uml.Property' ? csPartText(m) : String(m.label ?? '')),
12507
+ add: (text, owner) => csRow(owner, text),
12508
+ edit: (m, text) => { const r = csRow(null, text, m); return r.error ? r : { commands: csSets(m, r) }; },
12509
+ holds: n => CS_SUBJECTS.includes(n.type),
12510
+ };
12511
+
12512
+ const CS_STEREO = { key: 'stereotypes', label: 'Stereotypes', kind: 'list' };
12513
+ const CS_TYPE = registerDiagramType('uml.composite-structure', {
12514
+ title: 'composite structure diagram',
12515
+ elements: [['Property', 'Part'], ['Port', 'Port'], ['Property', 'Role'], ['CollaborationUse', 'Collaboration use'], ['Collaboration', 'Collaboration'], ['Comment', 'Comment']].map(([t, label]) => ({ type: 'uml.' + t, label })),
12516
+ lines: [['Connector', 'Connector'], ['RoleBinding', 'Role binding'], ['CommentLink', 'Comment link']].map(([t, label]) => ({ type: 'uml.' + t, label })),
12517
+ props: {
12518
+ 'uml.Property': [{ key: 'valueType', label: 'Type', kind: 'text' }, { key: 'multiplicity', label: 'Multiplicity', kind: 'text' }, { key: 'aggregation', label: 'Aggregation', kind: 'enum', options: ['composite', 'shared'] }, CS_STEREO],
12519
+ 'uml.Port': [{ key: 'valueType', label: 'Type', kind: 'text' }, CS_STEREO],
12520
+ 'uml.CollaborationUse': [{ key: 'collaboration', label: 'Collaboration', kind: 'text' }, CS_STEREO],
12521
+ 'uml.Class': [CS_STEREO], 'uml.Component': [CS_STEREO], 'uml.Collaboration': [CS_STEREO], 'uml.Connector': [CS_STEREO], 'uml.RoleBinding': [], 'uml.CommentLink': [],
12522
+ },
12523
+ refs: { 'uml.Connector': { 'props.ends.from.part': 'cascade', 'props.ends.to.part': 'cascade' } }, // a deleted part takes its connectors (S14)
12524
+ layout: Object.assign(csGeo, { rank: false }), // csGeo ranks the parts itself: the host passes only the pins
12525
+ member: CS_MEMBER,
12526
+ validate: csProblems,
12527
+ rules: CS_RULES,
12528
+ describe: csDescribe,
12529
+ // the PDF's tables (§11): a part's and a collaboration use's kind and rows; null: as before
12530
+ word: (model, id) => { const P = csPic(model, {}), q = P.mem.get(id), x = q ? q.m : P.at.get(id); if (!x) return null;
12531
+ return x.type === 'uml.Property' ? (q.owner.type === 'uml.Collaboration' ? 'Role' : 'Part') : x.type === 'uml.Port' ? 'Port' : x.type === 'uml.CollaborationUse' ? 'Collaboration use' : null; },
12532
+ rows: (model, view, id) => { const P = csPic(model, view), q = P.mem.get(id);
12533
+ if (q && q.m.type === 'uml.Property') return [csPartText(q.m), CS_PROPS(q.m).aggregation === 'shared' ? 'shared' : 'composite'];
12534
+ const u = P.at.get(id); if (!u || u.type !== 'uml.CollaborationUse') return null;
12535
+ return [...(CS_HAS(CS_PROPS(u).collaboration) ? [String(CS_PROPS(u).collaboration)] : []),
12536
+ ...CS_ARR(model.edges).filter(e => CS_OBJ(e) && e.type === 'uml.RoleBinding' && e.to === id).map(e => `${e.label ?? '?'} ← ${csName(P.el(e.from) || { id: e.from })}`)]; },
12537
+ edits: { connect: csConnect, moveTo: csMoveTo, add: csAdd, remove: csRemove, rename: csRename, member: csMember },
12538
+ });