feat(awareness): add calm sonar pulses that flash the range ring and echo contacts

Pulses: three dim rings grow from the Global Context subject to the
250 km range ring at constant speed, one every 3 s, each taking 9 s.
Each fades in over its first 8% of travel, then fades as
(1 - travel)^1.5 to about a third of its 60% peak, so it arrives at the
ring dim but visible. They are 3 px in the ring's colours, as polylines
with CallbackProperty positions and colours that share the ring's
centre object, so they follow a moving subject.

Ring: while pulses run, the 250 km ring rests unseen and flashes to full
strength as each pulse reaches it, fading over 1.8 s. Without pulses
(reduced motion, or a scene with no primitives or preRender hook) it
stays on screen as before.

Gradient: behind each pulse a radial band in the same blue fades inward
over 35% of the radius. One flat, translucent, unpickable 250 km disc
draws it with a small Fabric material. A preRender listener writes the
pulse radii and opacities into two vec4 uniforms each frame. The disc
(0.25 degree granularity, ~3.5 ms to build) is rebuilt only after the
subject moves 2 km.

Echoes: 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, spreading outward as it fades over
1.4 s. Awareness publishes the field (centre, radius, pulse timing,
echo styling) through the new src/awarenessEcho.js. The overlay reads
it, so no layer's icon or colour is touched. Mapped installations have
no detection bracket, so they do not echo.

Rendering cost: 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 ~12.5 fps while a subject is shown. This
changes the "parked Contacts stays quiet" behaviour on purpose.
clearVisual stops the timer and removes the disc, the listener and the
echo field.

awarenessRangeRingPositions can now reuse a result array, and passes the
WGS84 ellipsoid explicitly so a reused Cartesian never lands in the
ellipsoid argument. Tuning lives in AWARENESS_RANGE_PULSE,
AWARENESS_RANGE_ECHO and AWARENESS_RANGE_RING_STYLE. src/awarenessEcho.js
is registered in the awareness-layer, application-components and
application-layer-construction packages.

Tests cover:
- the pulse curve and calm rate;
- the ring flash once per arrival, and the static ring without pulses;
- the 80 ms frame cadence and its stop;
- the partial-scene fallback;
- the gradient disc: unpickable, uniform values, rebuild and cleanup;
- per-frame evaluation, and the echo field following the subject;
- awarenessEcho strength, rim wrap, layer filter and altitude handling;
- the detection renderer's echo-bracket band and outward spread.
This commit is contained in:
Manjunath committed 2026-11-03 16:30:56 +05:30
1 parent 2a629f0987
commit e80f583e66
10 files changed
+1064 -56

No files matched your search

+385 -31
View File
@@ -1,12 +1,29 @@
// src/layers/awareness/rangeRing.test.mjs — the 250 km Global Context range
// ring is a haloed polyline that stays legible on light and dark ground,
// sits where the old ellipse outline did, and follows the subject.
// Dim sonar pulses grow slowly from the subject to the ring, each with an
// inward-fading gradient behind it; the ring flashes as each one arrives and
// the overlay's echo field follows the subject. Under reduced motion the ring
// simply stays on screen.
import '../../testSupport/registerDom.mjs';
import test from 'node:test';
import assert from 'node:assert/strict';
import * as Cesium from 'cesium';
import { AWARENESS_RADIUS_M } from '../../data/militaryAwarenessEngine.js';
import { AWARENESS_RANGE_RING_STYLE, CONTEXT_RIM_HEIGHT_M } from './policy.js';
import { awarenessRangeRingPositions, createRendering } from './rendering.js';
import {
AWARENESS_RANGE_ECHO,
AWARENESS_RANGE_PULSE,
AWARENESS_RANGE_RING_STYLE,
CONTEXT_RIM_HEIGHT_M,
} from './policy.js';
import {
AWARENESS_PULSE_GRADIENT_MAX,
awarenessPulseState,
awarenessRangeRingPositions,
awarenessRingFlash,
createRendering,
} from './rendering.js';
import { getAwarenessEchoField } from '../../awarenessEcho.js';
import { createState } from './state.js';
/** WCAG relative luminance of a Cesium colour, ignoring alpha. */
@@ -20,27 +37,68 @@ function luminance(color) {
);
}
/** The rendering part over a bare entity collection, counting render asks. */
function ringRendering() {
/**
* 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.
*/
function ringRendering(t, { reducedMotion = false } = {}) {
const previous = globalThis.matchMedia;
globalThis.matchMedia = (query) => ({
matches: query === '(prefers-reduced-motion: reduce)' && reducedMotion,
});
t.after(() => {
globalThis.matchMedia = previous;
});
const state = createState({ services: {} });
const entities = new Cesium.EntityCollection();
state.viewer = { entities };
let renders = 0;
const scene = {
primitives: new Cesium.PrimitiveCollection(),
preRender: new Cesium.Event(),
};
state.viewer = { entities, scene };
const reasons = [];
const rendering = createRendering({
state,
services: {
geometry: {},
render: {
governorRequestRender() {
renders += 1;
governorRequestRender(reason) {
reasons.push(reason);
},
},
},
parts: {},
});
return { entities, rendering, renders: () => renders };
// A shown ring keeps a pulse timer; never leave one running past a test.
t.after(() => rendering.clearVisual());
return {
state,
entities,
scene,
rendering,
renders: () => reasons.filter((r) => r === 'awareness-visual').length,
pulseFrames: () => reasons.filter((r) => r === 'awareness-pulse').length,
};
}
/** The pulse gradient disc and the echo collection among scene primitives. */
function pulsePrimitives(scene) {
const all = Array.from({ length: scene.primitives.length }, (_, i) =>
scene.primitives.get(i),
);
return {
disc: all.find((p) => p instanceof Cesium.Primitive),
count: all.length,
};
}
const flight = (lon) => ({
layerId: 'flights',
id: 'abc123',
position: Cesium.Cartesian3.fromDegrees(lon, 27, 9000),
});
/** Surface distance from `center` to each ring position. */
function surfaceDistances(center, positions) {
const from = Cesium.Cartographic.fromCartesian(center);
@@ -99,15 +157,11 @@ test('the ring core and halo contrast on light and dark ground', () => {
assert.ok(width - 2 * outlineWidth >= 2);
});
test('renderVisual draws one haloed polyline ring, not an ellipse outline', () => {
const { entities, rendering } = ringRendering();
const subject = {
layerId: 'flights',
id: 'abc123',
position: Cesium.Cartesian3.fromDegrees(3, 27, 9000),
};
rendering.renderVisual(subject);
assert.equal(entities.values.length, 1);
test('renderVisual draws one haloed polyline ring, not an ellipse outline', (t) => {
const { entities, rendering } = ringRendering(t);
rendering.renderVisual(flight(3));
assert.equal(entities.values.length, 1 + AWARENESS_RANGE_PULSE.count);
assert.ok(entities.values.every((entity) => entity.ellipse === undefined));
const [entity] = entities.values;
assert.equal(entity.ellipse, undefined);
const now = Cesium.JulianDate.now();
@@ -115,12 +169,14 @@ test('renderVisual draws one haloed polyline ring, not an ellipse outline', () =
assert.equal(entity.polyline.arcType.getValue(now), Cesium.ArcType.NONE);
const material = entity.polyline.material;
assert.ok(material instanceof Cesium.PolylineOutlineMaterialProperty);
assert.equal(
material.color.getValue(now).toCssColorString(),
Cesium.Color.fromCssColorString(
AWARENESS_RANGE_RING_STYLE.color,
).toCssColorString(),
// The ring's hue is fixed; its opacity flashes with arriving pulses.
const core = Cesium.Color.fromCssColorString(
AWARENESS_RANGE_RING_STYLE.color,
);
const color = material.color.getValue(now);
assert.equal(color.red, core.red);
assert.equal(color.blue, core.blue);
assert.ok(color.alpha >= 0 && color.alpha <= core.alpha + 1e-9);
assert.equal(
material.outlineWidth.getValue(now),
AWARENESS_RANGE_RING_STYLE.outlineWidth,
@@ -133,13 +189,9 @@ test('renderVisual draws one haloed polyline ring, not an ellipse outline', () =
assert.ok(Math.abs(distance - AWARENESS_RADIUS_M) < 100);
});
test('the ring follows the subject past 250 m and ignores smaller drift', () => {
const { entities, rendering, renders } = ringRendering();
const subject = (lon) => ({
layerId: 'flights',
id: 'abc123',
position: Cesium.Cartesian3.fromDegrees(lon, 27, 9000),
});
test('the ring and pulses follow the subject past 250 m and ignore smaller drift', (t) => {
const { entities, rendering, renders } = ringRendering(t);
const subject = flight;
rendering.renderVisual(subject(3));
const [entity] = entities.values;
const now = Cesium.JulianDate.now();
@@ -153,7 +205,7 @@ test('the ring follows the subject past 250 m and ignores smaller drift', () =>
// ~1 km east: the same entity moves to the new centre.
rendering.renderVisual(subject(3.01));
assert.equal(entities.values.length, 1);
assert.equal(entities.values.length, 1 + AWARENESS_RANGE_PULSE.count);
assert.equal(entities.values[0], entity);
const moved = entity.polyline.positions.getValue(now);
assert.notEqual(moved, first);
@@ -163,7 +215,309 @@ test('the ring follows the subject past 250 m and ignores smaller drift', () =>
))
assert.ok(Math.abs(distance - AWARENESS_RADIUS_M) < 100);
assert.equal(renders(), rendersAfterAdd + 1);
// Each pulse is a circle around the NEW centre: one radius all the way round.
for (const pulse of entities.values.slice(1)) {
const radii = surfaceDistances(
Cesium.Cartesian3.fromDegrees(3.01, 27, 0),
pulse.polyline.positions.getValue(now),
);
assert.ok(Math.max(...radii) - Math.min(...radii) < 100);
}
rendering.clearVisual();
assert.equal(entities.values.length, 0);
});
test('a pulse grows slowly from the subject and arrives dim at the ring', () => {
const {
periodMs,
count,
startFraction,
fadeInFraction,
peakOpacity,
endOpacityFraction,
} = AWARENESS_RANGE_PULSE;
const at = (phase) => awarenessPulseState(phase * periodMs, 0);
// Starts small and invisible, so nothing pops in at the centre.
assert.equal(at(0).radiusM, AWARENESS_RADIUS_M * startFraction);
assert.equal(at(0).alpha, 0);
// Dim: never brighter than `peakOpacity`, which sits well under the ring.
let brightest = 0;
for (let phase = 0; phase < 1; phase += 0.005) {
const { alpha } = at(phase);
assert.ok(alpha >= 0 && alpha <= peakOpacity, `alpha ${alpha} at ${phase}`);
brightest = Math.max(brightest, alpha);
}
assert.ok(Math.abs(brightest - at(fadeInFraction).alpha) < 0.01);
assert.ok(peakOpacity <= 0.6);
// Fading as it travels, but still visible when it reaches the ring, where
// the ring takes over with its flash.
assert.ok(at(0.5).alpha < at(0.25).alpha);
assert.ok(at(0.9).alpha < at(0.5).alpha);
assert.ok(
Math.abs(at(0.999).alpha - peakOpacity * endOpacityFraction) < 0.01,
);
assert.ok(at(0.999).alpha >= 0.15, 'the arriving pulse is still visible');
assert.ok(Math.abs(at(0.999).radiusM - AWARENESS_RADIUS_M) < 300);
// Constant speed: equal time steps add equal radius.
const step = at(0.4).radiusM - at(0.3).radiusM;
assert.ok(Math.abs(at(0.7).radiusM - at(0.6).radiusM - step) < 1e-6);
// Calm: a new pulse at most every 2.5 s, crossing 250 km in 8 s or more.
assert.ok(periodMs / count >= 2500);
assert.ok(periodMs >= 8000);
// Pulses are evenly staggered across one period, and the cycle repeats.
const near = (a, b) =>
Math.abs(a.radiusM - b.radiusM) < 1e-6 &&
Math.abs(a.alpha - b.alpha) < 1e-9;
for (let index = 1; index < count; index++)
assert.ok(
near(
awarenessPulseState(0, index),
awarenessPulseState((index / count) * periodMs, 0),
),
);
assert.ok(
near(
awarenessPulseState(periodMs * 7 + 123, 1),
awarenessPulseState(123, 1),
),
);
});
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);
rendering.renderVisual(flight(3));
// A parked Contacts view repaints at the Cyber sonar cadence, ~12.5 fps,
// never every display frame.
assert.equal(pulseFrames(), 0);
t.mock.timers.tick(AWARENESS_RANGE_PULSE.frameIntervalMs * 10);
assert.equal(pulseFrames(), 10);
const now = Cesium.JulianDate.now();
const core = Cesium.Color.fromCssColorString(
AWARENESS_RANGE_RING_STYLE.color,
);
for (const pulse of entities.values.slice(1)) {
assert.equal(pulse.polyline.positions.isConstant, false);
assert.equal(
pulse.polyline.width.getValue(now),
AWARENESS_RANGE_PULSE.width,
);
const color = pulse.polyline.material.color.getValue(now);
assert.ok(color.alpha >= 0 && color.alpha <= core.alpha);
assert.equal(color.red, core.red);
const radii = surfaceDistances(
Cesium.Cartesian3.fromDegrees(3, 27, 0),
pulse.polyline.positions.getValue(now),
);
assert.ok(
Math.min(...radii) >=
AWARENESS_RADIUS_M * AWARENESS_RANGE_PULSE.startFraction - 100,
);
assert.ok(Math.max(...radii) <= AWARENESS_RADIUS_M + 100);
}
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');
});
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]) {
state.viewer = { entities, scene };
rendering.clearVisual();
rendering.renderVisual(flight(3));
assert.equal(entities.values.length, 1);
assert.equal(entities.values[0].polyline.positions.isConstant, true);
}
rendering.clearVisual();
assert.equal(pulseFrames(), 0);
});
test('a gradient disc behind the pulses fades inward and follows the subject', (t) => {
const { scene, rendering } = ringRendering(t);
rendering.renderVisual(flight(3));
assert.ok(AWARENESS_RANGE_PULSE.count <= AWARENESS_PULSE_GRADIENT_MAX);
const { disc, count } = pulsePrimitives(scene);
assert.equal(count, 1, 'the gradient disc');
assert.ok(disc);
// Never a click or tap target, and see-through.
assert.equal(disc.allowPicking, false);
assert.equal(disc.appearance.translucent, true);
const { material } = disc.appearance;
assert.ok(
Math.abs(
material.uniforms.depth - AWARENESS_RANGE_PULSE.gradientDepthFraction,
) < 1e-9,
);
assert.ok(
Math.abs(
material.uniforms.color.alpha - AWARENESS_RANGE_PULSE.gradientOpacity,
) < 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.
assert.equal(scene.preRender.numberOfListeners, 1);
const before = performance.now();
scene.preRender.raiseEvent(scene);
const after = performance.now();
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,
);
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,
);
}
// Unused shader slots stay empty.
assert.equal(
Cesium.Cartesian4.pack(alphas, [])[AWARENESS_PULSE_GRADIENT_MAX - 1],
0,
);
// The disc is rebuilt only past `gradientRebuildM` (2 km), not on every
// 250 m step the lines take, and keeps its material.
rendering.renderVisual(flight(3.001));
assert.equal(pulsePrimitives(scene).disc, disc);
rendering.renderVisual(flight(3.01)); // ~1 km: the lines move, the disc stays
assert.equal(pulsePrimitives(scene).disc, disc);
assert.ok(!disc.isDestroyed());
rendering.renderVisual(flight(3.03)); // ~3 km from the disc's centre
assert.equal(pulsePrimitives(scene).count, 1);
const moved = pulsePrimitives(scene).disc;
assert.notEqual(moved, disc);
assert.ok(disc.isDestroyed());
assert.equal(moved.appearance.material, material);
assert.equal(moved.allowPicking, false);
rendering.clearVisual();
assert.equal(scene.primitives.length, 0);
assert.equal(scene.preRender.numberOfListeners, 0);
});
test('pulses evaluate every frame without breaking, reusing their positions', (t) => {
const { entities, rendering } = ringRendering(t);
rendering.renderVisual(flight(3));
const center = Cesium.Cartesian3.fromDegrees(3, 27, 0);
for (const pulse of entities.values.slice(1)) {
const property = pulse.polyline.positions;
const first = property.getValue(Cesium.JulianDate.now());
// Cesium calls this on every rendered frame; the second call is where a
// reused Cartesian once landed in the ellipsoid argument and threw.
for (let frame = 0; frame < 5; frame++) {
const positions = property.getValue(Cesium.JulianDate.now());
assert.equal(positions, first, 'the same array is reused');
assert.equal(positions.length, AWARENESS_RANGE_PULSE.segments + 1);
const radii = surfaceDistances(center, positions);
assert.ok(radii.every(Number.isFinite));
assert.ok(Math.max(...radii) - Math.min(...radii) < 100);
}
}
});
test('reduced motion keeps the static ring and drops the pulses', (t) => {
t.mock.timers.enable({ apis: ['setInterval'] });
const { entities, scene, rendering, pulseFrames } = ringRendering(t, {
reducedMotion: true,
});
rendering.renderVisual(flight(3));
assert.equal(entities.values.length, 1);
assert.equal(entities.values[0].polyline.positions.isConstant, true);
t.mock.timers.tick(1000);
assert.equal(pulseFrames(), 0);
assert.equal(scene.primitives.length, 0);
assert.equal(scene.preRender.numberOfListeners, 0);
});
test('while pulses run the ring flashes as each one arrives, then rests', (t) => {
const { entities, rendering } = ringRendering(t);
rendering.renderVisual(flight(3));
const ring = entities.values[0];
const core = Cesium.Color.fromCssColorString(
AWARENESS_RANGE_RING_STYLE.color,
);
assert.equal(ring.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.
const arrival = periodMs * 6; // pulse 0 wraps here
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);
// Once per arriving pulse: `count` flashes in any one-period window that
// starts after a flash has faded.
let flashes = 0;
let wasLit = false;
const from = arrival + flashMs + 1;
for (let ms = 0; ms < periodMs; ms += 10) {
const lit = awarenessRingFlash(from + ms) > 0.5;
if (lit && !wasLit) flashes++;
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,
);
});
test('without pulses the ring stays on screen at full strength', (t) => {
const { entities, rendering } = ringRendering(t, { reducedMotion: true });
rendering.renderVisual(flight(3));
const color = entities.values[0].polyline.material.color;
assert.equal(color.isConstant, true);
assert.equal(
color.getValue(Cesium.JulianDate.now()).toCssColorString(),
Cesium.Color.fromCssColorString(
AWARENESS_RANGE_RING_STYLE.color,
).toCssColorString(),
);
assert.equal(getAwarenessEchoField(), null);
});
test('the echo field follows the subject while pulses run and clears with the ring', (t) => {
const { rendering } = ringRendering(t);
rendering.renderVisual(flight(3));
const field = getAwarenessEchoField();
assert.ok(field);
assert.equal(field.radiusM, AWARENESS_RADIUS_M);
assert.equal(field.pulse, AWARENESS_RANGE_PULSE);
assert.equal(field.echo, AWARENESS_RANGE_ECHO);
assert.ok(
Cesium.Cartesian3.equalsEpsilon(
field.center,
Cesium.Cartesian3.fromDegrees(3, 27, 0),
0,
1e-3,
),
);
// ~1 km later the same field has moved with the subject.
rendering.renderVisual(flight(3.01));
assert.equal(getAwarenessEchoField(), field);
assert.ok(
Cesium.Cartesian3.equalsEpsilon(
field.center,
Cesium.Cartesian3.fromDegrees(3.01, 27, 0),
0,
1e-3,
),
);
rendering.clearVisual();
assert.equal(getAwarenessEchoField(), null);
});