yichus 0.2.0 → 0.3.1

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.
Files changed (36) hide show
  1. package/README.md +45 -16
  2. package/dist/cli.js +17 -2123
  3. package/dist/cli.js.map +1 -1
  4. package/dist/main.js +3762 -0
  5. package/dist/main.js.map +1 -0
  6. package/dist/studio-page/dom.js +22 -0
  7. package/dist/studio-page/label-fit.js +7 -0
  8. package/dist/studio-page/layer-diagram.js +458 -0
  9. package/dist/studio-page/layer-groups.js +129 -0
  10. package/dist/studio-page/source-link.js +17 -0
  11. package/dist/studio-page/studio-views.js +256 -0
  12. package/dist/studio-page/system-map-record.js +124 -10
  13. package/dist/studio-page/system-map.js +476 -87
  14. package/dist/studio-page/what-happens-record.js +224 -0
  15. package/dist/studio-page/what-happens.js +771 -0
  16. package/dist/studio-reference/balance-listed-laws.bosatsu +29 -0
  17. package/dist/studio-reference/balance.bosatsu +7 -0
  18. package/dist/studio-reference/calculator.md +20 -0
  19. package/dist/studio-reference/consent-laws.bosatsu +37 -0
  20. package/dist/studio-reference/consent.bosatsu +15 -0
  21. package/dist/studio-reference/deploy-platforms.md +79 -0
  22. package/dist/studio-reference/laws.md +62 -0
  23. package/dist/studio-reference/portable-programs.md +58 -0
  24. package/dist/studio-reference/porting.md +34 -0
  25. package/dist/studio-reference/protocol-cases.md +11 -0
  26. package/dist/studio-reference/{protocol-specification.txt → protocol-specification.md} +16 -18
  27. package/dist/studio-reference/refund-desk-one-round.bosatsu +196 -0
  28. package/dist/studio-reference/refund-desk.bosatsu +194 -0
  29. package/dist/studio-reference/refund-policy.bosatsu +97 -0
  30. package/dist/studio-reference/refund-test.bosatsu +75 -0
  31. package/dist/studio-reference/render.yaml +42 -0
  32. package/dist/studio-reference/server.mjs +33 -0
  33. package/dist/studio-reference/store.mjs +16 -0
  34. package/dist/studio-reference/the-page.md +130 -0
  35. package/dist/studio-reference/worker-authority.md +159 -0
  36. package/package.json +2 -2
@@ -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))));
@@ -275,6 +269,11 @@ const afterRoom = (b, from, xr) => {
275
269
  return b.guards.length ? xr - 2 - (from + (xr - from) / (n + 1)) - 6 : xr - from - 8;
276
270
  };
277
271
 
272
+ // A hull's right wall on each path that leaves it, where the wrapper returns:
273
+ // whatever the path passes after it runs after the wrapper.
274
+ const hullWall = (x, ys, phone) =>
275
+ `<path d="${ys.map(y => `M${f1(x)} ${f1(y - (phone ? 8 : 10))} V${f1(y + (phone ? 8 : 10))}`).join(' ')}" stroke="${C.hullInk}" stroke-width="3"/>`;
276
+
278
277
  export function renderSystemMap(map, opts = {}) {
279
278
  const W = opts.width || 1100;
280
279
  return inFrame(W, () => map.kind === 'service' ? renderService(map, W, opts) : renderLibrary(map, W, opts));
@@ -329,7 +328,25 @@ function renderService(map, W, opts) {
329
328
  const gap = phone ? 3 : 4;
330
329
  const lockW = e => (e.authority === 'publishable' || e.signedIn ? (phone ? 12 : 14) : 0);
331
330
  const labX = e => x0 + (phone ? 7 : 10) + lockW(e);
332
- const countOf = e => (e.members.length > 1 ? `×${e.members.length}` : '');
331
+ // a bundle says what it counts: its routes
332
+ const countOf = e => (e.members.length > 1 ? `${e.members.length} routes` : '');
333
+ // A row's checks by name: every member's, not only the first's (a bundle
334
+ // holds routes with the same marks, not the same checks). A check that
335
+ // took another definition's place on the same route since the compared
336
+ // base is named with the one it replaced.
337
+ const replacedAt = new Map(((map.compare && map.compare.replaced) || []).map(r => [`${r.entry} ${r.within} ${r.at}`, r]));
338
+ const checks = (e, within, idsOf, namesOf) => {
339
+ const words = [], replaced = [];
340
+ for (const m of e.members) idsOf(m).forEach((id, i) => {
341
+ const r = replacedAt.get(`${m.key} ${within} ${i}`), nm = namesOf(m)[i];
342
+ const w = r ? `${nm} (in place of ${r.wasLabel})` : nm;
343
+ if (!words.includes(w)) words.push(w);
344
+ if (r) replaced.push(`replaced:${m.key}:${id}`);
345
+ });
346
+ return { text: words.join(', '), names: words, replaced };
347
+ };
348
+ const checkLabel = (c, svg) => c.replaced.length ? tg(c.replaced, shape('replaced', svg)) : svg;
349
+ 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
350
  // the hull band starts past the names of the routes that enter it, as far as half the wall
334
351
  const hx0 = Math.min(col(0.5, 0.5), Math.max(col(0.22, 0.34), ...rowsIn.filter(e => e.wrapper)
335
352
  .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 +484,7 @@ function renderService(map, W, opts) {
467
484
  // the left column
468
485
  const leftEnd = clear(i, x2, e.wrapper ? hx0 - 6 : (e.guards.length ? gx - 9 : xh - rS - 6), true);
469
486
  const left = [];
470
- const guardLines = !e.wrapper && e.guards.length ? wrapped(e.guards.join(', '), gx - 7 - x2, { size: fsS }) : [];
487
+ const guardLines = !e.wrapper && e.guards.length ? wrapped(e._chk.h.text, gx - 7 - x2, { size: fsS }) : [];
471
488
  guardLines.forEach(() => left.push('guard'));
472
489
  const guardFork = !e.wrapper && !dense ? forkLines(keyedTypes(e.members.flatMap(m => m.guardIds)), gx + 6 - 3 - x2) : [];
473
490
  guardFork.forEach(() => left.push('guardFork'));
@@ -487,7 +504,7 @@ function renderService(map, W, opts) {
487
504
  built = builtAt.get(`h:${i}`);
488
505
  if (built) right.push('builder');
489
506
  } else {
490
- const gname = p && p.guards.length && !dense ? p.guards.join(', ') : '';
507
+ const gname = p && p.guards.length && !dense ? e._chk.p.text : '';
491
508
  // the check's name over its diamond when it fits there and the function's name leaves room; else right of its stem
492
509
  guardBelow = !!gname && (tw(p.label || '', fsS) > xPG - tw(gname, fsS) / 2 - 8 - xSockText || tw(gname, fsS) > (hx1 - xPG) * 2 - 8);
493
510
  const gw = gname ? tw(gname, fsS) / 2 + 8 : 0;
@@ -523,7 +540,7 @@ function renderService(map, W, opts) {
523
540
  if (b.guards.length) {
524
541
  const free = dense || !(p && (p.label || (p.guards.length && !guardBelow)));
525
542
  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 });
543
+ afterGuard = wrapped(e._chk.a.text, xr - 8 - from, { size: fsS });
527
544
  }
528
545
  for (let k = 0; k < Math.max(inner.length, xpg, afterWords.length + afterAt - 1); k++) right.push('inner');
529
546
  // a wrapper of this row alone: its own station's forks, tables and builder sit under it too
@@ -578,9 +595,9 @@ function renderService(map, W, opts) {
578
595
  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
596
  };
580
597
  // 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) => {
598
+ const guardAt = (keys, names, x, y, room, s = 1, own = 'canvas', row = null, named = svg => svg) => {
582
599
  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 }))));
600
+ 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
601
  };
585
602
  // a builder hung under a mark on line k, its name to the right from x
586
603
  const builder = (b, px, y, k, x, end, own = 'canvas', row = null) => {
@@ -635,7 +652,7 @@ function renderService(map, W, opts) {
635
652
  if (e.guards.length) {
636
653
  guardAt(each(e, m => m.guardIds.map(g => `guard:${m.key}:${g}`)), [], gx, y, 0, 0.85);
637
654
  // 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)));
655
+ 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
656
  forkAt(e.members.flatMap(m => m.guardIds), gx + 6, y, left.indexOf('guardFork') + 1, x2, 'canvas', true, e);
640
657
  }
641
658
  const station = own.length ? mark.station(xh, y, rS, writesIn(own)) : mark.dot(xh, y, 3.2);
@@ -666,7 +683,8 @@ function renderService(map, W, opts) {
666
683
  const first = members[0], last = members[members.length - 1];
667
684
  const hy0 = Math.min(...ys) - (phone ? 20 : 24) - first._lay.lift, hy1 = Math.max(...ys) + (phone ? 18 : 22) + lead * Math.max(0, last._lay.lines - 1);
668
685
  const hull = `hull:${w.id}`;
669
- under.push(tg([`wrapper:${w.id}`], `<g data-mark="hull"${opener(w.id, `Open ${w.label}`)}><rect x="${f1(hx0)}" y="${f1(hy0)}" width="${f1(hx1 - hx0)}" height="${f1(hy1 - hy0)}" rx="${phone ? 9 : 13}" fill="${C.hull}" stroke="${C.hullEdge}" stroke-width="1.2"${boxAttr(hull)}/></g>`));
686
+ under.push(tg([`wrapper:${w.id}`], `<g data-mark="hull"${opener(w.id, `Open ${w.label}`)}><rect x="${f1(hx0)}" y="${f1(hy0)}" width="${f1(hx1 - hx0)}" height="${f1(hy1 - hy0)}" rx="${phone ? 9 : 13}" fill="${C.hull}" stroke="${C.hullEdge}" stroke-width="1.2"${boxAttr(hull)}/>` +
687
+ hullWall(hx1, ys, phone) + '</g>'));
670
688
  { const { ha, lines } = hullTitle.get(w.id);
671
689
  out.push(Tw(ha, hy0 - (phone ? 4 : 5), lines, { size: titleSz, weight: 700, fill: C.hullInk }, w.label, lineGap(titleSz), 'up')); }
672
690
  for (const e of members) {
@@ -700,7 +718,7 @@ function renderService(map, W, opts) {
700
718
  const rowId = `route:${e.key}`;
701
719
  over.push(tg(each(e, m => `socket:${m.key}`), mark.socket(xSock, y, rS * 1.05, writesIn(pr), pr.length > 0 && !writesIn(pr))));
702
720
  socketPt.set(e.key, [xSock, y]);
703
- const gw = p && p.guards.length ? tw(p.guards.join(', '), fsS) / 2 + 8 : 0;
721
+ const gw = p && p.guards.length ? tw(e._chk.p.text, fsS) / 2 + 8 : 0;
704
722
  // the handed-in function's name above the path; over a check whose name went below, high enough to clear the diamond
705
723
  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
724
  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 +727,8 @@ function renderService(map, W, opts) {
709
727
  if (p && p.guards.length) {
710
728
  out.push(`<path${anchorAttr(rowId)} d="M${f1(xSock + rS)} ${y} H${f1(xPG)}" stroke="${C.ink2}" stroke-width="${f1(cableW(e))}"/>`);
711
729
  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))));
730
+ guardAt(gkeys, dense || below ? [] : e._chk.p.names, xPG, y, (hx1 - xPG) * 2 - 8, 0.9, hull, e, svg => checkLabel(e._chk.p, svg));
731
+ 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
732
  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
733
  }
716
734
  // past the hull: the after band, then the reply bar
@@ -724,7 +742,7 @@ function renderService(map, W, opts) {
724
742
  const step = (xr - hx1) / (items.length + 1);
725
743
  items.forEach((draw, k) => draw(hx1 + step * (k + 1)));
726
744
  // 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')));
745
+ 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
746
  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
747
  over.push(mark.reply(xr, y, dense ? 0.75 : S));
730
748
  }
@@ -733,7 +751,8 @@ function renderService(map, W, opts) {
733
751
  // ---- the quiet line (after rows, so forks placed on rows are left out) ----
734
752
  let quietBottom = rowsBottom;
735
753
  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);
754
+ 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,
755
+ [...lanes.values()].map(l => [l.x - l.m, l.x + l.m]));
737
756
  if (Q && Q.items) {
738
757
  const body = Q.under.join('') + Q.out.join('') + Q.over.join('');
739
758
  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 +764,12 @@ function renderService(map, W, opts) {
745
764
  const ghostLink = map.ghostCallers.some(g => g.kind === 'link');
746
765
  // the struck importer sits above the rail's left end, clear of HTTP and of the platform under the rail
747
766
  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));
767
+ const yBot0 = anyRail ? busY + (phone ? 18 : 22) : (Q && Q.items ? quietBottom + (phone ? 10 : 14) : rowsEnd + (phone ? 34 : 38));
768
+ // ---- the program band: under the rail, at the bottom of the wall ----
769
+ const PB = (map.programs || []).length ? programBand(map.programs, x0, x1, yBot0 + (phone ? 10 : 14), phone,
770
+ { xr, y: key => { const rk = rowOf.get(key); return rk == null ? null : rowY.get(rk); } }) : null;
771
+ if (PB) { under.push(PB.under); out.push(PB.out); over.push(PB.over); }
772
+ const yBot = PB ? PB.bottom + (phone ? 10 : 14) : yBot0;
749
773
  const openGap = e => (phone ? 6 : 7) + (e.members.length > 1 ? Math.min(7, e.members.length * 1.1) : 0);
750
774
  const wall = wallPathGaps(x0, x1, yTop, yBot, ents.map(e => [rowY.get(e.key), openGap(e)]));
751
775
 
@@ -767,10 +791,18 @@ function renderService(map, W, opts) {
767
791
 
768
792
  // ---- the rack, outside on the right, in fixed slots ----
769
793
  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' } } : {});
794
+ // each resource: how many of the routes drawn reach it, and a glyph sized by that share (its area, like a bar's
795
+ // length, says how much it is used), never so small its name leaves it
796
+ const shown = map.entries.filter(e => rowOf.has(e.key));
797
+ const reaches = (e, k) => !!e[k] || (e.beside?.resources || []).includes(k);
798
+ const nReach = Object.fromEntries(RACK.map(k => [k, shown.filter(e => reaches(e, k)).length]));
799
+ const glyphW = phone ? 38 : 64, nameSz = phone ? 9.5 : 12;
800
+ // (as wide as its name needs, as tall as its share)
801
+ const share = k => Math.max(0.5, Math.sqrt(nReach[k] / shown.length));
802
+ 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) }]));
803
+ const countLines = n => tw(`reached by ${n}`, phone ? 9 : 10) <= resW - 4 ? [`reached by ${n}`] : ['reached', `by ${n}`];
804
+ 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}`] }]));
805
+ const R = rack(map, rackX, yTop + (phone ? 6 : 10), phone, resW - 4, notes, scale);
774
806
  out.push(R.svg);
775
807
 
776
808
  // ---- supply of the db handle ----
@@ -832,12 +864,309 @@ function renderService(map, W, opts) {
832
864
  }
833
865
  }
834
866
  out.push(T(x0 + (phone ? 10 : 13), busY - (phone ? 6 : 7), 'db', { size: phone ? 9 : 10, weight: 700, fill: C.db, mono: false }));
867
+ // once, by the rail's name: what a ? on it means
868
+ const someMixed = dbUses.filter(onRail).some(u => u.how === 'mixed');
869
+ if (someMixed) {
870
+ const kx = x0 + (phone ? 10 : 13) + tw('db', phone ? 9 : 10, false) + (phone ? 10 : 12), ky = busY - (phone ? 9.5 : 10.5);
871
+ over.push(tg(dbUses.filter(u => onRail(u) && u.how === 'mixed').map(u => `unknownKey:${u.i}`), mark.unknown(kx, ky, phone ? 5.5 : 6) +
872
+ T(kx + (phone ? 8 : 9), busY - (phone ? 6 : 7), UNTRACED_WORD, { size: 9, fill: C.muted, mono: false })));
873
+ }
835
874
  }
836
875
 
837
876
  const H = Math.max(yBot, R.bottom) + (phone ? 12 : 20);
838
877
  return outerFrame(W, H, wall.fill + under.join('') + shape('wall', wall.line) + out.join('') + over.join(''), { ...opts, frameTop }, map.program, opts.crumb);
839
878
  }
840
879
 
880
+ // =====================================================================
881
+ // PROGRAM BAND
882
+ // =====================================================================
883
+ // A pure program as a container: its step, its package, and one small
884
+ // diamond per decision it takes, so its area grows with its decisions. Its
885
+ // request constructors are openings on its right edge; each is wired to the
886
+ // engine lane that performs it, with a dot per resource the arm reaches. A
887
+ // request no engine performs is a struck opening. Each lane carries a fork
888
+ // with the engine's own decision count (none: a rule-free engine), ends in
889
+ // the resources its arms reach, and loops back into the program bottom
890
+ // through one loop node that names what repeats the step.
891
+ // A line broken at its spaces and separators so each piece fits `width`.
892
+ export const wrapAt = (text, width, size, mono = true) => {
893
+ const lines = [];
894
+ let cur = '';
895
+ for (const w of String(text).split(/(?<=, |· | )/)) {
896
+ if (cur && tw((cur + w).trimEnd(), size, mono) > width) { lines.push(cur.trimEnd()); cur = w; } else cur += w;
897
+ }
898
+ if (cur) lines.push(cur.trimEnd());
899
+ return lines;
900
+ };
901
+ const RES_WORD = { db: 'db', model: 'model', net: 'net', compile: 'compile', run: 'run', io: 'io' };
902
+ // A lane is named by its package, and by its own definition where another lane shares that package.
903
+ export const laneName = (program, engine) =>
904
+ (program.engines.some(x => x !== engine && x.pkg === engine.pkg) ? `${engine.pkgLabel}.${engine.label}` : engine.pkgLabel);
905
+
906
+ export function programBand(programs, x0, x1, y0, phone, rows = null) {
907
+ const under = [], out = [], over = [];
908
+ // a label that fits the room it is given: shrunk a little, then cut, with
909
+ // the whole text as its title when it is cut
910
+ const fitT = (x, y, s, width, o = {}) => {
911
+ const t = fitText(s, o.size || 10, width, o.mono !== false);
912
+ const svg = T(x, y, t.s, { ...o, size: t.size });
913
+ return t.s === String(s) ? svg : `<g><title>${esc(s)}</title>${svg}</g>`;
914
+ };
915
+ const cw = x1 - x0, fs = phone ? 9.5 : 11, fsS = phone ? 9 : 10;
916
+ const bw = phone ? 12 : 16, lead = phone ? 11 : 12.5;
917
+ let y = y0, wireN = 0;
918
+ for (const p of programs) {
919
+ // the lanes need room for their widest foot pill each; the program gives way
920
+ const pillW = rs => Math.max(tw(RES_WORD[rs], 9, false) * 1.12 + 12, phone ? 26 : 32);
921
+ const needSp = Math.max(0, ...p.engines.flatMap(e => [...e.resources, ...e.also].map(pillW))) + 6;
922
+ const lanesW = needSp * p.engines.length + (phone ? 24 + 26 : 70 + 56);
923
+ // 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
924
+ const countSp = 12 + Math.max(0, ...p.engines.map(e => tw(String(e.decisions), fsS, true, 700)));
925
+ 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))
926
+ : Math.max(px0 + cw * 0.4, Math.min(px0 + cw * 0.5, x1 - lanesW));
927
+ // one scale for every decision bar of this program and its engines
928
+ const maxDec = Math.max(1, p.decisions, ...p.engines.map(e => e.decisions));
929
+ const per = Math.min(phone ? 2 : 2.4, (phone ? 170 : 200) / maxDec);
930
+ const barH = n => Math.max(n > 0 ? 2 : 0, n * per);
931
+ const starts = p.starters.filter(s => !s.advances);
932
+ // 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)
933
+ const startLines = (() => {
934
+ if (!starts.length) return [];
935
+ const room = (px1 - px0) - (phone ? 18 : 22), groups = [[]];
936
+ // the words lead the first line, or stand on their own when the first path would not fit after them
937
+ const lead = 'one step per call of', alone = tw(`${lead} ${starts[0].path}`, fsS, false) > room;
938
+ const text = (g, j) => `${j || alone ? '' : `${lead} `}${g.join(', ')}`;
939
+ for (const st of starts) {
940
+ const g = groups[groups.length - 1];
941
+ if (!g.length || tw(text([...g, st.path], groups.length - 1) + ',', fsS, false) <= room) g.push(st.path);
942
+ else groups.push([st.path]);
943
+ }
944
+ const lines = [...(alone ? [lead] : []), ...groups.map((g, j) => text(g, j) + (j < groups.length - 1 ? ',' : ''))];
945
+ if (lines.length <= 3) return lines;
946
+ const shown = groups[0].length + (alone ? 0 : groups[1].length);
947
+ return [lines[0], lines[1], `and ${starts.length - shown} more`];
948
+ })();
949
+ // the counts on one line, or two when the program box is narrow
950
+ // its size in words a reader needs no key for: the package's own definitions, and how many more it uses from elsewhere
951
+ const own = `${p.definitions} definitions in ${p.pkgLabel}`;
952
+ // which packages they come from, the most used first: three named, the rest counted
953
+ // (each name stays with its count when the line wraps)
954
+ const from = p.elsewhere.slice(0, 3).map(x => `${x.label}\u00a0${x.n}`).join(', ') + (p.elsewhere.length > 3 ? `, ${p.elsewhere.length - 3}\u00a0more` : '');
955
+ const more = p.usesElsewhere ? `uses ${p.usesElsewhere} more from ${p.elsewhere.length} package${p.elsewhere.length === 1 ? '' : 's'}: ${from}` : '';
956
+ const defsTxt = more ? `${own} · ${more}` : own;
957
+ const headRoom = cw * (phone ? 0.56 : 0.5) - (phone ? 26 : 34);
958
+ const defsLines = tw(defsTxt, fsS, false) <= headRoom ? [defsTxt] : [own, more].filter(Boolean).flatMap(l => wrapAt(l, headRoom, fsS, false));
959
+ const headH = (phone ? 40 : 44) + (defsLines.length - 1) * lead + startLines.length * lead;
960
+ const vMax = 8;
961
+ const variantRows = q => q.variants.reduce((n, v) => n + Math.min(v.constructors.length, vMax + 1), 0);
962
+ const osOf = q => (phone ? 30 : 34) + variantRows(q) * lead;
963
+ 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),
964
+ ...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);
965
+ const openH = p.requests.reduce((h, q) => h + osOf(q), 0);
966
+ const bodyH = Math.max(openH, barH(p.decisions) + (phone ? 34 : 30));
967
+ const top = y + (phone ? 18 : 20), bot = top + headH + bodyH + (phone ? 10 : 14);
968
+ const openY = []; { let yy = top + headH; for (const q of p.requests) { openY.push(yy + (phone ? 15 : 17)); yy += osOf(q); } }
969
+ const gapOf = new Set(p.gaps.map(g => g.constructor));
970
+ const og = phone ? 6 : 7, r = phone ? 9 : 12;
971
+ let edge = `M${f1(px1 - r)} ${f1(top)} Q${f1(px1)} ${f1(top)} ${f1(px1)} ${f1(top + r)}`;
972
+ for (const oy of openY) edge += ` V${f1(oy - og)} M${f1(px1)} ${f1(oy + og)}`;
973
+ 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)}`;
974
+ // routes that only replay it to read where it is: named on hover, never drawn as starting it
975
+ 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>` : '';
976
+ 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}"/>` +
977
+ `<path d="${edge}" fill="none" stroke="${C.program}" stroke-width="2"/>`)));
978
+ const ix = px0 + (phone ? 8 : 12);
979
+ 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 })));
980
+ // effects in the program's own package: marked; none: a struck ghost
981
+ const markW = phone ? 50 : 64, mx = px1 - (phone ? 8 : 12) - markW / 2, my = top + (phone ? 12 : 13);
982
+ if (!p.beside.length) out.push(tg([`pure:${p.id}`], shape('struck', `<title>${esc(`no effects in ${p.pkg}`)}</title>` +
983
+ `<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"/>` +
984
+ `<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"/>` +
985
+ T(mx - markW / 2 + 14, my, 'effects', { size: 9, fill: C.muted, mono: false, halo: false }))));
986
+ 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>` +
987
+ `<circle cx="${f1(mx - markW / 2 + 6)}" cy="${f1(my - 3)}" r="4.5" fill="${C.guard}"/>` +
988
+ 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 }))));
989
+ 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 }));
990
+ // definitions: the package's own, as the layer stack counts them, and what the program reaches
991
+ 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>` : '';
992
+ 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('')));
993
+ 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>` +
994
+ 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('')));
995
+ // the program's decisions: a bar on the scale the engines' bars share, and its count
996
+ const by0 = top + headH + 4, bh = barH(p.decisions), dx = ix + bw + 6, dRoom = px1 - labW - (phone ? 14 : 20) - dx;
997
+ 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"/>`) +
998
+ T(dx, by0 + 10, String(p.decisions), { size: phone ? 11 : 13, weight: 700, fill: C.program }) +
999
+ (dRoom >= tw('decisions', fsS, false) ? T(dx, by0 + 10 + lead, 'decisions', { size: fsS, fill: C.muted, mono: false }) : '')));
1000
+ // lanes on the resource side
1001
+ const k = p.engines.length;
1002
+ const lx0 = px1 + (phone ? 24 : 70), lx1 = x1 - (phone ? 26 : 56);
1003
+ const laneX = p.engines.map((_, i) => lx0 + (lx1 - lx0) * (i + 0.5) / Math.max(1, k));
1004
+ const spacing = (lx1 - lx0) / Math.max(1, k);
1005
+ // an engine's decision bar, and its count beside it, end short of the next lane (the bar no narrower than 3)
1006
+ const forkW = Math.max(3, Math.min(bw, spacing - 9 - Math.max(...p.engines.map(e => tw(String(e.decisions), fsS, true, 700)))));
1007
+ // 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
1008
+ // (the arrow stays with what it gives, so a wrapped line never ends on it)
1009
+ const sigOf = e => `${e.type.plainTakes.join(', ') || '()'} →\u00a0${e.type.yields}`;
1010
+ const nameOf = e => laneName(p, e);
1011
+ const codeOf = e => `${e.label}: ${e.type.takes.join(', ')} → ${e.type.returns}`;
1012
+ // above each lane: its package, then what it does on at most two lines, when every lane's words fit there
1013
+ const sigLines = e => wrapAt(sigOf(e), spacing - 6, fsS);
1014
+ const wide = p.engines.every(x => tw(nameOf(x), fsS) <= spacing - 6 && sigLines(x).length <= 2 && sigLines(x).every(l => tw(l, fsS) <= spacing - 6));
1015
+ const laneTop = top + headH - (phone ? 6 : 8);
1016
+ const resH = phone ? 15 : 18, resGap = phone ? 4 : 5;
1017
+ const laneBot = Math.max(bot, openY.length ? openY[openY.length - 1] + 14 : bot, ...p.engines.map(e => laneTop + barH(e.decisions) + 8));
1018
+ const footOf = e => e.resources.length * (resH + resGap) + (e.also.length ? lead + e.also.length * (resH + resGap) : 0);
1019
+ // lanes closer than a pill: every other lane's pills start below its neighbours'
1020
+ const cramped = spacing < needSp;
1021
+ const evenFoot = Math.max(0, ...p.engines.filter((_, i) => i % 2 === 0).map(footOf));
1022
+ const footY0 = i => laneBot + 6 + (cramped && i % 2 ? evenFoot + resGap : 0);
1023
+ const footBot = Math.max(laneBot + 6, ...p.engines.map((e, i) => footY0(i) + footOf(e)));
1024
+ // each loop's caption, one thought per line: which engine (when the lanes
1025
+ // carry numbers), how it repeats and what it replies, and what runs it
1026
+ const capsOf = (e, i) => {
1027
+ // how the rounds come round within one call, and which calls run them
1028
+ 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';
1029
+ const reply = `replies ${e.replies.map(rs => RES_WORD[rs]).join(', ')}`;
1030
+ const lines = [];
1031
+ // a name too long for its room breaks after its package, so the definition that tells lanes apart stays whole
1032
+ if (!wide && nameOf(e) !== e.pkgLabel && tw(`${i + 1} ${nameOf(e)}:`, fsS) > capRoom(i)) lines.push([`${i + 1} ${e.pkgLabel}.`], [`${e.label}: ${sigOf(e)}`]);
1033
+ else if (!wide) lines.push([`${i + 1} ${nameOf(e)}: ${sigOf(e)}`]);
1034
+ lines.push(phone ? [how] : [`${how} · ${reply}`]);
1035
+ if (phone && e.replies.length) lines.push([reply]);
1036
+ 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}`)]);
1037
+ return lines;
1038
+ };
1039
+ // a caption never reaches the next lane, whose loop comes down there: it wraps at its separators instead
1040
+ // later lanes come back into the program further left, so no loop crosses another's caption
1041
+ const txOf = i => px0 + (px1 - px0) * (0.3 + 0.5 * (k - i) / (k + 1));
1042
+ // it may start just right of the next lane's way back up
1043
+ const capLeft = i => i < k - 1 ? txOf(i + 1) + 8 : txOf(i);
1044
+ const capRoom = i => (i < k - 1 ? laneX[i + 1] - 6 : x1 - 8) - capLeft(i);
1045
+ const caps = p.engines.map((e, i) => capsOf(e, i).flatMap(([t, keys]) => wrapAt(t, capRoom(i), fsS).map(l => [l, keys])));
1046
+ const loopGap = phone ? 26 : 28;
1047
+ const loopY = []; { let yy = footBot + (phone ? 12 : 14); caps.forEach(c => { loopY.push(yy); yy += loopGap + c.length * lead; }); }
1048
+ p.requests.forEach((q, i) => {
1049
+ const oy = openY[i];
1050
+ const gap = gapOf.has(q.constructor);
1051
+ const col = gap ? C.faint : C.program;
1052
+ const lab = fitText(q.constructor, fs, labW), lw0 = Math.min(labW, tw(lab.s, lab.size));
1053
+ const lx = px1 - (phone ? 8 : 10);
1054
+ out.push(tg([gap ? `gap:${p.id}:${q.constructor}` : `request:${p.id}:${q.constructor}`],
1055
+ 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"/>` +
1056
+ // a struck request's name and the lines that strike it are one label
1057
+ (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>' : '') +
1058
+ (q.fields.length ? fitT(lx, oy + fsS + 0.5, q.fields.join(', '), labW, { anchor: 'end', size: fsS, fill: C.muted }) : '') +
1059
+ (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"/>` +
1060
+ `<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>` : ''))));
1061
+ // the kinds of this request, from the sum type its payload carries: sub-openings under it
1062
+ let vy = oy + fsS + 0.5 + lead;
1063
+ for (const v of q.variants) {
1064
+ const shown = v.constructors.length > vMax + 1 ? v.constructors.slice(0, vMax) : v.constructors;
1065
+ const nm = v.namings && v.namings[0];
1066
+ const wordOf = (n, c) => (n.names.find(x => x.constructor === c) || {}).name;
1067
+ // one form for every kind of one type: kind and word when they all fit, else the words
1068
+ // 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
1069
+ const varW = Math.max(labW - 8, px1 - (phone ? 10 : 12) - (ix + bw + 6 + tw('decisions', fsS, false) + 10));
1070
+ const pairs = !!nm && shown.every(c => !wordOf(nm, c) || tw(`${c} → ${wordOf(nm, c)}`, fsS) <= varW);
1071
+ for (const c of shown) {
1072
+ // the kind, and the program's own word for it when a match gives one: both when they fit, else the word
1073
+ const word = nm && wordOf(nm, c);
1074
+ const both = word ? `${c} → ${word}` : c;
1075
+ const text = !word || pairs ? both : word;
1076
+ 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>` : '';
1077
+ out.push(tg([`variant:${p.id}:${q.constructor}:${c}`, ...(word ? [`variantName:${p.id}:${q.constructor}:${c}`] : [])],
1078
+ 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"/>` +
1079
+ fitT(lx, vy, text, pairs ? varW : labW - 8, { anchor: 'end', size: fsS, fill: word ? C.ink : C.ink2, weight: word ? 600 : 400 }))));
1080
+ vy += lead;
1081
+ }
1082
+ if (shown.length < v.constructors.length) {
1083
+ const rest = v.constructors.slice(shown.length);
1084
+ 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 }))));
1085
+ vy += lead;
1086
+ }
1087
+ }
1088
+ for (const h of q.handledBy) {
1089
+ const li = p.engines.findIndex(e => e.id === h.engine);
1090
+ const wx = laneX[li];
1091
+ // 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
1092
+ const words = h.resources.map(rs => RES_WORD[rs]), wgap = 5;
1093
+ const wordsW = words.reduce((w, t) => w + tw(t, 9, false), 0) + wgap * Math.max(0, words.length - 1);
1094
+ const left = Math.max(px1 + 6, ...laneX.filter(x => x < wx).map(x => x + 2.5 + bw + 6));
1095
+ const said = words.length && wordsW <= wx - 6 - left;
1096
+ let wxR = wx - 6;
1097
+ const marks = said ? h.resources.slice().reverse().map(rs => { const t = RES_WORD[rs], w = tw(t, 9, false), x = wxR; wxR -= w + wgap;
1098
+ return T(x, oy - 4, t, { anchor: 'end', size: 9, weight: 700, fill: C[rs] || C.io, mono: false }); }).reverse().join('')
1099
+ : 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('');
1100
+ const engineLabel = p.engines[li].label;
1101
+ under.push(tg([`wire:${p.id}:${q.constructor}:${h.engine}`], shape('wire', `<title>${esc(`${q.constructor} → ${engineLabel}${words.length ? `: reaches ${words.join(', ')}` : ''}`)}</title>` +
1102
+ `<path d="M${f1(px1 + 1)} ${f1(oy)} H${f1(wx)}" stroke="${C.ink2}" stroke-width="1.5"/>`)));
1103
+ if (marks) over.push(tg([`wire:${p.id}:${q.constructor}:${h.engine}`], marks));
1104
+ }
1105
+ });
1106
+ // the routes that start the program: a wire from their row into its top (wide pictures)
1107
+ const toRow = key => rows && rows.y(key);
1108
+ if (!phone && rows) starts.forEach(s => {
1109
+ const ry = toRow(s.entry);
1110
+ if (ry == null) return;
1111
+ const gx = rows.xr + 10 + (wireN++ % 5) * 5, ex = px1 - 24 - (wireN % 5) * 10, ty = top - (phone ? 16 : 20);
1112
+ 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"/>` +
1113
+ `<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"/>`)));
1114
+ });
1115
+ p.engines.forEach((e, i) => {
1116
+ const lx = laneX[i];
1117
+ const title = `<title>${esc(`${e.id}${e.entries.length ? ` · ${e.entries.map(k => k.replace(/^route:/, '')).join(', ')}` : ''}`)}</title>`;
1118
+ 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}"/>`)));
1119
+ // its package and name above the lane, or a number when the lanes are close
1120
+ if (wide) {
1121
+ const sl = sigLines(e), ly0 = laneTop - (phone ? 5 : 6) - (sl.length - 1) * lead;
1122
+ out.push(tg([`lane:${e.id}`, `engineType:${e.id}`], `<g><title>${esc(codeOf(e))}</title>` + T(lx, ly0 - lead, nameOf(e), { anchor: 'middle', size: fsS, fill: C.muted }) +
1123
+ sl.map((l, j) => T(lx, ly0 + j * lead, l, { anchor: 'middle', size: fsS, weight: 700, fill: C.ink })).join('') + '</g>'));
1124
+ } 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>'));
1125
+ // the engine's own decisions, on the program's scale; its bar gives up width so that the count fits before the next lane
1126
+ const eh = barH(e.decisions);
1127
+ over.push(tg([`laneFork:${e.id}`], shape('laneFork', `<title>${esc(`${e.decisions} decisions of its own`)}</title>` +
1128
+ (eh ? `<rect x="${f1(lx + 2.5)}" y="${f1(laneTop)}" width="${f1(forkW)}" height="${f1(eh)}" fill="${C.ink2}" opacity="0.55"/>` : '') +
1129
+ T(lx + 2.5 + forkW + 3, laneTop + 10, String(e.decisions), { size: fsS, weight: 700, fill: C.ink2 }))));
1130
+ // the routes that run it are named under its loop ("run by …"), not wired: one loop per lane is the only line back
1131
+ // the resources its arms reach, at its foot; then the ones it reaches for no request
1132
+ let yy = footY0(i);
1133
+ if (yy > laneBot + 6) under.push(`<path d="M${f1(lx)} ${f1(laneBot)} V${f1(yy)}" stroke="${C.ink2}" stroke-width="1.2"/>`);
1134
+ const pill = (rs, dashed, key) => {
1135
+ const w = pillW(rs);
1136
+ 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"' : ''}/>`);
1137
+ 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"' : ''}/>` +
1138
+ 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 }))));
1139
+ yy += resH + resGap;
1140
+ };
1141
+ e.resources.forEach(rs => pill(rs, false, `laneResource:${e.id}:${rs}`));
1142
+ if (e.also.length) {
1143
+ 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 })));
1144
+ yy += lead;
1145
+ e.also.forEach(rs => pill(rs, true, `laneAlso:${e.id}:${rs}`));
1146
+ }
1147
+ // the reply: one loop from the foot back into the program, one loop node on it
1148
+ const ly = loopY[i];
1149
+ const tx = txOf(i);
1150
+ 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)}`;
1151
+ 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"/>`;
1152
+ const nx = (tx + lx) / 2, nr = phone ? 5 : 6;
1153
+ const node = `<circle cx="${f1(nx)}" cy="${f1(ly)}" r="${nr + 3}" fill="${C.paper}"/>` + mark.loop(nx, ly, nr, C.program);
1154
+ // centred under the loop when it fits there, else from the loop's left end
1155
+ // every line of one caption set the same way: centred when they all fit there
1156
+ const centred = caps[i].every(([t]) => { const m = fitText(t, fsS, lx - tx - 8); return m.s === t && m.size === fsS; });
1157
+ const caption = (cy, text) => centred ? T(nx, cy, text, { anchor: 'middle', size: fsS, fill: C.program })
1158
+ : fitT(capLeft(i), cy, text, capRoom(i), { size: fsS, fill: C.program });
1159
+ const cy0 = ly + nr + (phone ? 11 : 13);
1160
+ const own = caps[i].filter(l => !l[1]), by = caps[i].filter(l => l[1]);
1161
+ 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}` +
1162
+ own.map((l, j) => caption(cy0 + j * lead, l[0])).join(''))) +
1163
+ (by.length ? tg(by[0][1], by.map((l, j) => caption(cy0 + (own.length + j) * lead, l[0])).join('')) : ''));
1164
+ });
1165
+ y = (k ? loopY[k - 1] + loopGap + caps[k - 1].length * lead : footBot + (phone ? 12 : 14)) + (phone ? 6 : 10);
1166
+ }
1167
+ return { under: under.join(''), out: out.join(''), over: over.join(''), bottom: y };
1168
+ }
1169
+
841
1170
  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
1171
  // one caller's bracket: a line from the caller to a spine beside the wall, a tick to each opening
843
1172
  function bracket(sx, sy, bx, y0, y1, ys) {
@@ -898,6 +1227,10 @@ function wallPathGaps(x0, x1, yTop, yBot, openings, r = 14) {
898
1227
  // line; each package is a stretch starting with a tick and its name; the
899
1228
  // parts' labels alternate above and below the line so it never wraps at
900
1229
  // phone width. A package with many forks draws one fork with a count.
1230
+ // A gathered mark's count, said in words: what it counts, never a bare ×n.
1231
+ const SET_WORDS = { forkset: ['choice', 'choices'], dbset: ['db use', 'db uses'], genset: ['generic part', 'generic parts'], stepset: ['step', 'steps'] };
1232
+ export const setWord = (kind, n) => `${n} ${SET_WORDS[kind][n === 1 ? 0 : 1]}`;
1233
+
901
1234
  export function quietItems(map, placed, { services = false, dense = false, phone = false, drawn = new Set(), socketFns = new Map(), genericById = new Map(), exportsLeft = [], strays = [] } = {}) {
902
1235
  const pkgOf = id => String(id).split('::')[0];
903
1236
  const bare = id => String(id).split('::').pop();
@@ -917,7 +1250,7 @@ export function quietItems(map, placed, { services = false, dense = false, phone
917
1250
  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
1251
  for (const [p, us] of strayBy) {
919
1252
  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}`) });
1253
+ 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
1254
  }
922
1255
  // forks (and bare generic parts) of one package gathered under one mark with a count when the line would be long
923
1256
  const total = [...list.values()].reduce((n, l) => n + l.length, 0);
@@ -926,10 +1259,10 @@ export function quietItems(map, placed, { services = false, dense = false, phone
926
1259
  const g = P.get(pkg);
927
1260
  const forks = ids2.filter(id => keyed.has(id) && !socketFns.has(id));
928
1261
  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}`] : [])]) });
1262
+ 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
1263
  else rest = rest.concat(forks);
931
1264
  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)); }
1265
+ 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
1266
  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
1267
  fork: keyed.has(id) ? keyed.get(id).types : null, gen: genericById.has(id) ? genericById.get(id).vars.join('') : null,
935
1268
  keys: [...(socketFns.has(id) ? [`applied:${id}`] : []), ...(keyed.has(id) ? [`fork:${id}`] : []), ...(genericById.has(id) ? [`generic:${id}`] : []), `part:${id}`] });
@@ -942,11 +1275,11 @@ export function quietItems(map, placed, { services = false, dense = false, phone
942
1275
  const of = k => all.filter(it => it.kind === k || (k === 'forkset' && it.fork && it.kind === 'part'));
943
1276
  const parts = [];
944
1277
  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) });
1278
+ const n = list => list.reduce((c, it) => c + (it.n || 1), 0);
1279
+ 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) });
1280
+ 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
1281
  parts.push(...others);
949
- if (steps.length) parts.push({ kind: 'stepset', label: `×${steps.length}`, keys: steps.map(st => `step:${st.pkg}:${st.type}`) });
1282
+ 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
1283
  return [{ pkg: '', exports: [], steps: [], parts, gathered: groups.length }];
951
1284
  }
952
1285
 
@@ -956,13 +1289,17 @@ export function quietItems(map, placed, { services = false, dense = false, phone
956
1289
  // above and below, so a label only has to clear the last one on its side.
957
1290
  function placeOn(pkgs, lsx, phone, f, M) {
958
1291
  const out = []; let x = lsx, lastUp = null, lastDown = null;
1292
+ // the risers that cross the line: the first x at or after x0 where [x + a, x + b] crosses none
1293
+ const bands = M.bands || [];
1294
+ 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
1295
  for (const g of pkgs) {
960
1296
  // headers alternate between two tiers, so a tick clears the header two packages back
961
1297
  const hdrBack = out.length >= 2 ? out[out.length - 2].tick + out[out.length - 2].hw : 0;
962
1298
  // a tick from the upper tier runs down past the lower tier's header before it
963
1299
  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);
1300
+ 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
1301
  const hw = M.header(g, f), sw = M.steps(g, f);
1302
+ const tick = shun(tick0, -(phone ? 4 : 6), Math.max(hw, sw));
966
1303
  lastUp = { x: tick, w: phone ? 10 : 14 }; lastDown = sw ? { x: tick, w: sw } : null;
967
1304
  let px = tick + (phone ? 9 : 14), flip = 0;
968
1305
  const xs = [], sides = [];
@@ -973,6 +1310,7 @@ function placeOn(pkgs, lsx, phone, f, M) {
973
1310
  let xx = px;
974
1311
  if (last && !(side === 'up' && last.x === tick)) xx = Math.max(xx, last.x + last.w);
975
1312
  if (side === 'down' && last && last.x === tick) xx = Math.max(xx, last.x + last.w + 2);
1313
+ xx = shun(xx, -(phone ? 6 : 7), M.width(it, f) - (phone ? 7 : 10));
976
1314
  xs.push(xx); sides.push(side);
977
1315
  const rec = { x: xx, w: M.width(it, f) };
978
1316
  if (side === 'up') lastUp = rec; else lastDown = rec;
@@ -988,13 +1326,15 @@ function placeOn(pkgs, lsx, phone, f, M) {
988
1326
  // The quiet line with its names at an angle: parts' names rise to the right
989
1327
  // from their marks; each package's name, with its steps, falls to the right
990
1328
  // from its tick. One line, however many parts. null when even this would not fit.
1329
+ // a gathered set of choices says what they choose on in its own words; anything else is its label
1330
+ const setLabel = it => it.kind === 'forkset' ? `${it.label} on ${it.fork.join(', ')}` : it.label;
991
1331
  function angledQuiet(pkgs, lsx, lex, y0, phone, fsS) {
992
1332
  const ang = 50, sin = Math.sin(ang * Math.PI / 180), cos = Math.cos(ang * Math.PI / 180);
993
1333
  const pkgName = p => p.split('/').pop();
994
1334
  // each name as its spans: [text, fill, attributes, how it is measured: mono, weight, italic]
995
1335
  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]] : [])];
1336
+ [setLabel(it), C.ink, it.kind === 'part' ? '' : ' font-weight="700"', true, it.kind === 'part' ? 400 : 700],
1337
+ ...(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
1338
  const downSpans = g => [[pkgName(g.pkg), C.faint, ` font-family="${SANS}" font-weight="700"`, false, 700],
999
1339
  ...(g.steps.length ? [[' ' + g.steps.map(st => st.label).join(', '), C.hullInk, ' font-weight="700"', true, 700]] : [])];
1000
1340
  const lay = pitch => {
@@ -1058,15 +1398,18 @@ function angledQuiet(pkgs, lsx, lex, y0, phone, fsS) {
1058
1398
  return { items: pkgs.length, under, out, over, bottom: ry + downLen * sin + (phone ? 4 : 6) };
1059
1399
  }
1060
1400
 
1061
- export function layoutQuiet(pkgs, lsx, lex, y0, phone, fsS0) {
1401
+ export function layoutQuiet(pkgs, lsx, lex, y0, phone, fsS0, bands = []) {
1062
1402
  pkgs = pkgs.filter(g => g.parts.length || g.steps.length || g.exports.length);
1063
1403
  if (!pkgs.length) return { items: 0, under: [], out: [], over: [], bottom: y0 };
1064
1404
  const pkgName = p => p.split('/').pop();
1065
1405
  let fsS = fsS0;
1066
- const sub = it => it.fork ? it.fork.join(',') : it.fns ? it.fns.join(', ') : '';
1406
+ // a gathered set of choices says what they choose on in its own line of words, with a wider gap after it
1407
+ // so the next count does not read as part of it; a single part keeps its type under it
1408
+ const sub = it => it.kind === 'forkset' ? '' : it.fork ? it.fork.join(',') : it.fns ? it.fns.join(', ') : '';
1067
1409
  const M = {
1410
+ bands,
1068
1411
  // 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),
1412
+ 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
1413
  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
1414
  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
1415
  };
@@ -1122,15 +1465,16 @@ export function layoutQuiet(pkgs, lsx, lex, y0, phone, fsS0) {
1122
1465
  const ny = up ? ry - (phone ? 7 : 9) : ry + (phone ? 13 : 16);
1123
1466
  // a fork's types a line further out than the name, clear of it
1124
1467
  const sy = ny + (up ? -1 : 1) * lineGap(fsS);
1125
- let gg = '', tx = x - 3;
1468
+ // a step set's count starts past its loop's arrowhead
1469
+ let gg = '', tx = it.kind === 'stepset' ? x + (phone ? 6 : 8) : x - 3;
1126
1470
  if (it.kind === 'forkset') gg += mark.fork(x - (phone ? 3 : 4), ry, phone ? 0.75 : 0.9);
1127
1471
  else if (it.kind === 'dbset') gg += mark.station(x, ry, phone ? 3.6 : 4.6, it.writes);
1128
1472
  else if (it.kind === 'stepset') gg += mark.loop(x, ry - 0.5, phone ? 4.2 : 5.4);
1129
1473
  else if (it.socket) gg += mark.socket(x, ry, phone ? 4.6 : 6);
1130
1474
  else gg += mark.dot(x, ry, phone ? 3 : 3.8);
1131
1475
  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 }); }
1476
+ gg += T(tx, ny, setLabel(it), { size: fsS, fill: it.kind === 'stepset' ? C.hullInk : C.ink, weight: it.kind === 'part' ? null : 700 });
1477
+ 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
1478
  else if (it.fns) gg += T(x - 3, sy, it.fns.join(', '), { size: fsS - 0.5, fill: C.hullInk, italic: true });
1135
1479
  over.push(tg(it.keys, gg));
1136
1480
  });
@@ -1229,9 +1573,11 @@ function renderLibrary(map, W, opts) {
1229
1573
  steps.forEach((s, k) => {
1230
1574
  const x = xs[k + 1];
1231
1575
  decorate(s.id, x, py);
1576
+ // a step's names are centred on it, within its share of the chain and short of the package's edge
1577
+ const room = 2 * Math.min(dx / 2 - 2, x1 - (phone ? 8 : 12) - x);
1232
1578
  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 })); }
1579
+ if (fns && !phone) out.push(T(x, py - (phone ? 9 : 11), clip(fns.join(', '), 9, room - 4), { anchor: 'middle', size: 9, fill: C.hullInk }));
1580
+ { 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
1581
  });
1236
1582
  drawn.add(c.value);
1237
1583
  y = py + (phone ? 26 : 32) + gapY + (phone ? 6 : 8);
@@ -1510,7 +1856,7 @@ export function partFor(map, id) {
1510
1856
  export function journeyLabel(map, key) {
1511
1857
  if (key === 'quiet') return 'off the entry paths';
1512
1858
  const b = map.bundles && map.bundles.find(x => x.id === key);
1513
- if (b) return b.count > 1 ? `${b.label} ×${b.count}` : b.label;
1859
+ if (b) return b.count > 1 ? `${b.label} (${b.count} routes)` : b.label;
1514
1860
  return partFor(map, key).label;
1515
1861
  }
1516
1862
 
@@ -1540,7 +1886,6 @@ function renderPart(map, partId, opts) {
1540
1886
  const rh = phone ? 56 : 66;
1541
1887
  const yTop = frameTop + (phone ? 16 : 20);
1542
1888
  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
1889
  const shared = n > 1;
1545
1890
  const atPart = u => part.station ? u.at.node === 'entry' && u.at.entry === part.station : u.at.node === 'wrapper' && u.at.wrapper === part.id;
1546
1891
  const uses = map.uses.map((u, i) => ({ ...u, i })).filter(u => u.kind === 'db' && atPart(u));
@@ -1548,17 +1893,52 @@ function renderPart(map, partId, opts) {
1548
1893
  const rS = phone ? 5.4 : 6.6;
1549
1894
  // columns: the casing holds the part; past it, the after band and the reply bars
1550
1895
  // 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);
1896
+ const k = phone ? 0.88 : 1, xr = x0 + cw * 0.96;
1897
+ const keyedById = new Map(map.keyed.map(k => [k.id, k]));
1898
+ const keyed = ids => [...new Set(ids)].filter(id => keyedById.has(id));
1899
+ const typesOf = ids => [...new Set(keyed(ids).flatMap(id => keyedById.get(id).types))].join(',');
1900
+ // The sockets and the checks after them take the room their labels' widest
1901
+ // pieces need, so that wrapping never has to clip one: the columns left of
1902
+ // the sockets give way down to three quarters of their width, then the
1903
+ // casing reaches into the band after it, which keeps room for its marks.
1904
+ const piece = s => (s ? widestPiece(s, fsS, true) : 0);
1905
+ const passed = part.station ? [] : callers.map(cl => cl.passed[0]).filter(Boolean);
1906
+ const checked = passed.filter(p => p.guards.length);
1907
+ 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)));
1908
+ 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)));
1909
+ 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; }));
1910
+ let caseEnd = x0 + cw * 0.8 * k, xSock = x0 + cw * 0.6 * k, xPG = x0 + cw * 0.72 * k;
1911
+ if (!part.station) {
1912
+ const checkSpan = checked.length ? Math.max(caseEnd - xPG, checkNeed + 6) : 0;
1913
+ const sockSpan = Math.max((checked.length ? xPG : caseEnd) - xSock, sockNeed + (checked.length ? 8 : 6));
1914
+ // (only columns with nothing of the part's own to label give way)
1915
+ 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);
1916
+ caseEnd = Math.max(caseEnd, Math.min(most, least + sockSpan + checkSpan));
1917
+ xPG = caseEnd - checkSpan;
1918
+ xSock = Math.max(least, Math.min(x0 + cw * 0.6 * k, xPG - sockSpan));
1919
+ }
1920
+ // the columns left of the sockets, in their share of what the sockets leave
1921
+ const c = p => x0 + (xSock - x0) * p / 0.6;
1922
+ const xMerge = c(0.08), xRead = c(0.22), xGuard = c(0.38), xSplit = c(0.5);
1554
1923
  const stX = part.station ? xSock : xRead;
1555
1924
  // the parameter every caller fixes drops into the check it decides, else the part's own station
1556
1925
  const feedX = s => (s.guardsFed.length && part.guards.length ? xGuard : xRead);
1557
- const keyedById = new Map(map.keyed.map(k => [k.id, k]));
1926
+ // the casing's head lists them, those dropping furthest left first, each on
1927
+ // lines of its own reaching to the casing's edge: a later one's drop starts
1928
+ // at its own name, under the names that reach across it
1929
+ const lead = phone ? 12 : 13;
1930
+ const feedRows = [];
1931
+ { let fy = yTop + (phone ? 20 : 23);
1932
+ for (const s of feeds.slice().sort((a, b) => feedX(a) - feedX(b))) {
1933
+ const gx = feedX(s), text = `${s.name}: ${String(s.type).split('::').pop()}`;
1934
+ const lines = wrapped(text, caseEnd - 6 - gx - 6, { size: fsS });
1935
+ feedRows.push({ s, gx, text, lines, y: fy });
1936
+ fy += lines.length * lead;
1937
+ } }
1938
+ const feedLines = feedRows.reduce((a, r) => a + r.lines.length, 0);
1939
+ const rowsTop = yTop + (phone ? 30 : 34) + (feeds.length ? (phone ? 34 : 39) + (feedLines - 1) * lead : 0);
1558
1940
  // 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(',');
1941
+ const line = (y, k) => y + (phone ? 14 : 16) + (k - 1) * lead;
1562
1942
  const forkLines = (ids, x, end) => (keyed(ids).length ? wrapped(typesOf(ids), end - x - 11, { size: fsS }) : []);
1563
1943
  const entryOf = cl => map.entries.find(e => e.handlerId === cl.id || e.key === cl.id);
1564
1944
  // Each caller's lines: its name over its path; under it its routes, then its
@@ -1578,7 +1958,8 @@ function renderPart(map, partId, opts) {
1578
1958
  const tokX = roles.length === 1 ? lx + tw(roles[0], fsS, false, 700) + 8 : lx;
1579
1959
  const tokSame = !!tokTxt && roles.length === 1 && tw(tokTxt, fsS) <= x0 - tokX - 6;
1580
1960
  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);
1961
+ // the roles and tokens start a line below the path's own (left empty without a route, for the lock)
1962
+ const left = (roles.length || toks.length ? Math.max(1, sub.length) : sub.length) + roles.length + (tokSame ? 0 : toks.length);
1582
1963
  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
1964
  if (!part.station) {
1584
1965
  const p = cl.passed[0], pe = p ? p.effects : [];
@@ -1618,7 +1999,7 @@ function renderPart(map, partId, opts) {
1618
1999
  const yBot = railUses.length ? busY + (phone ? 18 : 22) : rowsBottom + (phone ? 46 : 52);
1619
2000
  const wall = wallPath(x0, x1, yTop, yBot, ys.concat(railUses.length ? [busY] : []), phone ? 6 : 7);
1620
2001
  const casing = part.station ? 'canvas' : 'casing';
1621
- if (!part.station) under.push(shape('hull', `<rect x="${x0 + 8}" y="${yTop + 8}" width="${f1(caseEnd - x0 - 8)}" height="${yBot - yTop - 16}" rx="12" fill="${C.hull}" stroke="${C.hullEdge}" stroke-width="1"${boxAttr(casing)}/>`));
2002
+ if (!part.station) under.push(shape('hull', `<rect x="${x0 + 8}" y="${yTop + 8}" width="${f1(caseEnd - x0 - 8)}" height="${yBot - yTop - 16}" rx="12" fill="${C.hull}" stroke="${C.hullEdge}" stroke-width="1"${boxAttr(casing)}/>` + hullWall(caseEnd, ys, phone)));
1622
2003
  // a fork under a mark, its types from line k ending by `end`
1623
2004
  const forkAt = (ids, x, y, k, end) => {
1624
2005
  const ks = keyed(ids);
@@ -1658,12 +2039,15 @@ function renderPart(map, partId, opts) {
1658
2039
  } 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
2040
  // under the part's own station, right of its riser: its fork, then its tables; they end short of its check's stem
1660
2041
  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);
2042
+ // 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
2043
+ 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);
2044
+ const stEnd = part.station ? xr - 6 : part.guards.length && !short ? xGuard - 8 : xSplit - 4;
2045
+ const stLine = short ? 1 + Math.max(1, forkLines(part.guardIds || [], gx + 6, xSplit - 4).length) : 1;
2046
+ const stFork = forkLines([part.id], stX + 6, stEnd).length;
2047
+ forkAt([part.id], stX + 6, ty, stLine, stEnd);
1664
2048
  if (part.effects.length) {
1665
2049
  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 })));
2050
+ 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
2051
  } else if (part.station) over.push(mark.dot(stX, ty, 3.4));
1668
2052
  if (part.guards.length) {
1669
2053
  over.push(tg((part.guardIds || []).map(g => `guard:${part.id}:${g}`), mark.guard(gx, ty, phone ? 0.9 : 1.1)));
@@ -1674,11 +2058,16 @@ function renderPart(map, partId, opts) {
1674
2058
  forkAt(part.guardIds || [], gx + 6, ty, 1, part.station ? stX - rS - 6 : xSplit - 4);
1675
2059
  }
1676
2060
  // 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 })));
2061
+ const dropped = new Set();
2062
+ for (const { gx, text, lines, y } of feedRows) {
2063
+ // one drop for each column, from above the casing, or from the first name in it the names to its left leave clear
2064
+ if (!dropped.has(gx)) {
2065
+ dropped.add(gx);
2066
+ const from = feedRows.some(r => r.gx < gx) ? y - fsS : yTop + 2;
2067
+ 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"/>`);
2068
+ 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}"/>`);
2069
+ }
2070
+ out.push(owned(casing, Tw(gx + 6, y, lines, { size: fsS, fill: C.ink2 }, text, lead)));
1682
2071
  }
1683
2072
  if (!part.station) callers.forEach((cl, i) => {
1684
2073
  const y = ys[i];