fix(awareness): ignore canvas input while the cockpit owns the camera

The cockpit turns Cesium's camera controls off and moves the camera on
its own, so a click, drag or wheel on the canvas moved nothing yet still
counted as the user handling the map: the cockpit's next move woke a
nine-second pulse burst. Input now counts only while the camera controls
take it, and a gesture still pending when another owner takes the
camera is dropped.
This commit is contained in:
Sameh Khamis committed 2026-11-03 10:39:09 -08:00
1 parent fe3d17df02
commit 56a7cacbec
4 files changed
+109 -3

No files matched your search

+14 -1
View File
@@ -14,13 +14,22 @@ export function createLifecycle({
* Cesium canvas, where its camera controller listens), so a camera move the
* user made can wake the range pulses and one the app made (tracking,
* cockpit, orbit, a fly-to) does not. Keys are left out: Cesium's camera
* controller reads none, and typing elsewhere must not count.
* controller reads none, and typing elsewhere must not count. Nor does
* input while the camera controller's inputs are off: the cockpit (or a
* gizmo drag, or a drawing tool) owns the camera then, and the user's
* gesture moves nothing.
*/
function noteCameraInput(event) {
if (event?.type === 'pointermove' && !event.buttons) return;
if (!cameraInputsEnabled()) return;
layerState.lastCameraInputMs = Date.now();
}
function cameraInputsEnabled() {
const controller = layerState.viewer?.scene?.screenSpaceCameraController;
return controller?.enableInputs !== false;
}
const CAMERA_INPUT_EVENTS = [
'pointerdown',
'pointermove',
@@ -79,6 +88,10 @@ export function createLifecycle({
layerState.viewer.scene.preRender.addEventListener(() => {
if (!layerState.enabled) return;
const now = Date.now();
// Another owner took the camera: a gesture begun before it did must
// not wake the pulses on that owner's first move.
if (!cameraInputsEnabled())
layerState.lastCameraInputMs = Number.NEGATIVE_INFINITY;
// "Did the camera move" uses the SAME quantized pose signature the fleet
// rotation pass gates on (iconOrientation.cameraPoseSignature) rather than
// camera.changed, whose granularity is globally degraded by other layers
+1 -1
View File
@@ -92,7 +92,7 @@ export const AWARENESS_RANGE_RING_STYLE = Object.freeze({
* stays on screen, static, and nothing asks for frames, so a parked view
* goes quiet again. Only a camera move within `inputWakeMs` of the user
* handling the map (pointer, wheel or touch on the canvas, which covers a
* drag's inertia) wakes them; a moving subject and the app's own camera moves
* drag's inertia, while the camera controls take input) wakes them; a moving subject and the app's own camera moves
* (tracking, cockpit, orbit, a fly-to) do not. Below the disc's height they
* rest too. While they run they never hold
* continuous rendering; each drawn frame arms one more request after the
+93
View File
@@ -574,6 +574,99 @@ test('a camera move seen by the lifecycle wakes resting pulses; a parked camera
assert.equal(state.lastCameraInputMs < Date.now() - inputWakeMs, true);
});
/**
* The lifecycle's camera-input listeners over `ring`, with a camera the test
* moves by longitude (`pan`) and a camera controller whose inputs it can
* switch off, as the cockpit view does. Pulses start resting.
*/
function inputLifecycle(t, ring) {
const { state, rendering } = ring;
const loop = frameLoop(t, ring, { date: true });
state.enabled = true;
state.viewer.canvas = new EventTarget();
state.viewer.scene.screenSpaceCameraController = { enableInputs: true };
state.viewer.camera = {
positionWC: Cesium.Cartesian3.fromDegrees(3, 20, 800_000),
heading: 0,
pitch: -1,
roll: 0,
};
const lifecycle = createLifecycle({
state,
services: {
render: { holdContinuousRender() {}, releaseContinuousRender() {} },
},
parts: {
rendering,
model: {
awarenessRefreshDecision: () => ({ moving: false, refresh: false }),
awarenessNeedsContinuousRender: () => false,
},
},
});
lifecycle.attachRuntimeListeners();
t.after(() => lifecycle.detachRuntimeListeners());
rendering.renderVisual(flight(3));
loop.advance(AWARENESS_RANGE_PULSE.activeMs + 500);
let lon = 3;
return {
loop,
lifecycle,
controller: state.viewer.scene.screenSpaceCameraController,
dispatch: (type, init = {}) =>
state.viewer.canvas.dispatchEvent(Object.assign(new Event(type), init)),
pan(stepDeg = 0.1) {
lon += stepDeg;
state.viewer.camera.positionWC = Cesium.Cartesian3.fromDegrees(
lon,
20,
800_000,
);
loop.draw();
},
/** Whether the pulses drew frames during the next `ms`. */
pulsing(ms = AWARENESS_RANGE_PULSE.activeMs) {
const before = ring.pulseFrames();
loop.advance(ms);
return ring.pulseFrames() > before;
},
};
}
test('input the camera controller ignores, as in the cockpit, does not wake the pulses', (t) => {
const ring = ringRendering(t);
const { activeMs } = AWARENESS_RANGE_PULSE;
const { controller, dispatch, pan, pulsing, loop } = inputLifecycle(t, ring);
// The cockpit takes the camera: clicks, drags and wheels on the canvas
// move nothing, while the cockpit itself moves the camera every frame.
controller.enableInputs = false;
for (const [type, init] of [
['pointerdown', {}],
['pointermove', { buttons: 1 }],
['wheel', {}],
['touchstart', {}],
]) {
dispatch(type, init);
pan();
assert.equal(pulsing(), false, `${type} in the cockpit`);
}
// A gesture that began just before the cockpit took the camera is
// dropped too, rather than waking the pulses on the cockpit's first move.
controller.enableInputs = true;
loop.advance(activeMs);
dispatch('pointerdown');
controller.enableInputs = false;
pan();
assert.equal(pulsing(), false, 'a gesture cut off by the cockpit');
controller.enableInputs = true;
pan();
assert.equal(pulsing(), false, 'and stays dropped once it hands back');
// Back in the user's hands, a drag wakes them again.
dispatch('pointerdown');
pan();
assert.equal(pulsing(), true, 'a drag after the cockpit');
});
test('disabling the layer tears the pulses down completely', (t) => {
const ring = ringRendering(t);
const { state, scene, rendering, motion, pulseFrames } = ring;