@liggi/figkit 0.2.0 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,171 @@
1
+ /* The "sequence" spec drawn: participants across the top, each message a numbered step down the page. */
2
+ const NS = 'http://www.w3.org/2000/svg';
3
+ const esc = (s) => String(s).replace(/[&<>"]/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' })[c]);
4
+
5
+ function svgEl(tag, attrs, parent) {
6
+ const el = document.createElementNS(NS, tag);
7
+ for (const [k, v] of Object.entries(attrs)) el.setAttribute(k, v);
8
+ parent.appendChild(el);
9
+ return el;
10
+ }
11
+
12
+ /**
13
+ * Participants side by side in boxes, a line down from each, and the messages in order: a solid arrow for a call, a
14
+ * dashed one for a reply, a loop for work a participant does itself. Each label sits on the paper above its arrow,
15
+ * numbered, so prose can say "step 4". Repeated steps sit in a dashed frame named at its top.
16
+ */
17
+ export function drawSequence(spec, main) {
18
+ const phone = main.classList.contains('phone');
19
+ const host = document.createElement('div');
20
+ host.className = 'sequence';
21
+ main.appendChild(host);
22
+ const css = (n) => getComputedStyle(host).getPropertyValue(n).trim();
23
+ const ink = { line: css('--line'), soft: css('--soft'), accent: css('--accent'), hair: css('--hair'), muted: css('--muted') };
24
+
25
+ const lanes = document.createElement('div');
26
+ lanes.className = 'seq-lanes';
27
+ host.appendChild(lanes);
28
+ const svg = document.createElementNS(NS, 'svg');
29
+ host.prepend(svg);
30
+ const W = host.clientWidth, n = spec.participants.length;
31
+ const SEP = phone ? 12 : 24;
32
+ const boxes = spec.participants.map((p) => {
33
+ const box = document.createElement('div');
34
+ box.className = 'node seq-who' + (p.tone ? ' accent' : '');
35
+ box.id = 'who-' + p.id;
36
+ box.innerHTML = `<h3>${esc(p.name)}</h3>${p.kind ? `<p>${esc(p.kind)}</p>` : ''}`;
37
+ lanes.appendChild(box);
38
+ return box;
39
+ });
40
+ // Lanes share the width equally. When a name's longest word will not fit, lanes widen for the names that need it
41
+ // and narrow for the ones that don't; then the names drop a size; only then do the boxes alternate between two rows.
42
+ const set = (box, left, width) => Object.assign(box.style, { left: left + 'px', width: width + 'px' });
43
+ let edge = spec.participants.map((_, i) => (W / n) * i).concat(W);
44
+ const layout = () => boxes.forEach((b, i) => set(b, edge[i] + SEP / 2, edge[i + 1] - edge[i] - SEP));
45
+ const over = () => boxes.some((b) => b.scrollWidth > b.clientWidth + 1);
46
+ const fit = () => {
47
+ const need = boxes.map((b) => { b.style.width = 'min-content'; return b.offsetWidth + SEP; });
48
+ const spare = W - need.reduce((a, v) => a + v, 0);
49
+ if (spare < 0) return false;
50
+ edge = [0];
51
+ need.forEach((v) => edge.push(edge[edge.length - 1] + v + spare / n));
52
+ return true;
53
+ };
54
+ layout();
55
+ let stagger = false;
56
+ if (over()) {
57
+ if (!fit()) { lanes.classList.add('tight'); if (!fit()) { edge = spec.participants.map((_, i) => (W / n) * i).concat(W); stagger = n > 2; } }
58
+ layout();
59
+ if (stagger) {
60
+ boxes.forEach((b, i) => {
61
+ const lo = i > 0 ? (edge[i - 1] + edge[i]) / 2 + SEP / 2 : 0, hi = i < n - 1 ? (edge[i + 1] + edge[i + 2]) / 2 - SEP / 2 : W;
62
+ set(b, lo, hi - lo);
63
+ });
64
+ }
65
+ }
66
+ const x = {};
67
+ spec.participants.forEach((p, i) => { x[p.id] = (edge[i] + edge[i + 1]) / 2; });
68
+ const L = Math.min(...spec.participants.map((_, i) => edge[i + 1] - edge[i]));
69
+ const tall = Math.max(...boxes.map((b) => b.offsetHeight));
70
+ const ROW2 = tall + (phone ? 14 : 18);
71
+ const bottom = {};
72
+ boxes.forEach((b, i) => {
73
+ const down = stagger && i % 2 === 1;
74
+ Object.assign(b.style, { height: tall + 'px', top: (down ? ROW2 : 0) + 'px' });
75
+ bottom[spec.participants[i].id] = (down ? ROW2 : 0) + tall;
76
+ });
77
+ const top = Math.max(...Object.values(bottom));
78
+ lanes.style.height = top + 'px';
79
+ const GAP = phone ? 20 : 24, LIFT = 8, LOOP = phone ? 26 : 32, HEAD = 10;
80
+ const reps = spec.repeats ?? [];
81
+ const frames = [];
82
+ let y = top + (phone ? 26 : 30);
83
+ const label = (m, i) => {
84
+ const el = document.createElement('div');
85
+ el.className = 'seq-msg' + (m.reply ? ' reply' : '') + (m.tone ? ' accent' : '');
86
+ el.innerHTML = `<span class="seq-n"><span class="seq-ink">${i + 1}</span></span><span class="seq-text"><span class="seq-ink">${esc(m.label)}</span>${m.note ? `<span class="seq-note"><span class="seq-ink">${esc(m.note)}</span></span>` : ''}</span>`;
87
+ host.appendChild(el);
88
+ return el;
89
+ };
90
+ const place = (el, maxW) => {
91
+ el.style.maxWidth = Math.max(80, Math.floor(maxW)) + 'px';
92
+ el.style.left = '0px';
93
+ return el.offsetWidth;
94
+ };
95
+
96
+ spec.messages.forEach((m, i) => {
97
+ const rep = reps.find((r) => r.from === i + 1);
98
+ if (rep) {
99
+ const tab = document.createElement('div');
100
+ tab.className = 'seq-frame-label';
101
+ tab.textContent = rep.label;
102
+ host.appendChild(tab);
103
+ // The frame covers only the lanes its steps touch, edge to edge between lines, widened for any label past them.
104
+ const idx = spec.messages.slice(rep.from - 1, rep.to).flatMap((q) => [q.from, q.to]).map((id) => spec.participants.findIndex((p) => p.id === id));
105
+ tab.style.top = y + 'px';
106
+ frames.push({ rep, tab, top: y + tab.offsetHeight / 2, lo: x[spec.participants[Math.min(...idx)].id], hi: x[spec.participants[Math.max(...idx)].id], labels: [] });
107
+ y += tab.offsetHeight + 10;
108
+ }
109
+ const el = label(m, i);
110
+ const color = m.tone ? ink.accent : m.reply ? ink.soft : ink.line;
111
+ if (m.from === m.to) {
112
+ // Work a participant does itself: a loop out to the side of its line, the label beside the loop.
113
+ const lx = x[m.from];
114
+ const roomRight = W - (lx + LOOP + 14), roomLeft = lx - LOOP - 14;
115
+ const right = roomRight >= Math.min(roomLeft, 260) || roomRight >= 200;
116
+ const ids = spec.participants.map((p) => x[p.id]);
117
+ const next = right ? Math.min(...ids.filter((v) => v > lx), Infinity) - (lx + LOOP + 14) - 10 : (lx - LOOP - 14) - Math.max(...ids.filter((v) => v < lx), -Infinity) - 10;
118
+ const w = place(el, Math.min(right ? roomRight : roomLeft, next >= (phone ? 130 : 150) ? next : Infinity));
119
+ const h = el.offsetHeight;
120
+ const loopH = Math.max(h, 30);
121
+ el.style.left = (right ? lx + LOOP + 14 : lx - LOOP - 14 - w) + 'px';
122
+ el.style.top = y + (loopH - h) / 2 + 'px';
123
+ const side = right ? 1 : -1, x1 = lx + side * LOOP;
124
+ svgEl('path', { d: `M${lx},${y} H${x1} V${y + loopH} H${lx + side * (HEAD + 2)}`, fill: 'none', stroke: color, 'stroke-width': 2.25, 'stroke-linejoin': 'round' }, svg);
125
+ head(lx, y + loopH, -side, color);
126
+ y += loopH + GAP;
127
+ } else {
128
+ const a = x[m.from], b = x[m.to], dir = Math.sign(b - a);
129
+ // A label starts just inside the arrow's left end, the same for every arrow, and wraps to stay between the
130
+ // arrow's two lines. Where that is too narrow to read, it reaches at most halfway to the next line out.
131
+ const lo = Math.min(a, b), hi = Math.max(a, b), MIN = phone ? 150 : 180;
132
+ const next = Math.min(...Object.values(x).filter((v) => v > hi), Infinity);
133
+ const inside = hi - lo - 14 >= (phone ? 160 : 140);
134
+ const reach = inside ? hi - lo - 14 : Math.max(MIN, (next === Infinity ? W : (hi + next) / 2 - 6) - (lo + 6));
135
+ const w = place(el, Math.min(W, reach));
136
+ const left = Math.max(0, Math.min(W - w, lo + 6));
137
+ el.style.left = left + 'px';
138
+ el.style.top = y + 'px';
139
+ const ay = y + el.offsetHeight + LIFT;
140
+ svgEl('line', { x1: a, y1: ay, x2: b - dir * (HEAD + 1), y2: ay, stroke: color, 'stroke-width': 2.25, ...(m.reply ? { 'stroke-dasharray': '7 6' } : {}) }, svg);
141
+ head(b, ay, dir, color);
142
+ y = ay + GAP;
143
+ }
144
+ for (const f of frames) if (i + 1 >= f.rep.from && i + 1 <= f.rep.to) f.labels.push(el);
145
+ const open = frames.find((f) => f.rep.to === i + 1);
146
+ if (open) { open.bottom = y - GAP + 14; y += 14; }
147
+ });
148
+ const H = y - GAP + 24;
149
+ host.style.height = H + 'px';
150
+ svg.setAttribute('width', W); svg.setAttribute('height', H);
151
+ // Lines down from each participant and the frames round repeated steps, behind the arrows.
152
+ const back = document.createElementNS(NS, 'g');
153
+ svg.prepend(back);
154
+ // Every frame keeps the same inset outside its first and last lines: the widest any of them needs.
155
+ let pad = Math.min(L / 2 - 4, 40);
156
+ for (const f of frames) for (const el of f.labels) pad = Math.max(pad, f.lo - el.offsetLeft + 8, el.offsetLeft + el.offsetWidth + 8 - f.hi);
157
+ for (const f of frames) {
158
+ // The same inset outside the first and last lines, wide enough for every label inside.
159
+ f.left = Math.max(1, f.lo - pad); f.right = Math.min(W - 1, f.hi + pad);
160
+ // The name starts just past the first line inside the frame, so it covers no line.
161
+ f.tab.style.left = Math.max(f.left + 14, Math.min(f.lo + 6, f.right - 14 - f.tab.offsetWidth)) + 'px';
162
+ }
163
+ for (const f of frames) svgEl('rect', { x: f.left, y: f.top, width: f.right - f.left, height: f.bottom - f.top, rx: 10, fill: 'none', stroke: ink.muted, 'stroke-opacity': 0.55, 'stroke-width': 1.5, 'stroke-dasharray': '6 6' }, back);
164
+ for (const p of spec.participants) svgEl('line', { x1: x[p.id], y1: bottom[p.id], x2: x[p.id], y2: H, stroke: ink.hair, 'stroke-width': 2 }, back);
165
+
166
+ function head(tipX, tipY, dir, color) {
167
+ const back = tipX - dir * (HEAD + 2);
168
+ svgEl('path', { d: `M${tipX - dir * 1},${tipY} L${back},${tipY - 6.5} L${back},${tipY + 6.5} Z`, fill: color }, svg);
169
+ }
170
+ return host;
171
+ }
package/viz/spec.js CHANGED
@@ -1,5 +1,6 @@
1
1
  /* The named visualisations: one Zod schema per type. An agent picks a type and gives data; it never writes markup. */
2
2
  import { z } from 'zod';
3
+ import { sequenceShape, sequenceChecks } from './sequence-spec.js';
3
4
 
4
5
  const TONE = z.enum(['accent', 'second', 'good', 'bad']);
5
6
  const label = z.string({ error: 'needs a label' }).trim().min(1, 'needs a label').max(40, 'keep labels to 40 characters');
@@ -10,8 +11,15 @@ const common = {
10
11
  note: z.string().trim().max(160, 'keep the note to 160 characters').optional(),
11
12
  unit: z.string().max(8).optional(),
12
13
  source: z.string().trim().max(120).optional(),
14
+ style: z.enum(['report'], { error: 'style: "report" for a light chart that sits in a page, or leave it out' }).optional(),
13
15
  };
14
16
  const value = z.number({ error: 'must be a number' }).finite();
17
+ /** What the low-to-high ranges are, printed beside a drawn range: required whenever a chart has ranges. */
18
+ const range = z.string().trim().min(1).max(40, 'keep the range description to 40 characters, e.g. "95% interval"').optional();
19
+ const bound = z.number({ error: 'must be a number' }).finite().optional();
20
+ /** The plural noun one counted item is, so a count reads "14 runs" rather than "n = 14". */
21
+ const each = z.string().trim().min(1).max(16, 'keep it to 16 characters, a plural noun such as "runs"').optional();
22
+ const sample = z.number({ error: 'the sample size, a whole number' }).int('the sample size, a whole number').positive('must be more than 0').optional();
15
23
 
16
24
  /** Parts of a whole, as one bar cut into segments. */
17
25
  export const SplitSpec = z.object({
@@ -36,9 +44,14 @@ export const BarsSpec = z.object({
36
44
  tone: TONE.optional(),
37
45
  show: z.string().max(24).optional(),
38
46
  note: z.string().trim().max(40).optional(),
47
+ low: bound,
48
+ high: bound,
49
+ n: sample,
39
50
  }).strict()).min(1).max(12, 'at most 12 bars'),
51
+ range,
52
+ each,
40
53
  }).strict()
41
- .refine((s) => s.max === undefined || s.bars.every((b) => b.value <= s.max), { message: 'every value must be at most max', path: ['max'] });
54
+ .refine((s) => s.max === undefined || s.bars.every((b) => Math.max(b.value, b.high ?? 0) <= s.max), { message: 'every value and every high must be at most max', path: ['max'] });
42
55
 
43
56
  const side = z.object({
44
57
  label: z.string({ error: 'needs a label: when it was' }).trim().min(1, 'needs a label: when it was').max(24, 'keep it to 24 characters'),
@@ -79,11 +92,136 @@ export const DumbbellSpec = z.object({
79
92
  export const SeriesSpec = z.object({
80
93
  type: z.literal('series'),
81
94
  ...common,
82
- points: z.array(z.object({ x: point, y: value }).strict()).min(3, 'a series needs at least 3 points').max(2000),
95
+ points: z.array(z.object({ x: point, y: value, low: bound, high: bound }).strict()).min(3, 'a series needs at least 3 points').max(2000),
96
+ range,
97
+ ref: z.object({ y: value, label: z.string().trim().min(1).max(32, 'keep the reference label to 32 characters') }, { error: 'ref: { "y", "label" }, a level to compare the line with' }).strict().optional(),
83
98
  mark: z.object({ x: point, label: z.string().trim().min(1).max(32, 'keep the mark label to 32 characters') }, { error: 'required: the one point to notice, { "x", "label" }' }).strict(),
84
99
  zero: z.boolean().optional(),
85
100
  }).strict();
86
101
 
102
+
103
+ const seriesName = label.max(28, 'keep series names to 28 characters');
104
+ const TONE_ONE = z.enum(['accent', 'good', 'bad'], { error: 'tone: "accent", or "good" or "bad" when it is a result' });
105
+
106
+ /** Bars in groups: each group a few bars, one per series, read across the groups. */
107
+ const groupRow = z.object({
108
+ label: label.max(32, 'keep group labels to 32 characters'),
109
+ values: z.array(value.nonnegative('must be 0 or more: bars start at zero')),
110
+ }).strict();
111
+ const groupsData = {
112
+ 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'),
113
+ groups: z.array(groupRow).min(2, 'at least 2 groups; one group is a "bars" chart').max(6, 'at most 6 groups'),
114
+ accent: z.object({
115
+ series: z.string().optional(),
116
+ group: z.string().optional(),
117
+ tone: TONE_ONE.optional(),
118
+ }, { error: 'required: what to notice, { "series" } for a whole series, or { "series", "group" } for one bar' }).strict(),
119
+ max: z.number().positive().optional(),
120
+ };
121
+ export const GroupsSpec = z.object({ type: z.literal('groups'), ...common, ...groupsData }).strict();
122
+
123
+ /** Two or three lines on one axis, each named at its end; one is the line to notice. */
124
+ const linePoint = z.object({ x: point, y: value, low: bound, high: bound }).strict();
125
+ const linesData = {
126
+ series: z.array(z.object({
127
+ label: seriesName,
128
+ points: z.array(linePoint).min(3, 'a line needs at least 3 points').max(2000),
129
+ }).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")'),
130
+ accent: z.string({ error: 'required: the label of the line to notice' }),
131
+ mark: z.object({ x: point, label: z.string().trim().min(1).max(32, 'keep the mark label to 32 characters') }).strict().optional(),
132
+ zero: z.boolean().optional(),
133
+ range,
134
+ 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(),
135
+ };
136
+ export const LinesSpec = z.object({ type: z.literal('lines'), ...common, ...linesData }).strict();
137
+
138
+ /** The same chart drawn once per part, side by side on one scale. */
139
+ export const PanelsSpec = z.object({
140
+ type: z.literal('panels'),
141
+ ...common,
142
+ chart: z.enum(['groups', 'lines'], { error: 'chart: "groups" or "lines", the chart each panel draws' }),
143
+ series: groupsData.series.optional(),
144
+ accent: z.union([z.string(), groupsData.accent]),
145
+ max: z.number().positive().optional(),
146
+ zero: z.boolean().optional(),
147
+ range,
148
+ xTicks: linesData.xTicks,
149
+ panels: z.array(z.object({
150
+ label: label.max(32, 'keep panel labels to 32 characters'),
151
+ groups: z.array(groupRow).min(1).max(6, 'at most 6 groups').optional(),
152
+ series: linesData.series.optional(),
153
+ }).strict()).min(2, 'at least 2 panels').max(4, 'at most 4 panels; more is two figures'),
154
+ }).strict();
155
+
156
+ /** A grid of values coloured from pale to strong: where something is high or low across two dimensions. */
157
+ const axisLabel = z.union([z.string().trim().min(1).max(16, 'keep row and column labels to 16 characters'), z.number().finite()]);
158
+ export const HeatmapSpec = z.object({
159
+ type: z.literal('heatmap'),
160
+ ...common,
161
+ 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'),
162
+ rows: z.array(axisLabel).min(2).max(12, 'at most 12 rows'),
163
+ columns: z.array(axisLabel).min(2).max(31, 'at most 31 columns'),
164
+ values: z.array(z.array(value)),
165
+ mark: z.object({ row: axisLabel, column: axisLabel, label: z.string().trim().min(1).max(32, 'keep the mark label to 32 characters') }).strict().optional(),
166
+ }).strict();
167
+
168
+ /** Every value as a dot: one row per group, the dots stacked where they would overlap, so none hides another. */
169
+ export const DotsSpec = z.object({
170
+ type: z.literal('dots'),
171
+ ...common,
172
+ note: z.string({ error: 'required: what each dot is and what it measures, e.g. "One dot per run: minutes to finish"' }).trim().min(1).max(160, 'keep the note to 160 characters'),
173
+ groups: z.array(z.object({
174
+ label: label.max(28, 'keep group labels to 28 characters'),
175
+ values: z.array(value).min(1).max(300, 'at most 300 dots in a group; more is a distribution, not dots to look at one by one'),
176
+ tone: TONE.optional(),
177
+ }).strict()).min(1).max(6, 'at most 6 groups'),
178
+ each,
179
+ summary: z.enum(['median', 'mean'], { error: 'summary: "median" or "mean", drawn as a line across each group' }).optional(),
180
+ mark: z.object({ group: z.string(), value, label: z.string().trim().min(1).max(32, 'keep the mark label to 32 characters') }).strict().optional(),
181
+ zero: z.boolean().optional(),
182
+ max: z.number().finite().optional(),
183
+ }).strict();
184
+
185
+ /** Two measures against each other, a dot per thing measured. Both axes say what they measure. */
186
+ const axis = z.object({
187
+ label: z.string({ error: 'required: what this axis measures' }).trim().min(1, 'required: what this axis measures').max(40, 'keep axis labels to 40 characters'),
188
+ unit: z.string().max(8).optional(),
189
+ log: z.boolean().optional(),
190
+ zero: z.boolean().optional(),
191
+ time: z.enum(['seconds', 'minutes', 'hours', 'days'], { error: 'time: "seconds", "minutes", "hours" or "days", the unit the values are in; ticks then read 1 min, 1 h, 1 day' }).optional(),
192
+ }, { error: 'required: { "label" } saying what this axis measures' }).strict();
193
+ export const ScatterSpec = z.object({
194
+ type: z.literal('scatter'),
195
+ title: common.title,
196
+ note: z.string({ error: 'required: what each dot is, e.g. "One dot per merged pull request"' }).trim().min(1).max(160, 'keep the note to 160 characters'),
197
+ source: common.source,
198
+ style: common.style,
199
+ x: axis,
200
+ y: axis,
201
+ medians: z.boolean().optional(),
202
+ points: z.array(z.object({
203
+ x: value, y: value,
204
+ label: z.string().trim().min(1).max(32, 'keep point labels to 32 characters').optional(),
205
+ tone: TONE.optional(),
206
+ }).strict()).min(3, 'at least 3 points').max(1000, 'at most 1000 points'),
207
+ }).strict();
208
+
209
+ /** A small count made concrete: one square per thing counted, the ones that matter filled. */
210
+ export const UnitsSpec = z.object({
211
+ type: z.literal('units'),
212
+ title: common.title,
213
+ note: common.note,
214
+ source: common.source,
215
+ style: common.style,
216
+ total: z.number({ error: 'how many things there are, a whole number' }).int('a whole number; units are things, not fractions').min(2).max(100, 'at most 100 units; more cannot be counted by eye, so use a "split" bar of the shares'),
217
+ parts: z.array(z.object({
218
+ label: label.max(32, 'keep part labels to 32 characters'),
219
+ count: z.number({ error: 'a whole number' }).int('a whole number; units are things, not fractions').positive('more than 0'),
220
+ tone: TONE.optional(),
221
+ }).strict()).min(1).max(3, 'at most 3 filled parts; the rest are the empty squares'),
222
+ rest: label.max(32, 'keep the rest label to 32 characters'),
223
+ }).strict();
224
+
87
225
  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
226
 
89
227
  /**
@@ -95,6 +233,7 @@ export const EntitiesSpec = z.object({
95
233
  title,
96
234
  note: common.note,
97
235
  source: common.source,
236
+ style: common.style,
98
237
  records: z.array(z.object({
99
238
  id: slug,
100
239
  name: label.max(32, 'keep record names to 32 characters'),
@@ -111,19 +250,137 @@ export const EntitiesSpec = z.object({
111
250
  }).strict()).min(1, 'at least 1 reference').max(8, 'at most 8 references; more is two figures, not a reference left out'),
112
251
  }).strict();
113
252
 
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 };
253
+ /** Who calls whom, in order: participants side by side and numbered messages between them. */
254
+ export const SequenceSpec = z.object({ type: z.literal('sequence'), title, note: common.note, source: common.source, style: common.style, ...sequenceShape }).strict();
255
+
256
+ export const Spec = z.discriminatedUnion('type', [SplitSpec, BarsSpec, BeforeAfterSpec, DumbbellSpec, SeriesSpec, EntitiesSpec, GroupsSpec, LinesSpec, PanelsSpec, HeatmapSpec, DotsSpec, ScatterSpec, UnitsSpec, SequenceSpec]);
257
+ export const TYPES = { split: SplitSpec, bars: BarsSpec, 'before-after': BeforeAfterSpec, dumbbell: DumbbellSpec, series: SeriesSpec, entities: EntitiesSpec, groups: GroupsSpec, lines: LinesSpec, panels: PanelsSpec, heatmap: HeatmapSpec, dots: DotsSpec, scatter: ScatterSpec, units: UnitsSpec, sequence: SequenceSpec };
116
258
 
117
- const items = (s) => s.parts ?? s.bars ?? s.rows ?? s.records ?? (s.after ? [s.after] : []);
259
+ const items = (s) => s.parts ?? s.bars ?? s.rows ?? s.records ?? s.messages ?? (s.type === 'scatter' ? s.points : s.type === 'dots' ? s.groups : (s.after ? [s.after] : []));
118
260
  const kind = (v) => (typeof v === 'number' ? 'number' : 'date');
119
261
 
262
+
263
+ /**
264
+ * Ranges: a low and a high together, around their value, on every item or none (a value with no range among ones
265
+ * with ranges reads as certain), and said what they are. `of` gives each item's value.
266
+ */
267
+ function rangeChecks(items, of, desc, at) {
268
+ const out = [];
269
+ const has = items.map((x) => x.low !== undefined || x.high !== undefined);
270
+ items.forEach((x, i) => {
271
+ if (has[i] && (x.low === undefined || x.high === undefined)) out.push(`${at}[${i}]: a range needs both "low" and "high"`);
272
+ else if (has[i] && !(x.low <= of(x) && of(x) <= x.high)) out.push(`${at}[${i}]: the range ${x.low} to ${x.high} does not contain its value ${of(x)}; low ≤ value ≤ high`);
273
+ });
274
+ if (has.some(Boolean) && !has.every(Boolean)) out.push(`${at}: give every item a range or none; one without reads as certain`);
275
+ if (has.some(Boolean) && !desc) out.push('range: required with ranges: say what they are, e.g. "95% interval" or "lowest to highest run"');
276
+ if (!has.some(Boolean) && desc) out.push('range: there are no ranges to describe; give items "low" and "high", or leave "range" out');
277
+ return out;
278
+ }
279
+
280
+ function groupsChecks(s, at = '') {
281
+ const out = [];
282
+ 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}`); });
283
+ 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(', ')}`);
284
+ 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`);
285
+ 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');
286
+ 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');
287
+ return out;
288
+ }
289
+ function linesChecks(s, at = '') {
290
+ const out = [];
291
+ const xs = s.series.flatMap((l) => l.points.map((p) => p.x));
292
+ if (new Set(xs.map(kind)).size > 1) out.push(`${at}series: x values must be all numbers or all dates, not both`);
293
+ const t = (x) => (typeof x === 'number' ? x : Date.parse(x));
294
+ 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; } });
295
+ const names = s.series.map((l) => l.label);
296
+ if (new Set(names).size < names.length) out.push(`${at}series: two lines have the same label`);
297
+ if (!names.includes(s.accent)) out.push(`accent: must be the label of one of the lines, ${names.map((x) => `"${x}"`).join(', ')}`);
298
+ 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`); }
299
+ const ranged = s.series.filter((l) => l.points.some((p) => p.low !== undefined || p.high !== undefined));
300
+ s.series.forEach((l, i) => { if (ranged.includes(l)) out.push(...rangeChecks(l.points, (p) => p.y, s.range ?? 'given', `${at}series[${i}].points`)); });
301
+ if (!ranged.length && s.range) out.push('range: there are no ranges to describe; give points "low" and "high", or leave "range" out');
302
+ if (ranged.length && !s.range) out.push('range: required with ranges: say what they are, e.g. "95% interval"');
303
+ return out;
304
+ }
305
+
120
306
  /** Rules that span fields: x values of one kind, in order, and a mark that is one of the points. */
121
307
  function crossChecks(s) {
308
+ if (s.type === 'sequence') return sequenceChecks(s);
309
+ if (s.type === 'groups') return groupsChecks(s);
310
+ if (s.type === 'lines') return linesChecks(s);
311
+ if (s.type === 'panels') {
312
+ const out = [];
313
+ s.panels.forEach((p, i) => {
314
+ const at = `panels[${i}].`;
315
+ if (s.chart === 'groups') {
316
+ if (!p.groups || p.series) { out.push(`${at}: a groups panel has "groups" and no "series"`); return; }
317
+ if (!s.series) { out.push('series: a groups chart names its series once, at the top'); return; }
318
+ if (typeof s.accent !== 'object') { out.push('accent: { "series" } or { "series", "group" } for a groups chart'); return; }
319
+ out.push(...groupsChecks({ series: s.series, groups: p.groups, accent: { ...s.accent, group: undefined }, max: s.max }, at));
320
+ 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');
321
+ } else {
322
+ if (!p.series || p.groups) { out.push(`${at}: a lines panel has "series" and no "groups"`); return; }
323
+ if (typeof s.accent !== 'string') { out.push('accent: the label of the line to notice, the same in every panel'); return; }
324
+ out.push(...linesChecks({ series: p.series, accent: s.accent, range: s.range }, at));
325
+ const names = (q) => q.series.map((l) => l.label).join('|');
326
+ 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`);
327
+ }
328
+ });
329
+ return [...new Set(out)];
330
+ }
331
+ if (s.type === 'heatmap') {
332
+ const out = [];
333
+ if (s.values.length !== s.rows.length) out.push(`values: one row of values per row label, ${s.rows.length}, not ${s.values.length}`);
334
+ 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}`); });
335
+ if (s.mark && !s.rows.includes(s.mark.row)) out.push('mark.row: must be one of the row labels');
336
+ if (s.mark && !s.columns.includes(s.mark.column)) out.push('mark.column: must be one of the column labels');
337
+ return out;
338
+ }
122
339
  if (s.type === 'dumbbell') {
123
340
  const kinds = new Set(s.rows.flatMap((r) => [kind(r.from), kind(r.to)]));
124
341
  if (kinds.size > 1) return ['rows: use all numbers or all dates, not both'];
125
342
  }
343
+ if (s.type === 'bars') {
344
+ const out = rangeChecks(s.bars, (b) => b.value, s.range, 'bars');
345
+ s.bars.forEach((b, i) => { if (b.low !== undefined && b.low < 0) out.push(`bars[${i}].low: bars start at zero, so the range cannot go below it`); });
346
+ const ns = s.bars.map((b) => b.n !== undefined);
347
+ if (ns.some(Boolean) && !ns.every(Boolean)) out.push('bars: give every bar an "n" or none');
348
+ s.bars.forEach((b, i) => { if (b.n !== undefined && b.show === undefined && s.unit === '%' && b.n < 10) out.push(`bars[${i}].n: a percentage of ${b.n} things says more than it knows; show it as a count with "show", e.g. "${Math.round((b.value / 100) * b.n)} of ${b.n}", or draw a "units" chart`); });
349
+ return out;
350
+ }
351
+ if (s.type === 'dots') {
352
+ const out = [];
353
+ const names = s.groups.map((g) => g.label);
354
+ if (new Set(names).size < names.length) out.push('groups: two groups have the same label');
355
+ if (s.groups.reduce((a, g) => a + g.values.length, 0) > 600) out.push('groups: at most 600 dots in all');
356
+ if (s.mark) {
357
+ const g = s.groups.find((x) => x.label === s.mark.group);
358
+ if (!g) out.push(`mark.group: must be one of ${names.map((x) => `"${x}"`).join(', ')}`);
359
+ else if (!g.values.includes(s.mark.value)) out.push(`mark.value: must be one of the values in "${g.label}"`);
360
+ }
361
+ 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');
362
+ if (s.zero && s.groups.some((g) => g.values.some((v) => v < 0))) out.push('zero: an axis from zero cannot show values below it');
363
+ return out;
364
+ }
365
+ if (s.type === 'scatter') {
366
+ const out = [];
367
+ for (const k of ['x', 'y']) {
368
+ if (s[k].log && s.points.some((p) => p[k] <= 0)) out.push(`${k}.log: a log axis needs every ${k} above 0`);
369
+ if (s[k].log && s[k].zero) out.push(`${k}: a log axis has no zero; leave out "zero"`);
370
+ }
371
+ if (s.points.filter((p) => p.label).length > 5) out.push('points: label at most 5 points; the title is about one or two');
372
+ for (const k of ['x', 'y']) if (s[k].time && s[k].unit) out.push(`${k}: a time axis writes its own units (1 min, 1 h); leave out "unit"`);
373
+ return out;
374
+ }
375
+ if (s.type === 'units') {
376
+ const filled = s.parts.reduce((a, p) => a + p.count, 0);
377
+ if (filled > s.total) return [`parts: the counts add up to ${filled}, more than the total of ${s.total}`];
378
+ if (filled === s.total) return [`parts: the counts fill all ${s.total}; "rest" names the empty squares, so at least one must be left. For all of them, say so in the title`];
379
+ return [];
380
+ }
126
381
  if (s.type === 'series') {
382
+ const ranged = rangeChecks(s.points, (p) => p.y, s.range, 'points');
383
+ if (ranged.length) return ranged;
127
384
  const kinds = new Set(s.points.map((p) => kind(p.x)));
128
385
  if (kinds.size > 1) return ['points: x values must be all numbers or all dates, not both'];
129
386
  const at = (x) => (typeof x === 'number' ? x : Date.parse(x));