@liggi/figkit 0.4.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.
@@ -0,0 +1,6 @@
1
+ { "type": "bars", "title": "Two of the four new layouts beat the current page",
2
+ "note": "Share of visitors who finished checkout", "unit": "%",
3
+ "ref": { "label": "Current page", "value": 61 },
4
+ "bars": [ { "label": "One-page form", "value": 68 }, { "label": "Saved cards first", "value": 64 },
5
+ { "label": "Address lookup", "value": 60 }, { "label": "Guest by default", "value": 57 } ],
6
+ "source": "Invented data, for illustration." }
@@ -0,0 +1,7 @@
1
+ { "type": "estimates", "title": "Free delivery lifts orders in two regions; the north's range includes zero",
2
+ "note": "Change in orders per 100 visitors, free delivery minus paid", "range": "95% interval",
3
+ "sides": { "below": "Paid better", "above": "Free better" },
4
+ "rows": [ { "label": "South", "value": 2.4, "low": 1.1, "high": 3.7 },
5
+ { "label": "Midlands", "value": 1.8, "low": 0.6, "high": 3.0 },
6
+ { "label": "North", "value": 0.7, "low": -0.9, "high": 2.3 } ],
7
+ "source": "Invented data, for illustration." }
@@ -0,0 +1,5 @@
1
+ { "type": "heatmap", "title": "The new menu helped every region except the north",
2
+ "note": "Change in weekly orders per store after the new menu", "unit": "%",
3
+ "rows": ["South", "Midlands", "North"], "columns": ["Wk 1", "Wk 2", "Wk 3", "Wk 4"],
4
+ "values": [ [3.1, 4.4, 5.0, 4.8], [1.2, 2.6, 3.3, 3.9], [-2.4, -3.8, -1.9, -2.7] ],
5
+ "source": "Invented data, for illustration." }
@@ -0,0 +1,71 @@
1
+ {
2
+ "type": "states",
3
+ "title": "An order waits after a declined card until a new card is added",
4
+ "entry": "Shopper checks out",
5
+ "states": [
6
+ {
7
+ "id": "waiting",
8
+ "name": "Awaiting payment",
9
+ "start": true
10
+ },
11
+ {
12
+ "id": "declined",
13
+ "name": "Card declined",
14
+ "note": "Shopper emailed",
15
+ "tone": "accent"
16
+ },
17
+ {
18
+ "id": "paid",
19
+ "name": "Paid"
20
+ },
21
+ {
22
+ "id": "packed",
23
+ "name": "Packed"
24
+ },
25
+ {
26
+ "id": "shipped",
27
+ "name": "Shipped",
28
+ "end": true
29
+ },
30
+ {
31
+ "id": "cancelled",
32
+ "name": "Cancelled",
33
+ "end": true
34
+ }
35
+ ],
36
+ "moves": [
37
+ {
38
+ "from": "waiting",
39
+ "to": "paid",
40
+ "label": "Charge succeeds"
41
+ },
42
+ {
43
+ "from": "waiting",
44
+ "to": "declined",
45
+ "label": "Charge fails"
46
+ },
47
+ {
48
+ "from": "declined",
49
+ "to": "waiting",
50
+ "label": "New card added"
51
+ },
52
+ {
53
+ "from": "paid",
54
+ "to": "packed",
55
+ "label": "Warehouse picks"
56
+ },
57
+ {
58
+ "from": "packed",
59
+ "to": "shipped",
60
+ "label": "Carrier collects"
61
+ },
62
+ {
63
+ "from": [
64
+ "paid",
65
+ "packed"
66
+ ],
67
+ "to": "cancelled",
68
+ "label": "Shopper cancels"
69
+ }
70
+ ]
71
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@liggi/figkit",
3
- "version": "0.4.0",
3
+ "version": "0.5.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
@@ -274,3 +274,42 @@ fig-bars .fb-label .fb-n { display: block; font-size: var(--small); color: var(-
274
274
  .fig.phone fig-bars { grid-template-columns: minmax(0, 1fr); row-gap: 4px; }
275
275
  .fig.phone fig-bars .fb-label { margin-top: 10px; }
276
276
  .fig.phone fig-bars .fb-label .fb-n { display: inline; margin-left: 8px; }
277
+
278
+ /* estimates: signed values with ranges around a zero line; what each side of zero means under the axis */
279
+ .fig .estimates { position: relative; }
280
+
281
+ .fig .range-key i.est-dot { width: 14px; height: 14px; border-radius: 50%; border: 2.5px solid; box-sizing: border-box; }
282
+ .fig .est-sides { position: relative; height: calc(var(--small) * 1.4); margin-top: -6px; font-size: var(--small); color: var(--muted); }
283
+ .fig .est-sides span { position: absolute; top: 0; white-space: nowrap; }
284
+
285
+ /* bars with a reference: its own first row in the second colour, and a dashed line from its end through the rest */
286
+ fig-bars.has-ref { position: relative; }
287
+ fig-bars .fb-label.ref { color: var(--second); }
288
+ fig-bars .fb-refline { position: absolute; width: 0; border-left: 2px dashed var(--second); pointer-events: none; }
289
+ .fig .range-key i.swatch { width: 18px; height: 14px; border-radius: 2px; background: var(--accent); }
290
+ fig-bars.has-ref .fb-label.ref + .fb-track .fb-bar { opacity: .55; }
291
+ /* on a phone the names sit above the bars, so the reference line stops at each name rather than crossing it */
292
+ .fig.phone fig-bars.has-ref .fb-label { position: relative; z-index: 1; justify-self: start; background: var(--paper); padding-right: 8px; }
293
+ .fig .hm-key i.hm-signed { position: relative; margin-bottom: calc(var(--small) * 1.3); }
294
+ .fig .hm-key i.hm-signed b { font-style: normal; position: absolute; top: 100%; transform: translateX(-50%); font-weight: 400; color: var(--muted); padding-top: 2px; line-height: 1; }
295
+ .fig .hm-key i.hm-signed b::before { content: ''; position: absolute; left: 50%; top: -14px; height: 14px; border-left: 2px solid var(--paper); }
296
+ fig-bars.has-ref .fb-val:not(.lead) { color: var(--muted); }
297
+ /* Against a reference, a bar that clears it is solid accent (a faded orange reads as brown on dark); its range is drawn in ink on top. */
298
+ fig-bars.has-ref .fb-bar.dim.accent { opacity: 1; }
299
+ fig-bars.has-ref .fb-range.accent { --tone: var(--ink); }
300
+
301
+ /* ---------- states: what one thing moves through (a spec of type "states") ---------- */
302
+ .fig .states { display: grid; column-gap: 120px; row-gap: 76px; align-items: stretch; justify-content: start; }
303
+ .fig.phone .states { column-gap: 64px; row-gap: 68px; }
304
+ .fig.phone .state { padding: 12px 10px; }
305
+ .fig.phone .state > h3 { font-size: var(--body); }
306
+ .fig .state { text-align: center; align-items: center; justify-content: center; justify-self: stretch; }
307
+ .fig .state > p { margin-top: 4px; }
308
+ .fig .state > .st-stayed { color: var(--ink); }
309
+ .fig .node.bare.st-dot { width: 22px; height: 22px; padding: 0; border-radius: 50%; background: var(--line); justify-self: center; align-self: center; }
310
+ .fig .fig-wire-label .fig-wire-sub { display: block; font-weight: 400; font-variant-numeric: tabular-nums; }
311
+ .fig .st-key { font-size: var(--small); }
312
+ .fig .state.mid { align-self: center; }
313
+ /* the bracket beside the states that share one move, which leads to it with one arrow */
314
+ .fig .node.bare.st-bracket { justify-self: end; width: 14px; margin-right: -30px; padding: 0; border: 1.5px solid var(--line); border-left: 0; border-radius: 0 4px 4px 0; background: none; }
315
+ .fig.phone .node.bare.st-bracket { margin-right: -24px; width: 10px; }
package/runtime/fig.js CHANGED
@@ -42,7 +42,7 @@
42
42
  function obstacles() {
43
43
  const out = [];
44
44
  const solid = [...root().querySelectorAll(SOLID)].filter(visible);
45
- for (const el of solid) if (!el.classList.contains('bare')) out.push({ el, r: rectOf(el) });
45
+ for (const el of solid) if (!el.classList.contains('bare') || el.classList.contains('st-bracket')) out.push({ el, r: rectOf(el) });
46
46
  const walker = document.createTreeWalker(root(), NodeFilter.SHOW_TEXT);
47
47
  const seen = new Set();
48
48
  for (let n = walker.nextNode(); n; n = walker.nextNode()) {
@@ -370,7 +370,7 @@
370
370
  fig.wires.push({ from, to, pts, mode, ref });
371
371
 
372
372
  const text = ref ? '' : tag.getAttribute('label') ?? tag.textContent.trim();
373
- if (text) labels.push({ text, tone, color, pts, at: labelAt, want: tag.getAttribute('label-at'), from, to });
373
+ if (text) labels.push({ text, sub: tag.getAttribute('sub'), tone, color, pts, at: labelAt, want: tag.getAttribute('label-at'), from, to });
374
374
  }
375
375
  return placeLabels(labels, obs);
376
376
  }
@@ -467,6 +467,8 @@
467
467
  const lab = document.createElement('div');
468
468
  lab.className = 'fig-wire-label';
469
469
  lab.textContent = L.text;
470
+ // Further lines under the label, such as a count: one span each.
471
+ for (const t of (L.sub ?? '').split('\n').filter(Boolean)) { const e = document.createElement('span'); e.className = 'fig-wire-sub'; e.textContent = t; lab.appendChild(e); }
470
472
  if (L.tone !== 'line') lab.style.color = L.color;
471
473
  lab.dataset.arrow = `${L.from} → ${L.to}`;
472
474
  R.appendChild(lab);
@@ -553,10 +555,66 @@
553
555
  });
554
556
 
555
557
  // ---------- checks ----------
558
+ /** The colour as [r, g, b, a] (0–255, alpha 0–1), or null when it is not a plain colour (none, a gradient, a pattern). */
559
+ function rgba(c) {
560
+ const m = /^rgba?\(([\d.]+),\s*([\d.]+),\s*([\d.]+)(?:,\s*([\d.]+))?\)$/.exec((c || '').trim());
561
+ if (m) return [+m[1], +m[2], +m[3], m[4] === undefined ? 1 : +m[4]];
562
+ const s = /^color\(srgb ([\d.e-]+) ([\d.e-]+) ([\d.e-]+)(?: \/ ([\d.]+))?\)$/.exec((c || '').trim());
563
+ return s ? [+s[1] * 255, +s[2] * 255, +s[3] * 255, s[4] === undefined ? 1 : +s[4]] : null;
564
+ }
565
+ const lumOf = ([r, g, b]) => [r, g, b].map((v) => { v /= 255; return v <= 0.03928 ? v / 12.92 : ((v + 0.055) / 1.055) ** 2.4; }).reduce((s, v, i) => s + v * [0.2126, 0.7152, 0.0722][i], 0);
566
+ const over = (top, under) => [0, 1, 2].map((i) => top[i] * top[3] + under[i] * (1 - top[3])).concat(1);
567
+ function opacityOf(el) { let o = 1; for (let x = el; x && x !== document.documentElement; x = x.parentElement) o *= parseFloat(getComputedStyle(x).opacity); return o; }
568
+ /** The colour an element paints over its own area, with its opacity, or undefined when it paints nothing there. */
569
+ function paintOf(el) {
570
+ const cs = getComputedStyle(el);
571
+ if (el instanceof SVGElement) {
572
+ if (!/^(rect|path|circle|ellipse|polygon)$/.test(el.tagName)) return undefined;
573
+ if (cs.fill === 'none') return undefined;
574
+ const c = rgba(cs.fill);
575
+ return c ? [c[0], c[1], c[2], c[3] * parseFloat(cs.fillOpacity) * opacityOf(el)] : null;
576
+ }
577
+ if (cs.backgroundImage !== 'none') return null;
578
+ const c = rgba(cs.backgroundColor);
579
+ if (!c) return null;
580
+ return c[3] === 0 ? undefined : [c[0], c[1], c[2], c[3] * opacityOf(el)];
581
+ }
582
+ /** Contrast between a text element and what lies under its middle, as a WCAG ratio; null when it cannot be told. */
583
+ function contrastOf(el) {
584
+ const b = el.getBoundingClientRect();
585
+ if (b.top < 0 || b.bottom > innerHeight || b.left < 0 || b.right > innerWidth) el.scrollIntoView({ block: 'center', inline: 'center' });
586
+ const r = el.tagName === 'text' ? el.getBoundingClientRect() : (() => { const t = textRect(el), o = R0(); return { x: t.x + o.x, y: t.y + o.y, width: t.w, height: t.h }; })();
587
+ const stack = document.elementsFromPoint(r.x + r.width / 2, r.y + r.height / 2);
588
+ let i = stack.findIndex((x) => x === el || x.contains(el));
589
+ if (i < 0) return null;
590
+ if (el.tagName === 'text') i++;
591
+ const layers = [];
592
+ for (; i < stack.length; i++) {
593
+ const p = paintOf(stack[i]);
594
+ if (p === null) return null;
595
+ if (p === undefined) continue;
596
+ layers.push(p);
597
+ if (p[3] >= 0.99) break;
598
+ }
599
+ let bg = [255, 255, 255, 1];
600
+ for (const l of layers.reverse()) bg = over(l, bg);
601
+ const cs = getComputedStyle(el);
602
+ const fg = rgba(el.tagName === 'text' ? cs.fill : cs.color);
603
+ if (!fg) return null;
604
+ const ink = over([fg[0], fg[1], fg[2], fg[3] * opacityOf(el)], bg);
605
+ const la = lumOf(ink), lb = lumOf(bg);
606
+ return (Math.max(la, lb) + 0.05) / (Math.min(la, lb) + 0.05);
607
+ }
608
+ const R0 = () => { const b = root().getBoundingClientRect(); return { x: b.x, y: b.y }; };
609
+
556
610
  function check() {
557
611
  const R = root(), P = fig.problems;
558
612
  const W = R.offsetWidth, H = R.offsetHeight;
559
613
  const detail = R.classList.contains('detail');
614
+ // A phone layout is 560px wide (fig.css); opening gaps for labels can widen it past that.
615
+ const phoneW = R.classList.contains('phone') ? 560 : 0;
616
+ const wide = Math.max(W, R.scrollWidth, R.getBoundingClientRect().width);
617
+ if (phoneW && wide > phoneW + 1) P.push(`The figure is ${Math.round(wide)}px wide on a phone, wider than ${phoneW}px; shorten the widest labels.`);
560
618
  const minText = detail ? 13 : 15;
561
619
  const obs = obstacles();
562
620
 
@@ -574,6 +632,15 @@
574
632
  if (hit) P.push(`Chart label covers ${hit === 1 ? 'a dot' : hit + ' dots'}: "${a.e.textContent.trim()}".`);
575
633
  }
576
634
 
635
+ // Text that cannot be read against what is drawn behind it, such as white numbers on a pale cell.
636
+ for (const e of [...R.querySelectorAll('*')].filter((x) => !x.closest('svg') || x.tagName === 'text')) {
637
+ if (e.tagName !== 'text' && ![...e.childNodes].some((n) => n.nodeType === 3 && n.textContent.trim())) continue;
638
+ if (!e.textContent.trim() || !visible(e) || e.matches('script, style')) continue;
639
+ const ratio = contrastOf(e);
640
+ if (ratio !== null && ratio < 3) P.push(`Text cannot be read against what is behind it: "${e.textContent.trim().replace(/\s+/g, ' ')}" (contrast ${ratio.toFixed(1)} to 1; it needs 3).`);
641
+ }
642
+ window.scrollTo(0, 0);
643
+
577
644
  // Text: too small, cut off, or past its box.
578
645
  const small = [];
579
646
  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()))))) {
@@ -626,6 +693,7 @@
626
693
  const crossings = new Set(crossingPairs().map(([u, w]) => `${u.from} → ${u.to} and ${w.from} → ${w.to}`));
627
694
  if (crossings.size) P.push(R.querySelector('.entities')
628
695
  ? `References cross: ${[...crossings].join('; ')}. Split the records over two figures; do not leave a reference out.`
696
+ : R.querySelector('.states') ? `Moves cross: ${[...crossings].join('; ')}. figkit could not lay them out apart; draw the side branch as a second figure.`
629
697
  : `Arrows cross: ${[...crossings].join('; ')}. Reorder the boxes so they do not, or drop an arrow.`);
630
698
 
631
699
  // Neighbours that almost line up.
@@ -647,7 +715,9 @@
647
715
  const outside = R.querySelector('.panels') ? [...R.children].filter((c) => !c.matches('script, style') && !c.querySelector('.panels') && !c.matches('.panels')).map((c) => c.innerText).join(' ') : null;
648
716
  const words = (panelText !== null ? outside + ' ' + panelText : R.innerText).split(/\s+/).filter(Boolean).length;
649
717
  const nodes = R.querySelectorAll('.node:not(.bare), .decision').length, arrows = (fig.wires ?? []).length;
650
- const budget = detail ? { words: 190, nodes: 18, arrows: 18 } : { words: 70, nodes: 5, arrows: 8 };
718
+ // A states picture reads down one path of short names, so it holds up to 7 states (see the states spec).
719
+ const states = !!R.querySelector('.states');
720
+ const budget = detail ? { words: 190, nodes: 18, arrows: 18 } : states ? { words: 90, nodes: 7, arrows: 12 } : { words: 70, nodes: 5, arrows: 8 };
651
721
  if (words > budget.words) P.push(`${words} words; a ${detail ? 'detail' : 'glance'} figure holds about ${budget.words}. Cut sentences that explain, and keep labels.`);
652
722
  if (nodes > budget.nodes) P.push(`${nodes} boxes; more than ${budget.nodes} stops reading as one picture. ${detail ? 'Split it into two figures or merge boxes.' : 'Split it into two or three figures in a row (what exists, one case through it, what changes), or merge boxes.'}`);
653
723
  if (arrows > budget.arrows) P.push(`${arrows} arrows; more than ${budget.arrows} tangles. Show the main path and drop the rest.`);
package/viz/draw.js CHANGED
@@ -1,6 +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
3
  import { drawSequence } from './sequence.js';
4
+ import { drawStates } from './states.js';
4
5
  const NS = 'http://www.w3.org/2000/svg';
5
6
  const esc = (s) => String(s).replace(/[&<>"]/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' })[c]);
6
7
 
@@ -84,11 +85,15 @@ export function drawSplit(host, parts, unit) {
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
85
86
  * 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
  */
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)));
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);
89
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 }))];
90
95
  const fills = bars.map((b) => {
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>` : '') });
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>` : '') });
92
97
  const track = Object.assign(document.createElement('div'), { className: 'fb-track' });
93
98
  const tone = b.tone ? ' ' + b.tone : '';
94
99
  const ranged = b.low !== undefined;
@@ -109,6 +114,20 @@ export function drawBars(host, bars, unit, max, each) {
109
114
  f.range.style.width = at(f.hi - f.lo);
110
115
  f.val.style.marginLeft = `max(0px, calc((100% - ${room}px) * ${f.hi - f.ratio} - 3px))`;
111
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
+ }
112
131
  }
113
132
 
114
133
  /** What the ranges are, as a key line under the reading line: the mark as drawn, then the words. */
@@ -116,7 +135,8 @@ function rangeKey(spec) {
116
135
  const p = document.createElement('p');
117
136
  p.className = 'range-key';
118
137
  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>` : '');
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>` : '');
120
140
  return p;
121
141
  }
122
142
 
@@ -316,20 +336,33 @@ function drawEntities(spec, main) {
316
336
  }
317
337
  }
318
338
 
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' };
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' };
320
340
 
321
341
  /** A checked spec drawn into a figure: header, chart and footer, as figkit's own markup. */
322
342
  export function drawSpec(spec, main) {
323
343
  const header = document.createElement('header');
324
344
  header.innerHTML = `<h1>${esc(spec.title)}</h1>${spec.note ? `<p class="read">${esc(spec.note)}</p>` : ''}`;
325
345
  // 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));
346
+ if (spec.type === 'estimates') header.appendChild(estimatesKey(spec));
347
+ else if (spec.range || spec.ref) header.appendChild(rangeKey(spec));
327
348
  if (spec.type === 'entities') {
328
349
  main.append(header);
329
350
  drawEntities(spec, main);
330
351
  if (spec.source) { const footer = document.createElement('footer'); footer.innerHTML = `<p>${esc(spec.source)}</p>`; main.append(footer); }
331
352
  return { chart: main.querySelector('.entities'), draw: () => {} };
332
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
+ }
333
366
  if (spec.type === 'sequence') {
334
367
  main.append(header);
335
368
  const chart = drawSequence(spec, main);
@@ -346,6 +379,7 @@ export function drawSpec(spec, main) {
346
379
  const draw = () => {
347
380
  if (spec.type === 'before-after') return drawChange(chart, spec);
348
381
  if (spec.type === 'dumbbell') return drawDumbbell(chart, spec);
382
+ if (spec.type === 'estimates') return drawEstimates(chart, spec);
349
383
  if (spec.type === 'series') return drawSeries(chart, spec);
350
384
  if (spec.type === 'groups') return drawGroups(chart, spec, { unit: spec.unit, max: spec.max });
351
385
  if (spec.type === 'lines') return drawLines(chart, spec, { unit: spec.unit, xTicks: spec.xTicks });
@@ -356,7 +390,7 @@ export function drawSpec(spec, main) {
356
390
  if (spec.type === 'units') return drawUnits(chart, spec);
357
391
  const items = (spec.parts ?? spec.bars).map((x) => ({ ...x, text: String(x.value), label: esc(x.label) }));
358
392
  if (spec.type === 'split') drawSplit(chart, items, spec.unit);
359
- else drawBars(chart, items, spec.unit, spec.max, spec.each);
393
+ else drawBars(chart, items, spec.unit, spec.max, spec.each, spec.ref && { ...spec.ref, label: esc(spec.ref.label) });
360
394
  };
361
395
  draw();
362
396
  return { chart, draw };
@@ -484,14 +518,26 @@ export function drawPanels(host, spec) {
484
518
  */
485
519
  export function drawHeatmap(host, spec) {
486
520
  const t = themeOf(host);
521
+ host.classList.add('heatmap');
487
522
  const width = Math.floor(host.clientWidth) || 640, phone = phoneOf(host) || width < 420;
488
523
  const cells = spec.rows.flatMap((r, i) => spec.columns.map((c, j) => ({ r: String(r), c: String(c), v: spec.values[i][j] })));
489
524
  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 ?? '');
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>`;
495
541
  const cols = spec.columns.map(String), rows = spec.rows.map(String);
496
542
  const labelW = Math.ceil(widest(host, rows, t.small)) + 12;
497
543
  const cellW = (width - labelW - 8) / cols.length;
@@ -502,7 +548,7 @@ export function drawHeatmap(host, spec) {
502
548
  const rowH = phone ? 30 : 36;
503
549
  const key = document.createElement('p');
504
550
  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>`
551
+ key.innerHTML = ramp
506
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>` : '');
507
553
  plotInto(host, t, {
508
554
  width, height: rows.length * rowH + 36, marginLeft: labelW, marginRight: 8, marginTop: 0, marginBottom: 36,
@@ -510,7 +556,7 @@ export function drawHeatmap(host, spec) {
510
556
  y: { domain: rows, label: null, tickSize: 0, padding: 0.08 },
511
557
  marks: [
512
558
  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,
559
+ show ? Plot.text(cells, { x: 'c', y: 'r', text: (d) => fmt(d.v), fill: (d) => textOn(d.v), fontSize: t.small }) : null,
514
560
  // The marked cell: an accent ring, the one thing in the chart that is not the scale.
515
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,
516
562
  ],
@@ -519,6 +565,101 @@ export function drawHeatmap(host, spec) {
519
565
  host.prepend(key);
520
566
  }
521
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
+
522
663
  // ---------- every value as a dot, two measures against each other, small counts as units ----------
523
664
 
524
665
  /**