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 +70 -41
- package/dist/browser.d.ts +33 -0
- package/dist/browser.js +201 -0
- package/dist/check.js +104 -4
- package/dist/cli.js +177 -7
- package/dist/diff.js +1 -1
- package/dist/draw.js +20 -3
- package/dist/geometry.d.ts +20 -1
- package/dist/geometry.js +204 -14
- package/dist/gif.d.ts +12 -0
- package/dist/gif.js +245 -0
- package/dist/guide.d.ts +3 -3
- package/dist/guide.js +28 -11
- package/dist/index.d.ts +1 -1
- package/dist/index.js +357 -39
- package/dist/init.d.ts +5 -0
- package/dist/init.js +58 -22
- package/dist/mcp.js +1 -1
- package/dist/model.d.ts +180 -0
- package/dist/model.js +420 -8
- package/dist/open.d.ts +15 -0
- package/dist/open.js +82 -0
- package/dist/page.d.ts +2 -0
- package/dist/page.js +16 -0
- package/dist/picker.d.ts +33 -0
- package/dist/picker.js +148 -0
- package/dist/png.d.ts +7 -0
- package/dist/png.js +70 -0
- package/dist/scene.d.ts +9 -0
- package/dist/svg.d.ts +3 -0
- package/dist/svg.js +334 -76
- package/dist/text.d.ts +2 -0
- package/dist/text.js +19 -0
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -16,17 +16,30 @@
|
|
|
16
16
|
|
|
17
17
|
</div>
|
|
18
18
|
|
|
19
|
-
|
|
19
|
+
## Use it
|
|
20
20
|
|
|
21
|
-
|
|
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
|
-
|
|
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
|
|
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
|
+

|
|
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
|
+

|
|
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
|
+

|
|
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
|
|
459
|
-
|
|
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>;
|
package/dist/browser.js
ADDED
|
@@ -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
|
|
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
|
-
|
|
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))
|