umlimator 0.12.0 → 0.12.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -44,7 +44,7 @@ drill-down, a data pipeline, an AI agent's skills, and host pages for embedding,
44
44
  npm install umlimator
45
45
  ```
46
46
 
47
- Or from GitHub, at a tag or any commit: `"umlimator": "github:zintaen/umlimator#v0.12.0"`. npm 12 refuses git
47
+ Or from GitHub, at a tag or any commit: `"umlimator": "github:zintaen/umlimator#v0.12.1"`. npm 12 refuses git
48
48
  dependencies unless the project opts in, so that also needs `allow-git=root` in the `.npmrc` next to `package.json`.
49
49
 
50
50
  ## Embed
@@ -140,7 +140,7 @@ python3 umlimator.py diff --base origin/main --out diff/ # every mo
140
140
  python3 umlimator.py drift model.json traffic.json --out drift.html # drawn vs. seen (a Jaeger or Tempo dependency list)
141
141
  python3 umlimator.py timelapse model.json --out history.html # the model's versions in git history, on a slider
142
142
  python3 umlimator.py check page.html --expand # also each view with each stacked card unfolded
143
- python3 umlimator.py record bike-share.html --story ride --out ride.png # bike-share's story as an animated PNG, a frame per step
143
+ python3 umlimator.py record bike-share.html --story ride --out ride.png # bike-share's story as an animated PNG, its tours riding
144
144
  ```
145
145
 
146
146
  `check` also reports each rule the model breaks, and then exits 1.
@@ -170,7 +170,7 @@ jobs:
170
170
  runs-on: ubuntu-latest
171
171
  steps:
172
172
  - uses: actions/checkout@v4
173
- - uses: zintaen/umlimator@v0.12.0
173
+ - uses: zintaen/umlimator@v0.12.1
174
174
  with:
175
175
  models: "docs/*.json" # optional: where the models are (default: every .json that is a map model)
176
176
  ```
@@ -143,8 +143,9 @@ export interface Handle {
143
143
  on<K extends keyof Events>(type: K, fn: (e: Events[K]) => void): () => void;
144
144
  /** Unfolds a stacked card of the view shown into a box in place (#71): its inner view's own cards inside, this view's
145
145
  * lines to it moved onto them, the rest of the picture in its order. In this handle only, never in the model; kept per
146
- * view across update(). False when it can't: no loaded inner view, nothing of its own inside, a numbered (sequence)
147
- * view in or opened, an editable map. No expand event: that is for gestures. */
146
+ * view across update(). A numbered (sequence) inner view keeps its run order: numbers, loops back, lanes (#82). False
147
+ * when it can't: no loaded inner view, nothing of its own inside, a card on a numbered view, an editable map. No
148
+ * expand event: that is for gestures. */
148
149
  expand(id: string): boolean;
149
150
  /** Folds an unfolded card back (and anything unfolded inside it). False when it was not unfolded. */
150
151
  collapse(id: string): boolean;
@@ -195,8 +196,9 @@ export interface StoryPlayer {
195
196
 
196
197
  /** A mark on a card. tone: a palette name (--um-<tone>), drawn as a ring; badge: a tab after the card's own; level: 0 to 1. */
197
198
  export interface CardMark { tone?: string; badge?: Badge; pulse?: boolean; level?: number }
198
- /** A mark on a line. speed: its flow's speed, as a factor (0 stops it); tone: a glow under it; label: a pill under its own;
199
- * dash: drawn dashed; level: 0 to 1. A drawn line that stands for several edges takes the strongest of their marks. */
199
+ /** A mark on a line. speed: its flow's speed, as a factor (0 stops it); tone: a glow under it; label: a pill under its own,
200
+ * or along the line where it covers nothing (with no room, in the line's tooltip and name instead); dash: drawn dashed;
201
+ * level: 0 to 1. A drawn line that stands for several edges takes the strongest of their marks. */
200
202
  export interface LineMark { tone?: string; speed?: number; pulse?: boolean; label?: string; level?: number; dash?: boolean }
201
203
  /** A layer of marks: cards by node id, lines by edge id ("<from>-><to>#<n>" for an edge without one, as Refs name it).
202
204
  * dim: what the layer doesn't name fades. */
@@ -222,7 +224,8 @@ export function render(container: HTMLElement, model: MapModel, options?: Render
222
224
  export interface History { readonly model: MapModel; readonly canUndo: boolean; readonly canRedo: boolean; push(m: MapModel): MapModel; undo(): MapModel; redo(): MapModel }
223
225
  export function history(model: MapModel, limit?: number): History;
224
226
  /** expand: cards of the view unfolded in place (#71), as handle.expand() draws them; the geometry's `unfolded` lists those
225
- * that did (one inside another after it), and its `families` the boxes they became (`unfolds`: the inner view). */
227
+ * that did (one inside another after it), and its `families` the boxes they became (`unfolds`: the inner view). A
228
+ * numbered inner view's numbers are its lines' and cards' `step`, and its lanes are boxes with `lane: true` (#82). */
226
229
  export function layout(model: MapModel, viewId: string, options?: { repair?: boolean; measure?: (text: string, px: number) => number; expand?: string[] }): Geometry & { unfolded?: string[] };
227
230
  /** A model's own problem. path says where: "views[2].nodeIds[4]". Errors draw as holes or not at all (missing,
228
231
  * repeated or dangling ids, a box inside itself); warnings draw other than they say (an unknown kind, weight or side,
package/lib/umlimator.js CHANGED
@@ -264,6 +264,7 @@ const SIGIL = {
264
264
  const CHG = { added: ['green', 'NEW'], new: ['green', 'NEW'], modified: ['amber', 'CHANGED'], changed: ['amber', 'CHANGED'], removed: ['rose', 'REMOVED'], planned: ['cyan', 'PLANNED'] };
265
265
  const NW = 168, CG0 = 84, RG0 = 44;
266
266
  const pillW = (label, sub, max = 440) => Math.max(sansW(fit(label, 10.5, max), 10.5, .92), sub ? sansW(fit(sub, 9.5, max), 9.5, .92) : 0) + 18; // a line's label, and its sublabel under it (#44)
267
+ const stepW = s => String(s).length > 2 ? Math.max(16, sansW(String(s), 9.5, .9) + 8) : 16; // a line's step mark: a 16 px circle, or a pill for a number such as 4.1 (#82)
267
268
  // Text width. Without a measurer: a monospace estimate (font-independent, so layouts are the same on every machine;
268
269
  // the bench and layout() in Node use it). render() sets measureText to the host's own font while it lays out a view.
269
270
  let measureText = null;
@@ -483,10 +484,10 @@ const mapTabs = (n, c) => { const tw = t => t.toUpperCase().length * 6.4 + 10, b
483
484
  const t = n.k === 'm-public' ? 'PUBLIC' : n.k === 'm-ext' ? 'EXTERNAL' : n.k === 'm-lane' ? 'LANE' : ''; return t ? [{ text: t, fill: c, w: tw(t) }] : []; };
484
485
  const mapNodeW = n => {
485
486
  const px = n.emphasis ? 15 : 13;
486
- const sub = n.sub || n.sublabel || '';
487
+ const sub = n.sub || n.sublabel || '', chips = (n.verbs || []).reduce((a, v) => a + textW(v, 9.5) + 14, -4); // its chips as svgOf draws them: each verb and 10 px, 4 px apart (#84)
487
488
  return Math.min(n.emphasis ? 205 : 386, Math.max(n.emphasis ? 146 : 138,
488
489
  Math.ceil(textW(n.label, px) + 70), badgesOf(n).length ? Math.ceil(badgesOf(n).reduce((a, b) => a + b.text.length * 6.4 + 10, 0) + (badgesOf(n).length - 1) * 4 + 120) : 0,
489
- Math.min(386, Math.ceil(textW(sub, 10) + 70))));
490
+ Math.min(386, Math.ceil(Math.max(textW(sub, 10), chips) + 70))));
490
491
  };
491
492
  /* Geometry: node boxes, ports, orthogonal routes, label slots. A view with lines the router found no route for is laid
492
493
  out once more, with room where those lines need it (#40); a view whose lines all route keeps its geometry. */
@@ -523,8 +524,11 @@ function layoutPass(dg, map, room, last) { // room: { x: { col: px }, y: { row:
523
524
  return c ? [...(c.nodeIds || []), ...(dg.containers || []).filter(k => k.parent === b).flatMap(k => inBox(k.id, seen))] : []; };
524
525
  const near = (b, other) => { const cs = inBox(b).map(i => colOf[i]).filter(c => c != null); if (other == null || !cs.length) return null;
525
526
  const lo = Math.min(...cs), hi = Math.max(...cs); return other < lo ? lo : other > hi ? hi : null; };
527
+ // an unfolded numbered view's lane is a box in its box (#82): a line to a card in one, or to that box, crosses two borders.
528
+ // ponytail: lanes only; an inner view's own nested boxes have the same 12 px less, but room for them moves views drawn today
529
+ const inLane = id => (dg.containers || []).some(f => f.lane && (f.parent === id || (f.nodeIds || []).includes(id)));
526
530
  (dg.edges || []).forEach(e => { const p = colOf[e.from] ?? near(e.from, colOf[e.to]), q = colOf[e.to] ?? near(e.to, colOf[e.from]); if (p == null || q == null || !e.label || Math.abs(p - q) !== 1) return;
527
- const c = Math.min(p, q), w = pillW(e.label, e.sublabel) + (e.step != null ? 20 : 0), box = boxOf(e.from) !== boxOf(e.to) || !(e.from in colOf) || !(e.to in colOf) ? 48 : 0;
531
+ const c = Math.min(p, q), w = pillW(e.label, e.sublabel) + (e.step != null ? stepW(e.step) + 4 : 0), box = boxOf(e.from) !== boxOf(e.to) || !(e.from in colOf) || !(e.to in colOf) ? 48 + ([e.from, e.to].some(inLane) ? 12 : 0) : 0;
528
532
  gapAfter[c] = Math.max(gapAfter[c] || 0, w + 54 + box);
529
533
  [e.from, e.to].forEach(id => { const k = c + '|' + id, t = sum[k] ||= { w: -6, box: 0 }; t.w += w + 6; t.box = Math.max(t.box, box); gapAfter[c] = Math.max(gapAfter[c], t.w + 54 + t.box); }); }); }
530
534
  const extraX = { ...(room || {}).x }, extraY = { ...(room || {}).y }; // more room after a column (row): for lines with no route, and where a box would touch another box (below)
@@ -820,7 +824,9 @@ function layoutPass(dg, map, room, last) { // room: { x: { col: px }, y: { row:
820
824
  hpop(); const k = pk, cell = k >> 2, dir = k & 3, i = (cell / ny) | 0, j = cell - i * ny;
821
825
  if (pd > dist[k] + (hx[i] + hy[j])) continue;
822
826
  const d = dist[k];
823
- if (cell === t0) { best = k; if (dir === dEnd) break; continue; }
827
+ // in head-on if it can; else the cheapest way in, the first found (it kept the last, the dearest: a top-to-top line on
828
+ // the top row ran past its card and back, #80)
829
+ if (cell === t0) { if (dir === dEnd) { best = k; break; } if (best < 0) best = k; continue; }
824
830
  const xi = X[i], yj = Y[j];
825
831
  for (let nd = 0; nd < 4; nd++) {
826
832
  if ((nd ^ 1) === dir && ((nd >> 1) === (dir >> 1))) continue; // no reversal
@@ -863,7 +869,7 @@ function layoutPass(dg, map, room, last) { // room: { x: { col: px }, y: { row:
863
869
  const segs = segsOf(e.pts).map((s, i) => ({ a: s[0], b: s[1], i, l: Math.hypot(s[1][0] - s[0][0], s[1][1] - s[0][1]), h: s[0][1] === s[1][1] }));
864
870
  const order = [...segs].sort((p, q) => (q.h - p.h) * 40 + (q.l - p.l));
865
871
  for (const pass of (map ? [0, 1, 2] : [0])) for (const text of tries) { const relax = pass === 2; // 0 strict, 1 strict but may touch a box border, 2 relaxed
866
- const two = !!(text && sub), pill = map && e.step != null, w = map ? (text ? pillW(text, two && sub) : 0) + (pill ? (text ? 20 : 22) : 0) : textW(text, 10) + 12, hgt = map ? (two ? 32 : 20) : 18;
872
+ const two = !!(text && sub), pill = map && e.step != null, w = map ? (text ? pillW(text, two && sub) : 0) + (pill ? stepW(e.step) + (text ? 4 : 6) : 0) : textW(text, 10) + 12, hgt = map ? (two ? 32 : 20) : 18;
867
873
  for (const sg of order) {
868
874
  if (!relax && sg.h && sg.l < w + 10) continue;
869
875
  if (!relax && !sg.h && sg.l < hgt + 14) continue;
@@ -876,7 +882,7 @@ function layoutPass(dg, map, room, last) { // room: { x: { col: px }, y: { row:
876
882
  if (placed.some(r => (pass === 0 || !r.band) && hit(r, grow))) continue;
877
883
  if (!relax && routed.some(r => r !== e && segsOf(r.pts).some(([a, b]) => segHitsRect(a, b, grow)))) continue;
878
884
  if (!relax && segs.some(s2 => s2.i !== sg.i && segHitsRect(s2.a, s2.b, box))) continue;
879
- placed.push(box); reserved.push(grow); e.lbl = { ...box, text, ...(two ? { sub } : {}), step: map ? e.step : null, short: text !== full }; return;
885
+ placed.push(box); reserved.push(grow); e.lbl = { ...box, text, ...(two ? { sub } : {}), step: map ? e.step : null, ...(pill ? { sw: stepW(e.step) } : {}), short: text !== full }; return;
880
886
  }
881
887
  }
882
888
  }
@@ -1151,7 +1157,7 @@ return I && I.src ? `<rect x="${x - M * 3 / 28}" y="${y - M * 3 / 28}" width="${
1151
1157
  let vx = 0; const chips = [], maxW = third ? sw : n.w - 24; // on a third line they keep to the text's column, clear of the mark
1152
1158
  for (let i = 0; i < verbs.length; i++) {
1153
1159
  const wv = textW(verbs[i], 9.5) + 10, rest = verbs.length - i - 1, more = rest ? textW('+' + rest, 9.5) + 10 : 0;
1154
- if (vx + wv + (rest ? more + 4 : 0) > maxW && i) { chips.push(['+' + (verbs.length - i), textW('+' + (verbs.length - i), 9.5) + 10, true]); break; }
1160
+ if (vx + wv + (rest ? more + 4 : 0) > maxW) { chips.push(['+' + (verbs.length - i), textW('+' + (verbs.length - i), 9.5) + 10, true]); break; } // the first too: a verb wider than any card is "+N" (#84)
1155
1161
  chips.push([verbs[i], wv]); vx += wv + 4;
1156
1162
  }
1157
1163
  const total = chips.reduce((a, c2) => a + c2[1], 0) + (chips.length - 1) * 4;
@@ -1182,8 +1188,9 @@ return I && I.src ? `<rect x="${x - M * 3 / 28}" y="${y - M * 3 / 28}" width="${
1182
1188
  if (!e.lbl) return;
1183
1189
  const L = e.lbl;
1184
1190
  if (map) {
1185
- const st = L.step != null ? `<circle cx="${L.x + 11}" cy="${L.y + L.h / 2}" r="8" class="e-step"/><text x="${L.x + 11}" y="${L.y + L.h / 2 + 3.3}" text-anchor="middle" class="e-step-tx">${esc(L.step)}</text>` : '';
1186
- const ax = L.step != null ? L.x + 23 : L.x + L.w / 2, an = L.step != null ? '' : 'text-anchor="middle"';
1191
+ const sw = L.sw || 16, cy = L.y + L.h / 2; // the step mark: a circle, or a pill as wide as a longer number (#82)
1192
+ const st = L.step != null ? `${sw > 16 ? `<rect x="${L.x + 3}" y="${cy - 8}" width="${sw}" height="16" rx="8" class="e-step"/>` : `<circle cx="${L.x + 11}" cy="${cy}" r="8" class="e-step"/>`}<text x="${L.x + 3 + sw / 2}" y="${cy + 3.3}" text-anchor="middle" class="e-step-tx">${esc(L.step)}</text>` : '';
1193
+ const ax = L.step != null ? L.x + 7 + sw : L.x + L.w / 2, an = L.step != null ? '' : 'text-anchor="middle"';
1187
1194
  const tx = L.text ? `<text x="${ax}" y="${L.y + 14}" ${an} class="e-label" data-t="el" data-o="${esc(e.from)}>${esc(e.to)}">${esc(L.text)}</text>` : '';
1188
1195
  const sb = L.sub ? `<text x="${ax}" y="${L.y + 26}" ${an} class="e-sub" data-t="el" data-o="${esc(e.from)}>${esc(e.to)}">${esc(L.sub)}</text>` : '';
1189
1196
  s += `<g class="e-lab${L.tag ? ' ctag' : ''}${e.wk ? ' w-' + e.wk : ''}" data-i="${i}"><rect x="${L.x}" y="${L.y}" width="${L.w}" height="${L.h}" rx="${Math.min(L.h / 2, 10)}" class="e-lbg"/>${st}${tx}${sb}</g>`;
@@ -1447,6 +1454,8 @@ function auditSvg(svg, geo, where) {
1447
1454
  nodes.forEach(n => { if (n !== own && hit(a.b, R(n), -1)) issues.push(`${where}: text "${a.txt}" overlaps node "${n.label}"`); });
1448
1455
  }
1449
1456
  });
1457
+ svg.querySelectorAll('rect.verb-chip').forEach(r => { const own = geo.byId[r.closest('[data-id]').dataset.id], x = +r.getAttribute('x'); // a chip's pill, not only its text (#84)
1458
+ if (own && (x < own.x + 1 || x + +r.getAttribute('width') > own.x + own.w - 1)) issues.push(`${where}: chip "${r.nextElementSibling.textContent}" overflows node "${own.label}"`); });
1450
1459
  issues.push(...routeAudit(geo, where), ...leaveIssues(geo, where));
1451
1460
  const els = T.filter(a => a.t === 'el');
1452
1461
  for (let i = 0; i < els.length; i++) for (let j = i + 1; j < els.length; j++) if (hit(els[i].b, els[j].b, -1)) issues.push(`${where}: edge labels "${els[i].txt}" and "${els[j].txt}" overlap`);
@@ -1784,7 +1793,7 @@ function mapContext(model, opts = {}) {
1784
1793
  const ready = drawn[vid]; delete drawn[vid]; if (ready && !override && ready.pos === pos && repair === (v.repair !== false) && !quick) return ready.g;
1785
1794
  const mainSteps = v.mainSteps || v.render === 'process';
1786
1795
  const stepIds = v.stepIds || ids;
1787
- const dg = { nodeH: mainSteps ? 82 : v.subAndChips ? 80 : 64, subAndChips: !!v.subAndChips, mainSteps, containers: v.containers, nodes: ids.map(i => { const n = nodeIn(i, vid), step = mainSteps && stepIds.includes(i); return { ...n, sub: n.sub || n.sublabel, emphasis: !!step || !!n.emphasis, step: step ? stepIds.indexOf(i) + 1 : null, verbs: step ? [] : n.verbs, kids: kidCount(i, vid), opens: !!opensOf(i, vid), col: (pos[i] || [0, 0])[0], row: (pos[i] || [0, 0])[1] }; }),
1796
+ const dg = { nodeH: mainSteps ? 82 : v.subAndChips ? 80 : 64, subAndChips: !!v.subAndChips, mainSteps, containers: v.containers, nodes: ids.map(i => { const n = nodeIn(i, vid), step = mainSteps && stepIds.includes(i); return { ...n, sub: n.sub || n.sublabel, emphasis: !!step || !!n.emphasis, step: step ? stepIds.indexOf(i) + 1 : (v.cardSteps || {})[i] || null, verbs: step ? [] : n.verbs, kids: kidCount(i, vid), opens: !!opensOf(i, vid), col: (pos[i] || [0, 0])[0], row: (pos[i] || [0, 0])[1] }; }),
1788
1797
  edges: prepEdges(vid, edges, P.backKeys) };
1789
1798
  dg.authors = D.authors; dg.repair = repair; dg.intake = v.intake; dg.tour = v.tour; dg.gap = v.gap; dg.rgap = v.rgap; dg.quick = quick;
1790
1799
  return layoutGraph(dg, true);
@@ -1902,20 +1911,26 @@ function mapContext(model, opts = {}) {
1902
1911
  same way round, or, with none, ends on the box's border; the inner view's other lines to neighbours are not added. The
1903
1912
  box is the inner view's own cards in their own order, at the card's place: later columns move right by its width, and
1904
1913
  the cards under it in its column move down by its height, so the rest of the picture keeps its places (a hub view, which
1905
- has no columns, is laid out again). The inner view's boxes that hold its own cards come along inside. Neither in nor of
1906
- a numbered (sequence) view: its run order, numbers and lanes, would move or be lost; that one opens. */
1914
+ has no columns, is laid out again). The inner view's boxes that hold its own cards come along inside, and a numbered
1915
+ (sequence) view's run order: its numbers, its loops back and its lanes (#82). Not in a numbered view: its own steps
1916
+ would be numbered again; that card opens. */
1917
+ // the inner view's own cards: those this view shows neither as cards nor as boxes (a card unfolded here already is one, #98)
1918
+ const ownOf = (v, C, nid) => (C.nodeIds || []).filter(i => N[i] && i !== nid && !(v.nodeIds || []).includes(i) && !(v.containers || []).some(b => b && b.id === i));
1907
1919
  const unfoldable = (nid, vid) => { const v = V[vid], ids = (v && v.nodeIds) || [], c = ids.includes(nid) && N[nid] && v.render !== 'sequence' ? childOf(nid, vid) : null;
1908
- if (!c || c === vid || V[c].render === 'sequence' || chain(vid).includes(c) || (v.containers || []).some(b => b && b.id === nid)) return null;
1909
- return (V[c].nodeIds || []).some(i => N[i] && i !== nid && !ids.includes(i)) ? c : null; };
1920
+ if (!c || c === vid || chain(vid).includes(c) || (v.containers || []).some(b => b && b.id === nid)) return null;
1921
+ return ownOf(v, V[c], nid).length ? c : null; };
1910
1922
  function unfold(vid, nid) {
1911
1923
  const v = V[vid], c = unfoldable(nid, vid); if (!c) return false;
1912
- const C = V[c], outer = (v.nodeIds || []).filter(i => N[i]), inner = (C.nodeIds || []).filter(i => N[i] && i !== nid && !outer.includes(i));
1924
+ const C = V[c], outer = (v.nodeIds || []).filter(i => N[i]), inner = ownOf(v, C, nid);
1913
1925
  const n0 = nodeIn(nid, vid), holder = (v.containers || []).filter(b => b && (b.nodeIds || []).includes(nid)).pop();
1914
1926
  const boxes = (v.containers || []).filter(b => b && b.id).map(b => ({ ...b, nodeIds: (b.nodeIds || []).filter(i => i !== nid) }));
1915
1927
  const taken = new Set([...outer, ...inner, ...boxes.map(b => b.id)]);
1916
1928
  const cb = (C.containers || []).filter(b => b && b.id && !taken.has(b.id) && (b.nodeIds || []).some(i => inner.includes(i))), kept = new Set(cb.map(b => b.id));
1917
1929
  const above = b => { let p = b.parent; for (let k = 0; p != null && !kept.has(p) && k < 64; k++) p = ((C.containers || []).find(x => x && x.id === p) || {}).parent; return kept.has(p) ? p : nid; };
1918
- const own = cb.map(b => ({ ...b, nodeIds: b.nodeIds.filter(i => inner.includes(i)), parent: above(b) })), boxed = new Set(own.flatMap(b => b.nodeIds));
1930
+ // a numbered view drawn around one card (a hub) brings its lanes, boxed as it draws them, in their order (#82)
1931
+ const hp = C.render === 'sequence' && planOf(c).hub ? planOf(c) : null;
1932
+ const lanes = hp && hp.boxed ? hp.lanes.map((L, j) => ({ id: `${nid}:lane${j + 1}`, label: L.title, lane: true, nodeIds: L.ids.filter(i => inner.includes(i)), parent: nid })).filter(b => b.nodeIds.length) : [];
1933
+ const own = [...cb.map(b => ({ ...b, nodeIds: b.nodeIds.filter(i => inner.includes(i)), parent: above(b) })), ...lanes], boxed = new Set(own.flatMap(b => b.nodeIds));
1919
1934
  const box = { id: nid, label: n0.label || nid, nodeIds: inner.filter(i => !boxed.has(i)), unfolds: c, ...(holder ? { parent: holder.id } : {}), ...(badgesOf(n0).length ? { badges: badgesOf(n0) } : {}) };
1920
1935
  const isIn = id => inner.includes(id) || kept.has(id), mine = viewEdges(v);
1921
1936
  const to = new Set(mine.filter(e => e.to === nid).map(e => e.from)), from = new Set(mine.filter(e => e.from === nid).map(e => e.to)); // this view's neighbours of the card
@@ -1924,6 +1939,16 @@ function mapContext(model, opts = {}) {
1924
1939
  const ends = (a, out) => [...new Set(theirs.filter(e => out ? isIn(e.from) && e.to === a : e.from === a && isIn(e.to)).map(e => out ? e.from : e.to))].join();
1925
1940
  const split = new Set([...to].filter(a => from.has(a) && (ends(a, true) || ends(a, false)) && (ends(a, true) !== ends(a, false) || ends(a, true).includes(','))));
1926
1941
  theirs = theirs.filter(e => !split.has(isIn(e.from) ? e.to : e.from));
1942
+ /* a numbered view's run order comes along (#82): its lines keep their numbers, and a step back stays a loop back. A step
1943
+ between the card itself and a card inside has no line (the card is the box), and a hub's steps show on the cards they
1944
+ reach, as the numbered view draws them: those numbers go on that card inside. On a numbered card (a process view's
1945
+ step, or one an unfolded numbered view numbered) they go under its number: step 4's are 4.1, 4.2 */
1946
+ const tags = {}, onCard = e => e.from === nid ? e.to : e.to === nid ? e.from : hp && e.from === hp.hub ? e.to : null;
1947
+ const sid = v.stepIds || outer, at = (v.mainSteps || v.render === 'process') && sid.includes(nid) ? sid.indexOf(nid) + 1 : (v.cardSteps || {})[nid];
1948
+ const num = j => /^\d+(\.\d+)*$/.test(String(at ?? '')) ? `${at}.${j + 1}` : j + 1; // a card with several numbers (2·3) holds no one step
1949
+ if (C.render === 'sequence') { const lq = loopsOf(C);
1950
+ lq.edges.forEach((e, j) => { const o = onCard(e); if (inner.includes(o)) (tags[o] ||= []).push(num(j)); });
1951
+ theirs = theirs.map(e => { const j = lq.edges.indexOf(e); return { ...e, ...(inner.includes(onCard(e)) ? {} : { step: num(j) }), ...(lq.back.has(j) ? { loop: true } : {}) }; }); }
1927
1952
  const exact = new Set(theirs.flatMap(e => isIn(e.from) === isIn(e.to) ? [] : isIn(e.to) ? [e.from + '>'] : ['>' + e.to])); // neighbours the inner view joins to a card inside
1928
1953
  const k = (v.nodeIds || []).indexOf(nid), grid = !planOf(vid).hub;
1929
1954
  const pos = grid ? (v.layout ? fillLayout(vid, v.layout) : (layoutCache[vid] ||= autoPos(vid))) : null;
@@ -1933,6 +1958,7 @@ function mapContext(model, opts = {}) {
1933
1958
  if (v.mainSteps || v.render === 'process') v.stepIds = v.stepIds || outer; // the steps keep their numbers; the cards inside are not steps
1934
1959
  if (Array.isArray(v.intake)) v.intake = v.intake.filter(s => s && s.to !== nid);
1935
1960
  if (+C.gap > (v.gap || 66)) v.gap = +C.gap; if (+C.rgap > (v.rgap || 44)) v.rgap = +C.rgap; // the room the inner view's lines were given
1961
+ Object.entries(tags).forEach(([i, s]) => { (v.cardSteps ||= {})[i] = s.length > 2 ? s[0] + '+' : s.join('·'); }); // as hubGeo's stepTxt; buildGeo draws them
1936
1962
  delete v.layout; delete planCache[vid]; delete layoutCache[vid]; delete drawn[vid];
1937
1963
  if (!grid) return true; // a hub view has no columns to keep: laid out again, with its boxes
1938
1964
  /* the box's slots: the inner view's own cards in its columns, or in this view's reading order, and in its rows packed,
@@ -1950,7 +1976,11 @@ function mapContext(model, opts = {}) {
1950
1976
  // with another, or not at all
1951
1977
  const inBox = (id, seen = new Set()) => seen.has(id) ? [] : (seen.add(id), [...((boxes.find(x => x.id === id) || {}).nodeIds || []), ...boxes.filter(k => k.parent === id).flatMap(k => inBox(k.id, seen))]);
1952
1978
  const xBoxes = new Set(); for (let b = holder, k = 0; b && k < 64; k++, b = boxes.find(x => x.id === b.parent)) xBoxes.add(b.id); // the boxes the card is in
1953
- const ip0 = C.layout ? fillLayout(c, C.layout) : (layoutCache[c] ||= autoPos(c)), lp = loopsOf(v), rk = Object.fromEntries(inner.map(i => [i, 0]));
1979
+ // a hub's slots, as it draws them: the hub, then its lanes' columns in order (#82). ponytail: rows in each column's order,
1980
+ // not level with the part before as hubGeo lines them up; do that if a lane of several columns reads badly
1981
+ const hs = hp && { [hp.hub]: [0, 0] };
1982
+ if (hs) hp.lanes.flatMap(L => L.cols).map(col => col.filter(i => inner.includes(i))).filter(col => col.length).forEach((col, j) => col.forEach((i, r) => { hs[i] = [j + 1, r]; }));
1983
+ const ip0 = hs || (C.layout ? fillLayout(c, C.layout) : (layoutCache[c] ||= autoPos(c))), lp = loopsOf(v), rk = Object.fromEntries(inner.map(i => [i, 0]));
1954
1984
  const fwd = lp.edges.filter((e, j) => !lp.back.has(j) && e.from !== e.to && inner.includes(e.from) && inner.includes(e.to));
1955
1985
  for (let it = 0; it < inner.length; it++) fwd.forEach(e => { if (rk[e.to] < rk[e.from] + 1) rk[e.to] = rk[e.from] + 1; });
1956
1986
  const one = own.length || v.nodeIds.length > 30; // its boxes' room to keep, or too big to route seven times: its own slots only
@@ -2988,6 +3018,7 @@ export function render(container, model, options = {}) {
2988
3018
  s.querySelectorAll('.node[opacity], .edge-g[opacity], .e-lab[opacity]').forEach(x => x.removeAttribute('opacity'));
2989
3019
  s.querySelectorAll('.edge[style], .flowline[style]').forEach(x => x.removeAttribute('style'));
2990
3020
  s.querySelectorAll('.node[aria-description]').forEach(x => x.removeAttribute('aria-description'));
3021
+ s.querySelectorAll('[data-was]').forEach(x => { if (x.tagName === 'title') x.textContent = x.getAttribute('data-was'); else x.setAttribute('aria-label', x.getAttribute('data-was')); x.removeAttribute('data-was'); });
2991
3022
  if (s === svg) stage.classList.toggle('um-lit', [...layers.values()].some(L => L.dim));
2992
3023
  if (!layers.size) return;
2993
3024
  const tone = t => 'var(--um-' + String(t || 'accent').replace(/[^\w-]/g, '') + ')', op = m => m.level < 1 ? ` opacity="${m.level}"` : '';
@@ -3000,6 +3031,9 @@ export function render(container, model, options = {}) {
3000
3031
  const x = mapTabs(n).reduce((a, t) => a + t.w + 4, n.x + 12), w = b.text.toUpperCase().length * 6.4 + 10;
3001
3032
  over += `<g class="um-tab"${op(m)}><rect x="${x}" y="${n.y - 8}" width="${w}" height="15" rx="4" fill="${toneOf(b)}"/><text x="${x + w / 2}" y="${n.y + 3}" text-anchor="middle" class="tab-tx">${esc(b.text)}</text></g>`;
3002
3033
  el.setAttribute('aria-description', b.text); });
3034
+ const heads = (g.families || []).flatMap(f => [{ x: f.x + 8, y: f.y + 3, w: String(f.label || f.id).length * 6.6 + 8, h: 22 }, // a box's title, 11 px mono
3035
+ ...(f.tabs || []).map(t => ({ x: t.x, y: f.y - 8, w: t.w, h: 16 }))]); // and its tabs
3036
+ const pills = [], free = r => !g.nodes.some(n => hit(r, n, 8) || (n.tag && hit(r, n.tag, 2))) && !heads.some(h => hit(r, h, 2)) && !g.edges.some(o => o.lbl && hit(r, o.lbl, 2)) && !pills.some(p => hit(r, p, 2));
3003
3037
  g.edges.forEach((e, i) => { const m = markOf(L => strongest(edgeRefIds(e).map(x => (L.edges || {})[x]))), eg = s.querySelector(`.edge-g[data-i="${i}"]`); if (!m || !eg) return;
3004
3038
  const path = eg.querySelector('.edge'), flow = eg.querySelector('.flowline');
3005
3039
  if (m.level < 1) [eg, s.querySelector(`.e-lab[data-i="${i}"]`)].forEach(x => x && x.setAttribute('opacity', m.level));
@@ -3007,10 +3041,19 @@ export function render(container, model, options = {}) {
3007
3041
  if (m.dash) path.setAttribute('style', 'stroke-dasharray: 7 5');
3008
3042
  if (m.speed != null && flow) flow.setAttribute('style', +m.speed > 0 ? `animation-duration: ${(2.4 / m.speed).toFixed(2)}s` : 'animation: none; opacity: 0');
3009
3043
  if (m.label == null || m.label === '') return;
3010
- // ponytail: the label sits under the line's own (else on its longest run) with no room kept for it, so it may cover a neighbour's
3011
- const t = String(m.label), w = sansW(t, 10, .92) + 14, L = e.lbl, [a, b] = L ? [] : longest(e.pts);
3012
- const x = L ? L.x + L.w / 2 : (a[0] + b[0]) / 2, y = L ? L.y + L.h + 2 : (a[1] + b[1]) / 2 - 8;
3013
- over += `<g class="um-lab"${op(m)}><rect x="${x - w / 2}" y="${y}" width="${w}" height="16" rx="8"${m.tone ? ` style="stroke: ${tone(m.tone)}"` : ''}/><text x="${x}" y="${y + 11.5}" text-anchor="middle">${esc(t)}</text></g>`; });
3044
+ // the pill takes the first place that covers no card, box title or tab, line label or pill before it (#81): under the
3045
+ // line's own label (else on its longest run), then a few places along the line. The view is not laid out again for
3046
+ // it, so with no room at any, its text goes in the line's tooltip and accessible name instead
3047
+ // ponytail: six places, and another line may lie under one; scan along the line if too few find room
3048
+ const t = String(m.label), w = sansW(t, 10, .92) + 14, L = e.lbl, [a, b] = longest(e.pts), len = lenOf(e.pts);
3049
+ const box = [L ? [L.x + L.w / 2, L.y + L.h + 10] : [(a[0] + b[0]) / 2, (a[1] + b[1]) / 2], ...[.5, .3, .7, .15, .85].map(f => along(e.pts, f * len))]
3050
+ .map(([x, y]) => ({ x: x - w / 2, y: y - 8, w, h: 16 })).find(free);
3051
+ if (!box) { const tt = eg.querySelector(':scope > title'), key = s.querySelector(`.line-key[data-i="${i}"]`);
3052
+ if (tt) { tt.setAttribute('data-was', tt.textContent); tt.textContent += '\n' + t; }
3053
+ if (key) { key.setAttribute('data-was', key.getAttribute('aria-label')); key.setAttribute('aria-label', key.getAttribute('aria-label') + ', ' + t); }
3054
+ return; }
3055
+ pills.push(box);
3056
+ over += `<g class="um-lab"${op(m)}><rect x="${box.x}" y="${box.y}" width="${w}" height="16" rx="8"${m.tone ? ` style="stroke: ${tone(m.tone)}"` : ''}/><text x="${box.x + w / 2}" y="${box.y + 11.5}" text-anchor="middle">${esc(t)}</text></g>`; });
3014
3057
  const cardsG = s.querySelector('.nodes');
3015
3058
  if (under && cardsG) cardsG.insertAdjacentHTML('beforebegin', `<g class="um-rings" aria-hidden="true">${under}</g>`);
3016
3059
  if (over) s.insertAdjacentHTML('beforeend', `<g class="um-marks" aria-hidden="true">${over}</g>`);
@@ -3056,10 +3099,15 @@ export function render(container, model, options = {}) {
3056
3099
  const prev = legs[legs.length - 1], n = geo.byId[ids[k]];
3057
3100
  const pts = prev && prev.to === ids[k] ? [prev.pts[prev.pts.length - 1], [n.x + n.w / 2, n.y + n.h / 2], ...r.pts] : r.pts;
3058
3101
  legs.push({ pts, to: ids[k + 1], len: lenOf(pts) }); }
3059
- if (!legs.length || reducedMotion()) return Promise.resolve(legs.length > 0);
3102
+ if (!legs.length || (reducedMotion() && P.tourAt == null)) return Promise.resolve(legs.length > 0);
3060
3103
  const speed = Math.max(20, +o.speed || 240); // map units a second
3061
3104
  svg.insertAdjacentHTML('beforeend', '<g class="um-trip" aria-hidden="true"><circle r="5.5" class="tour-dot"/></g>');
3062
3105
  const g = svg.lastElementChild, dot = g.firstElementChild; let leg = 0, base = 0;
3106
+ if (P.tourAt != null) { // the page's #shot=1&at=<0 to 1>: the dot held that far along the ride, a frame for umlimator.py record (#83)
3107
+ let d = clamp01(P.tourAt) * legs.reduce((s, l) => s + l.len, 0); while (leg < legs.length - 1 && d > legs[leg].len) d -= legs[leg++].len;
3108
+ const [x, y] = along(legs[leg].pts, d); dot.setAttribute('cx', x); dot.setAttribute('cy', y);
3109
+ trip = { raf: 0, dot: g, end() {} }; return Promise.resolve(true);
3110
+ }
3063
3111
  return new Promise(res => { startTrip(ms => { const d = ms * speed / 1000;
3064
3112
  while (leg < legs.length && d - base >= legs[leg].len) { base += legs[leg].len; flash(legs[leg].to); leg++; }
3065
3113
  if (leg >= legs.length) return false;
@@ -3443,11 +3491,12 @@ export function mountPage(container, model, options = {}) {
3443
3491
  }
3444
3492
  const setTree = on => { document.body.classList.toggle('tree-open', on); $('m-outline').setAttribute('aria-expanded', on); };
3445
3493
 
3446
- // Pull concise points from captured prose; retain wording and keep the full point in its tooltip.
3494
+ // Pull concise points from captured prose; retain wording and keep the full point in its tooltip. A point is a sentence
3495
+ // or a clause; one longer than a line (160 characters) is cut in two, but a short sentence stays whole (#97).
3447
3496
  function detailBullets(body) {
3448
3497
  const normalized = String(body || '').replace(/^\s*(?:[-*]|\d+[.)])\s+/gm, '').replace(/\n+/g, ' ').trim();
3449
3498
  let parts = normalized.split(/(?<=[.!?])\s+(?=[A-Z`/])|;\s+|\s+[·|]\s+/).filter(Boolean);
3450
- while (parts.length < 2 || parts.some(p => p.length > 160) && parts.length < 4) {
3499
+ while (parts.some(p => p.length > 160) && parts.length < 4) {
3451
3500
  const index = parts.reduce((best, p, i) => p.length > (parts[best] || '').length ? i : best, 0), value = parts[index];
3452
3501
  if (!value || value.length < 8) break;
3453
3502
  const separators = [...value.matchAll(/:\s+|,\s+|\s+(?=then\b|with\b|before\b|after\b|once\b|to\b|and\b|or\b|during\b|by\b|whose\b|outside\b|on\b|for\b|as\b|whenever\b|into\b|in\b|from\b|when\b|includes\b|typed\b)/g)].map(m => m.index + m[0].length);
@@ -3748,7 +3797,7 @@ export function mountPage(container, model, options = {}) {
3748
3797
  view: v0, minimap: true, flow: flowPref ? flowPref === '1' : undefined,
3749
3798
  on: { story: ({ story: id, step }) => { told = step == null ? null : { id, step }; app.classList.toggle('story', !!told); if (X) setHash(true); } }, // from the first draw: a link may open a story (#69)
3750
3799
  _page: {
3751
- instant: options.instant, defer: !INSTANT, tour: !Q.get('still'), openHint: !Q.get('reader'), kinds: true, status,
3800
+ instant: options.instant, defer: !INSTANT, tour: !Q.get('still'), openHint: !Q.get('reader'), kinds: true, status, tourAt: Q.get('shot') ? Q.get('at') : null,
3752
3801
  wide: () => innerWidth > 820,
3753
3802
  upTarget: () => stack.length > 1 ? stack[stack.length - 2] : (V[cur] || {}).parent,
3754
3803
  inset: () => { const top = Math.max(58, 26 + $('m-vpill').offsetHeight); if (innerWidth <= 820) return { t: top, r: 12, b: 12, l: 12 }; const lg = $('m-legend'), on = lg && !lg.hidden; // the legend box sits bottom left
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "umlimator",
3
- "version": "0.12.0",
3
+ "version": "0.12.1",
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/",