umlimator 0.12.0 → 0.13.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 +150 -17
- package/lib/umlimator-element.d.ts +19 -0
- package/lib/umlimator-element.js +77 -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 +59 -8
- package/lib/umlimator.js +754 -277
- package/lib/umlimator.schema.json +164 -1
- package/package.json +3 -2
package/lib/umlimator.js
CHANGED
|
@@ -202,6 +202,139 @@ const STYLES = `
|
|
|
202
202
|
.um-story button { min-width: 30px; height: 28px; border: 0; border-radius: 8px; background: none; color: var(--um-text); font: 600 13px var(--um-mono); cursor: pointer; padding: 0 7px; }
|
|
203
203
|
.um-story button:hover:not(:disabled) { background: var(--um-panel-2); color: var(--um-accent); } .um-story button:disabled { opacity: .35; cursor: default; }
|
|
204
204
|
@media (max-width: 820px) { .um-story { left: 12px; right: 12px; bottom: 60px; max-width: none; } }
|
|
205
|
+
/* the standalone page's pieces (#99): the views list, the legend, the hint line and the details panel, in the page and,
|
|
206
|
+
when a host asks, in render()'s shadow root, where these first rules give them what the page gives its whole page */
|
|
207
|
+
:where(.outline, .m-legend, .hint, .panel, .lightbox, .um-navbtn), :where(.outline, .m-legend, .hint, .panel, .lightbox, .um-navbtn) * { box-sizing: border-box; }
|
|
208
|
+
:where(.outline, .m-legend, .panel, .lightbox) button:focus-visible, .um-navbtn:focus-visible { outline: 2px solid var(--um-cyan); outline-offset: 2px; }
|
|
209
|
+
:where(.panel, .lightbox) a { color: var(--um-cyan); }
|
|
210
|
+
: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; }
|
|
211
|
+
.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); }
|
|
212
|
+
.ibtn:hover, .ibtn[aria-expanded="true"] { border-color: var(--um-accent); color: var(--um-accent); }
|
|
213
|
+
.ibtn[aria-pressed="true"] { border-color: var(--um-accent); color: var(--um-accent); }
|
|
214
|
+
.ibtn.sm { width: 26px; height: 26px; font-size: 12px; border-radius: 7px; }
|
|
215
|
+
/* views: Finder-style column browser */
|
|
216
|
+
.outline { overflow: hidden; border-right: 1px solid var(--um-border); background: var(--um-bg); visibility: hidden; min-width: 0; }
|
|
217
|
+
.tree-open .outline { visibility: visible; }
|
|
218
|
+
.miller { display: flex; height: 100%; overflow-x: auto; overflow-y: hidden; scroll-behavior: smooth; scrollbar-width: thin; }
|
|
219
|
+
.mcol { flex: 0 0 224px; min-width: 224px; border-right: 1px solid var(--um-border); padding: 8px 6px; overflow-y: auto; }
|
|
220
|
+
.mcol:last-child { border-right: 0; flex-grow: 1; }
|
|
221
|
+
.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; }
|
|
222
|
+
.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; }
|
|
223
|
+
.mit span { min-width: 0; overflow-wrap: anywhere; white-space: normal; }
|
|
224
|
+
.mit em { font-style: normal; color: var(--um-muted); font-size: 11px; white-space: nowrap; flex: none; }
|
|
225
|
+
.mit:hover { background: var(--um-panel-2); }
|
|
226
|
+
.mit.path { background: var(--um-panel-2); font-weight: 650; }
|
|
227
|
+
.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; }
|
|
228
|
+
/* legend: a box in the corner that folds away */
|
|
229
|
+
.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); }
|
|
230
|
+
.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); }
|
|
231
|
+
.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; }
|
|
232
|
+
.m-legend.folded .lgi { display: none; }
|
|
233
|
+
.lgi { display: flex; align-items: center; gap: 7px; white-space: nowrap; }
|
|
234
|
+
.lgi i { width: 14px; height: 10px; border: 1.5px solid var(--um-muted); border-radius: 3px; flex: none; }
|
|
235
|
+
.lgi i.p { border-radius: 999px; } .lgi i.d { border-style: dashed; } .lgi i.h { border-radius: 0; clip-path: polygon(3px 0, calc(100% - 3px) 0, 100% 50%, calc(100% - 3px) 100%, 3px 100%, 0 50%); border: 0; }
|
|
236
|
+
.lgi i.s { box-shadow: 2px 2px 0 -.5px var(--um-panel), 2px 2px 0 .5px var(--um-muted); background: var(--um-panel); }
|
|
237
|
+
.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; }
|
|
238
|
+
.lgi i.tag { width: 20px; border-radius: 999px; border-width: 1px; }
|
|
239
|
+
.lgi b { font-weight: 650; } .lgi span { color: var(--um-muted); }
|
|
240
|
+
/* help: one quiet line at the bottom */
|
|
241
|
+
.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; }
|
|
242
|
+
.hint b { color: var(--um-text); font-weight: 600; }
|
|
243
|
+
.hint.msg { color: var(--um-accent); font-weight: 600; }
|
|
244
|
+
/* details: a short strip under the map — compact header, then When to use · What it is · How it works · Example · Behind the scenes tabs */
|
|
245
|
+
.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; }
|
|
246
|
+
.panel[hidden] { display: none; }
|
|
247
|
+
@keyframes rise { from { transform: translateY(12px); opacity: 0; } }
|
|
248
|
+
.ph { display: flex; align-items: center; gap: 12px; padding: 12px 18px 6px; }
|
|
249
|
+
.ph .big { width: 38px; height: 38px; border-radius: 10px; display: grid; place-items: center; color: var(--um-accent-fg); flex: none; }
|
|
250
|
+
.ph .pt { min-width: 0; flex: 1; }
|
|
251
|
+
.ph h3 { margin: 0; font: 650 17px var(--um-sans); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
|
252
|
+
.ph small { display: flex; flex-wrap: wrap; align-items: center; gap: 4px 8px; color: var(--um-muted); font-size: 12.5px; }
|
|
253
|
+
.ph .acts { display: flex; gap: 8px; align-items: center; flex: none; }
|
|
254
|
+
.vis { font: 700 9px var(--um-sans); letter-spacing: .1em; padding: 1px 6px; border-radius: 4px; border: 1px solid var(--um-border-2); color: var(--um-muted); }
|
|
255
|
+
.vis.pub { background: var(--um-k-public-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); }
|
|
256
|
+
.author { display: inline-flex; align-items: center; gap: 6px; padding: 1px 9px 1px 1px; border-radius: 999px; border: 1px solid var(--um-border-2); color: var(--um-text); text-decoration: none; background: var(--um-panel); font-size: 12px; }
|
|
257
|
+
.author:hover { border-color: var(--um-accent); }
|
|
258
|
+
.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); }
|
|
259
|
+
.author b { color: var(--um-muted); font-weight: 500; }
|
|
260
|
+
.origin { color: var(--um-muted); font-size: 12px; }
|
|
261
|
+
.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; }
|
|
262
|
+
.obtn span { font-weight: 500; color: var(--um-muted); font-size: 11.5px; }
|
|
263
|
+
.obtn:hover { background: color-mix(in srgb, var(--c) 20%, var(--um-panel)); }
|
|
264
|
+
.ptabs { display: flex; align-items: center; gap: 2px; padding: 0 12px; border-bottom: 1px solid var(--um-border); }
|
|
265
|
+
.ptablist { display: flex; align-items: center; gap: 2px; min-width: 0; } /* only tabs in it: ‹ › stand beside it */
|
|
266
|
+
.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; }
|
|
267
|
+
.ptab em { font-style: normal; font-size: 11px; color: var(--um-muted); margin-left: 4px; }
|
|
268
|
+
.ptab:hover { color: var(--um-text); }
|
|
269
|
+
.ptab[aria-selected="true"] { color: var(--um-text); font-weight: 650; border-bottom-color: var(--um-accent); }
|
|
270
|
+
.pnav { margin-left: auto; display: flex; gap: 4px; }
|
|
271
|
+
.pbody { padding: 10px 18px 14px; }
|
|
272
|
+
.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; }
|
|
273
|
+
.pbody h4 span { color: var(--um-muted); }
|
|
274
|
+
.clamp { font-size: 14px; line-height: 1.55; max-width: 980px; display: -webkit-box; -webkit-line-clamp: 3; -webkit-box-orient: vertical; overflow: hidden; }
|
|
275
|
+
.clamp.open { display: block; -webkit-line-clamp: unset; }
|
|
276
|
+
.clamp p { margin: 0 0 6px; } .clamp ul { margin: 0 0 6px; padding-left: 18px; }
|
|
277
|
+
.detail-bullets { margin: 0; padding-left: 18px; max-width: 1100px; font-size: 13px; line-height: 1.65; }
|
|
278
|
+
.detail-bullets li { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; padding-left: 2px; }
|
|
279
|
+
.detail-bullets li::before { content: '•'; margin-right: 8px; color: var(--um-accent); }
|
|
280
|
+
.links ul { margin: 0; padding: 0; list-style: none; }
|
|
281
|
+
.links li { margin: 0; }
|
|
282
|
+
.morebtn { border: 0; background: none; color: var(--um-accent); font: 600 12px var(--um-sans); padding: 2px 0 0; cursor: pointer; }
|
|
283
|
+
.pnone { color: var(--um-muted); font-size: 13px; }
|
|
284
|
+
.pverbs { display: flex; flex-wrap: wrap; gap: 6px; margin-top: 8px; }
|
|
285
|
+
.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; }
|
|
286
|
+
button.pverb { cursor: pointer; } button.pverb:hover { background: color-mix(in srgb, var(--c) 18%, transparent); }
|
|
287
|
+
.links { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 6px 22px; }
|
|
288
|
+
.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; }
|
|
289
|
+
.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); }
|
|
290
|
+
.rel:hover { background: var(--um-panel-2); }
|
|
291
|
+
.rel i { width: 8px; height: 8px; border-radius: 50%; }
|
|
292
|
+
.rel-l { font-weight: 600; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
293
|
+
.rel-e { font-size: 11.5px; color: var(--um-muted); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; max-width: 170px; }
|
|
294
|
+
.rel-none { color: var(--um-muted); font-size: 12.5px; padding: 2px 0; }
|
|
295
|
+
/* one yellow highlighter phrase per section (==text== in the model) */
|
|
296
|
+
.pbody mark { background: var(--um-mark); color: inherit; padding: 0 3px; border-radius: 3px; box-decoration-break: clone; -webkit-box-decoration-break: clone; }
|
|
297
|
+
/* Example tab: a data-driven gallery (node.examples = [{title, caption, thumb, full, href}]) */
|
|
298
|
+
.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; }
|
|
299
|
+
.ex { display: flex; flex-direction: column; gap: 4px; min-width: 0; }
|
|
300
|
+
.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; }
|
|
301
|
+
.ex-thumb img { display: block; width: 100%; height: 100%; object-fit: cover; object-position: top left; }
|
|
302
|
+
.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); }
|
|
303
|
+
.ex b { font: 650 13px var(--um-sans); }
|
|
304
|
+
.ex small { color: var(--um-muted); font-size: 12px; line-height: 1.4; }
|
|
305
|
+
.ex a { color: var(--um-accent); font: 600 12px var(--um-sans); text-decoration: none; }
|
|
306
|
+
.ex a:hover { text-decoration: underline; }
|
|
307
|
+
.lightbox { position: fixed; inset: 0; z-index: 60; display: grid; place-items: center; padding: 20px; background: var(--um-scrim); }
|
|
308
|
+
.lightbox[hidden] { display: none; }
|
|
309
|
+
.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); }
|
|
310
|
+
.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); }
|
|
311
|
+
.lightbox figcaption { display: flex; align-items: center; gap: 12px; flex-wrap: wrap; font: 600 14px var(--um-sans); }
|
|
312
|
+
.lightbox figcaption span { flex: 1; min-width: 0; }
|
|
313
|
+
.lightbox figcaption a { color: var(--um-accent); text-decoration: none; font-size: 13px; }
|
|
314
|
+
.pviews { display: flex; flex-wrap: wrap; gap: 6px; margin-top: 4px; }
|
|
315
|
+
.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; }
|
|
316
|
+
.pview:hover { color: var(--um-text); border-color: var(--um-accent); }
|
|
317
|
+
@media (max-width: 820px) {
|
|
318
|
+
.ph { padding-top: 6px; } .ph .obtn span { display: none; }
|
|
319
|
+
.links { grid-template-columns: 1fr; }
|
|
320
|
+
.ptabs, .ptablist { flex-wrap: wrap; } .pnav { display: none; } .ptab { padding: 7px 8px; }
|
|
321
|
+
.detail-bullets li { white-space: normal; overflow: visible; }
|
|
322
|
+
.exs { grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 10px; }
|
|
323
|
+
.m-legend, .hint { display: none; }
|
|
324
|
+
}
|
|
325
|
+
/* render()'s pieces (#99), laid out as the page lays them out: the views list left of the map (☰ or O), the details under
|
|
326
|
+
it, the legend and the hint line over it; while a story is told, the legend gives way to its caption bar */
|
|
327
|
+
.um-frame { position: absolute; inset: 0; display: grid; grid-template: minmax(0, 1fr) / 0 minmax(0, 1fr); transition: grid-template-columns .22s ease; }
|
|
328
|
+
.um-frame.tree-open { grid-template-columns: min(var(--browser-width, 224px), 38%) minmax(0, 1fr); }
|
|
329
|
+
.um-main { grid-area: 1 / 2; display: flex; flex-direction: column; min-width: 0; min-height: 0; }
|
|
330
|
+
.um-stage { flex: 1; min-height: 0; position: relative; overflow: hidden; }
|
|
331
|
+
.um-frame > .outline { grid-area: 1 / 1; }
|
|
332
|
+
.um-navbtn { grid-area: 1 / 2; align-self: start; justify-self: start; margin: 14px 16px; z-index: 4; }
|
|
333
|
+
.um-stage:has(.um-story) > .m-legend { display: none; }
|
|
334
|
+
/* a lens's legend (#94): each owner's tone, over the controls */
|
|
335
|
+
.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); }
|
|
336
|
+
.um-lens b { font: 700 10px var(--um-sans); letter-spacing: .1em; text-transform: uppercase; color: var(--um-muted); } .um-lens span { white-space: nowrap; }
|
|
337
|
+
.um-lens i { display: inline-block; width: 8px; height: 8px; border: 2.5px solid; border-radius: 4px; margin-right: 6px; vertical-align: -1px; }
|
|
205
338
|
` +/* 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
339
|
file does, for page chrome) a tint keeps the tone it was mixed from, whatever an ancestor of the map sets later */
|
|
207
340
|
`: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,
|
|
@@ -264,6 +397,7 @@ const SIGIL = {
|
|
|
264
397
|
const CHG = { added: ['green', 'NEW'], new: ['green', 'NEW'], modified: ['amber', 'CHANGED'], changed: ['amber', 'CHANGED'], removed: ['rose', 'REMOVED'], planned: ['cyan', 'PLANNED'] };
|
|
265
398
|
const NW = 168, CG0 = 84, RG0 = 44;
|
|
266
399
|
const pillW = (label, sub, max = 440) => Math.max(sansW(fit(label, 10.5, max), 10.5, .92), sub ? sansW(fit(sub, 9.5, max), 9.5, .92) : 0) + 18; // a line's label, and its sublabel under it (#44)
|
|
400
|
+
const stepW = s => String(s).length > 2 ? Math.max(16, sansW(String(s), 9.5, .9) + 8) : 16; // a line's step mark: a 16 px circle, or a pill for a number such as 4.1 (#82)
|
|
267
401
|
// Text width. Without a measurer: a monospace estimate (font-independent, so layouts are the same on every machine;
|
|
268
402
|
// the bench and layout() in Node use it). render() sets measureText to the host's own font while it lays out a view.
|
|
269
403
|
let measureText = null;
|
|
@@ -306,33 +440,39 @@ let hush = null; // a layout tried and maybe dropped (unfolding, #71) keeps its
|
|
|
306
440
|
its neighbours in the next or previous column lie, its place now breaking ties), and the best order found is kept only
|
|
307
441
|
when it crosses less than the order given. A line that skips columns has a point in each column it passes (Sugiyama's
|
|
308
442
|
dummy nodes), so it orders the cards it runs between too. cols: columns of ids in reading order, changed in place;
|
|
309
|
-
links: [a, b] with b in a later column.
|
|
310
|
-
|
|
443
|
+
links: [a, b] with b in a later column.
|
|
444
|
+
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]
|
|
445
|
+
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
|
|
446
|
+
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.
|
|
447
|
+
fixed: items at a set row in columns of their own, which stay as they are (a box's far ends, in blockPos). */
|
|
448
|
+
function untangle(cols, links, h = () => 1, fixed = {}) {
|
|
311
449
|
const colOf = {}; cols.forEach((c, k) => c.forEach(id => { colOf[id] = k; }));
|
|
312
|
-
const segs = [], extra = new Set(), want = {};
|
|
313
|
-
|
|
450
|
+
const segs = [], extra = new Set(), want = {}, hh = id => extra.has(id) ? 1 : h(id), mid = (id, o) => o ?? (hh(id) - 1) / 2;
|
|
451
|
+
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
|
|
452
|
+
const at0 = tops();
|
|
453
|
+
links.forEach(([a, b, oa, ob], n) => {
|
|
314
454
|
const ka = colOf[a], kb = colOf[b]; if (ka == null || kb == null || kb <= ka) return;
|
|
315
|
-
let prev = a; const ra =
|
|
455
|
+
let prev = a, po = mid(a, oa); const ra = at0[a] + po, rb = at0[b] + mid(b, ob);
|
|
316
456
|
for (let k = ka + 1; k < kb; k++) { // a point first goes where the straight line between its ends would be
|
|
317
457
|
const p = '\u0001' + n + '.' + k; extra.add(p); colOf[p] = k; want[p] = ra + (rb - ra) * (k - ka) / (kb - ka);
|
|
318
|
-
segs.push([prev, p]); prev = p; }
|
|
319
|
-
segs.push([prev, b]);
|
|
458
|
+
segs.push([prev, p, po, 0]); prev = p; po = 0; }
|
|
459
|
+
segs.push([prev, b, po, mid(b, ob)]);
|
|
320
460
|
});
|
|
321
461
|
if (extra.size) cols.forEach((c, k) => { const ps = [...extra].filter(p => colOf[p] === k);
|
|
322
|
-
if (ps.length) cols[k] = [...c.map(
|
|
323
|
-
const ins = {}, outs = {}; segs.forEach(([a, b]) => { (outs[a] ||= []).push(b); (ins[b] ||= []).push(a); });
|
|
324
|
-
const crossings = () => { const at =
|
|
325
|
-
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];
|
|
326
|
-
if (colOf[a] === colOf[c] && (at[a] - at[c]) * (at[b] - at[d]) < 0) n++; }
|
|
462
|
+
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]); });
|
|
463
|
+
const ins = {}, outs = {}; segs.forEach(([a, b, oa, ob]) => { (outs[a] ||= []).push([b, ob, oa]); (ins[b] ||= []).push([a, oa, ob]); });
|
|
464
|
+
const crossings = () => { const at = tops(); let n = 0;
|
|
465
|
+
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];
|
|
466
|
+
if (colOf[a] === colOf[c] && (at[a] + oa - at[c] - oc) * (at[b] + ob - at[d] - od) < 0) n++; }
|
|
327
467
|
return n; };
|
|
328
468
|
let best = segs.length > 1 ? crossings() : 0, keep = cols.map(c => c.slice()); // the order given, unless one crosses less
|
|
329
469
|
for (let it = 0; it < 8 && best; it++) {
|
|
330
|
-
const down = it % 2 === 0, seq = cols.map((_, k) => k).filter(k => down ? k > 0 : k < cols.length - 1);
|
|
470
|
+
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));
|
|
331
471
|
if (!down) seq.reverse();
|
|
332
|
-
for (const k of seq) { const at =
|
|
333
|
-
const key =
|
|
334
|
-
return nb.length ? nb.reduce((x, y) => x + y, 0) / nb.length :
|
|
335
|
-
cols[k] = cols[k].map((id, i) => [id, key(id
|
|
472
|
+
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
|
|
473
|
+
const key = id => { const nb = ((down ? ins : outs)[id] || []).filter(([x]) => colOf[x] === side).map(([x, ox, o]) => at[x] + ox - o);
|
|
474
|
+
return nb.length ? nb.reduce((x, y) => x + y, 0) / nb.length : at[id]; };
|
|
475
|
+
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]); }
|
|
336
476
|
const n = crossings(); if (n < best) { best = n; keep = cols.map(c => c.slice()); }
|
|
337
477
|
}
|
|
338
478
|
keep.forEach((c, k) => { cols[k] = c.filter(id => !extra.has(id)); });
|
|
@@ -349,9 +489,9 @@ function pullSources(ids, links, rank) {
|
|
|
349
489
|
to, then again from the left, as near as the order allows with its height between it and the next (least squares: pool
|
|
350
490
|
adjacent violators); on half rows from 0. A line between cards on one row runs straight, past fewer cards. h: an item's
|
|
351
491
|
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
|
|
352
|
-
ob (a box's card), else at its middle. */
|
|
353
|
-
function rowsFor(cols, links, h = () => 1) {
|
|
354
|
-
const row = {}, colOf = {}; cols.forEach((c, k) => { let r = 0; c.forEach(id => { row[id] = r; r += h(id); colOf[id] = k; }); });
|
|
492
|
+
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). */
|
|
493
|
+
function rowsFor(cols, links, h = () => 1, fixed = {}) {
|
|
494
|
+
const row = {}, colOf = {}; cols.forEach((c, k) => { let r = 0; c.forEach(id => { row[id] = fixed[id] ?? r; r += h(id); colOf[id] = k; }); });
|
|
355
495
|
const ins = {}, outs = {}, at = (id, o) => o ?? (h(id) - 1) / 2; // where a link meets an item, in rows from its top
|
|
356
496
|
links.forEach(([a, b, oa, ob]) => { if (colOf[a] == null || colOf[b] == null || colOf[b] <= colOf[a]) return;
|
|
357
497
|
(outs[a] ||= []).push([b, at(b, ob), at(a, oa)]); (ins[b] ||= []).push([a, at(a, oa), at(b, ob)]); });
|
|
@@ -361,11 +501,11 @@ function rowsFor(cols, links, h = () => 1) {
|
|
|
361
501
|
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; } });
|
|
362
502
|
off = 0; blocks.forEach(b => b.ids.forEach(id => { row[id] = b.sum / b.n + off; off += h(id); })); };
|
|
363
503
|
for (const [from, to, step] of [[1, cols.length, 1], [cols.length - 2, -1, -1], [1, cols.length, 1]])
|
|
364
|
-
for (let k = from; k !== to; k += step) { const want = {};
|
|
504
|
+
for (let k = from; k !== to; k += step) { if (cols[k].every(id => id in fixed)) continue; const want = {};
|
|
365
505
|
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]; });
|
|
366
506
|
fit(cols[k], want); }
|
|
367
|
-
const low = Math.min(...Object.
|
|
368
|
-
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])); }));
|
|
507
|
+
const low = Math.min(...Object.keys(row).filter(id => !(id in fixed)).map(id => row[id]));
|
|
508
|
+
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])); }));
|
|
369
509
|
return row;
|
|
370
510
|
}
|
|
371
511
|
|
|
@@ -483,10 +623,10 @@ const mapTabs = (n, c) => { const tw = t => t.toUpperCase().length * 6.4 + 10, b
|
|
|
483
623
|
const t = n.k === 'm-public' ? 'PUBLIC' : n.k === 'm-ext' ? 'EXTERNAL' : n.k === 'm-lane' ? 'LANE' : ''; return t ? [{ text: t, fill: c, w: tw(t) }] : []; };
|
|
484
624
|
const mapNodeW = n => {
|
|
485
625
|
const px = n.emphasis ? 15 : 13;
|
|
486
|
-
const sub = n.sub || n.sublabel || '';
|
|
626
|
+
const sub = n.sub || n.sublabel || '', chips = (n.verbs || []).reduce((a, v) => a + textW(v, 9.5) + 14, -4); // its chips as svgOf draws them: each verb and 10 px, 4 px apart (#84)
|
|
487
627
|
return Math.min(n.emphasis ? 205 : 386, Math.max(n.emphasis ? 146 : 138,
|
|
488
628
|
Math.ceil(textW(n.label, px) + 70), badgesOf(n).length ? Math.ceil(badgesOf(n).reduce((a, b) => a + b.text.length * 6.4 + 10, 0) + (badgesOf(n).length - 1) * 4 + 120) : 0,
|
|
489
|
-
Math.min(386, Math.ceil(textW(sub, 10) + 70))));
|
|
629
|
+
Math.min(386, Math.ceil(Math.max(textW(sub, 10), chips) + 70))));
|
|
490
630
|
};
|
|
491
631
|
/* Geometry: node boxes, ports, orthogonal routes, label slots. A view with lines the router found no route for is laid
|
|
492
632
|
out once more, with room where those lines need it (#40); a view whose lines all route keeps its geometry. */
|
|
@@ -523,8 +663,11 @@ function layoutPass(dg, map, room, last) { // room: { x: { col: px }, y: { row:
|
|
|
523
663
|
return c ? [...(c.nodeIds || []), ...(dg.containers || []).filter(k => k.parent === b).flatMap(k => inBox(k.id, seen))] : []; };
|
|
524
664
|
const near = (b, other) => { const cs = inBox(b).map(i => colOf[i]).filter(c => c != null); if (other == null || !cs.length) return null;
|
|
525
665
|
const lo = Math.min(...cs), hi = Math.max(...cs); return other < lo ? lo : other > hi ? hi : null; };
|
|
666
|
+
// a box in a box (an unfolded numbered view's lane, #82, or any nested box): a line to a card in one, or to the box that
|
|
667
|
+
// holds it, crosses two borders
|
|
668
|
+
const inLane = id => (dg.containers || []).some(f => f.parent && (f.parent === id || (f.nodeIds || []).includes(id)));
|
|
526
669
|
(dg.edges || []).forEach(e => { const p = colOf[e.from] ?? near(e.from, colOf[e.to]), q = colOf[e.to] ?? near(e.to, colOf[e.from]); if (p == null || q == null || !e.label || Math.abs(p - q) !== 1) return;
|
|
527
|
-
const c = Math.min(p, q), w = pillW(e.label, e.sublabel) + (e.step != null ?
|
|
670
|
+
const c = Math.min(p, q), w = pillW(e.label, e.sublabel) + (e.step != null ? stepW(e.step) + 4 : 0), box = boxOf(e.from) !== boxOf(e.to) || !(e.from in colOf) || !(e.to in colOf) ? 48 + ([e.from, e.to].some(inLane) ? 12 : 0) : 0;
|
|
528
671
|
gapAfter[c] = Math.max(gapAfter[c] || 0, w + 54 + box);
|
|
529
672
|
[e.from, e.to].forEach(id => { const k = c + '|' + id, t = sum[k] ||= { w: -6, box: 0 }; t.w += w + 6; t.box = Math.max(t.box, box); gapAfter[c] = Math.max(gapAfter[c], t.w + 54 + t.box); }); }); }
|
|
530
673
|
const extraX = { ...(room || {}).x }, extraY = { ...(room || {}).y }; // more room after a column (row): for lines with no route, and where a box would touch another box (below)
|
|
@@ -692,7 +835,11 @@ function layoutPass(dg, map, room, last) { // room: { x: { col: px }, y: { row:
|
|
|
692
835
|
// in one direction, cards in one column on the left of a bottom side came in the wrong order, and their lines crossed.
|
|
693
836
|
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);
|
|
694
837
|
while (a <= -Math.PI) a += 2 * Math.PI; while (a > Math.PI) a -= 2 * Math.PI; return side === 'T' ? a : -a; };
|
|
695
|
-
|
|
838
|
+
// 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
|
|
839
|
+
// nearer takes the higher port, since the farther one's line passes under it (b10/overview's Reports 7 took the port
|
|
840
|
+
// under Billing 1's, whose line ran below Reports, and had no way in but round the map)
|
|
841
|
+
const lean = it => Math.atan2(it.other.y + it.other.h / 2 - my, Math.abs(it.other.x + it.other.w / 2 - mx));
|
|
842
|
+
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));
|
|
696
843
|
const sp = vertical ? null : sideSpan(n, side);
|
|
697
844
|
const len = vertical ? n.h : sp ? sp[1] - sp[0] : n.w, mid = sp ? (sp[0] + sp[1]) / 2 : n.x + n.w / 2;
|
|
698
845
|
const gap = Math.min(vertical ? 16 : 26, (len * 0.84) / Math.max(1, list.length)), cy = n.y + n.h / 2;
|
|
@@ -788,6 +935,9 @@ function layoutPass(dg, map, room, last) { // room: { x: { col: px }, y: { row:
|
|
|
788
935
|
}
|
|
789
936
|
for (let k = 0; k < N; k++) if (!inObs[k] && (!onAv[k] || allowCross)) { ok[k] = 1; crossAt[k] = onAv[k]; }
|
|
790
937
|
if (any) for (const r of reserved) for (let i = from(X, r.x - 3); i < nx && X[i] < r.x + r.w + 3; i++) for (let j = from(Y, r.y - 3); j < ny && Y[j] < r.y + r.h + 3; j++) crossAt[i * ny + j] = 1;
|
|
938
|
+
// a run within 8 px of a box's border costs ten times its length: the grid keeps a port's track and an earlier line's however
|
|
939
|
+
// near a border, and a box levelled half a row from a card (#86) puts its border right by that card's ports
|
|
940
|
+
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));
|
|
791
941
|
const ix = v => X.indexOf(v), iy = v => Y.indexOf(v);
|
|
792
942
|
const s0 = ix(st[0]) * ny + iy(st[1]), t0 = ix(en[0]) * ny + iy(en[1]);
|
|
793
943
|
if (!ok[s0] || !ok[t0]) return null;
|
|
@@ -820,7 +970,9 @@ function layoutPass(dg, map, room, last) { // room: { x: { col: px }, y: { row:
|
|
|
820
970
|
hpop(); const k = pk, cell = k >> 2, dir = k & 3, i = (cell / ny) | 0, j = cell - i * ny;
|
|
821
971
|
if (pd > dist[k] + (hx[i] + hy[j])) continue;
|
|
822
972
|
const d = dist[k];
|
|
823
|
-
if
|
|
973
|
+
// in head-on if it can; else the cheapest way in, the first found (it kept the last, the dearest: a top-to-top line on
|
|
974
|
+
// the top row ran past its card and back, #80)
|
|
975
|
+
if (cell === t0) { if (dir === dEnd) { best = k; break; } if (best < 0) best = k; continue; }
|
|
824
976
|
const xi = X[i], yj = Y[j];
|
|
825
977
|
for (let nd = 0; nd < 4; nd++) {
|
|
826
978
|
if ((nd ^ 1) === dir && ((nd >> 1) === (dir >> 1))) continue; // no reversal
|
|
@@ -829,6 +981,7 @@ function layoutPass(dg, map, room, last) { // room: { x: { col: px }, y: { row:
|
|
|
829
981
|
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;
|
|
830
982
|
let w = Math.abs(X[i2] - xi) + Math.abs(Y[j2] - yj) + (nd === dir ? 0 : BEND) + (crossAt[c2] ? CROSS : 0);
|
|
831
983
|
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));
|
|
984
|
+
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));
|
|
832
985
|
if (c2 === t0 && nd !== dEnd) w += BEND;
|
|
833
986
|
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])); }
|
|
834
987
|
}
|
|
@@ -863,7 +1016,7 @@ function layoutPass(dg, map, room, last) { // room: { x: { col: px }, y: { row:
|
|
|
863
1016
|
const segs = segsOf(e.pts).map((s, i) => ({ a: s[0], b: s[1], i, l: Math.hypot(s[1][0] - s[0][0], s[1][1] - s[0][1]), h: s[0][1] === s[1][1] }));
|
|
864
1017
|
const order = [...segs].sort((p, q) => (q.h - p.h) * 40 + (q.l - p.l));
|
|
865
1018
|
for (const pass of (map ? [0, 1, 2] : [0])) for (const text of tries) { const relax = pass === 2; // 0 strict, 1 strict but may touch a box border, 2 relaxed
|
|
866
|
-
const two = !!(text && sub), pill = map && e.step != null, w = map ? (text ? pillW(text, two && sub) : 0) + (pill ? (text ?
|
|
1019
|
+
const two = !!(text && sub), pill = map && e.step != null, w = map ? (text ? pillW(text, two && sub) : 0) + (pill ? stepW(e.step) + (text ? 4 : 6) : 0) : textW(text, 10) + 12, hgt = map ? (two ? 32 : 20) : 18;
|
|
867
1020
|
for (const sg of order) {
|
|
868
1021
|
if (!relax && sg.h && sg.l < w + 10) continue;
|
|
869
1022
|
if (!relax && !sg.h && sg.l < hgt + 14) continue;
|
|
@@ -876,7 +1029,7 @@ function layoutPass(dg, map, room, last) { // room: { x: { col: px }, y: { row:
|
|
|
876
1029
|
if (placed.some(r => (pass === 0 || !r.band) && hit(r, grow))) continue;
|
|
877
1030
|
if (!relax && routed.some(r => r !== e && segsOf(r.pts).some(([a, b]) => segHitsRect(a, b, grow)))) continue;
|
|
878
1031
|
if (!relax && segs.some(s2 => s2.i !== sg.i && segHitsRect(s2.a, s2.b, box))) continue;
|
|
879
|
-
placed.push(box); reserved.push(grow); e.lbl = { ...box, text, ...(two ? { sub } : {}), step: map ? e.step : null, short: text !== full }; return;
|
|
1032
|
+
placed.push(box); reserved.push(grow); e.lbl = { ...box, text, ...(two ? { sub } : {}), step: map ? e.step : null, ...(pill ? { sw: stepW(e.step) } : {}), short: text !== full }; return;
|
|
880
1033
|
}
|
|
881
1034
|
}
|
|
882
1035
|
}
|
|
@@ -937,8 +1090,10 @@ function layoutPass(dg, map, room, last) { // room: { x: { col: px }, y: { row:
|
|
|
937
1090
|
return { room: need, ms: { probe: performance.now() - t0 } };
|
|
938
1091
|
}
|
|
939
1092
|
}
|
|
940
|
-
// port repair: two routes that cross next to a shared node side swap ports, keep it if issues drop
|
|
941
|
-
|
|
1093
|
+
// port repair: two routes that cross next to a shared node side swap ports, keep it if issues drop. On a map a detour
|
|
1094
|
+
// 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
|
|
1095
|
+
// was kept (unfolding Catalog)
|
|
1096
|
+
const issueCount = only => routeAudit({ edges }, '', only).length + (map ? (only ? [...only] : edges).filter(e => detour(e.pts)).length : 0);
|
|
942
1097
|
const endAt = (e, n, side) => (e.s === n && e.fs === side) ? 'p1' : (e.t === n && e.ts === side) ? 'p2' : null;
|
|
943
1098
|
let issues = issueCount();
|
|
944
1099
|
// a swap re-routes only the two swapped edges (ripped up, then routed after every other route), not the whole
|
|
@@ -968,11 +1123,11 @@ function layoutPass(dg, map, room, last) { // room: { x: { col: px }, y: { row:
|
|
|
968
1123
|
if (budget <= 0) break outer3;
|
|
969
1124
|
const pa = a[ka], pb = b[kb]; a[ka] = pb; b[kb] = pa; restub(a); restub(b);
|
|
970
1125
|
const snap = [a, b].map(e => [e, e.pts, e.lbl, e.how]), snapR = [routed.slice(), reserved.slice(), placed.slice()];
|
|
971
|
-
const ab = new Set([a, b]), was =
|
|
1126
|
+
const ab = new Set([a, b]), was = issueCount(ab); // issues away from a and b cannot change
|
|
972
1127
|
budget -= 2; __ms.reroutes++;
|
|
973
1128
|
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
|
|
974
1129
|
ripUp(a); ripUp(b);
|
|
975
|
-
const now = routeOne(a) && routeOne(b) ? issues - was +
|
|
1130
|
+
const now = routeOne(a) && routeOne(b) ? issues - was + issueCount(ab) : Infinity;
|
|
976
1131
|
if (now < issues) { issues = now; fixed = swapped = true; if (!issues) break outer3; continue outer3; }
|
|
977
1132
|
a[ka] = pa; b[kb] = pb; restub(a); restub(b);
|
|
978
1133
|
snap.forEach(([e, p, l, hw]) => { e.pts = p; e.lbl = l; e.how = hw; });
|
|
@@ -1151,7 +1306,7 @@ return I && I.src ? `<rect x="${x - M * 3 / 28}" y="${y - M * 3 / 28}" width="${
|
|
|
1151
1306
|
let vx = 0; const chips = [], maxW = third ? sw : n.w - 24; // on a third line they keep to the text's column, clear of the mark
|
|
1152
1307
|
for (let i = 0; i < verbs.length; i++) {
|
|
1153
1308
|
const wv = textW(verbs[i], 9.5) + 10, rest = verbs.length - i - 1, more = rest ? textW('+' + rest, 9.5) + 10 : 0;
|
|
1154
|
-
if (vx + wv + (rest ? more + 4 : 0) > maxW
|
|
1309
|
+
if (vx + wv + (rest ? more + 4 : 0) > maxW) { chips.push(['+' + (verbs.length - i), textW('+' + (verbs.length - i), 9.5) + 10, true]); break; } // the first too: a verb wider than any card is "+N" (#84)
|
|
1155
1310
|
chips.push([verbs[i], wv]); vx += wv + 4;
|
|
1156
1311
|
}
|
|
1157
1312
|
const total = chips.reduce((a, c2) => a + c2[1], 0) + (chips.length - 1) * 4;
|
|
@@ -1182,8 +1337,9 @@ return I && I.src ? `<rect x="${x - M * 3 / 28}" y="${y - M * 3 / 28}" width="${
|
|
|
1182
1337
|
if (!e.lbl) return;
|
|
1183
1338
|
const L = e.lbl;
|
|
1184
1339
|
if (map) {
|
|
1185
|
-
const
|
|
1186
|
-
const
|
|
1340
|
+
const sw = L.sw || 16, cy = L.y + L.h / 2; // the step mark: a circle, or a pill as wide as a longer number (#82)
|
|
1341
|
+
const st = L.step != null ? `${sw > 16 ? `<rect x="${L.x + 3}" y="${cy - 8}" width="${sw}" height="16" rx="8" class="e-step"/>` : `<circle cx="${L.x + 11}" cy="${cy}" r="8" class="e-step"/>`}<text x="${L.x + 3 + sw / 2}" y="${cy + 3.3}" text-anchor="middle" class="e-step-tx">${esc(L.step)}</text>` : '';
|
|
1342
|
+
const ax = L.step != null ? L.x + 7 + sw : L.x + L.w / 2, an = L.step != null ? '' : 'text-anchor="middle"';
|
|
1187
1343
|
const tx = L.text ? `<text x="${ax}" y="${L.y + 14}" ${an} class="e-label" data-t="el" data-o="${esc(e.from)}>${esc(e.to)}">${esc(L.text)}</text>` : '';
|
|
1188
1344
|
const sb = L.sub ? `<text x="${ax}" y="${L.y + 26}" ${an} class="e-sub" data-t="el" data-o="${esc(e.from)}>${esc(e.to)}">${esc(L.sub)}</text>` : '';
|
|
1189
1345
|
s += `<g class="e-lab${L.tag ? ' ctag' : ''}${e.wk ? ' w-' + e.wk : ''}" data-i="${i}"><rect x="${L.x}" y="${L.y}" width="${L.w}" height="${L.h}" rx="${Math.min(L.h / 2, 10)}" class="e-lbg"/>${st}${tx}${sb}</g>`;
|
|
@@ -1330,6 +1486,9 @@ class Camera {
|
|
|
1330
1486
|
// an issue's message, and what it is about (the cards, lines and boxes it names) when the caller keeps a list of that: check()
|
|
1331
1487
|
// turns those into Refs; the page's check reads only the messages
|
|
1332
1488
|
const teller = (issues, refs) => (message, ...about) => { issues.push(message); if (refs) refs.push(about); };
|
|
1489
|
+
// a detour: a route 2.5 times longer than the straight distance between its ends, or than 100 px; [its length, that distance]
|
|
1490
|
+
const detour = pts => { const len = pts.slice(1).reduce((a, p, k) => a + Math.abs(p[0] - pts[k][0]) + Math.abs(p[1] - pts[k][1]), 0);
|
|
1491
|
+
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; };
|
|
1333
1492
|
function routeAudit(geo, where, only, refs) { // only: a set of edges; counts just the issues that involve one of them
|
|
1334
1493
|
const sk = e => only && !only.has(e);
|
|
1335
1494
|
const issues = [], say = teller(issues, refs);
|
|
@@ -1365,10 +1524,7 @@ function routeAudit(geo, where, only, refs) { // only: a set of edges; counts ju
|
|
|
1365
1524
|
// clearance: a route keeps 12+ px from every node it does not connect
|
|
1366
1525
|
geo.nodes.forEach(n => { if (n.id === e.from || n.id === e.to) return;
|
|
1367
1526
|
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); });
|
|
1368
|
-
|
|
1369
|
-
const len = e.pts.slice(1).reduce((a, p, k) => a + Math.abs(p[0] - e.pts[k][0]) + Math.abs(p[1] - e.pts[k][1]), 0);
|
|
1370
|
-
const d = Math.hypot(e.pts[e.pts.length - 1][0] - e.pts[0][0], e.pts[e.pts.length - 1][1] - e.pts[0][1]);
|
|
1371
|
-
if (len > 2.5 * Math.max(d, 100)) say(`${where}: edge ${name(e)} takes a detour (${Math.round(len)}px for ${Math.round(d)}px)`, e);
|
|
1527
|
+
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);
|
|
1372
1528
|
// reading order: every wire runs left to right, except one that closes a loop or answers its pair (keepPairs)
|
|
1373
1529
|
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);
|
|
1374
1530
|
});
|
|
@@ -1447,6 +1603,8 @@ function auditSvg(svg, geo, where) {
|
|
|
1447
1603
|
nodes.forEach(n => { if (n !== own && hit(a.b, R(n), -1)) issues.push(`${where}: text "${a.txt}" overlaps node "${n.label}"`); });
|
|
1448
1604
|
}
|
|
1449
1605
|
});
|
|
1606
|
+
svg.querySelectorAll('rect.verb-chip').forEach(r => { const own = geo.byId[r.closest('[data-id]').dataset.id], x = +r.getAttribute('x'); // a chip's pill, not only its text (#84)
|
|
1607
|
+
if (own && (x < own.x + 1 || x + +r.getAttribute('width') > own.x + own.w - 1)) issues.push(`${where}: chip "${r.nextElementSibling.textContent}" overflows node "${own.label}"`); });
|
|
1450
1608
|
issues.push(...routeAudit(geo, where), ...leaveIssues(geo, where));
|
|
1451
1609
|
const els = T.filter(a => a.t === 'el');
|
|
1452
1610
|
for (let i = 0; i < els.length; i++) for (let j = i + 1; j < els.length; j++) if (hit(els[i].b, els[j].b, -1)) issues.push(`${where}: edge labels "${els[i].txt}" and "${els[j].txt}" overlap`);
|
|
@@ -1653,7 +1811,11 @@ function mapContext(model, opts = {}) {
|
|
|
1653
1811
|
/* plain views: longest-path columns from the roots, state at the bottom of its column */
|
|
1654
1812
|
/* views with containers: each container is a block in columns of its own, its cards in call order inside it; blocks (and
|
|
1655
1813
|
cards in no container) run left to right by the lines between them, and those with no line between them stack in the
|
|
1656
|
-
same columns. A nested container is a block inside its parent's.
|
|
1814
|
+
same columns. A nested container is a block inside its parent's.
|
|
1815
|
+
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
|
|
1816
|
+
lines come from and go to outside it too, where the pass before put them; then ordered by those lines as well. Its far
|
|
1817
|
+
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
|
|
1818
|
+
placed by its own lines alone, so a box fed from outside, and the lines that pass it, ran into the cards beside it. */
|
|
1657
1819
|
function blockPos(vid) {
|
|
1658
1820
|
const v = V[vid], ids = (v.nodeIds || []).filter(i => N[i]), lp = loopsOf(v), boxes = (v.containers || []).filter(c => c && c.id);
|
|
1659
1821
|
const fwd = lp.edges.filter((e, k) => !lp.back.has(k) && e.from !== e.to), byBox = Object.fromEntries(boxes.map(b => [b.id, b]));
|
|
@@ -1664,12 +1826,13 @@ function mapContext(model, opts = {}) {
|
|
|
1664
1826
|
const first = x => { const k = lp.edges.findIndex(e => holds(x, e.from) || holds(x, e.to)); return k < 0 ? Infinity : k; };
|
|
1665
1827
|
const sink = x => ids.includes(x) && isState(x) && !fwd.some(e => e.from === x); // state that leads nowhere goes last
|
|
1666
1828
|
const lined = x => lp.edges.some(e => e.from !== e.to && (holds(x, e.from) || holds(x, e.to)));
|
|
1667
|
-
|
|
1829
|
+
let was = {}, now = {}; // each box as the pass before laid it out, and as this one does
|
|
1830
|
+
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
|
|
1668
1831
|
const all = [...ids.filter(i => home[i] === g), ...boxes.filter(b => up(b.id) === g).map(b => b.id)];
|
|
1669
1832
|
// 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
|
|
1670
1833
|
// their box, or under the view; they stacked in the first column with the sources
|
|
1671
1834
|
const loose = all.some(lined) ? all.filter(x => ids.includes(x) && !lined(x)) : [], items = all.filter(x => !loose.includes(x)), size = {}, out = {};
|
|
1672
|
-
items.forEach(x => { size[x] = ids.includes(x) ? { w: 1, h: 1, pos: { [x]: [0, 0] } } : block(x); out[x] = []; });
|
|
1835
|
+
items.forEach(x => { size[x] = ids.includes(x) ? { w: 1, h: 1, pos: { [x]: [0, 0] } } : block(x, P, order); out[x] = []; });
|
|
1673
1836
|
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); });
|
|
1674
1837
|
const st = {}, dag = [], into = new Set(Object.values(out).flat()); // lines that close a loop do not order the blocks
|
|
1675
1838
|
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; };
|
|
@@ -1679,10 +1842,22 @@ function mapContext(model, opts = {}) {
|
|
|
1679
1842
|
const cols = [], pos = {}; let c0 = 0, h = 0;
|
|
1680
1843
|
items.forEach(x => (cols[rank[x]] ||= []).push(x));
|
|
1681
1844
|
const main = cols.map(l => (l || []).filter(x => !sink(x)).sort((a, b) => first(a) - first(b)));
|
|
1682
|
-
untangle(main, dag); // in call order, unless another order crosses less (#53)
|
|
1683
1845
|
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
|
|
1684
|
-
const
|
|
1685
|
-
|
|
1846
|
+
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);
|
|
1847
|
+
return a !== undefined && b !== undefined && a !== b ? { e, link: [a, b, inside(a, e.from), inside(b, e.to)] } : null; }).filter(Boolean);
|
|
1848
|
+
// 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.
|
|
1849
|
+
// 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
|
|
1850
|
+
// own column, or a line to a box, places nothing; passes until nothing moves, if a map needs them
|
|
1851
|
+
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;
|
|
1852
|
+
if (top != null) fwd.forEach(e => { const fin = holds(g, e.from); if (fin === holds(g, e.to)) return;
|
|
1853
|
+
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;
|
|
1854
|
+
const id = '\u0002' + far.length, left = q[0] < p[0]; fixed[id] = q[1] - top;
|
|
1855
|
+
far.push({ e, id, left, link: left ? [id, x, 0, inside(x, mine)] : [x, id, inside(x, mine), 0] }); });
|
|
1856
|
+
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] : [])];
|
|
1857
|
+
const hh = x => size[x] ? size[x].h : 1;
|
|
1858
|
+
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)
|
|
1859
|
+
main.splice(0, main.length, ...M.slice(L.length ? 1 : 0, M.length - (R.length ? 1 : 0)));
|
|
1860
|
+
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
|
|
1686
1861
|
main.forEach((l, k) => { cols[k] = [...l, ...(cols[k] || []).filter(sink).sort((a, b) => first(a) - first(b))]; });
|
|
1687
1862
|
cols.forEach(list => { let r0 = 0, w = 0;
|
|
1688
1863
|
list.forEach(x => { const sz = size[x]; if (row[x] != null) r0 = Math.max(r0, row[x]);
|
|
@@ -1690,9 +1865,11 @@ function mapContext(model, opts = {}) {
|
|
|
1690
1865
|
c0 += w; h = Math.max(h, r0); });
|
|
1691
1866
|
const per = Math.max(2, c0); loose.forEach((x, k) => { pos[x] = [k % per, h + Math.floor(k / per)]; });
|
|
1692
1867
|
if (loose.length) { c0 = Math.max(c0, Math.min(per, loose.length)); h += Math.ceil(loose.length / per); }
|
|
1693
|
-
return { w: c0, h, pos };
|
|
1868
|
+
return (now[g] = { w: c0, h, pos });
|
|
1694
1869
|
};
|
|
1695
|
-
|
|
1870
|
+
let P = block(null).pos;
|
|
1871
|
+
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
|
|
1872
|
+
return P;
|
|
1696
1873
|
}
|
|
1697
1874
|
const autoPos = vid => ((V[vid] || {}).containers || []).length ? blockPos(vid) : bestFlow(vid);
|
|
1698
1875
|
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
|
|
@@ -1784,7 +1961,7 @@ function mapContext(model, opts = {}) {
|
|
|
1784
1961
|
const ready = drawn[vid]; delete drawn[vid]; if (ready && !override && ready.pos === pos && repair === (v.repair !== false) && !quick) return ready.g;
|
|
1785
1962
|
const mainSteps = v.mainSteps || v.render === 'process';
|
|
1786
1963
|
const stepIds = v.stepIds || ids;
|
|
1787
|
-
const dg = { nodeH: mainSteps ? 82 : v.subAndChips ? 80 : 64, subAndChips: !!v.subAndChips, mainSteps, containers: v.containers, nodes: ids.map(i => { const n = nodeIn(i, vid), step = mainSteps && stepIds.includes(i); return { ...n, sub: n.sub || n.sublabel, emphasis: !!step || !!n.emphasis, step: step ? stepIds.indexOf(i) + 1 : null, verbs: step ? [] : n.verbs, kids: kidCount(i, vid), opens: !!opensOf(i, vid), col: (pos[i] || [0, 0])[0], row: (pos[i] || [0, 0])[1] }; }),
|
|
1964
|
+
const dg = { nodeH: mainSteps ? 82 : v.subAndChips ? 80 : 64, subAndChips: !!v.subAndChips, mainSteps, containers: v.containers, nodes: ids.map(i => { const n = nodeIn(i, vid), step = mainSteps && stepIds.includes(i); return { ...n, sub: n.sub || n.sublabel, emphasis: !!step || !!n.emphasis, step: step ? stepIds.indexOf(i) + 1 : (v.cardSteps || {})[i] || null, verbs: step ? [] : n.verbs, kids: kidCount(i, vid), opens: !!opensOf(i, vid), col: (pos[i] || [0, 0])[0], row: (pos[i] || [0, 0])[1] }; }),
|
|
1788
1965
|
edges: prepEdges(vid, edges, P.backKeys) };
|
|
1789
1966
|
dg.authors = D.authors; dg.repair = repair; dg.intake = v.intake; dg.tour = v.tour; dg.gap = v.gap; dg.rgap = v.rgap; dg.quick = quick;
|
|
1790
1967
|
return layoutGraph(dg, true);
|
|
@@ -1851,7 +2028,7 @@ function mapContext(model, opts = {}) {
|
|
|
1851
2028
|
const t0 = performance.now(); let wall = 4000; // per-view time box, extended while problems remain
|
|
1852
2029
|
const v = V[vid], ids = (v.nodeIds || []).filter(i => N[i]);
|
|
1853
2030
|
let pos = JSON.parse(JSON.stringify(autoPos(vid)));
|
|
1854
|
-
const boxed = (v.containers || []).length, homeOf = i => ((v.containers || []).filter(c => (c.nodeIds || []).includes(i)).pop() || {}).id; // blocks stay whole
|
|
2031
|
+
const start = JSON.stringify(pos), boxed = (v.containers || []).length, homeOf = i => ((v.containers || []).filter(c => (c.nodeIds || []).includes(i)).pop() || {}).id; // blocks stay whole
|
|
1855
2032
|
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);
|
|
1856
2033
|
let best = scoreGeo(buildGeo(vid, pos, false, true)), evals = 1;
|
|
1857
2034
|
const hub = subjectOf(v), hubCol = hub && pos[hub] ? pos[hub][0] : null;
|
|
@@ -1887,6 +2064,9 @@ function mapContext(model, opts = {}) {
|
|
|
1887
2064
|
}
|
|
1888
2065
|
}
|
|
1889
2066
|
}
|
|
2067
|
+
// it scores without port repair: render()'s layout stays unless the one it found routes better as the page draws it (#86: for
|
|
2068
|
+
// r15's monorepo it baked in a crossing that render()'s layout has not)
|
|
2069
|
+
if (JSON.stringify(pos) !== start && scoreGeo(buildGeo(vid, JSON.parse(start))) <= scoreGeo(buildGeo(vid, pos))) pos = JSON.parse(start);
|
|
1890
2070
|
return { pos, score: best, evals };
|
|
1891
2071
|
}
|
|
1892
2072
|
// what a view's picture is drawn from: an update keeps the layout of every view whose key did not change
|
|
@@ -1902,20 +2082,26 @@ function mapContext(model, opts = {}) {
|
|
|
1902
2082
|
same way round, or, with none, ends on the box's border; the inner view's other lines to neighbours are not added. The
|
|
1903
2083
|
box is the inner view's own cards in their own order, at the card's place: later columns move right by its width, and
|
|
1904
2084
|
the cards under it in its column move down by its height, so the rest of the picture keeps its places (a hub view, which
|
|
1905
|
-
has no columns, is laid out again). The inner view's boxes that hold its own cards come along inside
|
|
1906
|
-
|
|
2085
|
+
has no columns, is laid out again). The inner view's boxes that hold its own cards come along inside, and a numbered
|
|
2086
|
+
(sequence) view's run order: its numbers, its loops back and its lanes (#82). Not in a numbered view: its own steps
|
|
2087
|
+
would be numbered again; that card opens. */
|
|
2088
|
+
// the inner view's own cards: those this view shows neither as cards nor as boxes (a card unfolded here already is one, #98)
|
|
2089
|
+
const ownOf = (v, C, nid) => (C.nodeIds || []).filter(i => N[i] && i !== nid && !(v.nodeIds || []).includes(i) && !(v.containers || []).some(b => b && b.id === i));
|
|
1907
2090
|
const unfoldable = (nid, vid) => { const v = V[vid], ids = (v && v.nodeIds) || [], c = ids.includes(nid) && N[nid] && v.render !== 'sequence' ? childOf(nid, vid) : null;
|
|
1908
|
-
if (!c || c === vid ||
|
|
1909
|
-
return (V[c]
|
|
2091
|
+
if (!c || c === vid || chain(vid).includes(c) || (v.containers || []).some(b => b && b.id === nid)) return null;
|
|
2092
|
+
return ownOf(v, V[c], nid).length ? c : null; };
|
|
1910
2093
|
function unfold(vid, nid) {
|
|
1911
2094
|
const v = V[vid], c = unfoldable(nid, vid); if (!c) return false;
|
|
1912
|
-
const C = V[c], outer = (v.nodeIds || []).filter(i => N[i]), inner = (C
|
|
2095
|
+
const C = V[c], outer = (v.nodeIds || []).filter(i => N[i]), inner = ownOf(v, C, nid);
|
|
1913
2096
|
const n0 = nodeIn(nid, vid), holder = (v.containers || []).filter(b => b && (b.nodeIds || []).includes(nid)).pop();
|
|
1914
2097
|
const boxes = (v.containers || []).filter(b => b && b.id).map(b => ({ ...b, nodeIds: (b.nodeIds || []).filter(i => i !== nid) }));
|
|
1915
2098
|
const taken = new Set([...outer, ...inner, ...boxes.map(b => b.id)]);
|
|
1916
2099
|
const cb = (C.containers || []).filter(b => b && b.id && !taken.has(b.id) && (b.nodeIds || []).some(i => inner.includes(i))), kept = new Set(cb.map(b => b.id));
|
|
1917
2100
|
const above = b => { let p = b.parent; for (let k = 0; p != null && !kept.has(p) && k < 64; k++) p = ((C.containers || []).find(x => x && x.id === p) || {}).parent; return kept.has(p) ? p : nid; };
|
|
1918
|
-
|
|
2101
|
+
// a numbered view drawn around one card (a hub) brings its lanes, boxed as it draws them, in their order (#82)
|
|
2102
|
+
const hp = C.render === 'sequence' && planOf(c).hub ? planOf(c) : null;
|
|
2103
|
+
const lanes = hp && hp.boxed ? hp.lanes.map((L, j) => ({ id: `${nid}:lane${j + 1}`, label: L.title, lane: true, nodeIds: L.ids.filter(i => inner.includes(i)), parent: nid })).filter(b => b.nodeIds.length) : [];
|
|
2104
|
+
const own = [...cb.map(b => ({ ...b, nodeIds: b.nodeIds.filter(i => inner.includes(i)), parent: above(b) })), ...lanes], boxed = new Set(own.flatMap(b => b.nodeIds));
|
|
1919
2105
|
const box = { id: nid, label: n0.label || nid, nodeIds: inner.filter(i => !boxed.has(i)), unfolds: c, ...(holder ? { parent: holder.id } : {}), ...(badgesOf(n0).length ? { badges: badgesOf(n0) } : {}) };
|
|
1920
2106
|
const isIn = id => inner.includes(id) || kept.has(id), mine = viewEdges(v);
|
|
1921
2107
|
const to = new Set(mine.filter(e => e.to === nid).map(e => e.from)), from = new Set(mine.filter(e => e.from === nid).map(e => e.to)); // this view's neighbours of the card
|
|
@@ -1924,6 +2110,16 @@ function mapContext(model, opts = {}) {
|
|
|
1924
2110
|
const ends = (a, out) => [...new Set(theirs.filter(e => out ? isIn(e.from) && e.to === a : e.from === a && isIn(e.to)).map(e => out ? e.from : e.to))].join();
|
|
1925
2111
|
const split = new Set([...to].filter(a => from.has(a) && (ends(a, true) || ends(a, false)) && (ends(a, true) !== ends(a, false) || ends(a, true).includes(','))));
|
|
1926
2112
|
theirs = theirs.filter(e => !split.has(isIn(e.from) ? e.to : e.from));
|
|
2113
|
+
/* a numbered view's run order comes along (#82): its lines keep their numbers, and a step back stays a loop back. A step
|
|
2114
|
+
between the card itself and a card inside has no line (the card is the box), and a hub's steps show on the cards they
|
|
2115
|
+
reach, as the numbered view draws them: those numbers go on that card inside. On a numbered card (a process view's
|
|
2116
|
+
step, or one an unfolded numbered view numbered) they go under its number: step 4's are 4.1, 4.2 */
|
|
2117
|
+
const tags = {}, onCard = e => e.from === nid ? e.to : e.to === nid ? e.from : hp && e.from === hp.hub ? e.to : null;
|
|
2118
|
+
const sid = v.stepIds || outer, at = (v.mainSteps || v.render === 'process') && sid.includes(nid) ? sid.indexOf(nid) + 1 : (v.cardSteps || {})[nid];
|
|
2119
|
+
const num = j => /^\d+(\.\d+)*$/.test(String(at ?? '')) ? `${at}.${j + 1}` : j + 1; // a card with several numbers (2·3) holds no one step
|
|
2120
|
+
if (C.render === 'sequence') { const lq = loopsOf(C);
|
|
2121
|
+
lq.edges.forEach((e, j) => { const o = onCard(e); if (inner.includes(o)) (tags[o] ||= []).push(num(j)); });
|
|
2122
|
+
theirs = theirs.map(e => { const j = lq.edges.indexOf(e); return { ...e, ...(inner.includes(onCard(e)) ? {} : { step: num(j) }), ...(lq.back.has(j) ? { loop: true } : {}) }; }); }
|
|
1927
2123
|
const exact = new Set(theirs.flatMap(e => isIn(e.from) === isIn(e.to) ? [] : isIn(e.to) ? [e.from + '>'] : ['>' + e.to])); // neighbours the inner view joins to a card inside
|
|
1928
2124
|
const k = (v.nodeIds || []).indexOf(nid), grid = !planOf(vid).hub;
|
|
1929
2125
|
const pos = grid ? (v.layout ? fillLayout(vid, v.layout) : (layoutCache[vid] ||= autoPos(vid))) : null;
|
|
@@ -1933,6 +2129,7 @@ function mapContext(model, opts = {}) {
|
|
|
1933
2129
|
if (v.mainSteps || v.render === 'process') v.stepIds = v.stepIds || outer; // the steps keep their numbers; the cards inside are not steps
|
|
1934
2130
|
if (Array.isArray(v.intake)) v.intake = v.intake.filter(s => s && s.to !== nid);
|
|
1935
2131
|
if (+C.gap > (v.gap || 66)) v.gap = +C.gap; if (+C.rgap > (v.rgap || 44)) v.rgap = +C.rgap; // the room the inner view's lines were given
|
|
2132
|
+
Object.entries(tags).forEach(([i, s]) => { (v.cardSteps ||= {})[i] = s.length > 2 ? s[0] + '+' : s.join('·'); }); // as hubGeo's stepTxt; buildGeo draws them
|
|
1936
2133
|
delete v.layout; delete planCache[vid]; delete layoutCache[vid]; delete drawn[vid];
|
|
1937
2134
|
if (!grid) return true; // a hub view has no columns to keep: laid out again, with its boxes
|
|
1938
2135
|
/* the box's slots: the inner view's own cards in its columns, or in this view's reading order, and in its rows packed,
|
|
@@ -1950,7 +2147,11 @@ function mapContext(model, opts = {}) {
|
|
|
1950
2147
|
// with another, or not at all
|
|
1951
2148
|
const inBox = (id, seen = new Set()) => seen.has(id) ? [] : (seen.add(id), [...((boxes.find(x => x.id === id) || {}).nodeIds || []), ...boxes.filter(k => k.parent === id).flatMap(k => inBox(k.id, seen))]);
|
|
1952
2149
|
const xBoxes = new Set(); for (let b = holder, k = 0; b && k < 64; k++, b = boxes.find(x => x.id === b.parent)) xBoxes.add(b.id); // the boxes the card is in
|
|
1953
|
-
|
|
2150
|
+
// a hub's slots, as it draws them: the hub, then its lanes' columns in order (#82). ponytail: rows in each column's order,
|
|
2151
|
+
// not level with the part before as hubGeo lines them up; do that if a lane of several columns reads badly
|
|
2152
|
+
const hs = hp && { [hp.hub]: [0, 0] };
|
|
2153
|
+
if (hs) hp.lanes.flatMap(L => L.cols).map(col => col.filter(i => inner.includes(i))).filter(col => col.length).forEach((col, j) => col.forEach((i, r) => { hs[i] = [j + 1, r]; }));
|
|
2154
|
+
const ip0 = hs || (C.layout ? fillLayout(c, C.layout) : (layoutCache[c] ||= autoPos(c))), lp = loopsOf(v), rk = Object.fromEntries(inner.map(i => [i, 0]));
|
|
1954
2155
|
const fwd = lp.edges.filter((e, j) => !lp.back.has(j) && e.from !== e.to && inner.includes(e.from) && inner.includes(e.to));
|
|
1955
2156
|
for (let it = 0; it < inner.length; it++) fwd.forEach(e => { if (rk[e.to] < rk[e.from] + 1) rk[e.to] = rk[e.from] + 1; });
|
|
1956
2157
|
const one = own.length || v.nodeIds.length > 30; // its boxes' room to keep, or too big to route seven times: its own slots only
|
|
@@ -2036,6 +2237,7 @@ export function validate(model) {
|
|
|
2036
2237
|
if (ids.has(n.id)) err(at + '.id', `card id "${n.id}" is used twice`); ids.add(n.id);
|
|
2037
2238
|
const k = String(n.kind ?? '').toLowerCase();
|
|
2038
2239
|
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`);
|
|
2240
|
+
if (n.owner != null && typeof n.owner !== 'string') warn(at + '.owner', 'an owner is a name: ignored');
|
|
2039
2241
|
};
|
|
2040
2242
|
arr(m.nodes).forEach((n, i) => card(n, `nodes[${i}]`));
|
|
2041
2243
|
views.forEach((v, i) => arr(v.nodes).forEach((n, j) => card(n, `views[${i}].nodes[${j}]`)));
|
|
@@ -2049,6 +2251,7 @@ export function validate(model) {
|
|
|
2049
2251
|
if (e.kind != null && !WIRE_KINDS.includes(e.kind)) warn(at + '.kind', `unknown line kind "${e.kind}": drawn as calls`);
|
|
2050
2252
|
if (e.weight != null && ![1, 2, 3].includes(e.weight)) warn(at + '.weight', `weight ${JSON.stringify(e.weight)} is not 1, 2 or 3: drawn as 1`);
|
|
2051
2253
|
['fromSide', 'toSide'].forEach(k => { if (e[k] != null && !SIDES.includes(e[k])) warn(`${at}.${k}`, `"${e[k]}" is not left, right, top or bottom: ignored`); });
|
|
2254
|
+
['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)
|
|
2052
2255
|
};
|
|
2053
2256
|
// a model line may end on any view's box (#28); a view's own line only on that view's boxes
|
|
2054
2257
|
const anyBox = new Set(views.flatMap(v => arr(v.containers).map(c => c && c.id)));
|
|
@@ -2076,13 +2279,16 @@ export function validate(model) {
|
|
|
2076
2279
|
// rules (#70): an id, and from, to or noCycles; a selector that names nothing picks nothing, and a misspelt kind picks script cards
|
|
2077
2280
|
if (m.rules != null && !Array.isArray(m.rules)) err('rules', 'rules is a list');
|
|
2078
2281
|
const ruleIds = new Set(), allBoxes = new Set(anyBox), each = (x, at, f) => (Array.isArray(x) ? x : [x]).forEach((y, j) => { if (y != null) f(y, Array.isArray(x) ? `${at}[${j}]` : at); });
|
|
2079
|
-
const
|
|
2282
|
+
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'));
|
|
2283
|
+
const pick = (s, at, lens) => { // lens: a lens's pick (#94), which also takes line kinds at its top
|
|
2080
2284
|
if (s == null) return;
|
|
2081
|
-
if (typeof s !== 'object' || Array.isArray(s)) return err(at,
|
|
2082
|
-
Object.keys(s).forEach(k => { if (!['id', 'kind', 'container', 'not'].includes(k)) warn(`${at}.${k}`, `unknown key "${k}": ignored, so the selector picks more cards`); });
|
|
2285
|
+
if (typeof s !== 'object' || Array.isArray(s)) return err(at, `a selector is an object: id, kind, container, owner${lens ? ', line' : ''} or not`);
|
|
2286
|
+
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`); });
|
|
2083
2287
|
each(s.id, at + '.id', (x, p) => { if (!ids.has(x) && !allBoxes.has(x)) warn(p, `"${x}" is neither a card nor a box: it picks nothing`); });
|
|
2084
2288
|
each(s.kind, at + '.kind', (x, p) => { const k = String(x).toLowerCase(); if (!(k in KIND || k in MAPK || k in ALIAS)) warn(p, `unknown kind "${x}": it picks script cards`); });
|
|
2085
2289
|
each(s.container, at + '.container', (x, p) => { if (!allBoxes.has(x)) warn(p, `"${x}" is not a box: it picks nothing`); });
|
|
2290
|
+
each(s.owner, at + '.owner', (x, p) => { if (!owners.has(x)) warn(p, `no card's owner is "${x}": it picks nothing`); });
|
|
2291
|
+
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`); });
|
|
2086
2292
|
pick(s.not, at + '.not');
|
|
2087
2293
|
};
|
|
2088
2294
|
arr(m.rules).forEach((r, i) => {
|
|
@@ -2093,6 +2299,16 @@ export function validate(model) {
|
|
|
2093
2299
|
pick(r.from, at + '.from'); pick(r.to, at + '.to');
|
|
2094
2300
|
arr(r.views).forEach((v, j) => { if (!vids.has(v)) err(`${at}.views[${j}]`, `"${v}" is not a view`); });
|
|
2095
2301
|
});
|
|
2302
|
+
// lenses (#94): an id each, and a pick as a rule's selector, which also takes line kinds
|
|
2303
|
+
if (m.lenses != null && !Array.isArray(m.lenses)) err('lenses', 'lenses is a list');
|
|
2304
|
+
const lensIds = new Set();
|
|
2305
|
+
arr(m.lenses).forEach((l, i) => {
|
|
2306
|
+
const at = `lenses[${i}]`;
|
|
2307
|
+
if (!l || typeof l !== 'object') return err(at, 'a lens is an object with an id');
|
|
2308
|
+
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);
|
|
2309
|
+
pick(l.pick, at + '.pick', true);
|
|
2310
|
+
if (l.color != null && l.color !== 'owner') warn(at + '.color', `color ${JSON.stringify(l.color)} is not "owner": ignored`);
|
|
2311
|
+
});
|
|
2096
2312
|
// stories (#69): each step's view, and the cards, lines and tour it names there
|
|
2097
2313
|
if (m.stories != null && !Array.isArray(m.stories)) err('stories', 'stories is a list of stories');
|
|
2098
2314
|
const sids = new Set(), lines = new Set([...lineIds, ...views.flatMap(v => arr(v.edges).filter(e => e && e.id != null).map(e => e.id))]);
|
|
@@ -2132,21 +2348,28 @@ const boxesNamed = (s, isBox) => !s || typeof s !== 'object' ? []
|
|
|
2132
2348
|
const picks = (s, end) => !s || ((s.id == null || [s.id].flat().includes(end.id))
|
|
2133
2349
|
&& (s.kind == null || [s.kind].flat().some(k => end.kind != null && kindOf(k, true) === end.kind))
|
|
2134
2350
|
&& (s.container == null || [s.container].flat().some(c => end.boxes.includes(c)))
|
|
2351
|
+
&& (s.owner == null || [s.owner].flat().includes(end.owner))
|
|
2135
2352
|
&& (s.not == null || !picks(s.not, end)));
|
|
2136
2353
|
const andList = xs => xs.length > 1 ? xs.slice(0, -1).join(', ') + ' and ' + xs[xs.length - 1] : xs.join(''), arrOf = x => (Array.isArray(x) ? x : []);
|
|
2354
|
+
// 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
|
|
2355
|
+
// first) and its owner; a box is in itself and its parents, and has no kind or owner
|
|
2356
|
+
function endsIn(ctx, v) {
|
|
2357
|
+
const cs = arrOf(v.containers).filter(c => c && c.id), box = {}; cs.forEach(c => { box[c.id] ||= c; });
|
|
2358
|
+
const home = {}; cs.forEach(c => arrOf(c.nodeIds).forEach(id => { home[id] ??= c.id; }));
|
|
2359
|
+
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
|
|
2360
|
+
const end = id => box[id] ? { id, kind: null, boxes: chain(id), label: box[id].label || id }
|
|
2361
|
+
: (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));
|
|
2362
|
+
return { box, end };
|
|
2363
|
+
}
|
|
2137
2364
|
/** Every rule the model breaks, in each view that draws it broken: a line a rule forbids, or a cycle of boxes. */
|
|
2138
2365
|
export function rules(model) {
|
|
2139
2366
|
const list = arrOf(model && model.rules).filter(r => r && typeof r === 'object' && (r.from || r.to || r.noCycles));
|
|
2140
2367
|
if (!list.length) return [];
|
|
2141
|
-
const { views, N,
|
|
2368
|
+
const ctx = mapContext(model), { views, N, viewEdges } = ctx, out = [];
|
|
2142
2369
|
const anyBox = new Set(views.flatMap(v => arrOf(v.containers).map(c => c && c.id).filter(Boolean)));
|
|
2143
2370
|
const named = new Map(list.map(r => [r, [r.from, r.to].flatMap(s => boxesNamed(s, id => anyBox.has(id) && !N[id]))]));
|
|
2144
2371
|
views.forEach(v => {
|
|
2145
|
-
const
|
|
2146
|
-
const order = Object.values(box).map(c => c.id), home = {}; cs.forEach(c => arrOf(c.nodeIds).forEach(id => { home[id] ??= c.id; }));
|
|
2147
|
-
const chain = b => { const c = []; for (const seen = new Set(); b != null && box[b] && !seen.has(b); b = box[b].parent) { seen.add(b); c.unshift(b); } return c; }; // outermost first
|
|
2148
|
-
const end = id => box[id] ? { id, kind: null, boxes: chain(id), label: box[id].label || id }
|
|
2149
|
-
: { id, kind: kindOf(nodeIn(id, v.id).kind, true), boxes: chain(home[id]), label: nodeIn(id, v.id).label || id };
|
|
2372
|
+
const { box, end } = endsIn(ctx, v), order = Object.values(box).map(c => c.id);
|
|
2150
2373
|
// a target names the drawn line, which may stand for several edges: grouped as prepEdges groups them (parallel edges, and
|
|
2151
2374
|
// A→B with B→A, unless keepPairs), in its order, so select() finds it
|
|
2152
2375
|
// ponytail: a copy of prepEdges' grouping; share one helper if that grouping changes
|
|
@@ -2345,20 +2568,27 @@ export function render(container, model, options = {}) {
|
|
|
2345
2568
|
let ctx = mapContext(model, { measure, kinds: !!P.kinds, expand: expanded }); // kinds: the standalone page guesses line kinds from label words, for its legend
|
|
2346
2569
|
const root = host.attachShadow({ mode: 'open', delegatesFocus: true }); // host.focus() lands in the map (on the selected card)
|
|
2347
2570
|
root.adoptedStyleSheets = [sheet()];
|
|
2348
|
-
|
|
2571
|
+
// the standalone page's pieces a host asks for (#99), in a frame around the canvas: the views list left of the map (after
|
|
2572
|
+
// it in the tab order, so focus given to the element still lands on the map), the details panel under it, the legend and
|
|
2573
|
+
// the hint line over it. With none of them the map is drawn as it always was.
|
|
2574
|
+
const framed = ['nav', 'details', 'legend', 'hints'].some(k => options[k]);
|
|
2575
|
+
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">
|
|
2349
2576
|
<div class="mctl">${options.minimap ? '<div class="minimap" title="Overview — click or drag to move"></div>' : ''}${options.controls === false ? '' : `
|
|
2350
2577
|
<div class="ctl">
|
|
2351
2578
|
<button data-z="up" title="Up one level (Esc)" aria-label="Up one level">↑</button><span class="sep"></span>
|
|
2352
2579
|
<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>
|
|
2353
2580
|
<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>
|
|
2354
|
-
</div>`}</div></div
|
|
2581
|
+
</div>`}</div></div>${!framed ? '' : (options.legend ? '<div class="m-legend"></div>' : '') + (options.hints ? `<div class="hint" role="status" aria-live="polite">${hintHTML()}</div>` : '') + '</div>'
|
|
2582
|
+
+ (options.details ? '<aside class="panel" aria-label="Node details" hidden></aside>' : '') + '</div>'
|
|
2583
|
+
+ (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>'}`;
|
|
2355
2584
|
const stage = root.querySelector('.stage'), q = s => root.querySelector(s);
|
|
2585
|
+
const tell = q('.hint') && hinter(q('.hint'), hintHTML()); // the hint line says what just happened, for a while
|
|
2356
2586
|
const subs = {};
|
|
2357
2587
|
const emit = (type, e) => { const f = (options.on || {})[type]; if (f) f(e); (subs[type] || []).slice().forEach(fn => fn(e)); };
|
|
2358
2588
|
let cam = null, geo = null, svg = null, cur = null, sel = null, more = [], busy = false, paused = false, dead = false;
|
|
2359
2589
|
const timers = new Set(), later = (f, ms) => { const t = setTimeout(() => { timers.delete(t); if (!dead) f(); }, ms); timers.add(t); return t; };
|
|
2360
2590
|
const sleep = ms => new Promise(r => later(r, reducedMotion() ? 0 : ms));
|
|
2361
|
-
const status = t => P.status
|
|
2591
|
+
const status = t => P.status ? P.status(t) : tell && tell(t);
|
|
2362
2592
|
const wide = P.wide || (() => (cam ? cam.size()[0] : stage.clientWidth) > 820);
|
|
2363
2593
|
|
|
2364
2594
|
const upOf = () => (P.upTarget ? P.upTarget() : (ctx.V[cur] || {}).parent) || null;
|
|
@@ -2390,7 +2620,7 @@ export function render(container, model, options = {}) {
|
|
|
2390
2620
|
stage.insertAdjacentHTML('afterbegin', c.markup); geo = c.g;
|
|
2391
2621
|
}
|
|
2392
2622
|
svg = stage.querySelector('svg'); svg.classList.add('map-svg'); if (paused) svg.pauseAnimations();
|
|
2393
|
-
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() :
|
|
2623
|
+
if (!cam) cam = new Camera(stage, svg, { wheel: 'zoom', maxFit: 1.15, max: 4, mapDepth: 0.3, onChange: onCam, skip: e => !!((can('move') || can('reorder') || can('add')) && e.target.closest && e.target.closest('.node, .um-handle')), inset: () => clearOf([].concat(P.inset ? P.inset() : fitIn())) });
|
|
2394
2624
|
cam.setSvg(svg, geo.bounds);
|
|
2395
2625
|
const ub = q('[data-z="up"]'); if (ub) ub.disabled = !upOf();
|
|
2396
2626
|
if (options.minimap) buildMini();
|
|
@@ -2398,9 +2628,14 @@ export function render(container, model, options = {}) {
|
|
|
2398
2628
|
const had = sel; sel = null; tabKeys(); paint();
|
|
2399
2629
|
more = [];
|
|
2400
2630
|
if (had) emitSel();
|
|
2401
|
-
endTrip(); drawMarks(); // a new picture: a tour or replay on the old one ends; the host's marks go on again (#63)
|
|
2631
|
+
endTrip(); relens(); drawMarks(); // a new picture: a tour or replay on the old one ends; the host's marks and the lens go on again (#63, #94)
|
|
2632
|
+
if (pc) pc.chrome();
|
|
2402
2633
|
if (P.onMount) P.onMount(inner);
|
|
2403
2634
|
}
|
|
2635
|
+
// at fit the cards keep off the pieces over the map (#99): the views list's button, the hint line, and the legend, which
|
|
2636
|
+
// the map leaves room for above it or to its right, whichever leaves the map larger
|
|
2637
|
+
const lg = q('.m-legend'), fitIn = () => { const I = { t: q('.um-navbtn') ? 54 : 16, r: 16, b: tell ? 40 : 16, l: 16 };
|
|
2638
|
+
return lg && !lg.hidden ? [{ ...I, b: 48 + lg.offsetHeight }, { ...I, l: 24 + lg.offsetWidth }] : I; };
|
|
2404
2639
|
|
|
2405
2640
|
/* at fit, the zoom controls and the minimap keep off the cards (#50): when a card would sit under one of them, the fit
|
|
2406
2641
|
also tries room for them at the bottom and at the right, and keeps whichever leaves the map larger */
|
|
@@ -2408,7 +2643,7 @@ export function render(container, model, options = {}) {
|
|
|
2408
2643
|
function clearOf(base) {
|
|
2409
2644
|
if (!cam || !geo) return base;
|
|
2410
2645
|
const [W, H] = cam.size(), key = JSON.stringify([W, H, base]); if (clear && clear.geo === geo && clear.key === key) return clear.sets;
|
|
2411
|
-
const boxes = [...stage.querySelectorAll('.ctl, .minimap, .um-story')].map(x => x.getBoundingClientRect()).filter(r => r.width && r.height);
|
|
2646
|
+
const boxes = [...stage.querySelectorAll('.ctl, .minimap, .um-story, .um-lens')].map(x => x.getBoundingClientRect()).filter(r => r.width && r.height);
|
|
2412
2647
|
let sets = base; // none shown, or no layout (a test DOM): as before
|
|
2413
2648
|
if (boxes.length) {
|
|
2414
2649
|
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;
|
|
@@ -2876,7 +3111,8 @@ export function render(container, model, options = {}) {
|
|
|
2876
3111
|
off = document.createElement('div'); off.style.cssText = 'position:absolute;left:-100000px;top:0;width:10px;height:10px;overflow:hidden;pointer-events:none';
|
|
2877
3112
|
off.innerHTML = c.markup; stage.parentNode.appendChild(off); s = off.querySelector('svg'); s.classList.add('map-svg'); g = c.g;
|
|
2878
3113
|
}
|
|
2879
|
-
if (off)
|
|
3114
|
+
if (off) { const mine = layers.get('lens'), L = mine && arrOf(ctx.D.lenses).find(x => x && x.id === lensId); // the host's marks, on another view too (#63), and the lens as it lights that view
|
|
3115
|
+
if (L) layers.set('lens', lensOf(ctx, vid, L).layer); drawMarks(s, g); if (L) layers.set('lens', mine); }
|
|
2880
3116
|
const s0 = sel, flowing = stage.classList.contains('flowing'), hov = [...svg.querySelectorAll('.hov')];
|
|
2881
3117
|
sel = null; paint(); stage.classList.remove('flowing'); hov.forEach(x => x.classList.remove('hov')); stage.classList.add('um-still');
|
|
2882
3118
|
const clone = s.cloneNode(true), b = g.bounds;
|
|
@@ -2947,7 +3183,7 @@ export function render(container, model, options = {}) {
|
|
|
2947
3183
|
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)
|
|
2948
3184
|
if (!cur) return;
|
|
2949
3185
|
if (!ctx.V[cur]) { const from = cur; mount(ctx.rootId); cam.set(startView()); cam.touched = false; emit('viewchange', { view: cur, from }); return; }
|
|
2950
|
-
if (ctx.geoCache[cur] && keys[cur] === ctx.viewKey(cur)) return; // this view's picture did not change
|
|
3186
|
+
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)
|
|
2951
3187
|
const keep = sel, keepMore = more, touched = cam.touched, prev = [geo, svg];
|
|
2952
3188
|
mount(cur);
|
|
2953
3189
|
if (touched) cam.set({}); else cam.set(startView());
|
|
@@ -2988,6 +3224,7 @@ export function render(container, model, options = {}) {
|
|
|
2988
3224
|
s.querySelectorAll('.node[opacity], .edge-g[opacity], .e-lab[opacity]').forEach(x => x.removeAttribute('opacity'));
|
|
2989
3225
|
s.querySelectorAll('.edge[style], .flowline[style]').forEach(x => x.removeAttribute('style'));
|
|
2990
3226
|
s.querySelectorAll('.node[aria-description]').forEach(x => x.removeAttribute('aria-description'));
|
|
3227
|
+
s.querySelectorAll('[data-was]').forEach(x => { if (x.tagName === 'title') x.textContent = x.getAttribute('data-was'); else x.setAttribute('aria-label', x.getAttribute('data-was')); x.removeAttribute('data-was'); });
|
|
2991
3228
|
if (s === svg) stage.classList.toggle('um-lit', [...layers.values()].some(L => L.dim));
|
|
2992
3229
|
if (!layers.size) return;
|
|
2993
3230
|
const tone = t => 'var(--um-' + String(t || 'accent').replace(/[^\w-]/g, '') + ')', op = m => m.level < 1 ? ` opacity="${m.level}"` : '';
|
|
@@ -3000,6 +3237,9 @@ export function render(container, model, options = {}) {
|
|
|
3000
3237
|
const x = mapTabs(n).reduce((a, t) => a + t.w + 4, n.x + 12), w = b.text.toUpperCase().length * 6.4 + 10;
|
|
3001
3238
|
over += `<g class="um-tab"${op(m)}><rect x="${x}" y="${n.y - 8}" width="${w}" height="15" rx="4" fill="${toneOf(b)}"/><text x="${x + w / 2}" y="${n.y + 3}" text-anchor="middle" class="tab-tx">${esc(b.text)}</text></g>`;
|
|
3002
3239
|
el.setAttribute('aria-description', b.text); });
|
|
3240
|
+
const heads = (g.families || []).flatMap(f => [{ x: f.x + 8, y: f.y + 3, w: String(f.label || f.id).length * 6.6 + 8, h: 22 }, // a box's title, 11 px mono
|
|
3241
|
+
...(f.tabs || []).map(t => ({ x: t.x, y: f.y - 8, w: t.w, h: 16 }))]); // and its tabs
|
|
3242
|
+
const pills = [], free = r => !g.nodes.some(n => hit(r, n, 8) || (n.tag && hit(r, n.tag, 2))) && !heads.some(h => hit(r, h, 2)) && !g.edges.some(o => o.lbl && hit(r, o.lbl, 2)) && !pills.some(p => hit(r, p, 2));
|
|
3003
3243
|
g.edges.forEach((e, i) => { const m = markOf(L => strongest(edgeRefIds(e).map(x => (L.edges || {})[x]))), eg = s.querySelector(`.edge-g[data-i="${i}"]`); if (!m || !eg) return;
|
|
3004
3244
|
const path = eg.querySelector('.edge'), flow = eg.querySelector('.flowline');
|
|
3005
3245
|
if (m.level < 1) [eg, s.querySelector(`.e-lab[data-i="${i}"]`)].forEach(x => x && x.setAttribute('opacity', m.level));
|
|
@@ -3007,10 +3247,19 @@ export function render(container, model, options = {}) {
|
|
|
3007
3247
|
if (m.dash) path.setAttribute('style', 'stroke-dasharray: 7 5');
|
|
3008
3248
|
if (m.speed != null && flow) flow.setAttribute('style', +m.speed > 0 ? `animation-duration: ${(2.4 / m.speed).toFixed(2)}s` : 'animation: none; opacity: 0');
|
|
3009
3249
|
if (m.label == null || m.label === '') return;
|
|
3010
|
-
//
|
|
3011
|
-
|
|
3012
|
-
|
|
3013
|
-
|
|
3250
|
+
// the pill takes the first place that covers no card, box title or tab, line label or pill before it (#81): under the
|
|
3251
|
+
// line's own label (else on its longest run), then a few places along the line. The view is not laid out again for
|
|
3252
|
+
// it, so with no room at any, its text goes in the line's tooltip and accessible name instead
|
|
3253
|
+
// ponytail: six places, and another line may lie under one; scan along the line if too few find room
|
|
3254
|
+
const t = String(m.label), w = sansW(t, 10, .92) + 14, L = e.lbl, [a, b] = longest(e.pts), len = lenOf(e.pts);
|
|
3255
|
+
const box = [L ? [L.x + L.w / 2, L.y + L.h + 10] : [(a[0] + b[0]) / 2, (a[1] + b[1]) / 2], ...[.5, .3, .7, .15, .85].map(f => along(e.pts, f * len))]
|
|
3256
|
+
.map(([x, y]) => ({ x: x - w / 2, y: y - 8, w, h: 16 })).find(free);
|
|
3257
|
+
if (!box) { const tt = eg.querySelector(':scope > title'), key = s.querySelector(`.line-key[data-i="${i}"]`);
|
|
3258
|
+
if (tt) { tt.setAttribute('data-was', tt.textContent); tt.textContent += '\n' + t; }
|
|
3259
|
+
if (key) { key.setAttribute('data-was', key.getAttribute('aria-label')); key.setAttribute('aria-label', key.getAttribute('aria-label') + ', ' + t); }
|
|
3260
|
+
return; }
|
|
3261
|
+
pills.push(box);
|
|
3262
|
+
over += `<g class="um-lab"${op(m)}><rect x="${box.x}" y="${box.y}" width="${w}" height="16" rx="8"${m.tone ? ` style="stroke: ${tone(m.tone)}"` : ''}/><text x="${box.x + w / 2}" y="${box.y + 11.5}" text-anchor="middle">${esc(t)}</text></g>`; });
|
|
3014
3263
|
const cardsG = s.querySelector('.nodes');
|
|
3015
3264
|
if (under && cardsG) cardsG.insertAdjacentHTML('beforebegin', `<g class="um-rings" aria-hidden="true">${under}</g>`);
|
|
3016
3265
|
if (over) s.insertAdjacentHTML('beforeend', `<g class="um-marks" aria-hidden="true">${over}</g>`);
|
|
@@ -3029,6 +3278,24 @@ export function render(container, model, options = {}) {
|
|
|
3029
3278
|
else if (r.type === 'edge') (r.ids || []).forEach(x => { L.edges[x] = { level: Math.max(lv(r), (L.edges[x] || {}).level || 0) }; }); });
|
|
3030
3279
|
overlay('highlight', L);
|
|
3031
3280
|
}
|
|
3281
|
+
/* lenses (#94): one of the model's saved ways to look at the map, as the 'lens' layer, on every view it is shown on; the
|
|
3282
|
+
view is not laid out again. A lens that colours cards by owner puts their tones in a legend over the controls. */
|
|
3283
|
+
let lensId = null;
|
|
3284
|
+
function relens() {
|
|
3285
|
+
const L = lensId == null ? null : arrOf(ctx.D.lenses).find(x => x && x.id === lensId), was = q('.um-lens');
|
|
3286
|
+
if (was) { was.remove(); clear = null; }
|
|
3287
|
+
if (!L) { lensId = null; layers.delete('lens'); return; } // off, or the model no longer has it
|
|
3288
|
+
if (!cur || !ctx.V[cur]) return;
|
|
3289
|
+
const { layer, owners } = lensOf(ctx, cur, L), name = esc(L.title || L.id);
|
|
3290
|
+
layers.set('lens', layer);
|
|
3291
|
+
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; }
|
|
3292
|
+
}
|
|
3293
|
+
function setLens(id) { // true when the model has that lens (or id is null)
|
|
3294
|
+
if (dead || (id != null && !arrOf(ctx.D.lenses).some(x => x && x.id === id))) return false;
|
|
3295
|
+
lensId = id ?? null; relens(); drawMarks();
|
|
3296
|
+
if (cam && geo && !cam.touched) cam.set(startView()); // fitted: the cards keep clear of the legend, as of the controls
|
|
3297
|
+
return true;
|
|
3298
|
+
}
|
|
3032
3299
|
/* a dot that rides the map: a tour along a path the host names, or a trace replayed. One at a time; a new picture of the
|
|
3033
3300
|
view (setView, an update that redraws it) ends it, as does destroy() */
|
|
3034
3301
|
let trip = null;
|
|
@@ -3056,10 +3323,15 @@ export function render(container, model, options = {}) {
|
|
|
3056
3323
|
const prev = legs[legs.length - 1], n = geo.byId[ids[k]];
|
|
3057
3324
|
const pts = prev && prev.to === ids[k] ? [prev.pts[prev.pts.length - 1], [n.x + n.w / 2, n.y + n.h / 2], ...r.pts] : r.pts;
|
|
3058
3325
|
legs.push({ pts, to: ids[k + 1], len: lenOf(pts) }); }
|
|
3059
|
-
if (!legs.length || reducedMotion()) return Promise.resolve(legs.length > 0);
|
|
3326
|
+
if (!legs.length || (reducedMotion() && P.tourAt == null)) return Promise.resolve(legs.length > 0);
|
|
3060
3327
|
const speed = Math.max(20, +o.speed || 240); // map units a second
|
|
3061
3328
|
svg.insertAdjacentHTML('beforeend', '<g class="um-trip" aria-hidden="true"><circle r="5.5" class="tour-dot"/></g>');
|
|
3062
3329
|
const g = svg.lastElementChild, dot = g.firstElementChild; let leg = 0, base = 0;
|
|
3330
|
+
if (P.tourAt != null) { // the page's #shot=1&at=<0 to 1>: the dot held that far along the ride, a frame for umlimator.py record (#83)
|
|
3331
|
+
let d = clamp01(P.tourAt) * legs.reduce((s, l) => s + l.len, 0); while (leg < legs.length - 1 && d > legs[leg].len) d -= legs[leg++].len;
|
|
3332
|
+
const [x, y] = along(legs[leg].pts, d); dot.setAttribute('cx', x); dot.setAttribute('cy', y);
|
|
3333
|
+
trip = { raf: 0, dot: g, end() {} }; return Promise.resolve(true);
|
|
3334
|
+
}
|
|
3063
3335
|
return new Promise(res => { startTrip(ms => { const d = ms * speed / 1000;
|
|
3064
3336
|
while (leg < legs.length && d - base >= legs[leg].len) { base += legs[leg].len; flash(legs[leg].to); leg++; }
|
|
3065
3337
|
if (leg >= legs.length) return false;
|
|
@@ -3070,9 +3342,7 @@ export function render(container, model, options = {}) {
|
|
|
3070
3342
|
function replay(spans, o = {}) {
|
|
3071
3343
|
endTrip(); overlay('replay', null);
|
|
3072
3344
|
const list = [].concat(spans || []).filter(s => s && Number.isFinite(+s.start) && Number.isFinite(+s.end));
|
|
3073
|
-
const byId = new Map(list.map(s => [String(s.id), s])),
|
|
3074
|
-
const cardOf = svc => { if (svc == null || !geo) return null; if (match[svc] != null) return geo.byId[match[svc]] ? String(match[svc]) : null;
|
|
3075
|
-
if (geo.byId[svc]) return String(svc); const n = geo.nodes.find(x => low(x.label) === low(svc)); return n ? n.id : null; };
|
|
3345
|
+
const byId = new Map(list.map(s => [String(s.id), s])), cardOf = cardMatcher(geo ? geo.nodes : [], o.match);
|
|
3076
3346
|
let skipped = 0, t = null, done; const hops = [];
|
|
3077
3347
|
list.forEach(s => { const to = cardOf(s.service); if (!to) return skipped++;
|
|
3078
3348
|
const p = s.parent != null ? byId.get(String(s.parent)) : null, from = p && cardOf(p.service);
|
|
@@ -3082,7 +3352,7 @@ export function render(container, model, options = {}) {
|
|
|
3082
3352
|
const ctl = { hops: hops.length, skipped, done: new Promise(r => { done = r; }),
|
|
3083
3353
|
pause() { if (t && trip === t) t.paused = true; }, resume() { if (t && trip === t) t.paused = false; },
|
|
3084
3354
|
stop() { if (t && trip === t) endTrip(); else overlay('replay', null); } };
|
|
3085
|
-
const ms =
|
|
3355
|
+
const ms = msText, marks = {};
|
|
3086
3356
|
// ponytail: two calls at once on one line share its mark; the one that ends first takes the glow off
|
|
3087
3357
|
const show = (h, on) => { h.ids.forEach(x => { marks[x] = on ? { label: ms(h.dur), tone: 'accent', pulse: true } : { label: ms(h.dur) }; }); overlay('replay', { edges: { ...marks } }); };
|
|
3088
3358
|
if (!hops.length || reducedMotion()) { hops.forEach(h => show(h, false)); done(hops.length > 0); return ctl; }
|
|
@@ -3194,7 +3464,18 @@ export function render(container, model, options = {}) {
|
|
|
3194
3464
|
}
|
|
3195
3465
|
|
|
3196
3466
|
const inner = { ctx: () => ctx, geo: () => geo, cam: () => cam, svg: () => svg, stage, host, go, open, up, flash, setFlow, pause, key, startView, standalone, play,
|
|
3197
|
-
select, busy: () => busy, flowing: () => stage.classList.contains('flowing'), view: () => cur, unfold: gesture
|
|
3467
|
+
select, busy: () => busy, flowing: () => stage.classList.contains('flowing'), view: () => cur, unfold: gesture, lens: setLens,
|
|
3468
|
+
highlight, overlay, focus: focusMap, editable: !!E, told: id => { const st = story && story.steps[story.at]; return !!(st && st.select === id); } }; // told: a story's step picked it
|
|
3469
|
+
|
|
3470
|
+
// the pieces a host asked for (#99): they follow the map's selection and views, and take their keys wherever the focus is
|
|
3471
|
+
// in the map (Enter and Space press their buttons; the map's other keys reach the canvas, as in the page)
|
|
3472
|
+
const frame = q('.um-frame'), pc = frame && pieces(() => inner, { tree: q('.outline'), legend: lg, panel: q('.panel') }, { frame, toggle: q('.um-navbtn'), layer: frame, status: tell || say });
|
|
3473
|
+
if (pc) {
|
|
3474
|
+
(subs.select ||= []).push(e => pc.select(e.target, e.targets)); (subs.viewchange ||= []).push(pc.unlight);
|
|
3475
|
+
frame.addEventListener('keydown', e => { if (e.defaultPrevented || e.metaKey || e.ctrlKey || e.altKey) return;
|
|
3476
|
+
if (pc.key(e) || (!stage.contains(e.target) && e.key !== 'Enter' && e.key !== ' ' && key(e))) { e.preventDefault(); e.stopPropagation(); } });
|
|
3477
|
+
if (options.nav) { q('.um-navbtn').addEventListener('click', () => pc.setTree(!frame.classList.contains('tree-open'))); pc.setTree(ctx.views.length > 1); }
|
|
3478
|
+
}
|
|
3198
3479
|
const first = () => {
|
|
3199
3480
|
if (dead) return;
|
|
3200
3481
|
const v0 = ctx.V[options.view] ? options.view : ctx.rootId;
|
|
@@ -3225,6 +3506,7 @@ export function render(container, model, options = {}) {
|
|
|
3225
3506
|
get selected() { return picked(); },
|
|
3226
3507
|
overlay: (name, layer) => overlay(name, layer),
|
|
3227
3508
|
highlight: refs => highlight(refs),
|
|
3509
|
+
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
|
|
3228
3510
|
tour: (path, o) => tour(path, o),
|
|
3229
3511
|
replay: (spans, o) => replay(spans, o),
|
|
3230
3512
|
play: (id, o) => play(id, o),
|
|
@@ -3324,34 +3606,102 @@ export function otlpSpans(data) {
|
|
|
3324
3606
|
service, name: s.name, start: Number(s.startTimeUnixNano) / 1e6, end: Number(s.endTimeUnixNano) / 1e6 }))); });
|
|
3325
3607
|
return out;
|
|
3326
3608
|
}
|
|
3609
|
+
// a service's card (#65, #96): the card `match` names, else the card with that id, else the one with that label (any case)
|
|
3610
|
+
const cardMatcher = (cards, match) => { const m = match || {}, ids = new Set(cards.map(n => String(n.id))), low = x => String(x ?? '').trim().toLowerCase();
|
|
3611
|
+
return s => s == null ? null : Object.hasOwn(m, s) && m[s] != null ? (ids.has(String(m[s])) ? String(m[s]) : null)
|
|
3612
|
+
: ids.has(String(s)) ? String(s) : (n => n ? String(n.id) : null)(cards.find(n => low(n.label) === low(s))); };
|
|
3613
|
+
// a duration in words, as replay() labels a line and a trace's view says a step's: "37 ms", "1.2 s"
|
|
3614
|
+
const msText = x => x >= 1000 ? `${+(x / 1000).toFixed(x >= 1e4 ? 0 : 1)} s` : `${x >= 10 ? Math.round(x) : +x.toFixed(1)} ms`;
|
|
3615
|
+
|
|
3616
|
+
/** The path along a view's lines with the highest total of `by` (#95): `latency` (ms, the default) or `cost`, summed over
|
|
3617
|
+
* its lines (a line without one counts 0). It starts at `from` and ends at `to`, either left out for any card, and never
|
|
3618
|
+
* comes back to a card. { refs, total }: its cards in order, then its lines, as Refs for highlight(); no refs and 0 when
|
|
3619
|
+
* no line leads from `from` (to `to`). */
|
|
3620
|
+
export function critical(model, viewId, options = {}) {
|
|
3621
|
+
const { ids, edges } = lineGraph(model, viewId), by = options.by === 'cost' ? 'cost' : 'latency', { from, to } = options, none = { refs: [], total: 0 };
|
|
3622
|
+
if ((from != null && !ids.includes(from)) || (to != null && !ids.includes(to))) return none;
|
|
3623
|
+
const w = e => typeof e[by] === 'number' && e[by] > 0 ? e[by] : 0, path = [], on = new Set();
|
|
3624
|
+
let best = null;
|
|
3625
|
+
// ponytail: every path, depth first: exponential on a dense view with many ways between two cards; a longest-path pass
|
|
3626
|
+
// over the lines in run order (few loop back) if a map's size makes the page wait
|
|
3627
|
+
const walk = (u, sum) => { on.add(u);
|
|
3628
|
+
edges.forEach(e => { if (e.from !== u || on.has(e.to)) return;
|
|
3629
|
+
const s = sum + w(e); path.push(e);
|
|
3630
|
+
if ((to == null ? ids.includes(e.to) : e.to === to) && (!best || s > best.total)) best = { total: s, lines: [...path] };
|
|
3631
|
+
if (e.to !== to) walk(e.to, s);
|
|
3632
|
+
path.pop(); });
|
|
3633
|
+
on.delete(u); };
|
|
3634
|
+
(from != null ? [from] : ids).forEach(id => walk(id, 0));
|
|
3635
|
+
if (!best) return none;
|
|
3636
|
+
const cards = [best.lines[0].from, ...best.lines.map(e => e.to)].filter(id => ids.includes(id));
|
|
3637
|
+
return { refs: [...cards.map(id => ({ type: 'node', id, view: viewId })), ...best.lines.map(e => ({ type: 'edge', ids: [e._id], view: viewId }))],
|
|
3638
|
+
total: +best.total.toPrecision(12) };
|
|
3639
|
+
}
|
|
3640
|
+
|
|
3641
|
+
/** What taking cards out would cost (#95), with the model left as it is: `unreachable`, what loses its way to them in the
|
|
3642
|
+
* view (backward, as reach() finds it: for calls, what depends on them), each with its distance in lines from the nearest,
|
|
3643
|
+
* the cards themselves at 0. No rule breaks: rules only forbid lines and cycles, which taking cards out never adds. */
|
|
3644
|
+
export function whatIf(model, viewId, options = {}) {
|
|
3645
|
+
const gone = new Set([].concat(options.remove ?? []).map(String)), near = new Map();
|
|
3646
|
+
gone.forEach(id => reach(model, viewId, id, { backward: true }).forEach(r => { const k = r.type === 'node' ? r.id : '>' + r.ids[0];
|
|
3647
|
+
if (!near.has(k) || r.distance < near.get(k).distance) near.set(k, r); }));
|
|
3648
|
+
return { unreachable: [...near.values()].sort((a, b) => (a.type === 'edge') - (b.type === 'edge') || a.distance - b.distance) };
|
|
3649
|
+
}
|
|
3650
|
+
|
|
3651
|
+
/** A trace kept as a numbered view (#96): `model` with a sequence view (`render: "sequence"`) made from `spans` (as
|
|
3652
|
+
* otlpSpans() reads them). Each call from one card to another (a span and its parent's) is a step, in time order, its
|
|
3653
|
+
* line labelled with the span's name and its duration under it. A span's service is a card as replay() matches it:
|
|
3654
|
+
* through `match`, else by id, else by label (any case); a span with no card, or whose caller has none, is no step, and
|
|
3655
|
+
* the view's caption names the services with no card. The view (`view`, default "trace"; `title`) takes the place of one
|
|
3656
|
+
* with its id, else comes last, under the view that shows the most of its cards. The model passed in is not changed. */
|
|
3657
|
+
export function sequenceFromTrace(model, spans, options = {}) {
|
|
3658
|
+
const out = structuredClone(model || {}), id = String(options.view ?? 'trace'), views = arrOf(out.views);
|
|
3659
|
+
const cards = [...arrOf(out.nodes), ...views.flatMap(v => arrOf(v && v.nodes))].filter(n => n && n.id != null);
|
|
3660
|
+
const list = [].concat(spans || []).filter(s => s && Number.isFinite(+s.start) && Number.isFinite(+s.end));
|
|
3661
|
+
const span = new Map(list.map(s => [String(s.id), s])), cardOf = cardMatcher(cards, options.match), hops = [], lost = new Set();
|
|
3662
|
+
list.forEach(s => { const to = cardOf(s.service); if (!to) { if (s.service != null) lost.add(String(s.service)); return; }
|
|
3663
|
+
const p = s.parent != null ? span.get(String(s.parent)) : null, from = p && cardOf(p.service);
|
|
3664
|
+
if (from && from !== to) hops.push([from, to, s]); }); // a root span, a caller with no card, or work inside one card: no step
|
|
3665
|
+
hops.sort((a, b) => +a[2].start - +b[2].start); // stable: calls that start together keep the trace's order
|
|
3666
|
+
const nodeIds = [...new Set(hops.flatMap(([a, b]) => [a, b]))], n = hops.length, took = s => msText(+s.end - +s.start);
|
|
3667
|
+
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) }) }));
|
|
3668
|
+
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];
|
|
3669
|
+
const view = { id, ...(home != null ? { parent: home } : {}), title: options.title || id,
|
|
3670
|
+
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])}.` : ''}`,
|
|
3671
|
+
render: 'sequence', lanes: false, nodeIds, edges };
|
|
3672
|
+
const at = views.findIndex(v => v && v.id === id);
|
|
3673
|
+
out.views = at < 0 ? [...views, view] : views.map((v, k) => k === at ? view : v);
|
|
3674
|
+
return out;
|
|
3675
|
+
}
|
|
3676
|
+
|
|
3677
|
+
/* a lens (#94): what it lights on a view, as a layer of marks. Without `line`, the cards its pick picks and the lines
|
|
3678
|
+
between two of them; with `line` (line kinds), the lines of those kinds between two cards the rest of it picks, and the
|
|
3679
|
+
cards at their ends. The rest dims. With color 'owner', each lit card is ringed in its owner's tone, the same on every
|
|
3680
|
+
view (owners in name order); `owners`: the view's lit owners and their tones, for a legend. */
|
|
3681
|
+
const OWNER_TONES = ['cyan', 'violet', 'green', 'amber', 'fuchsia', 'orange', 'rose', 'slate'];
|
|
3682
|
+
function lensOf(ctx, vid, lens) {
|
|
3683
|
+
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));
|
|
3684
|
+
const kinds = s.line == null ? null : [s.line].flat(), cards = arrOf(v.nodeIds).filter(id => ctx.N[id]), all = ctx.viewEdges(v);
|
|
3685
|
+
const lines = all.filter(e => (!kinds || kinds.includes(WIRE_KINDS.includes(e.kind) ? e.kind : 'calls')) && ok(e.from) && ok(e.to));
|
|
3686
|
+
const lit = new Set(kinds ? lines.flatMap(e => [e.from, e.to]) : cards.filter(ok));
|
|
3687
|
+
// ponytail: eight tones, so the ninth owner shares the first's; a host that needs more rings cards itself with overlay()
|
|
3688
|
+
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();
|
|
3689
|
+
const tone = o => lens.color === 'owner' && names.includes(o) ? OWNER_TONES[names.indexOf(o) % OWNER_TONES.length] : null;
|
|
3690
|
+
const nodes = Object.fromEntries(cards.filter(id => lit.has(id)).map(id => [id, tone(end(id).owner) ? { tone: tone(end(id).owner) } : {}]));
|
|
3691
|
+
const owners = [...new Set(Object.keys(nodes).map(id => end(id).owner).filter(tone))].sort().map(o => [o, tone(o)]);
|
|
3692
|
+
const dim = Object.keys(nodes).length < cards.length || lines.length < all.length; // a lens that lights everything leaves the selection its own dimming
|
|
3693
|
+
return { layer: { nodes, edges: Object.fromEntries(lines.map(e => [e._id, {}])), dim }, owners };
|
|
3694
|
+
}
|
|
3327
3695
|
|
|
3328
3696
|
export function mountPage(container, model, options = {}) {
|
|
3329
3697
|
const Q = options.params || new URLSearchParams();
|
|
3330
3698
|
if (options.instant) INSTANT = true;
|
|
3331
3699
|
document.body.classList.add('map-mode');
|
|
3332
|
-
let { D, views, nodes, N, V, rootId, nodeIn: nodeIn0,
|
|
3333
|
-
const nodeIn = (nid, vid = cur) => nodeIn0(nid, vid)
|
|
3334
|
-
const shortT = t => String(t || '').split(' · ')[0];
|
|
3335
|
-
const root = D.meta && D.meta.root ? D.meta.root.replace(/\/$/, '') : '';
|
|
3336
|
-
const base = D.sourcesBase ? String(D.sourcesBase).replace(/\/$/, '') + '/' : '';
|
|
3337
|
-
const srcPath = p => p.replace(/:\d+(-\d+)?$/, '');
|
|
3338
|
-
// meta.root is the repo root relative to this page (older pages: absolute). `~/…` sources live
|
|
3339
|
-
// outside the repo, so they get no link.
|
|
3340
|
-
// meta.sourceBase (render --source-base): in-pack sources were rewritten to URLs under it and
|
|
3341
|
-
// show as their pack-relative path; any other relative source gets no link on a published page.
|
|
3342
|
-
const sb = D.meta && D.meta.sourceBase ? String(D.meta.sourceBase) : '';
|
|
3343
|
-
const srcLabel = p => sb && p.startsWith(sb) ? decodeURI(p.slice(sb.length)).replace(/#L(\d+)(?:-L(\d+))?$/, (_, a, b) => ':' + a + (b ? '-' + b : '')) : p;
|
|
3344
|
-
const srcHref = p => /^https?:/.test(p) ? p
|
|
3345
|
-
: p.startsWith('~') || sb ? ''
|
|
3346
|
-
: p.startsWith('/') ? 'file://' + encodeURI(srcPath(p))
|
|
3347
|
-
: root.startsWith('/') ? 'file://' + encodeURI(root + '/' + base + srcPath(p))
|
|
3348
|
-
: encodeURI((root ? root + '/' : '') + base + srcPath(p));
|
|
3349
|
-
const srcList = n => (Array.isArray(n.source) ? n.source : n.source ? [n.source] : []);
|
|
3700
|
+
let { D, views, nodes, N, V, rootId, nodeIn: nodeIn0, subjectOf, viewsWith, kidsOf, chain, viewEdges, planCache } = mapContext(model);
|
|
3701
|
+
const nodeIn = (nid, vid = cur) => nodeIn0(nid, vid);
|
|
3350
3702
|
const mac = /Mac|iPhone|iPad/.test(navigator.platform || navigator.userAgent || '');
|
|
3351
3703
|
const modK = mac ? '⌘K' : 'Ctrl K';
|
|
3352
|
-
const
|
|
3353
|
-
const HINT = touch ? '<span>pinch = zoom</span><span>drag = move</span><span>tap = details</span>'
|
|
3354
|
-
: `<span>scroll = zoom</span><span>drag = move</span><span>double-click ⤢ = open</span><span>${modK} = search</span><span>? = keys</span>`;
|
|
3704
|
+
const HINT = hintHTML(modK);
|
|
3355
3705
|
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('');
|
|
3356
3706
|
|
|
3357
3707
|
const app = container; app.className = 'mapapp';
|
|
@@ -3363,7 +3713,9 @@ export function mountPage(container, model, options = {}) {
|
|
|
3363
3713
|
<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>
|
|
3364
3714
|
<button class="ibtn" id="m-theme" title="Light / dark" aria-label="Toggle theme">◐</button>${(D.stories || []).length ? `
|
|
3365
3715
|
<div class="menu"><button class="ibtn txt" id="m-stories" aria-haspopup="true" aria-expanded="false" title="Stories: walk-throughs of this map">▶ Stories</button>
|
|
3366
|
-
<div class="menu-pop" id="m-stories-pop" hidden>${D.stories.map(s => `<button data-story="${esc(s && s.id)}">${esc((s && (s.title || s.id)) || '')}</button>`).join('')}</div></div>` : ''}
|
|
3716
|
+
<div class="menu-pop" id="m-stories-pop" hidden>${D.stories.map(s => `<button data-story="${esc(s && s.id)}">${esc((s && (s.title || s.id)) || '')}</button>`).join('')}</div></div>` : ''}${arrOf(D.lenses).some(l => l && l.id) ? `
|
|
3717
|
+
<div class="menu"><button class="ibtn txt" id="m-lenses" aria-haspopup="true" aria-expanded="false" title="Lenses: ways to look at this map">◎ Lenses</button>
|
|
3718
|
+
<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>` : ''}
|
|
3367
3719
|
<div class="menu"><button class="pbtn" id="m-export" aria-haspopup="true" aria-expanded="false">Export ▾</button>
|
|
3368
3720
|
<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>
|
|
3369
3721
|
<button class="ibtn" id="m-pause" type="button" title="Pause" aria-label="Pause" aria-pressed="false">❚❚</button>
|
|
@@ -3392,8 +3744,9 @@ export function mountPage(container, model, options = {}) {
|
|
|
3392
3744
|
const $ = id => document.getElementById(id);
|
|
3393
3745
|
const stage = $('m-stage'), panel = $('m-panel');
|
|
3394
3746
|
let X = null, cam = null, geo = null, svg = null, cur = null, sel = null, stack = [], paused = false, replaceNext = false;
|
|
3395
|
-
|
|
3396
|
-
|
|
3747
|
+
const status = hinter($('m-hint'), HINT);
|
|
3748
|
+
// the views list, the legend and the details panel (#99): the same pieces render() draws for a host
|
|
3749
|
+
const pc = pieces(() => X, { tree: $('m-tree'), legend: $('m-legend'), panel }, { page: true, frame: document.body, toggle: $('m-outline'), layer: document.body, status });
|
|
3397
3750
|
|
|
3398
3751
|
function renderChrome() {
|
|
3399
3752
|
const v = V[cur];
|
|
@@ -3401,7 +3754,7 @@ export function mountPage(container, model, options = {}) {
|
|
|
3401
3754
|
? `<span class="crumb cur" aria-current="page">${esc(V[id].title || id)}</span>`
|
|
3402
3755
|
: `<button class="crumb" data-v="${esc(id)}">${esc(shortT(V[id].title || id))}</button><span class="crumb-sep">›</span>`).join('');
|
|
3403
3756
|
const parent = stack.length > 1 ? stack[stack.length - 2] : v.parent;
|
|
3404
|
-
renderPill(parent);
|
|
3757
|
+
renderPill(parent); pc.chrome();
|
|
3405
3758
|
document.title = `${v.title || v.id} · ${D.title || 'map'}`;
|
|
3406
3759
|
}
|
|
3407
3760
|
function renderPill(parent) {
|
|
@@ -3416,149 +3769,17 @@ export function mountPage(container, model, options = {}) {
|
|
|
3416
3769
|
<div class="vm">${geo.nodes.length} nodes · ${geo.edges.length} wires${views.length > 1 ? ` · level ${chain(cur).length} of ${depth}` : ''}</div></div>`;
|
|
3417
3770
|
}
|
|
3418
3771
|
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); };
|
|
3419
|
-
|
|
3420
|
-
|
|
3421
|
-
|
|
3422
|
-
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'];
|
|
3423
|
-
const shape = k => k === 'm-verb' ? 'p' : (k === 'm-ext' || k === 'external') ? 'd' : k === 'm-hook' ? 'h' : '';
|
|
3424
|
-
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>`)
|
|
3425
|
-
.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>'] : [])
|
|
3426
|
-
.concat(geo.nodes.some(n => n.step) ? ['<span class="lgi"><i class="num">1</i><b>Step</b><span>run order</span></span>'] : [])
|
|
3427
|
-
.concat(geo.nodes.some(n => n.tag) ? ['<span class="lgi"><i class="tag"></i><b>@author</b><span>who wrote it</span></span>'] : [])
|
|
3428
|
-
.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>`))
|
|
3429
|
-
.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>'] : []);
|
|
3430
|
-
const el = $('m-legend');
|
|
3431
|
-
el.hidden = !items.length;
|
|
3432
|
-
el.classList.toggle('folded', legendFolded);
|
|
3433
|
-
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('')}`;
|
|
3434
|
-
}
|
|
3435
|
-
function renderTree() { // Readable columns; a declared order may include shortcuts to descendants.
|
|
3436
|
-
const ch = chain(cur);
|
|
3437
|
-
const roots = views.filter(x => !x.parent), cols = roots.length > 1 ? [{ head: 'Map', items: roots, on: ch[0] }] : [];
|
|
3438
|
-
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] }); });
|
|
3439
|
-
document.body.style.setProperty('--browser-width', Math.min(448, Math.max(1, cols.length) * 224) + 'px');
|
|
3440
|
-
$('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;
|
|
3441
|
-
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>`;
|
|
3442
|
-
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 = '';
|
|
3443
|
-
}
|
|
3444
|
-
const setTree = on => { document.body.classList.toggle('tree-open', on); $('m-outline').setAttribute('aria-expanded', on); };
|
|
3445
|
-
|
|
3446
|
-
// Pull concise points from captured prose; retain wording and keep the full point in its tooltip.
|
|
3447
|
-
function detailBullets(body) {
|
|
3448
|
-
const normalized = String(body || '').replace(/^\s*(?:[-*]|\d+[.)])\s+/gm, '').replace(/\n+/g, ' ').trim();
|
|
3449
|
-
let parts = normalized.split(/(?<=[.!?])\s+(?=[A-Z`/])|;\s+|\s+[·|]\s+/).filter(Boolean);
|
|
3450
|
-
while (parts.length < 2 || parts.some(p => p.length > 160) && parts.length < 4) {
|
|
3451
|
-
const index = parts.reduce((best, p, i) => p.length > (parts[best] || '').length ? i : best, 0), value = parts[index];
|
|
3452
|
-
if (!value || value.length < 8) break;
|
|
3453
|
-
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);
|
|
3454
|
-
const safe = i => (value.slice(0, i).match(/`/g) || []).length % 2 === 0;
|
|
3455
|
-
const cuts = separators.filter(i => i >= 6 && i < value.length - 4 && safe(i));
|
|
3456
|
-
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)));
|
|
3457
|
-
const cut = cuts.sort((a, b) => Math.abs(a - value.length / 2) - Math.abs(b - value.length / 2))[0];
|
|
3458
|
-
if (!cut) break;
|
|
3459
|
-
parts.splice(index, 1, value.slice(0, cut).replace(/[,;:]\s*$/, '').trim(), value.slice(cut).trim());
|
|
3460
|
-
}
|
|
3461
|
-
if (parts.length > 4) parts = [...parts.slice(0, 3), parts.slice(3).join(' ')];
|
|
3462
|
-
return parts;
|
|
3463
|
-
}
|
|
3464
|
-
/* selection + details strip */
|
|
3465
|
-
/* selection + details strip: the canvas reports, the page shows the panel and records the hash */
|
|
3772
|
+
const setTree = pc.setTree;
|
|
3773
|
+
|
|
3774
|
+
/* selection: the canvas reports, the pieces show the details panel, the page records the hash */
|
|
3466
3775
|
const select = (nid, opts = {}) => X.select({ type: 'node', id: nid }, opts);
|
|
3467
|
-
function hidePanel() { panel.hidden = true; }
|
|
3468
3776
|
const deselect = () => X.select(null);
|
|
3469
|
-
function relRows(list, dir) {
|
|
3470
|
-
if (!list.length) return `<div class="rel-none">${dir === 'up' ? 'Nothing calls this here' : 'Calls nothing here'}</div>`;
|
|
3471
|
-
return '<ul class="relation-list">' + list.map(({ e, id, inView }) => { const o = N[id] || { label: id, kind: '' }, k = kindOf(o.kind, true);
|
|
3472
|
-
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>
|
|
3473
|
-
<span class="rel-e">${e.step != null ? esc(e.step) + ' · ' : ''}${esc(e.label || '')}${inView ? '' : ' ↗'}</span></button></li>`; }).join('') + '</ul>';
|
|
3474
|
-
}
|
|
3475
|
-
const authorChip = login => { const A = (D.authors || {})[login] || {};
|
|
3476
|
-
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>`; };
|
|
3477
|
-
function renderPanel(nid) {
|
|
3478
|
-
const n = nodeIn(nid), k = kindOf(n.kind, true), c = `var(--um-${KIND[k][0]})`, pub = k === 'm-public', ext = k === 'm-ext';
|
|
3479
|
-
const inView = new Set(geo.nodes.map(x => x.id)), seen = new Set();
|
|
3480
|
-
const collect = dir => {
|
|
3481
|
-
const out = [];
|
|
3482
|
-
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 }); };
|
|
3483
|
-
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); });
|
|
3484
|
-
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); });
|
|
3485
|
-
(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)); });
|
|
3486
|
-
return out;
|
|
3487
|
-
};
|
|
3488
|
-
const ups = collect('up'), downs = collect('down'), srcs = srcList(n);
|
|
3489
|
-
const child = childOf(nid), canOpen = child && child !== cur;
|
|
3490
|
-
const vIn = viewsWith(nid).filter(v => v.id !== cur);
|
|
3491
|
-
const by = n.author ? `<span>·</span>${authorChip(n.author)}` : ext ? '<span class="origin">· author unknown</span>' : '';
|
|
3492
|
-
const exs = Array.isArray(n.examples) ? n.examples.filter(x => x && (x.thumb || x.href)) : [];
|
|
3493
|
-
const tabs = [['when', 'When to use'], ['what', 'What it is'], ['how', 'How it works'], ...(exs.length ? [['examples', 'Example']] : []), ['links', 'Behind the scenes']];
|
|
3494
|
-
TABS = tabs.map(t => t[0]);
|
|
3495
|
-
if (!TABS.includes(tab)) tab = 'when';
|
|
3496
|
-
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>`;
|
|
3497
|
-
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>` : '';
|
|
3498
|
-
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>';
|
|
3499
|
-
const bodies = {
|
|
3500
|
-
when: text('When to use', n.when, 'when'),
|
|
3501
|
-
what: text('What it is', n.what, 'what') + verbs,
|
|
3502
|
-
how: text('How it works', n.how, 'how'),
|
|
3503
|
-
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>`,
|
|
3504
|
-
links: `<div class="links"><div><h4>← Comes from</h4>${relRows(ups, 'up')}</div><div><h4>→ Leads to</h4>${relRows(downs, 'down')}</div>
|
|
3505
|
-
<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>`,
|
|
3506
|
-
};
|
|
3507
|
-
panel.innerHTML = `<div class="ph" style="--c:${c}">
|
|
3508
|
-
${(() => { const I = (D.icons || {})[n.icon];
|
|
3509
|
-
return I && I.src ? `<span class="big logo${I.dark ? ' dark' : ''}" aria-hidden="true"><img src="${I.src}" width="22" height="22" alt=""></span>`
|
|
3510
|
-
: `<span class="big" style="background:${c}" aria-hidden="true"><svg viewBox="0 0 ${I && I.g ? 24 : 16} ${I && I.g ? 24 : 16}" width="20" height="20" fill="none" stroke="currentColor" stroke-width="${I && I.g ? 2 : 1.5}" stroke-linecap="round" stroke-linejoin="round">${I && I.g ? I.g : SIGIL[k]}</svg></span>`; })()}
|
|
3511
|
-
<div class="pt"><h3>${esc(n.label || nid)}</h3><small><span>${esc(KIND[k][1])}</span><span class="vis ${pub ? 'pub' : ext ? 'ext' : ''}">${badgesOf(n).length ? badgesOf(n).map(b => esc(b.text.toUpperCase())).join(' · ') : pub ? 'PUBLIC' : ext ? 'EXTERNAL' : 'INTERNAL'}</span>${by}${n.origin ? `<span class="origin">${esc(n.origin)}</span>` : ''}</small></div>
|
|
3512
|
-
<div class="acts">${canOpen ? `<button class="obtn" data-open="${esc(child)}" style="--c:${c}">⤢ Open ${esc(shortT(V[child].title || child))} <span>${(V[child].nodeIds || []).length} nodes · Enter</span></button>` : ''}${X && X.ctx().unfoldable(nid, cur) ? `<button class="obtn" data-unfold="${esc(nid)}" style="--c:${c}">⤡ Unfold here <span>E</span></button>` : ''}${litButtons()}<button class="ibtn x" data-close title="Close (Esc)" aria-label="Close details">×</button></div>
|
|
3513
|
-
</div>
|
|
3514
|
-
<div class="ptabs" role="tablist" aria-label="Details">${tabs.map(t => `<button class="ptab" role="tab" id="pt-${t[0]}" data-tab="${t[0]}" aria-selected="${t[0] === tab}" aria-controls="m-pbody">${t[1]}${t[2] != null ? `<em>${t[2]}</em>` : ''}</button>`).join('')}
|
|
3515
|
-
<span class="pnav"><button class="ibtn sm" data-step="-1" title="Previous ([)" aria-label="Previous tab">‹</button><button class="ibtn sm" data-step="1" title="Next (])" aria-label="Next tab">›</button></span></div>
|
|
3516
|
-
<div class="pbody" id="m-pbody" role="tabpanel" aria-labelledby="pt-${tab}">${bodies[tab]}</div>`;
|
|
3517
|
-
panel.hidden = false;
|
|
3518
|
-
panel.dataset.node = nid;
|
|
3519
|
-
panel.querySelectorAll('.clamp').forEach(el => { if (el.scrollHeight > el.clientHeight + 2) el.insertAdjacentHTML('afterend', '<button class="morebtn" data-more>Show all</button>'); });
|
|
3520
|
-
}
|
|
3521
|
-
let TABS = ['when', 'what', 'how', 'links'];
|
|
3522
|
-
function openExample(nid, i) {
|
|
3523
|
-
const x = ((nodeIn(nid) || {}).examples || [])[i]; if (!x) return;
|
|
3524
|
-
let lb = $('m-lightbox');
|
|
3525
|
-
if (!lb) { lb = h(`<div class="lightbox" id="m-lightbox" role="dialog" aria-modal="true" aria-label="Example" hidden></div>`); document.body.appendChild(lb);
|
|
3526
|
-
lb.addEventListener('click', e => { if (e.target === lb || e.target.closest('[data-lbclose]')) closeExample(); }); }
|
|
3527
|
-
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>`;
|
|
3528
|
-
lb.hidden = false; lb.querySelector('[data-lbclose]').focus();
|
|
3529
|
-
}
|
|
3530
|
-
const closeExample = () => { const lb = $('m-lightbox'); if (lb) lb.hidden = true; };
|
|
3531
|
-
const setTab = t => { tab = t; try { localStorage.setItem('visual-tab', t); } catch (_) {} if (sel) renderPanel(sel); };
|
|
3532
|
-
|
|
3533
|
-
/* paths and reach (#64): lit on the map from the details panel or by key, and said in the hint line (a live region); a
|
|
3534
|
-
new selection or view clears them, and the same key again does */
|
|
3535
|
-
let lit = null, picked = [];
|
|
3536
|
-
const cardName = id => nodeIn(id).label || id, nCards = n => `${n} card${n === 1 ? '' : 's'}`;
|
|
3537
|
-
const litButtons = () => '<button class="ibtn" data-lit="out" title="Light what it leads to (>)" aria-label="Light what it leads to">→</button><button class="ibtn" data-lit="in" title="Light what leads to it (<)" aria-label="Light what leads to it">←</button>'
|
|
3538
|
-
+ (picked.length > 1 ? '<button class="ibtn" data-lit="paths" title="Light every path between the two cards (P)" aria-label="Light every path between the two selected cards">⇄</button>' : '');
|
|
3539
|
-
function unlight() { if (lit) { lit = null; handle.highlight(null); } }
|
|
3540
|
-
function light(key, refs, said) {
|
|
3541
|
-
if (lit === key) { unlight(); return status('Highlight cleared'); }
|
|
3542
|
-
lit = key; handle.highlight(refs.length ? refs : null); status(said, 3600);
|
|
3543
|
-
}
|
|
3544
|
-
function lightReach(back) {
|
|
3545
|
-
if (!sel || !geo.byId[sel]) return;
|
|
3546
|
-
const refs = reach(D, cur, sel, { backward: back }), n = refs.filter(r => r.type === 'node').length - 1, s = cardName(sel);
|
|
3547
|
-
light((back ? 'in ' : 'out ') + sel, n ? refs : [], !n ? (back ? `Nothing leads to ${s} here` : `${s} leads nowhere here`)
|
|
3548
|
-
: back ? `${nCards(n)} ${n === 1 ? 'leads' : 'lead'} to ${s}: lit on the map` : `${s} leads to ${nCards(n)}: lit on the map`);
|
|
3549
|
-
}
|
|
3550
|
-
function lightPaths() {
|
|
3551
|
-
const [a, b] = picked.slice(-2); if (!b) return;
|
|
3552
|
-
let refs = paths(D, cur, a, b), [x, y] = [a, b];
|
|
3553
|
-
if (!refs.length) { refs = paths(D, cur, b, a); [x, y] = [b, a]; } // the two cards in either order
|
|
3554
|
-
const n = refs.filter(r => r.type === 'node').length;
|
|
3555
|
-
light(`paths ${a} ${b}`, refs, n ? `Every path from ${cardName(x)} to ${cardName(y)}: ${nCards(n)}, lit on the map` : `No path joins ${cardName(a)} and ${cardName(b)} here`);
|
|
3556
|
-
}
|
|
3557
3777
|
|
|
3558
3778
|
/* navigation */
|
|
3559
3779
|
function setHash(replace) {
|
|
3560
3780
|
const p = new URLSearchParams(); p.set('view', cur); if (sel) p.set('node', sel);
|
|
3561
3781
|
if (told) { p.set('story', told.id); p.set('step', told.step + 1); } // the story shown, at its step (#69)
|
|
3782
|
+
if (lensOn) p.set('lens', lensOn); // the lens on the map (#94)
|
|
3562
3783
|
['theme', 'check', 'shot'].forEach(k => Q.get(k) && p.set(k, Q.get(k)));
|
|
3563
3784
|
const h = '#' + p.toString();
|
|
3564
3785
|
if (location.hash === h) return;
|
|
@@ -3566,35 +3787,20 @@ export function mountPage(container, model, options = {}) {
|
|
|
3566
3787
|
}
|
|
3567
3788
|
const go = (vid, how = {}) => { if (how.replace) replaceNext = true; return X.go(vid, how); };
|
|
3568
3789
|
const open = nid => X.open(nid), up = () => X.up();
|
|
3569
|
-
const findNode =
|
|
3790
|
+
const findNode = pc.find;
|
|
3570
3791
|
|
|
3571
3792
|
/* events */
|
|
3572
3793
|
stage.addEventListener('click', e => {
|
|
3573
3794
|
if (e.target.closest('#m-pillbtn')) return togglePill();
|
|
3574
|
-
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; }
|
|
3575
3795
|
const bv = e.target.closest('.vpop [data-v]'); if (bv) return go(bv.dataset.v);
|
|
3576
3796
|
});
|
|
3577
3797
|
const setFlow = on => X.setFlow(on);
|
|
3578
3798
|
const closeHelp = () => { $('m-helpbox').hidden = true; $('m-help').setAttribute('aria-expanded', 'false'); };
|
|
3579
3799
|
const closeExport = () => { $('m-export-pop').hidden = true; $('m-export').setAttribute('aria-expanded', 'false'); };
|
|
3580
|
-
panel.addEventListener('click', e => {
|
|
3581
|
-
const t = e.target;
|
|
3582
|
-
const tb = t.closest('[data-tab]'); if (tb) return setTab(tb.dataset.tab);
|
|
3583
|
-
const st = t.closest('[data-step]'); if (st) return setTab(TABS[(TABS.indexOf(tab) + +st.dataset.step + TABS.length) % TABS.length]);
|
|
3584
|
-
const mb = t.closest('[data-more]'); if (mb) { mb.previousElementSibling.classList.add('open'); mb.remove(); }
|
|
3585
|
-
const ex = t.closest('[data-ex]'); if (ex) return openExample(panel.dataset.node, +ex.dataset.ex);
|
|
3586
|
-
});
|
|
3587
3800
|
document.addEventListener('click', e => {
|
|
3588
3801
|
const t = e.target;
|
|
3589
|
-
|
|
3590
|
-
const
|
|
3591
|
-
const ob = t.closest('[data-open]'); if (ob) return go(ob.dataset.open, { via: sel });
|
|
3592
|
-
const ub = t.closest('#m-panel [data-unfold]'); if (ub) return X.unfold(ub.dataset.unfold); // in place (#71)
|
|
3593
|
-
const pv = t.closest('.pview'); if (pv) return go(pv.dataset.v, { select: pv.dataset.sel });
|
|
3594
|
-
const rn = t.closest('#m-panel [data-n]'); if (rn) return findNode(rn.dataset.n);
|
|
3595
|
-
const cv = t.closest('#m-crumbs [data-v], #m-tree [data-v]'); if (cv) { if (innerWidth <= 820) setTree(false); return go(cv.dataset.v); }
|
|
3596
|
-
const cp = t.closest('[data-copy]'); if (cp) { navigator.clipboard && navigator.clipboard.writeText(cp.dataset.copy).then(() => status('Path copied'), () => status(cp.dataset.copy)); return; }
|
|
3597
|
-
const mn = t.closest('.menu'); if (!mn || !mn.contains($('m-export'))) closeExport(); if (!mn || !mn.contains($('m-stories'))) closeStories();
|
|
3802
|
+
const cv = t.closest('#m-crumbs [data-v]'); if (cv) { if (innerWidth <= 820) setTree(false); return go(cv.dataset.v); }
|
|
3803
|
+
const mn = t.closest('.menu'); if (!mn || !mn.contains($('m-export'))) closeExport(); if (!mn || !mn.contains($('m-stories'))) closeStories(); if (!mn || !mn.contains($('m-lenses'))) closeLenses();
|
|
3598
3804
|
if (!t.closest('#m-helpbox, #m-help')) closeHelp();
|
|
3599
3805
|
});
|
|
3600
3806
|
$('m-outline').addEventListener('click', () => setTree(!document.body.classList.contains('tree-open')));
|
|
@@ -3607,12 +3813,25 @@ export function mountPage(container, model, options = {}) {
|
|
|
3607
3813
|
|
|
3608
3814
|
/* stories (#69): the Stories menu plays one; the link names the story and step shown; Export ▸ Record a story… */
|
|
3609
3815
|
let told = null; // { id, step }: the story on the map and its step
|
|
3610
|
-
const toldSel = id => { const s = told && (D.stories || []).find(x => x && x.id === told.id), st = s && (s.steps || [])[told.step]; return !!(st && st.select === id); }; // the step's own selection: a pointer, no details panel
|
|
3611
3816
|
const storiesPop = $('m-stories-pop'), closeStories = () => { if (storiesPop) { storiesPop.hidden = true; $('m-stories').setAttribute('aria-expanded', 'false'); } };
|
|
3612
3817
|
if (storiesPop) {
|
|
3613
3818
|
$('m-stories').addEventListener('click', () => { const on = storiesPop.hidden; storiesPop.hidden = !on; $('m-stories').setAttribute('aria-expanded', on); });
|
|
3614
3819
|
storiesPop.addEventListener('click', e => { const b = e.target.closest('[data-story]'); if (!b) return; closeStories(); handle.play(b.dataset.story); });
|
|
3615
3820
|
}
|
|
3821
|
+
/* lenses (#94): the Lenses menu puts one on the map, its name on the menu's button and in the link (#lens=<id>) */
|
|
3822
|
+
let lensOn = null;
|
|
3823
|
+
const lensesPop = $('m-lenses-pop'), closeLenses = () => { if (lensesPop) { lensesPop.hidden = true; $('m-lenses').setAttribute('aria-expanded', 'false'); } };
|
|
3824
|
+
function pickLens(id, fromLink) { // fromLink: the link names it already (on load, back and forward)
|
|
3825
|
+
if (!X || !X.lens(id || null)) return;
|
|
3826
|
+
lensOn = id || null;
|
|
3827
|
+
const L = arrOf(D.lenses).find(x => x && x.id === lensOn), name = L ? L.title || L.id : '';
|
|
3828
|
+
if (lensesPop) { lensesPop.querySelectorAll('[data-lens]').forEach(b => b.setAttribute('aria-pressed', b.dataset.lens === (lensOn || ''))); $('m-lenses').textContent = '◎ ' + (name || 'Lenses'); }
|
|
3829
|
+
if (!fromLink) { status(name ? 'Lens: ' + name : 'No lens'); setHash(true); }
|
|
3830
|
+
}
|
|
3831
|
+
if (lensesPop) {
|
|
3832
|
+
$('m-lenses').addEventListener('click', () => { const on = lensesPop.hidden; lensesPop.hidden = !on; $('m-lenses').setAttribute('aria-expanded', on); });
|
|
3833
|
+
lensesPop.addEventListener('click', e => { const b = e.target.closest('[data-lens]'); if (!b) return; closeLenses(); pickLens(b.dataset.lens); });
|
|
3834
|
+
}
|
|
3616
3835
|
// a story recorded from the page: each view and card the reader comes to is a step (Add step: as the camera is now too);
|
|
3617
3836
|
// captions are typed in, and Save writes the story's JSON, for the model's `stories`
|
|
3618
3837
|
let rec = null;
|
|
@@ -3705,21 +3924,18 @@ export function mountPage(container, model, options = {}) {
|
|
|
3705
3924
|
if ((e.metaKey || e.ctrlKey) && (e.key === 'k' || e.key === 'K')) { e.preventDefault(); return pal.hidden ? openPal() : closePal(); }
|
|
3706
3925
|
if (!X || (e.target.matches && e.target.matches('input, textarea')) || e.metaKey || e.ctrlKey || e.altKey) return;
|
|
3707
3926
|
const k = e.key, inCanvas = e.composedPath().includes(X.stage);
|
|
3708
|
-
if (
|
|
3709
|
-
|
|
3927
|
+
if (pc.key(e)) {} // the pieces' keys: an example's lightbox, the details tabs, the views list, paths and reach
|
|
3928
|
+
else if (k === 'Escape' || k === 'Backspace') {
|
|
3929
|
+
if (!$('m-helpbox').hidden) closeHelp(); else if (!$('m-export-pop').hidden) closeExport(); else if (storiesPop && !storiesPop.hidden) closeStories(); else if (lensesPop && !lensesPop.hidden) closeLenses(); else if ($('m-vpop') && !$('m-vpop').hidden) togglePill(false);
|
|
3710
3930
|
else if (!inCanvas) X.key(e); else return;
|
|
3711
3931
|
}
|
|
3712
3932
|
else if (k === '/') openPal();
|
|
3713
3933
|
else if (k === '?') $('m-help').click();
|
|
3714
3934
|
else if (k === 'i' || k === 'I') togglePill();
|
|
3715
|
-
else if ((k === '[' || k === ']') && sel) setTab(TABS[(TABS.indexOf(tab) + (k === ']' ? 1 : -1) + TABS.length) % TABS.length]);
|
|
3716
|
-
else if (k === 'o' || k === 'O') setTree(!document.body.classList.contains('tree-open'));
|
|
3717
|
-
else if ((k === '>' || k === '<') && sel) lightReach(k === '<');
|
|
3718
|
-
else if ((k === 'p' || k === 'P') && picked.length > 1) lightPaths();
|
|
3719
3935
|
else if (inCanvas || !X.key(e)) return;
|
|
3720
3936
|
e.preventDefault(); e.stopPropagation();
|
|
3721
3937
|
}, true);
|
|
3722
|
-
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(); });
|
|
3938
|
+
addEventListener('popstate', () => { const p = new URLSearchParams(location.hash.slice(1)); if ((p.get('lens') || null) !== lensOn) pickLens(p.get('lens'), true); const v = p.get('view') || rootId; if (v !== cur) go(v, { replace: true, select: p.get('node') || undefined }); else if (p.get('node')) select(p.get('node')); else if (sel) deselect(); });
|
|
3723
3939
|
|
|
3724
3940
|
/* export: resolve CSS variables into attributes so the file stands alone */
|
|
3725
3941
|
const standalone = scale => X.standalone(scale);
|
|
@@ -3748,17 +3964,17 @@ export function mountPage(container, model, options = {}) {
|
|
|
3748
3964
|
view: v0, minimap: true, flow: flowPref ? flowPref === '1' : undefined,
|
|
3749
3965
|
on: { story: ({ story: id, step }) => { told = step == null ? null : { id, step }; app.classList.toggle('story', !!told); if (X) setHash(true); } }, // from the first draw: a link may open a story (#69)
|
|
3750
3966
|
_page: {
|
|
3751
|
-
instant: options.instant, defer: !INSTANT, tour: !Q.get('still'), openHint: !Q.get('reader'), kinds: true, status,
|
|
3967
|
+
instant: options.instant, defer: !INSTANT, tour: !(Q.get('still') || Q.get('pdf')), openHint: !Q.get('reader'), kinds: true, status, tourAt: Q.get('shot') ? Q.get('at') : null,
|
|
3752
3968
|
wide: () => innerWidth > 820,
|
|
3753
3969
|
upTarget: () => stack.length > 1 ? stack[stack.length - 2] : (V[cur] || {}).parent,
|
|
3754
3970
|
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
|
|
3755
3971
|
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) }]; },
|
|
3756
3972
|
onNavigate: (vid, prev, mode) => { togglePill(false);
|
|
3757
3973
|
if (stack.includes(vid)) stack = stack.slice(0, stack.indexOf(vid) + 1); else if (mode === 'in' && prev) stack = [...stack, vid]; else stack = chain(vid); },
|
|
3758
|
-
onMount: inner => { X = inner; ({ D, views, nodes, N, V, rootId, subjectOf, viewsWith, kidsOf, chain, viewEdges, planCache } = X.ctx()); nodeIn0 = X.ctx().nodeIn;
|
|
3974
|
+
onMount: inner => { X = inner; ({ D, views, nodes, N, V, rootId, subjectOf, viewsWith, kidsOf, chain, viewEdges, planCache } = X.ctx()); nodeIn0 = X.ctx().nodeIn;
|
|
3759
3975
|
cam = X.cam(); geo = X.geo(); svg = X.svg(); cur = X.view(); if (paused) X.pause(true);
|
|
3760
3976
|
if (!stack.length || !stack.includes(cur)) stack = chain(cur);
|
|
3761
|
-
renderChrome();
|
|
3977
|
+
renderChrome(); },
|
|
3762
3978
|
onBackground: () => togglePill(false),
|
|
3763
3979
|
onFlow: on => { try { localStorage.setItem('visual-flow', on ? '1' : '0'); } catch (_) {} },
|
|
3764
3980
|
onReady: () => {
|
|
@@ -3766,11 +3982,272 @@ export function mountPage(container, model, options = {}) {
|
|
|
3766
3982
|
const n0 = p0.get('node') || (Q.get('shot') && cur !== rootId ? subjectOf(V[cur]) || (V[cur].nodeIds || [])[0] : null);
|
|
3767
3983
|
if (n0 && geo.byId[n0]) select(n0, { reveal: false });
|
|
3768
3984
|
if (p0.get('story')) setTimeout(() => X.play(p0.get('story'), { step: (+p0.get('step') || 1) - 1, auto: false })); // a link to a story's step: shown, not played on (once the page listens)
|
|
3985
|
+
if (p0.get('lens')) pickLens(p0.get('lens'), true); // a link's lens (#94)
|
|
3769
3986
|
setHash(true);
|
|
3770
3987
|
},
|
|
3771
3988
|
},
|
|
3772
3989
|
});
|
|
3773
|
-
handle.on('select', ({ target, targets }) => { sel = target && target.type === 'node' ? target.id : null;
|
|
3774
|
-
handle.on('viewchange', () => { unlight(); setHash(replaceNext); replaceNext = false; recordSoon(); });
|
|
3990
|
+
handle.on('select', ({ target, targets }) => { sel = target && target.type === 'node' ? target.id : null; pc.select(target, targets); setHash(true); recordSoon(); });
|
|
3991
|
+
handle.on('viewchange', () => { pc.unlight(); setHash(replaceNext); replaceNext = false; recordSoon(); });
|
|
3992
|
+
/* #pdf=1[&views=a,pic-*] (umlimator.py pdf, #93): the page becomes a document for Chrome to print, A4 across. A title
|
|
3993
|
+
page; each view on a page of its own, its picture (toSVG()'s, in vectors) as large as its describe() text below leaves
|
|
3994
|
+
room for; then the legend of what the pictures draw, and the model's rules, each holding or broken in the views
|
|
3995
|
+
printed, as the document says. It starts once the fonts are in, after the map's own redraw for them. */
|
|
3996
|
+
if (Q.get('pdf')) (document.fonts ? document.fonts.ready : Promise.resolve()).then(() => {
|
|
3997
|
+
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);
|
|
3998
|
+
const shown = views.filter(want).map(v => [v, X.standalone(1, v.id)]).filter(([, s]) => s), p = s => `<p>${esc(s)}</p>`;
|
|
3999
|
+
const broken = rules(model).filter(b => shown.some(([v]) => v.id === b.view)); // the views printed, as the document says
|
|
4000
|
+
const rs = (Array.isArray(model.rules) ? model.rules : []).filter(r => r && typeof r === 'object' && (r.from || r.to || r.noCycles)); // as rules() reads them
|
|
4001
|
+
const W = 1030, H = 690; // ponytail: A4 landscape inside 12 mm margins, in CSS px; a paper option if Letter or portrait is wanted
|
|
4002
|
+
document.title = model.title || 'Map'; document.body.removeAttribute('class');
|
|
4003
|
+
document.body.innerHTML = `<style>@page { size: A4 landscape; margin: 12mm; background: var(--um-bg); } html { -webkit-print-color-adjust: exact; print-color-adjust: exact; } body { background-image: none; }
|
|
4004
|
+
#um-pdf { width: ${W}px; font-size: 13px; line-height: 1.45; } #um-pdf section { padding: 0; } #um-pdf section + section { break-before: page; }
|
|
4005
|
+
#um-pdf h1 { font-size: 34px; margin: 230px 0 6px; } #um-pdf h2 { display: block; font: 600 19px/1.3 var(--um-sans); letter-spacing: 0; text-transform: none; color: var(--um-text); margin: 0 0 8px; }
|
|
4006
|
+
#um-pdf h2::after { content: none; } #um-pdf h2 ~ h2 { margin-top: 26px; } #um-pdf p { margin: 0 0 3px; } #um-pdf svg { display: block; margin: 0 0 10px; }
|
|
4007
|
+
#um-pdf .pdf-text { columns: 2; column-gap: 32px; font-size: 12px; } #um-pdf .pdf-text p { break-inside: avoid; } #um-pdf ul { margin: 0; padding-left: 20px; }
|
|
4008
|
+
#um-pdf .pdf-legend { display: grid; grid-template-columns: repeat(3, max-content); gap: 6px 28px; font-size: 12px; } .pdf-holds { color: var(--um-green); } .pdf-broken { color: var(--um-rose); }</style>
|
|
4009
|
+
<main id="um-pdf"><section><h1>${esc(model.title || 'Map')}</h1>${p(new Date(Date.now() - new Date().getTimezoneOffset() * 6e4).toISOString().slice(0, 10))}</section>
|
|
4010
|
+
${shown.map(([v, svg]) => `<section class="pdf-view"><h2>${esc(v.title || v.id)}</h2>${svg}<div class="pdf-text">${describe(model, v.id).split('\n').map(p).join('')}</div></section>`).join('')}
|
|
4011
|
+
<section><h2>Legend</h2><div class="pdf-legend">${legendItems(shown.map(([v]) => X.ctx().geoCache[v.id].g)).join('')}</div>
|
|
4012
|
+
<h2>Rules</h2>${rs.length ? `${p('Judged in the views printed here.')}<ul>${rs.map(r => { const b = broken.filter(x => x.rule === r.id);
|
|
4013
|
+
return `<li>${esc(r.title || r.id)}: <b class="${b.length ? 'pdf-broken' : 'pdf-holds'}">${b.length ? 'broken' : 'holds'}</b>${b.length ? `<ul>${b.map(x => `<li>${esc(x.message)}</li>`).join('')}</ul>` : ''}</li>`; }).join('')}</ul>` : p('The model has no rules.')}</section></main>`;
|
|
4014
|
+
document.querySelectorAll('#um-pdf .pdf-view svg').forEach(svg => { const b = svg.viewBox.baseVal, room = H - (svg.parentNode.offsetHeight - svg.getBoundingClientRect().height);
|
|
4015
|
+
const k = Math.min(1, W / b.width, Math.max(240, room) / b.height); svg.setAttribute('width', b.width * k); svg.setAttribute('height', b.height * k); });
|
|
4016
|
+
});
|
|
3775
4017
|
return handle;
|
|
3776
4018
|
}
|
|
4019
|
+
|
|
4020
|
+
/* ================= the standalone page's pieces (#99): views list, legend, hint line, details panel =================
|
|
4021
|
+
mountPage draws all four in its page; render() draws those a host asks for (nav, legend, hints, details) around its
|
|
4022
|
+
canvas, in its shadow root. The hint line is hintHTML() and hinter(); pieces() draws the others from the map and acts
|
|
4023
|
+
on it. X() is render()'s inner handle; el: the views list's, legend's and details panel's elements (null: not drawn);
|
|
4024
|
+
o.page: the standalone page, which keeps the reader's choices in localStorage; o.frame takes the class tree-open while
|
|
4025
|
+
the views list is open, and o.toggle says so; o.layer holds an example's lightbox; o.status(text, ms) says what happened. */
|
|
4026
|
+
const shortT = t => String(t || '').split(' · ')[0];
|
|
4027
|
+
const hintHTML = keys => typeof matchMedia === 'function' && matchMedia('(hover: none)').matches ? '<span>pinch = zoom</span><span>drag = move</span><span>tap = details</span>'
|
|
4028
|
+
: `<span>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
|
|
4029
|
+
function hinter(el, html) { let t; return (s, ms = 1800) => { el.textContent = s; el.classList.add('msg'); clearTimeout(t); t = setTimeout(() => { el.innerHTML = html; el.classList.remove('msg'); }, ms); }; }
|
|
4030
|
+
|
|
4031
|
+
// Pull concise points from captured prose; retain wording and keep the full point in its tooltip. A point is a sentence
|
|
4032
|
+
// or a clause; one longer than a line (160 characters) is cut in two, but a short sentence stays whole (#97).
|
|
4033
|
+
function detailBullets(body) {
|
|
4034
|
+
const normalized = String(body || '').replace(/^\s*(?:[-*]|\d+[.)])\s+/gm, '').replace(/\n+/g, ' ').trim();
|
|
4035
|
+
let parts = normalized.split(/(?<=[.!?])\s+(?=[A-Z`/])|;\s+|\s+[·|]\s+/).filter(Boolean);
|
|
4036
|
+
while (parts.some(p => p.length > 160) && parts.length < 4) {
|
|
4037
|
+
const index = parts.reduce((best, p, i) => p.length > (parts[best] || '').length ? i : best, 0), value = parts[index];
|
|
4038
|
+
if (!value || value.length < 8) break;
|
|
4039
|
+
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);
|
|
4040
|
+
const safe = i => (value.slice(0, i).match(/`/g) || []).length % 2 === 0;
|
|
4041
|
+
const cuts = separators.filter(i => i >= 6 && i < value.length - 4 && safe(i));
|
|
4042
|
+
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)));
|
|
4043
|
+
const cut = cuts.sort((a, b) => Math.abs(a - value.length / 2) - Math.abs(b - value.length / 2))[0];
|
|
4044
|
+
if (!cut) break;
|
|
4045
|
+
parts.splice(index, 1, value.slice(0, cut).replace(/[,;:]\s*$/, '').trim(), value.slice(cut).trim());
|
|
4046
|
+
}
|
|
4047
|
+
if (parts.length > 4) parts = [...parts.slice(0, 3), parts.slice(3).join(' ')];
|
|
4048
|
+
return parts;
|
|
4049
|
+
}
|
|
4050
|
+
|
|
4051
|
+
function legendItems(gs) { // what these pictures draw, each with what it means: the view's legend, and the PDF's (#93)
|
|
4052
|
+
const ns = gs.flatMap(g => g.nodes), es = gs.flatMap(g => g.edges), used = [...new Set(ns.map(n => n.k))];
|
|
4053
|
+
const order = ['m-public', 'm-step', 'm-lane', 'm-ext', 'm-verb', 'm-board', 'm-hook', 'm-script', 'frontend', 'backend', 'ai', 'queue', 'database', 'security', 'cloud', 'external', 'file'];
|
|
4054
|
+
const shape = k => k === 'm-verb' ? 'p' : (k === 'm-ext' || k === 'external') ? 'd' : k === 'm-hook' ? 'h' : '';
|
|
4055
|
+
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>`)
|
|
4056
|
+
.concat(ns.some(n => n.kids || n.opens) ? ['<span class="lgi"><i class="s"></i><b>⤢ Stacked</b><span>double-click to open</span></span>'] : [])
|
|
4057
|
+
.concat(ns.some(n => n.step) ? ['<span class="lgi"><i class="num">1</i><b>Step</b><span>run order</span></span>'] : [])
|
|
4058
|
+
.concat(ns.some(n => n.tag) ? ['<span class="lgi"><i class="tag"></i><b>@author</b><span>who wrote it</span></span>'] : [])
|
|
4059
|
+
.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>`))
|
|
4060
|
+
.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>'] : []);
|
|
4061
|
+
}
|
|
4062
|
+
|
|
4063
|
+
function pieces(X, el, o) {
|
|
4064
|
+
const kept = k => { try { return o.page ? localStorage.getItem(k) : null; } catch (_) { return null; } };
|
|
4065
|
+
const keep = (k, v) => { try { if (o.page) localStorage.setItem(k, v); } catch (_) {} };
|
|
4066
|
+
let sel = null, picked = [], lit = null, lb = null, folded = kept('visual-legend') === '0', tab = kept('visual-tab') || 'when', TABS = ['when', 'what', 'how', 'links'];
|
|
4067
|
+
|
|
4068
|
+
/* the legend: the kinds of cards and lines the view draws, in a box that folds away */
|
|
4069
|
+
function legend() {
|
|
4070
|
+
const items = legendItems([X().geo()]);
|
|
4071
|
+
el.legend.hidden = !items.length;
|
|
4072
|
+
el.legend.classList.toggle('folded', folded);
|
|
4073
|
+
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('')}`;
|
|
4074
|
+
}
|
|
4075
|
+
/* the views list: readable columns; a declared order may include shortcuts to descendants */
|
|
4076
|
+
function tree() {
|
|
4077
|
+
const { views, V, kidsOf, chain } = X().ctx(), cur = X().view(), ch = chain(cur);
|
|
4078
|
+
const roots = views.filter(x => !x.parent), cols = roots.length > 1 ? [{ head: 'Map', items: roots, on: ch[0] }] : [];
|
|
4079
|
+
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] }); });
|
|
4080
|
+
o.frame.style.setProperty('--browser-width', Math.min(448, Math.max(1, cols.length) * 224) + 'px');
|
|
4081
|
+
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;
|
|
4082
|
+
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>`;
|
|
4083
|
+
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 = '';
|
|
4084
|
+
}
|
|
4085
|
+
const setTree = on => { o.frame.classList.toggle('tree-open', on); if (o.toggle) o.toggle.setAttribute('aria-expanded', on); };
|
|
4086
|
+
|
|
4087
|
+
/* the details panel: header, actions and tabs of the selected card */
|
|
4088
|
+
function relRows(list, dir) {
|
|
4089
|
+
const { N } = X().ctx();
|
|
4090
|
+
if (!list.length) return `<div class="rel-none">${dir === 'up' ? 'Nothing calls this here' : 'Calls nothing here'}</div>`;
|
|
4091
|
+
return '<ul class="relation-list">' + list.map(({ e, id, inView }) => { const o = N[id] || { label: id, kind: '' }, k = kindOf(o.kind, true);
|
|
4092
|
+
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>
|
|
4093
|
+
<span class="rel-e">${e.step != null ? esc(e.step) + ' · ' : ''}${esc(e.label || '')}${inView ? '' : ' ↗'}</span></button></li>`; }).join('') + '</ul>';
|
|
4094
|
+
}
|
|
4095
|
+
const authorChip = login => { const A = (X().ctx().D.authors || {})[login] || {};
|
|
4096
|
+
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>`; };
|
|
4097
|
+
const focused = () => { const a = el.panel.getRootNode().activeElement; return a && el.panel.contains(a) ? a : null; };
|
|
4098
|
+
function show(nid) {
|
|
4099
|
+
const { D, N, V, nodeIn, opensOf, kidCount, unfoldable, viewsWith, viewEdges } = X().ctx(), geo = X().geo(), cur = X().view(), panel = el.panel;
|
|
4100
|
+
const root = D.meta && D.meta.root ? D.meta.root.replace(/\/$/, '') : '';
|
|
4101
|
+
const base = D.sourcesBase ? String(D.sourcesBase).replace(/\/$/, '') + '/' : '';
|
|
4102
|
+
const srcPath = p => p.replace(/:\d+(-\d+)?$/, '');
|
|
4103
|
+
// meta.root is the repo root relative to this page (older pages: absolute). `~/…` sources live
|
|
4104
|
+
// outside the repo, so they get no link.
|
|
4105
|
+
// meta.sourceBase (render --source-base): in-pack sources were rewritten to URLs under it and
|
|
4106
|
+
// show as their pack-relative path; any other relative source gets no link on a published page.
|
|
4107
|
+
const sb = D.meta && D.meta.sourceBase ? String(D.meta.sourceBase) : '';
|
|
4108
|
+
const srcLabel = p => sb && p.startsWith(sb) ? decodeURI(p.slice(sb.length)).replace(/#L(\d+)(?:-L(\d+))?$/, (_, a, b) => ':' + a + (b ? '-' + b : '')) : p;
|
|
4109
|
+
const srcHref = p => /^https?:/.test(p) ? p
|
|
4110
|
+
: p.startsWith('~') || sb ? ''
|
|
4111
|
+
: p.startsWith('/') ? 'file://' + encodeURI(srcPath(p))
|
|
4112
|
+
: root.startsWith('/') ? 'file://' + encodeURI(root + '/' + base + srcPath(p))
|
|
4113
|
+
: encodeURI((root ? root + '/' : '') + base + srcPath(p));
|
|
4114
|
+
const srcList = n => (Array.isArray(n.source) ? n.source : n.source ? [n.source] : []);
|
|
4115
|
+
const n = nodeIn(nid, cur), k = kindOf(n.kind, true), c = `var(--um-${KIND[k][0]})`, pub = k === 'm-public', ext = k === 'm-ext';
|
|
4116
|
+
const inView = new Set(geo.nodes.map(x => x.id)), seen = new Set();
|
|
4117
|
+
const collect = dir => {
|
|
4118
|
+
const out = [];
|
|
4119
|
+
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 }); };
|
|
4120
|
+
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); });
|
|
4121
|
+
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); });
|
|
4122
|
+
(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)); });
|
|
4123
|
+
return out;
|
|
4124
|
+
};
|
|
4125
|
+
const ups = collect('up'), downs = collect('down'), srcs = srcList(n);
|
|
4126
|
+
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)
|
|
4127
|
+
const vIn = viewsWith(nid).filter(v => v.id !== cur);
|
|
4128
|
+
const by = n.author ? `<span>·</span>${authorChip(n.author)}` : ext ? '<span class="origin">· author unknown</span>' : '';
|
|
4129
|
+
const exs = Array.isArray(n.examples) ? n.examples.filter(x => x && (x.thumb || x.href)) : [];
|
|
4130
|
+
const tabs = [['when', 'When to use'], ['what', 'What it is'], ['how', 'How it works'], ...(exs.length ? [['examples', 'Example']] : []), ['links', 'Behind the scenes']];
|
|
4131
|
+
TABS = tabs.map(t => t[0]);
|
|
4132
|
+
if (!TABS.includes(tab)) tab = 'when';
|
|
4133
|
+
const text = (h4, body, key) => body ? `<h4>${h4}</h4><ul class="detail-bullets">${detailBullets(body).map(b => `<li title="${esc(b)}">${inline(b)}</li>`).join('')}</ul>` : `<h4>${h4}</h4><div class="pnone">Not written yet — add <code>${key}</code> to this node in the map model.</div>`;
|
|
4134
|
+
const verbs = (n.verbs || []).length ? `<div class="pverbs">${n.verbs.map(v => { const vid = N[nid + ':' + v] ? nid + ':' + v : null; return `<${vid ? `button data-n="${esc(vid)}"` : 'span'} class="pverb" style="--c:${c}">${esc(v)}</${vid ? 'button' : 'span'}>`; }).join('')}</div>` : '';
|
|
4135
|
+
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>';
|
|
4136
|
+
const bodies = {
|
|
4137
|
+
when: text('When to use', n.when, 'when'),
|
|
4138
|
+
what: text('What it is', n.what, 'what') + verbs,
|
|
4139
|
+
how: text('How it works', n.how, 'how'),
|
|
4140
|
+
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>`,
|
|
4141
|
+
links: `<div class="links"><div><h4>← Comes from</h4>${relRows(ups, 'up')}</div><div><h4>→ Leads to</h4>${relRows(downs, 'down')}</div>
|
|
4142
|
+
<div><h4>Source files</h4>${srcHtml}${vIn.length ? `<h4 style="margin-top:10px">Also shown in</h4><ul class="pviews">${vIn.map(v => `<li><button class="pview" data-v="${esc(v.id)}" data-sel="${esc(nid)}">${esc(shortT(v.title || v.id))}</button></li>`).join('')}</ul>` : ''}</div></div>`,
|
|
4143
|
+
};
|
|
4144
|
+
const was = !o.page && focused(), on = was && [...was.attributes].find(a => a.name.startsWith('data-')); // a host's panel keeps focus on the control it was on
|
|
4145
|
+
panel.innerHTML = `<div class="ph" style="--c:${c}">
|
|
4146
|
+
${(() => { const I = (D.icons || {})[n.icon];
|
|
4147
|
+
return I && I.src ? `<span class="big logo${I.dark ? ' dark' : ''}" aria-hidden="true"><img src="${I.src}" width="22" height="22" alt=""></span>`
|
|
4148
|
+
: `<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>`; })()}
|
|
4149
|
+
<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>
|
|
4150
|
+
<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>
|
|
4151
|
+
</div>
|
|
4152
|
+
<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>
|
|
4153
|
+
<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>
|
|
4154
|
+
<div class="pbody" id="m-pbody" role="tabpanel" aria-labelledby="pt-${tab}">${bodies[tab]}</div>`;
|
|
4155
|
+
panel.hidden = false;
|
|
4156
|
+
panel.dataset.node = nid;
|
|
4157
|
+
panel.querySelectorAll('.clamp').forEach(el => { if (el.scrollHeight > el.clientHeight + 2) el.insertAdjacentHTML('afterend', '<button class="morebtn" data-more>Show all</button>'); });
|
|
4158
|
+
if (was) ((on && [...panel.querySelectorAll(`[${on.name}]`)].find(x => x.getAttribute(on.name) === on.value)) || panel.querySelector('[aria-selected="true"]')).focus();
|
|
4159
|
+
}
|
|
4160
|
+
function hide() { const back = !o.page && focused(); el.panel.hidden = true; if (back) queueMicrotask(() => { if (el.panel.hidden) X().focus(); }); } // a host's panel gives focus back to the map
|
|
4161
|
+
function openExample(nid, i) {
|
|
4162
|
+
const x = ((X().ctx().nodeIn(nid, X().view()) || {}).examples || [])[i]; if (!x) return;
|
|
4163
|
+
if (!lb) { lb = h(`<div class="lightbox" id="m-lightbox" role="dialog" aria-modal="true" aria-label="Example" hidden></div>`); o.layer.appendChild(lb);
|
|
4164
|
+
lb.addEventListener('click', e => { if (e.target === lb || e.target.closest('[data-lbclose]')) closeExample(); }); }
|
|
4165
|
+
lb.innerHTML = `<figure><img src="${esc(x.full || x.thumb)}" alt="${esc(x.title || '')}"><figcaption><span>${inline(x.title || '')}</span>${x.href ? `<a href="${esc(x.href)}" target="_blank" rel="noopener">Open live page ↗</a>` : ''}<button class="ibtn" data-lbclose title="Close (Esc)" aria-label="Close">×</button></figcaption></figure>`;
|
|
4166
|
+
lb.hidden = false; lb.querySelector('[data-lbclose]').focus();
|
|
4167
|
+
}
|
|
4168
|
+
const closeExample = () => { if (lb) lb.hidden = true; };
|
|
4169
|
+
const setTab = t => { tab = t; keep('visual-tab', t); if (sel) show(sel); };
|
|
4170
|
+
|
|
4171
|
+
/* paths and reach (#64): lit on the map from the details panel or by key, and said in the hint line (a live region); a
|
|
4172
|
+
new selection or view clears them, and the same key again does */
|
|
4173
|
+
const cardName = id => X().ctx().nodeIn(id, X().view()).label || id, nCards = n => `${n} card${n === 1 ? '' : 's'}`;
|
|
4174
|
+
// the slowest and the costliest path (#95), offered where the view's lines have latency or cost
|
|
4175
|
+
const BY = { latency: ['⏱', 'slowest'], cost: ['¤', 'costliest'] }, budgets = () => Object.keys(BY).filter(k => X().ctx().viewEdges(X().ctx().V[X().view()]).some(e => typeof e[k] === 'number'));
|
|
4176
|
+
const 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>'
|
|
4177
|
+
+ (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>' : '')
|
|
4178
|
+
+ 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}">${BY[k][0]}</button>`; }).join('')
|
|
4179
|
+
+ '<button class="ibtn" data-lit="down" title="What if this goes down?" aria-label="What if this goes down?">⊘</button>';
|
|
4180
|
+
function unlight() { if (lit) { lit = null; X().highlight(null); X().overlay('lit', null); } }
|
|
4181
|
+
function light(key, refs, said, marks = null) { // marks: a layer that goes with the highlight (a path's total, the card that is down)
|
|
4182
|
+
if (lit === key) { unlight(); return o.status('Highlight cleared'); }
|
|
4183
|
+
lit = key; X().highlight(refs.length ? refs : null); X().overlay('lit', marks); o.status(said, 3600);
|
|
4184
|
+
}
|
|
4185
|
+
function lightReach(back) {
|
|
4186
|
+
if (!sel || !X().geo().byId[sel]) return;
|
|
4187
|
+
const refs = reach(X().ctx().D, X().view(), sel, { backward: back }), n = refs.filter(r => r.type === 'node').length - 1, s = cardName(sel);
|
|
4188
|
+
light((back ? 'in ' : 'out ') + sel, n ? refs : [], !n ? (back ? `Nothing leads to ${s} here` : `${s} leads nowhere here`)
|
|
4189
|
+
: back ? `${nCards(n)} ${n === 1 ? 'leads' : 'lead'} to ${s}: lit on the map` : `${s} leads to ${nCards(n)}: lit on the map`);
|
|
4190
|
+
}
|
|
4191
|
+
function lightPaths() {
|
|
4192
|
+
const [a, b] = picked.slice(-2); if (!b) return;
|
|
4193
|
+
const D = X().ctx().D, cur = X().view();
|
|
4194
|
+
let refs = paths(D, cur, a, b), [x, y] = [a, b];
|
|
4195
|
+
if (!refs.length) { refs = paths(D, cur, b, a); [x, y] = [b, a]; } // the two cards in either order
|
|
4196
|
+
const n = refs.filter(r => r.type === 'node').length;
|
|
4197
|
+
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`);
|
|
4198
|
+
}
|
|
4199
|
+
function lightCritical(by) { // the slowest or costliest path from the selected card, or between the two selected, its total on its last card (#95)
|
|
4200
|
+
if (!sel) return;
|
|
4201
|
+
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 });
|
|
4202
|
+
let r = run(a, b); if (b && !r.refs.length) r = run(b, a); // the two cards in either order
|
|
4203
|
+
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];
|
|
4204
|
+
light(`${by} ${a} ${b || ''}`, r.refs, !cs.length ? (b ? `No path joins ${cardName(a)} and ${cardName(b)} here` : `${cardName(a)} leads nowhere here`)
|
|
4205
|
+
: `The ${word} path: ${cs.map(cardName).join(' → ')}, ${sum}`, cs.length ? { nodes: { [cs[cs.length - 1]]: { badge: { text: sum, tone: 'amber' } } } } : null);
|
|
4206
|
+
}
|
|
4207
|
+
function lightDown() { // what if the selected card goes down (#95): what loses its way to it
|
|
4208
|
+
if (!sel) return;
|
|
4209
|
+
const r = whatIf(X().ctx().D, X().view(), { remove: [sel] }), n = r.unreachable.filter(x => x.type === 'node').length - 1;
|
|
4210
|
+
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'}`,
|
|
4211
|
+
{ nodes: { [sel]: { tone: 'rose', badge: { text: 'down', tone: 'rose' } } } });
|
|
4212
|
+
}
|
|
4213
|
+
// a card: selected here, or shown in the view that has it, nearest this one first
|
|
4214
|
+
const find = nid => { const { viewsWith, chain } = X().ctx(), cur = X().view(); if (X().geo().byId[nid]) return X().select({ type: 'node', id: nid });
|
|
4215
|
+
const vs = viewsWith(nid), v = vs.find(x => chain(x.id).includes(cur)) || vs[0]; if (v) X().go(v.id, { select: nid }); };
|
|
4216
|
+
|
|
4217
|
+
if (el.legend) el.legend.addEventListener('click', e => { if (!e.target.closest('#m-lfold')) return;
|
|
4218
|
+
folded = !folded; keep('visual-legend', folded ? '0' : '1'); legend(); if (!X().cam().touched) X().cam().set(X().startView()); });
|
|
4219
|
+
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); });
|
|
4220
|
+
if (el.panel) el.panel.addEventListener('click', e => {
|
|
4221
|
+
const t = e.target;
|
|
4222
|
+
const tb = t.closest('[data-tab]'); if (tb) return setTab(tb.dataset.tab);
|
|
4223
|
+
const st = t.closest('[data-step]'); if (st) return setTab(TABS[(TABS.indexOf(tab) + +st.dataset.step + TABS.length) % TABS.length]);
|
|
4224
|
+
const mb = t.closest('[data-more]'); if (mb) { mb.previousElementSibling.classList.add('open'); mb.remove(); }
|
|
4225
|
+
const ex = t.closest('[data-ex]'); if (ex) return openExample(el.panel.dataset.node, +ex.dataset.ex);
|
|
4226
|
+
if (t.closest('[data-close]')) return X().select(null);
|
|
4227
|
+
const lt = t.closest('[data-lit]'); if (lt) { const k = lt.dataset.lit; return k === 'paths' ? lightPaths() : k === 'down' ? lightDown() : BY[k] ? lightCritical(k) : lightReach(k === 'in'); }
|
|
4228
|
+
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
|
|
4229
|
+
const ub = t.closest('[data-unfold]'); if (ub) return X().unfold(ub.dataset.unfold); // in place (#71), the expand event first
|
|
4230
|
+
const pv = t.closest('.pview'); if (pv) return X().go(pv.dataset.v, { select: pv.dataset.sel });
|
|
4231
|
+
const rn = t.closest('[data-n]'); if (rn) return find(rn.dataset.n);
|
|
4232
|
+
const cp = t.closest('[data-copy]'); if (cp) navigator.clipboard && navigator.clipboard.writeText(cp.dataset.copy).then(() => o.status('Path copied'), () => o.status(cp.dataset.copy));
|
|
4233
|
+
});
|
|
4234
|
+
|
|
4235
|
+
return {
|
|
4236
|
+
chrome() { if (el.legend) legend(); if (el.tree) tree(); if (sel && el.panel && !el.panel.hidden) show(sel); }, // each picture of the map
|
|
4237
|
+
select(target, targets) { // the map's select event: the selected card's details, unless a story's step picked it (a pointer)
|
|
4238
|
+
sel = target && target.type === 'node' ? target.id : null; picked = (targets || []).filter(t => t.type === 'node').map(t => t.id);
|
|
4239
|
+
unlight(); if (el.panel) { if (sel && !X().told(sel)) show(sel); else hide(); }
|
|
4240
|
+
},
|
|
4241
|
+
unlight, find, setTree,
|
|
4242
|
+
key(e) { // the pieces' keys: true when one was used
|
|
4243
|
+
const k = e.key;
|
|
4244
|
+
if ((k === 'Escape' || k === 'Backspace') && lb && !lb.hidden) closeExample();
|
|
4245
|
+
else if ((k === '[' || k === ']') && sel && el.panel) setTab(TABS[(TABS.indexOf(tab) + (k === ']' ? 1 : -1) + TABS.length) % TABS.length]);
|
|
4246
|
+
else if ((k === 'o' || k === 'O') && el.tree) setTree(!o.frame.classList.contains('tree-open'));
|
|
4247
|
+
else if ((k === '>' || k === '<') && sel && el.panel) lightReach(k === '<');
|
|
4248
|
+
else if ((k === 'p' || k === 'P') && picked.length > 1 && el.panel) lightPaths();
|
|
4249
|
+
else return false;
|
|
4250
|
+
return true;
|
|
4251
|
+
},
|
|
4252
|
+
};
|
|
4253
|
+
}
|