@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/GUIDE.md +194 -8
- package/README.md +4 -4
- package/bin/fig.mjs +3 -3
- package/dist/viz.iife.js +81 -18
- package/examples/charts/bars-range.json +6 -0
- package/examples/charts/bars-ref.json +6 -0
- package/examples/charts/dots.json +6 -0
- package/examples/charts/estimates.json +7 -0
- package/examples/charts/heatmap-signed.json +5 -0
- package/examples/charts/scatter.json +6 -0
- package/examples/charts/sequence.json +67 -0
- package/examples/charts/series-range.json +6 -0
- package/examples/charts/states.json +71 -0
- package/examples/charts/units.json +4 -0
- package/package.json +1 -1
- package/runtime/fig.css +95 -0
- package/runtime/fig.js +80 -3
- package/viz/build.mjs +1 -1
- package/viz/draw.js +437 -25
- package/viz/sequence-spec.js +60 -0
- package/viz/sequence.js +171 -0
- package/viz/spec.js +201 -10
- package/viz/states-spec.js +161 -0
- package/viz/states.js +203 -0
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) => ({ '&': '&', '<': '<', '>': '>', '"': '"' })[c]);
|
|
5
7
|
|
|
@@ -79,21 +81,63 @@ export function drawSplit(host, parts, unit) {
|
|
|
79
81
|
}
|
|
80
82
|
}
|
|
81
83
|
|
|
82
|
-
/**
|
|
83
|
-
|
|
84
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
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.
|
|
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
|
|
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.
|
|
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.
|
|
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
|
-
//
|
|
447
|
-
|
|
448
|
-
const
|
|
449
|
-
const
|
|
450
|
-
const fmt = (v) =>
|
|
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 =
|
|
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) => (
|
|
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
|
+
}
|