@liggi/figkit 0.2.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 +62 -3
- package/README.md +3 -2
- package/bin/fig.mjs +14 -6
- package/dist/viz.iife.js +12 -12
- 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/package.json +1 -1
- package/runtime/fig.css +36 -4
- package/runtime/fig.js +10 -2
- package/viz/draw.js +165 -4
- package/viz/spec.js +121 -2
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,7 +195,7 @@ 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
|
],
|
|
@@ -283,7 +284,7 @@ function drawEntities(spec, main) {
|
|
|
283
284
|
}
|
|
284
285
|
}
|
|
285
286
|
|
|
286
|
-
const HOSTS = { split: 'fig-split', bars: 'fig-bars', 'before-after': 'fig-change', dumbbell: 'div', series: 'div' };
|
|
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' };
|
|
287
288
|
|
|
288
289
|
/** A checked spec drawn into a figure: header, chart and footer, as figkit's own markup. */
|
|
289
290
|
export function drawSpec(spec, main) {
|
|
@@ -306,6 +307,10 @@ export function drawSpec(spec, main) {
|
|
|
306
307
|
if (spec.type === 'before-after') return drawChange(chart, spec);
|
|
307
308
|
if (spec.type === 'dumbbell') return drawDumbbell(chart, spec);
|
|
308
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);
|
|
309
314
|
const items = (spec.parts ?? spec.bars).map((x) => ({ ...x, text: String(x.value), label: esc(x.label) }));
|
|
310
315
|
if (spec.type === 'split') drawSplit(chart, items, spec.unit);
|
|
311
316
|
else drawBars(chart, items, spec.unit, spec.max);
|
|
@@ -313,3 +318,159 @@ export function drawSpec(spec, main) {
|
|
|
313
318
|
draw();
|
|
314
319
|
return { chart, draw };
|
|
315
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,6 +85,70 @@ export const SeriesSpec = z.object({
|
|
|
84
85
|
zero: z.boolean().optional(),
|
|
85
86
|
}).strict();
|
|
86
87
|
|
|
88
|
+
|
|
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
|
+
|
|
87
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"');
|
|
88
153
|
|
|
89
154
|
/**
|
|
@@ -95,6 +160,7 @@ export const EntitiesSpec = z.object({
|
|
|
95
160
|
title,
|
|
96
161
|
note: common.note,
|
|
97
162
|
source: common.source,
|
|
163
|
+
style: common.style,
|
|
98
164
|
records: z.array(z.object({
|
|
99
165
|
id: slug,
|
|
100
166
|
name: label.max(32, 'keep record names to 32 characters'),
|
|
@@ -111,14 +177,67 @@ export const EntitiesSpec = z.object({
|
|
|
111
177
|
}).strict()).min(1, 'at least 1 reference').max(8, 'at most 8 references; more is two figures, not a reference left out'),
|
|
112
178
|
}).strict();
|
|
113
179
|
|
|
114
|
-
export const Spec = z.discriminatedUnion('type', [SplitSpec, BarsSpec, BeforeAfterSpec, DumbbellSpec, SeriesSpec, EntitiesSpec]);
|
|
115
|
-
export const TYPES = { split: SplitSpec, bars: BarsSpec, 'before-after': BeforeAfterSpec, dumbbell: DumbbellSpec, series: SeriesSpec, entities: EntitiesSpec };
|
|
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 };
|
|
116
182
|
|
|
117
183
|
const items = (s) => s.parts ?? s.bars ?? s.rows ?? s.records ?? (s.after ? [s.after] : []);
|
|
118
184
|
const kind = (v) => (typeof v === 'number' ? 'number' : 'date');
|
|
119
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
|
+
|
|
120
209
|
/** Rules that span fields: x values of one kind, in order, and a mark that is one of the points. */
|
|
121
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
|
+
}
|
|
122
241
|
if (s.type === 'dumbbell') {
|
|
123
242
|
const kinds = new Set(s.rows.flatMap((r) => [kind(r.from), kind(r.to)]));
|
|
124
243
|
if (kinds.size > 1) return ['rows: use all numbers or all dates, not both'];
|