@liggi/figkit 0.4.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.
- package/GUIDE.md +94 -7
- package/bin/fig.mjs +3 -3
- package/dist/viz.iife.js +81 -18
- package/examples/charts/bars-ref.json +6 -0
- package/examples/charts/estimates.json +7 -0
- package/examples/charts/heatmap-signed.json +5 -0
- package/examples/charts/states.json +71 -0
- package/package.json +1 -1
- package/runtime/fig.css +39 -0
- package/runtime/fig.js +73 -3
- package/viz/draw.js +155 -14
- package/viz/spec.js +59 -6
- package/viz/states-spec.js +161 -0
- package/viz/states.js +203 -0
package/viz/spec.js
CHANGED
|
@@ -1,6 +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
3
|
import { sequenceShape, sequenceChecks } from './sequence-spec.js';
|
|
4
|
+
import { statesShape, statesChecks } from './states-spec.js';
|
|
4
5
|
|
|
5
6
|
const TONE = z.enum(['accent', 'second', 'good', 'bad']);
|
|
6
7
|
const label = z.string({ error: 'needs a label' }).trim().min(1, 'needs a label').max(40, 'keep labels to 40 characters');
|
|
@@ -50,6 +51,11 @@ export const BarsSpec = z.object({
|
|
|
50
51
|
}).strict()).min(1).max(12, 'at most 12 bars'),
|
|
51
52
|
range,
|
|
52
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(),
|
|
53
59
|
}).strict()
|
|
54
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'] });
|
|
55
61
|
|
|
@@ -88,6 +94,31 @@ export const DumbbellSpec = z.object({
|
|
|
88
94
|
}).strict()).min(1).max(8, 'at most 8 rows'),
|
|
89
95
|
}).strict();
|
|
90
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
|
+
|
|
91
122
|
/** A line over time (or any ordered x) with one point marked and labelled. */
|
|
92
123
|
export const SeriesSpec = z.object({
|
|
93
124
|
type: z.literal('series'),
|
|
@@ -154,15 +185,15 @@ export const PanelsSpec = z.object({
|
|
|
154
185
|
}).strict();
|
|
155
186
|
|
|
156
187
|
/** 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
|
|
188
|
+
const axisLabel = z.union([z.string().trim().min(1).max(16, 'keep column labels to 16 characters'), z.number().finite()]);
|
|
158
189
|
export const HeatmapSpec = z.object({
|
|
159
190
|
type: z.literal('heatmap'),
|
|
160
191
|
...common,
|
|
161
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'),
|
|
162
|
-
rows: z.array(
|
|
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'),
|
|
163
194
|
columns: z.array(axisLabel).min(2).max(31, 'at most 31 columns'),
|
|
164
195
|
values: z.array(z.array(value)),
|
|
165
|
-
mark: z.object({ row:
|
|
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(),
|
|
166
197
|
}).strict();
|
|
167
198
|
|
|
168
199
|
/** Every value as a dot: one row per group, the dots stacked where they would overlap, so none hides another. */
|
|
@@ -253,10 +284,14 @@ export const EntitiesSpec = z.object({
|
|
|
253
284
|
/** Who calls whom, in order: participants side by side and numbered messages between them. */
|
|
254
285
|
export const SequenceSpec = z.object({ type: z.literal('sequence'), title, note: common.note, source: common.source, style: common.style, ...sequenceShape }).strict();
|
|
255
286
|
|
|
256
|
-
|
|
257
|
-
export const
|
|
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();
|
|
289
|
+
|
|
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 };
|
|
258
292
|
|
|
259
|
-
|
|
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] : []));
|
|
260
295
|
const kind = (v) => (typeof v === 'number' ? 'number' : 'date');
|
|
261
296
|
|
|
262
297
|
|
|
@@ -306,6 +341,7 @@ function linesChecks(s, at = '') {
|
|
|
306
341
|
/** Rules that span fields: x values of one kind, in order, and a mark that is one of the points. */
|
|
307
342
|
function crossChecks(s) {
|
|
308
343
|
if (s.type === 'sequence') return sequenceChecks(s);
|
|
344
|
+
if (s.type === 'states') return statesChecks(s);
|
|
309
345
|
if (s.type === 'groups') return groupsChecks(s);
|
|
310
346
|
if (s.type === 'lines') return linesChecks(s);
|
|
311
347
|
if (s.type === 'panels') {
|
|
@@ -340,8 +376,25 @@ function crossChecks(s) {
|
|
|
340
376
|
const kinds = new Set(s.rows.flatMap((r) => [kind(r.from), kind(r.to)]));
|
|
341
377
|
if (kinds.size > 1) return ['rows: use all numbers or all dates, not both'];
|
|
342
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
|
+
}
|
|
343
391
|
if (s.type === 'bars') {
|
|
344
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
|
+
}
|
|
345
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`); });
|
|
346
399
|
const ns = s.bars.map((b) => b.n !== undefined);
|
|
347
400
|
if (ns.some(Boolean) && !ns.every(Boolean)) out.push('bars: give every bar an "n" or none');
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
/* The "states" spec: the states one thing moves through and the moves between them. spec.js adds it to the union. */
|
|
2
|
+
import { z } from 'zod';
|
|
3
|
+
|
|
4
|
+
const slug = z.string({ error: 'needs an id, e.g. "open"' }).regex(/^[a-z][a-z0-9-]{0,23}$/, 'ids are short lowercase words, e.g. "open" or "in-review"');
|
|
5
|
+
const text = (max, what, long) => z.string({ error: `needs ${what}` }).trim().min(1, `needs ${what}`).max(max, long);
|
|
6
|
+
const count = z.number({ error: 'a count, a whole number' }).int('a count, a whole number').nonnegative('a count is 0 or more').optional();
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* One thing's lifecycle: the states it can be in, where it starts, where it can stop, and each move from one state to
|
|
10
|
+
* another with what makes it happen. A count on a move is how many of the things made that move at least once.
|
|
11
|
+
*/
|
|
12
|
+
export const statesShape = {
|
|
13
|
+
entry: z.string().trim().min(1).max(28, 'keep the entry label to 28 characters: what starts the lifecycle').optional(),
|
|
14
|
+
each: z.string().trim().min(1).max(16, 'keep it to 16 characters, a plural noun such as "orders"').optional(),
|
|
15
|
+
shares: z.boolean().optional(),
|
|
16
|
+
states: z.array(z.object({
|
|
17
|
+
id: slug,
|
|
18
|
+
name: text(24, 'a name', 'keep state names to 24 characters'),
|
|
19
|
+
note: z.string().trim().min(1).max(32, 'keep the note to 32 characters: one condition or rule').optional(),
|
|
20
|
+
start: z.literal(true, { error: 'start: true on the one state things begin in, or leave it out' }).optional(),
|
|
21
|
+
end: z.literal(true, { error: 'end: true on a state things stop in, or leave it out' }).optional(),
|
|
22
|
+
count,
|
|
23
|
+
stayed: count,
|
|
24
|
+
tone: z.literal('accent', { error: 'tone: only "accent", on the one state to notice' }).optional(),
|
|
25
|
+
}).strict()).min(2, 'at least 2 states; one state is a sentence').max(7, 'at most 7 states; more is two figures (the main path with the side branches merged, then one branch)'),
|
|
26
|
+
moves: z.array(z.object({
|
|
27
|
+
from: z.union([slug, z.array(slug).min(2, 'a list of states needs at least 2; give one state as a plain id').max(4, 'at most 4 states in one "from"')], { error: 'from: a state id, or a list of ids for a move every one of them can make' }),
|
|
28
|
+
to: slug,
|
|
29
|
+
label: z.string().trim().min(1).max(28, 'keep move labels to 28 characters: what makes the move happen').optional(),
|
|
30
|
+
count,
|
|
31
|
+
back: z.string().trim().min(1).max(20, 'keep the back label to 20 characters').optional(),
|
|
32
|
+
backCount: count,
|
|
33
|
+
tone: z.literal('accent', { error: 'tone: only "accent", on the moves to notice' }).optional(),
|
|
34
|
+
}).strict()).min(1, 'at least 1 move').max(10, 'at most 10 moves; more is two figures (the main path, then the exceptions)'),
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
const list = (ids) => ids.map((x) => `"${x}"`).join(', ');
|
|
38
|
+
const froms = (m) => (Array.isArray(m.from) ? m.from : [m.from]);
|
|
39
|
+
const fmt = (n) => n.toLocaleString('en-GB');
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* The moves as edges, each forward or a loop back. A depth-first walk from the start, in the order the moves are
|
|
43
|
+
* written, calls a move a loop when it returns to a state still on the path that reached it. A move's `back` is the
|
|
44
|
+
* same move the other way, so it is always a loop.
|
|
45
|
+
*/
|
|
46
|
+
export function stateGraph(s) {
|
|
47
|
+
const edges = [];
|
|
48
|
+
s.moves.forEach((m, i) => {
|
|
49
|
+
for (const f of froms(m)) edges.push({ from: f, to: m.to, move: i, list: froms(m).length > 1 });
|
|
50
|
+
if (m.back) for (const f of froms(m)) edges.push({ from: m.to, to: f, move: i, back: true, list: froms(m).length > 1, loop: true });
|
|
51
|
+
});
|
|
52
|
+
const start = s.states.find((x) => x.start)?.id;
|
|
53
|
+
const seen = new Set(), stack = new Set();
|
|
54
|
+
const walk = (id) => {
|
|
55
|
+
seen.add(id); stack.add(id);
|
|
56
|
+
for (const e of edges) if (e.from === id && !e.back) {
|
|
57
|
+
if (stack.has(e.to)) e.loop = true;
|
|
58
|
+
else if (!seen.has(e.to)) walk(e.to);
|
|
59
|
+
}
|
|
60
|
+
stack.delete(id);
|
|
61
|
+
};
|
|
62
|
+
if (start) walk(start);
|
|
63
|
+
for (const e of edges) e.loop = !!e.loop;
|
|
64
|
+
return { edges, start, seen };
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* How many reached each state: the start's own count, or the counts on the forward moves into it. A loop back brings
|
|
69
|
+
* things that were already counted, so it adds none.
|
|
70
|
+
*/
|
|
71
|
+
export function reachedCounts(s, edges) {
|
|
72
|
+
const reached = {};
|
|
73
|
+
for (const st of s.states) if (st.start) reached[st.id] = st.count;
|
|
74
|
+
s.moves.forEach((m, i) => {
|
|
75
|
+
if (m.count == null || edges.some((e) => e.move === i && !e.back && e.loop)) return;
|
|
76
|
+
reached[m.to] = (reached[m.to] ?? 0) + m.count;
|
|
77
|
+
});
|
|
78
|
+
return reached;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** Rules that span fields: ids that exist, one start, ends with no way out, every state reachable, counts that add up. */
|
|
82
|
+
export function statesChecks(s) {
|
|
83
|
+
const out = [];
|
|
84
|
+
const ids = s.states.map((x) => x.id);
|
|
85
|
+
const dup = ids.find((id, i) => ids.indexOf(id) !== i);
|
|
86
|
+
if (dup) return [`states: two states have the id "${dup}"`];
|
|
87
|
+
const byId = Object.fromEntries(s.states.map((x) => [x.id, x]));
|
|
88
|
+
s.moves.forEach((m, i) => {
|
|
89
|
+
for (const f of froms(m)) if (!byId[f]) out.push(`moves[${i}].from: no state has the id "${f}"; the ids are ${list(ids)}`);
|
|
90
|
+
if (!byId[m.to]) out.push(`moves[${i}].to: no state has the id "${m.to}"; the ids are ${list(ids)}`);
|
|
91
|
+
if (froms(m).includes(m.to)) out.push(`moves[${i}]: a move from "${m.to}" to itself; say it in the state's note instead, e.g. "Restart: review again"`);
|
|
92
|
+
if (new Set(froms(m)).size !== froms(m).length) out.push(`moves[${i}].from: a state is listed twice`);
|
|
93
|
+
if (m.backCount != null && !m.back) out.push(`moves[${i}]: backCount without back; back names the move the other way`);
|
|
94
|
+
});
|
|
95
|
+
if (out.length) return out;
|
|
96
|
+
const pairs = new Map();
|
|
97
|
+
s.moves.forEach((m, i) => froms(m).forEach((f) => {
|
|
98
|
+
for (const [a, b] of [[f, m.to], ...(m.back ? [[m.to, f]] : [])]) {
|
|
99
|
+
const key = a + '>' + b;
|
|
100
|
+
if (pairs.has(key)) out.push(`moves[${i}]: a second move from "${a}" to "${b}" (moves[${pairs.get(key)}] is the first); make it one move with one label`);
|
|
101
|
+
pairs.set(key, i);
|
|
102
|
+
}
|
|
103
|
+
}));
|
|
104
|
+
|
|
105
|
+
const starts = s.states.filter((x) => x.start);
|
|
106
|
+
if (starts.length === 0) out.push('states: mark the state things begin in with "start": true');
|
|
107
|
+
if (starts.length > 1) out.push(`states: ${list(starts.map((x) => x.id))} ${starts.length === 2 ? 'are both' : 'are all'} marked start; a lifecycle begins in one state, so draw one figure per way in`);
|
|
108
|
+
for (const st of s.states) if (st.start && st.end) out.push(`states: "${st.id}" is both start and end; nothing moves`);
|
|
109
|
+
const { edges, seen } = stateGraph(s);
|
|
110
|
+
for (const st of s.states) {
|
|
111
|
+
const outs = edges.filter((e) => e.from === st.id);
|
|
112
|
+
if (st.end && outs.length) out.push(`states: "${st.id}" is an end, but ${outs[0].back ? `moves[${outs[0].move}].back leads out of it` : `moves[${outs[0].move}] leaves it`}; an end has no way out, so drop "end" or the move`);
|
|
113
|
+
if (!st.end && !outs.length) out.push(`states: nothing leaves "${st.id}"; mark it "end": true if things stop there, or add the move out`);
|
|
114
|
+
if (starts.length === 1 && !seen.has(st.id)) out.push(`states: "${st.id}" cannot be reached from the start "${starts[0].id}"; add the move into it, or leave it out`);
|
|
115
|
+
}
|
|
116
|
+
const accentS = s.states.filter((x) => x.tone).length, accentM = s.moves.filter((m) => m.tone).length;
|
|
117
|
+
if (accentS > 1) out.push('states: accent at most one state, the one to notice');
|
|
118
|
+
if (accentS && accentM) out.push('tone: accent one state or some moves, not both');
|
|
119
|
+
if (accentM && accentM === s.moves.length && s.moves.length > 1) out.push('moves: every move is accented, so none stands out; accent only the moves the title is about');
|
|
120
|
+
if (out.length) return out;
|
|
121
|
+
|
|
122
|
+
// Counts: on every move or none, with what is counted and how many entered.
|
|
123
|
+
const counted = s.moves.filter((m) => m.count != null).length;
|
|
124
|
+
for (const st of s.states) if (st.count != null && !st.start) out.push(`states: "${st.id}" has a count; only the start takes one (how many entered), and the counts on the moves say the rest`);
|
|
125
|
+
if (counted && counted < s.moves.length) out.push(`moves: ${counted} of ${s.moves.length} moves have a count; give every move one or none, since a move without reads as none`);
|
|
126
|
+
if (s.moves.some((m) => m.back && (m.backCount != null) !== (m.count != null))) out.push('moves: a move with back and a count needs backCount too, and only then');
|
|
127
|
+
if (!counted && starts[0]?.count != null) out.push(`states: the start "${starts[0].id}" has a count but no move does; give every move its count, or leave the start's out`);
|
|
128
|
+
for (const st of s.states) if (st.stayed != null && st.end) out.push(`states: "${st.id}" is an end, so everything that reaches it stays; leave out its stayed`);
|
|
129
|
+
if (!counted && s.states.some((st) => st.stayed != null)) out.push('states: stayed is for counts; give every move a count, or leave stayed out');
|
|
130
|
+
if (!counted && (s.each || s.shares)) out.push(`${s.shares ? 'shares' : 'each'}: only for counts; give every move a count, or leave it out`);
|
|
131
|
+
if (counted && !s.each) out.push('each: say what one count is, as a plural noun such as "orders"');
|
|
132
|
+
if (counted && starts[0]?.count == null) out.push(`states: give the start "${starts[0].id}" a count, how many entered; the moves are counted out of it`);
|
|
133
|
+
if (out.length) return out;
|
|
134
|
+
if (!counted) return out;
|
|
135
|
+
|
|
136
|
+
const reached = reachedCounts(s, edges);
|
|
137
|
+
const loopInto = new Set(edges.filter((e) => e.loop).map((e) => e.to));
|
|
138
|
+
s.moves.forEach((m, i) => {
|
|
139
|
+
const from = froms(m), base = from.reduce((a, f) => a + (reached[f] ?? 0), 0);
|
|
140
|
+
if (m.count > base) out.push(`moves[${i}]: ${fmt(m.count)} ${s.each} moved from ${from.length > 1 ? 'those states' : `"${from[0]}"`}, but only ${fmt(base)} reached ${from.length > 1 ? 'them' : 'it'}; a count is how many made the move at least once`);
|
|
141
|
+
if (m.back && m.backCount > (reached[m.to] ?? 0)) out.push(`moves[${i}].backCount: ${fmt(m.backCount)} moved back from "${m.to}", but only ${fmt(reached[m.to] ?? 0)} reached it`);
|
|
142
|
+
});
|
|
143
|
+
// A state nothing loops back into is passed through once, so the moves out of it cannot add up to more than reached it.
|
|
144
|
+
for (const st of s.states) {
|
|
145
|
+
if (loopInto.has(st.id) || reached[st.id] == null) continue;
|
|
146
|
+
const leave = edges.filter((e) => e.from === st.id && !e.list).map((e) => (e.back ? s.moves[e.move].backCount : s.moves[e.move].count)).reduce((a, v) => a + (v ?? 0), 0);
|
|
147
|
+
if (leave > reached[st.id]) out.push(`states: ${fmt(leave)} ${s.each} leave "${st.id}", but only ${fmt(reached[st.id])} reached it, and nothing loops back into it`);
|
|
148
|
+
}
|
|
149
|
+
for (const st of s.states) if (st.stayed != null && st.stayed > (reached[st.id] ?? 0)) out.push(`states: ${fmt(st.stayed)} ${s.each} stayed in "${st.id}", but only ${fmt(reached[st.id] ?? 0)} reached it`);
|
|
150
|
+
// Each thing ends in one state, so those that stayed and those that reached an end cannot outnumber those that entered.
|
|
151
|
+
const ended = s.states.reduce((a, st) => a + (st.stayed ?? 0) + (st.end ? reached[st.id] ?? 0 : 0), 0);
|
|
152
|
+
const entered = starts[0].count;
|
|
153
|
+
if (ended > entered) out.push(`states: ${fmt(ended)} ${s.each} stayed or reached an end, but only ${fmt(entered)} entered; each ends in one state`);
|
|
154
|
+
if (s.shares) {
|
|
155
|
+
s.moves.forEach((m, i) => {
|
|
156
|
+
const base = froms(m).reduce((a, f) => a + (reached[f] ?? 0), 0);
|
|
157
|
+
if (base < 10) out.push(`moves[${i}]: a share of ${fmt(base)} ${s.each} reads as more certain than it is; leave shares out and show the counts`);
|
|
158
|
+
});
|
|
159
|
+
}
|
|
160
|
+
return out;
|
|
161
|
+
}
|
package/viz/states.js
ADDED
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
/* The "states" spec drawn: the main path down one column, side states beside it, loops back on the left. */
|
|
2
|
+
import { stateGraph, reachedCounts } from './states-spec.js';
|
|
3
|
+
|
|
4
|
+
const esc = (s) => String(s).replace(/[&<>"]/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"' })[c]);
|
|
5
|
+
const fmt = (n) => n.toLocaleString('en-GB');
|
|
6
|
+
const froms = (m) => (Array.isArray(m.from) ? m.from : [m.from]);
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Where each state sits. The longest run of forward moves from the start is the main path, one state a row down a
|
|
10
|
+
* single column, so the lifecycle reads top to bottom. Every other state sits to the right of it, level with the
|
|
11
|
+
* states it moves to and from. Loops back up the main path run down its left side, in a lane kept for their labels.
|
|
12
|
+
*/
|
|
13
|
+
export function placeStates(spec) {
|
|
14
|
+
const { edges, start } = stateGraph(spec);
|
|
15
|
+
const fwd = edges.filter((e) => !e.loop);
|
|
16
|
+
// A move several states can make (retire, cancel) is an exit beside the path, never a step along it.
|
|
17
|
+
const path = fwd.filter((e) => !e.list);
|
|
18
|
+
const rank = { [start]: 0 };
|
|
19
|
+
for (let k = 0; k < spec.states.length; k++) for (const e of path) if (rank[e.from] != null && (rank[e.to] ?? -1) < rank[e.from] + 1) rank[e.to] = rank[e.from] + 1;
|
|
20
|
+
const order = spec.states.map((s) => s.id);
|
|
21
|
+
// The main path ends at the deepest state, an end state when one is that deep, the first written when several are.
|
|
22
|
+
const deepest = Math.max(...Object.values(rank));
|
|
23
|
+
for (const e of fwd) if (rank[e.to] == null) rank[e.to] = deepest + 1;
|
|
24
|
+
const tips = order.filter((id) => rank[id] === deepest);
|
|
25
|
+
let at = tips.find((id) => spec.states.find((s) => s.id === id).end) ?? tips[0];
|
|
26
|
+
const main = [at];
|
|
27
|
+
while (at !== start) {
|
|
28
|
+
const prev = order.find((id) => rank[id] === rank[at] - 1 && path.some((e) => e.from === id && e.to === at));
|
|
29
|
+
main.unshift(prev);
|
|
30
|
+
at = prev;
|
|
31
|
+
}
|
|
32
|
+
const cell = {};
|
|
33
|
+
main.forEach((id, i) => { cell[id] = [i, 0, i]; });
|
|
34
|
+
const taken = (r, c) => Object.values(cell).some(([a, b, z]) => b === c && r >= a && r <= z);
|
|
35
|
+
// A state that several main-path states can move to (retire, cancel) sits level with the middle of them, and one
|
|
36
|
+
// bracket beside them leads to it with one arrow and one label, so no move shares a line with another.
|
|
37
|
+
const bracket = {};
|
|
38
|
+
const lists = order.filter((id) => !main.includes(id) && edges.some((e) => e.to === id && e.list && !e.back));
|
|
39
|
+
for (const id of lists) {
|
|
40
|
+
const srcs = edges.filter((e) => e.to === id && e.list && !e.back).map((e) => cell[e.from]);
|
|
41
|
+
if (srcs.some((c) => !c || c[1] !== 0)) continue;
|
|
42
|
+
const lo = Math.min(...srcs.map((c) => c[0])), hi = Math.max(...srcs.map((c) => c[0]));
|
|
43
|
+
for (let c = 1; c <= 3; c++) {
|
|
44
|
+
let free = true;
|
|
45
|
+
for (let r = lo; r <= hi; r++) if (taken(r, c)) free = false;
|
|
46
|
+
if (free) { cell[id] = [lo, c, hi]; bracket[id] = [lo, hi]; break; }
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
const rest = order.filter((id) => !cell[id]).sort((a, b) => rank[a] - rank[b] || order.indexOf(a) - order.indexOf(b));
|
|
50
|
+
for (const id of rest) {
|
|
51
|
+
const near = edges.filter((e) => (e.from === id && cell[e.to]) || (e.to === id && cell[e.from])).map((e) => cell[e.from === id ? e.to : e.from][0]);
|
|
52
|
+
const want = near.length ? Math.round(near.reduce((a, v) => a + v, 0) / near.length) : Math.min(rank[id], main.length - 1);
|
|
53
|
+
let best = null;
|
|
54
|
+
for (let c = 1; c <= 3 && !best; c++) for (const d of [0, 1, -1, 2, -2]) {
|
|
55
|
+
const r = want + d;
|
|
56
|
+
if (r >= 0 && r < main.length + 1 && !taken(r, c)) { best = [r, c, r]; break; }
|
|
57
|
+
}
|
|
58
|
+
cell[id] = best;
|
|
59
|
+
}
|
|
60
|
+
const rows = Math.max(...Object.values(cell).map(([, , z]) => z)) + 1, cols = Math.max(...Object.values(cell).map(([, c]) => c)) + 1;
|
|
61
|
+
// A loop back up the main column runs on its left; any other move finds its own way.
|
|
62
|
+
const sides = edges.map((e) => (cell[e.from][1] === 0 && cell[e.to][1] === 0 && cell[e.to][0] < cell[e.from][0] ? 'left' : null));
|
|
63
|
+
return { cell, rows, cols, edges, sides, main, bracket };
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** States as boxes on a grid figkit chose, the start's dot above the first, and each move a <fig-arrow> with its label. */
|
|
67
|
+
export function drawStates(spec, main) {
|
|
68
|
+
const phone = main.classList.contains('phone');
|
|
69
|
+
const { cell, rows, cols, edges, sides, bracket } = placeStates(spec);
|
|
70
|
+
const reached = spec.moves.some((m) => m.count != null) ? reachedCounts(spec, edges) : null;
|
|
71
|
+
const share = (n, m) => {
|
|
72
|
+
if (!spec.shares) return '';
|
|
73
|
+
const base = froms(m).reduce((a, f) => a + (reached[f] ?? 0), 0);
|
|
74
|
+
return ` (${Math.round((n / base) * 100)}%)`;
|
|
75
|
+
};
|
|
76
|
+
// A move several states make, drawn as one arrow from a bracket beside them.
|
|
77
|
+
const across = (m) => Array.isArray(m.from) && !!bracket[m.to];
|
|
78
|
+
const title = (m) => m.label;
|
|
79
|
+
const lines = (m) => {
|
|
80
|
+
const out = [];
|
|
81
|
+
if (m.count != null) out.push(fmt(m.count) + share(m.count, m));
|
|
82
|
+
if (m.back) out.push(`Back: ${m.back}${m.backCount != null ? ' ' + fmt(m.backCount) : ''}`);
|
|
83
|
+
return out.join('\n');
|
|
84
|
+
};
|
|
85
|
+
|
|
86
|
+
// The lane on the left is as wide as the widest loop label.
|
|
87
|
+
const loops = edges.map((e, i) => (sides[i] === 'left' ? e : null)).filter(Boolean);
|
|
88
|
+
const probe = (m) => {
|
|
89
|
+
const lab = document.createElement('div');
|
|
90
|
+
lab.className = 'fig-wire-label';
|
|
91
|
+
lab.style.cssText = 'position:absolute;visibility:hidden;transform:none';
|
|
92
|
+
lab.textContent = title(m) ?? '';
|
|
93
|
+
for (const t of lines(m).split('\n').filter(Boolean)) { const s = document.createElement('span'); s.className = 'fig-wire-sub'; s.textContent = t; lab.appendChild(s); }
|
|
94
|
+
main.appendChild(lab);
|
|
95
|
+
const w = lab.offsetWidth;
|
|
96
|
+
lab.remove();
|
|
97
|
+
return w;
|
|
98
|
+
};
|
|
99
|
+
// A loop's label sits on its line, which runs in the column gap, so the lane only adds what the gap lacks.
|
|
100
|
+
// The gap beside a bracket holds its label on one line between the bracket and the state it leads to.
|
|
101
|
+
const into = spec.moves.filter(across).map(probe);
|
|
102
|
+
const gapX = Math.max(phone ? 64 : 120, ...into.map((w) => w + (phone ? 64 : 84)));
|
|
103
|
+
const lane = loops.length ? Math.max(8, Math.max(...loops.map((e) => probe(spec.moves[e.move]))) / 2 + 40 - gapX) : 0;
|
|
104
|
+
const box = phone ? 200 : 250;
|
|
105
|
+
// The labels on the main column's own arrows, which sit on its line: on a phone the column stays at least as wide.
|
|
106
|
+
const down = spec.moves.filter((m, i) => edges.some((e, k) => e.move === i && !e.loop && !sides[k] && cell[e.from][1] === 0 && cell[e.to][1] === 0));
|
|
107
|
+
const downW = Math.min(box, Math.max(0, ...down.map(probe)));
|
|
108
|
+
|
|
109
|
+
// On a desktop the figure is only as wide as the picture: the columns, the loop lane, and room for the labels beside
|
|
110
|
+
// the main column's arrows when nothing sits to its right. A phone keeps the phone width.
|
|
111
|
+
if (!phone) {
|
|
112
|
+
const gap = gapX, pad = parseFloat(getComputedStyle(main).paddingLeft) || 44;
|
|
113
|
+
const right = cols === 1 ? Math.max(0, ...spec.moves.map(probe)) + 40 - box / 2 : 0;
|
|
114
|
+
const grid = (lane ? lane + gap : 0) + cols * box + (cols - 1) * gap;
|
|
115
|
+
const need = grid + (cols === 1 ? right : 0);
|
|
116
|
+
main.style.setProperty('--w', Math.round(Math.min(960, Math.max(640, need + 2 * pad))) + 'px');
|
|
117
|
+
}
|
|
118
|
+
const grid = document.createElement('div');
|
|
119
|
+
grid.className = 'states';
|
|
120
|
+
grid.style.columnGap = gapX + 'px';
|
|
121
|
+
grid.style.gridTemplateColumns = [lane ? `${lane}px` : null, ...Array.from({ length: cols }, (_, c) => `minmax(${c === 0 && phone ? downW : 0}px, ${box}px)`)].filter(Boolean).join(' ');
|
|
122
|
+
grid.style.gridTemplateRows = `auto repeat(${rows}, auto)`;
|
|
123
|
+
const col0 = lane ? 2 : 1;
|
|
124
|
+
const dot = document.createElement('div');
|
|
125
|
+
dot.className = 'node bare st-dot';
|
|
126
|
+
dot.id = 'st--start';
|
|
127
|
+
dot.style.gridArea = `1 / ${col0}`;
|
|
128
|
+
grid.appendChild(dot);
|
|
129
|
+
for (const s of spec.states) {
|
|
130
|
+
const el = document.createElement('div');
|
|
131
|
+
el.className = 'node state' + (s.end ? ' pill' : '') + (bracket[s.id] ? ' mid' : '') + (s.tone ? ' accent' : '');
|
|
132
|
+
el.id = 'st-' + s.id;
|
|
133
|
+
el.style.gridArea = `${cell[s.id][0] + 2} / ${cell[s.id][1] + col0} / ${cell[s.id][2] + 3}`;
|
|
134
|
+
el.innerHTML = `<h3>${esc(s.name)}</h3>${s.note ? `<p>${esc(s.note)}</p>` : ''}${s.stayed != null ? `<p class="st-stayed">${fmt(s.stayed)} went no further</p>` : ''}`;
|
|
135
|
+
grid.appendChild(el);
|
|
136
|
+
}
|
|
137
|
+
for (const [id, [lo, hi]] of Object.entries(bracket)) {
|
|
138
|
+
const b = document.createElement('div');
|
|
139
|
+
b.className = 'node bare st-bracket';
|
|
140
|
+
b.id = 'st--into-' + id;
|
|
141
|
+
b.style.gridArea = `${lo + 2} / ${col0} / ${hi + 3}`;
|
|
142
|
+
grid.appendChild(b);
|
|
143
|
+
}
|
|
144
|
+
main.appendChild(grid);
|
|
145
|
+
// A label on the main column wider than its boxes reaches into the gap, so the bracket moves out past it.
|
|
146
|
+
const brackets = grid.querySelectorAll('.st-bracket');
|
|
147
|
+
if (brackets.length) {
|
|
148
|
+
const boxW = grid.querySelector('.state').offsetWidth;
|
|
149
|
+
const reach = Math.max(0, ...down.map(probe).map((w) => (w - boxW) / 2 + 14));
|
|
150
|
+
const now = -parseFloat(getComputedStyle(brackets[0]).marginRight);
|
|
151
|
+
if (reach > now) {
|
|
152
|
+
for (const b of brackets) b.style.marginRight = -reach + 'px';
|
|
153
|
+
grid.style.columnGap = gapX + reach - now + 'px';
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
const arrow = (from, to, attrs) => {
|
|
157
|
+
const a = document.createElement('fig-arrow');
|
|
158
|
+
a.setAttribute('from', from);
|
|
159
|
+
a.setAttribute('to', to);
|
|
160
|
+
for (const [k, v] of Object.entries(attrs)) if (v != null && v !== false) a.setAttribute(k, v === true ? '' : v);
|
|
161
|
+
main.appendChild(a);
|
|
162
|
+
};
|
|
163
|
+
const first = spec.states.find((s) => s.start);
|
|
164
|
+
arrow('st--start', 'st-' + first.id, {
|
|
165
|
+
label: spec.entry ?? (first.count != null ? `${fmt(first.count)} ${spec.each}` : null),
|
|
166
|
+
sub: spec.entry && first.count != null ? `${fmt(first.count)} ${spec.each}` : null,
|
|
167
|
+
'to-side': 'top',
|
|
168
|
+
});
|
|
169
|
+
spec.moves.forEach((m, i) => {
|
|
170
|
+
if (across(m)) {
|
|
171
|
+
arrow('st--into-' + m.to, 'st-' + m.to, { label: title(m) ?? null, sub: lines(m) || null, accent: !!m.tone, both: !!m.back, 'from-side': 'right', 'to-side': 'left' });
|
|
172
|
+
return;
|
|
173
|
+
}
|
|
174
|
+
const mine = edges.map((e, k) => ({ e, k })).filter(({ e }) => e.move === i && !e.back);
|
|
175
|
+
// A move several states can make is labelled once, on the arrow from a state level with where it goes, else the
|
|
176
|
+
// middle one. The others leave low on their state's side and join from above or below, so none shares a line
|
|
177
|
+
// with another move out of the same state.
|
|
178
|
+
const level = mine.find(({ e }) => cell[e.from][0] === cell[e.to][0]);
|
|
179
|
+
const labelled = (level ?? mine[Math.floor((mine.length - 1) / 2)]).e;
|
|
180
|
+
for (const { e, k } of mine) {
|
|
181
|
+
let side = sides[k], extra = {};
|
|
182
|
+
if (mine.length > 1 && cell[e.from][0] !== cell[e.to][0] && cell[e.from][1] < cell[e.to][1]) {
|
|
183
|
+
const above = cell[e.from][0] < cell[e.to][0];
|
|
184
|
+
extra = { 'from-side': 'right', 'from-at': above ? 0.85 : 0.15, 'to-side': above ? 'top' : 'bottom' };
|
|
185
|
+
}
|
|
186
|
+
// Two moves each way between states side by side: the forward one above, the one back below.
|
|
187
|
+
if (!side && !Object.keys(extra).length && cell[e.from][0] === cell[e.to][0] && edges.some((o) => o.from === e.to && o.to === e.from)) {
|
|
188
|
+
const at = e.loop ? 0.68 : 0.32;
|
|
189
|
+
extra = { 'from-at': at, 'to-at': at };
|
|
190
|
+
}
|
|
191
|
+
arrow('st-' + e.from, 'st-' + e.to, {
|
|
192
|
+
label: e === labelled ? title(m) ?? null : null,
|
|
193
|
+
sub: e === labelled ? lines(m) || null : null,
|
|
194
|
+
accent: !!m.tone,
|
|
195
|
+
both: !!m.back,
|
|
196
|
+
'from-side': side, 'to-side': side,
|
|
197
|
+
'from-at': side ? 0.62 : null, 'to-at': side ? 0.38 : null,
|
|
198
|
+
...extra,
|
|
199
|
+
});
|
|
200
|
+
}
|
|
201
|
+
});
|
|
202
|
+
return grid;
|
|
203
|
+
}
|