diff --git a/src/awarenessEcho.js b/src/awarenessEcho.js index ecf0f21a1..f4c106ead 100644 --- a/src/awarenessEcho.js +++ b/src/awarenessEcho.js @@ -45,6 +45,17 @@ export function getAwarenessEchoField() { return field; } +/** + * Whether any echo can light at `nowMs`: a field is set and its pulses are + * running. The overlay checks this once per frame, so resting pulses cost no + * per-contact work. + * @param {number} nowMs Monotonic clock in milliseconds. + * @returns {boolean} + */ +export function awarenessEchoActive(nowMs) { + return Boolean(field) && (field.level?.(nowMs) ?? 1) > 0; +} + /** * How lit a contact's echo is at `nowMs`: 1 the moment a pulse front reaches * its distance, fading as (1 - elapsed / `fadeMs`) squared, 0 otherwise. The @@ -99,13 +110,13 @@ export function awarenessEchoStrength(nowMs, distanceM, radiusM, pulse, echo) { */ export function awarenessEchoAt(layerId, position, nowMs) { if (!field || !position || !layerIds.has(layerId)) return 0; + const level = field.level?.(nowMs) ?? 1; + if (!(level > 0)) return 0; const ground = Cesium.Ellipsoid.WGS84.scaleToGeodeticSurface( position, groundScratch, ); if (!ground) return 0; - const level = field.level?.(nowMs) ?? 1; - if (!(level > 0)) return 0; return ( level * awarenessEchoStrength( diff --git a/src/awarenessEcho.test.mjs b/src/awarenessEcho.test.mjs index 846c630c4..b68e2eb96 100644 --- a/src/awarenessEcho.test.mjs +++ b/src/awarenessEcho.test.mjs @@ -4,6 +4,7 @@ import test from 'node:test'; import assert from 'node:assert/strict'; import * as Cesium from 'cesium'; import { + awarenessEchoActive, awarenessEchoAt, awarenessEchoStrength, clearAwarenessEchoField, @@ -114,8 +115,13 @@ test('echoes scale with the field level and rest with the pulses', (t) => { assert.ok( Math.abs(awarenessEchoAt('flights', contact, t0) - full / 2) < 1e-9, ); + assert.equal(awarenessEchoActive(t0), true); level = 0; assert.equal(awarenessEchoAt('flights', contact, t0), 0); + // At rest the overlay skips echoes for the whole frame. + assert.equal(awarenessEchoActive(t0), false); + clearAwarenessEchoField(); + assert.equal(awarenessEchoActive(t0), false); }); test('near the subject an echo is no brighter than the fading-in front', () => { diff --git a/src/data/detection.js b/src/data/detection.js index de896e046..e3cb41bfe 100644 --- a/src/data/detection.js +++ b/src/data/detection.js @@ -54,6 +54,7 @@ import { isCyberContactThemeActive as isCyberMapThemeActive, } from '../cyberSonar.js'; import { + awarenessEchoActive, awarenessEchoBracket, awarenessEchoColor, getAwarenessEchoField, @@ -1344,7 +1345,8 @@ function _drawOverlay(frame) { const bracketPaths = new Map(); // Global Context sonar echoes: a second bracket, banded by strength, around // each flight or vessel a pulse front has just crossed. - const echoField = getAwarenessEchoField(); + // Resting pulses light nothing, so the per-contact echo work is skipped. + const echoField = awarenessEchoActive(now) ? getAwarenessEchoField() : null; const echoPaths = echoField ? new Array(ECHO_ALPHA_STEPS + 1) : null; const pathFor = (map, color, alpha) => { const band = Math.max( diff --git a/src/data/detectionHost.test.mjs b/src/data/detectionHost.test.mjs index 2e3829c94..47e35c48e 100644 --- a/src/data/detectionHost.test.mjs +++ b/src/data/detectionHost.test.mjs @@ -673,6 +673,60 @@ test('Global Context echo brackets reach the canvas in the detection style colou `${style} echoes take the style line colour, got ${strokes}`, ); } + + } finally { + clearAwarenessEchoField(); + env.cleanup(); + } +}); + +test('resting Global Context pulses skip echo work for the whole frame, not per contact', () => { + const env = installEnvironment(); + // Six echo-eligible flights on screen; the identity camera maps x/y + // straight to device coordinates. + const flights = Array.from({ length: 6 }, (_, index) => ({ + position: new Cesium.Cartesian3(-0.5 + index * 0.2, 0, 6_356_752), + sourceId: `flight-${index}`, + id: `ECHO${index}`, + metric: `FL${300 + index}`, + type: 'AIR', + })); + const layer = { id: 'flights', getDetectableObjects: () => flights }; + let levelCalls = 0; + try { + setAwarenessEchoField({ + center: Cesium.Cartesian3.fromDegrees(0, 89, 0), + radiusM: 250_000, + pulse: { count: 1000, periodMs: 1000, startFraction: 0 }, + echo: { + layerIds: ['flights'], + fadeMs: 60_000, + color: 'rgb(1, 2, 3)', + spreadPx: 8, + lineWidth: 3.25, + }, + level: () => { + levelCalls++; + return 0; + }, + }); + initWorldOverlay(env.viewer); + initDetection(env.viewer, [layer], () => {}); + setMode('DENSE'); + settleFrame(env); + levelCalls = 0; + settleFrame(env); + const painted = getDetectionDiagnostics()?.visibleCount ?? flights.length; + assert.ok(painted >= 2, `contacts on screen: ${painted}`); + // One check per painted frame, however many contacts are on screen. + assert.ok(levelCalls >= 1, 'the frame asks whether echoes are live'); + assert.ok(levelCalls < painted, `${levelCalls} level checks for ${painted} contacts`); + assert.ok( + !env.detectionCtx.calls.some( + ([name, , style]) => name === 'stroke' && style === 'rgb(1, 2, 3)', + ), + 'no echoes while the pulses rest', + ); } finally { clearAwarenessEchoField(); env.cleanup(); diff --git a/src/layers/awareness/policy.js b/src/layers/awareness/policy.js index 11fb866ae..64e719160 100644 --- a/src/layers/awareness/policy.js +++ b/src/layers/awareness/policy.js @@ -76,15 +76,17 @@ export const AWARENESS_RANGE_RING_STYLE = Object.freeze({ * `fadeOutPower` down to `endOpacityFraction` of `peakOpacity`, so a pulse * is dim but still visible when it reaches the ring and the ring flashes. * Each line is `width` CSS pixels wide, its outer `outlineWidth` a dark - * halo, in the ring's colours, as Cesium's polyline outline material draws. Behind each line a radial gradient in the same blue - * fades inward over `gradientDepthFraction` of the radius, at most + * halo, in the ring's colours, as Cesium's polyline outline material draws. + * Behind each line a radial gradient in the same blue fades inward over + * `gradientDepthFraction` of the radius, at most * `gradientOpacity` strong. One unpickable disc draws the lines and the * gradient, so pulses never catch a click meant for a contact; its shader * takes at most four pulses. The disc is rebuilt only after the subject * moves `gradientRebuildM`, so the pulses sit at most that far off the ring's * centre, about a pixel when the ring fills the screen; while the pulses - * rest the hidden disc is left alone and catches up when they wake. The disc is hidden - * while the camera is below its height, where it would sweep the sky. + * rest the hidden disc is left alone and catches up when they wake. The + * disc is hidden while the camera is below its height, where it would sweep + * the sky. * 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 @@ -95,7 +97,6 @@ export const AWARENESS_RANGE_RING_STYLE = Object.freeze({ * 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, periodMs: 9000, @@ -120,7 +121,6 @@ export const AWARENESS_RANGE_PULSE = Object.freeze({ * bracket. It starts on the bracket and spreads outward by up to `spreadPx` * as it fades over `fadeMs`, so the frame pulses. Only `layerIds` echo; * mapped installations have no detection bracket. */ - export const AWARENESS_RANGE_ECHO = Object.freeze({ layerIds: ['flights', 'military', 'ais-live-vessels'], fadeMs: 1400,