@liggi/figkit 0.3.0 → 0.5.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,7 @@
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';
4
+ import { drawStates } from './states.js';
3
5
  const NS = 'http://www.w3.org/2000/svg';
4
6
  const esc = (s) => String(s).replace(/[&<>"]/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' })[c]);
5
7
 
@@ -79,21 +81,63 @@ export function drawSplit(host, parts, unit) {
79
81
  }
80
82
  }
81
83
 
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));
84
+ /**
85
+ * A few numbers compared into a <fig-bars>, on an axis from zero to max (default: the largest value or high). A bar
86
+ * with `low` and `high` gets a range drawn across its end, and its number moves past the range; `n` goes under its label.
87
+ */
88
+ export function drawBars(host, bars, unit, max, each, ref) {
89
+ const top = max ?? Math.max(...bars.map((b) => Math.max(b.value, b.high ?? 0)), ref?.value ?? 0);
85
90
  host.innerHTML = '';
91
+ host.classList.toggle('has-ref', !!ref);
92
+ // A reference is its own first row, in the second colour; a bar above it (its whole range, when it has one) is
93
+ // accented and every other bar is grey.
94
+ if (ref) bars = [{ ...ref, text: String(ref.value), tone: 'second', isRef: true }, ...bars.map((b) => ({ ...b, tone: (b.low ?? b.value) > ref.value ? 'accent' : undefined }))];
86
95
  const fills = bars.map((b) => {
87
- const label = Object.assign(document.createElement('div'), { className: 'fb-label', innerHTML: b.label });
96
+ const label = Object.assign(document.createElement('div'), { className: 'fb-label' + (b.isRef ? ' ref' : ''), 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
97
  const track = Object.assign(document.createElement('div'), { className: 'fb-track' });
89
98
  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>`;
99
+ const ranged = b.low !== undefined;
100
+ // With a range, the bar steps back and the range, in the bar's colour, leads; its ends are printed after the value.
101
+ const ends = ranged ? `<span class="fb-sub">${esc(num(b.low))}–${esc(num(b.high))}${esc(unit ?? '')}</span>` : '';
102
+ 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
103
  host.append(label, track);
92
- return { bar: track.firstChild, ratio: top > 0 ? Math.max(0, b.value) / top : 0 };
104
+ const r = (v) => (top > 0 ? Math.max(0, v) / top : 0);
105
+ 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
106
  });
94
- // The longest bar leaves room for the widest value and note, so no label runs past the edge.
107
+ // The longest bar or range leaves room for the widest value and note, so no label runs past the edge.
95
108
  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})`;
109
+ const at = (ratio) => `calc((100% - ${room}px) * ${ratio})`;
110
+ for (const f of fills) {
111
+ f.bar.style.width = at(f.ratio);
112
+ if (!f.range) continue;
113
+ f.range.style.left = at(f.lo);
114
+ f.range.style.width = at(f.hi - f.lo);
115
+ f.val.style.marginLeft = `max(0px, calc((100% - ${room}px) * ${f.hi - f.ratio} - 3px))`;
116
+ }
117
+ if (!ref) return;
118
+ // The reference level as a dashed line from the end of its bar down through every other row; a value that would
119
+ // sit on the line is moved past it.
120
+ const line = document.createElement('div');
121
+ line.className = 'fb-refline';
122
+ host.appendChild(line);
123
+ const hostBox = host.getBoundingClientRect();
124
+ const refBar = fills[0].bar.getBoundingClientRect(), last = fills[fills.length - 1].bar.parentElement.getBoundingClientRect();
125
+ const x = refBar.right - hostBox.left;
126
+ Object.assign(line.style, { left: `${x - 1}px`, top: `${refBar.bottom - hostBox.top}px`, height: `${last.bottom - refBar.bottom}px` });
127
+ for (const f of fills.slice(1)) {
128
+ const v = f.val.getBoundingClientRect();
129
+ if (v.left - hostBox.left < x + 8 && v.right - hostBox.left > x - 8) f.val.style.marginLeft = `${parseFloat(getComputedStyle(f.val).marginLeft) + (x + 10 - (v.left - hostBox.left))}px`;
130
+ }
131
+ }
132
+
133
+ /** What the ranges are, as a key line under the reading line: the mark as drawn, then the words. */
134
+ function rangeKey(spec) {
135
+ const p = document.createElement('p');
136
+ p.className = 'range-key';
137
+ p.innerHTML = (spec.range ? `<span><i class="${spec.type === 'bars' ? 'whisker' : 'band'}"></i>${esc(spec.range)}</span>` : '')
138
+ + (spec.ref && spec.type !== 'bars' ? `<span><i class="dash"></i>${esc(spec.ref.label)}</span>` : '')
139
+ + (spec.ref && spec.type === 'bars' && spec.bars.some((b) => (b.low ?? b.value) > spec.ref.value) ? `<span><i class="swatch"></i>${spec.range ? 'whole range ' : ''}above ${esc(spec.ref.label)}</span>` : '');
140
+ return p;
97
141
  }
98
142
 
99
143
  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>';
@@ -173,12 +217,13 @@ export function drawDumbbell(host, spec) {
173
217
  export function drawSeries(host, spec) {
174
218
  const t = themeOf(host);
175
219
  const width = Math.floor(host.clientWidth) || 640;
176
- const pts = spec.points.map((p) => ({ x: toX(p.x), y: p.y }));
220
+ const pts = spec.points.map((p) => ({ x: toX(p.x), y: p.y, low: p.low, high: p.high }));
221
+ const band = pts[0].low !== undefined;
177
222
  const i = pts.findIndex((p) => +p.x === +toX(spec.mark.x));
178
223
  const mark = pts[i];
179
224
  const near = pts.slice(Math.max(0, i - 3), i + 4).map((p) => p.y);
180
225
  const high = mark.y >= near.reduce((s, y) => s + y, 0) / near.length;
181
- const ys = pts.map((p) => p.y), zero = spec.zero ?? true;
226
+ const ys = [...pts.flatMap((p) => (band ? [p.y, p.low, p.high] : [p.y])), ...(spec.ref ? [spec.ref.y] : [])], zero = spec.zero ?? true;
182
227
  const top = Math.max(...ys), bottom = zero ? Math.min(0, ...ys) : Math.min(...ys);
183
228
  const fmt = (d) => num(d) + (spec.unit ?? '');
184
229
  const tickW = widest(host, [fmt(top), fmt(bottom)], t.small);
@@ -188,16 +233,23 @@ export function drawSeries(host, spec) {
188
233
  const right = at + 14 + widest(host, [spec.mark.label], t.body, 600) <= width - 20;
189
234
  // Above a high point; level with a low one, lifted clear of the axis when it sits near the bottom.
190
235
  const lift = zero ? ((mark.y - bottom) / (top * 1.04 - bottom)) * (340 - 30 - 40) : Infinity;
191
- const dy = high ? -12 : Math.min(0, lift - 18);
236
+ let dy = high ? -12 : Math.min(0, lift - 18);
237
+ // A label to the left of its point sits on the side away from the line coming in: above it when the previous
238
+ // point is lower, below when higher.
239
+ if (!right && i > 0) dy = pts[i - 1].y <= mark.y ? -18 : 20;
240
+ // Above or below, the label ends just past the point, so it does not reach back over the line.
241
+ const dx = right ? 14 : i > 0 ? 8 : -14;
192
242
  plotInto(host, t, {
193
243
  width, height: 340, marginLeft: left0, marginRight: 20, marginTop: 30, marginBottom: 40,
194
244
  x: { label: null, ticks: width < 600 ? 4 : 5, insetLeft: 18 },
195
245
  y: { label: null, grid: true, ticks: 4, tickSize: 0, domain: zero ? [bottom, top * 1.04] : undefined, tickFormat: fmt },
196
246
  marks: [
197
247
  zero ? Plot.ruleY([0], { stroke: t.line }) : null,
198
- Plot.lineY(pts, { x: 'x', y: 'y', stroke: t.fill, strokeWidth: 2.5 }),
248
+ band ? Plot.areaY(pts, { x: 'x', y1: 'low', y2: 'high', fill: t.fill, fillOpacity: 0.3 }) : null,
249
+ spec.ref ? Plot.ruleY([spec.ref.y], { stroke: t.soft, strokeWidth: 1.5, strokeDasharray: '5,4' }) : null,
250
+ Plot.lineY(pts, { x: 'x', y: 'y', stroke: band ? t.muted : t.fill, strokeWidth: 2.5 }),
199
251
  Plot.dot([mark], { x: 'x', y: 'y', r: 6.5, fill: t.accent }),
200
- 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 }),
252
+ 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 }),
201
253
  ],
202
254
  });
203
255
  }
@@ -284,18 +336,39 @@ function drawEntities(spec, main) {
284
336
  }
285
337
  }
286
338
 
287
- const HOSTS = { split: 'fig-split', bars: 'fig-bars', 'before-after': 'fig-change', dumbbell: 'div', series: 'div', groups: 'div', lines: 'div', panels: 'div', heatmap: 'div' };
339
+ const HOSTS = { split: 'fig-split', bars: 'fig-bars', estimates: 'div', 'before-after': 'fig-change', dumbbell: 'div', series: 'div', groups: 'div', lines: 'div', panels: 'div', heatmap: 'div', dots: 'div', scatter: 'div', units: 'div' };
288
340
 
289
341
  /** A checked spec drawn into a figure: header, chart and footer, as figkit's own markup. */
290
342
  export function drawSpec(spec, main) {
291
343
  const header = document.createElement('header');
292
344
  header.innerHTML = `<h1>${esc(spec.title)}</h1>${spec.note ? `<p class="read">${esc(spec.note)}</p>` : ''}`;
345
+ // What the ranges and a reference line are, said once under the reading line: there they never sit on the data.
346
+ if (spec.type === 'estimates') header.appendChild(estimatesKey(spec));
347
+ else if (spec.range || spec.ref) header.appendChild(rangeKey(spec));
293
348
  if (spec.type === 'entities') {
294
349
  main.append(header);
295
350
  drawEntities(spec, main);
296
351
  if (spec.source) { const footer = document.createElement('footer'); footer.innerHTML = `<p>${esc(spec.source)}</p>`; main.append(footer); }
297
352
  return { chart: main.querySelector('.entities'), draw: () => {} };
298
353
  }
354
+ if (spec.type === 'states') {
355
+ if (spec.moves.some((m) => m.count != null)) {
356
+ const key = document.createElement('p');
357
+ key.className = 'read st-key';
358
+ key.textContent = `Counts are ${spec.each} who made each move at least once${spec.shares ? '; % of those who reached the state it leaves' : ''}.`;
359
+ header.appendChild(key);
360
+ }
361
+ main.append(header);
362
+ const chart = drawStates(spec, main);
363
+ if (spec.source) { const footer = document.createElement('footer'); footer.innerHTML = `<p>${esc(spec.source)}</p>`; main.append(footer); }
364
+ return { chart, draw: () => {} };
365
+ }
366
+ if (spec.type === 'sequence') {
367
+ main.append(header);
368
+ const chart = drawSequence(spec, main);
369
+ if (spec.source) { const footer = document.createElement('footer'); footer.innerHTML = `<p>${esc(spec.source)}</p>`; main.append(footer); }
370
+ return { chart, draw: () => {} };
371
+ }
299
372
  const chart = document.createElement(HOSTS[spec.type]);
300
373
  main.append(header, chart);
301
374
  if (spec.source) {
@@ -306,14 +379,18 @@ export function drawSpec(spec, main) {
306
379
  const draw = () => {
307
380
  if (spec.type === 'before-after') return drawChange(chart, spec);
308
381
  if (spec.type === 'dumbbell') return drawDumbbell(chart, spec);
382
+ if (spec.type === 'estimates') return drawEstimates(chart, spec);
309
383
  if (spec.type === 'series') return drawSeries(chart, spec);
310
384
  if (spec.type === 'groups') return drawGroups(chart, spec, { unit: spec.unit, max: spec.max });
311
385
  if (spec.type === 'lines') return drawLines(chart, spec, { unit: spec.unit, xTicks: spec.xTicks });
312
386
  if (spec.type === 'panels') return drawPanels(chart, spec);
313
387
  if (spec.type === 'heatmap') return drawHeatmap(chart, spec);
388
+ if (spec.type === 'dots') return drawDots(chart, spec);
389
+ if (spec.type === 'scatter') return drawScatter(chart, spec);
390
+ if (spec.type === 'units') return drawUnits(chart, spec);
314
391
  const items = (spec.parts ?? spec.bars).map((x) => ({ ...x, text: String(x.value), label: esc(x.label) }));
315
392
  if (spec.type === 'split') drawSplit(chart, items, spec.unit);
316
- else drawBars(chart, items, spec.unit, spec.max);
393
+ else drawBars(chart, items, spec.unit, spec.max, spec.each, spec.ref && { ...spec.ref, label: esc(spec.ref.label) });
317
394
  };
318
395
  draw();
319
396
  return { chart, draw };
@@ -372,12 +449,12 @@ export function drawLines(host, data, opts = {}) {
372
449
  const t = themeOf(host);
373
450
  const width = Math.floor(host.clientWidth) || 640, phone = phoneOf(host) || width < 420;
374
451
  const greys = [t.fill2, t.fill];
375
- const lines = data.series.map((l) => ({ ...l, pts: l.points.map((p) => ({ x: toX(p.x), y: p.y, s: l.label })) }));
452
+ 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 })) }));
376
453
  const others = lines.filter((l) => l.label !== data.accent);
377
454
  others.forEach((l, i) => { l.color = greys[i % 2]; l.w = 2.25; });
378
455
  const hi = lines.find((l) => l.label === data.accent);
379
456
  hi.color = t.accent; hi.w = 3.25;
380
- const ys = lines.flatMap((l) => l.pts.map((p) => p.y));
457
+ const ys = lines.flatMap((l) => l.pts.flatMap((p) => (l.band ? [p.y, p.low, p.high] : [p.y])));
381
458
  const zero = data.zero ?? true;
382
459
  const domain = opts.domain ?? [zero ? Math.min(0, ...ys) : Math.min(...ys), Math.max(...ys) * 1.04];
383
460
  const fmt = (d) => num(d) + (opts.unit ?? '');
@@ -399,6 +476,7 @@ export function drawLines(host, data, opts = {}) {
399
476
  y: { domain, nice: false, label: null, grid: true, ticks: 4, tickSize: 0, tickFormat: fmt },
400
477
  marks: [
401
478
  zero ? Plot.ruleY([0], { stroke: t.line }) : null,
479
+ ...[...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 })),
402
480
  ...[...others, hi].map((l) => Plot.lineY(l.pts, { x: 'x', y: 'y', stroke: l.color, strokeWidth: l.w })),
403
481
  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 }),
404
482
  markPt ? Plot.dot([markPt], { x: 'x', y: 'y', r: 6, fill: t.accent }) : null,
@@ -427,7 +505,7 @@ export function drawPanels(host, spec) {
427
505
  drawGroups(chart, { series: spec.series, groups: p.groups, accent }, { unit: spec.unit, max, quietGroups: true });
428
506
  }
429
507
  } else {
430
- const ys = spec.panels.flatMap((p) => p.series.flatMap((l) => l.points.map((q) => q.y)));
508
+ 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]))));
431
509
  const zero = spec.zero ?? true;
432
510
  const domain = [zero ? Math.min(0, ...ys) : Math.min(...ys), Math.max(...ys) * 1.04];
433
511
  for (const { p, chart } of charts) drawLines(chart, { series: p.series, accent: spec.accent, zero }, { unit: spec.unit, domain, xTicks: spec.xTicks });
@@ -440,14 +518,26 @@ export function drawPanels(host, spec) {
440
518
  */
441
519
  export function drawHeatmap(host, spec) {
442
520
  const t = themeOf(host);
521
+ host.classList.add('heatmap');
443
522
  const width = Math.floor(host.clientWidth) || 640, phone = phoneOf(host) || width < 420;
444
523
  const cells = spec.rows.flatMap((r, i) => spec.columns.map((c, j) => ({ r: String(r), c: String(c), v: spec.values[i][j] })));
445
524
  const vs = cells.map((d) => d.v), lo = Math.min(...vs), hi = Math.max(...vs);
446
- // One hue, near white to dark: darker is always more, and busy does not read as alarm the way red does.
447
- const scale = Plot.scale({ color: { type: 'linear', scheme: 'blues', domain: [lo - (hi - lo) * 0.08, hi] } });
448
- const colour = (v) => scale.apply(v);
449
- const ramp = [0, 0.25, 0.5, 0.75, 1].map((p) => colour(lo + (hi - lo) * p)).join(', ');
450
- const fmt = (v) => num(v) + (spec.unit ?? '');
525
+ // Any value below zero makes the grid signed: a diverging scale around zero, orange below and blue above, equal
526
+ // distances from zero equally strong. Otherwise one hue, near white to dark: darker is always more.
527
+ const diverging = lo < 0;
528
+ const places = placesOf(vs);
529
+ const fmt = (v) => (diverging ? signed(v, places) : Number(v.toFixed(places)).toLocaleString('en-GB', { minimumFractionDigits: places })) + (spec.unit ?? '');
530
+ const scale = diverging ? null : Plot.scale({ color: { type: 'linear', scheme: 'blues', domain: [lo - (hi - lo) * 0.08, hi] } });
531
+ const MID = '#f2f2f0', NEG = '#a63603', POS = '#08519c', far = Math.max(-lo, hi) || 1;
532
+ const colour = (v) => (!diverging ? scale.apply(v) : v < 0 ? mix(MID, NEG, -v / far) : mix(MID, POS, v / far));
533
+ // Cell text in dark or white, whichever reads against the cell, whatever the theme's ink.
534
+ const lum = (c) => { const m = /rgb\((\d+), (\d+), (\d+)\)/.exec(c); const x = m ? m.slice(1).map(Number) : hex(c) ?? [0, 0, 0]; const [r, g, b] = x.map((v) => { v /= 255; return v <= 0.03928 ? v / 12.92 : ((v + 0.055) / 1.055) ** 2.4; }); return 0.2126 * r + 0.7152 * g + 0.0722 * b; };
535
+ const textOn = (v) => { const L = lum(colour(v)); return (L + 0.05) / (lum('#1d2130') + 0.05) >= 1.05 / (L + 0.05) ? '#1d2130' : '#ffffff'; };
536
+ const K = phone ? 120 : 200, z = Math.round((100 * -lo) / (hi - lo || 1));
537
+ // Signed: one bar through zero, with 0 marked where it falls on the scale.
538
+ const ramp = diverging
539
+ ? `<span>${esc(fmt(lo))}</span><i class="hm-signed" style="width:${K}px;background:linear-gradient(90deg, ${colour(lo)}, ${MID} ${z}%, ${colour(Math.max(hi, 0))})"><b style="left:${z}%">0</b></i><span>${esc(fmt(hi))}</span>`
540
+ : `<span>${esc(fmt(lo))}</span><i style="background:linear-gradient(90deg, ${[0, 0.25, 0.5, 0.75, 1].map((p) => colour(lo + (hi - lo) * p)).join(', ')})"></i><span>${esc(fmt(hi))}</span>`;
451
541
  const cols = spec.columns.map(String), rows = spec.rows.map(String);
452
542
  const labelW = Math.ceil(widest(host, rows, t.small)) + 12;
453
543
  const cellW = (width - labelW - 8) / cols.length;
@@ -458,7 +548,7 @@ export function drawHeatmap(host, spec) {
458
548
  const rowH = phone ? 30 : 36;
459
549
  const key = document.createElement('p');
460
550
  key.className = 'hm-key';
461
- key.innerHTML = `<span>${esc(fmt(lo))}</span><i style="background:linear-gradient(90deg, ${ramp})"></i><span>${esc(fmt(hi))}</span>`
551
+ key.innerHTML = ramp
462
552
  + (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>` : '');
463
553
  plotInto(host, t, {
464
554
  width, height: rows.length * rowH + 36, marginLeft: labelW, marginRight: 8, marginTop: 0, marginBottom: 36,
@@ -466,7 +556,7 @@ export function drawHeatmap(host, spec) {
466
556
  y: { domain: rows, label: null, tickSize: 0, padding: 0.08 },
467
557
  marks: [
468
558
  Plot.cell(cells, { x: 'c', y: 'r', fill: (d) => colour(d.v), inset: 0.5 }),
469
- 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,
559
+ show ? Plot.text(cells, { x: 'c', y: 'r', text: (d) => fmt(d.v), fill: (d) => textOn(d.v), fontSize: t.small }) : null,
470
560
  // The marked cell: an accent ring, the one thing in the chart that is not the scale.
471
561
  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,
472
562
  ],
@@ -474,3 +564,325 @@ export function drawHeatmap(host, spec) {
474
564
  key.style.marginLeft = labelW + 'px';
475
565
  host.prepend(key);
476
566
  }
567
+
568
+ // ---------- signed estimates with ranges around zero (a forest plot) ----------
569
+
570
+ /** Decimal places the author wrote, at most 3, so -0.18 is not printed as -0.2. */
571
+ const placesOf = (vs) => Math.min(3, Math.max(0, ...vs.map((v) => { const m = /\.(\d+)/.exec(String(v)); return m ? m[1].length : 0; })));
572
+ /** A signed number with a true minus sign: +7.4, −0.7, 0. */
573
+ const signed = (v, places) => { const r = Math.abs(v).toFixed(places); return (+r === 0 ? '' : v > 0 ? '+' : '−') + r; };
574
+
575
+ /**
576
+ * One row per estimate into a .chart: a dot at the value, a line across its range and a zero line through all of
577
+ * them. A row whose range clears zero is coloured; one whose range includes zero is grey with a hollow dot. Each row's
578
+ * value and range are printed in a column at the right; on a phone they go on a line above the row with its label.
579
+ */
580
+ export function drawEstimates(host, spec) {
581
+ const t = themeOf(host);
582
+ host.classList.add('estimates');
583
+ const width = Math.floor(host.clientWidth) || 640;
584
+ const places = placesOf(spec.rows.flatMap((r) => [r.value, r.low, r.high]));
585
+ const u = spec.unit ?? '';
586
+ const rows = spec.rows.map((r, i) => {
587
+ const clear = r.low > 0 || r.high < 0;
588
+ const good = spec.better ? (r.value > 0) === (spec.better === 'higher') : null;
589
+ const color = !clear ? t.soft : good === null ? t.accent : good ? t.good : t.bad;
590
+ const n = r.n === undefined ? '' : spec.each ? `${r.n.toLocaleString('en-GB')} ${spec.each}` : `n = ${r.n.toLocaleString('en-GB')}`;
591
+ return { ...r, i, clear, color, n, val: signed(r.value, places) + u, span: `${signed(r.low, places)} to ${signed(r.high, places)}` };
592
+ });
593
+ // Zero always shows, with room on its empty side, so the line it is measured from is never the frame's edge.
594
+ const lo0 = Math.min(0, ...rows.map((r) => r.low)), hi0 = Math.max(0, ...rows.map((r) => r.high));
595
+ const room = (hi0 - lo0 || 1) * 0.1;
596
+ let lo = Math.min(lo0 - room * 0.5, -room), hi = Math.max(hi0 + room * 0.5, room);
597
+ const spanW = Math.ceil(widest(host, rows.map((r) => r.span), t.small));
598
+ const labelW = Math.ceil(widest(host, rows.map((r) => r.label), t.body));
599
+ const side = 12, plotW = width - 2 * side, ticks = Math.max(3, Math.min(6, Math.floor(plotW / 120)));
600
+ // Room each side of zero for the words saying what that side means, so they meet at the zero line.
601
+ if (spec.sides) {
602
+ const need = (txt) => Math.min(plotW * 0.35, widest(host, [txt], t.small) + 24);
603
+ const nb = need('← ' + spec.sides.below), na = need(spec.sides.above + ' →');
604
+ if (-lo / (hi - lo) * plotW < nb) lo = -(nb * hi) / (plotW - nb);
605
+ if (hi / (hi - lo) * plotW < na) hi = (na * -lo) / (plotW - na);
606
+ }
607
+ // Each row is a line of text (its label at the left, its value and range at the right) over its range. y runs in
608
+ // row units from the top: the text sits at a third of the row, the range at 0.72 of it.
609
+ const rowH = 84, T = 0.32, M = 0.72;
610
+ const halo = { stroke: t.paper, strokeWidth: 8, paintOrder: 'stroke' };
611
+ const tick = (d) => signed(d, Math.max(0, places - 1)) + (u.length <= 2 ? u : '');
612
+ const raw = (hi - lo) / ticks, mag = 10 ** Math.floor(Math.log10(raw)), step = [1, 2, 2.5, 5, 10].map((k) => k * mag).find((k) => (hi - lo) / k <= ticks + 1);
613
+ const tickVals = []; for (let v = Math.ceil(lo / step) * step; v <= hi + 1e-9; v += step) tickVals.push(+v.toFixed(10));
614
+ // The grid and the zero line run from just under each row's text to the next row, never through text.
615
+ const spans = rows.map((r) => ({ y1: r.i + 0.5, y2: r.i + 1 }));
616
+ const nW = rows[0].n ? Math.ceil(widest(host, rows.map((r) => r.n), t.small)) : 0;
617
+ plotInto(host, t, {
618
+ width, height: rows.length * rowH + 40, marginLeft: side, marginRight: side, marginTop: 0, marginBottom: 40,
619
+ x: { domain: [lo, hi], ticks: tickVals, label: null, tickFormat: tick, tickSize: 0 },
620
+ y: { domain: [0, rows.length], range: [0, rows.length * rowH], axis: null },
621
+ marks: [
622
+ ...tickVals.filter((v) => v !== 0).map((v) => Plot.ruleX(spans, { x: () => v, y1: 'y1', y2: 'y2', stroke: t.hair })),
623
+ Plot.ruleX(spans, { x: () => 0, y1: 'y1', y2: 'y2', stroke: t.ink, strokeWidth: 1.5 }),
624
+ Plot.link(rows, { x1: 'low', x2: 'high', y1: (r) => r.i + M, y2: (r) => r.i + M, stroke: 'color', strokeWidth: 3 }),
625
+ Plot.ruleX(rows.flatMap((r) => [{ ...r, x: r.low }, { ...r, x: r.high }]), { x: 'x', y1: (r) => r.i + M - 9 / rowH, y2: (r) => r.i + M + 9 / rowH, stroke: 'color', strokeWidth: 3 }),
626
+ Plot.dot(rows, { x: 'value', y: (r) => r.i + M, r: 7.5, fill: (r) => (r.clear ? r.color : t.paper), stroke: 'color', strokeWidth: 2.5 }),
627
+ Plot.text(rows, { y: (r) => r.i + T, frameAnchor: 'left', dx: -side, text: 'label', textAnchor: 'start', fill: t.ink, fontSize: t.body, ...halo }),
628
+ nW ? Plot.text(rows, { y: (r) => r.i + T, frameAnchor: 'left', dx: -side + labelW + 12, text: 'n', textAnchor: 'start', fill: t.soft, fontSize: t.small, ...halo }) : null,
629
+ Plot.text(rows, { y: (r) => r.i + T, frameAnchor: 'right', dx: side - spanW - 12, text: 'val', textAnchor: 'end', fill: (r) => (r.clear ? r.color : t.muted), fontWeight: (r) => (r.clear ? 600 : 400), fontSize: t.body, ...halo }),
630
+ Plot.text(rows, { y: (r) => r.i + T, frameAnchor: 'right', dx: side, text: 'span', textAnchor: 'end', fill: t.muted, fontSize: t.small, ...halo }),
631
+ ],
632
+ });
633
+ // What each side of zero means, under the axis, each reading away from the zero line.
634
+ if (spec.sides) {
635
+ const x0 = host.querySelector('svg').scale('x').apply(0);
636
+ const p = document.createElement('div');
637
+ p.className = 'est-sides';
638
+ p.innerHTML = `<span>← ${esc(spec.sides.below)}</span><span>${esc(spec.sides.above)} →</span>`;
639
+ host.appendChild(p);
640
+ const [below, above] = p.children;
641
+ const bw = below.offsetWidth, aw = above.offsetWidth;
642
+ if (x0 - 12 - bw < 0) window.fig?.problems.push(`sides.below: "${spec.sides.below}" is too long to end at the zero line; shorten it`);
643
+ if (x0 + 12 + aw > width) window.fig?.problems.push(`sides.above: "${spec.sides.above}" is too long to start at the zero line; shorten it`);
644
+ const bLeft = Math.max(0, x0 - 12 - bw);
645
+ const aLeft = Math.min(width - aw, Math.max(x0 + 12, bLeft + bw + 24));
646
+ below.style.left = `${bLeft}px`;
647
+ above.style.left = `${aLeft}px`;
648
+ }
649
+ }
650
+
651
+ /** The estimates key, under the reading line: what the ranges are, and what a filled or hollow dot says. */
652
+ function estimatesKey(spec) {
653
+ const clear = spec.rows.map((r) => r.low > 0 || r.high < 0);
654
+ const p = document.createElement('p');
655
+ p.className = 'range-key';
656
+ const ink = spec.better ? 'var(--ink)' : 'var(--accent)';
657
+ p.innerHTML = `<span><i class="whisker"></i>${esc(spec.range)}</span>`
658
+ + (clear.some(Boolean) ? `<span><i class="est-dot" style="background:${ink};border-color:${ink}"></i>clear of 0</span>` : '')
659
+ + (clear.some((c) => !c) ? '<span><i class="est-dot" style="border-color:var(--soft)"></i>includes 0</span>' : '');
660
+ return p;
661
+ }
662
+
663
+ // ---------- every value as a dot, two measures against each other, small counts as units ----------
664
+
665
+ /**
666
+ * Offsets that stack dots of radius r along a row so none overlaps another, nearest the middle first (a beeswarm).
667
+ * xs are pixel positions; returns a pixel offset from the row's centre line for each.
668
+ */
669
+ export function swarm(xs, r) {
670
+ const d = 2 * r + 1, order = xs.map((x, i) => i).sort((a, b) => xs[a] - xs[b]);
671
+ const out = new Array(xs.length), placed = [];
672
+ for (const i of order) {
673
+ const x = xs[i], near = placed.filter((p) => Math.abs(p.x - x) < d);
674
+ const free = (y) => near.every((p) => (p.x - x) ** 2 + (p.y - y) ** 2 >= d * d - 1e-6);
675
+ 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]; })]
676
+ .sort((a, b) => Math.abs(a) - Math.abs(b) || b - a);
677
+ const y = tries.find(free) ?? 0;
678
+ out[i] = y;
679
+ placed.push({ x, y });
680
+ }
681
+ return out;
682
+ }
683
+
684
+ 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; };
685
+
686
+ /**
687
+ * Every value as a dot into a .chart: one row per group on a shared axis, dots stacked where they would overlap.
688
+ * The group's name and count sit to the left (above, on a phone); a median or mean is a line across the row.
689
+ */
690
+ export function drawDots(host, spec) {
691
+ const t = themeOf(host);
692
+ const width = Math.floor(host.clientWidth) || 640, phone = phoneOf(host) || width < 460;
693
+ const fmt = (v) => num(v) + (spec.unit ?? '');
694
+ const all = spec.groups.flatMap((g) => g.values);
695
+ const lo = spec.zero ? 0 : Math.min(...all), hi = spec.max ?? Math.max(...all);
696
+ const counts = spec.groups.map((g) => (spec.each ? `${g.values.length.toLocaleString('en-GB')} ${spec.each}` : `n = ${g.values.length.toLocaleString('en-GB')}`));
697
+ const many = spec.groups.length > 1;
698
+ 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;
699
+ // Room for half the first tick label, so "0 min" does not stick out past the text edge.
700
+ const ml = Math.max(labelW + 10, Math.ceil(widest(host, [fmt(lo)], t.small) / 2) + 2), mr = 18;
701
+ const x = Plot.scale({ x: { domain: [lo, hi === lo ? lo + 1 : hi], range: [ml, width - mr], nice: true } });
702
+ // As many ticks as fit with a clear gap between their labels.
703
+ const nt = Math.max(2, Math.min(6, Math.floor((width - ml - mr) / (widest(host, x.domain.map(fmt), t.small) + 32))));
704
+ // The largest dots that keep the tallest stack within reach of the row.
705
+ const most = phone ? 56 : 64;
706
+ let r = phone ? 6 : 7.5, offs;
707
+ for (;;) {
708
+ offs = spec.groups.map((g) => swarm(g.values.map((v) => x.apply(v)), r));
709
+ const tallest = Math.max(...offs.flat().map(Math.abs));
710
+ if (tallest <= most || r <= 2.5) break;
711
+ r -= 0.5;
712
+ }
713
+ const sumName = spec.summary, dots = [], rows = [];
714
+ // A single group needs no name in the chart: the title and note say what it is.
715
+ const head = (phone && many ? Math.ceil(t.small * 2.7) : 0) + (sumName ? Math.ceil(t.small * 1.6) : 0);
716
+ let y = 6;
717
+ spec.groups.forEach((g, gi) => {
718
+ const half = Math.max(...offs[gi].map(Math.abs)) + r;
719
+ const top = y + head, mid = top + half;
720
+ const s = sumName === 'mean' ? g.values.reduce((a, v) => a + v, 0) / g.values.length : sumName ? middle(g.values) : null;
721
+ const colour = g.tone ? t[g.tone] : t.fill;
722
+ g.values.forEach((v, i) => dots.push({ v, y: mid + offs[gi][i], colour, g: g.label }));
723
+ rows.push({ g, top, mid, bottom: mid + half, s, label: g.label, count: counts[gi], colour, tone: g.tone });
724
+ y = mid + half + (phone ? 22 : 26);
725
+ });
726
+ const height = y - (phone ? 22 : 26) + 8 + 40;
727
+ const mark = spec.mark && dots.find((d) => d.g === spec.mark.group && d.v === spec.mark.value);
728
+ // The mark's label sits above its dot, reaching away from the nearer edge, so it never covers the dots beside it.
729
+ const markEnd = mark && x.apply(mark.v) + widest(host, [spec.mark.label], t.small, 600) > width - mr;
730
+ plotInto(host, t, {
731
+ width, height, marginLeft: ml, marginRight: mr, marginTop: 0, marginBottom: 40,
732
+ x: { domain: x.domain, nice: false, label: null, ticks: nt, tickFormat: fmt },
733
+ y: { type: 'identity', axis: null },
734
+ marks: [
735
+ Plot.gridX({ ticks: nt, stroke: t.hair, strokeOpacity: 1, y1: 0, y2: height - 40 }),
736
+ // The summary line runs behind the dots and out past both ends of the row, so it shows without cutting a dot.
737
+ sumName ? Plot.ruleX(rows, { x: 's', y1: (d) => d.top - 6, y2: (d) => d.bottom + 6, stroke: t.ink, strokeWidth: 2.5 }) : null,
738
+ Plot.dot(dots, { x: 'v', y: 'y', r, fill: 'colour', stroke: t.paper, strokeWidth: 1 }),
739
+ 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,
740
+ 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,
741
+ 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,
742
+ 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,
743
+ 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,
744
+ mark ? Plot.dot([mark], { x: 'v', y: 'y', r: r + 3.5, stroke: t.accent, strokeWidth: 2.5, fill: 'none' }) : null,
745
+ 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,
746
+ ],
747
+ });
748
+ if (!many) {
749
+ const p = document.createElement('p');
750
+ p.className = 'dots-n';
751
+ p.textContent = counts[0];
752
+ host.prepend(p);
753
+ }
754
+ }
755
+
756
+ const SECONDS = { seconds: 1, minutes: 60, hours: 3600, days: 86400 };
757
+ 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]];
758
+ /** A duration in seconds as the reader says it: 40 s, 12 min, 5 h, 3 days. */
759
+ function said(sec) {
760
+ const at = (n, u) => `${num(n)} ${u}`;
761
+ if (sec === 0) return '0';
762
+ if (sec < 60) return at(sec, 's');
763
+ if (sec < 3600) return at(sec / 60, 'min');
764
+ if (sec < 172800) return at(sec / 3600, 'h');
765
+ return at(sec / 86400, 'days');
766
+ }
767
+
768
+ /**
769
+ * One scatter axis: its domain, ticks and labels. A log axis runs to whole powers of ten (or, for time, to the
770
+ * nearest of 1 min, 1 h, 1 day...) beyond the data, so every dot sits inside a labelled tick.
771
+ */
772
+ function scatterAxis(a, vs, host, t, room) {
773
+ const lo = Math.min(...vs), hi = Math.max(...vs);
774
+ const mult = a.time ? SECONDS[a.time] : 1;
775
+ 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 ?? '');
776
+ if (a.log && a.time) {
777
+ const s0 = lo * mult, s1 = hi * mult;
778
+ const from = [...SPANS].reverse().find(([v]) => v <= s0) ?? SPANS[0];
779
+ const to = SPANS.find(([v]) => v >= s1) ?? SPANS[SPANS.length - 1];
780
+ let ticks = SPANS.filter(([v]) => v >= from[0] && v <= to[0]);
781
+ if (ticks.length > 7) ticks = ticks.filter((k) => k[2] === 1 || k === from || k === to);
782
+ 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) };
783
+ }
784
+ if (a.log) {
785
+ const e0 = Math.floor(Math.log10(lo)), e1 = Math.ceil(Math.log10(hi));
786
+ const ms = e1 - e0 > 3 ? [1] : [1, 3], ticks = [];
787
+ for (let e = e0; e <= e1; e++) for (const m of ms) if (m * 10 ** e <= 10 ** e1) ticks.push(m * 10 ** e);
788
+ return { type: 'log', domain: [10 ** e0, 10 ** e1], ticks, fmt };
789
+ }
790
+ const domain = a.zero ? [Math.min(0, lo), hi] : [lo, hi];
791
+ const n = Math.max(2, Math.min(6, Math.floor(room / (widest(host, domain.map(fmt), t.small) + 32))));
792
+ return { type: 'linear', domain, ticks: n, fmt, nice: true };
793
+ }
794
+
795
+ /**
796
+ * Two measures against each other into a .chart: a dot per thing, the named ones in colour and labelled beside them.
797
+ * With `medians`, the median y of each band of x is drawn across that band: the relation, without fitting a model.
798
+ */
799
+ export function drawScatter(host, spec) {
800
+ const t = themeOf(host);
801
+ const width = Math.floor(host.clientWidth) || 640, phone = phoneOf(host) || width < 460;
802
+ const pts = [...spec.points].sort((a, b) => (a.tone || a.label ? 1 : 0) - (b.tone || b.label ? 1 : 0));
803
+ const Y = scatterAxis(spec.y, pts.map((p) => p.y), host, t, 300);
804
+ const tickW = Math.ceil(widest(host, (Array.isArray(Y.ticks) ? Y.ticks : Y.domain).map(Y.fmt), t.small)) + 14;
805
+ const X = scatterAxis(spec.x, pts.map((p) => p.x), host, t, width - tickW - 16);
806
+ // A log axis is drawn as a linear axis over log10 of the values, with figkit's own ticks: Plot's log axis blanks
807
+ // every tick label that is not a power of ten, whatever the format.
808
+ 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 });
809
+ const XL = lin(X), YL = lin(Y);
810
+ const dots = pts.map((p) => ({ ...p, px: XL.at(p.x), py: YL.at(p.y) }));
811
+ const height = phone ? 340 : 420;
812
+ const r = (pts.length > 300 ? 3.5 : pts.length > 80 ? 4.5 : 6) * (phone ? 0.8 : 1);
813
+ const faint = pts.length > 150 ? 0.45 : 0.7;
814
+ // A label reads away from the nearer side of the plot: points in the right half are labelled to their left.
815
+ const f = (v) => (X.type === 'log' ? Math.log10(v) : v), [x0, x1] = X.domain.map(f);
816
+ const leftward = (p) => f(p.x) > (x0 + x1) / 2;
817
+ let bands = [];
818
+ if (spec.medians) {
819
+ const edges = X.type === 'log'
820
+ ? Array.from({ length: Math.round(x1 - x0) + 1 }, (_, i) => 10 ** (x0 + i))
821
+ : Array.from({ length: 6 }, (_, i) => X.domain[0] + ((X.domain[1] - X.domain[0]) * i) / 5);
822
+ for (let i = 0; i + 1 < edges.length; i++) {
823
+ const inside = pts.filter((p) => p.x >= edges[i] && (i + 2 === edges.length ? p.x <= edges[i + 1] : p.x < edges[i + 1]));
824
+ if (inside.length < 5) continue;
825
+ 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))))];
826
+ // Each band spans its own dots, so a band with few dots at its near end does not reach across empty space.
827
+ 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) });
828
+ }
829
+ }
830
+ const mt = Math.ceil(t.small * 1.4) + 20, mb = Math.ceil(t.small * 2.6) + 14;
831
+ // Room for half the last x tick label, so "10,000" ends inside the card.
832
+ const last = Array.isArray(XL.ticks) ? XL.fmt(XL.ticks[XL.ticks.length - 1]) : XL.fmt(XL.domain[1]);
833
+ const mr = Math.max(16, Math.ceil(widest(host, [last], t.small) / 2) - r - 4);
834
+ plotInto(host, t, {
835
+ width, height, marginLeft: tickW, marginRight: mr, marginTop: mt, marginBottom: mb,
836
+ x: { domain: XL.domain, nice: XL.nice ?? false, inset: r + 6, label: null, ticks: XL.ticks, tickFormat: XL.fmt },
837
+ y: { domain: YL.domain, nice: YL.nice ?? false, inset: r + 6, label: null, grid: true, ticks: YL.ticks, tickSize: 0, tickFormat: YL.fmt },
838
+ marks: [
839
+ Plot.dot(dots, { x: 'px', y: 'py', r, fill: (p) => (p.tone ? t[p.tone] : t.fill), fillOpacity: (p) => (p.tone ? 1 : faint) }),
840
+ 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,
841
+ 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,
842
+ 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 }),
843
+ 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 }),
844
+ // What each axis measures, written along it: the y label above its axis, the x label under the ticks at the right.
845
+ 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 }),
846
+ 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 }),
847
+ ],
848
+ });
849
+ if (bands.length) {
850
+ const key = document.createElement('p');
851
+ key.className = 'range-key';
852
+ 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>`;
853
+ key.style.marginBottom = '14px';
854
+ host.prepend(key);
855
+ }
856
+ }
857
+
858
+ /**
859
+ * A small count as squares into a .chart: rows of ten, filled column by column so each part is one solid block from the
860
+ * left. No gutters: a gap between squares would read as a split the data does not have.
861
+ */
862
+ export function drawUnits(host, spec) {
863
+ const t = themeOf(host);
864
+ const width = Math.floor(host.clientWidth) || 640, phone = phoneOf(host) || width < 460;
865
+ const cols = Math.min(10, spec.total), rows = Math.ceil(spec.total / cols), gap = phone ? 5 : 7;
866
+ const size = Math.min(phone ? 34 : 48, Math.floor((width - gap * (cols - 1)) / cols));
867
+ const greys = [t.fill, t.fill2];
868
+ let g = 0;
869
+ const parts = spec.parts.map((p, i) => ({ ...p, colour: p.tone ? t[p.tone] : i === 0 ? t.accent : greys[g++ % 2] }));
870
+ host.className = 'units';
871
+ host.innerHTML = '';
872
+ const grid = document.createElement('div');
873
+ grid.className = 'un-grid';
874
+ 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;`;
875
+ const cells = parts.flatMap((p) => Array(p.count).fill(p.colour));
876
+ for (let i = 0; i < spec.total; i++) {
877
+ const c = document.createElement('i');
878
+ if (cells[i]) c.style.background = cells[i];
879
+ else c.className = 'empty';
880
+ grid.appendChild(c);
881
+ }
882
+ const rest = spec.total - cells.length;
883
+ const key = document.createElement('div');
884
+ key.className = 'un-key';
885
+ 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('')
886
+ + `<span><i class="empty"></i><b style="color:${t.muted}">${rest}</b>${esc(spec.rest)}</span>`;
887
+ host.append(grid, key);
888
+ }