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'); }