umlimator 0.12.1 → 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/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,
@@ -307,33 +440,39 @@ let hush = null; // a layout tried and maybe dropped (unfolding, #71) keeps its
307
440
  its neighbours in the next or previous column lie, its place now breaking ties), and the best order found is kept only
308
441
  when it crosses less than the order given. A line that skips columns has a point in each column it passes (Sugiyama's
309
442
  dummy nodes), so it orders the cards it runs between too. cols: columns of ids in reading order, changed in place;
310
- links: [a, b] with b in a later column. */
311
- function untangle(cols, links) {
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 = {}) {
312
449
  const colOf = {}; cols.forEach((c, k) => c.forEach(id => { colOf[id] = k; }));
313
- const segs = [], extra = new Set(), want = {};
314
- links.forEach(([a, b], n) => {
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) => {
315
454
  const ka = colOf[a], kb = colOf[b]; if (ka == null || kb == null || kb <= ka) return;
316
- let prev = a; const ra = cols[ka].indexOf(a), rb = cols[kb].indexOf(b);
455
+ let prev = a, po = mid(a, oa); const ra = at0[a] + po, rb = at0[b] + mid(b, ob);
317
456
  for (let k = ka + 1; k < kb; k++) { // a point first goes where the straight line between its ends would be
318
457
  const p = '\u0001' + n + '.' + k; extra.add(p); colOf[p] = k; want[p] = ra + (rb - ra) * (k - ka) / (kb - ka);
319
- segs.push([prev, p]); prev = p; }
320
- segs.push([prev, b]);
458
+ segs.push([prev, p, po, 0]); prev = p; po = 0; }
459
+ segs.push([prev, b, po, mid(b, ob)]);
321
460
  });
322
461
  if (extra.size) cols.forEach((c, k) => { const ps = [...extra].filter(p => colOf[p] === k);
323
- if (ps.length) cols[k] = [...c.map((id, i) => [id, i, 0]), ...ps.map(p => [p, want[p], 1])].sort((x, y) => x[1] - y[1] || x[2] - y[2]).map(x => x[0]); });
324
- const ins = {}, outs = {}; segs.forEach(([a, b]) => { (outs[a] ||= []).push(b); (ins[b] ||= []).push(a); });
325
- const crossings = () => { const at = {}; let n = 0; cols.forEach(c => c.forEach((id, i) => { at[id] = i; }));
326
- for (let i = 0; i < segs.length; i++) for (let j = i + 1; j < segs.length; j++) { const [a, b] = segs[i], [c, d] = segs[j];
327
- if (colOf[a] === colOf[c] && (at[a] - at[c]) * (at[b] - at[d]) < 0) n++; }
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++; }
328
467
  return n; };
329
468
  let best = segs.length > 1 ? crossings() : 0, keep = cols.map(c => c.slice()); // the order given, unless one crosses less
330
469
  for (let it = 0; it < 8 && best; it++) {
331
- const down = it % 2 === 0, seq = cols.map((_, k) => k).filter(k => down ? k > 0 : k < cols.length - 1);
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));
332
471
  if (!down) seq.reverse();
333
- for (const k of seq) { const at = Object.fromEntries(cols[down ? k - 1 : k + 1].map((id, i) => [id, i]));
334
- const key = (id, i) => { const nb = ((down ? ins : outs)[id] || []).filter(x => at[x] != null).map(x => at[x]);
335
- return nb.length ? nb.reduce((x, y) => x + y, 0) / nb.length : i; };
336
- cols[k] = cols[k].map((id, i) => [id, key(id, i), i]).sort((p, q) => p[1] - q[1] || p[2] - q[2]).map(x => x[0]); }
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]); }
337
476
  const n = crossings(); if (n < best) { best = n; keep = cols.map(c => c.slice()); }
338
477
  }
339
478
  keep.forEach((c, k) => { cols[k] = c.filter(id => !extra.has(id)); });
@@ -350,9 +489,9 @@ function pullSources(ids, links, rank) {
350
489
  to, then again from the left, as near as the order allows with its height between it and the next (least squares: pool
351
490
  adjacent violators); on half rows from 0. A line between cards on one row runs straight, past fewer cards. h: an item's
352
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
353
- ob (a box's card), else at its middle. */
354
- function rowsFor(cols, links, h = () => 1) {
355
- const row = {}, colOf = {}; cols.forEach((c, k) => { let r = 0; c.forEach(id => { row[id] = r; r += h(id); colOf[id] = k; }); });
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; }); });
356
495
  const ins = {}, outs = {}, at = (id, o) => o ?? (h(id) - 1) / 2; // where a link meets an item, in rows from its top
357
496
  links.forEach(([a, b, oa, ob]) => { if (colOf[a] == null || colOf[b] == null || colOf[b] <= colOf[a]) return;
358
497
  (outs[a] ||= []).push([b, at(b, ob), at(a, oa)]); (ins[b] ||= []).push([a, at(a, oa), at(b, ob)]); });
@@ -362,11 +501,11 @@ function rowsFor(cols, links, h = () => 1) {
362
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; } });
363
502
  off = 0; blocks.forEach(b => b.ids.forEach(id => { row[id] = b.sum / b.n + off; off += h(id); })); };
364
503
  for (const [from, to, step] of [[1, cols.length, 1], [cols.length - 2, -1, -1], [1, cols.length, 1]])
365
- for (let k = from; k !== to; k += step) { const want = {};
504
+ for (let k = from; k !== to; k += step) { if (cols[k].every(id => id in fixed)) continue; const want = {};
366
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]; });
367
506
  fit(cols[k], want); }
368
- const low = Math.min(...Object.values(row));
369
- cols.forEach(c => c.forEach((id, i) => { row[id] = Math.round((row[id] - low) * 2) / 2; if (i) row[id] = Math.max(row[id], row[c[i - 1]] + h(c[i - 1])); }));
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])); }));
370
509
  return row;
371
510
  }
372
511
 
@@ -524,9 +663,9 @@ function layoutPass(dg, map, room, last) { // room: { x: { col: px }, y: { row:
524
663
  return c ? [...(c.nodeIds || []), ...(dg.containers || []).filter(k => k.parent === b).flatMap(k => inBox(k.id, seen))] : []; };
525
664
  const near = (b, other) => { const cs = inBox(b).map(i => colOf[i]).filter(c => c != null); if (other == null || !cs.length) return null;
526
665
  const lo = Math.min(...cs), hi = Math.max(...cs); return other < lo ? lo : other > hi ? hi : null; };
527
- // an unfolded numbered view's lane is a box in its box (#82): a line to a card in one, or to that box, crosses two borders.
528
- // ponytail: lanes only; an inner view's own nested boxes have the same 12 px less, but room for them moves views drawn today
529
- const inLane = id => (dg.containers || []).some(f => f.lane && (f.parent === id || (f.nodeIds || []).includes(id)));
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)));
530
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;
531
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;
532
671
  gapAfter[c] = Math.max(gapAfter[c] || 0, w + 54 + box);
@@ -696,7 +835,11 @@ function layoutPass(dg, map, room, last) { // room: { x: { col: px }, y: { row:
696
835
  // in one direction, cards in one column on the left of a bottom side came in the wrong order, and their lines crossed.
697
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);
698
837
  while (a <= -Math.PI) a += 2 * Math.PI; while (a > Math.PI) a -= 2 * Math.PI; return side === 'T' ? a : -a; };
699
- list.sort((a, b) => vertical ? (a.other.y - b.other.y) || ((side === 'R' ? -1 : 1) * (a.other.x - b.other.x)) : turn(a) - turn(b));
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));
700
843
  const sp = vertical ? null : sideSpan(n, side);
701
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;
702
845
  const gap = Math.min(vertical ? 16 : 26, (len * 0.84) / Math.max(1, list.length)), cy = n.y + n.h / 2;
@@ -792,6 +935,9 @@ function layoutPass(dg, map, room, last) { // room: { x: { col: px }, y: { row:
792
935
  }
793
936
  for (let k = 0; k < N; k++) if (!inObs[k] && (!onAv[k] || allowCross)) { ok[k] = 1; crossAt[k] = onAv[k]; }
794
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));
795
941
  const ix = v => X.indexOf(v), iy = v => Y.indexOf(v);
796
942
  const s0 = ix(st[0]) * ny + iy(st[1]), t0 = ix(en[0]) * ny + iy(en[1]);
797
943
  if (!ok[s0] || !ok[t0]) return null;
@@ -835,6 +981,7 @@ function layoutPass(dg, map, room, last) { // room: { x: { col: px }, y: { row:
835
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;
836
982
  let w = Math.abs(X[i2] - xi) + Math.abs(Y[j2] - yj) + (nd === dir ? 0 : BEND) + (crossAt[c2] ? CROSS : 0);
837
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));
838
985
  if (c2 === t0 && nd !== dEnd) w += BEND;
839
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])); }
840
987
  }
@@ -943,8 +1090,10 @@ function layoutPass(dg, map, room, last) { // room: { x: { col: px }, y: { row:
943
1090
  return { room: need, ms: { probe: performance.now() - t0 } };
944
1091
  }
945
1092
  }
946
- // port repair: two routes that cross next to a shared node side swap ports, keep it if issues drop
947
- const issueCount = () => routeAudit({ edges }, '').length;
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);
948
1097
  const endAt = (e, n, side) => (e.s === n && e.fs === side) ? 'p1' : (e.t === n && e.ts === side) ? 'p2' : null;
949
1098
  let issues = issueCount();
950
1099
  // a swap re-routes only the two swapped edges (ripped up, then routed after every other route), not the whole
@@ -974,11 +1123,11 @@ function layoutPass(dg, map, room, last) { // room: { x: { col: px }, y: { row:
974
1123
  if (budget <= 0) break outer3;
975
1124
  const pa = a[ka], pb = b[kb]; a[ka] = pb; b[kb] = pa; restub(a); restub(b);
976
1125
  const snap = [a, b].map(e => [e, e.pts, e.lbl, e.how]), snapR = [routed.slice(), reserved.slice(), placed.slice()];
977
- const ab = new Set([a, b]), was = routeAudit({ edges }, '', ab).length; // issues away from a and b cannot change
1126
+ const ab = new Set([a, b]), was = issueCount(ab); // issues away from a and b cannot change
978
1127
  budget -= 2; __ms.reroutes++;
979
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
980
1129
  ripUp(a); ripUp(b);
981
- const now = routeOne(a) && routeOne(b) ? issues - was + routeAudit({ edges }, '', ab).length : Infinity;
1130
+ const now = routeOne(a) && routeOne(b) ? issues - was + issueCount(ab) : Infinity;
982
1131
  if (now < issues) { issues = now; fixed = swapped = true; if (!issues) break outer3; continue outer3; }
983
1132
  a[ka] = pa; b[kb] = pb; restub(a); restub(b);
984
1133
  snap.forEach(([e, p, l, hw]) => { e.pts = p; e.lbl = l; e.how = hw; });
@@ -1337,6 +1486,9 @@ class Camera {
1337
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()
1338
1487
  // turns those into Refs; the page's check reads only the messages
1339
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; };
1340
1492
  function routeAudit(geo, where, only, refs) { // only: a set of edges; counts just the issues that involve one of them
1341
1493
  const sk = e => only && !only.has(e);
1342
1494
  const issues = [], say = teller(issues, refs);
@@ -1372,10 +1524,7 @@ function routeAudit(geo, where, only, refs) { // only: a set of edges; counts ju
1372
1524
  // clearance: a route keeps 12+ px from every node it does not connect
1373
1525
  geo.nodes.forEach(n => { if (n.id === e.from || n.id === e.to) return;
1374
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); });
1375
- // no detours: a route stays under 2.5x the straight distance between its ends
1376
- 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);
1377
- const d = Math.hypot(e.pts[e.pts.length - 1][0] - e.pts[0][0], e.pts[e.pts.length - 1][1] - e.pts[0][1]);
1378
- if (len > 2.5 * Math.max(d, 100)) say(`${where}: edge ${name(e)} takes a detour (${Math.round(len)}px for ${Math.round(d)}px)`, e);
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);
1379
1528
  // reading order: every wire runs left to right, except one that closes a loop or answers its pair (keepPairs)
1380
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);
1381
1530
  });
@@ -1662,7 +1811,11 @@ function mapContext(model, opts = {}) {
1662
1811
  /* plain views: longest-path columns from the roots, state at the bottom of its column */
1663
1812
  /* views with containers: each container is a block in columns of its own, its cards in call order inside it; blocks (and
1664
1813
  cards in no container) run left to right by the lines between them, and those with no line between them stack in the
1665
- same columns. A nested container is a block inside its parent's. */
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. */
1666
1819
  function blockPos(vid) {
1667
1820
  const v = V[vid], ids = (v.nodeIds || []).filter(i => N[i]), lp = loopsOf(v), boxes = (v.containers || []).filter(c => c && c.id);
1668
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]));
@@ -1673,12 +1826,13 @@ function mapContext(model, opts = {}) {
1673
1826
  const first = x => { const k = lp.edges.findIndex(e => holds(x, e.from) || holds(x, e.to)); return k < 0 ? Infinity : k; };
1674
1827
  const sink = x => ids.includes(x) && isState(x) && !fwd.some(e => e.from === x); // state that leads nowhere goes last
1675
1828
  const lined = x => lp.edges.some(e => e.from !== e.to && (holds(x, e.from) || holds(x, e.to)));
1676
- const block = g => { // a group's cards at [col, row] from its top left, and its size in slots
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
1677
1831
  const all = [...ids.filter(i => home[i] === g), ...boxes.filter(b => up(b.id) === g).map(b => b.id)];
1678
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
1679
1833
  // their box, or under the view; they stacked in the first column with the sources
1680
1834
  const loose = all.some(lined) ? all.filter(x => ids.includes(x) && !lined(x)) : [], items = all.filter(x => !loose.includes(x)), size = {}, out = {};
1681
- items.forEach(x => { size[x] = ids.includes(x) ? { w: 1, h: 1, pos: { [x]: [0, 0] } } : block(x); out[x] = []; });
1835
+ items.forEach(x => { size[x] = ids.includes(x) ? { w: 1, h: 1, pos: { [x]: [0, 0] } } : block(x, P, order); out[x] = []; });
1682
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); });
1683
1837
  const st = {}, dag = [], into = new Set(Object.values(out).flat()); // lines that close a loop do not order the blocks
1684
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; };
@@ -1688,10 +1842,22 @@ function mapContext(model, opts = {}) {
1688
1842
  const cols = [], pos = {}; let c0 = 0, h = 0;
1689
1843
  items.forEach(x => (cols[rank[x]] ||= []).push(x));
1690
1844
  const main = cols.map(l => (l || []).filter(x => !sink(x)).sort((a, b) => first(a) - first(b)));
1691
- untangle(main, dag); // in call order, unless another order crosses less (#53)
1692
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
1693
- const row = rowsFor(main, fwd.filter(level).map(e => { const a = itemIn(e.from, g), b = itemIn(e.to, g); return a !== undefined && b !== undefined && a !== b ? [a, b, inside(a, e.from), inside(b, e.to)] : null; }).filter(Boolean),
1694
- x => size[x].h); // each block level with the cards its lines come from and go to
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
1695
1861
  main.forEach((l, k) => { cols[k] = [...l, ...(cols[k] || []).filter(sink).sort((a, b) => first(a) - first(b))]; });
1696
1862
  cols.forEach(list => { let r0 = 0, w = 0;
1697
1863
  list.forEach(x => { const sz = size[x]; if (row[x] != null) r0 = Math.max(r0, row[x]);
@@ -1699,9 +1865,11 @@ function mapContext(model, opts = {}) {
1699
1865
  c0 += w; h = Math.max(h, r0); });
1700
1866
  const per = Math.max(2, c0); loose.forEach((x, k) => { pos[x] = [k % per, h + Math.floor(k / per)]; });
1701
1867
  if (loose.length) { c0 = Math.max(c0, Math.min(per, loose.length)); h += Math.ceil(loose.length / per); }
1702
- return { w: c0, h, pos };
1868
+ return (now[g] = { w: c0, h, pos });
1703
1869
  };
1704
- return block(null).pos;
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;
1705
1873
  }
1706
1874
  const autoPos = vid => ((V[vid] || {}).containers || []).length ? blockPos(vid) : bestFlow(vid);
1707
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
@@ -1860,7 +2028,7 @@ function mapContext(model, opts = {}) {
1860
2028
  const t0 = performance.now(); let wall = 4000; // per-view time box, extended while problems remain
1861
2029
  const v = V[vid], ids = (v.nodeIds || []).filter(i => N[i]);
1862
2030
  let pos = JSON.parse(JSON.stringify(autoPos(vid)));
1863
- const boxed = (v.containers || []).length, homeOf = i => ((v.containers || []).filter(c => (c.nodeIds || []).includes(i)).pop() || {}).id; // blocks stay whole
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
1864
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);
1865
2033
  let best = scoreGeo(buildGeo(vid, pos, false, true)), evals = 1;
1866
2034
  const hub = subjectOf(v), hubCol = hub && pos[hub] ? pos[hub][0] : null;
@@ -1896,6 +2064,9 @@ function mapContext(model, opts = {}) {
1896
2064
  }
1897
2065
  }
1898
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);
1899
2070
  return { pos, score: best, evals };
1900
2071
  }
1901
2072
  // what a view's picture is drawn from: an update keeps the layout of every view whose key did not change
@@ -2066,6 +2237,7 @@ export function validate(model) {
2066
2237
  if (ids.has(n.id)) err(at + '.id', `card id "${n.id}" is used twice`); ids.add(n.id);
2067
2238
  const k = String(n.kind ?? '').toLowerCase();
2068
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');
2069
2241
  };
2070
2242
  arr(m.nodes).forEach((n, i) => card(n, `nodes[${i}]`));
2071
2243
  views.forEach((v, i) => arr(v.nodes).forEach((n, j) => card(n, `views[${i}].nodes[${j}]`)));
@@ -2079,6 +2251,7 @@ export function validate(model) {
2079
2251
  if (e.kind != null && !WIRE_KINDS.includes(e.kind)) warn(at + '.kind', `unknown line kind "${e.kind}": drawn as calls`);
2080
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`);
2081
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)
2082
2255
  };
2083
2256
  // a model line may end on any view's box (#28); a view's own line only on that view's boxes
2084
2257
  const anyBox = new Set(views.flatMap(v => arr(v.containers).map(c => c && c.id)));
@@ -2106,13 +2279,16 @@ export function validate(model) {
2106
2279
  // rules (#70): an id, and from, to or noCycles; a selector that names nothing picks nothing, and a misspelt kind picks script cards
2107
2280
  if (m.rules != null && !Array.isArray(m.rules)) err('rules', 'rules is a list');
2108
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); });
2109
- const pick = (s, at) => {
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
2110
2284
  if (s == null) return;
2111
- if (typeof s !== 'object' || Array.isArray(s)) return err(at, 'a selector is an object: id, kind, container or not');
2112
- Object.keys(s).forEach(k => { if (!['id', 'kind', 'container', 'not'].includes(k)) warn(`${at}.${k}`, `unknown key "${k}": ignored, so the selector picks more cards`); });
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`); });
2113
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`); });
2114
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`); });
2115
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`); });
2116
2292
  pick(s.not, at + '.not');
2117
2293
  };
2118
2294
  arr(m.rules).forEach((r, i) => {
@@ -2123,6 +2299,16 @@ export function validate(model) {
2123
2299
  pick(r.from, at + '.from'); pick(r.to, at + '.to');
2124
2300
  arr(r.views).forEach((v, j) => { if (!vids.has(v)) err(`${at}.views[${j}]`, `"${v}" is not a view`); });
2125
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
+ });
2126
2312
  // stories (#69): each step's view, and the cards, lines and tour it names there
2127
2313
  if (m.stories != null && !Array.isArray(m.stories)) err('stories', 'stories is a list of stories');
2128
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))]);
@@ -2162,21 +2348,28 @@ const boxesNamed = (s, isBox) => !s || typeof s !== 'object' ? []
2162
2348
  const picks = (s, end) => !s || ((s.id == null || [s.id].flat().includes(end.id))
2163
2349
  && (s.kind == null || [s.kind].flat().some(k => end.kind != null && kindOf(k, true) === end.kind))
2164
2350
  && (s.container == null || [s.container].flat().some(c => end.boxes.includes(c)))
2351
+ && (s.owner == null || [s.owner].flat().includes(end.owner))
2165
2352
  && (s.not == null || !picks(s.not, end)));
2166
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
+ }
2167
2364
  /** Every rule the model breaks, in each view that draws it broken: a line a rule forbids, or a cycle of boxes. */
2168
2365
  export function rules(model) {
2169
2366
  const list = arrOf(model && model.rules).filter(r => r && typeof r === 'object' && (r.from || r.to || r.noCycles));
2170
2367
  if (!list.length) return [];
2171
- const { views, N, nodeIn, viewEdges } = mapContext(model), out = [];
2368
+ const ctx = mapContext(model), { views, N, viewEdges } = ctx, out = [];
2172
2369
  const anyBox = new Set(views.flatMap(v => arrOf(v.containers).map(c => c && c.id).filter(Boolean)));
2173
2370
  const named = new Map(list.map(r => [r, [r.from, r.to].flatMap(s => boxesNamed(s, id => anyBox.has(id) && !N[id]))]));
2174
2371
  views.forEach(v => {
2175
- const cs = arrOf(v.containers).filter(c => c && c.id), box = {}; cs.forEach(c => { box[c.id] ||= c; });
2176
- const order = Object.values(box).map(c => c.id), home = {}; cs.forEach(c => arrOf(c.nodeIds).forEach(id => { home[id] ??= c.id; }));
2177
- const chain = b => { const c = []; for (const seen = new Set(); b != null && box[b] && !seen.has(b); b = box[b].parent) { seen.add(b); c.unshift(b); } return c; }; // outermost first
2178
- const end = id => box[id] ? { id, kind: null, boxes: chain(id), label: box[id].label || id }
2179
- : { id, kind: kindOf(nodeIn(id, v.id).kind, true), boxes: chain(home[id]), label: nodeIn(id, v.id).label || id };
2372
+ const { box, end } = endsIn(ctx, v), order = Object.values(box).map(c => c.id);
2180
2373
  // a target names the drawn line, which may stand for several edges: grouped as prepEdges groups them (parallel edges, and
2181
2374
  // A→B with B→A, unless keepPairs), in its order, so select() finds it
2182
2375
  // ponytail: a copy of prepEdges' grouping; share one helper if that grouping changes
@@ -2375,20 +2568,27 @@ export function render(container, model, options = {}) {
2375
2568
  let ctx = mapContext(model, { measure, kinds: !!P.kinds, expand: expanded }); // kinds: the standalone page guesses line kinds from label words, for its legend
2376
2569
  const root = host.attachShadow({ mode: 'open', delegatesFocus: true }); // host.focus() lands in the map (on the selected card)
2377
2570
  root.adoptedStyleSheets = [sheet()];
2378
- root.innerHTML = `<div class="um-live" role="status" aria-live="polite"></div><div class="stage" tabindex="-1">
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">
2379
2576
  <div class="mctl">${options.minimap ? '<div class="minimap" title="Overview — click or drag to move"></div>' : ''}${options.controls === false ? '' : `
2380
2577
  <div class="ctl">
2381
2578
  <button data-z="up" title="Up one level (Esc)" aria-label="Up one level">↑</button><span class="sep"></span>
2382
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>
2383
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>
2384
- </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>'}`;
2385
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
2386
2586
  const subs = {};
2387
2587
  const emit = (type, e) => { const f = (options.on || {})[type]; if (f) f(e); (subs[type] || []).slice().forEach(fn => fn(e)); };
2388
2588
  let cam = null, geo = null, svg = null, cur = null, sel = null, more = [], busy = false, paused = false, dead = false;
2389
2589
  const timers = new Set(), later = (f, ms) => { const t = setTimeout(() => { timers.delete(t); if (!dead) f(); }, ms); timers.add(t); return t; };
2390
2590
  const sleep = ms => new Promise(r => later(r, reducedMotion() ? 0 : ms));
2391
- const status = t => P.status && P.status(t);
2591
+ const status = t => P.status ? P.status(t) : tell && tell(t);
2392
2592
  const wide = P.wide || (() => (cam ? cam.size()[0] : stage.clientWidth) > 820);
2393
2593
 
2394
2594
  const upOf = () => (P.upTarget ? P.upTarget() : (ctx.V[cur] || {}).parent) || null;
@@ -2420,7 +2620,7 @@ export function render(container, model, options = {}) {
2420
2620
  stage.insertAdjacentHTML('afterbegin', c.markup); geo = c.g;
2421
2621
  }
2422
2622
  svg = stage.querySelector('svg'); svg.classList.add('map-svg'); if (paused) svg.pauseAnimations();
2423
- if (!cam) cam = new Camera(stage, svg, { wheel: 'zoom', maxFit: 1.15, max: 4, mapDepth: 0.3, onChange: onCam, skip: e => !!((can('move') || can('reorder') || can('add')) && e.target.closest && e.target.closest('.node, .um-handle')), inset: () => clearOf([].concat(P.inset ? P.inset() : { t: 16, r: 16, b: 16, l: 16 })) });
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())) });
2424
2624
  cam.setSvg(svg, geo.bounds);
2425
2625
  const ub = q('[data-z="up"]'); if (ub) ub.disabled = !upOf();
2426
2626
  if (options.minimap) buildMini();
@@ -2428,9 +2628,14 @@ export function render(container, model, options = {}) {
2428
2628
  const had = sel; sel = null; tabKeys(); paint();
2429
2629
  more = [];
2430
2630
  if (had) emitSel();
2431
- 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();
2432
2633
  if (P.onMount) P.onMount(inner);
2433
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; };
2434
2639
 
2435
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
2436
2641
  also tries room for them at the bottom and at the right, and keeps whichever leaves the map larger */
@@ -2438,7 +2643,7 @@ export function render(container, model, options = {}) {
2438
2643
  function clearOf(base) {
2439
2644
  if (!cam || !geo) return base;
2440
2645
  const [W, H] = cam.size(), key = JSON.stringify([W, H, base]); if (clear && clear.geo === geo && clear.key === key) return clear.sets;
2441
- const boxes = [...stage.querySelectorAll('.ctl, .minimap, .um-story')].map(x => x.getBoundingClientRect()).filter(r => r.width && r.height);
2646
+ const boxes = [...stage.querySelectorAll('.ctl, .minimap, .um-story, .um-lens')].map(x => x.getBoundingClientRect()).filter(r => r.width && r.height);
2442
2647
  let sets = base; // none shown, or no layout (a test DOM): as before
2443
2648
  if (boxes.length) {
2444
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;
@@ -2906,7 +3111,8 @@ export function render(container, model, options = {}) {
2906
3111
  off = document.createElement('div'); off.style.cssText = 'position:absolute;left:-100000px;top:0;width:10px;height:10px;overflow:hidden;pointer-events:none';
2907
3112
  off.innerHTML = c.markup; stage.parentNode.appendChild(off); s = off.querySelector('svg'); s.classList.add('map-svg'); g = c.g;
2908
3113
  }
2909
- if (off) drawMarks(s, g); // the host's marks, on another view too (#63)
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); }
2910
3116
  const s0 = sel, flowing = stage.classList.contains('flowing'), hov = [...svg.querySelectorAll('.hov')];
2911
3117
  sel = null; paint(); stage.classList.remove('flowing'); hov.forEach(x => x.classList.remove('hov')); stage.classList.add('um-still');
2912
3118
  const clone = s.cloneNode(true), b = g.bounds;
@@ -2977,7 +3183,7 @@ export function render(container, model, options = {}) {
2977
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)
2978
3184
  if (!cur) return;
2979
3185
  if (!ctx.V[cur]) { const from = cur; mount(ctx.rootId); cam.set(startView()); cam.touched = false; emit('viewchange', { view: cur, from }); return; }
2980
- 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)
2981
3187
  const keep = sel, keepMore = more, touched = cam.touched, prev = [geo, svg];
2982
3188
  mount(cur);
2983
3189
  if (touched) cam.set({}); else cam.set(startView());
@@ -3072,6 +3278,24 @@ export function render(container, model, options = {}) {
3072
3278
  else if (r.type === 'edge') (r.ids || []).forEach(x => { L.edges[x] = { level: Math.max(lv(r), (L.edges[x] || {}).level || 0) }; }); });
3073
3279
  overlay('highlight', L);
3074
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
+ }
3075
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
3076
3300
  view (setView, an update that redraws it) ends it, as does destroy() */
3077
3301
  let trip = null;
@@ -3118,9 +3342,7 @@ export function render(container, model, options = {}) {
3118
3342
  function replay(spans, o = {}) {
3119
3343
  endTrip(); overlay('replay', null);
3120
3344
  const list = [].concat(spans || []).filter(s => s && Number.isFinite(+s.start) && Number.isFinite(+s.end));
3121
- const byId = new Map(list.map(s => [String(s.id), s])), match = o.match || {}, low = x => String(x ?? '').trim().toLowerCase();
3122
- const cardOf = svc => { if (svc == null || !geo) return null; if (match[svc] != null) return geo.byId[match[svc]] ? String(match[svc]) : null;
3123
- if (geo.byId[svc]) return String(svc); const n = geo.nodes.find(x => low(x.label) === low(svc)); return n ? n.id : null; };
3345
+ const byId = new Map(list.map(s => [String(s.id), s])), cardOf = cardMatcher(geo ? geo.nodes : [], o.match);
3124
3346
  let skipped = 0, t = null, done; const hops = [];
3125
3347
  list.forEach(s => { const to = cardOf(s.service); if (!to) return skipped++;
3126
3348
  const p = s.parent != null ? byId.get(String(s.parent)) : null, from = p && cardOf(p.service);
@@ -3130,7 +3352,7 @@ export function render(container, model, options = {}) {
3130
3352
  const ctl = { hops: hops.length, skipped, done: new Promise(r => { done = r; }),
3131
3353
  pause() { if (t && trip === t) t.paused = true; }, resume() { if (t && trip === t) t.paused = false; },
3132
3354
  stop() { if (t && trip === t) endTrip(); else overlay('replay', null); } };
3133
- const ms = x => x >= 1000 ? `${+(x / 1000).toFixed(x >= 1e4 ? 0 : 1)} s` : `${x >= 10 ? Math.round(x) : +x.toFixed(1)} ms`, marks = {};
3355
+ const ms = msText, marks = {};
3134
3356
  // ponytail: two calls at once on one line share its mark; the one that ends first takes the glow off
3135
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 } }); };
3136
3358
  if (!hops.length || reducedMotion()) { hops.forEach(h => show(h, false)); done(hops.length > 0); return ctl; }
@@ -3242,7 +3464,18 @@ export function render(container, model, options = {}) {
3242
3464
  }
3243
3465
 
3244
3466
  const inner = { ctx: () => ctx, geo: () => geo, cam: () => cam, svg: () => svg, stage, host, go, open, up, flash, setFlow, pause, key, startView, standalone, play,
3245
- select, busy: () => busy, flowing: () => stage.classList.contains('flowing'), view: () => cur, unfold: gesture };
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
+ }
3246
3479
  const first = () => {
3247
3480
  if (dead) return;
3248
3481
  const v0 = ctx.V[options.view] ? options.view : ctx.rootId;
@@ -3273,6 +3506,7 @@ export function render(container, model, options = {}) {
3273
3506
  get selected() { return picked(); },
3274
3507
  overlay: (name, layer) => overlay(name, layer),
3275
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
3276
3510
  tour: (path, o) => tour(path, o),
3277
3511
  replay: (spans, o) => replay(spans, o),
3278
3512
  play: (id, o) => play(id, o),
@@ -3372,34 +3606,102 @@ export function otlpSpans(data) {
3372
3606
  service, name: s.name, start: Number(s.startTimeUnixNano) / 1e6, end: Number(s.endTimeUnixNano) / 1e6 }))); });
3373
3607
  return out;
3374
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
+ }
3375
3695
 
3376
3696
  export function mountPage(container, model, options = {}) {
3377
3697
  const Q = options.params || new URLSearchParams();
3378
3698
  if (options.instant) INSTANT = true;
3379
3699
  document.body.classList.add('map-mode');
3380
- let { D, views, nodes, N, V, rootId, nodeIn: nodeIn0, childOf: childOf0, subjectOf, viewsWith, kidsOf, chain, viewEdges, planCache } = mapContext(model);
3381
- const nodeIn = (nid, vid = cur) => nodeIn0(nid, vid), childOf = (nid, vid = cur) => childOf0(nid, vid);
3382
- const shortT = t => String(t || '').split(' · ')[0];
3383
- const root = D.meta && D.meta.root ? D.meta.root.replace(/\/$/, '') : '';
3384
- const base = D.sourcesBase ? String(D.sourcesBase).replace(/\/$/, '') + '/' : '';
3385
- const srcPath = p => p.replace(/:\d+(-\d+)?$/, '');
3386
- // meta.root is the repo root relative to this page (older pages: absolute). `~/…` sources live
3387
- // outside the repo, so they get no link.
3388
- // meta.sourceBase (render --source-base): in-pack sources were rewritten to URLs under it and
3389
- // show as their pack-relative path; any other relative source gets no link on a published page.
3390
- const sb = D.meta && D.meta.sourceBase ? String(D.meta.sourceBase) : '';
3391
- const srcLabel = p => sb && p.startsWith(sb) ? decodeURI(p.slice(sb.length)).replace(/#L(\d+)(?:-L(\d+))?$/, (_, a, b) => ':' + a + (b ? '-' + b : '')) : p;
3392
- const srcHref = p => /^https?:/.test(p) ? p
3393
- : p.startsWith('~') || sb ? ''
3394
- : p.startsWith('/') ? 'file://' + encodeURI(srcPath(p))
3395
- : root.startsWith('/') ? 'file://' + encodeURI(root + '/' + base + srcPath(p))
3396
- : encodeURI((root ? root + '/' : '') + base + srcPath(p));
3397
- const srcList = n => (Array.isArray(n.source) ? n.source : n.source ? [n.source] : []);
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);
3398
3702
  const mac = /Mac|iPhone|iPad/.test(navigator.platform || navigator.userAgent || '');
3399
3703
  const modK = mac ? '⌘K' : 'Ctrl K';
3400
- const touch = matchMedia('(hover: none)').matches;
3401
- const HINT = touch ? '<span>pinch = zoom</span><span>drag = move</span><span>tap = details</span>'
3402
- : `<span>scroll = zoom</span><span>drag = move</span><span>double-click ⤢ = open</span><span>${modK} = search</span><span>? = keys</span>`;
3704
+ const HINT = hintHTML(modK);
3403
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('');
3404
3706
 
3405
3707
  const app = container; app.className = 'mapapp';
@@ -3411,7 +3713,9 @@ export function mountPage(container, model, options = {}) {
3411
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>
3412
3714
  <button class="ibtn" id="m-theme" title="Light / dark" aria-label="Toggle theme">◐</button>${(D.stories || []).length ? `
3413
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>
3414
- <div class="menu-pop" id="m-stories-pop" hidden>${D.stories.map(s => `<button data-story="${esc(s && s.id)}">${esc((s && (s.title || s.id)) || '')}</button>`).join('')}</div></div>` : ''}
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>` : ''}
3415
3719
  <div class="menu"><button class="pbtn" id="m-export" aria-haspopup="true" aria-expanded="false">Export ▾</button>
3416
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>
3417
3721
  <button class="ibtn" id="m-pause" type="button" title="Pause" aria-label="Pause" aria-pressed="false">❚❚</button>
@@ -3440,8 +3744,9 @@ export function mountPage(container, model, options = {}) {
3440
3744
  const $ = id => document.getElementById(id);
3441
3745
  const stage = $('m-stage'), panel = $('m-panel');
3442
3746
  let X = null, cam = null, geo = null, svg = null, cur = null, sel = null, stack = [], paused = false, replaceNext = false;
3443
- let tab = 'when'; try { tab = localStorage.getItem('visual-tab') || 'when'; } catch (_) {}
3444
- const status = (t, ms = 1800) => { const el = $('m-hint'); el.textContent = t; el.classList.add('msg'); clearTimeout(status.t); status.t = setTimeout(() => { el.innerHTML = HINT; el.classList.remove('msg'); }, ms); };
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 });
3445
3750
 
3446
3751
  function renderChrome() {
3447
3752
  const v = V[cur];
@@ -3449,7 +3754,7 @@ export function mountPage(container, model, options = {}) {
3449
3754
  ? `<span class="crumb cur" aria-current="page">${esc(V[id].title || id)}</span>`
3450
3755
  : `<button class="crumb" data-v="${esc(id)}">${esc(shortT(V[id].title || id))}</button><span class="crumb-sep">›</span>`).join('');
3451
3756
  const parent = stack.length > 1 ? stack[stack.length - 2] : v.parent;
3452
- renderPill(parent); renderLegend(); renderTree();
3757
+ renderPill(parent); pc.chrome();
3453
3758
  document.title = `${v.title || v.id} · ${D.title || 'map'}`;
3454
3759
  }
3455
3760
  function renderPill(parent) {
@@ -3464,150 +3769,17 @@ export function mountPage(container, model, options = {}) {
3464
3769
  <div class="vm">${geo.nodes.length} nodes · ${geo.edges.length} wires${views.length > 1 ? ` · level ${chain(cur).length} of ${depth}` : ''}</div></div>`;
3465
3770
  }
3466
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); };
3467
- let legendFolded = false; try { legendFolded = localStorage.getItem('visual-legend') === '0'; } catch (_) {}
3468
- function renderLegend() {
3469
- const used = [...new Set(geo.nodes.map(n => n.k))];
3470
- const order = ['m-public', 'm-step', 'm-lane', 'm-ext', 'm-verb', 'm-board', 'm-hook', 'm-script', 'frontend', 'backend', 'ai', 'queue', 'database', 'security', 'cloud', 'external', 'file'];
3471
- const shape = k => k === 'm-verb' ? 'p' : (k === 'm-ext' || k === 'external') ? 'd' : k === 'm-hook' ? 'h' : '';
3472
- const items = order.filter(k => used.includes(k)).map(k => `<span class="lgi" title="${esc(KIND[k][1] + ' — ' + KIND[k][2])}"><i class="${shape(k)}" style="border-color:var(--um-${KIND[k][0]});background:var(--um-${KIND[k][0]}-f)${k === 'm-hook' ? `;background:var(--um-${KIND[k][0]})` : ''}"></i><b>${KIND[k][1]}</b><span>${KIND[k][2]}</span></span>`)
3473
- .concat(geo.nodes.some(n => n.kids || n.opens) ? ['<span class="lgi"><i class="s"></i><b>⤢ Stacked</b><span>double-click to open</span></span>'] : [])
3474
- .concat(geo.nodes.some(n => n.step) ? ['<span class="lgi"><i class="num">1</i><b>Step</b><span>run order</span></span>'] : [])
3475
- .concat(geo.nodes.some(n => n.tag) ? ['<span class="lgi"><i class="tag"></i><b>@author</b><span>who wrote it</span></span>'] : [])
3476
- .concat(WIRES.filter(w => geo.edges.some(e => e.wk === w[0])).map(w => `<span class="lgi"><i class="w" style="border-color:var(--um-w-${w[0]})"></i><b>${w[1]}</b><span>${w[2]}</span></span>`))
3477
- .concat(geo.edges.some(e => e.back) ? ['<span class="lgi"><i class="w dash" style="border-color:var(--um-w-calls)"></i><b>loops back</b><span>the one line that runs right to left</span></span>'] : []);
3478
- const el = $('m-legend');
3479
- el.hidden = !items.length;
3480
- el.classList.toggle('folded', legendFolded);
3481
- el.innerHTML = `<div class="t"><span>LEGEND</span><button type="button" id="m-lfold" aria-label="${legendFolded ? 'Show' : 'Hide'} legend" aria-expanded="${!legendFolded}">${legendFolded ? '+' : '–'}</button></div>${items.join('')}`;
3482
- }
3483
- function renderTree() { // Readable columns; a declared order may include shortcuts to descendants.
3484
- const ch = chain(cur);
3485
- const roots = views.filter(x => !x.parent), cols = roots.length > 1 ? [{ head: 'Map', items: roots, on: ch[0] }] : [];
3486
- ch.forEach((id, i) => { const order = V[id].browserOrder || [], k = [...new Set([...order, ...kidsOf(id).map(v => v.id)])].map(id => V[id]).filter(Boolean); if (k.length) cols.push({ head: shortT(V[id].title || id), items: k, on: ch[i + 1] }); });
3487
- document.body.style.setProperty('--browser-width', Math.min(448, Math.max(1, cols.length) * 224) + 'px');
3488
- $('m-tree').innerHTML = `<div class="miller">${cols.map(c => `<div class="mcol"><div class="mch" title="${esc(c.head)}">${esc(c.head)}</div>${c.items.map(x => { const n = kidsOf(x.id).length;
3489
- return `<button class="mit${x.id === cur ? ' on' : x.id === c.on ? ' path' : ''}" data-v="${esc(x.id)}" title="${esc(x.title || x.id)}"${x.id === cur ? ' aria-current="page"' : ''}><span>${esc(shortT(x.title || x.id))}</span><em>${(x.nodeIds || []).length}${n ? ' ›' : ''}</em></button>`; }).join('')}</div>`).join('')}</div>`;
3490
- const m = $('m-tree').firstChild, last = m.lastElementChild; m.style.scrollBehavior = 'auto'; m.scrollLeft = last ? Math.max(0, last.offsetLeft + last.offsetWidth - m.clientWidth) : 0; m.style.scrollBehavior = '';
3491
- }
3492
- const setTree = on => { document.body.classList.toggle('tree-open', on); $('m-outline').setAttribute('aria-expanded', on); };
3493
-
3494
- // Pull concise points from captured prose; retain wording and keep the full point in its tooltip. A point is a sentence
3495
- // or a clause; one longer than a line (160 characters) is cut in two, but a short sentence stays whole (#97).
3496
- function detailBullets(body) {
3497
- const normalized = String(body || '').replace(/^\s*(?:[-*]|\d+[.)])\s+/gm, '').replace(/\n+/g, ' ').trim();
3498
- let parts = normalized.split(/(?<=[.!?])\s+(?=[A-Z`/])|;\s+|\s+[·|]\s+/).filter(Boolean);
3499
- while (parts.some(p => p.length > 160) && parts.length < 4) {
3500
- const index = parts.reduce((best, p, i) => p.length > (parts[best] || '').length ? i : best, 0), value = parts[index];
3501
- if (!value || value.length < 8) break;
3502
- const separators = [...value.matchAll(/:\s+|,\s+|\s+(?=then\b|with\b|before\b|after\b|once\b|to\b|and\b|or\b|during\b|by\b|whose\b|outside\b|on\b|for\b|as\b|whenever\b|into\b|in\b|from\b|when\b|includes\b|typed\b)/g)].map(m => m.index + m[0].length);
3503
- const safe = i => (value.slice(0, i).match(/`/g) || []).length % 2 === 0;
3504
- const cuts = separators.filter(i => i >= 6 && i < value.length - 4 && safe(i));
3505
- if (!cuts.length && parts.length < 2) cuts.push(...[...value.matchAll(/\s+/g)].map(m => m.index).filter(i => i >= 6 && i < value.length - 4 && safe(i)));
3506
- const cut = cuts.sort((a, b) => Math.abs(a - value.length / 2) - Math.abs(b - value.length / 2))[0];
3507
- if (!cut) break;
3508
- parts.splice(index, 1, value.slice(0, cut).replace(/[,;:]\s*$/, '').trim(), value.slice(cut).trim());
3509
- }
3510
- if (parts.length > 4) parts = [...parts.slice(0, 3), parts.slice(3).join(' ')];
3511
- return parts;
3512
- }
3513
- /* selection + details strip */
3514
- /* selection + details strip: the canvas reports, the page shows the panel and records the hash */
3772
+ const setTree = pc.setTree;
3773
+
3774
+ /* selection: the canvas reports, the pieces show the details panel, the page records the hash */
3515
3775
  const select = (nid, opts = {}) => X.select({ type: 'node', id: nid }, opts);
3516
- function hidePanel() { panel.hidden = true; }
3517
3776
  const deselect = () => X.select(null);
3518
- function relRows(list, dir) {
3519
- if (!list.length) return `<div class="rel-none">${dir === 'up' ? 'Nothing calls this here' : 'Calls nothing here'}</div>`;
3520
- return '<ul class="relation-list">' + list.map(({ e, id, inView }) => { const o = N[id] || { label: id, kind: '' }, k = kindOf(o.kind, true);
3521
- return `<li><button class="rel" data-n="${esc(id)}" title="${esc((e.when ? e.when + ' — ' : '') + (inView ? 'Select' : 'Find in another view'))}"><i style="background:var(--um-${KIND[k][0]})"></i><span class="rel-l">${esc(o.label)}</span>
3522
- <span class="rel-e">${e.step != null ? esc(e.step) + ' · ' : ''}${esc(e.label || '')}${inView ? '' : ' ↗'}</span></button></li>`; }).join('') + '</ul>';
3523
- }
3524
- const authorChip = login => { const A = (D.authors || {})[login] || {};
3525
- return `<a class="author" href="${esc(A.url || 'https://github.com/' + login)}" target="_blank" rel="noopener">${A.avatar ? `<img src="${A.avatar}" alt="">` : `<i>${esc(login.slice(0, 2).toUpperCase())}</i>`}<span>${esc(A.name || login)} <b>@${esc(login)}</b></span></a>`; };
3526
- function renderPanel(nid) {
3527
- const n = nodeIn(nid), k = kindOf(n.kind, true), c = `var(--um-${KIND[k][0]})`, pub = k === 'm-public', ext = k === 'm-ext';
3528
- const inView = new Set(geo.nodes.map(x => x.id)), seen = new Set();
3529
- const collect = dir => {
3530
- const out = [];
3531
- const add = (e, id, iv) => { const key = dir + id + (e.label || ''); if (seen.has(key)) return; seen.add(key); out.push({ e, id, inView: iv }); };
3532
- geo.edges.forEach(e => { if (dir === 'up' && e.to === nid) add(e, e.from, true); if (dir === 'down' && e.from === nid) add(e, e.to, true); });
3533
- viewEdges(V[cur]).forEach((e, i) => { const e2 = V[cur].render === 'sequence' ? { ...e, step: i + 1 } : e; if (dir === 'up' && e.to === nid) add(e2, e.from, true); if (dir === 'down' && e.from === nid) add(e2, e.to, true); });
3534
- (D.edges || []).forEach(e => { if (dir === 'up' && e.to === nid) add(e, e.from, inView.has(e.from)); if (dir === 'down' && e.from === nid) add(e, e.to, inView.has(e.to)); });
3535
- return out;
3536
- };
3537
- const ups = collect('up'), downs = collect('down'), srcs = srcList(n);
3538
- const child = childOf(nid), canOpen = child && child !== cur;
3539
- const vIn = viewsWith(nid).filter(v => v.id !== cur);
3540
- const by = n.author ? `<span>·</span>${authorChip(n.author)}` : ext ? '<span class="origin">· author unknown</span>' : '';
3541
- const exs = Array.isArray(n.examples) ? n.examples.filter(x => x && (x.thumb || x.href)) : [];
3542
- const tabs = [['when', 'When to use'], ['what', 'What it is'], ['how', 'How it works'], ...(exs.length ? [['examples', 'Example']] : []), ['links', 'Behind the scenes']];
3543
- TABS = tabs.map(t => t[0]);
3544
- if (!TABS.includes(tab)) tab = 'when';
3545
- const text = (h4, body, key) => body ? `<h4>${h4}</h4><ul class="detail-bullets">${detailBullets(body).map(b => `<li title="${esc(b)}">${inline(b)}</li>`).join('')}</ul>` : `<h4>${h4}</h4><div class="pnone">Not written yet — add <code>${key}</code> to this node in the map model.</div>`;
3546
- const verbs = (n.verbs || []).length ? `<div class="pverbs">${n.verbs.map(v => { const vid = N[nid + ':' + v] ? nid + ':' + v : null; return `<${vid ? `button data-n="${esc(vid)}"` : 'span'} class="pverb" style="--c:${c}">${esc(v)}</${vid ? 'button' : 'span'}>`; }).join('')}</div>` : '';
3547
- const srcHtml = srcs.length ? '<ul class="source-list">' + srcs.map(s => `<li class="src">${srcHref(s) ? `<a href="${esc(srcHref(s))}" title="Open ${esc(sb ? srcLabel(s) : base + s)}"${sb ? ' target="_blank" rel="noopener"' : ''}>${esc(srcLabel(s))}</a>` : `<span>${esc(s)}</span>`}<button class="ibtn sm" data-copy="${esc(sb ? srcLabel(s) : base + s)}" title="Copy path" aria-label="Copy path">⧉</button></li>`).join('') + '</ul>' : '<div class="rel-none">No source listed</div>';
3548
- const bodies = {
3549
- when: text('When to use', n.when, 'when'),
3550
- what: text('What it is', n.what, 'what') + verbs,
3551
- how: text('How it works', n.how, 'how'),
3552
- examples: `<h4>Example</h4><ul class="exs">${exs.map((x, i) => `<li class="ex">${x.thumb ? `<button class="ex-thumb" data-ex="${i}" title="Open full size" aria-label="Open ${esc(x.title || 'example')} full size"><img src="${esc(x.thumb)}" alt="" loading="lazy"></button>` : ''}<b>${inline(x.title || '')}</b>${x.caption ? `<small>${inline(x.caption)}</small>` : ''}${x.href ? `<a href="${esc(x.href)}" target="_blank" rel="noopener">Open live page ↗</a>` : ''}</li>`).join('')}</ul>`,
3553
- links: `<div class="links"><div><h4>← Comes from</h4>${relRows(ups, 'up')}</div><div><h4>→ Leads to</h4>${relRows(downs, 'down')}</div>
3554
- <div><h4>Source files</h4>${srcHtml}${vIn.length ? `<h4 style="margin-top:10px">Also shown in</h4><ul class="pviews">${vIn.map(v => `<li><button class="pview" data-v="${esc(v.id)}" data-sel="${esc(nid)}">${esc(shortT(v.title || v.id))}</button></li>`).join('')}</ul>` : ''}</div></div>`,
3555
- };
3556
- panel.innerHTML = `<div class="ph" style="--c:${c}">
3557
- ${(() => { const I = (D.icons || {})[n.icon];
3558
- return I && I.src ? `<span class="big logo${I.dark ? ' dark' : ''}" aria-hidden="true"><img src="${I.src}" width="22" height="22" alt=""></span>`
3559
- : `<span class="big" style="background:${c}" aria-hidden="true"><svg viewBox="0 0 ${I && I.g ? 24 : 16} ${I && I.g ? 24 : 16}" width="20" height="20" fill="none" stroke="currentColor" stroke-width="${I && I.g ? 2 : 1.5}" stroke-linecap="round" stroke-linejoin="round">${I && I.g ? I.g : SIGIL[k]}</svg></span>`; })()}
3560
- <div class="pt"><h3>${esc(n.label || nid)}</h3><small><span>${esc(KIND[k][1])}</span><span class="vis ${pub ? 'pub' : ext ? 'ext' : ''}">${badgesOf(n).length ? badgesOf(n).map(b => esc(b.text.toUpperCase())).join(' · ') : pub ? 'PUBLIC' : ext ? 'EXTERNAL' : 'INTERNAL'}</span>${by}${n.origin ? `<span class="origin">${esc(n.origin)}</span>` : ''}</small></div>
3561
- <div class="acts">${canOpen ? `<button class="obtn" data-open="${esc(child)}" style="--c:${c}">⤢ Open ${esc(shortT(V[child].title || child))} <span>${(V[child].nodeIds || []).length} nodes · Enter</span></button>` : ''}${X && X.ctx().unfoldable(nid, cur) ? `<button class="obtn" data-unfold="${esc(nid)}" style="--c:${c}">⤡ Unfold here <span>E</span></button>` : ''}${litButtons()}<button class="ibtn x" data-close title="Close (Esc)" aria-label="Close details">×</button></div>
3562
- </div>
3563
- <div class="ptabs" role="tablist" aria-label="Details">${tabs.map(t => `<button class="ptab" role="tab" id="pt-${t[0]}" data-tab="${t[0]}" aria-selected="${t[0] === tab}" aria-controls="m-pbody">${t[1]}${t[2] != null ? `<em>${t[2]}</em>` : ''}</button>`).join('')}
3564
- <span class="pnav"><button class="ibtn sm" data-step="-1" title="Previous ([)" aria-label="Previous tab">‹</button><button class="ibtn sm" data-step="1" title="Next (])" aria-label="Next tab">›</button></span></div>
3565
- <div class="pbody" id="m-pbody" role="tabpanel" aria-labelledby="pt-${tab}">${bodies[tab]}</div>`;
3566
- panel.hidden = false;
3567
- panel.dataset.node = nid;
3568
- panel.querySelectorAll('.clamp').forEach(el => { if (el.scrollHeight > el.clientHeight + 2) el.insertAdjacentHTML('afterend', '<button class="morebtn" data-more>Show all</button>'); });
3569
- }
3570
- let TABS = ['when', 'what', 'how', 'links'];
3571
- function openExample(nid, i) {
3572
- const x = ((nodeIn(nid) || {}).examples || [])[i]; if (!x) return;
3573
- let lb = $('m-lightbox');
3574
- if (!lb) { lb = h(`<div class="lightbox" id="m-lightbox" role="dialog" aria-modal="true" aria-label="Example" hidden></div>`); document.body.appendChild(lb);
3575
- lb.addEventListener('click', e => { if (e.target === lb || e.target.closest('[data-lbclose]')) closeExample(); }); }
3576
- lb.innerHTML = `<figure><img src="${esc(x.full || x.thumb)}" alt="${esc(x.title || '')}"><figcaption><span>${inline(x.title || '')}</span>${x.href ? `<a href="${esc(x.href)}" target="_blank" rel="noopener">Open live page ↗</a>` : ''}<button class="ibtn" data-lbclose title="Close (Esc)" aria-label="Close">×</button></figcaption></figure>`;
3577
- lb.hidden = false; lb.querySelector('[data-lbclose]').focus();
3578
- }
3579
- const closeExample = () => { const lb = $('m-lightbox'); if (lb) lb.hidden = true; };
3580
- const setTab = t => { tab = t; try { localStorage.setItem('visual-tab', t); } catch (_) {} if (sel) renderPanel(sel); };
3581
-
3582
- /* paths and reach (#64): lit on the map from the details panel or by key, and said in the hint line (a live region); a
3583
- new selection or view clears them, and the same key again does */
3584
- let lit = null, picked = [];
3585
- const cardName = id => nodeIn(id).label || id, nCards = n => `${n} card${n === 1 ? '' : 's'}`;
3586
- const litButtons = () => '<button class="ibtn" data-lit="out" title="Light what it leads to (&gt;)" aria-label="Light what it leads to">→</button><button class="ibtn" data-lit="in" title="Light what leads to it (&lt;)" aria-label="Light what leads to it">←</button>'
3587
- + (picked.length > 1 ? '<button class="ibtn" data-lit="paths" title="Light every path between the two cards (P)" aria-label="Light every path between the two selected cards">⇄</button>' : '');
3588
- function unlight() { if (lit) { lit = null; handle.highlight(null); } }
3589
- function light(key, refs, said) {
3590
- if (lit === key) { unlight(); return status('Highlight cleared'); }
3591
- lit = key; handle.highlight(refs.length ? refs : null); status(said, 3600);
3592
- }
3593
- function lightReach(back) {
3594
- if (!sel || !geo.byId[sel]) return;
3595
- const refs = reach(D, cur, sel, { backward: back }), n = refs.filter(r => r.type === 'node').length - 1, s = cardName(sel);
3596
- light((back ? 'in ' : 'out ') + sel, n ? refs : [], !n ? (back ? `Nothing leads to ${s} here` : `${s} leads nowhere here`)
3597
- : back ? `${nCards(n)} ${n === 1 ? 'leads' : 'lead'} to ${s}: lit on the map` : `${s} leads to ${nCards(n)}: lit on the map`);
3598
- }
3599
- function lightPaths() {
3600
- const [a, b] = picked.slice(-2); if (!b) return;
3601
- let refs = paths(D, cur, a, b), [x, y] = [a, b];
3602
- if (!refs.length) { refs = paths(D, cur, b, a); [x, y] = [b, a]; } // the two cards in either order
3603
- const n = refs.filter(r => r.type === 'node').length;
3604
- light(`paths ${a} ${b}`, refs, n ? `Every path from ${cardName(x)} to ${cardName(y)}: ${nCards(n)}, lit on the map` : `No path joins ${cardName(a)} and ${cardName(b)} here`);
3605
- }
3606
3777
 
3607
3778
  /* navigation */
3608
3779
  function setHash(replace) {
3609
3780
  const p = new URLSearchParams(); p.set('view', cur); if (sel) p.set('node', sel);
3610
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)
3611
3783
  ['theme', 'check', 'shot'].forEach(k => Q.get(k) && p.set(k, Q.get(k)));
3612
3784
  const h = '#' + p.toString();
3613
3785
  if (location.hash === h) return;
@@ -3615,35 +3787,20 @@ export function mountPage(container, model, options = {}) {
3615
3787
  }
3616
3788
  const go = (vid, how = {}) => { if (how.replace) replaceNext = true; return X.go(vid, how); };
3617
3789
  const open = nid => X.open(nid), up = () => X.up();
3618
- const findNode = nid => { if (geo.byId[nid]) return select(nid); const vs = viewsWith(nid); const v = vs.find(x => chain(x.id).includes(cur)) || vs[0]; if (v) go(v.id, { select: nid }); };
3790
+ const findNode = pc.find;
3619
3791
 
3620
3792
  /* events */
3621
3793
  stage.addEventListener('click', e => {
3622
3794
  if (e.target.closest('#m-pillbtn')) return togglePill();
3623
- if (e.target.closest('#m-lfold')) { legendFolded = !legendFolded; try { localStorage.setItem('visual-legend', legendFolded ? '0' : '1'); } catch (_) {} renderLegend(); if (!cam.touched) cam.set(X.startView()); return; }
3624
3795
  const bv = e.target.closest('.vpop [data-v]'); if (bv) return go(bv.dataset.v);
3625
3796
  });
3626
3797
  const setFlow = on => X.setFlow(on);
3627
3798
  const closeHelp = () => { $('m-helpbox').hidden = true; $('m-help').setAttribute('aria-expanded', 'false'); };
3628
3799
  const closeExport = () => { $('m-export-pop').hidden = true; $('m-export').setAttribute('aria-expanded', 'false'); };
3629
- panel.addEventListener('click', e => {
3630
- const t = e.target;
3631
- const tb = t.closest('[data-tab]'); if (tb) return setTab(tb.dataset.tab);
3632
- const st = t.closest('[data-step]'); if (st) return setTab(TABS[(TABS.indexOf(tab) + +st.dataset.step + TABS.length) % TABS.length]);
3633
- const mb = t.closest('[data-more]'); if (mb) { mb.previousElementSibling.classList.add('open'); mb.remove(); }
3634
- const ex = t.closest('[data-ex]'); if (ex) return openExample(panel.dataset.node, +ex.dataset.ex);
3635
- });
3636
3800
  document.addEventListener('click', e => {
3637
3801
  const t = e.target;
3638
- if (t.closest('[data-close]')) return deselect();
3639
- const lb = t.closest('#m-panel [data-lit]'); if (lb) return lb.dataset.lit === 'paths' ? lightPaths() : lightReach(lb.dataset.lit === 'in');
3640
- const ob = t.closest('[data-open]'); if (ob) return go(ob.dataset.open, { via: sel });
3641
- const ub = t.closest('#m-panel [data-unfold]'); if (ub) return X.unfold(ub.dataset.unfold); // in place (#71)
3642
- const pv = t.closest('.pview'); if (pv) return go(pv.dataset.v, { select: pv.dataset.sel });
3643
- const rn = t.closest('#m-panel [data-n]'); if (rn) return findNode(rn.dataset.n);
3644
- const cv = t.closest('#m-crumbs [data-v], #m-tree [data-v]'); if (cv) { if (innerWidth <= 820) setTree(false); return go(cv.dataset.v); }
3645
- const cp = t.closest('[data-copy]'); if (cp) { navigator.clipboard && navigator.clipboard.writeText(cp.dataset.copy).then(() => status('Path copied'), () => status(cp.dataset.copy)); return; }
3646
- const mn = t.closest('.menu'); if (!mn || !mn.contains($('m-export'))) closeExport(); if (!mn || !mn.contains($('m-stories'))) closeStories();
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();
3647
3804
  if (!t.closest('#m-helpbox, #m-help')) closeHelp();
3648
3805
  });
3649
3806
  $('m-outline').addEventListener('click', () => setTree(!document.body.classList.contains('tree-open')));
@@ -3656,12 +3813,25 @@ export function mountPage(container, model, options = {}) {
3656
3813
 
3657
3814
  /* stories (#69): the Stories menu plays one; the link names the story and step shown; Export ▸ Record a story… */
3658
3815
  let told = null; // { id, step }: the story on the map and its step
3659
- 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
3660
3816
  const storiesPop = $('m-stories-pop'), closeStories = () => { if (storiesPop) { storiesPop.hidden = true; $('m-stories').setAttribute('aria-expanded', 'false'); } };
3661
3817
  if (storiesPop) {
3662
3818
  $('m-stories').addEventListener('click', () => { const on = storiesPop.hidden; storiesPop.hidden = !on; $('m-stories').setAttribute('aria-expanded', on); });
3663
3819
  storiesPop.addEventListener('click', e => { const b = e.target.closest('[data-story]'); if (!b) return; closeStories(); handle.play(b.dataset.story); });
3664
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
+ }
3665
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);
3666
3836
  // captions are typed in, and Save writes the story's JSON, for the model's `stories`
3667
3837
  let rec = null;
@@ -3754,21 +3924,18 @@ export function mountPage(container, model, options = {}) {
3754
3924
  if ((e.metaKey || e.ctrlKey) && (e.key === 'k' || e.key === 'K')) { e.preventDefault(); return pal.hidden ? openPal() : closePal(); }
3755
3925
  if (!X || (e.target.matches && e.target.matches('input, textarea')) || e.metaKey || e.ctrlKey || e.altKey) return;
3756
3926
  const k = e.key, inCanvas = e.composedPath().includes(X.stage);
3757
- if (k === 'Escape' || k === 'Backspace') {
3758
- if ($('m-lightbox') && !$('m-lightbox').hidden) closeExample(); else if (!$('m-helpbox').hidden) closeHelp(); else if (!$('m-export-pop').hidden) closeExport(); else if (storiesPop && !storiesPop.hidden) closeStories(); else if ($('m-vpop') && !$('m-vpop').hidden) togglePill(false);
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);
3759
3930
  else if (!inCanvas) X.key(e); else return;
3760
3931
  }
3761
3932
  else if (k === '/') openPal();
3762
3933
  else if (k === '?') $('m-help').click();
3763
3934
  else if (k === 'i' || k === 'I') togglePill();
3764
- else if ((k === '[' || k === ']') && sel) setTab(TABS[(TABS.indexOf(tab) + (k === ']' ? 1 : -1) + TABS.length) % TABS.length]);
3765
- else if (k === 'o' || k === 'O') setTree(!document.body.classList.contains('tree-open'));
3766
- else if ((k === '>' || k === '<') && sel) lightReach(k === '<');
3767
- else if ((k === 'p' || k === 'P') && picked.length > 1) lightPaths();
3768
3935
  else if (inCanvas || !X.key(e)) return;
3769
3936
  e.preventDefault(); e.stopPropagation();
3770
3937
  }, true);
3771
- addEventListener('popstate', () => { const p = new URLSearchParams(location.hash.slice(1)); const v = p.get('view') || rootId; if (v !== cur) go(v, { replace: true, select: p.get('node') || undefined }); else if (p.get('node')) select(p.get('node')); else if (sel) deselect(); });
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(); });
3772
3939
 
3773
3940
  /* export: resolve CSS variables into attributes so the file stands alone */
3774
3941
  const standalone = scale => X.standalone(scale);
@@ -3797,17 +3964,17 @@ export function mountPage(container, model, options = {}) {
3797
3964
  view: v0, minimap: true, flow: flowPref ? flowPref === '1' : undefined,
3798
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)
3799
3966
  _page: {
3800
- instant: options.instant, defer: !INSTANT, tour: !Q.get('still'), openHint: !Q.get('reader'), kinds: true, status, tourAt: Q.get('shot') ? Q.get('at') : null,
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,
3801
3968
  wide: () => innerWidth > 820,
3802
3969
  upTarget: () => stack.length > 1 ? stack[stack.length - 2] : (V[cur] || {}).parent,
3803
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
3804
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) }]; },
3805
3972
  onNavigate: (vid, prev, mode) => { togglePill(false);
3806
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); },
3807
- onMount: inner => { X = inner; ({ D, views, nodes, N, V, rootId, subjectOf, viewsWith, kidsOf, chain, viewEdges, planCache } = X.ctx()); nodeIn0 = X.ctx().nodeIn; childOf0 = X.ctx().childOf;
3974
+ onMount: inner => { X = inner; ({ D, views, nodes, N, V, rootId, subjectOf, viewsWith, kidsOf, chain, viewEdges, planCache } = X.ctx()); nodeIn0 = X.ctx().nodeIn;
3808
3975
  cam = X.cam(); geo = X.geo(); svg = X.svg(); cur = X.view(); if (paused) X.pause(true);
3809
3976
  if (!stack.length || !stack.includes(cur)) stack = chain(cur);
3810
- renderChrome(); hidePanel(); },
3977
+ renderChrome(); },
3811
3978
  onBackground: () => togglePill(false),
3812
3979
  onFlow: on => { try { localStorage.setItem('visual-flow', on ? '1' : '0'); } catch (_) {} },
3813
3980
  onReady: () => {
@@ -3815,11 +3982,272 @@ export function mountPage(container, model, options = {}) {
3815
3982
  const n0 = p0.get('node') || (Q.get('shot') && cur !== rootId ? subjectOf(V[cur]) || (V[cur].nodeIds || [])[0] : null);
3816
3983
  if (n0 && geo.byId[n0]) select(n0, { reveal: false });
3817
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)
3818
3986
  setHash(true);
3819
3987
  },
3820
3988
  },
3821
3989
  });
3822
- handle.on('select', ({ target, targets }) => { sel = target && target.type === 'node' ? target.id : null; picked = (targets || []).filter(t => t.type === 'node').map(t => t.id); unlight(); if (sel && !toldSel(sel)) renderPanel(sel); else hidePanel(); setHash(true); recordSoon(); });
3823
- 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
+ });
3824
4017
  return handle;
3825
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 (&gt;)" aria-label="Light what it leads to">→</button><button class="ibtn" data-lit="in" title="Light what leads to it (&lt;)" 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
+ }