@liggi/figkit 0.4.0 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/viz/map.js ADDED
@@ -0,0 +1,513 @@
1
+ /* The "map" spec drawn: parts in labelled subsystem regions packed into rows; on a phone, one box per subsystem. */
2
+ import dagre from '@dagrejs/dagre';
3
+ import { collapse } from './map-spec.js';
4
+
5
+ const esc = (s) => String(s).replace(/[&<>"]/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' })[c]);
6
+ /** The widest a map may be drawn (GUIDE.md: a detail figure widens to 1560px at most). */
7
+ const MAX_W = 1560;
8
+ /** A desktop screen the map is opened full screen on: the arrangement read largest there is preferred. */
9
+ const SCREEN = { w: 1440, h: 860 };
10
+ /** Space between regions: a lane for the links between subsystems and their labels. */
11
+ const GAP = { desk: 104, phone: 64 };
12
+ const PAD = { x: 22, bottom: 22 };
13
+ /** A lane round the outside of the regions, for links that leave a region and come back in elsewhere. */
14
+ const LANE = 32;
15
+ /** How far beside a part the highlighted path runs when it steps around another part: figkit's clearance from a box. */
16
+ const LANE_OFF = 16;
17
+ /** How many of the arrangements built around a highlighted path are routed for real. */
18
+ const SPINE_TRIES = 40;
19
+
20
+ /** The boxes, regions and links to lay out: the full map, or on a phone one box per subsystem. */
21
+ function graphOf(spec, phone) {
22
+ if (!phone) {
23
+ return {
24
+ groups: spec.subsystems,
25
+ nodes: spec.parts.map((p) => ({ id: p.id, group: p.in, tone: p.tone, html: `<h3>${esc(p.name)}</h3>${p.kind ? `<p>${esc(p.kind)}</p>` : ''}` })),
26
+ edges: spec.links.map((l) => ({ from: l.from, to: l.to, label: l.label, tone: l.tone })),
27
+ };
28
+ }
29
+ return {
30
+ groups: [],
31
+ nodes: spec.subsystems.map((g) => {
32
+ const parts = spec.parts.filter((p) => p.in === g.id);
33
+ return {
34
+ id: g.id, tone: parts.some((p) => p.tone) ? 'accent' : undefined, sub: true,
35
+ html: `<h3>${esc(g.name)}</h3><ul>${parts.map((p) => `<li${p.tone ? ' class="t-accent"' : ''}>${esc(p.name)}</li>`).join('')}</ul>`,
36
+ };
37
+ }),
38
+ edges: collapse(spec).map((e) => ({ from: e.from, to: e.to, label: String(e.count), tone: e.tone })),
39
+ };
40
+ }
41
+
42
+ /**
43
+ * The parts of one region placed in layers by dagre, across (RIGHT) or down (DOWN), with room kept between layers for
44
+ * the labels. Returns the region's inner size and each part's top-left corner.
45
+ */
46
+ function layoutRegion(children, edges, dir) {
47
+ const g = new dagre.graphlib.Graph({ multigraph: true });
48
+ g.setGraph({ rankdir: dir === 'RIGHT' ? 'LR' : 'TB', nodesep: 28, ranksep: 56, edgesep: 14, marginx: 0, marginy: 0 });
49
+ g.setDefaultEdgeLabel(() => ({}));
50
+ for (const c of children) g.setNode(c.id, { width: c.width, height: c.height });
51
+ // A highlighted link pulls hard, so the highlighted path runs straight through the region.
52
+ for (const e of edges) g.setEdge(e.from, e.to, { width: e.label?.width ?? 0, height: e.label?.height ?? 0, labelpos: 'c', weight: e.tone ? 12 : 1 }, e.id);
53
+ dagre.layout(g);
54
+ const placed = children.map((c) => { const n = g.node(c.id); return { id: c.id, width: c.width, height: c.height, x: n.x - c.width / 2, y: n.y - c.height / 2 }; });
55
+ const x0 = Math.min(...placed.map((c) => c.x)), y0 = Math.min(...placed.map((c) => c.y));
56
+ for (const c of placed) { c.x -= x0; c.y -= y0; }
57
+ return { children: placed, width: Math.max(...placed.map((c) => c.x + c.width)), height: Math.max(...placed.map((c) => c.y + c.height)) };
58
+ }
59
+
60
+ /**
61
+ * The runs along the highlighted links: from a highlighted link no other highlighted link feeds, to one that feeds none.
62
+ * Each run is the list of its links.
63
+ */
64
+ function runsOf(edges) {
65
+ const hot = edges.filter((e) => e.tone), runs = [];
66
+ const walk = (e, path) => {
67
+ const next = hot.filter((n) => n.from === e.to && !path.includes(n));
68
+ if (!next.length) runs.push([...path, e]);
69
+ for (const n of next) walk(n, [...path, e]);
70
+ };
71
+ for (const e of hot) if (!hot.some((u) => u.to === e.from)) walk(e, []);
72
+ return runs;
73
+ }
74
+
75
+ /** Whether a run through these points doubles back: goes left and right, or down and up, by more than a step. */
76
+ function doublesBack(pts) {
77
+ const seen = new Set();
78
+ for (let i = 1; i < pts.length; i++) {
79
+ const dx = pts[i][0] - pts[i - 1][0], dy = pts[i][1] - pts[i - 1][1];
80
+ if (Math.abs(dx) >= 40) seen.add(dx > 0 ? 'r' : 'l');
81
+ if (Math.abs(dy) >= 40) seen.add(dy > 0 ? 'd' : 'u');
82
+ }
83
+ return (seen.has('r') && seen.has('l')) || (seen.has('d') && seen.has('u'));
84
+ }
85
+
86
+ function permutations(n) {
87
+ const out = [], a = [...Array(n).keys()];
88
+ const walk = (k) => {
89
+ if (k === n) { out.push([...a]); return; }
90
+ for (let i = k; i < n; i++) { [a[k], a[i]] = [a[i], a[k]]; walk(k + 1); [a[k], a[i]] = [a[i], a[k]]; }
91
+ };
92
+ walk(0);
93
+ return out;
94
+ }
95
+
96
+ /**
97
+ * Where each region goes: regions in rows, left to right, the rows top to bottom. Every order and every way of
98
+ * breaking it into rows is tried. An arrangement costs more for links that must go round another region, for long
99
+ * links, and for links that run back up or to the left; on a desktop it also costs more the smaller it reads on a
100
+ * full screen. Each region may be laid out across or down, whichever arrangement is cheaper.
101
+ */
102
+ function pack(items, links, { maxW, gap, extra, phone, hotRuns = [] }) {
103
+ const n = items.length;
104
+ const keep = [];
105
+ // Every mix of regions laid out across and down.
106
+ const options = phone ? [items.map(() => 0)] : Array.from({ length: 1 << n }, (_, m) => items.map((_, i) => (m >> i) & 1));
107
+ for (const choice of options) {
108
+ const dims = items.map((it, i) => it.dims[choice[i]]);
109
+ for (const perm of permutations(n)) {
110
+ for (let mask = 0; mask < 1 << (n - 1); mask++) {
111
+ const rect = new Array(n);
112
+ let x = 0, y = 0, rowH = 0, W = 0;
113
+ perm.forEach((k, j) => {
114
+ if (j > 0 && mask & (1 << (j - 1))) { y += rowH + gap; x = 0; rowH = 0; }
115
+ rect[k] = { x, y, w: dims[k].w, h: dims[k].h };
116
+ x += dims[k].w + gap; rowH = Math.max(rowH, dims[k].h); W = Math.max(W, x - gap);
117
+ });
118
+ const H = y + rowH;
119
+ let cost = W > maxW ? 1000 + W / 100 : 0;
120
+ for (const { a, b, count } of links) {
121
+ const A = rect[a], B = rect[b];
122
+ const dx = Math.max(0, A.x - (B.x + B.w), B.x - (A.x + A.w)), dy = Math.max(0, A.y - (B.y + B.h), B.y - (A.y + A.h));
123
+ const ca = [A.x + A.w / 2, A.y + A.h / 2], cb = [B.x + B.w / 2, B.y + B.h / 2];
124
+ const between = rect.some((r, k) => k !== a && k !== b && segHits(ca, cb, r));
125
+ const back = cb[1] < A.y - 1 || (Math.abs(A.y - B.y) < 1 && cb[0] < ca[0]);
126
+ cost += count * ((dx + dy) / 500 + (between ? 1.2 : 0) + (back ? (phone ? 1.5 : 0.25) : 0));
127
+ }
128
+ // The highlighted path, subsystem to subsystem, should keep one way across the map, never turn back.
129
+ for (const run of hotRuns) if (doublesBack(run.map((k) => [rect[k].x + rect[k].w / 2, rect[k].y + rect[k].h / 2]))) cost += 3;
130
+ const read = Math.min(SCREEN.w / Math.max(W + extra.w, 960), SCREEN.h / (H + extra.h));
131
+ cost += phone ? H / 2000 : -8 * Math.log(read);
132
+ if (keep.length < 60 || cost < keep[keep.length - 1].cost) {
133
+ keep.push({ cost, rect, W, H, choice });
134
+ keep.sort((p, q) => p.cost - q.cost);
135
+ if (keep.length > 60) keep.pop();
136
+ }
137
+ }
138
+ }
139
+ }
140
+ return keep;
141
+ }
142
+
143
+ /**
144
+ * The cheapest arrangement once the parts themselves are placed: each candidate is tried with each region mirrored
145
+ * (down a region laid out across, across one laid out down, so links still run the same way), and costs more for
146
+ * every pair of links whose straight lines cross and every link whose line passes through a part it does not join.
147
+ * Returns them all, cheapest first.
148
+ */
149
+ function refine(cands, items, edges, partsOf) {
150
+ const all = [];
151
+ const n = items.length;
152
+ const runs = runsOf(edges);
153
+ for (const c of cands) {
154
+ for (let flip = 0; flip < 1 << n; flip++) {
155
+ const at = {}, rects = [];
156
+ items.forEach((it, k) => {
157
+ const r = c.rect[k];
158
+ for (const p of partsOf(k, c.choice[k], (flip >> k) & 1)) {
159
+ at[p.id] = [r.x + p.x + p.w / 2, r.y + p.y + p.h / 2];
160
+ rects.push({ id: p.id, x: r.x + p.x, y: r.y + p.y, w: p.w, h: p.h });
161
+ }
162
+ });
163
+ const segs = edges.map((e) => [at[e.from], at[e.to], e]);
164
+ let cost = c.cost;
165
+ for (let i = 0; i < segs.length; i++) {
166
+ const [p, q, e] = segs[i];
167
+ for (let j = i + 1; j < segs.length; j++) {
168
+ const [u, v, f] = segs[j];
169
+ if (e.from === f.from || e.from === f.to || e.to === f.from || e.to === f.to) continue;
170
+ if (crosses(p, q, u, v)) cost += !e.tone !== !f.tone ? 4 : 1;
171
+ }
172
+ for (const r of rects) if (r.id !== e.from && r.id !== e.to && segHits(p, q, r)) cost += 0.5;
173
+ }
174
+ // A highlighted run that doubles back is what the layout must avoid most.
175
+ for (const run of runs) if (doublesBack([at[run[0].from], ...run.map((e) => at[e.to])])) cost += 8;
176
+ all.push({ ...c, cost, flip });
177
+ }
178
+ }
179
+ return all.sort((p, q) => p.cost - q.cost);
180
+ }
181
+
182
+ /**
183
+ * Arrangements built around the highlighted path. The subsystems it passes through are placed one after another in the
184
+ * order the path reaches them, top to bottom (each laid out down) or left to right (each laid out across), and moved
185
+ * so the part the path enters lines up with the part it left: the path runs as one straight line, and steps aside only
186
+ * where it forks. The other subsystems go on either side of it, or after its end, wherever their links are shortest and
187
+ * cross the path least. Returns them cheapest first, in the form refine() returns; none when nothing is highlighted, or
188
+ * when the path goes back to a subsystem it has left.
189
+ */
190
+ function spine(items, edges, partsOf, owner, { maxW, gap, extra }) {
191
+ const hot = edges.filter((e) => e.tone);
192
+ if (!hot.length) return [];
193
+ const n = items.length;
194
+ const step = hot.map((e) => [owner(e.from), owner(e.to), e]).filter(([a, b]) => a !== b);
195
+ const on = new Set(hot.flatMap((e) => [owner(e.from), owner(e.to)]));
196
+ // Each of the path's subsystems at the step it is reached, counting the highlighted links between subsystems.
197
+ const level = new Map([...on].map((k) => [k, 0]));
198
+ for (let i = 0; i <= on.size; i++) {
199
+ let moved = false;
200
+ for (const [a, b] of step) if (level.get(b) < level.get(a) + 1) { level.set(b, level.get(a) + 1); moved = true; }
201
+ if (moved && i === on.size) return [];
202
+ }
203
+ const levels = [];
204
+ for (const [k, l] of level) (levels[l] ??= []).push(k);
205
+ if (levels.some((l) => !l)) return [];
206
+ const rest = [...Array(n).keys()].filter((k) => !on.has(k));
207
+ const spineK = [...on];
208
+ const runs = runsOf(edges);
209
+ const out = [];
210
+ const seen = new Set();
211
+ const keep = (c) => {
212
+ // The same placement reached another way (a mirror that moves nothing) is kept once.
213
+ const key = c.key;
214
+ if (seen.has(key)) return;
215
+ seen.add(key);
216
+ if (out.length < 80 || c.cost < out[out.length - 1].cost) { out.push(c); out.sort((a, b) => a.cost - b.cost); if (out.length > 80) out.pop(); }
217
+ };
218
+
219
+ // Worked in the path's own frame: "along" is the way the path runs, "across" the other way. Down: along is y.
220
+ for (const down of [true, false]) {
221
+ const sw = (r) => (down ? r : { x: r.y, y: r.x, w: r.h, h: r.w });
222
+ const pathChoice = down ? 1 : 0;
223
+ // Where the path forks, the subsystems side by side in every order.
224
+ let orders = [[]];
225
+ for (const lv of levels) orders = orders.flatMap((o) => permutations(lv.length).map((pm) => [...o, pm.map((i) => lv[i])])).slice(0, 24);
226
+ for (const order of orders)
227
+ for (let flipMask = 0; flipMask < 1 << spineK.length; flipMask++) {
228
+ const flipOf = (k) => (spineK.includes(k) ? (flipMask >> spineK.indexOf(k)) & 1 : 0);
229
+ const inset = new Array(n).fill(0);
230
+ // A part in the path's subsystem, in that frame, relative to the subsystem's corner.
231
+ const local = (k, choice, flip) => Object.fromEntries(partsOf(k, choice, flip, inset[k]).map((p) => [p.id, sw(p)]));
232
+ const size = (k, choice) => sw({ x: 0, y: 0, w: items[k].dims[choice].w + inset[k], h: items[k].dims[choice].h });
233
+ const rect = new Array(n), at = {};
234
+ const put = (k, choice, flip, x, y) => {
235
+ const d = size(k, choice);
236
+ rect[k] = { x, y, w: d.w, h: d.h };
237
+ for (const [id, p] of Object.entries(local(k, choice, flip))) at[id] = { x: x + p.x, y: y + p.y, w: p.w, h: p.h };
238
+ };
239
+ // Where a line may run into (or out of) a part along the path without crossing another part of its subsystem or
240
+ // its heading: straight in at the middle ("c"), or in a lane just to its left ("l") or right ("r"), entering from
241
+ // the side. Each as an offset across from the subsystem's corner, freest first.
242
+ const lanes = (k, choice, flip, id, into) => {
243
+ const ps = local(k, choice, flip), me = ps[id], d = size(k, choice);
244
+ const head = sw({ x: PAD.x, y: 0, w: items[k].headW, h: items[k].dims[choice].top - 8 });
245
+ const others = [...Object.entries(ps).filter(([q]) => q !== id).map(([, r]) => r), head];
246
+ const y0 = into ? -1 : me.y + me.h, y1 = into ? me.y : d.h + 1;
247
+ const free = (x) => !others.some((r) => r.x < x + 14 && r.x + r.w > x - 14 && r.y < y1 && r.y + r.h > y0);
248
+ // A part near the subsystem's edge steps out into the gap beside the subsystem, not along its border.
249
+ const l = me.x < 64 ? -gap / 2 : me.x - LANE_OFF, r = d.w - me.x - me.w < 64 ? d.w + gap / 2 : me.x + me.w + LANE_OFF;
250
+ return [['c', me.x + me.w / 2], ['l', l], ['r', r]].filter(([, x]) => free(x));
251
+ };
252
+ let y = 0;
253
+ const entered = {};
254
+ for (const lv of order) {
255
+ const into = lv.map((k) => step.find(([a, b]) => b === k && rect[a]));
256
+ // Laid out down, a line coming in from above would cross the subsystem's heading at its top left: the parts
257
+ // move right until a line straight down the middle clears it.
258
+ if (down) lv.forEach((k, i) => {
259
+ if (!into[i]) return;
260
+ const dst = local(k, pathChoice, flipOf(k))[into[i][2].to];
261
+ const need = Math.max(0, PAD.x + items[k].headW + 16 - (dst.x + dst.w / 2));
262
+ const before = inset[k];
263
+ inset[k] = need;
264
+ if (!lanes(k, pathChoice, flipOf(k), into[i][2].to, true).some(([s]) => s === 'c')) inset[k] = before;
265
+ });
266
+ // Out of the part above and into the part here: straight down if both are clear, else stepping aside through a
267
+ // lane, never out to one side and back in from the other, which would turn the path back on itself.
268
+ const xs = lv.map((k, i) => {
269
+ if (!into[i]) return null;
270
+ const [a, , e] = into[i], src = at[e.from];
271
+ // A part entered from one side is left by the other side or the bottom, so the path keeps going one way.
272
+ const outs = lanes(a, pathChoice, flipOf(a), e.from, false).filter(([s]) => s === 'c' || s !== entered[e.from]).map(([s, x]) => [s, x + rect[a].x]);
273
+ const ins = lanes(k, pathChoice, flipOf(k), e.to, true);
274
+ const rank = ([so], [si]) => (so === 'c' ? 0 : 1) + (si === 'c' ? 0 : 1);
275
+ const pairs = outs.flatMap((o) => ins.filter((n) => !(o[0] !== 'c' && n[0] === o[0])).map((n) => [o, n])).sort((p, q) => rank(...p) - rank(...q));
276
+ if (!pairs.length) return src.x + src.w / 2 - (local(k, pathChoice, flipOf(k))[e.to].x + local(k, pathChoice, flipOf(k))[e.to].w / 2);
277
+ const [[, ox], [si, ix]] = pairs[0];
278
+ entered[e.to] = si;
279
+ return ox - ix;
280
+ });
281
+ const h = Math.max(...lv.map((k) => size(k, pathChoice).h));
282
+ if (lv.length === 1) put(lv[0], pathChoice, flipOf(lv[0]), xs[0] ?? 0, y);
283
+ else {
284
+ // Where the path forks into several subsystems, they sit side by side, centred on where it came from.
285
+ const ws = lv.map((k) => size(k, pathChoice).w), total = ws.reduce((a, b) => a + b, 0) + gap * (lv.length - 1);
286
+ const known = lv.map((k, i) => (xs[i] == null ? null : xs[i] + ws[i] / 2)).filter((v) => v != null);
287
+ const mid = known.length ? known.reduce((a, b) => a + b, 0) / known.length : total / 2;
288
+ let x = mid - total / 2;
289
+ lv.forEach((k, i) => { put(k, pathChoice, flipOf(k), x, y); x += ws[i] + gap; });
290
+ }
291
+ y += h + gap;
292
+ }
293
+ const sx0 = Math.min(...spineK.map((k) => rect[k].x)), sx1 = Math.max(...spineK.map((k) => rect[k].x + rect[k].w));
294
+ const sy1 = y - gap;
295
+ // Each other subsystem: before the path (0), after it (1) or past its end (2), laid out across or down.
296
+ const options = 3 ** rest.length * 2 ** rest.length;
297
+ for (let o = 0; o < options; o++) {
298
+ let m = o;
299
+ const side = rest.map(() => { const v = m % 3; m = Math.floor(m / 3); return v; });
300
+ const choice = rest.map(() => { const v = m % 2; m >>= 1; return v; });
301
+ const sorted = [[], [], []];
302
+ rest.forEach((k, i) => sorted[side[i]].push(i));
303
+ // Each column in every order: the subsystem its links pull highest need not be the best one on top.
304
+ for (const p0 of permutations(sorted[0].length)) for (const p1 of permutations(sorted[1].length)) {
305
+ const R = rect.slice(), A = { ...at };
306
+ const placeRest = (i, x, yy) => {
307
+ const k = rest[i], d = size(k, choice[i]);
308
+ R[k] = { x, y: yy, w: d.w, h: d.h };
309
+ for (const [id, p] of Object.entries(local(k, choice[i], 0))) A[id] = { x: x + p.x, y: yy + p.y, w: p.w, h: p.h };
310
+ };
311
+ const cols = [p0.map((j) => sorted[0][j]), p1.map((j) => sorted[1][j]), sorted[2]];
312
+ // Beside the path, a subsystem sits level with the path's parts it links to.
313
+ const pull = (i) => {
314
+ const k = rest[i], ys = edges.filter((e) => (owner(e.from) === k) !== (owner(e.to) === k) && on.has(owner(owner(e.from) === k ? e.to : e.from)))
315
+ .map((e) => { const p = at[owner(e.from) === k ? e.to : e.from]; return p.y + p.h / 2; });
316
+ return ys.length ? ys.reduce((a, b) => a + b, 0) / ys.length : sy1;
317
+ };
318
+ for (const c of [0, 1]) {
319
+ // The gap beside the path widens with the links that cross it, so their lines and labels stay apart.
320
+ const ks = new Set(cols[c].map((i) => rest[i]));
321
+ const through = edges.filter((e) => ks.has(owner(e.from)) !== ks.has(owner(e.to))).length;
322
+ const cgap = gap + Math.max(0, through - 4) * 14;
323
+ let cy = 0;
324
+ const wmax = Math.max(0, ...cols[c].map((i) => size(rest[i], choice[i]).w));
325
+ for (const i of cols[c]) {
326
+ const d = size(rest[i], choice[i]);
327
+ const yy = Math.max(0, cy, Math.min(pull(i) - d.h / 2, sy1 - d.h));
328
+ placeRest(i, c === 0 ? sx0 - cgap - (wmax + d.w) / 2 : sx1 + cgap + (wmax - d.w) / 2, yy);
329
+ cy = yy + d.h + gap;
330
+ }
331
+ }
332
+ let bx = Math.min(sx0, ...cols[0].map((i) => R[rest[i]].x));
333
+ const below = Math.max(sy1, ...[0, 1].flatMap((c) => cols[c].map((i) => R[rest[i]].y + R[rest[i]].h))) + gap;
334
+ for (const i of cols[2]) { placeRest(i, bx, below); bx += size(rest[i], choice[i]).w + gap; }
335
+ // Back to the page's frame, starting at 0, 0.
336
+ const real = R.map(sw), x0 = Math.min(...real.map((r) => r.x)), y0 = Math.min(...real.map((r) => r.y));
337
+ const fin = real.map((r) => ({ ...r, x: r.x - x0, y: r.y - y0 }));
338
+ if (fin.some((r, i) => fin.some((q, j) => j > i && r.x < q.x + q.w + gap / 2 && q.x < r.x + r.w + gap / 2 && r.y < q.y + q.h + gap / 2 && q.y < r.y + r.h + gap / 2))) continue;
339
+ const W = Math.max(...fin.map((r) => r.x + r.w)), H = Math.max(...fin.map((r) => r.y + r.h));
340
+ const P = Object.fromEntries(Object.entries(A).map(([id, r]) => { const q = sw(r); return [id, { x: q.x - x0, y: q.y - y0, w: q.w, h: q.h }]; }));
341
+ const c = (id) => [P[id].x + P[id].w / 2, P[id].y + P[id].h / 2];
342
+ let cost = W > maxW ? 1000 + W / 100 : 0;
343
+ cost += -8 * Math.log(Math.min(SCREEN.w / Math.max(W + extra.w, 960), SCREEN.h / (H + extra.h)));
344
+ const segs = edges.map((e) => [c(e.from), c(e.to), e]);
345
+ for (let i = 0; i < segs.length; i++) {
346
+ const [p, q, e] = segs[i];
347
+ cost += (Math.abs(p[0] - q[0]) + Math.abs(p[1] - q[1])) / 500;
348
+ for (let j = i + 1; j < segs.length; j++) {
349
+ const [u, v, f] = segs[j];
350
+ if (e.from === f.from || e.from === f.to || e.to === f.from || e.to === f.to) continue;
351
+ if (crosses(p, q, u, v)) cost += !e.tone !== !f.tone ? 4 : 1;
352
+ }
353
+ for (const id in P) if (id !== e.from && id !== e.to && segHits(p, q, P[id])) cost += 0.5;
354
+ }
355
+ for (const run of runs) if (doublesBack([c(run[0].from), ...run.map((e) => c(e.to))])) cost += 8;
356
+ const ch = items.map((_, k) => (on.has(k) ? pathChoice : choice[rest.indexOf(k)]));
357
+ keep({ key: Object.keys(P).sort().map((id) => c(id).map(Math.round).join(',')).join(' '), cost, rect: fin, W, H, choice: ch, flip: spineK.reduce((f, k) => f | (flipOf(k) << k), 0), inset: inset.slice() });
358
+ }
359
+ }
360
+ }
361
+ }
362
+ return out;
363
+ }
364
+
365
+ function crosses(a, b, c, d) {
366
+ const o = (p, q, r) => Math.sign((q[0] - p[0]) * (r[1] - p[1]) - (q[1] - p[1]) * (r[0] - p[0]));
367
+ return o(a, b, c) * o(a, b, d) < 0 && o(c, d, a) * o(c, d, b) < 0;
368
+ }
369
+
370
+ /** Whether the line from p to q passes through rectangle r (Liang–Barsky clipping). */
371
+ function segHits(p, q, r) {
372
+ const dx = q[0] - p[0], dy = q[1] - p[1];
373
+ let t0 = 0, t1 = 1;
374
+ for (const [pp, qq] of [[-dx, p[0] - r.x], [dx, r.x + r.w - p[0]], [-dy, p[1] - r.y], [dy, r.y + r.h - p[1]]]) {
375
+ if (pp === 0) { if (qq <= 0) return false; continue; }
376
+ const t = qq / pp;
377
+ if (pp < 0) t0 = Math.max(t0, t); else t1 = Math.min(t1, t);
378
+ if (t0 >= t1) return false;
379
+ }
380
+ return true;
381
+ }
382
+
383
+ /**
384
+ * Lays the map out and draws it. Each box is measured in the page first. dagre lays out the parts inside each
385
+ * subsystem, across and down; figkit then packs the subsystems into rows that fit the figure, and its own router draws
386
+ * every link around the boxes, as for any figure, so the checks on arrows and labels apply unchanged.
387
+ */
388
+ export async function drawMap(spec, main) {
389
+ const phone = main.classList.contains('phone');
390
+ const { groups, nodes, edges } = graphOf(spec, phone);
391
+ const pad = parseFloat(getComputedStyle(main).paddingLeft) || 48;
392
+ const wrap = document.createElement('div');
393
+ wrap.className = 'map' + (phone ? ' map-overview' : '');
394
+ main.appendChild(wrap);
395
+
396
+ const box = {};
397
+ for (const n of nodes) {
398
+ const el = document.createElement('div');
399
+ el.className = 'node map-part' + (n.sub ? ' map-sub' : '') + (n.tone ? ' accent' : '');
400
+ el.id = 'mp-' + n.id;
401
+ el.innerHTML = n.html;
402
+ wrap.appendChild(el);
403
+ box[n.id] = el;
404
+ }
405
+ const region = {};
406
+ for (const g of groups) {
407
+ const el = document.createElement('div');
408
+ el.className = 'group map-region';
409
+ el.id = 'mp-' + g.id;
410
+ el.innerHTML = `<div class="mp-head"><h2>${esc(g.name)}</h2>${g.kind ? `<span class="tag">${esc(g.kind)}</span>` : ''}</div>`;
411
+ wrap.appendChild(el);
412
+ region[g.id] = el;
413
+ }
414
+ // A label measured as figkit draws it, so the layout keeps room for it between the layers of a region.
415
+ const probe = (text) => {
416
+ const el = document.createElement('div');
417
+ el.className = 'fig-wire-label';
418
+ el.style.visibility = 'hidden';
419
+ el.textContent = text;
420
+ wrap.appendChild(el);
421
+ const r = el.getBoundingClientRect();
422
+ el.remove();
423
+ return { width: Math.ceil(r.width), height: Math.ceil(r.height) };
424
+ };
425
+ await new Promise((r) => requestAnimationFrame(r));
426
+ // Fractional widths rounded down would wrap the text again once the box is given that width.
427
+ const size = (el) => { const r = el.getBoundingClientRect(); return { width: Math.ceil(r.width) + 1, height: Math.ceil(r.height) }; };
428
+ const sizes = Object.fromEntries(nodes.map((n) => [n.id, size(box[n.id])]));
429
+
430
+ // Each region laid out on its own, across and down; on a phone each subsystem is one box and needs no layout.
431
+ const items = [];
432
+ if (phone) for (const n of nodes) items.push({ id: n.id, dims: [{ w: sizes[n.id].width, h: sizes[n.id].height }] });
433
+ for (const g of groups) {
434
+ const head = size(region[g.id].querySelector('.mp-head'));
435
+ const mine = nodes.filter((n) => n.group === g.id);
436
+ const ids = new Set(mine.map((n) => n.id));
437
+ const inner = edges.map((e, i) => ({ e, i })).filter(({ e }) => ids.has(e.from) && ids.has(e.to));
438
+ const dims = [];
439
+ for (const dir of ['RIGHT', 'DOWN']) {
440
+ const out = layoutRegion(mine.map((n) => ({ id: n.id, ...sizes[n.id] })), inner.map(({ e, i }) => ({ id: 'e' + i, from: e.from, to: e.to, tone: e.tone, label: e.label ? probe(e.label) : null })), dir);
441
+ const top = head.height + 30;
442
+ dims.push({ w: Math.max(out.width + 2 * PAD.x, head.width + 2 * PAD.x), h: out.height + top + PAD.bottom, top, out });
443
+ }
444
+ items.push({ id: g.id, dims, headW: head.width });
445
+ }
446
+
447
+ const index = Object.fromEntries(items.map((it, i) => [it.id, i]));
448
+ const owner = (id) => index[phone ? id : nodes.find((n) => n.id === id).group];
449
+ const counts = new Map();
450
+ for (const e of edges) {
451
+ const a = owner(e.from), b = owner(e.to);
452
+ if (a === b) continue;
453
+ const key = a + ',' + b;
454
+ counts.set(key, (counts.get(key) ?? 0) + 1);
455
+ }
456
+ const links = [...counts].map(([k, count]) => { const [a, b] = k.split(',').map(Number); return { a, b, count }; });
457
+ const header = main.querySelector(':scope > header');
458
+ const gap = phone ? GAP.phone : GAP.desk;
459
+ const inner = phone ? (parseFloat(getComputedStyle(main).width) || 560) - 2 * pad : MAX_W - 2 * pad - 2 * LANE;
460
+ // Each highlighted run as the subsystems it passes through, in order.
461
+ const hotRuns = runsOf(edges).map((run) => [owner(run[0].from), ...run.map((e) => owner(e.to))].filter((k, i, a) => i === 0 || a[i - 1] !== k)).filter((r) => r.length > 2);
462
+ const cands = pack(items, links, { maxW: inner, gap, extra: { w: 2 * pad, h: (header ? header.offsetHeight + 28 : 0) + 2 * pad }, phone, hotRuns });
463
+ // Each part's box within its region, for a region laid out across (0) or down (1), mirrored or not.
464
+ const partsOf = (k, choice, flip, inset = 0) => {
465
+ const it = items[k];
466
+ if (phone) return [{ id: it.id, x: 0, y: 0, w: it.dims[0].w, h: it.dims[0].h }];
467
+ const d = it.dims[choice];
468
+ // A region wider than its parts (for its heading) centres them.
469
+ const shift = Math.max(0, (d.w - (d.out.width + 2 * PAD.x)) / 2);
470
+ return d.out.children.map((c) => ({
471
+ id: c.id, w: c.width, h: c.height,
472
+ x: PAD.x + shift + inset + (flip && choice === 1 ? d.out.width - c.x - c.width : c.x),
473
+ y: d.top + (flip && choice === 0 ? d.out.height - c.y - c.height : c.y),
474
+ }));
475
+ };
476
+ // With a highlighted path, the arrangements built around it are tried first; the packed ones stay as a fallback.
477
+ const around = phone ? [] : spine(items, edges, partsOf, owner, { maxW: inner, gap, extra: { w: 2 * pad, h: (header ? header.offsetHeight + 28 : 0) + 2 * pad } });
478
+ const ranked = [...around.slice(0, SPINE_TRIES), ...refine(cands, items, edges, partsOf)];
479
+
480
+ // Regions, and the parts inside each at the place the layout gave them; on a phone the boxes themselves.
481
+ const apply = (best) => {
482
+ const place = (el, x, y, w, h) => Object.assign(el.style, { left: x + 'px', top: y + 'px', ...(w ? { width: w + 'px', height: h + 'px' } : {}) });
483
+ const offsetX = phone ? Math.max(0, (inner - best.W) / 2) : 0;
484
+ items.forEach((it, k) => {
485
+ const r = best.rect[k];
486
+ if (phone) { place(box[it.id], r.x + offsetX, r.y); return; }
487
+ // A region shorter than its row keeps its own height; its parts are centred across its width.
488
+ place(region[it.id], r.x + LANE, r.y, r.w, r.h);
489
+ for (const p of partsOf(k, best.choice[k], (best.flip >> k) & 1, best.inset?.[k] ?? 0)) { region[it.id].appendChild(box[p.id]); place(box[p.id], p.x, p.y, p.w, p.h); }
490
+ });
491
+ wrap.style.width = (phone ? inner : best.W + 2 * LANE) + 'px';
492
+ wrap.style.height = best.H + 'px';
493
+ if (!phone) main.style.setProperty('--w', Math.round(Math.max(960, best.W + 2 * LANE + 2 * pad)) + 'px');
494
+
495
+ };
496
+ // The few cheapest arrangements are routed for real by fig.js, which keeps the one whose arrows cross least.
497
+ const per = new Map();
498
+ const tries = ranked.filter((c) => { const k = per.get(c.rect) ?? 0; per.set(c.rect, k + 1); return k < (phone ? 1 : 4); }).slice(0, phone ? 12 : 32 + around.slice(0, SPINE_TRIES).length);
499
+ window.fig.mapTry = tries.map((c) => () => apply(c));
500
+ apply(ranked[0]);
501
+
502
+ // Every link is an ordinary figkit arrow, routed around the boxes and regions with its label placed clear of them.
503
+ // The highlighted links are routed first, so they take the straightest way and the grey links go around them.
504
+ for (const e of [...edges.filter((x) => x.tone), ...edges.filter((x) => !x.tone)]) {
505
+ const a = document.createElement('fig-arrow');
506
+ a.setAttribute('from', 'mp-' + e.from);
507
+ a.setAttribute('to', 'mp-' + e.to);
508
+ if (e.label) a.setAttribute('label', e.label);
509
+ if (e.tone) a.setAttribute('accent', '');
510
+ main.appendChild(a);
511
+ }
512
+ return wrap;
513
+ }
package/viz/spec.js CHANGED
@@ -1,6 +1,8 @@
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';
5
+ import { mapShape, mapChecks } from './map-spec.js';
4
6
 
5
7
  const TONE = z.enum(['accent', 'second', 'good', 'bad']);
6
8
  const label = z.string({ error: 'needs a label' }).trim().min(1, 'needs a label').max(40, 'keep labels to 40 characters');
@@ -50,6 +52,11 @@ export const BarsSpec = z.object({
50
52
  }).strict()).min(1).max(12, 'at most 12 bars'),
51
53
  range,
52
54
  each,
55
+ ref: z.object({
56
+ value: value.nonnegative('must be 0 or more'),
57
+ 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'),
58
+ show: z.string().max(24).optional(),
59
+ }, { error: 'ref: { "value", "label" }, the level every bar is compared with, e.g. production' }).strict().optional(),
53
60
  }).strict()
54
61
  .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
62
 
@@ -88,6 +95,31 @@ export const DumbbellSpec = z.object({
88
95
  }).strict()).min(1).max(8, 'at most 8 rows'),
89
96
  }).strict();
90
97
 
98
+ /**
99
+ * Signed estimates with their ranges around a zero line, one row each (a forest plot): a gap, a change or a correlation,
100
+ * and how sure it is. Colour is not chosen by the author: a row is coloured when its range clears zero, grey when not.
101
+ */
102
+ export const EstimatesSpec = z.object({
103
+ type: z.literal('estimates'),
104
+ ...common,
105
+ 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'),
106
+ 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"'),
107
+ sides: z.object({
108
+ below: label.max(28, 'keep it to 28 characters'),
109
+ above: label.max(28, 'keep it to 28 characters'),
110
+ }, { error: 'sides: { "below", "above" }, what a value under and over zero means, e.g. "Control better"' }).strict().optional(),
111
+ 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(),
112
+ rows: z.array(z.object({
113
+ label: label.max(32, 'keep row labels to 32 characters'),
114
+ value,
115
+ low: z.number({ error: 'every estimate needs its range: "low" and "high". A number with no range is a "bars" chart' }).finite(),
116
+ high: z.number({ error: 'every estimate needs its range: "low" and "high". A number with no range is a "bars" chart' }).finite(),
117
+ 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(),
118
+ n: sample,
119
+ }).strict()).min(1).max(12, 'at most 12 rows; more is two figures'),
120
+ each,
121
+ }).strict();
122
+
91
123
  /** A line over time (or any ordered x) with one point marked and labelled. */
92
124
  export const SeriesSpec = z.object({
93
125
  type: z.literal('series'),
@@ -154,15 +186,15 @@ export const PanelsSpec = z.object({
154
186
  }).strict();
155
187
 
156
188
  /** A grid of values coloured from pale to strong: where something is high or low across two dimensions. */
157
- const axisLabel = z.union([z.string().trim().min(1).max(16, 'keep row and column labels to 16 characters'), z.number().finite()]);
189
+ const axisLabel = z.union([z.string().trim().min(1).max(16, 'keep column labels to 16 characters'), z.number().finite()]);
158
190
  export const HeatmapSpec = z.object({
159
191
  type: z.literal('heatmap'),
160
192
  ...common,
161
193
  note: z.string({ error: 'required: what the colour counts, e.g. "People active per hour"' }).trim().min(1).max(160, 'keep the note to 160 characters'),
162
- rows: z.array(axisLabel).min(2).max(12, 'at most 12 rows'),
194
+ 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
195
  columns: z.array(axisLabel).min(2).max(31, 'at most 31 columns'),
164
196
  values: z.array(z.array(value)),
165
- mark: z.object({ row: axisLabel, column: axisLabel, label: z.string().trim().min(1).max(32, 'keep the mark label to 32 characters') }).strict().optional(),
197
+ 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
198
  }).strict();
167
199
 
168
200
  /** Every value as a dot: one row per group, the dots stacked where they would overlap, so none hides another. */
@@ -253,10 +285,17 @@ export const EntitiesSpec = z.object({
253
285
  /** Who calls whom, in order: participants side by side and numbered messages between them. */
254
286
  export const SequenceSpec = z.object({ type: z.literal('sequence'), title, note: common.note, source: common.source, style: common.style, ...sequenceShape }).strict();
255
287
 
256
- export const Spec = z.discriminatedUnion('type', [SplitSpec, BarsSpec, BeforeAfterSpec, DumbbellSpec, SeriesSpec, EntitiesSpec, GroupsSpec, LinesSpec, PanelsSpec, HeatmapSpec, DotsSpec, ScatterSpec, UnitsSpec, SequenceSpec]);
257
- export const TYPES = { split: SplitSpec, bars: BarsSpec, 'before-after': BeforeAfterSpec, dumbbell: DumbbellSpec, series: SeriesSpec, entities: EntitiesSpec, groups: GroupsSpec, lines: LinesSpec, panels: PanelsSpec, heatmap: HeatmapSpec, dots: DotsSpec, scatter: ScatterSpec, units: UnitsSpec, sequence: SequenceSpec };
288
+ /** The states one thing moves through and the moves between them, with optional counts on the moves. */
289
+ export const StatesSpec = z.object({ type: z.literal('states'), title, note: common.note, source: common.source, style: common.style, ...statesShape }).strict();
290
+
291
+ /** A whole system: parts grouped by subsystem and the links between them, laid out by figkit. */
292
+ export const MapSpec = z.object({ type: z.literal('map'), title, note: common.note, source: common.source, style: common.style, ...mapShape }).strict();
293
+
294
+ export const Spec = z.discriminatedUnion('type', [SplitSpec, BarsSpec, EstimatesSpec, BeforeAfterSpec, DumbbellSpec, SeriesSpec, EntitiesSpec, GroupsSpec, LinesSpec, PanelsSpec, HeatmapSpec, DotsSpec, ScatterSpec, UnitsSpec, SequenceSpec, StatesSpec, MapSpec]);
295
+ 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, map: MapSpec };
258
296
 
259
- const items = (s) => s.parts ?? s.bars ?? s.rows ?? s.records ?? s.messages ?? (s.type === 'scatter' ? s.points : s.type === 'dots' ? s.groups : (s.after ? [s.after] : []));
297
+
298
+ const items = (s) => s.parts ?? s.bars ?? s.rows ?? s.records ?? s.messages ?? (s.type === 'states' ? [...s.states, ...s.moves] : s.type === 'map' ? [...s.parts, ...s.links] : null) ?? (s.type === 'scatter' ? s.points : s.type === 'dots' ? s.groups : (s.after ? [s.after] : []));
260
299
  const kind = (v) => (typeof v === 'number' ? 'number' : 'date');
261
300
 
262
301
 
@@ -306,6 +345,8 @@ function linesChecks(s, at = '') {
306
345
  /** Rules that span fields: x values of one kind, in order, and a mark that is one of the points. */
307
346
  function crossChecks(s) {
308
347
  if (s.type === 'sequence') return sequenceChecks(s);
348
+ if (s.type === 'states') return statesChecks(s);
349
+ if (s.type === 'map') return mapChecks(s);
309
350
  if (s.type === 'groups') return groupsChecks(s);
310
351
  if (s.type === 'lines') return linesChecks(s);
311
352
  if (s.type === 'panels') {
@@ -340,8 +381,25 @@ function crossChecks(s) {
340
381
  const kinds = new Set(s.rows.flatMap((r) => [kind(r.from), kind(r.to)]));
341
382
  if (kinds.size > 1) return ['rows: use all numbers or all dates, not both'];
342
383
  }
384
+ if (s.type === 'estimates') {
385
+ const out = [];
386
+ s.rows.forEach((r, i) => {
387
+ 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`);
388
+ 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`);
389
+ });
390
+ const names = s.rows.map((r) => r.label);
391
+ if (new Set(names).size < names.length) out.push('rows: two rows have the same label');
392
+ const ns = s.rows.map((r) => r.n !== undefined);
393
+ if (ns.some(Boolean) && !ns.every(Boolean)) out.push('rows: give every row an "n" or none');
394
+ return out;
395
+ }
343
396
  if (s.type === 'bars') {
344
397
  const out = rangeChecks(s.bars, (b) => b.value, s.range, 'bars');
398
+ if (s.ref) {
399
+ 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');
400
+ 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`); });
401
+ if (s.max !== undefined && s.ref.value > s.max) out.push('ref.value: must be at most max');
402
+ }
345
403
  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
404
  const ns = s.bars.map((b) => b.n !== undefined);
347
405
  if (ns.some(Boolean) && !ns.every(Boolean)) out.push('bars: give every bar an "n" or none');