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.
Files changed (95) hide show
  1. package/README.md +14 -7
  2. package/dist/automerge/doc-cell.d.ts +24 -11
  3. package/dist/automerge/doc-cell.js +19 -13
  4. package/dist/automerge/index.d.ts +3 -2
  5. package/dist/automerge/index.js +6 -5
  6. package/dist/automerge/reconcile.d.ts +5 -2
  7. package/dist/automerge/reconcile.js +73 -15
  8. package/dist/core/_counts.js +5 -12
  9. package/dist/core/cell.d.ts +3 -3
  10. package/dist/core/cell.js +6 -7
  11. package/dist/core/derived-geometry.js +4 -7
  12. package/dist/core/index.d.ts +3 -1
  13. package/dist/core/index.js +3 -1
  14. package/dist/core/lenses/aggregates.d.ts +42 -52
  15. package/dist/core/lenses/aggregates.js +225 -116
  16. package/dist/core/lenses/geometry.d.ts +22 -4
  17. package/dist/core/lenses/geometry.js +59 -27
  18. package/dist/core/lenses/index.d.ts +5 -6
  19. package/dist/core/lenses/index.js +5 -6
  20. package/dist/core/lenses/memory.js +4 -17
  21. package/dist/core/lenses/numerical.d.ts +100 -0
  22. package/dist/core/lenses/{typed-factor.js → numerical.js} +136 -34
  23. package/dist/core/lenses/point-cloud.d.ts +67 -0
  24. package/dist/core/lenses/{closed-form-policies.js → point-cloud.js} +218 -81
  25. package/dist/core/lenses/snap.d.ts +1 -1
  26. package/dist/core/lenses/snap.js +3 -10
  27. package/dist/core/lenses/text.d.ts +40 -0
  28. package/dist/core/lenses/text.js +202 -0
  29. package/dist/core/lifecycle.js +3 -6
  30. package/dist/core/linalg.js +5 -11
  31. package/dist/core/optic.js +10 -15
  32. package/dist/core/optics.js +4 -8
  33. package/dist/core/store.d.ts +1 -2
  34. package/dist/core/store.js +7 -15
  35. package/dist/core/traits.d.ts +4 -7
  36. package/dist/core/traits.js +8 -12
  37. package/dist/core/values/anchor.js +0 -4
  38. package/dist/core/values/arr.d.ts +110 -0
  39. package/dist/core/values/arr.js +336 -0
  40. package/dist/core/values/audio.d.ts +8 -9
  41. package/dist/core/values/audio.js +7 -23
  42. package/dist/core/values/bool.d.ts +11 -11
  43. package/dist/core/values/bool.js +12 -22
  44. package/dist/core/values/box.d.ts +15 -20
  45. package/dist/core/values/box.js +20 -33
  46. package/dist/core/values/canvas.d.ts +18 -25
  47. package/dist/core/values/canvas.js +17 -48
  48. package/dist/core/values/color.d.ts +5 -7
  49. package/dist/core/values/color.js +5 -11
  50. package/dist/core/values/field.d.ts +6 -7
  51. package/dist/core/values/field.js +10 -35
  52. package/dist/core/values/flags.d.ts +1 -2
  53. package/dist/core/values/flags.js +1 -17
  54. package/dist/core/values/gpu.d.ts +6 -10
  55. package/dist/core/values/gpu.js +8 -22
  56. package/dist/core/values/matrix.d.ts +2 -4
  57. package/dist/core/values/matrix.js +2 -12
  58. package/dist/core/values/num.d.ts +19 -28
  59. package/dist/core/values/num.js +23 -41
  60. package/dist/core/values/pose.d.ts +2 -4
  61. package/dist/core/values/pose.js +3 -12
  62. package/dist/core/values/range.d.ts +18 -26
  63. package/dist/core/values/range.js +22 -39
  64. package/dist/core/values/reg/ambiguity.d.ts +8 -0
  65. package/dist/core/values/reg/ambiguity.js +131 -0
  66. package/dist/core/values/reg/engine.d.ts +91 -0
  67. package/dist/core/values/reg/engine.js +373 -0
  68. package/dist/core/values/reg/nfa.d.ts +42 -0
  69. package/dist/core/values/reg/nfa.js +391 -0
  70. package/dist/core/values/reg/regex.d.ts +7 -0
  71. package/dist/core/values/reg/regex.js +318 -0
  72. package/dist/core/values/reg/types.d.ts +60 -0
  73. package/dist/core/values/reg/types.js +3 -0
  74. package/dist/core/values/reg.d.ts +250 -0
  75. package/dist/core/values/reg.js +649 -0
  76. package/dist/core/values/str.d.ts +16 -60
  77. package/dist/core/values/str.js +133 -315
  78. package/dist/core/values/template.js +1 -24
  79. package/dist/core/values/transform.d.ts +3 -5
  80. package/dist/core/values/transform.js +3 -12
  81. package/dist/core/values/tri.d.ts +9 -10
  82. package/dist/core/values/tri.js +9 -15
  83. package/dist/core/values/vec.d.ts +9 -24
  84. package/dist/core/values/vec.js +9 -64
  85. package/dist/index.d.ts +0 -11
  86. package/dist/index.js +1 -11
  87. package/package.json +17 -10
  88. package/dist/coll.d.ts +0 -74
  89. package/dist/coll.js +0 -210
  90. package/dist/core/lenses/closed-form-policies.d.ts +0 -57
  91. package/dist/core/lenses/decompositions.d.ts +0 -14
  92. package/dist/core/lenses/decompositions.js +0 -224
  93. package/dist/core/lenses/domain-aggregates.d.ts +0 -42
  94. package/dist/core/lenses/domain-aggregates.js +0 -245
  95. package/dist/core/lenses/typed-factor.d.ts +0 -40
@@ -1,16 +1,9 @@
1
- // closed-form-policies.ts — exact group-action lenses for point clouds.
2
- //
3
- // When an aggregate lens has a closed-form inverse, its bwd applies a
4
- // GROUP ELEMENT to the source set. Translation, rotation-about-pivot,
5
- // and scale-about-pivot are the building blocks; Procrustes, best-fit
6
- // line/circle, and PCA decompose into combinations of them.
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(`closed-form-policies: ${name} has no traits.pivotal`);
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
- * Trait-generic: Vec rotates position; Pose rotates position AND
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). Exact
65
- * cross-channel invariance with `rotateAbout`.
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. Vec-specific (Pivotal has no per-axis
115
- * method yet). Complement carries per-point per-axis fractions of
116
- * point 0's offset, so a per-axis collapse is recoverable (cf.
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 = rigidTranslate(points);
186
- // The principal axis is an eigenvector defined only up to sign, so the
187
- // raw atan2 jumps by π as the cloud rotates. `continuous` lifts it to its
188
- // universal cover (period π, since axis axis + π), tracking the last
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
- // Best-fit circle.
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 = rigidTranslate(points);
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
- // PCA / affine similarity decomposition.
265
- //
266
- // K points {mean: centroid, rotation: dominant-eigenvector angle,
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 mean = rigidTranslate(points);
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 `remember` anchored at zero with a signed
426
- * sum feature: a collapse to zero reinflates the stored ratios, seeded
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
- // Every lens here is a group action about a pivot (translate, rotateAbout,
445
- // scaleAbout, scaleAboutXY, scaleAlongAxis) or a `remember`/`continuous`
446
- // shape-memory; the decompositions combine them, each measured against a
447
- // derived feature (centroid, principal axis, mean radius).
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. Dragology's stickiness. Default 0. */
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
@@ -1,13 +1,6 @@
1
- // snap.ts the pointer math the `d` drag algebra (shapes/drag-spec.ts) and the
2
- // demos build on. Candidate positions are live layout cells, so there's no
3
- // speculative re-render to recover them:
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
+ }
@@ -1,9 +1,6 @@
1
- // lifecycle.ts — reactive-collection lifecycle helpers over the
2
- // `network` model. Implemented purely via `effect`:
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