umlimator 0.14.0 → 0.15.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/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); }
@@ -339,9 +356,11 @@ const STYLES = `
339
356
  .um-lens b { font: 700 10px var(--um-sans); letter-spacing: .1em; text-transform: uppercase; color: var(--um-muted); } .um-lens span { white-space: nowrap; }
340
357
  .um-lens i { display: inline-block; width: 8px; height: 8px; border: 2.5px solid; border-radius: 4px; margin-right: 6px; vertical-align: -1px; }
341
358
  ` +/* each tone's tint (--um-<tone>-f), mixed at the map from the tone and panel in effect there: mixed on :root (as the theme
342
- file does, for page chrome) a tint keeps the tone it was mixed from, whatever an ancestor of the map sets later */
359
+ file does, for page chrome) a tint keeps the tone it was mixed from, whatever an ancestor of the map sets later. A kind's
360
+ tint (every tone but the accent) is --um-tint times as deep: 1 by default, 0 for none, never past 18%, where a card's
361
+ sub-line would fall below 4.5:1 */
343
362
  `:host { ${Object.entries({ accent: 12, cyan: 9, green: 9, violet: 9, amber: 10, rose: 8, orange: 9, slate: 8, fuchsia: 8, 'k-public': 8, 'k-lane': 8,
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(' ')} }`;
363
+ 'k-ext': 7, 'k-verb': 7, 'k-script': 5, 'k-hook': 12, 'k-board': 9 }).map(([t, p]) => `--um-${t}-f: color-mix(in srgb, var(--um-${t}) ${t === 'accent' ? p + '%' : `min(${p}% * var(--um-tint, 1), 18%)`}, var(--um-panel));`).join(' ')} }`;
345
364
 
346
365
  const esc = s => String(s ?? '').replace(/[&<>"']/g, c => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c]));
347
366
  const inline = s => esc(s)
@@ -368,8 +387,8 @@ const KIND = {
368
387
  'm-step': ['k-public', 'Process step', 'open for implementation details'],
369
388
  };
370
389
  const MAPK = { /* umlimator */ public: 'm-public', lane: 'm-lane', script: 'm-script', 'skill-dep': 'm-ext', dep: 'm-ext', verb: 'm-verb', step: 'm-step', hook: 'm-hook', guard: 'm-hook', board: 'm-board', state: 'm-board' };
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',
390
+ const ALIAS = { client: 'frontend', ui: 'frontend', web: 'frontend', page: 'frontend', browser: 'frontend', app: 'frontend', person: 'frontend', mobile: 'frontend',
391
+ api: 'backend', service: 'backend', server: 'backend', function: 'backend', endpoint: 'backend', route: 'backend', script: 'backend', cli: 'backend', worker: 'backend', module: 'backend', package: 'backend',
373
392
  db: 'database', store: 'database', storage: 'database', cache: 'database', table: 'database', data: 'database',
374
393
  infra: 'cloud', cdn: 'cloud', deploy: 'cloud', host: 'cloud', ci: 'cloud',
375
394
  auth: 'security', secret: 'security', policy: 'security', guard: 'security', hook: 'security',
@@ -377,7 +396,8 @@ const ALIAS = { client: 'frontend', ui: 'frontend', web: 'frontend', page: 'fron
377
396
  user: 'external', human: 'external', thirdparty: 'external', vendor: 'external',
378
397
  doc: 'file', config: 'file', skill: 'file', md: 'file', template: 'file', output: 'file',
379
398
  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')); };
399
+ const hasKey = (o, k) => Object.hasOwn(o, k); // a table's own key: "constructor" or "__proto__" is no kind, shape or notation
400
+ const kindOf = (k, map) => { k = String(k || '').toLowerCase(); if (map && hasKey(MAPK, k)) return MAPK[k]; return hasKey(KIND, k) ? k : hasKey(ALIAS, k) ? ALIAS[k] : map ? 'm-script' : 'backend'; };
381
401
  const SIGIL = {
382
402
  frontend: '<rect x="2" y="3" width="12" height="8.5" rx="1.2"/><path d="M5.5 14h5M8 11.5V14"/>',
383
403
  backend: '<rect x="2.5" y="2.5" width="11" height="4.5" rx="1"/><rect x="2.5" y="9" width="11" height="4.5" rx="1"/><path d="M5 4.75h.01M5 11.25h.01"/>',
@@ -396,11 +416,203 @@ const SIGIL = {
396
416
  'm-verb': '<path d="M3 13 9.5 6.5M8 3l.7 1.6L10.3 5.3 8.7 6 8 7.6 7.3 6 5.7 5.3 7.3 4.6zM12 8.5l.45 1.05L13.5 10l-1.05.45L12 11.5l-.45-1.05L10.5 10l1.05-.45z"/>',
397
417
  'm-step': '<path d="M2 8h11M9 4l4 4-4 4"/>',
398
418
  'm-board': '<rect x="2" y="2.5" width="12" height="11" rx="1.5"/><path d="M6 2.5v11M10 2.5v11"/><path d="M3.4 5.2h1.2M7.4 5.2h1.2M7.4 7.6h1.2M11.4 5.2h1.2"/>',
419
+ // a shape's own mark (0.15.0), for a card with no icon whose shape says more than its kind does
420
+ person: '<circle cx="8" cy="5.2" r="2.7"/><path d="M3 13.6c.5-2.7 2.5-4.2 5-4.2s4.5 1.5 5 4.2"/>',
421
+ robot: '<rect x="3" y="5.2" width="10" height="8" rx="1.8"/><path d="M8 5.2V3M6.2 9.2h.01M9.8 9.2h.01M1.6 9.2h1.4M13 9.2h1.4"/>',
422
+ mobile: '<rect x="4.4" y="1.8" width="7.2" height="12.4" rx="1.5"/><path d="M7.3 11.9h1.4"/>',
423
+ terminal: '<rect x="1.8" y="2.6" width="12.4" height="10.8" rx="1.6"/><path d="m4.6 6.2 2 1.8-2 1.8M8.4 10.2h3"/>',
424
+ folder: '<path d="M2 4.3c0-.7.5-1.2 1.2-1.2h3l1.4 1.6h5.2c.7 0 1.2.5 1.2 1.2v6.2c0 .7-.5 1.2-1.2 1.2H3.2C2.5 13.3 2 12.8 2 12.1Z"/>',
425
+ node: '<path d="M2.4 5.6 5 3h8.6v7.4L11 13H2.4Z"/><path d="M2.4 5.6H11V13M11 5.6 13.6 3"/>',
426
+ // UML's component icon (§11.6.4): a box's mark in the uml notation
427
+ component: '<path d="M4.5 4.5V2.3a.8.8 0 0 1 .8-.8h7.9a.8.8 0 0 1 .8.8v11.4a.8.8 0 0 1-.8.8H5.3a.8.8 0 0 1-.8-.8v-2.2M4.5 7v2"/><rect x="2" y="4.5" width="5" height="2.5" rx=".6"/><rect x="2" y="9" width="5" height="2.5" rx=".6"/>',
399
428
  };
429
+ /* ================= card shapes (0.15.0) =================
430
+ A notation draws each card as a shape: box (the default: every card a rounded box, as before), c4 (people, browser apps,
431
+ phones, databases, queues and files as the C4 model's tools draw them) or uml (UML 2.5's actor, node, artifact, package
432
+ and data store, and the component icon as a box's mark). A card's own `shape` wins in any notation. Each shape is drawn
433
+ inside its card's own box, so layout, routing, widths and the layout check never see it: an outline (for the mask, the
434
+ body, the stack, rings, the minimap and the glow), a face (the rows its mark and text use), details drawn over the body
435
+ (one of them its accent, which lights as something arrives) and inset(side, t), how far inside the box's side the
436
+ outline runs at t, which slides a line's end from the box onto the outline. */
437
+ const NOTATIONS = ['box', 'c4', 'uml'];
438
+ // what a notation draws: by the kind word the model wrote, then by the kind it means (docs/module-api.md, 0.15.0)
439
+ const NOTED = {
440
+ c4: { person: 'person', user: 'person', human: 'person', mobile: 'mobile', agent: 'robot', cli: 'terminal',
441
+ frontend: 'browser', database: 'cylinder', queue: 'pipe', file: 'document', 'm-hook': 'hexagon' },
442
+ uml: { person: 'person', user: 'person', human: 'person', agent: 'robot', module: 'folder', package: 'folder',
443
+ cloud: 'node', database: 'cylinder', file: 'document', 'm-hook': 'hexagon' },
444
+ };
445
+ const r2 = v => Math.round(v * 100) / 100;
446
+ function rrect(x, y, w, h, r) { // a rounded rect's path; r: one radius, or [top left, top right, bottom right, bottom left]
447
+ const [a, b, c, d] = Array.isArray(r) ? r : [r, r, r, r], A = (q, X, Y) => q ? `A${r2(q)} ${r2(q)} 0 0 1 ${r2(X)} ${r2(Y)}` : '';
448
+ return `M${r2(x + a)} ${r2(y)}H${r2(x + w - b)}${A(b, x + w, y + b)}V${r2(y + h - c)}${A(c, x + w - c, y + h)}H${r2(x + d)}${A(d, x, y + h - d)}V${r2(y + a)}${A(a, x + a, y)}Z`;
449
+ }
450
+ const cornerIn = (r, t, a, b) => t < a + r ? r - Math.sqrt(Math.max(0, r * r - (a + r - t) ** 2)) : t > b - r ? r - Math.sqrt(Math.max(0, r * r - (t - b + r) ** 2)) : 0;
451
+ const rectIn = (n, s, t, r) => s === 'L' || s === 'R' ? cornerIn(r, t, n.y, n.y + n.h) : cornerIn(r, t, n.x, n.x + n.w);
452
+ const ellIn = (e, u, r) => e * (1 - Math.sqrt(Math.max(0, 1 - (u / r) ** 2))); // an ellipse's depth at u from its axis
453
+ const grown = (n, d) => ({ ...n, x: n.x - d, y: n.y - d, w: n.w + 2 * d, h: n.h + 2 * d });
454
+ const deeper = (k, p) => `color-mix(in srgb, var(--um-${KIND[k][0]}) ${p}%, var(--um-${KIND[k][0]}-f))`; // a detail's tint, over the card's own
455
+ const BAR = 14; // a window's title bar: browser, window, terminal
456
+ function chrome(n, c, k, what) { // a tinted title bar, three dots after the card's tabs, then an address field (browser) or a prompt (terminal)
457
+ const { x, y, w } = n, x0 = Math.max(x, n.tabEnd || x), end = n.chromeEnd || x + w - 10;
458
+ const dots = [9, 15.5, 22].map(d => `<circle cx="${r2(x0 + d)}" cy="${y + 7}" r="2.1" class="n-dot depth-read" fill="${c}"/>`).join('');
459
+ const more = what === 'browser' && end - x0 - 30 > 24 ? `<path d="${rrect(x0 + 30, y + 3.5, end - x0 - 30, 7, 3.5)}" class="n-field depth-read" stroke="${c}"/>`
460
+ : what === 'terminal' && end - x0 - 30 > 14 ? `<text x="${r2(x0 + 31)}" y="${y + 10.6}" class="n-prompt" fill="${c}">&gt;_</text>` : '';
461
+ return `<path d="${rrect(x + .75, y + .75, w - 1.5, BAR - .75, [9.25, 9.25, 0, 0])}" fill="${deeper(k, 10)}"/><path d="M${x} ${y + BAR}H${x + w}" class="n-det n-accent" stroke="${c}"/>${dots}${more}`;
462
+ }
463
+ // where a person's or robot's body starts: b px down, or just above the highest line or feed that ends on its left or right
464
+ // side (n.portTop, from the lines as laid out), so each one lands on its outline
465
+ const bodyTop = (n, b) => Math.min(n.y + b, (n.portTop ?? Infinity) - 1);
466
+ // each card's highest line end on its left side and on its right (a view's feeds end on the left), as laid out
467
+ function sidePorts(g) {
468
+ const m = new Map(), at = (n, side, y) => { if (!n || (side !== 'L' && side !== 'R')) return; const o = m.get(n) || { L: Infinity, R: Infinity }; o[side] = Math.min(o[side], y); m.set(n, o); };
469
+ (g.edges || []).forEach(e => { if (e.pts && e.pts.length) { at(e.s, e.fs, e.pts[0][1]); at(e.t, e.ts, e.pts[e.pts.length - 1][1]); } });
470
+ (g.intake || []).forEach(b => at((g.byId || {})[b.to], 'L', b.pts[b.pts.length - 1][1]));
471
+ return m;
472
+ }
473
+ const boxOut = r => (n, d = 0) => { const g = grown(n, d); return rrect(g.x, g.y, g.w, g.h, r + d); };
474
+ const SHAPE = {
475
+ box: { out: boxOut(10), face: n => [n.y, n.y + n.h], inset: (n, s, t) => rectIn(n, s, t, 10) },
476
+ person: { // the C4 model's person: a head over a body with wide, flat shoulders. The body starts 12 px down, or higher where
477
+ // a line ends on its side above that (bodyTop)
478
+ head: 13,
479
+ g(n, d = 0) { const r = 13 + d, cx = n.hx ?? n.x + n.w / 2, hy = n.y + 13, bt = bodyTop(n, 12) - d, sx = 20 + d, sy = 12 + d; return { r, cx, hy, bt, sx, sy, k: Math.sqrt(Math.max(0, r * r - (bt - hy) ** 2)) }; },
480
+ out(n, d = 0) { const { r, cx, hy, bt, sx, sy, k } = this.g(n, d), x = n.x - d, w = n.w + 2 * d, b = n.y + n.h + d, R = 10 + d;
481
+ return `M${r2(x)} ${r2(bt + sy)}A${sx} ${sy} 0 0 1 ${r2(x + sx)} ${r2(bt)}H${r2(cx - k)}A${r} ${r} 0 ${bt > hy ? 1 : 0} 1 ${r2(cx + k)} ${r2(bt)}H${r2(x + w - sx)}A${sx} ${sy} 0 0 1 ${r2(x + w)} ${r2(bt + sy)}V${r2(b - R)}A${R} ${R} 0 0 1 ${r2(x + w - R)} ${r2(b)}H${r2(x + R)}A${R} ${R} 0 0 1 ${r2(x)} ${r2(b - R)}Z`; },
482
+ face: n => [bodyTop(n, 12), n.y + n.h], tabY: n => bodyTop(n, 12),
483
+ det(n, c) { const { r, cx, hy, bt, k } = this.g(n); return `<path d="M${r2(cx - k)} ${r2(bt)}A${r} ${r} 0 ${bt < hy ? 1 : 0} 0 ${r2(cx + k)} ${r2(bt)}" class="n-det n-accent" stroke="${c}"/>`; },
484
+ inset(n, s, t) { const { r, cx, hy, bt, sx, sy } = this.g(n);
485
+ if (s === 'L' || s === 'R') return t < bt ? null : t < bt + sy ? ellIn(sx, bt + sy - t, sy) : rectIn(n, s, t, 10);
486
+ if (s === 'B') return rectIn(n, s, t, 10);
487
+ const u = t - cx; if (Math.abs(u) < r) return Math.min(hy - Math.sqrt(r * r - u * u), bt) - n.y;
488
+ const e = t < n.x + sx ? n.x + sx - t : t > n.x + n.w - sx ? t - n.x - n.w + sx : 0; return bt - n.y + ellIn(sy, e, sx); } },
489
+ robot: { // Structurizr's robot, and UML's non-human actor: the person's body under a square head with ears
490
+ head: 19,
491
+ // a body that starts higher than 14 px down (bodyTop) has a head as much shorter, its ears and corners with it
492
+ g(n, d = 0) { const B = bodyTop(n, 14), f = (B - n.y) / 14; return { cx: n.hx ?? n.x + n.w / 2, hw: 14 + d, ht: n.y - d, bt: B - d, ew: 19 + d, e0: n.y + 4 * f - d, e1: n.y + 10 * f + d, S: 8 + d, hr: 5 * f + d, ey: n.y + 7.5 * f }; },
493
+ out(n, d = 0) { const { cx, hw, ht, bt, ew, e0, e1, S, hr } = this.g(n, d), x = n.x - d, w = n.w + 2 * d, b = n.y + n.h + d, R = 10 + d;
494
+ return `M${r2(x)} ${r2(bt + S)}A${S} ${S} 0 0 1 ${r2(x + S)} ${r2(bt)}H${r2(cx - hw)}V${r2(e1)}H${r2(cx - ew)}V${r2(e0)}H${r2(cx - hw)}V${r2(ht + hr)}A${hr} ${hr} 0 0 1 ${r2(cx - hw + hr)} ${r2(ht)}H${r2(cx + hw - hr)}A${hr} ${hr} 0 0 1 ${r2(cx + hw)} ${r2(ht + hr)}V${r2(e0)}H${r2(cx + ew)}V${r2(e1)}H${r2(cx + hw)}V${r2(bt)}H${r2(x + w - S)}A${S} ${S} 0 0 1 ${r2(x + w)} ${r2(bt + S)}V${r2(b - R)}A${R} ${R} 0 0 1 ${r2(x + w - R)} ${r2(b)}H${r2(x + R)}A${R} ${R} 0 0 1 ${r2(x)} ${r2(b - R)}Z`; },
495
+ face: n => [bodyTop(n, 14), n.y + n.h], tabY: n => bodyTop(n, 14),
496
+ det(n, c) { const { cx, ey } = this.g(n); return [-5.5, 5.5].map(o => `<circle cx="${r2(cx + o)}" cy="${r2(ey)}" r="1.6" class="n-det n-accent" fill="${c}" stroke="${c}"/>`).join(''); },
497
+ inset(n, s, t) { const { cx, hw, ew, e0, bt, S, hr } = this.g(n);
498
+ if (s === 'L' || s === 'R') return t < bt ? null : t < bt + S ? S - Math.sqrt(S * S - (bt + S - t) ** 2) : rectIn(n, s, t, 10);
499
+ if (s === 'B') return rectIn(n, s, t, 10);
500
+ const u = Math.abs(t - cx); return u < hw ? cornerIn(hr, u, -1e6, hw) : u < ew ? e0 - n.y : bt - n.y + cornerIn(S, t, n.x, n.x + n.w); } },
501
+ browser: { out: boxOut(10), face: n => [n.y + BAR, n.y + n.h], inset: (n, s, t) => rectIn(n, s, t, 10), det: (n, c, k) => chrome(n, c, k, 'browser') },
502
+ window: { out: boxOut(10), face: n => [n.y + BAR, n.y + n.h], inset: (n, s, t) => rectIn(n, s, t, 10), det: (n, c, k) => chrome(n, c, k, 'window') },
503
+ terminal: { out: boxOut(10), face: n => [n.y + BAR, n.y + n.h], inset: (n, s, t) => rectIn(n, s, t, 10), det: (n, c, k) => chrome(n, c, k, 'terminal') },
504
+ mobile: { // a phone on its side, as the C4 model's example draws its mobile app (MobileDeviceLandscape): a screen, a home button, a speaker
505
+ out: boxOut(14), face: n => [n.y, n.y + n.h], inset: (n, s, t) => rectIn(n, s, t, 14),
506
+ det(n, c) { const { x, y, w, h } = n, cy = y + h / 2;
507
+ return `<path d="${rrect(x + 11, y + 5, w - 22, h - 10, 5)}" class="n-screen n-accent" stroke="${c}"/><circle cx="${x + 6.6}" cy="${r2(cy)}" r="2.1" class="n-det" stroke="${c}"/><path d="M${r2(x + w - 6.2)} ${r2(cy - 7)}V${r2(cy + 7)}" class="n-det n-speaker" stroke="${c}"/>`; } },
508
+ cylinder: { // a data store: its lid tinted, the lid's front edge its accent
509
+ ry: n => Math.max(5, Math.round(n.h * .11)),
510
+ out(n, d = 0) { const ry = this.ry(n), { x, y, w, h } = grown(n, d); return `M${r2(x)} ${r2(y + ry)}A${r2(w / 2)} ${ry} 0 0 1 ${r2(x + w)} ${r2(y + ry)}V${r2(y + h - ry)}A${r2(w / 2)} ${ry} 0 0 1 ${r2(x)} ${r2(y + h - ry)}Z`; },
511
+ face(n) { return [n.y + this.ry(n), n.y + n.h]; },
512
+ det(n, c, k) { const ry = this.ry(n), { x, y, w } = n;
513
+ return `<ellipse cx="${r2(x + w / 2)}" cy="${y + ry}" rx="${r2(w / 2 - .75)}" ry="${ry - .75}" fill="${deeper(k, 10)}"/><path d="M${x} ${y + ry}A${r2(w / 2)} ${ry} 0 0 0 ${r2(x + w)} ${y + ry}" class="n-det n-accent" stroke="${c}"/>`; },
514
+ inset(n, s, t) { const ry = this.ry(n), rx = n.w / 2; if (s === 'T' || s === 'B') return ellIn(ry, t - n.x - rx, rx);
515
+ const a = n.y + ry, b = n.y + n.h - ry; return t >= a && t <= b ? 0 : ellIn(rx, t < a ? a - t : t - b, ry); } },
516
+ pipe: { // a queue: a cylinder on its side, open at the right where the work leaves; dots on its floor move with the flow
517
+ e: n => Math.max(5, Math.round(n.h * .11)),
518
+ out(n, d = 0) { const e = this.e(n), { x, y, w, h } = grown(n, d); return `M${r2(x + e)} ${r2(y)}H${r2(x + w - e)}A${e} ${r2(h / 2)} 0 0 1 ${r2(x + w - e)} ${r2(y + h)}H${r2(x + e)}A${e} ${r2(h / 2)} 0 0 1 ${r2(x + e)} ${r2(y)}Z`; },
519
+ face: n => [n.y, n.y + n.h],
520
+ det(n, c, k) { const e = this.e(n), { x, y, w, h } = n;
521
+ return `<ellipse cx="${x + w - e}" cy="${r2(y + h / 2)}" rx="${e - .75}" ry="${r2(h / 2 - .75)}" fill="${deeper(k, 10)}"/><path d="M${x + w - e} ${y}A${e} ${r2(h / 2)} 0 0 0 ${x + w - e} ${y + h}" class="n-det n-accent" stroke="${c}"/><path d="M${x + e + 6} ${r2(y + h - 6.5)}H${x + w - 2 * e - 6}" class="n-floor" stroke="${c}"/>`; },
522
+ inset(n, s, t) { const e = this.e(n), ry = n.h / 2; if (s === 'L' || s === 'R') return ellIn(e, t - n.y - ry, ry);
523
+ const a = n.x + e, b = n.x + n.w - e; return t >= a && t <= b ? 0 : ellIn(ry, t < a ? a - t : t - b, e); } },
524
+ document: { // a file: UML's artifact, a page with its corner folded (and umlimator's own file sigil)
525
+ out(n, d = 0) { const { x, y, w, h } = grown(n, d), R = 10 + d, f = 10;
526
+ return `M${r2(x + R)} ${r2(y)}H${r2(x + w - f)}L${r2(x + w)} ${r2(y + f)}V${r2(y + h - R)}A${R} ${R} 0 0 1 ${r2(x + w - R)} ${r2(y + h)}H${r2(x + R)}A${R} ${R} 0 0 1 ${r2(x)} ${r2(y + h - R)}V${r2(y + R)}A${R} ${R} 0 0 1 ${r2(x + R)} ${r2(y)}Z`; },
527
+ face: n => [n.y, n.y + n.h],
528
+ det(n, c, k) { const { x, y, w } = n; return `<path d="M${x + w - 10} ${y}V${y + 7.5}Q${x + w - 10} ${y + 10} ${x + w - 7.5} ${y + 10}H${x + w}Z" fill="${deeper(k, 16)}" class="n-det n-accent" stroke="${c}"/>`; },
529
+ inset(n, s, t) { if (s === 'R' && t < n.y + 10) return 10 - (t - n.y); if (s === 'T' && t > n.x + n.w - 10) return t - (n.x + n.w - 10); return rectIn(n, s, t, 10); } },
530
+ hexagon: { // the hook card's hexagon, for any card
531
+ hx: n => Math.min(16, n.h / 3),
532
+ out(n, d = 0) { const hx = this.hx(n), { x, y, w, h } = grown(n, d); return `M${r2(x + hx)} ${r2(y)}H${r2(x + w - hx)}L${r2(x + w)} ${r2(y + h / 2)}L${r2(x + w - hx)} ${r2(y + h)}H${r2(x + hx)}L${r2(x)} ${r2(y + h / 2)}Z`; },
533
+ face: n => [n.y, n.y + n.h],
534
+ inset(n, s, t) { const hx = this.hx(n); if (s === 'L' || s === 'R') return hx * Math.abs(t - n.y - n.h / 2) / (n.h / 2);
535
+ const a = n.x + hx, b = n.x + n.w - hx; return t < a ? (a - t) * n.h / 2 / hx : t > b ? (t - b) * n.h / 2 / hx : 0; } },
536
+ folder: { // UML's package: a folder with its tab on top, after the card's own tabs
537
+ tw: n => Math.min(64, Math.round(n.w * .34)),
538
+ out(n, d = 0) { const tw = this.tw(n), x0 = n.tabX ?? n.x, { x, y, w, h } = grown(n, d), r = 4 + d, R = 8 + d, th = 9, X0 = x0 - d, T1 = x0 + tw + d;
539
+ const head = X0 <= x + .5 ? `M${r2(x)} ${r2(y + h - R)}V${r2(y + r)}A${r} ${r} 0 0 1 ${r2(x + r)} ${r2(y)}`
540
+ : `M${r2(x)} ${r2(y + h - R)}V${r2(y + th + R)}A${R} ${R} 0 0 1 ${r2(x + R)} ${r2(y + th)}H${r2(X0)}V${r2(y + r)}A${r} ${r} 0 0 1 ${r2(X0 + r)} ${r2(y)}`;
541
+ return `${head}H${r2(T1 - 3)}Q${r2(T1)} ${r2(y)} ${r2(T1 + 1.5)} ${r2(y + 2.5)}L${r2(T1 + 5)} ${r2(y + th)}H${r2(x + w - R)}A${R} ${R} 0 0 1 ${r2(x + w)} ${r2(y + th + R)}V${r2(y + h - R)}A${R} ${R} 0 0 1 ${r2(x + w - R)} ${r2(y + h)}H${r2(x + R)}A${R} ${R} 0 0 1 ${r2(x)} ${r2(y + h - R)}Z`; },
542
+ face: n => [n.y + 9, n.y + n.h], tabY: n => n.y + 9,
543
+ det(n, c, k) { const tw = this.tw(n), x0 = n.tabX ?? n.x;
544
+ return `<path d="M${r2(x0 + .75)} ${n.y + 9}V${n.y + 4}A3.25 3.25 0 0 1 ${r2(x0 + 4)} ${n.y + .75}H${r2(x0 + tw - 3)}Q${r2(x0 + tw)} ${n.y + .75} ${r2(x0 + tw + 1.5)} ${n.y + 3}L${r2(x0 + tw + 4.4)} ${n.y + 9}Z" fill="${deeper(k, 14)}"/><path d="M${r2(x0)} ${n.y + 9}H${r2(x0 + tw + 5)}" class="n-det n-accent" stroke="${c}"/>`; },
545
+ inset(n, s, t) { const tw = this.tw(n), x0 = n.tabX ?? n.x;
546
+ if (s === 'T') return t >= x0 && t <= x0 + tw ? 0 : 9 + cornerIn(8, t, x0 > n.x + .5 ? n.x : -1e6, n.x + n.w);
547
+ if (s === 'B') return rectIn(n, s, t, 8);
548
+ // the side's top: a tab at the card's left corner has a 4 px corner at the top; elsewhere the body starts 9 px down, 8 px round
549
+ const top = s === 'L' && x0 <= n.x + .5 ? cornerIn(4, t, n.y, 1e6) : t < n.y + 9 ? null : cornerIn(8, t, n.y + 9, 1e6);
550
+ return top == null || top > 0 ? top : cornerIn(8, t, -1e6, n.y + n.h); } },
551
+ node: { // UML's node: a box seen in perspective, for hosts and devices
552
+ out(n, d = 0) { const p = 7, { x, y, w, h } = grown(n, d); return `M${r2(x)} ${r2(y + p)}L${r2(x + p)} ${r2(y)}H${r2(x + w)}V${r2(y + h - p)}L${r2(x + w - p)} ${r2(y + h)}H${r2(x)}Z`; },
553
+ face: n => [n.y + 7, n.y + n.h],
554
+ det(n, c, k) { const p = 7, { x, y, w, h } = n;
555
+ return `<path d="M${x + 1} ${y + p}L${x + p} ${y + 1}H${x + w - 1}L${x + w - p} ${y + p}Z" fill="${deeper(k, 12)}"/><path d="M${x + w - p} ${y + p}L${x + w - 1} ${y + 1}V${y + h - p}L${x + w - p} ${y + h - 1}Z" fill="${deeper(k, 22)}"/>`
556
+ + `<path d="M${x} ${y + p}H${x + w - p}V${y + h}" class="n-det n-accent" stroke="${c}"/><path d="M${x + w - p} ${y + p}L${x + w} ${y}" class="n-det" stroke="${c}"/>`; },
557
+ inset(n, s, t) { const p = 7; return s === 'L' ? Math.max(0, n.y + p - t) : s === 'R' ? Math.max(0, t - n.y - n.h + p) : s === 'T' ? Math.max(0, n.x + p - t) : Math.max(0, t - n.x - n.w + p); } },
558
+ };
559
+ const SHAPE_NAMES = ['box', 'person', 'browser', 'mobile', 'cylinder', 'pipe', 'document', 'hexagon', 'robot', 'terminal', 'window', 'folder', 'node']; // all 13
560
+ // a card's shape in a notation: its own `shape` first; box draws every card as before (a hook stays a hexagon there too)
561
+ const shapeOf = (n, notation) => { const sh = String(n.shape ?? '').toLowerCase(); if (SHAPE_NAMES.includes(sh)) return sh;
562
+ const T = hasKey(NOTED, notation) && NOTED[notation], k = String(n.kind ?? '').toLowerCase(); return T ? (hasKey(T, k) && T[k]) || (hasKey(T, n.k) && T[n.k]) || 'box' : 'box'; };
563
+ const noteOf = x => NOTATIONS.includes(x) ? x : null;
564
+ // a card as its notation draws it (null for a box): its shape, and where its head, folder tab and title bar go, clear of its
565
+ // tabs, ⤢ and change badge. Drawing only: layout never sees it. ports: the card's highest line ends on its sides (sidePorts)
566
+ function cardLook(n, notation, hint, ports) {
567
+ const sh = shapeOf(n, notation); if (sh === 'box') return null;
568
+ const S = SHAPE[sh], L = { ...n, sh, S, portTop: ports ? Math.min(ports.L, ports.R) : Infinity, portL: ports ? ports.L : Infinity }, tabs = mapTabs(n), spans = []; let tx = n.x + 12;
569
+ tabs.forEach(t => { spans.push([tx, tx + t.w]); tx += t.w + 4; });
570
+ L.tabEnd = tabs.length ? tx - 2 : 0;
571
+ const right = []; if (hint) right.push([n.x + n.w - 44, n.x + n.w - 8]);
572
+ const ch = CHG[String(n.change || '').toLowerCase()]; if (ch) { const bw = ch[1].length * 6 + 12, bx = n.x + n.w - bw - 8 - (hint ? 40 : 0); right.push([bx, bx + bw]); }
573
+ L.chromeEnd = Math.min(n.x + n.w - 10, ...right.map(s => s[0] - 6));
574
+ const half = (S.head || 0) + 4;
575
+ if (S.head) { // a head in the middle, unless a tab or a badge is there: then in the widest gap they leave
576
+ const all = [...spans, ...right], lo = n.x + 22, hi = n.x + n.w - 22, mid = n.x + n.w / 2;
577
+ let gaps = [[lo, hi]]; all.forEach(([a, b]) => { gaps = gaps.flatMap(([p, q]) => b <= p || a >= q ? [[p, q]] : [[p, a], [b, q]].filter(([u, v]) => v > u)); });
578
+ const fits = gaps.filter(([p, q]) => q - p >= 2 * half), best = fits.find(([p, q]) => p <= mid - half && q >= mid + half) ? [mid - half, mid + half]
579
+ : fits.sort((u, v) => Math.abs((u[0] + u[1]) / 2 - mid) - Math.abs((v[0] + v[1]) / 2 - mid))[0];
580
+ L.hx = best ? Math.min(Math.max(mid, best[0] + half), best[1] - half) : mid;
581
+ }
582
+ if (sh === 'folder') L.tabX = tabs.length ? Math.min(L.tabEnd + 4, n.x + n.w - SHAPE.folder.tw(n) - 14) : n.x;
583
+ // what else is on the tab line after the card's own tabs: ⤢, the change badge, a head (a robot's with its ears), a folder's tab
584
+ L.busy = [...right, ...(S.head ? [[L.hx - S.head, L.hx + S.head]] : []), ...(sh === 'folder' ? [[L.tabX, L.tabX + SHAPE.folder.tw(n) + 5]] : [])];
585
+ return L;
586
+ }
587
+ // where a tab w wide goes after the card's own tabs (an overlay's or a lens's badge): on a shape, the first place inside the
588
+ // card 2 px clear of what L.busy names, else the place inside it that covers least of them; on a box, or where the card's
589
+ // own tabs fill it, right after them
590
+ function tabAfter(n, L, w) {
591
+ const x0 = mapTabs(n).reduce((a, t) => a + t.w + 4, n.x + 12); if (!L) return x0;
592
+ const max = n.x + n.w - 2 - w, over = x => L.busy.reduce((s, [a, b]) => s + Math.max(0, Math.min(x + w, b + 2) - Math.max(x, a - 2)), 0);
593
+ return [x0, ...L.busy.map(b => b[1] + 4), max].filter(x => x >= x0 && x <= max).reduce((b, x) => over(x) < over(b) ? x : b, x0);
594
+ }
595
+ // the line a card's tabs, ⤢, change badge and step sit on: a shape's top (a person's shoulders), else the box's
596
+ const tabLine = (n, L) => L && L.S.tabY ? L.S.tabY(L) : n.y;
597
+ const SIDE_OUT = { L: [-1, 0], R: [1, 0], T: [0, -1], B: [0, 1] };
598
+ // a line as drawn: its ends slid from the card's box onto the card's outline, along the side's normal (the stub lengthens
599
+ // by as much); layout's points stay as they are. look(n): the card's look, or null for a box
600
+ function drawnPts(e, look) {
601
+ const a = e.s && !e.s.box ? look(e.s) : null, b = e.t && !e.t.box ? look(e.t) : null;
602
+ if (!a && !b) return e.pts;
603
+ const slide = (L, side, p) => { const [ox, oy] = SIDE_OUT[side] || [0, 0], k = L.S.inset(L, side, side === 'L' || side === 'R' ? p[1] : p[0]) || 0; return [r2(p[0] - ox * k), r2(p[1] - oy * k)]; };
604
+ const pts = e.pts.map(p => p.slice());
605
+ if (a) pts[0] = slide(a, e.fs, pts[0]);
606
+ if (b) pts[pts.length - 1] = slide(b, e.ts, pts[pts.length - 1]);
607
+ return pts;
608
+ }
400
609
  const CHG = { added: ['green', 'NEW'], new: ['green', 'NEW'], modified: ['amber', 'CHANGED'], changed: ['amber', 'CHANGED'], removed: ['rose', 'REMOVED'], planned: ['cyan', 'PLANNED'] };
401
610
  const NW = 168, CG0 = 84, RG0 = 44;
402
611
  const pillW = (label, sub, max = 440) => Math.max(sansW(fit(label, 10.5, max), 10.5, .92), sub ? sansW(fit(sub, 9.5, max), 9.5, .92) : 0) + 18; // a line's label, and its sublabel under it (#44)
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)
612
+ const stepW = s => String(s).length > 1 ? Math.max(20, sansW(String(s), 9.5, .9) + 8) : 16; // a line's step mark: a 16 px circle, or a pill for 2a, 12 or 4.1 (#82)
613
+ // steps in order: numbers by value, text by its leading number and then the rest (2 < 2a < 2b < 3); no step last
614
+ const stepKey = s => { const t = String(s ?? ''), m = /^\d+(?:\.\d+)?/.exec(t); return [s == null ? 1 : 0, m ? +m[0] : Infinity, m ? t.slice(m[0].length) : t]; };
615
+ const stepOrder = (a, b) => { const p = stepKey(a), q = stepKey(b); return p[0] - q[0] || p[1] - q[1] || (p[2] < q[2] ? -1 : p[2] > q[2] ? 1 : 0); };
404
616
  // Text width. Without a measurer: a monospace estimate (font-independent, so layouts are the same on every machine;
405
617
  // the bench and layout() in Node use it). render() sets measureText to the host's own font while it lays out a view.
406
618
  let measureText = null;
@@ -678,7 +890,8 @@ function layoutPass(dg, map, room, last) { // room: { x: { col: px }, y: { row:
678
890
  const boxed = new Set((dg.containers || []).flatMap(f => f.nodeIds || [])); // a box's row keeps to the box's columns
679
891
  nodes.forEach(n => { n.aside = map && !dg.abs && !ends.has(n.id) && n.row > low && !boxed.has(n.id); });
680
892
  cols.forEach(c => { const ns = nodes.filter(n => n.col === c && !n.aside); if (ns.length) colW[c] = Math.max(...ns.map(n => n.w)); });
681
- // a labelled line between neighbouring columns gets a gap wide enough for its label and 27 px of line on each side
893
+ // a labelled line between neighbouring columns gets a gap wide enough for its label and 27 px of line on each side (a
894
+ // numbered line with no label: for its step mark)
682
895
  const gapAfter = {}, colOf = {}, boxOf = id => ((dg.containers || []).filter(f => (f.nodeIds || []).includes(id)).pop() || {}).id; if (map && !dg.abs) { nodes.forEach(n => colOf[n.id] = n.col);
683
896
  // labelled lines that leave (or reach) one card side by side run 16 px apart, too close for their pills to stack,
684
897
  // so the pills sit next to each other: the gap fits them all, with 6 px between pills
@@ -691,8 +904,8 @@ function layoutPass(dg, map, room, last) { // room: { x: { col: px }, y: { row:
691
904
  // a box in a box (an unfolded numbered view's lane, #82, or any nested box): a line to a card in one, or to the box that
692
905
  // holds it, crosses two borders
693
906
  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;
907
+ (dg.edges || []).forEach(e => { const p = colOf[e.from] ?? near(e.from, colOf[e.to]), q = colOf[e.to] ?? near(e.to, colOf[e.from]); if (p == null || q == null || (!e.label && e.step == null) || Math.abs(p - q) !== 1) return;
908
+ const c = Math.min(p, q), w = (e.label ? pillW(e.label, e.sublabel) : 0) + (e.step != null ? stepW(e.step) + (e.label ? 4 : 6) : 0), box = boxOf(e.from) !== boxOf(e.to) || !(e.from in colOf) || !(e.to in colOf) ? 48 + ([e.from, e.to].some(inLane) ? 12 : 0) : 0;
696
909
  gapAfter[c] = Math.max(gapAfter[c] || 0, w + 54 + box);
697
910
  [e.from, e.to].forEach(id => { const k = c + '|' + id, t = sum[k] ||= { w: -6, box: 0 }; t.w += w + 6; t.box = Math.max(t.box, box); gapAfter[c] = Math.max(gapAfter[c], t.w + 54 + t.box); }); }); }
698
911
  const extraX = { ...(room || {}).x }, extraY = { ...(room || {}).y }; // more room after a column (row): for lines with no route, and where a box would touch another box (below)
@@ -1243,11 +1456,15 @@ function svgOf(geo, opts = {}) {
1243
1456
  const marker = (n, c, k = 1) => `<marker id="${id}-${n}" markerWidth="${10 * k}" markerHeight="${8 * k}" refX="${9 * k}" refY="${4 * k}" orient="auto-start-reverse" markerUnits="userSpaceOnUse"><path d="M0 0 L${10 * k} ${4 * k} L0 ${8 * k} Z" fill="${c}"/></marker>`;
1244
1457
  const MC = { d: 'var(--um-arrow)', g: 'var(--um-green)', r: 'var(--um-rose)', v: 'var(--um-violet)', h: 'var(--um-cyan)', ...Object.fromEntries(WIRES.map(w => [w[0], `var(--um-w-${w[0]})`])) };
1245
1458
  const heavy = [...new Set(edges.filter(e => e.weight > 1).map(e => lstyle(e).m + '-w' + e.weight))]; // a heavier line's arrowhead grows with it
1459
+ // the notation (0.15.0): each card's look, and each line as drawn, its ends on the cards' outlines (a map's cards only)
1460
+ const notation = map ? noteOf(opts.shapes) || 'box' : 'box', kidsOf = n => n.kids != null ? n.kids : opts.childCount ? opts.childCount(n.id) : 0;
1461
+ const ports = map ? sidePorts(geo) : null, looks = new Map(map ? nodes.map(n => [n, cardLook(n, notation, !!(kidsOf(n) || n.opens) && opts.openHint !== false, ports.get(n))]) : []);
1462
+ const drawn = edges.map(e => looks.size ? drawnPts(e, n => looks.get(n) || null) : e.pts);
1246
1463
  let s = `<svg viewBox="${B.x} ${B.y} ${B.w} ${B.h}" data-w="${B.w}" data-h="${B.h}" role="${map ? 'group' : 'img'}" aria-label="${esc(opts.title || 'Diagram')}" xmlns="http://www.w3.org/2000/svg" preserveAspectRatio="xMidYMid meet">
1247
1464
  <defs>${marker('d', 'var(--um-arrow)')}${marker('g', 'var(--um-green)')}${marker('r', 'var(--um-rose)')}${marker('v', 'var(--um-violet)')}${marker('h', 'var(--um-cyan)')}${map ? WIRES.map(w => marker(w[0], `var(--um-w-${w[0]})`)).join('') : ''}${heavy.map(m => { const [c, k] = m.split('-w'); return marker(m, MC[c], 1 + (k - 1) * .35); }).join('')}</defs>`;
1248
1465
  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'})"/>
1466
+ families.slice().sort((a,b) => familyDepth(a)-familyDepth(b)).forEach(f => { // in c4 every box is a boundary, dashed as Structurizr draws one (a toned box is dashed in any notation)
1467
+ s += `<g class="family${f.tone ? ' toned' : ''}" data-family="${esc(f.id)}"${f.parent ? ` data-parent="${esc(f.parent)}"` : ''}${f.tone ? ` style="--um-tone:${toneOf(f)}"` : ''}><rect class="family-box" x="${f.x}" y="${f.y}" width="${f.w}" height="${f.h}" rx="14" stroke="var(--um-${familyDepth(f) ? 'cyan' : 'border-2'})"${notation === 'c4' ? ' stroke-dasharray="7 5"' : ''}/>
1251
1468
  <text class="family-title" x="${f.x+12}" y="${f.y+18}" fill="var(--um-muted)" data-t="ft" data-o="${esc(f.id)}">${esc(f.label || f.id)}</text>${(f.tabs || []).map(t => t.fold ? `
1252
1469
  <g class="fold-hint"><title>Fold back into one card (E, or Alt + click a card in it)</title><rect x="${t.x}" y="${f.y - 8}" width="${t.w}" height="16" rx="8" class="n-mask" stroke="var(--um-accent)"/><text x="${t.x + t.w / 2}" y="${f.y + 4}" text-anchor="middle" class="badge-tx" fill="var(--um-accent)">⤡</text></g>` : `
1253
1470
  <g class="pub-tab" data-badge="${t.badge}"><rect x="${t.x}" y="${f.y - 8}" width="${t.w}" height="15" rx="4" fill="${t.fill}"/><text x="${t.x + t.w / 2}" y="${f.y + 3}" text-anchor="middle" class="tab-tx">${esc(t.text)}</text></g>`).join('')}</g>`;
@@ -1269,7 +1486,7 @@ function svgOf(geo, opts = {}) {
1269
1486
  for (let i = 0; i + 1 < geo.tour.length; i++) {
1270
1487
  const a = geo.tour[i], b = geo.tour[i + 1], k = edges.findIndex(e => (e.from === a && e.to === b) || (e.both && e.from === b && e.to === a));
1271
1488
  if (k < 0) return null;
1272
- const p = edges[k].from === a ? edges[k].pts : [...edges[k].pts].reverse();
1489
+ const p = edges[k].from === a ? drawn[k] : [...drawn[k]].reverse();
1273
1490
  if (pts.length) pts.push([pts[pts.length - 1][0], p[0][1]]); // through the card to the next port
1274
1491
  const from = pts.length; pts.push(...p); ends.push([k, from, pts.length - 1, b]);
1275
1492
  }
@@ -1282,14 +1499,15 @@ function svgOf(geo, opts = {}) {
1282
1499
  const tourAnim = (vals, times) => `<animate attributeName="opacity" values="${vals}" keyTimes="${times.map(f2).join(';')}" dur="${TOUR}s" repeatCount="indefinite"/>`;
1283
1500
  s += '<g class="edges">';
1284
1501
  (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)"/>
1502
+ const bp = roundPath(looks.size ? drawnPts({ pts: b.pts, t: geo.byId[b.to], ts: 'L' }, n => looks.get(n) || null) : b.pts); // its end on the card's outline, as a line's
1503
+ s += `<g class="intake"><title>${esc(b.label)} feeds ${esc((geo.byId[b.to] || {}).label || b.to)}</title><path d="${bp}" class="edge" stroke="var(--um-w-feeds)" marker-end="url(#${id}-feeds)"/>
1286
1504
  <circle cx="${b.cx}" cy="${b.cy}" r="${b.r}" class="intake-chip${b.dashed ? ' dashed' : ''}"/>${b.logo ? `<image href="${esc(b.logo)}" x="${b.cx - 9}" y="${b.cy - 9}" width="18" height="18"/>` : b.mark ? `<text x="${b.cx}" y="${b.cy + 4.5}" text-anchor="middle" class="intake-mark">${esc(b.mark)}</text>` : ''}
1287
1505
  <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>`;
1506
+ ${tour ? `<circle r="3.2" class="tour tour-packet" opacity="0"><animateMotion path="${bp}" keyPoints="0;0;1" keyTimes="0;.86;1" calcMode="linear" dur="${TOUR}s" repeatCount="indefinite"/>${tourAnim('0;0;.8;.8;0', [0, .86, .88, .99, 1])}</circle>` : ''}</g>`;
1289
1507
  });
1290
1508
  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)
1509
+ const st = lstyle(e), why = broke(e), d = roundPath(drawn[i]), mk = st.m + (e.weight > 1 ? '-w' + e.weight : '');
1510
+ const tip = 9 * (e.weight > 1 ? 1 + (e.weight - 1) * .35 : 1), fd = roundPath(trimLine(drawn[i], e.both ? tip : 0, tip)); // an arrowhead's length (marker refX)
1293
1511
  if (tour && tour.legs[i]) { const [a, b] = tour.legs[i];
1294
1512
  s += `<path d="${d}" class="tour-glow" stroke="${st.c}" opacity="0">${tourAnim('0;0;.55;.55;0;0', [0, a, Math.min(a + .01, b), b, b + .03, 1])}</path>`; }
1295
1513
  s += `<g class="edge-g${st.cls || ''}${e.weight > 1 ? ' wt-' + e.weight : ''}" data-from="${esc(e.from)}" data-to="${esc(e.to)}" data-i="${i}"><path d="${d}" class="halo"/><path d="${d}" class="hitline"/>
@@ -1297,28 +1515,34 @@ function svgOf(geo, opts = {}) {
1297
1515
  <path d="${fd}" class="flowline" stroke="${st.c}"/>${(e.all || [e]).some(x => x.edited) && !e.strong ? (() => { let m = [e.pts[0], e.pts[1]];
1298
1516
  e.pts.slice(1).forEach((p, k) => { const a = e.pts[k]; if (Math.abs(p[0] - a[0]) + Math.abs(p[1] - a[1]) > Math.abs(m[1][0] - m[0][0]) + Math.abs(m[1][1] - m[0][1])) m = [a, p]; });
1299
1517
  return `<circle cx="${(m[0][0] + m[1][0]) / 2}" cy="${(m[0][1] + m[1][1]) / 2}" r="4" class="edited-dot"><title>Edited</title></circle>`; })() : ''}${why.length ? ruleMark(e, why) : ''}
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>`;
1518
+ <title>${e.step != null ? 'Step ' + esc(e.step) + ': ' : ''}${esc(e.s.label)} ${e.both ? '⇄' : '→'} ${esc(e.t.label)}${e.label ? ': ' + esc(e.label) : ''}${e.label && e.sublabel ? ' (' + esc(e.sublabel) + ')' : ''}${e.when ? '\n' + esc(e.when) : ''}${why.length ? '\nBreaks rule: ' + esc(why.join('; ')) : ''}</title></g>`;
1301
1519
  });
1302
1520
  // the tour's dot rides over the lines and boxes but under the cards: it goes into a card and comes out at its next
1303
1521
  // line, rather than across the card's name
1304
1522
  s += '</g>' + (tour ? `<g class="tour" aria-hidden="true"><circle r="5.5" class="tour-dot"><animateMotion path="${tour.d}" dur="${TOUR}s" repeatCount="indefinite"/></circle></g>` : '') + '<g class="nodes">';
1305
1523
  nodes.forEach(n => {
1306
1524
  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';
1525
+ const L = looks.get(n) || null, S = L && L.S; // a shaped card (0.15.0); null: a box, drawn as before
1526
+ const pub = map && n.k === 'm-public', priv = map && n.k === 'm-script', ext = map && n.k === 'm-ext', verb = map && n.k === 'm-verb', hook = map && n.k === 'm-hook' && String(n.shape ?? '').toLowerCase() !== 'box';
1527
+ const dash = removed || ext || planned || (map && notation !== 'box' && n.k === 'external'); // an external card is dashed where a notation draws types (the legend says so in every notation)
1308
1528
  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)
1529
+ const kids = kidsOf(n), stacked = !!(kids || n.opens); // opens a view, loaded or not (#47)
1310
1530
  const subject = opts.subject === n.id;
1311
1531
  const verbs = map && (n.verbs || []).length ? n.verbs : null;
1312
1532
  const subTxt = n.noSub ? '' : n.sub || (map && n.origin ? n.origin : '') || (map ? (n.what ? n.what.replace(/[`*]/g, '').split(/(?<=\.)\s/)[0] : KIND[n.k][1]) : '');
1313
1533
  const third = !!(verbs && subTxt && !priv && geo.subAndChips); // the view's subAndChips: the sub-line, then the chips on a third line (a script card shows no sub-line)
1314
1534
  const sw = map ? n.w - 70 : n.w - 24; // map cards: text runs from 54 px in (after the mark) to 16 px from the right
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)) : '';
1535
+ // a shape's face: the rows its mark and text use (a box's: the card). A short face takes a two-line sub-line to one, and
1536
+ // gives up the sub-line under a two-line name (the tooltip and the details panel keep it)
1537
+ const [ft, fb] = L ? S.face(L) : [n.y, n.y + n.h], fh = fb - ft, fy = (ft + fb) / 2;
1317
1538
  const labelPx = n.emphasis ? 15 : 13;
1318
1539
  const lw = map ? n.w - 70 : n.w - 72; // the mark (sigil, icon or logo tile) reaches 27 px in; the name keeps 36 px each side
1319
- const two = map && textW(n.label, labelPx) > lw ? wrap2(n.label, labelPx, lw) : null;
1540
+ const wraps = map && textW(n.label, labelPx) > lw && (!L || fh >= 30);
1541
+ const sub2 = map && subTxt && !verbs && textW(subTxt, 10) > sw && textW(n.label, n.emphasis ? 15 : 13) <= sw && (!L || fh >= 56) ? wrap2(subTxt, 10, sw) : null;
1542
+ const sub = subTxt && (!verbs || third) && !(L && wraps && fh < 44) ? (sub2 ? sub2.join(' ') : clauseFit(subTxt, 10, sw)) : '';
1543
+ const two = wraps ? wrap2(n.label, labelPx, lw) : null;
1320
1544
  const lpx = two ? labelPx : textW(n.label, labelPx) > lw ? (measureText ? Math.max(11, labelPx * lw / textW(n.label, labelPx)) : Math.max(11, lw / (String(n.label).length * 0.6))) : labelPx;
1321
- const ly = n.y + n.h / 2 + (third ? -14 : sub || verbs ? -3 : 4) - (two ? 8 : 0) - (sub2 ? 6 : 0);
1545
+ const ly = fy + (third ? -14 : sub || verbs ? -3 : 4) - (two ? 8 : 0) - (sub2 ? 6 : 0);
1322
1546
  // A card is a button named by what it shows, in the order it shows it, so its visible text is a run of whole words of its
1323
1547
  // name (WCAG 2.5.3, as axe checks it) at every zoom: what always shows comes first, then the sub-line and chips (faded out
1324
1548
  // 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 +1553,34 @@ function svgOf(geo, opts = {}) {
1329
1553
  const label = two ? two.join(' ') : fit(n.label, lpx, lw, true), subShown = sub && !priv; // a script card hides its sub-line
1330
1554
  said.push(part(label, n.label) + (subTxt && !subShown ? ` (${subTxt})` : ''));
1331
1555
  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
1556
+ if (L) { // a shape: its outline twice behind it when stacked, then the mask, the body and its details
1557
+ const o = S.out(L);
1558
+ if (stacked) parts.push([8, 4].map(k => `<path d="${o}" transform="translate(${k} ${k})" class="n-stack" stroke="${c}"/>`).join(''));
1559
+ if (pub) parts.push(`<path d="${S.out(L, 4)}" class="n-ring" stroke="${c}"/>`);
1560
+ parts.push(`<path d="${o}" class="n-mask"/><path d="${o}" class="n-body" fill="${f}" stroke="${c}"${dash ? ' stroke-dasharray="6 4"' : ''}${removed ? ' opacity=".6"' : ''}/>${S.det ? S.det(L, c, n.k) : ''}`); }
1561
+ else {
1562
+ if (stacked) parts.push(`<rect x="${n.x + 8}" y="${n.y + 8}" width="${n.w}" height="${n.h}" rx="${rx}" class="n-stack" stroke="${c}"/><rect x="${n.x + 4}" y="${n.y + 4}" width="${n.w}" height="${n.h}" rx="${rx}" class="n-stack" stroke="${c}"/>`);
1563
+ if (pub) parts.push(`<rect x="${n.x - 4}" y="${n.y - 4}" width="${n.w + 8}" height="${n.h + 8}" rx="14" class="n-ring" stroke="${c}"/>`);
1564
+ if (hook) { const hx = Math.min(16, n.h / 3), pts = `${n.x + hx},${n.y} ${n.x + n.w - hx},${n.y} ${n.x + n.w},${n.y + n.h / 2} ${n.x + n.w - hx},${n.y + n.h} ${n.x + hx},${n.y + n.h} ${n.x},${n.y + n.h / 2}`;
1565
+ parts.push(`<polygon points="${pts}" class="n-mask"/><polygon points="${pts}" class="n-body" fill="${f}" stroke="${c}" stroke-linejoin="round"/>`); }
1566
+ else parts.push(`<rect x="${n.x}" y="${n.y}" width="${n.w}" height="${n.h}" rx="${rx}" class="n-mask"/>
1567
+ <rect x="${n.x}" y="${n.y}" width="${n.w}" height="${n.h}" rx="${rx}" class="n-body" fill="${f}" stroke="${c}" ${dash ? 'stroke-dasharray="6 4"' : ''} ${removed ? 'opacity=".6"' : ''}/>`); }
1568
+ // the mark, centred on the face; in the uml notation a box's own sigil gives way to UML's component icon
1569
+ const glyph = L && SIGIL[L.sh] ? L.sh : notation === 'uml' && !L && !String(n.k).startsWith('m-') ? 'component' : n.k; // a person's, robot's, phone's, terminal's, folder's or node's own mark
1570
+ parts.push((() => { const I = (opts.icons || {})[n.icon], x = map ? n.x + 14 : n.x + (verb ? 14 : hook ? 17 : 9), y = map ? fy - 14 : n.y + (priv ? 8 : 11), M = map ? 28 : 16; // map: one 28 px mark, left, centred
1339
1571
  return I && I.src ? `<rect x="${x - M * 3 / 28}" y="${y - M * 3 / 28}" width="${M * 34 / 28}" height="${M * 34 / 28}" rx="${M / 4}" class="n-logo${I.dark ? ' dark' : ''}"/><image href="${I.src}" x="${x}" y="${y}" width="${M}" height="${M}"/>`
1340
1572
  : I && I.g ? `<g transform="translate(${x} ${y}) scale(${M / 24})" class="n-sigil" fill="none" stroke="${c}" stroke-width="${1.75 * 24 / M}" stroke-linecap="round" stroke-linejoin="round">${I.g}</g>`
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>`; })());
1573
+ : `<g transform="translate(${x} ${y}) scale(${M / 16})" class="n-sigil" fill="none" stroke="${c}" stroke-width="${1.75 * 16 / M}" stroke-linecap="round" stroke-linejoin="round">${SIGIL[glyph]}</g>`; })());
1342
1574
  parts.push(`<text x="${tx0}" y="${ly}" text-anchor="${anchor}" class="n-label" data-o="${esc(n.id)}" data-t="nl" style="font-size:${lpx.toFixed(1)}px" ${removed ? 'text-decoration="line-through"' : ''}>${two ? lines(two, 15) : esc(label)}</text>`);
1575
+ const ty = tabLine(n, L); // the line the tabs, ⤢, the change badge and a step sit on
1343
1576
  if (map) { let tx = n.x + 12; mapTabs(n, c).forEach(t => { const tw = t.w; // each badge is its own tab, clickable on its own
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>`);
1577
+ parts.push(`<g class="pub-tab"${t.badge != null ? ` data-badge="${t.badge}"` : ''}><rect x="${tx}" y="${ty - 8}" width="${tw}" height="15" rx="4" fill="${t.fill}"/><text x="${tx + tw / 2}" y="${ty + 3}" text-anchor="middle" class="tab-tx">${esc(t.text)}</text></g>`);
1345
1578
  said.push(t.badge != null ? badgesOf(n)[t.badge].text : t.text.toLowerCase()); tx += tw + 4; }); }
1346
1579
  if (map && n.edited) parts.push(`<g class="edited"><title>Edited</title><circle cx="${n.x + n.w - 10}" cy="${n.y + n.h - 10}" r="4" class="edited-dot"/></g>`);
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>`);
1580
+ // a step on a shape's tab line (a person's shoulder), but never down over the arrow of a line into its left side: as low as
1581
+ // keeps 6 px clear of the highest one, and no higher than the box's corner
1582
+ if (map && n.step) { const t = String(n.step), sw = Math.max(20, sansW(t, 9.5, .9) + 10), sy = L ? Math.max(n.y, Math.min(ty, L.portL - 16)) : ty;
1583
+ parts.push(`<g class="step"><title>Step ${esc(t)}</title><rect x="${n.x - 10}" y="${sy - 10}" width="${sw}" height="20" rx="10" class="stp"/><text x="${n.x - 10 + sw / 2}" y="${sy + 3.5}" text-anchor="middle" class="stp-tx">${esc(t)}</text></g>`);
1349
1584
  said.push(`(step) ${t}`); }
1350
1585
  if (n.tag) { // author name tag under the card: a bigger headshot + @handle (avatar inlined by render)
1351
1586
  const T = n.tag, cxp = T.x + 11, cyp = T.y + 11, cid = 'av-' + id + '-' + String(n.id).replace(/[^\w-]/g, '_');
@@ -1357,14 +1592,14 @@ return I && I.src ? `<rect x="${x - M * 3 / 28}" y="${y - M * 3 / 28}" width="${
1357
1592
  said.push(`(by${T.name ? ' ' + T.name : ''}) ${plain([ini, T.text].filter(Boolean).join(' '))}`);
1358
1593
  }
1359
1594
  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;
1595
+ if (hint) { const kx = map ? n.x + n.w - 44 : n.x + n.w - 40, ky = map ? ty - 8 : n.y + n.h - 11;
1361
1596
  parts.push(`<g class="open-hint${map ? '' : ' depth-read'}"><title>Open the inner view (or double-click the card)</title><rect x="${kx}" y="${ky}" width="36" height="16" rx="8" class="n-mask" stroke="${c}"/><text x="${kx + 18}" y="${ky + 12}" text-anchor="middle" class="badge-tx" fill="${c}">⤢${kids ? ' ' + kids : ''}</text></g>`);
1362
1597
  said.push(kids ? `${kids} (cards inside, opens the inner view)` : '(opens the inner view)'); }
1363
1598
  const ch = CHG[String(n.change || '').toLowerCase()];
1364
1599
  if (ch) {
1365
1600
  const bw = ch[1].length * 6 + 12, bx = n.x + n.w - bw - 8 - (hint && map ? 40 : 0); // left of a map card's ⤢ pill, which sits on the same edge
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>`);
1601
+ parts.push(`<rect x="${bx}" y="${ty - 8}" width="${bw}" height="16" rx="8" fill="var(--um-mask)" stroke="var(--um-${ch[0]})"/>
1602
+ <text x="${bx + bw / 2}" y="${ty + 3}" text-anchor="middle" class="badge-tx" fill="var(--um-${ch[0]})">${ch[1]}</text>`);
1368
1603
  said.push(ch[1].toLowerCase());
1369
1604
  }
1370
1605
  if (sub) { parts.push(`<text x="${tx0}" y="${ly + 16 + (two ? 15 : 0)}" text-anchor="${anchor}" class="n-sub depth-read" data-o="${esc(n.id)}" data-t="ns">${sub2 ? lines(sub2, 12) : esc(sub)}</text>`);
@@ -1383,12 +1618,12 @@ return I && I.src ? `<rect x="${x - M * 3 / 28}" y="${y - M * 3 / 28}" width="${
1383
1618
  }
1384
1619
  if (map) {
1385
1620
  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;
1621
+ edges.forEach((e, i) => { const point = e.from === n.id ? (L ? drawn[i][0] : e.p1) : e.to === n.id ? (L ? drawn[i][drawn[i].length - 1] : e.p2) : null; if (!point) return; // a shape's ports on its outline
1387
1622
  const key = point.join(','); const port = ports.get(key) || { point, indices: [] }; port.indices.push(i); ports.set(key, port); });
1388
1623
  ports.forEach(({ point, indices }) => { parts.push(`<circle cx="${point[0]}" cy="${point[1]}" r="3.5" class="n-port" data-edges="${indices.join(',')}" stroke="${c}"/>`); });
1389
1624
  }
1390
1625
  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)}">
1626
+ s += `<g class="node${pub ? ' pub' : ''}${priv ? ' priv' : ''}${stacked ? ' has-child' : ''}${subject ? ' subject' : ''}${n.emphasis ? ' main-step' : ''}${planned ? ' planned' : ''}${tour && n.id in tour.at ? ' tour-stop' : ''}"${tour && n.id in tour.at ? ` style="animation-delay:${(tour.at[n.id] * TOUR - .32).toFixed(2)}s"` : ''}${L ? ` data-shape="${L.sh}" data-face="${r2(ft)} ${r2(fb)}"` : ''} data-id="${esc(n.id)}" tabindex="${map ? 0 : -1}" role="${map ? 'button' : 'img'}" aria-label="${esc(name)}">
1392
1627
  ${parts.join('\n')}
1393
1628
  </g>`;
1394
1629
  });
@@ -1396,7 +1631,7 @@ return I && I.src ? `<rect x="${x - M * 3 / 28}" y="${y - M * 3 / 28}" width="${
1396
1631
  s += '</g><g class="keys">';
1397
1632
  edges.forEach((e, i) => { const said = e.label ? `: ${e.label}${e.sublabel ? ` (${e.sublabel})` : ''}` : '';
1398
1633
  const name = `${e.step != null ? `Step ${e.step}: ` : ''}Line ${e.both ? `between ${e.s.label} and ${e.t.label}, both ways` : `from ${e.s.label} to ${e.t.label}`}${said}${broke(e).length ? `, breaks rule ${broke(e).join('; ')}` : ''}`;
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>`; });
1634
+ s += `<g class="um-key line-key${e.weight > 1 ? ' wt-' + e.weight : ''}" data-i="${i}" tabindex="0" role="button" aria-pressed="false" aria-label="${esc(name)}"><path d="${roundPath(drawn[i])}" class="key-ring"/></g>`; });
1400
1635
  }
1401
1636
  s += '</g><g class="labels depth-read">';
1402
1637
  edges.forEach((e, i) => {
@@ -1404,7 +1639,7 @@ return I && I.src ? `<rect x="${x - M * 3 / 28}" y="${y - M * 3 / 28}" width="${
1404
1639
  const L = e.lbl;
1405
1640
  if (map) {
1406
1641
  const sw = L.sw || 16, cy = L.y + L.h / 2; // the step mark: a circle, or a pill as wide as a longer number (#82)
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>` : '';
1642
+ const st = L.step != null ? `${sw > 16 ? `<rect x="${L.x + 3}" y="${cy - 8}" width="${sw}" height="16" rx="8" class="e-step"/>` : `<circle cx="${L.x + 11}" cy="${cy}" r="8" class="e-step"/>`}<text x="${L.x + 3 + sw / 2}" y="${cy + 3.3}" text-anchor="middle" class="e-step-tx" data-t="el" data-o="${esc(e.from)}>${esc(e.to)}">${esc(L.step)}</text>` : '';
1408
1643
  const ax = L.step != null ? L.x + 7 + sw : L.x + L.w / 2, an = L.step != null ? '' : 'text-anchor="middle"';
1409
1644
  const tx = L.text ? `<text x="${ax}" y="${L.y + 14}" ${an} class="e-label" data-t="el" data-o="${esc(e.from)}>${esc(e.to)}">${esc(L.text)}</text>` : '';
1410
1645
  const sb = L.sub ? `<text x="${ax}" y="${L.y + 26}" ${an} class="e-sub" data-t="el" data-o="${esc(e.from)}>${esc(e.to)}">${esc(L.sub)}</text>` : '';
@@ -1645,7 +1880,7 @@ function overlapIssues(geo, where, refs) {
1645
1880
  }
1646
1881
  function leaveIssues(geo, where, refs) {
1647
1882
  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
1883
+ if (geo.map) geo.edges.forEach(e => { const L = e.lbl; if (!L || (!e.label && L.step == null) || !e.pts) return; // a label, or a step's mark, must leave some of its own line visible
1649
1884
  const c = [L.x + L.w / 2, L.y + L.h / 2], P = e.pts; let s = 0, at = null, half = 0, tot = 0;
1650
1885
  for (let i = 0; i + 1 < P.length; i++) { const a = P[i], b = P[i + 1], len = Math.abs(b[0] - a[0]) + Math.abs(b[1] - a[1]);
1651
1886
  if (at == null) { const onH = a[1] === b[1] && Math.abs(c[1] - a[1]) < 2 && c[0] >= Math.min(a[0], b[0]) - 1 && c[0] <= Math.max(a[0], b[0]) + 1;
@@ -1653,7 +1888,7 @@ function leaveIssues(geo, where, refs) {
1653
1888
  if (onH || onV) { at = tot + Math.abs(c[0] - a[0]) + Math.abs(c[1] - a[1]); half = onH ? L.w / 2 : L.h / 2; } }
1654
1889
  tot += len; }
1655
1890
  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); });
1891
+ if (room < 16) say(`${where}: label "${e.label || L.step}" leaves its line ${Math.max(0, Math.round(room))} px (${e.s.label}→${e.t.label})`, e); });
1657
1892
  return issues;
1658
1893
  }
1659
1894
 
@@ -1661,7 +1896,7 @@ function leaveIssues(geo, where, refs) {
1661
1896
  function auditSvg(svg, geo, where) {
1662
1897
  const issues = overlapIssues(geo, where), R = box;
1663
1898
  const T = [...svg.querySelectorAll('text[data-t]')].map(t => { let b; try { b = t.getBBox(); } catch (_) { return null; } return { t: t.dataset.t, o: t.dataset.o, txt: t.textContent, b: { x: b.x, y: b.y, w: b.width, h: b.height } }; }).filter(x => x && x.b.w);
1664
- const nodes = geo.nodes;
1899
+ const nodes = geo.nodes, faces = Object.fromEntries([...svg.querySelectorAll('.node[data-face]')].map(g => [g.dataset.id, g.dataset.face.split(' ').map(Number)])); // a shape's face (0.15.0)
1665
1900
  T.forEach(a => {
1666
1901
  if (a.t === 'lt' || a.t === 'ft') {
1667
1902
  nodes.forEach(n => { if (hit(a.b, R(n), -1)) issues.push(`${where}: lane "${a.txt}": header covers node "${n.label}"`); });
@@ -1671,7 +1906,9 @@ function auditSvg(svg, geo, where) {
1671
1906
  } else {
1672
1907
  const own = geo.byId[a.o];
1673
1908
  if (own && (a.b.x < own.x + 1 || a.b.x + a.b.w > own.x + own.w - 1)) issues.push(`${where}: text "${a.txt}" overflows node "${own.label}"`);
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}"`);
1909
+ const face = own && faces[own.id], fy = face ? (face[0] + face[1]) / 2 : own && own.y + own.h / 2; // a shape's mark and text keep to its face
1910
+ if (own && geo.map && (a.t === 'nl' || a.t === 'ns') && hit(a.b, { x: own.x + 11, y: fy - 17, w: 34, h: 34 }, 2)) issues.push(`${where}: text "${a.txt}" touches the mark on node "${own.label}"`);
1911
+ if (face && (a.b.y < face[0] - 1 || a.b.y + a.b.h > face[1] + 1)) issues.push(`${where}: text "${a.txt}" overflows the shape of node "${own.label}"`);
1675
1912
  nodes.forEach(n => { if (n !== own && hit(a.b, R(n), -1)) issues.push(`${where}: text "${a.txt}" overlaps node "${n.label}"`); });
1676
1913
  }
1677
1914
  });
@@ -1986,8 +2223,15 @@ function mapContext(model, opts = {}) {
1986
2223
  }
1987
2224
 
1988
2225
  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 }) };
2226
+ const g = buildGeo(vid);
2227
+ return { g, ...drawView(vid, g, opts) };
2228
+ }
2229
+ // a view's notation (0.15.0): its own `shapes`, else the model's, else box; drawView: the picture of a geometry in one,
2230
+ // with no layout (a change of notation draws the cards again and nothing else)
2231
+ const notationOf = vid => noteOf((V[vid] || {}).shapes) || noteOf(D.shapes) || 'box';
2232
+ function drawView(vid, g, opts = {}) {
2233
+ const v = V[vid], shapes = noteOf(opts.shapes) || notationOf(vid);
2234
+ return { markup: svgOf(g, { title: v.title, subject: subjectOf(v), icons: D.icons, broken: brokenIn(vid), ...opts, shapes }), shapes };
1991
2235
  }
1992
2236
  // the rules a view's lines break (#70), by model edge id: svgOf draws such a line rose, with a mark that names them
1993
2237
  let ruled = null;
@@ -2016,7 +2260,8 @@ function mapContext(model, opts = {}) {
2016
2260
  .map(e => e.all.length < 2 ? e : { ...e, label: (e.label || '') + ' +' + (e.all.length - 1), when: e.all.map(x => (x.step != null ? x.step + '. ' : '') + (x.label || '')).join('\n') });
2017
2261
  const out = merged.map(e => ({ ...e, back: !(V[vid] || {}).keepPairs && backKeys && backKeys.has(e.from + '>' + e.to) }));
2018
2262
  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; })
2263
+ return out.filter(e => { const r = !(V[vid] || {}).keepPairs && pair[e.to + '>' + e.from]; if (r) { r.both = true; r.back = false; r.all = [...(r.all || [r]), ...(e.all || [e])]; if (e.label && e.label !== r.label) r.label = (r.label ? r.label + ' / ' : '') + e.label;
2264
+ if (r.all.some(x => x.step != null)) r.when = r.all.map(x => (x.step != null ? x.step + '. ' : '') + (x.label || '')).join('\n'); else if (e.when) r.when = [r.when, e.when].filter(Boolean).join(' · '); return false; } pair[e.from + '>' + e.to] = e; return true; })
2020
2265
  .map(e => { const all = e.all || [e], subs = [...new Set(all.map(x => x.sublabel).filter(x => x != null && x !== '').map(String))];
2021
2266
  return { ...e, wk: wireKind(e, opts.kinds || (V[vid] || {}).kindsFromLabels), weight: Math.max(...all.map(x => [2, 3].includes(+x.weight) ? +x.weight : 1)), sublabel: subs.join(' / ') || undefined, ...markOf(all) }; });
2022
2267
  }
@@ -2145,7 +2390,7 @@ function mapContext(model, opts = {}) {
2145
2390
  const viewKey = vid => {
2146
2391
  const v = V[vid], ids = (v.nodeIds || []).filter(i => N[i]), ns = ids.map(i => nodeIn(i, vid));
2147
2392
  const icons = [...new Set(ns.map(n => n.icon).filter(Boolean))].map(k => (D.icons || {})[k]), who = [...new Set(ns.map(n => n.author).filter(Boolean))].map(k => (D.authors || {})[k]);
2148
- return JSON.stringify([v, ns, viewEdges(v), ids.map(i => [kidCount(i, vid), opensOf(i, vid)]), icons, who]);
2393
+ return JSON.stringify([{ ...v, shapes: undefined }, ns, viewEdges(v), ids.map(i => [kidCount(i, vid), opensOf(i, vid)]), icons, who]); // a view's notation lays nothing out
2149
2394
  };
2150
2395
  /* ---------- unfolding in place (#71): a stacked card opens into a box on the view it is on ----------
2151
2396
  The card becomes a box with its id and name, holding its inner view's own cards: those not on this view already (the
@@ -2267,7 +2512,7 @@ function mapContext(model, opts = {}) {
2267
2512
  for (let more = true; more && todo.length;) { more = false; todo = todo.filter(nid => { if (!unfold(vid, nid)) return true; (unfolded[vid] ||= []).push(nid); return !(more = true); }); } }))();
2268
2513
  const geoCache = {};
2269
2514
  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 };
2515
+ layoutCache, geoCache, buildGeo: withM(buildGeo), buildView: withM(buildView), drawView: withM(drawView), notationOf, optimize: withM(optimize), flowPos, kidCount, viewKey, brokenIn, unfoldable, unfolded };
2271
2516
  }
2272
2517
 
2273
2518
  const edgeRefIds = e => [...new Set((e.all || [e]).map(x => x._id).filter(Boolean))];
@@ -2308,15 +2553,24 @@ export function validate(model) {
2308
2553
  if (!n || typeof n.id !== 'string' || !n.id) return err(at + '.id', 'a card needs an id');
2309
2554
  if (ids.has(n.id)) err(at + '.id', `card id "${n.id}" is used twice`); ids.add(n.id);
2310
2555
  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`);
2556
+ if (n.kind != null && !(hasKey(KIND, k) || hasKey(MAPK, k) || hasKey(ALIAS, k))) warn(at + '.kind', `unknown kind "${n.kind}": drawn as a script card`);
2312
2557
  if (n.owner != null && typeof n.owner !== 'string') warn(at + '.owner', 'an owner is a name: ignored');
2558
+ shapeIs(n, at);
2313
2559
  };
2560
+ const shapeIs = (n, at) => { if (n && n.shape != null && !SHAPE_NAMES.includes(String(n.shape).toLowerCase())) warn(at + '.shape', `unknown shape "${n.shape}": the notation draws it by its kind (the shapes: ${SHAPE_NAMES.join(', ')})`); };
2561
+ const notation = (x, at) => { if (x != null && !noteOf(x)) warn(at, `"${x}" is not a notation (${NOTATIONS.join(', ')}): drawn as box`); }; // card shapes (0.15.0)
2562
+ notation(m.shapes, 'shapes');
2314
2563
  arr(m.nodes).forEach((n, i) => card(n, `nodes[${i}]`));
2315
2564
  views.forEach((v, i) => arr(v.nodes).forEach((n, j) => card(n, `views[${i}].nodes[${j}]`)));
2316
2565
  views.forEach((v, i) => { if (typeof v.id !== 'string' || !v.id) err(`views[${i}].id`, 'a view needs an id'); else if (vids.has(v.id)) err(`views[${i}].id`, `view id "${v.id}" is used twice`); else vids.add(v.id); });
2317
2566
  if (m.root != null && !vids.has(m.root)) err('root', `root "${m.root}" is not a view`);
2318
2567
  const later = (x, at) => { if (x && x.opensView && !vids.has(x.opensView)) warn(at + '.opensView', `opens "${x.opensView}", a view the model does not have: fine if the host loads it when it is opened`); };
2319
2568
  arr(m.nodes).forEach((n, i) => later(n, `nodes[${i}]`));
2569
+ views.forEach((v, i) => notation(v.shapes, `views[${i}].shapes`));
2570
+ // c4 draws a frontend card as a browser: a person's icon on one says the model means a person (0.15.0)
2571
+ const inC4 = new Set(views.filter(v => (noteOf(v.shapes) || noteOf(m.shapes)) === 'c4').flatMap(v => arr(v.nodeIds)));
2572
+ arr(m.nodes).forEach((n, i) => { if (!n || !inC4.has(n.id) || n.shape != null || !['user', 'users', 'usercheck'].includes(n.icon)) return;
2573
+ if (shapeOf({ ...n, k: kindOf(n.kind, true) }, 'c4') === 'browser') warn(`nodes[${i}].kind`, `"${n.label || n.id}" has a person's icon, but c4 draws a ${n.kind} card as a browser: kind "person" (or shape "person") draws a person`); });
2320
2574
  const line = (e, at, ends) => {
2321
2575
  if (!e || typeof e !== 'object') return err(at, 'a line is an object with from and to');
2322
2576
  ['from', 'to'].forEach(k => { if (!ends.has(e[k])) err(`${at}.${k}`, `"${e[k]}" is neither a card nor a box`); });
@@ -2324,6 +2578,8 @@ export function validate(model) {
2324
2578
  if (e.weight != null && ![1, 2, 3].includes(e.weight)) warn(at + '.weight', `weight ${JSON.stringify(e.weight)} is not 1, 2 or 3: drawn as 1`);
2325
2579
  ['fromSide', 'toSide'].forEach(k => { if (e[k] != null && !SIDES.includes(e[k])) warn(`${at}.${k}`, `"${e[k]}" is not left, right, top or bottom: ignored`); });
2326
2580
  ['latency', 'cost'].forEach(k => { if (e[k] != null && !(typeof e[k] === 'number' && e[k] >= 0)) warn(`${at}.${k}`, `${k} ${JSON.stringify(e[k])} is not a number of 0 or more: counted as 0`); }); // budgets (#95)
2581
+ if (e.step != null && !(typeof e.step === 'number' ? e.step >= 0 : typeof e.step === 'string' && e.step.length >= 1 && e.step.length <= 3))
2582
+ warn(at + '.step', `step ${JSON.stringify(e.step)} is not a number of 0 or more, or text of 1 to 3 characters (2a): drawn as written`);
2327
2583
  };
2328
2584
  // a model line may end on any view's box (#28); a view's own line only on that view's boxes
2329
2585
  const anyBox = new Set(views.flatMap(v => arr(v.containers).map(c => c && c.id)));
@@ -2333,7 +2589,7 @@ export function validate(model) {
2333
2589
  const at = `views[${i}]`, mine = new Set(arr(v.nodeIds)), boxes = {};
2334
2590
  if (v.parent != null && !vids.has(v.parent) && !ids.has(v.parent)) err(at + '.parent', `parent "${v.parent}" is not a view`);
2335
2591
  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}`); });
2592
+ Object.entries(v.nodeOverrides || {}).forEach(([id, o]) => { if (!ids.has(id)) err(`${at}.nodeOverrides.${id}`, `"${id}" is not a card`); later(o, `${at}.nodeOverrides.${id}`); shapeIs(o, `${at}.nodeOverrides.${id}`); });
2337
2593
  arr(v.nodes).forEach((n, j) => later(n, `${at}.nodes[${j}]`));
2338
2594
  ['stepIds', 'tour'].forEach(k => arr(v[k]).forEach((id, j) => { if (!mine.has(id)) err(`${at}.${k}[${j}]`, `"${id}" is not one of the view's cards`); }));
2339
2595
  arr(v.browserOrder).forEach((id, j) => { if (!vids.has(id)) err(`${at}.browserOrder[${j}]`, `"${id}" is not a view`); });
@@ -2342,6 +2598,7 @@ export function validate(model) {
2342
2598
  arr(v.containers).forEach((c, j) => { if (!c) return;
2343
2599
  arr(c.nodeIds).forEach((id, k) => { if (!mine.has(id)) err(`${at}.containers[${j}].nodeIds[${k}]`, `"${id}" is not one of the view's cards`); });
2344
2600
  if (c.parent != null && !boxes[c.parent]) err(`${at}.containers[${j}].parent`, `"${c.parent}" is not a box of this view`);
2601
+ if (c.tone != null && typeof c.tone !== 'string') warn(`${at}.containers[${j}].tone`, `tone ${JSON.stringify(c.tone)} is not a palette name (green, amber, ...): ${c.tone ? 'drawn in slate' : 'ignored'}`);
2345
2602
  for (let p = c.parent, seen = new Set([c.id]); p != null && boxes[p]; p = boxes[p].parent) { if (seen.has(p)) { err(`${at}.containers[${j}].parent`, `box "${c.id}" ends up inside itself`); break; } seen.add(p); } });
2346
2603
  const ends = new Set([...ids, ...Object.keys(boxes)]);
2347
2604
  if (Array.isArray(v.edges)) v.edges.forEach((e, j) => { if (typeof e !== 'string') line(e, `${at}.edges[${j}]`, ends); else if (!lineIds.has(e)) err(`${at}.edges[${j}]`, `"${e}" is not a line`); });
@@ -2357,7 +2614,7 @@ export function validate(model) {
2357
2614
  if (typeof s !== 'object' || Array.isArray(s)) return err(at, `a selector is an object: id, kind, container, owner${lens ? ', line' : ''} or not`);
2358
2615
  Object.keys(s).forEach(k => { if (!['id', 'kind', 'container', 'owner', 'not', ...(lens ? ['line'] : [])].includes(k)) warn(`${at}.${k}`, `unknown key "${k}": ignored, so the selector picks more cards`); });
2359
2616
  each(s.id, at + '.id', (x, p) => { if (!ids.has(x) && !allBoxes.has(x)) warn(p, `"${x}" is neither a card nor a box: it picks nothing`); });
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`); });
2617
+ each(s.kind, at + '.kind', (x, p) => { const k = String(x).toLowerCase(); if (!(hasKey(KIND, k) || hasKey(MAPK, k) || hasKey(ALIAS, k))) warn(p, `unknown kind "${x}": it picks script cards`); });
2361
2618
  each(s.container, at + '.container', (x, p) => { if (!allBoxes.has(x)) warn(p, `"${x}" is not a box: it picks nothing`); });
2362
2619
  each(s.owner, at + '.owner', (x, p) => { if (!owners.has(x)) warn(p, `no card's owner is "${x}": it picks nothing`); });
2363
2620
  if (lens) each(s.line, at + '.line', (x, p) => { if (!WIRE_KINDS.includes(x)) warn(p, `"${x}" is not a line kind (calls, feeds, triggers, state, guard): it picks no line`); });
@@ -2665,7 +2922,7 @@ export function render(container, model, options = {}) {
2665
2922
  event says which of its keys changed, once a frame at most (on a 16 ms timer, so a page in a hidden tab still says it).
2666
2923
  The camera is the exception: a pan, zoom, glide or drag is one state event, once it comes to rest; until then the
2667
2924
  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'];
2925
+ const KEYS = ['view', 'navigating', 'selection', 'expanded', 'lens', 'overlays', 'story', 'tour', 'replay', 'paused', 'camera', 'flow', 'shapes', 'motion', 'gesture', 'destroyed'];
2669
2926
  let lastState = null, stateT = 0, camRest = null, restT = 0; // the last state event's snapshot and the flush to come; the camera at rest, and its check to come
2670
2927
  const listensState = () => !!((options.on || {}).state || (subs.state || []).length);
2671
2928
  function touch() { if (!stateT && !dead && listensState()) stateT = setTimeout(flushState, 16); }
@@ -2676,6 +2933,18 @@ export function render(container, model, options = {}) {
2676
2933
  function camMoved() { clearTimeout(restT); restT = !dead && listensState() ? setTimeout(rested, 150) : 0; }
2677
2934
  function rested() { restT = 0; if (dead) return; if (cam && (cam.anim || cam.ptrs.size) || nav) return camMoved(); camRest = camNow(); touch(); }
2678
2935
  let cam = null, geo = null, svg = null, cur = null, sel = null, more = [], busy = false, paused = false, dead = false;
2936
+ // the notation (0.15.0): the host's (render()'s shapes, then handle.shapes) for every view, else each view's own
2937
+ if (options.shapes != null && !noteOf(options.shapes)) warn(`render(…, { shapes: ${JSON.stringify(options.shapes)} }): the notations are ${NOTATIONS.join(', ')}; each view draws its own`);
2938
+ let shapesSet = noteOf(options.shapes);
2939
+ const noteFor = vid => shapesSet || ctx.notationOf(vid);
2940
+ const pic = vid => { // a view's picture in its notation: laid out once, drawn again only when the notation changes
2941
+ const want = noteFor(vid), o = { tour: P.tour !== false, openHint: P.openHint !== false, shapes: want };
2942
+ let c = ctx.geoCache[vid]; if (!c) c = ctx.geoCache[vid] = ctx.buildView(vid, o); else if (c.shapes !== want) Object.assign(c, ctx.drawView(vid, c.g, o));
2943
+ return c; };
2944
+ // each card's look in the notation shown, for what is drawn over the picture: rings, the minimap, a tour's or replay's dot
2945
+ // (in box too: a card's own shape is drawn there)
2946
+ const lookIn = (nt, g) => { const m = new Map(), ports = sidePorts(g); return n => { if (!m.has(n)) m.set(n, cardLook(n, nt, !!(n.kids || n.opens) && P.openHint !== false, ports.get(n))); return m.get(n); }; };
2947
+ const looker = () => lookIn(noteFor(cur), geo);
2679
2948
  const timers = new Set(), later = (f, ms) => { const t = setTimeout(() => { timers.delete(t); if (!dead) f(); }, ms); timers.add(t); return t; };
2680
2949
  const sleeping = new Set(); // a sleep resolves on its own, or when the map is destroyed: nothing that awaits one is left pending
2681
2950
  const sleep = ms => new Promise(r => { if (dead) return r(); const wake = () => { sleeping.delete(wake); r(); }; sleeping.add(wake); later(wake, still() ? 0 : ms); });
@@ -2717,7 +2986,7 @@ export function render(container, model, options = {}) {
2717
2986
  const p = v && v.parent;
2718
2987
  stage.insertAdjacentHTML('beforeend', `<div class="empty-view"><div class="ico">◌</div><h4>Nothing here yet</h4><p>${v ? 'This view has no nodes yet.' : 'This map has no views yet.'}</p>${p ? `<button class="btn" data-v="${esc(p)}">← Back to ${esc(String(ctx.V[p].title || p).split(' · ')[0])}</button>` : ''}</div>`);
2719
2988
  } else {
2720
- const c = ctx.geoCache[vid] ||= ctx.buildView(vid, { tour: P.tour !== false, openHint: P.openHint !== false });
2989
+ const c = pic(vid);
2721
2990
  stage.insertAdjacentHTML('afterbegin', c.markup); geo = c.g;
2722
2991
  }
2723
2992
  svg = stage.querySelector('svg'); svg.classList.add('map-svg'); if (paused) svg.pauseAnimations();
@@ -2774,11 +3043,14 @@ export function render(container, model, options = {}) {
2774
3043
  /* minimap */
2775
3044
  const mini = {};
2776
3045
  function buildMini() {
2777
- const b = geo.bounds, el = q('.minimap');
3046
+ const b = geo.bounds, el = q('.minimap'), bw = Math.max(2, b.w / 300), c4 = noteFor(cur) === 'c4';
3047
+ // a box's tone, as its region; and a box dashed in the picture (any box in c4, a toned box in any notation) dashed here
3048
+ const rg = (geo.families || []).filter(f => f.tone || c4).map(f => `<rect x="${f.x}" y="${f.y}" width="${f.w}" height="${f.h}" rx="14" ${f.tone ? `style="--um-tone:${toneOf(f)}" ` : ''}fill="${f.tone ? 'var(--um-tone, var(--um-slate))' : 'none'}" fill-opacity=".14" stroke="${f.tone ? 'var(--um-tone, var(--um-slate))' : 'var(--um-border-2)'}" stroke-width="${bw}" stroke-dasharray="${bw * 4} ${bw * 3}"/>`).join('');
2778
3049
  const ln = (geo.lanes || []).map(l => `<rect x="${l.x}" y="${l.y}" width="${l.w}" height="${l.h}" rx="14" fill="none" stroke="var(--um-border-2)" stroke-width="${Math.max(2, b.w / 300)}"/>`).join('');
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('');
3050
+ const look = looker(), r = geo.nodes.map(n => { const L = look(n), paint = `fill="var(--um-${KIND[n.k][0]})" opacity="${n.k === 'm-public' ? .95 : .55}"`;
3051
+ return L ? `<path d="${L.S.out(L)}" ${paint}/>` : `<rect x="${n.x}" y="${n.y}" width="${n.w}" height="${n.h}" rx="8" ${paint}/>`; }).join('');
2780
3052
  const l = geo.edges.map(e => `<polyline points="${e.pts.map(p => p.join(',')).join(' ')}" fill="none" stroke="var(--um-arrow)" stroke-width="${Math.max(2, b.w / 260)}" opacity=".6"/>`).join('');
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>`;
3053
+ el.innerHTML = `<svg viewBox="${b.x} ${b.y} ${b.w} ${b.h}" preserveAspectRatio="xMidYMid meet">${rg}${ln}${l}${r}<rect class="vp" rx="${b.w / 80}"/></svg>`;
2782
3054
  mini.svg = el.firstChild; mini.vp = el.querySelector('.vp');
2783
3055
  mini.vp.setAttribute('stroke-width', Math.max(2, b.w / 160));
2784
3056
  cam && onCam(cam);
@@ -2801,8 +3073,9 @@ export function render(container, model, options = {}) {
2801
3073
  svg.querySelectorAll('.tab-key, .chip-key').forEach(k => k.remove());
2802
3074
  const cards = Object.fromEntries([...svg.querySelectorAll('.node')].map(g => [g.dataset.id, g]));
2803
3075
  svg.querySelectorAll('.family [data-badge]').forEach(t => { if (on) { t.setAttribute('tabindex', '0'); t.setAttribute('role', 'button'); } else { t.removeAttribute('tabindex'); t.removeAttribute('role'); } });
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; });
3076
+ const look = looker();
3077
+ if (on && geo.map) geo.nodes.forEach(n => { let tx = n.x + 12, html = ''; const card = cards[n.id], ty = tabLine(n, look(n)); if (!card) return;
3078
+ mapTabs(n).forEach(t => { if (t.badge != null) html += `<g class="um-key tab-key" data-node="${esc(n.id)}" data-badge="${t.badge}" tabindex="0" role="button" aria-label="${esc(t.text)}"><rect x="${tx - 2}" y="${ty - 10}" width="${t.w + 4}" height="19" rx="6" class="key-ring"/></g>`; tx += t.w + 4; });
2806
3079
  card.querySelectorAll('rect.verb-chip').forEach(r => { const i = +r.dataset.chip, { value } = chipRef(card, i), at = k => +r.getAttribute(k), verb = (n.verbs || [])[i];
2807
3080
  const name = value === verb ? value : `${value} (more: ${(n.verbs || []).slice(i).join(', ')})`; // the last chip may stand for the rest
2808
3081
  html += `<g class="um-key chip-key" data-node="${esc(n.id)}" data-chip="${i}" tabindex="0" role="button" aria-label="${esc(name)}"><rect x="${at('x') - 2}" y="${at('y') - 2}" width="${at('width') + 4}" height="${at('height') + 4}" rx="9" class="key-ring"/></g>`; });
@@ -2985,6 +3258,21 @@ export function render(container, model, options = {}) {
2985
3258
  return true;
2986
3259
  }
2987
3260
  function flash(nid) { const g = svg.querySelector(`.node[data-id="${CSS.escape(nid)}"]`); if (!g) return; g.classList.add('flash'); later(() => g.classList.remove('flash'), 1400); }
3261
+ /* the notation every view is drawn in (0.15.0): the host's, a reader's (the page's toggle and link), or null for each
3262
+ view's own. The view shown is drawn again from the geometry it has, with its camera, selection, lens and marks, and
3263
+ nothing is laid out. False for a notation that isn't one (with a warning). */
3264
+ function setShapes(x) {
3265
+ if (dead) return false;
3266
+ const want = x == null ? null : noteOf(x);
3267
+ if (x != null && !want) { warn(`shapes = ${JSON.stringify(x)}: the notations are ${NOTATIONS.join(', ')}`); return false; }
3268
+ shapesSet = want;
3269
+ if (cur && ctx.geoCache[cur] && ctx.geoCache[cur].shapes !== noteFor(cur)) {
3270
+ const keep = sel, keepMore = more, touched = cam.touched;
3271
+ mount(cur); cam.set({}); cam.touched = touched;
3272
+ if (keep && exists(keep)) { sel = keep; more = keep.type === 'node' ? keepMore.filter(id => geo.byId[id]) : []; paint(); }
3273
+ emitSel(); }
3274
+ touch(); return true;
3275
+ }
2988
3276
  function setFlow(on, save = true) { // save: the page keeps it as the reader's choice
2989
3277
  stage.classList.toggle('flowing', on);
2990
3278
  const b = q('[data-z="flow"]'); if (b) { b.classList.toggle('on', on); b.setAttribute('aria-pressed', on); }
@@ -2992,6 +3280,7 @@ export function render(container, model, options = {}) {
2992
3280
  touch();
2993
3281
  }
2994
3282
  function pause(on) { const was = paused; paused = on; stage.classList.toggle('paused', on); if (svg) on ? svg.pauseAnimations() : svg.unpauseAnimations();
3283
+ if (on !== was) touch(); // MapState.paused (0.15.0)
2995
3284
  if (story && on !== was) { if (on) clearTimeout(story.timer); else arm(story); } } // the page's Pause holds every clock: a dot that rides (startTrip), and a story's step, given its time again after
2996
3285
 
2997
3286
  /* keys on the canvas; the page forwards the same keys when the focus is elsewhere */
@@ -3287,7 +3576,7 @@ export function render(container, model, options = {}) {
3287
3576
  let s = svg, g = geo, off = null;
3288
3577
  if (vid !== cur) { // another view: drawn off screen, inside the shadow root so the same styles apply
3289
3578
  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 });
3579
+ const c = pic(vid);
3291
3580
  off = document.createElement('div'); off.style.cssText = 'position:absolute;left:-100000px;top:0;width:10px;height:10px;overflow:hidden;pointer-events:none';
3292
3581
  off.innerHTML = c.markup; stage.parentNode.appendChild(off); s = off.querySelector('svg'); s.classList.add('map-svg'); g = c.g;
3293
3582
  }
@@ -3380,7 +3669,7 @@ export function render(container, model, options = {}) {
3380
3669
  return; }
3381
3670
  if (!ctx.V[cur]) { // the view shown is gone: the root, or the empty card of a model with no views
3382
3671
  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)
3672
+ if (ctx.geoCache[cur] && keys[cur] === ctx.viewKey(cur) && ctx.geoCache[cur].shapes === noteFor(cur)) { if (lensId != null) { relens(); drawMarks(); } if (pc) pc.chrome(); return; } // this view's picture did not change (its lens or the views may have)
3384
3673
  const keep = sel, keepMore = more, touched = cam.touched, prev = [geo, svg];
3385
3674
  mount(cur);
3386
3675
  if (touched) cam.set({}); else cam.set(startView());
@@ -3446,12 +3735,15 @@ export function render(container, model, options = {}) {
3446
3735
  const tone = t => 'var(--um-' + String(t || 'accent').replace(/[^\w-]/g, '') + ')', op = m => m.level < 1 ? ` opacity="${m.level}"` : '';
3447
3736
  const cards = Object.fromEntries([...s.querySelectorAll('.node')].map(x => [x.dataset.id, x]));
3448
3737
  let under = '', over = '';
3738
+ const look = lookIn(noteFor(vid), g);
3449
3739
  g.nodes.forEach(n => { const m = markOf2(L => (L.nodes || {})[n.id]), el = cards[n.id]; if (!m || !el) return;
3450
3740
  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>`;
3741
+ const sh = (m.tone || m.pulse) && look(n); // a shape's ring: its outline, 5 px out (0.15.0)
3742
+ if (m.tone || m.pulse) under += `<g${op(m)}>${sh ? `<path d="${sh.S.out(sh, 5)}"` : `<rect x="${n.x - 5}" y="${n.y - 5}" width="${n.w + 10}" height="${n.h + 10}" rx="14"`} class="um-ring${m.pulse ? ' um-pulse' : ''}" stroke="${tone(m.tone)}"/></g>`;
3452
3743
  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>`;
3744
+ const L = look(n), ty = tabLine(n, L), w = b.text.toUpperCase().length * 6.4 + 10; // on the card's tab line, clear of a head and a folder's tab
3745
+ const x = tabAfter(n, L, w);
3746
+ over += `<g class="um-tab"${op(m)}><rect x="${x}" y="${ty - 8}" width="${w}" height="15" rx="4" fill="${toneOf(b)}"/><text x="${x + w / 2}" y="${ty + 3}" text-anchor="middle" class="tab-tx">${esc(b.text)}</text></g>`;
3455
3747
  el.setAttribute('aria-description', b.text); });
3456
3748
  const heads = (g.families || []).flatMap(f => [{ x: f.x + 8, y: f.y + 3, w: String(f.label || f.id).length * 6.6 + 8, h: 22 }, // a box's title, 11 px mono
3457
3749
  ...(f.tabs || []).map(t => ({ x: t.x, y: f.y - 8, w: t.w, h: 16 }))]); // and its tabs
@@ -3546,7 +3838,8 @@ export function render(container, model, options = {}) {
3546
3838
  return t;
3547
3839
  }
3548
3840
  const lineBetween = (a, b) => { let i = geo.edges.findIndex(e => e.from === a && e.to === b); if (i < 0) i = geo.edges.findIndex(e => e.from === b && e.to === a); // either way
3549
- return i < 0 ? null : { i, pts: geo.edges[i].from === a ? geo.edges[i].pts : [...geo.edges[i].pts].reverse() }; };
3841
+ if (i < 0) return null; const pts = drawnPts(geo.edges[i], looker()); // as drawn: into a shape at its outline (0.15.0)
3842
+ return { i, pts: geo.edges[i].from === a ? pts : [...pts].reverse() }; };
3550
3843
  const lenOf = pts => pts.slice(1).reduce((s, p, k) => s + Math.hypot(p[0] - pts[k][0], p[1] - pts[k][1]), 0);
3551
3844
  const along = (pts, d) => { for (let k = 1; k < pts.length; k++) { const a = pts[k - 1], b = pts[k], l = Math.hypot(b[0] - a[0], b[1] - a[1]);
3552
3845
  if (d <= l || k === pts.length - 1) { const f = l ? Math.min(1, d / l) : 1; return [a[0] + (b[0] - a[0]) * f, a[1] + (b[1] - a[1]) * f]; } d -= l; } return pts[0]; };
@@ -3746,7 +4039,7 @@ export function render(container, model, options = {}) {
3746
4039
 
3747
4040
  const inner = { ctx: () => ctx, geo: () => geo, cam: () => cam, svg: () => svg, stage, host, go, open, up, flash, setFlow, pause, key, startView, standalone, play,
3748
4041
  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)
4042
+ highlight, overlay, focus: focusMap, editable: !!E, shapes: () => shapesNow(), setShapes, override: () => shapesSet }; // a story's pointer is the pieces' own, from the story event (0.14.0)
3750
4043
 
3751
4044
  // the pieces a host asked for (#99): they follow the map's selection and views, and take their keys wherever the focus is
3752
4045
  // in the map (Enter and Space press their buttons; the map's other keys reach the canvas, as in the page)
@@ -3758,12 +4051,13 @@ export function render(container, model, options = {}) {
3758
4051
  if (options.nav) { q('.um-navbtn').addEventListener('click', () => pc.setTree(!frame.classList.contains('tree-open'))); pc.setTree(ctx.views.length > 1); }
3759
4052
  }
3760
4053
  const motionOf = () => instant ? 'instant' : reducedMotion() ? 'reduced' : 'full';
4054
+ const shapesNow = () => cur ? noteFor(cur) : shapesSet || noteOf(ctx.D.shapes) || 'box';
3761
4055
  const camNow = () => { const r = (x, d) => Math.round(x * d) / d; return cam ? { x: r(cam.s.cx, 10), y: r(cam.s.cy, 10), zoom: r(cam.s.k, 1000) } : { x: 0, y: 0, zoom: 1 }; };
3762
4056
  function snapshot(event = false) { // MapState: built fresh on every read, frozen all the way down, plain JSON; event: the camera at rest
3763
4057
  const deep = o => { Object.values(o).forEach(v => v && typeof v === 'object' && deep(v)); return Object.freeze(o); }, st = story, t = trip;
3764
4058
  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 });
4059
+ story: st ? { id: st.s.id, step: st.at, steps: st.steps.length, playing: !!st.auto } : null, tour: !!(t && t.kind === 'tour'), replay: t && t.kind === 'replay' ? { playing: !t.paused } : null, paused: !!paused,
4060
+ camera: event && camRest ? { ...camRest } : camNow(), flow: stage.classList.contains('flowing'), shapes: shapesNow(), motion: motionOf(), gesture: gestureNow(), destroyed: dead });
3767
4061
  }
3768
4062
  const first = () => {
3769
4063
  if (dead) return;
@@ -3802,6 +4096,8 @@ export function render(container, model, options = {}) {
3802
4096
  get destroyed() { return dead; },
3803
4097
  get flow() { return stage.classList.contains('flowing'); }, // the animated flow along lines (≋, F)
3804
4098
  set flow(on) { if (!dead) setFlow(!!on, false); }, // a host's choice, not the reader's: the page saves only ≋ and F
4099
+ get shapes() { return shapesNow(); }, // the notation the view shown is drawn in: 'box', 'c4' or 'uml' (0.15.0)
4100
+ set shapes(x) { setShapes(x); }, // every view in this one (null: each view's own); the cards are drawn again, nothing is laid out
3805
4101
  get motion() { return motionOf(); }, // 'instant' (the page's #check, #still, #shot), 'reduced' (the reader's setting), else 'full'
3806
4102
  get story() { return story ? story.ctl : null; }, // the player of the story shown (play()'s, the page menu's or a link's), else null
3807
4103
  overlay: (name, layer) => overlay(name, layer),
@@ -3885,7 +4181,7 @@ export function describe(model, viewId) {
3885
4181
  // a line's kind as its colour says it: the one it names, else a hook's line guards and a board's is state (no guesses from words)
3886
4182
  const kind = e => WIRE_KINDS.includes(e.kind) ? e.kind : (ctx.N[e.from] || {}).kind === 'hook' ? 'guard' : [e.from, e.to].some(i => (ctx.N[i] || {}).kind === 'board') ? 'state' : 'calls';
3887
4183
  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(', ')})` : ''; };
4184
+ const what = e => { const t = [e.label && `"${e.label}"`, e.sublabel, v.render !== 'sequence' && e.step != null && `step ${e.step}`].filter(Boolean); return t.length ? ` (${t.join(', ')})` : ''; };
3889
4185
  const out = [`${v.title || v.id}: ${count(ids.length, 'card')} and ${count(edges.length, 'line')}.`];
3890
4186
  if (ids.length) out.push(`Cards: ${ids.map(id => `${name(id)} (${KIND[kindOf(ctx.nodeIn(id, viewId).kind, true)][1]})`).join(', ')}.`);
3891
4187
  boxes.forEach(c => { const ins = (c.nodeIds || []).filter(i => ids.includes(i)); if (ins.length) out.push(`${name(c.id)} holds ${list(ins.map(name))}.`); });
@@ -4031,7 +4327,7 @@ export function mountPage(container, model, options = {}) {
4031
4327
  <span class="mbrand"><i></i><span>${esc(D.title || 'Map')}</span></span>
4032
4328
  <nav class="crumbs" id="m-crumbs" aria-label="Breadcrumb"></nav>
4033
4329
  <button class="findbtn" id="m-find" type="button" aria-label="Search nodes and views (${modK})"><span aria-hidden="true">⌕</span><span class="ft">Find a node or view…</span><span class="kbd">${modK}</span></button>
4034
- <button class="ibtn" id="m-theme" title="Light / dark" aria-label="Toggle theme">◐</button>${(D.stories || []).length ? `
4330
+ <button class="ibtn" id="m-theme" title="Light / dark" aria-label="Toggle theme">◐</button><button class="ibtn txt" id="m-shapes" type="button" title="Card shapes: box, c4 or uml">⬡<span class="lbl"></span></button>${(D.stories || []).length ? `
4035
4331
  <div class="menu"><button class="ibtn txt" id="m-stories" aria-haspopup="true" aria-expanded="false" title="Stories: walk-throughs of this map">▶<span class="lbl"> Stories</span></button>
4036
4332
  <div class="menu-pop" id="m-stories-pop" hidden>${D.stories.map(s => `<button data-story="${esc(s && s.id)}">${esc((s && (s.title || s.id)) || '')}</button>`).join('')}</div></div>` : ''}${arrOf(D.lenses).some(l => l && l.id) ? `
4037
4333
  <div class="menu"><button class="ibtn txt" id="m-lenses" aria-haspopup="true" aria-expanded="false" title="Lenses: ways to look at this map">◎<span class="lbl"> Lenses</span></button>
@@ -4096,6 +4392,7 @@ export function mountPage(container, model, options = {}) {
4096
4392
  const p = new URLSearchParams(); p.set('view', cur); if (sel) p.set('node', sel);
4097
4393
  if (told) { p.set('story', told.id); p.set('step', told.step + 1); } // the story shown, at its step (#69)
4098
4394
  if (lensOn) p.set('lens', lensOn); // the lens on the map (#94)
4395
+ if (X.override()) p.set('shapes', X.override()); // the notation a reader or the link picked (0.15.0)
4099
4396
  if (Q.get('theme')) p.set('theme', document.documentElement.dataset.theme || Q.get('theme'));
4100
4397
  ['reader', 'check', 'shot'].forEach(k => Q.get(k) && p.set(k, Q.get(k)));
4101
4398
  const h = '#' + p.toString();
@@ -4108,6 +4405,8 @@ export function mountPage(container, model, options = {}) {
4108
4405
  const idle = () => new Promise(r => { const t = () => X.busy() ? setTimeout(t, 30) : r(); t(); }); // once no move is under way
4109
4406
  async function follow(load = false) {
4110
4407
  const p = new URLSearchParams(location.hash.slice(1)), my = ++follows, lacks = [], stories = arrOf(D.stories);
4408
+ if (noteOf(p.get('shapes')) && noteOf(p.get('shapes')) !== handle.shapes) handle.shapes = p.get('shapes'); // the link's card shapes (0.15.0)
4409
+ else if (p.get('shapes') && !noteOf(p.get('shapes'))) lacks.push(`no card shapes “${p.get('shapes')}”`);
4111
4410
  const lens = p.get('lens') || null, sid = p.get('story'), at = Math.max(1, Math.floor(+p.get('step')) || 1) - 1;
4112
4411
  const def = sid ? stories.find(s => s && s.id === sid) : null, steps = def ? arrOf(def.steps).filter(s => s && typeof s === 'object') : [], step = steps[Math.min(at, steps.length - 1)];
4113
4412
  linking = { view: null };
@@ -4336,8 +4635,10 @@ export function mountPage(container, model, options = {}) {
4336
4635
  let flowPref = null; try { flowPref = reducedMotion() ? null : localStorage.getItem('visual-flow'); } catch (_) {} // the reader's ≋ and F; under reduced motion no flow is drawn
4337
4636
  const p0 = new URLSearchParams(location.hash.slice(1));
4338
4637
  const v0 = V[p0.get('view')] ? p0.get('view') : rootId;
4638
+ const shapesKey = 'visual-shapes:' + location.pathname; // the reader's notation, kept for this page (0.15.0)
4639
+ const shapes0 = noteOf(p0.get('shapes')) || (() => { try { return noteOf(localStorage.getItem(shapesKey)); } catch (_) { return null; } })() || noteOf(options.shapes);
4339
4640
  const handle = render($('m-canvas'), model, {
4340
- view: v0, minimap: true, flow: flowPref ? flowPref === '1' : undefined,
4641
+ view: v0, minimap: true, flow: flowPref ? flowPref === '1' : undefined, shapes: shapes0 || undefined,
4341
4642
  on: { story: e => { told = e.step == null ? null : { id: e.story, step: e.step }; app.classList.toggle('story', !!told); pc.story(e);
4342
4643
  const sv = told && stepView(told); if (X && !X.busy() && (!sv || sv === cur || !V[sv])) write(); } }, // a step on another view: in that view's entry, once it shows (#69)
4343
4644
  _page: {
@@ -4364,6 +4665,13 @@ export function mountPage(container, model, options = {}) {
4364
4665
  if (linking && linking.view === e.view) return; // a link's own move: its entry, written once the link has settled
4365
4666
  linking = null; write(true); }); // a new view, a new entry (a move the reader started while a link was followed, too)
4366
4667
  handle.on('lens', e => { lensOn = e.lens; showLens(); write(); });
4668
+ // the header's card shapes (0.15.0): box, c4 and uml in turn, for every view; the page keeps the reader's choice
4669
+ const showShapes = () => { const b = $('m-shapes'), n = handle.shapes, next = NOTATIONS[(NOTATIONS.indexOf(n) + 1) % NOTATIONS.length];
4670
+ b.querySelector('.lbl').textContent = ' ' + n; b.setAttribute('aria-label', `Card shapes: ${n}. Show them as ${next}`); };
4671
+ $('m-shapes').addEventListener('click', () => { const next = NOTATIONS[(NOTATIONS.indexOf(handle.shapes) + 1) % NOTATIONS.length];
4672
+ handle.shapes = next; try { localStorage.setItem(shapesKey, next); } catch (_) {} showShapes(); write(); status(`Card shapes: ${next}`); });
4673
+ handle.on('state', e => { if (e.changed.includes('shapes')) showShapes(); });
4674
+ showShapes();
4367
4675
  // #pdf=1 (umlimator.py pdf, #93): the page becomes a document for Chrome to print (printDoc), once the fonts are in,
4368
4676
  // after the map's own redraw for them
4369
4677
  if (Q.get('pdf')) (document.fonts ? document.fonts.ready : Promise.resolve()).then(() => printDoc(X, model, Q));
@@ -4466,12 +4774,12 @@ function printDoc(X, model, Q) {
4466
4774
  ...(rs.length ? ['<tr><td>Rules</td><td class="pg" data-pg="rules"></td></tr>'] : [])] }]);
4467
4775
 
4468
4776
  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]));
4777
+ const g = geoCache[v.id].g, ids = cards(v), es = lines(v), seq = v.render === 'sequence', stepped = es.some(e => e.step != null), at = Object.fromEntries(g.nodes.map(n => [n.id, n]));
4470
4778
  const wk = new Map(g.edges.flatMap(d => edgeRefIds(d).map(id => [id, d.wk]))), held = boxes(v).map(c => [c, arrOf(c.nodeIds).filter(i => ids.includes(i))]).filter(([, ins]) => ins.length);
4471
4779
  const up = chain(v.id).slice(0, -1).map(id => `<span class="up">${T(V[id])} › </span>`).join('');
4472
4780
  const head = more => `<header>${more ? `<div class="h2">${up}${T(v)} <span class="up">(continued)</span></div>` : `<h2>${up}${T(v)}</h2>`}<span class="meta">${[count(ids.length, 'card'), count(es.length, 'line'), ...(held.length ? [count(held.length, 'box', 'boxes')] : [])].join(' · ')}</span></header>${!more && v.caption ? `<p class="cap">${esc(v.caption)}</p>` : ''}`;
4473
4781
  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>`);
4782
+ body.insertAdjacentHTML('beforeend', `<figure>${svg}</figure><div class="lg"><b>Legend</b>${legendItems([g], 'opens a view of its own', [geoCache[v.id].shapes], D.icons).join('')}</div>`);
4475
4783
  // the picture as large as the page allows, never larger than drawn: below it the legend, then two columns of tables;
4476
4784
  // one too tall for that keeps the page's height, with the legend and one column beside it. Smaller (down to 70%, and
4477
4785
  // to a scale of 0.6 at least) when that keeps the tables on its page; else they go on over the next pages.
@@ -4485,8 +4793,8 @@ function printDoc(X, model, Q) {
4485
4793
  { title: `Cards <span>${ids.length}</span>`, cls: 'cards', head: '<th>Card</th><th>Kind</th><th>What it is</th>', rows: ids.map(id => { const c = nodeIn(id, v.id), t = KIND[kindOf(c.kind, true)], d = at[id] || {}, sub = c.sublabel ?? c.sub;
4486
4794
  return `<tr><td>${d.step ? `<i class="num">${esc(d.step)}</i>` : ''}<b>${esc(c.label || id)}</b>${d.kids || d.opens ? ' ⤢' : ''}${sub ? `<small>${esc(sub)}</small>` : ''}</td>`
4487
4795
  + `<td><i class="sw" style="border-color:var(--um-${t[0]});background:var(--um-${t[0]}-f)"></i>${t[1]}</td><td>${c.what ? md(c.what) : ''}</td></tr>`; }) },
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>`; }) },
4796
+ { title: `Lines <span>${es.length}</span>`, cls: 'lines', head: `${seq || stepped ? '<th class="n">#</th>' : ''}<th>From → to</th><th>Label</th><th>Technology</th>`, rows: (stepped && !seq ? [...es].sort((a, b) => stepOrder(a.step, b.step)) : es).map((e, i) => { const w = wk.get(e._id) || 'calls';
4797
+ return `<tr>${seq ? `<td>${i + 1}</td>` : stepped ? `<td>${esc(e.step ?? '')}</td>` : ''}<td><i class="w" style="border-color:var(--um-w-${w})"></i>${name(v, e.from)} → ${name(v, e.to)}</td><td>${e.label ? esc(e.label) : `<span class="up">${w}</span>`}</td><td>${esc(e.sublabel || '')}</td></tr>`; }) },
4490
4798
  { title: `Boxes <span>${held.length}</span>`, cls: 'boxes', head: '<th>Box</th><th>Holds</th>', rows: held.map(([c, ins]) => `<tr><td><b>${esc(c.label || c.id)}</b></td><td>${andList(ins.map(i => name(v, i)))}</td></tr>`) },
4491
4799
  ].filter(x => x.rows.length);
4492
4800
  [1, .9, .8, .7].some(f => k * f >= Math.min(k, .6) && lay(f)) || lay(1, true);
@@ -4539,11 +4847,31 @@ function detailBullets(body) {
4539
4847
  return parts;
4540
4848
  }
4541
4849
 
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)
4850
+ // what each shape stands for, in a legend (0.15.0)
4851
+ const SHAPE_SAYS = { person: ['Person', 'someone who uses it'], browser: ['Browser app', 'runs in a web browser'], mobile: ['Mobile app', 'runs on a phone'],
4852
+ cylinder: ['Data store', 'a database or a store'], pipe: ['Queue', 'hands work on, in order'], document: ['File', 'a document or a config'],
4853
+ hexagon: ['Hexagon', 'a guard or a service'], robot: ['Agent', 'an AI agent'], terminal: ['Command line', 'a CLI or a script'],
4854
+ window: ['Desktop app', 'runs in a window'], folder: ['Package', 'a module or a folder of code'], node: ['Host', 'a machine or runtime it runs on'],
4855
+ component: ['Component', 'a part that runs (UML)'] };
4856
+ const swatchOf = (sh, k, c, f, dashed) => { const n = { x: 0, y: 0, w: 100, h: 64 }, S = SHAPE[sh]; // a legend's key: the card's outline, small
4857
+ return `<svg class="lgs" viewBox="-4 -4 108 72" aria-hidden="true"><path d="${S.out(n)}" fill="${f}" stroke="${c}"${dashed ? ' stroke-dasharray="3 2"' : ''}/>${S.det ? S.det(n, c, k) : ''}</svg>`; };
4858
+ function legendItems(gs, opens = 'double-click to open', notes = [], icons = {}) { // what these pictures draw, each with what it means: the view's legend, and the PDF's (#93). notes: each picture's notation (0.15.0)
4543
4859
  const ns = gs.flatMap(g => g.nodes), es = gs.flatMap(g => g.edges), used = [...new Set(ns.map(n => n.k))];
4860
+ // each card as its picture's notation draws it: its shape, its dash and its mark (the legend draws what the map draws)
4861
+ const cards = gs.flatMap((g, i) => { const nt = g.map ? noteOf(notes[i]) || 'box' : 'box';
4862
+ return g.nodes.map(n => { const sh = shapeOf(n, nt), I = (icons || {})[n.icon]; // an icon the table lacks leaves the card the component mark, as svgOf draws it
4863
+ return { k: n.k, sh, dash: nt !== 'box' && n.k === 'external', comp: nt === 'uml' && sh === 'box' && !(I && (I.src || I.g)) && !String(n.k).startsWith('m-') }; }); });
4864
+ const kindShape = k => { const s = [...new Set(cards.filter(d => d.k === k).map(d => d.sh))]; return s.length === 1 && s[0] !== 'box' && !(k === 'm-hook' && s[0] === 'hexagon') ? s[0] : null; };
4865
+ const dashOf = k => k === 'm-ext' || cards.some(d => d.k === k && d.dash); // an external card is dashed only where it is drawn so
4544
4866
  const order = ['m-public', 'm-step', 'm-lane', 'm-ext', 'm-verb', 'm-board', 'm-hook', 'm-script', 'frontend', 'backend', 'ai', 'queue', 'database', 'security', 'cloud', 'external', 'file'];
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>`)
4867
+ const shape = k => k === 'm-verb' ? 'p' : dashOf(k) ? 'd' : k === 'm-hook' ? 'h' : '';
4868
+ const kinds = order.filter(k => used.includes(k)), own = new Set(kinds.map(kindShape).filter(Boolean)), tone = k => `var(--um-${KIND[k][0]})`;
4869
+ // a shape the kinds' keys don't show already: in a neutral outline, as colour is the kinds' (and UML's component mark)
4870
+ const shapes = SHAPE_NAMES.filter(sh => sh !== 'box' && !own.has(sh) && cards.some(d => d.sh === sh && !(sh === 'hexagon' && d.k === 'm-hook')))
4871
+ .map(sh => `<span class="lgi" title="${esc(SHAPE_SAYS[sh].join(' — '))}">${swatchOf(sh, 'file', 'var(--um-muted)', 'var(--um-panel)')}<b>${SHAPE_SAYS[sh][0]}</b><span>${SHAPE_SAYS[sh][1]}</span></span>`)
4872
+ .concat(cards.some(d => d.comp) ? [`<span class="lgi" title="${esc(SHAPE_SAYS.component.join(' — '))}"><svg class="lgs" viewBox="0 0 16 16" aria-hidden="true"><g fill="none" stroke="var(--um-muted)" stroke-linecap="round" stroke-linejoin="round">${SIGIL.component}</g></svg><b>${SHAPE_SAYS.component[0]}</b><span>${SHAPE_SAYS.component[1]}</span></span>`] : []);
4873
+ return kinds.map(k => `<span class="lgi" title="${esc(KIND[k][1] + ' — ' + KIND[k][2])}">${kindShape(k) ? swatchOf(kindShape(k), k, tone(k), `var(--um-${KIND[k][0]}-f)`, dashOf(k)) : `<i class="${shape(k)}" style="border-color:${tone(k)};background:var(--um-${KIND[k][0]}-f)${k === 'm-hook' ? `;background:${tone(k)}` : ''}"></i>`}<b>${KIND[k][1]}</b><span>${KIND[k][2]}</span></span>`)
4874
+ .concat(shapes)
4547
4875
  .concat(ns.some(n => n.kids || n.opens) ? [`<span class="lgi"><i class="s"></i><b>⤢ Stacked</b><span>${opens}</span></span>`] : [])
4548
4876
  .concat(ns.some(n => n.step) ? ['<span class="lgi"><i class="num">1</i><b>Step</b><span>run order</span></span>'] : [])
4549
4877
  .concat(ns.some(n => n.tag) ? ['<span class="lgi"><i class="tag"></i><b>@author</b><span>who wrote it</span></span>'] : [])
@@ -4560,7 +4888,7 @@ function pieces(X, el, o) {
4560
4888
 
4561
4889
  /* the legend: the kinds of cards and lines the view draws, in a box that folds away */
4562
4890
  function legend() {
4563
- const items = legendItems([X().geo()]), back = el.legend.contains(el.legend.getRootNode().activeElement);
4891
+ const items = legendItems([X().geo()], undefined, [X().shapes ? X().shapes() : 'box'], X().ctx().D.icons), back = el.legend.contains(el.legend.getRootNode().activeElement);
4564
4892
  el.legend.hidden = !items.length;
4565
4893
  el.legend.classList.toggle('folded', folded);
4566
4894
  el.legend.innerHTML = `<div class="t"><span>LEGEND</span><button type="button" id="m-lfold" aria-label="${folded ? 'Show' : 'Hide'} legend" aria-expanded="${!folded}">${folded ? '+' : '–'}</button></div>${items.join('')}`;