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/dist/cli.js
CHANGED
|
@@ -21,24 +21,34 @@ var __rewriteRelativeImportExtension = (this && this.__rewriteRelativeImportExte
|
|
|
21
21
|
* flowfig mcp # serve check, render, verify, diff and docs over MCP (stdio)
|
|
22
22
|
* flowfig init [dir] # write flowfig instructions for the coding agents of a repo
|
|
23
23
|
* flowfig draw "<question>" [--out out.svg] # ask Claude Code for a figure, then check it
|
|
24
|
+
* flowfig open figure.svg # show the figure in the default browser
|
|
25
|
+
* flowfig gif figure.svg [out.gif] # write an animated GIF of the figure (needs Chrome, Edge, Chromium or Brave)
|
|
24
26
|
*
|
|
25
27
|
* A render checks first and writes nothing on an error (--no-check skips that). --strict makes warnings errors; --width and
|
|
26
28
|
* --min-text set the page width and the smallest text the reader should get.
|
|
29
|
+
* --open opens the rendered SVG in the default browser (render and draw).
|
|
27
30
|
*
|
|
28
31
|
* Every rendered SVG carries its own spec in <metadata>, so a figure is editable later without anyone having to keep the JSON.
|
|
29
32
|
*/
|
|
30
|
-
import {
|
|
31
|
-
import {
|
|
33
|
+
import { spawn, spawnSync } from 'node:child_process';
|
|
34
|
+
import { existsSync, mkdtempSync, readFileSync, rmSync, statSync, writeFileSync } from 'node:fs';
|
|
35
|
+
import { tmpdir } from 'node:os';
|
|
36
|
+
import { basename, dirname, join, resolve } from 'node:path';
|
|
32
37
|
import { pathToFileURL } from 'node:url';
|
|
38
|
+
import { captureFrames, findBrowser, launch } from './browser.js';
|
|
39
|
+
import { delays, encodeGif } from './gif.js';
|
|
33
40
|
import { GUIDE } from './guide.js';
|
|
34
41
|
import { runDraw } from './draw.js';
|
|
35
42
|
import { runInit } from './init.js';
|
|
36
43
|
import { serve } from './mcp.js';
|
|
44
|
+
import { openedLine, openSvg, runOpen } from './open.js';
|
|
45
|
+
import { pageHtml } from './page.js';
|
|
46
|
+
import { decodePng } from './png.js';
|
|
37
47
|
import { diff, formatDiff } from './diff.js';
|
|
38
48
|
import { loadSpec, reportLines, sortFindings, specOf, svgWithSpec } from './load.js';
|
|
39
|
-
import { check } from './svg.js';
|
|
49
|
+
import { check, toSvg } from './svg.js';
|
|
40
50
|
import { links, verify } from './verify.js';
|
|
41
|
-
const USAGE = `usage: flowfig <-|spec.json|figure.ts> [out.svg] render a figure; a spec on stdin with -
|
|
51
|
+
const USAGE = `usage: flowfig <-|spec.json|figure.ts> [out.svg] [--open] render a figure; a spec on stdin with -
|
|
42
52
|
flowfig check <-|spec.json|figure.ts|figure.svg> list the faults; the input can be an SVG this wrote
|
|
43
53
|
flowfig --spec figure.svg print the spec the SVG carries
|
|
44
54
|
flowfig verify <input>... [--root <dir>] [--json] [--strict] check the code links of one or more figures
|
|
@@ -46,7 +56,9 @@ const USAGE = `usage: flowfig <-|spec.json|figure.ts> [out.svg] render a figur
|
|
|
46
56
|
flowfig docs print the guide (Markdown)
|
|
47
57
|
flowfig mcp serve check, render, verify, diff and docs over MCP (stdio)
|
|
48
58
|
flowfig init [dir] [--agents <ids>] [-y] [--global] [--dry-run] [--no-mcp] write flowfig instructions for the coding agents of a repo
|
|
49
|
-
flowfig draw "<question>" [--out <path>] [--model <alias>] [--max-turns <n>] [--json] ask Claude Code for a figure, then check it
|
|
59
|
+
flowfig draw "<question>" [--out <path>] [--model <alias>] [--max-turns <n>] [--json] [--open] ask Claude Code for a figure, then check it
|
|
60
|
+
flowfig open <figure.svg> [--html <path>] show the figure in the default browser
|
|
61
|
+
flowfig gif <figure.svg> [out.gif] [--step <n>] [--dark] [--fps <n>] [--scale <n>] [--mp4] write an animated GIF
|
|
50
62
|
flags for render and check: --strict (warnings are errors), --json, --width <px>, --min-text <px>, --no-check (render only)`;
|
|
51
63
|
/** Bad use, not a bad figure: exit 2 with a message, not a stack trace. A declaration, so TypeScript narrows after a call. */
|
|
52
64
|
function usage(message) {
|
|
@@ -86,6 +98,8 @@ if (args[0] === 'init')
|
|
|
86
98
|
process.exit(await runInit(args.slice(1)));
|
|
87
99
|
if (args[0] === 'draw')
|
|
88
100
|
process.exit(await runDraw(args.slice(1)));
|
|
101
|
+
if (args[0] === 'open')
|
|
102
|
+
process.exit(await runOpen(args.slice(1)));
|
|
89
103
|
if (args[0] === '--spec') {
|
|
90
104
|
if (!args[1])
|
|
91
105
|
usage('--spec needs the path of an SVG');
|
|
@@ -159,8 +173,155 @@ if (args[0] === 'diff') {
|
|
|
159
173
|
console.log(json ? JSON.stringify(changes, null, 2) : formatDiff(changes, md ? 'md' : 'text'));
|
|
160
174
|
process.exit(0);
|
|
161
175
|
}
|
|
176
|
+
if (args[0] === 'gif') {
|
|
177
|
+
args.shift();
|
|
178
|
+
const dark = flag('--dark'), mp4 = flag('--mp4');
|
|
179
|
+
const fps = value('--fps') ?? 20, scale = value('--scale') ?? 2, step = value('--step');
|
|
180
|
+
if (!Number.isInteger(fps) || fps > 50)
|
|
181
|
+
usage('--fps needs a whole number from 1 to 50');
|
|
182
|
+
const unknown = args.find((a) => a.startsWith('-'));
|
|
183
|
+
if (unknown)
|
|
184
|
+
usage(`unknown flag ${unknown}`);
|
|
185
|
+
const [path, outArg] = args;
|
|
186
|
+
if (!path)
|
|
187
|
+
usage('usage: flowfig gif <figure.svg> [out.gif] [--step <n>] [--dark] [--fps <n>] [--scale <n>] [--mp4]');
|
|
188
|
+
if (!path.endsWith('.svg'))
|
|
189
|
+
usage(`${path}: expected a .svg path`);
|
|
190
|
+
let svg = '';
|
|
191
|
+
try {
|
|
192
|
+
svg = readFileSync(path, 'utf8');
|
|
193
|
+
}
|
|
194
|
+
catch (e) {
|
|
195
|
+
usage(`${path}: ${e.message}`);
|
|
196
|
+
}
|
|
197
|
+
if (step !== undefined) {
|
|
198
|
+
// One step only: render the spec again with that step, so the loop holds that step alone.
|
|
199
|
+
const props = (() => {
|
|
200
|
+
try {
|
|
201
|
+
return loadSpec(path);
|
|
202
|
+
}
|
|
203
|
+
catch (e) {
|
|
204
|
+
return usage(e.message);
|
|
205
|
+
}
|
|
206
|
+
})();
|
|
207
|
+
const count = props.steps?.length ?? 0;
|
|
208
|
+
if (!count)
|
|
209
|
+
usage(`${path}: --step needs a figure with steps; this figure has 0`);
|
|
210
|
+
if (!Number.isInteger(step) || step > count)
|
|
211
|
+
usage(`--step needs a whole number from 1 to ${count}`);
|
|
212
|
+
svg = toSvg({ ...props, steps: [props.steps[step - 1]] });
|
|
213
|
+
}
|
|
214
|
+
const out = outArg ?? path.replace(/\.svg$/, step === undefined ? '.gif' : `-step${step}.gif`);
|
|
215
|
+
const size = /<svg\b[^>]*?\swidth="([\d.]+)"[^>]*?\sheight="([\d.]+)"/.exec(svg);
|
|
216
|
+
if (!size)
|
|
217
|
+
usage(`${path}: no width and height on the <svg> element`);
|
|
218
|
+
if (!existsSync(dirname(resolve(out))))
|
|
219
|
+
usage(`${out}: the folder does not exist`);
|
|
220
|
+
const found = findBrowser({ platform: process.platform, env: process.env, exists: existsSync });
|
|
221
|
+
if (!found.path)
|
|
222
|
+
usage(['gif needs Chrome, Edge, Chromium or Brave. Checked:', ...found.checked, 'Set CHROME_PATH to the browser program.'].join('\n'));
|
|
223
|
+
const line = (file, frames, bytes) => `${file} — ${frames} frame${frames === 1 ? '' : 's'}, ${(frames / fps).toFixed(1)} s, ${(bytes / 1048576).toFixed(1)} MB`;
|
|
224
|
+
const temp = mkdtempSync(join(tmpdir(), 'flowfig-gif-'));
|
|
225
|
+
// A Ctrl-C can come while the browser starts, so cleanup waits for the start before it stops the browser.
|
|
226
|
+
let launching;
|
|
227
|
+
let cleaning;
|
|
228
|
+
const cleanup = () => (cleaning ??= (async () => {
|
|
229
|
+
await (await launching?.catch(() => undefined))?.close();
|
|
230
|
+
try {
|
|
231
|
+
// The retries cover Windows, where the browser can hold a file lock for a short time after the exit.
|
|
232
|
+
rmSync(temp, { recursive: true, force: true, maxRetries: 3 });
|
|
233
|
+
}
|
|
234
|
+
catch (e) {
|
|
235
|
+
console.error(`warning: could not remove ${temp}: ${e.message}`);
|
|
236
|
+
}
|
|
237
|
+
})());
|
|
238
|
+
// The output files of this run: a stop removes them, so no partial file stays.
|
|
239
|
+
const written = [];
|
|
240
|
+
let ffmpeg, ffmpegDone = Promise.resolve(), stopped = 0;
|
|
241
|
+
const yieldLoop = () => new Promise((done) => setImmediate(done));
|
|
242
|
+
// The handlers stay for the whole run: a second signal during the cleanup must not end the process before the cleanup ends.
|
|
243
|
+
const onSignal = (signal) => {
|
|
244
|
+
if (stopped)
|
|
245
|
+
return;
|
|
246
|
+
stopped = signal === 'SIGINT' ? 130 : 143;
|
|
247
|
+
ffmpeg?.kill('SIGKILL');
|
|
248
|
+
void ffmpegDone
|
|
249
|
+
.then(() => written.forEach((f) => rmSync(f, { force: true })))
|
|
250
|
+
.then(cleanup)
|
|
251
|
+
.finally(() => process.exit(stopped));
|
|
252
|
+
};
|
|
253
|
+
process.on('SIGINT', onSignal);
|
|
254
|
+
process.on('SIGTERM', onSignal);
|
|
255
|
+
// The encode and the ffmpeg run give the event loop a turn, so a signal handler can run. A write comes straight after this check.
|
|
256
|
+
const proceed = async () => {
|
|
257
|
+
await yieldLoop();
|
|
258
|
+
if (stopped)
|
|
259
|
+
throw new Error('stopped');
|
|
260
|
+
};
|
|
261
|
+
let code = 0;
|
|
262
|
+
try {
|
|
263
|
+
launching = launch(found.path, join(temp, 'profile'));
|
|
264
|
+
const browser = await launching;
|
|
265
|
+
const viewport = { width: Math.ceil(Number(size[1])), height: Math.ceil(Number(size[2])) };
|
|
266
|
+
const { pngs } = await captureFrames(browser.cdp, pageHtml(svg, basename(path)), { ...viewport, scale, fps, dark });
|
|
267
|
+
const wait = delays(pngs.length, fps);
|
|
268
|
+
// The GIF keeps the PNG bytes, not the pixels: a decoded 2400 x 1600 frame is 15 MB.
|
|
269
|
+
const gif = await encodeGif(pngs.map((png, i) => ({ load: () => decodePng(png), delay: wait[i], same: i > 0 && png.equals(pngs[i - 1]) })));
|
|
270
|
+
await proceed();
|
|
271
|
+
written.push(out);
|
|
272
|
+
writeFileSync(out, gif);
|
|
273
|
+
console.log(line(out, pngs.length, gif.length));
|
|
274
|
+
if (gif.length > 10485760)
|
|
275
|
+
console.error(`warning: ${out} is ${(gif.length / 1048576).toFixed(1)} MB, over 10 MB. Try --step <n>, a lower --fps or a lower --scale.`);
|
|
276
|
+
if (mp4) {
|
|
277
|
+
const mp4Path = out.replace(/(\.gif)?$/, '.mp4');
|
|
278
|
+
// FLOWFIG_FFMPEG replaces the PATH lookup, so a test can hide ffmpeg and keep the full PATH.
|
|
279
|
+
const ffmpegBin = process.env.FLOWFIG_FFMPEG ?? 'ffmpeg';
|
|
280
|
+
if (spawnSync(ffmpegBin, ['-version']).error)
|
|
281
|
+
console.error('gif: no ffmpeg on the PATH, so no MP4. The GIF is written.');
|
|
282
|
+
else {
|
|
283
|
+
await proceed();
|
|
284
|
+
written.push(mp4Path);
|
|
285
|
+
// yuv420p needs an even width and height, so the pad filter adds a pixel where needed.
|
|
286
|
+
ffmpeg = spawn(ffmpegBin, [
|
|
287
|
+
...['-y', '-loglevel', 'error', '-f', 'image2pipe', '-framerate', String(fps), '-c:v', 'png', '-i', '-'],
|
|
288
|
+
...['-vf', 'pad=ceil(iw/2)*2:ceil(ih/2)*2', '-c:v', 'libx264', '-pix_fmt', 'yuv420p', mp4Path],
|
|
289
|
+
]);
|
|
290
|
+
let stderr = '';
|
|
291
|
+
ffmpeg.stderr.on('data', (d) => (stderr += d));
|
|
292
|
+
ffmpeg.stdin.on('error', () => { }); // ffmpeg can exit before it reads all the input; the exit code reports that
|
|
293
|
+
ffmpeg.stdin.end(Buffer.concat(pngs));
|
|
294
|
+
const status = await (ffmpegDone = new Promise((done) => {
|
|
295
|
+
ffmpeg.once('error', () => done(-1));
|
|
296
|
+
ffmpeg.once('close', done);
|
|
297
|
+
}));
|
|
298
|
+
await proceed();
|
|
299
|
+
if (status !== 0) {
|
|
300
|
+
rmSync(mp4Path, { force: true });
|
|
301
|
+
throw new Error(`ffmpeg failed: ${stderr.trim()}`);
|
|
302
|
+
}
|
|
303
|
+
console.log(line(mp4Path, pngs.length, statSync(mp4Path).size));
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
catch (e) {
|
|
308
|
+
if (!stopped)
|
|
309
|
+
console.error(`gif: ${e.message}`);
|
|
310
|
+
code = 1;
|
|
311
|
+
}
|
|
312
|
+
finally {
|
|
313
|
+
await cleanup();
|
|
314
|
+
}
|
|
315
|
+
// A signal that came in the last turn runs its handler here, before the exit.
|
|
316
|
+
await yieldLoop();
|
|
317
|
+
if (stopped)
|
|
318
|
+
written.forEach((f) => rmSync(f, { force: true }));
|
|
319
|
+
process.exit(stopped || code);
|
|
320
|
+
}
|
|
162
321
|
const command = args[0] === 'check' ? args.shift() : 'render';
|
|
163
|
-
const json = flag('--json'), strict = flag('--strict'), skip = flag('--no-check');
|
|
322
|
+
const json = flag('--json'), strict = flag('--strict'), skip = flag('--no-check'), open = flag('--open');
|
|
323
|
+
if (command === 'check' && open)
|
|
324
|
+
usage('--open works with a render, not with check');
|
|
164
325
|
const opts = { width: value('--width'), minText: value('--min-text') };
|
|
165
326
|
const unknown = args.find((a) => a.startsWith('-') && a !== '-');
|
|
166
327
|
if (unknown)
|
|
@@ -191,6 +352,15 @@ if (errors)
|
|
|
191
352
|
process.exit(1);
|
|
192
353
|
const dest = out ?? (input === '-' ? 'figure.svg' : input.replace(/\.[^./\\]+$/, '') + '.svg');
|
|
193
354
|
// The spec rides along in <metadata>: an SVG is then its own source, and no JSON has to be kept.
|
|
194
|
-
const withSpec = svgWithSpec(props);
|
|
355
|
+
const withSpec = svgWithSpec(props, opts);
|
|
195
356
|
writeFileSync(dest, withSpec);
|
|
196
357
|
console.log(`${dest} — ${(withSpec.length / 1024).toFixed(1)} kB`);
|
|
358
|
+
if (open) {
|
|
359
|
+
try {
|
|
360
|
+
console.log(openedLine((await openSvg(dest))[0]));
|
|
361
|
+
}
|
|
362
|
+
catch (e) {
|
|
363
|
+
console.error(`open: ${e.message}`);
|
|
364
|
+
process.exit(1);
|
|
365
|
+
}
|
|
366
|
+
}
|
package/dist/diff.js
CHANGED
|
@@ -36,7 +36,7 @@ export function diff(before, after) {
|
|
|
36
36
|
const edges = (f) => new Map(f.edges.map((e) => [edgeId(e), e]));
|
|
37
37
|
const steps = (f) => new Map((f.steps ?? []).map((s) => [str(s.label), s]));
|
|
38
38
|
const out = [
|
|
39
|
-
...compare('box', boxes(before), boxes(after), (a, b) => fields(a, b, ['label', 'sub', 'shape', 'tone', 'source', 'group'])),
|
|
39
|
+
...compare('box', boxes(before), boxes(after), (a, b) => fields(a, b, ['label', 'sub', 'shape', 'tone', 'mark', 'from', 'to', 'source', 'group'])),
|
|
40
40
|
...compare('edge', edges(before), edges(after), (a, b) => fields(a, b, ['from', 'to', 'label', 'source'])),
|
|
41
41
|
...compare('step', steps(before), steps(after), () => []),
|
|
42
42
|
...compare('message', messages(before), messages(after), () => []),
|
package/dist/draw.js
CHANGED
|
@@ -4,11 +4,12 @@ import { existsSync, mkdirSync, rmSync, statSync } from 'node:fs';
|
|
|
4
4
|
import { dirname, resolve } from 'node:path';
|
|
5
5
|
import { AGENT_TEXT } from './guide.js';
|
|
6
6
|
import { loadSpec, reportLines, sortFindings } from './load.js';
|
|
7
|
+
import { openedLine, openSvg } from './open.js';
|
|
7
8
|
import { check } from './svg.js';
|
|
8
9
|
import { verify } from './verify.js';
|
|
9
10
|
const ALLOWED = 'Read,Glob,Grep,Bash(npx flowfig *)';
|
|
10
11
|
const DISALLOWED = 'Edit,MultiEdit,NotebookEdit';
|
|
11
|
-
const USAGE = 'usage: flowfig draw "<question>" [--out <path>] [--model <alias>] [--max-turns <n>] [--json]';
|
|
12
|
+
const USAGE = 'usage: flowfig draw "<question>" [--out <path>] [--model <alias>] [--max-turns <n>] [--json] [--open]';
|
|
12
13
|
/** A file name from the question: lower case, letters and digits, joined by -, at most 60 characters. */
|
|
13
14
|
export function slug(question) {
|
|
14
15
|
const s = question
|
|
@@ -63,9 +64,10 @@ export async function runDraw(argv) {
|
|
|
63
64
|
throw new Bad(`${n} needs a value`);
|
|
64
65
|
return v;
|
|
65
66
|
};
|
|
66
|
-
let json, outArg, model, turns;
|
|
67
|
+
let json, open, outArg, model, turns;
|
|
67
68
|
try {
|
|
68
69
|
json = flag('--json');
|
|
70
|
+
open = flag('--open');
|
|
69
71
|
outArg = value('--out');
|
|
70
72
|
model = value('--model');
|
|
71
73
|
turns = value('--max-turns');
|
|
@@ -174,5 +176,20 @@ export async function runDraw(argv) {
|
|
|
174
176
|
console.log(`agent: claude, $${cost === null ? '?' : cost.toFixed(4)}, session ${session ?? '?'}`);
|
|
175
177
|
}
|
|
176
178
|
stop();
|
|
177
|
-
|
|
179
|
+
let code = errors || stopped !== undefined ? 1 : 0;
|
|
180
|
+
// The SVG is on disk also with findings, so the user can look at it.
|
|
181
|
+
if (open) {
|
|
182
|
+
try {
|
|
183
|
+
const line = openedLine((await openSvg(full))[0]);
|
|
184
|
+
if (json)
|
|
185
|
+
console.error(line);
|
|
186
|
+
else
|
|
187
|
+
console.log(line);
|
|
188
|
+
}
|
|
189
|
+
catch (e) {
|
|
190
|
+
console.error(`open: ${e.message}`);
|
|
191
|
+
code = 1;
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
return code;
|
|
178
195
|
}
|
package/dist/geometry.d.ts
CHANGED
|
@@ -4,15 +4,26 @@ export type Rect = {
|
|
|
4
4
|
w: number;
|
|
5
5
|
h: number;
|
|
6
6
|
};
|
|
7
|
+
export type Side = 'l' | 'r' | 't' | 'b';
|
|
7
8
|
export type Pt = {
|
|
8
9
|
x: number;
|
|
9
10
|
y: number;
|
|
10
11
|
};
|
|
12
|
+
/** A cross-block edge of wrapped lanes: the two drawn stubs, their pills, and their points for `check`. `d` then holds both stubs,
|
|
13
|
+
* so a packet runs the source stub and jumps to the target stub. */
|
|
14
|
+
export type Stub = {
|
|
15
|
+
parts: [string, string];
|
|
16
|
+
pills: [Rect, Rect];
|
|
17
|
+
pts: [Pt[], Pt[]];
|
|
18
|
+
short?: true;
|
|
19
|
+
tight: [boolean, boolean];
|
|
20
|
+
};
|
|
11
21
|
export type Routed = {
|
|
12
22
|
id: string;
|
|
13
23
|
d: string;
|
|
14
24
|
mid: Pt;
|
|
15
25
|
curve: [Pt, Pt, Pt, Pt];
|
|
26
|
+
stub?: Stub;
|
|
16
27
|
};
|
|
17
28
|
type Around = 'above' | 'below';
|
|
18
29
|
export declare function route(edges: {
|
|
@@ -20,5 +31,13 @@ export declare function route(edges: {
|
|
|
20
31
|
from: string;
|
|
21
32
|
to: string;
|
|
22
33
|
around?: Around;
|
|
23
|
-
|
|
34
|
+
sides?: [Side, Side];
|
|
35
|
+
elbow?: boolean;
|
|
36
|
+
stub?: number[];
|
|
37
|
+
bands?: [Rect | undefined, Rect | undefined];
|
|
38
|
+
labelW?: number;
|
|
39
|
+
}[], rects: Record<string, Rect>, tips?: Set<string>, avoid?: Rect[], // what an elbow's vertical run and a stub pill must not cross (the timeline's outside labels; the boxes and gutters of lanes)
|
|
40
|
+
area?: Rect): Routed[];
|
|
41
|
+
/** True if the segment a-b crosses the segment c-d. The ends of a-b are trimmed by 1.5 px, so a touch at a box side does not count. */
|
|
42
|
+
export declare function crosses(a: Pt, b: Pt, c: Pt, d: Pt): boolean;
|
|
24
43
|
export {};
|
package/dist/geometry.js
CHANGED
|
@@ -1,28 +1,45 @@
|
|
|
1
|
+
import { STUB } from './model.js';
|
|
1
2
|
const cx = (r) => r.x + r.w / 2;
|
|
2
3
|
const cy = (r) => r.y + r.h / 2;
|
|
4
|
+
/** A point on the cubic Bézier at `t`, as `check` samples an edge. */
|
|
5
|
+
const bezier = ([p0, p1, p2, p3], t) => {
|
|
6
|
+
const u = 1 - t;
|
|
7
|
+
const [a, b, c, d] = [u * u * u, 3 * u * u * t, 3 * u * t * t, t * t * t];
|
|
8
|
+
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 };
|
|
9
|
+
};
|
|
3
10
|
// Turns edges between measured boxes into curved SVG paths.
|
|
4
11
|
// Boxes stacked on top of each other connect bottom->top, otherwise side->side.
|
|
5
12
|
// Several edges leaving the same side of a box are spread out so they don't overlap,
|
|
6
13
|
// except on `tips` boxes (diamonds), where they all meet at the point.
|
|
14
|
+
// `elbow` draws a right-angle path, from a right end to a left end: its four curve points are the corners.
|
|
15
|
+
// `sides` fixes the two sides an edge uses (a timeline uses right to left).
|
|
7
16
|
// `around` makes an edge leave and enter from the top or bottom, arcing over whatever sits between.
|
|
8
|
-
|
|
17
|
+
// `bands` (with `stub`) are the lane bands of the source and of the target: each pill stays inside its band, 4 px clear of the border.
|
|
18
|
+
// `labelW` is the width of an edge's label pill: a stub pill keeps clear of it.
|
|
19
|
+
// `stub` (wrapped lanes: the source pill width, the target pill width, and the width of a shorter source pill) draws a short stub from the source to a pill, and from a second pill into the target.
|
|
20
|
+
export function route(edges, rects, tips = new Set(), avoid = [], // what an elbow's vertical run and a stub pill must not cross (the timeline's outside labels; the boxes and gutters of lanes)
|
|
21
|
+
area) {
|
|
9
22
|
const picks = [];
|
|
10
23
|
for (const e of edges) {
|
|
11
24
|
const a = rects[e.from], b = rects[e.to];
|
|
12
25
|
if (!a || !b)
|
|
13
26
|
continue;
|
|
14
27
|
const stacked = a.x < b.x + b.w && b.x < a.x + a.w;
|
|
15
|
-
const [sa, sb] = e.
|
|
16
|
-
? e.
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
28
|
+
const [sa, sb] = e.sides
|
|
29
|
+
? e.sides
|
|
30
|
+
: e.stub
|
|
31
|
+
? ['r', 'l']
|
|
32
|
+
: e.around
|
|
33
|
+
? e.around === 'above'
|
|
34
|
+
? ['t', 't']
|
|
35
|
+
: ['b', 'b']
|
|
36
|
+
: stacked
|
|
37
|
+
? a.y < b.y
|
|
38
|
+
? ['b', 't']
|
|
39
|
+
: ['t', 'b']
|
|
40
|
+
: a.x < b.x
|
|
41
|
+
? ['r', 'l']
|
|
42
|
+
: ['l', 'r'];
|
|
26
43
|
picks.push({ ...e, a, b, sa, sb });
|
|
27
44
|
}
|
|
28
45
|
// For every (box, side), list the edge ends that sit there, sorted by where the other end is.
|
|
@@ -63,8 +80,154 @@ export function route(edges, rects, tips = new Set()) {
|
|
|
63
80
|
const [s, c1, c2, e] = curve;
|
|
64
81
|
return { id, d: `M ${s.x} ${s.y} C ${c1.x} ${c1.y}, ${c2.x} ${c2.y}, ${e.x} ${e.y}`, mid, curve };
|
|
65
82
|
};
|
|
66
|
-
|
|
83
|
+
// Stub pills are placed after the other edges. Each pill keeps clear of `avoid`, of the edge labels, of the pills and stubs
|
|
84
|
+
// before it, and of every edge path (sampled as `check` samples it).
|
|
85
|
+
const pills = [];
|
|
86
|
+
const paths = [];
|
|
87
|
+
// The same samples as segments: a stub line must not cross one.
|
|
88
|
+
const runs = [];
|
|
89
|
+
const track = (pts) => {
|
|
90
|
+
paths.push(...pts);
|
|
91
|
+
pts.slice(1).forEach((q, i) => runs.push([pts[i], q]));
|
|
92
|
+
};
|
|
93
|
+
const inBand = (r, b) => !b || (r.x >= b.x + 4 && r.x + r.w <= b.x + b.w - 4 && r.y >= b.y + 4 && r.y + r.h <= b.y + b.h - 4);
|
|
94
|
+
const clear = (r, band) => inBand(r, band) &&
|
|
95
|
+
(!area || (r.x >= area.x && r.x + r.w <= area.x + area.w && r.y >= area.y && r.y + r.h <= area.y + area.h)) &&
|
|
96
|
+
![...avoid, ...pills].some((q) => r.x < q.x + q.w + 2 && q.x < r.x + r.w + 2 && r.y < q.y + q.h + 2 && q.y < r.y + r.h + 2) &&
|
|
97
|
+
!paths.some((q) => q.x > r.x - 2 && q.x < r.x + r.w + 2 && q.y > r.y - 2 && q.y < r.y + r.h + 2);
|
|
98
|
+
const one = (p) => {
|
|
67
99
|
const s = anchor.get(p.id + ':s'), e = anchor.get(p.id + ':e');
|
|
100
|
+
if (p.stub) {
|
|
101
|
+
// The source pill goes right of the source and the target pill left of the target, at the nearest clear place: it slides
|
|
102
|
+
// away from the box in 8 px steps, and up or down inside the band in 4 px steps. The stub then runs on a slant. The last
|
|
103
|
+
// places are below and above the box. A place is clear if the pill and its stub keep clear of everything, and the pill stays
|
|
104
|
+
// inside its band. If no place is clear, the source pill tries its shorter text. If no place is clear then, the first place
|
|
105
|
+
// stays and `check` reports it.
|
|
106
|
+
const [ow, iw, sw] = p.stub;
|
|
107
|
+
const [sb, tb] = p.bands ?? [];
|
|
108
|
+
const rows = (box, y0, band) => {
|
|
109
|
+
const ys = band
|
|
110
|
+
? Array.from({ length: Math.max(0, Math.floor((band.h - 26) / 4)) + 1 }, (_, i) => band.y + 4 + i * 4)
|
|
111
|
+
: [box.y + box.h + 2, box.y - 20];
|
|
112
|
+
return [y0, ...ys.filter((y) => y !== y0)];
|
|
113
|
+
};
|
|
114
|
+
// A place and its stub. Beside the box, the stub runs from the box side to the near end of the pill. Under or over the box,
|
|
115
|
+
// it runs straight down or up between the box and the pill. `dir` is 1 for the source (box to pill), -1 for the target.
|
|
116
|
+
const place = (box, from, dir, r, side, tip) => {
|
|
117
|
+
let a, b;
|
|
118
|
+
if (side)
|
|
119
|
+
[a, b] = [from, { x: dir > 0 ? r.x : r.x + r.w, y: r.y + 9 }];
|
|
120
|
+
else {
|
|
121
|
+
// A diamond has its point at the middle: the stub meets the point.
|
|
122
|
+
const x = tip ? cx(box) : Math.min(Math.max(r.x + r.w / 2, box.x + 8, r.x + 8), box.x + box.w - 8, r.x + r.w - 8);
|
|
123
|
+
const under = r.y >= box.y + box.h;
|
|
124
|
+
[a, b] = [
|
|
125
|
+
{ x, y: under ? box.y + box.h : box.y },
|
|
126
|
+
{ x, y: under ? r.y : r.y + r.h },
|
|
127
|
+
];
|
|
128
|
+
}
|
|
129
|
+
return dir > 0 ? [r, a, b] : [r, b, a];
|
|
130
|
+
};
|
|
131
|
+
const beside = (w, box, from, dir, band) => {
|
|
132
|
+
const tip = tips.has(dir > 0 ? p.from : p.to);
|
|
133
|
+
const out = [];
|
|
134
|
+
for (let dx = STUB - 8 * Math.ceil((box.w + w) / 8); dx <= STUB + 240; dx += 8)
|
|
135
|
+
for (const y of rows(box, from.y - 9, band)) {
|
|
136
|
+
// Closer than STUB to the box side, a pill sits under or over the box, with room for the stub.
|
|
137
|
+
const x = dir > 0 ? from.x + dx : from.x - dx - w;
|
|
138
|
+
if (dx < STUB && !(y >= box.y + box.h + STUB / 2 || y + 18 <= box.y - STUB / 2))
|
|
139
|
+
continue;
|
|
140
|
+
if (dx < STUB && tip && !(x + 8 <= cx(box) && cx(box) <= x + w - 8))
|
|
141
|
+
continue;
|
|
142
|
+
out.push([...place(box, from, dir, { x, y, w, h: 18 }, dx >= STUB, tip), Math.abs(dx - STUB) + Math.abs(y + 9 - from.y)]);
|
|
143
|
+
}
|
|
144
|
+
return out.sort((m, q) => m[3] - q[3]).map(([r, a, b]) => [r, a, b]);
|
|
145
|
+
};
|
|
146
|
+
const outs = (w) => [
|
|
147
|
+
...beside(w, p.a, s, 1, sb),
|
|
148
|
+
[
|
|
149
|
+
{ x: cx(p.a) - w / 2, y: p.a.y + p.a.h + STUB, w, h: 18 },
|
|
150
|
+
{ x: cx(p.a), y: p.a.y + p.a.h },
|
|
151
|
+
{ x: cx(p.a), y: p.a.y + p.a.h + STUB },
|
|
152
|
+
],
|
|
153
|
+
[
|
|
154
|
+
{ x: cx(p.a) - w / 2, y: p.a.y - STUB - 18, w, h: 18 },
|
|
155
|
+
{ x: cx(p.a), y: p.a.y },
|
|
156
|
+
{ x: cx(p.a), y: p.a.y - STUB },
|
|
157
|
+
],
|
|
158
|
+
];
|
|
159
|
+
const ins = [
|
|
160
|
+
...beside(iw, p.b, e, -1, tb),
|
|
161
|
+
[
|
|
162
|
+
{ x: cx(p.b) - iw / 2, y: p.b.y - STUB - 18, w: iw, h: 18 },
|
|
163
|
+
{ x: cx(p.b), y: p.b.y - STUB },
|
|
164
|
+
{ x: cx(p.b), y: p.b.y },
|
|
165
|
+
],
|
|
166
|
+
[
|
|
167
|
+
{ x: cx(p.b) - iw / 2, y: p.b.y + p.b.h + STUB, w: iw, h: 18 },
|
|
168
|
+
{ x: cx(p.b), y: p.b.y + p.b.h + STUB },
|
|
169
|
+
{ x: cx(p.b), y: p.b.y + p.b.h },
|
|
170
|
+
],
|
|
171
|
+
];
|
|
172
|
+
const seg = (a, b) => Array.from({ length: 9 }, (_, i) => ({ x: a.x + ((b.x - a.x) * i) / 8, y: a.y + ((b.y - a.y) * i) / 8 }));
|
|
173
|
+
// A stub must not run through a box other than its own, nor through a pill or a label, nor cross an edge path.
|
|
174
|
+
const free = (a, b, own) => seg(a, b).every((q) => ![...avoid, ...pills].some((r) => r !== own && q.x > r.x + 2 && q.x < r.x + r.w - 2 && q.y > r.y + 2 && q.y < r.y + r.h - 2));
|
|
175
|
+
const whole = (a, b) => !runs.some(([c, d]) => crosses(a, b, c, d));
|
|
176
|
+
const fits = (own, band, strict) => ([r, a, b]) => clear(r, band) && free(a, b, own) && (!strict || whole(a, b));
|
|
177
|
+
// The first place whose stub crosses no edge path. If none, the first place that clears everything else: in a dense
|
|
178
|
+
// figure no place may avoid every edge, and `check` then warns about the crossing.
|
|
179
|
+
const first = (own, band, lists) => [true, false].flatMap((strict) => lists.map((l, i) => [l.find(fits(own, band, strict)), i, !strict])).find(([x]) => x);
|
|
180
|
+
const hit = first(p.a, sb, [outs(ow), ...(sw == null ? [] : [outs(sw)])]);
|
|
181
|
+
const cut = hit?.[1] === 1;
|
|
182
|
+
const [po, o1, o2] = hit?.[0] ?? outs(ow)[0];
|
|
183
|
+
pills.push(po);
|
|
184
|
+
track(seg(o1, o2));
|
|
185
|
+
const hit2 = first(p.b, tb, [ins]);
|
|
186
|
+
const [pi, i1, i2] = hit2?.[0] ?? ins[0];
|
|
187
|
+
pills.push(pi);
|
|
188
|
+
track(seg(i1, i2));
|
|
189
|
+
const line = (a, b) => `M ${a.x} ${a.y} L ${b.x} ${b.y}`;
|
|
190
|
+
const parts = [line(o1, o2), line(i1, i2)];
|
|
191
|
+
return {
|
|
192
|
+
id: p.id,
|
|
193
|
+
d: parts.join(' '),
|
|
194
|
+
mid: { x: po.x + po.w / 2, y: po.y + 9 },
|
|
195
|
+
curve: [o1, o1, i2, i2],
|
|
196
|
+
stub: {
|
|
197
|
+
parts,
|
|
198
|
+
pills: [po, pi],
|
|
199
|
+
pts: [
|
|
200
|
+
[o1, o2],
|
|
201
|
+
[i1, i2],
|
|
202
|
+
],
|
|
203
|
+
tight: [hit?.[2] !== false, hit2?.[2] !== false],
|
|
204
|
+
...(cut && { short: true }),
|
|
205
|
+
},
|
|
206
|
+
};
|
|
207
|
+
}
|
|
208
|
+
if (p.elbow) {
|
|
209
|
+
// The vertical run sits at the midpoint x. Under 16 px of gap, it detours: out 8 px past the start, back to 8 px
|
|
210
|
+
// before the end, then forward into the target. A gap of 0 px or less takes the same detour.
|
|
211
|
+
// Over 16 px, it tries the midpoint, then 8 px before the end, then 8 px after the start, and takes the first that clears `avoid`.
|
|
212
|
+
const y0 = Math.min(s.y, e.y), y1 = Math.max(s.y, e.y);
|
|
213
|
+
const clear = (x) => !avoid.some((r) => x > r.x - 2 && x < r.x + r.w + 2 && y1 > r.y && y0 < r.y + r.h);
|
|
214
|
+
const mx = e.x - s.x >= 16 ? ([(s.x + e.x) / 2, e.x - 8, s.x + 8].find(clear) ?? (s.x + e.x) / 2) : s.x + 8;
|
|
215
|
+
const c1 = { x: mx, y: s.y }, c2 = { x: mx, y: e.y };
|
|
216
|
+
if (e.x - s.x < 16 && s.y === e.y) {
|
|
217
|
+
// One row: a straight line, since a detour would only double back.
|
|
218
|
+
return { id: p.id, d: `M ${s.x} ${s.y} H ${e.x}`, mid: { x: (s.x + e.x) / 2, y: s.y }, curve: [s, s, e, e] };
|
|
219
|
+
}
|
|
220
|
+
if (e.x - s.x < 16) {
|
|
221
|
+
const ex = e.x - 8, my = (s.y + e.y) / 2;
|
|
222
|
+
return {
|
|
223
|
+
id: p.id,
|
|
224
|
+
d: `M ${s.x} ${s.y} H ${mx} V ${my} H ${ex} V ${e.y} H ${e.x}`,
|
|
225
|
+
mid: { x: (mx + ex) / 2, y: my },
|
|
226
|
+
curve: [s, { x: mx, y: my }, { x: ex, y: my }, e],
|
|
227
|
+
};
|
|
228
|
+
}
|
|
229
|
+
return { id: p.id, d: `M ${s.x} ${s.y} H ${mx} V ${e.y} H ${e.x}`, mid: { x: mx, y: (s.y + e.y) / 2 }, curve: [s, c1, c2, e] };
|
|
230
|
+
}
|
|
68
231
|
if (p.around) {
|
|
69
232
|
// ponytail: arcs 50px past the two ends' boxes; a taller box in between can still be crossed.
|
|
70
233
|
const y = p.around === 'above' ? Math.min(p.a.y, p.b.y) - 50 : Math.max(p.a.y + p.a.h, p.b.y + p.b.h) + 50;
|
|
@@ -76,5 +239,32 @@ export function route(edges, rects, tips = new Set()) {
|
|
|
76
239
|
const c1 = horiz ? { x: s.x + sign * k, y: s.y } : { x: s.x, y: s.y + sign * k };
|
|
77
240
|
const c2 = horiz ? { x: e.x - sign * k, y: e.y } : { x: e.x, y: e.y - sign * k };
|
|
78
241
|
return drawn(p.id, [s, c1, c2, e], { x: (s.x + e.x) / 2, y: (s.y + e.y) / 2 });
|
|
79
|
-
}
|
|
242
|
+
};
|
|
243
|
+
const done = new Map();
|
|
244
|
+
for (const p of picks) {
|
|
245
|
+
if (p.stub)
|
|
246
|
+
continue;
|
|
247
|
+
const r = one(p);
|
|
248
|
+
done.set(p, r);
|
|
249
|
+
if (p.labelW)
|
|
250
|
+
pills.push({ x: r.mid.x - p.labelW / 2, y: r.mid.y - 9, w: p.labelW, h: 18 });
|
|
251
|
+
track(Array.from({ length: 33 }, (_, i) => bezier(r.curve, i / 32)));
|
|
252
|
+
}
|
|
253
|
+
for (const p of picks)
|
|
254
|
+
if (p.stub)
|
|
255
|
+
done.set(p, one(p));
|
|
256
|
+
return picks.map((p) => done.get(p));
|
|
257
|
+
}
|
|
258
|
+
/** True if the segment a-b crosses the segment c-d. The ends of a-b are trimmed by 1.5 px, so a touch at a box side does not count. */
|
|
259
|
+
export function crosses(a, b, c, d) {
|
|
260
|
+
const len = Math.hypot(b.x - a.x, b.y - a.y);
|
|
261
|
+
if (len < 4)
|
|
262
|
+
return false;
|
|
263
|
+
const t = 1.5 / len;
|
|
264
|
+
const [p, q] = [
|
|
265
|
+
{ x: a.x + (b.x - a.x) * t, y: a.y + (b.y - a.y) * t },
|
|
266
|
+
{ x: b.x - (b.x - a.x) * t, y: b.y - (b.y - a.y) * t },
|
|
267
|
+
];
|
|
268
|
+
const side = (o, u, v) => Math.sign((u.x - o.x) * (v.y - o.y) - (u.y - o.y) * (v.x - o.x));
|
|
269
|
+
return side(p, q, c) * side(p, q, d) < 0 && side(c, d, p) * side(c, d, q) < 0;
|
|
80
270
|
}
|
package/dist/gif.d.ts
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import type { Image } from './png.ts';
|
|
2
|
+
/** One frame: `load` returns the pixels (called twice per frame); `delay` is in centiseconds.
|
|
3
|
+
* `same` says the pixels equal the frame before: the encoder then never calls `load`. */
|
|
4
|
+
export type GifFrame = {
|
|
5
|
+
load: () => Image;
|
|
6
|
+
delay: number;
|
|
7
|
+
same?: boolean;
|
|
8
|
+
};
|
|
9
|
+
/** The delay of each of `count` frames in cs. The sum is round(count * 100 / fps). */
|
|
10
|
+
export declare function delays(count: number, fps: number): number[];
|
|
11
|
+
/** An animated GIF that loops forever. All frames have the size of the first frame. */
|
|
12
|
+
export declare function encodeGif(frames: GifFrame[]): Promise<Uint8Array>;
|