umlimator 0.10.0 → 0.12.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/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(' ')} }`;
@@ -279,32 +297,76 @@ function roundPath(pts, r = 9) {
279
297
  const last = pts[pts.length - 1]; return d + ` L ${last[0]} ${last[1]}`;
280
298
  }
281
299
  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
300
+ 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
301
+ let hush = null; // a layout tried and maybe dropped (unfolding, #71) keeps its warnings here: only the one kept says them
283
302
 
284
303
  /* Layered auto-layout for views that carry no col/row: longest-path layers (cycles broken by DFS),
285
304
  barycentre ordering sweeps, integer rows so row gaps stay free as routing channels. */
286
- /* Lines between neighbouring columns cross as little as the order of the cards in a column allows (#53): a few barycentre
287
- sweeps (each card goes where its neighbours in the next or previous column lie, its place now breaking ties), and the
288
- best order found is kept only when it crosses less than the order given. cols: columns of ids in reading order, changed
289
- in place; links: [a, b] with b in a later column (lines that skip a column do not order). */
305
+ /* Lines cross as little as the order of the cards in a column allows (#53): a few barycentre sweeps (each card goes where
306
+ its neighbours in the next or previous column lie, its place now breaking ties), and the best order found is kept only
307
+ when it crosses less than the order given. A line that skips columns has a point in each column it passes (Sugiyama's
308
+ dummy nodes), so it orders the cards it runs between too. cols: columns of ids in reading order, changed in place;
309
+ links: [a, b] with b in a later column. */
290
310
  function untangle(cols, links) {
291
311
  const colOf = {}; cols.forEach((c, k) => c.forEach(id => { colOf[id] = k; }));
292
- const adj = links.filter(([a, b]) => colOf[a] != null && colOf[b] === colOf[a] + 1);
312
+ const segs = [], extra = new Set(), want = {};
313
+ links.forEach(([a, b], n) => {
314
+ const ka = colOf[a], kb = colOf[b]; if (ka == null || kb == null || kb <= ka) return;
315
+ let prev = a; const ra = cols[ka].indexOf(a), rb = cols[kb].indexOf(b);
316
+ for (let k = ka + 1; k < kb; k++) { // a point first goes where the straight line between its ends would be
317
+ const p = '\u0001' + n + '.' + k; extra.add(p); colOf[p] = k; want[p] = ra + (rb - ra) * (k - ka) / (kb - ka);
318
+ segs.push([prev, p]); prev = p; }
319
+ segs.push([prev, b]);
320
+ });
321
+ if (extra.size) cols.forEach((c, k) => { const ps = [...extra].filter(p => colOf[p] === k);
322
+ if (ps.length) cols[k] = [...c.map((id, i) => [id, i, 0]), ...ps.map(p => [p, want[p], 1])].sort((x, y) => x[1] - y[1] || x[2] - y[2]).map(x => x[0]); });
323
+ const ins = {}, outs = {}; segs.forEach(([a, b]) => { (outs[a] ||= []).push(b); (ins[b] ||= []).push(a); });
293
324
  const crossings = () => { const at = {}; let n = 0; cols.forEach(c => c.forEach((id, i) => { at[id] = i; }));
294
- for (let i = 0; i < adj.length; i++) for (let j = i + 1; j < adj.length; j++) { const [a, b] = adj[i], [c, d] = adj[j];
325
+ for (let i = 0; i < segs.length; i++) for (let j = i + 1; j < segs.length; j++) { const [a, b] = segs[i], [c, d] = segs[j];
295
326
  if (colOf[a] === colOf[c] && (at[a] - at[c]) * (at[b] - at[d]) < 0) n++; }
296
327
  return n; };
297
- let best = adj.length > 1 ? crossings() : 0, keep = cols.map(c => c.slice()); // the order given, unless one crosses less
328
+ let best = segs.length > 1 ? crossings() : 0, keep = cols.map(c => c.slice()); // the order given, unless one crosses less
298
329
  for (let it = 0; it < 8 && best; it++) {
299
330
  const down = it % 2 === 0, seq = cols.map((_, k) => k).filter(k => down ? k > 0 : k < cols.length - 1);
300
331
  if (!down) seq.reverse();
301
332
  for (const k of seq) { const at = Object.fromEntries(cols[down ? k - 1 : k + 1].map((id, i) => [id, i]));
302
- const key = (id, i) => { const nb = adj.filter(([a, b]) => down ? b === id && at[a] != null : a === id && at[b] != null).map(([a, b]) => at[down ? a : b]);
333
+ const key = (id, i) => { const nb = ((down ? ins : outs)[id] || []).filter(x => at[x] != null).map(x => at[x]);
303
334
  return nb.length ? nb.reduce((x, y) => x + y, 0) / nb.length : i; };
304
335
  cols[k] = cols[k].map((id, i) => [id, key(id, i), i]).sort((p, q) => p[1] - q[1] || p[2] - q[2]).map(x => x[0]); }
305
336
  const n = crossings(); if (n < best) { best = n; keep = cols.map(c => c.slice()); }
306
337
  }
307
- keep.forEach((c, k) => { cols[k] = c; });
338
+ keep.forEach((c, k) => { cols[k] = c.filter(id => !extra.has(id)); });
339
+ }
340
+ /* A card no line comes into goes just before its nearest callee, not in the first column: its lines get shorter. Returns
341
+ the ranks with it moved, or null when none moves (bestFlow keeps them when they route better). */
342
+ function pullSources(ids, links, rank) {
343
+ const into = new Set(links.map(([, b]) => b)); let moved = false; const out = { ...rank };
344
+ ids.forEach(i => { const to = links.filter(([a]) => a === i).map(([, b]) => rank[b]).filter(r => r != null);
345
+ if (to.length && !into.has(i) && Math.min(...to) - 1 > rank[i]) { out[i] = Math.min(...to) - 1; moved = true; } });
346
+ return moved ? out : null;
347
+ }
348
+ /* Rows for ordered columns: each card level with the cards its lines come from (the median row), then with those they go
349
+ to, then again from the left, as near as the order allows with its height between it and the next (least squares: pool
350
+ adjacent violators); on half rows from 0. A line between cards on one row runs straight, past fewer cards. h: an item's
351
+ height in rows (a box in a view with boxes); a link [a, b, oa, ob] leaves a at row oa inside it and reaches b at row
352
+ ob (a box's card), else at its middle. */
353
+ function rowsFor(cols, links, h = () => 1) {
354
+ const row = {}, colOf = {}; cols.forEach((c, k) => { let r = 0; c.forEach(id => { row[id] = r; r += h(id); colOf[id] = k; }); });
355
+ const ins = {}, outs = {}, at = (id, o) => o ?? (h(id) - 1) / 2; // where a link meets an item, in rows from its top
356
+ links.forEach(([a, b, oa, ob]) => { if (colOf[a] == null || colOf[b] == null || colOf[b] <= colOf[a]) return;
357
+ (outs[a] ||= []).push([b, at(b, ob), at(a, oa)]); (ins[b] ||= []).push([a, at(a, oa), at(b, ob)]); });
358
+ const median = 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; };
359
+ const fit = (list, want) => { const blocks = []; let off = 0;
360
+ list.forEach(id => { blocks.push({ ids: [id], sum: want[id] - off, n: 1 }); off += h(id);
361
+ while (blocks.length > 1 && blocks.at(-2).sum / blocks.at(-2).n > blocks.at(-1).sum / blocks.at(-1).n) { const b = blocks.pop(), a = blocks.at(-1); a.ids.push(...b.ids); a.sum += b.sum; a.n += b.n; } });
362
+ off = 0; blocks.forEach(b => b.ids.forEach(id => { row[id] = b.sum / b.n + off; off += h(id); })); };
363
+ for (const [from, to, step] of [[1, cols.length, 1], [cols.length - 2, -1, -1], [1, cols.length, 1]])
364
+ for (let k = from; k !== to; k += step) { const want = {};
365
+ cols[k].forEach(id => { const nb = (step > 0 ? ins : outs)[id] || []; want[id] = nb.length ? median(nb.map(([x, ox, o]) => row[x] + ox - o)) : row[id]; });
366
+ fit(cols[k], want); }
367
+ const low = Math.min(...Object.values(row));
368
+ cols.forEach(c => c.forEach((id, i) => { row[id] = Math.round((row[id] - low) * 2) / 2; if (i) row[id] = Math.max(row[id], row[c[i - 1]] + h(c[i - 1])); }));
369
+ return row;
308
370
  }
309
371
 
310
372
  /* A view about one node: the node sits in the middle, callers to its left, callees to its right,
@@ -494,7 +556,7 @@ function layoutPass(dg, map, room, last) { // room: { x: { col: px }, y: { row:
494
556
  };
495
557
  const out = familyDefs.map(f => frameOf(f)).filter(Boolean);
496
558
  if (map) out.forEach(f => { // the box fits its title and its badges, which are tabs on its top border, at the right
497
- 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);
559
+ 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)
498
560
  f.w = Math.max(f.w, String(f.label || f.id).length * 6.7 + 32 + (tabs.length ? all + 8 : 0));
499
561
  let tx = f.x + f.w - 12 - all; f.tabs = tabs.map(t => { const o = { ...t, x: tx }; tx += t.w + 4; return o; }); });
500
562
  return out;
@@ -945,6 +1007,7 @@ function svgOf(geo, opts = {}) {
945
1007
  const id = 'g' + (svgSeq++), { nodes, edges, groups, bounds: B, map } = geo;
946
1008
  const col = k => `var(--um-${KIND[k][0]})`, fill = k => `var(--um-${KIND[k][0]}-f)`;
947
1009
  const estyle = e => {
1010
+ 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)
948
1011
  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' : '') };
949
1012
  const st = e.style || (e.change === 'added' ? 'added' : e.change === 'removed' ? 'removed' : '');
950
1013
  if (st === 'added' || st === 'emphasis') return { c: 'var(--um-green)', m: 'g', dash: '', flow: st === 'added' || e.animate };
@@ -953,15 +1016,21 @@ function svgOf(geo, opts = {}) {
953
1016
  if (st === 'dashed' || st === 'async') return { c: 'var(--um-violet)', m: 'v', dash: '4 4' };
954
1017
  return { c: 'var(--um-arrow)', m: 'd', dash: '' };
955
1018
  };
1019
+ // 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)
1020
+ const broke = e => opts.broken ? [...new Set((e.all || [e]).flatMap(x => opts.broken[x._id] || []))] : [];
1021
+ const lstyle = e => broke(e).length ? { ...estyle(e), c: 'var(--um-rose)', m: 'r' } : estyle(e);
1022
+ 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;
1023
+ 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>`; };
956
1024
  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>`;
957
1025
  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]})`])) };
958
- 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
1026
+ 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
959
1027
  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">
960
1028
  <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>`;
961
1029
  const families = geo.families || [], familyDepth = f => f.parent ? 1 + familyDepth(families.find(p => p.id === f.parent) || {}) : 0;
962
1030
  families.slice().sort((a,b) => familyDepth(a)-familyDepth(b)).forEach(f => {
963
1031
  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'})"/>
964
- <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 => `
1032
+ <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 ? `
1033
+ <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>` : `
965
1034
  <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>`;
966
1035
  });
967
1036
  (geo.lanes || []).forEach((l, i) => {
@@ -1000,15 +1069,15 @@ function svgOf(geo, opts = {}) {
1000
1069
  ${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>`;
1001
1070
  });
1002
1071
  edges.forEach((e, i) => {
1003
- const st = estyle(e), d = roundPath(e.pts), mk = st.m + (e.weight > 1 ? '-w' + e.weight : '');
1072
+ const st = lstyle(e), why = broke(e), d = roundPath(e.pts), mk = st.m + (e.weight > 1 ? '-w' + e.weight : '');
1004
1073
  if (tour && tour.legs[i]) { const [a, b] = tour.legs[i];
1005
1074
  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>`; }
1006
1075
  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"/>
1007
1076
  <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})"` : ''}/>
1008
- <path d="${d}" class="flowline" stroke="${st.c}"/>${(e.all || [e]).some(x => x.edited) ? (() => { let m = [e.pts[0], e.pts[1]];
1077
+ <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]];
1009
1078
  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]; });
1010
- 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>`; })() : ''}
1011
- <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>`;
1079
+ 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) : ''}
1080
+ <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>`;
1012
1081
  });
1013
1082
  s += '</g><g class="nodes">';
1014
1083
  nodes.forEach(n => {
@@ -1071,9 +1140,9 @@ return I && I.src ? `<rect x="${x - M * 3 / 28}" y="${y - M * 3 / 28}" width="${
1071
1140
  said.push(kids ? `${kids} (cards inside, opens the inner view)` : '(opens the inner view)'); }
1072
1141
  const ch = CHG[String(n.change || '').toLowerCase()];
1073
1142
  if (ch) {
1074
- const bw = ch[1].length * 6 + 12;
1075
- 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]})"/>
1076
- <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>`);
1143
+ 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
1144
+ parts.push(`<rect x="${bx}" y="${n.y - 8}" width="${bw}" height="16" rx="8" fill="var(--um-mask)" stroke="var(--um-${ch[0]})"/>
1145
+ <text x="${bx + bw / 2}" y="${n.y + 3}" text-anchor="middle" class="badge-tx" fill="var(--um-${ch[0]})">${ch[1]}</text>`);
1077
1146
  said.push(ch[1].toLowerCase());
1078
1147
  }
1079
1148
  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>`);
@@ -1105,7 +1174,7 @@ return I && I.src ? `<rect x="${x - M * 3 / 28}" y="${y - M * 3 / 28}" width="${
1105
1174
  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
1106
1175
  s += '</g><g class="keys">';
1107
1176
  edges.forEach((e, i) => { const said = e.label ? `: ${e.label}${e.sublabel ? ` (${e.sublabel})` : ''}` : '';
1108
- 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}`;
1177
+ 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('; ')}` : ''}`;
1109
1178
  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>`; });
1110
1179
  }
1111
1180
  s += '</g><g class="labels depth-read">';
@@ -1226,7 +1295,7 @@ class Camera {
1226
1295
  }
1227
1296
  onDown(e) {
1228
1297
  if (e.button !== 0 && e.pointerType === 'mouse') return;
1229
- if (e.target.closest('.ctl, .minimap, button, a, input, .um-edit') || (this.o.skip && this.o.skip(e))) return;
1298
+ if (e.target.closest('.ctl, .minimap, button, a, input, .um-edit, .um-story') || (this.o.skip && this.o.skip(e))) return;
1230
1299
  this.ptrs.set(e.pointerId, [e.clientX, e.clientY]);
1231
1300
  this.start = { x: e.clientX, y: e.clientY, s: { ...this.s }, dist: this.pinchDist() };
1232
1301
  this.moved = 0; this.dragged = false;
@@ -1258,9 +1327,12 @@ class Camera {
1258
1327
  }
1259
1328
  }
1260
1329
 
1261
- function routeAudit(geo, where, only) { // only: a set of edges; counts just the issues that involve one of them
1330
+ // 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()
1331
+ // turns those into Refs; the page's check reads only the messages
1332
+ const teller = (issues, refs) => (message, ...about) => { issues.push(message); if (refs) refs.push(about); };
1333
+ function routeAudit(geo, where, only, refs) { // only: a set of edges; counts just the issues that involve one of them
1262
1334
  const sk = e => only && !only.has(e);
1263
- const issues = [];
1335
+ const issues = [], say = teller(issues, refs);
1264
1336
  // routes: no crossings, no shared runs, no label lying across another route
1265
1337
  const segs = geo.edges.map(e => e.pts.slice(0, -1).map((a, i) => [a, e.pts[i + 1]]));
1266
1338
  const name = e => `${e.s.label}→${e.t.label}`;
@@ -1281,66 +1353,87 @@ function routeAudit(geo, where, only) { // only: a set of edges; counts just the
1281
1353
  if (sk(geo.edges[i]) && sk(geo.edges[j])) continue;
1282
1354
  let found = null;
1283
1355
  for (const s1 of segs[i]) { for (const s2 of segs[j]) { found = crossing(s1, s2); if (found) break; } if (found) break; }
1284
- if (found === 'cross') issues.push(`${where}: edges ${name(geo.edges[i])} and ${name(geo.edges[j])} cross`);
1285
- if (found === 'share') issues.push(`${where}: edges ${name(geo.edges[i])} and ${name(geo.edges[j])} share a line`);
1356
+ if (found === 'cross') say(`${where}: edges ${name(geo.edges[i])} and ${name(geo.edges[j])} cross`, geo.edges[i], geo.edges[j]);
1357
+ if (found === 'share') say(`${where}: edges ${name(geo.edges[i])} and ${name(geo.edges[j])} share a line`, geo.edges[i], geo.edges[j]);
1286
1358
  }
1287
1359
  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 };
1288
- 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)}`); }); });
1360
+ 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); }); });
1289
1361
  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);
1290
1362
  if (geo.map) {
1291
1363
  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
1292
1364
  geo.edges.forEach((e, i) => {
1293
1365
  // clearance: a route keeps 12+ px from every node it does not connect
1294
1366
  geo.nodes.forEach(n => { if (n.id === e.from || n.id === e.to) return;
1295
- 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}"`); });
1367
+ 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); });
1296
1368
  // no detours: a route stays under 2.5x the straight distance between its ends
1297
1369
  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);
1298
1370
  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]);
1299
- 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)`);
1371
+ 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);
1300
1372
  // reading order: every wire runs left to right, except one that closes a loop or answers its pair (keepPairs)
1301
- 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`);
1373
+ 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);
1302
1374
  });
1303
1375
  if (geo.hub) { // hub views: one visible line into each callee
1304
1376
  const into = {}; geo.edges.forEach(e => { (into[e.to] ||= []).push(e); });
1305
- 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`); });
1377
+ 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]); });
1306
1378
  }
1307
1379
  }
1308
1380
  // author name tags hang under their card: no route may run through one
1309
1381
  (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 };
1310
- geo.edges.forEach((o, j) => { if (segHit(r, j)) issues.push(`${where}: name tag "${n.tag.text}" lies on edge ${name(o)}`); }); });
1382
+ geo.edges.forEach((o, j) => { if (segHit(r, j)) say(`${where}: name tag "${n.tag.text}" lies on edge ${name(o)}`, n, o); }); });
1311
1383
  // lanes: every part sits inside its own lane, lanes never overlap, no route crosses a lane header
1312
1384
  const lanes = geo.lanes || [];
1313
1385
  lanes.forEach((l, i) => {
1314
- lanes.slice(i + 1).forEach(m => { if (hit(l, m, -1)) issues.push(`${where}: lanes "${l.title}" and "${m.title}" overlap`); });
1386
+ lanes.slice(i + 1).forEach(m => { if (hit(l, m, -1)) say(`${where}: lanes "${l.title}" and "${m.title}" overlap`); });
1315
1387
  (geo.nodes || []).forEach(n => {
1316
- 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`);
1317
- 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`);
1318
- 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`);
1388
+ 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);
1389
+ 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);
1390
+ 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);
1319
1391
  });
1320
1392
  });
1321
- (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)}`); }));
1393
+ (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); }));
1322
1394
  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;
1323
1395
  families.forEach(f => {
1324
1396
  const parent = families.find(p => p.id === f.parent);
1325
- if (f.parent && (!parent || !inside(parent,f))) issues.push(`${where}: family "${f.label}" sticks out of its parent`);
1326
- (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}"`); });
1327
- 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`); });
1328
- 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)}`); });
1397
+ if (f.parent && (!parent || !inside(parent,f))) say(`${where}: family "${f.label}" sticks out of its parent`, f);
1398
+ (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); });
1399
+ 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); });
1400
+ 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); });
1329
1401
  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]];
1330
1402
  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);
1331
- 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}"`); });
1403
+ 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); });
1332
1404
  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;
1333
- 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}"`); });
1405
+ 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); });
1334
1406
  });
1335
1407
  return issues;
1336
1408
  }
1337
1409
 
1410
+ /* The layout check's geometry, which needs no fonts: check() runs it on layout()'s geometry, and the page adds what it
1411
+ measures in the DOM (text against its card, a lane's or box's title, edge labels against each other). */
1412
+ const box = n => ({ x: n.x, y: n.y, w: n.w, h: n.h });
1413
+ function overlapIssues(geo, where, refs) {
1414
+ const issues = [], nodes = geo.nodes, say = teller(issues, refs);
1415
+ 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]);
1416
+ return issues;
1417
+ }
1418
+ function leaveIssues(geo, where, refs) {
1419
+ const issues = [], say = teller(issues, refs);
1420
+ 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
1421
+ const c = [L.x + L.w / 2, L.y + L.h / 2], P = e.pts; let s = 0, at = null, half = 0, tot = 0;
1422
+ 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]);
1423
+ 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;
1424
+ 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;
1425
+ 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; } }
1426
+ tot += len; }
1427
+ if (at == null) return; const room = Math.min(at - half, tot - at - half);
1428
+ 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); });
1429
+ return issues;
1430
+ }
1431
+
1338
1432
  /* label-overlap audit used by `visual.py check` (world coordinates, real font metrics) */
1339
1433
  function auditSvg(svg, geo, where) {
1340
- const issues = [], R = n => ({ x: n.x, y: n.y, w: n.w, h: n.h });
1434
+ const issues = overlapIssues(geo, where), R = box;
1341
1435
  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);
1342
1436
  const nodes = geo.nodes;
1343
- 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`);
1344
1437
  T.forEach(a => {
1345
1438
  if (a.t === 'lt' || a.t === 'ft') {
1346
1439
  nodes.forEach(n => { if (hit(a.b, R(n), -1)) issues.push(`${where}: lane "${a.txt}": header covers node "${n.label}"`); });
@@ -1354,16 +1447,7 @@ function auditSvg(svg, geo, where) {
1354
1447
  nodes.forEach(n => { if (n !== own && hit(a.b, R(n), -1)) issues.push(`${where}: text "${a.txt}" overlaps node "${n.label}"`); });
1355
1448
  }
1356
1449
  });
1357
- issues.push(...routeAudit(geo, where));
1358
- 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
1359
- const c = [L.x + L.w / 2, L.y + L.h / 2], P = e.pts; let s = 0, at = null, half = 0, tot = 0;
1360
- 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]);
1361
- 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;
1362
- 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;
1363
- 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; } }
1364
- tot += len; }
1365
- if (at == null) return; const room = Math.min(at - half, tot - at - half);
1366
- 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})`); });
1450
+ issues.push(...routeAudit(geo, where), ...leaveIssues(geo, where));
1367
1451
  const els = T.filter(a => a.t === 'el');
1368
1452
  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`);
1369
1453
  return issues;
@@ -1561,9 +1645,9 @@ function mapContext(model, opts = {}) {
1561
1645
  function fillLayout(vid, L) {
1562
1646
  const ids = (V[vid].nodeIds || []).filter(i => N[i]);
1563
1647
  if (ids.every(i => Array.isArray(L[i]))) return L;
1564
- const auto = autoPos(vid), out = {}, taken = new Set(), key = (c, r) => c + ',' + r;
1565
- ids.forEach(i => { if (Array.isArray(L[i])) { out[i] = L[i]; taken.add(key(...L[i])); } });
1566
- ids.forEach(i => { if (out[i]) return; let [c, r] = auto[i] || [0, 0]; while (taken.has(key(c, r))) r++; out[i] = [c, r]; taken.add(key(c, r)); });
1648
+ const auto = autoPos(vid), out = {};
1649
+ ids.forEach(i => { if (Array.isArray(L[i])) out[i] = L[i]; });
1650
+ ids.forEach(i => { if (out[i]) return; let [c, r] = auto[i] || [0, 0]; while (Object.values(out).some(([c2, r2]) => c2 === c && Math.abs(r2 - r) < 1)) r++; out[i] = [c, r]; }); // auto rows may be half rows: a row apart
1567
1651
  return out;
1568
1652
  }
1569
1653
  /* plain views: longest-path columns from the roots, state at the bottom of its column */
@@ -1596,9 +1680,12 @@ function mapContext(model, opts = {}) {
1596
1680
  items.forEach(x => (cols[rank[x]] ||= []).push(x));
1597
1681
  const main = cols.map(l => (l || []).filter(x => !sink(x)).sort((a, b) => first(a) - first(b)));
1598
1682
  untangle(main, dag); // in call order, unless another order crosses less (#53)
1683
+ const inside = (x, id) => ids.includes(x) ? 0 : (size[x].pos[id] || [0, null])[1]; // the row of a box's card that a line meets
1684
+ const row = rowsFor(main, fwd.filter(level).map(e => { const a = itemIn(e.from, g), b = itemIn(e.to, g); return a !== undefined && b !== undefined && a !== b ? [a, b, inside(a, e.from), inside(b, e.to)] : null; }).filter(Boolean),
1685
+ x => size[x].h); // each block level with the cards its lines come from and go to
1599
1686
  main.forEach((l, k) => { cols[k] = [...l, ...(cols[k] || []).filter(sink).sort((a, b) => first(a) - first(b))]; });
1600
1687
  cols.forEach(list => { let r0 = 0, w = 0;
1601
- list.forEach(x => { const sz = size[x];
1688
+ list.forEach(x => { const sz = size[x]; if (row[x] != null) r0 = Math.max(r0, row[x]);
1602
1689
  Object.entries(sz.pos).forEach(([id, [c, r]]) => { pos[id] = [c0 + c, r0 + r]; }); r0 += sz.h; w = Math.max(w, sz.w); });
1603
1690
  c0 += w; h = Math.max(h, r0); });
1604
1691
  const per = Math.max(2, c0); loose.forEach((x, k) => { pos[x] = [k % per, h + Math.floor(k / per)]; });
@@ -1607,8 +1694,23 @@ function mapContext(model, opts = {}) {
1607
1694
  };
1608
1695
  return block(null).pos;
1609
1696
  }
1610
- const autoPos = vid => ((V[vid] || {}).containers || []).length ? blockPos(vid) : flowPos(vid);
1611
- function flowPos(vid) {
1697
+ const autoPos = vid => ((V[vid] || {}).containers || []).length ? blockPos(vid) : bestFlow(vid);
1698
+ const level = e => !/^(top|bottom)$/.test(e.fromSide) && !/^(top|bottom)$/.test(e.toSide); // a line into a card's top or bottom asks for no row
1699
+ /* A small plain view is routed a few ways and keeps the one the layout check scores best: cards level with their
1700
+ neighbours, or packed from the top as before, with cards no line comes into beside their callee or not. A cheap
1701
+ version of the CLI's search (optimize), affordable up to 30 cards; a larger view takes the first way. */
1702
+ function bestFlow(vid) {
1703
+ const first = flowPos(vid), n = ((V[vid] || {}).nodeIds || []).length;
1704
+ if (n > 30 || n < 3) return first;
1705
+ const seen = new Set([JSON.stringify(first)]), ways = [first];
1706
+ for (const o of [{ pull: true }, { packed: true }, { pull: true, packed: true }]) { const p = flowPos(vid, o), k = JSON.stringify(p); if (!seen.has(k)) { seen.add(k); ways.push(p); } }
1707
+ if (ways.length < 2) return first;
1708
+ let best = first, score = Infinity;
1709
+ ways.forEach(p => { const g = buildGeo(vid, p), s = scoreGeo(g); if (s < score - 0.5) { score = s; best = p; drawn[vid] = { pos: p, g }; } }); // as it will be drawn: with port repair
1710
+ return best;
1711
+ }
1712
+ const drawn = {}; // the way bestFlow kept, routed already: the view's next buildGeo takes it once instead of routing it again
1713
+ function flowPos(vid, o = {}) {
1612
1714
  const v = V[vid], lp = loopsOf(v), fwd = lp.edges.filter((e, k) => !lp.back.has(k) && e.from !== e.to);
1613
1715
  // cards with no line go in rows of their own under the view (#46), as wide as its columns; they stacked with the sources
1614
1716
  const all = (v.nodeIds || []).filter(i => N[i]), lined = new Set(lp.edges.filter(e => e.from !== e.to).flatMap(e => [e.from, e.to]));
@@ -1617,14 +1719,16 @@ function mapContext(model, opts = {}) {
1617
1719
  for (let it = 0; it <= ids.length; it++) fwd.forEach(e => { if (rank[e.to] < rank[e.from] + 1) rank[e.to] = rank[e.from] + 1; });
1618
1720
  const seen = id => { const k = lp.edges.findIndex(e => e.to === id || e.from === id); return k < 0 ? 999 : k; };
1619
1721
  const sink = i => isState(i) && !fwd.some(e => e.from === i); // state that leads nowhere sits along the bottom
1620
- const cols = {}; ids.forEach(i => (cols[rank[i]] ||= []).push(i));
1621
- const pos = {};
1622
- const height = Math.max(1, ...Object.values(cols).map(c => c.filter(i => !sink(i)).length));
1623
- const ranks = Object.keys(cols).map(Number).sort((a, b) => a - b), mains = ranks.map(c => cols[c].filter(i => !sink(i)).sort((a, b) => seen(a) - seen(b)));
1624
- untangle(mains, fwd.map(e => [e.from, e.to])); // in reading order, unless another order crosses less (#53)
1722
+ const pos = {}, links = fwd.map(e => [e.from, e.to]);
1723
+ const order = rk => { const cols = {}; ids.forEach(i => (cols[rk[i]] ||= []).push(i));
1724
+ const ranks = Object.keys(cols).map(Number).sort((a, b) => a - b), mains = ranks.map(c => cols[c].filter(i => !sink(i)).sort((a, b) => seen(a) - seen(b)));
1725
+ untangle(mains, links); return { cols, ranks, mains }; }; // in reading order, unless another order crosses less (#53)
1726
+ const { cols, ranks, mains } = order((o.pull && pullSources(ids, links, rank)) || rank);
1727
+ const row = o.packed ? Object.fromEntries(mains.flatMap(m => m.map((i, r) => [i, r]))) : rowsFor(mains, fwd.filter(level).map(e => [e.from, e.to]));
1728
+ const height = Math.max(0, ...Object.values(row)) + 1;
1625
1729
  Object.entries(cols).forEach(([c, list]) => {
1626
1730
  const main = mains[ranks.indexOf(+c)], st = list.filter(sink).sort((a, b) => seen(a) - seen(b));
1627
- main.forEach((i, r) => { pos[i] = [+c, r]; });
1731
+ main.forEach(i => { pos[i] = [+c, row[i]]; });
1628
1732
  st.forEach((i, r) => { pos[i] = [+c, height + r]; }); // state along the bottom, never above the flow
1629
1733
  });
1630
1734
  const per = Math.max(2, Object.keys(cols).length), under = Math.max(...Object.values(pos).map(p => p[1])) + 1;
@@ -1634,8 +1738,16 @@ function mapContext(model, opts = {}) {
1634
1738
 
1635
1739
  function buildView(vid, opts = {}) {
1636
1740
  const g = buildGeo(vid), v = V[vid];
1637
- return { g, markup: svgOf(g, { title: v.title, subject: subjectOf(v), icons: D.icons, ...opts }) };
1638
- }
1741
+ return { g, markup: svgOf(g, { title: v.title, subject: subjectOf(v), icons: D.icons, broken: brokenIn(vid), ...opts }) };
1742
+ }
1743
+ // the rules a view's lines break (#70), by model edge id: svgOf draws such a line rose, with a mark that names them
1744
+ let ruled = null;
1745
+ const brokenIn = vid => {
1746
+ if (!arrOf(D.rules).length) return null;
1747
+ const by = {}, title = id => { const r = D.rules.find(x => x && x.id === id); return String((r && (r.title || r.id)) ?? id); };
1748
+ (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)); }); }); });
1749
+ return Object.keys(by).length ? by : null;
1750
+ };
1639
1751
  const layoutCache = {};
1640
1752
  // the cards a card opens: its inner view's, else the count the host gives for one it has not loaded (opensCount)
1641
1753
  const kidCount = (nid, vid) => { const c = childOf(nid, vid), k = c && c !== vid ? (V[c].nodeIds || []).length : 0, n = +nodeIn(nid, vid).opensCount;
@@ -1657,7 +1769,7 @@ function mapContext(model, opts = {}) {
1657
1769
  const pair = {}; // A→B plus B→A reads as one two-way edge
1658
1770
  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; })
1659
1771
  .map(e => { const all = e.all || [e], subs = [...new Set(all.map(x => x.sublabel).filter(x => x != null && x !== '').map(String))];
1660
- 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 }; });
1772
+ 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) }; });
1661
1773
  }
1662
1774
  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
1663
1775
  // 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
@@ -1669,6 +1781,7 @@ function mapContext(model, opts = {}) {
1669
1781
  const P = planCache[vid] || { flow: true, backKeys: loopsOf(v).keys, steps: {} };
1670
1782
  if (!override && P.hub) return hubGeo(vid, P, edges, repair, quick);
1671
1783
  const pos = override || (v.layout ? fillLayout(vid, v.layout) : (layoutCache[vid] ||= autoPos(vid)));
1784
+ const ready = drawn[vid]; delete drawn[vid]; if (ready && !override && ready.pos === pos && repair === (v.repair !== false) && !quick) return ready.g;
1672
1785
  const mainSteps = v.mainSteps || v.render === 'process';
1673
1786
  const stepIds = v.stepIds || ids;
1674
1787
  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] }; }),
@@ -1760,11 +1873,11 @@ function mapContext(model, opts = {}) {
1760
1873
  if (evals >= budget) break;
1761
1874
  const c = JSON.parse(JSON.stringify(pos));
1762
1875
  if (kind === 'swap') { if (a === hub) continue; const t2 = c[a]; c[a] = c[arg]; c[arg] = t2; }
1763
- else if (kind === 'col') { const nc = ca + arg; let r = ra; while (ids.some(b => c[b][0] === nc && c[b][1] === r)) r++; c[a] = [nc, r]; }
1876
+ else if (kind === 'col') { const nc = ca + arg; let r = ra; while (ids.some(b => b !== a && c[b][0] === nc && Math.abs(c[b][1] - r) < 1)) r++; c[a] = [nc, r]; }
1764
1877
  else if (kind === 'row') {
1765
1878
  let r = ra + arg;
1766
1879
  if (band(a) && r <= stateFloor()) r = stateFloor() + 1;
1767
- while (ids.some(b => c[b][0] === ca && c[b][1] === r)) r += arg > 0 ? 1 : -1;
1880
+ while (ids.some(b => b !== a && c[b][0] === ca && Math.abs(c[b][1] - r) < 1)) r += arg > 0 ? 1 : -1; // rows may be half rows: keep a row apart
1768
1881
  if (r < 0 || (band(a) && r <= stateFloor())) continue;
1769
1882
  c[a][1] = r;
1770
1883
  }
@@ -1782,23 +1895,424 @@ function mapContext(model, opts = {}) {
1782
1895
  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]);
1783
1896
  return JSON.stringify([v, ns, viewEdges(v), ids.map(i => [kidCount(i, vid), opensOf(i, vid)]), icons, who]);
1784
1897
  };
1898
+ /* ---------- unfolding in place (#71): a stacked card opens into a box on the view it is on ----------
1899
+ The card becomes a box with its id and name, holding its inner view's own cards: those not on this view already (the
1900
+ inner view's other cards are this view's, its neighbours). Inside, the inner view's lines between them. This view's
1901
+ lines stay this view's: one to the card moves onto every card inside that the inner view joins to that neighbour, the
1902
+ same way round, or, with none, ends on the box's border; the inner view's other lines to neighbours are not added. The
1903
+ 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
1904
+ 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
1905
+ has no columns, is laid out again). The inner view's boxes that hold its own cards come along inside. Neither in nor of
1906
+ a numbered (sequence) view: its run order, numbers and lanes, would move or be lost; that one opens. */
1907
+ 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;
1908
+ if (!c || c === vid || V[c].render === 'sequence' || chain(vid).includes(c) || (v.containers || []).some(b => b && b.id === nid)) return null;
1909
+ return (V[c].nodeIds || []).some(i => N[i] && i !== nid && !ids.includes(i)) ? c : null; };
1910
+ function unfold(vid, nid) {
1911
+ const v = V[vid], c = unfoldable(nid, vid); if (!c) return false;
1912
+ const C = V[c], outer = (v.nodeIds || []).filter(i => N[i]), inner = (C.nodeIds || []).filter(i => N[i] && i !== nid && !outer.includes(i));
1913
+ const n0 = nodeIn(nid, vid), holder = (v.containers || []).filter(b => b && (b.nodeIds || []).includes(nid)).pop();
1914
+ const boxes = (v.containers || []).filter(b => b && b.id).map(b => ({ ...b, nodeIds: (b.nodeIds || []).filter(i => i !== nid) }));
1915
+ const taken = new Set([...outer, ...inner, ...boxes.map(b => b.id)]);
1916
+ 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));
1917
+ 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; };
1918
+ const own = cb.map(b => ({ ...b, nodeIds: b.nodeIds.filter(i => inner.includes(i)), parent: above(b) })), boxed = new Set(own.flatMap(b => b.nodeIds));
1919
+ 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) } : {}) };
1920
+ const isIn = id => inner.includes(id) || kept.has(id), mine = viewEdges(v);
1921
+ 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
1922
+ let theirs = viewEdges(C).filter(e => isIn(e.from) ? isIn(e.to) || from.has(e.to) : isIn(e.to) && to.has(e.from));
1923
+ // a line drawn both ways stays a pair: it moves inside only if the inner view joins that neighbour to one card both ways
1924
+ 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();
1925
+ 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(','))));
1926
+ theirs = theirs.filter(e => !split.has(isIn(e.from) ? e.to : e.from));
1927
+ 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
1928
+ const k = (v.nodeIds || []).indexOf(nid), grid = !planOf(vid).hub;
1929
+ const pos = grid ? (v.layout ? fillLayout(vid, v.layout) : (layoutCache[vid] ||= autoPos(vid))) : null;
1930
+ Object.assign(v, { nodeIds: [...v.nodeIds.slice(0, k), ...inner, ...v.nodeIds.slice(k + 1)], containers: [...boxes, box, ...own],
1931
+ edges: [...mine.filter(e => !(e.to === nid && exact.has(e.from + '>')) && !(e.from === nid && exact.has('>' + e.to))), ...theirs] });
1932
+ if ((v.tour || []).includes(nid)) delete v.tour;
1933
+ if (v.mainSteps || v.render === 'process') v.stepIds = v.stepIds || outer; // the steps keep their numbers; the cards inside are not steps
1934
+ if (Array.isArray(v.intake)) v.intake = v.intake.filter(s => s && s.to !== nid);
1935
+ 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
1936
+ delete v.layout; delete planCache[vid]; delete layoutCache[vid]; delete drawn[vid];
1937
+ if (!grid) return true; // a hub view has no columns to keep: laid out again, with its boxes
1938
+ /* 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,
1939
+ upside down, or each card level with its neighbours (the median row of the cards outside it joins to, and of those to
1940
+ its left inside, as rowsFor does). Each way is routed, and the one the layout check scores best is kept */
1941
+ 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; };
1942
+ const levelled = (ic, ir) => { const R = {}, cols = [...new Set(inner.map(ic))].sort((a, b) => a - b);
1943
+ cols.forEach(k => { const want = {}, col = inner.filter(i => ic(i) === k);
1944
+ 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);
1945
+ want[i] = nb.length ? med(nb) : ir(i); });
1946
+ 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); }); });
1947
+ return i => R[i]; };
1948
+ // with boxes of its own, the inner view keeps its slots, moved to the box's corner: empty slots are its boxes' room. Else
1949
+ // its columns, or columns in this view's own reading order: a loop the inner view closes with one line may close here
1950
+ // with another, or not at all
1951
+ 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))]);
1952
+ 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
1953
+ const ip0 = C.layout ? fillLayout(c, C.layout) : (layoutCache[c] ||= autoPos(c)), lp = loopsOf(v), rk = Object.fromEntries(inner.map(i => [i, 0]));
1954
+ const fwd = lp.edges.filter((e, j) => !lp.back.has(j) && e.from !== e.to && inner.includes(e.from) && inner.includes(e.to));
1955
+ 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; });
1956
+ const one = own.length || v.nodeIds.length > 30; // its boxes' room to keep, or too big to route seven times: its own slots only
1957
+ // the sides the inner view gave its lines are for its own picture: with its own slots they stay; elsewhere each way is
1958
+ // also routed with the router picking them (copies, which keep the ids)
1959
+ 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;
1960
+ [ip0, ...(one ? [] : [Object.fromEntries(inner.map(i => [i, [rk[i], (ip0[i] || [0, 0])[1]]]))])].forEach(ip => (one ? [false] : [false, true, 'level']).forEach(flip => {
1961
+ 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];
1962
+ 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)
1963
+ return own.length ? i => (at(i, j) - lo) * k : i => u.indexOf(at(i, j)); };
1964
+ 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 = {};
1965
+ 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]; });
1966
+ inner.forEach(i => { L[i] = [xc + ic(i), xr + ir(i)]; });
1967
+ // a box of this view with cards on both sides of the card's column, under it, would now reach into the unfolded box:
1968
+ // it moves down under it, whole; then a card on a taken slot moves down a row
1969
+ boxes.forEach(b => { if (xBoxes.has(b.id)) return; const ms = inBox(b.id).filter(i => L[i]); if (!ms.length) return;
1970
+ const cs = ms.map(i => L[i][0]), rs = ms.map(i => L[i][1]);
1971
+ if (Math.max(...cs) < xc || Math.min(...cs) > xc + w - 1 || Math.max(...rs) < xr || Math.min(...rs) > xr + h - 1) return;
1972
+ const dr = xr + h - Math.min(...rs); ms.forEach(i => { L[i] = [L[i][0], L[i][1] + dr]; }); });
1973
+ const slots = inner.map(i => L[i]); outer.forEach(i => { if (i === nid) return; let [c0, r0] = L[i];
1974
+ while (slots.some(([c1, r1]) => c1 === c0 && Math.abs(r1 - r0) < 1)) r0++; slots.push(L[i] = [c0, r0]); });
1975
+ const key = JSON.stringify(inner.map(i => L[i])); if (seen.has(key)) return; seen.add(key);
1976
+ ways.push({ L, free: false }); if (freeE && (ip !== ip0 || flip !== false)) ways.push({ L, free: true }); }));
1977
+ // 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
1978
+ 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
1979
+ && !(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);
1980
+ 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
1981
+ const score = ({ L, free }) => { v.edges = free ? freeE : keepE; hush = []; let g; try { g = buildGeo(vid, L); } finally { g && (g.said = hush); hush = null; }
1982
+ const s = scoreGeo(g) + (strays(g) * 5 + leaveIssues(g, '').length) * 1000; if (!best || s < best.s - .5) best = { L, g, s, free }; };
1983
+ ways.forEach(score);
1984
+ // still an issue, and cards under the card in its column: the box one column to the right instead, so they keep their slots
1985
+ 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 = {};
1986
+ 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]; });
1987
+ inner.forEach(i => { L[i] = [B[i][0] + 1, B[i][1]]; }); score({ L, free: best.free }); }
1988
+ best.g.said.forEach(warn); delete best.g.said;
1989
+ 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
1990
+ return true;
1991
+ }
1992
+ const unfolded = {}; // per view: the cards opts.expand named that did unfold, in order (one inside another unfolds after it)
1993
+ withM(() => Object.entries(opts.expand || {}).forEach(([vid, ids]) => { let todo = V[vid] ? [...new Set([].concat(ids || []))] : [];
1994
+ 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); }); } }))();
1785
1995
  const geoCache = {};
1786
1996
  return { D, views, nodes, N, V, rootId, nodeIn, childOf, opensOf, subjectOf, viewsWith, kidsOf, chain, viewEdges, isState, planOf: withM(planOf), planCache,
1787
- layoutCache, geoCache, buildGeo: withM(buildGeo), buildView: withM(buildView), optimize: withM(optimize), flowPos, kidCount, viewKey };
1997
+ layoutCache, geoCache, buildGeo: withM(buildGeo), buildView: withM(buildView), optimize: withM(optimize), flowPos, kidCount, viewKey, brokenIn, unfoldable, unfolded };
1788
1998
  }
1789
1999
 
1790
2000
  const edgeRefIds = e => [...new Set((e.all || [e]).map(x => x._id).filter(Boolean))];
1791
2001
 
1792
2002
  /* layout(model, viewId): one view's geometry, as the canvas draws it. No DOM. */
1793
2003
  export function layout(model, viewId, options = {}) {
1794
- const ctx = mapContext(model, { measure: options.measure });
2004
+ const ctx = mapContext(model, { measure: options.measure, ...(options.expand ? { expand: { [viewId]: options.expand } } : {}) }); // expand: cards unfolded in place (#71)
1795
2005
  if (!ctx.V[viewId]) throw new Error(`umlimator: no view ${JSON.stringify(viewId)}`);
1796
2006
  ctx.planOf(viewId);
1797
2007
  const g = ctx.buildGeo(viewId, undefined, options.repair);
1798
2008
  g.edges.forEach(e => { e.ids = edgeRefIds(e); });
2009
+ if (options.expand) g.unfolded = ctx.unfolded[viewId] || [];
1799
2010
  return g;
1800
2011
  }
1801
2012
 
2013
+ /** The layout check's geometry for one view, without a browser: overlapping cards, crossing or shared lines, detours,
2014
+ * lines against reading order or through a card, labels on lines or off their own, boxes and lanes. The page's check
2015
+ * (`umlimator.py check`) adds what it measures in the DOM: text that overflows its card or touches its mark, titles
2016
+ * that overflow, edge labels on cards or on each other. Each issue names the cards and lines it is about. */
2017
+ export function check(model, viewId, options = {}) {
2018
+ const g = layout(model, viewId, options), where = `view ${viewId}`, about = [], lines = new Set(g.edges), boxes = new Set(g.families || []);
2019
+ 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 };
2020
+ return [...overlapIssues(g, where, about), ...routeAudit(g, where, null, about), ...leaveIssues(g, where, about)]
2021
+ .map((message, i) => ({ message, targets: about[i].filter(Boolean).map(ref) }));
2022
+ }
2023
+
2024
+ const WIRE_KINDS = ['calls', 'feeds', 'triggers', 'state', 'guard'], SIDES = ['left', 'right', 'top', 'bottom'];
2025
+ /** A model's own problems, before anything is drawn: errors draw as holes or not at all (ids that are missing,
2026
+ * repeated or name nothing, boxes inside themselves); warnings draw other than they say (an unknown kind or weight,
2027
+ * a side that is not one, a view opened before the host has loaded it). Each says where: `views[2].nodeIds[4]`. */
2028
+ export function validate(model) {
2029
+ const out = [], err = (path, message) => out.push({ level: 'error', path, message }), warn = (path, message) => out.push({ level: 'warning', path, message });
2030
+ const arr = x => (Array.isArray(x) ? x : []), m = model && typeof model === 'object' ? model : {}, views = arr(m.views).filter(v => v && typeof v === 'object');
2031
+ if (!Array.isArray(m.nodes)) err('nodes', 'the model needs a list of cards');
2032
+ if (!views.length) err('views', 'the model has no views');
2033
+ const ids = new Set(), vids = new Set(), lineIds = new Set();
2034
+ const card = (n, at) => {
2035
+ if (!n || typeof n.id !== 'string' || !n.id) return err(at + '.id', 'a card needs an id');
2036
+ if (ids.has(n.id)) err(at + '.id', `card id "${n.id}" is used twice`); ids.add(n.id);
2037
+ const k = String(n.kind ?? '').toLowerCase();
2038
+ 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`);
2039
+ };
2040
+ arr(m.nodes).forEach((n, i) => card(n, `nodes[${i}]`));
2041
+ views.forEach((v, i) => arr(v.nodes).forEach((n, j) => card(n, `views[${i}].nodes[${j}]`)));
2042
+ 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); });
2043
+ if (m.root != null && !vids.has(m.root)) err('root', `root "${m.root}" is not a view`);
2044
+ 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`); };
2045
+ arr(m.nodes).forEach((n, i) => later(n, `nodes[${i}]`));
2046
+ const line = (e, at, ends) => {
2047
+ if (!e || typeof e !== 'object') return err(at, 'a line is an object with from and to');
2048
+ ['from', 'to'].forEach(k => { if (!ends.has(e[k])) err(`${at}.${k}`, `"${e[k]}" is neither a card nor a box`); });
2049
+ if (e.kind != null && !WIRE_KINDS.includes(e.kind)) warn(at + '.kind', `unknown line kind "${e.kind}": drawn as calls`);
2050
+ 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`);
2051
+ ['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`); });
2052
+ };
2053
+ // a model line may end on any view's box (#28); a view's own line only on that view's boxes
2054
+ const anyBox = new Set(views.flatMap(v => arr(v.containers).map(c => c && c.id)));
2055
+ arr(m.edges).forEach((e, i) => { line(e, `edges[${i}]`, new Set([...ids, ...anyBox]));
2056
+ 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); } });
2057
+ views.forEach((v, i) => {
2058
+ const at = `views[${i}]`, mine = new Set(arr(v.nodeIds)), boxes = {};
2059
+ if (v.parent != null && !vids.has(v.parent) && !ids.has(v.parent)) err(at + '.parent', `parent "${v.parent}" is not a view`);
2060
+ arr(v.nodeIds).forEach((id, j) => { if (!ids.has(id)) err(`${at}.nodeIds[${j}]`, `"${id}" is not a card`); });
2061
+ 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}`); });
2062
+ arr(v.nodes).forEach((n, j) => later(n, `${at}.nodes[${j}]`));
2063
+ ['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`); }));
2064
+ arr(v.browserOrder).forEach((id, j) => { if (!vids.has(id)) err(`${at}.browserOrder[${j}]`, `"${id}" is not a view`); });
2065
+ 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`); });
2066
+ 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; });
2067
+ arr(v.containers).forEach((c, j) => { if (!c) return;
2068
+ 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`); });
2069
+ if (c.parent != null && !boxes[c.parent]) err(`${at}.containers[${j}].parent`, `"${c.parent}" is not a box of this view`);
2070
+ 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); } });
2071
+ const ends = new Set([...ids, ...Object.keys(boxes)]);
2072
+ 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`); });
2073
+ 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`); });
2074
+ 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`); }));
2075
+ });
2076
+ // rules (#70): an id, and from, to or noCycles; a selector that names nothing picks nothing, and a misspelt kind picks script cards
2077
+ if (m.rules != null && !Array.isArray(m.rules)) err('rules', 'rules is a list');
2078
+ 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); });
2079
+ const pick = (s, at) => {
2080
+ if (s == null) return;
2081
+ if (typeof s !== 'object' || Array.isArray(s)) return err(at, 'a selector is an object: id, kind, container or not');
2082
+ 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`); });
2083
+ 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`); });
2084
+ 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`); });
2085
+ each(s.container, at + '.container', (x, p) => { if (!allBoxes.has(x)) warn(p, `"${x}" is not a box: it picks nothing`); });
2086
+ pick(s.not, at + '.not');
2087
+ };
2088
+ arr(m.rules).forEach((r, i) => {
2089
+ const at = `rules[${i}]`;
2090
+ if (!r || typeof r !== 'object') return err(at, 'a rule is an object with an id');
2091
+ 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);
2092
+ if (!r.from && !r.to && !r.noCycles) err(at, 'a rule needs from, to or noCycles');
2093
+ pick(r.from, at + '.from'); pick(r.to, at + '.to');
2094
+ arr(r.views).forEach((v, j) => { if (!vids.has(v)) err(`${at}.views[${j}]`, `"${v}" is not a view`); });
2095
+ });
2096
+ // stories (#69): each step's view, and the cards, lines and tour it names there
2097
+ if (m.stories != null && !Array.isArray(m.stories)) err('stories', 'stories is a list of stories');
2098
+ const sids = new Set(), lines = new Set([...lineIds, ...views.flatMap(v => arr(v.edges).filter(e => e && e.id != null).map(e => e.id))]);
2099
+ arr(m.stories).forEach((s, i) => {
2100
+ const at = `stories[${i}]`;
2101
+ if (!s || typeof s.id !== 'string' || !s.id) return err(at + '.id', 'a story needs an id');
2102
+ if (sids.has(s.id)) err(at + '.id', `story id "${s.id}" is used twice`); sids.add(s.id);
2103
+ if (!arr(s.steps).length) err(at + '.steps', 'a story needs steps');
2104
+ arr(s.steps).forEach((st, j) => {
2105
+ const p = `${at}.steps[${j}]`, v = views.find(x => x.id === (st && st.view));
2106
+ if (!v) return err(p + '.view', `"${st && st.view}" is not a view`);
2107
+ 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)
2108
+ 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`); };
2109
+ named(st.select, p + '.select');
2110
+ arr(st.highlight).forEach((x, k) => named(x, `${p}.highlight[${k}]`));
2111
+ arr(st.tour).forEach((x, k) => { if (!mine.has(x)) err(`${p}.tour[${k}]`, `"${x}" is not one of the view's cards`); });
2112
+ const c = st.camera, fit = ': the step shows the whole view';
2113
+ 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}`); }
2114
+ 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}`);
2115
+ if (st.duration != null && !(st.duration > 0)) warn(p + '.duration', `duration ${JSON.stringify(st.duration)} is not a number of ms: 4000`);
2116
+ });
2117
+ });
2118
+ return out;
2119
+ }
2120
+
2121
+ /* ================= rules (#70): what a model says must hold =================
2122
+ A rule forbids lines from the cards `from` picks to the cards `to` picks (a side left out picks every card), or, with
2123
+ noCycles, boxes that reach each other through their lines. A line counts between the two boxes that hold its ends right
2124
+ 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
2125
+ own inner box counts for nothing. A selector picks cards by id, kind (as the map draws it: "db" is a database) or box
2126
+ (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
2127
+ 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
2128
+ 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
2129
+ every view. `views` narrows that further. */
2130
+ const boxesNamed = (s, isBox) => !s || typeof s !== 'object' ? []
2131
+ : [...[s.container].flat(), ...[s.id].flat().filter(isBox), ...boxesNamed(s.not, isBox)].filter(x => x != null);
2132
+ const picks = (s, end) => !s || ((s.id == null || [s.id].flat().includes(end.id))
2133
+ && (s.kind == null || [s.kind].flat().some(k => end.kind != null && kindOf(k, true) === end.kind))
2134
+ && (s.container == null || [s.container].flat().some(c => end.boxes.includes(c)))
2135
+ && (s.not == null || !picks(s.not, end)));
2136
+ const andList = xs => xs.length > 1 ? xs.slice(0, -1).join(', ') + ' and ' + xs[xs.length - 1] : xs.join(''), arrOf = x => (Array.isArray(x) ? x : []);
2137
+ /** Every rule the model breaks, in each view that draws it broken: a line a rule forbids, or a cycle of boxes. */
2138
+ export function rules(model) {
2139
+ const list = arrOf(model && model.rules).filter(r => r && typeof r === 'object' && (r.from || r.to || r.noCycles));
2140
+ if (!list.length) return [];
2141
+ const { views, N, nodeIn, viewEdges } = mapContext(model), out = [];
2142
+ const anyBox = new Set(views.flatMap(v => arrOf(v.containers).map(c => c && c.id).filter(Boolean)));
2143
+ const named = new Map(list.map(r => [r, [r.from, r.to].flatMap(s => boxesNamed(s, id => anyBox.has(id) && !N[id]))]));
2144
+ views.forEach(v => {
2145
+ const cs = arrOf(v.containers).filter(c => c && c.id), box = {}; cs.forEach(c => { box[c.id] ||= c; });
2146
+ const order = Object.values(box).map(c => c.id), home = {}; cs.forEach(c => arrOf(c.nodeIds).forEach(id => { home[id] ??= c.id; }));
2147
+ 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
2148
+ const end = id => box[id] ? { id, kind: null, boxes: chain(id), label: box[id].label || id }
2149
+ : { id, kind: kindOf(nodeIn(id, v.id).kind, true), boxes: chain(home[id]), label: nodeIn(id, v.id).label || id };
2150
+ // a target names the drawn line, which may stand for several edges: grouped as prepEdges groups them (parallel edges, and
2151
+ // A→B with B→A, unless keepPairs), in its order, so select() finds it
2152
+ // ponytail: a copy of prepEdges' grouping; share one helper if that grouping changes
2153
+ const lines = viewEdges(v), par = new Map(), drawn = new Map(), ends = new Map();
2154
+ 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]); });
2155
+ par.forEach(es => { const back = !v.keepPairs && ends.get(es[0].to + '>' + es[0].from);
2156
+ if (back) back.push(...es); else ends.set(es[0].from + '>' + es[0].to, es); es.forEach(e => drawn.set(e, back || es)); });
2157
+ const line = e => ({ type: 'edge', ids: [...new Set(drawn.get(e).map(x => x._id))], view: v.id });
2158
+ let rings = null; // the cycles of boxes, worked out for the first noCycles rule: [boxes, the lines between them]
2159
+ const cycles = () => {
2160
+ const next = {}; // box -> Map(box -> the lines from the one to the other)
2161
+ 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++;
2162
+ if (i < A.length && i < B.length) { const m = next[A[i]] ||= new Map(); m.set(B[i], [...(m.get(B[i]) || []), e]); } });
2163
+ 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; };
2164
+ const R = Object.fromEntries(order.map(b => [b, reach(b)])), done = new Set(), found = [];
2165
+ order.forEach(b => { if (done.has(b) || !R[b].has(b)) return;
2166
+ const ring = order.filter(c => R[b].has(c) && R[c].has(b)); ring.forEach(c => done.add(c));
2167
+ found.push([ring, ring.flatMap(a => ring.flatMap(c => (next[a] && next[a].get(c)) || []))]); });
2168
+ return found;
2169
+ };
2170
+ list.forEach(r => {
2171
+ if (Array.isArray(r.views) && !r.views.includes(v.id)) return;
2172
+ if (named.get(r).length && !named.get(r).some(b => box[b])) return; // it names boxes, none of them this view's
2173
+ const breaks = `breaks rule "${r.title || r.id}"`;
2174
+ if (r.noCycles) (rings ||= cycles()).forEach(([ring, via]) => out.push({ rule: r.id, view: v.id,
2175
+ message: `view ${v.id}: boxes ${andList(ring.map(c => `"${box[c].label || c}"`))} form a cycle, which ${breaks}`,
2176
+ targets: [...ring.map(c => ({ type: 'container', id: c, view: v.id })), ...[...new Set(via.map(e => drawn.get(e)))].map(es => line(es[0]))] }));
2177
+ else { const said = new Set(); // parallel edges that break it read as one line, once
2178
+ lines.forEach(e => { const a = end(e.from), b = end(e.to), message = `view ${v.id}: edge ${a.label}→${b.label} ${breaks}`;
2179
+ 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)] }); } }); }
2180
+ });
2181
+ });
2182
+ return out;
2183
+ }
2184
+
2185
+ /* ---------- change: diff (#66) and drift (#68) ----------
2186
+ Each returns a model marked with what the map draws already, so a page the CLI writes, or a still, shows the marks
2187
+ with nothing to call at run time: a card's change (a NEW or CHANGED tab; REMOVED is faded, dashed and struck
2188
+ through), a line's style (added: green and flowing; removed: rose, dashed and faded; security: rose and dashed;
2189
+ faded: its own colour, faint and still) and edited (an amber dot on the line). */
2190
+ 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);
2191
+ // 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
2192
+ const lineKeys = list => { const seen = {}; return list.map(e => typeof e === 'string' ? '=' + e : e && typeof e === 'object'
2193
+ ? (e.id != null ? String(e.id) : (k => `${k}#${seen[k] = (seen[k] ?? -1) + 1}`)(e.from + '->' + e.to)) : ''); };
2194
+ // a line that stands for several edges (A→B and B→A, parallel edges) shows the strongest of their marks: removed or
2195
+ // undrawn, then added, then changed (its edited dot, which a stronger mark hides); it fades as idle only when every one
2196
+ // of them is idle, and otherwise takes the first style that is no mark
2197
+ const markOf = all => { const top = all.find(x => x.style === 'removed' || x.drift === 'undrawn') || all.find(x => x.style === 'added');
2198
+ if (top) return { style: top.style, strong: true };
2199
+ return { style: all.every(x => x.style === 'faded') ? 'faded' : (all.find(x => x.style && x.style !== 'faded') || {}).style }; };
2200
+ // 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
2201
+ const linesOf = (m, v) => { const all = Array.isArray(m.edges) ? m.edges.filter(Boolean) : [];
2202
+ 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); }
2203
+ 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)); };
2204
+
2205
+ /** What changed from one model to the next, as one model to draw: `after`, with each card and line marked added, changed
2206
+ * or removed (removed ones come back from `before`, in their views, boxes and places), a summary in words, and the
2207
+ * views that show a change. Cards and lines are matched by id (a line without one by its ends, as a Ref names it). */
2208
+ export function diff(before, after) {
2209
+ const arr = x => (Array.isArray(x) ? x : []), A = structuredClone(before || {}), out = structuredClone(after || {});
2210
+ const how = new Map(), was = new Map(), said = []; // each marked card or line: added, changed or removed; a changed one's [before, after]
2211
+ const card = (x, h) => ({ ...x, change: h });
2212
+ const line = (x, h) => ({ ...x, change: h, ...(h === 'changed' ? { edited: true } : { style: h }) });
2213
+ 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
2214
+ const ko = keys(old), kn = keys(now), O = new Map(old.map((x, i) => [ko[i], x])), kept = new Set(kn);
2215
+ const res = now.map((x, i) => { if (!x || typeof x !== 'object') return x; const o = O.get(kn[i]);
2216
+ if (o && typeof o === 'object' && canon(o) === canon(x)) return x;
2217
+ 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; });
2218
+ 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); } });
2219
+ return res;
2220
+ }
2221
+ out.nodes = mark(arr(A.nodes), arr(out.nodes), card);
2222
+ out.edges = mark(arr(A.edges), arr(out.edges), line, lineKeys);
2223
+ const goneLines = new Set(out.edges.filter(e => how.get(e) === 'removed' && e.id != null).map(e => String(e.id)));
2224
+ 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));
2225
+ const moved = [], touched = new Set(); // cards put into or taken out of a view; views whose cards the diff marks there
2226
+ const views = arr(out.views).map(v => {
2227
+ const o = v && V0.get(v.id); if (!o) return v;
2228
+ const w = { ...v }, ids = arr(v.nodeIds), oldIds = arr(o.nodeIds);
2229
+ if (v.nodes || o.nodes) w.nodes = mark(arr(o.nodes), arr(v.nodes), card);
2230
+ const at = new Map([...out.nodes, ...arr(w.nodes)].filter(Boolean).map(n => [n.id, n]));
2231
+ const left = oldIds.filter(id => !ids.includes(id)), came = ids.filter(id => !oldIds.includes(id)), ov = { ...(v.nodeOverrides || {}) };
2232
+ w.nodeIds = [...ids, ...left];
2233
+ left.forEach(id => { if (how.get(at.get(id)) !== 'removed') { ov[id] = { ...(o.nodeOverrides || {})[id], change: 'removed' }; moved.push(['takes', id, v]); } });
2234
+ came.forEach(id => { if (at.get(id) && how.get(at.get(id)) !== 'added') { ov[id] = { ...ov[id], change: 'added' }; moved.push(['puts', id, v]); } });
2235
+ if (left.length || came.length) touched.add(v.id);
2236
+ if (Object.keys(ov).length) w.nodeOverrides = ov;
2237
+ 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
2238
+ 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; });
2239
+ if (left.length && v.layout && o.layout) { const L = { ...v.layout }, taken = new Set(Object.values(L).map(String));
2240
+ 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; }
2241
+ if (Array.isArray(v.edges)) { w.edges = mark(arr(o.edges), v.edges, line, lineKeys);
2242
+ 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
2243
+ return w;
2244
+ });
2245
+ arr(A.views).forEach(o => { if (!o || V1.has(o.id)) return; // a removed view comes back with everything in it removed
2246
+ const w = { ...o, nodeOverrides: { ...(o.nodeOverrides || {}) } };
2247
+ arr(o.nodeIds).forEach(id => { w.nodeOverrides[id] = { ...w.nodeOverrides[id], change: 'removed' }; });
2248
+ if (o.nodes) w.nodes = o.nodes.map(n => { const y = card(n, 'removed'); how.set(y, 'removed'); return y; });
2249
+ if (Array.isArray(o.edges)) w.edges = o.edges.map(e => e && typeof e === 'object' ? line(e, 'removed') : e);
2250
+ touched.add(o.id); views.push(w); });
2251
+ out.views = views;
2252
+
2253
+ // the summary: views, then cards, then lines, in model order; a line to a card that came or went goes with the card
2254
+ const cards = [...out.nodes, ...views.flatMap(v => arr(v && v.nodes))].filter(Boolean);
2255
+ const named = new Map([...views.flatMap(v => arr(v && v.containers)), ...cards].filter(Boolean).map(n => [n.id, n]));
2256
+ 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)]));
2257
+ const lines = [...out.edges, ...views.flatMap(v => arr(v && v.edges).filter(e => e && typeof e === 'object'))];
2258
+ const diffKeys = ([o, x]) => [...new Set([...Object.keys(o), ...Object.keys(x)])].filter(k => canon(o[k]) !== canon(x[k]));
2259
+ views.forEach(v => { if (v && !V0.has(v.id)) said.push(`adds the view ${v.title || v.id}`); });
2260
+ arr(A.views).forEach(o => { if (o && !V1.has(o.id)) said.push(`removes the view ${o.title || o.id}`); });
2261
+ cards.forEach(n => { const h = how.get(n);
2262
+ 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));
2263
+ 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)}` : ''))); }
2264
+ if (h === 'removed') said.push(`removes ${name(n.id)}`);
2265
+ if (h === 'changed') { const [o, x] = was.get(n), keys = diffKeys([o, x]), rest = keys.filter(k => k !== 'label');
2266
+ said.push((keys.includes('label') ? `renames ${o.label || o.id} to ${x.label || x.id}` : `changes ${name(n.id)}`) + (rest.length ? ` (${rest.join(', ')})` : '')); } });
2267
+ 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;
2268
+ const ends = `${name(e.from)} to ${name(e.to)}`, lab = e.label ? ` (${e.label})` : '';
2269
+ if (h === 'added') said.push(`adds a line from ${ends}${lab}`);
2270
+ if (h === 'removed') said.push(`removes the line from ${ends}${lab}`);
2271
+ if (h === 'changed') { const [o, x] = was.get(e);
2272
+ said.push(`changes the line from ${ends} (${diffKeys([o, x]).map(k => k === 'label' ? `label ${o.label ?? '-'} → ${x.label ?? '-'}` : k).join(', ')})`); } });
2273
+ moved.forEach(([verb, id, v]) => said.push(`${verb} ${name(id)} ${verb === 'puts' ? 'in' : 'out of'} the view ${v.title || v.id}`));
2274
+
2275
+ // the views that show a change: a card or line the diff marks, or a card it put in or took out
2276
+ 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);
2277
+ return { model: out, summary: said, views: changed };
2278
+ }
2279
+
2280
+ /** The map against the traffic seen: a line that carries traffic but is not drawn comes in, dashed rose ("not drawn",
2281
+ * with its calls); a drawn line between two cards that traffic was seen at, with none of its own, fades. `observed`:
2282
+ * [{ from, to, count }], or a Jaeger or Tempo dependency list ([{ parent, child, callCount }], or { data: [...] }). A
2283
+ * service is a card by `options.match[service]`, else by id, else by label (case aside). Counts: lines that carried
2284
+ * traffic, came in undrawn, are idle, or could not be judged (an end traffic never reached), and services with no card,
2285
+ * each a count of model edges (a drawn line that stands for several fades only when all are idle); views: those that
2286
+ * show drift. */
2287
+ export function drift(model, observed, options = {}) {
2288
+ const arr = x => (Array.isArray(x) ? x : []), out = structuredClone(model || {}), match = options.match || {};
2289
+ const list = Array.isArray(observed) ? observed : arr(observed && observed.data);
2290
+ const cards = [...arr(out.nodes), ...arr(out.views).flatMap(v => arr(v && v.nodes))].filter(n => n && n.id != null);
2291
+ const ids = new Set(cards.map(n => String(n.id))), lower = new Map();
2292
+ 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)); }));
2293
+ 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; };
2294
+ const seen = new Map(), live = new Set(), unmatched = new Set(); let counted = false;
2295
+ list.forEach(o => { if (!o || typeof o !== 'object') return;
2296
+ const f = o.from ?? o.parent, t = o.to ?? o.child, n = o.count ?? o.callCount, a = cardOf(f), b = cardOf(t);
2297
+ if (a == null) unmatched.add(String(f)); if (b == null) unmatched.add(String(t));
2298
+ if (a == null || b == null || a === b) return;
2299
+ live.add(a); live.add(b);
2300
+ if (n != null) { counted = true; if (!(+n > 0)) return; } // seen with no calls: both ends were watched, the line carried nothing
2301
+ const k = a + '\n' + b, s = seen.get(k) || { a, b, n: 0 }; s.n += +n || 0; seen.set(k, s); });
2302
+ const counts = { carried: 0, undrawn: 0, idle: 0, unknown: 0, unmatched: [...unmatched].sort() }, drawn = new Set();
2303
+ const judge = e => { if (!e || typeof e !== 'object') return e; const k = e.from + '\n' + e.to;
2304
+ if (seen.has(k)) { drawn.add(k); counts.carried++; return e; }
2305
+ if (live.has(String(e.from)) && live.has(String(e.to))) { counts.idle++; return { ...e, style: 'faded', drift: 'idle' }; }
2306
+ counts.unknown++; return e; };
2307
+ out.edges = arr(out.edges).map(judge);
2308
+ arr(out.views).forEach(v => { if (v && Array.isArray(v.edges)) v.edges = v.edges.map(judge); });
2309
+ seen.forEach(({ a, b, n }, k) => { if (drawn.has(k)) return; counts.undrawn++;
2310
+ const e = { id: `drift:${a}->${b}`, from: a, to: b, label: 'not drawn', ...(counted ? { sublabel: `${n.toLocaleString('en-US')} calls` } : {}), style: 'security', drift: 'undrawn' };
2311
+ out.edges.push(e);
2312
+ 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); }); });
2313
+ return { model: out, counts, views: arr(out.views).filter(v => v && linesOf(out, v).some(e => e.drift)).map(v => v.id) };
2314
+ }
2315
+
1802
2316
  let INSTANT = false; // the page's own hooks (#check, #still, #shot) draw without animation
1803
2317
  const reducedMotion = () => INSTANT || (typeof matchMedia === 'function' && matchMedia('(prefers-reduced-motion: reduce)').matches);
1804
2318
  let SHEET = null;
@@ -1827,7 +2341,8 @@ export function render(container, model, options = {}) {
1827
2341
  // text is measured in the host's font (--um-sans); measure: false keeps the font-independent estimate
1828
2342
  const own = typeof options.measure === 'function' ? options.measure : null;
1829
2343
  let measure = options.measure === false ? null : own || hostMeasure(host), current = model;
1830
- let ctx = mapContext(model, { measure, kinds: !!P.kinds }); // kinds: the standalone page guesses line kinds from label words, for its legend
2344
+ const expanded = {}; // per view: the cards unfolded in place (#71), in the order they unfolded; in this handle only, never in the model
2345
+ let ctx = mapContext(model, { measure, kinds: !!P.kinds, expand: expanded }); // kinds: the standalone page guesses line kinds from label words, for its legend
1831
2346
  const root = host.attachShadow({ mode: 'open', delegatesFocus: true }); // host.focus() lands in the map (on the selected card)
1832
2347
  root.adoptedStyleSheets = [sheet()];
1833
2348
  root.innerHTML = `<div class="um-live" role="status" aria-live="polite"></div><div class="stage" tabindex="-1">
@@ -1879,10 +2394,11 @@ export function render(container, model, options = {}) {
1879
2394
  cam.setSvg(svg, geo.bounds);
1880
2395
  const ub = q('[data-z="up"]'); if (ub) ub.disabled = !upOf();
1881
2396
  if (options.minimap) buildMini();
1882
- 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]]));
2397
+ 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
1883
2398
  const had = sel; sel = null; tabKeys(); paint();
1884
2399
  more = [];
1885
2400
  if (had) emitSel();
2401
+ endTrip(); drawMarks(); // a new picture: a tour or replay on the old one ends; the host's marks go on again (#63)
1886
2402
  if (P.onMount) P.onMount(inner);
1887
2403
  }
1888
2404
 
@@ -1892,7 +2408,7 @@ export function render(container, model, options = {}) {
1892
2408
  function clearOf(base) {
1893
2409
  if (!cam || !geo) return base;
1894
2410
  const [W, H] = cam.size(), key = JSON.stringify([W, H, base]); if (clear && clear.geo === geo && clear.key === key) return clear.sets;
1895
- const boxes = [...stage.querySelectorAll('.ctl, .minimap')].map(x => x.getBoundingClientRect()).filter(r => r.width && r.height);
2411
+ const boxes = [...stage.querySelectorAll('.ctl, .minimap, .um-story')].map(x => x.getBoundingClientRect()).filter(r => r.width && r.height);
1896
2412
  let sets = base; // none shown, or no layout (a test DOM): as before
1897
2413
  if (boxes.length) {
1898
2414
  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;
@@ -2064,6 +2580,35 @@ export function render(container, model, options = {}) {
2064
2580
  if (!stop && ctx.V[c]) go(c, { via: nid });
2065
2581
  }
2066
2582
  const up = () => { const p = upOf(); if (p) go(p); else status('Already at the top'); };
2583
+ /* unfolding in place (#71): a stacked card opens into a box on this view (mapContext's unfold), and folds back. Off in
2584
+ an editable map: an edit names the model's own views and boxes, and an unfolded box is neither. */
2585
+ const unfoldBox = id => { const f = ((ctx.V[cur] || {}).containers || []).find(b => b && b.id === id); return f && f.unfolds ? f : null; };
2586
+ 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
2587
+ 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; };
2588
+ function setUnfold(nid, on) { // true when the picture changed
2589
+ const list = expanded[cur] || [];
2590
+ if (E || dead || !cur || (on ? list.includes(nid) || !ctx.unfoldable(nid, cur) : !list.includes(nid))) return false;
2591
+ expanded[cur] = on ? [...list, nid] : list.filter(x => x !== nid);
2592
+ update(current, false, true);
2593
+ if ((expanded[cur] || []).includes(nid) !== on) return false; // the model would not have it after all
2594
+ const f = on && (geo.families || []).find(x => x.id === nid);
2595
+ if (f) { cam.reveal({ x: f.x, y: f.y, w: f.w, h: f.h }); select({ type: 'container', id: nid }, { reveal: false }); }
2596
+ else if (!on && geo.byId[nid]) select(nodeRef(nid));
2597
+ say(`${nameOf(nid)} ${on ? 'unfolded here' : 'folded'}`);
2598
+ return true;
2599
+ }
2600
+ function gesture(id, how) { // E, or Alt + click: a stacked card unfolds; an unfolded box, or a card in one, folds it
2601
+ if (E || !id || !geo) return false;
2602
+ 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);
2603
+ if (!nid) return false;
2604
+ let stop = false; // the host may take it, or load a view it has not yet and update() right here
2605
+ emit('expand', { target: nodeRef(nid), view: ctx.opensOf(nid, cur), expanded: on, preventDefault() { stop = true; } });
2606
+ if (stop || dead) return true;
2607
+ 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; }
2608
+ 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;
2609
+ 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
2610
+ return true;
2611
+ }
2067
2612
  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); }
2068
2613
  function setFlow(on) {
2069
2614
  stage.classList.toggle('flowing', on);
@@ -2075,6 +2620,7 @@ export function render(container, model, options = {}) {
2075
2620
  /* keys on the canvas; the page forwards the same keys when the focus is elsewhere */
2076
2621
  function key(e) {
2077
2622
  const k = e.key, g = e.target && e.target.closest && e.target.closest('.node');
2623
+ if (story) { const r = storyKey(e); if (r != null) return r; } // a story's keys first (#69)
2078
2624
  if (E && editKey(e)) return true;
2079
2625
  if (e.altKey) return false;
2080
2626
  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]');
@@ -2082,6 +2628,7 @@ export function render(container, model, options = {}) {
2082
2628
  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)
2083
2629
  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); }
2084
2630
  else if (k === 'Enter' && card) { const ref = chipRef(card, +ck.dataset.chip); if (activate(ref, e)) select(ref); } // as a click on the chip
2631
+ 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)
2085
2632
  else if (k === '+' || k === '=') cam.zoomAt(1.4, null, null, true);
2086
2633
  else if (k === '-' || k === '_') cam.zoomAt(1 / 1.4, null, null, true);
2087
2634
  else if (k === '0') cam.fit();
@@ -2283,6 +2830,8 @@ export function render(container, model, options = {}) {
2283
2830
  if (t.closest('.ctl, .minimap')) return;
2284
2831
  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');
2285
2832
  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)
2833
+ if (fam && t.closest('.fold-hint')) { if (e.detail <= 1) gesture(fam.dataset.family); return; } // an unfolded box's ⤡ tab folds it (#71)
2834
+ 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
2286
2835
  let ref = null;
2287
2836
  if (g && badge) ref = badgeRef(g.dataset.id, null, +badge.dataset.badge);
2288
2837
  else if (g && chip) ref = chipRef(g, +chip.dataset.chip);
@@ -2298,7 +2847,7 @@ export function render(container, model, options = {}) {
2298
2847
  select(null);
2299
2848
  if (P.onBackground) P.onBackground();
2300
2849
  });
2301
- stage.addEventListener('dblclick', e => { const g = e.target.closest('.node'); if (!g) return; e.preventDefault(); if (e.target.closest('.open-hint')) return;
2850
+ 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
2302
2851
  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
2303
2852
  open(g.dataset.id); });
2304
2853
  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(); } });
@@ -2318,6 +2867,7 @@ export function render(container, model, options = {}) {
2318
2867
  /* the picture as a file: CSS variables resolved into attributes so it stands alone. It is drawn at rest: no
2319
2868
  selection, hover, flow animation or edit handles, whatever the screen shows; the screen is left as it was. */
2320
2869
  function standalone(scale = 1, vid = cur, fontCSS = '') {
2870
+ if (settle) settle(); // an export right after update() draws where things end up, not mid-ease
2321
2871
  if (!svg || !geo) return '';
2322
2872
  let s = svg, g = geo, off = null;
2323
2873
  if (vid !== cur) { // another view: drawn off screen, inside the shadow root so the same styles apply
@@ -2326,6 +2876,7 @@ export function render(container, model, options = {}) {
2326
2876
  off = document.createElement('div'); off.style.cssText = 'position:absolute;left:-100000px;top:0;width:10px;height:10px;overflow:hidden;pointer-events:none';
2327
2877
  off.innerHTML = c.markup; stage.parentNode.appendChild(off); s = off.querySelector('svg'); s.classList.add('map-svg'); g = c.g;
2328
2878
  }
2879
+ if (off) drawMarks(s, g); // the host's marks, on another view too (#63)
2329
2880
  const s0 = sel, flowing = stage.classList.contains('flowing'), hov = [...svg.querySelectorAll('.hov')];
2330
2881
  sel = null; paint(); stage.classList.remove('flowing'); hov.forEach(x => x.classList.remove('hov')); stage.classList.add('um-still');
2331
2882
  const clone = s.cloneNode(true), b = g.bounds;
@@ -2337,7 +2888,7 @@ export function render(container, model, options = {}) {
2337
2888
  props.forEach(p => { const v = cs.getPropertyValue(p); if (v) d.style.setProperty(p, v); });
2338
2889
  if (cs.textTransform === 'uppercase' && !d.childElementCount) d.textContent = d.textContent.toUpperCase(); }); } // tab text, as drawn
2339
2890
  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(); }
2340
- clone.querySelectorAll('title, .flowline, .um-edit, .um-ghost, .um-link, .keys, .um-key').forEach(t => t.remove());
2891
+ clone.querySelectorAll('title, .flowline, .um-edit, .um-ghost, .um-link, .keys, .um-key, .um-trip').forEach(t => t.remove());
2341
2892
  clone.setAttribute('role', 'img'); clone.querySelectorAll('[tabindex], [aria-pressed]').forEach(t => ['tabindex', 'role', 'aria-pressed'].forEach(a => t.removeAttribute(a)));
2342
2893
  clone.setAttribute('viewBox', `${b.x} ${b.y} ${b.w} ${b.h}`); clone.setAttribute('width', b.w * scale); clone.setAttribute('height', b.h * scale);
2343
2894
  const bg = document.createElementNS('http://www.w3.org/2000/svg', 'rect');
@@ -2347,42 +2898,303 @@ export function render(container, model, options = {}) {
2347
2898
  return new XMLSerializer().serializeToString(clone);
2348
2899
  }
2349
2900
 
2350
- function update(model2, relayout = false) {
2901
+ /* the time-lapse (#67): an update that redraws the view eases into it. Cards that moved glide from where they were; new
2902
+ cards, and lines whose route changed, fade in; cards and lines that left fade out where they were, copied from the
2903
+ picture before (as nothing that can be clicked, focused, found or exported). CSS on the new picture only: the
2904
+ geometry is the update's own. Nothing moves under reduced motion. */
2905
+ let settle = null; // ends an ease still running (before an export, or the next update)
2906
+ function easeIn(oldGeo, oldSvg) {
2907
+ if (settle) settle();
2908
+ if (options.ease === false || reducedMotion() || !oldGeo || !oldSvg || !geo || !svg || oldSvg === svg) return;
2909
+ const T = 600, moves = [], key = e => edgeRefIds(e).join(' ') || e.from + '>' + e.to, all = (s, q) => [...s.querySelectorAll(q)];
2910
+ const was = new Map(oldGeo.nodes.map(n => [n.id, n])), now = new Set(geo.nodes.map(n => n.id));
2911
+ const lines = new Map(oldGeo.edges.map((e, i) => [key(e), [e, i]])), kept = new Set(geo.edges.map(key));
2912
+ geo.nodes.forEach(n => { const g = svg.querySelector(`.node[data-id="${CSS.escape(n.id)}"]`), o = was.get(n.id); if (!g) return;
2913
+ if (!o) moves.push([g, 'opacity', '0', 0]);
2914
+ 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]); });
2915
+ geo.edges.forEach((e, i) => { const o = lines.get(key(e)); if (o && canon(o[0].pts) === canon(e.pts)) return;
2916
+ all(svg, `.edge-g[data-i="${i}"], .e-lab[data-i="${i}"]`).forEach(x => moves.push([x, 'opacity', '0', T / 2])); });
2917
+ const gone = document.createElementNS('http://www.w3.org/2000/svg', 'g');
2918
+ gone.setAttribute('class', 'um-leaving'); gone.setAttribute('aria-hidden', 'true'); gone.style.pointerEvents = 'none';
2919
+ const leave = x => { const c = x.cloneNode(true);
2920
+ [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'); });
2921
+ gone.appendChild(c); };
2922
+ oldGeo.nodes.forEach(n => { if (!now.has(n.id)) all(oldSvg, `.node[data-id="${CSS.escape(n.id)}"]`).forEach(leave); });
2923
+ lines.forEach(([, i], k) => { if (!kept.has(k)) all(oldSvg, `.edge-g[data-i="${i}"], .e-lab[data-i="${i}"]`).forEach(leave); });
2924
+ if (!moves.length && !gone.childNodes.length) return;
2925
+ svg.appendChild(gone);
2926
+ moves.forEach(([el, p, v]) => { el.style.transition = 'none'; el.style.setProperty(p, v); });
2927
+ svg.getBoundingClientRect(); // the start, drawn: each change below is a transition from it
2928
+ moves.forEach(([el, p, , d]) => { el.style.transition = `${p} ${T}ms ease ${d}ms`; el.style.removeProperty(p); });
2929
+ gone.style.transition = `opacity ${T / 2}ms ease`; gone.style.opacity = '0';
2930
+ const t = later(() => settle && settle(), T * 2);
2931
+ settle = () => { clearTimeout(t); timers.delete(t); moves.forEach(([el]) => el.style.removeProperty('transition')); gone.remove(); settle = null; };
2932
+ }
2933
+
2934
+ function update(model2, relayout = false, unfolding = false) { // unfolding: the same model, a card unfolded or folded
2351
2935
  if (dead) return;
2352
2936
  // an identical model: nothing to redraw, unless the host accepted a move or reorder that only changed a position
2353
- if (!relayout && !Object.keys(hints).length && !Object.keys(slotsH).length && JSON.stringify(model2) === JSON.stringify(current)) { addHints.length = 0; return; }
2937
+ if (!relayout && !unfolding && !Object.keys(hints).length && !Object.keys(slotsH).length && JSON.stringify(model2) === JSON.stringify(current)) { addHints.length = 0; return; }
2354
2938
  const old = ctx, keys = {}, before = new Set(geo ? geo.nodes.map(n => n.id) : []), wantNew = selectNew;
2355
2939
  old.views.forEach(v => { keys[v.id] = relayout ? null : old.viewKey(v.id); });
2356
2940
  current = model2;
2357
- ctx = mapContext(withPlaces(model2), { measure, kinds: !!P.kinds, prev: options.stableLayout === false ? null : old.planCache });
2941
+ ctx = mapContext(withPlaces(model2), { measure, kinds: !!P.kinds, prev: options.stableLayout === false ? null : old.planCache, expand: expanded });
2942
+ Object.keys(expanded).forEach(v => { expanded[v] = ctx.unfolded[v] || []; }); // a card or inner view the model no longer has folds
2358
2943
  [hints, slotsH].forEach(o => Object.keys(o).forEach(k => delete o[k])); addHints.length = 0; selectNew = false;
2359
2944
  ctx.views.forEach(v => { const id = v.id; if (keys[id] == null || keys[id] !== ctx.viewKey(id)) return;
2360
2945
  if (id in old.planCache) ctx.planCache[id] = old.planCache[id];
2361
2946
  if (old.layoutCache[id]) ctx.layoutCache[id] = old.layoutCache[id];
2362
- if (old.geoCache[id]) ctx.geoCache[id] = old.geoCache[id]; });
2947
+ 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)
2363
2948
  if (!cur) return;
2364
2949
  if (!ctx.V[cur]) { const from = cur; mount(ctx.rootId); cam.set(startView()); cam.touched = false; emit('viewchange', { view: cur, from }); return; }
2365
2950
  if (ctx.geoCache[cur] && keys[cur] === ctx.viewKey(cur)) return; // this view's picture did not change
2366
- const keep = sel, keepMore = more, touched = cam.touched;
2951
+ const keep = sel, keepMore = more, touched = cam.touched, prev = [geo, svg];
2367
2952
  mount(cur);
2368
2953
  if (touched) cam.set({}); else cam.set(startView());
2369
2954
  cam.touched = touched;
2370
2955
  const fresh = geo.nodes.filter(n => !before.has(n.id));
2371
2956
  if (wantNew && fresh.length === 1) select(nodeRef(fresh[0].id), { reveal: false });
2372
2957
  else if (keep && exists(keep)) { sel = keep; more = keep.type === 'node' ? keepMore.filter(id => geo.byId[id]) : []; paint(); emitSel(); }
2958
+ if (!relayout) easeIn(...prev); // a font that came in late moves nothing on purpose: no ease
2373
2959
  }
2374
2960
 
2375
2961
  function destroy() {
2376
2962
  if (dead) return;
2377
- dead = true; if (ro) ro.disconnect(); cancelAnimationFrame(rz);
2963
+ dead = true; if (ro) ro.disconnect(); cancelAnimationFrame(rz); stopStory();
2378
2964
  timers.forEach(clearTimeout); timers.clear();
2379
2965
  if (cam) cam.stopAnimation();
2380
2966
  host.remove();
2381
2967
  Object.keys(subs).forEach(k => delete subs[k]);
2382
2968
  }
2383
2969
 
2384
- const inner = { ctx: () => ctx, geo: () => geo, cam: () => cam, svg: () => svg, stage, host, go, open, up, flash, setFlow, pause, key, startView, standalone,
2385
- select, busy: () => busy, flowing: () => stage.classList.contains('flowing'), view: () => cur };
2970
+ /* ---------- marks: overlay(), highlight(), tour() and replay() (#62, #63, #65) ----------
2971
+ The host's marks, drawn over the picture without touching the model: named layers, stacked in the order first set.
2972
+ Each mount draws them again, so they survive update() and setView(); a card or line the view doesn't show is ignored.
2973
+ toSVG() and toPNG() draw them too, at rest: they are the host's, like the model (the selection and hover are not). */
2974
+ const layers = new Map(), DIM = .2, clamp01 = x => Math.min(1, Math.max(0, +x || 0));
2975
+ // a drawn line that stands for several edges takes the strongest of their marks: a pulsing one, then the most lit, then the fastest
2976
+ 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];
2977
+ 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
2978
+ let out = null;
2979
+ layers.forEach(L => { const m = get(L); if (!m && !L.dim) return; out ||= { level: 1 };
2980
+ if (m) for (const k in m) if (m[k] != null && k !== 'level') out[k] = m[k];
2981
+ out.level = Math.min(out.level, m ? clamp01(m.level ?? 1) : DIM); });
2982
+ return out;
2983
+ }
2984
+ 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]]);
2985
+ function drawMarks(s = svg, g = geo) {
2986
+ if (!s || !g) return;
2987
+ s.querySelectorAll('.um-rings, .um-marks, .um-glow').forEach(x => x.remove());
2988
+ s.querySelectorAll('.node[opacity], .edge-g[opacity], .e-lab[opacity]').forEach(x => x.removeAttribute('opacity'));
2989
+ s.querySelectorAll('.edge[style], .flowline[style]').forEach(x => x.removeAttribute('style'));
2990
+ s.querySelectorAll('.node[aria-description]').forEach(x => x.removeAttribute('aria-description'));
2991
+ if (s === svg) stage.classList.toggle('um-lit', [...layers.values()].some(L => L.dim));
2992
+ if (!layers.size) return;
2993
+ const tone = t => 'var(--um-' + String(t || 'accent').replace(/[^\w-]/g, '') + ')', op = m => m.level < 1 ? ` opacity="${m.level}"` : '';
2994
+ const cards = Object.fromEntries([...s.querySelectorAll('.node')].map(x => [x.dataset.id, x]));
2995
+ let under = '', over = '';
2996
+ g.nodes.forEach(n => { const m = markOf(L => (L.nodes || {})[n.id]), el = cards[n.id]; if (!m || !el) return;
2997
+ if (m.level < 1) el.setAttribute('opacity', m.level);
2998
+ 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>`;
2999
+ const b = m.badge == null ? null : badgesOf({ badges: [m.badge] })[0]; if (!b) return; // a tab after the card's own
3000
+ const x = mapTabs(n).reduce((a, t) => a + t.w + 4, n.x + 12), w = b.text.toUpperCase().length * 6.4 + 10;
3001
+ 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>`;
3002
+ el.setAttribute('aria-description', b.text); });
3003
+ 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;
3004
+ const path = eg.querySelector('.edge'), flow = eg.querySelector('.flowline');
3005
+ if (m.level < 1) [eg, s.querySelector(`.e-lab[data-i="${i}"]`)].forEach(x => x && x.setAttribute('opacity', m.level));
3006
+ if (m.tone || m.pulse) path.insertAdjacentHTML('beforebegin', `<path d="${path.getAttribute('d')}" class="um-glow${m.pulse ? ' um-pulse' : ''}" stroke="${tone(m.tone)}"/>`);
3007
+ if (m.dash) path.setAttribute('style', 'stroke-dasharray: 7 5');
3008
+ if (m.speed != null && flow) flow.setAttribute('style', +m.speed > 0 ? `animation-duration: ${(2.4 / m.speed).toFixed(2)}s` : 'animation: none; opacity: 0');
3009
+ if (m.label == null || m.label === '') return;
3010
+ // ponytail: the label sits under the line's own (else on its longest run) with no room kept for it, so it may cover a neighbour's
3011
+ const t = String(m.label), w = sansW(t, 10, .92) + 14, L = e.lbl, [a, b] = L ? [] : longest(e.pts);
3012
+ const x = L ? L.x + L.w / 2 : (a[0] + b[0]) / 2, y = L ? L.y + L.h + 2 : (a[1] + b[1]) / 2 - 8;
3013
+ over += `<g class="um-lab"${op(m)}><rect x="${x - w / 2}" y="${y}" width="${w}" height="16" rx="8"${m.tone ? ` style="stroke: ${tone(m.tone)}"` : ''}/><text x="${x}" y="${y + 11.5}" text-anchor="middle">${esc(t)}</text></g>`; });
3014
+ const cardsG = s.querySelector('.nodes');
3015
+ if (under && cardsG) cardsG.insertAdjacentHTML('beforebegin', `<g class="um-rings" aria-hidden="true">${under}</g>`);
3016
+ if (over) s.insertAdjacentHTML('beforeend', `<g class="um-marks" aria-hidden="true">${over}</g>`);
3017
+ }
3018
+ function overlay(name, layer) {
3019
+ if (dead || name == null) return;
3020
+ if (layer == null) layers.delete(String(name)); else layers.set(String(name), layer); // a Map keeps the order first set
3021
+ drawMarks();
3022
+ }
3023
+ /* the 'highlight' layer: these cards and lines lit, the rest dimmed; a Ref's level (0 to 1), else its distance, fades it */
3024
+ function highlight(refs) {
3025
+ if (refs == null) return overlay('highlight', null);
3026
+ const L = { nodes: {}, edges: {}, dim: true }, lv = r => clamp01(r.level ?? (r.distance > 0 ? Math.max(.45, 1 - .15 * r.distance) : 1));
3027
+ [].concat(refs).forEach(r => { if (!r) return; const id = r.type === 'node' ? r.id : r.node; // a chip or badge: its card
3028
+ if (id != null) L.nodes[id] = { level: Math.max(lv(r), (L.nodes[id] || {}).level || 0) };
3029
+ else if (r.type === 'edge') (r.ids || []).forEach(x => { L.edges[x] = { level: Math.max(lv(r), (L.edges[x] || {}).level || 0) }; }); });
3030
+ overlay('highlight', L);
3031
+ }
3032
+ /* 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
3033
+ view (setView, an update that redraws it) ends it, as does destroy() */
3034
+ let trip = null;
3035
+ function endTrip(played = false) { const t = trip; if (!t) return; trip = null; cancelAnimationFrame(t.raf); if (t.dot) t.dot.remove(); t.end(played); }
3036
+ function startTrip(step, end) { // step(ms played, pauses left out) says whether to go on
3037
+ endTrip();
3038
+ const t = { raf: 0, dot: null, paused: false, at: 0, last: performance.now(), end }; trip = t;
3039
+ const frame = now => { if (trip !== t) return; if (dead) return endTrip(); if (!t.paused) t.at += now - t.last; t.last = now;
3040
+ if (step(t.at)) t.raf = requestAnimationFrame(frame); else endTrip(true); };
3041
+ t.raf = requestAnimationFrame(frame);
3042
+ return t;
3043
+ }
3044
+ 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
3045
+ return i < 0 ? null : { i, pts: geo.edges[i].from === a ? geo.edges[i].pts : [...geo.edges[i].pts].reverse() }; };
3046
+ const lenOf = pts => pts.slice(1).reduce((s, p, k) => s + Math.hypot(p[0] - pts[k][0], p[1] - pts[k][1]), 0);
3047
+ 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]);
3048
+ 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]; };
3049
+ /* tour(path): the dot rides the line between each pair of cards in turn, through each card to its next line, and each
3050
+ card it reaches flashes; where no line joins two cards it skips to the next. Resolves true when it reaches the end. */
3051
+ function tour(path, o = {}) {
3052
+ endTrip();
3053
+ if (dead || !geo || path == null) return Promise.resolve(false);
3054
+ const ids = [].concat(path).map(String).filter(id => geo.byId[id]), legs = [];
3055
+ for (let k = 0; k + 1 < ids.length; k++) { const r = lineBetween(ids[k], ids[k + 1]); if (!r) continue;
3056
+ const prev = legs[legs.length - 1], n = geo.byId[ids[k]];
3057
+ 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;
3058
+ legs.push({ pts, to: ids[k + 1], len: lenOf(pts) }); }
3059
+ if (!legs.length || reducedMotion()) return Promise.resolve(legs.length > 0);
3060
+ const speed = Math.max(20, +o.speed || 240); // map units a second
3061
+ svg.insertAdjacentHTML('beforeend', '<g class="um-trip" aria-hidden="true"><circle r="5.5" class="tour-dot"/></g>');
3062
+ const g = svg.lastElementChild, dot = g.firstElementChild; let leg = 0, base = 0;
3063
+ return new Promise(res => { startTrip(ms => { const d = ms * speed / 1000;
3064
+ while (leg < legs.length && d - base >= legs[leg].len) { base += legs[leg].len; flash(legs[leg].to); leg++; }
3065
+ if (leg >= legs.length) return false;
3066
+ const [x, y] = along(legs[leg].pts, d - base); dot.setAttribute('cx', x); dot.setAttribute('cy', y); return true; }, res).dot = g; });
3067
+ }
3068
+ /* replay(spans): each call from one card to another (a span and its parent's) runs along their line at the trace's relative
3069
+ time, the whole trace in `duration` ms; the line glows while the call lasts and keeps its duration as a label */
3070
+ function replay(spans, o = {}) {
3071
+ endTrip(); overlay('replay', null);
3072
+ const list = [].concat(spans || []).filter(s => s && Number.isFinite(+s.start) && Number.isFinite(+s.end));
3073
+ const byId = new Map(list.map(s => [String(s.id), s])), match = o.match || {}, low = x => String(x ?? '').trim().toLowerCase();
3074
+ const cardOf = svc => { if (svc == null || !geo) return null; if (match[svc] != null) return geo.byId[match[svc]] ? String(match[svc]) : null;
3075
+ if (geo.byId[svc]) return String(svc); const n = geo.nodes.find(x => low(x.label) === low(svc)); return n ? n.id : null; };
3076
+ let skipped = 0, t = null, done; const hops = [];
3077
+ list.forEach(s => { const to = cardOf(s.service); if (!to) return skipped++;
3078
+ const p = s.parent != null ? byId.get(String(s.parent)) : null, from = p && cardOf(p.service);
3079
+ if (!from || from === to) return; // a root span, a caller off the view, or work inside one card: no hop
3080
+ const r = lineBetween(from, to); if (!r) return skipped++; // a call the view draws no line for
3081
+ hops.push({ ...r, ids: edgeRefIds(geo.edges[r.i]), at: +s.start, dur: +s.end - +s.start, to, len: lenOf(r.pts) }); });
3082
+ const ctl = { hops: hops.length, skipped, done: new Promise(r => { done = r; }),
3083
+ pause() { if (t && trip === t) t.paused = true; }, resume() { if (t && trip === t) t.paused = false; },
3084
+ stop() { if (t && trip === t) endTrip(); else overlay('replay', null); } };
3085
+ const ms = x => x >= 1000 ? `${+(x / 1000).toFixed(x >= 1e4 ? 0 : 1)} s` : `${x >= 10 ? Math.round(x) : +x.toFixed(1)} ms`, marks = {};
3086
+ // ponytail: two calls at once on one line share its mark; the one that ends first takes the glow off
3087
+ 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 } }); };
3088
+ if (!hops.length || reducedMotion()) { hops.forEach(h => show(h, false)); done(hops.length > 0); return ctl; }
3089
+ 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);
3090
+ 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)); });
3091
+ const total = Math.max(...hops.map(h => Math.max(h.end, h.start + h.travel)));
3092
+ svg.insertAdjacentHTML('beforeend', '<g class="um-trip" aria-hidden="true"></g>'); const g = svg.lastElementChild;
3093
+ t = startTrip(now => { hops.forEach(h => {
3094
+ 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; }
3095
+ 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); } }
3096
+ if (h.on && !h.off && now >= h.end) { h.off = true; show(h, false); } });
3097
+ return now < total; }, played => { if (!played) overlay('replay', null); done(played); });
3098
+ t.dot = g;
3099
+ return ctl;
3100
+ }
3101
+
3102
+ /* ---------- stories (#69): the model's narrated walk-throughs, played step by step ----------
3103
+ A step sets the whole picture: its view, camera, selection, highlight and caption (what it leaves out is cleared),
3104
+ then plays its tour. Playing goes on by itself after each step's duration; the caption bar and the keys (← → PageUp
3105
+ PageDown, Esc) step through and stop it. One story at a time. */
3106
+ let story = null;
3107
+ // 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
3108
+ // object is a Ref already
3109
+ const stepRef = x => typeof x !== 'string' ? (x && typeof x === 'object' ? x : null) : geo.byId[x] ? nodeRef(x)
3110
+ : (e => e ? edgeRef(e) : null)(geo.edges.find(e => edgeRefIds(e).includes(x)));
3111
+ function stepLit(list) { // highlight: what it names, and every line between two cards it names
3112
+ const refs = [].concat(list || []).map(stepRef).filter(Boolean), ids = new Set(refs.filter(r => r.type === 'node').map(r => r.id));
3113
+ geo.edges.forEach(e => { if (ids.has(e.from) && ids.has(e.to)) refs.push(edgeRef(e)); });
3114
+ return refs.length ? refs : null;
3115
+ }
3116
+ function storyBar(st) {
3117
+ let bar = stage.querySelector('.um-story');
3118
+ if (!bar) {
3119
+ 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">
3120
+ <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>`);
3121
+ bar = stage.lastElementChild; clear = null; // the fit keeps the cards off the bar as it does off the controls
3122
+ bar.addEventListener('dblclick', e => e.stopPropagation()); // two quick clicks on › are two steps, not a zoom
3123
+ bar.addEventListener('click', e => { e.stopPropagation(); const s = story, b = e.target.closest('button'); if (!s || !b) return;
3124
+ ({ prev: () => stepStory(-1), next: () => stepStory(1), stop: () => stopStory(), play: () => s.auto ? s.ctl.pause() : s.ctl.resume() })[b.dataset.s](); });
3125
+ }
3126
+ const k = st.at, n = st.steps.length, b = s => bar.querySelector(`[data-s="${s}"]`);
3127
+ bar.setAttribute('aria-label', 'Story: ' + (st.s.title || st.s.id));
3128
+ bar.querySelector('.um-st').textContent = `${st.s.title || st.s.id} · ${k + 1} / ${n}`;
3129
+ bar.querySelector('p').textContent = (st.steps[k] || {}).caption || '';
3130
+ b('prev').disabled = k <= 0;
3131
+ b('play').textContent = st.auto ? '❚❚' : '▶'; b('play').setAttribute('aria-label', st.auto ? 'Pause' : 'Play'); b('play').title = st.auto ? 'Pause' : 'Play';
3132
+ b('next').textContent = k + 1 < n ? '›' : '✓'; b('next').setAttribute('aria-label', k + 1 < n ? 'Next step' : 'Finish the story');
3133
+ }
3134
+ async function showStep(st, k) {
3135
+ const seq = ++st.seq, step = st.steps[k] || {}, live = () => story === st && st.seq === seq && !dead;
3136
+ st.at = k; clearTimeout(st.timer); tour(null); storyBar(st);
3137
+ emit('story', { story: st.s.id, step: k, steps: st.steps.length }); // before the step's own select and viewchange
3138
+ while (busy && live()) await new Promise(r => setTimeout(r, 30)); // a move the user started: let it land first
3139
+ if (!live()) return;
3140
+ if (step.view !== cur && ctx.V[step.view]) await go(step.view);
3141
+ if (!live()) return;
3142
+ const c = step.camera && typeof step.camera === 'object' ? step.camera : null, n = c && c.card != null ? geo.byId[c.card] : null;
3143
+ const to = n ? { cx: n.x + n.w / 2, cy: n.y + n.h / 2, k: +c.zoom || 1.5 }
3144
+ : 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
3145
+ const moved = cam.animate(to); cam.touched = !!(n || (c && Number.isFinite(+c.x)));
3146
+ select(step.select == null ? null : stepRef(step.select), { reveal: false });
3147
+ highlight(stepLit(step.highlight));
3148
+ say(`Step ${k + 1} of ${st.steps.length}${step.caption ? ': ' + step.caption : ''}`); // after select(), which says what it selected
3149
+ await moved;
3150
+ if (!live()) return;
3151
+ st.toured = step.tour ? tour(step.tour) : Promise.resolve(true);
3152
+ arm(st);
3153
+ }
3154
+ function arm(st) { // playing: the next step once this one's duration is up and its tour has reached its end
3155
+ clearTimeout(st.timer);
3156
+ if (!st.auto || story !== st) return;
3157
+ const seq = st.seq, dur = Math.max(500, +(st.steps[st.at] || {}).duration || 4000); // reading time: not cut by reduced motion
3158
+ Promise.all([st.toured, new Promise(r => { st.timer = later(r, dur); })]).then(() => { if (story === st && st.seq === seq && st.auto) stepStory(1); });
3159
+ }
3160
+ function stepStory(d) {
3161
+ const st = story; if (!st) return;
3162
+ const k = st.at + d;
3163
+ if (k >= st.steps.length) return stopStory(true); // past the last step: the story has been told
3164
+ if (k >= 0) showStep(st, k);
3165
+ }
3166
+ function stopStory(told = false) {
3167
+ const st = story; if (!st) return;
3168
+ story = null; clearTimeout(st.timer); tour(null); highlight(null);
3169
+ const bar = stage.querySelector('.um-story'); if (bar) { bar.remove(); clear = null; }
3170
+ if (!dead) { say(told ? 'End of the story' : 'Story closed'); emit('story', { story: st.s.id, step: null, steps: st.steps.length }); }
3171
+ st.end(told);
3172
+ }
3173
+ function storyKey(e) { // null: not a story's key; on the bar's own buttons, Enter and Space press them
3174
+ const k = e.key;
3175
+ if ((k === 'Enter' || k === ' ') && e.composedPath().some(x => x.classList && x.classList.contains('um-story'))) return false;
3176
+ const d = { ArrowLeft: -1, PageUp: -1, ArrowRight: 1, PageDown: 1 }[k];
3177
+ if (d) { stepStory(d); return true; }
3178
+ if (k === 'Escape') { stopStory(); return true; }
3179
+ return null;
3180
+ }
3181
+ function play(id, o = {}) {
3182
+ stopStory();
3183
+ const s = (current.stories || []).find(x => x && x.id === id), steps = s && Array.isArray(s.steps) ? s.steps.filter(x => x && typeof x === 'object') : [];
3184
+ if (dead || !geo || !steps.length) return null;
3185
+ 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; }) };
3186
+ st.end = end;
3187
+ st.ctl = { get story() { return s.id; }, get step() { return st.at; }, get steps() { return steps.length; }, get playing() { return st.auto && story === st; },
3188
+ next() { if (story === st) stepStory(1); }, prev() { if (story === st) stepStory(-1); }, stop() { if (story === st) stopStory(); },
3189
+ pause() { if (story !== st) return; st.auto = false; clearTimeout(st.timer); storyBar(st); },
3190
+ resume() { if (story !== st || st.auto) return; st.auto = true; storyBar(st); arm(st); }, done: st.done };
3191
+ story = st;
3192
+ showStep(st, Math.max(0, Math.min(steps.length - 1, Math.floor(+o.step) || 0)));
3193
+ return st.ctl;
3194
+ }
3195
+
3196
+ const inner = { ctx: () => ctx, geo: () => geo, cam: () => cam, svg: () => svg, stage, host, go, open, up, flash, setFlow, pause, key, startView, standalone, play,
3197
+ select, busy: () => busy, flowing: () => stage.classList.contains('flowing'), view: () => cur, unfold: gesture };
2386
3198
  const first = () => {
2387
3199
  if (dead) return;
2388
3200
  const v0 = ctx.V[options.view] ? options.view : ctx.rootId;
@@ -2403,11 +3215,19 @@ export function render(container, model, options = {}) {
2403
3215
  toSVG: (o = {}) => dead ? '' : standalone(o.scale ?? 1, o.view ?? cur, o.fontCSS),
2404
3216
  toPNG: (o = {}) => dead ? Promise.resolve(null) : pngOf(standalone(o.scale ?? 2, o.view ?? cur, o.fontCSS)),
2405
3217
  focus: ref => { if (!dead) focusMap(ref); },
3218
+ expand: id => setUnfold(id, true), // unfold a stacked card of the view shown in place (#71): true when it did
3219
+ collapse: id => setUnfold(id, false),
3220
+ get expanded() { return [...(expanded[cur] || [])]; },
2406
3221
  on(type, fn) { (subs[type] ||= []).push(fn); if (type === 'click') tabKeys();
2407
3222
  return () => { subs[type] = (subs[type] || []).filter(f => f !== fn); if (type === 'click' && !dead) tabKeys(); }; },
2408
3223
  get view() { return cur; },
2409
3224
  get selection() { return sel; },
2410
3225
  get selected() { return picked(); },
3226
+ overlay: (name, layer) => overlay(name, layer),
3227
+ highlight: refs => highlight(refs),
3228
+ tour: (path, o) => tour(path, o),
3229
+ replay: (spans, o) => replay(spans, o),
3230
+ play: (id, o) => play(id, o),
2411
3231
  destroy,
2412
3232
  [INNER]: inner,
2413
3233
  };
@@ -2437,6 +3257,74 @@ export function history(model, limit = 100) {
2437
3257
  };
2438
3258
  }
2439
3259
 
3260
+ /* ---------- paths(), reach(), describe() and otlpSpans(): a view's lines walked and put in words, with no DOM (#62, #64, #65) ----------
3261
+ 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
3262
+ names one model edge (a drawn line that stands for several takes the strongest mark among them). */
3263
+ function lineGraph(model, viewId) {
3264
+ const ctx = mapContext(model), v = ctx.V[viewId];
3265
+ if (!v) throw new Error(`umlimator: no view ${JSON.stringify(viewId)}`);
3266
+ return { ctx, v, ids: (v.nodeIds || []).filter(i => ctx.N[i]), edges: ctx.viewEdges(v).filter(e => e.from !== e.to) };
3267
+ }
3268
+ const walkFrom = (edges, start, back) => { const seen = new Set([start]); // breadth first along lines (back: against them)
3269
+ 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); } }); }
3270
+ return seen; };
3271
+
3272
+ /** Every card and line on a path from one card to another, following lines forward: the cards both reachable from `from`
3273
+ * and leading to `to`, and the lines between them. [] when no path joins them. */
3274
+ export function paths(model, viewId, from, to) {
3275
+ const { ids, edges } = lineGraph(model, viewId);
3276
+ if (!ids.includes(from) || !ids.includes(to)) return [];
3277
+ const F = walkFrom(edges, from), B = walkFrom(edges, to, true), on = id => F.has(id) && B.has(id);
3278
+ if (!F.has(to)) return [];
3279
+ 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 }))];
3280
+ }
3281
+
3282
+ /** Everything a card's lines lead to (forward), or everything that leads to it (`backward`: for calls, who depends on it),
3283
+ * each with its distance in lines; `depth` stops the walk that many lines out. The card itself is first, at 0. */
3284
+ export function reach(model, viewId, from, options = {}) {
3285
+ const { ids, edges } = lineGraph(model, viewId), back = !!options.backward, depth = options.depth ?? Infinity;
3286
+ if (!ids.includes(from)) return [];
3287
+ const ends = e => back ? [e.to, e.from] : [e.from, e.to], dist = new Map([[from, 0]]);
3288
+ for (let q = [from]; q.length;) { const u = q.shift(), d = dist.get(u); if (d >= depth) continue;
3289
+ edges.forEach(e => { const [a, b] = ends(e); if (a === u && !dist.has(b)) { dist.set(b, d + 1); q.push(b); } }); }
3290
+ const near = (x, y) => x.distance - y.distance; // a stable sort: the view's order among equals
3291
+ return [...ids.filter(id => dist.has(id)).map(id => ({ type: 'node', id, view: viewId, distance: dist.get(id) })).sort(near),
3292
+ ...edges.filter(e => { const [a, b] = ends(e); return dist.has(a) && dist.has(b) && dist.get(a) < depth; })
3293
+ .map(e => ({ type: 'edge', ids: [e._id], view: viewId, distance: dist.get(ends(e)[0]) + 1 })).sort(near)];
3294
+ }
3295
+
3296
+ /** A view in plain sentences: its cards and their kinds, its boxes, what each card's lines do (in run order on a sequence
3297
+ * view), and the cards with no line. The same model gives the same words: a text alternative, docs, or an AI's context. */
3298
+ export function describe(model, viewId) {
3299
+ const { ctx, v, ids, edges } = lineGraph(model, viewId), boxes = (v.containers || []).filter(c => c && c.id);
3300
+ const name = id => ctx.N[id] ? ctx.nodeIn(id, viewId).label || id : (boxes.find(c => c.id === id) || {}).label || id;
3301
+ const list = xs => xs.length < 3 ? xs.join(' and ') : xs.slice(0, -1).join(', ') + ' and ' + xs[xs.length - 1];
3302
+ const count = (n, w) => `${n} ${w}${n === 1 ? '' : 's'}`;
3303
+ // 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)
3304
+ 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';
3305
+ const VERB = { calls: 'calls', feeds: 'feeds', triggers: 'triggers', state: 'reads or writes', guard: 'guards' };
3306
+ const what = e => { const t = [e.label && `"${e.label}"`, e.sublabel].filter(Boolean); return t.length ? ` (${t.join(', ')})` : ''; };
3307
+ const out = [`${v.title || v.id}: ${count(ids.length, 'card')} and ${count(edges.length, 'line')}.`];
3308
+ if (ids.length) out.push(`Cards: ${ids.map(id => `${name(id)} (${KIND[kindOf(ctx.nodeIn(id, viewId).kind, true)][1]})`).join(', ')}.`);
3309
+ 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))}.`); });
3310
+ if (v.render === 'sequence') edges.forEach((e, i) => out.push(`Step ${i + 1}: ${name(e.from)} ${VERB[kind(e)]} ${name(e.to)}${what(e)}.`));
3311
+ else [...ids, ...boxes.map(c => c.id)].forEach(id => { const outs = edges.filter(e => e.from === id); if (!outs.length) return;
3312
+ 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('; ')}.`); });
3313
+ const alone = ids.filter(id => !edges.some(e => e.from === id || e.to === id));
3314
+ if (alone.length) out.push(`Not connected here: ${list(alone.map(name))}.`);
3315
+ return out.join('\n');
3316
+ }
3317
+
3318
+ /** The spans of an OpenTelemetry trace exported as OTLP JSON, as replay() takes them: { id, parent, service, name, start,
3319
+ * end }, times in milliseconds. */
3320
+ export function otlpSpans(data) {
3321
+ const out = [], attr = (list, k) => { const v = ((list || []).find(a => a && a.key === k) || {}).value || {}; return v.stringValue ?? v.intValue ?? null; };
3322
+ ((data && data.resourceSpans) || []).forEach(rs => { const service = attr(rs.resource && rs.resource.attributes, 'service.name');
3323
+ (rs.scopeSpans || rs.instrumentationLibrarySpans || []).forEach(ss => (ss.spans || []).forEach(s => out.push({ id: s.spanId, parent: s.parentSpanId || null,
3324
+ service, name: s.name, start: Number(s.startTimeUnixNano) / 1e6, end: Number(s.endTimeUnixNano) / 1e6 }))); });
3325
+ return out;
3326
+ }
3327
+
2440
3328
  export function mountPage(container, model, options = {}) {
2441
3329
  const Q = options.params || new URLSearchParams();
2442
3330
  if (options.instant) INSTANT = true;
@@ -2473,9 +3361,11 @@ export function mountPage(container, model, options = {}) {
2473
3361
  <span class="mbrand"><i></i><span>${esc(D.title || 'Map')}</span></span>
2474
3362
  <nav class="crumbs" id="m-crumbs" aria-label="Breadcrumb"></nav>
2475
3363
  <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>
2476
- <button class="ibtn" id="m-theme" title="Light / dark" aria-label="Toggle theme">◐</button>
3364
+ <button class="ibtn" id="m-theme" title="Light / dark" aria-label="Toggle theme">◐</button>${(D.stories || []).length ? `
3365
+ <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>
3366
+ <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>` : ''}
2477
3367
  <div class="menu"><button class="pbtn" id="m-export" aria-haspopup="true" aria-expanded="false">Export ▾</button>
2478
- <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>
3368
+ <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>
2479
3369
  <button class="ibtn" id="m-pause" type="button" title="Pause" aria-label="Pause" aria-pressed="false">❚❚</button>
2480
3370
  <button class="ibtn" id="m-help" title="Keys (?)" aria-label="Keyboard shortcuts" aria-haspopup="true" aria-expanded="false">?</button>
2481
3371
  </header>
@@ -2491,8 +3381,10 @@ export function mountPage(container, model, options = {}) {
2491
3381
  <aside class="panel" id="m-panel" aria-label="Node details" hidden></aside>
2492
3382
  </div>
2493
3383
  <div class="hpop" id="m-helpbox" role="dialog" aria-label="Keys" hidden><b>Keys</b>
2494
- <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>
2495
- <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>
3384
+ <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>
3385
+ <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>
3386
+ <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>
3387
+ <dt>← → · PgUp PgDn</dt><dd>in a story: previous / next step (Esc closes it)</dd></dl></div>
2496
3388
  <div class="scrim" id="m-pal" hidden><div class="pal" role="dialog" aria-label="Search">
2497
3389
  <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>
2498
3390
  <div class="res" id="m-res" role="listbox"></div>
@@ -2617,7 +3509,7 @@ export function mountPage(container, model, options = {}) {
2617
3509
  return I && I.src ? `<span class="big logo${I.dark ? ' dark' : ''}" aria-hidden="true"><img src="${I.src}" width="22" height="22" alt=""></span>`
2618
3510
  : `<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>`; })()}
2619
3511
  <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>
2620
- <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>
3512
+ <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>
2621
3513
  </div>
2622
3514
  <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('')}
2623
3515
  <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>
@@ -2638,9 +3530,35 @@ export function mountPage(container, model, options = {}) {
2638
3530
  const closeExample = () => { const lb = $('m-lightbox'); if (lb) lb.hidden = true; };
2639
3531
  const setTab = t => { tab = t; try { localStorage.setItem('visual-tab', t); } catch (_) {} if (sel) renderPanel(sel); };
2640
3532
 
3533
+ /* 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
3534
+ new selection or view clears them, and the same key again does */
3535
+ let lit = null, picked = [];
3536
+ const cardName = id => nodeIn(id).label || id, nCards = n => `${n} card${n === 1 ? '' : 's'}`;
3537
+ 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>'
3538
+ + (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>' : '');
3539
+ function unlight() { if (lit) { lit = null; handle.highlight(null); } }
3540
+ function light(key, refs, said) {
3541
+ if (lit === key) { unlight(); return status('Highlight cleared'); }
3542
+ lit = key; handle.highlight(refs.length ? refs : null); status(said, 3600);
3543
+ }
3544
+ function lightReach(back) {
3545
+ if (!sel || !geo.byId[sel]) return;
3546
+ const refs = reach(D, cur, sel, { backward: back }), n = refs.filter(r => r.type === 'node').length - 1, s = cardName(sel);
3547
+ light((back ? 'in ' : 'out ') + sel, n ? refs : [], !n ? (back ? `Nothing leads to ${s} here` : `${s} leads nowhere here`)
3548
+ : back ? `${nCards(n)} ${n === 1 ? 'leads' : 'lead'} to ${s}: lit on the map` : `${s} leads to ${nCards(n)}: lit on the map`);
3549
+ }
3550
+ function lightPaths() {
3551
+ const [a, b] = picked.slice(-2); if (!b) return;
3552
+ let refs = paths(D, cur, a, b), [x, y] = [a, b];
3553
+ if (!refs.length) { refs = paths(D, cur, b, a); [x, y] = [b, a]; } // the two cards in either order
3554
+ const n = refs.filter(r => r.type === 'node').length;
3555
+ 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`);
3556
+ }
3557
+
2641
3558
  /* navigation */
2642
3559
  function setHash(replace) {
2643
3560
  const p = new URLSearchParams(); p.set('view', cur); if (sel) p.set('node', sel);
3561
+ if (told) { p.set('story', told.id); p.set('step', told.step + 1); } // the story shown, at its step (#69)
2644
3562
  ['theme', 'check', 'shot'].forEach(k => Q.get(k) && p.set(k, Q.get(k)));
2645
3563
  const h = '#' + p.toString();
2646
3564
  if (location.hash === h) return;
@@ -2669,12 +3587,14 @@ export function mountPage(container, model, options = {}) {
2669
3587
  document.addEventListener('click', e => {
2670
3588
  const t = e.target;
2671
3589
  if (t.closest('[data-close]')) return deselect();
3590
+ const lb = t.closest('#m-panel [data-lit]'); if (lb) return lb.dataset.lit === 'paths' ? lightPaths() : lightReach(lb.dataset.lit === 'in');
2672
3591
  const ob = t.closest('[data-open]'); if (ob) return go(ob.dataset.open, { via: sel });
3592
+ const ub = t.closest('#m-panel [data-unfold]'); if (ub) return X.unfold(ub.dataset.unfold); // in place (#71)
2673
3593
  const pv = t.closest('.pview'); if (pv) return go(pv.dataset.v, { select: pv.dataset.sel });
2674
3594
  const rn = t.closest('#m-panel [data-n]'); if (rn) return findNode(rn.dataset.n);
2675
3595
  const cv = t.closest('#m-crumbs [data-v], #m-tree [data-v]'); if (cv) { if (innerWidth <= 820) setTree(false); return go(cv.dataset.v); }
2676
3596
  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; }
2677
- if (!t.closest('.menu')) closeExport();
3597
+ const mn = t.closest('.menu'); if (!mn || !mn.contains($('m-export'))) closeExport(); if (!mn || !mn.contains($('m-stories'))) closeStories();
2678
3598
  if (!t.closest('#m-helpbox, #m-help')) closeHelp();
2679
3599
  });
2680
3600
  $('m-outline').addEventListener('click', () => setTree(!document.body.classList.contains('tree-open')));
@@ -2685,6 +3605,51 @@ export function mountPage(container, model, options = {}) {
2685
3605
  $('m-export').addEventListener('click', () => { const on = $('m-export-pop').hidden; $('m-export-pop').hidden = !on; $('m-export').setAttribute('aria-expanded', on); });
2686
3606
  $('m-export-pop').addEventListener('click', e => { const x = e.target.closest('[data-x]')?.dataset.x; if (!x) return; closeExport(); exportView(x); });
2687
3607
 
3608
+ /* stories (#69): the Stories menu plays one; the link names the story and step shown; Export ▸ Record a story… */
3609
+ let told = null; // { id, step }: the story on the map and its step
3610
+ 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
3611
+ const storiesPop = $('m-stories-pop'), closeStories = () => { if (storiesPop) { storiesPop.hidden = true; $('m-stories').setAttribute('aria-expanded', 'false'); } };
3612
+ if (storiesPop) {
3613
+ $('m-stories').addEventListener('click', () => { const on = storiesPop.hidden; storiesPop.hidden = !on; $('m-stories').setAttribute('aria-expanded', on); });
3614
+ storiesPop.addEventListener('click', e => { const b = e.target.closest('[data-story]'); if (!b) return; closeStories(); handle.play(b.dataset.story); });
3615
+ }
3616
+ // 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);
3617
+ // captions are typed in, and Save writes the story's JSON, for the model's `stories`
3618
+ let rec = null;
3619
+ function record() {
3620
+ if (rec) return;
3621
+ stage.insertAdjacentHTML('beforeend', `<div class="m-rec" id="m-rec" role="region" aria-label="Recording a story">
3622
+ <div class="t"><b>● Recording a story</b><span id="m-rec-n"></span></div>
3623
+ <input id="m-rec-title" type="text" aria-label="Story title" placeholder="Story title" value="${esc((D.title || 'Map') + ': a walk-through')}">
3624
+ <ol id="m-rec-list"></ol>
3625
+ <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>`);
3626
+ const box = $('m-rec'), same = (a, b) => JSON.stringify([a.view, a.select, a.camera]) === JSON.stringify([b.view, b.select, b.camera]);
3627
+ rec = { steps: [], t: 0 };
3628
+ const draw = () => { $('m-rec-n').textContent = rec.steps.length === 1 ? '1 step' : rec.steps.length + ' steps';
3629
+ $('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>
3630
+ <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(''); };
3631
+ rec.take = force => { // the map as it is: its view, selected card and, once moved by hand, its camera
3632
+ const c = X.cam(), s = { view: cur, caption: '' }, last = rec.steps[rec.steps.length - 1];
3633
+ if (sel) s.select = sel;
3634
+ if (c.touched) s.camera = { x: Math.round(c.s.cx), y: Math.round(c.s.cy), zoom: +c.s.k.toFixed(2) };
3635
+ if (force || !last || !same(last, s)) { rec.steps.push(s); draw(); }
3636
+ };
3637
+ box.addEventListener('input', e => { const i = e.target.dataset.i; if (i != null) rec.steps[+i].caption = e.target.value; });
3638
+ box.addEventListener('click', e => {
3639
+ const r = e.target.closest('[data-r]')?.dataset.r, del = e.target.closest('[data-del]');
3640
+ if (del) { rec.steps.splice(+del.dataset.del, 1); draw(); }
3641
+ else if (r === 'add') rec.take(true);
3642
+ else if (r === 'save') {
3643
+ const title = $('m-rec-title').value.trim() || 'A walk-through', id = title.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '') || 'story';
3644
+ const steps = rec.steps.map(({ caption, ...s }) => caption ? { view: s.view, caption, ...s } : s);
3645
+ download(id + '.story.json', new Blob([JSON.stringify({ id, title, steps }, null, 2) + '\n'], { type: 'application/json' }));
3646
+ status('Saved: add it to the model\'s "stories"', 4000);
3647
+ } else if (r === 'close') { clearTimeout(rec.t); rec = null; box.remove(); }
3648
+ });
3649
+ rec.take();
3650
+ }
3651
+ const recordSoon = () => { if (rec) { clearTimeout(rec.t); rec.t = setTimeout(() => rec && rec.take(), 650); } }; // once the camera has landed
3652
+
2688
3653
  /* ⌘K command box: nodes and views, Enter jumps */
2689
3654
  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() }));
2690
3655
  const viewIdx = views.map(v => ({ v, hay: [v.title, v.id, v.caption].join(' ').toLowerCase() }));
@@ -2741,7 +3706,7 @@ export function mountPage(container, model, options = {}) {
2741
3706
  if (!X || (e.target.matches && e.target.matches('input, textarea')) || e.metaKey || e.ctrlKey || e.altKey) return;
2742
3707
  const k = e.key, inCanvas = e.composedPath().includes(X.stage);
2743
3708
  if (k === 'Escape' || k === 'Backspace') {
2744
- 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);
3709
+ 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);
2745
3710
  else if (!inCanvas) X.key(e); else return;
2746
3711
  }
2747
3712
  else if (k === '/') openPal();
@@ -2749,6 +3714,8 @@ export function mountPage(container, model, options = {}) {
2749
3714
  else if (k === 'i' || k === 'I') togglePill();
2750
3715
  else if ((k === '[' || k === ']') && sel) setTab(TABS[(TABS.indexOf(tab) + (k === ']' ? 1 : -1) + TABS.length) % TABS.length]);
2751
3716
  else if (k === 'o' || k === 'O') setTree(!document.body.classList.contains('tree-open'));
3717
+ else if ((k === '>' || k === '<') && sel) lightReach(k === '<');
3718
+ else if ((k === 'p' || k === 'P') && picked.length > 1) lightPaths();
2752
3719
  else if (inCanvas || !X.key(e)) return;
2753
3720
  e.preventDefault(); e.stopPropagation();
2754
3721
  }, true);
@@ -2763,6 +3730,7 @@ export function mountPage(container, model, options = {}) {
2763
3730
  setTimeout(tick, 300); }
2764
3731
  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); }
2765
3732
  function exportView(x) {
3733
+ if (x === 'record') return record();
2766
3734
  const stem = (D.slug || D.title || 'map').toLowerCase().replace(/[^a-z0-9]+/g, '-') + '-' + cur.replace(/[^a-z0-9]+/gi, '-');
2767
3735
  if (x === 'svg') download(stem + '.svg', new Blob([standalone(1)], { type: 'image/svg+xml' }));
2768
3736
  else if (x === 'png') {
@@ -2778,6 +3746,7 @@ export function mountPage(container, model, options = {}) {
2778
3746
  const v0 = V[p0.get('view')] ? p0.get('view') : rootId;
2779
3747
  const handle = render($('m-canvas'), model, {
2780
3748
  view: v0, minimap: true, flow: flowPref ? flowPref === '1' : undefined,
3749
+ 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)
2781
3750
  _page: {
2782
3751
  instant: options.instant, defer: !INSTANT, tour: !Q.get('still'), openHint: !Q.get('reader'), kinds: true, status,
2783
3752
  wide: () => innerWidth > 820,
@@ -2796,11 +3765,12 @@ export function mountPage(container, model, options = {}) {
2796
3765
  $('m-ghosts').remove();
2797
3766
  const n0 = p0.get('node') || (Q.get('shot') && cur !== rootId ? subjectOf(V[cur]) || (V[cur].nodeIds || [])[0] : null);
2798
3767
  if (n0 && geo.byId[n0]) select(n0, { reveal: false });
3768
+ 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)
2799
3769
  setHash(true);
2800
3770
  },
2801
3771
  },
2802
3772
  });
2803
- handle.on('select', ({ target }) => { sel = target && target.type === 'node' ? target.id : null; if (sel) renderPanel(sel); else hidePanel(); setHash(true); });
2804
- handle.on('viewchange', () => { setHash(replaceNext); replaceNext = false; });
3773
+ 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(); });
3774
+ handle.on('viewchange', () => { unlight(); setHash(replaceNext); replaceNext = false; recordSoon(); });
2805
3775
  return handle;
2806
3776
  }