umlimator 0.13.0 → 0.15.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 +54 -20
- package/lib/umlimator-element.d.ts +4 -2
- package/lib/umlimator-element.js +27 -12
- package/lib/umlimator-markdown-it.js +5 -5
- package/lib/umlimator-theme.css +6 -2
- package/lib/umlimator.d.ts +99 -11
- package/lib/umlimator.js +1281 -428
- package/lib/umlimator.schema.json +73 -0
- package/package.json +1 -1
package/lib/umlimator.js
CHANGED
|
@@ -48,7 +48,9 @@ const STYLES = `
|
|
|
48
48
|
svg .edge { fill: none; stroke-width: calc(1.6px * var(--um-ew, 1)); stroke-linecap: round; stroke-linejoin: round; transition: stroke-width .15s, opacity .2s; }
|
|
49
49
|
svg .halo { fill: none; stroke: var(--um-bg-2); stroke-width: calc(6px + 1.8px * (var(--um-ew, 1) - 1)); }
|
|
50
50
|
svg .hitline { fill: none; stroke: transparent; stroke-width: calc(12px + 1.8px * (var(--um-ew, 1) - 1)); }
|
|
51
|
-
|
|
51
|
+
/* a line's flow: pulses in the canvas colour moving along it, as wide as the line (never a bump on it), and stopping
|
|
52
|
+
where its arrowhead begins (svgOf trims the path), so the arrow keeps its shape */
|
|
53
|
+
svg .flowline { fill: none; stroke: var(--um-bg-2); stroke-width: calc(1.6px * var(--um-ew, 1)); stroke-linecap: round; stroke-dasharray: 10 70; opacity: 0; pointer-events: none; }
|
|
52
54
|
.flowing svg .flowline { opacity: .9; animation: flowdash 2.4s linear infinite; }
|
|
53
55
|
.flowing svg .edge { opacity: .8; }
|
|
54
56
|
@keyframes flowdash { to { stroke-dashoffset: -160; } }
|
|
@@ -70,7 +72,8 @@ const STYLES = `
|
|
|
70
72
|
.flowing svg .edge-g.back .edge { animation: march 1.1s linear infinite; }
|
|
71
73
|
@keyframes march { to { stroke-dashoffset: -22; } }
|
|
72
74
|
@media (prefers-reduced-motion: reduce) { svg .tour, svg .tour-glow { display: none !important; } .flowing svg .node.tour-stop, .flowing svg .edge-g.back .edge { animation: none; } }
|
|
73
|
-
|
|
75
|
+
/* reduced motion: no flow is drawn, so ≋ isn't offered (as the page hides its ❚❚); the stage's class outranks :host .flowing below */
|
|
76
|
+
@media (prefers-reduced-motion: reduce) { :host .stage.flowing svg .flowline { animation: none; opacity: 0; } .flowing svg .edge { opacity: 1; } .ctl [data-z="flow"] { display: none; } }
|
|
74
77
|
.ctl button.on { color: var(--um-cyan); background: var(--um-cyan-f); }
|
|
75
78
|
svg .flow { stroke-dasharray: 6 6; animation: flow 1.2s linear infinite; }
|
|
76
79
|
@keyframes flow { to { stroke-dashoffset: -12; } }
|
|
@@ -112,7 +115,7 @@ const STYLES = `
|
|
|
112
115
|
svg .node.nb .n-body { stroke-width: 2.6; stroke-opacity: 1; }
|
|
113
116
|
svg .n-port { fill: var(--um-panel); stroke-width: 1.5; pointer-events: none; transition: fill .2s, stroke-width .2s; }
|
|
114
117
|
svg .node.sel .n-port, svg .node.nb .n-port.hot { fill: var(--um-accent); stroke: var(--um-accent); stroke-width: 2.2; }
|
|
115
|
-
:host .focusing svg .edge-g.hot .flowline { opacity: .95; stroke-width: calc(
|
|
118
|
+
:host .focusing svg .edge-g.hot .flowline { opacity: .95; stroke-width: calc(2.8px * var(--um-ew, 1)); stroke-dasharray: 8 13; animation: selected-flow 1.1s linear infinite; }
|
|
116
119
|
:host .focusing svg .edge-g.hot .edge { stroke-width: calc(2.8px * var(--um-ew, 1)); }
|
|
117
120
|
@keyframes selected-flow { to { stroke-dashoffset: -42; } }
|
|
118
121
|
@keyframes select-glow { from { filter: drop-shadow(0 0 12px color-mix(in srgb, var(--um-accent) 70%, transparent)); stroke-width: 5; } }
|
|
@@ -125,6 +128,17 @@ const STYLES = `
|
|
|
125
128
|
.panel { animation: none !important; }
|
|
126
129
|
}
|
|
127
130
|
svg .node.flash .n-mask { animation: flash 1.3s ease-out; }
|
|
131
|
+
/* card shapes (0.15.0): their details, and their motion: what arrives lights a shape's accent (a head, a lid, a title bar,
|
|
132
|
+
a fold); a queue's floor moves with the flow. Neither under reduced motion, nor in a picture at rest (an export) */
|
|
133
|
+
svg .n-det { fill: none; stroke-width: 1.2; stroke-linecap: round; stroke-linejoin: round; }
|
|
134
|
+
svg .n-dot { opacity: .75; } svg .n-field, svg .n-screen { fill: var(--um-panel); stroke-opacity: .55; stroke-width: 1; } svg .n-speaker { stroke-width: 1.9; }
|
|
135
|
+
svg .n-prompt { font: 700 9px var(--um-mono); }
|
|
136
|
+
svg .n-floor { fill: none; stroke-width: 1.6; stroke-linecap: round; stroke-dasharray: .1 7; opacity: 0; transition: opacity .2s; }
|
|
137
|
+
.flowing svg .n-floor { opacity: .7; animation: floor 1.07s linear infinite; }
|
|
138
|
+
@keyframes floor { to { stroke-dashoffset: -71; } }
|
|
139
|
+
svg .node.flash .n-accent { animation: accent 1.3s ease-out; }
|
|
140
|
+
@keyframes accent { 0%, 35% { stroke-width: 3.6; } }
|
|
141
|
+
@media (prefers-reduced-motion: reduce) { svg .node.flash .n-accent, .flowing svg .n-floor { animation: none; } .flowing svg .n-floor { opacity: 0; } }
|
|
128
142
|
@keyframes flash { 0%, 40% { stroke: var(--um-amber); stroke-width: 8; } 100% { stroke-width: 0; } }
|
|
129
143
|
|
|
130
144
|
|
|
@@ -174,6 +188,11 @@ const STYLES = `
|
|
|
174
188
|
.empty-view p { margin: 0 0 12px; color: var(--um-muted); font-size: 13px; }
|
|
175
189
|
svg .family-box { fill: var(--um-panel); fill-opacity: .45; stroke-width: 1.5; }
|
|
176
190
|
svg .family-title { font: 650 11px var(--um-mono); }
|
|
191
|
+
/* a box with a tone (--um-tone, set on it): a region tinted in the tone, as deep as --um-tint makes the cards, with its
|
|
192
|
+
border dashed and its title in the tone, darkened (lightened in dark) to keep 4.5:1; an unknown tone is slate */
|
|
193
|
+
svg .family.toned .family-box { fill: color-mix(in srgb, var(--um-tone, var(--um-slate)) min(7% * var(--um-tint, 1), 18%), var(--um-panel)); fill-opacity: 1;
|
|
194
|
+
stroke: color-mix(in srgb, var(--um-tone, var(--um-slate)) 60%, transparent); stroke-dasharray: 7 5; }
|
|
195
|
+
svg .family.toned .family-title { fill: color-mix(in srgb, var(--um-tone, var(--um-slate)) 75%, var(--um-text)); }
|
|
177
196
|
svg .family.sel .family-box { stroke: var(--um-accent); stroke-width: 2; }
|
|
178
197
|
svg .fold-hint { cursor: pointer; } svg .fold-hint:hover rect { stroke-width: 2; }
|
|
179
198
|
svg .edited-dot { fill: var(--um-edited); stroke: var(--um-mask); stroke-width: 1.5; }
|
|
@@ -200,7 +219,7 @@ const STYLES = `
|
|
|
200
219
|
.um-story .um-st { display: block; font: 600 11px var(--um-mono); color: var(--um-muted); margin-bottom: 2px; }
|
|
201
220
|
.um-story p { margin: 0; } .um-story .um-sb { display: flex; gap: 2px; flex: none; }
|
|
202
221
|
.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(
|
|
222
|
+
.um-story button:hover:not([aria-disabled="true"]) { background: var(--um-panel-2); color: var(--um-accent); } .um-story button[aria-disabled="true"] { opacity: .35; cursor: default; }
|
|
204
223
|
@media (max-width: 820px) { .um-story { left: 12px; right: 12px; bottom: 60px; max-width: none; } }
|
|
205
224
|
/* the standalone page's pieces (#99): the views list, the legend, the hint line and the details panel, in the page and,
|
|
206
225
|
when a host asks, in render()'s shadow root, where these first rules give them what the page gives its whole page */
|
|
@@ -232,6 +251,7 @@ const STYLES = `
|
|
|
232
251
|
.m-legend.folded .lgi { display: none; }
|
|
233
252
|
.lgi { display: flex; align-items: center; gap: 7px; white-space: nowrap; }
|
|
234
253
|
.lgi i { width: 14px; height: 10px; border: 1.5px solid var(--um-muted); border-radius: 3px; flex: none; }
|
|
254
|
+
.lgi svg.lgs { width: 21px; height: 14px; flex: none; overflow: visible; } .lgs * { vector-effect: non-scaling-stroke; stroke-width: 1.3; } .lgs .n-det, .lgs .n-screen { stroke-width: 1; }
|
|
235
255
|
.lgi i.p { border-radius: 999px; } .lgi i.d { border-style: dashed; } .lgi i.h { border-radius: 0; clip-path: polygon(3px 0, calc(100% - 3px) 0, 100% 50%, calc(100% - 3px) 100%, 3px 100%, 0 50%); border: 0; }
|
|
236
256
|
.lgi i.s { box-shadow: 2px 2px 0 -.5px var(--um-panel), 2px 2px 0 .5px var(--um-muted); background: var(--um-panel); }
|
|
237
257
|
.lgi i.num { width: 14px; height: 14px; border: 0; border-radius: 50%; background: var(--um-text); color: var(--um-bg); font: 700 8px/14px var(--um-sans); text-align: center; font-style: normal; }
|
|
@@ -252,7 +272,7 @@ const STYLES = `
|
|
|
252
272
|
.ph small { display: flex; flex-wrap: wrap; align-items: center; gap: 4px 8px; color: var(--um-muted); font-size: 12.5px; }
|
|
253
273
|
.ph .acts { display: flex; gap: 8px; align-items: center; flex: none; }
|
|
254
274
|
.vis { font: 700 9px var(--um-sans); letter-spacing: .1em; padding: 1px 6px; border-radius: 4px; border: 1px solid var(--um-border-2); color: var(--um-muted); }
|
|
255
|
-
.vis.pub { background: var(--um-k-public-
|
|
275
|
+
.vis.pub { background: color-mix(in srgb, var(--um-k-public) 8%, var(--um-panel)); border-color: var(--um-k-public); color: var(--um-k-public); } .vis.ext { border-style: dashed; border-color: var(--um-k-ext); color: var(--um-k-ext); }
|
|
256
276
|
.author { display: inline-flex; align-items: center; gap: 6px; padding: 1px 9px 1px 1px; border-radius: 999px; border: 1px solid var(--um-border-2); color: var(--um-text); text-decoration: none; background: var(--um-panel); font-size: 12px; }
|
|
257
277
|
.author:hover { border-color: var(--um-accent); }
|
|
258
278
|
.author img, .author i { width: 22px; height: 22px; border-radius: 50%; display: grid; place-items: center; font: 700 9px var(--um-sans); font-style: normal; background: var(--um-panel-2); }
|
|
@@ -336,9 +356,11 @@ const STYLES = `
|
|
|
336
356
|
.um-lens b { font: 700 10px var(--um-sans); letter-spacing: .1em; text-transform: uppercase; color: var(--um-muted); } .um-lens span { white-space: nowrap; }
|
|
337
357
|
.um-lens i { display: inline-block; width: 8px; height: 8px; border: 2.5px solid; border-radius: 4px; margin-right: 6px; vertical-align: -1px; }
|
|
338
358
|
` +/* 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
|
|
339
|
-
file does, for page chrome) a tint keeps the tone it was mixed from, whatever an ancestor of the map sets later
|
|
359
|
+
file does, for page chrome) a tint keeps the tone it was mixed from, whatever an ancestor of the map sets later. A kind's
|
|
360
|
+
tint (every tone but the accent) is --um-tint times as deep: 1 by default, 0 for none, never past 18%, where a card's
|
|
361
|
+
sub-line would fall below 4.5:1 */
|
|
340
362
|
`: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,
|
|
341
|
-
'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}
|
|
363
|
+
'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}) ${t === 'accent' ? p + '%' : `min(${p}% * var(--um-tint, 1), 18%)`}, var(--um-panel));`).join(' ')} }`;
|
|
342
364
|
|
|
343
365
|
const esc = s => String(s ?? '').replace(/[&<>"']/g, c => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c]));
|
|
344
366
|
const inline = s => esc(s)
|
|
@@ -365,8 +387,8 @@ const KIND = {
|
|
|
365
387
|
'm-step': ['k-public', 'Process step', 'open for implementation details'],
|
|
366
388
|
};
|
|
367
389
|
const MAPK = { /* umlimator */ public: 'm-public', lane: 'm-lane', script: 'm-script', 'skill-dep': 'm-ext', dep: 'm-ext', verb: 'm-verb', step: 'm-step', hook: 'm-hook', guard: 'm-hook', board: 'm-board', state: 'm-board' };
|
|
368
|
-
const ALIAS = { client: 'frontend', ui: 'frontend', web: 'frontend', page: 'frontend', browser: 'frontend', app: 'frontend',
|
|
369
|
-
api: 'backend', service: 'backend', server: 'backend', function: 'backend', endpoint: 'backend', route: 'backend', script: 'backend', cli: 'backend', worker: 'backend', module: 'backend',
|
|
390
|
+
const ALIAS = { client: 'frontend', ui: 'frontend', web: 'frontend', page: 'frontend', browser: 'frontend', app: 'frontend', person: 'frontend', mobile: 'frontend',
|
|
391
|
+
api: 'backend', service: 'backend', server: 'backend', function: 'backend', endpoint: 'backend', route: 'backend', script: 'backend', cli: 'backend', worker: 'backend', module: 'backend', package: 'backend',
|
|
370
392
|
db: 'database', store: 'database', storage: 'database', cache: 'database', table: 'database', data: 'database',
|
|
371
393
|
infra: 'cloud', cdn: 'cloud', deploy: 'cloud', host: 'cloud', ci: 'cloud',
|
|
372
394
|
auth: 'security', secret: 'security', policy: 'security', guard: 'security', hook: 'security',
|
|
@@ -374,7 +396,8 @@ const ALIAS = { client: 'frontend', ui: 'frontend', web: 'frontend', page: 'fron
|
|
|
374
396
|
user: 'external', human: 'external', thirdparty: 'external', vendor: 'external',
|
|
375
397
|
doc: 'file', config: 'file', skill: 'file', md: 'file', template: 'file', output: 'file',
|
|
376
398
|
agent: 'ai', llm: 'ai', model: 'ai' };
|
|
377
|
-
const
|
|
399
|
+
const hasKey = (o, k) => Object.hasOwn(o, k); // a table's own key: "constructor" or "__proto__" is no kind, shape or notation
|
|
400
|
+
const kindOf = (k, map) => { k = String(k || '').toLowerCase(); if (map && hasKey(MAPK, k)) return MAPK[k]; return hasKey(KIND, k) ? k : hasKey(ALIAS, k) ? ALIAS[k] : map ? 'm-script' : 'backend'; };
|
|
378
401
|
const SIGIL = {
|
|
379
402
|
frontend: '<rect x="2" y="3" width="12" height="8.5" rx="1.2"/><path d="M5.5 14h5M8 11.5V14"/>',
|
|
380
403
|
backend: '<rect x="2.5" y="2.5" width="11" height="4.5" rx="1"/><rect x="2.5" y="9" width="11" height="4.5" rx="1"/><path d="M5 4.75h.01M5 11.25h.01"/>',
|
|
@@ -393,11 +416,203 @@ const SIGIL = {
|
|
|
393
416
|
'm-verb': '<path d="M3 13 9.5 6.5M8 3l.7 1.6L10.3 5.3 8.7 6 8 7.6 7.3 6 5.7 5.3 7.3 4.6zM12 8.5l.45 1.05L13.5 10l-1.05.45L12 11.5l-.45-1.05L10.5 10l1.05-.45z"/>',
|
|
394
417
|
'm-step': '<path d="M2 8h11M9 4l4 4-4 4"/>',
|
|
395
418
|
'm-board': '<rect x="2" y="2.5" width="12" height="11" rx="1.5"/><path d="M6 2.5v11M10 2.5v11"/><path d="M3.4 5.2h1.2M7.4 5.2h1.2M7.4 7.6h1.2M11.4 5.2h1.2"/>',
|
|
419
|
+
// a shape's own mark (0.15.0), for a card with no icon whose shape says more than its kind does
|
|
420
|
+
person: '<circle cx="8" cy="5.2" r="2.7"/><path d="M3 13.6c.5-2.7 2.5-4.2 5-4.2s4.5 1.5 5 4.2"/>',
|
|
421
|
+
robot: '<rect x="3" y="5.2" width="10" height="8" rx="1.8"/><path d="M8 5.2V3M6.2 9.2h.01M9.8 9.2h.01M1.6 9.2h1.4M13 9.2h1.4"/>',
|
|
422
|
+
mobile: '<rect x="4.4" y="1.8" width="7.2" height="12.4" rx="1.5"/><path d="M7.3 11.9h1.4"/>',
|
|
423
|
+
terminal: '<rect x="1.8" y="2.6" width="12.4" height="10.8" rx="1.6"/><path d="m4.6 6.2 2 1.8-2 1.8M8.4 10.2h3"/>',
|
|
424
|
+
folder: '<path d="M2 4.3c0-.7.5-1.2 1.2-1.2h3l1.4 1.6h5.2c.7 0 1.2.5 1.2 1.2v6.2c0 .7-.5 1.2-1.2 1.2H3.2C2.5 13.3 2 12.8 2 12.1Z"/>',
|
|
425
|
+
node: '<path d="M2.4 5.6 5 3h8.6v7.4L11 13H2.4Z"/><path d="M2.4 5.6H11V13M11 5.6 13.6 3"/>',
|
|
426
|
+
// UML's component icon (§11.6.4): a box's mark in the uml notation
|
|
427
|
+
component: '<path d="M4.5 4.5V2.3a.8.8 0 0 1 .8-.8h7.9a.8.8 0 0 1 .8.8v11.4a.8.8 0 0 1-.8.8H5.3a.8.8 0 0 1-.8-.8v-2.2M4.5 7v2"/><rect x="2" y="4.5" width="5" height="2.5" rx=".6"/><rect x="2" y="9" width="5" height="2.5" rx=".6"/>',
|
|
396
428
|
};
|
|
429
|
+
/* ================= card shapes (0.15.0) =================
|
|
430
|
+
A notation draws each card as a shape: box (the default: every card a rounded box, as before), c4 (people, browser apps,
|
|
431
|
+
phones, databases, queues and files as the C4 model's tools draw them) or uml (UML 2.5's actor, node, artifact, package
|
|
432
|
+
and data store, and the component icon as a box's mark). A card's own `shape` wins in any notation. Each shape is drawn
|
|
433
|
+
inside its card's own box, so layout, routing, widths and the layout check never see it: an outline (for the mask, the
|
|
434
|
+
body, the stack, rings, the minimap and the glow), a face (the rows its mark and text use), details drawn over the body
|
|
435
|
+
(one of them its accent, which lights as something arrives) and inset(side, t), how far inside the box's side the
|
|
436
|
+
outline runs at t, which slides a line's end from the box onto the outline. */
|
|
437
|
+
const NOTATIONS = ['box', 'c4', 'uml'];
|
|
438
|
+
// what a notation draws: by the kind word the model wrote, then by the kind it means (docs/module-api.md, 0.15.0)
|
|
439
|
+
const NOTED = {
|
|
440
|
+
c4: { person: 'person', user: 'person', human: 'person', mobile: 'mobile', agent: 'robot', cli: 'terminal',
|
|
441
|
+
frontend: 'browser', database: 'cylinder', queue: 'pipe', file: 'document', 'm-hook': 'hexagon' },
|
|
442
|
+
uml: { person: 'person', user: 'person', human: 'person', agent: 'robot', module: 'folder', package: 'folder',
|
|
443
|
+
cloud: 'node', database: 'cylinder', file: 'document', 'm-hook': 'hexagon' },
|
|
444
|
+
};
|
|
445
|
+
const r2 = v => Math.round(v * 100) / 100;
|
|
446
|
+
function rrect(x, y, w, h, r) { // a rounded rect's path; r: one radius, or [top left, top right, bottom right, bottom left]
|
|
447
|
+
const [a, b, c, d] = Array.isArray(r) ? r : [r, r, r, r], A = (q, X, Y) => q ? `A${r2(q)} ${r2(q)} 0 0 1 ${r2(X)} ${r2(Y)}` : '';
|
|
448
|
+
return `M${r2(x + a)} ${r2(y)}H${r2(x + w - b)}${A(b, x + w, y + b)}V${r2(y + h - c)}${A(c, x + w - c, y + h)}H${r2(x + d)}${A(d, x, y + h - d)}V${r2(y + a)}${A(a, x + a, y)}Z`;
|
|
449
|
+
}
|
|
450
|
+
const cornerIn = (r, t, a, b) => t < a + r ? r - Math.sqrt(Math.max(0, r * r - (a + r - t) ** 2)) : t > b - r ? r - Math.sqrt(Math.max(0, r * r - (t - b + r) ** 2)) : 0;
|
|
451
|
+
const rectIn = (n, s, t, r) => s === 'L' || s === 'R' ? cornerIn(r, t, n.y, n.y + n.h) : cornerIn(r, t, n.x, n.x + n.w);
|
|
452
|
+
const ellIn = (e, u, r) => e * (1 - Math.sqrt(Math.max(0, 1 - (u / r) ** 2))); // an ellipse's depth at u from its axis
|
|
453
|
+
const grown = (n, d) => ({ ...n, x: n.x - d, y: n.y - d, w: n.w + 2 * d, h: n.h + 2 * d });
|
|
454
|
+
const deeper = (k, p) => `color-mix(in srgb, var(--um-${KIND[k][0]}) ${p}%, var(--um-${KIND[k][0]}-f))`; // a detail's tint, over the card's own
|
|
455
|
+
const BAR = 14; // a window's title bar: browser, window, terminal
|
|
456
|
+
function chrome(n, c, k, what) { // a tinted title bar, three dots after the card's tabs, then an address field (browser) or a prompt (terminal)
|
|
457
|
+
const { x, y, w } = n, x0 = Math.max(x, n.tabEnd || x), end = n.chromeEnd || x + w - 10;
|
|
458
|
+
const dots = [9, 15.5, 22].map(d => `<circle cx="${r2(x0 + d)}" cy="${y + 7}" r="2.1" class="n-dot depth-read" fill="${c}"/>`).join('');
|
|
459
|
+
const more = what === 'browser' && end - x0 - 30 > 24 ? `<path d="${rrect(x0 + 30, y + 3.5, end - x0 - 30, 7, 3.5)}" class="n-field depth-read" stroke="${c}"/>`
|
|
460
|
+
: what === 'terminal' && end - x0 - 30 > 14 ? `<text x="${r2(x0 + 31)}" y="${y + 10.6}" class="n-prompt" fill="${c}">>_</text>` : '';
|
|
461
|
+
return `<path d="${rrect(x + .75, y + .75, w - 1.5, BAR - .75, [9.25, 9.25, 0, 0])}" fill="${deeper(k, 10)}"/><path d="M${x} ${y + BAR}H${x + w}" class="n-det n-accent" stroke="${c}"/>${dots}${more}`;
|
|
462
|
+
}
|
|
463
|
+
// where a person's or robot's body starts: b px down, or just above the highest line or feed that ends on its left or right
|
|
464
|
+
// side (n.portTop, from the lines as laid out), so each one lands on its outline
|
|
465
|
+
const bodyTop = (n, b) => Math.min(n.y + b, (n.portTop ?? Infinity) - 1);
|
|
466
|
+
// each card's highest line end on its left side and on its right (a view's feeds end on the left), as laid out
|
|
467
|
+
function sidePorts(g) {
|
|
468
|
+
const m = new Map(), at = (n, side, y) => { if (!n || (side !== 'L' && side !== 'R')) return; const o = m.get(n) || { L: Infinity, R: Infinity }; o[side] = Math.min(o[side], y); m.set(n, o); };
|
|
469
|
+
(g.edges || []).forEach(e => { if (e.pts && e.pts.length) { at(e.s, e.fs, e.pts[0][1]); at(e.t, e.ts, e.pts[e.pts.length - 1][1]); } });
|
|
470
|
+
(g.intake || []).forEach(b => at((g.byId || {})[b.to], 'L', b.pts[b.pts.length - 1][1]));
|
|
471
|
+
return m;
|
|
472
|
+
}
|
|
473
|
+
const boxOut = r => (n, d = 0) => { const g = grown(n, d); return rrect(g.x, g.y, g.w, g.h, r + d); };
|
|
474
|
+
const SHAPE = {
|
|
475
|
+
box: { out: boxOut(10), face: n => [n.y, n.y + n.h], inset: (n, s, t) => rectIn(n, s, t, 10) },
|
|
476
|
+
person: { // the C4 model's person: a head over a body with wide, flat shoulders. The body starts 12 px down, or higher where
|
|
477
|
+
// a line ends on its side above that (bodyTop)
|
|
478
|
+
head: 13,
|
|
479
|
+
g(n, d = 0) { const r = 13 + d, cx = n.hx ?? n.x + n.w / 2, hy = n.y + 13, bt = bodyTop(n, 12) - d, sx = 20 + d, sy = 12 + d; return { r, cx, hy, bt, sx, sy, k: Math.sqrt(Math.max(0, r * r - (bt - hy) ** 2)) }; },
|
|
480
|
+
out(n, d = 0) { const { r, cx, hy, bt, sx, sy, k } = this.g(n, d), x = n.x - d, w = n.w + 2 * d, b = n.y + n.h + d, R = 10 + d;
|
|
481
|
+
return `M${r2(x)} ${r2(bt + sy)}A${sx} ${sy} 0 0 1 ${r2(x + sx)} ${r2(bt)}H${r2(cx - k)}A${r} ${r} 0 ${bt > hy ? 1 : 0} 1 ${r2(cx + k)} ${r2(bt)}H${r2(x + w - sx)}A${sx} ${sy} 0 0 1 ${r2(x + w)} ${r2(bt + sy)}V${r2(b - R)}A${R} ${R} 0 0 1 ${r2(x + w - R)} ${r2(b)}H${r2(x + R)}A${R} ${R} 0 0 1 ${r2(x)} ${r2(b - R)}Z`; },
|
|
482
|
+
face: n => [bodyTop(n, 12), n.y + n.h], tabY: n => bodyTop(n, 12),
|
|
483
|
+
det(n, c) { const { r, cx, hy, bt, k } = this.g(n); return `<path d="M${r2(cx - k)} ${r2(bt)}A${r} ${r} 0 ${bt < hy ? 1 : 0} 0 ${r2(cx + k)} ${r2(bt)}" class="n-det n-accent" stroke="${c}"/>`; },
|
|
484
|
+
inset(n, s, t) { const { r, cx, hy, bt, sx, sy } = this.g(n);
|
|
485
|
+
if (s === 'L' || s === 'R') return t < bt ? null : t < bt + sy ? ellIn(sx, bt + sy - t, sy) : rectIn(n, s, t, 10);
|
|
486
|
+
if (s === 'B') return rectIn(n, s, t, 10);
|
|
487
|
+
const u = t - cx; if (Math.abs(u) < r) return Math.min(hy - Math.sqrt(r * r - u * u), bt) - n.y;
|
|
488
|
+
const e = t < n.x + sx ? n.x + sx - t : t > n.x + n.w - sx ? t - n.x - n.w + sx : 0; return bt - n.y + ellIn(sy, e, sx); } },
|
|
489
|
+
robot: { // Structurizr's robot, and UML's non-human actor: the person's body under a square head with ears
|
|
490
|
+
head: 19,
|
|
491
|
+
// a body that starts higher than 14 px down (bodyTop) has a head as much shorter, its ears and corners with it
|
|
492
|
+
g(n, d = 0) { const B = bodyTop(n, 14), f = (B - n.y) / 14; return { cx: n.hx ?? n.x + n.w / 2, hw: 14 + d, ht: n.y - d, bt: B - d, ew: 19 + d, e0: n.y + 4 * f - d, e1: n.y + 10 * f + d, S: 8 + d, hr: 5 * f + d, ey: n.y + 7.5 * f }; },
|
|
493
|
+
out(n, d = 0) { const { cx, hw, ht, bt, ew, e0, e1, S, hr } = this.g(n, d), x = n.x - d, w = n.w + 2 * d, b = n.y + n.h + d, R = 10 + d;
|
|
494
|
+
return `M${r2(x)} ${r2(bt + S)}A${S} ${S} 0 0 1 ${r2(x + S)} ${r2(bt)}H${r2(cx - hw)}V${r2(e1)}H${r2(cx - ew)}V${r2(e0)}H${r2(cx - hw)}V${r2(ht + hr)}A${hr} ${hr} 0 0 1 ${r2(cx - hw + hr)} ${r2(ht)}H${r2(cx + hw - hr)}A${hr} ${hr} 0 0 1 ${r2(cx + hw)} ${r2(ht + hr)}V${r2(e0)}H${r2(cx + ew)}V${r2(e1)}H${r2(cx + hw)}V${r2(bt)}H${r2(x + w - S)}A${S} ${S} 0 0 1 ${r2(x + w)} ${r2(bt + S)}V${r2(b - R)}A${R} ${R} 0 0 1 ${r2(x + w - R)} ${r2(b)}H${r2(x + R)}A${R} ${R} 0 0 1 ${r2(x)} ${r2(b - R)}Z`; },
|
|
495
|
+
face: n => [bodyTop(n, 14), n.y + n.h], tabY: n => bodyTop(n, 14),
|
|
496
|
+
det(n, c) { const { cx, ey } = this.g(n); return [-5.5, 5.5].map(o => `<circle cx="${r2(cx + o)}" cy="${r2(ey)}" r="1.6" class="n-det n-accent" fill="${c}" stroke="${c}"/>`).join(''); },
|
|
497
|
+
inset(n, s, t) { const { cx, hw, ew, e0, bt, S, hr } = this.g(n);
|
|
498
|
+
if (s === 'L' || s === 'R') return t < bt ? null : t < bt + S ? S - Math.sqrt(S * S - (bt + S - t) ** 2) : rectIn(n, s, t, 10);
|
|
499
|
+
if (s === 'B') return rectIn(n, s, t, 10);
|
|
500
|
+
const u = Math.abs(t - cx); return u < hw ? cornerIn(hr, u, -1e6, hw) : u < ew ? e0 - n.y : bt - n.y + cornerIn(S, t, n.x, n.x + n.w); } },
|
|
501
|
+
browser: { out: boxOut(10), face: n => [n.y + BAR, n.y + n.h], inset: (n, s, t) => rectIn(n, s, t, 10), det: (n, c, k) => chrome(n, c, k, 'browser') },
|
|
502
|
+
window: { out: boxOut(10), face: n => [n.y + BAR, n.y + n.h], inset: (n, s, t) => rectIn(n, s, t, 10), det: (n, c, k) => chrome(n, c, k, 'window') },
|
|
503
|
+
terminal: { out: boxOut(10), face: n => [n.y + BAR, n.y + n.h], inset: (n, s, t) => rectIn(n, s, t, 10), det: (n, c, k) => chrome(n, c, k, 'terminal') },
|
|
504
|
+
mobile: { // a phone on its side, as the C4 model's example draws its mobile app (MobileDeviceLandscape): a screen, a home button, a speaker
|
|
505
|
+
out: boxOut(14), face: n => [n.y, n.y + n.h], inset: (n, s, t) => rectIn(n, s, t, 14),
|
|
506
|
+
det(n, c) { const { x, y, w, h } = n, cy = y + h / 2;
|
|
507
|
+
return `<path d="${rrect(x + 11, y + 5, w - 22, h - 10, 5)}" class="n-screen n-accent" stroke="${c}"/><circle cx="${x + 6.6}" cy="${r2(cy)}" r="2.1" class="n-det" stroke="${c}"/><path d="M${r2(x + w - 6.2)} ${r2(cy - 7)}V${r2(cy + 7)}" class="n-det n-speaker" stroke="${c}"/>`; } },
|
|
508
|
+
cylinder: { // a data store: its lid tinted, the lid's front edge its accent
|
|
509
|
+
ry: n => Math.max(5, Math.round(n.h * .11)),
|
|
510
|
+
out(n, d = 0) { const ry = this.ry(n), { x, y, w, h } = grown(n, d); return `M${r2(x)} ${r2(y + ry)}A${r2(w / 2)} ${ry} 0 0 1 ${r2(x + w)} ${r2(y + ry)}V${r2(y + h - ry)}A${r2(w / 2)} ${ry} 0 0 1 ${r2(x)} ${r2(y + h - ry)}Z`; },
|
|
511
|
+
face(n) { return [n.y + this.ry(n), n.y + n.h]; },
|
|
512
|
+
det(n, c, k) { const ry = this.ry(n), { x, y, w } = n;
|
|
513
|
+
return `<ellipse cx="${r2(x + w / 2)}" cy="${y + ry}" rx="${r2(w / 2 - .75)}" ry="${ry - .75}" fill="${deeper(k, 10)}"/><path d="M${x} ${y + ry}A${r2(w / 2)} ${ry} 0 0 0 ${r2(x + w)} ${y + ry}" class="n-det n-accent" stroke="${c}"/>`; },
|
|
514
|
+
inset(n, s, t) { const ry = this.ry(n), rx = n.w / 2; if (s === 'T' || s === 'B') return ellIn(ry, t - n.x - rx, rx);
|
|
515
|
+
const a = n.y + ry, b = n.y + n.h - ry; return t >= a && t <= b ? 0 : ellIn(rx, t < a ? a - t : t - b, ry); } },
|
|
516
|
+
pipe: { // a queue: a cylinder on its side, open at the right where the work leaves; dots on its floor move with the flow
|
|
517
|
+
e: n => Math.max(5, Math.round(n.h * .11)),
|
|
518
|
+
out(n, d = 0) { const e = this.e(n), { x, y, w, h } = grown(n, d); return `M${r2(x + e)} ${r2(y)}H${r2(x + w - e)}A${e} ${r2(h / 2)} 0 0 1 ${r2(x + w - e)} ${r2(y + h)}H${r2(x + e)}A${e} ${r2(h / 2)} 0 0 1 ${r2(x + e)} ${r2(y)}Z`; },
|
|
519
|
+
face: n => [n.y, n.y + n.h],
|
|
520
|
+
det(n, c, k) { const e = this.e(n), { x, y, w, h } = n;
|
|
521
|
+
return `<ellipse cx="${x + w - e}" cy="${r2(y + h / 2)}" rx="${e - .75}" ry="${r2(h / 2 - .75)}" fill="${deeper(k, 10)}"/><path d="M${x + w - e} ${y}A${e} ${r2(h / 2)} 0 0 0 ${x + w - e} ${y + h}" class="n-det n-accent" stroke="${c}"/><path d="M${x + e + 6} ${r2(y + h - 6.5)}H${x + w - 2 * e - 6}" class="n-floor" stroke="${c}"/>`; },
|
|
522
|
+
inset(n, s, t) { const e = this.e(n), ry = n.h / 2; if (s === 'L' || s === 'R') return ellIn(e, t - n.y - ry, ry);
|
|
523
|
+
const a = n.x + e, b = n.x + n.w - e; return t >= a && t <= b ? 0 : ellIn(ry, t < a ? a - t : t - b, e); } },
|
|
524
|
+
document: { // a file: UML's artifact, a page with its corner folded (and umlimator's own file sigil)
|
|
525
|
+
out(n, d = 0) { const { x, y, w, h } = grown(n, d), R = 10 + d, f = 10;
|
|
526
|
+
return `M${r2(x + R)} ${r2(y)}H${r2(x + w - f)}L${r2(x + w)} ${r2(y + f)}V${r2(y + h - R)}A${R} ${R} 0 0 1 ${r2(x + w - R)} ${r2(y + h)}H${r2(x + R)}A${R} ${R} 0 0 1 ${r2(x)} ${r2(y + h - R)}V${r2(y + R)}A${R} ${R} 0 0 1 ${r2(x + R)} ${r2(y)}Z`; },
|
|
527
|
+
face: n => [n.y, n.y + n.h],
|
|
528
|
+
det(n, c, k) { const { x, y, w } = n; return `<path d="M${x + w - 10} ${y}V${y + 7.5}Q${x + w - 10} ${y + 10} ${x + w - 7.5} ${y + 10}H${x + w}Z" fill="${deeper(k, 16)}" class="n-det n-accent" stroke="${c}"/>`; },
|
|
529
|
+
inset(n, s, t) { if (s === 'R' && t < n.y + 10) return 10 - (t - n.y); if (s === 'T' && t > n.x + n.w - 10) return t - (n.x + n.w - 10); return rectIn(n, s, t, 10); } },
|
|
530
|
+
hexagon: { // the hook card's hexagon, for any card
|
|
531
|
+
hx: n => Math.min(16, n.h / 3),
|
|
532
|
+
out(n, d = 0) { const hx = this.hx(n), { x, y, w, h } = grown(n, d); return `M${r2(x + hx)} ${r2(y)}H${r2(x + w - hx)}L${r2(x + w)} ${r2(y + h / 2)}L${r2(x + w - hx)} ${r2(y + h)}H${r2(x + hx)}L${r2(x)} ${r2(y + h / 2)}Z`; },
|
|
533
|
+
face: n => [n.y, n.y + n.h],
|
|
534
|
+
inset(n, s, t) { const hx = this.hx(n); if (s === 'L' || s === 'R') return hx * Math.abs(t - n.y - n.h / 2) / (n.h / 2);
|
|
535
|
+
const a = n.x + hx, b = n.x + n.w - hx; return t < a ? (a - t) * n.h / 2 / hx : t > b ? (t - b) * n.h / 2 / hx : 0; } },
|
|
536
|
+
folder: { // UML's package: a folder with its tab on top, after the card's own tabs
|
|
537
|
+
tw: n => Math.min(64, Math.round(n.w * .34)),
|
|
538
|
+
out(n, d = 0) { const tw = this.tw(n), x0 = n.tabX ?? n.x, { x, y, w, h } = grown(n, d), r = 4 + d, R = 8 + d, th = 9, X0 = x0 - d, T1 = x0 + tw + d;
|
|
539
|
+
const head = X0 <= x + .5 ? `M${r2(x)} ${r2(y + h - R)}V${r2(y + r)}A${r} ${r} 0 0 1 ${r2(x + r)} ${r2(y)}`
|
|
540
|
+
: `M${r2(x)} ${r2(y + h - R)}V${r2(y + th + R)}A${R} ${R} 0 0 1 ${r2(x + R)} ${r2(y + th)}H${r2(X0)}V${r2(y + r)}A${r} ${r} 0 0 1 ${r2(X0 + r)} ${r2(y)}`;
|
|
541
|
+
return `${head}H${r2(T1 - 3)}Q${r2(T1)} ${r2(y)} ${r2(T1 + 1.5)} ${r2(y + 2.5)}L${r2(T1 + 5)} ${r2(y + th)}H${r2(x + w - R)}A${R} ${R} 0 0 1 ${r2(x + w)} ${r2(y + th + R)}V${r2(y + h - R)}A${R} ${R} 0 0 1 ${r2(x + w - R)} ${r2(y + h)}H${r2(x + R)}A${R} ${R} 0 0 1 ${r2(x)} ${r2(y + h - R)}Z`; },
|
|
542
|
+
face: n => [n.y + 9, n.y + n.h], tabY: n => n.y + 9,
|
|
543
|
+
det(n, c, k) { const tw = this.tw(n), x0 = n.tabX ?? n.x;
|
|
544
|
+
return `<path d="M${r2(x0 + .75)} ${n.y + 9}V${n.y + 4}A3.25 3.25 0 0 1 ${r2(x0 + 4)} ${n.y + .75}H${r2(x0 + tw - 3)}Q${r2(x0 + tw)} ${n.y + .75} ${r2(x0 + tw + 1.5)} ${n.y + 3}L${r2(x0 + tw + 4.4)} ${n.y + 9}Z" fill="${deeper(k, 14)}"/><path d="M${r2(x0)} ${n.y + 9}H${r2(x0 + tw + 5)}" class="n-det n-accent" stroke="${c}"/>`; },
|
|
545
|
+
inset(n, s, t) { const tw = this.tw(n), x0 = n.tabX ?? n.x;
|
|
546
|
+
if (s === 'T') return t >= x0 && t <= x0 + tw ? 0 : 9 + cornerIn(8, t, x0 > n.x + .5 ? n.x : -1e6, n.x + n.w);
|
|
547
|
+
if (s === 'B') return rectIn(n, s, t, 8);
|
|
548
|
+
// the side's top: a tab at the card's left corner has a 4 px corner at the top; elsewhere the body starts 9 px down, 8 px round
|
|
549
|
+
const top = s === 'L' && x0 <= n.x + .5 ? cornerIn(4, t, n.y, 1e6) : t < n.y + 9 ? null : cornerIn(8, t, n.y + 9, 1e6);
|
|
550
|
+
return top == null || top > 0 ? top : cornerIn(8, t, -1e6, n.y + n.h); } },
|
|
551
|
+
node: { // UML's node: a box seen in perspective, for hosts and devices
|
|
552
|
+
out(n, d = 0) { const p = 7, { x, y, w, h } = grown(n, d); return `M${r2(x)} ${r2(y + p)}L${r2(x + p)} ${r2(y)}H${r2(x + w)}V${r2(y + h - p)}L${r2(x + w - p)} ${r2(y + h)}H${r2(x)}Z`; },
|
|
553
|
+
face: n => [n.y + 7, n.y + n.h],
|
|
554
|
+
det(n, c, k) { const p = 7, { x, y, w, h } = n;
|
|
555
|
+
return `<path d="M${x + 1} ${y + p}L${x + p} ${y + 1}H${x + w - 1}L${x + w - p} ${y + p}Z" fill="${deeper(k, 12)}"/><path d="M${x + w - p} ${y + p}L${x + w - 1} ${y + 1}V${y + h - p}L${x + w - p} ${y + h - 1}Z" fill="${deeper(k, 22)}"/>`
|
|
556
|
+
+ `<path d="M${x} ${y + p}H${x + w - p}V${y + h}" class="n-det n-accent" stroke="${c}"/><path d="M${x + w - p} ${y + p}L${x + w} ${y}" class="n-det" stroke="${c}"/>`; },
|
|
557
|
+
inset(n, s, t) { const p = 7; return s === 'L' ? Math.max(0, n.y + p - t) : s === 'R' ? Math.max(0, t - n.y - n.h + p) : s === 'T' ? Math.max(0, n.x + p - t) : Math.max(0, t - n.x - n.w + p); } },
|
|
558
|
+
};
|
|
559
|
+
const SHAPE_NAMES = ['box', 'person', 'browser', 'mobile', 'cylinder', 'pipe', 'document', 'hexagon', 'robot', 'terminal', 'window', 'folder', 'node']; // all 13
|
|
560
|
+
// a card's shape in a notation: its own `shape` first; box draws every card as before (a hook stays a hexagon there too)
|
|
561
|
+
const shapeOf = (n, notation) => { const sh = String(n.shape ?? '').toLowerCase(); if (SHAPE_NAMES.includes(sh)) return sh;
|
|
562
|
+
const T = hasKey(NOTED, notation) && NOTED[notation], k = String(n.kind ?? '').toLowerCase(); return T ? (hasKey(T, k) && T[k]) || (hasKey(T, n.k) && T[n.k]) || 'box' : 'box'; };
|
|
563
|
+
const noteOf = x => NOTATIONS.includes(x) ? x : null;
|
|
564
|
+
// a card as its notation draws it (null for a box): its shape, and where its head, folder tab and title bar go, clear of its
|
|
565
|
+
// tabs, ⤢ and change badge. Drawing only: layout never sees it. ports: the card's highest line ends on its sides (sidePorts)
|
|
566
|
+
function cardLook(n, notation, hint, ports) {
|
|
567
|
+
const sh = shapeOf(n, notation); if (sh === 'box') return null;
|
|
568
|
+
const S = SHAPE[sh], L = { ...n, sh, S, portTop: ports ? Math.min(ports.L, ports.R) : Infinity, portL: ports ? ports.L : Infinity }, tabs = mapTabs(n), spans = []; let tx = n.x + 12;
|
|
569
|
+
tabs.forEach(t => { spans.push([tx, tx + t.w]); tx += t.w + 4; });
|
|
570
|
+
L.tabEnd = tabs.length ? tx - 2 : 0;
|
|
571
|
+
const right = []; if (hint) right.push([n.x + n.w - 44, n.x + n.w - 8]);
|
|
572
|
+
const ch = CHG[String(n.change || '').toLowerCase()]; if (ch) { const bw = ch[1].length * 6 + 12, bx = n.x + n.w - bw - 8 - (hint ? 40 : 0); right.push([bx, bx + bw]); }
|
|
573
|
+
L.chromeEnd = Math.min(n.x + n.w - 10, ...right.map(s => s[0] - 6));
|
|
574
|
+
const half = (S.head || 0) + 4;
|
|
575
|
+
if (S.head) { // a head in the middle, unless a tab or a badge is there: then in the widest gap they leave
|
|
576
|
+
const all = [...spans, ...right], lo = n.x + 22, hi = n.x + n.w - 22, mid = n.x + n.w / 2;
|
|
577
|
+
let gaps = [[lo, hi]]; all.forEach(([a, b]) => { gaps = gaps.flatMap(([p, q]) => b <= p || a >= q ? [[p, q]] : [[p, a], [b, q]].filter(([u, v]) => v > u)); });
|
|
578
|
+
const fits = gaps.filter(([p, q]) => q - p >= 2 * half), best = fits.find(([p, q]) => p <= mid - half && q >= mid + half) ? [mid - half, mid + half]
|
|
579
|
+
: fits.sort((u, v) => Math.abs((u[0] + u[1]) / 2 - mid) - Math.abs((v[0] + v[1]) / 2 - mid))[0];
|
|
580
|
+
L.hx = best ? Math.min(Math.max(mid, best[0] + half), best[1] - half) : mid;
|
|
581
|
+
}
|
|
582
|
+
if (sh === 'folder') L.tabX = tabs.length ? Math.min(L.tabEnd + 4, n.x + n.w - SHAPE.folder.tw(n) - 14) : n.x;
|
|
583
|
+
// what else is on the tab line after the card's own tabs: ⤢, the change badge, a head (a robot's with its ears), a folder's tab
|
|
584
|
+
L.busy = [...right, ...(S.head ? [[L.hx - S.head, L.hx + S.head]] : []), ...(sh === 'folder' ? [[L.tabX, L.tabX + SHAPE.folder.tw(n) + 5]] : [])];
|
|
585
|
+
return L;
|
|
586
|
+
}
|
|
587
|
+
// where a tab w wide goes after the card's own tabs (an overlay's or a lens's badge): on a shape, the first place inside the
|
|
588
|
+
// card 2 px clear of what L.busy names, else the place inside it that covers least of them; on a box, or where the card's
|
|
589
|
+
// own tabs fill it, right after them
|
|
590
|
+
function tabAfter(n, L, w) {
|
|
591
|
+
const x0 = mapTabs(n).reduce((a, t) => a + t.w + 4, n.x + 12); if (!L) return x0;
|
|
592
|
+
const max = n.x + n.w - 2 - w, over = x => L.busy.reduce((s, [a, b]) => s + Math.max(0, Math.min(x + w, b + 2) - Math.max(x, a - 2)), 0);
|
|
593
|
+
return [x0, ...L.busy.map(b => b[1] + 4), max].filter(x => x >= x0 && x <= max).reduce((b, x) => over(x) < over(b) ? x : b, x0);
|
|
594
|
+
}
|
|
595
|
+
// the line a card's tabs, ⤢, change badge and step sit on: a shape's top (a person's shoulders), else the box's
|
|
596
|
+
const tabLine = (n, L) => L && L.S.tabY ? L.S.tabY(L) : n.y;
|
|
597
|
+
const SIDE_OUT = { L: [-1, 0], R: [1, 0], T: [0, -1], B: [0, 1] };
|
|
598
|
+
// a line as drawn: its ends slid from the card's box onto the card's outline, along the side's normal (the stub lengthens
|
|
599
|
+
// by as much); layout's points stay as they are. look(n): the card's look, or null for a box
|
|
600
|
+
function drawnPts(e, look) {
|
|
601
|
+
const a = e.s && !e.s.box ? look(e.s) : null, b = e.t && !e.t.box ? look(e.t) : null;
|
|
602
|
+
if (!a && !b) return e.pts;
|
|
603
|
+
const slide = (L, side, p) => { const [ox, oy] = SIDE_OUT[side] || [0, 0], k = L.S.inset(L, side, side === 'L' || side === 'R' ? p[1] : p[0]) || 0; return [r2(p[0] - ox * k), r2(p[1] - oy * k)]; };
|
|
604
|
+
const pts = e.pts.map(p => p.slice());
|
|
605
|
+
if (a) pts[0] = slide(a, e.fs, pts[0]);
|
|
606
|
+
if (b) pts[pts.length - 1] = slide(b, e.ts, pts[pts.length - 1]);
|
|
607
|
+
return pts;
|
|
608
|
+
}
|
|
397
609
|
const CHG = { added: ['green', 'NEW'], new: ['green', 'NEW'], modified: ['amber', 'CHANGED'], changed: ['amber', 'CHANGED'], removed: ['rose', 'REMOVED'], planned: ['cyan', 'PLANNED'] };
|
|
398
610
|
const NW = 168, CG0 = 84, RG0 = 44;
|
|
399
611
|
const pillW = (label, sub, max = 440) => Math.max(sansW(fit(label, 10.5, max), 10.5, .92), sub ? sansW(fit(sub, 9.5, max), 9.5, .92) : 0) + 18; // a line's label, and its sublabel under it (#44)
|
|
400
|
-
const stepW = s => String(s).length >
|
|
612
|
+
const stepW = s => String(s).length > 1 ? Math.max(20, sansW(String(s), 9.5, .9) + 8) : 16; // a line's step mark: a 16 px circle, or a pill for 2a, 12 or 4.1 (#82)
|
|
613
|
+
// steps in order: numbers by value, text by its leading number and then the rest (2 < 2a < 2b < 3); no step last
|
|
614
|
+
const stepKey = s => { const t = String(s ?? ''), m = /^\d+(?:\.\d+)?/.exec(t); return [s == null ? 1 : 0, m ? +m[0] : Infinity, m ? t.slice(m[0].length) : t]; };
|
|
615
|
+
const stepOrder = (a, b) => { const p = stepKey(a), q = stepKey(b); return p[0] - q[0] || p[1] - q[1] || (p[2] < q[2] ? -1 : p[2] > q[2] ? 1 : 0); };
|
|
401
616
|
// Text width. Without a measurer: a monospace estimate (font-independent, so layouts are the same on every machine;
|
|
402
617
|
// the bench and layout() in Node use it). render() sets measureText to the host's own font while it lays out a view.
|
|
403
618
|
let measureText = null;
|
|
@@ -419,6 +634,15 @@ function fit(s, px, max, words) { s = String(s ?? ''); if (textW(s, px) <= max)
|
|
|
419
634
|
let k; if (measureText) { k = s.length; while (k > 3 && textW(s.slice(0, k) + '…', px) > max) k--; } else k = Math.max(Math.floor(max / (px * 0.6)) - 1, 3);
|
|
420
635
|
const w = words ? s.slice(0, k + 1).replace(/\S*$/, '').replace(/[\s,;:]+$/, '') : '';
|
|
421
636
|
return (w || s.slice(0, k)) + '…'; }
|
|
637
|
+
// pts shortened by a at the start and b at the end, along its own segments: a line's flow stops where an arrowhead begins
|
|
638
|
+
const trimLine = (pts, a, b) => {
|
|
639
|
+
const cut = (p, d) => { p = p.map(q => q.slice());
|
|
640
|
+
while (d > 0 && p.length > 1) { const [x0, y0] = p[p.length - 2], [x1, y1] = p[p.length - 1], len = Math.hypot(x1 - x0, y1 - y0);
|
|
641
|
+
if (len > d) { const t = (len - d) / len; p[p.length - 1] = [x0 + (x1 - x0) * t, y0 + (y1 - y0) * t]; break; }
|
|
642
|
+
d -= len; p.pop(); }
|
|
643
|
+
return p; };
|
|
644
|
+
return cut(cut(pts, b).reverse(), a).reverse();
|
|
645
|
+
};
|
|
422
646
|
function roundPath(pts, r = 9) {
|
|
423
647
|
let d = `M ${pts[0][0]} ${pts[0][1]}`;
|
|
424
648
|
for (let i = 1; i < pts.length - 1; i++) {
|
|
@@ -631,9 +855,22 @@ const mapNodeW = n => {
|
|
|
631
855
|
/* Geometry: node boxes, ports, orthogonal routes, label slots. A view with lines the router found no route for is laid
|
|
632
856
|
out once more, with room where those lines need it (#40); a view whose lines all route keeps its geometry. */
|
|
633
857
|
function layoutGraph(dg, map) {
|
|
634
|
-
|
|
635
|
-
if (
|
|
636
|
-
|
|
858
|
+
let g = layoutPass(dg, map, null);
|
|
859
|
+
if (g.room) { const g2 = layoutPass(dg, map, g.room, true); g2.ms.probe = g.ms.probe; g = g2; }
|
|
860
|
+
// two lines that still cross: one takes another side, and the view is laid out so once more, kept if it has fewer issues. A
|
|
861
|
+
// line that closes a loop leaves its source on the far side, out round the map as a loop under the boxes does, and still comes
|
|
862
|
+
// in on its target's near side (clinic's reminder, from the SMS gateway under Patient records, crossed the line into Patient
|
|
863
|
+
// records); of two other lines, the steeper comes into its target's top or bottom (clinic's "Sends texts" with Clinic bookings
|
|
864
|
+
// unfolded, up past Patient records into the SMS gateway's bottom). ponytail: a layout more, so only up to 30 cards (as
|
|
865
|
+
// bestFlow); a larger view keeps its crossings
|
|
866
|
+
const refs = [], pairs = map && !dg.quick && !dg.flipped && dg.nodes.length <= 30 ? routeAudit(g, '', null, refs).flatMap((s, k) => s.endsWith(' cross') ? [refs[k]] : []) : [];
|
|
867
|
+
const far = { L: 'right', R: 'left', T: 'bottom', B: 'top' }, dy = e => (e.s.y + e.s.h / 2) - (e.t.y + e.t.h / 2), out = new Map();
|
|
868
|
+
pairs.forEach(p => p.forEach(e => { if (e.back && !e.fixed && !e.fromSide) out.set(e.from + '>' + e.to, ['fromSide', far[e.fs]]); }));
|
|
869
|
+
pairs.forEach(p => { const e = p.filter(e => !e.back && !e.fixed && !e.toSide && /[LR]/.test(e.ts) && dy(e)).sort((a, b) => Math.abs(dy(b)) - Math.abs(dy(a)))[0];
|
|
870
|
+
if (e && !p.some(o => out.has(o.from + '>' + o.to))) out.set(e.from + '>' + e.to, ['toSide', dy(e) > 0 ? 'bottom' : 'top']); });
|
|
871
|
+
if (!out.size) return g;
|
|
872
|
+
const g2 = layoutGraph({ ...dg, flipped: true, edges: dg.edges.map(e => out.has(e.from + '>' + e.to) ? { ...e, [out.get(e.from + '>' + e.to)[0]]: out.get(e.from + '>' + e.to)[1] } : e) }, map);
|
|
873
|
+
return routeAudit(g2, '').length < routeAudit(g, '').length ? g2 : g;
|
|
637
874
|
}
|
|
638
875
|
function layoutPass(dg, map, room, last) { // room: { x: { col: px }, y: { row: px } }; last: lay out with it, find no more
|
|
639
876
|
const __ms = { t: performance.now() }, t0 = __ms.t;
|
|
@@ -653,7 +890,8 @@ function layoutPass(dg, map, room, last) { // room: { x: { col: px }, y: { row:
|
|
|
653
890
|
const boxed = new Set((dg.containers || []).flatMap(f => f.nodeIds || [])); // a box's row keeps to the box's columns
|
|
654
891
|
nodes.forEach(n => { n.aside = map && !dg.abs && !ends.has(n.id) && n.row > low && !boxed.has(n.id); });
|
|
655
892
|
cols.forEach(c => { const ns = nodes.filter(n => n.col === c && !n.aside); if (ns.length) colW[c] = Math.max(...ns.map(n => n.w)); });
|
|
656
|
-
// a labelled line between neighbouring columns gets a gap wide enough for its label and 27 px of line on each side
|
|
893
|
+
// a labelled line between neighbouring columns gets a gap wide enough for its label and 27 px of line on each side (a
|
|
894
|
+
// numbered line with no label: for its step mark)
|
|
657
895
|
const gapAfter = {}, colOf = {}, boxOf = id => ((dg.containers || []).filter(f => (f.nodeIds || []).includes(id)).pop() || {}).id; if (map && !dg.abs) { nodes.forEach(n => colOf[n.id] = n.col);
|
|
658
896
|
// labelled lines that leave (or reach) one card side by side run 16 px apart, too close for their pills to stack,
|
|
659
897
|
// so the pills sit next to each other: the gap fits them all, with 6 px between pills
|
|
@@ -666,8 +904,8 @@ function layoutPass(dg, map, room, last) { // room: { x: { col: px }, y: { row:
|
|
|
666
904
|
// a box in a box (an unfolded numbered view's lane, #82, or any nested box): a line to a card in one, or to the box that
|
|
667
905
|
// holds it, crosses two borders
|
|
668
906
|
const inLane = id => (dg.containers || []).some(f => f.parent && (f.parent === id || (f.nodeIds || []).includes(id)));
|
|
669
|
-
(dg.edges || []).forEach(e => { const p = colOf[e.from] ?? near(e.from, colOf[e.to]), q = colOf[e.to] ?? near(e.to, colOf[e.from]); if (p == null || q == null || !e.label || Math.abs(p - q) !== 1) return;
|
|
670
|
-
const c = Math.min(p, q), w = pillW(e.label, e.sublabel) + (e.step != null ? stepW(e.step) + 4 : 0), box = boxOf(e.from) !== boxOf(e.to) || !(e.from in colOf) || !(e.to in colOf) ? 48 + ([e.from, e.to].some(inLane) ? 12 : 0) : 0;
|
|
907
|
+
(dg.edges || []).forEach(e => { const p = colOf[e.from] ?? near(e.from, colOf[e.to]), q = colOf[e.to] ?? near(e.to, colOf[e.from]); if (p == null || q == null || (!e.label && e.step == null) || Math.abs(p - q) !== 1) return;
|
|
908
|
+
const c = Math.min(p, q), w = (e.label ? pillW(e.label, e.sublabel) : 0) + (e.step != null ? stepW(e.step) + (e.label ? 4 : 6) : 0), box = boxOf(e.from) !== boxOf(e.to) || !(e.from in colOf) || !(e.to in colOf) ? 48 + ([e.from, e.to].some(inLane) ? 12 : 0) : 0;
|
|
671
909
|
gapAfter[c] = Math.max(gapAfter[c] || 0, w + 54 + box);
|
|
672
910
|
[e.from, e.to].forEach(id => { const k = c + '|' + id, t = sum[k] ||= { w: -6, box: 0 }; t.w += w + 6; t.box = Math.max(t.box, box); gapAfter[c] = Math.max(gapAfter[c], t.w + 54 + t.box); }); }); }
|
|
673
911
|
const extraX = { ...(room || {}).x }, extraY = { ...(room || {}).y }; // more room after a column (row): for lines with no route, and where a box would touch another box (below)
|
|
@@ -874,7 +1112,7 @@ function layoutPass(dg, map, room, last) { // room: { x: { col: px }, y: { row:
|
|
|
874
1112
|
return hz[0][1] > Math.min(v[0][1], v[1][1]) && hz[0][1] < Math.max(v[0][1], v[1][1]) && v[0][0] > Math.min(hz[0][0], hz[1][0]) && v[0][0] < Math.max(hz[0][0], hz[1][0]); };
|
|
875
1113
|
const onSegInterior = (p, a, b) => (a[0] === b[0] && Math.abs(p[0] - a[0]) < 0.5 && p[1] > Math.min(a[1], b[1]) && p[1] < Math.max(a[1], b[1]))
|
|
876
1114
|
|| (a[1] === b[1] && Math.abs(p[1] - a[1]) < 0.5 && p[0] > Math.min(a[0], b[0]) && p[0] < Math.max(a[0], b[0]));
|
|
877
|
-
function gridRoute(e, allowCross, win) {
|
|
1115
|
+
function gridRoute(e, allowCross, win, spare) {
|
|
878
1116
|
const st = [e.p1[0] + OUT[e.fs][0] * STUB, e.p1[1] + OUT[e.fs][1] * STUB];
|
|
879
1117
|
const en = [e.p2[0] + OUT[e.ts][0] * STUB, e.p2[1] + OUT[e.ts][1] * STUB];
|
|
880
1118
|
// ends that face each other closer than two stubs (a card 32 px beside a box) leave the grid no room: the plain
|
|
@@ -934,6 +1172,15 @@ function layoutPass(dg, map, room, last) { // room: { x: { col: px }, y: { row:
|
|
|
934
1172
|
}
|
|
935
1173
|
}
|
|
936
1174
|
for (let k = 0; k < N; k++) if (!inObs[k] && (!onAv[k] || allowCross)) { ok[k] = 1; crossAt[k] = onAv[k]; }
|
|
1175
|
+
// routed after a port swap, it keeps off the end of its partner's stub, where the partner, routed next, starts
|
|
1176
|
+
if (spare) for (const p of [spare.stubs[0][1], spare.stubs[1][0]]) { const i = X.indexOf(p[0]), j = Y.indexOf(p[1]); if (i >= 0 && j >= 0) { if (allowCross) crossAt[i * ny + j] = 1; else ok[i * ny + j] = 0; } }
|
|
1177
|
+
// a line never runs round one of its own ends: the grid behind each (past the side opposite its port) is shut to it, unless its
|
|
1178
|
+
// other end is there. A route with no crossing went any length, round its own card too (clinic's reminder went over the
|
|
1179
|
+
// patient, down its far side and under it to reach the port below the patient's other line), and port repair saw nothing
|
|
1180
|
+
for (const [n, side, far] of [[e.s, e.fs, en], [e.t, e.ts, st]]) if (!behind(n, side, far)) {
|
|
1181
|
+
const lr = side === 'R' || side === 'L';
|
|
1182
|
+
(lr ? X : Y).forEach((v, a) => { if (behind(n, side, lr ? [v, n.y] : [n.x, v])) for (let b = 0; b < (lr ? ny : nx); b++) ok[lr ? a * ny + b : b * ny + a] = 0; });
|
|
1183
|
+
}
|
|
937
1184
|
if (any) for (const r of reserved) for (let i = from(X, r.x - 3); i < nx && X[i] < r.x + r.w + 3; i++) for (let j = from(Y, r.y - 3); j < ny && Y[j] < r.y + r.h + 3; j++) crossAt[i * ny + j] = 1;
|
|
938
1185
|
// a run within 8 px of a box's border costs ten times its length: the grid keeps a port's track and an earlier line's however
|
|
939
1186
|
// near a border, and a box levelled half a row from a card (#86) puts its border right by that card's ports
|
|
@@ -1015,12 +1262,20 @@ function layoutPass(dg, map, room, last) { // room: { x: { col: px }, y: { row:
|
|
|
1015
1262
|
const tries = [full]; if (e.step != null && e.label && !dg.abs) tries.push(map ? '' : String(e.step));
|
|
1016
1263
|
const segs = segsOf(e.pts).map((s, i) => ({ a: s[0], b: s[1], i, l: Math.hypot(s[1][0] - s[0][0], s[1][1] - s[0][1]), h: s[0][1] === s[1][1] }));
|
|
1017
1264
|
const order = [...segs].sort((p, q) => (q.h - p.h) * 40 + (q.l - p.l));
|
|
1265
|
+
// the strict spots along a segment: the middle, a third and a fifth of the way, then those just clear of each card's stub room,
|
|
1266
|
+
// tag, label and line beside it, the middle's nearest first: where the label fits, one of them is in. The five alone missed a
|
|
1267
|
+
// gap 15 px wide, and the label sat on a box's border (clinic's "Queues reminders")
|
|
1268
|
+
const spots = (sg, w, hgt) => { const v = sg.h ? 0 : 1, lo = sg.a[v], len = sg.b[v] - lo, half = (v ? hgt : w) / 2 + 3.5, mid = sg.a[1 - v], across = (v ? w : hgt) / 2 + 3;
|
|
1269
|
+
const span = r => [[r.x, r.x + r.w], [r.y, r.y + r.h]], rs = [...nodeRects.map(r => ({ x: r.x - STUB - 4, y: r.y - STUB - 4, w: r.w + 2 * STUB + 8, h: r.h + 2 * STUB + 8 })), ...tagRects, ...placed,
|
|
1270
|
+
...[...routed.filter(r => r !== e).map(r => r.pts), e.pts].flatMap(P => segsOf(P).map(([p, q]) => ({ x: Math.min(p[0], q[0]), y: Math.min(p[1], q[1]), w: Math.abs(q[0] - p[0]), h: Math.abs(q[1] - p[1]) })))];
|
|
1271
|
+
return [.5, .32, .68, .2, .8, ...rs.map(span).filter(s => s[1 - v][0] < mid + across && s[1 - v][1] > mid - across).flatMap(s => [s[v][0] - half, s[v][1] + half])
|
|
1272
|
+
.map(u => (u - lo) / len).filter(f => f >= 0 && f <= 1).sort((p, q) => Math.abs(p - .5) - Math.abs(q - .5))]; };
|
|
1018
1273
|
for (const pass of (map ? [0, 1, 2] : [0])) for (const text of tries) { const relax = pass === 2; // 0 strict, 1 strict but may touch a box border, 2 relaxed
|
|
1019
1274
|
const two = !!(text && sub), pill = map && e.step != null, w = map ? (text ? pillW(text, two && sub) : 0) + (pill ? stepW(e.step) + (text ? 4 : 6) : 0) : textW(text, 10) + 12, hgt = map ? (two ? 32 : 20) : 18;
|
|
1020
1275
|
for (const sg of order) {
|
|
1021
1276
|
if (!relax && sg.h && sg.l < w + 10) continue;
|
|
1022
1277
|
if (!relax && !sg.h && sg.l < hgt + 14) continue;
|
|
1023
|
-
for (const f of (relax ? [.5, .32, .68, .2, .8, .1, .9, .42, .58, .26, .74, .05, .95] :
|
|
1278
|
+
for (const f of (relax ? [.5, .32, .68, .2, .8, .1, .9, .42, .58, .26, .74, .05, .95] : spots(sg, w, hgt))) {
|
|
1024
1279
|
const lx = sg.a[0] + (sg.b[0] - sg.a[0]) * f, ly = sg.a[1] + (sg.b[1] - sg.a[1]) * f;
|
|
1025
1280
|
const box = { x: lx - w / 2, y: ly - hgt / 2, w, h: hgt };
|
|
1026
1281
|
const grow = { x: box.x - 3, y: box.y - 3, w: box.w + 6, h: box.h + 6 };
|
|
@@ -1105,8 +1360,8 @@ function layoutPass(dg, map, room, last) { // room: { x: { col: px }, y: { row:
|
|
|
1105
1360
|
const p = placed.findIndex(same(0)); if (p >= 0) placed.splice(p, 1); const q = reserved.findIndex(same(3)); if (q >= 0) reserved.splice(q, 1); };
|
|
1106
1361
|
// a swapped edge tries a clean route near its ends, then one that may cross (near its ends, then anywhere); it
|
|
1107
1362
|
// skips the clean whole-grid search, which mostly fails here and visits every cell when it does
|
|
1108
|
-
const routeOne = e => { let how = 'grid'; e.lbl = null; e.pts = gridRoute(e, false, 320);
|
|
1109
|
-
if (!e.pts) { how = 'cross'; e.pts = gridRoute(e, true, 320) || gridRoute(e, true, 0); } if (!e.pts) { how = 'fallback'; e.pts = fallback(e); }
|
|
1363
|
+
const routeOne = (e, spare) => { let how = 'grid'; e.lbl = null; e.pts = gridRoute(e, false, 320, spare);
|
|
1364
|
+
if (!e.pts) { how = 'cross'; e.pts = gridRoute(e, true, 320, spare) || gridRoute(e, true, 0, spare); } if (!e.pts) { how = 'fallback'; e.pts = fallback(e); }
|
|
1110
1365
|
e.how = how; routed.push(e); placeLabel(e); return true; };
|
|
1111
1366
|
let budget = 2 * free.length, swapped = false;
|
|
1112
1367
|
for (let round = 0; issues && dg.repair !== false && round < 8 && budget > 0; round++) {
|
|
@@ -1115,7 +1370,8 @@ function layoutPass(dg, map, room, last) { // room: { x: { col: px }, y: { row:
|
|
|
1115
1370
|
const xing = (a, b) => segsE(a).some(([p, q]) => segsE(b).some(([r, s]) => crosses(p, q, r, s)));
|
|
1116
1371
|
const pairs = []; edges.forEach((a, i) => edges.slice(i + 1).forEach(b => { if (xing(a, b)) pairs.push([a, b]); }));
|
|
1117
1372
|
// a crossing pair, or a pair where one shares a node side with the other's crossing partner
|
|
1118
|
-
|
|
1373
|
+
// each pair once, crossing pairs first; by place, since a model's edges need no id (by id, every pair of r15's was one)
|
|
1374
|
+
const cand = [], seen = new Set(), add = (a, b) => { const k = edges.indexOf(a) + '|' + edges.indexOf(b); if (!seen.has(k)) { seen.add(k); cand.push([a, b]); } };
|
|
1119
1375
|
pairs.forEach(([a, b]) => add(a, b)); pairs.forEach(([a, b]) => free.forEach(o => { if (o !== a && o !== b) { add(a, o); add(b, o); } }));
|
|
1120
1376
|
outer3: for (const [a, b] of cand.filter(([x, y]) => !x.fixed && !y.fixed)) {
|
|
1121
1377
|
for (const n of [a.s, a.t]) for (const side of ['L', 'R', 'T', 'B']) {
|
|
@@ -1127,7 +1383,13 @@ function layoutPass(dg, map, room, last) { // room: { x: { col: px }, y: { row:
|
|
|
1127
1383
|
budget -= 2; __ms.reroutes++;
|
|
1128
1384
|
if (!was) { a[ka] = pa; b[kb] = pb; restub(a); restub(b); continue; } // nothing to gain: the count can only drop by their own issues
|
|
1129
1385
|
ripUp(a); ripUp(b);
|
|
1130
|
-
|
|
1386
|
+
// first the routes they have, each moved onto the other's port: two lines that cross where they leave one side no longer
|
|
1387
|
+
// cross (r15's web: its line to kit turned down past the line to api-client, and routed again they crossed again)
|
|
1388
|
+
const moved = (e, k, P) => { const [i, j] = k === 'p1' ? [0, 1] : [P.length - 1, P.length - 2], v = P[i][1] === P[j][1];
|
|
1389
|
+
return P.length > 2 && simplify(P.map((p, x) => x === i ? e[k] : x === j ? (v ? [p[0], e[k][1]] : [e[k][0], p[1]]) : p)); };
|
|
1390
|
+
const ma = moved(a, ka, a.pts), mb = moved(b, kb, b.pts); let now = Infinity;
|
|
1391
|
+
if (ma && mb) { a.pts = ma; b.pts = mb; a.lbl = b.lbl = null; routed.push(a, b); placeLabel(a); placeLabel(b); now = issues - was + issueCount(ab); if (now >= issues) { ripUp(a); ripUp(b); } }
|
|
1392
|
+
if (now >= issues) now = routeOne(a, b) && routeOne(b) ? issues - was + issueCount(ab) : Infinity;
|
|
1131
1393
|
if (now < issues) { issues = now; fixed = swapped = true; if (!issues) break outer3; continue outer3; }
|
|
1132
1394
|
a[ka] = pa; b[kb] = pb; restub(a); restub(b);
|
|
1133
1395
|
snap.forEach(([e, p, l, hw]) => { e.pts = p; e.lbl = l; e.how = hw; });
|
|
@@ -1137,6 +1399,21 @@ function layoutPass(dg, map, room, last) { // room: { x: { col: px }, y: { row:
|
|
|
1137
1399
|
if (!fixed) break;
|
|
1138
1400
|
}
|
|
1139
1401
|
if (swapped) { ['grid', 'cross', 'fallback'].forEach(k => { delete stats[k]; }); free.forEach(e => { stats[e.how] = (stats[e.how] || 0) + 1; }); }
|
|
1402
|
+
// a line that jogs 16 px or less between sides that face each other runs straight when one of its ends is the only port on its
|
|
1403
|
+
// side: that port moves level with the other end, if no issue comes with it. Ports are spread round the middle of a side, so a
|
|
1404
|
+
// line from the lower of a card's two ports into a card level with it jogged just before it (clinic's "Books appointments")
|
|
1405
|
+
if (map && dg.repair !== false) free.forEach(e => {
|
|
1406
|
+
const P = e.pts, ax = e.fs === 'L' || e.fs === 'R' ? 1 : 0, d = P[P.length - 1][ax] - P[0][ax];
|
|
1407
|
+
if (P.length !== 4 || !d || Math.abs(d) > 16 || OUT[e.fs][1 - ax] !== -OUT[e.ts][1 - ax]) return;
|
|
1408
|
+
const lone = (n, side) => !n.box && sides[n.id + '|' + side].length === 1, [k, n, side, v] = lone(e.t, e.ts) ? ['p2', e.t, e.ts, P[0][ax]] : lone(e.s, e.fs) ? ['p1', e.s, e.fs, P[3][ax]] : [];
|
|
1409
|
+
const sp = n && (ax ? [n.y + n.h * .08, n.y + n.h * .92] : sideSpan(n, side) || [n.x + n.w * .08, n.x + n.w * .92]);
|
|
1410
|
+
if (!k || v < sp[0] || v > sp[1]) return;
|
|
1411
|
+
const was = e[k], before = issueCount(new Set([e])), snap = [e.pts, e.lbl, e.how], snapR = [routed.slice(), reserved.slice(), placed.slice()];
|
|
1412
|
+
ripUp(e); e[k] = ax ? [was[0], v] : [v, was[1]]; restub(e); routeOne(e);
|
|
1413
|
+
if (e.pts.length === 2 && issueCount(new Set([e])) <= before) return;
|
|
1414
|
+
e[k] = was; restub(e); [e.pts, e.lbl, e.how] = snap;
|
|
1415
|
+
routed.splice(0, routed.length, ...snapR[0]); reserved.splice(0, reserved.length, ...snapR[1]); placed.splice(0, placed.length, ...snapR[2]);
|
|
1416
|
+
});
|
|
1140
1417
|
// the last resort for a line still with no route: around the cards, across or along other lines if it must (it was a
|
|
1141
1418
|
// plain elbow through whatever lay in its way, and a card's mask made the line look like a connection to that card)
|
|
1142
1419
|
if (!dg.quick) free.filter(e => e.how === 'fallback').forEach(e => { ripUp(e); e.pts = gridRoute(e, 2, 320) || gridRoute(e, 2, 0) || e.pts; e.lbl = null; routed.push(e); placeLabel(e); });
|
|
@@ -1179,11 +1456,15 @@ function svgOf(geo, opts = {}) {
|
|
|
1179
1456
|
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>`;
|
|
1180
1457
|
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]})`])) };
|
|
1181
1458
|
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
|
|
1459
|
+
// the notation (0.15.0): each card's look, and each line as drawn, its ends on the cards' outlines (a map's cards only)
|
|
1460
|
+
const notation = map ? noteOf(opts.shapes) || 'box' : 'box', kidsOf = n => n.kids != null ? n.kids : opts.childCount ? opts.childCount(n.id) : 0;
|
|
1461
|
+
const ports = map ? sidePorts(geo) : null, looks = new Map(map ? nodes.map(n => [n, cardLook(n, notation, !!(kidsOf(n) || n.opens) && opts.openHint !== false, ports.get(n))]) : []);
|
|
1462
|
+
const drawn = edges.map(e => looks.size ? drawnPts(e, n => looks.get(n) || null) : e.pts);
|
|
1182
1463
|
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">
|
|
1183
1464
|
<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>`;
|
|
1184
1465
|
const families = geo.families || [], familyDepth = f => f.parent ? 1 + familyDepth(families.find(p => p.id === f.parent) || {}) : 0;
|
|
1185
|
-
families.slice().sort((a,b) => familyDepth(a)-familyDepth(b)).forEach(f => {
|
|
1186
|
-
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'})"/>
|
|
1466
|
+
families.slice().sort((a,b) => familyDepth(a)-familyDepth(b)).forEach(f => { // in c4 every box is a boundary, dashed as Structurizr draws one (a toned box is dashed in any notation)
|
|
1467
|
+
s += `<g class="family${f.tone ? ' toned' : ''}" data-family="${esc(f.id)}"${f.parent ? ` data-parent="${esc(f.parent)}"` : ''}${f.tone ? ` style="--um-tone:${toneOf(f)}"` : ''}><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'})"${notation === 'c4' ? ' stroke-dasharray="7 5"' : ''}/>
|
|
1187
1468
|
<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 ? `
|
|
1188
1469
|
<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>` : `
|
|
1189
1470
|
<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>`;
|
|
@@ -1205,7 +1486,7 @@ function svgOf(geo, opts = {}) {
|
|
|
1205
1486
|
for (let i = 0; i + 1 < geo.tour.length; i++) {
|
|
1206
1487
|
const a = geo.tour[i], b = geo.tour[i + 1], k = edges.findIndex(e => (e.from === a && e.to === b) || (e.both && e.from === b && e.to === a));
|
|
1207
1488
|
if (k < 0) return null;
|
|
1208
|
-
const p = edges[k].from === a ?
|
|
1489
|
+
const p = edges[k].from === a ? drawn[k] : [...drawn[k]].reverse();
|
|
1209
1490
|
if (pts.length) pts.push([pts[pts.length - 1][0], p[0][1]]); // through the card to the next port
|
|
1210
1491
|
const from = pts.length; pts.push(...p); ends.push([k, from, pts.length - 1, b]);
|
|
1211
1492
|
}
|
|
@@ -1218,40 +1499,50 @@ function svgOf(geo, opts = {}) {
|
|
|
1218
1499
|
const tourAnim = (vals, times) => `<animate attributeName="opacity" values="${vals}" keyTimes="${times.map(f2).join(';')}" dur="${TOUR}s" repeatCount="indefinite"/>`;
|
|
1219
1500
|
s += '<g class="edges">';
|
|
1220
1501
|
(geo.intake || []).forEach(b => {
|
|
1221
|
-
|
|
1502
|
+
const bp = roundPath(looks.size ? drawnPts({ pts: b.pts, t: geo.byId[b.to], ts: 'L' }, n => looks.get(n) || null) : b.pts); // its end on the card's outline, as a line's
|
|
1503
|
+
s += `<g class="intake"><title>${esc(b.label)} feeds ${esc((geo.byId[b.to] || {}).label || b.to)}</title><path d="${bp}" class="edge" stroke="var(--um-w-feeds)" marker-end="url(#${id}-feeds)"/>
|
|
1222
1504
|
<circle cx="${b.cx}" cy="${b.cy}" r="${b.r}" class="intake-chip${b.dashed ? ' dashed' : ''}"/>${b.logo ? `<image href="${esc(b.logo)}" x="${b.cx - 9}" y="${b.cy - 9}" width="18" height="18"/>` : b.mark ? `<text x="${b.cx}" y="${b.cy + 4.5}" text-anchor="middle" class="intake-mark">${esc(b.mark)}</text>` : ''}
|
|
1223
1505
|
<text x="${b.cx - b.r - 7}" y="${b.cy + 3.5}" text-anchor="end" class="intake-tx">${esc(b.label)}</text>
|
|
1224
|
-
${tour ? `<circle r="3.2" class="tour tour-packet" opacity="0"><animateMotion path="${
|
|
1506
|
+
${tour ? `<circle r="3.2" class="tour tour-packet" opacity="0"><animateMotion path="${bp}" 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>`;
|
|
1225
1507
|
});
|
|
1226
1508
|
edges.forEach((e, i) => {
|
|
1227
|
-
const st = lstyle(e), why = broke(e), d = roundPath(
|
|
1509
|
+
const st = lstyle(e), why = broke(e), d = roundPath(drawn[i]), mk = st.m + (e.weight > 1 ? '-w' + e.weight : '');
|
|
1510
|
+
const tip = 9 * (e.weight > 1 ? 1 + (e.weight - 1) * .35 : 1), fd = roundPath(trimLine(drawn[i], e.both ? tip : 0, tip)); // an arrowhead's length (marker refX)
|
|
1228
1511
|
if (tour && tour.legs[i]) { const [a, b] = tour.legs[i];
|
|
1229
1512
|
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>`; }
|
|
1230
1513
|
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"/>
|
|
1231
1514
|
<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})"` : ''}/>
|
|
1232
|
-
<path d="${
|
|
1515
|
+
<path d="${fd}" class="flowline" stroke="${st.c}"/>${(e.all || [e]).some(x => x.edited) && !e.strong ? (() => { let m = [e.pts[0], e.pts[1]];
|
|
1233
1516
|
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]; });
|
|
1234
1517
|
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) : ''}
|
|
1235
|
-
<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>`;
|
|
1518
|
+
<title>${e.step != null ? 'Step ' + esc(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>`;
|
|
1236
1519
|
});
|
|
1237
|
-
|
|
1520
|
+
// the tour's dot rides over the lines and boxes but under the cards: it goes into a card and comes out at its next
|
|
1521
|
+
// line, rather than across the card's name
|
|
1522
|
+
s += '</g>' + (tour ? `<g class="tour" aria-hidden="true"><circle r="5.5" class="tour-dot"><animateMotion path="${tour.d}" dur="${TOUR}s" repeatCount="indefinite"/></circle></g>` : '') + '<g class="nodes">';
|
|
1238
1523
|
nodes.forEach(n => {
|
|
1239
1524
|
const c = col(n.k), f = fill(n.k), removed = n.change === 'removed', planned = n.change === 'planned';
|
|
1240
|
-
const
|
|
1525
|
+
const L = looks.get(n) || null, S = L && L.S; // a shaped card (0.15.0); null: a box, drawn as before
|
|
1526
|
+
const pub = map && n.k === 'm-public', priv = map && n.k === 'm-script', ext = map && n.k === 'm-ext', verb = map && n.k === 'm-verb', hook = map && n.k === 'm-hook' && String(n.shape ?? '').toLowerCase() !== 'box';
|
|
1527
|
+
const dash = removed || ext || planned || (map && notation !== 'box' && n.k === 'external'); // an external card is dashed where a notation draws types (the legend says so in every notation)
|
|
1241
1528
|
const rx = verb ? n.h / 2 : map ? 10 : 9;
|
|
1242
|
-
const kids =
|
|
1529
|
+
const kids = kidsOf(n), stacked = !!(kids || n.opens); // opens a view, loaded or not (#47)
|
|
1243
1530
|
const subject = opts.subject === n.id;
|
|
1244
1531
|
const verbs = map && (n.verbs || []).length ? n.verbs : null;
|
|
1245
1532
|
const subTxt = n.noSub ? '' : n.sub || (map && n.origin ? n.origin : '') || (map ? (n.what ? n.what.replace(/[`*]/g, '').split(/(?<=\.)\s/)[0] : KIND[n.k][1]) : '');
|
|
1246
1533
|
const third = !!(verbs && subTxt && !priv && geo.subAndChips); // the view's subAndChips: the sub-line, then the chips on a third line (a script card shows no sub-line)
|
|
1247
1534
|
const sw = map ? n.w - 70 : n.w - 24; // map cards: text runs from 54 px in (after the mark) to 16 px from the right
|
|
1248
|
-
|
|
1249
|
-
|
|
1535
|
+
// a shape's face: the rows its mark and text use (a box's: the card). A short face takes a two-line sub-line to one, and
|
|
1536
|
+
// gives up the sub-line under a two-line name (the tooltip and the details panel keep it)
|
|
1537
|
+
const [ft, fb] = L ? S.face(L) : [n.y, n.y + n.h], fh = fb - ft, fy = (ft + fb) / 2;
|
|
1250
1538
|
const labelPx = n.emphasis ? 15 : 13;
|
|
1251
1539
|
const lw = map ? n.w - 70 : n.w - 72; // the mark (sigil, icon or logo tile) reaches 27 px in; the name keeps 36 px each side
|
|
1252
|
-
const
|
|
1540
|
+
const wraps = map && textW(n.label, labelPx) > lw && (!L || fh >= 30);
|
|
1541
|
+
const sub2 = map && subTxt && !verbs && textW(subTxt, 10) > sw && textW(n.label, n.emphasis ? 15 : 13) <= sw && (!L || fh >= 56) ? wrap2(subTxt, 10, sw) : null;
|
|
1542
|
+
const sub = subTxt && (!verbs || third) && !(L && wraps && fh < 44) ? (sub2 ? sub2.join(' ') : clauseFit(subTxt, 10, sw)) : '';
|
|
1543
|
+
const two = wraps ? wrap2(n.label, labelPx, lw) : null;
|
|
1253
1544
|
const lpx = two ? labelPx : textW(n.label, labelPx) > lw ? (measureText ? Math.max(11, labelPx * lw / textW(n.label, labelPx)) : Math.max(11, lw / (String(n.label).length * 0.6))) : labelPx;
|
|
1254
|
-
const ly =
|
|
1545
|
+
const ly = fy + (third ? -14 : sub || verbs ? -3 : 4) - (two ? 8 : 0) - (sub2 ? 6 : 0);
|
|
1255
1546
|
// A card is a button named by what it shows, in the order it shows it, so its visible text is a run of whole words of its
|
|
1256
1547
|
// name (WCAG 2.5.3, as axe checks it) at every zoom: what always shows comes first, then the sub-line and chips (faded out
|
|
1257
1548
|
// when zoomed far out). What is not shown, or not in full, goes in parentheses, which that check leaves out. The card's
|
|
@@ -1262,23 +1553,34 @@ function svgOf(geo, opts = {}) {
|
|
|
1262
1553
|
const label = two ? two.join(' ') : fit(n.label, lpx, lw, true), subShown = sub && !priv; // a script card hides its sub-line
|
|
1263
1554
|
said.push(part(label, n.label) + (subTxt && !subShown ? ` (${subTxt})` : ''));
|
|
1264
1555
|
const parts = [`<title>${esc(n.label)}${subTxt ? ' · ' + esc(subTxt) : ''}${n.note ? '\n' + esc(n.note) : ''}${stacked ? '\nDouble-click to open' : ''}</title>`];
|
|
1265
|
-
if (
|
|
1266
|
-
|
|
1267
|
-
|
|
1268
|
-
parts.push(`<
|
|
1269
|
-
|
|
1270
|
-
|
|
1271
|
-
|
|
1556
|
+
if (L) { // a shape: its outline twice behind it when stacked, then the mask, the body and its details
|
|
1557
|
+
const o = S.out(L);
|
|
1558
|
+
if (stacked) parts.push([8, 4].map(k => `<path d="${o}" transform="translate(${k} ${k})" class="n-stack" stroke="${c}"/>`).join(''));
|
|
1559
|
+
if (pub) parts.push(`<path d="${S.out(L, 4)}" class="n-ring" stroke="${c}"/>`);
|
|
1560
|
+
parts.push(`<path d="${o}" class="n-mask"/><path d="${o}" class="n-body" fill="${f}" stroke="${c}"${dash ? ' stroke-dasharray="6 4"' : ''}${removed ? ' opacity=".6"' : ''}/>${S.det ? S.det(L, c, n.k) : ''}`); }
|
|
1561
|
+
else {
|
|
1562
|
+
if (stacked) parts.push(`<rect x="${n.x + 8}" y="${n.y + 8}" width="${n.w}" height="${n.h}" rx="${rx}" class="n-stack" stroke="${c}"/><rect x="${n.x + 4}" y="${n.y + 4}" width="${n.w}" height="${n.h}" rx="${rx}" class="n-stack" stroke="${c}"/>`);
|
|
1563
|
+
if (pub) parts.push(`<rect x="${n.x - 4}" y="${n.y - 4}" width="${n.w + 8}" height="${n.h + 8}" rx="14" class="n-ring" stroke="${c}"/>`);
|
|
1564
|
+
if (hook) { const hx = Math.min(16, n.h / 3), pts = `${n.x + hx},${n.y} ${n.x + n.w - hx},${n.y} ${n.x + n.w},${n.y + n.h / 2} ${n.x + n.w - hx},${n.y + n.h} ${n.x + hx},${n.y + n.h} ${n.x},${n.y + n.h / 2}`;
|
|
1565
|
+
parts.push(`<polygon points="${pts}" class="n-mask"/><polygon points="${pts}" class="n-body" fill="${f}" stroke="${c}" stroke-linejoin="round"/>`); }
|
|
1566
|
+
else parts.push(`<rect x="${n.x}" y="${n.y}" width="${n.w}" height="${n.h}" rx="${rx}" class="n-mask"/>
|
|
1567
|
+
<rect x="${n.x}" y="${n.y}" width="${n.w}" height="${n.h}" rx="${rx}" class="n-body" fill="${f}" stroke="${c}" ${dash ? 'stroke-dasharray="6 4"' : ''} ${removed ? 'opacity=".6"' : ''}/>`); }
|
|
1568
|
+
// the mark, centred on the face; in the uml notation a box's own sigil gives way to UML's component icon
|
|
1569
|
+
const glyph = L && SIGIL[L.sh] ? L.sh : notation === 'uml' && !L && !String(n.k).startsWith('m-') ? 'component' : n.k; // a person's, robot's, phone's, terminal's, folder's or node's own mark
|
|
1570
|
+
parts.push((() => { const I = (opts.icons || {})[n.icon], x = map ? n.x + 14 : n.x + (verb ? 14 : hook ? 17 : 9), y = map ? fy - 14 : n.y + (priv ? 8 : 11), M = map ? 28 : 16; // map: one 28 px mark, left, centred
|
|
1272
1571
|
return I && I.src ? `<rect x="${x - M * 3 / 28}" y="${y - M * 3 / 28}" width="${M * 34 / 28}" height="${M * 34 / 28}" rx="${M / 4}" class="n-logo${I.dark ? ' dark' : ''}"/><image href="${I.src}" x="${x}" y="${y}" width="${M}" height="${M}"/>`
|
|
1273
1572
|
: I && I.g ? `<g transform="translate(${x} ${y}) scale(${M / 24})" class="n-sigil" fill="none" stroke="${c}" stroke-width="${1.75 * 24 / M}" stroke-linecap="round" stroke-linejoin="round">${I.g}</g>`
|
|
1274
|
-
: `<g transform="translate(${x} ${y}) scale(${M / 16})" class="n-sigil" fill="none" stroke="${c}" stroke-width="${1.75 * 16 / M}" stroke-linecap="round" stroke-linejoin="round">${SIGIL[
|
|
1573
|
+
: `<g transform="translate(${x} ${y}) scale(${M / 16})" class="n-sigil" fill="none" stroke="${c}" stroke-width="${1.75 * 16 / M}" stroke-linecap="round" stroke-linejoin="round">${SIGIL[glyph]}</g>`; })());
|
|
1275
1574
|
parts.push(`<text x="${tx0}" y="${ly}" text-anchor="${anchor}" class="n-label" data-o="${esc(n.id)}" data-t="nl" style="font-size:${lpx.toFixed(1)}px" ${removed ? 'text-decoration="line-through"' : ''}>${two ? lines(two, 15) : esc(label)}</text>`);
|
|
1575
|
+
const ty = tabLine(n, L); // the line the tabs, ⤢, the change badge and a step sit on
|
|
1276
1576
|
if (map) { let tx = n.x + 12; mapTabs(n, c).forEach(t => { const tw = t.w; // each badge is its own tab, clickable on its own
|
|
1277
|
-
parts.push(`<g class="pub-tab"${t.badge != null ? ` data-badge="${t.badge}"` : ''}><rect x="${tx}" y="${
|
|
1577
|
+
parts.push(`<g class="pub-tab"${t.badge != null ? ` data-badge="${t.badge}"` : ''}><rect x="${tx}" y="${ty - 8}" width="${tw}" height="15" rx="4" fill="${t.fill}"/><text x="${tx + tw / 2}" y="${ty + 3}" text-anchor="middle" class="tab-tx">${esc(t.text)}</text></g>`);
|
|
1278
1578
|
said.push(t.badge != null ? badgesOf(n)[t.badge].text : t.text.toLowerCase()); tx += tw + 4; }); }
|
|
1279
1579
|
if (map && n.edited) parts.push(`<g class="edited"><title>Edited</title><circle cx="${n.x + n.w - 10}" cy="${n.y + n.h - 10}" r="4" class="edited-dot"/></g>`);
|
|
1280
|
-
|
|
1281
|
-
|
|
1580
|
+
// a step on a shape's tab line (a person's shoulder), but never down over the arrow of a line into its left side: as low as
|
|
1581
|
+
// keeps 6 px clear of the highest one, and no higher than the box's corner
|
|
1582
|
+
if (map && n.step) { const t = String(n.step), sw = Math.max(20, sansW(t, 9.5, .9) + 10), sy = L ? Math.max(n.y, Math.min(ty, L.portL - 16)) : ty;
|
|
1583
|
+
parts.push(`<g class="step"><title>Step ${esc(t)}</title><rect x="${n.x - 10}" y="${sy - 10}" width="${sw}" height="20" rx="10" class="stp"/><text x="${n.x - 10 + sw / 2}" y="${sy + 3.5}" text-anchor="middle" class="stp-tx">${esc(t)}</text></g>`);
|
|
1282
1584
|
said.push(`(step) ${t}`); }
|
|
1283
1585
|
if (n.tag) { // author name tag under the card: a bigger headshot + @handle (avatar inlined by render)
|
|
1284
1586
|
const T = n.tag, cxp = T.x + 11, cyp = T.y + 11, cid = 'av-' + id + '-' + String(n.id).replace(/[^\w-]/g, '_');
|
|
@@ -1290,14 +1592,14 @@ return I && I.src ? `<rect x="${x - M * 3 / 28}" y="${y - M * 3 / 28}" width="${
|
|
|
1290
1592
|
said.push(`(by${T.name ? ' ' + T.name : ''}) ${plain([ini, T.text].filter(Boolean).join(' '))}`);
|
|
1291
1593
|
}
|
|
1292
1594
|
const hint = stacked && opts.openHint !== false;
|
|
1293
|
-
if (hint) { const kx = map ? n.x + n.w - 44 : n.x + n.w - 40, ky = map ?
|
|
1595
|
+
if (hint) { const kx = map ? n.x + n.w - 44 : n.x + n.w - 40, ky = map ? ty - 8 : n.y + n.h - 11;
|
|
1294
1596
|
parts.push(`<g class="open-hint${map ? '' : ' depth-read'}"><title>Open the inner view (or double-click the card)</title><rect x="${kx}" y="${ky}" width="36" height="16" rx="8" class="n-mask" stroke="${c}"/><text x="${kx + 18}" y="${ky + 12}" text-anchor="middle" class="badge-tx" fill="${c}">⤢${kids ? ' ' + kids : ''}</text></g>`);
|
|
1295
1597
|
said.push(kids ? `${kids} (cards inside, opens the inner view)` : '(opens the inner view)'); }
|
|
1296
1598
|
const ch = CHG[String(n.change || '').toLowerCase()];
|
|
1297
1599
|
if (ch) {
|
|
1298
1600
|
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
|
|
1299
|
-
parts.push(`<rect x="${bx}" y="${
|
|
1300
|
-
<text x="${bx + bw / 2}" y="${
|
|
1601
|
+
parts.push(`<rect x="${bx}" y="${ty - 8}" width="${bw}" height="16" rx="8" fill="var(--um-mask)" stroke="var(--um-${ch[0]})"/>
|
|
1602
|
+
<text x="${bx + bw / 2}" y="${ty + 3}" text-anchor="middle" class="badge-tx" fill="var(--um-${ch[0]})">${ch[1]}</text>`);
|
|
1301
1603
|
said.push(ch[1].toLowerCase());
|
|
1302
1604
|
}
|
|
1303
1605
|
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>`);
|
|
@@ -1316,21 +1618,20 @@ return I && I.src ? `<rect x="${x - M * 3 / 28}" y="${y - M * 3 / 28}" width="${
|
|
|
1316
1618
|
}
|
|
1317
1619
|
if (map) {
|
|
1318
1620
|
const ports = new Map();
|
|
1319
|
-
edges.forEach((e, i) => { const point = e.from === n.id ? e.p1 : e.to === n.id ? e.p2 : null; if (!point) return;
|
|
1621
|
+
edges.forEach((e, i) => { const point = e.from === n.id ? (L ? drawn[i][0] : e.p1) : e.to === n.id ? (L ? drawn[i][drawn[i].length - 1] : e.p2) : null; if (!point) return; // a shape's ports on its outline
|
|
1320
1622
|
const key = point.join(','); const port = ports.get(key) || { point, indices: [] }; port.indices.push(i); ports.set(key, port); });
|
|
1321
1623
|
ports.forEach(({ point, indices }) => { parts.push(`<circle cx="${point[0]}" cy="${point[1]}" r="3.5" class="n-port" data-edges="${indices.join(',')}" stroke="${c}"/>`); });
|
|
1322
1624
|
}
|
|
1323
1625
|
const name = said.join(', ') + (n.edited ? ' (edited)' : '') + (stacked && !hint ? ' (opens inner view)' : '');
|
|
1324
|
-
s += `<g class="node${pub ? ' pub' : ''}${priv ? ' priv' : ''}${stacked ? ' has-child' : ''}${subject ? ' subject' : ''}${n.emphasis ? ' main-step' : ''}${planned ? ' planned' : ''}${tour && n.id in tour.at ? ' tour-stop' : ''}"${tour && n.id in tour.at ? ` style="animation-delay:${(tour.at[n.id] * TOUR - .32).toFixed(2)}s"` : ''} data-id="${esc(n.id)}" tabindex="${map ? 0 : -1}" role="${map ? 'button' : 'img'}" aria-label="${esc(name)}">
|
|
1626
|
+
s += `<g class="node${pub ? ' pub' : ''}${priv ? ' priv' : ''}${stacked ? ' has-child' : ''}${subject ? ' subject' : ''}${n.emphasis ? ' main-step' : ''}${planned ? ' planned' : ''}${tour && n.id in tour.at ? ' tour-stop' : ''}"${tour && n.id in tour.at ? ` style="animation-delay:${(tour.at[n.id] * TOUR - .32).toFixed(2)}s"` : ''}${L ? ` data-shape="${L.sh}" data-face="${r2(ft)} ${r2(fb)}"` : ''} data-id="${esc(n.id)}" tabindex="${map ? 0 : -1}" role="${map ? 'button' : 'img'}" aria-label="${esc(name)}">
|
|
1325
1627
|
${parts.join('\n')}
|
|
1326
1628
|
</g>`;
|
|
1327
1629
|
});
|
|
1328
|
-
if (tour) s += `<g class="tour" aria-hidden="true"><circle r="5.5" class="tour-dot"><animateMotion path="${tour.d}" dur="${TOUR}s" repeatCount="indefinite"/></circle></g>`;
|
|
1329
1630
|
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
|
|
1330
1631
|
s += '</g><g class="keys">';
|
|
1331
1632
|
edges.forEach((e, i) => { const said = e.label ? `: ${e.label}${e.sublabel ? ` (${e.sublabel})` : ''}` : '';
|
|
1332
1633
|
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('; ')}` : ''}`;
|
|
1333
|
-
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(
|
|
1634
|
+
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(drawn[i])}" class="key-ring"/></g>`; });
|
|
1334
1635
|
}
|
|
1335
1636
|
s += '</g><g class="labels depth-read">';
|
|
1336
1637
|
edges.forEach((e, i) => {
|
|
@@ -1338,7 +1639,7 @@ return I && I.src ? `<rect x="${x - M * 3 / 28}" y="${y - M * 3 / 28}" width="${
|
|
|
1338
1639
|
const L = e.lbl;
|
|
1339
1640
|
if (map) {
|
|
1340
1641
|
const sw = L.sw || 16, cy = L.y + L.h / 2; // the step mark: a circle, or a pill as wide as a longer number (#82)
|
|
1341
|
-
const st = L.step != null ? `${sw > 16 ? `<rect x="${L.x + 3}" y="${cy - 8}" width="${sw}" height="16" rx="8" class="e-step"/>` : `<circle cx="${L.x + 11}" cy="${cy}" r="8" class="e-step"/>`}<text x="${L.x + 3 + sw / 2}" y="${cy + 3.3}" text-anchor="middle" class="e-step-tx">${esc(L.step)}</text>` : '';
|
|
1642
|
+
const st = L.step != null ? `${sw > 16 ? `<rect x="${L.x + 3}" y="${cy - 8}" width="${sw}" height="16" rx="8" class="e-step"/>` : `<circle cx="${L.x + 11}" cy="${cy}" r="8" class="e-step"/>`}<text x="${L.x + 3 + sw / 2}" y="${cy + 3.3}" text-anchor="middle" class="e-step-tx" data-t="el" data-o="${esc(e.from)}>${esc(e.to)}">${esc(L.step)}</text>` : '';
|
|
1342
1643
|
const ax = L.step != null ? L.x + 7 + sw : L.x + L.w / 2, an = L.step != null ? '' : 'text-anchor="middle"';
|
|
1343
1644
|
const tx = L.text ? `<text x="${ax}" y="${L.y + 14}" ${an} class="e-label" data-t="el" data-o="${esc(e.from)}>${esc(e.to)}">${esc(L.text)}</text>` : '';
|
|
1344
1645
|
const sb = L.sub ? `<text x="${ax}" y="${L.y + 26}" ${an} class="e-sub" data-t="el" data-o="${esc(e.from)}>${esc(e.to)}">${esc(L.sub)}</text>` : '';
|
|
@@ -1396,14 +1697,14 @@ class Camera {
|
|
|
1396
1697
|
stopAnimation() {
|
|
1397
1698
|
cancelAnimationFrame(this.anim); this.anim = 0;
|
|
1398
1699
|
// Resize/refit interrupts camera motion. Navigation awaits that motion, so cancellation
|
|
1399
|
-
// must settle its promise too; otherwise go() never releases its busy flag.
|
|
1400
|
-
const done = this.animationDone; this.animationDone = null; if (done) done();
|
|
1700
|
+
// must settle its promise too; otherwise go() never releases its busy flag. It settles false: cut short (a story's step glides again)
|
|
1701
|
+
const done = this.animationDone; this.animationDone = null; if (done) done(false);
|
|
1401
1702
|
}
|
|
1402
1703
|
set(s) { this.stopAnimation(); this.s = this.clamp({ ...this.s, ...s }); this.apply(); }
|
|
1403
|
-
animate(to, ms = 380) {
|
|
1704
|
+
animate(to, ms = 380) { // resolves true once there, false when cut short
|
|
1404
1705
|
this.stopAnimation();
|
|
1405
1706
|
const from = { ...this.s }, dest = this.clamp({ ...this.s, ...to });
|
|
1406
|
-
if (reducedMotion()) { this.set(dest); return Promise.resolve(); }
|
|
1707
|
+
if (this.o.still ? this.o.still() : reducedMotion()) { this.set(dest); return Promise.resolve(true); }
|
|
1407
1708
|
return new Promise(res => {
|
|
1408
1709
|
this.animationDone = res;
|
|
1409
1710
|
const t0 = performance.now(), ease = t => t < .5 ? 4 * t * t * t : 1 - Math.pow(-2 * t + 2, 3) / 2;
|
|
@@ -1412,7 +1713,7 @@ class Camera {
|
|
|
1412
1713
|
const t = Math.min(1, (now - t0) / ms), e = ease(t);
|
|
1413
1714
|
this.s = { cx: from.cx + (dest.cx - from.cx) * e, cy: from.cy + (dest.cy - from.cy) * e, k: Math.exp(lk0 + (lk1 - lk0) * e) };
|
|
1414
1715
|
this.apply();
|
|
1415
|
-
if (t < 1) this.anim = requestAnimationFrame(step); else { this.anim = 0; this.animationDone = null; res(); }
|
|
1716
|
+
if (t < 1) this.anim = requestAnimationFrame(step); else { this.anim = 0; this.animationDone = null; res(true); }
|
|
1416
1717
|
};
|
|
1417
1718
|
this.anim = requestAnimationFrame(step);
|
|
1418
1719
|
});
|
|
@@ -1455,7 +1756,7 @@ class Camera {
|
|
|
1455
1756
|
this.ptrs.set(e.pointerId, [e.clientX, e.clientY]);
|
|
1456
1757
|
this.start = { x: e.clientX, y: e.clientY, s: { ...this.s }, dist: this.pinchDist() };
|
|
1457
1758
|
this.moved = 0; this.dragged = false;
|
|
1458
|
-
|
|
1759
|
+
this.stopAnimation(); // settles the glide's promise: a press during a navigation or a story's step lets it go on
|
|
1459
1760
|
}
|
|
1460
1761
|
pinchDist() { const p = [...this.ptrs.values()]; return p.length > 1 ? Math.hypot(p[0][0] - p[1][0], p[0][1] - p[1][1]) : 0; }
|
|
1461
1762
|
onMove(e) {
|
|
@@ -1489,6 +1790,11 @@ const teller = (issues, refs) => (message, ...about) => { issues.push(message);
|
|
|
1489
1790
|
// a detour: a route 2.5 times longer than the straight distance between its ends, or than 100 px; [its length, that distance]
|
|
1490
1791
|
const detour = pts => { const len = pts.slice(1).reduce((a, p, k) => a + Math.abs(p[0] - pts[k][0]) + Math.abs(p[1] - pts[k][1]), 0);
|
|
1491
1792
|
const d = Math.hypot(pts[pts.length - 1][0] - pts[0][0], pts[pts.length - 1][1] - pts[0][1]); return len > 2.5 * Math.max(d, 100) ? [len, d] : null; };
|
|
1793
|
+
// behind a card a line meets on its side `side` (R, L, T or B): past the opposite side
|
|
1794
|
+
const behind = (n, side, [x, y]) => side === 'R' ? x < n.x - .5 : side === 'L' ? x > n.x + n.w + .5 : side === 'B' ? y < n.y - .5 : y > n.y + n.h + .5;
|
|
1795
|
+
// [the end, its side] that a line runs round: its route goes behind one of its own ends while its other end is not there. A
|
|
1796
|
+
// long line passed the detour rule however it went round (clinic's reminder: over the patient, down its far side, under it)
|
|
1797
|
+
const roundEnd = e => [[e.s, e.fs, e.pts[e.pts.length - 1]], [e.t, e.ts, e.pts[0]]].find(([n, side, far]) => n && side && !behind(n, side, far) && e.pts.some(p => behind(n, side, p)));
|
|
1492
1798
|
function routeAudit(geo, where, only, refs) { // only: a set of edges; counts just the issues that involve one of them
|
|
1493
1799
|
const sk = e => only && !only.has(e);
|
|
1494
1800
|
const issues = [], say = teller(issues, refs);
|
|
@@ -1525,6 +1831,7 @@ function routeAudit(geo, where, only, refs) { // only: a set of edges; counts ju
|
|
|
1525
1831
|
geo.nodes.forEach(n => { if (n.id === e.from || n.id === e.to) return;
|
|
1526
1832
|
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); });
|
|
1527
1833
|
const dt = detour(e.pts); if (dt) say(`${where}: edge ${name(e)} takes a detour (${Math.round(dt[0])}px for ${Math.round(dt[1])}px)`, e);
|
|
1834
|
+
const r = roundEnd(e); if (r) say(`${where}: edge ${name(e)} runs round its own end "${r[0].label}"`, e, r[0].box ? (geo.families || []).find(f => f.id === r[0].id) : r[0]);
|
|
1528
1835
|
// reading order: every wire runs left to right, except one that closes a loop or answers its pair (keepPairs)
|
|
1529
1836
|
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);
|
|
1530
1837
|
});
|
|
@@ -1573,7 +1880,7 @@ function overlapIssues(geo, where, refs) {
|
|
|
1573
1880
|
}
|
|
1574
1881
|
function leaveIssues(geo, where, refs) {
|
|
1575
1882
|
const issues = [], say = teller(issues, refs);
|
|
1576
|
-
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
|
|
1883
|
+
if (geo.map) geo.edges.forEach(e => { const L = e.lbl; if (!L || (!e.label && L.step == null) || !e.pts) return; // a label, or a step's mark, must leave some of its own line visible
|
|
1577
1884
|
const c = [L.x + L.w / 2, L.y + L.h / 2], P = e.pts; let s = 0, at = null, half = 0, tot = 0;
|
|
1578
1885
|
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]);
|
|
1579
1886
|
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;
|
|
@@ -1581,7 +1888,7 @@ function leaveIssues(geo, where, refs) {
|
|
|
1581
1888
|
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; } }
|
|
1582
1889
|
tot += len; }
|
|
1583
1890
|
if (at == null) return; const room = Math.min(at - half, tot - at - half);
|
|
1584
|
-
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); });
|
|
1891
|
+
if (room < 16) say(`${where}: label "${e.label || L.step}" leaves its line ${Math.max(0, Math.round(room))} px (${e.s.label}→${e.t.label})`, e); });
|
|
1585
1892
|
return issues;
|
|
1586
1893
|
}
|
|
1587
1894
|
|
|
@@ -1589,7 +1896,7 @@ function leaveIssues(geo, where, refs) {
|
|
|
1589
1896
|
function auditSvg(svg, geo, where) {
|
|
1590
1897
|
const issues = overlapIssues(geo, where), R = box;
|
|
1591
1898
|
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);
|
|
1592
|
-
const nodes = geo.nodes;
|
|
1899
|
+
const nodes = geo.nodes, faces = Object.fromEntries([...svg.querySelectorAll('.node[data-face]')].map(g => [g.dataset.id, g.dataset.face.split(' ').map(Number)])); // a shape's face (0.15.0)
|
|
1593
1900
|
T.forEach(a => {
|
|
1594
1901
|
if (a.t === 'lt' || a.t === 'ft') {
|
|
1595
1902
|
nodes.forEach(n => { if (hit(a.b, R(n), -1)) issues.push(`${where}: lane "${a.txt}": header covers node "${n.label}"`); });
|
|
@@ -1599,7 +1906,9 @@ function auditSvg(svg, geo, where) {
|
|
|
1599
1906
|
} else {
|
|
1600
1907
|
const own = geo.byId[a.o];
|
|
1601
1908
|
if (own && (a.b.x < own.x + 1 || a.b.x + a.b.w > own.x + own.w - 1)) issues.push(`${where}: text "${a.txt}" overflows node "${own.label}"`);
|
|
1602
|
-
|
|
1909
|
+
const face = own && faces[own.id], fy = face ? (face[0] + face[1]) / 2 : own && own.y + own.h / 2; // a shape's mark and text keep to its face
|
|
1910
|
+
if (own && geo.map && (a.t === 'nl' || a.t === 'ns') && hit(a.b, { x: own.x + 11, y: fy - 17, w: 34, h: 34 }, 2)) issues.push(`${where}: text "${a.txt}" touches the mark on node "${own.label}"`);
|
|
1911
|
+
if (face && (a.b.y < face[0] - 1 || a.b.y + a.b.h > face[1] + 1)) issues.push(`${where}: text "${a.txt}" overflows the shape of node "${own.label}"`);
|
|
1603
1912
|
nodes.forEach(n => { if (n !== own && hit(a.b, R(n), -1)) issues.push(`${where}: text "${a.txt}" overlaps node "${n.label}"`); });
|
|
1604
1913
|
}
|
|
1605
1914
|
});
|
|
@@ -1914,8 +2223,15 @@ function mapContext(model, opts = {}) {
|
|
|
1914
2223
|
}
|
|
1915
2224
|
|
|
1916
2225
|
function buildView(vid, opts = {}) {
|
|
1917
|
-
const g = buildGeo(vid)
|
|
1918
|
-
return { g,
|
|
2226
|
+
const g = buildGeo(vid);
|
|
2227
|
+
return { g, ...drawView(vid, g, opts) };
|
|
2228
|
+
}
|
|
2229
|
+
// a view's notation (0.15.0): its own `shapes`, else the model's, else box; drawView: the picture of a geometry in one,
|
|
2230
|
+
// with no layout (a change of notation draws the cards again and nothing else)
|
|
2231
|
+
const notationOf = vid => noteOf((V[vid] || {}).shapes) || noteOf(D.shapes) || 'box';
|
|
2232
|
+
function drawView(vid, g, opts = {}) {
|
|
2233
|
+
const v = V[vid], shapes = noteOf(opts.shapes) || notationOf(vid);
|
|
2234
|
+
return { markup: svgOf(g, { title: v.title, subject: subjectOf(v), icons: D.icons, broken: brokenIn(vid), ...opts, shapes }), shapes };
|
|
1919
2235
|
}
|
|
1920
2236
|
// the rules a view's lines break (#70), by model edge id: svgOf draws such a line rose, with a mark that names them
|
|
1921
2237
|
let ruled = null;
|
|
@@ -1944,7 +2260,8 @@ function mapContext(model, opts = {}) {
|
|
|
1944
2260
|
.map(e => e.all.length < 2 ? e : { ...e, label: (e.label || '') + ' +' + (e.all.length - 1), when: e.all.map(x => (x.step != null ? x.step + '. ' : '') + (x.label || '')).join('\n') });
|
|
1945
2261
|
const out = merged.map(e => ({ ...e, back: !(V[vid] || {}).keepPairs && backKeys && backKeys.has(e.from + '>' + e.to) }));
|
|
1946
2262
|
const pair = {}; // A→B plus B→A reads as one two-way edge
|
|
1947
|
-
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;
|
|
2263
|
+
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;
|
|
2264
|
+
if (r.all.some(x => x.step != null)) r.when = r.all.map(x => (x.step != null ? x.step + '. ' : '') + (x.label || '')).join('\n'); else if (e.when) r.when = [r.when, e.when].filter(Boolean).join(' · '); return false; } pair[e.from + '>' + e.to] = e; return true; })
|
|
1948
2265
|
.map(e => { const all = e.all || [e], subs = [...new Set(all.map(x => x.sublabel).filter(x => x != null && x !== '').map(String))];
|
|
1949
2266
|
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) }; });
|
|
1950
2267
|
}
|
|
@@ -2073,7 +2390,7 @@ function mapContext(model, opts = {}) {
|
|
|
2073
2390
|
const viewKey = vid => {
|
|
2074
2391
|
const v = V[vid], ids = (v.nodeIds || []).filter(i => N[i]), ns = ids.map(i => nodeIn(i, vid));
|
|
2075
2392
|
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]);
|
|
2076
|
-
return JSON.stringify([v, ns, viewEdges(v), ids.map(i => [kidCount(i, vid), opensOf(i, vid)]), icons, who]);
|
|
2393
|
+
return JSON.stringify([{ ...v, shapes: undefined }, ns, viewEdges(v), ids.map(i => [kidCount(i, vid), opensOf(i, vid)]), icons, who]); // a view's notation lays nothing out
|
|
2077
2394
|
};
|
|
2078
2395
|
/* ---------- unfolding in place (#71): a stacked card opens into a box on the view it is on ----------
|
|
2079
2396
|
The card becomes a box with its id and name, holding its inner view's own cards: those not on this view already (the
|
|
@@ -2195,7 +2512,7 @@ function mapContext(model, opts = {}) {
|
|
|
2195
2512
|
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); }); } }))();
|
|
2196
2513
|
const geoCache = {};
|
|
2197
2514
|
return { D, views, nodes, N, V, rootId, nodeIn, childOf, opensOf, subjectOf, viewsWith, kidsOf, chain, viewEdges, isState, planOf: withM(planOf), planCache,
|
|
2198
|
-
layoutCache, geoCache, buildGeo: withM(buildGeo), buildView: withM(buildView), optimize: withM(optimize), flowPos, kidCount, viewKey, brokenIn, unfoldable, unfolded };
|
|
2515
|
+
layoutCache, geoCache, buildGeo: withM(buildGeo), buildView: withM(buildView), drawView: withM(drawView), notationOf, optimize: withM(optimize), flowPos, kidCount, viewKey, brokenIn, unfoldable, unfolded };
|
|
2199
2516
|
}
|
|
2200
2517
|
|
|
2201
2518
|
const edgeRefIds = e => [...new Set((e.all || [e]).map(x => x._id).filter(Boolean))];
|
|
@@ -2212,7 +2529,7 @@ export function layout(model, viewId, options = {}) {
|
|
|
2212
2529
|
}
|
|
2213
2530
|
|
|
2214
2531
|
/** The layout check's geometry for one view, without a browser: overlapping cards, crossing or shared lines, detours,
|
|
2215
|
-
* lines against reading order or through a card, labels on lines or off their own, boxes and lanes. The page's check
|
|
2532
|
+
* lines round their own ends, against reading order or through a card, labels on lines or off their own, boxes and lanes. The page's check
|
|
2216
2533
|
* (`umlimator.py check`) adds what it measures in the DOM: text that overflows its card or touches its mark, titles
|
|
2217
2534
|
* that overflow, edge labels on cards or on each other. Each issue names the cards and lines it is about. */
|
|
2218
2535
|
export function check(model, viewId, options = {}) {
|
|
@@ -2236,15 +2553,24 @@ export function validate(model) {
|
|
|
2236
2553
|
if (!n || typeof n.id !== 'string' || !n.id) return err(at + '.id', 'a card needs an id');
|
|
2237
2554
|
if (ids.has(n.id)) err(at + '.id', `card id "${n.id}" is used twice`); ids.add(n.id);
|
|
2238
2555
|
const k = String(n.kind ?? '').toLowerCase();
|
|
2239
|
-
if (n.kind != null && !(
|
|
2556
|
+
if (n.kind != null && !(hasKey(KIND, k) || hasKey(MAPK, k) || hasKey(ALIAS, k))) warn(at + '.kind', `unknown kind "${n.kind}": drawn as a script card`);
|
|
2240
2557
|
if (n.owner != null && typeof n.owner !== 'string') warn(at + '.owner', 'an owner is a name: ignored');
|
|
2558
|
+
shapeIs(n, at);
|
|
2241
2559
|
};
|
|
2560
|
+
const shapeIs = (n, at) => { if (n && n.shape != null && !SHAPE_NAMES.includes(String(n.shape).toLowerCase())) warn(at + '.shape', `unknown shape "${n.shape}": the notation draws it by its kind (the shapes: ${SHAPE_NAMES.join(', ')})`); };
|
|
2561
|
+
const notation = (x, at) => { if (x != null && !noteOf(x)) warn(at, `"${x}" is not a notation (${NOTATIONS.join(', ')}): drawn as box`); }; // card shapes (0.15.0)
|
|
2562
|
+
notation(m.shapes, 'shapes');
|
|
2242
2563
|
arr(m.nodes).forEach((n, i) => card(n, `nodes[${i}]`));
|
|
2243
2564
|
views.forEach((v, i) => arr(v.nodes).forEach((n, j) => card(n, `views[${i}].nodes[${j}]`)));
|
|
2244
2565
|
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); });
|
|
2245
2566
|
if (m.root != null && !vids.has(m.root)) err('root', `root "${m.root}" is not a view`);
|
|
2246
2567
|
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`); };
|
|
2247
2568
|
arr(m.nodes).forEach((n, i) => later(n, `nodes[${i}]`));
|
|
2569
|
+
views.forEach((v, i) => notation(v.shapes, `views[${i}].shapes`));
|
|
2570
|
+
// c4 draws a frontend card as a browser: a person's icon on one says the model means a person (0.15.0)
|
|
2571
|
+
const inC4 = new Set(views.filter(v => (noteOf(v.shapes) || noteOf(m.shapes)) === 'c4').flatMap(v => arr(v.nodeIds)));
|
|
2572
|
+
arr(m.nodes).forEach((n, i) => { if (!n || !inC4.has(n.id) || n.shape != null || !['user', 'users', 'usercheck'].includes(n.icon)) return;
|
|
2573
|
+
if (shapeOf({ ...n, k: kindOf(n.kind, true) }, 'c4') === 'browser') warn(`nodes[${i}].kind`, `"${n.label || n.id}" has a person's icon, but c4 draws a ${n.kind} card as a browser: kind "person" (or shape "person") draws a person`); });
|
|
2248
2574
|
const line = (e, at, ends) => {
|
|
2249
2575
|
if (!e || typeof e !== 'object') return err(at, 'a line is an object with from and to');
|
|
2250
2576
|
['from', 'to'].forEach(k => { if (!ends.has(e[k])) err(`${at}.${k}`, `"${e[k]}" is neither a card nor a box`); });
|
|
@@ -2252,6 +2578,8 @@ export function validate(model) {
|
|
|
2252
2578
|
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`);
|
|
2253
2579
|
['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`); });
|
|
2254
2580
|
['latency', 'cost'].forEach(k => { if (e[k] != null && !(typeof e[k] === 'number' && e[k] >= 0)) warn(`${at}.${k}`, `${k} ${JSON.stringify(e[k])} is not a number of 0 or more: counted as 0`); }); // budgets (#95)
|
|
2581
|
+
if (e.step != null && !(typeof e.step === 'number' ? e.step >= 0 : typeof e.step === 'string' && e.step.length >= 1 && e.step.length <= 3))
|
|
2582
|
+
warn(at + '.step', `step ${JSON.stringify(e.step)} is not a number of 0 or more, or text of 1 to 3 characters (2a): drawn as written`);
|
|
2255
2583
|
};
|
|
2256
2584
|
// a model line may end on any view's box (#28); a view's own line only on that view's boxes
|
|
2257
2585
|
const anyBox = new Set(views.flatMap(v => arr(v.containers).map(c => c && c.id)));
|
|
@@ -2261,7 +2589,7 @@ export function validate(model) {
|
|
|
2261
2589
|
const at = `views[${i}]`, mine = new Set(arr(v.nodeIds)), boxes = {};
|
|
2262
2590
|
if (v.parent != null && !vids.has(v.parent) && !ids.has(v.parent)) err(at + '.parent', `parent "${v.parent}" is not a view`);
|
|
2263
2591
|
arr(v.nodeIds).forEach((id, j) => { if (!ids.has(id)) err(`${at}.nodeIds[${j}]`, `"${id}" is not a card`); });
|
|
2264
|
-
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}`); });
|
|
2592
|
+
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}`); shapeIs(o, `${at}.nodeOverrides.${id}`); });
|
|
2265
2593
|
arr(v.nodes).forEach((n, j) => later(n, `${at}.nodes[${j}]`));
|
|
2266
2594
|
['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`); }));
|
|
2267
2595
|
arr(v.browserOrder).forEach((id, j) => { if (!vids.has(id)) err(`${at}.browserOrder[${j}]`, `"${id}" is not a view`); });
|
|
@@ -2270,6 +2598,7 @@ export function validate(model) {
|
|
|
2270
2598
|
arr(v.containers).forEach((c, j) => { if (!c) return;
|
|
2271
2599
|
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`); });
|
|
2272
2600
|
if (c.parent != null && !boxes[c.parent]) err(`${at}.containers[${j}].parent`, `"${c.parent}" is not a box of this view`);
|
|
2601
|
+
if (c.tone != null && typeof c.tone !== 'string') warn(`${at}.containers[${j}].tone`, `tone ${JSON.stringify(c.tone)} is not a palette name (green, amber, ...): ${c.tone ? 'drawn in slate' : 'ignored'}`);
|
|
2273
2602
|
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); } });
|
|
2274
2603
|
const ends = new Set([...ids, ...Object.keys(boxes)]);
|
|
2275
2604
|
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`); });
|
|
@@ -2285,7 +2614,7 @@ export function validate(model) {
|
|
|
2285
2614
|
if (typeof s !== 'object' || Array.isArray(s)) return err(at, `a selector is an object: id, kind, container, owner${lens ? ', line' : ''} or not`);
|
|
2286
2615
|
Object.keys(s).forEach(k => { if (!['id', 'kind', 'container', 'owner', 'not', ...(lens ? ['line'] : [])].includes(k)) warn(`${at}.${k}`, `unknown key "${k}": ignored, so the selector picks more cards`); });
|
|
2287
2616
|
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`); });
|
|
2288
|
-
each(s.kind, at + '.kind', (x, p) => { const k = String(x).toLowerCase(); if (!(
|
|
2617
|
+
each(s.kind, at + '.kind', (x, p) => { const k = String(x).toLowerCase(); if (!(hasKey(KIND, k) || hasKey(MAPK, k) || hasKey(ALIAS, k))) warn(p, `unknown kind "${x}": it picks script cards`); });
|
|
2289
2618
|
each(s.container, at + '.container', (x, p) => { if (!allBoxes.has(x)) warn(p, `"${x}" is not a box: it picks nothing`); });
|
|
2290
2619
|
each(s.owner, at + '.owner', (x, p) => { if (!owners.has(x)) warn(p, `no card's owner is "${x}": it picks nothing`); });
|
|
2291
2620
|
if (lens) each(s.line, at + '.line', (x, p) => { if (!WIRE_KINDS.includes(x)) warn(p, `"${x}" is not a line kind (calls, feeds, triggers, state, guard): it picks no line`); });
|
|
@@ -2536,8 +2865,8 @@ export function drift(model, observed, options = {}) {
|
|
|
2536
2865
|
return { model: out, counts, views: arr(out.views).filter(v => v && linesOf(out, v).some(e => e.drift)).map(v => v.id) };
|
|
2537
2866
|
}
|
|
2538
2867
|
|
|
2539
|
-
|
|
2540
|
-
const reducedMotion = () =>
|
|
2868
|
+
// the reader's setting; a handle drawn instant (the page's #check, #still and #shot hooks) is still on top of it, on its own
|
|
2869
|
+
const reducedMotion = () => typeof matchMedia === 'function' && matchMedia('(prefers-reduced-motion: reduce)').matches;
|
|
2541
2870
|
let SHEET = null;
|
|
2542
2871
|
const sheet = () => { if (!SHEET) { SHEET = new CSSStyleSheet(); SHEET.replaceSync(STYLES); } return SHEET; };
|
|
2543
2872
|
const INNER = Symbol('umlimator.inner'); // what mountPage needs from render() beyond the public handle
|
|
@@ -2554,7 +2883,8 @@ async function pngOf(file) {
|
|
|
2554
2883
|
|
|
2555
2884
|
export function render(container, model, options = {}) {
|
|
2556
2885
|
const P = options._page || {}; // the standalone page's hooks: status line, overlay insets, still and reader drawing
|
|
2557
|
-
|
|
2886
|
+
const instant = !!P.instant, still = () => instant || reducedMotion(); // this map's motion: an instant page stills only itself
|
|
2887
|
+
const ctrlWheel = options.wheel === 'ctrl'; // the wheel zooms only with Ctrl or ⌘ (a trackpad's pinch too), and else scrolls the page: a map in a document
|
|
2558
2888
|
const OPS = ['add', 'rename', 'move', 'delete', 'reorder'], ed = options.editable;
|
|
2559
2889
|
const E = ed === true ? Object.fromEntries(OPS.map(o => [o, true])) : ed && typeof ed === 'object' ? Object.fromEntries(OPS.map(o => [o, !!ed[o]])) : null;
|
|
2560
2890
|
const can = op => !!(E && E[op]);
|
|
@@ -2565,6 +2895,7 @@ export function render(container, model, options = {}) {
|
|
|
2565
2895
|
const own = typeof options.measure === 'function' ? options.measure : null;
|
|
2566
2896
|
let measure = options.measure === false ? null : own || hostMeasure(host), current = model;
|
|
2567
2897
|
const expanded = {}; // per view: the cards unfolded in place (#71), in the order they unfolded; in this handle only, never in the model
|
|
2898
|
+
const asked = {}; // per view: card id -> the inner view it waits for, when E or Alt + click asked to unfold it before the host loaded that view
|
|
2568
2899
|
let ctx = mapContext(model, { measure, kinds: !!P.kinds, expand: expanded }); // kinds: the standalone page guesses line kinds from label words, for its legend
|
|
2569
2900
|
const root = host.attachShadow({ mode: 'open', delegatesFocus: true }); // host.focus() lands in the map (on the selected card)
|
|
2570
2901
|
root.adoptedStyleSheets = [sheet()];
|
|
@@ -2578,59 +2909,100 @@ export function render(container, model, options = {}) {
|
|
|
2578
2909
|
<button data-z="up" title="Up one level (Esc)" aria-label="Up one level">↑</button><span class="sep"></span>
|
|
2579
2910
|
<button data-z="out" title="Zoom out (−)" aria-label="Zoom out">−</button><button data-z="fit" class="pct" title="Fit to screen (0)">100%</button><button data-z="in" title="Zoom in (+)" aria-label="Zoom in">+</button><span class="sep"></span>
|
|
2580
2911
|
<button data-z="one" title="Actual size (1)">1:1</button><button data-z="flow" title="Animate flow direction (F)" aria-label="Animate flow">≋</button>${E && E.add ? '<span class="sep"></span><button data-z="add" title="Add a card (N, or Alt + double-click)" aria-label="Add a card">+</button>' : ''}<span class="depth" title="Reading depth follows zoom">READ</span>
|
|
2581
|
-
</div>`}</div></div>${!framed ? '' : (options.legend ? '<div class="m-legend"></div>' : '') + (options.hints ? `<div class="hint" role="status" aria-live="polite">${hintHTML()}</div>` : '') + '</div>'
|
|
2912
|
+
</div>`}</div></div>${!framed ? '' : (options.legend ? '<div class="m-legend"></div>' : '') + (options.hints ? `<div class="hint" role="status" aria-live="polite">${hintHTML('', ctrlWheel)}</div>` : '') + '</div>'
|
|
2582
2913
|
+ (options.details ? '<aside class="panel" aria-label="Node details" hidden></aside>' : '') + '</div>'
|
|
2583
2914
|
+ (options.nav ? '<button class="ibtn um-navbtn" title="Views (O)" aria-label="Toggle the views list" aria-expanded="false">☰</button><aside class="outline" aria-label="Views"></aside>' : '') + '</div>'}`;
|
|
2584
2915
|
const stage = root.querySelector('.stage'), q = s => root.querySelector(s);
|
|
2585
|
-
const tell = q('.hint') && hinter(q('.hint'), hintHTML()); // the hint line says what just happened, for a while
|
|
2916
|
+
const tell = q('.hint') && hinter(q('.hint'), hintHTML('', ctrlWheel)); // the hint line says what just happened, for a while
|
|
2586
2917
|
const subs = {};
|
|
2587
|
-
|
|
2918
|
+
// each listener on its own: an error in one is reported, and the map's own transition goes on (a host's bug never wedges it)
|
|
2919
|
+
const fail = err => { if (typeof reportError === 'function') reportError(err); else console.error(err); };
|
|
2920
|
+
const emit = (type, e, last = false) => { if (dead && !last) return; [(options.on || {})[type], ...(subs[type] || [])].forEach(fn => { if (typeof fn !== 'function') return; try { fn(e); } catch (err) { fail(err); } }); if (type !== 'state') touch(); };
|
|
2921
|
+
/* the map's state for hosts and agents (0.14.0): handle.state is a fresh, frozen, JSON-serializable snapshot, and the state
|
|
2922
|
+
event says which of its keys changed, once a frame at most (on a 16 ms timer, so a page in a hidden tab still says it).
|
|
2923
|
+
The camera is the exception: a pan, zoom, glide or drag is one state event, once it comes to rest; until then the
|
|
2924
|
+
event's camera is where it last rested (handle.state reads it live) */
|
|
2925
|
+
const KEYS = ['view', 'navigating', 'selection', 'expanded', 'lens', 'overlays', 'story', 'tour', 'replay', 'paused', 'camera', 'flow', 'shapes', 'motion', 'gesture', 'destroyed'];
|
|
2926
|
+
let lastState = null, stateT = 0, camRest = null, restT = 0; // the last state event's snapshot and the flush to come; the camera at rest, and its check to come
|
|
2927
|
+
const listensState = () => !!((options.on || {}).state || (subs.state || []).length);
|
|
2928
|
+
function touch() { if (!stateT && !dead && listensState()) stateT = setTimeout(flushState, 16); }
|
|
2929
|
+
function flushState(last = false) { clearTimeout(stateT); stateT = 0; if (!listensState() || (dead && !last)) return;
|
|
2930
|
+
const s = snapshot(true), changed = KEYS.filter(k => JSON.stringify(s[k]) !== JSON.stringify(lastState ? lastState[k] : undefined)); lastState = s;
|
|
2931
|
+
if (changed.length) emit('state', { state: s, changed }, last); }
|
|
2932
|
+
// at rest: 150 ms with no change, no glide, no press on the map and no move under way; a moving camera is looked at again
|
|
2933
|
+
function camMoved() { clearTimeout(restT); restT = !dead && listensState() ? setTimeout(rested, 150) : 0; }
|
|
2934
|
+
function rested() { restT = 0; if (dead) return; if (cam && (cam.anim || cam.ptrs.size) || nav) return camMoved(); camRest = camNow(); touch(); }
|
|
2588
2935
|
let cam = null, geo = null, svg = null, cur = null, sel = null, more = [], busy = false, paused = false, dead = false;
|
|
2936
|
+
// the notation (0.15.0): the host's (render()'s shapes, then handle.shapes) for every view, else each view's own
|
|
2937
|
+
if (options.shapes != null && !noteOf(options.shapes)) warn(`render(…, { shapes: ${JSON.stringify(options.shapes)} }): the notations are ${NOTATIONS.join(', ')}; each view draws its own`);
|
|
2938
|
+
let shapesSet = noteOf(options.shapes);
|
|
2939
|
+
const noteFor = vid => shapesSet || ctx.notationOf(vid);
|
|
2940
|
+
const pic = vid => { // a view's picture in its notation: laid out once, drawn again only when the notation changes
|
|
2941
|
+
const want = noteFor(vid), o = { tour: P.tour !== false, openHint: P.openHint !== false, shapes: want };
|
|
2942
|
+
let c = ctx.geoCache[vid]; if (!c) c = ctx.geoCache[vid] = ctx.buildView(vid, o); else if (c.shapes !== want) Object.assign(c, ctx.drawView(vid, c.g, o));
|
|
2943
|
+
return c; };
|
|
2944
|
+
// each card's look in the notation shown, for what is drawn over the picture: rings, the minimap, a tour's or replay's dot
|
|
2945
|
+
// (in box too: a card's own shape is drawn there)
|
|
2946
|
+
const lookIn = (nt, g) => { const m = new Map(), ports = sidePorts(g); return n => { if (!m.has(n)) m.set(n, cardLook(n, nt, !!(n.kids || n.opens) && P.openHint !== false, ports.get(n))); return m.get(n); }; };
|
|
2947
|
+
const looker = () => lookIn(noteFor(cur), geo);
|
|
2589
2948
|
const timers = new Set(), later = (f, ms) => { const t = setTimeout(() => { timers.delete(t); if (!dead) f(); }, ms); timers.add(t); return t; };
|
|
2590
|
-
const
|
|
2949
|
+
const sleeping = new Set(); // a sleep resolves on its own, or when the map is destroyed: nothing that awaits one is left pending
|
|
2950
|
+
const sleep = ms => new Promise(r => { if (dead) return r(); const wake = () => { sleeping.delete(wake); r(); }; sleeping.add(wake); later(wake, still() ? 0 : ms); });
|
|
2591
2951
|
const status = t => P.status ? P.status(t) : tell && tell(t);
|
|
2592
2952
|
const wide = P.wide || (() => (cam ? cam.size()[0] : stage.clientWidth) > 820);
|
|
2593
2953
|
|
|
2594
2954
|
const upOf = () => (P.upTarget ? P.upTarget() : (ctx.V[cur] || {}).parent) || null;
|
|
2595
2955
|
const nodeRef = id => ({ type: 'node', id, view: cur });
|
|
2596
|
-
const
|
|
2956
|
+
const copyRef = r => r && { ...r, ...(Array.isArray(r.ids) ? { ids: [...r.ids] } : {}) }; // a host gets copies: the map's Refs stay its own
|
|
2957
|
+
const picked = () => sel ? [...more.map(nodeRef), copyRef(sel)] : []; // every selected thing, the latest last (more: other selected cards)
|
|
2597
2958
|
const say = t => { const l = root.querySelector('.um-live'); if (l) l.textContent = t; };
|
|
2598
2959
|
const nameOf = id => ctx.nodeIn(id, cur).label || ((geo.families || []).find(f => f.id === id) || {}).label || id;
|
|
2599
|
-
|
|
2960
|
+
let said = '[]'; // the selection last reported: a select event means it changed, or a click asked again (again)
|
|
2961
|
+
const emitSel = (again = false) => {
|
|
2962
|
+
const now = JSON.stringify(picked()); if (now === said && !again) return; said = now;
|
|
2600
2963
|
say(!sel ? 'Nothing selected' : sel.type === 'node' ? nameOf(sel.id) + (more.length ? `, ${more.length + 1} cards selected` : ', selected')
|
|
2601
2964
|
: sel.type === 'edge' ? (e => e ? `Line from ${nameOf(e.from)} to ${nameOf(e.to)}, selected` : 'Line selected')(geo.edges[edgeIndex(sel)]) : `Box ${(svg.querySelector(`.family[data-family="${CSS.escape(sel.id)}"] .family-title`) || {}).textContent || sel.id}, selected`);
|
|
2602
|
-
emit('select', { target: sel, targets: picked() });
|
|
2965
|
+
emit('select', { target: copyRef(sel), targets: picked() });
|
|
2603
2966
|
};
|
|
2604
2967
|
const edgeRef = e => ({ type: 'edge', ids: edgeRefIds(e), view: cur });
|
|
2605
2968
|
const edgeIndex = ref => geo.edges.findIndex(e => { const ids = edgeRefIds(e); return ids.length === ref.ids.length && ids.every((x, i) => x === ref.ids[i]); });
|
|
2606
2969
|
const exists = ref => !ref ? true : ref.type === 'node' ? !!geo.byId[ref.id] : ref.type === 'edge' ? edgeIndex(ref) >= 0
|
|
2607
2970
|
: ref.type === 'container' ? (geo.families || []).some(f => f.id === ref.id) : false;
|
|
2608
2971
|
|
|
2609
|
-
|
|
2610
|
-
|
|
2972
|
+
// focus in the map goes back on the same card or line in a new picture, else on the canvas; focus outside the map stays there
|
|
2973
|
+
const focusOf = () => { const a = root.activeElement; if (!a || !(a === stage || (svg && svg.contains(a)) || a.closest('.empty-view'))) return null;
|
|
2974
|
+
const lk = a.closest('.line-key'), k = a.closest('[data-node]'), g = a.closest('.node');
|
|
2975
|
+
return lk && geo.edges[+lk.dataset.i] ? edgeRef(geo.edges[+lk.dataset.i]) : k ? nodeRef(k.dataset.node) : g ? nodeRef(g.dataset.id) : {}; };
|
|
2976
|
+
const refocus = ref => { if (!ref) return; if ((ref.type === 'node' || ref.type === 'edge') && exists(ref)) focusMap(ref, false); else stage.focus({ preventScroll: true }); };
|
|
2977
|
+
function mount(vid) { // vid: a view of the model, checked first (one it lacks changes nothing: false), or null for a model with no views
|
|
2978
|
+
const v = vid == null ? null : ctx.V[vid]; if (vid != null && !v) return false;
|
|
2979
|
+
const back = focusOf();
|
|
2980
|
+
cancelGesture(!!v && vid === cur); // a new picture: a drag or a line under way stops; a name field stays over its card if the view draws it still
|
|
2981
|
+
cur = v ? vid : null; if (v) ctx.planOf(vid);
|
|
2611
2982
|
stage.querySelectorAll('svg.map-svg, .empty-view').forEach(s => s.remove());
|
|
2612
|
-
|
|
2613
|
-
if (empty) { // a view with nothing in it gets a friendly card, not a blank canvas
|
|
2983
|
+
if (!v || !(v.nodeIds || []).some(i => ctx.N[i])) { // a view with nothing in it, or a model with no views, gets a friendly card, not a blank canvas
|
|
2614
2984
|
geo = { nodes: [], edges: [], groups: [], lanes: [], families: [], byId: {}, stats: {}, bounds: { x: 0, y: 0, w: 400, h: 300 } };
|
|
2615
2985
|
stage.insertAdjacentHTML('afterbegin', '<svg class="map-svg" viewBox="0 0 400 300" xmlns="http://www.w3.org/2000/svg"></svg>');
|
|
2616
|
-
const p = v.parent;
|
|
2617
|
-
stage.insertAdjacentHTML('beforeend', `<div class="empty-view"><div class="ico">◌</div><h4>Nothing here yet</h4><p
|
|
2986
|
+
const p = v && v.parent;
|
|
2987
|
+
stage.insertAdjacentHTML('beforeend', `<div class="empty-view"><div class="ico">◌</div><h4>Nothing here yet</h4><p>${v ? 'This view has no nodes yet.' : 'This map has no views yet.'}</p>${p ? `<button class="btn" data-v="${esc(p)}">← Back to ${esc(String(ctx.V[p].title || p).split(' · ')[0])}</button>` : ''}</div>`);
|
|
2618
2988
|
} else {
|
|
2619
|
-
const c =
|
|
2989
|
+
const c = pic(vid);
|
|
2620
2990
|
stage.insertAdjacentHTML('afterbegin', c.markup); geo = c.g;
|
|
2621
2991
|
}
|
|
2622
2992
|
svg = stage.querySelector('svg'); svg.classList.add('map-svg'); if (paused) svg.pauseAnimations();
|
|
2623
|
-
if (!cam) cam = new Camera(stage, svg, { wheel: 'zoom', maxFit: 1.15, max: 4, mapDepth: 0.3, onChange: onCam, skip: e => !!((can('move') || can('reorder') || can('add')) && e.target.closest && e.target.closest('.node, .um-handle')), inset: () => clearOf([].concat(P.inset ? P.inset() : fitIn())) });
|
|
2993
|
+
if (!cam) cam = new Camera(stage, svg, { wheel: ctrlWheel ? 'ctrl' : 'zoom', maxFit: 1.15, max: 4, mapDepth: 0.3, onChange: onCam, still, skip: e => !!((can('move') || can('reorder') || can('add')) && e.target.closest && e.target.closest('.node, .um-handle')), inset: () => clearOf([].concat(P.inset ? P.inset() : fitIn())) });
|
|
2624
2994
|
cam.setSvg(svg, geo.bounds);
|
|
2625
2995
|
const ub = q('[data-z="up"]'); if (ub) ub.disabled = !upOf();
|
|
2626
2996
|
if (options.minimap) buildMini();
|
|
2627
|
-
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
|
|
2628
|
-
|
|
2629
|
-
more = [];
|
|
2630
|
-
if (had) emitSel();
|
|
2997
|
+
if (v && !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
|
|
2998
|
+
sel = null; more = []; sub = null; tabKeys(); paint(); // no select event here: the caller reports, once, what became of the selection
|
|
2631
2999
|
endTrip(); relens(); drawMarks(); // a new picture: a tour or replay on the old one ends; the host's marks and the lens go on again (#63, #94)
|
|
2632
|
-
if (
|
|
2633
|
-
|
|
3000
|
+
if (naming) { if (naming.ref && !exists(naming.ref)) naming.close(false); else naming.place(); }
|
|
3001
|
+
refocus(back);
|
|
3002
|
+
if (v && pc) pc.chrome();
|
|
3003
|
+
if (v && P.onMount) P.onMount(inner);
|
|
3004
|
+
touch();
|
|
3005
|
+
return true;
|
|
2634
3006
|
}
|
|
2635
3007
|
// at fit the cards keep off the pieces over the map (#99): the views list's button, the hint line, and the legend, which
|
|
2636
3008
|
// the map leaves room for above it or to its right, whichever leaves the map larger
|
|
@@ -2655,6 +3027,8 @@ export function render(container, model, options = {}) {
|
|
|
2655
3027
|
clear = { geo, key, sets }; return sets;
|
|
2656
3028
|
}
|
|
2657
3029
|
function onCam(c) {
|
|
3030
|
+
camMoved(); // the state event says where the camera comes to rest, once
|
|
3031
|
+
if (naming) naming.place(); // a name field stays over what it names
|
|
2658
3032
|
const pct = q('.pct'), depth = q('.depth');
|
|
2659
3033
|
if (pct) pct.textContent = Math.round(c.s.k * 100) + '%';
|
|
2660
3034
|
if (depth) depth.textContent = (stage.dataset.depth || 'read').toUpperCase();
|
|
@@ -2669,11 +3043,14 @@ export function render(container, model, options = {}) {
|
|
|
2669
3043
|
/* minimap */
|
|
2670
3044
|
const mini = {};
|
|
2671
3045
|
function buildMini() {
|
|
2672
|
-
const b = geo.bounds, el = q('.minimap');
|
|
3046
|
+
const b = geo.bounds, el = q('.minimap'), bw = Math.max(2, b.w / 300), c4 = noteFor(cur) === 'c4';
|
|
3047
|
+
// a box's tone, as its region; and a box dashed in the picture (any box in c4, a toned box in any notation) dashed here
|
|
3048
|
+
const rg = (geo.families || []).filter(f => f.tone || c4).map(f => `<rect x="${f.x}" y="${f.y}" width="${f.w}" height="${f.h}" rx="14" ${f.tone ? `style="--um-tone:${toneOf(f)}" ` : ''}fill="${f.tone ? 'var(--um-tone, var(--um-slate))' : 'none'}" fill-opacity=".14" stroke="${f.tone ? 'var(--um-tone, var(--um-slate))' : 'var(--um-border-2)'}" stroke-width="${bw}" stroke-dasharray="${bw * 4} ${bw * 3}"/>`).join('');
|
|
2673
3049
|
const ln = (geo.lanes || []).map(l => `<rect x="${l.x}" y="${l.y}" width="${l.w}" height="${l.h}" rx="14" fill="none" stroke="var(--um-border-2)" stroke-width="${Math.max(2, b.w / 300)}"/>`).join('');
|
|
2674
|
-
const r = geo.nodes.map(n =>
|
|
3050
|
+
const look = looker(), r = geo.nodes.map(n => { const L = look(n), paint = `fill="var(--um-${KIND[n.k][0]})" opacity="${n.k === 'm-public' ? .95 : .55}"`;
|
|
3051
|
+
return L ? `<path d="${L.S.out(L)}" ${paint}/>` : `<rect x="${n.x}" y="${n.y}" width="${n.w}" height="${n.h}" rx="8" ${paint}/>`; }).join('');
|
|
2675
3052
|
const l = geo.edges.map(e => `<polyline points="${e.pts.map(p => p.join(',')).join(' ')}" fill="none" stroke="var(--um-arrow)" stroke-width="${Math.max(2, b.w / 260)}" opacity=".6"/>`).join('');
|
|
2676
|
-
el.innerHTML = `<svg viewBox="${b.x} ${b.y} ${b.w} ${b.h}" preserveAspectRatio="xMidYMid meet">${ln}${l}${r}<rect class="vp" rx="${b.w / 80}"/></svg>`;
|
|
3053
|
+
el.innerHTML = `<svg viewBox="${b.x} ${b.y} ${b.w} ${b.h}" preserveAspectRatio="xMidYMid meet">${rg}${ln}${l}${r}<rect class="vp" rx="${b.w / 80}"/></svg>`;
|
|
2677
3054
|
mini.svg = el.firstChild; mini.vp = el.querySelector('.vp');
|
|
2678
3055
|
mini.vp.setAttribute('stroke-width', Math.max(2, b.w / 160));
|
|
2679
3056
|
cam && onCam(cam);
|
|
@@ -2696,8 +3073,9 @@ export function render(container, model, options = {}) {
|
|
|
2696
3073
|
svg.querySelectorAll('.tab-key, .chip-key').forEach(k => k.remove());
|
|
2697
3074
|
const cards = Object.fromEntries([...svg.querySelectorAll('.node')].map(g => [g.dataset.id, g]));
|
|
2698
3075
|
svg.querySelectorAll('.family [data-badge]').forEach(t => { if (on) { t.setAttribute('tabindex', '0'); t.setAttribute('role', 'button'); } else { t.removeAttribute('tabindex'); t.removeAttribute('role'); } });
|
|
2699
|
-
|
|
2700
|
-
|
|
3076
|
+
const look = looker();
|
|
3077
|
+
if (on && geo.map) geo.nodes.forEach(n => { let tx = n.x + 12, html = ''; const card = cards[n.id], ty = tabLine(n, look(n)); if (!card) return;
|
|
3078
|
+
mapTabs(n).forEach(t => { if (t.badge != null) html += `<g class="um-key tab-key" data-node="${esc(n.id)}" data-badge="${t.badge}" tabindex="0" role="button" aria-label="${esc(t.text)}"><rect x="${tx - 2}" y="${ty - 10}" width="${t.w + 4}" height="19" rx="6" class="key-ring"/></g>`; tx += t.w + 4; });
|
|
2701
3079
|
card.querySelectorAll('rect.verb-chip').forEach(r => { const i = +r.dataset.chip, { value } = chipRef(card, i), at = k => +r.getAttribute(k), verb = (n.verbs || [])[i];
|
|
2702
3080
|
const name = value === verb ? value : `${value} (more: ${(n.verbs || []).slice(i).join(', ')})`; // the last chip may stand for the rest
|
|
2703
3081
|
html += `<g class="um-key chip-key" data-node="${esc(n.id)}" data-chip="${i}" tabindex="0" role="button" aria-label="${esc(name)}"><rect x="${at('x') - 2}" y="${at('y') - 2}" width="${at('width') + 4}" height="${at('height') + 4}" rx="9" class="key-ring"/></g>`; });
|
|
@@ -2728,7 +3106,7 @@ export function render(container, model, options = {}) {
|
|
|
2728
3106
|
svg.querySelectorAll('.node').forEach(g => { const id = g.dataset.id; g.classList.toggle('sel', id === nid || more.includes(id)); g.classList.toggle('nb', ups.has(id) || downs.has(id)); });
|
|
2729
3107
|
svg.querySelectorAll('.edge-g').forEach(g => g.classList.toggle('hot', g.dataset.from === nid || g.dataset.to === nid));
|
|
2730
3108
|
svg.querySelectorAll('.e-lab').forEach(g => { const e = geo.edges[g.dataset.i]; g.classList.toggle('hot', e.from === nid || e.to === nid); });
|
|
2731
|
-
svg.querySelectorAll('.n-port').forEach(p => p.classList.toggle('hot', p.dataset.edges.split(',').some(i => geo.edges[i].from === nid || geo.edges[i].to === nid)));
|
|
3109
|
+
svg.querySelectorAll('.n-port[data-edges]').forEach(p => p.classList.toggle('hot', p.dataset.edges.split(',').some(i => geo.edges[i] && (geo.edges[i].from === nid || geo.edges[i].to === nid)))); // not an ease's fading copy (#67)
|
|
2732
3110
|
stage.classList.add('focusing');
|
|
2733
3111
|
} else if (sel.type === 'edge') {
|
|
2734
3112
|
const i = edgeIndex(sel), e = geo.edges[i];
|
|
@@ -2741,13 +3119,15 @@ export function render(container, model, options = {}) {
|
|
|
2741
3119
|
}
|
|
2742
3120
|
function select(ref, o = {}) {
|
|
2743
3121
|
if (ref && (ref.type === 'chip' || ref.type === 'badge')) ref = ref.container != null ? { type: 'container', id: ref.container } : nodeRef(ref.node); // a chip or badge: its card (box)
|
|
2744
|
-
if (ref && (!geo || !exists(ref))) return;
|
|
2745
|
-
|
|
2746
|
-
|
|
2747
|
-
sel = ref ? { ...ref, view: cur } : null;
|
|
3122
|
+
if (ref && (!geo || !exists(ref))) return false;
|
|
3123
|
+
if (!o.story) readerMoved(); // a selection the story didn't make: a story playing waits (0.14.0)
|
|
3124
|
+
more = []; selectNew = false; // the reader moved on: an add the host has not answered selects nothing later
|
|
3125
|
+
sel = ref ? { ...copyRef(ref), view: cur } : null;
|
|
3126
|
+
if (!ofSel(sub)) sub = null; // a chip or badge acts for Delete and Alt + arrows only while its card or box is the one selected
|
|
2748
3127
|
paint();
|
|
2749
3128
|
if (sel && sel.type === 'node' && o.reveal !== false) { const n = geo.byId[sel.id]; requestAnimationFrame(() => { if (!dead) cam.reveal({ x: n.x, y: n.y, w: n.w, h: n.h }); }); }
|
|
2750
|
-
|
|
3129
|
+
emitSel(!!o.again);
|
|
3130
|
+
return true;
|
|
2751
3131
|
}
|
|
2752
3132
|
|
|
2753
3133
|
/* keyboard: the nearest card in an arrow's direction, by distance along it plus twice the distance across it */
|
|
@@ -2764,41 +3144,65 @@ export function render(container, model, options = {}) {
|
|
|
2764
3144
|
if (id === sel.id) { if (!more.length) return select(null); sel = nodeRef(more.pop()); }
|
|
2765
3145
|
else if (more.includes(id)) more = more.filter(x => x !== id);
|
|
2766
3146
|
else { more.push(sel.id); sel = nodeRef(id); }
|
|
2767
|
-
paint(); emitSel();
|
|
3147
|
+
sub = null; selectNew = false; paint(); emitSel();
|
|
2768
3148
|
}
|
|
2769
3149
|
|
|
2770
|
-
/* navigation: zoom into the card that opens a view, out to the card a view came from
|
|
2771
|
-
|
|
2772
|
-
|
|
2773
|
-
|
|
2774
|
-
|
|
2775
|
-
|
|
2776
|
-
|
|
2777
|
-
if (
|
|
2778
|
-
|
|
2779
|
-
if (
|
|
2780
|
-
if (
|
|
2781
|
-
|
|
2782
|
-
|
|
2783
|
-
|
|
2784
|
-
}
|
|
2785
|
-
|
|
2786
|
-
|
|
2787
|
-
const
|
|
2788
|
-
|
|
2789
|
-
|
|
2790
|
-
|
|
2791
|
-
|
|
2792
|
-
|
|
2793
|
-
|
|
2794
|
-
|
|
2795
|
-
|
|
2796
|
-
|
|
2797
|
-
|
|
2798
|
-
|
|
2799
|
-
|
|
2800
|
-
|
|
2801
|
-
|
|
3150
|
+
/* navigation: zoom into the card that opens a view, out to the card a view came from. One move at a time, the latest
|
|
3151
|
+
wins: a setView() (or open, up) during a move takes it to its own view instead, and every request's promise says
|
|
3152
|
+
whether its view was shown (false: unknown, superseded, or the map destroyed). Under reduced motion a move is done
|
|
3153
|
+
before go() returns. */
|
|
3154
|
+
let nav = null; // the move under way: { to, how, waiting: the resolvers of the requests it answers }
|
|
3155
|
+
const answer = ok => { if (nav) nav.waiting.splice(0).forEach(r => r(ok)); };
|
|
3156
|
+
function go(vid, how = {}) {
|
|
3157
|
+
if (dead) return Promise.resolve(false);
|
|
3158
|
+
if (!ctx.V[vid]) { warn(`setView("${vid}"): the model has no such view; its views are ${ctx.views.map(v => `"${v.id}"`).join(', ') || 'none'}`); return Promise.resolve(false); }
|
|
3159
|
+
if (!nav && vid === cur) { if (how.select) select(nodeRef(how.select)); return Promise.resolve(true); }
|
|
3160
|
+
if (!how.story) readerMoved(); // a move the story didn't ask for: a story playing waits (0.14.0)
|
|
3161
|
+
return new Promise(res => {
|
|
3162
|
+
if (nav) { if (nav.to !== vid) answer(false); nav.to = vid; nav.how = how; nav.waiting.push(res); touch(); return; } // superseded: those requests learn so now
|
|
3163
|
+
nav = { to: vid, how, waiting: [res] }; move();
|
|
3164
|
+
});
|
|
3165
|
+
}
|
|
3166
|
+
async function move() { // takes the map from view to view until it shows the latest target; finishes or rolls back (busy, fading)
|
|
3167
|
+
const mine = nav, start = cur; let left = false; busy = true; touch();
|
|
3168
|
+
try {
|
|
3169
|
+
for (;;) {
|
|
3170
|
+
const prev = cur, via = () => nav.how.via; // a request's via: the card it opens, when the request is the latest
|
|
3171
|
+
const modeOf = v => prev && ctx.chain(v).includes(prev) || (v === nav.to && via()) ? 'in' : prev && ctx.chain(prev).includes(v) ? 'out' : 'jump';
|
|
3172
|
+
if (prev && !still() && nav.to !== prev) { // leave: into the card, or out, then fade
|
|
3173
|
+
const m = modeOf(nav.to), n = m === 'in' && via() && geo.byId[via()];
|
|
3174
|
+
if (n) await cam.animate(cam.zoomTo({ x: n.x, y: n.y, w: n.w, h: n.h }, 1.1), 420);
|
|
3175
|
+
else await cam.animate({ k: cam.s.k * (m === 'out' ? 0.6 : 1) }, m === 'out' ? 260 : 1);
|
|
3176
|
+
stage.classList.add('fading'); left = true; await sleep(130);
|
|
3177
|
+
if (dead || nav !== mine) return;
|
|
3178
|
+
}
|
|
3179
|
+
const vid = nav.to; // the latest request: one made during the leave is the view drawn
|
|
3180
|
+
if (!ctx.V[vid] || vid === cur) break; // gone from the model (an update() during the move), or back where it started
|
|
3181
|
+
const mode = modeOf(vid);
|
|
3182
|
+
delete asked[prev]; // an unfold asked for on the view left is not waited for (#71)
|
|
3183
|
+
if (P.onNavigate) P.onNavigate(vid, prev, mode);
|
|
3184
|
+
mount(vid); emitSel(); left = false;
|
|
3185
|
+
const fitS = startView(), back = mode === 'out' ? geo.nodes.find(n => ctx.childOf(n.id, cur) === prev) : null;
|
|
3186
|
+
if (prev && !still()) {
|
|
3187
|
+
if (mode === 'in') cam.set({ ...fitS, k: fitS.k * 0.55 });
|
|
3188
|
+
else if (mode === 'out') cam.set(back ? cam.zoomTo({ x: back.x, y: back.y, w: back.w, h: back.h }, 1.1) : { ...fitS, k: fitS.k * 1.6 });
|
|
3189
|
+
else cam.set(fitS);
|
|
3190
|
+
stage.classList.remove('fading');
|
|
3191
|
+
await cam.animate(fitS, mode === 'jump' ? 1 : 460);
|
|
3192
|
+
} else { cam.set(fitS); stage.classList.remove('fading'); }
|
|
3193
|
+
if (dead || nav !== mine) return;
|
|
3194
|
+
cam.touched = false;
|
|
3195
|
+
if (back) { flash(back.id); if (root.activeElement === stage) focusMap(nodeRef(back.id), false); } // keys go on from the card come back to
|
|
3196
|
+
if (nav.to === cur) break; // else a request during the zoom-in: on from here
|
|
3197
|
+
}
|
|
3198
|
+
// done: settled before any event, so a listener's own setView() starts a move of its own
|
|
3199
|
+
const ok = nav.to === cur, how = nav.how, shown = cur;
|
|
3200
|
+
answer(ok); nav = null; busy = false; stage.classList.remove('fading');
|
|
3201
|
+
if (left && cur) cam.animate(startView()); // left, and no other view drawn: the camera comes back
|
|
3202
|
+
if (shown && shown !== start) { say('Showing ' + (ctx.V[shown].title || shown)); emit('viewchange', { view: shown, from: start }); }
|
|
3203
|
+
if (ok && how.select && cur === shown) select(nodeRef(how.select), { reveal: true });
|
|
3204
|
+
} catch (err) { fail(err); } // a bug of ours or of a page hook: reported, and the map left usable below
|
|
3205
|
+
finally { if (nav === mine) { answer(false); nav = null; busy = false; stage.classList.remove('fading'); } touch(); }
|
|
2802
3206
|
}
|
|
2803
3207
|
/* where the camera starts: fit on a desk; on a phone a readable zoom from the top-left corner */
|
|
2804
3208
|
function startView() {
|
|
@@ -2822,15 +3226,20 @@ export function render(container, model, options = {}) {
|
|
|
2822
3226
|
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; };
|
|
2823
3227
|
function setUnfold(nid, on) { // true when the picture changed
|
|
2824
3228
|
const list = expanded[cur] || [];
|
|
3229
|
+
if (!on && asked[cur]) asked[cur].delete(nid); // folded back before its view came: no longer waited for
|
|
2825
3230
|
if (E || dead || !cur || (on ? list.includes(nid) || !ctx.unfoldable(nid, cur) : !list.includes(nid))) return false;
|
|
2826
3231
|
expanded[cur] = on ? [...list, nid] : list.filter(x => x !== nid);
|
|
2827
3232
|
update(current, false, true);
|
|
2828
|
-
if ((expanded[cur] || []).includes(nid) !== on) return false; // the model would not have it after all
|
|
3233
|
+
if ((expanded[cur] || []).includes(nid) !== on) { emitSel(); return false; } // the model would not have it after all
|
|
3234
|
+
unfolded(nid, on);
|
|
3235
|
+
return true;
|
|
3236
|
+
}
|
|
3237
|
+
function unfolded(nid, on) { // the box a card became is selected and brought into view, or the card it folded back into: one select event
|
|
2829
3238
|
const f = on && (geo.families || []).find(x => x.id === nid);
|
|
2830
3239
|
if (f) { cam.reveal({ x: f.x, y: f.y, w: f.w, h: f.h }); select({ type: 'container', id: nid }, { reveal: false }); }
|
|
2831
3240
|
else if (!on && geo.byId[nid]) select(nodeRef(nid));
|
|
3241
|
+
else emitSel();
|
|
2832
3242
|
say(`${nameOf(nid)} ${on ? 'unfolded here' : 'folded'}`);
|
|
2833
|
-
return true;
|
|
2834
3243
|
}
|
|
2835
3244
|
function gesture(id, how) { // E, or Alt + click: a stacked card unfolds; an unfolded box, or a card in one, folds it
|
|
2836
3245
|
if (E || !id || !geo) return false;
|
|
@@ -2839,23 +3248,47 @@ export function render(container, model, options = {}) {
|
|
|
2839
3248
|
let stop = false; // the host may take it, or load a view it has not yet and update() right here
|
|
2840
3249
|
emit('expand', { target: nodeRef(nid), view: ctx.opensOf(nid, cur), expanded: on, preventDefault() { stop = true; } });
|
|
2841
3250
|
if (stop || dead) return true;
|
|
2842
|
-
if (!setUnfold(nid, on)) {
|
|
3251
|
+
if (!setUnfold(nid, on)) {
|
|
3252
|
+
const c2 = on && ctx.opensOf(nid, cur);
|
|
3253
|
+
if (c2 && !ctx.V[c2]) { (asked[cur] ||= new Map()).set(nid, c2); say(`${nameOf(nid)}: its inner view is not loaded yet; it unfolds here once it is`); } // the update() that brings it unfolds it
|
|
3254
|
+
if (on) status(c2 && !ctx.V[c2] ? 'Its inner view is not loaded yet' : 'Nothing more to show here');
|
|
3255
|
+
return true; }
|
|
2843
3256
|
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;
|
|
2844
3257
|
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
|
|
2845
3258
|
return true;
|
|
2846
3259
|
}
|
|
2847
3260
|
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); }
|
|
2848
|
-
|
|
3261
|
+
/* the notation every view is drawn in (0.15.0): the host's, a reader's (the page's toggle and link), or null for each
|
|
3262
|
+
view's own. The view shown is drawn again from the geometry it has, with its camera, selection, lens and marks, and
|
|
3263
|
+
nothing is laid out. False for a notation that isn't one (with a warning). */
|
|
3264
|
+
function setShapes(x) {
|
|
3265
|
+
if (dead) return false;
|
|
3266
|
+
const want = x == null ? null : noteOf(x);
|
|
3267
|
+
if (x != null && !want) { warn(`shapes = ${JSON.stringify(x)}: the notations are ${NOTATIONS.join(', ')}`); return false; }
|
|
3268
|
+
shapesSet = want;
|
|
3269
|
+
if (cur && ctx.geoCache[cur] && ctx.geoCache[cur].shapes !== noteFor(cur)) {
|
|
3270
|
+
const keep = sel, keepMore = more, touched = cam.touched;
|
|
3271
|
+
mount(cur); cam.set({}); cam.touched = touched;
|
|
3272
|
+
if (keep && exists(keep)) { sel = keep; more = keep.type === 'node' ? keepMore.filter(id => geo.byId[id]) : []; paint(); }
|
|
3273
|
+
emitSel(); }
|
|
3274
|
+
touch(); return true;
|
|
3275
|
+
}
|
|
3276
|
+
function setFlow(on, save = true) { // save: the page keeps it as the reader's choice
|
|
2849
3277
|
stage.classList.toggle('flowing', on);
|
|
2850
3278
|
const b = q('[data-z="flow"]'); if (b) { b.classList.toggle('on', on); b.setAttribute('aria-pressed', on); }
|
|
2851
|
-
if (P.onFlow) P.onFlow(on);
|
|
3279
|
+
if (save && P.onFlow) P.onFlow(on);
|
|
3280
|
+
touch();
|
|
2852
3281
|
}
|
|
2853
|
-
function pause(on) { paused = on; stage.classList.toggle('paused', on); if (svg) on ? svg.pauseAnimations() : svg.unpauseAnimations();
|
|
3282
|
+
function pause(on) { const was = paused; paused = on; stage.classList.toggle('paused', on); if (svg) on ? svg.pauseAnimations() : svg.unpauseAnimations();
|
|
3283
|
+
if (on !== was) touch(); // MapState.paused (0.15.0)
|
|
3284
|
+
if (story && on !== was) { if (on) clearTimeout(story.timer); else arm(story); } } // the page's Pause holds every clock: a dot that rides (startTrip), and a story's step, given its time again after
|
|
2854
3285
|
|
|
2855
3286
|
/* keys on the canvas; the page forwards the same keys when the focus is elsewhere */
|
|
2856
3287
|
function key(e) {
|
|
2857
3288
|
const k = e.key, g = e.target && e.target.closest && e.target.closest('.node');
|
|
3289
|
+
if (k === 'Escape' && cancelGesture()) return true; // Escape stops a gesture in progress before anything else
|
|
2858
3290
|
if (story) { const r = storyKey(e); if (r != null) return r; } // a story's keys first (#69)
|
|
3291
|
+
if (k === 'Escape' && trip && trip.kind) { const was = trip.kind; endTrip(); say(was === 'replay' ? 'Replay stopped' : 'Tour stopped'); return true; } // Esc ends a tour or a replay first
|
|
2859
3292
|
if (E && editKey(e)) return true;
|
|
2860
3293
|
if (e.altKey) return false;
|
|
2861
3294
|
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]');
|
|
@@ -2872,35 +3305,61 @@ export function render(container, model, options = {}) {
|
|
|
2872
3305
|
const id = g ? g.dataset.id : sel.id;
|
|
2873
3306
|
if (sel && sel.type === 'node' && sel.id === id && ctx.opensOf(id, cur)) open(id); else select(nodeRef(id), { again: true }); }
|
|
2874
3307
|
else if (k === 'Escape' || k === 'Backspace') { if (sel) select(null); else up(); }
|
|
2875
|
-
else if (k === 'f' || k === 'F') setFlow(!stage.classList.contains('flowing'));
|
|
3308
|
+
else if ((k === 'f' || k === 'F') && !reducedMotion()) setFlow(!stage.classList.contains('flowing')); // under reduced motion no flow is drawn, and ≋ isn't offered
|
|
2876
3309
|
else if (k.startsWith('Arrow') && g && geo.byId[g.dataset.id]) { const to = nearest(geo.byId[g.dataset.id], k); if (!to) return false;
|
|
2877
3310
|
const tg = svg.querySelector(`.node[data-id="${CSS.escape(to.id)}"]`); tg.focus({ preventScroll: true }); cam.reveal({ x: to.x, y: to.y, w: to.w, h: to.h }); }
|
|
2878
3311
|
else if (k.startsWith('Arrow')) { const d = 80, m = { ArrowLeft: [d, 0], ArrowRight: [-d, 0], ArrowUp: [0, d], ArrowDown: [0, -d] }[k]; if (!m) return false; cam.pan(m[0], m[1], true); }
|
|
2879
3312
|
else return false;
|
|
3313
|
+
readerMoved(); // a key the map acted on: a story playing waits
|
|
2880
3314
|
return true;
|
|
2881
3315
|
}
|
|
2882
3316
|
|
|
2883
3317
|
|
|
2884
3318
|
/* ---------- editing: a gesture never changes the picture; it emits a `change` the host may apply (then update()) ----------
|
|
2885
3319
|
Each event carries what the user meant and, apart from it, an optional hint: where the card was dropped. */
|
|
2886
|
-
let seq = 0, sub = null, drag = null, conn = null, selectNew = false, swallow = 0;
|
|
3320
|
+
let seq = 0, sub = null, drag = null, conn = null, naming = null, stale = false, selectNew = false, swallow = 0;
|
|
3321
|
+
/* the gesture in progress, one at a time: a card dragged, a line drawn from the handle, or a name field open (naming:
|
|
3322
|
+
{ ref, place, close }). Escape, a new picture (setView, an update that redraws, unfolding) and destroy() cancel it,
|
|
3323
|
+
with no change emitted; keepName leaves a name field open over the same view's card, which mount() checks */
|
|
3324
|
+
const gestureNow = () => conn ? 'connecting' : drag && drag.on ? 'dragging' : naming ? 'naming' : 'idle';
|
|
3325
|
+
function cancelGesture(keepName = false) { // true when something was in progress
|
|
3326
|
+
let was = false;
|
|
3327
|
+
if (conn) { if (conn.path) conn.path.remove(); conn = null; was = stale = true; } // its pointerup and click then do nothing
|
|
3328
|
+
if (drag) { if (drag.ghost) drag.ghost.remove(); if (drag.on) was = stale = true; drag = null; }
|
|
3329
|
+
if (naming && !keepName) { naming.close(false); was = true; }
|
|
3330
|
+
if (was) touch();
|
|
3331
|
+
return was;
|
|
3332
|
+
}
|
|
2887
3333
|
const placed = {}; // per view: the slot every card had when last drawn (stable positions across updates; never in the model)
|
|
2888
|
-
|
|
2889
|
-
|
|
2890
|
-
|
|
3334
|
+
/* an edit's places wait for the host's answer, and the next update() says from its model whether it applied the edit:
|
|
3335
|
+
a move into or out of a box shows in the boxes, an add as a new card with its name, and a place alone (a move to a
|
|
3336
|
+
spot, a reorder) as the card's own `layout` entry the host wrote, or as the model unchanged (the host answering an
|
|
3337
|
+
edit that changes no field). Any other update drops it: an edit the host refused moves nothing, whatever comes next.
|
|
3338
|
+
Places an update applied are kept with the models before and after it, so an update back to either (undo, redo)
|
|
3339
|
+
puts the cards back where they were then */
|
|
3340
|
+
let pending = null; // the latest gesture's: { op, view, card, place, to, exact, label, ids, base, at }
|
|
3341
|
+
const moves = []; // { view, base, after, before, then }: places an update applied, and the ones before it
|
|
3342
|
+
const viewIn = (m, vid) => arrOf(m && m.views).find(v => v && v.id === vid) || {};
|
|
3343
|
+
const boxIdOf = (m, vid, id) => { const bs = arrOf(viewIn(m, vid).containers).filter(b => b && b.id != null && arrOf(b.nodeIds).includes(id)); // the innermost box listing it
|
|
3344
|
+
return ((bs.find(b => !bs.some(o => o.parent === b.id)) || bs[0]) || {}).id ?? null; };
|
|
3345
|
+
const norm = t => String(t ?? '').trim().replace(/\s+/g, ' ').toLowerCase();
|
|
3346
|
+
const labelIn = (m, v, id) => norm(((v.nodeOverrides || {})[id] || {}).label ?? ([...arrOf(m.nodes), ...arrOf(m.views).flatMap(w => arrOf(w && w.nodes))].find(n => n && n.id === id) || {}).label);
|
|
2891
3347
|
function change(c, exact) {
|
|
2892
3348
|
const ev = { id: 'c' + Date.now().toString(36) + '-' + (++seq), view: cur, ...c };
|
|
2893
|
-
// only the latest gesture
|
|
2894
|
-
[
|
|
2895
|
-
|
|
2896
|
-
|
|
2897
|
-
|
|
2898
|
-
else if (c.target && c.target.type === 'node') (hints[cur] ||= {})[c.target.id] = [ev.hint.col, ev.hint.row];
|
|
2899
|
-
}
|
|
2900
|
-
if (c.op === 'add' && c.kind === 'node') selectNew = true;
|
|
3349
|
+
// only the latest gesture waits for the host: an edit the host never answered leaves no trace
|
|
3350
|
+
const place = ev.hint && Number.isFinite(ev.hint.col) ? [ev.hint.col, ev.hint.row] : null, card = c.target && c.target.type === 'node' ? c.target.id : null, v = viewIn(current, cur);
|
|
3351
|
+
pending = (place || exact) && (card || (c.op === 'add' && c.kind === 'node')) ? { op: c.op, view: cur, card, place, exact, label: norm(c.label), ids: arrOf(v.nodeIds),
|
|
3352
|
+
to: c.op === 'move' && (c.to ?? null) !== boxIdOf(current, cur, card) ? c.to ?? null : undefined, base: JSON.stringify(current), at: JSON.stringify((v.layout || {})[card] ?? null) } : null;
|
|
3353
|
+
selectNew = c.op === 'add' && c.kind === 'node'; // the latest gesture's: an add the host never answered selects nothing later
|
|
2901
3354
|
emit('change', ev);
|
|
2902
3355
|
return ev;
|
|
2903
3356
|
}
|
|
3357
|
+
function answered(p, m2, json) { // the update's model shows the edit: the card its places go to, else null
|
|
3358
|
+
const v = viewIn(m2, p.view), was = new Set(p.ids);
|
|
3359
|
+
if (p.op === 'add') return arrOf(v.nodeIds).find(id => !was.has(id) && labelIn(m2, v, id) === p.label) ?? null; // a new card with its name
|
|
3360
|
+
if (p.to !== undefined) return boxIdOf(m2, p.view, p.card) === p.to ? p.card : null; // into or out of a box
|
|
3361
|
+
return json === p.base || JSON.stringify((v.layout || {})[p.card] ?? null) !== p.at ? p.card : null; // a place alone
|
|
3362
|
+
}
|
|
2904
3363
|
const worldOf = e => { const r = stage.getBoundingClientRect(); return cam.toWorld(e.clientX - r.left, e.clientY - r.top); };
|
|
2905
3364
|
const area = f => f.w * f.h;
|
|
2906
3365
|
const boxAt = (x, y) => (geo.families || []).filter(f => x >= f.x && x <= f.x + f.w && y >= f.y && y <= f.y + f.h).sort((a, b) => area(a) - area(b))[0] || null;
|
|
@@ -2945,27 +3404,34 @@ export function render(container, model, options = {}) {
|
|
|
2945
3404
|
if (k === 'Delete' && can('delete') && sel) { change({ op: 'delete', target: ofSel(sub) ? sub : sel }); return true; }
|
|
2946
3405
|
return false;
|
|
2947
3406
|
}
|
|
2948
|
-
/* a text field over the canvas: names a new card, or renames a card, line or container
|
|
2949
|
-
|
|
2950
|
-
|
|
2951
|
-
const
|
|
3407
|
+
/* a text field over the canvas: names a new card, or renames a card, line or container. It follows the camera, and a
|
|
3408
|
+
redraw of the same view keeps it over its card (a new view, or its card gone, closes it: see mount) */
|
|
3409
|
+
const nameBox = ref => { // where the field sits, in map units: over a card's name, a line's label (or its middle), a box's title
|
|
3410
|
+
if (ref.type === 'node') { const n = geo.byId[ref.id]; return n ? { x: n.x + 50, y: n.y + n.h / 2 - 13, w: n.w - 58, h: 26 } : null; }
|
|
3411
|
+
if (ref.type === 'edge') { const e = geo.edges[edgeIndex(ref)]; if (!e) return null; const L = e.lbl, m = e.pts[Math.floor(e.pts.length / 2)];
|
|
3412
|
+
return L ? { x: L.x - 8, y: L.y - 2, w: L.w + 16, h: L.h + 4 } : { x: m[0] - 60, y: m[1] - 12, w: 120, h: 24 }; }
|
|
3413
|
+
const f = ref.type === 'container' && (geo.families || []).find(x => x.id === ref.id); return f ? { x: f.x + 8, y: f.y + 2, w: Math.min(f.w - 16, 220), h: 24 } : null; };
|
|
3414
|
+
function editor(where, text, done, ref = null) { // where: a box in map units (a new card's), or ref names what the field sits over
|
|
3415
|
+
if (naming) naming.close(false);
|
|
3416
|
+
const inp = document.createElement('input');
|
|
2952
3417
|
inp.className = 'um-edit um-rename'; inp.value = text; inp.setAttribute('aria-label', text ? 'New name' : 'Name of the new card');
|
|
2953
|
-
|
|
2954
|
-
|
|
3418
|
+
const place = () => { const box = ref ? nameBox(ref) : where; if (!box) return; const k = cam.s.k, [W, H] = cam.size();
|
|
3419
|
+
Object.assign(inp.style, { left: (box.x - cam.s.cx) * k + W / 2 + 'px', top: (box.y - cam.s.cy) * k + H / 2 + 'px', width: Math.max(120, box.w * k) + 'px', height: Math.max(22, box.h * k) + 'px', fontSize: Math.max(11, 13 * k) + 'px' }); };
|
|
3420
|
+
place(); stage.appendChild(inp); inp.focus(); inp.select();
|
|
2955
3421
|
// once only: removing a focused field can fire blur (cancel) in the middle of a commit
|
|
2956
3422
|
let closed = false;
|
|
2957
|
-
const finish = ok => { if (closed) return; closed = true; const v = inp.value.trim(); if (ok && v && v !== text) done(v); inp.remove(); stage.focus({ preventScroll: true }); };
|
|
3423
|
+
const finish = ok => { if (closed) return; closed = true; naming = null; touch(); const v = inp.value.trim(); if (ok && v && v !== text) done(v); inp.remove(); stage.focus({ preventScroll: true }); };
|
|
3424
|
+
naming = { ref, place, close: finish }; touch();
|
|
2958
3425
|
inp.addEventListener('keydown', e => { e.stopPropagation(); if (e.key === 'Enter') { e.preventDefault(); finish(true); } else if (e.key === 'Escape') { e.preventDefault(); finish(false); } });
|
|
2959
3426
|
inp.addEventListener('blur', () => finish(false));
|
|
2960
3427
|
inp.addEventListener('pointerdown', e => e.stopPropagation());
|
|
2961
3428
|
return inp;
|
|
2962
3429
|
}
|
|
2963
3430
|
function rename(ref) {
|
|
2964
|
-
if (!can('rename') || !ref) return;
|
|
2965
|
-
|
|
2966
|
-
|
|
2967
|
-
|
|
2968
|
-
else if (ref.type === 'container') { const f = (geo.families || []).find(x => x.id === ref.id); if (!f) return; editor({ x: f.x + 8, y: f.y + 2, w: Math.min(f.w - 16, 220), h: 24 }, String(f.label || f.id), v => change({ op: 'rename', target: ref, label: v })); }
|
|
3431
|
+
ref = copyRef(ref); if (!can('rename') || !ref || !nameBox(ref)) return;
|
|
3432
|
+
const n = ref.type === 'node' && geo.byId[ref.id], e = ref.type === 'edge' && geo.edges[edgeIndex(ref)], f = ref.type === 'container' && (geo.families || []).find(x => x.id === ref.id);
|
|
3433
|
+
const text = n ? String(ctx.nodeIn(n.id, cur).label ?? n.label ?? '') : e ? String((e.all || [e])[0].label || '') : String(f.label || f.id);
|
|
3434
|
+
editor(null, text, v => change({ op: 'rename', target: ref, label: v }), ref);
|
|
2969
3435
|
}
|
|
2970
3436
|
function addAt(x, y) {
|
|
2971
3437
|
if (!can('add') || !geo) return;
|
|
@@ -2980,11 +3446,11 @@ export function render(container, model, options = {}) {
|
|
|
2980
3446
|
let pressed = false;
|
|
2981
3447
|
stage.addEventListener('pointerdown', () => { swallow = 0; pressed = true; }, true);
|
|
2982
3448
|
['pointerup', 'pointercancel'].forEach(t => stage.addEventListener(t, () => { pressed = false; }, true));
|
|
2983
|
-
function focusMap(ref = sel) {
|
|
3449
|
+
function focusMap(ref = sel, reveal = true) {
|
|
2984
3450
|
const line = ref && ref.type === 'edge' && svg && svg.querySelector(`.line-key[data-i="${edgeIndex(ref)}"]`); if (line) return line.focus({ preventScroll: true }); // a line (#43)
|
|
2985
3451
|
const id = ref && (ref.type === 'node' ? ref.id : ref.node), g = id && svg && [...svg.querySelectorAll('.node')].find(x => x.dataset.id === id); // no CSS.escape: jsdom has none
|
|
2986
3452
|
if (!g) return stage.focus({ preventScroll: true });
|
|
2987
|
-
g.focus({ preventScroll: true }); const n = geo.byId[id]; if (n) cam.reveal({ x: n.x, y: n.y, w: n.w, h: n.h });
|
|
3453
|
+
g.focus({ preventScroll: true }); const n = geo.byId[id]; if (n && reveal) cam.reveal({ x: n.x, y: n.y, w: n.w, h: n.h });
|
|
2988
3454
|
}
|
|
2989
3455
|
stage.addEventListener('focus', e => { if (!pressed && sel && (sel.type === 'node' || sel.type === 'edge') && !(e.relatedTarget && root.contains(e.relatedTarget))) focusMap(); });
|
|
2990
3456
|
stage.addEventListener('focusin', e => { const t = e.target; if (pressed || !cam || !t.closest || !t.closest('.node, .um-key, .family [data-badge]') || typeof t.getBBox !== 'function') return;
|
|
@@ -2993,7 +3459,7 @@ export function render(container, model, options = {}) {
|
|
|
2993
3459
|
stage.addEventListener('pointerdown', e => {
|
|
2994
3460
|
if (!E || e.button !== 0 || e.altKey || !geo) return;
|
|
2995
3461
|
const hd = e.target.closest('.um-handle');
|
|
2996
|
-
if (hd && can('add')) { e.preventDefault(); try { stage.setPointerCapture(e.pointerId); } catch (_) {} conn = { from: hd.dataset.from, path: null }; return; }
|
|
3462
|
+
if (hd && can('add')) { e.preventDefault(); try { stage.setPointerCapture(e.pointerId); } catch (_) {} conn = { from: hd.dataset.from, path: null }; touch(); return; }
|
|
2997
3463
|
const g = e.target.closest('.node');
|
|
2998
3464
|
if (g && geo.byId[g.dataset.id] && (can('move') || can('reorder'))) drag = { id: g.dataset.id, g, x0: e.clientX, y0: e.clientY, on: false, ghost: null, pid: e.pointerId };
|
|
2999
3465
|
});
|
|
@@ -3007,13 +3473,15 @@ export function render(container, model, options = {}) {
|
|
|
3007
3473
|
if (!drag) return;
|
|
3008
3474
|
const k = cam.s.k, dx = (e.clientX - drag.x0) / k, dy = (e.clientY - drag.y0) / k;
|
|
3009
3475
|
if (!drag.on) { if (Math.hypot(e.clientX - drag.x0, e.clientY - drag.y0) < 5) return;
|
|
3010
|
-
drag.on = true; try { stage.setPointerCapture(drag.pid); } catch (_) {} drag.ghost = drag.g.cloneNode(true); drag.ghost.classList.add('um-ghost'); drag.ghost.classList.remove('sel'); svg.appendChild(drag.ghost); }
|
|
3476
|
+
drag.on = true; try { stage.setPointerCapture(drag.pid); } catch (_) {} drag.ghost = drag.g.cloneNode(true); drag.ghost.classList.add('um-ghost'); drag.ghost.classList.remove('sel'); svg.appendChild(drag.ghost); touch(); }
|
|
3011
3477
|
drag.ghost.setAttribute('transform', `translate(${dx} ${dy})`);
|
|
3012
3478
|
const n = geo.byId[drag.id], into = boxAt(n.x + n.w / 2 + dx, n.y + n.h / 2 + dy);
|
|
3013
3479
|
svg.querySelectorAll('.family.um-target').forEach(f => f.classList.remove('um-target'));
|
|
3014
3480
|
if (into) { const f = svg.querySelector(`.family[data-family="${CSS.escape(into.id)}"]`); if (f) f.classList.add('um-target'); }
|
|
3015
3481
|
});
|
|
3016
3482
|
const endGesture = e => {
|
|
3483
|
+
if (conn || drag) touch(); // the gesture ends
|
|
3484
|
+
if (stale) { stale = false; swallow = performance.now() + 400; return; } // the release of a gesture Escape or a new picture cancelled
|
|
3017
3485
|
if (conn) { const c = conn; conn = null; if (c.path) c.path.remove(); swallow = performance.now() + 400;
|
|
3018
3486
|
const g = root.elementFromPoint(e.clientX, e.clientY), t = g && g.closest && g.closest('.node');
|
|
3019
3487
|
if (t && t.dataset.id !== c.from && geo.byId[t.dataset.id]) change({ op: 'add', kind: 'edge', from: c.from, to: t.dataset.id });
|
|
@@ -3037,22 +3505,21 @@ export function render(container, model, options = {}) {
|
|
|
3037
3505
|
else d.ghost.remove();
|
|
3038
3506
|
};
|
|
3039
3507
|
stage.addEventListener('pointerup', endGesture);
|
|
3040
|
-
stage.addEventListener('pointercancel', () => { if (drag && drag.ghost) drag.ghost.remove(); if (conn && conn.path) conn.path.remove(); drag = conn = null; });
|
|
3508
|
+
stage.addEventListener('pointercancel', () => { if (drag && drag.ghost) drag.ghost.remove(); if (conn && conn.path) conn.path.remove(); drag = conn = null; stale = false; });
|
|
3041
3509
|
/* the model to draw after an update: cards keep the slots they had (the host's own layout wins), a moved or added card
|
|
3042
3510
|
takes its hint; kept in this handle only, never written to the host's model */
|
|
3043
|
-
function withPlaces(m) {
|
|
3511
|
+
function withPlaces(m, use = null) { // use: an edit the update applied, { view, hv: id -> its place, ex: a reordered group's places }
|
|
3044
3512
|
if (options.stableLayout === false) return m;
|
|
3045
3513
|
const out = structuredClone(m);
|
|
3046
3514
|
(out.views || []).forEach(v => {
|
|
3047
|
-
const was = placed[v.id], hv =
|
|
3048
|
-
if (!was && !Object.keys(hv).length && !Object.keys(ex).length
|
|
3515
|
+
const was = placed[v.id], { hv = {}, ex = {} } = use && use.view === v.id ? use : {};
|
|
3516
|
+
if (!was && !Object.keys(hv).length && !Object.keys(ex).length) return;
|
|
3049
3517
|
const ids = (v.nodeIds || []).filter(id => typeof id === 'string'), mine = v.layout || {}, L = {}, taken = new Set(), key = p => p[0] + ',' + p[1];
|
|
3050
3518
|
const put = (id, p) => { let [c, r] = p; while (taken.has(key([c, r]))) r++; L[id] = [c, r]; taken.add(key([c, r])); };
|
|
3051
3519
|
ids.forEach(id => { if (Array.isArray(mine[id])) { L[id] = mine[id]; taken.add(key(mine[id])); } }); // the host's own layout
|
|
3052
3520
|
ids.forEach(id => { if (!L[id] && ex[id]) put(id, ex[id]); }); // a reordered column group
|
|
3053
3521
|
ids.filter(id => !L[id] && !hv[id] && was && was[id]).sort((a, b) => was[a][0] - was[b][0] || was[a][1] - was[b][1]).forEach(id => put(id, was[id])); // everyone stays
|
|
3054
|
-
ids.forEach(id => { if (!L[id] && hv[id]) put(id, hv[id]); }); // a moved card: its drop, or the nearest free slot
|
|
3055
|
-
ids.filter(id => !L[id] && !(was && was[id])).forEach(id => { if (adds.length) put(id, adds.shift()); }); // a new card likewise
|
|
3522
|
+
ids.forEach(id => { if (!L[id] && hv[id]) put(id, hv[id]); }); // a moved or added card: its drop, or the nearest free slot
|
|
3056
3523
|
if (Object.keys(L).length) v.layout = L;
|
|
3057
3524
|
});
|
|
3058
3525
|
return out;
|
|
@@ -3085,13 +3552,15 @@ export function render(container, model, options = {}) {
|
|
|
3085
3552
|
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
|
|
3086
3553
|
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
|
|
3087
3554
|
open(g.dataset.id); });
|
|
3088
|
-
stage.addEventListener('keydown', e => { if (e.defaultPrevented || e.metaKey || e.ctrlKey || (e.altKey && !E) || e.target.closest('input')) return;
|
|
3555
|
+
stage.addEventListener('keydown', e => { if (e.defaultPrevented || e.metaKey || e.ctrlKey || (e.altKey && !E) || e.target.closest('input')) return;
|
|
3556
|
+
if ((e.key === 'Enter' || e.key === ' ') && e.target.closest('button')) return; // a button of the map's own (zoom, ↑, ≋, Back) presses itself
|
|
3557
|
+
if (key(e)) { e.preventDefault(); e.stopPropagation(); } });
|
|
3089
3558
|
stage.addEventListener('pointerover', e => { const g = e.target.closest('.node'); if (!g) return; svg.querySelectorAll('.edge-g').forEach(x => x.classList.toggle('hov', x.dataset.from === g.dataset.id || x.dataset.to === g.dataset.id)); });
|
|
3090
3559
|
stage.addEventListener('pointerout', e => { if (e.target.closest('.node')) svg.querySelectorAll('.edge-g.hov').forEach(x => x.classList.remove('hov')); });
|
|
3091
3560
|
const ctl = q('.ctl');
|
|
3092
|
-
if (ctl) ctl.addEventListener('click', e => { const z = e.target.closest('button')?.dataset.z; if (!z) return;
|
|
3561
|
+
if (ctl) ctl.addEventListener('click', e => { const z = e.target.closest('button')?.dataset.z; if (!z) return; readerMoved(); // a press by key too: a story playing waits
|
|
3093
3562
|
if (z === 'in') cam.zoomAt(1.4, null, null, true); else if (z === 'out') cam.zoomAt(1 / 1.4, null, null, true); else if (z === 'fit') cam.fit(); else if (z === 'one') cam.actual(); else if (z === 'up') up(); else if (z === 'flow') setFlow(!stage.classList.contains('flowing')); else if (z === 'add') addAt(cam.s.cx, cam.s.cy); });
|
|
3094
|
-
setFlow(options.flow ?? !(
|
|
3563
|
+
setFlow(options.flow ?? !reducedMotion(), false); // the default, or the host's: not saved as the page reader's choice
|
|
3095
3564
|
|
|
3096
3565
|
// the stage changes size with its container: keep the zoom, refit only an untouched view (no ResizeObserver, as in
|
|
3097
3566
|
// jsdom: the view is fitted once)
|
|
@@ -3103,16 +3572,16 @@ export function render(container, model, options = {}) {
|
|
|
3103
3572
|
selection, hover, flow animation or edit handles, whatever the screen shows; the screen is left as it was. */
|
|
3104
3573
|
function standalone(scale = 1, vid = cur, fontCSS = '') {
|
|
3105
3574
|
if (settle) settle(); // an export right after update() draws where things end up, not mid-ease
|
|
3106
|
-
if (!svg || !geo) return '';
|
|
3575
|
+
if (!svg || !geo || vid == null) return ''; // a model with no views draws nothing
|
|
3107
3576
|
let s = svg, g = geo, off = null;
|
|
3108
3577
|
if (vid !== cur) { // another view: drawn off screen, inside the shadow root so the same styles apply
|
|
3109
3578
|
const v = ctx.V[vid]; if (!v || !(v.nodeIds || []).some(i => ctx.N[i])) return '';
|
|
3110
|
-
const c =
|
|
3579
|
+
const c = pic(vid);
|
|
3111
3580
|
off = document.createElement('div'); off.style.cssText = 'position:absolute;left:-100000px;top:0;width:10px;height:10px;overflow:hidden;pointer-events:none';
|
|
3112
3581
|
off.innerHTML = c.markup; stage.parentNode.appendChild(off); s = off.querySelector('svg'); s.classList.add('map-svg'); g = c.g;
|
|
3113
3582
|
}
|
|
3114
3583
|
if (off) { const mine = layers.get('lens'), L = mine && arrOf(ctx.D.lenses).find(x => x && x.id === lensId); // the host's marks, on another view too (#63), and the lens as it lights that view
|
|
3115
|
-
if (L) layers.set('lens', lensOf(ctx, vid, L).layer); drawMarks(s, g); if (L) layers.set('lens', mine); }
|
|
3584
|
+
if (L) layers.set('lens', lensOf(ctx, vid, L).layer); drawMarks(s, g, vid); if (L) layers.set('lens', mine); }
|
|
3116
3585
|
const s0 = sel, flowing = stage.classList.contains('flowing'), hov = [...svg.querySelectorAll('.hov')];
|
|
3117
3586
|
sel = null; paint(); stage.classList.remove('flowing'); hov.forEach(x => x.classList.remove('hov')); stage.classList.add('um-still');
|
|
3118
3587
|
const clone = s.cloneNode(true), b = g.bounds;
|
|
@@ -3141,7 +3610,7 @@ export function render(container, model, options = {}) {
|
|
|
3141
3610
|
let settle = null; // ends an ease still running (before an export, or the next update)
|
|
3142
3611
|
function easeIn(oldGeo, oldSvg) {
|
|
3143
3612
|
if (settle) settle();
|
|
3144
|
-
if (options.ease === false ||
|
|
3613
|
+
if (options.ease === false || still() || !oldGeo || !oldSvg || !geo || !svg || oldSvg === svg) return;
|
|
3145
3614
|
const T = 600, moves = [], key = e => edgeRefIds(e).join(' ') || e.from + '>' + e.to, all = (s, q) => [...s.querySelectorAll(q)];
|
|
3146
3615
|
const was = new Map(oldGeo.nodes.map(n => [n.id, n])), now = new Set(geo.nodes.map(n => n.id));
|
|
3147
3616
|
const lines = new Map(oldGeo.edges.map((e, i) => [key(e), [e, i]])), kept = new Set(geo.edges.map(key));
|
|
@@ -3153,7 +3622,7 @@ export function render(container, model, options = {}) {
|
|
|
3153
3622
|
const gone = document.createElementNS('http://www.w3.org/2000/svg', 'g');
|
|
3154
3623
|
gone.setAttribute('class', 'um-leaving'); gone.setAttribute('aria-hidden', 'true'); gone.style.pointerEvents = 'none';
|
|
3155
3624
|
const leave = x => { const c = x.cloneNode(true);
|
|
3156
|
-
[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'); });
|
|
3625
|
+
[c, ...c.querySelectorAll('*')].forEach(y => { ['tabindex', 'role', 'data-id', 'data-i', 'data-from', 'data-to', 'data-o', 'data-t', 'data-chip', 'data-badge', 'data-edges'].forEach(a => y.removeAttribute(a)); y.classList.remove('node', 'edge-g', 'e-lab', 'sel', 'hot', 'hov'); }); // a port's data-edges named lines the new picture may lack
|
|
3157
3626
|
gone.appendChild(c); };
|
|
3158
3627
|
oldGeo.nodes.forEach(n => { if (!now.has(n.id)) all(oldSvg, `.node[data-id="${CSS.escape(n.id)}"]`).forEach(leave); });
|
|
3159
3628
|
lines.forEach(([, i], k) => { if (!kept.has(k)) all(oldSvg, `.edge-g[data-i="${i}"], .e-lab[data-i="${i}"]`).forEach(leave); });
|
|
@@ -3169,37 +3638,63 @@ export function render(container, model, options = {}) {
|
|
|
3169
3638
|
|
|
3170
3639
|
function update(model2, relayout = false, unfolding = false) { // unfolding: the same model, a card unfolded or folded
|
|
3171
3640
|
if (dead) return;
|
|
3172
|
-
|
|
3173
|
-
|
|
3174
|
-
const
|
|
3641
|
+
if (!model2 || typeof model2 !== 'object' || Array.isArray(model2)) throw new TypeError(`umlimator: update(model) takes a map model, an object with nodes and views; it got ${model2 === null ? 'null' : Array.isArray(model2) ? 'an array' : typeof model2}`);
|
|
3642
|
+
// an identical model: nothing to redraw, unless it answers an edit (one that changed only a place)
|
|
3643
|
+
const json = JSON.stringify(model2), same = json === JSON.stringify(current), own = relayout || unfolding; // own: the map drawing its own model again, no answer to an edit
|
|
3644
|
+
if (!own && !pending && same) return;
|
|
3645
|
+
// the model before or after a move an update applied (undo, redo): the cards go back where they were then
|
|
3646
|
+
const back = !own && !same && [...moves].reverse().find(x => x.base === json || x.after === json);
|
|
3647
|
+
if (back) placed[back.view] = structuredClone(back.base === json ? back.before : back.then);
|
|
3648
|
+
// the edit waiting for this answer: its places if the model shows it, else none (it is dropped either way)
|
|
3649
|
+
const p = own ? null : pending, card = p && answered(p, model2, json);
|
|
3650
|
+
if (!own) pending = null;
|
|
3651
|
+
const use = card ? { view: p.view, hv: p.place ? { [card]: p.place } : {}, ex: p.exact || {} } : null, from = use && structuredClone(placed[p.view] || null);
|
|
3652
|
+
// a card E or Alt + click asked to unfold before its view was loaded unfolds once a model brings that view (#71)
|
|
3653
|
+
const brought = []; Object.entries(asked).forEach(([vid, m]) => m.forEach((c, nid) => { if (!arrOf(model2.views).some(v => v && v.id === c)) return;
|
|
3654
|
+
m.delete(nid); const l = expanded[vid] ||= []; if (!l.includes(nid)) { l.push(nid); if (vid === cur) brought.push(nid); } }));
|
|
3655
|
+
const old = ctx, keys = {}, wantNew = selectNew && !own;
|
|
3175
3656
|
old.views.forEach(v => { keys[v.id] = relayout ? null : old.viewKey(v.id); });
|
|
3176
3657
|
current = model2;
|
|
3177
|
-
ctx = mapContext(withPlaces(model2), { measure, kinds: !!P.kinds, prev: options.stableLayout === false ? null : old.planCache, expand: expanded });
|
|
3658
|
+
ctx = mapContext(withPlaces(model2, use), { measure, kinds: !!P.kinds, prev: options.stableLayout === false ? null : old.planCache, expand: expanded });
|
|
3178
3659
|
Object.keys(expanded).forEach(v => { expanded[v] = ctx.unfolded[v] || []; }); // a card or inner view the model no longer has folds
|
|
3179
|
-
|
|
3660
|
+
if (!own) selectNew = false;
|
|
3180
3661
|
ctx.views.forEach(v => { const id = v.id; if (keys[id] == null || keys[id] !== ctx.viewKey(id)) return;
|
|
3181
3662
|
if (id in old.planCache) ctx.planCache[id] = old.planCache[id];
|
|
3182
3663
|
if (old.layoutCache[id]) ctx.layoutCache[id] = old.layoutCache[id];
|
|
3183
3664
|
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)
|
|
3184
|
-
|
|
3185
|
-
if (!
|
|
3186
|
-
|
|
3665
|
+
restory(); // the story shown takes this model's steps, or stops when the model has it no more
|
|
3666
|
+
if (!cur) { // nothing shown yet (a first model with no views): this model's first view is drawn
|
|
3667
|
+
const v0 = ctx.V[options.view] ? options.view : ctx.rootId;
|
|
3668
|
+
if (v0 && cam) { mount(v0); cam.set(startView()); cam.touched = false; emitSel(); emit('viewchange', { view: cur, from: null }); }
|
|
3669
|
+
return; }
|
|
3670
|
+
if (!ctx.V[cur]) { // the view shown is gone: the root, or the empty card of a model with no views
|
|
3671
|
+
const from = cur; mount(ctx.rootId ?? null); cam.set(startView()); cam.touched = false; emitSel(); if (cur) emit('viewchange', { view: cur, from }); return; }
|
|
3672
|
+
if (ctx.geoCache[cur] && keys[cur] === ctx.viewKey(cur) && ctx.geoCache[cur].shapes === noteFor(cur)) { if (lensId != null) { relens(); drawMarks(); } if (pc) pc.chrome(); return; } // this view's picture did not change (its lens or the views may have)
|
|
3187
3673
|
const keep = sel, keepMore = more, touched = cam.touched, prev = [geo, svg];
|
|
3188
3674
|
mount(cur);
|
|
3189
3675
|
if (touched) cam.set({}); else cam.set(startView());
|
|
3190
3676
|
cam.touched = touched;
|
|
3191
|
-
|
|
3192
|
-
if (
|
|
3193
|
-
|
|
3677
|
+
// ponytail: the models' JSON, kept for 20 moves; a hash of them if models grow large enough for that to weigh
|
|
3678
|
+
if (use && p.view === cur && json !== p.base) { moves.push({ view: cur, base: p.base, after: json, before: from, then: structuredClone(placed[cur] || null) }); if (moves.length > 20) moves.shift(); }
|
|
3679
|
+
const box = brought.find(nid => (expanded[cur] || []).includes(nid));
|
|
3680
|
+
if (box) unfolded(box, true); // a card asked to unfold earlier: its box is selected
|
|
3681
|
+
else if (wantNew && use && p.op === 'add' && geo.byId[card]) select(nodeRef(card), { reveal: false }); // the card added, as the reader named it
|
|
3682
|
+
else { // the selection that is still drawn stays, and a select event comes only if it changed (unfolding: the caller says)
|
|
3683
|
+
if (keep && exists(keep)) { sel = keep; more = keep.type === 'node' ? keepMore.filter(id => geo.byId[id]) : []; paint(); }
|
|
3684
|
+
if (!unfolding) emitSel(); }
|
|
3194
3685
|
if (!relayout) easeIn(...prev); // a font that came in late moves nothing on purpose: no ease
|
|
3686
|
+
touch();
|
|
3195
3687
|
}
|
|
3196
3688
|
|
|
3197
3689
|
function destroy() {
|
|
3198
3690
|
if (dead) return;
|
|
3691
|
+
cancelGesture();
|
|
3199
3692
|
dead = true; if (ro) ro.disconnect(); cancelAnimationFrame(rz); stopStory();
|
|
3200
|
-
|
|
3693
|
+
answer(false); endTrip(); // every promise settles: a move's setView() false, a tour's and a replay's done false
|
|
3694
|
+
timers.forEach(clearTimeout); timers.clear(); sleeping.forEach(wake => wake());
|
|
3201
3695
|
if (cam) cam.stopAnimation();
|
|
3202
3696
|
host.remove();
|
|
3697
|
+
clearTimeout(restT); camRest = camNow(); flushState(true); // a state listener hears destroyed: true, last, and where the camera stopped
|
|
3203
3698
|
Object.keys(subs).forEach(k => delete subs[k]);
|
|
3204
3699
|
}
|
|
3205
3700
|
|
|
@@ -3208,39 +3703,52 @@ export function render(container, model, options = {}) {
|
|
|
3208
3703
|
Each mount draws them again, so they survive update() and setView(); a card or line the view doesn't show is ignored.
|
|
3209
3704
|
toSVG() and toPNG() draw them too, at rest: they are the host's, like the model (the selection and hover are not). */
|
|
3210
3705
|
const layers = new Map(), DIM = .2, clamp01 = x => Math.min(1, Math.max(0, +x || 0));
|
|
3706
|
+
const scopes = new Map(); // layer name -> the views it is about: a highlight's Refs name theirs, and it lights those only
|
|
3211
3707
|
// a drawn line that stands for several edges takes the strongest of their marks: a pulsing one, then the most lit, then the fastest
|
|
3212
3708
|
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];
|
|
3213
|
-
function markOf(get) { // one card's or line's mark over
|
|
3709
|
+
function markOf(get, here, dims) { // one card's or line's mark over the layers here: each property from the latest that sets it, the lowest level
|
|
3214
3710
|
let out = null;
|
|
3215
|
-
|
|
3711
|
+
here.forEach(L => { const m = get(L); if (!m && !dims.has(L)) return; out ||= { level: 1 };
|
|
3216
3712
|
if (m) for (const k in m) if (m[k] != null && k !== 'level') out[k] = m[k];
|
|
3217
3713
|
out.level = Math.min(out.level, m ? clamp01(m.level ?? 1) : DIM); });
|
|
3218
3714
|
return out;
|
|
3219
3715
|
}
|
|
3220
3716
|
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]]);
|
|
3221
|
-
function drawMarks(s = svg, g = geo) {
|
|
3717
|
+
function drawMarks(s = svg, g = geo, vid = cur) {
|
|
3222
3718
|
if (!s || !g) return;
|
|
3223
3719
|
s.querySelectorAll('.um-rings, .um-marks, .um-glow').forEach(x => x.remove());
|
|
3224
3720
|
s.querySelectorAll('.node[opacity], .edge-g[opacity], .e-lab[opacity]').forEach(x => x.removeAttribute('opacity'));
|
|
3225
3721
|
s.querySelectorAll('.edge[style], .flowline[style]').forEach(x => x.removeAttribute('style'));
|
|
3226
3722
|
s.querySelectorAll('.node[aria-description]').forEach(x => x.removeAttribute('aria-description'));
|
|
3227
3723
|
s.querySelectorAll('[data-was]').forEach(x => { if (x.tagName === 'title') x.textContent = x.getAttribute('data-was'); else x.setAttribute('aria-label', x.getAttribute('data-was')); x.removeAttribute('data-was'); });
|
|
3228
|
-
|
|
3229
|
-
|
|
3724
|
+
// the layers about this view; one that dims dims only where it names something: on a view it names nothing of, nothing fades
|
|
3725
|
+
const named = [...layers].filter(([n]) => !scopes.has(n) || scopes.get(n).includes(vid)), here = named.map(([, L]) => L);
|
|
3726
|
+
const names = L => g.nodes.some(n => (L.nodes || {})[n.id]) || g.edges.some(e => edgeRefIds(e).some(x => (L.edges || {})[x]));
|
|
3727
|
+
let dims = new Set(here.filter(L => L.dim && names(L)));
|
|
3728
|
+
// a story's step dims alone (0.14.0): a layer that dimmed before the step keeps its marks but not its dimming, and one set
|
|
3729
|
+
// while the step shows dims in its place, so a step looks as it was told and what the reader lights later still shows
|
|
3730
|
+
const sl = layers.get('story'), at = n => dimAt.get(n) || 0;
|
|
3731
|
+
if (dims.has(sl)) dims = named.some(([n, L]) => L !== sl && dims.has(L) && at(n) > at('story')) ? new Set([...dims].filter(L => L !== sl)) : new Set([sl]);
|
|
3732
|
+
const markOf2 = get => markOf(get, here, dims);
|
|
3733
|
+
if (s === svg) stage.classList.toggle('um-lit', dims.size > 0);
|
|
3734
|
+
if (!here.length) return;
|
|
3230
3735
|
const tone = t => 'var(--um-' + String(t || 'accent').replace(/[^\w-]/g, '') + ')', op = m => m.level < 1 ? ` opacity="${m.level}"` : '';
|
|
3231
3736
|
const cards = Object.fromEntries([...s.querySelectorAll('.node')].map(x => [x.dataset.id, x]));
|
|
3232
3737
|
let under = '', over = '';
|
|
3233
|
-
|
|
3738
|
+
const look = lookIn(noteFor(vid), g);
|
|
3739
|
+
g.nodes.forEach(n => { const m = markOf2(L => (L.nodes || {})[n.id]), el = cards[n.id]; if (!m || !el) return;
|
|
3234
3740
|
if (m.level < 1) el.setAttribute('opacity', m.level);
|
|
3235
|
-
|
|
3741
|
+
const sh = (m.tone || m.pulse) && look(n); // a shape's ring: its outline, 5 px out (0.15.0)
|
|
3742
|
+
if (m.tone || m.pulse) under += `<g${op(m)}>${sh ? `<path d="${sh.S.out(sh, 5)}"` : `<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>`;
|
|
3236
3743
|
const b = m.badge == null ? null : badgesOf({ badges: [m.badge] })[0]; if (!b) return; // a tab after the card's own
|
|
3237
|
-
const
|
|
3238
|
-
|
|
3744
|
+
const L = look(n), ty = tabLine(n, L), w = b.text.toUpperCase().length * 6.4 + 10; // on the card's tab line, clear of a head and a folder's tab
|
|
3745
|
+
const x = tabAfter(n, L, w);
|
|
3746
|
+
over += `<g class="um-tab"${op(m)}><rect x="${x}" y="${ty - 8}" width="${w}" height="15" rx="4" fill="${toneOf(b)}"/><text x="${x + w / 2}" y="${ty + 3}" text-anchor="middle" class="tab-tx">${esc(b.text)}</text></g>`;
|
|
3239
3747
|
el.setAttribute('aria-description', b.text); });
|
|
3240
3748
|
const heads = (g.families || []).flatMap(f => [{ x: f.x + 8, y: f.y + 3, w: String(f.label || f.id).length * 6.6 + 8, h: 22 }, // a box's title, 11 px mono
|
|
3241
3749
|
...(f.tabs || []).map(t => ({ x: t.x, y: f.y - 8, w: t.w, h: 16 }))]); // and its tabs
|
|
3242
3750
|
const pills = [], free = r => !g.nodes.some(n => hit(r, n, 8) || (n.tag && hit(r, n.tag, 2))) && !heads.some(h => hit(r, h, 2)) && !g.edges.some(o => o.lbl && hit(r, o.lbl, 2)) && !pills.some(p => hit(r, p, 2));
|
|
3243
|
-
g.edges.forEach((e, i) => { const m =
|
|
3751
|
+
g.edges.forEach((e, i) => { const m = markOf2(L => strongest(edgeRefIds(e).map(x => (L.edges || {})[x]))), eg = s.querySelector(`.edge-g[data-i="${i}"]`); if (!m || !eg) return;
|
|
3244
3752
|
const path = eg.querySelector('.edge'), flow = eg.querySelector('.flowline');
|
|
3245
3753
|
if (m.level < 1) [eg, s.querySelector(`.e-lab[data-i="${i}"]`)].forEach(x => x && x.setAttribute('opacity', m.level));
|
|
3246
3754
|
if (m.tone || m.pulse) path.insertAdjacentHTML('beforebegin', `<path d="${path.getAttribute('d')}" class="um-glow${m.pulse ? ' um-pulse' : ''}" stroke="${tone(m.tone)}"/>`);
|
|
@@ -3264,19 +3772,33 @@ export function render(container, model, options = {}) {
|
|
|
3264
3772
|
if (under && cardsG) cardsG.insertAdjacentHTML('beforebegin', `<g class="um-rings" aria-hidden="true">${under}</g>`);
|
|
3265
3773
|
if (over) s.insertAdjacentHTML('beforeend', `<g class="um-marks" aria-hidden="true">${over}</g>`);
|
|
3266
3774
|
}
|
|
3267
|
-
|
|
3775
|
+
const dimAt = new Map(); let dimClock = 0; // layer name -> when it was last set (a story's step dims alone over older layers, see drawMarks)
|
|
3776
|
+
function overlay(name, layer, views = null) { // views: the views the layer is about (highlight()'s); none: every view
|
|
3268
3777
|
if (dead || name == null) return;
|
|
3269
|
-
|
|
3270
|
-
|
|
3778
|
+
const n = String(name);
|
|
3779
|
+
if (layer == null) { layers.delete(n); dimAt.delete(n); } else { layers.set(n, layer); dimAt.set(n, ++dimClock); } // a Map keeps the order first set
|
|
3780
|
+
if (layer != null && views) scopes.set(n, views); else scopes.delete(n);
|
|
3781
|
+
drawMarks(); touch();
|
|
3271
3782
|
}
|
|
3272
|
-
|
|
3273
|
-
|
|
3274
|
-
if (
|
|
3783
|
+
// the cards in a box, at any depth, on a view (an unfolded card's box too)
|
|
3784
|
+
const boxCards = (vid, id) => { const bs = ((ctx.V[vid] || {}).containers || []).filter(Boolean), out = new Set(), seen = new Set();
|
|
3785
|
+
const walk = b => { if (seen.has(b)) return; seen.add(b); const c = bs.find(x => x.id === b); if (!c) return; (c.nodeIds || []).forEach(i => out.add(i)); bs.filter(x => x.parent === b).forEach(x => walk(x.id)); };
|
|
3786
|
+
walk(id); return out; };
|
|
3787
|
+
/* the 'highlight' layer (or another, `name`: a story step's 'story', the MCP page's 'mcp'): these cards and lines lit, the
|
|
3788
|
+
rest dimmed; a Ref's level (0 to 1), else its distance, fades it. A box's Ref lights its cards and the lines between
|
|
3789
|
+
them; Refs that name their view light that view only */
|
|
3790
|
+
function highlight(refs, name = 'highlight') {
|
|
3791
|
+
if (refs == null) return overlay(name, null);
|
|
3275
3792
|
const L = { nodes: {}, edges: {}, dim: true }, lv = r => clamp01(r.level ?? (r.distance > 0 ? Math.max(.45, 1 - .15 * r.distance) : 1));
|
|
3276
|
-
|
|
3277
|
-
|
|
3278
|
-
|
|
3279
|
-
|
|
3793
|
+
const lit = (o, x, r) => { o[x] = { level: Math.max(lv(r), (o[x] || {}).level || 0) }; }, list = [].concat(refs).filter(Boolean);
|
|
3794
|
+
list.forEach(r => { const id = r.type === 'node' ? r.id : r.node, box = r.type === 'container' ? r.id : r.container; // a chip or badge: its card, or its box
|
|
3795
|
+
if (id != null) lit(L.nodes, id, r);
|
|
3796
|
+
else if (r.type === 'edge') (r.ids || []).forEach(x => lit(L.edges, x, r));
|
|
3797
|
+
else if (box != null) { const vid = r.view ?? cur, ins = boxCards(vid, box), at = x => ins.has(x) || x === box;
|
|
3798
|
+
ins.forEach(x => lit(L.nodes, x, r));
|
|
3799
|
+
if (ctx.V[vid]) ctx.viewEdges(ctx.V[vid]).forEach(e => { if (at(e.from) && at(e.to) && e._id) lit(L.edges, e._id, r); }); } });
|
|
3800
|
+
const views = list.every(r => typeof r.view === 'string') ? [...new Set(list.map(r => r.view))] : null;
|
|
3801
|
+
overlay(name, L, views);
|
|
3280
3802
|
}
|
|
3281
3803
|
/* lenses (#94): one of the model's saved ways to look at the map, as the 'lens' layer, on every view it is shown on; the
|
|
3282
3804
|
view is not laid out again. A lens that colours cards by owner puts their tones in a legend over the controls. */
|
|
@@ -3284,7 +3806,7 @@ export function render(container, model, options = {}) {
|
|
|
3284
3806
|
function relens() {
|
|
3285
3807
|
const L = lensId == null ? null : arrOf(ctx.D.lenses).find(x => x && x.id === lensId), was = q('.um-lens');
|
|
3286
3808
|
if (was) { was.remove(); clear = null; }
|
|
3287
|
-
if (!L) { lensId = null; layers.delete('lens'); return; } // off, or the model no longer has it
|
|
3809
|
+
if (!L) { const had = lensId != null; lensId = null; layers.delete('lens'); if (had) emit('lens', { lens: null }); return; } // off, or the model no longer has it: the lens event says so
|
|
3288
3810
|
if (!cur || !ctx.V[cur]) return;
|
|
3289
3811
|
const { layer, owners } = lensOf(ctx, cur, L), name = esc(L.title || L.id);
|
|
3290
3812
|
layers.set('lens', layer);
|
|
@@ -3292,55 +3814,79 @@ export function render(container, model, options = {}) {
|
|
|
3292
3814
|
}
|
|
3293
3815
|
function setLens(id) { // true when the model has that lens (or id is null)
|
|
3294
3816
|
if (dead || (id != null && !arrOf(ctx.D.lenses).some(x => x && x.id === id))) return false;
|
|
3295
|
-
lensId = id ?? null; relens(); drawMarks();
|
|
3817
|
+
const was = lensId; lensId = id ?? null; if (lensId != null) dimAt.set('lens', ++dimClock); relens(); drawMarks(); if (lensId !== was) emit('lens', { lens: lensId });
|
|
3296
3818
|
if (cam && geo && !cam.touched) cam.set(startView()); // fitted: the cards keep clear of the legend, as of the controls
|
|
3297
3819
|
return true;
|
|
3298
3820
|
}
|
|
3299
3821
|
/* 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
|
|
3300
3822
|
view (setView, an update that redraws it) ends it, as does destroy() */
|
|
3301
3823
|
let trip = null;
|
|
3302
|
-
|
|
3824
|
+
// a tour's or a replay's dots: over the lines and boxes, under the cards (as the model's own tour), so a dot goes into
|
|
3825
|
+
// a card and out at its next line rather than across the card's name
|
|
3826
|
+
function tripLayer() {
|
|
3827
|
+
const cards = svg.querySelector(':scope > .nodes');
|
|
3828
|
+
(cards || svg).insertAdjacentHTML(cards ? 'beforebegin' : 'beforeend', '<g class="um-trip" aria-hidden="true"></g>');
|
|
3829
|
+
return cards ? cards.previousElementSibling : svg.lastElementChild;
|
|
3830
|
+
}
|
|
3831
|
+
function endTrip(played = false) { const t = trip; if (!t) return; trip = null; cancelAnimationFrame(t.raf); if (t.dot) t.dot.remove(); t.end(played); touch(); }
|
|
3303
3832
|
function startTrip(step, end) { // step(ms played, pauses left out) says whether to go on
|
|
3304
3833
|
endTrip();
|
|
3305
3834
|
const t = { raf: 0, dot: null, paused: false, at: 0, last: performance.now(), end }; trip = t;
|
|
3306
|
-
const frame = now => { if (trip !== t) return; if (dead) return endTrip(); if (!t.paused) t.at += now - t.last; t.last = now;
|
|
3835
|
+
const frame = now => { if (trip !== t) return; if (dead) return endTrip(); if (!t.paused && !paused && !(t.story && story && story.held)) t.at += now - t.last; t.last = now; // paused: the page's Pause; held: a story's step's tour, by its own pause
|
|
3307
3836
|
if (step(t.at)) t.raf = requestAnimationFrame(frame); else endTrip(true); };
|
|
3308
3837
|
t.raf = requestAnimationFrame(frame);
|
|
3309
3838
|
return t;
|
|
3310
3839
|
}
|
|
3311
3840
|
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
|
|
3312
|
-
|
|
3841
|
+
if (i < 0) return null; const pts = drawnPts(geo.edges[i], looker()); // as drawn: into a shape at its outline (0.15.0)
|
|
3842
|
+
return { i, pts: geo.edges[i].from === a ? pts : [...pts].reverse() }; };
|
|
3313
3843
|
const lenOf = pts => pts.slice(1).reduce((s, p, k) => s + Math.hypot(p[0] - pts[k][0], p[1] - pts[k][1]), 0);
|
|
3314
3844
|
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]);
|
|
3315
3845
|
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]; };
|
|
3316
3846
|
/* tour(path): the dot rides the line between each pair of cards in turn, through each card to its next line, and each
|
|
3317
3847
|
card it reaches flashes; where no line joins two cards it skips to the next. Resolves true when it reaches the end. */
|
|
3318
|
-
function tour(path, o = {}) {
|
|
3848
|
+
function tour(path, o = {}, quiet = false) { // quiet: a story's step, whose caption says it
|
|
3319
3849
|
endTrip();
|
|
3320
3850
|
if (dead || !geo || path == null) return Promise.resolve(false);
|
|
3321
3851
|
const ids = [].concat(path).map(String).filter(id => geo.byId[id]), legs = [];
|
|
3322
3852
|
for (let k = 0; k + 1 < ids.length; k++) { const r = lineBetween(ids[k], ids[k + 1]); if (!r) continue;
|
|
3323
3853
|
const prev = legs[legs.length - 1], n = geo.byId[ids[k]];
|
|
3324
|
-
const
|
|
3325
|
-
|
|
3326
|
-
|
|
3854
|
+
const through = prev && prev.to === ids[k] ? [prev.pts[prev.pts.length - 1], [n.x + n.w / 2, n.y + n.h / 2]] : [], pts = [...through, ...r.pts];
|
|
3855
|
+
// skip: the part through the card, where the dot rides under it and can't be seen
|
|
3856
|
+
legs.push({ pts, to: ids[k + 1], len: lenOf(pts), skip: through.length ? lenOf([...through, r.pts[0]]) : 0, from: ids[k], i: r.i }); }
|
|
3857
|
+
if (legs.length && still() && P.tourAt == null) atRest(legs, quiet);
|
|
3858
|
+
if (!legs.length || (still() && P.tourAt == null)) return Promise.resolve(legs.length > 0);
|
|
3327
3859
|
const speed = Math.max(20, +o.speed || 240); // map units a second
|
|
3328
|
-
|
|
3329
|
-
const
|
|
3860
|
+
const g = tripLayer(); g.insertAdjacentHTML('beforeend', '<circle r="5.5" class="tour-dot"/>');
|
|
3861
|
+
const dot = g.firstElementChild; let leg = 0, base = 0;
|
|
3330
3862
|
if (P.tourAt != null) { // the page's #shot=1&at=<0 to 1>: the dot held that far along the ride, a frame for umlimator.py record (#83)
|
|
3331
|
-
|
|
3332
|
-
const
|
|
3863
|
+
// spread over the lines only: a frame inside a card would show no dot (it rides under the cards)
|
|
3864
|
+
const seen = l => l.len - l.skip;
|
|
3865
|
+
let d = clamp01(P.tourAt) * legs.reduce((s, l) => s + seen(l), 0); while (leg < legs.length - 1 && d > seen(legs[leg])) d -= seen(legs[leg++]);
|
|
3866
|
+
const [x, y] = along(legs[leg].pts, legs[leg].skip + d); dot.setAttribute('cx', x); dot.setAttribute('cy', y);
|
|
3333
3867
|
trip = { raf: 0, dot: g, end() {} }; return Promise.resolve(true);
|
|
3334
3868
|
}
|
|
3335
3869
|
return new Promise(res => { startTrip(ms => { const d = ms * speed / 1000;
|
|
3336
3870
|
while (leg < legs.length && d - base >= legs[leg].len) { base += legs[leg].len; flash(legs[leg].to); leg++; }
|
|
3337
3871
|
if (leg >= legs.length) return false;
|
|
3338
|
-
const [x, y] = along(legs[leg].pts, d - base); dot.setAttribute('cx', x); dot.setAttribute('cy', y); return true; }, res).dot = g; });
|
|
3872
|
+
const [x, y] = along(legs[leg].pts, d - base); dot.setAttribute('cx', x); dot.setAttribute('cy', y); return true; }, res); trip.dot = g; trip.kind = 'tour'; trip.story = quiet; touch(); });
|
|
3873
|
+
}
|
|
3874
|
+
// under reduced motion no dot rides: the path is lit at rest instead, its cards ringed and numbered in turn and its lines
|
|
3875
|
+
// glowing (the 'tour' layer, which nothing dims), and the live region says it; it stays until the trip slot is taken
|
|
3876
|
+
function atRest(legs, quiet) {
|
|
3877
|
+
const L = { nodes: {}, edges: {} }, order = [];
|
|
3878
|
+
legs.forEach(l => { [l.from, l.to].forEach(id => { if (!L.nodes[id]) { order.push(id); L.nodes[id] = { tone: 'accent', badge: String(order.length) }; } });
|
|
3879
|
+
edgeRefIds(geo.edges[l.i]).forEach(x => { L.edges[x] = { tone: 'accent' }; }); });
|
|
3880
|
+
overlay('tour', L); trip = { kind: 'path', raf: 0, dot: null, end() { overlay('tour', null); } };
|
|
3881
|
+
if (!quiet) say('Tour: ' + order.map(nameOf).join(' → '));
|
|
3339
3882
|
}
|
|
3340
3883
|
/* replay(spans): each call from one card to another (a span and its parent's) runs along their line at the trace's relative
|
|
3341
3884
|
time, the whole trace in `duration` ms; the line glows while the call lasts and keeps its duration as a label */
|
|
3885
|
+
let replays = 0; // the newest replay's number: an older one's stop() leaves the newer one's marks alone
|
|
3342
3886
|
function replay(spans, o = {}) {
|
|
3887
|
+
if (dead) return { hops: 0, skipped: 0, done: Promise.resolve(false), pause() {}, resume() {}, stop() {} };
|
|
3343
3888
|
endTrip(); overlay('replay', null);
|
|
3889
|
+
const gen = ++replays;
|
|
3344
3890
|
const list = [].concat(spans || []).filter(s => s && Number.isFinite(+s.start) && Number.isFinite(+s.end));
|
|
3345
3891
|
const byId = new Map(list.map(s => [String(s.id), s])), cardOf = cardMatcher(geo ? geo.nodes : [], o.match);
|
|
3346
3892
|
let skipped = 0, t = null, done; const hops = [];
|
|
@@ -3350,30 +3896,36 @@ export function render(container, model, options = {}) {
|
|
|
3350
3896
|
const r = lineBetween(from, to); if (!r) return skipped++; // a call the view draws no line for
|
|
3351
3897
|
hops.push({ ...r, ids: edgeRefIds(geo.edges[r.i]), at: +s.start, dur: +s.end - +s.start, to, len: lenOf(r.pts) }); });
|
|
3352
3898
|
const ctl = { hops: hops.length, skipped, done: new Promise(r => { done = r; }),
|
|
3353
|
-
pause() { if (t && trip === t) t.paused = true; }, resume() { if (t && trip === t) t.paused = false; },
|
|
3354
|
-
stop() { if (t && trip === t) endTrip(); else overlay('replay', null); } };
|
|
3899
|
+
pause() { if (t && trip === t) { t.paused = true; touch(); } }, resume() { if (t && trip === t) { t.paused = false; touch(); } },
|
|
3900
|
+
stop() { if (t && trip === t) endTrip(); else if (gen === replays) overlay('replay', null); } };
|
|
3355
3901
|
const ms = msText, marks = {};
|
|
3356
3902
|
// ponytail: two calls at once on one line share its mark; the one that ends first takes the glow off
|
|
3357
3903
|
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 } }); };
|
|
3358
|
-
if (!hops.length ||
|
|
3904
|
+
if (!hops.length || still()) { hops.forEach(h => show(h, false)); done(hops.length > 0); return ctl; }
|
|
3359
3905
|
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);
|
|
3360
3906
|
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)); });
|
|
3361
3907
|
const total = Math.max(...hops.map(h => Math.max(h.end, h.start + h.travel)));
|
|
3362
|
-
|
|
3908
|
+
const g = tripLayer();
|
|
3363
3909
|
t = startTrip(now => { hops.forEach(h => {
|
|
3364
3910
|
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; }
|
|
3365
3911
|
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); } }
|
|
3366
3912
|
if (h.on && !h.off && now >= h.end) { h.off = true; show(h, false); } });
|
|
3367
3913
|
return now < total; }, played => { if (!played) overlay('replay', null); done(played); });
|
|
3368
|
-
t.dot = g;
|
|
3914
|
+
t.dot = g; t.kind = 'replay'; touch();
|
|
3369
3915
|
return ctl;
|
|
3370
3916
|
}
|
|
3371
3917
|
|
|
3372
3918
|
/* ---------- stories (#69): the model's narrated walk-throughs, played step by step ----------
|
|
3373
3919
|
A step sets the whole picture: its view, camera, selection, highlight and caption (what it leaves out is cleared),
|
|
3374
3920
|
then plays its tour. Playing goes on by itself after each step's duration; the caption bar and the keys (← → PageUp
|
|
3375
|
-
PageDown, Esc) step through and stop it. One story at a time.
|
|
3921
|
+
PageDown, Esc) step through and stop it. One story at a time.
|
|
3922
|
+
The player's state (0.14.0): entering a step (its view, camera, selection and highlight on their way), then playing or
|
|
3923
|
+
paused on it, until ended or stopped. A story event comes on each of these changes. The reader taking the map (a press,
|
|
3924
|
+
a key, a view or a selection the story didn't make) pauses it; a step lights its own layer, 'story'. */
|
|
3376
3925
|
let story = null;
|
|
3926
|
+
const stepsOf = s => s && Array.isArray(s.steps) ? s.steps.filter(x => x && typeof x === 'object') : [];
|
|
3927
|
+
// the story event, on every change: a step entered, a pause or a resume (playing), the end (step null), destroy() too
|
|
3928
|
+
const storyEvent = st => emit('story', { story: st.s.id, step: story === st ? st.at : null, steps: st.steps.length, playing: story === st && !!st.auto }, dead);
|
|
3377
3929
|
// 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
|
|
3378
3930
|
// object is a Ref already
|
|
3379
3931
|
const stepRef = x => typeof x !== 'string' ? (x && typeof x === 'object' ? x : null) : geo.byId[x] ? nodeRef(x)
|
|
@@ -3397,35 +3949,53 @@ export function render(container, model, options = {}) {
|
|
|
3397
3949
|
bar.setAttribute('aria-label', 'Story: ' + (st.s.title || st.s.id));
|
|
3398
3950
|
bar.querySelector('.um-st').textContent = `${st.s.title || st.s.id} · ${k + 1} / ${n}`;
|
|
3399
3951
|
bar.querySelector('p').textContent = (st.steps[k] || {}).caption || '';
|
|
3400
|
-
b('prev').disabled
|
|
3952
|
+
if (k <= 0) b('prev').setAttribute('aria-disabled', 'true'); else b('prev').removeAttribute('aria-disabled'); // off, and focusable still: a focused ‹ keeps the focus
|
|
3401
3953
|
b('play').textContent = st.auto ? '❚❚' : '▶'; b('play').setAttribute('aria-label', st.auto ? 'Pause' : 'Play'); b('play').title = st.auto ? 'Pause' : 'Play';
|
|
3402
3954
|
b('next').textContent = k + 1 < n ? '›' : '✓'; b('next').setAttribute('aria-label', k + 1 < n ? 'Next step' : 'Finish the story');
|
|
3403
3955
|
}
|
|
3404
3956
|
async function showStep(st, k) {
|
|
3405
3957
|
const seq = ++st.seq, step = st.steps[k] || {}, live = () => story === st && st.seq === seq && !dead;
|
|
3406
|
-
st.at = k; clearTimeout(st.timer); tour(null); storyBar(st);
|
|
3407
|
-
|
|
3958
|
+
st.at = k; st.phase = 'entering'; clearTimeout(st.timer); tour(null); storyBar(st);
|
|
3959
|
+
storyEvent(st); // before the step's own select and viewchange
|
|
3408
3960
|
while (busy && live()) await new Promise(r => setTimeout(r, 30)); // a move the user started: let it land first
|
|
3409
3961
|
if (!live()) return;
|
|
3410
|
-
if (step.view !== cur && ctx.V[step.view]) await go(step.view);
|
|
3962
|
+
if (step.view !== cur && ctx.V[step.view]) await go(step.view, { story: true });
|
|
3411
3963
|
if (!live()) return;
|
|
3412
|
-
|
|
3413
|
-
|
|
3414
|
-
|
|
3415
|
-
const
|
|
3416
|
-
|
|
3417
|
-
|
|
3964
|
+
if (ctx.V[step.view] && step.view !== cur) { st.phase = 'left'; touch(); return; } // the reader went elsewhere on the way: the step waits, paused, for resume()
|
|
3965
|
+
if ((expanded[cur] || []).length) { expanded[cur] = []; update(current, false, true); } // a step sets the whole picture: what the reader unfolded here folds back first
|
|
3966
|
+
const c = step.camera && typeof step.camera === 'object' ? step.camera : null;
|
|
3967
|
+
const aim = () => { const n = c && c.card != null ? geo.byId[c.card] : null; // the step's camera, on the picture as it is now
|
|
3968
|
+
return n ? { cx: n.x + n.w / 2, cy: n.y + n.h / 2, k: +c.zoom || 1.5 }
|
|
3969
|
+
: 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
|
|
3970
|
+
const moved = cam.animate(aim()); cam.touched = !!(c && (geo.byId[c.card] || Number.isFinite(+c.x)));
|
|
3971
|
+
select(step.select == null ? null : stepRef(step.select), { reveal: false, story: true });
|
|
3972
|
+
highlight(stepLit(step.highlight), 'story');
|
|
3418
3973
|
say(`Step ${k + 1} of ${st.steps.length}${step.caption ? ': ' + step.caption : ''}`); // after select(), which says what it selected
|
|
3419
|
-
await moved;
|
|
3974
|
+
if (await moved === false && live() && st.taken !== seq) await cam.animate(aim()); // cut short, not by the reader (an update()): the step gets its framing still
|
|
3420
3975
|
if (!live()) return;
|
|
3421
|
-
st.
|
|
3976
|
+
st.phase = 'shown'; touch();
|
|
3977
|
+
st.toured = step.tour ? tour(step.tour, {}, true) : Promise.resolve(true);
|
|
3422
3978
|
arm(st);
|
|
3423
3979
|
}
|
|
3424
3980
|
function arm(st) { // playing: the next step once this one's duration is up and its tour has reached its end
|
|
3425
3981
|
clearTimeout(st.timer);
|
|
3426
|
-
if (!st.auto || story !== st) return;
|
|
3982
|
+
if (!st.auto || story !== st || st.phase !== 'shown' || paused) return; // entering: it arms once shown; the page's Pause: once it ends
|
|
3427
3983
|
const seq = st.seq, dur = Math.max(500, +(st.steps[st.at] || {}).duration || 4000); // reading time: not cut by reduced motion
|
|
3428
|
-
Promise.all([st.toured, new Promise(r => { st.timer = later(r, dur); })]).then(() => { if (story === st && st.seq === seq && st.auto) stepStory(1); });
|
|
3984
|
+
Promise.all([st.toured, new Promise(r => { st.timer = later(r, dur); })]).then(() => { if (story === st && st.seq === seq && st.auto && !paused) stepStory(1); });
|
|
3985
|
+
}
|
|
3986
|
+
function pauseStory(st) { st.auto = false; st.held = true; clearTimeout(st.timer); storyBar(st); storyEvent(st); touch(); } // held: its step's tour waits too (startTrip)
|
|
3987
|
+
// the reader (or the host) took the map, by a press, a key, or a view or selection the story didn't make: a story playing waits
|
|
3988
|
+
function readerMoved() { const st = story; if (!st) return; st.taken = st.seq; if (st.auto) pauseStory(st); }
|
|
3989
|
+
stage.addEventListener('pointerdown', e => { if (!e.target.closest('.um-story')) readerMoved(); }, true); // not the bar's own buttons
|
|
3990
|
+
stage.addEventListener('wheel', e => { if (!ctrlWheel || e.ctrlKey || e.metaKey || stage.classList.contains('full')) readerMoved(); }, { capture: true, passive: true }); // a wheel that zooms (wheel: 'ctrl' leaves the rest to the page)
|
|
3991
|
+
function restory() { // update(): the story shown takes the model's steps, at the step it is on, or stops when the model has it no more
|
|
3992
|
+
const st = story; if (!st) return;
|
|
3993
|
+
const s = arrOf(current.stories).find(x => x && x.id === st.s.id), steps = stepsOf(s);
|
|
3994
|
+
if (!steps.length) return stopStory();
|
|
3995
|
+
const was = JSON.stringify(st.steps[st.at]), k = Math.min(st.at, steps.length - 1);
|
|
3996
|
+
st.s = s; st.steps = steps;
|
|
3997
|
+
if (st.phase !== 'left' && (k !== st.at || JSON.stringify(steps[k]) !== was)) showStep(st, k); // its step changed: shown again, as it is now
|
|
3998
|
+
else { st.at = k; storyBar(st); touch(); } // the same step (or one the reader left: the bar keeps within the model's steps)
|
|
3429
3999
|
}
|
|
3430
4000
|
function stepStory(d) {
|
|
3431
4001
|
const st = story; if (!st) return;
|
|
@@ -3435,9 +4005,11 @@ export function render(container, model, options = {}) {
|
|
|
3435
4005
|
}
|
|
3436
4006
|
function stopStory(told = false) {
|
|
3437
4007
|
const st = story; if (!st) return;
|
|
3438
|
-
story = null; clearTimeout(st.timer); tour(null); highlight(null);
|
|
3439
|
-
const bar = stage.querySelector('.um-story');
|
|
3440
|
-
if (
|
|
4008
|
+
story = null; st.told = told; clearTimeout(st.timer); tour(null); highlight(null, 'story');
|
|
4009
|
+
const bar = stage.querySelector('.um-story');
|
|
4010
|
+
if (bar) { const back = bar.contains(root.activeElement); bar.remove(); clear = null; if (back && !dead) stage.focus({ preventScroll: true }); } // focus on the bar stays in the map
|
|
4011
|
+
if (!dead) say(told ? 'End of the story' : 'Story closed');
|
|
4012
|
+
storyEvent(st); touch(); // destroy() too: a listener hears the end
|
|
3441
4013
|
st.end(told);
|
|
3442
4014
|
}
|
|
3443
4015
|
function storyKey(e) { // null: not a story's key; on the bar's own buttons, Enter and Space press them
|
|
@@ -3449,15 +4021,17 @@ export function render(container, model, options = {}) {
|
|
|
3449
4021
|
return null;
|
|
3450
4022
|
}
|
|
3451
4023
|
function play(id, o = {}) {
|
|
4024
|
+
if (id == null) { stopStory(); return null; } // play(null): the story shown stops
|
|
4025
|
+
const s = arrOf(current.stories).find(x => x && x.id === id), steps = stepsOf(s);
|
|
4026
|
+
if (dead || !geo || !steps.length) return null; // a story the model lacks: the one shown plays on
|
|
3452
4027
|
stopStory();
|
|
3453
|
-
const
|
|
3454
|
-
if (dead || !geo || !steps.length) return null;
|
|
3455
|
-
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; }) };
|
|
4028
|
+
let end; const st = { s, steps, at: -1, seq: 0, timer: 0, auto: o.auto !== false, phase: 'entering', told: false, toured: Promise.resolve(true), done: new Promise(r => { end = r; }) };
|
|
3456
4029
|
st.end = end;
|
|
3457
|
-
st.ctl = { get story() { return s.id; }, get step() { return st.at; }, get steps() { return steps.length; }, get playing() { return st.auto && story === st; },
|
|
4030
|
+
st.ctl = { get story() { return st.s.id; }, get step() { return st.at; }, get steps() { return st.steps.length; }, get playing() { return st.auto && story === st; },
|
|
4031
|
+
get state() { return story !== st ? (st.told ? 'ended' : 'stopped') : st.phase === 'entering' ? 'entering' : st.auto ? 'playing' : 'paused'; },
|
|
3458
4032
|
next() { if (story === st) stepStory(1); }, prev() { if (story === st) stepStory(-1); }, stop() { if (story === st) stopStory(); },
|
|
3459
|
-
pause() { if (story
|
|
3460
|
-
resume() { if (story !== st || st.auto) return; st.auto = true; storyBar(st); arm(st); }, done: st.done };
|
|
4033
|
+
pause() { if (story === st && st.auto) pauseStory(st); },
|
|
4034
|
+
resume() { if (story !== st || st.auto) return; st.auto = true; st.held = false; storyBar(st); storyEvent(st); touch(); if (st.phase === 'left') showStep(st, st.at); else arm(st); }, done: st.done };
|
|
3461
4035
|
story = st;
|
|
3462
4036
|
showStep(st, Math.max(0, Math.min(steps.length - 1, Math.floor(+o.step) || 0)));
|
|
3463
4037
|
return st.ctl;
|
|
@@ -3465,47 +4039,69 @@ export function render(container, model, options = {}) {
|
|
|
3465
4039
|
|
|
3466
4040
|
const inner = { ctx: () => ctx, geo: () => geo, cam: () => cam, svg: () => svg, stage, host, go, open, up, flash, setFlow, pause, key, startView, standalone, play,
|
|
3467
4041
|
select, busy: () => busy, flowing: () => stage.classList.contains('flowing'), view: () => cur, unfold: gesture, lens: setLens,
|
|
3468
|
-
highlight, overlay, focus: focusMap, editable: !!E,
|
|
4042
|
+
highlight, overlay, focus: focusMap, editable: !!E, shapes: () => shapesNow(), setShapes, override: () => shapesSet }; // a story's pointer is the pieces' own, from the story event (0.14.0)
|
|
3469
4043
|
|
|
3470
4044
|
// the pieces a host asked for (#99): they follow the map's selection and views, and take their keys wherever the focus is
|
|
3471
4045
|
// in the map (Enter and Space press their buttons; the map's other keys reach the canvas, as in the page)
|
|
3472
4046
|
const frame = q('.um-frame'), pc = frame && pieces(() => inner, { tree: q('.outline'), legend: lg, panel: q('.panel') }, { frame, toggle: q('.um-navbtn'), layer: frame, status: tell || say });
|
|
3473
4047
|
if (pc) {
|
|
3474
|
-
(subs.select ||= []).push(e => pc.select(e.target, e.targets)); (subs.viewchange ||= []).push(pc.unlight);
|
|
4048
|
+
(subs.select ||= []).push(e => pc.select(e.target, e.targets)); (subs.viewchange ||= []).push(pc.unlight); (subs.story ||= []).push(pc.story);
|
|
3475
4049
|
frame.addEventListener('keydown', e => { if (e.defaultPrevented || e.metaKey || e.ctrlKey || e.altKey) return;
|
|
3476
4050
|
if (pc.key(e) || (!stage.contains(e.target) && e.key !== 'Enter' && e.key !== ' ' && key(e))) { e.preventDefault(); e.stopPropagation(); } });
|
|
3477
4051
|
if (options.nav) { q('.um-navbtn').addEventListener('click', () => pc.setTree(!frame.classList.contains('tree-open'))); pc.setTree(ctx.views.length > 1); }
|
|
3478
4052
|
}
|
|
4053
|
+
const motionOf = () => instant ? 'instant' : reducedMotion() ? 'reduced' : 'full';
|
|
4054
|
+
const shapesNow = () => cur ? noteFor(cur) : shapesSet || noteOf(ctx.D.shapes) || 'box';
|
|
4055
|
+
const camNow = () => { const r = (x, d) => Math.round(x * d) / d; return cam ? { x: r(cam.s.cx, 10), y: r(cam.s.cy, 10), zoom: r(cam.s.k, 1000) } : { x: 0, y: 0, zoom: 1 }; };
|
|
4056
|
+
function snapshot(event = false) { // MapState: built fresh on every read, frozen all the way down, plain JSON; event: the camera at rest
|
|
4057
|
+
const deep = o => { Object.values(o).forEach(v => v && typeof v === 'object' && deep(v)); return Object.freeze(o); }, st = story, t = trip;
|
|
4058
|
+
return deep({ view: cur, navigating: nav ? nav.to : null, selection: picked(), expanded: [...(expanded[cur] || [])], lens: lensId, overlays: [...layers.keys()],
|
|
4059
|
+
story: st ? { id: st.s.id, step: st.at, steps: st.steps.length, playing: !!st.auto } : null, tour: !!(t && t.kind === 'tour'), replay: t && t.kind === 'replay' ? { playing: !t.paused } : null, paused: !!paused,
|
|
4060
|
+
camera: event && camRest ? { ...camRest } : camNow(), flow: stage.classList.contains('flowing'), shapes: shapesNow(), motion: motionOf(), gesture: gestureNow(), destroyed: dead });
|
|
4061
|
+
}
|
|
3479
4062
|
const first = () => {
|
|
3480
4063
|
if (dead) return;
|
|
3481
|
-
|
|
3482
|
-
if (!
|
|
3483
|
-
|
|
3484
|
-
|
|
3485
|
-
|
|
4064
|
+
if (options.view != null && !ctx.V[options.view]) warn(`render(…, { view: "${options.view}" }): the model has no such view; it starts on ${ctx.rootId ? `"${ctx.rootId}"` : 'none'}`);
|
|
4065
|
+
if (!cam) { // a view shown already (a setView() before a deferred first draw) stays
|
|
4066
|
+
const v0 = ctx.V[options.view] ? options.view : ctx.rootId;
|
|
4067
|
+
mount(v0 ?? null); cam.set(startView()); cam.touched = false; // no views: the empty card, until an update() brings one
|
|
4068
|
+
if (v0 && options.select) select(options.select, { reveal: false });
|
|
4069
|
+
}
|
|
4070
|
+
if (cur && P.onReady) P.onReady();
|
|
3486
4071
|
};
|
|
3487
4072
|
if (P.defer) requestAnimationFrame(() => later(first, 16)); else first();
|
|
4073
|
+
camRest = camNow(); if (listensState()) lastState = snapshot(true); // the state event reports changes from here
|
|
3488
4074
|
// the host's fonts may still be loading: measure again once they are in, and redraw
|
|
3489
|
-
if (measure && !own && document.fonts && document.fonts.status !== 'loaded') document.fonts.ready.then(() => { if (dead) return; measure = hostMeasure(host); update(current, true); });
|
|
4075
|
+
if (measure && !own && document.fonts && document.fonts.status !== 'loaded') document.fonts.ready.then(() => { if (dead || !current || typeof current !== 'object') return; measure = hostMeasure(host); update(current, true); });
|
|
3490
4076
|
|
|
3491
4077
|
return {
|
|
3492
4078
|
update: m => update(m),
|
|
3493
|
-
setView: id => go(id),
|
|
3494
|
-
select: ref => select(ref),
|
|
3495
|
-
fit: () => { if (cam) cam.fit(); },
|
|
4079
|
+
setView: id => go(id), // a Promise<boolean>: true once the view shows; false for a view the model lacks, a later request, or destroy()
|
|
4080
|
+
select: ref => { if (!dead) select(ref); },
|
|
4081
|
+
fit: () => { if (cam && !dead) { readerMoved(); cam.fit(); } }, // the host takes the camera: a story playing waits
|
|
3496
4082
|
toSVG: (o = {}) => dead ? '' : standalone(o.scale ?? 1, o.view ?? cur, o.fontCSS),
|
|
3497
4083
|
toPNG: (o = {}) => dead ? Promise.resolve(null) : pngOf(standalone(o.scale ?? 2, o.view ?? cur, o.fontCSS)),
|
|
3498
4084
|
focus: ref => { if (!dead) focusMap(ref); },
|
|
3499
4085
|
expand: id => setUnfold(id, true), // unfold a stacked card of the view shown in place (#71): true when it did
|
|
3500
4086
|
collapse: id => setUnfold(id, false),
|
|
3501
4087
|
get expanded() { return [...(expanded[cur] || [])]; },
|
|
3502
|
-
on(type, fn) { (
|
|
4088
|
+
on(type, fn) { if (dead) return () => {}; if (type === 'state' && !listensState()) { camRest = camNow(); lastState = snapshot(true); } // a first state listener hears changes from now
|
|
4089
|
+
(subs[type] ||= []).push(fn); if (type === 'click') tabKeys();
|
|
3503
4090
|
return () => { subs[type] = (subs[type] || []).filter(f => f !== fn); if (type === 'click' && !dead) tabKeys(); }; },
|
|
3504
4091
|
get view() { return cur; },
|
|
3505
|
-
get selection() { return sel; },
|
|
4092
|
+
get selection() { return copyRef(sel); },
|
|
3506
4093
|
get selected() { return picked(); },
|
|
4094
|
+
get state() { return snapshot(); }, // MapState (0.14.0): a fresh, frozen, JSON-serializable snapshot
|
|
4095
|
+
get navigating() { return nav ? nav.to : null; }, // the view a move is going to, while one is under way
|
|
4096
|
+
get destroyed() { return dead; },
|
|
4097
|
+
get flow() { return stage.classList.contains('flowing'); }, // the animated flow along lines (≋, F)
|
|
4098
|
+
set flow(on) { if (!dead) setFlow(!!on, false); }, // a host's choice, not the reader's: the page saves only ≋ and F
|
|
4099
|
+
get shapes() { return shapesNow(); }, // the notation the view shown is drawn in: 'box', 'c4' or 'uml' (0.15.0)
|
|
4100
|
+
set shapes(x) { setShapes(x); }, // every view in this one (null: each view's own); the cards are drawn again, nothing is laid out
|
|
4101
|
+
get motion() { return motionOf(); }, // 'instant' (the page's #check, #still, #shot), 'reduced' (the reader's setting), else 'full'
|
|
4102
|
+
get story() { return story ? story.ctl : null; }, // the player of the story shown (play()'s, the page menu's or a link's), else null
|
|
3507
4103
|
overlay: (name, layer) => overlay(name, layer),
|
|
3508
|
-
highlight: refs => highlight(refs),
|
|
4104
|
+
highlight: (refs, options) => highlight(refs, (options && options.layer) || 'highlight'),
|
|
3509
4105
|
lens: id => setLens(id), // one of the model's lenses on the map, or null for none (#94): false when the model has no such lens
|
|
3510
4106
|
tour: (path, o) => tour(path, o),
|
|
3511
4107
|
replay: (spans, o) => replay(spans, o),
|
|
@@ -3585,7 +4181,7 @@ export function describe(model, viewId) {
|
|
|
3585
4181
|
// 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)
|
|
3586
4182
|
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';
|
|
3587
4183
|
const VERB = { calls: 'calls', feeds: 'feeds', triggers: 'triggers', state: 'reads or writes', guard: 'guards' };
|
|
3588
|
-
const what = e => { const t = [e.label && `"${e.label}"`, e.sublabel].filter(Boolean); return t.length ? ` (${t.join(', ')})` : ''; };
|
|
4184
|
+
const what = e => { const t = [e.label && `"${e.label}"`, e.sublabel, v.render !== 'sequence' && e.step != null && `step ${e.step}`].filter(Boolean); return t.length ? ` (${t.join(', ')})` : ''; };
|
|
3589
4185
|
const out = [`${v.title || v.id}: ${count(ids.length, 'card')} and ${count(edges.length, 'line')}.`];
|
|
3590
4186
|
if (ids.length) out.push(`Cards: ${ids.map(id => `${name(id)} (${KIND[kindOf(ctx.nodeIn(id, viewId).kind, true)][1]})`).join(', ')}.`);
|
|
3591
4187
|
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))}.`); });
|
|
@@ -3695,7 +4291,6 @@ function lensOf(ctx, vid, lens) {
|
|
|
3695
4291
|
|
|
3696
4292
|
export function mountPage(container, model, options = {}) {
|
|
3697
4293
|
const Q = options.params || new URLSearchParams();
|
|
3698
|
-
if (options.instant) INSTANT = true;
|
|
3699
4294
|
document.body.classList.add('map-mode');
|
|
3700
4295
|
let { D, views, nodes, N, V, rootId, nodeIn: nodeIn0, subjectOf, viewsWith, kidsOf, chain, viewEdges, planCache } = mapContext(model);
|
|
3701
4296
|
const nodeIn = (nid, vid = cur) => nodeIn0(nid, vid);
|
|
@@ -3703,6 +4298,27 @@ export function mountPage(container, model, options = {}) {
|
|
|
3703
4298
|
const modK = mac ? '⌘K' : 'Ctrl K';
|
|
3704
4299
|
const HINT = hintHTML(modK);
|
|
3705
4300
|
const ghost = [[10, 26, 22], [40, 18, 24], [40, 46, 24], [10, 60, 22], [70, 32, 20], [70, 70, 20]].map(([l, t, w]) => `<span style="left:${l}%;top:${t}%;width:${w}%"></span>`).join('');
|
|
4301
|
+
// the page's keys (0.14.0), in the order the key list (?) shows them: what to press and what it does, then, for the keys
|
|
4302
|
+
// the page reads itself, those keys and what they do; the rest are the map's own, which reach its canvas (the router)
|
|
4303
|
+
const KEYS = [
|
|
4304
|
+
['/ or ' + modK, 'search nodes and views', ['/'], () => openPal()],
|
|
4305
|
+
['Scroll · pinch', 'zoom (two-finger drag pans)'],
|
|
4306
|
+
['Click', 'details below the map'],
|
|
4307
|
+
['Double-click · Enter', 'open a stacked node ⤢'],
|
|
4308
|
+
['E · Alt + click', 'unfold a stacked node here, or fold it back ⤡'],
|
|
4309
|
+
['Esc · Backspace', 'close what is on top (a menu, the recorder, a story too), else clear the selection, else up one level', ['Escape', 'Backspace'], (e, inCanvas) => closeTop(e, inCanvas)],
|
|
4310
|
+
['+ − 0 1', 'zoom in, out, fit, 100%'],
|
|
4311
|
+
['← ↑ → ↓', 'pan'],
|
|
4312
|
+
['[ ]', 'previous / next details tab', ['[', ']'], e => pc.key(e)],
|
|
4313
|
+
['i', 'about this view', ['i', 'I'], () => togglePill()],
|
|
4314
|
+
['O', 'views list', ['o', 'O'], e => pc.key(e)],
|
|
4315
|
+
['F', 'flow on/off'],
|
|
4316
|
+
['> <', 'light what a card leads to, what leads to it', ['>', '<'], e => pc.key(e)],
|
|
4317
|
+
['Shift-click · P', 'light every path between two cards', ['p', 'P'], e => pc.key(e)],
|
|
4318
|
+
['← → · PgUp PgDn', 'in a story: previous / next step'],
|
|
4319
|
+
['?', 'these keys', ['?'], () => toggleHelp()],
|
|
4320
|
+
];
|
|
4321
|
+
const ac = new AbortController(), signal = ac.signal; // the page's listeners on the document and window: destroy() takes them away
|
|
3706
4322
|
|
|
3707
4323
|
const app = container; app.className = 'mapapp';
|
|
3708
4324
|
app.innerHTML = `
|
|
@@ -3711,10 +4327,10 @@ export function mountPage(container, model, options = {}) {
|
|
|
3711
4327
|
<span class="mbrand"><i></i><span>${esc(D.title || 'Map')}</span></span>
|
|
3712
4328
|
<nav class="crumbs" id="m-crumbs" aria-label="Breadcrumb"></nav>
|
|
3713
4329
|
<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>
|
|
3714
|
-
<button class="ibtn" id="m-theme" title="Light / dark" aria-label="Toggle theme">◐</button>${(D.stories || []).length ? `
|
|
3715
|
-
<div class="menu"><button class="ibtn txt" id="m-stories" aria-haspopup="true" aria-expanded="false" title="Stories: walk-throughs of this map"
|
|
4330
|
+
<button class="ibtn" id="m-theme" title="Light / dark" aria-label="Toggle theme">◐</button><button class="ibtn txt" id="m-shapes" type="button" title="Card shapes: box, c4 or uml">⬡<span class="lbl"></span></button>${(D.stories || []).length ? `
|
|
4331
|
+
<div class="menu"><button class="ibtn txt" id="m-stories" aria-haspopup="true" aria-expanded="false" title="Stories: walk-throughs of this map">▶<span class="lbl"> Stories</span></button>
|
|
3716
4332
|
<div class="menu-pop" id="m-stories-pop" hidden>${D.stories.map(s => `<button data-story="${esc(s && s.id)}">${esc((s && (s.title || s.id)) || '')}</button>`).join('')}</div></div>` : ''}${arrOf(D.lenses).some(l => l && l.id) ? `
|
|
3717
|
-
<div class="menu"><button class="ibtn txt" id="m-lenses" aria-haspopup="true" aria-expanded="false" title="Lenses: ways to look at this map"
|
|
4333
|
+
<div class="menu"><button class="ibtn txt" id="m-lenses" aria-haspopup="true" aria-expanded="false" title="Lenses: ways to look at this map">◎<span class="lbl"> Lenses</span></button>
|
|
3718
4334
|
<div class="menu-pop" id="m-lenses-pop" hidden>${D.lenses.filter(l => l && l.id).map(l => `<button data-lens="${esc(l.id)}" aria-pressed="false">${esc(l.title || l.id)}</button>`).join('')}<hr><button data-lens="" aria-pressed="true">No lens</button></div></div>` : ''}
|
|
3719
4335
|
<div class="menu"><button class="pbtn" id="m-export" aria-haspopup="true" aria-expanded="false">Export ▾</button>
|
|
3720
4336
|
<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>
|
|
@@ -3733,17 +4349,14 @@ export function mountPage(container, model, options = {}) {
|
|
|
3733
4349
|
<aside class="panel" id="m-panel" aria-label="Node details" hidden></aside>
|
|
3734
4350
|
</div>
|
|
3735
4351
|
<div class="hpop" id="m-helpbox" role="dialog" aria-label="Keys" hidden><b>Keys</b>
|
|
3736
|
-
<dl
|
|
3737
|
-
<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>
|
|
3738
|
-
<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>
|
|
3739
|
-
<dt>← → · PgUp PgDn</dt><dd>in a story: previous / next step (Esc closes it)</dd></dl></div>
|
|
4352
|
+
<dl>${KEYS.map(([k, d]) => `<dt>${esc(k)}</dt><dd>${esc(d)}</dd>`).join('')}</dl></div>
|
|
3740
4353
|
<div class="scrim" id="m-pal" hidden><div class="pal" role="dialog" aria-label="Search">
|
|
3741
4354
|
<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>
|
|
3742
4355
|
<div class="res" id="m-res" role="listbox"></div>
|
|
3743
4356
|
<div class="pft"><span>↑↓ move</span><span>↵ open</span><span>esc close</span></div></div></div>`;
|
|
3744
4357
|
const $ = id => document.getElementById(id);
|
|
3745
4358
|
const stage = $('m-stage'), panel = $('m-panel');
|
|
3746
|
-
let X = null, cam = null, geo = null, svg = null, cur = null, sel = null, stack = [], paused = false
|
|
4359
|
+
let X = null, cam = null, geo = null, svg = null, cur = null, sel = null, stack = [], paused = false;
|
|
3747
4360
|
const status = hinter($('m-hint'), HINT);
|
|
3748
4361
|
// the views list, the legend and the details panel (#99): the same pieces render() draws for a host
|
|
3749
4362
|
const pc = pieces(() => X, { tree: $('m-tree'), legend: $('m-legend'), panel }, { page: true, frame: document.body, toggle: $('m-outline'), layer: document.body, status });
|
|
@@ -3771,21 +4384,49 @@ export function mountPage(container, model, options = {}) {
|
|
|
3771
4384
|
const togglePill = force => { const b = $('m-pillbtn'), p = $('m-vpop'); if (!b) return; const on = force != null ? force : p.hidden; p.hidden = !on; b.setAttribute('aria-expanded', on); };
|
|
3772
4385
|
const setTree = pc.setTree;
|
|
3773
4386
|
|
|
3774
|
-
/*
|
|
3775
|
-
|
|
3776
|
-
|
|
3777
|
-
|
|
3778
|
-
|
|
3779
|
-
function setHash(replace) {
|
|
4387
|
+
/* the link (0.14.0): one place writes it, from what the map shows: its view, card, story step and lens, and the reader
|
|
4388
|
+
copy, theme, check and shot the page was opened with (the theme as it is now, after ◐ too). A new view is a new entry,
|
|
4389
|
+
written once the move lands; anything else rewrites the entry. Nothing is written while a link is being followed */
|
|
4390
|
+
function write(push = false) {
|
|
4391
|
+
if (!X || linking || handle.destroyed) return;
|
|
3780
4392
|
const p = new URLSearchParams(); p.set('view', cur); if (sel) p.set('node', sel);
|
|
3781
4393
|
if (told) { p.set('story', told.id); p.set('step', told.step + 1); } // the story shown, at its step (#69)
|
|
3782
4394
|
if (lensOn) p.set('lens', lensOn); // the lens on the map (#94)
|
|
3783
|
-
|
|
4395
|
+
if (X.override()) p.set('shapes', X.override()); // the notation a reader or the link picked (0.15.0)
|
|
4396
|
+
if (Q.get('theme')) p.set('theme', document.documentElement.dataset.theme || Q.get('theme'));
|
|
4397
|
+
['reader', 'check', 'shot'].forEach(k => Q.get(k) && p.set(k, Q.get(k)));
|
|
3784
4398
|
const h = '#' + p.toString();
|
|
3785
|
-
if (location.hash
|
|
3786
|
-
|
|
4399
|
+
if (location.hash !== h) window.history[push ? 'pushState' : 'replaceState'](null, '', h); // window's: history() is this module's undo helper
|
|
4400
|
+
}
|
|
4401
|
+
/* following a link, on load and on Back, Forward or an edited link: its lens, view, story step and card, applied in that
|
|
4402
|
+
order. Once the map has settled, the page writes the link it shows: what the model lacks is corrected, and the hint
|
|
4403
|
+
line says so. A move the reader starts meanwhile wins, as a move of the reader's own (a new entry) */
|
|
4404
|
+
let linking = null, follows = 0; // linking: { view } while a link is followed: the view it goes to, whose arrival writes no entry
|
|
4405
|
+
const idle = () => new Promise(r => { const t = () => X.busy() ? setTimeout(t, 30) : r(); t(); }); // once no move is under way
|
|
4406
|
+
async function follow(load = false) {
|
|
4407
|
+
const p = new URLSearchParams(location.hash.slice(1)), my = ++follows, lacks = [], stories = arrOf(D.stories);
|
|
4408
|
+
if (noteOf(p.get('shapes')) && noteOf(p.get('shapes')) !== handle.shapes) handle.shapes = p.get('shapes'); // the link's card shapes (0.15.0)
|
|
4409
|
+
else if (p.get('shapes') && !noteOf(p.get('shapes'))) lacks.push(`no card shapes “${p.get('shapes')}”`);
|
|
4410
|
+
const lens = p.get('lens') || null, sid = p.get('story'), at = Math.max(1, Math.floor(+p.get('step')) || 1) - 1;
|
|
4411
|
+
const def = sid ? stories.find(s => s && s.id === sid) : null, steps = def ? arrOf(def.steps).filter(s => s && typeof s === 'object') : [], step = steps[Math.min(at, steps.length - 1)];
|
|
4412
|
+
linking = { view: null };
|
|
4413
|
+
if (lens !== lensOn && !X.lens(lens)) lacks.push(`no lens “${lens}”`);
|
|
4414
|
+
if (sid && !step) lacks.push(`no story “${sid}”`); else if (sid && at >= steps.length) lacks.push(`no step ${at + 1} in “${sid}”`);
|
|
4415
|
+
const shown = handle.state.story, k = Math.min(at, steps.length - 1), played = !!step && (!shown || shown.id !== sid || shown.step !== k);
|
|
4416
|
+
const v = played ? step.view : p.get('view') || rootId; // a step to show: its own view, gone to first so the step moves nothing
|
|
4417
|
+
if (!V[v]) lacks.push(`no view “${v}”`);
|
|
4418
|
+
else if (v !== cur || X.busy()) { linking.view = v; const ok = await X.go(v); if (my !== follows) return; if (!ok || !linking) { linking = null; return; } } // the reader's own move won: its landing writes its own entry
|
|
4419
|
+
if (played) { handle.play(sid, { step: k, auto: false }); await idle(); if (my !== follows || !linking) return; } // the story at the link's step, shown and not playing on
|
|
4420
|
+
else if (!step && shown) handle.play(null); // a link with no story: the one shown stops
|
|
4421
|
+
if (!played) { // the link's card, unless a step just chose the selection
|
|
4422
|
+
const node = p.get('node') || (load && Q.get('shot') && cur !== rootId ? subjectOf(V[cur]) || (V[cur].nodeIds || [])[0] : null);
|
|
4423
|
+
if (node) { if (node !== sel && !X.select({ type: 'node', id: node }, { reveal: !load })) lacks.push(`no card “${node}” here`); }
|
|
4424
|
+
else if (handle.selection) X.select(null);
|
|
4425
|
+
}
|
|
4426
|
+
linking = null; write();
|
|
4427
|
+
if (lacks.length) status('Link corrected: ' + lacks.join('; '), 5000);
|
|
3787
4428
|
}
|
|
3788
|
-
const go = (vid, how = {}) =>
|
|
4429
|
+
const go = (vid, how = {}) => X.go(vid, how);
|
|
3789
4430
|
const open = nid => X.open(nid), up = () => X.up();
|
|
3790
4431
|
const findNode = pc.find;
|
|
3791
4432
|
|
|
@@ -3795,46 +4436,61 @@ export function mountPage(container, model, options = {}) {
|
|
|
3795
4436
|
const bv = e.target.closest('.vpop [data-v]'); if (bv) return go(bv.dataset.v);
|
|
3796
4437
|
});
|
|
3797
4438
|
const setFlow = on => X.setFlow(on);
|
|
3798
|
-
const
|
|
3799
|
-
|
|
4439
|
+
const toggleHelp = force => { const on = force ?? $('m-helpbox').hidden; $('m-helpbox').hidden = !on; $('m-help').setAttribute('aria-expanded', on); if (on) openMenu(null); };
|
|
4440
|
+
/* the header's menus, Export, Stories and Lenses (0.14.0): one open at a time, or none. back: focus goes back to the
|
|
4441
|
+
button of the menu that closes (Esc) */
|
|
4442
|
+
let menuOn = null;
|
|
4443
|
+
function openMenu(name, back = false) {
|
|
4444
|
+
const was = menuOn; menuOn = name && $(`m-${name}-pop`) ? name : null;
|
|
4445
|
+
['export', 'stories', 'lenses'].forEach(n => { const p = $(`m-${n}-pop`); if (!p) return; p.hidden = n !== menuOn; $(`m-${n}`).setAttribute('aria-expanded', n === menuOn); });
|
|
4446
|
+
if (menuOn) toggleHelp(false);
|
|
4447
|
+
if (back && was && !menuOn) $(`m-${was}`).focus();
|
|
4448
|
+
}
|
|
4449
|
+
['export', 'stories', 'lenses'].forEach(n => { const b = $(`m-${n}`); if (b) b.addEventListener('click', () => openMenu(menuOn === n ? null : n)); });
|
|
3800
4450
|
document.addEventListener('click', e => {
|
|
3801
|
-
const t = e.target;
|
|
3802
|
-
|
|
3803
|
-
|
|
3804
|
-
|
|
3805
|
-
});
|
|
4451
|
+
const t = e.target, at = s => t.closest && t.closest(s);
|
|
4452
|
+
if (!at('.menu')) openMenu(null);
|
|
4453
|
+
if (!at('#m-helpbox, #m-help')) toggleHelp(false);
|
|
4454
|
+
const cv = at('#m-crumbs [data-v]'); if (cv) { if (innerWidth <= 820) setTree(false); go(cv.dataset.v); }
|
|
4455
|
+
}, { signal });
|
|
3806
4456
|
$('m-outline').addEventListener('click', () => setTree(!document.body.classList.contains('tree-open')));
|
|
3807
4457
|
if (innerWidth > 1180 && views.length > 1) setTree(true);
|
|
3808
|
-
|
|
3809
|
-
|
|
4458
|
+
// the views list, open over the map on a phone's width, closes when the window narrows to it: it would cover the map
|
|
4459
|
+
let narrow = innerWidth <= 820;
|
|
4460
|
+
addEventListener('resize', () => { const n = innerWidth <= 820; if (n && !narrow) setTree(false); narrow = n; }, { signal });
|
|
4461
|
+
$('m-help').addEventListener('click', () => toggleHelp());
|
|
4462
|
+
$('m-theme').addEventListener('click', () => { toggleTheme(); write(); }); // a link that names the theme names the one shown
|
|
3810
4463
|
$('m-pause').addEventListener('click', e => { paused = !paused; const b = e.currentTarget, l = paused ? 'Play' : 'Pause'; X.pause(paused); b.setAttribute('aria-pressed', paused); b.title = l; b.setAttribute('aria-label', l); b.textContent = paused ? '▶' : '❚❚'; });
|
|
3811
|
-
$('m-export').addEventListener('click',
|
|
3812
|
-
$('m-export-pop').addEventListener('click', e => { const x = e.target.closest('[data-x]')?.dataset.x; if (!x) return; closeExport(); exportView(x); });
|
|
4464
|
+
$('m-export-pop').addEventListener('click', e => { const x = e.target.closest('[data-x]')?.dataset.x; if (!x) return; openMenu(null); exportView(x); });
|
|
3813
4465
|
|
|
3814
|
-
/* stories (#69): the Stories menu plays one; the link names the story and step shown
|
|
3815
|
-
|
|
3816
|
-
|
|
3817
|
-
|
|
3818
|
-
|
|
3819
|
-
|
|
3820
|
-
|
|
3821
|
-
/* lenses (#94): the Lenses menu puts one on the map, its name on the menu's button and in the link (#lens=<id>) */
|
|
4466
|
+
/* stories (#69): the Stories menu plays one; the link names the story and step shown, in the entry of the step's own view
|
|
4467
|
+
(0.14.0); Export ▸ Record a story… */
|
|
4468
|
+
let told = null; // { id, step }: the story on the map and its step, from the story event
|
|
4469
|
+
const stepView = t => { const s = arrOf(D.stories).find(x => x && x.id === t.id), st = s && arrOf(s.steps).filter(x => x && typeof x === 'object')[t.step]; return st ? st.view : null; };
|
|
4470
|
+
if ($('m-stories-pop')) $('m-stories-pop').addEventListener('click', e => { const b = e.target.closest('[data-story]'); if (!b) return; openMenu(null); handle.play(b.dataset.story); });
|
|
4471
|
+
/* lenses (#94): the Lenses menu puts one on the map; the lens event (the menu's, the handle's, a link's) names it on the
|
|
4472
|
+
menu's button and in the link (#lens=<id>) */
|
|
3822
4473
|
let lensOn = null;
|
|
3823
|
-
const lensesPop = $('m-lenses-pop')
|
|
3824
|
-
function
|
|
3825
|
-
if (!
|
|
3826
|
-
|
|
3827
|
-
|
|
3828
|
-
|
|
3829
|
-
if (!fromLink) { status(name ? 'Lens: ' + name : 'No lens'); setHash(true); }
|
|
3830
|
-
}
|
|
3831
|
-
if (lensesPop) {
|
|
3832
|
-
$('m-lenses').addEventListener('click', () => { const on = lensesPop.hidden; lensesPop.hidden = !on; $('m-lenses').setAttribute('aria-expanded', on); });
|
|
3833
|
-
lensesPop.addEventListener('click', e => { const b = e.target.closest('[data-lens]'); if (!b) return; closeLenses(); pickLens(b.dataset.lens); });
|
|
4474
|
+
const lensesPop = $('m-lenses-pop');
|
|
4475
|
+
function showLens() {
|
|
4476
|
+
if (!lensesPop) return;
|
|
4477
|
+
const L = arrOf(D.lenses).find(x => x && x.id === lensOn);
|
|
4478
|
+
lensesPop.querySelectorAll('[data-lens]').forEach(b => b.setAttribute('aria-pressed', b.dataset.lens === (lensOn || '')));
|
|
4479
|
+
$('m-lenses').innerHTML = `◎<span class="lbl"> ${esc(L ? L.title || L.id : 'Lenses')}</span>`;
|
|
3834
4480
|
}
|
|
4481
|
+
if (lensesPop) lensesPop.addEventListener('click', e => { const b = e.target.closest('[data-lens]'); if (!b) return; openMenu(null);
|
|
4482
|
+
if (X.lens(b.dataset.lens || null)) { const L = arrOf(D.lenses).find(x => x && x.id === lensOn); status(L ? 'Lens: ' + (L.title || L.id) : 'No lens'); } });
|
|
3835
4483
|
// 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);
|
|
3836
|
-
// captions are typed in, and Save writes the story's JSON, for the model's `stories
|
|
4484
|
+
// captions are typed in, and Save writes the story's JSON, for the model's `stories`. Not while a story is shown: its
|
|
4485
|
+
// steps are not the reader's. Esc closes it (when it is on top); Close, Esc and leaving the page ask first while it holds
|
|
4486
|
+
// steps not saved (0.14.0)
|
|
3837
4487
|
let rec = null;
|
|
4488
|
+
const unsaved = () => !!rec && (rec.steps.length > 1 || rec.steps.some(s => s.caption)) && rec.json() !== rec.saved;
|
|
4489
|
+
const leaving = e => { if (unsaved()) { e.preventDefault(); e.returnValue = ''; } };
|
|
4490
|
+
function closeRec() { // false: the reader kept it
|
|
4491
|
+
if (!rec || (unsaved() && !confirm(`Close the recorder? Its ${rec.steps.length} steps are not saved.`))) return false;
|
|
4492
|
+
clearTimeout(rec.t); rec = null; $('m-rec').remove(); removeEventListener('beforeunload', leaving); return true;
|
|
4493
|
+
}
|
|
3838
4494
|
function record() {
|
|
3839
4495
|
if (rec) return;
|
|
3840
4496
|
stage.insertAdjacentHTML('beforeend', `<div class="m-rec" id="m-rec" role="region" aria-label="Recording a story">
|
|
@@ -3843,8 +4499,12 @@ export function mountPage(container, model, options = {}) {
|
|
|
3843
4499
|
<ol id="m-rec-list"></ol>
|
|
3844
4500
|
<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>`);
|
|
3845
4501
|
const box = $('m-rec'), same = (a, b) => JSON.stringify([a.view, a.select, a.camera]) === JSON.stringify([b.view, b.select, b.camera]);
|
|
3846
|
-
rec = { steps: [], t: 0 };
|
|
3847
|
-
const
|
|
4502
|
+
rec = { steps: [], t: 0, saved: null };
|
|
4503
|
+
const story = () => { const title = $('m-rec-title').value.trim() || 'A walk-through', id = title.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '') || 'story';
|
|
4504
|
+
return { id, title, steps: rec.steps.map(({ caption, ...s }) => caption ? { view: s.view, caption, ...s } : s) }; };
|
|
4505
|
+
rec.json = () => JSON.stringify(story());
|
|
4506
|
+
addEventListener('beforeunload', leaving);
|
|
4507
|
+
const draw = () => { $('m-rec-n').textContent = rec.steps.length === 1 ? '1 step' : rec.steps.length + ' steps'; box.querySelector('[data-r="save"]').disabled = !rec.steps.length; // nothing to save: a story needs steps
|
|
3848
4508
|
$('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>
|
|
3849
4509
|
<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(''); };
|
|
3850
4510
|
rec.take = force => { // the map as it is: its view, selected card and, once moved by hand, its camera
|
|
@@ -3858,16 +4518,15 @@ export function mountPage(container, model, options = {}) {
|
|
|
3858
4518
|
const r = e.target.closest('[data-r]')?.dataset.r, del = e.target.closest('[data-del]');
|
|
3859
4519
|
if (del) { rec.steps.splice(+del.dataset.del, 1); draw(); }
|
|
3860
4520
|
else if (r === 'add') rec.take(true);
|
|
3861
|
-
else if (r === 'save') {
|
|
3862
|
-
const
|
|
3863
|
-
|
|
3864
|
-
download(id + '.story.json', new Blob([JSON.stringify({ id, title, steps }, null, 2) + '\n'], { type: 'application/json' }));
|
|
4521
|
+
else if (r === 'save' && rec.steps.length) {
|
|
4522
|
+
const st = story(); rec.saved = JSON.stringify(st);
|
|
4523
|
+
download(st.id + '.story.json', new Blob([JSON.stringify(st, null, 2) + '\n'], { type: 'application/json' }));
|
|
3865
4524
|
status('Saved: add it to the model\'s "stories"', 4000);
|
|
3866
|
-
} else if (r === 'close')
|
|
4525
|
+
} else if (r === 'close') closeRec();
|
|
3867
4526
|
});
|
|
3868
4527
|
rec.take();
|
|
3869
4528
|
}
|
|
3870
|
-
const recordSoon = () => { if (rec) { clearTimeout(rec.t); rec.t = setTimeout(() => rec && rec.take(), 650); } }; // once the camera has landed
|
|
4529
|
+
const recordSoon = () => { if (rec && !handle.story) { clearTimeout(rec.t); rec.t = setTimeout(() => rec && !handle.story && rec.take(), 650); } }; // once the camera has landed; not a story's steps
|
|
3871
4530
|
|
|
3872
4531
|
/* ⌘K command box: nodes and views, Enter jumps */
|
|
3873
4532
|
const nodeIdx = nodes.map(n => ({ n, hay: [n.label, n.id, (n.verbs || []).join(' '), n.what, n.kind, n.author, ((D.authors || {})[n.author] || {}).name, n.origin].join(' ').toLowerCase() }));
|
|
@@ -3903,15 +4562,16 @@ export function mountPage(container, model, options = {}) {
|
|
|
3903
4562
|
res.innerHTML = html;
|
|
3904
4563
|
const on = res.querySelector('.fr.on'); on && on.scrollIntoView({ block: 'nearest' });
|
|
3905
4564
|
}
|
|
3906
|
-
|
|
3907
|
-
const
|
|
4565
|
+
let palBack = null; // where focus was before search opened: Esc gives it back
|
|
4566
|
+
const openPal = () => { toggleHelp(false); openMenu(null); palBack = document.activeElement; pal.hidden = false; q.value = ''; hi = 0; drawHits(); q.focus(); };
|
|
4567
|
+
const closePal = (back = false) => { pal.hidden = true; q.blur(); if (back && palBack && palBack.isConnected && palBack !== document.body) palBack.focus(); palBack = null; };
|
|
3908
4568
|
const pickHit = h => { if (!h) return; closePal(); if (h.t === 'n') findNode(h.n.id); else go(h.v.id); };
|
|
3909
4569
|
q.addEventListener('input', () => { hi = 0; drawHits(); });
|
|
3910
4570
|
q.addEventListener('keydown', e => {
|
|
3911
4571
|
if (e.key === 'ArrowDown') { hi = Math.min(hits.length - 1, hi + 1); drawHits(); e.preventDefault(); }
|
|
3912
4572
|
else if (e.key === 'ArrowUp') { hi = Math.max(0, hi - 1); drawHits(); e.preventDefault(); }
|
|
3913
4573
|
else if (e.key === 'Enter') { pickHit(hits[hi]); e.preventDefault(); }
|
|
3914
|
-
else if (e.key === 'Escape') { closePal(); e.preventDefault(); }
|
|
4574
|
+
else if (e.key === 'Escape') { closePal(true); e.preventDefault(); }
|
|
3915
4575
|
e.stopPropagation();
|
|
3916
4576
|
});
|
|
3917
4577
|
res.addEventListener('click', e => { const f = e.target.closest('[data-fill]'); if (f) { q.value = f.dataset.fill; hi = 0; drawHits(); q.focus(); return; } const b = e.target.closest('[data-i]'); if (b) pickHit(hits[+b.dataset.i]); });
|
|
@@ -3919,28 +4579,43 @@ export function mountPage(container, model, options = {}) {
|
|
|
3919
4579
|
pal.addEventListener('click', e => { if (e.target === pal) closePal(); });
|
|
3920
4580
|
$('m-find').addEventListener('click', openPal);
|
|
3921
4581
|
|
|
3922
|
-
/* keyboard:
|
|
4582
|
+
/* keyboard (0.14.0): one router, which reads each key from the top open layer down (an example shown full size, search,
|
|
4583
|
+
the key list, a menu, the view's popover, the story recorder, a story, then the map) and the KEYS table. Enter, Space
|
|
4584
|
+
and arrows aimed at a control outside the map are that control's own; the map's keys reach its canvas from anywhere
|
|
4585
|
+
else on the page */
|
|
4586
|
+
const layer = () => pc.modal() ? 'lightbox' : !pal.hidden ? 'search' : !$('m-helpbox').hidden ? 'help' : menuOn ? 'menu'
|
|
4587
|
+
: $('m-vpop') && !$('m-vpop').hidden ? 'pill' : rec ? 'recorder' : told ? 'story' : 'map';
|
|
4588
|
+
function closeTop(e, inCanvas) { // Esc and Backspace: the top open layer closes; else the map's own: the selection clears, then up a level
|
|
4589
|
+
const top = layer();
|
|
4590
|
+
if (top === 'search') closePal(true);
|
|
4591
|
+
else if (top === 'help') toggleHelp(false);
|
|
4592
|
+
else if (top === 'menu') openMenu(null, true);
|
|
4593
|
+
else if (top === 'pill') togglePill(false);
|
|
4594
|
+
else if (top === 'recorder') closeRec(); // asking first when it holds steps not saved
|
|
4595
|
+
else if (top === 'story') handle.play(null); // Backspace too
|
|
4596
|
+
else return !inCanvas && X.key(e); // in the canvas, the canvas takes its own
|
|
4597
|
+
}
|
|
4598
|
+
const CONTROL = 'a[href], button, input, select, textarea, summary, [contenteditable], [role=button], [role=tab], [role=option], [tabindex]:not([tabindex="-1"])';
|
|
3923
4599
|
document.addEventListener('keydown', e => {
|
|
3924
|
-
if (
|
|
3925
|
-
|
|
3926
|
-
|
|
3927
|
-
if (
|
|
3928
|
-
|
|
3929
|
-
|
|
3930
|
-
|
|
3931
|
-
}
|
|
3932
|
-
|
|
3933
|
-
|
|
3934
|
-
|
|
3935
|
-
|
|
3936
|
-
e.preventDefault(); e.stopPropagation();
|
|
3937
|
-
}, true);
|
|
3938
|
-
addEventListener('popstate', () => { const p = new URLSearchParams(location.hash.slice(1)); if ((p.get('lens') || null) !== lensOn) pickLens(p.get('lens'), true); const v = p.get('view') || rootId; if (v !== cur) go(v, { replace: true, select: p.get('node') || undefined }); else if (p.get('node')) select(p.get('node')); else if (sel) deselect(); });
|
|
4600
|
+
if (!X) return;
|
|
4601
|
+
const k = e.key, t = e.target, top = layer(), own = () => { e.preventDefault(); e.stopPropagation(); };
|
|
4602
|
+
if (top === 'lightbox') { if (pc.key(e)) own(); return; } // it holds every key while open: Esc closes it, Tab keeps to it, Enter and Space press its buttons
|
|
4603
|
+
if ((e.metaKey || e.ctrlKey) && (k === 'k' || k === 'K')) { own(); return top === 'search' ? closePal(true) : openPal(); }
|
|
4604
|
+
if (e.metaKey || e.ctrlKey || e.altKey || (t && t.matches && t.matches('input, textarea, select, [contenteditable]'))) return; // the browser's own shortcuts; typing
|
|
4605
|
+
const inCanvas = e.composedPath().includes(X.stage), row = KEYS.find(r => r[2] && r[2].includes(k));
|
|
4606
|
+
if (top === 'search' && k !== 'Escape' && k !== 'Backspace') return; // search covers the map: its field and hits take their own keys
|
|
4607
|
+
if (row) { if (row[3](e, inCanvas) !== false) own(); return; }
|
|
4608
|
+
if (inCanvas || (t && t.closest && t.closest(CONTROL) && /^(Enter| |Arrow|Page|Home|End)/.test(k))) return; // the canvas's own keys; a control's own
|
|
4609
|
+
if (X.key(e)) own(); // the map's keys, from anywhere else on the page
|
|
4610
|
+
}, { capture: true, signal });
|
|
4611
|
+
addEventListener('popstate', () => follow(), { signal }); // Back, Forward, or an edited link
|
|
3939
4612
|
|
|
3940
4613
|
/* export: resolve CSS variables into attributes so the file stands alone */
|
|
3941
4614
|
const standalone = scale => X.standalone(scale);
|
|
3942
4615
|
if (Q.get('still')) { const sv = Q.get('still'), sc = +(Q.get('scale') || 2);
|
|
3943
|
-
const tick = async () => { if (!geo
|
|
4616
|
+
const tick = async () => { if (!geo) return setTimeout(tick, 100);
|
|
4617
|
+
if (!V[sv]) return document.documentElement.setAttribute('data-still', JSON.stringify({ view: sv, error: `the model has no view "${sv}"` })); // an answer, not a wait: no still comes
|
|
4618
|
+
if (cur !== sv) { await go(sv, {}); return setTimeout(tick, 200); }
|
|
3944
4619
|
X.stage.querySelectorAll('.flowline').forEach(e => e.remove()); const out = standalone(sc), b = geo.bounds; document.documentElement.setAttribute('data-still', JSON.stringify({ view: sv, w: Math.ceil(b.w * sc), h: Math.ceil(b.h * sc) }));
|
|
3945
4620
|
document.body.innerHTML = '<div id="still">' + out + '</div>'; document.body.style.margin = '0'; };
|
|
3946
4621
|
setTimeout(tick, 300); }
|
|
@@ -3957,14 +4632,17 @@ export function mountPage(container, model, options = {}) {
|
|
|
3957
4632
|
}
|
|
3958
4633
|
|
|
3959
4634
|
|
|
3960
|
-
let flowPref = null; try { flowPref = localStorage.getItem('visual-flow'); } catch (_) {}
|
|
4635
|
+
let flowPref = null; try { flowPref = reducedMotion() ? null : localStorage.getItem('visual-flow'); } catch (_) {} // the reader's ≋ and F; under reduced motion no flow is drawn
|
|
3961
4636
|
const p0 = new URLSearchParams(location.hash.slice(1));
|
|
3962
4637
|
const v0 = V[p0.get('view')] ? p0.get('view') : rootId;
|
|
4638
|
+
const shapesKey = 'visual-shapes:' + location.pathname; // the reader's notation, kept for this page (0.15.0)
|
|
4639
|
+
const shapes0 = noteOf(p0.get('shapes')) || (() => { try { return noteOf(localStorage.getItem(shapesKey)); } catch (_) { return null; } })() || noteOf(options.shapes);
|
|
3963
4640
|
const handle = render($('m-canvas'), model, {
|
|
3964
|
-
view: v0, minimap: true, flow: flowPref ? flowPref === '1' : undefined,
|
|
3965
|
-
on: { story:
|
|
4641
|
+
view: v0, minimap: true, flow: flowPref ? flowPref === '1' : undefined, shapes: shapes0 || undefined,
|
|
4642
|
+
on: { story: e => { told = e.step == null ? null : { id: e.story, step: e.step }; app.classList.toggle('story', !!told); pc.story(e);
|
|
4643
|
+
const sv = told && stepView(told); if (X && !X.busy() && (!sv || sv === cur || !V[sv])) write(); } }, // a step on another view: in that view's entry, once it shows (#69)
|
|
3966
4644
|
_page: {
|
|
3967
|
-
instant: options.instant, defer: !
|
|
4645
|
+
instant: options.instant, defer: !options.instant, tour: !(Q.get('still') || Q.get('pdf')), openHint: !Q.get('reader'), kinds: true, status, tourAt: Q.get('shot') ? Q.get('at') : null,
|
|
3968
4646
|
wide: () => innerWidth > 820,
|
|
3969
4647
|
upTarget: () => stack.length > 1 ? stack[stack.length - 2] : (V[cur] || {}).parent,
|
|
3970
4648
|
inset: () => { const top = Math.max(58, 26 + $('m-vpill').offsetHeight); if (innerWidth <= 820) return { t: top, r: 12, b: 12, l: 12 }; const lg = $('m-legend'), on = lg && !lg.hidden; // the legend box sits bottom left
|
|
@@ -3977,46 +4655,167 @@ export function mountPage(container, model, options = {}) {
|
|
|
3977
4655
|
renderChrome(); },
|
|
3978
4656
|
onBackground: () => togglePill(false),
|
|
3979
4657
|
onFlow: on => { try { localStorage.setItem('visual-flow', on ? '1' : '0'); } catch (_) {} },
|
|
3980
|
-
onReady: () => {
|
|
3981
|
-
$('m-ghosts').remove();
|
|
3982
|
-
const n0 = p0.get('node') || (Q.get('shot') && cur !== rootId ? subjectOf(V[cur]) || (V[cur].nodeIds || [])[0] : null);
|
|
3983
|
-
if (n0 && geo.byId[n0]) select(n0, { reveal: false });
|
|
3984
|
-
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)
|
|
3985
|
-
if (p0.get('lens')) pickLens(p0.get('lens'), true); // a link's lens (#94)
|
|
3986
|
-
setHash(true);
|
|
3987
|
-
},
|
|
4658
|
+
onReady: () => { $('m-ghosts').remove(); queueMicrotask(() => follow(true)); }, // the link it opened with: its card, story step and lens (#69, #94), once the page listens
|
|
3988
4659
|
},
|
|
3989
4660
|
});
|
|
3990
|
-
|
|
3991
|
-
handle.
|
|
3992
|
-
|
|
3993
|
-
|
|
3994
|
-
|
|
3995
|
-
|
|
3996
|
-
|
|
3997
|
-
|
|
3998
|
-
|
|
3999
|
-
|
|
4000
|
-
|
|
4001
|
-
|
|
4002
|
-
|
|
4003
|
-
|
|
4004
|
-
|
|
4005
|
-
|
|
4006
|
-
|
|
4007
|
-
#um-pdf .pdf-text { columns: 2; column-gap: 32px; font-size: 12px; } #um-pdf .pdf-text p { break-inside: avoid; } #um-pdf ul { margin: 0; padding-left: 20px; }
|
|
4008
|
-
#um-pdf .pdf-legend { display: grid; grid-template-columns: repeat(3, max-content); gap: 6px 28px; font-size: 12px; } .pdf-holds { color: var(--um-green); } .pdf-broken { color: var(--um-rose); }</style>
|
|
4009
|
-
<main id="um-pdf"><section><h1>${esc(model.title || 'Map')}</h1>${p(new Date(Date.now() - new Date().getTimezoneOffset() * 6e4).toISOString().slice(0, 10))}</section>
|
|
4010
|
-
${shown.map(([v, svg]) => `<section class="pdf-view"><h2>${esc(v.title || v.id)}</h2>${svg}<div class="pdf-text">${describe(model, v.id).split('\n').map(p).join('')}</div></section>`).join('')}
|
|
4011
|
-
<section><h2>Legend</h2><div class="pdf-legend">${legendItems(shown.map(([v]) => X.ctx().geoCache[v.id].g)).join('')}</div>
|
|
4012
|
-
<h2>Rules</h2>${rs.length ? `${p('Judged in the views printed here.')}<ul>${rs.map(r => { const b = broken.filter(x => x.rule === r.id);
|
|
4013
|
-
return `<li>${esc(r.title || r.id)}: <b class="${b.length ? 'pdf-broken' : 'pdf-holds'}">${b.length ? 'broken' : 'holds'}</b>${b.length ? `<ul>${b.map(x => `<li>${esc(x.message)}</li>`).join('')}</ul>` : ''}</li>`; }).join('')}</ul>` : p('The model has no rules.')}</section></main>`;
|
|
4014
|
-
document.querySelectorAll('#um-pdf .pdf-view svg').forEach(svg => { const b = svg.viewBox.baseVal, room = H - (svg.parentNode.offsetHeight - svg.getBoundingClientRect().height);
|
|
4015
|
-
const k = Math.min(1, W / b.width, Math.max(240, room) / b.height); svg.setAttribute('width', b.width * k); svg.setAttribute('height', b.height * k); });
|
|
4016
|
-
});
|
|
4661
|
+
const destroy0 = handle.destroy;
|
|
4662
|
+
handle.destroy = () => { ac.abort(); if (rec) clearTimeout(rec.t); destroy0(); }; // the page's listeners go with the map
|
|
4663
|
+
handle.on('select', ({ target, targets }) => { sel = target && target.type === 'node' ? target.id : null; pc.select(target, targets); if (!X.busy()) write(); recordSoon(); }); // mid-move: the view's entry, once it lands
|
|
4664
|
+
handle.on('viewchange', e => { pc.unlight(); recordSoon();
|
|
4665
|
+
if (linking && linking.view === e.view) return; // a link's own move: its entry, written once the link has settled
|
|
4666
|
+
linking = null; write(true); }); // a new view, a new entry (a move the reader started while a link was followed, too)
|
|
4667
|
+
handle.on('lens', e => { lensOn = e.lens; showLens(); write(); });
|
|
4668
|
+
// the header's card shapes (0.15.0): box, c4 and uml in turn, for every view; the page keeps the reader's choice
|
|
4669
|
+
const showShapes = () => { const b = $('m-shapes'), n = handle.shapes, next = NOTATIONS[(NOTATIONS.indexOf(n) + 1) % NOTATIONS.length];
|
|
4670
|
+
b.querySelector('.lbl').textContent = ' ' + n; b.setAttribute('aria-label', `Card shapes: ${n}. Show them as ${next}`); };
|
|
4671
|
+
$('m-shapes').addEventListener('click', () => { const next = NOTATIONS[(NOTATIONS.indexOf(handle.shapes) + 1) % NOTATIONS.length];
|
|
4672
|
+
handle.shapes = next; try { localStorage.setItem(shapesKey, next); } catch (_) {} showShapes(); write(); status(`Card shapes: ${next}`); });
|
|
4673
|
+
handle.on('state', e => { if (e.changed.includes('shapes')) showShapes(); });
|
|
4674
|
+
showShapes();
|
|
4675
|
+
// #pdf=1 (umlimator.py pdf, #93): the page becomes a document for Chrome to print (printDoc), once the fonts are in,
|
|
4676
|
+
// after the map's own redraw for them
|
|
4677
|
+
if (Q.get('pdf')) (document.fonts ? document.fonts.ready : Promise.resolve()).then(() => printDoc(X, model, Q));
|
|
4017
4678
|
return handle;
|
|
4018
4679
|
}
|
|
4019
4680
|
|
|
4681
|
+
/* #pdf=1[&views=a,pic-*][&paper=letter] (umlimator.py pdf): the map as a document to hand in for a review, laid out here a
|
|
4682
|
+
page at a time, as Chrome's own page breaks can't be read back for the contents. Each sheet is one page: @page sets the
|
|
4683
|
+
paper (A4 or US Letter, across) and its margins, and each sheet ends in the title and "page N of M".
|
|
4684
|
+
- The cover: the title, the date, the model's file, a summary line and the contents, each view with its page.
|
|
4685
|
+
- Each view, from a page of its own: a header (the view's path and its caption), its picture (toSVG()'s, in vectors)
|
|
4686
|
+
as large as the page allows and never larger than drawn, the legend of what it draws, then tables of its cards,
|
|
4687
|
+
lines and boxes. They fill two columns below the picture, or one column beside a picture too tall to leave room
|
|
4688
|
+
below. A table that runs out of room goes on in the next column, under its heading and header row again; a row is
|
|
4689
|
+
never cut, and a heading never ends a column.
|
|
4690
|
+
- The rules, when the model has any: each holding or broken in the views printed, and what breaks it. */
|
|
4691
|
+
function printDoc(X, model, Q) {
|
|
4692
|
+
const { D, views, V, N, chain, nodeIn, viewEdges, geoCache } = X.ctx();
|
|
4693
|
+
const only = (Q.get('views') || '').split(',').filter(Boolean), want = v => !only.length || only.some(p => p.endsWith('*') ? v.id.startsWith(p.slice(0, -1)) : v.id === p);
|
|
4694
|
+
const shown = views.filter(want).map(v => [v, X.standalone(1, v.id)]).filter(([, s]) => s), on = new Set(shown.map(([v]) => v.id));
|
|
4695
|
+
const rs = shown.length ? arrOf(model.rules).filter(r => r && typeof r === 'object' && (r.from || r.to || r.noCycles)) : []; // as rules() reads them; none without a view
|
|
4696
|
+
const broken = rules(model).filter(b => on.has(b.view)); // judged in the views printed, as the document says
|
|
4697
|
+
const [pw, ph] = Q.get('paper') === 'letter' ? [279.4, 215.9] : [297, 210], title = esc(D.title || 'Map'); // the paper across, in mm
|
|
4698
|
+
const count = (k, w, ws = w + 's') => `${k} ${k === 1 ? w : ws}`, T = v => esc(v.title || v.id), full = c => Object.assign(c, { full: true });
|
|
4699
|
+
const cards = v => arrOf(v.nodeIds).filter(i => N[i]), lines = v => viewEdges(v).filter(e => e.from !== e.to), boxes = v => arrOf(v.containers).filter(c => c && c.id); // as describe() reads a view
|
|
4700
|
+
const name = (v, id) => esc(N[id] ? nodeIn(id, v.id).label || id : (boxes(v).find(c => c.id === id) || {}).label || id);
|
|
4701
|
+
document.title = D.title || 'Map'; document.body.removeAttribute('class');
|
|
4702
|
+
document.body.innerHTML = `<style>@page { size: ${pw}mm ${ph}mm; margin: 11mm 13mm 9mm; background: var(--um-bg); }
|
|
4703
|
+
html, body { -webkit-print-color-adjust: exact; print-color-adjust: exact; background: var(--um-bg); } body { margin: 0; background-image: none; }
|
|
4704
|
+
#um-pdf { color: var(--um-text); font: 8.5pt/1.4 var(--um-sans); font-variant-numeric: tabular-nums; }
|
|
4705
|
+
#um-pdf .sheet { width: ${pw - 26}mm; height: ${ph - 20.5}mm; padding: 0; display: flex; flex-direction: column; overflow: hidden; break-after: page; }
|
|
4706
|
+
#um-pdf .sheet:last-child { break-after: auto; }
|
|
4707
|
+
#um-pdf header { display: flex; align-items: baseline; justify-content: space-between; gap: 16pt; padding-bottom: 5pt; border-bottom: .75pt solid var(--um-border-2); }
|
|
4708
|
+
#um-pdf h1 { font-size: 26pt; line-height: 1.12; letter-spacing: -.02em; font-weight: 700; margin: 0 0 10pt; }
|
|
4709
|
+
#um-pdf h2, #um-pdf .h2 { display: block; font: 650 14pt/1.25 var(--um-sans); letter-spacing: -.01em; text-transform: none; color: var(--um-text); margin: 0; }
|
|
4710
|
+
#um-pdf h2::after { content: none; } #um-pdf .up { color: var(--um-muted); font-weight: 450; }
|
|
4711
|
+
#um-pdf .meta { color: var(--um-muted); font-size: 8pt; white-space: nowrap; } #um-pdf .cap { color: var(--um-muted); font-size: 9pt; margin: 4pt 0 0; }
|
|
4712
|
+
#um-pdf .body { flex: 1; min-height: 0; display: flex; flex-direction: column; gap: 8px; margin-top: 8pt; } #um-pdf .body.side { flex-direction: row; gap: 16pt; }
|
|
4713
|
+
#um-pdf figure { flex: none; align-self: center; margin: 0; border: 1px solid var(--um-border); border-radius: 4pt; overflow: hidden; }
|
|
4714
|
+
#um-pdf .side figure { align-self: flex-start; } #um-pdf figure svg { display: block; }
|
|
4715
|
+
#um-pdf .lg { flex: none; display: flex; flex-wrap: wrap; align-items: center; gap: 3pt 14pt; font-size: 7.5pt; } #um-pdf .lg > b { font-weight: 650; }
|
|
4716
|
+
#um-pdf .side .lg { margin-bottom: 10pt; }
|
|
4717
|
+
#um-pdf .cols { flex: 1; min-height: 0; display: grid; grid-template-columns: repeat(var(--n), minmax(0, 1fr)); grid-template-rows: minmax(0, 1fr); gap: 18pt; }
|
|
4718
|
+
#um-pdf .col { min-height: 0; overflow: hidden; } #um-pdf .blk + .blk { margin-top: 10pt; }
|
|
4719
|
+
#um-pdf .h3 { font-size: 9pt; font-weight: 650; margin: 0 0 3pt; } #um-pdf .h3 span { color: var(--um-muted); font-weight: 450; }
|
|
4720
|
+
#um-pdf table { width: 100%; border-collapse: collapse; table-layout: fixed; font-size: 8pt; line-height: 1.35; }
|
|
4721
|
+
#um-pdf th { text-align: left; font-size: 7pt; font-weight: 600; color: var(--um-muted); padding: 0 6pt 2.5pt 0; border-bottom: .75pt solid var(--um-border-2); }
|
|
4722
|
+
#um-pdf td { vertical-align: top; padding: 2.5pt 6pt 2.5pt 0; border-bottom: .5pt solid var(--um-border); overflow-wrap: anywhere; }
|
|
4723
|
+
#um-pdf td b { font-weight: 600; } #um-pdf td small { display: block; color: var(--um-muted); font: 7pt/1.3 var(--um-mono); }
|
|
4724
|
+
#um-pdf td p { margin: 0; } #um-pdf td ul { margin: 0; padding-left: 10pt; }
|
|
4725
|
+
#um-pdf td i { display: inline-block; font-style: normal; margin-right: 4pt; } #um-pdf i.sw { width: 8pt; height: 6pt; border: 1pt solid; border-radius: 1.5pt; }
|
|
4726
|
+
#um-pdf i.w { width: 10pt; border-top: 1.5pt solid; vertical-align: middle; }
|
|
4727
|
+
#um-pdf i.num { min-width: 10pt; height: 10pt; padding: 0 2pt; border-radius: 5pt; background: var(--um-text); color: var(--um-bg); font: 700 6.5pt/10pt var(--um-sans); text-align: center; }
|
|
4728
|
+
#um-pdf .cards th:nth-child(1), #um-pdf .boxes th:nth-child(1) { width: 32%; } #um-pdf .cards th:nth-child(2) { width: 20%; }
|
|
4729
|
+
#um-pdf .lines th.n { width: 6%; } #um-pdf .lines th:nth-last-child(3) { width: 42%; } #um-pdf .lines th:last-child { width: 24%; }
|
|
4730
|
+
#um-pdf .rules th:nth-child(1) { width: 30%; } #um-pdf .rules th:nth-child(2) { width: 24%; } #um-pdf .rules th:nth-child(3) { width: 10%; }
|
|
4731
|
+
#um-pdf .ok { color: var(--um-green); font-weight: 650; } #um-pdf .no { color: var(--um-rose); font-weight: 650; }
|
|
4732
|
+
#um-pdf .cover .body { flex-direction: row; gap: 28pt; margin-top: 0; } #um-pdf .cover .ttl { flex: 0 0 40%; padding-top: 34mm; } #um-pdf .cover .cols { padding-top: 34mm; }
|
|
4733
|
+
#um-pdf .sum { font-size: 10pt; margin: 6pt 0 0; } #um-pdf .toc td { padding: 4pt 6pt 4pt 0; } #um-pdf .toc td.pg { width: 3em; padding-right: 0; text-align: right; }
|
|
4734
|
+
#um-pdf .toc td span { display: block; color: var(--um-muted); font-size: 7.5pt; }
|
|
4735
|
+
#um-pdf footer { display: flex; justify-content: space-between; margin-top: 6pt; padding: 5pt 0 0; border-top: .5pt solid var(--um-border); color: var(--um-dim); font: 7pt var(--um-sans); }</style><main id="um-pdf"></main>`;
|
|
4736
|
+
const doc = document.getElementById('um-pdf');
|
|
4737
|
+
// a sheet: its header, the body its content fills, and its footer (`key` names the view or section it starts)
|
|
4738
|
+
const sheet = (head, key = '') => { doc.insertAdjacentHTML('beforeend', `<section class="sheet" data-key="${key}">${head}<div class="body"></div><footer><span>${title}</span><span class="pn"></span></footer></section>`);
|
|
4739
|
+
return doc.lastElementChild.querySelector('.body'); };
|
|
4740
|
+
const cols = (body, n) => { body.insertAdjacentHTML('beforeend', `<div class="cols" style="--n:${n}">${'<div class="col"></div>'.repeat(n)}</div>`); return [...body.lastElementChild.children]; };
|
|
4741
|
+
// tables into columns, next() handing out the next column (null: no more, and pour() says false). A table after the
|
|
4742
|
+
// first starts a column of its own while its page has one left. It starts with its heading, header row and first two
|
|
4743
|
+
// rows (its only one), else in the next column; a row that does not fit goes on, whole, in the next, never the last one
|
|
4744
|
+
// alone. A column handed out fresh on a page of its own (`full`) takes a table's start whatever its size.
|
|
4745
|
+
const pour = (next, blocks) => { let box = next();
|
|
4746
|
+
const over = () => box.lastElementChild.getBoundingClientRect().bottom > box.getBoundingClientRect().bottom - 2, fresh = () => !box.firstElementChild;
|
|
4747
|
+
for (const [n, b] of blocks.entries()) { let tb = null;
|
|
4748
|
+
if (n && !fresh() && next.spare()) box = next();
|
|
4749
|
+
const open = (i, k, force) => { box.insertAdjacentHTML('beforeend', `<div class="blk">${b.title ? `<div class="h3">${b.title}${i ? ' <span>(continued)</span>' : ''}</div>` : ''}<table class="${b.cls}">${b.head ? `<thead><tr>${b.head}</tr></thead>` : ''}<tbody>${b.rows.slice(i, i + k).join('')}</tbody></table></div>`);
|
|
4750
|
+
if (force || !over()) return box.lastElementChild.querySelector('tbody'); box.lastElementChild.remove(); };
|
|
4751
|
+
for (let i = 0; i < b.rows.length;) {
|
|
4752
|
+
if (!tb) { const k = Math.min(2, b.rows.length - i);
|
|
4753
|
+
while (!(tb = open(i, k)) && !(fresh() && box.full)) if (!(box = next())) return false;
|
|
4754
|
+
tb ||= open(i, 1) || open(i, 1, true); // ponytail: a row taller than a page is cut at its foot
|
|
4755
|
+
i += tb.rows.length; continue; }
|
|
4756
|
+
tb.insertAdjacentHTML('beforeend', b.rows[i]);
|
|
4757
|
+
if (!over()) { i++; continue; }
|
|
4758
|
+
tb.lastElementChild.remove();
|
|
4759
|
+
if (i === b.rows.length - 1 && tb.rows.length > 2) { tb.lastElementChild.remove(); i--; }
|
|
4760
|
+
if (!(box = next())) return false; tb = null;
|
|
4761
|
+
} }
|
|
4762
|
+
return true; };
|
|
4763
|
+
// the columns of a page, then (paging) of as many pages as it takes, or (no `head`) none more
|
|
4764
|
+
const paging = (head, n, first) => { const q = [...first], f = () => q.shift() || (head ? (q.push(...cols(sheet(head(true)), n).map(full)), q.shift()) : null);
|
|
4765
|
+
f.spare = () => q.length > 0; return f; };
|
|
4766
|
+
|
|
4767
|
+
// the cover, and its contents: a view's caption under its title, indented under the views printed that hold it
|
|
4768
|
+
const day = new Date(Date.now() - new Date().getTimezoneOffset() * 6e4).toISOString().slice(0, 10);
|
|
4769
|
+
const sum = [count(shown.length, 'view'), count(new Set(shown.flatMap(([v]) => cards(v))).size, 'card'), count(new Set(shown.flatMap(([v]) => lines(v).map(e => e._id))).size, 'line'), ...(rs.length ? [count(rs.length, 'rule')] : [])];
|
|
4770
|
+
const cover = sheet('', 'cover'); cover.parentNode.classList.add('cover');
|
|
4771
|
+
cover.insertAdjacentHTML('beforeend', `<div class="ttl"><h1>${title}</h1><p class="cap">${day}${D.source ? ` · <span class="mono">${esc(D.source)}</span>` : ''}</p><p class="sum">${sum.join(' · ')}</p></div>`);
|
|
4772
|
+
pour(paging(() => '<header><div class="h2">Contents <span class="up">(continued)</span></div></header>', 2, cols(cover, 1)), [{ title: 'Contents', cls: 'toc', rows: [
|
|
4773
|
+
...shown.map(([v], i) => `<tr><td style="padding-left:${chain(v.id).slice(0, -1).filter(id => on.has(id)).length * 12}pt">${T(v)}${v.caption ? `<span>${esc(v.caption)}</span>` : ''}</td><td class="pg" data-pg="${i}"></td></tr>`),
|
|
4774
|
+
...(rs.length ? ['<tr><td>Rules</td><td class="pg" data-pg="rules"></td></tr>'] : [])] }]);
|
|
4775
|
+
|
|
4776
|
+
shown.forEach(([v, svg], vi) => {
|
|
4777
|
+
const g = geoCache[v.id].g, ids = cards(v), es = lines(v), seq = v.render === 'sequence', stepped = es.some(e => e.step != null), at = Object.fromEntries(g.nodes.map(n => [n.id, n]));
|
|
4778
|
+
const wk = new Map(g.edges.flatMap(d => edgeRefIds(d).map(id => [id, d.wk]))), held = boxes(v).map(c => [c, arrOf(c.nodeIds).filter(i => ids.includes(i))]).filter(([, ins]) => ins.length);
|
|
4779
|
+
const up = chain(v.id).slice(0, -1).map(id => `<span class="up">${T(V[id])} › </span>`).join('');
|
|
4780
|
+
const head = more => `<header>${more ? `<div class="h2">${up}${T(v)} <span class="up">(continued)</span></div>` : `<h2>${up}${T(v)}</h2>`}<span class="meta">${[count(ids.length, 'card'), count(es.length, 'line'), ...(held.length ? [count(held.length, 'box', 'boxes')] : [])].join(' · ')}</span></header>${!more && v.caption ? `<p class="cap">${esc(v.caption)}</p>` : ''}`;
|
|
4781
|
+
const body = sheet(head(false), vi);
|
|
4782
|
+
body.insertAdjacentHTML('beforeend', `<figure>${svg}</figure><div class="lg"><b>Legend</b>${legendItems([g], 'opens a view of its own', [geoCache[v.id].shapes], D.icons).join('')}</div>`);
|
|
4783
|
+
// the picture as large as the page allows, never larger than drawn: below it the legend, then two columns of tables;
|
|
4784
|
+
// one too tall for that keeps the page's height, with the legend and one column beside it. Smaller (down to 70%, and
|
|
4785
|
+
// to a scale of 0.6 at least) when that keeps the tables on its page; else they go on over the next pages.
|
|
4786
|
+
const fig = body.firstElementChild, lg = fig.nextElementSibling, s = fig.firstElementChild, b = s.viewBox.baseVal, W = body.clientWidth - 2, H = body.clientHeight - 2, SIDE = 340;
|
|
4787
|
+
let k = Math.min(1, W / b.width, (H - lg.offsetHeight - 16) / b.height); const side = b.width * k <= W - SIDE;
|
|
4788
|
+
if (side) k = Math.min(1, H / b.height, (W - SIDE) / b.width);
|
|
4789
|
+
body.classList.toggle('side', side);
|
|
4790
|
+
const lay = (f, more) => { s.setAttribute('width', b.width * k * f); s.setAttribute('height', b.height * k * f); body.querySelector('.cols')?.remove();
|
|
4791
|
+
const first = cols(body, side ? 1 : 2); if (side) first[0].append(lg); return pour(paging(more && head, 2, first), tables); };
|
|
4792
|
+
const tables = [
|
|
4793
|
+
{ title: `Cards <span>${ids.length}</span>`, cls: 'cards', head: '<th>Card</th><th>Kind</th><th>What it is</th>', rows: ids.map(id => { const c = nodeIn(id, v.id), t = KIND[kindOf(c.kind, true)], d = at[id] || {}, sub = c.sublabel ?? c.sub;
|
|
4794
|
+
return `<tr><td>${d.step ? `<i class="num">${esc(d.step)}</i>` : ''}<b>${esc(c.label || id)}</b>${d.kids || d.opens ? ' ⤢' : ''}${sub ? `<small>${esc(sub)}</small>` : ''}</td>`
|
|
4795
|
+
+ `<td><i class="sw" style="border-color:var(--um-${t[0]});background:var(--um-${t[0]}-f)"></i>${t[1]}</td><td>${c.what ? md(c.what) : ''}</td></tr>`; }) },
|
|
4796
|
+
{ title: `Lines <span>${es.length}</span>`, cls: 'lines', head: `${seq || stepped ? '<th class="n">#</th>' : ''}<th>From → to</th><th>Label</th><th>Technology</th>`, rows: (stepped && !seq ? [...es].sort((a, b) => stepOrder(a.step, b.step)) : es).map((e, i) => { const w = wk.get(e._id) || 'calls';
|
|
4797
|
+
return `<tr>${seq ? `<td>${i + 1}</td>` : stepped ? `<td>${esc(e.step ?? '')}</td>` : ''}<td><i class="w" style="border-color:var(--um-w-${w})"></i>${name(v, e.from)} → ${name(v, e.to)}</td><td>${e.label ? esc(e.label) : `<span class="up">${w}</span>`}</td><td>${esc(e.sublabel || '')}</td></tr>`; }) },
|
|
4798
|
+
{ title: `Boxes <span>${held.length}</span>`, cls: 'boxes', head: '<th>Box</th><th>Holds</th>', rows: held.map(([c, ins]) => `<tr><td><b>${esc(c.label || c.id)}</b></td><td>${andList(ins.map(i => name(v, i)))}</td></tr>`) },
|
|
4799
|
+
].filter(x => x.rows.length);
|
|
4800
|
+
[1, .9, .8, .7].some(f => k * f >= Math.min(k, .6) && lay(f)) || lay(1, true);
|
|
4801
|
+
});
|
|
4802
|
+
|
|
4803
|
+
if (rs.length) { // the rules: where rules() judges each (a rule that names boxes, in the views that draw one; `views` narrows it)
|
|
4804
|
+
// ponytail: rules()' own scope, again; one helper for both if it changes
|
|
4805
|
+
const anyBox = new Set(views.flatMap(v => boxes(v).map(c => c.id))), printed = shown.map(([v]) => v);
|
|
4806
|
+
const where = r => { const named = [r.from, r.to].flatMap(s => boxesNamed(s, id => anyBox.has(id) && !N[id]));
|
|
4807
|
+
const vs = printed.filter(v => (!Array.isArray(r.views) || r.views.includes(v.id)) && (!named.length || boxes(v).some(c => named.includes(c.id))));
|
|
4808
|
+
return vs.length === printed.length ? 'every view printed' : vs.length ? vs.map(T).join(', ') : 'no view printed'; };
|
|
4809
|
+
const said = (x, r) => x.message.slice(`view ${x.view}: `.length).replace(`, which breaks rule "${r.title || r.id}"`, '').replace(` breaks rule "${r.title || r.id}"`, '').replace(/^edge /, ''); // its message's words: A→B, or the boxes in a cycle
|
|
4810
|
+
const head = more => `<header>${more ? '<div class="h2">Rules <span class="up">(continued)</span></div>' : '<h2>Rules</h2>'}<span class="meta">${count(rs.length, 'rule')} · ${new Set(broken.map(x => x.rule)).size} broken</span></header>${more ? '' : '<p class="cap">Judged in the views printed here.</p>'}`;
|
|
4811
|
+
pour(paging(head, 1, cols(sheet(head(false), 'rules'), 1).map(full)), [{ cls: 'rules', head: '<th>Rule</th><th>Applies in</th><th>State</th><th>Lines that break it</th>', rows: rs.map(r => { const bs = broken.filter(x => x.rule === r.id);
|
|
4812
|
+
return `<tr><td><b>${esc(r.title || r.id)}</b></td><td>${where(r)}</td><td class="${bs.length ? 'no' : 'ok'}">${bs.length ? 'broken' : 'holds'}</td><td>${bs.map(x => `<div>${T(V[x.view])}: ${esc(said(x, r))}</div>`).join('') || '<span class="up">none</span>'}</td></tr>`; }) }]);
|
|
4813
|
+
}
|
|
4814
|
+
const all = [...doc.children]; // the pages, numbered, and the contents' page of each view
|
|
4815
|
+
all.forEach((p, i) => { p.querySelector('.pn').textContent = `page ${i + 1} of ${all.length}`; });
|
|
4816
|
+
doc.querySelectorAll('[data-pg]').forEach(td => { td.textContent = all.findIndex(p => p.dataset.key === td.dataset.pg) + 1; });
|
|
4817
|
+
}
|
|
4818
|
+
|
|
4020
4819
|
/* ================= the standalone page's pieces (#99): views list, legend, hint line, details panel =================
|
|
4021
4820
|
mountPage draws all four in its page; render() draws those a host asks for (nav, legend, hints, details) around its
|
|
4022
4821
|
canvas, in its shadow root. The hint line is hintHTML() and hinter(); pieces() draws the others from the map and acts
|
|
@@ -4024,8 +4823,8 @@ export function mountPage(container, model, options = {}) {
|
|
|
4024
4823
|
o.page: the standalone page, which keeps the reader's choices in localStorage; o.frame takes the class tree-open while
|
|
4025
4824
|
the views list is open, and o.toggle says so; o.layer holds an example's lightbox; o.status(text, ms) says what happened. */
|
|
4026
4825
|
const shortT = t => String(t || '').split(' · ')[0];
|
|
4027
|
-
const hintHTML = keys => typeof matchMedia === 'function' && matchMedia('(hover: none)').matches ? '<span>pinch = zoom</span><span>drag = move</span><span>tap = details</span>'
|
|
4028
|
-
: `<span
|
|
4826
|
+
const hintHTML = (keys, ctrl) => typeof matchMedia === 'function' && matchMedia('(hover: none)').matches ? '<span>pinch = zoom</span><span>drag = move</span><span>tap = details</span>'
|
|
4827
|
+
: `<span>${ctrl ? 'Ctrl + scroll' : 'scroll'} = zoom</span><span>drag = move</span><span>double-click ⤢ = open</span>${keys ? `<span>${keys} = search</span><span>? = keys</span>` : ''}`; // keys: the page's search key; ctrl: wheel 'ctrl'
|
|
4029
4828
|
function hinter(el, html) { let t; return (s, ms = 1800) => { el.textContent = s; el.classList.add('msg'); clearTimeout(t); t = setTimeout(() => { el.innerHTML = html; el.classList.remove('msg'); }, ms); }; }
|
|
4030
4829
|
|
|
4031
4830
|
// Pull concise points from captured prose; retain wording and keep the full point in its tooltip. A point is a sentence
|
|
@@ -4048,12 +4847,32 @@ function detailBullets(body) {
|
|
|
4048
4847
|
return parts;
|
|
4049
4848
|
}
|
|
4050
4849
|
|
|
4051
|
-
|
|
4850
|
+
// what each shape stands for, in a legend (0.15.0)
|
|
4851
|
+
const SHAPE_SAYS = { person: ['Person', 'someone who uses it'], browser: ['Browser app', 'runs in a web browser'], mobile: ['Mobile app', 'runs on a phone'],
|
|
4852
|
+
cylinder: ['Data store', 'a database or a store'], pipe: ['Queue', 'hands work on, in order'], document: ['File', 'a document or a config'],
|
|
4853
|
+
hexagon: ['Hexagon', 'a guard or a service'], robot: ['Agent', 'an AI agent'], terminal: ['Command line', 'a CLI or a script'],
|
|
4854
|
+
window: ['Desktop app', 'runs in a window'], folder: ['Package', 'a module or a folder of code'], node: ['Host', 'a machine or runtime it runs on'],
|
|
4855
|
+
component: ['Component', 'a part that runs (UML)'] };
|
|
4856
|
+
const swatchOf = (sh, k, c, f, dashed) => { const n = { x: 0, y: 0, w: 100, h: 64 }, S = SHAPE[sh]; // a legend's key: the card's outline, small
|
|
4857
|
+
return `<svg class="lgs" viewBox="-4 -4 108 72" aria-hidden="true"><path d="${S.out(n)}" fill="${f}" stroke="${c}"${dashed ? ' stroke-dasharray="3 2"' : ''}/>${S.det ? S.det(n, c, k) : ''}</svg>`; };
|
|
4858
|
+
function legendItems(gs, opens = 'double-click to open', notes = [], icons = {}) { // what these pictures draw, each with what it means: the view's legend, and the PDF's (#93). notes: each picture's notation (0.15.0)
|
|
4052
4859
|
const ns = gs.flatMap(g => g.nodes), es = gs.flatMap(g => g.edges), used = [...new Set(ns.map(n => n.k))];
|
|
4860
|
+
// each card as its picture's notation draws it: its shape, its dash and its mark (the legend draws what the map draws)
|
|
4861
|
+
const cards = gs.flatMap((g, i) => { const nt = g.map ? noteOf(notes[i]) || 'box' : 'box';
|
|
4862
|
+
return g.nodes.map(n => { const sh = shapeOf(n, nt), I = (icons || {})[n.icon]; // an icon the table lacks leaves the card the component mark, as svgOf draws it
|
|
4863
|
+
return { k: n.k, sh, dash: nt !== 'box' && n.k === 'external', comp: nt === 'uml' && sh === 'box' && !(I && (I.src || I.g)) && !String(n.k).startsWith('m-') }; }); });
|
|
4864
|
+
const kindShape = k => { const s = [...new Set(cards.filter(d => d.k === k).map(d => d.sh))]; return s.length === 1 && s[0] !== 'box' && !(k === 'm-hook' && s[0] === 'hexagon') ? s[0] : null; };
|
|
4865
|
+
const dashOf = k => k === 'm-ext' || cards.some(d => d.k === k && d.dash); // an external card is dashed only where it is drawn so
|
|
4053
4866
|
const order = ['m-public', 'm-step', 'm-lane', 'm-ext', 'm-verb', 'm-board', 'm-hook', 'm-script', 'frontend', 'backend', 'ai', 'queue', 'database', 'security', 'cloud', 'external', 'file'];
|
|
4054
|
-
const shape = k => k === 'm-verb' ? 'p' : (k
|
|
4055
|
-
|
|
4056
|
-
|
|
4867
|
+
const shape = k => k === 'm-verb' ? 'p' : dashOf(k) ? 'd' : k === 'm-hook' ? 'h' : '';
|
|
4868
|
+
const kinds = order.filter(k => used.includes(k)), own = new Set(kinds.map(kindShape).filter(Boolean)), tone = k => `var(--um-${KIND[k][0]})`;
|
|
4869
|
+
// a shape the kinds' keys don't show already: in a neutral outline, as colour is the kinds' (and UML's component mark)
|
|
4870
|
+
const shapes = SHAPE_NAMES.filter(sh => sh !== 'box' && !own.has(sh) && cards.some(d => d.sh === sh && !(sh === 'hexagon' && d.k === 'm-hook')))
|
|
4871
|
+
.map(sh => `<span class="lgi" title="${esc(SHAPE_SAYS[sh].join(' — '))}">${swatchOf(sh, 'file', 'var(--um-muted)', 'var(--um-panel)')}<b>${SHAPE_SAYS[sh][0]}</b><span>${SHAPE_SAYS[sh][1]}</span></span>`)
|
|
4872
|
+
.concat(cards.some(d => d.comp) ? [`<span class="lgi" title="${esc(SHAPE_SAYS.component.join(' — '))}"><svg class="lgs" viewBox="0 0 16 16" aria-hidden="true"><g fill="none" stroke="var(--um-muted)" stroke-linecap="round" stroke-linejoin="round">${SIGIL.component}</g></svg><b>${SHAPE_SAYS.component[0]}</b><span>${SHAPE_SAYS.component[1]}</span></span>`] : []);
|
|
4873
|
+
return kinds.map(k => `<span class="lgi" title="${esc(KIND[k][1] + ' — ' + KIND[k][2])}">${kindShape(k) ? swatchOf(kindShape(k), k, tone(k), `var(--um-${KIND[k][0]}-f)`, dashOf(k)) : `<i class="${shape(k)}" style="border-color:${tone(k)};background:var(--um-${KIND[k][0]}-f)${k === 'm-hook' ? `;background:${tone(k)}` : ''}"></i>`}<b>${KIND[k][1]}</b><span>${KIND[k][2]}</span></span>`)
|
|
4874
|
+
.concat(shapes)
|
|
4875
|
+
.concat(ns.some(n => n.kids || n.opens) ? [`<span class="lgi"><i class="s"></i><b>⤢ Stacked</b><span>${opens}</span></span>`] : [])
|
|
4057
4876
|
.concat(ns.some(n => n.step) ? ['<span class="lgi"><i class="num">1</i><b>Step</b><span>run order</span></span>'] : [])
|
|
4058
4877
|
.concat(ns.some(n => n.tag) ? ['<span class="lgi"><i class="tag"></i><b>@author</b><span>who wrote it</span></span>'] : [])
|
|
4059
4878
|
.concat(WIRES.filter(w => es.some(e => e.wk === w[0])).map(w => `<span class="lgi"><i class="w" style="border-color:var(--um-w-${w[0]})"></i><b>${w[1]}</b><span>${w[2]}</span></span>`))
|
|
@@ -4063,14 +4882,17 @@ function legendItems(gs) { // what these pictures draw, each with what it means:
|
|
|
4063
4882
|
function pieces(X, el, o) {
|
|
4064
4883
|
const kept = k => { try { return o.page ? localStorage.getItem(k) : null; } catch (_) { return null; } };
|
|
4065
4884
|
const keep = (k, v) => { try { if (o.page) localStorage.setItem(k, v); } catch (_) {} };
|
|
4066
|
-
|
|
4885
|
+
// want: the tab the reader chose, kept; tab: the one shown, which is want when the card has it (0.14.0)
|
|
4886
|
+
let sel = null, picked = [], lit = null, lb = null, lbBack = null, folded = kept('visual-legend') === '0', want = kept('visual-tab') || 'when', tab = want, TABS = ['when', 'what', 'how', 'links'];
|
|
4887
|
+
let told = null; // the story's step shown: { id, step, pointer: the card its own select names, pending: that select is still to come }
|
|
4067
4888
|
|
|
4068
4889
|
/* the legend: the kinds of cards and lines the view draws, in a box that folds away */
|
|
4069
4890
|
function legend() {
|
|
4070
|
-
const items = legendItems([X().geo()]);
|
|
4891
|
+
const items = legendItems([X().geo()], undefined, [X().shapes ? X().shapes() : 'box'], X().ctx().D.icons), back = el.legend.contains(el.legend.getRootNode().activeElement);
|
|
4071
4892
|
el.legend.hidden = !items.length;
|
|
4072
4893
|
el.legend.classList.toggle('folded', folded);
|
|
4073
4894
|
el.legend.innerHTML = `<div class="t"><span>LEGEND</span><button type="button" id="m-lfold" aria-label="${folded ? 'Show' : 'Hide'} legend" aria-expanded="${!folded}">${folded ? '+' : '–'}</button></div>${items.join('')}`;
|
|
4895
|
+
if (back) el.legend.querySelector('#m-lfold').focus(); // a fold by keyboard keeps focus on the button
|
|
4074
4896
|
}
|
|
4075
4897
|
/* the views list: readable columns; a declared order may include shortcuts to descendants */
|
|
4076
4898
|
function tree() {
|
|
@@ -4129,7 +4951,7 @@ function pieces(X, el, o) {
|
|
|
4129
4951
|
const exs = Array.isArray(n.examples) ? n.examples.filter(x => x && (x.thumb || x.href)) : [];
|
|
4130
4952
|
const tabs = [['when', 'When to use'], ['what', 'What it is'], ['how', 'How it works'], ...(exs.length ? [['examples', 'Example']] : []), ['links', 'Behind the scenes']];
|
|
4131
4953
|
TABS = tabs.map(t => t[0]);
|
|
4132
|
-
|
|
4954
|
+
tab = TABS.includes(want) ? want : 'when'; // a card without the chosen tab shows its first; the choice stays for the next card
|
|
4133
4955
|
const text = (h4, body, key) => body ? `<h4>${h4}</h4><ul class="detail-bullets">${detailBullets(body).map(b => `<li title="${esc(b)}">${inline(b)}</li>`).join('')}</ul>` : `<h4>${h4}</h4><div class="pnone">Not written yet — add <code>${key}</code> to this node in the map model.</div>`;
|
|
4134
4956
|
const verbs = (n.verbs || []).length ? `<div class="pverbs">${n.verbs.map(v => { const vid = N[nid + ':' + v] ? nid + ':' + v : null; return `<${vid ? `button data-n="${esc(vid)}"` : 'span'} class="pverb" style="--c:${c}">${esc(v)}</${vid ? 'button' : 'span'}>`; }).join('')}</div>` : '';
|
|
4135
4957
|
const srcHtml = srcs.length ? '<ul class="source-list">' + srcs.map(s => `<li class="src">${srcHref(s) ? `<a href="${esc(srcHref(s))}" title="Open ${esc(sb ? srcLabel(s) : base + s)}"${sb ? ' target="_blank" rel="noopener"' : ''}>${esc(srcLabel(s))}</a>` : `<span>${esc(s)}</span>`}<button class="ibtn sm" data-copy="${esc(sb ? srcLabel(s) : base + s)}" title="Copy path" aria-label="Copy path">⧉</button></li>`).join('') + '</ul>' : '<div class="rel-none">No source listed</div>';
|
|
@@ -4141,7 +4963,7 @@ function pieces(X, el, o) {
|
|
|
4141
4963
|
links: `<div class="links"><div><h4>← Comes from</h4>${relRows(ups, 'up')}</div><div><h4>→ Leads to</h4>${relRows(downs, 'down')}</div>
|
|
4142
4964
|
<div><h4>Source files</h4>${srcHtml}${vIn.length ? `<h4 style="margin-top:10px">Also shown in</h4><ul class="pviews">${vIn.map(v => `<li><button class="pview" data-v="${esc(v.id)}" data-sel="${esc(nid)}">${esc(shortT(v.title || v.id))}</button></li>`).join('')}</ul>` : ''}</div></div>`,
|
|
4143
4965
|
};
|
|
4144
|
-
const was =
|
|
4966
|
+
const was = focused(), on = was && [...was.attributes].find(a => a.name.startsWith('data-')); // a panel drawn again keeps focus on the control it was on
|
|
4145
4967
|
panel.innerHTML = `<div class="ph" style="--c:${c}">
|
|
4146
4968
|
${(() => { const I = (D.icons || {})[n.icon];
|
|
4147
4969
|
return I && I.src ? `<span class="big logo${I.dark ? ' dark' : ''}" aria-hidden="true"><img src="${I.src}" width="22" height="22" alt=""></span>`
|
|
@@ -4157,30 +4979,41 @@ function pieces(X, el, o) {
|
|
|
4157
4979
|
panel.querySelectorAll('.clamp').forEach(el => { if (el.scrollHeight > el.clientHeight + 2) el.insertAdjacentHTML('afterend', '<button class="morebtn" data-more>Show all</button>'); });
|
|
4158
4980
|
if (was) ((on && [...panel.querySelectorAll(`[${on.name}]`)].find(x => x.getAttribute(on.name) === on.value)) || panel.querySelector('[aria-selected="true"]')).focus();
|
|
4159
4981
|
}
|
|
4160
|
-
function hide() { const back =
|
|
4161
|
-
function openExample(nid, i) {
|
|
4982
|
+
function hide() { const back = focused(); el.panel.hidden = true; if (back) queueMicrotask(() => { if (el.panel.hidden) X().focus(); }); } // a panel that closes gives focus back to the map
|
|
4983
|
+
function openExample(nid, i, from = null) { // from: the control that opened it, which focus goes back to
|
|
4162
4984
|
const x = ((X().ctx().nodeIn(nid, X().view()) || {}).examples || [])[i]; if (!x) return;
|
|
4163
4985
|
if (!lb) { lb = h(`<div class="lightbox" id="m-lightbox" role="dialog" aria-modal="true" aria-label="Example" hidden></div>`); o.layer.appendChild(lb);
|
|
4164
4986
|
lb.addEventListener('click', e => { if (e.target === lb || e.target.closest('[data-lbclose]')) closeExample(); }); }
|
|
4165
4987
|
lb.innerHTML = `<figure><img src="${esc(x.full || x.thumb)}" alt="${esc(x.title || '')}"><figcaption><span>${inline(x.title || '')}</span>${x.href ? `<a href="${esc(x.href)}" target="_blank" rel="noopener">Open live page ↗</a>` : ''}<button class="ibtn" data-lbclose title="Close (Esc)" aria-label="Close">×</button></figcaption></figure>`;
|
|
4166
|
-
lb.hidden = false; lb.querySelector('[data-lbclose]').focus();
|
|
4988
|
+
lbBack = from; lb.hidden = false; lb.querySelector('[data-lbclose]').focus();
|
|
4167
4989
|
}
|
|
4168
|
-
const closeExample = () => { if (lb) lb.hidden = true;
|
|
4169
|
-
|
|
4990
|
+
const closeExample = () => { if (!lb || lb.hidden) return; lb.hidden = true; // focus goes back where it came from: the thumbnail, else the panel's, else the map
|
|
4991
|
+
const to = lbBack && lbBack.isConnected ? lbBack : !el.panel.hidden && el.panel.querySelector('[data-ex]'); lbBack = null; if (to) to.focus(); else X().focus(); };
|
|
4992
|
+
const setTab = t => { want = tab = t; keep('visual-tab', t); if (sel && !el.panel.hidden) show(sel); };
|
|
4170
4993
|
|
|
4171
4994
|
/* 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
|
|
4172
4995
|
new selection or view clears them, and the same key again does */
|
|
4173
4996
|
const cardName = id => X().ctx().nodeIn(id, X().view()).label || id, nCards = n => `${n} card${n === 1 ? '' : 's'}`;
|
|
4174
4997
|
// the slowest and the costliest path (#95), offered where the view's lines have latency or cost
|
|
4175
4998
|
const BY = { latency: ['⏱', 'slowest'], cost: ['¤', 'costliest'] }, budgets = () => Object.keys(BY).filter(k => X().ctx().viewEdges(X().ctx().V[X().view()]).some(e => typeof e[k] === 'number'));
|
|
4176
|
-
const
|
|
4177
|
-
|
|
4178
|
-
+
|
|
4179
|
-
+ '
|
|
4180
|
-
|
|
4181
|
-
|
|
4999
|
+
const litOn = k => !!lit && lit.split(' ')[0] === k; // the button whose light is on says so (aria-pressed)
|
|
5000
|
+
const litButtons = () => `<button class="ibtn" data-lit="out" title="Light what it leads to (>)" aria-label="Light what it leads to" aria-pressed="${litOn('out')}">→</button><button class="ibtn" data-lit="in" title="Light what leads to it (<)" aria-label="Light what leads to it" aria-pressed="${litOn('in')}">←</button>`
|
|
5001
|
+
+ (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" aria-pressed="${litOn('paths')}">⇄</button>` : '')
|
|
5002
|
+
+ budgets().map(k => { const t = `Light the ${BY[k][1]} path ${picked.length > 1 ? 'between the two cards' : 'from this card'}`; return `<button class="ibtn" data-lit="${k}" title="${t}" aria-label="${t}" aria-pressed="${litOn(k)}">${BY[k][0]}</button>`; }).join('')
|
|
5003
|
+
+ `<button class="ibtn" data-lit="down" title="What if this goes down?" aria-label="What if this goes down?" aria-pressed="${litOn('down')}">⊘</button>`;
|
|
5004
|
+
/* what is lit is the pieces' own layer, 'lit' (0.14.0): it never takes a host's highlight() or a story's away, nor they it */
|
|
5005
|
+
const lv = r => Math.min(1, Math.max(0, +(r.level ?? (r.distance > 0 ? Math.max(.45, 1 - .15 * r.distance) : 1)) || 0)); // as highlight() fades a Ref
|
|
5006
|
+
function litLayer(refs, marks) { // the cards and lines lit, the rest dimmed, with the marks that go with them (a path's total, the card that is down)
|
|
5007
|
+
const L = { nodes: {}, edges: {}, dim: refs.length > 0 }, put = (to, x, r) => { to[x] = { level: Math.max(lv(r), (to[x] || {}).level || 0) }; };
|
|
5008
|
+
refs.forEach(r => { if (r.type === 'node') put(L.nodes, r.id, r); else if (r.type === 'edge') (r.ids || []).forEach(x => put(L.edges, x, r)); });
|
|
5009
|
+
Object.entries((marks || {}).nodes || {}).forEach(([id, m]) => { L.nodes[id] = { ...L.nodes[id], ...m }; });
|
|
5010
|
+
return L;
|
|
5011
|
+
}
|
|
5012
|
+
const pressed = () => { if (el.panel) el.panel.querySelectorAll('[data-lit]').forEach(b => b.setAttribute('aria-pressed', litOn(b.dataset.lit))); };
|
|
5013
|
+
function unlight() { if (lit) { lit = null; X().overlay('lit', null); pressed(); } }
|
|
5014
|
+
function light(key, refs, said, marks = null) {
|
|
4182
5015
|
if (lit === key) { unlight(); return o.status('Highlight cleared'); }
|
|
4183
|
-
lit = key; X().
|
|
5016
|
+
lit = key; X().overlay('lit', refs.length || marks ? litLayer(refs, marks) : null, [X().view()]); pressed(); o.status(said, 3600);
|
|
4184
5017
|
}
|
|
4185
5018
|
function lightReach(back) {
|
|
4186
5019
|
if (!sel || !X().geo().byId[sel]) return;
|
|
@@ -4222,7 +5055,7 @@ function pieces(X, el, o) {
|
|
|
4222
5055
|
const tb = t.closest('[data-tab]'); if (tb) return setTab(tb.dataset.tab);
|
|
4223
5056
|
const st = t.closest('[data-step]'); if (st) return setTab(TABS[(TABS.indexOf(tab) + +st.dataset.step + TABS.length) % TABS.length]);
|
|
4224
5057
|
const mb = t.closest('[data-more]'); if (mb) { mb.previousElementSibling.classList.add('open'); mb.remove(); }
|
|
4225
|
-
const ex = t.closest('[data-ex]'); if (ex) return openExample(el.panel.dataset.node, +ex.dataset.ex);
|
|
5058
|
+
const ex = t.closest('[data-ex]'); if (ex) return openExample(el.panel.dataset.node, +ex.dataset.ex, ex);
|
|
4226
5059
|
if (t.closest('[data-close]')) return X().select(null);
|
|
4227
5060
|
const lt = t.closest('[data-lit]'); if (lt) { const k = lt.dataset.lit; return k === 'paths' ? lightPaths() : k === 'down' ? lightDown() : BY[k] ? lightCritical(k) : lightReach(k === 'in'); }
|
|
4228
5061
|
const ob = t.closest('[data-open]'); if (ob) return X().open(ob.dataset.open); // the open event first, as a double-click on the card
|
|
@@ -4232,17 +5065,37 @@ function pieces(X, el, o) {
|
|
|
4232
5065
|
const cp = t.closest('[data-copy]'); if (cp) navigator.clipboard && navigator.clipboard.writeText(cp.dataset.copy).then(() => o.status('Path copied'), () => o.status(cp.dataset.copy));
|
|
4233
5066
|
});
|
|
4234
5067
|
|
|
5068
|
+
/* a story's step points at a card (0.14.0): the select its step makes is a pointer, which opens no details; a card the
|
|
5069
|
+
reader picks opens them, that one too. One rule, by id: a step's select is a card's id or a Ref (a chip's or a badge's
|
|
5070
|
+
names its card) */
|
|
5071
|
+
const pointerOf = x => typeof x === 'string' ? x : x && typeof x === 'object' ? (x.type === 'node' ? x.id : x.node ?? null) : null;
|
|
5072
|
+
function story(e) { // the map's story event
|
|
5073
|
+
if (!e || e.step == null) { told = null; return; }
|
|
5074
|
+
if (told && told.id === e.story && told.step === e.step) return; // the same step, said again (paused, played on)
|
|
5075
|
+
const def = arrOf(X().ctx().D.stories).find(s => s && s.id === e.story), step = def && arrOf(def.steps).filter(s => s && typeof s === 'object')[e.step];
|
|
5076
|
+
const pointer = step ? pointerOf(step.select) : null, already = pointer != null && sel === pointer && step.view === X().view() && !X().busy(); // its select changes nothing: no event comes
|
|
5077
|
+
told = { id: e.story, step: e.step, pointer, pending: pointer != null && !already };
|
|
5078
|
+
if (already && el.panel) hide();
|
|
5079
|
+
}
|
|
5080
|
+
|
|
4235
5081
|
return {
|
|
4236
5082
|
chrome() { if (el.legend) legend(); if (el.tree) tree(); if (sel && el.panel && !el.panel.hidden) show(sel); }, // each picture of the map
|
|
4237
|
-
select(target, targets) { // the map's select event: the selected card's details, unless a story's step
|
|
5083
|
+
select(target, targets) { // the map's select event: the selected card's details, unless it is the select a story's step makes (a pointer)
|
|
4238
5084
|
sel = target && target.type === 'node' ? target.id : null; picked = (targets || []).filter(t => t.type === 'node').map(t => t.id);
|
|
4239
|
-
|
|
5085
|
+
const pointer = !!(told && told.pending && sel != null && sel === told.pointer); if (pointer) told.pending = false;
|
|
5086
|
+
unlight(); if (el.panel) { if (sel && !pointer) show(sel); else hide(); }
|
|
4240
5087
|
},
|
|
4241
|
-
unlight, find, setTree,
|
|
5088
|
+
story, unlight, find, setTree,
|
|
5089
|
+
modal: () => !!(lb && !lb.hidden), // an example shown full size, which holds the keys while open
|
|
4242
5090
|
key(e) { // the pieces' keys: true when one was used
|
|
4243
5091
|
const k = e.key;
|
|
4244
|
-
if (
|
|
4245
|
-
|
|
5092
|
+
if (lb && !lb.hidden) { // the example shown full size holds every key: Esc closes it, Tab keeps to it, Enter and Space press its buttons
|
|
5093
|
+
if (k === 'Escape' || k === 'Backspace') closeExample();
|
|
5094
|
+
else if (k === 'Tab') { const f = [...lb.querySelectorAll('a[href], button')], i = f.indexOf(lb.getRootNode().activeElement); f[(i + (e.shiftKey ? -1 : 1) + f.length) % f.length].focus(); }
|
|
5095
|
+
else if (k === 'Enter' || k === ' ') return false;
|
|
5096
|
+
return true;
|
|
5097
|
+
}
|
|
5098
|
+
if ((k === '[' || k === ']') && sel && el.panel && !el.panel.hidden) setTab(TABS[(TABS.indexOf(tab) + (k === ']' ? 1 : -1) + TABS.length) % TABS.length]);
|
|
4246
5099
|
else if ((k === 'o' || k === 'O') && el.tree) setTree(!o.frame.classList.contains('tree-open'));
|
|
4247
5100
|
else if ((k === '>' || k === '<') && sel && el.panel) lightReach(k === '<');
|
|
4248
5101
|
else if ((k === 'p' || k === 'P') && picked.length > 1 && el.panel) lightPaths();
|