From 34e4dc1674c7cbcc8e78ddf9aec02221bcd691e2 Mon Sep 17 00:00:00 2001 From: Miguel de Benito Delgado Date: Sat, 19 Sep 2026 20:20:17 +0200 Subject: [PATCH] :sparkles: 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 --- frontend/resources/wasm-playground/js/lib.js | 34 +++-- .../renderer-benchmarks/tests/adapter.test.js | 130 ++++++++++++++++-- 2 files changed, 146 insertions(+), 18 deletions(-) diff --git a/frontend/resources/wasm-playground/js/lib.js b/frontend/resources/wasm-playground/js/lib.js index 8096afd5ca..9692ebfa22 100644 --- a/frontend/resources/wasm-playground/js/lib.js +++ b/frontend/resources/wasm-playground/js/lib.js @@ -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; }; diff --git a/frontend/scripts/renderer-benchmarks/tests/adapter.test.js b/frontend/scripts/renderer-benchmarks/tests/adapter.test.js index e1014a6468..eb4736ffff 100644 --- a/frontend/scripts/renderer-benchmarks/tests/adapter.test.js +++ b/frontend/scripts/renderer-benchmarks/tests/adapter.test.js @@ -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);