@liggi/figkit 0.1.0 → 0.3.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 +96 -7
- package/README.md +3 -2
- package/bin/fig.mjs +14 -6
- package/dist/viz.iife.js +12 -12
- package/examples/charts/entities.json +17 -0
- package/examples/charts/groups.json +40 -0
- package/examples/charts/heatmap.json +231 -0
- package/examples/charts/lines.json +234 -0
- package/examples/charts/panels.json +62 -0
- package/examples/consolidate.html +10 -6
- package/package.json +1 -1
- package/runtime/fig.css +43 -5
- package/runtime/fig.js +174 -35
- package/viz/draw.js +253 -4
- package/viz/spec.js +167 -3
package/viz/draw.js
CHANGED
|
@@ -108,7 +108,7 @@ export function drawChange(host, spec) {
|
|
|
108
108
|
|
|
109
109
|
const themeOf = (host) => {
|
|
110
110
|
const css = (n) => getComputedStyle(host).getPropertyValue(n).trim();
|
|
111
|
-
return { css, ink: css('--ink'), muted: css('--muted'), soft: css('--soft'), line: css('--line'), accent: css('--accent'), second: css('--second'), good: css('--good'), bad: css('--bad'), small: parseFloat(css('--small')) || 15, body: parseFloat(css('--body')) || 16, sans: css('--sans') };
|
|
111
|
+
return { css, ink: css('--ink'), muted: css('--muted'), soft: css('--soft'), fill: css('--fill'), fill2: css('--fill2'), hair: css('--hair'), paper: css('--paper'), line: css('--line'), accent: css('--accent'), second: css('--second'), good: css('--good'), bad: css('--bad'), small: parseFloat(css('--small')) || 15, body: parseFloat(css('--body')) || 16, sans: css('--sans') };
|
|
112
112
|
};
|
|
113
113
|
const toX = (v) => (typeof v === 'number' ? v : new Date(v));
|
|
114
114
|
const num = (n) => (Math.abs(n) >= 100 ? Math.round(n) : Math.round(n * 10) / 10).toLocaleString('en-GB');
|
|
@@ -153,7 +153,7 @@ export function drawDumbbell(host, spec) {
|
|
|
153
153
|
marks: [
|
|
154
154
|
Plot.text(rows, { y: 'label', text: 'label', frameAnchor: 'left', dx: -(Math.ceil(labelW) + 28), textAnchor: 'start', fill: t.ink, fontSize: t.body }),
|
|
155
155
|
Plot.link(rows, { x1: 'a', x2: 'b', y1: 'label', y2: 'label', stroke: 'color', strokeWidth: 3 }),
|
|
156
|
-
Plot.dot(rows, { x: 'a', y: 'label', r: 6.5, fill: t.
|
|
156
|
+
Plot.dot(rows, { x: 'a', y: 'label', r: 6.5, fill: t.fill }),
|
|
157
157
|
// A fixed-size head at the end, pointing the way the row moved.
|
|
158
158
|
// A fixed-size head whose tip, not its middle, sits on the end value.
|
|
159
159
|
Plot.dot(rows.filter((r) => r.b >= r.a), { x: 'b', y: 'label', symbol: 'triangle2', r: 7.5, dx: -5, fill: 'color', rotate: 90 }),
|
|
@@ -165,6 +165,7 @@ export function drawDumbbell(host, spec) {
|
|
|
165
165
|
const key = document.createElement('p');
|
|
166
166
|
key.className = 'fd-key';
|
|
167
167
|
key.innerHTML = `<span class="fd-from"><i></i>${esc(spec.fromLabel)}</span><span class="fd-to${rows.every((r) => r.b < r.a) ? ' back' : ''}" style="color:${t.accent}"><i></i>${esc(spec.toLabel)}</span>`;
|
|
168
|
+
key.style.marginLeft = labelW + 'px';
|
|
168
169
|
host.prepend(key);
|
|
169
170
|
}
|
|
170
171
|
|
|
@@ -194,19 +195,107 @@ export function drawSeries(host, spec) {
|
|
|
194
195
|
y: { label: null, grid: true, ticks: 4, tickSize: 0, domain: zero ? [bottom, top * 1.04] : undefined, tickFormat: fmt },
|
|
195
196
|
marks: [
|
|
196
197
|
zero ? Plot.ruleY([0], { stroke: t.line }) : null,
|
|
197
|
-
Plot.lineY(pts, { x: 'x', y: 'y', stroke: t.
|
|
198
|
+
Plot.lineY(pts, { x: 'x', y: 'y', stroke: t.fill, strokeWidth: 2.5 }),
|
|
198
199
|
Plot.dot([mark], { x: 'x', y: 'y', r: 6.5, fill: t.accent }),
|
|
199
200
|
Plot.text([mark], { x: 'x', y: 'y', text: () => spec.mark.label, dx: right ? 14 : -14, dy, textAnchor: right ? 'start' : 'end', fill: t.accent, fontWeight: 600, fontSize: t.body }),
|
|
200
201
|
],
|
|
201
202
|
});
|
|
202
203
|
}
|
|
203
204
|
|
|
204
|
-
|
|
205
|
+
/**
|
|
206
|
+
* Where each record sits: every arrangement of the records over a few small grids is scored, and the best kept.
|
|
207
|
+
* A score counts references that cross, references that pass over another record, long references, and records drawn
|
|
208
|
+
* above what they point at (a reference reads upward, to the thing it names). A phone gets at most two columns.
|
|
209
|
+
*/
|
|
210
|
+
export function placeRecords(records, refs, phone) {
|
|
211
|
+
const n = records.length;
|
|
212
|
+
const shapes = [];
|
|
213
|
+
for (let rows = 1; rows <= 3; rows++) for (let cols = 1; cols <= (phone ? 2 : 3); cols++) {
|
|
214
|
+
if (rows * cols >= n && rows * cols - n < Math.min(rows, cols)) shapes.push([rows, cols]);
|
|
215
|
+
}
|
|
216
|
+
const idx = new Map(records.map((r, i) => [r.id, i]));
|
|
217
|
+
const edges = refs.map((r) => [idx.get(r.from), idx.get(r.to)]);
|
|
218
|
+
const cross = (a, b, c, d) => {
|
|
219
|
+
const o = (p, q, r) => Math.sign((q[0] - p[0]) * (r[1] - p[1]) - (q[1] - p[1]) * (r[0] - p[0]));
|
|
220
|
+
return o(a, b, c) * o(a, b, d) < 0 && o(c, d, a) * o(c, d, b) < 0;
|
|
221
|
+
};
|
|
222
|
+
// A record's cell is about 1.6 times as wide as it is tall, gaps included.
|
|
223
|
+
const pt = ([r, c]) => [c * 1.6, r];
|
|
224
|
+
const over = (a, b, cell) => {
|
|
225
|
+
const [x1, y1] = pt(a), [x2, y2] = pt(b), [cx, cy] = pt(cell);
|
|
226
|
+
const hw = 0.55, hh = 0.32;
|
|
227
|
+
for (let t = 0.04; t < 1; t += 0.04) { const x = x1 + (x2 - x1) * t, y = y1 + (y2 - y1) * t; if (Math.abs(x - cx) < hw && Math.abs(y - cy) < hh) return true; }
|
|
228
|
+
return false;
|
|
229
|
+
};
|
|
230
|
+
let best = null;
|
|
231
|
+
for (const [rows, cols] of shapes) {
|
|
232
|
+
const cells = [];
|
|
233
|
+
for (let r = 0; r < rows; r++) for (let c = 0; c < cols; c++) cells.push([r, c]);
|
|
234
|
+
const used = new Array(cells.length).fill(false), at = new Array(n);
|
|
235
|
+
const score = () => {
|
|
236
|
+
let s = rows * 0.6 + cols * 0.3;
|
|
237
|
+
const pos = at.map((k) => cells[k]);
|
|
238
|
+
edges.forEach(([f, t], i) => {
|
|
239
|
+
const a = pos[f], b = pos[t];
|
|
240
|
+
const [x1, y1] = pt(a), [x2, y2] = pt(b);
|
|
241
|
+
s += Math.hypot(x2 - x1, y2 - y1) * 1.2;
|
|
242
|
+
if (a[0] !== b[0] && a[1] !== b[1]) s += 1.5;
|
|
243
|
+
if (a[0] < b[0]) s += 3;
|
|
244
|
+
pos.forEach((p, k) => { if (k !== f && k !== t && over(a, b, p)) s += 12; });
|
|
245
|
+
for (const [g, u] of edges.slice(i + 1)) if (g !== f && g !== t && u !== f && u !== t && cross(pt(a), pt(b), pt(pos[g]), pt(pos[u]))) s += 8;
|
|
246
|
+
});
|
|
247
|
+
// An empty cell in the middle of a row reads as something missing.
|
|
248
|
+
for (let r = 0; r < rows; r++) for (let c = 1; c < cols - 1; c++) if (!pos.some((p) => p[0] === r && p[1] === c) && pos.some((p) => p[0] === r)) s += 4;
|
|
249
|
+
return s;
|
|
250
|
+
};
|
|
251
|
+
const walk = (i) => {
|
|
252
|
+
if (i === n) { const s = score(); if (!best || s < best.s - 1e-9) best = { s, rows, cols, pos: at.map((k) => cells[k]) }; return; }
|
|
253
|
+
for (let k = 0; k < cells.length; k++) if (!used[k]) { used[k] = true; at[i] = k; walk(i + 1); used[k] = false; }
|
|
254
|
+
};
|
|
255
|
+
walk(0);
|
|
256
|
+
}
|
|
257
|
+
return best;
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
/** Records and the references between them into the figure: boxes on a grid figkit chose, and <fig-arrow ref> lines. */
|
|
261
|
+
function drawEntities(spec, main) {
|
|
262
|
+
const phone = main.classList.contains('phone');
|
|
263
|
+
const { rows, cols, pos } = placeRecords(spec.records, spec.refs, phone);
|
|
264
|
+
const grid = document.createElement('div');
|
|
265
|
+
grid.className = 'entities';
|
|
266
|
+
grid.style.cssText = `grid-template-columns: repeat(${cols}, minmax(0, 1fr)); grid-template-rows: repeat(${rows}, auto);`;
|
|
267
|
+
spec.records.forEach((r, i) => {
|
|
268
|
+
const fields = r.fields ?? [];
|
|
269
|
+
const box = document.createElement('div');
|
|
270
|
+
box.className = 'node record' + (r.tone ? ' accent' : '');
|
|
271
|
+
box.id = 'rec-' + r.id;
|
|
272
|
+
box.style.gridArea = `${pos[i][0] + 1} / ${pos[i][1] + 1}`;
|
|
273
|
+
box.innerHTML = `<h3>${esc(r.name)}</h3>${fields.length ? `<ul>${fields.map((f) => `<li>${esc(f)}</li>`).join('')}</ul>` : ''}`;
|
|
274
|
+
grid.appendChild(box);
|
|
275
|
+
});
|
|
276
|
+
main.appendChild(grid);
|
|
277
|
+
for (const ref of spec.refs) {
|
|
278
|
+
const a = document.createElement('fig-arrow');
|
|
279
|
+
a.setAttribute('from', 'rec-' + ref.from);
|
|
280
|
+
a.setAttribute('to', 'rec-' + ref.to);
|
|
281
|
+
a.setAttribute('ref', ref.count);
|
|
282
|
+
if (ref.optional) a.setAttribute('dashed', '');
|
|
283
|
+
main.appendChild(a);
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
|
|
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' };
|
|
205
288
|
|
|
206
289
|
/** A checked spec drawn into a figure: header, chart and footer, as figkit's own markup. */
|
|
207
290
|
export function drawSpec(spec, main) {
|
|
208
291
|
const header = document.createElement('header');
|
|
209
292
|
header.innerHTML = `<h1>${esc(spec.title)}</h1>${spec.note ? `<p class="read">${esc(spec.note)}</p>` : ''}`;
|
|
293
|
+
if (spec.type === 'entities') {
|
|
294
|
+
main.append(header);
|
|
295
|
+
drawEntities(spec, main);
|
|
296
|
+
if (spec.source) { const footer = document.createElement('footer'); footer.innerHTML = `<p>${esc(spec.source)}</p>`; main.append(footer); }
|
|
297
|
+
return { chart: main.querySelector('.entities'), draw: () => {} };
|
|
298
|
+
}
|
|
210
299
|
const chart = document.createElement(HOSTS[spec.type]);
|
|
211
300
|
main.append(header, chart);
|
|
212
301
|
if (spec.source) {
|
|
@@ -218,6 +307,10 @@ export function drawSpec(spec, main) {
|
|
|
218
307
|
if (spec.type === 'before-after') return drawChange(chart, spec);
|
|
219
308
|
if (spec.type === 'dumbbell') return drawDumbbell(chart, spec);
|
|
220
309
|
if (spec.type === 'series') return drawSeries(chart, spec);
|
|
310
|
+
if (spec.type === 'groups') return drawGroups(chart, spec, { unit: spec.unit, max: spec.max });
|
|
311
|
+
if (spec.type === 'lines') return drawLines(chart, spec, { unit: spec.unit, xTicks: spec.xTicks });
|
|
312
|
+
if (spec.type === 'panels') return drawPanels(chart, spec);
|
|
313
|
+
if (spec.type === 'heatmap') return drawHeatmap(chart, spec);
|
|
221
314
|
const items = (spec.parts ?? spec.bars).map((x) => ({ ...x, text: String(x.value), label: esc(x.label) }));
|
|
222
315
|
if (spec.type === 'split') drawSplit(chart, items, spec.unit);
|
|
223
316
|
else drawBars(chart, items, spec.unit, spec.max);
|
|
@@ -225,3 +318,159 @@ export function drawSpec(spec, main) {
|
|
|
225
318
|
draw();
|
|
226
319
|
return { chart, draw };
|
|
227
320
|
}
|
|
321
|
+
|
|
322
|
+
// ---------- grouped bars, several lines, panels, heat maps ----------
|
|
323
|
+
|
|
324
|
+
const hex = (c) => { const m = /^#?([0-9a-f]{6})$/i.exec(c.trim()); return m ? [0, 2, 4].map((i) => parseInt(m[1].slice(i, i + 2), 16)) : null; };
|
|
325
|
+
/** a mixed with b, p of the way to b, as #rrggbb; a when either is not a plain hex colour. */
|
|
326
|
+
function mix(a, b, p) {
|
|
327
|
+
const x = hex(a), y = hex(b);
|
|
328
|
+
if (!x || !y) return a;
|
|
329
|
+
return '#' + x.map((v, i) => Math.round(v + (y[i] - v) * p).toString(16).padStart(2, '0')).join('');
|
|
330
|
+
}
|
|
331
|
+
const phoneOf = (host) => !!host.closest('.phone');
|
|
332
|
+
|
|
333
|
+
/**
|
|
334
|
+
* Bars in groups into a .chart: a heading per group, a row per series labelled with its name, so no legend is needed.
|
|
335
|
+
* opts.max fixes the end of the axis (panels share one), opts.unit follows each number.
|
|
336
|
+
*/
|
|
337
|
+
export function drawGroups(host, data, opts = {}) {
|
|
338
|
+
const t = themeOf(host);
|
|
339
|
+
const width = Math.floor(host.clientWidth) || 640, phone = phoneOf(host) || width < 420;
|
|
340
|
+
const { series, groups, accent } = data;
|
|
341
|
+
const tone = t[accent.tone ?? 'accent'];
|
|
342
|
+
const rows = groups.flatMap((g) => series.map((s, i) => ({ g: g.label, s, v: g.values[i], hi: (accent.series === undefined || accent.series === s) && (accent.group === undefined || accent.group === g.label) })));
|
|
343
|
+
const fmt = (v) => num(v) + (opts.unit ?? '');
|
|
344
|
+
const top = opts.max ?? Math.max(...rows.map((r) => r.v));
|
|
345
|
+
const indent = 0;
|
|
346
|
+
const labelW = Math.ceil(Math.max(widest(host, series, t.body) + indent, widest(host, groups.map((g) => g.label), t.body, 600))) + 14;
|
|
347
|
+
const valueW = Math.ceil(widest(host, rows.map((r) => fmt(r.v)), t.body, 600)) + 12;
|
|
348
|
+
// A group's heading sits close above its own bars, with more room above it, so the groups read as blocks.
|
|
349
|
+
const rowH = Math.max(phone ? 34 : 38, Math.ceil(t.body * 1.75)), headH = Math.max(30, Math.ceil(t.body * 1.7)), above = 22;
|
|
350
|
+
const step = series.length * rowH + headH + above;
|
|
351
|
+
plotInto(host, t, {
|
|
352
|
+
width, height: groups.length * step - above + 8, marginLeft: labelW, marginRight: valueW, marginTop: headH, marginBottom: 8,
|
|
353
|
+
// Every bar carries its number, so an axis would only repeat it; the zero line is kept.
|
|
354
|
+
x: { domain: [0, top], axis: null },
|
|
355
|
+
y: { domain: series, axis: null, padding: 0.18 },
|
|
356
|
+
fy: { domain: groups.map((g) => g.label), axis: null, paddingInner: (headH + above) / step, paddingOuter: 0 },
|
|
357
|
+
marks: [
|
|
358
|
+
Plot.ruleX([0], { stroke: t.line }),
|
|
359
|
+
Plot.barX(rows, { x: 'v', y: 's', fy: 'g', fill: (r) => (r.hi ? tone : t.fill), rx: 2 }),
|
|
360
|
+
Plot.text(rows, { x: 'v', y: 's', fy: 'g', text: (r) => fmt(r.v), dx: 6, textAnchor: 'start', fill: (r) => (r.hi ? tone : t.ink), fontWeight: (r) => (r.hi ? 600 : 400), fontSize: t.body }),
|
|
361
|
+
Plot.text(rows, { x: 0, y: 's', fy: 'g', text: 's', dx: -labelW + indent, textAnchor: 'start', fill: t.ink, fontSize: t.body }),
|
|
362
|
+
Plot.text(groups.map((g) => g.label), { fy: (d) => d, frameAnchor: 'top-left', dx: -labelW, dy: -headH + 2, text: (d) => d, textAnchor: 'start', lineAnchor: 'top', fill: opts.quietGroups ? t.muted : t.ink, fontWeight: 600, fontSize: t.body * 1.06 }),
|
|
363
|
+
],
|
|
364
|
+
});
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
/**
|
|
368
|
+
* Two or three lines on one axis into a .chart. Each is named at its right end in its own colour; the accent line is
|
|
369
|
+
* drawn last and thicker, the others in two greys. opts.domain fixes the y axis (panels share one).
|
|
370
|
+
*/
|
|
371
|
+
export function drawLines(host, data, opts = {}) {
|
|
372
|
+
const t = themeOf(host);
|
|
373
|
+
const width = Math.floor(host.clientWidth) || 640, phone = phoneOf(host) || width < 420;
|
|
374
|
+
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 })) }));
|
|
376
|
+
const others = lines.filter((l) => l.label !== data.accent);
|
|
377
|
+
others.forEach((l, i) => { l.color = greys[i % 2]; l.w = 2.25; });
|
|
378
|
+
const hi = lines.find((l) => l.label === data.accent);
|
|
379
|
+
hi.color = t.accent; hi.w = 3.25;
|
|
380
|
+
const ys = lines.flatMap((l) => l.pts.map((p) => p.y));
|
|
381
|
+
const zero = data.zero ?? true;
|
|
382
|
+
const domain = opts.domain ?? [zero ? Math.min(0, ...ys) : Math.min(...ys), Math.max(...ys) * 1.04];
|
|
383
|
+
const fmt = (d) => num(d) + (opts.unit ?? '');
|
|
384
|
+
const height = phone ? 260 : 300, mt = data.mark ? 28 : 10, mb = 36;
|
|
385
|
+
const ends = [...others, hi].map((l) => ({ l, last: l.pts[l.pts.length - 1] }));
|
|
386
|
+
const labelW = Math.ceil(widest(host, ends.map((e) => e.l.label), t.small, 600)) + 14;
|
|
387
|
+
// Each name sits level with its line's last point, pushed apart when two would overlap.
|
|
388
|
+
const py = (v) => mt + (1 - (v - domain[0]) / (domain[1] - domain[0])) * (height - mt - mb);
|
|
389
|
+
const placed = ends.map((e) => ({ ...e, y: py(e.last.y) })).sort((a, b) => a.y - b.y);
|
|
390
|
+
const gap = t.small * 1.3;
|
|
391
|
+
for (let i = 1; i < placed.length; i++) placed[i].y = Math.max(placed[i].y, placed[i - 1].y + gap);
|
|
392
|
+
for (let i = placed.length - 2; i >= 0; i--) placed[i].y = Math.min(placed[i].y, placed[i + 1].y - gap);
|
|
393
|
+
const tickW = Math.ceil(widest(host, [fmt(domain[1]), fmt(domain[0])], t.small)) + 12;
|
|
394
|
+
const markPt = data.mark && hi.pts.find((p) => +p.x === +toX(data.mark.x));
|
|
395
|
+
plotInto(host, t, {
|
|
396
|
+
width, height, marginLeft: tickW, marginRight: labelW, marginTop: mt, marginBottom: mb,
|
|
397
|
+
x: opts.xTicks ? { label: null, insetLeft: 18, ticks: opts.xTicks.map((k) => toX(k.x)), tickFormat: (d) => opts.xTicks.find((k) => +toX(k.x) === +d)?.label ?? '' }
|
|
398
|
+
: { label: null, insetLeft: 18, ticks: width < 520 ? 3 : 5, tickFormat: typeof lines[0].pts[0].x === 'number' ? (d) => num(d) : undefined },
|
|
399
|
+
y: { domain, nice: false, label: null, grid: true, ticks: 4, tickSize: 0, tickFormat: fmt },
|
|
400
|
+
marks: [
|
|
401
|
+
zero ? Plot.ruleY([0], { stroke: t.line }) : null,
|
|
402
|
+
...[...others, hi].map((l) => Plot.lineY(l.pts, { x: 'x', y: 'y', stroke: l.color, strokeWidth: l.w })),
|
|
403
|
+
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
|
+
markPt ? Plot.dot([markPt], { x: 'x', y: 'y', r: 6, fill: t.accent }) : null,
|
|
405
|
+
markPt ? Plot.text([markPt], { x: 'x', y: 'y', text: () => data.mark.label, dy: -16, fill: t.accent, fontWeight: 600, fontSize: t.small }) : null,
|
|
406
|
+
],
|
|
407
|
+
});
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
/** The same chart for each part, side by side on one scale; stacked on a phone. */
|
|
411
|
+
export function drawPanels(host, spec) {
|
|
412
|
+
host.className = 'panels';
|
|
413
|
+
host.innerHTML = '';
|
|
414
|
+
const charts = spec.panels.map((p) => {
|
|
415
|
+
const box = document.createElement('div');
|
|
416
|
+
box.className = 'panel';
|
|
417
|
+
box.innerHTML = `<h2>${esc(p.label)}</h2>`;
|
|
418
|
+
const chart = document.createElement('div');
|
|
419
|
+
box.appendChild(chart);
|
|
420
|
+
host.appendChild(box);
|
|
421
|
+
return { p, chart };
|
|
422
|
+
});
|
|
423
|
+
if (spec.chart === 'groups') {
|
|
424
|
+
const max = spec.max ?? Math.max(...spec.panels.flatMap((p) => p.groups.flatMap((g) => g.values)));
|
|
425
|
+
for (const { p, chart } of charts) {
|
|
426
|
+
const accent = spec.accent.group && !p.groups.some((g) => g.label === spec.accent.group) ? { ...spec.accent, series: '\u0000' } : spec.accent;
|
|
427
|
+
drawGroups(chart, { series: spec.series, groups: p.groups, accent }, { unit: spec.unit, max, quietGroups: true });
|
|
428
|
+
}
|
|
429
|
+
} else {
|
|
430
|
+
const ys = spec.panels.flatMap((p) => p.series.flatMap((l) => l.points.map((q) => q.y)));
|
|
431
|
+
const zero = spec.zero ?? true;
|
|
432
|
+
const domain = [zero ? Math.min(0, ...ys) : Math.min(...ys), Math.max(...ys) * 1.04];
|
|
433
|
+
for (const { p, chart } of charts) drawLines(chart, { series: p.series, accent: spec.accent, zero }, { unit: spec.unit, domain, xTicks: spec.xTicks });
|
|
434
|
+
}
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
/**
|
|
438
|
+
* A heat map into a .chart: the colour runs from pale to the accent, one hue, so darker is always more. The scale is
|
|
439
|
+
* drawn above the grid at readable size. A marked cell is outlined and named in the scale line.
|
|
440
|
+
*/
|
|
441
|
+
export function drawHeatmap(host, spec) {
|
|
442
|
+
const t = themeOf(host);
|
|
443
|
+
const width = Math.floor(host.clientWidth) || 640, phone = phoneOf(host) || width < 420;
|
|
444
|
+
const cells = spec.rows.flatMap((r, i) => spec.columns.map((c, j) => ({ r: String(r), c: String(c), v: spec.values[i][j] })));
|
|
445
|
+
const vs = cells.map((d) => d.v), lo = Math.min(...vs), hi = Math.max(...vs);
|
|
446
|
+
// One hue, near white to dark: darker is always more, and busy does not read as alarm the way red does.
|
|
447
|
+
const scale = Plot.scale({ color: { type: 'linear', scheme: 'blues', domain: [lo - (hi - lo) * 0.08, hi] } });
|
|
448
|
+
const colour = (v) => scale.apply(v);
|
|
449
|
+
const ramp = [0, 0.25, 0.5, 0.75, 1].map((p) => colour(lo + (hi - lo) * p)).join(', ');
|
|
450
|
+
const fmt = (v) => num(v) + (spec.unit ?? '');
|
|
451
|
+
const cols = spec.columns.map(String), rows = spec.rows.map(String);
|
|
452
|
+
const labelW = Math.ceil(widest(host, rows, t.small)) + 12;
|
|
453
|
+
const cellW = (width - labelW - 8) / cols.length;
|
|
454
|
+
const show = cellW >= 48 && cells.length <= 72;
|
|
455
|
+
// Label every k-th column, k a divisor of the count where one exists, so 24 hours read 12am, 6am, 12pm, 6pm.
|
|
456
|
+
const most = phone ? 5 : 9, steps = [1, 2, 3, 4, 5, 6, 8, 10, 12].filter((k) => cols.length / k <= most);
|
|
457
|
+
const every = steps.find((k) => cols.length % k === 0) ?? steps[0] ?? Math.ceil(cols.length / most);
|
|
458
|
+
const rowH = phone ? 30 : 36;
|
|
459
|
+
const key = document.createElement('p');
|
|
460
|
+
key.className = 'hm-key';
|
|
461
|
+
key.innerHTML = `<span>${esc(fmt(lo))}</span><i style="background:linear-gradient(90deg, ${ramp})"></i><span>${esc(fmt(hi))}</span>`
|
|
462
|
+
+ (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
|
+
plotInto(host, t, {
|
|
464
|
+
width, height: rows.length * rowH + 36, marginLeft: labelW, marginRight: 8, marginTop: 0, marginBottom: 36,
|
|
465
|
+
x: { domain: cols, label: null, tickSize: 0, tickFormat: (d, i) => (i % every === 0 ? d : '') , padding: 0.06 },
|
|
466
|
+
y: { domain: rows, label: null, tickSize: 0, padding: 0.08 },
|
|
467
|
+
marks: [
|
|
468
|
+
Plot.cell(cells, { x: 'c', y: 'r', fill: (d) => colour(d.v), inset: 0.5 }),
|
|
469
|
+
show ? Plot.text(cells, { x: 'c', y: 'r', text: (d) => fmt(d.v), fill: (d) => ((d.v - lo) / (hi - lo || 1) > 0.55 ? '#ffffff' : t.ink), fontSize: t.small }) : null,
|
|
470
|
+
// The marked cell: an accent ring, the one thing in the chart that is not the scale.
|
|
471
|
+
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
|
+
],
|
|
473
|
+
});
|
|
474
|
+
key.style.marginLeft = labelW + 'px';
|
|
475
|
+
host.prepend(key);
|
|
476
|
+
}
|
package/viz/spec.js
CHANGED
|
@@ -10,6 +10,7 @@ const common = {
|
|
|
10
10
|
note: z.string().trim().max(160, 'keep the note to 160 characters').optional(),
|
|
11
11
|
unit: z.string().max(8).optional(),
|
|
12
12
|
source: z.string().trim().max(120).optional(),
|
|
13
|
+
style: z.enum(['report'], { error: 'style: "report" for a light chart that sits in a page, or leave it out' }).optional(),
|
|
13
14
|
};
|
|
14
15
|
const value = z.number({ error: 'must be a number' }).finite();
|
|
15
16
|
|
|
@@ -84,14 +85,159 @@ export const SeriesSpec = z.object({
|
|
|
84
85
|
zero: z.boolean().optional(),
|
|
85
86
|
}).strict();
|
|
86
87
|
|
|
87
|
-
export const Spec = z.discriminatedUnion('type', [SplitSpec, BarsSpec, BeforeAfterSpec, DumbbellSpec, SeriesSpec]);
|
|
88
|
-
export const TYPES = { split: SplitSpec, bars: BarsSpec, 'before-after': BeforeAfterSpec, dumbbell: DumbbellSpec, series: SeriesSpec };
|
|
89
88
|
|
|
90
|
-
const
|
|
89
|
+
const seriesName = label.max(28, 'keep series names to 28 characters');
|
|
90
|
+
const TONE_ONE = z.enum(['accent', 'good', 'bad'], { error: 'tone: "accent", or "good" or "bad" when it is a result' });
|
|
91
|
+
|
|
92
|
+
/** Bars in groups: each group a few bars, one per series, read across the groups. */
|
|
93
|
+
const groupRow = z.object({
|
|
94
|
+
label: label.max(32, 'keep group labels to 32 characters'),
|
|
95
|
+
values: z.array(value.nonnegative('must be 0 or more: bars start at zero')),
|
|
96
|
+
}).strict();
|
|
97
|
+
const groupsData = {
|
|
98
|
+
series: z.array(seriesName).min(2, 'at least 2 series; one bar per group is a "bars" chart').max(4, 'at most 4 series; more is too many bars to read'),
|
|
99
|
+
groups: z.array(groupRow).min(2, 'at least 2 groups; one group is a "bars" chart').max(6, 'at most 6 groups'),
|
|
100
|
+
accent: z.object({
|
|
101
|
+
series: z.string().optional(),
|
|
102
|
+
group: z.string().optional(),
|
|
103
|
+
tone: TONE_ONE.optional(),
|
|
104
|
+
}, { error: 'required: what to notice, { "series" } for a whole series, or { "series", "group" } for one bar' }).strict(),
|
|
105
|
+
max: z.number().positive().optional(),
|
|
106
|
+
};
|
|
107
|
+
export const GroupsSpec = z.object({ type: z.literal('groups'), ...common, ...groupsData }).strict();
|
|
108
|
+
|
|
109
|
+
/** Two or three lines on one axis, each named at its end; one is the line to notice. */
|
|
110
|
+
const linePoint = z.object({ x: point, y: value }).strict();
|
|
111
|
+
const linesData = {
|
|
112
|
+
series: z.array(z.object({
|
|
113
|
+
label: seriesName,
|
|
114
|
+
points: z.array(linePoint).min(3, 'a line needs at least 3 points').max(2000),
|
|
115
|
+
}).strict()).min(2, 'at least 2 lines; one line is a "series" chart').max(3, 'at most 3 lines on one axis; more is small multiples ("panels")'),
|
|
116
|
+
accent: z.string({ error: 'required: the label of the line to notice' }),
|
|
117
|
+
mark: z.object({ x: point, label: z.string().trim().min(1).max(32, 'keep the mark label to 32 characters') }).strict().optional(),
|
|
118
|
+
zero: z.boolean().optional(),
|
|
119
|
+
xTicks: z.array(z.object({ x: point, label: z.string().trim().min(1).max(12, 'keep tick labels to 12 characters') }).strict()).min(2).max(8, 'at most 8 ticks').optional(),
|
|
120
|
+
};
|
|
121
|
+
export const LinesSpec = z.object({ type: z.literal('lines'), ...common, ...linesData }).strict();
|
|
122
|
+
|
|
123
|
+
/** The same chart drawn once per part, side by side on one scale. */
|
|
124
|
+
export const PanelsSpec = z.object({
|
|
125
|
+
type: z.literal('panels'),
|
|
126
|
+
...common,
|
|
127
|
+
chart: z.enum(['groups', 'lines'], { error: 'chart: "groups" or "lines", the chart each panel draws' }),
|
|
128
|
+
series: groupsData.series.optional(),
|
|
129
|
+
accent: z.union([z.string(), groupsData.accent]),
|
|
130
|
+
max: z.number().positive().optional(),
|
|
131
|
+
zero: z.boolean().optional(),
|
|
132
|
+
xTicks: linesData.xTicks,
|
|
133
|
+
panels: z.array(z.object({
|
|
134
|
+
label: label.max(32, 'keep panel labels to 32 characters'),
|
|
135
|
+
groups: z.array(groupRow).min(1).max(6, 'at most 6 groups').optional(),
|
|
136
|
+
series: linesData.series.optional(),
|
|
137
|
+
}).strict()).min(2, 'at least 2 panels').max(4, 'at most 4 panels; more is two figures'),
|
|
138
|
+
}).strict();
|
|
139
|
+
|
|
140
|
+
/** A grid of values coloured from pale to strong: where something is high or low across two dimensions. */
|
|
141
|
+
const axisLabel = z.union([z.string().trim().min(1).max(16, 'keep row and column labels to 16 characters'), z.number().finite()]);
|
|
142
|
+
export const HeatmapSpec = z.object({
|
|
143
|
+
type: z.literal('heatmap'),
|
|
144
|
+
...common,
|
|
145
|
+
note: z.string({ error: 'required: what the colour counts, e.g. "People active per hour"' }).trim().min(1).max(160, 'keep the note to 160 characters'),
|
|
146
|
+
rows: z.array(axisLabel).min(2).max(12, 'at most 12 rows'),
|
|
147
|
+
columns: z.array(axisLabel).min(2).max(31, 'at most 31 columns'),
|
|
148
|
+
values: z.array(z.array(value)),
|
|
149
|
+
mark: z.object({ row: axisLabel, column: axisLabel, label: z.string().trim().min(1).max(32, 'keep the mark label to 32 characters') }).strict().optional(),
|
|
150
|
+
}).strict();
|
|
151
|
+
|
|
152
|
+
const slug = z.string({ error: 'needs an id, e.g. "order"' }).regex(/^[a-z][a-z0-9-]{0,23}$/, 'ids are short lowercase words, e.g. "order" or "order-line"');
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* What the things are and which points at which: records, and references from one record to another. A reference is
|
|
156
|
+
* a pointer one record holds (a foreign key, an id field), never a step in the order work happens.
|
|
157
|
+
*/
|
|
158
|
+
export const EntitiesSpec = z.object({
|
|
159
|
+
type: z.literal('entities'),
|
|
160
|
+
title,
|
|
161
|
+
note: common.note,
|
|
162
|
+
source: common.source,
|
|
163
|
+
style: common.style,
|
|
164
|
+
records: z.array(z.object({
|
|
165
|
+
id: slug,
|
|
166
|
+
name: label.max(32, 'keep record names to 32 characters'),
|
|
167
|
+
table: z.never({ error: 'no table: name the record in words; if the table name matters to the reader, put it in fields' }).optional(),
|
|
168
|
+
fields: z.array(z.string().trim().min(1).max(36, 'keep each field to 36 characters')).max(4, 'at most 4 fields: the ones that explain the picture').optional(),
|
|
169
|
+
tone: z.literal('accent', { error: 'tone: only "accent", on the one record to notice' }).optional(),
|
|
170
|
+
}).strict()).min(2, 'at least 2 records').max(5, 'at most 5 records; more is two figures (the core, then one corner of it), not one'),
|
|
171
|
+
refs: z.array(z.object({
|
|
172
|
+
from: slug,
|
|
173
|
+
to: slug,
|
|
174
|
+
field: z.never({ error: 'no field: the line is the reference, so its column is not written out' }).optional(),
|
|
175
|
+
count: z.enum(['many', 'one'], { error: 'count: "many" or "one": how many "from" records can point at one "to" record' }).default('many'),
|
|
176
|
+
optional: z.boolean().optional(),
|
|
177
|
+
}).strict()).min(1, 'at least 1 reference').max(8, 'at most 8 references; more is two figures, not a reference left out'),
|
|
178
|
+
}).strict();
|
|
179
|
+
|
|
180
|
+
export const Spec = z.discriminatedUnion('type', [SplitSpec, BarsSpec, BeforeAfterSpec, DumbbellSpec, SeriesSpec, EntitiesSpec, GroupsSpec, LinesSpec, PanelsSpec, HeatmapSpec]);
|
|
181
|
+
export const TYPES = { split: SplitSpec, bars: BarsSpec, 'before-after': BeforeAfterSpec, dumbbell: DumbbellSpec, series: SeriesSpec, entities: EntitiesSpec, groups: GroupsSpec, lines: LinesSpec, panels: PanelsSpec, heatmap: HeatmapSpec };
|
|
182
|
+
|
|
183
|
+
const items = (s) => s.parts ?? s.bars ?? s.rows ?? s.records ?? (s.after ? [s.after] : []);
|
|
91
184
|
const kind = (v) => (typeof v === 'number' ? 'number' : 'date');
|
|
92
185
|
|
|
186
|
+
|
|
187
|
+
function groupsChecks(s, at = '') {
|
|
188
|
+
const out = [];
|
|
189
|
+
s.groups.forEach((g, i) => { if (g.values.length !== s.series.length) out.push(`${at}groups[${i}].values: one value per series, ${s.series.length}, not ${g.values.length}`); });
|
|
190
|
+
if (s.accent.series !== undefined && !s.series.includes(s.accent.series)) out.push(`accent.series: must be one of ${s.series.map((x) => `"${x}"`).join(', ')}`);
|
|
191
|
+
if (s.accent.group !== undefined && !s.groups.some((g) => g.label === s.accent.group)) out.push(`${at}accent.group: must be the label of one of the groups`);
|
|
192
|
+
if (s.accent.series === undefined && s.accent.group === undefined) out.push('accent: name the series to notice, or a series and a group for one bar');
|
|
193
|
+
if (s.max !== undefined && s.groups.some((g) => g.values.some((v) => v > s.max))) out.push('max: every value must be at most max');
|
|
194
|
+
return out;
|
|
195
|
+
}
|
|
196
|
+
function linesChecks(s, at = '') {
|
|
197
|
+
const out = [];
|
|
198
|
+
const xs = s.series.flatMap((l) => l.points.map((p) => p.x));
|
|
199
|
+
if (new Set(xs.map(kind)).size > 1) out.push(`${at}series: x values must be all numbers or all dates, not both`);
|
|
200
|
+
const t = (x) => (typeof x === 'number' ? x : Date.parse(x));
|
|
201
|
+
s.series.forEach((l, i) => { for (let j = 1; j < l.points.length; j++) if (t(l.points[j].x) <= t(l.points[j - 1].x)) { out.push(`${at}series[${i}].points[${j}].x: points must be in order of x`); break; } });
|
|
202
|
+
const names = s.series.map((l) => l.label);
|
|
203
|
+
if (new Set(names).size < names.length) out.push(`${at}series: two lines have the same label`);
|
|
204
|
+
if (!names.includes(s.accent)) out.push(`accent: must be the label of one of the lines, ${names.map((x) => `"${x}"`).join(', ')}`);
|
|
205
|
+
if (s.mark) { const line = s.series.find((l) => l.label === s.accent); if (line && !line.points.some((p) => t(p.x) === t(s.mark.x))) out.push(`mark.x: must be the x of one of the points on the "${s.accent}" line`); }
|
|
206
|
+
return out;
|
|
207
|
+
}
|
|
208
|
+
|
|
93
209
|
/** Rules that span fields: x values of one kind, in order, and a mark that is one of the points. */
|
|
94
210
|
function crossChecks(s) {
|
|
211
|
+
if (s.type === 'groups') return groupsChecks(s);
|
|
212
|
+
if (s.type === 'lines') return linesChecks(s);
|
|
213
|
+
if (s.type === 'panels') {
|
|
214
|
+
const out = [];
|
|
215
|
+
s.panels.forEach((p, i) => {
|
|
216
|
+
const at = `panels[${i}].`;
|
|
217
|
+
if (s.chart === 'groups') {
|
|
218
|
+
if (!p.groups || p.series) { out.push(`${at}: a groups panel has "groups" and no "series"`); return; }
|
|
219
|
+
if (!s.series) { out.push('series: a groups chart names its series once, at the top'); return; }
|
|
220
|
+
if (typeof s.accent !== 'object') { out.push('accent: { "series" } or { "series", "group" } for a groups chart'); return; }
|
|
221
|
+
out.push(...groupsChecks({ series: s.series, groups: p.groups, accent: { ...s.accent, group: undefined }, max: s.max }, at));
|
|
222
|
+
if (s.accent.group !== undefined && !s.panels.some((q) => q.groups?.some((g) => g.label === s.accent.group))) out.push('accent.group: must be the label of a group in a panel');
|
|
223
|
+
} else {
|
|
224
|
+
if (!p.series || p.groups) { out.push(`${at}: a lines panel has "series" and no "groups"`); return; }
|
|
225
|
+
if (typeof s.accent !== 'string') { out.push('accent: the label of the line to notice, the same in every panel'); return; }
|
|
226
|
+
out.push(...linesChecks({ series: p.series, accent: s.accent }, at));
|
|
227
|
+
const names = (q) => q.series.map((l) => l.label).join('|');
|
|
228
|
+
if (names(p) !== names(s.panels[0])) out.push(`${at}series: every panel has the same lines, in the same order, so a line is one colour throughout`);
|
|
229
|
+
}
|
|
230
|
+
});
|
|
231
|
+
return [...new Set(out)];
|
|
232
|
+
}
|
|
233
|
+
if (s.type === 'heatmap') {
|
|
234
|
+
const out = [];
|
|
235
|
+
if (s.values.length !== s.rows.length) out.push(`values: one row of values per row label, ${s.rows.length}, not ${s.values.length}`);
|
|
236
|
+
s.values.forEach((r, i) => { if (r.length !== s.columns.length) out.push(`values[${i}]: one value per column, ${s.columns.length}, not ${r.length}`); });
|
|
237
|
+
if (s.mark && !s.rows.includes(s.mark.row)) out.push('mark.row: must be one of the row labels');
|
|
238
|
+
if (s.mark && !s.columns.includes(s.mark.column)) out.push('mark.column: must be one of the column labels');
|
|
239
|
+
return out;
|
|
240
|
+
}
|
|
95
241
|
if (s.type === 'dumbbell') {
|
|
96
242
|
const kinds = new Set(s.rows.flatMap((r) => [kind(r.from), kind(r.to)]));
|
|
97
243
|
if (kinds.size > 1) return ['rows: use all numbers or all dates, not both'];
|
|
@@ -103,6 +249,24 @@ function crossChecks(s) {
|
|
|
103
249
|
for (let i = 1; i < s.points.length; i++) if (at(s.points[i].x) <= at(s.points[i - 1].x)) return [`points[${i}].x: points must be in order of x, each later than the one before`];
|
|
104
250
|
if (!s.points.some((p) => at(p.x) === at(s.mark.x))) return [`mark.x: must be the x of one of the points, not ${JSON.stringify(s.mark.x)}`];
|
|
105
251
|
}
|
|
252
|
+
if (s.type === 'entities') {
|
|
253
|
+
const ids = s.records.map((r) => r.id);
|
|
254
|
+
const dup = ids.find((id, i) => ids.indexOf(id) !== i);
|
|
255
|
+
if (dup) return [`records: two records have the id "${dup}"`];
|
|
256
|
+
const out = [];
|
|
257
|
+
const pairs = new Set();
|
|
258
|
+
s.refs.forEach((r, i) => {
|
|
259
|
+
for (const end of ['from', 'to']) if (!ids.includes(r[end])) out.push(`refs[${i}].${end}: no record has the id "${r[end]}"; the ids are ${ids.map((x) => `"${x}"`).join(', ')}`);
|
|
260
|
+
if (r.from === r.to) out.push(`refs[${i}]: a record pointing at itself; say it in its fields instead`);
|
|
261
|
+
const key = r.from + '>' + r.to;
|
|
262
|
+
if (pairs.has(key)) out.push(`refs[${i}]: a second reference from "${r.from}" to "${r.to}"; make it one reference`);
|
|
263
|
+
pairs.add(key);
|
|
264
|
+
});
|
|
265
|
+
if (s.records.filter((r) => r.tone).length > 1) out.push('records: accent at most one record, the one to notice');
|
|
266
|
+
const loose = ids.filter((id) => !s.refs.some((r) => r.from === id || r.to === id));
|
|
267
|
+
if (loose.length) out.push(`records: ${loose.map((x) => `"${x}"`).join(', ')} ${loose.length === 1 ? 'is' : 'are'} not in any reference; leave ${loose.length === 1 ? 'it' : 'them'} out or add the reference`);
|
|
268
|
+
return out;
|
|
269
|
+
}
|
|
106
270
|
return [];
|
|
107
271
|
}
|
|
108
272
|
|