flowfig 0.2.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/dist/cli.js CHANGED
@@ -20,23 +20,35 @@ var __rewriteRelativeImportExtension = (this && this.__rewriteRelativeImportExte
20
20
  * flowfig docs # print the full guide (Markdown)
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
+ * 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)
23
26
  *
24
27
  * A render checks first and writes nothing on an error (--no-check skips that). --strict makes warnings errors; --width and
25
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).
26
30
  *
27
31
  * Every rendered SVG carries its own spec in <metadata>, so a figure is editable later without anyone having to keep the JSON.
28
32
  */
29
- import { readFileSync, writeFileSync } from 'node:fs';
30
- import { resolve } from 'node:path';
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';
31
37
  import { pathToFileURL } from 'node:url';
38
+ import { captureFrames, findBrowser, launch } from './browser.js';
39
+ import { delays, encodeGif } from './gif.js';
32
40
  import { GUIDE } from './guide.js';
41
+ import { runDraw } from './draw.js';
33
42
  import { runInit } from './init.js';
34
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';
35
47
  import { diff, formatDiff } from './diff.js';
36
48
  import { loadSpec, reportLines, sortFindings, specOf, svgWithSpec } from './load.js';
37
- import { check } from './svg.js';
49
+ import { check, toSvg } from './svg.js';
38
50
  import { links, verify } from './verify.js';
39
- 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 -
40
52
  flowfig check <-|spec.json|figure.ts|figure.svg> list the faults; the input can be an SVG this wrote
41
53
  flowfig --spec figure.svg print the spec the SVG carries
42
54
  flowfig verify <input>... [--root <dir>] [--json] [--strict] check the code links of one or more figures
@@ -44,6 +56,9 @@ const USAGE = `usage: flowfig <-|spec.json|figure.ts> [out.svg] render a figur
44
56
  flowfig docs print the guide (Markdown)
45
57
  flowfig mcp serve check, render, verify, diff and docs over MCP (stdio)
46
58
  flowfig init [dir] [--agents <ids>] [-y] [--global] [--dry-run] [--no-mcp] write flowfig instructions for the coding agents of a repo
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
47
62
  flags for render and check: --strict (warnings are errors), --json, --width <px>, --min-text <px>, --no-check (render only)`;
48
63
  /** Bad use, not a bad figure: exit 2 with a message, not a stack trace. A declaration, so TypeScript narrows after a call. */
49
64
  function usage(message) {
@@ -81,6 +96,10 @@ if (args[0] === 'mcp') {
81
96
  }
82
97
  if (args[0] === 'init')
83
98
  process.exit(await runInit(args.slice(1)));
99
+ if (args[0] === 'draw')
100
+ process.exit(await runDraw(args.slice(1)));
101
+ if (args[0] === 'open')
102
+ process.exit(await runOpen(args.slice(1)));
84
103
  if (args[0] === '--spec') {
85
104
  if (!args[1])
86
105
  usage('--spec needs the path of an SVG');
@@ -154,8 +173,155 @@ if (args[0] === 'diff') {
154
173
  console.log(json ? JSON.stringify(changes, null, 2) : formatDiff(changes, md ? 'md' : 'text'));
155
174
  process.exit(0);
156
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
+ }
157
321
  const command = args[0] === 'check' ? args.shift() : 'render';
158
- 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');
159
325
  const opts = { width: value('--width'), minText: value('--min-text') };
160
326
  const unknown = args.find((a) => a.startsWith('-') && a !== '-');
161
327
  if (unknown)
@@ -189,3 +355,12 @@ const dest = out ?? (input === '-' ? 'figure.svg' : input.replace(/\.[^./\\]+$/,
189
355
  const withSpec = svgWithSpec(props);
190
356
  writeFileSync(dest, withSpec);
191
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
@@ -23,7 +23,7 @@ function compare(kind, before, after, fieldsOf) {
23
23
  return out;
24
24
  }
25
25
  const messages = (fig) => new Map((fig.steps ?? [])
26
- .flatMap((s) => s.flow.flatMap((b) => toBeat(b).hops.map((h) => `${str(s.label)}: ${h.edge}${h.back ? ' back' : ''}${h.data != null ? ` "${str(h.data)}"` : ''}`)))
26
+ .flatMap((s) => s.flow.flatMap((b) => toBeat(b).hops.map((h) => `${str(s.label)}: ${h.edge}${h.back ? ' back' : ''}${h.data != null ? ` "${str(h.data)}"` : ''}${h.tone ? ` [${h.tone}]` : ''}`)))
27
27
  .map((id) => [id, id]));
28
28
  export function diff(before, after) {
29
29
  const boxes = (f) => {
@@ -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', '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.d.ts ADDED
@@ -0,0 +1,12 @@
1
+ /** A file name from the question: lower case, letters and digits, joined by -, at most 60 characters. */
2
+ export declare function slug(question: string): string;
3
+ /** The claude argv after the binary. The system prompt carries the agent text, the output path and the folder rule. */
4
+ export declare function agentArgs(o: {
5
+ question: string;
6
+ out: string;
7
+ cwd: string;
8
+ maxTurns: number;
9
+ model?: string;
10
+ }): string[];
11
+ /** Run `flowfig draw`. Prints, and returns the exit code. */
12
+ export declare function runDraw(argv: string[]): Promise<number>;
package/dist/draw.js ADDED
@@ -0,0 +1,195 @@
1
+ // `flowfig draw`: one command to the first figure. Claude Code reads the repo and renders; draw checks the result itself.
2
+ import { spawnSync } from 'node:child_process';
3
+ import { existsSync, mkdirSync, rmSync, statSync } from 'node:fs';
4
+ import { dirname, resolve } from 'node:path';
5
+ import { AGENT_TEXT } from './guide.js';
6
+ import { loadSpec, reportLines, sortFindings } from './load.js';
7
+ import { openedLine, openSvg } from './open.js';
8
+ import { check } from './svg.js';
9
+ import { verify } from './verify.js';
10
+ const ALLOWED = 'Read,Glob,Grep,Bash(npx flowfig *)';
11
+ const DISALLOWED = 'Edit,MultiEdit,NotebookEdit';
12
+ const USAGE = 'usage: flowfig draw "<question>" [--out <path>] [--model <alias>] [--max-turns <n>] [--json] [--open]';
13
+ /** A file name from the question: lower case, letters and digits, joined by -, at most 60 characters. */
14
+ export function slug(question) {
15
+ const s = question
16
+ .toLowerCase()
17
+ .match(/[a-z0-9]+/g)
18
+ ?.join('-')
19
+ .slice(0, 60)
20
+ .replace(/-$/, '');
21
+ return s || 'figure';
22
+ }
23
+ const specPath = (out) => out.replace(/\.svg$/, '.json');
24
+ /** The claude argv after the binary. The system prompt carries the agent text, the output path and the folder rule. */
25
+ export function agentArgs(o) {
26
+ const spec = specPath(o.out);
27
+ const system = `${AGENT_TEXT.trimEnd()}\n\nWrite the spec to ${spec} with the Write tool. Then run exactly \`npx flowfig ${spec} ${o.out}\` as one command, alone on its line: no heredoc, no \`;\`, no \`&&\`, no pipe (the permission rule matches one plain command only). Then run \`npx flowfig verify ${o.out}\` the same way. Run every command from this folder, ${o.cwd}.`;
28
+ const args = [
29
+ '-p',
30
+ o.question,
31
+ '--output-format',
32
+ 'json',
33
+ '--max-turns',
34
+ String(o.maxTurns),
35
+ '--permission-mode',
36
+ 'acceptEdits',
37
+ '--allowedTools',
38
+ ALLOWED,
39
+ '--disallowedTools',
40
+ DISALLOWED,
41
+ '--append-system-prompt',
42
+ system,
43
+ ];
44
+ if (o.model)
45
+ args.push('--model', o.model);
46
+ return args;
47
+ }
48
+ class Bad extends Error {
49
+ }
50
+ const fail = (message, code) => {
51
+ console.error(message);
52
+ return code;
53
+ };
54
+ /** Run `flowfig draw`. Prints, and returns the exit code. */
55
+ export async function runDraw(argv) {
56
+ const args = [...argv];
57
+ const flag = (n) => args.includes(n) && args.splice(args.indexOf(n), 1).length > 0;
58
+ const value = (n) => {
59
+ const i = args.indexOf(n);
60
+ if (i === -1)
61
+ return undefined;
62
+ const v = args.splice(i, 2)[1];
63
+ if (v === undefined)
64
+ throw new Bad(`${n} needs a value`);
65
+ return v;
66
+ };
67
+ let json, open, outArg, model, turns;
68
+ try {
69
+ json = flag('--json');
70
+ open = flag('--open');
71
+ outArg = value('--out');
72
+ model = value('--model');
73
+ turns = value('--max-turns');
74
+ }
75
+ catch (e) {
76
+ if (e instanceof Bad)
77
+ return fail(`draw: ${e.message}\n${USAGE}`, 2);
78
+ throw e;
79
+ }
80
+ const maxTurns = turns === undefined ? 40 : Number(turns);
81
+ if (!Number.isInteger(maxTurns) || maxTurns < 1)
82
+ return fail(`draw: --max-turns needs a positive whole number\n${USAGE}`, 2);
83
+ const bad = args.find((a) => a.startsWith('-'));
84
+ if (bad)
85
+ return fail(`draw: unknown flag ${bad}\n${USAGE}`, 2);
86
+ const question = args.join(' ').trim();
87
+ if (!question)
88
+ return fail(`draw needs a question\n${USAGE}`, 2);
89
+ if (process.platform === 'win32')
90
+ return fail('draw does not run on Windows yet', 2);
91
+ const bin = process.env.FLOWFIG_CLAUDE_BIN || 'claude';
92
+ const version = spawnSync(bin, ['--version'], { encoding: 'utf8' });
93
+ if (version.error || version.status !== 0)
94
+ return fail('draw needs Claude Code: npm i -g @anthropic-ai/claude-code', 2);
95
+ const cwd = process.cwd();
96
+ const out = outArg ?? `${slug(question)}.svg`;
97
+ if (!out.endsWith('.svg'))
98
+ return fail(`draw: --out needs a .svg path\n${USAGE}`, 2);
99
+ const full = resolve(cwd, out);
100
+ try {
101
+ mkdirSync(dirname(full), { recursive: true });
102
+ }
103
+ catch (e) {
104
+ return fail(`draw: ${e.message}`, 2);
105
+ }
106
+ // A figure from an earlier run must not pass as this run's work: compare the mtime.
107
+ const mtime = () => (existsSync(full) ? statSync(full).mtimeMs : undefined);
108
+ const before = mtime();
109
+ const run = spawnSync(bin, agentArgs({ question, out, cwd, maxTurns, model }), {
110
+ cwd,
111
+ encoding: 'utf8',
112
+ stdio: ['ignore', 'pipe', 'inherit'],
113
+ maxBuffer: 64 * 1024 * 1024,
114
+ });
115
+ // The JSON is the last line that starts with {: a warning line may come before it.
116
+ const line = (run.stdout ?? '')
117
+ .split('\n')
118
+ .reverse()
119
+ .find((l) => l.startsWith('{'));
120
+ let reply = '', cost = null, session = null, stopped, denials = [];
121
+ try {
122
+ const parsed = JSON.parse(line ?? '');
123
+ reply = String(parsed.result ?? '');
124
+ cost = typeof parsed.total_cost_usd === 'number' ? parsed.total_cost_usd : null;
125
+ session = typeof parsed.session_id === 'string' ? parsed.session_id : null;
126
+ if (parsed.is_error === true)
127
+ stopped = String(parsed.subtype ?? '?');
128
+ if (Array.isArray(parsed.permission_denials))
129
+ denials = parsed.permission_denials;
130
+ }
131
+ catch {
132
+ process.stderr.write(run.stdout ?? '');
133
+ const message = `claude failed (exit ${run.status ?? 'null'})`;
134
+ if (json)
135
+ console.log(JSON.stringify({ out, reply, cost, session, findings: [], error: message }, null, 2));
136
+ return fail(`draw: ${message}`, 1);
137
+ }
138
+ const stop = () => {
139
+ if (stopped !== undefined)
140
+ console.error(`draw: claude stopped (${stopped})`);
141
+ };
142
+ const after = mtime();
143
+ if (after === undefined || after === before) {
144
+ const message = `the agent wrote no figure at ${out}`;
145
+ if (json)
146
+ console.log(JSON.stringify({ out, reply, cost, session, findings: [], error: message }, null, 2));
147
+ else
148
+ console.log(reply);
149
+ for (const d of denials)
150
+ console.error(`denied: ${d.tool_name ?? '?'} ${d.tool_input?.command ?? d.tool_input?.file_path ?? ''}`.trimEnd());
151
+ stop();
152
+ return fail(`draw: ${message}`, 1);
153
+ }
154
+ let props;
155
+ try {
156
+ props = loadSpec(full);
157
+ }
158
+ catch (e) {
159
+ stop();
160
+ const message = e.message;
161
+ if (json)
162
+ console.log(JSON.stringify({ out, reply, cost, session, findings: [], error: message }, null, 2));
163
+ return fail(`draw: ${message}`, 1);
164
+ }
165
+ const findings = sortFindings([...check(props), ...verify(props, { root: cwd })], true);
166
+ const errors = findings.length > 0;
167
+ if (!errors && stopped === undefined)
168
+ rmSync(specPath(full), { force: true });
169
+ if (json)
170
+ console.log(JSON.stringify({ out, reply, cost, session, findings }, null, 2));
171
+ else {
172
+ for (const l of reportLines(props, findings))
173
+ console.log(l);
174
+ console.log('');
175
+ console.log(reply);
176
+ console.log(`agent: claude, $${cost === null ? '?' : cost.toFixed(4)}, session ${session ?? '?'}`);
177
+ }
178
+ stop();
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;
195
+ }
@@ -4,6 +4,7 @@ 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;
@@ -20,5 +21,7 @@ export declare function route(edges: {
20
21
  from: string;
21
22
  to: string;
22
23
  around?: Around;
23
- }[], rects: Record<string, Rect>, tips?: Set<string>): Routed[];
24
+ sides?: [Side, Side];
25
+ elbow?: boolean;
26
+ }[], rects: Record<string, Rect>, tips?: Set<string>, avoid?: Rect[]): Routed[];
24
27
  export {};
package/dist/geometry.js CHANGED
@@ -4,25 +4,29 @@ const cy = (r) => r.y + r.h / 2;
4
4
  // Boxes stacked on top of each other connect bottom->top, otherwise side->side.
5
5
  // Several edges leaving the same side of a box are spread out so they don't overlap,
6
6
  // except on `tips` boxes (diamonds), where they all meet at the point.
7
+ // `elbow` draws a right-angle path, from a right end to a left end: its four curve points are the corners.
8
+ // `sides` fixes the two sides an edge uses (a timeline uses right to left).
7
9
  // `around` makes an edge leave and enter from the top or bottom, arcing over whatever sits between.
8
- export function route(edges, rects, tips = new Set()) {
10
+ export function route(edges, rects, tips = new Set(), avoid = []) {
9
11
  const picks = [];
10
12
  for (const e of edges) {
11
13
  const a = rects[e.from], b = rects[e.to];
12
14
  if (!a || !b)
13
15
  continue;
14
16
  const stacked = a.x < b.x + b.w && b.x < a.x + a.w;
15
- const [sa, sb] = e.around
16
- ? e.around === 'above'
17
- ? ['t', 't']
18
- : ['b', 'b']
19
- : stacked
20
- ? a.y < b.y
21
- ? ['b', 't']
22
- : ['t', 'b']
23
- : a.x < b.x
24
- ? ['r', 'l']
25
- : ['l', 'r'];
17
+ const [sa, sb] = e.sides
18
+ ? e.sides
19
+ : e.around
20
+ ? e.around === 'above'
21
+ ? ['t', 't']
22
+ : ['b', 'b']
23
+ : stacked
24
+ ? a.y < b.y
25
+ ? ['b', 't']
26
+ : ['t', 'b']
27
+ : a.x < b.x
28
+ ? ['r', 'l']
29
+ : ['l', 'r'];
26
30
  picks.push({ ...e, a, b, sa, sb });
27
31
  }
28
32
  // For every (box, side), list the edge ends that sit there, sorted by where the other end is.
@@ -65,6 +69,29 @@ export function route(edges, rects, tips = new Set()) {
65
69
  };
66
70
  return picks.map((p) => {
67
71
  const s = anchor.get(p.id + ':s'), e = anchor.get(p.id + ':e');
72
+ if (p.elbow) {
73
+ // 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
74
+ // before the end, then forward into the target. A gap of 0 px or less takes the same detour.
75
+ // 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`.
76
+ const y0 = Math.min(s.y, e.y), y1 = Math.max(s.y, e.y);
77
+ const clear = (x) => !avoid.some((r) => x > r.x - 2 && x < r.x + r.w + 2 && y1 > r.y && y0 < r.y + r.h);
78
+ 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;
79
+ const c1 = { x: mx, y: s.y }, c2 = { x: mx, y: e.y };
80
+ if (e.x - s.x < 16 && s.y === e.y) {
81
+ // One row: a straight line, since a detour would only double back.
82
+ 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] };
83
+ }
84
+ if (e.x - s.x < 16) {
85
+ const ex = e.x - 8, my = (s.y + e.y) / 2;
86
+ return {
87
+ id: p.id,
88
+ d: `M ${s.x} ${s.y} H ${mx} V ${my} H ${ex} V ${e.y} H ${e.x}`,
89
+ mid: { x: (mx + ex) / 2, y: my },
90
+ curve: [s, { x: mx, y: my }, { x: ex, y: my }, e],
91
+ };
92
+ }
93
+ 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] };
94
+ }
68
95
  if (p.around) {
69
96
  // ponytail: arcs 50px past the two ends' boxes; a taller box in between can still be crossed.
70
97
  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;
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>;