mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-05 19:04:02 +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
|
# 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
|
- 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
|
unknown instead of 102.3 knots and 360 degrees. Genuine readings, including a
|
||||||
stopped vessel's zero and the highest encodable values, are unchanged.
|
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
|
## 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.
|
- 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.
|
- 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.
|
- 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 assert from 'node:assert/strict';
|
||||||
import { spawnSync } from 'node:child_process';
|
import { spawnSync } from 'node:child_process';
|
||||||
import { createApplication } from './application.js';
|
import { createApplication } from './application.js';
|
||||||
|
import { installTrackpadPinchZoom } from './viewer.js';
|
||||||
|
|
||||||
const phases = ['Scene', 'Controls', 'Data', 'Tools'];
|
const phases = ['Scene', 'Controls', 'Data', 'Tools'];
|
||||||
function fixture(overrides = {}) {
|
function fixture(overrides = {}) {
|
||||||
@@ -217,3 +218,115 @@ test('the separate viewer export imports without constructing a browser viewer',
|
|||||||
assert.equal(typeof createApplicationViewer, 'function');
|
assert.equal(typeof createApplicationViewer, 'function');
|
||||||
assert.throws(() => createApplicationViewer({}), /containers are required/);
|
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 { createApplicationOperations } from './operations.js';
|
||||||
import * as Cesium from 'cesium';
|
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 { registerDataCredits } from '../data/dataCredits.js';
|
||||||
import { configureCreditKeyboardAccess } from '../creditKeyboard.js';
|
import { configureCreditKeyboardAccess } from '../creditKeyboard.js';
|
||||||
import { MapStackController } from '../mapStackController.js';
|
import { MapStackController } from '../mapStackController.js';
|
||||||
@@ -52,6 +55,7 @@ export async function createApplicationScene({
|
|||||||
uninstallRenderGovernor(viewer);
|
uninstallRenderGovernor(viewer);
|
||||||
if (!viewer.isDestroyed()) viewer.destroy();
|
if (!viewer.isDestroyed()) viewer.destroy();
|
||||||
});
|
});
|
||||||
|
defer(installTrackpadPinchZoom(viewer));
|
||||||
registerDataCredits(viewer, credits);
|
registerDataCredits(viewer, credits);
|
||||||
configureCreditKeyboardAccess(document);
|
configureCreditKeyboardAccess(document);
|
||||||
loaderStatus.textContent =
|
loaderStatus.textContent =
|
||||||
|
|||||||
@@ -1,5 +1,106 @@
|
|||||||
import * as Cesium from 'cesium';
|
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. */
|
/** Create the standard globe viewer in caller-owned, visible containers. */
|
||||||
export function createApplicationViewer({ container, creditContainer }) {
|
export function createApplicationViewer({ container, creditContainer }) {
|
||||||
if (!container || !creditContainer)
|
if (!container || !creditContainer)
|
||||||
|
|||||||
Reference in new issue
Block a user