feat(sim): wheel + label-tap auto-scrub then lock; retire discrete advance()

Per plan Task 4. onWheel + jumpToScale now animate pos via autoScrub() (drives the
same setPos scrub path, ~600ms, lands exactly on the integer + locks). Removes the
now-dead advance()/playTransition()/FAST_BLEND_RATE (the server /api/ring/advance
round-trip is no longer used by the client). Post-landing morph warm-up folded into
setPos's settle branch.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
BenStullsBets
2026-06-27 15:45:37 -07:00
parent 20f49e936a
commit 8458ab59eb
2 changed files with 34 additions and 92 deletions
+11
View File
@@ -66,6 +66,17 @@ test("dragging the dial scrubs morph currentTime and audio gains", async ({ page
await page.mouse.up();
});
test("wheel auto-scrubs one altitude and locks", async ({ page }) => {
await boot(page);
const start = (await page.locator("#scale-name").textContent())!;
await wheelOnStage(page, 60); // wheel down = descend one altitude
await page.waitForFunction((s) => document.querySelector("#scale-name")?.textContent !== s, start, { timeout: 20000 });
const landed = (await page.locator("#scale-name").textContent())!;
expect(landed).toContain("orbit");
await page.waitForTimeout(1500);
expect(await page.locator("#scale-name").textContent()).toBe(landed); // locked
});
test("zoom in plays the matching morph, lands locked, and stays put while parked", async ({ page }) => {
await boot(page);
const start = (await page.locator("#scale-name").textContent())!;
+23 -92
View File
@@ -587,95 +587,25 @@ async function update() {
function debounced() { clearTimeout(timer); timer = setTimeout(update, 80); }
// --- Scale ring (endless encoder) ---
// --- Scale ring (endless encoder, scrub-driven — see the scrub engine below) ---
const FAST_BLEND_RATE = 2.5; // playback speed for a collapsed fast-spin pass
function playTransition(file, blended, fromDeg, toDeg) {
// Play one resolved morph start-to-finish, with the alteration layers hidden.
// The manifest already encodes direction (forward zoom-in vs `.rev` zoom-out),
// so the file is played as-is. A `blended` (fast-spin) step runs at
// FAST_BLEND_RATE so a quick encoder spin lands fast. If no morph was baked for
// the pair (file null), resolve immediately so advance() plain-cuts. Resolves on
// 'ended' (or a safety timeout that scales with playback rate).
//
// When fromDeg/toDeg are given, the Altitude needle is swept from fromDeg to
// toDeg IN SYNC with the morph's playback progress — so the dial turns AS the
// transition happens and lands exactly when the morph ends (not before).
return new Promise((resolve) => {
const sweep = typeof fromDeg === "number" && typeof toDeg === "number";
if (!file) { if (sweep) setNeedle(toDeg); resolve(); return; }
overlay.style.opacity = "0";
affectLayer.style.opacity = "0";
tint.style.opacity = "0";
vid.style.filter = "none";
vid.loop = false;
vid.style.opacity = "1";
vid.src = mediaUrl(file);
vid.playbackRate = blended ? FAST_BLEND_RATE : 1;
let done = false, raf = 0;
const tickNeedle = () => {
const p = vid.duration ? Math.min(vid.currentTime / vid.duration, 1) : 0;
setNeedle(fromDeg + (toDeg - fromDeg) * p);
if (!done) raf = requestAnimationFrame(tickNeedle);
};
const finish = () => {
if (done) return;
done = true;
if (raf) cancelAnimationFrame(raf);
if (sweep) setNeedle(toDeg); // land precisely on the detent
vid.removeEventListener("ended", finish);
vid.playbackRate = 1;
resolve();
};
vid.addEventListener("ended", finish);
vid.play().catch(finish);
if (sweep) raf = requestAnimationFrame(tickNeedle);
setTimeout(finish, blended ? 3000 : 6000);
});
}
// Advance the ring `delta` detents: pick the destination clip(s), play the matching
// chained morph(s), then LOCK on the landed clip until the next altitude change.
async function advance(delta) {
if (busy || !ring || ring.scales.length < 2) return;
busy = true;
const stepDeg = dialStep();
const dir = delta > 0 ? 1 : -1;
let curDeg = ringIndex * stepDeg; // the pre-move detent angle
setNeedle(curDeg); // reset to the start detent so the needle sweeps WITH the morph (overrides any live drag pre-move)
try {
// The server chooses the destination clip(s) FIRST (threading the currently-
// shown clip in as from_clip_id), then returns the matching member->member
// morphs to play — chained through each crossed altitude.
const resp = await fetch("/api/ring/advance", {
method: "POST", headers: { "content-type": "application/json" },
body: JSON.stringify({ from_index: ringIndex, delta, from_clip_id: activeClipId }),
});
if (!resp.ok) return;
const move = await resp.json();
// Play each chained morph, sweeping the needle one detent per step IN SYNC with
// that morph's playback. A fast spin marks every step blended (played quick); a
// slow spin plays each at 1x. Each morph already ends on its destination clip's
// frame, so there is NO secondary swap.
for (const step of move.steps) {
const toDeg = curDeg + dir * stepDeg;
// Diagnostic seam: record the logical morph path played (the resolved URL may
// be an in-memory blob, so this is the reliable record for Dev Mode + E2E).
(window.__hefMorphs || (window.__hefMorphs = [])).push(step.file);
await playTransition(step.file, step.blended, curDeg, toDeg);
curDeg = toDeg;
}
ringIndex = move.to_index;
activeClipId = move.target_clip_id; // LOCK on the landed clip — stays until the next altitude change
currentClipId = null; // force ensureClipMedia to (re)load + loop it
renderScaleReadout(); // also renderDial(): snaps needle to the exact detent + active label
} finally {
busy = false;
update(); // ensureClipMedia loads the locked clip; nothing re-rolls while parked
applyAudio(); // soundtrack follows the Altitude dial (crossfade to the new scale)
refreshReachablePreload(); // warm the morphs now reachable from the locked clip
}
// Animate `pos` to a target over `ms`, driving the same scrub path as a drag, then
// land exactly on the integer target (frac 0 settles + locks in setPos).
let autoRaf = 0;
function autoScrub(targetPos, ms = 600) {
if (!ring || ring.scales.length < 2) return;
if (autoRaf) cancelAnimationFrame(autoRaf);
const fromPos = pos, dist = targetPos - fromPos;
if (!dist) { setPos(targetPos); return; }
let startTs = null;
const tick = (ts) => {
if (startTs === null) startTs = ts;
const k = Math.min(1, (ts - startTs) / ms);
setPos(fromPos + dist * k);
if (k < 1) autoRaf = requestAnimationFrame(tick);
else { autoRaf = 0; setPos(targetPos); } // land exactly + lock (frac 0)
};
autoRaf = requestAnimationFrame(tick);
}
let wheelAccum = 0, wheelTimer = null;
@@ -686,7 +616,7 @@ function onWheel(e) {
wheelTimer = setTimeout(() => {
const detents = Math.trunc(wheelAccum / 60) || (wheelAccum > 0 ? 1 : -1);
wheelAccum = 0;
if (detents) advance(detents); // wheel down = zoom inward (+)
if (detents) autoScrub(Math.round(pos) + detents); // wheel down = descend (+); auto-scrub then lock
}, 90);
}
@@ -829,8 +759,9 @@ function setPos(next) {
currentClipId = null; // force ensureClipMedia to reload + loop the locked clip
setNeedle(ringIndex * dialStep());
renderScaleReadout();
update();
update(); // ensureClipMedia loads + loops the locked clip
restAudio(ringIndex);
refreshReachablePreload(); // warm the morphs now reachable from the locked clip
return;
}
busy = true; // mid-morph: block update() from reloading the base clip under us
@@ -873,14 +804,14 @@ function onDialUp(e) {
// No auto-complete: hold the blend wherever the knob stopped (continuous-encoder model).
}
// Click a label → travel the SHORTEST signed way around the ring to that scale.
// Click a label → auto-scrub the SHORTEST signed way around the ring to that scale.
function jumpToScale(idx) {
if (!ring) return;
const n = ring.scales.length;
let d = (idx - ringIndex) % n;
if (d > n / 2) d -= n;
if (d < -n / 2) d += n;
if (d) advance(d);
if (d) autoScrub(Math.round(pos) + d);
}
// --- Dev Mode: pool picker + live analysis, all under one toggle ---