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