fix(awareness): keep range pulses out of picking and in the style palette

Review follow-up for the sonar pulses (the non-blocking items).

Unpickable pulses: the three pulse lines were polyline entities, which
Cesium always picks, so near-miss and touch picks around them became
unowned hits. They are now drawn by the pulse disc's shader, which
already drew the gradient and is built with allowPicking false. Each
line keeps the polyline outline material's look: 3 CSS px wide with a
1 px dark outline in the ring's colours, measured from the screen-space
gradient of the disc's radius times czm_pixelRatio, as Cesium's contour
material does. Without derivatives only the gradient band is drawn.

Disc follow: with the lines on the disc, it is rebuilt after the subject
moves 500 m instead of 2 km, so the pulses stay concentric with the
ring. A resting disc is hidden and not rebuilt; it catches up when the
pulses wake.

Camera below the disc: the disc sits 2.5 km above the ellipsoid with
faceForward, so from a lower camera it swept across the sky. It is now
hidden while the camera is below that height.

Echo palette: echo brackets keep the pulse blue on the default
detection style and take the style's line colour under NVG, FLIR and
CRT.

Echo test: the detection renderer's echo maths moves into
awarenessEchoBracket and awarenessEchoColor in src/awarenessEcho.js,
tested directly instead of running a regex slice of detection.js
through vm, and the one-sided spread assert is now two-sided.

The positions reuse parameter of awarenessRangeRingPositions and the
pulse segment count had no callers left and are removed.
This commit is contained in:
Manjunath committed 2026-11-03 19:44:09 +05:30
1 parent 0c025987ef
commit 013cfdf91d
7 files changed
+303 -234

No files matched your search

+21 -14
View File
@@ -53,7 +53,11 @@ import {
isCyberContactSonarActive as isCyberSonarActive,
isCyberContactThemeActive as isCyberMapThemeActive,
} from '../cyberSonar.js';
import { awarenessEchoAt, getAwarenessEchoField } from '../awarenessEcho.js';
import {
awarenessEchoBracket,
awarenessEchoColor,
getAwarenessEchoField,
} from '../awarenessEcho.js';
/**
* @module detection
@@ -1445,22 +1449,22 @@ function _drawOverlay(frame) {
);
visibleCount++;
if (echoPaths) {
const echo = awarenessEchoAt(obj._layerId, obj.position, now);
if (echo > 0.01) {
// Starts on the contact's bracket and spreads outward as it fades.
const band = Math.max(
1,
Math.ceil(echo * admissionAlpha * ECHO_ALPHA_STEPS),
);
const spread = echoField.echo.spreadPx * (1 - echo);
// Starts on the contact's bracket and spreads outward as it fades.
const echo = awarenessEchoBracket(
obj._layerId,
obj.position,
now,
admissionAlpha,
ECHO_ALPHA_STEPS,
);
if (echo)
appendCornerBracket(
(echoPaths[band] ??= new Path2D()),
(echoPaths[echo.band] ??= new Path2D()),
sx,
sy,
halfW + spread,
halfH + spread,
halfW + echo.spreadPx,
halfH + echo.spreadPx,
);
}
}
if (obj.type === 'AIR')
aircraftBracketSectors[detectionHorizontalSector(sx, width)]++;
@@ -1603,7 +1607,10 @@ function _drawOverlay(frame) {
}
if (echoPaths) {
_ctx.lineWidth = echoField.echo.lineWidth;
_ctx.strokeStyle = echoField.echo.color;
_ctx.strokeStyle = awarenessEchoColor(
_theme.line,
_theme === THEME_MAP._default,
);
for (let band = 1; band <= ECHO_ALPHA_STEPS; band++) {
if (!echoPaths[band]) continue;
_ctx.globalAlpha =
+58 -66
View File
@@ -1,104 +1,96 @@
// src/data/detectionAwarenessEcho.test.mjs — Global Context sonar echoes pulse
// a contact's own detection bracket: a second bracket that starts on it and
// spreads outward as it fades. Exercises the renderer's actual echo block.
// spreads outward as it fades, in the detection style's own colour.
import test from 'node:test';
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import vm from 'node:vm';
import * as Cesium from 'cesium';
import {
awarenessEchoAt,
awarenessEchoBracket,
awarenessEchoColor,
clearAwarenessEchoField,
setAwarenessEchoField,
} from '../awarenessEcho.js';
import { DETECTION_THEME_MAP } from '../overlays/worldOverlayTokens.js';
const source = readFileSync(new URL('./detection.js', import.meta.url), 'utf8');
const echoBlock = source.match(
/if \(echoPaths\) \{\s*const echo = awarenessEchoAt[\s\S]*?\n {6}\}\n/,
)?.[0];
const STEPS = 8;
const pulse = { count: 3, periodMs: 9000, startFraction: 0.02 };
const echo = {
fadeMs: 1400,
layerIds: ['flights', 'military', 'ais-live-vessels'],
spreadPx: 8,
color: 'rgb(98, 181, 255)',
};
const center = Cesium.Cartesian3.fromDegrees(3, 27, 0);
const field = { center, radiusM: 250_000, pulse, echo };
/** Run the renderer's echo block for one contact and record the bracket. */
function paintEcho(field, obj, now, { admissionAlpha = 1 } = {}) {
const brackets = [];
const state = {
echoPaths: field ? new Array(9) : null,
echoField: field,
ECHO_ALPHA_STEPS: 8,
awarenessEchoAt,
Path2D: class {},
appendCornerBracket: (sink, sx, sy, halfW, halfH) =>
brackets.push({ sink, sx, sy, halfW, halfH }),
obj,
now,
admissionAlpha,
sx: 100,
sy: 50,
halfW: 9,
halfH: 7,
};
vm.runInNewContext(echoBlock, state);
const band = state.echoPaths?.findIndex((path) => path) ?? -1;
return { brackets, band };
/** The moment pulse 0's front reaches the ground under `position`. */
function arrivalAt(position) {
const ground = Cesium.Ellipsoid.WGS84.scaleToGeodeticSurface(position);
const d = Cesium.Cartesian3.distance(center, ground);
return (
((d / 250_000 - pulse.startFraction) / (1 - pulse.startFraction)) *
pulse.periodMs +
pulse.periodMs * 3
);
}
test('the echo bracket pulses outward from the contact bracket and fades', (t) => {
assert.ok(echoBlock, 'renderer has an echo block');
t.after(clearAwarenessEchoField);
const center = Cesium.Cartesian3.fromDegrees(3, 27, 0);
const field = { center, radiusM: 250_000, pulse, echo };
setAwarenessEchoField(field);
const position = Cesium.Cartesian3.fromDegrees(3.5, 27, 10_000);
const ground = Cesium.Cartesian3.fromDegrees(3.5, 27, 0);
const d = Cesium.Cartesian3.distance(center, ground);
const arrival =
((d / 250_000 - pulse.startFraction) / (1 - pulse.startFraction)) *
pulse.periodMs +
pulse.periodMs * 3;
const obj = { _layerId: 'flights', position };
const arrival = arrivalAt(position);
const at = (nowMs, admissionAlpha = 1) =>
awarenessEchoBracket('flights', position, nowMs, admissionAlpha, STEPS);
// The moment the front arrives: on the bracket, strongest band.
const fresh = paintEcho(field, obj, arrival);
assert.equal(fresh.brackets.length, 1);
assert.ok(fresh.brackets[0].halfW - 9 < 0.01);
assert.equal(fresh.band, 8);
// Half way through the fade: dimmer and spread outward.
const half = paintEcho(field, obj, arrival + echo.fadeMs / 2);
assert.ok(half.brackets[0].halfW > 9 + echo.spreadPx * 0.5);
assert.ok(half.brackets[0].halfH > 7 + echo.spreadPx * 0.5);
assert.equal(half.brackets[0].sx, 100);
assert.ok(half.band < 8 && half.band >= 1);
const fresh = at(arrival);
assert.ok(fresh);
assert.ok(Math.abs(fresh.spreadPx) < 0.01, `${fresh.spreadPx}`);
assert.equal(fresh.band, STEPS);
// Half way through the fade: dimmer and spread outward, but not past
// the field's spread.
const half = at(arrival + echo.fadeMs / 2);
assert.ok(half.spreadPx > echo.spreadPx * 0.5);
assert.ok(half.spreadPx < echo.spreadPx);
assert.ok(half.band < STEPS && half.band >= 1);
// Keyhole fade carries over to the echo.
assert.ok(paintEcho(field, obj, arrival, { admissionAlpha: 0.25 }).band <= 2);
assert.ok(at(arrival, 0.25).band <= 2);
// Faded out, or between fronts: no echo bracket.
assert.equal(
paintEcho(field, obj, arrival + echo.fadeMs + 5).brackets.length,
0,
);
assert.equal(at(arrival + echo.fadeMs + 5), null);
});
test('no echo field, or a layer that does not echo, draws nothing extra', (t) => {
assert.ok(echoBlock);
t.after(clearAwarenessEchoField);
const center = Cesium.Cartesian3.fromDegrees(3, 27, 0);
const position = Cesium.Cartesian3.fromDegrees(3.01, 27, 0);
// No field: the renderer skips the echo block entirely.
assert.equal(
paintEcho(null, { _layerId: 'flights', position }, 0).brackets.length,
0,
);
const field = { center, radiusM: 250_000, pulse, echo };
clearAwarenessEchoField();
for (let now = 0; now < pulse.periodMs; now += 25)
assert.equal(
awarenessEchoBracket('flights', position, now, 1, STEPS),
null,
);
setAwarenessEchoField(field);
for (let now = 0; now < pulse.periodMs; now += 25)
for (const layerId of ['cctv', 'satellites', 'traffic'])
assert.equal(
paintEcho(field, { _layerId: layerId, position }, now).brackets.length,
0,
awarenessEchoBracket(layerId, position, now, 1, STEPS),
null,
);
});
test('echoes take the detection style colour except on the default style', (t) => {
t.after(clearAwarenessEchoField);
assert.equal(
awarenessEchoColor(DETECTION_THEME_MAP._default.line, true),
null,
);
setAwarenessEchoField(field);
assert.equal(
awarenessEchoColor(DETECTION_THEME_MAP._default.line, true),
echo.color,
);
// CRT, NVG and FLIR keep their monochrome palettes.
for (const style of ['retro', 'surveillance', 'thermal']) {
const { line } = DETECTION_THEME_MAP[style];
assert.equal(awarenessEchoColor(line, false), line, style);
}
});