@liggi/figkit 0.0.0-stage → 0.2.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 +303 -0
- package/LICENSE +21 -0
- package/README.md +98 -2
- package/THIRD_PARTY_NOTICES.md +632 -0
- package/bin/fig.mjs +124 -0
- package/dist/viz.iife.js +59 -0
- package/examples/assets/settings-page.html +14 -0
- package/examples/assets/settings-page.png +0 -0
- package/examples/boundary.html +25 -0
- package/examples/charts/bars.json +8 -0
- package/examples/charts/before-after.json +16 -0
- package/examples/charts/dumbbell.json +9 -0
- package/examples/charts/entities.json +17 -0
- package/examples/charts/series.json +46 -0
- package/examples/charts/split.json +10 -0
- package/examples/consolidate.html +33 -0
- package/examples/custom-bar.html +25 -0
- package/examples/flow.html +50 -0
- package/examples/grouping.html +33 -0
- package/examples/line-chart.html +25 -0
- package/examples/screenshot-notes.html +16 -0
- package/examples/sides.html +35 -0
- package/examples/split.html +13 -0
- package/examples/timeline.html +33 -0
- package/package.json +55 -4
- package/runtime/fig.css +188 -0
- package/runtime/fig.js +704 -0
- package/viz/build.mjs +27 -0
- package/viz/draw.js +315 -0
- package/viz/index.js +2 -0
- package/viz/spec.js +177 -0
package/viz/build.mjs
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
/* Bundles viz/ into dist/viz.iife.js (window.figkitViz), which figure pages load to draw split bars, bars and chart specs. */
|
|
2
|
+
import fs from 'node:fs';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import { fileURLToPath } from 'node:url';
|
|
5
|
+
|
|
6
|
+
const HOME = path.join(path.dirname(fileURLToPath(import.meta.url)), '..');
|
|
7
|
+
const SOURCES = ['viz/draw.js', 'package.json'].map((f) => path.join(HOME, f));
|
|
8
|
+
const OUT = path.join(HOME, 'dist/viz.iife.js');
|
|
9
|
+
|
|
10
|
+
const mtime = (f) => (fs.existsSync(f) ? fs.statSync(f).mtimeMs : 0);
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Builds dist/ when a source is newer than it, so `fig` never runs a stale bundle. An installed package ships dist/
|
|
14
|
+
* without esbuild, and npm gives every file the same date, so there the shipped bundle is used as it is.
|
|
15
|
+
*/
|
|
16
|
+
export async function ensureBuilt({ force = false } = {}) {
|
|
17
|
+
const newest = Math.max(...SOURCES.map(mtime));
|
|
18
|
+
if (!force && mtime(OUT) >= newest) return;
|
|
19
|
+
const esbuild = await import('esbuild').catch(() => null);
|
|
20
|
+
if (!esbuild) {
|
|
21
|
+
if (fs.existsSync(OUT)) return;
|
|
22
|
+
throw new Error('dist/viz.iife.js is missing and esbuild is not installed: run npm install in the figkit folder.');
|
|
23
|
+
}
|
|
24
|
+
await esbuild.build({ entryPoints: [path.join(HOME, 'viz/draw.js')], bundle: true, minify: true, format: 'iife', globalName: 'figkitViz', outfile: OUT, logLevel: 'warning' });
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
if (process.argv[1] === fileURLToPath(import.meta.url)) await ensureBuilt({ force: true });
|
package/viz/draw.js
ADDED
|
@@ -0,0 +1,315 @@
|
|
|
1
|
+
/* The chart types, drawn into a host element styled by runtime/fig.css. Figures use them through runtime/fig.js. */
|
|
2
|
+
import * as Plot from '@observablehq/plot';
|
|
3
|
+
const NS = 'http://www.w3.org/2000/svg';
|
|
4
|
+
const esc = (s) => String(s).replace(/[&<>"]/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"' })[c]);
|
|
5
|
+
|
|
6
|
+
/** Each item: { value, text (the printed number), show?, label (HTML), tone?, note? }. */
|
|
7
|
+
const shown = (x, unit) => x.show ?? x.text + (unit ?? '');
|
|
8
|
+
|
|
9
|
+
/** Parts of a whole into a <fig-split>. Labels too wide for their segment move to a line under the bar. */
|
|
10
|
+
export function drawSplit(host, parts, unit) {
|
|
11
|
+
const total = parts.reduce((s, p) => s + p.value, 0);
|
|
12
|
+
host.innerHTML = '';
|
|
13
|
+
const bar = Object.assign(document.createElement('div'), { className: 'fs-bar' });
|
|
14
|
+
const rest = Object.assign(document.createElement('div'), { className: 'fs-rest' });
|
|
15
|
+
host.append(bar, rest);
|
|
16
|
+
const segs = parts.map((p) => {
|
|
17
|
+
const seg = document.createElement('div');
|
|
18
|
+
seg.className = 'fs-seg' + (p.tone ? ' ' + p.tone : '');
|
|
19
|
+
seg.style.flex = `${p.value / total} 1 0`;
|
|
20
|
+
seg.innerHTML = `<b>${esc(shown(p, unit))}</b><span>${p.label}</span>`;
|
|
21
|
+
bar.appendChild(seg);
|
|
22
|
+
return { p, seg };
|
|
23
|
+
});
|
|
24
|
+
const outs = segs.filter((s) => s.seg.scrollWidth > s.seg.clientWidth + 1);
|
|
25
|
+
if (!outs.length) { rest.remove(); return; }
|
|
26
|
+
const hostBox = host.getBoundingClientRect();
|
|
27
|
+
for (const o of outs) {
|
|
28
|
+
o.seg.classList.add('fs-out');
|
|
29
|
+
o.item = document.createElement('span');
|
|
30
|
+
o.item.innerHTML = `<b>${esc(shown(o.p, unit))}</b> ${o.p.label}`;
|
|
31
|
+
o.item.style.cssText = 'position: absolute; white-space: nowrap;';
|
|
32
|
+
rest.appendChild(o.item);
|
|
33
|
+
}
|
|
34
|
+
const W = hostBox.width, GAP = 22;
|
|
35
|
+
for (const o of outs) { const r = o.seg.getBoundingClientRect(); o.cx = r.left - hostBox.left + r.width / 2; o.w = o.item.offsetWidth; }
|
|
36
|
+
const line = getComputedStyle(host).getPropertyValue('--line').trim();
|
|
37
|
+
const paths = [];
|
|
38
|
+
let height;
|
|
39
|
+
// One line unless a label, pushed apart from its neighbours and kept inside the bar, would sit under another small
|
|
40
|
+
// segment and read as its label; then one label per line beside them, if there is room on a side.
|
|
41
|
+
for (const o of outs) o.x = Math.max(0, Math.min(W - o.w, o.cx - o.w / 2));
|
|
42
|
+
for (let i = 1; i < outs.length; i++) outs[i].x = Math.max(outs[i].x, outs[i - 1].x + outs[i - 1].w + GAP);
|
|
43
|
+
for (let i = outs.length - 1; i >= 0; i--) { const cap = i === outs.length - 1 ? W - outs[i].w : outs[i + 1].x - GAP - outs[i].w; outs[i].x = Math.min(outs[i].x, cap); }
|
|
44
|
+
const PAD = 28, widest = Math.max(...outs.map((o) => o.w));
|
|
45
|
+
const minCx = Math.min(...outs.map((o) => o.cx)), maxCx = Math.max(...outs.map((o) => o.cx));
|
|
46
|
+
const side = minCx - PAD >= widest ? 'left' : W - maxCx - PAD >= widest ? 'right' : null;
|
|
47
|
+
const misleading = outs.some((o) => outs.some((q) => q !== o && q.cx > o.x && q.cx < o.x + o.w));
|
|
48
|
+
if (!misleading || !side) {
|
|
49
|
+
const DROP = 26;
|
|
50
|
+
for (const o of outs) {
|
|
51
|
+
o.y = DROP;
|
|
52
|
+
const tx = Math.max(o.x + 8, Math.min(o.x + o.w - 8, o.cx));
|
|
53
|
+
paths.push(`M${o.cx},-8 V${DROP / 2 - 4} H${tx} V${DROP - 3}`);
|
|
54
|
+
}
|
|
55
|
+
height = DROP + outs[0].item.offsetHeight;
|
|
56
|
+
} else {
|
|
57
|
+
// Each label joined to its segment by an elbow; rows nearest the labels come first, so no line crosses another.
|
|
58
|
+
const TOP = 14, ROW = outs[0].item.offsetHeight + 8;
|
|
59
|
+
const left = side === 'left';
|
|
60
|
+
const rows = [...outs].sort((p, q) => (left ? p.cx - q.cx : q.cx - p.cx));
|
|
61
|
+
rows.forEach((o, i) => {
|
|
62
|
+
o.y = TOP + i * ROW;
|
|
63
|
+
const mid = o.y + o.item.offsetHeight / 2;
|
|
64
|
+
if (left) { const end = minCx - PAD; o.right = W - end; paths.push(`M${o.cx},-8 V${mid} H${end + 6}`); }
|
|
65
|
+
else { const start = maxCx + PAD; o.x = start; paths.push(`M${o.cx},-8 V${mid} H${start - 6}`); }
|
|
66
|
+
});
|
|
67
|
+
height = TOP + rows.length * ROW - 8;
|
|
68
|
+
}
|
|
69
|
+
rest.style.cssText = `position: relative; display: block; height: ${height}px;`;
|
|
70
|
+
const svg = document.createElementNS(NS, 'svg');
|
|
71
|
+
svg.setAttribute('width', W); svg.setAttribute('height', height);
|
|
72
|
+
svg.setAttribute('style', 'position:absolute;left:0;top:0;overflow:visible');
|
|
73
|
+
rest.appendChild(svg);
|
|
74
|
+
for (const o of outs) o.item.style.cssText = `position: absolute; ${o.right === undefined ? `left: ${o.x}` : `right: ${o.right}`}px; top: ${o.y}px; white-space: nowrap;`;
|
|
75
|
+
for (const d of paths) {
|
|
76
|
+
const path = document.createElementNS(NS, 'path');
|
|
77
|
+
for (const [k, v] of Object.entries({ d, fill: 'none', stroke: line, 'stroke-width': 1.5 })) path.setAttribute(k, v);
|
|
78
|
+
svg.appendChild(path);
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** A few numbers compared into a <fig-bars>, on an axis from zero to max (default: the largest value). */
|
|
83
|
+
export function drawBars(host, bars, unit, max) {
|
|
84
|
+
const top = max ?? Math.max(...bars.map((b) => b.value));
|
|
85
|
+
host.innerHTML = '';
|
|
86
|
+
const fills = bars.map((b) => {
|
|
87
|
+
const label = Object.assign(document.createElement('div'), { className: 'fb-label', innerHTML: b.label });
|
|
88
|
+
const track = Object.assign(document.createElement('div'), { className: 'fb-track' });
|
|
89
|
+
const tone = b.tone ? ' ' + b.tone : '';
|
|
90
|
+
track.innerHTML = `<div class="fb-bar${tone}"></div><div class="fb-val${b.tone ? ' lead ' + b.tone : ''}">${esc(shown(b, unit))}${b.note ? `<span class="fb-sub">${esc(b.note)}</span>` : ''}</div>`;
|
|
91
|
+
host.append(label, track);
|
|
92
|
+
return { bar: track.firstChild, ratio: top > 0 ? Math.max(0, b.value) / top : 0 };
|
|
93
|
+
});
|
|
94
|
+
// The longest bar leaves room for the widest value and note, so no label runs past the edge.
|
|
95
|
+
const room = Math.max(...[...host.querySelectorAll('.fb-val')].map((v) => v.offsetWidth)) + 12;
|
|
96
|
+
for (const f of fills) f.bar.style.width = `calc((100% - ${room}px) * ${f.ratio})`;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
const ARROW = '<svg width="56" height="24" viewBox="0 0 56 24" aria-hidden="true"><path d="M2 12 H48 M38 3 L50 12 L38 21" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"/></svg>';
|
|
100
|
+
|
|
101
|
+
/** One measure before and after into a <fig-change>: two numbers, each over a bar on one shared scale. */
|
|
102
|
+
export function drawChange(host, spec) {
|
|
103
|
+
const top = spec.max ?? Math.max(spec.before.value, spec.after.value);
|
|
104
|
+
const tone = spec.after.tone ?? 'accent';
|
|
105
|
+
const side = (x, cls) => `<div class="fc-side ${cls}"><span class="fc-when">${esc(x.label)}</span><b class="fc-num">${esc(shown({ ...x, text: String(x.value) }, spec.unit))}</b><div class="fc-bar" style="width:${top > 0 ? (100 * x.value) / top : 0}%"></div></div>`;
|
|
106
|
+
host.innerHTML = side(spec.before, 'before') + `<div class="fc-arrow">${ARROW}</div>` + side(spec.after, 'after ' + tone);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
const themeOf = (host) => {
|
|
110
|
+
const css = (n) => getComputedStyle(host).getPropertyValue(n).trim();
|
|
111
|
+
return { css, ink: css('--ink'), muted: css('--muted'), soft: css('--soft'), line: css('--line'), accent: css('--accent'), second: css('--second'), good: css('--good'), bad: css('--bad'), small: parseFloat(css('--small')) || 15, body: parseFloat(css('--body')) || 16, sans: css('--sans') };
|
|
112
|
+
};
|
|
113
|
+
const toX = (v) => (typeof v === 'number' ? v : new Date(v));
|
|
114
|
+
const num = (n) => (Math.abs(n) >= 100 ? Math.round(n) : Math.round(n * 10) / 10).toLocaleString('en-GB');
|
|
115
|
+
function span(a, b) {
|
|
116
|
+
const d = Math.abs(b - a) / 864e5;
|
|
117
|
+
if (d < 1) return `${num(d * 24)} hours`;
|
|
118
|
+
return `${num(d)} ${d === 1 ? 'day' : 'days'}`;
|
|
119
|
+
}
|
|
120
|
+
function widest(host, texts, size, weight = 400) {
|
|
121
|
+
const probe = document.createElement('span');
|
|
122
|
+
probe.style.cssText = `position:absolute;visibility:hidden;white-space:nowrap;font-size:${size}px;font-weight:${weight}`;
|
|
123
|
+
host.appendChild(probe);
|
|
124
|
+
const w = Math.max(...texts.map((t) => { probe.textContent = t; return probe.getBoundingClientRect().width; }));
|
|
125
|
+
probe.remove();
|
|
126
|
+
return w;
|
|
127
|
+
}
|
|
128
|
+
function plotInto(host, t, opts) {
|
|
129
|
+
host.classList.add('chart');
|
|
130
|
+
host.innerHTML = '';
|
|
131
|
+
host.appendChild(Plot.plot({ ...opts, style: { background: 'transparent', color: t.muted, fontFamily: t.sans, fontSize: t.small + 'px', overflow: 'visible' } }));
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/** Rows that each move from one value to another into a .chart: an arrow per row, its gap labelled above it. */
|
|
135
|
+
export function drawDumbbell(host, spec) {
|
|
136
|
+
const t = themeOf(host);
|
|
137
|
+
const width = Math.floor(host.clientWidth) || 640;
|
|
138
|
+
const date = typeof spec.rows[0].from === 'string';
|
|
139
|
+
const rows = spec.rows.map((r) => ({ ...r, a: toX(r.from), b: toX(r.to) }));
|
|
140
|
+
for (const r of rows) {
|
|
141
|
+
r.text = r.gap ?? (date ? span(r.a, r.b) : num(Math.abs(r.b - r.a)) + (spec.unit ?? ''));
|
|
142
|
+
r.mid = date ? new Date((+r.a + +r.b) / 2) : (r.a + r.b) / 2;
|
|
143
|
+
r.color = t[r.tone ?? 'accent'];
|
|
144
|
+
}
|
|
145
|
+
const all = rows.flatMap((r) => [+r.a, +r.b]);
|
|
146
|
+
const lo = Math.min(...all), hi = Math.max(...all), pad = (hi - lo || 1) * 0.08;
|
|
147
|
+
const domain = date ? [new Date(lo - pad), new Date(hi + pad)] : [lo - pad, hi + pad];
|
|
148
|
+
const labelW = widest(host, rows.map((r) => r.label), t.body);
|
|
149
|
+
plotInto(host, t, {
|
|
150
|
+
width, height: rows.length * 92 + 50, marginLeft: Math.ceil(labelW) + 28, marginRight: 24, marginTop: 0, marginBottom: 40,
|
|
151
|
+
x: { domain, ticks: width < 600 ? 4 : 5, label: null, tickFormat: date ? undefined : (d) => num(d) + (spec.unit ?? '') },
|
|
152
|
+
y: { domain: rows.map((r) => r.label), axis: null, padding: 0.5 },
|
|
153
|
+
marks: [
|
|
154
|
+
Plot.text(rows, { y: 'label', text: 'label', frameAnchor: 'left', dx: -(Math.ceil(labelW) + 28), textAnchor: 'start', fill: t.ink, fontSize: t.body }),
|
|
155
|
+
Plot.link(rows, { x1: 'a', x2: 'b', y1: 'label', y2: 'label', stroke: 'color', strokeWidth: 3 }),
|
|
156
|
+
Plot.dot(rows, { x: 'a', y: 'label', r: 6.5, fill: t.soft }),
|
|
157
|
+
// A fixed-size head at the end, pointing the way the row moved.
|
|
158
|
+
// A fixed-size head whose tip, not its middle, sits on the end value.
|
|
159
|
+
Plot.dot(rows.filter((r) => r.b >= r.a), { x: 'b', y: 'label', symbol: 'triangle2', r: 7.5, dx: -5, fill: 'color', rotate: 90 }),
|
|
160
|
+
Plot.dot(rows.filter((r) => r.b < r.a), { x: 'b', y: 'label', symbol: 'triangle2', r: 7.5, dx: 5, fill: 'color', rotate: -90 }),
|
|
161
|
+
Plot.text(rows, { x: 'mid', y: 'label', text: 'text', dy: -22, fill: t.ink, fontWeight: 600, fontSize: t.small }),
|
|
162
|
+
],
|
|
163
|
+
});
|
|
164
|
+
// The two ends named once, as a key above the rows.
|
|
165
|
+
const key = document.createElement('p');
|
|
166
|
+
key.className = 'fd-key';
|
|
167
|
+
key.innerHTML = `<span class="fd-from"><i></i>${esc(spec.fromLabel)}</span><span class="fd-to${rows.every((r) => r.b < r.a) ? ' back' : ''}" style="color:${t.accent}"><i></i>${esc(spec.toLabel)}</span>`;
|
|
168
|
+
host.prepend(key);
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/** A line over ordered x into a .chart, with one point marked and labelled beside it. */
|
|
172
|
+
export function drawSeries(host, spec) {
|
|
173
|
+
const t = themeOf(host);
|
|
174
|
+
const width = Math.floor(host.clientWidth) || 640;
|
|
175
|
+
const pts = spec.points.map((p) => ({ x: toX(p.x), y: p.y }));
|
|
176
|
+
const i = pts.findIndex((p) => +p.x === +toX(spec.mark.x));
|
|
177
|
+
const mark = pts[i];
|
|
178
|
+
const near = pts.slice(Math.max(0, i - 3), i + 4).map((p) => p.y);
|
|
179
|
+
const high = mark.y >= near.reduce((s, y) => s + y, 0) / near.length;
|
|
180
|
+
const ys = pts.map((p) => p.y), zero = spec.zero ?? true;
|
|
181
|
+
const top = Math.max(...ys), bottom = zero ? Math.min(0, ...ys) : Math.min(...ys);
|
|
182
|
+
const fmt = (d) => num(d) + (spec.unit ?? '');
|
|
183
|
+
const tickW = widest(host, [fmt(top), fmt(bottom)], t.small);
|
|
184
|
+
// Label to the right of the point when it fits inside the plot there, otherwise to the left.
|
|
185
|
+
const left0 = Math.ceil(tickW) + 14, plotW = width - left0 - 20;
|
|
186
|
+
const at = left0 + plotW * ((+mark.x - +pts[0].x) / (+pts[pts.length - 1].x - +pts[0].x));
|
|
187
|
+
const right = at + 14 + widest(host, [spec.mark.label], t.body, 600) <= width - 20;
|
|
188
|
+
// Above a high point; level with a low one, lifted clear of the axis when it sits near the bottom.
|
|
189
|
+
const lift = zero ? ((mark.y - bottom) / (top * 1.04 - bottom)) * (340 - 30 - 40) : Infinity;
|
|
190
|
+
const dy = high ? -12 : Math.min(0, lift - 18);
|
|
191
|
+
plotInto(host, t, {
|
|
192
|
+
width, height: 340, marginLeft: left0, marginRight: 20, marginTop: 30, marginBottom: 40,
|
|
193
|
+
x: { label: null, ticks: width < 600 ? 4 : 5, insetLeft: 18 },
|
|
194
|
+
y: { label: null, grid: true, ticks: 4, tickSize: 0, domain: zero ? [bottom, top * 1.04] : undefined, tickFormat: fmt },
|
|
195
|
+
marks: [
|
|
196
|
+
zero ? Plot.ruleY([0], { stroke: t.line }) : null,
|
|
197
|
+
Plot.lineY(pts, { x: 'x', y: 'y', stroke: t.soft, strokeWidth: 2.5 }),
|
|
198
|
+
Plot.dot([mark], { x: 'x', y: 'y', r: 6.5, fill: t.accent }),
|
|
199
|
+
Plot.text([mark], { x: 'x', y: 'y', text: () => spec.mark.label, dx: right ? 14 : -14, dy, textAnchor: right ? 'start' : 'end', fill: t.accent, fontWeight: 600, fontSize: t.body }),
|
|
200
|
+
],
|
|
201
|
+
});
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/**
|
|
205
|
+
* Where each record sits: every arrangement of the records over a few small grids is scored, and the best kept.
|
|
206
|
+
* A score counts references that cross, references that pass over another record, long references, and records drawn
|
|
207
|
+
* above what they point at (a reference reads upward, to the thing it names). A phone gets at most two columns.
|
|
208
|
+
*/
|
|
209
|
+
export function placeRecords(records, refs, phone) {
|
|
210
|
+
const n = records.length;
|
|
211
|
+
const shapes = [];
|
|
212
|
+
for (let rows = 1; rows <= 3; rows++) for (let cols = 1; cols <= (phone ? 2 : 3); cols++) {
|
|
213
|
+
if (rows * cols >= n && rows * cols - n < Math.min(rows, cols)) shapes.push([rows, cols]);
|
|
214
|
+
}
|
|
215
|
+
const idx = new Map(records.map((r, i) => [r.id, i]));
|
|
216
|
+
const edges = refs.map((r) => [idx.get(r.from), idx.get(r.to)]);
|
|
217
|
+
const cross = (a, b, c, d) => {
|
|
218
|
+
const o = (p, q, r) => Math.sign((q[0] - p[0]) * (r[1] - p[1]) - (q[1] - p[1]) * (r[0] - p[0]));
|
|
219
|
+
return o(a, b, c) * o(a, b, d) < 0 && o(c, d, a) * o(c, d, b) < 0;
|
|
220
|
+
};
|
|
221
|
+
// A record's cell is about 1.6 times as wide as it is tall, gaps included.
|
|
222
|
+
const pt = ([r, c]) => [c * 1.6, r];
|
|
223
|
+
const over = (a, b, cell) => {
|
|
224
|
+
const [x1, y1] = pt(a), [x2, y2] = pt(b), [cx, cy] = pt(cell);
|
|
225
|
+
const hw = 0.55, hh = 0.32;
|
|
226
|
+
for (let t = 0.04; t < 1; t += 0.04) { const x = x1 + (x2 - x1) * t, y = y1 + (y2 - y1) * t; if (Math.abs(x - cx) < hw && Math.abs(y - cy) < hh) return true; }
|
|
227
|
+
return false;
|
|
228
|
+
};
|
|
229
|
+
let best = null;
|
|
230
|
+
for (const [rows, cols] of shapes) {
|
|
231
|
+
const cells = [];
|
|
232
|
+
for (let r = 0; r < rows; r++) for (let c = 0; c < cols; c++) cells.push([r, c]);
|
|
233
|
+
const used = new Array(cells.length).fill(false), at = new Array(n);
|
|
234
|
+
const score = () => {
|
|
235
|
+
let s = rows * 0.6 + cols * 0.3;
|
|
236
|
+
const pos = at.map((k) => cells[k]);
|
|
237
|
+
edges.forEach(([f, t], i) => {
|
|
238
|
+
const a = pos[f], b = pos[t];
|
|
239
|
+
const [x1, y1] = pt(a), [x2, y2] = pt(b);
|
|
240
|
+
s += Math.hypot(x2 - x1, y2 - y1) * 1.2;
|
|
241
|
+
if (a[0] !== b[0] && a[1] !== b[1]) s += 1.5;
|
|
242
|
+
if (a[0] < b[0]) s += 3;
|
|
243
|
+
pos.forEach((p, k) => { if (k !== f && k !== t && over(a, b, p)) s += 12; });
|
|
244
|
+
for (const [g, u] of edges.slice(i + 1)) if (g !== f && g !== t && u !== f && u !== t && cross(pt(a), pt(b), pt(pos[g]), pt(pos[u]))) s += 8;
|
|
245
|
+
});
|
|
246
|
+
// An empty cell in the middle of a row reads as something missing.
|
|
247
|
+
for (let r = 0; r < rows; r++) for (let c = 1; c < cols - 1; c++) if (!pos.some((p) => p[0] === r && p[1] === c) && pos.some((p) => p[0] === r)) s += 4;
|
|
248
|
+
return s;
|
|
249
|
+
};
|
|
250
|
+
const walk = (i) => {
|
|
251
|
+
if (i === n) { const s = score(); if (!best || s < best.s - 1e-9) best = { s, rows, cols, pos: at.map((k) => cells[k]) }; return; }
|
|
252
|
+
for (let k = 0; k < cells.length; k++) if (!used[k]) { used[k] = true; at[i] = k; walk(i + 1); used[k] = false; }
|
|
253
|
+
};
|
|
254
|
+
walk(0);
|
|
255
|
+
}
|
|
256
|
+
return best;
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
/** Records and the references between them into the figure: boxes on a grid figkit chose, and <fig-arrow ref> lines. */
|
|
260
|
+
function drawEntities(spec, main) {
|
|
261
|
+
const phone = main.classList.contains('phone');
|
|
262
|
+
const { rows, cols, pos } = placeRecords(spec.records, spec.refs, phone);
|
|
263
|
+
const grid = document.createElement('div');
|
|
264
|
+
grid.className = 'entities';
|
|
265
|
+
grid.style.cssText = `grid-template-columns: repeat(${cols}, minmax(0, 1fr)); grid-template-rows: repeat(${rows}, auto);`;
|
|
266
|
+
spec.records.forEach((r, i) => {
|
|
267
|
+
const fields = r.fields ?? [];
|
|
268
|
+
const box = document.createElement('div');
|
|
269
|
+
box.className = 'node record' + (r.tone ? ' accent' : '');
|
|
270
|
+
box.id = 'rec-' + r.id;
|
|
271
|
+
box.style.gridArea = `${pos[i][0] + 1} / ${pos[i][1] + 1}`;
|
|
272
|
+
box.innerHTML = `<h3>${esc(r.name)}</h3>${fields.length ? `<ul>${fields.map((f) => `<li>${esc(f)}</li>`).join('')}</ul>` : ''}`;
|
|
273
|
+
grid.appendChild(box);
|
|
274
|
+
});
|
|
275
|
+
main.appendChild(grid);
|
|
276
|
+
for (const ref of spec.refs) {
|
|
277
|
+
const a = document.createElement('fig-arrow');
|
|
278
|
+
a.setAttribute('from', 'rec-' + ref.from);
|
|
279
|
+
a.setAttribute('to', 'rec-' + ref.to);
|
|
280
|
+
a.setAttribute('ref', ref.count);
|
|
281
|
+
if (ref.optional) a.setAttribute('dashed', '');
|
|
282
|
+
main.appendChild(a);
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
const HOSTS = { split: 'fig-split', bars: 'fig-bars', 'before-after': 'fig-change', dumbbell: 'div', series: 'div' };
|
|
287
|
+
|
|
288
|
+
/** A checked spec drawn into a figure: header, chart and footer, as figkit's own markup. */
|
|
289
|
+
export function drawSpec(spec, main) {
|
|
290
|
+
const header = document.createElement('header');
|
|
291
|
+
header.innerHTML = `<h1>${esc(spec.title)}</h1>${spec.note ? `<p class="read">${esc(spec.note)}</p>` : ''}`;
|
|
292
|
+
if (spec.type === 'entities') {
|
|
293
|
+
main.append(header);
|
|
294
|
+
drawEntities(spec, main);
|
|
295
|
+
if (spec.source) { const footer = document.createElement('footer'); footer.innerHTML = `<p>${esc(spec.source)}</p>`; main.append(footer); }
|
|
296
|
+
return { chart: main.querySelector('.entities'), draw: () => {} };
|
|
297
|
+
}
|
|
298
|
+
const chart = document.createElement(HOSTS[spec.type]);
|
|
299
|
+
main.append(header, chart);
|
|
300
|
+
if (spec.source) {
|
|
301
|
+
const footer = document.createElement('footer');
|
|
302
|
+
footer.innerHTML = `<p>${esc(spec.source)}</p>`;
|
|
303
|
+
main.append(footer);
|
|
304
|
+
}
|
|
305
|
+
const draw = () => {
|
|
306
|
+
if (spec.type === 'before-after') return drawChange(chart, spec);
|
|
307
|
+
if (spec.type === 'dumbbell') return drawDumbbell(chart, spec);
|
|
308
|
+
if (spec.type === 'series') return drawSeries(chart, spec);
|
|
309
|
+
const items = (spec.parts ?? spec.bars).map((x) => ({ ...x, text: String(x.value), label: esc(x.label) }));
|
|
310
|
+
if (spec.type === 'split') drawSplit(chart, items, spec.unit);
|
|
311
|
+
else drawBars(chart, items, spec.unit, spec.max);
|
|
312
|
+
};
|
|
313
|
+
draw();
|
|
314
|
+
return { chart, draw };
|
|
315
|
+
}
|
package/viz/index.js
ADDED
package/viz/spec.js
ADDED
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
/* The named visualisations: one Zod schema per type. An agent picks a type and gives data; it never writes markup. */
|
|
2
|
+
import { z } from 'zod';
|
|
3
|
+
|
|
4
|
+
const TONE = z.enum(['accent', 'second', 'good', 'bad']);
|
|
5
|
+
const label = z.string({ error: 'needs a label' }).trim().min(1, 'needs a label').max(40, 'keep labels to 40 characters');
|
|
6
|
+
const title = z.string({ error: 'needs a title: the one point, stated' }).trim().min(1, 'needs a title: the one point, stated')
|
|
7
|
+
.refine((t) => t.split(/\s+/).length <= 14, 'keep the title to 14 words');
|
|
8
|
+
const common = {
|
|
9
|
+
title,
|
|
10
|
+
note: z.string().trim().max(160, 'keep the note to 160 characters').optional(),
|
|
11
|
+
unit: z.string().max(8).optional(),
|
|
12
|
+
source: z.string().trim().max(120).optional(),
|
|
13
|
+
};
|
|
14
|
+
const value = z.number({ error: 'must be a number' }).finite();
|
|
15
|
+
|
|
16
|
+
/** Parts of a whole, as one bar cut into segments. */
|
|
17
|
+
export const SplitSpec = z.object({
|
|
18
|
+
type: z.literal('split'),
|
|
19
|
+
...common,
|
|
20
|
+
parts: z.array(z.object({
|
|
21
|
+
label,
|
|
22
|
+
value: value.positive('must be more than 0'),
|
|
23
|
+
tone: TONE.optional(),
|
|
24
|
+
show: z.string().max(24).optional(),
|
|
25
|
+
}).strict()).min(2, 'a split needs at least 2 parts').max(8, 'at most 8 parts; group the small ones as "Other"'),
|
|
26
|
+
}).strict();
|
|
27
|
+
|
|
28
|
+
/** A few numbers compared, one bar each, on an axis from zero. */
|
|
29
|
+
export const BarsSpec = z.object({
|
|
30
|
+
type: z.literal('bars'),
|
|
31
|
+
...common,
|
|
32
|
+
max: z.number().positive().optional(),
|
|
33
|
+
bars: z.array(z.object({
|
|
34
|
+
label,
|
|
35
|
+
value: value.nonnegative('must be 0 or more'),
|
|
36
|
+
tone: TONE.optional(),
|
|
37
|
+
show: z.string().max(24).optional(),
|
|
38
|
+
note: z.string().trim().max(40).optional(),
|
|
39
|
+
}).strict()).min(1).max(12, 'at most 12 bars'),
|
|
40
|
+
}).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'] });
|
|
42
|
+
|
|
43
|
+
const side = z.object({
|
|
44
|
+
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'),
|
|
45
|
+
value: value.nonnegative('must be 0 or more'),
|
|
46
|
+
show: z.string().max(16).optional(),
|
|
47
|
+
}).strict();
|
|
48
|
+
|
|
49
|
+
/** One measure that changed: the value before and after, side by side on one scale. */
|
|
50
|
+
export const BeforeAfterSpec = z.object({
|
|
51
|
+
type: z.literal('before-after'),
|
|
52
|
+
...common,
|
|
53
|
+
note: z.string({ error: 'required: what the number counts, e.g. "Share of orders delivered late"' }).trim().min(1).max(80, 'keep the note to 80 characters'),
|
|
54
|
+
max: z.number().positive().optional(),
|
|
55
|
+
before: side,
|
|
56
|
+
after: side.extend({ tone: TONE.optional() }),
|
|
57
|
+
}).strict()
|
|
58
|
+
.refine((s) => s.max === undefined || (s.before.value <= s.max && s.after.value <= s.max), { message: 'both values must be at most max', path: ['max'] });
|
|
59
|
+
|
|
60
|
+
const isoDate = z.string().refine((d) => /^\d{4}-\d{2}-\d{2}/.test(d) && !Number.isNaN(Date.parse(d)), 'must be a number or a date like 2026-10-05');
|
|
61
|
+
const point = z.union([z.number().finite(), isoDate], { error: 'must be a number or a date like 2026-10-05' });
|
|
62
|
+
|
|
63
|
+
/** Each row a move from one value to another, drawn as an arrow, with the gap labelled. */
|
|
64
|
+
export const DumbbellSpec = z.object({
|
|
65
|
+
type: z.literal('dumbbell'),
|
|
66
|
+
...common,
|
|
67
|
+
fromLabel: z.string({ error: 'required: what the start of each arrow is' }).trim().min(1).max(24),
|
|
68
|
+
toLabel: z.string({ error: 'required: what the end of each arrow is' }).trim().min(1).max(24),
|
|
69
|
+
rows: z.array(z.object({
|
|
70
|
+
label: label.max(24, 'keep row labels to 24 characters'),
|
|
71
|
+
from: point,
|
|
72
|
+
to: point,
|
|
73
|
+
gap: z.string().max(20).optional(),
|
|
74
|
+
tone: TONE.optional(),
|
|
75
|
+
}).strict()).min(1).max(8, 'at most 8 rows'),
|
|
76
|
+
}).strict();
|
|
77
|
+
|
|
78
|
+
/** A line over time (or any ordered x) with one point marked and labelled. */
|
|
79
|
+
export const SeriesSpec = z.object({
|
|
80
|
+
type: z.literal('series'),
|
|
81
|
+
...common,
|
|
82
|
+
points: z.array(z.object({ x: point, y: value }).strict()).min(3, 'a series needs at least 3 points').max(2000),
|
|
83
|
+
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
|
+
zero: z.boolean().optional(),
|
|
85
|
+
}).strict();
|
|
86
|
+
|
|
87
|
+
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
|
+
|
|
89
|
+
/**
|
|
90
|
+
* What the things are and which points at which: records, and references from one record to another. A reference is
|
|
91
|
+
* a pointer one record holds (a foreign key, an id field), never a step in the order work happens.
|
|
92
|
+
*/
|
|
93
|
+
export const EntitiesSpec = z.object({
|
|
94
|
+
type: z.literal('entities'),
|
|
95
|
+
title,
|
|
96
|
+
note: common.note,
|
|
97
|
+
source: common.source,
|
|
98
|
+
records: z.array(z.object({
|
|
99
|
+
id: slug,
|
|
100
|
+
name: label.max(32, 'keep record names to 32 characters'),
|
|
101
|
+
table: z.never({ error: 'no table: name the record in words; if the table name matters to the reader, put it in fields' }).optional(),
|
|
102
|
+
fields: z.array(z.string().trim().min(1).max(36, 'keep each field to 36 characters')).max(4, 'at most 4 fields: the ones that explain the picture').optional(),
|
|
103
|
+
tone: z.literal('accent', { error: 'tone: only "accent", on the one record to notice' }).optional(),
|
|
104
|
+
}).strict()).min(2, 'at least 2 records').max(5, 'at most 5 records; more is two figures (the core, then one corner of it), not one'),
|
|
105
|
+
refs: z.array(z.object({
|
|
106
|
+
from: slug,
|
|
107
|
+
to: slug,
|
|
108
|
+
field: z.never({ error: 'no field: the line is the reference, so its column is not written out' }).optional(),
|
|
109
|
+
count: z.enum(['many', 'one'], { error: 'count: "many" or "one": how many "from" records can point at one "to" record' }).default('many'),
|
|
110
|
+
optional: z.boolean().optional(),
|
|
111
|
+
}).strict()).min(1, 'at least 1 reference').max(8, 'at most 8 references; more is two figures, not a reference left out'),
|
|
112
|
+
}).strict();
|
|
113
|
+
|
|
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 };
|
|
116
|
+
|
|
117
|
+
const items = (s) => s.parts ?? s.bars ?? s.rows ?? s.records ?? (s.after ? [s.after] : []);
|
|
118
|
+
const kind = (v) => (typeof v === 'number' ? 'number' : 'date');
|
|
119
|
+
|
|
120
|
+
/** Rules that span fields: x values of one kind, in order, and a mark that is one of the points. */
|
|
121
|
+
function crossChecks(s) {
|
|
122
|
+
if (s.type === 'dumbbell') {
|
|
123
|
+
const kinds = new Set(s.rows.flatMap((r) => [kind(r.from), kind(r.to)]));
|
|
124
|
+
if (kinds.size > 1) return ['rows: use all numbers or all dates, not both'];
|
|
125
|
+
}
|
|
126
|
+
if (s.type === 'series') {
|
|
127
|
+
const kinds = new Set(s.points.map((p) => kind(p.x)));
|
|
128
|
+
if (kinds.size > 1) return ['points: x values must be all numbers or all dates, not both'];
|
|
129
|
+
const at = (x) => (typeof x === 'number' ? x : Date.parse(x));
|
|
130
|
+
for (let i = 1; i < s.points.length; i++) if (at(s.points[i].x) <= at(s.points[i - 1].x)) return [`points[${i}].x: points must be in order of x, each later than the one before`];
|
|
131
|
+
if (!s.points.some((p) => at(p.x) === at(s.mark.x))) return [`mark.x: must be the x of one of the points, not ${JSON.stringify(s.mark.x)}`];
|
|
132
|
+
}
|
|
133
|
+
if (s.type === 'entities') {
|
|
134
|
+
const ids = s.records.map((r) => r.id);
|
|
135
|
+
const dup = ids.find((id, i) => ids.indexOf(id) !== i);
|
|
136
|
+
if (dup) return [`records: two records have the id "${dup}"`];
|
|
137
|
+
const out = [];
|
|
138
|
+
const pairs = new Set();
|
|
139
|
+
s.refs.forEach((r, i) => {
|
|
140
|
+
for (const end of ['from', 'to']) if (!ids.includes(r[end])) out.push(`refs[${i}].${end}: no record has the id "${r[end]}"; the ids are ${ids.map((x) => `"${x}"`).join(', ')}`);
|
|
141
|
+
if (r.from === r.to) out.push(`refs[${i}]: a record pointing at itself; say it in its fields instead`);
|
|
142
|
+
const key = r.from + '>' + r.to;
|
|
143
|
+
if (pairs.has(key)) out.push(`refs[${i}]: a second reference from "${r.from}" to "${r.to}"; make it one reference`);
|
|
144
|
+
pairs.add(key);
|
|
145
|
+
});
|
|
146
|
+
if (s.records.filter((r) => r.tone).length > 1) out.push('records: accent at most one record, the one to notice');
|
|
147
|
+
const loose = ids.filter((id) => !s.refs.some((r) => r.from === id || r.to === id));
|
|
148
|
+
if (loose.length) out.push(`records: ${loose.map((x) => `"${x}"`).join(', ')} ${loose.length === 1 ? 'is' : 'are'} not in any reference; leave ${loose.length === 1 ? 'it' : 'them'} out or add the reference`);
|
|
149
|
+
return out;
|
|
150
|
+
}
|
|
151
|
+
return [];
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* Checks a spec (an object or JSON text). Returns { ok: true, spec } or { ok: false, problems },
|
|
156
|
+
* each problem one line naming where it is, e.g. `parts[2].value: must be more than 0`.
|
|
157
|
+
*/
|
|
158
|
+
export function parseSpec(input) {
|
|
159
|
+
let data = input;
|
|
160
|
+
if (typeof input === 'string') {
|
|
161
|
+
try { data = JSON.parse(input); } catch (e) { return { ok: false, problems: [`Not valid JSON: ${e.message}`] }; }
|
|
162
|
+
}
|
|
163
|
+
if (!data || typeof data !== 'object' || Array.isArray(data)) return { ok: false, problems: ['A spec is a JSON object with a "type"'] };
|
|
164
|
+
if (!(data.type in TYPES)) return { ok: false, problems: [`type: must be one of ${Object.keys(TYPES).map((t) => `"${t}"`).join(', ')}${data.type === undefined ? '' : `, not ${JSON.stringify(data.type)}`}`] };
|
|
165
|
+
const r = Spec.safeParse(data);
|
|
166
|
+
if (!r.success) return { ok: false, problems: r.error.issues.map((i) => `${where(i.path)}${i.message}`) };
|
|
167
|
+
const cross = crossChecks(r.data);
|
|
168
|
+
if (cross.length) return { ok: false, problems: cross };
|
|
169
|
+
const tones = new Set(items(r.data).map((x) => x.tone).filter(Boolean));
|
|
170
|
+
if (tones.size > 2) return { ok: false, problems: [`tone: use at most two colours, one for what to notice; this uses ${[...tones].join(', ')}`] };
|
|
171
|
+
return { ok: true, spec: r.data };
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
function where(path) {
|
|
175
|
+
if (!path.length) return '';
|
|
176
|
+
return path.reduce((s, p) => (typeof p === 'number' ? `${s}[${p}]` : s ? `${s}.${String(p)}` : String(p)), '') + ': ';
|
|
177
|
+
}
|