flowfig 0.5.0 → 0.7.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 +185 -114
- package/dist/browser.d.ts +1 -3
- package/dist/browser.js +72 -60
- package/dist/check.d.ts +5 -3
- package/dist/check.js +28 -21
- package/dist/cli.js +33 -47
- package/dist/diff.js +0 -4
- package/dist/draw.d.ts +1 -3
- package/dist/draw.js +3 -7
- package/dist/geometry.d.ts +13 -5
- package/dist/geometry.js +164 -70
- package/dist/gif.d.ts +1 -2
- package/dist/gif.js +115 -42
- package/dist/guide.d.ts +1 -4
- package/dist/guide.js +7 -4
- package/dist/index.d.ts +2 -2
- package/dist/index.js +69 -105
- package/dist/init.d.ts +3 -13
- package/dist/init.js +17 -19
- package/dist/load.d.ts +1 -3
- package/dist/load.js +2 -5
- package/dist/mcp.d.ts +1 -2
- package/dist/mcp.js +9 -12
- package/dist/model.d.ts +57 -108
- package/dist/model.js +21 -88
- package/dist/open.d.ts +2 -5
- package/dist/open.js +6 -11
- package/dist/page.d.ts +0 -1
- package/dist/page.js +1 -3
- package/dist/picker.d.ts +2 -4
- package/dist/picker.js +11 -14
- package/dist/png.js +9 -10
- package/dist/rail.d.ts +1 -3
- package/dist/rail.js +2 -16
- package/dist/scene.d.ts +9 -7
- package/dist/source.d.ts +0 -1
- package/dist/source.js +2 -2
- package/dist/svg.d.ts +5 -8
- package/dist/svg.js +81 -121
- package/dist/text.d.ts +0 -2
- package/dist/text.js +1 -4
- package/dist/verify.d.ts +1 -1
- package/dist/verify.js +3 -6
- package/dist/version.d.ts +1 -2
- package/dist/version.js +2 -2
- package/package.json +4 -2
package/dist/draw.js
CHANGED
|
@@ -1,4 +1,3 @@
|
|
|
1
|
-
// `flowfig draw`: one command to the first figure. Claude Code reads the repo and renders; draw checks the result itself.
|
|
2
1
|
import { spawnSync } from 'node:child_process';
|
|
3
2
|
import { existsSync, mkdirSync, rmSync, statSync } from 'node:fs';
|
|
4
3
|
import { dirname, resolve } from 'node:path';
|
|
@@ -10,7 +9,7 @@ import { verify } from './verify.js';
|
|
|
10
9
|
const ALLOWED = 'Read,Glob,Grep,Bash(npx flowfig *)';
|
|
11
10
|
const DISALLOWED = 'Edit,MultiEdit,NotebookEdit';
|
|
12
11
|
const USAGE = 'usage: flowfig draw "<question>" [--out <path>] [--model <alias>] [--max-turns <n>] [--json] [--open]';
|
|
13
|
-
/** A file name from the question:
|
|
12
|
+
/** A file name from the question: at most 60 characters. */
|
|
14
13
|
export function slug(question) {
|
|
15
14
|
const s = question
|
|
16
15
|
.toLowerCase()
|
|
@@ -21,7 +20,6 @@ export function slug(question) {
|
|
|
21
20
|
return s || 'figure';
|
|
22
21
|
}
|
|
23
22
|
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
23
|
export function agentArgs(o) {
|
|
26
24
|
const spec = specPath(o.out);
|
|
27
25
|
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}.`;
|
|
@@ -51,7 +49,6 @@ const fail = (message, code) => {
|
|
|
51
49
|
console.error(message);
|
|
52
50
|
return code;
|
|
53
51
|
};
|
|
54
|
-
/** Run `flowfig draw`. Prints, and returns the exit code. */
|
|
55
52
|
export async function runDraw(argv) {
|
|
56
53
|
const args = [...argv];
|
|
57
54
|
const flag = (n) => args.includes(n) && args.splice(args.indexOf(n), 1).length > 0;
|
|
@@ -103,7 +100,7 @@ export async function runDraw(argv) {
|
|
|
103
100
|
catch (e) {
|
|
104
101
|
return fail(`draw: ${e.message}`, 2);
|
|
105
102
|
}
|
|
106
|
-
//
|
|
103
|
+
// Compare the mtime: a figure from an earlier run must not pass.
|
|
107
104
|
const mtime = () => (existsSync(full) ? statSync(full).mtimeMs : undefined);
|
|
108
105
|
const before = mtime();
|
|
109
106
|
const run = spawnSync(bin, agentArgs({ question, out, cwd, maxTurns, model }), {
|
|
@@ -112,7 +109,7 @@ export async function runDraw(argv) {
|
|
|
112
109
|
stdio: ['ignore', 'pipe', 'inherit'],
|
|
113
110
|
maxBuffer: 64 * 1024 * 1024,
|
|
114
111
|
});
|
|
115
|
-
// The JSON is the last line that starts with {
|
|
112
|
+
// The JSON is the last line that starts with {; a warning can come before.
|
|
116
113
|
const line = (run.stdout ?? '')
|
|
117
114
|
.split('\n')
|
|
118
115
|
.reverse()
|
|
@@ -177,7 +174,6 @@ export async function runDraw(argv) {
|
|
|
177
174
|
}
|
|
178
175
|
stop();
|
|
179
176
|
let code = errors || stopped !== undefined ? 1 : 0;
|
|
180
|
-
// The SVG is on disk also with findings, so the user can look at it.
|
|
181
177
|
if (open) {
|
|
182
178
|
try {
|
|
183
179
|
const line = openedLine((await openSvg(full))[0]);
|
package/dist/geometry.d.ts
CHANGED
|
@@ -9,8 +9,7 @@ export type Pt = {
|
|
|
9
9
|
x: number;
|
|
10
10
|
y: number;
|
|
11
11
|
};
|
|
12
|
-
/** A cross-
|
|
13
|
-
* so a packet runs the source stub and jumps to the target stub. */
|
|
12
|
+
/** A cross-lane edge as two stubs, their pills and their points. */
|
|
14
13
|
export type Stub = {
|
|
15
14
|
parts: [string, string];
|
|
16
15
|
pills: [Rect, Rect];
|
|
@@ -18,14 +17,21 @@ export type Stub = {
|
|
|
18
17
|
short?: true;
|
|
19
18
|
tight: [boolean, boolean];
|
|
20
19
|
};
|
|
20
|
+
/** `elbow` holds the corners of an elbow path for `check`. */
|
|
21
21
|
export type Routed = {
|
|
22
22
|
id: string;
|
|
23
23
|
d: string;
|
|
24
24
|
mid: Pt;
|
|
25
25
|
curve: [Pt, Pt, Pt, Pt];
|
|
26
26
|
stub?: Stub;
|
|
27
|
+
elbow?: Pt[];
|
|
28
|
+
};
|
|
29
|
+
/** A rect that a route keeps clear of. `box` marks a box. */
|
|
30
|
+
export type Avoid = Rect & {
|
|
31
|
+
box?: boolean;
|
|
27
32
|
};
|
|
28
33
|
type Around = 'above' | 'below';
|
|
34
|
+
/** Routes edges between measured boxes as curved SVG paths. `elbow` draws right-angle paths; `stub` splits a cross-lane edge in two. */
|
|
29
35
|
export declare function route(edges: {
|
|
30
36
|
id: string;
|
|
31
37
|
from: string;
|
|
@@ -36,8 +42,10 @@ export declare function route(edges: {
|
|
|
36
42
|
stub?: number[];
|
|
37
43
|
bands?: [Rect | undefined, Rect | undefined];
|
|
38
44
|
labelW?: number;
|
|
39
|
-
}[], rects: Record<string, Rect>, tips?: Set<string>, avoid?:
|
|
40
|
-
|
|
41
|
-
|
|
45
|
+
}[], rects: Record<string, Rect>, tips?: Set<string>, avoid?: Avoid[], area?: Rect, lanes?: {
|
|
46
|
+
bands: Rect[];
|
|
47
|
+
boxes: Rect[];
|
|
48
|
+
}): Routed[];
|
|
49
|
+
/** True if segment a-b crosses c-d. The ends of a-b are trimmed by 1.5 px. */
|
|
42
50
|
export declare function crosses(a: Pt, b: Pt, c: Pt, d: Pt): boolean;
|
|
43
51
|
export {};
|
package/dist/geometry.js
CHANGED
|
@@ -1,24 +1,14 @@
|
|
|
1
1
|
import { STUB } from './model.js';
|
|
2
|
+
const LABEL_CLEAR = 4;
|
|
2
3
|
const cx = (r) => r.x + r.w / 2;
|
|
3
4
|
const cy = (r) => r.y + r.h / 2;
|
|
4
|
-
/** A point on the cubic Bézier at `t`, as `check` samples an edge. */
|
|
5
5
|
const bezier = ([p0, p1, p2, p3], t) => {
|
|
6
6
|
const u = 1 - t;
|
|
7
7
|
const [a, b, c, d] = [u * u * u, 3 * u * u * t, 3 * u * t * t, t * t * t];
|
|
8
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
9
|
};
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
// Several edges leaving the same side of a box are spread out so they don't overlap,
|
|
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).
|
|
16
|
-
// `around` makes an edge leave and enter from the top or bottom, arcing over whatever sits between.
|
|
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) {
|
|
10
|
+
/** Routes edges between measured boxes as curved SVG paths. `elbow` draws right-angle paths; `stub` splits a cross-lane edge in two. */
|
|
11
|
+
export function route(edges, rects, tips = new Set(), avoid = [], area, lanes) {
|
|
22
12
|
const picks = [];
|
|
23
13
|
for (const e of edges) {
|
|
24
14
|
const a = rects[e.from], b = rects[e.to];
|
|
@@ -42,7 +32,6 @@ area) {
|
|
|
42
32
|
: ['l', 'r'];
|
|
43
33
|
picks.push({ ...e, a, b, sa, sb });
|
|
44
34
|
}
|
|
45
|
-
// For every (box, side), list the edge ends that sit there, sorted by where the other end is.
|
|
46
35
|
const ends = new Map();
|
|
47
36
|
for (const pick of picks) {
|
|
48
37
|
for (const start of [true, false]) {
|
|
@@ -61,7 +50,7 @@ area) {
|
|
|
61
50
|
const r = x.start ? x.pick.b : x.pick.a;
|
|
62
51
|
return horiz ? cx(r) : cy(r);
|
|
63
52
|
};
|
|
64
|
-
list.sort((p, q) => other(p) - other(q));
|
|
53
|
+
list.sort((p, q) => other(p) - other(q));
|
|
65
54
|
list.forEach((x, i) => {
|
|
66
55
|
const r = x.start ? x.pick.a : x.pick.b;
|
|
67
56
|
const f = tip ? 0.5 : (i + 1) / (list.length + 1);
|
|
@@ -75,16 +64,12 @@ area) {
|
|
|
75
64
|
anchor.set(x.pick.id + (x.start ? ':s' : ':e'), pt);
|
|
76
65
|
});
|
|
77
66
|
}
|
|
78
|
-
// The four points travel with the path string: `flowfig check` samples the curve, and nothing has to parse `d`.
|
|
79
67
|
const drawn = (id, curve, mid) => {
|
|
80
68
|
const [s, c1, c2, e] = curve;
|
|
81
69
|
return { id, d: `M ${s.x} ${s.y} C ${c1.x} ${c1.y}, ${c2.x} ${c2.y}, ${e.x} ${e.y}`, mid, curve };
|
|
82
70
|
};
|
|
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
71
|
const pills = [];
|
|
86
72
|
const paths = [];
|
|
87
|
-
// The same samples as segments: a stub line must not cross one.
|
|
88
73
|
const runs = [];
|
|
89
74
|
const track = (pts) => {
|
|
90
75
|
paths.push(...pts);
|
|
@@ -98,11 +83,6 @@ area) {
|
|
|
98
83
|
const one = (p) => {
|
|
99
84
|
const s = anchor.get(p.id + ':s'), e = anchor.get(p.id + ':e');
|
|
100
85
|
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
86
|
const [ow, iw, sw] = p.stub;
|
|
107
87
|
const [sb, tb] = p.bands ?? [];
|
|
108
88
|
const rows = (box, y0, band) => {
|
|
@@ -111,14 +91,11 @@ area) {
|
|
|
111
91
|
: [box.y + box.h + 2, box.y - 20];
|
|
112
92
|
return [y0, ...ys.filter((y) => y !== y0)];
|
|
113
93
|
};
|
|
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
94
|
const place = (box, from, dir, r, side, tip) => {
|
|
117
95
|
let a, b;
|
|
118
96
|
if (side)
|
|
119
97
|
[a, b] = [from, { x: dir > 0 ? r.x : r.x + r.w, y: r.y + 9 }];
|
|
120
98
|
else {
|
|
121
|
-
// A diamond has its point at the middle: the stub meets the point.
|
|
122
99
|
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
100
|
const under = r.y >= box.y + box.h;
|
|
124
101
|
[a, b] = [
|
|
@@ -128,62 +105,112 @@ area) {
|
|
|
128
105
|
}
|
|
129
106
|
return dir > 0 ? [r, a, b] : [r, b, a];
|
|
130
107
|
};
|
|
131
|
-
|
|
108
|
+
function* beside(w, box, from, dir, band) {
|
|
132
109
|
const tip = tips.has(dir > 0 ? p.from : p.to);
|
|
110
|
+
const ys = rows(box, from.y - 9, band)
|
|
111
|
+
.map((y, i) => ({ y, d: Math.abs(y + 9 - from.y), i }))
|
|
112
|
+
.sort((m, q) => m.d - q.d || m.i - q.i);
|
|
113
|
+
const heap = [];
|
|
114
|
+
for (let dx = STUB - 8 * Math.ceil((box.w + w) / 8); dx <= STUB + 240; dx += 8) {
|
|
115
|
+
const x = dir > 0 ? from.x + dx : from.x - dx - w;
|
|
116
|
+
if (dx < STUB && tip && !(x + 8 <= cx(box) && cx(box) <= x + w - 8))
|
|
117
|
+
continue;
|
|
118
|
+
if (ys.length)
|
|
119
|
+
heap.push({ dx, x, k: 0, key: Math.abs(dx - STUB) + ys[0].d, i: heap.length });
|
|
120
|
+
}
|
|
121
|
+
// Equal keys must keep the dx order, then the row order, or a pill moves.
|
|
122
|
+
const less = (m, q) => m.key < q.key || (m.key === q.key && m.i < q.i);
|
|
123
|
+
heap.sort((m, q) => m.key - q.key || m.i - q.i);
|
|
124
|
+
while (heap.length) {
|
|
125
|
+
const h = heap[0];
|
|
126
|
+
const { y } = ys[h.k++];
|
|
127
|
+
if (h.k < ys.length)
|
|
128
|
+
h.key = Math.abs(h.dx - STUB) + ys[h.k].d;
|
|
129
|
+
else {
|
|
130
|
+
const last = heap.pop();
|
|
131
|
+
if (heap.length)
|
|
132
|
+
heap[0] = last;
|
|
133
|
+
}
|
|
134
|
+
for (let i = 0;;) {
|
|
135
|
+
const l = 2 * i + 1;
|
|
136
|
+
let m = i;
|
|
137
|
+
if (l < heap.length && less(heap[l], heap[m]))
|
|
138
|
+
m = l;
|
|
139
|
+
if (l + 1 < heap.length && less(heap[l + 1], heap[m]))
|
|
140
|
+
m = l + 1;
|
|
141
|
+
if (m === i)
|
|
142
|
+
break;
|
|
143
|
+
[heap[i], heap[m]] = [heap[m], heap[i]];
|
|
144
|
+
i = m;
|
|
145
|
+
}
|
|
146
|
+
if (h.dx < STUB && !(y >= box.y + box.h + STUB / 2 || y + 18 <= box.y - STUB / 2))
|
|
147
|
+
continue;
|
|
148
|
+
yield place(box, from, dir, { x: h.x, y, w, h: 18 }, h.dx >= STUB, tip);
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
const along = (w, box, from, dir, band) => {
|
|
152
|
+
if (!tips.has(dir > 0 ? p.from : p.to))
|
|
153
|
+
return [];
|
|
133
154
|
const out = [];
|
|
134
|
-
for (
|
|
135
|
-
for (
|
|
136
|
-
|
|
137
|
-
|
|
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))
|
|
155
|
+
for (const y of rows(box, from.y + 12, band))
|
|
156
|
+
for (let x = from.x - w + 8; x <= from.x - 8; x += 8) {
|
|
157
|
+
const down = y >= from.y + 12;
|
|
158
|
+
if (!down && y + 18 > from.y - 12)
|
|
141
159
|
continue;
|
|
142
|
-
|
|
160
|
+
const r = { x, y, w, h: 18 };
|
|
161
|
+
const [a, b] = [from, { x: from.x, y: down ? y : y + 18 }];
|
|
162
|
+
out.push([r, ...(dir > 0 ? [a, b] : [b, a]), Math.abs(y + 9 - from.y) + Math.abs(x + w / 2 - from.x)]);
|
|
143
163
|
}
|
|
144
164
|
return out.sort((m, q) => m[3] - q[3]).map(([r, a, b]) => [r, a, b]);
|
|
145
165
|
};
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
[
|
|
166
|
+
function* outs(w) {
|
|
167
|
+
yield* beside(w, p.a, s, 1, sb);
|
|
168
|
+
yield [
|
|
149
169
|
{ x: cx(p.a) - w / 2, y: p.a.y + p.a.h + STUB, w, h: 18 },
|
|
150
170
|
{ x: cx(p.a), y: p.a.y + p.a.h },
|
|
151
171
|
{ x: cx(p.a), y: p.a.y + p.a.h + STUB },
|
|
152
|
-
]
|
|
153
|
-
[
|
|
172
|
+
];
|
|
173
|
+
yield [
|
|
154
174
|
{ x: cx(p.a) - w / 2, y: p.a.y - STUB - 18, w, h: 18 },
|
|
155
175
|
{ x: cx(p.a), y: p.a.y },
|
|
156
176
|
{ x: cx(p.a), y: p.a.y - STUB },
|
|
157
|
-
]
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
177
|
+
];
|
|
178
|
+
yield* along(w, p.a, s, 1, sb);
|
|
179
|
+
}
|
|
180
|
+
function* ins() {
|
|
181
|
+
yield* beside(iw, p.b, e, -1, tb);
|
|
182
|
+
yield [
|
|
162
183
|
{ x: cx(p.b) - iw / 2, y: p.b.y - STUB - 18, w: iw, h: 18 },
|
|
163
184
|
{ x: cx(p.b), y: p.b.y - STUB },
|
|
164
185
|
{ x: cx(p.b), y: p.b.y },
|
|
165
|
-
]
|
|
166
|
-
[
|
|
186
|
+
];
|
|
187
|
+
yield [
|
|
167
188
|
{ x: cx(p.b) - iw / 2, y: p.b.y + p.b.h + STUB, w: iw, h: 18 },
|
|
168
189
|
{ x: cx(p.b), y: p.b.y + p.b.h + STUB },
|
|
169
190
|
{ x: cx(p.b), y: p.b.y + p.b.h },
|
|
170
|
-
]
|
|
171
|
-
|
|
191
|
+
];
|
|
192
|
+
yield* along(iw, p.b, e, -1, tb);
|
|
193
|
+
}
|
|
172
194
|
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
195
|
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
196
|
const whole = (a, b) => !runs.some(([c, d]) => crosses(a, b, c, d));
|
|
176
197
|
const fits = (own, band, strict) => ([r, a, b]) => clear(r, band) && free(a, b, own) && (!strict || whole(a, b));
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
198
|
+
const first = (own, band, lists) => {
|
|
199
|
+
for (const strict of [true, false]) {
|
|
200
|
+
const ok = fits(own, band, strict);
|
|
201
|
+
for (const [i, list] of lists.entries())
|
|
202
|
+
for (const c of list())
|
|
203
|
+
if (ok(c))
|
|
204
|
+
return [c, i, !strict];
|
|
205
|
+
}
|
|
206
|
+
};
|
|
207
|
+
const hit = first(p.a, sb, [() => outs(ow), ...(sw == null ? [] : [() => outs(sw)])]);
|
|
181
208
|
const cut = hit?.[1] === 1;
|
|
182
|
-
const [po, o1, o2] = hit?.[0] ?? outs(ow)
|
|
209
|
+
const [po, o1, o2] = hit?.[0] ?? outs(ow).next().value;
|
|
183
210
|
pills.push(po);
|
|
184
211
|
track(seg(o1, o2));
|
|
185
212
|
const hit2 = first(p.b, tb, [ins]);
|
|
186
|
-
const [pi, i1, i2] = hit2?.[0] ?? ins
|
|
213
|
+
const [pi, i1, i2] = hit2?.[0] ?? ins().next().value;
|
|
187
214
|
pills.push(pi);
|
|
188
215
|
track(seg(i1, i2));
|
|
189
216
|
const line = (a, b) => `M ${a.x} ${a.y} L ${b.x} ${b.y}`;
|
|
@@ -206,30 +233,72 @@ area) {
|
|
|
206
233
|
};
|
|
207
234
|
}
|
|
208
235
|
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
236
|
if (e.x - s.x < 16 && s.y === e.y) {
|
|
217
|
-
|
|
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] };
|
|
237
|
+
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], elbow: [s, e] };
|
|
219
238
|
}
|
|
220
239
|
if (e.x - s.x < 16) {
|
|
221
|
-
const ex = e.x - 8, my = (s.y + e.y) / 2;
|
|
240
|
+
const mx = s.x + 8, ex = e.x - 8, my = (s.y + e.y) / 2;
|
|
222
241
|
return {
|
|
223
242
|
id: p.id,
|
|
224
243
|
d: `M ${s.x} ${s.y} H ${mx} V ${my} H ${ex} V ${e.y} H ${e.x}`,
|
|
225
244
|
mid: { x: (mx + ex) / 2, y: my },
|
|
226
245
|
curve: [s, { x: mx, y: my }, { x: ex, y: my }, e],
|
|
246
|
+
elbow: [s, { x: mx, y: s.y }, { x: mx, y: my }, { x: ex, y: my }, { x: ex, y: e.y }, e],
|
|
227
247
|
};
|
|
228
248
|
}
|
|
229
|
-
|
|
249
|
+
const own = (r) => [p.a, p.b].some((q) => q.x === r.x && q.y === r.y && q.w === r.w && q.h === r.h);
|
|
250
|
+
const all = avoid.filter((r) => !own(r));
|
|
251
|
+
const boxes = all.filter((r) => r.box);
|
|
252
|
+
const vClear = (x, ya, yb, m = 2) => !all.some((r) => x > r.x - m && x < r.x + r.w + m && Math.max(ya, yb) > r.y && Math.min(ya, yb) < r.y + r.h);
|
|
253
|
+
const hClear = (y, xa, xb, list = boxes, m = 2) => !list.some((r) => y > r.y - m && y < r.y + r.h + m && Math.max(xa, xb) > r.x && Math.min(xa, xb) < r.x + r.w);
|
|
254
|
+
const mid = (s.x + e.x) / 2;
|
|
255
|
+
const gap = Array.from({ length: Math.floor((e.x - s.x - 16) / 4) + 1 }, (_, i) => s.x + 8 + i * 4).sort((u, w) => Math.abs(u - mid) - Math.abs(w - mid));
|
|
256
|
+
const mx = [mid, e.x - 8, s.x + 8, ...gap].find((x) => vClear(x, s.y, e.y) && hClear(s.y, s.x, x) && hClear(e.y, x, e.x));
|
|
257
|
+
if (mx == null && boxes.length) {
|
|
258
|
+
const right = Math.max(...boxes.map((r) => r.x + r.w)) + 12;
|
|
259
|
+
const left = Math.min(...boxes.map((r) => r.x));
|
|
260
|
+
const [top, bottom] = [Math.min(...boxes.map((r) => r.y)) - 12, Math.max(...boxes.map((r) => r.y + r.h)) + 12];
|
|
261
|
+
let best;
|
|
262
|
+
// ponytail: grid search in 4 px steps, about 10^5 tries on the roadmap.
|
|
263
|
+
for (let x = s.x + 8; x <= right; x += 4) {
|
|
264
|
+
if (!hClear(s.y, s.x, x))
|
|
265
|
+
continue;
|
|
266
|
+
for (let ex = e.x - 8; ex >= left; ex -= 4) {
|
|
267
|
+
if (!hClear(e.y, ex, e.x))
|
|
268
|
+
continue;
|
|
269
|
+
for (let y = top; y <= bottom; y += 4) {
|
|
270
|
+
const len = x - s.x + Math.abs(y - s.y) + Math.abs(x - ex) + Math.abs(e.y - y) + e.x - ex;
|
|
271
|
+
const off = Math.abs(y - (s.y + e.y) / 2);
|
|
272
|
+
if (best && (len > best.len || (len === best.len && off >= best.off)))
|
|
273
|
+
continue;
|
|
274
|
+
if (vClear(x, s.y, y, 8) && hClear(y, x, ex, all, 8) && vClear(ex, y, e.y))
|
|
275
|
+
best = { len, off, x, y, ex };
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
if (best) {
|
|
280
|
+
const { x, y, ex } = best;
|
|
281
|
+
return {
|
|
282
|
+
id: p.id,
|
|
283
|
+
d: `M ${s.x} ${s.y} H ${x} V ${y} H ${ex} V ${e.y} H ${e.x}`,
|
|
284
|
+
mid: { x: (x + ex) / 2, y },
|
|
285
|
+
curve: [s, { x, y }, { x: ex, y }, e],
|
|
286
|
+
elbow: [s, { x, y: s.y }, { x, y }, { x: ex, y }, { x: ex, y: e.y }, e],
|
|
287
|
+
};
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
const x = mx ?? mid;
|
|
291
|
+
const c1 = { x, y: s.y }, c2 = { x, y: e.y };
|
|
292
|
+
return {
|
|
293
|
+
id: p.id,
|
|
294
|
+
d: `M ${s.x} ${s.y} H ${x} V ${e.y} H ${e.x}`,
|
|
295
|
+
mid: { x, y: (s.y + e.y) / 2 },
|
|
296
|
+
curve: [s, c1, c2, e],
|
|
297
|
+
elbow: [s, c1, c2, e],
|
|
298
|
+
};
|
|
230
299
|
}
|
|
231
300
|
if (p.around) {
|
|
232
|
-
// ponytail: arcs
|
|
301
|
+
// ponytail: arcs 50 px past the box ends; a taller box between can be crossed.
|
|
233
302
|
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;
|
|
234
303
|
return drawn(p.id, [s, { x: s.x, y }, { x: e.x, y }, e], { x: (s.x + e.x) / 2, y: (s.y + 6 * y + e.y) / 8 });
|
|
235
304
|
}
|
|
@@ -246,6 +315,8 @@ area) {
|
|
|
246
315
|
continue;
|
|
247
316
|
const r = one(p);
|
|
248
317
|
done.set(p, r);
|
|
318
|
+
if (p.labelW && lanes)
|
|
319
|
+
r.mid = labelInBand(r, p.labelW, lanes, pills);
|
|
249
320
|
if (p.labelW)
|
|
250
321
|
pills.push({ x: r.mid.x - p.labelW / 2, y: r.mid.y - 9, w: p.labelW, h: 18 });
|
|
251
322
|
track(Array.from({ length: 33 }, (_, i) => bezier(r.curve, i / 32)));
|
|
@@ -255,7 +326,30 @@ area) {
|
|
|
255
326
|
done.set(p, one(p));
|
|
256
327
|
return picks.map((p) => done.get(p));
|
|
257
328
|
}
|
|
258
|
-
|
|
329
|
+
function labelInBand(r, w, { bands, boxes }, pills) {
|
|
330
|
+
const hit = (a, b, m) => a.x < b.x + b.w + m && b.x < a.x + a.w + m && a.y < b.y + b.h + m && b.y < a.y + a.h + m;
|
|
331
|
+
const ts = Array.from({ length: 65 }, (_, i) => i / 64).sort((a, b) => Math.abs(a - 0.5) - Math.abs(b - 0.5));
|
|
332
|
+
const path = ts.map((t) => bezier(r.curve, t));
|
|
333
|
+
const clear = (pill) => !boxes.some((b) => hit(pill, b, 1)) && !pills.some((b) => hit(pill, b, 1));
|
|
334
|
+
const room = (x, b) => x - w / 2 >= b.x + LABEL_CLEAR && x + w / 2 <= b.x + b.w - LABEL_CLEAR;
|
|
335
|
+
for (const q of path) {
|
|
336
|
+
const pill = { x: q.x - w / 2, y: q.y - 9, w, h: 18 };
|
|
337
|
+
const fits = bands.some((b) => room(q.x, b) && pill.y >= b.y + LABEL_CLEAR && pill.y + 18 <= b.y + b.h - LABEL_CLEAR);
|
|
338
|
+
if (fits && clear(pill))
|
|
339
|
+
return q;
|
|
340
|
+
}
|
|
341
|
+
for (const q of path)
|
|
342
|
+
for (const b of bands) {
|
|
343
|
+
if (!room(q.x, b) || b.h < 18 + LABEL_CLEAR * 2)
|
|
344
|
+
continue;
|
|
345
|
+
const y = Math.min(Math.max(q.y, b.y + LABEL_CLEAR + 9), b.y + b.h - LABEL_CLEAR - 9);
|
|
346
|
+
const pill = { x: q.x - w / 2, y: y - 9, w, h: 18 };
|
|
347
|
+
if (clear(pill) && path.some((o) => o.x >= pill.x && o.x <= pill.x + w && o.y >= pill.y && o.y <= pill.y + 18))
|
|
348
|
+
return { x: q.x, y };
|
|
349
|
+
}
|
|
350
|
+
return r.mid;
|
|
351
|
+
}
|
|
352
|
+
/** True if segment a-b crosses c-d. The ends of a-b are trimmed by 1.5 px. */
|
|
259
353
|
export function crosses(a, b, c, d) {
|
|
260
354
|
const len = Math.hypot(b.x - a.x, b.y - a.y);
|
|
261
355
|
if (len < 4)
|
package/dist/gif.d.ts
CHANGED
|
@@ -1,6 +1,5 @@
|
|
|
1
1
|
import type { Image } from './png.ts';
|
|
2
|
-
/** One frame
|
|
3
|
-
* `same` says the pixels equal the frame before: the encoder then never calls `load`. */
|
|
2
|
+
/** One frame. `load` returns the pixels, `delay` is in cs, `same` skips `load`. */
|
|
4
3
|
export type GifFrame = {
|
|
5
4
|
load: () => Image;
|
|
6
5
|
delay: number;
|