umlimator 0.11.0 → 0.12.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 +72 -8
- package/lib/umlimator.d.ts +143 -1
- package/lib/umlimator.js +983 -77
- package/lib/umlimator.schema.json +1139 -0
- package/package.json +2 -2
package/lib/umlimator.js
CHANGED
|
@@ -102,9 +102,9 @@ const STYLES = `
|
|
|
102
102
|
svg .node.priv:hover .n-label { fill: var(--um-text); }
|
|
103
103
|
svg .node:focus-visible .n-mask { stroke: var(--um-cyan); stroke-width: 3; }
|
|
104
104
|
svg .edge-g.hov .edge, svg .edge-g.hot .edge { stroke-width: calc(2.6px * var(--um-ew, 1)); }
|
|
105
|
-
.focusing svg .node:not(.sel):not(.nb) { opacity: .28; }
|
|
106
|
-
.focusing svg .edge-g:not(.hot) { opacity: .15; }
|
|
107
|
-
.focusing svg .e-lab:not(.hot) { opacity: .15; }
|
|
105
|
+
.focusing:not(.um-lit) svg .node:not(.sel):not(.nb) { opacity: .28; }
|
|
106
|
+
.focusing:not(.um-lit) svg .edge-g:not(.hot) { opacity: .15; }
|
|
107
|
+
.focusing:not(.um-lit) svg .e-lab:not(.hot) { opacity: .15; }
|
|
108
108
|
svg .node, svg .edge-g, svg .e-lab { transition: opacity .2s; }
|
|
109
109
|
svg .node.sel .n-body { stroke-width: 3.2; }
|
|
110
110
|
svg .node.sel .n-mask { stroke: var(--um-accent); stroke-width: 2; }
|
|
@@ -175,7 +175,10 @@ const STYLES = `
|
|
|
175
175
|
svg .family-box { fill: var(--um-panel); fill-opacity: .45; stroke-width: 1.5; }
|
|
176
176
|
svg .family-title { font: 650 11px var(--um-mono); }
|
|
177
177
|
svg .family.sel .family-box { stroke: var(--um-accent); stroke-width: 2; }
|
|
178
|
+
svg .fold-hint { cursor: pointer; } svg .fold-hint:hover rect { stroke-width: 2; }
|
|
178
179
|
svg .edited-dot { fill: var(--um-edited); stroke: var(--um-mask); stroke-width: 1.5; }
|
|
180
|
+
svg .rule-mark circle { fill: var(--um-rose); stroke: var(--um-mask); stroke-width: 1.5; } svg .rule-mark text { fill: var(--um-mask); font: 800 10px var(--um-sans); pointer-events: none; }
|
|
181
|
+
svg .edge-g.faded .flowline { display: none; }
|
|
179
182
|
.um-rename { position: absolute; z-index: 5; box-sizing: border-box; font-family: var(--um-sans); font-weight: 600; color: var(--um-text); background: var(--um-panel); border: 1.5px solid var(--um-accent); border-radius: 6px; padding: 0 6px; outline: none; box-shadow: var(--um-shadow); }
|
|
180
183
|
svg .um-ghost { opacity: .6; pointer-events: none; } svg .um-ghost.um-pending { opacity: .3; }
|
|
181
184
|
svg .family.um-target .family-box { stroke: var(--um-accent); stroke-width: 2.5; stroke-dasharray: 6 4; }
|
|
@@ -184,7 +187,22 @@ const STYLES = `
|
|
|
184
187
|
.um-still *, .um-still *::before, .um-still *::after { transition: none !important; animation: none !important; }
|
|
185
188
|
svg .um-link { fill: none; stroke: var(--um-accent); stroke-width: 2; stroke-dasharray: 5 4; pointer-events: none; }
|
|
186
189
|
@media (max-width: 820px) { .minimap { display: none; } .mctl { bottom: 14px; right: 12px; } }
|
|
187
|
-
|
|
190
|
+
/* marks (#63): a ring around a card, a glow under a line, tabs and labels over the map; a layer that dims (um-lit)
|
|
191
|
+
sets how lit each thing is, in place of the selection's dimming */
|
|
192
|
+
svg .um-ring { fill: none; stroke-width: 2.5; opacity: .9; } svg .um-glow { fill: none; stroke-width: calc(8px + 1.8px * (var(--um-ew, 1) - 1)); stroke-linecap: round; stroke-linejoin: round; opacity: .4; }
|
|
193
|
+
svg .um-pulse { animation: um-pulse 1.1s ease-in-out infinite; } @keyframes um-pulse { 50% { opacity: .08; } }
|
|
194
|
+
svg .um-marks .tab-tx { font: 800 8px var(--um-mono); letter-spacing: .14em; fill: var(--um-mask); text-transform: uppercase; }
|
|
195
|
+
svg .um-lab rect { fill: var(--um-panel); stroke: var(--um-border-2); } svg .um-lab text { fill: var(--um-text); font: 600 10px var(--um-sans); }
|
|
196
|
+
svg .um-trip { pointer-events: none; }
|
|
197
|
+
@media (prefers-reduced-motion: reduce) { svg .um-pulse { animation: none; } }
|
|
198
|
+
/* a story's caption bar (#69), bottom left, clear of the controls */
|
|
199
|
+
.um-story { position: absolute; left: 16px; bottom: 40px; z-index: 5; box-sizing: border-box; max-width: min(560px, calc(100% - 290px)); display: flex; align-items: flex-end; gap: 10px; padding: 9px 8px 9px 14px; border-radius: 12px; background: var(--um-panel); border: 1px solid var(--um-border-2); box-shadow: var(--um-shadow); color: var(--um-text); font: 13px/1.45 var(--um-sans); }
|
|
200
|
+
.um-story .um-st { display: block; font: 600 11px var(--um-mono); color: var(--um-muted); margin-bottom: 2px; }
|
|
201
|
+
.um-story p { margin: 0; } .um-story .um-sb { display: flex; gap: 2px; flex: none; }
|
|
202
|
+
.um-story button { min-width: 30px; height: 28px; border: 0; border-radius: 8px; background: none; color: var(--um-text); font: 600 13px var(--um-mono); cursor: pointer; padding: 0 7px; }
|
|
203
|
+
.um-story button:hover:not(:disabled) { background: var(--um-panel-2); color: var(--um-accent); } .um-story button:disabled { opacity: .35; cursor: default; }
|
|
204
|
+
@media (max-width: 820px) { .um-story { left: 12px; right: 12px; bottom: 60px; max-width: none; } }
|
|
205
|
+
` +/* each tone's tint (--um-<tone>-f), mixed at the map from the tone and panel in effect there: mixed on :root (as the theme
|
|
188
206
|
file does, for page chrome) a tint keeps the tone it was mixed from, whatever an ancestor of the map sets later */
|
|
189
207
|
`:host { ${Object.entries({ accent: 12, cyan: 9, green: 9, violet: 9, amber: 10, rose: 8, orange: 9, slate: 8, fuchsia: 8, 'k-public': 8, 'k-lane': 8,
|
|
190
208
|
'k-ext': 7, 'k-verb': 7, 'k-script': 5, 'k-hook': 12, 'k-board': 9 }).map(([t, p]) => `--um-${t}-f: color-mix(in srgb, var(--um-${t}) ${p}%, var(--um-panel));`).join(' ')} }`;
|
|
@@ -279,7 +297,8 @@ function roundPath(pts, r = 9) {
|
|
|
279
297
|
const last = pts[pts.length - 1]; return d + ` L ${last[0]} ${last[1]}`;
|
|
280
298
|
}
|
|
281
299
|
const hit = (a, b, pad = 0) => a.x < b.x + b.w + pad && b.x < a.x + a.w + pad && a.y < b.y + b.h + pad && b.y < a.y + a.h + pad;
|
|
282
|
-
const warned = new Set(), warn = m => { if (!warned.has(m)) { warned.add(m); console.warn('umlimator: ' + m); } }; // each warning once
|
|
300
|
+
const warned = new Set(), warn = m => { if (hush) hush.push(m); else if (!warned.has(m)) { warned.add(m); console.warn('umlimator: ' + m); } }; // each warning once
|
|
301
|
+
let hush = null; // a layout tried and maybe dropped (unfolding, #71) keeps its warnings here: only the one kept says them
|
|
283
302
|
|
|
284
303
|
/* Layered auto-layout for views that carry no col/row: longest-path layers (cycles broken by DFS),
|
|
285
304
|
barycentre ordering sweeps, integer rows so row gaps stay free as routing channels. */
|
|
@@ -537,7 +556,7 @@ function layoutPass(dg, map, room, last) { // room: { x: { col: px }, y: { row:
|
|
|
537
556
|
};
|
|
538
557
|
const out = familyDefs.map(f => frameOf(f)).filter(Boolean);
|
|
539
558
|
if (map) out.forEach(f => { // the box fits its title and its badges, which are tabs on its top border, at the right
|
|
540
|
-
const tabs = badgesOf(f).map((b, i) => ({ text: b.text, fill: toneOf(b), badge: i, w: b.text.length * 6.4 + 10 })), all = tabs.reduce((a, t) => a + t.w + 4, -4);
|
|
559
|
+
const tabs = [...badgesOf(f).map((b, i) => ({ text: b.text, fill: toneOf(b), badge: i, w: b.text.length * 6.4 + 10 })), ...(f.unfolds ? [{ text: '⤡', fold: true, w: 28 }] : [])], all = tabs.reduce((a, t) => a + t.w + 4, -4); // an unfolded card's box: its ⤡ folds it (#71)
|
|
541
560
|
f.w = Math.max(f.w, String(f.label || f.id).length * 6.7 + 32 + (tabs.length ? all + 8 : 0));
|
|
542
561
|
let tx = f.x + f.w - 12 - all; f.tabs = tabs.map(t => { const o = { ...t, x: tx }; tx += t.w + 4; return o; }); });
|
|
543
562
|
return out;
|
|
@@ -988,6 +1007,7 @@ function svgOf(geo, opts = {}) {
|
|
|
988
1007
|
const id = 'g' + (svgSeq++), { nodes, edges, groups, bounds: B, map } = geo;
|
|
989
1008
|
const col = k => `var(--um-${KIND[k][0]})`, fill = k => `var(--um-${KIND[k][0]}-f)`;
|
|
990
1009
|
const estyle = e => {
|
|
1010
|
+
if (e.style === 'faded') return { ...(map && e.wk ? { c: `var(--um-w-${e.wk})`, m: e.wk, cls: ' w-' + e.wk + ' faded' } : { c: 'var(--um-arrow)', m: 'd', cls: ' faded' }), dash: '', op: .35 }; // its own colour, faint, no flow (drift's idle lines)
|
|
991
1011
|
if (map && e.wk && !e.style) return { c: `var(--um-w-${e.wk})`, m: e.wk, dash: e.back ? '6 5' : '', cls: ' w-' + e.wk + (e.back ? ' back' : '') };
|
|
992
1012
|
const st = e.style || (e.change === 'added' ? 'added' : e.change === 'removed' ? 'removed' : '');
|
|
993
1013
|
if (st === 'added' || st === 'emphasis') return { c: 'var(--um-green)', m: 'g', dash: '', flow: st === 'added' || e.animate };
|
|
@@ -996,15 +1016,21 @@ function svgOf(geo, opts = {}) {
|
|
|
996
1016
|
if (st === 'dashed' || st === 'async') return { c: 'var(--um-violet)', m: 'v', dash: '4 4' };
|
|
997
1017
|
return { c: 'var(--um-arrow)', m: 'd', dash: '' };
|
|
998
1018
|
};
|
|
1019
|
+
// a line that breaks a rule (#70): rose, with a "!" just after its start that names the rule (opts.broken: rule titles by edge id)
|
|
1020
|
+
const broke = e => opts.broken ? [...new Set((e.all || [e]).flatMap(x => opts.broken[x._id] || []))] : [];
|
|
1021
|
+
const lstyle = e => broke(e).length ? { ...estyle(e), c: 'var(--um-rose)', m: 'r' } : estyle(e);
|
|
1022
|
+
const ruleMark = (e, why) => { const [a, b] = e.pts, L = Math.abs(b[0] - a[0]) + Math.abs(b[1] - a[1]) || 1, t = Math.min(18, L / 2) / L, x = a[0] + (b[0] - a[0]) * t, y = a[1] + (b[1] - a[1]) * t;
|
|
1023
|
+
return `<g class="rule-mark"><title>Breaks rule: ${esc(why.join('; '))}</title><circle cx="${x}" cy="${y}" r="6.5"/><text x="${x}" y="${y + 3.4}" text-anchor="middle">!</text></g>`; };
|
|
999
1024
|
const marker = (n, c, k = 1) => `<marker id="${id}-${n}" markerWidth="${10 * k}" markerHeight="${8 * k}" refX="${9 * k}" refY="${4 * k}" orient="auto-start-reverse" markerUnits="userSpaceOnUse"><path d="M0 0 L${10 * k} ${4 * k} L0 ${8 * k} Z" fill="${c}"/></marker>`;
|
|
1000
1025
|
const MC = { d: 'var(--um-arrow)', g: 'var(--um-green)', r: 'var(--um-rose)', v: 'var(--um-violet)', h: 'var(--um-cyan)', ...Object.fromEntries(WIRES.map(w => [w[0], `var(--um-w-${w[0]})`])) };
|
|
1001
|
-
const heavy = [...new Set(edges.filter(e => e.weight > 1).map(e =>
|
|
1026
|
+
const heavy = [...new Set(edges.filter(e => e.weight > 1).map(e => lstyle(e).m + '-w' + e.weight))]; // a heavier line's arrowhead grows with it
|
|
1002
1027
|
let s = `<svg viewBox="${B.x} ${B.y} ${B.w} ${B.h}" data-w="${B.w}" data-h="${B.h}" role="${map ? 'group' : 'img'}" aria-label="${esc(opts.title || 'Diagram')}" xmlns="http://www.w3.org/2000/svg" preserveAspectRatio="xMidYMid meet">
|
|
1003
1028
|
<defs>${marker('d', 'var(--um-arrow)')}${marker('g', 'var(--um-green)')}${marker('r', 'var(--um-rose)')}${marker('v', 'var(--um-violet)')}${marker('h', 'var(--um-cyan)')}${map ? WIRES.map(w => marker(w[0], `var(--um-w-${w[0]})`)).join('') : ''}${heavy.map(m => { const [c, k] = m.split('-w'); return marker(m, MC[c], 1 + (k - 1) * .35); }).join('')}</defs>`;
|
|
1004
1029
|
const families = geo.families || [], familyDepth = f => f.parent ? 1 + familyDepth(families.find(p => p.id === f.parent) || {}) : 0;
|
|
1005
1030
|
families.slice().sort((a,b) => familyDepth(a)-familyDepth(b)).forEach(f => {
|
|
1006
1031
|
s += `<g class="family" data-family="${esc(f.id)}"${f.parent ? ` data-parent="${esc(f.parent)}"` : ''}><rect class="family-box" x="${f.x}" y="${f.y}" width="${f.w}" height="${f.h}" rx="14" stroke="var(--um-${familyDepth(f) ? 'cyan' : 'border-2'})"/>
|
|
1007
|
-
<text class="family-title" x="${f.x+12}" y="${f.y+18}" fill="var(--um-muted)" data-t="ft" data-o="${esc(f.id)}">${esc(f.label || f.id)}</text>${(f.tabs || []).map(t => `
|
|
1032
|
+
<text class="family-title" x="${f.x+12}" y="${f.y+18}" fill="var(--um-muted)" data-t="ft" data-o="${esc(f.id)}">${esc(f.label || f.id)}</text>${(f.tabs || []).map(t => t.fold ? `
|
|
1033
|
+
<g class="fold-hint"><title>Fold back into one card (E, or Alt + click a card in it)</title><rect x="${t.x}" y="${f.y - 8}" width="${t.w}" height="16" rx="8" class="n-mask" stroke="var(--um-accent)"/><text x="${t.x + t.w / 2}" y="${f.y + 4}" text-anchor="middle" class="badge-tx" fill="var(--um-accent)">⤡</text></g>` : `
|
|
1008
1034
|
<g class="pub-tab" data-badge="${t.badge}"><rect x="${t.x}" y="${f.y - 8}" width="${t.w}" height="15" rx="4" fill="${t.fill}"/><text x="${t.x + t.w / 2}" y="${f.y + 3}" text-anchor="middle" class="tab-tx">${esc(t.text)}</text></g>`).join('')}</g>`;
|
|
1009
1035
|
});
|
|
1010
1036
|
(geo.lanes || []).forEach((l, i) => {
|
|
@@ -1043,15 +1069,15 @@ function svgOf(geo, opts = {}) {
|
|
|
1043
1069
|
${tour ? `<circle r="3.2" class="tour tour-packet" opacity="0"><animateMotion path="${roundPath(b.pts)}" keyPoints="0;0;1" keyTimes="0;.86;1" calcMode="linear" dur="${TOUR}s" repeatCount="indefinite"/>${tourAnim('0;0;.8;.8;0', [0, .86, .88, .99, 1])}</circle>` : ''}</g>`;
|
|
1044
1070
|
});
|
|
1045
1071
|
edges.forEach((e, i) => {
|
|
1046
|
-
const st =
|
|
1072
|
+
const st = lstyle(e), why = broke(e), d = roundPath(e.pts), mk = st.m + (e.weight > 1 ? '-w' + e.weight : '');
|
|
1047
1073
|
if (tour && tour.legs[i]) { const [a, b] = tour.legs[i];
|
|
1048
1074
|
s += `<path d="${d}" class="tour-glow" stroke="${st.c}" opacity="0">${tourAnim('0;0;.55;.55;0;0', [0, a, Math.min(a + .01, b), b, b + .03, 1])}</path>`; }
|
|
1049
1075
|
s += `<g class="edge-g${st.cls || ''}${e.weight > 1 ? ' wt-' + e.weight : ''}" data-from="${esc(e.from)}" data-to="${esc(e.to)}" data-i="${i}"><path d="${d}" class="halo"/><path d="${d}" class="hitline"/>
|
|
1050
1076
|
<path d="${d}" class="edge${st.flow ? ' flow' : ''}" stroke="${st.c}" ${st.dash && !st.flow ? `stroke-dasharray="${st.dash}"` : ''} ${st.op ? `opacity="${st.op}"` : ''} marker-end="url(#${id}-${mk})" ${e.both ? `marker-start="url(#${id}-${mk})"` : ''}/>
|
|
1051
|
-
<path d="${d}" class="flowline" stroke="${st.c}"/>${(e.all || [e]).some(x => x.edited) ? (() => { let m = [e.pts[0], e.pts[1]];
|
|
1077
|
+
<path d="${d}" class="flowline" stroke="${st.c}"/>${(e.all || [e]).some(x => x.edited) && !e.strong ? (() => { let m = [e.pts[0], e.pts[1]];
|
|
1052
1078
|
e.pts.slice(1).forEach((p, k) => { const a = e.pts[k]; if (Math.abs(p[0] - a[0]) + Math.abs(p[1] - a[1]) > Math.abs(m[1][0] - m[0][0]) + Math.abs(m[1][1] - m[0][1])) m = [a, p]; });
|
|
1053
|
-
return `<circle cx="${(m[0][0] + m[1][0]) / 2}" cy="${(m[0][1] + m[1][1]) / 2}" r="4" class="edited-dot"><title>Edited</title></circle>`; })() : ''}
|
|
1054
|
-
<title>${e.step != null ? 'Step ' + e.step + ': ' : ''}${esc(e.s.label)} ${e.both ? '⇄' : '→'} ${esc(e.t.label)}${e.label ? ': ' + esc(e.label) : ''}${e.label && e.sublabel ? ' (' + esc(e.sublabel) + ')' : ''}${e.when ? '\n' + esc(e.when) : ''}</title></g>`;
|
|
1079
|
+
return `<circle cx="${(m[0][0] + m[1][0]) / 2}" cy="${(m[0][1] + m[1][1]) / 2}" r="4" class="edited-dot"><title>Edited</title></circle>`; })() : ''}${why.length ? ruleMark(e, why) : ''}
|
|
1080
|
+
<title>${e.step != null ? 'Step ' + e.step + ': ' : ''}${esc(e.s.label)} ${e.both ? '⇄' : '→'} ${esc(e.t.label)}${e.label ? ': ' + esc(e.label) : ''}${e.label && e.sublabel ? ' (' + esc(e.sublabel) + ')' : ''}${e.when ? '\n' + esc(e.when) : ''}${why.length ? '\nBreaks rule: ' + esc(why.join('; ')) : ''}</title></g>`;
|
|
1055
1081
|
});
|
|
1056
1082
|
s += '</g><g class="nodes">';
|
|
1057
1083
|
nodes.forEach(n => {
|
|
@@ -1114,9 +1140,9 @@ return I && I.src ? `<rect x="${x - M * 3 / 28}" y="${y - M * 3 / 28}" width="${
|
|
|
1114
1140
|
said.push(kids ? `${kids} (cards inside, opens the inner view)` : '(opens the inner view)'); }
|
|
1115
1141
|
const ch = CHG[String(n.change || '').toLowerCase()];
|
|
1116
1142
|
if (ch) {
|
|
1117
|
-
const bw = ch[1].length * 6 + 12;
|
|
1118
|
-
parts.push(`<rect x="${
|
|
1119
|
-
<text x="${
|
|
1143
|
+
const bw = ch[1].length * 6 + 12, bx = n.x + n.w - bw - 8 - (hint && map ? 40 : 0); // left of a map card's ⤢ pill, which sits on the same edge
|
|
1144
|
+
parts.push(`<rect x="${bx}" y="${n.y - 8}" width="${bw}" height="16" rx="8" fill="var(--um-mask)" stroke="var(--um-${ch[0]})"/>
|
|
1145
|
+
<text x="${bx + bw / 2}" y="${n.y + 3}" text-anchor="middle" class="badge-tx" fill="var(--um-${ch[0]})">${ch[1]}</text>`);
|
|
1120
1146
|
said.push(ch[1].toLowerCase());
|
|
1121
1147
|
}
|
|
1122
1148
|
if (sub) { parts.push(`<text x="${tx0}" y="${ly + 16 + (two ? 15 : 0)}" text-anchor="${anchor}" class="n-sub depth-read" data-o="${esc(n.id)}" data-t="ns">${sub2 ? lines(sub2, 12) : esc(sub)}</text>`);
|
|
@@ -1148,7 +1174,7 @@ return I && I.src ? `<rect x="${x - M * 3 / 28}" y="${y - M * 3 / 28}" width="${
|
|
|
1148
1174
|
if (map) { // each line's focus target: after the cards, so lines follow them in the Tab order (#43), under the labels; the mouse still hits the line
|
|
1149
1175
|
s += '</g><g class="keys">';
|
|
1150
1176
|
edges.forEach((e, i) => { const said = e.label ? `: ${e.label}${e.sublabel ? ` (${e.sublabel})` : ''}` : '';
|
|
1151
|
-
const name = `${e.step != null ? `Step ${e.step}: ` : ''}Line ${e.both ? `between ${e.s.label} and ${e.t.label}, both ways` : `from ${e.s.label} to ${e.t.label}`}${said}`;
|
|
1177
|
+
const name = `${e.step != null ? `Step ${e.step}: ` : ''}Line ${e.both ? `between ${e.s.label} and ${e.t.label}, both ways` : `from ${e.s.label} to ${e.t.label}`}${said}${broke(e).length ? `, breaks rule ${broke(e).join('; ')}` : ''}`;
|
|
1152
1178
|
s += `<g class="um-key line-key${e.weight > 1 ? ' wt-' + e.weight : ''}" data-i="${i}" tabindex="0" role="button" aria-pressed="false" aria-label="${esc(name)}"><path d="${roundPath(e.pts)}" class="key-ring"/></g>`; });
|
|
1153
1179
|
}
|
|
1154
1180
|
s += '</g><g class="labels depth-read">';
|
|
@@ -1269,7 +1295,7 @@ class Camera {
|
|
|
1269
1295
|
}
|
|
1270
1296
|
onDown(e) {
|
|
1271
1297
|
if (e.button !== 0 && e.pointerType === 'mouse') return;
|
|
1272
|
-
if (e.target.closest('.ctl, .minimap, button, a, input, .um-edit') || (this.o.skip && this.o.skip(e))) return;
|
|
1298
|
+
if (e.target.closest('.ctl, .minimap, button, a, input, .um-edit, .um-story') || (this.o.skip && this.o.skip(e))) return;
|
|
1273
1299
|
this.ptrs.set(e.pointerId, [e.clientX, e.clientY]);
|
|
1274
1300
|
this.start = { x: e.clientX, y: e.clientY, s: { ...this.s }, dist: this.pinchDist() };
|
|
1275
1301
|
this.moved = 0; this.dragged = false;
|
|
@@ -1301,9 +1327,12 @@ class Camera {
|
|
|
1301
1327
|
}
|
|
1302
1328
|
}
|
|
1303
1329
|
|
|
1304
|
-
|
|
1330
|
+
// 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()
|
|
1331
|
+
// turns those into Refs; the page's check reads only the messages
|
|
1332
|
+
const teller = (issues, refs) => (message, ...about) => { issues.push(message); if (refs) refs.push(about); };
|
|
1333
|
+
function routeAudit(geo, where, only, refs) { // only: a set of edges; counts just the issues that involve one of them
|
|
1305
1334
|
const sk = e => only && !only.has(e);
|
|
1306
|
-
const issues = [];
|
|
1335
|
+
const issues = [], say = teller(issues, refs);
|
|
1307
1336
|
// routes: no crossings, no shared runs, no label lying across another route
|
|
1308
1337
|
const segs = geo.edges.map(e => e.pts.slice(0, -1).map((a, i) => [a, e.pts[i + 1]]));
|
|
1309
1338
|
const name = e => `${e.s.label}→${e.t.label}`;
|
|
@@ -1324,66 +1353,87 @@ function routeAudit(geo, where, only) { // only: a set of edges; counts just the
|
|
|
1324
1353
|
if (sk(geo.edges[i]) && sk(geo.edges[j])) continue;
|
|
1325
1354
|
let found = null;
|
|
1326
1355
|
for (const s1 of segs[i]) { for (const s2 of segs[j]) { found = crossing(s1, s2); if (found) break; } if (found) break; }
|
|
1327
|
-
if (found === 'cross')
|
|
1328
|
-
if (found === 'share')
|
|
1356
|
+
if (found === 'cross') say(`${where}: edges ${name(geo.edges[i])} and ${name(geo.edges[j])} cross`, geo.edges[i], geo.edges[j]);
|
|
1357
|
+
if (found === 'share') say(`${where}: edges ${name(geo.edges[i])} and ${name(geo.edges[j])} share a line`, geo.edges[i], geo.edges[j]);
|
|
1329
1358
|
}
|
|
1330
1359
|
geo.edges.forEach((e, i) => { if (!e.lbl) return; const L = { x: e.lbl.x + 1, y: e.lbl.y + 1, w: e.lbl.w - 2, h: e.lbl.h - 2 };
|
|
1331
|
-
geo.edges.forEach((o, j) => { if (j !== i && !(sk(e) && sk(o)) && segs[j].some(([a, b]) => Math.max(a[0], b[0]) > L.x && Math.min(a[0], b[0]) < L.x + L.w && Math.max(a[1], b[1]) > L.y && Math.min(a[1], b[1]) < L.y + L.h))
|
|
1360
|
+
geo.edges.forEach((o, j) => { if (j !== i && !(sk(e) && sk(o)) && segs[j].some(([a, b]) => Math.max(a[0], b[0]) > L.x && Math.min(a[0], b[0]) < L.x + L.w && Math.max(a[1], b[1]) > L.y && Math.min(a[1], b[1]) < L.y + L.h)) say(`${where}: label "${e.lbl.text || e.lbl.step}" lies on edge ${name(o)}`, e, o); }); });
|
|
1332
1361
|
const segHit = (r, i) => segs[i].some(([a, b]) => Math.max(a[0], b[0]) > r.x && Math.min(a[0], b[0]) < r.x + r.w && Math.max(a[1], b[1]) > r.y && Math.min(a[1], b[1]) < r.y + r.h);
|
|
1333
1362
|
if (geo.map) {
|
|
1334
1363
|
const ex = 12, drawn = new Set(geo.edges.map(e => e.from + '>' + e.to)); // A→B and B→A both drawn: a pair the view keeps apart
|
|
1335
1364
|
geo.edges.forEach((e, i) => {
|
|
1336
1365
|
// clearance: a route keeps 12+ px from every node it does not connect
|
|
1337
1366
|
geo.nodes.forEach(n => { if (n.id === e.from || n.id === e.to) return;
|
|
1338
|
-
if (segHit({ x: n.x - ex + .5, y: n.y - ex + .5, w: n.w + 2 * ex - 1, h: n.h + 2 * ex - 1 }, i))
|
|
1367
|
+
if (segHit({ x: n.x - ex + .5, y: n.y - ex + .5, w: n.w + 2 * ex - 1, h: n.h + 2 * ex - 1 }, i)) say(`${where}: edge ${name(e)} passes within ${ex}px of node "${n.label}"`, e, n); });
|
|
1339
1368
|
// no detours: a route stays under 2.5x the straight distance between its ends
|
|
1340
1369
|
const len = e.pts.slice(1).reduce((a, p, k) => a + Math.abs(p[0] - e.pts[k][0]) + Math.abs(p[1] - e.pts[k][1]), 0);
|
|
1341
1370
|
const d = Math.hypot(e.pts[e.pts.length - 1][0] - e.pts[0][0], e.pts[e.pts.length - 1][1] - e.pts[0][1]);
|
|
1342
|
-
if (len > 2.5 * Math.max(d, 100))
|
|
1371
|
+
if (len > 2.5 * Math.max(d, 100)) say(`${where}: edge ${name(e)} takes a detour (${Math.round(len)}px for ${Math.round(d)}px)`, e);
|
|
1343
1372
|
// reading order: every wire runs left to right, except one that closes a loop or answers its pair (keepPairs)
|
|
1344
|
-
if (!e.back && !e.both && !drawn.has(e.to + '>' + e.from) && e.t.x + e.t.w / 2 <= e.s.x + e.s.w / 2)
|
|
1373
|
+
if (!e.back && !e.both && !drawn.has(e.to + '>' + e.from) && e.t.x + e.t.w / 2 <= e.s.x + e.s.w / 2) say(`${where}: edge ${name(e)} runs right-to-left`, e);
|
|
1345
1374
|
});
|
|
1346
1375
|
if (geo.hub) { // hub views: one visible line into each callee
|
|
1347
1376
|
const into = {}; geo.edges.forEach(e => { (into[e.to] ||= []).push(e); });
|
|
1348
|
-
Object.entries(into).forEach(([id, es]) => { es = es.filter(e => !e.back); if (es.some(e => e.from === geo.hub) && es.length > 1)
|
|
1377
|
+
Object.entries(into).forEach(([id, es]) => { es = es.filter(e => !e.back); if (es.some(e => e.from === geo.hub) && es.length > 1) say(`${where}: hub callee "${(geo.byId[id] || {}).label || id}" has ${es.length} incoming lines`, geo.byId[id]); });
|
|
1349
1378
|
}
|
|
1350
1379
|
}
|
|
1351
1380
|
// author name tags hang under their card: no route may run through one
|
|
1352
1381
|
(geo.nodes || []).forEach(n => { if (!n.tag) return; const r = { x: n.tag.x + 1, y: n.tag.y + 1, w: n.tag.w - 2, h: n.tag.h - 2 };
|
|
1353
|
-
geo.edges.forEach((o, j) => { if (segHit(r, j))
|
|
1382
|
+
geo.edges.forEach((o, j) => { if (segHit(r, j)) say(`${where}: name tag "${n.tag.text}" lies on edge ${name(o)}`, n, o); }); });
|
|
1354
1383
|
// lanes: every part sits inside its own lane, lanes never overlap, no route crosses a lane header
|
|
1355
1384
|
const lanes = geo.lanes || [];
|
|
1356
1385
|
lanes.forEach((l, i) => {
|
|
1357
|
-
lanes.slice(i + 1).forEach(m => { if (hit(l, m, -1))
|
|
1386
|
+
lanes.slice(i + 1).forEach(m => { if (hit(l, m, -1)) say(`${where}: lanes "${l.title}" and "${m.title}" overlap`); });
|
|
1358
1387
|
(geo.nodes || []).forEach(n => {
|
|
1359
|
-
if (n.lane === i && (n.x < l.x || n.x + n.w > l.x + l.w || n.y < l.y || n.y + n.h + (n.tag ? 14 : 0) > l.y + l.h))
|
|
1360
|
-
if (n.lane != null && n.lane !== i && hit(n, l, -1))
|
|
1361
|
-
if (n.lane == null && hit(n, l, -1))
|
|
1388
|
+
if (n.lane === i && (n.x < l.x || n.x + n.w > l.x + l.w || n.y < l.y || n.y + n.h + (n.tag ? 14 : 0) > l.y + l.h)) say(`${where}: lane "${l.title}": node "${n.label}" sticks out`, n);
|
|
1389
|
+
if (n.lane != null && n.lane !== i && hit(n, l, -1)) say(`${where}: lane "${l.title}": node "${n.label}" of another lane sits in it`, n);
|
|
1390
|
+
if (n.lane == null && hit(n, l, -1)) say(`${where}: lane "${l.title}": node "${n.label}" belongs to no lane but sits in it`, n);
|
|
1362
1391
|
});
|
|
1363
1392
|
});
|
|
1364
|
-
(geo.blocks || []).forEach(b => geo.edges.forEach((o, j) => { if (segHit({ x: b.x + 1, y: b.y + 1, w: b.w - 2, h: b.h - 2 }, j))
|
|
1393
|
+
(geo.blocks || []).forEach(b => geo.edges.forEach((o, j) => { if (segHit({ x: b.x + 1, y: b.y + 1, w: b.w - 2, h: b.h - 2 }, j)) say(`${where}: lane "${b.title || ''}": header crossed by edge ${name(o)}`, o); }));
|
|
1365
1394
|
const families = geo.families || [], inside = (a,b) => b.x > a.x && b.y > a.y && b.x+b.w < a.x+a.w && b.y+b.h < a.y+a.h;
|
|
1366
1395
|
families.forEach(f => {
|
|
1367
1396
|
const parent = families.find(p => p.id === f.parent);
|
|
1368
|
-
if (f.parent && (!parent || !inside(parent,f)))
|
|
1369
|
-
(f.nodeIds || []).forEach(id => { const n = geo.byId[id]; if (n && !inside(f,n))
|
|
1370
|
-
families.filter(g => g !== f && g.parent === f.parent && g.id > f.id).forEach(g => { if (hit(f,g,-1))
|
|
1371
|
-
geo.edges.forEach((e,i) => { if (segHit({x:f.x+8,y:f.y+3,w:Math.min(f.w-16,textW(f.label || f.id,11)+16),h:22},i))
|
|
1397
|
+
if (f.parent && (!parent || !inside(parent,f))) say(`${where}: family "${f.label}" sticks out of its parent`, f);
|
|
1398
|
+
(f.nodeIds || []).forEach(id => { const n = geo.byId[id]; if (n && !inside(f,n)) say(`${where}: family "${f.label}" does not contain "${n.label}"`, f, n); });
|
|
1399
|
+
families.filter(g => g !== f && g.parent === f.parent && g.id > f.id).forEach(g => { if (hit(f,g,-1)) say(`${where}: families "${f.label}" and "${g.label}" overlap`, f, g); });
|
|
1400
|
+
geo.edges.forEach((e,i) => { if (segHit({x:f.x+8,y:f.y+3,w:Math.min(f.w-16,textW(f.label || f.id,11)+16),h:22},i)) say(`${where}: family "${f.label}": header crossed by edge ${name(e)}`, f, e); });
|
|
1372
1401
|
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]];
|
|
1373
1402
|
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);
|
|
1374
|
-
return hi - lo > 40 && (v ? [f.x, f.x + f.w] : [f.y, f.y + f.h]).some(u => Math.abs(c - u) < 8); }))
|
|
1403
|
+
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); });
|
|
1375
1404
|
geo.edges.forEach(e => { const L = e.lbl; if (!L) return; const xin = L.x + L.w > f.x && L.x < f.x + f.w, yin = L.y + L.h > f.y && L.y < f.y + f.h;
|
|
1376
|
-
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)))
|
|
1405
|
+
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); });
|
|
1377
1406
|
});
|
|
1378
1407
|
return issues;
|
|
1379
1408
|
}
|
|
1380
1409
|
|
|
1410
|
+
/* The layout check's geometry, which needs no fonts: check() runs it on layout()'s geometry, and the page adds what it
|
|
1411
|
+
measures in the DOM (text against its card, a lane's or box's title, edge labels against each other). */
|
|
1412
|
+
const box = n => ({ x: n.x, y: n.y, w: n.w, h: n.h });
|
|
1413
|
+
function overlapIssues(geo, where, refs) {
|
|
1414
|
+
const issues = [], nodes = geo.nodes, say = teller(issues, refs);
|
|
1415
|
+
for (let i = 0; i < nodes.length; i++) for (let j = i + 1; j < nodes.length; j++) if (hit(box(nodes[i]), box(nodes[j]), -1)) say(`${where}: nodes "${nodes[i].label}" and "${nodes[j].label}" overlap`, nodes[i], nodes[j]);
|
|
1416
|
+
return issues;
|
|
1417
|
+
}
|
|
1418
|
+
function leaveIssues(geo, where, refs) {
|
|
1419
|
+
const issues = [], say = teller(issues, refs);
|
|
1420
|
+
if (geo.map) geo.edges.forEach(e => { const L = e.lbl; if (!L || !e.label || !e.pts) return; // a label must leave some of its own line visible
|
|
1421
|
+
const c = [L.x + L.w / 2, L.y + L.h / 2], P = e.pts; let s = 0, at = null, half = 0, tot = 0;
|
|
1422
|
+
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]);
|
|
1423
|
+
if (at == null) { const onH = a[1] === b[1] && Math.abs(c[1] - a[1]) < 2 && c[0] >= Math.min(a[0], b[0]) - 1 && c[0] <= Math.max(a[0], b[0]) + 1;
|
|
1424
|
+
const onV = a[0] === b[0] && Math.abs(c[0] - a[0]) < 2 && c[1] >= Math.min(a[1], b[1]) - 1 && c[1] <= Math.max(a[1], b[1]) + 1;
|
|
1425
|
+
if (onH || onV) { at = tot + Math.abs(c[0] - a[0]) + Math.abs(c[1] - a[1]); half = onH ? L.w / 2 : L.h / 2; } }
|
|
1426
|
+
tot += len; }
|
|
1427
|
+
if (at == null) return; const room = Math.min(at - half, tot - at - half);
|
|
1428
|
+
if (room < 16) say(`${where}: label "${e.label}" leaves its line ${Math.max(0, Math.round(room))} px (${e.s.label}→${e.t.label})`, e); });
|
|
1429
|
+
return issues;
|
|
1430
|
+
}
|
|
1431
|
+
|
|
1381
1432
|
/* label-overlap audit used by `visual.py check` (world coordinates, real font metrics) */
|
|
1382
1433
|
function auditSvg(svg, geo, where) {
|
|
1383
|
-
const issues =
|
|
1434
|
+
const issues = overlapIssues(geo, where), R = box;
|
|
1384
1435
|
const T = [...svg.querySelectorAll('text[data-t]')].map(t => { let b; try { b = t.getBBox(); } catch (_) { return null; } return { t: t.dataset.t, o: t.dataset.o, txt: t.textContent, b: { x: b.x, y: b.y, w: b.width, h: b.height } }; }).filter(x => x && x.b.w);
|
|
1385
1436
|
const nodes = geo.nodes;
|
|
1386
|
-
for (let i = 0; i < nodes.length; i++) for (let j = i + 1; j < nodes.length; j++) if (hit(R(nodes[i]), R(nodes[j]), -1)) issues.push(`${where}: nodes "${nodes[i].label}" and "${nodes[j].label}" overlap`);
|
|
1387
1437
|
T.forEach(a => {
|
|
1388
1438
|
if (a.t === 'lt' || a.t === 'ft') {
|
|
1389
1439
|
nodes.forEach(n => { if (hit(a.b, R(n), -1)) issues.push(`${where}: lane "${a.txt}": header covers node "${n.label}"`); });
|
|
@@ -1397,16 +1447,7 @@ function auditSvg(svg, geo, where) {
|
|
|
1397
1447
|
nodes.forEach(n => { if (n !== own && hit(a.b, R(n), -1)) issues.push(`${where}: text "${a.txt}" overlaps node "${n.label}"`); });
|
|
1398
1448
|
}
|
|
1399
1449
|
});
|
|
1400
|
-
issues.push(...routeAudit(geo, where));
|
|
1401
|
-
if (geo.map) geo.edges.forEach(e => { const L = e.lbl; if (!L || !e.label || !e.pts) return; // a label must leave some of its own line visible
|
|
1402
|
-
const c = [L.x + L.w / 2, L.y + L.h / 2], P = e.pts; let s = 0, at = null, half = 0, tot = 0;
|
|
1403
|
-
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]);
|
|
1404
|
-
if (at == null) { const onH = a[1] === b[1] && Math.abs(c[1] - a[1]) < 2 && c[0] >= Math.min(a[0], b[0]) - 1 && c[0] <= Math.max(a[0], b[0]) + 1;
|
|
1405
|
-
const onV = a[0] === b[0] && Math.abs(c[0] - a[0]) < 2 && c[1] >= Math.min(a[1], b[1]) - 1 && c[1] <= Math.max(a[1], b[1]) + 1;
|
|
1406
|
-
if (onH || onV) { at = tot + Math.abs(c[0] - a[0]) + Math.abs(c[1] - a[1]); half = onH ? L.w / 2 : L.h / 2; } }
|
|
1407
|
-
tot += len; }
|
|
1408
|
-
if (at == null) return; const room = Math.min(at - half, tot - at - half);
|
|
1409
|
-
if (room < 16) issues.push(`${where}: label "${e.label}" leaves its line ${Math.max(0, Math.round(room))} px (${e.s.label}→${e.t.label})`); });
|
|
1450
|
+
issues.push(...routeAudit(geo, where), ...leaveIssues(geo, where));
|
|
1410
1451
|
const els = T.filter(a => a.t === 'el');
|
|
1411
1452
|
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`);
|
|
1412
1453
|
return issues;
|
|
@@ -1697,8 +1738,16 @@ function mapContext(model, opts = {}) {
|
|
|
1697
1738
|
|
|
1698
1739
|
function buildView(vid, opts = {}) {
|
|
1699
1740
|
const g = buildGeo(vid), v = V[vid];
|
|
1700
|
-
return { g, markup: svgOf(g, { title: v.title, subject: subjectOf(v), icons: D.icons, ...opts }) };
|
|
1701
|
-
}
|
|
1741
|
+
return { g, markup: svgOf(g, { title: v.title, subject: subjectOf(v), icons: D.icons, broken: brokenIn(vid), ...opts }) };
|
|
1742
|
+
}
|
|
1743
|
+
// the rules a view's lines break (#70), by model edge id: svgOf draws such a line rose, with a mark that names them
|
|
1744
|
+
let ruled = null;
|
|
1745
|
+
const brokenIn = vid => {
|
|
1746
|
+
if (!arrOf(D.rules).length) return null;
|
|
1747
|
+
const by = {}, title = id => { const r = D.rules.find(x => x && x.id === id); return String((r && (r.title || r.id)) ?? id); };
|
|
1748
|
+
(ruled ||= rules(D)).forEach(b => { if (b.view === vid) b.targets.forEach(t => { if (t.type === 'edge') t.ids.forEach(i => { const l = by[i] ||= []; if (!l.includes(title(b.rule))) l.push(title(b.rule)); }); }); });
|
|
1749
|
+
return Object.keys(by).length ? by : null;
|
|
1750
|
+
};
|
|
1702
1751
|
const layoutCache = {};
|
|
1703
1752
|
// the cards a card opens: its inner view's, else the count the host gives for one it has not loaded (opensCount)
|
|
1704
1753
|
const kidCount = (nid, vid) => { const c = childOf(nid, vid), k = c && c !== vid ? (V[c].nodeIds || []).length : 0, n = +nodeIn(nid, vid).opensCount;
|
|
@@ -1720,7 +1769,7 @@ function mapContext(model, opts = {}) {
|
|
|
1720
1769
|
const pair = {}; // A→B plus B→A reads as one two-way edge
|
|
1721
1770
|
return out.filter(e => { const r = !(V[vid] || {}).keepPairs && pair[e.to + '>' + e.from]; if (r) { r.both = true; r.back = false; r.all = [...(r.all || [r]), ...(e.all || [e])]; if (e.label && e.label !== r.label) r.label = (r.label ? r.label + ' / ' : '') + e.label; if (e.when) r.when = [r.when, e.when].filter(Boolean).join(' · '); return false; } pair[e.from + '>' + e.to] = e; return true; })
|
|
1722
1771
|
.map(e => { const all = e.all || [e], subs = [...new Set(all.map(x => x.sublabel).filter(x => x != null && x !== '').map(String))];
|
|
1723
|
-
return { ...e, wk: wireKind(e, opts.kinds || (V[vid] || {}).kindsFromLabels), weight: Math.max(...all.map(x => [2, 3].includes(+x.weight) ? +x.weight : 1)), sublabel: subs.join(' / ') || undefined }; });
|
|
1772
|
+
return { ...e, wk: wireKind(e, opts.kinds || (V[vid] || {}).kindsFromLabels), weight: Math.max(...all.map(x => [2, 3].includes(+x.weight) ? +x.weight : 1)), sublabel: subs.join(' / ') || undefined, ...markOf(all) }; });
|
|
1724
1773
|
}
|
|
1725
1774
|
const condTag = l => { const m = /^(?:routes|on-ramp:?)\s+(\S+)$/i.exec(String(l || '').trim()); return m ? m[1] : null; }; // "routes bug" → a "bug" tag
|
|
1726
1775
|
// quick: a candidate the layout searches below score and may drop, laid out once, as it was before 0.6.0 (no room for lines
|
|
@@ -1846,23 +1895,424 @@ function mapContext(model, opts = {}) {
|
|
|
1846
1895
|
const icons = [...new Set(ns.map(n => n.icon).filter(Boolean))].map(k => (D.icons || {})[k]), who = [...new Set(ns.map(n => n.author).filter(Boolean))].map(k => (D.authors || {})[k]);
|
|
1847
1896
|
return JSON.stringify([v, ns, viewEdges(v), ids.map(i => [kidCount(i, vid), opensOf(i, vid)]), icons, who]);
|
|
1848
1897
|
};
|
|
1898
|
+
/* ---------- unfolding in place (#71): a stacked card opens into a box on the view it is on ----------
|
|
1899
|
+
The card becomes a box with its id and name, holding its inner view's own cards: those not on this view already (the
|
|
1900
|
+
inner view's other cards are this view's, its neighbours). Inside, the inner view's lines between them. This view's
|
|
1901
|
+
lines stay this view's: one to the card moves onto every card inside that the inner view joins to that neighbour, the
|
|
1902
|
+
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
|
+
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
|
+
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. */
|
|
1907
|
+
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; };
|
|
1910
|
+
function unfold(vid, nid) {
|
|
1911
|
+
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));
|
|
1913
|
+
const n0 = nodeIn(nid, vid), holder = (v.containers || []).filter(b => b && (b.nodeIds || []).includes(nid)).pop();
|
|
1914
|
+
const boxes = (v.containers || []).filter(b => b && b.id).map(b => ({ ...b, nodeIds: (b.nodeIds || []).filter(i => i !== nid) }));
|
|
1915
|
+
const taken = new Set([...outer, ...inner, ...boxes.map(b => b.id)]);
|
|
1916
|
+
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
|
+
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));
|
|
1919
|
+
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
|
+
const isIn = id => inner.includes(id) || kept.has(id), mine = viewEdges(v);
|
|
1921
|
+
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
|
|
1922
|
+
let theirs = viewEdges(C).filter(e => isIn(e.from) ? isIn(e.to) || from.has(e.to) : isIn(e.to) && to.has(e.from));
|
|
1923
|
+
// a line drawn both ways stays a pair: it moves inside only if the inner view joins that neighbour to one card both ways
|
|
1924
|
+
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
|
+
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
|
+
theirs = theirs.filter(e => !split.has(isIn(e.from) ? e.to : e.from));
|
|
1927
|
+
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
|
+
const k = (v.nodeIds || []).indexOf(nid), grid = !planOf(vid).hub;
|
|
1929
|
+
const pos = grid ? (v.layout ? fillLayout(vid, v.layout) : (layoutCache[vid] ||= autoPos(vid))) : null;
|
|
1930
|
+
Object.assign(v, { nodeIds: [...v.nodeIds.slice(0, k), ...inner, ...v.nodeIds.slice(k + 1)], containers: [...boxes, box, ...own],
|
|
1931
|
+
edges: [...mine.filter(e => !(e.to === nid && exact.has(e.from + '>')) && !(e.from === nid && exact.has('>' + e.to))), ...theirs] });
|
|
1932
|
+
if ((v.tour || []).includes(nid)) delete v.tour;
|
|
1933
|
+
if (v.mainSteps || v.render === 'process') v.stepIds = v.stepIds || outer; // the steps keep their numbers; the cards inside are not steps
|
|
1934
|
+
if (Array.isArray(v.intake)) v.intake = v.intake.filter(s => s && s.to !== nid);
|
|
1935
|
+
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
|
|
1936
|
+
delete v.layout; delete planCache[vid]; delete layoutCache[vid]; delete drawn[vid];
|
|
1937
|
+
if (!grid) return true; // a hub view has no columns to keep: laid out again, with its boxes
|
|
1938
|
+
/* 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,
|
|
1939
|
+
upside down, or each card level with its neighbours (the median row of the cards outside it joins to, and of those to
|
|
1940
|
+
its left inside, as rowsFor does). Each way is routed, and the one the layout check scores best is kept */
|
|
1941
|
+
const [xc, xr] = pos[nid] || [0, 0], seen = new Set(), ways = [], med = xs => { const s = xs.slice().sort((a, b) => a - b), m = s.length >> 1; return s.length % 2 ? s[m] : (s[m - 1] + s[m]) / 2; };
|
|
1942
|
+
const levelled = (ic, ir) => { const R = {}, cols = [...new Set(inner.map(ic))].sort((a, b) => a - b);
|
|
1943
|
+
cols.forEach(k => { const want = {}, col = inner.filter(i => ic(i) === k);
|
|
1944
|
+
col.forEach(i => { const nb = theirs.flatMap(e => e.from === i ? [e.to] : e.to === i ? [e.from] : []).map(o => !isIn(o) && pos[o] ? pos[o][1] - xr : R[o]).filter(r => r != null);
|
|
1945
|
+
want[i] = nb.length ? med(nb) : ir(i); });
|
|
1946
|
+
let prev = -1; col.sort((a, b) => want[a] - want[b] || ir(a) - ir(b)).forEach(i => { R[i] = prev = Math.max(0, Math.round(want[i]), prev + 1); }); });
|
|
1947
|
+
return i => R[i]; };
|
|
1948
|
+
// with boxes of its own, the inner view keeps its slots, moved to the box's corner: empty slots are its boxes' room. Else
|
|
1949
|
+
// its columns, or columns in this view's own reading order: a loop the inner view closes with one line may close here
|
|
1950
|
+
// with another, or not at all
|
|
1951
|
+
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
|
+
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]));
|
|
1954
|
+
const fwd = lp.edges.filter((e, j) => !lp.back.has(j) && e.from !== e.to && inner.includes(e.from) && inner.includes(e.to));
|
|
1955
|
+
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
|
+
const one = own.length || v.nodeIds.length > 30; // its boxes' room to keep, or too big to route seven times: its own slots only
|
|
1957
|
+
// the sides the inner view gave its lines are for its own picture: with its own slots they stay; elsewhere each way is
|
|
1958
|
+
// also routed with the router picking them (copies, which keep the ids)
|
|
1959
|
+
const keepE = v.edges, freeE = theirs.some(e => e.fromSide || e.toSide) ? keepE.map(e => theirs.includes(e) && (e.fromSide || e.toSide) ? (({ fromSide, toSide, ...r }) => r)(e) : e) : null;
|
|
1960
|
+
[ip0, ...(one ? [] : [Object.fromEntries(inner.map(i => [i, [rk[i], (ip0[i] || [0, 0])[1]]]))])].forEach(ip => (one ? [false] : [false, true, 'level']).forEach(flip => {
|
|
1961
|
+
const at = (i, j) => (ip[i] || [0, 0])[j], rank = j => { const u = [...new Set(inner.map(i => at(i, j)))].sort((a, b) => flip === true && j ? b - a : a - b), lo = u[0];
|
|
1962
|
+
const k = j && u.some(r => r % 1) ? 2 : 1; // half rows doubled: a box that ends on a half row gets no gap under it (layoutPass widens whole rows)
|
|
1963
|
+
return own.length ? i => (at(i, j) - lo) * k : i => u.indexOf(at(i, j)); };
|
|
1964
|
+
const ic = rank(0), ir = flip === 'level' ? levelled(rank(0), rank(1)) : rank(1), w = Math.max(...inner.map(ic)) + 1, h = Math.max(...inner.map(ir)) + 1, L = {};
|
|
1965
|
+
outer.forEach(i => { if (i === nid) return; const [c0, r0] = pos[i] || [0, 0]; L[i] = c0 >= xc + .5 ? [c0 + w - 1, r0] : Math.abs(c0 - xc) < .5 && r0 > xr ? [c0, r0 + h - 1] : [c0, r0]; });
|
|
1966
|
+
inner.forEach(i => { L[i] = [xc + ic(i), xr + ir(i)]; });
|
|
1967
|
+
// a box of this view with cards on both sides of the card's column, under it, would now reach into the unfolded box:
|
|
1968
|
+
// it moves down under it, whole; then a card on a taken slot moves down a row
|
|
1969
|
+
boxes.forEach(b => { if (xBoxes.has(b.id)) return; const ms = inBox(b.id).filter(i => L[i]); if (!ms.length) return;
|
|
1970
|
+
const cs = ms.map(i => L[i][0]), rs = ms.map(i => L[i][1]);
|
|
1971
|
+
if (Math.max(...cs) < xc || Math.min(...cs) > xc + w - 1 || Math.max(...rs) < xr || Math.min(...rs) > xr + h - 1) return;
|
|
1972
|
+
const dr = xr + h - Math.min(...rs); ms.forEach(i => { L[i] = [L[i][0], L[i][1] + dr]; }); });
|
|
1973
|
+
const slots = inner.map(i => L[i]); outer.forEach(i => { if (i === nid) return; let [c0, r0] = L[i];
|
|
1974
|
+
while (slots.some(([c1, r1]) => c1 === c0 && Math.abs(r1 - r0) < 1)) r0++; slots.push(L[i] = [c0, r0]); });
|
|
1975
|
+
const key = JSON.stringify(inner.map(i => L[i])); if (seen.has(key)) return; seen.add(key);
|
|
1976
|
+
ways.push({ L, free: false }); if (freeE && (ip !== ip0 || flip !== false)) ways.push({ L, free: true }); }));
|
|
1977
|
+
// a card inside a box it is not in reads as one of the box's (and loses a line to the box): a way with one loses
|
|
1978
|
+
const strays = g => (g.families || []).reduce((a, f) => a + g.nodes.filter(n => !(f.nodeIds || []).includes(n.id) && n.x + n.w / 2 > f.x && n.x + n.w / 2 < f.x + f.w && n.y + n.h / 2 > f.y && n.y + n.h / 2 < f.y + f.h
|
|
1979
|
+
&& !(g.families || []).some(o => o.id !== f.id && (o.nodeIds || []).includes(n.id) && o.x >= f.x && o.y >= f.y && o.x + o.w <= f.x + f.w && o.y + o.h <= f.y + f.h)).length, 0);
|
|
1980
|
+
let best = null; // ponytail: up to 12 routings per unfold, a view of over 30 cards one; a smarter search when a bigger map needs it
|
|
1981
|
+
const score = ({ L, free }) => { v.edges = free ? freeE : keepE; hush = []; let g; try { g = buildGeo(vid, L); } finally { g && (g.said = hush); hush = null; }
|
|
1982
|
+
const s = scoreGeo(g) + (strays(g) * 5 + leaveIssues(g, '').length) * 1000; if (!best || s < best.s - .5) best = { L, g, s, free }; };
|
|
1983
|
+
ways.forEach(score);
|
|
1984
|
+
// still an issue, and cards under the card in its column: the box one column to the right instead, so they keep their slots
|
|
1985
|
+
if (best.s >= 1000 && v.nodeIds.length <= 30 && outer.some(i => i !== nid && pos[i] && Math.abs(pos[i][0] - xc) < .5)) { const B = best.L, L = {};
|
|
1986
|
+
outer.forEach(i => { if (i === nid) return; const [c0, r0] = pos[i] || [0, 0]; L[i] = c0 >= xc + .5 ? [c0 + 1 + Math.max(...inner.map(j => B[j][0])) - xc, r0] : [c0, r0]; });
|
|
1987
|
+
inner.forEach(i => { L[i] = [B[i][0] + 1, B[i][1]]; }); score({ L, free: best.free }); }
|
|
1988
|
+
best.g.said.forEach(warn); delete best.g.said;
|
|
1989
|
+
v.edges = best.free ? freeE : keepE; v.layout = best.L; drawn[vid] = { pos: best.L, g: best.g }; // drawn as scored, without routing it again
|
|
1990
|
+
return true;
|
|
1991
|
+
}
|
|
1992
|
+
const unfolded = {}; // per view: the cards opts.expand named that did unfold, in order (one inside another unfolds after it)
|
|
1993
|
+
withM(() => Object.entries(opts.expand || {}).forEach(([vid, ids]) => { let todo = V[vid] ? [...new Set([].concat(ids || []))] : [];
|
|
1994
|
+
for (let more = true; more && todo.length;) { more = false; todo = todo.filter(nid => { if (!unfold(vid, nid)) return true; (unfolded[vid] ||= []).push(nid); return !(more = true); }); } }))();
|
|
1849
1995
|
const geoCache = {};
|
|
1850
1996
|
return { D, views, nodes, N, V, rootId, nodeIn, childOf, opensOf, subjectOf, viewsWith, kidsOf, chain, viewEdges, isState, planOf: withM(planOf), planCache,
|
|
1851
|
-
layoutCache, geoCache, buildGeo: withM(buildGeo), buildView: withM(buildView), optimize: withM(optimize), flowPos, kidCount, viewKey };
|
|
1997
|
+
layoutCache, geoCache, buildGeo: withM(buildGeo), buildView: withM(buildView), optimize: withM(optimize), flowPos, kidCount, viewKey, brokenIn, unfoldable, unfolded };
|
|
1852
1998
|
}
|
|
1853
1999
|
|
|
1854
2000
|
const edgeRefIds = e => [...new Set((e.all || [e]).map(x => x._id).filter(Boolean))];
|
|
1855
2001
|
|
|
1856
2002
|
/* layout(model, viewId): one view's geometry, as the canvas draws it. No DOM. */
|
|
1857
2003
|
export function layout(model, viewId, options = {}) {
|
|
1858
|
-
const ctx = mapContext(model, { measure: options.measure });
|
|
2004
|
+
const ctx = mapContext(model, { measure: options.measure, ...(options.expand ? { expand: { [viewId]: options.expand } } : {}) }); // expand: cards unfolded in place (#71)
|
|
1859
2005
|
if (!ctx.V[viewId]) throw new Error(`umlimator: no view ${JSON.stringify(viewId)}`);
|
|
1860
2006
|
ctx.planOf(viewId);
|
|
1861
2007
|
const g = ctx.buildGeo(viewId, undefined, options.repair);
|
|
1862
2008
|
g.edges.forEach(e => { e.ids = edgeRefIds(e); });
|
|
2009
|
+
if (options.expand) g.unfolded = ctx.unfolded[viewId] || [];
|
|
1863
2010
|
return g;
|
|
1864
2011
|
}
|
|
1865
2012
|
|
|
2013
|
+
/** The layout check's geometry for one view, without a browser: overlapping cards, crossing or shared lines, detours,
|
|
2014
|
+
* lines against reading order or through a card, labels on lines or off their own, boxes and lanes. The page's check
|
|
2015
|
+
* (`umlimator.py check`) adds what it measures in the DOM: text that overflows its card or touches its mark, titles
|
|
2016
|
+
* that overflow, edge labels on cards or on each other. Each issue names the cards and lines it is about. */
|
|
2017
|
+
export function check(model, viewId, options = {}) {
|
|
2018
|
+
const g = layout(model, viewId, options), where = `view ${viewId}`, about = [], lines = new Set(g.edges), boxes = new Set(g.families || []);
|
|
2019
|
+
const ref = x => lines.has(x) ? { type: 'edge', ids: x.ids, view: viewId } : boxes.has(x) ? { type: 'container', id: x.id, view: viewId } : { type: 'node', id: x.id, view: viewId };
|
|
2020
|
+
return [...overlapIssues(g, where, about), ...routeAudit(g, where, null, about), ...leaveIssues(g, where, about)]
|
|
2021
|
+
.map((message, i) => ({ message, targets: about[i].filter(Boolean).map(ref) }));
|
|
2022
|
+
}
|
|
2023
|
+
|
|
2024
|
+
const WIRE_KINDS = ['calls', 'feeds', 'triggers', 'state', 'guard'], SIDES = ['left', 'right', 'top', 'bottom'];
|
|
2025
|
+
/** A model's own problems, before anything is drawn: errors draw as holes or not at all (ids that are missing,
|
|
2026
|
+
* repeated or name nothing, boxes inside themselves); warnings draw other than they say (an unknown kind or weight,
|
|
2027
|
+
* a side that is not one, a view opened before the host has loaded it). Each says where: `views[2].nodeIds[4]`. */
|
|
2028
|
+
export function validate(model) {
|
|
2029
|
+
const out = [], err = (path, message) => out.push({ level: 'error', path, message }), warn = (path, message) => out.push({ level: 'warning', path, message });
|
|
2030
|
+
const arr = x => (Array.isArray(x) ? x : []), m = model && typeof model === 'object' ? model : {}, views = arr(m.views).filter(v => v && typeof v === 'object');
|
|
2031
|
+
if (!Array.isArray(m.nodes)) err('nodes', 'the model needs a list of cards');
|
|
2032
|
+
if (!views.length) err('views', 'the model has no views');
|
|
2033
|
+
const ids = new Set(), vids = new Set(), lineIds = new Set();
|
|
2034
|
+
const card = (n, at) => {
|
|
2035
|
+
if (!n || typeof n.id !== 'string' || !n.id) return err(at + '.id', 'a card needs an id');
|
|
2036
|
+
if (ids.has(n.id)) err(at + '.id', `card id "${n.id}" is used twice`); ids.add(n.id);
|
|
2037
|
+
const k = String(n.kind ?? '').toLowerCase();
|
|
2038
|
+
if (n.kind != null && !(k in KIND || k in MAPK || k in ALIAS)) warn(at + '.kind', `unknown kind "${n.kind}": drawn as a script card`);
|
|
2039
|
+
};
|
|
2040
|
+
arr(m.nodes).forEach((n, i) => card(n, `nodes[${i}]`));
|
|
2041
|
+
views.forEach((v, i) => arr(v.nodes).forEach((n, j) => card(n, `views[${i}].nodes[${j}]`)));
|
|
2042
|
+
views.forEach((v, i) => { if (typeof v.id !== 'string' || !v.id) err(`views[${i}].id`, 'a view needs an id'); else if (vids.has(v.id)) err(`views[${i}].id`, `view id "${v.id}" is used twice`); else vids.add(v.id); });
|
|
2043
|
+
if (m.root != null && !vids.has(m.root)) err('root', `root "${m.root}" is not a view`);
|
|
2044
|
+
const later = (x, at) => { if (x && x.opensView && !vids.has(x.opensView)) warn(at + '.opensView', `opens "${x.opensView}", a view the model does not have: fine if the host loads it when it is opened`); };
|
|
2045
|
+
arr(m.nodes).forEach((n, i) => later(n, `nodes[${i}]`));
|
|
2046
|
+
const line = (e, at, ends) => {
|
|
2047
|
+
if (!e || typeof e !== 'object') return err(at, 'a line is an object with from and to');
|
|
2048
|
+
['from', 'to'].forEach(k => { if (!ends.has(e[k])) err(`${at}.${k}`, `"${e[k]}" is neither a card nor a box`); });
|
|
2049
|
+
if (e.kind != null && !WIRE_KINDS.includes(e.kind)) warn(at + '.kind', `unknown line kind "${e.kind}": drawn as calls`);
|
|
2050
|
+
if (e.weight != null && ![1, 2, 3].includes(e.weight)) warn(at + '.weight', `weight ${JSON.stringify(e.weight)} is not 1, 2 or 3: drawn as 1`);
|
|
2051
|
+
['fromSide', 'toSide'].forEach(k => { if (e[k] != null && !SIDES.includes(e[k])) warn(`${at}.${k}`, `"${e[k]}" is not left, right, top or bottom: ignored`); });
|
|
2052
|
+
};
|
|
2053
|
+
// a model line may end on any view's box (#28); a view's own line only on that view's boxes
|
|
2054
|
+
const anyBox = new Set(views.flatMap(v => arr(v.containers).map(c => c && c.id)));
|
|
2055
|
+
arr(m.edges).forEach((e, i) => { line(e, `edges[${i}]`, new Set([...ids, ...anyBox]));
|
|
2056
|
+
if (e && e.id != null) { if (lineIds.has(e.id)) err(`edges[${i}].id`, `line id "${e.id}" is used twice`); lineIds.add(e.id); } });
|
|
2057
|
+
views.forEach((v, i) => {
|
|
2058
|
+
const at = `views[${i}]`, mine = new Set(arr(v.nodeIds)), boxes = {};
|
|
2059
|
+
if (v.parent != null && !vids.has(v.parent) && !ids.has(v.parent)) err(at + '.parent', `parent "${v.parent}" is not a view`);
|
|
2060
|
+
arr(v.nodeIds).forEach((id, j) => { if (!ids.has(id)) err(`${at}.nodeIds[${j}]`, `"${id}" is not a card`); });
|
|
2061
|
+
Object.entries(v.nodeOverrides || {}).forEach(([id, o]) => { if (!ids.has(id)) err(`${at}.nodeOverrides.${id}`, `"${id}" is not a card`); later(o, `${at}.nodeOverrides.${id}`); });
|
|
2062
|
+
arr(v.nodes).forEach((n, j) => later(n, `${at}.nodes[${j}]`));
|
|
2063
|
+
['stepIds', 'tour'].forEach(k => arr(v[k]).forEach((id, j) => { if (!mine.has(id)) err(`${at}.${k}[${j}]`, `"${id}" is not one of the view's cards`); }));
|
|
2064
|
+
arr(v.browserOrder).forEach((id, j) => { if (!vids.has(id)) err(`${at}.browserOrder[${j}]`, `"${id}" is not a view`); });
|
|
2065
|
+
arr(v.intake).forEach((s, j) => { if (!mine.has(s && s.to)) err(`${at}.intake[${j}].to`, `"${s && s.to}" is not one of the view's cards`); });
|
|
2066
|
+
arr(v.containers).forEach((c, j) => { if (!c || !c.id) err(`${at}.containers[${j}].id`, 'a box needs an id'); else if (boxes[c.id]) err(`${at}.containers[${j}].id`, `box id "${c.id}" is used twice`); else boxes[c.id] = c; });
|
|
2067
|
+
arr(v.containers).forEach((c, j) => { if (!c) return;
|
|
2068
|
+
arr(c.nodeIds).forEach((id, k) => { if (!mine.has(id)) err(`${at}.containers[${j}].nodeIds[${k}]`, `"${id}" is not one of the view's cards`); });
|
|
2069
|
+
if (c.parent != null && !boxes[c.parent]) err(`${at}.containers[${j}].parent`, `"${c.parent}" is not a box of this view`);
|
|
2070
|
+
for (let p = c.parent, seen = new Set([c.id]); p != null && boxes[p]; p = boxes[p].parent) { if (seen.has(p)) { err(`${at}.containers[${j}].parent`, `box "${c.id}" ends up inside itself`); break; } seen.add(p); } });
|
|
2071
|
+
const ends = new Set([...ids, ...Object.keys(boxes)]);
|
|
2072
|
+
if (Array.isArray(v.edges)) v.edges.forEach((e, j) => { if (typeof e !== 'string') line(e, `${at}.edges[${j}]`, ends); else if (!lineIds.has(e)) err(`${at}.edges[${j}]`, `"${e}" is not a line`); });
|
|
2073
|
+
Object.keys(v.layout || {}).forEach(id => { if (!mine.has(id)) warn(`${at}.layout.${id}`, `"${id}" is not one of the view's cards: its place is ignored`); });
|
|
2074
|
+
if (Array.isArray(v.lanes)) v.lanes.forEach((l, j) => arr(l && l.nodeIds).forEach((id, k) => { if (!mine.has(id)) warn(`${at}.lanes[${j}].nodeIds[${k}]`, `"${id}" is not one of the view's cards: left out of the lane`); }));
|
|
2075
|
+
});
|
|
2076
|
+
// rules (#70): an id, and from, to or noCycles; a selector that names nothing picks nothing, and a misspelt kind picks script cards
|
|
2077
|
+
if (m.rules != null && !Array.isArray(m.rules)) err('rules', 'rules is a list');
|
|
2078
|
+
const ruleIds = new Set(), allBoxes = new Set(anyBox), each = (x, at, f) => (Array.isArray(x) ? x : [x]).forEach((y, j) => { if (y != null) f(y, Array.isArray(x) ? `${at}[${j}]` : at); });
|
|
2079
|
+
const pick = (s, at) => {
|
|
2080
|
+
if (s == null) return;
|
|
2081
|
+
if (typeof s !== 'object' || Array.isArray(s)) return err(at, 'a selector is an object: id, kind, container or not');
|
|
2082
|
+
Object.keys(s).forEach(k => { if (!['id', 'kind', 'container', 'not'].includes(k)) warn(`${at}.${k}`, `unknown key "${k}": ignored, so the selector picks more cards`); });
|
|
2083
|
+
each(s.id, at + '.id', (x, p) => { if (!ids.has(x) && !allBoxes.has(x)) warn(p, `"${x}" is neither a card nor a box: it picks nothing`); });
|
|
2084
|
+
each(s.kind, at + '.kind', (x, p) => { const k = String(x).toLowerCase(); if (!(k in KIND || k in MAPK || k in ALIAS)) warn(p, `unknown kind "${x}": it picks script cards`); });
|
|
2085
|
+
each(s.container, at + '.container', (x, p) => { if (!allBoxes.has(x)) warn(p, `"${x}" is not a box: it picks nothing`); });
|
|
2086
|
+
pick(s.not, at + '.not');
|
|
2087
|
+
};
|
|
2088
|
+
arr(m.rules).forEach((r, i) => {
|
|
2089
|
+
const at = `rules[${i}]`;
|
|
2090
|
+
if (!r || typeof r !== 'object') return err(at, 'a rule is an object with an id');
|
|
2091
|
+
if (typeof r.id !== 'string' || !r.id) err(at + '.id', 'a rule needs an id'); else if (ruleIds.has(r.id)) err(at + '.id', `rule id "${r.id}" is used twice`); else ruleIds.add(r.id);
|
|
2092
|
+
if (!r.from && !r.to && !r.noCycles) err(at, 'a rule needs from, to or noCycles');
|
|
2093
|
+
pick(r.from, at + '.from'); pick(r.to, at + '.to');
|
|
2094
|
+
arr(r.views).forEach((v, j) => { if (!vids.has(v)) err(`${at}.views[${j}]`, `"${v}" is not a view`); });
|
|
2095
|
+
});
|
|
2096
|
+
// stories (#69): each step's view, and the cards, lines and tour it names there
|
|
2097
|
+
if (m.stories != null && !Array.isArray(m.stories)) err('stories', 'stories is a list of stories');
|
|
2098
|
+
const sids = new Set(), lines = new Set([...lineIds, ...views.flatMap(v => arr(v.edges).filter(e => e && e.id != null).map(e => e.id))]);
|
|
2099
|
+
arr(m.stories).forEach((s, i) => {
|
|
2100
|
+
const at = `stories[${i}]`;
|
|
2101
|
+
if (!s || typeof s.id !== 'string' || !s.id) return err(at + '.id', 'a story needs an id');
|
|
2102
|
+
if (sids.has(s.id)) err(at + '.id', `story id "${s.id}" is used twice`); sids.add(s.id);
|
|
2103
|
+
if (!arr(s.steps).length) err(at + '.steps', 'a story needs steps');
|
|
2104
|
+
arr(s.steps).forEach((st, j) => {
|
|
2105
|
+
const p = `${at}.steps[${j}]`, v = views.find(x => x.id === (st && st.view));
|
|
2106
|
+
if (!v) return err(p + '.view', `"${st && st.view}" is not a view`);
|
|
2107
|
+
const mine = new Set(arr(v.nodeIds)), named = (x, q) => { // a card of the view or a line ("a->b#0" names a line with no id)
|
|
2108
|
+
if (typeof x === 'string' && !mine.has(x) && !lines.has(x) && !x.includes('->')) err(q, `"${x}" is neither one of view "${v.id}"'s cards nor a line`); };
|
|
2109
|
+
named(st.select, p + '.select');
|
|
2110
|
+
arr(st.highlight).forEach((x, k) => named(x, `${p}.highlight[${k}]`));
|
|
2111
|
+
arr(st.tour).forEach((x, k) => { if (!mine.has(x)) err(`${p}.tour[${k}]`, `"${x}" is not one of the view's cards`); });
|
|
2112
|
+
const c = st.camera, fit = ': the step shows the whole view';
|
|
2113
|
+
if (c && typeof c === 'object' && c.card != null) { if (!mine.has(c.card)) warn(p + '.camera.card', `"${c.card}" is not one of the view's cards${fit}`); }
|
|
2114
|
+
else if (c != null && c !== 'fit' && !(c && Number.isFinite(c.x) && Number.isFinite(c.y))) warn(p + '.camera', `the camera is "fit", { x, y, zoom } or { card, zoom }${fit}`);
|
|
2115
|
+
if (st.duration != null && !(st.duration > 0)) warn(p + '.duration', `duration ${JSON.stringify(st.duration)} is not a number of ms: 4000`);
|
|
2116
|
+
});
|
|
2117
|
+
});
|
|
2118
|
+
return out;
|
|
2119
|
+
}
|
|
2120
|
+
|
|
2121
|
+
/* ================= rules (#70): what a model says must hold =================
|
|
2122
|
+
A rule forbids lines from the cards `from` picks to the cards `to` picks (a side left out picks every card), or, with
|
|
2123
|
+
noCycles, boxes that reach each other through their lines. A line counts between the two boxes that hold its ends right
|
|
2124
|
+
under the box (or view) they share, so a box is weighed against its siblings, at every depth, and a line into a box's
|
|
2125
|
+
own inner box counts for nothing. A selector picks cards by id, kind (as the map draws it: "db" is a database) or box
|
|
2126
|
+
(at any depth), each a value or a list; every key it has must hold, and `not` turns a selector round. A line's end on
|
|
2127
|
+
a box (#28) is that box: in it, and of no kind. A rule that names a box (a container, or a box's id, in from, to or a
|
|
2128
|
+
not) holds only in the views that draw one of the boxes it names, as a box belongs to its view; any other rule holds in
|
|
2129
|
+
every view. `views` narrows that further. */
|
|
2130
|
+
const boxesNamed = (s, isBox) => !s || typeof s !== 'object' ? []
|
|
2131
|
+
: [...[s.container].flat(), ...[s.id].flat().filter(isBox), ...boxesNamed(s.not, isBox)].filter(x => x != null);
|
|
2132
|
+
const picks = (s, end) => !s || ((s.id == null || [s.id].flat().includes(end.id))
|
|
2133
|
+
&& (s.kind == null || [s.kind].flat().some(k => end.kind != null && kindOf(k, true) === end.kind))
|
|
2134
|
+
&& (s.container == null || [s.container].flat().some(c => end.boxes.includes(c)))
|
|
2135
|
+
&& (s.not == null || !picks(s.not, end)));
|
|
2136
|
+
const andList = xs => xs.length > 1 ? xs.slice(0, -1).join(', ') + ' and ' + xs[xs.length - 1] : xs.join(''), arrOf = x => (Array.isArray(x) ? x : []);
|
|
2137
|
+
/** Every rule the model breaks, in each view that draws it broken: a line a rule forbids, or a cycle of boxes. */
|
|
2138
|
+
export function rules(model) {
|
|
2139
|
+
const list = arrOf(model && model.rules).filter(r => r && typeof r === 'object' && (r.from || r.to || r.noCycles));
|
|
2140
|
+
if (!list.length) return [];
|
|
2141
|
+
const { views, N, nodeIn, viewEdges } = mapContext(model), out = [];
|
|
2142
|
+
const anyBox = new Set(views.flatMap(v => arrOf(v.containers).map(c => c && c.id).filter(Boolean)));
|
|
2143
|
+
const named = new Map(list.map(r => [r, [r.from, r.to].flatMap(s => boxesNamed(s, id => anyBox.has(id) && !N[id]))]));
|
|
2144
|
+
views.forEach(v => {
|
|
2145
|
+
const cs = arrOf(v.containers).filter(c => c && c.id), box = {}; cs.forEach(c => { box[c.id] ||= c; });
|
|
2146
|
+
const order = Object.values(box).map(c => c.id), home = {}; cs.forEach(c => arrOf(c.nodeIds).forEach(id => { home[id] ??= c.id; }));
|
|
2147
|
+
const chain = b => { const c = []; for (const seen = new Set(); b != null && box[b] && !seen.has(b); b = box[b].parent) { seen.add(b); c.unshift(b); } return c; }; // outermost first
|
|
2148
|
+
const end = id => box[id] ? { id, kind: null, boxes: chain(id), label: box[id].label || id }
|
|
2149
|
+
: { id, kind: kindOf(nodeIn(id, v.id).kind, true), boxes: chain(home[id]), label: nodeIn(id, v.id).label || id };
|
|
2150
|
+
// a target names the drawn line, which may stand for several edges: grouped as prepEdges groups them (parallel edges, and
|
|
2151
|
+
// A→B with B→A, unless keepPairs), in its order, so select() finds it
|
|
2152
|
+
// ponytail: a copy of prepEdges' grouping; share one helper if that grouping changes
|
|
2153
|
+
const lines = viewEdges(v), par = new Map(), drawn = new Map(), ends = new Map();
|
|
2154
|
+
lines.forEach(e => { const k = e.from + '>' + e.to + (v.keepPairs ? '#' + e.id : ''); par.has(k) ? par.get(k).push(e) : par.set(k, [e]); });
|
|
2155
|
+
par.forEach(es => { const back = !v.keepPairs && ends.get(es[0].to + '>' + es[0].from);
|
|
2156
|
+
if (back) back.push(...es); else ends.set(es[0].from + '>' + es[0].to, es); es.forEach(e => drawn.set(e, back || es)); });
|
|
2157
|
+
const line = e => ({ type: 'edge', ids: [...new Set(drawn.get(e).map(x => x._id))], view: v.id });
|
|
2158
|
+
let rings = null; // the cycles of boxes, worked out for the first noCycles rule: [boxes, the lines between them]
|
|
2159
|
+
const cycles = () => {
|
|
2160
|
+
const next = {}; // box -> Map(box -> the lines from the one to the other)
|
|
2161
|
+
lines.forEach(e => { const A = end(e.from).boxes, B = end(e.to).boxes; let i = 0; while (i < A.length && i < B.length && A[i] === B[i]) i++;
|
|
2162
|
+
if (i < A.length && i < B.length) { const m = next[A[i]] ||= new Map(); m.set(B[i], [...(m.get(B[i]) || []), e]); } });
|
|
2163
|
+
const reach = b => { const seen = new Set(), todo = [b]; while (todo.length) for (const c of (next[todo.pop()] || new Map()).keys()) if (!seen.has(c)) { seen.add(c); todo.push(c); } return seen; };
|
|
2164
|
+
const R = Object.fromEntries(order.map(b => [b, reach(b)])), done = new Set(), found = [];
|
|
2165
|
+
order.forEach(b => { if (done.has(b) || !R[b].has(b)) return;
|
|
2166
|
+
const ring = order.filter(c => R[b].has(c) && R[c].has(b)); ring.forEach(c => done.add(c));
|
|
2167
|
+
found.push([ring, ring.flatMap(a => ring.flatMap(c => (next[a] && next[a].get(c)) || []))]); });
|
|
2168
|
+
return found;
|
|
2169
|
+
};
|
|
2170
|
+
list.forEach(r => {
|
|
2171
|
+
if (Array.isArray(r.views) && !r.views.includes(v.id)) return;
|
|
2172
|
+
if (named.get(r).length && !named.get(r).some(b => box[b])) return; // it names boxes, none of them this view's
|
|
2173
|
+
const breaks = `breaks rule "${r.title || r.id}"`;
|
|
2174
|
+
if (r.noCycles) (rings ||= cycles()).forEach(([ring, via]) => out.push({ rule: r.id, view: v.id,
|
|
2175
|
+
message: `view ${v.id}: boxes ${andList(ring.map(c => `"${box[c].label || c}"`))} form a cycle, which ${breaks}`,
|
|
2176
|
+
targets: [...ring.map(c => ({ type: 'container', id: c, view: v.id })), ...[...new Set(via.map(e => drawn.get(e)))].map(es => line(es[0]))] }));
|
|
2177
|
+
else { const said = new Set(); // parallel edges that break it read as one line, once
|
|
2178
|
+
lines.forEach(e => { const a = end(e.from), b = end(e.to), message = `view ${v.id}: edge ${a.label}→${b.label} ${breaks}`;
|
|
2179
|
+
if (picks(r.from, a) && picks(r.to, b) && !said.has(message)) { said.add(message); out.push({ rule: r.id, view: v.id, message, targets: [line(e)] }); } }); }
|
|
2180
|
+
});
|
|
2181
|
+
});
|
|
2182
|
+
return out;
|
|
2183
|
+
}
|
|
2184
|
+
|
|
2185
|
+
/* ---------- change: diff (#66) and drift (#68) ----------
|
|
2186
|
+
Each returns a model marked with what the map draws already, so a page the CLI writes, or a still, shows the marks
|
|
2187
|
+
with nothing to call at run time: a card's change (a NEW or CHANGED tab; REMOVED is faded, dashed and struck
|
|
2188
|
+
through), a line's style (added: green and flowing; removed: rose, dashed and faded; security: rose and dashed;
|
|
2189
|
+
faded: its own colour, faint and still) and edited (an amber dot on the line). */
|
|
2190
|
+
const canon = x => JSON.stringify(x, (k, v) => v && typeof v === 'object' && !Array.isArray(v) ? Object.fromEntries(Object.keys(v).sort().map(f => [f, v[f]])) : v);
|
|
2191
|
+
// a line's identity in a list, as a Ref names it: its id, else "<from>-><to>#<n>"; "=<id>" for a view's reference to a model line
|
|
2192
|
+
const lineKeys = list => { const seen = {}; return list.map(e => typeof e === 'string' ? '=' + e : e && typeof e === 'object'
|
|
2193
|
+
? (e.id != null ? String(e.id) : (k => `${k}#${seen[k] = (seen[k] ?? -1) + 1}`)(e.from + '->' + e.to)) : ''); };
|
|
2194
|
+
// a line that stands for several edges (A→B and B→A, parallel edges) shows the strongest of their marks: removed or
|
|
2195
|
+
// undrawn, then added, then changed (its edited dot, which a stronger mark hides); it fades as idle only when every one
|
|
2196
|
+
// of them is idle, and otherwise takes the first style that is no mark
|
|
2197
|
+
const markOf = all => { const top = all.find(x => x.style === 'removed' || x.drift === 'undrawn') || all.find(x => x.style === 'added');
|
|
2198
|
+
if (top) return { style: top.style, strong: true };
|
|
2199
|
+
return { style: all.every(x => x.style === 'faded') ? 'faded' : (all.find(x => x.style && x.style !== 'faded') || {}).style }; };
|
|
2200
|
+
// the lines a view draws: its own list (a model line by id, or one of its own), else every model line between its cards and boxes
|
|
2201
|
+
const linesOf = (m, v) => { const all = Array.isArray(m.edges) ? m.edges.filter(Boolean) : [];
|
|
2202
|
+
if (Array.isArray(v.edges)) { const byId = new Map(all.filter(e => e.id != null).map(e => [String(e.id), e])); return v.edges.map(e => typeof e === 'string' ? byId.get(e) : e).filter(Boolean); }
|
|
2203
|
+
const ids = new Set([...(v.nodeIds || []), ...(v.containers || []).map(c => c && c.id)]); return all.filter(e => ids.has(e.from) && ids.has(e.to)); };
|
|
2204
|
+
|
|
2205
|
+
/** What changed from one model to the next, as one model to draw: `after`, with each card and line marked added, changed
|
|
2206
|
+
* or removed (removed ones come back from `before`, in their views, boxes and places), a summary in words, and the
|
|
2207
|
+
* views that show a change. Cards and lines are matched by id (a line without one by its ends, as a Ref names it). */
|
|
2208
|
+
export function diff(before, after) {
|
|
2209
|
+
const arr = x => (Array.isArray(x) ? x : []), A = structuredClone(before || {}), out = structuredClone(after || {});
|
|
2210
|
+
const how = new Map(), was = new Map(), said = []; // each marked card or line: added, changed or removed; a changed one's [before, after]
|
|
2211
|
+
const card = (x, h) => ({ ...x, change: h });
|
|
2212
|
+
const line = (x, h) => ({ ...x, change: h, ...(h === 'changed' ? { edited: true } : { style: h }) });
|
|
2213
|
+
function mark(old, now, put, keys = l => l.map(x => x && x.id)) { // now's items, marked; old's that left come back, marked removed
|
|
2214
|
+
const ko = keys(old), kn = keys(now), O = new Map(old.map((x, i) => [ko[i], x])), kept = new Set(kn);
|
|
2215
|
+
const res = now.map((x, i) => { if (!x || typeof x !== 'object') return x; const o = O.get(kn[i]);
|
|
2216
|
+
if (o && typeof o === 'object' && canon(o) === canon(x)) return x;
|
|
2217
|
+
const h = o && typeof o === 'object' ? 'changed' : 'added', y = put(x, h); how.set(y, h); if (h === 'changed') was.set(y, [o, x]); return y; });
|
|
2218
|
+
old.forEach((x, i) => { if (x && typeof x === 'object' && !kept.has(ko[i])) { const y = put(x, 'removed'); how.set(y, 'removed'); res.push(y); } });
|
|
2219
|
+
return res;
|
|
2220
|
+
}
|
|
2221
|
+
out.nodes = mark(arr(A.nodes), arr(out.nodes), card);
|
|
2222
|
+
out.edges = mark(arr(A.edges), arr(out.edges), line, lineKeys);
|
|
2223
|
+
const goneLines = new Set(out.edges.filter(e => how.get(e) === 'removed' && e.id != null).map(e => String(e.id)));
|
|
2224
|
+
const V0 = new Map(arr(A.views).filter(Boolean).map(v => [v.id, v])), V1 = new Set(arr(out.views).filter(Boolean).map(v => v.id));
|
|
2225
|
+
const moved = [], touched = new Set(); // cards put into or taken out of a view; views whose cards the diff marks there
|
|
2226
|
+
const views = arr(out.views).map(v => {
|
|
2227
|
+
const o = v && V0.get(v.id); if (!o) return v;
|
|
2228
|
+
const w = { ...v }, ids = arr(v.nodeIds), oldIds = arr(o.nodeIds);
|
|
2229
|
+
if (v.nodes || o.nodes) w.nodes = mark(arr(o.nodes), arr(v.nodes), card);
|
|
2230
|
+
const at = new Map([...out.nodes, ...arr(w.nodes)].filter(Boolean).map(n => [n.id, n]));
|
|
2231
|
+
const left = oldIds.filter(id => !ids.includes(id)), came = ids.filter(id => !oldIds.includes(id)), ov = { ...(v.nodeOverrides || {}) };
|
|
2232
|
+
w.nodeIds = [...ids, ...left];
|
|
2233
|
+
left.forEach(id => { if (how.get(at.get(id)) !== 'removed') { ov[id] = { ...(o.nodeOverrides || {})[id], change: 'removed' }; moved.push(['takes', id, v]); } });
|
|
2234
|
+
came.forEach(id => { if (at.get(id) && how.get(at.get(id)) !== 'added') { ov[id] = { ...ov[id], change: 'added' }; moved.push(['puts', id, v]); } });
|
|
2235
|
+
if (left.length || came.length) touched.add(v.id);
|
|
2236
|
+
if (Object.keys(ov).length) w.nodeOverrides = ov;
|
|
2237
|
+
if (left.length && Array.isArray(v.containers)) // a removed card stays in its box, and in its place on a view laid out by hand when that is free
|
|
2238
|
+
w.containers = v.containers.map(c => { const back = left.filter(id => c && arr(o.containers).some(b => b && b.id === c.id && arr(b.nodeIds).includes(id))); return back.length ? { ...c, nodeIds: [...arr(c.nodeIds), ...back] } : c; });
|
|
2239
|
+
if (left.length && v.layout && o.layout) { const L = { ...v.layout }, taken = new Set(Object.values(L).map(String));
|
|
2240
|
+
left.forEach(id => { const p = o.layout[id]; if (Array.isArray(p) && !taken.has(String(p))) { L[id] = p; taken.add(String(p)); } }); w.layout = L; }
|
|
2241
|
+
if (Array.isArray(v.edges)) { w.edges = mark(arr(o.edges), v.edges, line, lineKeys);
|
|
2242
|
+
arr(o.edges).forEach(e => { if (typeof e === 'string' && !v.edges.includes(e) && goneLines.has(e)) w.edges.push(e); }); } // a removed model line it showed
|
|
2243
|
+
return w;
|
|
2244
|
+
});
|
|
2245
|
+
arr(A.views).forEach(o => { if (!o || V1.has(o.id)) return; // a removed view comes back with everything in it removed
|
|
2246
|
+
const w = { ...o, nodeOverrides: { ...(o.nodeOverrides || {}) } };
|
|
2247
|
+
arr(o.nodeIds).forEach(id => { w.nodeOverrides[id] = { ...w.nodeOverrides[id], change: 'removed' }; });
|
|
2248
|
+
if (o.nodes) w.nodes = o.nodes.map(n => { const y = card(n, 'removed'); how.set(y, 'removed'); return y; });
|
|
2249
|
+
if (Array.isArray(o.edges)) w.edges = o.edges.map(e => e && typeof e === 'object' ? line(e, 'removed') : e);
|
|
2250
|
+
touched.add(o.id); views.push(w); });
|
|
2251
|
+
out.views = views;
|
|
2252
|
+
|
|
2253
|
+
// the summary: views, then cards, then lines, in model order; a line to a card that came or went goes with the card
|
|
2254
|
+
const cards = [...out.nodes, ...views.flatMap(v => arr(v && v.nodes))].filter(Boolean);
|
|
2255
|
+
const named = new Map([...views.flatMap(v => arr(v && v.containers)), ...cards].filter(Boolean).map(n => [n.id, n]));
|
|
2256
|
+
const name = id => String((named.get(id) || {}).label || id), cardHow = new Map(cards.filter(n => how.has(n)).map(n => [n.id, how.get(n)]));
|
|
2257
|
+
const lines = [...out.edges, ...views.flatMap(v => arr(v && v.edges).filter(e => e && typeof e === 'object'))];
|
|
2258
|
+
const diffKeys = ([o, x]) => [...new Set([...Object.keys(o), ...Object.keys(x)])].filter(k => canon(o[k]) !== canon(x[k]));
|
|
2259
|
+
views.forEach(v => { if (v && !V0.has(v.id)) said.push(`adds the view ${v.title || v.id}`); });
|
|
2260
|
+
arr(A.views).forEach(o => { if (o && !V1.has(o.id)) said.push(`removes the view ${o.title || o.id}`); });
|
|
2261
|
+
cards.forEach(n => { const h = how.get(n);
|
|
2262
|
+
if (h === 'added') { const ins = lines.filter(e => e.to === n.id && how.get(e) === 'added').map(e => name(e.from)), outs = lines.filter(e => e.from === n.id && how.get(e) === 'added').map(e => name(e.to));
|
|
2263
|
+
said.push(`adds ${name(n.id)}` + (ins.length === 1 && outs.length === 1 ? ` between ${ins[0]} and ${outs[0]}` : (ins.length ? `, from ${andList(ins)}` : '') + (outs.length ? `, to ${andList(outs)}` : ''))); }
|
|
2264
|
+
if (h === 'removed') said.push(`removes ${name(n.id)}`);
|
|
2265
|
+
if (h === 'changed') { const [o, x] = was.get(n), keys = diffKeys([o, x]), rest = keys.filter(k => k !== 'label');
|
|
2266
|
+
said.push((keys.includes('label') ? `renames ${o.label || o.id} to ${x.label || x.id}` : `changes ${name(n.id)}`) + (rest.length ? ` (${rest.join(', ')})` : '')); } });
|
|
2267
|
+
lines.forEach(e => { const h = how.get(e); if (!h || cardHow.get(e.from) === h && h !== 'changed' || cardHow.get(e.to) === h && h !== 'changed') return;
|
|
2268
|
+
const ends = `${name(e.from)} to ${name(e.to)}`, lab = e.label ? ` (${e.label})` : '';
|
|
2269
|
+
if (h === 'added') said.push(`adds a line from ${ends}${lab}`);
|
|
2270
|
+
if (h === 'removed') said.push(`removes the line from ${ends}${lab}`);
|
|
2271
|
+
if (h === 'changed') { const [o, x] = was.get(e);
|
|
2272
|
+
said.push(`changes the line from ${ends} (${diffKeys([o, x]).map(k => k === 'label' ? `label ${o.label ?? '-'} → ${x.label ?? '-'}` : k).join(', ')})`); } });
|
|
2273
|
+
moved.forEach(([verb, id, v]) => said.push(`${verb} ${name(id)} ${verb === 'puts' ? 'in' : 'out of'} the view ${v.title || v.id}`));
|
|
2274
|
+
|
|
2275
|
+
// the views that show a change: a card or line the diff marks, or a card it put in or took out
|
|
2276
|
+
const changed = views.filter(v => v && (touched.has(v.id) || arr(v.nodeIds).some(id => cardHow.has(id)) || linesOf(out, v).some(e => how.has(e)))).map(v => v.id);
|
|
2277
|
+
return { model: out, summary: said, views: changed };
|
|
2278
|
+
}
|
|
2279
|
+
|
|
2280
|
+
/** The map against the traffic seen: a line that carries traffic but is not drawn comes in, dashed rose ("not drawn",
|
|
2281
|
+
* with its calls); a drawn line between two cards that traffic was seen at, with none of its own, fades. `observed`:
|
|
2282
|
+
* [{ from, to, count }], or a Jaeger or Tempo dependency list ([{ parent, child, callCount }], or { data: [...] }). A
|
|
2283
|
+
* service is a card by `options.match[service]`, else by id, else by label (case aside). Counts: lines that carried
|
|
2284
|
+
* traffic, came in undrawn, are idle, or could not be judged (an end traffic never reached), and services with no card,
|
|
2285
|
+
* each a count of model edges (a drawn line that stands for several fades only when all are idle); views: those that
|
|
2286
|
+
* show drift. */
|
|
2287
|
+
export function drift(model, observed, options = {}) {
|
|
2288
|
+
const arr = x => (Array.isArray(x) ? x : []), out = structuredClone(model || {}), match = options.match || {};
|
|
2289
|
+
const list = Array.isArray(observed) ? observed : arr(observed && observed.data);
|
|
2290
|
+
const cards = [...arr(out.nodes), ...arr(out.views).flatMap(v => arr(v && v.nodes))].filter(n => n && n.id != null);
|
|
2291
|
+
const ids = new Set(cards.map(n => String(n.id))), lower = new Map();
|
|
2292
|
+
cards.forEach(n => [n.id, n.label].forEach(s => { if (s != null && !lower.has(String(s).toLowerCase())) lower.set(String(s).toLowerCase(), String(n.id)); }));
|
|
2293
|
+
const cardOf = s => { s = String(s ?? ''); const id = Object.hasOwn(match, s) ? String(match[s]) : ids.has(s) ? s : lower.get(s.toLowerCase()); return ids.has(id) ? id : null; };
|
|
2294
|
+
const seen = new Map(), live = new Set(), unmatched = new Set(); let counted = false;
|
|
2295
|
+
list.forEach(o => { if (!o || typeof o !== 'object') return;
|
|
2296
|
+
const f = o.from ?? o.parent, t = o.to ?? o.child, n = o.count ?? o.callCount, a = cardOf(f), b = cardOf(t);
|
|
2297
|
+
if (a == null) unmatched.add(String(f)); if (b == null) unmatched.add(String(t));
|
|
2298
|
+
if (a == null || b == null || a === b) return;
|
|
2299
|
+
live.add(a); live.add(b);
|
|
2300
|
+
if (n != null) { counted = true; if (!(+n > 0)) return; } // seen with no calls: both ends were watched, the line carried nothing
|
|
2301
|
+
const k = a + '\n' + b, s = seen.get(k) || { a, b, n: 0 }; s.n += +n || 0; seen.set(k, s); });
|
|
2302
|
+
const counts = { carried: 0, undrawn: 0, idle: 0, unknown: 0, unmatched: [...unmatched].sort() }, drawn = new Set();
|
|
2303
|
+
const judge = e => { if (!e || typeof e !== 'object') return e; const k = e.from + '\n' + e.to;
|
|
2304
|
+
if (seen.has(k)) { drawn.add(k); counts.carried++; return e; }
|
|
2305
|
+
if (live.has(String(e.from)) && live.has(String(e.to))) { counts.idle++; return { ...e, style: 'faded', drift: 'idle' }; }
|
|
2306
|
+
counts.unknown++; return e; };
|
|
2307
|
+
out.edges = arr(out.edges).map(judge);
|
|
2308
|
+
arr(out.views).forEach(v => { if (v && Array.isArray(v.edges)) v.edges = v.edges.map(judge); });
|
|
2309
|
+
seen.forEach(({ a, b, n }, k) => { if (drawn.has(k)) return; counts.undrawn++;
|
|
2310
|
+
const e = { id: `drift:${a}->${b}`, from: a, to: b, label: 'not drawn', ...(counted ? { sublabel: `${n.toLocaleString('en-US')} calls` } : {}), style: 'security', drift: 'undrawn' };
|
|
2311
|
+
out.edges.push(e);
|
|
2312
|
+
arr(out.views).forEach(v => { if (v && Array.isArray(v.edges) && arr(v.nodeIds).includes(a) && arr(v.nodeIds).includes(b)) v.edges.push(e.id); }); });
|
|
2313
|
+
return { model: out, counts, views: arr(out.views).filter(v => v && linesOf(out, v).some(e => e.drift)).map(v => v.id) };
|
|
2314
|
+
}
|
|
2315
|
+
|
|
1866
2316
|
let INSTANT = false; // the page's own hooks (#check, #still, #shot) draw without animation
|
|
1867
2317
|
const reducedMotion = () => INSTANT || (typeof matchMedia === 'function' && matchMedia('(prefers-reduced-motion: reduce)').matches);
|
|
1868
2318
|
let SHEET = null;
|
|
@@ -1891,7 +2341,8 @@ export function render(container, model, options = {}) {
|
|
|
1891
2341
|
// text is measured in the host's font (--um-sans); measure: false keeps the font-independent estimate
|
|
1892
2342
|
const own = typeof options.measure === 'function' ? options.measure : null;
|
|
1893
2343
|
let measure = options.measure === false ? null : own || hostMeasure(host), current = model;
|
|
1894
|
-
|
|
2344
|
+
const expanded = {}; // per view: the cards unfolded in place (#71), in the order they unfolded; in this handle only, never in the model
|
|
2345
|
+
let ctx = mapContext(model, { measure, kinds: !!P.kinds, expand: expanded }); // kinds: the standalone page guesses line kinds from label words, for its legend
|
|
1895
2346
|
const root = host.attachShadow({ mode: 'open', delegatesFocus: true }); // host.focus() lands in the map (on the selected card)
|
|
1896
2347
|
root.adoptedStyleSheets = [sheet()];
|
|
1897
2348
|
root.innerHTML = `<div class="um-live" role="status" aria-live="polite"></div><div class="stage" tabindex="-1">
|
|
@@ -1943,10 +2394,11 @@ export function render(container, model, options = {}) {
|
|
|
1943
2394
|
cam.setSvg(svg, geo.bounds);
|
|
1944
2395
|
const ub = q('[data-z="up"]'); if (ub) ub.disabled = !upOf();
|
|
1945
2396
|
if (options.minimap) buildMini();
|
|
1946
|
-
if (!geo.hub && geo.nodes.length && geo.nodes.every(n => Number.isFinite(n.col) && Number.isFinite(n.row))) placed[vid] = Object.fromEntries(geo.nodes.map(n => [n.id, [n.col, n.row]]));
|
|
2397
|
+
if (!geo.hub && geo.nodes.length && !(expanded[vid] || []).length && geo.nodes.every(n => Number.isFinite(n.col) && Number.isFinite(n.row))) placed[vid] = Object.fromEntries(geo.nodes.map(n => [n.id, [n.col, n.row]])); // unfolded: the places folded stay
|
|
1947
2398
|
const had = sel; sel = null; tabKeys(); paint();
|
|
1948
2399
|
more = [];
|
|
1949
2400
|
if (had) emitSel();
|
|
2401
|
+
endTrip(); drawMarks(); // a new picture: a tour or replay on the old one ends; the host's marks go on again (#63)
|
|
1950
2402
|
if (P.onMount) P.onMount(inner);
|
|
1951
2403
|
}
|
|
1952
2404
|
|
|
@@ -1956,7 +2408,7 @@ export function render(container, model, options = {}) {
|
|
|
1956
2408
|
function clearOf(base) {
|
|
1957
2409
|
if (!cam || !geo) return base;
|
|
1958
2410
|
const [W, H] = cam.size(), key = JSON.stringify([W, H, base]); if (clear && clear.geo === geo && clear.key === key) return clear.sets;
|
|
1959
|
-
const boxes = [...stage.querySelectorAll('.ctl, .minimap')].map(x => x.getBoundingClientRect()).filter(r => r.width && r.height);
|
|
2411
|
+
const boxes = [...stage.querySelectorAll('.ctl, .minimap, .um-story')].map(x => x.getBoundingClientRect()).filter(r => r.width && r.height);
|
|
1960
2412
|
let sets = base; // none shown, or no layout (a test DOM): as before
|
|
1961
2413
|
if (boxes.length) {
|
|
1962
2414
|
const S = stage.getBoundingClientRect(), top = Math.min(...boxes.map(r => r.top)) - S.top - 8, left = Math.min(...boxes.map(r => r.left)) - S.left - 8;
|
|
@@ -2128,6 +2580,35 @@ export function render(container, model, options = {}) {
|
|
|
2128
2580
|
if (!stop && ctx.V[c]) go(c, { via: nid });
|
|
2129
2581
|
}
|
|
2130
2582
|
const up = () => { const p = upOf(); if (p) go(p); else status('Already at the top'); };
|
|
2583
|
+
/* unfolding in place (#71): a stacked card opens into a box on this view (mapContext's unfold), and folds back. Off in
|
|
2584
|
+
an editable map: an edit names the model's own views and boxes, and an unfolded box is neither. */
|
|
2585
|
+
const unfoldBox = id => { const f = ((ctx.V[cur] || {}).containers || []).find(b => b && b.id === id); return f && f.unfolds ? f : null; };
|
|
2586
|
+
const foldOf = id => { const bs = (ctx.V[cur] || {}).containers || []; let b = bs.filter(x => x && (x.nodeIds || []).includes(id)).pop(); // the unfolded box a card is in
|
|
2587
|
+
for (let k = 0; b && k < 64; k++, b = bs.find(x => x && x.id === b.parent)) if (b.unfolds) return b.id; return null; };
|
|
2588
|
+
function setUnfold(nid, on) { // true when the picture changed
|
|
2589
|
+
const list = expanded[cur] || [];
|
|
2590
|
+
if (E || dead || !cur || (on ? list.includes(nid) || !ctx.unfoldable(nid, cur) : !list.includes(nid))) return false;
|
|
2591
|
+
expanded[cur] = on ? [...list, nid] : list.filter(x => x !== nid);
|
|
2592
|
+
update(current, false, true);
|
|
2593
|
+
if ((expanded[cur] || []).includes(nid) !== on) return false; // the model would not have it after all
|
|
2594
|
+
const f = on && (geo.families || []).find(x => x.id === nid);
|
|
2595
|
+
if (f) { cam.reveal({ x: f.x, y: f.y, w: f.w, h: f.h }); select({ type: 'container', id: nid }, { reveal: false }); }
|
|
2596
|
+
else if (!on && geo.byId[nid]) select(nodeRef(nid));
|
|
2597
|
+
say(`${nameOf(nid)} ${on ? 'unfolded here' : 'folded'}`);
|
|
2598
|
+
return true;
|
|
2599
|
+
}
|
|
2600
|
+
function gesture(id, how) { // E, or Alt + click: a stacked card unfolds; an unfolded box, or a card in one, folds it
|
|
2601
|
+
if (E || !id || !geo) return false;
|
|
2602
|
+
const c = geo.byId[id] ? ctx.opensOf(id, cur) : null, on = !!c && (!ctx.V[c] || !!ctx.unfoldable(id, cur)), nid = on ? id : unfoldBox(id) ? id : foldOf(id);
|
|
2603
|
+
if (!nid) return false;
|
|
2604
|
+
let stop = false; // the host may take it, or load a view it has not yet and update() right here
|
|
2605
|
+
emit('expand', { target: nodeRef(nid), view: ctx.opensOf(nid, cur), expanded: on, preventDefault() { stop = true; } });
|
|
2606
|
+
if (stop || dead) return true;
|
|
2607
|
+
if (!setUnfold(nid, on)) { if (on) status(ctx.V[ctx.opensOf(nid, cur)] ? 'Nothing more to show here' : 'Its inner view is not loaded yet'); return true; }
|
|
2608
|
+
if (how === 'key') { const f = on && (geo.families || []).find(x => x.id === nid), inside = n => n.x >= f.x && n.y >= f.y && n.x + n.w <= f.x + f.w && n.y + n.h <= f.y + f.h;
|
|
2609
|
+
const n0 = f ? geo.nodes.filter(inside).sort((a, b) => a.x - b.x || a.y - b.y)[0] : null; focusMap(n0 ? nodeRef(n0.id) : sel); } // keys go on inside, or on the card
|
|
2610
|
+
return true;
|
|
2611
|
+
}
|
|
2131
2612
|
function flash(nid) { const g = svg.querySelector(`.node[data-id="${CSS.escape(nid)}"]`); if (!g) return; g.classList.add('flash'); later(() => g.classList.remove('flash'), 1400); }
|
|
2132
2613
|
function setFlow(on) {
|
|
2133
2614
|
stage.classList.toggle('flowing', on);
|
|
@@ -2139,6 +2620,7 @@ export function render(container, model, options = {}) {
|
|
|
2139
2620
|
/* keys on the canvas; the page forwards the same keys when the focus is elsewhere */
|
|
2140
2621
|
function key(e) {
|
|
2141
2622
|
const k = e.key, g = e.target && e.target.closest && e.target.closest('.node');
|
|
2623
|
+
if (story) { const r = storyKey(e); if (r != null) return r; } // a story's keys first (#69)
|
|
2142
2624
|
if (E && editKey(e)) return true;
|
|
2143
2625
|
if (e.altKey) return false;
|
|
2144
2626
|
const lk = e.target && e.target.closest && e.target.closest('.line-key'), tk = e.target && e.target.closest && e.target.closest('.tab-key, .family [data-badge]');
|
|
@@ -2146,6 +2628,7 @@ export function render(container, model, options = {}) {
|
|
|
2146
2628
|
if (k === 'Enter' && lk && geo.edges[+lk.dataset.i]) select(edgeRef(geo.edges[+lk.dataset.i]), { again: true }); // as a click on the line (#43)
|
|
2147
2629
|
else if (k === 'Enter' && tk) { const fam = tk.closest('.family'), ref = badgeRef(tk.dataset.node, fam ? fam.dataset.family : null, +tk.dataset.badge); if (activate(ref, e)) select(ref); }
|
|
2148
2630
|
else if (k === 'Enter' && card) { const ref = chipRef(card, +ck.dataset.chip); if (activate(ref, e)) select(ref); } // as a click on the chip
|
|
2631
|
+
else if (k === 'e' || k === 'E') return gesture(g ? g.dataset.id : sel && (sel.type === 'node' || sel.type === 'container') ? sel.id : null, 'key'); // unfold here, or fold back (#71)
|
|
2149
2632
|
else if (k === '+' || k === '=') cam.zoomAt(1.4, null, null, true);
|
|
2150
2633
|
else if (k === '-' || k === '_') cam.zoomAt(1 / 1.4, null, null, true);
|
|
2151
2634
|
else if (k === '0') cam.fit();
|
|
@@ -2347,6 +2830,8 @@ export function render(container, model, options = {}) {
|
|
|
2347
2830
|
if (t.closest('.ctl, .minimap')) return;
|
|
2348
2831
|
const g = t.closest('.node'), chip = t.closest('[data-chip]'), badge = t.closest('[data-badge]'), eg = t.closest('.edge-g, .e-lab'), fam = t.closest('.family');
|
|
2349
2832
|
if (g && t.closest('.open-hint')) { if (e.detail <= 1) open(g.dataset.id); return; } // the ⤢ badge opens (a double-click on it opens once)
|
|
2833
|
+
if (fam && t.closest('.fold-hint')) { if (e.detail <= 1) gesture(fam.dataset.family); return; } // an unfolded box's ⤡ tab folds it (#71)
|
|
2834
|
+
if (g && e.altKey && !E && e.detail <= 1 && gesture(g.dataset.id)) return; // Alt + click: unfold the card here, or fold the box it is in
|
|
2350
2835
|
let ref = null;
|
|
2351
2836
|
if (g && badge) ref = badgeRef(g.dataset.id, null, +badge.dataset.badge);
|
|
2352
2837
|
else if (g && chip) ref = chipRef(g, +chip.dataset.chip);
|
|
@@ -2362,7 +2847,7 @@ export function render(container, model, options = {}) {
|
|
|
2362
2847
|
select(null);
|
|
2363
2848
|
if (P.onBackground) P.onBackground();
|
|
2364
2849
|
});
|
|
2365
|
-
stage.addEventListener('dblclick', e => { const g = e.target.closest('.node'); if (!g) return; e.preventDefault(); if (e.target.closest('.open-hint')) return;
|
|
2850
|
+
stage.addEventListener('dblclick', e => { const g = e.target.closest('.node'); if (!g) return; e.preventDefault(); if (e.target.closest('.open-hint') || (e.altKey && !E)) return; // Alt: its click unfolded
|
|
2366
2851
|
if (can('rename') && sel && sel.type === 'node' && sel.id === g.dataset.id && e.target.closest('.n-label')) return rename(sel); // a selected card's name edits
|
|
2367
2852
|
open(g.dataset.id); });
|
|
2368
2853
|
stage.addEventListener('keydown', e => { if (e.defaultPrevented || e.metaKey || e.ctrlKey || (e.altKey && !E) || e.target.closest('input')) return; if (key(e)) { e.preventDefault(); e.stopPropagation(); } });
|
|
@@ -2382,6 +2867,7 @@ export function render(container, model, options = {}) {
|
|
|
2382
2867
|
/* the picture as a file: CSS variables resolved into attributes so it stands alone. It is drawn at rest: no
|
|
2383
2868
|
selection, hover, flow animation or edit handles, whatever the screen shows; the screen is left as it was. */
|
|
2384
2869
|
function standalone(scale = 1, vid = cur, fontCSS = '') {
|
|
2870
|
+
if (settle) settle(); // an export right after update() draws where things end up, not mid-ease
|
|
2385
2871
|
if (!svg || !geo) return '';
|
|
2386
2872
|
let s = svg, g = geo, off = null;
|
|
2387
2873
|
if (vid !== cur) { // another view: drawn off screen, inside the shadow root so the same styles apply
|
|
@@ -2390,6 +2876,7 @@ export function render(container, model, options = {}) {
|
|
|
2390
2876
|
off = document.createElement('div'); off.style.cssText = 'position:absolute;left:-100000px;top:0;width:10px;height:10px;overflow:hidden;pointer-events:none';
|
|
2391
2877
|
off.innerHTML = c.markup; stage.parentNode.appendChild(off); s = off.querySelector('svg'); s.classList.add('map-svg'); g = c.g;
|
|
2392
2878
|
}
|
|
2879
|
+
if (off) drawMarks(s, g); // the host's marks, on another view too (#63)
|
|
2393
2880
|
const s0 = sel, flowing = stage.classList.contains('flowing'), hov = [...svg.querySelectorAll('.hov')];
|
|
2394
2881
|
sel = null; paint(); stage.classList.remove('flowing'); hov.forEach(x => x.classList.remove('hov')); stage.classList.add('um-still');
|
|
2395
2882
|
const clone = s.cloneNode(true), b = g.bounds;
|
|
@@ -2401,7 +2888,7 @@ export function render(container, model, options = {}) {
|
|
|
2401
2888
|
props.forEach(p => { const v = cs.getPropertyValue(p); if (v) d.style.setProperty(p, v); });
|
|
2402
2889
|
if (cs.textTransform === 'uppercase' && !d.childElementCount) d.textContent = d.textContent.toUpperCase(); }); } // tab text, as drawn
|
|
2403
2890
|
finally { stage.classList.remove('um-still'); hov.forEach(x => x.classList.add('hov')); if (flowing) stage.classList.add('flowing'); sel = s0; paint(); if (off) off.remove(); }
|
|
2404
|
-
clone.querySelectorAll('title, .flowline, .um-edit, .um-ghost, .um-link, .keys, .um-key').forEach(t => t.remove());
|
|
2891
|
+
clone.querySelectorAll('title, .flowline, .um-edit, .um-ghost, .um-link, .keys, .um-key, .um-trip').forEach(t => t.remove());
|
|
2405
2892
|
clone.setAttribute('role', 'img'); clone.querySelectorAll('[tabindex], [aria-pressed]').forEach(t => ['tabindex', 'role', 'aria-pressed'].forEach(a => t.removeAttribute(a)));
|
|
2406
2893
|
clone.setAttribute('viewBox', `${b.x} ${b.y} ${b.w} ${b.h}`); clone.setAttribute('width', b.w * scale); clone.setAttribute('height', b.h * scale);
|
|
2407
2894
|
const bg = document.createElementNS('http://www.w3.org/2000/svg', 'rect');
|
|
@@ -2411,42 +2898,303 @@ export function render(container, model, options = {}) {
|
|
|
2411
2898
|
return new XMLSerializer().serializeToString(clone);
|
|
2412
2899
|
}
|
|
2413
2900
|
|
|
2414
|
-
|
|
2901
|
+
/* the time-lapse (#67): an update that redraws the view eases into it. Cards that moved glide from where they were; new
|
|
2902
|
+
cards, and lines whose route changed, fade in; cards and lines that left fade out where they were, copied from the
|
|
2903
|
+
picture before (as nothing that can be clicked, focused, found or exported). CSS on the new picture only: the
|
|
2904
|
+
geometry is the update's own. Nothing moves under reduced motion. */
|
|
2905
|
+
let settle = null; // ends an ease still running (before an export, or the next update)
|
|
2906
|
+
function easeIn(oldGeo, oldSvg) {
|
|
2907
|
+
if (settle) settle();
|
|
2908
|
+
if (options.ease === false || reducedMotion() || !oldGeo || !oldSvg || !geo || !svg || oldSvg === svg) return;
|
|
2909
|
+
const T = 600, moves = [], key = e => edgeRefIds(e).join(' ') || e.from + '>' + e.to, all = (s, q) => [...s.querySelectorAll(q)];
|
|
2910
|
+
const was = new Map(oldGeo.nodes.map(n => [n.id, n])), now = new Set(geo.nodes.map(n => n.id));
|
|
2911
|
+
const lines = new Map(oldGeo.edges.map((e, i) => [key(e), [e, i]])), kept = new Set(geo.edges.map(key));
|
|
2912
|
+
geo.nodes.forEach(n => { const g = svg.querySelector(`.node[data-id="${CSS.escape(n.id)}"]`), o = was.get(n.id); if (!g) return;
|
|
2913
|
+
if (!o) moves.push([g, 'opacity', '0', 0]);
|
|
2914
|
+
else if (o.x !== n.x || o.y !== n.y) moves.push([g, 'transform', `translate(${o.x - n.x}px, ${o.y - n.y}px)`, 0]); });
|
|
2915
|
+
geo.edges.forEach((e, i) => { const o = lines.get(key(e)); if (o && canon(o[0].pts) === canon(e.pts)) return;
|
|
2916
|
+
all(svg, `.edge-g[data-i="${i}"], .e-lab[data-i="${i}"]`).forEach(x => moves.push([x, 'opacity', '0', T / 2])); });
|
|
2917
|
+
const gone = document.createElementNS('http://www.w3.org/2000/svg', 'g');
|
|
2918
|
+
gone.setAttribute('class', 'um-leaving'); gone.setAttribute('aria-hidden', 'true'); gone.style.pointerEvents = 'none';
|
|
2919
|
+
const leave = x => { const c = x.cloneNode(true);
|
|
2920
|
+
[c, ...c.querySelectorAll('*')].forEach(y => { ['tabindex', 'role', 'data-id', 'data-i', 'data-from', 'data-to', 'data-o', 'data-t', 'data-chip', 'data-badge'].forEach(a => y.removeAttribute(a)); y.classList.remove('node', 'edge-g', 'e-lab', 'sel', 'hot', 'hov'); });
|
|
2921
|
+
gone.appendChild(c); };
|
|
2922
|
+
oldGeo.nodes.forEach(n => { if (!now.has(n.id)) all(oldSvg, `.node[data-id="${CSS.escape(n.id)}"]`).forEach(leave); });
|
|
2923
|
+
lines.forEach(([, i], k) => { if (!kept.has(k)) all(oldSvg, `.edge-g[data-i="${i}"], .e-lab[data-i="${i}"]`).forEach(leave); });
|
|
2924
|
+
if (!moves.length && !gone.childNodes.length) return;
|
|
2925
|
+
svg.appendChild(gone);
|
|
2926
|
+
moves.forEach(([el, p, v]) => { el.style.transition = 'none'; el.style.setProperty(p, v); });
|
|
2927
|
+
svg.getBoundingClientRect(); // the start, drawn: each change below is a transition from it
|
|
2928
|
+
moves.forEach(([el, p, , d]) => { el.style.transition = `${p} ${T}ms ease ${d}ms`; el.style.removeProperty(p); });
|
|
2929
|
+
gone.style.transition = `opacity ${T / 2}ms ease`; gone.style.opacity = '0';
|
|
2930
|
+
const t = later(() => settle && settle(), T * 2);
|
|
2931
|
+
settle = () => { clearTimeout(t); timers.delete(t); moves.forEach(([el]) => el.style.removeProperty('transition')); gone.remove(); settle = null; };
|
|
2932
|
+
}
|
|
2933
|
+
|
|
2934
|
+
function update(model2, relayout = false, unfolding = false) { // unfolding: the same model, a card unfolded or folded
|
|
2415
2935
|
if (dead) return;
|
|
2416
2936
|
// an identical model: nothing to redraw, unless the host accepted a move or reorder that only changed a position
|
|
2417
|
-
if (!relayout && !Object.keys(hints).length && !Object.keys(slotsH).length && JSON.stringify(model2) === JSON.stringify(current)) { addHints.length = 0; return; }
|
|
2937
|
+
if (!relayout && !unfolding && !Object.keys(hints).length && !Object.keys(slotsH).length && JSON.stringify(model2) === JSON.stringify(current)) { addHints.length = 0; return; }
|
|
2418
2938
|
const old = ctx, keys = {}, before = new Set(geo ? geo.nodes.map(n => n.id) : []), wantNew = selectNew;
|
|
2419
2939
|
old.views.forEach(v => { keys[v.id] = relayout ? null : old.viewKey(v.id); });
|
|
2420
2940
|
current = model2;
|
|
2421
|
-
ctx = mapContext(withPlaces(model2), { measure, kinds: !!P.kinds, prev: options.stableLayout === false ? null : old.planCache });
|
|
2941
|
+
ctx = mapContext(withPlaces(model2), { measure, kinds: !!P.kinds, prev: options.stableLayout === false ? null : old.planCache, expand: expanded });
|
|
2942
|
+
Object.keys(expanded).forEach(v => { expanded[v] = ctx.unfolded[v] || []; }); // a card or inner view the model no longer has folds
|
|
2422
2943
|
[hints, slotsH].forEach(o => Object.keys(o).forEach(k => delete o[k])); addHints.length = 0; selectNew = false;
|
|
2423
2944
|
ctx.views.forEach(v => { const id = v.id; if (keys[id] == null || keys[id] !== ctx.viewKey(id)) return;
|
|
2424
2945
|
if (id in old.planCache) ctx.planCache[id] = old.planCache[id];
|
|
2425
2946
|
if (old.layoutCache[id]) ctx.layoutCache[id] = old.layoutCache[id];
|
|
2426
|
-
if (old.geoCache[id]) ctx.geoCache[id] = old.geoCache[id]; });
|
|
2947
|
+
if (old.geoCache[id] && JSON.stringify(old.brokenIn(id)) === JSON.stringify(ctx.brokenIn(id))) ctx.geoCache[id] = old.geoCache[id]; }); // the same rules broken (#70)
|
|
2427
2948
|
if (!cur) return;
|
|
2428
2949
|
if (!ctx.V[cur]) { const from = cur; mount(ctx.rootId); cam.set(startView()); cam.touched = false; emit('viewchange', { view: cur, from }); return; }
|
|
2429
2950
|
if (ctx.geoCache[cur] && keys[cur] === ctx.viewKey(cur)) return; // this view's picture did not change
|
|
2430
|
-
const keep = sel, keepMore = more, touched = cam.touched;
|
|
2951
|
+
const keep = sel, keepMore = more, touched = cam.touched, prev = [geo, svg];
|
|
2431
2952
|
mount(cur);
|
|
2432
2953
|
if (touched) cam.set({}); else cam.set(startView());
|
|
2433
2954
|
cam.touched = touched;
|
|
2434
2955
|
const fresh = geo.nodes.filter(n => !before.has(n.id));
|
|
2435
2956
|
if (wantNew && fresh.length === 1) select(nodeRef(fresh[0].id), { reveal: false });
|
|
2436
2957
|
else if (keep && exists(keep)) { sel = keep; more = keep.type === 'node' ? keepMore.filter(id => geo.byId[id]) : []; paint(); emitSel(); }
|
|
2958
|
+
if (!relayout) easeIn(...prev); // a font that came in late moves nothing on purpose: no ease
|
|
2437
2959
|
}
|
|
2438
2960
|
|
|
2439
2961
|
function destroy() {
|
|
2440
2962
|
if (dead) return;
|
|
2441
|
-
dead = true; if (ro) ro.disconnect(); cancelAnimationFrame(rz);
|
|
2963
|
+
dead = true; if (ro) ro.disconnect(); cancelAnimationFrame(rz); stopStory();
|
|
2442
2964
|
timers.forEach(clearTimeout); timers.clear();
|
|
2443
2965
|
if (cam) cam.stopAnimation();
|
|
2444
2966
|
host.remove();
|
|
2445
2967
|
Object.keys(subs).forEach(k => delete subs[k]);
|
|
2446
2968
|
}
|
|
2447
2969
|
|
|
2448
|
-
|
|
2449
|
-
|
|
2970
|
+
/* ---------- marks: overlay(), highlight(), tour() and replay() (#62, #63, #65) ----------
|
|
2971
|
+
The host's marks, drawn over the picture without touching the model: named layers, stacked in the order first set.
|
|
2972
|
+
Each mount draws them again, so they survive update() and setView(); a card or line the view doesn't show is ignored.
|
|
2973
|
+
toSVG() and toPNG() draw them too, at rest: they are the host's, like the model (the selection and hover are not). */
|
|
2974
|
+
const layers = new Map(), DIM = .2, clamp01 = x => Math.min(1, Math.max(0, +x || 0));
|
|
2975
|
+
// a drawn line that stands for several edges takes the strongest of their marks: a pulsing one, then the most lit, then the fastest
|
|
2976
|
+
const strongest = ms => ms.filter(Boolean).sort((a, b) => (!!b.pulse - !!a.pulse) || ((b.level ?? 1) - (a.level ?? 1)) || ((b.speed ?? 1) - (a.speed ?? 1)))[0];
|
|
2977
|
+
function markOf(get) { // one card's or line's mark over every layer: each property from the latest layer that sets it, the lowest level
|
|
2978
|
+
let out = null;
|
|
2979
|
+
layers.forEach(L => { const m = get(L); if (!m && !L.dim) return; out ||= { level: 1 };
|
|
2980
|
+
if (m) for (const k in m) if (m[k] != null && k !== 'level') out[k] = m[k];
|
|
2981
|
+
out.level = Math.min(out.level, m ? clamp01(m.level ?? 1) : DIM); });
|
|
2982
|
+
return out;
|
|
2983
|
+
}
|
|
2984
|
+
const longest = pts => pts.slice(1).reduce((m, p, k) => Math.abs(p[0] - pts[k][0]) + Math.abs(p[1] - pts[k][1]) > Math.abs(m[1][0] - m[0][0]) + Math.abs(m[1][1] - m[0][1]) ? [pts[k], p] : m, [pts[0], pts[1]]);
|
|
2985
|
+
function drawMarks(s = svg, g = geo) {
|
|
2986
|
+
if (!s || !g) return;
|
|
2987
|
+
s.querySelectorAll('.um-rings, .um-marks, .um-glow').forEach(x => x.remove());
|
|
2988
|
+
s.querySelectorAll('.node[opacity], .edge-g[opacity], .e-lab[opacity]').forEach(x => x.removeAttribute('opacity'));
|
|
2989
|
+
s.querySelectorAll('.edge[style], .flowline[style]').forEach(x => x.removeAttribute('style'));
|
|
2990
|
+
s.querySelectorAll('.node[aria-description]').forEach(x => x.removeAttribute('aria-description'));
|
|
2991
|
+
if (s === svg) stage.classList.toggle('um-lit', [...layers.values()].some(L => L.dim));
|
|
2992
|
+
if (!layers.size) return;
|
|
2993
|
+
const tone = t => 'var(--um-' + String(t || 'accent').replace(/[^\w-]/g, '') + ')', op = m => m.level < 1 ? ` opacity="${m.level}"` : '';
|
|
2994
|
+
const cards = Object.fromEntries([...s.querySelectorAll('.node')].map(x => [x.dataset.id, x]));
|
|
2995
|
+
let under = '', over = '';
|
|
2996
|
+
g.nodes.forEach(n => { const m = markOf(L => (L.nodes || {})[n.id]), el = cards[n.id]; if (!m || !el) return;
|
|
2997
|
+
if (m.level < 1) el.setAttribute('opacity', m.level);
|
|
2998
|
+
if (m.tone || m.pulse) under += `<g${op(m)}><rect x="${n.x - 5}" y="${n.y - 5}" width="${n.w + 10}" height="${n.h + 10}" rx="14" class="um-ring${m.pulse ? ' um-pulse' : ''}" stroke="${tone(m.tone)}"/></g>`;
|
|
2999
|
+
const b = m.badge == null ? null : badgesOf({ badges: [m.badge] })[0]; if (!b) return; // a tab after the card's own
|
|
3000
|
+
const x = mapTabs(n).reduce((a, t) => a + t.w + 4, n.x + 12), w = b.text.toUpperCase().length * 6.4 + 10;
|
|
3001
|
+
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
|
+
el.setAttribute('aria-description', b.text); });
|
|
3003
|
+
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
|
+
const path = eg.querySelector('.edge'), flow = eg.querySelector('.flowline');
|
|
3005
|
+
if (m.level < 1) [eg, s.querySelector(`.e-lab[data-i="${i}"]`)].forEach(x => x && x.setAttribute('opacity', m.level));
|
|
3006
|
+
if (m.tone || m.pulse) path.insertAdjacentHTML('beforebegin', `<path d="${path.getAttribute('d')}" class="um-glow${m.pulse ? ' um-pulse' : ''}" stroke="${tone(m.tone)}"/>`);
|
|
3007
|
+
if (m.dash) path.setAttribute('style', 'stroke-dasharray: 7 5');
|
|
3008
|
+
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
|
+
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>`; });
|
|
3014
|
+
const cardsG = s.querySelector('.nodes');
|
|
3015
|
+
if (under && cardsG) cardsG.insertAdjacentHTML('beforebegin', `<g class="um-rings" aria-hidden="true">${under}</g>`);
|
|
3016
|
+
if (over) s.insertAdjacentHTML('beforeend', `<g class="um-marks" aria-hidden="true">${over}</g>`);
|
|
3017
|
+
}
|
|
3018
|
+
function overlay(name, layer) {
|
|
3019
|
+
if (dead || name == null) return;
|
|
3020
|
+
if (layer == null) layers.delete(String(name)); else layers.set(String(name), layer); // a Map keeps the order first set
|
|
3021
|
+
drawMarks();
|
|
3022
|
+
}
|
|
3023
|
+
/* the 'highlight' layer: these cards and lines lit, the rest dimmed; a Ref's level (0 to 1), else its distance, fades it */
|
|
3024
|
+
function highlight(refs) {
|
|
3025
|
+
if (refs == null) return overlay('highlight', null);
|
|
3026
|
+
const L = { nodes: {}, edges: {}, dim: true }, lv = r => clamp01(r.level ?? (r.distance > 0 ? Math.max(.45, 1 - .15 * r.distance) : 1));
|
|
3027
|
+
[].concat(refs).forEach(r => { if (!r) return; const id = r.type === 'node' ? r.id : r.node; // a chip or badge: its card
|
|
3028
|
+
if (id != null) L.nodes[id] = { level: Math.max(lv(r), (L.nodes[id] || {}).level || 0) };
|
|
3029
|
+
else if (r.type === 'edge') (r.ids || []).forEach(x => { L.edges[x] = { level: Math.max(lv(r), (L.edges[x] || {}).level || 0) }; }); });
|
|
3030
|
+
overlay('highlight', L);
|
|
3031
|
+
}
|
|
3032
|
+
/* a dot that rides the map: a tour along a path the host names, or a trace replayed. One at a time; a new picture of the
|
|
3033
|
+
view (setView, an update that redraws it) ends it, as does destroy() */
|
|
3034
|
+
let trip = null;
|
|
3035
|
+
function endTrip(played = false) { const t = trip; if (!t) return; trip = null; cancelAnimationFrame(t.raf); if (t.dot) t.dot.remove(); t.end(played); }
|
|
3036
|
+
function startTrip(step, end) { // step(ms played, pauses left out) says whether to go on
|
|
3037
|
+
endTrip();
|
|
3038
|
+
const t = { raf: 0, dot: null, paused: false, at: 0, last: performance.now(), end }; trip = t;
|
|
3039
|
+
const frame = now => { if (trip !== t) return; if (dead) return endTrip(); if (!t.paused) t.at += now - t.last; t.last = now;
|
|
3040
|
+
if (step(t.at)) t.raf = requestAnimationFrame(frame); else endTrip(true); };
|
|
3041
|
+
t.raf = requestAnimationFrame(frame);
|
|
3042
|
+
return t;
|
|
3043
|
+
}
|
|
3044
|
+
const lineBetween = (a, b) => { let i = geo.edges.findIndex(e => e.from === a && e.to === b); if (i < 0) i = geo.edges.findIndex(e => e.from === b && e.to === a); // either way
|
|
3045
|
+
return i < 0 ? null : { i, pts: geo.edges[i].from === a ? geo.edges[i].pts : [...geo.edges[i].pts].reverse() }; };
|
|
3046
|
+
const lenOf = pts => pts.slice(1).reduce((s, p, k) => s + Math.hypot(p[0] - pts[k][0], p[1] - pts[k][1]), 0);
|
|
3047
|
+
const along = (pts, d) => { for (let k = 1; k < pts.length; k++) { const a = pts[k - 1], b = pts[k], l = Math.hypot(b[0] - a[0], b[1] - a[1]);
|
|
3048
|
+
if (d <= l || k === pts.length - 1) { const f = l ? Math.min(1, d / l) : 1; return [a[0] + (b[0] - a[0]) * f, a[1] + (b[1] - a[1]) * f]; } d -= l; } return pts[0]; };
|
|
3049
|
+
/* tour(path): the dot rides the line between each pair of cards in turn, through each card to its next line, and each
|
|
3050
|
+
card it reaches flashes; where no line joins two cards it skips to the next. Resolves true when it reaches the end. */
|
|
3051
|
+
function tour(path, o = {}) {
|
|
3052
|
+
endTrip();
|
|
3053
|
+
if (dead || !geo || path == null) return Promise.resolve(false);
|
|
3054
|
+
const ids = [].concat(path).map(String).filter(id => geo.byId[id]), legs = [];
|
|
3055
|
+
for (let k = 0; k + 1 < ids.length; k++) { const r = lineBetween(ids[k], ids[k + 1]); if (!r) continue;
|
|
3056
|
+
const prev = legs[legs.length - 1], n = geo.byId[ids[k]];
|
|
3057
|
+
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
|
+
legs.push({ pts, to: ids[k + 1], len: lenOf(pts) }); }
|
|
3059
|
+
if (!legs.length || reducedMotion()) return Promise.resolve(legs.length > 0);
|
|
3060
|
+
const speed = Math.max(20, +o.speed || 240); // map units a second
|
|
3061
|
+
svg.insertAdjacentHTML('beforeend', '<g class="um-trip" aria-hidden="true"><circle r="5.5" class="tour-dot"/></g>');
|
|
3062
|
+
const g = svg.lastElementChild, dot = g.firstElementChild; let leg = 0, base = 0;
|
|
3063
|
+
return new Promise(res => { startTrip(ms => { const d = ms * speed / 1000;
|
|
3064
|
+
while (leg < legs.length && d - base >= legs[leg].len) { base += legs[leg].len; flash(legs[leg].to); leg++; }
|
|
3065
|
+
if (leg >= legs.length) return false;
|
|
3066
|
+
const [x, y] = along(legs[leg].pts, d - base); dot.setAttribute('cx', x); dot.setAttribute('cy', y); return true; }, res).dot = g; });
|
|
3067
|
+
}
|
|
3068
|
+
/* replay(spans): each call from one card to another (a span and its parent's) runs along their line at the trace's relative
|
|
3069
|
+
time, the whole trace in `duration` ms; the line glows while the call lasts and keeps its duration as a label */
|
|
3070
|
+
function replay(spans, o = {}) {
|
|
3071
|
+
endTrip(); overlay('replay', null);
|
|
3072
|
+
const list = [].concat(spans || []).filter(s => s && Number.isFinite(+s.start) && Number.isFinite(+s.end));
|
|
3073
|
+
const byId = new Map(list.map(s => [String(s.id), s])), match = o.match || {}, low = x => String(x ?? '').trim().toLowerCase();
|
|
3074
|
+
const cardOf = svc => { if (svc == null || !geo) return null; if (match[svc] != null) return geo.byId[match[svc]] ? String(match[svc]) : null;
|
|
3075
|
+
if (geo.byId[svc]) return String(svc); const n = geo.nodes.find(x => low(x.label) === low(svc)); return n ? n.id : null; };
|
|
3076
|
+
let skipped = 0, t = null, done; const hops = [];
|
|
3077
|
+
list.forEach(s => { const to = cardOf(s.service); if (!to) return skipped++;
|
|
3078
|
+
const p = s.parent != null ? byId.get(String(s.parent)) : null, from = p && cardOf(p.service);
|
|
3079
|
+
if (!from || from === to) return; // a root span, a caller off the view, or work inside one card: no hop
|
|
3080
|
+
const r = lineBetween(from, to); if (!r) return skipped++; // a call the view draws no line for
|
|
3081
|
+
hops.push({ ...r, ids: edgeRefIds(geo.edges[r.i]), at: +s.start, dur: +s.end - +s.start, to, len: lenOf(r.pts) }); });
|
|
3082
|
+
const ctl = { hops: hops.length, skipped, done: new Promise(r => { done = r; }),
|
|
3083
|
+
pause() { if (t && trip === t) t.paused = true; }, resume() { if (t && trip === t) t.paused = false; },
|
|
3084
|
+
stop() { if (t && trip === t) endTrip(); else overlay('replay', null); } };
|
|
3085
|
+
const ms = x => x >= 1000 ? `${+(x / 1000).toFixed(x >= 1e4 ? 0 : 1)} s` : `${x >= 10 ? Math.round(x) : +x.toFixed(1)} ms`, marks = {};
|
|
3086
|
+
// ponytail: two calls at once on one line share its mark; the one that ends first takes the glow off
|
|
3087
|
+
const show = (h, on) => { h.ids.forEach(x => { marks[x] = on ? { label: ms(h.dur), tone: 'accent', pulse: true } : { label: ms(h.dur) }; }); overlay('replay', { edges: { ...marks } }); };
|
|
3088
|
+
if (!hops.length || reducedMotion()) { hops.forEach(h => show(h, false)); done(hops.length > 0); return ctl; }
|
|
3089
|
+
const t0 = Math.min(...list.map(s => +s.start)), k = (+o.duration || 6000) / Math.max(1e-6, Math.max(...list.map(s => +s.end)) - t0);
|
|
3090
|
+
hops.forEach(h => { h.start = (h.at - t0) * k; h.end = h.start + h.dur * k; h.travel = Math.min(1200, Math.max(350, h.dur * k)); });
|
|
3091
|
+
const total = Math.max(...hops.map(h => Math.max(h.end, h.start + h.travel)));
|
|
3092
|
+
svg.insertAdjacentHTML('beforeend', '<g class="um-trip" aria-hidden="true"></g>'); const g = svg.lastElementChild;
|
|
3093
|
+
t = startTrip(now => { hops.forEach(h => {
|
|
3094
|
+
if (!h.on && now >= h.start) { h.on = true; show(h, true); g.insertAdjacentHTML('beforeend', '<circle r="4.5" class="tour-dot"/>'); h.dot = g.lastElementChild; }
|
|
3095
|
+
if (h.dot) { const f = (now - h.start) / h.travel; if (f >= 1) { h.dot.remove(); h.dot = null; flash(h.to); } else { const [x, y] = along(h.pts, f * h.len); h.dot.setAttribute('cx', x); h.dot.setAttribute('cy', y); } }
|
|
3096
|
+
if (h.on && !h.off && now >= h.end) { h.off = true; show(h, false); } });
|
|
3097
|
+
return now < total; }, played => { if (!played) overlay('replay', null); done(played); });
|
|
3098
|
+
t.dot = g;
|
|
3099
|
+
return ctl;
|
|
3100
|
+
}
|
|
3101
|
+
|
|
3102
|
+
/* ---------- stories (#69): the model's narrated walk-throughs, played step by step ----------
|
|
3103
|
+
A step sets the whole picture: its view, camera, selection, highlight and caption (what it leaves out is cleared),
|
|
3104
|
+
then plays its tour. Playing goes on by itself after each step's duration; the caption bar and the keys (← → PageUp
|
|
3105
|
+
PageDown, Esc) step through and stop it. One story at a time. */
|
|
3106
|
+
let story = null;
|
|
3107
|
+
// a step's card or line: a string names a card of the view, else a line by its id (the drawn line that has it); an
|
|
3108
|
+
// object is a Ref already
|
|
3109
|
+
const stepRef = x => typeof x !== 'string' ? (x && typeof x === 'object' ? x : null) : geo.byId[x] ? nodeRef(x)
|
|
3110
|
+
: (e => e ? edgeRef(e) : null)(geo.edges.find(e => edgeRefIds(e).includes(x)));
|
|
3111
|
+
function stepLit(list) { // highlight: what it names, and every line between two cards it names
|
|
3112
|
+
const refs = [].concat(list || []).map(stepRef).filter(Boolean), ids = new Set(refs.filter(r => r.type === 'node').map(r => r.id));
|
|
3113
|
+
geo.edges.forEach(e => { if (ids.has(e.from) && ids.has(e.to)) refs.push(edgeRef(e)); });
|
|
3114
|
+
return refs.length ? refs : null;
|
|
3115
|
+
}
|
|
3116
|
+
function storyBar(st) {
|
|
3117
|
+
let bar = stage.querySelector('.um-story');
|
|
3118
|
+
if (!bar) {
|
|
3119
|
+
stage.insertAdjacentHTML('beforeend', `<div class="um-story" role="group"><div class="um-cap"><span class="um-st"></span><p></p></div><div class="um-sb">
|
|
3120
|
+
<button data-s="prev" title="Previous step (←)" aria-label="Previous step">‹</button><button data-s="play"></button><button data-s="next" title="Next step (→)"></button><button data-s="stop" title="Close (Esc)" aria-label="Close the story">✕</button></div></div>`);
|
|
3121
|
+
bar = stage.lastElementChild; clear = null; // the fit keeps the cards off the bar as it does off the controls
|
|
3122
|
+
bar.addEventListener('dblclick', e => e.stopPropagation()); // two quick clicks on › are two steps, not a zoom
|
|
3123
|
+
bar.addEventListener('click', e => { e.stopPropagation(); const s = story, b = e.target.closest('button'); if (!s || !b) return;
|
|
3124
|
+
({ prev: () => stepStory(-1), next: () => stepStory(1), stop: () => stopStory(), play: () => s.auto ? s.ctl.pause() : s.ctl.resume() })[b.dataset.s](); });
|
|
3125
|
+
}
|
|
3126
|
+
const k = st.at, n = st.steps.length, b = s => bar.querySelector(`[data-s="${s}"]`);
|
|
3127
|
+
bar.setAttribute('aria-label', 'Story: ' + (st.s.title || st.s.id));
|
|
3128
|
+
bar.querySelector('.um-st').textContent = `${st.s.title || st.s.id} · ${k + 1} / ${n}`;
|
|
3129
|
+
bar.querySelector('p').textContent = (st.steps[k] || {}).caption || '';
|
|
3130
|
+
b('prev').disabled = k <= 0;
|
|
3131
|
+
b('play').textContent = st.auto ? '❚❚' : '▶'; b('play').setAttribute('aria-label', st.auto ? 'Pause' : 'Play'); b('play').title = st.auto ? 'Pause' : 'Play';
|
|
3132
|
+
b('next').textContent = k + 1 < n ? '›' : '✓'; b('next').setAttribute('aria-label', k + 1 < n ? 'Next step' : 'Finish the story');
|
|
3133
|
+
}
|
|
3134
|
+
async function showStep(st, k) {
|
|
3135
|
+
const seq = ++st.seq, step = st.steps[k] || {}, live = () => story === st && st.seq === seq && !dead;
|
|
3136
|
+
st.at = k; clearTimeout(st.timer); tour(null); storyBar(st);
|
|
3137
|
+
emit('story', { story: st.s.id, step: k, steps: st.steps.length }); // before the step's own select and viewchange
|
|
3138
|
+
while (busy && live()) await new Promise(r => setTimeout(r, 30)); // a move the user started: let it land first
|
|
3139
|
+
if (!live()) return;
|
|
3140
|
+
if (step.view !== cur && ctx.V[step.view]) await go(step.view);
|
|
3141
|
+
if (!live()) return;
|
|
3142
|
+
const c = step.camera && typeof step.camera === 'object' ? step.camera : null, n = c && c.card != null ? geo.byId[c.card] : null;
|
|
3143
|
+
const to = n ? { cx: n.x + n.w / 2, cy: n.y + n.h / 2, k: +c.zoom || 1.5 }
|
|
3144
|
+
: c && Number.isFinite(+c.x) && Number.isFinite(+c.y) ? { cx: +c.x, cy: +c.y, k: +c.zoom || cam.s.k } : startView(); // 'fit', or none
|
|
3145
|
+
const moved = cam.animate(to); cam.touched = !!(n || (c && Number.isFinite(+c.x)));
|
|
3146
|
+
select(step.select == null ? null : stepRef(step.select), { reveal: false });
|
|
3147
|
+
highlight(stepLit(step.highlight));
|
|
3148
|
+
say(`Step ${k + 1} of ${st.steps.length}${step.caption ? ': ' + step.caption : ''}`); // after select(), which says what it selected
|
|
3149
|
+
await moved;
|
|
3150
|
+
if (!live()) return;
|
|
3151
|
+
st.toured = step.tour ? tour(step.tour) : Promise.resolve(true);
|
|
3152
|
+
arm(st);
|
|
3153
|
+
}
|
|
3154
|
+
function arm(st) { // playing: the next step once this one's duration is up and its tour has reached its end
|
|
3155
|
+
clearTimeout(st.timer);
|
|
3156
|
+
if (!st.auto || story !== st) return;
|
|
3157
|
+
const seq = st.seq, dur = Math.max(500, +(st.steps[st.at] || {}).duration || 4000); // reading time: not cut by reduced motion
|
|
3158
|
+
Promise.all([st.toured, new Promise(r => { st.timer = later(r, dur); })]).then(() => { if (story === st && st.seq === seq && st.auto) stepStory(1); });
|
|
3159
|
+
}
|
|
3160
|
+
function stepStory(d) {
|
|
3161
|
+
const st = story; if (!st) return;
|
|
3162
|
+
const k = st.at + d;
|
|
3163
|
+
if (k >= st.steps.length) return stopStory(true); // past the last step: the story has been told
|
|
3164
|
+
if (k >= 0) showStep(st, k);
|
|
3165
|
+
}
|
|
3166
|
+
function stopStory(told = false) {
|
|
3167
|
+
const st = story; if (!st) return;
|
|
3168
|
+
story = null; clearTimeout(st.timer); tour(null); highlight(null);
|
|
3169
|
+
const bar = stage.querySelector('.um-story'); if (bar) { bar.remove(); clear = null; }
|
|
3170
|
+
if (!dead) { say(told ? 'End of the story' : 'Story closed'); emit('story', { story: st.s.id, step: null, steps: st.steps.length }); }
|
|
3171
|
+
st.end(told);
|
|
3172
|
+
}
|
|
3173
|
+
function storyKey(e) { // null: not a story's key; on the bar's own buttons, Enter and Space press them
|
|
3174
|
+
const k = e.key;
|
|
3175
|
+
if ((k === 'Enter' || k === ' ') && e.composedPath().some(x => x.classList && x.classList.contains('um-story'))) return false;
|
|
3176
|
+
const d = { ArrowLeft: -1, PageUp: -1, ArrowRight: 1, PageDown: 1 }[k];
|
|
3177
|
+
if (d) { stepStory(d); return true; }
|
|
3178
|
+
if (k === 'Escape') { stopStory(); return true; }
|
|
3179
|
+
return null;
|
|
3180
|
+
}
|
|
3181
|
+
function play(id, o = {}) {
|
|
3182
|
+
stopStory();
|
|
3183
|
+
const s = (current.stories || []).find(x => x && x.id === id), steps = s && Array.isArray(s.steps) ? s.steps.filter(x => x && typeof x === 'object') : [];
|
|
3184
|
+
if (dead || !geo || !steps.length) return null;
|
|
3185
|
+
let end; const st = { s, steps, at: -1, seq: 0, timer: 0, auto: o.auto !== false, toured: Promise.resolve(true), done: new Promise(r => { end = r; }) };
|
|
3186
|
+
st.end = end;
|
|
3187
|
+
st.ctl = { get story() { return s.id; }, get step() { return st.at; }, get steps() { return steps.length; }, get playing() { return st.auto && story === st; },
|
|
3188
|
+
next() { if (story === st) stepStory(1); }, prev() { if (story === st) stepStory(-1); }, stop() { if (story === st) stopStory(); },
|
|
3189
|
+
pause() { if (story !== st) return; st.auto = false; clearTimeout(st.timer); storyBar(st); },
|
|
3190
|
+
resume() { if (story !== st || st.auto) return; st.auto = true; storyBar(st); arm(st); }, done: st.done };
|
|
3191
|
+
story = st;
|
|
3192
|
+
showStep(st, Math.max(0, Math.min(steps.length - 1, Math.floor(+o.step) || 0)));
|
|
3193
|
+
return st.ctl;
|
|
3194
|
+
}
|
|
3195
|
+
|
|
3196
|
+
const inner = { ctx: () => ctx, geo: () => geo, cam: () => cam, svg: () => svg, stage, host, go, open, up, flash, setFlow, pause, key, startView, standalone, play,
|
|
3197
|
+
select, busy: () => busy, flowing: () => stage.classList.contains('flowing'), view: () => cur, unfold: gesture };
|
|
2450
3198
|
const first = () => {
|
|
2451
3199
|
if (dead) return;
|
|
2452
3200
|
const v0 = ctx.V[options.view] ? options.view : ctx.rootId;
|
|
@@ -2467,11 +3215,19 @@ export function render(container, model, options = {}) {
|
|
|
2467
3215
|
toSVG: (o = {}) => dead ? '' : standalone(o.scale ?? 1, o.view ?? cur, o.fontCSS),
|
|
2468
3216
|
toPNG: (o = {}) => dead ? Promise.resolve(null) : pngOf(standalone(o.scale ?? 2, o.view ?? cur, o.fontCSS)),
|
|
2469
3217
|
focus: ref => { if (!dead) focusMap(ref); },
|
|
3218
|
+
expand: id => setUnfold(id, true), // unfold a stacked card of the view shown in place (#71): true when it did
|
|
3219
|
+
collapse: id => setUnfold(id, false),
|
|
3220
|
+
get expanded() { return [...(expanded[cur] || [])]; },
|
|
2470
3221
|
on(type, fn) { (subs[type] ||= []).push(fn); if (type === 'click') tabKeys();
|
|
2471
3222
|
return () => { subs[type] = (subs[type] || []).filter(f => f !== fn); if (type === 'click' && !dead) tabKeys(); }; },
|
|
2472
3223
|
get view() { return cur; },
|
|
2473
3224
|
get selection() { return sel; },
|
|
2474
3225
|
get selected() { return picked(); },
|
|
3226
|
+
overlay: (name, layer) => overlay(name, layer),
|
|
3227
|
+
highlight: refs => highlight(refs),
|
|
3228
|
+
tour: (path, o) => tour(path, o),
|
|
3229
|
+
replay: (spans, o) => replay(spans, o),
|
|
3230
|
+
play: (id, o) => play(id, o),
|
|
2475
3231
|
destroy,
|
|
2476
3232
|
[INNER]: inner,
|
|
2477
3233
|
};
|
|
@@ -2501,6 +3257,74 @@ export function history(model, limit = 100) {
|
|
|
2501
3257
|
};
|
|
2502
3258
|
}
|
|
2503
3259
|
|
|
3260
|
+
/* ---------- paths(), reach(), describe() and otlpSpans(): a view's lines walked and put in words, with no DOM (#62, #64, #65) ----------
|
|
3261
|
+
A line runs from → to as the model gives it. Each result is a list of Refs a host can hand to highlight(); a line's Ref
|
|
3262
|
+
names one model edge (a drawn line that stands for several takes the strongest mark among them). */
|
|
3263
|
+
function lineGraph(model, viewId) {
|
|
3264
|
+
const ctx = mapContext(model), v = ctx.V[viewId];
|
|
3265
|
+
if (!v) throw new Error(`umlimator: no view ${JSON.stringify(viewId)}`);
|
|
3266
|
+
return { ctx, v, ids: (v.nodeIds || []).filter(i => ctx.N[i]), edges: ctx.viewEdges(v).filter(e => e.from !== e.to) };
|
|
3267
|
+
}
|
|
3268
|
+
const walkFrom = (edges, start, back) => { const seen = new Set([start]); // breadth first along lines (back: against them)
|
|
3269
|
+
for (let q = [start]; q.length;) { const u = q.shift(); edges.forEach(e => { const [a, b] = back ? [e.to, e.from] : [e.from, e.to]; if (a === u && !seen.has(b)) { seen.add(b); q.push(b); } }); }
|
|
3270
|
+
return seen; };
|
|
3271
|
+
|
|
3272
|
+
/** Every card and line on a path from one card to another, following lines forward: the cards both reachable from `from`
|
|
3273
|
+
* and leading to `to`, and the lines between them. [] when no path joins them. */
|
|
3274
|
+
export function paths(model, viewId, from, to) {
|
|
3275
|
+
const { ids, edges } = lineGraph(model, viewId);
|
|
3276
|
+
if (!ids.includes(from) || !ids.includes(to)) return [];
|
|
3277
|
+
const F = walkFrom(edges, from), B = walkFrom(edges, to, true), on = id => F.has(id) && B.has(id);
|
|
3278
|
+
if (!F.has(to)) return [];
|
|
3279
|
+
return [...ids.filter(on).map(id => ({ type: 'node', id, view: viewId })), ...edges.filter(e => on(e.from) && on(e.to)).map(e => ({ type: 'edge', ids: [e._id], view: viewId }))];
|
|
3280
|
+
}
|
|
3281
|
+
|
|
3282
|
+
/** Everything a card's lines lead to (forward), or everything that leads to it (`backward`: for calls, who depends on it),
|
|
3283
|
+
* each with its distance in lines; `depth` stops the walk that many lines out. The card itself is first, at 0. */
|
|
3284
|
+
export function reach(model, viewId, from, options = {}) {
|
|
3285
|
+
const { ids, edges } = lineGraph(model, viewId), back = !!options.backward, depth = options.depth ?? Infinity;
|
|
3286
|
+
if (!ids.includes(from)) return [];
|
|
3287
|
+
const ends = e => back ? [e.to, e.from] : [e.from, e.to], dist = new Map([[from, 0]]);
|
|
3288
|
+
for (let q = [from]; q.length;) { const u = q.shift(), d = dist.get(u); if (d >= depth) continue;
|
|
3289
|
+
edges.forEach(e => { const [a, b] = ends(e); if (a === u && !dist.has(b)) { dist.set(b, d + 1); q.push(b); } }); }
|
|
3290
|
+
const near = (x, y) => x.distance - y.distance; // a stable sort: the view's order among equals
|
|
3291
|
+
return [...ids.filter(id => dist.has(id)).map(id => ({ type: 'node', id, view: viewId, distance: dist.get(id) })).sort(near),
|
|
3292
|
+
...edges.filter(e => { const [a, b] = ends(e); return dist.has(a) && dist.has(b) && dist.get(a) < depth; })
|
|
3293
|
+
.map(e => ({ type: 'edge', ids: [e._id], view: viewId, distance: dist.get(ends(e)[0]) + 1 })).sort(near)];
|
|
3294
|
+
}
|
|
3295
|
+
|
|
3296
|
+
/** A view in plain sentences: its cards and their kinds, its boxes, what each card's lines do (in run order on a sequence
|
|
3297
|
+
* view), and the cards with no line. The same model gives the same words: a text alternative, docs, or an AI's context. */
|
|
3298
|
+
export function describe(model, viewId) {
|
|
3299
|
+
const { ctx, v, ids, edges } = lineGraph(model, viewId), boxes = (v.containers || []).filter(c => c && c.id);
|
|
3300
|
+
const name = id => ctx.N[id] ? ctx.nodeIn(id, viewId).label || id : (boxes.find(c => c.id === id) || {}).label || id;
|
|
3301
|
+
const list = xs => xs.length < 3 ? xs.join(' and ') : xs.slice(0, -1).join(', ') + ' and ' + xs[xs.length - 1];
|
|
3302
|
+
const count = (n, w) => `${n} ${w}${n === 1 ? '' : 's'}`;
|
|
3303
|
+
// a line's kind as its colour says it: the one it names, else a hook's line guards and a board's is state (no guesses from words)
|
|
3304
|
+
const kind = e => WIRE_KINDS.includes(e.kind) ? e.kind : (ctx.N[e.from] || {}).kind === 'hook' ? 'guard' : [e.from, e.to].some(i => (ctx.N[i] || {}).kind === 'board') ? 'state' : 'calls';
|
|
3305
|
+
const VERB = { calls: 'calls', feeds: 'feeds', triggers: 'triggers', state: 'reads or writes', guard: 'guards' };
|
|
3306
|
+
const what = e => { const t = [e.label && `"${e.label}"`, e.sublabel].filter(Boolean); return t.length ? ` (${t.join(', ')})` : ''; };
|
|
3307
|
+
const out = [`${v.title || v.id}: ${count(ids.length, 'card')} and ${count(edges.length, 'line')}.`];
|
|
3308
|
+
if (ids.length) out.push(`Cards: ${ids.map(id => `${name(id)} (${KIND[kindOf(ctx.nodeIn(id, viewId).kind, true)][1]})`).join(', ')}.`);
|
|
3309
|
+
boxes.forEach(c => { const ins = (c.nodeIds || []).filter(i => ids.includes(i)); if (ins.length) out.push(`${name(c.id)} holds ${list(ins.map(name))}.`); });
|
|
3310
|
+
if (v.render === 'sequence') edges.forEach((e, i) => out.push(`Step ${i + 1}: ${name(e.from)} ${VERB[kind(e)]} ${name(e.to)}${what(e)}.`));
|
|
3311
|
+
else [...ids, ...boxes.map(c => c.id)].forEach(id => { const outs = edges.filter(e => e.from === id); if (!outs.length) return;
|
|
3312
|
+
out.push(`${name(id)} ${[...new Set(outs.map(kind))].map(k => `${VERB[k]} ${list(outs.filter(e => kind(e) === k).map(e => name(e.to) + what(e)))}`).join('; ')}.`); });
|
|
3313
|
+
const alone = ids.filter(id => !edges.some(e => e.from === id || e.to === id));
|
|
3314
|
+
if (alone.length) out.push(`Not connected here: ${list(alone.map(name))}.`);
|
|
3315
|
+
return out.join('\n');
|
|
3316
|
+
}
|
|
3317
|
+
|
|
3318
|
+
/** The spans of an OpenTelemetry trace exported as OTLP JSON, as replay() takes them: { id, parent, service, name, start,
|
|
3319
|
+
* end }, times in milliseconds. */
|
|
3320
|
+
export function otlpSpans(data) {
|
|
3321
|
+
const out = [], attr = (list, k) => { const v = ((list || []).find(a => a && a.key === k) || {}).value || {}; return v.stringValue ?? v.intValue ?? null; };
|
|
3322
|
+
((data && data.resourceSpans) || []).forEach(rs => { const service = attr(rs.resource && rs.resource.attributes, 'service.name');
|
|
3323
|
+
(rs.scopeSpans || rs.instrumentationLibrarySpans || []).forEach(ss => (ss.spans || []).forEach(s => out.push({ id: s.spanId, parent: s.parentSpanId || null,
|
|
3324
|
+
service, name: s.name, start: Number(s.startTimeUnixNano) / 1e6, end: Number(s.endTimeUnixNano) / 1e6 }))); });
|
|
3325
|
+
return out;
|
|
3326
|
+
}
|
|
3327
|
+
|
|
2504
3328
|
export function mountPage(container, model, options = {}) {
|
|
2505
3329
|
const Q = options.params || new URLSearchParams();
|
|
2506
3330
|
if (options.instant) INSTANT = true;
|
|
@@ -2537,9 +3361,11 @@ export function mountPage(container, model, options = {}) {
|
|
|
2537
3361
|
<span class="mbrand"><i></i><span>${esc(D.title || 'Map')}</span></span>
|
|
2538
3362
|
<nav class="crumbs" id="m-crumbs" aria-label="Breadcrumb"></nav>
|
|
2539
3363
|
<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>
|
|
2540
|
-
<button class="ibtn" id="m-theme" title="Light / dark" aria-label="Toggle theme">◐</button
|
|
3364
|
+
<button class="ibtn" id="m-theme" title="Light / dark" aria-label="Toggle theme">◐</button>${(D.stories || []).length ? `
|
|
3365
|
+
<div class="menu"><button class="ibtn txt" id="m-stories" aria-haspopup="true" aria-expanded="false" title="Stories: walk-throughs of this map">▶ Stories</button>
|
|
3366
|
+
<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>` : ''}
|
|
2541
3367
|
<div class="menu"><button class="pbtn" id="m-export" aria-haspopup="true" aria-expanded="false">Export ▾</button>
|
|
2542
|
-
<div class="menu-pop" id="m-export-pop" hidden><button data-x="svg">SVG · this view</button><button data-x="png">PNG · this view (2×)</button><button data-x="link">Copy link to view</button><hr><button data-x="json">Map model (JSON)</button></div></div>
|
|
3368
|
+
<div class="menu-pop" id="m-export-pop" hidden><button data-x="svg">SVG · this view</button><button data-x="png">PNG · this view (2×)</button><button data-x="link">Copy link to view</button><hr><button data-x="json">Map model (JSON)</button><button data-x="record">Record a story…</button></div></div>
|
|
2543
3369
|
<button class="ibtn" id="m-pause" type="button" title="Pause" aria-label="Pause" aria-pressed="false">❚❚</button>
|
|
2544
3370
|
<button class="ibtn" id="m-help" title="Keys (?)" aria-label="Keyboard shortcuts" aria-haspopup="true" aria-expanded="false">?</button>
|
|
2545
3371
|
</header>
|
|
@@ -2555,8 +3381,10 @@ export function mountPage(container, model, options = {}) {
|
|
|
2555
3381
|
<aside class="panel" id="m-panel" aria-label="Node details" hidden></aside>
|
|
2556
3382
|
</div>
|
|
2557
3383
|
<div class="hpop" id="m-helpbox" role="dialog" aria-label="Keys" hidden><b>Keys</b>
|
|
2558
|
-
<dl><dt>/ or ${modK}</dt><dd>search nodes and views</dd><dt>Scroll · pinch</dt><dd>zoom (two-finger drag pans)</dd><dt>Click</dt><dd>details below the map</dd><dt>Double-click · Enter</dt><dd>open a stacked node ⤢</dd>
|
|
2559
|
-
<dt>Esc · Backspace</dt><dd>close, then up one level</dd><dt>+ − 0 1</dt><dd>zoom in, out, fit, 100%</dd><dt>← ↑ → ↓</dt><dd>pan</dd><dt>[ ]</dt><dd>previous / next details tab</dd><dt>i</dt><dd>about this view</dd><dt>O</dt><dd>views list</dd><dt>F</dt><dd>flow on/off</dd
|
|
3384
|
+
<dl><dt>/ or ${modK}</dt><dd>search nodes and views</dd><dt>Scroll · pinch</dt><dd>zoom (two-finger drag pans)</dd><dt>Click</dt><dd>details below the map</dd><dt>Double-click · Enter</dt><dd>open a stacked node ⤢</dd><dt>E · Alt + click</dt><dd>unfold a stacked node here, or fold it back ⤡</dd>
|
|
3385
|
+
<dt>Esc · Backspace</dt><dd>close, then up one level</dd><dt>+ − 0 1</dt><dd>zoom in, out, fit, 100%</dd><dt>← ↑ → ↓</dt><dd>pan</dd><dt>[ ]</dt><dd>previous / next details tab</dd><dt>i</dt><dd>about this view</dd><dt>O</dt><dd>views list</dd><dt>F</dt><dd>flow on/off</dd>
|
|
3386
|
+
<dt>> <</dt><dd>light what a card leads to, what leads to it</dd><dt>Shift-click · P</dt><dd>light every path between two cards</dd>
|
|
3387
|
+
<dt>← → · PgUp PgDn</dt><dd>in a story: previous / next step (Esc closes it)</dd></dl></div>
|
|
2560
3388
|
<div class="scrim" id="m-pal" hidden><div class="pal" role="dialog" aria-label="Search">
|
|
2561
3389
|
<div class="in"><span aria-hidden="true">⌕</span><input id="m-q" type="text" autocomplete="off" spellcheck="false" placeholder="Search nodes and views" aria-label="Search nodes and views" aria-controls="m-res"><span class="kbd">esc</span></div>
|
|
2562
3390
|
<div class="res" id="m-res" role="listbox"></div>
|
|
@@ -2681,7 +3509,7 @@ export function mountPage(container, model, options = {}) {
|
|
|
2681
3509
|
return I && I.src ? `<span class="big logo${I.dark ? ' dark' : ''}" aria-hidden="true"><img src="${I.src}" width="22" height="22" alt=""></span>`
|
|
2682
3510
|
: `<span class="big" style="background:${c}" aria-hidden="true"><svg viewBox="0 0 ${I && I.g ? 24 : 16} ${I && I.g ? 24 : 16}" width="20" height="20" fill="none" stroke="currentColor" stroke-width="${I && I.g ? 2 : 1.5}" stroke-linecap="round" stroke-linejoin="round">${I && I.g ? I.g : SIGIL[k]}</svg></span>`; })()}
|
|
2683
3511
|
<div class="pt"><h3>${esc(n.label || nid)}</h3><small><span>${esc(KIND[k][1])}</span><span class="vis ${pub ? 'pub' : ext ? 'ext' : ''}">${badgesOf(n).length ? badgesOf(n).map(b => esc(b.text.toUpperCase())).join(' · ') : pub ? 'PUBLIC' : ext ? 'EXTERNAL' : 'INTERNAL'}</span>${by}${n.origin ? `<span class="origin">${esc(n.origin)}</span>` : ''}</small></div>
|
|
2684
|
-
<div class="acts">${canOpen ? `<button class="obtn" data-open="${esc(child)}" style="--c:${c}">⤢ Open ${esc(shortT(V[child].title || child))} <span>${(V[child].nodeIds || []).length} nodes · Enter</span></button>` : ''}<button class="ibtn x" data-close title="Close (Esc)" aria-label="Close details">×</button></div>
|
|
3512
|
+
<div class="acts">${canOpen ? `<button class="obtn" data-open="${esc(child)}" style="--c:${c}">⤢ Open ${esc(shortT(V[child].title || child))} <span>${(V[child].nodeIds || []).length} nodes · Enter</span></button>` : ''}${X && X.ctx().unfoldable(nid, cur) ? `<button class="obtn" data-unfold="${esc(nid)}" style="--c:${c}">⤡ Unfold here <span>E</span></button>` : ''}${litButtons()}<button class="ibtn x" data-close title="Close (Esc)" aria-label="Close details">×</button></div>
|
|
2685
3513
|
</div>
|
|
2686
3514
|
<div class="ptabs" role="tablist" aria-label="Details">${tabs.map(t => `<button class="ptab" role="tab" id="pt-${t[0]}" data-tab="${t[0]}" aria-selected="${t[0] === tab}" aria-controls="m-pbody">${t[1]}${t[2] != null ? `<em>${t[2]}</em>` : ''}</button>`).join('')}
|
|
2687
3515
|
<span class="pnav"><button class="ibtn sm" data-step="-1" title="Previous ([)" aria-label="Previous tab">‹</button><button class="ibtn sm" data-step="1" title="Next (])" aria-label="Next tab">›</button></span></div>
|
|
@@ -2702,9 +3530,35 @@ export function mountPage(container, model, options = {}) {
|
|
|
2702
3530
|
const closeExample = () => { const lb = $('m-lightbox'); if (lb) lb.hidden = true; };
|
|
2703
3531
|
const setTab = t => { tab = t; try { localStorage.setItem('visual-tab', t); } catch (_) {} if (sel) renderPanel(sel); };
|
|
2704
3532
|
|
|
3533
|
+
/* paths and reach (#64): lit on the map from the details panel or by key, and said in the hint line (a live region); a
|
|
3534
|
+
new selection or view clears them, and the same key again does */
|
|
3535
|
+
let lit = null, picked = [];
|
|
3536
|
+
const cardName = id => nodeIn(id).label || id, nCards = n => `${n} card${n === 1 ? '' : 's'}`;
|
|
3537
|
+
const litButtons = () => '<button class="ibtn" data-lit="out" title="Light what it leads to (>)" aria-label="Light what it leads to">→</button><button class="ibtn" data-lit="in" title="Light what leads to it (<)" aria-label="Light what leads to it">←</button>'
|
|
3538
|
+
+ (picked.length > 1 ? '<button class="ibtn" data-lit="paths" title="Light every path between the two cards (P)" aria-label="Light every path between the two selected cards">⇄</button>' : '');
|
|
3539
|
+
function unlight() { if (lit) { lit = null; handle.highlight(null); } }
|
|
3540
|
+
function light(key, refs, said) {
|
|
3541
|
+
if (lit === key) { unlight(); return status('Highlight cleared'); }
|
|
3542
|
+
lit = key; handle.highlight(refs.length ? refs : null); status(said, 3600);
|
|
3543
|
+
}
|
|
3544
|
+
function lightReach(back) {
|
|
3545
|
+
if (!sel || !geo.byId[sel]) return;
|
|
3546
|
+
const refs = reach(D, cur, sel, { backward: back }), n = refs.filter(r => r.type === 'node').length - 1, s = cardName(sel);
|
|
3547
|
+
light((back ? 'in ' : 'out ') + sel, n ? refs : [], !n ? (back ? `Nothing leads to ${s} here` : `${s} leads nowhere here`)
|
|
3548
|
+
: back ? `${nCards(n)} ${n === 1 ? 'leads' : 'lead'} to ${s}: lit on the map` : `${s} leads to ${nCards(n)}: lit on the map`);
|
|
3549
|
+
}
|
|
3550
|
+
function lightPaths() {
|
|
3551
|
+
const [a, b] = picked.slice(-2); if (!b) return;
|
|
3552
|
+
let refs = paths(D, cur, a, b), [x, y] = [a, b];
|
|
3553
|
+
if (!refs.length) { refs = paths(D, cur, b, a); [x, y] = [b, a]; } // the two cards in either order
|
|
3554
|
+
const n = refs.filter(r => r.type === 'node').length;
|
|
3555
|
+
light(`paths ${a} ${b}`, refs, n ? `Every path from ${cardName(x)} to ${cardName(y)}: ${nCards(n)}, lit on the map` : `No path joins ${cardName(a)} and ${cardName(b)} here`);
|
|
3556
|
+
}
|
|
3557
|
+
|
|
2705
3558
|
/* navigation */
|
|
2706
3559
|
function setHash(replace) {
|
|
2707
3560
|
const p = new URLSearchParams(); p.set('view', cur); if (sel) p.set('node', sel);
|
|
3561
|
+
if (told) { p.set('story', told.id); p.set('step', told.step + 1); } // the story shown, at its step (#69)
|
|
2708
3562
|
['theme', 'check', 'shot'].forEach(k => Q.get(k) && p.set(k, Q.get(k)));
|
|
2709
3563
|
const h = '#' + p.toString();
|
|
2710
3564
|
if (location.hash === h) return;
|
|
@@ -2733,12 +3587,14 @@ export function mountPage(container, model, options = {}) {
|
|
|
2733
3587
|
document.addEventListener('click', e => {
|
|
2734
3588
|
const t = e.target;
|
|
2735
3589
|
if (t.closest('[data-close]')) return deselect();
|
|
3590
|
+
const lb = t.closest('#m-panel [data-lit]'); if (lb) return lb.dataset.lit === 'paths' ? lightPaths() : lightReach(lb.dataset.lit === 'in');
|
|
2736
3591
|
const ob = t.closest('[data-open]'); if (ob) return go(ob.dataset.open, { via: sel });
|
|
3592
|
+
const ub = t.closest('#m-panel [data-unfold]'); if (ub) return X.unfold(ub.dataset.unfold); // in place (#71)
|
|
2737
3593
|
const pv = t.closest('.pview'); if (pv) return go(pv.dataset.v, { select: pv.dataset.sel });
|
|
2738
3594
|
const rn = t.closest('#m-panel [data-n]'); if (rn) return findNode(rn.dataset.n);
|
|
2739
3595
|
const cv = t.closest('#m-crumbs [data-v], #m-tree [data-v]'); if (cv) { if (innerWidth <= 820) setTree(false); return go(cv.dataset.v); }
|
|
2740
3596
|
const cp = t.closest('[data-copy]'); if (cp) { navigator.clipboard && navigator.clipboard.writeText(cp.dataset.copy).then(() => status('Path copied'), () => status(cp.dataset.copy)); return; }
|
|
2741
|
-
|
|
3597
|
+
const mn = t.closest('.menu'); if (!mn || !mn.contains($('m-export'))) closeExport(); if (!mn || !mn.contains($('m-stories'))) closeStories();
|
|
2742
3598
|
if (!t.closest('#m-helpbox, #m-help')) closeHelp();
|
|
2743
3599
|
});
|
|
2744
3600
|
$('m-outline').addEventListener('click', () => setTree(!document.body.classList.contains('tree-open')));
|
|
@@ -2749,6 +3605,51 @@ export function mountPage(container, model, options = {}) {
|
|
|
2749
3605
|
$('m-export').addEventListener('click', () => { const on = $('m-export-pop').hidden; $('m-export-pop').hidden = !on; $('m-export').setAttribute('aria-expanded', on); });
|
|
2750
3606
|
$('m-export-pop').addEventListener('click', e => { const x = e.target.closest('[data-x]')?.dataset.x; if (!x) return; closeExport(); exportView(x); });
|
|
2751
3607
|
|
|
3608
|
+
/* stories (#69): the Stories menu plays one; the link names the story and step shown; Export ▸ Record a story… */
|
|
3609
|
+
let told = null; // { id, step }: the story on the map and its step
|
|
3610
|
+
const toldSel = id => { const s = told && (D.stories || []).find(x => x && x.id === told.id), st = s && (s.steps || [])[told.step]; return !!(st && st.select === id); }; // the step's own selection: a pointer, no details panel
|
|
3611
|
+
const storiesPop = $('m-stories-pop'), closeStories = () => { if (storiesPop) { storiesPop.hidden = true; $('m-stories').setAttribute('aria-expanded', 'false'); } };
|
|
3612
|
+
if (storiesPop) {
|
|
3613
|
+
$('m-stories').addEventListener('click', () => { const on = storiesPop.hidden; storiesPop.hidden = !on; $('m-stories').setAttribute('aria-expanded', on); });
|
|
3614
|
+
storiesPop.addEventListener('click', e => { const b = e.target.closest('[data-story]'); if (!b) return; closeStories(); handle.play(b.dataset.story); });
|
|
3615
|
+
}
|
|
3616
|
+
// a story recorded from the page: each view and card the reader comes to is a step (Add step: as the camera is now too);
|
|
3617
|
+
// captions are typed in, and Save writes the story's JSON, for the model's `stories`
|
|
3618
|
+
let rec = null;
|
|
3619
|
+
function record() {
|
|
3620
|
+
if (rec) return;
|
|
3621
|
+
stage.insertAdjacentHTML('beforeend', `<div class="m-rec" id="m-rec" role="region" aria-label="Recording a story">
|
|
3622
|
+
<div class="t"><b>● Recording a story</b><span id="m-rec-n"></span></div>
|
|
3623
|
+
<input id="m-rec-title" type="text" aria-label="Story title" placeholder="Story title" value="${esc((D.title || 'Map') + ': a walk-through')}">
|
|
3624
|
+
<ol id="m-rec-list"></ol>
|
|
3625
|
+
<div class="b"><button type="button" data-r="add">Add step</button><button type="button" data-r="save">Save JSON</button><button type="button" data-r="close">Close</button></div></div>`);
|
|
3626
|
+
const box = $('m-rec'), same = (a, b) => JSON.stringify([a.view, a.select, a.camera]) === JSON.stringify([b.view, b.select, b.camera]);
|
|
3627
|
+
rec = { steps: [], t: 0 };
|
|
3628
|
+
const draw = () => { $('m-rec-n').textContent = rec.steps.length === 1 ? '1 step' : rec.steps.length + ' steps';
|
|
3629
|
+
$('m-rec-list').innerHTML = rec.steps.map((s, i) => `<li><span>${esc(shortT((V[s.view] || {}).title || s.view))}${s.select ? ' · ' + esc(nodeIn(s.select, s.view).label || s.select) : ''}${s.camera ? ' · zoomed' : ''}</span>
|
|
3630
|
+
<input type="text" data-i="${i}" aria-label="Caption of step ${i + 1}" placeholder="Caption" value="${esc(s.caption)}"><button type="button" data-del="${i}" aria-label="Remove step ${i + 1}">✕</button></li>`).join(''); };
|
|
3631
|
+
rec.take = force => { // the map as it is: its view, selected card and, once moved by hand, its camera
|
|
3632
|
+
const c = X.cam(), s = { view: cur, caption: '' }, last = rec.steps[rec.steps.length - 1];
|
|
3633
|
+
if (sel) s.select = sel;
|
|
3634
|
+
if (c.touched) s.camera = { x: Math.round(c.s.cx), y: Math.round(c.s.cy), zoom: +c.s.k.toFixed(2) };
|
|
3635
|
+
if (force || !last || !same(last, s)) { rec.steps.push(s); draw(); }
|
|
3636
|
+
};
|
|
3637
|
+
box.addEventListener('input', e => { const i = e.target.dataset.i; if (i != null) rec.steps[+i].caption = e.target.value; });
|
|
3638
|
+
box.addEventListener('click', e => {
|
|
3639
|
+
const r = e.target.closest('[data-r]')?.dataset.r, del = e.target.closest('[data-del]');
|
|
3640
|
+
if (del) { rec.steps.splice(+del.dataset.del, 1); draw(); }
|
|
3641
|
+
else if (r === 'add') rec.take(true);
|
|
3642
|
+
else if (r === 'save') {
|
|
3643
|
+
const title = $('m-rec-title').value.trim() || 'A walk-through', id = title.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '') || 'story';
|
|
3644
|
+
const steps = rec.steps.map(({ caption, ...s }) => caption ? { view: s.view, caption, ...s } : s);
|
|
3645
|
+
download(id + '.story.json', new Blob([JSON.stringify({ id, title, steps }, null, 2) + '\n'], { type: 'application/json' }));
|
|
3646
|
+
status('Saved: add it to the model\'s "stories"', 4000);
|
|
3647
|
+
} else if (r === 'close') { clearTimeout(rec.t); rec = null; box.remove(); }
|
|
3648
|
+
});
|
|
3649
|
+
rec.take();
|
|
3650
|
+
}
|
|
3651
|
+
const recordSoon = () => { if (rec) { clearTimeout(rec.t); rec.t = setTimeout(() => rec && rec.take(), 650); } }; // once the camera has landed
|
|
3652
|
+
|
|
2752
3653
|
/* ⌘K command box: nodes and views, Enter jumps */
|
|
2753
3654
|
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() }));
|
|
2754
3655
|
const viewIdx = views.map(v => ({ v, hay: [v.title, v.id, v.caption].join(' ').toLowerCase() }));
|
|
@@ -2805,7 +3706,7 @@ export function mountPage(container, model, options = {}) {
|
|
|
2805
3706
|
if (!X || (e.target.matches && e.target.matches('input, textarea')) || e.metaKey || e.ctrlKey || e.altKey) return;
|
|
2806
3707
|
const k = e.key, inCanvas = e.composedPath().includes(X.stage);
|
|
2807
3708
|
if (k === 'Escape' || k === 'Backspace') {
|
|
2808
|
-
if ($('m-lightbox') && !$('m-lightbox').hidden) closeExample(); else if (!$('m-helpbox').hidden) closeHelp(); else if (!$('m-export-pop').hidden) closeExport(); else if ($('m-vpop') && !$('m-vpop').hidden) togglePill(false);
|
|
3709
|
+
if ($('m-lightbox') && !$('m-lightbox').hidden) closeExample(); else if (!$('m-helpbox').hidden) closeHelp(); else if (!$('m-export-pop').hidden) closeExport(); else if (storiesPop && !storiesPop.hidden) closeStories(); else if ($('m-vpop') && !$('m-vpop').hidden) togglePill(false);
|
|
2809
3710
|
else if (!inCanvas) X.key(e); else return;
|
|
2810
3711
|
}
|
|
2811
3712
|
else if (k === '/') openPal();
|
|
@@ -2813,6 +3714,8 @@ export function mountPage(container, model, options = {}) {
|
|
|
2813
3714
|
else if (k === 'i' || k === 'I') togglePill();
|
|
2814
3715
|
else if ((k === '[' || k === ']') && sel) setTab(TABS[(TABS.indexOf(tab) + (k === ']' ? 1 : -1) + TABS.length) % TABS.length]);
|
|
2815
3716
|
else if (k === 'o' || k === 'O') setTree(!document.body.classList.contains('tree-open'));
|
|
3717
|
+
else if ((k === '>' || k === '<') && sel) lightReach(k === '<');
|
|
3718
|
+
else if ((k === 'p' || k === 'P') && picked.length > 1) lightPaths();
|
|
2816
3719
|
else if (inCanvas || !X.key(e)) return;
|
|
2817
3720
|
e.preventDefault(); e.stopPropagation();
|
|
2818
3721
|
}, true);
|
|
@@ -2827,6 +3730,7 @@ export function mountPage(container, model, options = {}) {
|
|
|
2827
3730
|
setTimeout(tick, 300); }
|
|
2828
3731
|
function download(name, blob) { const a = document.createElement('a'); a.href = URL.createObjectURL(blob); a.download = name; document.body.appendChild(a); a.click(); setTimeout(() => { URL.revokeObjectURL(a.href); a.remove(); }, 500); }
|
|
2829
3732
|
function exportView(x) {
|
|
3733
|
+
if (x === 'record') return record();
|
|
2830
3734
|
const stem = (D.slug || D.title || 'map').toLowerCase().replace(/[^a-z0-9]+/g, '-') + '-' + cur.replace(/[^a-z0-9]+/gi, '-');
|
|
2831
3735
|
if (x === 'svg') download(stem + '.svg', new Blob([standalone(1)], { type: 'image/svg+xml' }));
|
|
2832
3736
|
else if (x === 'png') {
|
|
@@ -2842,6 +3746,7 @@ export function mountPage(container, model, options = {}) {
|
|
|
2842
3746
|
const v0 = V[p0.get('view')] ? p0.get('view') : rootId;
|
|
2843
3747
|
const handle = render($('m-canvas'), model, {
|
|
2844
3748
|
view: v0, minimap: true, flow: flowPref ? flowPref === '1' : undefined,
|
|
3749
|
+
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)
|
|
2845
3750
|
_page: {
|
|
2846
3751
|
instant: options.instant, defer: !INSTANT, tour: !Q.get('still'), openHint: !Q.get('reader'), kinds: true, status,
|
|
2847
3752
|
wide: () => innerWidth > 820,
|
|
@@ -2860,11 +3765,12 @@ export function mountPage(container, model, options = {}) {
|
|
|
2860
3765
|
$('m-ghosts').remove();
|
|
2861
3766
|
const n0 = p0.get('node') || (Q.get('shot') && cur !== rootId ? subjectOf(V[cur]) || (V[cur].nodeIds || [])[0] : null);
|
|
2862
3767
|
if (n0 && geo.byId[n0]) select(n0, { reveal: false });
|
|
3768
|
+
if (p0.get('story')) setTimeout(() => X.play(p0.get('story'), { step: (+p0.get('step') || 1) - 1, auto: false })); // a link to a story's step: shown, not played on (once the page listens)
|
|
2863
3769
|
setHash(true);
|
|
2864
3770
|
},
|
|
2865
3771
|
},
|
|
2866
3772
|
});
|
|
2867
|
-
handle.on('select', ({ target }) => { sel = target && target.type === 'node' ? target.id : null; if (sel) renderPanel(sel); else hidePanel(); setHash(true); });
|
|
2868
|
-
handle.on('viewchange', () => { setHash(replaceNext); replaceNext = false; });
|
|
3773
|
+
handle.on('select', ({ target, targets }) => { sel = target && target.type === 'node' ? target.id : null; picked = (targets || []).filter(t => t.type === 'node').map(t => t.id); unlight(); if (sel && !toldSel(sel)) renderPanel(sel); else hidePanel(); setHash(true); recordSoon(); });
|
|
3774
|
+
handle.on('viewchange', () => { unlight(); setHash(replaceNext); replaceNext = false; recordSoon(); });
|
|
2869
3775
|
return handle;
|
|
2870
3776
|
}
|