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/README.md +24 -6
- package/lib/umlimator-element.js +5 -2
- package/lib/umlimator-theme.css +6 -2
- package/lib/umlimator.d.ts +35 -4
- package/lib/umlimator.js +409 -81
- package/lib/umlimator.schema.json +73 -0
- package/package.json +1 -1
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-
|
|
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}
|
|
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 => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[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
|
|
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}">>_</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 >
|
|
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 ?
|
|
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
|
-
|
|
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="${
|
|
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(
|
|
1292
|
-
const tip = 9 * (e.weight > 1 ? 1 + (e.weight - 1) * .35 : 1), fd = roundPath(trimLine(
|
|
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
|
|
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 =
|
|
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
|
-
|
|
1316
|
-
|
|
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
|
|
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 =
|
|
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 (
|
|
1333
|
-
|
|
1334
|
-
|
|
1335
|
-
parts.push(`<
|
|
1336
|
-
|
|
1337
|
-
|
|
1338
|
-
|
|
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[
|
|
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="${
|
|
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
|
-
|
|
1348
|
-
|
|
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 ?
|
|
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="${
|
|
1367
|
-
<text x="${bx + bw / 2}" y="${
|
|
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(
|
|
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
|
-
|
|
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)
|
|
1990
|
-
return { g,
|
|
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;
|
|
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 && !(
|
|
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 (!(
|
|
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 =
|
|
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 =>
|
|
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
|
-
|
|
2805
|
-
|
|
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 =
|
|
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
|
-
|
|
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
|
|
3454
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
4546
|
-
|
|
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('')}`;
|