@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/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.soft }),
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.soft, strokeWidth: 2.5 }),
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'];