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 +3 -3
- package/lib/umlimator.js +165 -55
- package/package.json +1 -1
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.
|
|
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.
|
|
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
|
-
|
|
872
|
-
const
|
|
873
|
-
|
|
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
|
-
//
|
|
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))
|
|
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
|
|
1019
|
-
const
|
|
1020
|
-
|
|
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
|
-
|
|
1259
|
-
|
|
1260
|
-
|
|
1261
|
-
|
|
1262
|
-
|
|
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
|
-
|
|
1274
|
-
|
|
1275
|
-
|
|
1276
|
-
|
|
1277
|
-
|
|
1278
|
-
|
|
1279
|
-
|
|
1280
|
-
|
|
1281
|
-
|
|
1282
|
-
|
|
1283
|
-
|
|
1284
|
-
|
|
1285
|
-
|
|
1286
|
-
|
|
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
|
-
|
|
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 (
|
|
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"
|
|
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
|
-
|
|
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="
|
|
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"
|
|
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
|
|
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
|
-
|
|
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').
|
|
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) {
|
|
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.
|
|
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/",
|