From a8fae8d87a29bd44c43bf6480bf6ecf695377237 Mon Sep 17 00:00:00 2001 From: jrmagnus Date: Thu, 10 Sep 2026 23:23:16 -0300 Subject: [PATCH 1/2] fix: enable trackpad pinch-to-zoom on the globe MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit macOS reports trackpad pinch as wheel events with ctrlKey=true, but Cesium's default zoomEventTypes only binds plain WHEEL (no modifier), so pinch was silently ignored — only two-finger scroll worked. Adds the CTRL-modified WHEEL binding so Cesium recognizes pinch as a zoom gesture, plus a capture-phase relay that amplifies the pinch event's deltaY before Cesium processes it, since macOS reports much smaller deltaY per unit of finger movement for pinch than for scroll (same fixed Cesium formula otherwise made pinch feel unresponsive). --- src/main.js | 52 ++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 52 insertions(+) diff --git a/src/main.js b/src/main.js index 84fbf12fe..24f8fe0aa 100644 --- a/src/main.js +++ b/src/main.js @@ -125,6 +125,58 @@ async function init() { // 120 Hz hardware; a no-op on 60 Hz displays. (perf item 2) viewer.targetFrameRate = 60; + // macOS trackpad pinch reaches the canvas as a `wheel` event with + // ctrlKey=true (Safari/Chrome both do this so pages can implement their + // own pinch-zoom instead of the OS zooming the whole page). Cesium's + // default zoomEventTypes only binds plain WHEEL (no modifier), so pinch + // events were falling through to no-op — two-finger scroll (plain wheel, + // no ctrlKey) worked, pinch didn't. Add the CTRL-modified WHEEL binding + // so pinch drives zoom the same way scroll does. + viewer.scene.screenSpaceCameraController.zoomEventTypes = [ + Cesium.CameraEventType.RIGHT_DRAG, + Cesium.CameraEventType.WHEEL, + Cesium.CameraEventType.PINCH, + { + eventType: Cesium.CameraEventType.WHEEL, + modifier: Cesium.KeyboardEventModifier.CTRL, + }, + ]; + + // Cesium turns any wheel `delta` into camera movement via the same fixed + // formula regardless of what produced it (arcLength = 7.5deg * delta). + // macOS reports trackpad pinch as wheel+ctrlKey with a much smaller + // deltaY per unit of finger movement than a real two-finger scroll + // produces, so pinch alone reads as "barely moving" even with the + // CTRL-modified zoom binding above. There's no public Cesium knob to + // rescale just the CTRL+WHEEL case, so intercept the raw pinch event + // ahead of Cesium's own listener (capture phase on an ancestor of the + // canvas always runs first) and re-dispatch it with deltaY amplified. + const PINCH_ZOOM_MULTIPLIER = 8; + const PINCH_RELAY_FLAG = '__pinchRelayed'; + viewer.container.addEventListener( + 'wheel', + (event) => { + if (!event.ctrlKey || event[PINCH_RELAY_FLAG]) return; + event.preventDefault(); + event.stopPropagation(); + const relayed = new WheelEvent('wheel', { + deltaX: event.deltaX, + deltaY: event.deltaY * PINCH_ZOOM_MULTIPLIER, + deltaZ: event.deltaZ, + deltaMode: event.deltaMode, + clientX: event.clientX, + clientY: event.clientY, + ctrlKey: true, + bubbles: true, + cancelable: true, + view: window, + }); + relayed[PINCH_RELAY_FLAG] = true; + viewer.canvas.dispatchEvent(relayed); + }, + { capture: true, passive: false }, + ); + // Register per-layer data attribution into the "Data attribution" popover. // Required by each source's license (ODbL, CC BY-NC-SA, NASA FIRMS, etc.); // strings are verbatim from DATA_SOURCES.md. Static + always-present in the From b641ad2019e16bef56969b88a4d9b37bff770c8d Mon Sep 17 00:00:00 2001 From: Sameh Khamis Date: Sun, 20 Sep 2026 19:49:07 -0700 Subject: [PATCH 2/2] fix: integrate bounded trackpad pinch zoom --- CHANGELOG.md | 5 ++ docs/CURRENT-STATE.md | 4 ++ src/app/application.test.mjs | 113 +++++++++++++++++++++++++++++++++++ src/app/scene.js | 6 +- src/app/viewer.js | 101 +++++++++++++++++++++++++++++++ 5 files changed, 228 insertions(+), 1 deletion(-) 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)