umlimator 0.11.0 → 0.12.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/umlimator.js CHANGED
@@ -102,9 +102,9 @@ const STYLES = `
102
102
  svg .node.priv:hover .n-label { fill: var(--um-text); }
103
103
  svg .node:focus-visible .n-mask { stroke: var(--um-cyan); stroke-width: 3; }
104
104
  svg .edge-g.hov .edge, svg .edge-g.hot .edge { stroke-width: calc(2.6px * var(--um-ew, 1)); }
105
- .focusing svg .node:not(.sel):not(.nb) { opacity: .28; }
106
- .focusing svg .edge-g:not(.hot) { opacity: .15; }
107
- .focusing svg .e-lab:not(.hot) { opacity: .15; }
105
+ .focusing:not(.um-lit) svg .node:not(.sel):not(.nb) { opacity: .28; }
106
+ .focusing:not(.um-lit) svg .edge-g:not(.hot) { opacity: .15; }
107
+ .focusing:not(.um-lit) svg .e-lab:not(.hot) { opacity: .15; }
108
108
  svg .node, svg .edge-g, svg .e-lab { transition: opacity .2s; }
109
109
  svg .node.sel .n-body { stroke-width: 3.2; }
110
110
  svg .node.sel .n-mask { stroke: var(--um-accent); stroke-width: 2; }
@@ -175,7 +175,10 @@ const STYLES = `
175
175
  svg .family-box { fill: var(--um-panel); fill-opacity: .45; stroke-width: 1.5; }
176
176
  svg .family-title { font: 650 11px var(--um-mono); }
177
177
  svg .family.sel .family-box { stroke: var(--um-accent); stroke-width: 2; }
178
+ svg .fold-hint { cursor: pointer; } svg .fold-hint:hover rect { stroke-width: 2; }
178
179
  svg .edited-dot { fill: var(--um-edited); stroke: var(--um-mask); stroke-width: 1.5; }
180
+ svg .rule-mark circle { fill: var(--um-rose); stroke: var(--um-mask); stroke-width: 1.5; } svg .rule-mark text { fill: var(--um-mask); font: 800 10px var(--um-sans); pointer-events: none; }
181
+ svg .edge-g.faded .flowline { display: none; }
179
182
  .um-rename { position: absolute; z-index: 5; box-sizing: border-box; font-family: var(--um-sans); font-weight: 600; color: var(--um-text); background: var(--um-panel); border: 1.5px solid var(--um-accent); border-radius: 6px; padding: 0 6px; outline: none; box-shadow: var(--um-shadow); }
180
183
  svg .um-ghost { opacity: .6; pointer-events: none; } svg .um-ghost.um-pending { opacity: .3; }
181
184
  svg .family.um-target .family-box { stroke: var(--um-accent); stroke-width: 2.5; stroke-dasharray: 6 4; }
@@ -184,7 +187,22 @@ const STYLES = `
184
187
  .um-still *, .um-still *::before, .um-still *::after { transition: none !important; animation: none !important; }
185
188
  svg .um-link { fill: none; stroke: var(--um-accent); stroke-width: 2; stroke-dasharray: 5 4; pointer-events: none; }
186
189
  @media (max-width: 820px) { .minimap { display: none; } .mctl { bottom: 14px; right: 12px; } }
187
- ` + /* each tone's tint (--um-<tone>-f), mixed at the map from the tone and panel in effect there: mixed on :root (as the theme
190
+ /* marks (#63): a ring around a card, a glow under a line, tabs and labels over the map; a layer that dims (um-lit)
191
+ sets how lit each thing is, in place of the selection's dimming */
192
+ svg .um-ring { fill: none; stroke-width: 2.5; opacity: .9; } svg .um-glow { fill: none; stroke-width: calc(8px + 1.8px * (var(--um-ew, 1) - 1)); stroke-linecap: round; stroke-linejoin: round; opacity: .4; }
193
+ svg .um-pulse { animation: um-pulse 1.1s ease-in-out infinite; } @keyframes um-pulse { 50% { opacity: .08; } }
194
+ svg .um-marks .tab-tx { font: 800 8px var(--um-mono); letter-spacing: .14em; fill: var(--um-mask); text-transform: uppercase; }
195
+ svg .um-lab rect { fill: var(--um-panel); stroke: var(--um-border-2); } svg .um-lab text { fill: var(--um-text); font: 600 10px var(--um-sans); }
196
+ svg .um-trip { pointer-events: none; }
197
+ @media (prefers-reduced-motion: reduce) { svg .um-pulse { animation: none; } }
198
+ /* a story's caption bar (#69), bottom left, clear of the controls */
199
+ .um-story { position: absolute; left: 16px; bottom: 40px; z-index: 5; box-sizing: border-box; max-width: min(560px, calc(100% - 290px)); display: flex; align-items: flex-end; gap: 10px; padding: 9px 8px 9px 14px; border-radius: 12px; background: var(--um-panel); border: 1px solid var(--um-border-2); box-shadow: var(--um-shadow); color: var(--um-text); font: 13px/1.45 var(--um-sans); }
200
+ .um-story .um-st { display: block; font: 600 11px var(--um-mono); color: var(--um-muted); margin-bottom: 2px; }
201
+ .um-story p { margin: 0; } .um-story .um-sb { display: flex; gap: 2px; flex: none; }
202
+ .um-story button { min-width: 30px; height: 28px; border: 0; border-radius: 8px; background: none; color: var(--um-text); font: 600 13px var(--um-mono); cursor: pointer; padding: 0 7px; }
203
+ .um-story button:hover:not(:disabled) { background: var(--um-panel-2); color: var(--um-accent); } .um-story button:disabled { opacity: .35; cursor: default; }
204
+ @media (max-width: 820px) { .um-story { left: 12px; right: 12px; bottom: 60px; max-width: none; } }
205
+ ` +/* each tone's tint (--um-<tone>-f), mixed at the map from the tone and panel in effect there: mixed on :root (as the theme
188
206
  file does, for page chrome) a tint keeps the tone it was mixed from, whatever an ancestor of the map sets later */
189
207
  `:host { ${Object.entries({ accent: 12, cyan: 9, green: 9, violet: 9, amber: 10, rose: 8, orange: 9, slate: 8, fuchsia: 8, 'k-public': 8, 'k-lane': 8,
190
208
  'k-ext': 7, 'k-verb': 7, 'k-script': 5, 'k-hook': 12, 'k-board': 9 }).map(([t, p]) => `--um-${t}-f: color-mix(in srgb, var(--um-${t}) ${p}%, var(--um-panel));`).join(' ')} }`;
@@ -246,6 +264,7 @@ const SIGIL = {
246
264
  const CHG = { added: ['green', 'NEW'], new: ['green', 'NEW'], modified: ['amber', 'CHANGED'], changed: ['amber', 'CHANGED'], removed: ['rose', 'REMOVED'], planned: ['cyan', 'PLANNED'] };
247
265
  const NW = 168, CG0 = 84, RG0 = 44;
248
266
  const pillW = (label, sub, max = 440) => Math.max(sansW(fit(label, 10.5, max), 10.5, .92), sub ? sansW(fit(sub, 9.5, max), 9.5, .92) : 0) + 18; // a line's label, and its sublabel under it (#44)
267
+ const stepW = s => String(s).length > 2 ? Math.max(16, sansW(String(s), 9.5, .9) + 8) : 16; // a line's step mark: a 16 px circle, or a pill for a number such as 4.1 (#82)
249
268
  // Text width. Without a measurer: a monospace estimate (font-independent, so layouts are the same on every machine;
250
269
  // the bench and layout() in Node use it). render() sets measureText to the host's own font while it lays out a view.
251
270
  let measureText = null;
@@ -279,7 +298,8 @@ function roundPath(pts, r = 9) {
279
298
  const last = pts[pts.length - 1]; return d + ` L ${last[0]} ${last[1]}`;
280
299
  }
281
300
  const hit = (a, b, pad = 0) => a.x < b.x + b.w + pad && b.x < a.x + a.w + pad && a.y < b.y + b.h + pad && b.y < a.y + a.h + pad;
282
- const warned = new Set(), warn = m => { if (!warned.has(m)) { warned.add(m); console.warn('umlimator: ' + m); } }; // each warning once
301
+ const warned = new Set(), warn = m => { if (hush) hush.push(m); else if (!warned.has(m)) { warned.add(m); console.warn('umlimator: ' + m); } }; // each warning once
302
+ let hush = null; // a layout tried and maybe dropped (unfolding, #71) keeps its warnings here: only the one kept says them
283
303
 
284
304
  /* Layered auto-layout for views that carry no col/row: longest-path layers (cycles broken by DFS),
285
305
  barycentre ordering sweeps, integer rows so row gaps stay free as routing channels. */
@@ -464,10 +484,10 @@ const mapTabs = (n, c) => { const tw = t => t.toUpperCase().length * 6.4 + 10, b
464
484
  const t = n.k === 'm-public' ? 'PUBLIC' : n.k === 'm-ext' ? 'EXTERNAL' : n.k === 'm-lane' ? 'LANE' : ''; return t ? [{ text: t, fill: c, w: tw(t) }] : []; };
465
485
  const mapNodeW = n => {
466
486
  const px = n.emphasis ? 15 : 13;
467
- const sub = n.sub || n.sublabel || '';
487
+ const sub = n.sub || n.sublabel || '', chips = (n.verbs || []).reduce((a, v) => a + textW(v, 9.5) + 14, -4); // its chips as svgOf draws them: each verb and 10 px, 4 px apart (#84)
468
488
  return Math.min(n.emphasis ? 205 : 386, Math.max(n.emphasis ? 146 : 138,
469
489
  Math.ceil(textW(n.label, px) + 70), badgesOf(n).length ? Math.ceil(badgesOf(n).reduce((a, b) => a + b.text.length * 6.4 + 10, 0) + (badgesOf(n).length - 1) * 4 + 120) : 0,
470
- Math.min(386, Math.ceil(textW(sub, 10) + 70))));
490
+ Math.min(386, Math.ceil(Math.max(textW(sub, 10), chips) + 70))));
471
491
  };
472
492
  /* Geometry: node boxes, ports, orthogonal routes, label slots. A view with lines the router found no route for is laid
473
493
  out once more, with room where those lines need it (#40); a view whose lines all route keeps its geometry. */
@@ -504,8 +524,11 @@ function layoutPass(dg, map, room, last) { // room: { x: { col: px }, y: { row:
504
524
  return c ? [...(c.nodeIds || []), ...(dg.containers || []).filter(k => k.parent === b).flatMap(k => inBox(k.id, seen))] : []; };
505
525
  const near = (b, other) => { const cs = inBox(b).map(i => colOf[i]).filter(c => c != null); if (other == null || !cs.length) return null;
506
526
  const lo = Math.min(...cs), hi = Math.max(...cs); return other < lo ? lo : other > hi ? hi : null; };
527
+ // an unfolded numbered view's lane is a box in its box (#82): a line to a card in one, or to that box, crosses two borders.
528
+ // ponytail: lanes only; an inner view's own nested boxes have the same 12 px less, but room for them moves views drawn today
529
+ const inLane = id => (dg.containers || []).some(f => f.lane && (f.parent === id || (f.nodeIds || []).includes(id)));
507
530
  (dg.edges || []).forEach(e => { const p = colOf[e.from] ?? near(e.from, colOf[e.to]), q = colOf[e.to] ?? near(e.to, colOf[e.from]); if (p == null || q == null || !e.label || Math.abs(p - q) !== 1) return;
508
- const c = Math.min(p, q), w = pillW(e.label, e.sublabel) + (e.step != null ? 20 : 0), box = boxOf(e.from) !== boxOf(e.to) || !(e.from in colOf) || !(e.to in colOf) ? 48 : 0;
531
+ const c = Math.min(p, q), w = pillW(e.label, e.sublabel) + (e.step != null ? stepW(e.step) + 4 : 0), box = boxOf(e.from) !== boxOf(e.to) || !(e.from in colOf) || !(e.to in colOf) ? 48 + ([e.from, e.to].some(inLane) ? 12 : 0) : 0;
509
532
  gapAfter[c] = Math.max(gapAfter[c] || 0, w + 54 + box);
510
533
  [e.from, e.to].forEach(id => { const k = c + '|' + id, t = sum[k] ||= { w: -6, box: 0 }; t.w += w + 6; t.box = Math.max(t.box, box); gapAfter[c] = Math.max(gapAfter[c], t.w + 54 + t.box); }); }); }
511
534
  const extraX = { ...(room || {}).x }, extraY = { ...(room || {}).y }; // more room after a column (row): for lines with no route, and where a box would touch another box (below)
@@ -537,7 +560,7 @@ function layoutPass(dg, map, room, last) { // room: { x: { col: px }, y: { row:
537
560
  };
538
561
  const out = familyDefs.map(f => frameOf(f)).filter(Boolean);
539
562
  if (map) out.forEach(f => { // the box fits its title and its badges, which are tabs on its top border, at the right
540
- const tabs = badgesOf(f).map((b, i) => ({ text: b.text, fill: toneOf(b), badge: i, w: b.text.length * 6.4 + 10 })), all = tabs.reduce((a, t) => a + t.w + 4, -4);
563
+ const tabs = [...badgesOf(f).map((b, i) => ({ text: b.text, fill: toneOf(b), badge: i, w: b.text.length * 6.4 + 10 })), ...(f.unfolds ? [{ text: '⤡', fold: true, w: 28 }] : [])], all = tabs.reduce((a, t) => a + t.w + 4, -4); // an unfolded card's box: its ⤡ folds it (#71)
541
564
  f.w = Math.max(f.w, String(f.label || f.id).length * 6.7 + 32 + (tabs.length ? all + 8 : 0));
542
565
  let tx = f.x + f.w - 12 - all; f.tabs = tabs.map(t => { const o = { ...t, x: tx }; tx += t.w + 4; return o; }); });
543
566
  return out;
@@ -801,7 +824,9 @@ function layoutPass(dg, map, room, last) { // room: { x: { col: px }, y: { row:
801
824
  hpop(); const k = pk, cell = k >> 2, dir = k & 3, i = (cell / ny) | 0, j = cell - i * ny;
802
825
  if (pd > dist[k] + (hx[i] + hy[j])) continue;
803
826
  const d = dist[k];
804
- if (cell === t0) { best = k; if (dir === dEnd) break; continue; }
827
+ // in head-on if it can; else the cheapest way in, the first found (it kept the last, the dearest: a top-to-top line on
828
+ // the top row ran past its card and back, #80)
829
+ if (cell === t0) { if (dir === dEnd) { best = k; break; } if (best < 0) best = k; continue; }
805
830
  const xi = X[i], yj = Y[j];
806
831
  for (let nd = 0; nd < 4; nd++) {
807
832
  if ((nd ^ 1) === dir && ((nd >> 1) === (dir >> 1))) continue; // no reversal
@@ -844,7 +869,7 @@ function layoutPass(dg, map, room, last) { // room: { x: { col: px }, y: { row:
844
869
  const segs = segsOf(e.pts).map((s, i) => ({ a: s[0], b: s[1], i, l: Math.hypot(s[1][0] - s[0][0], s[1][1] - s[0][1]), h: s[0][1] === s[1][1] }));
845
870
  const order = [...segs].sort((p, q) => (q.h - p.h) * 40 + (q.l - p.l));
846
871
  for (const pass of (map ? [0, 1, 2] : [0])) for (const text of tries) { const relax = pass === 2; // 0 strict, 1 strict but may touch a box border, 2 relaxed
847
- const two = !!(text && sub), pill = map && e.step != null, w = map ? (text ? pillW(text, two && sub) : 0) + (pill ? (text ? 20 : 22) : 0) : textW(text, 10) + 12, hgt = map ? (two ? 32 : 20) : 18;
872
+ const two = !!(text && sub), pill = map && e.step != null, w = map ? (text ? pillW(text, two && sub) : 0) + (pill ? stepW(e.step) + (text ? 4 : 6) : 0) : textW(text, 10) + 12, hgt = map ? (two ? 32 : 20) : 18;
848
873
  for (const sg of order) {
849
874
  if (!relax && sg.h && sg.l < w + 10) continue;
850
875
  if (!relax && !sg.h && sg.l < hgt + 14) continue;
@@ -857,7 +882,7 @@ function layoutPass(dg, map, room, last) { // room: { x: { col: px }, y: { row:
857
882
  if (placed.some(r => (pass === 0 || !r.band) && hit(r, grow))) continue;
858
883
  if (!relax && routed.some(r => r !== e && segsOf(r.pts).some(([a, b]) => segHitsRect(a, b, grow)))) continue;
859
884
  if (!relax && segs.some(s2 => s2.i !== sg.i && segHitsRect(s2.a, s2.b, box))) continue;
860
- placed.push(box); reserved.push(grow); e.lbl = { ...box, text, ...(two ? { sub } : {}), step: map ? e.step : null, short: text !== full }; return;
885
+ placed.push(box); reserved.push(grow); e.lbl = { ...box, text, ...(two ? { sub } : {}), step: map ? e.step : null, ...(pill ? { sw: stepW(e.step) } : {}), short: text !== full }; return;
861
886
  }
862
887
  }
863
888
  }
@@ -988,6 +1013,7 @@ function svgOf(geo, opts = {}) {
988
1013
  const id = 'g' + (svgSeq++), { nodes, edges, groups, bounds: B, map } = geo;
989
1014
  const col = k => `var(--um-${KIND[k][0]})`, fill = k => `var(--um-${KIND[k][0]}-f)`;
990
1015
  const estyle = e => {
1016
+ if (e.style === 'faded') return { ...(map && e.wk ? { c: `var(--um-w-${e.wk})`, m: e.wk, cls: ' w-' + e.wk + ' faded' } : { c: 'var(--um-arrow)', m: 'd', cls: ' faded' }), dash: '', op: .35 }; // its own colour, faint, no flow (drift's idle lines)
991
1017
  if (map && e.wk && !e.style) return { c: `var(--um-w-${e.wk})`, m: e.wk, dash: e.back ? '6 5' : '', cls: ' w-' + e.wk + (e.back ? ' back' : '') };
992
1018
  const st = e.style || (e.change === 'added' ? 'added' : e.change === 'removed' ? 'removed' : '');
993
1019
  if (st === 'added' || st === 'emphasis') return { c: 'var(--um-green)', m: 'g', dash: '', flow: st === 'added' || e.animate };
@@ -996,15 +1022,21 @@ function svgOf(geo, opts = {}) {
996
1022
  if (st === 'dashed' || st === 'async') return { c: 'var(--um-violet)', m: 'v', dash: '4 4' };
997
1023
  return { c: 'var(--um-arrow)', m: 'd', dash: '' };
998
1024
  };
1025
+ // a line that breaks a rule (#70): rose, with a "!" just after its start that names the rule (opts.broken: rule titles by edge id)
1026
+ const broke = e => opts.broken ? [...new Set((e.all || [e]).flatMap(x => opts.broken[x._id] || []))] : [];
1027
+ const lstyle = e => broke(e).length ? { ...estyle(e), c: 'var(--um-rose)', m: 'r' } : estyle(e);
1028
+ const ruleMark = (e, why) => { const [a, b] = e.pts, L = Math.abs(b[0] - a[0]) + Math.abs(b[1] - a[1]) || 1, t = Math.min(18, L / 2) / L, x = a[0] + (b[0] - a[0]) * t, y = a[1] + (b[1] - a[1]) * t;
1029
+ return `<g class="rule-mark"><title>Breaks rule: ${esc(why.join('; '))}</title><circle cx="${x}" cy="${y}" r="6.5"/><text x="${x}" y="${y + 3.4}" text-anchor="middle">!</text></g>`; };
999
1030
  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>`;
1000
1031
  const MC = { d: 'var(--um-arrow)', 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]})`])) };
1001
- const heavy = [...new Set(edges.filter(e => e.weight > 1).map(e => estyle(e).m + '-w' + e.weight))]; // a heavier line's arrowhead grows with it
1032
+ 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
1002
1033
  let s = `<svg viewBox="${B.x} ${B.y} ${B.w} ${B.h}" data-w="${B.w}" data-h="${B.h}" role="${map ? 'group' : 'img'}" aria-label="${esc(opts.title || 'Diagram')}" xmlns="http://www.w3.org/2000/svg" preserveAspectRatio="xMidYMid meet">
1003
1034
  <defs>${marker('d', 'var(--um-arrow)')}${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('')}</defs>`;
1004
1035
  const families = geo.families || [], familyDepth = f => f.parent ? 1 + familyDepth(families.find(p => p.id === f.parent) || {}) : 0;
1005
1036
  families.slice().sort((a,b) => familyDepth(a)-familyDepth(b)).forEach(f => {
1006
1037
  s += `<g class="family" data-family="${esc(f.id)}"${f.parent ? ` data-parent="${esc(f.parent)}"` : ''}><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'})"/>
1007
- <text class="family-title" x="${f.x+12}" y="${f.y+18}" fill="var(--um-muted)" data-t="ft" data-o="${esc(f.id)}">${esc(f.label || f.id)}</text>${(f.tabs || []).map(t => `
1038
+ <text class="family-title" x="${f.x+12}" y="${f.y+18}" fill="var(--um-muted)" data-t="ft" data-o="${esc(f.id)}">${esc(f.label || f.id)}</text>${(f.tabs || []).map(t => t.fold ? `
1039
+ <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>` : `
1008
1040
  <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>`;
1009
1041
  });
1010
1042
  (geo.lanes || []).forEach((l, i) => {
@@ -1043,15 +1075,15 @@ function svgOf(geo, opts = {}) {
1043
1075
  ${tour ? `<circle r="3.2" class="tour tour-packet" opacity="0"><animateMotion path="${roundPath(b.pts)}" keyPoints="0;0;1" keyTimes="0;.86;1" calcMode="linear" dur="${TOUR}s" repeatCount="indefinite"/>${tourAnim('0;0;.8;.8;0', [0, .86, .88, .99, 1])}</circle>` : ''}</g>`;
1044
1076
  });
1045
1077
  edges.forEach((e, i) => {
1046
- const st = estyle(e), d = roundPath(e.pts), mk = st.m + (e.weight > 1 ? '-w' + e.weight : '');
1078
+ const st = lstyle(e), why = broke(e), d = roundPath(e.pts), mk = st.m + (e.weight > 1 ? '-w' + e.weight : '');
1047
1079
  if (tour && tour.legs[i]) { const [a, b] = tour.legs[i];
1048
1080
  s += `<path d="${d}" class="tour-glow" stroke="${st.c}" opacity="0">${tourAnim('0;0;.55;.55;0;0', [0, a, Math.min(a + .01, b), b, b + .03, 1])}</path>`; }
1049
1081
  s += `<g class="edge-g${st.cls || ''}${e.weight > 1 ? ' wt-' + e.weight : ''}" data-from="${esc(e.from)}" data-to="${esc(e.to)}" data-i="${i}"><path d="${d}" class="halo"/><path d="${d}" class="hitline"/>
1050
1082
  <path d="${d}" class="edge${st.flow ? ' flow' : ''}" stroke="${st.c}" ${st.dash && !st.flow ? `stroke-dasharray="${st.dash}"` : ''} ${st.op ? `opacity="${st.op}"` : ''} marker-end="url(#${id}-${mk})" ${e.both ? `marker-start="url(#${id}-${mk})"` : ''}/>
1051
- <path d="${d}" class="flowline" stroke="${st.c}"/>${(e.all || [e]).some(x => x.edited) ? (() => { let m = [e.pts[0], e.pts[1]];
1083
+ <path d="${d}" class="flowline" stroke="${st.c}"/>${(e.all || [e]).some(x => x.edited) && !e.strong ? (() => { let m = [e.pts[0], e.pts[1]];
1052
1084
  e.pts.slice(1).forEach((p, k) => { const a = e.pts[k]; if (Math.abs(p[0] - a[0]) + Math.abs(p[1] - a[1]) > Math.abs(m[1][0] - m[0][0]) + Math.abs(m[1][1] - m[0][1])) m = [a, p]; });
1053
- return `<circle cx="${(m[0][0] + m[1][0]) / 2}" cy="${(m[0][1] + m[1][1]) / 2}" r="4" class="edited-dot"><title>Edited</title></circle>`; })() : ''}
1054
- <title>${e.step != null ? 'Step ' + e.step + ': ' : ''}${esc(e.s.label)} ${e.both ? '⇄' : '→'} ${esc(e.t.label)}${e.label ? ': ' + esc(e.label) : ''}${e.label && e.sublabel ? ' (' + esc(e.sublabel) + ')' : ''}${e.when ? '\n' + esc(e.when) : ''}</title></g>`;
1085
+ return `<circle cx="${(m[0][0] + m[1][0]) / 2}" cy="${(m[0][1] + m[1][1]) / 2}" r="4" class="edited-dot"><title>Edited</title></circle>`; })() : ''}${why.length ? ruleMark(e, why) : ''}
1086
+ <title>${e.step != null ? 'Step ' + e.step + ': ' : ''}${esc(e.s.label)} ${e.both ? '⇄' : '→'} ${esc(e.t.label)}${e.label ? ': ' + esc(e.label) : ''}${e.label && e.sublabel ? ' (' + esc(e.sublabel) + ')' : ''}${e.when ? '\n' + esc(e.when) : ''}${why.length ? '\nBreaks rule: ' + esc(why.join('; ')) : ''}</title></g>`;
1055
1087
  });
1056
1088
  s += '</g><g class="nodes">';
1057
1089
  nodes.forEach(n => {
@@ -1114,9 +1146,9 @@ return I && I.src ? `<rect x="${x - M * 3 / 28}" y="${y - M * 3 / 28}" width="${
1114
1146
  said.push(kids ? `${kids} (cards inside, opens the inner view)` : '(opens the inner view)'); }
1115
1147
  const ch = CHG[String(n.change || '').toLowerCase()];
1116
1148
  if (ch) {
1117
- const bw = ch[1].length * 6 + 12;
1118
- parts.push(`<rect x="${n.x + n.w - bw - 8}" y="${n.y - 8}" width="${bw}" height="16" rx="8" fill="var(--um-mask)" stroke="var(--um-${ch[0]})"/>
1119
- <text x="${n.x + n.w - bw / 2 - 8}" y="${n.y + 3}" text-anchor="middle" class="badge-tx" fill="var(--um-${ch[0]})">${ch[1]}</text>`);
1149
+ const bw = ch[1].length * 6 + 12, bx = n.x + n.w - bw - 8 - (hint && map ? 40 : 0); // left of a map card's ⤢ pill, which sits on the same edge
1150
+ parts.push(`<rect x="${bx}" y="${n.y - 8}" width="${bw}" height="16" rx="8" fill="var(--um-mask)" stroke="var(--um-${ch[0]})"/>
1151
+ <text x="${bx + bw / 2}" y="${n.y + 3}" text-anchor="middle" class="badge-tx" fill="var(--um-${ch[0]})">${ch[1]}</text>`);
1120
1152
  said.push(ch[1].toLowerCase());
1121
1153
  }
1122
1154
  if (sub) { parts.push(`<text x="${tx0}" y="${ly + 16 + (two ? 15 : 0)}" text-anchor="${anchor}" class="n-sub depth-read" data-o="${esc(n.id)}" data-t="ns">${sub2 ? lines(sub2, 12) : esc(sub)}</text>`);
@@ -1125,7 +1157,7 @@ return I && I.src ? `<rect x="${x - M * 3 / 28}" y="${y - M * 3 / 28}" width="${
1125
1157
  let vx = 0; const chips = [], maxW = third ? sw : n.w - 24; // on a third line they keep to the text's column, clear of the mark
1126
1158
  for (let i = 0; i < verbs.length; i++) {
1127
1159
  const wv = textW(verbs[i], 9.5) + 10, rest = verbs.length - i - 1, more = rest ? textW('+' + rest, 9.5) + 10 : 0;
1128
- if (vx + wv + (rest ? more + 4 : 0) > maxW && i) { chips.push(['+' + (verbs.length - i), textW('+' + (verbs.length - i), 9.5) + 10, true]); break; }
1160
+ if (vx + wv + (rest ? more + 4 : 0) > maxW) { chips.push(['+' + (verbs.length - i), textW('+' + (verbs.length - i), 9.5) + 10, true]); break; } // the first too: a verb wider than any card is "+N" (#84)
1129
1161
  chips.push([verbs[i], wv]); vx += wv + 4;
1130
1162
  }
1131
1163
  const total = chips.reduce((a, c2) => a + c2[1], 0) + (chips.length - 1) * 4;
@@ -1148,7 +1180,7 @@ return I && I.src ? `<rect x="${x - M * 3 / 28}" y="${y - M * 3 / 28}" width="${
1148
1180
  if (map) { // each line's focus target: after the cards, so lines follow them in the Tab order (#43), under the labels; the mouse still hits the line
1149
1181
  s += '</g><g class="keys">';
1150
1182
  edges.forEach((e, i) => { const said = e.label ? `: ${e.label}${e.sublabel ? ` (${e.sublabel})` : ''}` : '';
1151
- const name = `${e.step != null ? `Step ${e.step}: ` : ''}Line ${e.both ? `between ${e.s.label} and ${e.t.label}, both ways` : `from ${e.s.label} to ${e.t.label}`}${said}`;
1183
+ const name = `${e.step != null ? `Step ${e.step}: ` : ''}Line ${e.both ? `between ${e.s.label} and ${e.t.label}, both ways` : `from ${e.s.label} to ${e.t.label}`}${said}${broke(e).length ? `, breaks rule ${broke(e).join('; ')}` : ''}`;
1152
1184
  s += `<g class="um-key line-key${e.weight > 1 ? ' wt-' + e.weight : ''}" data-i="${i}" tabindex="0" role="button" aria-pressed="false" aria-label="${esc(name)}"><path d="${roundPath(e.pts)}" class="key-ring"/></g>`; });
1153
1185
  }
1154
1186
  s += '</g><g class="labels depth-read">';
@@ -1156,8 +1188,9 @@ return I && I.src ? `<rect x="${x - M * 3 / 28}" y="${y - M * 3 / 28}" width="${
1156
1188
  if (!e.lbl) return;
1157
1189
  const L = e.lbl;
1158
1190
  if (map) {
1159
- const st = L.step != null ? `<circle cx="${L.x + 11}" cy="${L.y + L.h / 2}" r="8" class="e-step"/><text x="${L.x + 11}" y="${L.y + L.h / 2 + 3.3}" text-anchor="middle" class="e-step-tx">${esc(L.step)}</text>` : '';
1160
- const ax = L.step != null ? L.x + 23 : L.x + L.w / 2, an = L.step != null ? '' : 'text-anchor="middle"';
1191
+ const sw = L.sw || 16, cy = L.y + L.h / 2; // the step mark: a circle, or a pill as wide as a longer number (#82)
1192
+ const st = L.step != null ? `${sw > 16 ? `<rect x="${L.x + 3}" y="${cy - 8}" width="${sw}" height="16" rx="8" class="e-step"/>` : `<circle cx="${L.x + 11}" cy="${cy}" r="8" class="e-step"/>`}<text x="${L.x + 3 + sw / 2}" y="${cy + 3.3}" text-anchor="middle" class="e-step-tx">${esc(L.step)}</text>` : '';
1193
+ const ax = L.step != null ? L.x + 7 + sw : L.x + L.w / 2, an = L.step != null ? '' : 'text-anchor="middle"';
1161
1194
  const tx = L.text ? `<text x="${ax}" y="${L.y + 14}" ${an} class="e-label" data-t="el" data-o="${esc(e.from)}>${esc(e.to)}">${esc(L.text)}</text>` : '';
1162
1195
  const sb = L.sub ? `<text x="${ax}" y="${L.y + 26}" ${an} class="e-sub" data-t="el" data-o="${esc(e.from)}>${esc(e.to)}">${esc(L.sub)}</text>` : '';
1163
1196
  s += `<g class="e-lab${L.tag ? ' ctag' : ''}${e.wk ? ' w-' + e.wk : ''}" data-i="${i}"><rect x="${L.x}" y="${L.y}" width="${L.w}" height="${L.h}" rx="${Math.min(L.h / 2, 10)}" class="e-lbg"/>${st}${tx}${sb}</g>`;
@@ -1269,7 +1302,7 @@ class Camera {
1269
1302
  }
1270
1303
  onDown(e) {
1271
1304
  if (e.button !== 0 && e.pointerType === 'mouse') return;
1272
- if (e.target.closest('.ctl, .minimap, button, a, input, .um-edit') || (this.o.skip && this.o.skip(e))) return;
1305
+ if (e.target.closest('.ctl, .minimap, button, a, input, .um-edit, .um-story') || (this.o.skip && this.o.skip(e))) return;
1273
1306
  this.ptrs.set(e.pointerId, [e.clientX, e.clientY]);
1274
1307
  this.start = { x: e.clientX, y: e.clientY, s: { ...this.s }, dist: this.pinchDist() };
1275
1308
  this.moved = 0; this.dragged = false;
@@ -1301,9 +1334,12 @@ class Camera {
1301
1334
  }
1302
1335
  }
1303
1336
 
1304
- function routeAudit(geo, where, only) { // only: a set of edges; counts just the issues that involve one of them
1337
+ // an issue's message, and what it is about (the cards, lines and boxes it names) when the caller keeps a list of that: check()
1338
+ // turns those into Refs; the page's check reads only the messages
1339
+ const teller = (issues, refs) => (message, ...about) => { issues.push(message); if (refs) refs.push(about); };
1340
+ function routeAudit(geo, where, only, refs) { // only: a set of edges; counts just the issues that involve one of them
1305
1341
  const sk = e => only && !only.has(e);
1306
- const issues = [];
1342
+ const issues = [], say = teller(issues, refs);
1307
1343
  // routes: no crossings, no shared runs, no label lying across another route
1308
1344
  const segs = geo.edges.map(e => e.pts.slice(0, -1).map((a, i) => [a, e.pts[i + 1]]));
1309
1345
  const name = e => `${e.s.label}→${e.t.label}`;
@@ -1324,66 +1360,87 @@ function routeAudit(geo, where, only) { // only: a set of edges; counts just the
1324
1360
  if (sk(geo.edges[i]) && sk(geo.edges[j])) continue;
1325
1361
  let found = null;
1326
1362
  for (const s1 of segs[i]) { for (const s2 of segs[j]) { found = crossing(s1, s2); if (found) break; } if (found) break; }
1327
- if (found === 'cross') issues.push(`${where}: edges ${name(geo.edges[i])} and ${name(geo.edges[j])} cross`);
1328
- if (found === 'share') issues.push(`${where}: edges ${name(geo.edges[i])} and ${name(geo.edges[j])} share a line`);
1363
+ if (found === 'cross') say(`${where}: edges ${name(geo.edges[i])} and ${name(geo.edges[j])} cross`, geo.edges[i], geo.edges[j]);
1364
+ if (found === 'share') say(`${where}: edges ${name(geo.edges[i])} and ${name(geo.edges[j])} share a line`, geo.edges[i], geo.edges[j]);
1329
1365
  }
1330
1366
  geo.edges.forEach((e, i) => { if (!e.lbl) return; const L = { x: e.lbl.x + 1, y: e.lbl.y + 1, w: e.lbl.w - 2, h: e.lbl.h - 2 };
1331
- geo.edges.forEach((o, j) => { if (j !== i && !(sk(e) && sk(o)) && segs[j].some(([a, b]) => Math.max(a[0], b[0]) > L.x && Math.min(a[0], b[0]) < L.x + L.w && Math.max(a[1], b[1]) > L.y && Math.min(a[1], b[1]) < L.y + L.h)) issues.push(`${where}: label "${e.lbl.text || e.lbl.step}" lies on edge ${name(o)}`); }); });
1367
+ geo.edges.forEach((o, j) => { if (j !== i && !(sk(e) && sk(o)) && segs[j].some(([a, b]) => Math.max(a[0], b[0]) > L.x && Math.min(a[0], b[0]) < L.x + L.w && Math.max(a[1], b[1]) > L.y && Math.min(a[1], b[1]) < L.y + L.h)) say(`${where}: label "${e.lbl.text || e.lbl.step}" lies on edge ${name(o)}`, e, o); }); });
1332
1368
  const segHit = (r, i) => segs[i].some(([a, b]) => Math.max(a[0], b[0]) > r.x && Math.min(a[0], b[0]) < r.x + r.w && Math.max(a[1], b[1]) > r.y && Math.min(a[1], b[1]) < r.y + r.h);
1333
1369
  if (geo.map) {
1334
1370
  const ex = 12, drawn = new Set(geo.edges.map(e => e.from + '>' + e.to)); // A→B and B→A both drawn: a pair the view keeps apart
1335
1371
  geo.edges.forEach((e, i) => {
1336
1372
  // clearance: a route keeps 12+ px from every node it does not connect
1337
1373
  geo.nodes.forEach(n => { if (n.id === e.from || n.id === e.to) return;
1338
- if (segHit({ x: n.x - ex + .5, y: n.y - ex + .5, w: n.w + 2 * ex - 1, h: n.h + 2 * ex - 1 }, i)) issues.push(`${where}: edge ${name(e)} passes within ${ex}px of node "${n.label}"`); });
1374
+ if (segHit({ x: n.x - ex + .5, y: n.y - ex + .5, w: n.w + 2 * ex - 1, h: n.h + 2 * ex - 1 }, i)) say(`${where}: edge ${name(e)} passes within ${ex}px of node "${n.label}"`, e, n); });
1339
1375
  // no detours: a route stays under 2.5x the straight distance between its ends
1340
1376
  const len = e.pts.slice(1).reduce((a, p, k) => a + Math.abs(p[0] - e.pts[k][0]) + Math.abs(p[1] - e.pts[k][1]), 0);
1341
1377
  const d = Math.hypot(e.pts[e.pts.length - 1][0] - e.pts[0][0], e.pts[e.pts.length - 1][1] - e.pts[0][1]);
1342
- if (len > 2.5 * Math.max(d, 100)) issues.push(`${where}: edge ${name(e)} takes a detour (${Math.round(len)}px for ${Math.round(d)}px)`);
1378
+ if (len > 2.5 * Math.max(d, 100)) say(`${where}: edge ${name(e)} takes a detour (${Math.round(len)}px for ${Math.round(d)}px)`, e);
1343
1379
  // reading order: every wire runs left to right, except one that closes a loop or answers its pair (keepPairs)
1344
- if (!e.back && !e.both && !drawn.has(e.to + '>' + e.from) && e.t.x + e.t.w / 2 <= e.s.x + e.s.w / 2) issues.push(`${where}: edge ${name(e)} runs right-to-left`);
1380
+ if (!e.back && !e.both && !drawn.has(e.to + '>' + e.from) && e.t.x + e.t.w / 2 <= e.s.x + e.s.w / 2) say(`${where}: edge ${name(e)} runs right-to-left`, e);
1345
1381
  });
1346
1382
  if (geo.hub) { // hub views: one visible line into each callee
1347
1383
  const into = {}; geo.edges.forEach(e => { (into[e.to] ||= []).push(e); });
1348
- Object.entries(into).forEach(([id, es]) => { es = es.filter(e => !e.back); if (es.some(e => e.from === geo.hub) && es.length > 1) issues.push(`${where}: hub callee "${(geo.byId[id] || {}).label || id}" has ${es.length} incoming lines`); });
1384
+ Object.entries(into).forEach(([id, es]) => { es = es.filter(e => !e.back); if (es.some(e => e.from === geo.hub) && es.length > 1) say(`${where}: hub callee "${(geo.byId[id] || {}).label || id}" has ${es.length} incoming lines`, geo.byId[id]); });
1349
1385
  }
1350
1386
  }
1351
1387
  // author name tags hang under their card: no route may run through one
1352
1388
  (geo.nodes || []).forEach(n => { if (!n.tag) return; const r = { x: n.tag.x + 1, y: n.tag.y + 1, w: n.tag.w - 2, h: n.tag.h - 2 };
1353
- geo.edges.forEach((o, j) => { if (segHit(r, j)) issues.push(`${where}: name tag "${n.tag.text}" lies on edge ${name(o)}`); }); });
1389
+ geo.edges.forEach((o, j) => { if (segHit(r, j)) say(`${where}: name tag "${n.tag.text}" lies on edge ${name(o)}`, n, o); }); });
1354
1390
  // lanes: every part sits inside its own lane, lanes never overlap, no route crosses a lane header
1355
1391
  const lanes = geo.lanes || [];
1356
1392
  lanes.forEach((l, i) => {
1357
- lanes.slice(i + 1).forEach(m => { if (hit(l, m, -1)) issues.push(`${where}: lanes "${l.title}" and "${m.title}" overlap`); });
1393
+ lanes.slice(i + 1).forEach(m => { if (hit(l, m, -1)) say(`${where}: lanes "${l.title}" and "${m.title}" overlap`); });
1358
1394
  (geo.nodes || []).forEach(n => {
1359
- if (n.lane === i && (n.x < l.x || n.x + n.w > l.x + l.w || n.y < l.y || n.y + n.h + (n.tag ? 14 : 0) > l.y + l.h)) issues.push(`${where}: lane "${l.title}": node "${n.label}" sticks out`);
1360
- if (n.lane != null && n.lane !== i && hit(n, l, -1)) issues.push(`${where}: lane "${l.title}": node "${n.label}" of another lane sits in it`);
1361
- if (n.lane == null && hit(n, l, -1)) issues.push(`${where}: lane "${l.title}": node "${n.label}" belongs to no lane but sits in it`);
1395
+ if (n.lane === i && (n.x < l.x || n.x + n.w > l.x + l.w || n.y < l.y || n.y + n.h + (n.tag ? 14 : 0) > l.y + l.h)) say(`${where}: lane "${l.title}": node "${n.label}" sticks out`, n);
1396
+ if (n.lane != null && n.lane !== i && hit(n, l, -1)) say(`${where}: lane "${l.title}": node "${n.label}" of another lane sits in it`, n);
1397
+ if (n.lane == null && hit(n, l, -1)) say(`${where}: lane "${l.title}": node "${n.label}" belongs to no lane but sits in it`, n);
1362
1398
  });
1363
1399
  });
1364
- (geo.blocks || []).forEach(b => geo.edges.forEach((o, j) => { if (segHit({ x: b.x + 1, y: b.y + 1, w: b.w - 2, h: b.h - 2 }, j)) issues.push(`${where}: lane "${b.title || ''}": header crossed by edge ${name(o)}`); }));
1400
+ (geo.blocks || []).forEach(b => geo.edges.forEach((o, j) => { if (segHit({ x: b.x + 1, y: b.y + 1, w: b.w - 2, h: b.h - 2 }, j)) say(`${where}: lane "${b.title || ''}": header crossed by edge ${name(o)}`, o); }));
1365
1401
  const families = geo.families || [], inside = (a,b) => b.x > a.x && b.y > a.y && b.x+b.w < a.x+a.w && b.y+b.h < a.y+a.h;
1366
1402
  families.forEach(f => {
1367
1403
  const parent = families.find(p => p.id === f.parent);
1368
- if (f.parent && (!parent || !inside(parent,f))) issues.push(`${where}: family "${f.label}" sticks out of its parent`);
1369
- (f.nodeIds || []).forEach(id => { const n = geo.byId[id]; if (n && !inside(f,n)) issues.push(`${where}: family "${f.label}" does not contain "${n.label}"`); });
1370
- families.filter(g => g !== f && g.parent === f.parent && g.id > f.id).forEach(g => { if (hit(f,g,-1)) issues.push(`${where}: families "${f.label}" and "${g.label}" overlap`); });
1371
- geo.edges.forEach((e,i) => { if (segHit({x:f.x+8,y:f.y+3,w:Math.min(f.w-16,textW(f.label || f.id,11)+16),h:22},i)) issues.push(`${where}: family "${f.label}": header crossed by edge ${name(e)}`); });
1404
+ if (f.parent && (!parent || !inside(parent,f))) say(`${where}: family "${f.label}" sticks out of its parent`, f);
1405
+ (f.nodeIds || []).forEach(id => { const n = geo.byId[id]; if (n && !inside(f,n)) say(`${where}: family "${f.label}" does not contain "${n.label}"`, f, n); });
1406
+ families.filter(g => g !== f && g.parent === f.parent && g.id > f.id).forEach(g => { if (hit(f,g,-1)) say(`${where}: families "${f.label}" and "${g.label}" overlap`, f, g); });
1407
+ geo.edges.forEach((e,i) => { if (segHit({x:f.x+8,y:f.y+3,w:Math.min(f.w-16,textW(f.label || f.id,11)+16),h:22},i)) say(`${where}: family "${f.label}": header crossed by edge ${name(e)}`, f, e); });
1372
1408
  geo.edges.forEach((e,i) => { if (segs[i].some(([a,b]) => { const v = a[0] === b[0], c = v ? a[0] : a[1], p = v ? [a[1], b[1]] : [a[0], b[0]];
1373
1409
  const lo = Math.max(Math.min(...p), v ? f.y : f.x), hi = Math.min(Math.max(...p), v ? f.y + f.h : f.x + f.w);
1374
- return hi - lo > 40 && (v ? [f.x, f.x + f.w] : [f.y, f.y + f.h]).some(u => Math.abs(c - u) < 8); })) issues.push(`${where}: edge ${name(e)} runs along the border of family "${f.label}"`); });
1410
+ return hi - lo > 40 && (v ? [f.x, f.x + f.w] : [f.y, f.y + f.h]).some(u => Math.abs(c - u) < 8); })) say(`${where}: edge ${name(e)} runs along the border of family "${f.label}"`, e, f); });
1375
1411
  geo.edges.forEach(e => { const L = e.lbl; if (!L) return; const xin = L.x + L.w > f.x && L.x < f.x + f.w, yin = L.y + L.h > f.y && L.y < f.y + f.h;
1376
- if ((xin && [f.y, f.y + f.h].some(u => L.y < u && L.y + L.h > u)) || (yin && [f.x, f.x + f.w].some(u => L.x < u && L.x + L.w > u))) issues.push(`${where}: label "${L.text || L.step}" sits on the border of family "${f.label}"`); });
1412
+ if ((xin && [f.y, f.y + f.h].some(u => L.y < u && L.y + L.h > u)) || (yin && [f.x, f.x + f.w].some(u => L.x < u && L.x + L.w > u))) say(`${where}: label "${L.text || L.step}" sits on the border of family "${f.label}"`, e, f); });
1377
1413
  });
1378
1414
  return issues;
1379
1415
  }
1380
1416
 
1417
+ /* The layout check's geometry, which needs no fonts: check() runs it on layout()'s geometry, and the page adds what it
1418
+ measures in the DOM (text against its card, a lane's or box's title, edge labels against each other). */
1419
+ const box = n => ({ x: n.x, y: n.y, w: n.w, h: n.h });
1420
+ function overlapIssues(geo, where, refs) {
1421
+ const issues = [], nodes = geo.nodes, say = teller(issues, refs);
1422
+ for (let i = 0; i < nodes.length; i++) for (let j = i + 1; j < nodes.length; j++) if (hit(box(nodes[i]), box(nodes[j]), -1)) say(`${where}: nodes "${nodes[i].label}" and "${nodes[j].label}" overlap`, nodes[i], nodes[j]);
1423
+ return issues;
1424
+ }
1425
+ function leaveIssues(geo, where, refs) {
1426
+ const issues = [], say = teller(issues, refs);
1427
+ if (geo.map) geo.edges.forEach(e => { const L = e.lbl; if (!L || !e.label || !e.pts) return; // a label must leave some of its own line visible
1428
+ const c = [L.x + L.w / 2, L.y + L.h / 2], P = e.pts; let s = 0, at = null, half = 0, tot = 0;
1429
+ for (let i = 0; i + 1 < P.length; i++) { const a = P[i], b = P[i + 1], len = Math.abs(b[0] - a[0]) + Math.abs(b[1] - a[1]);
1430
+ if (at == null) { const onH = a[1] === b[1] && Math.abs(c[1] - a[1]) < 2 && c[0] >= Math.min(a[0], b[0]) - 1 && c[0] <= Math.max(a[0], b[0]) + 1;
1431
+ const onV = a[0] === b[0] && Math.abs(c[0] - a[0]) < 2 && c[1] >= Math.min(a[1], b[1]) - 1 && c[1] <= Math.max(a[1], b[1]) + 1;
1432
+ if (onH || onV) { at = tot + Math.abs(c[0] - a[0]) + Math.abs(c[1] - a[1]); half = onH ? L.w / 2 : L.h / 2; } }
1433
+ tot += len; }
1434
+ if (at == null) return; const room = Math.min(at - half, tot - at - half);
1435
+ if (room < 16) say(`${where}: label "${e.label}" leaves its line ${Math.max(0, Math.round(room))} px (${e.s.label}→${e.t.label})`, e); });
1436
+ return issues;
1437
+ }
1438
+
1381
1439
  /* label-overlap audit used by `visual.py check` (world coordinates, real font metrics) */
1382
1440
  function auditSvg(svg, geo, where) {
1383
- const issues = [], R = n => ({ x: n.x, y: n.y, w: n.w, h: n.h });
1441
+ const issues = overlapIssues(geo, where), R = box;
1384
1442
  const T = [...svg.querySelectorAll('text[data-t]')].map(t => { let b; try { b = t.getBBox(); } catch (_) { return null; } return { t: t.dataset.t, o: t.dataset.o, txt: t.textContent, b: { x: b.x, y: b.y, w: b.width, h: b.height } }; }).filter(x => x && x.b.w);
1385
1443
  const nodes = geo.nodes;
1386
- for (let i = 0; i < nodes.length; i++) for (let j = i + 1; j < nodes.length; j++) if (hit(R(nodes[i]), R(nodes[j]), -1)) issues.push(`${where}: nodes "${nodes[i].label}" and "${nodes[j].label}" overlap`);
1387
1444
  T.forEach(a => {
1388
1445
  if (a.t === 'lt' || a.t === 'ft') {
1389
1446
  nodes.forEach(n => { if (hit(a.b, R(n), -1)) issues.push(`${where}: lane "${a.txt}": header covers node "${n.label}"`); });
@@ -1397,16 +1454,9 @@ function auditSvg(svg, geo, where) {
1397
1454
  nodes.forEach(n => { if (n !== own && hit(a.b, R(n), -1)) issues.push(`${where}: text "${a.txt}" overlaps node "${n.label}"`); });
1398
1455
  }
1399
1456
  });
1400
- issues.push(...routeAudit(geo, where));
1401
- if (geo.map) geo.edges.forEach(e => { const L = e.lbl; if (!L || !e.label || !e.pts) return; // a label must leave some of its own line visible
1402
- const c = [L.x + L.w / 2, L.y + L.h / 2], P = e.pts; let s = 0, at = null, half = 0, tot = 0;
1403
- for (let i = 0; i + 1 < P.length; i++) { const a = P[i], b = P[i + 1], len = Math.abs(b[0] - a[0]) + Math.abs(b[1] - a[1]);
1404
- if (at == null) { const onH = a[1] === b[1] && Math.abs(c[1] - a[1]) < 2 && c[0] >= Math.min(a[0], b[0]) - 1 && c[0] <= Math.max(a[0], b[0]) + 1;
1405
- const onV = a[0] === b[0] && Math.abs(c[0] - a[0]) < 2 && c[1] >= Math.min(a[1], b[1]) - 1 && c[1] <= Math.max(a[1], b[1]) + 1;
1406
- if (onH || onV) { at = tot + Math.abs(c[0] - a[0]) + Math.abs(c[1] - a[1]); half = onH ? L.w / 2 : L.h / 2; } }
1407
- tot += len; }
1408
- if (at == null) return; const room = Math.min(at - half, tot - at - half);
1409
- if (room < 16) issues.push(`${where}: label "${e.label}" leaves its line ${Math.max(0, Math.round(room))} px (${e.s.label}→${e.t.label})`); });
1457
+ svg.querySelectorAll('rect.verb-chip').forEach(r => { const own = geo.byId[r.closest('[data-id]').dataset.id], x = +r.getAttribute('x'); // a chip's pill, not only its text (#84)
1458
+ if (own && (x < own.x + 1 || x + +r.getAttribute('width') > own.x + own.w - 1)) issues.push(`${where}: chip "${r.nextElementSibling.textContent}" overflows node "${own.label}"`); });
1459
+ issues.push(...routeAudit(geo, where), ...leaveIssues(geo, where));
1410
1460
  const els = T.filter(a => a.t === 'el');
1411
1461
  for (let i = 0; i < els.length; i++) for (let j = i + 1; j < els.length; j++) if (hit(els[i].b, els[j].b, -1)) issues.push(`${where}: edge labels "${els[i].txt}" and "${els[j].txt}" overlap`);
1412
1462
  return issues;
@@ -1697,8 +1747,16 @@ function mapContext(model, opts = {}) {
1697
1747
 
1698
1748
  function buildView(vid, opts = {}) {
1699
1749
  const g = buildGeo(vid), v = V[vid];
1700
- return { g, markup: svgOf(g, { title: v.title, subject: subjectOf(v), icons: D.icons, ...opts }) };
1701
- }
1750
+ return { g, markup: svgOf(g, { title: v.title, subject: subjectOf(v), icons: D.icons, broken: brokenIn(vid), ...opts }) };
1751
+ }
1752
+ // the rules a view's lines break (#70), by model edge id: svgOf draws such a line rose, with a mark that names them
1753
+ let ruled = null;
1754
+ const brokenIn = vid => {
1755
+ if (!arrOf(D.rules).length) return null;
1756
+ const by = {}, title = id => { const r = D.rules.find(x => x && x.id === id); return String((r && (r.title || r.id)) ?? id); };
1757
+ (ruled ||= rules(D)).forEach(b => { if (b.view === vid) b.targets.forEach(t => { if (t.type === 'edge') t.ids.forEach(i => { const l = by[i] ||= []; if (!l.includes(title(b.rule))) l.push(title(b.rule)); }); }); });
1758
+ return Object.keys(by).length ? by : null;
1759
+ };
1702
1760
  const layoutCache = {};
1703
1761
  // the cards a card opens: its inner view's, else the count the host gives for one it has not loaded (opensCount)
1704
1762
  const kidCount = (nid, vid) => { const c = childOf(nid, vid), k = c && c !== vid ? (V[c].nodeIds || []).length : 0, n = +nodeIn(nid, vid).opensCount;
@@ -1720,7 +1778,7 @@ function mapContext(model, opts = {}) {
1720
1778
  const pair = {}; // A→B plus B→A reads as one two-way edge
1721
1779
  return out.filter(e => { const r = !(V[vid] || {}).keepPairs && pair[e.to + '>' + e.from]; if (r) { r.both = true; r.back = false; r.all = [...(r.all || [r]), ...(e.all || [e])]; if (e.label && e.label !== r.label) r.label = (r.label ? r.label + ' / ' : '') + e.label; if (e.when) r.when = [r.when, e.when].filter(Boolean).join(' · '); return false; } pair[e.from + '>' + e.to] = e; return true; })
1722
1780
  .map(e => { const all = e.all || [e], subs = [...new Set(all.map(x => x.sublabel).filter(x => x != null && x !== '').map(String))];
1723
- return { ...e, wk: wireKind(e, opts.kinds || (V[vid] || {}).kindsFromLabels), weight: Math.max(...all.map(x => [2, 3].includes(+x.weight) ? +x.weight : 1)), sublabel: subs.join(' / ') || undefined }; });
1781
+ return { ...e, wk: wireKind(e, opts.kinds || (V[vid] || {}).kindsFromLabels), weight: Math.max(...all.map(x => [2, 3].includes(+x.weight) ? +x.weight : 1)), sublabel: subs.join(' / ') || undefined, ...markOf(all) }; });
1724
1782
  }
1725
1783
  const condTag = l => { const m = /^(?:routes|on-ramp:?)\s+(\S+)$/i.exec(String(l || '').trim()); return m ? m[1] : null; }; // "routes bug" → a "bug" tag
1726
1784
  // quick: a candidate the layout searches below score and may drop, laid out once, as it was before 0.6.0 (no room for lines
@@ -1735,7 +1793,7 @@ function mapContext(model, opts = {}) {
1735
1793
  const ready = drawn[vid]; delete drawn[vid]; if (ready && !override && ready.pos === pos && repair === (v.repair !== false) && !quick) return ready.g;
1736
1794
  const mainSteps = v.mainSteps || v.render === 'process';
1737
1795
  const stepIds = v.stepIds || ids;
1738
- const dg = { nodeH: mainSteps ? 82 : v.subAndChips ? 80 : 64, subAndChips: !!v.subAndChips, mainSteps, containers: v.containers, nodes: ids.map(i => { const n = nodeIn(i, vid), step = mainSteps && stepIds.includes(i); return { ...n, sub: n.sub || n.sublabel, emphasis: !!step || !!n.emphasis, step: step ? stepIds.indexOf(i) + 1 : null, verbs: step ? [] : n.verbs, kids: kidCount(i, vid), opens: !!opensOf(i, vid), col: (pos[i] || [0, 0])[0], row: (pos[i] || [0, 0])[1] }; }),
1796
+ const dg = { nodeH: mainSteps ? 82 : v.subAndChips ? 80 : 64, subAndChips: !!v.subAndChips, mainSteps, containers: v.containers, nodes: ids.map(i => { const n = nodeIn(i, vid), step = mainSteps && stepIds.includes(i); return { ...n, sub: n.sub || n.sublabel, emphasis: !!step || !!n.emphasis, step: step ? stepIds.indexOf(i) + 1 : (v.cardSteps || {})[i] || null, verbs: step ? [] : n.verbs, kids: kidCount(i, vid), opens: !!opensOf(i, vid), col: (pos[i] || [0, 0])[0], row: (pos[i] || [0, 0])[1] }; }),
1739
1797
  edges: prepEdges(vid, edges, P.backKeys) };
1740
1798
  dg.authors = D.authors; dg.repair = repair; dg.intake = v.intake; dg.tour = v.tour; dg.gap = v.gap; dg.rgap = v.rgap; dg.quick = quick;
1741
1799
  return layoutGraph(dg, true);
@@ -1846,23 +1904,445 @@ function mapContext(model, opts = {}) {
1846
1904
  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]);
1847
1905
  return JSON.stringify([v, ns, viewEdges(v), ids.map(i => [kidCount(i, vid), opensOf(i, vid)]), icons, who]);
1848
1906
  };
1907
+ /* ---------- unfolding in place (#71): a stacked card opens into a box on the view it is on ----------
1908
+ The card becomes a box with its id and name, holding its inner view's own cards: those not on this view already (the
1909
+ inner view's other cards are this view's, its neighbours). Inside, the inner view's lines between them. This view's
1910
+ lines stay this view's: one to the card moves onto every card inside that the inner view joins to that neighbour, the
1911
+ same way round, or, with none, ends on the box's border; the inner view's other lines to neighbours are not added. The
1912
+ box is the inner view's own cards in their own order, at the card's place: later columns move right by its width, and
1913
+ 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
1914
+ has no columns, is laid out again). The inner view's boxes that hold its own cards come along inside, and a numbered
1915
+ (sequence) view's run order: its numbers, its loops back and its lanes (#82). Not in a numbered view: its own steps
1916
+ would be numbered again; that card opens. */
1917
+ // the inner view's own cards: those this view shows neither as cards nor as boxes (a card unfolded here already is one, #98)
1918
+ 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));
1919
+ 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;
1920
+ if (!c || c === vid || chain(vid).includes(c) || (v.containers || []).some(b => b && b.id === nid)) return null;
1921
+ return ownOf(v, V[c], nid).length ? c : null; };
1922
+ function unfold(vid, nid) {
1923
+ const v = V[vid], c = unfoldable(nid, vid); if (!c) return false;
1924
+ const C = V[c], outer = (v.nodeIds || []).filter(i => N[i]), inner = ownOf(v, C, nid);
1925
+ const n0 = nodeIn(nid, vid), holder = (v.containers || []).filter(b => b && (b.nodeIds || []).includes(nid)).pop();
1926
+ const boxes = (v.containers || []).filter(b => b && b.id).map(b => ({ ...b, nodeIds: (b.nodeIds || []).filter(i => i !== nid) }));
1927
+ const taken = new Set([...outer, ...inner, ...boxes.map(b => b.id)]);
1928
+ const cb = (C.containers || []).filter(b => b && b.id && !taken.has(b.id) && (b.nodeIds || []).some(i => inner.includes(i))), kept = new Set(cb.map(b => b.id));
1929
+ const above = b => { let p = b.parent; for (let k = 0; p != null && !kept.has(p) && k < 64; k++) p = ((C.containers || []).find(x => x && x.id === p) || {}).parent; return kept.has(p) ? p : nid; };
1930
+ // a numbered view drawn around one card (a hub) brings its lanes, boxed as it draws them, in their order (#82)
1931
+ const hp = C.render === 'sequence' && planOf(c).hub ? planOf(c) : null;
1932
+ const lanes = hp && hp.boxed ? hp.lanes.map((L, j) => ({ id: `${nid}:lane${j + 1}`, label: L.title, lane: true, nodeIds: L.ids.filter(i => inner.includes(i)), parent: nid })).filter(b => b.nodeIds.length) : [];
1933
+ const own = [...cb.map(b => ({ ...b, nodeIds: b.nodeIds.filter(i => inner.includes(i)), parent: above(b) })), ...lanes], boxed = new Set(own.flatMap(b => b.nodeIds));
1934
+ const box = { id: nid, label: n0.label || nid, nodeIds: inner.filter(i => !boxed.has(i)), unfolds: c, ...(holder ? { parent: holder.id } : {}), ...(badgesOf(n0).length ? { badges: badgesOf(n0) } : {}) };
1935
+ const isIn = id => inner.includes(id) || kept.has(id), mine = viewEdges(v);
1936
+ const to = new Set(mine.filter(e => e.to === nid).map(e => e.from)), from = new Set(mine.filter(e => e.from === nid).map(e => e.to)); // this view's neighbours of the card
1937
+ let theirs = viewEdges(C).filter(e => isIn(e.from) ? isIn(e.to) || from.has(e.to) : isIn(e.to) && to.has(e.from));
1938
+ // a line drawn both ways stays a pair: it moves inside only if the inner view joins that neighbour to one card both ways
1939
+ const ends = (a, out) => [...new Set(theirs.filter(e => out ? isIn(e.from) && e.to === a : e.from === a && isIn(e.to)).map(e => out ? e.from : e.to))].join();
1940
+ const split = new Set([...to].filter(a => from.has(a) && (ends(a, true) || ends(a, false)) && (ends(a, true) !== ends(a, false) || ends(a, true).includes(','))));
1941
+ theirs = theirs.filter(e => !split.has(isIn(e.from) ? e.to : e.from));
1942
+ /* a numbered view's run order comes along (#82): its lines keep their numbers, and a step back stays a loop back. A step
1943
+ between the card itself and a card inside has no line (the card is the box), and a hub's steps show on the cards they
1944
+ reach, as the numbered view draws them: those numbers go on that card inside. On a numbered card (a process view's
1945
+ step, or one an unfolded numbered view numbered) they go under its number: step 4's are 4.1, 4.2 */
1946
+ const tags = {}, onCard = e => e.from === nid ? e.to : e.to === nid ? e.from : hp && e.from === hp.hub ? e.to : null;
1947
+ const sid = v.stepIds || outer, at = (v.mainSteps || v.render === 'process') && sid.includes(nid) ? sid.indexOf(nid) + 1 : (v.cardSteps || {})[nid];
1948
+ const num = j => /^\d+(\.\d+)*$/.test(String(at ?? '')) ? `${at}.${j + 1}` : j + 1; // a card with several numbers (2·3) holds no one step
1949
+ if (C.render === 'sequence') { const lq = loopsOf(C);
1950
+ lq.edges.forEach((e, j) => { const o = onCard(e); if (inner.includes(o)) (tags[o] ||= []).push(num(j)); });
1951
+ theirs = theirs.map(e => { const j = lq.edges.indexOf(e); return { ...e, ...(inner.includes(onCard(e)) ? {} : { step: num(j) }), ...(lq.back.has(j) ? { loop: true } : {}) }; }); }
1952
+ const exact = new Set(theirs.flatMap(e => isIn(e.from) === isIn(e.to) ? [] : isIn(e.to) ? [e.from + '>'] : ['>' + e.to])); // neighbours the inner view joins to a card inside
1953
+ const k = (v.nodeIds || []).indexOf(nid), grid = !planOf(vid).hub;
1954
+ const pos = grid ? (v.layout ? fillLayout(vid, v.layout) : (layoutCache[vid] ||= autoPos(vid))) : null;
1955
+ Object.assign(v, { nodeIds: [...v.nodeIds.slice(0, k), ...inner, ...v.nodeIds.slice(k + 1)], containers: [...boxes, box, ...own],
1956
+ edges: [...mine.filter(e => !(e.to === nid && exact.has(e.from + '>')) && !(e.from === nid && exact.has('>' + e.to))), ...theirs] });
1957
+ if ((v.tour || []).includes(nid)) delete v.tour;
1958
+ if (v.mainSteps || v.render === 'process') v.stepIds = v.stepIds || outer; // the steps keep their numbers; the cards inside are not steps
1959
+ if (Array.isArray(v.intake)) v.intake = v.intake.filter(s => s && s.to !== nid);
1960
+ if (+C.gap > (v.gap || 66)) v.gap = +C.gap; if (+C.rgap > (v.rgap || 44)) v.rgap = +C.rgap; // the room the inner view's lines were given
1961
+ Object.entries(tags).forEach(([i, s]) => { (v.cardSteps ||= {})[i] = s.length > 2 ? s[0] + '+' : s.join('·'); }); // as hubGeo's stepTxt; buildGeo draws them
1962
+ delete v.layout; delete planCache[vid]; delete layoutCache[vid]; delete drawn[vid];
1963
+ if (!grid) return true; // a hub view has no columns to keep: laid out again, with its boxes
1964
+ /* the box's slots: the inner view's own cards in its columns, or in this view's reading order, and in its rows packed,
1965
+ upside down, or each card level with its neighbours (the median row of the cards outside it joins to, and of those to
1966
+ its left inside, as rowsFor does). Each way is routed, and the one the layout check scores best is kept */
1967
+ const [xc, xr] = pos[nid] || [0, 0], seen = new Set(), ways = [], med = xs => { const s = xs.slice().sort((a, b) => a - b), m = s.length >> 1; return s.length % 2 ? s[m] : (s[m - 1] + s[m]) / 2; };
1968
+ const levelled = (ic, ir) => { const R = {}, cols = [...new Set(inner.map(ic))].sort((a, b) => a - b);
1969
+ cols.forEach(k => { const want = {}, col = inner.filter(i => ic(i) === k);
1970
+ col.forEach(i => { const nb = theirs.flatMap(e => e.from === i ? [e.to] : e.to === i ? [e.from] : []).map(o => !isIn(o) && pos[o] ? pos[o][1] - xr : R[o]).filter(r => r != null);
1971
+ want[i] = nb.length ? med(nb) : ir(i); });
1972
+ let prev = -1; col.sort((a, b) => want[a] - want[b] || ir(a) - ir(b)).forEach(i => { R[i] = prev = Math.max(0, Math.round(want[i]), prev + 1); }); });
1973
+ return i => R[i]; };
1974
+ // with boxes of its own, the inner view keeps its slots, moved to the box's corner: empty slots are its boxes' room. Else
1975
+ // its columns, or columns in this view's own reading order: a loop the inner view closes with one line may close here
1976
+ // with another, or not at all
1977
+ const inBox = (id, seen = new Set()) => seen.has(id) ? [] : (seen.add(id), [...((boxes.find(x => x.id === id) || {}).nodeIds || []), ...boxes.filter(k => k.parent === id).flatMap(k => inBox(k.id, seen))]);
1978
+ const xBoxes = new Set(); for (let b = holder, k = 0; b && k < 64; k++, b = boxes.find(x => x.id === b.parent)) xBoxes.add(b.id); // the boxes the card is in
1979
+ // a hub's slots, as it draws them: the hub, then its lanes' columns in order (#82). ponytail: rows in each column's order,
1980
+ // not level with the part before as hubGeo lines them up; do that if a lane of several columns reads badly
1981
+ const hs = hp && { [hp.hub]: [0, 0] };
1982
+ if (hs) hp.lanes.flatMap(L => L.cols).map(col => col.filter(i => inner.includes(i))).filter(col => col.length).forEach((col, j) => col.forEach((i, r) => { hs[i] = [j + 1, r]; }));
1983
+ const ip0 = hs || (C.layout ? fillLayout(c, C.layout) : (layoutCache[c] ||= autoPos(c))), lp = loopsOf(v), rk = Object.fromEntries(inner.map(i => [i, 0]));
1984
+ const fwd = lp.edges.filter((e, j) => !lp.back.has(j) && e.from !== e.to && inner.includes(e.from) && inner.includes(e.to));
1985
+ for (let it = 0; it < inner.length; it++) fwd.forEach(e => { if (rk[e.to] < rk[e.from] + 1) rk[e.to] = rk[e.from] + 1; });
1986
+ const one = own.length || v.nodeIds.length > 30; // its boxes' room to keep, or too big to route seven times: its own slots only
1987
+ // the sides the inner view gave its lines are for its own picture: with its own slots they stay; elsewhere each way is
1988
+ // also routed with the router picking them (copies, which keep the ids)
1989
+ const keepE = v.edges, freeE = theirs.some(e => e.fromSide || e.toSide) ? keepE.map(e => theirs.includes(e) && (e.fromSide || e.toSide) ? (({ fromSide, toSide, ...r }) => r)(e) : e) : null;
1990
+ [ip0, ...(one ? [] : [Object.fromEntries(inner.map(i => [i, [rk[i], (ip0[i] || [0, 0])[1]]]))])].forEach(ip => (one ? [false] : [false, true, 'level']).forEach(flip => {
1991
+ const at = (i, j) => (ip[i] || [0, 0])[j], rank = j => { const u = [...new Set(inner.map(i => at(i, j)))].sort((a, b) => flip === true && j ? b - a : a - b), lo = u[0];
1992
+ const k = j && u.some(r => r % 1) ? 2 : 1; // half rows doubled: a box that ends on a half row gets no gap under it (layoutPass widens whole rows)
1993
+ return own.length ? i => (at(i, j) - lo) * k : i => u.indexOf(at(i, j)); };
1994
+ const ic = rank(0), ir = flip === 'level' ? levelled(rank(0), rank(1)) : rank(1), w = Math.max(...inner.map(ic)) + 1, h = Math.max(...inner.map(ir)) + 1, L = {};
1995
+ outer.forEach(i => { if (i === nid) return; const [c0, r0] = pos[i] || [0, 0]; L[i] = c0 >= xc + .5 ? [c0 + w - 1, r0] : Math.abs(c0 - xc) < .5 && r0 > xr ? [c0, r0 + h - 1] : [c0, r0]; });
1996
+ inner.forEach(i => { L[i] = [xc + ic(i), xr + ir(i)]; });
1997
+ // a box of this view with cards on both sides of the card's column, under it, would now reach into the unfolded box:
1998
+ // it moves down under it, whole; then a card on a taken slot moves down a row
1999
+ boxes.forEach(b => { if (xBoxes.has(b.id)) return; const ms = inBox(b.id).filter(i => L[i]); if (!ms.length) return;
2000
+ const cs = ms.map(i => L[i][0]), rs = ms.map(i => L[i][1]);
2001
+ if (Math.max(...cs) < xc || Math.min(...cs) > xc + w - 1 || Math.max(...rs) < xr || Math.min(...rs) > xr + h - 1) return;
2002
+ const dr = xr + h - Math.min(...rs); ms.forEach(i => { L[i] = [L[i][0], L[i][1] + dr]; }); });
2003
+ const slots = inner.map(i => L[i]); outer.forEach(i => { if (i === nid) return; let [c0, r0] = L[i];
2004
+ while (slots.some(([c1, r1]) => c1 === c0 && Math.abs(r1 - r0) < 1)) r0++; slots.push(L[i] = [c0, r0]); });
2005
+ const key = JSON.stringify(inner.map(i => L[i])); if (seen.has(key)) return; seen.add(key);
2006
+ ways.push({ L, free: false }); if (freeE && (ip !== ip0 || flip !== false)) ways.push({ L, free: true }); }));
2007
+ // a card inside a box it is not in reads as one of the box's (and loses a line to the box): a way with one loses
2008
+ const strays = g => (g.families || []).reduce((a, f) => a + g.nodes.filter(n => !(f.nodeIds || []).includes(n.id) && n.x + n.w / 2 > f.x && n.x + n.w / 2 < f.x + f.w && n.y + n.h / 2 > f.y && n.y + n.h / 2 < f.y + f.h
2009
+ && !(g.families || []).some(o => o.id !== f.id && (o.nodeIds || []).includes(n.id) && o.x >= f.x && o.y >= f.y && o.x + o.w <= f.x + f.w && o.y + o.h <= f.y + f.h)).length, 0);
2010
+ let best = null; // ponytail: up to 12 routings per unfold, a view of over 30 cards one; a smarter search when a bigger map needs it
2011
+ const score = ({ L, free }) => { v.edges = free ? freeE : keepE; hush = []; let g; try { g = buildGeo(vid, L); } finally { g && (g.said = hush); hush = null; }
2012
+ const s = scoreGeo(g) + (strays(g) * 5 + leaveIssues(g, '').length) * 1000; if (!best || s < best.s - .5) best = { L, g, s, free }; };
2013
+ ways.forEach(score);
2014
+ // still an issue, and cards under the card in its column: the box one column to the right instead, so they keep their slots
2015
+ if (best.s >= 1000 && v.nodeIds.length <= 30 && outer.some(i => i !== nid && pos[i] && Math.abs(pos[i][0] - xc) < .5)) { const B = best.L, L = {};
2016
+ outer.forEach(i => { if (i === nid) return; const [c0, r0] = pos[i] || [0, 0]; L[i] = c0 >= xc + .5 ? [c0 + 1 + Math.max(...inner.map(j => B[j][0])) - xc, r0] : [c0, r0]; });
2017
+ inner.forEach(i => { L[i] = [B[i][0] + 1, B[i][1]]; }); score({ L, free: best.free }); }
2018
+ best.g.said.forEach(warn); delete best.g.said;
2019
+ v.edges = best.free ? freeE : keepE; v.layout = best.L; drawn[vid] = { pos: best.L, g: best.g }; // drawn as scored, without routing it again
2020
+ return true;
2021
+ }
2022
+ const unfolded = {}; // per view: the cards opts.expand named that did unfold, in order (one inside another unfolds after it)
2023
+ withM(() => Object.entries(opts.expand || {}).forEach(([vid, ids]) => { let todo = V[vid] ? [...new Set([].concat(ids || []))] : [];
2024
+ 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); }); } }))();
1849
2025
  const geoCache = {};
1850
2026
  return { D, views, nodes, N, V, rootId, nodeIn, childOf, opensOf, subjectOf, viewsWith, kidsOf, chain, viewEdges, isState, planOf: withM(planOf), planCache,
1851
- layoutCache, geoCache, buildGeo: withM(buildGeo), buildView: withM(buildView), optimize: withM(optimize), flowPos, kidCount, viewKey };
2027
+ layoutCache, geoCache, buildGeo: withM(buildGeo), buildView: withM(buildView), optimize: withM(optimize), flowPos, kidCount, viewKey, brokenIn, unfoldable, unfolded };
1852
2028
  }
1853
2029
 
1854
2030
  const edgeRefIds = e => [...new Set((e.all || [e]).map(x => x._id).filter(Boolean))];
1855
2031
 
1856
2032
  /* layout(model, viewId): one view's geometry, as the canvas draws it. No DOM. */
1857
2033
  export function layout(model, viewId, options = {}) {
1858
- const ctx = mapContext(model, { measure: options.measure });
2034
+ const ctx = mapContext(model, { measure: options.measure, ...(options.expand ? { expand: { [viewId]: options.expand } } : {}) }); // expand: cards unfolded in place (#71)
1859
2035
  if (!ctx.V[viewId]) throw new Error(`umlimator: no view ${JSON.stringify(viewId)}`);
1860
2036
  ctx.planOf(viewId);
1861
2037
  const g = ctx.buildGeo(viewId, undefined, options.repair);
1862
2038
  g.edges.forEach(e => { e.ids = edgeRefIds(e); });
2039
+ if (options.expand) g.unfolded = ctx.unfolded[viewId] || [];
1863
2040
  return g;
1864
2041
  }
1865
2042
 
2043
+ /** The layout check's geometry for one view, without a browser: overlapping cards, crossing or shared lines, detours,
2044
+ * lines against reading order or through a card, labels on lines or off their own, boxes and lanes. The page's check
2045
+ * (`umlimator.py check`) adds what it measures in the DOM: text that overflows its card or touches its mark, titles
2046
+ * that overflow, edge labels on cards or on each other. Each issue names the cards and lines it is about. */
2047
+ export function check(model, viewId, options = {}) {
2048
+ const g = layout(model, viewId, options), where = `view ${viewId}`, about = [], lines = new Set(g.edges), boxes = new Set(g.families || []);
2049
+ const ref = x => lines.has(x) ? { type: 'edge', ids: x.ids, view: viewId } : boxes.has(x) ? { type: 'container', id: x.id, view: viewId } : { type: 'node', id: x.id, view: viewId };
2050
+ return [...overlapIssues(g, where, about), ...routeAudit(g, where, null, about), ...leaveIssues(g, where, about)]
2051
+ .map((message, i) => ({ message, targets: about[i].filter(Boolean).map(ref) }));
2052
+ }
2053
+
2054
+ const WIRE_KINDS = ['calls', 'feeds', 'triggers', 'state', 'guard'], SIDES = ['left', 'right', 'top', 'bottom'];
2055
+ /** A model's own problems, before anything is drawn: errors draw as holes or not at all (ids that are missing,
2056
+ * repeated or name nothing, boxes inside themselves); warnings draw other than they say (an unknown kind or weight,
2057
+ * a side that is not one, a view opened before the host has loaded it). Each says where: `views[2].nodeIds[4]`. */
2058
+ export function validate(model) {
2059
+ const out = [], err = (path, message) => out.push({ level: 'error', path, message }), warn = (path, message) => out.push({ level: 'warning', path, message });
2060
+ const arr = x => (Array.isArray(x) ? x : []), m = model && typeof model === 'object' ? model : {}, views = arr(m.views).filter(v => v && typeof v === 'object');
2061
+ if (!Array.isArray(m.nodes)) err('nodes', 'the model needs a list of cards');
2062
+ if (!views.length) err('views', 'the model has no views');
2063
+ const ids = new Set(), vids = new Set(), lineIds = new Set();
2064
+ const card = (n, at) => {
2065
+ if (!n || typeof n.id !== 'string' || !n.id) return err(at + '.id', 'a card needs an id');
2066
+ if (ids.has(n.id)) err(at + '.id', `card id "${n.id}" is used twice`); ids.add(n.id);
2067
+ const k = String(n.kind ?? '').toLowerCase();
2068
+ if (n.kind != null && !(k in KIND || k in MAPK || k in ALIAS)) warn(at + '.kind', `unknown kind "${n.kind}": drawn as a script card`);
2069
+ };
2070
+ arr(m.nodes).forEach((n, i) => card(n, `nodes[${i}]`));
2071
+ views.forEach((v, i) => arr(v.nodes).forEach((n, j) => card(n, `views[${i}].nodes[${j}]`)));
2072
+ 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); });
2073
+ if (m.root != null && !vids.has(m.root)) err('root', `root "${m.root}" is not a view`);
2074
+ 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`); };
2075
+ arr(m.nodes).forEach((n, i) => later(n, `nodes[${i}]`));
2076
+ const line = (e, at, ends) => {
2077
+ if (!e || typeof e !== 'object') return err(at, 'a line is an object with from and to');
2078
+ ['from', 'to'].forEach(k => { if (!ends.has(e[k])) err(`${at}.${k}`, `"${e[k]}" is neither a card nor a box`); });
2079
+ if (e.kind != null && !WIRE_KINDS.includes(e.kind)) warn(at + '.kind', `unknown line kind "${e.kind}": drawn as calls`);
2080
+ if (e.weight != null && ![1, 2, 3].includes(e.weight)) warn(at + '.weight', `weight ${JSON.stringify(e.weight)} is not 1, 2 or 3: drawn as 1`);
2081
+ ['fromSide', 'toSide'].forEach(k => { if (e[k] != null && !SIDES.includes(e[k])) warn(`${at}.${k}`, `"${e[k]}" is not left, right, top or bottom: ignored`); });
2082
+ };
2083
+ // a model line may end on any view's box (#28); a view's own line only on that view's boxes
2084
+ const anyBox = new Set(views.flatMap(v => arr(v.containers).map(c => c && c.id)));
2085
+ arr(m.edges).forEach((e, i) => { line(e, `edges[${i}]`, new Set([...ids, ...anyBox]));
2086
+ if (e && e.id != null) { if (lineIds.has(e.id)) err(`edges[${i}].id`, `line id "${e.id}" is used twice`); lineIds.add(e.id); } });
2087
+ views.forEach((v, i) => {
2088
+ const at = `views[${i}]`, mine = new Set(arr(v.nodeIds)), boxes = {};
2089
+ if (v.parent != null && !vids.has(v.parent) && !ids.has(v.parent)) err(at + '.parent', `parent "${v.parent}" is not a view`);
2090
+ arr(v.nodeIds).forEach((id, j) => { if (!ids.has(id)) err(`${at}.nodeIds[${j}]`, `"${id}" is not a card`); });
2091
+ Object.entries(v.nodeOverrides || {}).forEach(([id, o]) => { if (!ids.has(id)) err(`${at}.nodeOverrides.${id}`, `"${id}" is not a card`); later(o, `${at}.nodeOverrides.${id}`); });
2092
+ arr(v.nodes).forEach((n, j) => later(n, `${at}.nodes[${j}]`));
2093
+ ['stepIds', 'tour'].forEach(k => arr(v[k]).forEach((id, j) => { if (!mine.has(id)) err(`${at}.${k}[${j}]`, `"${id}" is not one of the view's cards`); }));
2094
+ arr(v.browserOrder).forEach((id, j) => { if (!vids.has(id)) err(`${at}.browserOrder[${j}]`, `"${id}" is not a view`); });
2095
+ arr(v.intake).forEach((s, j) => { if (!mine.has(s && s.to)) err(`${at}.intake[${j}].to`, `"${s && s.to}" is not one of the view's cards`); });
2096
+ arr(v.containers).forEach((c, j) => { if (!c || !c.id) err(`${at}.containers[${j}].id`, 'a box needs an id'); else if (boxes[c.id]) err(`${at}.containers[${j}].id`, `box id "${c.id}" is used twice`); else boxes[c.id] = c; });
2097
+ arr(v.containers).forEach((c, j) => { if (!c) return;
2098
+ arr(c.nodeIds).forEach((id, k) => { if (!mine.has(id)) err(`${at}.containers[${j}].nodeIds[${k}]`, `"${id}" is not one of the view's cards`); });
2099
+ if (c.parent != null && !boxes[c.parent]) err(`${at}.containers[${j}].parent`, `"${c.parent}" is not a box of this view`);
2100
+ for (let p = c.parent, seen = new Set([c.id]); p != null && boxes[p]; p = boxes[p].parent) { if (seen.has(p)) { err(`${at}.containers[${j}].parent`, `box "${c.id}" ends up inside itself`); break; } seen.add(p); } });
2101
+ const ends = new Set([...ids, ...Object.keys(boxes)]);
2102
+ if (Array.isArray(v.edges)) v.edges.forEach((e, j) => { if (typeof e !== 'string') line(e, `${at}.edges[${j}]`, ends); else if (!lineIds.has(e)) err(`${at}.edges[${j}]`, `"${e}" is not a line`); });
2103
+ Object.keys(v.layout || {}).forEach(id => { if (!mine.has(id)) warn(`${at}.layout.${id}`, `"${id}" is not one of the view's cards: its place is ignored`); });
2104
+ if (Array.isArray(v.lanes)) v.lanes.forEach((l, j) => arr(l && l.nodeIds).forEach((id, k) => { if (!mine.has(id)) warn(`${at}.lanes[${j}].nodeIds[${k}]`, `"${id}" is not one of the view's cards: left out of the lane`); }));
2105
+ });
2106
+ // rules (#70): an id, and from, to or noCycles; a selector that names nothing picks nothing, and a misspelt kind picks script cards
2107
+ if (m.rules != null && !Array.isArray(m.rules)) err('rules', 'rules is a list');
2108
+ const ruleIds = new Set(), allBoxes = new Set(anyBox), each = (x, at, f) => (Array.isArray(x) ? x : [x]).forEach((y, j) => { if (y != null) f(y, Array.isArray(x) ? `${at}[${j}]` : at); });
2109
+ const pick = (s, at) => {
2110
+ if (s == null) return;
2111
+ if (typeof s !== 'object' || Array.isArray(s)) return err(at, 'a selector is an object: id, kind, container or not');
2112
+ Object.keys(s).forEach(k => { if (!['id', 'kind', 'container', 'not'].includes(k)) warn(`${at}.${k}`, `unknown key "${k}": ignored, so the selector picks more cards`); });
2113
+ each(s.id, at + '.id', (x, p) => { if (!ids.has(x) && !allBoxes.has(x)) warn(p, `"${x}" is neither a card nor a box: it picks nothing`); });
2114
+ each(s.kind, at + '.kind', (x, p) => { const k = String(x).toLowerCase(); if (!(k in KIND || k in MAPK || k in ALIAS)) warn(p, `unknown kind "${x}": it picks script cards`); });
2115
+ each(s.container, at + '.container', (x, p) => { if (!allBoxes.has(x)) warn(p, `"${x}" is not a box: it picks nothing`); });
2116
+ pick(s.not, at + '.not');
2117
+ };
2118
+ arr(m.rules).forEach((r, i) => {
2119
+ const at = `rules[${i}]`;
2120
+ if (!r || typeof r !== 'object') return err(at, 'a rule is an object with an id');
2121
+ if (typeof r.id !== 'string' || !r.id) err(at + '.id', 'a rule needs an id'); else if (ruleIds.has(r.id)) err(at + '.id', `rule id "${r.id}" is used twice`); else ruleIds.add(r.id);
2122
+ if (!r.from && !r.to && !r.noCycles) err(at, 'a rule needs from, to or noCycles');
2123
+ pick(r.from, at + '.from'); pick(r.to, at + '.to');
2124
+ arr(r.views).forEach((v, j) => { if (!vids.has(v)) err(`${at}.views[${j}]`, `"${v}" is not a view`); });
2125
+ });
2126
+ // stories (#69): each step's view, and the cards, lines and tour it names there
2127
+ if (m.stories != null && !Array.isArray(m.stories)) err('stories', 'stories is a list of stories');
2128
+ const sids = new Set(), lines = new Set([...lineIds, ...views.flatMap(v => arr(v.edges).filter(e => e && e.id != null).map(e => e.id))]);
2129
+ arr(m.stories).forEach((s, i) => {
2130
+ const at = `stories[${i}]`;
2131
+ if (!s || typeof s.id !== 'string' || !s.id) return err(at + '.id', 'a story needs an id');
2132
+ if (sids.has(s.id)) err(at + '.id', `story id "${s.id}" is used twice`); sids.add(s.id);
2133
+ if (!arr(s.steps).length) err(at + '.steps', 'a story needs steps');
2134
+ arr(s.steps).forEach((st, j) => {
2135
+ const p = `${at}.steps[${j}]`, v = views.find(x => x.id === (st && st.view));
2136
+ if (!v) return err(p + '.view', `"${st && st.view}" is not a view`);
2137
+ const mine = new Set(arr(v.nodeIds)), named = (x, q) => { // a card of the view or a line ("a->b#0" names a line with no id)
2138
+ if (typeof x === 'string' && !mine.has(x) && !lines.has(x) && !x.includes('->')) err(q, `"${x}" is neither one of view "${v.id}"'s cards nor a line`); };
2139
+ named(st.select, p + '.select');
2140
+ arr(st.highlight).forEach((x, k) => named(x, `${p}.highlight[${k}]`));
2141
+ arr(st.tour).forEach((x, k) => { if (!mine.has(x)) err(`${p}.tour[${k}]`, `"${x}" is not one of the view's cards`); });
2142
+ const c = st.camera, fit = ': the step shows the whole view';
2143
+ if (c && typeof c === 'object' && c.card != null) { if (!mine.has(c.card)) warn(p + '.camera.card', `"${c.card}" is not one of the view's cards${fit}`); }
2144
+ else if (c != null && c !== 'fit' && !(c && Number.isFinite(c.x) && Number.isFinite(c.y))) warn(p + '.camera', `the camera is "fit", { x, y, zoom } or { card, zoom }${fit}`);
2145
+ if (st.duration != null && !(st.duration > 0)) warn(p + '.duration', `duration ${JSON.stringify(st.duration)} is not a number of ms: 4000`);
2146
+ });
2147
+ });
2148
+ return out;
2149
+ }
2150
+
2151
+ /* ================= rules (#70): what a model says must hold =================
2152
+ A rule forbids lines from the cards `from` picks to the cards `to` picks (a side left out picks every card), or, with
2153
+ noCycles, boxes that reach each other through their lines. A line counts between the two boxes that hold its ends right
2154
+ under the box (or view) they share, so a box is weighed against its siblings, at every depth, and a line into a box's
2155
+ own inner box counts for nothing. A selector picks cards by id, kind (as the map draws it: "db" is a database) or box
2156
+ (at any depth), each a value or a list; every key it has must hold, and `not` turns a selector round. A line's end on
2157
+ a box (#28) is that box: in it, and of no kind. A rule that names a box (a container, or a box's id, in from, to or a
2158
+ not) holds only in the views that draw one of the boxes it names, as a box belongs to its view; any other rule holds in
2159
+ every view. `views` narrows that further. */
2160
+ const boxesNamed = (s, isBox) => !s || typeof s !== 'object' ? []
2161
+ : [...[s.container].flat(), ...[s.id].flat().filter(isBox), ...boxesNamed(s.not, isBox)].filter(x => x != null);
2162
+ const picks = (s, end) => !s || ((s.id == null || [s.id].flat().includes(end.id))
2163
+ && (s.kind == null || [s.kind].flat().some(k => end.kind != null && kindOf(k, true) === end.kind))
2164
+ && (s.container == null || [s.container].flat().some(c => end.boxes.includes(c)))
2165
+ && (s.not == null || !picks(s.not, end)));
2166
+ const andList = xs => xs.length > 1 ? xs.slice(0, -1).join(', ') + ' and ' + xs[xs.length - 1] : xs.join(''), arrOf = x => (Array.isArray(x) ? x : []);
2167
+ /** Every rule the model breaks, in each view that draws it broken: a line a rule forbids, or a cycle of boxes. */
2168
+ export function rules(model) {
2169
+ const list = arrOf(model && model.rules).filter(r => r && typeof r === 'object' && (r.from || r.to || r.noCycles));
2170
+ if (!list.length) return [];
2171
+ const { views, N, nodeIn, viewEdges } = mapContext(model), out = [];
2172
+ const anyBox = new Set(views.flatMap(v => arrOf(v.containers).map(c => c && c.id).filter(Boolean)));
2173
+ const named = new Map(list.map(r => [r, [r.from, r.to].flatMap(s => boxesNamed(s, id => anyBox.has(id) && !N[id]))]));
2174
+ views.forEach(v => {
2175
+ const cs = arrOf(v.containers).filter(c => c && c.id), box = {}; cs.forEach(c => { box[c.id] ||= c; });
2176
+ const order = Object.values(box).map(c => c.id), home = {}; cs.forEach(c => arrOf(c.nodeIds).forEach(id => { home[id] ??= c.id; }));
2177
+ const chain = b => { const c = []; for (const seen = new Set(); b != null && box[b] && !seen.has(b); b = box[b].parent) { seen.add(b); c.unshift(b); } return c; }; // outermost first
2178
+ const end = id => box[id] ? { id, kind: null, boxes: chain(id), label: box[id].label || id }
2179
+ : { id, kind: kindOf(nodeIn(id, v.id).kind, true), boxes: chain(home[id]), label: nodeIn(id, v.id).label || id };
2180
+ // a target names the drawn line, which may stand for several edges: grouped as prepEdges groups them (parallel edges, and
2181
+ // A→B with B→A, unless keepPairs), in its order, so select() finds it
2182
+ // ponytail: a copy of prepEdges' grouping; share one helper if that grouping changes
2183
+ const lines = viewEdges(v), par = new Map(), drawn = new Map(), ends = new Map();
2184
+ lines.forEach(e => { const k = e.from + '>' + e.to + (v.keepPairs ? '#' + e.id : ''); par.has(k) ? par.get(k).push(e) : par.set(k, [e]); });
2185
+ par.forEach(es => { const back = !v.keepPairs && ends.get(es[0].to + '>' + es[0].from);
2186
+ if (back) back.push(...es); else ends.set(es[0].from + '>' + es[0].to, es); es.forEach(e => drawn.set(e, back || es)); });
2187
+ const line = e => ({ type: 'edge', ids: [...new Set(drawn.get(e).map(x => x._id))], view: v.id });
2188
+ let rings = null; // the cycles of boxes, worked out for the first noCycles rule: [boxes, the lines between them]
2189
+ const cycles = () => {
2190
+ const next = {}; // box -> Map(box -> the lines from the one to the other)
2191
+ lines.forEach(e => { const A = end(e.from).boxes, B = end(e.to).boxes; let i = 0; while (i < A.length && i < B.length && A[i] === B[i]) i++;
2192
+ if (i < A.length && i < B.length) { const m = next[A[i]] ||= new Map(); m.set(B[i], [...(m.get(B[i]) || []), e]); } });
2193
+ const reach = b => { const seen = new Set(), todo = [b]; while (todo.length) for (const c of (next[todo.pop()] || new Map()).keys()) if (!seen.has(c)) { seen.add(c); todo.push(c); } return seen; };
2194
+ const R = Object.fromEntries(order.map(b => [b, reach(b)])), done = new Set(), found = [];
2195
+ order.forEach(b => { if (done.has(b) || !R[b].has(b)) return;
2196
+ const ring = order.filter(c => R[b].has(c) && R[c].has(b)); ring.forEach(c => done.add(c));
2197
+ found.push([ring, ring.flatMap(a => ring.flatMap(c => (next[a] && next[a].get(c)) || []))]); });
2198
+ return found;
2199
+ };
2200
+ list.forEach(r => {
2201
+ if (Array.isArray(r.views) && !r.views.includes(v.id)) return;
2202
+ if (named.get(r).length && !named.get(r).some(b => box[b])) return; // it names boxes, none of them this view's
2203
+ const breaks = `breaks rule "${r.title || r.id}"`;
2204
+ if (r.noCycles) (rings ||= cycles()).forEach(([ring, via]) => out.push({ rule: r.id, view: v.id,
2205
+ message: `view ${v.id}: boxes ${andList(ring.map(c => `"${box[c].label || c}"`))} form a cycle, which ${breaks}`,
2206
+ targets: [...ring.map(c => ({ type: 'container', id: c, view: v.id })), ...[...new Set(via.map(e => drawn.get(e)))].map(es => line(es[0]))] }));
2207
+ else { const said = new Set(); // parallel edges that break it read as one line, once
2208
+ lines.forEach(e => { const a = end(e.from), b = end(e.to), message = `view ${v.id}: edge ${a.label}→${b.label} ${breaks}`;
2209
+ if (picks(r.from, a) && picks(r.to, b) && !said.has(message)) { said.add(message); out.push({ rule: r.id, view: v.id, message, targets: [line(e)] }); } }); }
2210
+ });
2211
+ });
2212
+ return out;
2213
+ }
2214
+
2215
+ /* ---------- change: diff (#66) and drift (#68) ----------
2216
+ Each returns a model marked with what the map draws already, so a page the CLI writes, or a still, shows the marks
2217
+ with nothing to call at run time: a card's change (a NEW or CHANGED tab; REMOVED is faded, dashed and struck
2218
+ through), a line's style (added: green and flowing; removed: rose, dashed and faded; security: rose and dashed;
2219
+ faded: its own colour, faint and still) and edited (an amber dot on the line). */
2220
+ const canon = x => JSON.stringify(x, (k, v) => v && typeof v === 'object' && !Array.isArray(v) ? Object.fromEntries(Object.keys(v).sort().map(f => [f, v[f]])) : v);
2221
+ // a line's identity in a list, as a Ref names it: its id, else "<from>-><to>#<n>"; "=<id>" for a view's reference to a model line
2222
+ const lineKeys = list => { const seen = {}; return list.map(e => typeof e === 'string' ? '=' + e : e && typeof e === 'object'
2223
+ ? (e.id != null ? String(e.id) : (k => `${k}#${seen[k] = (seen[k] ?? -1) + 1}`)(e.from + '->' + e.to)) : ''); };
2224
+ // a line that stands for several edges (A→B and B→A, parallel edges) shows the strongest of their marks: removed or
2225
+ // undrawn, then added, then changed (its edited dot, which a stronger mark hides); it fades as idle only when every one
2226
+ // of them is idle, and otherwise takes the first style that is no mark
2227
+ const markOf = all => { const top = all.find(x => x.style === 'removed' || x.drift === 'undrawn') || all.find(x => x.style === 'added');
2228
+ if (top) return { style: top.style, strong: true };
2229
+ return { style: all.every(x => x.style === 'faded') ? 'faded' : (all.find(x => x.style && x.style !== 'faded') || {}).style }; };
2230
+ // the lines a view draws: its own list (a model line by id, or one of its own), else every model line between its cards and boxes
2231
+ const linesOf = (m, v) => { const all = Array.isArray(m.edges) ? m.edges.filter(Boolean) : [];
2232
+ if (Array.isArray(v.edges)) { const byId = new Map(all.filter(e => e.id != null).map(e => [String(e.id), e])); return v.edges.map(e => typeof e === 'string' ? byId.get(e) : e).filter(Boolean); }
2233
+ const ids = new Set([...(v.nodeIds || []), ...(v.containers || []).map(c => c && c.id)]); return all.filter(e => ids.has(e.from) && ids.has(e.to)); };
2234
+
2235
+ /** What changed from one model to the next, as one model to draw: `after`, with each card and line marked added, changed
2236
+ * or removed (removed ones come back from `before`, in their views, boxes and places), a summary in words, and the
2237
+ * views that show a change. Cards and lines are matched by id (a line without one by its ends, as a Ref names it). */
2238
+ export function diff(before, after) {
2239
+ const arr = x => (Array.isArray(x) ? x : []), A = structuredClone(before || {}), out = structuredClone(after || {});
2240
+ const how = new Map(), was = new Map(), said = []; // each marked card or line: added, changed or removed; a changed one's [before, after]
2241
+ const card = (x, h) => ({ ...x, change: h });
2242
+ const line = (x, h) => ({ ...x, change: h, ...(h === 'changed' ? { edited: true } : { style: h }) });
2243
+ function mark(old, now, put, keys = l => l.map(x => x && x.id)) { // now's items, marked; old's that left come back, marked removed
2244
+ const ko = keys(old), kn = keys(now), O = new Map(old.map((x, i) => [ko[i], x])), kept = new Set(kn);
2245
+ const res = now.map((x, i) => { if (!x || typeof x !== 'object') return x; const o = O.get(kn[i]);
2246
+ if (o && typeof o === 'object' && canon(o) === canon(x)) return x;
2247
+ const h = o && typeof o === 'object' ? 'changed' : 'added', y = put(x, h); how.set(y, h); if (h === 'changed') was.set(y, [o, x]); return y; });
2248
+ old.forEach((x, i) => { if (x && typeof x === 'object' && !kept.has(ko[i])) { const y = put(x, 'removed'); how.set(y, 'removed'); res.push(y); } });
2249
+ return res;
2250
+ }
2251
+ out.nodes = mark(arr(A.nodes), arr(out.nodes), card);
2252
+ out.edges = mark(arr(A.edges), arr(out.edges), line, lineKeys);
2253
+ const goneLines = new Set(out.edges.filter(e => how.get(e) === 'removed' && e.id != null).map(e => String(e.id)));
2254
+ const V0 = new Map(arr(A.views).filter(Boolean).map(v => [v.id, v])), V1 = new Set(arr(out.views).filter(Boolean).map(v => v.id));
2255
+ const moved = [], touched = new Set(); // cards put into or taken out of a view; views whose cards the diff marks there
2256
+ const views = arr(out.views).map(v => {
2257
+ const o = v && V0.get(v.id); if (!o) return v;
2258
+ const w = { ...v }, ids = arr(v.nodeIds), oldIds = arr(o.nodeIds);
2259
+ if (v.nodes || o.nodes) w.nodes = mark(arr(o.nodes), arr(v.nodes), card);
2260
+ const at = new Map([...out.nodes, ...arr(w.nodes)].filter(Boolean).map(n => [n.id, n]));
2261
+ const left = oldIds.filter(id => !ids.includes(id)), came = ids.filter(id => !oldIds.includes(id)), ov = { ...(v.nodeOverrides || {}) };
2262
+ w.nodeIds = [...ids, ...left];
2263
+ left.forEach(id => { if (how.get(at.get(id)) !== 'removed') { ov[id] = { ...(o.nodeOverrides || {})[id], change: 'removed' }; moved.push(['takes', id, v]); } });
2264
+ came.forEach(id => { if (at.get(id) && how.get(at.get(id)) !== 'added') { ov[id] = { ...ov[id], change: 'added' }; moved.push(['puts', id, v]); } });
2265
+ if (left.length || came.length) touched.add(v.id);
2266
+ if (Object.keys(ov).length) w.nodeOverrides = ov;
2267
+ if (left.length && Array.isArray(v.containers)) // a removed card stays in its box, and in its place on a view laid out by hand when that is free
2268
+ w.containers = v.containers.map(c => { const back = left.filter(id => c && arr(o.containers).some(b => b && b.id === c.id && arr(b.nodeIds).includes(id))); return back.length ? { ...c, nodeIds: [...arr(c.nodeIds), ...back] } : c; });
2269
+ if (left.length && v.layout && o.layout) { const L = { ...v.layout }, taken = new Set(Object.values(L).map(String));
2270
+ left.forEach(id => { const p = o.layout[id]; if (Array.isArray(p) && !taken.has(String(p))) { L[id] = p; taken.add(String(p)); } }); w.layout = L; }
2271
+ if (Array.isArray(v.edges)) { w.edges = mark(arr(o.edges), v.edges, line, lineKeys);
2272
+ arr(o.edges).forEach(e => { if (typeof e === 'string' && !v.edges.includes(e) && goneLines.has(e)) w.edges.push(e); }); } // a removed model line it showed
2273
+ return w;
2274
+ });
2275
+ arr(A.views).forEach(o => { if (!o || V1.has(o.id)) return; // a removed view comes back with everything in it removed
2276
+ const w = { ...o, nodeOverrides: { ...(o.nodeOverrides || {}) } };
2277
+ arr(o.nodeIds).forEach(id => { w.nodeOverrides[id] = { ...w.nodeOverrides[id], change: 'removed' }; });
2278
+ if (o.nodes) w.nodes = o.nodes.map(n => { const y = card(n, 'removed'); how.set(y, 'removed'); return y; });
2279
+ if (Array.isArray(o.edges)) w.edges = o.edges.map(e => e && typeof e === 'object' ? line(e, 'removed') : e);
2280
+ touched.add(o.id); views.push(w); });
2281
+ out.views = views;
2282
+
2283
+ // the summary: views, then cards, then lines, in model order; a line to a card that came or went goes with the card
2284
+ const cards = [...out.nodes, ...views.flatMap(v => arr(v && v.nodes))].filter(Boolean);
2285
+ const named = new Map([...views.flatMap(v => arr(v && v.containers)), ...cards].filter(Boolean).map(n => [n.id, n]));
2286
+ const name = id => String((named.get(id) || {}).label || id), cardHow = new Map(cards.filter(n => how.has(n)).map(n => [n.id, how.get(n)]));
2287
+ const lines = [...out.edges, ...views.flatMap(v => arr(v && v.edges).filter(e => e && typeof e === 'object'))];
2288
+ const diffKeys = ([o, x]) => [...new Set([...Object.keys(o), ...Object.keys(x)])].filter(k => canon(o[k]) !== canon(x[k]));
2289
+ views.forEach(v => { if (v && !V0.has(v.id)) said.push(`adds the view ${v.title || v.id}`); });
2290
+ arr(A.views).forEach(o => { if (o && !V1.has(o.id)) said.push(`removes the view ${o.title || o.id}`); });
2291
+ cards.forEach(n => { const h = how.get(n);
2292
+ if (h === 'added') { const ins = lines.filter(e => e.to === n.id && how.get(e) === 'added').map(e => name(e.from)), outs = lines.filter(e => e.from === n.id && how.get(e) === 'added').map(e => name(e.to));
2293
+ said.push(`adds ${name(n.id)}` + (ins.length === 1 && outs.length === 1 ? ` between ${ins[0]} and ${outs[0]}` : (ins.length ? `, from ${andList(ins)}` : '') + (outs.length ? `, to ${andList(outs)}` : ''))); }
2294
+ if (h === 'removed') said.push(`removes ${name(n.id)}`);
2295
+ if (h === 'changed') { const [o, x] = was.get(n), keys = diffKeys([o, x]), rest = keys.filter(k => k !== 'label');
2296
+ said.push((keys.includes('label') ? `renames ${o.label || o.id} to ${x.label || x.id}` : `changes ${name(n.id)}`) + (rest.length ? ` (${rest.join(', ')})` : '')); } });
2297
+ lines.forEach(e => { const h = how.get(e); if (!h || cardHow.get(e.from) === h && h !== 'changed' || cardHow.get(e.to) === h && h !== 'changed') return;
2298
+ const ends = `${name(e.from)} to ${name(e.to)}`, lab = e.label ? ` (${e.label})` : '';
2299
+ if (h === 'added') said.push(`adds a line from ${ends}${lab}`);
2300
+ if (h === 'removed') said.push(`removes the line from ${ends}${lab}`);
2301
+ if (h === 'changed') { const [o, x] = was.get(e);
2302
+ said.push(`changes the line from ${ends} (${diffKeys([o, x]).map(k => k === 'label' ? `label ${o.label ?? '-'} → ${x.label ?? '-'}` : k).join(', ')})`); } });
2303
+ moved.forEach(([verb, id, v]) => said.push(`${verb} ${name(id)} ${verb === 'puts' ? 'in' : 'out of'} the view ${v.title || v.id}`));
2304
+
2305
+ // the views that show a change: a card or line the diff marks, or a card it put in or took out
2306
+ const changed = views.filter(v => v && (touched.has(v.id) || arr(v.nodeIds).some(id => cardHow.has(id)) || linesOf(out, v).some(e => how.has(e)))).map(v => v.id);
2307
+ return { model: out, summary: said, views: changed };
2308
+ }
2309
+
2310
+ /** The map against the traffic seen: a line that carries traffic but is not drawn comes in, dashed rose ("not drawn",
2311
+ * with its calls); a drawn line between two cards that traffic was seen at, with none of its own, fades. `observed`:
2312
+ * [{ from, to, count }], or a Jaeger or Tempo dependency list ([{ parent, child, callCount }], or { data: [...] }). A
2313
+ * service is a card by `options.match[service]`, else by id, else by label (case aside). Counts: lines that carried
2314
+ * traffic, came in undrawn, are idle, or could not be judged (an end traffic never reached), and services with no card,
2315
+ * each a count of model edges (a drawn line that stands for several fades only when all are idle); views: those that
2316
+ * show drift. */
2317
+ export function drift(model, observed, options = {}) {
2318
+ const arr = x => (Array.isArray(x) ? x : []), out = structuredClone(model || {}), match = options.match || {};
2319
+ const list = Array.isArray(observed) ? observed : arr(observed && observed.data);
2320
+ const cards = [...arr(out.nodes), ...arr(out.views).flatMap(v => arr(v && v.nodes))].filter(n => n && n.id != null);
2321
+ const ids = new Set(cards.map(n => String(n.id))), lower = new Map();
2322
+ cards.forEach(n => [n.id, n.label].forEach(s => { if (s != null && !lower.has(String(s).toLowerCase())) lower.set(String(s).toLowerCase(), String(n.id)); }));
2323
+ const cardOf = s => { s = String(s ?? ''); const id = Object.hasOwn(match, s) ? String(match[s]) : ids.has(s) ? s : lower.get(s.toLowerCase()); return ids.has(id) ? id : null; };
2324
+ const seen = new Map(), live = new Set(), unmatched = new Set(); let counted = false;
2325
+ list.forEach(o => { if (!o || typeof o !== 'object') return;
2326
+ const f = o.from ?? o.parent, t = o.to ?? o.child, n = o.count ?? o.callCount, a = cardOf(f), b = cardOf(t);
2327
+ if (a == null) unmatched.add(String(f)); if (b == null) unmatched.add(String(t));
2328
+ if (a == null || b == null || a === b) return;
2329
+ live.add(a); live.add(b);
2330
+ if (n != null) { counted = true; if (!(+n > 0)) return; } // seen with no calls: both ends were watched, the line carried nothing
2331
+ const k = a + '\n' + b, s = seen.get(k) || { a, b, n: 0 }; s.n += +n || 0; seen.set(k, s); });
2332
+ const counts = { carried: 0, undrawn: 0, idle: 0, unknown: 0, unmatched: [...unmatched].sort() }, drawn = new Set();
2333
+ const judge = e => { if (!e || typeof e !== 'object') return e; const k = e.from + '\n' + e.to;
2334
+ if (seen.has(k)) { drawn.add(k); counts.carried++; return e; }
2335
+ if (live.has(String(e.from)) && live.has(String(e.to))) { counts.idle++; return { ...e, style: 'faded', drift: 'idle' }; }
2336
+ counts.unknown++; return e; };
2337
+ out.edges = arr(out.edges).map(judge);
2338
+ arr(out.views).forEach(v => { if (v && Array.isArray(v.edges)) v.edges = v.edges.map(judge); });
2339
+ seen.forEach(({ a, b, n }, k) => { if (drawn.has(k)) return; counts.undrawn++;
2340
+ const e = { id: `drift:${a}->${b}`, from: a, to: b, label: 'not drawn', ...(counted ? { sublabel: `${n.toLocaleString('en-US')} calls` } : {}), style: 'security', drift: 'undrawn' };
2341
+ out.edges.push(e);
2342
+ arr(out.views).forEach(v => { if (v && Array.isArray(v.edges) && arr(v.nodeIds).includes(a) && arr(v.nodeIds).includes(b)) v.edges.push(e.id); }); });
2343
+ return { model: out, counts, views: arr(out.views).filter(v => v && linesOf(out, v).some(e => e.drift)).map(v => v.id) };
2344
+ }
2345
+
1866
2346
  let INSTANT = false; // the page's own hooks (#check, #still, #shot) draw without animation
1867
2347
  const reducedMotion = () => INSTANT || (typeof matchMedia === 'function' && matchMedia('(prefers-reduced-motion: reduce)').matches);
1868
2348
  let SHEET = null;
@@ -1891,7 +2371,8 @@ export function render(container, model, options = {}) {
1891
2371
  // text is measured in the host's font (--um-sans); measure: false keeps the font-independent estimate
1892
2372
  const own = typeof options.measure === 'function' ? options.measure : null;
1893
2373
  let measure = options.measure === false ? null : own || hostMeasure(host), current = model;
1894
- let ctx = mapContext(model, { measure, kinds: !!P.kinds }); // kinds: the standalone page guesses line kinds from label words, for its legend
2374
+ const expanded = {}; // per view: the cards unfolded in place (#71), in the order they unfolded; in this handle only, never in the model
2375
+ let ctx = mapContext(model, { measure, kinds: !!P.kinds, expand: expanded }); // kinds: the standalone page guesses line kinds from label words, for its legend
1895
2376
  const root = host.attachShadow({ mode: 'open', delegatesFocus: true }); // host.focus() lands in the map (on the selected card)
1896
2377
  root.adoptedStyleSheets = [sheet()];
1897
2378
  root.innerHTML = `<div class="um-live" role="status" aria-live="polite"></div><div class="stage" tabindex="-1">
@@ -1943,10 +2424,11 @@ export function render(container, model, options = {}) {
1943
2424
  cam.setSvg(svg, geo.bounds);
1944
2425
  const ub = q('[data-z="up"]'); if (ub) ub.disabled = !upOf();
1945
2426
  if (options.minimap) buildMini();
1946
- if (!geo.hub && geo.nodes.length && geo.nodes.every(n => Number.isFinite(n.col) && Number.isFinite(n.row))) placed[vid] = Object.fromEntries(geo.nodes.map(n => [n.id, [n.col, n.row]]));
2427
+ if (!geo.hub && geo.nodes.length && !(expanded[vid] || []).length && geo.nodes.every(n => Number.isFinite(n.col) && Number.isFinite(n.row))) placed[vid] = Object.fromEntries(geo.nodes.map(n => [n.id, [n.col, n.row]])); // unfolded: the places folded stay
1947
2428
  const had = sel; sel = null; tabKeys(); paint();
1948
2429
  more = [];
1949
2430
  if (had) emitSel();
2431
+ endTrip(); drawMarks(); // a new picture: a tour or replay on the old one ends; the host's marks go on again (#63)
1950
2432
  if (P.onMount) P.onMount(inner);
1951
2433
  }
1952
2434
 
@@ -1956,7 +2438,7 @@ export function render(container, model, options = {}) {
1956
2438
  function clearOf(base) {
1957
2439
  if (!cam || !geo) return base;
1958
2440
  const [W, H] = cam.size(), key = JSON.stringify([W, H, base]); if (clear && clear.geo === geo && clear.key === key) return clear.sets;
1959
- const boxes = [...stage.querySelectorAll('.ctl, .minimap')].map(x => x.getBoundingClientRect()).filter(r => r.width && r.height);
2441
+ const boxes = [...stage.querySelectorAll('.ctl, .minimap, .um-story')].map(x => x.getBoundingClientRect()).filter(r => r.width && r.height);
1960
2442
  let sets = base; // none shown, or no layout (a test DOM): as before
1961
2443
  if (boxes.length) {
1962
2444
  const S = stage.getBoundingClientRect(), top = Math.min(...boxes.map(r => r.top)) - S.top - 8, left = Math.min(...boxes.map(r => r.left)) - S.left - 8;
@@ -2128,6 +2610,35 @@ export function render(container, model, options = {}) {
2128
2610
  if (!stop && ctx.V[c]) go(c, { via: nid });
2129
2611
  }
2130
2612
  const up = () => { const p = upOf(); if (p) go(p); else status('Already at the top'); };
2613
+ /* unfolding in place (#71): a stacked card opens into a box on this view (mapContext's unfold), and folds back. Off in
2614
+ an editable map: an edit names the model's own views and boxes, and an unfolded box is neither. */
2615
+ const unfoldBox = id => { const f = ((ctx.V[cur] || {}).containers || []).find(b => b && b.id === id); return f && f.unfolds ? f : null; };
2616
+ const foldOf = id => { const bs = (ctx.V[cur] || {}).containers || []; let b = bs.filter(x => x && (x.nodeIds || []).includes(id)).pop(); // the unfolded box a card is in
2617
+ for (let k = 0; b && k < 64; k++, b = bs.find(x => x && x.id === b.parent)) if (b.unfolds) return b.id; return null; };
2618
+ function setUnfold(nid, on) { // true when the picture changed
2619
+ const list = expanded[cur] || [];
2620
+ if (E || dead || !cur || (on ? list.includes(nid) || !ctx.unfoldable(nid, cur) : !list.includes(nid))) return false;
2621
+ expanded[cur] = on ? [...list, nid] : list.filter(x => x !== nid);
2622
+ update(current, false, true);
2623
+ if ((expanded[cur] || []).includes(nid) !== on) return false; // the model would not have it after all
2624
+ const f = on && (geo.families || []).find(x => x.id === nid);
2625
+ if (f) { cam.reveal({ x: f.x, y: f.y, w: f.w, h: f.h }); select({ type: 'container', id: nid }, { reveal: false }); }
2626
+ else if (!on && geo.byId[nid]) select(nodeRef(nid));
2627
+ say(`${nameOf(nid)} ${on ? 'unfolded here' : 'folded'}`);
2628
+ return true;
2629
+ }
2630
+ function gesture(id, how) { // E, or Alt + click: a stacked card unfolds; an unfolded box, or a card in one, folds it
2631
+ if (E || !id || !geo) return false;
2632
+ const c = geo.byId[id] ? ctx.opensOf(id, cur) : null, on = !!c && (!ctx.V[c] || !!ctx.unfoldable(id, cur)), nid = on ? id : unfoldBox(id) ? id : foldOf(id);
2633
+ if (!nid) return false;
2634
+ let stop = false; // the host may take it, or load a view it has not yet and update() right here
2635
+ emit('expand', { target: nodeRef(nid), view: ctx.opensOf(nid, cur), expanded: on, preventDefault() { stop = true; } });
2636
+ if (stop || dead) return true;
2637
+ if (!setUnfold(nid, on)) { if (on) status(ctx.V[ctx.opensOf(nid, cur)] ? 'Nothing more to show here' : 'Its inner view is not loaded yet'); return true; }
2638
+ if (how === 'key') { const f = on && (geo.families || []).find(x => x.id === nid), inside = n => n.x >= f.x && n.y >= f.y && n.x + n.w <= f.x + f.w && n.y + n.h <= f.y + f.h;
2639
+ const n0 = f ? geo.nodes.filter(inside).sort((a, b) => a.x - b.x || a.y - b.y)[0] : null; focusMap(n0 ? nodeRef(n0.id) : sel); } // keys go on inside, or on the card
2640
+ return true;
2641
+ }
2131
2642
  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); }
2132
2643
  function setFlow(on) {
2133
2644
  stage.classList.toggle('flowing', on);
@@ -2139,6 +2650,7 @@ export function render(container, model, options = {}) {
2139
2650
  /* keys on the canvas; the page forwards the same keys when the focus is elsewhere */
2140
2651
  function key(e) {
2141
2652
  const k = e.key, g = e.target && e.target.closest && e.target.closest('.node');
2653
+ if (story) { const r = storyKey(e); if (r != null) return r; } // a story's keys first (#69)
2142
2654
  if (E && editKey(e)) return true;
2143
2655
  if (e.altKey) return false;
2144
2656
  const lk = e.target && e.target.closest && e.target.closest('.line-key'), tk = e.target && e.target.closest && e.target.closest('.tab-key, .family [data-badge]');
@@ -2146,6 +2658,7 @@ export function render(container, model, options = {}) {
2146
2658
  if (k === 'Enter' && lk && geo.edges[+lk.dataset.i]) select(edgeRef(geo.edges[+lk.dataset.i]), { again: true }); // as a click on the line (#43)
2147
2659
  else if (k === 'Enter' && tk) { const fam = tk.closest('.family'), ref = badgeRef(tk.dataset.node, fam ? fam.dataset.family : null, +tk.dataset.badge); if (activate(ref, e)) select(ref); }
2148
2660
  else if (k === 'Enter' && card) { const ref = chipRef(card, +ck.dataset.chip); if (activate(ref, e)) select(ref); } // as a click on the chip
2661
+ else if (k === 'e' || k === 'E') return gesture(g ? g.dataset.id : sel && (sel.type === 'node' || sel.type === 'container') ? sel.id : null, 'key'); // unfold here, or fold back (#71)
2149
2662
  else if (k === '+' || k === '=') cam.zoomAt(1.4, null, null, true);
2150
2663
  else if (k === '-' || k === '_') cam.zoomAt(1 / 1.4, null, null, true);
2151
2664
  else if (k === '0') cam.fit();
@@ -2347,6 +2860,8 @@ export function render(container, model, options = {}) {
2347
2860
  if (t.closest('.ctl, .minimap')) return;
2348
2861
  const g = t.closest('.node'), chip = t.closest('[data-chip]'), badge = t.closest('[data-badge]'), eg = t.closest('.edge-g, .e-lab'), fam = t.closest('.family');
2349
2862
  if (g && t.closest('.open-hint')) { if (e.detail <= 1) open(g.dataset.id); return; } // the ⤢ badge opens (a double-click on it opens once)
2863
+ if (fam && t.closest('.fold-hint')) { if (e.detail <= 1) gesture(fam.dataset.family); return; } // an unfolded box's ⤡ tab folds it (#71)
2864
+ if (g && e.altKey && !E && e.detail <= 1 && gesture(g.dataset.id)) return; // Alt + click: unfold the card here, or fold the box it is in
2350
2865
  let ref = null;
2351
2866
  if (g && badge) ref = badgeRef(g.dataset.id, null, +badge.dataset.badge);
2352
2867
  else if (g && chip) ref = chipRef(g, +chip.dataset.chip);
@@ -2362,7 +2877,7 @@ export function render(container, model, options = {}) {
2362
2877
  select(null);
2363
2878
  if (P.onBackground) P.onBackground();
2364
2879
  });
2365
- stage.addEventListener('dblclick', e => { const g = e.target.closest('.node'); if (!g) return; e.preventDefault(); if (e.target.closest('.open-hint')) return;
2880
+ stage.addEventListener('dblclick', e => { const g = e.target.closest('.node'); if (!g) return; e.preventDefault(); if (e.target.closest('.open-hint') || (e.altKey && !E)) return; // Alt: its click unfolded
2366
2881
  if (can('rename') && sel && sel.type === 'node' && sel.id === g.dataset.id && e.target.closest('.n-label')) return rename(sel); // a selected card's name edits
2367
2882
  open(g.dataset.id); });
2368
2883
  stage.addEventListener('keydown', e => { if (e.defaultPrevented || e.metaKey || e.ctrlKey || (e.altKey && !E) || e.target.closest('input')) return; if (key(e)) { e.preventDefault(); e.stopPropagation(); } });
@@ -2382,6 +2897,7 @@ export function render(container, model, options = {}) {
2382
2897
  /* the picture as a file: CSS variables resolved into attributes so it stands alone. It is drawn at rest: no
2383
2898
  selection, hover, flow animation or edit handles, whatever the screen shows; the screen is left as it was. */
2384
2899
  function standalone(scale = 1, vid = cur, fontCSS = '') {
2900
+ if (settle) settle(); // an export right after update() draws where things end up, not mid-ease
2385
2901
  if (!svg || !geo) return '';
2386
2902
  let s = svg, g = geo, off = null;
2387
2903
  if (vid !== cur) { // another view: drawn off screen, inside the shadow root so the same styles apply
@@ -2390,6 +2906,7 @@ export function render(container, model, options = {}) {
2390
2906
  off = document.createElement('div'); off.style.cssText = 'position:absolute;left:-100000px;top:0;width:10px;height:10px;overflow:hidden;pointer-events:none';
2391
2907
  off.innerHTML = c.markup; stage.parentNode.appendChild(off); s = off.querySelector('svg'); s.classList.add('map-svg'); g = c.g;
2392
2908
  }
2909
+ if (off) drawMarks(s, g); // the host's marks, on another view too (#63)
2393
2910
  const s0 = sel, flowing = stage.classList.contains('flowing'), hov = [...svg.querySelectorAll('.hov')];
2394
2911
  sel = null; paint(); stage.classList.remove('flowing'); hov.forEach(x => x.classList.remove('hov')); stage.classList.add('um-still');
2395
2912
  const clone = s.cloneNode(true), b = g.bounds;
@@ -2401,7 +2918,7 @@ export function render(container, model, options = {}) {
2401
2918
  props.forEach(p => { const v = cs.getPropertyValue(p); if (v) d.style.setProperty(p, v); });
2402
2919
  if (cs.textTransform === 'uppercase' && !d.childElementCount) d.textContent = d.textContent.toUpperCase(); }); } // tab text, as drawn
2403
2920
  finally { stage.classList.remove('um-still'); hov.forEach(x => x.classList.add('hov')); if (flowing) stage.classList.add('flowing'); sel = s0; paint(); if (off) off.remove(); }
2404
- clone.querySelectorAll('title, .flowline, .um-edit, .um-ghost, .um-link, .keys, .um-key').forEach(t => t.remove());
2921
+ clone.querySelectorAll('title, .flowline, .um-edit, .um-ghost, .um-link, .keys, .um-key, .um-trip').forEach(t => t.remove());
2405
2922
  clone.setAttribute('role', 'img'); clone.querySelectorAll('[tabindex], [aria-pressed]').forEach(t => ['tabindex', 'role', 'aria-pressed'].forEach(a => t.removeAttribute(a)));
2406
2923
  clone.setAttribute('viewBox', `${b.x} ${b.y} ${b.w} ${b.h}`); clone.setAttribute('width', b.w * scale); clone.setAttribute('height', b.h * scale);
2407
2924
  const bg = document.createElementNS('http://www.w3.org/2000/svg', 'rect');
@@ -2411,42 +2928,321 @@ export function render(container, model, options = {}) {
2411
2928
  return new XMLSerializer().serializeToString(clone);
2412
2929
  }
2413
2930
 
2414
- function update(model2, relayout = false) {
2931
+ /* the time-lapse (#67): an update that redraws the view eases into it. Cards that moved glide from where they were; new
2932
+ cards, and lines whose route changed, fade in; cards and lines that left fade out where they were, copied from the
2933
+ picture before (as nothing that can be clicked, focused, found or exported). CSS on the new picture only: the
2934
+ geometry is the update's own. Nothing moves under reduced motion. */
2935
+ let settle = null; // ends an ease still running (before an export, or the next update)
2936
+ function easeIn(oldGeo, oldSvg) {
2937
+ if (settle) settle();
2938
+ if (options.ease === false || reducedMotion() || !oldGeo || !oldSvg || !geo || !svg || oldSvg === svg) return;
2939
+ const T = 600, moves = [], key = e => edgeRefIds(e).join(' ') || e.from + '>' + e.to, all = (s, q) => [...s.querySelectorAll(q)];
2940
+ const was = new Map(oldGeo.nodes.map(n => [n.id, n])), now = new Set(geo.nodes.map(n => n.id));
2941
+ const lines = new Map(oldGeo.edges.map((e, i) => [key(e), [e, i]])), kept = new Set(geo.edges.map(key));
2942
+ geo.nodes.forEach(n => { const g = svg.querySelector(`.node[data-id="${CSS.escape(n.id)}"]`), o = was.get(n.id); if (!g) return;
2943
+ if (!o) moves.push([g, 'opacity', '0', 0]);
2944
+ else if (o.x !== n.x || o.y !== n.y) moves.push([g, 'transform', `translate(${o.x - n.x}px, ${o.y - n.y}px)`, 0]); });
2945
+ geo.edges.forEach((e, i) => { const o = lines.get(key(e)); if (o && canon(o[0].pts) === canon(e.pts)) return;
2946
+ all(svg, `.edge-g[data-i="${i}"], .e-lab[data-i="${i}"]`).forEach(x => moves.push([x, 'opacity', '0', T / 2])); });
2947
+ const gone = document.createElementNS('http://www.w3.org/2000/svg', 'g');
2948
+ gone.setAttribute('class', 'um-leaving'); gone.setAttribute('aria-hidden', 'true'); gone.style.pointerEvents = 'none';
2949
+ const leave = x => { const c = x.cloneNode(true);
2950
+ [c, ...c.querySelectorAll('*')].forEach(y => { ['tabindex', 'role', 'data-id', 'data-i', 'data-from', 'data-to', 'data-o', 'data-t', 'data-chip', 'data-badge'].forEach(a => y.removeAttribute(a)); y.classList.remove('node', 'edge-g', 'e-lab', 'sel', 'hot', 'hov'); });
2951
+ gone.appendChild(c); };
2952
+ oldGeo.nodes.forEach(n => { if (!now.has(n.id)) all(oldSvg, `.node[data-id="${CSS.escape(n.id)}"]`).forEach(leave); });
2953
+ lines.forEach(([, i], k) => { if (!kept.has(k)) all(oldSvg, `.edge-g[data-i="${i}"], .e-lab[data-i="${i}"]`).forEach(leave); });
2954
+ if (!moves.length && !gone.childNodes.length) return;
2955
+ svg.appendChild(gone);
2956
+ moves.forEach(([el, p, v]) => { el.style.transition = 'none'; el.style.setProperty(p, v); });
2957
+ svg.getBoundingClientRect(); // the start, drawn: each change below is a transition from it
2958
+ moves.forEach(([el, p, , d]) => { el.style.transition = `${p} ${T}ms ease ${d}ms`; el.style.removeProperty(p); });
2959
+ gone.style.transition = `opacity ${T / 2}ms ease`; gone.style.opacity = '0';
2960
+ const t = later(() => settle && settle(), T * 2);
2961
+ settle = () => { clearTimeout(t); timers.delete(t); moves.forEach(([el]) => el.style.removeProperty('transition')); gone.remove(); settle = null; };
2962
+ }
2963
+
2964
+ function update(model2, relayout = false, unfolding = false) { // unfolding: the same model, a card unfolded or folded
2415
2965
  if (dead) return;
2416
2966
  // an identical model: nothing to redraw, unless the host accepted a move or reorder that only changed a position
2417
- if (!relayout && !Object.keys(hints).length && !Object.keys(slotsH).length && JSON.stringify(model2) === JSON.stringify(current)) { addHints.length = 0; return; }
2967
+ if (!relayout && !unfolding && !Object.keys(hints).length && !Object.keys(slotsH).length && JSON.stringify(model2) === JSON.stringify(current)) { addHints.length = 0; return; }
2418
2968
  const old = ctx, keys = {}, before = new Set(geo ? geo.nodes.map(n => n.id) : []), wantNew = selectNew;
2419
2969
  old.views.forEach(v => { keys[v.id] = relayout ? null : old.viewKey(v.id); });
2420
2970
  current = model2;
2421
- ctx = mapContext(withPlaces(model2), { measure, kinds: !!P.kinds, prev: options.stableLayout === false ? null : old.planCache });
2971
+ ctx = mapContext(withPlaces(model2), { measure, kinds: !!P.kinds, prev: options.stableLayout === false ? null : old.planCache, expand: expanded });
2972
+ Object.keys(expanded).forEach(v => { expanded[v] = ctx.unfolded[v] || []; }); // a card or inner view the model no longer has folds
2422
2973
  [hints, slotsH].forEach(o => Object.keys(o).forEach(k => delete o[k])); addHints.length = 0; selectNew = false;
2423
2974
  ctx.views.forEach(v => { const id = v.id; if (keys[id] == null || keys[id] !== ctx.viewKey(id)) return;
2424
2975
  if (id in old.planCache) ctx.planCache[id] = old.planCache[id];
2425
2976
  if (old.layoutCache[id]) ctx.layoutCache[id] = old.layoutCache[id];
2426
- if (old.geoCache[id]) ctx.geoCache[id] = old.geoCache[id]; });
2977
+ if (old.geoCache[id] && JSON.stringify(old.brokenIn(id)) === JSON.stringify(ctx.brokenIn(id))) ctx.geoCache[id] = old.geoCache[id]; }); // the same rules broken (#70)
2427
2978
  if (!cur) return;
2428
2979
  if (!ctx.V[cur]) { const from = cur; mount(ctx.rootId); cam.set(startView()); cam.touched = false; emit('viewchange', { view: cur, from }); return; }
2429
2980
  if (ctx.geoCache[cur] && keys[cur] === ctx.viewKey(cur)) return; // this view's picture did not change
2430
- const keep = sel, keepMore = more, touched = cam.touched;
2981
+ const keep = sel, keepMore = more, touched = cam.touched, prev = [geo, svg];
2431
2982
  mount(cur);
2432
2983
  if (touched) cam.set({}); else cam.set(startView());
2433
2984
  cam.touched = touched;
2434
2985
  const fresh = geo.nodes.filter(n => !before.has(n.id));
2435
2986
  if (wantNew && fresh.length === 1) select(nodeRef(fresh[0].id), { reveal: false });
2436
2987
  else if (keep && exists(keep)) { sel = keep; more = keep.type === 'node' ? keepMore.filter(id => geo.byId[id]) : []; paint(); emitSel(); }
2988
+ if (!relayout) easeIn(...prev); // a font that came in late moves nothing on purpose: no ease
2437
2989
  }
2438
2990
 
2439
2991
  function destroy() {
2440
2992
  if (dead) return;
2441
- dead = true; if (ro) ro.disconnect(); cancelAnimationFrame(rz);
2993
+ dead = true; if (ro) ro.disconnect(); cancelAnimationFrame(rz); stopStory();
2442
2994
  timers.forEach(clearTimeout); timers.clear();
2443
2995
  if (cam) cam.stopAnimation();
2444
2996
  host.remove();
2445
2997
  Object.keys(subs).forEach(k => delete subs[k]);
2446
2998
  }
2447
2999
 
2448
- const inner = { ctx: () => ctx, geo: () => geo, cam: () => cam, svg: () => svg, stage, host, go, open, up, flash, setFlow, pause, key, startView, standalone,
2449
- select, busy: () => busy, flowing: () => stage.classList.contains('flowing'), view: () => cur };
3000
+ /* ---------- marks: overlay(), highlight(), tour() and replay() (#62, #63, #65) ----------
3001
+ The host's marks, drawn over the picture without touching the model: named layers, stacked in the order first set.
3002
+ Each mount draws them again, so they survive update() and setView(); a card or line the view doesn't show is ignored.
3003
+ toSVG() and toPNG() draw them too, at rest: they are the host's, like the model (the selection and hover are not). */
3004
+ const layers = new Map(), DIM = .2, clamp01 = x => Math.min(1, Math.max(0, +x || 0));
3005
+ // a drawn line that stands for several edges takes the strongest of their marks: a pulsing one, then the most lit, then the fastest
3006
+ const strongest = ms => ms.filter(Boolean).sort((a, b) => (!!b.pulse - !!a.pulse) || ((b.level ?? 1) - (a.level ?? 1)) || ((b.speed ?? 1) - (a.speed ?? 1)))[0];
3007
+ function markOf(get) { // one card's or line's mark over every layer: each property from the latest layer that sets it, the lowest level
3008
+ let out = null;
3009
+ layers.forEach(L => { const m = get(L); if (!m && !L.dim) return; out ||= { level: 1 };
3010
+ if (m) for (const k in m) if (m[k] != null && k !== 'level') out[k] = m[k];
3011
+ out.level = Math.min(out.level, m ? clamp01(m.level ?? 1) : DIM); });
3012
+ return out;
3013
+ }
3014
+ const longest = pts => pts.slice(1).reduce((m, p, k) => Math.abs(p[0] - pts[k][0]) + Math.abs(p[1] - pts[k][1]) > Math.abs(m[1][0] - m[0][0]) + Math.abs(m[1][1] - m[0][1]) ? [pts[k], p] : m, [pts[0], pts[1]]);
3015
+ function drawMarks(s = svg, g = geo) {
3016
+ if (!s || !g) return;
3017
+ s.querySelectorAll('.um-rings, .um-marks, .um-glow').forEach(x => x.remove());
3018
+ s.querySelectorAll('.node[opacity], .edge-g[opacity], .e-lab[opacity]').forEach(x => x.removeAttribute('opacity'));
3019
+ s.querySelectorAll('.edge[style], .flowline[style]').forEach(x => x.removeAttribute('style'));
3020
+ s.querySelectorAll('.node[aria-description]').forEach(x => x.removeAttribute('aria-description'));
3021
+ s.querySelectorAll('[data-was]').forEach(x => { if (x.tagName === 'title') x.textContent = x.getAttribute('data-was'); else x.setAttribute('aria-label', x.getAttribute('data-was')); x.removeAttribute('data-was'); });
3022
+ if (s === svg) stage.classList.toggle('um-lit', [...layers.values()].some(L => L.dim));
3023
+ if (!layers.size) return;
3024
+ const tone = t => 'var(--um-' + String(t || 'accent').replace(/[^\w-]/g, '') + ')', op = m => m.level < 1 ? ` opacity="${m.level}"` : '';
3025
+ const cards = Object.fromEntries([...s.querySelectorAll('.node')].map(x => [x.dataset.id, x]));
3026
+ let under = '', over = '';
3027
+ g.nodes.forEach(n => { const m = markOf(L => (L.nodes || {})[n.id]), el = cards[n.id]; if (!m || !el) return;
3028
+ if (m.level < 1) el.setAttribute('opacity', m.level);
3029
+ if (m.tone || m.pulse) under += `<g${op(m)}><rect x="${n.x - 5}" y="${n.y - 5}" width="${n.w + 10}" height="${n.h + 10}" rx="14" class="um-ring${m.pulse ? ' um-pulse' : ''}" stroke="${tone(m.tone)}"/></g>`;
3030
+ const b = m.badge == null ? null : badgesOf({ badges: [m.badge] })[0]; if (!b) return; // a tab after the card's own
3031
+ const x = mapTabs(n).reduce((a, t) => a + t.w + 4, n.x + 12), w = b.text.toUpperCase().length * 6.4 + 10;
3032
+ over += `<g class="um-tab"${op(m)}><rect x="${x}" y="${n.y - 8}" width="${w}" height="15" rx="4" fill="${toneOf(b)}"/><text x="${x + w / 2}" y="${n.y + 3}" text-anchor="middle" class="tab-tx">${esc(b.text)}</text></g>`;
3033
+ el.setAttribute('aria-description', b.text); });
3034
+ const heads = (g.families || []).flatMap(f => [{ x: f.x + 8, y: f.y + 3, w: String(f.label || f.id).length * 6.6 + 8, h: 22 }, // a box's title, 11 px mono
3035
+ ...(f.tabs || []).map(t => ({ x: t.x, y: f.y - 8, w: t.w, h: 16 }))]); // and its tabs
3036
+ const pills = [], free = r => !g.nodes.some(n => hit(r, n, 8) || (n.tag && hit(r, n.tag, 2))) && !heads.some(h => hit(r, h, 2)) && !g.edges.some(o => o.lbl && hit(r, o.lbl, 2)) && !pills.some(p => hit(r, p, 2));
3037
+ g.edges.forEach((e, i) => { const m = markOf(L => strongest(edgeRefIds(e).map(x => (L.edges || {})[x]))), eg = s.querySelector(`.edge-g[data-i="${i}"]`); if (!m || !eg) return;
3038
+ const path = eg.querySelector('.edge'), flow = eg.querySelector('.flowline');
3039
+ if (m.level < 1) [eg, s.querySelector(`.e-lab[data-i="${i}"]`)].forEach(x => x && x.setAttribute('opacity', m.level));
3040
+ if (m.tone || m.pulse) path.insertAdjacentHTML('beforebegin', `<path d="${path.getAttribute('d')}" class="um-glow${m.pulse ? ' um-pulse' : ''}" stroke="${tone(m.tone)}"/>`);
3041
+ if (m.dash) path.setAttribute('style', 'stroke-dasharray: 7 5');
3042
+ if (m.speed != null && flow) flow.setAttribute('style', +m.speed > 0 ? `animation-duration: ${(2.4 / m.speed).toFixed(2)}s` : 'animation: none; opacity: 0');
3043
+ if (m.label == null || m.label === '') return;
3044
+ // the pill takes the first place that covers no card, box title or tab, line label or pill before it (#81): under the
3045
+ // line's own label (else on its longest run), then a few places along the line. The view is not laid out again for
3046
+ // it, so with no room at any, its text goes in the line's tooltip and accessible name instead
3047
+ // ponytail: six places, and another line may lie under one; scan along the line if too few find room
3048
+ const t = String(m.label), w = sansW(t, 10, .92) + 14, L = e.lbl, [a, b] = longest(e.pts), len = lenOf(e.pts);
3049
+ const box = [L ? [L.x + L.w / 2, L.y + L.h + 10] : [(a[0] + b[0]) / 2, (a[1] + b[1]) / 2], ...[.5, .3, .7, .15, .85].map(f => along(e.pts, f * len))]
3050
+ .map(([x, y]) => ({ x: x - w / 2, y: y - 8, w, h: 16 })).find(free);
3051
+ if (!box) { const tt = eg.querySelector(':scope > title'), key = s.querySelector(`.line-key[data-i="${i}"]`);
3052
+ if (tt) { tt.setAttribute('data-was', tt.textContent); tt.textContent += '\n' + t; }
3053
+ if (key) { key.setAttribute('data-was', key.getAttribute('aria-label')); key.setAttribute('aria-label', key.getAttribute('aria-label') + ', ' + t); }
3054
+ return; }
3055
+ pills.push(box);
3056
+ over += `<g class="um-lab"${op(m)}><rect x="${box.x}" y="${box.y}" width="${w}" height="16" rx="8"${m.tone ? ` style="stroke: ${tone(m.tone)}"` : ''}/><text x="${box.x + w / 2}" y="${box.y + 11.5}" text-anchor="middle">${esc(t)}</text></g>`; });
3057
+ const cardsG = s.querySelector('.nodes');
3058
+ if (under && cardsG) cardsG.insertAdjacentHTML('beforebegin', `<g class="um-rings" aria-hidden="true">${under}</g>`);
3059
+ if (over) s.insertAdjacentHTML('beforeend', `<g class="um-marks" aria-hidden="true">${over}</g>`);
3060
+ }
3061
+ function overlay(name, layer) {
3062
+ if (dead || name == null) return;
3063
+ if (layer == null) layers.delete(String(name)); else layers.set(String(name), layer); // a Map keeps the order first set
3064
+ drawMarks();
3065
+ }
3066
+ /* the 'highlight' layer: these cards and lines lit, the rest dimmed; a Ref's level (0 to 1), else its distance, fades it */
3067
+ function highlight(refs) {
3068
+ if (refs == null) return overlay('highlight', null);
3069
+ const L = { nodes: {}, edges: {}, dim: true }, lv = r => clamp01(r.level ?? (r.distance > 0 ? Math.max(.45, 1 - .15 * r.distance) : 1));
3070
+ [].concat(refs).forEach(r => { if (!r) return; const id = r.type === 'node' ? r.id : r.node; // a chip or badge: its card
3071
+ if (id != null) L.nodes[id] = { level: Math.max(lv(r), (L.nodes[id] || {}).level || 0) };
3072
+ else if (r.type === 'edge') (r.ids || []).forEach(x => { L.edges[x] = { level: Math.max(lv(r), (L.edges[x] || {}).level || 0) }; }); });
3073
+ overlay('highlight', L);
3074
+ }
3075
+ /* a dot that rides the map: a tour along a path the host names, or a trace replayed. One at a time; a new picture of the
3076
+ view (setView, an update that redraws it) ends it, as does destroy() */
3077
+ let trip = null;
3078
+ function endTrip(played = false) { const t = trip; if (!t) return; trip = null; cancelAnimationFrame(t.raf); if (t.dot) t.dot.remove(); t.end(played); }
3079
+ function startTrip(step, end) { // step(ms played, pauses left out) says whether to go on
3080
+ endTrip();
3081
+ const t = { raf: 0, dot: null, paused: false, at: 0, last: performance.now(), end }; trip = t;
3082
+ const frame = now => { if (trip !== t) return; if (dead) return endTrip(); if (!t.paused) t.at += now - t.last; t.last = now;
3083
+ if (step(t.at)) t.raf = requestAnimationFrame(frame); else endTrip(true); };
3084
+ t.raf = requestAnimationFrame(frame);
3085
+ return t;
3086
+ }
3087
+ const lineBetween = (a, b) => { let i = geo.edges.findIndex(e => e.from === a && e.to === b); if (i < 0) i = geo.edges.findIndex(e => e.from === b && e.to === a); // either way
3088
+ return i < 0 ? null : { i, pts: geo.edges[i].from === a ? geo.edges[i].pts : [...geo.edges[i].pts].reverse() }; };
3089
+ const lenOf = pts => pts.slice(1).reduce((s, p, k) => s + Math.hypot(p[0] - pts[k][0], p[1] - pts[k][1]), 0);
3090
+ const along = (pts, d) => { for (let k = 1; k < pts.length; k++) { const a = pts[k - 1], b = pts[k], l = Math.hypot(b[0] - a[0], b[1] - a[1]);
3091
+ if (d <= l || k === pts.length - 1) { const f = l ? Math.min(1, d / l) : 1; return [a[0] + (b[0] - a[0]) * f, a[1] + (b[1] - a[1]) * f]; } d -= l; } return pts[0]; };
3092
+ /* tour(path): the dot rides the line between each pair of cards in turn, through each card to its next line, and each
3093
+ card it reaches flashes; where no line joins two cards it skips to the next. Resolves true when it reaches the end. */
3094
+ function tour(path, o = {}) {
3095
+ endTrip();
3096
+ if (dead || !geo || path == null) return Promise.resolve(false);
3097
+ const ids = [].concat(path).map(String).filter(id => geo.byId[id]), legs = [];
3098
+ for (let k = 0; k + 1 < ids.length; k++) { const r = lineBetween(ids[k], ids[k + 1]); if (!r) continue;
3099
+ const prev = legs[legs.length - 1], n = geo.byId[ids[k]];
3100
+ const pts = prev && prev.to === ids[k] ? [prev.pts[prev.pts.length - 1], [n.x + n.w / 2, n.y + n.h / 2], ...r.pts] : r.pts;
3101
+ legs.push({ pts, to: ids[k + 1], len: lenOf(pts) }); }
3102
+ if (!legs.length || (reducedMotion() && P.tourAt == null)) return Promise.resolve(legs.length > 0);
3103
+ const speed = Math.max(20, +o.speed || 240); // map units a second
3104
+ svg.insertAdjacentHTML('beforeend', '<g class="um-trip" aria-hidden="true"><circle r="5.5" class="tour-dot"/></g>');
3105
+ const g = svg.lastElementChild, dot = g.firstElementChild; let leg = 0, base = 0;
3106
+ if (P.tourAt != null) { // the page's #shot=1&at=<0 to 1>: the dot held that far along the ride, a frame for umlimator.py record (#83)
3107
+ let d = clamp01(P.tourAt) * legs.reduce((s, l) => s + l.len, 0); while (leg < legs.length - 1 && d > legs[leg].len) d -= legs[leg++].len;
3108
+ const [x, y] = along(legs[leg].pts, d); dot.setAttribute('cx', x); dot.setAttribute('cy', y);
3109
+ trip = { raf: 0, dot: g, end() {} }; return Promise.resolve(true);
3110
+ }
3111
+ return new Promise(res => { startTrip(ms => { const d = ms * speed / 1000;
3112
+ while (leg < legs.length && d - base >= legs[leg].len) { base += legs[leg].len; flash(legs[leg].to); leg++; }
3113
+ if (leg >= legs.length) return false;
3114
+ const [x, y] = along(legs[leg].pts, d - base); dot.setAttribute('cx', x); dot.setAttribute('cy', y); return true; }, res).dot = g; });
3115
+ }
3116
+ /* replay(spans): each call from one card to another (a span and its parent's) runs along their line at the trace's relative
3117
+ time, the whole trace in `duration` ms; the line glows while the call lasts and keeps its duration as a label */
3118
+ function replay(spans, o = {}) {
3119
+ endTrip(); overlay('replay', null);
3120
+ const list = [].concat(spans || []).filter(s => s && Number.isFinite(+s.start) && Number.isFinite(+s.end));
3121
+ const byId = new Map(list.map(s => [String(s.id), s])), match = o.match || {}, low = x => String(x ?? '').trim().toLowerCase();
3122
+ const cardOf = svc => { if (svc == null || !geo) return null; if (match[svc] != null) return geo.byId[match[svc]] ? String(match[svc]) : null;
3123
+ if (geo.byId[svc]) return String(svc); const n = geo.nodes.find(x => low(x.label) === low(svc)); return n ? n.id : null; };
3124
+ let skipped = 0, t = null, done; const hops = [];
3125
+ list.forEach(s => { const to = cardOf(s.service); if (!to) return skipped++;
3126
+ const p = s.parent != null ? byId.get(String(s.parent)) : null, from = p && cardOf(p.service);
3127
+ if (!from || from === to) return; // a root span, a caller off the view, or work inside one card: no hop
3128
+ const r = lineBetween(from, to); if (!r) return skipped++; // a call the view draws no line for
3129
+ hops.push({ ...r, ids: edgeRefIds(geo.edges[r.i]), at: +s.start, dur: +s.end - +s.start, to, len: lenOf(r.pts) }); });
3130
+ const ctl = { hops: hops.length, skipped, done: new Promise(r => { done = r; }),
3131
+ pause() { if (t && trip === t) t.paused = true; }, resume() { if (t && trip === t) t.paused = false; },
3132
+ stop() { if (t && trip === t) endTrip(); else overlay('replay', null); } };
3133
+ const ms = x => x >= 1000 ? `${+(x / 1000).toFixed(x >= 1e4 ? 0 : 1)} s` : `${x >= 10 ? Math.round(x) : +x.toFixed(1)} ms`, marks = {};
3134
+ // ponytail: two calls at once on one line share its mark; the one that ends first takes the glow off
3135
+ const show = (h, on) => { h.ids.forEach(x => { marks[x] = on ? { label: ms(h.dur), tone: 'accent', pulse: true } : { label: ms(h.dur) }; }); overlay('replay', { edges: { ...marks } }); };
3136
+ if (!hops.length || reducedMotion()) { hops.forEach(h => show(h, false)); done(hops.length > 0); return ctl; }
3137
+ const t0 = Math.min(...list.map(s => +s.start)), k = (+o.duration || 6000) / Math.max(1e-6, Math.max(...list.map(s => +s.end)) - t0);
3138
+ hops.forEach(h => { h.start = (h.at - t0) * k; h.end = h.start + h.dur * k; h.travel = Math.min(1200, Math.max(350, h.dur * k)); });
3139
+ const total = Math.max(...hops.map(h => Math.max(h.end, h.start + h.travel)));
3140
+ svg.insertAdjacentHTML('beforeend', '<g class="um-trip" aria-hidden="true"></g>'); const g = svg.lastElementChild;
3141
+ t = startTrip(now => { hops.forEach(h => {
3142
+ if (!h.on && now >= h.start) { h.on = true; show(h, true); g.insertAdjacentHTML('beforeend', '<circle r="4.5" class="tour-dot"/>'); h.dot = g.lastElementChild; }
3143
+ if (h.dot) { const f = (now - h.start) / h.travel; if (f >= 1) { h.dot.remove(); h.dot = null; flash(h.to); } else { const [x, y] = along(h.pts, f * h.len); h.dot.setAttribute('cx', x); h.dot.setAttribute('cy', y); } }
3144
+ if (h.on && !h.off && now >= h.end) { h.off = true; show(h, false); } });
3145
+ return now < total; }, played => { if (!played) overlay('replay', null); done(played); });
3146
+ t.dot = g;
3147
+ return ctl;
3148
+ }
3149
+
3150
+ /* ---------- stories (#69): the model's narrated walk-throughs, played step by step ----------
3151
+ A step sets the whole picture: its view, camera, selection, highlight and caption (what it leaves out is cleared),
3152
+ then plays its tour. Playing goes on by itself after each step's duration; the caption bar and the keys (← → PageUp
3153
+ PageDown, Esc) step through and stop it. One story at a time. */
3154
+ let story = null;
3155
+ // a step's card or line: a string names a card of the view, else a line by its id (the drawn line that has it); an
3156
+ // object is a Ref already
3157
+ const stepRef = x => typeof x !== 'string' ? (x && typeof x === 'object' ? x : null) : geo.byId[x] ? nodeRef(x)
3158
+ : (e => e ? edgeRef(e) : null)(geo.edges.find(e => edgeRefIds(e).includes(x)));
3159
+ function stepLit(list) { // highlight: what it names, and every line between two cards it names
3160
+ const refs = [].concat(list || []).map(stepRef).filter(Boolean), ids = new Set(refs.filter(r => r.type === 'node').map(r => r.id));
3161
+ geo.edges.forEach(e => { if (ids.has(e.from) && ids.has(e.to)) refs.push(edgeRef(e)); });
3162
+ return refs.length ? refs : null;
3163
+ }
3164
+ function storyBar(st) {
3165
+ let bar = stage.querySelector('.um-story');
3166
+ if (!bar) {
3167
+ stage.insertAdjacentHTML('beforeend', `<div class="um-story" role="group"><div class="um-cap"><span class="um-st"></span><p></p></div><div class="um-sb">
3168
+ <button data-s="prev" title="Previous step (←)" aria-label="Previous step">‹</button><button data-s="play"></button><button data-s="next" title="Next step (→)"></button><button data-s="stop" title="Close (Esc)" aria-label="Close the story">✕</button></div></div>`);
3169
+ bar = stage.lastElementChild; clear = null; // the fit keeps the cards off the bar as it does off the controls
3170
+ bar.addEventListener('dblclick', e => e.stopPropagation()); // two quick clicks on › are two steps, not a zoom
3171
+ bar.addEventListener('click', e => { e.stopPropagation(); const s = story, b = e.target.closest('button'); if (!s || !b) return;
3172
+ ({ prev: () => stepStory(-1), next: () => stepStory(1), stop: () => stopStory(), play: () => s.auto ? s.ctl.pause() : s.ctl.resume() })[b.dataset.s](); });
3173
+ }
3174
+ const k = st.at, n = st.steps.length, b = s => bar.querySelector(`[data-s="${s}"]`);
3175
+ bar.setAttribute('aria-label', 'Story: ' + (st.s.title || st.s.id));
3176
+ bar.querySelector('.um-st').textContent = `${st.s.title || st.s.id} · ${k + 1} / ${n}`;
3177
+ bar.querySelector('p').textContent = (st.steps[k] || {}).caption || '';
3178
+ b('prev').disabled = k <= 0;
3179
+ b('play').textContent = st.auto ? '❚❚' : '▶'; b('play').setAttribute('aria-label', st.auto ? 'Pause' : 'Play'); b('play').title = st.auto ? 'Pause' : 'Play';
3180
+ b('next').textContent = k + 1 < n ? '›' : '✓'; b('next').setAttribute('aria-label', k + 1 < n ? 'Next step' : 'Finish the story');
3181
+ }
3182
+ async function showStep(st, k) {
3183
+ const seq = ++st.seq, step = st.steps[k] || {}, live = () => story === st && st.seq === seq && !dead;
3184
+ st.at = k; clearTimeout(st.timer); tour(null); storyBar(st);
3185
+ emit('story', { story: st.s.id, step: k, steps: st.steps.length }); // before the step's own select and viewchange
3186
+ while (busy && live()) await new Promise(r => setTimeout(r, 30)); // a move the user started: let it land first
3187
+ if (!live()) return;
3188
+ if (step.view !== cur && ctx.V[step.view]) await go(step.view);
3189
+ if (!live()) return;
3190
+ const c = step.camera && typeof step.camera === 'object' ? step.camera : null, n = c && c.card != null ? geo.byId[c.card] : null;
3191
+ const to = n ? { cx: n.x + n.w / 2, cy: n.y + n.h / 2, k: +c.zoom || 1.5 }
3192
+ : c && Number.isFinite(+c.x) && Number.isFinite(+c.y) ? { cx: +c.x, cy: +c.y, k: +c.zoom || cam.s.k } : startView(); // 'fit', or none
3193
+ const moved = cam.animate(to); cam.touched = !!(n || (c && Number.isFinite(+c.x)));
3194
+ select(step.select == null ? null : stepRef(step.select), { reveal: false });
3195
+ highlight(stepLit(step.highlight));
3196
+ say(`Step ${k + 1} of ${st.steps.length}${step.caption ? ': ' + step.caption : ''}`); // after select(), which says what it selected
3197
+ await moved;
3198
+ if (!live()) return;
3199
+ st.toured = step.tour ? tour(step.tour) : Promise.resolve(true);
3200
+ arm(st);
3201
+ }
3202
+ function arm(st) { // playing: the next step once this one's duration is up and its tour has reached its end
3203
+ clearTimeout(st.timer);
3204
+ if (!st.auto || story !== st) return;
3205
+ const seq = st.seq, dur = Math.max(500, +(st.steps[st.at] || {}).duration || 4000); // reading time: not cut by reduced motion
3206
+ Promise.all([st.toured, new Promise(r => { st.timer = later(r, dur); })]).then(() => { if (story === st && st.seq === seq && st.auto) stepStory(1); });
3207
+ }
3208
+ function stepStory(d) {
3209
+ const st = story; if (!st) return;
3210
+ const k = st.at + d;
3211
+ if (k >= st.steps.length) return stopStory(true); // past the last step: the story has been told
3212
+ if (k >= 0) showStep(st, k);
3213
+ }
3214
+ function stopStory(told = false) {
3215
+ const st = story; if (!st) return;
3216
+ story = null; clearTimeout(st.timer); tour(null); highlight(null);
3217
+ const bar = stage.querySelector('.um-story'); if (bar) { bar.remove(); clear = null; }
3218
+ if (!dead) { say(told ? 'End of the story' : 'Story closed'); emit('story', { story: st.s.id, step: null, steps: st.steps.length }); }
3219
+ st.end(told);
3220
+ }
3221
+ function storyKey(e) { // null: not a story's key; on the bar's own buttons, Enter and Space press them
3222
+ const k = e.key;
3223
+ if ((k === 'Enter' || k === ' ') && e.composedPath().some(x => x.classList && x.classList.contains('um-story'))) return false;
3224
+ const d = { ArrowLeft: -1, PageUp: -1, ArrowRight: 1, PageDown: 1 }[k];
3225
+ if (d) { stepStory(d); return true; }
3226
+ if (k === 'Escape') { stopStory(); return true; }
3227
+ return null;
3228
+ }
3229
+ function play(id, o = {}) {
3230
+ stopStory();
3231
+ const s = (current.stories || []).find(x => x && x.id === id), steps = s && Array.isArray(s.steps) ? s.steps.filter(x => x && typeof x === 'object') : [];
3232
+ if (dead || !geo || !steps.length) return null;
3233
+ let end; const st = { s, steps, at: -1, seq: 0, timer: 0, auto: o.auto !== false, toured: Promise.resolve(true), done: new Promise(r => { end = r; }) };
3234
+ st.end = end;
3235
+ st.ctl = { get story() { return s.id; }, get step() { return st.at; }, get steps() { return steps.length; }, get playing() { return st.auto && story === st; },
3236
+ next() { if (story === st) stepStory(1); }, prev() { if (story === st) stepStory(-1); }, stop() { if (story === st) stopStory(); },
3237
+ pause() { if (story !== st) return; st.auto = false; clearTimeout(st.timer); storyBar(st); },
3238
+ resume() { if (story !== st || st.auto) return; st.auto = true; storyBar(st); arm(st); }, done: st.done };
3239
+ story = st;
3240
+ showStep(st, Math.max(0, Math.min(steps.length - 1, Math.floor(+o.step) || 0)));
3241
+ return st.ctl;
3242
+ }
3243
+
3244
+ const inner = { ctx: () => ctx, geo: () => geo, cam: () => cam, svg: () => svg, stage, host, go, open, up, flash, setFlow, pause, key, startView, standalone, play,
3245
+ select, busy: () => busy, flowing: () => stage.classList.contains('flowing'), view: () => cur, unfold: gesture };
2450
3246
  const first = () => {
2451
3247
  if (dead) return;
2452
3248
  const v0 = ctx.V[options.view] ? options.view : ctx.rootId;
@@ -2467,11 +3263,19 @@ export function render(container, model, options = {}) {
2467
3263
  toSVG: (o = {}) => dead ? '' : standalone(o.scale ?? 1, o.view ?? cur, o.fontCSS),
2468
3264
  toPNG: (o = {}) => dead ? Promise.resolve(null) : pngOf(standalone(o.scale ?? 2, o.view ?? cur, o.fontCSS)),
2469
3265
  focus: ref => { if (!dead) focusMap(ref); },
3266
+ expand: id => setUnfold(id, true), // unfold a stacked card of the view shown in place (#71): true when it did
3267
+ collapse: id => setUnfold(id, false),
3268
+ get expanded() { return [...(expanded[cur] || [])]; },
2470
3269
  on(type, fn) { (subs[type] ||= []).push(fn); if (type === 'click') tabKeys();
2471
3270
  return () => { subs[type] = (subs[type] || []).filter(f => f !== fn); if (type === 'click' && !dead) tabKeys(); }; },
2472
3271
  get view() { return cur; },
2473
3272
  get selection() { return sel; },
2474
3273
  get selected() { return picked(); },
3274
+ overlay: (name, layer) => overlay(name, layer),
3275
+ highlight: refs => highlight(refs),
3276
+ tour: (path, o) => tour(path, o),
3277
+ replay: (spans, o) => replay(spans, o),
3278
+ play: (id, o) => play(id, o),
2475
3279
  destroy,
2476
3280
  [INNER]: inner,
2477
3281
  };
@@ -2501,6 +3305,74 @@ export function history(model, limit = 100) {
2501
3305
  };
2502
3306
  }
2503
3307
 
3308
+ /* ---------- paths(), reach(), describe() and otlpSpans(): a view's lines walked and put in words, with no DOM (#62, #64, #65) ----------
3309
+ A line runs from → to as the model gives it. Each result is a list of Refs a host can hand to highlight(); a line's Ref
3310
+ names one model edge (a drawn line that stands for several takes the strongest mark among them). */
3311
+ function lineGraph(model, viewId) {
3312
+ const ctx = mapContext(model), v = ctx.V[viewId];
3313
+ if (!v) throw new Error(`umlimator: no view ${JSON.stringify(viewId)}`);
3314
+ return { ctx, v, ids: (v.nodeIds || []).filter(i => ctx.N[i]), edges: ctx.viewEdges(v).filter(e => e.from !== e.to) };
3315
+ }
3316
+ const walkFrom = (edges, start, back) => { const seen = new Set([start]); // breadth first along lines (back: against them)
3317
+ for (let q = [start]; q.length;) { const u = q.shift(); edges.forEach(e => { const [a, b] = back ? [e.to, e.from] : [e.from, e.to]; if (a === u && !seen.has(b)) { seen.add(b); q.push(b); } }); }
3318
+ return seen; };
3319
+
3320
+ /** Every card and line on a path from one card to another, following lines forward: the cards both reachable from `from`
3321
+ * and leading to `to`, and the lines between them. [] when no path joins them. */
3322
+ export function paths(model, viewId, from, to) {
3323
+ const { ids, edges } = lineGraph(model, viewId);
3324
+ if (!ids.includes(from) || !ids.includes(to)) return [];
3325
+ const F = walkFrom(edges, from), B = walkFrom(edges, to, true), on = id => F.has(id) && B.has(id);
3326
+ if (!F.has(to)) return [];
3327
+ return [...ids.filter(on).map(id => ({ type: 'node', id, view: viewId })), ...edges.filter(e => on(e.from) && on(e.to)).map(e => ({ type: 'edge', ids: [e._id], view: viewId }))];
3328
+ }
3329
+
3330
+ /** Everything a card's lines lead to (forward), or everything that leads to it (`backward`: for calls, who depends on it),
3331
+ * each with its distance in lines; `depth` stops the walk that many lines out. The card itself is first, at 0. */
3332
+ export function reach(model, viewId, from, options = {}) {
3333
+ const { ids, edges } = lineGraph(model, viewId), back = !!options.backward, depth = options.depth ?? Infinity;
3334
+ if (!ids.includes(from)) return [];
3335
+ const ends = e => back ? [e.to, e.from] : [e.from, e.to], dist = new Map([[from, 0]]);
3336
+ for (let q = [from]; q.length;) { const u = q.shift(), d = dist.get(u); if (d >= depth) continue;
3337
+ edges.forEach(e => { const [a, b] = ends(e); if (a === u && !dist.has(b)) { dist.set(b, d + 1); q.push(b); } }); }
3338
+ const near = (x, y) => x.distance - y.distance; // a stable sort: the view's order among equals
3339
+ return [...ids.filter(id => dist.has(id)).map(id => ({ type: 'node', id, view: viewId, distance: dist.get(id) })).sort(near),
3340
+ ...edges.filter(e => { const [a, b] = ends(e); return dist.has(a) && dist.has(b) && dist.get(a) < depth; })
3341
+ .map(e => ({ type: 'edge', ids: [e._id], view: viewId, distance: dist.get(ends(e)[0]) + 1 })).sort(near)];
3342
+ }
3343
+
3344
+ /** A view in plain sentences: its cards and their kinds, its boxes, what each card's lines do (in run order on a sequence
3345
+ * view), and the cards with no line. The same model gives the same words: a text alternative, docs, or an AI's context. */
3346
+ export function describe(model, viewId) {
3347
+ const { ctx, v, ids, edges } = lineGraph(model, viewId), boxes = (v.containers || []).filter(c => c && c.id);
3348
+ const name = id => ctx.N[id] ? ctx.nodeIn(id, viewId).label || id : (boxes.find(c => c.id === id) || {}).label || id;
3349
+ const list = xs => xs.length < 3 ? xs.join(' and ') : xs.slice(0, -1).join(', ') + ' and ' + xs[xs.length - 1];
3350
+ const count = (n, w) => `${n} ${w}${n === 1 ? '' : 's'}`;
3351
+ // a line's kind as its colour says it: the one it names, else a hook's line guards and a board's is state (no guesses from words)
3352
+ const kind = e => WIRE_KINDS.includes(e.kind) ? e.kind : (ctx.N[e.from] || {}).kind === 'hook' ? 'guard' : [e.from, e.to].some(i => (ctx.N[i] || {}).kind === 'board') ? 'state' : 'calls';
3353
+ const VERB = { calls: 'calls', feeds: 'feeds', triggers: 'triggers', state: 'reads or writes', guard: 'guards' };
3354
+ const what = e => { const t = [e.label && `"${e.label}"`, e.sublabel].filter(Boolean); return t.length ? ` (${t.join(', ')})` : ''; };
3355
+ const out = [`${v.title || v.id}: ${count(ids.length, 'card')} and ${count(edges.length, 'line')}.`];
3356
+ if (ids.length) out.push(`Cards: ${ids.map(id => `${name(id)} (${KIND[kindOf(ctx.nodeIn(id, viewId).kind, true)][1]})`).join(', ')}.`);
3357
+ boxes.forEach(c => { const ins = (c.nodeIds || []).filter(i => ids.includes(i)); if (ins.length) out.push(`${name(c.id)} holds ${list(ins.map(name))}.`); });
3358
+ if (v.render === 'sequence') edges.forEach((e, i) => out.push(`Step ${i + 1}: ${name(e.from)} ${VERB[kind(e)]} ${name(e.to)}${what(e)}.`));
3359
+ else [...ids, ...boxes.map(c => c.id)].forEach(id => { const outs = edges.filter(e => e.from === id); if (!outs.length) return;
3360
+ out.push(`${name(id)} ${[...new Set(outs.map(kind))].map(k => `${VERB[k]} ${list(outs.filter(e => kind(e) === k).map(e => name(e.to) + what(e)))}`).join('; ')}.`); });
3361
+ const alone = ids.filter(id => !edges.some(e => e.from === id || e.to === id));
3362
+ if (alone.length) out.push(`Not connected here: ${list(alone.map(name))}.`);
3363
+ return out.join('\n');
3364
+ }
3365
+
3366
+ /** The spans of an OpenTelemetry trace exported as OTLP JSON, as replay() takes them: { id, parent, service, name, start,
3367
+ * end }, times in milliseconds. */
3368
+ export function otlpSpans(data) {
3369
+ const out = [], attr = (list, k) => { const v = ((list || []).find(a => a && a.key === k) || {}).value || {}; return v.stringValue ?? v.intValue ?? null; };
3370
+ ((data && data.resourceSpans) || []).forEach(rs => { const service = attr(rs.resource && rs.resource.attributes, 'service.name');
3371
+ (rs.scopeSpans || rs.instrumentationLibrarySpans || []).forEach(ss => (ss.spans || []).forEach(s => out.push({ id: s.spanId, parent: s.parentSpanId || null,
3372
+ service, name: s.name, start: Number(s.startTimeUnixNano) / 1e6, end: Number(s.endTimeUnixNano) / 1e6 }))); });
3373
+ return out;
3374
+ }
3375
+
2504
3376
  export function mountPage(container, model, options = {}) {
2505
3377
  const Q = options.params || new URLSearchParams();
2506
3378
  if (options.instant) INSTANT = true;
@@ -2537,9 +3409,11 @@ export function mountPage(container, model, options = {}) {
2537
3409
  <span class="mbrand"><i></i><span>${esc(D.title || 'Map')}</span></span>
2538
3410
  <nav class="crumbs" id="m-crumbs" aria-label="Breadcrumb"></nav>
2539
3411
  <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>
2540
- <button class="ibtn" id="m-theme" title="Light / dark" aria-label="Toggle theme">◐</button>
3412
+ <button class="ibtn" id="m-theme" title="Light / dark" aria-label="Toggle theme">◐</button>${(D.stories || []).length ? `
3413
+ <div class="menu"><button class="ibtn txt" id="m-stories" aria-haspopup="true" aria-expanded="false" title="Stories: walk-throughs of this map">▶ Stories</button>
3414
+ <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>` : ''}
2541
3415
  <div class="menu"><button class="pbtn" id="m-export" aria-haspopup="true" aria-expanded="false">Export ▾</button>
2542
- <div class="menu-pop" id="m-export-pop" hidden><button data-x="svg">SVG · this view</button><button data-x="png">PNG · this view (2×)</button><button data-x="link">Copy link to view</button><hr><button data-x="json">Map model (JSON)</button></div></div>
3416
+ <div class="menu-pop" id="m-export-pop" hidden><button data-x="svg">SVG · this view</button><button data-x="png">PNG · this view (2×)</button><button data-x="link">Copy link to view</button><hr><button data-x="json">Map model (JSON)</button><button data-x="record">Record a story…</button></div></div>
2543
3417
  <button class="ibtn" id="m-pause" type="button" title="Pause" aria-label="Pause" aria-pressed="false">❚❚</button>
2544
3418
  <button class="ibtn" id="m-help" title="Keys (?)" aria-label="Keyboard shortcuts" aria-haspopup="true" aria-expanded="false">?</button>
2545
3419
  </header>
@@ -2555,8 +3429,10 @@ export function mountPage(container, model, options = {}) {
2555
3429
  <aside class="panel" id="m-panel" aria-label="Node details" hidden></aside>
2556
3430
  </div>
2557
3431
  <div class="hpop" id="m-helpbox" role="dialog" aria-label="Keys" hidden><b>Keys</b>
2558
- <dl><dt>/ or ${modK}</dt><dd>search nodes and views</dd><dt>Scroll · pinch</dt><dd>zoom (two-finger drag pans)</dd><dt>Click</dt><dd>details below the map</dd><dt>Double-click · Enter</dt><dd>open a stacked node ⤢</dd>
2559
- <dt>Esc · Backspace</dt><dd>close, then up one level</dd><dt>+ − 0 1</dt><dd>zoom in, out, fit, 100%</dd><dt>← ↑ → ↓</dt><dd>pan</dd><dt>[ ]</dt><dd>previous / next details tab</dd><dt>i</dt><dd>about this view</dd><dt>O</dt><dd>views list</dd><dt>F</dt><dd>flow on/off</dd></dl></div>
3432
+ <dl><dt>/ or ${modK}</dt><dd>search nodes and views</dd><dt>Scroll · pinch</dt><dd>zoom (two-finger drag pans)</dd><dt>Click</dt><dd>details below the map</dd><dt>Double-click · Enter</dt><dd>open a stacked node ⤢</dd><dt>E · Alt + click</dt><dd>unfold a stacked node here, or fold it back ⤡</dd>
3433
+ <dt>Esc · Backspace</dt><dd>close, then up one level</dd><dt>+ − 0 1</dt><dd>zoom in, out, fit, 100%</dd><dt>← ↑ → ↓</dt><dd>pan</dd><dt>[ ]</dt><dd>previous / next details tab</dd><dt>i</dt><dd>about this view</dd><dt>O</dt><dd>views list</dd><dt>F</dt><dd>flow on/off</dd>
3434
+ <dt>&gt; &lt;</dt><dd>light what a card leads to, what leads to it</dd><dt>Shift-click · P</dt><dd>light every path between two cards</dd>
3435
+ <dt>← → · PgUp PgDn</dt><dd>in a story: previous / next step (Esc closes it)</dd></dl></div>
2560
3436
  <div class="scrim" id="m-pal" hidden><div class="pal" role="dialog" aria-label="Search">
2561
3437
  <div class="in"><span aria-hidden="true">⌕</span><input id="m-q" type="text" autocomplete="off" spellcheck="false" placeholder="Search nodes and views" aria-label="Search nodes and views" aria-controls="m-res"><span class="kbd">esc</span></div>
2562
3438
  <div class="res" id="m-res" role="listbox"></div>
@@ -2615,11 +3491,12 @@ export function mountPage(container, model, options = {}) {
2615
3491
  }
2616
3492
  const setTree = on => { document.body.classList.toggle('tree-open', on); $('m-outline').setAttribute('aria-expanded', on); };
2617
3493
 
2618
- // Pull concise points from captured prose; retain wording and keep the full point in its tooltip.
3494
+ // Pull concise points from captured prose; retain wording and keep the full point in its tooltip. A point is a sentence
3495
+ // or a clause; one longer than a line (160 characters) is cut in two, but a short sentence stays whole (#97).
2619
3496
  function detailBullets(body) {
2620
3497
  const normalized = String(body || '').replace(/^\s*(?:[-*]|\d+[.)])\s+/gm, '').replace(/\n+/g, ' ').trim();
2621
3498
  let parts = normalized.split(/(?<=[.!?])\s+(?=[A-Z`/])|;\s+|\s+[·|]\s+/).filter(Boolean);
2622
- while (parts.length < 2 || parts.some(p => p.length > 160) && parts.length < 4) {
3499
+ while (parts.some(p => p.length > 160) && parts.length < 4) {
2623
3500
  const index = parts.reduce((best, p, i) => p.length > (parts[best] || '').length ? i : best, 0), value = parts[index];
2624
3501
  if (!value || value.length < 8) break;
2625
3502
  const separators = [...value.matchAll(/:\s+|,\s+|\s+(?=then\b|with\b|before\b|after\b|once\b|to\b|and\b|or\b|during\b|by\b|whose\b|outside\b|on\b|for\b|as\b|whenever\b|into\b|in\b|from\b|when\b|includes\b|typed\b)/g)].map(m => m.index + m[0].length);
@@ -2681,7 +3558,7 @@ export function mountPage(container, model, options = {}) {
2681
3558
  return I && I.src ? `<span class="big logo${I.dark ? ' dark' : ''}" aria-hidden="true"><img src="${I.src}" width="22" height="22" alt=""></span>`
2682
3559
  : `<span class="big" style="background:${c}" aria-hidden="true"><svg viewBox="0 0 ${I && I.g ? 24 : 16} ${I && I.g ? 24 : 16}" width="20" height="20" fill="none" stroke="currentColor" stroke-width="${I && I.g ? 2 : 1.5}" stroke-linecap="round" stroke-linejoin="round">${I && I.g ? I.g : SIGIL[k]}</svg></span>`; })()}
2683
3560
  <div class="pt"><h3>${esc(n.label || nid)}</h3><small><span>${esc(KIND[k][1])}</span><span class="vis ${pub ? 'pub' : ext ? 'ext' : ''}">${badgesOf(n).length ? badgesOf(n).map(b => esc(b.text.toUpperCase())).join(' · ') : pub ? 'PUBLIC' : ext ? 'EXTERNAL' : 'INTERNAL'}</span>${by}${n.origin ? `<span class="origin">${esc(n.origin)}</span>` : ''}</small></div>
2684
- <div class="acts">${canOpen ? `<button class="obtn" data-open="${esc(child)}" style="--c:${c}">⤢ Open ${esc(shortT(V[child].title || child))} <span>${(V[child].nodeIds || []).length} nodes · Enter</span></button>` : ''}<button class="ibtn x" data-close title="Close (Esc)" aria-label="Close details">×</button></div>
3561
+ <div class="acts">${canOpen ? `<button class="obtn" data-open="${esc(child)}" style="--c:${c}">⤢ Open ${esc(shortT(V[child].title || child))} <span>${(V[child].nodeIds || []).length} nodes · Enter</span></button>` : ''}${X && X.ctx().unfoldable(nid, cur) ? `<button class="obtn" data-unfold="${esc(nid)}" style="--c:${c}">⤡ Unfold here <span>E</span></button>` : ''}${litButtons()}<button class="ibtn x" data-close title="Close (Esc)" aria-label="Close details">×</button></div>
2685
3562
  </div>
2686
3563
  <div class="ptabs" role="tablist" aria-label="Details">${tabs.map(t => `<button class="ptab" role="tab" id="pt-${t[0]}" data-tab="${t[0]}" aria-selected="${t[0] === tab}" aria-controls="m-pbody">${t[1]}${t[2] != null ? `<em>${t[2]}</em>` : ''}</button>`).join('')}
2687
3564
  <span class="pnav"><button class="ibtn sm" data-step="-1" title="Previous ([)" aria-label="Previous tab">‹</button><button class="ibtn sm" data-step="1" title="Next (])" aria-label="Next tab">›</button></span></div>
@@ -2702,9 +3579,35 @@ export function mountPage(container, model, options = {}) {
2702
3579
  const closeExample = () => { const lb = $('m-lightbox'); if (lb) lb.hidden = true; };
2703
3580
  const setTab = t => { tab = t; try { localStorage.setItem('visual-tab', t); } catch (_) {} if (sel) renderPanel(sel); };
2704
3581
 
3582
+ /* paths and reach (#64): lit on the map from the details panel or by key, and said in the hint line (a live region); a
3583
+ new selection or view clears them, and the same key again does */
3584
+ let lit = null, picked = [];
3585
+ const cardName = id => nodeIn(id).label || id, nCards = n => `${n} card${n === 1 ? '' : 's'}`;
3586
+ const litButtons = () => '<button class="ibtn" data-lit="out" title="Light what it leads to (&gt;)" aria-label="Light what it leads to">→</button><button class="ibtn" data-lit="in" title="Light what leads to it (&lt;)" aria-label="Light what leads to it">←</button>'
3587
+ + (picked.length > 1 ? '<button class="ibtn" data-lit="paths" title="Light every path between the two cards (P)" aria-label="Light every path between the two selected cards">⇄</button>' : '');
3588
+ function unlight() { if (lit) { lit = null; handle.highlight(null); } }
3589
+ function light(key, refs, said) {
3590
+ if (lit === key) { unlight(); return status('Highlight cleared'); }
3591
+ lit = key; handle.highlight(refs.length ? refs : null); status(said, 3600);
3592
+ }
3593
+ function lightReach(back) {
3594
+ if (!sel || !geo.byId[sel]) return;
3595
+ const refs = reach(D, cur, sel, { backward: back }), n = refs.filter(r => r.type === 'node').length - 1, s = cardName(sel);
3596
+ light((back ? 'in ' : 'out ') + sel, n ? refs : [], !n ? (back ? `Nothing leads to ${s} here` : `${s} leads nowhere here`)
3597
+ : back ? `${nCards(n)} ${n === 1 ? 'leads' : 'lead'} to ${s}: lit on the map` : `${s} leads to ${nCards(n)}: lit on the map`);
3598
+ }
3599
+ function lightPaths() {
3600
+ const [a, b] = picked.slice(-2); if (!b) return;
3601
+ let refs = paths(D, cur, a, b), [x, y] = [a, b];
3602
+ if (!refs.length) { refs = paths(D, cur, b, a); [x, y] = [b, a]; } // the two cards in either order
3603
+ const n = refs.filter(r => r.type === 'node').length;
3604
+ light(`paths ${a} ${b}`, refs, n ? `Every path from ${cardName(x)} to ${cardName(y)}: ${nCards(n)}, lit on the map` : `No path joins ${cardName(a)} and ${cardName(b)} here`);
3605
+ }
3606
+
2705
3607
  /* navigation */
2706
3608
  function setHash(replace) {
2707
3609
  const p = new URLSearchParams(); p.set('view', cur); if (sel) p.set('node', sel);
3610
+ if (told) { p.set('story', told.id); p.set('step', told.step + 1); } // the story shown, at its step (#69)
2708
3611
  ['theme', 'check', 'shot'].forEach(k => Q.get(k) && p.set(k, Q.get(k)));
2709
3612
  const h = '#' + p.toString();
2710
3613
  if (location.hash === h) return;
@@ -2733,12 +3636,14 @@ export function mountPage(container, model, options = {}) {
2733
3636
  document.addEventListener('click', e => {
2734
3637
  const t = e.target;
2735
3638
  if (t.closest('[data-close]')) return deselect();
3639
+ const lb = t.closest('#m-panel [data-lit]'); if (lb) return lb.dataset.lit === 'paths' ? lightPaths() : lightReach(lb.dataset.lit === 'in');
2736
3640
  const ob = t.closest('[data-open]'); if (ob) return go(ob.dataset.open, { via: sel });
3641
+ const ub = t.closest('#m-panel [data-unfold]'); if (ub) return X.unfold(ub.dataset.unfold); // in place (#71)
2737
3642
  const pv = t.closest('.pview'); if (pv) return go(pv.dataset.v, { select: pv.dataset.sel });
2738
3643
  const rn = t.closest('#m-panel [data-n]'); if (rn) return findNode(rn.dataset.n);
2739
3644
  const cv = t.closest('#m-crumbs [data-v], #m-tree [data-v]'); if (cv) { if (innerWidth <= 820) setTree(false); return go(cv.dataset.v); }
2740
3645
  const cp = t.closest('[data-copy]'); if (cp) { navigator.clipboard && navigator.clipboard.writeText(cp.dataset.copy).then(() => status('Path copied'), () => status(cp.dataset.copy)); return; }
2741
- if (!t.closest('.menu')) closeExport();
3646
+ const mn = t.closest('.menu'); if (!mn || !mn.contains($('m-export'))) closeExport(); if (!mn || !mn.contains($('m-stories'))) closeStories();
2742
3647
  if (!t.closest('#m-helpbox, #m-help')) closeHelp();
2743
3648
  });
2744
3649
  $('m-outline').addEventListener('click', () => setTree(!document.body.classList.contains('tree-open')));
@@ -2749,6 +3654,51 @@ export function mountPage(container, model, options = {}) {
2749
3654
  $('m-export').addEventListener('click', () => { const on = $('m-export-pop').hidden; $('m-export-pop').hidden = !on; $('m-export').setAttribute('aria-expanded', on); });
2750
3655
  $('m-export-pop').addEventListener('click', e => { const x = e.target.closest('[data-x]')?.dataset.x; if (!x) return; closeExport(); exportView(x); });
2751
3656
 
3657
+ /* stories (#69): the Stories menu plays one; the link names the story and step shown; Export ▸ Record a story… */
3658
+ let told = null; // { id, step }: the story on the map and its step
3659
+ const toldSel = id => { const s = told && (D.stories || []).find(x => x && x.id === told.id), st = s && (s.steps || [])[told.step]; return !!(st && st.select === id); }; // the step's own selection: a pointer, no details panel
3660
+ const storiesPop = $('m-stories-pop'), closeStories = () => { if (storiesPop) { storiesPop.hidden = true; $('m-stories').setAttribute('aria-expanded', 'false'); } };
3661
+ if (storiesPop) {
3662
+ $('m-stories').addEventListener('click', () => { const on = storiesPop.hidden; storiesPop.hidden = !on; $('m-stories').setAttribute('aria-expanded', on); });
3663
+ storiesPop.addEventListener('click', e => { const b = e.target.closest('[data-story]'); if (!b) return; closeStories(); handle.play(b.dataset.story); });
3664
+ }
3665
+ // a story recorded from the page: each view and card the reader comes to is a step (Add step: as the camera is now too);
3666
+ // captions are typed in, and Save writes the story's JSON, for the model's `stories`
3667
+ let rec = null;
3668
+ function record() {
3669
+ if (rec) return;
3670
+ stage.insertAdjacentHTML('beforeend', `<div class="m-rec" id="m-rec" role="region" aria-label="Recording a story">
3671
+ <div class="t"><b>● Recording a story</b><span id="m-rec-n"></span></div>
3672
+ <input id="m-rec-title" type="text" aria-label="Story title" placeholder="Story title" value="${esc((D.title || 'Map') + ': a walk-through')}">
3673
+ <ol id="m-rec-list"></ol>
3674
+ <div class="b"><button type="button" data-r="add">Add step</button><button type="button" data-r="save">Save JSON</button><button type="button" data-r="close">Close</button></div></div>`);
3675
+ const box = $('m-rec'), same = (a, b) => JSON.stringify([a.view, a.select, a.camera]) === JSON.stringify([b.view, b.select, b.camera]);
3676
+ rec = { steps: [], t: 0 };
3677
+ const draw = () => { $('m-rec-n').textContent = rec.steps.length === 1 ? '1 step' : rec.steps.length + ' steps';
3678
+ $('m-rec-list').innerHTML = rec.steps.map((s, i) => `<li><span>${esc(shortT((V[s.view] || {}).title || s.view))}${s.select ? ' · ' + esc(nodeIn(s.select, s.view).label || s.select) : ''}${s.camera ? ' · zoomed' : ''}</span>
3679
+ <input type="text" data-i="${i}" aria-label="Caption of step ${i + 1}" placeholder="Caption" value="${esc(s.caption)}"><button type="button" data-del="${i}" aria-label="Remove step ${i + 1}">✕</button></li>`).join(''); };
3680
+ rec.take = force => { // the map as it is: its view, selected card and, once moved by hand, its camera
3681
+ const c = X.cam(), s = { view: cur, caption: '' }, last = rec.steps[rec.steps.length - 1];
3682
+ if (sel) s.select = sel;
3683
+ if (c.touched) s.camera = { x: Math.round(c.s.cx), y: Math.round(c.s.cy), zoom: +c.s.k.toFixed(2) };
3684
+ if (force || !last || !same(last, s)) { rec.steps.push(s); draw(); }
3685
+ };
3686
+ box.addEventListener('input', e => { const i = e.target.dataset.i; if (i != null) rec.steps[+i].caption = e.target.value; });
3687
+ box.addEventListener('click', e => {
3688
+ const r = e.target.closest('[data-r]')?.dataset.r, del = e.target.closest('[data-del]');
3689
+ if (del) { rec.steps.splice(+del.dataset.del, 1); draw(); }
3690
+ else if (r === 'add') rec.take(true);
3691
+ else if (r === 'save') {
3692
+ const title = $('m-rec-title').value.trim() || 'A walk-through', id = title.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '') || 'story';
3693
+ const steps = rec.steps.map(({ caption, ...s }) => caption ? { view: s.view, caption, ...s } : s);
3694
+ download(id + '.story.json', new Blob([JSON.stringify({ id, title, steps }, null, 2) + '\n'], { type: 'application/json' }));
3695
+ status('Saved: add it to the model\'s "stories"', 4000);
3696
+ } else if (r === 'close') { clearTimeout(rec.t); rec = null; box.remove(); }
3697
+ });
3698
+ rec.take();
3699
+ }
3700
+ const recordSoon = () => { if (rec) { clearTimeout(rec.t); rec.t = setTimeout(() => rec && rec.take(), 650); } }; // once the camera has landed
3701
+
2752
3702
  /* ⌘K command box: nodes and views, Enter jumps */
2753
3703
  const nodeIdx = nodes.map(n => ({ n, hay: [n.label, n.id, (n.verbs || []).join(' '), n.what, n.kind, n.author, ((D.authors || {})[n.author] || {}).name, n.origin].join(' ').toLowerCase() }));
2754
3704
  const viewIdx = views.map(v => ({ v, hay: [v.title, v.id, v.caption].join(' ').toLowerCase() }));
@@ -2805,7 +3755,7 @@ export function mountPage(container, model, options = {}) {
2805
3755
  if (!X || (e.target.matches && e.target.matches('input, textarea')) || e.metaKey || e.ctrlKey || e.altKey) return;
2806
3756
  const k = e.key, inCanvas = e.composedPath().includes(X.stage);
2807
3757
  if (k === 'Escape' || k === 'Backspace') {
2808
- if ($('m-lightbox') && !$('m-lightbox').hidden) closeExample(); else if (!$('m-helpbox').hidden) closeHelp(); else if (!$('m-export-pop').hidden) closeExport(); else if ($('m-vpop') && !$('m-vpop').hidden) togglePill(false);
3758
+ if ($('m-lightbox') && !$('m-lightbox').hidden) closeExample(); else if (!$('m-helpbox').hidden) closeHelp(); else if (!$('m-export-pop').hidden) closeExport(); else if (storiesPop && !storiesPop.hidden) closeStories(); else if ($('m-vpop') && !$('m-vpop').hidden) togglePill(false);
2809
3759
  else if (!inCanvas) X.key(e); else return;
2810
3760
  }
2811
3761
  else if (k === '/') openPal();
@@ -2813,6 +3763,8 @@ export function mountPage(container, model, options = {}) {
2813
3763
  else if (k === 'i' || k === 'I') togglePill();
2814
3764
  else if ((k === '[' || k === ']') && sel) setTab(TABS[(TABS.indexOf(tab) + (k === ']' ? 1 : -1) + TABS.length) % TABS.length]);
2815
3765
  else if (k === 'o' || k === 'O') setTree(!document.body.classList.contains('tree-open'));
3766
+ else if ((k === '>' || k === '<') && sel) lightReach(k === '<');
3767
+ else if ((k === 'p' || k === 'P') && picked.length > 1) lightPaths();
2816
3768
  else if (inCanvas || !X.key(e)) return;
2817
3769
  e.preventDefault(); e.stopPropagation();
2818
3770
  }, true);
@@ -2827,6 +3779,7 @@ export function mountPage(container, model, options = {}) {
2827
3779
  setTimeout(tick, 300); }
2828
3780
  function download(name, blob) { const a = document.createElement('a'); a.href = URL.createObjectURL(blob); a.download = name; document.body.appendChild(a); a.click(); setTimeout(() => { URL.revokeObjectURL(a.href); a.remove(); }, 500); }
2829
3781
  function exportView(x) {
3782
+ if (x === 'record') return record();
2830
3783
  const stem = (D.slug || D.title || 'map').toLowerCase().replace(/[^a-z0-9]+/g, '-') + '-' + cur.replace(/[^a-z0-9]+/gi, '-');
2831
3784
  if (x === 'svg') download(stem + '.svg', new Blob([standalone(1)], { type: 'image/svg+xml' }));
2832
3785
  else if (x === 'png') {
@@ -2842,8 +3795,9 @@ export function mountPage(container, model, options = {}) {
2842
3795
  const v0 = V[p0.get('view')] ? p0.get('view') : rootId;
2843
3796
  const handle = render($('m-canvas'), model, {
2844
3797
  view: v0, minimap: true, flow: flowPref ? flowPref === '1' : undefined,
3798
+ on: { story: ({ story: id, step }) => { told = step == null ? null : { id, step }; app.classList.toggle('story', !!told); if (X) setHash(true); } }, // from the first draw: a link may open a story (#69)
2845
3799
  _page: {
2846
- instant: options.instant, defer: !INSTANT, tour: !Q.get('still'), openHint: !Q.get('reader'), kinds: true, status,
3800
+ instant: options.instant, defer: !INSTANT, tour: !Q.get('still'), openHint: !Q.get('reader'), kinds: true, status, tourAt: Q.get('shot') ? Q.get('at') : null,
2847
3801
  wide: () => innerWidth > 820,
2848
3802
  upTarget: () => stack.length > 1 ? stack[stack.length - 2] : (V[cur] || {}).parent,
2849
3803
  inset: () => { const top = Math.max(58, 26 + $('m-vpill').offsetHeight); if (innerWidth <= 820) return { t: top, r: 12, b: 12, l: 12 }; const lg = $('m-legend'), on = lg && !lg.hidden; // the legend box sits bottom left
@@ -2860,11 +3814,12 @@ export function mountPage(container, model, options = {}) {
2860
3814
  $('m-ghosts').remove();
2861
3815
  const n0 = p0.get('node') || (Q.get('shot') && cur !== rootId ? subjectOf(V[cur]) || (V[cur].nodeIds || [])[0] : null);
2862
3816
  if (n0 && geo.byId[n0]) select(n0, { reveal: false });
3817
+ if (p0.get('story')) setTimeout(() => X.play(p0.get('story'), { step: (+p0.get('step') || 1) - 1, auto: false })); // a link to a story's step: shown, not played on (once the page listens)
2863
3818
  setHash(true);
2864
3819
  },
2865
3820
  },
2866
3821
  });
2867
- handle.on('select', ({ target }) => { sel = target && target.type === 'node' ? target.id : null; if (sel) renderPanel(sel); else hidePanel(); setHash(true); });
2868
- handle.on('viewchange', () => { setHash(replaceNext); replaceNext = false; });
3822
+ handle.on('select', ({ target, targets }) => { sel = target && target.type === 'node' ? target.id : null; picked = (targets || []).filter(t => t.type === 'node').map(t => t.id); unlight(); if (sel && !toldSel(sel)) renderPanel(sel); else hidePanel(); setHash(true); recordSoon(); });
3823
+ handle.on('viewchange', () => { unlight(); setHash(replaceNext); replaceNext = false; recordSoon(); });
2869
3824
  return handle;
2870
3825
  }