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;