✨ Coalesce playground wheel zoom per animation frame

Apply at most one _set_view + _render_from_cache per animation
frame in the wasm playground, accumulating the wheel zoom factor
and the last cursor point instead of rendering synchronously on
every wheel event.

This mirrors schedule-zoom! in
app.main.ui.workspace.viewport.actions so playground
interactions match the application. A comment records that the
playground should call the real function once it migrates to
ClojureScript.

Extend the adapter tests with the coalesced flush, factor reset
across frames, scale clamps, and per-move pointer pan.

AI-assisted-by: deepseek-flash
This commit is contained in:
Miguel de Benito Delgado 2026-09-19 20:20:17 +02:00
parent f81696886a
commit 34e4dc1674
2 changed files with 146 additions and 18 deletions

View File

@ -104,19 +104,35 @@ export function createRendererAdapter(module, scheduler = globalThis) {
scheduler.clearTimeout(timer);
timer = scheduler.setTimeout(finish, 100);
};
// Mirrors schedule-zoom! in app.main.ui.workspace.viewport.actions:
// accumulate wheel zoom and flush at most one _set_view + _render_from_cache
// per animation frame. When the playground migrates to ClojureScript, call
// that function directly instead of duplicating this scheduling.
const zoom = { factor: 1, pt: null, frame: undefined };
const flushZoom = () => {
zoom.frame = undefined;
if (disposed) return;
stopRender();
const factor = zoom.factor;
const [px, py] = zoom.pt;
zoom.factor = 1;
zoom.pt = null;
const nextScale = Math.max(0.01, Math.min(100, view.scale * factor));
view.x += px / nextScale - px / view.scale;
view.y += py / nextScale - py / view.scale;
view.scale = nextScale;
update();
};
listen(
"wheel",
(event) => {
event.preventDefault();
begin();
const nextScale = Math.max(
0.01,
Math.min(100, view.scale * (event.deltaY < 0 ? 1.1 : 0.9)),
);
view.x += event.offsetX / nextScale - event.offsetX / view.scale;
view.y += event.offsetY / nextScale - event.offsetY / view.scale;
view.scale = nextScale;
update();
zoom.factor *= event.deltaY < 0 ? 1.1 : 0.9;
zoom.pt = [event.offsetX, event.offsetY];
if (zoom.frame === undefined) {
zoom.frame = scheduler.requestAnimationFrame(flushZoom);
}
},
{ passive: false },
);
@ -143,6 +159,8 @@ export function createRendererAdapter(module, scheduler = globalThis) {
detach = () => {
for (const remove of listeners) remove();
scheduler.clearTimeout(timer);
if (zoom.frame !== undefined) scheduler.cancelAnimationFrame(zoom.frame);
zoom.frame = undefined;
panning = false;
active = false;
};

View File

@ -23,6 +23,7 @@ function fixture() {
},
};
const state = { rendered: 0, cleaned: false, deleted: false, lost: false };
const calls = { views: [], cached: 0, start: 0, end: 0 };
const gl = {
getExtension: (name) =>
name === "WEBGL_lose_context"
@ -47,10 +48,18 @@ function fixture() {
_init() {},
_set_render_options() {},
_set_browser() {},
_set_view_start() {},
_set_view() {},
_set_view_end() {},
_render_from_cache() {},
_set_view_start() {
calls.start++;
},
_set_view(...args) {
calls.views.push(args);
},
_set_view_end() {
calls.end++;
},
_render_from_cache() {
calls.cached++;
},
_render() {
state.rendered++;
return 2;
@ -62,22 +71,122 @@ function fixture() {
const adapter = createRendererAdapter(module, scheduler);
adapter.assignCanvas(canvas);
adapter.setupInteraction(canvas);
return { adapter, state, canvas, frames, timers, module };
return { adapter, state, calls, canvas, frames, timers, module };
}
test("wheel zoom flushes once per animation frame", () => {
const f = fixture();
const wheel = (deltaY, offsetX, offsetY) =>
f.canvas.dispatchEvent(
Object.assign(new Event("wheel"), { deltaY, offsetX, offsetY }),
);
const runFrame = () => {
const [id] = f.frames.keys();
const callback = f.frames.get(id);
f.frames.delete(id);
callback(16);
};
wheel(-1, 10, 20);
wheel(-1, 30, 40);
wheel(-1, 50, 60);
assert.equal(f.frames.size, 1);
assert.equal(f.calls.start, 1);
assert.equal(f.calls.views.length, 0);
assert.equal(f.calls.cached, 0);
runFrame();
assert.equal(f.calls.views.length, 1);
assert.equal(f.calls.cached, 1);
const [scale, x, y] = f.calls.views[0];
assert.ok(Math.abs(scale - 1.1 ** 3) < 1e-9, `unexpected scale ${scale}`);
assert.ok(
Math.abs(x - (50 / 1.1 ** 3 - 50)) < 1e-9,
`unexpected x anchor ${x}`,
);
assert.ok(
Math.abs(y - (60 / 1.1 ** 3 - 60)) < 1e-9,
`unexpected y anchor ${y}`,
);
assert.equal(f.timers.size, 1);
// The flush releases the frame slot and resets the accumulated factor.
wheel(-1, 10, 20);
assert.equal(f.frames.size, 1);
assert.equal(f.calls.cached, 1);
runFrame();
assert.equal(f.calls.views.length, 2);
assert.equal(f.calls.cached, 2);
assert.ok(
Math.abs(f.calls.views[1][0] - 1.1 ** 4) < 1e-9,
`unexpected scale ${f.calls.views[1][0]}`,
);
f.adapter.dispose();
});
test("wheel zoom clamps the accumulated factor to the scale bounds", () => {
const f = fixture();
const wheel = (deltaY) =>
f.canvas.dispatchEvent(
Object.assign(new Event("wheel"), { deltaY, offsetX: 10, offsetY: 20 }),
);
const runFrame = () => {
const [id] = f.frames.keys();
const callback = f.frames.get(id);
f.frames.delete(id);
callback(16);
};
for (let index = 0; index < 100; index++) wheel(-1);
runFrame();
assert.equal(f.calls.views[0][0], 100);
for (let index = 0; index < 200; index++) wheel(1);
runFrame();
assert.equal(f.calls.views[1][0], 0.01);
f.adapter.dispose();
});
test("pointer pan still renders on every move", () => {
const f = fixture();
f.canvas.dispatchEvent(
Object.assign(new Event("pointerdown"), {
clientX: 0,
clientY: 0,
pointerId: 1,
}),
);
f.canvas.dispatchEvent(
Object.assign(new Event("pointermove"), { clientX: 10, clientY: 20 }),
);
f.canvas.dispatchEvent(
Object.assign(new Event("pointermove"), { clientX: 30, clientY: 40 }),
);
assert.equal(f.calls.views.length, 2);
assert.equal(f.calls.cached, 2);
assert.equal(f.frames.size, 0);
f.adapter.dispose();
});
test("disposing an adapter settles pending work and isolates late callbacks", async () => {
const first = fixture();
const second = fixture();
const pending = first.adapter.renderFull();
const lateFrame = [...first.frames.values()][0];
const lateRender = [...first.frames.values()][0];
first.canvas.dispatchEvent(
Object.assign(new Event("wheel"), { deltaY: 1, offsetX: 10, offsetY: 20 }),
);
const lateTimer = [...first.timers.values()][0];
assert.equal(first.frames.size, 1);
const lateZoom = [...first.frames.values()][0];
first.adapter.dispose();
first.adapter.dispose();
lateFrame(16);
lateTimer();
lateRender(16);
lateZoom(16);
assert.equal(await pending, false);
assert.equal(first.frames.size + first.timers.size, 0);
assert.deepEqual(first.state, {
@ -86,8 +195,9 @@ test("disposing an adapter settles pending work and isolates late callbacks", as
deleted: true,
lost: true,
});
assert.equal(first.calls.cached, 0);
first.canvas.dispatchEvent(new Event("wheel"));
assert.equal(first.timers.size, 0);
assert.equal(first.frames.size + first.timers.size, 0);
const completed = second.adapter.renderFull();
[...second.frames.values()][0](32);
assert.equal(await completed, true);