@liggi/figkit 0.3.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.
@@ -0,0 +1,6 @@
1
+ { "type": "bars", "style": "report", "title": "The new reply check catches more, but the ranges still overlap",
2
+ "note": "Share of unsafe replies caught, on the same test set", "unit": "%", "max": 100,
3
+ "range": "likely range (95%)", "each": "replies",
4
+ "bars": [ { "label": "Current check", "value": 64, "low": 53, "high": 74, "n": 72 },
5
+ { "label": "New check", "value": 78, "low": 67, "high": 86, "n": 72, "tone": "accent" } ],
6
+ "source": "Invented example data" }
@@ -0,0 +1,6 @@
1
+ { "type": "dots", "style": "report", "title": "Most builds finish in four minutes; a few take over ten",
2
+ "note": "One dot per build: minutes from push to result", "unit": " min", "summary": "median", "zero": true, "each": "builds",
3
+ "groups": [ { "label": "Main branch", "values": [3.8,4.1,3.6,4.4,3.9,4.0,4.2,3.7,4.6,3.9,4.3,11.8,4.0,3.5,4.1,4.8,3.9,4.2,12.6,4.0,3.8,4.4,4.1,3.7,4.5,3.9,10.4,4.2] },
4
+ { "label": "Pull requests", "values": [4.9,5.2,4.6,5.8,5.1,4.8,6.2,5.0,5.5,4.7,5.3,5.9,5.0,4.9,6.4,5.2,5.6,4.8,5.1,5.4], "tone": "accent" } ],
5
+ "mark": { "group": "Main branch", "value": 12.6, "label": "12.6 min, cache miss" },
6
+ "source": "Invented example data" }
@@ -0,0 +1,6 @@
1
+ { "type": "scatter", "style": "report", "title": "Bigger pull requests wait longer for review",
2
+ "note": "One dot per merged pull request", "x": { "label": "Lines changed", "log": true }, "y": { "label": "Time to first review", "time": "hours", "zero": true },
3
+ "points": [ {"x":12,"y":1.5},{"x":30,"y":2.1},{"x":8,"y":0.6},{"x":45,"y":3.2},{"x":120,"y":5.5},{"x":260,"y":9.1},{"x":75,"y":2.8},
4
+ {"x":18,"y":1.1},{"x":540,"y":22.0,"label":"Search rewrite","tone":"accent"},{"x":95,"y":6.0},{"x":160,"y":4.2},{"x":22,"y":3.9},
5
+ {"x":64,"y":2.2},{"x":310,"y":12.5},{"x":40,"y":1.8},{"x":5,"y":0.4},{"x":210,"y":7.7},{"x":880,"y":30.5},{"x":15,"y":0.9},{"x":130,"y":3.6} ],
6
+ "source": "Invented example data" }
@@ -0,0 +1,67 @@
1
+ {
2
+ "type": "sequence",
3
+ "title": "The shopper has an order number before the receipt is sent",
4
+ "participants": [
5
+ {
6
+ "id": "app",
7
+ "name": "Shop app",
8
+ "kind": "browser"
9
+ },
10
+ {
11
+ "id": "api",
12
+ "name": "Checkout API"
13
+ },
14
+ {
15
+ "id": "pay",
16
+ "name": "Card processor"
17
+ },
18
+ {
19
+ "id": "mail",
20
+ "name": "Email queue"
21
+ }
22
+ ],
23
+ "messages": [
24
+ {
25
+ "from": "app",
26
+ "to": "api",
27
+ "label": "Place order"
28
+ },
29
+ {
30
+ "from": "api",
31
+ "to": "api",
32
+ "label": "Check stock and price"
33
+ },
34
+ {
35
+ "from": "api",
36
+ "to": "pay",
37
+ "label": "Charge the card",
38
+ "tone": "accent"
39
+ },
40
+ {
41
+ "from": "pay",
42
+ "to": "api",
43
+ "label": "Approved or declined",
44
+ "reply": true,
45
+ "tone": "accent"
46
+ },
47
+ {
48
+ "from": "api",
49
+ "to": "app",
50
+ "label": "Order number",
51
+ "reply": true
52
+ },
53
+ {
54
+ "from": "api",
55
+ "to": "mail",
56
+ "label": "Send the receipt",
57
+ "note": "within a minute"
58
+ }
59
+ ],
60
+ "repeats": [
61
+ {
62
+ "from": 3,
63
+ "to": 4,
64
+ "label": "Up to 3 tries"
65
+ }
66
+ ]
67
+ }
@@ -0,0 +1,6 @@
1
+ { "type": "series", "style": "report", "title": "The weekly pass rate dipped in week 6, beyond its usual range",
2
+ "note": "Share of test runs that passed, each week", "unit": "%", "zero": false, "range": "likely range (95%)",
3
+ "points": [ {"x":1,"y":91,"low":86,"high":94}, {"x":2,"y":93,"low":88,"high":96}, {"x":3,"y":90,"low":85,"high":94},
4
+ {"x":4,"y":92,"low":87,"high":95}, {"x":5,"y":91,"low":86,"high":95}, {"x":6,"y":79,"low":72,"high":85},
5
+ {"x":7,"y":89,"low":84,"high":93}, {"x":8,"y":92,"low":87,"high":95} ],
6
+ "mark": { "x": 6, "label": "79%, week 6" }, "source": "Invented example data" }
@@ -0,0 +1,4 @@
1
+ { "type": "units", "style": "report", "title": "11 of the 29 flagged days were real problems",
2
+ "note": "One square per day the monitor flagged in October", "total": 29,
3
+ "parts": [ { "label": "real problems", "count": 11 } ], "rest": "false alarms",
4
+ "source": "Invented example data" }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@liggi/figkit",
3
- "version": "0.3.0",
3
+ "version": "0.4.0",
4
4
  "description": "Explanatory figures for agents and people: write a little HTML or a chart spec, get a laid-out PNG and a list of what is wrong with it.",
5
5
  "license": "MIT",
6
6
  "type": "module",
package/runtime/fig.css CHANGED
@@ -218,3 +218,59 @@ fig-bars .fb-sub { font-weight: 400; color: var(--soft); font-size: var(--small)
218
218
  .fig .hm-key { display: flex; flex-wrap: wrap; align-items: center; gap: 10px; font-size: var(--small); color: var(--muted); margin: 0 0 12px; }
219
219
  .fig .hm-key i { display: inline-block; width: 160px; height: 14px; border-radius: 3px; }
220
220
  .fig.phone .hm-key i { width: 84px; }
221
+
222
+ /* ---------- sequence: who calls whom, in order (a spec of type "sequence") ---------- */
223
+ .fig .sequence { position: relative; }
224
+ .fig .sequence > svg { position: absolute; left: 0; top: 0; overflow: visible; }
225
+ .fig .seq-lanes { position: relative; }
226
+ .fig .seq-who { position: absolute; }
227
+ .fig .seq-who { text-align: center; align-items: center; justify-content: center; }
228
+ .fig.phone .seq-who { padding: 10px 8px; }
229
+ .fig.phone .seq-who > h3 { font-size: var(--body); }
230
+ /* The paper shows only behind the words themselves, line by line, so a line passing a label stops just at its text. */
231
+ .fig .seq-msg { position: absolute; box-sizing: border-box; width: max-content; display: flex; gap: 10px; align-items: baseline; padding: 1px 6px; }
232
+ .fig .seq-ink { background: var(--paper); box-decoration-break: clone; -webkit-box-decoration-break: clone; padding: 1px 0; }
233
+ .fig .seq-n { font-size: var(--small); font-variant-numeric: tabular-nums; color: var(--soft); flex: 0 0 auto; min-width: 2ch; text-align: right; }
234
+ .fig .seq-text { display: flex; flex-direction: column; gap: 2px; min-width: 0; }
235
+ .fig .seq-note { font-size: var(--small); color: var(--muted); }
236
+ .fig .seq-msg.reply .seq-text { color: var(--muted); }
237
+ .fig .seq-msg.accent .seq-text, .fig .seq-msg.accent .seq-n { color: var(--accent); }
238
+ .fig .seq-msg.accent .seq-text { font-weight: 600; }
239
+ .fig .seq-msg.accent .seq-note { font-weight: 400; }
240
+ .fig .seq-frame-label { position: absolute; white-space: nowrap; background: var(--paper); padding: 0 8px; font-size: var(--small); font-weight: 600; color: var(--muted); }
241
+ .fig .seq-lanes.tight .seq-who { padding: 8px 4px; }
242
+ .fig .seq-lanes.tight .seq-who > h3 { font-size: var(--small); }
243
+
244
+ /* ranges: what they are, said once under the reading line; on bars a line with end caps across the bar's end */
245
+ .fig .range-key { display: flex; flex-wrap: wrap; column-gap: 28px; row-gap: 6px; margin: 2px 0 0; font-size: var(--small); color: var(--muted); }
246
+ .fig .range-key > span { display: inline-flex; align-items: center; gap: 10px; }
247
+ .fig .range-key i { flex: 0 0 auto; }
248
+ .fig .range-key i.dash { width: 34px; height: 0; border-top: 2px dashed var(--ink); }
249
+ .fig .range-key i.whisker { position: relative; width: 34px; height: 14px; border-left: 2px solid var(--ink); border-right: 2px solid var(--ink); }
250
+ .fig .range-key i.whisker::after { content: ''; position: absolute; left: 0; right: 0; top: 6px; border-top: 2px solid var(--ink); }
251
+ .fig .range-key i.median { width: 34px; height: 4px; border-radius: 2px; }
252
+ .fig .range-key i.band { width: 34px; height: 14px; border-radius: 2px; background: var(--fill); opacity: .45; }
253
+ fig-bars .fb-track { position: relative; }
254
+ fig-bars .fb-range { --tone: var(--ink); position: absolute; top: 50%; height: 12px; margin-top: -6px; border-left: 2px solid var(--tone); border-right: 2px solid var(--tone); pointer-events: none; }
255
+ fig-bars .fb-range::after { content: ''; position: absolute; left: 0; right: 0; top: 4.5px; border-top: 3px solid var(--tone); }
256
+ fig-bars .fb-bar.dim { opacity: .4; }
257
+ fig-bars .fb-val .fb-sub + .fb-sub { margin-left: 10px; }
258
+ fig-bars .fb-label .fb-n { display: block; font-size: var(--small); color: var(--soft); }
259
+
260
+ /* dots: the count of a single group, above its dots */
261
+ .fig .dots-n { margin: 0 0 4px; font-size: var(--small); color: var(--muted); }
262
+
263
+ /* units: one square per thing counted */
264
+ .fig .units { display: flex; flex-direction: column; gap: 22px; }
265
+ .fig .un-grid { display: grid; }
266
+ .fig .un-grid i { width: var(--un); height: var(--un); border-radius: 4px; }
267
+ .fig .un-grid i.empty, .fig .un-key i.empty { background: var(--hair); }
268
+ .fig .un-key { display: flex; flex-wrap: wrap; column-gap: 28px; row-gap: 8px; font-size: var(--body); color: var(--muted); }
269
+ .fig .un-key span { display: inline-flex; align-items: center; gap: 8px; }
270
+ .fig .un-key i { width: 18px; height: 18px; border-radius: 3px; display: inline-block; }
271
+ .fig .un-key b { color: var(--ink); font-weight: 600; font-variant-numeric: tabular-nums; }
272
+
273
+ /* bars on a phone: the name and count above each bar, so the bar and its range get the full width */
274
+ .fig.phone fig-bars { grid-template-columns: minmax(0, 1fr); row-gap: 4px; }
275
+ .fig.phone fig-bars .fb-label { margin-top: 10px; }
276
+ .fig.phone fig-bars .fb-label .fb-n { display: inline; margin-left: 8px; }
package/runtime/fig.js CHANGED
@@ -567,6 +567,13 @@
567
567
  for (const b of marks.slice(i + 1)) if (overlap(a.r, b.r) && !a.e.contains(b.e) && !b.e.contains(a.e)) P.push(`Chart labels overlap: "${a.e.textContent.trim()}" and "${b.e.textContent.trim()}".`);
568
568
  }
569
569
 
570
+ // Chart labels over dots: a label drawn across a dot hides the value it sits on.
571
+ const dots = [...R.querySelectorAll('.chart svg circle')].filter(visible).map(rectOf).map((r) => grow(r, -1.5));
572
+ for (const a of marks) {
573
+ const hit = dots.filter((d) => overlap(grow(a.r, -1), d)).length;
574
+ if (hit) P.push(`Chart label covers ${hit === 1 ? 'a dot' : hit + ' dots'}: "${a.e.textContent.trim()}".`);
575
+ }
576
+
570
577
  // Text: too small, cut off, or past its box.
571
578
  const small = [];
572
579
  for (const o of obs.flatMap((b) => (b.text ? [b.el] : [...b.el.querySelectorAll('*')].filter((e) => [...e.childNodes].some((n) => n.nodeType === 3 && n.textContent.trim()))))) {
package/viz/build.mjs CHANGED
@@ -4,7 +4,7 @@ import path from 'node:path';
4
4
  import { fileURLToPath } from 'node:url';
5
5
 
6
6
  const HOME = path.join(path.dirname(fileURLToPath(import.meta.url)), '..');
7
- const SOURCES = ['viz/draw.js', 'package.json'].map((f) => path.join(HOME, f));
7
+ const SOURCES = [...fs.readdirSync(path.join(HOME, 'viz')).filter((f) => f.endsWith('.js')).map((f) => 'viz/' + f), 'package.json'].map((f) => path.join(HOME, f));
8
8
  const OUT = path.join(HOME, 'dist/viz.iife.js');
9
9
 
10
10
  const mtime = (f) => (fs.existsSync(f) ? fs.statSync(f).mtimeMs : 0);
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>';
@@ -173,12 +197,13 @@ export function drawDumbbell(host, spec) {
173
197
  export function drawSeries(host, spec) {
174
198
  const t = themeOf(host);
175
199
  const width = Math.floor(host.clientWidth) || 640;
176
- 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;
177
202
  const i = pts.findIndex((p) => +p.x === +toX(spec.mark.x));
178
203
  const mark = pts[i];
179
204
  const near = pts.slice(Math.max(0, i - 3), i + 4).map((p) => p.y);
180
205
  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;
206
+ const ys = [...pts.flatMap((p) => (band ? [p.y, p.low, p.high] : [p.y])), ...(spec.ref ? [spec.ref.y] : [])], zero = spec.zero ?? true;
182
207
  const top = Math.max(...ys), bottom = zero ? Math.min(0, ...ys) : Math.min(...ys);
183
208
  const fmt = (d) => num(d) + (spec.unit ?? '');
184
209
  const tickW = widest(host, [fmt(top), fmt(bottom)], t.small);
@@ -188,16 +213,23 @@ export function drawSeries(host, spec) {
188
213
  const right = at + 14 + widest(host, [spec.mark.label], t.body, 600) <= width - 20;
189
214
  // Above a high point; level with a low one, lifted clear of the axis when it sits near the bottom.
190
215
  const lift = zero ? ((mark.y - bottom) / (top * 1.04 - bottom)) * (340 - 30 - 40) : Infinity;
191
- 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;
192
222
  plotInto(host, t, {
193
223
  width, height: 340, marginLeft: left0, marginRight: 20, marginTop: 30, marginBottom: 40,
194
224
  x: { label: null, ticks: width < 600 ? 4 : 5, insetLeft: 18 },
195
225
  y: { label: null, grid: true, ticks: 4, tickSize: 0, domain: zero ? [bottom, top * 1.04] : undefined, tickFormat: fmt },
196
226
  marks: [
197
227
  zero ? Plot.ruleY([0], { stroke: t.line }) : null,
198
- Plot.lineY(pts, { x: 'x', y: 'y', stroke: t.fill, 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 }),
199
231
  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 }),
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 }),
201
233
  ],
202
234
  });
203
235
  }
@@ -284,18 +316,26 @@ function drawEntities(spec, main) {
284
316
  }
285
317
  }
286
318
 
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' };
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' };
288
320
 
289
321
  /** A checked spec drawn into a figure: header, chart and footer, as figkit's own markup. */
290
322
  export function drawSpec(spec, main) {
291
323
  const header = document.createElement('header');
292
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));
293
327
  if (spec.type === 'entities') {
294
328
  main.append(header);
295
329
  drawEntities(spec, main);
296
330
  if (spec.source) { const footer = document.createElement('footer'); footer.innerHTML = `<p>${esc(spec.source)}</p>`; main.append(footer); }
297
331
  return { chart: main.querySelector('.entities'), draw: () => {} };
298
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
+ }
299
339
  const chart = document.createElement(HOSTS[spec.type]);
300
340
  main.append(header, chart);
301
341
  if (spec.source) {
@@ -311,9 +351,12 @@ export function drawSpec(spec, main) {
311
351
  if (spec.type === 'lines') return drawLines(chart, spec, { unit: spec.unit, xTicks: spec.xTicks });
312
352
  if (spec.type === 'panels') return drawPanels(chart, spec);
313
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);
314
357
  const items = (spec.parts ?? spec.bars).map((x) => ({ ...x, text: String(x.value), label: esc(x.label) }));
315
358
  if (spec.type === 'split') drawSplit(chart, items, spec.unit);
316
- else drawBars(chart, items, spec.unit, spec.max);
359
+ else drawBars(chart, items, spec.unit, spec.max, spec.each);
317
360
  };
318
361
  draw();
319
362
  return { chart, draw };
@@ -372,12 +415,12 @@ export function drawLines(host, data, opts = {}) {
372
415
  const t = themeOf(host);
373
416
  const width = Math.floor(host.clientWidth) || 640, phone = phoneOf(host) || width < 420;
374
417
  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 })) }));
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 })) }));
376
419
  const others = lines.filter((l) => l.label !== data.accent);
377
420
  others.forEach((l, i) => { l.color = greys[i % 2]; l.w = 2.25; });
378
421
  const hi = lines.find((l) => l.label === data.accent);
379
422
  hi.color = t.accent; hi.w = 3.25;
380
- const ys = lines.flatMap((l) => l.pts.map((p) => p.y));
423
+ const ys = lines.flatMap((l) => l.pts.flatMap((p) => (l.band ? [p.y, p.low, p.high] : [p.y])));
381
424
  const zero = data.zero ?? true;
382
425
  const domain = opts.domain ?? [zero ? Math.min(0, ...ys) : Math.min(...ys), Math.max(...ys) * 1.04];
383
426
  const fmt = (d) => num(d) + (opts.unit ?? '');
@@ -399,6 +442,7 @@ export function drawLines(host, data, opts = {}) {
399
442
  y: { domain, nice: false, label: null, grid: true, ticks: 4, tickSize: 0, tickFormat: fmt },
400
443
  marks: [
401
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 })),
402
446
  ...[...others, hi].map((l) => Plot.lineY(l.pts, { x: 'x', y: 'y', stroke: l.color, strokeWidth: l.w })),
403
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 }),
404
448
  markPt ? Plot.dot([markPt], { x: 'x', y: 'y', r: 6, fill: t.accent }) : null,
@@ -427,7 +471,7 @@ export function drawPanels(host, spec) {
427
471
  drawGroups(chart, { series: spec.series, groups: p.groups, accent }, { unit: spec.unit, max, quietGroups: true });
428
472
  }
429
473
  } else {
430
- const ys = spec.panels.flatMap((p) => p.series.flatMap((l) => l.points.map((q) => q.y)));
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]))));
431
475
  const zero = spec.zero ?? true;
432
476
  const domain = [zero ? Math.min(0, ...ys) : Math.min(...ys), Math.max(...ys) * 1.04];
433
477
  for (const { p, chart } of charts) drawLines(chart, { series: p.series, accent: spec.accent, zero }, { unit: spec.unit, domain, xTicks: spec.xTicks });
@@ -474,3 +518,230 @@ export function drawHeatmap(host, spec) {
474
518
  key.style.marginLeft = labelW + 'px';
475
519
  host.prepend(key);
476
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
+ }