flowfig 0.8.4 → 0.10.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/dist/atlas.js ADDED
@@ -0,0 +1,122 @@
1
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
2
+ import { dirname, join, posix, resolve } from 'node:path';
3
+ import { coverageLines, coverageReport } from './coverage.js';
4
+ import { loadSpec, specOf } from './load.js';
5
+ import { altText, nodes, str } from './model.js';
6
+ import { openFile } from './open.js';
7
+ import { esc, pageHtml } from './page.js';
8
+ import { toSvg } from './svg.js';
9
+ const USAGE = 'usage: flowfig atlas [--figures <glob>] [--root <dir>] [--out <dir>] [--open]';
10
+ const CSS = `body { font: 16px/1.5 system-ui, sans-serif; margin: 0 auto; max-width: 960px; padding: 16px }
11
+ table { border-collapse: collapse; width: 100% }
12
+ td, th { text-align: left; padding: 6px 10px; border-bottom: 1px solid color-mix(in srgb, CanvasText 20%, transparent) }
13
+ strong[data-state] { padding: 1px 8px; border-radius: 4px; border: 1px solid currentColor; font-size: 14px }
14
+ strong[data-state="ok"] { color: light-dark(#116329, #7ee787) }
15
+ strong[data-state="stale"] { color: light-dark(#8a5a00, #e3b341) }
16
+ strong[data-state="fail"] { color: light-dark(#b42318, #ff7b72) }
17
+ strong[data-state="none"] { color: GrayText }`;
18
+ const badge = (f) => `<strong data-state="${f.state}">${f.state}</strong>`;
19
+ const pageOf = (figure) => {
20
+ const page = figure.replace(/\.svg$/, '.html');
21
+ return page === 'index.html' ? 'index.figure.html' : page;
22
+ };
23
+ const MARK = '<meta name="generator" content="flowfig atlas">';
24
+ const url = (path) => path.split('/').map(encodeURIComponent).join('/');
25
+ const hrefTo = (from, to) => url(posix.relative(posix.dirname(from), to));
26
+ export function atlasFiles({ root = process.cwd(), figures } = {}) {
27
+ const report = coverageReport({ root, figures });
28
+ const rows = report.figures.sort((a, b) => a.figure.localeCompare(b.figure));
29
+ if (!rows.length)
30
+ return new Map();
31
+ const specs = new Map();
32
+ for (const { figure } of rows) {
33
+ try {
34
+ specs.set(figure, loadSpec(JSON.parse(specOf(readFileSync(resolve(root, figure), 'utf8'), figure)), figure));
35
+ }
36
+ catch { }
37
+ }
38
+ const files = new Map();
39
+ const shell = (title, body) => `<!doctype html>\n<meta charset="utf-8">\n${MARK}\n<meta name="viewport" content="width=device-width, initial-scale=1">\n<title>${esc(title)}</title>\n<style>\n:root { color-scheme: light dark }\n${CSS}\n</style>\n${body}\n`;
40
+ for (const f of rows) {
41
+ const spec = specs.get(f.figure);
42
+ if (!spec)
43
+ continue;
44
+ const page = pageOf(f.figure);
45
+ const targets = nodes(spec.layout).flatMap((n) => {
46
+ const to = typeof n.detail === 'string' ? posix.normalize(n.detail) : '';
47
+ return to && specs.has(to) ? [{ n, to }] : [];
48
+ });
49
+ const hrefs = new Map(targets.map(({ n, to }) => [n.id, hrefTo(page, pageOf(to))]));
50
+ const svg = toSvg(spec, { links: Object.fromEntries(hrefs) });
51
+ const details = nodes(spec.layout)
52
+ .filter((n) => typeof n.detail === 'string')
53
+ .map((n) => {
54
+ const name = esc(str(n.label) || n.id);
55
+ const href = hrefs.get(n.id);
56
+ return `<li>${name}: ${href == null ? `${esc(n.detail)} (not in the atlas)` : `<a href="${esc(href)}">${esc(n.detail)}</a>`}</li>`;
57
+ });
58
+ const { title, desc } = altText(spec);
59
+ const before = `<style>\n${CSS}\n</style>\n<main>\n<p><a href="${esc(hrefTo(page, 'index.html'))}">All figures</a></p>\n` +
60
+ `<h1>${esc(f.figure)}</h1>\n<p>${badge(f)} ${esc(f.detail)}</p>\n`;
61
+ const after = (details.length ? `\n<h2>Details</h2>\n<ul>\n${details.join('\n')}\n</ul>` : '') +
62
+ `\n<details><summary>Transcript</summary><pre style="white-space: pre-wrap">${esc(desc)}</pre></details>\n</main>`;
63
+ files.set(page, pageHtml(svg, `${title} (${f.figure})`, before, after, `${MARK}\n`));
64
+ }
65
+ const body = rows
66
+ .map((f) => {
67
+ const spec = specs.get(f.figure);
68
+ const name = spec ? `<a href="${esc(url(pageOf(f.figure)))}">${esc(f.figure)}</a>` : esc(f.figure);
69
+ return `<tr><td>${name}</td><td>${badge(f)}</td><td>${esc(f.detail)}</td><td>${spec ? esc(altText(spec).title) : ''}</td></tr>`;
70
+ })
71
+ .join('\n');
72
+ files.set('index.html', shell('Figures', `<main>\n<h1>Figures</h1>\n<p>${esc(coverageLines(report).at(-1))}</p>\n<table>\n<tr><th>Figure</th><th>Health</th><th>Detail</th><th>Title</th></tr>\n${body}\n</table>\n</main>`));
73
+ files.set('.nojekyll', '');
74
+ return files;
75
+ }
76
+ export async function runAtlas(argv) {
77
+ const fail = (message, code) => (console.error(message), code);
78
+ const args = [...argv];
79
+ const open = args.includes('--open');
80
+ if (open)
81
+ args.splice(args.indexOf('--open'), 1);
82
+ const opts = {};
83
+ for (const name of ['--figures', '--root', '--out']) {
84
+ const at = args.indexOf(name);
85
+ if (at === -1)
86
+ continue;
87
+ const value = args.splice(at, 2)[1];
88
+ if (!value || value.startsWith('-'))
89
+ return fail(`${name} needs a value`, 2);
90
+ opts[name] = value;
91
+ }
92
+ if (args.length)
93
+ return fail(args[0].startsWith('-') ? `unknown flag ${args[0]}` : USAGE, 2);
94
+ const root = resolve(opts['--root'] ?? '.');
95
+ if (!existsSync(root))
96
+ return fail(`${root}: the folder does not exist`, 2);
97
+ const files = atlasFiles({ root, figures: opts['--figures'] });
98
+ if (!files.size)
99
+ return fail('no figure SVG matches', 1);
100
+ const out = resolve(opts['--out'] ?? 'atlas');
101
+ const mine = [...files.keys()].filter((p) => p !== '.nojekyll');
102
+ const foreign = mine.find((p) => existsSync(join(out, p)) && !readFileSync(join(out, p), 'utf8').includes(MARK));
103
+ if (foreign)
104
+ return fail(`${join(out, foreign)}: the file exists and flowfig atlas did not write it; nothing was written`, 2);
105
+ for (const [path, text] of files) {
106
+ if (!text && existsSync(join(out, path)))
107
+ continue;
108
+ mkdirSync(dirname(join(out, path)), { recursive: true });
109
+ writeFileSync(join(out, path), text);
110
+ }
111
+ const index = join(out, 'index.html');
112
+ console.log(`${index} — ${files.size - 2} figure page${files.size === 3 ? '' : 's'}`);
113
+ if (open) {
114
+ try {
115
+ await openFile(index);
116
+ }
117
+ catch (e) {
118
+ return fail(`open: ${e.message}`, 1);
119
+ }
120
+ }
121
+ return 0;
122
+ }
package/dist/auto.d.ts ADDED
@@ -0,0 +1,3 @@
1
+ import { type FigEdge, type FlowProps } from './model.ts';
2
+ export declare const rowBreaks: WeakSet<FigEdge>;
3
+ export declare function autoLayout(spec: FlowProps): FlowProps;
package/dist/auto.js ADDED
@@ -0,0 +1,302 @@
1
+ import { edgeId, fitCap, idsIn, isGroup, isRows, itemWidth, nodes, toBeat, NODE_MAX_W, } from './model.js';
2
+ const PAGE = 830, PAD = 24, SWEEPS = 8, TALL = 2.5,
3
+ // A 20-box chain measures 105 px for each rank.
4
+ RANK_H = 105, MIN_TEXT = 10;
5
+ const flat = (it) => (isGroup(it) && it.label == null && it.id == null ? it.children.flatMap(flat) : [it]);
6
+ function seenOrder(fig) {
7
+ const at = new Map();
8
+ const byId = new Map(fig.edges.map((e) => [edgeId(e), e]));
9
+ for (const s of fig.steps ?? [])
10
+ for (const b of s.flow)
11
+ for (const h of toBeat(b).hops) {
12
+ const e = byId.get(h.edge);
13
+ if (e)
14
+ for (const id of h.back ? [e.to, e.from] : [e.from, e.to])
15
+ if (!at.has(id))
16
+ at.set(id, at.size);
17
+ }
18
+ for (const n of nodes(fig.layout))
19
+ if (!at.has(n.id))
20
+ at.set(n.id, at.size);
21
+ return at;
22
+ }
23
+ function level(items, dir, ctx) {
24
+ const n = items.length;
25
+ const unit = new Map();
26
+ items.forEach((it, k) => idsIn(it).forEach((id) => unit.set(id, k)));
27
+ const first = items.map((it) => Math.min(...idsIn(it).map((id) => ctx.at.get(id) ?? Infinity)));
28
+ const pairs = ctx.edges.flatMap((e) => {
29
+ const a = unit.get(e.from), b = unit.get(e.to);
30
+ return a == null || b == null || a === b ? [] : [[a, b, e]];
31
+ });
32
+ const keys = [...items.keys()].sort((p, q) => first[p] - first[q]);
33
+ const state = Array.from({ length: n }, () => 0);
34
+ const back = new Set();
35
+ const dfs = (u) => {
36
+ state[u] = 1;
37
+ const out = [...pairs.keys()].filter((i) => pairs[i][0] === u).sort((i, j) => first[pairs[i][1]] - first[pairs[j][1]]);
38
+ for (const i of out) {
39
+ const v = pairs[i][1];
40
+ if (state[v] === 1)
41
+ back.add(i);
42
+ else if (state[v] === 0)
43
+ dfs(v);
44
+ }
45
+ state[u] = 2;
46
+ };
47
+ for (const k of keys)
48
+ if (!state[k])
49
+ dfs(k);
50
+ const dag = pairs.map(([a, b], i) => (back.has(i) ? [b, a] : [a, b]));
51
+ const rank = Array.from({ length: n }, () => 0);
52
+ for (let pass = 0; pass < n; pass++)
53
+ for (const [a, b] of dag)
54
+ rank[b] = Math.max(rank[b], rank[a] + 1);
55
+ const ranks = Math.max(0, ...rank) + 1;
56
+ const layers = Array.from({ length: ranks }, () => []);
57
+ const layerOf = [...rank];
58
+ for (const k of keys)
59
+ layers[rank[k]].push(k);
60
+ const links = [];
61
+ const via = dag.map(() => []);
62
+ for (const [i, [a, b]] of dag.entries()) {
63
+ let prev = a;
64
+ for (let r = rank[a] + 1; r < rank[b]; r++) {
65
+ const v = layerOf.length;
66
+ layerOf.push(r);
67
+ layers[r].push(v);
68
+ via[i].push(v);
69
+ links.push([prev, v]);
70
+ prev = v;
71
+ }
72
+ links.push([prev, b]);
73
+ }
74
+ const pos = [];
75
+ const index = () => layers.forEach((l) => l.forEach((x, i) => (pos[x] = i)));
76
+ const crossings = () => {
77
+ index();
78
+ let c = 0;
79
+ for (let i = 0; i < links.length; i++)
80
+ for (let j = i + 1; j < links.length; j++) {
81
+ const [a, b] = links[i], [p, q] = links[j];
82
+ if (layerOf[a] === layerOf[p] && (pos[a] - pos[p]) * (pos[b] - pos[q]) < 0)
83
+ c++;
84
+ }
85
+ return c;
86
+ };
87
+ let best = layers.map((l) => [...l]);
88
+ let fewest = crossings();
89
+ for (let it = 0; it < SWEEPS; it++) {
90
+ const down = it % 2 === 0;
91
+ const order = [...layers.keys()].slice(1).map((r) => (down ? r : ranks - 1 - r));
92
+ for (const r of order) {
93
+ index();
94
+ const bary = (x) => {
95
+ const ns = links.filter(([a, b]) => (down ? b : a) === x).map(([a, b]) => pos[down ? a : b]);
96
+ return ns.length ? ns.reduce((s, v) => s + v, 0) / ns.length : pos[x];
97
+ };
98
+ const w = new Map(layers[r].map((x) => [x, bary(x)]));
99
+ layers[r].sort((x, y) => w.get(x) - w.get(y));
100
+ }
101
+ const c = crossings();
102
+ if (c < fewest)
103
+ [fewest, best] = [c, layers.map((l) => [...l])];
104
+ }
105
+ const cross = dir === 'row' ? 'column' : 'row';
106
+ const built = items.map((it) => (isGroup(it) ? { ...it, direction: dir, children: level(it.children.flatMap(flat), dir, ctx) } : it));
107
+ const beside = new Map();
108
+ const unitOf = (k) => {
109
+ const leaf = beside.get(k);
110
+ if (leaf == null)
111
+ return built[k];
112
+ const real = best.find((l) => l.includes(k)).filter((x) => x < n);
113
+ const pair = [built[k], built[leaf]];
114
+ return { direction: 'row', children: real.indexOf(k) < (real.length - 1) / 2 ? pair.reverse() : pair };
115
+ };
116
+ const rankRow = (l) => {
117
+ const real = l.filter((x) => x < n);
118
+ return real.length === 1 ? unitOf(real[0]) : { direction: cross, children: real.map(unitOf) };
119
+ };
120
+ if (dir === 'column') {
121
+ const fits = (l) => itemWidth(rankRow(l), ctx.carded, ctx.edges, NODE_MAX_W) <= PAGE - 2 * PAD;
122
+ for (const l of best) {
123
+ const leaves = l
124
+ .filter((k) => k < n && !dag.some(([a]) => a === k) && dag.filter(([, b]) => b === k).length === 1)
125
+ .sort((p, q) => first[q] - first[p]);
126
+ for (const k of leaves) {
127
+ if (fits(l))
128
+ break;
129
+ const src = dag.find(([, b]) => b === k)[0];
130
+ if (beside.has(src))
131
+ continue;
132
+ const home = best.find((m) => m.includes(src));
133
+ const i = l.indexOf(k);
134
+ l.splice(i, 1);
135
+ beside.set(src, k);
136
+ if (!fits(home)) {
137
+ beside.delete(src);
138
+ l.splice(i, 0, k);
139
+ }
140
+ }
141
+ }
142
+ }
143
+ const outer = (vs, side) => vs.every((v) => {
144
+ const l = best[layerOf[v]];
145
+ const k = l.indexOf(v);
146
+ return (side > 0 ? l.slice(k + 1) : l.slice(0, k)).every((x) => x >= n);
147
+ });
148
+ const hints = () => via.map((vs, i) => {
149
+ const [a, b] = dag[i];
150
+ const stacked = [a, b].every((k) => best[rank[k]].filter((x) => x < n).length === 1);
151
+ return !back.has(i) && (!vs.length || stacked) ? 0 : outer([a, ...vs, b], 1) ? 1 : outer([a, ...vs, b], -1) ? -1 : 0;
152
+ });
153
+ const turn = (o, u, w) => Math.sign((u[0] - o[0]) * (w[1] - o[1]) - (u[1] - o[1]) * (w[0] - o[0]));
154
+ const drawn = (ws) => {
155
+ const at = [];
156
+ best.forEach((l, r) => l.filter((x) => x < n).forEach((x, i, real) => (at[x] = [r, i - (real.length - 1) / 2])));
157
+ let c = 0;
158
+ for (let i = 0; i < dag.length; i++)
159
+ for (let j = i + 1; j < dag.length; j++) {
160
+ const [a, b] = dag[i], [p, q] = dag[j];
161
+ if (a === p || a === q || b === p || b === q || ![a, b, p, q].every((x) => at[x]))
162
+ continue;
163
+ if (ws[i] || ws[j])
164
+ c += +(ws[i] === ws[j] && rank[a] < rank[q] && rank[p] < rank[b]);
165
+ else
166
+ c += +(turn(at[a], at[b], at[p]) * turn(at[a], at[b], at[q]) < 0 && turn(at[p], at[q], at[a]) * turn(at[p], at[q], at[b]) < 0);
167
+ }
168
+ return c;
169
+ };
170
+ let ways = hints();
171
+ for (const i of ways.keys())
172
+ for (const j of ways.keys()) {
173
+ const wi = ways[i];
174
+ const [ai, bi] = dag[i], [aj, bj] = dag[j];
175
+ if (j <= i || !wi || wi !== ways[j] || rank[ai] >= rank[bj] || rank[aj] >= rank[bi])
176
+ continue;
177
+ for (const k of via[i].length <= via[j].length ? [i, j] : [j, i]) {
178
+ const units = [dag[k][0], ...via[k], dag[k][1]];
179
+ if (units.some((v) => !best[layerOf[v]].includes(v)))
180
+ continue;
181
+ const kept = best.map((l) => [...l]);
182
+ const was = drawn(ways);
183
+ for (const v of units) {
184
+ const l = best[layerOf[v]];
185
+ l.splice(l.indexOf(v), 1);
186
+ if (wi > 0)
187
+ l.unshift(v);
188
+ else
189
+ l.push(v);
190
+ }
191
+ const now = hints();
192
+ if (now[k] === -wi && now[i + j - k] === wi && drawn(now) < was) {
193
+ ways = now;
194
+ break;
195
+ }
196
+ best = kept;
197
+ }
198
+ }
199
+ ways.forEach((way, i) => {
200
+ if (way)
201
+ pairs[i][2].around = dir === 'column' ? (way > 0 ? 'right' : 'left') : way > 0 ? 'below' : 'above';
202
+ });
203
+ const kept = best.filter((l) => l.some((x) => x < n));
204
+ const rows = kept.map(rankRow);
205
+ if (dir === 'row' || kept.length * RANK_H <= TALL * PAGE)
206
+ return rows;
207
+ const out = [];
208
+ let run = [];
209
+ const lines = (per) => {
210
+ const k = Math.ceil(run.length / per), q = Math.ceil(run.length / k);
211
+ return Array.from({ length: k }, (_, i) => ({ direction: 'row', children: run.slice(i * q, (i + 1) * q) }));
212
+ };
213
+ const flush = () => {
214
+ const folded = run.length < 4 ? undefined : [4, 3].map(lines).find((ls) => ls.every((l) => itemWidth(l, ctx.carded, ctx.edges) <= PAGE - 2 * PAD));
215
+ out.push(...(folded ?? run));
216
+ run = [];
217
+ };
218
+ rows.forEach((r, i) => {
219
+ if (kept[i].length === 1 && !isGroup(r))
220
+ run.push(r);
221
+ else {
222
+ flush();
223
+ out.push(r);
224
+ }
225
+ });
226
+ flush();
227
+ return out;
228
+ }
229
+ const pruned = (g) => ({
230
+ ...g,
231
+ children: g.children.map((c) => (isGroup(c) ? pruned(c) : c)).filter((c) => !isGroup(c) || c.children.length),
232
+ });
233
+ const titled = (it) => isGroup(it) && (it.label != null || it.children.some(titled));
234
+ function smallFont(f, carded) {
235
+ const shown = (f.steps ?? []).flatMap((s) => s.flow.flatMap((b) => Object.values(toBeat(b).show ?? {})));
236
+ if (shown.some((c) => isRows(c) && c.some((r) => r.mono)))
237
+ return 10.5;
238
+ if (carded.size || f.edges.some((e) => e.label != null) || titled(f.layout))
239
+ return 11;
240
+ if ((f.steps ?? []).some((s) => s.flow.some((b) => toBeat(b).hops.some((h) => h.data != null))))
241
+ return 11.5;
242
+ if (nodes(f.layout).some((n) => n.sub != null))
243
+ return 12;
244
+ return f.steps?.length ? 13.5 : 14;
245
+ }
246
+ export const rowBreaks = new WeakSet();
247
+ function fold(f, carded) {
248
+ const room = (PAGE * smallFont(f, carded)) / MIN_TEXT - 2 * PAD;
249
+ if (f.rail || itemWidth(f.layout, carded, f.edges, fitCap(f, PAGE)) <= room)
250
+ return f;
251
+ const ranks = f.layout.children;
252
+ const n = ranks.length;
253
+ const unit = new Map(ranks.flatMap((r, k) => idsIn(r).map((id) => [id, k])));
254
+ const ends = f.edges.filter((e) => unit.has(e.from) && unit.has(e.to)).map((e) => [unit.get(e.from), unit.get(e.to)]);
255
+ const across = (c) => ends.filter(([a, b]) => a < c !== b < c).length;
256
+ const last = Math.min(n - 1, ...f.edges.filter((e) => e.around).flatMap((e) => [e.from, e.to].map((id) => unit.get(id) ?? n)));
257
+ const better = (p, q) => p[0] - q[0] || p[1] - q[1] || p[2] - q[2];
258
+ const plans = [{ cost: [0, 0, 0], from: -1 }];
259
+ for (let j = 1; j <= n; j++)
260
+ for (let i = j - 1; i >= 0; i--) {
261
+ const w = itemWidth({ direction: 'row', children: ranks.slice(i, j) }, carded, f.edges);
262
+ if (w > room)
263
+ break;
264
+ const prev = plans[i];
265
+ if (!prev || (j === n && i > last))
266
+ continue;
267
+ const cost = [prev.cost[0] + 1, prev.cost[1] + (i ? across(i) : 0), Math.max(prev.cost[2], w)];
268
+ if (!plans[j] || better(cost, plans[j].cost) < 0)
269
+ plans[j] = { cost, from: i };
270
+ }
271
+ if (!plans[n] || plans[n].cost[0] < 2)
272
+ return f;
273
+ const cuts = [];
274
+ for (let j = n; j > 0; j = plans[j].from)
275
+ cuts.unshift(plans[j].from);
276
+ const rows = cuts.map((c, k) => ranks.slice(c, cuts[k + 1] ?? n));
277
+ for (const e of f.edges)
278
+ if (cuts.some((c) => c && unit.get(e.from) < c && unit.get(e.to) >= c))
279
+ rowBreaks.add(e);
280
+ const children = rows.map((rs) => (rs.length === 1 ? rs[0] : { direction: 'row', children: rs }));
281
+ return { ...f, layout: { direction: 'column', children } };
282
+ }
283
+ export function autoLayout(spec) {
284
+ if (spec.lanes || spec.timeline)
285
+ return spec;
286
+ const fig = { ...spec, layout: pruned(spec.layout) };
287
+ if (!fig.layout.auto)
288
+ return fig;
289
+ const carded = new Set((fig.steps ?? []).flatMap((s) => s.flow.flatMap((b) => Object.keys(toBeat(b).show ?? {}))));
290
+ const at = seenOrder(fig);
291
+ const items = fig.layout.children.flatMap(flat);
292
+ const make = (direction) => {
293
+ const edges = fig.edges.map((e) => ({ ...e, around: undefined }));
294
+ return { ...fig, edges, layout: { direction, children: level(items, direction, { edges, at, carded }) } };
295
+ };
296
+ if (fig.layout.direction === 'row')
297
+ return fold(make('row'), carded);
298
+ if (fig.layout.direction)
299
+ return make(fig.layout.direction);
300
+ const lr = make('row');
301
+ return itemWidth(lr.layout, carded, lr.edges, fitCap(lr, PAGE)) + 2 * PAD <= PAGE ? lr : make('column');
302
+ }
package/dist/browser.js CHANGED
@@ -36,6 +36,8 @@ export function findBrowser(o) {
36
36
  }
37
37
  return { path: null, checked };
38
38
  }
39
+ // Measured: 2 parallel starts on a loaded 4-CPU Linux runner took over 30 s.
40
+ const START_MS = 120_000;
39
41
  export function launch(path, profile, timeoutMs = 30_000) {
40
42
  const args = [
41
43
  '--headless=new',
@@ -147,7 +149,7 @@ export function launch(path, profile, timeoutMs = 30_000) {
147
149
  killGroup();
148
150
  await closed;
149
151
  };
150
- return cdp.send('Browser.getVersion').then(() => ({ cdp, close }), async (e) => {
152
+ return cdp.send('Browser.getVersion', {}, undefined, START_MS).then(() => ({ cdp, close }), async (e) => {
151
153
  await close();
152
154
  throw e;
153
155
  });
package/dist/check.js CHANGED
@@ -1,6 +1,6 @@
1
1
  import { DARK, LIGHT, ON_ACCENT, TONES, edgeId, isGroup, dayOf, isLanesLayout, lanePlan, validAt, laneColumns, nodes, str, toBeat, } from './model.js';
2
2
  import { layoutRail } from './rail.js';
3
- import { crosses, hit as curveHit } from './geometry.js';
3
+ import { crosses, samples } from './geometry.js';
4
4
  import { textWidth } from './text.js';
5
5
  import { owners, parseSource } from './source.js';
6
6
  const err = (rule, ids, message) => ({ rule, severity: 'error', ids, message });
@@ -75,10 +75,22 @@ export function checkSpec(fig) {
75
75
  twice('edges', edges);
76
76
  const known = new Set([...boxIds, ...groups]);
77
77
  const knownEdges = new Set(edges);
78
+ const empty = new Set();
79
+ const walk = (g) => {
80
+ for (const c of g.children)
81
+ if (isGroup(c)) {
82
+ if (c.id != null && !nodes(c).length)
83
+ empty.add(c.id);
84
+ walk(c);
85
+ }
86
+ };
87
+ walk(fig.layout);
78
88
  fig.edges.forEach((e, i) => {
79
89
  for (const end of [e.from, e.to])
80
90
  if (!known.has(end))
81
91
  out.push(err('unknown-id', [edges[i], end], `edge "${edges[i]}" names box "${end}", which does not exist`));
92
+ else if (empty.has(end))
93
+ out.push(err('unknown-id', [edges[i], end], `edge "${edges[i]}" ends at group "${end}", which has no boxes`));
82
94
  });
83
95
  (fig.steps ?? []).forEach((s, si) => {
84
96
  const where = `step ${si + 1} ("${str(s.label)}")`;
@@ -96,6 +108,17 @@ export function checkSpec(fig) {
96
108
  out.push(err('unknown-id', [id], `${where}: show, light or focus names box "${id}", which does not exist`));
97
109
  }
98
110
  });
111
+ const form = fig.timeline ? 'timeline' : fig.lanes ? 'lanes' : null;
112
+ if (fig.layout.auto && form)
113
+ out.push(warn('auto-ignored', [], `the figure sets ${form}; ${form === 'lanes' ? 'lanes place the boxes and ignore' : 'the timeline places the boxes and ignores'} layout.auto`));
114
+ if (fig.layout.auto && !form)
115
+ fig.edges.forEach((e, i) => {
116
+ if (e.around)
117
+ out.push(warn('auto-ignored', [edges[i]], `auto routes edge "${edges[i]}"; remove around`));
118
+ });
119
+ const nestedAuto = (g) => g.children.some((c) => isGroup(c) && (c.auto || nestedAuto(c)));
120
+ if (nestedAuto(fig.layout))
121
+ out.push(warn('auto-ignored', [], 'auto works on the root layout only'));
99
122
  const used = new Set((fig.steps ?? []).flatMap((s) => s.flow.flatMap((b) => toBeat(b).hops.map((h) => h.edge))));
100
123
  fig.edges.forEach((e, i) => {
101
124
  if (e.quiet && !used.has(edges[i]))
@@ -107,6 +130,9 @@ export function checkSpec(fig) {
107
130
  if (via != null && (typeof via !== 'string' || !/^\S+$/.test(via)))
108
131
  out.push(warn('bad-source', [], `${who}: via ${JSON.stringify(via)} is not a string of one word`));
109
132
  }
133
+ for (const n of nodes(fig.layout))
134
+ if (n.detail != null && !(typeof n.detail === 'string' && n.detail.endsWith('.svg') && !/^([\\/]|[A-Za-z]+:)/.test(n.detail)))
135
+ out.push(warn('bad-detail', [n.id], `box "${n.id}": detail ${JSON.stringify(n.detail)} is not a relative path to an .svg file`));
110
136
  const marks = nodes(fig.layout).map((n) => n.mark);
111
137
  const starts = marks.filter((m) => m === 'start').length;
112
138
  const ends = marks.filter((m) => m === 'end').length;
@@ -130,7 +156,10 @@ export function checkSpec(fig) {
130
156
  if (f != null && t != null && t < f)
131
157
  out.push(err('bad-date', [n.id], `box "${n.id}": to ${n.to} is before from ${n.from}`));
132
158
  }
133
- const span = new Map(nodes(fig.layout).map((n) => [n.id, [n.from && dayOf(n.from), (n.to && dayOf(n.to)) ?? (n.from && dayOf(n.from))]]));
159
+ const span = new Map(nodes(fig.layout).map((n) => {
160
+ const f = n.from == null ? null : dayOf(n.from);
161
+ return [n.id, [f, n.to ? dayOf(n.to) : f == null ? null : f - 1]];
162
+ }));
134
163
  for (const e of fig.edges) {
135
164
  const start = span.get(e.to)?.[0];
136
165
  const end = span.get(e.from)?.[1];
@@ -164,16 +193,22 @@ export function checkSpec(fig) {
164
193
  out.push(...plainText(fig));
165
194
  return out;
166
195
  }
167
- const at = ([p0, p1, p2, p3], t) => {
168
- const u = 1 - t;
169
- const [a, b, c, d] = [u * u * u, 3 * u * u * t, 3 * u * t * t, t * t * t];
170
- return { x: a * p0.x + b * p1.x + c * p2.x + d * p3.x, y: a * p0.y + b * p1.y + c * p2.y + d * p3.y };
171
- };
172
196
  const lerp = (p, q, t) => ({ x: p.x + (q.x - p.x) * t, y: p.y + (q.y - p.y) * t });
197
+ const path = (e) => {
198
+ const line = e.elbow ?? e.pts;
199
+ if (!line)
200
+ return samples(e.curve);
201
+ return line.slice(1).flatMap((q, k) => {
202
+ const n = Math.max(16, Math.ceil(Math.hypot(q.x - line[k].x, q.y - line[k].y) / 4));
203
+ return Array.from({ length: n + 1 }, (_, i) => lerp(line[k], q, i / n));
204
+ });
205
+ };
173
206
  // Margins stop edges along a border and pills edge to edge from counting as touches.
174
207
  const inside = (p, r, pad) => p.x > r.x + pad && p.x < r.x + r.w - pad && p.y > r.y + pad && p.y < r.y + r.h - pad;
175
208
  const overlap = (a, b, pad) => a.x + pad < b.x + b.w - pad && b.x + pad < a.x + a.w - pad && a.y + pad < b.y + b.h - pad && b.y + pad < a.y + a.h - pad;
176
209
  const px = (n) => Math.round(n * 10) / 10;
210
+ // Measured corner touches go 3.5 px deep; real crossings go 24 px or more.
211
+ const TOUCH = 6;
177
212
  export function checkScene(scene, { width = 830, minText = 10 } = {}) {
178
213
  const out = [];
179
214
  for (const b of scene.boxes)
@@ -185,29 +220,31 @@ export function checkScene(scene, { width = 830, minText = 10 } = {}) {
185
220
  for (const e of scene.edges) {
186
221
  if (e.behind && !e.elbow)
187
222
  continue;
188
- const line = e.elbow ?? e.pts;
189
- const pts = line?.slice(1).flatMap((q, k) => Array.from({ length: 17 }, (_, i) => lerp(line[k], q, i / 16)));
223
+ const pts = path(e);
190
224
  for (const b of scene.boxes)
191
- if (b.id !== e.from && b.id !== e.to && (pts ? pts.some((p) => inside(p, b.rect, 2)) : curveHit(e.curve, [b.rect], -2).length))
192
- out.push(err('edge-crosses-box', [e.id, b.id], `edge "${e.id}" passes through box "${b.id}"`));
225
+ if (b.id !== e.from && b.id !== e.to && pts.some((p) => inside(p, b.rect, TOUCH)))
226
+ out.push(err('edge-crosses-box', [e.id, b.id], `edge "${e.id}" passes through box "${b.id}": move a box, or set "auto": true on the root layout`));
227
+ }
228
+ for (const e of scene.edges) {
229
+ const pts = path(e);
230
+ const drawn = pts.slice(1).reduce((s, q, k) => s + Math.hypot(q.x - pts[k].x, q.y - pts[k].y), 0);
231
+ const ratio = drawn / Math.max(1, Math.hypot(pts.at(-1).x - pts[0].x, pts.at(-1).y - pts[0].y));
232
+ // The README figures have a good edge at ratio 1.77 and 425 px.
233
+ if (ratio >= 1.6 && drawn >= 600)
234
+ out.push(warn('long-edge', [e.id], `edge "${e.id}" is ${px(ratio)} times the straight distance between its ends: put its ends in one row or column, route it with around, or set "auto": true on the root layout`));
193
235
  }
194
236
  const pills = scene.edges.filter((e) => e.pts && e.label);
195
237
  const under = new Set();
196
238
  for (const e of scene.edges) {
197
239
  if (e.behind)
198
240
  continue;
199
- const pts = e.pts
200
- ? e.pts.slice(1).flatMap((q, k) => Array.from({ length: 17 }, (_, i) => lerp(e.pts[k], q, i / 16)))
201
- : Array.from({ length: 33 }, (_, i) => at(e.curve, i / 32));
241
+ const pts = path(e);
202
242
  for (const f of pills)
203
243
  if (f.id !== e.id && !under.has(e.id + ' ' + f.id) && pts.some((p) => inside(p, f.label, 2))) {
204
244
  under.add(e.id + ' ' + f.id);
205
245
  out.push(err('label-overlap', [e.id, f.id], `edge "${e.id}" passes under the pill of edge "${f.id}"`));
206
246
  }
207
247
  }
208
- const path = (e) => e.pts
209
- ? e.pts.slice(1).flatMap((q, k) => Array.from({ length: 17 }, (_, i) => lerp(e.pts[k], q, i / 16)))
210
- : Array.from({ length: 33 }, (_, i) => at(e.curve, i / 32));
211
248
  const stubs = scene.edges.filter((e) => e.pts && e.pts.length > 1);
212
249
  const hit = new Set();
213
250
  for (const f of stubs)