umlimator 0.13.0 → 0.14.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
@@ -48,7 +48,9 @@ const STYLES = `
48
48
  svg .edge { fill: none; stroke-width: calc(1.6px * var(--um-ew, 1)); stroke-linecap: round; stroke-linejoin: round; transition: stroke-width .15s, opacity .2s; }
49
49
  svg .halo { fill: none; stroke: var(--um-bg-2); stroke-width: calc(6px + 1.8px * (var(--um-ew, 1) - 1)); }
50
50
  svg .hitline { fill: none; stroke: transparent; stroke-width: calc(12px + 1.8px * (var(--um-ew, 1) - 1)); }
51
- svg .flowline { fill: none; stroke-width: calc(2.4px * var(--um-ew, 1)); stroke-linecap: round; stroke-dasharray: 10 70; opacity: 0; pointer-events: none; }
51
+ /* a line's flow: pulses in the canvas colour moving along it, as wide as the line (never a bump on it), and stopping
52
+ where its arrowhead begins (svgOf trims the path), so the arrow keeps its shape */
53
+ svg .flowline { fill: none; stroke: var(--um-bg-2); stroke-width: calc(1.6px * var(--um-ew, 1)); stroke-linecap: round; stroke-dasharray: 10 70; opacity: 0; pointer-events: none; }
52
54
  .flowing svg .flowline { opacity: .9; animation: flowdash 2.4s linear infinite; }
53
55
  .flowing svg .edge { opacity: .8; }
54
56
  @keyframes flowdash { to { stroke-dashoffset: -160; } }
@@ -70,7 +72,8 @@ const STYLES = `
70
72
  .flowing svg .edge-g.back .edge { animation: march 1.1s linear infinite; }
71
73
  @keyframes march { to { stroke-dashoffset: -22; } }
72
74
  @media (prefers-reduced-motion: reduce) { svg .tour, svg .tour-glow { display: none !important; } .flowing svg .node.tour-stop, .flowing svg .edge-g.back .edge { animation: none; } }
73
- @media (prefers-reduced-motion: reduce) { .flowing svg .flowline { animation: none; opacity: 0; } .flowing svg .edge { opacity: 1; } }
75
+ /* reduced motion: no flow is drawn, so ≋ isn't offered (as the page hides its ❚❚); the stage's class outranks :host .flowing below */
76
+ @media (prefers-reduced-motion: reduce) { :host .stage.flowing svg .flowline { animation: none; opacity: 0; } .flowing svg .edge { opacity: 1; } .ctl [data-z="flow"] { display: none; } }
74
77
  .ctl button.on { color: var(--um-cyan); background: var(--um-cyan-f); }
75
78
  svg .flow { stroke-dasharray: 6 6; animation: flow 1.2s linear infinite; }
76
79
  @keyframes flow { to { stroke-dashoffset: -12; } }
@@ -112,7 +115,7 @@ const STYLES = `
112
115
  svg .node.nb .n-body { stroke-width: 2.6; stroke-opacity: 1; }
113
116
  svg .n-port { fill: var(--um-panel); stroke-width: 1.5; pointer-events: none; transition: fill .2s, stroke-width .2s; }
114
117
  svg .node.sel .n-port, svg .node.nb .n-port.hot { fill: var(--um-accent); stroke: var(--um-accent); stroke-width: 2.2; }
115
- :host .focusing svg .edge-g.hot .flowline { opacity: .95; stroke-width: calc(3.4px * var(--um-ew, 1)); stroke-dasharray: 8 13; animation: selected-flow 1.1s linear infinite; }
118
+ :host .focusing svg .edge-g.hot .flowline { opacity: .95; stroke-width: calc(2.8px * var(--um-ew, 1)); stroke-dasharray: 8 13; animation: selected-flow 1.1s linear infinite; }
116
119
  :host .focusing svg .edge-g.hot .edge { stroke-width: calc(2.8px * var(--um-ew, 1)); }
117
120
  @keyframes selected-flow { to { stroke-dashoffset: -42; } }
118
121
  @keyframes select-glow { from { filter: drop-shadow(0 0 12px color-mix(in srgb, var(--um-accent) 70%, transparent)); stroke-width: 5; } }
@@ -200,7 +203,7 @@ const STYLES = `
200
203
  .um-story .um-st { display: block; font: 600 11px var(--um-mono); color: var(--um-muted); margin-bottom: 2px; }
201
204
  .um-story p { margin: 0; } .um-story .um-sb { display: flex; gap: 2px; flex: none; }
202
205
  .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; }
206
+ .um-story button:hover:not([aria-disabled="true"]) { background: var(--um-panel-2); color: var(--um-accent); } .um-story button[aria-disabled="true"] { opacity: .35; cursor: default; }
204
207
  @media (max-width: 820px) { .um-story { left: 12px; right: 12px; bottom: 60px; max-width: none; } }
205
208
  /* the standalone page's pieces (#99): the views list, the legend, the hint line and the details panel, in the page and,
206
209
  when a host asks, in render()'s shadow root, where these first rules give them what the page gives its whole page */
@@ -419,6 +422,15 @@ function fit(s, px, max, words) { s = String(s ?? ''); if (textW(s, px) <= max)
419
422
  let k; if (measureText) { k = s.length; while (k > 3 && textW(s.slice(0, k) + '…', px) > max) k--; } else k = Math.max(Math.floor(max / (px * 0.6)) - 1, 3);
420
423
  const w = words ? s.slice(0, k + 1).replace(/\S*$/, '').replace(/[\s,;:]+$/, '') : '';
421
424
  return (w || s.slice(0, k)) + '…'; }
425
+ // pts shortened by a at the start and b at the end, along its own segments: a line's flow stops where an arrowhead begins
426
+ const trimLine = (pts, a, b) => {
427
+ const cut = (p, d) => { p = p.map(q => q.slice());
428
+ while (d > 0 && p.length > 1) { const [x0, y0] = p[p.length - 2], [x1, y1] = p[p.length - 1], len = Math.hypot(x1 - x0, y1 - y0);
429
+ if (len > d) { const t = (len - d) / len; p[p.length - 1] = [x0 + (x1 - x0) * t, y0 + (y1 - y0) * t]; break; }
430
+ d -= len; p.pop(); }
431
+ return p; };
432
+ return cut(cut(pts, b).reverse(), a).reverse();
433
+ };
422
434
  function roundPath(pts, r = 9) {
423
435
  let d = `M ${pts[0][0]} ${pts[0][1]}`;
424
436
  for (let i = 1; i < pts.length - 1; i++) {
@@ -631,9 +643,22 @@ const mapNodeW = n => {
631
643
  /* Geometry: node boxes, ports, orthogonal routes, label slots. A view with lines the router found no route for is laid
632
644
  out once more, with room where those lines need it (#40); a view whose lines all route keeps its geometry. */
633
645
  function layoutGraph(dg, map) {
634
- const g = layoutPass(dg, map, null);
635
- if (!g.room) return g;
636
- const g2 = layoutPass(dg, map, g.room, true); g2.ms.probe = g.ms.probe; return g2;
646
+ let g = layoutPass(dg, map, null);
647
+ if (g.room) { const g2 = layoutPass(dg, map, g.room, true); g2.ms.probe = g.ms.probe; g = g2; }
648
+ // two lines that still cross: one takes another side, and the view is laid out so once more, kept if it has fewer issues. A
649
+ // line that closes a loop leaves its source on the far side, out round the map as a loop under the boxes does, and still comes
650
+ // in on its target's near side (clinic's reminder, from the SMS gateway under Patient records, crossed the line into Patient
651
+ // records); of two other lines, the steeper comes into its target's top or bottom (clinic's "Sends texts" with Clinic bookings
652
+ // unfolded, up past Patient records into the SMS gateway's bottom). ponytail: a layout more, so only up to 30 cards (as
653
+ // bestFlow); a larger view keeps its crossings
654
+ const refs = [], pairs = map && !dg.quick && !dg.flipped && dg.nodes.length <= 30 ? routeAudit(g, '', null, refs).flatMap((s, k) => s.endsWith(' cross') ? [refs[k]] : []) : [];
655
+ const far = { L: 'right', R: 'left', T: 'bottom', B: 'top' }, dy = e => (e.s.y + e.s.h / 2) - (e.t.y + e.t.h / 2), out = new Map();
656
+ pairs.forEach(p => p.forEach(e => { if (e.back && !e.fixed && !e.fromSide) out.set(e.from + '>' + e.to, ['fromSide', far[e.fs]]); }));
657
+ pairs.forEach(p => { const e = p.filter(e => !e.back && !e.fixed && !e.toSide && /[LR]/.test(e.ts) && dy(e)).sort((a, b) => Math.abs(dy(b)) - Math.abs(dy(a)))[0];
658
+ if (e && !p.some(o => out.has(o.from + '>' + o.to))) out.set(e.from + '>' + e.to, ['toSide', dy(e) > 0 ? 'bottom' : 'top']); });
659
+ if (!out.size) return g;
660
+ const g2 = layoutGraph({ ...dg, flipped: true, edges: dg.edges.map(e => out.has(e.from + '>' + e.to) ? { ...e, [out.get(e.from + '>' + e.to)[0]]: out.get(e.from + '>' + e.to)[1] } : e) }, map);
661
+ return routeAudit(g2, '').length < routeAudit(g, '').length ? g2 : g;
637
662
  }
638
663
  function layoutPass(dg, map, room, last) { // room: { x: { col: px }, y: { row: px } }; last: lay out with it, find no more
639
664
  const __ms = { t: performance.now() }, t0 = __ms.t;
@@ -874,7 +899,7 @@ function layoutPass(dg, map, room, last) { // room: { x: { col: px }, y: { row:
874
899
  return hz[0][1] > Math.min(v[0][1], v[1][1]) && hz[0][1] < Math.max(v[0][1], v[1][1]) && v[0][0] > Math.min(hz[0][0], hz[1][0]) && v[0][0] < Math.max(hz[0][0], hz[1][0]); };
875
900
  const onSegInterior = (p, a, b) => (a[0] === b[0] && Math.abs(p[0] - a[0]) < 0.5 && p[1] > Math.min(a[1], b[1]) && p[1] < Math.max(a[1], b[1]))
876
901
  || (a[1] === b[1] && Math.abs(p[1] - a[1]) < 0.5 && p[0] > Math.min(a[0], b[0]) && p[0] < Math.max(a[0], b[0]));
877
- function gridRoute(e, allowCross, win) {
902
+ function gridRoute(e, allowCross, win, spare) {
878
903
  const st = [e.p1[0] + OUT[e.fs][0] * STUB, e.p1[1] + OUT[e.fs][1] * STUB];
879
904
  const en = [e.p2[0] + OUT[e.ts][0] * STUB, e.p2[1] + OUT[e.ts][1] * STUB];
880
905
  // ends that face each other closer than two stubs (a card 32 px beside a box) leave the grid no room: the plain
@@ -934,6 +959,15 @@ function layoutPass(dg, map, room, last) { // room: { x: { col: px }, y: { row:
934
959
  }
935
960
  }
936
961
  for (let k = 0; k < N; k++) if (!inObs[k] && (!onAv[k] || allowCross)) { ok[k] = 1; crossAt[k] = onAv[k]; }
962
+ // routed after a port swap, it keeps off the end of its partner's stub, where the partner, routed next, starts
963
+ if (spare) for (const p of [spare.stubs[0][1], spare.stubs[1][0]]) { const i = X.indexOf(p[0]), j = Y.indexOf(p[1]); if (i >= 0 && j >= 0) { if (allowCross) crossAt[i * ny + j] = 1; else ok[i * ny + j] = 0; } }
964
+ // a line never runs round one of its own ends: the grid behind each (past the side opposite its port) is shut to it, unless its
965
+ // other end is there. A route with no crossing went any length, round its own card too (clinic's reminder went over the
966
+ // patient, down its far side and under it to reach the port below the patient's other line), and port repair saw nothing
967
+ for (const [n, side, far] of [[e.s, e.fs, en], [e.t, e.ts, st]]) if (!behind(n, side, far)) {
968
+ const lr = side === 'R' || side === 'L';
969
+ (lr ? X : Y).forEach((v, a) => { if (behind(n, side, lr ? [v, n.y] : [n.x, v])) for (let b = 0; b < (lr ? ny : nx); b++) ok[lr ? a * ny + b : b * ny + a] = 0; });
970
+ }
937
971
  if (any) for (const r of reserved) for (let i = from(X, r.x - 3); i < nx && X[i] < r.x + r.w + 3; i++) for (let j = from(Y, r.y - 3); j < ny && Y[j] < r.y + r.h + 3; j++) crossAt[i * ny + j] = 1;
938
972
  // a run within 8 px of a box's border costs ten times its length: the grid keeps a port's track and an earlier line's however
939
973
  // near a border, and a box levelled half a row from a card (#86) puts its border right by that card's ports
@@ -1015,12 +1049,20 @@ function layoutPass(dg, map, room, last) { // room: { x: { col: px }, y: { row:
1015
1049
  const tries = [full]; if (e.step != null && e.label && !dg.abs) tries.push(map ? '' : String(e.step));
1016
1050
  const segs = segsOf(e.pts).map((s, i) => ({ a: s[0], b: s[1], i, l: Math.hypot(s[1][0] - s[0][0], s[1][1] - s[0][1]), h: s[0][1] === s[1][1] }));
1017
1051
  const order = [...segs].sort((p, q) => (q.h - p.h) * 40 + (q.l - p.l));
1052
+ // the strict spots along a segment: the middle, a third and a fifth of the way, then those just clear of each card's stub room,
1053
+ // tag, label and line beside it, the middle's nearest first: where the label fits, one of them is in. The five alone missed a
1054
+ // gap 15 px wide, and the label sat on a box's border (clinic's "Queues reminders")
1055
+ const spots = (sg, w, hgt) => { const v = sg.h ? 0 : 1, lo = sg.a[v], len = sg.b[v] - lo, half = (v ? hgt : w) / 2 + 3.5, mid = sg.a[1 - v], across = (v ? w : hgt) / 2 + 3;
1056
+ const span = r => [[r.x, r.x + r.w], [r.y, r.y + r.h]], rs = [...nodeRects.map(r => ({ x: r.x - STUB - 4, y: r.y - STUB - 4, w: r.w + 2 * STUB + 8, h: r.h + 2 * STUB + 8 })), ...tagRects, ...placed,
1057
+ ...[...routed.filter(r => r !== e).map(r => r.pts), e.pts].flatMap(P => segsOf(P).map(([p, q]) => ({ x: Math.min(p[0], q[0]), y: Math.min(p[1], q[1]), w: Math.abs(q[0] - p[0]), h: Math.abs(q[1] - p[1]) })))];
1058
+ return [.5, .32, .68, .2, .8, ...rs.map(span).filter(s => s[1 - v][0] < mid + across && s[1 - v][1] > mid - across).flatMap(s => [s[v][0] - half, s[v][1] + half])
1059
+ .map(u => (u - lo) / len).filter(f => f >= 0 && f <= 1).sort((p, q) => Math.abs(p - .5) - Math.abs(q - .5))]; };
1018
1060
  for (const pass of (map ? [0, 1, 2] : [0])) for (const text of tries) { const relax = pass === 2; // 0 strict, 1 strict but may touch a box border, 2 relaxed
1019
1061
  const two = !!(text && sub), pill = map && e.step != null, w = map ? (text ? pillW(text, two && sub) : 0) + (pill ? stepW(e.step) + (text ? 4 : 6) : 0) : textW(text, 10) + 12, hgt = map ? (two ? 32 : 20) : 18;
1020
1062
  for (const sg of order) {
1021
1063
  if (!relax && sg.h && sg.l < w + 10) continue;
1022
1064
  if (!relax && !sg.h && sg.l < hgt + 14) continue;
1023
- for (const f of (relax ? [.5, .32, .68, .2, .8, .1, .9, .42, .58, .26, .74, .05, .95] : [.5, .32, .68, .2, .8])) {
1065
+ for (const f of (relax ? [.5, .32, .68, .2, .8, .1, .9, .42, .58, .26, .74, .05, .95] : spots(sg, w, hgt))) {
1024
1066
  const lx = sg.a[0] + (sg.b[0] - sg.a[0]) * f, ly = sg.a[1] + (sg.b[1] - sg.a[1]) * f;
1025
1067
  const box = { x: lx - w / 2, y: ly - hgt / 2, w, h: hgt };
1026
1068
  const grow = { x: box.x - 3, y: box.y - 3, w: box.w + 6, h: box.h + 6 };
@@ -1105,8 +1147,8 @@ function layoutPass(dg, map, room, last) { // room: { x: { col: px }, y: { row:
1105
1147
  const p = placed.findIndex(same(0)); if (p >= 0) placed.splice(p, 1); const q = reserved.findIndex(same(3)); if (q >= 0) reserved.splice(q, 1); };
1106
1148
  // a swapped edge tries a clean route near its ends, then one that may cross (near its ends, then anywhere); it
1107
1149
  // skips the clean whole-grid search, which mostly fails here and visits every cell when it does
1108
- const routeOne = e => { let how = 'grid'; e.lbl = null; e.pts = gridRoute(e, false, 320);
1109
- if (!e.pts) { how = 'cross'; e.pts = gridRoute(e, true, 320) || gridRoute(e, true, 0); } if (!e.pts) { how = 'fallback'; e.pts = fallback(e); }
1150
+ const routeOne = (e, spare) => { let how = 'grid'; e.lbl = null; e.pts = gridRoute(e, false, 320, spare);
1151
+ if (!e.pts) { how = 'cross'; e.pts = gridRoute(e, true, 320, spare) || gridRoute(e, true, 0, spare); } if (!e.pts) { how = 'fallback'; e.pts = fallback(e); }
1110
1152
  e.how = how; routed.push(e); placeLabel(e); return true; };
1111
1153
  let budget = 2 * free.length, swapped = false;
1112
1154
  for (let round = 0; issues && dg.repair !== false && round < 8 && budget > 0; round++) {
@@ -1115,7 +1157,8 @@ function layoutPass(dg, map, room, last) { // room: { x: { col: px }, y: { row:
1115
1157
  const xing = (a, b) => segsE(a).some(([p, q]) => segsE(b).some(([r, s]) => crosses(p, q, r, s)));
1116
1158
  const pairs = []; edges.forEach((a, i) => edges.slice(i + 1).forEach(b => { if (xing(a, b)) pairs.push([a, b]); }));
1117
1159
  // a crossing pair, or a pair where one shares a node side with the other's crossing partner
1118
- const cand = [], seen = new Set(), add = (a, b) => { const k = a.id + '|' + b.id; if (!seen.has(k)) { seen.add(k); cand.push([a, b]); } }; // each pair once; crossing pairs first
1160
+ // each pair once, crossing pairs first; by place, since a model's edges need no id (by id, every pair of r15's was one)
1161
+ const cand = [], seen = new Set(), add = (a, b) => { const k = edges.indexOf(a) + '|' + edges.indexOf(b); if (!seen.has(k)) { seen.add(k); cand.push([a, b]); } };
1119
1162
  pairs.forEach(([a, b]) => add(a, b)); pairs.forEach(([a, b]) => free.forEach(o => { if (o !== a && o !== b) { add(a, o); add(b, o); } }));
1120
1163
  outer3: for (const [a, b] of cand.filter(([x, y]) => !x.fixed && !y.fixed)) {
1121
1164
  for (const n of [a.s, a.t]) for (const side of ['L', 'R', 'T', 'B']) {
@@ -1127,7 +1170,13 @@ function layoutPass(dg, map, room, last) { // room: { x: { col: px }, y: { row:
1127
1170
  budget -= 2; __ms.reroutes++;
1128
1171
  if (!was) { a[ka] = pa; b[kb] = pb; restub(a); restub(b); continue; } // nothing to gain: the count can only drop by their own issues
1129
1172
  ripUp(a); ripUp(b);
1130
- const now = routeOne(a) && routeOne(b) ? issues - was + issueCount(ab) : Infinity;
1173
+ // first the routes they have, each moved onto the other's port: two lines that cross where they leave one side no longer
1174
+ // cross (r15's web: its line to kit turned down past the line to api-client, and routed again they crossed again)
1175
+ const moved = (e, k, P) => { const [i, j] = k === 'p1' ? [0, 1] : [P.length - 1, P.length - 2], v = P[i][1] === P[j][1];
1176
+ return P.length > 2 && simplify(P.map((p, x) => x === i ? e[k] : x === j ? (v ? [p[0], e[k][1]] : [e[k][0], p[1]]) : p)); };
1177
+ const ma = moved(a, ka, a.pts), mb = moved(b, kb, b.pts); let now = Infinity;
1178
+ if (ma && mb) { a.pts = ma; b.pts = mb; a.lbl = b.lbl = null; routed.push(a, b); placeLabel(a); placeLabel(b); now = issues - was + issueCount(ab); if (now >= issues) { ripUp(a); ripUp(b); } }
1179
+ if (now >= issues) now = routeOne(a, b) && routeOne(b) ? issues - was + issueCount(ab) : Infinity;
1131
1180
  if (now < issues) { issues = now; fixed = swapped = true; if (!issues) break outer3; continue outer3; }
1132
1181
  a[ka] = pa; b[kb] = pb; restub(a); restub(b);
1133
1182
  snap.forEach(([e, p, l, hw]) => { e.pts = p; e.lbl = l; e.how = hw; });
@@ -1137,6 +1186,21 @@ function layoutPass(dg, map, room, last) { // room: { x: { col: px }, y: { row:
1137
1186
  if (!fixed) break;
1138
1187
  }
1139
1188
  if (swapped) { ['grid', 'cross', 'fallback'].forEach(k => { delete stats[k]; }); free.forEach(e => { stats[e.how] = (stats[e.how] || 0) + 1; }); }
1189
+ // a line that jogs 16 px or less between sides that face each other runs straight when one of its ends is the only port on its
1190
+ // side: that port moves level with the other end, if no issue comes with it. Ports are spread round the middle of a side, so a
1191
+ // line from the lower of a card's two ports into a card level with it jogged just before it (clinic's "Books appointments")
1192
+ if (map && dg.repair !== false) free.forEach(e => {
1193
+ const P = e.pts, ax = e.fs === 'L' || e.fs === 'R' ? 1 : 0, d = P[P.length - 1][ax] - P[0][ax];
1194
+ if (P.length !== 4 || !d || Math.abs(d) > 16 || OUT[e.fs][1 - ax] !== -OUT[e.ts][1 - ax]) return;
1195
+ const lone = (n, side) => !n.box && sides[n.id + '|' + side].length === 1, [k, n, side, v] = lone(e.t, e.ts) ? ['p2', e.t, e.ts, P[0][ax]] : lone(e.s, e.fs) ? ['p1', e.s, e.fs, P[3][ax]] : [];
1196
+ const sp = n && (ax ? [n.y + n.h * .08, n.y + n.h * .92] : sideSpan(n, side) || [n.x + n.w * .08, n.x + n.w * .92]);
1197
+ if (!k || v < sp[0] || v > sp[1]) return;
1198
+ const was = e[k], before = issueCount(new Set([e])), snap = [e.pts, e.lbl, e.how], snapR = [routed.slice(), reserved.slice(), placed.slice()];
1199
+ ripUp(e); e[k] = ax ? [was[0], v] : [v, was[1]]; restub(e); routeOne(e);
1200
+ if (e.pts.length === 2 && issueCount(new Set([e])) <= before) return;
1201
+ e[k] = was; restub(e); [e.pts, e.lbl, e.how] = snap;
1202
+ routed.splice(0, routed.length, ...snapR[0]); reserved.splice(0, reserved.length, ...snapR[1]); placed.splice(0, placed.length, ...snapR[2]);
1203
+ });
1140
1204
  // the last resort for a line still with no route: around the cards, across or along other lines if it must (it was a
1141
1205
  // plain elbow through whatever lay in its way, and a card's mask made the line look like a connection to that card)
1142
1206
  if (!dg.quick) free.filter(e => e.how === 'fallback').forEach(e => { ripUp(e); e.pts = gridRoute(e, 2, 320) || gridRoute(e, 2, 0) || e.pts; e.lbl = null; routed.push(e); placeLabel(e); });
@@ -1225,16 +1289,19 @@ function svgOf(geo, opts = {}) {
1225
1289
  });
1226
1290
  edges.forEach((e, i) => {
1227
1291
  const st = lstyle(e), why = broke(e), d = roundPath(e.pts), mk = st.m + (e.weight > 1 ? '-w' + e.weight : '');
1292
+ const tip = 9 * (e.weight > 1 ? 1 + (e.weight - 1) * .35 : 1), fd = roundPath(trimLine(e.pts, e.both ? tip : 0, tip)); // an arrowhead's length (marker refX)
1228
1293
  if (tour && tour.legs[i]) { const [a, b] = tour.legs[i];
1229
1294
  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>`; }
1230
1295
  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"/>
1231
1296
  <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})"` : ''}/>
1232
- <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]];
1297
+ <path d="${fd}" class="flowline" stroke="${st.c}"/>${(e.all || [e]).some(x => x.edited) && !e.strong ? (() => { let m = [e.pts[0], e.pts[1]];
1233
1298
  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]; });
1234
1299
  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) : ''}
1235
1300
  <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>`;
1236
1301
  });
1237
- s += '</g><g class="nodes">';
1302
+ // the tour's dot rides over the lines and boxes but under the cards: it goes into a card and comes out at its next
1303
+ // line, rather than across the card's name
1304
+ s += '</g>' + (tour ? `<g class="tour" aria-hidden="true"><circle r="5.5" class="tour-dot"><animateMotion path="${tour.d}" dur="${TOUR}s" repeatCount="indefinite"/></circle></g>` : '') + '<g class="nodes">';
1238
1305
  nodes.forEach(n => {
1239
1306
  const c = col(n.k), f = fill(n.k), removed = n.change === 'removed', planned = n.change === 'planned';
1240
1307
  const pub = map && n.k === 'm-public', priv = map && n.k === 'm-script', ext = map && n.k === 'm-ext', verb = map && n.k === 'm-verb', hook = map && n.k === 'm-hook';
@@ -1325,7 +1392,6 @@ return I && I.src ? `<rect x="${x - M * 3 / 28}" y="${y - M * 3 / 28}" width="${
1325
1392
  ${parts.join('\n')}
1326
1393
  </g>`;
1327
1394
  });
1328
- if (tour) s += `<g class="tour" aria-hidden="true"><circle r="5.5" class="tour-dot"><animateMotion path="${tour.d}" dur="${TOUR}s" repeatCount="indefinite"/></circle></g>`;
1329
1395
  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
1330
1396
  s += '</g><g class="keys">';
1331
1397
  edges.forEach((e, i) => { const said = e.label ? `: ${e.label}${e.sublabel ? ` (${e.sublabel})` : ''}` : '';
@@ -1396,14 +1462,14 @@ class Camera {
1396
1462
  stopAnimation() {
1397
1463
  cancelAnimationFrame(this.anim); this.anim = 0;
1398
1464
  // Resize/refit interrupts camera motion. Navigation awaits that motion, so cancellation
1399
- // must settle its promise too; otherwise go() never releases its busy flag.
1400
- const done = this.animationDone; this.animationDone = null; if (done) done();
1465
+ // must settle its promise too; otherwise go() never releases its busy flag. It settles false: cut short (a story's step glides again)
1466
+ const done = this.animationDone; this.animationDone = null; if (done) done(false);
1401
1467
  }
1402
1468
  set(s) { this.stopAnimation(); this.s = this.clamp({ ...this.s, ...s }); this.apply(); }
1403
- animate(to, ms = 380) {
1469
+ animate(to, ms = 380) { // resolves true once there, false when cut short
1404
1470
  this.stopAnimation();
1405
1471
  const from = { ...this.s }, dest = this.clamp({ ...this.s, ...to });
1406
- if (reducedMotion()) { this.set(dest); return Promise.resolve(); }
1472
+ if (this.o.still ? this.o.still() : reducedMotion()) { this.set(dest); return Promise.resolve(true); }
1407
1473
  return new Promise(res => {
1408
1474
  this.animationDone = res;
1409
1475
  const t0 = performance.now(), ease = t => t < .5 ? 4 * t * t * t : 1 - Math.pow(-2 * t + 2, 3) / 2;
@@ -1412,7 +1478,7 @@ class Camera {
1412
1478
  const t = Math.min(1, (now - t0) / ms), e = ease(t);
1413
1479
  this.s = { cx: from.cx + (dest.cx - from.cx) * e, cy: from.cy + (dest.cy - from.cy) * e, k: Math.exp(lk0 + (lk1 - lk0) * e) };
1414
1480
  this.apply();
1415
- if (t < 1) this.anim = requestAnimationFrame(step); else { this.anim = 0; this.animationDone = null; res(); }
1481
+ if (t < 1) this.anim = requestAnimationFrame(step); else { this.anim = 0; this.animationDone = null; res(true); }
1416
1482
  };
1417
1483
  this.anim = requestAnimationFrame(step);
1418
1484
  });
@@ -1455,7 +1521,7 @@ class Camera {
1455
1521
  this.ptrs.set(e.pointerId, [e.clientX, e.clientY]);
1456
1522
  this.start = { x: e.clientX, y: e.clientY, s: { ...this.s }, dist: this.pinchDist() };
1457
1523
  this.moved = 0; this.dragged = false;
1458
- cancelAnimationFrame(this.anim);
1524
+ this.stopAnimation(); // settles the glide's promise: a press during a navigation or a story's step lets it go on
1459
1525
  }
1460
1526
  pinchDist() { const p = [...this.ptrs.values()]; return p.length > 1 ? Math.hypot(p[0][0] - p[1][0], p[0][1] - p[1][1]) : 0; }
1461
1527
  onMove(e) {
@@ -1489,6 +1555,11 @@ const teller = (issues, refs) => (message, ...about) => { issues.push(message);
1489
1555
  // a detour: a route 2.5 times longer than the straight distance between its ends, or than 100 px; [its length, that distance]
1490
1556
  const detour = pts => { const len = pts.slice(1).reduce((a, p, k) => a + Math.abs(p[0] - pts[k][0]) + Math.abs(p[1] - pts[k][1]), 0);
1491
1557
  const d = Math.hypot(pts[pts.length - 1][0] - pts[0][0], pts[pts.length - 1][1] - pts[0][1]); return len > 2.5 * Math.max(d, 100) ? [len, d] : null; };
1558
+ // behind a card a line meets on its side `side` (R, L, T or B): past the opposite side
1559
+ const behind = (n, side, [x, y]) => side === 'R' ? x < n.x - .5 : side === 'L' ? x > n.x + n.w + .5 : side === 'B' ? y < n.y - .5 : y > n.y + n.h + .5;
1560
+ // [the end, its side] that a line runs round: its route goes behind one of its own ends while its other end is not there. A
1561
+ // long line passed the detour rule however it went round (clinic's reminder: over the patient, down its far side, under it)
1562
+ const roundEnd = e => [[e.s, e.fs, e.pts[e.pts.length - 1]], [e.t, e.ts, e.pts[0]]].find(([n, side, far]) => n && side && !behind(n, side, far) && e.pts.some(p => behind(n, side, p)));
1492
1563
  function routeAudit(geo, where, only, refs) { // only: a set of edges; counts just the issues that involve one of them
1493
1564
  const sk = e => only && !only.has(e);
1494
1565
  const issues = [], say = teller(issues, refs);
@@ -1525,6 +1596,7 @@ function routeAudit(geo, where, only, refs) { // only: a set of edges; counts ju
1525
1596
  geo.nodes.forEach(n => { if (n.id === e.from || n.id === e.to) return;
1526
1597
  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); });
1527
1598
  const dt = detour(e.pts); if (dt) say(`${where}: edge ${name(e)} takes a detour (${Math.round(dt[0])}px for ${Math.round(dt[1])}px)`, e);
1599
+ const r = roundEnd(e); if (r) say(`${where}: edge ${name(e)} runs round its own end "${r[0].label}"`, e, r[0].box ? (geo.families || []).find(f => f.id === r[0].id) : r[0]);
1528
1600
  // reading order: every wire runs left to right, except one that closes a loop or answers its pair (keepPairs)
1529
1601
  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);
1530
1602
  });
@@ -2212,7 +2284,7 @@ export function layout(model, viewId, options = {}) {
2212
2284
  }
2213
2285
 
2214
2286
  /** The layout check's geometry for one view, without a browser: overlapping cards, crossing or shared lines, detours,
2215
- * lines against reading order or through a card, labels on lines or off their own, boxes and lanes. The page's check
2287
+ * lines round their own ends, against reading order or through a card, labels on lines or off their own, boxes and lanes. The page's check
2216
2288
  * (`umlimator.py check`) adds what it measures in the DOM: text that overflows its card or touches its mark, titles
2217
2289
  * that overflow, edge labels on cards or on each other. Each issue names the cards and lines it is about. */
2218
2290
  export function check(model, viewId, options = {}) {
@@ -2536,8 +2608,8 @@ export function drift(model, observed, options = {}) {
2536
2608
  return { model: out, counts, views: arr(out.views).filter(v => v && linesOf(out, v).some(e => e.drift)).map(v => v.id) };
2537
2609
  }
2538
2610
 
2539
- let INSTANT = false; // the page's own hooks (#check, #still, #shot) draw without animation
2540
- const reducedMotion = () => INSTANT || (typeof matchMedia === 'function' && matchMedia('(prefers-reduced-motion: reduce)').matches);
2611
+ // the reader's setting; a handle drawn instant (the page's #check, #still and #shot hooks) is still on top of it, on its own
2612
+ const reducedMotion = () => typeof matchMedia === 'function' && matchMedia('(prefers-reduced-motion: reduce)').matches;
2541
2613
  let SHEET = null;
2542
2614
  const sheet = () => { if (!SHEET) { SHEET = new CSSStyleSheet(); SHEET.replaceSync(STYLES); } return SHEET; };
2543
2615
  const INNER = Symbol('umlimator.inner'); // what mountPage needs from render() beyond the public handle
@@ -2554,7 +2626,8 @@ async function pngOf(file) {
2554
2626
 
2555
2627
  export function render(container, model, options = {}) {
2556
2628
  const P = options._page || {}; // the standalone page's hooks: status line, overlay insets, still and reader drawing
2557
- if (P.instant) INSTANT = true;
2629
+ const instant = !!P.instant, still = () => instant || reducedMotion(); // this map's motion: an instant page stills only itself
2630
+ const ctrlWheel = options.wheel === 'ctrl'; // the wheel zooms only with Ctrl or ⌘ (a trackpad's pinch too), and else scrolls the page: a map in a document
2558
2631
  const OPS = ['add', 'rename', 'move', 'delete', 'reorder'], ed = options.editable;
2559
2632
  const E = ed === true ? Object.fromEntries(OPS.map(o => [o, true])) : ed && typeof ed === 'object' ? Object.fromEntries(OPS.map(o => [o, !!ed[o]])) : null;
2560
2633
  const can = op => !!(E && E[op]);
@@ -2565,6 +2638,7 @@ export function render(container, model, options = {}) {
2565
2638
  const own = typeof options.measure === 'function' ? options.measure : null;
2566
2639
  let measure = options.measure === false ? null : own || hostMeasure(host), current = model;
2567
2640
  const expanded = {}; // per view: the cards unfolded in place (#71), in the order they unfolded; in this handle only, never in the model
2641
+ const asked = {}; // per view: card id -> the inner view it waits for, when E or Alt + click asked to unfold it before the host loaded that view
2568
2642
  let ctx = mapContext(model, { measure, kinds: !!P.kinds, expand: expanded }); // kinds: the standalone page guesses line kinds from label words, for its legend
2569
2643
  const root = host.attachShadow({ mode: 'open', delegatesFocus: true }); // host.focus() lands in the map (on the selected card)
2570
2644
  root.adoptedStyleSheets = [sheet()];
@@ -2578,59 +2652,88 @@ export function render(container, model, options = {}) {
2578
2652
  <button data-z="up" title="Up one level (Esc)" aria-label="Up one level">↑</button><span class="sep"></span>
2579
2653
  <button data-z="out" title="Zoom out (−)" aria-label="Zoom out">−</button><button data-z="fit" class="pct" title="Fit to screen (0)">100%</button><button data-z="in" title="Zoom in (+)" aria-label="Zoom in">+</button><span class="sep"></span>
2580
2654
  <button data-z="one" title="Actual size (1)">1:1</button><button data-z="flow" title="Animate flow direction (F)" aria-label="Animate flow">≋</button>${E && E.add ? '<span class="sep"></span><button data-z="add" title="Add a card (N, or Alt + double-click)" aria-label="Add a card">+</button>' : ''}<span class="depth" title="Reading depth follows zoom">READ</span>
2581
- </div>`}</div></div>${!framed ? '' : (options.legend ? '<div class="m-legend"></div>' : '') + (options.hints ? `<div class="hint" role="status" aria-live="polite">${hintHTML()}</div>` : '') + '</div>'
2655
+ </div>`}</div></div>${!framed ? '' : (options.legend ? '<div class="m-legend"></div>' : '') + (options.hints ? `<div class="hint" role="status" aria-live="polite">${hintHTML('', ctrlWheel)}</div>` : '') + '</div>'
2582
2656
  + (options.details ? '<aside class="panel" aria-label="Node details" hidden></aside>' : '') + '</div>'
2583
2657
  + (options.nav ? '<button class="ibtn um-navbtn" title="Views (O)" aria-label="Toggle the views list" aria-expanded="false">☰</button><aside class="outline" aria-label="Views"></aside>' : '') + '</div>'}`;
2584
2658
  const stage = root.querySelector('.stage'), q = s => root.querySelector(s);
2585
- const tell = q('.hint') && hinter(q('.hint'), hintHTML()); // the hint line says what just happened, for a while
2659
+ const tell = q('.hint') && hinter(q('.hint'), hintHTML('', ctrlWheel)); // the hint line says what just happened, for a while
2586
2660
  const subs = {};
2587
- const emit = (type, e) => { const f = (options.on || {})[type]; if (f) f(e); (subs[type] || []).slice().forEach(fn => fn(e)); };
2661
+ // each listener on its own: an error in one is reported, and the map's own transition goes on (a host's bug never wedges it)
2662
+ const fail = err => { if (typeof reportError === 'function') reportError(err); else console.error(err); };
2663
+ const emit = (type, e, last = false) => { if (dead && !last) return; [(options.on || {})[type], ...(subs[type] || [])].forEach(fn => { if (typeof fn !== 'function') return; try { fn(e); } catch (err) { fail(err); } }); if (type !== 'state') touch(); };
2664
+ /* the map's state for hosts and agents (0.14.0): handle.state is a fresh, frozen, JSON-serializable snapshot, and the state
2665
+ event says which of its keys changed, once a frame at most (on a 16 ms timer, so a page in a hidden tab still says it).
2666
+ The camera is the exception: a pan, zoom, glide or drag is one state event, once it comes to rest; until then the
2667
+ event's camera is where it last rested (handle.state reads it live) */
2668
+ const KEYS = ['view', 'navigating', 'selection', 'expanded', 'lens', 'overlays', 'story', 'tour', 'replay', 'camera', 'flow', 'motion', 'gesture', 'destroyed'];
2669
+ let lastState = null, stateT = 0, camRest = null, restT = 0; // the last state event's snapshot and the flush to come; the camera at rest, and its check to come
2670
+ const listensState = () => !!((options.on || {}).state || (subs.state || []).length);
2671
+ function touch() { if (!stateT && !dead && listensState()) stateT = setTimeout(flushState, 16); }
2672
+ function flushState(last = false) { clearTimeout(stateT); stateT = 0; if (!listensState() || (dead && !last)) return;
2673
+ const s = snapshot(true), changed = KEYS.filter(k => JSON.stringify(s[k]) !== JSON.stringify(lastState ? lastState[k] : undefined)); lastState = s;
2674
+ if (changed.length) emit('state', { state: s, changed }, last); }
2675
+ // at rest: 150 ms with no change, no glide, no press on the map and no move under way; a moving camera is looked at again
2676
+ function camMoved() { clearTimeout(restT); restT = !dead && listensState() ? setTimeout(rested, 150) : 0; }
2677
+ function rested() { restT = 0; if (dead) return; if (cam && (cam.anim || cam.ptrs.size) || nav) return camMoved(); camRest = camNow(); touch(); }
2588
2678
  let cam = null, geo = null, svg = null, cur = null, sel = null, more = [], busy = false, paused = false, dead = false;
2589
2679
  const timers = new Set(), later = (f, ms) => { const t = setTimeout(() => { timers.delete(t); if (!dead) f(); }, ms); timers.add(t); return t; };
2590
- const sleep = ms => new Promise(r => later(r, reducedMotion() ? 0 : ms));
2680
+ const sleeping = new Set(); // a sleep resolves on its own, or when the map is destroyed: nothing that awaits one is left pending
2681
+ const sleep = ms => new Promise(r => { if (dead) return r(); const wake = () => { sleeping.delete(wake); r(); }; sleeping.add(wake); later(wake, still() ? 0 : ms); });
2591
2682
  const status = t => P.status ? P.status(t) : tell && tell(t);
2592
2683
  const wide = P.wide || (() => (cam ? cam.size()[0] : stage.clientWidth) > 820);
2593
2684
 
2594
2685
  const upOf = () => (P.upTarget ? P.upTarget() : (ctx.V[cur] || {}).parent) || null;
2595
2686
  const nodeRef = id => ({ type: 'node', id, view: cur });
2596
- const picked = () => sel ? [...more.map(nodeRef), sel] : []; // every selected thing, the latest last (more: other selected cards)
2687
+ const copyRef = r => r && { ...r, ...(Array.isArray(r.ids) ? { ids: [...r.ids] } : {}) }; // a host gets copies: the map's Refs stay its own
2688
+ const picked = () => sel ? [...more.map(nodeRef), copyRef(sel)] : []; // every selected thing, the latest last (more: other selected cards)
2597
2689
  const say = t => { const l = root.querySelector('.um-live'); if (l) l.textContent = t; };
2598
2690
  const nameOf = id => ctx.nodeIn(id, cur).label || ((geo.families || []).find(f => f.id === id) || {}).label || id;
2599
- const emitSel = () => {
2691
+ let said = '[]'; // the selection last reported: a select event means it changed, or a click asked again (again)
2692
+ const emitSel = (again = false) => {
2693
+ const now = JSON.stringify(picked()); if (now === said && !again) return; said = now;
2600
2694
  say(!sel ? 'Nothing selected' : sel.type === 'node' ? nameOf(sel.id) + (more.length ? `, ${more.length + 1} cards selected` : ', selected')
2601
2695
  : sel.type === 'edge' ? (e => e ? `Line from ${nameOf(e.from)} to ${nameOf(e.to)}, selected` : 'Line selected')(geo.edges[edgeIndex(sel)]) : `Box ${(svg.querySelector(`.family[data-family="${CSS.escape(sel.id)}"] .family-title`) || {}).textContent || sel.id}, selected`);
2602
- emit('select', { target: sel, targets: picked() });
2696
+ emit('select', { target: copyRef(sel), targets: picked() });
2603
2697
  };
2604
2698
  const edgeRef = e => ({ type: 'edge', ids: edgeRefIds(e), view: cur });
2605
2699
  const edgeIndex = ref => geo.edges.findIndex(e => { const ids = edgeRefIds(e); return ids.length === ref.ids.length && ids.every((x, i) => x === ref.ids[i]); });
2606
2700
  const exists = ref => !ref ? true : ref.type === 'node' ? !!geo.byId[ref.id] : ref.type === 'edge' ? edgeIndex(ref) >= 0
2607
2701
  : ref.type === 'container' ? (geo.families || []).some(f => f.id === ref.id) : false;
2608
2702
 
2609
- function mount(vid) {
2610
- cur = vid; ctx.planOf(vid);
2703
+ // focus in the map goes back on the same card or line in a new picture, else on the canvas; focus outside the map stays there
2704
+ const focusOf = () => { const a = root.activeElement; if (!a || !(a === stage || (svg && svg.contains(a)) || a.closest('.empty-view'))) return null;
2705
+ const lk = a.closest('.line-key'), k = a.closest('[data-node]'), g = a.closest('.node');
2706
+ return lk && geo.edges[+lk.dataset.i] ? edgeRef(geo.edges[+lk.dataset.i]) : k ? nodeRef(k.dataset.node) : g ? nodeRef(g.dataset.id) : {}; };
2707
+ const refocus = ref => { if (!ref) return; if ((ref.type === 'node' || ref.type === 'edge') && exists(ref)) focusMap(ref, false); else stage.focus({ preventScroll: true }); };
2708
+ function mount(vid) { // vid: a view of the model, checked first (one it lacks changes nothing: false), or null for a model with no views
2709
+ const v = vid == null ? null : ctx.V[vid]; if (vid != null && !v) return false;
2710
+ const back = focusOf();
2711
+ cancelGesture(!!v && vid === cur); // a new picture: a drag or a line under way stops; a name field stays over its card if the view draws it still
2712
+ cur = v ? vid : null; if (v) ctx.planOf(vid);
2611
2713
  stage.querySelectorAll('svg.map-svg, .empty-view').forEach(s => s.remove());
2612
- const v = ctx.V[vid], empty = !(v.nodeIds || []).some(i => ctx.N[i]);
2613
- if (empty) { // a view with nothing in it gets a friendly card, not a blank canvas
2714
+ if (!v || !(v.nodeIds || []).some(i => ctx.N[i])) { // a view with nothing in it, or a model with no views, gets a friendly card, not a blank canvas
2614
2715
  geo = { nodes: [], edges: [], groups: [], lanes: [], families: [], byId: {}, stats: {}, bounds: { x: 0, y: 0, w: 400, h: 300 } };
2615
2716
  stage.insertAdjacentHTML('afterbegin', '<svg class="map-svg" viewBox="0 0 400 300" xmlns="http://www.w3.org/2000/svg"></svg>');
2616
- const p = v.parent;
2617
- stage.insertAdjacentHTML('beforeend', `<div class="empty-view"><div class="ico">◌</div><h4>Nothing here yet</h4><p>This view has no nodes yet.</p>${p ? `<button class="btn" data-v="${esc(p)}">← Back to ${esc(String(ctx.V[p].title || p).split(' · ')[0])}</button>` : ''}</div>`);
2717
+ const p = v && v.parent;
2718
+ stage.insertAdjacentHTML('beforeend', `<div class="empty-view"><div class="ico">◌</div><h4>Nothing here yet</h4><p>${v ? 'This view has no nodes yet.' : 'This map has no views yet.'}</p>${p ? `<button class="btn" data-v="${esc(p)}">← Back to ${esc(String(ctx.V[p].title || p).split(' · ')[0])}</button>` : ''}</div>`);
2618
2719
  } else {
2619
2720
  const c = ctx.geoCache[vid] ||= ctx.buildView(vid, { tour: P.tour !== false, openHint: P.openHint !== false });
2620
2721
  stage.insertAdjacentHTML('afterbegin', c.markup); geo = c.g;
2621
2722
  }
2622
2723
  svg = stage.querySelector('svg'); svg.classList.add('map-svg'); if (paused) svg.pauseAnimations();
2623
- if (!cam) cam = new Camera(stage, svg, { wheel: 'zoom', maxFit: 1.15, max: 4, mapDepth: 0.3, onChange: onCam, skip: e => !!((can('move') || can('reorder') || can('add')) && e.target.closest && e.target.closest('.node, .um-handle')), inset: () => clearOf([].concat(P.inset ? P.inset() : fitIn())) });
2724
+ if (!cam) cam = new Camera(stage, svg, { wheel: ctrlWheel ? 'ctrl' : 'zoom', maxFit: 1.15, max: 4, mapDepth: 0.3, onChange: onCam, still, skip: e => !!((can('move') || can('reorder') || can('add')) && e.target.closest && e.target.closest('.node, .um-handle')), inset: () => clearOf([].concat(P.inset ? P.inset() : fitIn())) });
2624
2725
  cam.setSvg(svg, geo.bounds);
2625
2726
  const ub = q('[data-z="up"]'); if (ub) ub.disabled = !upOf();
2626
2727
  if (options.minimap) buildMini();
2627
- 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
2628
- const had = sel; sel = null; tabKeys(); paint();
2629
- more = [];
2630
- if (had) emitSel();
2728
+ if (v && !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
2729
+ sel = null; more = []; sub = null; tabKeys(); paint(); // no select event here: the caller reports, once, what became of the selection
2631
2730
  endTrip(); relens(); drawMarks(); // a new picture: a tour or replay on the old one ends; the host's marks and the lens go on again (#63, #94)
2632
- if (pc) pc.chrome();
2633
- if (P.onMount) P.onMount(inner);
2731
+ if (naming) { if (naming.ref && !exists(naming.ref)) naming.close(false); else naming.place(); }
2732
+ refocus(back);
2733
+ if (v && pc) pc.chrome();
2734
+ if (v && P.onMount) P.onMount(inner);
2735
+ touch();
2736
+ return true;
2634
2737
  }
2635
2738
  // at fit the cards keep off the pieces over the map (#99): the views list's button, the hint line, and the legend, which
2636
2739
  // the map leaves room for above it or to its right, whichever leaves the map larger
@@ -2655,6 +2758,8 @@ export function render(container, model, options = {}) {
2655
2758
  clear = { geo, key, sets }; return sets;
2656
2759
  }
2657
2760
  function onCam(c) {
2761
+ camMoved(); // the state event says where the camera comes to rest, once
2762
+ if (naming) naming.place(); // a name field stays over what it names
2658
2763
  const pct = q('.pct'), depth = q('.depth');
2659
2764
  if (pct) pct.textContent = Math.round(c.s.k * 100) + '%';
2660
2765
  if (depth) depth.textContent = (stage.dataset.depth || 'read').toUpperCase();
@@ -2728,7 +2833,7 @@ export function render(container, model, options = {}) {
2728
2833
  svg.querySelectorAll('.node').forEach(g => { const id = g.dataset.id; g.classList.toggle('sel', id === nid || more.includes(id)); g.classList.toggle('nb', ups.has(id) || downs.has(id)); });
2729
2834
  svg.querySelectorAll('.edge-g').forEach(g => g.classList.toggle('hot', g.dataset.from === nid || g.dataset.to === nid));
2730
2835
  svg.querySelectorAll('.e-lab').forEach(g => { const e = geo.edges[g.dataset.i]; g.classList.toggle('hot', e.from === nid || e.to === nid); });
2731
- svg.querySelectorAll('.n-port').forEach(p => p.classList.toggle('hot', p.dataset.edges.split(',').some(i => geo.edges[i].from === nid || geo.edges[i].to === nid)));
2836
+ svg.querySelectorAll('.n-port[data-edges]').forEach(p => p.classList.toggle('hot', p.dataset.edges.split(',').some(i => geo.edges[i] && (geo.edges[i].from === nid || geo.edges[i].to === nid)))); // not an ease's fading copy (#67)
2732
2837
  stage.classList.add('focusing');
2733
2838
  } else if (sel.type === 'edge') {
2734
2839
  const i = edgeIndex(sel), e = geo.edges[i];
@@ -2741,13 +2846,15 @@ export function render(container, model, options = {}) {
2741
2846
  }
2742
2847
  function select(ref, o = {}) {
2743
2848
  if (ref && (ref.type === 'chip' || ref.type === 'badge')) ref = ref.container != null ? { type: 'container', id: ref.container } : nodeRef(ref.node); // a chip or badge: its card (box)
2744
- if (ref && (!geo || !exists(ref))) return;
2745
- const before = JSON.stringify(sel);
2746
- const was = more.length; more = [];
2747
- sel = ref ? { ...ref, view: cur } : null;
2849
+ if (ref && (!geo || !exists(ref))) return false;
2850
+ if (!o.story) readerMoved(); // a selection the story didn't make: a story playing waits (0.14.0)
2851
+ more = []; selectNew = false; // the reader moved on: an add the host has not answered selects nothing later
2852
+ sel = ref ? { ...copyRef(ref), view: cur } : null;
2853
+ if (!ofSel(sub)) sub = null; // a chip or badge acts for Delete and Alt + arrows only while its card or box is the one selected
2748
2854
  paint();
2749
2855
  if (sel && sel.type === 'node' && o.reveal !== false) { const n = geo.byId[sel.id]; requestAnimationFrame(() => { if (!dead) cam.reveal({ x: n.x, y: n.y, w: n.w, h: n.h }); }); }
2750
- if (JSON.stringify(sel) !== before || o.again || was) emitSel();
2856
+ emitSel(!!o.again);
2857
+ return true;
2751
2858
  }
2752
2859
 
2753
2860
  /* keyboard: the nearest card in an arrow's direction, by distance along it plus twice the distance across it */
@@ -2764,41 +2871,65 @@ export function render(container, model, options = {}) {
2764
2871
  if (id === sel.id) { if (!more.length) return select(null); sel = nodeRef(more.pop()); }
2765
2872
  else if (more.includes(id)) more = more.filter(x => x !== id);
2766
2873
  else { more.push(sel.id); sel = nodeRef(id); }
2767
- paint(); emitSel();
2874
+ sub = null; selectNew = false; paint(); emitSel();
2768
2875
  }
2769
2876
 
2770
- /* navigation: zoom into the card that opens a view, out to the card a view came from */
2771
- async function go(vid, how = {}) {
2772
- if (!ctx.V[vid] || busy || dead) return;
2773
- if (vid === cur) { if (how.select) select(nodeRef(how.select)); return; }
2774
- busy = true;
2775
- const prev = cur, via = how.via;
2776
- let mode = 'jump';
2777
- if (prev && ctx.chain(vid).includes(prev) || how.via) mode = 'in';
2778
- else if (prev && ctx.chain(prev).includes(vid)) mode = 'out';
2779
- if (P.onNavigate) P.onNavigate(vid, prev, mode);
2780
- if (prev && !reducedMotion()) {
2781
- if (mode === 'in' && via && geo.byId[via]) { const n = geo.byId[via]; await cam.animate(cam.zoomTo({ x: n.x, y: n.y, w: n.w, h: n.h }, 1.1), 420); }
2782
- else await cam.animate({ k: cam.s.k * (mode === 'out' ? 0.6 : 1) }, mode === 'out' ? 260 : 1);
2783
- stage.classList.add('fading'); await sleep(130);
2784
- }
2785
- if (dead) return;
2786
- mount(vid);
2787
- const fitS = startView();
2788
- if (prev && !reducedMotion()) {
2789
- if (mode === 'in') cam.set({ ...fitS, k: fitS.k * 0.55 });
2790
- else if (mode === 'out') { const back = geo.nodes.find(n => ctx.childOf(n.id, cur) === prev); cam.set(back ? cam.zoomTo({ x: back.x, y: back.y, w: back.w, h: back.h }, 1.1) : { ...fitS, k: fitS.k * 1.6 }); }
2791
- else cam.set(fitS);
2792
- stage.classList.remove('fading');
2793
- await cam.animate(fitS, mode === 'jump' ? 1 : 460);
2794
- } else { cam.set(fitS); stage.classList.remove('fading'); }
2795
- if (dead) return;
2796
- cam.touched = false;
2797
- if (mode === 'out') { const back = geo.nodes.find(n => ctx.childOf(n.id, cur) === prev); if (back) flash(back.id); }
2798
- if (how.select) select(nodeRef(how.select), { reveal: true });
2799
- busy = false;
2800
- say('Showing ' + (ctx.V[cur].title || cur));
2801
- emit('viewchange', { view: vid, from: prev });
2877
+ /* navigation: zoom into the card that opens a view, out to the card a view came from. One move at a time, the latest
2878
+ wins: a setView() (or open, up) during a move takes it to its own view instead, and every request's promise says
2879
+ whether its view was shown (false: unknown, superseded, or the map destroyed). Under reduced motion a move is done
2880
+ before go() returns. */
2881
+ let nav = null; // the move under way: { to, how, waiting: the resolvers of the requests it answers }
2882
+ const answer = ok => { if (nav) nav.waiting.splice(0).forEach(r => r(ok)); };
2883
+ function go(vid, how = {}) {
2884
+ if (dead) return Promise.resolve(false);
2885
+ if (!ctx.V[vid]) { warn(`setView("${vid}"): the model has no such view; its views are ${ctx.views.map(v => `"${v.id}"`).join(', ') || 'none'}`); return Promise.resolve(false); }
2886
+ if (!nav && vid === cur) { if (how.select) select(nodeRef(how.select)); return Promise.resolve(true); }
2887
+ if (!how.story) readerMoved(); // a move the story didn't ask for: a story playing waits (0.14.0)
2888
+ return new Promise(res => {
2889
+ if (nav) { if (nav.to !== vid) answer(false); nav.to = vid; nav.how = how; nav.waiting.push(res); touch(); return; } // superseded: those requests learn so now
2890
+ nav = { to: vid, how, waiting: [res] }; move();
2891
+ });
2892
+ }
2893
+ async function move() { // takes the map from view to view until it shows the latest target; finishes or rolls back (busy, fading)
2894
+ const mine = nav, start = cur; let left = false; busy = true; touch();
2895
+ try {
2896
+ for (;;) {
2897
+ const prev = cur, via = () => nav.how.via; // a request's via: the card it opens, when the request is the latest
2898
+ const modeOf = v => prev && ctx.chain(v).includes(prev) || (v === nav.to && via()) ? 'in' : prev && ctx.chain(prev).includes(v) ? 'out' : 'jump';
2899
+ if (prev && !still() && nav.to !== prev) { // leave: into the card, or out, then fade
2900
+ const m = modeOf(nav.to), n = m === 'in' && via() && geo.byId[via()];
2901
+ if (n) await cam.animate(cam.zoomTo({ x: n.x, y: n.y, w: n.w, h: n.h }, 1.1), 420);
2902
+ else await cam.animate({ k: cam.s.k * (m === 'out' ? 0.6 : 1) }, m === 'out' ? 260 : 1);
2903
+ stage.classList.add('fading'); left = true; await sleep(130);
2904
+ if (dead || nav !== mine) return;
2905
+ }
2906
+ const vid = nav.to; // the latest request: one made during the leave is the view drawn
2907
+ if (!ctx.V[vid] || vid === cur) break; // gone from the model (an update() during the move), or back where it started
2908
+ const mode = modeOf(vid);
2909
+ delete asked[prev]; // an unfold asked for on the view left is not waited for (#71)
2910
+ if (P.onNavigate) P.onNavigate(vid, prev, mode);
2911
+ mount(vid); emitSel(); left = false;
2912
+ const fitS = startView(), back = mode === 'out' ? geo.nodes.find(n => ctx.childOf(n.id, cur) === prev) : null;
2913
+ if (prev && !still()) {
2914
+ if (mode === 'in') cam.set({ ...fitS, k: fitS.k * 0.55 });
2915
+ else if (mode === 'out') cam.set(back ? cam.zoomTo({ x: back.x, y: back.y, w: back.w, h: back.h }, 1.1) : { ...fitS, k: fitS.k * 1.6 });
2916
+ else cam.set(fitS);
2917
+ stage.classList.remove('fading');
2918
+ await cam.animate(fitS, mode === 'jump' ? 1 : 460);
2919
+ } else { cam.set(fitS); stage.classList.remove('fading'); }
2920
+ if (dead || nav !== mine) return;
2921
+ cam.touched = false;
2922
+ if (back) { flash(back.id); if (root.activeElement === stage) focusMap(nodeRef(back.id), false); } // keys go on from the card come back to
2923
+ if (nav.to === cur) break; // else a request during the zoom-in: on from here
2924
+ }
2925
+ // done: settled before any event, so a listener's own setView() starts a move of its own
2926
+ const ok = nav.to === cur, how = nav.how, shown = cur;
2927
+ answer(ok); nav = null; busy = false; stage.classList.remove('fading');
2928
+ if (left && cur) cam.animate(startView()); // left, and no other view drawn: the camera comes back
2929
+ if (shown && shown !== start) { say('Showing ' + (ctx.V[shown].title || shown)); emit('viewchange', { view: shown, from: start }); }
2930
+ if (ok && how.select && cur === shown) select(nodeRef(how.select), { reveal: true });
2931
+ } catch (err) { fail(err); } // a bug of ours or of a page hook: reported, and the map left usable below
2932
+ finally { if (nav === mine) { answer(false); nav = null; busy = false; stage.classList.remove('fading'); } touch(); }
2802
2933
  }
2803
2934
  /* where the camera starts: fit on a desk; on a phone a readable zoom from the top-left corner */
2804
2935
  function startView() {
@@ -2822,15 +2953,20 @@ export function render(container, model, options = {}) {
2822
2953
  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; };
2823
2954
  function setUnfold(nid, on) { // true when the picture changed
2824
2955
  const list = expanded[cur] || [];
2956
+ if (!on && asked[cur]) asked[cur].delete(nid); // folded back before its view came: no longer waited for
2825
2957
  if (E || dead || !cur || (on ? list.includes(nid) || !ctx.unfoldable(nid, cur) : !list.includes(nid))) return false;
2826
2958
  expanded[cur] = on ? [...list, nid] : list.filter(x => x !== nid);
2827
2959
  update(current, false, true);
2828
- if ((expanded[cur] || []).includes(nid) !== on) return false; // the model would not have it after all
2960
+ if ((expanded[cur] || []).includes(nid) !== on) { emitSel(); return false; } // the model would not have it after all
2961
+ unfolded(nid, on);
2962
+ return true;
2963
+ }
2964
+ function unfolded(nid, on) { // the box a card became is selected and brought into view, or the card it folded back into: one select event
2829
2965
  const f = on && (geo.families || []).find(x => x.id === nid);
2830
2966
  if (f) { cam.reveal({ x: f.x, y: f.y, w: f.w, h: f.h }); select({ type: 'container', id: nid }, { reveal: false }); }
2831
2967
  else if (!on && geo.byId[nid]) select(nodeRef(nid));
2968
+ else emitSel();
2832
2969
  say(`${nameOf(nid)} ${on ? 'unfolded here' : 'folded'}`);
2833
- return true;
2834
2970
  }
2835
2971
  function gesture(id, how) { // E, or Alt + click: a stacked card unfolds; an unfolded box, or a card in one, folds it
2836
2972
  if (E || !id || !geo) return false;
@@ -2839,23 +2975,31 @@ export function render(container, model, options = {}) {
2839
2975
  let stop = false; // the host may take it, or load a view it has not yet and update() right here
2840
2976
  emit('expand', { target: nodeRef(nid), view: ctx.opensOf(nid, cur), expanded: on, preventDefault() { stop = true; } });
2841
2977
  if (stop || dead) return true;
2842
- 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; }
2978
+ if (!setUnfold(nid, on)) {
2979
+ const c2 = on && ctx.opensOf(nid, cur);
2980
+ if (c2 && !ctx.V[c2]) { (asked[cur] ||= new Map()).set(nid, c2); say(`${nameOf(nid)}: its inner view is not loaded yet; it unfolds here once it is`); } // the update() that brings it unfolds it
2981
+ if (on) status(c2 && !ctx.V[c2] ? 'Its inner view is not loaded yet' : 'Nothing more to show here');
2982
+ return true; }
2843
2983
  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;
2844
2984
  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
2845
2985
  return true;
2846
2986
  }
2847
2987
  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); }
2848
- function setFlow(on) {
2988
+ function setFlow(on, save = true) { // save: the page keeps it as the reader's choice
2849
2989
  stage.classList.toggle('flowing', on);
2850
2990
  const b = q('[data-z="flow"]'); if (b) { b.classList.toggle('on', on); b.setAttribute('aria-pressed', on); }
2851
- if (P.onFlow) P.onFlow(on);
2991
+ if (save && P.onFlow) P.onFlow(on);
2992
+ touch();
2852
2993
  }
2853
- function pause(on) { paused = on; stage.classList.toggle('paused', on); if (svg) on ? svg.pauseAnimations() : svg.unpauseAnimations(); }
2994
+ function pause(on) { const was = paused; paused = on; stage.classList.toggle('paused', on); if (svg) on ? svg.pauseAnimations() : svg.unpauseAnimations();
2995
+ if (story && on !== was) { if (on) clearTimeout(story.timer); else arm(story); } } // the page's Pause holds every clock: a dot that rides (startTrip), and a story's step, given its time again after
2854
2996
 
2855
2997
  /* keys on the canvas; the page forwards the same keys when the focus is elsewhere */
2856
2998
  function key(e) {
2857
2999
  const k = e.key, g = e.target && e.target.closest && e.target.closest('.node');
3000
+ if (k === 'Escape' && cancelGesture()) return true; // Escape stops a gesture in progress before anything else
2858
3001
  if (story) { const r = storyKey(e); if (r != null) return r; } // a story's keys first (#69)
3002
+ if (k === 'Escape' && trip && trip.kind) { const was = trip.kind; endTrip(); say(was === 'replay' ? 'Replay stopped' : 'Tour stopped'); return true; } // Esc ends a tour or a replay first
2859
3003
  if (E && editKey(e)) return true;
2860
3004
  if (e.altKey) return false;
2861
3005
  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]');
@@ -2872,35 +3016,61 @@ export function render(container, model, options = {}) {
2872
3016
  const id = g ? g.dataset.id : sel.id;
2873
3017
  if (sel && sel.type === 'node' && sel.id === id && ctx.opensOf(id, cur)) open(id); else select(nodeRef(id), { again: true }); }
2874
3018
  else if (k === 'Escape' || k === 'Backspace') { if (sel) select(null); else up(); }
2875
- else if (k === 'f' || k === 'F') setFlow(!stage.classList.contains('flowing'));
3019
+ else if ((k === 'f' || k === 'F') && !reducedMotion()) setFlow(!stage.classList.contains('flowing')); // under reduced motion no flow is drawn, and ≋ isn't offered
2876
3020
  else if (k.startsWith('Arrow') && g && geo.byId[g.dataset.id]) { const to = nearest(geo.byId[g.dataset.id], k); if (!to) return false;
2877
3021
  const tg = svg.querySelector(`.node[data-id="${CSS.escape(to.id)}"]`); tg.focus({ preventScroll: true }); cam.reveal({ x: to.x, y: to.y, w: to.w, h: to.h }); }
2878
3022
  else if (k.startsWith('Arrow')) { const d = 80, m = { ArrowLeft: [d, 0], ArrowRight: [-d, 0], ArrowUp: [0, d], ArrowDown: [0, -d] }[k]; if (!m) return false; cam.pan(m[0], m[1], true); }
2879
3023
  else return false;
3024
+ readerMoved(); // a key the map acted on: a story playing waits
2880
3025
  return true;
2881
3026
  }
2882
3027
 
2883
3028
 
2884
3029
  /* ---------- editing: a gesture never changes the picture; it emits a `change` the host may apply (then update()) ----------
2885
3030
  Each event carries what the user meant and, apart from it, an optional hint: where the card was dropped. */
2886
- let seq = 0, sub = null, drag = null, conn = null, selectNew = false, swallow = 0;
3031
+ let seq = 0, sub = null, drag = null, conn = null, naming = null, stale = false, selectNew = false, swallow = 0;
3032
+ /* the gesture in progress, one at a time: a card dragged, a line drawn from the handle, or a name field open (naming:
3033
+ { ref, place, close }). Escape, a new picture (setView, an update that redraws, unfolding) and destroy() cancel it,
3034
+ with no change emitted; keepName leaves a name field open over the same view's card, which mount() checks */
3035
+ const gestureNow = () => conn ? 'connecting' : drag && drag.on ? 'dragging' : naming ? 'naming' : 'idle';
3036
+ function cancelGesture(keepName = false) { // true when something was in progress
3037
+ let was = false;
3038
+ if (conn) { if (conn.path) conn.path.remove(); conn = null; was = stale = true; } // its pointerup and click then do nothing
3039
+ if (drag) { if (drag.ghost) drag.ghost.remove(); if (drag.on) was = stale = true; drag = null; }
3040
+ if (naming && !keepName) { naming.close(false); was = true; }
3041
+ if (was) touch();
3042
+ return was;
3043
+ }
2887
3044
  const placed = {}; // per view: the slot every card had when last drawn (stable positions across updates; never in the model)
2888
- const hints = {}; // per view: id -> [col, row] where the last move dropped a card, for the next update only
2889
- const slotsH = {}; // per view: id -> [col, row] of a reordered column group (exact), for the next update only
2890
- const addHints = []; // slots of adds not yet seen as new cards
3045
+ /* an edit's places wait for the host's answer, and the next update() says from its model whether it applied the edit:
3046
+ a move into or out of a box shows in the boxes, an add as a new card with its name, and a place alone (a move to a
3047
+ spot, a reorder) as the card's own `layout` entry the host wrote, or as the model unchanged (the host answering an
3048
+ edit that changes no field). Any other update drops it: an edit the host refused moves nothing, whatever comes next.
3049
+ Places an update applied are kept with the models before and after it, so an update back to either (undo, redo)
3050
+ puts the cards back where they were then */
3051
+ let pending = null; // the latest gesture's: { op, view, card, place, to, exact, label, ids, base, at }
3052
+ const moves = []; // { view, base, after, before, then }: places an update applied, and the ones before it
3053
+ const viewIn = (m, vid) => arrOf(m && m.views).find(v => v && v.id === vid) || {};
3054
+ const boxIdOf = (m, vid, id) => { const bs = arrOf(viewIn(m, vid).containers).filter(b => b && b.id != null && arrOf(b.nodeIds).includes(id)); // the innermost box listing it
3055
+ return ((bs.find(b => !bs.some(o => o.parent === b.id)) || bs[0]) || {}).id ?? null; };
3056
+ const norm = t => String(t ?? '').trim().replace(/\s+/g, ' ').toLowerCase();
3057
+ const labelIn = (m, v, id) => norm(((v.nodeOverrides || {})[id] || {}).label ?? ([...arrOf(m.nodes), ...arrOf(m.views).flatMap(w => arrOf(w && w.nodes))].find(n => n && n.id === id) || {}).label);
2891
3058
  function change(c, exact) {
2892
3059
  const ev = { id: 'c' + Date.now().toString(36) + '-' + (++seq), view: cur, ...c };
2893
- // only the latest gesture's hint waits for the host: an edit the host never answered leaves no trace
2894
- [hints, slotsH].forEach(o => Object.keys(o).forEach(k => delete o[k])); addHints.length = 0;
2895
- if (exact) slotsH[cur] = exact;
2896
- else if (ev.hint && Number.isFinite(ev.hint.col)) {
2897
- if (c.op === 'add' && c.kind === 'node') addHints.push([ev.hint.col, ev.hint.row]);
2898
- else if (c.target && c.target.type === 'node') (hints[cur] ||= {})[c.target.id] = [ev.hint.col, ev.hint.row];
2899
- }
2900
- if (c.op === 'add' && c.kind === 'node') selectNew = true;
3060
+ // only the latest gesture waits for the host: an edit the host never answered leaves no trace
3061
+ const place = ev.hint && Number.isFinite(ev.hint.col) ? [ev.hint.col, ev.hint.row] : null, card = c.target && c.target.type === 'node' ? c.target.id : null, v = viewIn(current, cur);
3062
+ pending = (place || exact) && (card || (c.op === 'add' && c.kind === 'node')) ? { op: c.op, view: cur, card, place, exact, label: norm(c.label), ids: arrOf(v.nodeIds),
3063
+ to: c.op === 'move' && (c.to ?? null) !== boxIdOf(current, cur, card) ? c.to ?? null : undefined, base: JSON.stringify(current), at: JSON.stringify((v.layout || {})[card] ?? null) } : null;
3064
+ selectNew = c.op === 'add' && c.kind === 'node'; // the latest gesture's: an add the host never answered selects nothing later
2901
3065
  emit('change', ev);
2902
3066
  return ev;
2903
3067
  }
3068
+ function answered(p, m2, json) { // the update's model shows the edit: the card its places go to, else null
3069
+ const v = viewIn(m2, p.view), was = new Set(p.ids);
3070
+ if (p.op === 'add') return arrOf(v.nodeIds).find(id => !was.has(id) && labelIn(m2, v, id) === p.label) ?? null; // a new card with its name
3071
+ if (p.to !== undefined) return boxIdOf(m2, p.view, p.card) === p.to ? p.card : null; // into or out of a box
3072
+ return json === p.base || JSON.stringify((v.layout || {})[p.card] ?? null) !== p.at ? p.card : null; // a place alone
3073
+ }
2904
3074
  const worldOf = e => { const r = stage.getBoundingClientRect(); return cam.toWorld(e.clientX - r.left, e.clientY - r.top); };
2905
3075
  const area = f => f.w * f.h;
2906
3076
  const boxAt = (x, y) => (geo.families || []).filter(f => x >= f.x && x <= f.x + f.w && y >= f.y && y <= f.y + f.h).sort((a, b) => area(a) - area(b))[0] || null;
@@ -2945,27 +3115,34 @@ export function render(container, model, options = {}) {
2945
3115
  if (k === 'Delete' && can('delete') && sel) { change({ op: 'delete', target: ofSel(sub) ? sub : sel }); return true; }
2946
3116
  return false;
2947
3117
  }
2948
- /* a text field over the canvas: names a new card, or renames a card, line or container */
2949
- function editor(box, text, done) {
2950
- root.querySelectorAll('.um-rename').forEach(x => x.remove());
2951
- const k = cam.s.k, [W, H] = cam.size(), inp = document.createElement('input');
3118
+ /* a text field over the canvas: names a new card, or renames a card, line or container. It follows the camera, and a
3119
+ redraw of the same view keeps it over its card (a new view, or its card gone, closes it: see mount) */
3120
+ const nameBox = ref => { // where the field sits, in map units: over a card's name, a line's label (or its middle), a box's title
3121
+ if (ref.type === 'node') { const n = geo.byId[ref.id]; return n ? { x: n.x + 50, y: n.y + n.h / 2 - 13, w: n.w - 58, h: 26 } : null; }
3122
+ if (ref.type === 'edge') { const e = geo.edges[edgeIndex(ref)]; if (!e) return null; const L = e.lbl, m = e.pts[Math.floor(e.pts.length / 2)];
3123
+ return L ? { x: L.x - 8, y: L.y - 2, w: L.w + 16, h: L.h + 4 } : { x: m[0] - 60, y: m[1] - 12, w: 120, h: 24 }; }
3124
+ const f = ref.type === 'container' && (geo.families || []).find(x => x.id === ref.id); return f ? { x: f.x + 8, y: f.y + 2, w: Math.min(f.w - 16, 220), h: 24 } : null; };
3125
+ function editor(where, text, done, ref = null) { // where: a box in map units (a new card's), or ref names what the field sits over
3126
+ if (naming) naming.close(false);
3127
+ const inp = document.createElement('input');
2952
3128
  inp.className = 'um-edit um-rename'; inp.value = text; inp.setAttribute('aria-label', text ? 'New name' : 'Name of the new card');
2953
- Object.assign(inp.style, { left: (box.x - cam.s.cx) * k + W / 2 + 'px', top: (box.y - cam.s.cy) * k + H / 2 + 'px', width: Math.max(120, box.w * k) + 'px', height: Math.max(22, box.h * k) + 'px', fontSize: Math.max(11, 13 * k) + 'px' });
2954
- stage.appendChild(inp); inp.focus(); inp.select();
3129
+ const place = () => { const box = ref ? nameBox(ref) : where; if (!box) return; const k = cam.s.k, [W, H] = cam.size();
3130
+ Object.assign(inp.style, { left: (box.x - cam.s.cx) * k + W / 2 + 'px', top: (box.y - cam.s.cy) * k + H / 2 + 'px', width: Math.max(120, box.w * k) + 'px', height: Math.max(22, box.h * k) + 'px', fontSize: Math.max(11, 13 * k) + 'px' }); };
3131
+ place(); stage.appendChild(inp); inp.focus(); inp.select();
2955
3132
  // once only: removing a focused field can fire blur (cancel) in the middle of a commit
2956
3133
  let closed = false;
2957
- const finish = ok => { if (closed) return; closed = true; const v = inp.value.trim(); if (ok && v && v !== text) done(v); inp.remove(); stage.focus({ preventScroll: true }); };
3134
+ const finish = ok => { if (closed) return; closed = true; naming = null; touch(); const v = inp.value.trim(); if (ok && v && v !== text) done(v); inp.remove(); stage.focus({ preventScroll: true }); };
3135
+ naming = { ref, place, close: finish }; touch();
2958
3136
  inp.addEventListener('keydown', e => { e.stopPropagation(); if (e.key === 'Enter') { e.preventDefault(); finish(true); } else if (e.key === 'Escape') { e.preventDefault(); finish(false); } });
2959
3137
  inp.addEventListener('blur', () => finish(false));
2960
3138
  inp.addEventListener('pointerdown', e => e.stopPropagation());
2961
3139
  return inp;
2962
3140
  }
2963
3141
  function rename(ref) {
2964
- if (!can('rename') || !ref) return;
2965
- if (ref.type === 'node' && geo.byId[ref.id]) { const n = geo.byId[ref.id]; editor({ x: n.x + 50, y: n.y + n.h / 2 - 13, w: n.w - 58, h: 26 }, String(ctx.nodeIn(n.id, cur).label ?? n.label ?? ''), v => change({ op: 'rename', target: ref, label: v })); }
2966
- else if (ref.type === 'edge') { const e = geo.edges[edgeIndex(ref)]; if (!e) return; const L = e.lbl, m = e.pts[Math.floor(e.pts.length / 2)];
2967
- editor(L ? { x: L.x - 8, y: L.y - 2, w: L.w + 16, h: L.h + 4 } : { x: m[0] - 60, y: m[1] - 12, w: 120, h: 24 }, String((e.all || [e])[0].label || ''), v => change({ op: 'rename', target: ref, label: v })); }
2968
- else if (ref.type === 'container') { const f = (geo.families || []).find(x => x.id === ref.id); if (!f) return; editor({ x: f.x + 8, y: f.y + 2, w: Math.min(f.w - 16, 220), h: 24 }, String(f.label || f.id), v => change({ op: 'rename', target: ref, label: v })); }
3142
+ ref = copyRef(ref); if (!can('rename') || !ref || !nameBox(ref)) return;
3143
+ const n = ref.type === 'node' && geo.byId[ref.id], e = ref.type === 'edge' && geo.edges[edgeIndex(ref)], f = ref.type === 'container' && (geo.families || []).find(x => x.id === ref.id);
3144
+ const text = n ? String(ctx.nodeIn(n.id, cur).label ?? n.label ?? '') : e ? String((e.all || [e])[0].label || '') : String(f.label || f.id);
3145
+ editor(null, text, v => change({ op: 'rename', target: ref, label: v }), ref);
2969
3146
  }
2970
3147
  function addAt(x, y) {
2971
3148
  if (!can('add') || !geo) return;
@@ -2980,11 +3157,11 @@ export function render(container, model, options = {}) {
2980
3157
  let pressed = false;
2981
3158
  stage.addEventListener('pointerdown', () => { swallow = 0; pressed = true; }, true);
2982
3159
  ['pointerup', 'pointercancel'].forEach(t => stage.addEventListener(t, () => { pressed = false; }, true));
2983
- function focusMap(ref = sel) {
3160
+ function focusMap(ref = sel, reveal = true) {
2984
3161
  const line = ref && ref.type === 'edge' && svg && svg.querySelector(`.line-key[data-i="${edgeIndex(ref)}"]`); if (line) return line.focus({ preventScroll: true }); // a line (#43)
2985
3162
  const id = ref && (ref.type === 'node' ? ref.id : ref.node), g = id && svg && [...svg.querySelectorAll('.node')].find(x => x.dataset.id === id); // no CSS.escape: jsdom has none
2986
3163
  if (!g) return stage.focus({ preventScroll: true });
2987
- g.focus({ preventScroll: true }); const n = geo.byId[id]; if (n) cam.reveal({ x: n.x, y: n.y, w: n.w, h: n.h });
3164
+ g.focus({ preventScroll: true }); const n = geo.byId[id]; if (n && reveal) cam.reveal({ x: n.x, y: n.y, w: n.w, h: n.h });
2988
3165
  }
2989
3166
  stage.addEventListener('focus', e => { if (!pressed && sel && (sel.type === 'node' || sel.type === 'edge') && !(e.relatedTarget && root.contains(e.relatedTarget))) focusMap(); });
2990
3167
  stage.addEventListener('focusin', e => { const t = e.target; if (pressed || !cam || !t.closest || !t.closest('.node, .um-key, .family [data-badge]') || typeof t.getBBox !== 'function') return;
@@ -2993,7 +3170,7 @@ export function render(container, model, options = {}) {
2993
3170
  stage.addEventListener('pointerdown', e => {
2994
3171
  if (!E || e.button !== 0 || e.altKey || !geo) return;
2995
3172
  const hd = e.target.closest('.um-handle');
2996
- if (hd && can('add')) { e.preventDefault(); try { stage.setPointerCapture(e.pointerId); } catch (_) {} conn = { from: hd.dataset.from, path: null }; return; }
3173
+ if (hd && can('add')) { e.preventDefault(); try { stage.setPointerCapture(e.pointerId); } catch (_) {} conn = { from: hd.dataset.from, path: null }; touch(); return; }
2997
3174
  const g = e.target.closest('.node');
2998
3175
  if (g && geo.byId[g.dataset.id] && (can('move') || can('reorder'))) drag = { id: g.dataset.id, g, x0: e.clientX, y0: e.clientY, on: false, ghost: null, pid: e.pointerId };
2999
3176
  });
@@ -3007,13 +3184,15 @@ export function render(container, model, options = {}) {
3007
3184
  if (!drag) return;
3008
3185
  const k = cam.s.k, dx = (e.clientX - drag.x0) / k, dy = (e.clientY - drag.y0) / k;
3009
3186
  if (!drag.on) { if (Math.hypot(e.clientX - drag.x0, e.clientY - drag.y0) < 5) return;
3010
- drag.on = true; try { stage.setPointerCapture(drag.pid); } catch (_) {} drag.ghost = drag.g.cloneNode(true); drag.ghost.classList.add('um-ghost'); drag.ghost.classList.remove('sel'); svg.appendChild(drag.ghost); }
3187
+ drag.on = true; try { stage.setPointerCapture(drag.pid); } catch (_) {} drag.ghost = drag.g.cloneNode(true); drag.ghost.classList.add('um-ghost'); drag.ghost.classList.remove('sel'); svg.appendChild(drag.ghost); touch(); }
3011
3188
  drag.ghost.setAttribute('transform', `translate(${dx} ${dy})`);
3012
3189
  const n = geo.byId[drag.id], into = boxAt(n.x + n.w / 2 + dx, n.y + n.h / 2 + dy);
3013
3190
  svg.querySelectorAll('.family.um-target').forEach(f => f.classList.remove('um-target'));
3014
3191
  if (into) { const f = svg.querySelector(`.family[data-family="${CSS.escape(into.id)}"]`); if (f) f.classList.add('um-target'); }
3015
3192
  });
3016
3193
  const endGesture = e => {
3194
+ if (conn || drag) touch(); // the gesture ends
3195
+ if (stale) { stale = false; swallow = performance.now() + 400; return; } // the release of a gesture Escape or a new picture cancelled
3017
3196
  if (conn) { const c = conn; conn = null; if (c.path) c.path.remove(); swallow = performance.now() + 400;
3018
3197
  const g = root.elementFromPoint(e.clientX, e.clientY), t = g && g.closest && g.closest('.node');
3019
3198
  if (t && t.dataset.id !== c.from && geo.byId[t.dataset.id]) change({ op: 'add', kind: 'edge', from: c.from, to: t.dataset.id });
@@ -3037,22 +3216,21 @@ export function render(container, model, options = {}) {
3037
3216
  else d.ghost.remove();
3038
3217
  };
3039
3218
  stage.addEventListener('pointerup', endGesture);
3040
- stage.addEventListener('pointercancel', () => { if (drag && drag.ghost) drag.ghost.remove(); if (conn && conn.path) conn.path.remove(); drag = conn = null; });
3219
+ stage.addEventListener('pointercancel', () => { if (drag && drag.ghost) drag.ghost.remove(); if (conn && conn.path) conn.path.remove(); drag = conn = null; stale = false; });
3041
3220
  /* the model to draw after an update: cards keep the slots they had (the host's own layout wins), a moved or added card
3042
3221
  takes its hint; kept in this handle only, never written to the host's model */
3043
- function withPlaces(m) {
3222
+ function withPlaces(m, use = null) { // use: an edit the update applied, { view, hv: id -> its place, ex: a reordered group's places }
3044
3223
  if (options.stableLayout === false) return m;
3045
3224
  const out = structuredClone(m);
3046
3225
  (out.views || []).forEach(v => {
3047
- const was = placed[v.id], hv = hints[v.id] || {}, ex = slotsH[v.id] || {}, adds = v.id === cur ? addHints.slice() : [];
3048
- if (!was && !Object.keys(hv).length && !Object.keys(ex).length && !adds.length) return;
3226
+ const was = placed[v.id], { hv = {}, ex = {} } = use && use.view === v.id ? use : {};
3227
+ if (!was && !Object.keys(hv).length && !Object.keys(ex).length) return;
3049
3228
  const ids = (v.nodeIds || []).filter(id => typeof id === 'string'), mine = v.layout || {}, L = {}, taken = new Set(), key = p => p[0] + ',' + p[1];
3050
3229
  const put = (id, p) => { let [c, r] = p; while (taken.has(key([c, r]))) r++; L[id] = [c, r]; taken.add(key([c, r])); };
3051
3230
  ids.forEach(id => { if (Array.isArray(mine[id])) { L[id] = mine[id]; taken.add(key(mine[id])); } }); // the host's own layout
3052
3231
  ids.forEach(id => { if (!L[id] && ex[id]) put(id, ex[id]); }); // a reordered column group
3053
3232
  ids.filter(id => !L[id] && !hv[id] && was && was[id]).sort((a, b) => was[a][0] - was[b][0] || was[a][1] - was[b][1]).forEach(id => put(id, was[id])); // everyone stays
3054
- ids.forEach(id => { if (!L[id] && hv[id]) put(id, hv[id]); }); // a moved card: its drop, or the nearest free slot
3055
- ids.filter(id => !L[id] && !(was && was[id])).forEach(id => { if (adds.length) put(id, adds.shift()); }); // a new card likewise
3233
+ ids.forEach(id => { if (!L[id] && hv[id]) put(id, hv[id]); }); // a moved or added card: its drop, or the nearest free slot
3056
3234
  if (Object.keys(L).length) v.layout = L;
3057
3235
  });
3058
3236
  return out;
@@ -3085,13 +3263,15 @@ export function render(container, model, options = {}) {
3085
3263
  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
3086
3264
  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
3087
3265
  open(g.dataset.id); });
3088
- 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(); } });
3266
+ stage.addEventListener('keydown', e => { if (e.defaultPrevented || e.metaKey || e.ctrlKey || (e.altKey && !E) || e.target.closest('input')) return;
3267
+ if ((e.key === 'Enter' || e.key === ' ') && e.target.closest('button')) return; // a button of the map's own (zoom, ↑, ≋, Back) presses itself
3268
+ if (key(e)) { e.preventDefault(); e.stopPropagation(); } });
3089
3269
  stage.addEventListener('pointerover', e => { const g = e.target.closest('.node'); if (!g) return; svg.querySelectorAll('.edge-g').forEach(x => x.classList.toggle('hov', x.dataset.from === g.dataset.id || x.dataset.to === g.dataset.id)); });
3090
3270
  stage.addEventListener('pointerout', e => { if (e.target.closest('.node')) svg.querySelectorAll('.edge-g.hov').forEach(x => x.classList.remove('hov')); });
3091
3271
  const ctl = q('.ctl');
3092
- if (ctl) ctl.addEventListener('click', e => { const z = e.target.closest('button')?.dataset.z; if (!z) return;
3272
+ if (ctl) ctl.addEventListener('click', e => { const z = e.target.closest('button')?.dataset.z; if (!z) return; readerMoved(); // a press by key too: a story playing waits
3093
3273
  if (z === 'in') cam.zoomAt(1.4, null, null, true); else if (z === 'out') cam.zoomAt(1 / 1.4, null, null, true); else if (z === 'fit') cam.fit(); else if (z === 'one') cam.actual(); else if (z === 'up') up(); else if (z === 'flow') setFlow(!stage.classList.contains('flowing')); else if (z === 'add') addAt(cam.s.cx, cam.s.cy); });
3094
- setFlow(options.flow ?? !(typeof matchMedia === 'function' && matchMedia('(prefers-reduced-motion: reduce)').matches));
3274
+ setFlow(options.flow ?? !reducedMotion(), false); // the default, or the host's: not saved as the page reader's choice
3095
3275
 
3096
3276
  // the stage changes size with its container: keep the zoom, refit only an untouched view (no ResizeObserver, as in
3097
3277
  // jsdom: the view is fitted once)
@@ -3103,7 +3283,7 @@ export function render(container, model, options = {}) {
3103
3283
  selection, hover, flow animation or edit handles, whatever the screen shows; the screen is left as it was. */
3104
3284
  function standalone(scale = 1, vid = cur, fontCSS = '') {
3105
3285
  if (settle) settle(); // an export right after update() draws where things end up, not mid-ease
3106
- if (!svg || !geo) return '';
3286
+ if (!svg || !geo || vid == null) return ''; // a model with no views draws nothing
3107
3287
  let s = svg, g = geo, off = null;
3108
3288
  if (vid !== cur) { // another view: drawn off screen, inside the shadow root so the same styles apply
3109
3289
  const v = ctx.V[vid]; if (!v || !(v.nodeIds || []).some(i => ctx.N[i])) return '';
@@ -3112,7 +3292,7 @@ export function render(container, model, options = {}) {
3112
3292
  off.innerHTML = c.markup; stage.parentNode.appendChild(off); s = off.querySelector('svg'); s.classList.add('map-svg'); g = c.g;
3113
3293
  }
3114
3294
  if (off) { const mine = layers.get('lens'), L = mine && arrOf(ctx.D.lenses).find(x => x && x.id === lensId); // the host's marks, on another view too (#63), and the lens as it lights that view
3115
- if (L) layers.set('lens', lensOf(ctx, vid, L).layer); drawMarks(s, g); if (L) layers.set('lens', mine); }
3295
+ if (L) layers.set('lens', lensOf(ctx, vid, L).layer); drawMarks(s, g, vid); if (L) layers.set('lens', mine); }
3116
3296
  const s0 = sel, flowing = stage.classList.contains('flowing'), hov = [...svg.querySelectorAll('.hov')];
3117
3297
  sel = null; paint(); stage.classList.remove('flowing'); hov.forEach(x => x.classList.remove('hov')); stage.classList.add('um-still');
3118
3298
  const clone = s.cloneNode(true), b = g.bounds;
@@ -3141,7 +3321,7 @@ export function render(container, model, options = {}) {
3141
3321
  let settle = null; // ends an ease still running (before an export, or the next update)
3142
3322
  function easeIn(oldGeo, oldSvg) {
3143
3323
  if (settle) settle();
3144
- if (options.ease === false || reducedMotion() || !oldGeo || !oldSvg || !geo || !svg || oldSvg === svg) return;
3324
+ if (options.ease === false || still() || !oldGeo || !oldSvg || !geo || !svg || oldSvg === svg) return;
3145
3325
  const T = 600, moves = [], key = e => edgeRefIds(e).join(' ') || e.from + '>' + e.to, all = (s, q) => [...s.querySelectorAll(q)];
3146
3326
  const was = new Map(oldGeo.nodes.map(n => [n.id, n])), now = new Set(geo.nodes.map(n => n.id));
3147
3327
  const lines = new Map(oldGeo.edges.map((e, i) => [key(e), [e, i]])), kept = new Set(geo.edges.map(key));
@@ -3153,7 +3333,7 @@ export function render(container, model, options = {}) {
3153
3333
  const gone = document.createElementNS('http://www.w3.org/2000/svg', 'g');
3154
3334
  gone.setAttribute('class', 'um-leaving'); gone.setAttribute('aria-hidden', 'true'); gone.style.pointerEvents = 'none';
3155
3335
  const leave = x => { const c = x.cloneNode(true);
3156
- [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'); });
3336
+ [c, ...c.querySelectorAll('*')].forEach(y => { ['tabindex', 'role', 'data-id', 'data-i', 'data-from', 'data-to', 'data-o', 'data-t', 'data-chip', 'data-badge', 'data-edges'].forEach(a => y.removeAttribute(a)); y.classList.remove('node', 'edge-g', 'e-lab', 'sel', 'hot', 'hov'); }); // a port's data-edges named lines the new picture may lack
3157
3337
  gone.appendChild(c); };
3158
3338
  oldGeo.nodes.forEach(n => { if (!now.has(n.id)) all(oldSvg, `.node[data-id="${CSS.escape(n.id)}"]`).forEach(leave); });
3159
3339
  lines.forEach(([, i], k) => { if (!kept.has(k)) all(oldSvg, `.edge-g[data-i="${i}"], .e-lab[data-i="${i}"]`).forEach(leave); });
@@ -3169,37 +3349,63 @@ export function render(container, model, options = {}) {
3169
3349
 
3170
3350
  function update(model2, relayout = false, unfolding = false) { // unfolding: the same model, a card unfolded or folded
3171
3351
  if (dead) return;
3172
- // an identical model: nothing to redraw, unless the host accepted a move or reorder that only changed a position
3173
- if (!relayout && !unfolding && !Object.keys(hints).length && !Object.keys(slotsH).length && JSON.stringify(model2) === JSON.stringify(current)) { addHints.length = 0; return; }
3174
- const old = ctx, keys = {}, before = new Set(geo ? geo.nodes.map(n => n.id) : []), wantNew = selectNew;
3352
+ if (!model2 || typeof model2 !== 'object' || Array.isArray(model2)) throw new TypeError(`umlimator: update(model) takes a map model, an object with nodes and views; it got ${model2 === null ? 'null' : Array.isArray(model2) ? 'an array' : typeof model2}`);
3353
+ // an identical model: nothing to redraw, unless it answers an edit (one that changed only a place)
3354
+ const json = JSON.stringify(model2), same = json === JSON.stringify(current), own = relayout || unfolding; // own: the map drawing its own model again, no answer to an edit
3355
+ if (!own && !pending && same) return;
3356
+ // the model before or after a move an update applied (undo, redo): the cards go back where they were then
3357
+ const back = !own && !same && [...moves].reverse().find(x => x.base === json || x.after === json);
3358
+ if (back) placed[back.view] = structuredClone(back.base === json ? back.before : back.then);
3359
+ // the edit waiting for this answer: its places if the model shows it, else none (it is dropped either way)
3360
+ const p = own ? null : pending, card = p && answered(p, model2, json);
3361
+ if (!own) pending = null;
3362
+ const use = card ? { view: p.view, hv: p.place ? { [card]: p.place } : {}, ex: p.exact || {} } : null, from = use && structuredClone(placed[p.view] || null);
3363
+ // a card E or Alt + click asked to unfold before its view was loaded unfolds once a model brings that view (#71)
3364
+ const brought = []; Object.entries(asked).forEach(([vid, m]) => m.forEach((c, nid) => { if (!arrOf(model2.views).some(v => v && v.id === c)) return;
3365
+ m.delete(nid); const l = expanded[vid] ||= []; if (!l.includes(nid)) { l.push(nid); if (vid === cur) brought.push(nid); } }));
3366
+ const old = ctx, keys = {}, wantNew = selectNew && !own;
3175
3367
  old.views.forEach(v => { keys[v.id] = relayout ? null : old.viewKey(v.id); });
3176
3368
  current = model2;
3177
- ctx = mapContext(withPlaces(model2), { measure, kinds: !!P.kinds, prev: options.stableLayout === false ? null : old.planCache, expand: expanded });
3369
+ ctx = mapContext(withPlaces(model2, use), { measure, kinds: !!P.kinds, prev: options.stableLayout === false ? null : old.planCache, expand: expanded });
3178
3370
  Object.keys(expanded).forEach(v => { expanded[v] = ctx.unfolded[v] || []; }); // a card or inner view the model no longer has folds
3179
- [hints, slotsH].forEach(o => Object.keys(o).forEach(k => delete o[k])); addHints.length = 0; selectNew = false;
3371
+ if (!own) selectNew = false;
3180
3372
  ctx.views.forEach(v => { const id = v.id; if (keys[id] == null || keys[id] !== ctx.viewKey(id)) return;
3181
3373
  if (id in old.planCache) ctx.planCache[id] = old.planCache[id];
3182
3374
  if (old.layoutCache[id]) ctx.layoutCache[id] = old.layoutCache[id];
3183
3375
  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)
3184
- if (!cur) return;
3185
- if (!ctx.V[cur]) { const from = cur; mount(ctx.rootId); cam.set(startView()); cam.touched = false; emit('viewchange', { view: cur, from }); return; }
3376
+ restory(); // the story shown takes this model's steps, or stops when the model has it no more
3377
+ if (!cur) { // nothing shown yet (a first model with no views): this model's first view is drawn
3378
+ const v0 = ctx.V[options.view] ? options.view : ctx.rootId;
3379
+ if (v0 && cam) { mount(v0); cam.set(startView()); cam.touched = false; emitSel(); emit('viewchange', { view: cur, from: null }); }
3380
+ return; }
3381
+ if (!ctx.V[cur]) { // the view shown is gone: the root, or the empty card of a model with no views
3382
+ const from = cur; mount(ctx.rootId ?? null); cam.set(startView()); cam.touched = false; emitSel(); if (cur) emit('viewchange', { view: cur, from }); return; }
3186
3383
  if (ctx.geoCache[cur] && keys[cur] === ctx.viewKey(cur)) { if (lensId != null) { relens(); drawMarks(); } if (pc) pc.chrome(); return; } // this view's picture did not change (its lens or the views may have)
3187
3384
  const keep = sel, keepMore = more, touched = cam.touched, prev = [geo, svg];
3188
3385
  mount(cur);
3189
3386
  if (touched) cam.set({}); else cam.set(startView());
3190
3387
  cam.touched = touched;
3191
- const fresh = geo.nodes.filter(n => !before.has(n.id));
3192
- if (wantNew && fresh.length === 1) select(nodeRef(fresh[0].id), { reveal: false });
3193
- else if (keep && exists(keep)) { sel = keep; more = keep.type === 'node' ? keepMore.filter(id => geo.byId[id]) : []; paint(); emitSel(); }
3388
+ // ponytail: the models' JSON, kept for 20 moves; a hash of them if models grow large enough for that to weigh
3389
+ if (use && p.view === cur && json !== p.base) { moves.push({ view: cur, base: p.base, after: json, before: from, then: structuredClone(placed[cur] || null) }); if (moves.length > 20) moves.shift(); }
3390
+ const box = brought.find(nid => (expanded[cur] || []).includes(nid));
3391
+ if (box) unfolded(box, true); // a card asked to unfold earlier: its box is selected
3392
+ else if (wantNew && use && p.op === 'add' && geo.byId[card]) select(nodeRef(card), { reveal: false }); // the card added, as the reader named it
3393
+ else { // the selection that is still drawn stays, and a select event comes only if it changed (unfolding: the caller says)
3394
+ if (keep && exists(keep)) { sel = keep; more = keep.type === 'node' ? keepMore.filter(id => geo.byId[id]) : []; paint(); }
3395
+ if (!unfolding) emitSel(); }
3194
3396
  if (!relayout) easeIn(...prev); // a font that came in late moves nothing on purpose: no ease
3397
+ touch();
3195
3398
  }
3196
3399
 
3197
3400
  function destroy() {
3198
3401
  if (dead) return;
3402
+ cancelGesture();
3199
3403
  dead = true; if (ro) ro.disconnect(); cancelAnimationFrame(rz); stopStory();
3200
- timers.forEach(clearTimeout); timers.clear();
3404
+ answer(false); endTrip(); // every promise settles: a move's setView() false, a tour's and a replay's done false
3405
+ timers.forEach(clearTimeout); timers.clear(); sleeping.forEach(wake => wake());
3201
3406
  if (cam) cam.stopAnimation();
3202
3407
  host.remove();
3408
+ clearTimeout(restT); camRest = camNow(); flushState(true); // a state listener hears destroyed: true, last, and where the camera stopped
3203
3409
  Object.keys(subs).forEach(k => delete subs[k]);
3204
3410
  }
3205
3411
 
@@ -3208,29 +3414,39 @@ export function render(container, model, options = {}) {
3208
3414
  Each mount draws them again, so they survive update() and setView(); a card or line the view doesn't show is ignored.
3209
3415
  toSVG() and toPNG() draw them too, at rest: they are the host's, like the model (the selection and hover are not). */
3210
3416
  const layers = new Map(), DIM = .2, clamp01 = x => Math.min(1, Math.max(0, +x || 0));
3417
+ const scopes = new Map(); // layer name -> the views it is about: a highlight's Refs name theirs, and it lights those only
3211
3418
  // a drawn line that stands for several edges takes the strongest of their marks: a pulsing one, then the most lit, then the fastest
3212
3419
  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];
3213
- 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
3420
+ function markOf(get, here, dims) { // one card's or line's mark over the layers here: each property from the latest that sets it, the lowest level
3214
3421
  let out = null;
3215
- layers.forEach(L => { const m = get(L); if (!m && !L.dim) return; out ||= { level: 1 };
3422
+ here.forEach(L => { const m = get(L); if (!m && !dims.has(L)) return; out ||= { level: 1 };
3216
3423
  if (m) for (const k in m) if (m[k] != null && k !== 'level') out[k] = m[k];
3217
3424
  out.level = Math.min(out.level, m ? clamp01(m.level ?? 1) : DIM); });
3218
3425
  return out;
3219
3426
  }
3220
3427
  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]]);
3221
- function drawMarks(s = svg, g = geo) {
3428
+ function drawMarks(s = svg, g = geo, vid = cur) {
3222
3429
  if (!s || !g) return;
3223
3430
  s.querySelectorAll('.um-rings, .um-marks, .um-glow').forEach(x => x.remove());
3224
3431
  s.querySelectorAll('.node[opacity], .edge-g[opacity], .e-lab[opacity]').forEach(x => x.removeAttribute('opacity'));
3225
3432
  s.querySelectorAll('.edge[style], .flowline[style]').forEach(x => x.removeAttribute('style'));
3226
3433
  s.querySelectorAll('.node[aria-description]').forEach(x => x.removeAttribute('aria-description'));
3227
3434
  s.querySelectorAll('[data-was]').forEach(x => { if (x.tagName === 'title') x.textContent = x.getAttribute('data-was'); else x.setAttribute('aria-label', x.getAttribute('data-was')); x.removeAttribute('data-was'); });
3228
- if (s === svg) stage.classList.toggle('um-lit', [...layers.values()].some(L => L.dim));
3229
- if (!layers.size) return;
3435
+ // the layers about this view; one that dims dims only where it names something: on a view it names nothing of, nothing fades
3436
+ const named = [...layers].filter(([n]) => !scopes.has(n) || scopes.get(n).includes(vid)), here = named.map(([, L]) => L);
3437
+ const names = L => g.nodes.some(n => (L.nodes || {})[n.id]) || g.edges.some(e => edgeRefIds(e).some(x => (L.edges || {})[x]));
3438
+ let dims = new Set(here.filter(L => L.dim && names(L)));
3439
+ // a story's step dims alone (0.14.0): a layer that dimmed before the step keeps its marks but not its dimming, and one set
3440
+ // while the step shows dims in its place, so a step looks as it was told and what the reader lights later still shows
3441
+ const sl = layers.get('story'), at = n => dimAt.get(n) || 0;
3442
+ if (dims.has(sl)) dims = named.some(([n, L]) => L !== sl && dims.has(L) && at(n) > at('story')) ? new Set([...dims].filter(L => L !== sl)) : new Set([sl]);
3443
+ const markOf2 = get => markOf(get, here, dims);
3444
+ if (s === svg) stage.classList.toggle('um-lit', dims.size > 0);
3445
+ if (!here.length) return;
3230
3446
  const tone = t => 'var(--um-' + String(t || 'accent').replace(/[^\w-]/g, '') + ')', op = m => m.level < 1 ? ` opacity="${m.level}"` : '';
3231
3447
  const cards = Object.fromEntries([...s.querySelectorAll('.node')].map(x => [x.dataset.id, x]));
3232
3448
  let under = '', over = '';
3233
- g.nodes.forEach(n => { const m = markOf(L => (L.nodes || {})[n.id]), el = cards[n.id]; if (!m || !el) return;
3449
+ g.nodes.forEach(n => { const m = markOf2(L => (L.nodes || {})[n.id]), el = cards[n.id]; if (!m || !el) return;
3234
3450
  if (m.level < 1) el.setAttribute('opacity', m.level);
3235
3451
  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>`;
3236
3452
  const b = m.badge == null ? null : badgesOf({ badges: [m.badge] })[0]; if (!b) return; // a tab after the card's own
@@ -3240,7 +3456,7 @@ export function render(container, model, options = {}) {
3240
3456
  const heads = (g.families || []).flatMap(f => [{ x: f.x + 8, y: f.y + 3, w: String(f.label || f.id).length * 6.6 + 8, h: 22 }, // a box's title, 11 px mono
3241
3457
  ...(f.tabs || []).map(t => ({ x: t.x, y: f.y - 8, w: t.w, h: 16 }))]); // and its tabs
3242
3458
  const pills = [], free = r => !g.nodes.some(n => hit(r, n, 8) || (n.tag && hit(r, n.tag, 2))) && !heads.some(h => hit(r, h, 2)) && !g.edges.some(o => o.lbl && hit(r, o.lbl, 2)) && !pills.some(p => hit(r, p, 2));
3243
- 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;
3459
+ g.edges.forEach((e, i) => { const m = markOf2(L => strongest(edgeRefIds(e).map(x => (L.edges || {})[x]))), eg = s.querySelector(`.edge-g[data-i="${i}"]`); if (!m || !eg) return;
3244
3460
  const path = eg.querySelector('.edge'), flow = eg.querySelector('.flowline');
3245
3461
  if (m.level < 1) [eg, s.querySelector(`.e-lab[data-i="${i}"]`)].forEach(x => x && x.setAttribute('opacity', m.level));
3246
3462
  if (m.tone || m.pulse) path.insertAdjacentHTML('beforebegin', `<path d="${path.getAttribute('d')}" class="um-glow${m.pulse ? ' um-pulse' : ''}" stroke="${tone(m.tone)}"/>`);
@@ -3264,19 +3480,33 @@ export function render(container, model, options = {}) {
3264
3480
  if (under && cardsG) cardsG.insertAdjacentHTML('beforebegin', `<g class="um-rings" aria-hidden="true">${under}</g>`);
3265
3481
  if (over) s.insertAdjacentHTML('beforeend', `<g class="um-marks" aria-hidden="true">${over}</g>`);
3266
3482
  }
3267
- function overlay(name, layer) {
3483
+ const dimAt = new Map(); let dimClock = 0; // layer name -> when it was last set (a story's step dims alone over older layers, see drawMarks)
3484
+ function overlay(name, layer, views = null) { // views: the views the layer is about (highlight()'s); none: every view
3268
3485
  if (dead || name == null) return;
3269
- if (layer == null) layers.delete(String(name)); else layers.set(String(name), layer); // a Map keeps the order first set
3270
- drawMarks();
3486
+ const n = String(name);
3487
+ if (layer == null) { layers.delete(n); dimAt.delete(n); } else { layers.set(n, layer); dimAt.set(n, ++dimClock); } // a Map keeps the order first set
3488
+ if (layer != null && views) scopes.set(n, views); else scopes.delete(n);
3489
+ drawMarks(); touch();
3271
3490
  }
3272
- /* the 'highlight' layer: these cards and lines lit, the rest dimmed; a Ref's level (0 to 1), else its distance, fades it */
3273
- function highlight(refs) {
3274
- if (refs == null) return overlay('highlight', null);
3491
+ // the cards in a box, at any depth, on a view (an unfolded card's box too)
3492
+ const boxCards = (vid, id) => { const bs = ((ctx.V[vid] || {}).containers || []).filter(Boolean), out = new Set(), seen = new Set();
3493
+ const walk = b => { if (seen.has(b)) return; seen.add(b); const c = bs.find(x => x.id === b); if (!c) return; (c.nodeIds || []).forEach(i => out.add(i)); bs.filter(x => x.parent === b).forEach(x => walk(x.id)); };
3494
+ walk(id); return out; };
3495
+ /* the 'highlight' layer (or another, `name`: a story step's 'story', the MCP page's 'mcp'): these cards and lines lit, the
3496
+ rest dimmed; a Ref's level (0 to 1), else its distance, fades it. A box's Ref lights its cards and the lines between
3497
+ them; Refs that name their view light that view only */
3498
+ function highlight(refs, name = 'highlight') {
3499
+ if (refs == null) return overlay(name, null);
3275
3500
  const L = { nodes: {}, edges: {}, dim: true }, lv = r => clamp01(r.level ?? (r.distance > 0 ? Math.max(.45, 1 - .15 * r.distance) : 1));
3276
- [].concat(refs).forEach(r => { if (!r) return; const id = r.type === 'node' ? r.id : r.node; // a chip or badge: its card
3277
- if (id != null) L.nodes[id] = { level: Math.max(lv(r), (L.nodes[id] || {}).level || 0) };
3278
- else if (r.type === 'edge') (r.ids || []).forEach(x => { L.edges[x] = { level: Math.max(lv(r), (L.edges[x] || {}).level || 0) }; }); });
3279
- overlay('highlight', L);
3501
+ const lit = (o, x, r) => { o[x] = { level: Math.max(lv(r), (o[x] || {}).level || 0) }; }, list = [].concat(refs).filter(Boolean);
3502
+ list.forEach(r => { const id = r.type === 'node' ? r.id : r.node, box = r.type === 'container' ? r.id : r.container; // a chip or badge: its card, or its box
3503
+ if (id != null) lit(L.nodes, id, r);
3504
+ else if (r.type === 'edge') (r.ids || []).forEach(x => lit(L.edges, x, r));
3505
+ else if (box != null) { const vid = r.view ?? cur, ins = boxCards(vid, box), at = x => ins.has(x) || x === box;
3506
+ ins.forEach(x => lit(L.nodes, x, r));
3507
+ if (ctx.V[vid]) ctx.viewEdges(ctx.V[vid]).forEach(e => { if (at(e.from) && at(e.to) && e._id) lit(L.edges, e._id, r); }); } });
3508
+ const views = list.every(r => typeof r.view === 'string') ? [...new Set(list.map(r => r.view))] : null;
3509
+ overlay(name, L, views);
3280
3510
  }
3281
3511
  /* lenses (#94): one of the model's saved ways to look at the map, as the 'lens' layer, on every view it is shown on; the
3282
3512
  view is not laid out again. A lens that colours cards by owner puts their tones in a legend over the controls. */
@@ -3284,7 +3514,7 @@ export function render(container, model, options = {}) {
3284
3514
  function relens() {
3285
3515
  const L = lensId == null ? null : arrOf(ctx.D.lenses).find(x => x && x.id === lensId), was = q('.um-lens');
3286
3516
  if (was) { was.remove(); clear = null; }
3287
- if (!L) { lensId = null; layers.delete('lens'); return; } // off, or the model no longer has it
3517
+ if (!L) { const had = lensId != null; lensId = null; layers.delete('lens'); if (had) emit('lens', { lens: null }); return; } // off, or the model no longer has it: the lens event says so
3288
3518
  if (!cur || !ctx.V[cur]) return;
3289
3519
  const { layer, owners } = lensOf(ctx, cur, L), name = esc(L.title || L.id);
3290
3520
  layers.set('lens', layer);
@@ -3292,18 +3522,25 @@ export function render(container, model, options = {}) {
3292
3522
  }
3293
3523
  function setLens(id) { // true when the model has that lens (or id is null)
3294
3524
  if (dead || (id != null && !arrOf(ctx.D.lenses).some(x => x && x.id === id))) return false;
3295
- lensId = id ?? null; relens(); drawMarks();
3525
+ const was = lensId; lensId = id ?? null; if (lensId != null) dimAt.set('lens', ++dimClock); relens(); drawMarks(); if (lensId !== was) emit('lens', { lens: lensId });
3296
3526
  if (cam && geo && !cam.touched) cam.set(startView()); // fitted: the cards keep clear of the legend, as of the controls
3297
3527
  return true;
3298
3528
  }
3299
3529
  /* 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
3300
3530
  view (setView, an update that redraws it) ends it, as does destroy() */
3301
3531
  let trip = null;
3302
- function endTrip(played = false) { const t = trip; if (!t) return; trip = null; cancelAnimationFrame(t.raf); if (t.dot) t.dot.remove(); t.end(played); }
3532
+ // a tour's or a replay's dots: over the lines and boxes, under the cards (as the model's own tour), so a dot goes into
3533
+ // a card and out at its next line rather than across the card's name
3534
+ function tripLayer() {
3535
+ const cards = svg.querySelector(':scope > .nodes');
3536
+ (cards || svg).insertAdjacentHTML(cards ? 'beforebegin' : 'beforeend', '<g class="um-trip" aria-hidden="true"></g>');
3537
+ return cards ? cards.previousElementSibling : svg.lastElementChild;
3538
+ }
3539
+ function endTrip(played = false) { const t = trip; if (!t) return; trip = null; cancelAnimationFrame(t.raf); if (t.dot) t.dot.remove(); t.end(played); touch(); }
3303
3540
  function startTrip(step, end) { // step(ms played, pauses left out) says whether to go on
3304
3541
  endTrip();
3305
3542
  const t = { raf: 0, dot: null, paused: false, at: 0, last: performance.now(), end }; trip = t;
3306
- const frame = now => { if (trip !== t) return; if (dead) return endTrip(); if (!t.paused) t.at += now - t.last; t.last = now;
3543
+ const frame = now => { if (trip !== t) return; if (dead) return endTrip(); if (!t.paused && !paused && !(t.story && story && story.held)) t.at += now - t.last; t.last = now; // paused: the page's Pause; held: a story's step's tour, by its own pause
3307
3544
  if (step(t.at)) t.raf = requestAnimationFrame(frame); else endTrip(true); };
3308
3545
  t.raf = requestAnimationFrame(frame);
3309
3546
  return t;
@@ -3315,32 +3552,48 @@ export function render(container, model, options = {}) {
3315
3552
  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]; };
3316
3553
  /* tour(path): the dot rides the line between each pair of cards in turn, through each card to its next line, and each
3317
3554
  card it reaches flashes; where no line joins two cards it skips to the next. Resolves true when it reaches the end. */
3318
- function tour(path, o = {}) {
3555
+ function tour(path, o = {}, quiet = false) { // quiet: a story's step, whose caption says it
3319
3556
  endTrip();
3320
3557
  if (dead || !geo || path == null) return Promise.resolve(false);
3321
3558
  const ids = [].concat(path).map(String).filter(id => geo.byId[id]), legs = [];
3322
3559
  for (let k = 0; k + 1 < ids.length; k++) { const r = lineBetween(ids[k], ids[k + 1]); if (!r) continue;
3323
3560
  const prev = legs[legs.length - 1], n = geo.byId[ids[k]];
3324
- 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;
3325
- legs.push({ pts, to: ids[k + 1], len: lenOf(pts) }); }
3326
- if (!legs.length || (reducedMotion() && P.tourAt == null)) return Promise.resolve(legs.length > 0);
3561
+ const through = prev && prev.to === ids[k] ? [prev.pts[prev.pts.length - 1], [n.x + n.w / 2, n.y + n.h / 2]] : [], pts = [...through, ...r.pts];
3562
+ // skip: the part through the card, where the dot rides under it and can't be seen
3563
+ legs.push({ pts, to: ids[k + 1], len: lenOf(pts), skip: through.length ? lenOf([...through, r.pts[0]]) : 0, from: ids[k], i: r.i }); }
3564
+ if (legs.length && still() && P.tourAt == null) atRest(legs, quiet);
3565
+ if (!legs.length || (still() && P.tourAt == null)) return Promise.resolve(legs.length > 0);
3327
3566
  const speed = Math.max(20, +o.speed || 240); // map units a second
3328
- svg.insertAdjacentHTML('beforeend', '<g class="um-trip" aria-hidden="true"><circle r="5.5" class="tour-dot"/></g>');
3329
- const g = svg.lastElementChild, dot = g.firstElementChild; let leg = 0, base = 0;
3567
+ const g = tripLayer(); g.insertAdjacentHTML('beforeend', '<circle r="5.5" class="tour-dot"/>');
3568
+ const dot = g.firstElementChild; let leg = 0, base = 0;
3330
3569
  if (P.tourAt != null) { // the page's #shot=1&at=<0 to 1>: the dot held that far along the ride, a frame for umlimator.py record (#83)
3331
- let d = clamp01(P.tourAt) * legs.reduce((s, l) => s + l.len, 0); while (leg < legs.length - 1 && d > legs[leg].len) d -= legs[leg++].len;
3332
- const [x, y] = along(legs[leg].pts, d); dot.setAttribute('cx', x); dot.setAttribute('cy', y);
3570
+ // spread over the lines only: a frame inside a card would show no dot (it rides under the cards)
3571
+ const seen = l => l.len - l.skip;
3572
+ let d = clamp01(P.tourAt) * legs.reduce((s, l) => s + seen(l), 0); while (leg < legs.length - 1 && d > seen(legs[leg])) d -= seen(legs[leg++]);
3573
+ const [x, y] = along(legs[leg].pts, legs[leg].skip + d); dot.setAttribute('cx', x); dot.setAttribute('cy', y);
3333
3574
  trip = { raf: 0, dot: g, end() {} }; return Promise.resolve(true);
3334
3575
  }
3335
3576
  return new Promise(res => { startTrip(ms => { const d = ms * speed / 1000;
3336
3577
  while (leg < legs.length && d - base >= legs[leg].len) { base += legs[leg].len; flash(legs[leg].to); leg++; }
3337
3578
  if (leg >= legs.length) return false;
3338
- const [x, y] = along(legs[leg].pts, d - base); dot.setAttribute('cx', x); dot.setAttribute('cy', y); return true; }, res).dot = g; });
3579
+ const [x, y] = along(legs[leg].pts, d - base); dot.setAttribute('cx', x); dot.setAttribute('cy', y); return true; }, res); trip.dot = g; trip.kind = 'tour'; trip.story = quiet; touch(); });
3580
+ }
3581
+ // under reduced motion no dot rides: the path is lit at rest instead, its cards ringed and numbered in turn and its lines
3582
+ // glowing (the 'tour' layer, which nothing dims), and the live region says it; it stays until the trip slot is taken
3583
+ function atRest(legs, quiet) {
3584
+ const L = { nodes: {}, edges: {} }, order = [];
3585
+ legs.forEach(l => { [l.from, l.to].forEach(id => { if (!L.nodes[id]) { order.push(id); L.nodes[id] = { tone: 'accent', badge: String(order.length) }; } });
3586
+ edgeRefIds(geo.edges[l.i]).forEach(x => { L.edges[x] = { tone: 'accent' }; }); });
3587
+ overlay('tour', L); trip = { kind: 'path', raf: 0, dot: null, end() { overlay('tour', null); } };
3588
+ if (!quiet) say('Tour: ' + order.map(nameOf).join(' → '));
3339
3589
  }
3340
3590
  /* replay(spans): each call from one card to another (a span and its parent's) runs along their line at the trace's relative
3341
3591
  time, the whole trace in `duration` ms; the line glows while the call lasts and keeps its duration as a label */
3592
+ let replays = 0; // the newest replay's number: an older one's stop() leaves the newer one's marks alone
3342
3593
  function replay(spans, o = {}) {
3594
+ if (dead) return { hops: 0, skipped: 0, done: Promise.resolve(false), pause() {}, resume() {}, stop() {} };
3343
3595
  endTrip(); overlay('replay', null);
3596
+ const gen = ++replays;
3344
3597
  const list = [].concat(spans || []).filter(s => s && Number.isFinite(+s.start) && Number.isFinite(+s.end));
3345
3598
  const byId = new Map(list.map(s => [String(s.id), s])), cardOf = cardMatcher(geo ? geo.nodes : [], o.match);
3346
3599
  let skipped = 0, t = null, done; const hops = [];
@@ -3350,30 +3603,36 @@ export function render(container, model, options = {}) {
3350
3603
  const r = lineBetween(from, to); if (!r) return skipped++; // a call the view draws no line for
3351
3604
  hops.push({ ...r, ids: edgeRefIds(geo.edges[r.i]), at: +s.start, dur: +s.end - +s.start, to, len: lenOf(r.pts) }); });
3352
3605
  const ctl = { hops: hops.length, skipped, done: new Promise(r => { done = r; }),
3353
- pause() { if (t && trip === t) t.paused = true; }, resume() { if (t && trip === t) t.paused = false; },
3354
- stop() { if (t && trip === t) endTrip(); else overlay('replay', null); } };
3606
+ pause() { if (t && trip === t) { t.paused = true; touch(); } }, resume() { if (t && trip === t) { t.paused = false; touch(); } },
3607
+ stop() { if (t && trip === t) endTrip(); else if (gen === replays) overlay('replay', null); } };
3355
3608
  const ms = msText, marks = {};
3356
3609
  // ponytail: two calls at once on one line share its mark; the one that ends first takes the glow off
3357
3610
  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 } }); };
3358
- if (!hops.length || reducedMotion()) { hops.forEach(h => show(h, false)); done(hops.length > 0); return ctl; }
3611
+ if (!hops.length || still()) { hops.forEach(h => show(h, false)); done(hops.length > 0); return ctl; }
3359
3612
  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);
3360
3613
  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)); });
3361
3614
  const total = Math.max(...hops.map(h => Math.max(h.end, h.start + h.travel)));
3362
- svg.insertAdjacentHTML('beforeend', '<g class="um-trip" aria-hidden="true"></g>'); const g = svg.lastElementChild;
3615
+ const g = tripLayer();
3363
3616
  t = startTrip(now => { hops.forEach(h => {
3364
3617
  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; }
3365
3618
  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); } }
3366
3619
  if (h.on && !h.off && now >= h.end) { h.off = true; show(h, false); } });
3367
3620
  return now < total; }, played => { if (!played) overlay('replay', null); done(played); });
3368
- t.dot = g;
3621
+ t.dot = g; t.kind = 'replay'; touch();
3369
3622
  return ctl;
3370
3623
  }
3371
3624
 
3372
3625
  /* ---------- stories (#69): the model's narrated walk-throughs, played step by step ----------
3373
3626
  A step sets the whole picture: its view, camera, selection, highlight and caption (what it leaves out is cleared),
3374
3627
  then plays its tour. Playing goes on by itself after each step's duration; the caption bar and the keys (← → PageUp
3375
- PageDown, Esc) step through and stop it. One story at a time. */
3628
+ PageDown, Esc) step through and stop it. One story at a time.
3629
+ The player's state (0.14.0): entering a step (its view, camera, selection and highlight on their way), then playing or
3630
+ paused on it, until ended or stopped. A story event comes on each of these changes. The reader taking the map (a press,
3631
+ a key, a view or a selection the story didn't make) pauses it; a step lights its own layer, 'story'. */
3376
3632
  let story = null;
3633
+ const stepsOf = s => s && Array.isArray(s.steps) ? s.steps.filter(x => x && typeof x === 'object') : [];
3634
+ // the story event, on every change: a step entered, a pause or a resume (playing), the end (step null), destroy() too
3635
+ const storyEvent = st => emit('story', { story: st.s.id, step: story === st ? st.at : null, steps: st.steps.length, playing: story === st && !!st.auto }, dead);
3377
3636
  // 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
3378
3637
  // object is a Ref already
3379
3638
  const stepRef = x => typeof x !== 'string' ? (x && typeof x === 'object' ? x : null) : geo.byId[x] ? nodeRef(x)
@@ -3397,35 +3656,53 @@ export function render(container, model, options = {}) {
3397
3656
  bar.setAttribute('aria-label', 'Story: ' + (st.s.title || st.s.id));
3398
3657
  bar.querySelector('.um-st').textContent = `${st.s.title || st.s.id} · ${k + 1} / ${n}`;
3399
3658
  bar.querySelector('p').textContent = (st.steps[k] || {}).caption || '';
3400
- b('prev').disabled = k <= 0;
3659
+ if (k <= 0) b('prev').setAttribute('aria-disabled', 'true'); else b('prev').removeAttribute('aria-disabled'); // off, and focusable still: a focused ‹ keeps the focus
3401
3660
  b('play').textContent = st.auto ? '❚❚' : '▶'; b('play').setAttribute('aria-label', st.auto ? 'Pause' : 'Play'); b('play').title = st.auto ? 'Pause' : 'Play';
3402
3661
  b('next').textContent = k + 1 < n ? '›' : '✓'; b('next').setAttribute('aria-label', k + 1 < n ? 'Next step' : 'Finish the story');
3403
3662
  }
3404
3663
  async function showStep(st, k) {
3405
3664
  const seq = ++st.seq, step = st.steps[k] || {}, live = () => story === st && st.seq === seq && !dead;
3406
- st.at = k; clearTimeout(st.timer); tour(null); storyBar(st);
3407
- emit('story', { story: st.s.id, step: k, steps: st.steps.length }); // before the step's own select and viewchange
3665
+ st.at = k; st.phase = 'entering'; clearTimeout(st.timer); tour(null); storyBar(st);
3666
+ storyEvent(st); // before the step's own select and viewchange
3408
3667
  while (busy && live()) await new Promise(r => setTimeout(r, 30)); // a move the user started: let it land first
3409
3668
  if (!live()) return;
3410
- if (step.view !== cur && ctx.V[step.view]) await go(step.view);
3669
+ if (step.view !== cur && ctx.V[step.view]) await go(step.view, { story: true });
3411
3670
  if (!live()) return;
3412
- const c = step.camera && typeof step.camera === 'object' ? step.camera : null, n = c && c.card != null ? geo.byId[c.card] : null;
3413
- const to = n ? { cx: n.x + n.w / 2, cy: n.y + n.h / 2, k: +c.zoom || 1.5 }
3414
- : 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
3415
- const moved = cam.animate(to); cam.touched = !!(n || (c && Number.isFinite(+c.x)));
3416
- select(step.select == null ? null : stepRef(step.select), { reveal: false });
3417
- highlight(stepLit(step.highlight));
3671
+ if (ctx.V[step.view] && step.view !== cur) { st.phase = 'left'; touch(); return; } // the reader went elsewhere on the way: the step waits, paused, for resume()
3672
+ if ((expanded[cur] || []).length) { expanded[cur] = []; update(current, false, true); } // a step sets the whole picture: what the reader unfolded here folds back first
3673
+ const c = step.camera && typeof step.camera === 'object' ? step.camera : null;
3674
+ const aim = () => { const n = c && c.card != null ? geo.byId[c.card] : null; // the step's camera, on the picture as it is now
3675
+ return n ? { cx: n.x + n.w / 2, cy: n.y + n.h / 2, k: +c.zoom || 1.5 }
3676
+ : 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
3677
+ const moved = cam.animate(aim()); cam.touched = !!(c && (geo.byId[c.card] || Number.isFinite(+c.x)));
3678
+ select(step.select == null ? null : stepRef(step.select), { reveal: false, story: true });
3679
+ highlight(stepLit(step.highlight), 'story');
3418
3680
  say(`Step ${k + 1} of ${st.steps.length}${step.caption ? ': ' + step.caption : ''}`); // after select(), which says what it selected
3419
- await moved;
3681
+ if (await moved === false && live() && st.taken !== seq) await cam.animate(aim()); // cut short, not by the reader (an update()): the step gets its framing still
3420
3682
  if (!live()) return;
3421
- st.toured = step.tour ? tour(step.tour) : Promise.resolve(true);
3683
+ st.phase = 'shown'; touch();
3684
+ st.toured = step.tour ? tour(step.tour, {}, true) : Promise.resolve(true);
3422
3685
  arm(st);
3423
3686
  }
3424
3687
  function arm(st) { // playing: the next step once this one's duration is up and its tour has reached its end
3425
3688
  clearTimeout(st.timer);
3426
- if (!st.auto || story !== st) return;
3689
+ if (!st.auto || story !== st || st.phase !== 'shown' || paused) return; // entering: it arms once shown; the page's Pause: once it ends
3427
3690
  const seq = st.seq, dur = Math.max(500, +(st.steps[st.at] || {}).duration || 4000); // reading time: not cut by reduced motion
3428
- Promise.all([st.toured, new Promise(r => { st.timer = later(r, dur); })]).then(() => { if (story === st && st.seq === seq && st.auto) stepStory(1); });
3691
+ Promise.all([st.toured, new Promise(r => { st.timer = later(r, dur); })]).then(() => { if (story === st && st.seq === seq && st.auto && !paused) stepStory(1); });
3692
+ }
3693
+ function pauseStory(st) { st.auto = false; st.held = true; clearTimeout(st.timer); storyBar(st); storyEvent(st); touch(); } // held: its step's tour waits too (startTrip)
3694
+ // the reader (or the host) took the map, by a press, a key, or a view or selection the story didn't make: a story playing waits
3695
+ function readerMoved() { const st = story; if (!st) return; st.taken = st.seq; if (st.auto) pauseStory(st); }
3696
+ stage.addEventListener('pointerdown', e => { if (!e.target.closest('.um-story')) readerMoved(); }, true); // not the bar's own buttons
3697
+ stage.addEventListener('wheel', e => { if (!ctrlWheel || e.ctrlKey || e.metaKey || stage.classList.contains('full')) readerMoved(); }, { capture: true, passive: true }); // a wheel that zooms (wheel: 'ctrl' leaves the rest to the page)
3698
+ function restory() { // update(): the story shown takes the model's steps, at the step it is on, or stops when the model has it no more
3699
+ const st = story; if (!st) return;
3700
+ const s = arrOf(current.stories).find(x => x && x.id === st.s.id), steps = stepsOf(s);
3701
+ if (!steps.length) return stopStory();
3702
+ const was = JSON.stringify(st.steps[st.at]), k = Math.min(st.at, steps.length - 1);
3703
+ st.s = s; st.steps = steps;
3704
+ if (st.phase !== 'left' && (k !== st.at || JSON.stringify(steps[k]) !== was)) showStep(st, k); // its step changed: shown again, as it is now
3705
+ else { st.at = k; storyBar(st); touch(); } // the same step (or one the reader left: the bar keeps within the model's steps)
3429
3706
  }
3430
3707
  function stepStory(d) {
3431
3708
  const st = story; if (!st) return;
@@ -3435,9 +3712,11 @@ export function render(container, model, options = {}) {
3435
3712
  }
3436
3713
  function stopStory(told = false) {
3437
3714
  const st = story; if (!st) return;
3438
- story = null; clearTimeout(st.timer); tour(null); highlight(null);
3439
- const bar = stage.querySelector('.um-story'); if (bar) { bar.remove(); clear = null; }
3440
- if (!dead) { say(told ? 'End of the story' : 'Story closed'); emit('story', { story: st.s.id, step: null, steps: st.steps.length }); }
3715
+ story = null; st.told = told; clearTimeout(st.timer); tour(null); highlight(null, 'story');
3716
+ const bar = stage.querySelector('.um-story');
3717
+ if (bar) { const back = bar.contains(root.activeElement); bar.remove(); clear = null; if (back && !dead) stage.focus({ preventScroll: true }); } // focus on the bar stays in the map
3718
+ if (!dead) say(told ? 'End of the story' : 'Story closed');
3719
+ storyEvent(st); touch(); // destroy() too: a listener hears the end
3441
3720
  st.end(told);
3442
3721
  }
3443
3722
  function storyKey(e) { // null: not a story's key; on the bar's own buttons, Enter and Space press them
@@ -3449,15 +3728,17 @@ export function render(container, model, options = {}) {
3449
3728
  return null;
3450
3729
  }
3451
3730
  function play(id, o = {}) {
3731
+ if (id == null) { stopStory(); return null; } // play(null): the story shown stops
3732
+ const s = arrOf(current.stories).find(x => x && x.id === id), steps = stepsOf(s);
3733
+ if (dead || !geo || !steps.length) return null; // a story the model lacks: the one shown plays on
3452
3734
  stopStory();
3453
- const s = (current.stories || []).find(x => x && x.id === id), steps = s && Array.isArray(s.steps) ? s.steps.filter(x => x && typeof x === 'object') : [];
3454
- if (dead || !geo || !steps.length) return null;
3455
- 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; }) };
3735
+ let end; const st = { s, steps, at: -1, seq: 0, timer: 0, auto: o.auto !== false, phase: 'entering', told: false, toured: Promise.resolve(true), done: new Promise(r => { end = r; }) };
3456
3736
  st.end = end;
3457
- st.ctl = { get story() { return s.id; }, get step() { return st.at; }, get steps() { return steps.length; }, get playing() { return st.auto && story === st; },
3737
+ st.ctl = { get story() { return st.s.id; }, get step() { return st.at; }, get steps() { return st.steps.length; }, get playing() { return st.auto && story === st; },
3738
+ get state() { return story !== st ? (st.told ? 'ended' : 'stopped') : st.phase === 'entering' ? 'entering' : st.auto ? 'playing' : 'paused'; },
3458
3739
  next() { if (story === st) stepStory(1); }, prev() { if (story === st) stepStory(-1); }, stop() { if (story === st) stopStory(); },
3459
- pause() { if (story !== st) return; st.auto = false; clearTimeout(st.timer); storyBar(st); },
3460
- resume() { if (story !== st || st.auto) return; st.auto = true; storyBar(st); arm(st); }, done: st.done };
3740
+ pause() { if (story === st && st.auto) pauseStory(st); },
3741
+ resume() { if (story !== st || st.auto) return; st.auto = true; st.held = false; storyBar(st); storyEvent(st); touch(); if (st.phase === 'left') showStep(st, st.at); else arm(st); }, done: st.done };
3461
3742
  story = st;
3462
3743
  showStep(st, Math.max(0, Math.min(steps.length - 1, Math.floor(+o.step) || 0)));
3463
3744
  return st.ctl;
@@ -3465,47 +3746,66 @@ export function render(container, model, options = {}) {
3465
3746
 
3466
3747
  const inner = { ctx: () => ctx, geo: () => geo, cam: () => cam, svg: () => svg, stage, host, go, open, up, flash, setFlow, pause, key, startView, standalone, play,
3467
3748
  select, busy: () => busy, flowing: () => stage.classList.contains('flowing'), view: () => cur, unfold: gesture, lens: setLens,
3468
- highlight, overlay, focus: focusMap, editable: !!E, told: id => { const st = story && story.steps[story.at]; return !!(st && st.select === id); } }; // told: a story's step picked it
3749
+ highlight, overlay, focus: focusMap, editable: !!E }; // a story's pointer is the pieces' own, from the story event (0.14.0)
3469
3750
 
3470
3751
  // the pieces a host asked for (#99): they follow the map's selection and views, and take their keys wherever the focus is
3471
3752
  // in the map (Enter and Space press their buttons; the map's other keys reach the canvas, as in the page)
3472
3753
  const frame = q('.um-frame'), pc = frame && pieces(() => inner, { tree: q('.outline'), legend: lg, panel: q('.panel') }, { frame, toggle: q('.um-navbtn'), layer: frame, status: tell || say });
3473
3754
  if (pc) {
3474
- (subs.select ||= []).push(e => pc.select(e.target, e.targets)); (subs.viewchange ||= []).push(pc.unlight);
3755
+ (subs.select ||= []).push(e => pc.select(e.target, e.targets)); (subs.viewchange ||= []).push(pc.unlight); (subs.story ||= []).push(pc.story);
3475
3756
  frame.addEventListener('keydown', e => { if (e.defaultPrevented || e.metaKey || e.ctrlKey || e.altKey) return;
3476
3757
  if (pc.key(e) || (!stage.contains(e.target) && e.key !== 'Enter' && e.key !== ' ' && key(e))) { e.preventDefault(); e.stopPropagation(); } });
3477
3758
  if (options.nav) { q('.um-navbtn').addEventListener('click', () => pc.setTree(!frame.classList.contains('tree-open'))); pc.setTree(ctx.views.length > 1); }
3478
3759
  }
3760
+ const motionOf = () => instant ? 'instant' : reducedMotion() ? 'reduced' : 'full';
3761
+ const camNow = () => { const r = (x, d) => Math.round(x * d) / d; return cam ? { x: r(cam.s.cx, 10), y: r(cam.s.cy, 10), zoom: r(cam.s.k, 1000) } : { x: 0, y: 0, zoom: 1 }; };
3762
+ function snapshot(event = false) { // MapState: built fresh on every read, frozen all the way down, plain JSON; event: the camera at rest
3763
+ const deep = o => { Object.values(o).forEach(v => v && typeof v === 'object' && deep(v)); return Object.freeze(o); }, st = story, t = trip;
3764
+ return deep({ view: cur, navigating: nav ? nav.to : null, selection: picked(), expanded: [...(expanded[cur] || [])], lens: lensId, overlays: [...layers.keys()],
3765
+ story: st ? { id: st.s.id, step: st.at, steps: st.steps.length, playing: !!st.auto } : null, tour: !!(t && t.kind === 'tour'), replay: t && t.kind === 'replay' ? { playing: !t.paused } : null,
3766
+ camera: event && camRest ? { ...camRest } : camNow(), flow: stage.classList.contains('flowing'), motion: motionOf(), gesture: gestureNow(), destroyed: dead });
3767
+ }
3479
3768
  const first = () => {
3480
3769
  if (dead) return;
3481
- const v0 = ctx.V[options.view] ? options.view : ctx.rootId;
3482
- if (!v0) return;
3483
- mount(v0); cam.set(startView()); cam.touched = false;
3484
- if (options.select) select(options.select, { reveal: false });
3485
- if (P.onReady) P.onReady();
3770
+ if (options.view != null && !ctx.V[options.view]) warn(`render(…, { view: "${options.view}" }): the model has no such view; it starts on ${ctx.rootId ? `"${ctx.rootId}"` : 'none'}`);
3771
+ if (!cam) { // a view shown already (a setView() before a deferred first draw) stays
3772
+ const v0 = ctx.V[options.view] ? options.view : ctx.rootId;
3773
+ mount(v0 ?? null); cam.set(startView()); cam.touched = false; // no views: the empty card, until an update() brings one
3774
+ if (v0 && options.select) select(options.select, { reveal: false });
3775
+ }
3776
+ if (cur && P.onReady) P.onReady();
3486
3777
  };
3487
3778
  if (P.defer) requestAnimationFrame(() => later(first, 16)); else first();
3779
+ camRest = camNow(); if (listensState()) lastState = snapshot(true); // the state event reports changes from here
3488
3780
  // the host's fonts may still be loading: measure again once they are in, and redraw
3489
- if (measure && !own && document.fonts && document.fonts.status !== 'loaded') document.fonts.ready.then(() => { if (dead) return; measure = hostMeasure(host); update(current, true); });
3781
+ if (measure && !own && document.fonts && document.fonts.status !== 'loaded') document.fonts.ready.then(() => { if (dead || !current || typeof current !== 'object') return; measure = hostMeasure(host); update(current, true); });
3490
3782
 
3491
3783
  return {
3492
3784
  update: m => update(m),
3493
- setView: id => go(id),
3494
- select: ref => select(ref),
3495
- fit: () => { if (cam) cam.fit(); },
3785
+ setView: id => go(id), // a Promise<boolean>: true once the view shows; false for a view the model lacks, a later request, or destroy()
3786
+ select: ref => { if (!dead) select(ref); },
3787
+ fit: () => { if (cam && !dead) { readerMoved(); cam.fit(); } }, // the host takes the camera: a story playing waits
3496
3788
  toSVG: (o = {}) => dead ? '' : standalone(o.scale ?? 1, o.view ?? cur, o.fontCSS),
3497
3789
  toPNG: (o = {}) => dead ? Promise.resolve(null) : pngOf(standalone(o.scale ?? 2, o.view ?? cur, o.fontCSS)),
3498
3790
  focus: ref => { if (!dead) focusMap(ref); },
3499
3791
  expand: id => setUnfold(id, true), // unfold a stacked card of the view shown in place (#71): true when it did
3500
3792
  collapse: id => setUnfold(id, false),
3501
3793
  get expanded() { return [...(expanded[cur] || [])]; },
3502
- on(type, fn) { (subs[type] ||= []).push(fn); if (type === 'click') tabKeys();
3794
+ on(type, fn) { if (dead) return () => {}; if (type === 'state' && !listensState()) { camRest = camNow(); lastState = snapshot(true); } // a first state listener hears changes from now
3795
+ (subs[type] ||= []).push(fn); if (type === 'click') tabKeys();
3503
3796
  return () => { subs[type] = (subs[type] || []).filter(f => f !== fn); if (type === 'click' && !dead) tabKeys(); }; },
3504
3797
  get view() { return cur; },
3505
- get selection() { return sel; },
3798
+ get selection() { return copyRef(sel); },
3506
3799
  get selected() { return picked(); },
3800
+ get state() { return snapshot(); }, // MapState (0.14.0): a fresh, frozen, JSON-serializable snapshot
3801
+ get navigating() { return nav ? nav.to : null; }, // the view a move is going to, while one is under way
3802
+ get destroyed() { return dead; },
3803
+ get flow() { return stage.classList.contains('flowing'); }, // the animated flow along lines (≋, F)
3804
+ set flow(on) { if (!dead) setFlow(!!on, false); }, // a host's choice, not the reader's: the page saves only ≋ and F
3805
+ get motion() { return motionOf(); }, // 'instant' (the page's #check, #still, #shot), 'reduced' (the reader's setting), else 'full'
3806
+ get story() { return story ? story.ctl : null; }, // the player of the story shown (play()'s, the page menu's or a link's), else null
3507
3807
  overlay: (name, layer) => overlay(name, layer),
3508
- highlight: refs => highlight(refs),
3808
+ highlight: (refs, options) => highlight(refs, (options && options.layer) || 'highlight'),
3509
3809
  lens: id => setLens(id), // one of the model's lenses on the map, or null for none (#94): false when the model has no such lens
3510
3810
  tour: (path, o) => tour(path, o),
3511
3811
  replay: (spans, o) => replay(spans, o),
@@ -3695,7 +3995,6 @@ function lensOf(ctx, vid, lens) {
3695
3995
 
3696
3996
  export function mountPage(container, model, options = {}) {
3697
3997
  const Q = options.params || new URLSearchParams();
3698
- if (options.instant) INSTANT = true;
3699
3998
  document.body.classList.add('map-mode');
3700
3999
  let { D, views, nodes, N, V, rootId, nodeIn: nodeIn0, subjectOf, viewsWith, kidsOf, chain, viewEdges, planCache } = mapContext(model);
3701
4000
  const nodeIn = (nid, vid = cur) => nodeIn0(nid, vid);
@@ -3703,6 +4002,27 @@ export function mountPage(container, model, options = {}) {
3703
4002
  const modK = mac ? '⌘K' : 'Ctrl K';
3704
4003
  const HINT = hintHTML(modK);
3705
4004
  const ghost = [[10, 26, 22], [40, 18, 24], [40, 46, 24], [10, 60, 22], [70, 32, 20], [70, 70, 20]].map(([l, t, w]) => `<span style="left:${l}%;top:${t}%;width:${w}%"></span>`).join('');
4005
+ // the page's keys (0.14.0), in the order the key list (?) shows them: what to press and what it does, then, for the keys
4006
+ // the page reads itself, those keys and what they do; the rest are the map's own, which reach its canvas (the router)
4007
+ const KEYS = [
4008
+ ['/ or ' + modK, 'search nodes and views', ['/'], () => openPal()],
4009
+ ['Scroll · pinch', 'zoom (two-finger drag pans)'],
4010
+ ['Click', 'details below the map'],
4011
+ ['Double-click · Enter', 'open a stacked node ⤢'],
4012
+ ['E · Alt + click', 'unfold a stacked node here, or fold it back ⤡'],
4013
+ ['Esc · Backspace', 'close what is on top (a menu, the recorder, a story too), else clear the selection, else up one level', ['Escape', 'Backspace'], (e, inCanvas) => closeTop(e, inCanvas)],
4014
+ ['+ − 0 1', 'zoom in, out, fit, 100%'],
4015
+ ['← ↑ → ↓', 'pan'],
4016
+ ['[ ]', 'previous / next details tab', ['[', ']'], e => pc.key(e)],
4017
+ ['i', 'about this view', ['i', 'I'], () => togglePill()],
4018
+ ['O', 'views list', ['o', 'O'], e => pc.key(e)],
4019
+ ['F', 'flow on/off'],
4020
+ ['> <', 'light what a card leads to, what leads to it', ['>', '<'], e => pc.key(e)],
4021
+ ['Shift-click · P', 'light every path between two cards', ['p', 'P'], e => pc.key(e)],
4022
+ ['← → · PgUp PgDn', 'in a story: previous / next step'],
4023
+ ['?', 'these keys', ['?'], () => toggleHelp()],
4024
+ ];
4025
+ const ac = new AbortController(), signal = ac.signal; // the page's listeners on the document and window: destroy() takes them away
3706
4026
 
3707
4027
  const app = container; app.className = 'mapapp';
3708
4028
  app.innerHTML = `
@@ -3712,9 +4032,9 @@ export function mountPage(container, model, options = {}) {
3712
4032
  <nav class="crumbs" id="m-crumbs" aria-label="Breadcrumb"></nav>
3713
4033
  <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>
3714
4034
  <button class="ibtn" id="m-theme" title="Light / dark" aria-label="Toggle theme">◐</button>${(D.stories || []).length ? `
3715
- <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>
4035
+ <div class="menu"><button class="ibtn txt" id="m-stories" aria-haspopup="true" aria-expanded="false" title="Stories: walk-throughs of this map">▶<span class="lbl"> Stories</span></button>
3716
4036
  <div class="menu-pop" id="m-stories-pop" hidden>${D.stories.map(s => `<button data-story="${esc(s && s.id)}">${esc((s && (s.title || s.id)) || '')}</button>`).join('')}</div></div>` : ''}${arrOf(D.lenses).some(l => l && l.id) ? `
3717
- <div class="menu"><button class="ibtn txt" id="m-lenses" aria-haspopup="true" aria-expanded="false" title="Lenses: ways to look at this map">◎ Lenses</button>
4037
+ <div class="menu"><button class="ibtn txt" id="m-lenses" aria-haspopup="true" aria-expanded="false" title="Lenses: ways to look at this map">◎<span class="lbl"> Lenses</span></button>
3718
4038
  <div class="menu-pop" id="m-lenses-pop" hidden>${D.lenses.filter(l => l && l.id).map(l => `<button data-lens="${esc(l.id)}" aria-pressed="false">${esc(l.title || l.id)}</button>`).join('')}<hr><button data-lens="" aria-pressed="true">No lens</button></div></div>` : ''}
3719
4039
  <div class="menu"><button class="pbtn" id="m-export" aria-haspopup="true" aria-expanded="false">Export ▾</button>
3720
4040
  <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>
@@ -3733,17 +4053,14 @@ export function mountPage(container, model, options = {}) {
3733
4053
  <aside class="panel" id="m-panel" aria-label="Node details" hidden></aside>
3734
4054
  </div>
3735
4055
  <div class="hpop" id="m-helpbox" role="dialog" aria-label="Keys" hidden><b>Keys</b>
3736
- <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>
3737
- <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>
3738
- <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>
3739
- <dt>← → · PgUp PgDn</dt><dd>in a story: previous / next step (Esc closes it)</dd></dl></div>
4056
+ <dl>${KEYS.map(([k, d]) => `<dt>${esc(k)}</dt><dd>${esc(d)}</dd>`).join('')}</dl></div>
3740
4057
  <div class="scrim" id="m-pal" hidden><div class="pal" role="dialog" aria-label="Search">
3741
4058
  <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>
3742
4059
  <div class="res" id="m-res" role="listbox"></div>
3743
4060
  <div class="pft"><span>↑↓ move</span><span>↵ open</span><span>esc close</span></div></div></div>`;
3744
4061
  const $ = id => document.getElementById(id);
3745
4062
  const stage = $('m-stage'), panel = $('m-panel');
3746
- let X = null, cam = null, geo = null, svg = null, cur = null, sel = null, stack = [], paused = false, replaceNext = false;
4063
+ let X = null, cam = null, geo = null, svg = null, cur = null, sel = null, stack = [], paused = false;
3747
4064
  const status = hinter($('m-hint'), HINT);
3748
4065
  // the views list, the legend and the details panel (#99): the same pieces render() draws for a host
3749
4066
  const pc = pieces(() => X, { tree: $('m-tree'), legend: $('m-legend'), panel }, { page: true, frame: document.body, toggle: $('m-outline'), layer: document.body, status });
@@ -3771,21 +4088,46 @@ export function mountPage(container, model, options = {}) {
3771
4088
  const togglePill = force => { const b = $('m-pillbtn'), p = $('m-vpop'); if (!b) return; const on = force != null ? force : p.hidden; p.hidden = !on; b.setAttribute('aria-expanded', on); };
3772
4089
  const setTree = pc.setTree;
3773
4090
 
3774
- /* selection: the canvas reports, the pieces show the details panel, the page records the hash */
3775
- const select = (nid, opts = {}) => X.select({ type: 'node', id: nid }, opts);
3776
- const deselect = () => X.select(null);
3777
-
3778
- /* navigation */
3779
- function setHash(replace) {
4091
+ /* the link (0.14.0): one place writes it, from what the map shows: its view, card, story step and lens, and the reader
4092
+ copy, theme, check and shot the page was opened with (the theme as it is now, after ◐ too). A new view is a new entry,
4093
+ written once the move lands; anything else rewrites the entry. Nothing is written while a link is being followed */
4094
+ function write(push = false) {
4095
+ if (!X || linking || handle.destroyed) return;
3780
4096
  const p = new URLSearchParams(); p.set('view', cur); if (sel) p.set('node', sel);
3781
4097
  if (told) { p.set('story', told.id); p.set('step', told.step + 1); } // the story shown, at its step (#69)
3782
4098
  if (lensOn) p.set('lens', lensOn); // the lens on the map (#94)
3783
- ['theme', 'check', 'shot'].forEach(k => Q.get(k) && p.set(k, Q.get(k)));
4099
+ if (Q.get('theme')) p.set('theme', document.documentElement.dataset.theme || Q.get('theme'));
4100
+ ['reader', 'check', 'shot'].forEach(k => Q.get(k) && p.set(k, Q.get(k)));
3784
4101
  const h = '#' + p.toString();
3785
- if (location.hash === h) return;
3786
- if (replace) window.history.replaceState(null, '', h); else window.history.pushState(null, '', h); // window's: history() is this module's undo helper
4102
+ if (location.hash !== h) window.history[push ? 'pushState' : 'replaceState'](null, '', h); // window's: history() is this module's undo helper
4103
+ }
4104
+ /* following a link, on load and on Back, Forward or an edited link: its lens, view, story step and card, applied in that
4105
+ order. Once the map has settled, the page writes the link it shows: what the model lacks is corrected, and the hint
4106
+ line says so. A move the reader starts meanwhile wins, as a move of the reader's own (a new entry) */
4107
+ let linking = null, follows = 0; // linking: { view } while a link is followed: the view it goes to, whose arrival writes no entry
4108
+ const idle = () => new Promise(r => { const t = () => X.busy() ? setTimeout(t, 30) : r(); t(); }); // once no move is under way
4109
+ async function follow(load = false) {
4110
+ const p = new URLSearchParams(location.hash.slice(1)), my = ++follows, lacks = [], stories = arrOf(D.stories);
4111
+ const lens = p.get('lens') || null, sid = p.get('story'), at = Math.max(1, Math.floor(+p.get('step')) || 1) - 1;
4112
+ const def = sid ? stories.find(s => s && s.id === sid) : null, steps = def ? arrOf(def.steps).filter(s => s && typeof s === 'object') : [], step = steps[Math.min(at, steps.length - 1)];
4113
+ linking = { view: null };
4114
+ if (lens !== lensOn && !X.lens(lens)) lacks.push(`no lens “${lens}”`);
4115
+ if (sid && !step) lacks.push(`no story “${sid}”`); else if (sid && at >= steps.length) lacks.push(`no step ${at + 1} in “${sid}”`);
4116
+ const shown = handle.state.story, k = Math.min(at, steps.length - 1), played = !!step && (!shown || shown.id !== sid || shown.step !== k);
4117
+ const v = played ? step.view : p.get('view') || rootId; // a step to show: its own view, gone to first so the step moves nothing
4118
+ if (!V[v]) lacks.push(`no view “${v}”`);
4119
+ else if (v !== cur || X.busy()) { linking.view = v; const ok = await X.go(v); if (my !== follows) return; if (!ok || !linking) { linking = null; return; } } // the reader's own move won: its landing writes its own entry
4120
+ if (played) { handle.play(sid, { step: k, auto: false }); await idle(); if (my !== follows || !linking) return; } // the story at the link's step, shown and not playing on
4121
+ else if (!step && shown) handle.play(null); // a link with no story: the one shown stops
4122
+ if (!played) { // the link's card, unless a step just chose the selection
4123
+ const node = p.get('node') || (load && Q.get('shot') && cur !== rootId ? subjectOf(V[cur]) || (V[cur].nodeIds || [])[0] : null);
4124
+ if (node) { if (node !== sel && !X.select({ type: 'node', id: node }, { reveal: !load })) lacks.push(`no card “${node}” here`); }
4125
+ else if (handle.selection) X.select(null);
4126
+ }
4127
+ linking = null; write();
4128
+ if (lacks.length) status('Link corrected: ' + lacks.join('; '), 5000);
3787
4129
  }
3788
- const go = (vid, how = {}) => { if (how.replace) replaceNext = true; return X.go(vid, how); };
4130
+ const go = (vid, how = {}) => X.go(vid, how);
3789
4131
  const open = nid => X.open(nid), up = () => X.up();
3790
4132
  const findNode = pc.find;
3791
4133
 
@@ -3795,46 +4137,61 @@ export function mountPage(container, model, options = {}) {
3795
4137
  const bv = e.target.closest('.vpop [data-v]'); if (bv) return go(bv.dataset.v);
3796
4138
  });
3797
4139
  const setFlow = on => X.setFlow(on);
3798
- const closeHelp = () => { $('m-helpbox').hidden = true; $('m-help').setAttribute('aria-expanded', 'false'); };
3799
- const closeExport = () => { $('m-export-pop').hidden = true; $('m-export').setAttribute('aria-expanded', 'false'); };
4140
+ const toggleHelp = force => { const on = force ?? $('m-helpbox').hidden; $('m-helpbox').hidden = !on; $('m-help').setAttribute('aria-expanded', on); if (on) openMenu(null); };
4141
+ /* the header's menus, Export, Stories and Lenses (0.14.0): one open at a time, or none. back: focus goes back to the
4142
+ button of the menu that closes (Esc) */
4143
+ let menuOn = null;
4144
+ function openMenu(name, back = false) {
4145
+ const was = menuOn; menuOn = name && $(`m-${name}-pop`) ? name : null;
4146
+ ['export', 'stories', 'lenses'].forEach(n => { const p = $(`m-${n}-pop`); if (!p) return; p.hidden = n !== menuOn; $(`m-${n}`).setAttribute('aria-expanded', n === menuOn); });
4147
+ if (menuOn) toggleHelp(false);
4148
+ if (back && was && !menuOn) $(`m-${was}`).focus();
4149
+ }
4150
+ ['export', 'stories', 'lenses'].forEach(n => { const b = $(`m-${n}`); if (b) b.addEventListener('click', () => openMenu(menuOn === n ? null : n)); });
3800
4151
  document.addEventListener('click', e => {
3801
- const t = e.target;
3802
- const cv = t.closest('#m-crumbs [data-v]'); if (cv) { if (innerWidth <= 820) setTree(false); return go(cv.dataset.v); }
3803
- const mn = t.closest('.menu'); if (!mn || !mn.contains($('m-export'))) closeExport(); if (!mn || !mn.contains($('m-stories'))) closeStories(); if (!mn || !mn.contains($('m-lenses'))) closeLenses();
3804
- if (!t.closest('#m-helpbox, #m-help')) closeHelp();
3805
- });
4152
+ const t = e.target, at = s => t.closest && t.closest(s);
4153
+ if (!at('.menu')) openMenu(null);
4154
+ if (!at('#m-helpbox, #m-help')) toggleHelp(false);
4155
+ const cv = at('#m-crumbs [data-v]'); if (cv) { if (innerWidth <= 820) setTree(false); go(cv.dataset.v); }
4156
+ }, { signal });
3806
4157
  $('m-outline').addEventListener('click', () => setTree(!document.body.classList.contains('tree-open')));
3807
4158
  if (innerWidth > 1180 && views.length > 1) setTree(true);
3808
- $('m-help').addEventListener('click', () => { const on = $('m-helpbox').hidden; $('m-helpbox').hidden = !on; $('m-help').setAttribute('aria-expanded', on); });
3809
- $('m-theme').addEventListener('click', toggleTheme);
4159
+ // the views list, open over the map on a phone's width, closes when the window narrows to it: it would cover the map
4160
+ let narrow = innerWidth <= 820;
4161
+ addEventListener('resize', () => { const n = innerWidth <= 820; if (n && !narrow) setTree(false); narrow = n; }, { signal });
4162
+ $('m-help').addEventListener('click', () => toggleHelp());
4163
+ $('m-theme').addEventListener('click', () => { toggleTheme(); write(); }); // a link that names the theme names the one shown
3810
4164
  $('m-pause').addEventListener('click', e => { paused = !paused; const b = e.currentTarget, l = paused ? 'Play' : 'Pause'; X.pause(paused); b.setAttribute('aria-pressed', paused); b.title = l; b.setAttribute('aria-label', l); b.textContent = paused ? '▶' : '❚❚'; });
3811
- $('m-export').addEventListener('click', () => { const on = $('m-export-pop').hidden; $('m-export-pop').hidden = !on; $('m-export').setAttribute('aria-expanded', on); });
3812
- $('m-export-pop').addEventListener('click', e => { const x = e.target.closest('[data-x]')?.dataset.x; if (!x) return; closeExport(); exportView(x); });
4165
+ $('m-export-pop').addEventListener('click', e => { const x = e.target.closest('[data-x]')?.dataset.x; if (!x) return; openMenu(null); exportView(x); });
3813
4166
 
3814
- /* stories (#69): the Stories menu plays one; the link names the story and step shown; Export ▸ Record a story… */
3815
- let told = null; // { id, step }: the story on the map and its step
3816
- const storiesPop = $('m-stories-pop'), closeStories = () => { if (storiesPop) { storiesPop.hidden = true; $('m-stories').setAttribute('aria-expanded', 'false'); } };
3817
- if (storiesPop) {
3818
- $('m-stories').addEventListener('click', () => { const on = storiesPop.hidden; storiesPop.hidden = !on; $('m-stories').setAttribute('aria-expanded', on); });
3819
- storiesPop.addEventListener('click', e => { const b = e.target.closest('[data-story]'); if (!b) return; closeStories(); handle.play(b.dataset.story); });
3820
- }
3821
- /* lenses (#94): the Lenses menu puts one on the map, its name on the menu's button and in the link (#lens=<id>) */
4167
+ /* stories (#69): the Stories menu plays one; the link names the story and step shown, in the entry of the step's own view
4168
+ (0.14.0); Export ▸ Record a story… */
4169
+ let told = null; // { id, step }: the story on the map and its step, from the story event
4170
+ const stepView = t => { const s = arrOf(D.stories).find(x => x && x.id === t.id), st = s && arrOf(s.steps).filter(x => x && typeof x === 'object')[t.step]; return st ? st.view : null; };
4171
+ if ($('m-stories-pop')) $('m-stories-pop').addEventListener('click', e => { const b = e.target.closest('[data-story]'); if (!b) return; openMenu(null); handle.play(b.dataset.story); });
4172
+ /* lenses (#94): the Lenses menu puts one on the map; the lens event (the menu's, the handle's, a link's) names it on the
4173
+ menu's button and in the link (#lens=<id>) */
3822
4174
  let lensOn = null;
3823
- const lensesPop = $('m-lenses-pop'), closeLenses = () => { if (lensesPop) { lensesPop.hidden = true; $('m-lenses').setAttribute('aria-expanded', 'false'); } };
3824
- function pickLens(id, fromLink) { // fromLink: the link names it already (on load, back and forward)
3825
- if (!X || !X.lens(id || null)) return;
3826
- lensOn = id || null;
3827
- const L = arrOf(D.lenses).find(x => x && x.id === lensOn), name = L ? L.title || L.id : '';
3828
- if (lensesPop) { lensesPop.querySelectorAll('[data-lens]').forEach(b => b.setAttribute('aria-pressed', b.dataset.lens === (lensOn || ''))); $('m-lenses').textContent = '◎ ' + (name || 'Lenses'); }
3829
- if (!fromLink) { status(name ? 'Lens: ' + name : 'No lens'); setHash(true); }
3830
- }
3831
- if (lensesPop) {
3832
- $('m-lenses').addEventListener('click', () => { const on = lensesPop.hidden; lensesPop.hidden = !on; $('m-lenses').setAttribute('aria-expanded', on); });
3833
- lensesPop.addEventListener('click', e => { const b = e.target.closest('[data-lens]'); if (!b) return; closeLenses(); pickLens(b.dataset.lens); });
4175
+ const lensesPop = $('m-lenses-pop');
4176
+ function showLens() {
4177
+ if (!lensesPop) return;
4178
+ const L = arrOf(D.lenses).find(x => x && x.id === lensOn);
4179
+ lensesPop.querySelectorAll('[data-lens]').forEach(b => b.setAttribute('aria-pressed', b.dataset.lens === (lensOn || '')));
4180
+ $('m-lenses').innerHTML = `◎<span class="lbl"> ${esc(L ? L.title || L.id : 'Lenses')}</span>`;
3834
4181
  }
4182
+ if (lensesPop) lensesPop.addEventListener('click', e => { const b = e.target.closest('[data-lens]'); if (!b) return; openMenu(null);
4183
+ if (X.lens(b.dataset.lens || null)) { const L = arrOf(D.lenses).find(x => x && x.id === lensOn); status(L ? 'Lens: ' + (L.title || L.id) : 'No lens'); } });
3835
4184
  // 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);
3836
- // captions are typed in, and Save writes the story's JSON, for the model's `stories`
4185
+ // captions are typed in, and Save writes the story's JSON, for the model's `stories`. Not while a story is shown: its
4186
+ // steps are not the reader's. Esc closes it (when it is on top); Close, Esc and leaving the page ask first while it holds
4187
+ // steps not saved (0.14.0)
3837
4188
  let rec = null;
4189
+ const unsaved = () => !!rec && (rec.steps.length > 1 || rec.steps.some(s => s.caption)) && rec.json() !== rec.saved;
4190
+ const leaving = e => { if (unsaved()) { e.preventDefault(); e.returnValue = ''; } };
4191
+ function closeRec() { // false: the reader kept it
4192
+ if (!rec || (unsaved() && !confirm(`Close the recorder? Its ${rec.steps.length} steps are not saved.`))) return false;
4193
+ clearTimeout(rec.t); rec = null; $('m-rec').remove(); removeEventListener('beforeunload', leaving); return true;
4194
+ }
3838
4195
  function record() {
3839
4196
  if (rec) return;
3840
4197
  stage.insertAdjacentHTML('beforeend', `<div class="m-rec" id="m-rec" role="region" aria-label="Recording a story">
@@ -3843,8 +4200,12 @@ export function mountPage(container, model, options = {}) {
3843
4200
  <ol id="m-rec-list"></ol>
3844
4201
  <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>`);
3845
4202
  const box = $('m-rec'), same = (a, b) => JSON.stringify([a.view, a.select, a.camera]) === JSON.stringify([b.view, b.select, b.camera]);
3846
- rec = { steps: [], t: 0 };
3847
- const draw = () => { $('m-rec-n').textContent = rec.steps.length === 1 ? '1 step' : rec.steps.length + ' steps';
4203
+ rec = { steps: [], t: 0, saved: null };
4204
+ const story = () => { const title = $('m-rec-title').value.trim() || 'A walk-through', id = title.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '') || 'story';
4205
+ return { id, title, steps: rec.steps.map(({ caption, ...s }) => caption ? { view: s.view, caption, ...s } : s) }; };
4206
+ rec.json = () => JSON.stringify(story());
4207
+ addEventListener('beforeunload', leaving);
4208
+ const draw = () => { $('m-rec-n').textContent = rec.steps.length === 1 ? '1 step' : rec.steps.length + ' steps'; box.querySelector('[data-r="save"]').disabled = !rec.steps.length; // nothing to save: a story needs steps
3848
4209
  $('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>
3849
4210
  <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(''); };
3850
4211
  rec.take = force => { // the map as it is: its view, selected card and, once moved by hand, its camera
@@ -3858,16 +4219,15 @@ export function mountPage(container, model, options = {}) {
3858
4219
  const r = e.target.closest('[data-r]')?.dataset.r, del = e.target.closest('[data-del]');
3859
4220
  if (del) { rec.steps.splice(+del.dataset.del, 1); draw(); }
3860
4221
  else if (r === 'add') rec.take(true);
3861
- else if (r === 'save') {
3862
- const title = $('m-rec-title').value.trim() || 'A walk-through', id = title.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '') || 'story';
3863
- const steps = rec.steps.map(({ caption, ...s }) => caption ? { view: s.view, caption, ...s } : s);
3864
- download(id + '.story.json', new Blob([JSON.stringify({ id, title, steps }, null, 2) + '\n'], { type: 'application/json' }));
4222
+ else if (r === 'save' && rec.steps.length) {
4223
+ const st = story(); rec.saved = JSON.stringify(st);
4224
+ download(st.id + '.story.json', new Blob([JSON.stringify(st, null, 2) + '\n'], { type: 'application/json' }));
3865
4225
  status('Saved: add it to the model\'s "stories"', 4000);
3866
- } else if (r === 'close') { clearTimeout(rec.t); rec = null; box.remove(); }
4226
+ } else if (r === 'close') closeRec();
3867
4227
  });
3868
4228
  rec.take();
3869
4229
  }
3870
- const recordSoon = () => { if (rec) { clearTimeout(rec.t); rec.t = setTimeout(() => rec && rec.take(), 650); } }; // once the camera has landed
4230
+ const recordSoon = () => { if (rec && !handle.story) { clearTimeout(rec.t); rec.t = setTimeout(() => rec && !handle.story && rec.take(), 650); } }; // once the camera has landed; not a story's steps
3871
4231
 
3872
4232
  /* ⌘K command box: nodes and views, Enter jumps */
3873
4233
  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() }));
@@ -3903,15 +4263,16 @@ export function mountPage(container, model, options = {}) {
3903
4263
  res.innerHTML = html;
3904
4264
  const on = res.querySelector('.fr.on'); on && on.scrollIntoView({ block: 'nearest' });
3905
4265
  }
3906
- const openPal = () => { closeHelp(); closeExport(); pal.hidden = false; q.value = ''; hi = 0; drawHits(); q.focus(); };
3907
- const closePal = () => { pal.hidden = true; q.blur(); };
4266
+ let palBack = null; // where focus was before search opened: Esc gives it back
4267
+ const openPal = () => { toggleHelp(false); openMenu(null); palBack = document.activeElement; pal.hidden = false; q.value = ''; hi = 0; drawHits(); q.focus(); };
4268
+ const closePal = (back = false) => { pal.hidden = true; q.blur(); if (back && palBack && palBack.isConnected && palBack !== document.body) palBack.focus(); palBack = null; };
3908
4269
  const pickHit = h => { if (!h) return; closePal(); if (h.t === 'n') findNode(h.n.id); else go(h.v.id); };
3909
4270
  q.addEventListener('input', () => { hi = 0; drawHits(); });
3910
4271
  q.addEventListener('keydown', e => {
3911
4272
  if (e.key === 'ArrowDown') { hi = Math.min(hits.length - 1, hi + 1); drawHits(); e.preventDefault(); }
3912
4273
  else if (e.key === 'ArrowUp') { hi = Math.max(0, hi - 1); drawHits(); e.preventDefault(); }
3913
4274
  else if (e.key === 'Enter') { pickHit(hits[hi]); e.preventDefault(); }
3914
- else if (e.key === 'Escape') { closePal(); e.preventDefault(); }
4275
+ else if (e.key === 'Escape') { closePal(true); e.preventDefault(); }
3915
4276
  e.stopPropagation();
3916
4277
  });
3917
4278
  res.addEventListener('click', e => { const f = e.target.closest('[data-fill]'); if (f) { q.value = f.dataset.fill; hi = 0; drawHits(); q.focus(); return; } const b = e.target.closest('[data-i]'); if (b) pickHit(hits[+b.dataset.i]); });
@@ -3919,28 +4280,43 @@ export function mountPage(container, model, options = {}) {
3919
4280
  pal.addEventListener('click', e => { if (e.target === pal) closePal(); });
3920
4281
  $('m-find').addEventListener('click', openPal);
3921
4282
 
3922
- /* keyboard: the page's own keys first (search, help, panels); the map's keys reach the canvas wherever the focus is */
4283
+ /* keyboard (0.14.0): one router, which reads each key from the top open layer down (an example shown full size, search,
4284
+ the key list, a menu, the view's popover, the story recorder, a story, then the map) and the KEYS table. Enter, Space
4285
+ and arrows aimed at a control outside the map are that control's own; the map's keys reach its canvas from anywhere
4286
+ else on the page */
4287
+ const layer = () => pc.modal() ? 'lightbox' : !pal.hidden ? 'search' : !$('m-helpbox').hidden ? 'help' : menuOn ? 'menu'
4288
+ : $('m-vpop') && !$('m-vpop').hidden ? 'pill' : rec ? 'recorder' : told ? 'story' : 'map';
4289
+ function closeTop(e, inCanvas) { // Esc and Backspace: the top open layer closes; else the map's own: the selection clears, then up a level
4290
+ const top = layer();
4291
+ if (top === 'search') closePal(true);
4292
+ else if (top === 'help') toggleHelp(false);
4293
+ else if (top === 'menu') openMenu(null, true);
4294
+ else if (top === 'pill') togglePill(false);
4295
+ else if (top === 'recorder') closeRec(); // asking first when it holds steps not saved
4296
+ else if (top === 'story') handle.play(null); // Backspace too
4297
+ else return !inCanvas && X.key(e); // in the canvas, the canvas takes its own
4298
+ }
4299
+ const CONTROL = 'a[href], button, input, select, textarea, summary, [contenteditable], [role=button], [role=tab], [role=option], [tabindex]:not([tabindex="-1"])';
3923
4300
  document.addEventListener('keydown', e => {
3924
- if ((e.metaKey || e.ctrlKey) && (e.key === 'k' || e.key === 'K')) { e.preventDefault(); return pal.hidden ? openPal() : closePal(); }
3925
- if (!X || (e.target.matches && e.target.matches('input, textarea')) || e.metaKey || e.ctrlKey || e.altKey) return;
3926
- const k = e.key, inCanvas = e.composedPath().includes(X.stage);
3927
- if (pc.key(e)) {} // the pieces' keys: an example's lightbox, the details tabs, the views list, paths and reach
3928
- else if (k === 'Escape' || k === 'Backspace') {
3929
- if (!$('m-helpbox').hidden) closeHelp(); else if (!$('m-export-pop').hidden) closeExport(); else if (storiesPop && !storiesPop.hidden) closeStories(); else if (lensesPop && !lensesPop.hidden) closeLenses(); else if ($('m-vpop') && !$('m-vpop').hidden) togglePill(false);
3930
- else if (!inCanvas) X.key(e); else return;
3931
- }
3932
- else if (k === '/') openPal();
3933
- else if (k === '?') $('m-help').click();
3934
- else if (k === 'i' || k === 'I') togglePill();
3935
- else if (inCanvas || !X.key(e)) return;
3936
- e.preventDefault(); e.stopPropagation();
3937
- }, true);
3938
- addEventListener('popstate', () => { const p = new URLSearchParams(location.hash.slice(1)); if ((p.get('lens') || null) !== lensOn) pickLens(p.get('lens'), true); const v = p.get('view') || rootId; if (v !== cur) go(v, { replace: true, select: p.get('node') || undefined }); else if (p.get('node')) select(p.get('node')); else if (sel) deselect(); });
4301
+ if (!X) return;
4302
+ const k = e.key, t = e.target, top = layer(), own = () => { e.preventDefault(); e.stopPropagation(); };
4303
+ if (top === 'lightbox') { if (pc.key(e)) own(); return; } // it holds every key while open: Esc closes it, Tab keeps to it, Enter and Space press its buttons
4304
+ if ((e.metaKey || e.ctrlKey) && (k === 'k' || k === 'K')) { own(); return top === 'search' ? closePal(true) : openPal(); }
4305
+ if (e.metaKey || e.ctrlKey || e.altKey || (t && t.matches && t.matches('input, textarea, select, [contenteditable]'))) return; // the browser's own shortcuts; typing
4306
+ const inCanvas = e.composedPath().includes(X.stage), row = KEYS.find(r => r[2] && r[2].includes(k));
4307
+ if (top === 'search' && k !== 'Escape' && k !== 'Backspace') return; // search covers the map: its field and hits take their own keys
4308
+ if (row) { if (row[3](e, inCanvas) !== false) own(); return; }
4309
+ if (inCanvas || (t && t.closest && t.closest(CONTROL) && /^(Enter| |Arrow|Page|Home|End)/.test(k))) return; // the canvas's own keys; a control's own
4310
+ if (X.key(e)) own(); // the map's keys, from anywhere else on the page
4311
+ }, { capture: true, signal });
4312
+ addEventListener('popstate', () => follow(), { signal }); // Back, Forward, or an edited link
3939
4313
 
3940
4314
  /* export: resolve CSS variables into attributes so the file stands alone */
3941
4315
  const standalone = scale => X.standalone(scale);
3942
4316
  if (Q.get('still')) { const sv = Q.get('still'), sc = +(Q.get('scale') || 2);
3943
- const tick = async () => { if (!geo || !V[sv]) return setTimeout(tick, 100); if (cur !== sv) { await go(sv, {}); return setTimeout(tick, 200); }
4317
+ const tick = async () => { if (!geo) return setTimeout(tick, 100);
4318
+ if (!V[sv]) return document.documentElement.setAttribute('data-still', JSON.stringify({ view: sv, error: `the model has no view "${sv}"` })); // an answer, not a wait: no still comes
4319
+ if (cur !== sv) { await go(sv, {}); return setTimeout(tick, 200); }
3944
4320
  X.stage.querySelectorAll('.flowline').forEach(e => e.remove()); const out = standalone(sc), b = geo.bounds; document.documentElement.setAttribute('data-still', JSON.stringify({ view: sv, w: Math.ceil(b.w * sc), h: Math.ceil(b.h * sc) }));
3945
4321
  document.body.innerHTML = '<div id="still">' + out + '</div>'; document.body.style.margin = '0'; };
3946
4322
  setTimeout(tick, 300); }
@@ -3957,14 +4333,15 @@ export function mountPage(container, model, options = {}) {
3957
4333
  }
3958
4334
 
3959
4335
 
3960
- let flowPref = null; try { flowPref = localStorage.getItem('visual-flow'); } catch (_) {}
4336
+ let flowPref = null; try { flowPref = reducedMotion() ? null : localStorage.getItem('visual-flow'); } catch (_) {} // the reader's ≋ and F; under reduced motion no flow is drawn
3961
4337
  const p0 = new URLSearchParams(location.hash.slice(1));
3962
4338
  const v0 = V[p0.get('view')] ? p0.get('view') : rootId;
3963
4339
  const handle = render($('m-canvas'), model, {
3964
4340
  view: v0, minimap: true, flow: flowPref ? flowPref === '1' : undefined,
3965
- 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)
4341
+ on: { story: e => { told = e.step == null ? null : { id: e.story, step: e.step }; app.classList.toggle('story', !!told); pc.story(e);
4342
+ const sv = told && stepView(told); if (X && !X.busy() && (!sv || sv === cur || !V[sv])) write(); } }, // a step on another view: in that view's entry, once it shows (#69)
3966
4343
  _page: {
3967
- instant: options.instant, defer: !INSTANT, tour: !(Q.get('still') || Q.get('pdf')), openHint: !Q.get('reader'), kinds: true, status, tourAt: Q.get('shot') ? Q.get('at') : null,
4344
+ instant: options.instant, defer: !options.instant, tour: !(Q.get('still') || Q.get('pdf')), openHint: !Q.get('reader'), kinds: true, status, tourAt: Q.get('shot') ? Q.get('at') : null,
3968
4345
  wide: () => innerWidth > 820,
3969
4346
  upTarget: () => stack.length > 1 ? stack[stack.length - 2] : (V[cur] || {}).parent,
3970
4347
  inset: () => { const top = Math.max(58, 26 + $('m-vpill').offsetHeight); if (innerWidth <= 820) return { t: top, r: 12, b: 12, l: 12 }; const lg = $('m-legend'), on = lg && !lg.hidden; // the legend box sits bottom left
@@ -3977,46 +4354,160 @@ export function mountPage(container, model, options = {}) {
3977
4354
  renderChrome(); },
3978
4355
  onBackground: () => togglePill(false),
3979
4356
  onFlow: on => { try { localStorage.setItem('visual-flow', on ? '1' : '0'); } catch (_) {} },
3980
- onReady: () => {
3981
- $('m-ghosts').remove();
3982
- const n0 = p0.get('node') || (Q.get('shot') && cur !== rootId ? subjectOf(V[cur]) || (V[cur].nodeIds || [])[0] : null);
3983
- if (n0 && geo.byId[n0]) select(n0, { reveal: false });
3984
- 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)
3985
- if (p0.get('lens')) pickLens(p0.get('lens'), true); // a link's lens (#94)
3986
- setHash(true);
3987
- },
4357
+ onReady: () => { $('m-ghosts').remove(); queueMicrotask(() => follow(true)); }, // the link it opened with: its card, story step and lens (#69, #94), once the page listens
3988
4358
  },
3989
4359
  });
3990
- handle.on('select', ({ target, targets }) => { sel = target && target.type === 'node' ? target.id : null; pc.select(target, targets); setHash(true); recordSoon(); });
3991
- handle.on('viewchange', () => { pc.unlight(); setHash(replaceNext); replaceNext = false; recordSoon(); });
3992
- /* #pdf=1[&views=a,pic-*] (umlimator.py pdf, #93): the page becomes a document for Chrome to print, A4 across. A title
3993
- page; each view on a page of its own, its picture (toSVG()'s, in vectors) as large as its describe() text below leaves
3994
- room for; then the legend of what the pictures draw, and the model's rules, each holding or broken in the views
3995
- printed, as the document says. It starts once the fonts are in, after the map's own redraw for them. */
3996
- if (Q.get('pdf')) (document.fonts ? document.fonts.ready : Promise.resolve()).then(() => {
3997
- const only = (Q.get('views') || '').split(',').filter(Boolean), want = v => !only.length || only.some(p => p.endsWith('*') ? v.id.startsWith(p.slice(0, -1)) : v.id === p);
3998
- const shown = views.filter(want).map(v => [v, X.standalone(1, v.id)]).filter(([, s]) => s), p = s => `<p>${esc(s)}</p>`;
3999
- const broken = rules(model).filter(b => shown.some(([v]) => v.id === b.view)); // the views printed, as the document says
4000
- const rs = (Array.isArray(model.rules) ? model.rules : []).filter(r => r && typeof r === 'object' && (r.from || r.to || r.noCycles)); // as rules() reads them
4001
- const W = 1030, H = 690; // ponytail: A4 landscape inside 12 mm margins, in CSS px; a paper option if Letter or portrait is wanted
4002
- document.title = model.title || 'Map'; document.body.removeAttribute('class');
4003
- document.body.innerHTML = `<style>@page { size: A4 landscape; margin: 12mm; background: var(--um-bg); } html { -webkit-print-color-adjust: exact; print-color-adjust: exact; } body { background-image: none; }
4004
- #um-pdf { width: ${W}px; font-size: 13px; line-height: 1.45; } #um-pdf section { padding: 0; } #um-pdf section + section { break-before: page; }
4005
- #um-pdf h1 { font-size: 34px; margin: 230px 0 6px; } #um-pdf h2 { display: block; font: 600 19px/1.3 var(--um-sans); letter-spacing: 0; text-transform: none; color: var(--um-text); margin: 0 0 8px; }
4006
- #um-pdf h2::after { content: none; } #um-pdf h2 ~ h2 { margin-top: 26px; } #um-pdf p { margin: 0 0 3px; } #um-pdf svg { display: block; margin: 0 0 10px; }
4007
- #um-pdf .pdf-text { columns: 2; column-gap: 32px; font-size: 12px; } #um-pdf .pdf-text p { break-inside: avoid; } #um-pdf ul { margin: 0; padding-left: 20px; }
4008
- #um-pdf .pdf-legend { display: grid; grid-template-columns: repeat(3, max-content); gap: 6px 28px; font-size: 12px; } .pdf-holds { color: var(--um-green); } .pdf-broken { color: var(--um-rose); }</style>
4009
- <main id="um-pdf"><section><h1>${esc(model.title || 'Map')}</h1>${p(new Date(Date.now() - new Date().getTimezoneOffset() * 6e4).toISOString().slice(0, 10))}</section>
4010
- ${shown.map(([v, svg]) => `<section class="pdf-view"><h2>${esc(v.title || v.id)}</h2>${svg}<div class="pdf-text">${describe(model, v.id).split('\n').map(p).join('')}</div></section>`).join('')}
4011
- <section><h2>Legend</h2><div class="pdf-legend">${legendItems(shown.map(([v]) => X.ctx().geoCache[v.id].g)).join('')}</div>
4012
- <h2>Rules</h2>${rs.length ? `${p('Judged in the views printed here.')}<ul>${rs.map(r => { const b = broken.filter(x => x.rule === r.id);
4013
- return `<li>${esc(r.title || r.id)}: <b class="${b.length ? 'pdf-broken' : 'pdf-holds'}">${b.length ? 'broken' : 'holds'}</b>${b.length ? `<ul>${b.map(x => `<li>${esc(x.message)}</li>`).join('')}</ul>` : ''}</li>`; }).join('')}</ul>` : p('The model has no rules.')}</section></main>`;
4014
- document.querySelectorAll('#um-pdf .pdf-view svg').forEach(svg => { const b = svg.viewBox.baseVal, room = H - (svg.parentNode.offsetHeight - svg.getBoundingClientRect().height);
4015
- const k = Math.min(1, W / b.width, Math.max(240, room) / b.height); svg.setAttribute('width', b.width * k); svg.setAttribute('height', b.height * k); });
4016
- });
4360
+ const destroy0 = handle.destroy;
4361
+ handle.destroy = () => { ac.abort(); if (rec) clearTimeout(rec.t); destroy0(); }; // the page's listeners go with the map
4362
+ handle.on('select', ({ target, targets }) => { sel = target && target.type === 'node' ? target.id : null; pc.select(target, targets); if (!X.busy()) write(); recordSoon(); }); // mid-move: the view's entry, once it lands
4363
+ handle.on('viewchange', e => { pc.unlight(); recordSoon();
4364
+ if (linking && linking.view === e.view) return; // a link's own move: its entry, written once the link has settled
4365
+ linking = null; write(true); }); // a new view, a new entry (a move the reader started while a link was followed, too)
4366
+ handle.on('lens', e => { lensOn = e.lens; showLens(); write(); });
4367
+ // #pdf=1 (umlimator.py pdf, #93): the page becomes a document for Chrome to print (printDoc), once the fonts are in,
4368
+ // after the map's own redraw for them
4369
+ if (Q.get('pdf')) (document.fonts ? document.fonts.ready : Promise.resolve()).then(() => printDoc(X, model, Q));
4017
4370
  return handle;
4018
4371
  }
4019
4372
 
4373
+ /* #pdf=1[&views=a,pic-*][&paper=letter] (umlimator.py pdf): the map as a document to hand in for a review, laid out here a
4374
+ page at a time, as Chrome's own page breaks can't be read back for the contents. Each sheet is one page: @page sets the
4375
+ paper (A4 or US Letter, across) and its margins, and each sheet ends in the title and "page N of M".
4376
+ - The cover: the title, the date, the model's file, a summary line and the contents, each view with its page.
4377
+ - Each view, from a page of its own: a header (the view's path and its caption), its picture (toSVG()'s, in vectors)
4378
+ as large as the page allows and never larger than drawn, the legend of what it draws, then tables of its cards,
4379
+ lines and boxes. They fill two columns below the picture, or one column beside a picture too tall to leave room
4380
+ below. A table that runs out of room goes on in the next column, under its heading and header row again; a row is
4381
+ never cut, and a heading never ends a column.
4382
+ - The rules, when the model has any: each holding or broken in the views printed, and what breaks it. */
4383
+ function printDoc(X, model, Q) {
4384
+ const { D, views, V, N, chain, nodeIn, viewEdges, geoCache } = X.ctx();
4385
+ const only = (Q.get('views') || '').split(',').filter(Boolean), want = v => !only.length || only.some(p => p.endsWith('*') ? v.id.startsWith(p.slice(0, -1)) : v.id === p);
4386
+ const shown = views.filter(want).map(v => [v, X.standalone(1, v.id)]).filter(([, s]) => s), on = new Set(shown.map(([v]) => v.id));
4387
+ const rs = shown.length ? arrOf(model.rules).filter(r => r && typeof r === 'object' && (r.from || r.to || r.noCycles)) : []; // as rules() reads them; none without a view
4388
+ const broken = rules(model).filter(b => on.has(b.view)); // judged in the views printed, as the document says
4389
+ const [pw, ph] = Q.get('paper') === 'letter' ? [279.4, 215.9] : [297, 210], title = esc(D.title || 'Map'); // the paper across, in mm
4390
+ const count = (k, w, ws = w + 's') => `${k} ${k === 1 ? w : ws}`, T = v => esc(v.title || v.id), full = c => Object.assign(c, { full: true });
4391
+ const cards = v => arrOf(v.nodeIds).filter(i => N[i]), lines = v => viewEdges(v).filter(e => e.from !== e.to), boxes = v => arrOf(v.containers).filter(c => c && c.id); // as describe() reads a view
4392
+ const name = (v, id) => esc(N[id] ? nodeIn(id, v.id).label || id : (boxes(v).find(c => c.id === id) || {}).label || id);
4393
+ document.title = D.title || 'Map'; document.body.removeAttribute('class');
4394
+ document.body.innerHTML = `<style>@page { size: ${pw}mm ${ph}mm; margin: 11mm 13mm 9mm; background: var(--um-bg); }
4395
+ html, body { -webkit-print-color-adjust: exact; print-color-adjust: exact; background: var(--um-bg); } body { margin: 0; background-image: none; }
4396
+ #um-pdf { color: var(--um-text); font: 8.5pt/1.4 var(--um-sans); font-variant-numeric: tabular-nums; }
4397
+ #um-pdf .sheet { width: ${pw - 26}mm; height: ${ph - 20.5}mm; padding: 0; display: flex; flex-direction: column; overflow: hidden; break-after: page; }
4398
+ #um-pdf .sheet:last-child { break-after: auto; }
4399
+ #um-pdf header { display: flex; align-items: baseline; justify-content: space-between; gap: 16pt; padding-bottom: 5pt; border-bottom: .75pt solid var(--um-border-2); }
4400
+ #um-pdf h1 { font-size: 26pt; line-height: 1.12; letter-spacing: -.02em; font-weight: 700; margin: 0 0 10pt; }
4401
+ #um-pdf h2, #um-pdf .h2 { display: block; font: 650 14pt/1.25 var(--um-sans); letter-spacing: -.01em; text-transform: none; color: var(--um-text); margin: 0; }
4402
+ #um-pdf h2::after { content: none; } #um-pdf .up { color: var(--um-muted); font-weight: 450; }
4403
+ #um-pdf .meta { color: var(--um-muted); font-size: 8pt; white-space: nowrap; } #um-pdf .cap { color: var(--um-muted); font-size: 9pt; margin: 4pt 0 0; }
4404
+ #um-pdf .body { flex: 1; min-height: 0; display: flex; flex-direction: column; gap: 8px; margin-top: 8pt; } #um-pdf .body.side { flex-direction: row; gap: 16pt; }
4405
+ #um-pdf figure { flex: none; align-self: center; margin: 0; border: 1px solid var(--um-border); border-radius: 4pt; overflow: hidden; }
4406
+ #um-pdf .side figure { align-self: flex-start; } #um-pdf figure svg { display: block; }
4407
+ #um-pdf .lg { flex: none; display: flex; flex-wrap: wrap; align-items: center; gap: 3pt 14pt; font-size: 7.5pt; } #um-pdf .lg > b { font-weight: 650; }
4408
+ #um-pdf .side .lg { margin-bottom: 10pt; }
4409
+ #um-pdf .cols { flex: 1; min-height: 0; display: grid; grid-template-columns: repeat(var(--n), minmax(0, 1fr)); grid-template-rows: minmax(0, 1fr); gap: 18pt; }
4410
+ #um-pdf .col { min-height: 0; overflow: hidden; } #um-pdf .blk + .blk { margin-top: 10pt; }
4411
+ #um-pdf .h3 { font-size: 9pt; font-weight: 650; margin: 0 0 3pt; } #um-pdf .h3 span { color: var(--um-muted); font-weight: 450; }
4412
+ #um-pdf table { width: 100%; border-collapse: collapse; table-layout: fixed; font-size: 8pt; line-height: 1.35; }
4413
+ #um-pdf th { text-align: left; font-size: 7pt; font-weight: 600; color: var(--um-muted); padding: 0 6pt 2.5pt 0; border-bottom: .75pt solid var(--um-border-2); }
4414
+ #um-pdf td { vertical-align: top; padding: 2.5pt 6pt 2.5pt 0; border-bottom: .5pt solid var(--um-border); overflow-wrap: anywhere; }
4415
+ #um-pdf td b { font-weight: 600; } #um-pdf td small { display: block; color: var(--um-muted); font: 7pt/1.3 var(--um-mono); }
4416
+ #um-pdf td p { margin: 0; } #um-pdf td ul { margin: 0; padding-left: 10pt; }
4417
+ #um-pdf td i { display: inline-block; font-style: normal; margin-right: 4pt; } #um-pdf i.sw { width: 8pt; height: 6pt; border: 1pt solid; border-radius: 1.5pt; }
4418
+ #um-pdf i.w { width: 10pt; border-top: 1.5pt solid; vertical-align: middle; }
4419
+ #um-pdf i.num { min-width: 10pt; height: 10pt; padding: 0 2pt; border-radius: 5pt; background: var(--um-text); color: var(--um-bg); font: 700 6.5pt/10pt var(--um-sans); text-align: center; }
4420
+ #um-pdf .cards th:nth-child(1), #um-pdf .boxes th:nth-child(1) { width: 32%; } #um-pdf .cards th:nth-child(2) { width: 20%; }
4421
+ #um-pdf .lines th.n { width: 6%; } #um-pdf .lines th:nth-last-child(3) { width: 42%; } #um-pdf .lines th:last-child { width: 24%; }
4422
+ #um-pdf .rules th:nth-child(1) { width: 30%; } #um-pdf .rules th:nth-child(2) { width: 24%; } #um-pdf .rules th:nth-child(3) { width: 10%; }
4423
+ #um-pdf .ok { color: var(--um-green); font-weight: 650; } #um-pdf .no { color: var(--um-rose); font-weight: 650; }
4424
+ #um-pdf .cover .body { flex-direction: row; gap: 28pt; margin-top: 0; } #um-pdf .cover .ttl { flex: 0 0 40%; padding-top: 34mm; } #um-pdf .cover .cols { padding-top: 34mm; }
4425
+ #um-pdf .sum { font-size: 10pt; margin: 6pt 0 0; } #um-pdf .toc td { padding: 4pt 6pt 4pt 0; } #um-pdf .toc td.pg { width: 3em; padding-right: 0; text-align: right; }
4426
+ #um-pdf .toc td span { display: block; color: var(--um-muted); font-size: 7.5pt; }
4427
+ #um-pdf footer { display: flex; justify-content: space-between; margin-top: 6pt; padding: 5pt 0 0; border-top: .5pt solid var(--um-border); color: var(--um-dim); font: 7pt var(--um-sans); }</style><main id="um-pdf"></main>`;
4428
+ const doc = document.getElementById('um-pdf');
4429
+ // a sheet: its header, the body its content fills, and its footer (`key` names the view or section it starts)
4430
+ const sheet = (head, key = '') => { doc.insertAdjacentHTML('beforeend', `<section class="sheet" data-key="${key}">${head}<div class="body"></div><footer><span>${title}</span><span class="pn"></span></footer></section>`);
4431
+ return doc.lastElementChild.querySelector('.body'); };
4432
+ const cols = (body, n) => { body.insertAdjacentHTML('beforeend', `<div class="cols" style="--n:${n}">${'<div class="col"></div>'.repeat(n)}</div>`); return [...body.lastElementChild.children]; };
4433
+ // tables into columns, next() handing out the next column (null: no more, and pour() says false). A table after the
4434
+ // first starts a column of its own while its page has one left. It starts with its heading, header row and first two
4435
+ // rows (its only one), else in the next column; a row that does not fit goes on, whole, in the next, never the last one
4436
+ // alone. A column handed out fresh on a page of its own (`full`) takes a table's start whatever its size.
4437
+ const pour = (next, blocks) => { let box = next();
4438
+ const over = () => box.lastElementChild.getBoundingClientRect().bottom > box.getBoundingClientRect().bottom - 2, fresh = () => !box.firstElementChild;
4439
+ for (const [n, b] of blocks.entries()) { let tb = null;
4440
+ if (n && !fresh() && next.spare()) box = next();
4441
+ const open = (i, k, force) => { box.insertAdjacentHTML('beforeend', `<div class="blk">${b.title ? `<div class="h3">${b.title}${i ? ' <span>(continued)</span>' : ''}</div>` : ''}<table class="${b.cls}">${b.head ? `<thead><tr>${b.head}</tr></thead>` : ''}<tbody>${b.rows.slice(i, i + k).join('')}</tbody></table></div>`);
4442
+ if (force || !over()) return box.lastElementChild.querySelector('tbody'); box.lastElementChild.remove(); };
4443
+ for (let i = 0; i < b.rows.length;) {
4444
+ if (!tb) { const k = Math.min(2, b.rows.length - i);
4445
+ while (!(tb = open(i, k)) && !(fresh() && box.full)) if (!(box = next())) return false;
4446
+ tb ||= open(i, 1) || open(i, 1, true); // ponytail: a row taller than a page is cut at its foot
4447
+ i += tb.rows.length; continue; }
4448
+ tb.insertAdjacentHTML('beforeend', b.rows[i]);
4449
+ if (!over()) { i++; continue; }
4450
+ tb.lastElementChild.remove();
4451
+ if (i === b.rows.length - 1 && tb.rows.length > 2) { tb.lastElementChild.remove(); i--; }
4452
+ if (!(box = next())) return false; tb = null;
4453
+ } }
4454
+ return true; };
4455
+ // the columns of a page, then (paging) of as many pages as it takes, or (no `head`) none more
4456
+ const paging = (head, n, first) => { const q = [...first], f = () => q.shift() || (head ? (q.push(...cols(sheet(head(true)), n).map(full)), q.shift()) : null);
4457
+ f.spare = () => q.length > 0; return f; };
4458
+
4459
+ // the cover, and its contents: a view's caption under its title, indented under the views printed that hold it
4460
+ const day = new Date(Date.now() - new Date().getTimezoneOffset() * 6e4).toISOString().slice(0, 10);
4461
+ const sum = [count(shown.length, 'view'), count(new Set(shown.flatMap(([v]) => cards(v))).size, 'card'), count(new Set(shown.flatMap(([v]) => lines(v).map(e => e._id))).size, 'line'), ...(rs.length ? [count(rs.length, 'rule')] : [])];
4462
+ const cover = sheet('', 'cover'); cover.parentNode.classList.add('cover');
4463
+ cover.insertAdjacentHTML('beforeend', `<div class="ttl"><h1>${title}</h1><p class="cap">${day}${D.source ? ` · <span class="mono">${esc(D.source)}</span>` : ''}</p><p class="sum">${sum.join(' · ')}</p></div>`);
4464
+ pour(paging(() => '<header><div class="h2">Contents <span class="up">(continued)</span></div></header>', 2, cols(cover, 1)), [{ title: 'Contents', cls: 'toc', rows: [
4465
+ ...shown.map(([v], i) => `<tr><td style="padding-left:${chain(v.id).slice(0, -1).filter(id => on.has(id)).length * 12}pt">${T(v)}${v.caption ? `<span>${esc(v.caption)}</span>` : ''}</td><td class="pg" data-pg="${i}"></td></tr>`),
4466
+ ...(rs.length ? ['<tr><td>Rules</td><td class="pg" data-pg="rules"></td></tr>'] : [])] }]);
4467
+
4468
+ shown.forEach(([v, svg], vi) => {
4469
+ const g = geoCache[v.id].g, ids = cards(v), es = lines(v), seq = v.render === 'sequence', at = Object.fromEntries(g.nodes.map(n => [n.id, n]));
4470
+ const wk = new Map(g.edges.flatMap(d => edgeRefIds(d).map(id => [id, d.wk]))), held = boxes(v).map(c => [c, arrOf(c.nodeIds).filter(i => ids.includes(i))]).filter(([, ins]) => ins.length);
4471
+ const up = chain(v.id).slice(0, -1).map(id => `<span class="up">${T(V[id])} › </span>`).join('');
4472
+ const head = more => `<header>${more ? `<div class="h2">${up}${T(v)} <span class="up">(continued)</span></div>` : `<h2>${up}${T(v)}</h2>`}<span class="meta">${[count(ids.length, 'card'), count(es.length, 'line'), ...(held.length ? [count(held.length, 'box', 'boxes')] : [])].join(' · ')}</span></header>${!more && v.caption ? `<p class="cap">${esc(v.caption)}</p>` : ''}`;
4473
+ const body = sheet(head(false), vi);
4474
+ body.insertAdjacentHTML('beforeend', `<figure>${svg}</figure><div class="lg"><b>Legend</b>${legendItems([g], 'opens a view of its own').join('')}</div>`);
4475
+ // the picture as large as the page allows, never larger than drawn: below it the legend, then two columns of tables;
4476
+ // one too tall for that keeps the page's height, with the legend and one column beside it. Smaller (down to 70%, and
4477
+ // to a scale of 0.6 at least) when that keeps the tables on its page; else they go on over the next pages.
4478
+ const fig = body.firstElementChild, lg = fig.nextElementSibling, s = fig.firstElementChild, b = s.viewBox.baseVal, W = body.clientWidth - 2, H = body.clientHeight - 2, SIDE = 340;
4479
+ let k = Math.min(1, W / b.width, (H - lg.offsetHeight - 16) / b.height); const side = b.width * k <= W - SIDE;
4480
+ if (side) k = Math.min(1, H / b.height, (W - SIDE) / b.width);
4481
+ body.classList.toggle('side', side);
4482
+ const lay = (f, more) => { s.setAttribute('width', b.width * k * f); s.setAttribute('height', b.height * k * f); body.querySelector('.cols')?.remove();
4483
+ const first = cols(body, side ? 1 : 2); if (side) first[0].append(lg); return pour(paging(more && head, 2, first), tables); };
4484
+ const tables = [
4485
+ { title: `Cards <span>${ids.length}</span>`, cls: 'cards', head: '<th>Card</th><th>Kind</th><th>What it is</th>', rows: ids.map(id => { const c = nodeIn(id, v.id), t = KIND[kindOf(c.kind, true)], d = at[id] || {}, sub = c.sublabel ?? c.sub;
4486
+ return `<tr><td>${d.step ? `<i class="num">${esc(d.step)}</i>` : ''}<b>${esc(c.label || id)}</b>${d.kids || d.opens ? ' ⤢' : ''}${sub ? `<small>${esc(sub)}</small>` : ''}</td>`
4487
+ + `<td><i class="sw" style="border-color:var(--um-${t[0]});background:var(--um-${t[0]}-f)"></i>${t[1]}</td><td>${c.what ? md(c.what) : ''}</td></tr>`; }) },
4488
+ { title: `Lines <span>${es.length}</span>`, cls: 'lines', head: `${seq ? '<th class="n">#</th>' : ''}<th>From → to</th><th>Label</th><th>Technology</th>`, rows: es.map((e, i) => { const w = wk.get(e._id) || 'calls';
4489
+ return `<tr>${seq ? `<td>${i + 1}</td>` : ''}<td><i class="w" style="border-color:var(--um-w-${w})"></i>${name(v, e.from)} → ${name(v, e.to)}</td><td>${e.label ? esc(e.label) : `<span class="up">${w}</span>`}</td><td>${esc(e.sublabel || '')}</td></tr>`; }) },
4490
+ { title: `Boxes <span>${held.length}</span>`, cls: 'boxes', head: '<th>Box</th><th>Holds</th>', rows: held.map(([c, ins]) => `<tr><td><b>${esc(c.label || c.id)}</b></td><td>${andList(ins.map(i => name(v, i)))}</td></tr>`) },
4491
+ ].filter(x => x.rows.length);
4492
+ [1, .9, .8, .7].some(f => k * f >= Math.min(k, .6) && lay(f)) || lay(1, true);
4493
+ });
4494
+
4495
+ if (rs.length) { // the rules: where rules() judges each (a rule that names boxes, in the views that draw one; `views` narrows it)
4496
+ // ponytail: rules()' own scope, again; one helper for both if it changes
4497
+ const anyBox = new Set(views.flatMap(v => boxes(v).map(c => c.id))), printed = shown.map(([v]) => v);
4498
+ const where = r => { const named = [r.from, r.to].flatMap(s => boxesNamed(s, id => anyBox.has(id) && !N[id]));
4499
+ const vs = printed.filter(v => (!Array.isArray(r.views) || r.views.includes(v.id)) && (!named.length || boxes(v).some(c => named.includes(c.id))));
4500
+ return vs.length === printed.length ? 'every view printed' : vs.length ? vs.map(T).join(', ') : 'no view printed'; };
4501
+ const said = (x, r) => x.message.slice(`view ${x.view}: `.length).replace(`, which breaks rule "${r.title || r.id}"`, '').replace(` breaks rule "${r.title || r.id}"`, '').replace(/^edge /, ''); // its message's words: A→B, or the boxes in a cycle
4502
+ const head = more => `<header>${more ? '<div class="h2">Rules <span class="up">(continued)</span></div>' : '<h2>Rules</h2>'}<span class="meta">${count(rs.length, 'rule')} · ${new Set(broken.map(x => x.rule)).size} broken</span></header>${more ? '' : '<p class="cap">Judged in the views printed here.</p>'}`;
4503
+ pour(paging(head, 1, cols(sheet(head(false), 'rules'), 1).map(full)), [{ cls: 'rules', head: '<th>Rule</th><th>Applies in</th><th>State</th><th>Lines that break it</th>', rows: rs.map(r => { const bs = broken.filter(x => x.rule === r.id);
4504
+ return `<tr><td><b>${esc(r.title || r.id)}</b></td><td>${where(r)}</td><td class="${bs.length ? 'no' : 'ok'}">${bs.length ? 'broken' : 'holds'}</td><td>${bs.map(x => `<div>${T(V[x.view])}: ${esc(said(x, r))}</div>`).join('') || '<span class="up">none</span>'}</td></tr>`; }) }]);
4505
+ }
4506
+ const all = [...doc.children]; // the pages, numbered, and the contents' page of each view
4507
+ all.forEach((p, i) => { p.querySelector('.pn').textContent = `page ${i + 1} of ${all.length}`; });
4508
+ doc.querySelectorAll('[data-pg]').forEach(td => { td.textContent = all.findIndex(p => p.dataset.key === td.dataset.pg) + 1; });
4509
+ }
4510
+
4020
4511
  /* ================= the standalone page's pieces (#99): views list, legend, hint line, details panel =================
4021
4512
  mountPage draws all four in its page; render() draws those a host asks for (nav, legend, hints, details) around its
4022
4513
  canvas, in its shadow root. The hint line is hintHTML() and hinter(); pieces() draws the others from the map and acts
@@ -4024,8 +4515,8 @@ export function mountPage(container, model, options = {}) {
4024
4515
  o.page: the standalone page, which keeps the reader's choices in localStorage; o.frame takes the class tree-open while
4025
4516
  the views list is open, and o.toggle says so; o.layer holds an example's lightbox; o.status(text, ms) says what happened. */
4026
4517
  const shortT = t => String(t || '').split(' · ')[0];
4027
- const hintHTML = keys => typeof matchMedia === 'function' && matchMedia('(hover: none)').matches ? '<span>pinch = zoom</span><span>drag = move</span><span>tap = details</span>'
4028
- : `<span>scroll = zoom</span><span>drag = move</span><span>double-click ⤢ = open</span>${keys ? `<span>${keys} = search</span><span>? = keys</span>` : ''}`; // keys: the page's search key
4518
+ const hintHTML = (keys, ctrl) => typeof matchMedia === 'function' && matchMedia('(hover: none)').matches ? '<span>pinch = zoom</span><span>drag = move</span><span>tap = details</span>'
4519
+ : `<span>${ctrl ? 'Ctrl + scroll' : 'scroll'} = zoom</span><span>drag = move</span><span>double-click ⤢ = open</span>${keys ? `<span>${keys} = search</span><span>? = keys</span>` : ''}`; // keys: the page's search key; ctrl: wheel 'ctrl'
4029
4520
  function hinter(el, html) { let t; return (s, ms = 1800) => { el.textContent = s; el.classList.add('msg'); clearTimeout(t); t = setTimeout(() => { el.innerHTML = html; el.classList.remove('msg'); }, ms); }; }
4030
4521
 
4031
4522
  // Pull concise points from captured prose; retain wording and keep the full point in its tooltip. A point is a sentence
@@ -4048,12 +4539,12 @@ function detailBullets(body) {
4048
4539
  return parts;
4049
4540
  }
4050
4541
 
4051
- function legendItems(gs) { // what these pictures draw, each with what it means: the view's legend, and the PDF's (#93)
4542
+ function legendItems(gs, opens = 'double-click to open') { // what these pictures draw, each with what it means: the view's legend, and the PDF's (#93)
4052
4543
  const ns = gs.flatMap(g => g.nodes), es = gs.flatMap(g => g.edges), used = [...new Set(ns.map(n => n.k))];
4053
4544
  const order = ['m-public', 'm-step', 'm-lane', 'm-ext', 'm-verb', 'm-board', 'm-hook', 'm-script', 'frontend', 'backend', 'ai', 'queue', 'database', 'security', 'cloud', 'external', 'file'];
4054
4545
  const shape = k => k === 'm-verb' ? 'p' : (k === 'm-ext' || k === 'external') ? 'd' : k === 'm-hook' ? 'h' : '';
4055
4546
  return order.filter(k => used.includes(k)).map(k => `<span class="lgi" title="${esc(KIND[k][1] + ' — ' + KIND[k][2])}"><i class="${shape(k)}" style="border-color:var(--um-${KIND[k][0]});background:var(--um-${KIND[k][0]}-f)${k === 'm-hook' ? `;background:var(--um-${KIND[k][0]})` : ''}"></i><b>${KIND[k][1]}</b><span>${KIND[k][2]}</span></span>`)
4056
- .concat(ns.some(n => n.kids || n.opens) ? ['<span class="lgi"><i class="s"></i><b>⤢ Stacked</b><span>double-click to open</span></span>'] : [])
4547
+ .concat(ns.some(n => n.kids || n.opens) ? [`<span class="lgi"><i class="s"></i><b>⤢ Stacked</b><span>${opens}</span></span>`] : [])
4057
4548
  .concat(ns.some(n => n.step) ? ['<span class="lgi"><i class="num">1</i><b>Step</b><span>run order</span></span>'] : [])
4058
4549
  .concat(ns.some(n => n.tag) ? ['<span class="lgi"><i class="tag"></i><b>@author</b><span>who wrote it</span></span>'] : [])
4059
4550
  .concat(WIRES.filter(w => es.some(e => e.wk === w[0])).map(w => `<span class="lgi"><i class="w" style="border-color:var(--um-w-${w[0]})"></i><b>${w[1]}</b><span>${w[2]}</span></span>`))
@@ -4063,14 +4554,17 @@ function legendItems(gs) { // what these pictures draw, each with what it means:
4063
4554
  function pieces(X, el, o) {
4064
4555
  const kept = k => { try { return o.page ? localStorage.getItem(k) : null; } catch (_) { return null; } };
4065
4556
  const keep = (k, v) => { try { if (o.page) localStorage.setItem(k, v); } catch (_) {} };
4066
- let sel = null, picked = [], lit = null, lb = null, folded = kept('visual-legend') === '0', tab = kept('visual-tab') || 'when', TABS = ['when', 'what', 'how', 'links'];
4557
+ // want: the tab the reader chose, kept; tab: the one shown, which is want when the card has it (0.14.0)
4558
+ let sel = null, picked = [], lit = null, lb = null, lbBack = null, folded = kept('visual-legend') === '0', want = kept('visual-tab') || 'when', tab = want, TABS = ['when', 'what', 'how', 'links'];
4559
+ let told = null; // the story's step shown: { id, step, pointer: the card its own select names, pending: that select is still to come }
4067
4560
 
4068
4561
  /* the legend: the kinds of cards and lines the view draws, in a box that folds away */
4069
4562
  function legend() {
4070
- const items = legendItems([X().geo()]);
4563
+ const items = legendItems([X().geo()]), back = el.legend.contains(el.legend.getRootNode().activeElement);
4071
4564
  el.legend.hidden = !items.length;
4072
4565
  el.legend.classList.toggle('folded', folded);
4073
4566
  el.legend.innerHTML = `<div class="t"><span>LEGEND</span><button type="button" id="m-lfold" aria-label="${folded ? 'Show' : 'Hide'} legend" aria-expanded="${!folded}">${folded ? '+' : '–'}</button></div>${items.join('')}`;
4567
+ if (back) el.legend.querySelector('#m-lfold').focus(); // a fold by keyboard keeps focus on the button
4074
4568
  }
4075
4569
  /* the views list: readable columns; a declared order may include shortcuts to descendants */
4076
4570
  function tree() {
@@ -4129,7 +4623,7 @@ function pieces(X, el, o) {
4129
4623
  const exs = Array.isArray(n.examples) ? n.examples.filter(x => x && (x.thumb || x.href)) : [];
4130
4624
  const tabs = [['when', 'When to use'], ['what', 'What it is'], ['how', 'How it works'], ...(exs.length ? [['examples', 'Example']] : []), ['links', 'Behind the scenes']];
4131
4625
  TABS = tabs.map(t => t[0]);
4132
- if (!TABS.includes(tab)) tab = 'when';
4626
+ tab = TABS.includes(want) ? want : 'when'; // a card without the chosen tab shows its first; the choice stays for the next card
4133
4627
  const text = (h4, body, key) => body ? `<h4>${h4}</h4><ul class="detail-bullets">${detailBullets(body).map(b => `<li title="${esc(b)}">${inline(b)}</li>`).join('')}</ul>` : `<h4>${h4}</h4><div class="pnone">Not written yet — add <code>${key}</code> to this node in the map model.</div>`;
4134
4628
  const verbs = (n.verbs || []).length ? `<div class="pverbs">${n.verbs.map(v => { const vid = N[nid + ':' + v] ? nid + ':' + v : null; return `<${vid ? `button data-n="${esc(vid)}"` : 'span'} class="pverb" style="--c:${c}">${esc(v)}</${vid ? 'button' : 'span'}>`; }).join('')}</div>` : '';
4135
4629
  const srcHtml = srcs.length ? '<ul class="source-list">' + srcs.map(s => `<li class="src">${srcHref(s) ? `<a href="${esc(srcHref(s))}" title="Open ${esc(sb ? srcLabel(s) : base + s)}"${sb ? ' target="_blank" rel="noopener"' : ''}>${esc(srcLabel(s))}</a>` : `<span>${esc(s)}</span>`}<button class="ibtn sm" data-copy="${esc(sb ? srcLabel(s) : base + s)}" title="Copy path" aria-label="Copy path">⧉</button></li>`).join('') + '</ul>' : '<div class="rel-none">No source listed</div>';
@@ -4141,7 +4635,7 @@ function pieces(X, el, o) {
4141
4635
  links: `<div class="links"><div><h4>← Comes from</h4>${relRows(ups, 'up')}</div><div><h4>→ Leads to</h4>${relRows(downs, 'down')}</div>
4142
4636
  <div><h4>Source files</h4>${srcHtml}${vIn.length ? `<h4 style="margin-top:10px">Also shown in</h4><ul class="pviews">${vIn.map(v => `<li><button class="pview" data-v="${esc(v.id)}" data-sel="${esc(nid)}">${esc(shortT(v.title || v.id))}</button></li>`).join('')}</ul>` : ''}</div></div>`,
4143
4637
  };
4144
- const was = !o.page && focused(), on = was && [...was.attributes].find(a => a.name.startsWith('data-')); // a host's panel keeps focus on the control it was on
4638
+ const was = focused(), on = was && [...was.attributes].find(a => a.name.startsWith('data-')); // a panel drawn again keeps focus on the control it was on
4145
4639
  panel.innerHTML = `<div class="ph" style="--c:${c}">
4146
4640
  ${(() => { const I = (D.icons || {})[n.icon];
4147
4641
  return I && I.src ? `<span class="big logo${I.dark ? ' dark' : ''}" aria-hidden="true"><img src="${I.src}" width="22" height="22" alt=""></span>`
@@ -4157,30 +4651,41 @@ function pieces(X, el, o) {
4157
4651
  panel.querySelectorAll('.clamp').forEach(el => { if (el.scrollHeight > el.clientHeight + 2) el.insertAdjacentHTML('afterend', '<button class="morebtn" data-more>Show all</button>'); });
4158
4652
  if (was) ((on && [...panel.querySelectorAll(`[${on.name}]`)].find(x => x.getAttribute(on.name) === on.value)) || panel.querySelector('[aria-selected="true"]')).focus();
4159
4653
  }
4160
- function hide() { const back = !o.page && focused(); el.panel.hidden = true; if (back) queueMicrotask(() => { if (el.panel.hidden) X().focus(); }); } // a host's panel gives focus back to the map
4161
- function openExample(nid, i) {
4654
+ function hide() { const back = focused(); el.panel.hidden = true; if (back) queueMicrotask(() => { if (el.panel.hidden) X().focus(); }); } // a panel that closes gives focus back to the map
4655
+ function openExample(nid, i, from = null) { // from: the control that opened it, which focus goes back to
4162
4656
  const x = ((X().ctx().nodeIn(nid, X().view()) || {}).examples || [])[i]; if (!x) return;
4163
4657
  if (!lb) { lb = h(`<div class="lightbox" id="m-lightbox" role="dialog" aria-modal="true" aria-label="Example" hidden></div>`); o.layer.appendChild(lb);
4164
4658
  lb.addEventListener('click', e => { if (e.target === lb || e.target.closest('[data-lbclose]')) closeExample(); }); }
4165
4659
  lb.innerHTML = `<figure><img src="${esc(x.full || x.thumb)}" alt="${esc(x.title || '')}"><figcaption><span>${inline(x.title || '')}</span>${x.href ? `<a href="${esc(x.href)}" target="_blank" rel="noopener">Open live page ↗</a>` : ''}<button class="ibtn" data-lbclose title="Close (Esc)" aria-label="Close">×</button></figcaption></figure>`;
4166
- lb.hidden = false; lb.querySelector('[data-lbclose]').focus();
4660
+ lbBack = from; lb.hidden = false; lb.querySelector('[data-lbclose]').focus();
4167
4661
  }
4168
- const closeExample = () => { if (lb) lb.hidden = true; };
4169
- const setTab = t => { tab = t; keep('visual-tab', t); if (sel) show(sel); };
4662
+ const closeExample = () => { if (!lb || lb.hidden) return; lb.hidden = true; // focus goes back where it came from: the thumbnail, else the panel's, else the map
4663
+ const to = lbBack && lbBack.isConnected ? lbBack : !el.panel.hidden && el.panel.querySelector('[data-ex]'); lbBack = null; if (to) to.focus(); else X().focus(); };
4664
+ const setTab = t => { want = tab = t; keep('visual-tab', t); if (sel && !el.panel.hidden) show(sel); };
4170
4665
 
4171
4666
  /* 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
4172
4667
  new selection or view clears them, and the same key again does */
4173
4668
  const cardName = id => X().ctx().nodeIn(id, X().view()).label || id, nCards = n => `${n} card${n === 1 ? '' : 's'}`;
4174
4669
  // the slowest and the costliest path (#95), offered where the view's lines have latency or cost
4175
4670
  const BY = { latency: ['⏱', 'slowest'], cost: ['¤', 'costliest'] }, budgets = () => Object.keys(BY).filter(k => X().ctx().viewEdges(X().ctx().V[X().view()]).some(e => typeof e[k] === 'number'));
4176
- 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>'
4177
- + (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>' : '')
4178
- + budgets().map(k => { const t = `Light the ${BY[k][1]} path ${picked.length > 1 ? 'between the two cards' : 'from this card'}`; return `<button class="ibtn" data-lit="${k}" title="${t}" aria-label="${t}">${BY[k][0]}</button>`; }).join('')
4179
- + '<button class="ibtn" data-lit="down" title="What if this goes down?" aria-label="What if this goes down?">⊘</button>';
4180
- function unlight() { if (lit) { lit = null; X().highlight(null); X().overlay('lit', null); } }
4181
- function light(key, refs, said, marks = null) { // marks: a layer that goes with the highlight (a path's total, the card that is down)
4671
+ const litOn = k => !!lit && lit.split(' ')[0] === k; // the button whose light is on says so (aria-pressed)
4672
+ const litButtons = () => `<button class="ibtn" data-lit="out" title="Light what it leads to (&gt;)" aria-label="Light what it leads to" aria-pressed="${litOn('out')}">→</button><button class="ibtn" data-lit="in" title="Light what leads to it (&lt;)" aria-label="Light what leads to it" aria-pressed="${litOn('in')}">←</button>`
4673
+ + (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" aria-pressed="${litOn('paths')}">⇄</button>` : '')
4674
+ + budgets().map(k => { const t = `Light the ${BY[k][1]} path ${picked.length > 1 ? 'between the two cards' : 'from this card'}`; return `<button class="ibtn" data-lit="${k}" title="${t}" aria-label="${t}" aria-pressed="${litOn(k)}">${BY[k][0]}</button>`; }).join('')
4675
+ + `<button class="ibtn" data-lit="down" title="What if this goes down?" aria-label="What if this goes down?" aria-pressed="${litOn('down')}">⊘</button>`;
4676
+ /* what is lit is the pieces' own layer, 'lit' (0.14.0): it never takes a host's highlight() or a story's away, nor they it */
4677
+ const lv = r => Math.min(1, Math.max(0, +(r.level ?? (r.distance > 0 ? Math.max(.45, 1 - .15 * r.distance) : 1)) || 0)); // as highlight() fades a Ref
4678
+ function litLayer(refs, marks) { // the cards and lines lit, the rest dimmed, with the marks that go with them (a path's total, the card that is down)
4679
+ const L = { nodes: {}, edges: {}, dim: refs.length > 0 }, put = (to, x, r) => { to[x] = { level: Math.max(lv(r), (to[x] || {}).level || 0) }; };
4680
+ refs.forEach(r => { if (r.type === 'node') put(L.nodes, r.id, r); else if (r.type === 'edge') (r.ids || []).forEach(x => put(L.edges, x, r)); });
4681
+ Object.entries((marks || {}).nodes || {}).forEach(([id, m]) => { L.nodes[id] = { ...L.nodes[id], ...m }; });
4682
+ return L;
4683
+ }
4684
+ const pressed = () => { if (el.panel) el.panel.querySelectorAll('[data-lit]').forEach(b => b.setAttribute('aria-pressed', litOn(b.dataset.lit))); };
4685
+ function unlight() { if (lit) { lit = null; X().overlay('lit', null); pressed(); } }
4686
+ function light(key, refs, said, marks = null) {
4182
4687
  if (lit === key) { unlight(); return o.status('Highlight cleared'); }
4183
- lit = key; X().highlight(refs.length ? refs : null); X().overlay('lit', marks); o.status(said, 3600);
4688
+ lit = key; X().overlay('lit', refs.length || marks ? litLayer(refs, marks) : null, [X().view()]); pressed(); o.status(said, 3600);
4184
4689
  }
4185
4690
  function lightReach(back) {
4186
4691
  if (!sel || !X().geo().byId[sel]) return;
@@ -4222,7 +4727,7 @@ function pieces(X, el, o) {
4222
4727
  const tb = t.closest('[data-tab]'); if (tb) return setTab(tb.dataset.tab);
4223
4728
  const st = t.closest('[data-step]'); if (st) return setTab(TABS[(TABS.indexOf(tab) + +st.dataset.step + TABS.length) % TABS.length]);
4224
4729
  const mb = t.closest('[data-more]'); if (mb) { mb.previousElementSibling.classList.add('open'); mb.remove(); }
4225
- const ex = t.closest('[data-ex]'); if (ex) return openExample(el.panel.dataset.node, +ex.dataset.ex);
4730
+ const ex = t.closest('[data-ex]'); if (ex) return openExample(el.panel.dataset.node, +ex.dataset.ex, ex);
4226
4731
  if (t.closest('[data-close]')) return X().select(null);
4227
4732
  const lt = t.closest('[data-lit]'); if (lt) { const k = lt.dataset.lit; return k === 'paths' ? lightPaths() : k === 'down' ? lightDown() : BY[k] ? lightCritical(k) : lightReach(k === 'in'); }
4228
4733
  const ob = t.closest('[data-open]'); if (ob) return X().open(ob.dataset.open); // the open event first, as a double-click on the card
@@ -4232,17 +4737,37 @@ function pieces(X, el, o) {
4232
4737
  const cp = t.closest('[data-copy]'); if (cp) navigator.clipboard && navigator.clipboard.writeText(cp.dataset.copy).then(() => o.status('Path copied'), () => o.status(cp.dataset.copy));
4233
4738
  });
4234
4739
 
4740
+ /* a story's step points at a card (0.14.0): the select its step makes is a pointer, which opens no details; a card the
4741
+ reader picks opens them, that one too. One rule, by id: a step's select is a card's id or a Ref (a chip's or a badge's
4742
+ names its card) */
4743
+ const pointerOf = x => typeof x === 'string' ? x : x && typeof x === 'object' ? (x.type === 'node' ? x.id : x.node ?? null) : null;
4744
+ function story(e) { // the map's story event
4745
+ if (!e || e.step == null) { told = null; return; }
4746
+ if (told && told.id === e.story && told.step === e.step) return; // the same step, said again (paused, played on)
4747
+ const def = arrOf(X().ctx().D.stories).find(s => s && s.id === e.story), step = def && arrOf(def.steps).filter(s => s && typeof s === 'object')[e.step];
4748
+ const pointer = step ? pointerOf(step.select) : null, already = pointer != null && sel === pointer && step.view === X().view() && !X().busy(); // its select changes nothing: no event comes
4749
+ told = { id: e.story, step: e.step, pointer, pending: pointer != null && !already };
4750
+ if (already && el.panel) hide();
4751
+ }
4752
+
4235
4753
  return {
4236
4754
  chrome() { if (el.legend) legend(); if (el.tree) tree(); if (sel && el.panel && !el.panel.hidden) show(sel); }, // each picture of the map
4237
- select(target, targets) { // the map's select event: the selected card's details, unless a story's step picked it (a pointer)
4755
+ select(target, targets) { // the map's select event: the selected card's details, unless it is the select a story's step makes (a pointer)
4238
4756
  sel = target && target.type === 'node' ? target.id : null; picked = (targets || []).filter(t => t.type === 'node').map(t => t.id);
4239
- unlight(); if (el.panel) { if (sel && !X().told(sel)) show(sel); else hide(); }
4757
+ const pointer = !!(told && told.pending && sel != null && sel === told.pointer); if (pointer) told.pending = false;
4758
+ unlight(); if (el.panel) { if (sel && !pointer) show(sel); else hide(); }
4240
4759
  },
4241
- unlight, find, setTree,
4760
+ story, unlight, find, setTree,
4761
+ modal: () => !!(lb && !lb.hidden), // an example shown full size, which holds the keys while open
4242
4762
  key(e) { // the pieces' keys: true when one was used
4243
4763
  const k = e.key;
4244
- if ((k === 'Escape' || k === 'Backspace') && lb && !lb.hidden) closeExample();
4245
- else if ((k === '[' || k === ']') && sel && el.panel) setTab(TABS[(TABS.indexOf(tab) + (k === ']' ? 1 : -1) + TABS.length) % TABS.length]);
4764
+ if (lb && !lb.hidden) { // the example shown full size holds every key: Esc closes it, Tab keeps to it, Enter and Space press its buttons
4765
+ if (k === 'Escape' || k === 'Backspace') closeExample();
4766
+ else if (k === 'Tab') { const f = [...lb.querySelectorAll('a[href], button')], i = f.indexOf(lb.getRootNode().activeElement); f[(i + (e.shiftKey ? -1 : 1) + f.length) % f.length].focus(); }
4767
+ else if (k === 'Enter' || k === ' ') return false;
4768
+ return true;
4769
+ }
4770
+ if ((k === '[' || k === ']') && sel && el.panel && !el.panel.hidden) setTab(TABS[(TABS.indexOf(tab) + (k === ']' ? 1 : -1) + TABS.length) % TABS.length]);
4246
4771
  else if ((k === 'o' || k === 'O') && el.tree) setTree(!o.frame.classList.contains('tree-open'));
4247
4772
  else if ((k === '>' || k === '<') && sel && el.panel) lightReach(k === '<');
4248
4773
  else if ((k === 'p' || k === 'P') && picked.length > 1 && el.panel) lightPaths();