From e80f583e663de3aeb22585ab2a41b3054cde7948 Mon Sep 17 00:00:00 2001 From: Manjunath Date: Tue, 3 Nov 2026 16:30:56 +0530 Subject: [PATCH] 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. --- docs/CURRENT-STATE.md | 2 +- scripts/format-scope.json | 3 + scripts/package-boundaries.json | 3 + src/awarenessEcho.js | 104 ++++++ src/awarenessEcho.test.mjs | 84 +++++ src/data/detection.js | 36 ++ src/data/detectionAwarenessEcho.test.mjs | 104 ++++++ src/layers/awareness/policy.js | 57 +++- src/layers/awareness/rangeRing.test.mjs | 416 +++++++++++++++++++++-- src/layers/awareness/rendering.js | 311 +++++++++++++++-- 10 files changed, 1064 insertions(+), 56 deletions(-) create mode 100644 src/awarenessEcho.js create mode 100644 src/awarenessEcho.test.mjs create mode 100644 src/data/detectionAwarenessEcho.test.mjs diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index 038d23fa0..46b012cf6 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -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. 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 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. - 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. diff --git a/scripts/format-scope.json b/scripts/format-scope.json index 1ce21e6c0..bbcdc7775 100644 --- a/scripts/format-scope.json +++ b/scripts/format-scope.json @@ -1178,6 +1178,9 @@ "src/ui/stores/awarenessDirection.js", "src/layers/awareness/directionOverlay.test.mjs", "src/layers/awareness/rangeRing.test.mjs", + "src/awarenessEcho.js", + "src/awarenessEcho.test.mjs", + "src/data/detectionAwarenessEcho.test.mjs", "src/hudReadouts.js", "src/ui/stores/hud.js", "src/ui/views/intelHudView.test.mjs", diff --git a/scripts/package-boundaries.json b/scripts/package-boundaries.json index de6b2e490..5650b759b 100644 --- a/scripts/package-boundaries.json +++ b/scripts/package-boundaries.json @@ -1677,6 +1677,7 @@ "src/data/distanceFormat.js", "src/i18n/marker.js", "src/cyberSonar.js", + "src/awarenessEcho.js", "src/cockpitMath.js", "src/data/iconOrientation.js", "src/data/militaryAwarenessEngine.js", @@ -2227,6 +2228,7 @@ "src/ui/placeNames.js", "src/cyberSonarScene.js", "src/cyberSonar.js", + "src/awarenessEcho.js", "src/hudLayouts.js", "src/hudLayoutPolicy.js", "src/ui/radioPanelPlacement.js", @@ -3211,6 +3213,7 @@ "src/data/headingDegrees.js", "src/data/distanceFormat.js", "src/cyberSonar.js", + "src/awarenessEcho.js", "src/data/approxDistance.js", "src/data/bhoteKoshiEvent.js", "src/ui/imagerySplit.js", diff --git a/src/awarenessEcho.js b/src/awarenessEcho.js new file mode 100644 index 000000000..16fe6cda4 --- /dev/null +++ b/src/awarenessEcho.js @@ -0,0 +1,104 @@ +/** + * @module awarenessEcho + * @description The Global Context sonar echo field. The awareness layer + * publishes it while its range pulses run; the detection overlay reads it to + * pulse the brackets of contacts a pulse front has just crossed. It holds no + * awareness policy of its own: the publisher passes radius, pulse timing and + * echo styling in, so the overlay never imports awareness internals. + */ +import * as Cesium from 'cesium'; + +/** + * @typedef {object} AwarenessEchoField + * @property {Cesium.Cartesian3} center Subject's ground point. The publisher + * may move it in place; it is read on every query. + * @property {number} radiusM Range the pulses grow to. + * @property {{count: number, periodMs: number, startFraction: number}} pulse + * @property {{fadeMs: number, layerIds: string[]}} echo + */ + +/** @type {AwarenessEchoField|null} */ +let field = null; +let layerIds = new Set(); +const groundScratch = new Cesium.Cartesian3(); + +/** + * Start (or replace) the echo field. + * @param {AwarenessEchoField} next + * @returns {void} + */ +export function setAwarenessEchoField(next) { + field = next; + layerIds = new Set(next.echo.layerIds); +} + +/** Stop all echoes. Safe when none were set. */ +export function clearAwarenessEchoField() { + field = null; + layerIds = new Set(); +} + +/** @returns {AwarenessEchoField|null} The field the overlay styles from. */ +export function getAwarenessEchoField() { + return field; +} + +/** + * 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 + * strongest pulse wins. A front that has wrapped back to the centre still + * counts for the contact it last crossed, so rim contacts fade out instead of + * cutting off. + * @param {number} nowMs Monotonic clock in milliseconds. + * @param {number} distanceM Contact's distance from the subject. + * @param {number} radiusM Range the pulses grow to. + * @param {{count: number, periodMs: number, startFraction: number}} pulse + * @param {{fadeMs: number}} echo + * @returns {number} 0 to 1. + */ +export function awarenessEchoStrength(nowMs, distanceM, radiusM, pulse, echo) { + if (!Number.isFinite(distanceM) || distanceM < 0 || distanceM > radiusM) + return 0; + // The phase at which a pulse front sits at this distance. + const touchPhase = Math.max( + 0, + (distanceM / radiusM - pulse.startFraction) / (1 - pulse.startFraction), + ); + let strength = 0; + for (let index = 0; index < pulse.count; index++) { + const cycle = nowMs / pulse.periodMs + index / pulse.count; + let since = cycle - Math.floor(cycle) - touchPhase; + // Rounding at the exact crossing must not read as a whole period ago. + if (since < -1e-9) since += 1; + else if (since < 0) since = 0; + const elapsedMs = since * pulse.periodMs; + if (elapsedMs < echo.fadeMs) + strength = Math.max(strength, (1 - elapsedMs / echo.fadeMs) ** 2); + } + return strength; +} + +/** + * The echo for one contact of `layerId` at world `position`, or 0 when no + * field is set or the layer does not echo. Distance is measured between + * ground points, so an aircraft's altitude does not shift its echo. + * @param {string} layerId Owning layer of the contact. + * @param {Cesium.Cartesian3} position Contact's world position. + * @param {number} nowMs Monotonic clock in milliseconds. + * @returns {number} 0 to 1. + */ +export function awarenessEchoAt(layerId, position, nowMs) { + if (!field || !position || !layerIds.has(layerId)) return 0; + const ground = Cesium.Ellipsoid.WGS84.scaleToGeodeticSurface( + position, + groundScratch, + ); + if (!ground) return 0; + return awarenessEchoStrength( + nowMs, + Cesium.Cartesian3.distance(field.center, ground), + field.radiusM, + field.pulse, + field.echo, + ); +} diff --git a/src/awarenessEcho.test.mjs b/src/awarenessEcho.test.mjs new file mode 100644 index 000000000..9bc7e6cd6 --- /dev/null +++ b/src/awarenessEcho.test.mjs @@ -0,0 +1,84 @@ +// src/awarenessEcho.test.mjs — the Global Context echo field: when a sonar +// pulse front crosses a flight or vessel, its bracket echo lights and fades. +import test from 'node:test'; +import assert from 'node:assert/strict'; +import * as Cesium from 'cesium'; +import { + awarenessEchoAt, + awarenessEchoStrength, + clearAwarenessEchoField, + getAwarenessEchoField, + setAwarenessEchoField, +} from './awarenessEcho.js'; + +const R = 250_000; +const pulse = { count: 3, periodMs: 9000, startFraction: 0.02 }; +const echo = { fadeMs: 1400, layerIds: ['flights', 'ais-live-vessels'] }; +const strength = (nowMs, distanceM) => + awarenessEchoStrength(nowMs, distanceM, R, pulse, echo); +/** The moment pulse 0's front sits at `distanceM`, in a later cycle. */ +const arrival = (distanceM) => + ((distanceM / R - pulse.startFraction) / (1 - pulse.startFraction)) * + pulse.periodMs + + pulse.periodMs * 4; + +test('an echo lights as a pulse front reaches the contact, then fades', () => { + const d = 100_000; + const t0 = arrival(d); + assert.ok(Math.abs(strength(t0, d) - 1) < 1e-6); + assert.ok(Math.abs(strength(t0 + echo.fadeMs / 2, d) - 0.25) < 1e-6); + assert.equal(strength(t0 + echo.fadeMs + 1, d), 0); + assert.equal(strength(t0 - 50, d), 0, 'dark just before the front'); + // Every pulse lights it once a period: `count` separate flashes in any + // one-period window that starts after a flash has faded. + let flashes = 0; + let wasLit = false; + const from = t0 + echo.fadeMs + 1; + for (let ms = 0; ms < pulse.periodMs; ms += 10) { + const lit = strength(from + ms, d) > 0.5; + if (lit && !wasLit) flashes++; + wasLit = lit; + } + assert.equal(flashes, pulse.count); +}); + +test('rim contacts fade out after the front wraps; outside the range never', () => { + const rim = R * 0.999; + assert.ok(strength(arrival(rim) + echo.fadeMs / 2, rim) > 0.2); + assert.ok(strength(pulse.periodMs * 5, 1000) > 0.99, 'inside the start ring'); + for (let ms = 0; ms < pulse.periodMs; ms += 50) + assert.equal(strength(ms, R + 1), 0); + assert.equal(strength(0, Number.NaN), 0); + assert.equal(strength(0, -1), 0); +}); + +test('awarenessEchoAt reads the published field, by layer, at ground level', (t) => { + t.after(clearAwarenessEchoField); + const contact = Cesium.Cartesian3.fromDegrees(3.5, 27, 0); + assert.equal(awarenessEchoAt('flights', contact, 0), 0, 'no field, no echo'); + const center = Cesium.Cartesian3.fromDegrees(3, 27, 0); + setAwarenessEchoField({ center, radiusM: R, pulse, echo }); + assert.ok(getAwarenessEchoField()); + const d = Cesium.Cartesian3.distance(center, contact); + const t0 = arrival(d); + assert.ok(awarenessEchoAt('flights', contact, t0) > 0.99); + assert.ok(awarenessEchoAt('ais-live-vessels', contact, t0) > 0.99); + // Layers outside the echo list never light. + assert.equal(awarenessEchoAt('cctv', contact, t0), 0); + assert.equal(awarenessEchoAt('military-installations', contact, t0), 0); + // An aircraft 11 km up echoes with the ground point under it. + const aloft = Cesium.Cartesian3.fromDegrees(3.5, 27, 11_000); + assert.ok( + Math.abs( + awarenessEchoAt('flights', aloft, t0 + 300) - + awarenessEchoAt('flights', contact, t0 + 300), + ) < 0.01, + ); + // The field's centre is read in place, so a moved subject moves the echo. + Cesium.Cartesian3.clone(contact, center); + assert.ok(awarenessEchoAt('flights', contact, pulse.periodMs * 7) > 0.99); + assert.equal(awarenessEchoAt('flights', null, t0), 0); + clearAwarenessEchoField(); + assert.equal(getAwarenessEchoField(), null); + assert.equal(awarenessEchoAt('flights', contact, t0), 0); +}); diff --git a/src/data/detection.js b/src/data/detection.js index c2e63c3d9..018d3d348 100644 --- a/src/data/detection.js +++ b/src/data/detection.js @@ -53,6 +53,7 @@ import { isCyberContactSonarActive as isCyberSonarActive, isCyberContactThemeActive as isCyberMapThemeActive, } from '../cyberSonar.js'; +import { awarenessEchoAt, getAwarenessEchoField } from '../awarenessEcho.js'; /** * @module detection @@ -103,6 +104,8 @@ const SPARSE_ZONE_FRACTION = 0.5; const LABEL_SOLVE_INTERVAL_MS = 125; /** Alpha bands retain bracket batching while approximating a continuous radial fade. */ const BRACKET_ALPHA_STEPS = 4; +/** Finer bands for Global Context echo brackets, whose fade is animated. */ +const ECHO_ALPHA_STEPS = 8; /** Stable per-layer candidate safety cap; independent of density and camera bearing. */ const LAYER_CANDIDATE_CAP = 2600; const LAYER_WEIGHTS = Object.freeze({ @@ -1335,6 +1338,10 @@ function _drawOverlay(frame) { const tiers = _theme.tiers || null; const colorFor = (key) => (tiers && tiers[key]) || _theme.line; 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(); + const echoPaths = echoField ? new Array(ECHO_ALPHA_STEPS + 1) : null; const pathFor = (map, color, alpha) => { const band = Math.max( 1, @@ -1437,6 +1444,24 @@ function _drawOverlay(frame) { halfH, ); 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); + appendCornerBracket( + (echoPaths[band] ??= new Path2D()), + sx, + sy, + halfW + spread, + halfH + spread, + ); + } + } if (obj.type === 'AIR') aircraftBracketSectors[detectionHorizontalSector(sx, width)]++; if (bracketAlpha >= 1) bracketOpacityCounts.full++; @@ -1576,6 +1601,17 @@ function _drawOverlay(frame) { _ctx.stroke(entry.path); } } + if (echoPaths) { + _ctx.lineWidth = echoField.echo.lineWidth; + _ctx.strokeStyle = echoField.echo.color; + for (let band = 1; band <= ECHO_ALPHA_STEPS; band++) { + if (!echoPaths[band]) continue; + _ctx.globalAlpha = + fade * (band / ECHO_ALPHA_STEPS) * bracketPresentationOpacity; + _ctx.stroke(echoPaths[band]); + } + _ctx.lineWidth = bracketWidth; + } const renderEntries = _labelArbiter.renderEntries(candidateMap, now); const fadingCount = countFadingRenderEntries(renderEntries); diff --git a/src/data/detectionAwarenessEcho.test.mjs b/src/data/detectionAwarenessEcho.test.mjs new file mode 100644 index 000000000..4c34672d5 --- /dev/null +++ b/src/data/detectionAwarenessEcho.test.mjs @@ -0,0 +1,104 @@ +// 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. +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, + clearAwarenessEchoField, + setAwarenessEchoField, +} from '../awarenessEcho.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 pulse = { count: 3, periodMs: 9000, startFraction: 0.02 }; +const echo = { + fadeMs: 1400, + layerIds: ['flights', 'military', 'ais-live-vessels'], + spreadPx: 8, +}; + +/** 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 }; +} + +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 }; + + // 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); + // Keyhole fade carries over to the echo. + assert.ok(paintEcho(field, obj, arrival, { admissionAlpha: 0.25 }).band <= 2); + // Faded out, or between fronts: no echo bracket. + assert.equal( + paintEcho(field, obj, arrival + echo.fadeMs + 5).brackets.length, + 0, + ); +}); + +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 }; + 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, + ); +}); diff --git a/src/layers/awareness/policy.js b/src/layers/awareness/policy.js index b33da8433..a0af9e3d7 100644 --- a/src/layers/awareness/policy.js +++ b/src/layers/awareness/policy.js @@ -49,7 +49,10 @@ export const CONTEXT_RIM_HEIGHT_M = 2500; * the FLIR, NVG and CRT styles. A bright core inside a dark halo keeps * 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. */ + * 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. */ export const AWARENESS_RANGE_RING_STYLE = Object.freeze({ color: 'rgba(98, 181, 255, 0.95)', @@ -57,6 +60,58 @@ export const AWARENESS_RANGE_RING_STYLE = Object.freeze({ width: 4, outlineWidth: 1, segments: 128, + flashMs: 1800, + restOpacity: 0, +}); + +/** @constant Sonar-style pulses that grow slowly from the subject to the + * range ring and are almost gone when they reach it. `count` pulses share + * one `periodMs`, evenly staggered, so a new one starts every + * `periodMs / count`. Each starts at `startFraction` of the radius, fades in + * over `fadeInFraction` of its travel, then fades as (1 - travel) to the + * `fadeOutPower` down to `endOpacityFraction` of `peakOpacity`, so a pulse + * is dim but still visible when it reaches the ring and the ring flashes. Behind each line a radial gradient in the same blue fades + * inward over `gradientDepthFraction` of the radius, at most + * `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. */ + +export const AWARENESS_RANGE_PULSE = Object.freeze({ + count: 3, + periodMs: 9000, + startFraction: 0.02, + fadeInFraction: 0.08, + fadeOutPower: 1.5, + endOpacityFraction: 0.35, + peakOpacity: 0.6, + width: 3, + outlineWidth: 1, + segments: 96, + gradientDepthFraction: 0.35, + gradientOpacity: 0.22, + gradientGranularityDeg: 0.25, + gradientRebuildM: 2000, + frameIntervalMs: 80, +}); + +/** @constant Sonar echoes on contact brackets. When a pulse front reaches a + * flight, military aircraft or AIS vessel in the window, the detection + * overlay draws a second bracket in `color` around that contact's own + * 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, + color: 'rgb(98, 181, 255)', + spreadPx: 8, + lineWidth: 1.75, }); export const VESSEL_FOCUS_RADIUS_M = 3000; diff --git a/src/layers/awareness/rangeRing.test.mjs b/src/layers/awareness/rangeRing.test.mjs index 074d449fe..655bdc080 100644 --- a/src/layers/awareness/rangeRing.test.mjs +++ b/src/layers/awareness/rangeRing.test.mjs @@ -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); +}); diff --git a/src/layers/awareness/rendering.js b/src/layers/awareness/rendering.js index 8cee87d80..ca22bacbd 100644 --- a/src/layers/awareness/rendering.js +++ b/src/layers/awareness/rendering.js @@ -12,10 +12,17 @@ import { import { bearingBetweenCoordinates } from '../../cockpitMath.js'; import { AWARENESS_PAGE_SIZE, + AWARENESS_RANGE_ECHO, + AWARENESS_RANGE_PULSE, AWARENESS_RANGE_RING_STYLE, CONTEXT_RIM_HEIGHT_M, } from './policy.js'; import { formatAwarenessDirection } from './presentation.js'; +import { + awarenessEchoStrength, + clearAwarenessEchoField, + setAwarenessEchoField, +} from '../../awarenessEcho.js'; /** * A closed ring of positions `radiusM` along the surface from `groundCenter`, @@ -26,6 +33,7 @@ import { formatAwarenessDirection } from './presentation.js'; * @param {number} radiusM Surface radius in metres. * @param {number} heightM Height of the ring above the ellipsoid. * @param {number} [segments] Chords around the ring. + * @param {Cesium.Cartesian3[]} [result] Array whose Cartesians are reused. * @returns {Cesium.Cartesian3[]} `segments + 1` positions; the last repeats the first. */ export function awarenessRangeRingPositions( @@ -33,6 +41,7 @@ export function awarenessRangeRingPositions( radiusM, heightM, segments = AWARENESS_RANGE_RING_STYLE.segments, + result = [], ) { const { latitude, longitude } = Cesium.Cartographic.fromCartesian(groundCenter); @@ -43,7 +52,8 @@ export function awarenessRangeRingPositions( const w = 1 - e2 * sinLat * sinLat; const meridional = (a * (1 - e2)) / (w * Math.sqrt(w)); const primeVertical = a / Math.sqrt(w); - const positions = []; + const positions = result; + positions.length = segments + 1; for (let i = 0; i <= segments; i++) { const bearing = ((i % segments) / segments) * Cesium.Math.TWO_PI; const cosB = Math.cos(bearing); @@ -59,11 +69,136 @@ export function awarenessRangeRingPositions( sinB * Math.sin(angular) * cosLat, Math.cos(angular) - sinLat * Math.sin(lat), ); - positions.push(Cesium.Cartesian3.fromRadians(lon, lat, heightM)); + positions[i] = Cesium.Cartesian3.fromRadians( + lon, + lat, + heightM, + Cesium.Ellipsoid.WGS84, + positions[i], + ); } return positions; } +/** + * Where pulse `index` is at `nowMs`: its radius and opacity. Pulses travel at + * constant speed from `startFraction` of the range to the full range, fade in + * over `fadeInFraction` of the way, then fade as (1 - travel) to the + * `fadeOutPower` down to `endOpacityFraction` of the peak, so each is dim + * but still visible when it reaches the range ring. The opacity already + * includes `peakOpacity`. + * @param {number} nowMs Monotonic clock in milliseconds. + * @param {number} index Pulse number, 0 to `count - 1`. + * @param {number} [radiusM] Range the pulses grow to. + * @param {object} [pulse] Timing and shape, see `AWARENESS_RANGE_PULSE`. + * @returns {{radiusM: number, alpha: number}} + */ +export function awarenessPulseState( + nowMs, + index, + radiusM = AWARENESS_RADIUS_M, + pulse = AWARENESS_RANGE_PULSE, +) { + const cycle = nowMs / pulse.periodMs + index / pulse.count; + const phase = cycle - Math.floor(cycle); + return { + radiusM: + radiusM * (pulse.startFraction + (1 - pulse.startFraction) * phase), + alpha: + pulse.peakOpacity * + Math.min(1, phase / pulse.fadeInFraction) * + (pulse.endOpacityFraction + + (1 - pulse.endOpacityFraction) * (1 - phase) ** pulse.fadeOutPower), + }; +} + +/** Whether the viewer asked the system for less motion. */ +function prefersReducedMotion() { + return ( + globalThis.matchMedia?.('(prefers-reduced-motion: reduce)')?.matches ?? + false + ); +} + +/** + * How strongly the 250 km ring shows at `nowMs` while pulses run: 1 as a + * pulse front reaches it, fading over `flashMs`, so the ring appears once + * per arriving pulse instead of standing on screen. + * @param {number} nowMs Monotonic clock in milliseconds. + * @returns {number} 0 to 1. + */ +export function awarenessRingFlash(nowMs) { + return awarenessEchoStrength( + nowMs, + AWARENESS_RADIUS_M, + AWARENESS_RADIUS_M, + AWARENESS_RANGE_PULSE, + { fadeMs: AWARENESS_RANGE_RING_STYLE.flashMs }, + ); +} + +/** Most pulses the gradient shader draws; its uniforms are vec4s. */ +export const AWARENESS_PULSE_GRADIENT_MAX = 4; + +// Ellipse texture coordinates put the centre at (0.5, 0.5) and the rim at +// distance 0.5, so `r` is the fraction of the range. Behind each pulse line +// the band is strongest at the line and fades to nothing `depth` inward. +const PULSE_GRADIENT_SOURCE = ` +uniform vec4 color; +uniform vec4 radii; +uniform vec4 alphas; +uniform float depth; +czm_material czm_getMaterial(czm_materialInput materialInput) { + czm_material material = czm_getDefaultMaterial(materialInput); + float r = length(materialInput.st - vec2(0.5)) * 2.0; + float alpha = 0.0; + for (int i = 0; i < 4; i++) { + float inside = radii[i] - r; + if (inside >= 0.0 && inside <= depth) { + float t = 1.0 - inside / depth; + alpha += alphas[i] * t * t; + } + } + material.diffuse = color.rgb; + material.alpha = clamp(alpha * color.a, 0.0, 1.0); + return material; +} +`; + +/** + * The pulse gradient: one flat, translucent, unpickable disc the size of the + * range ring whose shader draws a band behind each pulse. Moving pulses only + * change its uniforms; the disc is rebuilt only when the subject moves. + * @param {Cesium.Cartesian3} groundCenter Disc centre on the ellipsoid. + * @param {Cesium.Material} material Shared pulse-gradient material. + * @returns {Cesium.Primitive} + */ +function createPulseGradient(groundCenter, material) { + return new Cesium.Primitive({ + geometryInstances: new Cesium.GeometryInstance({ + geometry: new Cesium.EllipseGeometry({ + center: groundCenter, + semiMajorAxis: AWARENESS_RADIUS_M, + semiMinorAxis: AWARENESS_RADIUS_M, + height: CONTEXT_RIM_HEIGHT_M, + granularity: Cesium.Math.toRadians( + AWARENESS_RANGE_PULSE.gradientGranularityDeg, + ), + vertexFormat: + Cesium.MaterialAppearance.MaterialSupport.TEXTURED.vertexFormat, + }), + }), + appearance: new Cesium.MaterialAppearance({ + material, + flat: true, + translucent: true, + faceForward: true, + }), + asynchronous: false, + allowPicking: false, + }); +} + export function createRendering({ state: layerState, services, @@ -323,6 +458,11 @@ export function createRendering({ } function clearVisual() { + globalThis.clearInterval(layerState.visual?.pulseTimer); + layerState.visual?.removePulseListener?.(); + if (layerState.visual?.gradient && layerState.viewer) + layerState.viewer.scene.primitives.remove(layerState.visual.gradient); + clearAwarenessEchoField(); if (layerState.visual?.entities && layerState.viewer) { for (const entity of layerState.visual.entities) layerState.viewer.entities.remove(entity); @@ -354,9 +494,26 @@ export function createRendering({ AWARENESS_RADIUS_M, CONTEXT_RIM_HEIGHT_M, ); - for (const entity of layerState.visual.entities) - entity.polyline.positions.setValue(positions); + // Pulse lines read `visual.center` every frame and follow on their + // own. The gradient disc is geometry, so it is rebuilt, but only + // every `gradientRebuildM`: a build costs a few milliseconds. + layerState.visual.ring.polyline.positions.setValue(positions); Cesium.Cartesian3.clone(groundCenter, layerState.visual.center); + const { gradient, gradientCenter } = layerState.visual; + if ( + gradient && + Cesium.Cartesian3.distance(gradientCenter, groundCenter) >= + AWARENESS_RANGE_PULSE.gradientRebuildM + ) { + const primitives = layerState.viewer.scene.primitives; + layerState.visual.gradient = primitives.add( + createPulseGradient(groundCenter, gradient.appearance.material), + ); + // Primitive.destroy leaves the material alone, so the new disc + // keeps sharing it. + primitives.remove(gradient); + Cesium.Cartesian3.clone(groundCenter, gradientCenter); + } // Content actually changed — buy exactly one frame instead of holding. governorRequestRender('awareness-visual'); } @@ -365,35 +522,143 @@ export function createRendering({ clearVisual(); const entities = []; + const scene = layerState.viewer.scene; + // A viewer without a primitive collection or a preRender hook (headless + // 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() && + scene?.primitives?.add && + scene?.preRender?.addEventListener, + ); // A polyline, not an ellipse outline: WebGL draws ellipse outlines at // 1 px whatever `outlineWidth` says, and the ring needs a halo. const ring = AWARENESS_RANGE_RING_STYLE; - entities.push( - layerState.viewer.entities.add({ - polyline: { - positions: new Cesium.ConstantProperty( - awarenessRangeRingPositions( - groundCenter, - AWARENESS_RADIUS_M, - CONTEXT_RIM_HEIGHT_M, - ), + 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. + 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); + return color; + }, false); + }; + const ringEntity = layerState.viewer.entities.add({ + polyline: { + positions: new Cesium.ConstantProperty( + awarenessRangeRingPositions( + groundCenter, + AWARENESS_RADIUS_M, + CONTEXT_RIM_HEIGHT_M, ), - width: ring.width, - arcType: Cesium.ArcType.NONE, - material: new Cesium.PolylineOutlineMaterialProperty({ - color: Cesium.Color.fromCssColorString(ring.color), - outlineColor: Cesium.Color.fromCssColorString(ring.outlineColor), - outlineWidth: ring.outlineWidth, + ), + width: ring.width, + arcType: Cesium.ArcType.NONE, + material: new Cesium.PolylineOutlineMaterialProperty({ + color: animate ? flashing(core) : core, + outlineColor: animate ? flashing(halo) : halo, + outlineWidth: ring.outlineWidth, + }), + }, + }); + entities.push(ringEntity); + + const center = Cesium.Cartesian3.clone(groundCenter); + let gradient = null; + let removePulseListener = null; + let pulseTimer = 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); + entities.push( + layerState.viewer.entities.add({ + polyline: { + positions: new Cesium.CallbackProperty( + () => + awarenessRangeRingPositions( + center, + stateAt().radiusM, + CONTEXT_RIM_HEIGHT_M, + pulse.segments, + positions, + ), + false, + ), + width: pulse.width, + arcType: Cesium.ArcType.NONE, + material: new Cesium.PolylineOutlineMaterialProperty({ + color: new Cesium.CallbackProperty(() => { + coreColor.alpha = core.alpha * stateAt().alpha; + return coreColor; + }, false), + outlineColor: new Cesium.CallbackProperty(() => { + haloColor.alpha = halo.alpha * stateAt().alpha; + return haloColor; + }, false), + outlineWidth: pulse.outlineWidth, + }), + }, }), - }, - }), - ); + ); + } + const count = Math.min(pulse.count, AWARENESS_PULSE_GRADIENT_MAX); + const uniforms = { + color: core.clone(), + radii: new Cesium.Cartesian4(), + alphas: new Cesium.Cartesian4(), + depth: pulse.gradientDepthFraction, + }; + uniforms.color.alpha = pulse.gradientOpacity; + const material = new Cesium.Material({ + fabric: { uniforms, source: PULSE_GRADIENT_SOURCE }, + translucent: true, + }); + gradient = scene.primitives.add( + createPulseGradient(groundCenter, material), + ); + const radii = [0, 0, 0, 0]; + const alphas = [0, 0, 0, 0]; + removePulseListener = scene.preRender.addEventListener(() => { + const nowMs = performance.now(); + for (let index = 0; index < count; index++) { + const state = awarenessPulseState(nowMs, index); + radii[index] = state.radiusM / AWARENESS_RADIUS_M; + alphas[index] = state.alpha; + } + Cesium.Cartesian4.fromArray(radii, 0, material.uniforms.radii); + Cesium.Cartesian4.fromArray(alphas, 0, material.uniforms.alphas); + }); + // The detection overlay pulses the brackets of flights and vessels a + // front crosses; it reads the centre in place, so it follows too. + setAwarenessEchoField({ + center, + radiusM: AWARENESS_RADIUS_M, + pulse, + echo: AWARENESS_RANGE_ECHO, + }); + pulseTimer = globalThis.setInterval( + () => governorRequestRender('awareness-pulse'), + pulse.frameIntervalMs, + ); + } layerState.visual = { key, entities, - center: Cesium.Cartesian3.clone(groundCenter), + ring: ringEntity, + center, + gradient, + gradientCenter: Cesium.Cartesian3.clone(groundCenter), + removePulseListener, + pulseTimer, }; governorRequestRender('awareness-visual'); }