Phase 10 reframe R4: seven-knob inline visualizer controls, always-on lava loop, filled lava-lamp icon

This commit is contained in:
daniel-c-harvey
2026-06-16 17:17:14 -04:00
parent fe28573b68
commit 41ac7a5a93
9 changed files with 518 additions and 343 deletions
+191 -151
View File
@@ -27,11 +27,17 @@
* gradient is Wave R3. No glass, no screen-space noise (removed in R1).
*
* The Blazor component owns the canvas element and the inputs (datum, playback,
* zoom, theme, the control dials); this module owns the requestAnimationFrame loop,
* scroll speed, theme, the control dials); this module owns the requestAnimationFrame loop,
* the physics step, and all the GL math. The component drives it through the handle
* returned by `create`. The handle SHAPE is unchanged from Phase 10 — the three
* effect setters are temporarily re-routed to the lava params for this wave (see
* their definitions); Wave R4 gives them proper names + a six-knob UI.
* returned by `create`. As of Wave R4 the handle exposes SEVEN dedicated control setters
* (setScrollSpeed / setGradientRotationSpeed / setLavaGravity / setLavaHeat / setBlobDensity /
* setCollisionStrength / setWaveformWidth) — the R2 temp-remapping is gone. Gradient rotation is
* stored but inert until Wave R3 builds the OKLab gradient.
*
* PAUSE BEHAVIOR (Wave R4 Part C): the rAF loop runs CONTINUOUSLY while the component is alive and
* the tab is visible — it is no longer gated on playback. The fluid sim keeps convecting while audio
* is paused; only the waveform scroll/playhead freezes (effectivePlayhead() holds the static pushed
* position while !isPlaying). The loop stops only on tab-hidden (visibilitychange) and dispose.
*/
// ── Tuning anchors (see spec §B). These are the load-bearing constants. ──────────
@@ -51,47 +57,51 @@ export const DEFAULT_VISIBLE_SECONDS = 10;
// ── Control tuning anchors. These mirror the C#-side defaults in ──────────────────
// MixVisualizerControlState.cs — keep the two in sync, exactly as the
// DEFAULT_VISIBLE_SECONDS / DefaultVisibleSeconds pair is kept in sync. All three are
// normalized [0,1].
// DEFAULT_VISIBLE_SECONDS / DefaultVisibleSeconds pair is kept in sync. All seven are
// normalized [0,1] (scroll speed is mapped to a visible time-span on the C# side before it
// reaches setScrollSpeed; it arrives here already in seconds).
//
// R2 TEMPORARY RE-WIRING (Wave R4 replaces this with the proper seven-knob set):
// the FOUR existing control knobs are re-purposed to drive the new lava physics so
// Daniel can feel the system in-browser this wave. The knob NAMES on screen still say
// the old thing; the SETTERS below route them to the new physics params. Mapping:
// • "Detach" knob (Air icon) → lava HEAT (setDetach)
// • "Bubblyness" knob (BubbleChart) → lava GRAVITY (setBubblyness)
// • "Color-shift" knob (Palette) → COLLISION STRENGTH (setColorShiftSpeed)
// • "Resolution" knob (ZoomIn) → WAVEFORM WIDTH (setWaveformWidth) ← R2 NEW
// The resolution/zoom knob is repurposed because scroll speed is not critical for
// evaluating the lava: the controls row no longer mutates VisibleSeconds, so the window
// holds at DEFAULT_VISIBLE_SECONDS (setZoom is still seeded once with that default).
// Blob DENSITY has no live knob this wave; it sits at
// DEFAULT_BLOB_DENSITY (R4 adds it). The defaults below are tuned to Daniel's sweet spot
// (~20% gravity, ~100% heat) so the lava looks ALIVE and fluid on open — he then tunes
// on screen. ALL of this temp wiring is removed in R4 for the real knob set.
// Wave R4 — the SEVEN dedicated controls. Each knob drives its own physics/colour dial; the
// R2 temporary remapping (where four knobs masqueraded as other things) is gone. Mapping:
// • Scroll speed → visible time-span / scroll rate (setScrollSpeed)
// • Gradient rotation speed → colour anchor-rotation rate (setGradientRotationSpeed) — INERT
// until Wave R3 builds the OKLab gradient that consumes it
// • Lava gravity → gravity dial (setLavaGravity)
// • Lava heat → heat dial (setLavaHeat)
// • Blob density/size → density dial (setBlobDensity)
// • Collision strength → collision hardness dial (setCollisionStrength)
// • Waveform width → ribbon half-width uniform (setWaveformWidth)
// The defaults below are Daniel's feel-anchors (~20% gravity, ~100% heat sweet spot, §4c) — he
// tunes on screen from here.
/** Default GRAVITY dial (was bulge). Mirrors C# DefaultBubblyness.
* Tuned to Daniel's R2 sweet spot (~20% gravity): the wax is buoyant-dominated and flows. */
export const DEFAULT_BUBBLYNESS = 0.2;
/** Default GRAVITY dial. Mirrors C# DefaultLavaGravity.
* Tuned to Daniel's sweet spot (~20% gravity): the wax is buoyant-dominated and flows. */
export const DEFAULT_LAVA_GRAVITY = 0.2;
/** Default HEAT dial (was detach). Mirrors C# DefaultDetach.
* Tuned to Daniel's R2 sweet spot (~100% heat): lots of small, lively, turbulent bubbles. */
export const DEFAULT_DETACH = 1.0;
/** Default HEAT dial. Mirrors C# DefaultLavaHeat.
* Tuned to Daniel's sweet spot (~100% heat): lots of small, lively, turbulent bubbles. */
export const DEFAULT_LAVA_HEAT = 1.0;
/** Default COLLISION-STRENGTH dial (was color-shift). Mirrors C# DefaultColorShiftSpeed.
/** Default COLLISION-STRENGTH dial. Mirrors C# DefaultCollisionStrength.
* Mid soft↔hard: elastic enough to throw bubbles up+out, not so hard it reads as marbles. */
export const DEFAULT_COLOR_SHIFT_SPEED = 0.5;
export const DEFAULT_COLLISION_STRENGTH = 0.5;
/** Default blob density (no live knob this wave; R4 exposes it). 0 = few large lazy blobs, 1 = many small. */
/** Default blob density. Mirrors C# DefaultBlobDensity. 0 = few large lazy blobs, 1 = many small. */
export const DEFAULT_BLOB_DENSITY = 0.4;
/**
* Default WAVEFORM-WIDTH dial (R2 TEMP — mapped to the resolution/zoom knob for in-browser
* test; R4 gives it its own knob). 1 = full ribbon width (the prior behaviour); lower values
* narrow the waveform band so the lava fluid gets more room to move on loud songs. Mirrors C#
* DefaultWaveformWidth. Opens at full width so the default look matches the prior ribbon.
* Default GRADIENT-ROTATION-SPEED dial. Mirrors C# DefaultGradientRotationSpeed. Normalized
* [0,1] → slow→fast anchor rotation. INERT until Wave R3 builds the OKLab three-colour gradient
* that consumes it — stored and round-tripped through the handle so the knob persists, but it
* drives nothing this wave (the R2 flat placeholder fill ignores it).
*/
export const DEFAULT_WAVEFORM_WIDTH = 1.0;
export const DEFAULT_GRADIENT_ROTATION_SPEED = 0.3;
/**
* Default WAVEFORM-WIDTH dial. Mirrors C# DefaultWaveformWidth. 1 = full ribbon width; lower
* values narrow the waveform band so the lava fluid gets more room to move on loud songs.
*/
export const DEFAULT_WAVEFORM_WIDTH = 0.6;
/**
* Where the "now" line sits within the window, as a fraction from the top.
@@ -320,9 +330,9 @@ const PLAYHEAD_CORRECTION_SNAP_SECONDS = 0.0005;
// received/uploaded, first-draw dimensions, GL error after first draw) are gated
// here so they can be silenced once the renderer is confirmed healthy. Leave it on
// while the runtime fix is being verified through the browser.
// NOTE: ON for this visual-iteration pass (Phase 10 W3 rework). Daniel tests in-browser;
// the resolved navy/moss RGB + FPS lines confirm the fixes. Flip back to false once the
// look is approved.
// NOTE: ON for the Phase 10 reframe Wave R4 controls pass. Daniel tests in-browser; the FPS lines
// (which should hold ~60 even while paused, confirming the continuous-loop power cost is acceptable)
// + the seven-dial lava line confirm the controls + pause fix. Flip back to false at reframe close.
const DEBUG = true;
const TAG = '[MixVisualizer]';
@@ -447,7 +457,8 @@ interface Playback {
* effectivePlayhead (see draw()), anchored on this value.
*/
positionSeconds: number;
/** Whether audio is actively playing — gates the rAF loop so a paused mix stays cool. */
/** Whether audio is actively playing. Gates whether the playhead ADVANCES (scroll) or HOLDS
* (freeze) — NOT whether the rAF loop runs (the loop is continuous now, Part C). */
isPlaying: boolean;
/**
* performance.now() (ms) captured when positionSeconds was pushed. The rAF loop
@@ -462,14 +473,19 @@ interface Playback {
export interface MixVisualizerHandle {
setDatum(samplesBase64: string, durationSeconds: number): void;
setPlayback(positionSeconds: number, isPlaying: boolean): void;
setZoom(visibleSeconds: number): void;
/** [0,1]. R2 TEMP: routes the "Bubblyness" knob to lava GRAVITY (R4 renames). */
setBubblyness(value: number): void;
/** [0,1]. R2 TEMP: routes the "Detach" knob to lava HEAT (R4 renames). */
setDetach(value: number): void;
/** [0,1]. R2 TEMP: routes the "Color-shift" knob to COLLISION STRENGTH (R4 renames). */
setColorShiftSpeed(value: number): void;
/** [0,1]. R2 TEMP: routes the "Resolution"/zoom knob to WAVEFORM WIDTH (R4 gives it its own knob). */
/** Visible time-span in seconds — the scroll-speed control, mapped from [0,1] on the C# side. */
setScrollSpeed(visibleSeconds: number): void;
/** [0,1]. Colour anchor-rotation rate. INERT until Wave R3 (stored + round-tripped only). */
setGradientRotationSpeed(value: number): void;
/** [0,1]. Downward force on the wax. */
setLavaGravity(value: number): void;
/** [0,1]. Energy into the lava system (0 = rest-at-bottom, 1 = roiling). */
setLavaHeat(value: number): void;
/** [0,1]. Amount of wax — blob count/size. */
setBlobDensity(value: number): void;
/** [0,1]. Collision hardness (0 = soft mush, 1 = hard up-and-out throw). */
setCollisionStrength(value: number): void;
/** [0,1]. Waveform-band horizontal extent (1 = full ribbon, lower narrows). */
setWaveformWidth(value: number): void;
/** Re-read the palette CSS vars off the canvas (call after a dark-mode toggle). */
refreshTheme(): void;
@@ -854,10 +870,12 @@ function noopHandle(): MixVisualizerHandle {
return {
setDatum() {},
setPlayback() {},
setZoom() {},
setBubblyness() {},
setDetach() {},
setColorShiftSpeed() {},
setScrollSpeed() {},
setGradientRotationSpeed() {},
setLavaGravity() {},
setLavaHeat() {},
setBlobDensity() {},
setCollisionStrength() {},
setWaveformWidth() {},
refreshTheme() {},
dispose() {},
@@ -937,14 +955,17 @@ export function create(canvas: HTMLCanvasElement): MixVisualizerHandle {
let playback: Playback = { positionSeconds: 0, isPlaying: false, pushWallClockMs: performance.now() };
let visibleSeconds = DEFAULT_VISIBLE_SECONDS;
// ── Lava physics control values (the R2 TEMP knob re-mapping — see the control-default
// consts at the top of this file). These are the dials the existing knobs feed, routed
// here by the handle setters. They drive the CPU physics step below, NOT a shader uniform.
let lavaHeat = DEFAULT_DETACH; // "Detach" knob → heat
let lavaGravity = DEFAULT_BUBBLYNESS; // "Bubblyness" knob → gravity
let collisionStrength = DEFAULT_COLOR_SHIFT_SPEED; // "Color-shift" knob → collision hardness
let blobDensity = DEFAULT_BLOB_DENSITY; // no live knob this wave (R4 adds it)
let waveformWidth = DEFAULT_WAVEFORM_WIDTH; // "Resolution" knob → ribbon width (R2 TEMP, R4 own knob)
// ── Lava physics control values (Wave R4 — each its own dedicated knob; see the control-default
// consts at the top of this file). These are the dials the seven knobs feed, routed here by the
// handle setters. The lava dials drive the CPU physics step below; waveformWidth is a shader
// uniform; gradientRotationSpeed is stored but INERT until Wave R3 builds the colour gradient.
let lavaHeat = DEFAULT_LAVA_HEAT;
let lavaGravity = DEFAULT_LAVA_GRAVITY;
let collisionStrength = DEFAULT_COLLISION_STRENGTH;
let blobDensity = DEFAULT_BLOB_DENSITY;
let waveformWidth = DEFAULT_WAVEFORM_WIDTH;
// INERT until Wave R3 — held so the knob round-trips and persists; nothing reads it this wave.
let gradientRotationSpeed = DEFAULT_GRADIENT_ROTATION_SPEED;
/**
* The *authoritative* playhead for this instant: the last pushed position advanced
@@ -1374,11 +1395,11 @@ export function create(canvas: HTMLCanvasElement): MixVisualizerHandle {
// Wall-clock anchor for the physics dt (separate from the playhead decay clock).
let lastPhysicsMs = performance.now();
// FPS diagnostic (verification aid for the smoothness fix — gated on DEBUG). Counts
// actual rAF callbacks and logs the rate ~once/sec while playing. This distinguishes
// the two failure modes: a rate near the display refresh (~60) with the playhead
// interpolated means motion is smooth; a rate near ~10 would mean the loop is gated
// to the playback pushes instead of free-running. Reset when the loop (re)starts.
// FPS diagnostic (verification aid — gated on DEBUG). Counts actual rAF callbacks and logs the
// rate ~once/sec while the loop runs (which is now continuously, playing or paused — Part C). A
// rate near the display refresh (~60) confirms the continuous loop holds frame rate; a paused-but-
// foregrounded lamp should still read ~60 (the cheap sim + one draw), confirming the power cost of
// running while paused is acceptable. Reset when the loop (re)starts.
let fpsFrameCount = 0;
let fpsWindowStartMs = 0;
@@ -1418,9 +1439,9 @@ export function create(canvas: HTMLCanvasElement): MixVisualizerHandle {
const nextCssWidth = box ? box.inlineSize : entry.contentRect.width;
const nextCssHeight = box ? box.blockSize : entry.contentRect.height;
applySize(nextCssWidth, nextCssHeight);
// While idle, draw one still frame reflecting the new size. While playing,
// the running loop will redraw on its next tick — no action needed.
if (!playback.isPlaying) redrawOnce();
// The continuous loop redraws on its next tick. Only force a still frame if the loop is
// stopped (tab hidden) so a resize while hidden is reflected when the tab returns.
if (rafId === null) redrawOnce();
});
resizeObserver.observe(canvas);
@@ -1517,20 +1538,25 @@ export function create(canvas: HTMLCanvasElement): MixVisualizerHandle {
}
}
// ── rAF loop lifecycle (spec §E: cool when paused/backgrounded). ────────────
// ── rAF loop lifecycle (lava reframe Part C: sim animates while paused; only scroll freezes).
//
// DESIGN: The loop runs ONLY while playing. When paused or stopped, no frames
// are scheduled — the GPU is idle. The still slice stays correct via one-shot
// redraws triggered by the handle methods (setZoom/refreshTheme/setDatum) and
// by the ResizeObserver.
// DESIGN (changed in Wave R4): the loop runs whenever the component is ALIVE and the tab is
// VISIBLE — it is NO LONGER gated on playback.isPlaying. A real lava lamp keeps convecting
// regardless of the music, so the fluid sim (physics + render) keeps animating while audio is
// paused; only the waveform SCROLL / playhead freezes. That freeze falls straight out of
// effectivePlayhead(): while !isPlaying it returns the static last-pushed position, so the
// waveform holds at its paused row while the physics dt clock (lastPhysicsMs in draw()) keeps
// advancing the wax. Power-saving is preserved by stopping the loop on tab-hidden (visibilitychange)
// and on dispose — just not merely because audio paused. A foregrounded-but-paused lamp runs only
// the cheap CPU sim + one GL draw per frame, which holds 60 FPS comfortably.
//
// Smoothness (spec §2e / §5.4): the scroll must advance every animation frame, not
// step at Blazor's ~10 Hz playback-push cadence. We achieve that by interpolating
// the playhead on the wall clock — each frame uploads renderedPlayhead() (= effectivePlayhead()
// + the decaying jitter-correction offset), which advances the last pushed position by real time
// elapsed since the push and blends out any accumulated timing error. (The separate uTimeSeconds
// monotonic clock drives the blob-radius wobble in the shader; the CPU physics uses its own
// wall-clock dt — neither drives the scroll, which is the playhead alone.)
// Smoothness (spec §2e / §5.4): while playing, the scroll must advance every animation frame, not
// step at Blazor's ~10 Hz playback-push cadence. We achieve that by interpolating the playhead on
// the wall clock — each frame uploads renderedPlayhead() (= effectivePlayhead() + the decaying
// jitter-correction offset), which advances the last pushed position by real time elapsed since the
// push and blends out any accumulated timing error. (The separate uTimeSeconds monotonic clock
// drives the blob-radius wobble in the shader; the CPU physics uses its own wall-clock dt — neither
// drives the scroll, which is the playhead alone, and the playhead is frozen while paused.)
/** Draw one still frame immediately, without scheduling a new rAF. */
function redrawOnce(): void {
@@ -1565,15 +1591,14 @@ export function create(canvas: HTMLCanvasElement): MixVisualizerHandle {
}
/**
* The animation loop. Runs only while playing. Each frame draws the scrolling
* waveform at the wall-clock-interpolated playhead (effectivePlayhead, advancing
* smoothly between the ~10 Hz pushes), then reschedules itself — unless playback
* stopped since this frame was queued, in which case it draws one final still
* frame (already done above) and exits the loop.
*
* A backgrounded tab gets rAF throttled by the browser automatically; on top of
* that the loop does not run at all when paused, so a foregrounded-but-paused
* mix burns no frames (spec §E / §5.3).
* The animation loop. Runs continuously while the component is alive and the tab is visible
* (lava reframe Part C) — NOT gated on playback. Each frame advances the wax physics and draws.
* While playing, it draws at the wall-clock-interpolated playhead (effectivePlayhead, advancing
* smoothly between the ~10 Hz pushes); while paused, effectivePlayhead() holds the static pushed
* position so the waveform freezes in place while the lava keeps convecting. It reschedules itself
* every frame; the only things that stop it are dispose() and the tab going hidden (the
* visibilitychange handler calls stopLoop). A backgrounded tab also gets rAF throttled by the
* browser, and we stop the loop entirely when hidden, so a backgrounded lamp burns no frames.
*/
function frame(): void {
if (disposed) {
@@ -1618,21 +1643,36 @@ export function create(canvas: HTMLCanvasElement): MixVisualizerHandle {
}
}
if (playback.isPlaying) {
rafId = requestAnimationFrame(frame);
} else {
// Playback stopped between queue and now; final still frame drawn above.
rafId = null;
}
// Reschedule unconditionally — the loop runs continuously now (lava reframe Part C); it is
// stopped only by dispose() or the tab going hidden, never by audio pausing.
rafId = requestAnimationFrame(frame);
}
// ── Tab-visibility gating (lava reframe Part C power-saving). ────────────────────
// The loop runs continuously while alive, but a HIDDEN tab should not animate at all
// (the browser throttles rAF anyway, but we stop outright to be sure). On becoming
// visible again we restart the loop; startLoop re-bases the dt clocks so the wax
// doesn't lurch by the whole hidden gap on the first resumed frame.
function onVisibilityChange(): void {
if (disposed) return;
if (document.hidden) {
stopLoop();
} else {
startLoop();
}
}
document.addEventListener('visibilitychange', onVisibilityChange);
// Read the initial size synchronously (one getBoundingClientRect at setup is
// fine — it is the ResizeObserver that must not measure per-frame), then draw a
// still frame so the canvas isn't blank before the first play command.
// fine — it is the ResizeObserver that must not measure per-frame), draw a still
// frame so the canvas isn't blank, then START the continuous loop (Part C: the lava
// animates from the moment the visualizer mounts, paused or playing) — unless the tab
// is already hidden, in which case the visibilitychange handler will start it later.
{
const rect = canvas.getBoundingClientRect();
applySize(rect.width, rect.height);
redrawOnce();
if (!document.hidden) startLoop();
}
/**
@@ -1722,9 +1762,10 @@ export function create(canvas: HTMLCanvasElement): MixVisualizerHandle {
datum = null;
}
datum = uploadDatum(samplesBase64, durationSeconds);
// New datum changes what is drawn — refresh the still slice immediately
// when idle. If playing, the running loop picks it up next frame.
if (!playback.isPlaying) redrawOnce();
// New datum changes what is drawn — the continuous loop picks it up next frame. Only force
// a still frame if the loop is stopped (tab hidden) so a datum that arrives while hidden is
// reflected the moment the tab becomes visible-and-draws.
if (rafId === null) redrawOnce();
},
setPlayback(positionSeconds: number, isPlaying: boolean): void {
@@ -1739,7 +1780,9 @@ export function create(canvas: HTMLCanvasElement): MixVisualizerHandle {
// Anchor the pushed position to wall-clock NOW: the rAF loop interpolates
// forward from here each frame (effectivePlayhead), so the scroll advances
// smoothly between these ~10 Hz pushes.
// smoothly between these ~10 Hz pushes. While paused, effectivePlayhead()
// returns this static position, so the waveform freezes here (Part C) — the
// continuous loop keeps animating the lava, but the scroll holds.
playback = { positionSeconds, isPlaying, pushWallClockMs: performance.now() };
// Fold the re-anchor discontinuity into the correction offset so the rendered
@@ -1749,9 +1792,9 @@ export function create(canvas: HTMLCanvasElement): MixVisualizerHandle {
// authoritative position. When pushes are regular the gap is ~0, so offset is
// ~0 and steady-state matches the prior hard-anchor behaviour exactly.
//
// Only smooth while continuously playing. On a play/pause edge or while idle
// Only smooth while continuously playing. On a play/pause edge or while paused
// we want the exact authoritative position, not a glide from a stale render:
// a resume should land on the real position, and a paused still frame must be
// a resume should land on the real position, and a paused frame must be
// truthful (read-only contract — never show a position the player isn't at).
if (isPlaying && wasPlaying) {
correctionOffset = renderedBefore - effectivePlayhead();
@@ -1759,78 +1802,75 @@ export function create(canvas: HTMLCanvasElement): MixVisualizerHandle {
correctionOffset = 0;
}
if (isPlaying && !wasPlaying) {
// Transition paused/stopped → playing: start the rAF loop.
debugLog(`playback started — position ${positionSeconds.toFixed(2)}s, datum ${datum ? 'present' : 'ABSENT'}; starting rAF loop.`);
startLoop();
} else if (!isPlaying && wasPlaying) {
// Transition playing → paused/stopped: the in-flight frame draws the
// final still position and exits on its own (frame() checks
// playback.isPlaying before rescheduling). We do NOT stopLoop() here —
// that would cancel the in-flight frame before it draws, leaving a
// stale canvas. Let the frame run out.
// NOTE (Part C): we do NOT start/stop the rAF loop on the play/pause edge anymore — the
// loop runs continuously while the tab is visible so the lava keeps convecting when paused.
// The play-state only changes whether effectivePlayhead() advances (scroll) or holds
// (freeze); the loop itself is owned by setup + the visibilitychange handler + dispose.
if (isPlaying !== wasPlaying) {
debugLog(`playback ${isPlaying ? 'resumed' : 'paused'} — position ${positionSeconds.toFixed(2)}s; scroll ${isPlaying ? 'advancing' : 'frozen'}, lava keeps animating.`);
}
// isPlaying unchanged (position-only update): the running loop (if any)
// redraws next frame; nothing to do here.
},
setZoom(seconds: number): void {
// Clamp into the supported span so a stray value can't break the math.
// ── Wave R4 — the seven dedicated control setters. Each routes its value to the one dial it
// drives; no more R2 temp-remapping. The lava loop now runs continuously (see startLoop /
// the visibility handling), so a paused tweak is already picked up by the next frame — but we
// keep a redrawOnce() guard for the rare fully-stopped case (loop not running, e.g. tab
// hidden) so a tweak still lands a still frame when it resumes-and-draws.
// Scroll speed: arrives already mapped to a visible time-span (seconds) on the C# side. Clamp
// into the supported span so a stray value can't break the scroll math.
setScrollSpeed(seconds: number): void {
visibleSeconds = Math.min(MAX_VISIBLE_SECONDS, Math.max(MIN_VISIBLE_SECONDS, seconds));
// While playing, the running rAF loop uploads uVisibleSeconds next frame; while idle the
// loop is stopped (spec §E), so a zoom change must force one still frame here or the new
// span is uploaded only on the next unrelated redraw (theme/datum/resize) — i.e. never.
const idleRedraw = !playback.isPlaying;
debugLog(`setZoom — requested ${seconds.toFixed(3)}s, clamped ${visibleSeconds.toFixed(3)}s; idleRedraw=${idleRedraw} (isPlaying=${playback.isPlaying}).`);
if (idleRedraw) redrawOnce();
debugLog(`setScrollSpeed — visibleSeconds ${visibleSeconds.toFixed(3)}s.`);
if (rafId === null) redrawOnce();
},
// ── R2 TEMPORARY control re-wiring (Wave R4 replaces this with the proper six-knob
// set). The bridge still calls these three setters by their OLD names — the names are
// a Wave-2 artifact and are NOT worth a bridge/contract change just to rename for one
// wave. Each routes its [0,1] value to the lava-physics dial it now drives, so Daniel
// can FEEL heat/gravity/collision in-browser this wave. The on-screen knob captions
// still read the old labels (BubbleChart/Air/Palette) — R4 redraws the controls UI.
// setBubblyness ← "Bubblyness" knob → lava GRAVITY
// setDetach ← "Detach" knob → lava HEAT
// setColorShiftSpeed← "Color-shift" knob → COLLISION STRENGTH
// Idle redraw mirrors setZoom so a paused tweak still updates the still frame.
setBubblyness(value: number): void {
lavaGravity = Math.min(1, Math.max(0, value)); // R2 TEMP → gravity
debugLog(`setGravity (via setBubblyness) → ${lavaGravity.toFixed(3)}.`);
if (!playback.isPlaying) redrawOnce();
// Gradient rotation speed: INERT until Wave R3. Stored so the knob round-trips/persists; the
// R2 flat placeholder fill ignores it, so there is nothing to redraw.
setGradientRotationSpeed(value: number): void {
gradientRotationSpeed = Math.min(1, Math.max(0, value));
debugLog(`setGradientRotationSpeed → ${gradientRotationSpeed.toFixed(3)} (inert until R3).`);
},
setDetach(value: number): void {
lavaHeat = Math.min(1, Math.max(0, value)); // R2 TEMP → heat
debugLog(`setHeat (via setDetach)${lavaHeat.toFixed(3)}.`);
if (!playback.isPlaying) redrawOnce();
setLavaGravity(value: number): void {
lavaGravity = Math.min(1, Math.max(0, value));
debugLog(`setLavaGravity${lavaGravity.toFixed(3)}.`);
if (rafId === null) redrawOnce();
},
setColorShiftSpeed(value: number): void {
collisionStrength = Math.min(1, Math.max(0, value)); // R2 TEMP → collision hardness
debugLog(`setCollisionStrength (via setColorShiftSpeed)${collisionStrength.toFixed(3)}.`);
if (!playback.isPlaying) redrawOnce();
setLavaHeat(value: number): void {
lavaHeat = Math.min(1, Math.max(0, value));
debugLog(`setLavaHeat${lavaHeat.toFixed(3)}.`);
if (rafId === null) redrawOnce();
},
setBlobDensity(value: number): void {
blobDensity = Math.min(1, Math.max(0, value));
debugLog(`setBlobDensity → ${blobDensity.toFixed(3)}.`);
if (rafId === null) redrawOnce();
},
setCollisionStrength(value: number): void {
collisionStrength = Math.min(1, Math.max(0, value));
debugLog(`setCollisionStrength → ${collisionStrength.toFixed(3)}.`);
if (rafId === null) redrawOnce();
},
// R2 TEMP: the resolution/zoom knob is repurposed to the waveform-width param this wave
// (scroll speed isn't critical for evaluating the lava). The bridge calls this with the
// raw knob fraction [0,1]; 1 = full ribbon, lower narrows the band. R4 gives width its
// own knob and restores the resolution knob to setZoom.
setWaveformWidth(value: number): void {
waveformWidth = Math.min(1, Math.max(0, value));
debugLog(`setWaveformWidth (via resolution knob)${waveformWidth.toFixed(3)}.`);
if (!playback.isPlaying) redrawOnce();
debugLog(`setWaveformWidth → ${waveformWidth.toFixed(3)}.`);
if (rafId === null) redrawOnce();
},
refreshTheme(): void {
theme = readTheme();
if (!playback.isPlaying) redrawOnce();
if (rafId === null) redrawOnce();
},
dispose(): void {
disposed = true;
stopLoop();
document.removeEventListener('visibilitychange', onVisibilityChange);
resizeObserver.disconnect();
// Release all GL resources so nothing leaks on navigation (spec §5.11).
if (datum) {