@wave3d/core 0.2.2 → 0.3.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.
@@ -0,0 +1,474 @@
1
+ import { clamp01 } from "../util/math.js";
2
+ import * as THREE from "three";
3
+ const RIPPLE_LIFETIME = 1.5;
4
+ const VELOCITY_TAU = .08;
5
+ const POINTER_SPEED_REF = 4;
6
+ const SCROLL_VELOCITY_REF = 2;
7
+ const SCROLL_VELOCITY_TAU = .15;
8
+ const DEFAULT_POINTER_TAU = .12;
9
+ const DEFAULT_BINDING_TAU = .25;
10
+ const POINTER_SPRING_ZETA = .7;
11
+ const MIN_POINTER_TAU = .02;
12
+ const SPRING_MAX_STEP = 1 / 120;
13
+ const SPRING_MAX_SUBSTEPS = 6;
14
+ /** Frame-rate-independent exponential smoothing factor for time constant `tau` (seconds). */
15
+ function alpha(tau, dt) {
16
+ return tau > 0 ? 1 - Math.exp(-dt / tau) : 1;
17
+ }
18
+ /**
19
+ * Advance a damped spring (`pos`/`vel`) toward `target` by `dt`, using semi-implicit (symplectic)
20
+ * Euler. `omega` is the natural angular frequency (≈ 1/response-time), `zeta` the damping ratio
21
+ * (<1 underdamped → overshoots and settles; 1 critical; >1 sluggish). Unlike a first-order lag this
22
+ * carries momentum, so motion has weight and settles instead of creeping to a dead stop. Substeps
23
+ * when `dt` spikes (e.g. the tab was backgrounded) so a stiff spring can't blow up; ~1 step at 60fps.
24
+ */
25
+ function springVec2(pos, vel, target, omega, zeta, dt) {
26
+ if (dt <= 0) return;
27
+ const steps = dt > SPRING_MAX_STEP ? Math.min(Math.ceil(dt / SPRING_MAX_STEP), SPRING_MAX_SUBSTEPS) : 1;
28
+ const h = dt / steps;
29
+ const k = omega * omega;
30
+ const c = 2 * zeta * omega;
31
+ for (let s = 0; s < steps; s++) {
32
+ vel.x += (k * (target.x - pos.x) - c * vel.x) * h;
33
+ vel.y += (k * (target.y - pos.y) - c * vel.y) * h;
34
+ pos.x += vel.x * h;
35
+ pos.y += vel.y * h;
36
+ }
37
+ }
38
+ const waveApplier = (base, apply) => ({
39
+ base,
40
+ apply
41
+ });
42
+ const sceneApplier = (base, apply) => ({
43
+ base,
44
+ apply
45
+ });
46
+ /**
47
+ * Per-wave binding targets → (how to read the authored base value, how to write the modulated one).
48
+ * Each base() mirrors the exact fallback refresh() uses, so a binding at rest (from omitted, source
49
+ * 0) writes the same value the renderer already had — no visible jump. This object is the runtime
50
+ * source of truth for {@link WaveInteractionTarget} (enforced by `satisfies`).
51
+ */
52
+ const WAVE_APPLIERS = {
53
+ displaceAmount: waveApplier((w) => w.displaceAmount, (v, a) => {
54
+ a.u.uDispAmount.value = v;
55
+ }),
56
+ detailAmount: waveApplier((w) => w.detailAmount ?? 0, (v, a) => {
57
+ a.u.uDetailAmount.value = v;
58
+ }),
59
+ twistPowerX: waveApplier((w) => w.twistPower.x, (v, a) => {
60
+ a.u.uTwPowX.value = v;
61
+ }),
62
+ twistPowerY: waveApplier((w) => w.twistPower.y, (v, a) => {
63
+ a.u.uTwPowY.value = v;
64
+ }),
65
+ twistPowerZ: waveApplier((w) => w.twistPower.z, (v, a) => {
66
+ a.u.uTwPowZ.value = v;
67
+ }),
68
+ twistFrequencyX: waveApplier((w) => w.twistFrequency.x, (v, a) => {
69
+ a.u.uTwFreqX.value = v;
70
+ }),
71
+ twistFrequencyY: waveApplier((w) => w.twistFrequency.y, (v, a) => {
72
+ a.u.uTwFreqY.value = v;
73
+ }),
74
+ twistFrequencyZ: waveApplier((w) => w.twistFrequency.z, (v, a) => {
75
+ a.u.uTwFreqZ.value = v;
76
+ }),
77
+ hueShift: waveApplier((w) => w.hueShift, (v, a) => {
78
+ a.u.uHueShift.value = v;
79
+ }),
80
+ gradientShift: waveApplier((w) => w.gradientShift ?? 0, (v, a) => {
81
+ a.u.uGradShift.value = v;
82
+ }),
83
+ colorSaturation: waveApplier((w) => w.colorSaturation, (v, a) => {
84
+ a.u.uSaturation.value = v;
85
+ }),
86
+ opacity: waveApplier((w) => w.opacity, (v, a) => {
87
+ a.u.uOpacity.value = v;
88
+ }),
89
+ lineThickness: waveApplier((w) => w.lineThickness ?? 1, (v, a) => {
90
+ a.u.uLineThickness.value = v;
91
+ }),
92
+ lineAmount: waveApplier((w) => w.lineAmount ?? 425, (v, a) => {
93
+ a.u.uLineAmount.value = v;
94
+ }),
95
+ fiberStrength: waveApplier((w) => w.fiberStrength, (v, a) => {
96
+ a.u.uFiberStrength.value = v;
97
+ }),
98
+ sheen: waveApplier((w) => w.sheen ?? 1, (v, a) => {
99
+ a.u.uSheen.value = v;
100
+ }),
101
+ iridescence: waveApplier((w) => w.iridescence ?? 0, (v, a) => {
102
+ a.u.uIridescence.value = v;
103
+ }),
104
+ positionX: waveApplier((w) => w.position.x, (v, a) => {
105
+ a.mesh.position.x = v;
106
+ }),
107
+ positionY: waveApplier((w) => w.position.y, (v, a) => {
108
+ a.mesh.position.y = v;
109
+ })
110
+ };
111
+ /** Scene-level binding targets. base() mirrors updateTime() / applyZoom() / applyPost() fallbacks. */
112
+ const SCENE_APPLIERS = {
113
+ timeOffset: sceneApplier((c) => c.timeOffset ?? 0, (v, a) => {
114
+ a.out.timeOffset = v;
115
+ }),
116
+ cameraZoom: sceneApplier((c) => c.cameraZoom ?? 1, (v, a) => {
117
+ a.out.zoom = v;
118
+ }),
119
+ blur: sceneApplier((c) => c.blur, (v, a) => {
120
+ a.post.uBlurAmount.value = v;
121
+ }),
122
+ grain: sceneApplier((c) => c.grain, (v, a) => {
123
+ a.post.uGrainAmount.value = v;
124
+ })
125
+ };
126
+ /** The global master switch: only `scene.interaction.enabled === false` turns the whole layer off. */
127
+ function notDisabled(cfg) {
128
+ return cfg.interaction?.enabled !== false;
129
+ }
130
+ /** Whether a wave has a pointer field (hover effects, or a click ripple). */
131
+ function waveHasPointerField(w) {
132
+ const it = w.interaction;
133
+ return !!it && (!!it.hover || (it.press?.ripple ?? 0) > 0);
134
+ }
135
+ /** Whether this wave has an active pointer field → its POINTER_FX shader path compiles. */
136
+ function wavePointerFxActive(cfg, w) {
137
+ return notDisabled(cfg) && waveHasPointerField(w);
138
+ }
139
+ /** Whether this wave has active click ripples → its nested POINTER_RIPPLES path compiles. */
140
+ function waveRipplesActive(cfg, w) {
141
+ return notDisabled(cfg) && (w.interaction?.press?.ripple ?? 0) > 0;
142
+ }
143
+ /** Whether ANY wave has a pointer field (so the renderer bothers writing the shared pointer uniforms). */
144
+ function anyPointerFxActive(cfg) {
145
+ return notDisabled(cfg) && cfg.waves.some(waveHasPointerField);
146
+ }
147
+ /** Whether the interaction layer should run at all (any wave interaction, or any scene binding). */
148
+ function interactionActive(cfg) {
149
+ if (!notDisabled(cfg)) return false;
150
+ if ((cfg.interaction?.bindings?.length ?? 0) > 0) return true;
151
+ return cfg.waves.some((w) => {
152
+ const it = w.interaction;
153
+ return !!it && (!!it.hover || (it.press?.ripple ?? 0) > 0 || (it.bindings?.length ?? 0) > 0);
154
+ });
155
+ }
156
+ /**
157
+ * Owns the one cursor's input + scroll + press/appear/custom and all smoothing. Constructed by the
158
+ * renderer when {@link interactionActive} first turns true, disposed when it turns false. All
159
+ * listeners are passive and container-scoped (the poster overlay passes events through).
160
+ */
161
+ var InteractionController = class {
162
+ container;
163
+ cfg;
164
+ /** Studio-only scroll preview: when non-null, overrides the computed scroll progress. */
165
+ scrollOverride = null;
166
+ ndc = new THREE.Vector2();
167
+ ndcTarget = new THREE.Vector2();
168
+ ndcPrev = new THREE.Vector2();
169
+ velNdc = new THREE.Vector2();
170
+ presence = 0;
171
+ presenceTarget = 0;
172
+ press = 0;
173
+ pressTarget = 0;
174
+ pointerSpeed = 0;
175
+ scroll = 0;
176
+ scrollPrev = 0;
177
+ scrollVel = 0;
178
+ appearLatched = false;
179
+ customInputs = /* @__PURE__ */ new Map();
180
+ ripples = [];
181
+ fields = [];
182
+ bindingState = /* @__PURE__ */ new Map();
183
+ seenBindings = /* @__PURE__ */ new Set();
184
+ out;
185
+ constructor(container, cfg) {
186
+ this.container = container;
187
+ this.cfg = cfg;
188
+ for (let i = 0; i < 4; i++) this.ripples.push({
189
+ origin: new THREE.Vector2(),
190
+ age: 0,
191
+ amp: 0,
192
+ active: false
193
+ });
194
+ this.out = {
195
+ ndc: this.ndc,
196
+ presence: 0,
197
+ ripples: this.ripples
198
+ };
199
+ const opts = { passive: true };
200
+ container.addEventListener("pointerenter", this.onPointerEnter, opts);
201
+ container.addEventListener("pointermove", this.onPointerMove, opts);
202
+ container.addEventListener("pointerleave", this.onPointerLeave, opts);
203
+ container.addEventListener("pointercancel", this.onPointerCancel, opts);
204
+ container.addEventListener("pointerdown", this.onPointerDown, opts);
205
+ container.addEventListener("pointerup", this.onPointerUp, opts);
206
+ }
207
+ /** Ignore coarse (touch) pointers unless the scene opts in with interaction.touch. */
208
+ ignore(e) {
209
+ return e.pointerType === "touch" && this.cfg()?.interaction?.touch !== true;
210
+ }
211
+ setNdcTarget(e) {
212
+ const rect = this.container.getBoundingClientRect();
213
+ if (rect.width <= 0 || rect.height <= 0) return;
214
+ this.ndcTarget.set((e.clientX - rect.left) / rect.width * 2 - 1, -((e.clientY - rect.top) / rect.height * 2 - 1));
215
+ }
216
+ onPointerEnter = (e) => {
217
+ if (this.ignore(e)) return;
218
+ this.presenceTarget = 1;
219
+ this.setNdcTarget(e);
220
+ };
221
+ onPointerMove = (e) => {
222
+ if (this.ignore(e)) return;
223
+ if (e.pointerType === "touch" && this.pressTarget < .5) return;
224
+ this.presenceTarget = 1;
225
+ this.setNdcTarget(e);
226
+ };
227
+ onPointerLeave = (e) => {
228
+ if (this.ignore(e)) return;
229
+ this.presenceTarget = 0;
230
+ this.ndcTarget.set(0, 0);
231
+ };
232
+ onPointerCancel = (e) => {
233
+ if (this.ignore(e)) return;
234
+ this.pressTarget = 0;
235
+ this.presenceTarget = 0;
236
+ this.ndcTarget.set(0, 0);
237
+ };
238
+ onPointerDown = (e) => {
239
+ if (this.ignore(e)) return;
240
+ this.pressTarget = 1;
241
+ this.presenceTarget = 1;
242
+ this.setNdcTarget(e);
243
+ const cfg = this.cfg();
244
+ if (cfg && cfg.waves.some((w) => (w.interaction?.press?.ripple ?? 0) > 0)) this.spawnRipple();
245
+ };
246
+ onPointerUp = (e) => {
247
+ if (this.ignore(e)) return;
248
+ this.pressTarget = 0;
249
+ if (e.pointerType === "touch") {
250
+ this.presenceTarget = 0;
251
+ this.ndcTarget.set(0, 0);
252
+ }
253
+ };
254
+ /** Spawn a normalized ripple (envelope 0..1) at the click NDC; per-wave amplitude scales it in the
255
+ * shader. Reuses a free slot or evicts the oldest. */
256
+ spawnRipple() {
257
+ let slot = this.ripples.find((r) => !r.active);
258
+ if (!slot) {
259
+ slot = this.ripples[0];
260
+ for (const r of this.ripples) if (r.age > slot.age) slot = r;
261
+ }
262
+ slot.origin.copy(this.ndcTarget);
263
+ slot.age = 0;
264
+ slot.amp = 1;
265
+ slot.active = true;
266
+ }
267
+ /** Advance all smoothed state by `dt` seconds. Called from the render loop with the same delta. */
268
+ update(dt) {
269
+ const cfg = this.cfg();
270
+ if (!cfg) return;
271
+ const d = Math.max(dt, 0);
272
+ const kPointer = alpha(DEFAULT_POINTER_TAU, d);
273
+ this.ndcPrev.copy(this.ndc);
274
+ this.ndc.lerp(this.ndcTarget, kPointer);
275
+ this.presence += (this.presenceTarget - this.presence) * kPointer;
276
+ this.press += (this.pressTarget - this.press) * kPointer;
277
+ if (d > 1e-5) {
278
+ const kv = alpha(VELOCITY_TAU, d);
279
+ this.velNdc.x += ((this.ndc.x - this.ndcPrev.x) / d - this.velNdc.x) * kv;
280
+ this.velNdc.y += ((this.ndc.y - this.ndcPrev.y) / d - this.velNdc.y) * kv;
281
+ }
282
+ this.pointerSpeed = this.presence * clamp01(this.velNdc.length() / POINTER_SPEED_REF);
283
+ const waves = cfg.waves;
284
+ if (this.fields.length > waves.length) this.fields.length = waves.length;
285
+ for (let i = 0; i < waves.length; i++) {
286
+ let f = this.fields[i];
287
+ if (!f) {
288
+ f = {
289
+ ndc: this.ndcTarget.clone(),
290
+ vel: new THREE.Vector2(),
291
+ presence: this.presenceTarget
292
+ };
293
+ this.fields[i] = f;
294
+ }
295
+ const tau = Math.max(waves[i].interaction?.hover?.smoothing ?? DEFAULT_POINTER_TAU, MIN_POINTER_TAU);
296
+ springVec2(f.ndc, f.vel, this.ndcTarget, 1 / tau, POINTER_SPRING_ZETA, d);
297
+ f.presence += (this.presenceTarget - f.presence) * alpha(tau, d);
298
+ }
299
+ const rawScroll = this.scrollOverride ?? this.computeScroll();
300
+ if (d > 1e-5) {
301
+ const sv = Math.abs(rawScroll - this.scrollPrev) / d;
302
+ this.scrollVel += (sv - this.scrollVel) * alpha(SCROLL_VELOCITY_TAU, d);
303
+ }
304
+ this.scrollPrev = rawScroll;
305
+ this.scroll = rawScroll;
306
+ this.appearLatched = true;
307
+ for (const r of this.ripples) {
308
+ if (!r.active) continue;
309
+ r.age += d;
310
+ const env = Math.max(0, 1 - r.age / RIPPLE_LIFETIME);
311
+ r.amp = env * env;
312
+ if (r.amp <= 0) r.active = false;
313
+ }
314
+ this.updateBindings(cfg, d);
315
+ }
316
+ updateBindings(cfg, dt) {
317
+ const seen = this.seenBindings;
318
+ seen.clear();
319
+ const sceneBindings = cfg.interaction?.bindings;
320
+ if (sceneBindings) for (let i = 0; i < sceneBindings.length; i++) this.advanceBinding(sceneBindings[i], dt);
321
+ for (let w = 0; w < cfg.waves.length; w++) {
322
+ const bindings = cfg.waves[w].interaction?.bindings;
323
+ if (!bindings) continue;
324
+ for (let i = 0; i < bindings.length; i++) this.advanceBinding(bindings[i], dt);
325
+ }
326
+ if (this.bindingState.size > seen.size) {
327
+ for (const key of this.bindingState.keys()) if (!seen.has(key)) this.bindingState.delete(key);
328
+ }
329
+ }
330
+ /** Advance one binding's smoothed source value by `dt` and mark it live in `seenBindings`. */
331
+ advanceBinding(b, dt) {
332
+ this.seenBindings.add(b);
333
+ const raw = this.rawSource(b.source);
334
+ let st = this.bindingState.get(b);
335
+ if (!st || st.source !== b.source) {
336
+ st = {
337
+ value: b.source === "appear" ? 0 : raw,
338
+ source: b.source
339
+ };
340
+ this.bindingState.set(b, st);
341
+ }
342
+ st.value += (raw - st.value) * alpha(b.smoothing ?? DEFAULT_BINDING_TAU, dt);
343
+ }
344
+ /** The current smoothed 0..1 value of a binding's source (0 if the binding is unknown). */
345
+ bindingValue(b) {
346
+ return this.bindingState.get(b)?.value ?? 0;
347
+ }
348
+ /** The current raw (un-per-binding-smoothed) 0..1 value of a source signal. */
349
+ rawSource(source) {
350
+ switch (source) {
351
+ case "scroll": return this.scroll;
352
+ case "hover": return this.presence;
353
+ case "pointerX": return (this.ndc.x + 1) * .5;
354
+ case "pointerY": return (this.ndc.y + 1) * .5;
355
+ case "pointerSpeed": return this.pointerSpeed;
356
+ case "press": return this.press;
357
+ case "scrollVelocity": return clamp01(this.scrollVel / SCROLL_VELOCITY_REF);
358
+ case "appear": return this.appearLatched ? 1 : 0;
359
+ default: return this.customInputs.get(source.slice(7)) ?? 0;
360
+ }
361
+ }
362
+ /** Container progress through the viewport: 0 as it enters from below, 1 once scrolled past. */
363
+ computeScroll() {
364
+ const rect = this.container.getBoundingClientRect();
365
+ const vh = window.innerHeight || document.documentElement.clientHeight || 1;
366
+ return clamp01((vh - rect.top) / (vh + rect.height));
367
+ }
368
+ /** The shared pointer-field state + ripples for the renderer (live references — read synchronously). */
369
+ sample() {
370
+ this.out.presence = this.presence;
371
+ return this.out;
372
+ }
373
+ /** This wave's smoothed pointer-field state (it trails the cursor at its own hover smoothing), or
374
+ * null if the wave hasn't been advanced by update() yet (treat as rest). */
375
+ fieldFor(waveIdx) {
376
+ return this.fields[waveIdx] ?? null;
377
+ }
378
+ /** Velocity-driven agitation drive 0..1 (how fast the cursor is moving, presence-gated). The
379
+ * renderer scales each wave's hover `agitate` by this, so the churn tracks the gesture instead
380
+ * of buzzing at a constant rate whenever the cursor is merely present. */
381
+ pointerFlux() {
382
+ return this.pointerSpeed;
383
+ }
384
+ /** Smoothed pointer velocity, NDC/s (direction + speed of the drag). The renderer feeds it to the
385
+ * drag-wake shader so the trailing trough forms behind the motion. Live reference — read per frame. */
386
+ pointerVelocity() {
387
+ return this.velNdc;
388
+ }
389
+ /** Feed a `custom:<name>` input (developer API; staged/forwarded by the shell). */
390
+ setInput(name, value) {
391
+ if (typeof name !== "string" || !Number.isFinite(value)) return;
392
+ this.customInputs.set(name, value);
393
+ }
394
+ /**
395
+ * Collapse to the settled resting state for the single frame drawn when the loop stops (paused /
396
+ * reduced-motion / offscreen): presence / velocity / press / pointerSpeed → 0, ripples cleared,
397
+ * scroll → its current raw value, pointer → centre, and `appear` → 1 (reduced-motion users must
398
+ * see the FINAL entered state). Custom inputs KEEP their last explicit values. Each binding snaps
399
+ * to its settled source so the one settled frame shows the final look.
400
+ */
401
+ settle() {
402
+ this.presence = this.presenceTarget = 0;
403
+ this.press = this.pressTarget = 0;
404
+ this.pointerSpeed = 0;
405
+ this.velNdc.set(0, 0);
406
+ this.ndc.set(0, 0);
407
+ this.ndcTarget.set(0, 0);
408
+ this.ndcPrev.set(0, 0);
409
+ for (const f of this.fields) {
410
+ f.ndc.set(0, 0);
411
+ f.vel.set(0, 0);
412
+ f.presence = 0;
413
+ }
414
+ for (const r of this.ripples) {
415
+ r.age = 0;
416
+ r.amp = 0;
417
+ r.active = false;
418
+ }
419
+ const rawScroll = this.scrollOverride ?? this.computeScroll();
420
+ this.scroll = this.scrollPrev = rawScroll;
421
+ this.scrollVel = 0;
422
+ this.appearLatched = true;
423
+ const cfg = this.cfg();
424
+ if (cfg) {
425
+ this.bindingState.clear();
426
+ const snap = (b) => {
427
+ this.bindingState.set(b, {
428
+ value: this.rawSource(b.source),
429
+ source: b.source
430
+ });
431
+ };
432
+ for (const b of cfg.interaction?.bindings ?? []) snap(b);
433
+ for (const w of cfg.waves) for (const b of w.interaction?.bindings ?? []) snap(b);
434
+ }
435
+ }
436
+ /**
437
+ * Snap scroll progress + the scroll-sourced bindings to the current override at once, leaving
438
+ * every other input (pointer / press / appear / velocity / custom) advancing live. Used by the
439
+ * studio scroll preview: the studio page never really scrolls, so dragging the preview slider is a
440
+ * manual scrub that must reflect the instant you move it — not on the next animation frame, which
441
+ * the browser fully suspends whenever the tab isn't foreground. Unlike settle() (which collapses
442
+ * ALL input to rest for a paused still frame), this touches only the scroll signal.
443
+ */
444
+ snapScroll() {
445
+ const raw = this.scrollOverride ?? this.computeScroll();
446
+ this.scroll = this.scrollPrev = raw;
447
+ this.scrollVel = 0;
448
+ const cfg = this.cfg();
449
+ if (!cfg) return;
450
+ const snap = (b) => {
451
+ if (b.source === "scroll" || b.source === "scrollVelocity") this.bindingState.set(b, {
452
+ value: this.rawSource(b.source),
453
+ source: b.source
454
+ });
455
+ };
456
+ for (const b of cfg.interaction?.bindings ?? []) snap(b);
457
+ for (const w of cfg.waves) for (const b of w.interaction?.bindings ?? []) snap(b);
458
+ }
459
+ dispose() {
460
+ const c = this.container;
461
+ c.removeEventListener("pointerenter", this.onPointerEnter);
462
+ c.removeEventListener("pointermove", this.onPointerMove);
463
+ c.removeEventListener("pointerleave", this.onPointerLeave);
464
+ c.removeEventListener("pointercancel", this.onPointerCancel);
465
+ c.removeEventListener("pointerdown", this.onPointerDown);
466
+ c.removeEventListener("pointerup", this.onPointerUp);
467
+ this.customInputs.clear();
468
+ this.bindingState.clear();
469
+ }
470
+ };
471
+ //#endregion
472
+ export { InteractionController, SCENE_APPLIERS, WAVE_APPLIERS, anyPointerFxActive, interactionActive, wavePointerFxActive, waveRipplesActive };
473
+
474
+ //# sourceMappingURL=interaction.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"interaction.js","names":[],"sources":["../../src/renderer/interaction.ts"],"sourcesContent":["// The optional interactivity runtime for the wave renderer: a per-wave pointer field (localized\n// cursor effects) + per-wave and scene input→param bindings, driven by ONE shared cursor/scroll.\n// It lives in renderer/ so it stays below the shell/studio/index layers (depcruise); it may import\n// only `three`, ../config/model, and ../util/math.\n//\n// Split of responsibility with WaveRenderer: this controller owns ALL input + smoothing (the one\n// cursor's position / presence / press / velocity, scroll progress + velocity, the `appear` latch,\n// custom inputs, click ripples, and every binding's smoothed 0..1 source value — keyed by binding\n// identity so scene + per-wave binding lists all get their own smoothing). The renderer reads\n// sample() / bindingValue() once per frame and writes uniforms — the pointer field per wave, and\n// bindings via the WAVE_APPLIERS / SCENE_APPLIERS tables. Bindings NEVER mutate `config`.\nimport * as THREE from \"three\";\nimport { clamp01 } from \"../util/math\";\nimport type {\n InteractionSource,\n SceneInteractionBinding,\n SceneInteractionTarget,\n StudioConfig,\n WaveConfig,\n WaveInteractionBinding,\n WaveInteractionTarget,\n} from \"../config/model\";\n\n/** Click-ripple ring-buffer size. MUST match the `[4]` array sizes in shaders.ts (POINTER_RIPPLES). */\nexport const RIPPLE_SLOTS = 4;\nconst RIPPLE_LIFETIME = 1.5; // seconds a ripple lives (crest travels out + fades by then)\nconst VELOCITY_TAU = 0.08; // pointer-velocity smoothing time constant (seconds)\nconst POINTER_SPEED_REF = 4.0; // NDC/s that normalizes pointerSpeed to 1.0\nconst SCROLL_VELOCITY_REF = 2.0; // progress/s that normalizes scrollVelocity to 1.0\nconst SCROLL_VELOCITY_TAU = 0.15; // scroll-velocity smoothing (seconds)\nconst DEFAULT_POINTER_TAU = 0.12; // pointer-follow smoothing default (seconds)\nconst DEFAULT_BINDING_TAU = 0.25; // per-binding source smoothing default (seconds)\nconst POINTER_SPRING_ZETA = 0.7; // pointer-field damping ratio (<1 → slight overshoot = \"weight\")\nconst MIN_POINTER_TAU = 0.02; // floor before smoothing→spring frequency (omega = 1/tau)\nconst SPRING_MAX_STEP = 1 / 120; // substep the spring below this dt so it stays stable after a stall\nconst SPRING_MAX_SUBSTEPS = 6;\n\ntype AnyBinding = WaveInteractionBinding | SceneInteractionBinding;\n\n/** Frame-rate-independent exponential smoothing factor for time constant `tau` (seconds). */\nfunction alpha(tau: number, dt: number): number {\n return tau > 0 ? 1 - Math.exp(-dt / tau) : 1;\n}\n\n/**\n * Advance a damped spring (`pos`/`vel`) toward `target` by `dt`, using semi-implicit (symplectic)\n * Euler. `omega` is the natural angular frequency (≈ 1/response-time), `zeta` the damping ratio\n * (<1 underdamped → overshoots and settles; 1 critical; >1 sluggish). Unlike a first-order lag this\n * carries momentum, so motion has weight and settles instead of creeping to a dead stop. Substeps\n * when `dt` spikes (e.g. the tab was backgrounded) so a stiff spring can't blow up; ~1 step at 60fps.\n */\nfunction springVec2(\n pos: THREE.Vector2,\n vel: THREE.Vector2,\n target: THREE.Vector2,\n omega: number,\n zeta: number,\n dt: number,\n): void {\n if (dt <= 0) return;\n const steps =\n dt > SPRING_MAX_STEP ? Math.min(Math.ceil(dt / SPRING_MAX_STEP), SPRING_MAX_SUBSTEPS) : 1;\n const h = dt / steps;\n const k = omega * omega;\n const c = 2 * zeta * omega;\n for (let s = 0; s < steps; s++) {\n vel.x += (k * (target.x - pos.x) - c * vel.x) * h;\n vel.y += (k * (target.y - pos.y) - c * vel.y) * h;\n pos.x += vel.x * h;\n pos.y += vel.y * h;\n }\n}\n\n// ---- Binding applier tables -----------------------------------------------------------------\n\n/** What a wave-scoped applier writes into: one wave's uniforms + its mesh transform. */\nexport interface WaveApplyArgs {\n u: Record<string, THREE.IUniform>;\n mesh: THREE.Object3D;\n}\n/** What a scene-scoped applier writes into: the post-pass uniforms + a small out-param the renderer\n * seeds (0 / 1) each frame and reads back (the interaction time-offset + zoom multiplier). */\nexport interface SceneApplyArgs {\n post: Record<string, THREE.IUniform>;\n out: { timeOffset: number; zoom: number };\n}\ninterface WaveApplier {\n base(w: WaveConfig): number;\n apply(value: number, a: WaveApplyArgs): void;\n}\ninterface SceneApplier {\n base(c: StudioConfig): number;\n apply(value: number, a: SceneApplyArgs): void;\n}\n\nconst waveApplier = (\n base: (w: WaveConfig) => number,\n apply: (value: number, a: WaveApplyArgs) => void,\n): WaveApplier => ({ base, apply });\nconst sceneApplier = (\n base: (c: StudioConfig) => number,\n apply: (value: number, a: SceneApplyArgs) => void,\n): SceneApplier => ({ base, apply });\n\n/**\n * Per-wave binding targets → (how to read the authored base value, how to write the modulated one).\n * Each base() mirrors the exact fallback refresh() uses, so a binding at rest (from omitted, source\n * 0) writes the same value the renderer already had — no visible jump. This object is the runtime\n * source of truth for {@link WaveInteractionTarget} (enforced by `satisfies`).\n */\nexport const WAVE_APPLIERS = {\n displaceAmount: waveApplier(\n (w) => w.displaceAmount,\n (v, a) => {\n a.u.uDispAmount.value = v;\n },\n ),\n detailAmount: waveApplier(\n (w) => w.detailAmount ?? 0,\n (v, a) => {\n a.u.uDetailAmount.value = v;\n },\n ),\n twistPowerX: waveApplier(\n (w) => w.twistPower.x,\n (v, a) => {\n a.u.uTwPowX.value = v;\n },\n ),\n twistPowerY: waveApplier(\n (w) => w.twistPower.y,\n (v, a) => {\n a.u.uTwPowY.value = v;\n },\n ),\n twistPowerZ: waveApplier(\n (w) => w.twistPower.z,\n (v, a) => {\n a.u.uTwPowZ.value = v;\n },\n ),\n twistFrequencyX: waveApplier(\n (w) => w.twistFrequency.x,\n (v, a) => {\n a.u.uTwFreqX.value = v;\n },\n ),\n twistFrequencyY: waveApplier(\n (w) => w.twistFrequency.y,\n (v, a) => {\n a.u.uTwFreqY.value = v;\n },\n ),\n twistFrequencyZ: waveApplier(\n (w) => w.twistFrequency.z,\n (v, a) => {\n a.u.uTwFreqZ.value = v;\n },\n ),\n hueShift: waveApplier(\n (w) => w.hueShift,\n (v, a) => {\n a.u.uHueShift.value = v;\n },\n ),\n gradientShift: waveApplier(\n (w) => w.gradientShift ?? 0,\n (v, a) => {\n a.u.uGradShift.value = v;\n },\n ),\n colorSaturation: waveApplier(\n (w) => w.colorSaturation,\n (v, a) => {\n a.u.uSaturation.value = v;\n },\n ),\n opacity: waveApplier(\n (w) => w.opacity,\n (v, a) => {\n a.u.uOpacity.value = v;\n },\n ),\n lineThickness: waveApplier(\n (w) => w.lineThickness ?? 1,\n (v, a) => {\n a.u.uLineThickness.value = v;\n },\n ),\n lineAmount: waveApplier(\n (w) => w.lineAmount ?? 425,\n (v, a) => {\n a.u.uLineAmount.value = v;\n },\n ),\n fiberStrength: waveApplier(\n (w) => w.fiberStrength,\n (v, a) => {\n a.u.uFiberStrength.value = v;\n },\n ),\n sheen: waveApplier(\n (w) => w.sheen ?? 1,\n (v, a) => {\n a.u.uSheen.value = v;\n },\n ),\n iridescence: waveApplier(\n (w) => w.iridescence ?? 0,\n (v, a) => {\n a.u.uIridescence.value = v;\n },\n ),\n positionX: waveApplier(\n (w) => w.position.x,\n (v, a) => {\n a.mesh.position.x = v;\n },\n ),\n positionY: waveApplier(\n (w) => w.position.y,\n (v, a) => {\n a.mesh.position.y = v;\n },\n ),\n} satisfies Record<WaveInteractionTarget, WaveApplier>;\n\n/** Scene-level binding targets. base() mirrors updateTime() / applyZoom() / applyPost() fallbacks. */\nexport const SCENE_APPLIERS = {\n timeOffset: sceneApplier(\n (c) => c.timeOffset ?? 0,\n (v, a) => {\n a.out.timeOffset = v;\n },\n ),\n cameraZoom: sceneApplier(\n (c) => c.cameraZoom ?? 1,\n (v, a) => {\n a.out.zoom = v;\n },\n ),\n blur: sceneApplier(\n (c) => c.blur,\n (v, a) => {\n a.post.uBlurAmount.value = v;\n },\n ),\n grain: sceneApplier(\n (c) => c.grain,\n (v, a) => {\n a.post.uGrainAmount.value = v;\n },\n ),\n} satisfies Record<SceneInteractionTarget, SceneApplier>;\n\n// ---- Active-state predicates (keyed off config only, so input never triggers a recompile) ----\n\n/** The global master switch: only `scene.interaction.enabled === false` turns the whole layer off. */\nfunction notDisabled(cfg: StudioConfig): boolean {\n return cfg.interaction?.enabled !== false;\n}\n\n/** Whether a wave has a pointer field (hover effects, or a click ripple). */\nfunction waveHasPointerField(w: WaveConfig): boolean {\n const it = w.interaction;\n return !!it && (!!it.hover || (it.press?.ripple ?? 0) > 0);\n}\n\n/** Whether this wave has an active pointer field → its POINTER_FX shader path compiles. */\nexport function wavePointerFxActive(cfg: StudioConfig, w: WaveConfig): boolean {\n return notDisabled(cfg) && waveHasPointerField(w);\n}\n\n/** Whether this wave has active click ripples → its nested POINTER_RIPPLES path compiles. */\nexport function waveRipplesActive(cfg: StudioConfig, w: WaveConfig): boolean {\n return notDisabled(cfg) && (w.interaction?.press?.ripple ?? 0) > 0;\n}\n\n/** Whether ANY wave has a pointer field (so the renderer bothers writing the shared pointer uniforms). */\nexport function anyPointerFxActive(cfg: StudioConfig): boolean {\n return notDisabled(cfg) && cfg.waves.some(waveHasPointerField);\n}\n\n/** Whether the interaction layer should run at all (any wave interaction, or any scene binding). */\nexport function interactionActive(cfg: StudioConfig): boolean {\n if (!notDisabled(cfg)) return false;\n if ((cfg.interaction?.bindings?.length ?? 0) > 0) return true;\n return cfg.waves.some((w) => {\n const it = w.interaction;\n return !!it && (!!it.hover || (it.press?.ripple ?? 0) > 0 || (it.bindings?.length ?? 0) > 0);\n });\n}\n\n// ---- Sample shape + the controller ----------------------------------------------------------\n\ninterface RippleSlot {\n origin: THREE.Vector2; // NDC\n age: number; // seconds since spawn\n amp: number; // 0..1 decay envelope (0 = free slot)\n}\ninterface RippleState extends RippleSlot {\n active: boolean;\n}\n\n/** A per-frame snapshot of the pointer-field state. Fields are LIVE references into the controller's\n * state — read them synchronously each frame; don't retain them. */\nexport interface InteractionSample {\n /** Smoothed pointer position, NDC (-1..1). */\n ndc: THREE.Vector2;\n /** Smoothed pointer presence 0..1 (→ uPointerActive). */\n presence: number;\n /** Click-ripple ring buffer (amp = shared 0..1 envelope; 0 = free slot). */\n ripples: readonly RippleSlot[];\n}\n\n/** A wave's own smoothed pointer-field state — trails the shared cursor at the wave's own rate. */\nexport interface PointerField {\n /** Smoothed pointer position for this wave, NDC (-1..1). */\n ndc: THREE.Vector2;\n /** Spring velocity of `ndc` (NDC/s) — internal spring state, not read by the renderer. */\n vel: THREE.Vector2;\n /** Smoothed pointer presence 0..1 for this wave. */\n presence: number;\n}\n\n/**\n * Owns the one cursor's input + scroll + press/appear/custom and all smoothing. Constructed by the\n * renderer when {@link interactionActive} first turns true, disposed when it turns false. All\n * listeners are passive and container-scoped (the poster overlay passes events through).\n */\nexport class InteractionController {\n /** Studio-only scroll preview: when non-null, overrides the computed scroll progress. */\n scrollOverride: number | null = null;\n\n private readonly ndc = new THREE.Vector2();\n private readonly ndcTarget = new THREE.Vector2();\n private readonly ndcPrev = new THREE.Vector2();\n private readonly velNdc = new THREE.Vector2();\n private presence = 0;\n private presenceTarget = 0;\n private press = 0;\n private pressTarget = 0;\n private pointerSpeed = 0;\n private scroll = 0;\n private scrollPrev = 0;\n private scrollVel = 0;\n private appearLatched = false;\n private readonly customInputs = new Map<string, number>();\n private readonly ripples: RippleState[] = [];\n // Per-wave pointer-field state (index-parallel to config.waves); each trails the cursor at its own\n // hover smoothing. Grown/shrunk in update().\n private readonly fields: PointerField[] = [];\n // Per-binding smoothing state, keyed by binding-object identity (covers scene + every wave list).\n private readonly bindingState = new Map<\n AnyBinding,\n { value: number; source: InteractionSource }\n >();\n // Scratch set reused by updateBindings every frame (cleared, never reallocated).\n private readonly seenBindings = new Set<AnyBinding>();\n private readonly out: InteractionSample;\n\n constructor(\n private readonly container: HTMLElement,\n private readonly cfg: () => StudioConfig | undefined,\n ) {\n for (let i = 0; i < RIPPLE_SLOTS; i++) {\n this.ripples.push({ origin: new THREE.Vector2(), age: 0, amp: 0, active: false });\n }\n this.out = { ndc: this.ndc, presence: 0, ripples: this.ripples };\n const opts = { passive: true } as const;\n container.addEventListener(\"pointerenter\", this.onPointerEnter, opts);\n container.addEventListener(\"pointermove\", this.onPointerMove, opts);\n container.addEventListener(\"pointerleave\", this.onPointerLeave, opts);\n container.addEventListener(\"pointercancel\", this.onPointerCancel, opts);\n container.addEventListener(\"pointerdown\", this.onPointerDown, opts);\n container.addEventListener(\"pointerup\", this.onPointerUp, opts);\n }\n\n /** Ignore coarse (touch) pointers unless the scene opts in with interaction.touch. */\n private ignore(e: PointerEvent): boolean {\n return e.pointerType === \"touch\" && this.cfg()?.interaction?.touch !== true;\n }\n\n private setNdcTarget(e: PointerEvent): void {\n const rect = this.container.getBoundingClientRect();\n if (rect.width <= 0 || rect.height <= 0) return;\n this.ndcTarget.set(\n ((e.clientX - rect.left) / rect.width) * 2 - 1,\n -(((e.clientY - rect.top) / rect.height) * 2 - 1),\n );\n }\n\n private onPointerEnter = (e: PointerEvent): void => {\n if (this.ignore(e)) return;\n this.presenceTarget = 1;\n this.setNdcTarget(e);\n };\n private onPointerMove = (e: PointerEvent): void => {\n if (this.ignore(e)) return;\n if (e.pointerType === \"touch\" && this.pressTarget < 0.5) return; // touch: only track while down\n this.presenceTarget = 1;\n this.setNdcTarget(e);\n };\n private onPointerLeave = (e: PointerEvent): void => {\n if (this.ignore(e)) return;\n this.presenceTarget = 0;\n this.ndcTarget.set(0, 0); // relax toward centre → pointerX/Y rest at 0.5\n };\n private onPointerCancel = (e: PointerEvent): void => {\n if (this.ignore(e)) return;\n this.pressTarget = 0;\n this.presenceTarget = 0;\n this.ndcTarget.set(0, 0);\n };\n private onPointerDown = (e: PointerEvent): void => {\n if (this.ignore(e)) return;\n this.pressTarget = 1;\n this.presenceTarget = 1;\n this.setNdcTarget(e);\n // Spawn a ripple only if some wave actually wants ripples (else it is wasted state).\n const cfg = this.cfg();\n if (cfg && cfg.waves.some((w) => (w.interaction?.press?.ripple ?? 0) > 0)) this.spawnRipple();\n };\n private onPointerUp = (e: PointerEvent): void => {\n if (this.ignore(e)) return;\n this.pressTarget = 0;\n if (e.pointerType === \"touch\") {\n this.presenceTarget = 0; // touch has no hover — presence ends with the touch\n this.ndcTarget.set(0, 0);\n }\n };\n\n /** Spawn a normalized ripple (envelope 0..1) at the click NDC; per-wave amplitude scales it in the\n * shader. Reuses a free slot or evicts the oldest. */\n private spawnRipple(): void {\n let slot = this.ripples.find((r) => !r.active);\n if (!slot) {\n slot = this.ripples[0];\n for (const r of this.ripples) if (r.age > slot.age) slot = r;\n }\n slot.origin.copy(this.ndcTarget);\n slot.age = 0;\n slot.amp = 1;\n slot.active = true;\n }\n\n /** Advance all smoothed state by `dt` seconds. Called from the render loop with the same delta. */\n update(dt: number): void {\n const cfg = this.cfg();\n if (!cfg) return;\n const d = Math.max(dt, 0);\n // The SHARED pointer state feeds binding sources (hover / pointerX-Y / pointerSpeed / press) at a\n // fixed baseline; each wave's FIELD trails at its own hover smoothing further below.\n const kPointer = alpha(DEFAULT_POINTER_TAU, d);\n\n // Pointer position + presence + press.\n this.ndcPrev.copy(this.ndc);\n this.ndc.lerp(this.ndcTarget, kPointer);\n this.presence += (this.presenceTarget - this.presence) * kPointer;\n this.press += (this.pressTarget - this.press) * kPointer;\n\n // Velocity (own tau) from the smoothed-position delta.\n if (d > 1e-5) {\n const kv = alpha(VELOCITY_TAU, d);\n this.velNdc.x += ((this.ndc.x - this.ndcPrev.x) / d - this.velNdc.x) * kv;\n this.velNdc.y += ((this.ndc.y - this.ndcPrev.y) / d - this.velNdc.y) * kv;\n }\n this.pointerSpeed = this.presence * clamp01(this.velNdc.length() / POINTER_SPEED_REF);\n\n // Per-wave pointer FIELD: each wave's position is a damped SPRING toward the raw cursor target,\n // so a stack trails the cursor at different rates (parallax) and — because the spring is slightly\n // underdamped — carries a little weight: it overshoots and settles instead of creeping to a dead\n // stop. Presence stays a plain ramp (a spring there could dip below 0 and invert the effect).\n // A wave's hover `smoothing` sets the spring frequency (omega = 1/tau).\n const waves = cfg.waves;\n if (this.fields.length > waves.length) this.fields.length = waves.length;\n for (let i = 0; i < waves.length; i++) {\n let f = this.fields[i];\n if (!f) {\n f = {\n ndc: this.ndcTarget.clone(),\n vel: new THREE.Vector2(),\n presence: this.presenceTarget,\n };\n this.fields[i] = f;\n }\n const tau = Math.max(\n waves[i].interaction?.hover?.smoothing ?? DEFAULT_POINTER_TAU,\n MIN_POINTER_TAU,\n );\n springVec2(f.ndc, f.vel, this.ndcTarget, 1 / tau, POINTER_SPRING_ZETA, d);\n f.presence += (this.presenceTarget - f.presence) * alpha(tau, d);\n }\n\n // Scroll progress + velocity.\n const rawScroll = this.scrollOverride ?? this.computeScroll();\n if (d > 1e-5) {\n const sv = Math.abs(rawScroll - this.scrollPrev) / d;\n this.scrollVel += (sv - this.scrollVel) * alpha(SCROLL_VELOCITY_TAU, d);\n }\n this.scrollPrev = rawScroll;\n this.scroll = rawScroll;\n\n // Appear latch: the render loop is visibility-gated, so the first update() IS first-visible.\n this.appearLatched = true;\n\n // Ripples: age + quadratic-decay envelope.\n for (const r of this.ripples) {\n if (!r.active) continue;\n r.age += d;\n const env = Math.max(0, 1 - r.age / RIPPLE_LIFETIME);\n r.amp = env * env;\n if (r.amp <= 0) r.active = false;\n }\n\n this.updateBindings(cfg, d);\n }\n\n // Indexed loops + a reused scratch set (no per-frame closure/array/Set) — this runs every frame.\n private updateBindings(cfg: StudioConfig, dt: number): void {\n const seen = this.seenBindings;\n seen.clear();\n const sceneBindings = cfg.interaction?.bindings;\n if (sceneBindings) {\n for (let i = 0; i < sceneBindings.length; i++) this.advanceBinding(sceneBindings[i], dt);\n }\n for (let w = 0; w < cfg.waves.length; w++) {\n const bindings = cfg.waves[w].interaction?.bindings;\n if (!bindings) continue;\n for (let i = 0; i < bindings.length; i++) this.advanceBinding(bindings[i], dt);\n }\n // Prune state for bindings that no longer exist (edited/removed slots). advanceBinding puts every\n // seen binding in the map, so map ⊇ seen — equal sizes means nothing is stale to walk for.\n if (this.bindingState.size > seen.size) {\n for (const key of this.bindingState.keys()) if (!seen.has(key)) this.bindingState.delete(key);\n }\n }\n\n /** Advance one binding's smoothed source value by `dt` and mark it live in `seenBindings`. */\n private advanceBinding(b: AnyBinding, dt: number): void {\n this.seenBindings.add(b);\n const raw = this.rawSource(b.source);\n let st = this.bindingState.get(b);\n // (Re)initialise on first sight or when the slot's source changed (studio edit): `appear`\n // ramps from 0 (entrance), every other source snaps to its current value.\n if (!st || st.source !== b.source) {\n st = { value: b.source === \"appear\" ? 0 : raw, source: b.source };\n this.bindingState.set(b, st);\n }\n st.value += (raw - st.value) * alpha(b.smoothing ?? DEFAULT_BINDING_TAU, dt);\n }\n\n /** The current smoothed 0..1 value of a binding's source (0 if the binding is unknown). */\n bindingValue(b: AnyBinding): number {\n return this.bindingState.get(b)?.value ?? 0;\n }\n\n /** The current raw (un-per-binding-smoothed) 0..1 value of a source signal. */\n private rawSource(source: InteractionSource): number {\n switch (source) {\n case \"scroll\":\n return this.scroll;\n case \"hover\":\n return this.presence;\n case \"pointerX\":\n return (this.ndc.x + 1) * 0.5;\n case \"pointerY\":\n return (this.ndc.y + 1) * 0.5;\n case \"pointerSpeed\":\n return this.pointerSpeed;\n case \"press\":\n return this.press;\n case \"scrollVelocity\":\n return clamp01(this.scrollVel / SCROLL_VELOCITY_REF);\n case \"appear\":\n return this.appearLatched ? 1 : 0;\n default:\n // custom:<name> — fed by setInput(name, value).\n return this.customInputs.get(source.slice(\"custom:\".length)) ?? 0;\n }\n }\n\n /** Container progress through the viewport: 0 as it enters from below, 1 once scrolled past. */\n private computeScroll(): number {\n const rect = this.container.getBoundingClientRect();\n const vh = window.innerHeight || document.documentElement.clientHeight || 1;\n return clamp01((vh - rect.top) / (vh + rect.height));\n }\n\n /** The shared pointer-field state + ripples for the renderer (live references — read synchronously). */\n sample(): InteractionSample {\n this.out.presence = this.presence;\n return this.out;\n }\n\n /** This wave's smoothed pointer-field state (it trails the cursor at its own hover smoothing), or\n * null if the wave hasn't been advanced by update() yet (treat as rest). */\n fieldFor(waveIdx: number): PointerField | null {\n return this.fields[waveIdx] ?? null;\n }\n\n /** Velocity-driven agitation drive 0..1 (how fast the cursor is moving, presence-gated). The\n * renderer scales each wave's hover `agitate` by this, so the churn tracks the gesture instead\n * of buzzing at a constant rate whenever the cursor is merely present. */\n pointerFlux(): number {\n return this.pointerSpeed;\n }\n\n /** Smoothed pointer velocity, NDC/s (direction + speed of the drag). The renderer feeds it to the\n * drag-wake shader so the trailing trough forms behind the motion. Live reference — read per frame. */\n pointerVelocity(): THREE.Vector2 {\n return this.velNdc;\n }\n\n /** Feed a `custom:<name>` input (developer API; staged/forwarded by the shell). */\n setInput(name: string, value: number): void {\n if (typeof name !== \"string\" || !Number.isFinite(value)) return;\n this.customInputs.set(name, value);\n }\n\n /**\n * Collapse to the settled resting state for the single frame drawn when the loop stops (paused /\n * reduced-motion / offscreen): presence / velocity / press / pointerSpeed → 0, ripples cleared,\n * scroll → its current raw value, pointer → centre, and `appear` → 1 (reduced-motion users must\n * see the FINAL entered state). Custom inputs KEEP their last explicit values. Each binding snaps\n * to its settled source so the one settled frame shows the final look.\n */\n settle(): void {\n this.presence = this.presenceTarget = 0;\n this.press = this.pressTarget = 0;\n this.pointerSpeed = 0;\n this.velNdc.set(0, 0);\n this.ndc.set(0, 0);\n this.ndcTarget.set(0, 0);\n this.ndcPrev.set(0, 0);\n for (const f of this.fields) {\n f.ndc.set(0, 0);\n f.vel.set(0, 0);\n f.presence = 0;\n }\n for (const r of this.ripples) {\n r.age = 0;\n r.amp = 0;\n r.active = false;\n }\n const rawScroll = this.scrollOverride ?? this.computeScroll();\n this.scroll = this.scrollPrev = rawScroll;\n this.scrollVel = 0;\n this.appearLatched = true;\n const cfg = this.cfg();\n if (cfg) {\n this.bindingState.clear();\n const snap = (b: AnyBinding): void => {\n this.bindingState.set(b, { value: this.rawSource(b.source), source: b.source });\n };\n for (const b of cfg.interaction?.bindings ?? []) snap(b);\n for (const w of cfg.waves) for (const b of w.interaction?.bindings ?? []) snap(b);\n }\n }\n\n /**\n * Snap scroll progress + the scroll-sourced bindings to the current override at once, leaving\n * every other input (pointer / press / appear / velocity / custom) advancing live. Used by the\n * studio scroll preview: the studio page never really scrolls, so dragging the preview slider is a\n * manual scrub that must reflect the instant you move it — not on the next animation frame, which\n * the browser fully suspends whenever the tab isn't foreground. Unlike settle() (which collapses\n * ALL input to rest for a paused still frame), this touches only the scroll signal.\n */\n snapScroll(): void {\n const raw = this.scrollOverride ?? this.computeScroll();\n this.scroll = this.scrollPrev = raw;\n this.scrollVel = 0; // a static scrub has no velocity\n const cfg = this.cfg();\n if (!cfg) return;\n const snap = (b: AnyBinding): void => {\n if (b.source === \"scroll\" || b.source === \"scrollVelocity\") {\n this.bindingState.set(b, { value: this.rawSource(b.source), source: b.source });\n }\n };\n for (const b of cfg.interaction?.bindings ?? []) snap(b);\n for (const w of cfg.waves) for (const b of w.interaction?.bindings ?? []) snap(b);\n }\n\n dispose(): void {\n const c = this.container;\n c.removeEventListener(\"pointerenter\", this.onPointerEnter);\n c.removeEventListener(\"pointermove\", this.onPointerMove);\n c.removeEventListener(\"pointerleave\", this.onPointerLeave);\n c.removeEventListener(\"pointercancel\", this.onPointerCancel);\n c.removeEventListener(\"pointerdown\", this.onPointerDown);\n c.removeEventListener(\"pointerup\", this.onPointerUp);\n this.customInputs.clear();\n this.bindingState.clear();\n }\n}\n"],"mappings":"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"}
@@ -411,6 +411,20 @@ const PALETTE_MAPS = {
411
411
  edgeColor: "#e4312b",
412
412
  edgeAmount: .22
413
413
  },
414
+ spain: {
415
+ label: "Spain",
416
+ kind: "gradient",
417
+ stops: mk([
418
+ ["#aa151b", 0],
419
+ ["#aa151b", .24],
420
+ ["#f1bf00", .34],
421
+ ["#f1bf00", .66],
422
+ ["#aa151b", .76],
423
+ ["#aa151b", 1]
424
+ ]),
425
+ edgeColor: "#7a0f14",
426
+ edgeAmount: .22
427
+ },
414
428
  grandLine: {
415
429
  label: "Grand Line",
416
430
  kind: "gradient",