From fb7b593e45fd22bee7f67b0a6d473fb0807cd2a0 Mon Sep 17 00:00:00 2001 From: Al-4SW <122236501+Al-4SW@users.noreply.github.com> Date: Thu, 5 Dec 2024 18:17:54 +0000 Subject: [PATCH] Fix smooth mouse wheel zooming (#5154) * Tests added for smooth zooming with easing * Reverted the condition for 'wheel' event easing back to v4.5.0, to fix smooth zooming for multiple wheel events during the same zoom event. Introduced a minimum wheelEventTimeDiff value used to calcualte zoom easing, to allow smooth zooming for very fast free scrolling WheelEvents. * Update CHANGELOG.md * Renamed time difference adjustment variable to 'wheelEventTimeDiffAdjustment' * Simplified 'Zooms for multiple mouse wheel ticks..' tests * Added missing map.remove() to refactored multiple tick tests --- CHANGELOG.md | 2 +- src/ui/handler/scroll_zoom.test.ts | 230 +++++++++++++++++++++++++---- src/ui/handler/scroll_zoom.ts | 12 +- 3 files changed, 213 insertions(+), 31 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c0485124045..949c8223953 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,7 +5,7 @@ - _...Add new stuff here..._ ### 🐞 Bug fixes - +- Fix smooth mouse wheel zooming ([#5154](https://github.com/maplibre/maplibre-gl-js/pull/5154)) - ⚠️ Change drag rotate behavior to be less abrupt around the center ([#5104](https://github.com/maplibre/maplibre-gl-js/pull/5104)) - Fix regression in render world copies ([#5101](https://github.com/maplibre/maplibre-gl-js/pull/5101)) - Fix unwanted roll when motion is interrupted ([#5083](https://github.com/maplibre/maplibre-gl-js/pull/5083)) diff --git a/src/ui/handler/scroll_zoom.test.ts b/src/ui/handler/scroll_zoom.test.ts index 62692861a55..173bf04c5c3 100644 --- a/src/ui/handler/scroll_zoom.test.ts +++ b/src/ui/handler/scroll_zoom.test.ts @@ -45,6 +45,38 @@ describe('ScrollZoomHandler', () => { map.remove(); }); + test('Zooms for single mouse wheel tick with easing for smooth zooming', () => { + const browserNow = vi.spyOn(browser, 'now'); + let now = 1555555555555; + browserNow.mockReturnValue(now); + + const map = createMap(); + map.setZoom(5); + map._renderTaskQueue.run(); + + // simulate a single 'wheel' event + const startZoom = map.getZoom(); + + simulate.wheel(map.getCanvas(), {type: 'wheel', deltaY: -simulate.magicWheelZoomDelta}); + map._renderTaskQueue.run(); + + // A single tick zoom with easing completes in approx. 200ms + now += 100; + browserNow.mockReturnValue(now); + map._renderTaskQueue.run(); + const midZoom = map.getZoom(); + + now += 400; + browserNow.mockReturnValue(now); + map._renderTaskQueue.run(); + const endZoom = map.getZoom(); + + expect(midZoom).toBeGreaterThan(startZoom); + expect(midZoom).toBeLessThan(endZoom); + + map.remove(); + }); + test('Zooms for multiple fast mouse wheel ticks', () => { const browserNow = vi.spyOn(browser, 'now'); let now = 1555555555555; @@ -66,11 +98,53 @@ describe('ScrollZoomHandler', () => { map._renderTaskQueue.run(); } + now += 400; + browserNow.mockReturnValue(now); + map._renderTaskQueue.run(); + expect(map.getZoom() - startZoom).toBeCloseTo(0.0285 * iterations, 2); map.remove(); }); + test('Zooms for multiple fast mouse wheel ticks with easing for smooth zooming', () => { + const browserNow = vi.spyOn(browser, 'now'); + let now = 1555555555555; + browserNow.mockReturnValue(now); + + const map = createMap(); + map.setZoom(5); + map._renderTaskQueue.run(); + + // simulate a multiple fast 'wheel' event + const startZoom = map.getZoom(); + let midZoom = 0; + + const iterations = 10; + + for (let i = 0; i < iterations; i++) { + simulate.wheel(map.getCanvas(), {type: 'wheel', deltaY: -simulate.magicWheelZoomDelta}); + map._renderTaskQueue.run(); + now += 0; + browserNow.mockReturnValue(now); + map._renderTaskQueue.run(); + + if (i === iterations - 1) { + midZoom = map.getZoom(); + } + } + + now += 400; + browserNow.mockReturnValue(now); + map._renderTaskQueue.run(); + const endZoom = map.getZoom(); + + expect(midZoom).toBeGreaterThan(startZoom); + expect(midZoom).toBeLessThan(endZoom); + + map.remove(); + }); + test('Zooms for single mouse wheel tick with non-magical deltaY', () => new Promise(done => { const browserNow = vi.spyOn(browser, 'now'); const now = 1555555555555; @@ -89,45 +163,147 @@ describe('ScrollZoomHandler', () => { }); })); + test('Zooms for single mouse wheel tick with non-magical deltaY with easing for smooth zooming', () => { + const browserNow = vi.spyOn(browser, 'now'); + let now = 1555555555555; + browserNow.mockReturnValue(now); + vi.useFakeTimers(); + setPerformance(); + const map = createMap(); + map.setZoom(5); + map._renderTaskQueue.run(); + + const startZoom = map.getZoom(); + + // simulate a single 'wheel' event with non-magical deltaY + simulate.wheel(map.getCanvas(), {type: 'wheel', deltaY: -20}); + vi.advanceTimersByTime(40); + now += 40; + map._renderTaskQueue.run(); + + // A single tick zoom with easing completes in approx. 200ms + now += 100; + browserNow.mockReturnValue(now); + map._renderTaskQueue.run(); + const midZoom = map.getZoom(); + + now += 400; + browserNow.mockReturnValue(now); + map._renderTaskQueue.run(); + const endZoom = map.getZoom(); + + expect(midZoom).toBeGreaterThan(startZoom); + expect(midZoom).toBeLessThan(endZoom); + + map.remove(); + }); + test('Zooms for multiple mouse wheel ticks', () => { const browserNow = vi.spyOn(browser, 'now'); let now = 1555555555555; browserNow.mockReturnValue(now); const map = createMap(); - map._renderTaskQueue.run(); + + // simulate 3 'wheel' events const startZoom = map.getZoom(); - const events = [ - [2, {type: 'wheel', deltaY: -simulate.magicWheelZoomDelta}], - [7, {type: 'wheel', deltaY: -41}], - [30, {type: 'wheel', deltaY: -169}], - [1, {type: 'wheel', deltaY: -801}], - [5, {type: 'wheel', deltaY: -326}], - [20, {type: 'wheel', deltaY: -345}], - [22, {type: 'wheel', deltaY: -376}], - ] as [number, any][]; + now += 2; + browserNow.mockReturnValue(now); + map._renderTaskQueue.run(); + simulate.wheel(map.getCanvas(), {type: 'wheel', deltaY: -simulate.magicWheelZoomDelta}); + map._renderTaskQueue.run(); - const end = now + 500; - let lastWheelEvent = now; + now += 7; + browserNow.mockReturnValue(now); + map._renderTaskQueue.run(); + simulate.wheel(map.getCanvas(), {type: 'wheel', deltaY: -simulate.magicWheelZoomDelta}); + map._renderTaskQueue.run(); - // simulate the above sequence of wheel events, with render frames - // interspersed every 20ms - while (now < end) { - now += 1; - browserNow.mockReturnValue(now); - if (events.length && lastWheelEvent + events[0][0] === now) { - const [, event] = events.shift(); - simulate.wheel(map.getCanvas(), event); - lastWheelEvent = now; - } - if (now % 20 === 0) { - map._renderTaskQueue.run(); - } - } + now += 30; + browserNow.mockReturnValue(now); + map._renderTaskQueue.run(); + simulate.wheel(map.getCanvas(), {type: 'wheel', deltaY: -simulate.magicWheelZoomDelta}); + map._renderTaskQueue.run(); + + now += 400; + browserNow.mockReturnValue(now); + map._renderTaskQueue.run(); + + expect(map.getZoom() - startZoom).toBeCloseTo(0.0285 * 3, 3); + + map.remove(); + }); + + test('Zooms for multiple mouse wheel ticks with easing for smooth zooming', () => { + const browserNow = vi.spyOn(browser, 'now'); + let now = 1555555555555; + browserNow.mockReturnValue(now); + + const map = createMap(); + map.setZoom(5); + map._renderTaskQueue.run(); + + // simulate 3 'wheel' events + // Event 1 Start + now += 2; + browserNow.mockReturnValue(now); + map._renderTaskQueue.run(); + simulate.wheel(map.getCanvas(), {type: 'wheel', deltaY: -simulate.magicWheelZoomDelta}); + const startZoomEvent1 = map.getZoom(); + map._renderTaskQueue.run(); + + // Event 1 mid-zoom + now += 3; + browserNow.mockReturnValue(now); + map._renderTaskQueue.run(); + const midZoomEvent1 = map.getZoom(); + + // Event 2 Start + now += 4; + browserNow.mockReturnValue(now); + map._renderTaskQueue.run(); + const endZoomEvent1 = map.getZoom(); + const startZoomEvent2 = map.getZoom(); + simulate.wheel(map.getCanvas(), {type: 'wheel', deltaY: -simulate.magicWheelZoomDelta}); + map._renderTaskQueue.run(); + + // Event 2 mid-zoom + now += 15; + browserNow.mockReturnValue(now); + map._renderTaskQueue.run(); + const midZoomEvent2 = map.getZoom(); + + // Event 3 Start + now += 15; + browserNow.mockReturnValue(now); + map._renderTaskQueue.run(); + const endZoomEvent2 = map.getZoom(); + const startZoomEvent3 = map.getZoom(); + simulate.wheel(map.getCanvas(), {type: 'wheel', deltaY: -simulate.magicWheelZoomDelta}); + map._renderTaskQueue.run(); + + // Event 3 mid-zoom + // A single tick zoom with easing completes in approx. 200ms + now += 100; + browserNow.mockReturnValue(now); + map._renderTaskQueue.run(); + const midZoomEvent3 = map.getZoom(); + + now += 400; + browserNow.mockReturnValue(now); + map._renderTaskQueue.run(); + const endZoomEvent3 = map.getZoom(); + + expect(midZoomEvent1).toBeGreaterThan(startZoomEvent1); + expect(midZoomEvent1).toBeLessThan(endZoomEvent1); + + expect(midZoomEvent2).toBeGreaterThan(startZoomEvent2); + expect(midZoomEvent2).toBeLessThan(endZoomEvent2); - expect(map.getZoom() - startZoom).toBeCloseTo(1.944, 3); + expect(midZoomEvent3).toBeGreaterThan(startZoomEvent3); + expect(midZoomEvent3).toBeLessThan(endZoomEvent3); map.remove(); }); diff --git a/src/ui/handler/scroll_zoom.ts b/src/ui/handler/scroll_zoom.ts index 95718481d46..3c19acfc4e4 100644 --- a/src/ui/handler/scroll_zoom.ts +++ b/src/ui/handler/scroll_zoom.ts @@ -23,6 +23,11 @@ const wheelZoomRate = 1 / 450; // is used to limit zoom rate in the case of very fast scrolling const maxScalePerFrame = 2; +// Minimum time difference value to be used for calculating zoom easing in renderFrame(); +// this is used to normalise very fast (typically 0 to 0.3ms) repeating lastWheelEventTimeDiff +// values generated by Chromium based browsers during fast scrolling wheel events. +const wheelEventTimeDiffAdjustment = 5; + /** * The `ScrollZoomHandler` allows the user to zoom the map by scrolling. * @@ -312,9 +317,10 @@ export class ScrollZoomHandler implements Handler { let finished = false; let zoom; - const lastWheelEventTimeDiff = browser.now() - this._lastWheelEventTime; - if (this._type === 'wheel' && startZoom && easing && lastWheelEventTimeDiff) { - const t = Math.min(lastWheelEventTimeDiff / 200, 1); + if (this._type === 'wheel' && startZoom && easing) { + const lastWheelEventTimeDiff = browser.now() - this._lastWheelEventTime; + + const t = Math.min((lastWheelEventTimeDiff + wheelEventTimeDiffAdjustment) / 200, 1); const k = easing(t); zoom = interpolates.number(startZoom, targetZoom, k);