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
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).
+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`.
- 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.
+13 -6
View File
@@ -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,
)
);
}
+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(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) {
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,
+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
* 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
+300 -44
View File
@@ -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) => {
+127 -22
View File
@@ -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,
};
}