umlimator 0.15.0 → 0.16.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/README.md CHANGED
@@ -65,7 +65,7 @@ drill-down, a data pipeline, an AI agent's skills, and host pages for embedding,
65
65
  npm install umlimator
66
66
  ```
67
67
 
68
- Or from GitHub, at a tag or any commit: `"umlimator": "github:zintaen/umlimator#v0.15.0"`. npm 12 refuses git
68
+ Or from GitHub, at a tag or any commit: `"umlimator": "github:zintaen/umlimator#v0.16.0"`. npm 12 refuses git
69
69
  dependencies unless the project opts in, so that also needs `allow-git=root` in the `.npmrc` next to `package.json`.
70
70
 
71
71
  ## Embed
@@ -299,7 +299,7 @@ jobs:
299
299
  runs-on: ubuntu-latest
300
300
  steps:
301
301
  - uses: actions/checkout@v4
302
- - uses: zintaen/umlimator@v0.15.0
302
+ - uses: zintaen/umlimator@v0.16.0
303
303
  with:
304
304
  models: "docs/*.json" # optional: where the models are (default: every .json that is a map model)
305
305
  ```
@@ -394,7 +394,7 @@ umlimator_mcp.py the MCP server that `umlimator.py mcp` runs
394
394
  action.yml the diff Action: what a pull request changes in its maps, posted on it
395
395
  lib/ the module (umlimator.js), its types, the default theme, <umlimator-map> and the docs plugins
396
396
  engine/ the page templates the CLI inlines the module into; the upstream renderer and schema
397
- docs/ the module's API and the editing design
397
+ docs/ the module's API and the editing design; docs/diagrams/: the plan for 28 diagram types (not built yet)
398
398
  tests/, bench/ tests, benchmark models and results
399
399
  icons/ line icons and the logo manifest (sources and hashes only)
400
400
  examples/ made-up systems: models, host pages and a Node script (live: zintaen.github.io/umlimator)
package/lib/umlimator.js CHANGED
@@ -340,7 +340,8 @@ const STYLES = `
340
340
  .ptabs, .ptablist { flex-wrap: wrap; } .pnav { display: none; } .ptab { padding: 7px 8px; }
341
341
  .detail-bullets li { white-space: normal; overflow: visible; }
342
342
  .exs { grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 10px; }
343
- .m-legend, .hint { display: none; }
343
+ .m-legend, .hint:not(.msg) { display: none; } /* a message shows, as a toast (issue 134) */
344
+ .hint.msg { left: 12px; right: 12px; bottom: 12px; height: auto; min-height: 28px; padding: 6px 12px; white-space: normal; text-align: center; border: 1px solid var(--um-border); border-radius: 10px; box-shadow: var(--um-shadow); }
344
345
  }
345
346
  /* render()'s pieces (#99), laid out as the page lays them out: the views list left of the map (☰ or O), the details under
346
347
  it, the legend and the hint line over it; while a story is told, the legend gives way to its caption bar */
@@ -566,6 +567,9 @@ const noteOf = x => NOTATIONS.includes(x) ? x : null;
566
567
  function cardLook(n, notation, hint, ports) {
567
568
  const sh = shapeOf(n, notation); if (sh === 'box') return null;
568
569
  const S = SHAPE[sh], L = { ...n, sh, S, portTop: ports ? Math.min(ports.L, ports.R) : Infinity, portL: ports ? ports.L : Infinity }, tabs = mapTabs(n), spans = []; let tx = n.x + 12;
570
+ // a step keeps 16 px above the highest line into the left side (svgOf): where that is above a person's or robot's shoulder,
571
+ // the shoulder comes up under it, 5 px below its middle (it floated 4 px above the shoulder)
572
+ if (n.step && S.head && L.portL - 16 < S.tabY(L)) L.portTop = Math.min(L.portTop, Math.max(n.y, L.portL - 16) + 6);
569
573
  tabs.forEach(t => { spans.push([tx, tx + t.w]); tx += t.w + 4; });
570
574
  L.tabEnd = tabs.length ? tx - 2 : 0;
571
575
  const right = []; if (hint) right.push([n.x + n.w - 44, n.x + n.w - 8]);
@@ -857,6 +861,9 @@ const mapNodeW = n => {
857
861
  function layoutGraph(dg, map) {
858
862
  let g = layoutPass(dg, map, null);
859
863
  if (g.room) { const g2 = layoutPass(dg, map, g.room, true); g2.ms.probe = g.ms.probe; g = g2; }
864
+ // a label with no room where it fits: the view laid out again with room for it, kept with fewer issues and labels cut short
865
+ if (g.labelRoom) { const g2 = layoutPass(dg, map, g.labelRoom, true), cut = h => h.edges.filter(e => (e.label || e.step != null) && (!e.lbl || e.lbl.short)).length;
866
+ delete g.labelRoom; if (routeAudit(g2, '').length + cut(g2) < routeAudit(g, '').length + cut(g)) g = g2; }
860
867
  // 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
861
868
  // 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
862
869
  // in on its target's near side (clinic's reminder, from the SMS gateway under Patient records, crossed the line into Patient
@@ -868,11 +875,49 @@ function layoutGraph(dg, map) {
868
875
  pairs.forEach(p => p.forEach(e => { if (e.back && !e.fixed && !e.fromSide) out.set(e.from + '>' + e.to, ['fromSide', far[e.fs]]); }));
869
876
  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];
870
877
  if (e && !p.some(o => out.has(o.from + '>' + o.to))) out.set(e.from + '>' + e.to, ['toSide', dy(e) > 0 ? 'bottom' : 'top']); });
871
- if (!out.size) return g;
872
- 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);
873
- return routeAudit(g2, '').length < routeAudit(g, '').length ? g2 : g;
878
+ let best = g, bestDg = dg, n = pairs.length ? routeAudit(g, '').length : 0;
879
+ const attempt = d => { const h = layoutGraph({ ...d, flipped: true }, map), k = routeAudit(h, '').length; if (k < n) { best = h; bestDg = d; n = k; } };
880
+ if (out.size) attempt({ ...dg, 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) });
881
+ // two lines that still cross where neither can take another side without crossing the other (two cards that each feed the
882
+ // same two cards, as web and admin feed kit and api-client in r15's monorepo, issue 111): one of them goes round the outside,
883
+ // out its source's far side and into its target's top (bottom) from beyond. Of the first two crossing pairs, the lines from
884
+ // two cards that both feed both their targets are tried first, then the pairs' own lines, each on its own (on the layout kept so far,
885
+ // and on the first), and the best layout kept. ponytail: up to four layouts more, on views of up to 30 cards; r19/plain takes
886
+ // about four times as long for them, and gains nothing
887
+ const r2 = [], left = n && best !== g ? routeAudit(best, '', null, r2).flatMap((s, k) => s.endsWith(' cross') ? [r2[k]] : []) : pairs;
888
+ const feeds = (s, t) => best.edges.some(o => o.s === s && o.t === t), both = ([a, b]) => { const k = a.t !== b.t && best.edges.filter(o => [a.t, b.t].includes(o.t) && feeds(o.s, a.t) && feeds(o.s, b.t)); return k && k.length >= 4 ? k : []; };
889
+ const round = [...new Set([...left.slice(0, 2).flatMap(both), ...left.slice(0, 2).flat()])].filter(e => !e.back && !e.fixed && !e.fromSide && !e.toSide && /[LR]/.test(e.fs) && /[LR]/.test(e.ts));
890
+ let tries = 4;
891
+ round.forEach(e => { const side = { fromSide: e.fs === 'R' ? 'left' : 'right', toSide: dy(e) >= 0 ? 'top' : 'bottom' };
892
+ [...new Set([bestDg, dg])].forEach(d => { if (n && tries-- > 0) attempt({ ...d, edges: d.edges.map(x => x.from === e.from && x.to === e.to ? { ...x, ...side } : x) }); }); });
893
+ // fan-in (issue 149): lines into a card that six or more lines come into still cross. One line of such a pair at a time, of
894
+ // the first pair first, goes round the outside (out its source's far side, or into its target's), or its target moves to
895
+ // the foot of its column; a layout with fewer such crossings and no more issues is kept, and the search goes on from it; the
896
+ // view keeps the one found only when it has fewer issues than at the start.
897
+ // r19's logger: boxed 5 → 1, plain 6 → 3. ponytail: 14 layouts more at most, on views of up to 30 cards; a crossing-aware
898
+ // placement would reach plain's last ones (search beside db)
899
+ const fan = {}; best.edges.forEach(e => { if (!e.back) fan[e.to] = (fan[e.to] || 0) + 1; });
900
+ if (map && dg.fan && !dg.quick && !dg.flipped && dg.nodes.length <= 30 && Object.values(fan).some(k => k >= 6)) {
901
+ const audit = h => { const r = [], is = routeAudit(h, '', null, r), hot = r.filter((p, k) => / (cross|share a line)$/.test(is[k]) && p.some(e => fan[e.to] >= 6));
902
+ return { hot, n: is.length }; };
903
+ const far = { L: 'right', R: 'left', T: 'bottom', B: 'top' }, tried = new Set(), start = audit(best), g0 = [best, bestDg], lined = new Set(dg.edges.flatMap(e => [e.from, e.to]));
904
+ let cur = start, left = 14;
905
+ const ways = e => [['fromSide', far[e.fs]], ['toSide', far[e.ts]], ['foot', e.to]].map(([k, s]) => {
906
+ if (k !== 'foot') return !e[k] && s && { key: e.from + '>' + e.to + k, d: { ...bestDg, edges: bestDg.edges.map(x => x.from === e.from && x.to === e.to ? { ...x, [k]: s } : x) } };
907
+ const nodes = bestDg.nodes.map(n => ({ ...n })), me = nodes.find(n => n.id === s), col = nodes.filter(n => n !== me && n.col === me.col), rows = col.filter(n => lined.has(n.id)).map(n => n.row);
908
+ if (!rows.length || me.row > Math.max(...rows)) return null; const r = Math.max(...rows) + 1;
909
+ col.forEach(n => { if (!lined.has(n.id) && n.row > r - 1) n.row++; }); me.row = r; // cards with no line stay under it
910
+ return { key: 'foot ' + s, d: { ...bestDg, nodes } }; });
911
+ for (let more = true; more && cur.hot.length && left > 0;) { more = false;
912
+ for (const e of [...new Set(cur.hot.flat())]) { if (more || e.back || e.fixed) continue;
913
+ for (const w of ways(e)) { if (more || left <= 0 || !w || tried.has(w.key)) continue; tried.add(w.key); left--;
914
+ const h = layoutGraph({ ...w.d, flipped: true }, map), a = audit(h);
915
+ if (a.n <= cur.n && (a.hot.length < cur.hot.length || a.hot.length === cur.hot.length && a.n < cur.n)) { best = h; bestDg = w.d; cur = a; more = true; } } } }
916
+ if (cur.n >= start.n) [best, bestDg] = g0; // kept only with fewer issues
917
+ }
918
+ return best;
874
919
  }
875
- function layoutPass(dg, map, room, last) { // room: { x: { col: px }, y: { row: px } }; last: lay out with it, find no more
920
+ function layoutPass(dg, map, room, last) { // room: { x: { col: px }, y: { row: px } } (lx, ly: a label's, past the boxes'); last: lay out with it, find no more
876
921
  const __ms = { t: performance.now() }, t0 = __ms.t;
877
922
  const CG = dg.gap ? dg.gap : map ? (dg.mainSteps && !dg.containers ? 38 : 66) : CG0, RG = dg.rgap ? dg.rgap : map ? 44 : RG0; // family borders also need clearance between columns
878
923
  const NH = dg.nodeH || 64, pitch = NH + RG;
@@ -960,6 +1005,8 @@ function layoutPass(dg, map, room, last) { // room: { x: { col: px }, y: { row:
960
1005
  Object.entries(wantX).forEach(([k, v]) => { extraX[k] = (extraX[k] || 0) + v; }); Object.entries(wantY).forEach(([k, v]) => { extraY[k] = (extraY[k] || 0) + v; });
961
1006
  place();
962
1007
  }
1008
+ // a label's room comes on top of what keeps the boxes apart
1009
+ if (room && (room.lx || room.ly)) { for (const [k, o] of [['lx', extraX], ['ly', extraY]]) Object.entries(room[k] || {}).forEach(([g, v]) => { o[g] = (o[g] || 0) + v; }); place(); }
963
1010
  // decorations hanging on a node's top/bottom border (kind tab, step badge, ⤢ badge, author name tag):
964
1011
  // ports keep off them, routes and labels treat the name tag as an obstacle
965
1012
  if (map) nodes.forEach(n => {
@@ -1007,20 +1054,25 @@ function layoutPass(dg, map, room, last) { // room: { x: { col: px }, y: { row:
1007
1054
  const segHitsRect = (a, b, r) => { const x1 = Math.min(a[0], b[0]), x2 = Math.max(a[0], b[0]), y1 = Math.min(a[1], b[1]), y2 = Math.max(a[1], b[1]); return x2 > r.x && x1 < r.x + r.w && y2 > r.y && y1 < r.y + r.h; };
1008
1055
  const through = (pts, e) => nodes.find(n => n !== e.s && n !== e.t && segsOf(pts).some(([a, b]) => segHitsRect(a, b, { x: n.x + 2, y: n.y + 2, w: n.w - 4, h: n.h - 4 }))); // a card it does not join
1009
1056
  // fixed edges arrive with their route already drawn (the hub comb); the router works around them
1010
- // a loop back out of a family runs under the frames: out the source's right side, down past every
1011
- // frame and card it would cut through, left along the bottom, and up into the target's bottom
1057
+ // a loop back out of a family runs under the frames: out the source's right side, down past every frame and card it
1058
+ // would cut through, left along the bottom, and up the gap right of its target into its near side, the lowest port
1059
+ // there (issue 128); where that would cut through a card, up into the target's bottom, as before. Loops into one target
1060
+ // nest: the outer comes up nearer the card, into the lower port.
1012
1061
  const inFam = n => families.filter(f => n.x >= f.x && n.x + n.w <= f.x + f.w && n.y >= f.y && n.y + n.h <= f.y + f.h);
1013
- edges.filter(e => map && e.back && !e.fixed && (inFam(e.s).length || inFam(e.t).length)).forEach((e, k) => {
1062
+ const unders = edges.filter(e => map && e.back && !e.fixed && (inFam(e.s).length || inFam(e.t).length));
1063
+ unders.forEach((e, k) => {
1014
1064
  // the source's other outgoing wires keep its middle port; the loop leaves lower down
1015
1065
  const { s, t } = e, sy = s.y + s.h / 2 + (edges.some(o => o !== e && o.from === s.id && !o.back) ? s.h / 4 : 0);
1016
1066
  const xr = Math.max(s.x + s.w + 22, ...inFam(s).map(f => f.x + f.w + 14)) + k * 10;
1017
1067
  const tx = t.tag ? Math.max(t.x + 12, (t.x + t.tag.x - 2) / 2) : t.x + t.w / 2, ty = t.y + t.h + (t.kids || t.opens ? 8 : 0);
1018
- const span = nodes.filter(n => n.x < xr && n.x + n.w > tx), yb = Math.max(...[...inFam(s), ...inFam(t)].map(f => f.y + f.h), ...span.map(n => n.y + n.h + (n.tag ? 22 : 8))) + 24 + k * 10;
1019
- const pts = [[s.x + s.w, sy], [xr, sy], [xr, yb], [tx, yb], [tx, ty]];
1020
- if (!through(pts, e)) { e.fixed = pts; e.under = true; } // one that would cut through a card is left to the router
1068
+ const xn = t.x + t.w + 16 + 10 * unders.slice(k + 1).filter(o => o.t === t).length, near = xn < xr;
1069
+ const span = nodes.filter(n => n.x < xr && n.x + n.w > (near ? xn : tx)), yb = Math.max(...[...inFam(s), ...inFam(t)].map(f => f.y + f.h), ...span.map(n => n.y + n.h + (n.tag ? 22 : 8))) + 24 + k * 10;
1070
+ const pts = [[s.x + s.w, sy], [xr, sy], [xr, yb]], up = [[xn, yb], [xn, t.y + t.h / 2], [t.x + t.w, t.y + t.h / 2]], bottom = [[tx, yb], [tx, ty]];
1071
+ if (near && !through([...pts, ...up], e)) { e.fixed = [...pts, ...up]; e.under = k + 1; }
1072
+ else if (!through([...pts, ...bottom], e)) { e.fixed = [...pts, ...bottom]; e.under = true; } // one that would cut through a card is left to the router
1021
1073
  });
1022
1074
  const free = edges.filter(e => !e.fixed);
1023
- edges.filter(e => e.fixed).forEach(e => { e.pts = e.fixed; e.p1 = e.pts[0]; e.p2 = e.pts[e.pts.length - 1]; e.fs = 'R'; e.ts = e.under ? 'B' : 'L'; e.stubs = []; e.how = e.under ? 'under' : 'comb'; });
1075
+ edges.filter(e => e.fixed).forEach(e => { e.pts = e.fixed; e.p1 = e.pts[0]; e.p2 = e.pts[e.pts.length - 1]; e.fs = 'R'; e.ts = e.under > 0 ? 'R' : e.under ? 'B' : 'L'; e.stubs = []; e.how = e.under ? 'under' : 'comb'; });
1024
1076
  const sides = {};
1025
1077
  const pushSide = (n, side, e, other) => ((sides[n.id + '|' + side] ||= []).push({ e, other, n }));
1026
1078
  free.forEach(e => {
@@ -1052,6 +1104,7 @@ function layoutPass(dg, map, room, last) { // room: { x: { col: px }, y: { row:
1052
1104
  });
1053
1105
  });
1054
1106
  free.forEach(e => { pushSide(e.s, e.fs, e, e.t); pushSide(e.t, e.ts, e, e.s); });
1107
+ edges.filter(e => e.under > 0).forEach(e => pushSide(e.t, 'R', e, e.s)); // a loop under the boxes takes its port with the side's other lines
1055
1108
  const portAt = (it, side, p) => { if (it.n === it.e.s && it.e.fs === side) it.e.p1 = p; else it.e.p2 = p; };
1056
1109
  Object.entries(sides).sort(([, a], [, b]) => !!a[0].n.box - !!b[0].n.box).forEach(([key, list]) => { // cards first: a box's port lines up with them
1057
1110
  const side = key.split('|').pop(), vertical = side === 'L' || side === 'R';
@@ -1077,7 +1130,7 @@ function layoutPass(dg, map, room, last) { // room: { x: { col: px }, y: { row:
1077
1130
  // nearer takes the higher port, since the farther one's line passes under it (b10/overview's Reports 7 took the port
1078
1131
  // under Billing 1's, whose line ran below Reports, and had no way in but round the map)
1079
1132
  const lean = it => Math.atan2(it.other.y + it.other.h / 2 - my, Math.abs(it.other.x + it.other.w / 2 - mx));
1080
- list.sort((a, b) => vertical ? (a.other.y - b.other.y) || (lean(a) - lean(b)) || ((side === 'R' ? -1 : 1) * (a.other.x - b.other.x)) : turn(a) - turn(b));
1133
+ list.sort((a, b) => vertical ? ((a.e.under || 0) - (b.e.under || 0)) || (a.other.y - b.other.y) || (lean(a) - lean(b)) || ((side === 'R' ? -1 : 1) * (a.other.x - b.other.x)) : turn(a) - turn(b));
1081
1134
  const sp = vertical ? null : sideSpan(n, side);
1082
1135
  const len = vertical ? n.h : sp ? sp[1] - sp[0] : n.w, mid = sp ? (sp[0] + sp[1]) / 2 : n.x + n.w / 2;
1083
1136
  const gap = Math.min(vertical ? 16 : 26, (len * 0.84) / Math.max(1, list.length)), cy = n.y + n.h / 2;
@@ -1088,6 +1141,7 @@ function layoutPass(dg, map, room, last) { // room: { x: { col: px }, y: { row:
1088
1141
  portAt(it, side, p);
1089
1142
  });
1090
1143
  });
1144
+ edges.filter(e => e.under > 0).forEach(e => { const P = e.fixed; e.pts = e.fixed = [...P.slice(0, 4), [P[3][0], e.p2[1]], e.p2]; });
1091
1145
  const C = map ? 16 : 12, STUB = dg.mainSteps ? C : C + 6, SEP = 10, BEND = map ? 60 : 26, CROSS = 900; // maps: 16 px clear of other nodes, few bends
1092
1146
  const OUT = { R: [1, 0], L: [-1, 0], T: [0, -1], B: [0, 1] };
1093
1147
  const nodeRects = nodes.map(n => ({ x: n.x, y: n.y, w: n.w, h: n.h }));
@@ -1255,11 +1309,12 @@ function layoutPass(dg, map, room, last) { // room: { x: { col: px }, y: { row:
1255
1309
  }
1256
1310
  // labels: on the edge's own segment, in free space — never across another route, node, or label
1257
1311
  const placed = [];
1258
- function placeLabel(e) {
1259
- if (!e.label && e.step == null) return;
1260
- const full = map ? fit(e.label || '', 10.5, e.under ? 320 : 440) : fit((e.step != null ? e.step + ' · ' : '') + (e.label || ''), 10, 150);
1261
- const sub = map && e.label && e.sublabel ? fit(e.sublabel, 9.5, e.under ? 320 : 440) : ''; // a second, smaller line under the label (#44)
1262
- const tries = [full]; if (e.step != null && e.label && !dg.abs) tries.push(map ? '' : String(e.step));
1312
+ // every spot on e's line where a label of this text fits, best first, at pass 0 (strict), 1 (strict, but it may touch a box
1313
+ // border) or 2 (relaxed)
1314
+ const subOf = (e, text) => map && text && e.sublabel ? fit(e.sublabel, 9.5, e.under ? 320 : 440) : ''; // a second, smaller line under the label (#44)
1315
+ const sizeOf = (e, text, sub = subOf(e, text)) => { const two = !!(text && sub), pill = map && e.step != null;
1316
+ return { two, 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 }; };
1317
+ function* fits(e, text, pass) { const relax = pass === 2, sub = subOf(e, text);
1263
1318
  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] }));
1264
1319
  const order = [...segs].sort((p, q) => (q.h - p.h) * 40 + (q.l - p.l));
1265
1320
  // 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,
@@ -1270,25 +1325,43 @@ function layoutPass(dg, map, room, last) { // room: { x: { col: px }, y: { row:
1270
1325
  ...[...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]) })))];
1271
1326
  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])
1272
1327
  .map(u => (u - lo) / len).filter(f => f >= 0 && f <= 1).sort((p, q) => Math.abs(p - .5) - Math.abs(q - .5))]; };
1273
- 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
1274
- 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;
1275
- for (const sg of order) {
1276
- if (!relax && sg.h && sg.l < w + 10) continue;
1277
- if (!relax && !sg.h && sg.l < hgt + 14) continue;
1278
- for (const f of (relax ? [.5, .32, .68, .2, .8, .1, .9, .42, .58, .26, .74, .05, .95] : spots(sg, w, hgt))) {
1279
- const lx = sg.a[0] + (sg.b[0] - sg.a[0]) * f, ly = sg.a[1] + (sg.b[1] - sg.a[1]) * f;
1280
- const box = { x: lx - w / 2, y: ly - hgt / 2, w, h: hgt };
1281
- const grow = { x: box.x - 3, y: box.y - 3, w: box.w + 6, h: box.h + 6 };
1282
- if (nodeRects.some(r => hit(relax ? { x: r.x, y: r.y - 10, w: r.w, h: r.h + 10 } : { x: r.x - STUB - 4, y: r.y - STUB - 4, w: r.w + 2 * STUB + 8, h: r.h + 2 * STUB + 8 }, box))) continue; // keep port stubs free
1283
- if (tagRects.some(r => hit(r, grow))) continue;
1284
- if (placed.some(r => (pass === 0 || !r.band) && hit(r, grow))) continue;
1285
- if (!relax && routed.some(r => r !== e && segsOf(r.pts).some(([a, b]) => segHitsRect(a, b, grow)))) continue;
1286
- if (!relax && segs.some(s2 => s2.i !== sg.i && segHitsRect(s2.a, s2.b, box))) continue;
1287
- placed.push(box); reserved.push(grow); e.lbl = { ...box, text, ...(two ? { sub } : {}), step: map ? e.step : null, ...(pill ? { sw: stepW(e.step) } : {}), short: text !== full }; return;
1288
- }
1328
+ const { two, w, hgt } = sizeOf(e, text, sub), pill = map && e.step != null;
1329
+ for (const sg of order) {
1330
+ if (!relax && sg.h && sg.l < w + 10) continue;
1331
+ if (!relax && !sg.h && sg.l < hgt + 14) continue;
1332
+ for (const f of (relax ? [.5, .32, .68, .2, .8, .1, .9, .42, .58, .26, .74, .05, .95] : spots(sg, w, hgt))) {
1333
+ const lx = sg.a[0] + (sg.b[0] - sg.a[0]) * f, ly = sg.a[1] + (sg.b[1] - sg.a[1]) * f;
1334
+ const box = { x: lx - w / 2, y: ly - hgt / 2, w, h: hgt };
1335
+ const grow = { x: box.x - 3, y: box.y - 3, w: box.w + 6, h: box.h + 6 };
1336
+ if (nodeRects.some(r => hit(relax ? { x: r.x, y: r.y - 10, w: r.w, h: r.h + 10 } : { x: r.x - STUB - 4, y: r.y - STUB - 4, w: r.w + 2 * STUB + 8, h: r.h + 2 * STUB + 8 }, box))) continue; // keep port stubs free
1337
+ if (tagRects.some(r => hit(r, grow))) continue;
1338
+ if (placed.some(r => (pass === 0 || !r.band) && hit(r, grow))) continue;
1339
+ if (!relax && routed.some(r => r !== e && segsOf(r.pts).some(([a, b]) => segHitsRect(a, b, grow)))) continue;
1340
+ if (!relax && segs.some(s2 => s2.i !== sg.i && segHitsRect(s2.a, s2.b, box))) continue;
1341
+ yield { box, grow, lbl: { ...box, text, ...(two ? { sub } : {}), step: map ? e.step : null, ...(pill ? { sw: stepW(e.step) } : {}) } };
1289
1342
  }
1290
1343
  }
1291
1344
  }
1345
+ const put = (e, s, full) => { placed.push(s.box); reserved.push(s.grow); e.lbl = { ...s.lbl, short: s.lbl.text !== full }; };
1346
+ const unput = e => { const L = e.lbl, same = m => r => r.x === L.x - m && r.y === L.y - m && r.w === L.w + 2 * m && r.h === L.h + 2 * m;
1347
+ const p = placed.findIndex(same(0)), q = reserved.findIndex(same(3)); if (p >= 0) placed.splice(p, 1); if (q >= 0) reserved.splice(q, 1); e.lbl = null; };
1348
+ const fullOf = e => map ? fit(e.label || '', 10.5, e.under ? 320 : 440) : fit((e.step != null ? e.step + ' · ' : '') + (e.label || ''), 10, 150);
1349
+ function placeLabel(e) {
1350
+ if (!e.label && e.step == null) return;
1351
+ const full = fullOf(e);
1352
+ const tries = [full]; if (e.step != null && e.label && !dg.abs) tries.push(map ? '' : String(e.step));
1353
+ const first = (x, text, pass) => fits(x, text, pass).next().value;
1354
+ let s = first(e, full, 0); if (s) return put(e, s, full);
1355
+ // a label whose spots are all taken by a label placed before it: that label moves to each of its other spots in turn, until
1356
+ // this one fits too. Both centred on their lines, the labels of two lines that leave a card side by side (16 px apart, too
1357
+ // close to stack) left the second no room, and it fell back to its step (assistant's "a draft", by a jog of 16 px)
1358
+ if (map) for (const o of routed) { if (o === e || !o.lbl || o.lbl.short || o.fixed) continue;
1359
+ const was = o.lbl, back = () => put(o, { box: { x: was.x, y: was.y, w: was.w, h: was.h }, grow: { x: was.x - 3, y: was.y - 3, w: was.w + 6, h: was.h + 6 }, lbl: was }, was.text);
1360
+ unput(o); if (!first(e, full, 0)) { back(); continue; } // e finds no spot even without it
1361
+ for (const so of fits(o, was.text, 0)) { put(o, so, was.text); if ((s = first(e, full, 0))) return put(e, s, full); unput(o); }
1362
+ back(); }
1363
+ for (const pass of (map ? [0, 1, 2] : [0])) for (const text of tries) if ((pass || text !== full) && (s = first(e, text, pass))) return put(e, s, full);
1364
+ }
1292
1365
  let stats = {};
1293
1366
  free.forEach(e => { // every port's stub is reserved before routing starts
1294
1367
  const st = [e.p1[0] + OUT[e.fs][0] * STUB, e.p1[1] + OUT[e.fs][1] * STUB], en = [e.p2[0] + OUT[e.ts][0] * STUB, e.p2[1] + OUT[e.ts][1] * STUB];
@@ -1318,10 +1391,16 @@ function layoutPass(dg, map, room, last) { // room: { x: { col: px }, y: { row:
1318
1391
  const nearFirst = e => { const d = e.p1[ax] - e.p2[ax]; return d > 0 ? -e.p2[ax] : d < 0 ? e.p2[ax] - 1e6 : 1e7; };
1319
1392
  const slots = list.map(e => order.indexOf(e)).sort((a, b) => a - b), turns = [...list].sort((a, b) => nearFirst(a) - nearFirst(b));
1320
1393
  slots.forEach((at, i) => { order[at] = turns[i]; }); });
1394
+ // fan-in (issue 111): lines into a card that six or more lines come into go first, in that order, so they take the tracks by
1395
+ // the card and the rest go round them (r19's logger has seven; its lines crossed the ones routed before them).
1396
+ // ponytail: six fits the bench (five made d24's hub worse); a fan-in card's own layout row is what is left
1397
+ const fan = {}, hot = e => fan[e.t.id] >= 6; free.forEach(e => { fan[e.t.id] = (fan[e.t.id] || 0) + 1; });
1398
+ order = [...order.filter(hot), ...order.filter(e => !hot(e))];
1321
1399
  let bad = runRouting(order);
1322
1400
  // rip-up and reroute: an edge that had to cross goes first next time; keep the best attempt (not when the view is laid
1323
- // out again with room: that layout's tries seldom gained anything, and they cost a third of its time)
1324
- for (let tries = 0; bad && !last && tries < 4; tries++) {
1401
+ // out again with room for lines: that layout's tries seldom gained anything, and they cost a third of its time; with room for
1402
+ // a label it tries, as the layout it may replace did)
1403
+ for (let tries = 0; bad && (!last || room.lx) && tries < 4; tries++) {
1325
1404
  const stuck = order.filter(e => e.how !== 'grid');
1326
1405
  const next = [...stuck, ...order.filter(e => e.how === 'grid')];
1327
1406
  const snap = order.map(e => [e, e.pts, e.lbl, e.how]), snapStats = stats, snapR = [routed.slice(), reserved.slice(), placed.slice()];
@@ -1419,6 +1498,22 @@ function layoutPass(dg, map, room, last) { // room: { x: { col: px }, y: { row:
1419
1498
  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); });
1420
1499
  if (!dg.quick) edges.forEach(e => { const n = through(e.pts, e); if (n) warn(`the line for edge ${edgeRefIds(e).join(' / ') || e.from + '->' + e.to} runs through card "${n.id}", which it does not join: no route found around it`); });
1421
1500
  __ms.repair = performance.now() - __ms.t; delete __ms.t;
1501
+ // a label on a box's border, cut to its step or with no spot asks for room where it is: the column gap it sits in widens by
1502
+ // the label's width, else the row gap by its height and 14 px; a label in neither, or with no spot, the gap its line's longest
1503
+ // run crosses, a column's first. layoutGraph lays the view out again with it, and keeps that layout if it is better
1504
+ let labelRoom = null;
1505
+ const tight = !last && !dg.quick && map && !dg.abs ? free.filter(e => (e.label || e.step != null) && (!e.lbl || e.lbl.short || families.some(f => onBorder(e.lbl, f)))) : [];
1506
+ if (tight.length) {
1507
+ const cs = Object.keys(colX).map(Number), rs = nodes.map(n => n.row), r0 = Math.floor(Math.min(...rs)), r1 = Math.ceil(Math.max(...rs)), need = { x: {}, y: {} };
1508
+ const gaps = [...cs.filter(c => colX[c + 1] != null).map(c => ['x', c, colX[c] + (colW[c] || NW), colX[c + 1]]),
1509
+ ...Array.from({ length: Math.max(0, r1 - r0) }, (_, k) => ['y', r0 + k, rowY(r0 + k) + NH, rowY(r0 + k + 1)])];
1510
+ tight.forEach(e => { const { w, hgt: h } = sizeOf(e, fullOf(e)), L = e.lbl, c = L && { x: L.x + L.w / 2, y: L.y + L.h / 2 };
1511
+ let best = c && ['x', 'y'].map(k => gaps.find(([gk, g, p, q]) => gk === k && c[k] >= p && c[k] <= q)).find(Boolean);
1512
+ if (!best) segsOf(e.pts).forEach(([a, b]) => { const k = a[1] === b[1] ? 'x' : 'y', i = k === 'x' ? 0 : 1, lo = Math.min(a[i], b[i]), hi = Math.max(a[i], b[i]);
1513
+ gaps.forEach(([gk, g, p, q]) => { const run = Math.min(hi, q) - Math.max(lo, p); if (gk === k && run > 0 && (!best || (k === 'x') > (best[0] === 'x') || ((k === 'x') === (best[0] === 'x') && run > best[2]))) best = [k, g, run]; }); });
1514
+ if (best) need[best[0]][best[1]] = (need[best[0]][best[1]] || 0) + (best[0] === 'x' ? w : h + 14); });
1515
+ if (Object.keys(need.x).length + Object.keys(need.y).length) labelRoom = { ...room, lx: need.x, ly: need.y };
1516
+ }
1422
1517
  const groups = (dg.groups || []).map(g => {
1423
1518
  const ms = (g.nodes || []).map(i => byId[i]).filter(Boolean);
1424
1519
  if (!ms.length) return null;
@@ -1430,7 +1525,7 @@ function layoutPass(dg, map, room, last) { // room: { x: { col: px }, y: { row:
1430
1525
  const ys = [...families.flatMap(f => [f.y,f.y+f.h]), ...(dg.lanes || []).flatMap(l => [l.y, l.y + l.h]), ...nodes.flatMap(n => [n.y - 14, n.y + n.h + (n.tag ? 18 : 8)]), ...groups.flatMap(g => [g.y, g.y2]), ...edges.flatMap(e => e.pts.map(p => p[1])), ...placed.flatMap(p => [p.y, p.y + p.h])];
1431
1526
  intake.forEach(b => { xs.push(b.cx - b.r - 8 - textW(b.label || '', 10.5), b.cx + b.r); ys.push(b.cy - b.r, b.cy + b.r); });
1432
1527
  const M = 36, minX = Math.min(...xs) - M, minY = Math.min(...ys) - M;
1433
- return { ms: __ms, stats, nodes, edges, subAndChips: !!dg.subAndChips, groups, families, intake, tour: dg.tour || null, byId, map, hub: dg.hub || null, lanes: dg.lanes || [], blocks: dg.blocks || [], bounds: { x: minX, y: minY, w: Math.max(...xs) - minX + M, h: Math.max(...ys) - minY + M } };
1528
+ return { ms: __ms, ...(labelRoom ? { labelRoom } : {}), stats, nodes, edges, subAndChips: !!dg.subAndChips, groups, families, intake, tour: dg.tour || null, byId, map, hub: dg.hub || null, lanes: dg.lanes || [], blocks: dg.blocks || [], bounds: { x: minX, y: minY, w: Math.max(...xs) - minX + M, h: Math.max(...ys) - minY + M } };
1434
1529
  }
1435
1530
 
1436
1531
  let svgSeq = 0;
@@ -1787,6 +1882,9 @@ class Camera {
1787
1882
  // an issue's message, and what it is about (the cards, lines and boxes it names) when the caller keeps a list of that: check()
1788
1883
  // turns those into Refs; the page's check reads only the messages
1789
1884
  const teller = (issues, refs) => (message, ...about) => { issues.push(message); if (refs) refs.push(about); };
1885
+ // a label's box L across one of box f's borders
1886
+ const onBorder = (L, f) => { const xin = L.x + L.w > f.x && L.x < f.x + f.w, yin = L.y + L.h > f.y && L.y < f.y + f.h;
1887
+ return (xin && [f.y, f.y + f.h].some(u => L.y < u && L.y + L.h > u)) || (yin && [f.x, f.x + f.w].some(u => L.x < u && L.x + L.w > u)); };
1790
1888
  // a detour: a route 2.5 times longer than the straight distance between its ends, or than 100 px; [its length, that distance]
1791
1889
  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);
1792
1890
  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; };
@@ -1864,8 +1962,7 @@ function routeAudit(geo, where, only, refs) { // only: a set of edges; counts ju
1864
1962
  geo.edges.forEach((e,i) => { if (segs[i].some(([a,b]) => { const v = a[0] === b[0], c = v ? a[0] : a[1], p = v ? [a[1], b[1]] : [a[0], b[0]];
1865
1963
  const lo = Math.max(Math.min(...p), v ? f.y : f.x), hi = Math.min(Math.max(...p), v ? f.y + f.h : f.x + f.w);
1866
1964
  return hi - lo > 40 && (v ? [f.x, f.x + f.w] : [f.y, f.y + f.h]).some(u => Math.abs(c - u) < 8); })) say(`${where}: edge ${name(e)} runs along the border of family "${f.label}"`, e, f); });
1867
- geo.edges.forEach(e => { const L = e.lbl; if (!L) return; const xin = L.x + L.w > f.x && L.x < f.x + f.w, yin = L.y + L.h > f.y && L.y < f.y + f.h;
1868
- if ((xin && [f.y, f.y + f.h].some(u => L.y < u && L.y + L.h > u)) || (yin && [f.x, f.x + f.w].some(u => L.x < u && L.x + L.w > u))) say(`${where}: label "${L.text || L.step}" sits on the border of family "${f.label}"`, e, f); });
1965
+ geo.edges.forEach(e => { const L = e.lbl; if (L && onBorder(L, f)) say(`${where}: label "${L.text || L.step}" sits on the border of family "${f.label}"`, e, f); });
1869
1966
  });
1870
1967
  return issues;
1871
1968
  }
@@ -1880,6 +1977,8 @@ function overlapIssues(geo, where, refs) {
1880
1977
  }
1881
1978
  function leaveIssues(geo, where, refs) {
1882
1979
  const issues = [], say = teller(issues, refs);
1980
+ // a numbered line's label with no room on it: only its step shows, its words in the line's title and the details only
1981
+ if (geo.map) geo.edges.forEach(e => { if (e.label && e.lbl && e.lbl.short) say(`${where}: label "${e.label}" has no room on edge ${e.s.label}→${e.t.label}: only its step ${e.lbl.step} shows`, e); });
1883
1982
  if (geo.map) geo.edges.forEach(e => { const L = e.lbl; if (!L || (!e.label && L.step == null) || !e.pts) return; // a label, or a step's mark, must leave some of its own line visible
1884
1983
  const c = [L.x + L.w / 2, L.y + L.h / 2], P = e.pts; let s = 0, at = null, half = 0, tot = 0;
1885
1984
  for (let i = 0; i + 1 < P.length; i++) { const a = P[i], b = P[i + 1], len = Math.abs(b[0] - a[0]) + Math.abs(b[1] - a[1]);
@@ -2192,9 +2291,12 @@ function mapContext(model, opts = {}) {
2192
2291
  for (const o of [{ pull: true }, { packed: true }, { pull: true, packed: true }]) { const p = flowPos(vid, o), k = JSON.stringify(p); if (!seen.has(k)) { seen.add(k); ways.push(p); } }
2193
2292
  if (ways.length < 2) return first;
2194
2293
  let best = first, score = Infinity;
2195
- ways.forEach(p => { const g = buildGeo(vid, p), s = scoreGeo(g); if (s < score - 0.5) { score = s; best = p; drawn[vid] = { pos: p, g }; } }); // as it will be drawn: with port repair
2294
+ scouting = true; ways.forEach(p => { const g = buildGeo(vid, p), s = scoreGeo(g); if (s < score - 0.5) { score = s; best = p; drawn[vid] = { pos: p, g }; } }); scouting = false; // as it will be drawn: with port repair
2295
+ if (fanIn(vid)) delete drawn[vid];
2196
2296
  return best;
2197
2297
  }
2298
+ let scouting = false;
2299
+ const fanIn = vid => { const k = {}; return viewEdges(V[vid] || {}).some(e => e.from !== e.to && (k[e.to] = (k[e.to] || 0) + 1) >= 6); };
2198
2300
  const drawn = {}; // the way bestFlow kept, routed already: the view's next buildGeo takes it once instead of routing it again
2199
2301
  function flowPos(vid, o = {}) {
2200
2302
  const v = V[vid], lp = loopsOf(v), fwd = lp.edges.filter((e, k) => !lp.back.has(k) && e.from !== e.to);
@@ -2280,7 +2382,7 @@ function mapContext(model, opts = {}) {
2280
2382
  const stepIds = v.stepIds || ids;
2281
2383
  const dg = { nodeH: mainSteps ? 82 : v.subAndChips ? 80 : 64, subAndChips: !!v.subAndChips, mainSteps, containers: v.containers, nodes: ids.map(i => { const n = nodeIn(i, vid), step = mainSteps && stepIds.includes(i); return { ...n, sub: n.sub || n.sublabel, emphasis: !!step || !!n.emphasis, step: step ? stepIds.indexOf(i) + 1 : (v.cardSteps || {})[i] || null, verbs: step ? [] : n.verbs, kids: kidCount(i, vid), opens: !!opensOf(i, vid), col: (pos[i] || [0, 0])[0], row: (pos[i] || [0, 0])[1] }; }),
2282
2384
  edges: prepEdges(vid, edges, P.backKeys) };
2283
- dg.authors = D.authors; dg.repair = repair; dg.intake = v.intake; dg.tour = v.tour; dg.gap = v.gap; dg.rgap = v.rgap; dg.quick = quick;
2385
+ dg.authors = D.authors; dg.fan = !scouting; dg.repair = repair; dg.intake = v.intake; dg.tour = v.tour; dg.gap = v.gap; dg.rgap = v.rgap; dg.quick = quick;
2284
2386
  return layoutGraph(dg, true);
2285
2387
  }
2286
2388
  function hubGeo(vid, P, edges, repair, quick) {
@@ -2909,7 +3011,7 @@ export function render(container, model, options = {}) {
2909
3011
  <button data-z="up" title="Up one level (Esc)" aria-label="Up one level">↑</button><span class="sep"></span>
2910
3012
  <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>
2911
3013
  <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>
2912
- </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>'
3014
+ </div>`}</div></div>${!framed ? '' : (options.legend ? '<div class="m-legend"></div>' : '') + (options.hints ? `<div class="hint">${hintHTML('', ctrlWheel)}</div>` : '') + '</div>'
2913
3015
  + (options.details ? '<aside class="panel" aria-label="Node details" hidden></aside>' : '') + '</div>'
2914
3016
  + (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>'}`;
2915
3017
  const stage = root.querySelector('.stage'), q = s => root.querySelector(s);
@@ -2951,7 +3053,9 @@ export function render(container, model, options = {}) {
2951
3053
  const status = t => P.status ? P.status(t) : tell && tell(t);
2952
3054
  const wide = P.wide || (() => (cam ? cam.size()[0] : stage.clientWidth) > 820);
2953
3055
 
2954
- const upOf = () => (P.upTarget ? P.upTarget() : (ctx.V[cur] || {}).parent) || null;
3056
+ // the views the reader walked through, the one shown last: up goes back along them, then to the model's parent (issue 137)
3057
+ let trail = [];
3058
+ const upOf = () => (P.upTarget ? P.upTarget() : trail.length > 1 ? trail[trail.length - 2] : (ctx.V[cur] || {}).parent) || null;
2955
3059
  const nodeRef = id => ({ type: 'node', id, view: cur });
2956
3060
  const copyRef = r => r && { ...r, ...(Array.isArray(r.ids) ? { ids: [...r.ids] } : {}) }; // a host gets copies: the map's Refs stay its own
2957
3061
  const picked = () => sel ? [...more.map(nodeRef), copyRef(sel)] : []; // every selected thing, the latest last (more: other selected cards)
@@ -2979,6 +3083,7 @@ export function render(container, model, options = {}) {
2979
3083
  const back = focusOf();
2980
3084
  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
2981
3085
  cur = v ? vid : null; if (v) ctx.planOf(vid);
3086
+ if (v) { trail = trail.filter(id => ctx.V[id]); if (!trail.includes(vid)) trail = [vid]; } // the first view, or after an update() took the walk's views away
2982
3087
  stage.querySelectorAll('svg.map-svg, .empty-view').forEach(s => s.remove());
2983
3088
  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
2984
3089
  geo = { nodes: [], edges: [], groups: [], lanes: [], families: [], byId: {}, stats: {}, bounds: { x: 0, y: 0, w: 400, h: 300 } };
@@ -3181,6 +3286,7 @@ export function render(container, model, options = {}) {
3181
3286
  const mode = modeOf(vid);
3182
3287
  delete asked[prev]; // an unfold asked for on the view left is not waited for (#71)
3183
3288
  if (P.onNavigate) P.onNavigate(vid, prev, mode);
3289
+ const at = trail.indexOf(vid); trail = at >= 0 ? trail.slice(0, at + 1) : trail.length === 1 && vid === (ctx.V[prev] || {}).parent ? [vid] : [...trail, vid];
3184
3290
  mount(vid); emitSel(); left = false;
3185
3291
  const fitS = startView(), back = mode === 'out' ? geo.nodes.find(n => ctx.childOf(n.id, cur) === prev) : null;
3186
3292
  if (prev && !still()) {
@@ -3669,7 +3775,7 @@ export function render(container, model, options = {}) {
3669
3775
  return; }
3670
3776
  if (!ctx.V[cur]) { // the view shown is gone: the root, or the empty card of a model with no views
3671
3777
  const from = cur; mount(ctx.rootId ?? null); cam.set(startView()); cam.touched = false; emitSel(); if (cur) emit('viewchange', { view: cur, from }); return; }
3672
- if (ctx.geoCache[cur] && keys[cur] === ctx.viewKey(cur) && ctx.geoCache[cur].shapes === noteFor(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)
3778
+ if (ctx.geoCache[cur] && keys[cur] === ctx.viewKey(cur) && ctx.geoCache[cur].shapes === noteFor(cur)) { if (lensId != null) { relens(); drawMarks(); } if (pc) pc.chrome(); if (P.onMount) P.onMount(inner); return; } // this view's picture did not change (its lens or the views may have; the page reads the new model, issue 133)
3673
3779
  const keep = sel, keepMore = more, touched = cam.touched, prev = [geo, svg];
3674
3780
  mount(cur);
3675
3781
  if (touched) cam.set({}); else cam.set(startView());
@@ -4327,7 +4433,7 @@ export function mountPage(container, model, options = {}) {
4327
4433
  <span class="mbrand"><i></i><span>${esc(D.title || 'Map')}</span></span>
4328
4434
  <nav class="crumbs" id="m-crumbs" aria-label="Breadcrumb"></nav>
4329
4435
  <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>
4330
- <button class="ibtn" id="m-theme" title="Light / dark" aria-label="Toggle theme">◐</button><button class="ibtn txt" id="m-shapes" type="button" title="Card shapes: box, c4 or uml">⬡<span class="lbl"></span></button>${(D.stories || []).length ? `
4436
+ <button class="ibtn" id="m-theme" title="Light / dark" aria-label="Dark theme" aria-pressed="false">◐</button><button class="ibtn txt" id="m-shapes" type="button" title="Card shapes: box, c4 or uml">⬡<span class="lbl"></span></button>${(D.stories || []).length ? `
4331
4437
  <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>
4332
4438
  <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) ? `
4333
4439
  <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>
@@ -4344,7 +4450,7 @@ export function mountPage(container, model, options = {}) {
4344
4450
  <div class="ghosts" id="m-ghosts" aria-hidden="true">${ghost}</div>
4345
4451
  <div class="vpill" id="m-vpill"></div>
4346
4452
  <div class="m-legend" id="m-legend"></div>
4347
- <div class="hint" id="m-hint" role="status" aria-live="polite">${HINT}</div>
4453
+ <div class="hint" id="m-hint">${HINT}</div>
4348
4454
  </div>
4349
4455
  <aside class="panel" id="m-panel" aria-label="Node details" hidden></aside>
4350
4456
  </div>
@@ -4385,7 +4491,7 @@ export function mountPage(container, model, options = {}) {
4385
4491
  const setTree = pc.setTree;
4386
4492
 
4387
4493
  /* the link (0.14.0): one place writes it, from what the map shows: its view, card, story step and lens, and the reader
4388
- 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,
4494
+ copy and theme the page was opened with (the theme as it is now, after ◐ too). A new view is a new entry,
4389
4495
  written once the move lands; anything else rewrites the entry. Nothing is written while a link is being followed */
4390
4496
  function write(push = false) {
4391
4497
  if (!X || linking || handle.destroyed) return;
@@ -4394,7 +4500,7 @@ export function mountPage(container, model, options = {}) {
4394
4500
  if (lensOn) p.set('lens', lensOn); // the lens on the map (#94)
4395
4501
  if (X.override()) p.set('shapes', X.override()); // the notation a reader or the link picked (0.15.0)
4396
4502
  if (Q.get('theme')) p.set('theme', document.documentElement.dataset.theme || Q.get('theme'));
4397
- ['reader', 'check', 'shot'].forEach(k => Q.get(k) && p.set(k, Q.get(k)));
4503
+ if (Q.get('reader')) p.set('reader', Q.get('reader')); // check, shot and still describe one run: never in the link a reader shares (issue 136)
4398
4504
  const h = '#' + p.toString();
4399
4505
  if (location.hash !== h) window.history[push ? 'pushState' : 'replaceState'](null, '', h); // window's: history() is this module's undo helper
4400
4506
  }
@@ -4459,7 +4565,9 @@ export function mountPage(container, model, options = {}) {
4459
4565
  let narrow = innerWidth <= 820;
4460
4566
  addEventListener('resize', () => { const n = innerWidth <= 820; if (n && !narrow) setTree(false); narrow = n; }, { signal });
4461
4567
  $('m-help').addEventListener('click', () => toggleHelp());
4462
- $('m-theme').addEventListener('click', () => { toggleTheme(); write(); }); // a link that names the theme names the one shown
4568
+ const themed = () => $('m-theme').setAttribute('aria-pressed', (document.documentElement.dataset.theme || (matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light')) === 'dark'); // ◐ says which theme is on (issue 135)
4569
+ themed(); matchMedia('(prefers-color-scheme: dark)').addEventListener('change', themed, { signal }); // the system's theme, when the page sets none
4570
+ $('m-theme').addEventListener('click', () => { toggleTheme(); themed(); write(); }); // a link that names the theme names the one shown
4463
4571
  $('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 ? '▶' : '❚❚'; });
4464
4572
  $('m-export-pop').addEventListener('click', e => { const x = e.target.closest('[data-x]')?.dataset.x; if (!x) return; openMenu(null); exportView(x); });
4465
4573
 
@@ -4528,9 +4636,9 @@ export function mountPage(container, model, options = {}) {
4528
4636
  }
4529
4637
  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
4530
4638
 
4531
- /* ⌘K command box: nodes and views, Enter jumps */
4532
- 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() }));
4533
- const viewIdx = views.map(v => ({ v, hay: [v.title, v.id, v.caption].join(' ').toLowerCase() }));
4639
+ /* ⌘K command box: nodes and views, Enter jumps. Indexed from the model shown, as an update() leaves it (issue 133) */
4640
+ 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() }));
4641
+ const viewIdx = () => views.map(v => ({ v, hay: [v.title, v.id, v.caption].join(' ').toLowerCase() }));
4534
4642
  const pal = $('m-pal'), q = $('m-q'), res = $('m-res');
4535
4643
  let hits = [], hi = 0;
4536
4644
  const mark = (s, words) => { let out = esc(s); words.filter(w => w.length > 1).forEach(w => { out = out.replace(new RegExp('(' + w.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') + ')', 'ig'), '<mark>$1</mark>'); }); return out; };
@@ -4541,8 +4649,8 @@ export function mountPage(container, model, options = {}) {
4541
4649
  hits = [...kidsOf(cur), ...views.filter(v => v.id !== cur && !kidsOf(cur).includes(v))].slice(0, 8).map(v => ({ t: 'v', v }));
4542
4650
  } else {
4543
4651
  const starts = s => String(s || '').toLowerCase().startsWith(qs) ? 0 : 1;
4544
- const ns = nodeIdx.filter(x => words.every(w => x.hay.includes(w))).sort((a, b) => starts(a.n.label) - starts(b.n.label) || (geo.byId[b.n.id] ? 1 : 0) - (geo.byId[a.n.id] ? 1 : 0)).slice(0, 7).map(x => ({ t: 'n', n: x.n }));
4545
- const vs = viewIdx.filter(x => words.every(w => x.hay.includes(w))).slice(0, 5).map(x => ({ t: 'v', v: x.v }));
4652
+ const ns = nodeIdx().filter(x => words.every(w => x.hay.includes(w))).sort((a, b) => starts(a.n.label) - starts(b.n.label) || (geo.byId[b.n.id] ? 1 : 0) - (geo.byId[a.n.id] ? 1 : 0)).slice(0, 7).map(x => ({ t: 'n', n: x.n }));
4653
+ const vs = viewIdx().filter(x => words.every(w => x.hay.includes(w))).slice(0, 5).map(x => ({ t: 'v', v: x.v }));
4546
4654
  hits = [...ns, ...vs];
4547
4655
  }
4548
4656
  hi = Math.min(hi, Math.max(0, hits.length - 1));
@@ -4825,7 +4933,9 @@ function printDoc(X, model, Q) {
4825
4933
  const shortT = t => String(t || '').split(' · ')[0];
4826
4934
  const hintHTML = (keys, ctrl) => typeof matchMedia === 'function' && matchMedia('(hover: none)').matches ? '<span>pinch = zoom</span><span>drag = move</span><span>tap = details</span>'
4827
4935
  : `<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'
4828
- 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); }; }
4936
+ function hinter(el, html) { // messages are said in a live region of their own, apart from the key hints (issue 134)
4937
+ let t; const live = document.createElement('div'); live.className = 'um-live'; live.setAttribute('role', 'status'); live.setAttribute('aria-live', 'polite'); el.after(live);
4938
+ return (s, ms = 1800) => { el.textContent = live.textContent = s; el.classList.add('msg'); clearTimeout(t); t = setTimeout(() => { el.innerHTML = html; el.classList.remove('msg'); }, ms); }; }
4829
4939
 
4830
4940
  // Pull concise points from captured prose; retain wording and keep the full point in its tooltip. A point is a sentence
4831
4941
  // or a clause; one longer than a line (160 characters) is cut in two, but a short sentence stays whole (#97).
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "umlimator",
3
- "version": "0.15.0",
3
+ "version": "0.16.0",
4
4
  "description": "Embeddable, layered architecture maps: layout, orthogonal routing and an interactive canvas in one ES module.",
5
5
  "keywords": ["architecture", "diagram", "c4", "map", "svg", "layout", "orthogonal-routing", "drill-down"],
6
6
  "homepage": "https://zintaen.github.io/umlimator/",