diff --git a/CHANGELOG.md b/CHANGELOG.md index da600f5b0..744f4be0a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,10 @@ # Changelog +- Enable responsive trackpad pinch zoom on the globe. Browser pixel-mode + `Ctrl+wheel` pinch gestures now reach Cesium with bounded amplification, + while ordinary wheel, line-mode and touch-pinch inputs retain their existing + behavior; the listener is removed with the application scene. + - Report AIS speed and course that carry the standard "not available" code as unknown instead of 102.3 knots and 360 degrees. Genuine readings, including a stopped vessel's zero and the highest encodable values, are unchanged. diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index 791755972..9c381ac0c 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -3587,6 +3587,10 @@ are omitted rather than framing the wrong part of the globe. ## UI/UX Runtime Defaults +- Trackpad pinch zoom is registered as Cesium's `Ctrl+wheel` camera input. + Pixel-mode pinch deltas receive bounded amplification before reaching the + canvas; ordinary wheel, line/page-mode and native touch-pinch paths are left + unchanged. The application scene owns and removes the relay listener. - Z ladder: panels promote within 100–139 (renormalized on wrap), voice pill 150, toast 200, clean-view exit 300. - Panel POSITION keys are versioned `v8` (`godsEyeView.v8.panelPos.`); collapsed-state keys remain `v6`. The one-time position reset clears stale DISPLAY placements that could overlap the Context rail. - Map Source lives in the bottom Visual Presets tray. The left accordion contains no MAP STACK panel, and the `k` panel token that addressed it is gone from the share registry, so legacy `ui=k...` state takes the ordinary unknown-token skip. diff --git a/src/app/application.test.mjs b/src/app/application.test.mjs index 441667a12..d3ef8c7cc 100644 --- a/src/app/application.test.mjs +++ b/src/app/application.test.mjs @@ -2,6 +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'; const phases = ['Scene', 'Controls', 'Data', 'Tools']; function fixture(overrides = {}) { @@ -217,3 +218,115 @@ test('the separate viewer export imports without constructing a browser viewer', assert.equal(typeof createApplicationViewer, 'function'); assert.throws(() => createApplicationViewer({}), /containers are required/); }); + +function pinchFixture({ zoomEventTypes } = {}) { + const handlers = new Set(); + const relayed = []; + const container = { + addEventListener(type, handler, options) { + assert.equal(type, 'wheel'); + assert.deepEqual(options, { capture: true, passive: false }); + handlers.add(handler); + }, + removeEventListener(type, handler, capture) { + assert.equal(type, 'wheel'); + assert.equal(capture, true); + handlers.delete(handler); + }, + emit(event) { + for (const handler of handlers) handler(event); + }, + }; + const canvas = { + dispatchEvent(event) { + // A real canvas dispatch crosses the container's capture listener again. + container.emit(event); + relayed.push(event); + return true; + }, + }; + const controller = { zoomEventTypes }; + const viewer = { + container, + canvas, + scene: { screenSpaceCameraController: controller }, + }; + const createEvent = (init = {}) => ({ + deltaX: 0, + deltaY: 0, + deltaZ: 0, + deltaMode: 0, + screenX: 0, + screenY: 0, + clientX: 0, + clientY: 0, + ctrlKey: false, + defaultPrevented: false, + propagationStopped: false, + preventDefault() { + this.defaultPrevented = true; + }, + stopPropagation() { + this.propagationStopped = true; + }, + ...init, + }); + return { viewer, controller, container, handlers, relayed, createEvent }; +} + +test('trackpad pinch adds the Ctrl+wheel binding without replacing Cesium inputs', () => { + const original = [1, 2, 3]; + const f = pinchFixture({ zoomEventTypes: original }); + const dispose = installTrackpadPinchZoom(f.viewer, { + createWheelEvent: (_type, init) => f.createEvent(init), + }); + assert.deepEqual(f.controller.zoomEventTypes.slice(0, 3), original); + assert.equal(f.controller.zoomEventTypes.length, 4); + + const plain = f.createEvent({ deltaY: 2 }); + f.container.emit(plain); + assert.equal(plain.defaultPrevented, false); + const lineMode = f.createEvent({ ctrlKey: true, deltaY: 2, deltaMode: 1 }); + f.container.emit(lineMode); + assert.equal(lineMode.defaultPrevented, false); + assert.equal(f.relayed.length, 0); + + dispose(); + dispose(); + assert.equal(f.controller.zoomEventTypes, original); + assert.equal(f.handlers.size, 0); +}); + +test('trackpad pinch relays pixel deltas once with bounded amplification', () => { + const f = pinchFixture({ zoomEventTypes: [1, 2, 3] }); + installTrackpadPinchZoom(f.viewer, { + createWheelEvent: (_type, init) => f.createEvent(init), + }); + + const small = f.createEvent({ ctrlKey: true, deltaY: 2 }); + f.container.emit(small); + assert.equal(small.defaultPrevented, true); + assert.equal(small.propagationStopped, true); + assert.equal(f.relayed.length, 1); + assert.equal(f.relayed[0].deltaY, 16); + assert.equal(f.relayed[0].ctrlKey, true); + + const large = f.createEvent({ ctrlKey: true, deltaY: -100 }); + f.container.emit(large); + assert.equal(f.relayed.length, 2); + assert.equal(f.relayed[1].deltaY, -120); +}); + +test('trackpad pinch falls back to the original event when relay construction fails', () => { + const f = pinchFixture({ zoomEventTypes: [1, 2, 3] }); + installTrackpadPinchZoom(f.viewer, { + createWheelEvent() { + throw new Error('unsupported'); + }, + }); + const event = f.createEvent({ ctrlKey: true, deltaY: 2 }); + f.container.emit(event); + assert.equal(event.defaultPrevented, false); + assert.equal(event.propagationStopped, false); + assert.equal(f.relayed.length, 0); +}); diff --git a/src/app/scene.js b/src/app/scene.js index c1a9e178a..65e40cd7e 100644 --- a/src/app/scene.js +++ b/src/app/scene.js @@ -1,6 +1,9 @@ import { createApplicationOperations } from './operations.js'; import * as Cesium from 'cesium'; -import { createApplicationViewer } from '../app/viewer.js'; +import { + createApplicationViewer, + installTrackpadPinchZoom, +} from '../app/viewer.js'; import { registerDataCredits } from '../data/dataCredits.js'; import { configureCreditKeyboardAccess } from '../creditKeyboard.js'; import { MapStackController } from '../mapStackController.js'; @@ -52,6 +55,7 @@ export async function createApplicationScene({ uninstallRenderGovernor(viewer); if (!viewer.isDestroyed()) viewer.destroy(); }); + defer(installTrackpadPinchZoom(viewer)); registerDataCredits(viewer, credits); configureCreditKeyboardAccess(document); loaderStatus.textContent = diff --git a/src/app/viewer.js b/src/app/viewer.js index 0b5f720d0..ec58f52dc 100644 --- a/src/app/viewer.js +++ b/src/app/viewer.js @@ -1,5 +1,106 @@ 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; + }; +} + /** Create the standard globe viewer in caller-owned, visible containers. */ export function createApplicationViewer({ container, creditContainer }) { if (!container || !creditContainer)