diff --git a/changelog.d/1519-range-ring-sonar-pulses.md b/changelog.d/1519-range-ring-sonar-pulses.md index f82d9e5c9..36ac2eb1c 100644 --- a/changelog.d/1519-range-ring-sonar-pulses.md +++ b/changelog.d/1519-range-ring-sonar-pulses.md @@ -1,6 +1,8 @@ - The Global Context range ring pulses like a sonar: three dim rings with a soft inward glow grow from the subject to 250 km, and the ring flashes as each one arrives. Flights, military aircraft and AIS vessels a pulse crosses - get a brief echo around their brackets. While a subject is shown, a parked - Contacts view repaints about 12 times a second. Under reduced motion there - are no pulses and the ring stays on screen (manjunath22466, #1519). + get a brief echo around their brackets. The pulses run for about 9 s after + a selection or camera move, dimming the ring between flashes, then rest + with the ring steady on screen, so a parked view stops repainting. Under + reduced motion there are no pulses and the ring stays on screen + (manjunath22466, #1519). diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index 46b012cf6..5b85500ae 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. Three dim sonar pulses (3 px, 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, drawn by one unpickable 250 km disc whose shader reads the pulse radii and opacities each frame; the disc itself is rebuilt only after the subject moves 2 km. While pulses run, the ring is not shown on its own: it flashes to full strength as each pulse reaches it and fades over 1.8 s. As a pulse front crosses a flight, military aircraft or AIS vessel, the detection overlay draws a second bracket in the pulse blue around that contact's own bracket, 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 never hold continuous rendering: they ask for a frame every 80 ms, the Cyber sonar's parked cadence, so a parked Contacts view repaints at about 12.5 fps while a subject is shown, and at full rate only when something else already renders continuously. Under `prefers-reduced-motion`, or in a scene without primitives or a preRender hook, there are no pulses or echoes and the ring stays on screen. 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. Three dim sonar pulses (3 px, 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, drawn by one unpickable 250 km disc whose shader reads the pulse radii and opacities each frame; the disc itself is rebuilt only after the subject moves 2 km. 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 in the pulse blue around that contact's own bracket, 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 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. A moving subject does not wake them, since a flying subject moves on every refresh; a camera that follows a tracked aircraft does keep them running, but that follow already renders every frame. 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/awarenessEcho.js b/src/awarenessEcho.js index 16fe6cda4..5f8efba0f 100644 --- a/src/awarenessEcho.js +++ b/src/awarenessEcho.js @@ -15,6 +15,8 @@ import * as Cesium from 'cesium'; * @property {number} radiusM Range the pulses grow to. * @property {{count: number, periodMs: number, startFraction: number}} pulse * @property {{fadeMs: number, layerIds: string[]}} echo + * @property {(nowMs: number) => number} [level] How far the pulses are + * switched on, 0 to 1; echoes scale with it, so they rest with the pulses. */ /** @type {AwarenessEchoField|null} */ @@ -94,11 +96,16 @@ export function awarenessEchoAt(layerId, position, nowMs) { groundScratch, ); if (!ground) return 0; - return awarenessEchoStrength( - nowMs, - Cesium.Cartesian3.distance(field.center, ground), - field.radiusM, - field.pulse, - field.echo, + const level = field.level?.(nowMs) ?? 1; + if (!(level > 0)) return 0; + return ( + level * + awarenessEchoStrength( + nowMs, + Cesium.Cartesian3.distance(field.center, ground), + field.radiusM, + field.pulse, + field.echo, + ) ); } diff --git a/src/awarenessEcho.test.mjs b/src/awarenessEcho.test.mjs index 9bc7e6cd6..f3bdec026 100644 --- a/src/awarenessEcho.test.mjs +++ b/src/awarenessEcho.test.mjs @@ -82,3 +82,28 @@ test('awarenessEchoAt reads the published field, by layer, at ground level', (t) assert.equal(getAwarenessEchoField(), null); assert.equal(awarenessEchoAt('flights', contact, t0), 0); }); + +test('echoes scale with the field level and rest with the pulses', (t) => { + t.after(clearAwarenessEchoField); + const center = Cesium.Cartesian3.fromDegrees(3, 27, 0); + // ~100 km east of the subject, on the ground. + const contact = Cesium.Cartesian3.fromDegrees(4.008, 27, 0); + let level = 1; + setAwarenessEchoField({ + center, + radiusM: R, + pulse, + echo, + level: () => level, + }); + const d = Cesium.Cartesian3.distance(center, contact); + const t0 = arrival(d); + const full = awarenessEchoAt('flights', contact, t0); + assert.ok(full > 0.99); + level = 0.5; + assert.ok( + Math.abs(awarenessEchoAt('flights', contact, t0) - full / 2) < 1e-9, + ); + level = 0; + assert.equal(awarenessEchoAt('flights', contact, t0), 0); +}); diff --git a/src/layers/awareness/lifecycle.js b/src/layers/awareness/lifecycle.js index 925a3a9ca..27a7d0e55 100644 --- a/src/layers/awareness/lifecycle.js +++ b/src/layers/awareness/lifecycle.js @@ -62,6 +62,8 @@ export function createLifecycle({ if (poseSig !== layerState.lastCameraPoseSig) { layerState.lastCameraPoseSig = poseSig; layerState.lastCameraPoseChangeMs = now; + // A camera move runs the range pulses again; parked, they rest. + parts.rendering.wakePulses(); } const decision = parts.model.awarenessRefreshDecision({ nowMs: now, diff --git a/src/layers/awareness/policy.js b/src/layers/awareness/policy.js index a0af9e3d7..66485c4eb 100644 --- a/src/layers/awareness/policy.js +++ b/src/layers/awareness/policy.js @@ -50,9 +50,11 @@ export const CONTEXT_RIM_HEIGHT_M = 2500; * luminance contrast on light and dark ground alike, and luminance is what * the monochrome styles keep. The core is the Context panel's blue; the halo * is the map-card ink. Widths are screen pixels; `outlineWidth` is per side. - * While the sonar pulses run, the ring rests at `restOpacity` and flashes - * to full as each pulse reaches it, fading over `flashMs`. Without pulses - * (reduced motion, or a scene that cannot animate) it stays on screen. */ + * While the sonar pulses run, the ring dims to `restOpacity` and flashes + * to full as each pulse reaches it, fading over `flashMs`. `restOpacity` + * keeps the 250 km edge readable between flashes, in screenshots and for + * low-vision viewers. Once the pulses rest, and without pulses (reduced + * motion, or a scene that cannot animate), it stays on screen at full. */ export const AWARENESS_RANGE_RING_STYLE = Object.freeze({ color: 'rgba(98, 181, 255, 0.95)', @@ -61,7 +63,7 @@ export const AWARENESS_RANGE_RING_STYLE = Object.freeze({ outlineWidth: 1, segments: 128, flashMs: 1800, - restOpacity: 0, + restOpacity: 0.4, }); /** @constant Sonar-style pulses that grow slowly from the subject to the @@ -75,11 +77,16 @@ export const AWARENESS_RANGE_RING_STYLE = Object.freeze({ * `gradientOpacity` strong. The gradient disc is rebuilt only after the * subject moves `gradientRebuildM`, since a soft band 2 km off centre on a * 250 km disc does not show. The gradient shader takes at most four pulses. - * The pulses do not hold continuous rendering: a parked Contacts view must - * not repaint every frame. They ask for a frame every `frameIntervalMs` - * instead, the cadence the Cyber sonar uses for parked scenes; when - * something else already renders continuously they update every frame. - * Skipped under prefers-reduced-motion. */ + * The pulses run for `activeMs` (three ring flashes) after a selection or + * a camera move, fading in and out over `settleMs`, then rest: the ring + * stays on screen, static, and nothing asks for frames, so a parked view + * goes quiet again. A moving subject does not wake them, since a flying + * subject moves on every refresh, but a camera that follows it does; the + * follow already renders every frame. While they run they never hold + * continuous rendering; each drawn frame arms one more request after the + * Cyber sonar's 80 ms cadence, so the two never beat. When something else + * already renders continuously they update every frame. Skipped under + * prefers-reduced-motion. */ export const AWARENESS_RANGE_PULSE = Object.freeze({ count: 3, @@ -96,7 +103,8 @@ export const AWARENESS_RANGE_PULSE = Object.freeze({ gradientOpacity: 0.22, gradientGranularityDeg: 0.25, gradientRebuildM: 2000, - frameIntervalMs: 80, + activeMs: 9000, + settleMs: 1200, }); /** @constant Sonar echoes on contact brackets. When a pulse front reaches a diff --git a/src/layers/awareness/rangeRing.test.mjs b/src/layers/awareness/rangeRing.test.mjs index 655bdc080..8b56685f7 100644 --- a/src/layers/awareness/rangeRing.test.mjs +++ b/src/layers/awareness/rangeRing.test.mjs @@ -16,14 +16,18 @@ import { AWARENESS_RANGE_RING_STYLE, CONTEXT_RIM_HEIGHT_M, } from './policy.js'; +import { CYBER_SONAR_RENDER_INTERVAL_MS } from '../../cyberSonar.js'; import { AWARENESS_PULSE_GRADIENT_MAX, + awarenessPulseLevel, awarenessPulseState, awarenessRangeRingPositions, awarenessRingFlash, createRendering, + wakeAwarenessPulses, } from './rendering.js'; import { getAwarenessEchoField } from '../../awarenessEcho.js'; +import { createLifecycle } from './lifecycle.js'; import { createState } from './state.js'; /** WCAG relative luminance of a Cesium colour, ignoring alpha. */ @@ -39,22 +43,48 @@ function luminance(color) { /** * The rendering part over a bare entity collection, counting render asks and - * by reason. `reducedMotion` stubs the media query for - * the length of the test. + * by reason. `reducedMotion` stubs the media query for the length of the + * test; `motion.set(on)` flips it later and tells its listeners, as the + * browser does. */ function ringRendering(t, { reducedMotion = false } = {}) { const previous = globalThis.matchMedia; - globalThis.matchMedia = (query) => ({ - matches: query === '(prefers-reduced-motion: reduce)' && reducedMotion, - }); + const listeners = new Set(); + const query = { + get matches() { + return reducedMotion; + }, + addEventListener(type, listener) { + if (type === 'change') listeners.add(listener); + }, + removeEventListener(type, listener) { + if (type === 'change') listeners.delete(listener); + }, + }; + globalThis.matchMedia = (media) => + media === '(prefers-reduced-motion: reduce)' + ? query + : { matches: false, addEventListener() {}, removeEventListener() {} }; t.after(() => { globalThis.matchMedia = previous; }); + const motion = { + set(on) { + reducedMotion = on; + for (const listener of [...listeners]) listener({ matches: on }); + }, + /** Change the preference without a change event, as old browsers do. */ + setQuietly(on) { + reducedMotion = on; + }, + listeners: () => listeners.size, + }; const state = createState({ services: {} }); const entities = new Cesium.EntityCollection(); const scene = { primitives: new Cesium.PrimitiveCollection(), preRender: new Cesium.Event(), + postRender: new Cesium.Event(), }; state.viewer = { entities, scene }; const reasons = []; @@ -77,11 +107,42 @@ function ringRendering(t, { reducedMotion = false } = {}) { entities, scene, rendering, + motion, + reasons, renders: () => reasons.filter((r) => r === 'awareness-visual').length, pulseFrames: () => reasons.filter((r) => r === 'awareness-pulse').length, }; } +/** + * A request-render scene on a fake clock: `performance.now()` and the + * timers advance together, and any render ask is drawn (preRender, then + * postRender) on the next millisecond, as Cesium's request-render mode does. + * `draw()` draws a frame someone else asked for. + */ +function frameLoop(t, ring) { + let clock = 1_000_000; + t.mock.method(performance, 'now', () => clock); + t.mock.timers.enable({ apis: ['setTimeout', 'setInterval'] }); + let drawn = 0; + const draw = () => { + drawn = ring.reasons.length; + ring.scene.preRender.raiseEvent(ring.scene); + ring.scene.postRender.raiseEvent(ring.scene); + }; + return { + now: () => clock, + draw, + advance(ms) { + for (let step = 0; step < ms; step++) { + clock++; + t.mock.timers.tick(1); + if (ring.reasons.length > drawn) draw(); + } + }, + }; +} + /** The pulse gradient disc and the echo collection among scene primitives. */ function pulsePrimitives(scene) { const all = Array.from({ length: scene.primitives.length }, (_, i) => @@ -284,15 +345,17 @@ test('a pulse grows slowly from the subject and arrives dim at the ring', () => ); }); -test('pulses ask for a frame every 80 ms instead of holding continuous rendering', (t) => { - t.mock.timers.enable({ apis: ['setInterval'] }); - const { entities, rendering, pulseFrames } = ringRendering(t); +test('pulses ask for a frame 80 ms after each drawn frame instead of holding continuous rendering', (t) => { + const ring = ringRendering(t); + const { entities, rendering, pulseFrames } = ring; + const loop = frameLoop(t, ring); rendering.renderVisual(flight(3)); - // A parked Contacts view repaints at the Cyber sonar cadence, ~12.5 fps, - // never every display frame. + // A Contacts view repaints at the Cyber sonar cadence, ~12.5 fps, while + // the pulses run, never every display frame. assert.equal(pulseFrames(), 0); - t.mock.timers.tick(AWARENESS_RANGE_PULSE.frameIntervalMs * 10); - assert.equal(pulseFrames(), 10); + loop.advance(1); // the frame the new ring asked for arms the first request + loop.advance(CYBER_SONAR_RENDER_INTERVAL_MS * 10); + assert.ok(Math.abs(pulseFrames() - 10) <= 1, `${pulseFrames()} frames`); const now = Cesium.JulianDate.now(); const core = Cesium.Color.fromCssColorString( AWARENESS_RANGE_RING_STYLE.color, @@ -316,15 +379,167 @@ test('pulses ask for a frame every 80 ms instead of holding continuous rendering ); assert.ok(Math.max(...radii) <= AWARENESS_RADIUS_M + 100); } + const frames = pulseFrames(); rendering.clearVisual(); assert.equal(entities.values.length, 0); - t.mock.timers.tick(AWARENESS_RANGE_PULSE.frameIntervalMs * 10); - assert.equal(pulseFrames(), 10, 'no frames once the ring is gone'); + assert.equal(ring.scene.postRender.numberOfListeners, 0); + loop.advance(CYBER_SONAR_RENDER_INTERVAL_MS * 10); + assert.equal(pulseFrames(), frames, 'no frames once the ring is gone'); +}); + +test('a parked view stops asking for frames once the pulses rest', (t) => { + const ring = ringRendering(t); + const { entities, scene, rendering, pulseFrames } = ring; + const loop = frameLoop(t, ring); + const { activeMs, count, periodMs } = AWARENESS_RANGE_PULSE; + // Long enough for three ring flashes, then quiet. + assert.ok(activeMs >= 3 * (periodMs / count)); + rendering.renderVisual(flight(3)); + loop.advance(activeMs + 500); + const frames = pulseFrames(); + assert.ok(frames >= activeMs / CYBER_SONAR_RENDER_INTERVAL_MS - 2); + // Parked: a minute later not one more request. + loop.advance(60_000); + assert.equal(pulseFrames(), frames, 'render requests stop after the bound'); + // At rest the ring stands on screen at full, as without pulses, and the + // pulses, gradient and echoes are hidden. + const now = Cesium.JulianDate.now(); + const core = Cesium.Color.fromCssColorString( + AWARENESS_RANGE_RING_STYLE.color, + ); + const [ringEntity, ...pulses] = entities.values; + assert.ok( + Math.abs( + ringEntity.polyline.material.color.getValue(now).alpha - core.alpha, + ) < 1e-9, + ); + for (const pulse of pulses) + assert.equal(pulse.polyline.show.getValue(now), false); + assert.equal(pulsePrimitives(scene).disc.show, false); + assert.equal(getAwarenessEchoField().level(loop.now()), 0); +}); + +test('a camera move wakes resting pulses for another bounded run', (t) => { + const ring = ringRendering(t); + const { rendering, pulseFrames } = ring; + const loop = frameLoop(t, ring); + const { activeMs } = AWARENESS_RANGE_PULSE; + rendering.renderVisual(flight(3)); + loop.advance(activeMs + 500); + const rested = pulseFrames(); + // The camera moves: lifecycle wakes the pulses inside the frame it draws, + // and that frame arms the next request. Waking asks for none of its own. + rendering.wakePulses(); + assert.equal(pulseFrames(), rested); + loop.draw(); + loop.advance(activeMs + 500); + const woke = pulseFrames() - rested; + assert.ok(woke >= activeMs / CYBER_SONAR_RENDER_INTERVAL_MS - 2, `${woke}`); + loop.advance(60_000); + assert.equal(pulseFrames(), rested + woke, 'and rest again'); + // The subject moving does not wake them: a flying subject moves on every + // refresh, which would bring the endless repaint back. + rendering.renderVisual(flight(3.01)); + loop.advance(10_000); + assert.equal(pulseFrames(), rested + woke); +}); + +test('a camera move seen by the lifecycle wakes resting pulses; a parked camera does not', (t) => { + const ring = ringRendering(t); + const { state, rendering, pulseFrames } = ring; + const loop = frameLoop(t, ring); + const { activeMs } = AWARENESS_RANGE_PULSE; + state.enabled = 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(activeMs + 500); + const rested = pulseFrames(); + // Parked: frames other owners draw do not wake the pulses. + loop.draw(); + loop.advance(activeMs); + assert.equal(pulseFrames(), rested); + // The camera pans 50 km: the next frame wakes them for another run. + state.viewer.camera.positionWC = Cesium.Cartesian3.fromDegrees( + 3.5, + 20, + 800_000, + ); + loop.draw(); + loop.advance(activeMs + 500); + assert.ok( + pulseFrames() - rested >= activeMs / CYBER_SONAR_RENDER_INTERVAL_MS - 2, + ); + const woke = pulseFrames(); + loop.advance(60_000); + assert.equal(pulseFrames(), woke, 'and rest again once it parks'); +}); + +test('pulse requests wait 80 ms after any frame, so they cannot beat the Cyber sonar', (t) => { + const ring = ringRendering(t); + const { rendering, pulseFrames } = ring; + const loop = frameLoop(t, ring); + rendering.renderVisual(flight(3)); + loop.advance(1); + const frames = pulseFrames(); + // Another owner (the Cyber sonar, a data update) draws 67 ms after ours. + loop.advance(67); + loop.draw(); + loop.advance(CYBER_SONAR_RENDER_INTERVAL_MS - 1); + assert.equal(pulseFrames(), frames, 'no request 13 ms after that frame'); + loop.advance(1); + assert.equal(pulseFrames(), frames + 1, 'one request 80 ms after it'); +}); + +test('the pulse level fades in, holds, fades out and keeps its level when woken', () => { + const { activeMs, settleMs } = AWARENESS_RANGE_PULSE; + const wake = wakeAwarenessPulses( + { startMs: -Infinity, untilMs: -Infinity }, + 0, + ); + assert.equal(awarenessPulseLevel(0, wake), 0); + assert.ok(Math.abs(awarenessPulseLevel(settleMs / 2, wake) - 0.5) < 1e-9); + assert.equal(awarenessPulseLevel(activeMs / 2, wake), 1); + assert.ok( + Math.abs(awarenessPulseLevel(activeMs - settleMs / 4, wake) - 0.25) < 1e-9, + ); + assert.equal(awarenessPulseLevel(activeMs, wake), 0); + assert.equal(awarenessPulseLevel(activeMs * 10, wake), 0); + // Woken mid-fade-out: no jump back to full, then a fresh run. + const at = activeMs - settleMs / 4; + wakeAwarenessPulses(wake, at); + assert.ok(Math.abs(awarenessPulseLevel(at, wake) - 0.25) < 1e-9); + assert.equal(awarenessPulseLevel(at + settleMs, wake), 1); + assert.equal(awarenessPulseLevel(at + activeMs, wake), 0); }); test('a scene without primitives or preRender gets the static ring only', (t) => { const { state, entities, rendering, pulseFrames } = ringRendering(t); - for (const scene of [{}, { primitives: { add() {} } }, undefined]) { + for (const scene of [ + {}, + { primitives: { add() {} } }, + { primitives: { add() {} }, preRender: new Cesium.Event() }, + undefined, + ]) { state.viewer = { entities, scene }; rendering.clearVisual(); rendering.renderVisual(flight(3)); @@ -336,7 +551,9 @@ test('a scene without primitives or preRender gets the static ring only', (t) => }); test('a gradient disc behind the pulses fades inward and follows the subject', (t) => { - const { scene, rendering } = ringRendering(t); + const ring = ringRendering(t); + const { scene, rendering } = ring; + const loop = frameLoop(t, ring); rendering.renderVisual(flight(3)); assert.ok(AWARENESS_RANGE_PULSE.count <= AWARENESS_PULSE_GRADIENT_MAX); const { disc, count } = pulsePrimitives(scene); @@ -357,26 +574,20 @@ test('a gradient disc behind the pulses fades inward and follows the subject', ( ) < 1e-6, ); // Each frame writes the pulses' radius (as a fraction of the range) and - // opacity into the shader, from the same curve the lines use. + // opacity into the shader, from the same curve the lines use, once the + // pulses have faded in. assert.equal(scene.preRender.numberOfListeners, 1); - const before = performance.now(); + loop.advance(AWARENESS_RANGE_PULSE.settleMs + 500); scene.preRender.raiseEvent(scene); - const after = performance.now(); + assert.equal(disc.show, true); const { radii, alphas } = material.uniforms; for (let index = 0; index < AWARENESS_RANGE_PULSE.count; index++) { const radius = Cesium.Cartesian4.pack(radii, [])[index]; const alpha = Cesium.Cartesian4.pack(alphas, [])[index]; - const low = awarenessPulseState(before, index); - const high = awarenessPulseState(after, index); - // A few milliseconds pass during the raise; allow for that drift. - assert.ok( - Math.abs(radius - low.radiusM / AWARENESS_RADIUS_M) < 0.01 || - Math.abs(radius - high.radiusM / AWARENESS_RADIUS_M) < 0.01, - ); + const expected = awarenessPulseState(loop.now(), index); + assert.ok(Math.abs(radius - expected.radiusM / AWARENESS_RADIUS_M) < 1e-6); assert.ok(alpha >= 0 && alpha <= AWARENESS_RANGE_PULSE.peakOpacity); - assert.ok( - Math.abs(alpha - low.alpha) < 0.02 || Math.abs(alpha - high.alpha) < 0.02, - ); + assert.ok(Math.abs(alpha - expected.alpha) < 1e-6); } // Unused shader slots stay empty. assert.equal( @@ -425,7 +636,7 @@ test('pulses evaluate every frame without breaking, reusing their positions', (t }); test('reduced motion keeps the static ring and drops the pulses', (t) => { - t.mock.timers.enable({ apis: ['setInterval'] }); + t.mock.timers.enable({ apis: ['setTimeout', 'setInterval'] }); const { entities, scene, rendering, pulseFrames } = ringRendering(t, { reducedMotion: true, }); @@ -436,16 +647,58 @@ test('reduced motion keeps the static ring and drops the pulses', (t) => { assert.equal(pulseFrames(), 0); assert.equal(scene.primitives.length, 0); assert.equal(scene.preRender.numberOfListeners, 0); + assert.equal(scene.postRender.numberOfListeners, 0); +}); + +test('turning reduced motion on mid-session stops the pulses, and off restarts them', (t) => { + const ring = ringRendering(t); + const { entities, scene, rendering, motion, state } = ring; + const loop = frameLoop(t, ring); + state.subject = flight(3); + rendering.renderVisual(state.subject); + assert.equal(entities.values.length, 1 + AWARENESS_RANGE_PULSE.count); + loop.advance(1000); + // The OS setting changes while the same aircraft stays tracked. + motion.set(true); + assert.equal(entities.values.length, 1); + assert.equal(entities.values[0].polyline.material.color.isConstant, true); + assert.equal(scene.primitives.length, 0); + assert.equal(scene.preRender.numberOfListeners, 0); + assert.equal(scene.postRender.numberOfListeners, 0); + assert.equal(getAwarenessEchoField(), null); + const frames = ring.pulseFrames(); + loop.advance(5000); + assert.equal(ring.pulseFrames(), frames); + motion.set(false); + assert.equal(entities.values.length, 1 + AWARENESS_RANGE_PULSE.count); + assert.ok(getAwarenessEchoField()); + // One media-query listener per visual; none once the ring is gone. + assert.equal(motion.listeners(), 1); + rendering.clearVisual(); + assert.equal(motion.listeners(), 0); +}); + +test('a refresh after reduced motion changed rebuilds the visual too', (t) => { + const { entities, rendering, motion } = ringRendering(t); + rendering.renderVisual(flight(3)); + assert.equal(entities.values.length, 1 + AWARENESS_RANGE_PULSE.count); + // No change event: the next same-subject refresh notices on its own. + motion.setQuietly(true); + rendering.renderVisual(flight(3)); + assert.equal(entities.values.length, 1); + assert.equal(entities.values[0].polyline.positions.isConstant, true); }); test('while pulses run the ring flashes as each one arrives, then rests', (t) => { - const { entities, rendering } = ringRendering(t); + const ring = ringRendering(t); + const { entities, rendering } = ring; + const loop = frameLoop(t, ring); rendering.renderVisual(flight(3)); - const ring = entities.values[0]; + const ringEntity = entities.values[0]; const core = Cesium.Color.fromCssColorString( AWARENESS_RANGE_RING_STYLE.color, ); - assert.equal(ring.polyline.material.color.isConstant, false); + assert.equal(ringEntity.polyline.material.color.isConstant, false); const { periodMs, count } = AWARENESS_RANGE_PULSE; const { flashMs, restOpacity } = AWARENESS_RANGE_RING_STYLE; // Full the moment a front reaches 250 km, gone `flashMs` later. @@ -453,7 +706,8 @@ test('while pulses run the ring flashes as each one arrives, then rests', (t) => assert.ok(Math.abs(awarenessRingFlash(arrival) - 1) < 1e-6); assert.ok(awarenessRingFlash(arrival + flashMs / 2) > 0.2); assert.equal(awarenessRingFlash(arrival + flashMs + 1), 0); - assert.equal(restOpacity, 0); + // Between flashes the 250 km edge stays readable, dimmed, not gone. + assert.ok(restOpacity >= 0.35 && restOpacity <= 0.5, `${restOpacity}`); // Once per arriving pulse: `count` flashes in any one-period window that // starts after a flash has faded. let flashes = 0; @@ -465,16 +719,18 @@ test('while pulses run the ring flashes as each one arrives, then rests', (t) => wasLit = lit; } assert.equal(flashes, count); - // The live colour tracks the flash at this instant. - const before = awarenessRingFlash(performance.now()); - const alpha = ring.polyline.material.color.getValue( - Cesium.JulianDate.now(), - ).alpha; - const after = awarenessRingFlash(performance.now()); - assert.ok( - alpha >= core.alpha * Math.min(before, after) - 0.02 && - alpha <= core.alpha * Math.max(before, after) + 0.02, - ); + // Once the pulses have faded in, the live colour tracks the flash, and + // never drops below `restOpacity`. + loop.advance(AWARENESS_RANGE_PULSE.settleMs); + for (let step = 0; step < 40; step++) { + loop.advance(100); + const alpha = ringEntity.polyline.material.color.getValue( + Cesium.JulianDate.now(), + ).alpha; + const expected = + core.alpha * Math.max(restOpacity, awarenessRingFlash(loop.now())); + assert.ok(Math.abs(alpha - expected) < 1e-9, `${alpha} vs ${expected}`); + } }); test('without pulses the ring stays on screen at full strength', (t) => { diff --git a/src/layers/awareness/rendering.js b/src/layers/awareness/rendering.js index ca22bacbd..b8197f60b 100644 --- a/src/layers/awareness/rendering.js +++ b/src/layers/awareness/rendering.js @@ -1,6 +1,7 @@ import * as Cesium from 'cesium'; import { t } from '../../i18n/index.js'; import { + CYBER_SONAR_RENDER_INTERVAL_MS, createCyberSonarSampler, isCyberContactSonarActive as isCyberSonarActive, } from '../../cyberSonar.js'; @@ -112,12 +113,51 @@ export function awarenessPulseState( }; } +const REDUCED_MOTION_QUERY = '(prefers-reduced-motion: reduce)'; + /** Whether the viewer asked the system for less motion. */ function prefersReducedMotion() { - return ( - globalThis.matchMedia?.('(prefers-reduced-motion: reduce)')?.matches ?? - false + return globalThis.matchMedia?.(REDUCED_MOTION_QUERY)?.matches ?? false; +} + +/** + * How far the pulses are switched on at `nowMs`: 0 at rest, rising to 1 over + * `settleMs` from `wake.startMs` and falling back to 0 over `settleMs` up to + * `wake.untilMs`. + * @param {number} nowMs Monotonic clock in milliseconds. + * @param {{startMs: number, untilMs: number}} wake Window from `wakeAwarenessPulses`. + * @param {object} [pulse] Timing, see `AWARENESS_RANGE_PULSE`. + * @returns {number} 0 to 1. + */ +export function awarenessPulseLevel( + nowMs, + wake, + pulse = AWARENESS_RANGE_PULSE, +) { + const level = Math.min( + (nowMs - wake.startMs) / pulse.settleMs, + (wake.untilMs - nowMs) / pulse.settleMs, ); + return Math.max(0, Math.min(1, level)); +} + +/** + * Run the pulses for `activeMs` from `nowMs`. A wake while they already run + * keeps their current level, so they never jump back to full mid-fade. + * @param {{startMs: number, untilMs: number}} wake Updated in place. + * @param {number} nowMs Monotonic clock in milliseconds. + * @param {object} [pulse] Timing, see `AWARENESS_RANGE_PULSE`. + * @returns {{startMs: number, untilMs: number}} `wake`. + */ +export function wakeAwarenessPulses( + wake, + nowMs, + pulse = AWARENESS_RANGE_PULSE, +) { + wake.startMs = + nowMs - awarenessPulseLevel(nowMs, wake, pulse) * pulse.settleMs; + wake.untilMs = nowMs + pulse.activeMs; + return wake; } /** @@ -457,9 +497,19 @@ export function createRendering({ presentDirection({ hidden: false, ring, heading, labels, markers }); } + /** + * Run the range pulses again for `activeMs`, after a camera move. Call it + * during a frame (lifecycle's preRender): that frame's postRender arms the + * next request, so waking asks for no extra frame of its own. + */ + function wakePulses() { + const wake = layerState.visual?.wake; + if (wake) wakeAwarenessPulses(wake, performance.now()); + } + function clearVisual() { - globalThis.clearInterval(layerState.visual?.pulseTimer); - layerState.visual?.removePulseListener?.(); + layerState.visual?.stopPulses?.(); + layerState.visual?.removeMotionListener?.(); if (layerState.visual?.gradient && layerState.viewer) layerState.viewer.scene.primitives.remove(layerState.visual.gradient); clearAwarenessEchoField(); @@ -482,7 +532,12 @@ export function createRendering({ 0, ); const key = `${subject.layerId}:${subject.id}`; - if (layerState.visual?.key === key) { + const reducedMotion = prefersReducedMotion(); + // A reduced-motion change rebuilds the visual, with or without pulses. + if ( + layerState.visual?.key === key && + layerState.visual.reducedMotion === reducedMotion + ) { // Move the ring only after a meaningful displacement so live tracking // does not rebuild its geometry for sub-pixel motion every 750 ms. if ( @@ -527,10 +582,19 @@ export function createRendering({ // tools, partial stubs) gets the static ring only, as the Cyber sonar // scene does. So does a viewer who prefers reduced motion. const animate = Boolean( - !prefersReducedMotion() && + !reducedMotion && scene?.primitives?.add && - scene?.preRender?.addEventListener, + scene?.preRender?.addEventListener && + scene?.postRender?.addEventListener, ); + // Pulses run from now until `wake.untilMs`, then rest. + const wake = animate + ? wakeAwarenessPulses( + { startMs: -Infinity, untilMs: -Infinity }, + performance.now(), + ) + : null; + const levelAt = (nowMs) => (wake ? awarenessPulseLevel(nowMs, wake) : 0); // A polyline, not an ellipse outline: WebGL draws ellipse outlines at // 1 px whatever `outlineWidth` says, and the ring needs a halo. @@ -538,12 +602,13 @@ export function createRendering({ const core = Cesium.Color.fromCssColorString(ring.color); const halo = Cesium.Color.fromCssColorString(ring.outlineColor); // While pulses run, the ring flashes as each one arrives and otherwise - // rests at `restOpacity`; without pulses it stays on screen. + // dims to `restOpacity`; as they settle it returns to full and stays. const flashing = (base) => { const color = base.clone(); return new Cesium.CallbackProperty(() => { - const flash = awarenessRingFlash(performance.now()); - color.alpha = base.alpha * Math.max(ring.restOpacity, flash); + const nowMs = performance.now(); + const pulsed = Math.max(ring.restOpacity, awarenessRingFlash(nowMs)); + color.alpha = base.alpha * (1 - levelAt(nowMs) * (1 - pulsed)); return color; }, false); }; @@ -569,18 +634,26 @@ export function createRendering({ const center = Cesium.Cartesian3.clone(groundCenter); let gradient = null; - let removePulseListener = null; - let pulseTimer = null; + let stopPulses = null; if (animate) { const pulse = AWARENESS_RANGE_PULSE; for (let index = 0; index < pulse.count; index++) { const positions = []; const coreColor = core.clone(); const haloColor = halo.clone(); - const stateAt = () => awarenessPulseState(performance.now(), index); + const stateAt = () => { + const nowMs = performance.now(); + const state = awarenessPulseState(nowMs, index); + state.alpha *= levelAt(nowMs); + return state; + }; entities.push( layerState.viewer.entities.add({ polyline: { + show: new Cesium.CallbackProperty( + () => levelAt(performance.now()) > 0, + false, + ), positions: new Cesium.CallbackProperty( () => awarenessRangeRingPositions( @@ -626,16 +699,40 @@ export function createRendering({ ); const radii = [0, 0, 0, 0]; const alphas = [0, 0, 0, 0]; - removePulseListener = scene.preRender.addEventListener(() => { + const removePreRender = scene.preRender.addEventListener(() => { const nowMs = performance.now(); + const level = levelAt(nowMs); + // The disc is looked up each frame: a subject move swaps it. + const disc = layerState.visual?.gradient; + if (disc) disc.show = level > 0; for (let index = 0; index < count; index++) { const state = awarenessPulseState(nowMs, index); radii[index] = state.radiusM / AWARENESS_RADIUS_M; - alphas[index] = state.alpha; + alphas[index] = state.alpha * level; } Cesium.Cartesian4.fromArray(radii, 0, material.uniforms.radii); Cesium.Cartesian4.fromArray(alphas, 0, material.uniforms.alphas); }); + // Each drawn frame, whoever asked for it, arms one request a Cyber + // sonar interval later, as the Cyber sonar scene does. Requests never + // land within 80 ms of a frame, so they cannot beat the detection + // lane's 80 ms gate, and they stop once the pulses rest. + let timer = null; + const removePostRender = scene.postRender.addEventListener(() => { + globalThis.clearTimeout(timer); + timer = null; + if (performance.now() < wake.untilMs) + timer = globalThis.setTimeout(() => { + timer = null; + governorRequestRender('awareness-pulse'); + }, CYBER_SONAR_RENDER_INTERVAL_MS); + }); + stopPulses = () => { + removePreRender(); + removePostRender(); + globalThis.clearTimeout(timer); + timer = null; + }; // The detection overlay pulses the brackets of flights and vessels a // front crosses; it reads the centre in place, so it follows too. setAwarenessEchoField({ @@ -643,13 +740,17 @@ export function createRendering({ radiusM: AWARENESS_RADIUS_M, pulse, echo: AWARENESS_RANGE_ECHO, + level: levelAt, }); - pulseTimer = globalThis.setInterval( - () => governorRequestRender('awareness-pulse'), - pulse.frameIntervalMs, - ); } + // Turning reduced motion on or off mid-session rebuilds the visual. + const motionQuery = globalThis.matchMedia?.(REDUCED_MOTION_QUERY); + const onMotionChange = () => { + if (layerState.subject) renderVisual(layerState.subject); + }; + motionQuery?.addEventListener?.('change', onMotionChange); + layerState.visual = { key, entities, @@ -657,8 +758,11 @@ export function createRendering({ center, gradient, gradientCenter: Cesium.Cartesian3.clone(groundCenter), - removePulseListener, - pulseTimer, + reducedMotion, + wake, + stopPulses, + removeMotionListener: () => + motionQuery?.removeEventListener?.('change', onMotionChange), }; governorRequestRender('awareness-visual'); } @@ -672,5 +776,6 @@ export function createRendering({ updateDirectionOverlay, clearVisual, renderVisual, + wakePulses, }; }