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 +3 -3
- package/lib/umlimator.d.ts +8 -5
- package/lib/umlimator.js +74 -25
- package/package.json +1 -1
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.
|
|
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,
|
|
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.
|
|
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
|
```
|
package/lib/umlimator.d.ts
CHANGED
|
@@ -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().
|
|
147
|
-
* view
|
|
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
|
-
*
|
|
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 ?
|
|
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
|
|
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 ?
|
|
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
|
|
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
|
|
1186
|
-
const
|
|
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
|
|
1906
|
-
|
|
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 ||
|
|
1909
|
-
return (V[c]
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
3011
|
-
|
|
3012
|
-
|
|
3013
|
-
|
|
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.
|
|
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.
|
|
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/",
|