umlimator 0.14.0 → 0.16.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
@@ -128,6 +128,17 @@ const STYLES = `
128
128
  .panel { animation: none !important; }
129
129
  }
130
130
  svg .node.flash .n-mask { animation: flash 1.3s ease-out; }
131
+ /* card shapes (0.15.0): their details, and their motion: what arrives lights a shape's accent (a head, a lid, a title bar,
132
+ a fold); a queue's floor moves with the flow. Neither under reduced motion, nor in a picture at rest (an export) */
133
+ svg .n-det { fill: none; stroke-width: 1.2; stroke-linecap: round; stroke-linejoin: round; }
134
+ svg .n-dot { opacity: .75; } svg .n-field, svg .n-screen { fill: var(--um-panel); stroke-opacity: .55; stroke-width: 1; } svg .n-speaker { stroke-width: 1.9; }
135
+ svg .n-prompt { font: 700 9px var(--um-mono); }
136
+ svg .n-floor { fill: none; stroke-width: 1.6; stroke-linecap: round; stroke-dasharray: .1 7; opacity: 0; transition: opacity .2s; }
137
+ .flowing svg .n-floor { opacity: .7; animation: floor 1.07s linear infinite; }
138
+ @keyframes floor { to { stroke-dashoffset: -71; } }
139
+ svg .node.flash .n-accent { animation: accent 1.3s ease-out; }
140
+ @keyframes accent { 0%, 35% { stroke-width: 3.6; } }
141
+ @media (prefers-reduced-motion: reduce) { svg .node.flash .n-accent, .flowing svg .n-floor { animation: none; } .flowing svg .n-floor { opacity: 0; } }
131
142
  @keyframes flash { 0%, 40% { stroke: var(--um-amber); stroke-width: 8; } 100% { stroke-width: 0; } }
132
143
 
133
144
 
@@ -177,6 +188,11 @@ const STYLES = `
177
188
  .empty-view p { margin: 0 0 12px; color: var(--um-muted); font-size: 13px; }
178
189
  svg .family-box { fill: var(--um-panel); fill-opacity: .45; stroke-width: 1.5; }
179
190
  svg .family-title { font: 650 11px var(--um-mono); }
191
+ /* a box with a tone (--um-tone, set on it): a region tinted in the tone, as deep as --um-tint makes the cards, with its
192
+ border dashed and its title in the tone, darkened (lightened in dark) to keep 4.5:1; an unknown tone is slate */
193
+ svg .family.toned .family-box { fill: color-mix(in srgb, var(--um-tone, var(--um-slate)) min(7% * var(--um-tint, 1), 18%), var(--um-panel)); fill-opacity: 1;
194
+ stroke: color-mix(in srgb, var(--um-tone, var(--um-slate)) 60%, transparent); stroke-dasharray: 7 5; }
195
+ svg .family.toned .family-title { fill: color-mix(in srgb, var(--um-tone, var(--um-slate)) 75%, var(--um-text)); }
180
196
  svg .family.sel .family-box { stroke: var(--um-accent); stroke-width: 2; }
181
197
  svg .fold-hint { cursor: pointer; } svg .fold-hint:hover rect { stroke-width: 2; }
182
198
  svg .edited-dot { fill: var(--um-edited); stroke: var(--um-mask); stroke-width: 1.5; }
@@ -235,6 +251,7 @@ const STYLES = `
235
251
  .m-legend.folded .lgi { display: none; }
236
252
  .lgi { display: flex; align-items: center; gap: 7px; white-space: nowrap; }
237
253
  .lgi i { width: 14px; height: 10px; border: 1.5px solid var(--um-muted); border-radius: 3px; flex: none; }
254
+ .lgi svg.lgs { width: 21px; height: 14px; flex: none; overflow: visible; } .lgs * { vector-effect: non-scaling-stroke; stroke-width: 1.3; } .lgs .n-det, .lgs .n-screen { stroke-width: 1; }
238
255
  .lgi i.p { border-radius: 999px; } .lgi i.d { border-style: dashed; } .lgi i.h { border-radius: 0; clip-path: polygon(3px 0, calc(100% - 3px) 0, 100% 50%, calc(100% - 3px) 100%, 3px 100%, 0 50%); border: 0; }
239
256
  .lgi i.s { box-shadow: 2px 2px 0 -.5px var(--um-panel), 2px 2px 0 .5px var(--um-muted); background: var(--um-panel); }
240
257
  .lgi i.num { width: 14px; height: 14px; border: 0; border-radius: 50%; background: var(--um-text); color: var(--um-bg); font: 700 8px/14px var(--um-sans); text-align: center; font-style: normal; }
@@ -255,7 +272,7 @@ const STYLES = `
255
272
  .ph small { display: flex; flex-wrap: wrap; align-items: center; gap: 4px 8px; color: var(--um-muted); font-size: 12.5px; }
256
273
  .ph .acts { display: flex; gap: 8px; align-items: center; flex: none; }
257
274
  .vis { font: 700 9px var(--um-sans); letter-spacing: .1em; padding: 1px 6px; border-radius: 4px; border: 1px solid var(--um-border-2); color: var(--um-muted); }
258
- .vis.pub { background: var(--um-k-public-f); border-color: var(--um-k-public); color: var(--um-k-public); } .vis.ext { border-style: dashed; border-color: var(--um-k-ext); color: var(--um-k-ext); }
275
+ .vis.pub { background: color-mix(in srgb, var(--um-k-public) 8%, var(--um-panel)); border-color: var(--um-k-public); color: var(--um-k-public); } .vis.ext { border-style: dashed; border-color: var(--um-k-ext); color: var(--um-k-ext); }
259
276
  .author { display: inline-flex; align-items: center; gap: 6px; padding: 1px 9px 1px 1px; border-radius: 999px; border: 1px solid var(--um-border-2); color: var(--um-text); text-decoration: none; background: var(--um-panel); font-size: 12px; }
260
277
  .author:hover { border-color: var(--um-accent); }
261
278
  .author img, .author i { width: 22px; height: 22px; border-radius: 50%; display: grid; place-items: center; font: 700 9px var(--um-sans); font-style: normal; background: var(--um-panel-2); }
@@ -323,7 +340,8 @@ const STYLES = `
323
340
  .ptabs, .ptablist { flex-wrap: wrap; } .pnav { display: none; } .ptab { padding: 7px 8px; }
324
341
  .detail-bullets li { white-space: normal; overflow: visible; }
325
342
  .exs { grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 10px; }
326
- .m-legend, .hint { display: none; }
343
+ .m-legend, .hint:not(.msg) { display: none; } /* a message shows, as a toast (issue 134) */
344
+ .hint.msg { left: 12px; right: 12px; bottom: 12px; height: auto; min-height: 28px; padding: 6px 12px; white-space: normal; text-align: center; border: 1px solid var(--um-border); border-radius: 10px; box-shadow: var(--um-shadow); }
327
345
  }
328
346
  /* render()'s pieces (#99), laid out as the page lays them out: the views list left of the map (☰ or O), the details under
329
347
  it, the legend and the hint line over it; while a story is told, the legend gives way to its caption bar */
@@ -339,9 +357,11 @@ const STYLES = `
339
357
  .um-lens b { font: 700 10px var(--um-sans); letter-spacing: .1em; text-transform: uppercase; color: var(--um-muted); } .um-lens span { white-space: nowrap; }
340
358
  .um-lens i { display: inline-block; width: 8px; height: 8px; border: 2.5px solid; border-radius: 4px; margin-right: 6px; vertical-align: -1px; }
341
359
  ` +/* 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
342
- file does, for page chrome) a tint keeps the tone it was mixed from, whatever an ancestor of the map sets later */
360
+ file does, for page chrome) a tint keeps the tone it was mixed from, whatever an ancestor of the map sets later. A kind's
361
+ tint (every tone but the accent) is --um-tint times as deep: 1 by default, 0 for none, never past 18%, where a card's
362
+ sub-line would fall below 4.5:1 */
343
363
  `: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,
344
- 'k-ext': 7, 'k-verb': 7, 'k-script': 5, 'k-hook': 12, 'k-board': 9 }).map(([t, p]) => `--um-${t}-f: color-mix(in srgb, var(--um-${t}) ${p}%, var(--um-panel));`).join(' ')} }`;
364
+ 'k-ext': 7, 'k-verb': 7, 'k-script': 5, 'k-hook': 12, 'k-board': 9 }).map(([t, p]) => `--um-${t}-f: color-mix(in srgb, var(--um-${t}) ${t === 'accent' ? p + '%' : `min(${p}% * var(--um-tint, 1), 18%)`}, var(--um-panel));`).join(' ')} }`;
345
365
 
346
366
  const esc = s => String(s ?? '').replace(/[&<>"']/g, c => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c]));
347
367
  const inline = s => esc(s)
@@ -368,8 +388,8 @@ const KIND = {
368
388
  'm-step': ['k-public', 'Process step', 'open for implementation details'],
369
389
  };
370
390
  const MAPK = { /* umlimator */ public: 'm-public', lane: 'm-lane', script: 'm-script', 'skill-dep': 'm-ext', dep: 'm-ext', verb: 'm-verb', step: 'm-step', hook: 'm-hook', guard: 'm-hook', board: 'm-board', state: 'm-board' };
371
- const ALIAS = { client: 'frontend', ui: 'frontend', web: 'frontend', page: 'frontend', browser: 'frontend', app: 'frontend',
372
- api: 'backend', service: 'backend', server: 'backend', function: 'backend', endpoint: 'backend', route: 'backend', script: 'backend', cli: 'backend', worker: 'backend', module: 'backend',
391
+ const ALIAS = { client: 'frontend', ui: 'frontend', web: 'frontend', page: 'frontend', browser: 'frontend', app: 'frontend', person: 'frontend', mobile: 'frontend',
392
+ api: 'backend', service: 'backend', server: 'backend', function: 'backend', endpoint: 'backend', route: 'backend', script: 'backend', cli: 'backend', worker: 'backend', module: 'backend', package: 'backend',
373
393
  db: 'database', store: 'database', storage: 'database', cache: 'database', table: 'database', data: 'database',
374
394
  infra: 'cloud', cdn: 'cloud', deploy: 'cloud', host: 'cloud', ci: 'cloud',
375
395
  auth: 'security', secret: 'security', policy: 'security', guard: 'security', hook: 'security',
@@ -377,7 +397,8 @@ const ALIAS = { client: 'frontend', ui: 'frontend', web: 'frontend', page: 'fron
377
397
  user: 'external', human: 'external', thirdparty: 'external', vendor: 'external',
378
398
  doc: 'file', config: 'file', skill: 'file', md: 'file', template: 'file', output: 'file',
379
399
  agent: 'ai', llm: 'ai', model: 'ai' };
380
- const kindOf = (k, map) => { k = String(k || '').toLowerCase(); if (map && MAPK[k]) return MAPK[k]; return KIND[k] ? k : (ALIAS[k] || (map ? 'm-script' : 'backend')); };
400
+ const hasKey = (o, k) => Object.hasOwn(o, k); // a table's own key: "constructor" or "__proto__" is no kind, shape or notation
401
+ const kindOf = (k, map) => { k = String(k || '').toLowerCase(); if (map && hasKey(MAPK, k)) return MAPK[k]; return hasKey(KIND, k) ? k : hasKey(ALIAS, k) ? ALIAS[k] : map ? 'm-script' : 'backend'; };
381
402
  const SIGIL = {
382
403
  frontend: '<rect x="2" y="3" width="12" height="8.5" rx="1.2"/><path d="M5.5 14h5M8 11.5V14"/>',
383
404
  backend: '<rect x="2.5" y="2.5" width="11" height="4.5" rx="1"/><rect x="2.5" y="9" width="11" height="4.5" rx="1"/><path d="M5 4.75h.01M5 11.25h.01"/>',
@@ -396,11 +417,206 @@ const SIGIL = {
396
417
  'm-verb': '<path d="M3 13 9.5 6.5M8 3l.7 1.6L10.3 5.3 8.7 6 8 7.6 7.3 6 5.7 5.3 7.3 4.6zM12 8.5l.45 1.05L13.5 10l-1.05.45L12 11.5l-.45-1.05L10.5 10l1.05-.45z"/>',
397
418
  'm-step': '<path d="M2 8h11M9 4l4 4-4 4"/>',
398
419
  'm-board': '<rect x="2" y="2.5" width="12" height="11" rx="1.5"/><path d="M6 2.5v11M10 2.5v11"/><path d="M3.4 5.2h1.2M7.4 5.2h1.2M7.4 7.6h1.2M11.4 5.2h1.2"/>',
420
+ // a shape's own mark (0.15.0), for a card with no icon whose shape says more than its kind does
421
+ person: '<circle cx="8" cy="5.2" r="2.7"/><path d="M3 13.6c.5-2.7 2.5-4.2 5-4.2s4.5 1.5 5 4.2"/>',
422
+ robot: '<rect x="3" y="5.2" width="10" height="8" rx="1.8"/><path d="M8 5.2V3M6.2 9.2h.01M9.8 9.2h.01M1.6 9.2h1.4M13 9.2h1.4"/>',
423
+ mobile: '<rect x="4.4" y="1.8" width="7.2" height="12.4" rx="1.5"/><path d="M7.3 11.9h1.4"/>',
424
+ terminal: '<rect x="1.8" y="2.6" width="12.4" height="10.8" rx="1.6"/><path d="m4.6 6.2 2 1.8-2 1.8M8.4 10.2h3"/>',
425
+ folder: '<path d="M2 4.3c0-.7.5-1.2 1.2-1.2h3l1.4 1.6h5.2c.7 0 1.2.5 1.2 1.2v6.2c0 .7-.5 1.2-1.2 1.2H3.2C2.5 13.3 2 12.8 2 12.1Z"/>',
426
+ node: '<path d="M2.4 5.6 5 3h8.6v7.4L11 13H2.4Z"/><path d="M2.4 5.6H11V13M11 5.6 13.6 3"/>',
427
+ // UML's component icon (§11.6.4): a box's mark in the uml notation
428
+ component: '<path d="M4.5 4.5V2.3a.8.8 0 0 1 .8-.8h7.9a.8.8 0 0 1 .8.8v11.4a.8.8 0 0 1-.8.8H5.3a.8.8 0 0 1-.8-.8v-2.2M4.5 7v2"/><rect x="2" y="4.5" width="5" height="2.5" rx=".6"/><rect x="2" y="9" width="5" height="2.5" rx=".6"/>',
399
429
  };
430
+ /* ================= card shapes (0.15.0) =================
431
+ A notation draws each card as a shape: box (the default: every card a rounded box, as before), c4 (people, browser apps,
432
+ phones, databases, queues and files as the C4 model's tools draw them) or uml (UML 2.5's actor, node, artifact, package
433
+ and data store, and the component icon as a box's mark). A card's own `shape` wins in any notation. Each shape is drawn
434
+ inside its card's own box, so layout, routing, widths and the layout check never see it: an outline (for the mask, the
435
+ body, the stack, rings, the minimap and the glow), a face (the rows its mark and text use), details drawn over the body
436
+ (one of them its accent, which lights as something arrives) and inset(side, t), how far inside the box's side the
437
+ outline runs at t, which slides a line's end from the box onto the outline. */
438
+ const NOTATIONS = ['box', 'c4', 'uml'];
439
+ // what a notation draws: by the kind word the model wrote, then by the kind it means (docs/module-api.md, 0.15.0)
440
+ const NOTED = {
441
+ c4: { person: 'person', user: 'person', human: 'person', mobile: 'mobile', agent: 'robot', cli: 'terminal',
442
+ frontend: 'browser', database: 'cylinder', queue: 'pipe', file: 'document', 'm-hook': 'hexagon' },
443
+ uml: { person: 'person', user: 'person', human: 'person', agent: 'robot', module: 'folder', package: 'folder',
444
+ cloud: 'node', database: 'cylinder', file: 'document', 'm-hook': 'hexagon' },
445
+ };
446
+ const r2 = v => Math.round(v * 100) / 100;
447
+ function rrect(x, y, w, h, r) { // a rounded rect's path; r: one radius, or [top left, top right, bottom right, bottom left]
448
+ const [a, b, c, d] = Array.isArray(r) ? r : [r, r, r, r], A = (q, X, Y) => q ? `A${r2(q)} ${r2(q)} 0 0 1 ${r2(X)} ${r2(Y)}` : '';
449
+ return `M${r2(x + a)} ${r2(y)}H${r2(x + w - b)}${A(b, x + w, y + b)}V${r2(y + h - c)}${A(c, x + w - c, y + h)}H${r2(x + d)}${A(d, x, y + h - d)}V${r2(y + a)}${A(a, x + a, y)}Z`;
450
+ }
451
+ const cornerIn = (r, t, a, b) => t < a + r ? r - Math.sqrt(Math.max(0, r * r - (a + r - t) ** 2)) : t > b - r ? r - Math.sqrt(Math.max(0, r * r - (t - b + r) ** 2)) : 0;
452
+ const rectIn = (n, s, t, r) => s === 'L' || s === 'R' ? cornerIn(r, t, n.y, n.y + n.h) : cornerIn(r, t, n.x, n.x + n.w);
453
+ const ellIn = (e, u, r) => e * (1 - Math.sqrt(Math.max(0, 1 - (u / r) ** 2))); // an ellipse's depth at u from its axis
454
+ const grown = (n, d) => ({ ...n, x: n.x - d, y: n.y - d, w: n.w + 2 * d, h: n.h + 2 * d });
455
+ const deeper = (k, p) => `color-mix(in srgb, var(--um-${KIND[k][0]}) ${p}%, var(--um-${KIND[k][0]}-f))`; // a detail's tint, over the card's own
456
+ const BAR = 14; // a window's title bar: browser, window, terminal
457
+ function chrome(n, c, k, what) { // a tinted title bar, three dots after the card's tabs, then an address field (browser) or a prompt (terminal)
458
+ const { x, y, w } = n, x0 = Math.max(x, n.tabEnd || x), end = n.chromeEnd || x + w - 10;
459
+ const dots = [9, 15.5, 22].map(d => `<circle cx="${r2(x0 + d)}" cy="${y + 7}" r="2.1" class="n-dot depth-read" fill="${c}"/>`).join('');
460
+ const more = what === 'browser' && end - x0 - 30 > 24 ? `<path d="${rrect(x0 + 30, y + 3.5, end - x0 - 30, 7, 3.5)}" class="n-field depth-read" stroke="${c}"/>`
461
+ : what === 'terminal' && end - x0 - 30 > 14 ? `<text x="${r2(x0 + 31)}" y="${y + 10.6}" class="n-prompt" fill="${c}">&gt;_</text>` : '';
462
+ return `<path d="${rrect(x + .75, y + .75, w - 1.5, BAR - .75, [9.25, 9.25, 0, 0])}" fill="${deeper(k, 10)}"/><path d="M${x} ${y + BAR}H${x + w}" class="n-det n-accent" stroke="${c}"/>${dots}${more}`;
463
+ }
464
+ // where a person's or robot's body starts: b px down, or just above the highest line or feed that ends on its left or right
465
+ // side (n.portTop, from the lines as laid out), so each one lands on its outline
466
+ const bodyTop = (n, b) => Math.min(n.y + b, (n.portTop ?? Infinity) - 1);
467
+ // each card's highest line end on its left side and on its right (a view's feeds end on the left), as laid out
468
+ function sidePorts(g) {
469
+ const m = new Map(), at = (n, side, y) => { if (!n || (side !== 'L' && side !== 'R')) return; const o = m.get(n) || { L: Infinity, R: Infinity }; o[side] = Math.min(o[side], y); m.set(n, o); };
470
+ (g.edges || []).forEach(e => { if (e.pts && e.pts.length) { at(e.s, e.fs, e.pts[0][1]); at(e.t, e.ts, e.pts[e.pts.length - 1][1]); } });
471
+ (g.intake || []).forEach(b => at((g.byId || {})[b.to], 'L', b.pts[b.pts.length - 1][1]));
472
+ return m;
473
+ }
474
+ const boxOut = r => (n, d = 0) => { const g = grown(n, d); return rrect(g.x, g.y, g.w, g.h, r + d); };
475
+ const SHAPE = {
476
+ box: { out: boxOut(10), face: n => [n.y, n.y + n.h], inset: (n, s, t) => rectIn(n, s, t, 10) },
477
+ person: { // the C4 model's person: a head over a body with wide, flat shoulders. The body starts 12 px down, or higher where
478
+ // a line ends on its side above that (bodyTop)
479
+ head: 13,
480
+ g(n, d = 0) { const r = 13 + d, cx = n.hx ?? n.x + n.w / 2, hy = n.y + 13, bt = bodyTop(n, 12) - d, sx = 20 + d, sy = 12 + d; return { r, cx, hy, bt, sx, sy, k: Math.sqrt(Math.max(0, r * r - (bt - hy) ** 2)) }; },
481
+ out(n, d = 0) { const { r, cx, hy, bt, sx, sy, k } = this.g(n, d), x = n.x - d, w = n.w + 2 * d, b = n.y + n.h + d, R = 10 + d;
482
+ return `M${r2(x)} ${r2(bt + sy)}A${sx} ${sy} 0 0 1 ${r2(x + sx)} ${r2(bt)}H${r2(cx - k)}A${r} ${r} 0 ${bt > hy ? 1 : 0} 1 ${r2(cx + k)} ${r2(bt)}H${r2(x + w - sx)}A${sx} ${sy} 0 0 1 ${r2(x + w)} ${r2(bt + sy)}V${r2(b - R)}A${R} ${R} 0 0 1 ${r2(x + w - R)} ${r2(b)}H${r2(x + R)}A${R} ${R} 0 0 1 ${r2(x)} ${r2(b - R)}Z`; },
483
+ face: n => [bodyTop(n, 12), n.y + n.h], tabY: n => bodyTop(n, 12),
484
+ det(n, c) { const { r, cx, hy, bt, k } = this.g(n); return `<path d="M${r2(cx - k)} ${r2(bt)}A${r} ${r} 0 ${bt < hy ? 1 : 0} 0 ${r2(cx + k)} ${r2(bt)}" class="n-det n-accent" stroke="${c}"/>`; },
485
+ inset(n, s, t) { const { r, cx, hy, bt, sx, sy } = this.g(n);
486
+ if (s === 'L' || s === 'R') return t < bt ? null : t < bt + sy ? ellIn(sx, bt + sy - t, sy) : rectIn(n, s, t, 10);
487
+ if (s === 'B') return rectIn(n, s, t, 10);
488
+ const u = t - cx; if (Math.abs(u) < r) return Math.min(hy - Math.sqrt(r * r - u * u), bt) - n.y;
489
+ const e = t < n.x + sx ? n.x + sx - t : t > n.x + n.w - sx ? t - n.x - n.w + sx : 0; return bt - n.y + ellIn(sy, e, sx); } },
490
+ robot: { // Structurizr's robot, and UML's non-human actor: the person's body under a square head with ears
491
+ head: 19,
492
+ // a body that starts higher than 14 px down (bodyTop) has a head as much shorter, its ears and corners with it
493
+ g(n, d = 0) { const B = bodyTop(n, 14), f = (B - n.y) / 14; return { cx: n.hx ?? n.x + n.w / 2, hw: 14 + d, ht: n.y - d, bt: B - d, ew: 19 + d, e0: n.y + 4 * f - d, e1: n.y + 10 * f + d, S: 8 + d, hr: 5 * f + d, ey: n.y + 7.5 * f }; },
494
+ out(n, d = 0) { const { cx, hw, ht, bt, ew, e0, e1, S, hr } = this.g(n, d), x = n.x - d, w = n.w + 2 * d, b = n.y + n.h + d, R = 10 + d;
495
+ return `M${r2(x)} ${r2(bt + S)}A${S} ${S} 0 0 1 ${r2(x + S)} ${r2(bt)}H${r2(cx - hw)}V${r2(e1)}H${r2(cx - ew)}V${r2(e0)}H${r2(cx - hw)}V${r2(ht + hr)}A${hr} ${hr} 0 0 1 ${r2(cx - hw + hr)} ${r2(ht)}H${r2(cx + hw - hr)}A${hr} ${hr} 0 0 1 ${r2(cx + hw)} ${r2(ht + hr)}V${r2(e0)}H${r2(cx + ew)}V${r2(e1)}H${r2(cx + hw)}V${r2(bt)}H${r2(x + w - S)}A${S} ${S} 0 0 1 ${r2(x + w)} ${r2(bt + S)}V${r2(b - R)}A${R} ${R} 0 0 1 ${r2(x + w - R)} ${r2(b)}H${r2(x + R)}A${R} ${R} 0 0 1 ${r2(x)} ${r2(b - R)}Z`; },
496
+ face: n => [bodyTop(n, 14), n.y + n.h], tabY: n => bodyTop(n, 14),
497
+ det(n, c) { const { cx, ey } = this.g(n); return [-5.5, 5.5].map(o => `<circle cx="${r2(cx + o)}" cy="${r2(ey)}" r="1.6" class="n-det n-accent" fill="${c}" stroke="${c}"/>`).join(''); },
498
+ inset(n, s, t) { const { cx, hw, ew, e0, bt, S, hr } = this.g(n);
499
+ if (s === 'L' || s === 'R') return t < bt ? null : t < bt + S ? S - Math.sqrt(S * S - (bt + S - t) ** 2) : rectIn(n, s, t, 10);
500
+ if (s === 'B') return rectIn(n, s, t, 10);
501
+ const u = Math.abs(t - cx); return u < hw ? cornerIn(hr, u, -1e6, hw) : u < ew ? e0 - n.y : bt - n.y + cornerIn(S, t, n.x, n.x + n.w); } },
502
+ browser: { out: boxOut(10), face: n => [n.y + BAR, n.y + n.h], inset: (n, s, t) => rectIn(n, s, t, 10), det: (n, c, k) => chrome(n, c, k, 'browser') },
503
+ window: { out: boxOut(10), face: n => [n.y + BAR, n.y + n.h], inset: (n, s, t) => rectIn(n, s, t, 10), det: (n, c, k) => chrome(n, c, k, 'window') },
504
+ terminal: { out: boxOut(10), face: n => [n.y + BAR, n.y + n.h], inset: (n, s, t) => rectIn(n, s, t, 10), det: (n, c, k) => chrome(n, c, k, 'terminal') },
505
+ mobile: { // a phone on its side, as the C4 model's example draws its mobile app (MobileDeviceLandscape): a screen, a home button, a speaker
506
+ out: boxOut(14), face: n => [n.y, n.y + n.h], inset: (n, s, t) => rectIn(n, s, t, 14),
507
+ det(n, c) { const { x, y, w, h } = n, cy = y + h / 2;
508
+ return `<path d="${rrect(x + 11, y + 5, w - 22, h - 10, 5)}" class="n-screen n-accent" stroke="${c}"/><circle cx="${x + 6.6}" cy="${r2(cy)}" r="2.1" class="n-det" stroke="${c}"/><path d="M${r2(x + w - 6.2)} ${r2(cy - 7)}V${r2(cy + 7)}" class="n-det n-speaker" stroke="${c}"/>`; } },
509
+ cylinder: { // a data store: its lid tinted, the lid's front edge its accent
510
+ ry: n => Math.max(5, Math.round(n.h * .11)),
511
+ out(n, d = 0) { const ry = this.ry(n), { x, y, w, h } = grown(n, d); return `M${r2(x)} ${r2(y + ry)}A${r2(w / 2)} ${ry} 0 0 1 ${r2(x + w)} ${r2(y + ry)}V${r2(y + h - ry)}A${r2(w / 2)} ${ry} 0 0 1 ${r2(x)} ${r2(y + h - ry)}Z`; },
512
+ face(n) { return [n.y + this.ry(n), n.y + n.h]; },
513
+ det(n, c, k) { const ry = this.ry(n), { x, y, w } = n;
514
+ return `<ellipse cx="${r2(x + w / 2)}" cy="${y + ry}" rx="${r2(w / 2 - .75)}" ry="${ry - .75}" fill="${deeper(k, 10)}"/><path d="M${x} ${y + ry}A${r2(w / 2)} ${ry} 0 0 0 ${r2(x + w)} ${y + ry}" class="n-det n-accent" stroke="${c}"/>`; },
515
+ inset(n, s, t) { const ry = this.ry(n), rx = n.w / 2; if (s === 'T' || s === 'B') return ellIn(ry, t - n.x - rx, rx);
516
+ const a = n.y + ry, b = n.y + n.h - ry; return t >= a && t <= b ? 0 : ellIn(rx, t < a ? a - t : t - b, ry); } },
517
+ pipe: { // a queue: a cylinder on its side, open at the right where the work leaves; dots on its floor move with the flow
518
+ e: n => Math.max(5, Math.round(n.h * .11)),
519
+ out(n, d = 0) { const e = this.e(n), { x, y, w, h } = grown(n, d); return `M${r2(x + e)} ${r2(y)}H${r2(x + w - e)}A${e} ${r2(h / 2)} 0 0 1 ${r2(x + w - e)} ${r2(y + h)}H${r2(x + e)}A${e} ${r2(h / 2)} 0 0 1 ${r2(x + e)} ${r2(y)}Z`; },
520
+ face: n => [n.y, n.y + n.h],
521
+ det(n, c, k) { const e = this.e(n), { x, y, w, h } = n;
522
+ return `<ellipse cx="${x + w - e}" cy="${r2(y + h / 2)}" rx="${e - .75}" ry="${r2(h / 2 - .75)}" fill="${deeper(k, 10)}"/><path d="M${x + w - e} ${y}A${e} ${r2(h / 2)} 0 0 0 ${x + w - e} ${y + h}" class="n-det n-accent" stroke="${c}"/><path d="M${x + e + 6} ${r2(y + h - 6.5)}H${x + w - 2 * e - 6}" class="n-floor" stroke="${c}"/>`; },
523
+ inset(n, s, t) { const e = this.e(n), ry = n.h / 2; if (s === 'L' || s === 'R') return ellIn(e, t - n.y - ry, ry);
524
+ const a = n.x + e, b = n.x + n.w - e; return t >= a && t <= b ? 0 : ellIn(ry, t < a ? a - t : t - b, e); } },
525
+ document: { // a file: UML's artifact, a page with its corner folded (and umlimator's own file sigil)
526
+ out(n, d = 0) { const { x, y, w, h } = grown(n, d), R = 10 + d, f = 10;
527
+ return `M${r2(x + R)} ${r2(y)}H${r2(x + w - f)}L${r2(x + w)} ${r2(y + f)}V${r2(y + h - R)}A${R} ${R} 0 0 1 ${r2(x + w - R)} ${r2(y + h)}H${r2(x + R)}A${R} ${R} 0 0 1 ${r2(x)} ${r2(y + h - R)}V${r2(y + R)}A${R} ${R} 0 0 1 ${r2(x + R)} ${r2(y)}Z`; },
528
+ face: n => [n.y, n.y + n.h],
529
+ det(n, c, k) { const { x, y, w } = n; return `<path d="M${x + w - 10} ${y}V${y + 7.5}Q${x + w - 10} ${y + 10} ${x + w - 7.5} ${y + 10}H${x + w}Z" fill="${deeper(k, 16)}" class="n-det n-accent" stroke="${c}"/>`; },
530
+ inset(n, s, t) { if (s === 'R' && t < n.y + 10) return 10 - (t - n.y); if (s === 'T' && t > n.x + n.w - 10) return t - (n.x + n.w - 10); return rectIn(n, s, t, 10); } },
531
+ hexagon: { // the hook card's hexagon, for any card
532
+ hx: n => Math.min(16, n.h / 3),
533
+ out(n, d = 0) { const hx = this.hx(n), { x, y, w, h } = grown(n, d); return `M${r2(x + hx)} ${r2(y)}H${r2(x + w - hx)}L${r2(x + w)} ${r2(y + h / 2)}L${r2(x + w - hx)} ${r2(y + h)}H${r2(x + hx)}L${r2(x)} ${r2(y + h / 2)}Z`; },
534
+ face: n => [n.y, n.y + n.h],
535
+ inset(n, s, t) { const hx = this.hx(n); if (s === 'L' || s === 'R') return hx * Math.abs(t - n.y - n.h / 2) / (n.h / 2);
536
+ const a = n.x + hx, b = n.x + n.w - hx; return t < a ? (a - t) * n.h / 2 / hx : t > b ? (t - b) * n.h / 2 / hx : 0; } },
537
+ folder: { // UML's package: a folder with its tab on top, after the card's own tabs
538
+ tw: n => Math.min(64, Math.round(n.w * .34)),
539
+ out(n, d = 0) { const tw = this.tw(n), x0 = n.tabX ?? n.x, { x, y, w, h } = grown(n, d), r = 4 + d, R = 8 + d, th = 9, X0 = x0 - d, T1 = x0 + tw + d;
540
+ const head = X0 <= x + .5 ? `M${r2(x)} ${r2(y + h - R)}V${r2(y + r)}A${r} ${r} 0 0 1 ${r2(x + r)} ${r2(y)}`
541
+ : `M${r2(x)} ${r2(y + h - R)}V${r2(y + th + R)}A${R} ${R} 0 0 1 ${r2(x + R)} ${r2(y + th)}H${r2(X0)}V${r2(y + r)}A${r} ${r} 0 0 1 ${r2(X0 + r)} ${r2(y)}`;
542
+ return `${head}H${r2(T1 - 3)}Q${r2(T1)} ${r2(y)} ${r2(T1 + 1.5)} ${r2(y + 2.5)}L${r2(T1 + 5)} ${r2(y + th)}H${r2(x + w - R)}A${R} ${R} 0 0 1 ${r2(x + w)} ${r2(y + th + R)}V${r2(y + h - R)}A${R} ${R} 0 0 1 ${r2(x + w - R)} ${r2(y + h)}H${r2(x + R)}A${R} ${R} 0 0 1 ${r2(x)} ${r2(y + h - R)}Z`; },
543
+ face: n => [n.y + 9, n.y + n.h], tabY: n => n.y + 9,
544
+ det(n, c, k) { const tw = this.tw(n), x0 = n.tabX ?? n.x;
545
+ return `<path d="M${r2(x0 + .75)} ${n.y + 9}V${n.y + 4}A3.25 3.25 0 0 1 ${r2(x0 + 4)} ${n.y + .75}H${r2(x0 + tw - 3)}Q${r2(x0 + tw)} ${n.y + .75} ${r2(x0 + tw + 1.5)} ${n.y + 3}L${r2(x0 + tw + 4.4)} ${n.y + 9}Z" fill="${deeper(k, 14)}"/><path d="M${r2(x0)} ${n.y + 9}H${r2(x0 + tw + 5)}" class="n-det n-accent" stroke="${c}"/>`; },
546
+ inset(n, s, t) { const tw = this.tw(n), x0 = n.tabX ?? n.x;
547
+ if (s === 'T') return t >= x0 && t <= x0 + tw ? 0 : 9 + cornerIn(8, t, x0 > n.x + .5 ? n.x : -1e6, n.x + n.w);
548
+ if (s === 'B') return rectIn(n, s, t, 8);
549
+ // the side's top: a tab at the card's left corner has a 4 px corner at the top; elsewhere the body starts 9 px down, 8 px round
550
+ const top = s === 'L' && x0 <= n.x + .5 ? cornerIn(4, t, n.y, 1e6) : t < n.y + 9 ? null : cornerIn(8, t, n.y + 9, 1e6);
551
+ return top == null || top > 0 ? top : cornerIn(8, t, -1e6, n.y + n.h); } },
552
+ node: { // UML's node: a box seen in perspective, for hosts and devices
553
+ out(n, d = 0) { const p = 7, { x, y, w, h } = grown(n, d); return `M${r2(x)} ${r2(y + p)}L${r2(x + p)} ${r2(y)}H${r2(x + w)}V${r2(y + h - p)}L${r2(x + w - p)} ${r2(y + h)}H${r2(x)}Z`; },
554
+ face: n => [n.y + 7, n.y + n.h],
555
+ det(n, c, k) { const p = 7, { x, y, w, h } = n;
556
+ return `<path d="M${x + 1} ${y + p}L${x + p} ${y + 1}H${x + w - 1}L${x + w - p} ${y + p}Z" fill="${deeper(k, 12)}"/><path d="M${x + w - p} ${y + p}L${x + w - 1} ${y + 1}V${y + h - p}L${x + w - p} ${y + h - 1}Z" fill="${deeper(k, 22)}"/>`
557
+ + `<path d="M${x} ${y + p}H${x + w - p}V${y + h}" class="n-det n-accent" stroke="${c}"/><path d="M${x + w - p} ${y + p}L${x + w} ${y}" class="n-det" stroke="${c}"/>`; },
558
+ inset(n, s, t) { const p = 7; return s === 'L' ? Math.max(0, n.y + p - t) : s === 'R' ? Math.max(0, t - n.y - n.h + p) : s === 'T' ? Math.max(0, n.x + p - t) : Math.max(0, t - n.x - n.w + p); } },
559
+ };
560
+ const SHAPE_NAMES = ['box', 'person', 'browser', 'mobile', 'cylinder', 'pipe', 'document', 'hexagon', 'robot', 'terminal', 'window', 'folder', 'node']; // all 13
561
+ // a card's shape in a notation: its own `shape` first; box draws every card as before (a hook stays a hexagon there too)
562
+ const shapeOf = (n, notation) => { const sh = String(n.shape ?? '').toLowerCase(); if (SHAPE_NAMES.includes(sh)) return sh;
563
+ const T = hasKey(NOTED, notation) && NOTED[notation], k = String(n.kind ?? '').toLowerCase(); return T ? (hasKey(T, k) && T[k]) || (hasKey(T, n.k) && T[n.k]) || 'box' : 'box'; };
564
+ const noteOf = x => NOTATIONS.includes(x) ? x : null;
565
+ // a card as its notation draws it (null for a box): its shape, and where its head, folder tab and title bar go, clear of its
566
+ // tabs, ⤢ and change badge. Drawing only: layout never sees it. ports: the card's highest line ends on its sides (sidePorts)
567
+ function cardLook(n, notation, hint, ports) {
568
+ const sh = shapeOf(n, notation); if (sh === 'box') return null;
569
+ const S = SHAPE[sh], L = { ...n, sh, S, portTop: ports ? Math.min(ports.L, ports.R) : Infinity, portL: ports ? ports.L : Infinity }, tabs = mapTabs(n), spans = []; let tx = n.x + 12;
570
+ // a step keeps 16 px above the highest line into the left side (svgOf): where that is above a person's or robot's shoulder,
571
+ // the shoulder comes up under it, 5 px below its middle (it floated 4 px above the shoulder)
572
+ if (n.step && S.head && L.portL - 16 < S.tabY(L)) L.portTop = Math.min(L.portTop, Math.max(n.y, L.portL - 16) + 6);
573
+ tabs.forEach(t => { spans.push([tx, tx + t.w]); tx += t.w + 4; });
574
+ L.tabEnd = tabs.length ? tx - 2 : 0;
575
+ const right = []; if (hint) right.push([n.x + n.w - 44, n.x + n.w - 8]);
576
+ const ch = CHG[String(n.change || '').toLowerCase()]; if (ch) { const bw = ch[1].length * 6 + 12, bx = n.x + n.w - bw - 8 - (hint ? 40 : 0); right.push([bx, bx + bw]); }
577
+ L.chromeEnd = Math.min(n.x + n.w - 10, ...right.map(s => s[0] - 6));
578
+ const half = (S.head || 0) + 4;
579
+ if (S.head) { // a head in the middle, unless a tab or a badge is there: then in the widest gap they leave
580
+ const all = [...spans, ...right], lo = n.x + 22, hi = n.x + n.w - 22, mid = n.x + n.w / 2;
581
+ let gaps = [[lo, hi]]; all.forEach(([a, b]) => { gaps = gaps.flatMap(([p, q]) => b <= p || a >= q ? [[p, q]] : [[p, a], [b, q]].filter(([u, v]) => v > u)); });
582
+ const fits = gaps.filter(([p, q]) => q - p >= 2 * half), best = fits.find(([p, q]) => p <= mid - half && q >= mid + half) ? [mid - half, mid + half]
583
+ : fits.sort((u, v) => Math.abs((u[0] + u[1]) / 2 - mid) - Math.abs((v[0] + v[1]) / 2 - mid))[0];
584
+ L.hx = best ? Math.min(Math.max(mid, best[0] + half), best[1] - half) : mid;
585
+ }
586
+ if (sh === 'folder') L.tabX = tabs.length ? Math.min(L.tabEnd + 4, n.x + n.w - SHAPE.folder.tw(n) - 14) : n.x;
587
+ // what else is on the tab line after the card's own tabs: ⤢, the change badge, a head (a robot's with its ears), a folder's tab
588
+ L.busy = [...right, ...(S.head ? [[L.hx - S.head, L.hx + S.head]] : []), ...(sh === 'folder' ? [[L.tabX, L.tabX + SHAPE.folder.tw(n) + 5]] : [])];
589
+ return L;
590
+ }
591
+ // where a tab w wide goes after the card's own tabs (an overlay's or a lens's badge): on a shape, the first place inside the
592
+ // card 2 px clear of what L.busy names, else the place inside it that covers least of them; on a box, or where the card's
593
+ // own tabs fill it, right after them
594
+ function tabAfter(n, L, w) {
595
+ const x0 = mapTabs(n).reduce((a, t) => a + t.w + 4, n.x + 12); if (!L) return x0;
596
+ const max = n.x + n.w - 2 - w, over = x => L.busy.reduce((s, [a, b]) => s + Math.max(0, Math.min(x + w, b + 2) - Math.max(x, a - 2)), 0);
597
+ return [x0, ...L.busy.map(b => b[1] + 4), max].filter(x => x >= x0 && x <= max).reduce((b, x) => over(x) < over(b) ? x : b, x0);
598
+ }
599
+ // the line a card's tabs, ⤢, change badge and step sit on: a shape's top (a person's shoulders), else the box's
600
+ const tabLine = (n, L) => L && L.S.tabY ? L.S.tabY(L) : n.y;
601
+ const SIDE_OUT = { L: [-1, 0], R: [1, 0], T: [0, -1], B: [0, 1] };
602
+ // a line as drawn: its ends slid from the card's box onto the card's outline, along the side's normal (the stub lengthens
603
+ // by as much); layout's points stay as they are. look(n): the card's look, or null for a box
604
+ function drawnPts(e, look) {
605
+ const a = e.s && !e.s.box ? look(e.s) : null, b = e.t && !e.t.box ? look(e.t) : null;
606
+ if (!a && !b) return e.pts;
607
+ const slide = (L, side, p) => { const [ox, oy] = SIDE_OUT[side] || [0, 0], k = L.S.inset(L, side, side === 'L' || side === 'R' ? p[1] : p[0]) || 0; return [r2(p[0] - ox * k), r2(p[1] - oy * k)]; };
608
+ const pts = e.pts.map(p => p.slice());
609
+ if (a) pts[0] = slide(a, e.fs, pts[0]);
610
+ if (b) pts[pts.length - 1] = slide(b, e.ts, pts[pts.length - 1]);
611
+ return pts;
612
+ }
400
613
  const CHG = { added: ['green', 'NEW'], new: ['green', 'NEW'], modified: ['amber', 'CHANGED'], changed: ['amber', 'CHANGED'], removed: ['rose', 'REMOVED'], planned: ['cyan', 'PLANNED'] };
401
614
  const NW = 168, CG0 = 84, RG0 = 44;
402
615
  const pillW = (label, sub, max = 440) => Math.max(sansW(fit(label, 10.5, max), 10.5, .92), sub ? sansW(fit(sub, 9.5, max), 9.5, .92) : 0) + 18; // a line's label, and its sublabel under it (#44)
403
- const stepW = s => String(s).length > 2 ? Math.max(16, sansW(String(s), 9.5, .9) + 8) : 16; // a line's step mark: a 16 px circle, or a pill for a number such as 4.1 (#82)
616
+ const stepW = s => String(s).length > 1 ? Math.max(20, sansW(String(s), 9.5, .9) + 8) : 16; // a line's step mark: a 16 px circle, or a pill for 2a, 12 or 4.1 (#82)
617
+ // steps in order: numbers by value, text by its leading number and then the rest (2 < 2a < 2b < 3); no step last
618
+ const stepKey = s => { const t = String(s ?? ''), m = /^\d+(?:\.\d+)?/.exec(t); return [s == null ? 1 : 0, m ? +m[0] : Infinity, m ? t.slice(m[0].length) : t]; };
619
+ const stepOrder = (a, b) => { const p = stepKey(a), q = stepKey(b); return p[0] - q[0] || p[1] - q[1] || (p[2] < q[2] ? -1 : p[2] > q[2] ? 1 : 0); };
404
620
  // Text width. Without a measurer: a monospace estimate (font-independent, so layouts are the same on every machine;
405
621
  // the bench and layout() in Node use it). render() sets measureText to the host's own font while it lays out a view.
406
622
  let measureText = null;
@@ -645,6 +861,9 @@ const mapNodeW = n => {
645
861
  function layoutGraph(dg, map) {
646
862
  let g = layoutPass(dg, map, null);
647
863
  if (g.room) { const g2 = layoutPass(dg, map, g.room, true); g2.ms.probe = g.ms.probe; g = g2; }
864
+ // a label with no room where it fits: the view laid out again with room for it, kept with fewer issues and labels cut short
865
+ if (g.labelRoom) { const g2 = layoutPass(dg, map, g.labelRoom, true), cut = h => h.edges.filter(e => (e.label || e.step != null) && (!e.lbl || e.lbl.short)).length;
866
+ delete g.labelRoom; if (routeAudit(g2, '').length + cut(g2) < routeAudit(g, '').length + cut(g)) g = g2; }
648
867
  // two lines that still cross: one takes another side, and the view is laid out so once more, kept if it has fewer issues. A
649
868
  // line that closes a loop leaves its source on the far side, out round the map as a loop under the boxes does, and still comes
650
869
  // in on its target's near side (clinic's reminder, from the SMS gateway under Patient records, crossed the line into Patient
@@ -656,11 +875,49 @@ function layoutGraph(dg, map) {
656
875
  pairs.forEach(p => p.forEach(e => { if (e.back && !e.fixed && !e.fromSide) out.set(e.from + '>' + e.to, ['fromSide', far[e.fs]]); }));
657
876
  pairs.forEach(p => { const e = p.filter(e => !e.back && !e.fixed && !e.toSide && /[LR]/.test(e.ts) && dy(e)).sort((a, b) => Math.abs(dy(b)) - Math.abs(dy(a)))[0];
658
877
  if (e && !p.some(o => out.has(o.from + '>' + o.to))) out.set(e.from + '>' + e.to, ['toSide', dy(e) > 0 ? 'bottom' : 'top']); });
659
- if (!out.size) return g;
660
- const g2 = layoutGraph({ ...dg, flipped: true, edges: dg.edges.map(e => out.has(e.from + '>' + e.to) ? { ...e, [out.get(e.from + '>' + e.to)[0]]: out.get(e.from + '>' + e.to)[1] } : e) }, map);
661
- return routeAudit(g2, '').length < routeAudit(g, '').length ? g2 : g;
878
+ let best = g, bestDg = dg, n = pairs.length ? routeAudit(g, '').length : 0;
879
+ const attempt = d => { const h = layoutGraph({ ...d, flipped: true }, map), k = routeAudit(h, '').length; if (k < n) { best = h; bestDg = d; n = k; } };
880
+ if (out.size) attempt({ ...dg, edges: dg.edges.map(e => out.has(e.from + '>' + e.to) ? { ...e, [out.get(e.from + '>' + e.to)[0]]: out.get(e.from + '>' + e.to)[1] } : e) });
881
+ // two lines that still cross where neither can take another side without crossing the other (two cards that each feed the
882
+ // same two cards, as web and admin feed kit and api-client in r15's monorepo, issue 111): one of them goes round the outside,
883
+ // out its source's far side and into its target's top (bottom) from beyond. Of the first two crossing pairs, the lines from
884
+ // two cards that both feed both their targets are tried first, then the pairs' own lines, each on its own (on the layout kept so far,
885
+ // and on the first), and the best layout kept. ponytail: up to four layouts more, on views of up to 30 cards; r19/plain takes
886
+ // about four times as long for them, and gains nothing
887
+ const r2 = [], left = n && best !== g ? routeAudit(best, '', null, r2).flatMap((s, k) => s.endsWith(' cross') ? [r2[k]] : []) : pairs;
888
+ const feeds = (s, t) => best.edges.some(o => o.s === s && o.t === t), both = ([a, b]) => { const k = a.t !== b.t && best.edges.filter(o => [a.t, b.t].includes(o.t) && feeds(o.s, a.t) && feeds(o.s, b.t)); return k && k.length >= 4 ? k : []; };
889
+ const round = [...new Set([...left.slice(0, 2).flatMap(both), ...left.slice(0, 2).flat()])].filter(e => !e.back && !e.fixed && !e.fromSide && !e.toSide && /[LR]/.test(e.fs) && /[LR]/.test(e.ts));
890
+ let tries = 4;
891
+ round.forEach(e => { const side = { fromSide: e.fs === 'R' ? 'left' : 'right', toSide: dy(e) >= 0 ? 'top' : 'bottom' };
892
+ [...new Set([bestDg, dg])].forEach(d => { if (n && tries-- > 0) attempt({ ...d, edges: d.edges.map(x => x.from === e.from && x.to === e.to ? { ...x, ...side } : x) }); }); });
893
+ // fan-in (issue 149): lines into a card that six or more lines come into still cross. One line of such a pair at a time, of
894
+ // the first pair first, goes round the outside (out its source's far side, or into its target's), or its target moves to
895
+ // the foot of its column; a layout with fewer such crossings and no more issues is kept, and the search goes on from it; the
896
+ // view keeps the one found only when it has fewer issues than at the start.
897
+ // r19's logger: boxed 5 → 1, plain 6 → 3. ponytail: 14 layouts more at most, on views of up to 30 cards; a crossing-aware
898
+ // placement would reach plain's last ones (search beside db)
899
+ const fan = {}; best.edges.forEach(e => { if (!e.back) fan[e.to] = (fan[e.to] || 0) + 1; });
900
+ if (map && dg.fan && !dg.quick && !dg.flipped && dg.nodes.length <= 30 && Object.values(fan).some(k => k >= 6)) {
901
+ const audit = h => { const r = [], is = routeAudit(h, '', null, r), hot = r.filter((p, k) => / (cross|share a line)$/.test(is[k]) && p.some(e => fan[e.to] >= 6));
902
+ return { hot, n: is.length }; };
903
+ const far = { L: 'right', R: 'left', T: 'bottom', B: 'top' }, tried = new Set(), start = audit(best), g0 = [best, bestDg], lined = new Set(dg.edges.flatMap(e => [e.from, e.to]));
904
+ let cur = start, left = 14;
905
+ const ways = e => [['fromSide', far[e.fs]], ['toSide', far[e.ts]], ['foot', e.to]].map(([k, s]) => {
906
+ if (k !== 'foot') return !e[k] && s && { key: e.from + '>' + e.to + k, d: { ...bestDg, edges: bestDg.edges.map(x => x.from === e.from && x.to === e.to ? { ...x, [k]: s } : x) } };
907
+ const nodes = bestDg.nodes.map(n => ({ ...n })), me = nodes.find(n => n.id === s), col = nodes.filter(n => n !== me && n.col === me.col), rows = col.filter(n => lined.has(n.id)).map(n => n.row);
908
+ if (!rows.length || me.row > Math.max(...rows)) return null; const r = Math.max(...rows) + 1;
909
+ col.forEach(n => { if (!lined.has(n.id) && n.row > r - 1) n.row++; }); me.row = r; // cards with no line stay under it
910
+ return { key: 'foot ' + s, d: { ...bestDg, nodes } }; });
911
+ for (let more = true; more && cur.hot.length && left > 0;) { more = false;
912
+ for (const e of [...new Set(cur.hot.flat())]) { if (more || e.back || e.fixed) continue;
913
+ for (const w of ways(e)) { if (more || left <= 0 || !w || tried.has(w.key)) continue; tried.add(w.key); left--;
914
+ const h = layoutGraph({ ...w.d, flipped: true }, map), a = audit(h);
915
+ if (a.n <= cur.n && (a.hot.length < cur.hot.length || a.hot.length === cur.hot.length && a.n < cur.n)) { best = h; bestDg = w.d; cur = a; more = true; } } } }
916
+ if (cur.n >= start.n) [best, bestDg] = g0; // kept only with fewer issues
917
+ }
918
+ return best;
662
919
  }
663
- function layoutPass(dg, map, room, last) { // room: { x: { col: px }, y: { row: px } }; last: lay out with it, find no more
920
+ function layoutPass(dg, map, room, last) { // room: { x: { col: px }, y: { row: px } } (lx, ly: a label's, past the boxes'); last: lay out with it, find no more
664
921
  const __ms = { t: performance.now() }, t0 = __ms.t;
665
922
  const CG = dg.gap ? dg.gap : map ? (dg.mainSteps && !dg.containers ? 38 : 66) : CG0, RG = dg.rgap ? dg.rgap : map ? 44 : RG0; // family borders also need clearance between columns
666
923
  const NH = dg.nodeH || 64, pitch = NH + RG;
@@ -678,7 +935,8 @@ function layoutPass(dg, map, room, last) { // room: { x: { col: px }, y: { row:
678
935
  const boxed = new Set((dg.containers || []).flatMap(f => f.nodeIds || [])); // a box's row keeps to the box's columns
679
936
  nodes.forEach(n => { n.aside = map && !dg.abs && !ends.has(n.id) && n.row > low && !boxed.has(n.id); });
680
937
  cols.forEach(c => { const ns = nodes.filter(n => n.col === c && !n.aside); if (ns.length) colW[c] = Math.max(...ns.map(n => n.w)); });
681
- // a labelled line between neighbouring columns gets a gap wide enough for its label and 27 px of line on each side
938
+ // a labelled line between neighbouring columns gets a gap wide enough for its label and 27 px of line on each side (a
939
+ // numbered line with no label: for its step mark)
682
940
  const gapAfter = {}, colOf = {}, boxOf = id => ((dg.containers || []).filter(f => (f.nodeIds || []).includes(id)).pop() || {}).id; if (map && !dg.abs) { nodes.forEach(n => colOf[n.id] = n.col);
683
941
  // labelled lines that leave (or reach) one card side by side run 16 px apart, too close for their pills to stack,
684
942
  // so the pills sit next to each other: the gap fits them all, with 6 px between pills
@@ -691,8 +949,8 @@ function layoutPass(dg, map, room, last) { // room: { x: { col: px }, y: { row:
691
949
  // a box in a box (an unfolded numbered view's lane, #82, or any nested box): a line to a card in one, or to the box that
692
950
  // holds it, crosses two borders
693
951
  const inLane = id => (dg.containers || []).some(f => f.parent && (f.parent === id || (f.nodeIds || []).includes(id)));
694
- (dg.edges || []).forEach(e => { const p = colOf[e.from] ?? near(e.from, colOf[e.to]), q = colOf[e.to] ?? near(e.to, colOf[e.from]); if (p == null || q == null || !e.label || Math.abs(p - q) !== 1) return;
695
- const c = Math.min(p, q), w = pillW(e.label, e.sublabel) + (e.step != null ? stepW(e.step) + 4 : 0), box = boxOf(e.from) !== boxOf(e.to) || !(e.from in colOf) || !(e.to in colOf) ? 48 + ([e.from, e.to].some(inLane) ? 12 : 0) : 0;
952
+ (dg.edges || []).forEach(e => { const p = colOf[e.from] ?? near(e.from, colOf[e.to]), q = colOf[e.to] ?? near(e.to, colOf[e.from]); if (p == null || q == null || (!e.label && e.step == null) || Math.abs(p - q) !== 1) return;
953
+ const c = Math.min(p, q), w = (e.label ? pillW(e.label, e.sublabel) : 0) + (e.step != null ? stepW(e.step) + (e.label ? 4 : 6) : 0), box = boxOf(e.from) !== boxOf(e.to) || !(e.from in colOf) || !(e.to in colOf) ? 48 + ([e.from, e.to].some(inLane) ? 12 : 0) : 0;
696
954
  gapAfter[c] = Math.max(gapAfter[c] || 0, w + 54 + box);
697
955
  [e.from, e.to].forEach(id => { const k = c + '|' + id, t = sum[k] ||= { w: -6, box: 0 }; t.w += w + 6; t.box = Math.max(t.box, box); gapAfter[c] = Math.max(gapAfter[c], t.w + 54 + t.box); }); }); }
698
956
  const extraX = { ...(room || {}).x }, extraY = { ...(room || {}).y }; // more room after a column (row): for lines with no route, and where a box would touch another box (below)
@@ -747,6 +1005,8 @@ function layoutPass(dg, map, room, last) { // room: { x: { col: px }, y: { row:
747
1005
  Object.entries(wantX).forEach(([k, v]) => { extraX[k] = (extraX[k] || 0) + v; }); Object.entries(wantY).forEach(([k, v]) => { extraY[k] = (extraY[k] || 0) + v; });
748
1006
  place();
749
1007
  }
1008
+ // a label's room comes on top of what keeps the boxes apart
1009
+ if (room && (room.lx || room.ly)) { for (const [k, o] of [['lx', extraX], ['ly', extraY]]) Object.entries(room[k] || {}).forEach(([g, v]) => { o[g] = (o[g] || 0) + v; }); place(); }
750
1010
  // decorations hanging on a node's top/bottom border (kind tab, step badge, ⤢ badge, author name tag):
751
1011
  // ports keep off them, routes and labels treat the name tag as an obstacle
752
1012
  if (map) nodes.forEach(n => {
@@ -794,20 +1054,25 @@ function layoutPass(dg, map, room, last) { // room: { x: { col: px }, y: { row:
794
1054
  const segHitsRect = (a, b, r) => { const x1 = Math.min(a[0], b[0]), x2 = Math.max(a[0], b[0]), y1 = Math.min(a[1], b[1]), y2 = Math.max(a[1], b[1]); return x2 > r.x && x1 < r.x + r.w && y2 > r.y && y1 < r.y + r.h; };
795
1055
  const through = (pts, e) => nodes.find(n => n !== e.s && n !== e.t && segsOf(pts).some(([a, b]) => segHitsRect(a, b, { x: n.x + 2, y: n.y + 2, w: n.w - 4, h: n.h - 4 }))); // a card it does not join
796
1056
  // fixed edges arrive with their route already drawn (the hub comb); the router works around them
797
- // a loop back out of a family runs under the frames: out the source's right side, down past every
798
- // frame and card it would cut through, left along the bottom, and up into the target's bottom
1057
+ // a loop back out of a family runs under the frames: out the source's right side, down past every frame and card it
1058
+ // would cut through, left along the bottom, and up the gap right of its target into its near side, the lowest port
1059
+ // there (issue 128); where that would cut through a card, up into the target's bottom, as before. Loops into one target
1060
+ // nest: the outer comes up nearer the card, into the lower port.
799
1061
  const inFam = n => families.filter(f => n.x >= f.x && n.x + n.w <= f.x + f.w && n.y >= f.y && n.y + n.h <= f.y + f.h);
800
- edges.filter(e => map && e.back && !e.fixed && (inFam(e.s).length || inFam(e.t).length)).forEach((e, k) => {
1062
+ const unders = edges.filter(e => map && e.back && !e.fixed && (inFam(e.s).length || inFam(e.t).length));
1063
+ unders.forEach((e, k) => {
801
1064
  // the source's other outgoing wires keep its middle port; the loop leaves lower down
802
1065
  const { s, t } = e, sy = s.y + s.h / 2 + (edges.some(o => o !== e && o.from === s.id && !o.back) ? s.h / 4 : 0);
803
1066
  const xr = Math.max(s.x + s.w + 22, ...inFam(s).map(f => f.x + f.w + 14)) + k * 10;
804
1067
  const tx = t.tag ? Math.max(t.x + 12, (t.x + t.tag.x - 2) / 2) : t.x + t.w / 2, ty = t.y + t.h + (t.kids || t.opens ? 8 : 0);
805
- const span = nodes.filter(n => n.x < xr && n.x + n.w > tx), yb = Math.max(...[...inFam(s), ...inFam(t)].map(f => f.y + f.h), ...span.map(n => n.y + n.h + (n.tag ? 22 : 8))) + 24 + k * 10;
806
- const pts = [[s.x + s.w, sy], [xr, sy], [xr, yb], [tx, yb], [tx, ty]];
807
- if (!through(pts, e)) { e.fixed = pts; e.under = true; } // one that would cut through a card is left to the router
1068
+ const xn = t.x + t.w + 16 + 10 * unders.slice(k + 1).filter(o => o.t === t).length, near = xn < xr;
1069
+ const span = nodes.filter(n => n.x < xr && n.x + n.w > (near ? xn : tx)), yb = Math.max(...[...inFam(s), ...inFam(t)].map(f => f.y + f.h), ...span.map(n => n.y + n.h + (n.tag ? 22 : 8))) + 24 + k * 10;
1070
+ const pts = [[s.x + s.w, sy], [xr, sy], [xr, yb]], up = [[xn, yb], [xn, t.y + t.h / 2], [t.x + t.w, t.y + t.h / 2]], bottom = [[tx, yb], [tx, ty]];
1071
+ if (near && !through([...pts, ...up], e)) { e.fixed = [...pts, ...up]; e.under = k + 1; }
1072
+ else if (!through([...pts, ...bottom], e)) { e.fixed = [...pts, ...bottom]; e.under = true; } // one that would cut through a card is left to the router
808
1073
  });
809
1074
  const free = edges.filter(e => !e.fixed);
810
- edges.filter(e => e.fixed).forEach(e => { e.pts = e.fixed; e.p1 = e.pts[0]; e.p2 = e.pts[e.pts.length - 1]; e.fs = 'R'; e.ts = e.under ? 'B' : 'L'; e.stubs = []; e.how = e.under ? 'under' : 'comb'; });
1075
+ edges.filter(e => e.fixed).forEach(e => { e.pts = e.fixed; e.p1 = e.pts[0]; e.p2 = e.pts[e.pts.length - 1]; e.fs = 'R'; e.ts = e.under > 0 ? 'R' : e.under ? 'B' : 'L'; e.stubs = []; e.how = e.under ? 'under' : 'comb'; });
811
1076
  const sides = {};
812
1077
  const pushSide = (n, side, e, other) => ((sides[n.id + '|' + side] ||= []).push({ e, other, n }));
813
1078
  free.forEach(e => {
@@ -839,6 +1104,7 @@ function layoutPass(dg, map, room, last) { // room: { x: { col: px }, y: { row:
839
1104
  });
840
1105
  });
841
1106
  free.forEach(e => { pushSide(e.s, e.fs, e, e.t); pushSide(e.t, e.ts, e, e.s); });
1107
+ edges.filter(e => e.under > 0).forEach(e => pushSide(e.t, 'R', e, e.s)); // a loop under the boxes takes its port with the side's other lines
842
1108
  const portAt = (it, side, p) => { if (it.n === it.e.s && it.e.fs === side) it.e.p1 = p; else it.e.p2 = p; };
843
1109
  Object.entries(sides).sort(([, a], [, b]) => !!a[0].n.box - !!b[0].n.box).forEach(([key, list]) => { // cards first: a box's port lines up with them
844
1110
  const side = key.split('|').pop(), vertical = side === 'L' || side === 'R';
@@ -864,7 +1130,7 @@ function layoutPass(dg, map, room, last) { // room: { x: { col: px }, y: { row:
864
1130
  // nearer takes the higher port, since the farther one's line passes under it (b10/overview's Reports 7 took the port
865
1131
  // under Billing 1's, whose line ran below Reports, and had no way in but round the map)
866
1132
  const lean = it => Math.atan2(it.other.y + it.other.h / 2 - my, Math.abs(it.other.x + it.other.w / 2 - mx));
867
- list.sort((a, b) => vertical ? (a.other.y - b.other.y) || (lean(a) - lean(b)) || ((side === 'R' ? -1 : 1) * (a.other.x - b.other.x)) : turn(a) - turn(b));
1133
+ list.sort((a, b) => vertical ? ((a.e.under || 0) - (b.e.under || 0)) || (a.other.y - b.other.y) || (lean(a) - lean(b)) || ((side === 'R' ? -1 : 1) * (a.other.x - b.other.x)) : turn(a) - turn(b));
868
1134
  const sp = vertical ? null : sideSpan(n, side);
869
1135
  const len = vertical ? n.h : sp ? sp[1] - sp[0] : n.w, mid = sp ? (sp[0] + sp[1]) / 2 : n.x + n.w / 2;
870
1136
  const gap = Math.min(vertical ? 16 : 26, (len * 0.84) / Math.max(1, list.length)), cy = n.y + n.h / 2;
@@ -875,6 +1141,7 @@ function layoutPass(dg, map, room, last) { // room: { x: { col: px }, y: { row:
875
1141
  portAt(it, side, p);
876
1142
  });
877
1143
  });
1144
+ edges.filter(e => e.under > 0).forEach(e => { const P = e.fixed; e.pts = e.fixed = [...P.slice(0, 4), [P[3][0], e.p2[1]], e.p2]; });
878
1145
  const C = map ? 16 : 12, STUB = dg.mainSteps ? C : C + 6, SEP = 10, BEND = map ? 60 : 26, CROSS = 900; // maps: 16 px clear of other nodes, few bends
879
1146
  const OUT = { R: [1, 0], L: [-1, 0], T: [0, -1], B: [0, 1] };
880
1147
  const nodeRects = nodes.map(n => ({ x: n.x, y: n.y, w: n.w, h: n.h }));
@@ -1042,11 +1309,12 @@ function layoutPass(dg, map, room, last) { // room: { x: { col: px }, y: { row:
1042
1309
  }
1043
1310
  // labels: on the edge's own segment, in free space — never across another route, node, or label
1044
1311
  const placed = [];
1045
- function placeLabel(e) {
1046
- if (!e.label && e.step == null) return;
1047
- const full = map ? fit(e.label || '', 10.5, e.under ? 320 : 440) : fit((e.step != null ? e.step + ' · ' : '') + (e.label || ''), 10, 150);
1048
- const sub = map && e.label && e.sublabel ? fit(e.sublabel, 9.5, e.under ? 320 : 440) : ''; // a second, smaller line under the label (#44)
1049
- const tries = [full]; if (e.step != null && e.label && !dg.abs) tries.push(map ? '' : String(e.step));
1312
+ // every spot on e's line where a label of this text fits, best first, at pass 0 (strict), 1 (strict, but it may touch a box
1313
+ // border) or 2 (relaxed)
1314
+ const subOf = (e, text) => map && text && e.sublabel ? fit(e.sublabel, 9.5, e.under ? 320 : 440) : ''; // a second, smaller line under the label (#44)
1315
+ const sizeOf = (e, text, sub = subOf(e, text)) => { const two = !!(text && sub), pill = map && e.step != null;
1316
+ return { two, w: map ? (text ? pillW(text, two && sub) : 0) + (pill ? stepW(e.step) + (text ? 4 : 6) : 0) : textW(text, 10) + 12, hgt: map ? (two ? 32 : 20) : 18 }; };
1317
+ function* fits(e, text, pass) { const relax = pass === 2, sub = subOf(e, text);
1050
1318
  const segs = segsOf(e.pts).map((s, i) => ({ a: s[0], b: s[1], i, l: Math.hypot(s[1][0] - s[0][0], s[1][1] - s[0][1]), h: s[0][1] === s[1][1] }));
1051
1319
  const order = [...segs].sort((p, q) => (q.h - p.h) * 40 + (q.l - p.l));
1052
1320
  // the strict spots along a segment: the middle, a third and a fifth of the way, then those just clear of each card's stub room,
@@ -1057,25 +1325,43 @@ function layoutPass(dg, map, room, last) { // room: { x: { col: px }, y: { row:
1057
1325
  ...[...routed.filter(r => r !== e).map(r => r.pts), e.pts].flatMap(P => segsOf(P).map(([p, q]) => ({ x: Math.min(p[0], q[0]), y: Math.min(p[1], q[1]), w: Math.abs(q[0] - p[0]), h: Math.abs(q[1] - p[1]) })))];
1058
1326
  return [.5, .32, .68, .2, .8, ...rs.map(span).filter(s => s[1 - v][0] < mid + across && s[1 - v][1] > mid - across).flatMap(s => [s[v][0] - half, s[v][1] + half])
1059
1327
  .map(u => (u - lo) / len).filter(f => f >= 0 && f <= 1).sort((p, q) => Math.abs(p - .5) - Math.abs(q - .5))]; };
1060
- for (const pass of (map ? [0, 1, 2] : [0])) for (const text of tries) { const relax = pass === 2; // 0 strict, 1 strict but may touch a box border, 2 relaxed
1061
- const two = !!(text && sub), pill = map && e.step != null, w = map ? (text ? pillW(text, two && sub) : 0) + (pill ? stepW(e.step) + (text ? 4 : 6) : 0) : textW(text, 10) + 12, hgt = map ? (two ? 32 : 20) : 18;
1062
- for (const sg of order) {
1063
- if (!relax && sg.h && sg.l < w + 10) continue;
1064
- if (!relax && !sg.h && sg.l < hgt + 14) continue;
1065
- for (const f of (relax ? [.5, .32, .68, .2, .8, .1, .9, .42, .58, .26, .74, .05, .95] : spots(sg, w, hgt))) {
1066
- const lx = sg.a[0] + (sg.b[0] - sg.a[0]) * f, ly = sg.a[1] + (sg.b[1] - sg.a[1]) * f;
1067
- const box = { x: lx - w / 2, y: ly - hgt / 2, w, h: hgt };
1068
- const grow = { x: box.x - 3, y: box.y - 3, w: box.w + 6, h: box.h + 6 };
1069
- if (nodeRects.some(r => hit(relax ? { x: r.x, y: r.y - 10, w: r.w, h: r.h + 10 } : { x: r.x - STUB - 4, y: r.y - STUB - 4, w: r.w + 2 * STUB + 8, h: r.h + 2 * STUB + 8 }, box))) continue; // keep port stubs free
1070
- if (tagRects.some(r => hit(r, grow))) continue;
1071
- if (placed.some(r => (pass === 0 || !r.band) && hit(r, grow))) continue;
1072
- if (!relax && routed.some(r => r !== e && segsOf(r.pts).some(([a, b]) => segHitsRect(a, b, grow)))) continue;
1073
- if (!relax && segs.some(s2 => s2.i !== sg.i && segHitsRect(s2.a, s2.b, box))) continue;
1074
- placed.push(box); reserved.push(grow); e.lbl = { ...box, text, ...(two ? { sub } : {}), step: map ? e.step : null, ...(pill ? { sw: stepW(e.step) } : {}), short: text !== full }; return;
1075
- }
1328
+ const { two, w, hgt } = sizeOf(e, text, sub), pill = map && e.step != null;
1329
+ for (const sg of order) {
1330
+ if (!relax && sg.h && sg.l < w + 10) continue;
1331
+ if (!relax && !sg.h && sg.l < hgt + 14) continue;
1332
+ for (const f of (relax ? [.5, .32, .68, .2, .8, .1, .9, .42, .58, .26, .74, .05, .95] : spots(sg, w, hgt))) {
1333
+ const lx = sg.a[0] + (sg.b[0] - sg.a[0]) * f, ly = sg.a[1] + (sg.b[1] - sg.a[1]) * f;
1334
+ const box = { x: lx - w / 2, y: ly - hgt / 2, w, h: hgt };
1335
+ const grow = { x: box.x - 3, y: box.y - 3, w: box.w + 6, h: box.h + 6 };
1336
+ if (nodeRects.some(r => hit(relax ? { x: r.x, y: r.y - 10, w: r.w, h: r.h + 10 } : { x: r.x - STUB - 4, y: r.y - STUB - 4, w: r.w + 2 * STUB + 8, h: r.h + 2 * STUB + 8 }, box))) continue; // keep port stubs free
1337
+ if (tagRects.some(r => hit(r, grow))) continue;
1338
+ if (placed.some(r => (pass === 0 || !r.band) && hit(r, grow))) continue;
1339
+ if (!relax && routed.some(r => r !== e && segsOf(r.pts).some(([a, b]) => segHitsRect(a, b, grow)))) continue;
1340
+ if (!relax && segs.some(s2 => s2.i !== sg.i && segHitsRect(s2.a, s2.b, box))) continue;
1341
+ yield { box, grow, lbl: { ...box, text, ...(two ? { sub } : {}), step: map ? e.step : null, ...(pill ? { sw: stepW(e.step) } : {}) } };
1076
1342
  }
1077
1343
  }
1078
1344
  }
1345
+ const put = (e, s, full) => { placed.push(s.box); reserved.push(s.grow); e.lbl = { ...s.lbl, short: s.lbl.text !== full }; };
1346
+ const unput = e => { const L = e.lbl, same = m => r => r.x === L.x - m && r.y === L.y - m && r.w === L.w + 2 * m && r.h === L.h + 2 * m;
1347
+ const p = placed.findIndex(same(0)), q = reserved.findIndex(same(3)); if (p >= 0) placed.splice(p, 1); if (q >= 0) reserved.splice(q, 1); e.lbl = null; };
1348
+ const fullOf = e => map ? fit(e.label || '', 10.5, e.under ? 320 : 440) : fit((e.step != null ? e.step + ' · ' : '') + (e.label || ''), 10, 150);
1349
+ function placeLabel(e) {
1350
+ if (!e.label && e.step == null) return;
1351
+ const full = fullOf(e);
1352
+ const tries = [full]; if (e.step != null && e.label && !dg.abs) tries.push(map ? '' : String(e.step));
1353
+ const first = (x, text, pass) => fits(x, text, pass).next().value;
1354
+ let s = first(e, full, 0); if (s) return put(e, s, full);
1355
+ // a label whose spots are all taken by a label placed before it: that label moves to each of its other spots in turn, until
1356
+ // this one fits too. Both centred on their lines, the labels of two lines that leave a card side by side (16 px apart, too
1357
+ // close to stack) left the second no room, and it fell back to its step (assistant's "a draft", by a jog of 16 px)
1358
+ if (map) for (const o of routed) { if (o === e || !o.lbl || o.lbl.short || o.fixed) continue;
1359
+ const was = o.lbl, back = () => put(o, { box: { x: was.x, y: was.y, w: was.w, h: was.h }, grow: { x: was.x - 3, y: was.y - 3, w: was.w + 6, h: was.h + 6 }, lbl: was }, was.text);
1360
+ unput(o); if (!first(e, full, 0)) { back(); continue; } // e finds no spot even without it
1361
+ for (const so of fits(o, was.text, 0)) { put(o, so, was.text); if ((s = first(e, full, 0))) return put(e, s, full); unput(o); }
1362
+ back(); }
1363
+ for (const pass of (map ? [0, 1, 2] : [0])) for (const text of tries) if ((pass || text !== full) && (s = first(e, text, pass))) return put(e, s, full);
1364
+ }
1079
1365
  let stats = {};
1080
1366
  free.forEach(e => { // every port's stub is reserved before routing starts
1081
1367
  const st = [e.p1[0] + OUT[e.fs][0] * STUB, e.p1[1] + OUT[e.fs][1] * STUB], en = [e.p2[0] + OUT[e.ts][0] * STUB, e.p2[1] + OUT[e.ts][1] * STUB];
@@ -1105,10 +1391,16 @@ function layoutPass(dg, map, room, last) { // room: { x: { col: px }, y: { row:
1105
1391
  const nearFirst = e => { const d = e.p1[ax] - e.p2[ax]; return d > 0 ? -e.p2[ax] : d < 0 ? e.p2[ax] - 1e6 : 1e7; };
1106
1392
  const slots = list.map(e => order.indexOf(e)).sort((a, b) => a - b), turns = [...list].sort((a, b) => nearFirst(a) - nearFirst(b));
1107
1393
  slots.forEach((at, i) => { order[at] = turns[i]; }); });
1394
+ // fan-in (issue 111): lines into a card that six or more lines come into go first, in that order, so they take the tracks by
1395
+ // the card and the rest go round them (r19's logger has seven; its lines crossed the ones routed before them).
1396
+ // ponytail: six fits the bench (five made d24's hub worse); a fan-in card's own layout row is what is left
1397
+ const fan = {}, hot = e => fan[e.t.id] >= 6; free.forEach(e => { fan[e.t.id] = (fan[e.t.id] || 0) + 1; });
1398
+ order = [...order.filter(hot), ...order.filter(e => !hot(e))];
1108
1399
  let bad = runRouting(order);
1109
1400
  // rip-up and reroute: an edge that had to cross goes first next time; keep the best attempt (not when the view is laid
1110
- // out again with room: that layout's tries seldom gained anything, and they cost a third of its time)
1111
- for (let tries = 0; bad && !last && tries < 4; tries++) {
1401
+ // out again with room for lines: that layout's tries seldom gained anything, and they cost a third of its time; with room for
1402
+ // a label it tries, as the layout it may replace did)
1403
+ for (let tries = 0; bad && (!last || room.lx) && tries < 4; tries++) {
1112
1404
  const stuck = order.filter(e => e.how !== 'grid');
1113
1405
  const next = [...stuck, ...order.filter(e => e.how === 'grid')];
1114
1406
  const snap = order.map(e => [e, e.pts, e.lbl, e.how]), snapStats = stats, snapR = [routed.slice(), reserved.slice(), placed.slice()];
@@ -1206,6 +1498,22 @@ function layoutPass(dg, map, room, last) { // room: { x: { col: px }, y: { row:
1206
1498
  if (!dg.quick) free.filter(e => e.how === 'fallback').forEach(e => { ripUp(e); e.pts = gridRoute(e, 2, 320) || gridRoute(e, 2, 0) || e.pts; e.lbl = null; routed.push(e); placeLabel(e); });
1207
1499
  if (!dg.quick) edges.forEach(e => { const n = through(e.pts, e); if (n) warn(`the line for edge ${edgeRefIds(e).join(' / ') || e.from + '->' + e.to} runs through card "${n.id}", which it does not join: no route found around it`); });
1208
1500
  __ms.repair = performance.now() - __ms.t; delete __ms.t;
1501
+ // a label on a box's border, cut to its step or with no spot asks for room where it is: the column gap it sits in widens by
1502
+ // the label's width, else the row gap by its height and 14 px; a label in neither, or with no spot, the gap its line's longest
1503
+ // run crosses, a column's first. layoutGraph lays the view out again with it, and keeps that layout if it is better
1504
+ let labelRoom = null;
1505
+ const tight = !last && !dg.quick && map && !dg.abs ? free.filter(e => (e.label || e.step != null) && (!e.lbl || e.lbl.short || families.some(f => onBorder(e.lbl, f)))) : [];
1506
+ if (tight.length) {
1507
+ const cs = Object.keys(colX).map(Number), rs = nodes.map(n => n.row), r0 = Math.floor(Math.min(...rs)), r1 = Math.ceil(Math.max(...rs)), need = { x: {}, y: {} };
1508
+ const gaps = [...cs.filter(c => colX[c + 1] != null).map(c => ['x', c, colX[c] + (colW[c] || NW), colX[c + 1]]),
1509
+ ...Array.from({ length: Math.max(0, r1 - r0) }, (_, k) => ['y', r0 + k, rowY(r0 + k) + NH, rowY(r0 + k + 1)])];
1510
+ tight.forEach(e => { const { w, hgt: h } = sizeOf(e, fullOf(e)), L = e.lbl, c = L && { x: L.x + L.w / 2, y: L.y + L.h / 2 };
1511
+ let best = c && ['x', 'y'].map(k => gaps.find(([gk, g, p, q]) => gk === k && c[k] >= p && c[k] <= q)).find(Boolean);
1512
+ if (!best) segsOf(e.pts).forEach(([a, b]) => { const k = a[1] === b[1] ? 'x' : 'y', i = k === 'x' ? 0 : 1, lo = Math.min(a[i], b[i]), hi = Math.max(a[i], b[i]);
1513
+ gaps.forEach(([gk, g, p, q]) => { const run = Math.min(hi, q) - Math.max(lo, p); if (gk === k && run > 0 && (!best || (k === 'x') > (best[0] === 'x') || ((k === 'x') === (best[0] === 'x') && run > best[2]))) best = [k, g, run]; }); });
1514
+ if (best) need[best[0]][best[1]] = (need[best[0]][best[1]] || 0) + (best[0] === 'x' ? w : h + 14); });
1515
+ if (Object.keys(need.x).length + Object.keys(need.y).length) labelRoom = { ...room, lx: need.x, ly: need.y };
1516
+ }
1209
1517
  const groups = (dg.groups || []).map(g => {
1210
1518
  const ms = (g.nodes || []).map(i => byId[i]).filter(Boolean);
1211
1519
  if (!ms.length) return null;
@@ -1217,7 +1525,7 @@ function layoutPass(dg, map, room, last) { // room: { x: { col: px }, y: { row:
1217
1525
  const ys = [...families.flatMap(f => [f.y,f.y+f.h]), ...(dg.lanes || []).flatMap(l => [l.y, l.y + l.h]), ...nodes.flatMap(n => [n.y - 14, n.y + n.h + (n.tag ? 18 : 8)]), ...groups.flatMap(g => [g.y, g.y2]), ...edges.flatMap(e => e.pts.map(p => p[1])), ...placed.flatMap(p => [p.y, p.y + p.h])];
1218
1526
  intake.forEach(b => { xs.push(b.cx - b.r - 8 - textW(b.label || '', 10.5), b.cx + b.r); ys.push(b.cy - b.r, b.cy + b.r); });
1219
1527
  const M = 36, minX = Math.min(...xs) - M, minY = Math.min(...ys) - M;
1220
- return { ms: __ms, stats, nodes, edges, subAndChips: !!dg.subAndChips, groups, families, intake, tour: dg.tour || null, byId, map, hub: dg.hub || null, lanes: dg.lanes || [], blocks: dg.blocks || [], bounds: { x: minX, y: minY, w: Math.max(...xs) - minX + M, h: Math.max(...ys) - minY + M } };
1528
+ return { ms: __ms, ...(labelRoom ? { labelRoom } : {}), stats, nodes, edges, subAndChips: !!dg.subAndChips, groups, families, intake, tour: dg.tour || null, byId, map, hub: dg.hub || null, lanes: dg.lanes || [], blocks: dg.blocks || [], bounds: { x: minX, y: minY, w: Math.max(...xs) - minX + M, h: Math.max(...ys) - minY + M } };
1221
1529
  }
1222
1530
 
1223
1531
  let svgSeq = 0;
@@ -1243,11 +1551,15 @@ function svgOf(geo, opts = {}) {
1243
1551
  const marker = (n, c, k = 1) => `<marker id="${id}-${n}" markerWidth="${10 * k}" markerHeight="${8 * k}" refX="${9 * k}" refY="${4 * k}" orient="auto-start-reverse" markerUnits="userSpaceOnUse"><path d="M0 0 L${10 * k} ${4 * k} L0 ${8 * k} Z" fill="${c}"/></marker>`;
1244
1552
  const MC = { d: 'var(--um-arrow)', g: 'var(--um-green)', r: 'var(--um-rose)', v: 'var(--um-violet)', h: 'var(--um-cyan)', ...Object.fromEntries(WIRES.map(w => [w[0], `var(--um-w-${w[0]})`])) };
1245
1553
  const heavy = [...new Set(edges.filter(e => e.weight > 1).map(e => lstyle(e).m + '-w' + e.weight))]; // a heavier line's arrowhead grows with it
1554
+ // the notation (0.15.0): each card's look, and each line as drawn, its ends on the cards' outlines (a map's cards only)
1555
+ const notation = map ? noteOf(opts.shapes) || 'box' : 'box', kidsOf = n => n.kids != null ? n.kids : opts.childCount ? opts.childCount(n.id) : 0;
1556
+ const ports = map ? sidePorts(geo) : null, looks = new Map(map ? nodes.map(n => [n, cardLook(n, notation, !!(kidsOf(n) || n.opens) && opts.openHint !== false, ports.get(n))]) : []);
1557
+ const drawn = edges.map(e => looks.size ? drawnPts(e, n => looks.get(n) || null) : e.pts);
1246
1558
  let s = `<svg viewBox="${B.x} ${B.y} ${B.w} ${B.h}" data-w="${B.w}" data-h="${B.h}" role="${map ? 'group' : 'img'}" aria-label="${esc(opts.title || 'Diagram')}" xmlns="http://www.w3.org/2000/svg" preserveAspectRatio="xMidYMid meet">
1247
1559
  <defs>${marker('d', 'var(--um-arrow)')}${marker('g', 'var(--um-green)')}${marker('r', 'var(--um-rose)')}${marker('v', 'var(--um-violet)')}${marker('h', 'var(--um-cyan)')}${map ? WIRES.map(w => marker(w[0], `var(--um-w-${w[0]})`)).join('') : ''}${heavy.map(m => { const [c, k] = m.split('-w'); return marker(m, MC[c], 1 + (k - 1) * .35); }).join('')}</defs>`;
1248
1560
  const families = geo.families || [], familyDepth = f => f.parent ? 1 + familyDepth(families.find(p => p.id === f.parent) || {}) : 0;
1249
- families.slice().sort((a,b) => familyDepth(a)-familyDepth(b)).forEach(f => {
1250
- s += `<g class="family" data-family="${esc(f.id)}"${f.parent ? ` data-parent="${esc(f.parent)}"` : ''}><rect class="family-box" x="${f.x}" y="${f.y}" width="${f.w}" height="${f.h}" rx="14" stroke="var(--um-${familyDepth(f) ? 'cyan' : 'border-2'})"/>
1561
+ families.slice().sort((a,b) => familyDepth(a)-familyDepth(b)).forEach(f => { // in c4 every box is a boundary, dashed as Structurizr draws one (a toned box is dashed in any notation)
1562
+ s += `<g class="family${f.tone ? ' toned' : ''}" data-family="${esc(f.id)}"${f.parent ? ` data-parent="${esc(f.parent)}"` : ''}${f.tone ? ` style="--um-tone:${toneOf(f)}"` : ''}><rect class="family-box" x="${f.x}" y="${f.y}" width="${f.w}" height="${f.h}" rx="14" stroke="var(--um-${familyDepth(f) ? 'cyan' : 'border-2'})"${notation === 'c4' ? ' stroke-dasharray="7 5"' : ''}/>
1251
1563
  <text class="family-title" x="${f.x+12}" y="${f.y+18}" fill="var(--um-muted)" data-t="ft" data-o="${esc(f.id)}">${esc(f.label || f.id)}</text>${(f.tabs || []).map(t => t.fold ? `
1252
1564
  <g class="fold-hint"><title>Fold back into one card (E, or Alt + click a card in it)</title><rect x="${t.x}" y="${f.y - 8}" width="${t.w}" height="16" rx="8" class="n-mask" stroke="var(--um-accent)"/><text x="${t.x + t.w / 2}" y="${f.y + 4}" text-anchor="middle" class="badge-tx" fill="var(--um-accent)">⤡</text></g>` : `
1253
1565
  <g class="pub-tab" data-badge="${t.badge}"><rect x="${t.x}" y="${f.y - 8}" width="${t.w}" height="15" rx="4" fill="${t.fill}"/><text x="${t.x + t.w / 2}" y="${f.y + 3}" text-anchor="middle" class="tab-tx">${esc(t.text)}</text></g>`).join('')}</g>`;
@@ -1269,7 +1581,7 @@ function svgOf(geo, opts = {}) {
1269
1581
  for (let i = 0; i + 1 < geo.tour.length; i++) {
1270
1582
  const a = geo.tour[i], b = geo.tour[i + 1], k = edges.findIndex(e => (e.from === a && e.to === b) || (e.both && e.from === b && e.to === a));
1271
1583
  if (k < 0) return null;
1272
- const p = edges[k].from === a ? edges[k].pts : [...edges[k].pts].reverse();
1584
+ const p = edges[k].from === a ? drawn[k] : [...drawn[k]].reverse();
1273
1585
  if (pts.length) pts.push([pts[pts.length - 1][0], p[0][1]]); // through the card to the next port
1274
1586
  const from = pts.length; pts.push(...p); ends.push([k, from, pts.length - 1, b]);
1275
1587
  }
@@ -1282,14 +1594,15 @@ function svgOf(geo, opts = {}) {
1282
1594
  const tourAnim = (vals, times) => `<animate attributeName="opacity" values="${vals}" keyTimes="${times.map(f2).join(';')}" dur="${TOUR}s" repeatCount="indefinite"/>`;
1283
1595
  s += '<g class="edges">';
1284
1596
  (geo.intake || []).forEach(b => {
1285
- s += `<g class="intake"><title>${esc(b.label)} feeds ${esc((geo.byId[b.to] || {}).label || b.to)}</title><path d="${roundPath(b.pts)}" class="edge" stroke="var(--um-w-feeds)" marker-end="url(#${id}-feeds)"/>
1597
+ const bp = roundPath(looks.size ? drawnPts({ pts: b.pts, t: geo.byId[b.to], ts: 'L' }, n => looks.get(n) || null) : b.pts); // its end on the card's outline, as a line's
1598
+ s += `<g class="intake"><title>${esc(b.label)} feeds ${esc((geo.byId[b.to] || {}).label || b.to)}</title><path d="${bp}" class="edge" stroke="var(--um-w-feeds)" marker-end="url(#${id}-feeds)"/>
1286
1599
  <circle cx="${b.cx}" cy="${b.cy}" r="${b.r}" class="intake-chip${b.dashed ? ' dashed' : ''}"/>${b.logo ? `<image href="${esc(b.logo)}" x="${b.cx - 9}" y="${b.cy - 9}" width="18" height="18"/>` : b.mark ? `<text x="${b.cx}" y="${b.cy + 4.5}" text-anchor="middle" class="intake-mark">${esc(b.mark)}</text>` : ''}
1287
1600
  <text x="${b.cx - b.r - 7}" y="${b.cy + 3.5}" text-anchor="end" class="intake-tx">${esc(b.label)}</text>
1288
- ${tour ? `<circle r="3.2" class="tour tour-packet" opacity="0"><animateMotion path="${roundPath(b.pts)}" keyPoints="0;0;1" keyTimes="0;.86;1" calcMode="linear" dur="${TOUR}s" repeatCount="indefinite"/>${tourAnim('0;0;.8;.8;0', [0, .86, .88, .99, 1])}</circle>` : ''}</g>`;
1601
+ ${tour ? `<circle r="3.2" class="tour tour-packet" opacity="0"><animateMotion path="${bp}" keyPoints="0;0;1" keyTimes="0;.86;1" calcMode="linear" dur="${TOUR}s" repeatCount="indefinite"/>${tourAnim('0;0;.8;.8;0', [0, .86, .88, .99, 1])}</circle>` : ''}</g>`;
1289
1602
  });
1290
1603
  edges.forEach((e, i) => {
1291
- const st = lstyle(e), why = broke(e), d = roundPath(e.pts), mk = st.m + (e.weight > 1 ? '-w' + e.weight : '');
1292
- const tip = 9 * (e.weight > 1 ? 1 + (e.weight - 1) * .35 : 1), fd = roundPath(trimLine(e.pts, e.both ? tip : 0, tip)); // an arrowhead's length (marker refX)
1604
+ const st = lstyle(e), why = broke(e), d = roundPath(drawn[i]), mk = st.m + (e.weight > 1 ? '-w' + e.weight : '');
1605
+ const tip = 9 * (e.weight > 1 ? 1 + (e.weight - 1) * .35 : 1), fd = roundPath(trimLine(drawn[i], e.both ? tip : 0, tip)); // an arrowhead's length (marker refX)
1293
1606
  if (tour && tour.legs[i]) { const [a, b] = tour.legs[i];
1294
1607
  s += `<path d="${d}" class="tour-glow" stroke="${st.c}" opacity="0">${tourAnim('0;0;.55;.55;0;0', [0, a, Math.min(a + .01, b), b, b + .03, 1])}</path>`; }
1295
1608
  s += `<g class="edge-g${st.cls || ''}${e.weight > 1 ? ' wt-' + e.weight : ''}" data-from="${esc(e.from)}" data-to="${esc(e.to)}" data-i="${i}"><path d="${d}" class="halo"/><path d="${d}" class="hitline"/>
@@ -1297,28 +1610,34 @@ function svgOf(geo, opts = {}) {
1297
1610
  <path d="${fd}" class="flowline" stroke="${st.c}"/>${(e.all || [e]).some(x => x.edited) && !e.strong ? (() => { let m = [e.pts[0], e.pts[1]];
1298
1611
  e.pts.slice(1).forEach((p, k) => { const a = e.pts[k]; if (Math.abs(p[0] - a[0]) + Math.abs(p[1] - a[1]) > Math.abs(m[1][0] - m[0][0]) + Math.abs(m[1][1] - m[0][1])) m = [a, p]; });
1299
1612
  return `<circle cx="${(m[0][0] + m[1][0]) / 2}" cy="${(m[0][1] + m[1][1]) / 2}" r="4" class="edited-dot"><title>Edited</title></circle>`; })() : ''}${why.length ? ruleMark(e, why) : ''}
1300
- <title>${e.step != null ? 'Step ' + e.step + ': ' : ''}${esc(e.s.label)} ${e.both ? '⇄' : '→'} ${esc(e.t.label)}${e.label ? ': ' + esc(e.label) : ''}${e.label && e.sublabel ? ' (' + esc(e.sublabel) + ')' : ''}${e.when ? '\n' + esc(e.when) : ''}${why.length ? '\nBreaks rule: ' + esc(why.join('; ')) : ''}</title></g>`;
1613
+ <title>${e.step != null ? 'Step ' + esc(e.step) + ': ' : ''}${esc(e.s.label)} ${e.both ? '⇄' : '→'} ${esc(e.t.label)}${e.label ? ': ' + esc(e.label) : ''}${e.label && e.sublabel ? ' (' + esc(e.sublabel) + ')' : ''}${e.when ? '\n' + esc(e.when) : ''}${why.length ? '\nBreaks rule: ' + esc(why.join('; ')) : ''}</title></g>`;
1301
1614
  });
1302
1615
  // the tour's dot rides over the lines and boxes but under the cards: it goes into a card and comes out at its next
1303
1616
  // line, rather than across the card's name
1304
1617
  s += '</g>' + (tour ? `<g class="tour" aria-hidden="true"><circle r="5.5" class="tour-dot"><animateMotion path="${tour.d}" dur="${TOUR}s" repeatCount="indefinite"/></circle></g>` : '') + '<g class="nodes">';
1305
1618
  nodes.forEach(n => {
1306
1619
  const c = col(n.k), f = fill(n.k), removed = n.change === 'removed', planned = n.change === 'planned';
1307
- const pub = map && n.k === 'm-public', priv = map && n.k === 'm-script', ext = map && n.k === 'm-ext', verb = map && n.k === 'm-verb', hook = map && n.k === 'm-hook';
1620
+ const L = looks.get(n) || null, S = L && L.S; // a shaped card (0.15.0); null: a box, drawn as before
1621
+ const pub = map && n.k === 'm-public', priv = map && n.k === 'm-script', ext = map && n.k === 'm-ext', verb = map && n.k === 'm-verb', hook = map && n.k === 'm-hook' && String(n.shape ?? '').toLowerCase() !== 'box';
1622
+ const dash = removed || ext || planned || (map && notation !== 'box' && n.k === 'external'); // an external card is dashed where a notation draws types (the legend says so in every notation)
1308
1623
  const rx = verb ? n.h / 2 : map ? 10 : 9;
1309
- const kids = n.kids != null ? n.kids : opts.childCount ? opts.childCount(n.id) : 0, stacked = !!(kids || n.opens); // opens a view, loaded or not (#47)
1624
+ const kids = kidsOf(n), stacked = !!(kids || n.opens); // opens a view, loaded or not (#47)
1310
1625
  const subject = opts.subject === n.id;
1311
1626
  const verbs = map && (n.verbs || []).length ? n.verbs : null;
1312
1627
  const subTxt = n.noSub ? '' : n.sub || (map && n.origin ? n.origin : '') || (map ? (n.what ? n.what.replace(/[`*]/g, '').split(/(?<=\.)\s/)[0] : KIND[n.k][1]) : '');
1313
1628
  const third = !!(verbs && subTxt && !priv && geo.subAndChips); // the view's subAndChips: the sub-line, then the chips on a third line (a script card shows no sub-line)
1314
1629
  const sw = map ? n.w - 70 : n.w - 24; // map cards: text runs from 54 px in (after the mark) to 16 px from the right
1315
- const sub2 = map && subTxt && !verbs && textW(subTxt, 10) > sw && textW(n.label, n.emphasis ? 15 : 13) <= sw ? wrap2(subTxt, 10, sw) : null;
1316
- const sub = subTxt && (!verbs || third) ? (sub2 ? sub2.join(' ') : clauseFit(subTxt, 10, sw)) : '';
1630
+ // a shape's face: the rows its mark and text use (a box's: the card). A short face takes a two-line sub-line to one, and
1631
+ // gives up the sub-line under a two-line name (the tooltip and the details panel keep it)
1632
+ const [ft, fb] = L ? S.face(L) : [n.y, n.y + n.h], fh = fb - ft, fy = (ft + fb) / 2;
1317
1633
  const labelPx = n.emphasis ? 15 : 13;
1318
1634
  const lw = map ? n.w - 70 : n.w - 72; // the mark (sigil, icon or logo tile) reaches 27 px in; the name keeps 36 px each side
1319
- const two = map && textW(n.label, labelPx) > lw ? wrap2(n.label, labelPx, lw) : null;
1635
+ const wraps = map && textW(n.label, labelPx) > lw && (!L || fh >= 30);
1636
+ const sub2 = map && subTxt && !verbs && textW(subTxt, 10) > sw && textW(n.label, n.emphasis ? 15 : 13) <= sw && (!L || fh >= 56) ? wrap2(subTxt, 10, sw) : null;
1637
+ const sub = subTxt && (!verbs || third) && !(L && wraps && fh < 44) ? (sub2 ? sub2.join(' ') : clauseFit(subTxt, 10, sw)) : '';
1638
+ const two = wraps ? wrap2(n.label, labelPx, lw) : null;
1320
1639
  const lpx = two ? labelPx : textW(n.label, labelPx) > lw ? (measureText ? Math.max(11, labelPx * lw / textW(n.label, labelPx)) : Math.max(11, lw / (String(n.label).length * 0.6))) : labelPx;
1321
- const ly = n.y + n.h / 2 + (third ? -14 : sub || verbs ? -3 : 4) - (two ? 8 : 0) - (sub2 ? 6 : 0);
1640
+ const ly = fy + (third ? -14 : sub || verbs ? -3 : 4) - (two ? 8 : 0) - (sub2 ? 6 : 0);
1322
1641
  // A card is a button named by what it shows, in the order it shows it, so its visible text is a run of whole words of its
1323
1642
  // name (WCAG 2.5.3, as axe checks it) at every zoom: what always shows comes first, then the sub-line and chips (faded out
1324
1643
  // when zoomed far out). What is not shown, or not in full, goes in parentheses, which that check leaves out. The card's
@@ -1329,23 +1648,34 @@ function svgOf(geo, opts = {}) {
1329
1648
  const label = two ? two.join(' ') : fit(n.label, lpx, lw, true), subShown = sub && !priv; // a script card hides its sub-line
1330
1649
  said.push(part(label, n.label) + (subTxt && !subShown ? ` (${subTxt})` : ''));
1331
1650
  const parts = [`<title>${esc(n.label)}${subTxt ? ' · ' + esc(subTxt) : ''}${n.note ? '\n' + esc(n.note) : ''}${stacked ? '\nDouble-click to open' : ''}</title>`];
1332
- if (stacked) parts.push(`<rect x="${n.x + 8}" y="${n.y + 8}" width="${n.w}" height="${n.h}" rx="${rx}" class="n-stack" stroke="${c}"/><rect x="${n.x + 4}" y="${n.y + 4}" width="${n.w}" height="${n.h}" rx="${rx}" class="n-stack" stroke="${c}"/>`);
1333
- if (pub) parts.push(`<rect x="${n.x - 4}" y="${n.y - 4}" width="${n.w + 8}" height="${n.h + 8}" rx="14" class="n-ring" stroke="${c}"/>`);
1334
- if (hook) { const hx = Math.min(16, n.h / 3), pts = `${n.x + hx},${n.y} ${n.x + n.w - hx},${n.y} ${n.x + n.w},${n.y + n.h / 2} ${n.x + n.w - hx},${n.y + n.h} ${n.x + hx},${n.y + n.h} ${n.x},${n.y + n.h / 2}`;
1335
- parts.push(`<polygon points="${pts}" class="n-mask"/><polygon points="${pts}" class="n-body" fill="${f}" stroke="${c}" stroke-linejoin="round"/>`); }
1336
- else parts.push(`<rect x="${n.x}" y="${n.y}" width="${n.w}" height="${n.h}" rx="${rx}" class="n-mask"/>
1337
- <rect x="${n.x}" y="${n.y}" width="${n.w}" height="${n.h}" rx="${rx}" class="n-body" fill="${f}" stroke="${c}" ${removed || ext || planned ? 'stroke-dasharray="6 4"' : ''} ${removed ? 'opacity=".6"' : ''}/>`);
1338
- parts.push((() => { const I = (opts.icons || {})[n.icon], x = map ? n.x + 14 : n.x + (verb ? 14 : hook ? 17 : 9), y = map ? n.y + n.h / 2 - 14 : n.y + (priv ? 8 : 11), M = map ? 28 : 16; // map: one 28 px mark, left, centred
1651
+ if (L) { // a shape: its outline twice behind it when stacked, then the mask, the body and its details
1652
+ const o = S.out(L);
1653
+ if (stacked) parts.push([8, 4].map(k => `<path d="${o}" transform="translate(${k} ${k})" class="n-stack" stroke="${c}"/>`).join(''));
1654
+ if (pub) parts.push(`<path d="${S.out(L, 4)}" class="n-ring" stroke="${c}"/>`);
1655
+ parts.push(`<path d="${o}" class="n-mask"/><path d="${o}" class="n-body" fill="${f}" stroke="${c}"${dash ? ' stroke-dasharray="6 4"' : ''}${removed ? ' opacity=".6"' : ''}/>${S.det ? S.det(L, c, n.k) : ''}`); }
1656
+ else {
1657
+ if (stacked) parts.push(`<rect x="${n.x + 8}" y="${n.y + 8}" width="${n.w}" height="${n.h}" rx="${rx}" class="n-stack" stroke="${c}"/><rect x="${n.x + 4}" y="${n.y + 4}" width="${n.w}" height="${n.h}" rx="${rx}" class="n-stack" stroke="${c}"/>`);
1658
+ if (pub) parts.push(`<rect x="${n.x - 4}" y="${n.y - 4}" width="${n.w + 8}" height="${n.h + 8}" rx="14" class="n-ring" stroke="${c}"/>`);
1659
+ if (hook) { const hx = Math.min(16, n.h / 3), pts = `${n.x + hx},${n.y} ${n.x + n.w - hx},${n.y} ${n.x + n.w},${n.y + n.h / 2} ${n.x + n.w - hx},${n.y + n.h} ${n.x + hx},${n.y + n.h} ${n.x},${n.y + n.h / 2}`;
1660
+ parts.push(`<polygon points="${pts}" class="n-mask"/><polygon points="${pts}" class="n-body" fill="${f}" stroke="${c}" stroke-linejoin="round"/>`); }
1661
+ else parts.push(`<rect x="${n.x}" y="${n.y}" width="${n.w}" height="${n.h}" rx="${rx}" class="n-mask"/>
1662
+ <rect x="${n.x}" y="${n.y}" width="${n.w}" height="${n.h}" rx="${rx}" class="n-body" fill="${f}" stroke="${c}" ${dash ? 'stroke-dasharray="6 4"' : ''} ${removed ? 'opacity=".6"' : ''}/>`); }
1663
+ // the mark, centred on the face; in the uml notation a box's own sigil gives way to UML's component icon
1664
+ const glyph = L && SIGIL[L.sh] ? L.sh : notation === 'uml' && !L && !String(n.k).startsWith('m-') ? 'component' : n.k; // a person's, robot's, phone's, terminal's, folder's or node's own mark
1665
+ parts.push((() => { const I = (opts.icons || {})[n.icon], x = map ? n.x + 14 : n.x + (verb ? 14 : hook ? 17 : 9), y = map ? fy - 14 : n.y + (priv ? 8 : 11), M = map ? 28 : 16; // map: one 28 px mark, left, centred
1339
1666
  return I && I.src ? `<rect x="${x - M * 3 / 28}" y="${y - M * 3 / 28}" width="${M * 34 / 28}" height="${M * 34 / 28}" rx="${M / 4}" class="n-logo${I.dark ? ' dark' : ''}"/><image href="${I.src}" x="${x}" y="${y}" width="${M}" height="${M}"/>`
1340
1667
  : I && I.g ? `<g transform="translate(${x} ${y}) scale(${M / 24})" class="n-sigil" fill="none" stroke="${c}" stroke-width="${1.75 * 24 / M}" stroke-linecap="round" stroke-linejoin="round">${I.g}</g>`
1341
- : `<g transform="translate(${x} ${y}) scale(${M / 16})" class="n-sigil" fill="none" stroke="${c}" stroke-width="${1.75 * 16 / M}" stroke-linecap="round" stroke-linejoin="round">${SIGIL[n.k]}</g>`; })());
1668
+ : `<g transform="translate(${x} ${y}) scale(${M / 16})" class="n-sigil" fill="none" stroke="${c}" stroke-width="${1.75 * 16 / M}" stroke-linecap="round" stroke-linejoin="round">${SIGIL[glyph]}</g>`; })());
1342
1669
  parts.push(`<text x="${tx0}" y="${ly}" text-anchor="${anchor}" class="n-label" data-o="${esc(n.id)}" data-t="nl" style="font-size:${lpx.toFixed(1)}px" ${removed ? 'text-decoration="line-through"' : ''}>${two ? lines(two, 15) : esc(label)}</text>`);
1670
+ const ty = tabLine(n, L); // the line the tabs, ⤢, the change badge and a step sit on
1343
1671
  if (map) { let tx = n.x + 12; mapTabs(n, c).forEach(t => { const tw = t.w; // each badge is its own tab, clickable on its own
1344
- parts.push(`<g class="pub-tab"${t.badge != null ? ` data-badge="${t.badge}"` : ''}><rect x="${tx}" y="${n.y - 8}" width="${tw}" height="15" rx="4" fill="${t.fill}"/><text x="${tx + tw / 2}" y="${n.y + 3}" text-anchor="middle" class="tab-tx">${esc(t.text)}</text></g>`);
1672
+ parts.push(`<g class="pub-tab"${t.badge != null ? ` data-badge="${t.badge}"` : ''}><rect x="${tx}" y="${ty - 8}" width="${tw}" height="15" rx="4" fill="${t.fill}"/><text x="${tx + tw / 2}" y="${ty + 3}" text-anchor="middle" class="tab-tx">${esc(t.text)}</text></g>`);
1345
1673
  said.push(t.badge != null ? badgesOf(n)[t.badge].text : t.text.toLowerCase()); tx += tw + 4; }); }
1346
1674
  if (map && n.edited) parts.push(`<g class="edited"><title>Edited</title><circle cx="${n.x + n.w - 10}" cy="${n.y + n.h - 10}" r="4" class="edited-dot"/></g>`);
1347
- if (map && n.step) { const t = String(n.step), sw = Math.max(20, sansW(t, 9.5, .9) + 10);
1348
- parts.push(`<g class="step"><title>Step ${esc(t)}</title><rect x="${n.x - 10}" y="${n.y - 10}" width="${sw}" height="20" rx="10" class="stp"/><text x="${n.x - 10 + sw / 2}" y="${n.y + 3.5}" text-anchor="middle" class="stp-tx">${esc(t)}</text></g>`);
1675
+ // a step on a shape's tab line (a person's shoulder), but never down over the arrow of a line into its left side: as low as
1676
+ // keeps 6 px clear of the highest one, and no higher than the box's corner
1677
+ if (map && n.step) { const t = String(n.step), sw = Math.max(20, sansW(t, 9.5, .9) + 10), sy = L ? Math.max(n.y, Math.min(ty, L.portL - 16)) : ty;
1678
+ parts.push(`<g class="step"><title>Step ${esc(t)}</title><rect x="${n.x - 10}" y="${sy - 10}" width="${sw}" height="20" rx="10" class="stp"/><text x="${n.x - 10 + sw / 2}" y="${sy + 3.5}" text-anchor="middle" class="stp-tx">${esc(t)}</text></g>`);
1349
1679
  said.push(`(step) ${t}`); }
1350
1680
  if (n.tag) { // author name tag under the card: a bigger headshot + @handle (avatar inlined by render)
1351
1681
  const T = n.tag, cxp = T.x + 11, cyp = T.y + 11, cid = 'av-' + id + '-' + String(n.id).replace(/[^\w-]/g, '_');
@@ -1357,14 +1687,14 @@ return I && I.src ? `<rect x="${x - M * 3 / 28}" y="${y - M * 3 / 28}" width="${
1357
1687
  said.push(`(by${T.name ? ' ' + T.name : ''}) ${plain([ini, T.text].filter(Boolean).join(' '))}`);
1358
1688
  }
1359
1689
  const hint = stacked && opts.openHint !== false;
1360
- if (hint) { const kx = map ? n.x + n.w - 44 : n.x + n.w - 40, ky = map ? n.y - 8 : n.y + n.h - 11;
1690
+ if (hint) { const kx = map ? n.x + n.w - 44 : n.x + n.w - 40, ky = map ? ty - 8 : n.y + n.h - 11;
1361
1691
  parts.push(`<g class="open-hint${map ? '' : ' depth-read'}"><title>Open the inner view (or double-click the card)</title><rect x="${kx}" y="${ky}" width="36" height="16" rx="8" class="n-mask" stroke="${c}"/><text x="${kx + 18}" y="${ky + 12}" text-anchor="middle" class="badge-tx" fill="${c}">⤢${kids ? ' ' + kids : ''}</text></g>`);
1362
1692
  said.push(kids ? `${kids} (cards inside, opens the inner view)` : '(opens the inner view)'); }
1363
1693
  const ch = CHG[String(n.change || '').toLowerCase()];
1364
1694
  if (ch) {
1365
1695
  const bw = ch[1].length * 6 + 12, bx = n.x + n.w - bw - 8 - (hint && map ? 40 : 0); // left of a map card's ⤢ pill, which sits on the same edge
1366
- parts.push(`<rect x="${bx}" y="${n.y - 8}" width="${bw}" height="16" rx="8" fill="var(--um-mask)" stroke="var(--um-${ch[0]})"/>
1367
- <text x="${bx + bw / 2}" y="${n.y + 3}" text-anchor="middle" class="badge-tx" fill="var(--um-${ch[0]})">${ch[1]}</text>`);
1696
+ parts.push(`<rect x="${bx}" y="${ty - 8}" width="${bw}" height="16" rx="8" fill="var(--um-mask)" stroke="var(--um-${ch[0]})"/>
1697
+ <text x="${bx + bw / 2}" y="${ty + 3}" text-anchor="middle" class="badge-tx" fill="var(--um-${ch[0]})">${ch[1]}</text>`);
1368
1698
  said.push(ch[1].toLowerCase());
1369
1699
  }
1370
1700
  if (sub) { parts.push(`<text x="${tx0}" y="${ly + 16 + (two ? 15 : 0)}" text-anchor="${anchor}" class="n-sub depth-read" data-o="${esc(n.id)}" data-t="ns">${sub2 ? lines(sub2, 12) : esc(sub)}</text>`);
@@ -1383,12 +1713,12 @@ return I && I.src ? `<rect x="${x - M * 3 / 28}" y="${y - M * 3 / 28}" width="${
1383
1713
  }
1384
1714
  if (map) {
1385
1715
  const ports = new Map();
1386
- edges.forEach((e, i) => { const point = e.from === n.id ? e.p1 : e.to === n.id ? e.p2 : null; if (!point) return;
1716
+ edges.forEach((e, i) => { const point = e.from === n.id ? (L ? drawn[i][0] : e.p1) : e.to === n.id ? (L ? drawn[i][drawn[i].length - 1] : e.p2) : null; if (!point) return; // a shape's ports on its outline
1387
1717
  const key = point.join(','); const port = ports.get(key) || { point, indices: [] }; port.indices.push(i); ports.set(key, port); });
1388
1718
  ports.forEach(({ point, indices }) => { parts.push(`<circle cx="${point[0]}" cy="${point[1]}" r="3.5" class="n-port" data-edges="${indices.join(',')}" stroke="${c}"/>`); });
1389
1719
  }
1390
1720
  const name = said.join(', ') + (n.edited ? ' (edited)' : '') + (stacked && !hint ? ' (opens inner view)' : '');
1391
- s += `<g class="node${pub ? ' pub' : ''}${priv ? ' priv' : ''}${stacked ? ' has-child' : ''}${subject ? ' subject' : ''}${n.emphasis ? ' main-step' : ''}${planned ? ' planned' : ''}${tour && n.id in tour.at ? ' tour-stop' : ''}"${tour && n.id in tour.at ? ` style="animation-delay:${(tour.at[n.id] * TOUR - .32).toFixed(2)}s"` : ''} data-id="${esc(n.id)}" tabindex="${map ? 0 : -1}" role="${map ? 'button' : 'img'}" aria-label="${esc(name)}">
1721
+ s += `<g class="node${pub ? ' pub' : ''}${priv ? ' priv' : ''}${stacked ? ' has-child' : ''}${subject ? ' subject' : ''}${n.emphasis ? ' main-step' : ''}${planned ? ' planned' : ''}${tour && n.id in tour.at ? ' tour-stop' : ''}"${tour && n.id in tour.at ? ` style="animation-delay:${(tour.at[n.id] * TOUR - .32).toFixed(2)}s"` : ''}${L ? ` data-shape="${L.sh}" data-face="${r2(ft)} ${r2(fb)}"` : ''} data-id="${esc(n.id)}" tabindex="${map ? 0 : -1}" role="${map ? 'button' : 'img'}" aria-label="${esc(name)}">
1392
1722
  ${parts.join('\n')}
1393
1723
  </g>`;
1394
1724
  });
@@ -1396,7 +1726,7 @@ return I && I.src ? `<rect x="${x - M * 3 / 28}" y="${y - M * 3 / 28}" width="${
1396
1726
  s += '</g><g class="keys">';
1397
1727
  edges.forEach((e, i) => { const said = e.label ? `: ${e.label}${e.sublabel ? ` (${e.sublabel})` : ''}` : '';
1398
1728
  const name = `${e.step != null ? `Step ${e.step}: ` : ''}Line ${e.both ? `between ${e.s.label} and ${e.t.label}, both ways` : `from ${e.s.label} to ${e.t.label}`}${said}${broke(e).length ? `, breaks rule ${broke(e).join('; ')}` : ''}`;
1399
- s += `<g class="um-key line-key${e.weight > 1 ? ' wt-' + e.weight : ''}" data-i="${i}" tabindex="0" role="button" aria-pressed="false" aria-label="${esc(name)}"><path d="${roundPath(e.pts)}" class="key-ring"/></g>`; });
1729
+ s += `<g class="um-key line-key${e.weight > 1 ? ' wt-' + e.weight : ''}" data-i="${i}" tabindex="0" role="button" aria-pressed="false" aria-label="${esc(name)}"><path d="${roundPath(drawn[i])}" class="key-ring"/></g>`; });
1400
1730
  }
1401
1731
  s += '</g><g class="labels depth-read">';
1402
1732
  edges.forEach((e, i) => {
@@ -1404,7 +1734,7 @@ return I && I.src ? `<rect x="${x - M * 3 / 28}" y="${y - M * 3 / 28}" width="${
1404
1734
  const L = e.lbl;
1405
1735
  if (map) {
1406
1736
  const sw = L.sw || 16, cy = L.y + L.h / 2; // the step mark: a circle, or a pill as wide as a longer number (#82)
1407
- const st = L.step != null ? `${sw > 16 ? `<rect x="${L.x + 3}" y="${cy - 8}" width="${sw}" height="16" rx="8" class="e-step"/>` : `<circle cx="${L.x + 11}" cy="${cy}" r="8" class="e-step"/>`}<text x="${L.x + 3 + sw / 2}" y="${cy + 3.3}" text-anchor="middle" class="e-step-tx">${esc(L.step)}</text>` : '';
1737
+ const st = L.step != null ? `${sw > 16 ? `<rect x="${L.x + 3}" y="${cy - 8}" width="${sw}" height="16" rx="8" class="e-step"/>` : `<circle cx="${L.x + 11}" cy="${cy}" r="8" class="e-step"/>`}<text x="${L.x + 3 + sw / 2}" y="${cy + 3.3}" text-anchor="middle" class="e-step-tx" data-t="el" data-o="${esc(e.from)}>${esc(e.to)}">${esc(L.step)}</text>` : '';
1408
1738
  const ax = L.step != null ? L.x + 7 + sw : L.x + L.w / 2, an = L.step != null ? '' : 'text-anchor="middle"';
1409
1739
  const tx = L.text ? `<text x="${ax}" y="${L.y + 14}" ${an} class="e-label" data-t="el" data-o="${esc(e.from)}>${esc(e.to)}">${esc(L.text)}</text>` : '';
1410
1740
  const sb = L.sub ? `<text x="${ax}" y="${L.y + 26}" ${an} class="e-sub" data-t="el" data-o="${esc(e.from)}>${esc(e.to)}">${esc(L.sub)}</text>` : '';
@@ -1552,6 +1882,9 @@ class Camera {
1552
1882
  // 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()
1553
1883
  // turns those into Refs; the page's check reads only the messages
1554
1884
  const teller = (issues, refs) => (message, ...about) => { issues.push(message); if (refs) refs.push(about); };
1885
+ // a label's box L across one of box f's borders
1886
+ const onBorder = (L, f) => { const xin = L.x + L.w > f.x && L.x < f.x + f.w, yin = L.y + L.h > f.y && L.y < f.y + f.h;
1887
+ return (xin && [f.y, f.y + f.h].some(u => L.y < u && L.y + L.h > u)) || (yin && [f.x, f.x + f.w].some(u => L.x < u && L.x + L.w > u)); };
1555
1888
  // a detour: a route 2.5 times longer than the straight distance between its ends, or than 100 px; [its length, that distance]
1556
1889
  const detour = pts => { const len = pts.slice(1).reduce((a, p, k) => a + Math.abs(p[0] - pts[k][0]) + Math.abs(p[1] - pts[k][1]), 0);
1557
1890
  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; };
@@ -1629,8 +1962,7 @@ function routeAudit(geo, where, only, refs) { // only: a set of edges; counts ju
1629
1962
  geo.edges.forEach((e,i) => { if (segs[i].some(([a,b]) => { const v = a[0] === b[0], c = v ? a[0] : a[1], p = v ? [a[1], b[1]] : [a[0], b[0]];
1630
1963
  const lo = Math.max(Math.min(...p), v ? f.y : f.x), hi = Math.min(Math.max(...p), v ? f.y + f.h : f.x + f.w);
1631
1964
  return hi - lo > 40 && (v ? [f.x, f.x + f.w] : [f.y, f.y + f.h]).some(u => Math.abs(c - u) < 8); })) say(`${where}: edge ${name(e)} runs along the border of family "${f.label}"`, e, f); });
1632
- geo.edges.forEach(e => { const L = e.lbl; if (!L) return; const xin = L.x + L.w > f.x && L.x < f.x + f.w, yin = L.y + L.h > f.y && L.y < f.y + f.h;
1633
- if ((xin && [f.y, f.y + f.h].some(u => L.y < u && L.y + L.h > u)) || (yin && [f.x, f.x + f.w].some(u => L.x < u && L.x + L.w > u))) say(`${where}: label "${L.text || L.step}" sits on the border of family "${f.label}"`, e, f); });
1965
+ geo.edges.forEach(e => { const L = e.lbl; if (L && onBorder(L, f)) say(`${where}: label "${L.text || L.step}" sits on the border of family "${f.label}"`, e, f); });
1634
1966
  });
1635
1967
  return issues;
1636
1968
  }
@@ -1645,7 +1977,9 @@ function overlapIssues(geo, where, refs) {
1645
1977
  }
1646
1978
  function leaveIssues(geo, where, refs) {
1647
1979
  const issues = [], say = teller(issues, refs);
1648
- if (geo.map) geo.edges.forEach(e => { const L = e.lbl; if (!L || !e.label || !e.pts) return; // a label must leave some of its own line visible
1980
+ // a numbered line's label with no room on it: only its step shows, its words in the line's title and the details only
1981
+ if (geo.map) geo.edges.forEach(e => { if (e.label && e.lbl && e.lbl.short) say(`${where}: label "${e.label}" has no room on edge ${e.s.label}→${e.t.label}: only its step ${e.lbl.step} shows`, e); });
1982
+ if (geo.map) geo.edges.forEach(e => { const L = e.lbl; if (!L || (!e.label && L.step == null) || !e.pts) return; // a label, or a step's mark, must leave some of its own line visible
1649
1983
  const c = [L.x + L.w / 2, L.y + L.h / 2], P = e.pts; let s = 0, at = null, half = 0, tot = 0;
1650
1984
  for (let i = 0; i + 1 < P.length; i++) { const a = P[i], b = P[i + 1], len = Math.abs(b[0] - a[0]) + Math.abs(b[1] - a[1]);
1651
1985
  if (at == null) { const onH = a[1] === b[1] && Math.abs(c[1] - a[1]) < 2 && c[0] >= Math.min(a[0], b[0]) - 1 && c[0] <= Math.max(a[0], b[0]) + 1;
@@ -1653,7 +1987,7 @@ function leaveIssues(geo, where, refs) {
1653
1987
  if (onH || onV) { at = tot + Math.abs(c[0] - a[0]) + Math.abs(c[1] - a[1]); half = onH ? L.w / 2 : L.h / 2; } }
1654
1988
  tot += len; }
1655
1989
  if (at == null) return; const room = Math.min(at - half, tot - at - half);
1656
- if (room < 16) say(`${where}: label "${e.label}" leaves its line ${Math.max(0, Math.round(room))} px (${e.s.label}→${e.t.label})`, e); });
1990
+ if (room < 16) say(`${where}: label "${e.label || L.step}" leaves its line ${Math.max(0, Math.round(room))} px (${e.s.label}→${e.t.label})`, e); });
1657
1991
  return issues;
1658
1992
  }
1659
1993
 
@@ -1661,7 +1995,7 @@ function leaveIssues(geo, where, refs) {
1661
1995
  function auditSvg(svg, geo, where) {
1662
1996
  const issues = overlapIssues(geo, where), R = box;
1663
1997
  const T = [...svg.querySelectorAll('text[data-t]')].map(t => { let b; try { b = t.getBBox(); } catch (_) { return null; } return { t: t.dataset.t, o: t.dataset.o, txt: t.textContent, b: { x: b.x, y: b.y, w: b.width, h: b.height } }; }).filter(x => x && x.b.w);
1664
- const nodes = geo.nodes;
1998
+ const nodes = geo.nodes, faces = Object.fromEntries([...svg.querySelectorAll('.node[data-face]')].map(g => [g.dataset.id, g.dataset.face.split(' ').map(Number)])); // a shape's face (0.15.0)
1665
1999
  T.forEach(a => {
1666
2000
  if (a.t === 'lt' || a.t === 'ft') {
1667
2001
  nodes.forEach(n => { if (hit(a.b, R(n), -1)) issues.push(`${where}: lane "${a.txt}": header covers node "${n.label}"`); });
@@ -1671,7 +2005,9 @@ function auditSvg(svg, geo, where) {
1671
2005
  } else {
1672
2006
  const own = geo.byId[a.o];
1673
2007
  if (own && (a.b.x < own.x + 1 || a.b.x + a.b.w > own.x + own.w - 1)) issues.push(`${where}: text "${a.txt}" overflows node "${own.label}"`);
1674
- if (own && geo.map && (a.t === 'nl' || a.t === 'ns') && hit(a.b, { x: own.x + 11, y: own.y + own.h / 2 - 17, w: 34, h: 34 }, 2)) issues.push(`${where}: text "${a.txt}" touches the mark on node "${own.label}"`);
2008
+ const face = own && faces[own.id], fy = face ? (face[0] + face[1]) / 2 : own && own.y + own.h / 2; // a shape's mark and text keep to its face
2009
+ if (own && geo.map && (a.t === 'nl' || a.t === 'ns') && hit(a.b, { x: own.x + 11, y: fy - 17, w: 34, h: 34 }, 2)) issues.push(`${where}: text "${a.txt}" touches the mark on node "${own.label}"`);
2010
+ if (face && (a.b.y < face[0] - 1 || a.b.y + a.b.h > face[1] + 1)) issues.push(`${where}: text "${a.txt}" overflows the shape of node "${own.label}"`);
1675
2011
  nodes.forEach(n => { if (n !== own && hit(a.b, R(n), -1)) issues.push(`${where}: text "${a.txt}" overlaps node "${n.label}"`); });
1676
2012
  }
1677
2013
  });
@@ -1955,9 +2291,12 @@ function mapContext(model, opts = {}) {
1955
2291
  for (const o of [{ pull: true }, { packed: true }, { pull: true, packed: true }]) { const p = flowPos(vid, o), k = JSON.stringify(p); if (!seen.has(k)) { seen.add(k); ways.push(p); } }
1956
2292
  if (ways.length < 2) return first;
1957
2293
  let best = first, score = Infinity;
1958
- ways.forEach(p => { const g = buildGeo(vid, p), s = scoreGeo(g); if (s < score - 0.5) { score = s; best = p; drawn[vid] = { pos: p, g }; } }); // as it will be drawn: with port repair
2294
+ scouting = true; ways.forEach(p => { const g = buildGeo(vid, p), s = scoreGeo(g); if (s < score - 0.5) { score = s; best = p; drawn[vid] = { pos: p, g }; } }); scouting = false; // as it will be drawn: with port repair
2295
+ if (fanIn(vid)) delete drawn[vid];
1959
2296
  return best;
1960
2297
  }
2298
+ let scouting = false;
2299
+ const fanIn = vid => { const k = {}; return viewEdges(V[vid] || {}).some(e => e.from !== e.to && (k[e.to] = (k[e.to] || 0) + 1) >= 6); };
1961
2300
  const drawn = {}; // the way bestFlow kept, routed already: the view's next buildGeo takes it once instead of routing it again
1962
2301
  function flowPos(vid, o = {}) {
1963
2302
  const v = V[vid], lp = loopsOf(v), fwd = lp.edges.filter((e, k) => !lp.back.has(k) && e.from !== e.to);
@@ -1986,8 +2325,15 @@ function mapContext(model, opts = {}) {
1986
2325
  }
1987
2326
 
1988
2327
  function buildView(vid, opts = {}) {
1989
- const g = buildGeo(vid), v = V[vid];
1990
- return { g, markup: svgOf(g, { title: v.title, subject: subjectOf(v), icons: D.icons, broken: brokenIn(vid), ...opts }) };
2328
+ const g = buildGeo(vid);
2329
+ return { g, ...drawView(vid, g, opts) };
2330
+ }
2331
+ // a view's notation (0.15.0): its own `shapes`, else the model's, else box; drawView: the picture of a geometry in one,
2332
+ // with no layout (a change of notation draws the cards again and nothing else)
2333
+ const notationOf = vid => noteOf((V[vid] || {}).shapes) || noteOf(D.shapes) || 'box';
2334
+ function drawView(vid, g, opts = {}) {
2335
+ const v = V[vid], shapes = noteOf(opts.shapes) || notationOf(vid);
2336
+ return { markup: svgOf(g, { title: v.title, subject: subjectOf(v), icons: D.icons, broken: brokenIn(vid), ...opts, shapes }), shapes };
1991
2337
  }
1992
2338
  // the rules a view's lines break (#70), by model edge id: svgOf draws such a line rose, with a mark that names them
1993
2339
  let ruled = null;
@@ -2016,7 +2362,8 @@ function mapContext(model, opts = {}) {
2016
2362
  .map(e => e.all.length < 2 ? e : { ...e, label: (e.label || '') + ' +' + (e.all.length - 1), when: e.all.map(x => (x.step != null ? x.step + '. ' : '') + (x.label || '')).join('\n') });
2017
2363
  const out = merged.map(e => ({ ...e, back: !(V[vid] || {}).keepPairs && backKeys && backKeys.has(e.from + '>' + e.to) }));
2018
2364
  const pair = {}; // A→B plus B→A reads as one two-way edge
2019
- return out.filter(e => { const r = !(V[vid] || {}).keepPairs && pair[e.to + '>' + e.from]; if (r) { r.both = true; r.back = false; r.all = [...(r.all || [r]), ...(e.all || [e])]; if (e.label && e.label !== r.label) r.label = (r.label ? r.label + ' / ' : '') + e.label; if (e.when) r.when = [r.when, e.when].filter(Boolean).join(' · '); return false; } pair[e.from + '>' + e.to] = e; return true; })
2365
+ return out.filter(e => { const r = !(V[vid] || {}).keepPairs && pair[e.to + '>' + e.from]; if (r) { r.both = true; r.back = false; r.all = [...(r.all || [r]), ...(e.all || [e])]; if (e.label && e.label !== r.label) r.label = (r.label ? r.label + ' / ' : '') + e.label;
2366
+ if (r.all.some(x => x.step != null)) r.when = r.all.map(x => (x.step != null ? x.step + '. ' : '') + (x.label || '')).join('\n'); else if (e.when) r.when = [r.when, e.when].filter(Boolean).join(' · '); return false; } pair[e.from + '>' + e.to] = e; return true; })
2020
2367
  .map(e => { const all = e.all || [e], subs = [...new Set(all.map(x => x.sublabel).filter(x => x != null && x !== '').map(String))];
2021
2368
  return { ...e, wk: wireKind(e, opts.kinds || (V[vid] || {}).kindsFromLabels), weight: Math.max(...all.map(x => [2, 3].includes(+x.weight) ? +x.weight : 1)), sublabel: subs.join(' / ') || undefined, ...markOf(all) }; });
2022
2369
  }
@@ -2035,7 +2382,7 @@ function mapContext(model, opts = {}) {
2035
2382
  const stepIds = v.stepIds || ids;
2036
2383
  const dg = { nodeH: mainSteps ? 82 : v.subAndChips ? 80 : 64, subAndChips: !!v.subAndChips, mainSteps, containers: v.containers, nodes: ids.map(i => { const n = nodeIn(i, vid), step = mainSteps && stepIds.includes(i); return { ...n, sub: n.sub || n.sublabel, emphasis: !!step || !!n.emphasis, step: step ? stepIds.indexOf(i) + 1 : (v.cardSteps || {})[i] || null, verbs: step ? [] : n.verbs, kids: kidCount(i, vid), opens: !!opensOf(i, vid), col: (pos[i] || [0, 0])[0], row: (pos[i] || [0, 0])[1] }; }),
2037
2384
  edges: prepEdges(vid, edges, P.backKeys) };
2038
- dg.authors = D.authors; dg.repair = repair; dg.intake = v.intake; dg.tour = v.tour; dg.gap = v.gap; dg.rgap = v.rgap; dg.quick = quick;
2385
+ dg.authors = D.authors; dg.fan = !scouting; dg.repair = repair; dg.intake = v.intake; dg.tour = v.tour; dg.gap = v.gap; dg.rgap = v.rgap; dg.quick = quick;
2039
2386
  return layoutGraph(dg, true);
2040
2387
  }
2041
2388
  function hubGeo(vid, P, edges, repair, quick) {
@@ -2145,7 +2492,7 @@ function mapContext(model, opts = {}) {
2145
2492
  const viewKey = vid => {
2146
2493
  const v = V[vid], ids = (v.nodeIds || []).filter(i => N[i]), ns = ids.map(i => nodeIn(i, vid));
2147
2494
  const icons = [...new Set(ns.map(n => n.icon).filter(Boolean))].map(k => (D.icons || {})[k]), who = [...new Set(ns.map(n => n.author).filter(Boolean))].map(k => (D.authors || {})[k]);
2148
- return JSON.stringify([v, ns, viewEdges(v), ids.map(i => [kidCount(i, vid), opensOf(i, vid)]), icons, who]);
2495
+ return JSON.stringify([{ ...v, shapes: undefined }, ns, viewEdges(v), ids.map(i => [kidCount(i, vid), opensOf(i, vid)]), icons, who]); // a view's notation lays nothing out
2149
2496
  };
2150
2497
  /* ---------- unfolding in place (#71): a stacked card opens into a box on the view it is on ----------
2151
2498
  The card becomes a box with its id and name, holding its inner view's own cards: those not on this view already (the
@@ -2267,7 +2614,7 @@ function mapContext(model, opts = {}) {
2267
2614
  for (let more = true; more && todo.length;) { more = false; todo = todo.filter(nid => { if (!unfold(vid, nid)) return true; (unfolded[vid] ||= []).push(nid); return !(more = true); }); } }))();
2268
2615
  const geoCache = {};
2269
2616
  return { D, views, nodes, N, V, rootId, nodeIn, childOf, opensOf, subjectOf, viewsWith, kidsOf, chain, viewEdges, isState, planOf: withM(planOf), planCache,
2270
- layoutCache, geoCache, buildGeo: withM(buildGeo), buildView: withM(buildView), optimize: withM(optimize), flowPos, kidCount, viewKey, brokenIn, unfoldable, unfolded };
2617
+ layoutCache, geoCache, buildGeo: withM(buildGeo), buildView: withM(buildView), drawView: withM(drawView), notationOf, optimize: withM(optimize), flowPos, kidCount, viewKey, brokenIn, unfoldable, unfolded };
2271
2618
  }
2272
2619
 
2273
2620
  const edgeRefIds = e => [...new Set((e.all || [e]).map(x => x._id).filter(Boolean))];
@@ -2308,15 +2655,24 @@ export function validate(model) {
2308
2655
  if (!n || typeof n.id !== 'string' || !n.id) return err(at + '.id', 'a card needs an id');
2309
2656
  if (ids.has(n.id)) err(at + '.id', `card id "${n.id}" is used twice`); ids.add(n.id);
2310
2657
  const k = String(n.kind ?? '').toLowerCase();
2311
- if (n.kind != null && !(k in KIND || k in MAPK || k in ALIAS)) warn(at + '.kind', `unknown kind "${n.kind}": drawn as a script card`);
2658
+ if (n.kind != null && !(hasKey(KIND, k) || hasKey(MAPK, k) || hasKey(ALIAS, k))) warn(at + '.kind', `unknown kind "${n.kind}": drawn as a script card`);
2312
2659
  if (n.owner != null && typeof n.owner !== 'string') warn(at + '.owner', 'an owner is a name: ignored');
2660
+ shapeIs(n, at);
2313
2661
  };
2662
+ const shapeIs = (n, at) => { if (n && n.shape != null && !SHAPE_NAMES.includes(String(n.shape).toLowerCase())) warn(at + '.shape', `unknown shape "${n.shape}": the notation draws it by its kind (the shapes: ${SHAPE_NAMES.join(', ')})`); };
2663
+ const notation = (x, at) => { if (x != null && !noteOf(x)) warn(at, `"${x}" is not a notation (${NOTATIONS.join(', ')}): drawn as box`); }; // card shapes (0.15.0)
2664
+ notation(m.shapes, 'shapes');
2314
2665
  arr(m.nodes).forEach((n, i) => card(n, `nodes[${i}]`));
2315
2666
  views.forEach((v, i) => arr(v.nodes).forEach((n, j) => card(n, `views[${i}].nodes[${j}]`)));
2316
2667
  views.forEach((v, i) => { if (typeof v.id !== 'string' || !v.id) err(`views[${i}].id`, 'a view needs an id'); else if (vids.has(v.id)) err(`views[${i}].id`, `view id "${v.id}" is used twice`); else vids.add(v.id); });
2317
2668
  if (m.root != null && !vids.has(m.root)) err('root', `root "${m.root}" is not a view`);
2318
2669
  const later = (x, at) => { if (x && x.opensView && !vids.has(x.opensView)) warn(at + '.opensView', `opens "${x.opensView}", a view the model does not have: fine if the host loads it when it is opened`); };
2319
2670
  arr(m.nodes).forEach((n, i) => later(n, `nodes[${i}]`));
2671
+ views.forEach((v, i) => notation(v.shapes, `views[${i}].shapes`));
2672
+ // c4 draws a frontend card as a browser: a person's icon on one says the model means a person (0.15.0)
2673
+ const inC4 = new Set(views.filter(v => (noteOf(v.shapes) || noteOf(m.shapes)) === 'c4').flatMap(v => arr(v.nodeIds)));
2674
+ arr(m.nodes).forEach((n, i) => { if (!n || !inC4.has(n.id) || n.shape != null || !['user', 'users', 'usercheck'].includes(n.icon)) return;
2675
+ if (shapeOf({ ...n, k: kindOf(n.kind, true) }, 'c4') === 'browser') warn(`nodes[${i}].kind`, `"${n.label || n.id}" has a person's icon, but c4 draws a ${n.kind} card as a browser: kind "person" (or shape "person") draws a person`); });
2320
2676
  const line = (e, at, ends) => {
2321
2677
  if (!e || typeof e !== 'object') return err(at, 'a line is an object with from and to');
2322
2678
  ['from', 'to'].forEach(k => { if (!ends.has(e[k])) err(`${at}.${k}`, `"${e[k]}" is neither a card nor a box`); });
@@ -2324,6 +2680,8 @@ export function validate(model) {
2324
2680
  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`);
2325
2681
  ['fromSide', 'toSide'].forEach(k => { if (e[k] != null && !SIDES.includes(e[k])) warn(`${at}.${k}`, `"${e[k]}" is not left, right, top or bottom: ignored`); });
2326
2682
  ['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)
2683
+ if (e.step != null && !(typeof e.step === 'number' ? e.step >= 0 : typeof e.step === 'string' && e.step.length >= 1 && e.step.length <= 3))
2684
+ warn(at + '.step', `step ${JSON.stringify(e.step)} is not a number of 0 or more, or text of 1 to 3 characters (2a): drawn as written`);
2327
2685
  };
2328
2686
  // a model line may end on any view's box (#28); a view's own line only on that view's boxes
2329
2687
  const anyBox = new Set(views.flatMap(v => arr(v.containers).map(c => c && c.id)));
@@ -2333,7 +2691,7 @@ export function validate(model) {
2333
2691
  const at = `views[${i}]`, mine = new Set(arr(v.nodeIds)), boxes = {};
2334
2692
  if (v.parent != null && !vids.has(v.parent) && !ids.has(v.parent)) err(at + '.parent', `parent "${v.parent}" is not a view`);
2335
2693
  arr(v.nodeIds).forEach((id, j) => { if (!ids.has(id)) err(`${at}.nodeIds[${j}]`, `"${id}" is not a card`); });
2336
- Object.entries(v.nodeOverrides || {}).forEach(([id, o]) => { if (!ids.has(id)) err(`${at}.nodeOverrides.${id}`, `"${id}" is not a card`); later(o, `${at}.nodeOverrides.${id}`); });
2694
+ Object.entries(v.nodeOverrides || {}).forEach(([id, o]) => { if (!ids.has(id)) err(`${at}.nodeOverrides.${id}`, `"${id}" is not a card`); later(o, `${at}.nodeOverrides.${id}`); shapeIs(o, `${at}.nodeOverrides.${id}`); });
2337
2695
  arr(v.nodes).forEach((n, j) => later(n, `${at}.nodes[${j}]`));
2338
2696
  ['stepIds', 'tour'].forEach(k => arr(v[k]).forEach((id, j) => { if (!mine.has(id)) err(`${at}.${k}[${j}]`, `"${id}" is not one of the view's cards`); }));
2339
2697
  arr(v.browserOrder).forEach((id, j) => { if (!vids.has(id)) err(`${at}.browserOrder[${j}]`, `"${id}" is not a view`); });
@@ -2342,6 +2700,7 @@ export function validate(model) {
2342
2700
  arr(v.containers).forEach((c, j) => { if (!c) return;
2343
2701
  arr(c.nodeIds).forEach((id, k) => { if (!mine.has(id)) err(`${at}.containers[${j}].nodeIds[${k}]`, `"${id}" is not one of the view's cards`); });
2344
2702
  if (c.parent != null && !boxes[c.parent]) err(`${at}.containers[${j}].parent`, `"${c.parent}" is not a box of this view`);
2703
+ if (c.tone != null && typeof c.tone !== 'string') warn(`${at}.containers[${j}].tone`, `tone ${JSON.stringify(c.tone)} is not a palette name (green, amber, ...): ${c.tone ? 'drawn in slate' : 'ignored'}`);
2345
2704
  for (let p = c.parent, seen = new Set([c.id]); p != null && boxes[p]; p = boxes[p].parent) { if (seen.has(p)) { err(`${at}.containers[${j}].parent`, `box "${c.id}" ends up inside itself`); break; } seen.add(p); } });
2346
2705
  const ends = new Set([...ids, ...Object.keys(boxes)]);
2347
2706
  if (Array.isArray(v.edges)) v.edges.forEach((e, j) => { if (typeof e !== 'string') line(e, `${at}.edges[${j}]`, ends); else if (!lineIds.has(e)) err(`${at}.edges[${j}]`, `"${e}" is not a line`); });
@@ -2357,7 +2716,7 @@ export function validate(model) {
2357
2716
  if (typeof s !== 'object' || Array.isArray(s)) return err(at, `a selector is an object: id, kind, container, owner${lens ? ', line' : ''} or not`);
2358
2717
  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`); });
2359
2718
  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`); });
2360
- each(s.kind, at + '.kind', (x, p) => { const k = String(x).toLowerCase(); if (!(k in KIND || k in MAPK || k in ALIAS)) warn(p, `unknown kind "${x}": it picks script cards`); });
2719
+ each(s.kind, at + '.kind', (x, p) => { const k = String(x).toLowerCase(); if (!(hasKey(KIND, k) || hasKey(MAPK, k) || hasKey(ALIAS, k))) warn(p, `unknown kind "${x}": it picks script cards`); });
2361
2720
  each(s.container, at + '.container', (x, p) => { if (!allBoxes.has(x)) warn(p, `"${x}" is not a box: it picks nothing`); });
2362
2721
  each(s.owner, at + '.owner', (x, p) => { if (!owners.has(x)) warn(p, `no card's owner is "${x}": it picks nothing`); });
2363
2722
  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`); });
@@ -2652,7 +3011,7 @@ export function render(container, model, options = {}) {
2652
3011
  <button data-z="up" title="Up one level (Esc)" aria-label="Up one level">↑</button><span class="sep"></span>
2653
3012
  <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>
2654
3013
  <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>
2655
- </div>`}</div></div>${!framed ? '' : (options.legend ? '<div class="m-legend"></div>' : '') + (options.hints ? `<div class="hint" role="status" aria-live="polite">${hintHTML('', ctrlWheel)}</div>` : '') + '</div>'
3014
+ </div>`}</div></div>${!framed ? '' : (options.legend ? '<div class="m-legend"></div>' : '') + (options.hints ? `<div class="hint">${hintHTML('', ctrlWheel)}</div>` : '') + '</div>'
2656
3015
  + (options.details ? '<aside class="panel" aria-label="Node details" hidden></aside>' : '') + '</div>'
2657
3016
  + (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>'}`;
2658
3017
  const stage = root.querySelector('.stage'), q = s => root.querySelector(s);
@@ -2665,7 +3024,7 @@ export function render(container, model, options = {}) {
2665
3024
  event says which of its keys changed, once a frame at most (on a 16 ms timer, so a page in a hidden tab still says it).
2666
3025
  The camera is the exception: a pan, zoom, glide or drag is one state event, once it comes to rest; until then the
2667
3026
  event's camera is where it last rested (handle.state reads it live) */
2668
- const KEYS = ['view', 'navigating', 'selection', 'expanded', 'lens', 'overlays', 'story', 'tour', 'replay', 'camera', 'flow', 'motion', 'gesture', 'destroyed'];
3027
+ const KEYS = ['view', 'navigating', 'selection', 'expanded', 'lens', 'overlays', 'story', 'tour', 'replay', 'paused', 'camera', 'flow', 'shapes', 'motion', 'gesture', 'destroyed'];
2669
3028
  let lastState = null, stateT = 0, camRest = null, restT = 0; // the last state event's snapshot and the flush to come; the camera at rest, and its check to come
2670
3029
  const listensState = () => !!((options.on || {}).state || (subs.state || []).length);
2671
3030
  function touch() { if (!stateT && !dead && listensState()) stateT = setTimeout(flushState, 16); }
@@ -2676,13 +3035,27 @@ export function render(container, model, options = {}) {
2676
3035
  function camMoved() { clearTimeout(restT); restT = !dead && listensState() ? setTimeout(rested, 150) : 0; }
2677
3036
  function rested() { restT = 0; if (dead) return; if (cam && (cam.anim || cam.ptrs.size) || nav) return camMoved(); camRest = camNow(); touch(); }
2678
3037
  let cam = null, geo = null, svg = null, cur = null, sel = null, more = [], busy = false, paused = false, dead = false;
3038
+ // the notation (0.15.0): the host's (render()'s shapes, then handle.shapes) for every view, else each view's own
3039
+ if (options.shapes != null && !noteOf(options.shapes)) warn(`render(…, { shapes: ${JSON.stringify(options.shapes)} }): the notations are ${NOTATIONS.join(', ')}; each view draws its own`);
3040
+ let shapesSet = noteOf(options.shapes);
3041
+ const noteFor = vid => shapesSet || ctx.notationOf(vid);
3042
+ const pic = vid => { // a view's picture in its notation: laid out once, drawn again only when the notation changes
3043
+ const want = noteFor(vid), o = { tour: P.tour !== false, openHint: P.openHint !== false, shapes: want };
3044
+ let c = ctx.geoCache[vid]; if (!c) c = ctx.geoCache[vid] = ctx.buildView(vid, o); else if (c.shapes !== want) Object.assign(c, ctx.drawView(vid, c.g, o));
3045
+ return c; };
3046
+ // each card's look in the notation shown, for what is drawn over the picture: rings, the minimap, a tour's or replay's dot
3047
+ // (in box too: a card's own shape is drawn there)
3048
+ const lookIn = (nt, g) => { const m = new Map(), ports = sidePorts(g); return n => { if (!m.has(n)) m.set(n, cardLook(n, nt, !!(n.kids || n.opens) && P.openHint !== false, ports.get(n))); return m.get(n); }; };
3049
+ const looker = () => lookIn(noteFor(cur), geo);
2679
3050
  const timers = new Set(), later = (f, ms) => { const t = setTimeout(() => { timers.delete(t); if (!dead) f(); }, ms); timers.add(t); return t; };
2680
3051
  const sleeping = new Set(); // a sleep resolves on its own, or when the map is destroyed: nothing that awaits one is left pending
2681
3052
  const sleep = ms => new Promise(r => { if (dead) return r(); const wake = () => { sleeping.delete(wake); r(); }; sleeping.add(wake); later(wake, still() ? 0 : ms); });
2682
3053
  const status = t => P.status ? P.status(t) : tell && tell(t);
2683
3054
  const wide = P.wide || (() => (cam ? cam.size()[0] : stage.clientWidth) > 820);
2684
3055
 
2685
- const upOf = () => (P.upTarget ? P.upTarget() : (ctx.V[cur] || {}).parent) || null;
3056
+ // the views the reader walked through, the one shown last: up goes back along them, then to the model's parent (issue 137)
3057
+ let trail = [];
3058
+ const upOf = () => (P.upTarget ? P.upTarget() : trail.length > 1 ? trail[trail.length - 2] : (ctx.V[cur] || {}).parent) || null;
2686
3059
  const nodeRef = id => ({ type: 'node', id, view: cur });
2687
3060
  const copyRef = r => r && { ...r, ...(Array.isArray(r.ids) ? { ids: [...r.ids] } : {}) }; // a host gets copies: the map's Refs stay its own
2688
3061
  const picked = () => sel ? [...more.map(nodeRef), copyRef(sel)] : []; // every selected thing, the latest last (more: other selected cards)
@@ -2710,6 +3083,7 @@ export function render(container, model, options = {}) {
2710
3083
  const back = focusOf();
2711
3084
  cancelGesture(!!v && vid === cur); // a new picture: a drag or a line under way stops; a name field stays over its card if the view draws it still
2712
3085
  cur = v ? vid : null; if (v) ctx.planOf(vid);
3086
+ if (v) { trail = trail.filter(id => ctx.V[id]); if (!trail.includes(vid)) trail = [vid]; } // the first view, or after an update() took the walk's views away
2713
3087
  stage.querySelectorAll('svg.map-svg, .empty-view').forEach(s => s.remove());
2714
3088
  if (!v || !(v.nodeIds || []).some(i => ctx.N[i])) { // a view with nothing in it, or a model with no views, gets a friendly card, not a blank canvas
2715
3089
  geo = { nodes: [], edges: [], groups: [], lanes: [], families: [], byId: {}, stats: {}, bounds: { x: 0, y: 0, w: 400, h: 300 } };
@@ -2717,7 +3091,7 @@ export function render(container, model, options = {}) {
2717
3091
  const p = v && v.parent;
2718
3092
  stage.insertAdjacentHTML('beforeend', `<div class="empty-view"><div class="ico">◌</div><h4>Nothing here yet</h4><p>${v ? 'This view has no nodes yet.' : 'This map has no views yet.'}</p>${p ? `<button class="btn" data-v="${esc(p)}">← Back to ${esc(String(ctx.V[p].title || p).split(' · ')[0])}</button>` : ''}</div>`);
2719
3093
  } else {
2720
- const c = ctx.geoCache[vid] ||= ctx.buildView(vid, { tour: P.tour !== false, openHint: P.openHint !== false });
3094
+ const c = pic(vid);
2721
3095
  stage.insertAdjacentHTML('afterbegin', c.markup); geo = c.g;
2722
3096
  }
2723
3097
  svg = stage.querySelector('svg'); svg.classList.add('map-svg'); if (paused) svg.pauseAnimations();
@@ -2774,11 +3148,14 @@ export function render(container, model, options = {}) {
2774
3148
  /* minimap */
2775
3149
  const mini = {};
2776
3150
  function buildMini() {
2777
- const b = geo.bounds, el = q('.minimap');
3151
+ const b = geo.bounds, el = q('.minimap'), bw = Math.max(2, b.w / 300), c4 = noteFor(cur) === 'c4';
3152
+ // a box's tone, as its region; and a box dashed in the picture (any box in c4, a toned box in any notation) dashed here
3153
+ const rg = (geo.families || []).filter(f => f.tone || c4).map(f => `<rect x="${f.x}" y="${f.y}" width="${f.w}" height="${f.h}" rx="14" ${f.tone ? `style="--um-tone:${toneOf(f)}" ` : ''}fill="${f.tone ? 'var(--um-tone, var(--um-slate))' : 'none'}" fill-opacity=".14" stroke="${f.tone ? 'var(--um-tone, var(--um-slate))' : 'var(--um-border-2)'}" stroke-width="${bw}" stroke-dasharray="${bw * 4} ${bw * 3}"/>`).join('');
2778
3154
  const ln = (geo.lanes || []).map(l => `<rect x="${l.x}" y="${l.y}" width="${l.w}" height="${l.h}" rx="14" fill="none" stroke="var(--um-border-2)" stroke-width="${Math.max(2, b.w / 300)}"/>`).join('');
2779
- const r = geo.nodes.map(n => `<rect x="${n.x}" y="${n.y}" width="${n.w}" height="${n.h}" rx="8" fill="var(--um-${KIND[n.k][0]})" opacity="${n.k === 'm-public' ? .95 : .55}"/>`).join('');
3155
+ const look = looker(), r = geo.nodes.map(n => { const L = look(n), paint = `fill="var(--um-${KIND[n.k][0]})" opacity="${n.k === 'm-public' ? .95 : .55}"`;
3156
+ return L ? `<path d="${L.S.out(L)}" ${paint}/>` : `<rect x="${n.x}" y="${n.y}" width="${n.w}" height="${n.h}" rx="8" ${paint}/>`; }).join('');
2780
3157
  const l = geo.edges.map(e => `<polyline points="${e.pts.map(p => p.join(',')).join(' ')}" fill="none" stroke="var(--um-arrow)" stroke-width="${Math.max(2, b.w / 260)}" opacity=".6"/>`).join('');
2781
- el.innerHTML = `<svg viewBox="${b.x} ${b.y} ${b.w} ${b.h}" preserveAspectRatio="xMidYMid meet">${ln}${l}${r}<rect class="vp" rx="${b.w / 80}"/></svg>`;
3158
+ el.innerHTML = `<svg viewBox="${b.x} ${b.y} ${b.w} ${b.h}" preserveAspectRatio="xMidYMid meet">${rg}${ln}${l}${r}<rect class="vp" rx="${b.w / 80}"/></svg>`;
2782
3159
  mini.svg = el.firstChild; mini.vp = el.querySelector('.vp');
2783
3160
  mini.vp.setAttribute('stroke-width', Math.max(2, b.w / 160));
2784
3161
  cam && onCam(cam);
@@ -2801,8 +3178,9 @@ export function render(container, model, options = {}) {
2801
3178
  svg.querySelectorAll('.tab-key, .chip-key').forEach(k => k.remove());
2802
3179
  const cards = Object.fromEntries([...svg.querySelectorAll('.node')].map(g => [g.dataset.id, g]));
2803
3180
  svg.querySelectorAll('.family [data-badge]').forEach(t => { if (on) { t.setAttribute('tabindex', '0'); t.setAttribute('role', 'button'); } else { t.removeAttribute('tabindex'); t.removeAttribute('role'); } });
2804
- if (on && geo.map) geo.nodes.forEach(n => { let tx = n.x + 12, html = ''; const card = cards[n.id]; if (!card) return;
2805
- mapTabs(n).forEach(t => { if (t.badge != null) html += `<g class="um-key tab-key" data-node="${esc(n.id)}" data-badge="${t.badge}" tabindex="0" role="button" aria-label="${esc(t.text)}"><rect x="${tx - 2}" y="${n.y - 10}" width="${t.w + 4}" height="19" rx="6" class="key-ring"/></g>`; tx += t.w + 4; });
3181
+ const look = looker();
3182
+ if (on && geo.map) geo.nodes.forEach(n => { let tx = n.x + 12, html = ''; const card = cards[n.id], ty = tabLine(n, look(n)); if (!card) return;
3183
+ mapTabs(n).forEach(t => { if (t.badge != null) html += `<g class="um-key tab-key" data-node="${esc(n.id)}" data-badge="${t.badge}" tabindex="0" role="button" aria-label="${esc(t.text)}"><rect x="${tx - 2}" y="${ty - 10}" width="${t.w + 4}" height="19" rx="6" class="key-ring"/></g>`; tx += t.w + 4; });
2806
3184
  card.querySelectorAll('rect.verb-chip').forEach(r => { const i = +r.dataset.chip, { value } = chipRef(card, i), at = k => +r.getAttribute(k), verb = (n.verbs || [])[i];
2807
3185
  const name = value === verb ? value : `${value} (more: ${(n.verbs || []).slice(i).join(', ')})`; // the last chip may stand for the rest
2808
3186
  html += `<g class="um-key chip-key" data-node="${esc(n.id)}" data-chip="${i}" tabindex="0" role="button" aria-label="${esc(name)}"><rect x="${at('x') - 2}" y="${at('y') - 2}" width="${at('width') + 4}" height="${at('height') + 4}" rx="9" class="key-ring"/></g>`; });
@@ -2908,6 +3286,7 @@ export function render(container, model, options = {}) {
2908
3286
  const mode = modeOf(vid);
2909
3287
  delete asked[prev]; // an unfold asked for on the view left is not waited for (#71)
2910
3288
  if (P.onNavigate) P.onNavigate(vid, prev, mode);
3289
+ const at = trail.indexOf(vid); trail = at >= 0 ? trail.slice(0, at + 1) : trail.length === 1 && vid === (ctx.V[prev] || {}).parent ? [vid] : [...trail, vid];
2911
3290
  mount(vid); emitSel(); left = false;
2912
3291
  const fitS = startView(), back = mode === 'out' ? geo.nodes.find(n => ctx.childOf(n.id, cur) === prev) : null;
2913
3292
  if (prev && !still()) {
@@ -2985,6 +3364,21 @@ export function render(container, model, options = {}) {
2985
3364
  return true;
2986
3365
  }
2987
3366
  function flash(nid) { const g = svg.querySelector(`.node[data-id="${CSS.escape(nid)}"]`); if (!g) return; g.classList.add('flash'); later(() => g.classList.remove('flash'), 1400); }
3367
+ /* the notation every view is drawn in (0.15.0): the host's, a reader's (the page's toggle and link), or null for each
3368
+ view's own. The view shown is drawn again from the geometry it has, with its camera, selection, lens and marks, and
3369
+ nothing is laid out. False for a notation that isn't one (with a warning). */
3370
+ function setShapes(x) {
3371
+ if (dead) return false;
3372
+ const want = x == null ? null : noteOf(x);
3373
+ if (x != null && !want) { warn(`shapes = ${JSON.stringify(x)}: the notations are ${NOTATIONS.join(', ')}`); return false; }
3374
+ shapesSet = want;
3375
+ if (cur && ctx.geoCache[cur] && ctx.geoCache[cur].shapes !== noteFor(cur)) {
3376
+ const keep = sel, keepMore = more, touched = cam.touched;
3377
+ mount(cur); cam.set({}); cam.touched = touched;
3378
+ if (keep && exists(keep)) { sel = keep; more = keep.type === 'node' ? keepMore.filter(id => geo.byId[id]) : []; paint(); }
3379
+ emitSel(); }
3380
+ touch(); return true;
3381
+ }
2988
3382
  function setFlow(on, save = true) { // save: the page keeps it as the reader's choice
2989
3383
  stage.classList.toggle('flowing', on);
2990
3384
  const b = q('[data-z="flow"]'); if (b) { b.classList.toggle('on', on); b.setAttribute('aria-pressed', on); }
@@ -2992,6 +3386,7 @@ export function render(container, model, options = {}) {
2992
3386
  touch();
2993
3387
  }
2994
3388
  function pause(on) { const was = paused; paused = on; stage.classList.toggle('paused', on); if (svg) on ? svg.pauseAnimations() : svg.unpauseAnimations();
3389
+ if (on !== was) touch(); // MapState.paused (0.15.0)
2995
3390
  if (story && on !== was) { if (on) clearTimeout(story.timer); else arm(story); } } // the page's Pause holds every clock: a dot that rides (startTrip), and a story's step, given its time again after
2996
3391
 
2997
3392
  /* keys on the canvas; the page forwards the same keys when the focus is elsewhere */
@@ -3287,7 +3682,7 @@ export function render(container, model, options = {}) {
3287
3682
  let s = svg, g = geo, off = null;
3288
3683
  if (vid !== cur) { // another view: drawn off screen, inside the shadow root so the same styles apply
3289
3684
  const v = ctx.V[vid]; if (!v || !(v.nodeIds || []).some(i => ctx.N[i])) return '';
3290
- const c = ctx.geoCache[vid] ||= ctx.buildView(vid, { tour: P.tour !== false, openHint: P.openHint !== false });
3685
+ const c = pic(vid);
3291
3686
  off = document.createElement('div'); off.style.cssText = 'position:absolute;left:-100000px;top:0;width:10px;height:10px;overflow:hidden;pointer-events:none';
3292
3687
  off.innerHTML = c.markup; stage.parentNode.appendChild(off); s = off.querySelector('svg'); s.classList.add('map-svg'); g = c.g;
3293
3688
  }
@@ -3380,7 +3775,7 @@ export function render(container, model, options = {}) {
3380
3775
  return; }
3381
3776
  if (!ctx.V[cur]) { // the view shown is gone: the root, or the empty card of a model with no views
3382
3777
  const from = cur; mount(ctx.rootId ?? null); cam.set(startView()); cam.touched = false; emitSel(); if (cur) emit('viewchange', { view: cur, from }); return; }
3383
- if (ctx.geoCache[cur] && keys[cur] === ctx.viewKey(cur)) { if (lensId != null) { relens(); drawMarks(); } if (pc) pc.chrome(); return; } // this view's picture did not change (its lens or the views may have)
3778
+ if (ctx.geoCache[cur] && keys[cur] === ctx.viewKey(cur) && ctx.geoCache[cur].shapes === noteFor(cur)) { if (lensId != null) { relens(); drawMarks(); } if (pc) pc.chrome(); if (P.onMount) P.onMount(inner); return; } // this view's picture did not change (its lens or the views may have; the page reads the new model, issue 133)
3384
3779
  const keep = sel, keepMore = more, touched = cam.touched, prev = [geo, svg];
3385
3780
  mount(cur);
3386
3781
  if (touched) cam.set({}); else cam.set(startView());
@@ -3446,12 +3841,15 @@ export function render(container, model, options = {}) {
3446
3841
  const tone = t => 'var(--um-' + String(t || 'accent').replace(/[^\w-]/g, '') + ')', op = m => m.level < 1 ? ` opacity="${m.level}"` : '';
3447
3842
  const cards = Object.fromEntries([...s.querySelectorAll('.node')].map(x => [x.dataset.id, x]));
3448
3843
  let under = '', over = '';
3844
+ const look = lookIn(noteFor(vid), g);
3449
3845
  g.nodes.forEach(n => { const m = markOf2(L => (L.nodes || {})[n.id]), el = cards[n.id]; if (!m || !el) return;
3450
3846
  if (m.level < 1) el.setAttribute('opacity', m.level);
3451
- if (m.tone || m.pulse) under += `<g${op(m)}><rect x="${n.x - 5}" y="${n.y - 5}" width="${n.w + 10}" height="${n.h + 10}" rx="14" class="um-ring${m.pulse ? ' um-pulse' : ''}" stroke="${tone(m.tone)}"/></g>`;
3847
+ const sh = (m.tone || m.pulse) && look(n); // a shape's ring: its outline, 5 px out (0.15.0)
3848
+ if (m.tone || m.pulse) under += `<g${op(m)}>${sh ? `<path d="${sh.S.out(sh, 5)}"` : `<rect x="${n.x - 5}" y="${n.y - 5}" width="${n.w + 10}" height="${n.h + 10}" rx="14"`} class="um-ring${m.pulse ? ' um-pulse' : ''}" stroke="${tone(m.tone)}"/></g>`;
3452
3849
  const b = m.badge == null ? null : badgesOf({ badges: [m.badge] })[0]; if (!b) return; // a tab after the card's own
3453
- const x = mapTabs(n).reduce((a, t) => a + t.w + 4, n.x + 12), w = b.text.toUpperCase().length * 6.4 + 10;
3454
- over += `<g class="um-tab"${op(m)}><rect x="${x}" y="${n.y - 8}" width="${w}" height="15" rx="4" fill="${toneOf(b)}"/><text x="${x + w / 2}" y="${n.y + 3}" text-anchor="middle" class="tab-tx">${esc(b.text)}</text></g>`;
3850
+ const L = look(n), ty = tabLine(n, L), w = b.text.toUpperCase().length * 6.4 + 10; // on the card's tab line, clear of a head and a folder's tab
3851
+ const x = tabAfter(n, L, w);
3852
+ over += `<g class="um-tab"${op(m)}><rect x="${x}" y="${ty - 8}" width="${w}" height="15" rx="4" fill="${toneOf(b)}"/><text x="${x + w / 2}" y="${ty + 3}" text-anchor="middle" class="tab-tx">${esc(b.text)}</text></g>`;
3455
3853
  el.setAttribute('aria-description', b.text); });
3456
3854
  const heads = (g.families || []).flatMap(f => [{ x: f.x + 8, y: f.y + 3, w: String(f.label || f.id).length * 6.6 + 8, h: 22 }, // a box's title, 11 px mono
3457
3855
  ...(f.tabs || []).map(t => ({ x: t.x, y: f.y - 8, w: t.w, h: 16 }))]); // and its tabs
@@ -3546,7 +3944,8 @@ export function render(container, model, options = {}) {
3546
3944
  return t;
3547
3945
  }
3548
3946
  const lineBetween = (a, b) => { let i = geo.edges.findIndex(e => e.from === a && e.to === b); if (i < 0) i = geo.edges.findIndex(e => e.from === b && e.to === a); // either way
3549
- return i < 0 ? null : { i, pts: geo.edges[i].from === a ? geo.edges[i].pts : [...geo.edges[i].pts].reverse() }; };
3947
+ if (i < 0) return null; const pts = drawnPts(geo.edges[i], looker()); // as drawn: into a shape at its outline (0.15.0)
3948
+ return { i, pts: geo.edges[i].from === a ? pts : [...pts].reverse() }; };
3550
3949
  const lenOf = pts => pts.slice(1).reduce((s, p, k) => s + Math.hypot(p[0] - pts[k][0], p[1] - pts[k][1]), 0);
3551
3950
  const along = (pts, d) => { for (let k = 1; k < pts.length; k++) { const a = pts[k - 1], b = pts[k], l = Math.hypot(b[0] - a[0], b[1] - a[1]);
3552
3951
  if (d <= l || k === pts.length - 1) { const f = l ? Math.min(1, d / l) : 1; return [a[0] + (b[0] - a[0]) * f, a[1] + (b[1] - a[1]) * f]; } d -= l; } return pts[0]; };
@@ -3746,7 +4145,7 @@ export function render(container, model, options = {}) {
3746
4145
 
3747
4146
  const inner = { ctx: () => ctx, geo: () => geo, cam: () => cam, svg: () => svg, stage, host, go, open, up, flash, setFlow, pause, key, startView, standalone, play,
3748
4147
  select, busy: () => busy, flowing: () => stage.classList.contains('flowing'), view: () => cur, unfold: gesture, lens: setLens,
3749
- highlight, overlay, focus: focusMap, editable: !!E }; // a story's pointer is the pieces' own, from the story event (0.14.0)
4148
+ highlight, overlay, focus: focusMap, editable: !!E, shapes: () => shapesNow(), setShapes, override: () => shapesSet }; // a story's pointer is the pieces' own, from the story event (0.14.0)
3750
4149
 
3751
4150
  // the pieces a host asked for (#99): they follow the map's selection and views, and take their keys wherever the focus is
3752
4151
  // in the map (Enter and Space press their buttons; the map's other keys reach the canvas, as in the page)
@@ -3758,12 +4157,13 @@ export function render(container, model, options = {}) {
3758
4157
  if (options.nav) { q('.um-navbtn').addEventListener('click', () => pc.setTree(!frame.classList.contains('tree-open'))); pc.setTree(ctx.views.length > 1); }
3759
4158
  }
3760
4159
  const motionOf = () => instant ? 'instant' : reducedMotion() ? 'reduced' : 'full';
4160
+ const shapesNow = () => cur ? noteFor(cur) : shapesSet || noteOf(ctx.D.shapes) || 'box';
3761
4161
  const camNow = () => { const r = (x, d) => Math.round(x * d) / d; return cam ? { x: r(cam.s.cx, 10), y: r(cam.s.cy, 10), zoom: r(cam.s.k, 1000) } : { x: 0, y: 0, zoom: 1 }; };
3762
4162
  function snapshot(event = false) { // MapState: built fresh on every read, frozen all the way down, plain JSON; event: the camera at rest
3763
4163
  const deep = o => { Object.values(o).forEach(v => v && typeof v === 'object' && deep(v)); return Object.freeze(o); }, st = story, t = trip;
3764
4164
  return deep({ view: cur, navigating: nav ? nav.to : null, selection: picked(), expanded: [...(expanded[cur] || [])], lens: lensId, overlays: [...layers.keys()],
3765
- story: st ? { id: st.s.id, step: st.at, steps: st.steps.length, playing: !!st.auto } : null, tour: !!(t && t.kind === 'tour'), replay: t && t.kind === 'replay' ? { playing: !t.paused } : null,
3766
- camera: event && camRest ? { ...camRest } : camNow(), flow: stage.classList.contains('flowing'), motion: motionOf(), gesture: gestureNow(), destroyed: dead });
4165
+ story: st ? { id: st.s.id, step: st.at, steps: st.steps.length, playing: !!st.auto } : null, tour: !!(t && t.kind === 'tour'), replay: t && t.kind === 'replay' ? { playing: !t.paused } : null, paused: !!paused,
4166
+ camera: event && camRest ? { ...camRest } : camNow(), flow: stage.classList.contains('flowing'), shapes: shapesNow(), motion: motionOf(), gesture: gestureNow(), destroyed: dead });
3767
4167
  }
3768
4168
  const first = () => {
3769
4169
  if (dead) return;
@@ -3802,6 +4202,8 @@ export function render(container, model, options = {}) {
3802
4202
  get destroyed() { return dead; },
3803
4203
  get flow() { return stage.classList.contains('flowing'); }, // the animated flow along lines (≋, F)
3804
4204
  set flow(on) { if (!dead) setFlow(!!on, false); }, // a host's choice, not the reader's: the page saves only ≋ and F
4205
+ get shapes() { return shapesNow(); }, // the notation the view shown is drawn in: 'box', 'c4' or 'uml' (0.15.0)
4206
+ set shapes(x) { setShapes(x); }, // every view in this one (null: each view's own); the cards are drawn again, nothing is laid out
3805
4207
  get motion() { return motionOf(); }, // 'instant' (the page's #check, #still, #shot), 'reduced' (the reader's setting), else 'full'
3806
4208
  get story() { return story ? story.ctl : null; }, // the player of the story shown (play()'s, the page menu's or a link's), else null
3807
4209
  overlay: (name, layer) => overlay(name, layer),
@@ -3885,7 +4287,7 @@ export function describe(model, viewId) {
3885
4287
  // a line's kind as its colour says it: the one it names, else a hook's line guards and a board's is state (no guesses from words)
3886
4288
  const kind = e => WIRE_KINDS.includes(e.kind) ? e.kind : (ctx.N[e.from] || {}).kind === 'hook' ? 'guard' : [e.from, e.to].some(i => (ctx.N[i] || {}).kind === 'board') ? 'state' : 'calls';
3887
4289
  const VERB = { calls: 'calls', feeds: 'feeds', triggers: 'triggers', state: 'reads or writes', guard: 'guards' };
3888
- const what = e => { const t = [e.label && `"${e.label}"`, e.sublabel].filter(Boolean); return t.length ? ` (${t.join(', ')})` : ''; };
4290
+ const what = e => { const t = [e.label && `"${e.label}"`, e.sublabel, v.render !== 'sequence' && e.step != null && `step ${e.step}`].filter(Boolean); return t.length ? ` (${t.join(', ')})` : ''; };
3889
4291
  const out = [`${v.title || v.id}: ${count(ids.length, 'card')} and ${count(edges.length, 'line')}.`];
3890
4292
  if (ids.length) out.push(`Cards: ${ids.map(id => `${name(id)} (${KIND[kindOf(ctx.nodeIn(id, viewId).kind, true)][1]})`).join(', ')}.`);
3891
4293
  boxes.forEach(c => { const ins = (c.nodeIds || []).filter(i => ids.includes(i)); if (ins.length) out.push(`${name(c.id)} holds ${list(ins.map(name))}.`); });
@@ -4031,7 +4433,7 @@ export function mountPage(container, model, options = {}) {
4031
4433
  <span class="mbrand"><i></i><span>${esc(D.title || 'Map')}</span></span>
4032
4434
  <nav class="crumbs" id="m-crumbs" aria-label="Breadcrumb"></nav>
4033
4435
  <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>
4034
- <button class="ibtn" id="m-theme" title="Light / dark" aria-label="Toggle theme">◐</button>${(D.stories || []).length ? `
4436
+ <button class="ibtn" id="m-theme" title="Light / dark" aria-label="Dark theme" aria-pressed="false">◐</button><button class="ibtn txt" id="m-shapes" type="button" title="Card shapes: box, c4 or uml">⬡<span class="lbl"></span></button>${(D.stories || []).length ? `
4035
4437
  <div class="menu"><button class="ibtn txt" id="m-stories" aria-haspopup="true" aria-expanded="false" title="Stories: walk-throughs of this map">▶<span class="lbl"> Stories</span></button>
4036
4438
  <div class="menu-pop" id="m-stories-pop" hidden>${D.stories.map(s => `<button data-story="${esc(s && s.id)}">${esc((s && (s.title || s.id)) || '')}</button>`).join('')}</div></div>` : ''}${arrOf(D.lenses).some(l => l && l.id) ? `
4037
4439
  <div class="menu"><button class="ibtn txt" id="m-lenses" aria-haspopup="true" aria-expanded="false" title="Lenses: ways to look at this map">◎<span class="lbl"> Lenses</span></button>
@@ -4048,7 +4450,7 @@ export function mountPage(container, model, options = {}) {
4048
4450
  <div class="ghosts" id="m-ghosts" aria-hidden="true">${ghost}</div>
4049
4451
  <div class="vpill" id="m-vpill"></div>
4050
4452
  <div class="m-legend" id="m-legend"></div>
4051
- <div class="hint" id="m-hint" role="status" aria-live="polite">${HINT}</div>
4453
+ <div class="hint" id="m-hint">${HINT}</div>
4052
4454
  </div>
4053
4455
  <aside class="panel" id="m-panel" aria-label="Node details" hidden></aside>
4054
4456
  </div>
@@ -4089,15 +4491,16 @@ export function mountPage(container, model, options = {}) {
4089
4491
  const setTree = pc.setTree;
4090
4492
 
4091
4493
  /* the link (0.14.0): one place writes it, from what the map shows: its view, card, story step and lens, and the reader
4092
- copy, theme, check and shot the page was opened with (the theme as it is now, after ◐ too). A new view is a new entry,
4494
+ copy and theme the page was opened with (the theme as it is now, after ◐ too). A new view is a new entry,
4093
4495
  written once the move lands; anything else rewrites the entry. Nothing is written while a link is being followed */
4094
4496
  function write(push = false) {
4095
4497
  if (!X || linking || handle.destroyed) return;
4096
4498
  const p = new URLSearchParams(); p.set('view', cur); if (sel) p.set('node', sel);
4097
4499
  if (told) { p.set('story', told.id); p.set('step', told.step + 1); } // the story shown, at its step (#69)
4098
4500
  if (lensOn) p.set('lens', lensOn); // the lens on the map (#94)
4501
+ if (X.override()) p.set('shapes', X.override()); // the notation a reader or the link picked (0.15.0)
4099
4502
  if (Q.get('theme')) p.set('theme', document.documentElement.dataset.theme || Q.get('theme'));
4100
- ['reader', 'check', 'shot'].forEach(k => Q.get(k) && p.set(k, Q.get(k)));
4503
+ if (Q.get('reader')) p.set('reader', Q.get('reader')); // check, shot and still describe one run: never in the link a reader shares (issue 136)
4101
4504
  const h = '#' + p.toString();
4102
4505
  if (location.hash !== h) window.history[push ? 'pushState' : 'replaceState'](null, '', h); // window's: history() is this module's undo helper
4103
4506
  }
@@ -4108,6 +4511,8 @@ export function mountPage(container, model, options = {}) {
4108
4511
  const idle = () => new Promise(r => { const t = () => X.busy() ? setTimeout(t, 30) : r(); t(); }); // once no move is under way
4109
4512
  async function follow(load = false) {
4110
4513
  const p = new URLSearchParams(location.hash.slice(1)), my = ++follows, lacks = [], stories = arrOf(D.stories);
4514
+ if (noteOf(p.get('shapes')) && noteOf(p.get('shapes')) !== handle.shapes) handle.shapes = p.get('shapes'); // the link's card shapes (0.15.0)
4515
+ else if (p.get('shapes') && !noteOf(p.get('shapes'))) lacks.push(`no card shapes “${p.get('shapes')}”`);
4111
4516
  const lens = p.get('lens') || null, sid = p.get('story'), at = Math.max(1, Math.floor(+p.get('step')) || 1) - 1;
4112
4517
  const def = sid ? stories.find(s => s && s.id === sid) : null, steps = def ? arrOf(def.steps).filter(s => s && typeof s === 'object') : [], step = steps[Math.min(at, steps.length - 1)];
4113
4518
  linking = { view: null };
@@ -4160,7 +4565,9 @@ export function mountPage(container, model, options = {}) {
4160
4565
  let narrow = innerWidth <= 820;
4161
4566
  addEventListener('resize', () => { const n = innerWidth <= 820; if (n && !narrow) setTree(false); narrow = n; }, { signal });
4162
4567
  $('m-help').addEventListener('click', () => toggleHelp());
4163
- $('m-theme').addEventListener('click', () => { toggleTheme(); write(); }); // a link that names the theme names the one shown
4568
+ const themed = () => $('m-theme').setAttribute('aria-pressed', (document.documentElement.dataset.theme || (matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light')) === 'dark'); // ◐ says which theme is on (issue 135)
4569
+ themed(); matchMedia('(prefers-color-scheme: dark)').addEventListener('change', themed, { signal }); // the system's theme, when the page sets none
4570
+ $('m-theme').addEventListener('click', () => { toggleTheme(); themed(); write(); }); // a link that names the theme names the one shown
4164
4571
  $('m-pause').addEventListener('click', e => { paused = !paused; const b = e.currentTarget, l = paused ? 'Play' : 'Pause'; X.pause(paused); b.setAttribute('aria-pressed', paused); b.title = l; b.setAttribute('aria-label', l); b.textContent = paused ? '▶' : '❚❚'; });
4165
4572
  $('m-export-pop').addEventListener('click', e => { const x = e.target.closest('[data-x]')?.dataset.x; if (!x) return; openMenu(null); exportView(x); });
4166
4573
 
@@ -4229,9 +4636,9 @@ export function mountPage(container, model, options = {}) {
4229
4636
  }
4230
4637
  const recordSoon = () => { if (rec && !handle.story) { clearTimeout(rec.t); rec.t = setTimeout(() => rec && !handle.story && rec.take(), 650); } }; // once the camera has landed; not a story's steps
4231
4638
 
4232
- /* ⌘K command box: nodes and views, Enter jumps */
4233
- const nodeIdx = nodes.map(n => ({ n, hay: [n.label, n.id, (n.verbs || []).join(' '), n.what, n.kind, n.author, ((D.authors || {})[n.author] || {}).name, n.origin].join(' ').toLowerCase() }));
4234
- const viewIdx = views.map(v => ({ v, hay: [v.title, v.id, v.caption].join(' ').toLowerCase() }));
4639
+ /* ⌘K command box: nodes and views, Enter jumps. Indexed from the model shown, as an update() leaves it (issue 133) */
4640
+ const nodeIdx = () => nodes.map(n => ({ n, hay: [n.label, n.id, (n.verbs || []).join(' '), n.what, n.kind, n.author, ((D.authors || {})[n.author] || {}).name, n.origin].join(' ').toLowerCase() }));
4641
+ const viewIdx = () => views.map(v => ({ v, hay: [v.title, v.id, v.caption].join(' ').toLowerCase() }));
4235
4642
  const pal = $('m-pal'), q = $('m-q'), res = $('m-res');
4236
4643
  let hits = [], hi = 0;
4237
4644
  const mark = (s, words) => { let out = esc(s); words.filter(w => w.length > 1).forEach(w => { out = out.replace(new RegExp('(' + w.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') + ')', 'ig'), '<mark>$1</mark>'); }); return out; };
@@ -4242,8 +4649,8 @@ export function mountPage(container, model, options = {}) {
4242
4649
  hits = [...kidsOf(cur), ...views.filter(v => v.id !== cur && !kidsOf(cur).includes(v))].slice(0, 8).map(v => ({ t: 'v', v }));
4243
4650
  } else {
4244
4651
  const starts = s => String(s || '').toLowerCase().startsWith(qs) ? 0 : 1;
4245
- const ns = nodeIdx.filter(x => words.every(w => x.hay.includes(w))).sort((a, b) => starts(a.n.label) - starts(b.n.label) || (geo.byId[b.n.id] ? 1 : 0) - (geo.byId[a.n.id] ? 1 : 0)).slice(0, 7).map(x => ({ t: 'n', n: x.n }));
4246
- const vs = viewIdx.filter(x => words.every(w => x.hay.includes(w))).slice(0, 5).map(x => ({ t: 'v', v: x.v }));
4652
+ const ns = nodeIdx().filter(x => words.every(w => x.hay.includes(w))).sort((a, b) => starts(a.n.label) - starts(b.n.label) || (geo.byId[b.n.id] ? 1 : 0) - (geo.byId[a.n.id] ? 1 : 0)).slice(0, 7).map(x => ({ t: 'n', n: x.n }));
4653
+ const vs = viewIdx().filter(x => words.every(w => x.hay.includes(w))).slice(0, 5).map(x => ({ t: 'v', v: x.v }));
4247
4654
  hits = [...ns, ...vs];
4248
4655
  }
4249
4656
  hi = Math.min(hi, Math.max(0, hits.length - 1));
@@ -4336,8 +4743,10 @@ export function mountPage(container, model, options = {}) {
4336
4743
  let flowPref = null; try { flowPref = reducedMotion() ? null : localStorage.getItem('visual-flow'); } catch (_) {} // the reader's ≋ and F; under reduced motion no flow is drawn
4337
4744
  const p0 = new URLSearchParams(location.hash.slice(1));
4338
4745
  const v0 = V[p0.get('view')] ? p0.get('view') : rootId;
4746
+ const shapesKey = 'visual-shapes:' + location.pathname; // the reader's notation, kept for this page (0.15.0)
4747
+ const shapes0 = noteOf(p0.get('shapes')) || (() => { try { return noteOf(localStorage.getItem(shapesKey)); } catch (_) { return null; } })() || noteOf(options.shapes);
4339
4748
  const handle = render($('m-canvas'), model, {
4340
- view: v0, minimap: true, flow: flowPref ? flowPref === '1' : undefined,
4749
+ view: v0, minimap: true, flow: flowPref ? flowPref === '1' : undefined, shapes: shapes0 || undefined,
4341
4750
  on: { story: e => { told = e.step == null ? null : { id: e.story, step: e.step }; app.classList.toggle('story', !!told); pc.story(e);
4342
4751
  const sv = told && stepView(told); if (X && !X.busy() && (!sv || sv === cur || !V[sv])) write(); } }, // a step on another view: in that view's entry, once it shows (#69)
4343
4752
  _page: {
@@ -4364,6 +4773,13 @@ export function mountPage(container, model, options = {}) {
4364
4773
  if (linking && linking.view === e.view) return; // a link's own move: its entry, written once the link has settled
4365
4774
  linking = null; write(true); }); // a new view, a new entry (a move the reader started while a link was followed, too)
4366
4775
  handle.on('lens', e => { lensOn = e.lens; showLens(); write(); });
4776
+ // the header's card shapes (0.15.0): box, c4 and uml in turn, for every view; the page keeps the reader's choice
4777
+ const showShapes = () => { const b = $('m-shapes'), n = handle.shapes, next = NOTATIONS[(NOTATIONS.indexOf(n) + 1) % NOTATIONS.length];
4778
+ b.querySelector('.lbl').textContent = ' ' + n; b.setAttribute('aria-label', `Card shapes: ${n}. Show them as ${next}`); };
4779
+ $('m-shapes').addEventListener('click', () => { const next = NOTATIONS[(NOTATIONS.indexOf(handle.shapes) + 1) % NOTATIONS.length];
4780
+ handle.shapes = next; try { localStorage.setItem(shapesKey, next); } catch (_) {} showShapes(); write(); status(`Card shapes: ${next}`); });
4781
+ handle.on('state', e => { if (e.changed.includes('shapes')) showShapes(); });
4782
+ showShapes();
4367
4783
  // #pdf=1 (umlimator.py pdf, #93): the page becomes a document for Chrome to print (printDoc), once the fonts are in,
4368
4784
  // after the map's own redraw for them
4369
4785
  if (Q.get('pdf')) (document.fonts ? document.fonts.ready : Promise.resolve()).then(() => printDoc(X, model, Q));
@@ -4466,12 +4882,12 @@ function printDoc(X, model, Q) {
4466
4882
  ...(rs.length ? ['<tr><td>Rules</td><td class="pg" data-pg="rules"></td></tr>'] : [])] }]);
4467
4883
 
4468
4884
  shown.forEach(([v, svg], vi) => {
4469
- const g = geoCache[v.id].g, ids = cards(v), es = lines(v), seq = v.render === 'sequence', at = Object.fromEntries(g.nodes.map(n => [n.id, n]));
4885
+ const g = geoCache[v.id].g, ids = cards(v), es = lines(v), seq = v.render === 'sequence', stepped = es.some(e => e.step != null), at = Object.fromEntries(g.nodes.map(n => [n.id, n]));
4470
4886
  const wk = new Map(g.edges.flatMap(d => edgeRefIds(d).map(id => [id, d.wk]))), held = boxes(v).map(c => [c, arrOf(c.nodeIds).filter(i => ids.includes(i))]).filter(([, ins]) => ins.length);
4471
4887
  const up = chain(v.id).slice(0, -1).map(id => `<span class="up">${T(V[id])} › </span>`).join('');
4472
4888
  const head = more => `<header>${more ? `<div class="h2">${up}${T(v)} <span class="up">(continued)</span></div>` : `<h2>${up}${T(v)}</h2>`}<span class="meta">${[count(ids.length, 'card'), count(es.length, 'line'), ...(held.length ? [count(held.length, 'box', 'boxes')] : [])].join(' · ')}</span></header>${!more && v.caption ? `<p class="cap">${esc(v.caption)}</p>` : ''}`;
4473
4889
  const body = sheet(head(false), vi);
4474
- body.insertAdjacentHTML('beforeend', `<figure>${svg}</figure><div class="lg"><b>Legend</b>${legendItems([g], 'opens a view of its own').join('')}</div>`);
4890
+ body.insertAdjacentHTML('beforeend', `<figure>${svg}</figure><div class="lg"><b>Legend</b>${legendItems([g], 'opens a view of its own', [geoCache[v.id].shapes], D.icons).join('')}</div>`);
4475
4891
  // the picture as large as the page allows, never larger than drawn: below it the legend, then two columns of tables;
4476
4892
  // one too tall for that keeps the page's height, with the legend and one column beside it. Smaller (down to 70%, and
4477
4893
  // to a scale of 0.6 at least) when that keeps the tables on its page; else they go on over the next pages.
@@ -4485,8 +4901,8 @@ function printDoc(X, model, Q) {
4485
4901
  { title: `Cards <span>${ids.length}</span>`, cls: 'cards', head: '<th>Card</th><th>Kind</th><th>What it is</th>', rows: ids.map(id => { const c = nodeIn(id, v.id), t = KIND[kindOf(c.kind, true)], d = at[id] || {}, sub = c.sublabel ?? c.sub;
4486
4902
  return `<tr><td>${d.step ? `<i class="num">${esc(d.step)}</i>` : ''}<b>${esc(c.label || id)}</b>${d.kids || d.opens ? ' ⤢' : ''}${sub ? `<small>${esc(sub)}</small>` : ''}</td>`
4487
4903
  + `<td><i class="sw" style="border-color:var(--um-${t[0]});background:var(--um-${t[0]}-f)"></i>${t[1]}</td><td>${c.what ? md(c.what) : ''}</td></tr>`; }) },
4488
- { title: `Lines <span>${es.length}</span>`, cls: 'lines', head: `${seq ? '<th class="n">#</th>' : ''}<th>From → to</th><th>Label</th><th>Technology</th>`, rows: es.map((e, i) => { const w = wk.get(e._id) || 'calls';
4489
- return `<tr>${seq ? `<td>${i + 1}</td>` : ''}<td><i class="w" style="border-color:var(--um-w-${w})"></i>${name(v, e.from)} → ${name(v, e.to)}</td><td>${e.label ? esc(e.label) : `<span class="up">${w}</span>`}</td><td>${esc(e.sublabel || '')}</td></tr>`; }) },
4904
+ { title: `Lines <span>${es.length}</span>`, cls: 'lines', head: `${seq || stepped ? '<th class="n">#</th>' : ''}<th>From → to</th><th>Label</th><th>Technology</th>`, rows: (stepped && !seq ? [...es].sort((a, b) => stepOrder(a.step, b.step)) : es).map((e, i) => { const w = wk.get(e._id) || 'calls';
4905
+ return `<tr>${seq ? `<td>${i + 1}</td>` : stepped ? `<td>${esc(e.step ?? '')}</td>` : ''}<td><i class="w" style="border-color:var(--um-w-${w})"></i>${name(v, e.from)} → ${name(v, e.to)}</td><td>${e.label ? esc(e.label) : `<span class="up">${w}</span>`}</td><td>${esc(e.sublabel || '')}</td></tr>`; }) },
4490
4906
  { title: `Boxes <span>${held.length}</span>`, cls: 'boxes', head: '<th>Box</th><th>Holds</th>', rows: held.map(([c, ins]) => `<tr><td><b>${esc(c.label || c.id)}</b></td><td>${andList(ins.map(i => name(v, i)))}</td></tr>`) },
4491
4907
  ].filter(x => x.rows.length);
4492
4908
  [1, .9, .8, .7].some(f => k * f >= Math.min(k, .6) && lay(f)) || lay(1, true);
@@ -4517,7 +4933,9 @@ function printDoc(X, model, Q) {
4517
4933
  const shortT = t => String(t || '').split(' · ')[0];
4518
4934
  const hintHTML = (keys, ctrl) => typeof matchMedia === 'function' && matchMedia('(hover: none)').matches ? '<span>pinch = zoom</span><span>drag = move</span><span>tap = details</span>'
4519
4935
  : `<span>${ctrl ? 'Ctrl + scroll' : 'scroll'} = zoom</span><span>drag = move</span><span>double-click ⤢ = open</span>${keys ? `<span>${keys} = search</span><span>? = keys</span>` : ''}`; // keys: the page's search key; ctrl: wheel 'ctrl'
4520
- function hinter(el, html) { let t; return (s, ms = 1800) => { el.textContent = s; el.classList.add('msg'); clearTimeout(t); t = setTimeout(() => { el.innerHTML = html; el.classList.remove('msg'); }, ms); }; }
4936
+ function hinter(el, html) { // messages are said in a live region of their own, apart from the key hints (issue 134)
4937
+ let t; const live = document.createElement('div'); live.className = 'um-live'; live.setAttribute('role', 'status'); live.setAttribute('aria-live', 'polite'); el.after(live);
4938
+ return (s, ms = 1800) => { el.textContent = live.textContent = s; el.classList.add('msg'); clearTimeout(t); t = setTimeout(() => { el.innerHTML = html; el.classList.remove('msg'); }, ms); }; }
4521
4939
 
4522
4940
  // Pull concise points from captured prose; retain wording and keep the full point in its tooltip. A point is a sentence
4523
4941
  // or a clause; one longer than a line (160 characters) is cut in two, but a short sentence stays whole (#97).
@@ -4539,11 +4957,31 @@ function detailBullets(body) {
4539
4957
  return parts;
4540
4958
  }
4541
4959
 
4542
- function legendItems(gs, opens = 'double-click to open') { // what these pictures draw, each with what it means: the view's legend, and the PDF's (#93)
4960
+ // what each shape stands for, in a legend (0.15.0)
4961
+ const SHAPE_SAYS = { person: ['Person', 'someone who uses it'], browser: ['Browser app', 'runs in a web browser'], mobile: ['Mobile app', 'runs on a phone'],
4962
+ cylinder: ['Data store', 'a database or a store'], pipe: ['Queue', 'hands work on, in order'], document: ['File', 'a document or a config'],
4963
+ hexagon: ['Hexagon', 'a guard or a service'], robot: ['Agent', 'an AI agent'], terminal: ['Command line', 'a CLI or a script'],
4964
+ window: ['Desktop app', 'runs in a window'], folder: ['Package', 'a module or a folder of code'], node: ['Host', 'a machine or runtime it runs on'],
4965
+ component: ['Component', 'a part that runs (UML)'] };
4966
+ const swatchOf = (sh, k, c, f, dashed) => { const n = { x: 0, y: 0, w: 100, h: 64 }, S = SHAPE[sh]; // a legend's key: the card's outline, small
4967
+ return `<svg class="lgs" viewBox="-4 -4 108 72" aria-hidden="true"><path d="${S.out(n)}" fill="${f}" stroke="${c}"${dashed ? ' stroke-dasharray="3 2"' : ''}/>${S.det ? S.det(n, c, k) : ''}</svg>`; };
4968
+ function legendItems(gs, opens = 'double-click to open', notes = [], icons = {}) { // what these pictures draw, each with what it means: the view's legend, and the PDF's (#93). notes: each picture's notation (0.15.0)
4543
4969
  const ns = gs.flatMap(g => g.nodes), es = gs.flatMap(g => g.edges), used = [...new Set(ns.map(n => n.k))];
4970
+ // each card as its picture's notation draws it: its shape, its dash and its mark (the legend draws what the map draws)
4971
+ const cards = gs.flatMap((g, i) => { const nt = g.map ? noteOf(notes[i]) || 'box' : 'box';
4972
+ return g.nodes.map(n => { const sh = shapeOf(n, nt), I = (icons || {})[n.icon]; // an icon the table lacks leaves the card the component mark, as svgOf draws it
4973
+ return { k: n.k, sh, dash: nt !== 'box' && n.k === 'external', comp: nt === 'uml' && sh === 'box' && !(I && (I.src || I.g)) && !String(n.k).startsWith('m-') }; }); });
4974
+ const kindShape = k => { const s = [...new Set(cards.filter(d => d.k === k).map(d => d.sh))]; return s.length === 1 && s[0] !== 'box' && !(k === 'm-hook' && s[0] === 'hexagon') ? s[0] : null; };
4975
+ const dashOf = k => k === 'm-ext' || cards.some(d => d.k === k && d.dash); // an external card is dashed only where it is drawn so
4544
4976
  const order = ['m-public', 'm-step', 'm-lane', 'm-ext', 'm-verb', 'm-board', 'm-hook', 'm-script', 'frontend', 'backend', 'ai', 'queue', 'database', 'security', 'cloud', 'external', 'file'];
4545
- const shape = k => k === 'm-verb' ? 'p' : (k === 'm-ext' || k === 'external') ? 'd' : k === 'm-hook' ? 'h' : '';
4546
- return order.filter(k => used.includes(k)).map(k => `<span class="lgi" title="${esc(KIND[k][1] + ' — ' + KIND[k][2])}"><i class="${shape(k)}" style="border-color:var(--um-${KIND[k][0]});background:var(--um-${KIND[k][0]}-f)${k === 'm-hook' ? `;background:var(--um-${KIND[k][0]})` : ''}"></i><b>${KIND[k][1]}</b><span>${KIND[k][2]}</span></span>`)
4977
+ const shape = k => k === 'm-verb' ? 'p' : dashOf(k) ? 'd' : k === 'm-hook' ? 'h' : '';
4978
+ const kinds = order.filter(k => used.includes(k)), own = new Set(kinds.map(kindShape).filter(Boolean)), tone = k => `var(--um-${KIND[k][0]})`;
4979
+ // a shape the kinds' keys don't show already: in a neutral outline, as colour is the kinds' (and UML's component mark)
4980
+ const shapes = SHAPE_NAMES.filter(sh => sh !== 'box' && !own.has(sh) && cards.some(d => d.sh === sh && !(sh === 'hexagon' && d.k === 'm-hook')))
4981
+ .map(sh => `<span class="lgi" title="${esc(SHAPE_SAYS[sh].join(' — '))}">${swatchOf(sh, 'file', 'var(--um-muted)', 'var(--um-panel)')}<b>${SHAPE_SAYS[sh][0]}</b><span>${SHAPE_SAYS[sh][1]}</span></span>`)
4982
+ .concat(cards.some(d => d.comp) ? [`<span class="lgi" title="${esc(SHAPE_SAYS.component.join(' — '))}"><svg class="lgs" viewBox="0 0 16 16" aria-hidden="true"><g fill="none" stroke="var(--um-muted)" stroke-linecap="round" stroke-linejoin="round">${SIGIL.component}</g></svg><b>${SHAPE_SAYS.component[0]}</b><span>${SHAPE_SAYS.component[1]}</span></span>`] : []);
4983
+ return kinds.map(k => `<span class="lgi" title="${esc(KIND[k][1] + ' — ' + KIND[k][2])}">${kindShape(k) ? swatchOf(kindShape(k), k, tone(k), `var(--um-${KIND[k][0]}-f)`, dashOf(k)) : `<i class="${shape(k)}" style="border-color:${tone(k)};background:var(--um-${KIND[k][0]}-f)${k === 'm-hook' ? `;background:${tone(k)}` : ''}"></i>`}<b>${KIND[k][1]}</b><span>${KIND[k][2]}</span></span>`)
4984
+ .concat(shapes)
4547
4985
  .concat(ns.some(n => n.kids || n.opens) ? [`<span class="lgi"><i class="s"></i><b>⤢ Stacked</b><span>${opens}</span></span>`] : [])
4548
4986
  .concat(ns.some(n => n.step) ? ['<span class="lgi"><i class="num">1</i><b>Step</b><span>run order</span></span>'] : [])
4549
4987
  .concat(ns.some(n => n.tag) ? ['<span class="lgi"><i class="tag"></i><b>@author</b><span>who wrote it</span></span>'] : [])
@@ -4560,7 +4998,7 @@ function pieces(X, el, o) {
4560
4998
 
4561
4999
  /* the legend: the kinds of cards and lines the view draws, in a box that folds away */
4562
5000
  function legend() {
4563
- const items = legendItems([X().geo()]), back = el.legend.contains(el.legend.getRootNode().activeElement);
5001
+ const items = legendItems([X().geo()], undefined, [X().shapes ? X().shapes() : 'box'], X().ctx().D.icons), back = el.legend.contains(el.legend.getRootNode().activeElement);
4564
5002
  el.legend.hidden = !items.length;
4565
5003
  el.legend.classList.toggle('folded', folded);
4566
5004
  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('')}`;