flowfig 0.9.0 → 0.11.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/auto.js ADDED
@@ -0,0 +1,397 @@
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
+ export const rowBreaks = new WeakSet();
7
+ const less = (p, q) => {
8
+ const k = p.findIndex((v, i) => v !== q[i]);
9
+ return k >= 0 && p[k] < q[k];
10
+ };
11
+ function arcFold(run, long, pos, carded, edges) {
12
+ const m = run.length;
13
+ const spans = long.map((e) => [pos.get(e.from), pos.get(e.to)].sort((p, q) => p - q));
14
+ const wide = new Map();
15
+ const width = (i, j) => wide.get(i * m + j) ?? wide.set(i * m + j, itemWidth({ direction: 'row', children: run.slice(i, j) }, carded, edges)).get(i * m + j);
16
+ let pick;
17
+ for (let code = 0; long.length <= 6 && code < 3 ** long.length; code++) {
18
+ const sides = spans.map((_, k) => Math.floor(code / 3 ** k) % 3);
19
+ const need = new Set(), full = new Set(), ban = new Set();
20
+ spans.forEach(([a, b], k) => {
21
+ if (!sides[k])
22
+ for (let c = a + 1; c <= b; c++)
23
+ ban.add(c);
24
+ else
25
+ for (const c of sides[k] === 1 ? [a + 1, b + 1] : [a, b]) {
26
+ need.add(c);
27
+ if (sides[k] === 1)
28
+ full.add(c);
29
+ }
30
+ });
31
+ const plans = [{ cost: [0, 0], from: -1 }];
32
+ for (let j = 1; j <= m; j++)
33
+ for (let i = j - 1; i >= Math.max(0, j - 4); i--) {
34
+ if ((i < j - 1 && need.has(i + 1)) || width(i, j) > PAGE - 2 * PAD)
35
+ break;
36
+ const prev = plans[i];
37
+ if (!prev || (i && ban.has(i)) || (full.has(j) && j - i < 4))
38
+ continue;
39
+ const cost = [prev.cost[0] + 1, Math.max(prev.cost[1], width(i, j))];
40
+ if (!plans[j] || less(cost, plans[j].cost))
41
+ plans[j] = { cost, from: i };
42
+ }
43
+ if (!plans[m] || (pick && !less(plans[m].cost, pick.cost)))
44
+ continue;
45
+ const cuts = [];
46
+ for (let j = m; j > 0; j = plans[j].from)
47
+ cuts.unshift(plans[j].from);
48
+ const line = (k) => cuts.findLastIndex((c) => c <= k);
49
+ const rowW = (r) => width(cuts[r], cuts[r + 1] ?? m);
50
+ const clash = spans.some(([a, b], k) => {
51
+ const [ra, rb] = [line(a), line(b)];
52
+ const inner = Array.from({ length: rb - ra + 1 }, (_, r) => rowW(ra + r));
53
+ return ((!sides[k] && ra > 0 && ra < cuts.length - 1) ||
54
+ (sides[k] === 1 && Math.min(rowW(ra), rowW(rb)) < Math.max(...inner)) ||
55
+ spans.some(([p, q], l) => l > k && sides[k] && sides[k] === sides[l] && a < q && p < b));
56
+ });
57
+ if (!clash)
58
+ pick = { cost: plans[m].cost, cuts, sides };
59
+ }
60
+ if (!pick)
61
+ return undefined;
62
+ const top = pick.cuts[1] ?? m;
63
+ long.forEach((e, k) => (e.around = [pos.get(e.from) < top ? 'above' : 'below', 'right', 'left'][pick.sides[k]]));
64
+ const rows = pick.cuts.map((c, k) => ({ direction: 'row', children: run.slice(c, pick.cuts[k + 1] ?? m) }));
65
+ const line = (id) => rows.findIndex((l) => idsIn(l).includes(id));
66
+ for (const e of edges)
67
+ if (!long.includes(e) && line(e.from) >= 0 && line(e.from) < line(e.to))
68
+ rowBreaks.add(e);
69
+ return { direction: 'column', align: 'start', children: rows };
70
+ }
71
+ function seenOrder(fig) {
72
+ const at = new Map();
73
+ const byId = new Map(fig.edges.map((e) => [edgeId(e), e]));
74
+ for (const s of fig.steps ?? [])
75
+ for (const b of s.flow)
76
+ for (const h of toBeat(b).hops) {
77
+ const e = byId.get(h.edge);
78
+ if (e)
79
+ for (const id of h.back ? [e.to, e.from] : [e.from, e.to])
80
+ if (!at.has(id))
81
+ at.set(id, at.size);
82
+ }
83
+ for (const n of nodes(fig.layout))
84
+ if (!at.has(n.id))
85
+ at.set(n.id, at.size);
86
+ return at;
87
+ }
88
+ function level(items, dir, ctx) {
89
+ const n = items.length;
90
+ const unit = new Map();
91
+ items.forEach((it, k) => idsIn(it).forEach((id) => unit.set(id, k)));
92
+ const first = items.map((it) => Math.min(...idsIn(it).map((id) => ctx.at.get(id) ?? Infinity)));
93
+ const pairs = ctx.edges.flatMap((e) => {
94
+ const a = unit.get(e.from), b = unit.get(e.to);
95
+ return a == null || b == null || a === b ? [] : [[a, b, e]];
96
+ });
97
+ const keys = [...items.keys()].sort((p, q) => first[p] - first[q]);
98
+ const state = Array.from({ length: n }, () => 0);
99
+ const back = new Set();
100
+ const dfs = (u) => {
101
+ state[u] = 1;
102
+ const out = [...pairs.keys()].filter((i) => pairs[i][0] === u).sort((i, j) => first[pairs[i][1]] - first[pairs[j][1]]);
103
+ for (const i of out) {
104
+ const v = pairs[i][1];
105
+ if (state[v] === 1)
106
+ back.add(i);
107
+ else if (state[v] === 0)
108
+ dfs(v);
109
+ }
110
+ state[u] = 2;
111
+ };
112
+ for (const k of keys)
113
+ if (!state[k])
114
+ dfs(k);
115
+ const dag = pairs.map(([a, b], i) => (back.has(i) ? [b, a] : [a, b]));
116
+ const rank = Array.from({ length: n }, () => 0);
117
+ for (let pass = 0; pass < n; pass++)
118
+ for (const [a, b] of dag)
119
+ rank[b] = Math.max(rank[b], rank[a] + 1);
120
+ const ranks = Math.max(0, ...rank) + 1;
121
+ const layers = Array.from({ length: ranks }, () => []);
122
+ const layerOf = [...rank];
123
+ for (const k of keys)
124
+ layers[rank[k]].push(k);
125
+ const links = [];
126
+ const via = dag.map(() => []);
127
+ for (const [i, [a, b]] of dag.entries()) {
128
+ let prev = a;
129
+ for (let r = rank[a] + 1; r < rank[b]; r++) {
130
+ const v = layerOf.length;
131
+ layerOf.push(r);
132
+ layers[r].push(v);
133
+ via[i].push(v);
134
+ links.push([prev, v]);
135
+ prev = v;
136
+ }
137
+ links.push([prev, b]);
138
+ }
139
+ const pos = [];
140
+ const index = () => layers.forEach((l) => l.forEach((x, i) => (pos[x] = i)));
141
+ const crossings = () => {
142
+ index();
143
+ let c = 0;
144
+ for (let i = 0; i < links.length; i++)
145
+ for (let j = i + 1; j < links.length; j++) {
146
+ const [a, b] = links[i], [p, q] = links[j];
147
+ if (layerOf[a] === layerOf[p] && (pos[a] - pos[p]) * (pos[b] - pos[q]) < 0)
148
+ c++;
149
+ }
150
+ return c;
151
+ };
152
+ let best = layers.map((l) => [...l]);
153
+ let fewest = crossings();
154
+ for (let it = 0; it < SWEEPS; it++) {
155
+ const down = it % 2 === 0;
156
+ const order = [...layers.keys()].slice(1).map((r) => (down ? r : ranks - 1 - r));
157
+ for (const r of order) {
158
+ index();
159
+ const bary = (x) => {
160
+ const ns = links.filter(([a, b]) => (down ? b : a) === x).map(([a, b]) => pos[down ? a : b]);
161
+ return ns.length ? ns.reduce((s, v) => s + v, 0) / ns.length : pos[x];
162
+ };
163
+ const w = new Map(layers[r].map((x) => [x, bary(x)]));
164
+ layers[r].sort((x, y) => w.get(x) - w.get(y));
165
+ }
166
+ const c = crossings();
167
+ if (c < fewest)
168
+ [fewest, best] = [c, layers.map((l) => [...l])];
169
+ }
170
+ const cross = dir === 'row' ? 'column' : 'row';
171
+ const built = items.map((it) => (isGroup(it) ? { ...it, direction: dir, children: level(it.children.flatMap(flat), dir, ctx) } : it));
172
+ const beside = new Map();
173
+ const unitOf = (k) => {
174
+ const leaf = beside.get(k);
175
+ if (leaf == null)
176
+ return built[k];
177
+ const real = best.find((l) => l.includes(k)).filter((x) => x < n);
178
+ const pair = [built[k], built[leaf]];
179
+ return { direction: 'row', children: real.indexOf(k) < (real.length - 1) / 2 ? pair.reverse() : pair };
180
+ };
181
+ const rankRow = (l) => {
182
+ const real = l.filter((x) => x < n);
183
+ return real.length === 1 ? unitOf(real[0]) : { direction: cross, children: real.map(unitOf) };
184
+ };
185
+ if (dir === 'column') {
186
+ const fits = (l) => itemWidth(rankRow(l), ctx.carded, ctx.edges, NODE_MAX_W) <= PAGE - 2 * PAD;
187
+ for (const l of best) {
188
+ const leaves = l
189
+ .filter((k) => k < n && !dag.some(([a]) => a === k) && dag.filter(([, b]) => b === k).length === 1)
190
+ .sort((p, q) => first[q] - first[p]);
191
+ for (const k of leaves) {
192
+ if (fits(l))
193
+ break;
194
+ const src = dag.find(([, b]) => b === k)[0];
195
+ if (beside.has(src))
196
+ continue;
197
+ const home = best.find((m) => m.includes(src));
198
+ const i = l.indexOf(k);
199
+ l.splice(i, 1);
200
+ beside.set(src, k);
201
+ if (!fits(home)) {
202
+ beside.delete(src);
203
+ l.splice(i, 0, k);
204
+ }
205
+ }
206
+ }
207
+ }
208
+ const outer = (vs, side) => vs.every((v) => {
209
+ const l = best[layerOf[v]];
210
+ const k = l.indexOf(v);
211
+ return (side > 0 ? l.slice(k + 1) : l.slice(0, k)).every((x) => x >= n);
212
+ });
213
+ const hints = () => via.map((vs, i) => {
214
+ const [a, b] = dag[i];
215
+ const stacked = [a, b].every((k) => best[rank[k]].filter((x) => x < n).length === 1);
216
+ return !back.has(i) && (!vs.length || stacked) ? 0 : outer([a, ...vs, b], 1) ? 1 : outer([a, ...vs, b], -1) ? -1 : 0;
217
+ });
218
+ const turn = (o, u, w) => Math.sign((u[0] - o[0]) * (w[1] - o[1]) - (u[1] - o[1]) * (w[0] - o[0]));
219
+ const drawn = (ws) => {
220
+ const at = [];
221
+ best.forEach((l, r) => l.filter((x) => x < n).forEach((x, i, real) => (at[x] = [r, i - (real.length - 1) / 2])));
222
+ let c = 0;
223
+ for (let i = 0; i < dag.length; i++)
224
+ for (let j = i + 1; j < dag.length; j++) {
225
+ const [a, b] = dag[i], [p, q] = dag[j];
226
+ if (a === p || a === q || b === p || b === q || ![a, b, p, q].every((x) => at[x]))
227
+ continue;
228
+ if (ws[i] || ws[j])
229
+ c += +(ws[i] === ws[j] && rank[a] < rank[q] && rank[p] < rank[b]);
230
+ else
231
+ 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);
232
+ }
233
+ return c;
234
+ };
235
+ let ways = hints();
236
+ for (const i of ways.keys())
237
+ for (const j of ways.keys()) {
238
+ const wi = ways[i];
239
+ const [ai, bi] = dag[i], [aj, bj] = dag[j];
240
+ if (j <= i || !wi || wi !== ways[j] || rank[ai] >= rank[bj] || rank[aj] >= rank[bi])
241
+ continue;
242
+ for (const k of via[i].length <= via[j].length ? [i, j] : [j, i]) {
243
+ const units = [dag[k][0], ...via[k], dag[k][1]];
244
+ if (units.some((v) => !best[layerOf[v]].includes(v)))
245
+ continue;
246
+ const kept = best.map((l) => [...l]);
247
+ const was = drawn(ways);
248
+ for (const v of units) {
249
+ const l = best[layerOf[v]];
250
+ l.splice(l.indexOf(v), 1);
251
+ if (wi > 0)
252
+ l.unshift(v);
253
+ else
254
+ l.push(v);
255
+ }
256
+ const now = hints();
257
+ if (now[k] === -wi && now[i + j - k] === wi && drawn(now) < was) {
258
+ ways = now;
259
+ break;
260
+ }
261
+ best = kept;
262
+ }
263
+ }
264
+ ways.forEach((way, i) => {
265
+ if (way)
266
+ pairs[i][2].around = dir === 'column' ? (way > 0 ? 'right' : 'left') : way > 0 ? 'below' : 'above';
267
+ });
268
+ const kept = best.filter((l) => l.some((x) => x < n));
269
+ const rows = kept.map(rankRow);
270
+ if (dir === 'row' || kept.length * RANK_H <= TALL * PAGE)
271
+ return rows;
272
+ const out = [];
273
+ let run = [];
274
+ let runAt = [];
275
+ const owner = new Map(via.flatMap((vs, i) => vs.map((v) => [v, pairs[i][2]])));
276
+ const lines = (part, per) => {
277
+ const k = Math.ceil(part.length / per), q = Math.ceil(part.length / k);
278
+ return Array.from({ length: k }, (_, i) => ({ direction: 'row', children: part.slice(i * q, (i + 1) * q) }));
279
+ };
280
+ const plain = (part) => {
281
+ const folded = part.length < 4
282
+ ? undefined
283
+ : [4, 3].map((per) => lines(part, per)).find((ls) => ls.every((l) => itemWidth(l, ctx.carded, ctx.edges) <= PAGE - 2 * PAD));
284
+ out.push(...(folded ?? part));
285
+ };
286
+ const flush = () => {
287
+ const pos = new Map(run.map((r, k) => [r.id, k]));
288
+ const long = ctx.edges.filter((e) => pos.has(e.from) && pos.has(e.to) && Math.abs(pos.get(e.from) - pos.get(e.to)) > 1);
289
+ const local = runAt.every((i) => kept[i].every((x) => x < n || long.includes(owner.get(x))));
290
+ const folded = run.length >= 4 && long.length && local ? arcFold(run, long, pos, ctx.carded, ctx.edges) : undefined;
291
+ if (folded)
292
+ out.push(folded);
293
+ else {
294
+ let part = [];
295
+ run.forEach((r, k) => {
296
+ if (kept[runAt[k]].length === 1)
297
+ part.push(r);
298
+ else {
299
+ plain(part);
300
+ part = [];
301
+ out.push(r);
302
+ }
303
+ });
304
+ plain(part);
305
+ }
306
+ run = [];
307
+ runAt = [];
308
+ };
309
+ rows.forEach((r, i) => {
310
+ if (kept[i].filter((x) => x < n).length === 1 && !isGroup(r)) {
311
+ run.push(r);
312
+ runAt.push(i);
313
+ }
314
+ else {
315
+ flush();
316
+ out.push(r);
317
+ }
318
+ });
319
+ flush();
320
+ return out;
321
+ }
322
+ const pruned = (g) => ({
323
+ ...g,
324
+ children: g.children.map((c) => (isGroup(c) ? pruned(c) : c)).filter((c) => !isGroup(c) || c.children.length),
325
+ });
326
+ const titled = (it) => isGroup(it) && (it.label != null || it.children.some(titled));
327
+ function smallFont(f, carded) {
328
+ const shown = (f.steps ?? []).flatMap((s) => s.flow.flatMap((b) => Object.values(toBeat(b).show ?? {})));
329
+ if (shown.some((c) => isRows(c) && c.some((r) => r.mono)))
330
+ return 10.5;
331
+ if (carded.size || f.edges.some((e) => e.label != null) || titled(f.layout))
332
+ return 11;
333
+ if ((f.steps ?? []).some((s) => s.flow.some((b) => toBeat(b).hops.some((h) => h.data != null))))
334
+ return 11.5;
335
+ if (nodes(f.layout).some((n) => n.sub != null))
336
+ return 12;
337
+ return f.steps?.length ? 13.5 : 14;
338
+ }
339
+ function fold(f, carded) {
340
+ const room = (PAGE * smallFont(f, carded)) / MIN_TEXT - 2 * PAD;
341
+ if (f.rail || itemWidth(f.layout, carded, f.edges, fitCap(f, PAGE)) <= room)
342
+ return f;
343
+ const ranks = f.layout.children;
344
+ const n = ranks.length;
345
+ const unit = new Map(ranks.flatMap((r, k) => idsIn(r).map((id) => [id, k])));
346
+ const ends = f.edges.filter((e) => unit.has(e.from) && unit.has(e.to)).map((e) => [unit.get(e.from), unit.get(e.to)]);
347
+ const across = (c) => ends.filter(([a, b]) => a < c !== b < c).length;
348
+ const last = Math.min(n - 1, ...f.edges.filter((e) => e.around).flatMap((e) => [e.from, e.to].map((id) => unit.get(id) ?? n)));
349
+ const better = (p, q) => p[0] - q[0] || p[1] - q[1] || p[2] - q[2];
350
+ const plans = [{ cost: [0, 0, 0], from: -1 }];
351
+ for (let j = 1; j <= n; j++)
352
+ for (let i = j - 1; i >= 0; i--) {
353
+ const w = itemWidth({ direction: 'row', children: ranks.slice(i, j) }, carded, f.edges);
354
+ if (w > room)
355
+ break;
356
+ const prev = plans[i];
357
+ if (!prev || (j === n && i > last))
358
+ continue;
359
+ const cost = [prev.cost[0] + 1, prev.cost[1] + (i ? across(i) : 0), Math.max(prev.cost[2], w)];
360
+ if (!plans[j] || better(cost, plans[j].cost) < 0)
361
+ plans[j] = { cost, from: i };
362
+ }
363
+ if (!plans[n] || plans[n].cost[0] < 2) {
364
+ const long = f.edges.filter((e) => unit.has(e.from) && unit.has(e.to) && (e.around || Math.abs(unit.get(e.from) - unit.get(e.to)) > 1));
365
+ const layout = n >= 4 && long.some((e) => e.around) && !ranks.some(isGroup) ? arcFold(ranks, long, unit, carded, f.edges) : undefined;
366
+ return layout ? { ...f, layout } : f;
367
+ }
368
+ const cuts = [];
369
+ for (let j = n; j > 0; j = plans[j].from)
370
+ cuts.unshift(plans[j].from);
371
+ const rows = cuts.map((c, k) => ranks.slice(c, cuts[k + 1] ?? n));
372
+ for (const e of f.edges)
373
+ if (cuts.some((c) => c && unit.get(e.from) < c && unit.get(e.to) >= c))
374
+ rowBreaks.add(e);
375
+ const children = rows.map((rs) => (rs.length === 1 ? rs[0] : { direction: 'row', children: rs }));
376
+ return { ...f, layout: { direction: 'column', children } };
377
+ }
378
+ export function autoLayout(spec) {
379
+ if (spec.lanes || spec.timeline)
380
+ return spec;
381
+ const fig = { ...spec, layout: pruned(spec.layout) };
382
+ if (!fig.layout.auto)
383
+ return fig;
384
+ const carded = new Set((fig.steps ?? []).flatMap((s) => s.flow.flatMap((b) => Object.keys(toBeat(b).show ?? {}))));
385
+ const at = seenOrder(fig);
386
+ const items = fig.layout.children.flatMap(flat);
387
+ const make = (direction) => {
388
+ const edges = fig.edges.map((e) => ({ ...e, around: undefined }));
389
+ return { ...fig, edges, layout: { direction, children: level(items, direction, { edges, at, carded }) } };
390
+ };
391
+ if (fig.layout.direction === 'row')
392
+ return fold(make('row'), carded);
393
+ if (fig.layout.direction)
394
+ return make(fig.layout.direction);
395
+ const lr = make('row');
396
+ return itemWidth(lr.layout, carded, lr.edges, fitCap(lr, PAGE)) + 2 * PAD <= PAGE ? lr : make('column');
397
+ }
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.d.ts CHANGED
@@ -6,8 +6,9 @@ export declare function checkSpec(fig: FlowProps): Finding[];
6
6
  export type CheckOptions = {
7
7
  width?: number;
8
8
  minText?: number;
9
+ auto?: boolean;
9
10
  };
10
- export declare function checkScene(scene: Scene, { width, minText }?: CheckOptions): Finding[];
11
+ export declare function checkScene(scene: Scene, { width, minText, auto }?: CheckOptions): Finding[];
11
12
  /** The WCAG contrast ratio of two colors, rounded to 2 places; null if either cannot be read. */
12
13
  export declare function contrast(a: string, b: string): number | null;
13
14
  /** Text the reader cannot read: each text and background pair, in light, dark and any custom theme, needs 4.5:1 (WCAG AA). */
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,17 +193,23 @@ 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;
177
- export function checkScene(scene, { width = 830, minText = 10 } = {}) {
210
+ // Measured corner touches go 3.5 px deep; real crossings go 24 px or more.
211
+ const TOUCH = 6;
212
+ export function checkScene(scene, { width = 830, minText = 10, auto } = {}) {
178
213
  const out = [];
179
214
  for (const b of scene.boxes)
180
215
  for (const t of b.texts) {
@@ -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${auto ? '' : ', 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${auto ? ', or route it with around' : ', 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)
@@ -346,5 +383,10 @@ export function checkRendered(fig, opts, scene) {
346
383
  ids: [id],
347
384
  message: `edge "${id}" ends at a lane that no block on its side shows; the edge uses the nearest block`,
348
385
  }));
349
- return [...checkSpec(fig), ...checkScene(scene, opts), ...lost, ...checkTheme({ ...fig.theme, ...opts.theme })];
386
+ return [
387
+ ...checkSpec(fig),
388
+ ...checkScene(scene, { ...opts, auto: opts.auto ?? fig.layout.auto }),
389
+ ...lost,
390
+ ...checkTheme({ ...fig.theme, ...opts.theme }),
391
+ ];
350
392
  }
package/dist/cli.js CHANGED
@@ -18,30 +18,36 @@ import { GUIDE, TOPICS } from './guide.js';
18
18
  import { runDraw } from './draw.js';
19
19
  import { runInit } from './init.js';
20
20
  import { serve } from './mcp.js';
21
+ import { runAtlas } from './atlas.js';
22
+ import { runFromMermaid } from './mermaid.js';
21
23
  import { openedLine, openSvg, runOpen } from './open.js';
22
24
  import { pageHtml } from './page.js';
23
25
  import { decodePng } from './png.js';
24
- import { diff, formatDiff } from './diff.js';
26
+ import { diff, formatDiff, mergeFigures } from './diff.js';
25
27
  import { loadSpec, reportLines, sortFindings, specOf, summaryLines, svgWithSpec, verifyLines } from './load.js';
26
28
  import { check, render, toSvg } from './svg.js';
27
29
  import { checkRendered } from './check.js';
28
30
  import { coverageLine, links, parseSource, unsureLines, verifyReport } from './verify.js';
31
+ import { VERSION } from './version.js';
29
32
  import { trace, traceLines } from './trace.js';
30
33
  import { coverageLines, coverageReport } from './coverage.js';
31
34
  const USAGE = `usage: flowfig <-|spec.json|figure.ts> [out.svg] [--open] render a figure; a spec on stdin with -
32
35
  flowfig check <-|spec.json|figure.ts|figure.svg> list the faults; the input can be an SVG this wrote
33
36
  flowfig --spec figure.svg print the spec the SVG carries
34
37
  flowfig verify <input>... [--root <dir>] [--json] [--strict] check the code links of one or more figures
35
- flowfig diff <old> <new> [--json|--md] list the spec changes between two figures
38
+ flowfig diff <old> <new> [--json|--md] [--svg <out.svg>] list the spec changes between two figures, or draw them in one SVG
36
39
  flowfig trace <file#symbol> [--depth <n>] [--max <n>] [--root <dir>] [--json] list the calls under a symbol, each one checked
37
40
  flowfig coverage [--figures <glob>] [--entries <glob>] [--root <dir>] [--strict] [--json] list failing and stale figures, and code with no figure
38
41
  flowfig docs [topic] print the guide, or one topic (Markdown)
39
42
  flowfig mcp serve check, render, verify, diff and docs over MCP (stdio)
40
- flowfig init [dir] [--agents <ids>] [-y] [--global] [--dry-run] [--no-mcp] write flowfig instructions for the coding agents of a repo
43
+ flowfig init [dir] [--agents <ids> | --all-agents | --list-agents] [-y] [--global] [--dry-run] [--no-mcp] write flowfig instructions for the coding agents of a repo
41
44
  flowfig draw "<question>" [--out <path>] [--model <alias>] [--max-turns <n>] [--json] [--open] ask Claude Code for a figure, then check it
45
+ flowfig atlas [--figures <glob>] [--root <dir>] [--out <dir>] [--open] write one linked web page for each figure, and an index
42
46
  flowfig open <figure.svg> [--html <path>] show the figure in the default browser
47
+ flowfig from-mermaid <file.mmd|file.md|-> [--out <dir>] convert Mermaid diagrams to specs
43
48
  flowfig gif <figure.svg> [out.gif] [--step <n>] [--dark] [--fps <n>] [--scale <n>] [--mp4] write an animated GIF
44
- flags for render and check: --strict (warnings are errors), --json, --width <px>, --min-text <px>, --no-check (render only), --no-verify (render only)`;
49
+ flags for render and check: --strict (warnings are errors), --json, --width <px>, --min-text <px>, --no-check (render only), --no-verify (render only)
50
+ flowfig --version, -v print the version`;
45
51
  function usage(message) {
46
52
  console.error(message);
47
53
  process.exit(2);
@@ -69,6 +75,10 @@ if (!args[0] || ['help', '--help', '-h'].includes(args[0])) {
69
75
  usage(USAGE);
70
76
  process.exit(0);
71
77
  }
78
+ if (['--version', '-v'].includes(args[0])) {
79
+ console.log(VERSION);
80
+ process.exit(0);
81
+ }
72
82
  if (args[0] === 'docs') {
73
83
  const topic = args[1];
74
84
  if (topic && !Object.hasOwn(TOPICS, topic))
@@ -86,8 +96,12 @@ if (args[0] === 'init')
86
96
  process.exit(await runInit(args.slice(1)));
87
97
  if (args[0] === 'draw')
88
98
  process.exit(await runDraw(args.slice(1)));
99
+ if (args[0] === 'atlas')
100
+ process.exit(await runAtlas(args.slice(1)));
89
101
  if (args[0] === 'open')
90
102
  process.exit(await runOpen(args.slice(1)));
103
+ if (args[0] === 'from-mermaid')
104
+ process.exit(runFromMermaid(args.slice(1)));
91
105
  if (args[0] === '--spec') {
92
106
  if (!args[1])
93
107
  usage('--spec needs the path of an SVG');
@@ -197,12 +211,21 @@ if (args[0] === 'coverage') {
197
211
  if (args[0] === 'diff') {
198
212
  args.shift();
199
213
  const json = flag('--json'), md = flag('--md');
214
+ const svgOut = option('--svg');
200
215
  const unknown = args.find((a) => a.startsWith('-') && a !== '-');
201
216
  if (unknown)
202
217
  usage(`unknown flag ${unknown}`);
203
218
  if (args.length !== 2)
204
- usage('diff needs two figures: flowfig diff <old> <new> [--json|--md]');
205
- const changes = diff(await load(args[0]), await load(args[1]));
219
+ usage('diff needs two figures: flowfig diff <old> <new> [--json|--md] [--svg <out.svg>]');
220
+ const [before, after] = [await load(args[0]), await load(args[1])];
221
+ const changes = diff(before, after);
222
+ if (svgOut) {
223
+ const { figure, marks } = mergeFigures(before, after);
224
+ const { svg, scene } = render(figure, { marks });
225
+ for (const f of sortFindings(checkRendered(figure, {}, scene), false))
226
+ console.error(`${f.severity.padEnd(8)} ${f.rule.padEnd(18)} ${f.message}`);
227
+ writeFileSync(svgOut, svg);
228
+ }
206
229
  console.log(json ? JSON.stringify(changes, null, 2) : formatDiff(changes, md ? 'md' : 'text'));
207
230
  process.exit(0);
208
231
  }