feat(sim): scrub-driven altitude transitions #28

Merged
benstull merged 14 commits from session-0026 into main 2026-06-28 01:46:55 +00:00
2 changed files with 25 additions and 3 deletions
Showing only changes of commit 0e401c9a42 - Show all commits
+15
View File
@@ -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<number> {
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
+10 -3
View File
@@ -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;