umlimator 0.12.1 → 0.14.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 +167 -18
- package/lib/umlimator-element.d.ts +21 -0
- package/lib/umlimator-element.js +89 -0
- package/lib/umlimator-markdown-it.d.ts +6 -0
- package/lib/umlimator-markdown-it.js +33 -0
- package/lib/umlimator-remark.d.ts +4 -0
- package/lib/umlimator-remark.js +13 -0
- package/lib/umlimator-theme.css +9 -5
- package/lib/umlimator.d.ts +118 -13
- package/lib/umlimator.js +1485 -532
- package/lib/umlimator.schema.json +164 -1
- package/package.json +3 -2
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; } }
|
|
@@ -200,8 +203,141 @@ const STYLES = `
|
|
|
200
203
|
.um-story .um-st { display: block; font: 600 11px var(--um-mono); color: var(--um-muted); margin-bottom: 2px; }
|
|
201
204
|
.um-story p { margin: 0; } .um-story .um-sb { display: flex; gap: 2px; flex: none; }
|
|
202
205
|
.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(
|
|
206
|
+
.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
207
|
@media (max-width: 820px) { .um-story { left: 12px; right: 12px; bottom: 60px; max-width: none; } }
|
|
208
|
+
/* the standalone page's pieces (#99): the views list, the legend, the hint line and the details panel, in the page and,
|
|
209
|
+
when a host asks, in render()'s shadow root, where these first rules give them what the page gives its whole page */
|
|
210
|
+
:where(.outline, .m-legend, .hint, .panel, .lightbox, .um-navbtn), :where(.outline, .m-legend, .hint, .panel, .lightbox, .um-navbtn) * { box-sizing: border-box; }
|
|
211
|
+
:where(.outline, .m-legend, .panel, .lightbox) button:focus-visible, .um-navbtn:focus-visible { outline: 2px solid var(--um-cyan); outline-offset: 2px; }
|
|
212
|
+
:where(.panel, .lightbox) a { color: var(--um-cyan); }
|
|
213
|
+
:where(.panel) code { font-family: var(--um-mono); font-size: .86em; overflow-wrap: anywhere; background: var(--um-panel-2); border: 1px solid var(--um-border); padding: .05em .35em; border-radius: 5px; }
|
|
214
|
+
.ibtn { width: 32px; height: 32px; flex: none; border-radius: 8px; border: 1px solid var(--um-border-2); background: var(--um-panel); color: var(--um-text); cursor: pointer; font: 600 14px var(--um-sans); }
|
|
215
|
+
.ibtn:hover, .ibtn[aria-expanded="true"] { border-color: var(--um-accent); color: var(--um-accent); }
|
|
216
|
+
.ibtn[aria-pressed="true"] { border-color: var(--um-accent); color: var(--um-accent); }
|
|
217
|
+
.ibtn.sm { width: 26px; height: 26px; font-size: 12px; border-radius: 7px; }
|
|
218
|
+
/* views: Finder-style column browser */
|
|
219
|
+
.outline { overflow: hidden; border-right: 1px solid var(--um-border); background: var(--um-bg); visibility: hidden; min-width: 0; }
|
|
220
|
+
.tree-open .outline { visibility: visible; }
|
|
221
|
+
.miller { display: flex; height: 100%; overflow-x: auto; overflow-y: hidden; scroll-behavior: smooth; scrollbar-width: thin; }
|
|
222
|
+
.mcol { flex: 0 0 224px; min-width: 224px; border-right: 1px solid var(--um-border); padding: 8px 6px; overflow-y: auto; }
|
|
223
|
+
.mcol:last-child { border-right: 0; flex-grow: 1; }
|
|
224
|
+
.mch { font: 700 10px var(--um-sans); letter-spacing: .08em; text-transform: uppercase; color: var(--um-muted); padding: 2px 8px 6px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
|
225
|
+
.mit { display: flex; justify-content: space-between; align-items: center; gap: 6px; width: 100%; min-height: 34px; padding: 7px 8px; border-radius: 6px; border: 0; background: none; color: var(--um-text); font: 500 12.5px/1.4 var(--um-sans); cursor: pointer; text-align: left; }
|
|
226
|
+
.mit span { min-width: 0; overflow-wrap: anywhere; white-space: normal; }
|
|
227
|
+
.mit em { font-style: normal; color: var(--um-muted); font-size: 11px; white-space: nowrap; flex: none; }
|
|
228
|
+
.mit:hover { background: var(--um-panel-2); }
|
|
229
|
+
.mit.path { background: var(--um-panel-2); font-weight: 650; }
|
|
230
|
+
.mit.on { background: var(--um-accent-f); box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--um-accent) 40%, transparent); font-weight: 650; }
|
|
231
|
+
/* legend: a box in the corner that folds away */
|
|
232
|
+
.m-legend { position: absolute; left: 16px; bottom: 40px; z-index: 3; background: var(--um-panel); border: 1px solid var(--um-border-2); border-radius: 12px; box-shadow: var(--um-shadow); padding: 8px 12px; font: 11.5px var(--um-sans); display: grid; grid-template-columns: repeat(2, auto); gap: 3px 16px; max-width: calc(100% - 290px); }
|
|
233
|
+
.m-legend .t { grid-column: 1 / -1; display: flex; justify-content: space-between; align-items: center; gap: 12px; font: 700 10px var(--um-sans); letter-spacing: .1em; color: var(--um-muted); }
|
|
234
|
+
.m-legend .t button { border: 0; background: none; color: var(--um-muted); cursor: pointer; font: 700 13px var(--um-sans); padding: 0 2px; line-height: 1; }
|
|
235
|
+
.m-legend.folded .lgi { display: none; }
|
|
236
|
+
.lgi { display: flex; align-items: center; gap: 7px; white-space: nowrap; }
|
|
237
|
+
.lgi i { width: 14px; height: 10px; border: 1.5px solid var(--um-muted); border-radius: 3px; flex: none; }
|
|
238
|
+
.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; }
|
|
239
|
+
.lgi i.s { box-shadow: 2px 2px 0 -.5px var(--um-panel), 2px 2px 0 .5px var(--um-muted); background: var(--um-panel); }
|
|
240
|
+
.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; }
|
|
241
|
+
.lgi i.tag { width: 20px; border-radius: 999px; border-width: 1px; }
|
|
242
|
+
.lgi b { font-weight: 650; } .lgi span { color: var(--um-muted); }
|
|
243
|
+
/* help: one quiet line at the bottom */
|
|
244
|
+
.hint { position: absolute; left: 0; right: 0; bottom: 0; z-index: 3; display: flex; gap: 16px; justify-content: center; align-items: center; height: 28px; padding: 0 12px; font: 11.5px var(--um-sans); color: var(--um-muted); background: color-mix(in srgb, var(--um-bg) 88%, transparent); border-top: 1px solid var(--um-border); white-space: nowrap; overflow: hidden; }
|
|
245
|
+
.hint b { color: var(--um-text); font-weight: 600; }
|
|
246
|
+
.hint.msg { color: var(--um-accent); font-weight: 600; }
|
|
247
|
+
/* details: a short strip under the map — compact header, then When to use · What it is · How it works · Example · Behind the scenes tabs */
|
|
248
|
+
.panel { flex: none; height: 234px; max-height: 44%; overflow: auto; border-top: 1px solid var(--um-border-2); background: var(--um-panel); z-index: 5; animation: rise .2s ease; }
|
|
249
|
+
.panel[hidden] { display: none; }
|
|
250
|
+
@keyframes rise { from { transform: translateY(12px); opacity: 0; } }
|
|
251
|
+
.ph { display: flex; align-items: center; gap: 12px; padding: 12px 18px 6px; }
|
|
252
|
+
.ph .big { width: 38px; height: 38px; border-radius: 10px; display: grid; place-items: center; color: var(--um-accent-fg); flex: none; }
|
|
253
|
+
.ph .pt { min-width: 0; flex: 1; }
|
|
254
|
+
.ph h3 { margin: 0; font: 650 17px var(--um-sans); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
|
255
|
+
.ph small { display: flex; flex-wrap: wrap; align-items: center; gap: 4px 8px; color: var(--um-muted); font-size: 12.5px; }
|
|
256
|
+
.ph .acts { display: flex; gap: 8px; align-items: center; flex: none; }
|
|
257
|
+
.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); }
|
|
258
|
+
.vis.pub { background: var(--um-k-public-f); 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); }
|
|
259
|
+
.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; }
|
|
260
|
+
.author:hover { border-color: var(--um-accent); }
|
|
261
|
+
.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); }
|
|
262
|
+
.author b { color: var(--um-muted); font-weight: 500; }
|
|
263
|
+
.origin { color: var(--um-muted); font-size: 12px; }
|
|
264
|
+
.obtn { display: inline-flex; align-items: center; gap: 8px; height: 32px; padding: 0 12px; border-radius: 8px; border: 1px solid var(--c); background: color-mix(in srgb, var(--c) 10%, var(--um-panel)); color: var(--um-text); font: 600 13px var(--um-sans); cursor: pointer; white-space: nowrap; }
|
|
265
|
+
.obtn span { font-weight: 500; color: var(--um-muted); font-size: 11.5px; }
|
|
266
|
+
.obtn:hover { background: color-mix(in srgb, var(--c) 20%, var(--um-panel)); }
|
|
267
|
+
.ptabs { display: flex; align-items: center; gap: 2px; padding: 0 12px; border-bottom: 1px solid var(--um-border); }
|
|
268
|
+
.ptablist { display: flex; align-items: center; gap: 2px; min-width: 0; } /* only tabs in it: ‹ › stand beside it */
|
|
269
|
+
.ptab { border: 0; border-bottom: 2px solid transparent; background: none; color: var(--um-muted); font: 500 13px var(--um-sans); padding: 7px 10px; cursor: pointer; white-space: nowrap; }
|
|
270
|
+
.ptab em { font-style: normal; font-size: 11px; color: var(--um-muted); margin-left: 4px; }
|
|
271
|
+
.ptab:hover { color: var(--um-text); }
|
|
272
|
+
.ptab[aria-selected="true"] { color: var(--um-text); font-weight: 650; border-bottom-color: var(--um-accent); }
|
|
273
|
+
.pnav { margin-left: auto; display: flex; gap: 4px; }
|
|
274
|
+
.pbody { padding: 10px 18px 14px; }
|
|
275
|
+
.pbody h4 { margin: 0 0 4px; font: 700 10px var(--um-sans); letter-spacing: .1em; text-transform: uppercase; color: var(--um-muted); display: flex; gap: 6px; }
|
|
276
|
+
.pbody h4 span { color: var(--um-muted); }
|
|
277
|
+
.clamp { font-size: 14px; line-height: 1.55; max-width: 980px; display: -webkit-box; -webkit-line-clamp: 3; -webkit-box-orient: vertical; overflow: hidden; }
|
|
278
|
+
.clamp.open { display: block; -webkit-line-clamp: unset; }
|
|
279
|
+
.clamp p { margin: 0 0 6px; } .clamp ul { margin: 0 0 6px; padding-left: 18px; }
|
|
280
|
+
.detail-bullets { margin: 0; padding-left: 18px; max-width: 1100px; font-size: 13px; line-height: 1.65; }
|
|
281
|
+
.detail-bullets li { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; padding-left: 2px; }
|
|
282
|
+
.detail-bullets li::before { content: '•'; margin-right: 8px; color: var(--um-accent); }
|
|
283
|
+
.links ul { margin: 0; padding: 0; list-style: none; }
|
|
284
|
+
.links li { margin: 0; }
|
|
285
|
+
.morebtn { border: 0; background: none; color: var(--um-accent); font: 600 12px var(--um-sans); padding: 2px 0 0; cursor: pointer; }
|
|
286
|
+
.pnone { color: var(--um-muted); font-size: 13px; }
|
|
287
|
+
.pverbs { display: flex; flex-wrap: wrap; gap: 6px; margin-top: 8px; }
|
|
288
|
+
.pverb { font: 600 12px var(--um-sans); color: var(--c); border: 1px solid color-mix(in srgb, var(--c) 50%, transparent); background: color-mix(in srgb, var(--c) 8%, transparent); padding: 1px 9px; border-radius: 999px; }
|
|
289
|
+
button.pverb { cursor: pointer; } button.pverb:hover { background: color-mix(in srgb, var(--c) 18%, transparent); }
|
|
290
|
+
.links { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 6px 22px; }
|
|
291
|
+
.src { display: flex; align-items: center; gap: 6px; min-width: 0; } .src a, .src span { font: 12px var(--um-mono); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; flex: 1; min-width: 0; }
|
|
292
|
+
.rel { display: grid; grid-template-columns: 10px minmax(0, 1fr) auto; gap: 0 8px; align-items: center; text-align: left; background: none; border: 0; padding: 4px 6px; margin: 0 -6px; width: calc(100% + 12px); border-radius: 6px; cursor: pointer; color: var(--um-text); font: 13px var(--um-sans); }
|
|
293
|
+
.rel:hover { background: var(--um-panel-2); }
|
|
294
|
+
.rel i { width: 8px; height: 8px; border-radius: 50%; }
|
|
295
|
+
.rel-l { font-weight: 600; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
296
|
+
.rel-e { font-size: 11.5px; color: var(--um-muted); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; max-width: 170px; }
|
|
297
|
+
.rel-none { color: var(--um-muted); font-size: 12.5px; padding: 2px 0; }
|
|
298
|
+
/* one yellow highlighter phrase per section (==text== in the model) */
|
|
299
|
+
.pbody mark { background: var(--um-mark); color: inherit; padding: 0 3px; border-radius: 3px; box-decoration-break: clone; -webkit-box-decoration-break: clone; }
|
|
300
|
+
/* Example tab: a data-driven gallery (node.examples = [{title, caption, thumb, full, href}]) */
|
|
301
|
+
.exs { display: grid; grid-template-columns: repeat(auto-fill, minmax(190px, 1fr)); gap: 12px; margin: 4px 0 0; padding: 0; list-style: none; max-width: 1100px; }
|
|
302
|
+
.ex { display: flex; flex-direction: column; gap: 4px; min-width: 0; }
|
|
303
|
+
.ex-thumb { display: block; padding: 0; border: 1px solid var(--um-border-2); border-radius: 8px; background: var(--um-panel-2); overflow: hidden; cursor: zoom-in; aspect-ratio: 16 / 10; }
|
|
304
|
+
.ex-thumb img { display: block; width: 100%; height: 100%; object-fit: cover; object-position: top left; }
|
|
305
|
+
.ex-thumb:hover, .ex-thumb:focus-visible { border-color: var(--um-accent); outline: none; box-shadow: 0 0 0 2px color-mix(in srgb, var(--um-accent) 30%, transparent); }
|
|
306
|
+
.ex b { font: 650 13px var(--um-sans); }
|
|
307
|
+
.ex small { color: var(--um-muted); font-size: 12px; line-height: 1.4; }
|
|
308
|
+
.ex a { color: var(--um-accent); font: 600 12px var(--um-sans); text-decoration: none; }
|
|
309
|
+
.ex a:hover { text-decoration: underline; }
|
|
310
|
+
.lightbox { position: fixed; inset: 0; z-index: 60; display: grid; place-items: center; padding: 20px; background: var(--um-scrim); }
|
|
311
|
+
.lightbox[hidden] { display: none; }
|
|
312
|
+
.lightbox figure { margin: 0; max-width: min(1280px, 100%); max-height: 100%; display: flex; flex-direction: column; gap: 10px; background: var(--um-panel); border-radius: 12px; padding: 12px; box-shadow: var(--um-shadow); }
|
|
313
|
+
.lightbox img { max-width: 100%; max-height: calc(100dvh - 140px); object-fit: contain; border-radius: 6px; border: 1px solid var(--um-border); background: var(--um-logo-tile); }
|
|
314
|
+
.lightbox figcaption { display: flex; align-items: center; gap: 12px; flex-wrap: wrap; font: 600 14px var(--um-sans); }
|
|
315
|
+
.lightbox figcaption span { flex: 1; min-width: 0; }
|
|
316
|
+
.lightbox figcaption a { color: var(--um-accent); text-decoration: none; font-size: 13px; }
|
|
317
|
+
.pviews { display: flex; flex-wrap: wrap; gap: 6px; margin-top: 4px; }
|
|
318
|
+
.pview { font: 500 12px var(--um-sans); color: var(--um-muted); background: var(--um-panel-2); border: 1px solid var(--um-border); border-radius: 6px; padding: 2px 8px; cursor: pointer; }
|
|
319
|
+
.pview:hover { color: var(--um-text); border-color: var(--um-accent); }
|
|
320
|
+
@media (max-width: 820px) {
|
|
321
|
+
.ph { padding-top: 6px; } .ph .obtn span { display: none; }
|
|
322
|
+
.links { grid-template-columns: 1fr; }
|
|
323
|
+
.ptabs, .ptablist { flex-wrap: wrap; } .pnav { display: none; } .ptab { padding: 7px 8px; }
|
|
324
|
+
.detail-bullets li { white-space: normal; overflow: visible; }
|
|
325
|
+
.exs { grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 10px; }
|
|
326
|
+
.m-legend, .hint { display: none; }
|
|
327
|
+
}
|
|
328
|
+
/* render()'s pieces (#99), laid out as the page lays them out: the views list left of the map (☰ or O), the details under
|
|
329
|
+
it, the legend and the hint line over it; while a story is told, the legend gives way to its caption bar */
|
|
330
|
+
.um-frame { position: absolute; inset: 0; display: grid; grid-template: minmax(0, 1fr) / 0 minmax(0, 1fr); transition: grid-template-columns .22s ease; }
|
|
331
|
+
.um-frame.tree-open { grid-template-columns: min(var(--browser-width, 224px), 38%) minmax(0, 1fr); }
|
|
332
|
+
.um-main { grid-area: 1 / 2; display: flex; flex-direction: column; min-width: 0; min-height: 0; }
|
|
333
|
+
.um-stage { flex: 1; min-height: 0; position: relative; overflow: hidden; }
|
|
334
|
+
.um-frame > .outline { grid-area: 1 / 1; }
|
|
335
|
+
.um-navbtn { grid-area: 1 / 2; align-self: start; justify-self: start; margin: 14px 16px; z-index: 4; }
|
|
336
|
+
.um-stage:has(.um-story) > .m-legend { display: none; }
|
|
337
|
+
/* a lens's legend (#94): each owner's tone, over the controls */
|
|
338
|
+
.um-lens { display: flex; flex-wrap: wrap; align-items: baseline; gap: 2px 12px; box-sizing: border-box; max-width: 300px; padding: 6px 11px; border-radius: 10px; background: var(--um-panel); border: 1px solid var(--um-border-2); box-shadow: var(--um-shadow); color: var(--um-text); font: 12px/1.5 var(--um-sans); }
|
|
339
|
+
.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; }
|
|
340
|
+
.um-lens i { display: inline-block; width: 8px; height: 8px; border: 2.5px solid; border-radius: 4px; margin-right: 6px; vertical-align: -1px; }
|
|
205
341
|
` +/* 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
|
|
206
342
|
file does, for page chrome) a tint keeps the tone it was mixed from, whatever an ancestor of the map sets later */
|
|
207
343
|
`: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,
|
|
@@ -286,6 +422,15 @@ function fit(s, px, max, words) { s = String(s ?? ''); if (textW(s, px) <= max)
|
|
|
286
422
|
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);
|
|
287
423
|
const w = words ? s.slice(0, k + 1).replace(/\S*$/, '').replace(/[\s,;:]+$/, '') : '';
|
|
288
424
|
return (w || s.slice(0, k)) + '…'; }
|
|
425
|
+
// 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
|
|
426
|
+
const trimLine = (pts, a, b) => {
|
|
427
|
+
const cut = (p, d) => { p = p.map(q => q.slice());
|
|
428
|
+
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);
|
|
429
|
+
if (len > d) { const t = (len - d) / len; p[p.length - 1] = [x0 + (x1 - x0) * t, y0 + (y1 - y0) * t]; break; }
|
|
430
|
+
d -= len; p.pop(); }
|
|
431
|
+
return p; };
|
|
432
|
+
return cut(cut(pts, b).reverse(), a).reverse();
|
|
433
|
+
};
|
|
289
434
|
function roundPath(pts, r = 9) {
|
|
290
435
|
let d = `M ${pts[0][0]} ${pts[0][1]}`;
|
|
291
436
|
for (let i = 1; i < pts.length - 1; i++) {
|
|
@@ -307,33 +452,39 @@ let hush = null; // a layout tried and maybe dropped (unfolding, #71) keeps its
|
|
|
307
452
|
its neighbours in the next or previous column lie, its place now breaking ties), and the best order found is kept only
|
|
308
453
|
when it crosses less than the order given. A line that skips columns has a point in each column it passes (Sugiyama's
|
|
309
454
|
dummy nodes), so it orders the cards it runs between too. cols: columns of ids in reading order, changed in place;
|
|
310
|
-
links: [a, b] with b in a later column.
|
|
311
|
-
|
|
455
|
+
links: [a, b] with b in a later column.
|
|
456
|
+
A box is one item of its column, h(id) rows tall, and a line meets it at the row of its card inside (#86): [a, b, oa, ob]
|
|
457
|
+
leaves a at row oa inside it and reaches b at row ob, as rowsFor's links do (the middle when not given). Each line into a
|
|
458
|
+
box has its own entry, so two that cross on the way in to its cards count as crossing; they all met it at one point.
|
|
459
|
+
fixed: items at a set row in columns of their own, which stay as they are (a box's far ends, in blockPos). */
|
|
460
|
+
function untangle(cols, links, h = () => 1, fixed = {}) {
|
|
312
461
|
const colOf = {}; cols.forEach((c, k) => c.forEach(id => { colOf[id] = k; }));
|
|
313
|
-
const segs = [], extra = new Set(), want = {};
|
|
314
|
-
|
|
462
|
+
const segs = [], extra = new Set(), want = {}, hh = id => extra.has(id) ? 1 : h(id), mid = (id, o) => o ?? (hh(id) - 1) / 2;
|
|
463
|
+
const tops = () => { const at = {}; cols.forEach(c => { let r = 0; c.forEach(id => { at[id] = fixed[id] ?? r; r += hh(id); }); }); return at; }; // each item's top row
|
|
464
|
+
const at0 = tops();
|
|
465
|
+
links.forEach(([a, b, oa, ob], n) => {
|
|
315
466
|
const ka = colOf[a], kb = colOf[b]; if (ka == null || kb == null || kb <= ka) return;
|
|
316
|
-
let prev = a; const ra =
|
|
467
|
+
let prev = a, po = mid(a, oa); const ra = at0[a] + po, rb = at0[b] + mid(b, ob);
|
|
317
468
|
for (let k = ka + 1; k < kb; k++) { // a point first goes where the straight line between its ends would be
|
|
318
469
|
const p = '\u0001' + n + '.' + k; extra.add(p); colOf[p] = k; want[p] = ra + (rb - ra) * (k - ka) / (kb - ka);
|
|
319
|
-
segs.push([prev, p]); prev = p; }
|
|
320
|
-
segs.push([prev, b]);
|
|
470
|
+
segs.push([prev, p, po, 0]); prev = p; po = 0; }
|
|
471
|
+
segs.push([prev, b, po, mid(b, ob)]);
|
|
321
472
|
});
|
|
322
473
|
if (extra.size) cols.forEach((c, k) => { const ps = [...extra].filter(p => colOf[p] === k);
|
|
323
|
-
if (ps.length) cols[k] = [...c.map(
|
|
324
|
-
const ins = {}, outs = {}; segs.forEach(([a, b]) => { (outs[a] ||= []).push(b); (ins[b] ||= []).push(a); });
|
|
325
|
-
const crossings = () => { const at =
|
|
326
|
-
for (let i = 0; i < segs.length; i++) for (let j = i + 1; j < segs.length; j++) { const [a, b] = segs[i], [c, d] = segs[j];
|
|
327
|
-
if (colOf[a] === colOf[c] && (at[a] - at[c]) * (at[b] - at[d]) < 0) n++; }
|
|
474
|
+
if (ps.length) cols[k] = [...c.map(id => [id, at0[id], 0]), ...ps.map(p => [p, want[p], 1])].sort((x, y) => x[1] - y[1] || x[2] - y[2]).map(x => x[0]); });
|
|
475
|
+
const ins = {}, outs = {}; segs.forEach(([a, b, oa, ob]) => { (outs[a] ||= []).push([b, ob, oa]); (ins[b] ||= []).push([a, oa, ob]); });
|
|
476
|
+
const crossings = () => { const at = tops(); let n = 0;
|
|
477
|
+
for (let i = 0; i < segs.length; i++) for (let j = i + 1; j < segs.length; j++) { const [a, b, oa, ob] = segs[i], [c, d, oc, od] = segs[j];
|
|
478
|
+
if (colOf[a] === colOf[c] && (at[a] + oa - at[c] - oc) * (at[b] + ob - at[d] - od) < 0) n++; }
|
|
328
479
|
return n; };
|
|
329
480
|
let best = segs.length > 1 ? crossings() : 0, keep = cols.map(c => c.slice()); // the order given, unless one crosses less
|
|
330
481
|
for (let it = 0; it < 8 && best; it++) {
|
|
331
|
-
const down = it % 2 === 0, seq = cols.map((_, k) => k).filter(k => down ? k > 0 : k < cols.length - 1);
|
|
482
|
+
const down = it % 2 === 0, seq = cols.map((_, k) => k).filter(k => (down ? k > 0 : k < cols.length - 1) && !cols[k].every(id => id in fixed));
|
|
332
483
|
if (!down) seq.reverse();
|
|
333
|
-
for (const k of seq) { const at =
|
|
334
|
-
const key =
|
|
335
|
-
return nb.length ? nb.reduce((x, y) => x + y, 0) / nb.length :
|
|
336
|
-
cols[k] = cols[k].map((id, i) => [id, key(id
|
|
484
|
+
for (const k of seq) { const at = tops(), side = down ? k - 1 : k + 1; // where each item would be for its lines to run level
|
|
485
|
+
const key = id => { const nb = ((down ? ins : outs)[id] || []).filter(([x]) => colOf[x] === side).map(([x, ox, o]) => at[x] + ox - o);
|
|
486
|
+
return nb.length ? nb.reduce((x, y) => x + y, 0) / nb.length : at[id]; };
|
|
487
|
+
cols[k] = cols[k].map((id, i) => [id, key(id), i]).sort((p, q) => p[1] - q[1] || p[2] - q[2]).map(x => x[0]); }
|
|
337
488
|
const n = crossings(); if (n < best) { best = n; keep = cols.map(c => c.slice()); }
|
|
338
489
|
}
|
|
339
490
|
keep.forEach((c, k) => { cols[k] = c.filter(id => !extra.has(id)); });
|
|
@@ -350,9 +501,9 @@ function pullSources(ids, links, rank) {
|
|
|
350
501
|
to, then again from the left, as near as the order allows with its height between it and the next (least squares: pool
|
|
351
502
|
adjacent violators); on half rows from 0. A line between cards on one row runs straight, past fewer cards. h: an item's
|
|
352
503
|
height in rows (a box in a view with boxes); a link [a, b, oa, ob] leaves a at row oa inside it and reaches b at row
|
|
353
|
-
ob (a box's card), else at its middle. */
|
|
354
|
-
function rowsFor(cols, links, h = () => 1) {
|
|
355
|
-
const row = {}, colOf = {}; cols.forEach((c, k) => { let r = 0; c.forEach(id => { row[id] = r; r += h(id); colOf[id] = k; }); });
|
|
504
|
+
ob (a box's card), else at its middle. fixed: items at a set row, in columns of their own that stay (as untangle's). */
|
|
505
|
+
function rowsFor(cols, links, h = () => 1, fixed = {}) {
|
|
506
|
+
const row = {}, colOf = {}; cols.forEach((c, k) => { let r = 0; c.forEach(id => { row[id] = fixed[id] ?? r; r += h(id); colOf[id] = k; }); });
|
|
356
507
|
const ins = {}, outs = {}, at = (id, o) => o ?? (h(id) - 1) / 2; // where a link meets an item, in rows from its top
|
|
357
508
|
links.forEach(([a, b, oa, ob]) => { if (colOf[a] == null || colOf[b] == null || colOf[b] <= colOf[a]) return;
|
|
358
509
|
(outs[a] ||= []).push([b, at(b, ob), at(a, oa)]); (ins[b] ||= []).push([a, at(a, oa), at(b, ob)]); });
|
|
@@ -362,11 +513,11 @@ function rowsFor(cols, links, h = () => 1) {
|
|
|
362
513
|
while (blocks.length > 1 && blocks.at(-2).sum / blocks.at(-2).n > blocks.at(-1).sum / blocks.at(-1).n) { const b = blocks.pop(), a = blocks.at(-1); a.ids.push(...b.ids); a.sum += b.sum; a.n += b.n; } });
|
|
363
514
|
off = 0; blocks.forEach(b => b.ids.forEach(id => { row[id] = b.sum / b.n + off; off += h(id); })); };
|
|
364
515
|
for (const [from, to, step] of [[1, cols.length, 1], [cols.length - 2, -1, -1], [1, cols.length, 1]])
|
|
365
|
-
for (let k = from; k !== to; k += step) { const want = {};
|
|
516
|
+
for (let k = from; k !== to; k += step) { if (cols[k].every(id => id in fixed)) continue; const want = {};
|
|
366
517
|
cols[k].forEach(id => { const nb = (step > 0 ? ins : outs)[id] || []; want[id] = nb.length ? median(nb.map(([x, ox, o]) => row[x] + ox - o)) : row[id]; });
|
|
367
518
|
fit(cols[k], want); }
|
|
368
|
-
const low = Math.min(...Object.
|
|
369
|
-
cols.forEach(c => c.forEach((id, i) => { row[id] = Math.round((row[id] - low) * 2) / 2; if (i) row[id] = Math.max(row[id], row[c[i - 1]] + h(c[i - 1])); }));
|
|
519
|
+
const low = Math.min(...Object.keys(row).filter(id => !(id in fixed)).map(id => row[id]));
|
|
520
|
+
cols.forEach(c => !c.every(id => id in fixed) && c.forEach((id, i) => { row[id] = Math.round((row[id] - low) * 2) / 2; if (i) row[id] = Math.max(row[id], row[c[i - 1]] + h(c[i - 1])); }));
|
|
370
521
|
return row;
|
|
371
522
|
}
|
|
372
523
|
|
|
@@ -492,9 +643,22 @@ const mapNodeW = n => {
|
|
|
492
643
|
/* Geometry: node boxes, ports, orthogonal routes, label slots. A view with lines the router found no route for is laid
|
|
493
644
|
out once more, with room where those lines need it (#40); a view whose lines all route keeps its geometry. */
|
|
494
645
|
function layoutGraph(dg, map) {
|
|
495
|
-
|
|
496
|
-
if (
|
|
497
|
-
|
|
646
|
+
let g = layoutPass(dg, map, null);
|
|
647
|
+
if (g.room) { const g2 = layoutPass(dg, map, g.room, true); g2.ms.probe = g.ms.probe; g = g2; }
|
|
648
|
+
// 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
|
|
649
|
+
// 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
|
|
650
|
+
// in on its target's near side (clinic's reminder, from the SMS gateway under Patient records, crossed the line into Patient
|
|
651
|
+
// records); of two other lines, the steeper comes into its target's top or bottom (clinic's "Sends texts" with Clinic bookings
|
|
652
|
+
// unfolded, up past Patient records into the SMS gateway's bottom). ponytail: a layout more, so only up to 30 cards (as
|
|
653
|
+
// bestFlow); a larger view keeps its crossings
|
|
654
|
+
const refs = [], pairs = map && !dg.quick && !dg.flipped && dg.nodes.length <= 30 ? routeAudit(g, '', null, refs).flatMap((s, k) => s.endsWith(' cross') ? [refs[k]] : []) : [];
|
|
655
|
+
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();
|
|
656
|
+
pairs.forEach(p => p.forEach(e => { if (e.back && !e.fixed && !e.fromSide) out.set(e.from + '>' + e.to, ['fromSide', far[e.fs]]); }));
|
|
657
|
+
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];
|
|
658
|
+
if (e && !p.some(o => out.has(o.from + '>' + o.to))) out.set(e.from + '>' + e.to, ['toSide', dy(e) > 0 ? 'bottom' : 'top']); });
|
|
659
|
+
if (!out.size) return g;
|
|
660
|
+
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);
|
|
661
|
+
return routeAudit(g2, '').length < routeAudit(g, '').length ? g2 : g;
|
|
498
662
|
}
|
|
499
663
|
function layoutPass(dg, map, room, last) { // room: { x: { col: px }, y: { row: px } }; last: lay out with it, find no more
|
|
500
664
|
const __ms = { t: performance.now() }, t0 = __ms.t;
|
|
@@ -524,9 +688,9 @@ function layoutPass(dg, map, room, last) { // room: { x: { col: px }, y: { row:
|
|
|
524
688
|
return c ? [...(c.nodeIds || []), ...(dg.containers || []).filter(k => k.parent === b).flatMap(k => inBox(k.id, seen))] : []; };
|
|
525
689
|
const near = (b, other) => { const cs = inBox(b).map(i => colOf[i]).filter(c => c != null); if (other == null || !cs.length) return null;
|
|
526
690
|
const lo = Math.min(...cs), hi = Math.max(...cs); return other < lo ? lo : other > hi ? hi : null; };
|
|
527
|
-
// an unfolded numbered view's lane
|
|
528
|
-
//
|
|
529
|
-
const inLane = id => (dg.containers || []).some(f => f.
|
|
691
|
+
// 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
|
|
692
|
+
// holds it, crosses two borders
|
|
693
|
+
const inLane = id => (dg.containers || []).some(f => f.parent && (f.parent === id || (f.nodeIds || []).includes(id)));
|
|
530
694
|
(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;
|
|
531
695
|
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;
|
|
532
696
|
gapAfter[c] = Math.max(gapAfter[c] || 0, w + 54 + box);
|
|
@@ -696,7 +860,11 @@ function layoutPass(dg, map, room, last) { // room: { x: { col: px }, y: { row:
|
|
|
696
860
|
// in one direction, cards in one column on the left of a bottom side came in the wrong order, and their lines crossed.
|
|
697
861
|
const mx = n.x + n.w / 2, my = n.y + n.h / 2, turn = it => { let a = Math.atan2(it.other.y + it.other.h / 2 - my, it.other.x + it.other.w / 2 - mx) - (side === 'T' ? -Math.PI / 2 : Math.PI / 2);
|
|
698
862
|
while (a <= -Math.PI) a += 2 * Math.PI; while (a > Math.PI) a -= 2 * Math.PI; return side === 'T' ? a : -a; };
|
|
699
|
-
|
|
863
|
+
// Far ends level with each other go by the angle they come in at, as on a top or bottom side: of two above the card, the
|
|
864
|
+
// nearer takes the higher port, since the farther one's line passes under it (b10/overview's Reports 7 took the port
|
|
865
|
+
// under Billing 1's, whose line ran below Reports, and had no way in but round the map)
|
|
866
|
+
const lean = it => Math.atan2(it.other.y + it.other.h / 2 - my, Math.abs(it.other.x + it.other.w / 2 - mx));
|
|
867
|
+
list.sort((a, b) => vertical ? (a.other.y - b.other.y) || (lean(a) - lean(b)) || ((side === 'R' ? -1 : 1) * (a.other.x - b.other.x)) : turn(a) - turn(b));
|
|
700
868
|
const sp = vertical ? null : sideSpan(n, side);
|
|
701
869
|
const len = vertical ? n.h : sp ? sp[1] - sp[0] : n.w, mid = sp ? (sp[0] + sp[1]) / 2 : n.x + n.w / 2;
|
|
702
870
|
const gap = Math.min(vertical ? 16 : 26, (len * 0.84) / Math.max(1, list.length)), cy = n.y + n.h / 2;
|
|
@@ -731,7 +899,7 @@ function layoutPass(dg, map, room, last) { // room: { x: { col: px }, y: { row:
|
|
|
731
899
|
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]); };
|
|
732
900
|
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]))
|
|
733
901
|
|| (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]));
|
|
734
|
-
function gridRoute(e, allowCross, win) {
|
|
902
|
+
function gridRoute(e, allowCross, win, spare) {
|
|
735
903
|
const st = [e.p1[0] + OUT[e.fs][0] * STUB, e.p1[1] + OUT[e.fs][1] * STUB];
|
|
736
904
|
const en = [e.p2[0] + OUT[e.ts][0] * STUB, e.p2[1] + OUT[e.ts][1] * STUB];
|
|
737
905
|
// ends that face each other closer than two stubs (a card 32 px beside a box) leave the grid no room: the plain
|
|
@@ -791,7 +959,19 @@ function layoutPass(dg, map, room, last) { // room: { x: { col: px }, y: { row:
|
|
|
791
959
|
}
|
|
792
960
|
}
|
|
793
961
|
for (let k = 0; k < N; k++) if (!inObs[k] && (!onAv[k] || allowCross)) { ok[k] = 1; crossAt[k] = onAv[k]; }
|
|
962
|
+
// routed after a port swap, it keeps off the end of its partner's stub, where the partner, routed next, starts
|
|
963
|
+
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; } }
|
|
964
|
+
// 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
|
|
965
|
+
// other end is there. A route with no crossing went any length, round its own card too (clinic's reminder went over the
|
|
966
|
+
// patient, down its far side and under it to reach the port below the patient's other line), and port repair saw nothing
|
|
967
|
+
for (const [n, side, far] of [[e.s, e.fs, en], [e.t, e.ts, st]]) if (!behind(n, side, far)) {
|
|
968
|
+
const lr = side === 'R' || side === 'L';
|
|
969
|
+
(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; });
|
|
970
|
+
}
|
|
794
971
|
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;
|
|
972
|
+
// 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
|
|
973
|
+
// near a border, and a box levelled half a row from a card (#86) puts its border right by that card's ports
|
|
974
|
+
const nearY = families.length && Y.map(y => families.filter(f => Math.abs(y - f.y) < 8 || Math.abs(y - f.y - f.h) < 8)), nearX = families.length && X.map(x => families.filter(f => Math.abs(x - f.x) < 8 || Math.abs(x - f.x - f.w) < 8));
|
|
795
975
|
const ix = v => X.indexOf(v), iy = v => Y.indexOf(v);
|
|
796
976
|
const s0 = ix(st[0]) * ny + iy(st[1]), t0 = ix(en[0]) * ny + iy(en[1]);
|
|
797
977
|
if (!ok[s0] || !ok[t0]) return null;
|
|
@@ -835,6 +1015,7 @@ function layoutPass(dg, map, room, last) { // room: { x: { col: px }, y: { row:
|
|
|
835
1015
|
const c2 = i2 * ny + j2; if (!ok[c2] || (nd < 2 ? blockH[(nd === 0 ? i : i2) * ny + j] : blockV[i * ny + (nd === 2 ? j : j2)])) continue;
|
|
836
1016
|
let w = Math.abs(X[i2] - xi) + Math.abs(Y[j2] - yj) + (nd === dir ? 0 : BEND) + (crossAt[c2] ? CROSS : 0);
|
|
837
1017
|
if (any && (nd < 2 ? alongH[(nd === 0 ? i : i2) * ny + j] : alongV[i * ny + (nd === 2 ? j : j2)])) w += 2 * CROSS + 3 * (Math.abs(X[i2] - xi) + Math.abs(Y[j2] - yj));
|
|
1018
|
+
if (nearY && (nd < 2 ? nearY[j].some(f => Math.max(xi, X[i2]) > f.x && Math.min(xi, X[i2]) < f.x + f.w) : nearX[i].some(f => Math.max(yj, Y[j2]) > f.y && Math.min(yj, Y[j2]) < f.y + f.h))) w += 10 * (Math.abs(X[i2] - xi) + Math.abs(Y[j2] - yj));
|
|
838
1019
|
if (c2 === t0 && nd !== dEnd) w += BEND;
|
|
839
1020
|
const k2 = c2 * 4 + nd; if (d + w < dist[k2]) { dist[k2] = d + w; prev[k2] = k; hpush(k2, d + w + (hx[i2] + hy[j2])); }
|
|
840
1021
|
}
|
|
@@ -868,12 +1049,20 @@ function layoutPass(dg, map, room, last) { // room: { x: { col: px }, y: { row:
|
|
|
868
1049
|
const tries = [full]; if (e.step != null && e.label && !dg.abs) tries.push(map ? '' : String(e.step));
|
|
869
1050
|
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] }));
|
|
870
1051
|
const order = [...segs].sort((p, q) => (q.h - p.h) * 40 + (q.l - p.l));
|
|
1052
|
+
// 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,
|
|
1053
|
+
// 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
|
|
1054
|
+
// gap 15 px wide, and the label sat on a box's border (clinic's "Queues reminders")
|
|
1055
|
+
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;
|
|
1056
|
+
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,
|
|
1057
|
+
...[...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]) })))];
|
|
1058
|
+
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])
|
|
1059
|
+
.map(u => (u - lo) / len).filter(f => f >= 0 && f <= 1).sort((p, q) => Math.abs(p - .5) - Math.abs(q - .5))]; };
|
|
871
1060
|
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
|
|
872
1061
|
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;
|
|
873
1062
|
for (const sg of order) {
|
|
874
1063
|
if (!relax && sg.h && sg.l < w + 10) continue;
|
|
875
1064
|
if (!relax && !sg.h && sg.l < hgt + 14) continue;
|
|
876
|
-
for (const f of (relax ? [.5, .32, .68, .2, .8, .1, .9, .42, .58, .26, .74, .05, .95] :
|
|
1065
|
+
for (const f of (relax ? [.5, .32, .68, .2, .8, .1, .9, .42, .58, .26, .74, .05, .95] : spots(sg, w, hgt))) {
|
|
877
1066
|
const lx = sg.a[0] + (sg.b[0] - sg.a[0]) * f, ly = sg.a[1] + (sg.b[1] - sg.a[1]) * f;
|
|
878
1067
|
const box = { x: lx - w / 2, y: ly - hgt / 2, w, h: hgt };
|
|
879
1068
|
const grow = { x: box.x - 3, y: box.y - 3, w: box.w + 6, h: box.h + 6 };
|
|
@@ -943,8 +1132,10 @@ function layoutPass(dg, map, room, last) { // room: { x: { col: px }, y: { row:
|
|
|
943
1132
|
return { room: need, ms: { probe: performance.now() - t0 } };
|
|
944
1133
|
}
|
|
945
1134
|
}
|
|
946
|
-
// port repair: two routes that cross next to a shared node side swap ports, keep it if issues drop
|
|
947
|
-
|
|
1135
|
+
// port repair: two routes that cross next to a shared node side swap ports, keep it if issues drop. On a map a detour
|
|
1136
|
+
// counts, as the layout check counts it: a swap that took u21's Web shop's two lines round the map to save one crossing
|
|
1137
|
+
// was kept (unfolding Catalog)
|
|
1138
|
+
const issueCount = only => routeAudit({ edges }, '', only).length + (map ? (only ? [...only] : edges).filter(e => detour(e.pts)).length : 0);
|
|
948
1139
|
const endAt = (e, n, side) => (e.s === n && e.fs === side) ? 'p1' : (e.t === n && e.ts === side) ? 'p2' : null;
|
|
949
1140
|
let issues = issueCount();
|
|
950
1141
|
// a swap re-routes only the two swapped edges (ripped up, then routed after every other route), not the whole
|
|
@@ -956,8 +1147,8 @@ function layoutPass(dg, map, room, last) { // room: { x: { col: px }, y: { row:
|
|
|
956
1147
|
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); };
|
|
957
1148
|
// a swapped edge tries a clean route near its ends, then one that may cross (near its ends, then anywhere); it
|
|
958
1149
|
// skips the clean whole-grid search, which mostly fails here and visits every cell when it does
|
|
959
|
-
const routeOne = e => { let how = 'grid'; e.lbl = null; e.pts = gridRoute(e, false, 320);
|
|
960
|
-
if (!e.pts) { how = 'cross'; e.pts = gridRoute(e, true, 320) || gridRoute(e, true, 0); } if (!e.pts) { how = 'fallback'; e.pts = fallback(e); }
|
|
1150
|
+
const routeOne = (e, spare) => { let how = 'grid'; e.lbl = null; e.pts = gridRoute(e, false, 320, spare);
|
|
1151
|
+
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); }
|
|
961
1152
|
e.how = how; routed.push(e); placeLabel(e); return true; };
|
|
962
1153
|
let budget = 2 * free.length, swapped = false;
|
|
963
1154
|
for (let round = 0; issues && dg.repair !== false && round < 8 && budget > 0; round++) {
|
|
@@ -966,7 +1157,8 @@ function layoutPass(dg, map, room, last) { // room: { x: { col: px }, y: { row:
|
|
|
966
1157
|
const xing = (a, b) => segsE(a).some(([p, q]) => segsE(b).some(([r, s]) => crosses(p, q, r, s)));
|
|
967
1158
|
const pairs = []; edges.forEach((a, i) => edges.slice(i + 1).forEach(b => { if (xing(a, b)) pairs.push([a, b]); }));
|
|
968
1159
|
// a crossing pair, or a pair where one shares a node side with the other's crossing partner
|
|
969
|
-
|
|
1160
|
+
// 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)
|
|
1161
|
+
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]); } };
|
|
970
1162
|
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); } }));
|
|
971
1163
|
outer3: for (const [a, b] of cand.filter(([x, y]) => !x.fixed && !y.fixed)) {
|
|
972
1164
|
for (const n of [a.s, a.t]) for (const side of ['L', 'R', 'T', 'B']) {
|
|
@@ -974,11 +1166,17 @@ function layoutPass(dg, map, room, last) { // room: { x: { col: px }, y: { row:
|
|
|
974
1166
|
if (budget <= 0) break outer3;
|
|
975
1167
|
const pa = a[ka], pb = b[kb]; a[ka] = pb; b[kb] = pa; restub(a); restub(b);
|
|
976
1168
|
const snap = [a, b].map(e => [e, e.pts, e.lbl, e.how]), snapR = [routed.slice(), reserved.slice(), placed.slice()];
|
|
977
|
-
const ab = new Set([a, b]), was =
|
|
1169
|
+
const ab = new Set([a, b]), was = issueCount(ab); // issues away from a and b cannot change
|
|
978
1170
|
budget -= 2; __ms.reroutes++;
|
|
979
1171
|
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
|
|
980
1172
|
ripUp(a); ripUp(b);
|
|
981
|
-
|
|
1173
|
+
// first the routes they have, each moved onto the other's port: two lines that cross where they leave one side no longer
|
|
1174
|
+
// cross (r15's web: its line to kit turned down past the line to api-client, and routed again they crossed again)
|
|
1175
|
+
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];
|
|
1176
|
+
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)); };
|
|
1177
|
+
const ma = moved(a, ka, a.pts), mb = moved(b, kb, b.pts); let now = Infinity;
|
|
1178
|
+
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); } }
|
|
1179
|
+
if (now >= issues) now = routeOne(a, b) && routeOne(b) ? issues - was + issueCount(ab) : Infinity;
|
|
982
1180
|
if (now < issues) { issues = now; fixed = swapped = true; if (!issues) break outer3; continue outer3; }
|
|
983
1181
|
a[ka] = pa; b[kb] = pb; restub(a); restub(b);
|
|
984
1182
|
snap.forEach(([e, p, l, hw]) => { e.pts = p; e.lbl = l; e.how = hw; });
|
|
@@ -988,6 +1186,21 @@ function layoutPass(dg, map, room, last) { // room: { x: { col: px }, y: { row:
|
|
|
988
1186
|
if (!fixed) break;
|
|
989
1187
|
}
|
|
990
1188
|
if (swapped) { ['grid', 'cross', 'fallback'].forEach(k => { delete stats[k]; }); free.forEach(e => { stats[e.how] = (stats[e.how] || 0) + 1; }); }
|
|
1189
|
+
// 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
|
|
1190
|
+
// 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
|
|
1191
|
+
// line from the lower of a card's two ports into a card level with it jogged just before it (clinic's "Books appointments")
|
|
1192
|
+
if (map && dg.repair !== false) free.forEach(e => {
|
|
1193
|
+
const P = e.pts, ax = e.fs === 'L' || e.fs === 'R' ? 1 : 0, d = P[P.length - 1][ax] - P[0][ax];
|
|
1194
|
+
if (P.length !== 4 || !d || Math.abs(d) > 16 || OUT[e.fs][1 - ax] !== -OUT[e.ts][1 - ax]) return;
|
|
1195
|
+
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]] : [];
|
|
1196
|
+
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]);
|
|
1197
|
+
if (!k || v < sp[0] || v > sp[1]) return;
|
|
1198
|
+
const was = e[k], before = issueCount(new Set([e])), snap = [e.pts, e.lbl, e.how], snapR = [routed.slice(), reserved.slice(), placed.slice()];
|
|
1199
|
+
ripUp(e); e[k] = ax ? [was[0], v] : [v, was[1]]; restub(e); routeOne(e);
|
|
1200
|
+
if (e.pts.length === 2 && issueCount(new Set([e])) <= before) return;
|
|
1201
|
+
e[k] = was; restub(e); [e.pts, e.lbl, e.how] = snap;
|
|
1202
|
+
routed.splice(0, routed.length, ...snapR[0]); reserved.splice(0, reserved.length, ...snapR[1]); placed.splice(0, placed.length, ...snapR[2]);
|
|
1203
|
+
});
|
|
991
1204
|
// the last resort for a line still with no route: around the cards, across or along other lines if it must (it was a
|
|
992
1205
|
// plain elbow through whatever lay in its way, and a card's mask made the line look like a connection to that card)
|
|
993
1206
|
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); });
|
|
@@ -1076,16 +1289,19 @@ function svgOf(geo, opts = {}) {
|
|
|
1076
1289
|
});
|
|
1077
1290
|
edges.forEach((e, i) => {
|
|
1078
1291
|
const st = lstyle(e), why = broke(e), d = roundPath(e.pts), mk = st.m + (e.weight > 1 ? '-w' + e.weight : '');
|
|
1292
|
+
const tip = 9 * (e.weight > 1 ? 1 + (e.weight - 1) * .35 : 1), fd = roundPath(trimLine(e.pts, e.both ? tip : 0, tip)); // an arrowhead's length (marker refX)
|
|
1079
1293
|
if (tour && tour.legs[i]) { const [a, b] = tour.legs[i];
|
|
1080
1294
|
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>`; }
|
|
1081
1295
|
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"/>
|
|
1082
1296
|
<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})"` : ''}/>
|
|
1083
|
-
<path d="${
|
|
1297
|
+
<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]];
|
|
1084
1298
|
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]; });
|
|
1085
1299
|
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) : ''}
|
|
1086
1300
|
<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>`;
|
|
1087
1301
|
});
|
|
1088
|
-
|
|
1302
|
+
// 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
|
|
1303
|
+
// line, rather than across the card's name
|
|
1304
|
+
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">';
|
|
1089
1305
|
nodes.forEach(n => {
|
|
1090
1306
|
const c = col(n.k), f = fill(n.k), removed = n.change === 'removed', planned = n.change === 'planned';
|
|
1091
1307
|
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';
|
|
@@ -1176,7 +1392,6 @@ return I && I.src ? `<rect x="${x - M * 3 / 28}" y="${y - M * 3 / 28}" width="${
|
|
|
1176
1392
|
${parts.join('\n')}
|
|
1177
1393
|
</g>`;
|
|
1178
1394
|
});
|
|
1179
|
-
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>`;
|
|
1180
1395
|
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
|
|
1181
1396
|
s += '</g><g class="keys">';
|
|
1182
1397
|
edges.forEach((e, i) => { const said = e.label ? `: ${e.label}${e.sublabel ? ` (${e.sublabel})` : ''}` : '';
|
|
@@ -1247,14 +1462,14 @@ class Camera {
|
|
|
1247
1462
|
stopAnimation() {
|
|
1248
1463
|
cancelAnimationFrame(this.anim); this.anim = 0;
|
|
1249
1464
|
// Resize/refit interrupts camera motion. Navigation awaits that motion, so cancellation
|
|
1250
|
-
// must settle its promise too; otherwise go() never releases its busy flag.
|
|
1251
|
-
const done = this.animationDone; this.animationDone = null; if (done) done();
|
|
1465
|
+
// must settle its promise too; otherwise go() never releases its busy flag. It settles false: cut short (a story's step glides again)
|
|
1466
|
+
const done = this.animationDone; this.animationDone = null; if (done) done(false);
|
|
1252
1467
|
}
|
|
1253
1468
|
set(s) { this.stopAnimation(); this.s = this.clamp({ ...this.s, ...s }); this.apply(); }
|
|
1254
|
-
animate(to, ms = 380) {
|
|
1469
|
+
animate(to, ms = 380) { // resolves true once there, false when cut short
|
|
1255
1470
|
this.stopAnimation();
|
|
1256
1471
|
const from = { ...this.s }, dest = this.clamp({ ...this.s, ...to });
|
|
1257
|
-
if (reducedMotion()) { this.set(dest); return Promise.resolve(); }
|
|
1472
|
+
if (this.o.still ? this.o.still() : reducedMotion()) { this.set(dest); return Promise.resolve(true); }
|
|
1258
1473
|
return new Promise(res => {
|
|
1259
1474
|
this.animationDone = res;
|
|
1260
1475
|
const t0 = performance.now(), ease = t => t < .5 ? 4 * t * t * t : 1 - Math.pow(-2 * t + 2, 3) / 2;
|
|
@@ -1263,7 +1478,7 @@ class Camera {
|
|
|
1263
1478
|
const t = Math.min(1, (now - t0) / ms), e = ease(t);
|
|
1264
1479
|
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) };
|
|
1265
1480
|
this.apply();
|
|
1266
|
-
if (t < 1) this.anim = requestAnimationFrame(step); else { this.anim = 0; this.animationDone = null; res(); }
|
|
1481
|
+
if (t < 1) this.anim = requestAnimationFrame(step); else { this.anim = 0; this.animationDone = null; res(true); }
|
|
1267
1482
|
};
|
|
1268
1483
|
this.anim = requestAnimationFrame(step);
|
|
1269
1484
|
});
|
|
@@ -1306,7 +1521,7 @@ class Camera {
|
|
|
1306
1521
|
this.ptrs.set(e.pointerId, [e.clientX, e.clientY]);
|
|
1307
1522
|
this.start = { x: e.clientX, y: e.clientY, s: { ...this.s }, dist: this.pinchDist() };
|
|
1308
1523
|
this.moved = 0; this.dragged = false;
|
|
1309
|
-
|
|
1524
|
+
this.stopAnimation(); // settles the glide's promise: a press during a navigation or a story's step lets it go on
|
|
1310
1525
|
}
|
|
1311
1526
|
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; }
|
|
1312
1527
|
onMove(e) {
|
|
@@ -1337,6 +1552,14 @@ class Camera {
|
|
|
1337
1552
|
// an issue's message, and what it is about (the cards, lines and boxes it names) when the caller keeps a list of that: check()
|
|
1338
1553
|
// turns those into Refs; the page's check reads only the messages
|
|
1339
1554
|
const teller = (issues, refs) => (message, ...about) => { issues.push(message); if (refs) refs.push(about); };
|
|
1555
|
+
// a detour: a route 2.5 times longer than the straight distance between its ends, or than 100 px; [its length, that distance]
|
|
1556
|
+
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);
|
|
1557
|
+
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; };
|
|
1558
|
+
// behind a card a line meets on its side `side` (R, L, T or B): past the opposite side
|
|
1559
|
+
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;
|
|
1560
|
+
// [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
|
|
1561
|
+
// long line passed the detour rule however it went round (clinic's reminder: over the patient, down its far side, under it)
|
|
1562
|
+
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)));
|
|
1340
1563
|
function routeAudit(geo, where, only, refs) { // only: a set of edges; counts just the issues that involve one of them
|
|
1341
1564
|
const sk = e => only && !only.has(e);
|
|
1342
1565
|
const issues = [], say = teller(issues, refs);
|
|
@@ -1372,10 +1595,8 @@ function routeAudit(geo, where, only, refs) { // only: a set of edges; counts ju
|
|
|
1372
1595
|
// clearance: a route keeps 12+ px from every node it does not connect
|
|
1373
1596
|
geo.nodes.forEach(n => { if (n.id === e.from || n.id === e.to) return;
|
|
1374
1597
|
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); });
|
|
1375
|
-
|
|
1376
|
-
const
|
|
1377
|
-
const d = Math.hypot(e.pts[e.pts.length - 1][0] - e.pts[0][0], e.pts[e.pts.length - 1][1] - e.pts[0][1]);
|
|
1378
|
-
if (len > 2.5 * Math.max(d, 100)) say(`${where}: edge ${name(e)} takes a detour (${Math.round(len)}px for ${Math.round(d)}px)`, e);
|
|
1598
|
+
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);
|
|
1599
|
+
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]);
|
|
1379
1600
|
// reading order: every wire runs left to right, except one that closes a loop or answers its pair (keepPairs)
|
|
1380
1601
|
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);
|
|
1381
1602
|
});
|
|
@@ -1662,7 +1883,11 @@ function mapContext(model, opts = {}) {
|
|
|
1662
1883
|
/* plain views: longest-path columns from the roots, state at the bottom of its column */
|
|
1663
1884
|
/* views with containers: each container is a block in columns of its own, its cards in call order inside it; blocks (and
|
|
1664
1885
|
cards in no container) run left to right by the lines between them, and those with no line between them stack in the
|
|
1665
|
-
same columns. A nested container is a block inside its parent's.
|
|
1886
|
+
same columns. A nested container is a block inside its parent's.
|
|
1887
|
+
A box is laid out three times (#86): by the lines inside it, as before; then each of its cards level with the cards its
|
|
1888
|
+
lines come from and go to outside it too, where the pass before put them; then ordered by those lines as well. Its far
|
|
1889
|
+
ends are items at their rows, in a column before its first (a line from the left) or after its last. A box's cards were
|
|
1890
|
+
placed by its own lines alone, so a box fed from outside, and the lines that pass it, ran into the cards beside it. */
|
|
1666
1891
|
function blockPos(vid) {
|
|
1667
1892
|
const v = V[vid], ids = (v.nodeIds || []).filter(i => N[i]), lp = loopsOf(v), boxes = (v.containers || []).filter(c => c && c.id);
|
|
1668
1893
|
const fwd = lp.edges.filter((e, k) => !lp.back.has(k) && e.from !== e.to), byBox = Object.fromEntries(boxes.map(b => [b.id, b]));
|
|
@@ -1673,12 +1898,13 @@ function mapContext(model, opts = {}) {
|
|
|
1673
1898
|
const first = x => { const k = lp.edges.findIndex(e => holds(x, e.from) || holds(x, e.to)); return k < 0 ? Infinity : k; };
|
|
1674
1899
|
const sink = x => ids.includes(x) && isState(x) && !fwd.some(e => e.from === x); // state that leads nowhere goes last
|
|
1675
1900
|
const lined = x => lp.edges.some(e => e.from !== e.to && (holds(x, e.from) || holds(x, e.to)));
|
|
1676
|
-
|
|
1901
|
+
let was = {}, now = {}; // each box as the pass before laid it out, and as this one does
|
|
1902
|
+
const block = (g, P, order) => { // a group's cards at [col, row] from its top left, and its size in slots; P: where the pass before put each card
|
|
1677
1903
|
const all = [...ids.filter(i => home[i] === g), ...boxes.filter(b => up(b.id) === g).map(b => b.id)];
|
|
1678
1904
|
// cards with no line go in rows of their own under the group's columns, as under a plain view (#46): at the bottom of
|
|
1679
1905
|
// their box, or under the view; they stacked in the first column with the sources
|
|
1680
1906
|
const loose = all.some(lined) ? all.filter(x => ids.includes(x) && !lined(x)) : [], items = all.filter(x => !loose.includes(x)), size = {}, out = {};
|
|
1681
|
-
items.forEach(x => { size[x] = ids.includes(x) ? { w: 1, h: 1, pos: { [x]: [0, 0] } } : block(x); out[x] = []; });
|
|
1907
|
+
items.forEach(x => { size[x] = ids.includes(x) ? { w: 1, h: 1, pos: { [x]: [0, 0] } } : block(x, P, order); out[x] = []; });
|
|
1682
1908
|
fwd.forEach(e => { const a = itemIn(e.from, g), b = itemIn(e.to, g); if (a !== undefined && b !== undefined && a !== b && !out[a].includes(b)) out[a].push(b); });
|
|
1683
1909
|
const st = {}, dag = [], into = new Set(Object.values(out).flat()); // lines that close a loop do not order the blocks
|
|
1684
1910
|
const dfs = u => { st[u] = 1; out[u].forEach(w => { if (st[w] === 1) return; dag.push([u, w]); if (!st[w]) dfs(w); }); st[u] = 2; };
|
|
@@ -1688,10 +1914,22 @@ function mapContext(model, opts = {}) {
|
|
|
1688
1914
|
const cols = [], pos = {}; let c0 = 0, h = 0;
|
|
1689
1915
|
items.forEach(x => (cols[rank[x]] ||= []).push(x));
|
|
1690
1916
|
const main = cols.map(l => (l || []).filter(x => !sink(x)).sort((a, b) => first(a) - first(b)));
|
|
1691
|
-
untangle(main, dag); // in call order, unless another order crosses less (#53)
|
|
1692
1917
|
const inside = (x, id) => ids.includes(x) ? 0 : (size[x].pos[id] || [0, null])[1]; // the row of a box's card that a line meets
|
|
1693
|
-
const
|
|
1694
|
-
|
|
1918
|
+
const dagged = new Set(dag.map(([a, b]) => a + '\u0000' + b)), lines = fwd.map(e => { const a = itemIn(e.from, g), b = itemIn(e.to, g);
|
|
1919
|
+
return a !== undefined && b !== undefined && a !== b ? { e, link: [a, b, inside(a, e.from), inside(b, e.to)] } : null; }).filter(Boolean);
|
|
1920
|
+
// the box's lines to cards outside it (#86): each far end at its row from the box's top, where the pass before put both.
|
|
1921
|
+
// ponytail: three passes, so a far end that moves in the last one is followed where it was, and a far end in the card's
|
|
1922
|
+
// own column, or a line to a box, places nothing; passes until nothing moves, if a map needs them
|
|
1923
|
+
const far = [], fixed = {}, top = P && g != null && was[g] ? Object.keys(was[g].pos).map(c => P[c] && P[c][1] - was[g].pos[c][1]).find(r => r != null) : null;
|
|
1924
|
+
if (top != null) fwd.forEach(e => { const fin = holds(g, e.from); if (fin === holds(g, e.to)) return;
|
|
1925
|
+
const [mine, other] = fin ? [e.from, e.to] : [e.to, e.from], x = itemIn(mine, g), p = P[mine], q = P[other]; if (x === undefined || !p || !q || p[0] === q[0]) return;
|
|
1926
|
+
const id = '\u0002' + far.length, left = q[0] < p[0]; fixed[id] = q[1] - top;
|
|
1927
|
+
far.push({ e, id, left, link: left ? [id, x, 0, inside(x, mine)] : [x, id, inside(x, mine), 0] }); });
|
|
1928
|
+
const L = far.filter(f => f.left).map(f => f.id), R = far.filter(f => !f.left).map(f => f.id), M = [...(L.length ? [L] : []), ...main, ...(R.length ? [R] : [])];
|
|
1929
|
+
const hh = x => size[x] ? size[x].h : 1;
|
|
1930
|
+
untangle(M, [...lines.filter(l => dagged.has(l.link[0] + '\u0000' + l.link[1])), ...(order ? far : [])].map(l => l.link), hh, fixed); // in call order, unless another order crosses less (#53)
|
|
1931
|
+
main.splice(0, main.length, ...M.slice(L.length ? 1 : 0, M.length - (R.length ? 1 : 0)));
|
|
1932
|
+
const row = rowsFor(M, [...lines, ...far].filter(l => level(l.e)).map(l => l.link), hh, fixed); // each block level with the cards its lines come from and go to
|
|
1695
1933
|
main.forEach((l, k) => { cols[k] = [...l, ...(cols[k] || []).filter(sink).sort((a, b) => first(a) - first(b))]; });
|
|
1696
1934
|
cols.forEach(list => { let r0 = 0, w = 0;
|
|
1697
1935
|
list.forEach(x => { const sz = size[x]; if (row[x] != null) r0 = Math.max(r0, row[x]);
|
|
@@ -1699,9 +1937,11 @@ function mapContext(model, opts = {}) {
|
|
|
1699
1937
|
c0 += w; h = Math.max(h, r0); });
|
|
1700
1938
|
const per = Math.max(2, c0); loose.forEach((x, k) => { pos[x] = [k % per, h + Math.floor(k / per)]; });
|
|
1701
1939
|
if (loose.length) { c0 = Math.max(c0, Math.min(per, loose.length)); h += Math.ceil(loose.length / per); }
|
|
1702
|
-
return { w: c0, h, pos };
|
|
1940
|
+
return (now[g] = { w: c0, h, pos });
|
|
1703
1941
|
};
|
|
1704
|
-
|
|
1942
|
+
let P = block(null).pos;
|
|
1943
|
+
for (const order of [false, true]) { was = now; now = {}; P = block(null, P, order).pos; } // levelled by the lines from outside, then ordered by them too
|
|
1944
|
+
return P;
|
|
1705
1945
|
}
|
|
1706
1946
|
const autoPos = vid => ((V[vid] || {}).containers || []).length ? blockPos(vid) : bestFlow(vid);
|
|
1707
1947
|
const level = e => !/^(top|bottom)$/.test(e.fromSide) && !/^(top|bottom)$/.test(e.toSide); // a line into a card's top or bottom asks for no row
|
|
@@ -1860,7 +2100,7 @@ function mapContext(model, opts = {}) {
|
|
|
1860
2100
|
const t0 = performance.now(); let wall = 4000; // per-view time box, extended while problems remain
|
|
1861
2101
|
const v = V[vid], ids = (v.nodeIds || []).filter(i => N[i]);
|
|
1862
2102
|
let pos = JSON.parse(JSON.stringify(autoPos(vid)));
|
|
1863
|
-
const boxed = (v.containers || []).length, homeOf = i => ((v.containers || []).filter(c => (c.nodeIds || []).includes(i)).pop() || {}).id; // blocks stay whole
|
|
2103
|
+
const start = JSON.stringify(pos), boxed = (v.containers || []).length, homeOf = i => ((v.containers || []).filter(c => (c.nodeIds || []).includes(i)).pop() || {}).id; // blocks stay whole
|
|
1864
2104
|
const lined = new Set(viewEdges(v).filter(e => e.from !== e.to).flatMap(e => [e.from, e.to])), loose = i => lined.size && !lined.has(i);
|
|
1865
2105
|
let best = scoreGeo(buildGeo(vid, pos, false, true)), evals = 1;
|
|
1866
2106
|
const hub = subjectOf(v), hubCol = hub && pos[hub] ? pos[hub][0] : null;
|
|
@@ -1896,6 +2136,9 @@ function mapContext(model, opts = {}) {
|
|
|
1896
2136
|
}
|
|
1897
2137
|
}
|
|
1898
2138
|
}
|
|
2139
|
+
// it scores without port repair: render()'s layout stays unless the one it found routes better as the page draws it (#86: for
|
|
2140
|
+
// r15's monorepo it baked in a crossing that render()'s layout has not)
|
|
2141
|
+
if (JSON.stringify(pos) !== start && scoreGeo(buildGeo(vid, JSON.parse(start))) <= scoreGeo(buildGeo(vid, pos))) pos = JSON.parse(start);
|
|
1899
2142
|
return { pos, score: best, evals };
|
|
1900
2143
|
}
|
|
1901
2144
|
// what a view's picture is drawn from: an update keeps the layout of every view whose key did not change
|
|
@@ -2041,7 +2284,7 @@ export function layout(model, viewId, options = {}) {
|
|
|
2041
2284
|
}
|
|
2042
2285
|
|
|
2043
2286
|
/** The layout check's geometry for one view, without a browser: overlapping cards, crossing or shared lines, detours,
|
|
2044
|
-
* lines against reading order or through a card, labels on lines or off their own, boxes and lanes. The page's check
|
|
2287
|
+
* 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
|
|
2045
2288
|
* (`umlimator.py check`) adds what it measures in the DOM: text that overflows its card or touches its mark, titles
|
|
2046
2289
|
* that overflow, edge labels on cards or on each other. Each issue names the cards and lines it is about. */
|
|
2047
2290
|
export function check(model, viewId, options = {}) {
|
|
@@ -2066,6 +2309,7 @@ export function validate(model) {
|
|
|
2066
2309
|
if (ids.has(n.id)) err(at + '.id', `card id "${n.id}" is used twice`); ids.add(n.id);
|
|
2067
2310
|
const k = String(n.kind ?? '').toLowerCase();
|
|
2068
2311
|
if (n.kind != null && !(k in KIND || k in MAPK || k in ALIAS)) warn(at + '.kind', `unknown kind "${n.kind}": drawn as a script card`);
|
|
2312
|
+
if (n.owner != null && typeof n.owner !== 'string') warn(at + '.owner', 'an owner is a name: ignored');
|
|
2069
2313
|
};
|
|
2070
2314
|
arr(m.nodes).forEach((n, i) => card(n, `nodes[${i}]`));
|
|
2071
2315
|
views.forEach((v, i) => arr(v.nodes).forEach((n, j) => card(n, `views[${i}].nodes[${j}]`)));
|
|
@@ -2079,6 +2323,7 @@ export function validate(model) {
|
|
|
2079
2323
|
if (e.kind != null && !WIRE_KINDS.includes(e.kind)) warn(at + '.kind', `unknown line kind "${e.kind}": drawn as calls`);
|
|
2080
2324
|
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`);
|
|
2081
2325
|
['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`); });
|
|
2326
|
+
['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)
|
|
2082
2327
|
};
|
|
2083
2328
|
// a model line may end on any view's box (#28); a view's own line only on that view's boxes
|
|
2084
2329
|
const anyBox = new Set(views.flatMap(v => arr(v.containers).map(c => c && c.id)));
|
|
@@ -2106,13 +2351,16 @@ export function validate(model) {
|
|
|
2106
2351
|
// rules (#70): an id, and from, to or noCycles; a selector that names nothing picks nothing, and a misspelt kind picks script cards
|
|
2107
2352
|
if (m.rules != null && !Array.isArray(m.rules)) err('rules', 'rules is a list');
|
|
2108
2353
|
const ruleIds = new Set(), allBoxes = new Set(anyBox), each = (x, at, f) => (Array.isArray(x) ? x : [x]).forEach((y, j) => { if (y != null) f(y, Array.isArray(x) ? `${at}[${j}]` : at); });
|
|
2109
|
-
const
|
|
2354
|
+
const owners = new Set([...arr(m.nodes), ...views.flatMap(v => [...arr(v.nodes), ...Object.values(v.nodeOverrides || {})])].map(n => n && n.owner).filter(o => typeof o === 'string'));
|
|
2355
|
+
const pick = (s, at, lens) => { // lens: a lens's pick (#94), which also takes line kinds at its top
|
|
2110
2356
|
if (s == null) return;
|
|
2111
|
-
if (typeof s !== 'object' || Array.isArray(s)) return err(at,
|
|
2112
|
-
Object.keys(s).forEach(k => { if (!['id', 'kind', 'container', 'not'].includes(k)) warn(`${at}.${k}`, `unknown key "${k}": ignored, so the selector picks more cards`); });
|
|
2357
|
+
if (typeof s !== 'object' || Array.isArray(s)) return err(at, `a selector is an object: id, kind, container, owner${lens ? ', line' : ''} or not`);
|
|
2358
|
+
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`); });
|
|
2113
2359
|
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`); });
|
|
2114
2360
|
each(s.kind, at + '.kind', (x, p) => { const k = String(x).toLowerCase(); if (!(k in KIND || k in MAPK || k in ALIAS)) warn(p, `unknown kind "${x}": it picks script cards`); });
|
|
2115
2361
|
each(s.container, at + '.container', (x, p) => { if (!allBoxes.has(x)) warn(p, `"${x}" is not a box: it picks nothing`); });
|
|
2362
|
+
each(s.owner, at + '.owner', (x, p) => { if (!owners.has(x)) warn(p, `no card's owner is "${x}": it picks nothing`); });
|
|
2363
|
+
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`); });
|
|
2116
2364
|
pick(s.not, at + '.not');
|
|
2117
2365
|
};
|
|
2118
2366
|
arr(m.rules).forEach((r, i) => {
|
|
@@ -2123,6 +2371,16 @@ export function validate(model) {
|
|
|
2123
2371
|
pick(r.from, at + '.from'); pick(r.to, at + '.to');
|
|
2124
2372
|
arr(r.views).forEach((v, j) => { if (!vids.has(v)) err(`${at}.views[${j}]`, `"${v}" is not a view`); });
|
|
2125
2373
|
});
|
|
2374
|
+
// lenses (#94): an id each, and a pick as a rule's selector, which also takes line kinds
|
|
2375
|
+
if (m.lenses != null && !Array.isArray(m.lenses)) err('lenses', 'lenses is a list');
|
|
2376
|
+
const lensIds = new Set();
|
|
2377
|
+
arr(m.lenses).forEach((l, i) => {
|
|
2378
|
+
const at = `lenses[${i}]`;
|
|
2379
|
+
if (!l || typeof l !== 'object') return err(at, 'a lens is an object with an id');
|
|
2380
|
+
if (typeof l.id !== 'string' || !l.id) err(at + '.id', 'a lens needs an id'); else if (lensIds.has(l.id)) err(at + '.id', `lens id "${l.id}" is used twice`); else lensIds.add(l.id);
|
|
2381
|
+
pick(l.pick, at + '.pick', true);
|
|
2382
|
+
if (l.color != null && l.color !== 'owner') warn(at + '.color', `color ${JSON.stringify(l.color)} is not "owner": ignored`);
|
|
2383
|
+
});
|
|
2126
2384
|
// stories (#69): each step's view, and the cards, lines and tour it names there
|
|
2127
2385
|
if (m.stories != null && !Array.isArray(m.stories)) err('stories', 'stories is a list of stories');
|
|
2128
2386
|
const sids = new Set(), lines = new Set([...lineIds, ...views.flatMap(v => arr(v.edges).filter(e => e && e.id != null).map(e => e.id))]);
|
|
@@ -2162,21 +2420,28 @@ const boxesNamed = (s, isBox) => !s || typeof s !== 'object' ? []
|
|
|
2162
2420
|
const picks = (s, end) => !s || ((s.id == null || [s.id].flat().includes(end.id))
|
|
2163
2421
|
&& (s.kind == null || [s.kind].flat().some(k => end.kind != null && kindOf(k, true) === end.kind))
|
|
2164
2422
|
&& (s.container == null || [s.container].flat().some(c => end.boxes.includes(c)))
|
|
2423
|
+
&& (s.owner == null || [s.owner].flat().includes(end.owner))
|
|
2165
2424
|
&& (s.not == null || !picks(s.not, end)));
|
|
2166
2425
|
const andList = xs => xs.length > 1 ? xs.slice(0, -1).join(', ') + ' and ' + xs[xs.length - 1] : xs.join(''), arrOf = x => (Array.isArray(x) ? x : []);
|
|
2426
|
+
// a view's cards and boxes as a selector sees them (rules, lenses): a card's kind as drawn, the boxes it is in (outermost
|
|
2427
|
+
// first) and its owner; a box is in itself and its parents, and has no kind or owner
|
|
2428
|
+
function endsIn(ctx, v) {
|
|
2429
|
+
const cs = arrOf(v.containers).filter(c => c && c.id), box = {}; cs.forEach(c => { box[c.id] ||= c; });
|
|
2430
|
+
const home = {}; cs.forEach(c => arrOf(c.nodeIds).forEach(id => { home[id] ??= c.id; }));
|
|
2431
|
+
const chain = b => { const c = []; for (const seen = new Set(); b != null && box[b] && !seen.has(b); b = box[b].parent) { seen.add(b); c.unshift(b); } return c; }; // outermost first
|
|
2432
|
+
const end = id => box[id] ? { id, kind: null, boxes: chain(id), label: box[id].label || id }
|
|
2433
|
+
: (n => ({ id, kind: kindOf(n.kind, true), boxes: chain(home[id]), owner: typeof n.owner === 'string' ? n.owner : undefined, label: n.label || id }))(ctx.nodeIn(id, v.id));
|
|
2434
|
+
return { box, end };
|
|
2435
|
+
}
|
|
2167
2436
|
/** Every rule the model breaks, in each view that draws it broken: a line a rule forbids, or a cycle of boxes. */
|
|
2168
2437
|
export function rules(model) {
|
|
2169
2438
|
const list = arrOf(model && model.rules).filter(r => r && typeof r === 'object' && (r.from || r.to || r.noCycles));
|
|
2170
2439
|
if (!list.length) return [];
|
|
2171
|
-
const { views, N,
|
|
2440
|
+
const ctx = mapContext(model), { views, N, viewEdges } = ctx, out = [];
|
|
2172
2441
|
const anyBox = new Set(views.flatMap(v => arrOf(v.containers).map(c => c && c.id).filter(Boolean)));
|
|
2173
2442
|
const named = new Map(list.map(r => [r, [r.from, r.to].flatMap(s => boxesNamed(s, id => anyBox.has(id) && !N[id]))]));
|
|
2174
2443
|
views.forEach(v => {
|
|
2175
|
-
const
|
|
2176
|
-
const order = Object.values(box).map(c => c.id), home = {}; cs.forEach(c => arrOf(c.nodeIds).forEach(id => { home[id] ??= c.id; }));
|
|
2177
|
-
const chain = b => { const c = []; for (const seen = new Set(); b != null && box[b] && !seen.has(b); b = box[b].parent) { seen.add(b); c.unshift(b); } return c; }; // outermost first
|
|
2178
|
-
const end = id => box[id] ? { id, kind: null, boxes: chain(id), label: box[id].label || id }
|
|
2179
|
-
: { id, kind: kindOf(nodeIn(id, v.id).kind, true), boxes: chain(home[id]), label: nodeIn(id, v.id).label || id };
|
|
2444
|
+
const { box, end } = endsIn(ctx, v), order = Object.values(box).map(c => c.id);
|
|
2180
2445
|
// a target names the drawn line, which may stand for several edges: grouped as prepEdges groups them (parallel edges, and
|
|
2181
2446
|
// A→B with B→A, unless keepPairs), in its order, so select() finds it
|
|
2182
2447
|
// ponytail: a copy of prepEdges' grouping; share one helper if that grouping changes
|
|
@@ -2343,8 +2608,8 @@ export function drift(model, observed, options = {}) {
|
|
|
2343
2608
|
return { model: out, counts, views: arr(out.views).filter(v => v && linesOf(out, v).some(e => e.drift)).map(v => v.id) };
|
|
2344
2609
|
}
|
|
2345
2610
|
|
|
2346
|
-
|
|
2347
|
-
const reducedMotion = () =>
|
|
2611
|
+
// 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
|
|
2612
|
+
const reducedMotion = () => typeof matchMedia === 'function' && matchMedia('(prefers-reduced-motion: reduce)').matches;
|
|
2348
2613
|
let SHEET = null;
|
|
2349
2614
|
const sheet = () => { if (!SHEET) { SHEET = new CSSStyleSheet(); SHEET.replaceSync(STYLES); } return SHEET; };
|
|
2350
2615
|
const INNER = Symbol('umlimator.inner'); // what mountPage needs from render() beyond the public handle
|
|
@@ -2361,7 +2626,8 @@ async function pngOf(file) {
|
|
|
2361
2626
|
|
|
2362
2627
|
export function render(container, model, options = {}) {
|
|
2363
2628
|
const P = options._page || {}; // the standalone page's hooks: status line, overlay insets, still and reader drawing
|
|
2364
|
-
|
|
2629
|
+
const instant = !!P.instant, still = () => instant || reducedMotion(); // this map's motion: an instant page stills only itself
|
|
2630
|
+
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
|
|
2365
2631
|
const OPS = ['add', 'rename', 'move', 'delete', 'reorder'], ed = options.editable;
|
|
2366
2632
|
const E = ed === true ? Object.fromEntries(OPS.map(o => [o, true])) : ed && typeof ed === 'object' ? Object.fromEntries(OPS.map(o => [o, !!ed[o]])) : null;
|
|
2367
2633
|
const can = op => !!(E && E[op]);
|
|
@@ -2372,65 +2638,107 @@ export function render(container, model, options = {}) {
|
|
|
2372
2638
|
const own = typeof options.measure === 'function' ? options.measure : null;
|
|
2373
2639
|
let measure = options.measure === false ? null : own || hostMeasure(host), current = model;
|
|
2374
2640
|
const expanded = {}; // per view: the cards unfolded in place (#71), in the order they unfolded; in this handle only, never in the model
|
|
2641
|
+
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
|
|
2375
2642
|
let ctx = mapContext(model, { measure, kinds: !!P.kinds, expand: expanded }); // kinds: the standalone page guesses line kinds from label words, for its legend
|
|
2376
2643
|
const root = host.attachShadow({ mode: 'open', delegatesFocus: true }); // host.focus() lands in the map (on the selected card)
|
|
2377
2644
|
root.adoptedStyleSheets = [sheet()];
|
|
2378
|
-
|
|
2645
|
+
// the standalone page's pieces a host asks for (#99), in a frame around the canvas: the views list left of the map (after
|
|
2646
|
+
// it in the tab order, so focus given to the element still lands on the map), the details panel under it, the legend and
|
|
2647
|
+
// the hint line over it. With none of them the map is drawn as it always was.
|
|
2648
|
+
const framed = ['nav', 'details', 'legend', 'hints'].some(k => options[k]);
|
|
2649
|
+
root.innerHTML = `<div class="um-live" role="status" aria-live="polite"></div>${framed ? '<div class="um-frame"><div class="um-main"><div class="um-stage">' : ''}<div class="stage" tabindex="-1">
|
|
2379
2650
|
<div class="mctl">${options.minimap ? '<div class="minimap" title="Overview — click or drag to move"></div>' : ''}${options.controls === false ? '' : `
|
|
2380
2651
|
<div class="ctl">
|
|
2381
2652
|
<button data-z="up" title="Up one level (Esc)" aria-label="Up one level">↑</button><span class="sep"></span>
|
|
2382
2653
|
<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>
|
|
2383
2654
|
<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>
|
|
2384
|
-
</div>`}</div></div
|
|
2655
|
+
</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>'
|
|
2656
|
+
+ (options.details ? '<aside class="panel" aria-label="Node details" hidden></aside>' : '') + '</div>'
|
|
2657
|
+
+ (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>'}`;
|
|
2385
2658
|
const stage = root.querySelector('.stage'), q = s => root.querySelector(s);
|
|
2659
|
+
const tell = q('.hint') && hinter(q('.hint'), hintHTML('', ctrlWheel)); // the hint line says what just happened, for a while
|
|
2386
2660
|
const subs = {};
|
|
2387
|
-
|
|
2661
|
+
// 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)
|
|
2662
|
+
const fail = err => { if (typeof reportError === 'function') reportError(err); else console.error(err); };
|
|
2663
|
+
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(); };
|
|
2664
|
+
/* the map's state for hosts and agents (0.14.0): handle.state is a fresh, frozen, JSON-serializable snapshot, and the state
|
|
2665
|
+
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).
|
|
2666
|
+
The camera is the exception: a pan, zoom, glide or drag is one state event, once it comes to rest; until then the
|
|
2667
|
+
event's camera is where it last rested (handle.state reads it live) */
|
|
2668
|
+
const KEYS = ['view', 'navigating', 'selection', 'expanded', 'lens', 'overlays', 'story', 'tour', 'replay', 'camera', 'flow', 'motion', 'gesture', 'destroyed'];
|
|
2669
|
+
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
|
|
2670
|
+
const listensState = () => !!((options.on || {}).state || (subs.state || []).length);
|
|
2671
|
+
function touch() { if (!stateT && !dead && listensState()) stateT = setTimeout(flushState, 16); }
|
|
2672
|
+
function flushState(last = false) { clearTimeout(stateT); stateT = 0; if (!listensState() || (dead && !last)) return;
|
|
2673
|
+
const s = snapshot(true), changed = KEYS.filter(k => JSON.stringify(s[k]) !== JSON.stringify(lastState ? lastState[k] : undefined)); lastState = s;
|
|
2674
|
+
if (changed.length) emit('state', { state: s, changed }, last); }
|
|
2675
|
+
// 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
|
|
2676
|
+
function camMoved() { clearTimeout(restT); restT = !dead && listensState() ? setTimeout(rested, 150) : 0; }
|
|
2677
|
+
function rested() { restT = 0; if (dead) return; if (cam && (cam.anim || cam.ptrs.size) || nav) return camMoved(); camRest = camNow(); touch(); }
|
|
2388
2678
|
let cam = null, geo = null, svg = null, cur = null, sel = null, more = [], busy = false, paused = false, dead = false;
|
|
2389
2679
|
const timers = new Set(), later = (f, ms) => { const t = setTimeout(() => { timers.delete(t); if (!dead) f(); }, ms); timers.add(t); return t; };
|
|
2390
|
-
const
|
|
2391
|
-
const
|
|
2680
|
+
const sleeping = new Set(); // a sleep resolves on its own, or when the map is destroyed: nothing that awaits one is left pending
|
|
2681
|
+
const sleep = ms => new Promise(r => { if (dead) return r(); const wake = () => { sleeping.delete(wake); r(); }; sleeping.add(wake); later(wake, still() ? 0 : ms); });
|
|
2682
|
+
const status = t => P.status ? P.status(t) : tell && tell(t);
|
|
2392
2683
|
const wide = P.wide || (() => (cam ? cam.size()[0] : stage.clientWidth) > 820);
|
|
2393
2684
|
|
|
2394
2685
|
const upOf = () => (P.upTarget ? P.upTarget() : (ctx.V[cur] || {}).parent) || null;
|
|
2395
2686
|
const nodeRef = id => ({ type: 'node', id, view: cur });
|
|
2396
|
-
const
|
|
2687
|
+
const copyRef = r => r && { ...r, ...(Array.isArray(r.ids) ? { ids: [...r.ids] } : {}) }; // a host gets copies: the map's Refs stay its own
|
|
2688
|
+
const picked = () => sel ? [...more.map(nodeRef), copyRef(sel)] : []; // every selected thing, the latest last (more: other selected cards)
|
|
2397
2689
|
const say = t => { const l = root.querySelector('.um-live'); if (l) l.textContent = t; };
|
|
2398
2690
|
const nameOf = id => ctx.nodeIn(id, cur).label || ((geo.families || []).find(f => f.id === id) || {}).label || id;
|
|
2399
|
-
|
|
2691
|
+
let said = '[]'; // the selection last reported: a select event means it changed, or a click asked again (again)
|
|
2692
|
+
const emitSel = (again = false) => {
|
|
2693
|
+
const now = JSON.stringify(picked()); if (now === said && !again) return; said = now;
|
|
2400
2694
|
say(!sel ? 'Nothing selected' : sel.type === 'node' ? nameOf(sel.id) + (more.length ? `, ${more.length + 1} cards selected` : ', selected')
|
|
2401
2695
|
: 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`);
|
|
2402
|
-
emit('select', { target: sel, targets: picked() });
|
|
2696
|
+
emit('select', { target: copyRef(sel), targets: picked() });
|
|
2403
2697
|
};
|
|
2404
2698
|
const edgeRef = e => ({ type: 'edge', ids: edgeRefIds(e), view: cur });
|
|
2405
2699
|
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]); });
|
|
2406
2700
|
const exists = ref => !ref ? true : ref.type === 'node' ? !!geo.byId[ref.id] : ref.type === 'edge' ? edgeIndex(ref) >= 0
|
|
2407
2701
|
: ref.type === 'container' ? (geo.families || []).some(f => f.id === ref.id) : false;
|
|
2408
2702
|
|
|
2409
|
-
|
|
2410
|
-
|
|
2703
|
+
// 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
|
|
2704
|
+
const focusOf = () => { const a = root.activeElement; if (!a || !(a === stage || (svg && svg.contains(a)) || a.closest('.empty-view'))) return null;
|
|
2705
|
+
const lk = a.closest('.line-key'), k = a.closest('[data-node]'), g = a.closest('.node');
|
|
2706
|
+
return lk && geo.edges[+lk.dataset.i] ? edgeRef(geo.edges[+lk.dataset.i]) : k ? nodeRef(k.dataset.node) : g ? nodeRef(g.dataset.id) : {}; };
|
|
2707
|
+
const refocus = ref => { if (!ref) return; if ((ref.type === 'node' || ref.type === 'edge') && exists(ref)) focusMap(ref, false); else stage.focus({ preventScroll: true }); };
|
|
2708
|
+
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
|
|
2709
|
+
const v = vid == null ? null : ctx.V[vid]; if (vid != null && !v) return false;
|
|
2710
|
+
const back = focusOf();
|
|
2711
|
+
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
|
|
2712
|
+
cur = v ? vid : null; if (v) ctx.planOf(vid);
|
|
2411
2713
|
stage.querySelectorAll('svg.map-svg, .empty-view').forEach(s => s.remove());
|
|
2412
|
-
|
|
2413
|
-
if (empty) { // a view with nothing in it gets a friendly card, not a blank canvas
|
|
2714
|
+
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
|
|
2414
2715
|
geo = { nodes: [], edges: [], groups: [], lanes: [], families: [], byId: {}, stats: {}, bounds: { x: 0, y: 0, w: 400, h: 300 } };
|
|
2415
2716
|
stage.insertAdjacentHTML('afterbegin', '<svg class="map-svg" viewBox="0 0 400 300" xmlns="http://www.w3.org/2000/svg"></svg>');
|
|
2416
|
-
const p = v.parent;
|
|
2417
|
-
stage.insertAdjacentHTML('beforeend', `<div class="empty-view"><div class="ico">◌</div><h4>Nothing here yet</h4><p
|
|
2717
|
+
const p = v && v.parent;
|
|
2718
|
+
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>`);
|
|
2418
2719
|
} else {
|
|
2419
2720
|
const c = ctx.geoCache[vid] ||= ctx.buildView(vid, { tour: P.tour !== false, openHint: P.openHint !== false });
|
|
2420
2721
|
stage.insertAdjacentHTML('afterbegin', c.markup); geo = c.g;
|
|
2421
2722
|
}
|
|
2422
2723
|
svg = stage.querySelector('svg'); svg.classList.add('map-svg'); if (paused) svg.pauseAnimations();
|
|
2423
|
-
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() :
|
|
2724
|
+
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())) });
|
|
2424
2725
|
cam.setSvg(svg, geo.bounds);
|
|
2425
2726
|
const ub = q('[data-z="up"]'); if (ub) ub.disabled = !upOf();
|
|
2426
2727
|
if (options.minimap) buildMini();
|
|
2427
|
-
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
|
|
2428
|
-
|
|
2429
|
-
|
|
2430
|
-
if (
|
|
2431
|
-
|
|
2432
|
-
if (
|
|
2728
|
+
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
|
|
2729
|
+
sel = null; more = []; sub = null; tabKeys(); paint(); // no select event here: the caller reports, once, what became of the selection
|
|
2730
|
+
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)
|
|
2731
|
+
if (naming) { if (naming.ref && !exists(naming.ref)) naming.close(false); else naming.place(); }
|
|
2732
|
+
refocus(back);
|
|
2733
|
+
if (v && pc) pc.chrome();
|
|
2734
|
+
if (v && P.onMount) P.onMount(inner);
|
|
2735
|
+
touch();
|
|
2736
|
+
return true;
|
|
2433
2737
|
}
|
|
2738
|
+
// at fit the cards keep off the pieces over the map (#99): the views list's button, the hint line, and the legend, which
|
|
2739
|
+
// the map leaves room for above it or to its right, whichever leaves the map larger
|
|
2740
|
+
const lg = q('.m-legend'), fitIn = () => { const I = { t: q('.um-navbtn') ? 54 : 16, r: 16, b: tell ? 40 : 16, l: 16 };
|
|
2741
|
+
return lg && !lg.hidden ? [{ ...I, b: 48 + lg.offsetHeight }, { ...I, l: 24 + lg.offsetWidth }] : I; };
|
|
2434
2742
|
|
|
2435
2743
|
/* at fit, the zoom controls and the minimap keep off the cards (#50): when a card would sit under one of them, the fit
|
|
2436
2744
|
also tries room for them at the bottom and at the right, and keeps whichever leaves the map larger */
|
|
@@ -2438,7 +2746,7 @@ export function render(container, model, options = {}) {
|
|
|
2438
2746
|
function clearOf(base) {
|
|
2439
2747
|
if (!cam || !geo) return base;
|
|
2440
2748
|
const [W, H] = cam.size(), key = JSON.stringify([W, H, base]); if (clear && clear.geo === geo && clear.key === key) return clear.sets;
|
|
2441
|
-
const boxes = [...stage.querySelectorAll('.ctl, .minimap, .um-story')].map(x => x.getBoundingClientRect()).filter(r => r.width && r.height);
|
|
2749
|
+
const boxes = [...stage.querySelectorAll('.ctl, .minimap, .um-story, .um-lens')].map(x => x.getBoundingClientRect()).filter(r => r.width && r.height);
|
|
2442
2750
|
let sets = base; // none shown, or no layout (a test DOM): as before
|
|
2443
2751
|
if (boxes.length) {
|
|
2444
2752
|
const S = stage.getBoundingClientRect(), top = Math.min(...boxes.map(r => r.top)) - S.top - 8, left = Math.min(...boxes.map(r => r.left)) - S.left - 8;
|
|
@@ -2450,6 +2758,8 @@ export function render(container, model, options = {}) {
|
|
|
2450
2758
|
clear = { geo, key, sets }; return sets;
|
|
2451
2759
|
}
|
|
2452
2760
|
function onCam(c) {
|
|
2761
|
+
camMoved(); // the state event says where the camera comes to rest, once
|
|
2762
|
+
if (naming) naming.place(); // a name field stays over what it names
|
|
2453
2763
|
const pct = q('.pct'), depth = q('.depth');
|
|
2454
2764
|
if (pct) pct.textContent = Math.round(c.s.k * 100) + '%';
|
|
2455
2765
|
if (depth) depth.textContent = (stage.dataset.depth || 'read').toUpperCase();
|
|
@@ -2523,7 +2833,7 @@ export function render(container, model, options = {}) {
|
|
|
2523
2833
|
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)); });
|
|
2524
2834
|
svg.querySelectorAll('.edge-g').forEach(g => g.classList.toggle('hot', g.dataset.from === nid || g.dataset.to === nid));
|
|
2525
2835
|
svg.querySelectorAll('.e-lab').forEach(g => { const e = geo.edges[g.dataset.i]; g.classList.toggle('hot', e.from === nid || e.to === nid); });
|
|
2526
|
-
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)));
|
|
2836
|
+
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)
|
|
2527
2837
|
stage.classList.add('focusing');
|
|
2528
2838
|
} else if (sel.type === 'edge') {
|
|
2529
2839
|
const i = edgeIndex(sel), e = geo.edges[i];
|
|
@@ -2536,13 +2846,15 @@ export function render(container, model, options = {}) {
|
|
|
2536
2846
|
}
|
|
2537
2847
|
function select(ref, o = {}) {
|
|
2538
2848
|
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)
|
|
2539
|
-
if (ref && (!geo || !exists(ref))) return;
|
|
2540
|
-
|
|
2541
|
-
|
|
2542
|
-
sel = ref ? { ...ref, view: cur } : null;
|
|
2849
|
+
if (ref && (!geo || !exists(ref))) return false;
|
|
2850
|
+
if (!o.story) readerMoved(); // a selection the story didn't make: a story playing waits (0.14.0)
|
|
2851
|
+
more = []; selectNew = false; // the reader moved on: an add the host has not answered selects nothing later
|
|
2852
|
+
sel = ref ? { ...copyRef(ref), view: cur } : null;
|
|
2853
|
+
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
|
|
2543
2854
|
paint();
|
|
2544
2855
|
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 }); }); }
|
|
2545
|
-
|
|
2856
|
+
emitSel(!!o.again);
|
|
2857
|
+
return true;
|
|
2546
2858
|
}
|
|
2547
2859
|
|
|
2548
2860
|
/* keyboard: the nearest card in an arrow's direction, by distance along it plus twice the distance across it */
|
|
@@ -2559,41 +2871,65 @@ export function render(container, model, options = {}) {
|
|
|
2559
2871
|
if (id === sel.id) { if (!more.length) return select(null); sel = nodeRef(more.pop()); }
|
|
2560
2872
|
else if (more.includes(id)) more = more.filter(x => x !== id);
|
|
2561
2873
|
else { more.push(sel.id); sel = nodeRef(id); }
|
|
2562
|
-
paint(); emitSel();
|
|
2563
|
-
}
|
|
2564
|
-
|
|
2565
|
-
/* navigation: zoom into the card that opens a view, out to the card a view came from
|
|
2566
|
-
|
|
2567
|
-
|
|
2568
|
-
|
|
2569
|
-
|
|
2570
|
-
|
|
2571
|
-
|
|
2572
|
-
if (
|
|
2573
|
-
|
|
2574
|
-
if (
|
|
2575
|
-
if (
|
|
2576
|
-
|
|
2577
|
-
|
|
2578
|
-
|
|
2579
|
-
}
|
|
2580
|
-
|
|
2581
|
-
|
|
2582
|
-
const
|
|
2583
|
-
|
|
2584
|
-
|
|
2585
|
-
|
|
2586
|
-
|
|
2587
|
-
|
|
2588
|
-
|
|
2589
|
-
|
|
2590
|
-
|
|
2591
|
-
|
|
2592
|
-
|
|
2593
|
-
|
|
2594
|
-
|
|
2595
|
-
|
|
2596
|
-
|
|
2874
|
+
sub = null; selectNew = false; paint(); emitSel();
|
|
2875
|
+
}
|
|
2876
|
+
|
|
2877
|
+
/* navigation: zoom into the card that opens a view, out to the card a view came from. One move at a time, the latest
|
|
2878
|
+
wins: a setView() (or open, up) during a move takes it to its own view instead, and every request's promise says
|
|
2879
|
+
whether its view was shown (false: unknown, superseded, or the map destroyed). Under reduced motion a move is done
|
|
2880
|
+
before go() returns. */
|
|
2881
|
+
let nav = null; // the move under way: { to, how, waiting: the resolvers of the requests it answers }
|
|
2882
|
+
const answer = ok => { if (nav) nav.waiting.splice(0).forEach(r => r(ok)); };
|
|
2883
|
+
function go(vid, how = {}) {
|
|
2884
|
+
if (dead) return Promise.resolve(false);
|
|
2885
|
+
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); }
|
|
2886
|
+
if (!nav && vid === cur) { if (how.select) select(nodeRef(how.select)); return Promise.resolve(true); }
|
|
2887
|
+
if (!how.story) readerMoved(); // a move the story didn't ask for: a story playing waits (0.14.0)
|
|
2888
|
+
return new Promise(res => {
|
|
2889
|
+
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
|
|
2890
|
+
nav = { to: vid, how, waiting: [res] }; move();
|
|
2891
|
+
});
|
|
2892
|
+
}
|
|
2893
|
+
async function move() { // takes the map from view to view until it shows the latest target; finishes or rolls back (busy, fading)
|
|
2894
|
+
const mine = nav, start = cur; let left = false; busy = true; touch();
|
|
2895
|
+
try {
|
|
2896
|
+
for (;;) {
|
|
2897
|
+
const prev = cur, via = () => nav.how.via; // a request's via: the card it opens, when the request is the latest
|
|
2898
|
+
const modeOf = v => prev && ctx.chain(v).includes(prev) || (v === nav.to && via()) ? 'in' : prev && ctx.chain(prev).includes(v) ? 'out' : 'jump';
|
|
2899
|
+
if (prev && !still() && nav.to !== prev) { // leave: into the card, or out, then fade
|
|
2900
|
+
const m = modeOf(nav.to), n = m === 'in' && via() && geo.byId[via()];
|
|
2901
|
+
if (n) await cam.animate(cam.zoomTo({ x: n.x, y: n.y, w: n.w, h: n.h }, 1.1), 420);
|
|
2902
|
+
else await cam.animate({ k: cam.s.k * (m === 'out' ? 0.6 : 1) }, m === 'out' ? 260 : 1);
|
|
2903
|
+
stage.classList.add('fading'); left = true; await sleep(130);
|
|
2904
|
+
if (dead || nav !== mine) return;
|
|
2905
|
+
}
|
|
2906
|
+
const vid = nav.to; // the latest request: one made during the leave is the view drawn
|
|
2907
|
+
if (!ctx.V[vid] || vid === cur) break; // gone from the model (an update() during the move), or back where it started
|
|
2908
|
+
const mode = modeOf(vid);
|
|
2909
|
+
delete asked[prev]; // an unfold asked for on the view left is not waited for (#71)
|
|
2910
|
+
if (P.onNavigate) P.onNavigate(vid, prev, mode);
|
|
2911
|
+
mount(vid); emitSel(); left = false;
|
|
2912
|
+
const fitS = startView(), back = mode === 'out' ? geo.nodes.find(n => ctx.childOf(n.id, cur) === prev) : null;
|
|
2913
|
+
if (prev && !still()) {
|
|
2914
|
+
if (mode === 'in') cam.set({ ...fitS, k: fitS.k * 0.55 });
|
|
2915
|
+
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 });
|
|
2916
|
+
else cam.set(fitS);
|
|
2917
|
+
stage.classList.remove('fading');
|
|
2918
|
+
await cam.animate(fitS, mode === 'jump' ? 1 : 460);
|
|
2919
|
+
} else { cam.set(fitS); stage.classList.remove('fading'); }
|
|
2920
|
+
if (dead || nav !== mine) return;
|
|
2921
|
+
cam.touched = false;
|
|
2922
|
+
if (back) { flash(back.id); if (root.activeElement === stage) focusMap(nodeRef(back.id), false); } // keys go on from the card come back to
|
|
2923
|
+
if (nav.to === cur) break; // else a request during the zoom-in: on from here
|
|
2924
|
+
}
|
|
2925
|
+
// done: settled before any event, so a listener's own setView() starts a move of its own
|
|
2926
|
+
const ok = nav.to === cur, how = nav.how, shown = cur;
|
|
2927
|
+
answer(ok); nav = null; busy = false; stage.classList.remove('fading');
|
|
2928
|
+
if (left && cur) cam.animate(startView()); // left, and no other view drawn: the camera comes back
|
|
2929
|
+
if (shown && shown !== start) { say('Showing ' + (ctx.V[shown].title || shown)); emit('viewchange', { view: shown, from: start }); }
|
|
2930
|
+
if (ok && how.select && cur === shown) select(nodeRef(how.select), { reveal: true });
|
|
2931
|
+
} catch (err) { fail(err); } // a bug of ours or of a page hook: reported, and the map left usable below
|
|
2932
|
+
finally { if (nav === mine) { answer(false); nav = null; busy = false; stage.classList.remove('fading'); } touch(); }
|
|
2597
2933
|
}
|
|
2598
2934
|
/* where the camera starts: fit on a desk; on a phone a readable zoom from the top-left corner */
|
|
2599
2935
|
function startView() {
|
|
@@ -2617,15 +2953,20 @@ export function render(container, model, options = {}) {
|
|
|
2617
2953
|
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; };
|
|
2618
2954
|
function setUnfold(nid, on) { // true when the picture changed
|
|
2619
2955
|
const list = expanded[cur] || [];
|
|
2956
|
+
if (!on && asked[cur]) asked[cur].delete(nid); // folded back before its view came: no longer waited for
|
|
2620
2957
|
if (E || dead || !cur || (on ? list.includes(nid) || !ctx.unfoldable(nid, cur) : !list.includes(nid))) return false;
|
|
2621
2958
|
expanded[cur] = on ? [...list, nid] : list.filter(x => x !== nid);
|
|
2622
2959
|
update(current, false, true);
|
|
2623
|
-
if ((expanded[cur] || []).includes(nid) !== on) return false; // the model would not have it after all
|
|
2960
|
+
if ((expanded[cur] || []).includes(nid) !== on) { emitSel(); return false; } // the model would not have it after all
|
|
2961
|
+
unfolded(nid, on);
|
|
2962
|
+
return true;
|
|
2963
|
+
}
|
|
2964
|
+
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
|
|
2624
2965
|
const f = on && (geo.families || []).find(x => x.id === nid);
|
|
2625
2966
|
if (f) { cam.reveal({ x: f.x, y: f.y, w: f.w, h: f.h }); select({ type: 'container', id: nid }, { reveal: false }); }
|
|
2626
2967
|
else if (!on && geo.byId[nid]) select(nodeRef(nid));
|
|
2968
|
+
else emitSel();
|
|
2627
2969
|
say(`${nameOf(nid)} ${on ? 'unfolded here' : 'folded'}`);
|
|
2628
|
-
return true;
|
|
2629
2970
|
}
|
|
2630
2971
|
function gesture(id, how) { // E, or Alt + click: a stacked card unfolds; an unfolded box, or a card in one, folds it
|
|
2631
2972
|
if (E || !id || !geo) return false;
|
|
@@ -2634,23 +2975,31 @@ export function render(container, model, options = {}) {
|
|
|
2634
2975
|
let stop = false; // the host may take it, or load a view it has not yet and update() right here
|
|
2635
2976
|
emit('expand', { target: nodeRef(nid), view: ctx.opensOf(nid, cur), expanded: on, preventDefault() { stop = true; } });
|
|
2636
2977
|
if (stop || dead) return true;
|
|
2637
|
-
if (!setUnfold(nid, on)) {
|
|
2978
|
+
if (!setUnfold(nid, on)) {
|
|
2979
|
+
const c2 = on && ctx.opensOf(nid, cur);
|
|
2980
|
+
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
|
|
2981
|
+
if (on) status(c2 && !ctx.V[c2] ? 'Its inner view is not loaded yet' : 'Nothing more to show here');
|
|
2982
|
+
return true; }
|
|
2638
2983
|
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;
|
|
2639
2984
|
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
|
|
2640
2985
|
return true;
|
|
2641
2986
|
}
|
|
2642
2987
|
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); }
|
|
2643
|
-
function setFlow(on) {
|
|
2988
|
+
function setFlow(on, save = true) { // save: the page keeps it as the reader's choice
|
|
2644
2989
|
stage.classList.toggle('flowing', on);
|
|
2645
2990
|
const b = q('[data-z="flow"]'); if (b) { b.classList.toggle('on', on); b.setAttribute('aria-pressed', on); }
|
|
2646
|
-
if (P.onFlow) P.onFlow(on);
|
|
2991
|
+
if (save && P.onFlow) P.onFlow(on);
|
|
2992
|
+
touch();
|
|
2647
2993
|
}
|
|
2648
|
-
function pause(on) { paused = on; stage.classList.toggle('paused', on); if (svg) on ? svg.pauseAnimations() : svg.unpauseAnimations();
|
|
2994
|
+
function pause(on) { const was = paused; paused = on; stage.classList.toggle('paused', on); if (svg) on ? svg.pauseAnimations() : svg.unpauseAnimations();
|
|
2995
|
+
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
|
|
2649
2996
|
|
|
2650
2997
|
/* keys on the canvas; the page forwards the same keys when the focus is elsewhere */
|
|
2651
2998
|
function key(e) {
|
|
2652
2999
|
const k = e.key, g = e.target && e.target.closest && e.target.closest('.node');
|
|
3000
|
+
if (k === 'Escape' && cancelGesture()) return true; // Escape stops a gesture in progress before anything else
|
|
2653
3001
|
if (story) { const r = storyKey(e); if (r != null) return r; } // a story's keys first (#69)
|
|
3002
|
+
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
|
|
2654
3003
|
if (E && editKey(e)) return true;
|
|
2655
3004
|
if (e.altKey) return false;
|
|
2656
3005
|
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]');
|
|
@@ -2667,35 +3016,61 @@ export function render(container, model, options = {}) {
|
|
|
2667
3016
|
const id = g ? g.dataset.id : sel.id;
|
|
2668
3017
|
if (sel && sel.type === 'node' && sel.id === id && ctx.opensOf(id, cur)) open(id); else select(nodeRef(id), { again: true }); }
|
|
2669
3018
|
else if (k === 'Escape' || k === 'Backspace') { if (sel) select(null); else up(); }
|
|
2670
|
-
else if (k === 'f' || k === 'F') setFlow(!stage.classList.contains('flowing'));
|
|
3019
|
+
else if ((k === 'f' || k === 'F') && !reducedMotion()) setFlow(!stage.classList.contains('flowing')); // under reduced motion no flow is drawn, and ≋ isn't offered
|
|
2671
3020
|
else if (k.startsWith('Arrow') && g && geo.byId[g.dataset.id]) { const to = nearest(geo.byId[g.dataset.id], k); if (!to) return false;
|
|
2672
3021
|
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 }); }
|
|
2673
3022
|
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); }
|
|
2674
3023
|
else return false;
|
|
3024
|
+
readerMoved(); // a key the map acted on: a story playing waits
|
|
2675
3025
|
return true;
|
|
2676
3026
|
}
|
|
2677
3027
|
|
|
2678
3028
|
|
|
2679
3029
|
/* ---------- editing: a gesture never changes the picture; it emits a `change` the host may apply (then update()) ----------
|
|
2680
3030
|
Each event carries what the user meant and, apart from it, an optional hint: where the card was dropped. */
|
|
2681
|
-
let seq = 0, sub = null, drag = null, conn = null, selectNew = false, swallow = 0;
|
|
3031
|
+
let seq = 0, sub = null, drag = null, conn = null, naming = null, stale = false, selectNew = false, swallow = 0;
|
|
3032
|
+
/* the gesture in progress, one at a time: a card dragged, a line drawn from the handle, or a name field open (naming:
|
|
3033
|
+
{ ref, place, close }). Escape, a new picture (setView, an update that redraws, unfolding) and destroy() cancel it,
|
|
3034
|
+
with no change emitted; keepName leaves a name field open over the same view's card, which mount() checks */
|
|
3035
|
+
const gestureNow = () => conn ? 'connecting' : drag && drag.on ? 'dragging' : naming ? 'naming' : 'idle';
|
|
3036
|
+
function cancelGesture(keepName = false) { // true when something was in progress
|
|
3037
|
+
let was = false;
|
|
3038
|
+
if (conn) { if (conn.path) conn.path.remove(); conn = null; was = stale = true; } // its pointerup and click then do nothing
|
|
3039
|
+
if (drag) { if (drag.ghost) drag.ghost.remove(); if (drag.on) was = stale = true; drag = null; }
|
|
3040
|
+
if (naming && !keepName) { naming.close(false); was = true; }
|
|
3041
|
+
if (was) touch();
|
|
3042
|
+
return was;
|
|
3043
|
+
}
|
|
2682
3044
|
const placed = {}; // per view: the slot every card had when last drawn (stable positions across updates; never in the model)
|
|
2683
|
-
|
|
2684
|
-
|
|
2685
|
-
|
|
3045
|
+
/* an edit's places wait for the host's answer, and the next update() says from its model whether it applied the edit:
|
|
3046
|
+
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
|
|
3047
|
+
spot, a reorder) as the card's own `layout` entry the host wrote, or as the model unchanged (the host answering an
|
|
3048
|
+
edit that changes no field). Any other update drops it: an edit the host refused moves nothing, whatever comes next.
|
|
3049
|
+
Places an update applied are kept with the models before and after it, so an update back to either (undo, redo)
|
|
3050
|
+
puts the cards back where they were then */
|
|
3051
|
+
let pending = null; // the latest gesture's: { op, view, card, place, to, exact, label, ids, base, at }
|
|
3052
|
+
const moves = []; // { view, base, after, before, then }: places an update applied, and the ones before it
|
|
3053
|
+
const viewIn = (m, vid) => arrOf(m && m.views).find(v => v && v.id === vid) || {};
|
|
3054
|
+
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
|
|
3055
|
+
return ((bs.find(b => !bs.some(o => o.parent === b.id)) || bs[0]) || {}).id ?? null; };
|
|
3056
|
+
const norm = t => String(t ?? '').trim().replace(/\s+/g, ' ').toLowerCase();
|
|
3057
|
+
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);
|
|
2686
3058
|
function change(c, exact) {
|
|
2687
3059
|
const ev = { id: 'c' + Date.now().toString(36) + '-' + (++seq), view: cur, ...c };
|
|
2688
|
-
// only the latest gesture
|
|
2689
|
-
[
|
|
2690
|
-
|
|
2691
|
-
|
|
2692
|
-
|
|
2693
|
-
else if (c.target && c.target.type === 'node') (hints[cur] ||= {})[c.target.id] = [ev.hint.col, ev.hint.row];
|
|
2694
|
-
}
|
|
2695
|
-
if (c.op === 'add' && c.kind === 'node') selectNew = true;
|
|
3060
|
+
// only the latest gesture waits for the host: an edit the host never answered leaves no trace
|
|
3061
|
+
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);
|
|
3062
|
+
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),
|
|
3063
|
+
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;
|
|
3064
|
+
selectNew = c.op === 'add' && c.kind === 'node'; // the latest gesture's: an add the host never answered selects nothing later
|
|
2696
3065
|
emit('change', ev);
|
|
2697
3066
|
return ev;
|
|
2698
3067
|
}
|
|
3068
|
+
function answered(p, m2, json) { // the update's model shows the edit: the card its places go to, else null
|
|
3069
|
+
const v = viewIn(m2, p.view), was = new Set(p.ids);
|
|
3070
|
+
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
|
|
3071
|
+
if (p.to !== undefined) return boxIdOf(m2, p.view, p.card) === p.to ? p.card : null; // into or out of a box
|
|
3072
|
+
return json === p.base || JSON.stringify((v.layout || {})[p.card] ?? null) !== p.at ? p.card : null; // a place alone
|
|
3073
|
+
}
|
|
2699
3074
|
const worldOf = e => { const r = stage.getBoundingClientRect(); return cam.toWorld(e.clientX - r.left, e.clientY - r.top); };
|
|
2700
3075
|
const area = f => f.w * f.h;
|
|
2701
3076
|
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;
|
|
@@ -2740,27 +3115,34 @@ export function render(container, model, options = {}) {
|
|
|
2740
3115
|
if (k === 'Delete' && can('delete') && sel) { change({ op: 'delete', target: ofSel(sub) ? sub : sel }); return true; }
|
|
2741
3116
|
return false;
|
|
2742
3117
|
}
|
|
2743
|
-
/* a text field over the canvas: names a new card, or renames a card, line or container
|
|
2744
|
-
|
|
2745
|
-
|
|
2746
|
-
const
|
|
3118
|
+
/* a text field over the canvas: names a new card, or renames a card, line or container. It follows the camera, and a
|
|
3119
|
+
redraw of the same view keeps it over its card (a new view, or its card gone, closes it: see mount) */
|
|
3120
|
+
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
|
|
3121
|
+
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; }
|
|
3122
|
+
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)];
|
|
3123
|
+
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 }; }
|
|
3124
|
+
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; };
|
|
3125
|
+
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
|
|
3126
|
+
if (naming) naming.close(false);
|
|
3127
|
+
const inp = document.createElement('input');
|
|
2747
3128
|
inp.className = 'um-edit um-rename'; inp.value = text; inp.setAttribute('aria-label', text ? 'New name' : 'Name of the new card');
|
|
2748
|
-
|
|
2749
|
-
|
|
3129
|
+
const place = () => { const box = ref ? nameBox(ref) : where; if (!box) return; const k = cam.s.k, [W, H] = cam.size();
|
|
3130
|
+
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' }); };
|
|
3131
|
+
place(); stage.appendChild(inp); inp.focus(); inp.select();
|
|
2750
3132
|
// once only: removing a focused field can fire blur (cancel) in the middle of a commit
|
|
2751
3133
|
let closed = false;
|
|
2752
|
-
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 }); };
|
|
3134
|
+
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 }); };
|
|
3135
|
+
naming = { ref, place, close: finish }; touch();
|
|
2753
3136
|
inp.addEventListener('keydown', e => { e.stopPropagation(); if (e.key === 'Enter') { e.preventDefault(); finish(true); } else if (e.key === 'Escape') { e.preventDefault(); finish(false); } });
|
|
2754
3137
|
inp.addEventListener('blur', () => finish(false));
|
|
2755
3138
|
inp.addEventListener('pointerdown', e => e.stopPropagation());
|
|
2756
3139
|
return inp;
|
|
2757
3140
|
}
|
|
2758
3141
|
function rename(ref) {
|
|
2759
|
-
if (!can('rename') || !ref) return;
|
|
2760
|
-
|
|
2761
|
-
|
|
2762
|
-
|
|
2763
|
-
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 })); }
|
|
3142
|
+
ref = copyRef(ref); if (!can('rename') || !ref || !nameBox(ref)) return;
|
|
3143
|
+
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);
|
|
3144
|
+
const text = n ? String(ctx.nodeIn(n.id, cur).label ?? n.label ?? '') : e ? String((e.all || [e])[0].label || '') : String(f.label || f.id);
|
|
3145
|
+
editor(null, text, v => change({ op: 'rename', target: ref, label: v }), ref);
|
|
2764
3146
|
}
|
|
2765
3147
|
function addAt(x, y) {
|
|
2766
3148
|
if (!can('add') || !geo) return;
|
|
@@ -2775,11 +3157,11 @@ export function render(container, model, options = {}) {
|
|
|
2775
3157
|
let pressed = false;
|
|
2776
3158
|
stage.addEventListener('pointerdown', () => { swallow = 0; pressed = true; }, true);
|
|
2777
3159
|
['pointerup', 'pointercancel'].forEach(t => stage.addEventListener(t, () => { pressed = false; }, true));
|
|
2778
|
-
function focusMap(ref = sel) {
|
|
3160
|
+
function focusMap(ref = sel, reveal = true) {
|
|
2779
3161
|
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)
|
|
2780
3162
|
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
|
|
2781
3163
|
if (!g) return stage.focus({ preventScroll: true });
|
|
2782
|
-
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 });
|
|
3164
|
+
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 });
|
|
2783
3165
|
}
|
|
2784
3166
|
stage.addEventListener('focus', e => { if (!pressed && sel && (sel.type === 'node' || sel.type === 'edge') && !(e.relatedTarget && root.contains(e.relatedTarget))) focusMap(); });
|
|
2785
3167
|
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;
|
|
@@ -2788,7 +3170,7 @@ export function render(container, model, options = {}) {
|
|
|
2788
3170
|
stage.addEventListener('pointerdown', e => {
|
|
2789
3171
|
if (!E || e.button !== 0 || e.altKey || !geo) return;
|
|
2790
3172
|
const hd = e.target.closest('.um-handle');
|
|
2791
|
-
if (hd && can('add')) { e.preventDefault(); try { stage.setPointerCapture(e.pointerId); } catch (_) {} conn = { from: hd.dataset.from, path: null }; return; }
|
|
3173
|
+
if (hd && can('add')) { e.preventDefault(); try { stage.setPointerCapture(e.pointerId); } catch (_) {} conn = { from: hd.dataset.from, path: null }; touch(); return; }
|
|
2792
3174
|
const g = e.target.closest('.node');
|
|
2793
3175
|
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 };
|
|
2794
3176
|
});
|
|
@@ -2802,13 +3184,15 @@ export function render(container, model, options = {}) {
|
|
|
2802
3184
|
if (!drag) return;
|
|
2803
3185
|
const k = cam.s.k, dx = (e.clientX - drag.x0) / k, dy = (e.clientY - drag.y0) / k;
|
|
2804
3186
|
if (!drag.on) { if (Math.hypot(e.clientX - drag.x0, e.clientY - drag.y0) < 5) return;
|
|
2805
|
-
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); }
|
|
3187
|
+
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(); }
|
|
2806
3188
|
drag.ghost.setAttribute('transform', `translate(${dx} ${dy})`);
|
|
2807
3189
|
const n = geo.byId[drag.id], into = boxAt(n.x + n.w / 2 + dx, n.y + n.h / 2 + dy);
|
|
2808
3190
|
svg.querySelectorAll('.family.um-target').forEach(f => f.classList.remove('um-target'));
|
|
2809
3191
|
if (into) { const f = svg.querySelector(`.family[data-family="${CSS.escape(into.id)}"]`); if (f) f.classList.add('um-target'); }
|
|
2810
3192
|
});
|
|
2811
3193
|
const endGesture = e => {
|
|
3194
|
+
if (conn || drag) touch(); // the gesture ends
|
|
3195
|
+
if (stale) { stale = false; swallow = performance.now() + 400; return; } // the release of a gesture Escape or a new picture cancelled
|
|
2812
3196
|
if (conn) { const c = conn; conn = null; if (c.path) c.path.remove(); swallow = performance.now() + 400;
|
|
2813
3197
|
const g = root.elementFromPoint(e.clientX, e.clientY), t = g && g.closest && g.closest('.node');
|
|
2814
3198
|
if (t && t.dataset.id !== c.from && geo.byId[t.dataset.id]) change({ op: 'add', kind: 'edge', from: c.from, to: t.dataset.id });
|
|
@@ -2832,22 +3216,21 @@ export function render(container, model, options = {}) {
|
|
|
2832
3216
|
else d.ghost.remove();
|
|
2833
3217
|
};
|
|
2834
3218
|
stage.addEventListener('pointerup', endGesture);
|
|
2835
|
-
stage.addEventListener('pointercancel', () => { if (drag && drag.ghost) drag.ghost.remove(); if (conn && conn.path) conn.path.remove(); drag = conn = null; });
|
|
3219
|
+
stage.addEventListener('pointercancel', () => { if (drag && drag.ghost) drag.ghost.remove(); if (conn && conn.path) conn.path.remove(); drag = conn = null; stale = false; });
|
|
2836
3220
|
/* the model to draw after an update: cards keep the slots they had (the host's own layout wins), a moved or added card
|
|
2837
3221
|
takes its hint; kept in this handle only, never written to the host's model */
|
|
2838
|
-
function withPlaces(m) {
|
|
3222
|
+
function withPlaces(m, use = null) { // use: an edit the update applied, { view, hv: id -> its place, ex: a reordered group's places }
|
|
2839
3223
|
if (options.stableLayout === false) return m;
|
|
2840
3224
|
const out = structuredClone(m);
|
|
2841
3225
|
(out.views || []).forEach(v => {
|
|
2842
|
-
const was = placed[v.id], hv =
|
|
2843
|
-
if (!was && !Object.keys(hv).length && !Object.keys(ex).length
|
|
3226
|
+
const was = placed[v.id], { hv = {}, ex = {} } = use && use.view === v.id ? use : {};
|
|
3227
|
+
if (!was && !Object.keys(hv).length && !Object.keys(ex).length) return;
|
|
2844
3228
|
const ids = (v.nodeIds || []).filter(id => typeof id === 'string'), mine = v.layout || {}, L = {}, taken = new Set(), key = p => p[0] + ',' + p[1];
|
|
2845
3229
|
const put = (id, p) => { let [c, r] = p; while (taken.has(key([c, r]))) r++; L[id] = [c, r]; taken.add(key([c, r])); };
|
|
2846
3230
|
ids.forEach(id => { if (Array.isArray(mine[id])) { L[id] = mine[id]; taken.add(key(mine[id])); } }); // the host's own layout
|
|
2847
3231
|
ids.forEach(id => { if (!L[id] && ex[id]) put(id, ex[id]); }); // a reordered column group
|
|
2848
3232
|
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
|
|
2849
|
-
ids.forEach(id => { if (!L[id] && hv[id]) put(id, hv[id]); }); // a moved card: its drop, or the nearest free slot
|
|
2850
|
-
ids.filter(id => !L[id] && !(was && was[id])).forEach(id => { if (adds.length) put(id, adds.shift()); }); // a new card likewise
|
|
3233
|
+
ids.forEach(id => { if (!L[id] && hv[id]) put(id, hv[id]); }); // a moved or added card: its drop, or the nearest free slot
|
|
2851
3234
|
if (Object.keys(L).length) v.layout = L;
|
|
2852
3235
|
});
|
|
2853
3236
|
return out;
|
|
@@ -2880,13 +3263,15 @@ export function render(container, model, options = {}) {
|
|
|
2880
3263
|
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
|
|
2881
3264
|
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
|
|
2882
3265
|
open(g.dataset.id); });
|
|
2883
|
-
stage.addEventListener('keydown', e => { if (e.defaultPrevented || e.metaKey || e.ctrlKey || (e.altKey && !E) || e.target.closest('input')) return;
|
|
3266
|
+
stage.addEventListener('keydown', e => { if (e.defaultPrevented || e.metaKey || e.ctrlKey || (e.altKey && !E) || e.target.closest('input')) return;
|
|
3267
|
+
if ((e.key === 'Enter' || e.key === ' ') && e.target.closest('button')) return; // a button of the map's own (zoom, ↑, ≋, Back) presses itself
|
|
3268
|
+
if (key(e)) { e.preventDefault(); e.stopPropagation(); } });
|
|
2884
3269
|
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)); });
|
|
2885
3270
|
stage.addEventListener('pointerout', e => { if (e.target.closest('.node')) svg.querySelectorAll('.edge-g.hov').forEach(x => x.classList.remove('hov')); });
|
|
2886
3271
|
const ctl = q('.ctl');
|
|
2887
|
-
if (ctl) ctl.addEventListener('click', e => { const z = e.target.closest('button')?.dataset.z; if (!z) return;
|
|
3272
|
+
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
|
|
2888
3273
|
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); });
|
|
2889
|
-
setFlow(options.flow ?? !(
|
|
3274
|
+
setFlow(options.flow ?? !reducedMotion(), false); // the default, or the host's: not saved as the page reader's choice
|
|
2890
3275
|
|
|
2891
3276
|
// the stage changes size with its container: keep the zoom, refit only an untouched view (no ResizeObserver, as in
|
|
2892
3277
|
// jsdom: the view is fitted once)
|
|
@@ -2898,7 +3283,7 @@ export function render(container, model, options = {}) {
|
|
|
2898
3283
|
selection, hover, flow animation or edit handles, whatever the screen shows; the screen is left as it was. */
|
|
2899
3284
|
function standalone(scale = 1, vid = cur, fontCSS = '') {
|
|
2900
3285
|
if (settle) settle(); // an export right after update() draws where things end up, not mid-ease
|
|
2901
|
-
if (!svg || !geo) return '';
|
|
3286
|
+
if (!svg || !geo || vid == null) return ''; // a model with no views draws nothing
|
|
2902
3287
|
let s = svg, g = geo, off = null;
|
|
2903
3288
|
if (vid !== cur) { // another view: drawn off screen, inside the shadow root so the same styles apply
|
|
2904
3289
|
const v = ctx.V[vid]; if (!v || !(v.nodeIds || []).some(i => ctx.N[i])) return '';
|
|
@@ -2906,7 +3291,8 @@ export function render(container, model, options = {}) {
|
|
|
2906
3291
|
off = document.createElement('div'); off.style.cssText = 'position:absolute;left:-100000px;top:0;width:10px;height:10px;overflow:hidden;pointer-events:none';
|
|
2907
3292
|
off.innerHTML = c.markup; stage.parentNode.appendChild(off); s = off.querySelector('svg'); s.classList.add('map-svg'); g = c.g;
|
|
2908
3293
|
}
|
|
2909
|
-
if (off)
|
|
3294
|
+
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
|
|
3295
|
+
if (L) layers.set('lens', lensOf(ctx, vid, L).layer); drawMarks(s, g, vid); if (L) layers.set('lens', mine); }
|
|
2910
3296
|
const s0 = sel, flowing = stage.classList.contains('flowing'), hov = [...svg.querySelectorAll('.hov')];
|
|
2911
3297
|
sel = null; paint(); stage.classList.remove('flowing'); hov.forEach(x => x.classList.remove('hov')); stage.classList.add('um-still');
|
|
2912
3298
|
const clone = s.cloneNode(true), b = g.bounds;
|
|
@@ -2935,7 +3321,7 @@ export function render(container, model, options = {}) {
|
|
|
2935
3321
|
let settle = null; // ends an ease still running (before an export, or the next update)
|
|
2936
3322
|
function easeIn(oldGeo, oldSvg) {
|
|
2937
3323
|
if (settle) settle();
|
|
2938
|
-
if (options.ease === false ||
|
|
3324
|
+
if (options.ease === false || still() || !oldGeo || !oldSvg || !geo || !svg || oldSvg === svg) return;
|
|
2939
3325
|
const T = 600, moves = [], key = e => edgeRefIds(e).join(' ') || e.from + '>' + e.to, all = (s, q) => [...s.querySelectorAll(q)];
|
|
2940
3326
|
const was = new Map(oldGeo.nodes.map(n => [n.id, n])), now = new Set(geo.nodes.map(n => n.id));
|
|
2941
3327
|
const lines = new Map(oldGeo.edges.map((e, i) => [key(e), [e, i]])), kept = new Set(geo.edges.map(key));
|
|
@@ -2947,7 +3333,7 @@ export function render(container, model, options = {}) {
|
|
|
2947
3333
|
const gone = document.createElementNS('http://www.w3.org/2000/svg', 'g');
|
|
2948
3334
|
gone.setAttribute('class', 'um-leaving'); gone.setAttribute('aria-hidden', 'true'); gone.style.pointerEvents = 'none';
|
|
2949
3335
|
const leave = x => { const c = x.cloneNode(true);
|
|
2950
|
-
[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'); });
|
|
3336
|
+
[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
|
|
2951
3337
|
gone.appendChild(c); };
|
|
2952
3338
|
oldGeo.nodes.forEach(n => { if (!now.has(n.id)) all(oldSvg, `.node[data-id="${CSS.escape(n.id)}"]`).forEach(leave); });
|
|
2953
3339
|
lines.forEach(([, i], k) => { if (!kept.has(k)) all(oldSvg, `.edge-g[data-i="${i}"], .e-lab[data-i="${i}"]`).forEach(leave); });
|
|
@@ -2963,37 +3349,63 @@ export function render(container, model, options = {}) {
|
|
|
2963
3349
|
|
|
2964
3350
|
function update(model2, relayout = false, unfolding = false) { // unfolding: the same model, a card unfolded or folded
|
|
2965
3351
|
if (dead) return;
|
|
2966
|
-
|
|
2967
|
-
|
|
2968
|
-
const
|
|
3352
|
+
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}`);
|
|
3353
|
+
// an identical model: nothing to redraw, unless it answers an edit (one that changed only a place)
|
|
3354
|
+
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
|
|
3355
|
+
if (!own && !pending && same) return;
|
|
3356
|
+
// the model before or after a move an update applied (undo, redo): the cards go back where they were then
|
|
3357
|
+
const back = !own && !same && [...moves].reverse().find(x => x.base === json || x.after === json);
|
|
3358
|
+
if (back) placed[back.view] = structuredClone(back.base === json ? back.before : back.then);
|
|
3359
|
+
// the edit waiting for this answer: its places if the model shows it, else none (it is dropped either way)
|
|
3360
|
+
const p = own ? null : pending, card = p && answered(p, model2, json);
|
|
3361
|
+
if (!own) pending = null;
|
|
3362
|
+
const use = card ? { view: p.view, hv: p.place ? { [card]: p.place } : {}, ex: p.exact || {} } : null, from = use && structuredClone(placed[p.view] || null);
|
|
3363
|
+
// a card E or Alt + click asked to unfold before its view was loaded unfolds once a model brings that view (#71)
|
|
3364
|
+
const brought = []; Object.entries(asked).forEach(([vid, m]) => m.forEach((c, nid) => { if (!arrOf(model2.views).some(v => v && v.id === c)) return;
|
|
3365
|
+
m.delete(nid); const l = expanded[vid] ||= []; if (!l.includes(nid)) { l.push(nid); if (vid === cur) brought.push(nid); } }));
|
|
3366
|
+
const old = ctx, keys = {}, wantNew = selectNew && !own;
|
|
2969
3367
|
old.views.forEach(v => { keys[v.id] = relayout ? null : old.viewKey(v.id); });
|
|
2970
3368
|
current = model2;
|
|
2971
|
-
ctx = mapContext(withPlaces(model2), { measure, kinds: !!P.kinds, prev: options.stableLayout === false ? null : old.planCache, expand: expanded });
|
|
3369
|
+
ctx = mapContext(withPlaces(model2, use), { measure, kinds: !!P.kinds, prev: options.stableLayout === false ? null : old.planCache, expand: expanded });
|
|
2972
3370
|
Object.keys(expanded).forEach(v => { expanded[v] = ctx.unfolded[v] || []; }); // a card or inner view the model no longer has folds
|
|
2973
|
-
|
|
3371
|
+
if (!own) selectNew = false;
|
|
2974
3372
|
ctx.views.forEach(v => { const id = v.id; if (keys[id] == null || keys[id] !== ctx.viewKey(id)) return;
|
|
2975
3373
|
if (id in old.planCache) ctx.planCache[id] = old.planCache[id];
|
|
2976
3374
|
if (old.layoutCache[id]) ctx.layoutCache[id] = old.layoutCache[id];
|
|
2977
3375
|
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)
|
|
2978
|
-
|
|
2979
|
-
if (!
|
|
2980
|
-
|
|
3376
|
+
restory(); // the story shown takes this model's steps, or stops when the model has it no more
|
|
3377
|
+
if (!cur) { // nothing shown yet (a first model with no views): this model's first view is drawn
|
|
3378
|
+
const v0 = ctx.V[options.view] ? options.view : ctx.rootId;
|
|
3379
|
+
if (v0 && cam) { mount(v0); cam.set(startView()); cam.touched = false; emitSel(); emit('viewchange', { view: cur, from: null }); }
|
|
3380
|
+
return; }
|
|
3381
|
+
if (!ctx.V[cur]) { // the view shown is gone: the root, or the empty card of a model with no views
|
|
3382
|
+
const from = cur; mount(ctx.rootId ?? null); cam.set(startView()); cam.touched = false; emitSel(); if (cur) emit('viewchange', { view: cur, from }); return; }
|
|
3383
|
+
if (ctx.geoCache[cur] && keys[cur] === ctx.viewKey(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)
|
|
2981
3384
|
const keep = sel, keepMore = more, touched = cam.touched, prev = [geo, svg];
|
|
2982
3385
|
mount(cur);
|
|
2983
3386
|
if (touched) cam.set({}); else cam.set(startView());
|
|
2984
3387
|
cam.touched = touched;
|
|
2985
|
-
|
|
2986
|
-
if (
|
|
2987
|
-
|
|
3388
|
+
// ponytail: the models' JSON, kept for 20 moves; a hash of them if models grow large enough for that to weigh
|
|
3389
|
+
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(); }
|
|
3390
|
+
const box = brought.find(nid => (expanded[cur] || []).includes(nid));
|
|
3391
|
+
if (box) unfolded(box, true); // a card asked to unfold earlier: its box is selected
|
|
3392
|
+
else if (wantNew && use && p.op === 'add' && geo.byId[card]) select(nodeRef(card), { reveal: false }); // the card added, as the reader named it
|
|
3393
|
+
else { // the selection that is still drawn stays, and a select event comes only if it changed (unfolding: the caller says)
|
|
3394
|
+
if (keep && exists(keep)) { sel = keep; more = keep.type === 'node' ? keepMore.filter(id => geo.byId[id]) : []; paint(); }
|
|
3395
|
+
if (!unfolding) emitSel(); }
|
|
2988
3396
|
if (!relayout) easeIn(...prev); // a font that came in late moves nothing on purpose: no ease
|
|
3397
|
+
touch();
|
|
2989
3398
|
}
|
|
2990
3399
|
|
|
2991
3400
|
function destroy() {
|
|
2992
3401
|
if (dead) return;
|
|
3402
|
+
cancelGesture();
|
|
2993
3403
|
dead = true; if (ro) ro.disconnect(); cancelAnimationFrame(rz); stopStory();
|
|
2994
|
-
|
|
3404
|
+
answer(false); endTrip(); // every promise settles: a move's setView() false, a tour's and a replay's done false
|
|
3405
|
+
timers.forEach(clearTimeout); timers.clear(); sleeping.forEach(wake => wake());
|
|
2995
3406
|
if (cam) cam.stopAnimation();
|
|
2996
3407
|
host.remove();
|
|
3408
|
+
clearTimeout(restT); camRest = camNow(); flushState(true); // a state listener hears destroyed: true, last, and where the camera stopped
|
|
2997
3409
|
Object.keys(subs).forEach(k => delete subs[k]);
|
|
2998
3410
|
}
|
|
2999
3411
|
|
|
@@ -3002,29 +3414,39 @@ export function render(container, model, options = {}) {
|
|
|
3002
3414
|
Each mount draws them again, so they survive update() and setView(); a card or line the view doesn't show is ignored.
|
|
3003
3415
|
toSVG() and toPNG() draw them too, at rest: they are the host's, like the model (the selection and hover are not). */
|
|
3004
3416
|
const layers = new Map(), DIM = .2, clamp01 = x => Math.min(1, Math.max(0, +x || 0));
|
|
3417
|
+
const scopes = new Map(); // layer name -> the views it is about: a highlight's Refs name theirs, and it lights those only
|
|
3005
3418
|
// a drawn line that stands for several edges takes the strongest of their marks: a pulsing one, then the most lit, then the fastest
|
|
3006
3419
|
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];
|
|
3007
|
-
function markOf(get) { // one card's or line's mark over
|
|
3420
|
+
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
|
|
3008
3421
|
let out = null;
|
|
3009
|
-
|
|
3422
|
+
here.forEach(L => { const m = get(L); if (!m && !dims.has(L)) return; out ||= { level: 1 };
|
|
3010
3423
|
if (m) for (const k in m) if (m[k] != null && k !== 'level') out[k] = m[k];
|
|
3011
3424
|
out.level = Math.min(out.level, m ? clamp01(m.level ?? 1) : DIM); });
|
|
3012
3425
|
return out;
|
|
3013
3426
|
}
|
|
3014
3427
|
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]]);
|
|
3015
|
-
function drawMarks(s = svg, g = geo) {
|
|
3428
|
+
function drawMarks(s = svg, g = geo, vid = cur) {
|
|
3016
3429
|
if (!s || !g) return;
|
|
3017
3430
|
s.querySelectorAll('.um-rings, .um-marks, .um-glow').forEach(x => x.remove());
|
|
3018
3431
|
s.querySelectorAll('.node[opacity], .edge-g[opacity], .e-lab[opacity]').forEach(x => x.removeAttribute('opacity'));
|
|
3019
3432
|
s.querySelectorAll('.edge[style], .flowline[style]').forEach(x => x.removeAttribute('style'));
|
|
3020
3433
|
s.querySelectorAll('.node[aria-description]').forEach(x => x.removeAttribute('aria-description'));
|
|
3021
3434
|
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'); });
|
|
3022
|
-
|
|
3023
|
-
|
|
3435
|
+
// the layers about this view; one that dims dims only where it names something: on a view it names nothing of, nothing fades
|
|
3436
|
+
const named = [...layers].filter(([n]) => !scopes.has(n) || scopes.get(n).includes(vid)), here = named.map(([, L]) => L);
|
|
3437
|
+
const names = L => g.nodes.some(n => (L.nodes || {})[n.id]) || g.edges.some(e => edgeRefIds(e).some(x => (L.edges || {})[x]));
|
|
3438
|
+
let dims = new Set(here.filter(L => L.dim && names(L)));
|
|
3439
|
+
// 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
|
|
3440
|
+
// while the step shows dims in its place, so a step looks as it was told and what the reader lights later still shows
|
|
3441
|
+
const sl = layers.get('story'), at = n => dimAt.get(n) || 0;
|
|
3442
|
+
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]);
|
|
3443
|
+
const markOf2 = get => markOf(get, here, dims);
|
|
3444
|
+
if (s === svg) stage.classList.toggle('um-lit', dims.size > 0);
|
|
3445
|
+
if (!here.length) return;
|
|
3024
3446
|
const tone = t => 'var(--um-' + String(t || 'accent').replace(/[^\w-]/g, '') + ')', op = m => m.level < 1 ? ` opacity="${m.level}"` : '';
|
|
3025
3447
|
const cards = Object.fromEntries([...s.querySelectorAll('.node')].map(x => [x.dataset.id, x]));
|
|
3026
3448
|
let under = '', over = '';
|
|
3027
|
-
g.nodes.forEach(n => { const m =
|
|
3449
|
+
g.nodes.forEach(n => { const m = markOf2(L => (L.nodes || {})[n.id]), el = cards[n.id]; if (!m || !el) return;
|
|
3028
3450
|
if (m.level < 1) el.setAttribute('opacity', m.level);
|
|
3029
3451
|
if (m.tone || m.pulse) under += `<g${op(m)}><rect x="${n.x - 5}" y="${n.y - 5}" width="${n.w + 10}" height="${n.h + 10}" rx="14" class="um-ring${m.pulse ? ' um-pulse' : ''}" stroke="${tone(m.tone)}"/></g>`;
|
|
3030
3452
|
const b = m.badge == null ? null : badgesOf({ badges: [m.badge] })[0]; if (!b) return; // a tab after the card's own
|
|
@@ -3034,7 +3456,7 @@ export function render(container, model, options = {}) {
|
|
|
3034
3456
|
const heads = (g.families || []).flatMap(f => [{ x: f.x + 8, y: f.y + 3, w: String(f.label || f.id).length * 6.6 + 8, h: 22 }, // a box's title, 11 px mono
|
|
3035
3457
|
...(f.tabs || []).map(t => ({ x: t.x, y: f.y - 8, w: t.w, h: 16 }))]); // and its tabs
|
|
3036
3458
|
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));
|
|
3037
|
-
g.edges.forEach((e, i) => { const m =
|
|
3459
|
+
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;
|
|
3038
3460
|
const path = eg.querySelector('.edge'), flow = eg.querySelector('.flowline');
|
|
3039
3461
|
if (m.level < 1) [eg, s.querySelector(`.e-lab[data-i="${i}"]`)].forEach(x => x && x.setAttribute('opacity', m.level));
|
|
3040
3462
|
if (m.tone || m.pulse) path.insertAdjacentHTML('beforebegin', `<path d="${path.getAttribute('d')}" class="um-glow${m.pulse ? ' um-pulse' : ''}" stroke="${tone(m.tone)}"/>`);
|
|
@@ -3058,28 +3480,67 @@ export function render(container, model, options = {}) {
|
|
|
3058
3480
|
if (under && cardsG) cardsG.insertAdjacentHTML('beforebegin', `<g class="um-rings" aria-hidden="true">${under}</g>`);
|
|
3059
3481
|
if (over) s.insertAdjacentHTML('beforeend', `<g class="um-marks" aria-hidden="true">${over}</g>`);
|
|
3060
3482
|
}
|
|
3061
|
-
|
|
3483
|
+
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)
|
|
3484
|
+
function overlay(name, layer, views = null) { // views: the views the layer is about (highlight()'s); none: every view
|
|
3062
3485
|
if (dead || name == null) return;
|
|
3063
|
-
|
|
3064
|
-
|
|
3486
|
+
const n = String(name);
|
|
3487
|
+
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
|
|
3488
|
+
if (layer != null && views) scopes.set(n, views); else scopes.delete(n);
|
|
3489
|
+
drawMarks(); touch();
|
|
3065
3490
|
}
|
|
3066
|
-
|
|
3067
|
-
|
|
3068
|
-
if (
|
|
3491
|
+
// the cards in a box, at any depth, on a view (an unfolded card's box too)
|
|
3492
|
+
const boxCards = (vid, id) => { const bs = ((ctx.V[vid] || {}).containers || []).filter(Boolean), out = new Set(), seen = new Set();
|
|
3493
|
+
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)); };
|
|
3494
|
+
walk(id); return out; };
|
|
3495
|
+
/* the 'highlight' layer (or another, `name`: a story step's 'story', the MCP page's 'mcp'): these cards and lines lit, the
|
|
3496
|
+
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
|
|
3497
|
+
them; Refs that name their view light that view only */
|
|
3498
|
+
function highlight(refs, name = 'highlight') {
|
|
3499
|
+
if (refs == null) return overlay(name, null);
|
|
3069
3500
|
const L = { nodes: {}, edges: {}, dim: true }, lv = r => clamp01(r.level ?? (r.distance > 0 ? Math.max(.45, 1 - .15 * r.distance) : 1));
|
|
3070
|
-
|
|
3071
|
-
|
|
3072
|
-
|
|
3073
|
-
|
|
3501
|
+
const lit = (o, x, r) => { o[x] = { level: Math.max(lv(r), (o[x] || {}).level || 0) }; }, list = [].concat(refs).filter(Boolean);
|
|
3502
|
+
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
|
|
3503
|
+
if (id != null) lit(L.nodes, id, r);
|
|
3504
|
+
else if (r.type === 'edge') (r.ids || []).forEach(x => lit(L.edges, x, r));
|
|
3505
|
+
else if (box != null) { const vid = r.view ?? cur, ins = boxCards(vid, box), at = x => ins.has(x) || x === box;
|
|
3506
|
+
ins.forEach(x => lit(L.nodes, x, r));
|
|
3507
|
+
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); }); } });
|
|
3508
|
+
const views = list.every(r => typeof r.view === 'string') ? [...new Set(list.map(r => r.view))] : null;
|
|
3509
|
+
overlay(name, L, views);
|
|
3510
|
+
}
|
|
3511
|
+
/* 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
|
|
3512
|
+
view is not laid out again. A lens that colours cards by owner puts their tones in a legend over the controls. */
|
|
3513
|
+
let lensId = null;
|
|
3514
|
+
function relens() {
|
|
3515
|
+
const L = lensId == null ? null : arrOf(ctx.D.lenses).find(x => x && x.id === lensId), was = q('.um-lens');
|
|
3516
|
+
if (was) { was.remove(); clear = null; }
|
|
3517
|
+
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
|
|
3518
|
+
if (!cur || !ctx.V[cur]) return;
|
|
3519
|
+
const { layer, owners } = lensOf(ctx, cur, L), name = esc(L.title || L.id);
|
|
3520
|
+
layers.set('lens', layer);
|
|
3521
|
+
if (owners.length) { q('.mctl').insertAdjacentHTML('afterbegin', `<div class="um-lens" role="group" aria-label="${name}"><b>${name}</b>${owners.map(([o, t]) => `<span><i style="border-color:var(--um-${t})"></i>${esc(o)}</span>`).join('')}</div>`); clear = null; }
|
|
3522
|
+
}
|
|
3523
|
+
function setLens(id) { // true when the model has that lens (or id is null)
|
|
3524
|
+
if (dead || (id != null && !arrOf(ctx.D.lenses).some(x => x && x.id === id))) return false;
|
|
3525
|
+
const was = lensId; lensId = id ?? null; if (lensId != null) dimAt.set('lens', ++dimClock); relens(); drawMarks(); if (lensId !== was) emit('lens', { lens: lensId });
|
|
3526
|
+
if (cam && geo && !cam.touched) cam.set(startView()); // fitted: the cards keep clear of the legend, as of the controls
|
|
3527
|
+
return true;
|
|
3074
3528
|
}
|
|
3075
3529
|
/* 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
|
|
3076
3530
|
view (setView, an update that redraws it) ends it, as does destroy() */
|
|
3077
3531
|
let trip = null;
|
|
3078
|
-
|
|
3532
|
+
// 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
|
|
3533
|
+
// a card and out at its next line rather than across the card's name
|
|
3534
|
+
function tripLayer() {
|
|
3535
|
+
const cards = svg.querySelector(':scope > .nodes');
|
|
3536
|
+
(cards || svg).insertAdjacentHTML(cards ? 'beforebegin' : 'beforeend', '<g class="um-trip" aria-hidden="true"></g>');
|
|
3537
|
+
return cards ? cards.previousElementSibling : svg.lastElementChild;
|
|
3538
|
+
}
|
|
3539
|
+
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(); }
|
|
3079
3540
|
function startTrip(step, end) { // step(ms played, pauses left out) says whether to go on
|
|
3080
3541
|
endTrip();
|
|
3081
3542
|
const t = { raf: 0, dot: null, paused: false, at: 0, last: performance.now(), end }; trip = t;
|
|
3082
|
-
const frame = now => { if (trip !== t) return; if (dead) return endTrip(); if (!t.paused) t.at += now - t.last; t.last = now;
|
|
3543
|
+
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
|
|
3083
3544
|
if (step(t.at)) t.raf = requestAnimationFrame(frame); else endTrip(true); };
|
|
3084
3545
|
t.raf = requestAnimationFrame(frame);
|
|
3085
3546
|
return t;
|
|
@@ -3091,36 +3552,50 @@ export function render(container, model, options = {}) {
|
|
|
3091
3552
|
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]; };
|
|
3092
3553
|
/* tour(path): the dot rides the line between each pair of cards in turn, through each card to its next line, and each
|
|
3093
3554
|
card it reaches flashes; where no line joins two cards it skips to the next. Resolves true when it reaches the end. */
|
|
3094
|
-
function tour(path, o = {}) {
|
|
3555
|
+
function tour(path, o = {}, quiet = false) { // quiet: a story's step, whose caption says it
|
|
3095
3556
|
endTrip();
|
|
3096
3557
|
if (dead || !geo || path == null) return Promise.resolve(false);
|
|
3097
3558
|
const ids = [].concat(path).map(String).filter(id => geo.byId[id]), legs = [];
|
|
3098
3559
|
for (let k = 0; k + 1 < ids.length; k++) { const r = lineBetween(ids[k], ids[k + 1]); if (!r) continue;
|
|
3099
3560
|
const prev = legs[legs.length - 1], n = geo.byId[ids[k]];
|
|
3100
|
-
const
|
|
3101
|
-
|
|
3102
|
-
|
|
3561
|
+
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];
|
|
3562
|
+
// skip: the part through the card, where the dot rides under it and can't be seen
|
|
3563
|
+
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 }); }
|
|
3564
|
+
if (legs.length && still() && P.tourAt == null) atRest(legs, quiet);
|
|
3565
|
+
if (!legs.length || (still() && P.tourAt == null)) return Promise.resolve(legs.length > 0);
|
|
3103
3566
|
const speed = Math.max(20, +o.speed || 240); // map units a second
|
|
3104
|
-
|
|
3105
|
-
const
|
|
3567
|
+
const g = tripLayer(); g.insertAdjacentHTML('beforeend', '<circle r="5.5" class="tour-dot"/>');
|
|
3568
|
+
const dot = g.firstElementChild; let leg = 0, base = 0;
|
|
3106
3569
|
if (P.tourAt != null) { // the page's #shot=1&at=<0 to 1>: the dot held that far along the ride, a frame for umlimator.py record (#83)
|
|
3107
|
-
|
|
3108
|
-
const
|
|
3570
|
+
// spread over the lines only: a frame inside a card would show no dot (it rides under the cards)
|
|
3571
|
+
const seen = l => l.len - l.skip;
|
|
3572
|
+
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++]);
|
|
3573
|
+
const [x, y] = along(legs[leg].pts, legs[leg].skip + d); dot.setAttribute('cx', x); dot.setAttribute('cy', y);
|
|
3109
3574
|
trip = { raf: 0, dot: g, end() {} }; return Promise.resolve(true);
|
|
3110
3575
|
}
|
|
3111
3576
|
return new Promise(res => { startTrip(ms => { const d = ms * speed / 1000;
|
|
3112
3577
|
while (leg < legs.length && d - base >= legs[leg].len) { base += legs[leg].len; flash(legs[leg].to); leg++; }
|
|
3113
3578
|
if (leg >= legs.length) return false;
|
|
3114
|
-
const [x, y] = along(legs[leg].pts, d - base); dot.setAttribute('cx', x); dot.setAttribute('cy', y); return true; }, res).dot = g; });
|
|
3579
|
+
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(); });
|
|
3580
|
+
}
|
|
3581
|
+
// under reduced motion no dot rides: the path is lit at rest instead, its cards ringed and numbered in turn and its lines
|
|
3582
|
+
// glowing (the 'tour' layer, which nothing dims), and the live region says it; it stays until the trip slot is taken
|
|
3583
|
+
function atRest(legs, quiet) {
|
|
3584
|
+
const L = { nodes: {}, edges: {} }, order = [];
|
|
3585
|
+
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) }; } });
|
|
3586
|
+
edgeRefIds(geo.edges[l.i]).forEach(x => { L.edges[x] = { tone: 'accent' }; }); });
|
|
3587
|
+
overlay('tour', L); trip = { kind: 'path', raf: 0, dot: null, end() { overlay('tour', null); } };
|
|
3588
|
+
if (!quiet) say('Tour: ' + order.map(nameOf).join(' → '));
|
|
3115
3589
|
}
|
|
3116
3590
|
/* replay(spans): each call from one card to another (a span and its parent's) runs along their line at the trace's relative
|
|
3117
3591
|
time, the whole trace in `duration` ms; the line glows while the call lasts and keeps its duration as a label */
|
|
3592
|
+
let replays = 0; // the newest replay's number: an older one's stop() leaves the newer one's marks alone
|
|
3118
3593
|
function replay(spans, o = {}) {
|
|
3594
|
+
if (dead) return { hops: 0, skipped: 0, done: Promise.resolve(false), pause() {}, resume() {}, stop() {} };
|
|
3119
3595
|
endTrip(); overlay('replay', null);
|
|
3596
|
+
const gen = ++replays;
|
|
3120
3597
|
const list = [].concat(spans || []).filter(s => s && Number.isFinite(+s.start) && Number.isFinite(+s.end));
|
|
3121
|
-
const byId = new Map(list.map(s => [String(s.id), s])),
|
|
3122
|
-
const cardOf = svc => { if (svc == null || !geo) return null; if (match[svc] != null) return geo.byId[match[svc]] ? String(match[svc]) : null;
|
|
3123
|
-
if (geo.byId[svc]) return String(svc); const n = geo.nodes.find(x => low(x.label) === low(svc)); return n ? n.id : null; };
|
|
3598
|
+
const byId = new Map(list.map(s => [String(s.id), s])), cardOf = cardMatcher(geo ? geo.nodes : [], o.match);
|
|
3124
3599
|
let skipped = 0, t = null, done; const hops = [];
|
|
3125
3600
|
list.forEach(s => { const to = cardOf(s.service); if (!to) return skipped++;
|
|
3126
3601
|
const p = s.parent != null ? byId.get(String(s.parent)) : null, from = p && cardOf(p.service);
|
|
@@ -3128,30 +3603,36 @@ export function render(container, model, options = {}) {
|
|
|
3128
3603
|
const r = lineBetween(from, to); if (!r) return skipped++; // a call the view draws no line for
|
|
3129
3604
|
hops.push({ ...r, ids: edgeRefIds(geo.edges[r.i]), at: +s.start, dur: +s.end - +s.start, to, len: lenOf(r.pts) }); });
|
|
3130
3605
|
const ctl = { hops: hops.length, skipped, done: new Promise(r => { done = r; }),
|
|
3131
|
-
pause() { if (t && trip === t) t.paused = true; }, resume() { if (t && trip === t) t.paused = false; },
|
|
3132
|
-
stop() { if (t && trip === t) endTrip(); else overlay('replay', null); } };
|
|
3133
|
-
const ms =
|
|
3606
|
+
pause() { if (t && trip === t) { t.paused = true; touch(); } }, resume() { if (t && trip === t) { t.paused = false; touch(); } },
|
|
3607
|
+
stop() { if (t && trip === t) endTrip(); else if (gen === replays) overlay('replay', null); } };
|
|
3608
|
+
const ms = msText, marks = {};
|
|
3134
3609
|
// ponytail: two calls at once on one line share its mark; the one that ends first takes the glow off
|
|
3135
3610
|
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 } }); };
|
|
3136
|
-
if (!hops.length ||
|
|
3611
|
+
if (!hops.length || still()) { hops.forEach(h => show(h, false)); done(hops.length > 0); return ctl; }
|
|
3137
3612
|
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);
|
|
3138
3613
|
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)); });
|
|
3139
3614
|
const total = Math.max(...hops.map(h => Math.max(h.end, h.start + h.travel)));
|
|
3140
|
-
|
|
3615
|
+
const g = tripLayer();
|
|
3141
3616
|
t = startTrip(now => { hops.forEach(h => {
|
|
3142
3617
|
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; }
|
|
3143
3618
|
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); } }
|
|
3144
3619
|
if (h.on && !h.off && now >= h.end) { h.off = true; show(h, false); } });
|
|
3145
3620
|
return now < total; }, played => { if (!played) overlay('replay', null); done(played); });
|
|
3146
|
-
t.dot = g;
|
|
3621
|
+
t.dot = g; t.kind = 'replay'; touch();
|
|
3147
3622
|
return ctl;
|
|
3148
3623
|
}
|
|
3149
3624
|
|
|
3150
3625
|
/* ---------- stories (#69): the model's narrated walk-throughs, played step by step ----------
|
|
3151
3626
|
A step sets the whole picture: its view, camera, selection, highlight and caption (what it leaves out is cleared),
|
|
3152
3627
|
then plays its tour. Playing goes on by itself after each step's duration; the caption bar and the keys (← → PageUp
|
|
3153
|
-
PageDown, Esc) step through and stop it. One story at a time.
|
|
3628
|
+
PageDown, Esc) step through and stop it. One story at a time.
|
|
3629
|
+
The player's state (0.14.0): entering a step (its view, camera, selection and highlight on their way), then playing or
|
|
3630
|
+
paused on it, until ended or stopped. A story event comes on each of these changes. The reader taking the map (a press,
|
|
3631
|
+
a key, a view or a selection the story didn't make) pauses it; a step lights its own layer, 'story'. */
|
|
3154
3632
|
let story = null;
|
|
3633
|
+
const stepsOf = s => s && Array.isArray(s.steps) ? s.steps.filter(x => x && typeof x === 'object') : [];
|
|
3634
|
+
// the story event, on every change: a step entered, a pause or a resume (playing), the end (step null), destroy() too
|
|
3635
|
+
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);
|
|
3155
3636
|
// 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
|
|
3156
3637
|
// object is a Ref already
|
|
3157
3638
|
const stepRef = x => typeof x !== 'string' ? (x && typeof x === 'object' ? x : null) : geo.byId[x] ? nodeRef(x)
|
|
@@ -3175,35 +3656,53 @@ export function render(container, model, options = {}) {
|
|
|
3175
3656
|
bar.setAttribute('aria-label', 'Story: ' + (st.s.title || st.s.id));
|
|
3176
3657
|
bar.querySelector('.um-st').textContent = `${st.s.title || st.s.id} · ${k + 1} / ${n}`;
|
|
3177
3658
|
bar.querySelector('p').textContent = (st.steps[k] || {}).caption || '';
|
|
3178
|
-
b('prev').disabled
|
|
3659
|
+
if (k <= 0) b('prev').setAttribute('aria-disabled', 'true'); else b('prev').removeAttribute('aria-disabled'); // off, and focusable still: a focused ‹ keeps the focus
|
|
3179
3660
|
b('play').textContent = st.auto ? '❚❚' : '▶'; b('play').setAttribute('aria-label', st.auto ? 'Pause' : 'Play'); b('play').title = st.auto ? 'Pause' : 'Play';
|
|
3180
3661
|
b('next').textContent = k + 1 < n ? '›' : '✓'; b('next').setAttribute('aria-label', k + 1 < n ? 'Next step' : 'Finish the story');
|
|
3181
3662
|
}
|
|
3182
3663
|
async function showStep(st, k) {
|
|
3183
3664
|
const seq = ++st.seq, step = st.steps[k] || {}, live = () => story === st && st.seq === seq && !dead;
|
|
3184
|
-
st.at = k; clearTimeout(st.timer); tour(null); storyBar(st);
|
|
3185
|
-
|
|
3665
|
+
st.at = k; st.phase = 'entering'; clearTimeout(st.timer); tour(null); storyBar(st);
|
|
3666
|
+
storyEvent(st); // before the step's own select and viewchange
|
|
3186
3667
|
while (busy && live()) await new Promise(r => setTimeout(r, 30)); // a move the user started: let it land first
|
|
3187
3668
|
if (!live()) return;
|
|
3188
|
-
if (step.view !== cur && ctx.V[step.view]) await go(step.view);
|
|
3669
|
+
if (step.view !== cur && ctx.V[step.view]) await go(step.view, { story: true });
|
|
3189
3670
|
if (!live()) return;
|
|
3190
|
-
|
|
3191
|
-
|
|
3192
|
-
|
|
3193
|
-
const
|
|
3194
|
-
|
|
3195
|
-
|
|
3671
|
+
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()
|
|
3672
|
+
if ((expanded[cur] || []).length) { expanded[cur] = []; update(current, false, true); } // a step sets the whole picture: what the reader unfolded here folds back first
|
|
3673
|
+
const c = step.camera && typeof step.camera === 'object' ? step.camera : null;
|
|
3674
|
+
const aim = () => { const n = c && c.card != null ? geo.byId[c.card] : null; // the step's camera, on the picture as it is now
|
|
3675
|
+
return n ? { cx: n.x + n.w / 2, cy: n.y + n.h / 2, k: +c.zoom || 1.5 }
|
|
3676
|
+
: 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
|
|
3677
|
+
const moved = cam.animate(aim()); cam.touched = !!(c && (geo.byId[c.card] || Number.isFinite(+c.x)));
|
|
3678
|
+
select(step.select == null ? null : stepRef(step.select), { reveal: false, story: true });
|
|
3679
|
+
highlight(stepLit(step.highlight), 'story');
|
|
3196
3680
|
say(`Step ${k + 1} of ${st.steps.length}${step.caption ? ': ' + step.caption : ''}`); // after select(), which says what it selected
|
|
3197
|
-
await moved;
|
|
3681
|
+
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
|
|
3198
3682
|
if (!live()) return;
|
|
3199
|
-
st.
|
|
3683
|
+
st.phase = 'shown'; touch();
|
|
3684
|
+
st.toured = step.tour ? tour(step.tour, {}, true) : Promise.resolve(true);
|
|
3200
3685
|
arm(st);
|
|
3201
3686
|
}
|
|
3202
3687
|
function arm(st) { // playing: the next step once this one's duration is up and its tour has reached its end
|
|
3203
3688
|
clearTimeout(st.timer);
|
|
3204
|
-
if (!st.auto || story !== st) return;
|
|
3689
|
+
if (!st.auto || story !== st || st.phase !== 'shown' || paused) return; // entering: it arms once shown; the page's Pause: once it ends
|
|
3205
3690
|
const seq = st.seq, dur = Math.max(500, +(st.steps[st.at] || {}).duration || 4000); // reading time: not cut by reduced motion
|
|
3206
|
-
Promise.all([st.toured, new Promise(r => { st.timer = later(r, dur); })]).then(() => { if (story === st && st.seq === seq && st.auto) stepStory(1); });
|
|
3691
|
+
Promise.all([st.toured, new Promise(r => { st.timer = later(r, dur); })]).then(() => { if (story === st && st.seq === seq && st.auto && !paused) stepStory(1); });
|
|
3692
|
+
}
|
|
3693
|
+
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)
|
|
3694
|
+
// 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
|
|
3695
|
+
function readerMoved() { const st = story; if (!st) return; st.taken = st.seq; if (st.auto) pauseStory(st); }
|
|
3696
|
+
stage.addEventListener('pointerdown', e => { if (!e.target.closest('.um-story')) readerMoved(); }, true); // not the bar's own buttons
|
|
3697
|
+
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)
|
|
3698
|
+
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
|
|
3699
|
+
const st = story; if (!st) return;
|
|
3700
|
+
const s = arrOf(current.stories).find(x => x && x.id === st.s.id), steps = stepsOf(s);
|
|
3701
|
+
if (!steps.length) return stopStory();
|
|
3702
|
+
const was = JSON.stringify(st.steps[st.at]), k = Math.min(st.at, steps.length - 1);
|
|
3703
|
+
st.s = s; st.steps = steps;
|
|
3704
|
+
if (st.phase !== 'left' && (k !== st.at || JSON.stringify(steps[k]) !== was)) showStep(st, k); // its step changed: shown again, as it is now
|
|
3705
|
+
else { st.at = k; storyBar(st); touch(); } // the same step (or one the reader left: the bar keeps within the model's steps)
|
|
3207
3706
|
}
|
|
3208
3707
|
function stepStory(d) {
|
|
3209
3708
|
const st = story; if (!st) return;
|
|
@@ -3213,9 +3712,11 @@ export function render(container, model, options = {}) {
|
|
|
3213
3712
|
}
|
|
3214
3713
|
function stopStory(told = false) {
|
|
3215
3714
|
const st = story; if (!st) return;
|
|
3216
|
-
story = null; clearTimeout(st.timer); tour(null); highlight(null);
|
|
3217
|
-
const bar = stage.querySelector('.um-story');
|
|
3218
|
-
if (
|
|
3715
|
+
story = null; st.told = told; clearTimeout(st.timer); tour(null); highlight(null, 'story');
|
|
3716
|
+
const bar = stage.querySelector('.um-story');
|
|
3717
|
+
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
|
|
3718
|
+
if (!dead) say(told ? 'End of the story' : 'Story closed');
|
|
3719
|
+
storyEvent(st); touch(); // destroy() too: a listener hears the end
|
|
3219
3720
|
st.end(told);
|
|
3220
3721
|
}
|
|
3221
3722
|
function storyKey(e) { // null: not a story's key; on the bar's own buttons, Enter and Space press them
|
|
@@ -3227,52 +3728,85 @@ export function render(container, model, options = {}) {
|
|
|
3227
3728
|
return null;
|
|
3228
3729
|
}
|
|
3229
3730
|
function play(id, o = {}) {
|
|
3731
|
+
if (id == null) { stopStory(); return null; } // play(null): the story shown stops
|
|
3732
|
+
const s = arrOf(current.stories).find(x => x && x.id === id), steps = stepsOf(s);
|
|
3733
|
+
if (dead || !geo || !steps.length) return null; // a story the model lacks: the one shown plays on
|
|
3230
3734
|
stopStory();
|
|
3231
|
-
const
|
|
3232
|
-
if (dead || !geo || !steps.length) return null;
|
|
3233
|
-
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; }) };
|
|
3735
|
+
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; }) };
|
|
3234
3736
|
st.end = end;
|
|
3235
|
-
st.ctl = { get story() { return s.id; }, get step() { return st.at; }, get steps() { return steps.length; }, get playing() { return st.auto && story === st; },
|
|
3737
|
+
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; },
|
|
3738
|
+
get state() { return story !== st ? (st.told ? 'ended' : 'stopped') : st.phase === 'entering' ? 'entering' : st.auto ? 'playing' : 'paused'; },
|
|
3236
3739
|
next() { if (story === st) stepStory(1); }, prev() { if (story === st) stepStory(-1); }, stop() { if (story === st) stopStory(); },
|
|
3237
|
-
pause() { if (story
|
|
3238
|
-
resume() { if (story !== st || st.auto) return; st.auto = true; storyBar(st); arm(st); }, done: st.done };
|
|
3740
|
+
pause() { if (story === st && st.auto) pauseStory(st); },
|
|
3741
|
+
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 };
|
|
3239
3742
|
story = st;
|
|
3240
3743
|
showStep(st, Math.max(0, Math.min(steps.length - 1, Math.floor(+o.step) || 0)));
|
|
3241
3744
|
return st.ctl;
|
|
3242
3745
|
}
|
|
3243
3746
|
|
|
3244
3747
|
const inner = { ctx: () => ctx, geo: () => geo, cam: () => cam, svg: () => svg, stage, host, go, open, up, flash, setFlow, pause, key, startView, standalone, play,
|
|
3245
|
-
select, busy: () => busy, flowing: () => stage.classList.contains('flowing'), view: () => cur, unfold: gesture
|
|
3748
|
+
select, busy: () => busy, flowing: () => stage.classList.contains('flowing'), view: () => cur, unfold: gesture, lens: setLens,
|
|
3749
|
+
highlight, overlay, focus: focusMap, editable: !!E }; // a story's pointer is the pieces' own, from the story event (0.14.0)
|
|
3750
|
+
|
|
3751
|
+
// the pieces a host asked for (#99): they follow the map's selection and views, and take their keys wherever the focus is
|
|
3752
|
+
// in the map (Enter and Space press their buttons; the map's other keys reach the canvas, as in the page)
|
|
3753
|
+
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 });
|
|
3754
|
+
if (pc) {
|
|
3755
|
+
(subs.select ||= []).push(e => pc.select(e.target, e.targets)); (subs.viewchange ||= []).push(pc.unlight); (subs.story ||= []).push(pc.story);
|
|
3756
|
+
frame.addEventListener('keydown', e => { if (e.defaultPrevented || e.metaKey || e.ctrlKey || e.altKey) return;
|
|
3757
|
+
if (pc.key(e) || (!stage.contains(e.target) && e.key !== 'Enter' && e.key !== ' ' && key(e))) { e.preventDefault(); e.stopPropagation(); } });
|
|
3758
|
+
if (options.nav) { q('.um-navbtn').addEventListener('click', () => pc.setTree(!frame.classList.contains('tree-open'))); pc.setTree(ctx.views.length > 1); }
|
|
3759
|
+
}
|
|
3760
|
+
const motionOf = () => instant ? 'instant' : reducedMotion() ? 'reduced' : 'full';
|
|
3761
|
+
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 }; };
|
|
3762
|
+
function snapshot(event = false) { // MapState: built fresh on every read, frozen all the way down, plain JSON; event: the camera at rest
|
|
3763
|
+
const deep = o => { Object.values(o).forEach(v => v && typeof v === 'object' && deep(v)); return Object.freeze(o); }, st = story, t = trip;
|
|
3764
|
+
return deep({ view: cur, navigating: nav ? nav.to : null, selection: picked(), expanded: [...(expanded[cur] || [])], lens: lensId, overlays: [...layers.keys()],
|
|
3765
|
+
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,
|
|
3766
|
+
camera: event && camRest ? { ...camRest } : camNow(), flow: stage.classList.contains('flowing'), motion: motionOf(), gesture: gestureNow(), destroyed: dead });
|
|
3767
|
+
}
|
|
3246
3768
|
const first = () => {
|
|
3247
3769
|
if (dead) return;
|
|
3248
|
-
|
|
3249
|
-
if (!
|
|
3250
|
-
|
|
3251
|
-
|
|
3252
|
-
|
|
3770
|
+
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'}`);
|
|
3771
|
+
if (!cam) { // a view shown already (a setView() before a deferred first draw) stays
|
|
3772
|
+
const v0 = ctx.V[options.view] ? options.view : ctx.rootId;
|
|
3773
|
+
mount(v0 ?? null); cam.set(startView()); cam.touched = false; // no views: the empty card, until an update() brings one
|
|
3774
|
+
if (v0 && options.select) select(options.select, { reveal: false });
|
|
3775
|
+
}
|
|
3776
|
+
if (cur && P.onReady) P.onReady();
|
|
3253
3777
|
};
|
|
3254
3778
|
if (P.defer) requestAnimationFrame(() => later(first, 16)); else first();
|
|
3779
|
+
camRest = camNow(); if (listensState()) lastState = snapshot(true); // the state event reports changes from here
|
|
3255
3780
|
// the host's fonts may still be loading: measure again once they are in, and redraw
|
|
3256
|
-
if (measure && !own && document.fonts && document.fonts.status !== 'loaded') document.fonts.ready.then(() => { if (dead) return; measure = hostMeasure(host); update(current, true); });
|
|
3781
|
+
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); });
|
|
3257
3782
|
|
|
3258
3783
|
return {
|
|
3259
3784
|
update: m => update(m),
|
|
3260
|
-
setView: id => go(id),
|
|
3261
|
-
select: ref => select(ref),
|
|
3262
|
-
fit: () => { if (cam) cam.fit(); },
|
|
3785
|
+
setView: id => go(id), // a Promise<boolean>: true once the view shows; false for a view the model lacks, a later request, or destroy()
|
|
3786
|
+
select: ref => { if (!dead) select(ref); },
|
|
3787
|
+
fit: () => { if (cam && !dead) { readerMoved(); cam.fit(); } }, // the host takes the camera: a story playing waits
|
|
3263
3788
|
toSVG: (o = {}) => dead ? '' : standalone(o.scale ?? 1, o.view ?? cur, o.fontCSS),
|
|
3264
3789
|
toPNG: (o = {}) => dead ? Promise.resolve(null) : pngOf(standalone(o.scale ?? 2, o.view ?? cur, o.fontCSS)),
|
|
3265
3790
|
focus: ref => { if (!dead) focusMap(ref); },
|
|
3266
3791
|
expand: id => setUnfold(id, true), // unfold a stacked card of the view shown in place (#71): true when it did
|
|
3267
3792
|
collapse: id => setUnfold(id, false),
|
|
3268
3793
|
get expanded() { return [...(expanded[cur] || [])]; },
|
|
3269
|
-
on(type, fn) { (
|
|
3794
|
+
on(type, fn) { if (dead) return () => {}; if (type === 'state' && !listensState()) { camRest = camNow(); lastState = snapshot(true); } // a first state listener hears changes from now
|
|
3795
|
+
(subs[type] ||= []).push(fn); if (type === 'click') tabKeys();
|
|
3270
3796
|
return () => { subs[type] = (subs[type] || []).filter(f => f !== fn); if (type === 'click' && !dead) tabKeys(); }; },
|
|
3271
3797
|
get view() { return cur; },
|
|
3272
|
-
get selection() { return sel; },
|
|
3798
|
+
get selection() { return copyRef(sel); },
|
|
3273
3799
|
get selected() { return picked(); },
|
|
3800
|
+
get state() { return snapshot(); }, // MapState (0.14.0): a fresh, frozen, JSON-serializable snapshot
|
|
3801
|
+
get navigating() { return nav ? nav.to : null; }, // the view a move is going to, while one is under way
|
|
3802
|
+
get destroyed() { return dead; },
|
|
3803
|
+
get flow() { return stage.classList.contains('flowing'); }, // the animated flow along lines (≋, F)
|
|
3804
|
+
set flow(on) { if (!dead) setFlow(!!on, false); }, // a host's choice, not the reader's: the page saves only ≋ and F
|
|
3805
|
+
get motion() { return motionOf(); }, // 'instant' (the page's #check, #still, #shot), 'reduced' (the reader's setting), else 'full'
|
|
3806
|
+
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
|
|
3274
3807
|
overlay: (name, layer) => overlay(name, layer),
|
|
3275
|
-
highlight: refs => highlight(refs),
|
|
3808
|
+
highlight: (refs, options) => highlight(refs, (options && options.layer) || 'highlight'),
|
|
3809
|
+
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
|
|
3276
3810
|
tour: (path, o) => tour(path, o),
|
|
3277
3811
|
replay: (spans, o) => replay(spans, o),
|
|
3278
3812
|
play: (id, o) => play(id, o),
|
|
@@ -3372,35 +3906,123 @@ export function otlpSpans(data) {
|
|
|
3372
3906
|
service, name: s.name, start: Number(s.startTimeUnixNano) / 1e6, end: Number(s.endTimeUnixNano) / 1e6 }))); });
|
|
3373
3907
|
return out;
|
|
3374
3908
|
}
|
|
3909
|
+
// a service's card (#65, #96): the card `match` names, else the card with that id, else the one with that label (any case)
|
|
3910
|
+
const cardMatcher = (cards, match) => { const m = match || {}, ids = new Set(cards.map(n => String(n.id))), low = x => String(x ?? '').trim().toLowerCase();
|
|
3911
|
+
return s => s == null ? null : Object.hasOwn(m, s) && m[s] != null ? (ids.has(String(m[s])) ? String(m[s]) : null)
|
|
3912
|
+
: ids.has(String(s)) ? String(s) : (n => n ? String(n.id) : null)(cards.find(n => low(n.label) === low(s))); };
|
|
3913
|
+
// a duration in words, as replay() labels a line and a trace's view says a step's: "37 ms", "1.2 s"
|
|
3914
|
+
const msText = x => x >= 1000 ? `${+(x / 1000).toFixed(x >= 1e4 ? 0 : 1)} s` : `${x >= 10 ? Math.round(x) : +x.toFixed(1)} ms`;
|
|
3915
|
+
|
|
3916
|
+
/** The path along a view's lines with the highest total of `by` (#95): `latency` (ms, the default) or `cost`, summed over
|
|
3917
|
+
* its lines (a line without one counts 0). It starts at `from` and ends at `to`, either left out for any card, and never
|
|
3918
|
+
* comes back to a card. { refs, total }: its cards in order, then its lines, as Refs for highlight(); no refs and 0 when
|
|
3919
|
+
* no line leads from `from` (to `to`). */
|
|
3920
|
+
export function critical(model, viewId, options = {}) {
|
|
3921
|
+
const { ids, edges } = lineGraph(model, viewId), by = options.by === 'cost' ? 'cost' : 'latency', { from, to } = options, none = { refs: [], total: 0 };
|
|
3922
|
+
if ((from != null && !ids.includes(from)) || (to != null && !ids.includes(to))) return none;
|
|
3923
|
+
const w = e => typeof e[by] === 'number' && e[by] > 0 ? e[by] : 0, path = [], on = new Set();
|
|
3924
|
+
let best = null;
|
|
3925
|
+
// ponytail: every path, depth first: exponential on a dense view with many ways between two cards; a longest-path pass
|
|
3926
|
+
// over the lines in run order (few loop back) if a map's size makes the page wait
|
|
3927
|
+
const walk = (u, sum) => { on.add(u);
|
|
3928
|
+
edges.forEach(e => { if (e.from !== u || on.has(e.to)) return;
|
|
3929
|
+
const s = sum + w(e); path.push(e);
|
|
3930
|
+
if ((to == null ? ids.includes(e.to) : e.to === to) && (!best || s > best.total)) best = { total: s, lines: [...path] };
|
|
3931
|
+
if (e.to !== to) walk(e.to, s);
|
|
3932
|
+
path.pop(); });
|
|
3933
|
+
on.delete(u); };
|
|
3934
|
+
(from != null ? [from] : ids).forEach(id => walk(id, 0));
|
|
3935
|
+
if (!best) return none;
|
|
3936
|
+
const cards = [best.lines[0].from, ...best.lines.map(e => e.to)].filter(id => ids.includes(id));
|
|
3937
|
+
return { refs: [...cards.map(id => ({ type: 'node', id, view: viewId })), ...best.lines.map(e => ({ type: 'edge', ids: [e._id], view: viewId }))],
|
|
3938
|
+
total: +best.total.toPrecision(12) };
|
|
3939
|
+
}
|
|
3940
|
+
|
|
3941
|
+
/** What taking cards out would cost (#95), with the model left as it is: `unreachable`, what loses its way to them in the
|
|
3942
|
+
* view (backward, as reach() finds it: for calls, what depends on them), each with its distance in lines from the nearest,
|
|
3943
|
+
* the cards themselves at 0. No rule breaks: rules only forbid lines and cycles, which taking cards out never adds. */
|
|
3944
|
+
export function whatIf(model, viewId, options = {}) {
|
|
3945
|
+
const gone = new Set([].concat(options.remove ?? []).map(String)), near = new Map();
|
|
3946
|
+
gone.forEach(id => reach(model, viewId, id, { backward: true }).forEach(r => { const k = r.type === 'node' ? r.id : '>' + r.ids[0];
|
|
3947
|
+
if (!near.has(k) || r.distance < near.get(k).distance) near.set(k, r); }));
|
|
3948
|
+
return { unreachable: [...near.values()].sort((a, b) => (a.type === 'edge') - (b.type === 'edge') || a.distance - b.distance) };
|
|
3949
|
+
}
|
|
3950
|
+
|
|
3951
|
+
/** A trace kept as a numbered view (#96): `model` with a sequence view (`render: "sequence"`) made from `spans` (as
|
|
3952
|
+
* otlpSpans() reads them). Each call from one card to another (a span and its parent's) is a step, in time order, its
|
|
3953
|
+
* line labelled with the span's name and its duration under it. A span's service is a card as replay() matches it:
|
|
3954
|
+
* through `match`, else by id, else by label (any case); a span with no card, or whose caller has none, is no step, and
|
|
3955
|
+
* the view's caption names the services with no card. The view (`view`, default "trace"; `title`) takes the place of one
|
|
3956
|
+
* with its id, else comes last, under the view that shows the most of its cards. The model passed in is not changed. */
|
|
3957
|
+
export function sequenceFromTrace(model, spans, options = {}) {
|
|
3958
|
+
const out = structuredClone(model || {}), id = String(options.view ?? 'trace'), views = arrOf(out.views);
|
|
3959
|
+
const cards = [...arrOf(out.nodes), ...views.flatMap(v => arrOf(v && v.nodes))].filter(n => n && n.id != null);
|
|
3960
|
+
const list = [].concat(spans || []).filter(s => s && Number.isFinite(+s.start) && Number.isFinite(+s.end));
|
|
3961
|
+
const span = new Map(list.map(s => [String(s.id), s])), cardOf = cardMatcher(cards, options.match), hops = [], lost = new Set();
|
|
3962
|
+
list.forEach(s => { const to = cardOf(s.service); if (!to) { if (s.service != null) lost.add(String(s.service)); return; }
|
|
3963
|
+
const p = s.parent != null ? span.get(String(s.parent)) : null, from = p && cardOf(p.service);
|
|
3964
|
+
if (from && from !== to) hops.push([from, to, s]); }); // a root span, a caller with no card, or work inside one card: no step
|
|
3965
|
+
hops.sort((a, b) => +a[2].start - +b[2].start); // stable: calls that start together keep the trace's order
|
|
3966
|
+
const nodeIds = [...new Set(hops.flatMap(([a, b]) => [a, b]))], n = hops.length, took = s => msText(+s.end - +s.start);
|
|
3967
|
+
const edges = hops.map(([from, to, s], i) => ({ id: `${id}-${i + 1}`, from, to, ...(s.name ? { label: String(s.name), sublabel: took(s) } : { label: took(s) }) }));
|
|
3968
|
+
const home = views.reduce((a, v) => { const k = v && v.id !== id ? nodeIds.filter(x => arrOf(v.nodeIds).includes(x)).length : 0; return k > a[1] ? [v.id, k] : a; }, [null, 0])[0];
|
|
3969
|
+
const view = { id, ...(home != null ? { parent: home } : {}), title: options.title || id,
|
|
3970
|
+
caption: `From a trace: ${n} call${n === 1 ? '' : 's'} between cards, in time order, each with its duration.${lost.size ? ` No card for ${andList([...lost])}.` : ''}`,
|
|
3971
|
+
render: 'sequence', lanes: false, nodeIds, edges };
|
|
3972
|
+
const at = views.findIndex(v => v && v.id === id);
|
|
3973
|
+
out.views = at < 0 ? [...views, view] : views.map((v, k) => k === at ? view : v);
|
|
3974
|
+
return out;
|
|
3975
|
+
}
|
|
3976
|
+
|
|
3977
|
+
/* a lens (#94): what it lights on a view, as a layer of marks. Without `line`, the cards its pick picks and the lines
|
|
3978
|
+
between two of them; with `line` (line kinds), the lines of those kinds between two cards the rest of it picks, and the
|
|
3979
|
+
cards at their ends. The rest dims. With color 'owner', each lit card is ringed in its owner's tone, the same on every
|
|
3980
|
+
view (owners in name order); `owners`: the view's lit owners and their tones, for a legend. */
|
|
3981
|
+
const OWNER_TONES = ['cyan', 'violet', 'green', 'amber', 'fuchsia', 'orange', 'rose', 'slate'];
|
|
3982
|
+
function lensOf(ctx, vid, lens) {
|
|
3983
|
+
const v = ctx.V[vid], s = lens.pick && typeof lens.pick === 'object' && !Array.isArray(lens.pick) ? lens.pick : {}, { end } = endsIn(ctx, v), ok = id => picks(s, end(id));
|
|
3984
|
+
const kinds = s.line == null ? null : [s.line].flat(), cards = arrOf(v.nodeIds).filter(id => ctx.N[id]), all = ctx.viewEdges(v);
|
|
3985
|
+
const lines = all.filter(e => (!kinds || kinds.includes(WIRE_KINDS.includes(e.kind) ? e.kind : 'calls')) && ok(e.from) && ok(e.to));
|
|
3986
|
+
const lit = new Set(kinds ? lines.flatMap(e => [e.from, e.to]) : cards.filter(ok));
|
|
3987
|
+
// ponytail: eight tones, so the ninth owner shares the first's; a host that needs more rings cards itself with overlay()
|
|
3988
|
+
const names = [...new Set([...ctx.nodes, ...ctx.views.flatMap(w => Object.values(w.nodeOverrides || {}))].map(n => n && n.owner).filter(o => typeof o === 'string'))].sort();
|
|
3989
|
+
const tone = o => lens.color === 'owner' && names.includes(o) ? OWNER_TONES[names.indexOf(o) % OWNER_TONES.length] : null;
|
|
3990
|
+
const nodes = Object.fromEntries(cards.filter(id => lit.has(id)).map(id => [id, tone(end(id).owner) ? { tone: tone(end(id).owner) } : {}]));
|
|
3991
|
+
const owners = [...new Set(Object.keys(nodes).map(id => end(id).owner).filter(tone))].sort().map(o => [o, tone(o)]);
|
|
3992
|
+
const dim = Object.keys(nodes).length < cards.length || lines.length < all.length; // a lens that lights everything leaves the selection its own dimming
|
|
3993
|
+
return { layer: { nodes, edges: Object.fromEntries(lines.map(e => [e._id, {}])), dim }, owners };
|
|
3994
|
+
}
|
|
3375
3995
|
|
|
3376
3996
|
export function mountPage(container, model, options = {}) {
|
|
3377
3997
|
const Q = options.params || new URLSearchParams();
|
|
3378
|
-
if (options.instant) INSTANT = true;
|
|
3379
3998
|
document.body.classList.add('map-mode');
|
|
3380
|
-
let { D, views, nodes, N, V, rootId, nodeIn: nodeIn0,
|
|
3381
|
-
const nodeIn = (nid, vid = cur) => nodeIn0(nid, vid)
|
|
3382
|
-
const shortT = t => String(t || '').split(' · ')[0];
|
|
3383
|
-
const root = D.meta && D.meta.root ? D.meta.root.replace(/\/$/, '') : '';
|
|
3384
|
-
const base = D.sourcesBase ? String(D.sourcesBase).replace(/\/$/, '') + '/' : '';
|
|
3385
|
-
const srcPath = p => p.replace(/:\d+(-\d+)?$/, '');
|
|
3386
|
-
// meta.root is the repo root relative to this page (older pages: absolute). `~/…` sources live
|
|
3387
|
-
// outside the repo, so they get no link.
|
|
3388
|
-
// meta.sourceBase (render --source-base): in-pack sources were rewritten to URLs under it and
|
|
3389
|
-
// show as their pack-relative path; any other relative source gets no link on a published page.
|
|
3390
|
-
const sb = D.meta && D.meta.sourceBase ? String(D.meta.sourceBase) : '';
|
|
3391
|
-
const srcLabel = p => sb && p.startsWith(sb) ? decodeURI(p.slice(sb.length)).replace(/#L(\d+)(?:-L(\d+))?$/, (_, a, b) => ':' + a + (b ? '-' + b : '')) : p;
|
|
3392
|
-
const srcHref = p => /^https?:/.test(p) ? p
|
|
3393
|
-
: p.startsWith('~') || sb ? ''
|
|
3394
|
-
: p.startsWith('/') ? 'file://' + encodeURI(srcPath(p))
|
|
3395
|
-
: root.startsWith('/') ? 'file://' + encodeURI(root + '/' + base + srcPath(p))
|
|
3396
|
-
: encodeURI((root ? root + '/' : '') + base + srcPath(p));
|
|
3397
|
-
const srcList = n => (Array.isArray(n.source) ? n.source : n.source ? [n.source] : []);
|
|
3999
|
+
let { D, views, nodes, N, V, rootId, nodeIn: nodeIn0, subjectOf, viewsWith, kidsOf, chain, viewEdges, planCache } = mapContext(model);
|
|
4000
|
+
const nodeIn = (nid, vid = cur) => nodeIn0(nid, vid);
|
|
3398
4001
|
const mac = /Mac|iPhone|iPad/.test(navigator.platform || navigator.userAgent || '');
|
|
3399
4002
|
const modK = mac ? '⌘K' : 'Ctrl K';
|
|
3400
|
-
const
|
|
3401
|
-
const HINT = touch ? '<span>pinch = zoom</span><span>drag = move</span><span>tap = details</span>'
|
|
3402
|
-
: `<span>scroll = zoom</span><span>drag = move</span><span>double-click ⤢ = open</span><span>${modK} = search</span><span>? = keys</span>`;
|
|
4003
|
+
const HINT = hintHTML(modK);
|
|
3403
4004
|
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('');
|
|
4005
|
+
// 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
|
|
4006
|
+
// the page reads itself, those keys and what they do; the rest are the map's own, which reach its canvas (the router)
|
|
4007
|
+
const KEYS = [
|
|
4008
|
+
['/ or ' + modK, 'search nodes and views', ['/'], () => openPal()],
|
|
4009
|
+
['Scroll · pinch', 'zoom (two-finger drag pans)'],
|
|
4010
|
+
['Click', 'details below the map'],
|
|
4011
|
+
['Double-click · Enter', 'open a stacked node ⤢'],
|
|
4012
|
+
['E · Alt + click', 'unfold a stacked node here, or fold it back ⤡'],
|
|
4013
|
+
['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)],
|
|
4014
|
+
['+ − 0 1', 'zoom in, out, fit, 100%'],
|
|
4015
|
+
['← ↑ → ↓', 'pan'],
|
|
4016
|
+
['[ ]', 'previous / next details tab', ['[', ']'], e => pc.key(e)],
|
|
4017
|
+
['i', 'about this view', ['i', 'I'], () => togglePill()],
|
|
4018
|
+
['O', 'views list', ['o', 'O'], e => pc.key(e)],
|
|
4019
|
+
['F', 'flow on/off'],
|
|
4020
|
+
['> <', 'light what a card leads to, what leads to it', ['>', '<'], e => pc.key(e)],
|
|
4021
|
+
['Shift-click · P', 'light every path between two cards', ['p', 'P'], e => pc.key(e)],
|
|
4022
|
+
['← → · PgUp PgDn', 'in a story: previous / next step'],
|
|
4023
|
+
['?', 'these keys', ['?'], () => toggleHelp()],
|
|
4024
|
+
];
|
|
4025
|
+
const ac = new AbortController(), signal = ac.signal; // the page's listeners on the document and window: destroy() takes them away
|
|
3404
4026
|
|
|
3405
4027
|
const app = container; app.className = 'mapapp';
|
|
3406
4028
|
app.innerHTML = `
|
|
@@ -3410,8 +4032,10 @@ export function mountPage(container, model, options = {}) {
|
|
|
3410
4032
|
<nav class="crumbs" id="m-crumbs" aria-label="Breadcrumb"></nav>
|
|
3411
4033
|
<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>
|
|
3412
4034
|
<button class="ibtn" id="m-theme" title="Light / dark" aria-label="Toggle theme">◐</button>${(D.stories || []).length ? `
|
|
3413
|
-
<div class="menu"><button class="ibtn txt" id="m-stories" aria-haspopup="true" aria-expanded="false" title="Stories: walk-throughs of this map"
|
|
3414
|
-
<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>` : ''}
|
|
4035
|
+
<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>
|
|
4036
|
+
<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) ? `
|
|
4037
|
+
<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>
|
|
4038
|
+
<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>` : ''}
|
|
3415
4039
|
<div class="menu"><button class="pbtn" id="m-export" aria-haspopup="true" aria-expanded="false">Export ▾</button>
|
|
3416
4040
|
<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>
|
|
3417
4041
|
<button class="ibtn" id="m-pause" type="button" title="Pause" aria-label="Pause" aria-pressed="false">❚❚</button>
|
|
@@ -3429,19 +4053,17 @@ export function mountPage(container, model, options = {}) {
|
|
|
3429
4053
|
<aside class="panel" id="m-panel" aria-label="Node details" hidden></aside>
|
|
3430
4054
|
</div>
|
|
3431
4055
|
<div class="hpop" id="m-helpbox" role="dialog" aria-label="Keys" hidden><b>Keys</b>
|
|
3432
|
-
<dl
|
|
3433
|
-
<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>
|
|
3434
|
-
<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>
|
|
3435
|
-
<dt>← → · PgUp PgDn</dt><dd>in a story: previous / next step (Esc closes it)</dd></dl></div>
|
|
4056
|
+
<dl>${KEYS.map(([k, d]) => `<dt>${esc(k)}</dt><dd>${esc(d)}</dd>`).join('')}</dl></div>
|
|
3436
4057
|
<div class="scrim" id="m-pal" hidden><div class="pal" role="dialog" aria-label="Search">
|
|
3437
4058
|
<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>
|
|
3438
4059
|
<div class="res" id="m-res" role="listbox"></div>
|
|
3439
4060
|
<div class="pft"><span>↑↓ move</span><span>↵ open</span><span>esc close</span></div></div></div>`;
|
|
3440
4061
|
const $ = id => document.getElementById(id);
|
|
3441
4062
|
const stage = $('m-stage'), panel = $('m-panel');
|
|
3442
|
-
let X = null, cam = null, geo = null, svg = null, cur = null, sel = null, stack = [], paused = false
|
|
3443
|
-
|
|
3444
|
-
|
|
4063
|
+
let X = null, cam = null, geo = null, svg = null, cur = null, sel = null, stack = [], paused = false;
|
|
4064
|
+
const status = hinter($('m-hint'), HINT);
|
|
4065
|
+
// the views list, the legend and the details panel (#99): the same pieces render() draws for a host
|
|
4066
|
+
const pc = pieces(() => X, { tree: $('m-tree'), legend: $('m-legend'), panel }, { page: true, frame: document.body, toggle: $('m-outline'), layer: document.body, status });
|
|
3445
4067
|
|
|
3446
4068
|
function renderChrome() {
|
|
3447
4069
|
const v = V[cur];
|
|
@@ -3449,7 +4071,7 @@ export function mountPage(container, model, options = {}) {
|
|
|
3449
4071
|
? `<span class="crumb cur" aria-current="page">${esc(V[id].title || id)}</span>`
|
|
3450
4072
|
: `<button class="crumb" data-v="${esc(id)}">${esc(shortT(V[id].title || id))}</button><span class="crumb-sep">›</span>`).join('');
|
|
3451
4073
|
const parent = stack.length > 1 ? stack[stack.length - 2] : v.parent;
|
|
3452
|
-
renderPill(parent);
|
|
4074
|
+
renderPill(parent); pc.chrome();
|
|
3453
4075
|
document.title = `${v.title || v.id} · ${D.title || 'map'}`;
|
|
3454
4076
|
}
|
|
3455
4077
|
function renderPill(parent) {
|
|
@@ -3464,207 +4086,112 @@ export function mountPage(container, model, options = {}) {
|
|
|
3464
4086
|
<div class="vm">${geo.nodes.length} nodes · ${geo.edges.length} wires${views.length > 1 ? ` · level ${chain(cur).length} of ${depth}` : ''}</div></div>`;
|
|
3465
4087
|
}
|
|
3466
4088
|
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); };
|
|
3467
|
-
|
|
3468
|
-
function renderLegend() {
|
|
3469
|
-
const used = [...new Set(geo.nodes.map(n => n.k))];
|
|
3470
|
-
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'];
|
|
3471
|
-
const shape = k => k === 'm-verb' ? 'p' : (k === 'm-ext' || k === 'external') ? 'd' : k === 'm-hook' ? 'h' : '';
|
|
3472
|
-
const items = order.filter(k => used.includes(k)).map(k => `<span class="lgi" title="${esc(KIND[k][1] + ' — ' + KIND[k][2])}"><i class="${shape(k)}" style="border-color:var(--um-${KIND[k][0]});background:var(--um-${KIND[k][0]}-f)${k === 'm-hook' ? `;background:var(--um-${KIND[k][0]})` : ''}"></i><b>${KIND[k][1]}</b><span>${KIND[k][2]}</span></span>`)
|
|
3473
|
-
.concat(geo.nodes.some(n => n.kids || n.opens) ? ['<span class="lgi"><i class="s"></i><b>⤢ Stacked</b><span>double-click to open</span></span>'] : [])
|
|
3474
|
-
.concat(geo.nodes.some(n => n.step) ? ['<span class="lgi"><i class="num">1</i><b>Step</b><span>run order</span></span>'] : [])
|
|
3475
|
-
.concat(geo.nodes.some(n => n.tag) ? ['<span class="lgi"><i class="tag"></i><b>@author</b><span>who wrote it</span></span>'] : [])
|
|
3476
|
-
.concat(WIRES.filter(w => geo.edges.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>`))
|
|
3477
|
-
.concat(geo.edges.some(e => e.back) ? ['<span class="lgi"><i class="w dash" style="border-color:var(--um-w-calls)"></i><b>loops back</b><span>the one line that runs right to left</span></span>'] : []);
|
|
3478
|
-
const el = $('m-legend');
|
|
3479
|
-
el.hidden = !items.length;
|
|
3480
|
-
el.classList.toggle('folded', legendFolded);
|
|
3481
|
-
el.innerHTML = `<div class="t"><span>LEGEND</span><button type="button" id="m-lfold" aria-label="${legendFolded ? 'Show' : 'Hide'} legend" aria-expanded="${!legendFolded}">${legendFolded ? '+' : '–'}</button></div>${items.join('')}`;
|
|
3482
|
-
}
|
|
3483
|
-
function renderTree() { // Readable columns; a declared order may include shortcuts to descendants.
|
|
3484
|
-
const ch = chain(cur);
|
|
3485
|
-
const roots = views.filter(x => !x.parent), cols = roots.length > 1 ? [{ head: 'Map', items: roots, on: ch[0] }] : [];
|
|
3486
|
-
ch.forEach((id, i) => { const order = V[id].browserOrder || [], k = [...new Set([...order, ...kidsOf(id).map(v => v.id)])].map(id => V[id]).filter(Boolean); if (k.length) cols.push({ head: shortT(V[id].title || id), items: k, on: ch[i + 1] }); });
|
|
3487
|
-
document.body.style.setProperty('--browser-width', Math.min(448, Math.max(1, cols.length) * 224) + 'px');
|
|
3488
|
-
$('m-tree').innerHTML = `<div class="miller">${cols.map(c => `<div class="mcol"><div class="mch" title="${esc(c.head)}">${esc(c.head)}</div>${c.items.map(x => { const n = kidsOf(x.id).length;
|
|
3489
|
-
return `<button class="mit${x.id === cur ? ' on' : x.id === c.on ? ' path' : ''}" data-v="${esc(x.id)}" title="${esc(x.title || x.id)}"${x.id === cur ? ' aria-current="page"' : ''}><span>${esc(shortT(x.title || x.id))}</span><em>${(x.nodeIds || []).length}${n ? ' ›' : ''}</em></button>`; }).join('')}</div>`).join('')}</div>`;
|
|
3490
|
-
const m = $('m-tree').firstChild, last = m.lastElementChild; m.style.scrollBehavior = 'auto'; m.scrollLeft = last ? Math.max(0, last.offsetLeft + last.offsetWidth - m.clientWidth) : 0; m.style.scrollBehavior = '';
|
|
3491
|
-
}
|
|
3492
|
-
const setTree = on => { document.body.classList.toggle('tree-open', on); $('m-outline').setAttribute('aria-expanded', on); };
|
|
3493
|
-
|
|
3494
|
-
// Pull concise points from captured prose; retain wording and keep the full point in its tooltip. A point is a sentence
|
|
3495
|
-
// or a clause; one longer than a line (160 characters) is cut in two, but a short sentence stays whole (#97).
|
|
3496
|
-
function detailBullets(body) {
|
|
3497
|
-
const normalized = String(body || '').replace(/^\s*(?:[-*]|\d+[.)])\s+/gm, '').replace(/\n+/g, ' ').trim();
|
|
3498
|
-
let parts = normalized.split(/(?<=[.!?])\s+(?=[A-Z`/])|;\s+|\s+[·|]\s+/).filter(Boolean);
|
|
3499
|
-
while (parts.some(p => p.length > 160) && parts.length < 4) {
|
|
3500
|
-
const index = parts.reduce((best, p, i) => p.length > (parts[best] || '').length ? i : best, 0), value = parts[index];
|
|
3501
|
-
if (!value || value.length < 8) break;
|
|
3502
|
-
const separators = [...value.matchAll(/:\s+|,\s+|\s+(?=then\b|with\b|before\b|after\b|once\b|to\b|and\b|or\b|during\b|by\b|whose\b|outside\b|on\b|for\b|as\b|whenever\b|into\b|in\b|from\b|when\b|includes\b|typed\b)/g)].map(m => m.index + m[0].length);
|
|
3503
|
-
const safe = i => (value.slice(0, i).match(/`/g) || []).length % 2 === 0;
|
|
3504
|
-
const cuts = separators.filter(i => i >= 6 && i < value.length - 4 && safe(i));
|
|
3505
|
-
if (!cuts.length && parts.length < 2) cuts.push(...[...value.matchAll(/\s+/g)].map(m => m.index).filter(i => i >= 6 && i < value.length - 4 && safe(i)));
|
|
3506
|
-
const cut = cuts.sort((a, b) => Math.abs(a - value.length / 2) - Math.abs(b - value.length / 2))[0];
|
|
3507
|
-
if (!cut) break;
|
|
3508
|
-
parts.splice(index, 1, value.slice(0, cut).replace(/[,;:]\s*$/, '').trim(), value.slice(cut).trim());
|
|
3509
|
-
}
|
|
3510
|
-
if (parts.length > 4) parts = [...parts.slice(0, 3), parts.slice(3).join(' ')];
|
|
3511
|
-
return parts;
|
|
3512
|
-
}
|
|
3513
|
-
/* selection + details strip */
|
|
3514
|
-
/* selection + details strip: the canvas reports, the page shows the panel and records the hash */
|
|
3515
|
-
const select = (nid, opts = {}) => X.select({ type: 'node', id: nid }, opts);
|
|
3516
|
-
function hidePanel() { panel.hidden = true; }
|
|
3517
|
-
const deselect = () => X.select(null);
|
|
3518
|
-
function relRows(list, dir) {
|
|
3519
|
-
if (!list.length) return `<div class="rel-none">${dir === 'up' ? 'Nothing calls this here' : 'Calls nothing here'}</div>`;
|
|
3520
|
-
return '<ul class="relation-list">' + list.map(({ e, id, inView }) => { const o = N[id] || { label: id, kind: '' }, k = kindOf(o.kind, true);
|
|
3521
|
-
return `<li><button class="rel" data-n="${esc(id)}" title="${esc((e.when ? e.when + ' — ' : '') + (inView ? 'Select' : 'Find in another view'))}"><i style="background:var(--um-${KIND[k][0]})"></i><span class="rel-l">${esc(o.label)}</span>
|
|
3522
|
-
<span class="rel-e">${e.step != null ? esc(e.step) + ' · ' : ''}${esc(e.label || '')}${inView ? '' : ' ↗'}</span></button></li>`; }).join('') + '</ul>';
|
|
3523
|
-
}
|
|
3524
|
-
const authorChip = login => { const A = (D.authors || {})[login] || {};
|
|
3525
|
-
return `<a class="author" href="${esc(A.url || 'https://github.com/' + login)}" target="_blank" rel="noopener">${A.avatar ? `<img src="${A.avatar}" alt="">` : `<i>${esc(login.slice(0, 2).toUpperCase())}</i>`}<span>${esc(A.name || login)} <b>@${esc(login)}</b></span></a>`; };
|
|
3526
|
-
function renderPanel(nid) {
|
|
3527
|
-
const n = nodeIn(nid), k = kindOf(n.kind, true), c = `var(--um-${KIND[k][0]})`, pub = k === 'm-public', ext = k === 'm-ext';
|
|
3528
|
-
const inView = new Set(geo.nodes.map(x => x.id)), seen = new Set();
|
|
3529
|
-
const collect = dir => {
|
|
3530
|
-
const out = [];
|
|
3531
|
-
const add = (e, id, iv) => { const key = dir + id + (e.label || ''); if (seen.has(key)) return; seen.add(key); out.push({ e, id, inView: iv }); };
|
|
3532
|
-
geo.edges.forEach(e => { if (dir === 'up' && e.to === nid) add(e, e.from, true); if (dir === 'down' && e.from === nid) add(e, e.to, true); });
|
|
3533
|
-
viewEdges(V[cur]).forEach((e, i) => { const e2 = V[cur].render === 'sequence' ? { ...e, step: i + 1 } : e; if (dir === 'up' && e.to === nid) add(e2, e.from, true); if (dir === 'down' && e.from === nid) add(e2, e.to, true); });
|
|
3534
|
-
(D.edges || []).forEach(e => { if (dir === 'up' && e.to === nid) add(e, e.from, inView.has(e.from)); if (dir === 'down' && e.from === nid) add(e, e.to, inView.has(e.to)); });
|
|
3535
|
-
return out;
|
|
3536
|
-
};
|
|
3537
|
-
const ups = collect('up'), downs = collect('down'), srcs = srcList(n);
|
|
3538
|
-
const child = childOf(nid), canOpen = child && child !== cur;
|
|
3539
|
-
const vIn = viewsWith(nid).filter(v => v.id !== cur);
|
|
3540
|
-
const by = n.author ? `<span>·</span>${authorChip(n.author)}` : ext ? '<span class="origin">· author unknown</span>' : '';
|
|
3541
|
-
const exs = Array.isArray(n.examples) ? n.examples.filter(x => x && (x.thumb || x.href)) : [];
|
|
3542
|
-
const tabs = [['when', 'When to use'], ['what', 'What it is'], ['how', 'How it works'], ...(exs.length ? [['examples', 'Example']] : []), ['links', 'Behind the scenes']];
|
|
3543
|
-
TABS = tabs.map(t => t[0]);
|
|
3544
|
-
if (!TABS.includes(tab)) tab = 'when';
|
|
3545
|
-
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>`;
|
|
3546
|
-
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>` : '';
|
|
3547
|
-
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>';
|
|
3548
|
-
const bodies = {
|
|
3549
|
-
when: text('When to use', n.when, 'when'),
|
|
3550
|
-
what: text('What it is', n.what, 'what') + verbs,
|
|
3551
|
-
how: text('How it works', n.how, 'how'),
|
|
3552
|
-
examples: `<h4>Example</h4><ul class="exs">${exs.map((x, i) => `<li class="ex">${x.thumb ? `<button class="ex-thumb" data-ex="${i}" title="Open full size" aria-label="Open ${esc(x.title || 'example')} full size"><img src="${esc(x.thumb)}" alt="" loading="lazy"></button>` : ''}<b>${inline(x.title || '')}</b>${x.caption ? `<small>${inline(x.caption)}</small>` : ''}${x.href ? `<a href="${esc(x.href)}" target="_blank" rel="noopener">Open live page ↗</a>` : ''}</li>`).join('')}</ul>`,
|
|
3553
|
-
links: `<div class="links"><div><h4>← Comes from</h4>${relRows(ups, 'up')}</div><div><h4>→ Leads to</h4>${relRows(downs, 'down')}</div>
|
|
3554
|
-
<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>`,
|
|
3555
|
-
};
|
|
3556
|
-
panel.innerHTML = `<div class="ph" style="--c:${c}">
|
|
3557
|
-
${(() => { const I = (D.icons || {})[n.icon];
|
|
3558
|
-
return I && I.src ? `<span class="big logo${I.dark ? ' dark' : ''}" aria-hidden="true"><img src="${I.src}" width="22" height="22" alt=""></span>`
|
|
3559
|
-
: `<span class="big" style="background:${c}" aria-hidden="true"><svg viewBox="0 0 ${I && I.g ? 24 : 16} ${I && I.g ? 24 : 16}" width="20" height="20" fill="none" stroke="currentColor" stroke-width="${I && I.g ? 2 : 1.5}" stroke-linecap="round" stroke-linejoin="round">${I && I.g ? I.g : SIGIL[k]}</svg></span>`; })()}
|
|
3560
|
-
<div class="pt"><h3>${esc(n.label || nid)}</h3><small><span>${esc(KIND[k][1])}</span><span class="vis ${pub ? 'pub' : ext ? 'ext' : ''}">${badgesOf(n).length ? badgesOf(n).map(b => esc(b.text.toUpperCase())).join(' · ') : pub ? 'PUBLIC' : ext ? 'EXTERNAL' : 'INTERNAL'}</span>${by}${n.origin ? `<span class="origin">${esc(n.origin)}</span>` : ''}</small></div>
|
|
3561
|
-
<div class="acts">${canOpen ? `<button class="obtn" data-open="${esc(child)}" style="--c:${c}">⤢ Open ${esc(shortT(V[child].title || child))} <span>${(V[child].nodeIds || []).length} nodes · Enter</span></button>` : ''}${X && X.ctx().unfoldable(nid, cur) ? `<button class="obtn" data-unfold="${esc(nid)}" style="--c:${c}">⤡ Unfold here <span>E</span></button>` : ''}${litButtons()}<button class="ibtn x" data-close title="Close (Esc)" aria-label="Close details">×</button></div>
|
|
3562
|
-
</div>
|
|
3563
|
-
<div class="ptabs" role="tablist" aria-label="Details">${tabs.map(t => `<button class="ptab" role="tab" id="pt-${t[0]}" data-tab="${t[0]}" aria-selected="${t[0] === tab}" aria-controls="m-pbody">${t[1]}${t[2] != null ? `<em>${t[2]}</em>` : ''}</button>`).join('')}
|
|
3564
|
-
<span class="pnav"><button class="ibtn sm" data-step="-1" title="Previous ([)" aria-label="Previous tab">‹</button><button class="ibtn sm" data-step="1" title="Next (])" aria-label="Next tab">›</button></span></div>
|
|
3565
|
-
<div class="pbody" id="m-pbody" role="tabpanel" aria-labelledby="pt-${tab}">${bodies[tab]}</div>`;
|
|
3566
|
-
panel.hidden = false;
|
|
3567
|
-
panel.dataset.node = nid;
|
|
3568
|
-
panel.querySelectorAll('.clamp').forEach(el => { if (el.scrollHeight > el.clientHeight + 2) el.insertAdjacentHTML('afterend', '<button class="morebtn" data-more>Show all</button>'); });
|
|
3569
|
-
}
|
|
3570
|
-
let TABS = ['when', 'what', 'how', 'links'];
|
|
3571
|
-
function openExample(nid, i) {
|
|
3572
|
-
const x = ((nodeIn(nid) || {}).examples || [])[i]; if (!x) return;
|
|
3573
|
-
let lb = $('m-lightbox');
|
|
3574
|
-
if (!lb) { lb = h(`<div class="lightbox" id="m-lightbox" role="dialog" aria-modal="true" aria-label="Example" hidden></div>`); document.body.appendChild(lb);
|
|
3575
|
-
lb.addEventListener('click', e => { if (e.target === lb || e.target.closest('[data-lbclose]')) closeExample(); }); }
|
|
3576
|
-
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>`;
|
|
3577
|
-
lb.hidden = false; lb.querySelector('[data-lbclose]').focus();
|
|
3578
|
-
}
|
|
3579
|
-
const closeExample = () => { const lb = $('m-lightbox'); if (lb) lb.hidden = true; };
|
|
3580
|
-
const setTab = t => { tab = t; try { localStorage.setItem('visual-tab', t); } catch (_) {} if (sel) renderPanel(sel); };
|
|
3581
|
-
|
|
3582
|
-
/* 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
|
|
3583
|
-
new selection or view clears them, and the same key again does */
|
|
3584
|
-
let lit = null, picked = [];
|
|
3585
|
-
const cardName = id => nodeIn(id).label || id, nCards = n => `${n} card${n === 1 ? '' : 's'}`;
|
|
3586
|
-
const litButtons = () => '<button class="ibtn" data-lit="out" title="Light what it leads to (>)" aria-label="Light what it leads to">→</button><button class="ibtn" data-lit="in" title="Light what leads to it (<)" aria-label="Light what leads to it">←</button>'
|
|
3587
|
-
+ (picked.length > 1 ? '<button class="ibtn" data-lit="paths" title="Light every path between the two cards (P)" aria-label="Light every path between the two selected cards">⇄</button>' : '');
|
|
3588
|
-
function unlight() { if (lit) { lit = null; handle.highlight(null); } }
|
|
3589
|
-
function light(key, refs, said) {
|
|
3590
|
-
if (lit === key) { unlight(); return status('Highlight cleared'); }
|
|
3591
|
-
lit = key; handle.highlight(refs.length ? refs : null); status(said, 3600);
|
|
3592
|
-
}
|
|
3593
|
-
function lightReach(back) {
|
|
3594
|
-
if (!sel || !geo.byId[sel]) return;
|
|
3595
|
-
const refs = reach(D, cur, sel, { backward: back }), n = refs.filter(r => r.type === 'node').length - 1, s = cardName(sel);
|
|
3596
|
-
light((back ? 'in ' : 'out ') + sel, n ? refs : [], !n ? (back ? `Nothing leads to ${s} here` : `${s} leads nowhere here`)
|
|
3597
|
-
: back ? `${nCards(n)} ${n === 1 ? 'leads' : 'lead'} to ${s}: lit on the map` : `${s} leads to ${nCards(n)}: lit on the map`);
|
|
3598
|
-
}
|
|
3599
|
-
function lightPaths() {
|
|
3600
|
-
const [a, b] = picked.slice(-2); if (!b) return;
|
|
3601
|
-
let refs = paths(D, cur, a, b), [x, y] = [a, b];
|
|
3602
|
-
if (!refs.length) { refs = paths(D, cur, b, a); [x, y] = [b, a]; } // the two cards in either order
|
|
3603
|
-
const n = refs.filter(r => r.type === 'node').length;
|
|
3604
|
-
light(`paths ${a} ${b}`, refs, n ? `Every path from ${cardName(x)} to ${cardName(y)}: ${nCards(n)}, lit on the map` : `No path joins ${cardName(a)} and ${cardName(b)} here`);
|
|
3605
|
-
}
|
|
4089
|
+
const setTree = pc.setTree;
|
|
3606
4090
|
|
|
3607
|
-
/*
|
|
3608
|
-
|
|
4091
|
+
/* the link (0.14.0): one place writes it, from what the map shows: its view, card, story step and lens, and the reader
|
|
4092
|
+
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,
|
|
4093
|
+
written once the move lands; anything else rewrites the entry. Nothing is written while a link is being followed */
|
|
4094
|
+
function write(push = false) {
|
|
4095
|
+
if (!X || linking || handle.destroyed) return;
|
|
3609
4096
|
const p = new URLSearchParams(); p.set('view', cur); if (sel) p.set('node', sel);
|
|
3610
4097
|
if (told) { p.set('story', told.id); p.set('step', told.step + 1); } // the story shown, at its step (#69)
|
|
3611
|
-
|
|
4098
|
+
if (lensOn) p.set('lens', lensOn); // the lens on the map (#94)
|
|
4099
|
+
if (Q.get('theme')) p.set('theme', document.documentElement.dataset.theme || Q.get('theme'));
|
|
4100
|
+
['reader', 'check', 'shot'].forEach(k => Q.get(k) && p.set(k, Q.get(k)));
|
|
3612
4101
|
const h = '#' + p.toString();
|
|
3613
|
-
if (location.hash
|
|
3614
|
-
|
|
4102
|
+
if (location.hash !== h) window.history[push ? 'pushState' : 'replaceState'](null, '', h); // window's: history() is this module's undo helper
|
|
4103
|
+
}
|
|
4104
|
+
/* following a link, on load and on Back, Forward or an edited link: its lens, view, story step and card, applied in that
|
|
4105
|
+
order. Once the map has settled, the page writes the link it shows: what the model lacks is corrected, and the hint
|
|
4106
|
+
line says so. A move the reader starts meanwhile wins, as a move of the reader's own (a new entry) */
|
|
4107
|
+
let linking = null, follows = 0; // linking: { view } while a link is followed: the view it goes to, whose arrival writes no entry
|
|
4108
|
+
const idle = () => new Promise(r => { const t = () => X.busy() ? setTimeout(t, 30) : r(); t(); }); // once no move is under way
|
|
4109
|
+
async function follow(load = false) {
|
|
4110
|
+
const p = new URLSearchParams(location.hash.slice(1)), my = ++follows, lacks = [], stories = arrOf(D.stories);
|
|
4111
|
+
const lens = p.get('lens') || null, sid = p.get('story'), at = Math.max(1, Math.floor(+p.get('step')) || 1) - 1;
|
|
4112
|
+
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)];
|
|
4113
|
+
linking = { view: null };
|
|
4114
|
+
if (lens !== lensOn && !X.lens(lens)) lacks.push(`no lens “${lens}”`);
|
|
4115
|
+
if (sid && !step) lacks.push(`no story “${sid}”`); else if (sid && at >= steps.length) lacks.push(`no step ${at + 1} in “${sid}”`);
|
|
4116
|
+
const shown = handle.state.story, k = Math.min(at, steps.length - 1), played = !!step && (!shown || shown.id !== sid || shown.step !== k);
|
|
4117
|
+
const v = played ? step.view : p.get('view') || rootId; // a step to show: its own view, gone to first so the step moves nothing
|
|
4118
|
+
if (!V[v]) lacks.push(`no view “${v}”`);
|
|
4119
|
+
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
|
|
4120
|
+
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
|
|
4121
|
+
else if (!step && shown) handle.play(null); // a link with no story: the one shown stops
|
|
4122
|
+
if (!played) { // the link's card, unless a step just chose the selection
|
|
4123
|
+
const node = p.get('node') || (load && Q.get('shot') && cur !== rootId ? subjectOf(V[cur]) || (V[cur].nodeIds || [])[0] : null);
|
|
4124
|
+
if (node) { if (node !== sel && !X.select({ type: 'node', id: node }, { reveal: !load })) lacks.push(`no card “${node}” here`); }
|
|
4125
|
+
else if (handle.selection) X.select(null);
|
|
4126
|
+
}
|
|
4127
|
+
linking = null; write();
|
|
4128
|
+
if (lacks.length) status('Link corrected: ' + lacks.join('; '), 5000);
|
|
3615
4129
|
}
|
|
3616
|
-
const go = (vid, how = {}) =>
|
|
4130
|
+
const go = (vid, how = {}) => X.go(vid, how);
|
|
3617
4131
|
const open = nid => X.open(nid), up = () => X.up();
|
|
3618
|
-
const findNode =
|
|
4132
|
+
const findNode = pc.find;
|
|
3619
4133
|
|
|
3620
4134
|
/* events */
|
|
3621
4135
|
stage.addEventListener('click', e => {
|
|
3622
4136
|
if (e.target.closest('#m-pillbtn')) return togglePill();
|
|
3623
|
-
if (e.target.closest('#m-lfold')) { legendFolded = !legendFolded; try { localStorage.setItem('visual-legend', legendFolded ? '0' : '1'); } catch (_) {} renderLegend(); if (!cam.touched) cam.set(X.startView()); return; }
|
|
3624
4137
|
const bv = e.target.closest('.vpop [data-v]'); if (bv) return go(bv.dataset.v);
|
|
3625
4138
|
});
|
|
3626
4139
|
const setFlow = on => X.setFlow(on);
|
|
3627
|
-
const
|
|
3628
|
-
|
|
3629
|
-
|
|
3630
|
-
|
|
3631
|
-
|
|
3632
|
-
const
|
|
3633
|
-
const
|
|
3634
|
-
|
|
3635
|
-
|
|
4140
|
+
const toggleHelp = force => { const on = force ?? $('m-helpbox').hidden; $('m-helpbox').hidden = !on; $('m-help').setAttribute('aria-expanded', on); if (on) openMenu(null); };
|
|
4141
|
+
/* the header's menus, Export, Stories and Lenses (0.14.0): one open at a time, or none. back: focus goes back to the
|
|
4142
|
+
button of the menu that closes (Esc) */
|
|
4143
|
+
let menuOn = null;
|
|
4144
|
+
function openMenu(name, back = false) {
|
|
4145
|
+
const was = menuOn; menuOn = name && $(`m-${name}-pop`) ? name : null;
|
|
4146
|
+
['export', 'stories', 'lenses'].forEach(n => { const p = $(`m-${n}-pop`); if (!p) return; p.hidden = n !== menuOn; $(`m-${n}`).setAttribute('aria-expanded', n === menuOn); });
|
|
4147
|
+
if (menuOn) toggleHelp(false);
|
|
4148
|
+
if (back && was && !menuOn) $(`m-${was}`).focus();
|
|
4149
|
+
}
|
|
4150
|
+
['export', 'stories', 'lenses'].forEach(n => { const b = $(`m-${n}`); if (b) b.addEventListener('click', () => openMenu(menuOn === n ? null : n)); });
|
|
3636
4151
|
document.addEventListener('click', e => {
|
|
3637
|
-
const t = e.target;
|
|
3638
|
-
if (
|
|
3639
|
-
|
|
3640
|
-
const
|
|
3641
|
-
|
|
3642
|
-
const pv = t.closest('.pview'); if (pv) return go(pv.dataset.v, { select: pv.dataset.sel });
|
|
3643
|
-
const rn = t.closest('#m-panel [data-n]'); if (rn) return findNode(rn.dataset.n);
|
|
3644
|
-
const cv = t.closest('#m-crumbs [data-v], #m-tree [data-v]'); if (cv) { if (innerWidth <= 820) setTree(false); return go(cv.dataset.v); }
|
|
3645
|
-
const cp = t.closest('[data-copy]'); if (cp) { navigator.clipboard && navigator.clipboard.writeText(cp.dataset.copy).then(() => status('Path copied'), () => status(cp.dataset.copy)); return; }
|
|
3646
|
-
const mn = t.closest('.menu'); if (!mn || !mn.contains($('m-export'))) closeExport(); if (!mn || !mn.contains($('m-stories'))) closeStories();
|
|
3647
|
-
if (!t.closest('#m-helpbox, #m-help')) closeHelp();
|
|
3648
|
-
});
|
|
4152
|
+
const t = e.target, at = s => t.closest && t.closest(s);
|
|
4153
|
+
if (!at('.menu')) openMenu(null);
|
|
4154
|
+
if (!at('#m-helpbox, #m-help')) toggleHelp(false);
|
|
4155
|
+
const cv = at('#m-crumbs [data-v]'); if (cv) { if (innerWidth <= 820) setTree(false); go(cv.dataset.v); }
|
|
4156
|
+
}, { signal });
|
|
3649
4157
|
$('m-outline').addEventListener('click', () => setTree(!document.body.classList.contains('tree-open')));
|
|
3650
4158
|
if (innerWidth > 1180 && views.length > 1) setTree(true);
|
|
3651
|
-
|
|
3652
|
-
|
|
4159
|
+
// the views list, open over the map on a phone's width, closes when the window narrows to it: it would cover the map
|
|
4160
|
+
let narrow = innerWidth <= 820;
|
|
4161
|
+
addEventListener('resize', () => { const n = innerWidth <= 820; if (n && !narrow) setTree(false); narrow = n; }, { signal });
|
|
4162
|
+
$('m-help').addEventListener('click', () => toggleHelp());
|
|
4163
|
+
$('m-theme').addEventListener('click', () => { toggleTheme(); write(); }); // a link that names the theme names the one shown
|
|
3653
4164
|
$('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 ? '▶' : '❚❚'; });
|
|
3654
|
-
$('m-export').addEventListener('click',
|
|
3655
|
-
$('m-export-pop').addEventListener('click', e => { const x = e.target.closest('[data-x]')?.dataset.x; if (!x) return; closeExport(); exportView(x); });
|
|
4165
|
+
$('m-export-pop').addEventListener('click', e => { const x = e.target.closest('[data-x]')?.dataset.x; if (!x) return; openMenu(null); exportView(x); });
|
|
3656
4166
|
|
|
3657
|
-
/* stories (#69): the Stories menu plays one; the link names the story and step shown
|
|
3658
|
-
|
|
3659
|
-
|
|
3660
|
-
const
|
|
3661
|
-
if (
|
|
3662
|
-
|
|
3663
|
-
|
|
4167
|
+
/* stories (#69): the Stories menu plays one; the link names the story and step shown, in the entry of the step's own view
|
|
4168
|
+
(0.14.0); Export ▸ Record a story… */
|
|
4169
|
+
let told = null; // { id, step }: the story on the map and its step, from the story event
|
|
4170
|
+
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; };
|
|
4171
|
+
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); });
|
|
4172
|
+
/* 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
|
|
4173
|
+
menu's button and in the link (#lens=<id>) */
|
|
4174
|
+
let lensOn = null;
|
|
4175
|
+
const lensesPop = $('m-lenses-pop');
|
|
4176
|
+
function showLens() {
|
|
4177
|
+
if (!lensesPop) return;
|
|
4178
|
+
const L = arrOf(D.lenses).find(x => x && x.id === lensOn);
|
|
4179
|
+
lensesPop.querySelectorAll('[data-lens]').forEach(b => b.setAttribute('aria-pressed', b.dataset.lens === (lensOn || '')));
|
|
4180
|
+
$('m-lenses').innerHTML = `◎<span class="lbl"> ${esc(L ? L.title || L.id : 'Lenses')}</span>`;
|
|
3664
4181
|
}
|
|
4182
|
+
if (lensesPop) lensesPop.addEventListener('click', e => { const b = e.target.closest('[data-lens]'); if (!b) return; openMenu(null);
|
|
4183
|
+
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'); } });
|
|
3665
4184
|
// 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);
|
|
3666
|
-
// captions are typed in, and Save writes the story's JSON, for the model's `stories
|
|
4185
|
+
// captions are typed in, and Save writes the story's JSON, for the model's `stories`. Not while a story is shown: its
|
|
4186
|
+
// 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
|
|
4187
|
+
// steps not saved (0.14.0)
|
|
3667
4188
|
let rec = null;
|
|
4189
|
+
const unsaved = () => !!rec && (rec.steps.length > 1 || rec.steps.some(s => s.caption)) && rec.json() !== rec.saved;
|
|
4190
|
+
const leaving = e => { if (unsaved()) { e.preventDefault(); e.returnValue = ''; } };
|
|
4191
|
+
function closeRec() { // false: the reader kept it
|
|
4192
|
+
if (!rec || (unsaved() && !confirm(`Close the recorder? Its ${rec.steps.length} steps are not saved.`))) return false;
|
|
4193
|
+
clearTimeout(rec.t); rec = null; $('m-rec').remove(); removeEventListener('beforeunload', leaving); return true;
|
|
4194
|
+
}
|
|
3668
4195
|
function record() {
|
|
3669
4196
|
if (rec) return;
|
|
3670
4197
|
stage.insertAdjacentHTML('beforeend', `<div class="m-rec" id="m-rec" role="region" aria-label="Recording a story">
|
|
@@ -3673,8 +4200,12 @@ export function mountPage(container, model, options = {}) {
|
|
|
3673
4200
|
<ol id="m-rec-list"></ol>
|
|
3674
4201
|
<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>`);
|
|
3675
4202
|
const box = $('m-rec'), same = (a, b) => JSON.stringify([a.view, a.select, a.camera]) === JSON.stringify([b.view, b.select, b.camera]);
|
|
3676
|
-
rec = { steps: [], t: 0 };
|
|
3677
|
-
const
|
|
4203
|
+
rec = { steps: [], t: 0, saved: null };
|
|
4204
|
+
const story = () => { const title = $('m-rec-title').value.trim() || 'A walk-through', id = title.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '') || 'story';
|
|
4205
|
+
return { id, title, steps: rec.steps.map(({ caption, ...s }) => caption ? { view: s.view, caption, ...s } : s) }; };
|
|
4206
|
+
rec.json = () => JSON.stringify(story());
|
|
4207
|
+
addEventListener('beforeunload', leaving);
|
|
4208
|
+
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
|
|
3678
4209
|
$('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>
|
|
3679
4210
|
<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(''); };
|
|
3680
4211
|
rec.take = force => { // the map as it is: its view, selected card and, once moved by hand, its camera
|
|
@@ -3688,16 +4219,15 @@ export function mountPage(container, model, options = {}) {
|
|
|
3688
4219
|
const r = e.target.closest('[data-r]')?.dataset.r, del = e.target.closest('[data-del]');
|
|
3689
4220
|
if (del) { rec.steps.splice(+del.dataset.del, 1); draw(); }
|
|
3690
4221
|
else if (r === 'add') rec.take(true);
|
|
3691
|
-
else if (r === 'save') {
|
|
3692
|
-
const
|
|
3693
|
-
|
|
3694
|
-
download(id + '.story.json', new Blob([JSON.stringify({ id, title, steps }, null, 2) + '\n'], { type: 'application/json' }));
|
|
4222
|
+
else if (r === 'save' && rec.steps.length) {
|
|
4223
|
+
const st = story(); rec.saved = JSON.stringify(st);
|
|
4224
|
+
download(st.id + '.story.json', new Blob([JSON.stringify(st, null, 2) + '\n'], { type: 'application/json' }));
|
|
3695
4225
|
status('Saved: add it to the model\'s "stories"', 4000);
|
|
3696
|
-
} else if (r === 'close')
|
|
4226
|
+
} else if (r === 'close') closeRec();
|
|
3697
4227
|
});
|
|
3698
4228
|
rec.take();
|
|
3699
4229
|
}
|
|
3700
|
-
const recordSoon = () => { if (rec) { clearTimeout(rec.t); rec.t = setTimeout(() => rec && rec.take(), 650); } }; // once the camera has landed
|
|
4230
|
+
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
|
|
3701
4231
|
|
|
3702
4232
|
/* ⌘K command box: nodes and views, Enter jumps */
|
|
3703
4233
|
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() }));
|
|
@@ -3733,15 +4263,16 @@ export function mountPage(container, model, options = {}) {
|
|
|
3733
4263
|
res.innerHTML = html;
|
|
3734
4264
|
const on = res.querySelector('.fr.on'); on && on.scrollIntoView({ block: 'nearest' });
|
|
3735
4265
|
}
|
|
3736
|
-
|
|
3737
|
-
const
|
|
4266
|
+
let palBack = null; // where focus was before search opened: Esc gives it back
|
|
4267
|
+
const openPal = () => { toggleHelp(false); openMenu(null); palBack = document.activeElement; pal.hidden = false; q.value = ''; hi = 0; drawHits(); q.focus(); };
|
|
4268
|
+
const closePal = (back = false) => { pal.hidden = true; q.blur(); if (back && palBack && palBack.isConnected && palBack !== document.body) palBack.focus(); palBack = null; };
|
|
3738
4269
|
const pickHit = h => { if (!h) return; closePal(); if (h.t === 'n') findNode(h.n.id); else go(h.v.id); };
|
|
3739
4270
|
q.addEventListener('input', () => { hi = 0; drawHits(); });
|
|
3740
4271
|
q.addEventListener('keydown', e => {
|
|
3741
4272
|
if (e.key === 'ArrowDown') { hi = Math.min(hits.length - 1, hi + 1); drawHits(); e.preventDefault(); }
|
|
3742
4273
|
else if (e.key === 'ArrowUp') { hi = Math.max(0, hi - 1); drawHits(); e.preventDefault(); }
|
|
3743
4274
|
else if (e.key === 'Enter') { pickHit(hits[hi]); e.preventDefault(); }
|
|
3744
|
-
else if (e.key === 'Escape') { closePal(); e.preventDefault(); }
|
|
4275
|
+
else if (e.key === 'Escape') { closePal(true); e.preventDefault(); }
|
|
3745
4276
|
e.stopPropagation();
|
|
3746
4277
|
});
|
|
3747
4278
|
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]); });
|
|
@@ -3749,31 +4280,43 @@ export function mountPage(container, model, options = {}) {
|
|
|
3749
4280
|
pal.addEventListener('click', e => { if (e.target === pal) closePal(); });
|
|
3750
4281
|
$('m-find').addEventListener('click', openPal);
|
|
3751
4282
|
|
|
3752
|
-
/* keyboard:
|
|
4283
|
+
/* keyboard (0.14.0): one router, which reads each key from the top open layer down (an example shown full size, search,
|
|
4284
|
+
the key list, a menu, the view's popover, the story recorder, a story, then the map) and the KEYS table. Enter, Space
|
|
4285
|
+
and arrows aimed at a control outside the map are that control's own; the map's keys reach its canvas from anywhere
|
|
4286
|
+
else on the page */
|
|
4287
|
+
const layer = () => pc.modal() ? 'lightbox' : !pal.hidden ? 'search' : !$('m-helpbox').hidden ? 'help' : menuOn ? 'menu'
|
|
4288
|
+
: $('m-vpop') && !$('m-vpop').hidden ? 'pill' : rec ? 'recorder' : told ? 'story' : 'map';
|
|
4289
|
+
function closeTop(e, inCanvas) { // Esc and Backspace: the top open layer closes; else the map's own: the selection clears, then up a level
|
|
4290
|
+
const top = layer();
|
|
4291
|
+
if (top === 'search') closePal(true);
|
|
4292
|
+
else if (top === 'help') toggleHelp(false);
|
|
4293
|
+
else if (top === 'menu') openMenu(null, true);
|
|
4294
|
+
else if (top === 'pill') togglePill(false);
|
|
4295
|
+
else if (top === 'recorder') closeRec(); // asking first when it holds steps not saved
|
|
4296
|
+
else if (top === 'story') handle.play(null); // Backspace too
|
|
4297
|
+
else return !inCanvas && X.key(e); // in the canvas, the canvas takes its own
|
|
4298
|
+
}
|
|
4299
|
+
const CONTROL = 'a[href], button, input, select, textarea, summary, [contenteditable], [role=button], [role=tab], [role=option], [tabindex]:not([tabindex="-1"])';
|
|
3753
4300
|
document.addEventListener('keydown', e => {
|
|
3754
|
-
if (
|
|
3755
|
-
|
|
3756
|
-
|
|
3757
|
-
if (k === '
|
|
3758
|
-
|
|
3759
|
-
|
|
3760
|
-
|
|
3761
|
-
|
|
3762
|
-
|
|
3763
|
-
|
|
3764
|
-
|
|
3765
|
-
|
|
3766
|
-
else if ((k === '>' || k === '<') && sel) lightReach(k === '<');
|
|
3767
|
-
else if ((k === 'p' || k === 'P') && picked.length > 1) lightPaths();
|
|
3768
|
-
else if (inCanvas || !X.key(e)) return;
|
|
3769
|
-
e.preventDefault(); e.stopPropagation();
|
|
3770
|
-
}, true);
|
|
3771
|
-
addEventListener('popstate', () => { const p = new URLSearchParams(location.hash.slice(1)); 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(); });
|
|
4301
|
+
if (!X) return;
|
|
4302
|
+
const k = e.key, t = e.target, top = layer(), own = () => { e.preventDefault(); e.stopPropagation(); };
|
|
4303
|
+
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
|
|
4304
|
+
if ((e.metaKey || e.ctrlKey) && (k === 'k' || k === 'K')) { own(); return top === 'search' ? closePal(true) : openPal(); }
|
|
4305
|
+
if (e.metaKey || e.ctrlKey || e.altKey || (t && t.matches && t.matches('input, textarea, select, [contenteditable]'))) return; // the browser's own shortcuts; typing
|
|
4306
|
+
const inCanvas = e.composedPath().includes(X.stage), row = KEYS.find(r => r[2] && r[2].includes(k));
|
|
4307
|
+
if (top === 'search' && k !== 'Escape' && k !== 'Backspace') return; // search covers the map: its field and hits take their own keys
|
|
4308
|
+
if (row) { if (row[3](e, inCanvas) !== false) own(); return; }
|
|
4309
|
+
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
|
|
4310
|
+
if (X.key(e)) own(); // the map's keys, from anywhere else on the page
|
|
4311
|
+
}, { capture: true, signal });
|
|
4312
|
+
addEventListener('popstate', () => follow(), { signal }); // Back, Forward, or an edited link
|
|
3772
4313
|
|
|
3773
4314
|
/* export: resolve CSS variables into attributes so the file stands alone */
|
|
3774
4315
|
const standalone = scale => X.standalone(scale);
|
|
3775
4316
|
if (Q.get('still')) { const sv = Q.get('still'), sc = +(Q.get('scale') || 2);
|
|
3776
|
-
const tick = async () => { if (!geo
|
|
4317
|
+
const tick = async () => { if (!geo) return setTimeout(tick, 100);
|
|
4318
|
+
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
|
|
4319
|
+
if (cur !== sv) { await go(sv, {}); return setTimeout(tick, 200); }
|
|
3777
4320
|
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) }));
|
|
3778
4321
|
document.body.innerHTML = '<div id="still">' + out + '</div>'; document.body.style.margin = '0'; };
|
|
3779
4322
|
setTimeout(tick, 300); }
|
|
@@ -3790,36 +4333,446 @@ export function mountPage(container, model, options = {}) {
|
|
|
3790
4333
|
}
|
|
3791
4334
|
|
|
3792
4335
|
|
|
3793
|
-
let flowPref = null; try { flowPref = localStorage.getItem('visual-flow'); } catch (_) {}
|
|
4336
|
+
let flowPref = null; try { flowPref = reducedMotion() ? null : localStorage.getItem('visual-flow'); } catch (_) {} // the reader's ≋ and F; under reduced motion no flow is drawn
|
|
3794
4337
|
const p0 = new URLSearchParams(location.hash.slice(1));
|
|
3795
4338
|
const v0 = V[p0.get('view')] ? p0.get('view') : rootId;
|
|
3796
4339
|
const handle = render($('m-canvas'), model, {
|
|
3797
4340
|
view: v0, minimap: true, flow: flowPref ? flowPref === '1' : undefined,
|
|
3798
|
-
on: { story:
|
|
4341
|
+
on: { story: e => { told = e.step == null ? null : { id: e.story, step: e.step }; app.classList.toggle('story', !!told); pc.story(e);
|
|
4342
|
+
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)
|
|
3799
4343
|
_page: {
|
|
3800
|
-
instant: options.instant, defer: !
|
|
4344
|
+
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,
|
|
3801
4345
|
wide: () => innerWidth > 820,
|
|
3802
4346
|
upTarget: () => stack.length > 1 ? stack[stack.length - 2] : (V[cur] || {}).parent,
|
|
3803
4347
|
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
|
|
3804
4348
|
return [{ t: top, r: 20, b: 40 + (on ? lg.offsetHeight + 8 : 0), l: 20 }, { t: top, r: 20, b: 40, l: 20 + (on ? lg.offsetWidth + 8 : 0) }]; },
|
|
3805
4349
|
onNavigate: (vid, prev, mode) => { togglePill(false);
|
|
3806
4350
|
if (stack.includes(vid)) stack = stack.slice(0, stack.indexOf(vid) + 1); else if (mode === 'in' && prev) stack = [...stack, vid]; else stack = chain(vid); },
|
|
3807
|
-
onMount: inner => { X = inner; ({ D, views, nodes, N, V, rootId, subjectOf, viewsWith, kidsOf, chain, viewEdges, planCache } = X.ctx()); nodeIn0 = X.ctx().nodeIn;
|
|
4351
|
+
onMount: inner => { X = inner; ({ D, views, nodes, N, V, rootId, subjectOf, viewsWith, kidsOf, chain, viewEdges, planCache } = X.ctx()); nodeIn0 = X.ctx().nodeIn;
|
|
3808
4352
|
cam = X.cam(); geo = X.geo(); svg = X.svg(); cur = X.view(); if (paused) X.pause(true);
|
|
3809
4353
|
if (!stack.length || !stack.includes(cur)) stack = chain(cur);
|
|
3810
|
-
renderChrome();
|
|
4354
|
+
renderChrome(); },
|
|
3811
4355
|
onBackground: () => togglePill(false),
|
|
3812
4356
|
onFlow: on => { try { localStorage.setItem('visual-flow', on ? '1' : '0'); } catch (_) {} },
|
|
3813
|
-
onReady: () => {
|
|
3814
|
-
$('m-ghosts').remove();
|
|
3815
|
-
const n0 = p0.get('node') || (Q.get('shot') && cur !== rootId ? subjectOf(V[cur]) || (V[cur].nodeIds || [])[0] : null);
|
|
3816
|
-
if (n0 && geo.byId[n0]) select(n0, { reveal: false });
|
|
3817
|
-
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)
|
|
3818
|
-
setHash(true);
|
|
3819
|
-
},
|
|
4357
|
+
onReady: () => { $('m-ghosts').remove(); queueMicrotask(() => follow(true)); }, // the link it opened with: its card, story step and lens (#69, #94), once the page listens
|
|
3820
4358
|
},
|
|
3821
4359
|
});
|
|
3822
|
-
|
|
3823
|
-
handle.
|
|
4360
|
+
const destroy0 = handle.destroy;
|
|
4361
|
+
handle.destroy = () => { ac.abort(); if (rec) clearTimeout(rec.t); destroy0(); }; // the page's listeners go with the map
|
|
4362
|
+
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
|
|
4363
|
+
handle.on('viewchange', e => { pc.unlight(); recordSoon();
|
|
4364
|
+
if (linking && linking.view === e.view) return; // a link's own move: its entry, written once the link has settled
|
|
4365
|
+
linking = null; write(true); }); // a new view, a new entry (a move the reader started while a link was followed, too)
|
|
4366
|
+
handle.on('lens', e => { lensOn = e.lens; showLens(); write(); });
|
|
4367
|
+
// #pdf=1 (umlimator.py pdf, #93): the page becomes a document for Chrome to print (printDoc), once the fonts are in,
|
|
4368
|
+
// after the map's own redraw for them
|
|
4369
|
+
if (Q.get('pdf')) (document.fonts ? document.fonts.ready : Promise.resolve()).then(() => printDoc(X, model, Q));
|
|
3824
4370
|
return handle;
|
|
3825
4371
|
}
|
|
4372
|
+
|
|
4373
|
+
/* #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
|
|
4374
|
+
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
|
|
4375
|
+
paper (A4 or US Letter, across) and its margins, and each sheet ends in the title and "page N of M".
|
|
4376
|
+
- The cover: the title, the date, the model's file, a summary line and the contents, each view with its page.
|
|
4377
|
+
- Each view, from a page of its own: a header (the view's path and its caption), its picture (toSVG()'s, in vectors)
|
|
4378
|
+
as large as the page allows and never larger than drawn, the legend of what it draws, then tables of its cards,
|
|
4379
|
+
lines and boxes. They fill two columns below the picture, or one column beside a picture too tall to leave room
|
|
4380
|
+
below. A table that runs out of room goes on in the next column, under its heading and header row again; a row is
|
|
4381
|
+
never cut, and a heading never ends a column.
|
|
4382
|
+
- The rules, when the model has any: each holding or broken in the views printed, and what breaks it. */
|
|
4383
|
+
function printDoc(X, model, Q) {
|
|
4384
|
+
const { D, views, V, N, chain, nodeIn, viewEdges, geoCache } = X.ctx();
|
|
4385
|
+
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);
|
|
4386
|
+
const shown = views.filter(want).map(v => [v, X.standalone(1, v.id)]).filter(([, s]) => s), on = new Set(shown.map(([v]) => v.id));
|
|
4387
|
+
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
|
|
4388
|
+
const broken = rules(model).filter(b => on.has(b.view)); // judged in the views printed, as the document says
|
|
4389
|
+
const [pw, ph] = Q.get('paper') === 'letter' ? [279.4, 215.9] : [297, 210], title = esc(D.title || 'Map'); // the paper across, in mm
|
|
4390
|
+
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 });
|
|
4391
|
+
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
|
|
4392
|
+
const name = (v, id) => esc(N[id] ? nodeIn(id, v.id).label || id : (boxes(v).find(c => c.id === id) || {}).label || id);
|
|
4393
|
+
document.title = D.title || 'Map'; document.body.removeAttribute('class');
|
|
4394
|
+
document.body.innerHTML = `<style>@page { size: ${pw}mm ${ph}mm; margin: 11mm 13mm 9mm; background: var(--um-bg); }
|
|
4395
|
+
html, body { -webkit-print-color-adjust: exact; print-color-adjust: exact; background: var(--um-bg); } body { margin: 0; background-image: none; }
|
|
4396
|
+
#um-pdf { color: var(--um-text); font: 8.5pt/1.4 var(--um-sans); font-variant-numeric: tabular-nums; }
|
|
4397
|
+
#um-pdf .sheet { width: ${pw - 26}mm; height: ${ph - 20.5}mm; padding: 0; display: flex; flex-direction: column; overflow: hidden; break-after: page; }
|
|
4398
|
+
#um-pdf .sheet:last-child { break-after: auto; }
|
|
4399
|
+
#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); }
|
|
4400
|
+
#um-pdf h1 { font-size: 26pt; line-height: 1.12; letter-spacing: -.02em; font-weight: 700; margin: 0 0 10pt; }
|
|
4401
|
+
#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; }
|
|
4402
|
+
#um-pdf h2::after { content: none; } #um-pdf .up { color: var(--um-muted); font-weight: 450; }
|
|
4403
|
+
#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; }
|
|
4404
|
+
#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; }
|
|
4405
|
+
#um-pdf figure { flex: none; align-self: center; margin: 0; border: 1px solid var(--um-border); border-radius: 4pt; overflow: hidden; }
|
|
4406
|
+
#um-pdf .side figure { align-self: flex-start; } #um-pdf figure svg { display: block; }
|
|
4407
|
+
#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; }
|
|
4408
|
+
#um-pdf .side .lg { margin-bottom: 10pt; }
|
|
4409
|
+
#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; }
|
|
4410
|
+
#um-pdf .col { min-height: 0; overflow: hidden; } #um-pdf .blk + .blk { margin-top: 10pt; }
|
|
4411
|
+
#um-pdf .h3 { font-size: 9pt; font-weight: 650; margin: 0 0 3pt; } #um-pdf .h3 span { color: var(--um-muted); font-weight: 450; }
|
|
4412
|
+
#um-pdf table { width: 100%; border-collapse: collapse; table-layout: fixed; font-size: 8pt; line-height: 1.35; }
|
|
4413
|
+
#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); }
|
|
4414
|
+
#um-pdf td { vertical-align: top; padding: 2.5pt 6pt 2.5pt 0; border-bottom: .5pt solid var(--um-border); overflow-wrap: anywhere; }
|
|
4415
|
+
#um-pdf td b { font-weight: 600; } #um-pdf td small { display: block; color: var(--um-muted); font: 7pt/1.3 var(--um-mono); }
|
|
4416
|
+
#um-pdf td p { margin: 0; } #um-pdf td ul { margin: 0; padding-left: 10pt; }
|
|
4417
|
+
#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; }
|
|
4418
|
+
#um-pdf i.w { width: 10pt; border-top: 1.5pt solid; vertical-align: middle; }
|
|
4419
|
+
#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; }
|
|
4420
|
+
#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%; }
|
|
4421
|
+
#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%; }
|
|
4422
|
+
#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%; }
|
|
4423
|
+
#um-pdf .ok { color: var(--um-green); font-weight: 650; } #um-pdf .no { color: var(--um-rose); font-weight: 650; }
|
|
4424
|
+
#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; }
|
|
4425
|
+
#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; }
|
|
4426
|
+
#um-pdf .toc td span { display: block; color: var(--um-muted); font-size: 7.5pt; }
|
|
4427
|
+
#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>`;
|
|
4428
|
+
const doc = document.getElementById('um-pdf');
|
|
4429
|
+
// a sheet: its header, the body its content fills, and its footer (`key` names the view or section it starts)
|
|
4430
|
+
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>`);
|
|
4431
|
+
return doc.lastElementChild.querySelector('.body'); };
|
|
4432
|
+
const cols = (body, n) => { body.insertAdjacentHTML('beforeend', `<div class="cols" style="--n:${n}">${'<div class="col"></div>'.repeat(n)}</div>`); return [...body.lastElementChild.children]; };
|
|
4433
|
+
// tables into columns, next() handing out the next column (null: no more, and pour() says false). A table after the
|
|
4434
|
+
// first starts a column of its own while its page has one left. It starts with its heading, header row and first two
|
|
4435
|
+
// 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
|
|
4436
|
+
// alone. A column handed out fresh on a page of its own (`full`) takes a table's start whatever its size.
|
|
4437
|
+
const pour = (next, blocks) => { let box = next();
|
|
4438
|
+
const over = () => box.lastElementChild.getBoundingClientRect().bottom > box.getBoundingClientRect().bottom - 2, fresh = () => !box.firstElementChild;
|
|
4439
|
+
for (const [n, b] of blocks.entries()) { let tb = null;
|
|
4440
|
+
if (n && !fresh() && next.spare()) box = next();
|
|
4441
|
+
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>`);
|
|
4442
|
+
if (force || !over()) return box.lastElementChild.querySelector('tbody'); box.lastElementChild.remove(); };
|
|
4443
|
+
for (let i = 0; i < b.rows.length;) {
|
|
4444
|
+
if (!tb) { const k = Math.min(2, b.rows.length - i);
|
|
4445
|
+
while (!(tb = open(i, k)) && !(fresh() && box.full)) if (!(box = next())) return false;
|
|
4446
|
+
tb ||= open(i, 1) || open(i, 1, true); // ponytail: a row taller than a page is cut at its foot
|
|
4447
|
+
i += tb.rows.length; continue; }
|
|
4448
|
+
tb.insertAdjacentHTML('beforeend', b.rows[i]);
|
|
4449
|
+
if (!over()) { i++; continue; }
|
|
4450
|
+
tb.lastElementChild.remove();
|
|
4451
|
+
if (i === b.rows.length - 1 && tb.rows.length > 2) { tb.lastElementChild.remove(); i--; }
|
|
4452
|
+
if (!(box = next())) return false; tb = null;
|
|
4453
|
+
} }
|
|
4454
|
+
return true; };
|
|
4455
|
+
// the columns of a page, then (paging) of as many pages as it takes, or (no `head`) none more
|
|
4456
|
+
const paging = (head, n, first) => { const q = [...first], f = () => q.shift() || (head ? (q.push(...cols(sheet(head(true)), n).map(full)), q.shift()) : null);
|
|
4457
|
+
f.spare = () => q.length > 0; return f; };
|
|
4458
|
+
|
|
4459
|
+
// the cover, and its contents: a view's caption under its title, indented under the views printed that hold it
|
|
4460
|
+
const day = new Date(Date.now() - new Date().getTimezoneOffset() * 6e4).toISOString().slice(0, 10);
|
|
4461
|
+
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')] : [])];
|
|
4462
|
+
const cover = sheet('', 'cover'); cover.parentNode.classList.add('cover');
|
|
4463
|
+
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>`);
|
|
4464
|
+
pour(paging(() => '<header><div class="h2">Contents <span class="up">(continued)</span></div></header>', 2, cols(cover, 1)), [{ title: 'Contents', cls: 'toc', rows: [
|
|
4465
|
+
...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>`),
|
|
4466
|
+
...(rs.length ? ['<tr><td>Rules</td><td class="pg" data-pg="rules"></td></tr>'] : [])] }]);
|
|
4467
|
+
|
|
4468
|
+
shown.forEach(([v, svg], vi) => {
|
|
4469
|
+
const g = geoCache[v.id].g, ids = cards(v), es = lines(v), seq = v.render === 'sequence', at = Object.fromEntries(g.nodes.map(n => [n.id, n]));
|
|
4470
|
+
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);
|
|
4471
|
+
const up = chain(v.id).slice(0, -1).map(id => `<span class="up">${T(V[id])} › </span>`).join('');
|
|
4472
|
+
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>` : ''}`;
|
|
4473
|
+
const body = sheet(head(false), vi);
|
|
4474
|
+
body.insertAdjacentHTML('beforeend', `<figure>${svg}</figure><div class="lg"><b>Legend</b>${legendItems([g], 'opens a view of its own').join('')}</div>`);
|
|
4475
|
+
// the picture as large as the page allows, never larger than drawn: below it the legend, then two columns of tables;
|
|
4476
|
+
// one too tall for that keeps the page's height, with the legend and one column beside it. Smaller (down to 70%, and
|
|
4477
|
+
// to a scale of 0.6 at least) when that keeps the tables on its page; else they go on over the next pages.
|
|
4478
|
+
const fig = body.firstElementChild, lg = fig.nextElementSibling, s = fig.firstElementChild, b = s.viewBox.baseVal, W = body.clientWidth - 2, H = body.clientHeight - 2, SIDE = 340;
|
|
4479
|
+
let k = Math.min(1, W / b.width, (H - lg.offsetHeight - 16) / b.height); const side = b.width * k <= W - SIDE;
|
|
4480
|
+
if (side) k = Math.min(1, H / b.height, (W - SIDE) / b.width);
|
|
4481
|
+
body.classList.toggle('side', side);
|
|
4482
|
+
const lay = (f, more) => { s.setAttribute('width', b.width * k * f); s.setAttribute('height', b.height * k * f); body.querySelector('.cols')?.remove();
|
|
4483
|
+
const first = cols(body, side ? 1 : 2); if (side) first[0].append(lg); return pour(paging(more && head, 2, first), tables); };
|
|
4484
|
+
const tables = [
|
|
4485
|
+
{ 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;
|
|
4486
|
+
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>`
|
|
4487
|
+
+ `<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>`; }) },
|
|
4488
|
+
{ title: `Lines <span>${es.length}</span>`, cls: 'lines', head: `${seq ? '<th class="n">#</th>' : ''}<th>From → to</th><th>Label</th><th>Technology</th>`, rows: es.map((e, i) => { const w = wk.get(e._id) || 'calls';
|
|
4489
|
+
return `<tr>${seq ? `<td>${i + 1}</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>`; }) },
|
|
4490
|
+
{ 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>`) },
|
|
4491
|
+
].filter(x => x.rows.length);
|
|
4492
|
+
[1, .9, .8, .7].some(f => k * f >= Math.min(k, .6) && lay(f)) || lay(1, true);
|
|
4493
|
+
});
|
|
4494
|
+
|
|
4495
|
+
if (rs.length) { // the rules: where rules() judges each (a rule that names boxes, in the views that draw one; `views` narrows it)
|
|
4496
|
+
// ponytail: rules()' own scope, again; one helper for both if it changes
|
|
4497
|
+
const anyBox = new Set(views.flatMap(v => boxes(v).map(c => c.id))), printed = shown.map(([v]) => v);
|
|
4498
|
+
const where = r => { const named = [r.from, r.to].flatMap(s => boxesNamed(s, id => anyBox.has(id) && !N[id]));
|
|
4499
|
+
const vs = printed.filter(v => (!Array.isArray(r.views) || r.views.includes(v.id)) && (!named.length || boxes(v).some(c => named.includes(c.id))));
|
|
4500
|
+
return vs.length === printed.length ? 'every view printed' : vs.length ? vs.map(T).join(', ') : 'no view printed'; };
|
|
4501
|
+
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
|
|
4502
|
+
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>'}`;
|
|
4503
|
+
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);
|
|
4504
|
+
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>`; }) }]);
|
|
4505
|
+
}
|
|
4506
|
+
const all = [...doc.children]; // the pages, numbered, and the contents' page of each view
|
|
4507
|
+
all.forEach((p, i) => { p.querySelector('.pn').textContent = `page ${i + 1} of ${all.length}`; });
|
|
4508
|
+
doc.querySelectorAll('[data-pg]').forEach(td => { td.textContent = all.findIndex(p => p.dataset.key === td.dataset.pg) + 1; });
|
|
4509
|
+
}
|
|
4510
|
+
|
|
4511
|
+
/* ================= the standalone page's pieces (#99): views list, legend, hint line, details panel =================
|
|
4512
|
+
mountPage draws all four in its page; render() draws those a host asks for (nav, legend, hints, details) around its
|
|
4513
|
+
canvas, in its shadow root. The hint line is hintHTML() and hinter(); pieces() draws the others from the map and acts
|
|
4514
|
+
on it. X() is render()'s inner handle; el: the views list's, legend's and details panel's elements (null: not drawn);
|
|
4515
|
+
o.page: the standalone page, which keeps the reader's choices in localStorage; o.frame takes the class tree-open while
|
|
4516
|
+
the views list is open, and o.toggle says so; o.layer holds an example's lightbox; o.status(text, ms) says what happened. */
|
|
4517
|
+
const shortT = t => String(t || '').split(' · ')[0];
|
|
4518
|
+
const hintHTML = (keys, ctrl) => typeof matchMedia === 'function' && matchMedia('(hover: none)').matches ? '<span>pinch = zoom</span><span>drag = move</span><span>tap = details</span>'
|
|
4519
|
+
: `<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'
|
|
4520
|
+
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); }; }
|
|
4521
|
+
|
|
4522
|
+
// Pull concise points from captured prose; retain wording and keep the full point in its tooltip. A point is a sentence
|
|
4523
|
+
// or a clause; one longer than a line (160 characters) is cut in two, but a short sentence stays whole (#97).
|
|
4524
|
+
function detailBullets(body) {
|
|
4525
|
+
const normalized = String(body || '').replace(/^\s*(?:[-*]|\d+[.)])\s+/gm, '').replace(/\n+/g, ' ').trim();
|
|
4526
|
+
let parts = normalized.split(/(?<=[.!?])\s+(?=[A-Z`/])|;\s+|\s+[·|]\s+/).filter(Boolean);
|
|
4527
|
+
while (parts.some(p => p.length > 160) && parts.length < 4) {
|
|
4528
|
+
const index = parts.reduce((best, p, i) => p.length > (parts[best] || '').length ? i : best, 0), value = parts[index];
|
|
4529
|
+
if (!value || value.length < 8) break;
|
|
4530
|
+
const separators = [...value.matchAll(/:\s+|,\s+|\s+(?=then\b|with\b|before\b|after\b|once\b|to\b|and\b|or\b|during\b|by\b|whose\b|outside\b|on\b|for\b|as\b|whenever\b|into\b|in\b|from\b|when\b|includes\b|typed\b)/g)].map(m => m.index + m[0].length);
|
|
4531
|
+
const safe = i => (value.slice(0, i).match(/`/g) || []).length % 2 === 0;
|
|
4532
|
+
const cuts = separators.filter(i => i >= 6 && i < value.length - 4 && safe(i));
|
|
4533
|
+
if (!cuts.length && parts.length < 2) cuts.push(...[...value.matchAll(/\s+/g)].map(m => m.index).filter(i => i >= 6 && i < value.length - 4 && safe(i)));
|
|
4534
|
+
const cut = cuts.sort((a, b) => Math.abs(a - value.length / 2) - Math.abs(b - value.length / 2))[0];
|
|
4535
|
+
if (!cut) break;
|
|
4536
|
+
parts.splice(index, 1, value.slice(0, cut).replace(/[,;:]\s*$/, '').trim(), value.slice(cut).trim());
|
|
4537
|
+
}
|
|
4538
|
+
if (parts.length > 4) parts = [...parts.slice(0, 3), parts.slice(3).join(' ')];
|
|
4539
|
+
return parts;
|
|
4540
|
+
}
|
|
4541
|
+
|
|
4542
|
+
function legendItems(gs, opens = 'double-click to open') { // what these pictures draw, each with what it means: the view's legend, and the PDF's (#93)
|
|
4543
|
+
const ns = gs.flatMap(g => g.nodes), es = gs.flatMap(g => g.edges), used = [...new Set(ns.map(n => n.k))];
|
|
4544
|
+
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'];
|
|
4545
|
+
const shape = k => k === 'm-verb' ? 'p' : (k === 'm-ext' || k === 'external') ? 'd' : k === 'm-hook' ? 'h' : '';
|
|
4546
|
+
return order.filter(k => used.includes(k)).map(k => `<span class="lgi" title="${esc(KIND[k][1] + ' — ' + KIND[k][2])}"><i class="${shape(k)}" style="border-color:var(--um-${KIND[k][0]});background:var(--um-${KIND[k][0]}-f)${k === 'm-hook' ? `;background:var(--um-${KIND[k][0]})` : ''}"></i><b>${KIND[k][1]}</b><span>${KIND[k][2]}</span></span>`)
|
|
4547
|
+
.concat(ns.some(n => n.kids || n.opens) ? [`<span class="lgi"><i class="s"></i><b>⤢ Stacked</b><span>${opens}</span></span>`] : [])
|
|
4548
|
+
.concat(ns.some(n => n.step) ? ['<span class="lgi"><i class="num">1</i><b>Step</b><span>run order</span></span>'] : [])
|
|
4549
|
+
.concat(ns.some(n => n.tag) ? ['<span class="lgi"><i class="tag"></i><b>@author</b><span>who wrote it</span></span>'] : [])
|
|
4550
|
+
.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>`))
|
|
4551
|
+
.concat(es.some(e => e.back) ? ['<span class="lgi"><i class="w dash" style="border-color:var(--um-w-calls)"></i><b>loops back</b><span>the one line that runs right to left</span></span>'] : []);
|
|
4552
|
+
}
|
|
4553
|
+
|
|
4554
|
+
function pieces(X, el, o) {
|
|
4555
|
+
const kept = k => { try { return o.page ? localStorage.getItem(k) : null; } catch (_) { return null; } };
|
|
4556
|
+
const keep = (k, v) => { try { if (o.page) localStorage.setItem(k, v); } catch (_) {} };
|
|
4557
|
+
// want: the tab the reader chose, kept; tab: the one shown, which is want when the card has it (0.14.0)
|
|
4558
|
+
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'];
|
|
4559
|
+
let told = null; // the story's step shown: { id, step, pointer: the card its own select names, pending: that select is still to come }
|
|
4560
|
+
|
|
4561
|
+
/* the legend: the kinds of cards and lines the view draws, in a box that folds away */
|
|
4562
|
+
function legend() {
|
|
4563
|
+
const items = legendItems([X().geo()]), back = el.legend.contains(el.legend.getRootNode().activeElement);
|
|
4564
|
+
el.legend.hidden = !items.length;
|
|
4565
|
+
el.legend.classList.toggle('folded', folded);
|
|
4566
|
+
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('')}`;
|
|
4567
|
+
if (back) el.legend.querySelector('#m-lfold').focus(); // a fold by keyboard keeps focus on the button
|
|
4568
|
+
}
|
|
4569
|
+
/* the views list: readable columns; a declared order may include shortcuts to descendants */
|
|
4570
|
+
function tree() {
|
|
4571
|
+
const { views, V, kidsOf, chain } = X().ctx(), cur = X().view(), ch = chain(cur);
|
|
4572
|
+
const roots = views.filter(x => !x.parent), cols = roots.length > 1 ? [{ head: 'Map', items: roots, on: ch[0] }] : [];
|
|
4573
|
+
ch.forEach((id, i) => { const order = V[id].browserOrder || [], k = [...new Set([...order, ...kidsOf(id).map(v => v.id)])].map(id => V[id]).filter(Boolean); if (k.length) cols.push({ head: shortT(V[id].title || id), items: k, on: ch[i + 1] }); });
|
|
4574
|
+
o.frame.style.setProperty('--browser-width', Math.min(448, Math.max(1, cols.length) * 224) + 'px');
|
|
4575
|
+
el.tree.innerHTML = `<div class="miller">${cols.map(c => `<div class="mcol"><div class="mch" title="${esc(c.head)}">${esc(c.head)}</div>${c.items.map(x => { const n = kidsOf(x.id).length;
|
|
4576
|
+
return `<button class="mit${x.id === cur ? ' on' : x.id === c.on ? ' path' : ''}" data-v="${esc(x.id)}" title="${esc(x.title || x.id)}"${x.id === cur ? ' aria-current="page"' : ''}><span>${esc(shortT(x.title || x.id))}</span><em>${(x.nodeIds || []).length}${n ? ' ›' : ''}</em></button>`; }).join('')}</div>`).join('')}</div>`;
|
|
4577
|
+
const m = el.tree.firstChild, last = m.lastElementChild; m.style.scrollBehavior = 'auto'; m.scrollLeft = last ? Math.max(0, last.offsetLeft + last.offsetWidth - m.clientWidth) : 0; m.style.scrollBehavior = '';
|
|
4578
|
+
}
|
|
4579
|
+
const setTree = on => { o.frame.classList.toggle('tree-open', on); if (o.toggle) o.toggle.setAttribute('aria-expanded', on); };
|
|
4580
|
+
|
|
4581
|
+
/* the details panel: header, actions and tabs of the selected card */
|
|
4582
|
+
function relRows(list, dir) {
|
|
4583
|
+
const { N } = X().ctx();
|
|
4584
|
+
if (!list.length) return `<div class="rel-none">${dir === 'up' ? 'Nothing calls this here' : 'Calls nothing here'}</div>`;
|
|
4585
|
+
return '<ul class="relation-list">' + list.map(({ e, id, inView }) => { const o = N[id] || { label: id, kind: '' }, k = kindOf(o.kind, true);
|
|
4586
|
+
return `<li><button class="rel" data-n="${esc(id)}" title="${esc((e.when ? e.when + ' — ' : '') + (inView ? 'Select' : 'Find in another view'))}"><i style="background:var(--um-${KIND[k][0]})"></i><span class="rel-l">${esc(o.label)}</span>
|
|
4587
|
+
<span class="rel-e">${e.step != null ? esc(e.step) + ' · ' : ''}${esc(e.label || '')}${inView ? '' : ' ↗'}</span></button></li>`; }).join('') + '</ul>';
|
|
4588
|
+
}
|
|
4589
|
+
const authorChip = login => { const A = (X().ctx().D.authors || {})[login] || {};
|
|
4590
|
+
return `<a class="author" href="${esc(A.url || 'https://github.com/' + login)}" target="_blank" rel="noopener">${A.avatar ? `<img src="${A.avatar}" alt="">` : `<i>${esc(login.slice(0, 2).toUpperCase())}</i>`}<span>${esc(A.name || login)} <b>@${esc(login)}</b></span></a>`; };
|
|
4591
|
+
const focused = () => { const a = el.panel.getRootNode().activeElement; return a && el.panel.contains(a) ? a : null; };
|
|
4592
|
+
function show(nid) {
|
|
4593
|
+
const { D, N, V, nodeIn, opensOf, kidCount, unfoldable, viewsWith, viewEdges } = X().ctx(), geo = X().geo(), cur = X().view(), panel = el.panel;
|
|
4594
|
+
const root = D.meta && D.meta.root ? D.meta.root.replace(/\/$/, '') : '';
|
|
4595
|
+
const base = D.sourcesBase ? String(D.sourcesBase).replace(/\/$/, '') + '/' : '';
|
|
4596
|
+
const srcPath = p => p.replace(/:\d+(-\d+)?$/, '');
|
|
4597
|
+
// meta.root is the repo root relative to this page (older pages: absolute). `~/…` sources live
|
|
4598
|
+
// outside the repo, so they get no link.
|
|
4599
|
+
// meta.sourceBase (render --source-base): in-pack sources were rewritten to URLs under it and
|
|
4600
|
+
// show as their pack-relative path; any other relative source gets no link on a published page.
|
|
4601
|
+
const sb = D.meta && D.meta.sourceBase ? String(D.meta.sourceBase) : '';
|
|
4602
|
+
const srcLabel = p => sb && p.startsWith(sb) ? decodeURI(p.slice(sb.length)).replace(/#L(\d+)(?:-L(\d+))?$/, (_, a, b) => ':' + a + (b ? '-' + b : '')) : p;
|
|
4603
|
+
const srcHref = p => /^https?:/.test(p) ? p
|
|
4604
|
+
: p.startsWith('~') || sb ? ''
|
|
4605
|
+
: p.startsWith('/') ? 'file://' + encodeURI(srcPath(p))
|
|
4606
|
+
: root.startsWith('/') ? 'file://' + encodeURI(root + '/' + base + srcPath(p))
|
|
4607
|
+
: encodeURI((root ? root + '/' : '') + base + srcPath(p));
|
|
4608
|
+
const srcList = n => (Array.isArray(n.source) ? n.source : n.source ? [n.source] : []);
|
|
4609
|
+
const n = nodeIn(nid, cur), k = kindOf(n.kind, true), c = `var(--um-${KIND[k][0]})`, pub = k === 'm-public', ext = k === 'm-ext';
|
|
4610
|
+
const inView = new Set(geo.nodes.map(x => x.id)), seen = new Set();
|
|
4611
|
+
const collect = dir => {
|
|
4612
|
+
const out = [];
|
|
4613
|
+
const add = (e, id, iv) => { const key = dir + id + (e.label || ''); if (seen.has(key)) return; seen.add(key); out.push({ e, id, inView: iv }); };
|
|
4614
|
+
geo.edges.forEach(e => { if (dir === 'up' && e.to === nid) add(e, e.from, true); if (dir === 'down' && e.from === nid) add(e, e.to, true); });
|
|
4615
|
+
viewEdges(V[cur]).forEach((e, i) => { const e2 = V[cur].render === 'sequence' ? { ...e, step: i + 1 } : e; if (dir === 'up' && e.to === nid) add(e2, e.from, true); if (dir === 'down' && e.from === nid) add(e2, e.to, true); });
|
|
4616
|
+
(D.edges || []).forEach(e => { if (dir === 'up' && e.to === nid) add(e, e.from, inView.has(e.from)); if (dir === 'down' && e.from === nid) add(e, e.to, inView.has(e.to)); });
|
|
4617
|
+
return out;
|
|
4618
|
+
};
|
|
4619
|
+
const ups = collect('up'), downs = collect('down'), srcs = srcList(n);
|
|
4620
|
+
const child = opensOf(nid, cur), later = !o.page && child && !V[child], count = V[child] ? (V[child].nodeIds || []).length : kidCount(nid, cur); // later: a view the host loads when asked (#47)
|
|
4621
|
+
const vIn = viewsWith(nid).filter(v => v.id !== cur);
|
|
4622
|
+
const by = n.author ? `<span>·</span>${authorChip(n.author)}` : ext ? '<span class="origin">· author unknown</span>' : '';
|
|
4623
|
+
const exs = Array.isArray(n.examples) ? n.examples.filter(x => x && (x.thumb || x.href)) : [];
|
|
4624
|
+
const tabs = [['when', 'When to use'], ['what', 'What it is'], ['how', 'How it works'], ...(exs.length ? [['examples', 'Example']] : []), ['links', 'Behind the scenes']];
|
|
4625
|
+
TABS = tabs.map(t => t[0]);
|
|
4626
|
+
tab = TABS.includes(want) ? want : 'when'; // a card without the chosen tab shows its first; the choice stays for the next card
|
|
4627
|
+
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>`;
|
|
4628
|
+
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>` : '';
|
|
4629
|
+
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>';
|
|
4630
|
+
const bodies = {
|
|
4631
|
+
when: text('When to use', n.when, 'when'),
|
|
4632
|
+
what: text('What it is', n.what, 'what') + verbs,
|
|
4633
|
+
how: text('How it works', n.how, 'how'),
|
|
4634
|
+
examples: `<h4>Example</h4><ul class="exs">${exs.map((x, i) => `<li class="ex">${x.thumb ? `<button class="ex-thumb" data-ex="${i}" title="Open full size" aria-label="Open ${esc(x.title || 'example')} full size"><img src="${esc(x.thumb)}" alt="" loading="lazy"></button>` : ''}<b>${inline(x.title || '')}</b>${x.caption ? `<small>${inline(x.caption)}</small>` : ''}${x.href ? `<a href="${esc(x.href)}" target="_blank" rel="noopener">Open live page ↗</a>` : ''}</li>`).join('')}</ul>`,
|
|
4635
|
+
links: `<div class="links"><div><h4>← Comes from</h4>${relRows(ups, 'up')}</div><div><h4>→ Leads to</h4>${relRows(downs, 'down')}</div>
|
|
4636
|
+
<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>`,
|
|
4637
|
+
};
|
|
4638
|
+
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
|
|
4639
|
+
panel.innerHTML = `<div class="ph" style="--c:${c}">
|
|
4640
|
+
${(() => { const I = (D.icons || {})[n.icon];
|
|
4641
|
+
return I && I.src ? `<span class="big logo${I.dark ? ' dark' : ''}" aria-hidden="true"><img src="${I.src}" width="22" height="22" alt=""></span>`
|
|
4642
|
+
: `<span class="big" style="background:${c}" aria-hidden="true"><svg viewBox="0 0 ${I && I.g ? 24 : 16} ${I && I.g ? 24 : 16}" width="20" height="20" fill="none" stroke="currentColor" stroke-width="${I && I.g ? 2 : 1.5}" stroke-linecap="round" stroke-linejoin="round">${I && I.g ? I.g : SIGIL[k]}</svg></span>`; })()}
|
|
4643
|
+
<div class="pt"><h3>${esc(n.label || nid)}</h3><small><span>${esc(KIND[k][1])}</span><span class="vis ${pub ? 'pub' : ext ? 'ext' : ''}">${badgesOf(n).length ? badgesOf(n).map(b => esc(b.text.toUpperCase())).join(' · ') : pub ? 'PUBLIC' : ext ? 'EXTERNAL' : 'INTERNAL'}</span>${by}${n.origin ? `<span class="origin">${esc(n.origin)}</span>` : ''}</small></div>
|
|
4644
|
+
<div class="acts">${V[child] || later ? `<button class="obtn" data-open="${esc(nid)}" style="--c:${c}">⤢ Open ${esc(shortT(V[child] ? V[child].title || child : child))} <span>${V[child] || count ? count + ' nodes · ' : ''}Enter</span></button>` : ''}${!X().editable && (unfoldable(nid, cur) || later) ? `<button class="obtn" data-unfold="${esc(nid)}" style="--c:${c}">⤡ Unfold here <span>E</span></button>` : ''}${litButtons()}<button class="ibtn x" data-close title="Close (Esc)" aria-label="Close details">×</button></div>
|
|
4645
|
+
</div>
|
|
4646
|
+
<div class="ptabs"><div class="ptablist" role="tablist" aria-label="Details">${tabs.map(t => `<button class="ptab" role="tab" id="pt-${t[0]}" data-tab="${t[0]}" aria-selected="${t[0] === tab}" aria-controls="m-pbody">${t[1]}${t[2] != null ? `<em>${t[2]}</em>` : ''}</button>`).join('')}</div>
|
|
4647
|
+
<span class="pnav"><button class="ibtn sm" data-step="-1" title="Previous ([)" aria-label="Previous tab">‹</button><button class="ibtn sm" data-step="1" title="Next (])" aria-label="Next tab">›</button></span></div>
|
|
4648
|
+
<div class="pbody" id="m-pbody" role="tabpanel" aria-labelledby="pt-${tab}">${bodies[tab]}</div>`;
|
|
4649
|
+
panel.hidden = false;
|
|
4650
|
+
panel.dataset.node = nid;
|
|
4651
|
+
panel.querySelectorAll('.clamp').forEach(el => { if (el.scrollHeight > el.clientHeight + 2) el.insertAdjacentHTML('afterend', '<button class="morebtn" data-more>Show all</button>'); });
|
|
4652
|
+
if (was) ((on && [...panel.querySelectorAll(`[${on.name}]`)].find(x => x.getAttribute(on.name) === on.value)) || panel.querySelector('[aria-selected="true"]')).focus();
|
|
4653
|
+
}
|
|
4654
|
+
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
|
|
4655
|
+
function openExample(nid, i, from = null) { // from: the control that opened it, which focus goes back to
|
|
4656
|
+
const x = ((X().ctx().nodeIn(nid, X().view()) || {}).examples || [])[i]; if (!x) return;
|
|
4657
|
+
if (!lb) { lb = h(`<div class="lightbox" id="m-lightbox" role="dialog" aria-modal="true" aria-label="Example" hidden></div>`); o.layer.appendChild(lb);
|
|
4658
|
+
lb.addEventListener('click', e => { if (e.target === lb || e.target.closest('[data-lbclose]')) closeExample(); }); }
|
|
4659
|
+
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>`;
|
|
4660
|
+
lbBack = from; lb.hidden = false; lb.querySelector('[data-lbclose]').focus();
|
|
4661
|
+
}
|
|
4662
|
+
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
|
|
4663
|
+
const to = lbBack && lbBack.isConnected ? lbBack : !el.panel.hidden && el.panel.querySelector('[data-ex]'); lbBack = null; if (to) to.focus(); else X().focus(); };
|
|
4664
|
+
const setTab = t => { want = tab = t; keep('visual-tab', t); if (sel && !el.panel.hidden) show(sel); };
|
|
4665
|
+
|
|
4666
|
+
/* 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
|
|
4667
|
+
new selection or view clears them, and the same key again does */
|
|
4668
|
+
const cardName = id => X().ctx().nodeIn(id, X().view()).label || id, nCards = n => `${n} card${n === 1 ? '' : 's'}`;
|
|
4669
|
+
// the slowest and the costliest path (#95), offered where the view's lines have latency or cost
|
|
4670
|
+
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'));
|
|
4671
|
+
const litOn = k => !!lit && lit.split(' ')[0] === k; // the button whose light is on says so (aria-pressed)
|
|
4672
|
+
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>`
|
|
4673
|
+
+ (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>` : '')
|
|
4674
|
+
+ 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('')
|
|
4675
|
+
+ `<button class="ibtn" data-lit="down" title="What if this goes down?" aria-label="What if this goes down?" aria-pressed="${litOn('down')}">⊘</button>`;
|
|
4676
|
+
/* 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 */
|
|
4677
|
+
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
|
|
4678
|
+
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)
|
|
4679
|
+
const L = { nodes: {}, edges: {}, dim: refs.length > 0 }, put = (to, x, r) => { to[x] = { level: Math.max(lv(r), (to[x] || {}).level || 0) }; };
|
|
4680
|
+
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)); });
|
|
4681
|
+
Object.entries((marks || {}).nodes || {}).forEach(([id, m]) => { L.nodes[id] = { ...L.nodes[id], ...m }; });
|
|
4682
|
+
return L;
|
|
4683
|
+
}
|
|
4684
|
+
const pressed = () => { if (el.panel) el.panel.querySelectorAll('[data-lit]').forEach(b => b.setAttribute('aria-pressed', litOn(b.dataset.lit))); };
|
|
4685
|
+
function unlight() { if (lit) { lit = null; X().overlay('lit', null); pressed(); } }
|
|
4686
|
+
function light(key, refs, said, marks = null) {
|
|
4687
|
+
if (lit === key) { unlight(); return o.status('Highlight cleared'); }
|
|
4688
|
+
lit = key; X().overlay('lit', refs.length || marks ? litLayer(refs, marks) : null, [X().view()]); pressed(); o.status(said, 3600);
|
|
4689
|
+
}
|
|
4690
|
+
function lightReach(back) {
|
|
4691
|
+
if (!sel || !X().geo().byId[sel]) return;
|
|
4692
|
+
const refs = reach(X().ctx().D, X().view(), sel, { backward: back }), n = refs.filter(r => r.type === 'node').length - 1, s = cardName(sel);
|
|
4693
|
+
light((back ? 'in ' : 'out ') + sel, n ? refs : [], !n ? (back ? `Nothing leads to ${s} here` : `${s} leads nowhere here`)
|
|
4694
|
+
: back ? `${nCards(n)} ${n === 1 ? 'leads' : 'lead'} to ${s}: lit on the map` : `${s} leads to ${nCards(n)}: lit on the map`);
|
|
4695
|
+
}
|
|
4696
|
+
function lightPaths() {
|
|
4697
|
+
const [a, b] = picked.slice(-2); if (!b) return;
|
|
4698
|
+
const D = X().ctx().D, cur = X().view();
|
|
4699
|
+
let refs = paths(D, cur, a, b), [x, y] = [a, b];
|
|
4700
|
+
if (!refs.length) { refs = paths(D, cur, b, a); [x, y] = [b, a]; } // the two cards in either order
|
|
4701
|
+
const n = refs.filter(r => r.type === 'node').length;
|
|
4702
|
+
light(`paths ${a} ${b}`, refs, n ? `Every path from ${cardName(x)} to ${cardName(y)}: ${nCards(n)}, lit on the map` : `No path joins ${cardName(a)} and ${cardName(b)} here`);
|
|
4703
|
+
}
|
|
4704
|
+
function lightCritical(by) { // the slowest or costliest path from the selected card, or between the two selected, its total on its last card (#95)
|
|
4705
|
+
if (!sel) return;
|
|
4706
|
+
const D = X().ctx().D, cur = X().view(), [a, b] = picked.length > 1 ? picked.slice(-2) : [sel], run = (from, to) => critical(D, cur, { from, to, by });
|
|
4707
|
+
let r = run(a, b); if (b && !r.refs.length) r = run(b, a); // the two cards in either order
|
|
4708
|
+
const cs = r.refs.filter(x => x.type === 'node').map(x => x.id), sum = by === 'latency' ? msText(r.total) : `cost ${r.total}`, word = BY[by][1];
|
|
4709
|
+
light(`${by} ${a} ${b || ''}`, r.refs, !cs.length ? (b ? `No path joins ${cardName(a)} and ${cardName(b)} here` : `${cardName(a)} leads nowhere here`)
|
|
4710
|
+
: `The ${word} path: ${cs.map(cardName).join(' → ')}, ${sum}`, cs.length ? { nodes: { [cs[cs.length - 1]]: { badge: { text: sum, tone: 'amber' } } } } : null);
|
|
4711
|
+
}
|
|
4712
|
+
function lightDown() { // what if the selected card goes down (#95): what loses its way to it
|
|
4713
|
+
if (!sel) return;
|
|
4714
|
+
const r = whatIf(X().ctx().D, X().view(), { remove: [sel] }), n = r.unreachable.filter(x => x.type === 'node').length - 1;
|
|
4715
|
+
light('down ' + sel, r.unreachable, `If ${cardName(sel)} goes down: ${n ? `${nCards(n)} lose${n === 1 ? 's its' : ' their'} way to it` : 'nothing here leads to it'}`,
|
|
4716
|
+
{ nodes: { [sel]: { tone: 'rose', badge: { text: 'down', tone: 'rose' } } } });
|
|
4717
|
+
}
|
|
4718
|
+
// a card: selected here, or shown in the view that has it, nearest this one first
|
|
4719
|
+
const find = nid => { const { viewsWith, chain } = X().ctx(), cur = X().view(); if (X().geo().byId[nid]) return X().select({ type: 'node', id: nid });
|
|
4720
|
+
const vs = viewsWith(nid), v = vs.find(x => chain(x.id).includes(cur)) || vs[0]; if (v) X().go(v.id, { select: nid }); };
|
|
4721
|
+
|
|
4722
|
+
if (el.legend) el.legend.addEventListener('click', e => { if (!e.target.closest('#m-lfold')) return;
|
|
4723
|
+
folded = !folded; keep('visual-legend', folded ? '0' : '1'); legend(); if (!X().cam().touched) X().cam().set(X().startView()); });
|
|
4724
|
+
if (el.tree) el.tree.addEventListener('click', e => { const b = e.target.closest('[data-v]'); if (!b) return; if (innerWidth <= 820) setTree(false); X().go(b.dataset.v); });
|
|
4725
|
+
if (el.panel) el.panel.addEventListener('click', e => {
|
|
4726
|
+
const t = e.target;
|
|
4727
|
+
const tb = t.closest('[data-tab]'); if (tb) return setTab(tb.dataset.tab);
|
|
4728
|
+
const st = t.closest('[data-step]'); if (st) return setTab(TABS[(TABS.indexOf(tab) + +st.dataset.step + TABS.length) % TABS.length]);
|
|
4729
|
+
const mb = t.closest('[data-more]'); if (mb) { mb.previousElementSibling.classList.add('open'); mb.remove(); }
|
|
4730
|
+
const ex = t.closest('[data-ex]'); if (ex) return openExample(el.panel.dataset.node, +ex.dataset.ex, ex);
|
|
4731
|
+
if (t.closest('[data-close]')) return X().select(null);
|
|
4732
|
+
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'); }
|
|
4733
|
+
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
|
|
4734
|
+
const ub = t.closest('[data-unfold]'); if (ub) return X().unfold(ub.dataset.unfold); // in place (#71), the expand event first
|
|
4735
|
+
const pv = t.closest('.pview'); if (pv) return X().go(pv.dataset.v, { select: pv.dataset.sel });
|
|
4736
|
+
const rn = t.closest('[data-n]'); if (rn) return find(rn.dataset.n);
|
|
4737
|
+
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));
|
|
4738
|
+
});
|
|
4739
|
+
|
|
4740
|
+
/* 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
|
|
4741
|
+
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
|
|
4742
|
+
names its card) */
|
|
4743
|
+
const pointerOf = x => typeof x === 'string' ? x : x && typeof x === 'object' ? (x.type === 'node' ? x.id : x.node ?? null) : null;
|
|
4744
|
+
function story(e) { // the map's story event
|
|
4745
|
+
if (!e || e.step == null) { told = null; return; }
|
|
4746
|
+
if (told && told.id === e.story && told.step === e.step) return; // the same step, said again (paused, played on)
|
|
4747
|
+
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];
|
|
4748
|
+
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
|
|
4749
|
+
told = { id: e.story, step: e.step, pointer, pending: pointer != null && !already };
|
|
4750
|
+
if (already && el.panel) hide();
|
|
4751
|
+
}
|
|
4752
|
+
|
|
4753
|
+
return {
|
|
4754
|
+
chrome() { if (el.legend) legend(); if (el.tree) tree(); if (sel && el.panel && !el.panel.hidden) show(sel); }, // each picture of the map
|
|
4755
|
+
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)
|
|
4756
|
+
sel = target && target.type === 'node' ? target.id : null; picked = (targets || []).filter(t => t.type === 'node').map(t => t.id);
|
|
4757
|
+
const pointer = !!(told && told.pending && sel != null && sel === told.pointer); if (pointer) told.pending = false;
|
|
4758
|
+
unlight(); if (el.panel) { if (sel && !pointer) show(sel); else hide(); }
|
|
4759
|
+
},
|
|
4760
|
+
story, unlight, find, setTree,
|
|
4761
|
+
modal: () => !!(lb && !lb.hidden), // an example shown full size, which holds the keys while open
|
|
4762
|
+
key(e) { // the pieces' keys: true when one was used
|
|
4763
|
+
const k = e.key;
|
|
4764
|
+
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
|
|
4765
|
+
if (k === 'Escape' || k === 'Backspace') closeExample();
|
|
4766
|
+
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(); }
|
|
4767
|
+
else if (k === 'Enter' || k === ' ') return false;
|
|
4768
|
+
return true;
|
|
4769
|
+
}
|
|
4770
|
+
if ((k === '[' || k === ']') && sel && el.panel && !el.panel.hidden) setTab(TABS[(TABS.indexOf(tab) + (k === ']' ? 1 : -1) + TABS.length) % TABS.length]);
|
|
4771
|
+
else if ((k === 'o' || k === 'O') && el.tree) setTree(!o.frame.classList.contains('tree-open'));
|
|
4772
|
+
else if ((k === '>' || k === '<') && sel && el.panel) lightReach(k === '<');
|
|
4773
|
+
else if ((k === 'p' || k === 'P') && picked.length > 1 && el.panel) lightPaths();
|
|
4774
|
+
else return false;
|
|
4775
|
+
return true;
|
|
4776
|
+
},
|
|
4777
|
+
};
|
|
4778
|
+
}
|