flowfig 0.3.0 → 0.5.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/README.md CHANGED
@@ -16,17 +16,30 @@
16
16
 
17
17
  </div>
18
18
 
19
- An agent with the flowfig instructions made faithful, checked diagrams more often than the same agent without them:
19
+ ## Use it
20
20
 
21
- | Criterion | Agent without flowfig instructions | Agent with flowfig instructions |
22
- | --------------------------------------------------- | ---------------------------------- | ---------------------------------------------- |
23
- | Diagram faithful to the code (no invented part) | 7 of 8 | 8 of 8 |
24
- | Right diagram form for the task | 5 of 7 | 7 of 7 |
25
- | Passes `flowfig check --strict` | 5 of 7 | 6 of 6 |
26
- | Right action (draws, declines or splits) | 6 of 8 | 7 of 8 |
27
- | Held-out tasks (not used to write the instructions) | – | 2 of 4 pass all 6 criteria; 2 of 4 pass 4 of 6 |
21
+ Set up your repo:
28
22
 
29
- [How we measured](#how-we-measured).
23
+ ```bash
24
+ npx flowfig init
25
+ ```
26
+
27
+ Then start a new agent session. In Claude Code, run:
28
+
29
+ ```
30
+ /figure how does login work
31
+ ```
32
+
33
+ With any agent, ask in plain words:
34
+
35
+ ```
36
+ draw a diagram of how login works in this repo
37
+ use flowfig to draw the checkout flow as a sequence
38
+ draw the refund process with a lane for each team
39
+ ```
40
+
41
+ The agent writes an SVG and ends its reply with `npx flowfig open <path>`. Run that command to see the animation in your
42
+ browser.
30
43
 
31
44
  ## One command
32
45
 
@@ -41,18 +54,7 @@ checks the figure itself with `check --strict` and `verify`, and removes the spe
41
54
  `draw` exits 1 and shows the tool calls that the permission rules denied. `draw` prints the result, the reply of the agent
42
55
  and the cost of the run. `draw` needs Claude Code on the machine and a login. `draw` does not run on Windows yet.
43
56
  `--out` sets the path (it must end in `.svg`), `--model` sets the model, `--max-turns` sets the turn cap (default 40) and
44
- `--json` prints `{ out, reply, cost, session, findings }` for scripts.
45
-
46
- ## Quick start
47
-
48
- ```bash
49
- npx flowfig init
50
- ```
51
-
52
- Then ask your agent: "draw a diagram of how login works in this repo".
53
-
54
- `init` writes the flowfig instructions for the agents that your repo uses. The agent reads the code, writes a spec, runs
55
- `flowfig check`, fixes the faults, and renders the SVG.
57
+ `--json` prints `{ out, reply, cost, session, findings }` for scripts. `--open` opens the figure in the default browser after the check.
56
58
 
57
59
  ## The problem
58
60
 
@@ -95,13 +97,14 @@ Then ask your agent: "draw a diagram of how login works in this repo".
95
97
  - [Three forms](#three-forms)
96
98
  - [The spec](#the-spec)
97
99
  - [Check a figure](#check-a-figure)
100
+ - [Open and share a figure](#open-and-share-a-figure)
98
101
  - [Verify in CI](#verify-in-ci)
102
+ - [For teams](#for-teams)
99
103
  - [MCP server](#mcp-server)
100
104
  - [Use in React](#use-in-react)
101
105
  - [Use from Node](#use-from-node)
102
106
  - [Use with your coding agent](#use-with-your-coding-agent)
103
107
  - [What flowfig does not draw](#what-flowfig-does-not-draw)
104
- - [How we measured](#how-we-measured)
105
108
  - [License](#license)
106
109
  - [Development](#development)
107
110
 
@@ -298,6 +301,7 @@ The last line gives the counts of the parts of the figure. Compare the counts wi
298
301
  | `low-contrast` | error | A text and background pair below 4.5:1, in the light, dark or custom theme. |
299
302
  | `empty-step` | warning | A step with no beats. |
300
303
  | `small-text` | warning | At the page width, the smallest text is below the minimum size. |
304
+ | `lane-end-block` | warning | In wrapped lanes, an edge ends at a lane that no block on its side shows. |
301
305
  | `font-estimated` | warning | `theme.font` is set. The SVG check estimates text width for the system font. |
302
306
  | `color-not-checked` | warning | A color that the check cannot read, so its contrast is not checked. |
303
307
 
@@ -305,7 +309,7 @@ The last line gives the counts of the parts of the figure. Compare the counts wi
305
309
  | ----------------- | ---------------------------------------------------------------------------------------------------------- |
306
310
  | `--strict` | Every warning becomes an error. |
307
311
  | `--json` | Print the findings as a JSON array, for scripts. Each finding has `rule`, `severity`, `ids` and `message`. |
308
- | `--width <px>` | The page width for `small-text`. Default: 830. |
312
+ | `--width <px>` | The page width for `small-text` and for the lanes wrap. Default: 830. |
309
313
  | `--min-text <px>` | The smallest text size the reader must get. Default: 10. |
310
314
 
311
315
  The exit code is 0 with no errors, 1 with one or more errors, and 2 for bad use, such as a missing input, an unknown flag, or a `--width` value that is not a number.
@@ -315,6 +319,31 @@ A render runs the same check first. If the check finds an error, the render writ
315
319
  In React, `<Flow check />` runs the same rules on the layout that the browser drew. The player prints each fault with
316
320
  `console.warn`.
317
321
 
322
+ ## Open and share a figure
323
+
324
+ `flowfig open` shows a figure in your default browser. On macOS, an `.svg` file often opens in a text editor or in Xcode. Thus
325
+ `open` writes an HTML page that holds the SVG, and opens that page.
326
+
327
+ ```bash
328
+ npx flowfig open docs/checkout.svg
329
+ npx flowfig open docs/checkout.svg --html checkout.html # also write the page to checkout.html
330
+ npx flowfig docs/checkout.json docs/checkout.svg --open # render, then open
331
+ ```
332
+
333
+ `flowfig gif` writes an animated GIF of the whole loop. A GIF plays in Slack, Notion, X and slide decks, where an SVG
334
+ animation does not play.
335
+
336
+ ```bash
337
+ npx flowfig gif docs/checkout.svg # writes docs/checkout.gif
338
+ npx flowfig gif docs/checkout.svg --step 2 # only step 2: writes docs/checkout-step2.gif
339
+ npx flowfig gif docs/checkout.svg --dark --mp4 # the dark theme, and also docs/checkout.mp4
340
+ ```
341
+
342
+ `gif` needs Chrome, Edge, Chromium or Brave in the standard install path. Set `CHROME_PATH` to use another browser program.
343
+ `--fps` sets the frames per second (1 to 50, default 20). `--scale` sets the pixel scale (default 2). `--mp4` needs `ffmpeg`
344
+ on the PATH. If a GIF is over 10 MB, `gif` prints a warning. Use `--step`, a lower `--fps` or a lower `--scale` to make the
345
+ file smaller.
346
+
318
347
  ## Verify in CI
319
348
 
320
349
  A box, an edge or a hop can name the code it draws: `"source": "src/auth/login.ts#verifyPassword"`. `npx flowfig verify docs/login.svg`
@@ -340,6 +369,21 @@ steps:
340
369
  figures: 'docs/**/*.svg' # default **/*.svg
341
370
  ```
342
371
 
372
+ A state lifecycle marks the first state with `mark: "start"` and each final state with `mark: "end"`.
373
+
374
+ ![An order status lifecycle with a start dot and two end rings](docs/order-status.svg)
375
+
376
+ ## For teams
377
+
378
+ A process is a diagram too. `lanes: true` draws one lane per role and the steps left to right in time order. Each step
379
+ links to a heading in the SOP, so `verify` fails when the SOP changes under the diagram.
380
+
381
+ ![The refund process across Customer, Support and Finance](docs/refund-process.svg)
382
+
383
+ A plan is a figure too. `timeline: true` draws one track per team, with each item as a bar from its `from` date to its `to` date. A milestone has only `from`, and `today` draws the line for now.
384
+
385
+ ![A Q4 roadmap with three tracks, milestones and a today line](docs/roadmap.svg)
386
+
343
387
  ## MCP server
344
388
 
345
389
  `npx flowfig mcp` serves the tools `docs`, `check`, `render`, `verify` and `diff` over stdio, for an agent with no shell.
@@ -455,24 +499,9 @@ of the file. A second run also replaces a whole file that `init` wrote. If a who
455
499
 
456
500
  ## What flowfig does not draw
457
501
 
458
- flowfig draws parts, the messages between the parts, and their order. flowfig does not draw class diagrams, ER diagrams, Gantt
459
- charts, timelines with dates, charts of numbers or mind maps. Use Mermaid or a chart library for those.
460
-
461
- ## How we measured
462
-
463
- We ran an evaluation before this release: 47 coding-agent runs, with Claude Sonnet as the agent. The runs covered 12 tasks in 5
464
- test repos: a TypeScript login API, a Python ETL pipeline, an order state machine, a 20-service platform and a mixed CLI repo.
465
- The agent ran without the flowfig instructions and with them. An Opus grader scored each run against an answer key.
466
-
467
- 4 tasks were held out: they shaped no instruction. On the held-out tasks, 2 of 4 runs passed all 6 criteria. The other 2 passed
468
- 4 of 6: one drew an edge from the wrong box, and one reply stated a runtime effect wrongly.
469
-
470
- A first draft of the instructions scored worse than no instructions: 5 of 8 faithful. We rewrote the instructions from the
471
- failures and tested them again. The "with" column comes from the version that we tested on all 8 tasks. The shipped instructions
472
- add two refinements: the scope of a repo with many flows, and each edge taken from the caller in the code. 9 more runs tested
473
- the two refinements on the tasks that still failed.
474
-
475
- The evaluation also found 3 faults in flowfig itself. We fixed all 3 before this release.
502
+ flowfig draws parts, the messages between the parts, and their order. flowfig draws a roadmap or a timeline with dates (`timeline: true`),
503
+ and converts a Mermaid `gantt` to it. flowfig does not draw class diagrams, ER diagrams, charts of numbers or mind maps. Use Mermaid
504
+ or a chart library for those.
476
505
 
477
506
  ## License
478
507
 
@@ -0,0 +1,33 @@
1
+ export type Found = {
2
+ path: string | null;
3
+ checked: string[];
4
+ };
5
+ /** The first capture browser that exists. `CHROME_PATH` wins. Pure apart from `exists`. */
6
+ export declare function findBrowser(o: {
7
+ platform: NodeJS.Platform;
8
+ env: Record<string, string | undefined>;
9
+ exists: (p: string) => boolean;
10
+ }): Found;
11
+ export type Cdp = {
12
+ send(method: string, params?: object, sessionId?: string, timeoutMs?: number): Promise<any>;
13
+ once(event: string, sessionId?: string, timeoutMs?: number): Promise<any>;
14
+ };
15
+ /** Start the capture browser in headless mode with a CDP pipe. `close` stops it and never throws. */
16
+ export declare function launch(path: string, profile: string, timeoutMs?: number): Promise<{
17
+ cdp: Cdp;
18
+ close: () => Promise<void>;
19
+ }>;
20
+ export type Capture = {
21
+ pngs: Buffer[];
22
+ width: number;
23
+ height: number;
24
+ loopMs: number;
25
+ };
26
+ /** Set the page, pause the animations and take one PNG per frame time. */
27
+ export declare function captureFrames(cdp: Cdp, html: string, o: {
28
+ width: number;
29
+ height: number;
30
+ scale: number;
31
+ fps: number;
32
+ dark: boolean;
33
+ }): Promise<Capture>;
@@ -0,0 +1,201 @@
1
+ // The capture browser of `flowfig gif`: find it, start it in headless mode, and talk CDP to it over a pipe. Node only.
2
+ import { spawn } from 'node:child_process';
3
+ import { win32 } from 'node:path';
4
+ const MAC = ['Google Chrome', 'Microsoft Edge', 'Chromium', 'Brave Browser'].map((n) => `/Applications/${n}.app/Contents/MacOS/${n}`);
5
+ const LINUX = [
6
+ '/usr/bin/google-chrome',
7
+ '/usr/bin/google-chrome-stable',
8
+ '/usr/bin/chromium',
9
+ '/usr/bin/chromium-browser',
10
+ '/snap/bin/chromium',
11
+ '/usr/bin/microsoft-edge',
12
+ '/usr/bin/brave-browser',
13
+ '/opt/google/chrome/chrome',
14
+ ];
15
+ const WINDOWS = [
16
+ 'Google\\Chrome\\Application\\chrome.exe',
17
+ 'Microsoft\\Edge\\Application\\msedge.exe',
18
+ 'BraveSoftware\\Brave-Browser\\Application\\brave.exe',
19
+ 'Chromium\\Application\\chrome.exe',
20
+ ];
21
+ /** The first capture browser that exists. `CHROME_PATH` wins. Pure apart from `exists`. */
22
+ export function findBrowser(o) {
23
+ const { env } = o;
24
+ const paths = env.CHROME_PATH
25
+ ? [env.CHROME_PATH]
26
+ : o.platform === 'darwin'
27
+ ? MAC
28
+ : o.platform === 'win32'
29
+ ? [env.PROGRAMFILES, env['PROGRAMFILES(X86)'], env.LOCALAPPDATA].flatMap((dir) => dir ? WINDOWS.map((p) => win32.join(dir, p)) : [])
30
+ : LINUX;
31
+ const checked = [];
32
+ for (const p of paths) {
33
+ checked.push(p);
34
+ if (o.exists(p))
35
+ return { path: p, checked };
36
+ }
37
+ return { path: null, checked };
38
+ }
39
+ /** Start the capture browser in headless mode with a CDP pipe. `close` stops it and never throws. */
40
+ export function launch(path, profile, timeoutMs = 30_000) {
41
+ const args = [
42
+ '--headless=new',
43
+ '--remote-debugging-pipe',
44
+ `--user-data-dir=${profile}`,
45
+ '--no-first-run',
46
+ '--no-default-browser-check',
47
+ '--hide-scrollbars',
48
+ '--mute-audio',
49
+ // The GPU raster gave a different pixel run to run on a rounded corner, and a bigger GIF.
50
+ '--disable-gpu',
51
+ // A transform animation (the timeline playhead) runs on the compositor thread, so a seek can draw a stale x under load.
52
+ '--disable-threaded-animation',
53
+ ];
54
+ // Chrome refuses to start as root without it, for example in a Docker container.
55
+ if (process.getuid?.() === 0)
56
+ args.push('--no-sandbox');
57
+ const child = spawn(path, [...args, 'about:blank'], {
58
+ stdio: ['ignore', 'ignore', 'pipe', 'pipe', 'pipe'],
59
+ // Its own process group, so close can stop the helpers too. Not on Windows: there it opens a console window.
60
+ detached: process.platform !== 'win32',
61
+ });
62
+ // The browser reads commands on fd 3 and writes replies on fd 4. A NUL byte ends each message.
63
+ const out = child.stdio[3], input = child.stdio[4];
64
+ out.on('error', () => { }); // a write after the exit fails; the exit handler below rejects the commands
65
+ let tail = '', dead = null, nextId = 1;
66
+ child.stderr.on('data', (d) => (tail = (tail + d.toString()).slice(-2048)));
67
+ const pending = new Map();
68
+ const events = [];
69
+ const stop = (e) => {
70
+ dead ??= e;
71
+ for (const w of [...pending.values(), ...events.splice(0)])
72
+ w.reject(dead);
73
+ pending.clear();
74
+ };
75
+ // The browser starts helper processes in its process group. A helper can outlive the browser and keep a pipe open.
76
+ const killGroup = () => {
77
+ try {
78
+ if (process.platform === 'win32')
79
+ child.kill('SIGKILL');
80
+ else if (child.pid)
81
+ process.kill(-child.pid, 'SIGKILL');
82
+ }
83
+ catch {
84
+ // the group is already gone
85
+ }
86
+ };
87
+ const closed = new Promise((done) => {
88
+ const end = (e) => (stop(e), done());
89
+ const stopped = (code) => new Error(`the browser stopped (exit ${code}): ${tail.trim()}`);
90
+ child.once('error', end);
91
+ child.once('exit', (code) => {
92
+ out.destroy(); // the write end of fd 3 does not close by itself, and 'close' waits for it
93
+ killGroup();
94
+ setTimeout(() => end(stopped(code)), 1000).unref(); // in case a pipe stays open after the group kill
95
+ });
96
+ child.once('close', (code) => end(stopped(code)));
97
+ });
98
+ let parts = [];
99
+ input.on('data', (chunk) => {
100
+ let start = 0;
101
+ for (let end = chunk.indexOf(0); end !== -1; start = end + 1, end = chunk.indexOf(0, start)) {
102
+ parts.push(chunk.subarray(start, end));
103
+ const msg = JSON.parse(Buffer.concat(parts).toString('utf8'));
104
+ parts = [];
105
+ if (msg.id !== undefined) {
106
+ const w = pending.get(msg.id);
107
+ pending.delete(msg.id);
108
+ if (msg.error)
109
+ w?.reject(new Error(`${w.method}: ${msg.error.message}`));
110
+ else
111
+ w?.resolve(msg.result);
112
+ }
113
+ else {
114
+ const i = events.findIndex((w) => w.event === msg.method && w.sessionId === msg.sessionId);
115
+ if (i !== -1)
116
+ events.splice(i, 1)[0].resolve(msg.params);
117
+ }
118
+ }
119
+ if (start < chunk.length)
120
+ parts.push(chunk.subarray(start));
121
+ });
122
+ /** A promise that rejects after the timeout; `add` registers the waiter, `drop` removes it. */
123
+ const timed = (label, add, drop, ms = timeoutMs) => new Promise((resolve, reject) => {
124
+ if (dead)
125
+ return reject(dead);
126
+ const timer = setTimeout(() => {
127
+ drop();
128
+ reject(new Error(`${label}: no reply in ${ms / 1000} s`));
129
+ }, ms);
130
+ add({ resolve: (v) => (clearTimeout(timer), resolve(v)), reject: (e) => (clearTimeout(timer), reject(e)) });
131
+ });
132
+ const cdp = {
133
+ send: (method, params = {}, sessionId, ms) => {
134
+ const id = nextId++;
135
+ return timed(method, (w) => {
136
+ pending.set(id, { ...w, method });
137
+ out.write(JSON.stringify({ id, method, params, sessionId }) + '\0');
138
+ }, () => pending.delete(id), ms);
139
+ },
140
+ once: (event, sessionId, ms) => {
141
+ let me;
142
+ return timed(event, (w) => events.push((me = { ...w, event, sessionId })), () => events.splice(events.indexOf(me), 1), ms);
143
+ },
144
+ };
145
+ // No Browser.close: the profile is a temp folder with nothing to keep, and Chrome takes about 10 s to exit after it.
146
+ const close = async () => {
147
+ killGroup();
148
+ await closed;
149
+ };
150
+ return cdp.send('Browser.getVersion').then(() => ({ cdp, close }), async (e) => {
151
+ await close();
152
+ throw e;
153
+ });
154
+ }
155
+ /** Set the page, pause the animations and take one PNG per frame time. */
156
+ export async function captureFrames(cdp, html, o) {
157
+ const { targetId } = await cdp.send('Target.createTarget', { url: 'about:blank' });
158
+ const { sessionId } = await cdp.send('Target.attachToTarget', { targetId, flatten: true });
159
+ const send = (method, params) => cdp.send(method, params, sessionId);
160
+ const run = async (expression) => {
161
+ const r = await send('Runtime.evaluate', { expression, awaitPromise: true, returnByValue: true });
162
+ if (r.exceptionDetails)
163
+ throw new Error(`Runtime.evaluate: ${r.exceptionDetails.exception?.description ?? r.exceptionDetails.text}`);
164
+ return r.result.value;
165
+ };
166
+ await send('Emulation.setDeviceMetricsOverride', { width: o.width, height: o.height, deviceScaleFactor: o.scale, mobile: false });
167
+ // The figure picks its theme with a media query, so the run sets the query and leaves the SVG as it is.
168
+ await send('Emulation.setEmulatedMedia', { features: [{ name: 'prefers-color-scheme', value: o.dark ? 'dark' : 'light' }] });
169
+ // The page goes in as content, not as a file: a snap browser has a private /tmp and cannot read the temp folder.
170
+ const { frameTree } = await send('Page.getFrameTree');
171
+ await send('Page.setDocumentContent', { frameId: frameTree.frame.id, html });
172
+ // CSS animations are in getAnimations(). The packet moves with SMIL, which only the <svg> element controls.
173
+ const info = await run(`(async () => {
174
+ await document.fonts.ready;
175
+ const svg = document.querySelector('svg');
176
+ const css = document.getAnimations();
177
+ for (const a of css) a.pause();
178
+ svg.pauseAnimations();
179
+ let loop = 0;
180
+ for (const a of css) loop = Math.max(loop, Number(a.effect.getTiming().duration) || 0);
181
+ for (const m of svg.querySelectorAll('animate, animateMotion, animateTransform, set'))
182
+ try { loop = Math.max(loop, m.getSimpleDuration() * 1000); } catch {}
183
+ const r = svg.getBoundingClientRect();
184
+ return { loop, x: r.x, y: r.y, width: r.width, height: r.height };
185
+ })()`);
186
+ const n = Math.max(1, Math.round((info.loop * o.fps) / 1000));
187
+ const clip = { x: info.x, y: info.y, width: info.width, height: info.height, scale: 1 };
188
+ const pngs = [];
189
+ for (let i = 0; i < n; i++) {
190
+ const t = (i * 1000) / o.fps;
191
+ await run(`(() => {
192
+ for (const a of document.getAnimations()) a.currentTime = ${t};
193
+ document.querySelector('svg').setCurrentTime(${t / 1000});
194
+ // two frames: one frame can fire before the browser draws the new time under load
195
+ return new Promise((r) => requestAnimationFrame(() => requestAnimationFrame(r)));
196
+ })()`);
197
+ const { data } = await send('Page.captureScreenshot', { format: 'png', clip });
198
+ pngs.push(Buffer.from(data, 'base64'));
199
+ }
200
+ return { pngs, width: Math.round(info.width * o.scale), height: Math.round(info.height * o.scale), loopMs: info.loop };
201
+ }
package/dist/check.js CHANGED
@@ -1,6 +1,7 @@
1
1
  // The rules behind `flowfig check`. They never draw and never measure: a renderer gives a Scene, and these read it. So one rule set
2
2
  // covers the SVG (estimated text) and the React player (measured text), and any later view that gives a scene.
3
- import { DARK, LIGHT, ON_ACCENT, TONES, edgeId, isGroup, nodes, str, toBeat, } from './model.js';
3
+ import { DARK, LIGHT, ON_ACCENT, TONES, edgeId, isGroup, dayOf, isLanesLayout, validAt, laneColumns, nodes, str, toBeat, } from './model.js';
4
+ import { crosses } from './geometry.js';
4
5
  import { textWidth } from './text.js';
5
6
  import { owners, parseSource } from './source.js';
6
7
  const err = (rule, ids, message) => ({ rule, severity: 'error', ids, message });
@@ -34,9 +35,9 @@ export function checkSpec(fig) {
34
35
  for (const h of b.hops)
35
36
  if (!knownEdges.has(h.edge))
36
37
  out.push(err('unknown-id', [h.edge], `${where} moves along edge "${h.edge}", which does not exist`));
37
- for (const id of [...Object.keys(b.show ?? {}), ...(b.light ?? [])])
38
+ for (const id of [...Object.keys(b.show ?? {}), ...(b.light ?? []), ...(b.focus ?? [])])
38
39
  if (!known.has(id))
39
- out.push(err('unknown-id', [id], `${where}: show or light names box "${id}", which does not exist`));
40
+ out.push(err('unknown-id', [id], `${where}: show, light or focus names box "${id}", which does not exist`));
40
41
  }
41
42
  });
42
43
  const used = new Set((fig.steps ?? []).flatMap((s) => s.flow.flatMap((b) => toBeat(b).hops.map((h) => h.edge))));
@@ -48,6 +49,61 @@ export function checkSpec(fig) {
48
49
  for (const [who, source] of owners(fig))
49
50
  if (source != null && !parseSource(source))
50
51
  out.push(warn('bad-source', [], `${who}: source "${source}" is not path or path#symbol`));
52
+ const marks = nodes(fig.layout).map((n) => n.mark);
53
+ const starts = marks.filter((m) => m === 'start').length;
54
+ const ends = marks.filter((m) => m === 'end').length;
55
+ if (starts > 1 || (starts && !ends))
56
+ out.push(warn('mark-count', [], `a lifecycle has one start and at least one end: ${starts} start, ${ends} end`));
57
+ if (fig.timeline && fig.lanes)
58
+ out.push(warn('timeline-and-lanes', [], 'the figure sets timeline and lanes; the renderers draw the timeline and ignore lanes'));
59
+ if (fig.timeline) {
60
+ if (!isLanesLayout(fig.layout))
61
+ out.push(err('lanes-need-column', [], 'a timeline uses the lanes layout: a column group of labeled groups, one per track, with boxes only inside'));
62
+ for (const n of nodes(fig.layout)) {
63
+ if (n.from == null)
64
+ out.push(err('timeline-need-from', [n.id], `box "${n.id}" has no from date, and a timeline needs one`));
65
+ const [f, t] = [n.from == null ? null : dayOf(n.from), n.to == null ? null : dayOf(n.to)];
66
+ for (const [name, v, d] of [
67
+ ['from', n.from, f],
68
+ ['to', n.to, t],
69
+ ])
70
+ if (v != null && d == null)
71
+ out.push(err('bad-date', [n.id], `box "${n.id}": ${name} "${v}" is not a real YYYY-MM-DD date`));
72
+ if (f != null && t != null && t < f)
73
+ out.push(err('bad-date', [n.id], `box "${n.id}": to ${n.to} is before from ${n.from}`));
74
+ }
75
+ // A dependent item starts after its source ends. A milestone ends the day it starts.
76
+ 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))]]));
77
+ for (const e of fig.edges) {
78
+ const start = span.get(e.to)?.[0];
79
+ const end = span.get(e.from)?.[1];
80
+ if (start != null && end != null && start <= end)
81
+ out.push(warn('timeline-dependency-order', [e.from, e.to], `"${e.to}" does not start after "${e.from}" ends`));
82
+ }
83
+ if (fig.today != null && dayOf(fig.today) == null)
84
+ out.push(err('bad-date', [], `today "${fig.today}" is not a real YYYY-MM-DD date`));
85
+ }
86
+ else if (fig.lanes) {
87
+ const top = fig.layout;
88
+ if (!isLanesLayout(top))
89
+ out.push(err('lanes-need-column', [], 'lanes need a column group of labeled groups, one per lane, with boxes only inside'));
90
+ else {
91
+ const cols = laneColumns(fig);
92
+ for (const lane of top.children) {
93
+ const at = new Map();
94
+ for (const n of lane.children) {
95
+ const c = cols.get(n.id);
96
+ const other = at.get(c);
97
+ if (other != null)
98
+ out.push(warn('lane-column-taken', [other, n.id], `boxes "${other}" and "${n.id}" share column ${c} in lane "${str(lane.label)}"`));
99
+ at.set(c, n.id);
100
+ }
101
+ }
102
+ }
103
+ }
104
+ for (const n of nodes(fig.layout))
105
+ if (n.at != null && !validAt(n.at))
106
+ out.push(err('bad-at', [n.id], `box "${n.id}": at ${n.at} is not an integer of 0 or more`));
51
107
  return out;
52
108
  }
53
109
  /** A point on the cubic Bézier at `t`. */
@@ -56,6 +112,7 @@ const at = ([p0, p1, p2, p3], t) => {
56
112
  const [a, b, c, d] = [u * u * u, 3 * u * u * t, 3 * u * t * t, t * t * t];
57
113
  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 };
58
114
  };
115
+ const lerp = (p, q, t) => ({ x: p.x + (q.x - p.x) * t, y: p.y + (q.y - p.y) * t });
59
116
  // The margins keep a touch from counting: an edge leaves along a border, and pills sit edge to edge.
60
117
  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;
61
118
  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;
@@ -70,11 +127,54 @@ export function checkScene(scene, { width = 830, minText = 10 } = {}) {
70
127
  out.push(err('text-overflow', [b.id], `box "${b.id}": "${t.text}" needs ${Math.ceil(need)} px, has ${Math.floor(t.room)} px`));
71
128
  }
72
129
  for (const e of scene.edges) {
73
- const pts = Array.from({ length: 33 }, (_, i) => at(e.curve, i / 32));
130
+ if (e.behind)
131
+ continue;
132
+ const pts = e.pts
133
+ ? e.pts.slice(1).flatMap((q, k) => Array.from({ length: 17 }, (_, i) => lerp(e.pts[k], q, i / 16)))
134
+ : Array.from({ length: 33 }, (_, i) => at(e.curve, i / 32));
74
135
  for (const b of scene.boxes)
75
136
  if (b.id !== e.from && b.id !== e.to && pts.some((p) => inside(p, b.rect, 2)))
76
137
  out.push(err('edge-crosses-box', [e.id, b.id], `edge "${e.id}" passes through box "${b.id}"`));
77
138
  }
139
+ // A stub pill (the label of an edge with `pts`) must not sit on another edge's path.
140
+ const pills = scene.edges.filter((e) => e.pts && e.label);
141
+ const under = new Set();
142
+ for (const e of scene.edges) {
143
+ if (e.behind)
144
+ continue;
145
+ const pts = e.pts
146
+ ? e.pts.slice(1).flatMap((q, k) => Array.from({ length: 17 }, (_, i) => lerp(e.pts[k], q, i / 16)))
147
+ : Array.from({ length: 33 }, (_, i) => at(e.curve, i / 32));
148
+ for (const f of pills)
149
+ if (f.id !== e.id && !under.has(e.id + ' ' + f.id) && pts.some((p) => inside(p, f.label, 2))) {
150
+ under.add(e.id + ' ' + f.id);
151
+ out.push(err('label-overlap', [e.id, f.id], `edge "${e.id}" passes under the pill of edge "${f.id}"`));
152
+ }
153
+ }
154
+ // A stub line (a segment of an edge with `pts`) must not cross the path of another edge.
155
+ const path = (e) => e.pts
156
+ ? e.pts.slice(1).flatMap((q, k) => Array.from({ length: 17 }, (_, i) => lerp(e.pts[k], q, i / 16)))
157
+ : Array.from({ length: 33 }, (_, i) => at(e.curve, i / 32));
158
+ const stubs = scene.edges.filter((e) => e.pts && e.pts.length > 1);
159
+ const hit = new Set();
160
+ for (const f of stubs)
161
+ for (const e of scene.edges) {
162
+ if (e.behind || e === f || (e.id === f.id && e.pts) || hit.has(f.id + ' ' + e.id))
163
+ continue;
164
+ const p = path(e);
165
+ if (f.pts.slice(1).some((a, k) => p.slice(1).some((b, i) => crosses(f.pts[k], a, p[i], b)))) {
166
+ hit.add(f.id + ' ' + e.id);
167
+ out.push(warn('stub-crosses-edge', [f.id, e.id], `the stub of edge "${f.id}" crosses edge "${e.id}"`));
168
+ }
169
+ }
170
+ // A stub pill sits inside the band of its box: a pill across a band border reads as part of two lanes.
171
+ for (const f of pills)
172
+ for (const l of scene.lanes ?? []) {
173
+ const [p, r] = [f.label, l.rect];
174
+ const inside = p.x >= r.x && p.x + p.w <= r.x + r.w && p.y >= r.y && p.y + p.h <= r.y + r.h;
175
+ if (!inside && overlap(p, r, 0))
176
+ out.push(err('label-overlap', [f.id], `the pill of edge "${f.id}" crosses the border of lane "${l.id}"`));
177
+ }
78
178
  const labeled = scene.edges.filter((e) => e.label);
79
179
  labeled.forEach((e, i) => {
80
180
  for (const f of labeled.slice(i + 1))