flowfig 0.3.0 → 0.4.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 +71 -14
- package/dist/browser.d.ts +33 -0
- package/dist/browser.js +201 -0
- package/dist/check.js +60 -3
- package/dist/cli.js +176 -6
- package/dist/diff.js +1 -1
- package/dist/draw.js +20 -3
- package/dist/geometry.d.ts +4 -1
- package/dist/geometry.js +39 -12
- package/dist/gif.d.ts +12 -0
- package/dist/gif.js +245 -0
- package/dist/guide.d.ts +3 -3
- package/dist/guide.js +25 -10
- package/dist/index.d.ts +1 -1
- package/dist/index.js +209 -13
- package/dist/init.d.ts +5 -0
- package/dist/init.js +58 -22
- package/dist/model.d.ts +89 -0
- package/dist/model.js +171 -7
- 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 +2 -0
- package/dist/svg.js +205 -15
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -28,6 +28,31 @@ An agent with the flowfig instructions made faithful, checked diagrams more ofte
|
|
|
28
28
|
|
|
29
29
|
[How we measured](#how-we-measured).
|
|
30
30
|
|
|
31
|
+
## Use it
|
|
32
|
+
|
|
33
|
+
Set up your repo:
|
|
34
|
+
|
|
35
|
+
```bash
|
|
36
|
+
npx flowfig init
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
Then start a new agent session. In Claude Code, run:
|
|
40
|
+
|
|
41
|
+
```
|
|
42
|
+
/figure how does login work
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
With any agent, ask in plain words:
|
|
46
|
+
|
|
47
|
+
```
|
|
48
|
+
draw a diagram of how login works in this repo
|
|
49
|
+
use flowfig to draw the checkout flow as a sequence
|
|
50
|
+
draw the refund process with a lane for each team
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
The agent writes an SVG and ends its reply with `npx flowfig open <path>`. Run that command to see the animation in your
|
|
54
|
+
browser.
|
|
55
|
+
|
|
31
56
|
## One command
|
|
32
57
|
|
|
33
58
|
```bash
|
|
@@ -41,18 +66,7 @@ checks the figure itself with `check --strict` and `verify`, and removes the spe
|
|
|
41
66
|
`draw` exits 1 and shows the tool calls that the permission rules denied. `draw` prints the result, the reply of the agent
|
|
42
67
|
and the cost of the run. `draw` needs Claude Code on the machine and a login. `draw` does not run on Windows yet.
|
|
43
68
|
`--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.
|
|
69
|
+
`--json` prints `{ out, reply, cost, session, findings }` for scripts. `--open` opens the figure in the default browser after the check.
|
|
56
70
|
|
|
57
71
|
## The problem
|
|
58
72
|
|
|
@@ -95,7 +109,9 @@ Then ask your agent: "draw a diagram of how login works in this repo".
|
|
|
95
109
|
- [Three forms](#three-forms)
|
|
96
110
|
- [The spec](#the-spec)
|
|
97
111
|
- [Check a figure](#check-a-figure)
|
|
112
|
+
- [Open and share a figure](#open-and-share-a-figure)
|
|
98
113
|
- [Verify in CI](#verify-in-ci)
|
|
114
|
+
- [For teams](#for-teams)
|
|
99
115
|
- [MCP server](#mcp-server)
|
|
100
116
|
- [Use in React](#use-in-react)
|
|
101
117
|
- [Use from Node](#use-from-node)
|
|
@@ -315,6 +331,31 @@ A render runs the same check first. If the check finds an error, the render writ
|
|
|
315
331
|
In React, `<Flow check />` runs the same rules on the layout that the browser drew. The player prints each fault with
|
|
316
332
|
`console.warn`.
|
|
317
333
|
|
|
334
|
+
## Open and share a figure
|
|
335
|
+
|
|
336
|
+
`flowfig open` shows a figure in your default browser. On macOS, an `.svg` file often opens in a text editor or in Xcode. Thus
|
|
337
|
+
`open` writes an HTML page that holds the SVG, and opens that page.
|
|
338
|
+
|
|
339
|
+
```bash
|
|
340
|
+
npx flowfig open docs/checkout.svg
|
|
341
|
+
npx flowfig open docs/checkout.svg --html checkout.html # also write the page to checkout.html
|
|
342
|
+
npx flowfig docs/checkout.json docs/checkout.svg --open # render, then open
|
|
343
|
+
```
|
|
344
|
+
|
|
345
|
+
`flowfig gif` writes an animated GIF of the whole loop. A GIF plays in Slack, Notion, X and slide decks, where an SVG
|
|
346
|
+
animation does not play.
|
|
347
|
+
|
|
348
|
+
```bash
|
|
349
|
+
npx flowfig gif docs/checkout.svg # writes docs/checkout.gif
|
|
350
|
+
npx flowfig gif docs/checkout.svg --step 2 # only step 2: writes docs/checkout-step2.gif
|
|
351
|
+
npx flowfig gif docs/checkout.svg --dark --mp4 # the dark theme, and also docs/checkout.mp4
|
|
352
|
+
```
|
|
353
|
+
|
|
354
|
+
`gif` needs Chrome, Edge, Chromium or Brave in the standard install path. Set `CHROME_PATH` to use another browser program.
|
|
355
|
+
`--fps` sets the frames per second (1 to 50, default 20). `--scale` sets the pixel scale (default 2). `--mp4` needs `ffmpeg`
|
|
356
|
+
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
|
|
357
|
+
file smaller.
|
|
358
|
+
|
|
318
359
|
## Verify in CI
|
|
319
360
|
|
|
320
361
|
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 +381,21 @@ steps:
|
|
|
340
381
|
figures: 'docs/**/*.svg' # default **/*.svg
|
|
341
382
|
```
|
|
342
383
|
|
|
384
|
+
A state lifecycle marks the first state with `mark: "start"` and each final state with `mark: "end"`.
|
|
385
|
+
|
|
386
|
+

|
|
387
|
+
|
|
388
|
+
## For teams
|
|
389
|
+
|
|
390
|
+
A process is a diagram too. `lanes: true` draws one lane per role and the steps left to right in time order. Each step
|
|
391
|
+
links to a heading in the SOP, so `verify` fails when the SOP changes under the diagram.
|
|
392
|
+
|
|
393
|
+

|
|
394
|
+
|
|
395
|
+
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.
|
|
396
|
+
|
|
397
|
+

|
|
398
|
+
|
|
343
399
|
## MCP server
|
|
344
400
|
|
|
345
401
|
`npx flowfig mcp` serves the tools `docs`, `check`, `render`, `verify` and `diff` over stdio, for an agent with no shell.
|
|
@@ -455,8 +511,9 @@ of the file. A second run also replaces a whole file that `init` wrote. If a who
|
|
|
455
511
|
|
|
456
512
|
## What flowfig does not draw
|
|
457
513
|
|
|
458
|
-
flowfig draws parts, the messages between the parts, and their order. flowfig
|
|
459
|
-
|
|
514
|
+
flowfig draws parts, the messages between the parts, and their order. flowfig draws a roadmap or a timeline with dates (`timeline: true`),
|
|
515
|
+
and converts a Mermaid `gantt` to it. flowfig does not draw class diagrams, ER diagrams, charts of numbers or mind maps. Use Mermaid
|
|
516
|
+
or a chart library for those.
|
|
460
517
|
|
|
461
518
|
## How we measured
|
|
462
519
|
|
|
@@ -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,6 @@
|
|
|
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
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 });
|
|
@@ -34,9 +34,9 @@ export function checkSpec(fig) {
|
|
|
34
34
|
for (const h of b.hops)
|
|
35
35
|
if (!knownEdges.has(h.edge))
|
|
36
36
|
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 ?? [])])
|
|
37
|
+
for (const id of [...Object.keys(b.show ?? {}), ...(b.light ?? []), ...(b.focus ?? [])])
|
|
38
38
|
if (!known.has(id))
|
|
39
|
-
out.push(err('unknown-id', [id], `${where}: show or
|
|
39
|
+
out.push(err('unknown-id', [id], `${where}: show, light or focus names box "${id}", which does not exist`));
|
|
40
40
|
}
|
|
41
41
|
});
|
|
42
42
|
const used = new Set((fig.steps ?? []).flatMap((s) => s.flow.flatMap((b) => toBeat(b).hops.map((h) => h.edge))));
|
|
@@ -48,6 +48,61 @@ export function checkSpec(fig) {
|
|
|
48
48
|
for (const [who, source] of owners(fig))
|
|
49
49
|
if (source != null && !parseSource(source))
|
|
50
50
|
out.push(warn('bad-source', [], `${who}: source "${source}" is not path or path#symbol`));
|
|
51
|
+
const marks = nodes(fig.layout).map((n) => n.mark);
|
|
52
|
+
const starts = marks.filter((m) => m === 'start').length;
|
|
53
|
+
const ends = marks.filter((m) => m === 'end').length;
|
|
54
|
+
if (starts > 1 || (starts && !ends))
|
|
55
|
+
out.push(warn('mark-count', [], `a lifecycle has one start and at least one end: ${starts} start, ${ends} end`));
|
|
56
|
+
if (fig.timeline && fig.lanes)
|
|
57
|
+
out.push(warn('timeline-and-lanes', [], 'the figure sets timeline and lanes; the renderers draw the timeline and ignore lanes'));
|
|
58
|
+
if (fig.timeline) {
|
|
59
|
+
if (!isLanesLayout(fig.layout))
|
|
60
|
+
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'));
|
|
61
|
+
for (const n of nodes(fig.layout)) {
|
|
62
|
+
if (n.from == null)
|
|
63
|
+
out.push(err('timeline-need-from', [n.id], `box "${n.id}" has no from date, and a timeline needs one`));
|
|
64
|
+
const [f, t] = [n.from == null ? null : dayOf(n.from), n.to == null ? null : dayOf(n.to)];
|
|
65
|
+
for (const [name, v, d] of [
|
|
66
|
+
['from', n.from, f],
|
|
67
|
+
['to', n.to, t],
|
|
68
|
+
])
|
|
69
|
+
if (v != null && d == null)
|
|
70
|
+
out.push(err('bad-date', [n.id], `box "${n.id}": ${name} "${v}" is not a real YYYY-MM-DD date`));
|
|
71
|
+
if (f != null && t != null && t < f)
|
|
72
|
+
out.push(err('bad-date', [n.id], `box "${n.id}": to ${n.to} is before from ${n.from}`));
|
|
73
|
+
}
|
|
74
|
+
// A dependent item starts after its source ends. A milestone ends the day it starts.
|
|
75
|
+
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))]]));
|
|
76
|
+
for (const e of fig.edges) {
|
|
77
|
+
const start = span.get(e.to)?.[0];
|
|
78
|
+
const end = span.get(e.from)?.[1];
|
|
79
|
+
if (start != null && end != null && start <= end)
|
|
80
|
+
out.push(warn('timeline-dependency-order', [e.from, e.to], `"${e.to}" does not start after "${e.from}" ends`));
|
|
81
|
+
}
|
|
82
|
+
if (fig.today != null && dayOf(fig.today) == null)
|
|
83
|
+
out.push(err('bad-date', [], `today "${fig.today}" is not a real YYYY-MM-DD date`));
|
|
84
|
+
}
|
|
85
|
+
else if (fig.lanes) {
|
|
86
|
+
const top = fig.layout;
|
|
87
|
+
if (!isLanesLayout(top))
|
|
88
|
+
out.push(err('lanes-need-column', [], 'lanes need a column group of labeled groups, one per lane, with boxes only inside'));
|
|
89
|
+
else {
|
|
90
|
+
const cols = laneColumns(fig);
|
|
91
|
+
for (const lane of top.children) {
|
|
92
|
+
const at = new Map();
|
|
93
|
+
for (const n of lane.children) {
|
|
94
|
+
const c = cols.get(n.id);
|
|
95
|
+
const other = at.get(c);
|
|
96
|
+
if (other != null)
|
|
97
|
+
out.push(warn('lane-column-taken', [other, n.id], `boxes "${other}" and "${n.id}" share column ${c} in lane "${str(lane.label)}"`));
|
|
98
|
+
at.set(c, n.id);
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
for (const n of nodes(fig.layout))
|
|
104
|
+
if (n.at != null && !validAt(n.at))
|
|
105
|
+
out.push(err('bad-at', [n.id], `box "${n.id}": at ${n.at} is not an integer of 0 or more`));
|
|
51
106
|
return out;
|
|
52
107
|
}
|
|
53
108
|
/** A point on the cubic Bézier at `t`. */
|
|
@@ -70,6 +125,8 @@ export function checkScene(scene, { width = 830, minText = 10 } = {}) {
|
|
|
70
125
|
out.push(err('text-overflow', [b.id], `box "${b.id}": "${t.text}" needs ${Math.ceil(need)} px, has ${Math.floor(t.room)} px`));
|
|
71
126
|
}
|
|
72
127
|
for (const e of scene.edges) {
|
|
128
|
+
if (e.behind)
|
|
129
|
+
continue;
|
|
73
130
|
const pts = Array.from({ length: 33 }, (_, i) => at(e.curve, i / 32));
|
|
74
131
|
for (const b of scene.boxes)
|
|
75
132
|
if (b.id !== e.from && b.id !== e.to && pts.some((p) => inside(p, b.rect, 2)))
|