From de634952ec9b5e3fea12a4f1e17315f3e260f651 Mon Sep 17 00:00:00 2001 From: Sameh Khamis Date: Tue, 3 Nov 2026 10:40:30 -0800 Subject: [PATCH] fix(awareness): keep a long drag or touch counting as camera input Only canvas events refreshed the 1.5 s input window, and a touch only on touchstart. Cesium follows a drag that leaves the canvas at document level (in Firefox, and wherever there are no pointer events), so a long gesture kept moving the camera after the window closed, and the pulses rested mid-gesture. From a pointer or touch start on the canvas until its end or cancel, moves at document level now count too; the listeners go when the gesture ends or the layer detaches. --- docs/CURRENT-STATE.md | 2 +- src/layers/awareness/lifecycle.js | 31 ++++++++++++++++++++ src/layers/awareness/rangeRing.test.mjs | 39 +++++++++++++++++++++++++ 3 files changed, 71 insertions(+), 1 deletion(-) diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index d53e86a25..b4ce97d81 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -4957,7 +4957,7 @@ silently demoting every later lookup for the session. - Enabled Data Layer controls report normalized feed health on the button (`LOADING`, `DEGRADED`, `STALE`, `FALLBACK`, or `UNAVAILABLE`) while the metadata line retains the source and reason. A partial CelesTrak group failure keeps the usable catalog and reports `DEGRADED`; a total outage keeps last-good catalog data visible but reports `UNAVAILABLE`. - On activation it focuses the nearest currently observed aircraft across the civilian and military feeds, with military winning an exact distance tie; if none are available, it focuses an observed AIS vessel. The aircraft search is deliberately uncapped and one refresh-tick retry handles initially empty feeds. This is an attention-priority navigation shortcut, not a high-risk, affiliation, or threat classification. - A selected aircraft, AIS vessel, or mapped installation gets a 250 km **context window** with nearby cohort counts, nearest examples, source labels, and stale/unavailable reasons. It emits `NEARBY` or `UNKNOWN`; no detection, engagement, affiliation, or sensor-activity conclusion is calculated. -- The window's edge is drawn on the globe as a 250 km range ring: a 4 px polyline with a 2 px Context-blue core inside a 1 px dark halo on each side, 2.5 km above the ellipsoid (`AWARENESS_RANGE_RING_STYLE` in `src/layers/awareness/policy.js`). The halo keeps it legible over bright desert and snow, dark ocean and forest, and under the FLIR, NVG and CRT styles, where the earlier 1 px ellipse outline disappeared. It is a polyline because WebGL draws ellipse outlines at 1 px whatever width is asked for, drawn as an unpickable primitive rather than an entity so a click or tap on its edge reaches the contact underneath (headless viewers without a primitive collection get a static entity instead); it is rebuilt when the subject moves. Three dim sonar pulses (3 px with a 1 px dark outline, same colours, at most 60% opacity) grow slowly from the subject at constant speed, one every 3 s and each taking 9 s, and arrive at the ring at about a third of that. Behind each pulse a radial gradient in the same blue fades inward over 35% of the radius. One unpickable 250 km disc draws both the pulse lines and the gradient, its shader reading the pulse radii and opacities each frame, so a click or tap near a pulse still reaches the contact underneath; the disc follows the subject by a model-matrix rotation and is rebuilt only after the subject moves 20 km, and not at all while the pulses rest. While the camera is below the disc's 2.5 km height, where it would sweep across the sky, the pulses rest: the disc is hidden and there is no ring flash, no echo and no frame request. Terrain or 3D tiles higher than 2.5 km (the Alps, Andes, Rockies) still cut through the disc and the ring when seen from above, as the ring alone already did; the height check uses the camera's height above the ellipsoid, not above the ground. While pulses run, the ring dims to 40% and flashes to full strength as each pulse reaches it, fading over 1.8 s. As a pulse front crosses a flight, military aircraft or AIS vessel, the detection overlay draws a second bracket around that contact's own bracket, in the pulse blue on the default detection style and in the style's own line colour under NVG, FLIR and CRT, which spreads outward as it fades over 1.4 s (`AWARENESS_RANGE_ECHO`, read through `src/awarenessEcho.js`; no layer's icon is restyled, and mapped installations have no detection bracket). Pulses are born 5 km out, so the subject itself and anything nearer never echo. Pulses run for 9 s (three ring flashes) after a selection or a camera move, fading in and out over 1.2 s, then rest: the ring stays on screen at full strength, the pulses, gradient and echoes are hidden, and nothing asks for frames, so a parked Contacts view is quiet again. Only a camera move within 1.5 s of the user handling the map (pointer, wheel or touch on the Cesium canvas, which covers a drag's inertia; keys typed elsewhere do not count, nor does input while Cesium's camera controls are off, as in the cockpit view, and a gesture still pending when the cockpit or another owner takes the camera is dropped) wakes them; a moving subject and the app's own camera moves (a tracked aircraft, the cockpit view, an orbit, a fly-to) do not, since they move on every refresh. While they run they never hold continuous rendering: each drawn frame arms one render request 80 ms later (the Cyber sonar cadence, `CYBER_SONAR_RENDER_INTERVAL_MS`), so they repaint at about 12.5 fps and never beat the detection lane's 80 ms gate. Under `prefers-reduced-motion`, or in a scene without primitives or preRender and postRender hooks, there are no pulses or echoes and the ring stays on screen; changing the preference mid-session rebuilds the ring. The ring follows a moving subject once it has moved 250 m. +- The window's edge is drawn on the globe as a 250 km range ring: a 4 px polyline with a 2 px Context-blue core inside a 1 px dark halo on each side, 2.5 km above the ellipsoid (`AWARENESS_RANGE_RING_STYLE` in `src/layers/awareness/policy.js`). The halo keeps it legible over bright desert and snow, dark ocean and forest, and under the FLIR, NVG and CRT styles, where the earlier 1 px ellipse outline disappeared. It is a polyline because WebGL draws ellipse outlines at 1 px whatever width is asked for, drawn as an unpickable primitive rather than an entity so a click or tap on its edge reaches the contact underneath (headless viewers without a primitive collection get a static entity instead); it is rebuilt when the subject moves. Three dim sonar pulses (3 px with a 1 px dark outline, same colours, at most 60% opacity) grow slowly from the subject at constant speed, one every 3 s and each taking 9 s, and arrive at the ring at about a third of that. Behind each pulse a radial gradient in the same blue fades inward over 35% of the radius. One unpickable 250 km disc draws both the pulse lines and the gradient, its shader reading the pulse radii and opacities each frame, so a click or tap near a pulse still reaches the contact underneath; the disc follows the subject by a model-matrix rotation and is rebuilt only after the subject moves 20 km, and not at all while the pulses rest. While the camera is below the disc's 2.5 km height, where it would sweep across the sky, the pulses rest: the disc is hidden and there is no ring flash, no echo and no frame request. Terrain or 3D tiles higher than 2.5 km (the Alps, Andes, Rockies) still cut through the disc and the ring when seen from above, as the ring alone already did; the height check uses the camera's height above the ellipsoid, not above the ground. While pulses run, the ring dims to 40% and flashes to full strength as each pulse reaches it, fading over 1.8 s. As a pulse front crosses a flight, military aircraft or AIS vessel, the detection overlay draws a second bracket around that contact's own bracket, in the pulse blue on the default detection style and in the style's own line colour under NVG, FLIR and CRT, which spreads outward as it fades over 1.4 s (`AWARENESS_RANGE_ECHO`, read through `src/awarenessEcho.js`; no layer's icon is restyled, and mapped installations have no detection bracket). Pulses are born 5 km out, so the subject itself and anything nearer never echo. Pulses run for 9 s (three ring flashes) after a selection or a camera move, fading in and out over 1.2 s, then rest: the ring stays on screen at full strength, the pulses, gradient and echoes are hidden, and nothing asks for frames, so a parked Contacts view is quiet again. Only a camera move within 1.5 s of the user handling the map (pointer, wheel or touch on the Cesium canvas, which covers a drag's inertia, and a drag or touch begun there keeps counting until it ends, even outside the canvas; keys typed elsewhere do not count, nor does input while Cesium's camera controls are off, as in the cockpit view, and a gesture still pending when the cockpit or another owner takes the camera is dropped) wakes them; a moving subject and the app's own camera moves (a tracked aircraft, the cockpit view, an orbit, a fly-to) do not, since they move on every refresh. While they run they never hold continuous rendering: each drawn frame arms one render request 80 ms later (the Cyber sonar cadence, `CYBER_SONAR_RENDER_INTERVAL_MS`), so they repaint at about 12.5 fps and never beat the detection lane's 80 ms gate. Under `prefers-reduced-motion`, or in a scene without primitives or preRender and postRender hooks, there are no pulses or echoes and the ring stays on screen; changing the preference mid-session rebuilds the ring. The ring follows a moving subject once it has moved 250 m. - For a selected live aircraft or vessel, the context window refreshes from the existing tracker/feed position every 750 ms, so nearest distances and cohort counts follow the subject without introducing a duplicate poll loop. - Aircraft cohort membership uses every locally loaded, selectable contact inside the 250 km window, including a plane hidden only by horizon culling or because its 3D model owns the visual. Counts and navigation therefore do not change with the current camera angle or billboard/model handoff. - Nearby examples in the context panel are focus controls: they use their owning layer's existing selection/tracking path, then frame that contact. Static-installation distances use ellipsoidal surface distance so the count matches the ground-projected context disk. diff --git a/src/layers/awareness/lifecycle.js b/src/layers/awareness/lifecycle.js index 57903eecb..042148c37 100644 --- a/src/layers/awareness/lifecycle.js +++ b/src/layers/awareness/lifecycle.js @@ -23,6 +23,36 @@ export function createLifecycle({ if (event?.type === 'pointermove' && !event.buttons) return; if (!cameraInputsEnabled()) return; layerState.lastCameraInputMs = Date.now(); + if (event?.type === 'pointerdown' || event?.type === 'touchstart') + followGesture(true); + } + + // A drag can leave the canvas, and Cesium follows it at document level + // (in Firefox, and wherever there are no pointer events). So does this, + // from a gesture's start on the canvas to its end, so a long gesture keeps + // the input window open the whole way. + const GESTURE_MOVE_EVENTS = ['pointermove', 'touchmove']; + const GESTURE_END_EVENTS = [ + 'pointerup', + 'pointercancel', + 'touchend', + 'touchcancel', + ]; + let followingGesture = false; + + function followGesture(on) { + if (followingGesture === on) return; + followingGesture = on; + const listen = on ? 'addEventListener' : 'removeEventListener'; + for (const type of GESTURE_MOVE_EVENTS) + globalThis.document?.[listen]?.(type, noteCameraInput, { passive: true }); + for (const type of GESTURE_END_EVENTS) + globalThis.document?.[listen]?.(type, endGesture, { passive: true }); + } + + function endGesture(event) { + noteCameraInput(event); + followGesture(false); } function cameraInputsEnabled() { @@ -160,6 +190,7 @@ export function createLifecycle({ noteCameraInput, ); layerState.cameraInputTarget = null; + followGesture(false); } layerState.preRenderRemover?.(); layerState.preRenderRemover = null; diff --git a/src/layers/awareness/rangeRing.test.mjs b/src/layers/awareness/rangeRing.test.mjs index 5c85a23eb..a24794401 100644 --- a/src/layers/awareness/rangeRing.test.mjs +++ b/src/layers/awareness/rangeRing.test.mjs @@ -667,6 +667,45 @@ test('input the camera controller ignores, as in the cockpit, does not wake the assert.equal(pulsing(), true, 'a drag after the cockpit'); }); +test('a long drag or touch keeps the pulses awake after it leaves the canvas', (t) => { + const ring = ringRendering(t); + const { inputWakeMs } = AWARENESS_RANGE_PULSE; + const { dispatch, pan, pulsing, loop, lifecycle } = inputLifecycle(t, ring); + const onDocument = (type, init = {}) => + document.dispatchEvent(Object.assign(new Event(type), init)); + // The gesture starts on the canvas and carries on past `inputWakeMs` + // outside it, where Cesium still follows it at document level. + const longGesture = (start, move, init) => { + dispatch(start, init); + for (let ms = 0; ms < inputWakeMs + 1000; ms += 250) { + loop.advance(250); + onDocument(move, init); + } + }; + for (const [start, move, end, init] of [ + ['pointerdown', 'pointermove', 'pointerup', { buttons: 1 }], + ['touchstart', 'touchmove', 'touchend', {}], + ['touchstart', 'touchmove', 'touchcancel', {}], + ]) { + longGesture(start, move, init); + pan(); + assert.equal(pulsing(), true, `${move} outside the canvas`); + // Once it ends, moves elsewhere are not handling the map. + onDocument(end); + loop.advance(inputWakeMs + 100); + onDocument(move, init); + pan(); + assert.equal(pulsing(), false, `${move} after ${end}`); + } + // Detaching mid-gesture drops the document listeners too. + dispatch('pointerdown'); + lifecycle.detachRuntimeListeners(); + loop.advance(inputWakeMs + 100); + const noted = ring.state.lastCameraInputMs; + onDocument('pointermove', { buttons: 1 }); + assert.equal(ring.state.lastCameraInputMs, noted); +}); + test('disabling the layer tears the pulses down completely', (t) => { const ring = ringRendering(t); const { state, scene, rendering, motion, pulseFrames } = ring;