@liggi/figkit 0.3.0 → 0.5.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,60 @@
1
+ /* The "sequence" spec: who calls whom, in order. Kept beside spec.js, which adds it to the union of types. */
2
+ import { z } from 'zod';
3
+
4
+ const slug = z.string({ error: 'needs an id, e.g. "bot"' }).regex(/^[a-z][a-z0-9-]{0,23}$/, 'ids are short lowercase words, e.g. "bot" or "ci-job"');
5
+ const text = (max, what, long) => z.string({ error: `needs ${what}` }).trim().min(1, `needs ${what}`).max(max, long);
6
+
7
+ /**
8
+ * Who calls whom, in order: participants side by side, each message a numbered step down the page from one to another.
9
+ * A reply answers an earlier call; a message to its own sender is work it does itself.
10
+ */
11
+ export const sequenceShape = {
12
+ participants: z.array(z.object({
13
+ id: slug,
14
+ name: text(28, 'a name', 'keep names to 28 characters'),
15
+ kind: z.string().trim().min(1).max(28, 'keep the kind to 28 characters, e.g. "GitHub Actions"').optional(),
16
+ tone: z.literal('accent', { error: 'tone: only "accent", on the one participant to notice' }).optional(),
17
+ }).strict()).min(2, 'at least 2 participants').max(5, 'at most 5 participants; more is two figures (the whole run with the parts merged, then one stage of it)'),
18
+ messages: z.array(z.object({
19
+ from: slug,
20
+ to: slug,
21
+ label: text(40, 'a label', 'keep labels to 40 characters: what is asked for or sent, not how'),
22
+ reply: z.boolean().optional(),
23
+ note: z.string().trim().min(1).max(40, 'keep the note to 40 characters').optional(),
24
+ tone: z.literal('accent', { error: 'tone: only "accent", on the steps to notice' }).optional(),
25
+ }).strict()).min(2, 'at least 2 messages; one call is a sentence').max(12, 'at most 12 messages; more is two figures (the run end to end, then the stage that needs detail)'),
26
+ repeats: z.array(z.object({
27
+ from: z.number().int().min(1),
28
+ to: z.number().int().min(1),
29
+ label: text(32, 'a label, e.g. "For each model"', 'keep the label to 32 characters'),
30
+ }).strict()).max(2, 'at most 2 repeated runs of steps').optional(),
31
+ };
32
+
33
+ /** Rules that span fields: ids that exist, replies that answer a call, repeats over real steps. */
34
+ export function sequenceChecks(s) {
35
+ const out = [];
36
+ const ids = s.participants.map((p) => p.id);
37
+ const dup = ids.find((id, i) => ids.indexOf(id) !== i);
38
+ if (dup) return [`participants: two participants have the id "${dup}"`];
39
+ s.messages.forEach((m, i) => {
40
+ for (const end of ['from', 'to']) if (!ids.includes(m[end])) out.push(`messages[${i}].${end}: no participant has the id "${m[end]}"; the ids are ${ids.map((x) => `"${x}"`).join(', ')}`);
41
+ if (m.reply && m.from === m.to) out.push(`messages[${i}]: a reply to itself; make it a plain step, or leave it out`);
42
+ else if (m.reply && !s.messages.slice(0, i).some((c) => !c.reply && c.from === m.to && c.to === m.from)) {
43
+ out.push(`messages[${i}]: a reply from "${m.from}" to "${m.to}", but "${m.to}" never called "${m.from}" before it; a reply answers an earlier call`);
44
+ }
45
+ });
46
+ const unused = ids.filter((id) => !s.messages.some((m) => m.from === id || m.to === id));
47
+ if (unused.length) out.push(`participants: ${unused.map((x) => `"${x}"`).join(', ')} ${unused.length === 1 ? 'sends and receives' : 'send and receive'} nothing; leave ${unused.length === 1 ? 'it' : 'them'} out or add the message`);
48
+ const accentP = s.participants.filter((p) => p.tone).length, accentM = s.messages.filter((m) => m.tone).length;
49
+ if (accentP > 1) out.push('participants: accent at most one participant, the one to notice');
50
+ if (accentP && accentM) out.push('tone: accent one participant or some steps, not both');
51
+ if (accentM && accentM === s.messages.length) out.push('messages: every step is accented, so none stands out; accent only the steps the title is about');
52
+ const n = s.messages.length;
53
+ const reps = [...(s.repeats ?? [])].sort((a, b) => a.from - b.from);
54
+ reps.forEach((r, i) => {
55
+ if (r.to > n || r.from > n) out.push(`repeats[${i}]: steps run 1 to ${n}`);
56
+ else if (r.from > r.to) out.push(`repeats[${i}]: "from" is the first repeated step and "to" the last; ${r.from} is after ${r.to}`);
57
+ if (i > 0 && r.from <= reps[i - 1].to) out.push('repeats: two repeated runs share steps; make them one, or separate them');
58
+ });
59
+ return out;
60
+ }
@@ -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,7 @@
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';
4
+ import { statesShape, statesChecks } from './states-spec.js';
3
5
 
4
6
  const TONE = z.enum(['accent', 'second', 'good', 'bad']);
5
7
  const label = z.string({ error: 'needs a label' }).trim().min(1, 'needs a label').max(40, 'keep labels to 40 characters');
@@ -13,6 +15,12 @@ const common = {
13
15
  style: z.enum(['report'], { error: 'style: "report" for a light chart that sits in a page, or leave it out' }).optional(),
14
16
  };
15
17
  const value = z.number({ error: 'must be a number' }).finite();
18
+ /** What the low-to-high ranges are, printed beside a drawn range: required whenever a chart has ranges. */
19
+ const range = z.string().trim().min(1).max(40, 'keep the range description to 40 characters, e.g. "95% interval"').optional();
20
+ const bound = z.number({ error: 'must be a number' }).finite().optional();
21
+ /** The plural noun one counted item is, so a count reads "14 runs" rather than "n = 14". */
22
+ const each = z.string().trim().min(1).max(16, 'keep it to 16 characters, a plural noun such as "runs"').optional();
23
+ 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();
16
24
 
17
25
  /** Parts of a whole, as one bar cut into segments. */
18
26
  export const SplitSpec = z.object({
@@ -37,9 +45,19 @@ export const BarsSpec = z.object({
37
45
  tone: TONE.optional(),
38
46
  show: z.string().max(24).optional(),
39
47
  note: z.string().trim().max(40).optional(),
48
+ low: bound,
49
+ high: bound,
50
+ n: sample,
40
51
  }).strict()).min(1).max(12, 'at most 12 bars'),
52
+ range,
53
+ each,
54
+ ref: z.object({
55
+ value: value.nonnegative('must be 0 or more'),
56
+ label: label.refine((t) => /\p{L}/u.test(t), 'say what the reference is in words, e.g. "Production (ash-prod-27b)", not just a number'),
57
+ show: z.string().max(24).optional(),
58
+ }, { error: 'ref: { "value", "label" }, the level every bar is compared with, e.g. production' }).strict().optional(),
41
59
  }).strict()
42
- .refine((s) => s.max === undefined || s.bars.every((b) => b.value <= s.max), { message: 'every value must be at most max', path: ['max'] });
60
+ .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'] });
43
61
 
44
62
  const side = z.object({
45
63
  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'),
@@ -76,11 +94,38 @@ export const DumbbellSpec = z.object({
76
94
  }).strict()).min(1).max(8, 'at most 8 rows'),
77
95
  }).strict();
78
96
 
97
+ /**
98
+ * Signed estimates with their ranges around a zero line, one row each (a forest plot): a gap, a change or a correlation,
99
+ * and how sure it is. Colour is not chosen by the author: a row is coloured when its range clears zero, grey when not.
100
+ */
101
+ export const EstimatesSpec = z.object({
102
+ type: z.literal('estimates'),
103
+ ...common,
104
+ note: z.string({ error: 'required: what each number is and which way round, e.g. "Gap in points, matched minus not matched"' }).trim().min(1).max(160, 'keep the note to 160 characters'),
105
+ range: z.string({ error: 'required: what the ranges are, e.g. "95% interval"' }).trim().min(1).max(40, 'keep the range description to 40 characters, e.g. "95% interval"'),
106
+ sides: z.object({
107
+ below: label.max(28, 'keep it to 28 characters'),
108
+ above: label.max(28, 'keep it to 28 characters'),
109
+ }, { error: 'sides: { "below", "above" }, what a value under and over zero means, e.g. "Control better"' }).strict().optional(),
110
+ better: z.enum(['higher', 'lower'], { error: 'better: "higher" or "lower", when one direction is good; rows clear of zero then read as good or bad' }).optional(),
111
+ rows: z.array(z.object({
112
+ label: label.max(32, 'keep row labels to 32 characters'),
113
+ value,
114
+ low: z.number({ error: 'every estimate needs its range: "low" and "high". A number with no range is a "bars" chart' }).finite(),
115
+ high: z.number({ error: 'every estimate needs its range: "low" and "high". A number with no range is a "bars" chart' }).finite(),
116
+ tone: z.never({ error: 'no tone: figkit colours a row whose range clears zero and greys one whose range includes it, so the colour cannot claim more than the range' }).optional(),
117
+ n: sample,
118
+ }).strict()).min(1).max(12, 'at most 12 rows; more is two figures'),
119
+ each,
120
+ }).strict();
121
+
79
122
  /** A line over time (or any ordered x) with one point marked and labelled. */
80
123
  export const SeriesSpec = z.object({
81
124
  type: z.literal('series'),
82
125
  ...common,
83
- points: z.array(z.object({ x: point, y: value }).strict()).min(3, 'a series needs at least 3 points').max(2000),
126
+ 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),
127
+ range,
128
+ 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(),
84
129
  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(),
85
130
  zero: z.boolean().optional(),
86
131
  }).strict();
@@ -107,7 +152,7 @@ const groupsData = {
107
152
  export const GroupsSpec = z.object({ type: z.literal('groups'), ...common, ...groupsData }).strict();
108
153
 
109
154
  /** 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();
155
+ const linePoint = z.object({ x: point, y: value, low: bound, high: bound }).strict();
111
156
  const linesData = {
112
157
  series: z.array(z.object({
113
158
  label: seriesName,
@@ -116,6 +161,7 @@ const linesData = {
116
161
  accent: z.string({ error: 'required: the label of the line to notice' }),
117
162
  mark: z.object({ x: point, label: z.string().trim().min(1).max(32, 'keep the mark label to 32 characters') }).strict().optional(),
118
163
  zero: z.boolean().optional(),
164
+ range,
119
165
  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
166
  };
121
167
  export const LinesSpec = z.object({ type: z.literal('lines'), ...common, ...linesData }).strict();
@@ -129,6 +175,7 @@ export const PanelsSpec = z.object({
129
175
  accent: z.union([z.string(), groupsData.accent]),
130
176
  max: z.number().positive().optional(),
131
177
  zero: z.boolean().optional(),
178
+ range,
132
179
  xTicks: linesData.xTicks,
133
180
  panels: z.array(z.object({
134
181
  label: label.max(32, 'keep panel labels to 32 characters'),
@@ -138,15 +185,72 @@ export const PanelsSpec = z.object({
138
185
  }).strict();
139
186
 
140
187
  /** 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()]);
188
+ const axisLabel = z.union([z.string().trim().min(1).max(16, 'keep column labels to 16 characters'), z.number().finite()]);
142
189
  export const HeatmapSpec = z.object({
143
190
  type: z.literal('heatmap'),
144
191
  ...common,
145
192
  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'),
193
+ rows: z.array(z.union([z.string().trim().min(1).max(28, 'keep row labels to 28 characters'), z.number().finite()])).min(2).max(12, 'at most 12 rows'),
147
194
  columns: z.array(axisLabel).min(2).max(31, 'at most 31 columns'),
148
195
  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(),
196
+ mark: z.object({ row: z.union([z.string(), z.number()]), column: axisLabel, label: z.string().trim().min(1).max(32, 'keep the mark label to 32 characters') }).strict().optional(),
197
+ }).strict();
198
+
199
+ /** Every value as a dot: one row per group, the dots stacked where they would overlap, so none hides another. */
200
+ export const DotsSpec = z.object({
201
+ type: z.literal('dots'),
202
+ ...common,
203
+ 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'),
204
+ groups: z.array(z.object({
205
+ label: label.max(28, 'keep group labels to 28 characters'),
206
+ 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'),
207
+ tone: TONE.optional(),
208
+ }).strict()).min(1).max(6, 'at most 6 groups'),
209
+ each,
210
+ summary: z.enum(['median', 'mean'], { error: 'summary: "median" or "mean", drawn as a line across each group' }).optional(),
211
+ mark: z.object({ group: z.string(), value, label: z.string().trim().min(1).max(32, 'keep the mark label to 32 characters') }).strict().optional(),
212
+ zero: z.boolean().optional(),
213
+ max: z.number().finite().optional(),
214
+ }).strict();
215
+
216
+ /** Two measures against each other, a dot per thing measured. Both axes say what they measure. */
217
+ const axis = z.object({
218
+ 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'),
219
+ unit: z.string().max(8).optional(),
220
+ log: z.boolean().optional(),
221
+ zero: z.boolean().optional(),
222
+ 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(),
223
+ }, { error: 'required: { "label" } saying what this axis measures' }).strict();
224
+ export const ScatterSpec = z.object({
225
+ type: z.literal('scatter'),
226
+ title: common.title,
227
+ 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'),
228
+ source: common.source,
229
+ style: common.style,
230
+ x: axis,
231
+ y: axis,
232
+ medians: z.boolean().optional(),
233
+ points: z.array(z.object({
234
+ x: value, y: value,
235
+ label: z.string().trim().min(1).max(32, 'keep point labels to 32 characters').optional(),
236
+ tone: TONE.optional(),
237
+ }).strict()).min(3, 'at least 3 points').max(1000, 'at most 1000 points'),
238
+ }).strict();
239
+
240
+ /** A small count made concrete: one square per thing counted, the ones that matter filled. */
241
+ export const UnitsSpec = z.object({
242
+ type: z.literal('units'),
243
+ title: common.title,
244
+ note: common.note,
245
+ source: common.source,
246
+ style: common.style,
247
+ 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'),
248
+ parts: z.array(z.object({
249
+ label: label.max(32, 'keep part labels to 32 characters'),
250
+ count: z.number({ error: 'a whole number' }).int('a whole number; units are things, not fractions').positive('more than 0'),
251
+ tone: TONE.optional(),
252
+ }).strict()).min(1).max(3, 'at most 3 filled parts; the rest are the empty squares'),
253
+ rest: label.max(32, 'keep the rest label to 32 characters'),
150
254
  }).strict();
151
255
 
152
256
  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"');
@@ -177,13 +281,37 @@ export const EntitiesSpec = z.object({
177
281
  }).strict()).min(1, 'at least 1 reference').max(8, 'at most 8 references; more is two figures, not a reference left out'),
178
282
  }).strict();
179
283
 
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 };
284
+ /** Who calls whom, in order: participants side by side and numbered messages between them. */
285
+ export const SequenceSpec = z.object({ type: z.literal('sequence'), title, note: common.note, source: common.source, style: common.style, ...sequenceShape }).strict();
286
+
287
+ /** The states one thing moves through and the moves between them, with optional counts on the moves. */
288
+ export const StatesSpec = z.object({ type: z.literal('states'), title, note: common.note, source: common.source, style: common.style, ...statesShape }).strict();
182
289
 
183
- const items = (s) => s.parts ?? s.bars ?? s.rows ?? s.records ?? (s.after ? [s.after] : []);
290
+ export const Spec = z.discriminatedUnion('type', [SplitSpec, BarsSpec, EstimatesSpec, BeforeAfterSpec, DumbbellSpec, SeriesSpec, EntitiesSpec, GroupsSpec, LinesSpec, PanelsSpec, HeatmapSpec, DotsSpec, ScatterSpec, UnitsSpec, SequenceSpec, StatesSpec]);
291
+ export const TYPES = { split: SplitSpec, bars: BarsSpec, estimates: EstimatesSpec, '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, states: StatesSpec };
292
+
293
+
294
+ const items = (s) => s.parts ?? s.bars ?? s.rows ?? s.records ?? s.messages ?? (s.type === 'states' ? [...s.states, ...s.moves] : null) ?? (s.type === 'scatter' ? s.points : s.type === 'dots' ? s.groups : (s.after ? [s.after] : []));
184
295
  const kind = (v) => (typeof v === 'number' ? 'number' : 'date');
185
296
 
186
297
 
298
+ /**
299
+ * Ranges: a low and a high together, around their value, on every item or none (a value with no range among ones
300
+ * with ranges reads as certain), and said what they are. `of` gives each item's value.
301
+ */
302
+ function rangeChecks(items, of, desc, at) {
303
+ const out = [];
304
+ const has = items.map((x) => x.low !== undefined || x.high !== undefined);
305
+ items.forEach((x, i) => {
306
+ if (has[i] && (x.low === undefined || x.high === undefined)) out.push(`${at}[${i}]: a range needs both "low" and "high"`);
307
+ 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`);
308
+ });
309
+ if (has.some(Boolean) && !has.every(Boolean)) out.push(`${at}: give every item a range or none; one without reads as certain`);
310
+ if (has.some(Boolean) && !desc) out.push('range: required with ranges: say what they are, e.g. "95% interval" or "lowest to highest run"');
311
+ if (!has.some(Boolean) && desc) out.push('range: there are no ranges to describe; give items "low" and "high", or leave "range" out');
312
+ return out;
313
+ }
314
+
187
315
  function groupsChecks(s, at = '') {
188
316
  const out = [];
189
317
  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}`); });
@@ -203,11 +331,17 @@ function linesChecks(s, at = '') {
203
331
  if (new Set(names).size < names.length) out.push(`${at}series: two lines have the same label`);
204
332
  if (!names.includes(s.accent)) out.push(`accent: must be the label of one of the lines, ${names.map((x) => `"${x}"`).join(', ')}`);
205
333
  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`); }
334
+ const ranged = s.series.filter((l) => l.points.some((p) => p.low !== undefined || p.high !== undefined));
335
+ s.series.forEach((l, i) => { if (ranged.includes(l)) out.push(...rangeChecks(l.points, (p) => p.y, s.range ?? 'given', `${at}series[${i}].points`)); });
336
+ if (!ranged.length && s.range) out.push('range: there are no ranges to describe; give points "low" and "high", or leave "range" out');
337
+ if (ranged.length && !s.range) out.push('range: required with ranges: say what they are, e.g. "95% interval"');
206
338
  return out;
207
339
  }
208
340
 
209
341
  /** Rules that span fields: x values of one kind, in order, and a mark that is one of the points. */
210
342
  function crossChecks(s) {
343
+ if (s.type === 'sequence') return sequenceChecks(s);
344
+ if (s.type === 'states') return statesChecks(s);
211
345
  if (s.type === 'groups') return groupsChecks(s);
212
346
  if (s.type === 'lines') return linesChecks(s);
213
347
  if (s.type === 'panels') {
@@ -223,7 +357,7 @@ function crossChecks(s) {
223
357
  } else {
224
358
  if (!p.series || p.groups) { out.push(`${at}: a lines panel has "series" and no "groups"`); return; }
225
359
  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));
360
+ out.push(...linesChecks({ series: p.series, accent: s.accent, range: s.range }, at));
227
361
  const names = (q) => q.series.map((l) => l.label).join('|');
228
362
  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
363
  }
@@ -242,7 +376,64 @@ function crossChecks(s) {
242
376
  const kinds = new Set(s.rows.flatMap((r) => [kind(r.from), kind(r.to)]));
243
377
  if (kinds.size > 1) return ['rows: use all numbers or all dates, not both'];
244
378
  }
379
+ if (s.type === 'estimates') {
380
+ const out = [];
381
+ s.rows.forEach((r, i) => {
382
+ if (!(r.low <= r.value && r.value <= r.high)) out.push(`rows[${i}]: the range ${r.low} to ${r.high} does not contain its value ${r.value}; low ≤ value ≤ high`);
383
+ else if (r.low === r.high) out.push(`rows[${i}]: a range from ${r.low} to ${r.high} says the number is exact; give its real range, or draw a "bars" chart`);
384
+ });
385
+ const names = s.rows.map((r) => r.label);
386
+ if (new Set(names).size < names.length) out.push('rows: two rows have the same label');
387
+ const ns = s.rows.map((r) => r.n !== undefined);
388
+ if (ns.some(Boolean) && !ns.every(Boolean)) out.push('rows: give every row an "n" or none');
389
+ return out;
390
+ }
391
+ if (s.type === 'bars') {
392
+ const out = rangeChecks(s.bars, (b) => b.value, s.range, 'bars');
393
+ if (s.ref) {
394
+ if (!s.note) out.push('note: required with ref: say what the number is ("AshIndex score, higher is better"), so the reference value means something');
395
+ s.bars.forEach((b, i) => { if (b.tone) out.push(`bars[${i}].tone: with a ref, figkit colours the bars above it and greys the rest; leave tone out`); });
396
+ if (s.max !== undefined && s.ref.value > s.max) out.push('ref.value: must be at most max');
397
+ }
398
+ 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`); });
399
+ const ns = s.bars.map((b) => b.n !== undefined);
400
+ if (ns.some(Boolean) && !ns.every(Boolean)) out.push('bars: give every bar an "n" or none');
401
+ 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`); });
402
+ return out;
403
+ }
404
+ if (s.type === 'dots') {
405
+ const out = [];
406
+ const names = s.groups.map((g) => g.label);
407
+ if (new Set(names).size < names.length) out.push('groups: two groups have the same label');
408
+ if (s.groups.reduce((a, g) => a + g.values.length, 0) > 600) out.push('groups: at most 600 dots in all');
409
+ if (s.mark) {
410
+ const g = s.groups.find((x) => x.label === s.mark.group);
411
+ if (!g) out.push(`mark.group: must be one of ${names.map((x) => `"${x}"`).join(', ')}`);
412
+ else if (!g.values.includes(s.mark.value)) out.push(`mark.value: must be one of the values in "${g.label}"`);
413
+ }
414
+ 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');
415
+ 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');
416
+ return out;
417
+ }
418
+ if (s.type === 'scatter') {
419
+ const out = [];
420
+ for (const k of ['x', 'y']) {
421
+ if (s[k].log && s.points.some((p) => p[k] <= 0)) out.push(`${k}.log: a log axis needs every ${k} above 0`);
422
+ if (s[k].log && s[k].zero) out.push(`${k}: a log axis has no zero; leave out "zero"`);
423
+ }
424
+ if (s.points.filter((p) => p.label).length > 5) out.push('points: label at most 5 points; the title is about one or two');
425
+ 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"`);
426
+ return out;
427
+ }
428
+ if (s.type === 'units') {
429
+ const filled = s.parts.reduce((a, p) => a + p.count, 0);
430
+ if (filled > s.total) return [`parts: the counts add up to ${filled}, more than the total of ${s.total}`];
431
+ 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`];
432
+ return [];
433
+ }
245
434
  if (s.type === 'series') {
435
+ const ranged = rangeChecks(s.points, (p) => p.y, s.range, 'points');
436
+ if (ranged.length) return ranged;
246
437
  const kinds = new Set(s.points.map((p) => kind(p.x)));
247
438
  if (kinds.size > 1) return ['points: x values must be all numbers or all dates, not both'];
248
439
  const at = (x) => (typeof x === 'number' ? x : Date.parse(x));