flowfig 0.6.0 → 0.8.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 +28 -8
- package/dist/browser.d.ts +1 -3
- package/dist/browser.js +67 -59
- package/dist/check.d.ts +5 -3
- package/dist/check.js +24 -17
- package/dist/cli.js +43 -49
- package/dist/code.d.ts +26 -0
- package/dist/code.js +568 -0
- package/dist/diff.js +0 -4
- package/dist/draw.d.ts +1 -3
- package/dist/draw.js +3 -7
- package/dist/edges.d.ts +7 -0
- package/dist/edges.js +510 -0
- package/dist/geometry.d.ts +6 -8
- package/dist/geometry.js +75 -82
- package/dist/gif.d.ts +1 -2
- package/dist/gif.js +115 -42
- package/dist/guide.d.ts +1 -4
- package/dist/guide.js +10 -11
- package/dist/imports.d.ts +11 -0
- package/dist/imports.js +235 -0
- package/dist/index.d.ts +2 -2
- package/dist/index.js +20 -78
- package/dist/init.d.ts +3 -13
- package/dist/init.js +13 -18
- package/dist/load.d.ts +1 -3
- package/dist/load.js +2 -5
- package/dist/mcp.d.ts +1 -2
- package/dist/mcp.js +15 -15
- package/dist/model.d.ts +57 -109
- package/dist/model.js +20 -89
- 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 +8 -8
- package/dist/source.d.ts +1 -2
- package/dist/source.js +5 -5
- package/dist/svg.d.ts +5 -8
- package/dist/svg.js +50 -112
- package/dist/text.d.ts +0 -2
- package/dist/text.js +1 -4
- package/dist/type-index.d.ts +55 -0
- package/dist/type-index.js +383 -0
- package/dist/types.d.ts +32 -0
- package/dist/types.js +733 -0
- package/dist/verify.d.ts +26 -4
- package/dist/verify.js +68 -26
- package/dist/version.d.ts +1 -2
- package/dist/version.js +2 -2
- package/package.json +4 -2
package/dist/geometry.js
CHANGED
|
@@ -1,27 +1,14 @@
|
|
|
1
1
|
import { STUB } from './model.js';
|
|
2
|
-
/** The least gap between a label pill and a lane border. */
|
|
3
2
|
const LABEL_CLEAR = 4;
|
|
4
3
|
const cx = (r) => r.x + r.w / 2;
|
|
5
4
|
const cy = (r) => r.y + r.h / 2;
|
|
6
|
-
/** A point on the cubic Bézier at `t`, as `check` samples an edge. */
|
|
7
5
|
const bezier = ([p0, p1, p2, p3], t) => {
|
|
8
6
|
const u = 1 - t;
|
|
9
7
|
const [a, b, c, d] = [u * u * u, 3 * u * u * t, 3 * u * t * t, t * t * t];
|
|
10
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 };
|
|
11
9
|
};
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
// Several edges leaving the same side of a box are spread out so they don't overlap,
|
|
15
|
-
// except on `tips` boxes (diamonds), where they all meet at the point.
|
|
16
|
-
// `elbow` draws a right-angle path, from a right end to a left end: its four curve points are the corners.
|
|
17
|
-
// `sides` fixes the two sides an edge uses (a timeline uses right to left).
|
|
18
|
-
// `around` makes an edge leave and enter from the top or bottom, arcing over whatever sits between.
|
|
19
|
-
// `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.
|
|
20
|
-
// `labelW` is the width of an edge's label pill: a stub pill keeps clear of it.
|
|
21
|
-
// `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.
|
|
22
|
-
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)
|
|
23
|
-
area, // where a stub pill may sit: the lanes right of the gutter
|
|
24
|
-
lanes) {
|
|
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) {
|
|
25
12
|
const picks = [];
|
|
26
13
|
for (const e of edges) {
|
|
27
14
|
const a = rects[e.from], b = rects[e.to];
|
|
@@ -45,7 +32,6 @@ lanes) {
|
|
|
45
32
|
: ['l', 'r'];
|
|
46
33
|
picks.push({ ...e, a, b, sa, sb });
|
|
47
34
|
}
|
|
48
|
-
// For every (box, side), list the edge ends that sit there, sorted by where the other end is.
|
|
49
35
|
const ends = new Map();
|
|
50
36
|
for (const pick of picks) {
|
|
51
37
|
for (const start of [true, false]) {
|
|
@@ -64,7 +50,7 @@ lanes) {
|
|
|
64
50
|
const r = x.start ? x.pick.b : x.pick.a;
|
|
65
51
|
return horiz ? cx(r) : cy(r);
|
|
66
52
|
};
|
|
67
|
-
list.sort((p, q) => other(p) - other(q));
|
|
53
|
+
list.sort((p, q) => other(p) - other(q));
|
|
68
54
|
list.forEach((x, i) => {
|
|
69
55
|
const r = x.start ? x.pick.a : x.pick.b;
|
|
70
56
|
const f = tip ? 0.5 : (i + 1) / (list.length + 1);
|
|
@@ -78,16 +64,12 @@ lanes) {
|
|
|
78
64
|
anchor.set(x.pick.id + (x.start ? ':s' : ':e'), pt);
|
|
79
65
|
});
|
|
80
66
|
}
|
|
81
|
-
// The four points travel with the path string: `flowfig check` samples the curve, and nothing has to parse `d`.
|
|
82
67
|
const drawn = (id, curve, mid) => {
|
|
83
68
|
const [s, c1, c2, e] = curve;
|
|
84
69
|
return { id, d: `M ${s.x} ${s.y} C ${c1.x} ${c1.y}, ${c2.x} ${c2.y}, ${e.x} ${e.y}`, mid, curve };
|
|
85
70
|
};
|
|
86
|
-
// Stub pills are placed after the other edges. Each pill keeps clear of `avoid`, of the edge labels, of the pills and stubs
|
|
87
|
-
// before it, and of every edge path (sampled as `check` samples it).
|
|
88
71
|
const pills = [];
|
|
89
72
|
const paths = [];
|
|
90
|
-
// The same samples as segments: a stub line must not cross one.
|
|
91
73
|
const runs = [];
|
|
92
74
|
const track = (pts) => {
|
|
93
75
|
paths.push(...pts);
|
|
@@ -101,11 +83,6 @@ lanes) {
|
|
|
101
83
|
const one = (p) => {
|
|
102
84
|
const s = anchor.get(p.id + ':s'), e = anchor.get(p.id + ':e');
|
|
103
85
|
if (p.stub) {
|
|
104
|
-
// The source pill goes right of the source and the target pill left of the target, at the nearest clear place: it slides
|
|
105
|
-
// 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
|
|
106
|
-
// 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
|
|
107
|
-
// inside its band. If no place is clear, the source pill tries its shorter text. If no place is clear then, the first place
|
|
108
|
-
// stays and `check` reports it.
|
|
109
86
|
const [ow, iw, sw] = p.stub;
|
|
110
87
|
const [sb, tb] = p.bands ?? [];
|
|
111
88
|
const rows = (box, y0, band) => {
|
|
@@ -114,14 +91,11 @@ lanes) {
|
|
|
114
91
|
: [box.y + box.h + 2, box.y - 20];
|
|
115
92
|
return [y0, ...ys.filter((y) => y !== y0)];
|
|
116
93
|
};
|
|
117
|
-
// 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,
|
|
118
|
-
// 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.
|
|
119
94
|
const place = (box, from, dir, r, side, tip) => {
|
|
120
95
|
let a, b;
|
|
121
96
|
if (side)
|
|
122
97
|
[a, b] = [from, { x: dir > 0 ? r.x : r.x + r.w, y: r.y + 9 }];
|
|
123
98
|
else {
|
|
124
|
-
// A diamond has its point at the middle: the stub meets the point.
|
|
125
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);
|
|
126
100
|
const under = r.y >= box.y + box.h;
|
|
127
101
|
[a, b] = [
|
|
@@ -131,22 +105,49 @@ lanes) {
|
|
|
131
105
|
}
|
|
132
106
|
return dir > 0 ? [r, a, b] : [r, b, a];
|
|
133
107
|
};
|
|
134
|
-
|
|
108
|
+
function* beside(w, box, from, dir, band) {
|
|
135
109
|
const tip = tips.has(dir > 0 ? p.from : p.to);
|
|
136
|
-
const
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
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;
|
|
146
133
|
}
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
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
|
+
}
|
|
150
151
|
const along = (w, box, from, dir, band) => {
|
|
151
152
|
if (!tips.has(dir > 0 ? p.from : p.to))
|
|
152
153
|
return [];
|
|
@@ -162,49 +163,54 @@ lanes) {
|
|
|
162
163
|
}
|
|
163
164
|
return out.sort((m, q) => m[3] - q[3]).map(([r, a, b]) => [r, a, b]);
|
|
164
165
|
};
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
[
|
|
166
|
+
function* outs(w) {
|
|
167
|
+
yield* beside(w, p.a, s, 1, sb);
|
|
168
|
+
yield [
|
|
168
169
|
{ x: cx(p.a) - w / 2, y: p.a.y + p.a.h + STUB, w, h: 18 },
|
|
169
170
|
{ x: cx(p.a), y: p.a.y + p.a.h },
|
|
170
171
|
{ x: cx(p.a), y: p.a.y + p.a.h + STUB },
|
|
171
|
-
]
|
|
172
|
-
[
|
|
172
|
+
];
|
|
173
|
+
yield [
|
|
173
174
|
{ x: cx(p.a) - w / 2, y: p.a.y - STUB - 18, w, h: 18 },
|
|
174
175
|
{ x: cx(p.a), y: p.a.y },
|
|
175
176
|
{ x: cx(p.a), y: p.a.y - STUB },
|
|
176
|
-
]
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
[
|
|
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 [
|
|
182
183
|
{ x: cx(p.b) - iw / 2, y: p.b.y - STUB - 18, w: iw, h: 18 },
|
|
183
184
|
{ x: cx(p.b), y: p.b.y - STUB },
|
|
184
185
|
{ x: cx(p.b), y: p.b.y },
|
|
185
|
-
]
|
|
186
|
-
[
|
|
186
|
+
];
|
|
187
|
+
yield [
|
|
187
188
|
{ x: cx(p.b) - iw / 2, y: p.b.y + p.b.h + STUB, w: iw, h: 18 },
|
|
188
189
|
{ x: cx(p.b), y: p.b.y + p.b.h + STUB },
|
|
189
190
|
{ x: cx(p.b), y: p.b.y + p.b.h },
|
|
190
|
-
]
|
|
191
|
-
|
|
192
|
-
|
|
191
|
+
];
|
|
192
|
+
yield* along(iw, p.b, e, -1, tb);
|
|
193
|
+
}
|
|
193
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 }));
|
|
194
|
-
// A stub must not run through a box other than its own, nor through a pill or a label, nor cross an edge path.
|
|
195
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));
|
|
196
196
|
const whole = (a, b) => !runs.some(([c, d]) => crosses(a, b, c, d));
|
|
197
197
|
const fits = (own, band, strict) => ([r, a, b]) => clear(r, band) && free(a, b, own) && (!strict || whole(a, b));
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
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)])]);
|
|
202
208
|
const cut = hit?.[1] === 1;
|
|
203
|
-
const [po, o1, o2] = hit?.[0] ?? outs(ow)
|
|
209
|
+
const [po, o1, o2] = hit?.[0] ?? outs(ow).next().value;
|
|
204
210
|
pills.push(po);
|
|
205
211
|
track(seg(o1, o2));
|
|
206
212
|
const hit2 = first(p.b, tb, [ins]);
|
|
207
|
-
const [pi, i1, i2] = hit2?.[0] ?? ins
|
|
213
|
+
const [pi, i1, i2] = hit2?.[0] ?? ins().next().value;
|
|
208
214
|
pills.push(pi);
|
|
209
215
|
track(seg(i1, i2));
|
|
210
216
|
const line = (a, b) => `M ${a.x} ${a.y} L ${b.x} ${b.y}`;
|
|
@@ -227,10 +233,7 @@ lanes) {
|
|
|
227
233
|
};
|
|
228
234
|
}
|
|
229
235
|
if (p.elbow) {
|
|
230
|
-
// Under 16 px of gap, the elbow detours: out 8 px past the start, back to 8 px before the end, then forward into the target.
|
|
231
|
-
// A gap of 0 px or less takes the same detour.
|
|
232
236
|
if (e.x - s.x < 16 && s.y === e.y) {
|
|
233
|
-
// One row: a straight line, since a detour would only double back.
|
|
234
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] };
|
|
235
238
|
}
|
|
236
239
|
if (e.x - s.x < 16) {
|
|
@@ -243,26 +246,20 @@ lanes) {
|
|
|
243
246
|
elbow: [s, { x: mx, y: s.y }, { x: mx, y: my }, { x: ex, y: my }, { x: ex, y: e.y }, e],
|
|
244
247
|
};
|
|
245
248
|
}
|
|
246
|
-
// Over 16 px, the vertical run tries the midpoint, 8 px before the end, 8 px after the start, then any x in the gap.
|
|
247
|
-
// A vertical run keeps clear of every label and box; a horizontal run keeps clear of every box but the two ends.
|
|
248
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);
|
|
249
250
|
const all = avoid.filter((r) => !own(r));
|
|
250
251
|
const boxes = all.filter((r) => r.box);
|
|
251
|
-
// `m` is the margin: 2 px in the gap, 8 px on a detour, so a detour does not run along a box side.
|
|
252
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
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
254
|
const mid = (s.x + e.x) / 2;
|
|
255
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
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
257
|
if (mx == null && boxes.length) {
|
|
258
|
-
// No x in the gap is clear: the elbow goes around. It runs right past the boxes in its way, along a clear row, back to
|
|
259
|
-
// a clear x at or before 8 px before the target, then down or up and forward into the target. The shortest clear
|
|
260
|
-
// detour wins; ties take the row nearest the middle.
|
|
261
258
|
const right = Math.max(...boxes.map((r) => r.x + r.w)) + 12;
|
|
262
259
|
const left = Math.min(...boxes.map((r) => r.x));
|
|
263
260
|
const [top, bottom] = [Math.min(...boxes.map((r) => r.y)) - 12, Math.max(...boxes.map((r) => r.y + r.h)) + 12];
|
|
264
261
|
let best;
|
|
265
|
-
// ponytail:
|
|
262
|
+
// ponytail: grid search in 4 px steps, about 10^5 tries on the roadmap.
|
|
266
263
|
for (let x = s.x + 8; x <= right; x += 4) {
|
|
267
264
|
if (!hClear(s.y, s.x, x))
|
|
268
265
|
continue;
|
|
@@ -290,7 +287,6 @@ lanes) {
|
|
|
290
287
|
};
|
|
291
288
|
}
|
|
292
289
|
}
|
|
293
|
-
// If no path is clear, the midpoint stays and `check` reports the crossing.
|
|
294
290
|
const x = mx ?? mid;
|
|
295
291
|
const c1 = { x, y: s.y }, c2 = { x, y: e.y };
|
|
296
292
|
return {
|
|
@@ -302,7 +298,7 @@ lanes) {
|
|
|
302
298
|
};
|
|
303
299
|
}
|
|
304
300
|
if (p.around) {
|
|
305
|
-
// ponytail: arcs
|
|
301
|
+
// ponytail: arcs 50 px past the box ends; a taller box between can be crossed.
|
|
306
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;
|
|
307
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 });
|
|
308
304
|
}
|
|
@@ -330,9 +326,6 @@ lanes) {
|
|
|
330
326
|
done.set(p, one(p));
|
|
331
327
|
return picks.map((p) => done.get(p));
|
|
332
328
|
}
|
|
333
|
-
/** The point of the path nearest its middle where a label pill of width `w` sits inside one band, LABEL_CLEAR from the border, clear of the boxes
|
|
334
|
-
* and the earlier pills. If a short run leaves no such point, the pill keeps the x of a path point and moves up or down into the nearest
|
|
335
|
-
* band, still on the path. If that fails too, the middle. Both renderers route through this, so they place the label alike. */
|
|
336
329
|
function labelInBand(r, w, { bands, boxes }, pills) {
|
|
337
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;
|
|
338
331
|
const ts = Array.from({ length: 65 }, (_, i) => i / 64).sort((a, b) => Math.abs(a - 0.5) - Math.abs(b - 0.5));
|
|
@@ -356,7 +349,7 @@ function labelInBand(r, w, { bands, boxes }, pills) {
|
|
|
356
349
|
}
|
|
357
350
|
return r.mid;
|
|
358
351
|
}
|
|
359
|
-
/** True if
|
|
352
|
+
/** True if segment a-b crosses c-d. The ends of a-b are trimmed by 1.5 px. */
|
|
360
353
|
export function crosses(a, b, c, d) {
|
|
361
354
|
const len = Math.hypot(b.x - a.x, b.y - a.y);
|
|
362
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;
|
package/dist/gif.js
CHANGED
|
@@ -1,12 +1,41 @@
|
|
|
1
|
+
import { endianness } from 'node:os';
|
|
1
2
|
/** The delay of each of `count` frames in cs. The sum is round(count * 100 / fps). */
|
|
2
3
|
export function delays(count, fps) {
|
|
3
|
-
// Each delay is a whole cs: the rounding error moves between frames and does not add up.
|
|
4
4
|
return Array.from({ length: count }, (_, i) => Math.round(((i + 1) * 100) / fps) - Math.round((i * 100) / fps));
|
|
5
5
|
}
|
|
6
|
-
// A color bin: 5 bits per channel, 32768 bins.
|
|
7
6
|
const binOf = (rgb) => ((rgb >> 9) & 0x7c00) | ((rgb >> 6) & 0x3e0) | ((rgb >> 3) & 0x1f);
|
|
7
|
+
// Little-endian: a pixel word is A B G R, so this swaps R and B.
|
|
8
|
+
const rgbOf = (v) => ((v & 255) << 16) | (v & 0xff00) | ((v >> 16) & 255);
|
|
9
|
+
const CROP_LIMIT = 64 << 20;
|
|
10
|
+
function changedBox(a, b, width, height) {
|
|
11
|
+
let x0 = width, x1 = -1, y0 = -1, y1 = -1;
|
|
12
|
+
for (let y = 0; y < height; y++) {
|
|
13
|
+
const o = y * width;
|
|
14
|
+
let lo = 0;
|
|
15
|
+
while (lo < width && a[o + lo] === b[o + lo])
|
|
16
|
+
lo++;
|
|
17
|
+
if (lo === width)
|
|
18
|
+
continue;
|
|
19
|
+
let hi = width - 1;
|
|
20
|
+
while (a[o + hi] === b[o + hi])
|
|
21
|
+
hi--;
|
|
22
|
+
if (lo < x0)
|
|
23
|
+
x0 = lo;
|
|
24
|
+
if (hi > x1)
|
|
25
|
+
x1 = hi;
|
|
26
|
+
if (y0 < 0)
|
|
27
|
+
y0 = y;
|
|
28
|
+
y1 = y;
|
|
29
|
+
}
|
|
30
|
+
return y1 < 0 ? null : [x0, y0, x1, y1];
|
|
31
|
+
}
|
|
8
32
|
const channel = (bin, c) => (bin >> (10 - 5 * c)) & 31;
|
|
9
|
-
|
|
33
|
+
function cropOf(px, [x0, y0, x1, y1], width) {
|
|
34
|
+
const w = x1 - x0 + 1, out = new Uint32Array(w * (y1 - y0 + 1));
|
|
35
|
+
for (let y = y0; y <= y1; y++)
|
|
36
|
+
out.set(px.subarray(y * width + x0, y * width + x1 + 1), (y - y0) * w);
|
|
37
|
+
return out;
|
|
38
|
+
}
|
|
10
39
|
function medianCut(bins, counts) {
|
|
11
40
|
const boxes = [bins];
|
|
12
41
|
while (boxes.length < 256) {
|
|
@@ -36,7 +65,6 @@ function medianCut(bins, counts) {
|
|
|
36
65
|
}
|
|
37
66
|
return boxes;
|
|
38
67
|
}
|
|
39
|
-
/** The palette: the exact colors if there are 256 or fewer, else median cut over the bins. No dither. */
|
|
40
68
|
function palette(exact, counts, sums) {
|
|
41
69
|
const colors = new Uint8Array(768);
|
|
42
70
|
if (exact) {
|
|
@@ -59,7 +87,6 @@ function palette(exact, counts, sums) {
|
|
|
59
87
|
}
|
|
60
88
|
colors.set([Math.round(r / n), Math.round(g / n), Math.round(b / n)], k * 3);
|
|
61
89
|
});
|
|
62
|
-
// Each bin maps to the palette color nearest to the mean color of the bin.
|
|
63
90
|
const table = new Uint8Array(32768);
|
|
64
91
|
for (const x of bins) {
|
|
65
92
|
const r = sums[x * 3] / counts[x], g = sums[x * 3 + 1] / counts[x], b = sums[x * 3 + 2] / counts[x];
|
|
@@ -73,7 +100,6 @@ function palette(exact, counts, sums) {
|
|
|
73
100
|
}
|
|
74
101
|
return { colors, index: (rgb) => table[binOf(rgb)] };
|
|
75
102
|
}
|
|
76
|
-
/** GIF LZW: minimum code size 8, a clear code first, codes up to 12 bits, data in sub-blocks of 255 bytes or fewer. */
|
|
77
103
|
function lzw(px, width, rect, byte) {
|
|
78
104
|
const [x0, y0, x1, y1] = rect;
|
|
79
105
|
const CLEAR = 256, END = 257;
|
|
@@ -115,7 +141,7 @@ function lzw(px, width, rect, byte) {
|
|
|
115
141
|
[next, size] = [258, 9];
|
|
116
142
|
}
|
|
117
143
|
else {
|
|
118
|
-
// The decoder runs one code behind: grow the size
|
|
144
|
+
// The decoder runs one code behind: grow the size one code early.
|
|
119
145
|
if (next >= 1 << size)
|
|
120
146
|
size++;
|
|
121
147
|
dict.set((prefix << 8) | k, next++);
|
|
@@ -134,17 +160,19 @@ function lzw(px, width, rect, byte) {
|
|
|
134
160
|
byte(0);
|
|
135
161
|
}
|
|
136
162
|
/** An animated GIF that loops forever. All frames have the size of the first frame. */
|
|
137
|
-
// It yields
|
|
163
|
+
// It yields between frames so a signal handler can run.
|
|
138
164
|
export async function encodeGif(frames) {
|
|
139
165
|
if (!frames.length)
|
|
140
166
|
throw new Error('a GIF needs at least one frame');
|
|
141
|
-
|
|
167
|
+
if (endianness() !== 'LE')
|
|
168
|
+
throw new Error('flowfig gif needs a little-endian CPU');
|
|
142
169
|
const counts = new Uint32Array(32768), sums = new Float64Array(32768 * 3);
|
|
143
170
|
let exact = new Map(), width = 0, height = 0;
|
|
171
|
+
let before = null, kept = 0;
|
|
172
|
+
const crops = [];
|
|
144
173
|
for (let f = 0; f < frames.length; f++) {
|
|
145
174
|
if (frames[f].same && f > 0)
|
|
146
175
|
continue;
|
|
147
|
-
// A run of equal frames counts once, with the weight of the run.
|
|
148
176
|
let weight = 1;
|
|
149
177
|
while (f + weight < frames.length && frames[f + weight].same)
|
|
150
178
|
weight++;
|
|
@@ -154,20 +182,39 @@ export async function encodeGif(frames) {
|
|
|
154
182
|
[width, height] = [w, h];
|
|
155
183
|
else if (w !== width || h !== height)
|
|
156
184
|
throw new Error(`frame ${f + 1} is ${w} x ${h}; the first frame is ${width} x ${height}`);
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
185
|
+
const px = new Uint32Array(data.buffer, data.byteOffset, data.length >> 2);
|
|
186
|
+
let last = px[0] & 0xffffff, run = 0;
|
|
187
|
+
const add = (v, n) => {
|
|
188
|
+
const rgb = rgbOf(v), bin = binOf(rgb), k = n * weight;
|
|
189
|
+
if (exact && !exact.has(rgb))
|
|
190
|
+
exact = exact.size < 256 ? exact.set(rgb, exact.size) : null;
|
|
191
|
+
counts[bin] += k;
|
|
192
|
+
sums[bin * 3] += (rgb >> 16) * k;
|
|
193
|
+
sums[bin * 3 + 1] += ((rgb >> 8) & 255) * k;
|
|
194
|
+
sums[bin * 3 + 2] += (rgb & 255) * k;
|
|
195
|
+
};
|
|
196
|
+
for (let i = 0; i < px.length; i++) {
|
|
197
|
+
const v = px[i] & 0xffffff;
|
|
198
|
+
if (v === last)
|
|
199
|
+
run++;
|
|
200
|
+
else {
|
|
201
|
+
add(last, run);
|
|
202
|
+
[last, run] = [v, 1];
|
|
165
203
|
}
|
|
166
|
-
counts[bin] += weight;
|
|
167
|
-
sums[bin * 3] += data[i] * weight;
|
|
168
|
-
sums[bin * 3 + 1] += data[i + 1] * weight;
|
|
169
|
-
sums[bin * 3 + 2] += data[i + 2] * weight;
|
|
170
204
|
}
|
|
205
|
+
add(last, run);
|
|
206
|
+
const box = before ? changedBox(px, before, width, height) : [0, 0, width - 1, height - 1];
|
|
207
|
+
if (!box)
|
|
208
|
+
crops[f] = null;
|
|
209
|
+
else {
|
|
210
|
+
const [x0, y0, x1, y1] = box;
|
|
211
|
+
const size = (x1 - x0 + 1) * (y1 - y0 + 1) * 4;
|
|
212
|
+
if (kept + size <= CROP_LIMIT) {
|
|
213
|
+
kept += size;
|
|
214
|
+
crops[f] = { x0, y0, x1, y1, px: before ? cropOf(px, box, width) : px };
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
before = px;
|
|
171
218
|
}
|
|
172
219
|
const { colors, index } = palette(exact, counts, sums);
|
|
173
220
|
let buf = new Uint8Array(1 << 20), len = 0;
|
|
@@ -196,20 +243,34 @@ export async function encodeGif(frames) {
|
|
|
196
243
|
bytes(0x21, 0xff, 11);
|
|
197
244
|
text('NETSCAPE2.0');
|
|
198
245
|
bytes(3, 1, 0, 0, 0); // loop count 0: loop forever
|
|
199
|
-
// Pass 2: each frame as palette indices. A later frame sends only the rectangle that changed.
|
|
200
246
|
let prev = null;
|
|
201
|
-
for (
|
|
247
|
+
for (let i = 0; i < frames.length; i++) {
|
|
248
|
+
const frame = frames[i], crop = crops[i];
|
|
202
249
|
await new Promise(setImmediate);
|
|
203
250
|
let cur = prev;
|
|
204
|
-
if (
|
|
205
|
-
|
|
251
|
+
if (crop === null && prev)
|
|
252
|
+
cur = prev;
|
|
253
|
+
else if (!(frame.same && prev)) {
|
|
206
254
|
cur = new Uint8Array(width * height);
|
|
207
255
|
let last = -1, k = 0;
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
if (
|
|
211
|
-
[last, k] = [
|
|
256
|
+
const put = (p, v) => {
|
|
257
|
+
v &= 0xffffff;
|
|
258
|
+
if (v !== last)
|
|
259
|
+
[last, k] = [v, index(rgbOf(v))];
|
|
212
260
|
cur[p] = k;
|
|
261
|
+
};
|
|
262
|
+
if (crop) {
|
|
263
|
+
if (prev)
|
|
264
|
+
cur.set(prev);
|
|
265
|
+
for (let y = crop.y0, q = 0; y <= crop.y1; y++)
|
|
266
|
+
for (let x = crop.x0; x <= crop.x1; x++)
|
|
267
|
+
put(y * width + x, crop.px[q++]);
|
|
268
|
+
}
|
|
269
|
+
else {
|
|
270
|
+
const { data } = frame.load();
|
|
271
|
+
const px = new Uint32Array(data.buffer, data.byteOffset, data.length >> 2);
|
|
272
|
+
for (let p = 0; p < cur.length; p++)
|
|
273
|
+
put(p, px[p]);
|
|
213
274
|
}
|
|
214
275
|
}
|
|
215
276
|
let rect = [0, 0, width - 1, height - 1];
|
|
@@ -217,19 +278,31 @@ export async function encodeGif(frames) {
|
|
|
217
278
|
rect = [0, 0, -1, -1];
|
|
218
279
|
else if (prev) {
|
|
219
280
|
rect = [width, height, -1, -1];
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
281
|
+
const wide = (width & 3) === 0;
|
|
282
|
+
const a32 = wide ? new Uint32Array(cur.buffer) : cur, b32 = wide ? new Uint32Array(prev.buffer) : prev, row = wide ? width >> 2 : width;
|
|
283
|
+
for (let y = crop?.y0 ?? 0; y <= (crop?.y1 ?? height - 1); y++) {
|
|
284
|
+
let x = y * row;
|
|
285
|
+
const end = x + row;
|
|
286
|
+
while (x < end && a32[x] === b32[x])
|
|
287
|
+
x++;
|
|
288
|
+
if (x === end)
|
|
289
|
+
continue;
|
|
290
|
+
const o = y * width;
|
|
291
|
+
let x0 = 0, x1 = width - 1;
|
|
292
|
+
while (cur[o + x0] === prev[o + x0])
|
|
293
|
+
x0++;
|
|
294
|
+
while (cur[o + x1] === prev[o + x1])
|
|
295
|
+
x1--;
|
|
296
|
+
if (x0 < rect[0])
|
|
297
|
+
rect[0] = x0;
|
|
298
|
+
if (x1 > rect[2])
|
|
299
|
+
rect[2] = x1;
|
|
300
|
+
if (y < rect[1])
|
|
301
|
+
rect[1] = y;
|
|
302
|
+
rect[3] = y;
|
|
303
|
+
}
|
|
231
304
|
}
|
|
232
|
-
//
|
|
305
|
+
// A 1 x 1 frame in the old color keeps the frame and its delay.
|
|
233
306
|
if (rect[2] < 0)
|
|
234
307
|
rect = [0, 0, 0, 0];
|
|
235
308
|
bytes(0x21, 0xf9, 4, 1 << 2); // disposal method 1: the next frame draws over this frame
|