bireactive 0.3.1 → 0.3.3
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 +14 -7
- package/dist/automerge/doc-cell.d.ts +24 -11
- package/dist/automerge/doc-cell.js +19 -13
- package/dist/automerge/index.d.ts +3 -2
- package/dist/automerge/index.js +6 -5
- package/dist/automerge/reconcile.d.ts +5 -2
- package/dist/automerge/reconcile.js +73 -15
- package/dist/core/_counts.js +5 -12
- package/dist/core/cell.d.ts +3 -3
- package/dist/core/cell.js +6 -7
- package/dist/core/derived-geometry.js +4 -7
- package/dist/core/index.d.ts +3 -1
- package/dist/core/index.js +3 -1
- package/dist/core/lenses/aggregates.d.ts +42 -52
- package/dist/core/lenses/aggregates.js +225 -116
- package/dist/core/lenses/geometry.d.ts +22 -4
- package/dist/core/lenses/geometry.js +59 -27
- package/dist/core/lenses/index.d.ts +5 -6
- package/dist/core/lenses/index.js +5 -6
- package/dist/core/lenses/memory.js +4 -17
- package/dist/core/lenses/numerical.d.ts +100 -0
- package/dist/core/lenses/{typed-factor.js → numerical.js} +136 -34
- package/dist/core/lenses/point-cloud.d.ts +67 -0
- package/dist/core/lenses/{closed-form-policies.js → point-cloud.js} +218 -81
- package/dist/core/lenses/snap.d.ts +1 -1
- package/dist/core/lenses/snap.js +3 -10
- package/dist/core/lenses/text.d.ts +40 -0
- package/dist/core/lenses/text.js +202 -0
- package/dist/core/lifecycle.js +3 -6
- package/dist/core/linalg.js +5 -11
- package/dist/core/optic.js +10 -15
- package/dist/core/optics.js +4 -8
- package/dist/core/store.d.ts +1 -2
- package/dist/core/store.js +7 -15
- package/dist/core/traits.d.ts +4 -7
- package/dist/core/traits.js +8 -12
- package/dist/core/values/anchor.js +0 -4
- package/dist/core/values/arr.d.ts +110 -0
- package/dist/core/values/arr.js +336 -0
- package/dist/core/values/audio.d.ts +8 -9
- package/dist/core/values/audio.js +7 -23
- package/dist/core/values/bool.d.ts +11 -11
- package/dist/core/values/bool.js +12 -22
- package/dist/core/values/box.d.ts +15 -20
- package/dist/core/values/box.js +20 -33
- package/dist/core/values/canvas.d.ts +18 -25
- package/dist/core/values/canvas.js +17 -48
- package/dist/core/values/color.d.ts +5 -7
- package/dist/core/values/color.js +5 -11
- package/dist/core/values/field.d.ts +6 -7
- package/dist/core/values/field.js +10 -35
- package/dist/core/values/flags.d.ts +1 -2
- package/dist/core/values/flags.js +1 -17
- package/dist/core/values/gpu.d.ts +6 -10
- package/dist/core/values/gpu.js +8 -22
- package/dist/core/values/matrix.d.ts +2 -4
- package/dist/core/values/matrix.js +2 -12
- package/dist/core/values/num.d.ts +19 -28
- package/dist/core/values/num.js +23 -41
- package/dist/core/values/pose.d.ts +2 -4
- package/dist/core/values/pose.js +3 -12
- package/dist/core/values/range.d.ts +18 -26
- package/dist/core/values/range.js +22 -39
- package/dist/core/values/reg/ambiguity.d.ts +8 -0
- package/dist/core/values/reg/ambiguity.js +131 -0
- package/dist/core/values/reg/engine.d.ts +91 -0
- package/dist/core/values/reg/engine.js +373 -0
- package/dist/core/values/reg/nfa.d.ts +42 -0
- package/dist/core/values/reg/nfa.js +391 -0
- package/dist/core/values/reg/regex.d.ts +7 -0
- package/dist/core/values/reg/regex.js +318 -0
- package/dist/core/values/reg/types.d.ts +60 -0
- package/dist/core/values/reg/types.js +3 -0
- package/dist/core/values/reg.d.ts +250 -0
- package/dist/core/values/reg.js +649 -0
- package/dist/core/values/str.d.ts +16 -60
- package/dist/core/values/str.js +133 -315
- package/dist/core/values/template.js +1 -24
- package/dist/core/values/transform.d.ts +3 -5
- package/dist/core/values/transform.js +3 -12
- package/dist/core/values/tri.d.ts +9 -10
- package/dist/core/values/tri.js +9 -15
- package/dist/core/values/vec.d.ts +9 -24
- package/dist/core/values/vec.js +9 -64
- package/dist/index.d.ts +0 -11
- package/dist/index.js +1 -11
- package/package.json +17 -10
- package/dist/coll.d.ts +0 -74
- package/dist/coll.js +0 -210
- package/dist/core/lenses/closed-form-policies.d.ts +0 -57
- package/dist/core/lenses/decompositions.d.ts +0 -14
- package/dist/core/lenses/decompositions.js +0 -224
- package/dist/core/lenses/domain-aggregates.d.ts +0 -42
- package/dist/core/lenses/domain-aggregates.js +0 -245
- package/dist/core/lenses/typed-factor.d.ts +0 -40
|
@@ -1,16 +1,9 @@
|
|
|
1
|
-
//
|
|
2
|
-
//
|
|
3
|
-
//
|
|
4
|
-
//
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
//
|
|
8
|
-
// Layout: building-block actions (rigidTranslate, rotateAbout,
|
|
9
|
-
// scaleAbout, scaleAboutXY), then closed-form decompositions
|
|
10
|
-
// (bestFitLine, bestFitCircle, pca, total). All exact, idempotent,
|
|
11
|
-
// cross-channel invariant by construction, on the same `Cls.lens`
|
|
12
|
-
// machinery — no engine changes.
|
|
13
|
-
import { mean, Num, SKIP, Vec, } from "../index.js";
|
|
1
|
+
// Exact group-action lenses for point clouds. The backward pass applies a
|
|
2
|
+
// group element (translate / rotate / scale about a pivot) to the whole
|
|
3
|
+
// source set, so the fits below (bestFitLine, bestFitCircle, pca, procrustes)
|
|
4
|
+
// are exact and cross-channel invariant.
|
|
5
|
+
import { Num, SKIP, Vec, } from "../index.js";
|
|
6
|
+
import { mean } from "./aggregates.js";
|
|
14
7
|
import { continuous, remember } from "./memory.js";
|
|
15
8
|
// Pivotal trait lookup via the value class's `static traits.pivotal` slot.
|
|
16
9
|
// biome-ignore lint/suspicious/noExplicitAny: dynamic trait lookup
|
|
@@ -20,23 +13,14 @@ function pivotalOf(input) {
|
|
|
20
13
|
const p = Cls.traits?.pivotal;
|
|
21
14
|
if (!p) {
|
|
22
15
|
const name = Cls.name ?? "?";
|
|
23
|
-
throw new Error(`
|
|
16
|
+
throw new Error(`point-cloud: ${name} has no traits.pivotal`);
|
|
24
17
|
}
|
|
25
18
|
return p;
|
|
26
19
|
}
|
|
27
|
-
/** Writable centroid; on write, translates every point by the delta.
|
|
28
|
-
* The Vec-specific group-action reading of `mean`. */
|
|
29
|
-
export function rigidTranslate(points) {
|
|
30
|
-
return mean(points);
|
|
31
|
-
}
|
|
32
20
|
/** Writable angle from `pivot` to `points[0]`; write rotates every input
|
|
33
|
-
* about `pivot` by (target − current) via its `Pivotal` trait
|
|
34
|
-
*
|
|
35
|
-
*
|
|
36
|
-
* orientation. Rotation-about-pivot fixes the pivot and preserves radial
|
|
37
|
-
* distances, so scale-about-pivot reads unchanged. `pivot` is reactive
|
|
38
|
-
* (re-read per write); pass `rigidTranslate(points)` for rotation about
|
|
39
|
-
* the cluster's own centroid. */
|
|
21
|
+
* about `pivot` by (target − current) via its `Pivotal` trait (Vec rotates
|
|
22
|
+
* position, Pose also rotates orientation). `pivot` is reactive; pass
|
|
23
|
+
* `mean(points)` to rotate about the cluster's own centroid. */
|
|
40
24
|
export function rotateAbout(points, pivot) {
|
|
41
25
|
const K = points.length;
|
|
42
26
|
if (K < 1)
|
|
@@ -61,13 +45,9 @@ export function rotateAbout(points, pivot) {
|
|
|
61
45
|
});
|
|
62
46
|
}
|
|
63
47
|
/** Writable radial distance from pivot to `points[0]`; write scales every
|
|
64
|
-
* input radially about `pivot` (negative target reflects).
|
|
65
|
-
*
|
|
66
|
-
*
|
|
67
|
-
* Complement carries per-point offsets from the pivot at the last
|
|
68
|
-
* non-degenerate state, so a collapse onto the pivot (radius ≈ 0)
|
|
69
|
-
* reinflates from the stored shape. Pose `theta` survives the round-trip
|
|
70
|
-
* (only spatial offset is stored). */
|
|
48
|
+
* input radially about `pivot` (negative target reflects). The complement
|
|
49
|
+
* carries per-point offsets from the pivot, so a collapse onto it (radius
|
|
50
|
+
* ≈ 0) reinflates from the stored shape. Pose `theta` survives. */
|
|
71
51
|
export function scaleAbout(points, pivot) {
|
|
72
52
|
const K = points.length;
|
|
73
53
|
if (K < 1)
|
|
@@ -111,10 +91,9 @@ export function scaleAbout(points, pivot) {
|
|
|
111
91
|
},
|
|
112
92
|
});
|
|
113
93
|
}
|
|
114
|
-
/** Per-axis scale about a pivot
|
|
115
|
-
*
|
|
116
|
-
*
|
|
117
|
-
* `bboxLens.size`). */
|
|
94
|
+
/** Per-axis scale about a pivot (Vec-specific). The complement carries
|
|
95
|
+
* per-point per-axis fractions of point 0's offset, so a per-axis collapse
|
|
96
|
+
* is recoverable. */
|
|
118
97
|
export function scaleAboutXY(points, pivot) {
|
|
119
98
|
const K = points.length;
|
|
120
99
|
if (K < 1)
|
|
@@ -153,13 +132,6 @@ export function scaleAboutXY(points, pivot) {
|
|
|
153
132
|
},
|
|
154
133
|
});
|
|
155
134
|
}
|
|
156
|
-
// Best-fit line.
|
|
157
|
-
//
|
|
158
|
-
// K points → {point: centroid, direction: principal-axis angle}.
|
|
159
|
-
// write point → rigidTranslate
|
|
160
|
-
// write direction → rotate all about centroid to set principal axis
|
|
161
|
-
// Invariance: principal axis is translation-invariant; centroid is
|
|
162
|
-
// invariant under rotation-about-itself.
|
|
163
135
|
/** Angle of the dominant eigenvector of symmetric 2×2 [[cxx,cxy],[cxy,cyy]]. */
|
|
164
136
|
function dominantAxisAngle(cxx, cxy, cyy) {
|
|
165
137
|
return 0.5 * Math.atan2(2 * cxy, cxx - cyy);
|
|
@@ -178,16 +150,16 @@ function covariance(points, cx, cy) {
|
|
|
178
150
|
}
|
|
179
151
|
return { cxx: cxx / K, cxy: cxy / K, cyy: cyy / K };
|
|
180
152
|
}
|
|
153
|
+
/** K points → {point: centroid, direction: principal-axis angle}. Writing
|
|
154
|
+
* `point` translates; writing `direction` rotates all about the centroid. */
|
|
181
155
|
export function bestFitLine(points) {
|
|
182
156
|
const K = points.length;
|
|
183
157
|
if (K < 2)
|
|
184
158
|
throw new Error("bestFitLine: need ≥ 2 points");
|
|
185
|
-
const point =
|
|
186
|
-
//
|
|
187
|
-
//
|
|
188
|
-
//
|
|
189
|
-
// emitted angle so the direction stays continuous; a collapsed cloud has
|
|
190
|
-
// no axis (`defined: false`), so it freezes and stashes the target.
|
|
159
|
+
const point = mean(points);
|
|
160
|
+
// Axis angle is an eigenvector direction (defined up to sign), so the raw
|
|
161
|
+
// atan2 jumps by π as the cloud rotates; `continuous` (period π) tracks the
|
|
162
|
+
// last emitted angle to stay continuous, freezing on a collapsed cloud.
|
|
191
163
|
// Centroid + dominant-axis raw angle; `degenerate` when covariance vanishes.
|
|
192
164
|
const axisOf = (vals) => {
|
|
193
165
|
let sx = 0;
|
|
@@ -225,18 +197,13 @@ export function bestFitLine(points) {
|
|
|
225
197
|
});
|
|
226
198
|
return { point, direction };
|
|
227
199
|
}
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
// K points → {center: centroid, radius: mean distance from center}.
|
|
231
|
-
// write center → rigidTranslate
|
|
232
|
-
// write radius → scale all about center by target/current
|
|
233
|
-
// Simplest closed-form fit (mean center). Invariance: translation
|
|
234
|
-
// preserves radii; uniform scale-about-center preserves the center.
|
|
200
|
+
/** K points → {center: centroid, radius: mean distance from center}. Writing
|
|
201
|
+
* `center` translates; writing `radius` scales all about the center. */
|
|
235
202
|
export function bestFitCircle(points) {
|
|
236
203
|
const K = points.length;
|
|
237
204
|
if (K < 1)
|
|
238
205
|
throw new Error("bestFitCircle: need ≥ 1 point");
|
|
239
|
-
const center =
|
|
206
|
+
const center = mean(points);
|
|
240
207
|
// Radius = mean distance from the centroid; writing it scales the cluster
|
|
241
208
|
// about the centroid, and a collapse (mean → 0) reinflates the remembered
|
|
242
209
|
// shape — exactly `remember`'s magnitude view, anchored at the centroid.
|
|
@@ -261,20 +228,14 @@ export function bestFitCircle(points) {
|
|
|
261
228
|
});
|
|
262
229
|
return { center, radius };
|
|
263
230
|
}
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
// majorLength: √λ_major, minorLength: √λ_minor (per-axis std-devs)}.
|
|
268
|
-
// write mean → rigidTranslate
|
|
269
|
-
// write rotation → rotate all about mean to set principal axis
|
|
270
|
-
// write major/minor → scale along that axis by target/current
|
|
271
|
-
// Each write is a single group action; cross-channel invariance holds
|
|
272
|
-
// for all pairs.
|
|
231
|
+
/** K points → {mean: centroid, rotation: dominant-eigenvector angle,
|
|
232
|
+
* majorLength/minorLength: per-axis std-devs (√λ)}. Each write is a single
|
|
233
|
+
* group action about the mean, so all pairs are cross-channel invariant. */
|
|
273
234
|
export function pca(points) {
|
|
274
235
|
const K = points.length;
|
|
275
236
|
if (K < 2)
|
|
276
237
|
throw new Error("pca: need ≥ 2 points");
|
|
277
|
-
const
|
|
238
|
+
const meanCell = mean(points);
|
|
278
239
|
// 2×2 symmetric eigendecomp → {θ, λ_major, λ_minor}; null when fully
|
|
279
240
|
// collapsed (λ_major ≈ 0).
|
|
280
241
|
const decompose = (vals) => {
|
|
@@ -414,17 +375,11 @@ export function pca(points) {
|
|
|
414
375
|
};
|
|
415
376
|
const majorLength = buildAxisLens("major");
|
|
416
377
|
const minorLength = buildAxisLens("minor");
|
|
417
|
-
return { mean, rotation, majorLength, minorLength };
|
|
378
|
+
return { mean: meanCell, rotation, majorLength, minorLength };
|
|
418
379
|
}
|
|
419
|
-
// Partition / simplex lens.
|
|
420
|
-
//
|
|
421
|
-
// K parts → {total}: writing total scales all parts proportionally.
|
|
422
|
-
// (A {total, ratios} form is possible but ratios on a K-simplex have
|
|
423
|
-
// K−1 DOF, so it's left out of this prototype.)
|
|
424
380
|
/** Writable total over K parts; write scales all parts proportionally,
|
|
425
|
-
* preserving their ratios. A
|
|
426
|
-
*
|
|
427
|
-
* uniform so an all-zero start splits evenly. */
|
|
381
|
+
* preserving their ratios. A collapse to zero reinflates the stored ratios,
|
|
382
|
+
* seeded uniform so an all-zero start splits evenly. */
|
|
428
383
|
export function total(parts) {
|
|
429
384
|
const K = parts.length;
|
|
430
385
|
if (K < 1)
|
|
@@ -441,7 +396,189 @@ export function total(parts) {
|
|
|
441
396
|
seed: () => parts.map(() => 1 / K),
|
|
442
397
|
});
|
|
443
398
|
}
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
399
|
+
/** K Vecs → {centroid, rotation (angle of point[0] about centroid), scale
|
|
400
|
+
* (its distance from centroid)}. Each write is a closed-form transform about
|
|
401
|
+
* the centroid (translate / rotate / scale), so the three are cross-channel
|
|
402
|
+
* invariant. A collapsed cluster makes rotation singular and scale a no-op. */
|
|
403
|
+
export function procrustes(points) {
|
|
404
|
+
const K = points.length;
|
|
405
|
+
if (K < 2)
|
|
406
|
+
throw new Error("procrustes: need ≥ 2 points");
|
|
407
|
+
const centroid = Vec.lens(points, (vals) => {
|
|
408
|
+
let sx = 0;
|
|
409
|
+
let sy = 0;
|
|
410
|
+
for (let i = 0; i < K; i++) {
|
|
411
|
+
sx += vals[i].x;
|
|
412
|
+
sy += vals[i].y;
|
|
413
|
+
}
|
|
414
|
+
return { x: sx / K, y: sy / K };
|
|
415
|
+
}, (target, vals) => {
|
|
416
|
+
let sx = 0;
|
|
417
|
+
let sy = 0;
|
|
418
|
+
for (let i = 0; i < K; i++) {
|
|
419
|
+
sx += vals[i].x;
|
|
420
|
+
sy += vals[i].y;
|
|
421
|
+
}
|
|
422
|
+
const dx = target.x - sx / K;
|
|
423
|
+
const dy = target.y - sy / K;
|
|
424
|
+
const out = new Array(K);
|
|
425
|
+
for (let i = 0; i < K; i++)
|
|
426
|
+
out[i] = { x: vals[i].x + dx, y: vals[i].y + dy };
|
|
427
|
+
return out;
|
|
428
|
+
});
|
|
429
|
+
const rotation = Num.lens(points, (vals) => {
|
|
430
|
+
let sx = 0;
|
|
431
|
+
let sy = 0;
|
|
432
|
+
for (let i = 0; i < K; i++) {
|
|
433
|
+
sx += vals[i].x;
|
|
434
|
+
sy += vals[i].y;
|
|
435
|
+
}
|
|
436
|
+
const cx = sx / K;
|
|
437
|
+
const cy = sy / K;
|
|
438
|
+
return Math.atan2(vals[0].y - cy, vals[0].x - cx);
|
|
439
|
+
}, (target, vals) => {
|
|
440
|
+
let sx = 0;
|
|
441
|
+
let sy = 0;
|
|
442
|
+
for (let i = 0; i < K; i++) {
|
|
443
|
+
sx += vals[i].x;
|
|
444
|
+
sy += vals[i].y;
|
|
445
|
+
}
|
|
446
|
+
const cx = sx / K;
|
|
447
|
+
const cy = sy / K;
|
|
448
|
+
const rx0 = vals[0].x - cx;
|
|
449
|
+
const ry0 = vals[0].y - cy;
|
|
450
|
+
if (rx0 * rx0 + ry0 * ry0 < 1e-24) {
|
|
451
|
+
// Collapsed cluster; no angle to rotate from.
|
|
452
|
+
return vals.map(() => SKIP);
|
|
453
|
+
}
|
|
454
|
+
const oldθ = Math.atan2(ry0, rx0);
|
|
455
|
+
const dθ = target - oldθ;
|
|
456
|
+
const cos = Math.cos(dθ);
|
|
457
|
+
const sin = Math.sin(dθ);
|
|
458
|
+
const out = new Array(K);
|
|
459
|
+
for (let i = 0; i < K; i++) {
|
|
460
|
+
const rx = vals[i].x - cx;
|
|
461
|
+
const ry = vals[i].y - cy;
|
|
462
|
+
out[i] = { x: cx + cos * rx - sin * ry, y: cy + sin * rx + cos * ry };
|
|
463
|
+
}
|
|
464
|
+
return out;
|
|
465
|
+
});
|
|
466
|
+
const centroidOf = (vals) => {
|
|
467
|
+
let sx = 0;
|
|
468
|
+
let sy = 0;
|
|
469
|
+
for (let i = 0; i < K; i++) {
|
|
470
|
+
sx += vals[i].x;
|
|
471
|
+
sy += vals[i].y;
|
|
472
|
+
}
|
|
473
|
+
return { x: sx / K, y: sy / K };
|
|
474
|
+
};
|
|
475
|
+
const refreshDevs = (devs, vals) => {
|
|
476
|
+
const c = centroidOf(vals);
|
|
477
|
+
return devs.map((d, i) => {
|
|
478
|
+
const dx = vals[i].x - c.x;
|
|
479
|
+
const dy = vals[i].y - c.y;
|
|
480
|
+
return dx * dx + dy * dy > 1e-18 ? { x: dx, y: dy } : d;
|
|
481
|
+
});
|
|
482
|
+
};
|
|
483
|
+
const scale = Num.lens(points, {
|
|
484
|
+
init: (vals) => {
|
|
485
|
+
const c = centroidOf(vals);
|
|
486
|
+
return { devs: vals.map(v => ({ x: v.x - c.x, y: v.y - c.y })) };
|
|
487
|
+
},
|
|
488
|
+
step: (vals, c) => ({ devs: refreshDevs(c.devs, vals) }),
|
|
489
|
+
fwd: (vals) => {
|
|
490
|
+
const c = centroidOf(vals);
|
|
491
|
+
return Math.hypot(vals[0].x - c.x, vals[0].y - c.y);
|
|
492
|
+
},
|
|
493
|
+
bwd: (target, vals, c) => {
|
|
494
|
+
const cen = centroidOf(vals);
|
|
495
|
+
const d0 = c.devs[0];
|
|
496
|
+
const r0 = Math.hypot(d0.x, d0.y);
|
|
497
|
+
if (r0 < 1e-12)
|
|
498
|
+
return { updates: vals.map(() => SKIP), complement: c };
|
|
499
|
+
const k = target / r0;
|
|
500
|
+
const out = c.devs.map(d => ({ x: cen.x + k * d.x, y: cen.y + k * d.y }));
|
|
501
|
+
return { updates: out, complement: c };
|
|
502
|
+
},
|
|
503
|
+
});
|
|
504
|
+
return { centroid, rotation, scale };
|
|
505
|
+
}
|
|
506
|
+
/** K Vecs → {center, size} of the axis-aligned bounding box. Writing `center`
|
|
507
|
+
* translates; writing `size` scales all about the center per-axis. Degenerate
|
|
508
|
+
* axes (size = 0) write as no-ops; negative size reflects. */
|
|
509
|
+
export function bbox(points) {
|
|
510
|
+
const K = points.length;
|
|
511
|
+
if (K < 1)
|
|
512
|
+
throw new Error("bbox: need ≥ 1 point");
|
|
513
|
+
const computeBox = (vals) => {
|
|
514
|
+
let minX = Number.POSITIVE_INFINITY;
|
|
515
|
+
let minY = Number.POSITIVE_INFINITY;
|
|
516
|
+
let maxX = Number.NEGATIVE_INFINITY;
|
|
517
|
+
let maxY = Number.NEGATIVE_INFINITY;
|
|
518
|
+
for (let i = 0; i < K; i++) {
|
|
519
|
+
const x = vals[i].x;
|
|
520
|
+
const y = vals[i].y;
|
|
521
|
+
if (x < minX)
|
|
522
|
+
minX = x;
|
|
523
|
+
if (x > maxX)
|
|
524
|
+
maxX = x;
|
|
525
|
+
if (y < minY)
|
|
526
|
+
minY = y;
|
|
527
|
+
if (y > maxY)
|
|
528
|
+
maxY = y;
|
|
529
|
+
}
|
|
530
|
+
return {
|
|
531
|
+
cx: (minX + maxX) / 2,
|
|
532
|
+
cy: (minY + maxY) / 2,
|
|
533
|
+
sx: maxX - minX,
|
|
534
|
+
sy: maxY - minY,
|
|
535
|
+
};
|
|
536
|
+
};
|
|
537
|
+
const center = Vec.lens(points, (vals) => {
|
|
538
|
+
const b = computeBox(vals);
|
|
539
|
+
return { x: b.cx, y: b.cy };
|
|
540
|
+
}, (target, vals) => {
|
|
541
|
+
const b = computeBox(vals);
|
|
542
|
+
const dx = target.x - b.cx;
|
|
543
|
+
const dy = target.y - b.cy;
|
|
544
|
+
const out = new Array(K);
|
|
545
|
+
for (let i = 0; i < K; i++)
|
|
546
|
+
out[i] = { x: vals[i].x + dx, y: vals[i].y + dy };
|
|
547
|
+
return out;
|
|
548
|
+
});
|
|
549
|
+
const refreshFracs = (fracs, vals) => {
|
|
550
|
+
const b = computeBox(vals);
|
|
551
|
+
const hx = b.sx > 1e-12 ? b.sx / 2 : 0;
|
|
552
|
+
const hy = b.sy > 1e-12 ? b.sy / 2 : 0;
|
|
553
|
+
return fracs.map((f, i) => ({
|
|
554
|
+
x: hx > 0 ? (vals[i].x - b.cx) / hx : f.x,
|
|
555
|
+
y: hy > 0 ? (vals[i].y - b.cy) / hy : f.y,
|
|
556
|
+
}));
|
|
557
|
+
};
|
|
558
|
+
const size = Vec.lens(points, {
|
|
559
|
+
init: (vals) => {
|
|
560
|
+
const b = computeBox(vals);
|
|
561
|
+
const halfX0 = b.sx > 1e-12 ? b.sx / 2 : 1;
|
|
562
|
+
const halfY0 = b.sy > 1e-12 ? b.sy / 2 : 1;
|
|
563
|
+
return {
|
|
564
|
+
fracs: vals.map(v => ({
|
|
565
|
+
x: b.sx > 1e-12 ? (v.x - b.cx) / halfX0 : 0,
|
|
566
|
+
y: b.sy > 1e-12 ? (v.y - b.cy) / halfY0 : 0,
|
|
567
|
+
})),
|
|
568
|
+
};
|
|
569
|
+
},
|
|
570
|
+
step: (vals, c) => ({ fracs: refreshFracs(c.fracs, vals) }),
|
|
571
|
+
fwd: (vals) => {
|
|
572
|
+
const b = computeBox(vals);
|
|
573
|
+
return { x: b.sx, y: b.sy };
|
|
574
|
+
},
|
|
575
|
+
bwd: (target, vals, c) => {
|
|
576
|
+
const b = computeBox(vals);
|
|
577
|
+
const halfTx = target.x / 2;
|
|
578
|
+
const halfTy = target.y / 2;
|
|
579
|
+
const out = c.fracs.map(f => ({ x: b.cx + f.x * halfTx, y: b.cy + f.y * halfTy }));
|
|
580
|
+
return { updates: out, complement: c };
|
|
581
|
+
},
|
|
582
|
+
});
|
|
583
|
+
return { center, size };
|
|
584
|
+
}
|
|
@@ -8,7 +8,7 @@ type V = {
|
|
|
8
8
|
export declare function hullWeights(q: V, pts: readonly V[]): number[];
|
|
9
9
|
export interface ClosestOpts {
|
|
10
10
|
/** Hysteresis margin (px): the current pick is kept until a rival is
|
|
11
|
-
* nearer by more than this.
|
|
11
|
+
* nearer by more than this. Default 0. */
|
|
12
12
|
sticky?: number;
|
|
13
13
|
}
|
|
14
14
|
/** Index of the candidate nearest `pointer`, with hysteresis. Read-only
|
package/dist/core/lenses/snap.js
CHANGED
|
@@ -1,13 +1,6 @@
|
|
|
1
|
-
//
|
|
2
|
-
//
|
|
3
|
-
//
|
|
4
|
-
//
|
|
5
|
-
// • hullWeights — barycentric weights of a point in the convex hull of K
|
|
6
|
-
// targets (closed form for K ≤ 3, Frank–Wolfe for K > 3). Powers
|
|
7
|
-
// `d.between`'s blend and any pointer-in-hull interpolation.
|
|
8
|
-
// • nearestIndex — the candidate nearest the pointer, with hysteresis. The
|
|
9
|
-
// "stickiness" lives in the lens complement (the sanctioned home for
|
|
10
|
-
// path-dependence), so reads stay pure.
|
|
1
|
+
// Pointer math for the drag algebra: `hullWeights` (barycentric weights of a
|
|
2
|
+
// point in a convex hull) and `nearestIndex` (nearest candidate, with sticky
|
|
3
|
+
// hysteresis carried in the lens complement).
|
|
11
4
|
import { lens, SKIP } from "../cell.js";
|
|
12
5
|
const sub = (a, b) => ({ x: a.x - b.x, y: a.y - b.y });
|
|
13
6
|
const dot = (a, b) => a.x * b.x + a.y * b.y;
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import type { Cell, Writable } from "../cell.js";
|
|
2
|
+
import { Str } from "../values/str.js";
|
|
3
|
+
type V = string;
|
|
4
|
+
/** Split `s` into words and separators. Returns:
|
|
5
|
+
*
|
|
6
|
+
* words[i] — the i-th run of word characters
|
|
7
|
+
* seps[0] — leading non-word characters (possibly empty)
|
|
8
|
+
* seps[i] — for 1 ≤ i ≤ words.length-1, the separator BETWEEN
|
|
9
|
+
* `words[i-1]` and `words[i]`
|
|
10
|
+
* seps[words.length] — trailing non-word characters
|
|
11
|
+
*
|
|
12
|
+
* Always satisfies `seps.length === words.length + 1`. */
|
|
13
|
+
export declare function parseWords(s: V): {
|
|
14
|
+
words: V[];
|
|
15
|
+
seps: V[];
|
|
16
|
+
};
|
|
17
|
+
/** Inverse of `parseWords`. Interleaves words with `seps`; added words
|
|
18
|
+
* get `" "` gaps, removed words keep the original trailing separator.
|
|
19
|
+
* A zero-word original (`seps.length === 1`) treats its one entry as
|
|
20
|
+
* lead only, so words append after it without double-counting as trail. */
|
|
21
|
+
export declare function rebuildWords(words: V[], seps: V[]): V;
|
|
22
|
+
/** Per-character case mask: `U` upper letter, `L` lower letter,
|
|
23
|
+
* `" "` non-letter. Length matches the source. */
|
|
24
|
+
export declare function caseMaskOf(s: V): string;
|
|
25
|
+
/** Apply a case mask to `target`, position by position. Mask positions
|
|
26
|
+
* beyond `target.length` are ignored; target positions beyond the
|
|
27
|
+
* mask keep their native case (e.g. user appended a longer word). */
|
|
28
|
+
export declare function applyCaseMask(target: V, mask: string): V;
|
|
29
|
+
/** Apply the case pattern of a source word to a target word. Detects
|
|
30
|
+
* all-upper / all-lower / title case, else falls back to position-wise
|
|
31
|
+
* `applyCaseMask`. Non-letter target chars always pass through unchanged
|
|
32
|
+
* (title-casing "-gng" → "-Gng"). */
|
|
33
|
+
export declare function applyCasePattern(target: V, mask: string): V;
|
|
34
|
+
/** Case-folded view of a string cell with word-aware case recovery on
|
|
35
|
+
* write. Read folds to lower (default) or upper; write recovers the
|
|
36
|
+
* source's per-word case — lookup priority: (1) content match (FIFO
|
|
37
|
+
* across duplicates); (2) per-position fallback for new content; (3)
|
|
38
|
+
* native for content beyond the source structure. */
|
|
39
|
+
export declare function caseFold(parent: Cell<V>, to?: "lower" | "upper"): Writable<Str>;
|
|
40
|
+
export {};
|
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
import { Str } from "../values/str.js";
|
|
2
|
+
// ── word parsing ─────────────────────────────────────────────────────
|
|
3
|
+
/** A "word" character: letters, digits, underscore, apostrophe, hyphen
|
|
4
|
+
* (handles "don't", "co-op"). Everything else is a separator. */
|
|
5
|
+
const WORD_CHAR = /[\p{L}\p{N}_'-]/u;
|
|
6
|
+
/** Split `s` into words and separators. Returns:
|
|
7
|
+
*
|
|
8
|
+
* words[i] — the i-th run of word characters
|
|
9
|
+
* seps[0] — leading non-word characters (possibly empty)
|
|
10
|
+
* seps[i] — for 1 ≤ i ≤ words.length-1, the separator BETWEEN
|
|
11
|
+
* `words[i-1]` and `words[i]`
|
|
12
|
+
* seps[words.length] — trailing non-word characters
|
|
13
|
+
*
|
|
14
|
+
* Always satisfies `seps.length === words.length + 1`. */
|
|
15
|
+
export function parseWords(s) {
|
|
16
|
+
const words = [];
|
|
17
|
+
const seps = [];
|
|
18
|
+
let cur = "";
|
|
19
|
+
let inWord = false;
|
|
20
|
+
for (let i = 0; i < s.length; i++) {
|
|
21
|
+
const c = s[i];
|
|
22
|
+
if (WORD_CHAR.test(c)) {
|
|
23
|
+
if (!inWord) {
|
|
24
|
+
seps.push(cur);
|
|
25
|
+
cur = "";
|
|
26
|
+
inWord = true;
|
|
27
|
+
}
|
|
28
|
+
cur += c;
|
|
29
|
+
}
|
|
30
|
+
else {
|
|
31
|
+
if (inWord) {
|
|
32
|
+
words.push(cur);
|
|
33
|
+
cur = "";
|
|
34
|
+
inWord = false;
|
|
35
|
+
}
|
|
36
|
+
cur += c;
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
if (inWord) {
|
|
40
|
+
words.push(cur);
|
|
41
|
+
seps.push("");
|
|
42
|
+
}
|
|
43
|
+
else {
|
|
44
|
+
seps.push(cur);
|
|
45
|
+
}
|
|
46
|
+
return { words, seps };
|
|
47
|
+
}
|
|
48
|
+
/** Inverse of `parseWords`. Interleaves words with `seps`; added words
|
|
49
|
+
* get `" "` gaps, removed words keep the original trailing separator.
|
|
50
|
+
* A zero-word original (`seps.length === 1`) treats its one entry as
|
|
51
|
+
* lead only, so words append after it without double-counting as trail. */
|
|
52
|
+
export function rebuildWords(words, seps) {
|
|
53
|
+
const n = words.length;
|
|
54
|
+
if (n === 0)
|
|
55
|
+
return seps[0] ?? "";
|
|
56
|
+
const lead = seps[0] ?? "";
|
|
57
|
+
const trail = seps.length > 1 ? (seps[seps.length - 1] ?? "") : "";
|
|
58
|
+
let out = lead;
|
|
59
|
+
for (let i = 0; i < n; i++) {
|
|
60
|
+
out += words[i];
|
|
61
|
+
if (i < n - 1) {
|
|
62
|
+
const idx = i + 1;
|
|
63
|
+
// Interior separators only; the final `seps` entry is the trail.
|
|
64
|
+
const sep = idx < seps.length - 1 ? seps[idx] : undefined;
|
|
65
|
+
out += sep !== undefined ? sep : " ";
|
|
66
|
+
}
|
|
67
|
+
else {
|
|
68
|
+
out += trail;
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
return out;
|
|
72
|
+
}
|
|
73
|
+
// ── case masks ───────────────────────────────────────────────────────
|
|
74
|
+
/** Per-character case mask: `U` upper letter, `L` lower letter,
|
|
75
|
+
* `" "` non-letter. Length matches the source. */
|
|
76
|
+
export function caseMaskOf(s) {
|
|
77
|
+
let mask = "";
|
|
78
|
+
for (let i = 0; i < s.length; i++) {
|
|
79
|
+
const c = s[i];
|
|
80
|
+
if (c >= "A" && c <= "Z")
|
|
81
|
+
mask += "U";
|
|
82
|
+
else if (c >= "a" && c <= "z")
|
|
83
|
+
mask += "L";
|
|
84
|
+
else
|
|
85
|
+
mask += " ";
|
|
86
|
+
}
|
|
87
|
+
return mask;
|
|
88
|
+
}
|
|
89
|
+
/** Apply a case mask to `target`, position by position. Mask positions
|
|
90
|
+
* beyond `target.length` are ignored; target positions beyond the
|
|
91
|
+
* mask keep their native case (e.g. user appended a longer word). */
|
|
92
|
+
export function applyCaseMask(target, mask) {
|
|
93
|
+
let out = "";
|
|
94
|
+
for (let i = 0; i < target.length; i++) {
|
|
95
|
+
const c = target[i];
|
|
96
|
+
const m = i < mask.length ? mask[i] : " ";
|
|
97
|
+
if (m === "U")
|
|
98
|
+
out += c.toUpperCase();
|
|
99
|
+
else if (m === "L")
|
|
100
|
+
out += c.toLowerCase();
|
|
101
|
+
else
|
|
102
|
+
out += c;
|
|
103
|
+
}
|
|
104
|
+
return out;
|
|
105
|
+
}
|
|
106
|
+
const ASCII_LETTER = (c) => (c >= "a" && c <= "z") || (c >= "A" && c <= "Z");
|
|
107
|
+
/** Apply the case pattern of a source word to a target word. Detects
|
|
108
|
+
* all-upper / all-lower / title case, else falls back to position-wise
|
|
109
|
+
* `applyCaseMask`. Non-letter target chars always pass through unchanged
|
|
110
|
+
* (title-casing "-gng" → "-Gng"). */
|
|
111
|
+
export function applyCasePattern(target, mask) {
|
|
112
|
+
if (target.length === 0 || mask.length === 0)
|
|
113
|
+
return target;
|
|
114
|
+
const letters = [...mask].filter(c => c === "U" || c === "L");
|
|
115
|
+
if (letters.length === 0)
|
|
116
|
+
return target;
|
|
117
|
+
if (letters.every(c => c === "U"))
|
|
118
|
+
return target.toUpperCase();
|
|
119
|
+
if (letters.every(c => c === "L"))
|
|
120
|
+
return target.toLowerCase();
|
|
121
|
+
if (letters[0] === "U" && letters.slice(1).every(c => c === "L")) {
|
|
122
|
+
// Title case: uppercase the first letter (skipping leading
|
|
123
|
+
// non-letters), lowercase the rest, pass non-letters through.
|
|
124
|
+
let out = "";
|
|
125
|
+
let firstLetterDone = false;
|
|
126
|
+
for (let i = 0; i < target.length; i++) {
|
|
127
|
+
const c = target[i];
|
|
128
|
+
if (ASCII_LETTER(c)) {
|
|
129
|
+
out += firstLetterDone ? c.toLowerCase() : c.toUpperCase();
|
|
130
|
+
firstLetterDone = true;
|
|
131
|
+
}
|
|
132
|
+
else {
|
|
133
|
+
out += c;
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
return out;
|
|
137
|
+
}
|
|
138
|
+
return applyCaseMask(target, mask);
|
|
139
|
+
}
|
|
140
|
+
/** (Re)build the case complement: positional `wordMasks` and content-keyed
|
|
141
|
+
* `byContent` in one pass. `byContent` lists stay in source order for FIFO
|
|
142
|
+
* consumption on write-back. */
|
|
143
|
+
function refreshCaseComplement(s, c) {
|
|
144
|
+
const { words } = parseWords(s);
|
|
145
|
+
const wordMasks = words.map(caseMaskOf);
|
|
146
|
+
const byContent = new Map();
|
|
147
|
+
for (let i = 0; i < words.length; i++) {
|
|
148
|
+
const key = words[i].toLowerCase();
|
|
149
|
+
let list = byContent.get(key);
|
|
150
|
+
if (list === undefined) {
|
|
151
|
+
list = [];
|
|
152
|
+
byContent.set(key, list);
|
|
153
|
+
}
|
|
154
|
+
list.push(wordMasks[i]);
|
|
155
|
+
}
|
|
156
|
+
c.wordMasks = wordMasks;
|
|
157
|
+
c.byContent = byContent;
|
|
158
|
+
}
|
|
159
|
+
/** Apply the case complement to a target string and rebuild. Each
|
|
160
|
+
* target word goes through three lookup tiers — content match
|
|
161
|
+
* (FIFO-consumed from a per-call clone), positional fallback, then
|
|
162
|
+
* native pass-through. */
|
|
163
|
+
function applyCaseComplement(target, c) {
|
|
164
|
+
const { words, seps } = parseWords(target);
|
|
165
|
+
// Per-call clone: consume FIFO without mutating the stored map, so
|
|
166
|
+
// repeated writes start from the same state.
|
|
167
|
+
const remaining = new Map();
|
|
168
|
+
for (const [k, list] of c.byContent)
|
|
169
|
+
remaining.set(k, list.slice());
|
|
170
|
+
const cased = words.map((w, i) => {
|
|
171
|
+
const key = w.toLowerCase();
|
|
172
|
+
const matches = remaining.get(key);
|
|
173
|
+
if (matches !== undefined && matches.length > 0) {
|
|
174
|
+
return applyCasePattern(w, matches.shift());
|
|
175
|
+
}
|
|
176
|
+
const mask = i < c.wordMasks.length ? c.wordMasks[i] : "";
|
|
177
|
+
return mask.length === 0 ? w : applyCasePattern(w, mask);
|
|
178
|
+
});
|
|
179
|
+
return rebuildWords(cased, seps);
|
|
180
|
+
}
|
|
181
|
+
function buildCaseComplement(s) {
|
|
182
|
+
const c = { wordMasks: [], byContent: new Map() };
|
|
183
|
+
refreshCaseComplement(s, c);
|
|
184
|
+
return c;
|
|
185
|
+
}
|
|
186
|
+
// ── caseFold ─────────────────────────────────────────────────────────
|
|
187
|
+
/** Case-folded view of a string cell with word-aware case recovery on
|
|
188
|
+
* write. Read folds to lower (default) or upper; write recovers the
|
|
189
|
+
* source's per-word case — lookup priority: (1) content match (FIFO
|
|
190
|
+
* across duplicates); (2) per-position fallback for new content; (3)
|
|
191
|
+
* native for content beyond the source structure. */
|
|
192
|
+
export function caseFold(parent, to = "lower") {
|
|
193
|
+
const fold = to === "upper" ? (s) => s.toUpperCase() : (s) => s.toLowerCase();
|
|
194
|
+
return Str.lens(parent, {
|
|
195
|
+
init: (s) => buildCaseComplement(s),
|
|
196
|
+
fwd: (s) => fold(s),
|
|
197
|
+
bwd: (target, _s, c) => ({
|
|
198
|
+
update: applyCaseComplement(target, c),
|
|
199
|
+
complement: c,
|
|
200
|
+
}),
|
|
201
|
+
});
|
|
202
|
+
}
|
package/dist/core/lifecycle.js
CHANGED
|
@@ -1,9 +1,6 @@
|
|
|
1
|
-
//
|
|
2
|
-
//
|
|
3
|
-
//
|
|
4
|
-
// `each(source, body)` — body runs per element (keyed by reference
|
|
5
|
-
// identity); cleanup on removal.
|
|
6
|
-
// `when(source, body)` — body runs while truthy; cleanup on falsy.
|
|
1
|
+
// Reactive-collection lifecycle helpers, implemented via `effect`: `each` runs a
|
|
2
|
+
// body per element (keyed by reference identity) with cleanup on removal; `when`
|
|
3
|
+
// runs a body while truthy with cleanup on falsy.
|
|
7
4
|
import { effect } from "./cell.js";
|
|
8
5
|
/** Run `body(item)` per element on first sight (storing its cleanup) and
|
|
9
6
|
* run that cleanup when the element leaves. Identity is element
|