@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.
- package/GUIDE.md +104 -5
- package/README.md +4 -4
- package/bin/fig.mjs +3 -3
- package/dist/viz.iife.js +14 -14
- package/examples/charts/bars-range.json +6 -0
- package/examples/charts/dots.json +6 -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/units.json +4 -0
- package/package.json +1 -1
- package/runtime/fig.css +56 -0
- package/runtime/fig.js +7 -0
- package/viz/build.mjs +1 -1
- package/viz/draw.js +289 -18
- package/viz/sequence-spec.js +60 -0
- package/viz/sequence.js +171 -0
- package/viz/spec.js +145 -7
|
@@ -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
|
+
"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
|
|
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) => ({ '&': '&', '<': '<', '>': '>', '"': '"' })[c]);
|
|
5
6
|
|
|
@@ -79,21 +80,44 @@ export function drawSplit(host, parts, unit) {
|
|
|
79
80
|
}
|
|
80
81
|
}
|
|
81
82
|
|
|
82
|
-
/**
|
|
83
|
-
|
|
84
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
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.
|
|
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
|
|
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.
|
|
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.
|
|
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
|
+
}
|