From 0e401c9a422dd29b5f10fcad8a6305ef5c9d3b26 Mon Sep 17 00:00:00 2001 From: BenStullsBets Date: Sat, 27 Jun 2026 17:36:45 -0700 Subject: [PATCH] feat(sim): auto-scrub at constant per-altitude speed, 2x slower Operator: slow click/auto transitions 2x and make them the same speed between any two altitudes (2 away should take twice as long as 1). autoScrub duration is now PROPORTIONAL to distance (PER_ALTITUDE_MS=1200, ~2x the old fixed 600ms total): a label-click or wheel jump of N altitudes takes N*1200ms, so per-step speed is constant. Manual drag is unaffected (operator drives its speed). E2E asserts a 2-step jump is ~2x a 1-step. Co-Authored-By: Claude Opus 4.8 (1M context) --- simulator/e2e/tests/altitude-lock.spec.ts | 15 +++++++++++++++ simulator/static/app.js | 13 ++++++++++--- 2 files changed, 25 insertions(+), 3 deletions(-) diff --git a/simulator/e2e/tests/altitude-lock.spec.ts b/simulator/e2e/tests/altitude-lock.spec.ts index 7cad845..479121c 100644 --- a/simulator/e2e/tests/altitude-lock.spec.ts +++ b/simulator/e2e/tests/altitude-lock.spec.ts @@ -125,6 +125,21 @@ test("wheel auto-scrubs one altitude and locks", async ({ page }) => { expect(await page.locator("#scale-name").textContent()).toBe(landed); // locked }); +test("auto-scrub speed is constant per altitude (a 2-step jump takes ~2x a 1-step)", async ({ page }) => { + await boot(page); + async function timeJump(deltaY: number, target: string): Promise { + const t0 = await page.evaluate(() => performance.now()); + await page.locator("#stage").dispatchEvent("wheel", { deltaY }); + await page.waitForFunction((tgt) => !!document.querySelector("#scale-name")?.textContent?.includes(tgt), target, { timeout: 20000 }); + return (await page.evaluate(() => performance.now())) - t0; + } + const t1 = await timeJump(60, "orbit"); // cosmos -> orbit (1 altitude) + const t2 = await timeJump(120, "reef"); // orbit -> reef (2 altitudes) + const ratio = t2 / t1; + expect(ratio).toBeGreaterThan(1.5); // proportional to distance, not a fixed total + expect(ratio).toBeLessThan(2.7); // ~2x, not more +}); + test("a landed clip loops from the morph-tail offset (no jump-back to frame 0)", async ({ page }) => { await boot(page); await enableVideo(page); // base loop only loads when video is shown diff --git a/simulator/static/app.js b/simulator/static/app.js index 3f33058..947dc72 100644 --- a/simulator/static/app.js +++ b/simulator/static/app.js @@ -639,14 +639,21 @@ function debounced() { clearTimeout(timer); timer = setTimeout(update, 80); } // --- Scale ring (endless encoder, scrub-driven — see the scrub engine below) --- -// 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). +// Per-altitude duration of an AUTO transition (wheel / label-click). The total time +// is PROPORTIONAL to the distance, so the speed between any two adjacent altitudes is +// the same — a 2-altitude jump takes twice as long as a 1-altitude one. (A manual +// drag is exempt: the operator drives its speed directly.) +const PER_ALTITUDE_MS = 1200; + +// Animate `pos` to a target at a constant per-altitude speed, driving the same scrub +// path as a drag, then land exactly on the integer target (frac 0 settles + locks). let autoRaf = 0; -function autoScrub(targetPos, ms = 600) { +function autoScrub(targetPos, perAltMs = PER_ALTITUDE_MS) { if (!ring || ring.scales.length < 2) return; if (autoRaf) cancelAnimationFrame(autoRaf); const fromPos = pos, dist = targetPos - fromPos; if (!dist) { setPos(targetPos); return; } + const ms = perAltMs * Math.abs(dist); // constant per-altitude speed → N steps take N× as long let startTs = null; const tick = (ts) => { if (startTs === null) startTs = ts;