flowfig 0.9.0 → 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/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)
package/dist/cli.js CHANGED
@@ -18,10 +18,12 @@ 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';
@@ -32,14 +34,16 @@ const USAGE = `usage: flowfig <-|spec.json|figure.ts> [out.svg] [--open] rende
32
34
  flowfig check <-|spec.json|figure.ts|figure.svg> list the faults; the input can be an SVG this wrote
33
35
  flowfig --spec figure.svg print the spec the SVG carries
34
36
  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
37
+ flowfig diff <old> <new> [--json|--md] [--svg <out.svg>] list the spec changes between two figures, or draw them in one SVG
36
38
  flowfig trace <file#symbol> [--depth <n>] [--max <n>] [--root <dir>] [--json] list the calls under a symbol, each one checked
37
39
  flowfig coverage [--figures <glob>] [--entries <glob>] [--root <dir>] [--strict] [--json] list failing and stale figures, and code with no figure
38
40
  flowfig docs [topic] print the guide, or one topic (Markdown)
39
41
  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
42
+ 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
43
  flowfig draw "<question>" [--out <path>] [--model <alias>] [--max-turns <n>] [--json] [--open] ask Claude Code for a figure, then check it
44
+ flowfig atlas [--figures <glob>] [--root <dir>] [--out <dir>] [--open] write one linked web page for each figure, and an index
42
45
  flowfig open <figure.svg> [--html <path>] show the figure in the default browser
46
+ flowfig from-mermaid <file.mmd|file.md|-> [--out <dir>] convert Mermaid diagrams to specs
43
47
  flowfig gif <figure.svg> [out.gif] [--step <n>] [--dark] [--fps <n>] [--scale <n>] [--mp4] write an animated GIF
44
48
  flags for render and check: --strict (warnings are errors), --json, --width <px>, --min-text <px>, --no-check (render only), --no-verify (render only)`;
45
49
  function usage(message) {
@@ -86,8 +90,12 @@ if (args[0] === 'init')
86
90
  process.exit(await runInit(args.slice(1)));
87
91
  if (args[0] === 'draw')
88
92
  process.exit(await runDraw(args.slice(1)));
93
+ if (args[0] === 'atlas')
94
+ process.exit(await runAtlas(args.slice(1)));
89
95
  if (args[0] === 'open')
90
96
  process.exit(await runOpen(args.slice(1)));
97
+ if (args[0] === 'from-mermaid')
98
+ process.exit(runFromMermaid(args.slice(1)));
91
99
  if (args[0] === '--spec') {
92
100
  if (!args[1])
93
101
  usage('--spec needs the path of an SVG');
@@ -197,12 +205,21 @@ if (args[0] === 'coverage') {
197
205
  if (args[0] === 'diff') {
198
206
  args.shift();
199
207
  const json = flag('--json'), md = flag('--md');
208
+ const svgOut = option('--svg');
200
209
  const unknown = args.find((a) => a.startsWith('-') && a !== '-');
201
210
  if (unknown)
202
211
  usage(`unknown flag ${unknown}`);
203
212
  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]));
213
+ usage('diff needs two figures: flowfig diff <old> <new> [--json|--md] [--svg <out.svg>]');
214
+ const [before, after] = [await load(args[0]), await load(args[1])];
215
+ const changes = diff(before, after);
216
+ if (svgOut) {
217
+ const { figure, marks } = mergeFigures(before, after);
218
+ const { svg, scene } = render(figure, { marks });
219
+ for (const f of sortFindings(checkRendered(figure, {}, scene), false))
220
+ console.error(`${f.severity.padEnd(8)} ${f.rule.padEnd(18)} ${f.message}`);
221
+ writeFileSync(svgOut, svg);
222
+ }
206
223
  console.log(json ? JSON.stringify(changes, null, 2) : formatDiff(changes, md ? 'md' : 'text'));
207
224
  process.exit(0);
208
225
  }
package/dist/coverage.js CHANGED
@@ -3,12 +3,12 @@ import { readFileSync } from 'node:fs';
3
3
  import { posix, resolve } from 'node:path';
4
4
  import { langOf, readFile } from './code.js';
5
5
  import { loadSpec, specOf } from './load.js';
6
- import { links, verifyReport } from './verify.js';
7
- const MARK = '<metadata id="figure-spec">';
6
+ import { detailFindings, links, SPEC_MARK, verifyReport } from './verify.js';
8
7
  const globRe = (glob) => new RegExp(`^${glob
9
8
  .replace(/^\.\//, '')
10
9
  .replace(/[.+^${}()|[\]\\]/g, '\\$&')
11
10
  .replace(/\*\*\/|\*\*|\*|\?/g, (t) => (t === '**/' ? '(?:.*/)?' : t === '**' ? '.*' : t === '*' ? '[^/]*' : '[^/]'))}$`);
11
+ const isTest = (p) => /(^|\/)(__tests__|tests|src\/test)\//.test(p) || /\.(test|spec)\.[^/]+$|(^|\/)test_[^/]*\.py$|_test\.(py|go)$|Tests?\.(java|cs)$/.test(p);
12
12
  const day = (t) => (t === Infinity ? 'now' : new Date(t * 1000).toISOString().slice(0, 10));
13
13
  const count = (k, word) => `${k} ${word}${k === 1 ? '' : 's'}`;
14
14
  function gitTimes(root) {
@@ -42,7 +42,7 @@ export function coverageReport({ root = process.cwd(), figures = '**/*.svg', ent
42
42
  const out = [];
43
43
  for (const figure of all.filter((p) => p.endsWith('.svg') && want.test(p))) {
44
44
  const text = readFileSync(resolve(root, figure), 'utf8');
45
- if (!text.includes(MARK))
45
+ if (!text.includes(SPEC_MARK))
46
46
  continue;
47
47
  let fig;
48
48
  try {
@@ -54,8 +54,10 @@ export function coverageReport({ root = process.cwd(), figures = '**/*.svg', ent
54
54
  }
55
55
  const paths = [...new Set(links(fig).map((l) => posix.normalize(l.path)))];
56
56
  paths.forEach((p) => covered.add(p));
57
+ const lost = detailFindings(fig, root).length;
58
+ const lostText = lost ? `; ${lost} missing-detail` : '';
57
59
  if (!paths.length) {
58
- out.push({ figure, state: 'none', detail: 'no source links' });
60
+ out.push({ figure, state: 'none', detail: `no source links${lostText}` });
59
61
  continue;
60
62
  }
61
63
  const { findings, coverage } = verifyReport(fig, { root, cache });
@@ -72,13 +74,14 @@ export function coverageReport({ root = process.cwd(), figures = '**/*.svg', ent
72
74
  const newer = time && paths.find((p) => time(p) > mine);
73
75
  out.push(newer
74
76
  ? { figure, state: 'stale', detail: `${newer} changed ${day(time(newer))}, figure ${day(mine)}` }
75
- : { figure, state: 'ok', detail: `${coverage.boxesDefined} of ${coverage.boxes} boxes defined` });
77
+ : { figure, state: 'ok', detail: `${coverage.boxesDefined} of ${coverage.boxes} boxes defined${lostText}` });
76
78
  }
77
- const code = all.filter((p) => langOf(p) != null);
79
+ const code = all.filter((p) => langOf(p) != null && !isTest(p));
78
80
  const report = { figures: out, git: time != null };
79
81
  if (entries) {
80
82
  const re = globRe(entries);
81
- const matched = code.filter((p) => re.test(p));
83
+ const named = entries.replace(/^\.\//, '');
84
+ const matched = all.filter((p) => langOf(p) != null && re.test(p) && (p === named || !isTest(p)));
82
85
  report.entries = { glob: entries, total: matched.length, uncovered: matched.filter((p) => !covered.has(p)) };
83
86
  }
84
87
  else {
package/dist/diff.d.ts CHANGED
@@ -7,3 +7,13 @@ export type Change = {
7
7
  };
8
8
  export declare function diff(before: FlowProps, after: FlowProps): Change[];
9
9
  export declare function formatDiff(changes: Change[], as: 'text' | 'md'): string;
10
+ export type DiffMark = 'added' | 'removed' | 'changed';
11
+ export type DiffMarks = {
12
+ boxes: Record<string, DiffMark>;
13
+ edges: Record<string, DiffMark>;
14
+ steps: number;
15
+ };
16
+ export declare function mergeFigures(before: FlowProps, after: FlowProps): {
17
+ figure: FlowProps;
18
+ marks: DiffMarks;
19
+ };