yichus 0.2.0 → 0.3.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.
@@ -16,7 +16,7 @@
16
16
  // from the record. Every drawn mark carries data-mark (its kind, explained
17
17
  // on web/system-map-blocks.html) and data-f (the facts it draws).
18
18
 
19
- import { SANS, MONO, textWidth, clipText, fitLabel, lineGap, boxAttr, fitAttr, owned, labelled, wrapText, anchorAttr, anchored, bound } from './label-fit.js';
19
+ import { SANS, MONO, textWidth, clipText, fitLabel, lineGap, boxAttr, fitAttr, owned, labelled, wrapText, widestPiece, anchorAttr, anchored, bound } from './label-fit.js';
20
20
 
21
21
  const C = {
22
22
  ink: '#0f172a', ink2: '#334155', muted: '#64748b', faint: '#94a3b8', hair: '#e2e8f0',
@@ -26,6 +26,9 @@ const C = {
26
26
  hull: '#f1ecfb', hullEdge: '#c9b8ee', hullInk: '#5b3fa8',
27
27
  guard: '#b4231d', region: '#f4f6fa', regionEdge: '#dfe4ec',
28
28
  model: '#8b45d6', modelSoft: '#f3ebfc', net: '#c7700a', netSoft: '#fcf1e3',
29
+ // the program runner (compile, run) and an effect of no outside resource
30
+ compile: '#0e7490', compileSoft: '#e0f2f6', run: '#0e7490', runSoft: '#e0f2f6', io: '#64748b', ioSoft: '#eef2f6',
31
+ program: '#0f5132', programSoft: '#eef7f1', programEdge: '#7fb69a',
29
32
  };
30
33
  const esc = s => String(s).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
31
34
  export const tw = (s, size, mono = true, weight = 400) => textWidth(s, size, mono, weight);
@@ -36,33 +39,16 @@ const f1 = n => (+n).toFixed(1);
36
39
  export const MIN_LABEL = 9;
37
40
  // every drawn element names the facts it draws, for tests/site/system-map.test.mjs
38
41
  export const tg = (keys, svg) => { const ks = [].concat(keys).filter(Boolean); return ks.length ? `<g data-f="${ks.map(k => encodeURIComponent(k)).join(' ')}">${svg}</g>` : svg; };
39
- // A name that does not fit on one line at 80% size is broken in two after a
40
- // separator ('_', ', ', ' in ', '(', ' · ') nearest its middle; 'up' puts the
41
- // first half above, 'down' below. Returns the svg.
42
- function wrapLines(s, width, size = 10, mono = true, maxLines = 3) {
43
- s = String(s);
44
- const one = fitText(s, size, width, mono, 0.8);
45
- if (!one.s.endsWith('…') || maxLines < 2) return [one];
46
- // greedy: as much as fits on each line, cut after a separator
47
- const sz = Math.max(MIN_LABEL, size * 0.8);
48
- const lines = []; let rest = s;
49
- while (rest.length && lines.length < maxLines) {
50
- if (tw(rest, sz, mono) <= width || lines.length === maxLines - 1) { lines.push(rest); rest = ''; break; }
51
- let cut = -1;
52
- for (const sep of ['_', ', ', ' in ', '(', ' · ', '-', '/', ' ']) { let i = rest.indexOf(sep, 1); while (i > 0) { const k = i + (sep === '(' ? 0 : sep.length); if (tw(rest.slice(0, k), sz, mono) <= width && k > cut) cut = k; i = rest.indexOf(sep, i + 1); } }
53
- if (cut <= 0) { cut = 1; while (cut < rest.length && tw(rest.slice(0, cut + 1), sz, mono) <= width) cut++; }
54
- lines.push(rest.slice(0, cut)); rest = rest.slice(cut);
55
- }
56
- return lines.map(t => fitText(t, sz, width, mono, 0.85));
57
- }
58
- const lineLead = size => size * 0.95 + 1.4;
59
- function T2(x, y, s, width, o = {}, dir = 'up', maxLines = 3) {
60
- const size = o.size || 10, lines = wrapLines(s, width, size, o.mono !== false, maxLines), n = lines.length;
61
- return lines.map((f, i) => T(x, dir === 'up' ? y - (n - 1 - i) * lineLead(size) : y + i * lineLead(size), f.s, { ...o, size: f.size, full: f.s.endsWith('…') ? s : null })).join('');
62
- }
63
42
  // a station's verb and tables, on as many lines as they take up to three; more, the verb and the count of tables
64
43
  function reachLines(text, width, size) {
65
- const ls = wrapText(text, Math.max(MIN_LABEL, size), width).map(s => ({ s, size: Math.max(MIN_LABEL, size) }));
44
+ const sz = Math.max(MIN_LABEL, size);
45
+ let ls = wrapText(text, sz, width).map(s => ({ s, size: sz }));
46
+ // too narrow for its words on one line: the verb stays whole on its own line, the tables clip under it
47
+ const [verb, ...rest] = text.split(' ');
48
+ // not even the verb fits: no words (the station's mark still says it reads or writes)
49
+ if (wrapText(verb, sz, width)[0] !== verb) return [];
50
+ if (ls.length === 1 && ls[0].s.endsWith('…') && rest.length)
51
+ ls = [verb, ...wrapText(rest.join(' '), sz, width)].map(s => ({ s, size: sz }));
66
52
  if (ls.length <= 3) return ls;
67
53
  const short = fitText(`${text.match(/^(read|write|delete)( · (read|write|delete))*/)[0]} ${text.split(', ').length} tables`, size, width);
68
54
  return short.s.endsWith('…') ? ls : [short];
@@ -116,8 +102,12 @@ const Tw = (x, y, lines, o, full, gap, dir = 'down') => {
116
102
  return n > 1 ? `<g><title>${esc(name)}</title>${body}</g>` : body;
117
103
  };
118
104
 
105
+
119
106
  // ---------------------------------------------------------------- marks
120
107
  // Each mark is one concept. The same function draws it in both views.
108
+ // what a ? says: on hover, and once in words in the key beside the rail
109
+ const UNTRACED = 'the database handle is traced to where it comes from on some paths only';
110
+ const UNTRACED_WORD = 'partly traced';
121
111
  export const mark = {
122
112
  dot: (x, y, r = 3.4, fill = C.ink2) => `<circle cx="${f1(x)}" cy="${f1(y)}" r="${r}" fill="${fill}"/>`,
123
113
  // station: a ring where a definition touches a resource; filled = writes
@@ -146,7 +136,7 @@ export const mark = {
146
136
  handle: (x, y, kind = 'db', r = 5.5) => `<circle cx="${f1(x)}" cy="${f1(y)}" r="${r}" fill="${kind === 'record' ? C.rec : C.db}" stroke="${C.paper}" stroke-width="1.5"/>`,
147
137
  // the builder: a plus-disc where the handle is made, at the use
148
138
  builder: (x, y, kind = 'db', r = 5) => `<circle cx="${f1(x)}" cy="${f1(y)}" r="${r}" fill="${C.paper}" stroke="${kind === 'record' ? C.rec : C.db}" stroke-width="1.6"/><path d="M${f1(x - r * 0.55)} ${f1(y)} H${f1(x + r * 0.55)} M${f1(x)} ${f1(y - r * 0.55)} V${f1(y + r * 0.55)}" stroke="${kind === 'record' ? C.rec : C.db}" stroke-width="1.6"/>`,
149
- unknown: (x, y, r = 7) => `<circle cx="${f1(x)}" cy="${f1(y)}" r="${r}" fill="${C.wash}"/>` + T(x, y + r * 0.5, '?', { anchor: 'middle', size: r * 1.4, weight: 700, fill: C.muted, mono: false, halo: false }),
139
+ unknown: (x, y, r = 7) => `<title>${UNTRACED}</title><circle cx="${f1(x)}" cy="${f1(y)}" r="${r}" fill="${C.wash}"/>` + T(x, y + r * 0.5, '?', { anchor: 'middle', size: r * 1.4, weight: 700, fill: C.muted, mono: false, halo: false }),
150
140
  loop: (x, y, r, color = C.hullInk) => `<path d="M${f1(x + r)} ${f1(y)} A${r} ${r} 0 1 1 ${f1(x)} ${f1(y - r)}" fill="none" stroke="${color}" stroke-width="1.6"/><path d="M${f1(x - 4)} ${f1(y - r - 3.5)} L${f1(x + 1)} ${f1(y - r)} L${f1(x - 4)} ${f1(y - r + 3.5)}" fill="none" stroke="${color}" stroke-width="1.4"/>`,
151
141
  // callers
152
142
  client: (x, y, s = 1, ghost = false) => {
@@ -170,7 +160,9 @@ for (const [k, f] of Object.entries(mark)) mark[k] = (...a) => `<g data-mark="${
170
160
  // A drawn shape of one kind that is not one of the marks above.
171
161
  const shape = (kind, svg) => `<g data-mark="${kind}">${svg}</g>`;
172
162
  export const MARK_KINDS = [...new Set([...Object.values(MARK_KIND), 'resource', 'hull', 'rail', 'cable', 'bracket', 'role', 'token', 'afterDot', 'modelRing', 'netRing',
173
- 'modelCount', 'port', 'plug', 'package', 'chain', 'composes', 'combination', 'generic', 'loop', 'wall',
163
+ 'reachedBy', 'port', 'plug', 'package', 'chain', 'composes', 'combination', 'generic', 'loop', 'wall',
164
+ // the program band: a pure program, its requests, and the engines that perform them
165
+ 'program', 'decision', 'request', 'variant', 'gap', 'wire', 'lane', 'laneFork', 'laneResource', 'replyLoop', 'starter', 'programEffect', 'replaced',
174
166
  // the what-happens view's (what-happens.js) one new mark: the end of a path
175
167
  'outcome'])].sort();
176
168
 
@@ -213,20 +205,22 @@ const tableLines = (tables, room, phone) => tables.flatMap(t => wrapText(t, phon
213
205
  const RACK = ['db', 'model', 'net'];
214
206
 
215
207
  // notes: lines of computed text under a present slot, e.g. { model: { lines, kind } }
216
- function rack(map, x, y, phone, room, notes = {}) {
208
+ function rack(map, x, y, phone, room, notes = {}, scale = {}) {
217
209
  // fixed slots: db, model, net, top to bottom, present or struck
218
210
  const out = [], pos = new Map();
219
- const w = phone ? 38 : 64, h = phone ? 46 : 64, gw = w * 0.66, gh = h * 0.66;
211
+ const w0 = phone ? 38 : 64, h0 = phone ? 46 : 64, gw = w0 * 0.66, gh = h0 * 0.66;
220
212
  let yy = y;
221
213
  for (const k of RACK) {
222
214
  const present = map.resources.find(r => r.kind === k);
215
+ // a present resource drawn at its share of the routes that reach it (1 when nothing is counted)
216
+ const w = w0 * (scale[k] ? scale[k].w : 1), h = h0 * (scale[k] ? scale[k].h : 1);
223
217
  if (present) {
224
218
  const tl = k === 'db' ? (present.tables || []) : [];
225
219
  out.push(tg([`resource:${k}`], shape('resource', resourceGlyph(k, x - w / 2, yy, w, h, phone, false, tl, room))));
226
220
  pos.set(k, { x: x - w / 2, cx: x, y0: yy, y1: yy + h, bottom: yy + h });
227
221
  const tablesH = tl.length && tl.length < 12 ? tableLines(tl, room, phone).length * (phone ? 11 : 13) + (phone ? 6 : 8) : 0;
228
222
  const note = notes[k], ls = phone ? 11 : 12;
229
- if (note) out.push(shape(note.kind, note.lines.map((l, i) => T(x, yy + h + tablesH + (phone ? 12 : 14) + i * ls, l, { anchor: 'middle', size: phone ? 9 : 10, fill: C[k] })).join('')));
223
+ if (note) out.push(tg(note.keys, shape(note.kind, note.lines.map((l, i) => T(x, yy + h + tablesH + (phone ? 12 : 14) + i * ls, l, { anchor: 'middle', size: phone ? 9 : 10, fill: C[k] })).join(''))));
230
224
  yy += h + tablesH + (note ? note.lines.length * ls + 4 : 0) + (phone ? 14 : 20);
231
225
  } else {
232
226
  out.push(tg([`ghost:${k}`], shape('struck', resourceGlyph(k, x - gw / 2, yy, gw, gh, phone, true))));
@@ -329,7 +323,25 @@ function renderService(map, W, opts) {
329
323
  const gap = phone ? 3 : 4;
330
324
  const lockW = e => (e.authority === 'publishable' || e.signedIn ? (phone ? 12 : 14) : 0);
331
325
  const labX = e => x0 + (phone ? 7 : 10) + lockW(e);
332
- const countOf = e => (e.members.length > 1 ? `×${e.members.length}` : '');
326
+ // a bundle says what it counts: its routes
327
+ const countOf = e => (e.members.length > 1 ? `${e.members.length} routes` : '');
328
+ // A row's checks by name: every member's, not only the first's (a bundle
329
+ // holds routes with the same marks, not the same checks). A check that
330
+ // took another definition's place on the same route since the compared
331
+ // base is named with the one it replaced.
332
+ const replacedAt = new Map(((map.compare && map.compare.replaced) || []).map(r => [`${r.entry} ${r.within} ${r.at}`, r]));
333
+ const checks = (e, within, idsOf, namesOf) => {
334
+ const words = [], replaced = [];
335
+ for (const m of e.members) idsOf(m).forEach((id, i) => {
336
+ const r = replacedAt.get(`${m.key} ${within} ${i}`), nm = namesOf(m)[i];
337
+ const w = r ? `${nm} (in place of ${r.wasLabel})` : nm;
338
+ if (!words.includes(w)) words.push(w);
339
+ if (r) replaced.push(`replaced:${m.key}:${id}`);
340
+ });
341
+ return { text: words.join(', '), names: words, replaced };
342
+ };
343
+ const checkLabel = (c, svg) => c.replaced.length ? tg(c.replaced, shape('replaced', svg)) : svg;
344
+ for (const e of ents) e._chk = { h: checks(e, 'handler', m => m.guardIds, m => m.guards), p: checks(e, 'passed', m => m.passed[0]?.guardIds || [], m => m.passed[0]?.guards || []), a: checks(e, 'after', m => m.beside?.guardIds || [], m => m.beside?.guards || []) };
333
345
  // the hull band starts past the names of the routes that enter it, as far as half the wall
334
346
  const hx0 = Math.min(col(0.5, 0.5), Math.max(col(0.22, 0.34), ...rowsIn.filter(e => e.wrapper)
335
347
  .map(e => labX(e) + tw(e.bundle ? e.bundle.label : e.path, fs) + (countOf(e) ? gap + tw(countOf(e), fs, true, 700) : 0) + 6)));
@@ -467,7 +479,7 @@ function renderService(map, W, opts) {
467
479
  // the left column
468
480
  const leftEnd = clear(i, x2, e.wrapper ? hx0 - 6 : (e.guards.length ? gx - 9 : xh - rS - 6), true);
469
481
  const left = [];
470
- const guardLines = !e.wrapper && e.guards.length ? wrapped(e.guards.join(', '), gx - 7 - x2, { size: fsS }) : [];
482
+ const guardLines = !e.wrapper && e.guards.length ? wrapped(e._chk.h.text, gx - 7 - x2, { size: fsS }) : [];
471
483
  guardLines.forEach(() => left.push('guard'));
472
484
  const guardFork = !e.wrapper && !dense ? forkLines(keyedTypes(e.members.flatMap(m => m.guardIds)), gx + 6 - 3 - x2) : [];
473
485
  guardFork.forEach(() => left.push('guardFork'));
@@ -487,7 +499,7 @@ function renderService(map, W, opts) {
487
499
  built = builtAt.get(`h:${i}`);
488
500
  if (built) right.push('builder');
489
501
  } else {
490
- const gname = p && p.guards.length && !dense ? p.guards.join(', ') : '';
502
+ const gname = p && p.guards.length && !dense ? e._chk.p.text : '';
491
503
  // the check's name over its diamond when it fits there and the function's name leaves room; else right of its stem
492
504
  guardBelow = !!gname && (tw(p.label || '', fsS) > xPG - tw(gname, fsS) / 2 - 8 - xSockText || tw(gname, fsS) > (hx1 - xPG) * 2 - 8);
493
505
  const gw = gname ? tw(gname, fsS) / 2 + 8 : 0;
@@ -523,7 +535,7 @@ function renderService(map, W, opts) {
523
535
  if (b.guards.length) {
524
536
  const free = dense || !(p && (p.label || (p.guards.length && !guardBelow)));
525
537
  const from = free ? [...lanes.values()].filter(l => i > l.top && l.x < xr).reduce((s, l) => (l.x + l.m > s ? l.x + l.m : s), xSock + rS + 4) : hx1;
526
- afterGuard = wrapped(b.guards.join(', '), xr - 8 - from, { size: fsS });
538
+ afterGuard = wrapped(e._chk.a.text, xr - 8 - from, { size: fsS });
527
539
  }
528
540
  for (let k = 0; k < Math.max(inner.length, xpg, afterWords.length + afterAt - 1); k++) right.push('inner');
529
541
  // a wrapper of this row alone: its own station's forks, tables and builder sit under it too
@@ -578,9 +590,9 @@ function renderService(map, W, opts) {
578
590
  over.push(tg(ks.flatMap(id => [`fork:${id}`, `forkRow:${id}`]), mark.fork(x, fy, phone ? 0.6 : 0.7) + owned(own, rowB(row, label))));
579
591
  };
580
592
  // a check's diamond with its name above it, inside the room given
581
- const guardAt = (keys, names, x, y, room, s = 1, own = 'canvas', row = null) => {
593
+ const guardAt = (keys, names, x, y, room, s = 1, own = 'canvas', row = null, named = svg => svg) => {
582
594
  over.push(tg(keys, mark.guard(x, y, s * (phone ? 0.8 : 0.95))));
583
- if (names.length) out.push(owned(own, rowB(row, L(x, y - (phone ? 10 : 12), names.join(', '), room, { anchor: 'middle', size: fsS, fill: C.guard }))));
595
+ if (names.length) out.push(owned(own, rowB(row, named(L(x, y - (phone ? 10 : 12), names.join(', '), room, { anchor: 'middle', size: fsS, fill: C.guard })))));
584
596
  };
585
597
  // a builder hung under a mark on line k, its name to the right from x
586
598
  const builder = (b, px, y, k, x, end, own = 'canvas', row = null) => {
@@ -635,7 +647,7 @@ function renderService(map, W, opts) {
635
647
  if (e.guards.length) {
636
648
  guardAt(each(e, m => m.guardIds.map(g => `guard:${m.key}:${g}`)), [], gx, y, 0, 0.85);
637
649
  // its name under the path, left of the failure stem; its fork's types under that
638
- out.push(rowB(e, Tw(gx - 7, at('guard'), guardLines, { anchor: 'end', size: fsS, fill: C.guard }, e.guards.join(', '), lead)));
650
+ out.push(rowB(e, checkLabel(e._chk.h, Tw(gx - 7, at('guard'), guardLines, { anchor: 'end', size: fsS, fill: C.guard }, e._chk.h.text, lead))));
639
651
  forkAt(e.members.flatMap(m => m.guardIds), gx + 6, y, left.indexOf('guardFork') + 1, x2, 'canvas', true, e);
640
652
  }
641
653
  const station = own.length ? mark.station(xh, y, rS, writesIn(own)) : mark.dot(xh, y, 3.2);
@@ -700,7 +712,7 @@ function renderService(map, W, opts) {
700
712
  const rowId = `route:${e.key}`;
701
713
  over.push(tg(each(e, m => `socket:${m.key}`), mark.socket(xSock, y, rS * 1.05, writesIn(pr), pr.length > 0 && !writesIn(pr))));
702
714
  socketPt.set(e.key, [xSock, y]);
703
- const gw = p && p.guards.length ? tw(p.guards.join(', '), fsS) / 2 + 8 : 0;
715
+ const gw = p && p.guards.length ? tw(e._chk.p.text, fsS) / 2 + 8 : 0;
704
716
  // the handed-in function's name above the path; over a check whose name went below, high enough to clear the diamond
705
717
  if (handed.length) out.push(owned(hull, rowB(e, Tw(xSockText, y - (below ? (phone ? 9 : 11) : (phone ? 6 : 7)), handed, { size: fsS, fill: C.hullInk }, p.label, lead, 'up'))));
706
718
  reach.forEach((f, k) => out.push(owned(hull, rowB(e, T(rightX, line(y, k + 1), f.s, { size: f.size, fill: C.db, full: reachLabel(pr) })))));
@@ -709,8 +721,8 @@ function renderService(map, W, opts) {
709
721
  if (p && p.guards.length) {
710
722
  out.push(`<path${anchorAttr(rowId)} d="M${f1(xSock + rS)} ${y} H${f1(xPG)}" stroke="${C.ink2}" stroke-width="${f1(cableW(e))}"/>`);
711
723
  const gkeys = each(e, m => (m.passed[0]?.guardIds || []).map(g => `guard:${m.key}:passed:${g}`));
712
- guardAt(gkeys, dense || below ? [] : p.guards, xPG, y, (hx1 - xPG) * 2 - 8, 0.9, hull, e);
713
- if (below) out.push(owned(hull, rowB(e, Tw(xPG + 6, line(y, 1), xpgGuard, { size: fsS, fill: C.guard }, p.guards.join(', '), lead))));
724
+ guardAt(gkeys, dense || below ? [] : e._chk.p.names, xPG, y, (hx1 - xPG) * 2 - 8, 0.9, hull, e, svg => checkLabel(e._chk.p, svg));
725
+ if (below) out.push(owned(hull, rowB(e, checkLabel(e._chk.p, Tw(xPG + 6, line(y, 1), xpgGuard, { size: fsS, fill: C.guard }, e._chk.p.text, lead)))));
714
726
  if (xpgFork.length || dense) forkAt(e.members.flatMap(m => m.passed[0]?.guardIds || []), xPG + 4, y, xpgGuard.length + 1, hx1 - 6, hull, false, e);
715
727
  }
716
728
  // past the hull: the after band, then the reply bar
@@ -724,7 +736,7 @@ function renderService(map, W, opts) {
724
736
  const step = (xr - hx1) / (items.length + 1);
725
737
  items.forEach((draw, k) => draw(hx1 + step * (k + 1)));
726
738
  // the after band's checks by name, above the path at its right end, and its calls and tables under it
727
- if (b.guards.length) out.push(rowB(e, Tw(xr - 4, y - (phone ? 9 : 10.5), afterGuard, { anchor: 'end', size: fsS, fill: C.guard, mask: true }, b.guards.join(', '), lead, 'up')));
739
+ if (b.guards.length) out.push(rowB(e, checkLabel(e._chk.a, Tw(xr - 4, y - (phone ? 9 : 10.5), afterGuard, { anchor: 'end', size: fsS, fill: C.guard, mask: true }, e._chk.a.text, lead, 'up'))));
728
740
  if (items.length && afterWords.length) out.push(rowB(e, Tw(xr - 2, line(y, afterAt), afterWords, { anchor: 'end', size: fsS, ...(afterWide ? { mask: true } : {}) }, [...b.calls, ...(b.effects.length ? [reachLabel(b.effects)] : [])].join(', '), lead)));
729
741
  over.push(mark.reply(xr, y, dense ? 0.75 : S));
730
742
  }
@@ -733,7 +745,8 @@ function renderService(map, W, opts) {
733
745
  // ---- the quiet line (after rows, so forks placed on rows are left out) ----
734
746
  let quietBottom = rowsBottom;
735
747
  const strays = dbUses.filter(u => u.at.node === 'definition' || (u.at.entry && !rowOf.has(u.at.entry)));
736
- const Q = opts.noQuiet ? null : layoutQuiet(quietItems(map, forkPlaced, { services: true, dense, phone, strays }), x0 + (phone ? 22 : 40), x1 - (phone ? 8 : 20), quietTop, phone, fsS);
748
+ const Q = opts.noQuiet ? null : layoutQuiet(quietItems(map, forkPlaced, { services: true, dense, phone, strays }), x0 + (phone ? 22 : 40), x1 - (phone ? 8 : 20), quietTop, phone, fsS,
749
+ [...lanes.values()].map(l => [l.x - l.m, l.x + l.m]));
737
750
  if (Q && Q.items) {
738
751
  const body = Q.under.join('') + Q.out.join('') + Q.over.join('');
739
752
  out.push(dense ? `<g${opener('quiet', 'Open the parts on no entry path')}>${body}<rect x="${x0 + 4}" y="${f1(quietTop)}" width="${f1(cw - 8)}" height="${f1(Q.bottom - quietTop)}" fill="transparent"/></g>` : body);
@@ -745,7 +758,12 @@ function renderService(map, W, opts) {
745
758
  const ghostLink = map.ghostCallers.some(g => g.kind === 'link');
746
759
  // the struck importer sits above the rail's left end, clear of HTTP and of the platform under the rail
747
760
  const busY = Math.max(Q && Q.items ? quietBottom + (phone ? 14 : 18) : rowsEnd + (phone ? 40 : 46), ghostLink && anyRail ? httpY + (phone ? 104 : 134) : 0);
748
- const yBot = anyRail ? busY + (phone ? 18 : 22) : (Q && Q.items ? quietBottom + (phone ? 10 : 14) : rowsEnd + (phone ? 34 : 38));
761
+ const yBot0 = anyRail ? busY + (phone ? 18 : 22) : (Q && Q.items ? quietBottom + (phone ? 10 : 14) : rowsEnd + (phone ? 34 : 38));
762
+ // ---- the program band: under the rail, at the bottom of the wall ----
763
+ const PB = (map.programs || []).length ? programBand(map.programs, x0, x1, yBot0 + (phone ? 10 : 14), phone,
764
+ { xr, y: key => { const rk = rowOf.get(key); return rk == null ? null : rowY.get(rk); } }) : null;
765
+ if (PB) { under.push(PB.under); out.push(PB.out); over.push(PB.over); }
766
+ const yBot = PB ? PB.bottom + (phone ? 10 : 14) : yBot0;
749
767
  const openGap = e => (phone ? 6 : 7) + (e.members.length > 1 ? Math.min(7, e.members.length * 1.1) : 0);
750
768
  const wall = wallPathGaps(x0, x1, yTop, yBot, ents.map(e => [rowY.get(e.key), openGap(e)]));
751
769
 
@@ -767,10 +785,18 @@ function renderService(map, W, opts) {
767
785
 
768
786
  // ---- the rack, outside on the right, in fixed slots ----
769
787
  const rackX = x1 + resW / 2 + (phone ? 2 : 4);
770
- // a model reached from an entry: how many entries reach it
771
- const nModel = map.entries.filter(e => (e.model || e.beside?.resources.includes('model')) && rowOf.has(e.key)).length;
772
- const countLines = tw(`reached by ${nModel}`, phone ? 9 : 10) <= resW - 4 ? [`reached by ${nModel}`] : ['reached', `by ${nModel}`];
773
- const R = rack(map, rackX, yTop + (phone ? 6 : 10), phone, resW - 4, nModel ? { model: { lines: countLines, kind: 'modelCount' } } : {});
788
+ // each resource: how many of the routes drawn reach it, and a glyph sized by that share (its area, like a bar's
789
+ // length, says how much it is used), never so small its name leaves it
790
+ const shown = map.entries.filter(e => rowOf.has(e.key));
791
+ const reaches = (e, k) => !!e[k] || (e.beside?.resources || []).includes(k);
792
+ const nReach = Object.fromEntries(RACK.map(k => [k, shown.filter(e => reaches(e, k)).length]));
793
+ const glyphW = phone ? 38 : 64, nameSz = phone ? 9.5 : 12;
794
+ // (as wide as its name needs, as tall as its share)
795
+ const share = k => Math.max(0.5, Math.sqrt(nReach[k] / shown.length));
796
+ const scale = Object.fromEntries(RACK.filter(k => nReach[k]).map(k => [k, { w: Math.min(1, Math.max((tw(k, nameSz, false, 700) + 12) / glyphW, share(k))), h: share(k) }]));
797
+ const countLines = n => tw(`reached by ${n}`, phone ? 9 : 10) <= resW - 4 ? [`reached by ${n}`] : ['reached', `by ${n}`];
798
+ const notes = Object.fromEntries(RACK.filter(k => nReach[k] && map.resources.some(r => r.kind === k)).map(k => [k, { lines: countLines(nReach[k]), kind: 'reachedBy', keys: [`reachedBy:${k}`] }]));
799
+ const R = rack(map, rackX, yTop + (phone ? 6 : 10), phone, resW - 4, notes, scale);
774
800
  out.push(R.svg);
775
801
 
776
802
  // ---- supply of the db handle ----
@@ -832,12 +858,302 @@ function renderService(map, W, opts) {
832
858
  }
833
859
  }
834
860
  out.push(T(x0 + (phone ? 10 : 13), busY - (phone ? 6 : 7), 'db', { size: phone ? 9 : 10, weight: 700, fill: C.db, mono: false }));
861
+ // once, by the rail's name: what a ? on it means
862
+ const someMixed = dbUses.filter(onRail).some(u => u.how === 'mixed');
863
+ if (someMixed) {
864
+ const kx = x0 + (phone ? 10 : 13) + tw('db', phone ? 9 : 10, false) + (phone ? 10 : 12), ky = busY - (phone ? 9.5 : 10.5);
865
+ over.push(tg(dbUses.filter(u => onRail(u) && u.how === 'mixed').map(u => `unknownKey:${u.i}`), mark.unknown(kx, ky, phone ? 5.5 : 6) +
866
+ T(kx + (phone ? 8 : 9), busY - (phone ? 6 : 7), UNTRACED_WORD, { size: 9, fill: C.muted, mono: false })));
867
+ }
835
868
  }
836
869
 
837
870
  const H = Math.max(yBot, R.bottom) + (phone ? 12 : 20);
838
871
  return outerFrame(W, H, wall.fill + under.join('') + shape('wall', wall.line) + out.join('') + over.join(''), { ...opts, frameTop }, map.program, opts.crumb);
839
872
  }
840
873
 
874
+ // =====================================================================
875
+ // PROGRAM BAND
876
+ // =====================================================================
877
+ // A pure program as a container: its step, its package, and one small
878
+ // diamond per decision it takes, so its area grows with its decisions. Its
879
+ // request constructors are openings on its right edge; each is wired to the
880
+ // engine lane that performs it, with a dot per resource the arm reaches. A
881
+ // request no engine performs is a struck opening. Each lane carries a fork
882
+ // with the engine's own decision count (none: a rule-free engine), ends in
883
+ // the resources its arms reach, and loops back into the program bottom
884
+ // through one loop node that names what repeats the step.
885
+ // A line broken at its spaces and separators so each piece fits `width`.
886
+ export const wrapAt = (text, width, size, mono = true) => {
887
+ const lines = [];
888
+ let cur = '';
889
+ for (const w of String(text).split(/(?<=, |· | )/)) {
890
+ if (cur && tw((cur + w).trimEnd(), size, mono) > width) { lines.push(cur.trimEnd()); cur = w; } else cur += w;
891
+ }
892
+ if (cur) lines.push(cur.trimEnd());
893
+ return lines;
894
+ };
895
+ const RES_WORD = { db: 'db', model: 'model', net: 'net', compile: 'compile', run: 'run', io: 'io' };
896
+ export function programBand(programs, x0, x1, y0, phone, rows = null) {
897
+ const under = [], out = [], over = [];
898
+ // a label that fits the room it is given: shrunk a little, then cut, with
899
+ // the whole text as its title when it is cut
900
+ const fitT = (x, y, s, width, o = {}) => {
901
+ const t = fitText(s, o.size || 10, width, o.mono !== false);
902
+ const svg = T(x, y, t.s, { ...o, size: t.size });
903
+ return t.s === String(s) ? svg : `<g><title>${esc(s)}</title>${svg}</g>`;
904
+ };
905
+ const cw = x1 - x0, fs = phone ? 9.5 : 11, fsS = phone ? 9 : 10;
906
+ const bw = phone ? 12 : 16, lead = phone ? 11 : 12.5;
907
+ let y = y0, wireN = 0;
908
+ for (const p of programs) {
909
+ // the lanes need room for their widest foot pill each; the program gives way
910
+ const pillW = rs => Math.max(tw(RES_WORD[rs], 9, false) * 1.12 + 12, phone ? 26 : 32);
911
+ const needSp = Math.max(0, ...p.engines.flatMap(e => [...e.resources, ...e.also].map(pillW))) + 6;
912
+ const lanesW = needSp * p.engines.length + (phone ? 24 + 26 : 70 + 56);
913
+ // at phone width close lanes stagger their pills, but each lane still needs room for its decision count beside a bar no narrower than 3
914
+ const countSp = 12 + Math.max(0, ...p.engines.map(e => tw(String(e.decisions), fsS, true, 700)));
915
+ const px0 = x0 + (phone ? 8 : 24), px1 = phone ? Math.max(px0 + cw * 0.4, Math.min(px0 + cw * 0.56, x1 - countSp * p.engines.length - 24 - 26))
916
+ : Math.max(px0 + cw * 0.4, Math.min(px0 + cw * 0.5, x1 - lanesW));
917
+ // one scale for every decision bar of this program and its engines
918
+ const maxDec = Math.max(1, p.decisions, ...p.engines.map(e => e.decisions));
919
+ const per = Math.min(phone ? 2 : 2.4, (phone ? 170 : 200) / maxDec);
920
+ const barH = n => Math.max(n > 0 ? 2 : 0, n * per);
921
+ const starts = p.starters.filter(s => !s.advances);
922
+ // the routes that only take a step, as many lines as they need (at most three, then a count with every path in its title)
923
+ const startLines = (() => {
924
+ if (!starts.length) return [];
925
+ const room = (px1 - px0) - (phone ? 18 : 22), groups = [[]];
926
+ // the words lead the first line, or stand on their own when the first path would not fit after them
927
+ const lead = 'one step per call of', alone = tw(`${lead} ${starts[0].path}`, fsS, false) > room;
928
+ const text = (g, j) => `${j || alone ? '' : `${lead} `}${g.join(', ')}`;
929
+ for (const st of starts) {
930
+ const g = groups[groups.length - 1];
931
+ if (!g.length || tw(text([...g, st.path], groups.length - 1) + ',', fsS, false) <= room) g.push(st.path);
932
+ else groups.push([st.path]);
933
+ }
934
+ const lines = [...(alone ? [lead] : []), ...groups.map((g, j) => text(g, j) + (j < groups.length - 1 ? ',' : ''))];
935
+ if (lines.length <= 3) return lines;
936
+ const shown = groups[0].length + (alone ? 0 : groups[1].length);
937
+ return [lines[0], lines[1], `and ${starts.length - shown} more`];
938
+ })();
939
+ // the counts on one line, or two when the program box is narrow
940
+ // its size in words a reader needs no key for: the package's own definitions, and how many more it uses from elsewhere
941
+ const own = `${p.definitions} definitions in ${p.pkgLabel}`;
942
+ // which packages they come from, the most used first: three named, the rest counted
943
+ // (each name stays with its count when the line wraps)
944
+ const from = p.elsewhere.slice(0, 3).map(x => `${x.label}\u00a0${x.n}`).join(', ') + (p.elsewhere.length > 3 ? `, ${p.elsewhere.length - 3}\u00a0more` : '');
945
+ const more = p.usesElsewhere ? `uses ${p.usesElsewhere} more from ${p.elsewhere.length} package${p.elsewhere.length === 1 ? '' : 's'}: ${from}` : '';
946
+ const defsTxt = more ? `${own} · ${more}` : own;
947
+ const headRoom = cw * (phone ? 0.56 : 0.5) - (phone ? 26 : 34);
948
+ const defsLines = tw(defsTxt, fsS, false) <= headRoom ? [defsTxt] : [own, more].filter(Boolean).flatMap(l => wrapAt(l, headRoom, fsS, false));
949
+ const headH = (phone ? 40 : 44) + (defsLines.length - 1) * lead + startLines.length * lead;
950
+ const vMax = 8;
951
+ const variantRows = q => q.variants.reduce((n, v) => n + Math.min(v.constructors.length, vMax + 1), 0);
952
+ const osOf = q => (phone ? 30 : 34) + variantRows(q) * lead;
953
+ const labW = Math.min((px1 - px0) * (phone ? 0.58 : 0.42), Math.max(...p.requests.map(q => Math.max(tw(q.constructor, fs), tw(q.fields.join(', '), fsS),
954
+ ...q.variants.flatMap(v => v.constructors.map(c => tw((n => n ? `${c} → ${n}` : c)(v.namings && v.namings[0] && (v.namings[0].names.find(x => x.constructor === c) || {}).name), fsS) + 8))))) + 4);
955
+ const openH = p.requests.reduce((h, q) => h + osOf(q), 0);
956
+ const bodyH = Math.max(openH, barH(p.decisions) + (phone ? 34 : 30));
957
+ const top = y + (phone ? 18 : 20), bot = top + headH + bodyH + (phone ? 10 : 14);
958
+ const openY = []; { let yy = top + headH; for (const q of p.requests) { openY.push(yy + (phone ? 15 : 17)); yy += osOf(q); } }
959
+ const gapOf = new Set(p.gaps.map(g => g.constructor));
960
+ const og = phone ? 6 : 7, r = phone ? 9 : 12;
961
+ let edge = `M${f1(px1 - r)} ${f1(top)} Q${f1(px1)} ${f1(top)} ${f1(px1)} ${f1(top + r)}`;
962
+ for (const oy of openY) edge += ` V${f1(oy - og)} M${f1(px1)} ${f1(oy + og)}`;
963
+ edge += ` V${f1(bot - r)} Q${f1(px1)} ${f1(bot)} ${f1(px1 - r)} ${f1(bot)} H${f1(px0 + r)} Q${f1(px0)} ${f1(bot)} ${f1(px0)} ${f1(bot - r)} V${f1(top + r)} Q${f1(px0)} ${f1(top)} ${f1(px0 + r)} ${f1(top)} H${f1(px1 - r)}`;
964
+ // routes that only replay it to read where it is: named on hover, never drawn as starting it
965
+ const readBy = p.replays.length ? `<title>${esc(`read, not started, by ${p.replays.map(r => r.path).join(', ')}: they replay it and hand its answer back`)}</title>` : '';
966
+ under.push(tg([`program:${p.id}`, ...p.replays.map(r => `replay:${p.id}:${r.entry}`)], shape('program', `${readBy}<rect x="${f1(px0)}" y="${f1(top)}" width="${f1(px1 - px0)}" height="${f1(bot - top)}" rx="${r}" fill="${C.programSoft}"/>` +
967
+ `<path d="${edge}" fill="none" stroke="${C.program}" stroke-width="2"/>`)));
968
+ const ix = px0 + (phone ? 8 : 12);
969
+ out.push(tg([`program:${p.id}`], fitT(px0 + 2, top - (phone ? 5 : 6), p.pkg, px1 - px0 - 4, { size: phone ? 9.5 : 11, weight: 700, fill: C.program, mono: false, haloColor: C.paper })));
970
+ // effects in the program's own package: marked; none: a struck ghost
971
+ const markW = phone ? 50 : 64, mx = px1 - (phone ? 8 : 12) - markW / 2, my = top + (phone ? 12 : 13);
972
+ if (!p.beside.length) out.push(tg([`pure:${p.id}`], shape('struck', `<title>${esc(`no effects in ${p.pkg}`)}</title>` +
973
+ `<circle cx="${f1(mx - markW / 2 + 6)}" cy="${f1(my - 3)}" r="4.5" fill="none" stroke="${C.faint}" stroke-width="1.2" stroke-dasharray="2.4 2"/>` +
974
+ `<path d="M${f1(mx - markW / 2 + 0.5)} ${f1(my + 2)} L${f1(mx - markW / 2 + 11.5)} ${f1(my - 8)}" stroke="${C.faint}" stroke-width="1.2"/>` +
975
+ T(mx - markW / 2 + 14, my, 'effects', { size: 9, fill: C.muted, mono: false, halo: false }))));
976
+ else out.push(tg(p.beside.map(b => `beside:${p.id}:${b.id}`), shape('programEffect', `<title>${esc(`effects in ${p.pkg}: ${p.beside.map(b => `${b.label} (${(b.resources.length ? b.resources : ['io']).join(', ')})`).join('; ')}`)}</title>` +
977
+ `<circle cx="${f1(mx - markW / 2 + 6)}" cy="${f1(my - 3)}" r="4.5" fill="${C.guard}"/>` +
978
+ T(mx - markW / 2 + 14, my, `${p.beside.length} effect${p.beside.length === 1 ? '' : 's'}`, { size: 9, weight: 700, fill: C.guard, mono: false, halo: false }))));
979
+ out.push(fitT(ix, top + (phone ? 15 : 17), `${p.label} → ${p.result}`, px1 - ix - markW - (phone ? 10 : 16), { size: fs, weight: 700, fill: C.ink }));
980
+ // definitions: the package's own, as the layer stack counts them, and what the program reaches
981
+ const defsTitle = p.elsewhere.length ? `<title>${esc(`${p.usesElsewhere} definition${p.usesElsewhere === 1 ? '' : 's'} from other packages: ${p.elsewhere.map(x => `${x.pkg} ${x.n}`).join(', ')}`)}</title>` : '';
982
+ out.push(tg([`definitions:${p.id}`, ...p.elsewhere.map(x => `elsewhere:${p.id}:${x.pkg}`)], defsTitle + defsLines.map((l, j) => fitT(ix, top + (phone ? 29 : 33) + j * lead, l, px1 - ix - 10, { size: fsS, fill: C.muted, mono: false })).join('')));
983
+ if (starts.length) out.push(tg(starts.map(s => `starter:${p.id}:${s.entry}`), `<title>${esc(`${starts.map(s => s.path).join(', ')}: each call takes one step of ${p.label} and acts on its answer; the rounds are run by the engines on the right`)}</title>` +
984
+ startLines.map((l, j) => fitT(ix, top + (phone ? 29 : 33) + (defsLines.length + j) * lead, l, px1 - ix - 10, { size: fsS, fill: C.ink2, mono: false })).join('')));
985
+ // the program's decisions: a bar on the scale the engines' bars share, and its count
986
+ const by0 = top + headH + 4, bh = barH(p.decisions), dx = ix + bw + 6, dRoom = px1 - labW - (phone ? 14 : 20) - dx;
987
+ over.push(tg([`decisions:${p.id}`], shape('decision', `<title>${esc(`${p.decisions} decisions`)}</title><rect x="${f1(ix)}" y="${f1(by0)}" width="${bw}" height="${f1(bh)}" fill="${C.program}" opacity="0.8"/>`) +
988
+ T(dx, by0 + 10, String(p.decisions), { size: phone ? 11 : 13, weight: 700, fill: C.program }) +
989
+ (dRoom >= tw('decisions', fsS, false) ? T(dx, by0 + 10 + lead, 'decisions', { size: fsS, fill: C.muted, mono: false }) : '')));
990
+ // lanes on the resource side
991
+ const k = p.engines.length;
992
+ const lx0 = px1 + (phone ? 24 : 70), lx1 = x1 - (phone ? 26 : 56);
993
+ const laneX = p.engines.map((_, i) => lx0 + (lx1 - lx0) * (i + 0.5) / Math.max(1, k));
994
+ const spacing = (lx1 - lx0) / Math.max(1, k);
995
+ // an engine's decision bar, and its count beside it, end short of the next lane (the bar no narrower than 3)
996
+ const forkW = Math.max(3, Math.min(bw, spacing - 9 - Math.max(...p.engines.map(e => tw(String(e.decisions), fsS, true, 700)))));
997
+ // what an engine does, from its type: what it takes of the program's own and what its effect yields; its code name on hover
998
+ // (the arrow stays with what it gives, so a wrapped line never ends on it)
999
+ const sigOf = e => `${e.type.plainTakes.join(', ') || '()'} →\u00a0${e.type.yields}`;
1000
+ const codeOf = e => `${e.label}: ${e.type.takes.join(', ')} → ${e.type.returns}`;
1001
+ // above each lane: its package, then what it does on at most two lines, when every lane's words fit there
1002
+ const sigLines = e => wrapAt(sigOf(e), spacing - 6, fsS);
1003
+ const wide = p.engines.every(x => tw(x.pkgLabel, fsS) <= spacing - 6 && sigLines(x).length <= 2 && sigLines(x).every(l => tw(l, fsS) <= spacing - 6));
1004
+ const laneTop = top + headH - (phone ? 6 : 8);
1005
+ const resH = phone ? 15 : 18, resGap = phone ? 4 : 5;
1006
+ const laneBot = Math.max(bot, openY.length ? openY[openY.length - 1] + 14 : bot, ...p.engines.map(e => laneTop + barH(e.decisions) + 8));
1007
+ const footOf = e => e.resources.length * (resH + resGap) + (e.also.length ? lead + e.also.length * (resH + resGap) : 0);
1008
+ // lanes closer than a pill: every other lane's pills start below its neighbours'
1009
+ const cramped = spacing < needSp;
1010
+ const evenFoot = Math.max(0, ...p.engines.filter((_, i) => i % 2 === 0).map(footOf));
1011
+ const footY0 = i => laneBot + 6 + (cramped && i % 2 ? evenFoot + resGap : 0);
1012
+ const footBot = Math.max(laneBot + 6, ...p.engines.map((e, i) => footY0(i) + footOf(e)));
1013
+ // each loop's caption, one thought per line: which engine (when the lanes
1014
+ // carry numbers), how it repeats and what it replies, and what runs it
1015
+ const capsOf = (e, i) => {
1016
+ // how the rounds come round within one call, and which calls run them
1017
+ const how = e.loop.kind === 'repeat' ? `${e.loop.label} repeats the round` : e.loop.kind === 'recursion' ? `${e.loop.label} calls itself, a round each time` : 'rounds written out one after another';
1018
+ const reply = `replies ${e.replies.map(rs => RES_WORD[rs]).join(', ')}`;
1019
+ const lines = [];
1020
+ if (!wide) lines.push([`${i + 1} ${e.pkgLabel}: ${sigOf(e)}`]);
1021
+ lines.push(phone ? [how] : [`${how} · ${reply}`]);
1022
+ if (phone && e.replies.length) lines.push([reply]);
1023
+ if (e.starters.length) lines.push([`each call of ${e.starters.map(k => k.replace(/^route:/, '')).join(', ')} runs ${e.loop.kind === 'sequence' ? 'them' : 'it'}`, e.starters.map(k => `starter:${p.id}:${k}`)]);
1024
+ return lines;
1025
+ };
1026
+ // a caption never reaches the next lane, whose loop comes down there: it wraps at its separators instead
1027
+ // later lanes come back into the program further left, so no loop crosses another's caption
1028
+ const txOf = i => px0 + (px1 - px0) * (0.3 + 0.5 * (k - i) / (k + 1));
1029
+ // it may start just right of the next lane's way back up
1030
+ const capLeft = i => i < k - 1 ? txOf(i + 1) + 8 : txOf(i);
1031
+ const capRoom = i => (i < k - 1 ? laneX[i + 1] - 6 : x1 - 8) - capLeft(i);
1032
+ const caps = p.engines.map((e, i) => capsOf(e, i).flatMap(([t, keys]) => wrapAt(t, capRoom(i), fsS).map(l => [l, keys])));
1033
+ const loopGap = phone ? 26 : 28;
1034
+ const loopY = []; { let yy = footBot + (phone ? 12 : 14); caps.forEach(c => { loopY.push(yy); yy += loopGap + c.length * lead; }); }
1035
+ p.requests.forEach((q, i) => {
1036
+ const oy = openY[i];
1037
+ const gap = gapOf.has(q.constructor);
1038
+ const col = gap ? C.faint : C.program;
1039
+ const lab = fitText(q.constructor, fs, labW), lw0 = Math.min(labW, tw(lab.s, lab.size));
1040
+ const lx = px1 - (phone ? 8 : 10);
1041
+ out.push(tg([gap ? `gap:${p.id}:${q.constructor}` : `request:${p.id}:${q.constructor}`],
1042
+ shape(gap ? 'gap' : 'request', `<path d="M${f1(px1 - 5)} ${f1(oy - og)} H${f1(px1 + 5)} M${f1(px1 - 5)} ${f1(oy + og)} H${f1(px1 + 5)}" stroke="${col}" stroke-width="2" stroke-linecap="round"/>` +
1043
+ // a struck request's name and the lines that strike it are one label
1044
+ (gap ? `<g data-label="${esc(`gap:${p.id}:${q.constructor}`)}">` : '') + fitT(lx, oy - (q.fields.length ? 1 : -3.5), q.constructor, labW, { anchor: 'end', size: fs, weight: 600, fill: gap ? C.muted : C.ink }) + (gap ? '</g>' : '') +
1045
+ (q.fields.length ? fitT(lx, oy + fsS + 0.5, q.fields.join(', '), labW, { anchor: 'end', size: fsS, fill: C.muted }) : '') +
1046
+ (gap ? `<g data-label="${esc(`gap:${p.id}:${q.constructor}`)}"><path d="M${f1(px1 - 9)} ${f1(oy + 10)} L${f1(px1 + 9)} ${f1(oy - 10)}" stroke="${C.guard}" stroke-width="1.6"/>` +
1047
+ `<path d="M${f1(px1 - 10 - lw0)} ${f1(oy - (q.fields.length ? 4.5 : -0.5))} H${f1(px1 - (phone ? 7 : 9))}" stroke="${C.muted}" stroke-width="1"/></g>` : ''))));
1048
+ // the kinds of this request, from the sum type its payload carries: sub-openings under it
1049
+ let vy = oy + fsS + 0.5 + lead;
1050
+ for (const v of q.variants) {
1051
+ const shown = v.constructors.length > vMax + 1 ? v.constructors.slice(0, vMax) : v.constructors;
1052
+ const nm = v.namings && v.namings[0];
1053
+ const wordOf = (n, c) => (n.names.find(x => x.constructor === c) || {}).name;
1054
+ // one form for every kind of one type: kind and word when they all fit, else the words
1055
+ // a kind's row is its own, so it may reach further left than the request's name, up to the decision bar's words
1056
+ const varW = Math.max(labW - 8, px1 - (phone ? 10 : 12) - (ix + bw + 6 + tw('decisions', fsS, false) + 10));
1057
+ const pairs = !!nm && shown.every(c => !wordOf(nm, c) || tw(`${c} → ${wordOf(nm, c)}`, fsS) <= varW);
1058
+ for (const c of shown) {
1059
+ // the kind, and the program's own word for it when a match gives one: both when they fit, else the word
1060
+ const word = nm && wordOf(nm, c);
1061
+ const both = word ? `${c} → ${word}` : c;
1062
+ const text = !word || pairs ? both : word;
1063
+ const title = word ? `<title>${esc(`${c} → ${word}, by ${nm.label}${v.namings.length > 1 ? `; also ${v.namings.slice(1).map(n => `${wordOf(n, c)} by ${n.label}`).join(', ')}` : ''}`)}</title>` : '';
1064
+ out.push(tg([`variant:${p.id}:${q.constructor}:${c}`, ...(word ? [`variantName:${p.id}:${q.constructor}:${c}`] : [])],
1065
+ shape('variant', `${title}<path d="M${f1(px1 - 3)} ${f1(vy - 3.5)} H${f1(px1 + 3)}" stroke="${col}" stroke-width="1.6" stroke-linecap="round"/>` +
1066
+ fitT(lx, vy, text, pairs ? varW : labW - 8, { anchor: 'end', size: fsS, fill: word ? C.ink : C.ink2, weight: word ? 600 : 400 }))));
1067
+ vy += lead;
1068
+ }
1069
+ if (shown.length < v.constructors.length) {
1070
+ const rest = v.constructors.slice(shown.length);
1071
+ out.push(tg(rest.map(c => `variant:${p.id}:${q.constructor}:${c}`), shape('variant', `<title>${esc(rest.join(', '))}</title>` + T(lx, vy, `+${rest.length} more`, { anchor: 'end', size: fsS, fill: C.muted }))));
1072
+ vy += lead;
1073
+ }
1074
+ }
1075
+ for (const h of q.handledBy) {
1076
+ const li = p.engines.findIndex(e => e.id === h.engine);
1077
+ const wx = laneX[li];
1078
+ // what this arm reaches, said in words over the wire's end where they fit (clear of any lane it passes), else a dot each
1079
+ const words = h.resources.map(rs => RES_WORD[rs]), wgap = 5;
1080
+ const wordsW = words.reduce((w, t) => w + tw(t, 9, false), 0) + wgap * Math.max(0, words.length - 1);
1081
+ const left = Math.max(px1 + 6, ...laneX.filter(x => x < wx).map(x => x + 2.5 + bw + 6));
1082
+ const said = words.length && wordsW <= wx - 6 - left;
1083
+ let wxR = wx - 6;
1084
+ const marks = said ? h.resources.slice().reverse().map(rs => { const t = RES_WORD[rs], w = tw(t, 9, false), x = wxR; wxR -= w + wgap;
1085
+ return T(x, oy - 4, t, { anchor: 'end', size: 9, weight: 700, fill: C[rs] || C.io, mono: false }); }).reverse().join('')
1086
+ : h.resources.map((rs, j) => `<circle cx="${f1(wx - (phone ? 7 : 10) - j * (phone ? 6 : 8))}" cy="${f1(oy)}" r="${phone ? 2.6 : 3.2}" fill="${C[rs] || C.io}" stroke="${C.paper}" stroke-width="1"/>`).join('');
1087
+ const engineLabel = p.engines[li].label;
1088
+ under.push(tg([`wire:${p.id}:${q.constructor}:${h.engine}`], shape('wire', `<title>${esc(`${q.constructor} → ${engineLabel}${words.length ? `: reaches ${words.join(', ')}` : ''}`)}</title>` +
1089
+ `<path d="M${f1(px1 + 1)} ${f1(oy)} H${f1(wx)}" stroke="${C.ink2}" stroke-width="1.5"/>`)));
1090
+ if (marks) over.push(tg([`wire:${p.id}:${q.constructor}:${h.engine}`], marks));
1091
+ }
1092
+ });
1093
+ // the routes that start the program: a wire from their row into its top (wide pictures)
1094
+ const toRow = key => rows && rows.y(key);
1095
+ if (!phone && rows) starts.forEach(s => {
1096
+ const ry = toRow(s.entry);
1097
+ if (ry == null) return;
1098
+ const gx = rows.xr + 10 + (wireN++ % 5) * 5, ex = px1 - 24 - (wireN % 5) * 10, ty = top - (phone ? 16 : 20);
1099
+ under.push(tg([`starter:${p.id}:${s.entry}`], shape('starter', `<path d="M${f1(rows.xr)} ${f1(ry)} H${f1(gx)} V${f1(ty - 6)} H${f1(ex)} V${f1(top - 3)}" fill="none" stroke="${C.program}" stroke-width="1.3" stroke-dasharray="5 3"/>` +
1100
+ `<path d="M${f1(ex - 4)} ${f1(top - 9)} L${f1(ex)} ${f1(top - 2)} L${f1(ex + 4)} ${f1(top - 9)}" fill="none" stroke="${C.program}" stroke-width="1.4"/>`)));
1101
+ });
1102
+ p.engines.forEach((e, i) => {
1103
+ const lx = laneX[i];
1104
+ const title = `<title>${esc(`${e.id}${e.entries.length ? ` · ${e.entries.map(k => k.replace(/^route:/, '')).join(', ')}` : ''}`)}</title>`;
1105
+ under.push(tg([`lane:${e.id}`], shape('lane', `${title}<rect x="${f1(lx - 1.5)}" y="${f1(laneTop)}" width="3" height="${f1(laneBot - laneTop)}" rx="1.5" fill="${C.ink2}"/>`)));
1106
+ // its package and name above the lane, or a number when the lanes are close
1107
+ if (wide) {
1108
+ const sl = sigLines(e), ly0 = laneTop - (phone ? 5 : 6) - (sl.length - 1) * lead;
1109
+ out.push(tg([`lane:${e.id}`, `engineType:${e.id}`], `<g><title>${esc(codeOf(e))}</title>` + T(lx, ly0 - lead, e.pkgLabel, { anchor: 'middle', size: fsS, fill: C.muted }) +
1110
+ sl.map((l, j) => T(lx, ly0 + j * lead, l, { anchor: 'middle', size: fsS, weight: 700, fill: C.ink })).join('') + '</g>'));
1111
+ } else out.push(tg([`lane:${e.id}`, `engineType:${e.id}`], `<g><title>${esc(codeOf(e))}</title>` + T(lx, laneTop - (phone ? 5 : 6), String(i + 1), { anchor: 'middle', size: fsS, weight: 700, fill: C.ink }) + '</g>'));
1112
+ // the engine's own decisions, on the program's scale; its bar gives up width so that the count fits before the next lane
1113
+ const eh = barH(e.decisions);
1114
+ over.push(tg([`laneFork:${e.id}`], shape('laneFork', `<title>${esc(`${e.decisions} decisions of its own`)}</title>` +
1115
+ (eh ? `<rect x="${f1(lx + 2.5)}" y="${f1(laneTop)}" width="${f1(forkW)}" height="${f1(eh)}" fill="${C.ink2}" opacity="0.55"/>` : '') +
1116
+ T(lx + 2.5 + forkW + 3, laneTop + 10, String(e.decisions), { size: fsS, weight: 700, fill: C.ink2 }))));
1117
+ // the routes that run it are named under its loop ("run by …"), not wired: one loop per lane is the only line back
1118
+ // the resources its arms reach, at its foot; then the ones it reaches for no request
1119
+ let yy = footY0(i);
1120
+ if (yy > laneBot + 6) under.push(`<path d="M${f1(lx)} ${f1(laneBot)} V${f1(yy)}" stroke="${C.ink2}" stroke-width="1.2"/>`);
1121
+ const pill = (rs, dashed, key) => {
1122
+ const w = pillW(rs);
1123
+ under.push(`<path d="M${f1(lx)} ${f1(yy - (dashed ? 2 : 6))} V${f1(yy + resH / 2)}" stroke="${C.ink2}" stroke-width="1.2"${dashed ? ' stroke-dasharray="2 2"' : ''}/>`);
1124
+ over.push(tg([key], shape('laneResource', `<rect x="${f1(lx - w / 2)}" y="${f1(yy)}" width="${f1(w)}" height="${resH}" rx="${f1(resH / 2)}" fill="${dashed ? C.paper : C[rs + 'Soft'] || C.ioSoft}" stroke="${C[rs] || C.io}" stroke-width="1.4"${dashed ? ' stroke-dasharray="3 2"' : ''}/>` +
1125
+ T(lx, yy + resH / 2 + 3.2, RES_WORD[rs], { anchor: 'middle', size: 9, weight: 700, fill: C[rs] || C.io, mono: false, halo: false }))));
1126
+ yy += resH + resGap;
1127
+ };
1128
+ e.resources.forEach(rs => pill(rs, false, `laneResource:${e.id}:${rs}`));
1129
+ if (e.also.length) {
1130
+ out.push(tg(e.also.map(rs => `laneAlso:${e.id}:${rs}`), T(lx, yy + 8, 'also', { anchor: 'middle', size: 9, fill: C.muted, mono: false })));
1131
+ yy += lead;
1132
+ e.also.forEach(rs => pill(rs, true, `laneAlso:${e.id}:${rs}`));
1133
+ }
1134
+ // the reply: one loop from the foot back into the program, one loop node on it
1135
+ const ly = loopY[i];
1136
+ const tx = txOf(i);
1137
+ const d = `M${f1(lx)} ${f1(yy - resGap)} V${f1(ly - 8)} Q${f1(lx)} ${f1(ly)} ${f1(lx - 8)} ${f1(ly)} H${f1(tx + 8)} Q${f1(tx)} ${f1(ly)} ${f1(tx)} ${f1(ly - 8)} V${f1(bot + 6)}`;
1138
+ const arrow = `<path d="M${f1(tx - 4)} ${f1(bot + 8)} L${f1(tx)} ${f1(bot + 1.5)} L${f1(tx + 4)} ${f1(bot + 8)}" fill="none" stroke="${C.program}" stroke-width="1.6" stroke-linejoin="round"/>`;
1139
+ const nx = (tx + lx) / 2, nr = phone ? 5 : 6;
1140
+ const node = `<circle cx="${f1(nx)}" cy="${f1(ly)}" r="${nr + 3}" fill="${C.paper}"/>` + mark.loop(nx, ly, nr, C.program);
1141
+ // centred under the loop when it fits there, else from the loop's left end
1142
+ // every line of one caption set the same way: centred when they all fit there
1143
+ const centred = caps[i].every(([t]) => { const m = fitText(t, fsS, lx - tx - 8); return m.s === t && m.size === fsS; });
1144
+ const caption = (cy, text) => centred ? T(nx, cy, text, { anchor: 'middle', size: fsS, fill: C.program })
1145
+ : fitT(capLeft(i), cy, text, capRoom(i), { size: fsS, fill: C.program });
1146
+ const cy0 = ly + nr + (phone ? 11 : 13);
1147
+ const own = caps[i].filter(l => !l[1]), by = caps[i].filter(l => l[1]);
1148
+ over.push(tg([`reply:${e.id}`, `loop:${e.id}`], shape('replyLoop', `<title>${esc(codeOf(e))}</title><path d="${d}" fill="none" stroke="${C.program}" stroke-width="1.6"/>${arrow}${node}` +
1149
+ own.map((l, j) => caption(cy0 + j * lead, l[0])).join(''))) +
1150
+ (by.length ? tg(by[0][1], by.map((l, j) => caption(cy0 + (own.length + j) * lead, l[0])).join('')) : ''));
1151
+ });
1152
+ y = (k ? loopY[k - 1] + loopGap + caps[k - 1].length * lead : footBot + (phone ? 12 : 14)) + (phone ? 6 : 10);
1153
+ }
1154
+ return { under: under.join(''), out: out.join(''), over: over.join(''), bottom: y };
1155
+ }
1156
+
841
1157
  const ring = (x, y, r, color) => `<circle cx="${f1(x)}" cy="${f1(y)}" r="${f1(r)}" fill="${C.paper}" stroke="${color}" stroke-width="1.8"/>`;
842
1158
  // one caller's bracket: a line from the caller to a spine beside the wall, a tick to each opening
843
1159
  function bracket(sx, sy, bx, y0, y1, ys) {
@@ -898,6 +1214,10 @@ function wallPathGaps(x0, x1, yTop, yBot, openings, r = 14) {
898
1214
  // line; each package is a stretch starting with a tick and its name; the
899
1215
  // parts' labels alternate above and below the line so it never wraps at
900
1216
  // phone width. A package with many forks draws one fork with a count.
1217
+ // A gathered mark's count, said in words: what it counts, never a bare ×n.
1218
+ const SET_WORDS = { forkset: ['choice', 'choices'], dbset: ['db use', 'db uses'], genset: ['generic part', 'generic parts'], stepset: ['step', 'steps'] };
1219
+ export const setWord = (kind, n) => `${n} ${SET_WORDS[kind][n === 1 ? 0 : 1]}`;
1220
+
901
1221
  export function quietItems(map, placed, { services = false, dense = false, phone = false, drawn = new Set(), socketFns = new Map(), genericById = new Map(), exportsLeft = [], strays = [] } = {}) {
902
1222
  const pkgOf = id => String(id).split('::')[0];
903
1223
  const bare = id => String(id).split('::').pop();
@@ -917,7 +1237,7 @@ export function quietItems(map, placed, { services = false, dense = false, phone
917
1237
  for (const u of strays) { const p = pkgOf(u.definition); pk(p); if (!strayBy.has(p)) strayBy.set(p, []); strayBy.get(p).push(u); }
918
1238
  for (const [p, us] of strayBy) {
919
1239
  const defs = [...new Set(us.map(u => u.definition))];
920
- P.get(p).parts.push({ kind: 'dbset', label: defs.length === 1 && !dense ? bare(defs[0]) : `×${defs.length}`, writes: us.some(u => u.writes), keys: us.map(u => `use:${u.i}`) });
1240
+ P.get(p).parts.push({ kind: 'dbset', label: defs.length === 1 && !dense ? bare(defs[0]) : setWord('dbset', defs.length), n: defs.length, writes: us.some(u => u.writes), keys: us.map(u => `use:${u.i}`) });
921
1241
  }
922
1242
  // forks (and bare generic parts) of one package gathered under one mark with a count when the line would be long
923
1243
  const total = [...list.values()].reduce((n, l) => n + l.length, 0);
@@ -926,10 +1246,10 @@ export function quietItems(map, placed, { services = false, dense = false, phone
926
1246
  const g = P.get(pkg);
927
1247
  const forks = ids2.filter(id => keyed.has(id) && !socketFns.has(id));
928
1248
  let rest = ids2.filter(id => !forks.includes(id));
929
- if (gather && forks.length > 1) g.parts.push({ kind: 'forkset', label: `×${forks.length}`, fork: [...new Set(forks.flatMap(id => keyed.get(id).types))], keys: forks.flatMap(id => [`fork:${id}`, `part:${id}`, ...(genericById.has(id) ? [`generic:${id}`] : [])]) });
1249
+ if (gather && forks.length > 1) g.parts.push({ kind: 'forkset', label: setWord('forkset', forks.length), n: forks.length, fork: [...new Set(forks.flatMap(id => keyed.get(id).types))], keys: forks.flatMap(id => [`fork:${id}`, `part:${id}`, ...(genericById.has(id) ? [`generic:${id}`] : [])]) });
930
1250
  else rest = rest.concat(forks);
931
1251
  const gens = rest.filter(id => genericById.has(id) && !socketFns.has(id) && !keyed.has(id));
932
- if (gather && gens.length > 1) { g.parts.push({ kind: 'genset', label: `×${gens.length}`, gen: [...new Set(gens.flatMap(id => genericById.get(id).vars))].join(''), keys: gens.flatMap(id => [`generic:${id}`, `part:${id}`]) }); rest = rest.filter(id => !gens.includes(id)); }
1252
+ if (gather && gens.length > 1) { g.parts.push({ kind: 'genset', label: setWord('genset', gens.length), n: gens.length, gen: [...new Set(gens.flatMap(id => genericById.get(id).vars))].join(''), keys: gens.flatMap(id => [`generic:${id}`, `part:${id}`]) }); rest = rest.filter(id => !gens.includes(id)); }
933
1253
  for (const id of rest.sort()) g.parts.push({ kind: 'part', id, label: bare(id), socket: socketFns.has(id), fns: socketFns.get(id) || null,
934
1254
  fork: keyed.has(id) ? keyed.get(id).types : null, gen: genericById.has(id) ? genericById.get(id).vars.join('') : null,
935
1255
  keys: [...(socketFns.has(id) ? [`applied:${id}`] : []), ...(keyed.has(id) ? [`fork:${id}`] : []), ...(genericById.has(id) ? [`generic:${id}`] : []), `part:${id}`] });
@@ -942,11 +1262,11 @@ export function quietItems(map, placed, { services = false, dense = false, phone
942
1262
  const of = k => all.filter(it => it.kind === k || (k === 'forkset' && it.fork && it.kind === 'part'));
943
1263
  const parts = [];
944
1264
  const forks = of('forkset'), dbs = of('dbset'), others = all.filter(it => !forks.includes(it) && !dbs.includes(it));
945
- const n = list => list.reduce((c, it) => c + (it.kind === 'part' ? 1 : +String(it.label).replace('×', '') || 1), 0);
946
- if (forks.length) parts.push({ kind: 'forkset', label: `×${n(forks)}`, fork: [...new Set(forks.flatMap(it => it.fork))], keys: forks.flatMap(it => it.keys) });
947
- if (dbs.length) parts.push({ kind: 'dbset', label: `×${dbs.reduce((c, it) => c + (+String(it.label).replace('×', '') || 1), 0)}`, writes: dbs.some(it => it.writes), keys: dbs.flatMap(it => it.keys) });
1265
+ const n = list => list.reduce((c, it) => c + (it.n || 1), 0);
1266
+ if (forks.length) parts.push({ kind: 'forkset', label: setWord('forkset', n(forks)), n: n(forks), fork: [...new Set(forks.flatMap(it => it.fork))], keys: forks.flatMap(it => it.keys) });
1267
+ if (dbs.length) parts.push({ kind: 'dbset', label: setWord('dbset', n(dbs)), n: n(dbs), writes: dbs.some(it => it.writes), keys: dbs.flatMap(it => it.keys) });
948
1268
  parts.push(...others);
949
- if (steps.length) parts.push({ kind: 'stepset', label: `×${steps.length}`, keys: steps.map(st => `step:${st.pkg}:${st.type}`) });
1269
+ if (steps.length) parts.push({ kind: 'stepset', label: setWord('stepset', steps.length), n: steps.length, keys: steps.map(st => `step:${st.pkg}:${st.type}`) });
950
1270
  return [{ pkg: '', exports: [], steps: [], parts, gathered: groups.length }];
951
1271
  }
952
1272
 
@@ -956,13 +1276,17 @@ export function quietItems(map, placed, { services = false, dense = false, phone
956
1276
  // above and below, so a label only has to clear the last one on its side.
957
1277
  function placeOn(pkgs, lsx, phone, f, M) {
958
1278
  const out = []; let x = lsx, lastUp = null, lastDown = null;
1279
+ // the risers that cross the line: the first x at or after x0 where [x + a, x + b] crosses none
1280
+ const bands = M.bands || [];
1281
+ const shun = (x0, a, b) => { let v = x0; for (let k = 0; k < 8; k++) { const r = bands.find(([lo, hi]) => v + b > lo && v + a < hi); if (!r) break; v = r[1] - a + 1; } return v; };
959
1282
  for (const g of pkgs) {
960
1283
  // headers alternate between two tiers, so a tick clears the header two packages back
961
1284
  const hdrBack = out.length >= 2 ? out[out.length - 2].tick + out[out.length - 2].hw : 0;
962
1285
  // a tick from the upper tier runs down past the lower tier's header before it
963
1286
  const hdrPrev = out.length % 2 === 1 ? out[out.length - 1].tick + out[out.length - 1].hw : 0;
964
- const tick = out.length ? Math.max(x + (phone ? 10 : 16), hdrBack, hdrPrev, lastUp && lastUp.x !== out[out.length - 1].tick ? lastUp.x + lastUp.w + 4 : out[out.length - 1].tick + (phone ? 16 : 22), lastDown ? lastDown.x + lastDown.w + 4 : 0) : x - (phone ? 9 : 14);
1287
+ const tick0 = out.length ? Math.max(x + (phone ? 10 : 16), hdrBack, hdrPrev, lastUp && lastUp.x !== out[out.length - 1].tick ? lastUp.x + lastUp.w + 4 : out[out.length - 1].tick + (phone ? 16 : 22), lastDown ? lastDown.x + lastDown.w + 4 : 0) : x - (phone ? 9 : 14);
965
1288
  const hw = M.header(g, f), sw = M.steps(g, f);
1289
+ const tick = shun(tick0, -(phone ? 4 : 6), Math.max(hw, sw));
966
1290
  lastUp = { x: tick, w: phone ? 10 : 14 }; lastDown = sw ? { x: tick, w: sw } : null;
967
1291
  let px = tick + (phone ? 9 : 14), flip = 0;
968
1292
  const xs = [], sides = [];
@@ -973,6 +1297,7 @@ function placeOn(pkgs, lsx, phone, f, M) {
973
1297
  let xx = px;
974
1298
  if (last && !(side === 'up' && last.x === tick)) xx = Math.max(xx, last.x + last.w);
975
1299
  if (side === 'down' && last && last.x === tick) xx = Math.max(xx, last.x + last.w + 2);
1300
+ xx = shun(xx, -(phone ? 6 : 7), M.width(it, f) - (phone ? 7 : 10));
976
1301
  xs.push(xx); sides.push(side);
977
1302
  const rec = { x: xx, w: M.width(it, f) };
978
1303
  if (side === 'up') lastUp = rec; else lastDown = rec;
@@ -988,13 +1313,15 @@ function placeOn(pkgs, lsx, phone, f, M) {
988
1313
  // The quiet line with its names at an angle: parts' names rise to the right
989
1314
  // from their marks; each package's name, with its steps, falls to the right
990
1315
  // from its tick. One line, however many parts. null when even this would not fit.
1316
+ // a gathered set of choices says what they choose on in its own words; anything else is its label
1317
+ const setLabel = it => it.kind === 'forkset' ? `${it.label} on ${it.fork.join(', ')}` : it.label;
991
1318
  function angledQuiet(pkgs, lsx, lex, y0, phone, fsS) {
992
1319
  const ang = 50, sin = Math.sin(ang * Math.PI / 180), cos = Math.cos(ang * Math.PI / 180);
993
1320
  const pkgName = p => p.split('/').pop();
994
1321
  // each name as its spans: [text, fill, attributes, how it is measured: mono, weight, italic]
995
1322
  const upSpans = it => [...(it.gen ? [[`∀${it.gen} `, C.hullInk, ` font-family="${SANS}" font-weight="700"`, false, 700]] : []),
996
- [it.label, C.ink, it.kind === 'part' ? '' : ' font-weight="700"', true, it.kind === 'part' ? 400 : 700],
997
- ...(it.fork ? [[' ' + it.fork.join(','), C.muted, '', true, 400]] : []), ...(it.fns ? [[' ' + it.fns.join(', '), C.hullInk, ' font-style="italic"', true, 400, true]] : [])];
1323
+ [setLabel(it), C.ink, it.kind === 'part' ? '' : ' font-weight="700"', true, it.kind === 'part' ? 400 : 700],
1324
+ ...(it.fork && it.kind !== 'forkset' ? [[' ' + it.fork.join(','), C.muted, '', true, 400]] : []), ...(it.fns ? [[' ' + it.fns.join(', '), C.hullInk, ' font-style="italic"', true, 400, true]] : [])];
998
1325
  const downSpans = g => [[pkgName(g.pkg), C.faint, ` font-family="${SANS}" font-weight="700"`, false, 700],
999
1326
  ...(g.steps.length ? [[' ' + g.steps.map(st => st.label).join(', '), C.hullInk, ' font-weight="700"', true, 700]] : [])];
1000
1327
  const lay = pitch => {
@@ -1058,15 +1385,18 @@ function angledQuiet(pkgs, lsx, lex, y0, phone, fsS) {
1058
1385
  return { items: pkgs.length, under, out, over, bottom: ry + downLen * sin + (phone ? 4 : 6) };
1059
1386
  }
1060
1387
 
1061
- export function layoutQuiet(pkgs, lsx, lex, y0, phone, fsS0) {
1388
+ export function layoutQuiet(pkgs, lsx, lex, y0, phone, fsS0, bands = []) {
1062
1389
  pkgs = pkgs.filter(g => g.parts.length || g.steps.length || g.exports.length);
1063
1390
  if (!pkgs.length) return { items: 0, under: [], out: [], over: [], bottom: y0 };
1064
1391
  const pkgName = p => p.split('/').pop();
1065
1392
  let fsS = fsS0;
1066
- const sub = it => it.fork ? it.fork.join(',') : it.fns ? it.fns.join(', ') : '';
1393
+ // a gathered set of choices says what they choose on in its own line of words, with a wider gap after it
1394
+ // so the next count does not read as part of it; a single part keeps its type under it
1395
+ const sub = it => it.kind === 'forkset' ? '' : it.fork ? it.fork.join(',') : it.fns ? it.fns.join(', ') : '';
1067
1396
  const M = {
1397
+ bands,
1068
1398
  // as drawn: a gathered count and a step set in bold, ∀ in bold sans
1069
- width: (it, f) => Math.max((it.gen ? tw(`∀${it.gen} `, f, false, 700) : 0) + tw(it.label, f, true, it.kind === 'part' ? 400 : 700), tw(sub(it), Math.max(MIN_LABEL, f - 0.5)) + (it.fork ? (phone ? 9 : 12) : 0)) + (phone ? 7 : 10),
1399
+ width: (it, f) => Math.max((it.gen ? tw(`∀${it.gen} `, f, false, 700) : 0) + tw(setLabel(it), f, true, it.kind === 'part' ? 400 : 700), tw(sub(it), Math.max(MIN_LABEL, f - 0.5)) + (sub(it) && it.fork ? (phone ? 9 : 12) : 0)) + (it.kind === 'forkset' ? (phone ? 16 : 22) : (phone ? 7 : 10)) + (it.kind === 'stepset' ? (phone ? 9 : 11) : 0),
1070
1400
  header: (g, f) => tw(g.pkg ? pkgName(g.pkg) : `${g.gathered} packages`, f, false, 700) + (g.exports.length ? (phone ? 9 : 12) + tw(String(g.exports.length), f, true, 700) : 0) + (phone ? 8 : 12),
1071
1401
  steps: (g, f) => g.steps.length ? Math.max(...g.steps.map(s => tw(s.label, f, true, 700))) + (phone ? 10 : 13) + (phone ? 6 : 8) : 0,
1072
1402
  };
@@ -1122,15 +1452,16 @@ export function layoutQuiet(pkgs, lsx, lex, y0, phone, fsS0) {
1122
1452
  const ny = up ? ry - (phone ? 7 : 9) : ry + (phone ? 13 : 16);
1123
1453
  // a fork's types a line further out than the name, clear of it
1124
1454
  const sy = ny + (up ? -1 : 1) * lineGap(fsS);
1125
- let gg = '', tx = x - 3;
1455
+ // a step set's count starts past its loop's arrowhead
1456
+ let gg = '', tx = it.kind === 'stepset' ? x + (phone ? 6 : 8) : x - 3;
1126
1457
  if (it.kind === 'forkset') gg += mark.fork(x - (phone ? 3 : 4), ry, phone ? 0.75 : 0.9);
1127
1458
  else if (it.kind === 'dbset') gg += mark.station(x, ry, phone ? 3.6 : 4.6, it.writes);
1128
1459
  else if (it.kind === 'stepset') gg += mark.loop(x, ry - 0.5, phone ? 4.2 : 5.4);
1129
1460
  else if (it.socket) gg += mark.socket(x, ry, phone ? 4.6 : 6);
1130
1461
  else gg += mark.dot(x, ry, phone ? 3 : 3.8);
1131
1462
  if (it.gen) { gg += T(tx, ny, `∀${it.gen}`, { size: fsS, weight: 700, fill: C.hullInk, mono: false }); tx += tw(`∀${it.gen} `, fsS, false, 700); }
1132
- gg += T(tx, ny, it.label, { size: fsS, fill: it.kind === 'stepset' ? C.hullInk : C.ink, weight: it.kind === 'part' ? null : 700 });
1133
- if (it.fork) { gg += mark.fork(x - 2, sy - (phone ? 2.5 : 3.2), phone ? 0.55 : 0.7); gg += T(x + (phone ? 6 : 8), sy, it.fork.join(','), { size: fsS - 0.5, fill: C.muted }); }
1463
+ gg += T(tx, ny, setLabel(it), { size: fsS, fill: it.kind === 'stepset' ? C.hullInk : C.ink, weight: it.kind === 'part' ? null : 700 });
1464
+ if (it.fork && it.kind !== 'forkset') { gg += mark.fork(x - 2, sy - (phone ? 2.5 : 3.2), phone ? 0.55 : 0.7); gg += T(x + (phone ? 6 : 8), sy, it.fork.join(','), { size: fsS - 0.5, fill: C.muted }); }
1134
1465
  else if (it.fns) gg += T(x - 3, sy, it.fns.join(', '), { size: fsS - 0.5, fill: C.hullInk, italic: true });
1135
1466
  over.push(tg(it.keys, gg));
1136
1467
  });
@@ -1229,9 +1560,11 @@ function renderLibrary(map, W, opts) {
1229
1560
  steps.forEach((s, k) => {
1230
1561
  const x = xs[k + 1];
1231
1562
  decorate(s.id, x, py);
1563
+ // a step's names are centred on it, within its share of the chain and short of the package's edge
1564
+ const room = 2 * Math.min(dx / 2 - 2, x1 - (phone ? 8 : 12) - x);
1232
1565
  const fns = socketFns.get(s.id);
1233
- if (fns && !phone) out.push(T(x, py - (phone ? 9 : 11), clip(fns.join(', '), 9, dx - 8), { anchor: 'middle', size: 9, fill: C.hullInk }));
1234
- { const t = fitText(s.label + (s.applied ? '(..)' : ''), fs, dx - 4); out.push(T(x, py + (phone ? 17 : 22), t.s, { anchor: 'middle', size: t.size, fill: C.ink })); }
1566
+ if (fns && !phone) out.push(T(x, py - (phone ? 9 : 11), clip(fns.join(', '), 9, room - 4), { anchor: 'middle', size: 9, fill: C.hullInk }));
1567
+ { const t = fitText(s.label + (s.applied ? '(..)' : ''), fs, room); out.push(T(x, py + (phone ? 17 : 22), t.s, { anchor: 'middle', size: t.size, fill: C.ink })); }
1235
1568
  });
1236
1569
  drawn.add(c.value);
1237
1570
  y = py + (phone ? 26 : 32) + gapY + (phone ? 6 : 8);
@@ -1510,7 +1843,7 @@ export function partFor(map, id) {
1510
1843
  export function journeyLabel(map, key) {
1511
1844
  if (key === 'quiet') return 'off the entry paths';
1512
1845
  const b = map.bundles && map.bundles.find(x => x.id === key);
1513
- if (b) return b.count > 1 ? `${b.label} ×${b.count}` : b.label;
1846
+ if (b) return b.count > 1 ? `${b.label} (${b.count} routes)` : b.label;
1514
1847
  return partFor(map, key).label;
1515
1848
  }
1516
1849
 
@@ -1540,7 +1873,6 @@ function renderPart(map, partId, opts) {
1540
1873
  const rh = phone ? 56 : 66;
1541
1874
  const yTop = frameTop + (phone ? 16 : 20);
1542
1875
  const feeds = part.groups.flat().filter(s => s.role === 'semantic-object');
1543
- const rowsTop = yTop + (phone ? 30 : 34) + (feeds.length ? (phone ? 28 : 34) : 0);
1544
1876
  const shared = n > 1;
1545
1877
  const atPart = u => part.station ? u.at.node === 'entry' && u.at.entry === part.station : u.at.node === 'wrapper' && u.at.wrapper === part.id;
1546
1878
  const uses = map.uses.map((u, i) => ({ ...u, i })).filter(u => u.kind === 'db' && atPart(u));
@@ -1548,17 +1880,52 @@ function renderPart(map, partId, opts) {
1548
1880
  const rS = phone ? 5.4 : 6.6;
1549
1881
  // columns: the casing holds the part; past it, the after band and the reply bars
1550
1882
  // on a phone the casing gives up width to the after band
1551
- const c = p => x0 + cw * p, k = phone ? 0.88 : 1;
1552
- const caseEnd = c(0.8 * k);
1553
- const xMerge = c(0.08 * k), xRead = c(0.22 * k), xGuard = c(0.38 * k), xSplit = c(0.5 * k), xSock = c(0.6 * k), xPG = c(0.72 * k), xr = c(0.96);
1883
+ const k = phone ? 0.88 : 1, xr = x0 + cw * 0.96;
1884
+ const keyedById = new Map(map.keyed.map(k => [k.id, k]));
1885
+ const keyed = ids => [...new Set(ids)].filter(id => keyedById.has(id));
1886
+ const typesOf = ids => [...new Set(keyed(ids).flatMap(id => keyedById.get(id).types))].join(',');
1887
+ // The sockets and the checks after them take the room their labels' widest
1888
+ // pieces need, so that wrapping never has to clip one: the columns left of
1889
+ // the sockets give way down to three quarters of their width, then the
1890
+ // casing reaches into the band after it, which keeps room for its marks.
1891
+ const piece = s => (s ? widestPiece(s, fsS, true) : 0);
1892
+ const passed = part.station ? [] : callers.map(cl => cl.passed[0]).filter(Boolean);
1893
+ const checked = passed.filter(p => p.guards.length);
1894
+ const sockNeed = Math.max(0, ...passed.map(p => Math.max(rS + 3 + Math.max(piece(p.label), piece(reachLabel(p.effects))), keyed([p.id]).length ? 13 + piece(typesOf([p.id])) : 0)));
1895
+ const checkNeed = Math.max(0, ...checked.map(p => Math.max(6 + piece(p.guards.join(', ')), keyed(p.guardIds || []).length ? 15 + piece(typesOf(p.guardIds || [])) : 0)));
1896
+ const afterMarks = Math.max(0, ...callers.map(cl => { const b = cl.beside; return [b.guards.length, b.effects.length, b.resources.includes('model'), b.calls.length].filter(Boolean).length; }));
1897
+ let caseEnd = x0 + cw * 0.8 * k, xSock = x0 + cw * 0.6 * k, xPG = x0 + cw * 0.72 * k;
1898
+ if (!part.station) {
1899
+ const checkSpan = checked.length ? Math.max(caseEnd - xPG, checkNeed + 6) : 0;
1900
+ const sockSpan = Math.max((checked.length ? xPG : caseEnd) - xSock, sockNeed + (checked.length ? 8 : 6));
1901
+ // (only columns with nothing of the part's own to label give way)
1902
+ const least = x0 + cw * 0.6 * k * (part.effects.length || keyed([part.id]).length || part.guards.length ? 1 : 0.75), most = xr - (phone ? 14 : 18) * (afterMarks + 1);
1903
+ caseEnd = Math.max(caseEnd, Math.min(most, least + sockSpan + checkSpan));
1904
+ xPG = caseEnd - checkSpan;
1905
+ xSock = Math.max(least, Math.min(x0 + cw * 0.6 * k, xPG - sockSpan));
1906
+ }
1907
+ // the columns left of the sockets, in their share of what the sockets leave
1908
+ const c = p => x0 + (xSock - x0) * p / 0.6;
1909
+ const xMerge = c(0.08), xRead = c(0.22), xGuard = c(0.38), xSplit = c(0.5);
1554
1910
  const stX = part.station ? xSock : xRead;
1555
1911
  // the parameter every caller fixes drops into the check it decides, else the part's own station
1556
1912
  const feedX = s => (s.guardsFed.length && part.guards.length ? xGuard : xRead);
1557
- const keyedById = new Map(map.keyed.map(k => [k.id, k]));
1913
+ // the casing's head lists them, those dropping furthest left first, each on
1914
+ // lines of its own reaching to the casing's edge: a later one's drop starts
1915
+ // at its own name, under the names that reach across it
1916
+ const lead = phone ? 12 : 13;
1917
+ const feedRows = [];
1918
+ { let fy = yTop + (phone ? 20 : 23);
1919
+ for (const s of feeds.slice().sort((a, b) => feedX(a) - feedX(b))) {
1920
+ const gx = feedX(s), text = `${s.name}: ${String(s.type).split('::').pop()}`;
1921
+ const lines = wrapped(text, caseEnd - 6 - gx - 6, { size: fsS });
1922
+ feedRows.push({ s, gx, text, lines, y: fy });
1923
+ fy += lines.length * lead;
1924
+ } }
1925
+ const feedLines = feedRows.reduce((a, r) => a + r.lines.length, 0);
1926
+ const rowsTop = yTop + (phone ? 30 : 34) + (feeds.length ? (phone ? 34 : 39) + (feedLines - 1) * lead : 0);
1558
1927
  // line k (from 1) under a path at y
1559
- const lead = phone ? 12 : 13, line = (y, k) => y + (phone ? 14 : 16) + (k - 1) * lead;
1560
- const keyed = ids => [...new Set(ids)].filter(id => keyedById.has(id));
1561
- const typesOf = ids => [...new Set(keyed(ids).flatMap(id => keyedById.get(id).types))].join(',');
1928
+ const line = (y, k) => y + (phone ? 14 : 16) + (k - 1) * lead;
1562
1929
  const forkLines = (ids, x, end) => (keyed(ids).length ? wrapped(typesOf(ids), end - x - 11, { size: fsS }) : []);
1563
1930
  const entryOf = cl => map.entries.find(e => e.handlerId === cl.id || e.key === cl.id);
1564
1931
  // Each caller's lines: its name over its path; under it its routes, then its
@@ -1578,7 +1945,8 @@ function renderPart(map, partId, opts) {
1578
1945
  const tokX = roles.length === 1 ? lx + tw(roles[0], fsS, false, 700) + 8 : lx;
1579
1946
  const tokSame = !!tokTxt && roles.length === 1 && tw(tokTxt, fsS) <= x0 - tokX - 6;
1580
1947
  const toks = tokTxt ? (tokSame ? [tokTxt] : wrapped(tokTxt, x0 - lx - 6, { size: fsS })) : [];
1581
- const left = sub.length + Math.max(roles.length + (tokSame ? 0 : toks.length), 0);
1948
+ // the roles and tokens start a line below the path's own (left empty without a route, for the lock)
1949
+ const left = (roles.length || toks.length ? Math.max(1, sub.length) : sub.length) + roles.length + (tokSame ? 0 : toks.length);
1582
1950
  const r = { e, lx, name, sx, sub, subTxt, roles, roleTxt, toks, tokTxt, tokSame, tokX, handed: [], reach: [], fork: [], below: false, gLines: [], gFork: [], after: [], afterAt: 1, afterWide: false, afterG: [] };
1583
1951
  if (!part.station) {
1584
1952
  const p = cl.passed[0], pe = p ? p.effects : [];
@@ -1658,12 +2026,15 @@ function renderPart(map, partId, opts) {
1658
2026
  } else out.push(`<path d="M${x0 + 4} ${f1(ty)} H${f1(part.station ? xr : xSock - rS)}" stroke="${C.ink2}" stroke-width="1.6"/>`);
1659
2027
  // under the part's own station, right of its riser: its fork, then its tables; they end short of its check's stem
1660
2028
  const gx = part.station ? xRead : xGuard;
1661
- const stEnd = part.station ? xr - 6 : part.guards.length ? xGuard - 8 : xSplit - 4;
1662
- const stFork = keyed([part.id]).length ? 1 : 0;
1663
- forkAt([part.id], stX + 6, ty, 1, stEnd);
2029
+ // when a piece of them would not fit before the part's check, they go under its failure bar and its fork, to the split
2030
+ const short = !part.station && part.guards.length > 0 && (piece(typesOf([part.id])) > xGuard - 8 - stX - 17 || piece(reachLabel(part.effects)) > xGuard - 8 - stX - rS - 4);
2031
+ const stEnd = part.station ? xr - 6 : part.guards.length && !short ? xGuard - 8 : xSplit - 4;
2032
+ const stLine = short ? 1 + Math.max(1, forkLines(part.guardIds || [], gx + 6, xSplit - 4).length) : 1;
2033
+ const stFork = forkLines([part.id], stX + 6, stEnd).length;
2034
+ forkAt([part.id], stX + 6, ty, stLine, stEnd);
1664
2035
  if (part.effects.length) {
1665
2036
  over.push(mark.station(stX, ty, shared ? rS * 1.15 : rS, writesIn(part.effects)));
1666
- out.push(owned(casing, L(stX + rS + 4, line(ty, 1 + stFork), reachLabel(part.effects), stEnd - stX - rS - 4, { size: fsS, fill: C.db })));
2037
+ out.push(owned(casing, L(stX + rS + 4, line(ty, stLine + stFork), reachLabel(part.effects), stEnd - stX - rS - 4, { size: fsS, fill: C.db })));
1667
2038
  } else if (part.station) over.push(mark.dot(stX, ty, 3.4));
1668
2039
  if (part.guards.length) {
1669
2040
  over.push(tg((part.guardIds || []).map(g => `guard:${part.id}:${g}`), mark.guard(gx, ty, phone ? 0.9 : 1.1)));
@@ -1674,11 +2045,16 @@ function renderPart(map, partId, opts) {
1674
2045
  forkAt(part.guardIds || [], gx + 6, ty, 1, part.station ? stX - rS - 6 : xSplit - 4);
1675
2046
  }
1676
2047
  // the parameter every caller fixes drops in from above into the check it decides
1677
- for (const s of feeds) {
1678
- const gx = feedX(s), next = Math.min(caseEnd - 6, ...feeds.map(feedX).filter(x => x > gx).map(x => x - 6));
1679
- out.push(`<path d="M${f1(gx)} ${yTop + 2} V${f1(ty - (phone ? 16 : 20))}" stroke="${C.ink2}" stroke-width="1.4" stroke-dasharray="1.5 3"/>`);
1680
- out.push(`<path d="M${f1(gx - 3.5)} ${f1(ty - (phone ? 21 : 25))} L${f1(gx)} ${f1(ty - (phone ? 15 : 19))} L${f1(gx + 3.5)} ${f1(ty - (phone ? 21 : 25))}" fill="${C.ink2}"/>`);
1681
- out.push(owned(casing, L(gx + 6, yTop + (phone ? 14 : 18), `${s.name}: ${String(s.type).split('::').pop()}`, next - gx - 6, { size: fsS, fill: C.ink2 })));
2048
+ const dropped = new Set();
2049
+ for (const { gx, text, lines, y } of feedRows) {
2050
+ // one drop for each column, from above the casing, or from the first name in it the names to its left leave clear
2051
+ if (!dropped.has(gx)) {
2052
+ dropped.add(gx);
2053
+ const from = feedRows.some(r => r.gx < gx) ? y - fsS : yTop + 2;
2054
+ out.push(`<path d="M${f1(gx)} ${f1(from)} V${f1(ty - (phone ? 16 : 20))}" stroke="${C.ink2}" stroke-width="1.4" stroke-dasharray="1.5 3"/>`);
2055
+ out.push(`<path d="M${f1(gx - 3.5)} ${f1(ty - (phone ? 21 : 25))} L${f1(gx)} ${f1(ty - (phone ? 15 : 19))} L${f1(gx + 3.5)} ${f1(ty - (phone ? 21 : 25))}" fill="${C.ink2}"/>`);
2056
+ }
2057
+ out.push(owned(casing, Tw(gx + 6, y, lines, { size: fsS, fill: C.ink2 }, text, lead)));
1682
2058
  }
1683
2059
  if (!part.station) callers.forEach((cl, i) => {
1684
2060
  const y = ys[i];