@liggi/figkit 0.2.0 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/viz/draw.js CHANGED
@@ -1,5 +1,6 @@
1
1
  /* The chart types, drawn into a host element styled by runtime/fig.css. Figures use them through runtime/fig.js. */
2
2
  import * as Plot from '@observablehq/plot';
3
+ import { drawSequence } from './sequence.js';
3
4
  const NS = 'http://www.w3.org/2000/svg';
4
5
  const esc = (s) => String(s).replace(/[&<>"]/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' })[c]);
5
6
 
@@ -79,21 +80,44 @@ export function drawSplit(host, parts, unit) {
79
80
  }
80
81
  }
81
82
 
82
- /** A few numbers compared into a <fig-bars>, on an axis from zero to max (default: the largest value). */
83
- export function drawBars(host, bars, unit, max) {
84
- const top = max ?? Math.max(...bars.map((b) => b.value));
83
+ /**
84
+ * A few numbers compared into a <fig-bars>, on an axis from zero to max (default: the largest value or high). A bar
85
+ * with `low` and `high` gets a range drawn across its end, and its number moves past the range; `n` goes under its label.
86
+ */
87
+ export function drawBars(host, bars, unit, max, each) {
88
+ const top = max ?? Math.max(...bars.map((b) => Math.max(b.value, b.high ?? 0)));
85
89
  host.innerHTML = '';
86
90
  const fills = bars.map((b) => {
87
- const label = Object.assign(document.createElement('div'), { className: 'fb-label', innerHTML: b.label });
91
+ const label = Object.assign(document.createElement('div'), { className: 'fb-label', innerHTML: b.label + (b.n !== undefined ? `<span class="fb-n">${each ? `${esc(b.n.toLocaleString('en-GB'))} ${esc(each)}` : `n = ${esc(b.n.toLocaleString('en-GB'))}`}</span>` : '') });
88
92
  const track = Object.assign(document.createElement('div'), { className: 'fb-track' });
89
93
  const tone = b.tone ? ' ' + b.tone : '';
90
- track.innerHTML = `<div class="fb-bar${tone}"></div><div class="fb-val${b.tone ? ' lead ' + b.tone : ''}">${esc(shown(b, unit))}${b.note ? `<span class="fb-sub">${esc(b.note)}</span>` : ''}</div>`;
94
+ const ranged = b.low !== undefined;
95
+ // With a range, the bar steps back and the range, in the bar's colour, leads; its ends are printed after the value.
96
+ const ends = ranged ? `<span class="fb-sub">${esc(num(b.low))}–${esc(num(b.high))}${esc(unit ?? '')}</span>` : '';
97
+ track.innerHTML = `<div class="fb-bar${tone}${ranged ? ' dim' : ''}"></div>${ranged ? `<div class="fb-range${tone}"></div>` : ''}<div class="fb-val${b.tone ? ' lead ' + b.tone : ''}">${esc(shown(b, unit))}${ends}${b.note ? `<span class="fb-sub">${esc(b.note)}</span>` : ''}</div>`;
91
98
  host.append(label, track);
92
- return { bar: track.firstChild, ratio: top > 0 ? Math.max(0, b.value) / top : 0 };
99
+ const r = (v) => (top > 0 ? Math.max(0, v) / top : 0);
100
+ return { b, bar: track.firstChild, range: track.querySelector('.fb-range'), val: track.querySelector('.fb-val'), ratio: r(b.value), lo: r(b.low ?? 0), hi: r(b.high ?? 0) };
93
101
  });
94
- // The longest bar leaves room for the widest value and note, so no label runs past the edge.
102
+ // The longest bar or range leaves room for the widest value and note, so no label runs past the edge.
95
103
  const room = Math.max(...[...host.querySelectorAll('.fb-val')].map((v) => v.offsetWidth)) + 12;
96
- for (const f of fills) f.bar.style.width = `calc((100% - ${room}px) * ${f.ratio})`;
104
+ const at = (ratio) => `calc((100% - ${room}px) * ${ratio})`;
105
+ for (const f of fills) {
106
+ f.bar.style.width = at(f.ratio);
107
+ if (!f.range) continue;
108
+ f.range.style.left = at(f.lo);
109
+ f.range.style.width = at(f.hi - f.lo);
110
+ f.val.style.marginLeft = `max(0px, calc((100% - ${room}px) * ${f.hi - f.ratio} - 3px))`;
111
+ }
112
+ }
113
+
114
+ /** What the ranges are, as a key line under the reading line: the mark as drawn, then the words. */
115
+ function rangeKey(spec) {
116
+ const p = document.createElement('p');
117
+ p.className = 'range-key';
118
+ p.innerHTML = (spec.range ? `<span><i class="${spec.type === 'bars' ? 'whisker' : 'band'}"></i>${esc(spec.range)}</span>` : '')
119
+ + (spec.ref ? `<span><i class="dash"></i>${esc(spec.ref.label)}</span>` : '');
120
+ return p;
97
121
  }
98
122
 
99
123
  const ARROW = '<svg width="56" height="24" viewBox="0 0 56 24" aria-hidden="true"><path d="M2 12 H48 M38 3 L50 12 L38 21" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"/></svg>';
@@ -108,7 +132,7 @@ export function drawChange(host, spec) {
108
132
 
109
133
  const themeOf = (host) => {
110
134
  const css = (n) => getComputedStyle(host).getPropertyValue(n).trim();
111
- return { css, ink: css('--ink'), muted: css('--muted'), soft: css('--soft'), line: css('--line'), accent: css('--accent'), second: css('--second'), good: css('--good'), bad: css('--bad'), small: parseFloat(css('--small')) || 15, body: parseFloat(css('--body')) || 16, sans: css('--sans') };
135
+ return { css, ink: css('--ink'), muted: css('--muted'), soft: css('--soft'), fill: css('--fill'), fill2: css('--fill2'), hair: css('--hair'), paper: css('--paper'), line: css('--line'), accent: css('--accent'), second: css('--second'), good: css('--good'), bad: css('--bad'), small: parseFloat(css('--small')) || 15, body: parseFloat(css('--body')) || 16, sans: css('--sans') };
112
136
  };
113
137
  const toX = (v) => (typeof v === 'number' ? v : new Date(v));
114
138
  const num = (n) => (Math.abs(n) >= 100 ? Math.round(n) : Math.round(n * 10) / 10).toLocaleString('en-GB');
@@ -153,7 +177,7 @@ export function drawDumbbell(host, spec) {
153
177
  marks: [
154
178
  Plot.text(rows, { y: 'label', text: 'label', frameAnchor: 'left', dx: -(Math.ceil(labelW) + 28), textAnchor: 'start', fill: t.ink, fontSize: t.body }),
155
179
  Plot.link(rows, { x1: 'a', x2: 'b', y1: 'label', y2: 'label', stroke: 'color', strokeWidth: 3 }),
156
- Plot.dot(rows, { x: 'a', y: 'label', r: 6.5, fill: t.soft }),
180
+ Plot.dot(rows, { x: 'a', y: 'label', r: 6.5, fill: t.fill }),
157
181
  // A fixed-size head at the end, pointing the way the row moved.
158
182
  // A fixed-size head whose tip, not its middle, sits on the end value.
159
183
  Plot.dot(rows.filter((r) => r.b >= r.a), { x: 'b', y: 'label', symbol: 'triangle2', r: 7.5, dx: -5, fill: 'color', rotate: 90 }),
@@ -165,6 +189,7 @@ export function drawDumbbell(host, spec) {
165
189
  const key = document.createElement('p');
166
190
  key.className = 'fd-key';
167
191
  key.innerHTML = `<span class="fd-from"><i></i>${esc(spec.fromLabel)}</span><span class="fd-to${rows.every((r) => r.b < r.a) ? ' back' : ''}" style="color:${t.accent}"><i></i>${esc(spec.toLabel)}</span>`;
192
+ key.style.marginLeft = labelW + 'px';
168
193
  host.prepend(key);
169
194
  }
170
195
 
@@ -172,12 +197,13 @@ export function drawDumbbell(host, spec) {
172
197
  export function drawSeries(host, spec) {
173
198
  const t = themeOf(host);
174
199
  const width = Math.floor(host.clientWidth) || 640;
175
- const pts = spec.points.map((p) => ({ x: toX(p.x), y: p.y }));
200
+ const pts = spec.points.map((p) => ({ x: toX(p.x), y: p.y, low: p.low, high: p.high }));
201
+ const band = pts[0].low !== undefined;
176
202
  const i = pts.findIndex((p) => +p.x === +toX(spec.mark.x));
177
203
  const mark = pts[i];
178
204
  const near = pts.slice(Math.max(0, i - 3), i + 4).map((p) => p.y);
179
205
  const high = mark.y >= near.reduce((s, y) => s + y, 0) / near.length;
180
- const ys = pts.map((p) => p.y), zero = spec.zero ?? true;
206
+ const ys = [...pts.flatMap((p) => (band ? [p.y, p.low, p.high] : [p.y])), ...(spec.ref ? [spec.ref.y] : [])], zero = spec.zero ?? true;
181
207
  const top = Math.max(...ys), bottom = zero ? Math.min(0, ...ys) : Math.min(...ys);
182
208
  const fmt = (d) => num(d) + (spec.unit ?? '');
183
209
  const tickW = widest(host, [fmt(top), fmt(bottom)], t.small);
@@ -187,16 +213,23 @@ export function drawSeries(host, spec) {
187
213
  const right = at + 14 + widest(host, [spec.mark.label], t.body, 600) <= width - 20;
188
214
  // Above a high point; level with a low one, lifted clear of the axis when it sits near the bottom.
189
215
  const lift = zero ? ((mark.y - bottom) / (top * 1.04 - bottom)) * (340 - 30 - 40) : Infinity;
190
- const dy = high ? -12 : Math.min(0, lift - 18);
216
+ let dy = high ? -12 : Math.min(0, lift - 18);
217
+ // A label to the left of its point sits on the side away from the line coming in: above it when the previous
218
+ // point is lower, below when higher.
219
+ if (!right && i > 0) dy = pts[i - 1].y <= mark.y ? -18 : 20;
220
+ // Above or below, the label ends just past the point, so it does not reach back over the line.
221
+ const dx = right ? 14 : i > 0 ? 8 : -14;
191
222
  plotInto(host, t, {
192
223
  width, height: 340, marginLeft: left0, marginRight: 20, marginTop: 30, marginBottom: 40,
193
224
  x: { label: null, ticks: width < 600 ? 4 : 5, insetLeft: 18 },
194
225
  y: { label: null, grid: true, ticks: 4, tickSize: 0, domain: zero ? [bottom, top * 1.04] : undefined, tickFormat: fmt },
195
226
  marks: [
196
227
  zero ? Plot.ruleY([0], { stroke: t.line }) : null,
197
- Plot.lineY(pts, { x: 'x', y: 'y', stroke: t.soft, strokeWidth: 2.5 }),
228
+ band ? Plot.areaY(pts, { x: 'x', y1: 'low', y2: 'high', fill: t.fill, fillOpacity: 0.3 }) : null,
229
+ spec.ref ? Plot.ruleY([spec.ref.y], { stroke: t.soft, strokeWidth: 1.5, strokeDasharray: '5,4' }) : null,
230
+ Plot.lineY(pts, { x: 'x', y: 'y', stroke: band ? t.muted : t.fill, strokeWidth: 2.5 }),
198
231
  Plot.dot([mark], { x: 'x', y: 'y', r: 6.5, fill: t.accent }),
199
- Plot.text([mark], { x: 'x', y: 'y', text: () => spec.mark.label, dx: right ? 14 : -14, dy, textAnchor: right ? 'start' : 'end', fill: t.accent, fontWeight: 600, fontSize: t.body }),
232
+ Plot.text([mark], { x: 'x', y: 'y', text: () => spec.mark.label, dx, dy, textAnchor: right ? 'start' : 'end', fill: t.accent, fontWeight: 600, fontSize: t.body }),
200
233
  ],
201
234
  });
202
235
  }
@@ -283,18 +316,26 @@ function drawEntities(spec, main) {
283
316
  }
284
317
  }
285
318
 
286
- const HOSTS = { split: 'fig-split', bars: 'fig-bars', 'before-after': 'fig-change', dumbbell: 'div', series: 'div' };
319
+ const HOSTS = { split: 'fig-split', bars: 'fig-bars', 'before-after': 'fig-change', dumbbell: 'div', series: 'div', groups: 'div', lines: 'div', panels: 'div', heatmap: 'div', dots: 'div', scatter: 'div', units: 'div' };
287
320
 
288
321
  /** A checked spec drawn into a figure: header, chart and footer, as figkit's own markup. */
289
322
  export function drawSpec(spec, main) {
290
323
  const header = document.createElement('header');
291
324
  header.innerHTML = `<h1>${esc(spec.title)}</h1>${spec.note ? `<p class="read">${esc(spec.note)}</p>` : ''}`;
325
+ // What the ranges and a reference line are, said once under the reading line: there they never sit on the data.
326
+ if (spec.range || spec.ref) header.appendChild(rangeKey(spec));
292
327
  if (spec.type === 'entities') {
293
328
  main.append(header);
294
329
  drawEntities(spec, main);
295
330
  if (spec.source) { const footer = document.createElement('footer'); footer.innerHTML = `<p>${esc(spec.source)}</p>`; main.append(footer); }
296
331
  return { chart: main.querySelector('.entities'), draw: () => {} };
297
332
  }
333
+ if (spec.type === 'sequence') {
334
+ main.append(header);
335
+ const chart = drawSequence(spec, main);
336
+ if (spec.source) { const footer = document.createElement('footer'); footer.innerHTML = `<p>${esc(spec.source)}</p>`; main.append(footer); }
337
+ return { chart, draw: () => {} };
338
+ }
298
339
  const chart = document.createElement(HOSTS[spec.type]);
299
340
  main.append(header, chart);
300
341
  if (spec.source) {
@@ -306,10 +347,401 @@ export function drawSpec(spec, main) {
306
347
  if (spec.type === 'before-after') return drawChange(chart, spec);
307
348
  if (spec.type === 'dumbbell') return drawDumbbell(chart, spec);
308
349
  if (spec.type === 'series') return drawSeries(chart, spec);
350
+ if (spec.type === 'groups') return drawGroups(chart, spec, { unit: spec.unit, max: spec.max });
351
+ if (spec.type === 'lines') return drawLines(chart, spec, { unit: spec.unit, xTicks: spec.xTicks });
352
+ if (spec.type === 'panels') return drawPanels(chart, spec);
353
+ if (spec.type === 'heatmap') return drawHeatmap(chart, spec);
354
+ if (spec.type === 'dots') return drawDots(chart, spec);
355
+ if (spec.type === 'scatter') return drawScatter(chart, spec);
356
+ if (spec.type === 'units') return drawUnits(chart, spec);
309
357
  const items = (spec.parts ?? spec.bars).map((x) => ({ ...x, text: String(x.value), label: esc(x.label) }));
310
358
  if (spec.type === 'split') drawSplit(chart, items, spec.unit);
311
- else drawBars(chart, items, spec.unit, spec.max);
359
+ else drawBars(chart, items, spec.unit, spec.max, spec.each);
312
360
  };
313
361
  draw();
314
362
  return { chart, draw };
315
363
  }
364
+
365
+ // ---------- grouped bars, several lines, panels, heat maps ----------
366
+
367
+ const hex = (c) => { const m = /^#?([0-9a-f]{6})$/i.exec(c.trim()); return m ? [0, 2, 4].map((i) => parseInt(m[1].slice(i, i + 2), 16)) : null; };
368
+ /** a mixed with b, p of the way to b, as #rrggbb; a when either is not a plain hex colour. */
369
+ function mix(a, b, p) {
370
+ const x = hex(a), y = hex(b);
371
+ if (!x || !y) return a;
372
+ return '#' + x.map((v, i) => Math.round(v + (y[i] - v) * p).toString(16).padStart(2, '0')).join('');
373
+ }
374
+ const phoneOf = (host) => !!host.closest('.phone');
375
+
376
+ /**
377
+ * Bars in groups into a .chart: a heading per group, a row per series labelled with its name, so no legend is needed.
378
+ * opts.max fixes the end of the axis (panels share one), opts.unit follows each number.
379
+ */
380
+ export function drawGroups(host, data, opts = {}) {
381
+ const t = themeOf(host);
382
+ const width = Math.floor(host.clientWidth) || 640, phone = phoneOf(host) || width < 420;
383
+ const { series, groups, accent } = data;
384
+ const tone = t[accent.tone ?? 'accent'];
385
+ const rows = groups.flatMap((g) => series.map((s, i) => ({ g: g.label, s, v: g.values[i], hi: (accent.series === undefined || accent.series === s) && (accent.group === undefined || accent.group === g.label) })));
386
+ const fmt = (v) => num(v) + (opts.unit ?? '');
387
+ const top = opts.max ?? Math.max(...rows.map((r) => r.v));
388
+ const indent = 0;
389
+ const labelW = Math.ceil(Math.max(widest(host, series, t.body) + indent, widest(host, groups.map((g) => g.label), t.body, 600))) + 14;
390
+ const valueW = Math.ceil(widest(host, rows.map((r) => fmt(r.v)), t.body, 600)) + 12;
391
+ // A group's heading sits close above its own bars, with more room above it, so the groups read as blocks.
392
+ const rowH = Math.max(phone ? 34 : 38, Math.ceil(t.body * 1.75)), headH = Math.max(30, Math.ceil(t.body * 1.7)), above = 22;
393
+ const step = series.length * rowH + headH + above;
394
+ plotInto(host, t, {
395
+ width, height: groups.length * step - above + 8, marginLeft: labelW, marginRight: valueW, marginTop: headH, marginBottom: 8,
396
+ // Every bar carries its number, so an axis would only repeat it; the zero line is kept.
397
+ x: { domain: [0, top], axis: null },
398
+ y: { domain: series, axis: null, padding: 0.18 },
399
+ fy: { domain: groups.map((g) => g.label), axis: null, paddingInner: (headH + above) / step, paddingOuter: 0 },
400
+ marks: [
401
+ Plot.ruleX([0], { stroke: t.line }),
402
+ Plot.barX(rows, { x: 'v', y: 's', fy: 'g', fill: (r) => (r.hi ? tone : t.fill), rx: 2 }),
403
+ Plot.text(rows, { x: 'v', y: 's', fy: 'g', text: (r) => fmt(r.v), dx: 6, textAnchor: 'start', fill: (r) => (r.hi ? tone : t.ink), fontWeight: (r) => (r.hi ? 600 : 400), fontSize: t.body }),
404
+ Plot.text(rows, { x: 0, y: 's', fy: 'g', text: 's', dx: -labelW + indent, textAnchor: 'start', fill: t.ink, fontSize: t.body }),
405
+ Plot.text(groups.map((g) => g.label), { fy: (d) => d, frameAnchor: 'top-left', dx: -labelW, dy: -headH + 2, text: (d) => d, textAnchor: 'start', lineAnchor: 'top', fill: opts.quietGroups ? t.muted : t.ink, fontWeight: 600, fontSize: t.body * 1.06 }),
406
+ ],
407
+ });
408
+ }
409
+
410
+ /**
411
+ * Two or three lines on one axis into a .chart. Each is named at its right end in its own colour; the accent line is
412
+ * drawn last and thicker, the others in two greys. opts.domain fixes the y axis (panels share one).
413
+ */
414
+ export function drawLines(host, data, opts = {}) {
415
+ const t = themeOf(host);
416
+ const width = Math.floor(host.clientWidth) || 640, phone = phoneOf(host) || width < 420;
417
+ const greys = [t.fill2, t.fill];
418
+ const lines = data.series.map((l) => ({ ...l, band: l.points[0].low !== undefined, pts: l.points.map((p) => ({ x: toX(p.x), y: p.y, low: p.low, high: p.high, s: l.label })) }));
419
+ const others = lines.filter((l) => l.label !== data.accent);
420
+ others.forEach((l, i) => { l.color = greys[i % 2]; l.w = 2.25; });
421
+ const hi = lines.find((l) => l.label === data.accent);
422
+ hi.color = t.accent; hi.w = 3.25;
423
+ const ys = lines.flatMap((l) => l.pts.flatMap((p) => (l.band ? [p.y, p.low, p.high] : [p.y])));
424
+ const zero = data.zero ?? true;
425
+ const domain = opts.domain ?? [zero ? Math.min(0, ...ys) : Math.min(...ys), Math.max(...ys) * 1.04];
426
+ const fmt = (d) => num(d) + (opts.unit ?? '');
427
+ const height = phone ? 260 : 300, mt = data.mark ? 28 : 10, mb = 36;
428
+ const ends = [...others, hi].map((l) => ({ l, last: l.pts[l.pts.length - 1] }));
429
+ const labelW = Math.ceil(widest(host, ends.map((e) => e.l.label), t.small, 600)) + 14;
430
+ // Each name sits level with its line's last point, pushed apart when two would overlap.
431
+ const py = (v) => mt + (1 - (v - domain[0]) / (domain[1] - domain[0])) * (height - mt - mb);
432
+ const placed = ends.map((e) => ({ ...e, y: py(e.last.y) })).sort((a, b) => a.y - b.y);
433
+ const gap = t.small * 1.3;
434
+ for (let i = 1; i < placed.length; i++) placed[i].y = Math.max(placed[i].y, placed[i - 1].y + gap);
435
+ for (let i = placed.length - 2; i >= 0; i--) placed[i].y = Math.min(placed[i].y, placed[i + 1].y - gap);
436
+ const tickW = Math.ceil(widest(host, [fmt(domain[1]), fmt(domain[0])], t.small)) + 12;
437
+ const markPt = data.mark && hi.pts.find((p) => +p.x === +toX(data.mark.x));
438
+ plotInto(host, t, {
439
+ width, height, marginLeft: tickW, marginRight: labelW, marginTop: mt, marginBottom: mb,
440
+ x: opts.xTicks ? { label: null, insetLeft: 18, ticks: opts.xTicks.map((k) => toX(k.x)), tickFormat: (d) => opts.xTicks.find((k) => +toX(k.x) === +d)?.label ?? '' }
441
+ : { label: null, insetLeft: 18, ticks: width < 520 ? 3 : 5, tickFormat: typeof lines[0].pts[0].x === 'number' ? (d) => num(d) : undefined },
442
+ y: { domain, nice: false, label: null, grid: true, ticks: 4, tickSize: 0, tickFormat: fmt },
443
+ marks: [
444
+ zero ? Plot.ruleY([0], { stroke: t.line }) : null,
445
+ ...[...others, hi].filter((l) => l.band).map((l) => Plot.areaY(l.pts, { x: 'x', y1: 'low', y2: 'high', fill: l.color, fillOpacity: l === hi ? 0.22 : 0.18 })),
446
+ ...[...others, hi].map((l) => Plot.lineY(l.pts, { x: 'x', y: 'y', stroke: l.color, strokeWidth: l.w })),
447
+ Plot.text(placed, { x: (e) => e.last.x, y: (e) => domain[0] + (1 - (e.y - mt) / (height - mt - mb)) * (domain[1] - domain[0]), text: (e) => e.l.label, dx: 8, textAnchor: 'start', fill: (e) => (e.l === hi ? t.accent : t.muted), fontWeight: 600, fontSize: t.small }),
448
+ markPt ? Plot.dot([markPt], { x: 'x', y: 'y', r: 6, fill: t.accent }) : null,
449
+ markPt ? Plot.text([markPt], { x: 'x', y: 'y', text: () => data.mark.label, dy: -16, fill: t.accent, fontWeight: 600, fontSize: t.small }) : null,
450
+ ],
451
+ });
452
+ }
453
+
454
+ /** The same chart for each part, side by side on one scale; stacked on a phone. */
455
+ export function drawPanels(host, spec) {
456
+ host.className = 'panels';
457
+ host.innerHTML = '';
458
+ const charts = spec.panels.map((p) => {
459
+ const box = document.createElement('div');
460
+ box.className = 'panel';
461
+ box.innerHTML = `<h2>${esc(p.label)}</h2>`;
462
+ const chart = document.createElement('div');
463
+ box.appendChild(chart);
464
+ host.appendChild(box);
465
+ return { p, chart };
466
+ });
467
+ if (spec.chart === 'groups') {
468
+ const max = spec.max ?? Math.max(...spec.panels.flatMap((p) => p.groups.flatMap((g) => g.values)));
469
+ for (const { p, chart } of charts) {
470
+ const accent = spec.accent.group && !p.groups.some((g) => g.label === spec.accent.group) ? { ...spec.accent, series: '\u0000' } : spec.accent;
471
+ drawGroups(chart, { series: spec.series, groups: p.groups, accent }, { unit: spec.unit, max, quietGroups: true });
472
+ }
473
+ } else {
474
+ const ys = spec.panels.flatMap((p) => p.series.flatMap((l) => l.points.flatMap((q) => (q.low !== undefined ? [q.y, q.low, q.high] : [q.y]))));
475
+ const zero = spec.zero ?? true;
476
+ const domain = [zero ? Math.min(0, ...ys) : Math.min(...ys), Math.max(...ys) * 1.04];
477
+ for (const { p, chart } of charts) drawLines(chart, { series: p.series, accent: spec.accent, zero }, { unit: spec.unit, domain, xTicks: spec.xTicks });
478
+ }
479
+ }
480
+
481
+ /**
482
+ * A heat map into a .chart: the colour runs from pale to the accent, one hue, so darker is always more. The scale is
483
+ * drawn above the grid at readable size. A marked cell is outlined and named in the scale line.
484
+ */
485
+ export function drawHeatmap(host, spec) {
486
+ const t = themeOf(host);
487
+ const width = Math.floor(host.clientWidth) || 640, phone = phoneOf(host) || width < 420;
488
+ const cells = spec.rows.flatMap((r, i) => spec.columns.map((c, j) => ({ r: String(r), c: String(c), v: spec.values[i][j] })));
489
+ const vs = cells.map((d) => d.v), lo = Math.min(...vs), hi = Math.max(...vs);
490
+ // One hue, near white to dark: darker is always more, and busy does not read as alarm the way red does.
491
+ const scale = Plot.scale({ color: { type: 'linear', scheme: 'blues', domain: [lo - (hi - lo) * 0.08, hi] } });
492
+ const colour = (v) => scale.apply(v);
493
+ const ramp = [0, 0.25, 0.5, 0.75, 1].map((p) => colour(lo + (hi - lo) * p)).join(', ');
494
+ const fmt = (v) => num(v) + (spec.unit ?? '');
495
+ const cols = spec.columns.map(String), rows = spec.rows.map(String);
496
+ const labelW = Math.ceil(widest(host, rows, t.small)) + 12;
497
+ const cellW = (width - labelW - 8) / cols.length;
498
+ const show = cellW >= 48 && cells.length <= 72;
499
+ // Label every k-th column, k a divisor of the count where one exists, so 24 hours read 12am, 6am, 12pm, 6pm.
500
+ const most = phone ? 5 : 9, steps = [1, 2, 3, 4, 5, 6, 8, 10, 12].filter((k) => cols.length / k <= most);
501
+ const every = steps.find((k) => cols.length % k === 0) ?? steps[0] ?? Math.ceil(cols.length / most);
502
+ const rowH = phone ? 30 : 36;
503
+ const key = document.createElement('p');
504
+ key.className = 'hm-key';
505
+ key.innerHTML = `<span>${esc(fmt(lo))}</span><i style="background:linear-gradient(90deg, ${ramp})"></i><span>${esc(fmt(hi))}</span>`
506
+ + (spec.mark ? `<span style="margin-left:14px;white-space:nowrap;display:inline-flex;align-items:center;gap:8px;color:${t.ink};font-weight:600"><i style="width:16px;height:16px;border:2.5px solid ${t.accent};border-radius:2px;background:none"></i>${esc(spec.mark.label)}</span>` : '');
507
+ plotInto(host, t, {
508
+ width, height: rows.length * rowH + 36, marginLeft: labelW, marginRight: 8, marginTop: 0, marginBottom: 36,
509
+ x: { domain: cols, label: null, tickSize: 0, tickFormat: (d, i) => (i % every === 0 ? d : '') , padding: 0.06 },
510
+ y: { domain: rows, label: null, tickSize: 0, padding: 0.08 },
511
+ marks: [
512
+ Plot.cell(cells, { x: 'c', y: 'r', fill: (d) => colour(d.v), inset: 0.5 }),
513
+ show ? Plot.text(cells, { x: 'c', y: 'r', text: (d) => fmt(d.v), fill: (d) => ((d.v - lo) / (hi - lo || 1) > 0.55 ? '#ffffff' : t.ink), fontSize: t.small }) : null,
514
+ // The marked cell: an accent ring, the one thing in the chart that is not the scale.
515
+ spec.mark ? Plot.cell([{ r: String(spec.mark.row), c: String(spec.mark.column) }], { x: 'c', y: 'r', fill: 'none', stroke: t.accent, strokeWidth: 2.5 }) : null,
516
+ ],
517
+ });
518
+ key.style.marginLeft = labelW + 'px';
519
+ host.prepend(key);
520
+ }
521
+
522
+ // ---------- every value as a dot, two measures against each other, small counts as units ----------
523
+
524
+ /**
525
+ * Offsets that stack dots of radius r along a row so none overlaps another, nearest the middle first (a beeswarm).
526
+ * xs are pixel positions; returns a pixel offset from the row's centre line for each.
527
+ */
528
+ export function swarm(xs, r) {
529
+ const d = 2 * r + 1, order = xs.map((x, i) => i).sort((a, b) => xs[a] - xs[b]);
530
+ const out = new Array(xs.length), placed = [];
531
+ for (const i of order) {
532
+ const x = xs[i], near = placed.filter((p) => Math.abs(p.x - x) < d);
533
+ const free = (y) => near.every((p) => (p.x - x) ** 2 + (p.y - y) ** 2 >= d * d - 1e-6);
534
+ const tries = [0, ...near.flatMap((p) => { const h = Math.sqrt(Math.max(0, d * d - (p.x - x) ** 2)); return [p.y + h, p.y - h]; })]
535
+ .sort((a, b) => Math.abs(a) - Math.abs(b) || b - a);
536
+ const y = tries.find(free) ?? 0;
537
+ out[i] = y;
538
+ placed.push({ x, y });
539
+ }
540
+ return out;
541
+ }
542
+
543
+ const middle = (vs) => { const s = [...vs].sort((a, b) => a - b), m = s.length >> 1; return s.length % 2 ? s[m] : (s[m - 1] + s[m]) / 2; };
544
+
545
+ /**
546
+ * Every value as a dot into a .chart: one row per group on a shared axis, dots stacked where they would overlap.
547
+ * The group's name and count sit to the left (above, on a phone); a median or mean is a line across the row.
548
+ */
549
+ export function drawDots(host, spec) {
550
+ const t = themeOf(host);
551
+ const width = Math.floor(host.clientWidth) || 640, phone = phoneOf(host) || width < 460;
552
+ const fmt = (v) => num(v) + (spec.unit ?? '');
553
+ const all = spec.groups.flatMap((g) => g.values);
554
+ const lo = spec.zero ? 0 : Math.min(...all), hi = spec.max ?? Math.max(...all);
555
+ const counts = spec.groups.map((g) => (spec.each ? `${g.values.length.toLocaleString('en-GB')} ${spec.each}` : `n = ${g.values.length.toLocaleString('en-GB')}`));
556
+ const many = spec.groups.length > 1;
557
+ const labelW = phone || !many ? 0 : Math.ceil(Math.max(widest(host, spec.groups.map((g) => g.label), t.body, 600), widest(host, counts, t.small))) + 24;
558
+ // Room for half the first tick label, so "0 min" does not stick out past the text edge.
559
+ const ml = Math.max(labelW + 10, Math.ceil(widest(host, [fmt(lo)], t.small) / 2) + 2), mr = 18;
560
+ const x = Plot.scale({ x: { domain: [lo, hi === lo ? lo + 1 : hi], range: [ml, width - mr], nice: true } });
561
+ // As many ticks as fit with a clear gap between their labels.
562
+ const nt = Math.max(2, Math.min(6, Math.floor((width - ml - mr) / (widest(host, x.domain.map(fmt), t.small) + 32))));
563
+ // The largest dots that keep the tallest stack within reach of the row.
564
+ const most = phone ? 56 : 64;
565
+ let r = phone ? 6 : 7.5, offs;
566
+ for (;;) {
567
+ offs = spec.groups.map((g) => swarm(g.values.map((v) => x.apply(v)), r));
568
+ const tallest = Math.max(...offs.flat().map(Math.abs));
569
+ if (tallest <= most || r <= 2.5) break;
570
+ r -= 0.5;
571
+ }
572
+ const sumName = spec.summary, dots = [], rows = [];
573
+ // A single group needs no name in the chart: the title and note say what it is.
574
+ const head = (phone && many ? Math.ceil(t.small * 2.7) : 0) + (sumName ? Math.ceil(t.small * 1.6) : 0);
575
+ let y = 6;
576
+ spec.groups.forEach((g, gi) => {
577
+ const half = Math.max(...offs[gi].map(Math.abs)) + r;
578
+ const top = y + head, mid = top + half;
579
+ const s = sumName === 'mean' ? g.values.reduce((a, v) => a + v, 0) / g.values.length : sumName ? middle(g.values) : null;
580
+ const colour = g.tone ? t[g.tone] : t.fill;
581
+ g.values.forEach((v, i) => dots.push({ v, y: mid + offs[gi][i], colour, g: g.label }));
582
+ rows.push({ g, top, mid, bottom: mid + half, s, label: g.label, count: counts[gi], colour, tone: g.tone });
583
+ y = mid + half + (phone ? 22 : 26);
584
+ });
585
+ const height = y - (phone ? 22 : 26) + 8 + 40;
586
+ const mark = spec.mark && dots.find((d) => d.g === spec.mark.group && d.v === spec.mark.value);
587
+ // The mark's label sits above its dot, reaching away from the nearer edge, so it never covers the dots beside it.
588
+ const markEnd = mark && x.apply(mark.v) + widest(host, [spec.mark.label], t.small, 600) > width - mr;
589
+ plotInto(host, t, {
590
+ width, height, marginLeft: ml, marginRight: mr, marginTop: 0, marginBottom: 40,
591
+ x: { domain: x.domain, nice: false, label: null, ticks: nt, tickFormat: fmt },
592
+ y: { type: 'identity', axis: null },
593
+ marks: [
594
+ Plot.gridX({ ticks: nt, stroke: t.hair, strokeOpacity: 1, y1: 0, y2: height - 40 }),
595
+ // The summary line runs behind the dots and out past both ends of the row, so it shows without cutting a dot.
596
+ sumName ? Plot.ruleX(rows, { x: 's', y1: (d) => d.top - 6, y2: (d) => d.bottom + 6, stroke: t.ink, strokeWidth: 2.5 }) : null,
597
+ Plot.dot(dots, { x: 'v', y: 'y', r, fill: 'colour', stroke: t.paper, strokeWidth: 1 }),
598
+ sumName ? Plot.text(rows, { x: 's', y: (d) => d.top - 10, text: (d) => `${sumName} ${fmt(d.s)}`, lineAnchor: 'bottom', fill: t.muted, fontSize: t.small, stroke: t.paper, strokeWidth: 6, paintOrder: 'stroke' }) : null,
599
+ many && !phone ? Plot.text(rows, { frameAnchor: 'left', y: (d) => d.mid - t.small * 0.6, text: 'label', textAnchor: 'start', dx: -ml, fill: (d) => (d.tone ? t[d.tone] : t.ink), fontWeight: 600, fontSize: t.body }) : null,
600
+ many && !phone ? Plot.text(rows, { frameAnchor: 'left', y: (d) => d.mid + t.small * 0.7, text: 'count', textAnchor: 'start', dx: -ml, fill: t.muted, fontSize: t.small }) : null,
601
+ many && phone ? Plot.text(rows, { frameAnchor: 'left', y: (d) => d.top - head + 2, text: 'label', textAnchor: 'start', lineAnchor: 'top', dx: -ml, fill: (d) => (d.tone ? t[d.tone] : t.ink), fontWeight: 600, fontSize: t.small, stroke: t.paper, strokeWidth: 6, paintOrder: 'stroke' }) : null,
602
+ many && phone ? Plot.text(rows, { frameAnchor: 'left', y: (d) => d.top - head + 2 + t.small * 1.3, text: 'count', textAnchor: 'start', lineAnchor: 'top', dx: -ml, fill: t.muted, fontSize: t.small, stroke: t.paper, strokeWidth: 6, paintOrder: 'stroke' }) : null,
603
+ mark ? Plot.dot([mark], { x: 'v', y: 'y', r: r + 3.5, stroke: t.accent, strokeWidth: 2.5, fill: 'none' }) : null,
604
+ mark ? Plot.text([mark], { x: 'v', y: (d) => d.y - r - 9, text: () => spec.mark.label, dx: markEnd ? r + 3.5 : -(r + 3.5), lineAnchor: 'bottom', textAnchor: markEnd ? 'end' : 'start', fill: t.accent, fontWeight: 600, fontSize: t.small }) : null,
605
+ ],
606
+ });
607
+ if (!many) {
608
+ const p = document.createElement('p');
609
+ p.className = 'dots-n';
610
+ p.textContent = counts[0];
611
+ host.prepend(p);
612
+ }
613
+ }
614
+
615
+ const SECONDS = { seconds: 1, minutes: 60, hours: 3600, days: 86400 };
616
+ const SPANS = [[1, '1 s', 1], [10, '10 s', 2], [60, '1 min', 1], [600, '10 min', 2], [3600, '1 h', 1], [21600, '6 h', 2], [86400, '1 day', 1], [604800, '1 wk', 1], [2592000, '1 mo', 1], [31536000, '1 yr', 1]];
617
+ /** A duration in seconds as the reader says it: 40 s, 12 min, 5 h, 3 days. */
618
+ function said(sec) {
619
+ const at = (n, u) => `${num(n)} ${u}`;
620
+ if (sec === 0) return '0';
621
+ if (sec < 60) return at(sec, 's');
622
+ if (sec < 3600) return at(sec / 60, 'min');
623
+ if (sec < 172800) return at(sec / 3600, 'h');
624
+ return at(sec / 86400, 'days');
625
+ }
626
+
627
+ /**
628
+ * One scatter axis: its domain, ticks and labels. A log axis runs to whole powers of ten (or, for time, to the
629
+ * nearest of 1 min, 1 h, 1 day...) beyond the data, so every dot sits inside a labelled tick.
630
+ */
631
+ function scatterAxis(a, vs, host, t, room) {
632
+ const lo = Math.min(...vs), hi = Math.max(...vs);
633
+ const mult = a.time ? SECONDS[a.time] : 1;
634
+ const fmt = a.time ? (v) => said(v * mult) : (v) => (Math.abs(v) > 0 && Math.abs(v) < 1 ? String(+v.toPrecision(2)) : num(v)) + (a.unit ?? '');
635
+ if (a.log && a.time) {
636
+ const s0 = lo * mult, s1 = hi * mult;
637
+ const from = [...SPANS].reverse().find(([v]) => v <= s0) ?? SPANS[0];
638
+ const to = SPANS.find(([v]) => v >= s1) ?? SPANS[SPANS.length - 1];
639
+ let ticks = SPANS.filter(([v]) => v >= from[0] && v <= to[0]);
640
+ if (ticks.length > 7) ticks = ticks.filter((k) => k[2] === 1 || k === from || k === to);
641
+ return { type: 'log', domain: [from[0] / mult, to[0] / mult], ticks: ticks.map(([v]) => v / mult), fmt: (v) => SPANS.find(([s]) => Math.abs(s - v * mult) < 1e-6)?.[1] ?? said(v * mult) };
642
+ }
643
+ if (a.log) {
644
+ const e0 = Math.floor(Math.log10(lo)), e1 = Math.ceil(Math.log10(hi));
645
+ const ms = e1 - e0 > 3 ? [1] : [1, 3], ticks = [];
646
+ for (let e = e0; e <= e1; e++) for (const m of ms) if (m * 10 ** e <= 10 ** e1) ticks.push(m * 10 ** e);
647
+ return { type: 'log', domain: [10 ** e0, 10 ** e1], ticks, fmt };
648
+ }
649
+ const domain = a.zero ? [Math.min(0, lo), hi] : [lo, hi];
650
+ const n = Math.max(2, Math.min(6, Math.floor(room / (widest(host, domain.map(fmt), t.small) + 32))));
651
+ return { type: 'linear', domain, ticks: n, fmt, nice: true };
652
+ }
653
+
654
+ /**
655
+ * Two measures against each other into a .chart: a dot per thing, the named ones in colour and labelled beside them.
656
+ * With `medians`, the median y of each band of x is drawn across that band: the relation, without fitting a model.
657
+ */
658
+ export function drawScatter(host, spec) {
659
+ const t = themeOf(host);
660
+ const width = Math.floor(host.clientWidth) || 640, phone = phoneOf(host) || width < 460;
661
+ const pts = [...spec.points].sort((a, b) => (a.tone || a.label ? 1 : 0) - (b.tone || b.label ? 1 : 0));
662
+ const Y = scatterAxis(spec.y, pts.map((p) => p.y), host, t, 300);
663
+ const tickW = Math.ceil(widest(host, (Array.isArray(Y.ticks) ? Y.ticks : Y.domain).map(Y.fmt), t.small)) + 14;
664
+ const X = scatterAxis(spec.x, pts.map((p) => p.x), host, t, width - tickW - 16);
665
+ // A log axis is drawn as a linear axis over log10 of the values, with figkit's own ticks: Plot's log axis blanks
666
+ // every tick label that is not a power of ten, whatever the format.
667
+ const lin = (A) => (A.type === 'log' ? { ...A, type: 'linear', domain: A.domain.map(Math.log10), ticks: A.ticks.map(Math.log10), fmt: (l) => A.fmt(10 ** l), at: Math.log10 } : { ...A, at: (v) => v });
668
+ const XL = lin(X), YL = lin(Y);
669
+ const dots = pts.map((p) => ({ ...p, px: XL.at(p.x), py: YL.at(p.y) }));
670
+ const height = phone ? 340 : 420;
671
+ const r = (pts.length > 300 ? 3.5 : pts.length > 80 ? 4.5 : 6) * (phone ? 0.8 : 1);
672
+ const faint = pts.length > 150 ? 0.45 : 0.7;
673
+ // A label reads away from the nearer side of the plot: points in the right half are labelled to their left.
674
+ const f = (v) => (X.type === 'log' ? Math.log10(v) : v), [x0, x1] = X.domain.map(f);
675
+ const leftward = (p) => f(p.x) > (x0 + x1) / 2;
676
+ let bands = [];
677
+ if (spec.medians) {
678
+ const edges = X.type === 'log'
679
+ ? Array.from({ length: Math.round(x1 - x0) + 1 }, (_, i) => 10 ** (x0 + i))
680
+ : Array.from({ length: 6 }, (_, i) => X.domain[0] + ((X.domain[1] - X.domain[0]) * i) / 5);
681
+ for (let i = 0; i + 1 < edges.length; i++) {
682
+ const inside = pts.filter((p) => p.x >= edges[i] && (i + 2 === edges.length ? p.x <= edges[i + 1] : p.x < edges[i + 1]));
683
+ if (inside.length < 5) continue;
684
+ const ys = inside.map((p) => p.y).sort((u, v) => u - v), q = (k) => ys[Math.min(ys.length - 1, Math.max(0, Math.round(k * (ys.length - 1))))];
685
+ // Each band spans its own dots, so a band with few dots at its near end does not reach across empty space.
686
+ bands.push({ a: Math.min(...inside.map((p) => p.x)), b: Math.max(...inside.map((p) => p.x)), m: middle(ys), q1: q(0.25), q3: q(0.75) });
687
+ }
688
+ }
689
+ const mt = Math.ceil(t.small * 1.4) + 20, mb = Math.ceil(t.small * 2.6) + 14;
690
+ // Room for half the last x tick label, so "10,000" ends inside the card.
691
+ const last = Array.isArray(XL.ticks) ? XL.fmt(XL.ticks[XL.ticks.length - 1]) : XL.fmt(XL.domain[1]);
692
+ const mr = Math.max(16, Math.ceil(widest(host, [last], t.small) / 2) - r - 4);
693
+ plotInto(host, t, {
694
+ width, height, marginLeft: tickW, marginRight: mr, marginTop: mt, marginBottom: mb,
695
+ x: { domain: XL.domain, nice: XL.nice ?? false, inset: r + 6, label: null, ticks: XL.ticks, tickFormat: XL.fmt },
696
+ y: { domain: YL.domain, nice: YL.nice ?? false, inset: r + 6, label: null, grid: true, ticks: YL.ticks, tickSize: 0, tickFormat: YL.fmt },
697
+ marks: [
698
+ Plot.dot(dots, { x: 'px', y: 'py', r, fill: (p) => (p.tone ? t[p.tone] : t.fill), fillOpacity: (p) => (p.tone ? 1 : faint) }),
699
+ bands.length ? Plot.rect(bands, { x1: (d) => XL.at(d.a), x2: (d) => XL.at(d.b), y1: (d) => YL.at(d.q1), y2: (d) => YL.at(d.q3), fill: t.accent, fillOpacity: 0.14, insetLeft: 2, insetRight: 2 }) : null,
700
+ bands.length ? Plot.ruleY(bands, { y: (d) => YL.at(d.m), x1: (d) => XL.at(d.a), x2: (d) => XL.at(d.b), stroke: t.accent, strokeWidth: 4, insetLeft: 2, insetRight: 2 }) : null,
701
+ Plot.text(dots.filter((p) => p.label && !leftward(p)), { x: 'px', y: 'py', text: 'label', dx: r + 8, textAnchor: 'start', fill: (p) => (p.tone ? t[p.tone] : t.ink), fontWeight: 600, fontSize: t.small }),
702
+ Plot.text(dots.filter((p) => p.label && leftward(p)), { x: 'px', y: 'py', text: 'label', dx: -(r + 8), textAnchor: 'end', fill: (p) => (p.tone ? t[p.tone] : t.ink), fontWeight: 600, fontSize: t.small }),
703
+ // What each axis measures, written along it: the y label above its axis, the x label under the ticks at the right.
704
+ Plot.text([spec.y.label], { frameAnchor: 'top-left', dx: -tickW, dy: -mt + 2, text: (d) => d, textAnchor: 'start', lineAnchor: 'top', fill: t.muted, fontWeight: 600, fontSize: t.small }),
705
+ Plot.text([spec.x.label], { frameAnchor: 'bottom-right', dy: mb - 4, text: (d) => d, textAnchor: 'end', lineAnchor: 'bottom', fill: t.muted, fontWeight: 600, fontSize: t.small }),
706
+ ],
707
+ });
708
+ if (bands.length) {
709
+ const key = document.createElement('p');
710
+ key.className = 'range-key';
711
+ key.innerHTML = `<span><i class="median" style="background:${t.accent}"></i>median of each band of ${esc(spec.x.label.toLowerCase())}</span><span><i class="band" style="background:${t.accent};opacity:.3"></i>its middle half</span>`;
712
+ key.style.marginBottom = '14px';
713
+ host.prepend(key);
714
+ }
715
+ }
716
+
717
+ /**
718
+ * A small count as squares into a .chart: rows of ten, filled column by column so each part is one solid block from the
719
+ * left. No gutters: a gap between squares would read as a split the data does not have.
720
+ */
721
+ export function drawUnits(host, spec) {
722
+ const t = themeOf(host);
723
+ const width = Math.floor(host.clientWidth) || 640, phone = phoneOf(host) || width < 460;
724
+ const cols = Math.min(10, spec.total), rows = Math.ceil(spec.total / cols), gap = phone ? 5 : 7;
725
+ const size = Math.min(phone ? 34 : 48, Math.floor((width - gap * (cols - 1)) / cols));
726
+ const greys = [t.fill, t.fill2];
727
+ let g = 0;
728
+ const parts = spec.parts.map((p, i) => ({ ...p, colour: p.tone ? t[p.tone] : i === 0 ? t.accent : greys[g++ % 2] }));
729
+ host.className = 'units';
730
+ host.innerHTML = '';
731
+ const grid = document.createElement('div');
732
+ grid.className = 'un-grid';
733
+ grid.style.cssText = `grid-template-columns: repeat(${cols}, ${size}px); grid-template-rows: repeat(${rows}, ${size}px); grid-auto-flow: column; gap: ${gap}px; --un: ${size}px;`;
734
+ const cells = parts.flatMap((p) => Array(p.count).fill(p.colour));
735
+ for (let i = 0; i < spec.total; i++) {
736
+ const c = document.createElement('i');
737
+ if (cells[i]) c.style.background = cells[i];
738
+ else c.className = 'empty';
739
+ grid.appendChild(c);
740
+ }
741
+ const rest = spec.total - cells.length;
742
+ const key = document.createElement('div');
743
+ key.className = 'un-key';
744
+ key.innerHTML = parts.map((p) => `<span><i style="background:${p.colour}"></i><b style="color:${p.colour === t.accent || p.tone ? p.colour : t.ink}">${p.count}</b>${esc(p.label)}</span>`).join('')
745
+ + `<span><i class="empty"></i><b style="color:${t.muted}">${rest}</b>${esc(spec.rest)}</span>`;
746
+ host.append(grid, key);
747
+ }
@@ -0,0 +1,60 @@
1
+ /* The "sequence" spec: who calls whom, in order. Kept beside spec.js, which adds it to the union of types. */
2
+ import { z } from 'zod';
3
+
4
+ const slug = z.string({ error: 'needs an id, e.g. "bot"' }).regex(/^[a-z][a-z0-9-]{0,23}$/, 'ids are short lowercase words, e.g. "bot" or "ci-job"');
5
+ const text = (max, what, long) => z.string({ error: `needs ${what}` }).trim().min(1, `needs ${what}`).max(max, long);
6
+
7
+ /**
8
+ * Who calls whom, in order: participants side by side, each message a numbered step down the page from one to another.
9
+ * A reply answers an earlier call; a message to its own sender is work it does itself.
10
+ */
11
+ export const sequenceShape = {
12
+ participants: z.array(z.object({
13
+ id: slug,
14
+ name: text(28, 'a name', 'keep names to 28 characters'),
15
+ kind: z.string().trim().min(1).max(28, 'keep the kind to 28 characters, e.g. "GitHub Actions"').optional(),
16
+ tone: z.literal('accent', { error: 'tone: only "accent", on the one participant to notice' }).optional(),
17
+ }).strict()).min(2, 'at least 2 participants').max(5, 'at most 5 participants; more is two figures (the whole run with the parts merged, then one stage of it)'),
18
+ messages: z.array(z.object({
19
+ from: slug,
20
+ to: slug,
21
+ label: text(40, 'a label', 'keep labels to 40 characters: what is asked for or sent, not how'),
22
+ reply: z.boolean().optional(),
23
+ note: z.string().trim().min(1).max(40, 'keep the note to 40 characters').optional(),
24
+ tone: z.literal('accent', { error: 'tone: only "accent", on the steps to notice' }).optional(),
25
+ }).strict()).min(2, 'at least 2 messages; one call is a sentence').max(12, 'at most 12 messages; more is two figures (the run end to end, then the stage that needs detail)'),
26
+ repeats: z.array(z.object({
27
+ from: z.number().int().min(1),
28
+ to: z.number().int().min(1),
29
+ label: text(32, 'a label, e.g. "For each model"', 'keep the label to 32 characters'),
30
+ }).strict()).max(2, 'at most 2 repeated runs of steps').optional(),
31
+ };
32
+
33
+ /** Rules that span fields: ids that exist, replies that answer a call, repeats over real steps. */
34
+ export function sequenceChecks(s) {
35
+ const out = [];
36
+ const ids = s.participants.map((p) => p.id);
37
+ const dup = ids.find((id, i) => ids.indexOf(id) !== i);
38
+ if (dup) return [`participants: two participants have the id "${dup}"`];
39
+ s.messages.forEach((m, i) => {
40
+ for (const end of ['from', 'to']) if (!ids.includes(m[end])) out.push(`messages[${i}].${end}: no participant has the id "${m[end]}"; the ids are ${ids.map((x) => `"${x}"`).join(', ')}`);
41
+ if (m.reply && m.from === m.to) out.push(`messages[${i}]: a reply to itself; make it a plain step, or leave it out`);
42
+ else if (m.reply && !s.messages.slice(0, i).some((c) => !c.reply && c.from === m.to && c.to === m.from)) {
43
+ out.push(`messages[${i}]: a reply from "${m.from}" to "${m.to}", but "${m.to}" never called "${m.from}" before it; a reply answers an earlier call`);
44
+ }
45
+ });
46
+ const unused = ids.filter((id) => !s.messages.some((m) => m.from === id || m.to === id));
47
+ if (unused.length) out.push(`participants: ${unused.map((x) => `"${x}"`).join(', ')} ${unused.length === 1 ? 'sends and receives' : 'send and receive'} nothing; leave ${unused.length === 1 ? 'it' : 'them'} out or add the message`);
48
+ const accentP = s.participants.filter((p) => p.tone).length, accentM = s.messages.filter((m) => m.tone).length;
49
+ if (accentP > 1) out.push('participants: accent at most one participant, the one to notice');
50
+ if (accentP && accentM) out.push('tone: accent one participant or some steps, not both');
51
+ if (accentM && accentM === s.messages.length) out.push('messages: every step is accented, so none stands out; accent only the steps the title is about');
52
+ const n = s.messages.length;
53
+ const reps = [...(s.repeats ?? [])].sort((a, b) => a.from - b.from);
54
+ reps.forEach((r, i) => {
55
+ if (r.to > n || r.from > n) out.push(`repeats[${i}]: steps run 1 to ${n}`);
56
+ else if (r.from > r.to) out.push(`repeats[${i}]: "from" is the first repeated step and "to" the last; ${r.from} is after ${r.to}`);
57
+ if (i > 0 && r.from <= reps[i - 1].to) out.push('repeats: two repeated runs share steps; make them one, or separate them');
58
+ });
59
+ return out;
60
+ }