diff --git a/scripts/format-scope.json b/scripts/format-scope.json index 4b5ae815f..a708c6b74 100644 --- a/scripts/format-scope.json +++ b/scripts/format-scope.json @@ -50,6 +50,7 @@ "src/queries/heights.js", "src/render/cesium/internals.js", "src/render/cesium/cameraInput.js", + "src/render/cesium/ctrlWheelZoom.js", "src/render/cesium/cameraInput.test.mjs", "src/render/input/router.js", "src/render/input/router.test.mjs", diff --git a/scripts/package-boundaries.json b/scripts/package-boundaries.json index 8077a3ceb..9eb75f2a9 100644 --- a/scripts/package-boundaries.json +++ b/scripts/package-boundaries.json @@ -2448,6 +2448,7 @@ "src/app/surfaceServices.js", "src/app/tools.js", "src/app/viewer.js", + "src/render/cesium/ctrlWheelZoom.js", "src/sky/starrySky.js", "src/sky/stars.js", "src/app/atmosphereCompat.js", diff --git a/src/app/application.test.mjs b/src/app/application.test.mjs index 3e9079b87..d756e967c 100644 --- a/src/app/application.test.mjs +++ b/src/app/application.test.mjs @@ -2,7 +2,7 @@ import test from 'node:test'; import assert from 'node:assert/strict'; import { spawnSync } from 'node:child_process'; import { createApplication } from './application.js'; -import { installTrackpadPinchZoom } from './viewer.js'; +import { installTrackpadPinchZoom } from '../render/cesium/ctrlWheelZoom.js'; const phases = ['Scene', 'Controls', 'Data', 'Tools']; function fixture(overrides = {}) { diff --git a/src/app/scene.js b/src/app/scene.js index 87f9736ee..b1405108b 100644 --- a/src/app/scene.js +++ b/src/app/scene.js @@ -1,6 +1,6 @@ import { createApplicationOperations } from './operations.js'; import * as Cesium from 'cesium'; -import { installTrackpadPinchZoom } from '../app/viewer.js'; +import { installTrackpadPinchZoom } from '../render/cesium/ctrlWheelZoom.js'; import { cesiumRenderer, cesiumViewerOf, diff --git a/src/app/viewer.js b/src/app/viewer.js index c8b841352..9bc72b7f4 100644 --- a/src/app/viewer.js +++ b/src/app/viewer.js @@ -2,107 +2,6 @@ import * as Cesium from 'cesium'; import { applyModelAtmosphereWorkaround } from './atmosphereCompat.js'; import { StarrySky } from '../sky/starrySky.js'; -const PINCH_ZOOM_MULTIPLIER = 8; -const MAX_PINCH_PIXEL_DELTA = 120; - -function boundedPinchDelta(delta) { - if (!Number.isFinite(delta) || delta === 0) return delta; - return ( - Math.sign(delta) * - Math.min(Math.abs(delta) * PINCH_ZOOM_MULTIPLIER, MAX_PINCH_PIXEL_DELTA) - ); -} - -/** - * Add browser trackpad pinch to Cesium's zoom inputs and return its disposer. - * Browsers expose this gesture as a small pixel-mode Ctrl+wheel event. - */ -export function installTrackpadPinchZoom( - viewer, - { createWheelEvent = (type, init) => new WheelEvent(type, init) } = {}, -) { - const controller = viewer?.scene?.screenSpaceCameraController; - const container = viewer?.container; - const canvas = viewer?.canvas; - if (!controller || !container || !canvas) - throw new TypeError('A complete Cesium viewer is required'); - - const originalZoomEventTypes = controller.zoomEventTypes; - const zoomEventTypes = Array.isArray(originalZoomEventTypes) - ? originalZoomEventTypes - : originalZoomEventTypes === undefined - ? [] - : [originalZoomEventTypes]; - const alreadyHandlesControlWheel = zoomEventTypes.some( - (binding) => - binding?.eventType === Cesium.CameraEventType.WHEEL && - binding?.modifier === Cesium.KeyboardEventModifier.CTRL, - ); - const configuredZoomEventTypes = alreadyHandlesControlWheel - ? originalZoomEventTypes - : [ - ...zoomEventTypes, - { - eventType: Cesium.CameraEventType.WHEEL, - modifier: Cesium.KeyboardEventModifier.CTRL, - }, - ]; - if (!alreadyHandlesControlWheel) - controller.zoomEventTypes = configuredZoomEventTypes; - - const relayedEvents = new WeakSet(); - const relayPinch = (event) => { - if ( - !event.ctrlKey || - relayedEvents.has(event) || - event.deltaMode !== 0 || - !Number.isFinite(event.deltaY) || - event.deltaY === 0 - ) - return; - let relayed; - try { - relayed = createWheelEvent('wheel', { - deltaX: event.deltaX, - deltaY: boundedPinchDelta(event.deltaY), - deltaZ: event.deltaZ, - deltaMode: event.deltaMode, - screenX: event.screenX, - screenY: event.screenY, - clientX: event.clientX, - clientY: event.clientY, - ctrlKey: true, - bubbles: true, - cancelable: true, - view: globalThis.window, - }); - } catch { - // The registered Ctrl+wheel binding can still consume the original. - return; - } - relayedEvents.add(relayed); - event.preventDefault(); - event.stopPropagation(); - canvas.dispatchEvent(relayed); - }; - container.addEventListener('wheel', relayPinch, { - capture: true, - passive: false, - }); - - let active = true; - return () => { - if (!active) return; - active = false; - container.removeEventListener('wheel', relayPinch, true); - if ( - !alreadyHandlesControlWheel && - controller.zoomEventTypes === configuredZoomEventTypes - ) - controller.zoomEventTypes = originalZoomEventTypes; - }; -} - /** Cesium.Viewer options of the standard globe viewer. */ export function applicationViewerOptions(creditContainer) { return { diff --git a/src/render/cesium/ctrlWheelZoom.js b/src/render/cesium/ctrlWheelZoom.js new file mode 100644 index 000000000..54ac3197a --- /dev/null +++ b/src/render/cesium/ctrlWheelZoom.js @@ -0,0 +1,108 @@ +/** + * @module render/cesium/ctrlWheelZoom + * @description Ctrl+wheel zoom on a Cesium scene: a Ctrl+wheel binding among + * the camera controller's zoom inputs, and browser trackpad pinches (small + * pixel-mode Ctrl+wheel events) relayed at a usable speed. + */ +import * as Cesium from 'cesium'; + +const PINCH_ZOOM_MULTIPLIER = 8; +const MAX_PINCH_PIXEL_DELTA = 120; + +function boundedPinchDelta(delta) { + if (!Number.isFinite(delta) || delta === 0) return delta; + return ( + Math.sign(delta) * + Math.min(Math.abs(delta) * PINCH_ZOOM_MULTIPLIER, MAX_PINCH_PIXEL_DELTA) + ); +} + +/** + * Add browser trackpad pinch to Cesium's zoom inputs and return its disposer. + * Browsers expose this gesture as a small pixel-mode Ctrl+wheel event. + */ +export function installTrackpadPinchZoom( + viewer, + { createWheelEvent = (type, init) => new WheelEvent(type, init) } = {}, +) { + const controller = viewer?.scene?.screenSpaceCameraController; + const container = viewer?.container; + const canvas = viewer?.canvas; + if (!controller || !container || !canvas) + throw new TypeError('A complete Cesium viewer is required'); + + const originalZoomEventTypes = controller.zoomEventTypes; + const zoomEventTypes = Array.isArray(originalZoomEventTypes) + ? originalZoomEventTypes + : originalZoomEventTypes === undefined + ? [] + : [originalZoomEventTypes]; + const alreadyHandlesControlWheel = zoomEventTypes.some( + (binding) => + binding?.eventType === Cesium.CameraEventType.WHEEL && + binding?.modifier === Cesium.KeyboardEventModifier.CTRL, + ); + const configuredZoomEventTypes = alreadyHandlesControlWheel + ? originalZoomEventTypes + : [ + ...zoomEventTypes, + { + eventType: Cesium.CameraEventType.WHEEL, + modifier: Cesium.KeyboardEventModifier.CTRL, + }, + ]; + if (!alreadyHandlesControlWheel) + controller.zoomEventTypes = configuredZoomEventTypes; + + const relayedEvents = new WeakSet(); + const relayPinch = (event) => { + if ( + !event.ctrlKey || + relayedEvents.has(event) || + event.deltaMode !== 0 || + !Number.isFinite(event.deltaY) || + event.deltaY === 0 + ) + return; + let relayed; + try { + relayed = createWheelEvent('wheel', { + deltaX: event.deltaX, + deltaY: boundedPinchDelta(event.deltaY), + deltaZ: event.deltaZ, + deltaMode: event.deltaMode, + screenX: event.screenX, + screenY: event.screenY, + clientX: event.clientX, + clientY: event.clientY, + ctrlKey: true, + bubbles: true, + cancelable: true, + view: globalThis.window, + }); + } catch { + // The registered Ctrl+wheel binding can still consume the original. + return; + } + relayedEvents.add(relayed); + event.preventDefault(); + event.stopPropagation(); + canvas.dispatchEvent(relayed); + }; + container.addEventListener('wheel', relayPinch, { + capture: true, + passive: false, + }); + + let active = true; + return () => { + if (!active) return; + active = false; + container.removeEventListener('wheel', relayPinch, true); + if ( + !alreadyHandlesControlWheel && + controller.zoomEventTypes === configuredZoomEventTypes + ) + controller.zoomEventTypes = originalZoomEventTypes; + }; +}