mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-04 02:14:01 +00:00
Merge pull request #284 from jrmagnus/fix/trackpad-pinch-zoom
Fix trackpad pinch-to-zoom on macOS
This commit is contained in:
5 files changed
+228
-1
No files matched your search
@@ -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.
|
||||
|
||||
@@ -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.<id>`); 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.
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
+5
-1
@@ -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 =
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in new issue
Block a user