fix(awareness): bound the range ring pulses so a parked view goes quiet

Review follow-up for the sonar pulses.

Bounded repaint: the pulses used to ask for a frame every 80 ms from a
setInterval for as long as a subject was shown, so a parked view
repainted at 12.5 fps indefinitely. They now 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 stands on screen at full strength, the pulse
lines and gradient disc are hidden, echoes stop, and nothing asks for
frames. A moving subject does not wake them, since a flying subject
moves on every refresh. The lifecycle's existing camera pose signature
wakes them.

No beat with the Cyber sonar: the setInterval is replaced by a
postRender-armed setTimeout on CYBER_SONAR_RENDER_INTERVAL_MS, as in
cyberSonarScene. Every drawn frame, whoever asked for it, re-arms the
request 80 ms later, so requests never land inside the detection
lane's 80 ms gate.

Reduced motion mid-session: a change listener on the media query
rebuilds the visual, and the same-subject refresh path rebuilds when
the preference no longer matches. The listener is removed in
clearVisual.

Readable edge: restOpacity goes from 0 to 0.4, so the 250 km ring stays
visible between flashes, in screenshots and for low-vision viewers.

The echo field takes an optional level so echoes rest with the pulses.
CURRENT-STATE and the changelog fragment describe the bounded run and
the dimmed ring.

Tests: render requests stop after the bound and stay stopped for a
minute; a camera move seen by the lifecycle wakes them for another
bounded run; a foreign frame postpones the next request to 80 ms after
it; reduced motion flipped by event and by a later refresh; the ring
never drops below restOpacity; echoes scale with the level; the level
fades in, holds, fades out and keeps its value when woken.
This commit is contained in:
Manjunath committed 2026-11-03 19:17:03 +05:30
1 parent 20770e52a9
commit 0c025987ef
8 files changed
+491 -86

No files matched your search

+5 -3
View File
@@ -1,6 +1,8 @@
- The Global Context range ring pulses like a sonar: three dim rings with a - 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 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 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 get a brief echo around their brackets. The pulses run for about 9 s after
Contacts view repaints about 12 times a second. Under reduced motion there a selection or camera move, dimming the ring between flashes, then rest
are no pulses and the ring stays on screen (manjunath22466, #1519). 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).
+1 -1
View File
@@ -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`. - 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. - 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. - 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. - 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. - 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. - 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.
+13 -6
View File
@@ -15,6 +15,8 @@ import * as Cesium from 'cesium';
* @property {number} radiusM Range the pulses grow to. * @property {number} radiusM Range the pulses grow to.
* @property {{count: number, periodMs: number, startFraction: number}} pulse * @property {{count: number, periodMs: number, startFraction: number}} pulse
* @property {{fadeMs: number, layerIds: string[]}} echo * @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} */ /** @type {AwarenessEchoField|null} */
@@ -94,11 +96,16 @@ export function awarenessEchoAt(layerId, position, nowMs) {
groundScratch, groundScratch,
); );
if (!ground) return 0; if (!ground) return 0;
return awarenessEchoStrength( const level = field.level?.(nowMs) ?? 1;
nowMs, if (!(level > 0)) return 0;
Cesium.Cartesian3.distance(field.center, ground), return (
field.radiusM, level *
field.pulse, awarenessEchoStrength(
field.echo, nowMs,
Cesium.Cartesian3.distance(field.center, ground),
field.radiusM,
field.pulse,
field.echo,
)
); );
} }
+25
View File
@@ -82,3 +82,28 @@ test('awarenessEchoAt reads the published field, by layer, at ground level', (t)
assert.equal(getAwarenessEchoField(), null); assert.equal(getAwarenessEchoField(), null);
assert.equal(awarenessEchoAt('flights', contact, t0), 0); 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);
});
+2
View File
@@ -62,6 +62,8 @@ export function createLifecycle({
if (poseSig !== layerState.lastCameraPoseSig) { if (poseSig !== layerState.lastCameraPoseSig) {
layerState.lastCameraPoseSig = poseSig; layerState.lastCameraPoseSig = poseSig;
layerState.lastCameraPoseChangeMs = now; layerState.lastCameraPoseChangeMs = now;
// A camera move runs the range pulses again; parked, they rest.
parts.rendering.wakePulses();
} }
const decision = parts.model.awarenessRefreshDecision({ const decision = parts.model.awarenessRefreshDecision({
nowMs: now, nowMs: now,
+18 -10
View File
@@ -50,9 +50,11 @@ export const CONTEXT_RIM_HEIGHT_M = 2500;
* luminance contrast on light and dark ground alike, and luminance is what * 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 * 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. * 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 * While the sonar pulses run, the ring dims to `restOpacity` and flashes
* to full as each pulse reaches it, fading over `flashMs`. Without pulses * to full as each pulse reaches it, fading over `flashMs`. `restOpacity`
* (reduced motion, or a scene that cannot animate) it stays on screen. */ * 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({ export const AWARENESS_RANGE_RING_STYLE = Object.freeze({
color: 'rgba(98, 181, 255, 0.95)', color: 'rgba(98, 181, 255, 0.95)',
@@ -61,7 +63,7 @@ export const AWARENESS_RANGE_RING_STYLE = Object.freeze({
outlineWidth: 1, outlineWidth: 1,
segments: 128, segments: 128,
flashMs: 1800, flashMs: 1800,
restOpacity: 0, restOpacity: 0.4,
}); });
/** @constant Sonar-style pulses that grow slowly from the subject to the /** @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 * `gradientOpacity` strong. The gradient disc is rebuilt only after the
* subject moves `gradientRebuildM`, since a soft band 2 km off centre on a * 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. * 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 * The pulses run for `activeMs` (three ring flashes) after a selection or
* not repaint every frame. They ask for a frame every `frameIntervalMs` * a camera move, fading in and out over `settleMs`, then rest: the ring
* instead, the cadence the Cyber sonar uses for parked scenes; when * stays on screen, static, and nothing asks for frames, so a parked view
* something else already renders continuously they update every frame. * goes quiet again. A moving subject does not wake them, since a flying
* Skipped under prefers-reduced-motion. */ * 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({ export const AWARENESS_RANGE_PULSE = Object.freeze({
count: 3, count: 3,
@@ -96,7 +103,8 @@ export const AWARENESS_RANGE_PULSE = Object.freeze({
gradientOpacity: 0.22, gradientOpacity: 0.22,
gradientGranularityDeg: 0.25, gradientGranularityDeg: 0.25,
gradientRebuildM: 2000, gradientRebuildM: 2000,
frameIntervalMs: 80, activeMs: 9000,
settleMs: 1200,
}); });
/** @constant Sonar echoes on contact brackets. When a pulse front reaches a /** @constant Sonar echoes on contact brackets. When a pulse front reaches a
+300 -44
View File
@@ -16,14 +16,18 @@ import {
AWARENESS_RANGE_RING_STYLE, AWARENESS_RANGE_RING_STYLE,
CONTEXT_RIM_HEIGHT_M, CONTEXT_RIM_HEIGHT_M,
} from './policy.js'; } from './policy.js';
import { CYBER_SONAR_RENDER_INTERVAL_MS } from '../../cyberSonar.js';
import { import {
AWARENESS_PULSE_GRADIENT_MAX, AWARENESS_PULSE_GRADIENT_MAX,
awarenessPulseLevel,
awarenessPulseState, awarenessPulseState,
awarenessRangeRingPositions, awarenessRangeRingPositions,
awarenessRingFlash, awarenessRingFlash,
createRendering, createRendering,
wakeAwarenessPulses,
} from './rendering.js'; } from './rendering.js';
import { getAwarenessEchoField } from '../../awarenessEcho.js'; import { getAwarenessEchoField } from '../../awarenessEcho.js';
import { createLifecycle } from './lifecycle.js';
import { createState } from './state.js'; import { createState } from './state.js';
/** WCAG relative luminance of a Cesium colour, ignoring alpha. */ /** 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 * The rendering part over a bare entity collection, counting render asks and
* by reason. `reducedMotion` stubs the media query for * by reason. `reducedMotion` stubs the media query for the length of the
* the length of the test. * test; `motion.set(on)` flips it later and tells its listeners, as the
* browser does.
*/ */
function ringRendering(t, { reducedMotion = false } = {}) { function ringRendering(t, { reducedMotion = false } = {}) {
const previous = globalThis.matchMedia; const previous = globalThis.matchMedia;
globalThis.matchMedia = (query) => ({ const listeners = new Set();
matches: query === '(prefers-reduced-motion: reduce)' && reducedMotion, 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(() => { t.after(() => {
globalThis.matchMedia = previous; 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 state = createState({ services: {} });
const entities = new Cesium.EntityCollection(); const entities = new Cesium.EntityCollection();
const scene = { const scene = {
primitives: new Cesium.PrimitiveCollection(), primitives: new Cesium.PrimitiveCollection(),
preRender: new Cesium.Event(), preRender: new Cesium.Event(),
postRender: new Cesium.Event(),
}; };
state.viewer = { entities, scene }; state.viewer = { entities, scene };
const reasons = []; const reasons = [];
@@ -77,11 +107,42 @@ function ringRendering(t, { reducedMotion = false } = {}) {
entities, entities,
scene, scene,
rendering, rendering,
motion,
reasons,
renders: () => reasons.filter((r) => r === 'awareness-visual').length, renders: () => reasons.filter((r) => r === 'awareness-visual').length,
pulseFrames: () => reasons.filter((r) => r === 'awareness-pulse').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. */ /** The pulse gradient disc and the echo collection among scene primitives. */
function pulsePrimitives(scene) { function pulsePrimitives(scene) {
const all = Array.from({ length: scene.primitives.length }, (_, i) => 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) => { test('pulses ask for a frame 80 ms after each drawn frame instead of holding continuous rendering', (t) => {
t.mock.timers.enable({ apis: ['setInterval'] }); const ring = ringRendering(t);
const { entities, rendering, pulseFrames } = ringRendering(t); const { entities, rendering, pulseFrames } = ring;
const loop = frameLoop(t, ring);
rendering.renderVisual(flight(3)); rendering.renderVisual(flight(3));
// A parked Contacts view repaints at the Cyber sonar cadence, ~12.5 fps, // A Contacts view repaints at the Cyber sonar cadence, ~12.5 fps, while
// never every display frame. // the pulses run, never every display frame.
assert.equal(pulseFrames(), 0); assert.equal(pulseFrames(), 0);
t.mock.timers.tick(AWARENESS_RANGE_PULSE.frameIntervalMs * 10); loop.advance(1); // the frame the new ring asked for arms the first request
assert.equal(pulseFrames(), 10); loop.advance(CYBER_SONAR_RENDER_INTERVAL_MS * 10);
assert.ok(Math.abs(pulseFrames() - 10) <= 1, `${pulseFrames()} frames`);
const now = Cesium.JulianDate.now(); const now = Cesium.JulianDate.now();
const core = Cesium.Color.fromCssColorString( const core = Cesium.Color.fromCssColorString(
AWARENESS_RANGE_RING_STYLE.color, 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); assert.ok(Math.max(...radii) <= AWARENESS_RADIUS_M + 100);
} }
const frames = pulseFrames();
rendering.clearVisual(); rendering.clearVisual();
assert.equal(entities.values.length, 0); assert.equal(entities.values.length, 0);
t.mock.timers.tick(AWARENESS_RANGE_PULSE.frameIntervalMs * 10); assert.equal(ring.scene.postRender.numberOfListeners, 0);
assert.equal(pulseFrames(), 10, 'no frames once the ring is gone'); 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) => { test('a scene without primitives or preRender gets the static ring only', (t) => {
const { state, entities, rendering, pulseFrames } = ringRendering(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 }; state.viewer = { entities, scene };
rendering.clearVisual(); rendering.clearVisual();
rendering.renderVisual(flight(3)); 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) => { 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)); rendering.renderVisual(flight(3));
assert.ok(AWARENESS_RANGE_PULSE.count <= AWARENESS_PULSE_GRADIENT_MAX); assert.ok(AWARENESS_RANGE_PULSE.count <= AWARENESS_PULSE_GRADIENT_MAX);
const { disc, count } = pulsePrimitives(scene); const { disc, count } = pulsePrimitives(scene);
@@ -357,26 +574,20 @@ test('a gradient disc behind the pulses fades inward and follows the subject', (
) < 1e-6, ) < 1e-6,
); );
// Each frame writes the pulses' radius (as a fraction of the range) and // 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); assert.equal(scene.preRender.numberOfListeners, 1);
const before = performance.now(); loop.advance(AWARENESS_RANGE_PULSE.settleMs + 500);
scene.preRender.raiseEvent(scene); scene.preRender.raiseEvent(scene);
const after = performance.now(); assert.equal(disc.show, true);
const { radii, alphas } = material.uniforms; const { radii, alphas } = material.uniforms;
for (let index = 0; index < AWARENESS_RANGE_PULSE.count; index++) { for (let index = 0; index < AWARENESS_RANGE_PULSE.count; index++) {
const radius = Cesium.Cartesian4.pack(radii, [])[index]; const radius = Cesium.Cartesian4.pack(radii, [])[index];
const alpha = Cesium.Cartesian4.pack(alphas, [])[index]; const alpha = Cesium.Cartesian4.pack(alphas, [])[index];
const low = awarenessPulseState(before, index); const expected = awarenessPulseState(loop.now(), index);
const high = awarenessPulseState(after, index); assert.ok(Math.abs(radius - expected.radiusM / AWARENESS_RADIUS_M) < 1e-6);
// 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,
);
assert.ok(alpha >= 0 && alpha <= AWARENESS_RANGE_PULSE.peakOpacity); assert.ok(alpha >= 0 && alpha <= AWARENESS_RANGE_PULSE.peakOpacity);
assert.ok( assert.ok(Math.abs(alpha - expected.alpha) < 1e-6);
Math.abs(alpha - low.alpha) < 0.02 || Math.abs(alpha - high.alpha) < 0.02,
);
} }
// Unused shader slots stay empty. // Unused shader slots stay empty.
assert.equal( 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) => { 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, { const { entities, scene, rendering, pulseFrames } = ringRendering(t, {
reducedMotion: true, reducedMotion: true,
}); });
@@ -436,16 +647,58 @@ test('reduced motion keeps the static ring and drops the pulses', (t) => {
assert.equal(pulseFrames(), 0); assert.equal(pulseFrames(), 0);
assert.equal(scene.primitives.length, 0); assert.equal(scene.primitives.length, 0);
assert.equal(scene.preRender.numberOfListeners, 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) => { 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)); rendering.renderVisual(flight(3));
const ring = entities.values[0]; const ringEntity = entities.values[0];
const core = Cesium.Color.fromCssColorString( const core = Cesium.Color.fromCssColorString(
AWARENESS_RANGE_RING_STYLE.color, 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 { periodMs, count } = AWARENESS_RANGE_PULSE;
const { flashMs, restOpacity } = AWARENESS_RANGE_RING_STYLE; const { flashMs, restOpacity } = AWARENESS_RANGE_RING_STYLE;
// Full the moment a front reaches 250 km, gone `flashMs` later. // 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(Math.abs(awarenessRingFlash(arrival) - 1) < 1e-6);
assert.ok(awarenessRingFlash(arrival + flashMs / 2) > 0.2); assert.ok(awarenessRingFlash(arrival + flashMs / 2) > 0.2);
assert.equal(awarenessRingFlash(arrival + flashMs + 1), 0); 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 // Once per arriving pulse: `count` flashes in any one-period window that
// starts after a flash has faded. // starts after a flash has faded.
let flashes = 0; let flashes = 0;
@@ -465,16 +719,18 @@ test('while pulses run the ring flashes as each one arrives, then rests', (t) =>
wasLit = lit; wasLit = lit;
} }
assert.equal(flashes, count); assert.equal(flashes, count);
// The live colour tracks the flash at this instant. // Once the pulses have faded in, the live colour tracks the flash, and
const before = awarenessRingFlash(performance.now()); // never drops below `restOpacity`.
const alpha = ring.polyline.material.color.getValue( loop.advance(AWARENESS_RANGE_PULSE.settleMs);
Cesium.JulianDate.now(), for (let step = 0; step < 40; step++) {
).alpha; loop.advance(100);
const after = awarenessRingFlash(performance.now()); const alpha = ringEntity.polyline.material.color.getValue(
assert.ok( Cesium.JulianDate.now(),
alpha >= core.alpha * Math.min(before, after) - 0.02 && ).alpha;
alpha <= core.alpha * Math.max(before, after) + 0.02, 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) => { test('without pulses the ring stays on screen at full strength', (t) => {
+127 -22
View File
@@ -1,6 +1,7 @@
import * as Cesium from 'cesium'; import * as Cesium from 'cesium';
import { t } from '../../i18n/index.js'; import { t } from '../../i18n/index.js';
import { import {
CYBER_SONAR_RENDER_INTERVAL_MS,
createCyberSonarSampler, createCyberSonarSampler,
isCyberContactSonarActive as isCyberSonarActive, isCyberContactSonarActive as isCyberSonarActive,
} from '../../cyberSonar.js'; } 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. */ /** Whether the viewer asked the system for less motion. */
function prefersReducedMotion() { function prefersReducedMotion() {
return ( return globalThis.matchMedia?.(REDUCED_MOTION_QUERY)?.matches ?? false;
globalThis.matchMedia?.('(prefers-reduced-motion: reduce)')?.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 }); 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() { function clearVisual() {
globalThis.clearInterval(layerState.visual?.pulseTimer); layerState.visual?.stopPulses?.();
layerState.visual?.removePulseListener?.(); layerState.visual?.removeMotionListener?.();
if (layerState.visual?.gradient && layerState.viewer) if (layerState.visual?.gradient && layerState.viewer)
layerState.viewer.scene.primitives.remove(layerState.visual.gradient); layerState.viewer.scene.primitives.remove(layerState.visual.gradient);
clearAwarenessEchoField(); clearAwarenessEchoField();
@@ -482,7 +532,12 @@ export function createRendering({
0, 0,
); );
const key = `${subject.layerId}:${subject.id}`; 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 // Move the ring only after a meaningful displacement so live tracking
// does not rebuild its geometry for sub-pixel motion every 750 ms. // does not rebuild its geometry for sub-pixel motion every 750 ms.
if ( if (
@@ -527,10 +582,19 @@ export function createRendering({
// tools, partial stubs) gets the static ring only, as the Cyber sonar // tools, partial stubs) gets the static ring only, as the Cyber sonar
// scene does. So does a viewer who prefers reduced motion. // scene does. So does a viewer who prefers reduced motion.
const animate = Boolean( const animate = Boolean(
!prefersReducedMotion() && !reducedMotion &&
scene?.primitives?.add && 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 // A polyline, not an ellipse outline: WebGL draws ellipse outlines at
// 1 px whatever `outlineWidth` says, and the ring needs a halo. // 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 core = Cesium.Color.fromCssColorString(ring.color);
const halo = Cesium.Color.fromCssColorString(ring.outlineColor); const halo = Cesium.Color.fromCssColorString(ring.outlineColor);
// While pulses run, the ring flashes as each one arrives and otherwise // 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 flashing = (base) => {
const color = base.clone(); const color = base.clone();
return new Cesium.CallbackProperty(() => { return new Cesium.CallbackProperty(() => {
const flash = awarenessRingFlash(performance.now()); const nowMs = performance.now();
color.alpha = base.alpha * Math.max(ring.restOpacity, flash); const pulsed = Math.max(ring.restOpacity, awarenessRingFlash(nowMs));
color.alpha = base.alpha * (1 - levelAt(nowMs) * (1 - pulsed));
return color; return color;
}, false); }, false);
}; };
@@ -569,18 +634,26 @@ export function createRendering({
const center = Cesium.Cartesian3.clone(groundCenter); const center = Cesium.Cartesian3.clone(groundCenter);
let gradient = null; let gradient = null;
let removePulseListener = null; let stopPulses = null;
let pulseTimer = null;
if (animate) { if (animate) {
const pulse = AWARENESS_RANGE_PULSE; const pulse = AWARENESS_RANGE_PULSE;
for (let index = 0; index < pulse.count; index++) { for (let index = 0; index < pulse.count; index++) {
const positions = []; const positions = [];
const coreColor = core.clone(); const coreColor = core.clone();
const haloColor = halo.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( entities.push(
layerState.viewer.entities.add({ layerState.viewer.entities.add({
polyline: { polyline: {
show: new Cesium.CallbackProperty(
() => levelAt(performance.now()) > 0,
false,
),
positions: new Cesium.CallbackProperty( positions: new Cesium.CallbackProperty(
() => () =>
awarenessRangeRingPositions( awarenessRangeRingPositions(
@@ -626,16 +699,40 @@ export function createRendering({
); );
const radii = [0, 0, 0, 0]; const radii = [0, 0, 0, 0];
const alphas = [0, 0, 0, 0]; const alphas = [0, 0, 0, 0];
removePulseListener = scene.preRender.addEventListener(() => { const removePreRender = scene.preRender.addEventListener(() => {
const nowMs = performance.now(); 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++) { for (let index = 0; index < count; index++) {
const state = awarenessPulseState(nowMs, index); const state = awarenessPulseState(nowMs, index);
radii[index] = state.radiusM / AWARENESS_RADIUS_M; 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(radii, 0, material.uniforms.radii);
Cesium.Cartesian4.fromArray(alphas, 0, material.uniforms.alphas); 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 // The detection overlay pulses the brackets of flights and vessels a
// front crosses; it reads the centre in place, so it follows too. // front crosses; it reads the centre in place, so it follows too.
setAwarenessEchoField({ setAwarenessEchoField({
@@ -643,13 +740,17 @@ export function createRendering({
radiusM: AWARENESS_RADIUS_M, radiusM: AWARENESS_RADIUS_M,
pulse, pulse,
echo: AWARENESS_RANGE_ECHO, 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 = { layerState.visual = {
key, key,
entities, entities,
@@ -657,8 +758,11 @@ export function createRendering({
center, center,
gradient, gradient,
gradientCenter: Cesium.Cartesian3.clone(groundCenter), gradientCenter: Cesium.Cartesian3.clone(groundCenter),
removePulseListener, reducedMotion,
pulseTimer, wake,
stopPulses,
removeMotionListener: () =>
motionQuery?.removeEventListener?.('change', onMotionChange),
}; };
governorRequestRender('awareness-visual'); governorRequestRender('awareness-visual');
} }
@@ -672,5 +776,6 @@ export function createRendering({
updateDirectionOverlay, updateDirectionOverlay,
clearVisual, clearVisual,
renderVisual, renderVisual,
wakePulses,
}; };
} }