From e80f583e663de3aeb22585ab2a41b3054cde7948 Mon Sep 17 00:00:00 2001 From: Manjunath Date: Tue, 3 Nov 2026 16:30:56 +0530 Subject: [PATCH 01/12] 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'); } From 20770e52a9bed937f9c0901dbc50882931929826 Mon Sep 17 00:00:00 2001 From: Manjunath Date: Tue, 3 Nov 2026 16:31:02 +0530 Subject: [PATCH 02/12] docs(changelog): add the range ring sonar pulses fragment for #1519 --- changelog.d/1519-range-ring-sonar-pulses.md | 6 ++++++ 1 file changed, 6 insertions(+) create mode 100644 changelog.d/1519-range-ring-sonar-pulses.md diff --git a/changelog.d/1519-range-ring-sonar-pulses.md b/changelog.d/1519-range-ring-sonar-pulses.md new file mode 100644 index 000000000..f82d9e5c9 --- /dev/null +++ b/changelog.d/1519-range-ring-sonar-pulses.md @@ -0,0 +1,6 @@ +- The Global Context range ring pulses like a sonar: three dim rings with a + soft inward glow grow from the subject to 250 km, and the ring flashes as + each one arrives. Flights, military aircraft and AIS vessels a pulse crosses + get a brief echo around their brackets. While a subject is shown, a parked + Contacts view repaints about 12 times a second. Under reduced motion there + are no pulses and the ring stays on screen (manjunath22466, #1519). From 0c025987efc84cf89aa2ea42864cc61d5af7b80d Mon Sep 17 00:00:00 2001 From: Manjunath Date: Tue, 3 Nov 2026 19:17:03 +0530 Subject: [PATCH 03/12] fix(awareness): bound the range ring pulses so a parked view goes quiet Review follow-up for the sonar pulses. Bounded repaint: the pulses used to ask for a frame every 80 ms from a setInterval for as long as a subject was shown, so a parked view repainted at 12.5 fps indefinitely. They now run for 9 s (three ring flashes) after a selection or a camera move, fading in and out over 1.2 s, then rest: the ring stands on screen at full strength, the pulse lines and gradient disc are hidden, echoes stop, and nothing asks for frames. A moving subject does not wake them, since a flying subject moves on every refresh. The lifecycle's existing camera pose signature wakes them. No beat with the Cyber sonar: the setInterval is replaced by a postRender-armed setTimeout on CYBER_SONAR_RENDER_INTERVAL_MS, as in cyberSonarScene. Every drawn frame, whoever asked for it, re-arms the request 80 ms later, so requests never land inside the detection lane's 80 ms gate. Reduced motion mid-session: a change listener on the media query rebuilds the visual, and the same-subject refresh path rebuilds when the preference no longer matches. The listener is removed in clearVisual. Readable edge: restOpacity goes from 0 to 0.4, so the 250 km ring stays visible between flashes, in screenshots and for low-vision viewers. The echo field takes an optional level so echoes rest with the pulses. CURRENT-STATE and the changelog fragment describe the bounded run and the dimmed ring. Tests: render requests stop after the bound and stay stopped for a minute; a camera move seen by the lifecycle wakes them for another bounded run; a foreign frame postpones the next request to 80 ms after it; reduced motion flipped by event and by a later refresh; the ring never drops below restOpacity; echoes scale with the level; the level fades in, holds, fades out and keeps its value when woken. --- changelog.d/1519-range-ring-sonar-pulses.md | 8 +- docs/CURRENT-STATE.md | 2 +- src/awarenessEcho.js | 19 +- src/awarenessEcho.test.mjs | 25 ++ src/layers/awareness/lifecycle.js | 2 + src/layers/awareness/policy.js | 28 +- src/layers/awareness/rangeRing.test.mjs | 344 +++++++++++++++++--- src/layers/awareness/rendering.js | 149 +++++++-- 8 files changed, 491 insertions(+), 86 deletions(-) diff --git a/changelog.d/1519-range-ring-sonar-pulses.md b/changelog.d/1519-range-ring-sonar-pulses.md index f82d9e5c9..36ac2eb1c 100644 --- a/changelog.d/1519-range-ring-sonar-pulses.md +++ b/changelog.d/1519-range-ring-sonar-pulses.md @@ -1,6 +1,8 @@ - The Global Context range ring pulses like a sonar: three dim rings with a soft inward glow grow from the subject to 250 km, and the ring flashes as each one arrives. Flights, military aircraft and AIS vessels a pulse crosses - get a brief echo around their brackets. While a subject is shown, a parked - Contacts view repaints about 12 times a second. Under reduced motion there - are no pulses and the ring stays on screen (manjunath22466, #1519). + get a brief echo around their brackets. The pulses run for about 9 s after + a selection or camera move, dimming the ring between flashes, then rest + with the ring steady on screen, so a parked view stops repainting. Under + reduced motion there are no pulses and the ring stays on screen + (manjunath22466, #1519). diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index 46b012cf6..5b85500ae 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. 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. +- 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 dims to 40% and flashes to full strength as each pulse reaches it, fading 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 run for 9 s (three ring flashes) after a selection or a camera move, fading in and out over 1.2 s, then rest: the ring stays on screen at full strength, the pulses, gradient and echoes are hidden, and nothing asks for frames, so a parked Contacts view is quiet again. A moving subject does not wake them, since a flying subject moves on every refresh; a camera that follows a tracked aircraft does keep them running, but that follow already renders every frame. While they run they never hold continuous rendering: each drawn frame arms one render request 80 ms later (the Cyber sonar cadence, `CYBER_SONAR_RENDER_INTERVAL_MS`), so they repaint at about 12.5 fps and never beat the detection lane's 80 ms gate. Under `prefers-reduced-motion`, or in a scene without primitives or preRender and postRender hooks, there are no pulses or echoes and the ring stays on screen; changing the preference mid-session rebuilds the ring. 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/src/awarenessEcho.js b/src/awarenessEcho.js index 16fe6cda4..5f8efba0f 100644 --- a/src/awarenessEcho.js +++ b/src/awarenessEcho.js @@ -15,6 +15,8 @@ import * as Cesium from 'cesium'; * @property {number} radiusM Range the pulses grow to. * @property {{count: number, periodMs: number, startFraction: number}} pulse * @property {{fadeMs: number, layerIds: string[]}} echo + * @property {(nowMs: number) => number} [level] How far the pulses are + * switched on, 0 to 1; echoes scale with it, so they rest with the pulses. */ /** @type {AwarenessEchoField|null} */ @@ -94,11 +96,16 @@ export function awarenessEchoAt(layerId, position, nowMs) { groundScratch, ); if (!ground) return 0; - return awarenessEchoStrength( - nowMs, - Cesium.Cartesian3.distance(field.center, ground), - field.radiusM, - field.pulse, - field.echo, + const level = field.level?.(nowMs) ?? 1; + if (!(level > 0)) return 0; + return ( + level * + 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 index 9bc7e6cd6..f3bdec026 100644 --- a/src/awarenessEcho.test.mjs +++ b/src/awarenessEcho.test.mjs @@ -82,3 +82,28 @@ test('awarenessEchoAt reads the published field, by layer, at ground level', (t) assert.equal(getAwarenessEchoField(), null); assert.equal(awarenessEchoAt('flights', contact, t0), 0); }); + +test('echoes scale with the field level and rest with the pulses', (t) => { + t.after(clearAwarenessEchoField); + const center = Cesium.Cartesian3.fromDegrees(3, 27, 0); + // ~100 km east of the subject, on the ground. + const contact = Cesium.Cartesian3.fromDegrees(4.008, 27, 0); + let level = 1; + setAwarenessEchoField({ + center, + radiusM: R, + pulse, + echo, + level: () => level, + }); + const d = Cesium.Cartesian3.distance(center, contact); + const t0 = arrival(d); + const full = awarenessEchoAt('flights', contact, t0); + assert.ok(full > 0.99); + level = 0.5; + assert.ok( + Math.abs(awarenessEchoAt('flights', contact, t0) - full / 2) < 1e-9, + ); + level = 0; + assert.equal(awarenessEchoAt('flights', contact, t0), 0); +}); diff --git a/src/layers/awareness/lifecycle.js b/src/layers/awareness/lifecycle.js index 925a3a9ca..27a7d0e55 100644 --- a/src/layers/awareness/lifecycle.js +++ b/src/layers/awareness/lifecycle.js @@ -62,6 +62,8 @@ export function createLifecycle({ if (poseSig !== layerState.lastCameraPoseSig) { layerState.lastCameraPoseSig = poseSig; layerState.lastCameraPoseChangeMs = now; + // A camera move runs the range pulses again; parked, they rest. + parts.rendering.wakePulses(); } const decision = parts.model.awarenessRefreshDecision({ nowMs: now, diff --git a/src/layers/awareness/policy.js b/src/layers/awareness/policy.js index a0af9e3d7..66485c4eb 100644 --- a/src/layers/awareness/policy.js +++ b/src/layers/awareness/policy.js @@ -50,9 +50,11 @@ export const CONTEXT_RIM_HEIGHT_M = 2500; * 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. - * 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. */ + * While the sonar pulses run, the ring dims to `restOpacity` and flashes + * to full as each pulse reaches it, fading over `flashMs`. `restOpacity` + * keeps the 250 km edge readable between flashes, in screenshots and for + * low-vision viewers. Once the pulses rest, and without pulses (reduced + * motion, or a scene that cannot animate), it stays on screen at full. */ export const AWARENESS_RANGE_RING_STYLE = Object.freeze({ color: 'rgba(98, 181, 255, 0.95)', @@ -61,7 +63,7 @@ export const AWARENESS_RANGE_RING_STYLE = Object.freeze({ outlineWidth: 1, segments: 128, flashMs: 1800, - restOpacity: 0, + restOpacity: 0.4, }); /** @constant Sonar-style pulses that grow slowly from the subject to the @@ -75,11 +77,16 @@ export const AWARENESS_RANGE_RING_STYLE = Object.freeze({ * `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. */ + * The pulses run for `activeMs` (three ring flashes) after a selection or + * a camera move, fading in and out over `settleMs`, then rest: the ring + * stays on screen, static, and nothing asks for frames, so a parked view + * goes quiet again. A moving subject does not wake them, since a flying + * subject moves on every refresh, but a camera that follows it does; the + * follow already renders every frame. While they run they never hold + * continuous rendering; each drawn frame arms one more request after the + * Cyber sonar's 80 ms cadence, so the two never beat. When something else + * already renders continuously they update every frame. Skipped under + * prefers-reduced-motion. */ export const AWARENESS_RANGE_PULSE = Object.freeze({ count: 3, @@ -96,7 +103,8 @@ export const AWARENESS_RANGE_PULSE = Object.freeze({ gradientOpacity: 0.22, gradientGranularityDeg: 0.25, gradientRebuildM: 2000, - frameIntervalMs: 80, + activeMs: 9000, + settleMs: 1200, }); /** @constant Sonar echoes on contact brackets. When a pulse front reaches a diff --git a/src/layers/awareness/rangeRing.test.mjs b/src/layers/awareness/rangeRing.test.mjs index 655bdc080..8b56685f7 100644 --- a/src/layers/awareness/rangeRing.test.mjs +++ b/src/layers/awareness/rangeRing.test.mjs @@ -16,14 +16,18 @@ import { AWARENESS_RANGE_RING_STYLE, CONTEXT_RIM_HEIGHT_M, } from './policy.js'; +import { CYBER_SONAR_RENDER_INTERVAL_MS } from '../../cyberSonar.js'; import { AWARENESS_PULSE_GRADIENT_MAX, + awarenessPulseLevel, awarenessPulseState, awarenessRangeRingPositions, awarenessRingFlash, createRendering, + wakeAwarenessPulses, } from './rendering.js'; import { getAwarenessEchoField } from '../../awarenessEcho.js'; +import { createLifecycle } from './lifecycle.js'; import { createState } from './state.js'; /** WCAG relative luminance of a Cesium colour, ignoring alpha. */ @@ -39,22 +43,48 @@ function luminance(color) { /** * 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. + * by reason. `reducedMotion` stubs the media query for the length of the + * test; `motion.set(on)` flips it later and tells its listeners, as the + * browser does. */ function ringRendering(t, { reducedMotion = false } = {}) { const previous = globalThis.matchMedia; - globalThis.matchMedia = (query) => ({ - matches: query === '(prefers-reduced-motion: reduce)' && reducedMotion, - }); + const listeners = new Set(); + const query = { + get matches() { + return reducedMotion; + }, + addEventListener(type, listener) { + if (type === 'change') listeners.add(listener); + }, + removeEventListener(type, listener) { + if (type === 'change') listeners.delete(listener); + }, + }; + globalThis.matchMedia = (media) => + media === '(prefers-reduced-motion: reduce)' + ? query + : { matches: false, addEventListener() {}, removeEventListener() {} }; t.after(() => { globalThis.matchMedia = previous; }); + const motion = { + set(on) { + reducedMotion = on; + for (const listener of [...listeners]) listener({ matches: on }); + }, + /** Change the preference without a change event, as old browsers do. */ + setQuietly(on) { + reducedMotion = on; + }, + listeners: () => listeners.size, + }; const state = createState({ services: {} }); const entities = new Cesium.EntityCollection(); const scene = { primitives: new Cesium.PrimitiveCollection(), preRender: new Cesium.Event(), + postRender: new Cesium.Event(), }; state.viewer = { entities, scene }; const reasons = []; @@ -77,11 +107,42 @@ function ringRendering(t, { reducedMotion = false } = {}) { entities, scene, rendering, + motion, + reasons, renders: () => reasons.filter((r) => r === 'awareness-visual').length, pulseFrames: () => reasons.filter((r) => r === 'awareness-pulse').length, }; } +/** + * A request-render scene on a fake clock: `performance.now()` and the + * timers advance together, and any render ask is drawn (preRender, then + * postRender) on the next millisecond, as Cesium's request-render mode does. + * `draw()` draws a frame someone else asked for. + */ +function frameLoop(t, ring) { + let clock = 1_000_000; + t.mock.method(performance, 'now', () => clock); + t.mock.timers.enable({ apis: ['setTimeout', 'setInterval'] }); + let drawn = 0; + const draw = () => { + drawn = ring.reasons.length; + ring.scene.preRender.raiseEvent(ring.scene); + ring.scene.postRender.raiseEvent(ring.scene); + }; + return { + now: () => clock, + draw, + advance(ms) { + for (let step = 0; step < ms; step++) { + clock++; + t.mock.timers.tick(1); + if (ring.reasons.length > drawn) draw(); + } + }, + }; +} + /** The pulse gradient disc and the echo collection among scene primitives. */ function pulsePrimitives(scene) { const all = Array.from({ length: scene.primitives.length }, (_, i) => @@ -284,15 +345,17 @@ test('a pulse grows slowly from the subject and arrives dim at the ring', () => ); }); -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); +test('pulses ask for a frame 80 ms after each drawn frame instead of holding continuous rendering', (t) => { + const ring = ringRendering(t); + const { entities, rendering, pulseFrames } = ring; + const loop = frameLoop(t, ring); rendering.renderVisual(flight(3)); - // A parked Contacts view repaints at the Cyber sonar cadence, ~12.5 fps, - // never every display frame. + // A Contacts view repaints at the Cyber sonar cadence, ~12.5 fps, while + // the pulses run, never every display frame. assert.equal(pulseFrames(), 0); - t.mock.timers.tick(AWARENESS_RANGE_PULSE.frameIntervalMs * 10); - assert.equal(pulseFrames(), 10); + loop.advance(1); // the frame the new ring asked for arms the first request + loop.advance(CYBER_SONAR_RENDER_INTERVAL_MS * 10); + assert.ok(Math.abs(pulseFrames() - 10) <= 1, `${pulseFrames()} frames`); const now = Cesium.JulianDate.now(); const core = Cesium.Color.fromCssColorString( AWARENESS_RANGE_RING_STYLE.color, @@ -316,15 +379,167 @@ test('pulses ask for a frame every 80 ms instead of holding continuous rendering ); assert.ok(Math.max(...radii) <= AWARENESS_RADIUS_M + 100); } + const frames = pulseFrames(); 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'); + assert.equal(ring.scene.postRender.numberOfListeners, 0); + loop.advance(CYBER_SONAR_RENDER_INTERVAL_MS * 10); + assert.equal(pulseFrames(), frames, 'no frames once the ring is gone'); +}); + +test('a parked view stops asking for frames once the pulses rest', (t) => { + const ring = ringRendering(t); + const { entities, scene, rendering, pulseFrames } = ring; + const loop = frameLoop(t, ring); + const { activeMs, count, periodMs } = AWARENESS_RANGE_PULSE; + // Long enough for three ring flashes, then quiet. + assert.ok(activeMs >= 3 * (periodMs / count)); + rendering.renderVisual(flight(3)); + loop.advance(activeMs + 500); + const frames = pulseFrames(); + assert.ok(frames >= activeMs / CYBER_SONAR_RENDER_INTERVAL_MS - 2); + // Parked: a minute later not one more request. + loop.advance(60_000); + assert.equal(pulseFrames(), frames, 'render requests stop after the bound'); + // At rest the ring stands on screen at full, as without pulses, and the + // pulses, gradient and echoes are hidden. + const now = Cesium.JulianDate.now(); + const core = Cesium.Color.fromCssColorString( + AWARENESS_RANGE_RING_STYLE.color, + ); + const [ringEntity, ...pulses] = entities.values; + assert.ok( + Math.abs( + ringEntity.polyline.material.color.getValue(now).alpha - core.alpha, + ) < 1e-9, + ); + for (const pulse of pulses) + assert.equal(pulse.polyline.show.getValue(now), false); + assert.equal(pulsePrimitives(scene).disc.show, false); + assert.equal(getAwarenessEchoField().level(loop.now()), 0); +}); + +test('a camera move wakes resting pulses for another bounded run', (t) => { + const ring = ringRendering(t); + const { rendering, pulseFrames } = ring; + const loop = frameLoop(t, ring); + const { activeMs } = AWARENESS_RANGE_PULSE; + rendering.renderVisual(flight(3)); + loop.advance(activeMs + 500); + const rested = pulseFrames(); + // The camera moves: lifecycle wakes the pulses inside the frame it draws, + // and that frame arms the next request. Waking asks for none of its own. + rendering.wakePulses(); + assert.equal(pulseFrames(), rested); + loop.draw(); + loop.advance(activeMs + 500); + const woke = pulseFrames() - rested; + assert.ok(woke >= activeMs / CYBER_SONAR_RENDER_INTERVAL_MS - 2, `${woke}`); + loop.advance(60_000); + assert.equal(pulseFrames(), rested + woke, 'and rest again'); + // The subject moving does not wake them: a flying subject moves on every + // refresh, which would bring the endless repaint back. + rendering.renderVisual(flight(3.01)); + loop.advance(10_000); + assert.equal(pulseFrames(), rested + woke); +}); + +test('a camera move seen by the lifecycle wakes resting pulses; a parked camera does not', (t) => { + const ring = ringRendering(t); + const { state, rendering, pulseFrames } = ring; + const loop = frameLoop(t, ring); + const { activeMs } = AWARENESS_RANGE_PULSE; + state.enabled = true; + state.viewer.camera = { + positionWC: Cesium.Cartesian3.fromDegrees(3, 20, 800_000), + heading: 0, + pitch: -1, + roll: 0, + }; + const lifecycle = createLifecycle({ + state, + services: { + render: { holdContinuousRender() {}, releaseContinuousRender() {} }, + }, + parts: { + rendering, + model: { + awarenessRefreshDecision: () => ({ moving: false, refresh: false }), + awarenessNeedsContinuousRender: () => false, + }, + }, + }); + lifecycle.attachRuntimeListeners(); + t.after(() => lifecycle.detachRuntimeListeners()); + rendering.renderVisual(flight(3)); + loop.advance(activeMs + 500); + const rested = pulseFrames(); + // Parked: frames other owners draw do not wake the pulses. + loop.draw(); + loop.advance(activeMs); + assert.equal(pulseFrames(), rested); + // The camera pans 50 km: the next frame wakes them for another run. + state.viewer.camera.positionWC = Cesium.Cartesian3.fromDegrees( + 3.5, + 20, + 800_000, + ); + loop.draw(); + loop.advance(activeMs + 500); + assert.ok( + pulseFrames() - rested >= activeMs / CYBER_SONAR_RENDER_INTERVAL_MS - 2, + ); + const woke = pulseFrames(); + loop.advance(60_000); + assert.equal(pulseFrames(), woke, 'and rest again once it parks'); +}); + +test('pulse requests wait 80 ms after any frame, so they cannot beat the Cyber sonar', (t) => { + const ring = ringRendering(t); + const { rendering, pulseFrames } = ring; + const loop = frameLoop(t, ring); + rendering.renderVisual(flight(3)); + loop.advance(1); + const frames = pulseFrames(); + // Another owner (the Cyber sonar, a data update) draws 67 ms after ours. + loop.advance(67); + loop.draw(); + loop.advance(CYBER_SONAR_RENDER_INTERVAL_MS - 1); + assert.equal(pulseFrames(), frames, 'no request 13 ms after that frame'); + loop.advance(1); + assert.equal(pulseFrames(), frames + 1, 'one request 80 ms after it'); +}); + +test('the pulse level fades in, holds, fades out and keeps its level when woken', () => { + const { activeMs, settleMs } = AWARENESS_RANGE_PULSE; + const wake = wakeAwarenessPulses( + { startMs: -Infinity, untilMs: -Infinity }, + 0, + ); + assert.equal(awarenessPulseLevel(0, wake), 0); + assert.ok(Math.abs(awarenessPulseLevel(settleMs / 2, wake) - 0.5) < 1e-9); + assert.equal(awarenessPulseLevel(activeMs / 2, wake), 1); + assert.ok( + Math.abs(awarenessPulseLevel(activeMs - settleMs / 4, wake) - 0.25) < 1e-9, + ); + assert.equal(awarenessPulseLevel(activeMs, wake), 0); + assert.equal(awarenessPulseLevel(activeMs * 10, wake), 0); + // Woken mid-fade-out: no jump back to full, then a fresh run. + const at = activeMs - settleMs / 4; + wakeAwarenessPulses(wake, at); + assert.ok(Math.abs(awarenessPulseLevel(at, wake) - 0.25) < 1e-9); + assert.equal(awarenessPulseLevel(at + settleMs, wake), 1); + assert.equal(awarenessPulseLevel(at + activeMs, wake), 0); }); 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]) { + for (const scene of [ + {}, + { primitives: { add() {} } }, + { primitives: { add() {} }, preRender: new Cesium.Event() }, + undefined, + ]) { state.viewer = { entities, scene }; rendering.clearVisual(); rendering.renderVisual(flight(3)); @@ -336,7 +551,9 @@ test('a scene without primitives or preRender gets the static ring only', (t) => }); test('a gradient disc behind the pulses fades inward and follows the subject', (t) => { - const { scene, rendering } = ringRendering(t); + const ring = ringRendering(t); + const { scene, rendering } = ring; + const loop = frameLoop(t, ring); rendering.renderVisual(flight(3)); assert.ok(AWARENESS_RANGE_PULSE.count <= AWARENESS_PULSE_GRADIENT_MAX); const { disc, count } = pulsePrimitives(scene); @@ -357,26 +574,20 @@ test('a gradient disc behind the pulses fades inward and follows the subject', ( ) < 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. + // opacity into the shader, from the same curve the lines use, once the + // pulses have faded in. assert.equal(scene.preRender.numberOfListeners, 1); - const before = performance.now(); + loop.advance(AWARENESS_RANGE_PULSE.settleMs + 500); scene.preRender.raiseEvent(scene); - const after = performance.now(); + assert.equal(disc.show, true); 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, - ); + const expected = awarenessPulseState(loop.now(), index); + assert.ok(Math.abs(radius - expected.radiusM / AWARENESS_RADIUS_M) < 1e-6); 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, - ); + assert.ok(Math.abs(alpha - expected.alpha) < 1e-6); } // Unused shader slots stay empty. assert.equal( @@ -425,7 +636,7 @@ test('pulses evaluate every frame without breaking, reusing their positions', (t }); test('reduced motion keeps the static ring and drops the pulses', (t) => { - t.mock.timers.enable({ apis: ['setInterval'] }); + t.mock.timers.enable({ apis: ['setTimeout', 'setInterval'] }); const { entities, scene, rendering, pulseFrames } = ringRendering(t, { reducedMotion: true, }); @@ -436,16 +647,58 @@ test('reduced motion keeps the static ring and drops the pulses', (t) => { assert.equal(pulseFrames(), 0); assert.equal(scene.primitives.length, 0); assert.equal(scene.preRender.numberOfListeners, 0); + assert.equal(scene.postRender.numberOfListeners, 0); +}); + +test('turning reduced motion on mid-session stops the pulses, and off restarts them', (t) => { + const ring = ringRendering(t); + const { entities, scene, rendering, motion, state } = ring; + const loop = frameLoop(t, ring); + state.subject = flight(3); + rendering.renderVisual(state.subject); + assert.equal(entities.values.length, 1 + AWARENESS_RANGE_PULSE.count); + loop.advance(1000); + // The OS setting changes while the same aircraft stays tracked. + motion.set(true); + assert.equal(entities.values.length, 1); + assert.equal(entities.values[0].polyline.material.color.isConstant, true); + assert.equal(scene.primitives.length, 0); + assert.equal(scene.preRender.numberOfListeners, 0); + assert.equal(scene.postRender.numberOfListeners, 0); + assert.equal(getAwarenessEchoField(), null); + const frames = ring.pulseFrames(); + loop.advance(5000); + assert.equal(ring.pulseFrames(), frames); + motion.set(false); + assert.equal(entities.values.length, 1 + AWARENESS_RANGE_PULSE.count); + assert.ok(getAwarenessEchoField()); + // One media-query listener per visual; none once the ring is gone. + assert.equal(motion.listeners(), 1); + rendering.clearVisual(); + assert.equal(motion.listeners(), 0); +}); + +test('a refresh after reduced motion changed rebuilds the visual too', (t) => { + const { entities, rendering, motion } = ringRendering(t); + rendering.renderVisual(flight(3)); + assert.equal(entities.values.length, 1 + AWARENESS_RANGE_PULSE.count); + // No change event: the next same-subject refresh notices on its own. + motion.setQuietly(true); + rendering.renderVisual(flight(3)); + assert.equal(entities.values.length, 1); + assert.equal(entities.values[0].polyline.positions.isConstant, true); }); test('while pulses run the ring flashes as each one arrives, then rests', (t) => { - const { entities, rendering } = ringRendering(t); + const ring = ringRendering(t); + const { entities, rendering } = ring; + const loop = frameLoop(t, ring); rendering.renderVisual(flight(3)); - const ring = entities.values[0]; + const ringEntity = entities.values[0]; const core = Cesium.Color.fromCssColorString( AWARENESS_RANGE_RING_STYLE.color, ); - assert.equal(ring.polyline.material.color.isConstant, false); + assert.equal(ringEntity.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. @@ -453,7 +706,8 @@ test('while pulses run the ring flashes as each one arrives, then rests', (t) => 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); + // Between flashes the 250 km edge stays readable, dimmed, not gone. + assert.ok(restOpacity >= 0.35 && restOpacity <= 0.5, `${restOpacity}`); // Once per arriving pulse: `count` flashes in any one-period window that // starts after a flash has faded. let flashes = 0; @@ -465,16 +719,18 @@ test('while pulses run the ring flashes as each one arrives, then rests', (t) => 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, - ); + // Once the pulses have faded in, the live colour tracks the flash, and + // never drops below `restOpacity`. + loop.advance(AWARENESS_RANGE_PULSE.settleMs); + for (let step = 0; step < 40; step++) { + loop.advance(100); + const alpha = ringEntity.polyline.material.color.getValue( + Cesium.JulianDate.now(), + ).alpha; + const expected = + core.alpha * Math.max(restOpacity, awarenessRingFlash(loop.now())); + assert.ok(Math.abs(alpha - expected) < 1e-9, `${alpha} vs ${expected}`); + } }); test('without pulses the ring stays on screen at full strength', (t) => { diff --git a/src/layers/awareness/rendering.js b/src/layers/awareness/rendering.js index ca22bacbd..b8197f60b 100644 --- a/src/layers/awareness/rendering.js +++ b/src/layers/awareness/rendering.js @@ -1,6 +1,7 @@ import * as Cesium from 'cesium'; import { t } from '../../i18n/index.js'; import { + CYBER_SONAR_RENDER_INTERVAL_MS, createCyberSonarSampler, isCyberContactSonarActive as isCyberSonarActive, } from '../../cyberSonar.js'; @@ -112,12 +113,51 @@ export function awarenessPulseState( }; } +const REDUCED_MOTION_QUERY = '(prefers-reduced-motion: reduce)'; + /** Whether the viewer asked the system for less motion. */ function prefersReducedMotion() { - return ( - globalThis.matchMedia?.('(prefers-reduced-motion: reduce)')?.matches ?? - false + return globalThis.matchMedia?.(REDUCED_MOTION_QUERY)?.matches ?? false; +} + +/** + * How far the pulses are switched on at `nowMs`: 0 at rest, rising to 1 over + * `settleMs` from `wake.startMs` and falling back to 0 over `settleMs` up to + * `wake.untilMs`. + * @param {number} nowMs Monotonic clock in milliseconds. + * @param {{startMs: number, untilMs: number}} wake Window from `wakeAwarenessPulses`. + * @param {object} [pulse] Timing, see `AWARENESS_RANGE_PULSE`. + * @returns {number} 0 to 1. + */ +export function awarenessPulseLevel( + nowMs, + wake, + pulse = AWARENESS_RANGE_PULSE, +) { + const level = Math.min( + (nowMs - wake.startMs) / pulse.settleMs, + (wake.untilMs - nowMs) / pulse.settleMs, ); + return Math.max(0, Math.min(1, level)); +} + +/** + * Run the pulses for `activeMs` from `nowMs`. A wake while they already run + * keeps their current level, so they never jump back to full mid-fade. + * @param {{startMs: number, untilMs: number}} wake Updated in place. + * @param {number} nowMs Monotonic clock in milliseconds. + * @param {object} [pulse] Timing, see `AWARENESS_RANGE_PULSE`. + * @returns {{startMs: number, untilMs: number}} `wake`. + */ +export function wakeAwarenessPulses( + wake, + nowMs, + pulse = AWARENESS_RANGE_PULSE, +) { + wake.startMs = + nowMs - awarenessPulseLevel(nowMs, wake, pulse) * pulse.settleMs; + wake.untilMs = nowMs + pulse.activeMs; + return wake; } /** @@ -457,9 +497,19 @@ export function createRendering({ presentDirection({ hidden: false, ring, heading, labels, markers }); } + /** + * Run the range pulses again for `activeMs`, after a camera move. Call it + * during a frame (lifecycle's preRender): that frame's postRender arms the + * next request, so waking asks for no extra frame of its own. + */ + function wakePulses() { + const wake = layerState.visual?.wake; + if (wake) wakeAwarenessPulses(wake, performance.now()); + } + function clearVisual() { - globalThis.clearInterval(layerState.visual?.pulseTimer); - layerState.visual?.removePulseListener?.(); + layerState.visual?.stopPulses?.(); + layerState.visual?.removeMotionListener?.(); if (layerState.visual?.gradient && layerState.viewer) layerState.viewer.scene.primitives.remove(layerState.visual.gradient); clearAwarenessEchoField(); @@ -482,7 +532,12 @@ export function createRendering({ 0, ); const key = `${subject.layerId}:${subject.id}`; - if (layerState.visual?.key === key) { + const reducedMotion = prefersReducedMotion(); + // A reduced-motion change rebuilds the visual, with or without pulses. + if ( + layerState.visual?.key === key && + layerState.visual.reducedMotion === reducedMotion + ) { // Move the ring only after a meaningful displacement so live tracking // does not rebuild its geometry for sub-pixel motion every 750 ms. if ( @@ -527,10 +582,19 @@ export function createRendering({ // 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() && + !reducedMotion && scene?.primitives?.add && - scene?.preRender?.addEventListener, + scene?.preRender?.addEventListener && + scene?.postRender?.addEventListener, ); + // Pulses run from now until `wake.untilMs`, then rest. + const wake = animate + ? wakeAwarenessPulses( + { startMs: -Infinity, untilMs: -Infinity }, + performance.now(), + ) + : null; + const levelAt = (nowMs) => (wake ? awarenessPulseLevel(nowMs, wake) : 0); // A polyline, not an ellipse outline: WebGL draws ellipse outlines at // 1 px whatever `outlineWidth` says, and the ring needs a halo. @@ -538,12 +602,13 @@ export function createRendering({ 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. + // dims to `restOpacity`; as they settle it returns to full and stays. 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); + const nowMs = performance.now(); + const pulsed = Math.max(ring.restOpacity, awarenessRingFlash(nowMs)); + color.alpha = base.alpha * (1 - levelAt(nowMs) * (1 - pulsed)); return color; }, false); }; @@ -569,18 +634,26 @@ export function createRendering({ const center = Cesium.Cartesian3.clone(groundCenter); let gradient = null; - let removePulseListener = null; - let pulseTimer = null; + let stopPulses = 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); + const stateAt = () => { + const nowMs = performance.now(); + const state = awarenessPulseState(nowMs, index); + state.alpha *= levelAt(nowMs); + return state; + }; entities.push( layerState.viewer.entities.add({ polyline: { + show: new Cesium.CallbackProperty( + () => levelAt(performance.now()) > 0, + false, + ), positions: new Cesium.CallbackProperty( () => awarenessRangeRingPositions( @@ -626,16 +699,40 @@ export function createRendering({ ); const radii = [0, 0, 0, 0]; const alphas = [0, 0, 0, 0]; - removePulseListener = scene.preRender.addEventListener(() => { + const removePreRender = scene.preRender.addEventListener(() => { const nowMs = performance.now(); + const level = levelAt(nowMs); + // The disc is looked up each frame: a subject move swaps it. + const disc = layerState.visual?.gradient; + if (disc) disc.show = level > 0; for (let index = 0; index < count; index++) { const state = awarenessPulseState(nowMs, index); radii[index] = state.radiusM / AWARENESS_RADIUS_M; - alphas[index] = state.alpha; + alphas[index] = state.alpha * level; } Cesium.Cartesian4.fromArray(radii, 0, material.uniforms.radii); Cesium.Cartesian4.fromArray(alphas, 0, material.uniforms.alphas); }); + // Each drawn frame, whoever asked for it, arms one request a Cyber + // sonar interval later, as the Cyber sonar scene does. Requests never + // land within 80 ms of a frame, so they cannot beat the detection + // lane's 80 ms gate, and they stop once the pulses rest. + let timer = null; + const removePostRender = scene.postRender.addEventListener(() => { + globalThis.clearTimeout(timer); + timer = null; + if (performance.now() < wake.untilMs) + timer = globalThis.setTimeout(() => { + timer = null; + governorRequestRender('awareness-pulse'); + }, CYBER_SONAR_RENDER_INTERVAL_MS); + }); + stopPulses = () => { + removePreRender(); + removePostRender(); + globalThis.clearTimeout(timer); + timer = null; + }; // The detection overlay pulses the brackets of flights and vessels a // front crosses; it reads the centre in place, so it follows too. setAwarenessEchoField({ @@ -643,13 +740,17 @@ export function createRendering({ radiusM: AWARENESS_RADIUS_M, pulse, echo: AWARENESS_RANGE_ECHO, + level: levelAt, }); - pulseTimer = globalThis.setInterval( - () => governorRequestRender('awareness-pulse'), - pulse.frameIntervalMs, - ); } + // Turning reduced motion on or off mid-session rebuilds the visual. + const motionQuery = globalThis.matchMedia?.(REDUCED_MOTION_QUERY); + const onMotionChange = () => { + if (layerState.subject) renderVisual(layerState.subject); + }; + motionQuery?.addEventListener?.('change', onMotionChange); + layerState.visual = { key, entities, @@ -657,8 +758,11 @@ export function createRendering({ center, gradient, gradientCenter: Cesium.Cartesian3.clone(groundCenter), - removePulseListener, - pulseTimer, + reducedMotion, + wake, + stopPulses, + removeMotionListener: () => + motionQuery?.removeEventListener?.('change', onMotionChange), }; governorRequestRender('awareness-visual'); } @@ -672,5 +776,6 @@ export function createRendering({ updateDirectionOverlay, clearVisual, renderVisual, + wakePulses, }; } From 013cfdf91da93466853d022dd3b78570ee89c741 Mon Sep 17 00:00:00 2001 From: Manjunath Date: Tue, 3 Nov 2026 19:44:09 +0530 Subject: [PATCH 04/12] fix(awareness): keep range pulses out of picking and in the style palette Review follow-up for the sonar pulses (the non-blocking items). Unpickable pulses: the three pulse lines were polyline entities, which Cesium always picks, so near-miss and touch picks around them became unowned hits. They are now drawn by the pulse disc's shader, which already drew the gradient and is built with allowPicking false. Each line keeps the polyline outline material's look: 3 CSS px wide with a 1 px dark outline in the ring's colours, measured from the screen-space gradient of the disc's radius times czm_pixelRatio, as Cesium's contour material does. Without derivatives only the gradient band is drawn. Disc follow: with the lines on the disc, it is rebuilt after the subject moves 500 m instead of 2 km, so the pulses stay concentric with the ring. A resting disc is hidden and not rebuilt; it catches up when the pulses wake. Camera below the disc: the disc sits 2.5 km above the ellipsoid with faceForward, so from a lower camera it swept across the sky. It is now hidden while the camera is below that height. Echo palette: echo brackets keep the pulse blue on the default detection style and take the style's line colour under NVG, FLIR and CRT. Echo test: the detection renderer's echo maths moves into awarenessEchoBracket and awarenessEchoColor in src/awarenessEcho.js, tested directly instead of running a regex slice of detection.js through vm, and the one-sided spread assert is now two-sided. The positions reuse parameter of awarenessRangeRingPositions and the pulse segment count had no callers left and are removed. --- docs/CURRENT-STATE.md | 2 +- src/awarenessEcho.js | 41 ++++++ src/data/detection.js | 35 +++-- src/data/detectionAwarenessEcho.test.mjs | 124 ++++++++--------- src/layers/awareness/policy.js | 19 ++- src/layers/awareness/rangeRing.test.mjs | 147 +++++++++++--------- src/layers/awareness/rendering.js | 169 ++++++++++++----------- 7 files changed, 303 insertions(+), 234 deletions(-) diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index 5b85500ae..764d9df28 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. 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 dims to 40% and flashes to full strength as each pulse reaches it, fading 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 run for 9 s (three ring flashes) after a selection or a camera move, fading in and out over 1.2 s, then rest: the ring stays on screen at full strength, the pulses, gradient and echoes are hidden, and nothing asks for frames, so a parked Contacts view is quiet again. A moving subject does not wake them, since a flying subject moves on every refresh; a camera that follows a tracked aircraft does keep them running, but that follow already renders every frame. While they run they never hold continuous rendering: each drawn frame arms one render request 80 ms later (the Cyber sonar cadence, `CYBER_SONAR_RENDER_INTERVAL_MS`), so they repaint at about 12.5 fps and never beat the detection lane's 80 ms gate. Under `prefers-reduced-motion`, or in a scene without primitives or preRender and postRender hooks, there are no pulses or echoes and the ring stays on screen; changing the preference mid-session rebuilds the ring. 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 with a 1 px dark outline, 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. One unpickable 250 km disc draws both the pulse lines and the gradient, its shader reading the pulse radii and opacities each frame, so a click or tap near a pulse still reaches the contact underneath; the disc is rebuilt only after the subject moves 500 m, and not at all while the pulses rest, and it is hidden while the camera is below its 2.5 km height, where it would sweep across the sky. While pulses run, the ring dims to 40% and flashes to full strength as each pulse reaches it, fading over 1.8 s. As a pulse front crosses a flight, military aircraft or AIS vessel, the detection overlay draws a second bracket around that contact's own bracket, in the pulse blue on the default detection style and in the style's own line colour under NVG, FLIR and CRT, 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 run for 9 s (three ring flashes) after a selection or a camera move, fading in and out over 1.2 s, then rest: the ring stays on screen at full strength, the pulses, gradient and echoes are hidden, and nothing asks for frames, so a parked Contacts view is quiet again. A moving subject does not wake them, since a flying subject moves on every refresh; a camera that follows a tracked aircraft does keep them running, but that follow already renders every frame. While they run they never hold continuous rendering: each drawn frame arms one render request 80 ms later (the Cyber sonar cadence, `CYBER_SONAR_RENDER_INTERVAL_MS`), so they repaint at about 12.5 fps and never beat the detection lane's 80 ms gate. Under `prefers-reduced-motion`, or in a scene without primitives or preRender and postRender hooks, there are no pulses or echoes and the ring stays on screen; changing the preference mid-session rebuilds the ring. 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/src/awarenessEcho.js b/src/awarenessEcho.js index 5f8efba0f..ed197a517 100644 --- a/src/awarenessEcho.js +++ b/src/awarenessEcho.js @@ -109,3 +109,44 @@ export function awarenessEchoAt(layerId, position, nowMs) { ) ); } + +/** + * The echo bracket the detection overlay draws around one contact, or null + * when it has no echo. `band` is the alpha band, 1 to `steps`, scaled by the + * contact's own keyhole `admissionAlpha`; `spreadPx` is how far the echo + * sits outside the contact's bracket, 0 as the front arrives, growing to the + * field's `spreadPx` as it fades. + * @param {string} layerId Owning layer of the contact. + * @param {Cesium.Cartesian3} position Contact's world position. + * @param {number} nowMs Monotonic clock in milliseconds. + * @param {number} admissionAlpha The contact bracket's own fade, 0 to 1. + * @param {number} steps Alpha bands the overlay batches by. + * @returns {{band: number, spreadPx: number}|null} + */ +export function awarenessEchoBracket( + layerId, + position, + nowMs, + admissionAlpha, + steps, +) { + const echo = awarenessEchoAt(layerId, position, nowMs); + if (!(echo > 0.01)) return null; + return { + band: Math.max(1, Math.ceil(echo * admissionAlpha * steps)), + spreadPx: field.echo.spreadPx * (1 - echo), + }; +} + +/** + * The echo's stroke colour: the field's own colour on the default detection + * style, and the style's line colour under the others (NVG, FLIR, CRT), so + * an echo never stands out of a monochrome palette. + * @param {string} styleLine The active detection style's line colour. + * @param {boolean} defaultStyle Whether the default detection style is active. + * @returns {string|null} Null when no field is set. + */ +export function awarenessEchoColor(styleLine, defaultStyle) { + if (!field) return null; + return defaultStyle ? field.echo.color : styleLine; +} diff --git a/src/data/detection.js b/src/data/detection.js index 018d3d348..de896e046 100644 --- a/src/data/detection.js +++ b/src/data/detection.js @@ -53,7 +53,11 @@ import { isCyberContactSonarActive as isCyberSonarActive, isCyberContactThemeActive as isCyberMapThemeActive, } from '../cyberSonar.js'; -import { awarenessEchoAt, getAwarenessEchoField } from '../awarenessEcho.js'; +import { + awarenessEchoBracket, + awarenessEchoColor, + getAwarenessEchoField, +} from '../awarenessEcho.js'; /** * @module detection @@ -1445,22 +1449,22 @@ function _drawOverlay(frame) { ); visibleCount++; if (echoPaths) { - const echo = awarenessEchoAt(obj._layerId, obj.position, now); - if (echo > 0.01) { - // Starts on the contact's bracket and spreads outward as it fades. - const band = Math.max( - 1, - Math.ceil(echo * admissionAlpha * ECHO_ALPHA_STEPS), - ); - const spread = echoField.echo.spreadPx * (1 - echo); + // Starts on the contact's bracket and spreads outward as it fades. + const echo = awarenessEchoBracket( + obj._layerId, + obj.position, + now, + admissionAlpha, + ECHO_ALPHA_STEPS, + ); + if (echo) appendCornerBracket( - (echoPaths[band] ??= new Path2D()), + (echoPaths[echo.band] ??= new Path2D()), sx, sy, - halfW + spread, - halfH + spread, + halfW + echo.spreadPx, + halfH + echo.spreadPx, ); - } } if (obj.type === 'AIR') aircraftBracketSectors[detectionHorizontalSector(sx, width)]++; @@ -1603,7 +1607,10 @@ function _drawOverlay(frame) { } if (echoPaths) { _ctx.lineWidth = echoField.echo.lineWidth; - _ctx.strokeStyle = echoField.echo.color; + _ctx.strokeStyle = awarenessEchoColor( + _theme.line, + _theme === THEME_MAP._default, + ); for (let band = 1; band <= ECHO_ALPHA_STEPS; band++) { if (!echoPaths[band]) continue; _ctx.globalAlpha = diff --git a/src/data/detectionAwarenessEcho.test.mjs b/src/data/detectionAwarenessEcho.test.mjs index 4c34672d5..a9c4fecc3 100644 --- a/src/data/detectionAwarenessEcho.test.mjs +++ b/src/data/detectionAwarenessEcho.test.mjs @@ -1,104 +1,96 @@ // src/data/detectionAwarenessEcho.test.mjs — Global Context sonar echoes pulse // a contact's own detection bracket: a second bracket that starts on it and -// spreads outward as it fades. Exercises the renderer's actual echo block. +// spreads outward as it fades, in the detection style's own colour. import test from 'node:test'; import assert from 'node:assert/strict'; -import { readFileSync } from 'node:fs'; -import vm from 'node:vm'; import * as Cesium from 'cesium'; import { - awarenessEchoAt, + awarenessEchoBracket, + awarenessEchoColor, clearAwarenessEchoField, setAwarenessEchoField, } from '../awarenessEcho.js'; +import { DETECTION_THEME_MAP } from '../overlays/worldOverlayTokens.js'; -const source = readFileSync(new URL('./detection.js', import.meta.url), 'utf8'); -const echoBlock = source.match( - /if \(echoPaths\) \{\s*const echo = awarenessEchoAt[\s\S]*?\n {6}\}\n/, -)?.[0]; - +const STEPS = 8; const pulse = { count: 3, periodMs: 9000, startFraction: 0.02 }; const echo = { fadeMs: 1400, layerIds: ['flights', 'military', 'ais-live-vessels'], spreadPx: 8, + color: 'rgb(98, 181, 255)', }; +const center = Cesium.Cartesian3.fromDegrees(3, 27, 0); +const field = { center, radiusM: 250_000, pulse, echo }; -/** Run the renderer's echo block for one contact and record the bracket. */ -function paintEcho(field, obj, now, { admissionAlpha = 1 } = {}) { - const brackets = []; - const state = { - echoPaths: field ? new Array(9) : null, - echoField: field, - ECHO_ALPHA_STEPS: 8, - awarenessEchoAt, - Path2D: class {}, - appendCornerBracket: (sink, sx, sy, halfW, halfH) => - brackets.push({ sink, sx, sy, halfW, halfH }), - obj, - now, - admissionAlpha, - sx: 100, - sy: 50, - halfW: 9, - halfH: 7, - }; - vm.runInNewContext(echoBlock, state); - const band = state.echoPaths?.findIndex((path) => path) ?? -1; - return { brackets, band }; +/** The moment pulse 0's front reaches the ground under `position`. */ +function arrivalAt(position) { + const ground = Cesium.Ellipsoid.WGS84.scaleToGeodeticSurface(position); + const d = Cesium.Cartesian3.distance(center, ground); + return ( + ((d / 250_000 - pulse.startFraction) / (1 - pulse.startFraction)) * + pulse.periodMs + + pulse.periodMs * 3 + ); } test('the echo bracket pulses outward from the contact bracket and fades', (t) => { - assert.ok(echoBlock, 'renderer has an echo block'); t.after(clearAwarenessEchoField); - const center = Cesium.Cartesian3.fromDegrees(3, 27, 0); - const field = { center, radiusM: 250_000, pulse, echo }; setAwarenessEchoField(field); const position = Cesium.Cartesian3.fromDegrees(3.5, 27, 10_000); - const ground = Cesium.Cartesian3.fromDegrees(3.5, 27, 0); - const d = Cesium.Cartesian3.distance(center, ground); - const arrival = - ((d / 250_000 - pulse.startFraction) / (1 - pulse.startFraction)) * - pulse.periodMs + - pulse.periodMs * 3; - const obj = { _layerId: 'flights', position }; + const arrival = arrivalAt(position); + const at = (nowMs, admissionAlpha = 1) => + awarenessEchoBracket('flights', position, nowMs, admissionAlpha, STEPS); // The moment the front arrives: on the bracket, strongest band. - const fresh = paintEcho(field, obj, arrival); - assert.equal(fresh.brackets.length, 1); - assert.ok(fresh.brackets[0].halfW - 9 < 0.01); - assert.equal(fresh.band, 8); - // Half way through the fade: dimmer and spread outward. - const half = paintEcho(field, obj, arrival + echo.fadeMs / 2); - assert.ok(half.brackets[0].halfW > 9 + echo.spreadPx * 0.5); - assert.ok(half.brackets[0].halfH > 7 + echo.spreadPx * 0.5); - assert.equal(half.brackets[0].sx, 100); - assert.ok(half.band < 8 && half.band >= 1); + const fresh = at(arrival); + assert.ok(fresh); + assert.ok(Math.abs(fresh.spreadPx) < 0.01, `${fresh.spreadPx}`); + assert.equal(fresh.band, STEPS); + // Half way through the fade: dimmer and spread outward, but not past + // the field's spread. + const half = at(arrival + echo.fadeMs / 2); + assert.ok(half.spreadPx > echo.spreadPx * 0.5); + assert.ok(half.spreadPx < echo.spreadPx); + assert.ok(half.band < STEPS && half.band >= 1); // Keyhole fade carries over to the echo. - assert.ok(paintEcho(field, obj, arrival, { admissionAlpha: 0.25 }).band <= 2); + assert.ok(at(arrival, 0.25).band <= 2); // Faded out, or between fronts: no echo bracket. - assert.equal( - paintEcho(field, obj, arrival + echo.fadeMs + 5).brackets.length, - 0, - ); + assert.equal(at(arrival + echo.fadeMs + 5), null); }); test('no echo field, or a layer that does not echo, draws nothing extra', (t) => { - assert.ok(echoBlock); t.after(clearAwarenessEchoField); - const center = Cesium.Cartesian3.fromDegrees(3, 27, 0); const position = Cesium.Cartesian3.fromDegrees(3.01, 27, 0); - // No field: the renderer skips the echo block entirely. - assert.equal( - paintEcho(null, { _layerId: 'flights', position }, 0).brackets.length, - 0, - ); - const field = { center, radiusM: 250_000, pulse, echo }; + clearAwarenessEchoField(); + for (let now = 0; now < pulse.periodMs; now += 25) + assert.equal( + awarenessEchoBracket('flights', position, now, 1, STEPS), + null, + ); setAwarenessEchoField(field); for (let now = 0; now < pulse.periodMs; now += 25) for (const layerId of ['cctv', 'satellites', 'traffic']) assert.equal( - paintEcho(field, { _layerId: layerId, position }, now).brackets.length, - 0, + awarenessEchoBracket(layerId, position, now, 1, STEPS), + null, ); }); + +test('echoes take the detection style colour except on the default style', (t) => { + t.after(clearAwarenessEchoField); + assert.equal( + awarenessEchoColor(DETECTION_THEME_MAP._default.line, true), + null, + ); + setAwarenessEchoField(field); + assert.equal( + awarenessEchoColor(DETECTION_THEME_MAP._default.line, true), + echo.color, + ); + // CRT, NVG and FLIR keep their monochrome palettes. + for (const style of ['retro', 'surveillance', 'thermal']) { + const { line } = DETECTION_THEME_MAP[style]; + assert.equal(awarenessEchoColor(line, false), line, style); + } +}); diff --git a/src/layers/awareness/policy.js b/src/layers/awareness/policy.js index 66485c4eb..9acabb822 100644 --- a/src/layers/awareness/policy.js +++ b/src/layers/awareness/policy.js @@ -72,11 +72,17 @@ export const AWARENESS_RANGE_RING_STYLE = Object.freeze({ * `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. + * is dim but still visible when it reaches the ring and the ring flashes. + * Each line is `width` CSS pixels wide, its outer `outlineWidth` a dark + * halo, in the ring's colours, as Cesium's polyline outline material draws. Behind each line a radial gradient in the same blue + * fades inward over `gradientDepthFraction` of the radius, at most + * `gradientOpacity` strong. One unpickable disc draws the lines and the + * gradient, so pulses never catch a click meant for a contact; its shader + * takes at most four pulses. The disc is rebuilt only after the subject + * moves `gradientRebuildM`, so the pulses sit at most that far off the ring's + * centre, about a pixel when the ring fills the screen; while the pulses + * rest the hidden disc is left alone and catches up when they wake. The disc is hidden + * while the camera is below its height, where it would sweep the sky. * The pulses run for `activeMs` (three ring flashes) after a selection or * a camera move, fading in and out over `settleMs`, then rest: the ring * stays on screen, static, and nothing asks for frames, so a parked view @@ -98,11 +104,10 @@ export const AWARENESS_RANGE_PULSE = Object.freeze({ peakOpacity: 0.6, width: 3, outlineWidth: 1, - segments: 96, gradientDepthFraction: 0.35, gradientOpacity: 0.22, gradientGranularityDeg: 0.25, - gradientRebuildM: 2000, + gradientRebuildM: 500, activeMs: 9000, settleMs: 1200, }); diff --git a/src/layers/awareness/rangeRing.test.mjs b/src/layers/awareness/rangeRing.test.mjs index 8b56685f7..54018f296 100644 --- a/src/layers/awareness/rangeRing.test.mjs +++ b/src/layers/awareness/rangeRing.test.mjs @@ -2,9 +2,11 @@ // 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. +// inward-fading gradient behind it, drawn on one unpickable disc; the ring +// dims and flashes as each one arrives and the overlay's echo field follows +// the subject. The pulses run for a bounded time after a selection or camera +// move, then rest with the ring steady. Under reduced motion the ring simply +// stays on screen. import '../../testSupport/registerDom.mjs'; import test from 'node:test'; import assert from 'node:assert/strict'; @@ -221,8 +223,7 @@ test('the ring core and halo contrast on light and dark ground', () => { 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)); + assert.equal(entities.values.length, 1); const [entity] = entities.values; assert.equal(entity.ellipse, undefined); const now = Cesium.JulianDate.now(); @@ -251,12 +252,13 @@ test('renderVisual draws one haloed polyline ring, not an ellipse outline', (t) }); test('the ring and pulses follow the subject past 250 m and ignore smaller drift', (t) => { - const { entities, rendering, renders } = ringRendering(t); + const { entities, scene, rendering, renders } = ringRendering(t); const subject = flight; rendering.renderVisual(subject(3)); const [entity] = entities.values; const now = Cesium.JulianDate.now(); const first = entity.polyline.positions.getValue(now); + const discBefore = pulsePrimitives(scene).disc; const rendersAfterAdd = renders(); // ~100 m east: under the threshold, nothing is rebuilt or rendered. @@ -266,7 +268,7 @@ test('the ring and pulses follow the subject past 250 m and ignore smaller drift // ~1 km east: the same entity moves to the new centre. rendering.renderVisual(subject(3.01)); - assert.equal(entities.values.length, 1 + AWARENESS_RANGE_PULSE.count); + assert.equal(entities.values.length, 1); assert.equal(entities.values[0], entity); const moved = entity.polyline.positions.getValue(now); assert.notEqual(moved, first); @@ -276,14 +278,9 @@ test('the ring and pulses follow the subject past 250 m and ignore 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); - } + // The pulses are drawn on a disc, rebuilt around the NEW centre a + // kilometre on, so they stay concentric with the ring. + assert.notEqual(pulsePrimitives(scene).disc, discBefore); rendering.clearVisual(); assert.equal(entities.values.length, 0); @@ -356,29 +353,6 @@ test('pulses ask for a frame 80 ms after each drawn frame instead of holding con loop.advance(1); // the frame the new ring asked for arms the first request loop.advance(CYBER_SONAR_RENDER_INTERVAL_MS * 10); assert.ok(Math.abs(pulseFrames() - 10) <= 1, `${pulseFrames()} frames`); - 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); - } const frames = pulseFrames(); rendering.clearVisual(); assert.equal(entities.values.length, 0); @@ -407,14 +381,13 @@ test('a parked view stops asking for frames once the pulses rest', (t) => { const core = Cesium.Color.fromCssColorString( AWARENESS_RANGE_RING_STYLE.color, ); - const [ringEntity, ...pulses] = entities.values; + const [ringEntity] = entities.values; assert.ok( Math.abs( ringEntity.polyline.material.color.getValue(now).alpha - core.alpha, ) < 1e-9, ); - for (const pulse of pulses) - assert.equal(pulse.polyline.show.getValue(now), false); + scene.preRender.raiseEvent(scene); assert.equal(pulsePrimitives(scene).disc.show, false); assert.equal(getAwarenessEchoField().level(loop.now()), 0); }); @@ -573,6 +546,17 @@ test('a gradient disc behind the pulses fades inward and follows the subject', ( material.uniforms.color.alpha - AWARENESS_RANGE_PULSE.gradientOpacity, ) < 1e-6, ); + // The pulse lines are drawn in the ring's colours and widths. + const core = Cesium.Color.fromCssColorString( + AWARENESS_RANGE_RING_STYLE.color, + ); + const halo = Cesium.Color.fromCssColorString( + AWARENESS_RANGE_RING_STYLE.outlineColor, + ); + assert.ok(material.uniforms.lineColor.equalsEpsilon(core, 1e-6)); + assert.ok(material.uniforms.haloColor.equalsEpsilon(halo, 1e-6)); + assert.equal(material.uniforms.lineWidth, AWARENESS_RANGE_PULSE.width); + assert.equal(material.uniforms.haloWidth, AWARENESS_RANGE_PULSE.outlineWidth); // Each frame writes the pulses' radius (as a fraction of the range) and // opacity into the shader, from the same curve the lines use, once the // pulses have faded in. @@ -595,14 +579,14 @@ test('a gradient disc behind the pulses fades inward and follows the subject', ( 0, ); - // The disc is rebuilt only past `gradientRebuildM` (2 km), not on every - // 250 m step the lines take, and keeps its material. + // The disc is rebuilt only past `gradientRebuildM`, not on every 250 m + // step the ring takes, 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 + rendering.renderVisual(flight(3.004)); // ~400 m: the ring moves, 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 + rendering.renderVisual(flight(3.01)); // ~1 km from the disc's centre assert.equal(pulsePrimitives(scene).count, 1); const moved = pulsePrimitives(scene).disc; assert.notEqual(moved, disc); @@ -615,24 +599,55 @@ test('a gradient disc behind the pulses fades inward and follows the subject', ( assert.equal(scene.preRender.numberOfListeners, 0); }); -test('pulses evaluate every frame without breaking, reusing their positions', (t) => { - const { entities, rendering } = ringRendering(t); +test('a resting pulse disc is not rebuilt as the subject moves, and catches up on wake', (t) => { + const ring = ringRendering(t); + const { scene, rendering } = ring; + const loop = frameLoop(t, ring); 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); - } - } + loop.advance(AWARENESS_RANGE_PULSE.activeMs + 500); + const resting = pulsePrimitives(scene).disc; + // A flying subject keeps moving while the pulses rest: no rebuilds. + for (let lon = 3.01; lon < 3.1; lon += 0.01) + rendering.renderVisual(flight(lon)); + assert.equal(pulsePrimitives(scene).disc, resting); + assert.ok(!resting.isDestroyed()); + // Woken, the disc moves to the subject at once. + rendering.wakePulses(); + const woken = pulsePrimitives(scene).disc; + assert.notEqual(woken, resting); + assert.ok(resting.isDestroyed()); + assert.equal(pulsePrimitives(scene).count, 1); + // Woken again without moving: the disc stays. + rendering.wakePulses(); + assert.equal(pulsePrimitives(scene).disc, woken); +}); + +test('pulses never catch a click: only the ring is an entity, and the disc is unpickable', (t) => { + const { entities, scene, rendering } = ringRendering(t); + rendering.renderVisual(flight(3)); + // The ring is #1518's entity; the pulses add no entity of their own. + assert.equal(entities.values.length, 1); + assert.equal(entities.values[0].polyline.positions.isConstant, true); + for (let i = 0; i < scene.primitives.length; i++) + assert.equal(scene.primitives.get(i).allowPicking, false); +}); + +test('a camera below the rim height hides the pulse disc instead of sweeping the sky', (t) => { + const ring = ringRendering(t); + const { scene, rendering } = ring; + const loop = frameLoop(t, ring); + rendering.renderVisual(flight(3)); + loop.advance(AWARENESS_RANGE_PULSE.settleMs + 500); + const cameraAt = (height) => { + scene.camera = { + positionCartographic: new Cesium.Cartographic(0, 0, height), + }; + scene.preRender.raiseEvent(scene); + return pulsePrimitives(scene).disc.show; + }; + assert.equal(cameraAt(CONTEXT_RIM_HEIGHT_M + 500), true); + assert.equal(cameraAt(CONTEXT_RIM_HEIGHT_M - 500), false, 'cockpit view'); + assert.equal(cameraAt(800_000), true); }); test('reduced motion keeps the static ring and drops the pulses', (t) => { @@ -656,7 +671,7 @@ test('turning reduced motion on mid-session stops the pulses, and off restarts t const loop = frameLoop(t, ring); state.subject = flight(3); rendering.renderVisual(state.subject); - assert.equal(entities.values.length, 1 + AWARENESS_RANGE_PULSE.count); + assert.equal(scene.primitives.length, 1); loop.advance(1000); // The OS setting changes while the same aircraft stays tracked. motion.set(true); @@ -670,7 +685,7 @@ test('turning reduced motion on mid-session stops the pulses, and off restarts t loop.advance(5000); assert.equal(ring.pulseFrames(), frames); motion.set(false); - assert.equal(entities.values.length, 1 + AWARENESS_RANGE_PULSE.count); + assert.equal(scene.primitives.length, 1); assert.ok(getAwarenessEchoField()); // One media-query listener per visual; none once the ring is gone. assert.equal(motion.listeners(), 1); @@ -681,12 +696,12 @@ test('turning reduced motion on mid-session stops the pulses, and off restarts t test('a refresh after reduced motion changed rebuilds the visual too', (t) => { const { entities, rendering, motion } = ringRendering(t); rendering.renderVisual(flight(3)); - assert.equal(entities.values.length, 1 + AWARENESS_RANGE_PULSE.count); + assert.equal(entities.values[0].polyline.material.color.isConstant, false); // No change event: the next same-subject refresh notices on its own. motion.setQuietly(true); rendering.renderVisual(flight(3)); assert.equal(entities.values.length, 1); - assert.equal(entities.values[0].polyline.positions.isConstant, true); + assert.equal(entities.values[0].polyline.material.color.isConstant, true); }); test('while pulses run the ring flashes as each one arrives, then rests', (t) => { diff --git a/src/layers/awareness/rendering.js b/src/layers/awareness/rendering.js index b8197f60b..b5069e973 100644 --- a/src/layers/awareness/rendering.js +++ b/src/layers/awareness/rendering.js @@ -34,7 +34,6 @@ import { * @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( @@ -42,7 +41,6 @@ export function awarenessRangeRingPositions( radiusM, heightM, segments = AWARENESS_RANGE_RING_STYLE.segments, - result = [], ) { const { latitude, longitude } = Cesium.Cartographic.fromCartesian(groundCenter); @@ -53,8 +51,7 @@ 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 = result; - positions.length = segments + 1; + const positions = new Array(segments + 1); for (let i = 0; i <= segments; i++) { const bearing = ((i % segments) / segments) * Cesium.Math.TWO_PI; const cosB = Math.cos(bearing); @@ -75,7 +72,6 @@ export function awarenessRangeRingPositions( lat, heightM, Cesium.Ellipsoid.WGS84, - positions[i], ); } return positions; @@ -177,37 +173,69 @@ export function awarenessRingFlash(nowMs) { ); } -/** Most pulses the gradient shader draws; its uniforms are vec4s. */ +/** Most pulses the pulse 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. +// distance 0.5, so `r` is the fraction of the range. Each pulse is a line +// `lineWidth` CSS pixels wide whose outer `haloWidth` is a dark halo, half +// on each side, as Cesium's polyline outline material draws it, with a band +// behind it that is strongest at the line and fades to nothing `depth` +// inward. Widths come from the screen-space gradient of `r`, as Cesium's +// contour material measures its lines; without derivatives the lines are +// left out and only the band shows. const PULSE_GRADIENT_SOURCE = ` uniform vec4 color; +uniform vec4 lineColor; +uniform vec4 haloColor; uniform vec4 radii; uniform vec4 alphas; uniform float depth; +uniform float lineWidth; +uniform float haloWidth; +vec4 over(vec4 top, vec4 under) { + float a = top.a + under.a * (1.0 - top.a); + if (a <= 0.0) return vec4(0.0); + return vec4((top.rgb * top.a + under.rgb * under.a * (1.0 - top.a)) / a, a); +} 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; + float band = 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; + band += alphas[i] * t * t; } } - material.diffuse = color.rgb; - material.alpha = clamp(alpha * color.a, 0.0, 1.0); + vec4 result = vec4(color.rgb, clamp(band * color.a, 0.0, 1.0)); +#if (__VERSION__ == 300 || defined(GL_OES_standard_derivatives)) + // Change in r per CSS pixel: the gradient's length per device pixel, + // times the device pixels in one CSS pixel. + float cssPx = max(length(vec2(dFdx(r), dFdy(r))), 1e-7) * czm_pixelRatio; + float outerHalf = lineWidth * 0.5; + float coreHalf = max(lineWidth - haloWidth, 0.0) * 0.5; + for (int i = 0; i < 4; i++) { + float d = abs(radii[i] - r) / cssPx; + float cover = clamp(outerHalf + 0.5 - d, 0.0, 1.0); + if (cover <= 0.0) continue; + float core = clamp(coreHalf + 0.5 - d, 0.0, 1.0); + vec4 pulse = mix(haloColor, lineColor, core); + result = over(vec4(pulse.rgb, pulse.a * cover * alphas[i]), result); + } +#endif + material.diffuse = result.rgb; + material.alpha = result.a; 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 + * The pulses: one flat, translucent, unpickable disc the size of the range + * ring whose shader draws each pulse line and the band behind it. Drawing + * them here rather than as entities keeps them out of picking, so clicks and + * taps near a pulse still reach the contact underneath. 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. @@ -239,6 +267,11 @@ function createPulseGradient(groundCenter, material) { }); } +/** Camera height above the ellipsoid, or NaN when the scene cannot say. */ +function cameraHeightM(scene) { + return scene.camera?.positionCartographic?.height ?? Number.NaN; +} + export function createRendering({ state: layerState, services, @@ -504,7 +537,32 @@ export function createRendering({ */ function wakePulses() { const wake = layerState.visual?.wake; - if (wake) wakeAwarenessPulses(wake, performance.now()); + if (!wake) return; + wakeAwarenessPulses(wake, performance.now()); + followPulseDisc(); + } + + /** + * Rebuild the pulse disc around the subject once it is `gradientRebuildM` + * from the disc's centre. A build costs a few milliseconds, so a subject + * that moves less keeps the disc it has. + */ + function followPulseDisc() { + const { gradient, gradientCenter, center } = layerState.visual; + if ( + !gradient || + Cesium.Cartesian3.distance(gradientCenter, center) < + AWARENESS_RANGE_PULSE.gradientRebuildM + ) + return; + const primitives = layerState.viewer.scene.primitives; + layerState.visual.gradient = primitives.add( + createPulseGradient(center, gradient.appearance.material), + ); + // Primitive.destroy leaves the material alone, so the new disc keeps + // sharing it. + primitives.remove(gradient); + Cesium.Cartesian3.clone(center, gradientCenter); } function clearVisual() { @@ -549,26 +607,14 @@ export function createRendering({ AWARENESS_RADIUS_M, CONTEXT_RIM_HEIGHT_M, ); - // 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. + // The echo field reads `visual.center` in place and follows on its + // own. The pulse 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); - } + // A resting disc is hidden, so it catches up when the pulses wake. + if (performance.now() < layerState.visual.wake?.untilMs) + followPulseDisc(); // Content actually changed — buy exactly one frame instead of holding. governorRequestRender('awareness-visual'); } @@ -637,57 +683,16 @@ export function createRendering({ let stopPulses = 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 = () => { - const nowMs = performance.now(); - const state = awarenessPulseState(nowMs, index); - state.alpha *= levelAt(nowMs); - return state; - }; - entities.push( - layerState.viewer.entities.add({ - polyline: { - show: new Cesium.CallbackProperty( - () => levelAt(performance.now()) > 0, - false, - ), - 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(), + lineColor: core.clone(), + haloColor: halo.clone(), radii: new Cesium.Cartesian4(), alphas: new Cesium.Cartesian4(), depth: pulse.gradientDepthFraction, + lineWidth: pulse.width, + haloWidth: pulse.outlineWidth, }; uniforms.color.alpha = pulse.gradientOpacity; const material = new Cesium.Material({ @@ -702,9 +707,13 @@ export function createRendering({ const removePreRender = scene.preRender.addEventListener(() => { const nowMs = performance.now(); const level = levelAt(nowMs); - // The disc is looked up each frame: a subject move swaps it. + // The disc is looked up each frame: a subject move swaps it. From a + // camera below the rim height it would sweep across the sky, so it + // is hidden there. const disc = layerState.visual?.gradient; - if (disc) disc.show = level > 0; + if (disc) + disc.show = + level > 0 && !(cameraHeightM(scene) < CONTEXT_RIM_HEIGHT_M); for (let index = 0; index < count; index++) { const state = awarenessPulseState(nowMs, index); radii[index] = state.radiusM / AWARENESS_RADIUS_M; From 0f56b3147b69749638ef9c28dfa99f4170de8661 Mon Sep 17 00:00:00 2001 From: Manjunath Date: Tue, 3 Nov 2026 20:04:33 +0530 Subject: [PATCH 05/12] fix(awareness): stop the subject echoing itself and cover the echo wiring Remaining review follow-ups for the sonar pulses. No self-echo: pulses are born 5 km out (startFraction of the radius), but contacts nearer than that, the subject included, still lit on every pulse birth. They now never echo. Just past the birth ring, an echo is scaled with the front's own fade-in, so a bracket no longer flashes before a visible front reaches it. The ring flash at the full radius is unchanged. Older Safari: the reduced-motion listener falls back to the media query's addListener / removeListener when addEventListener is missing, as other modules here already do. Echo wiring test: a detectionHost test now paints through the real detection lane with an echo field set, and checks the echo stroke takes the echo colour on the default style and each style's line colour under CRT, NVG and FLIR. CURRENT-STATE notes the 5 km no-echo zone. --- docs/CURRENT-STATE.md | 2 +- src/awarenessEcho.js | 26 ++++++++----- src/awarenessEcho.test.mjs | 34 ++++++++++++++-- src/data/detectionHost.test.mjs | 52 +++++++++++++++++++++++++ src/layers/awareness/rangeRing.test.mjs | 49 +++++++++++++++++------ src/layers/awareness/rendering.js | 12 ++++-- 6 files changed, 146 insertions(+), 29 deletions(-) diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index 764d9df28..c4e2b11bc 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. Three dim sonar pulses (3 px with a 1 px dark outline, 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. One unpickable 250 km disc draws both the pulse lines and the gradient, its shader reading the pulse radii and opacities each frame, so a click or tap near a pulse still reaches the contact underneath; the disc is rebuilt only after the subject moves 500 m, and not at all while the pulses rest, and it is hidden while the camera is below its 2.5 km height, where it would sweep across the sky. While pulses run, the ring dims to 40% and flashes to full strength as each pulse reaches it, fading over 1.8 s. As a pulse front crosses a flight, military aircraft or AIS vessel, the detection overlay draws a second bracket around that contact's own bracket, in the pulse blue on the default detection style and in the style's own line colour under NVG, FLIR and CRT, 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 run for 9 s (three ring flashes) after a selection or a camera move, fading in and out over 1.2 s, then rest: the ring stays on screen at full strength, the pulses, gradient and echoes are hidden, and nothing asks for frames, so a parked Contacts view is quiet again. A moving subject does not wake them, since a flying subject moves on every refresh; a camera that follows a tracked aircraft does keep them running, but that follow already renders every frame. While they run they never hold continuous rendering: each drawn frame arms one render request 80 ms later (the Cyber sonar cadence, `CYBER_SONAR_RENDER_INTERVAL_MS`), so they repaint at about 12.5 fps and never beat the detection lane's 80 ms gate. Under `prefers-reduced-motion`, or in a scene without primitives or preRender and postRender hooks, there are no pulses or echoes and the ring stays on screen; changing the preference mid-session rebuilds the ring. 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 with a 1 px dark outline, 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. One unpickable 250 km disc draws both the pulse lines and the gradient, its shader reading the pulse radii and opacities each frame, so a click or tap near a pulse still reaches the contact underneath; the disc is rebuilt only after the subject moves 500 m, and not at all while the pulses rest, and it is hidden while the camera is below its 2.5 km height, where it would sweep across the sky. While pulses run, the ring dims to 40% and flashes to full strength as each pulse reaches it, fading over 1.8 s. As a pulse front crosses a flight, military aircraft or AIS vessel, the detection overlay draws a second bracket around that contact's own bracket, in the pulse blue on the default detection style and in the style's own line colour under NVG, FLIR and CRT, 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 are born 5 km out, so the subject itself and anything nearer never echo. Pulses run for 9 s (three ring flashes) after a selection or a camera move, fading in and out over 1.2 s, then rest: the ring stays on screen at full strength, the pulses, gradient and echoes are hidden, and nothing asks for frames, so a parked Contacts view is quiet again. A moving subject does not wake them, since a flying subject moves on every refresh; a camera that follows a tracked aircraft does keep them running, but that follow already renders every frame. While they run they never hold continuous rendering: each drawn frame arms one render request 80 ms later (the Cyber sonar cadence, `CYBER_SONAR_RENDER_INTERVAL_MS`), so they repaint at about 12.5 fps and never beat the detection lane's 80 ms gate. Under `prefers-reduced-motion`, or in a scene without primitives or preRender and postRender hooks, there are no pulses or echoes and the ring stays on screen; changing the preference mid-session rebuilds the ring. 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/src/awarenessEcho.js b/src/awarenessEcho.js index ed197a517..ecf0f21a1 100644 --- a/src/awarenessEcho.js +++ b/src/awarenessEcho.js @@ -13,7 +13,7 @@ import * as Cesium from 'cesium'; * @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 {{count: number, periodMs: number, startFraction: number, fadeInFraction?: number}} pulse * @property {{fadeMs: number, layerIds: string[]}} echo * @property {(nowMs: number) => number} [level] How far the pulses are * switched on, 0 to 1; echoes scale with it, so they rest with the pulses. @@ -50,22 +50,27 @@ export function getAwarenessEchoField() { * 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. + * cutting off. Nothing nearer than the birth ring `startFraction` out echoes, + * and within the pulse's `fadeInFraction` the echo is scaled down with it. * @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 {{count: number, periodMs: number, startFraction: number, fadeInFraction?: 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) + // Pulses are born `startFraction` out, so nothing nearer (the subject + // itself, its wingmen) is ever crossed; without this it lit on every birth. + if ( + !Number.isFinite(distanceM) || + distanceM < radiusM * pulse.startFraction || + 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), - ); + const touchPhase = + (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; @@ -77,7 +82,10 @@ export function awarenessEchoStrength(nowMs, distanceM, radiusM, pulse, echo) { if (elapsedMs < echo.fadeMs) strength = Math.max(strength, (1 - elapsedMs / echo.fadeMs) ** 2); } - return strength; + // A front still fading in over `fadeInFraction` of its travel echoes no + // brighter than it shows. + const fadeIn = pulse.fadeInFraction ?? 0; + return fadeIn > 0 ? strength * Math.min(1, touchPhase / fadeIn) : strength; } /** diff --git a/src/awarenessEcho.test.mjs b/src/awarenessEcho.test.mjs index f3bdec026..846c630c4 100644 --- a/src/awarenessEcho.test.mjs +++ b/src/awarenessEcho.test.mjs @@ -45,7 +45,14 @@ test('an echo lights as a pulse front reaches the contact, then fades', () => { 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'); + // Inside the ring pulses are born on, nothing is ever crossed: the + // subject and anything within 5 km never echo, at any moment. + const inner = R * pulse.startFraction; + for (let ms = 0; ms < pulse.periodMs; ms += 10) { + assert.equal(strength(ms, 0), 0, 'the subject itself'); + assert.equal(strength(ms, inner - 1), 0); + } + assert.ok(strength(pulse.periodMs * 5, inner) > 0.99, 'the birth ring'); for (let ms = 0; ms < pulse.periodMs; ms += 50) assert.equal(strength(ms, R + 1), 0); assert.equal(strength(0, Number.NaN), 0); @@ -74,9 +81,12 @@ test('awarenessEchoAt reads the published field, by layer, at ground level', (t) 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); + // The field's centre is read in place, so a moved subject moves the echo: + // about 50 km farther, the contact lights at its new distance's moment. + Cesium.Cartesian3.clone(Cesium.Cartesian3.fromDegrees(3.5, 26.1, 0), center); + const moved = Cesium.Cartesian3.distance(center, contact); + assert.ok(Math.abs(moved - d) > 1000); + assert.ok(awarenessEchoAt('flights', contact, arrival(moved)) > 0.99); assert.equal(awarenessEchoAt('flights', null, t0), 0); clearAwarenessEchoField(); assert.equal(getAwarenessEchoField(), null); @@ -107,3 +117,19 @@ test('echoes scale with the field level and rest with the pulses', (t) => { level = 0; assert.equal(awarenessEchoAt('flights', contact, t0), 0); }); + +test('near the subject an echo is no brighter than the fading-in front', () => { + const fading = { ...pulse, fadeInFraction: 0.08 }; + const at = (distanceM) => + awarenessEchoStrength(arrival(distanceM), distanceM, R, fading, echo); + // Born at 5 km, the front reaches full strength 8% of the way out. + const full = R * (fading.startFraction + 0.08 * (1 - fading.startFraction)); + assert.equal(at(R * fading.startFraction), 0); + const halfway = (R * fading.startFraction + full) / 2; + assert.ok(Math.abs(at(halfway) - 0.5) < 0.01, `${at(halfway)}`); + assert.ok(at(full + 1) > 0.99); + assert.ok( + at(R * 0.999) > 0.99, + 'the rim, and so the ring flash, is unchanged', + ); +}); diff --git a/src/data/detectionHost.test.mjs b/src/data/detectionHost.test.mjs index 06bfe01af..2e3829c94 100644 --- a/src/data/detectionHost.test.mjs +++ b/src/data/detectionHost.test.mjs @@ -24,6 +24,10 @@ import { setOverlayEntries, } from '../overlays/worldOverlay.js'; import { DETECTION_THEME_MAP } from '../overlays/worldOverlayTokens.js'; +import { + clearAwarenessEchoField, + setAwarenessEchoField, +} from '../awarenessEcho.js'; import { composeLabel, resolveTier } from './detectionDraw.js'; test('detection diagnostics count rendered fading rows instead of absent selected identities', () => { @@ -627,6 +631,54 @@ test('surveillance military tier reaches the dedicated render target as shipped } }); +test('Global Context echo brackets reach the canvas in the detection style colour', () => { + const env = installEnvironment(); + // Fronts every millisecond with a long fade, so the contact is lit on + // whatever frame paints; a distinctive width picks the echo stroke out. + const echo = { + layerIds: ['military'], + fadeMs: 60_000, + color: 'rgb(1, 2, 3)', + spreadPx: 8, + lineWidth: 3.25, + }; + const echoStrokes = () => + env.detectionCtx.calls + .filter(([name, , , width]) => name === 'stroke' && width === echo.lineWidth) + .map(([, , style]) => style); + try { + setAwarenessEchoField({ + center: Cesium.Cartesian3.fromDegrees(0, 89, 0), + radiusM: 250_000, + pulse: { count: 1000, periodMs: 1000, startFraction: 0 }, + echo, + }); + initWorldOverlay(env.viewer); + initDetection(env.viewer, [militaryLayer()], () => {}); + setMode('DENSE'); + setDetectionStyle('normal'); + settleFrame(env); + assert.ok(echoStrokes().length > 0, 'the echo bracket is painted'); + assert.ok(echoStrokes().every((style) => style === echo.color)); + + // CRT, NVG and FLIR keep their own palettes. + for (const style of ['retro', 'surveillance', 'thermal']) { + env.detectionCtx.calls.length = 0; + setDetectionStyle(style); + settleFrame(env); + const strokes = echoStrokes(); + assert.ok(strokes.length > 0, style); + assert.ok( + strokes.every((stroke) => stroke === DETECTION_THEME_MAP[style].line), + `${style} echoes take the style line colour, got ${strokes}`, + ); + } + } finally { + clearAwarenessEchoField(); + env.cleanup(); + } +}); + test('pathological detection paint holds alternate frames without freezing shared lanes', () => { // Opts into the telemetry banner: the held-frame assertions count banner paints. const env = installEnvironment({ search: '?detectDebug=1' }); diff --git a/src/layers/awareness/rangeRing.test.mjs b/src/layers/awareness/rangeRing.test.mjs index 54018f296..01beac378 100644 --- a/src/layers/awareness/rangeRing.test.mjs +++ b/src/layers/awareness/rangeRing.test.mjs @@ -49,20 +49,30 @@ function luminance(color) { * test; `motion.set(on)` flips it later and tells its listeners, as the * browser does. */ -function ringRendering(t, { reducedMotion = false } = {}) { +function ringRendering(t, { reducedMotion = false, legacyQuery = false } = {}) { const previous = globalThis.matchMedia; const listeners = new Set(); - const query = { - get matches() { - return reducedMotion; - }, - addEventListener(type, listener) { - if (type === 'change') listeners.add(listener); - }, - removeEventListener(type, listener) { - if (type === 'change') listeners.delete(listener); - }, - }; + // `legacyQuery` is a MediaQueryList as Safari before 14 shipped it, with + // only addListener / removeListener. + const query = legacyQuery + ? { + get matches() { + return reducedMotion; + }, + addListener: (listener) => listeners.add(listener), + removeListener: (listener) => listeners.delete(listener), + } + : { + get matches() { + return reducedMotion; + }, + addEventListener(type, listener) { + if (type === 'change') listeners.add(listener); + }, + removeEventListener(type, listener) { + if (type === 'change') listeners.delete(listener); + }, + }; globalThis.matchMedia = (media) => media === '(prefers-reduced-motion: reduce)' ? query @@ -693,6 +703,21 @@ test('turning reduced motion on mid-session stops the pulses, and off restarts t assert.equal(motion.listeners(), 0); }); +test('a reduced-motion change reaches older Safari through addListener', (t) => { + const { entities, scene, rendering, motion, state } = ringRendering(t, { + legacyQuery: true, + }); + state.subject = flight(3); + rendering.renderVisual(state.subject); + assert.equal(scene.primitives.length, 1); + assert.equal(motion.listeners(), 1); + motion.set(true); + assert.equal(scene.primitives.length, 0); + assert.equal(entities.values[0].polyline.material.color.isConstant, true); + rendering.clearVisual(); + assert.equal(motion.listeners(), 0); +}); + test('a refresh after reduced motion changed rebuilds the visual too', (t) => { const { entities, rendering, motion } = ringRendering(t); rendering.renderVisual(flight(3)); diff --git a/src/layers/awareness/rendering.js b/src/layers/awareness/rendering.js index b5069e973..3e929752b 100644 --- a/src/layers/awareness/rendering.js +++ b/src/layers/awareness/rendering.js @@ -758,7 +758,10 @@ export function createRendering({ const onMotionChange = () => { if (layerState.subject) renderVisual(layerState.subject); }; - motionQuery?.addEventListener?.('change', onMotionChange); + // Safari before 14 has only the older addListener / removeListener. + if (motionQuery?.addEventListener) + motionQuery.addEventListener('change', onMotionChange); + else motionQuery?.addListener?.(onMotionChange); layerState.visual = { key, @@ -770,8 +773,11 @@ export function createRendering({ reducedMotion, wake, stopPulses, - removeMotionListener: () => - motionQuery?.removeEventListener?.('change', onMotionChange), + removeMotionListener: () => { + if (motionQuery?.removeEventListener) + motionQuery.removeEventListener('change', onMotionChange); + else motionQuery?.removeListener?.(onMotionChange); + }, }; governorRequestRender('awareness-visual'); } From 686bc4fd0ed2b09c5cd30dbdeb8219ace53b49d4 Mon Sep 17 00:00:00 2001 From: Manjunath Date: Tue, 3 Nov 2026 20:25:30 +0530 Subject: [PATCH 06/12] fix(awareness): draw the 250 km range ring as an unpickable primitive The range ring was a Cesium entity, and entities are always picked, so a click or tap on its edge became an unowned hit: vessel selection read it as empty space and pickContact stopped its touch search there. A contact under the 250 km edge was hard to select. The ring is now a polyline primitive with allowPicking false, in the same PolylineOutline material and 4 px width the entity used, so it draws the same. The flash while pulses run writes the material's colour alphas from the pulses' per-frame update; under reduced motion the colours stay at full and no listener is added. A subject that moves 250 m rebuilds the primitive and keeps the material, so a flash carries on across the move. Viewers without a usable primitive collection (headless tools, stubs) still get a static entity. The ring's geometry options come from awarenessRingGeometryOptions, tested directly, and the tests now check that the awareness visual adds no entity and that every primitive it adds is unpickable, with and without reduced motion. --- docs/CURRENT-STATE.md | 2 +- src/layers/awareness/policy.js | 2 + src/layers/awareness/rangeRing.test.mjs | 183 +++++++++++++----------- src/layers/awareness/rendering.js | 128 +++++++++++++---- 4 files changed, 203 insertions(+), 112 deletions(-) diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index c4e2b11bc..be0d26204 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. Three dim sonar pulses (3 px with a 1 px dark outline, 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. One unpickable 250 km disc draws both the pulse lines and the gradient, its shader reading the pulse radii and opacities each frame, so a click or tap near a pulse still reaches the contact underneath; the disc is rebuilt only after the subject moves 500 m, and not at all while the pulses rest, and it is hidden while the camera is below its 2.5 km height, where it would sweep across the sky. While pulses run, the ring dims to 40% and flashes to full strength as each pulse reaches it, fading over 1.8 s. As a pulse front crosses a flight, military aircraft or AIS vessel, the detection overlay draws a second bracket around that contact's own bracket, in the pulse blue on the default detection style and in the style's own line colour under NVG, FLIR and CRT, 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 are born 5 km out, so the subject itself and anything nearer never echo. Pulses run for 9 s (three ring flashes) after a selection or a camera move, fading in and out over 1.2 s, then rest: the ring stays on screen at full strength, the pulses, gradient and echoes are hidden, and nothing asks for frames, so a parked Contacts view is quiet again. A moving subject does not wake them, since a flying subject moves on every refresh; a camera that follows a tracked aircraft does keep them running, but that follow already renders every frame. While they run they never hold continuous rendering: each drawn frame arms one render request 80 ms later (the Cyber sonar cadence, `CYBER_SONAR_RENDER_INTERVAL_MS`), so they repaint at about 12.5 fps and never beat the detection lane's 80 ms gate. Under `prefers-reduced-motion`, or in a scene without primitives or preRender and postRender hooks, there are no pulses or echoes and the ring stays on screen; changing the preference mid-session rebuilds the ring. 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, drawn as an unpickable primitive rather than an entity so a click or tap on its edge reaches the contact underneath (headless viewers without a primitive collection get a static entity instead); it is rebuilt when the subject moves. Three dim sonar pulses (3 px with a 1 px dark outline, 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. One unpickable 250 km disc draws both the pulse lines and the gradient, its shader reading the pulse radii and opacities each frame, so a click or tap near a pulse still reaches the contact underneath; the disc is rebuilt only after the subject moves 500 m, and not at all while the pulses rest, and it is hidden while the camera is below its 2.5 km height, where it would sweep across the sky. While pulses run, the ring dims to 40% and flashes to full strength as each pulse reaches it, fading over 1.8 s. As a pulse front crosses a flight, military aircraft or AIS vessel, the detection overlay draws a second bracket around that contact's own bracket, in the pulse blue on the default detection style and in the style's own line colour under NVG, FLIR and CRT, 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 are born 5 km out, so the subject itself and anything nearer never echo. Pulses run for 9 s (three ring flashes) after a selection or a camera move, fading in and out over 1.2 s, then rest: the ring stays on screen at full strength, the pulses, gradient and echoes are hidden, and nothing asks for frames, so a parked Contacts view is quiet again. A moving subject does not wake them, since a flying subject moves on every refresh; a camera that follows a tracked aircraft does keep them running, but that follow already renders every frame. While they run they never hold continuous rendering: each drawn frame arms one render request 80 ms later (the Cyber sonar cadence, `CYBER_SONAR_RENDER_INTERVAL_MS`), so they repaint at about 12.5 fps and never beat the detection lane's 80 ms gate. Under `prefers-reduced-motion`, or in a scene without primitives or preRender and postRender hooks, there are no pulses or echoes and the ring stays on screen; changing the preference mid-session rebuilds the ring. 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/src/layers/awareness/policy.js b/src/layers/awareness/policy.js index 9acabb822..11fb866ae 100644 --- a/src/layers/awareness/policy.js +++ b/src/layers/awareness/policy.js @@ -50,6 +50,8 @@ export const CONTEXT_RIM_HEIGHT_M = 2500; * 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. + * It is drawn as an unpickable primitive, so clicks on its edge reach the + * contacts underneath. * While the sonar pulses run, the ring dims to `restOpacity` and flashes * to full as each pulse reaches it, fading over `flashMs`. `restOpacity` * keeps the 250 km edge readable between flashes, in screenshots and for diff --git a/src/layers/awareness/rangeRing.test.mjs b/src/layers/awareness/rangeRing.test.mjs index 01beac378..f005d3b21 100644 --- a/src/layers/awareness/rangeRing.test.mjs +++ b/src/layers/awareness/rangeRing.test.mjs @@ -25,6 +25,7 @@ import { awarenessPulseState, awarenessRangeRingPositions, awarenessRingFlash, + awarenessRingGeometryOptions, createRendering, wakeAwarenessPulses, } from './rendering.js'; @@ -155,15 +156,22 @@ function frameLoop(t, ring) { }; } -/** The pulse gradient disc and the echo collection among scene primitives. */ +/** The ring and the pulse disc 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 ring = all.find( + (p) => p.appearance instanceof Cesium.PolylineMaterialAppearance, + ); + const discs = all.filter((p) => p instanceof Cesium.Primitive && p !== ring); + return { ring, disc: discs[0], count: discs.length }; +} + +/** The ring's material uniforms as the next drawn frame sets them. */ +function ringColors(scene) { + scene.preRender.raiseEvent(scene); + return pulsePrimitives(scene).ring.appearance.material.uniforms; } const flight = (lon) => ({ @@ -231,56 +239,65 @@ test('the ring core and halo contrast on light and dark ground', () => { }); test('renderVisual draws one haloed polyline ring, not an ellipse outline', (t) => { - const { entities, rendering } = ringRendering(t); + const { entities, scene, state, rendering } = ringRendering(t); rendering.renderVisual(flight(3)); - assert.equal(entities.values.length, 1); - const [entity] = entities.values; - assert.equal(entity.ellipse, undefined); - const now = Cesium.JulianDate.now(); - assert.equal(entity.polyline.width.getValue(now), 4); - assert.equal(entity.polyline.arcType.getValue(now), Cesium.ArcType.NONE); - const material = entity.polyline.material; - assert.ok(material instanceof Cesium.PolylineOutlineMaterialProperty); + // A polyline primitive, never an entity or an ellipse outline. + assert.equal(entities.values.length, 0); + const { ring } = pulsePrimitives(scene); + assert.ok(ring); + const geometry = ring.geometryInstances.geometry; + assert.ok(geometry instanceof Cesium.PolylineGeometry); + // 4 px wide, straight chords between surface-following positions, as the + // entity ring was drawn. + const options = awarenessRingGeometryOptions(state.visual.ringPositions); + assert.equal(options.width, 4); + assert.equal(options.width, AWARENESS_RANGE_RING_STYLE.width); + assert.equal(options.arcType, Cesium.ArcType.NONE); + assert.equal(options.positions, state.visual.ringPositions); + assert.equal( + options.vertexFormat, + Cesium.PolylineMaterialAppearance.VERTEX_FORMAT, + ); + const { material } = ring.appearance; + assert.equal(material.type, 'PolylineOutline'); // 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); + const { color, outlineWidth } = ringColors(scene); 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, - ); + assert.equal(outlineWidth, AWARENESS_RANGE_RING_STYLE.outlineWidth); const ground = Cesium.Cartesian3.fromDegrees(3, 27, 0); - for (const distance of surfaceDistances( - ground, - entity.polyline.positions.getValue(now), - )) + for (const distance of surfaceDistances(ground, state.visual.ringPositions)) assert.ok(Math.abs(distance - AWARENESS_RADIUS_M) < 100); }); test('the ring and pulses follow the subject past 250 m and ignore smaller drift', (t) => { - const { entities, scene, rendering, renders } = ringRendering(t); + const { scene, state, rendering, renders } = ringRendering(t); const subject = flight; rendering.renderVisual(subject(3)); - const [entity] = entities.values; - const now = Cesium.JulianDate.now(); - const first = entity.polyline.positions.getValue(now); + const ringBefore = pulsePrimitives(scene).ring; + const first = state.visual.ringPositions; const discBefore = pulsePrimitives(scene).disc; const rendersAfterAdd = renders(); // ~100 m east: under the threshold, nothing is rebuilt or rendered. rendering.renderVisual(subject(3.001)); - assert.equal(entity.polyline.positions.getValue(now), first); + assert.equal(pulsePrimitives(scene).ring, ringBefore); + assert.equal(state.visual.ringPositions, first); assert.equal(renders(), rendersAfterAdd); - // ~1 km east: the same entity moves to the new centre. + // ~1 km east: the ring is rebuilt around the new centre, keeping its + // material, so a flash carries on. rendering.renderVisual(subject(3.01)); - assert.equal(entities.values.length, 1); - assert.equal(entities.values[0], entity); - const moved = entity.polyline.positions.getValue(now); + const ringAfter = pulsePrimitives(scene).ring; + assert.notEqual(ringAfter, ringBefore); + assert.ok(ringBefore.isDestroyed()); + assert.equal(ringAfter.appearance.material, ringBefore.appearance.material); + assert.equal(ringAfter.allowPicking, false); + const moved = state.visual.ringPositions; assert.notEqual(moved, first); for (const distance of surfaceDistances( Cesium.Cartesian3.fromDegrees(3.01, 27, 0), @@ -293,7 +310,7 @@ test('the ring and pulses follow the subject past 250 m and ignore smaller drift assert.notEqual(pulsePrimitives(scene).disc, discBefore); rendering.clearVisual(); - assert.equal(entities.values.length, 0); + assert.equal(scene.primitives.length, 0); }); test('a pulse grows slowly from the subject and arrives dim at the ring', () => { @@ -354,7 +371,7 @@ test('a pulse grows slowly from the subject and arrives dim at the ring', () => test('pulses ask for a frame 80 ms after each drawn frame instead of holding continuous rendering', (t) => { const ring = ringRendering(t); - const { entities, rendering, pulseFrames } = ring; + const { rendering, pulseFrames } = ring; const loop = frameLoop(t, ring); rendering.renderVisual(flight(3)); // A Contacts view repaints at the Cyber sonar cadence, ~12.5 fps, while @@ -365,7 +382,7 @@ test('pulses ask for a frame 80 ms after each drawn frame instead of holding con assert.ok(Math.abs(pulseFrames() - 10) <= 1, `${pulseFrames()} frames`); const frames = pulseFrames(); rendering.clearVisual(); - assert.equal(entities.values.length, 0); + assert.equal(ring.scene.primitives.length, 0); assert.equal(ring.scene.postRender.numberOfListeners, 0); loop.advance(CYBER_SONAR_RENDER_INTERVAL_MS * 10); assert.equal(pulseFrames(), frames, 'no frames once the ring is gone'); @@ -373,7 +390,7 @@ test('pulses ask for a frame 80 ms after each drawn frame instead of holding con test('a parked view stops asking for frames once the pulses rest', (t) => { const ring = ringRendering(t); - const { entities, scene, rendering, pulseFrames } = ring; + const { scene, rendering, pulseFrames } = ring; const loop = frameLoop(t, ring); const { activeMs, count, periodMs } = AWARENESS_RANGE_PULSE; // Long enough for three ring flashes, then quiet. @@ -387,17 +404,10 @@ test('a parked view stops asking for frames once the pulses rest', (t) => { assert.equal(pulseFrames(), frames, 'render requests stop after the bound'); // At rest the ring stands on screen at full, as without pulses, and the // pulses, gradient and echoes are hidden. - const now = Cesium.JulianDate.now(); const core = Cesium.Color.fromCssColorString( AWARENESS_RANGE_RING_STYLE.color, ); - const [ringEntity] = entities.values; - assert.ok( - Math.abs( - ringEntity.polyline.material.color.getValue(now).alpha - core.alpha, - ) < 1e-9, - ); - scene.preRender.raiseEvent(scene); + assert.ok(Math.abs(ringColors(scene).color.alpha - core.alpha) < 1e-9); assert.equal(pulsePrimitives(scene).disc.show, false); assert.equal(getAwarenessEchoField().level(loop.now()), 0); }); @@ -526,8 +536,10 @@ test('a scene without primitives or preRender gets the static ring only', (t) => state.viewer = { entities, scene }; rendering.clearVisual(); rendering.renderVisual(flight(3)); + // No usable primitive collection: the ring falls back to an entity. assert.equal(entities.values.length, 1); assert.equal(entities.values[0].polyline.positions.isConstant, true); + assert.equal(entities.values[0].polyline.material.color.isConstant, true); } rendering.clearVisual(); assert.equal(pulseFrames(), 0); @@ -539,6 +551,7 @@ test('a gradient disc behind the pulses fades inward and follows the subject', ( const loop = frameLoop(t, ring); rendering.renderVisual(flight(3)); assert.ok(AWARENESS_RANGE_PULSE.count <= AWARENESS_PULSE_GRADIENT_MAX); + assert.equal(scene.primitives.length, 2, 'the ring and the pulse disc'); const { disc, count } = pulsePrimitives(scene); assert.equal(count, 1, 'the gradient disc'); assert.ok(disc); @@ -632,14 +645,17 @@ test('a resting pulse disc is not rebuilt as the subject moves, and catches up o assert.equal(pulsePrimitives(scene).disc, woken); }); -test('pulses never catch a click: only the ring is an entity, and the disc is unpickable', (t) => { - const { entities, scene, rendering } = ringRendering(t); - rendering.renderVisual(flight(3)); - // The ring is #1518's entity; the pulses add no entity of their own. - assert.equal(entities.values.length, 1); - assert.equal(entities.values[0].polyline.positions.isConstant, true); - for (let i = 0; i < scene.primitives.length; i++) - assert.equal(scene.primitives.get(i).allowPicking, false); +test('the ring and pulses never catch a click: no entities, every primitive unpickable', (t) => { + for (const reducedMotion of [false, true]) { + const { entities, scene, rendering } = ringRendering(t, { reducedMotion }); + rendering.renderVisual(flight(3)); + // Cesium always picks entities, so the awareness visual adds none. + assert.equal(entities.values.length, 0); + assert.ok(scene.primitives.length >= 1); + for (let i = 0; i < scene.primitives.length; i++) + assert.equal(scene.primitives.get(i).allowPicking, false); + rendering.clearVisual(); + } }); test('a camera below the rim height hides the pulse disc instead of sweeping the sky', (t) => { @@ -662,32 +678,30 @@ test('a camera below the rim height hides the pulse disc instead of sweeping the test('reduced motion keeps the static ring and drops the pulses', (t) => { t.mock.timers.enable({ apis: ['setTimeout', 'setInterval'] }); - const { entities, scene, rendering, pulseFrames } = ringRendering(t, { + const { 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); + assert.ok(pulsePrimitives(scene).ring); t.mock.timers.tick(1000); assert.equal(pulseFrames(), 0); - assert.equal(scene.primitives.length, 0); + assert.equal(pulsePrimitives(scene).count, 0, 'no pulse disc'); assert.equal(scene.preRender.numberOfListeners, 0); assert.equal(scene.postRender.numberOfListeners, 0); }); test('turning reduced motion on mid-session stops the pulses, and off restarts them', (t) => { const ring = ringRendering(t); - const { entities, scene, rendering, motion, state } = ring; + const { scene, rendering, motion, state } = ring; const loop = frameLoop(t, ring); state.subject = flight(3); rendering.renderVisual(state.subject); - assert.equal(scene.primitives.length, 1); + assert.equal(pulsePrimitives(scene).count, 1); loop.advance(1000); // The OS setting changes while the same aircraft stays tracked. motion.set(true); - assert.equal(entities.values.length, 1); - assert.equal(entities.values[0].polyline.material.color.isConstant, true); - assert.equal(scene.primitives.length, 0); + assert.ok(pulsePrimitives(scene).ring); + assert.equal(pulsePrimitives(scene).count, 0); assert.equal(scene.preRender.numberOfListeners, 0); assert.equal(scene.postRender.numberOfListeners, 0); assert.equal(getAwarenessEchoField(), null); @@ -695,7 +709,7 @@ test('turning reduced motion on mid-session stops the pulses, and off restarts t loop.advance(5000); assert.equal(ring.pulseFrames(), frames); motion.set(false); - assert.equal(scene.primitives.length, 1); + assert.equal(pulsePrimitives(scene).count, 1); assert.ok(getAwarenessEchoField()); // One media-query listener per visual; none once the ring is gone. assert.equal(motion.listeners(), 1); @@ -704,41 +718,43 @@ test('turning reduced motion on mid-session stops the pulses, and off restarts t }); test('a reduced-motion change reaches older Safari through addListener', (t) => { - const { entities, scene, rendering, motion, state } = ringRendering(t, { + const { scene, rendering, motion, state } = ringRendering(t, { legacyQuery: true, }); state.subject = flight(3); rendering.renderVisual(state.subject); - assert.equal(scene.primitives.length, 1); + assert.equal(pulsePrimitives(scene).count, 1); assert.equal(motion.listeners(), 1); motion.set(true); - assert.equal(scene.primitives.length, 0); - assert.equal(entities.values[0].polyline.material.color.isConstant, true); + assert.equal(pulsePrimitives(scene).count, 0); + assert.equal(scene.preRender.numberOfListeners, 0); rendering.clearVisual(); assert.equal(motion.listeners(), 0); }); test('a refresh after reduced motion changed rebuilds the visual too', (t) => { - const { entities, rendering, motion } = ringRendering(t); + const { scene, rendering, motion } = ringRendering(t); rendering.renderVisual(flight(3)); - assert.equal(entities.values[0].polyline.material.color.isConstant, false); + assert.equal(pulsePrimitives(scene).count, 1); // No change event: the next same-subject refresh notices on its own. motion.setQuietly(true); rendering.renderVisual(flight(3)); - assert.equal(entities.values.length, 1); - assert.equal(entities.values[0].polyline.material.color.isConstant, true); + assert.ok(pulsePrimitives(scene).ring); + assert.equal(pulsePrimitives(scene).count, 0); + assert.equal(scene.preRender.numberOfListeners, 0); }); test('while pulses run the ring flashes as each one arrives, then rests', (t) => { const ring = ringRendering(t); - const { entities, rendering } = ring; + const { scene, rendering } = ring; const loop = frameLoop(t, ring); rendering.renderVisual(flight(3)); - const ringEntity = entities.values[0]; const core = Cesium.Color.fromCssColorString( AWARENESS_RANGE_RING_STYLE.color, ); - assert.equal(ringEntity.polyline.material.color.isConstant, false); + const halo = Cesium.Color.fromCssColorString( + AWARENESS_RANGE_RING_STYLE.outlineColor, + ); const { periodMs, count } = AWARENESS_RANGE_PULSE; const { flashMs, restOpacity } = AWARENESS_RANGE_RING_STYLE; // Full the moment a front reaches 250 km, gone `flashMs` later. @@ -764,22 +780,23 @@ test('while pulses run the ring flashes as each one arrives, then rests', (t) => loop.advance(AWARENESS_RANGE_PULSE.settleMs); for (let step = 0; step < 40; step++) { loop.advance(100); - const alpha = ringEntity.polyline.material.color.getValue( - Cesium.JulianDate.now(), - ).alpha; - const expected = - core.alpha * Math.max(restOpacity, awarenessRingFlash(loop.now())); - assert.ok(Math.abs(alpha - expected) < 1e-9, `${alpha} vs ${expected}`); + const { color, outlineColor } = ringColors(scene); + const flash = Math.max(restOpacity, awarenessRingFlash(loop.now())); + const expected = core.alpha * flash; + assert.ok( + Math.abs(color.alpha - expected) < 1e-9, + `${color.alpha} vs ${expected}`, + ); + assert.ok(Math.abs(outlineColor.alpha - halo.alpha * flash) < 1e-9); } }); test('without pulses the ring stays on screen at full strength', (t) => { - const { entities, rendering } = ringRendering(t, { reducedMotion: true }); + const { scene, rendering } = ringRendering(t, { reducedMotion: true }); rendering.renderVisual(flight(3)); - const color = entities.values[0].polyline.material.color; - assert.equal(color.isConstant, true); + assert.equal(scene.preRender.numberOfListeners, 0); assert.equal( - color.getValue(Cesium.JulianDate.now()).toCssColorString(), + ringColors(scene).color.toCssColorString(), Cesium.Color.fromCssColorString( AWARENESS_RANGE_RING_STYLE.color, ).toCssColorString(), diff --git a/src/layers/awareness/rendering.js b/src/layers/awareness/rendering.js index 3e929752b..5a5b5689b 100644 --- a/src/layers/awareness/rendering.js +++ b/src/layers/awareness/rendering.js @@ -267,6 +267,47 @@ function createPulseGradient(groundCenter, material) { }); } +/** + * The ring's polyline geometry: `AWARENESS_RANGE_RING_STYLE.width` screen + * pixels, straight chords between the given positions (they already follow + * the surface), in the material appearance's vertex format. + * @param {Cesium.Cartesian3[]} positions Ring positions. + * @returns {object} PolylineGeometry options. + */ +export function awarenessRingGeometryOptions(positions) { + return { + positions, + width: AWARENESS_RANGE_RING_STYLE.width, + arcType: Cesium.ArcType.NONE, + vertexFormat: Cesium.PolylineMaterialAppearance.VERTEX_FORMAT, + }; +} + +/** + * The 250 km ring as an unpickable polyline primitive in the ring's outline + * material, so a click or tap on its edge reaches the contact underneath. + * Its positions are geometry, so a moved subject rebuilds it; the material + * is shared between builds so a flash keeps going. + * @param {Cesium.Cartesian3[]} positions Ring positions. + * @param {Cesium.Material} material Shared PolylineOutline material. + * @returns {Cesium.Primitive} + */ +function createRingPrimitive(positions, material) { + return new Cesium.Primitive({ + geometryInstances: new Cesium.GeometryInstance({ + geometry: new Cesium.PolylineGeometry( + awarenessRingGeometryOptions(positions), + ), + }), + appearance: new Cesium.PolylineMaterialAppearance({ + material, + translucent: true, + }), + asynchronous: false, + allowPicking: false, + }); +} + /** Camera height above the ellipsoid, or NaN when the scene cannot say. */ function cameraHeightM(scene) { return scene.camera?.positionCartographic?.height ?? Number.NaN; @@ -570,6 +611,12 @@ export function createRendering({ layerState.visual?.removeMotionListener?.(); if (layerState.visual?.gradient && layerState.viewer) layerState.viewer.scene.primitives.remove(layerState.visual.gradient); + if (layerState.visual?.ringPrimitive && layerState.viewer) { + layerState.viewer.scene.primitives.remove?.( + layerState.visual.ringPrimitive, + ); + governorRequestRender('awareness-visual'); + } clearAwarenessEchoField(); if (layerState.visual?.entities && layerState.viewer) { for (const entity of layerState.visual.entities) @@ -610,7 +657,15 @@ export function createRendering({ // The echo field reads `visual.center` in place and follows on its // own. The pulse disc is geometry, so it is rebuilt, but only every // `gradientRebuildM`: a build costs a few milliseconds. - layerState.visual.ring.polyline.positions.setValue(positions); + layerState.visual.ringPositions = positions; + const { ringPrimitive } = layerState.visual; + if (ringPrimitive) { + const primitives = layerState.viewer.scene.primitives; + layerState.visual.ringPrimitive = primitives.add( + createRingPrimitive(positions, ringPrimitive.appearance.material), + ); + primitives.remove(ringPrimitive); + } else layerState.visual.ring.polyline.positions.setValue(positions); Cesium.Cartesian3.clone(groundCenter, layerState.visual.center); // A resting disc is hidden, so it catches up when the pulses wake. if (performance.now() < layerState.visual.wake?.untilMs) @@ -647,36 +702,48 @@ export function createRendering({ const ring = AWARENESS_RANGE_RING_STYLE; const core = Cesium.Color.fromCssColorString(ring.color); const halo = Cesium.Color.fromCssColorString(ring.outlineColor); + const ringPositions = awarenessRangeRingPositions( + groundCenter, + AWARENESS_RADIUS_M, + CONTEXT_RIM_HEIGHT_M, + ); // While pulses run, the ring flashes as each one arrives and otherwise // dims to `restOpacity`; as they settle it returns to full and stays. - const flashing = (base) => { - const color = base.clone(); - return new Cesium.CallbackProperty(() => { - const nowMs = performance.now(); - const pulsed = Math.max(ring.restOpacity, awarenessRingFlash(nowMs)); - color.alpha = base.alpha * (1 - levelAt(nowMs) * (1 - pulsed)); - return color; - }, false); + const ringAlpha = (nowMs) => { + const pulsed = Math.max(ring.restOpacity, awarenessRingFlash(nowMs)); + return 1 - levelAt(nowMs) * (1 - pulsed); }; - 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: animate ? flashing(core) : core, - outlineColor: animate ? flashing(halo) : halo, - outlineWidth: ring.outlineWidth, - }), - }, - }); - entities.push(ringEntity); + let ringEntity = null; + let ringMaterial = null; + let ringPrimitive = null; + if (scene?.primitives?.add) { + ringMaterial = Cesium.Material.fromType('PolylineOutline', { + color: core.clone(), + outlineColor: halo.clone(), + outlineWidth: ring.outlineWidth, + }); + ringPrimitive = + scene.primitives.add( + createRingPrimitive(ringPositions, ringMaterial), + ) ?? null; + } + if (!ringPrimitive) { + // Headless tools and partial stubs have no primitive collection; they + // get the static ring as an entity. + ringEntity = layerState.viewer.entities.add({ + polyline: { + positions: new Cesium.ConstantProperty(ringPositions), + width: ring.width, + arcType: Cesium.ArcType.NONE, + material: new Cesium.PolylineOutlineMaterialProperty({ + color: core, + outlineColor: halo, + outlineWidth: ring.outlineWidth, + }), + }, + }); + entities.push(ringEntity); + } const center = Cesium.Cartesian3.clone(groundCenter); let gradient = null; @@ -707,6 +774,9 @@ export function createRendering({ const removePreRender = scene.preRender.addEventListener(() => { const nowMs = performance.now(); const level = levelAt(nowMs); + const alpha = ringAlpha(nowMs); + ringMaterial.uniforms.color.alpha = core.alpha * alpha; + ringMaterial.uniforms.outlineColor.alpha = halo.alpha * alpha; // The disc is looked up each frame: a subject move swaps it. From a // camera below the rim height it would sweep across the sky, so it // is hidden there. @@ -767,6 +837,8 @@ export function createRendering({ key, entities, ring: ringEntity, + ringPrimitive, + ringPositions, center, gradient, gradientCenter: Cesium.Cartesian3.clone(groundCenter), From 3c6c342b2b1c66b44ba5b56fa81321e3740afd8a Mon Sep 17 00:00:00 2001 From: Manjunath Date: Tue, 3 Nov 2026 21:22:36 +0530 Subject: [PATCH 07/12] perf(awareness): skip echo work for the whole frame while pulses rest The detection overlay asked the echo field for every visible flight, military aircraft and vessel on every frame while a subject was selected, projecting each to the ground before learning the pulses were resting. It now checks awarenessEchoActive once per frame and builds no echo paths at rest, and awarenessEchoAt checks the level before the projection. A detectionHost test with six echo-eligible flights checks that a resting frame asks for the level fewer times than there are contacts and paints no echo; it fails on the per-contact path. The pulse and echo policy doc comments are attached to their constants again, and one long comment line is re-wrapped. --- src/awarenessEcho.js | 15 +++++++-- src/awarenessEcho.test.mjs | 6 ++++ src/data/detection.js | 4 ++- src/data/detectionHost.test.mjs | 54 +++++++++++++++++++++++++++++++++ src/layers/awareness/policy.js | 12 ++++---- 5 files changed, 82 insertions(+), 9 deletions(-) diff --git a/src/awarenessEcho.js b/src/awarenessEcho.js index ecf0f21a1..f4c106ead 100644 --- a/src/awarenessEcho.js +++ b/src/awarenessEcho.js @@ -45,6 +45,17 @@ export function getAwarenessEchoField() { return field; } +/** + * Whether any echo can light at `nowMs`: a field is set and its pulses are + * running. The overlay checks this once per frame, so resting pulses cost no + * per-contact work. + * @param {number} nowMs Monotonic clock in milliseconds. + * @returns {boolean} + */ +export function awarenessEchoActive(nowMs) { + return Boolean(field) && (field.level?.(nowMs) ?? 1) > 0; +} + /** * How lit a contact's echo is at `nowMs`: 1 the moment a pulse front reaches * its distance, fading as (1 - elapsed / `fadeMs`) squared, 0 otherwise. The @@ -99,13 +110,13 @@ export function awarenessEchoStrength(nowMs, distanceM, radiusM, pulse, echo) { */ export function awarenessEchoAt(layerId, position, nowMs) { if (!field || !position || !layerIds.has(layerId)) return 0; + const level = field.level?.(nowMs) ?? 1; + if (!(level > 0)) return 0; const ground = Cesium.Ellipsoid.WGS84.scaleToGeodeticSurface( position, groundScratch, ); if (!ground) return 0; - const level = field.level?.(nowMs) ?? 1; - if (!(level > 0)) return 0; return ( level * awarenessEchoStrength( diff --git a/src/awarenessEcho.test.mjs b/src/awarenessEcho.test.mjs index 846c630c4..b68e2eb96 100644 --- a/src/awarenessEcho.test.mjs +++ b/src/awarenessEcho.test.mjs @@ -4,6 +4,7 @@ import test from 'node:test'; import assert from 'node:assert/strict'; import * as Cesium from 'cesium'; import { + awarenessEchoActive, awarenessEchoAt, awarenessEchoStrength, clearAwarenessEchoField, @@ -114,8 +115,13 @@ test('echoes scale with the field level and rest with the pulses', (t) => { assert.ok( Math.abs(awarenessEchoAt('flights', contact, t0) - full / 2) < 1e-9, ); + assert.equal(awarenessEchoActive(t0), true); level = 0; assert.equal(awarenessEchoAt('flights', contact, t0), 0); + // At rest the overlay skips echoes for the whole frame. + assert.equal(awarenessEchoActive(t0), false); + clearAwarenessEchoField(); + assert.equal(awarenessEchoActive(t0), false); }); test('near the subject an echo is no brighter than the fading-in front', () => { diff --git a/src/data/detection.js b/src/data/detection.js index de896e046..e3cb41bfe 100644 --- a/src/data/detection.js +++ b/src/data/detection.js @@ -54,6 +54,7 @@ import { isCyberContactThemeActive as isCyberMapThemeActive, } from '../cyberSonar.js'; import { + awarenessEchoActive, awarenessEchoBracket, awarenessEchoColor, getAwarenessEchoField, @@ -1344,7 +1345,8 @@ function _drawOverlay(frame) { const bracketPaths = new Map(); // Global Context sonar echoes: a second bracket, banded by strength, around // each flight or vessel a pulse front has just crossed. - const echoField = getAwarenessEchoField(); + // Resting pulses light nothing, so the per-contact echo work is skipped. + const echoField = awarenessEchoActive(now) ? getAwarenessEchoField() : null; const echoPaths = echoField ? new Array(ECHO_ALPHA_STEPS + 1) : null; const pathFor = (map, color, alpha) => { const band = Math.max( diff --git a/src/data/detectionHost.test.mjs b/src/data/detectionHost.test.mjs index 2e3829c94..47e35c48e 100644 --- a/src/data/detectionHost.test.mjs +++ b/src/data/detectionHost.test.mjs @@ -673,6 +673,60 @@ test('Global Context echo brackets reach the canvas in the detection style colou `${style} echoes take the style line colour, got ${strokes}`, ); } + + } finally { + clearAwarenessEchoField(); + env.cleanup(); + } +}); + +test('resting Global Context pulses skip echo work for the whole frame, not per contact', () => { + const env = installEnvironment(); + // Six echo-eligible flights on screen; the identity camera maps x/y + // straight to device coordinates. + const flights = Array.from({ length: 6 }, (_, index) => ({ + position: new Cesium.Cartesian3(-0.5 + index * 0.2, 0, 6_356_752), + sourceId: `flight-${index}`, + id: `ECHO${index}`, + metric: `FL${300 + index}`, + type: 'AIR', + })); + const layer = { id: 'flights', getDetectableObjects: () => flights }; + let levelCalls = 0; + try { + setAwarenessEchoField({ + center: Cesium.Cartesian3.fromDegrees(0, 89, 0), + radiusM: 250_000, + pulse: { count: 1000, periodMs: 1000, startFraction: 0 }, + echo: { + layerIds: ['flights'], + fadeMs: 60_000, + color: 'rgb(1, 2, 3)', + spreadPx: 8, + lineWidth: 3.25, + }, + level: () => { + levelCalls++; + return 0; + }, + }); + initWorldOverlay(env.viewer); + initDetection(env.viewer, [layer], () => {}); + setMode('DENSE'); + settleFrame(env); + levelCalls = 0; + settleFrame(env); + const painted = getDetectionDiagnostics()?.visibleCount ?? flights.length; + assert.ok(painted >= 2, `contacts on screen: ${painted}`); + // One check per painted frame, however many contacts are on screen. + assert.ok(levelCalls >= 1, 'the frame asks whether echoes are live'); + assert.ok(levelCalls < painted, `${levelCalls} level checks for ${painted} contacts`); + assert.ok( + !env.detectionCtx.calls.some( + ([name, , style]) => name === 'stroke' && style === 'rgb(1, 2, 3)', + ), + 'no echoes while the pulses rest', + ); } finally { clearAwarenessEchoField(); env.cleanup(); diff --git a/src/layers/awareness/policy.js b/src/layers/awareness/policy.js index 11fb866ae..64e719160 100644 --- a/src/layers/awareness/policy.js +++ b/src/layers/awareness/policy.js @@ -76,15 +76,17 @@ export const AWARENESS_RANGE_RING_STYLE = Object.freeze({ * `fadeOutPower` down to `endOpacityFraction` of `peakOpacity`, so a pulse * is dim but still visible when it reaches the ring and the ring flashes. * Each line is `width` CSS pixels wide, its outer `outlineWidth` a dark - * halo, in the ring's colours, as Cesium's polyline outline material draws. Behind each line a radial gradient in the same blue - * fades inward over `gradientDepthFraction` of the radius, at most + * halo, in the ring's colours, as Cesium's polyline outline material draws. + * Behind each line a radial gradient in the same blue fades inward over + * `gradientDepthFraction` of the radius, at most * `gradientOpacity` strong. One unpickable disc draws the lines and the * gradient, so pulses never catch a click meant for a contact; its shader * takes at most four pulses. The disc is rebuilt only after the subject * moves `gradientRebuildM`, so the pulses sit at most that far off the ring's * centre, about a pixel when the ring fills the screen; while the pulses - * rest the hidden disc is left alone and catches up when they wake. The disc is hidden - * while the camera is below its height, where it would sweep the sky. + * rest the hidden disc is left alone and catches up when they wake. The + * disc is hidden while the camera is below its height, where it would sweep + * the sky. * The pulses run for `activeMs` (three ring flashes) after a selection or * a camera move, fading in and out over `settleMs`, then rest: the ring * stays on screen, static, and nothing asks for frames, so a parked view @@ -95,7 +97,6 @@ export const AWARENESS_RANGE_RING_STYLE = Object.freeze({ * Cyber sonar's 80 ms cadence, so the two never beat. When something else * already renders continuously they update every frame. Skipped under * prefers-reduced-motion. */ - export const AWARENESS_RANGE_PULSE = Object.freeze({ count: 3, periodMs: 9000, @@ -120,7 +121,6 @@ export const AWARENESS_RANGE_PULSE = Object.freeze({ * bracket. It starts on the bracket and spreads outward by up to `spreadPx` * as it fades over `fadeMs`, so the frame pulses. Only `layerIds` echo; * mapped installations have no detection bracket. */ - export const AWARENESS_RANGE_ECHO = Object.freeze({ layerIds: ['flights', 'military', 'ais-live-vessels'], fadeMs: 1400, From a851199f5b6aa1b14e387056b1a7879aa02cc455 Mon Sep 17 00:00:00 2001 From: Manjunath Date: Tue, 3 Nov 2026 22:50:32 +0530 Subject: [PATCH 08/12] fix(awareness): rest the range pulses unless the user moves the camera Review follow-ups for the sonar pulses. Wake on user input only: a camera move wakes the pulses only within 1.5 s of a pointer, wheel or touch on the Cesium canvas, where its camera controller listens. Tracking, the cockpit view, an orbit or a fly-to move the camera on every frame and no longer keep the pulses running. Keys don't count: the camera controller reads none. Below the disc: with the camera under 2.5 km the pulses rest completely, with no disc, ring flash, echoes or frame requests. Echo brightness: echoes scale by the contact bracket's drawn alpha, which includes the Cyber sonar sweep, rounded to the nearest band with no floor, so an echo is never brighter than its bracket. Disc follow: the pulse disc follows the subject by a model-matrix rotation about the Earth's centre and is rebuilt only every 20 km instead of every 500 m. Tests: a cap on burst length; teardown through disable(); the echo's spread and alpha on the canvas; echoes under the Cyber sonar never brighter than their bracket; waking by pointer, wheel, touch and drag but not by keys, app camera moves or stale input; the rest below the disc; and tighter asserts. CURRENT-STATE notes that terrain above 2.5 km still cuts through the disc and ring. --- docs/CURRENT-STATE.md | 2 +- src/awarenessEcho.js | 19 +- src/data/detection.js | 2 +- src/data/detectionAwarenessEcho.test.mjs | 19 +- src/data/detectionHost.test.mjs | 134 +++++++++++++- src/layers/awareness/lifecycle.js | 41 ++++- src/layers/awareness/policy.js | 19 +- src/layers/awareness/rangeRing.test.mjs | 220 ++++++++++++++++++++--- src/layers/awareness/rendering.js | 70 ++++++-- src/layers/awareness/state.js | 4 + 10 files changed, 457 insertions(+), 73 deletions(-) diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index be0d26204..fd4789f60 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, drawn as an unpickable primitive rather than an entity so a click or tap on its edge reaches the contact underneath (headless viewers without a primitive collection get a static entity instead); it is rebuilt when the subject moves. Three dim sonar pulses (3 px with a 1 px dark outline, 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. One unpickable 250 km disc draws both the pulse lines and the gradient, its shader reading the pulse radii and opacities each frame, so a click or tap near a pulse still reaches the contact underneath; the disc is rebuilt only after the subject moves 500 m, and not at all while the pulses rest, and it is hidden while the camera is below its 2.5 km height, where it would sweep across the sky. While pulses run, the ring dims to 40% and flashes to full strength as each pulse reaches it, fading over 1.8 s. As a pulse front crosses a flight, military aircraft or AIS vessel, the detection overlay draws a second bracket around that contact's own bracket, in the pulse blue on the default detection style and in the style's own line colour under NVG, FLIR and CRT, 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 are born 5 km out, so the subject itself and anything nearer never echo. Pulses run for 9 s (three ring flashes) after a selection or a camera move, fading in and out over 1.2 s, then rest: the ring stays on screen at full strength, the pulses, gradient and echoes are hidden, and nothing asks for frames, so a parked Contacts view is quiet again. A moving subject does not wake them, since a flying subject moves on every refresh; a camera that follows a tracked aircraft does keep them running, but that follow already renders every frame. While they run they never hold continuous rendering: each drawn frame arms one render request 80 ms later (the Cyber sonar cadence, `CYBER_SONAR_RENDER_INTERVAL_MS`), so they repaint at about 12.5 fps and never beat the detection lane's 80 ms gate. Under `prefers-reduced-motion`, or in a scene without primitives or preRender and postRender hooks, there are no pulses or echoes and the ring stays on screen; changing the preference mid-session rebuilds the ring. 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, drawn as an unpickable primitive rather than an entity so a click or tap on its edge reaches the contact underneath (headless viewers without a primitive collection get a static entity instead); it is rebuilt when the subject moves. Three dim sonar pulses (3 px with a 1 px dark outline, 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. One unpickable 250 km disc draws both the pulse lines and the gradient, its shader reading the pulse radii and opacities each frame, so a click or tap near a pulse still reaches the contact underneath; the disc follows the subject by a model-matrix rotation and is rebuilt only after the subject moves 20 km, and not at all while the pulses rest. While the camera is below the disc's 2.5 km height, where it would sweep across the sky, the pulses rest: the disc is hidden and there is no ring flash, no echo and no frame request. Terrain or 3D tiles higher than 2.5 km (the Alps, Andes, Rockies) still cut through the disc and the ring when seen from above, as the ring alone already did; the height check uses the camera's height above the ellipsoid, not above the ground. While pulses run, the ring dims to 40% and flashes to full strength as each pulse reaches it, fading over 1.8 s. As a pulse front crosses a flight, military aircraft or AIS vessel, the detection overlay draws a second bracket around that contact's own bracket, in the pulse blue on the default detection style and in the style's own line colour under NVG, FLIR and CRT, 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 are born 5 km out, so the subject itself and anything nearer never echo. Pulses run for 9 s (three ring flashes) after a selection or a camera move, fading in and out over 1.2 s, then rest: the ring stays on screen at full strength, the pulses, gradient and echoes are hidden, and nothing asks for frames, so a parked Contacts view is quiet again. Only a camera move within 1.5 s of the user handling the map (pointer, wheel or touch on the Cesium canvas, which covers a drag's inertia; keys typed elsewhere do not count) wakes them; a moving subject and the app's own camera moves (a tracked aircraft, the cockpit view, an orbit, a fly-to) do not, since they move on every refresh. While they run they never hold continuous rendering: each drawn frame arms one render request 80 ms later (the Cyber sonar cadence, `CYBER_SONAR_RENDER_INTERVAL_MS`), so they repaint at about 12.5 fps and never beat the detection lane's 80 ms gate. Under `prefers-reduced-motion`, or in a scene without primitives or preRender and postRender hooks, there are no pulses or echoes and the ring stays on screen; changing the preference mid-session rebuilds the ring. 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/src/awarenessEcho.js b/src/awarenessEcho.js index f4c106ead..44a5e37fa 100644 --- a/src/awarenessEcho.js +++ b/src/awarenessEcho.js @@ -131,14 +131,16 @@ export function awarenessEchoAt(layerId, position, nowMs) { /** * The echo bracket the detection overlay draws around one contact, or null - * when it has no echo. `band` is the alpha band, 1 to `steps`, scaled by the - * contact's own keyhole `admissionAlpha`; `spreadPx` is how far the echo - * sits outside the contact's bracket, 0 as the front arrives, growing to the + * when it has no echo. `band` is the alpha band, 1 to `steps`, the echo's + * strength times the contact bracket's own `bracketAlpha` rounded to the + * nearest band, so an echo is never drawn brighter than its bracket; one + * that rounds to nothing is not drawn. `spreadPx` is how far the echo sits + * outside the contact's bracket, 0 as the front arrives, growing to the * field's `spreadPx` as it fades. * @param {string} layerId Owning layer of the contact. * @param {Cesium.Cartesian3} position Contact's world position. * @param {number} nowMs Monotonic clock in milliseconds. - * @param {number} admissionAlpha The contact bracket's own fade, 0 to 1. + * @param {number} bracketAlpha The contact bracket's drawn alpha, 0 to 1. * @param {number} steps Alpha bands the overlay batches by. * @returns {{band: number, spreadPx: number}|null} */ @@ -146,15 +148,14 @@ export function awarenessEchoBracket( layerId, position, nowMs, - admissionAlpha, + bracketAlpha, steps, ) { const echo = awarenessEchoAt(layerId, position, nowMs); if (!(echo > 0.01)) return null; - return { - band: Math.max(1, Math.ceil(echo * admissionAlpha * steps)), - spreadPx: field.echo.spreadPx * (1 - echo), - }; + const band = Math.min(steps, Math.round(echo * bracketAlpha * steps)); + if (!(band >= 1)) return null; + return { band, spreadPx: field.echo.spreadPx * (1 - echo) }; } /** diff --git a/src/data/detection.js b/src/data/detection.js index e3cb41bfe..36ed6372e 100644 --- a/src/data/detection.js +++ b/src/data/detection.js @@ -1456,7 +1456,7 @@ function _drawOverlay(frame) { obj._layerId, obj.position, now, - admissionAlpha, + bracketAlpha, ECHO_ALPHA_STEPS, ); if (echo) diff --git a/src/data/detectionAwarenessEcho.test.mjs b/src/data/detectionAwarenessEcho.test.mjs index a9c4fecc3..852ea7f2b 100644 --- a/src/data/detectionAwarenessEcho.test.mjs +++ b/src/data/detectionAwarenessEcho.test.mjs @@ -39,8 +39,8 @@ test('the echo bracket pulses outward from the contact bracket and fades', (t) = setAwarenessEchoField(field); const position = Cesium.Cartesian3.fromDegrees(3.5, 27, 10_000); const arrival = arrivalAt(position); - const at = (nowMs, admissionAlpha = 1) => - awarenessEchoBracket('flights', position, nowMs, admissionAlpha, STEPS); + const at = (nowMs, bracketAlpha = 1) => + awarenessEchoBracket('flights', position, nowMs, bracketAlpha, STEPS); // The moment the front arrives: on the bracket, strongest band. const fresh = at(arrival); @@ -53,8 +53,19 @@ test('the echo bracket pulses outward from the contact bracket and fades', (t) = assert.ok(half.spreadPx > echo.spreadPx * 0.5); assert.ok(half.spreadPx < echo.spreadPx); assert.ok(half.band < STEPS && half.band >= 1); - // Keyhole fade carries over to the echo. - assert.ok(at(arrival, 0.25).band <= 2); + // The bracket's own dimming (keyhole fade, Cyber sonar sweep) carries + // over, and the echo is never drawn brighter than its bracket: a bracket + // too dim for even one band draws no echo. + assert.equal(at(arrival, 0.25).band, 2); + assert.equal(at(arrival, 0.05), null); + for (let alpha = 0.05; alpha <= 1; alpha += 0.05) { + const echoed = at(arrival + echo.fadeMs / 3, alpha); + if (echoed) + assert.ok( + echoed.band / STEPS <= alpha + 0.5 / STEPS + 1e-9, + `band ${echoed.band} for a bracket at ${alpha}`, + ); + } // Faded out, or between fronts: no echo bracket. assert.equal(at(arrival + echo.fadeMs + 5), null); }); diff --git a/src/data/detectionHost.test.mjs b/src/data/detectionHost.test.mjs index 47e35c48e..f8858d602 100644 --- a/src/data/detectionHost.test.mjs +++ b/src/data/detectionHost.test.mjs @@ -54,8 +54,9 @@ class MockEvent { } class MockPath2D { - moveTo() {} - lineTo() {} + constructor() { this.points = []; } + moveTo(x, y) { this.points.push([x, y]); } + lineTo(x, y) { this.points.push([x, y]); } roundRect() {} arcTo() {} closePath() {} @@ -102,7 +103,7 @@ function mockContext(target, trace) { // set at some point during the frame. globalAlpha rides along for the same // reason: the backdrop feather is an alpha, not a colour. fill(path) { record('fill', path, this.fillStyle, this.globalAlpha); }, - stroke(path) { record('stroke', path, this.strokeStyle, this.lineWidth); }, + stroke(path) { record('stroke', path, this.strokeStyle, this.lineWidth, this.globalAlpha); }, fillRect(...args) { record('fillRect', ...args); }, fillText(...args) { record('fillText', ...args); }, drawImage(...args) { record('drawImage', ...args); }, @@ -680,6 +681,129 @@ test('Global Context echo brackets reach the canvas in the detection style colou } }); +test('a Global Context echo bracket is drawn outside its contact bracket and dimmer than it', () => { + const env = installEnvironment(); + const echo = { + layerIds: ['military'], + fadeMs: 60_000, + color: 'rgb(1, 2, 3)', + spreadPx: 8, + lineWidth: 3.25, + }; + // Fronts every millisecond keep the echo at full strength; a level of 0.5 + // halves it, so it sits 4 px out and draws at half the bracket's alpha. + try { + setAwarenessEchoField({ + center: Cesium.Cartesian3.fromDegrees(0, 89, 0), + radiusM: 250_000, + pulse: { count: 1000, periodMs: 1000, startFraction: 0 }, + echo, + level: () => 0.5, + }); + initWorldOverlay(env.viewer); + initDetection(env.viewer, [militaryLayer()], () => {}); + setMode('DENSE'); + setDetectionStyle('normal'); + settleFrame(env); + env.detectionCtx.calls.length = 0; + settleFrame(env); + const strokes = env.detectionCtx.calls.filter(([name]) => name === 'stroke'); + const echoStroke = strokes.find(([, , style, width]) => style === echo.color && width === echo.lineWidth); + assert.ok(echoStroke, 'the echo bracket is painted'); + const bracketStroke = strokes.find(([, path, style, width]) => + path instanceof MockPath2D && path.points.length && width !== echo.lineWidth && style !== echo.color); + assert.ok(bracketStroke, 'the contact bracket is painted'); + const extent = (path) => { + const xs = path.points.map(([x]) => x); + const ys = path.points.map(([, y]) => y); + return { halfW: (Math.max(...xs) - Math.min(...xs)) / 2, halfH: (Math.max(...ys) - Math.min(...ys)) / 2 }; + }; + const echoBox = extent(echoStroke[1]); + const bracketBox = extent(bracketStroke[1]); + assert.ok(Math.abs(echoBox.halfW - bracketBox.halfW - echo.spreadPx / 2) < 1e-3, + `echo ${echoBox.halfW} vs bracket ${bracketBox.halfW}`); + assert.ok(Math.abs(echoBox.halfH - bracketBox.halfH - echo.spreadPx / 2) < 1e-3); + const echoAlpha = echoStroke[4]; + const bracketAlpha = bracketStroke[4]; + assert.ok(echoAlpha > 0 && echoAlpha < bracketAlpha, `${echoAlpha} vs ${bracketAlpha}`); + assert.ok(Math.abs(echoAlpha - bracketAlpha / 2) < 1e-6, `${echoAlpha} vs ${bracketAlpha}`); + } finally { + clearAwarenessEchoField(); + env.cleanup(); + } +}); + +test('under the Cyber sonar sweep an echo is never brighter than its own bracket', () => { + const env = installEnvironment(); + // The cyber theme with its HUD open turns the contact sonar on; its sweep + // dims brackets it is not passing over. + env.document.documentElement = { + // The lowest sweep floor, so dimmed brackets drop a whole alpha band. + dataset: { uiTheme: 'cyber', cyberSonarOpacity: '35' }, + style: { setProperty() {}, removeProperty() {} }, + }; + const hud = env.document.createElement('div'); + hud.id = 'intel-hud'; + hud.classList = { contains: (name) => name === 'active' }; + env.document.body.appendChild(hud); + const echo = { + layerIds: ['military'], + fadeMs: 60_000, + color: 'rgb(1, 2, 3)', + spreadPx: 8, + lineWidth: 3.25, + }; + const contact = { + id: 'military', + getDetectableObjects: () => [{ + position: new Cesium.Cartesian3(0.5, 0.3, 6_356_752), + sourceId: 'sonar-pin', + id: 'SONAR', + metric: 'FL300', + type: 'AIR', + tier: 'military', + }], + }; + try { + setAwarenessEchoField({ + center: Cesium.Cartesian3.fromDegrees(0, 89, 0), + radiusM: 250_000, + pulse: { count: 1000, periodMs: 1000, startFraction: 0 }, + echo, + level: () => 1, + }); + initWorldOverlay(env.viewer); + initDetection(env.viewer, [contact], () => {}); + setMode('DENSE'); + setDetectionStyle('normal'); + settleFrame(env); + let dimmed = 0; + let compared = 0; + for (let frame = 0; frame < 40; frame++) { + env.detectionCtx.calls.length = 0; + settleFrame(env); + const strokes = env.detectionCtx.calls.filter(([name]) => name === 'stroke'); + const echoStroke = strokes.find(([, , style, width]) => style === echo.color && width === echo.lineWidth); + const bracketStroke = strokes.find(([, path, style, width]) => + path instanceof MockPath2D && path.points.length && width !== echo.lineWidth && style !== echo.color); + if (!bracketStroke) continue; + const bracketAlpha = bracketStroke[4]; + if (bracketAlpha < 0.99) dimmed++; + if (!echoStroke) continue; + compared++; + assert.ok( + echoStroke[4] <= bracketAlpha + 1e-9, + `echo ${echoStroke[4]} over bracket ${bracketAlpha} on frame ${frame}`, + ); + } + assert.ok(dimmed > 0, 'the sweep dimmed the bracket on some frame'); + assert.ok(compared > 0, 'an echo was painted'); + } finally { + clearAwarenessEchoField(); + env.cleanup(); + } +}); + test('resting Global Context pulses skip echo work for the whole frame, not per contact', () => { const env = installEnvironment(); // Six echo-eligible flights on screen; the identity camera maps x/y @@ -716,8 +840,8 @@ test('resting Global Context pulses skip echo work for the whole frame, not per settleFrame(env); levelCalls = 0; settleFrame(env); - const painted = getDetectionDiagnostics()?.visibleCount ?? flights.length; - assert.ok(painted >= 2, `contacts on screen: ${painted}`); + const painted = getDetectionDiagnostics().visibleCount; + assert.equal(painted, flights.length, 'every flight is on screen'); // One check per painted frame, however many contacts are on screen. assert.ok(levelCalls >= 1, 'the frame asks whether echoes are live'); assert.ok(levelCalls < painted, `${levelCalls} level checks for ${painted} contacts`); diff --git a/src/layers/awareness/lifecycle.js b/src/layers/awareness/lifecycle.js index 27a7d0e55..272b85edc 100644 --- a/src/layers/awareness/lifecycle.js +++ b/src/layers/awareness/lifecycle.js @@ -1,4 +1,5 @@ import { cameraPoseSignature } from '../../data/iconOrientation.js'; +import { AWARENESS_RANGE_PULSE } from './policy.js'; export function createLifecycle({ state: layerState, @@ -8,6 +9,26 @@ export function createLifecycle({ }) { const { holdContinuousRender, releaseContinuousRender } = services.render; + /** + * Note the user handling the map's camera (pointer, wheel or touch on the + * Cesium canvas, where its camera controller listens), so a camera move the + * user made can wake the range pulses and one the app made (tracking, + * cockpit, orbit, a fly-to) does not. Keys are left out: Cesium's camera + * controller reads none, and typing elsewhere must not count. + */ + function noteCameraInput(event) { + if (event?.type === 'pointermove' && !event.buttons) return; + layerState.lastCameraInputMs = Date.now(); + } + + const CAMERA_INPUT_EVENTS = [ + 'pointerdown', + 'pointermove', + 'pointerup', + 'wheel', + 'touchstart', + ]; + /** * Quantized camera-pose signature, or '' when the camera cannot report a full * pose yet. An unknown pose reads as PARKED, so a camera that is still coming @@ -50,6 +71,10 @@ export function createLifecycle({ 'gev:awareness-subject-cleared', layerState.subjectClearListener, ); + const input = layerState.viewer.canvas ?? layerState.viewer.scene?.canvas; + for (const type of CAMERA_INPUT_EVENTS) + input?.addEventListener?.(type, noteCameraInput, { passive: true }); + layerState.cameraInputTarget = input ?? null; layerState.preRenderRemover = layerState.viewer.scene.preRender.addEventListener(() => { if (!layerState.enabled) return; @@ -62,8 +87,14 @@ export function createLifecycle({ if (poseSig !== layerState.lastCameraPoseSig) { layerState.lastCameraPoseSig = poseSig; layerState.lastCameraPoseChangeMs = now; - // A camera move runs the range pulses again; parked, they rest. - parts.rendering.wakePulses(); + // A camera move the user made runs the range pulses again; parked, + // or moved by the app (tracking, cockpit, orbit, a fly-to), they + // rest. + if ( + now - layerState.lastCameraInputMs < + AWARENESS_RANGE_PULSE.inputWakeMs + ) + parts.rendering.wakePulses(); } const decision = parts.model.awarenessRefreshDecision({ nowMs: now, @@ -110,6 +141,12 @@ export function createLifecycle({ 'gev:awareness-subject-cleared', layerState.subjectClearListener, ); + for (const type of CAMERA_INPUT_EVENTS) + layerState.cameraInputTarget?.removeEventListener?.( + type, + noteCameraInput, + ); + layerState.cameraInputTarget = null; } layerState.preRenderRemover?.(); layerState.preRenderRemover = null; diff --git a/src/layers/awareness/policy.js b/src/layers/awareness/policy.js index 64e719160..1c1320768 100644 --- a/src/layers/awareness/policy.js +++ b/src/layers/awareness/policy.js @@ -81,18 +81,20 @@ export const AWARENESS_RANGE_RING_STYLE = Object.freeze({ * `gradientDepthFraction` of the radius, at most * `gradientOpacity` strong. One unpickable disc draws the lines and the * gradient, so pulses never catch a click meant for a contact; its shader - * takes at most four pulses. The disc is rebuilt only after the subject - * moves `gradientRebuildM`, so the pulses sit at most that far off the ring's - * centre, about a pixel when the ring fills the screen; while the pulses - * rest the hidden disc is left alone and catches up when they wake. The + * takes at most four pulses. The disc follows the subject by a model-matrix + * rotation, so it stays concentric with the ring, and is rebuilt only after + * the subject moves `gradientRebuildM` from where it was built; while the + * pulses rest the hidden disc is left alone and catches up when they wake. The * disc is hidden while the camera is below its height, where it would sweep * the sky. * The pulses run for `activeMs` (three ring flashes) after a selection or * a camera move, fading in and out over `settleMs`, then rest: the ring * stays on screen, static, and nothing asks for frames, so a parked view - * goes quiet again. A moving subject does not wake them, since a flying - * subject moves on every refresh, but a camera that follows it does; the - * follow already renders every frame. While they run they never hold + * goes quiet again. Only a camera move within `inputWakeMs` of the user + * handling the map (pointer, wheel or touch on the canvas, which covers a + * drag's inertia) wakes them; a moving subject and the app's own camera moves + * (tracking, cockpit, orbit, a fly-to) do not. Below the disc's height they + * rest too. While they run they never hold * continuous rendering; each drawn frame arms one more request after the * Cyber sonar's 80 ms cadence, so the two never beat. When something else * already renders continuously they update every frame. Skipped under @@ -110,9 +112,10 @@ export const AWARENESS_RANGE_PULSE = Object.freeze({ gradientDepthFraction: 0.35, gradientOpacity: 0.22, gradientGranularityDeg: 0.25, - gradientRebuildM: 500, + gradientRebuildM: 20000, activeMs: 9000, settleMs: 1200, + inputWakeMs: 1500, }); /** @constant Sonar echoes on contact brackets. When a pulse front reaches a diff --git a/src/layers/awareness/rangeRing.test.mjs b/src/layers/awareness/rangeRing.test.mjs index f005d3b21..17caa3bbc 100644 --- a/src/layers/awareness/rangeRing.test.mjs +++ b/src/layers/awareness/rangeRing.test.mjs @@ -133,10 +133,15 @@ function ringRendering(t, { reducedMotion = false, legacyQuery = false } = {}) { * postRender) on the next millisecond, as Cesium's request-render mode does. * `draw()` draws a frame someone else asked for. */ -function frameLoop(t, ring) { +function frameLoop(t, ring, { date = false } = {}) { let clock = 1_000_000; t.mock.method(performance, 'now', () => clock); - t.mock.timers.enable({ apis: ['setTimeout', 'setInterval'] }); + t.mock.timers.enable({ + apis: date + ? ['setTimeout', 'setInterval', 'Date'] + : ['setTimeout', 'setInterval'], + now: clock, + }); let drawn = 0; const draw = () => { drawn = ring.reasons.length; @@ -168,6 +173,15 @@ function pulsePrimitives(scene) { return { ring, disc: discs[0], count: discs.length }; } +/** Where a disc built around `built` sits after its model matrix. */ +function discCenter(disc, built) { + return Cesium.Matrix4.multiplyByPoint( + disc.modelMatrix, + built, + new Cesium.Cartesian3(), + ); +} + /** The ring's material uniforms as the next drawn frame sets them. */ function ringColors(scene) { scene.preRender.raiseEvent(scene); @@ -239,13 +253,15 @@ test('the ring core and halo contrast on light and dark ground', () => { }); test('renderVisual draws one haloed polyline ring, not an ellipse outline', (t) => { - const { entities, scene, state, rendering } = ringRendering(t); + const ring = ringRendering(t); + const { entities, scene, state, rendering } = ring; + frameLoop(t, ring); // freeze the clock: the pulses have not faded in yet rendering.renderVisual(flight(3)); // A polyline primitive, never an entity or an ellipse outline. assert.equal(entities.values.length, 0); - const { ring } = pulsePrimitives(scene); - assert.ok(ring); - const geometry = ring.geometryInstances.geometry; + const ringPrimitive = pulsePrimitives(scene).ring; + assert.ok(ringPrimitive); + const geometry = ringPrimitive.geometryInstances.geometry; assert.ok(geometry instanceof Cesium.PolylineGeometry); // 4 px wide, straight chords between surface-following positions, as the // entity ring was drawn. @@ -258,16 +274,17 @@ test('renderVisual draws one haloed polyline ring, not an ellipse outline', (t) options.vertexFormat, Cesium.PolylineMaterialAppearance.VERTEX_FORMAT, ); - const { material } = ring.appearance; + const { material } = ringPrimitive.appearance; assert.equal(material.type, 'PolylineOutline'); - // The ring's hue is fixed; its opacity flashes with arriving pulses. + // The ring's hue is fixed; its opacity flashes with arriving pulses, and + // at the moment it appears, before the pulses fade in, it is at full. const core = Cesium.Color.fromCssColorString( AWARENESS_RANGE_RING_STYLE.color, ); const { color, outlineWidth } = ringColors(scene); assert.equal(color.red, core.red); assert.equal(color.blue, core.blue); - assert.ok(color.alpha >= 0 && color.alpha <= core.alpha + 1e-9); + assert.ok(Math.abs(color.alpha - core.alpha) < 1e-9, `${color.alpha}`); assert.equal(outlineWidth, AWARENESS_RANGE_RING_STYLE.outlineWidth); const ground = Cesium.Cartesian3.fromDegrees(3, 27, 0); for (const distance of surfaceDistances(ground, state.visual.ringPositions)) @@ -305,9 +322,15 @@ test('the ring and pulses follow the subject past 250 m and ignore smaller drift )) assert.ok(Math.abs(distance - AWARENESS_RADIUS_M) < 100); assert.equal(renders(), rendersAfterAdd + 1); - // The pulses are drawn on a disc, rebuilt around the NEW centre a - // kilometre on, so they stay concentric with the ring. - assert.notEqual(pulsePrimitives(scene).disc, discBefore); + // The pulses are drawn on a disc that follows to the NEW centre by its + // model matrix, so they stay concentric with the ring without a rebuild. + assert.equal(pulsePrimitives(scene).disc, discBefore); + assert.ok( + Cesium.Cartesian3.distance( + discCenter(discBefore, Cesium.Cartesian3.fromDegrees(3, 27, 0)), + Cesium.Cartesian3.fromDegrees(3.01, 27, 0), + ) < 1, + ); rendering.clearVisual(); assert.equal(scene.primitives.length, 0); @@ -393,12 +416,18 @@ test('a parked view stops asking for frames once the pulses rest', (t) => { const { scene, rendering, pulseFrames } = ring; const loop = frameLoop(t, ring); const { activeMs, count, periodMs } = AWARENESS_RANGE_PULSE; - // Long enough for three ring flashes, then quiet. + // Long enough for three ring flashes, then quiet, and short: a burst is + // seconds, not minutes. assert.ok(activeMs >= 3 * (periodMs / count)); + assert.ok(activeMs <= 15_000, `${activeMs} ms burst`); rendering.renderVisual(flight(3)); loop.advance(activeMs + 500); const frames = pulseFrames(); assert.ok(frames >= activeMs / CYBER_SONAR_RENDER_INTERVAL_MS - 2); + assert.ok( + frames <= activeMs / CYBER_SONAR_RENDER_INTERVAL_MS + 2, + `${frames} frames in one burst`, + ); // Parked: a minute later not one more request. loop.advance(60_000); assert.equal(pulseFrames(), frames, 'render requests stop after the bound'); @@ -440,9 +469,12 @@ test('a camera move wakes resting pulses for another bounded run', (t) => { test('a camera move seen by the lifecycle wakes resting pulses; a parked camera does not', (t) => { const ring = ringRendering(t); const { state, rendering, pulseFrames } = ring; - const loop = frameLoop(t, ring); - const { activeMs } = AWARENESS_RANGE_PULSE; + const loop = frameLoop(t, ring, { date: true }); + const { activeMs, inputWakeMs } = AWARENESS_RANGE_PULSE; state.enabled = true; + state.viewer.canvas = new EventTarget(); + const touch = () => + state.viewer.canvas.dispatchEvent(new Event('pointerdown')); state.viewer.camera = { positionWC: Cesium.Cartesian3.fromDegrees(3, 20, 800_000), heading: 0, @@ -471,7 +503,9 @@ test('a camera move seen by the lifecycle wakes resting pulses; a parked camera loop.draw(); loop.advance(activeMs); assert.equal(pulseFrames(), rested); - // The camera pans 50 km: the next frame wakes them for another run. + // The user drags the camera 50 km: the next frame wakes them for another + // run. + touch(); state.viewer.camera.positionWC = Cesium.Cartesian3.fromDegrees( 3.5, 20, @@ -485,6 +519,115 @@ test('a camera move seen by the lifecycle wakes resting pulses; a parked camera const woke = pulseFrames(); loop.advance(60_000); assert.equal(pulseFrames(), woke, 'and rest again once it parks'); + // The app moving the camera, with no hand on it, does not wake them: a + // tracking follow, the cockpit view, an orbit or a fly-to all move it on + // every frame. + const follow = (lon) => { + state.viewer.camera.positionWC = Cesium.Cartesian3.fromDegrees( + lon, + 20, + 800_000, + ); + loop.draw(); + loop.advance(500); + }; + for (let lon = 3.6; lon < 5.6; lon += 0.1) follow(lon); + assert.equal(pulseFrames(), woke, 'app camera moves do not wake them'); + // Input long past (beyond `inputWakeMs`) does not count either; a hover + // without a button pressed is not handling the camera. + touch(); + loop.advance(inputWakeMs + 100); + state.viewer.canvas.dispatchEvent( + Object.assign(new Event('pointermove'), { buttons: 0 }), + ); + follow(5.7); + assert.equal(pulseFrames(), woke, 'stale input does not wake them'); + // Each way of handling the map counts: a wheel, a touch, a drag; keys + // typed anywhere in the window do not. + const panWith = (handle, lon) => { + loop.advance(activeMs + 500); + const before = pulseFrames(); + handle(); + follow(lon); + loop.advance(activeMs); + return pulseFrames() > before; + }; + const onCanvas = (event) => () => state.viewer.canvas.dispatchEvent(event); + assert.equal(panWith(onCanvas(new Event('wheel')), 5.8), true, 'wheel'); + assert.equal(panWith(onCanvas(new Event('touchstart')), 5.9), true, 'touch'); + assert.equal( + panWith( + onCanvas(Object.assign(new Event('pointermove'), { buttons: 1 })), + 6.0, + ), + true, + 'drag', + ); + assert.equal( + panWith(() => window.dispatchEvent(new Event('keydown')), 6.1), + false, + 'keys do not count', + ); + // Detached, the canvas listeners are gone. + lifecycle.detachRuntimeListeners(); + touch(); + assert.equal(state.lastCameraInputMs < Date.now() - inputWakeMs, true); +}); + +test('disabling the layer tears the pulses down completely', (t) => { + const ring = ringRendering(t); + const { state, scene, rendering, motion, pulseFrames } = ring; + const loop = frameLoop(t, ring); + state.viewer.camera = { + positionWC: Cesium.Cartesian3.fromDegrees(3, 20, 800_000), + heading: 0, + pitch: -1, + roll: 0, + }; + const lifecycle = createLifecycle({ + state, + services: { + render: { holdContinuousRender() {}, releaseContinuousRender() {} }, + }, + parts: { + rendering, + model: { + awarenessRefreshDecision: () => ({ moving: false, refresh: false }), + awarenessNeedsContinuousRender: () => false, + }, + panel: { + startAwarenessPageRotation() {}, + stopAwarenessPageRotation() {}, + ensurePanel() {}, + hidePanel() {}, + }, + dependencies: { + activateOperationalContext: () => true, + releaseOwnedDependencies: () => true, + }, + }, + }); + state.passive = true; + lifecycle.methods.enable(); + state.subject = flight(3); + rendering.renderVisual(state.subject); + loop.advance(1000); + assert.ok(pulseFrames() > 0); + assert.equal(scene.primitives.length, 2); + lifecycle.methods.disable(); + assert.equal(scene.primitives.length, 0, 'ring and disc removed'); + assert.equal(scene.preRender.numberOfListeners, 0); + assert.equal(scene.postRender.numberOfListeners, 0); + assert.equal(motion.listeners(), 0, 'no media-query listener'); + assert.equal(getAwarenessEchoField(), null); + assert.equal(state.visual, null); + const frames = pulseFrames(); + loop.advance(60_000); + assert.equal( + pulseFrames(), + frames, + 'no awareness-pulse requests after disable', + ); }); test('pulse requests wait 80 ms after any frame, so they cannot beat the Cyber sonar', (t) => { @@ -602,14 +745,23 @@ test('a gradient disc behind the pulses fades inward and follows the subject', ( 0, ); - // The disc is rebuilt only past `gradientRebuildM`, not on every 250 m - // step the ring takes, and keeps its material. - rendering.renderVisual(flight(3.001)); - assert.equal(pulsePrimitives(scene).disc, disc); - rendering.renderVisual(flight(3.004)); // ~400 m: the ring moves, the disc stays - assert.equal(pulsePrimitives(scene).disc, disc); - assert.ok(!disc.isDestroyed()); - rendering.renderVisual(flight(3.01)); // ~1 km from the disc's centre + // The disc follows by its model matrix and is rebuilt only past + // `gradientRebuildM` (20 km), keeping its material. + const built = Cesium.Cartesian3.fromDegrees(3, 27, 0); + for (const lon of [3.001, 3.01, 3.1, 3.19]) { + rendering.renderVisual(flight(lon)); + assert.equal(pulsePrimitives(scene).disc, disc, `${lon}`); + assert.ok(!disc.isDestroyed()); + // Concentric with the ring wherever the subject went, within a metre + // across the ground; the ellipsoid curves away from the rotation's + // sphere by tens of metres in height at most. + const at = Cesium.Cartographic.fromCartesian(discCenter(disc, built)); + const want = Cesium.Cartographic.fromCartesian(ring.state.visual.center); + const across = new Cesium.EllipsoidGeodesic(at, want).surfaceDistance; + assert.ok(across < 1, `${across} m off at ${lon}`); + assert.ok(Math.abs(at.height) < 50, `${at.height} m off the ground`); + } + rendering.renderVisual(flight(3.25)); // ~25 km from where it was built assert.equal(pulsePrimitives(scene).count, 1); const moved = pulsePrimitives(scene).disc; assert.notEqual(moved, disc); @@ -629,12 +781,15 @@ test('a resting pulse disc is not rebuilt as the subject moves, and catches up o rendering.renderVisual(flight(3)); loop.advance(AWARENESS_RANGE_PULSE.activeMs + 500); const resting = pulsePrimitives(scene).disc; - // A flying subject keeps moving while the pulses rest: no rebuilds. - for (let lon = 3.01; lon < 3.1; lon += 0.01) + const restingMatrix = Cesium.Matrix4.clone(resting.modelMatrix); + // A flying subject keeps moving while the pulses rest: the hidden disc is + // neither moved nor rebuilt, even well past the rebuild distance. + for (let lon = 3.01; lon < 3.4; lon += 0.01) rendering.renderVisual(flight(lon)); assert.equal(pulsePrimitives(scene).disc, resting); assert.ok(!resting.isDestroyed()); - // Woken, the disc moves to the subject at once. + assert.ok(Cesium.Matrix4.equals(resting.modelMatrix, restingMatrix)); + // Woken, the disc catches up at once: rebuilt around the subject. rendering.wakePulses(); const woken = pulsePrimitives(scene).disc; assert.notEqual(woken, resting); @@ -673,6 +828,17 @@ test('a camera below the rim height hides the pulse disc instead of sweeping the }; assert.equal(cameraAt(CONTEXT_RIM_HEIGHT_M + 500), true); assert.equal(cameraAt(CONTEXT_RIM_HEIGHT_M - 500), false, 'cockpit view'); + // Below the disc the pulses rest with it: the ring is steady at full, no + // echo lights and no frame is asked for. + const core = Cesium.Color.fromCssColorString( + AWARENESS_RANGE_RING_STYLE.color, + ); + assert.ok(Math.abs(ringColors(scene).color.alpha - core.alpha) < 1e-9); + assert.equal(getAwarenessEchoField().level(loop.now()), 0); + const frames = ring.pulseFrames(); + loop.draw(); + loop.advance(2000); + assert.equal(ring.pulseFrames(), frames, 'no requests below the rim'); assert.equal(cameraAt(800_000), true); }); diff --git a/src/layers/awareness/rendering.js b/src/layers/awareness/rendering.js index 5a5b5689b..2611dcbfb 100644 --- a/src/layers/awareness/rendering.js +++ b/src/layers/awareness/rendering.js @@ -308,6 +308,34 @@ function createRingPrimitive(positions, material) { }); } +/** + * The rotation about the Earth's centre that carries `from` onto `to`, as a + * model matrix, so a disc built around `from` sits around `to`. + * @param {Cesium.Cartesian3} from Where the disc was built. + * @param {Cesium.Cartesian3} to Where it should be. + * @param {Cesium.Matrix4} [result] + * @returns {Cesium.Matrix4} + */ +export function discFollowMatrix(from, to, result = new Cesium.Matrix4()) { + const a = Cesium.Cartesian3.normalize(from, new Cesium.Cartesian3()); + const b = Cesium.Cartesian3.normalize(to, new Cesium.Cartesian3()); + const axis = Cesium.Cartesian3.cross(a, b, new Cesium.Cartesian3()); + const sin = Cesium.Cartesian3.magnitude(axis); + if (sin < 1e-12) return Cesium.Matrix4.clone(Cesium.Matrix4.IDENTITY, result); + const angle = Math.atan2(sin, Cesium.Cartesian3.dot(a, b)); + const rotation = Cesium.Matrix3.fromQuaternion( + Cesium.Quaternion.fromAxisAngle( + Cesium.Cartesian3.divideByScalar(axis, sin, axis), + angle, + ), + ); + return Cesium.Matrix4.fromRotationTranslation( + rotation, + Cesium.Cartesian3.ZERO, + result, + ); +} + /** Camera height above the ellipsoid, or NaN when the scene cannot say. */ function cameraHeightM(scene) { return scene.camera?.positionCartographic?.height ?? Number.NaN; @@ -584,18 +612,27 @@ export function createRendering({ } /** - * Rebuild the pulse disc around the subject once it is `gradientRebuildM` - * from the disc's centre. A build costs a few milliseconds, so a subject - * that moves less keeps the disc it has. + * Keep the pulse disc centred on the subject. Within `gradientRebuildM` of + * where it was built, the disc is rotated about the Earth's centre onto + * the subject by its model matrix, which keeps it concentric with the ring + * at no build cost; the ellipsoid curves a little differently from that + * sphere, a few tens of metres in height at 20 km. Further away it is + * rebuilt, which costs a few milliseconds. */ function followPulseDisc() { const { gradient, gradientCenter, center } = layerState.visual; + if (!gradient) return; if ( - !gradient || Cesium.Cartesian3.distance(gradientCenter, center) < - AWARENESS_RANGE_PULSE.gradientRebuildM - ) + AWARENESS_RANGE_PULSE.gradientRebuildM + ) { + gradient.modelMatrix = discFollowMatrix( + gradientCenter, + center, + gradient.modelMatrix, + ); return; + } const primitives = layerState.viewer.scene.primitives; layerState.visual.gradient = primitives.add( createPulseGradient(center, gradient.appearance.material), @@ -655,8 +692,8 @@ export function createRendering({ CONTEXT_RIM_HEIGHT_M, ); // The echo field reads `visual.center` in place and follows on its - // own. The pulse disc is geometry, so it is rebuilt, but only every - // `gradientRebuildM`: a build costs a few milliseconds. + // own. The pulse disc follows by its model matrix and is rebuilt only + // every `gradientRebuildM`: a build costs a few milliseconds. layerState.visual.ringPositions = positions; const { ringPrimitive } = layerState.visual; if (ringPrimitive) { @@ -695,7 +732,12 @@ export function createRendering({ performance.now(), ) : null; - const levelAt = (nowMs) => (wake ? awarenessPulseLevel(nowMs, wake) : 0); + // From a camera below the rim height the disc would sweep across the sky, + // so it is hidden there, and the pulses rest with it: no flash, no + // echoes, no frames. + const belowRim = () => cameraHeightM(scene) < CONTEXT_RIM_HEIGHT_M; + const levelAt = (nowMs) => + wake && !belowRim() ? awarenessPulseLevel(nowMs, wake) : 0; // A polyline, not an ellipse outline: WebGL draws ellipse outlines at // 1 px whatever `outlineWidth` says, and the ring needs a halo. @@ -777,13 +819,9 @@ export function createRendering({ const alpha = ringAlpha(nowMs); ringMaterial.uniforms.color.alpha = core.alpha * alpha; ringMaterial.uniforms.outlineColor.alpha = halo.alpha * alpha; - // The disc is looked up each frame: a subject move swaps it. From a - // camera below the rim height it would sweep across the sky, so it - // is hidden there. + // The disc is looked up each frame: a subject move swaps it. const disc = layerState.visual?.gradient; - if (disc) - disc.show = - level > 0 && !(cameraHeightM(scene) < CONTEXT_RIM_HEIGHT_M); + if (disc) disc.show = level > 0; for (let index = 0; index < count; index++) { const state = awarenessPulseState(nowMs, index); radii[index] = state.radiusM / AWARENESS_RADIUS_M; @@ -800,7 +838,7 @@ export function createRendering({ const removePostRender = scene.postRender.addEventListener(() => { globalThis.clearTimeout(timer); timer = null; - if (performance.now() < wake.untilMs) + if (performance.now() < wake.untilMs && !belowRim()) timer = globalThis.setTimeout(() => { timer = null; governorRequestRender('awareness-pulse'); diff --git a/src/layers/awareness/state.js b/src/layers/awareness/state.js index 5fd8bb912..4d7d0d6e7 100644 --- a/src/layers/awareness/state.js +++ b/src/layers/awareness/state.js @@ -59,6 +59,10 @@ export function createState({ services }) { lastCameraPoseSig: '', /** When the pose signature last changed bins (hysteresis anchor). */ lastCameraPoseChangeMs: 0, + /** When the viewer last handled the camera (pointer, wheel, touch, key). */ + lastCameraInputMs: Number.NEGATIVE_INFINITY, + /** Element the camera-input listeners are attached to. */ + cameraInputTarget: null, /** Whether the view currently counts as moving (hysteretic, not per-frame). */ cameraMoving: false, lastEvaluatedPosition: null, From fe3d17df02a002be7ba25b55c6816356c6baaefa Mon Sep 17 00:00:00 2001 From: Sameh Khamis Date: Tue, 3 Nov 2026 10:37:57 -0800 Subject: [PATCH 09/12] fix(awareness): give the pulse material a named type so Cesium caches it once Cesium keeps every material type in a global cache for the page's lifetime. The pulse disc's fabric had no type, so each new animated visual (a subject switch, a reselect, reduced motion turned off) added a GUID-named entry that was never removed. Name the type, and give each material built from the cached template its own uniform objects back. --- src/layers/awareness/rangeRing.test.mjs | 30 +++++++++++++++++++++++++ src/layers/awareness/rendering.js | 14 +++++++++++- 2 files changed, 43 insertions(+), 1 deletion(-) diff --git a/src/layers/awareness/rangeRing.test.mjs b/src/layers/awareness/rangeRing.test.mjs index 17caa3bbc..db2758f56 100644 --- a/src/layers/awareness/rangeRing.test.mjs +++ b/src/layers/awareness/rangeRing.test.mjs @@ -774,6 +774,36 @@ test('a gradient disc behind the pulses fades inward and follows the subject', ( assert.equal(scene.preRender.numberOfListeners, 0); }); +test("switching subjects keeps one pulse material in Cesium's global cache", (t) => { + const ring = ringRendering(t); + const { scene, rendering } = ring; + frameLoop(t, ring); + // Cesium keeps every new material type for the page's lifetime, so an + // unnamed fabric (a fresh GUID type each time) would grow it per subject. + const cached = () => + Object.keys(Cesium.Material._materialCache._materials).length; + rendering.renderVisual({ ...flight(3), id: 'subject-0' }); + const first = pulsePrimitives(scene).disc.appearance.material; + const size = cached(); + let last = first; + for (let index = 1; index <= 6; index++) { + rendering.renderVisual({ ...flight(3 + index), id: `subject-${index}` }); + last = pulsePrimitives(scene).disc.appearance.material; + } + assert.equal(cached(), size, 'subject switches add no cache entries'); + rendering.clearVisual(); + rendering.renderVisual(flight(3)); + assert.equal(cached(), size, 'nor does a reselect'); + // A material built from the cached template still owns its uniform values. + const again = pulsePrimitives(scene).disc.appearance.material; + assert.notEqual(again, last); + assert.notEqual(again.uniforms.radii, last.uniforms.radii); + assert.notEqual(again.uniforms.alphas, first.uniforms.alphas); + assert.ok(again.uniforms.radii instanceof Cesium.Cartesian4); + assert.ok(again.uniforms.lineColor instanceof Cesium.Color); + assert.equal(again.uniforms.lineWidth, AWARENESS_RANGE_PULSE.width); +}); + test('a resting pulse disc is not rebuilt as the subject moves, and catches up on wake', (t) => { const ring = ringRendering(t); const { scene, rendering } = ring; diff --git a/src/layers/awareness/rendering.js b/src/layers/awareness/rendering.js index 2611dcbfb..969ffe5d5 100644 --- a/src/layers/awareness/rendering.js +++ b/src/layers/awareness/rendering.js @@ -176,6 +176,9 @@ export function awarenessRingFlash(nowMs) { /** Most pulses the pulse shader draws; its uniforms are vec4s. */ export const AWARENESS_PULSE_GRADIENT_MAX = 4; +/** The pulse disc's Cesium material type, one cached template per page. */ +const AWARENESS_PULSE_MATERIAL_TYPE = 'GevAwarenessPulseGradient'; + // 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. Each pulse is a line // `lineWidth` CSS pixels wide whose outer `haloWidth` is a dark halo, half @@ -804,10 +807,19 @@ export function createRendering({ haloWidth: pulse.outlineWidth, }; uniforms.color.alpha = pulse.gradientOpacity; + // A named type: Cesium caches every material type for the page's + // lifetime, so an unnamed fabric (a fresh GUID each time) would leave + // one entry per new visual. Built from the cached template, the + // uniforms come back as plain copies; restore this visual's own. const material = new Cesium.Material({ - fabric: { uniforms, source: PULSE_GRADIENT_SOURCE }, + fabric: { + type: AWARENESS_PULSE_MATERIAL_TYPE, + uniforms, + source: PULSE_GRADIENT_SOURCE, + }, translucent: true, }); + Object.assign(material.uniforms, uniforms); gradient = scene.primitives.add( createPulseGradient(groundCenter, material), ); From 56a7cacbecfa8e1e6842172accac761d1596e5b1 Mon Sep 17 00:00:00 2001 From: Sameh Khamis Date: Tue, 3 Nov 2026 10:39:09 -0800 Subject: [PATCH 10/12] fix(awareness): ignore canvas input while the cockpit owns the camera The cockpit turns Cesium's camera controls off and moves the camera on its own, so a click, drag or wheel on the canvas moved nothing yet still counted as the user handling the map: the cockpit's next move woke a nine-second pulse burst. Input now counts only while the camera controls take it, and a gesture still pending when another owner takes the camera is dropped. --- docs/CURRENT-STATE.md | 2 +- src/layers/awareness/lifecycle.js | 15 +++- src/layers/awareness/policy.js | 2 +- src/layers/awareness/rangeRing.test.mjs | 93 +++++++++++++++++++++++++ 4 files changed, 109 insertions(+), 3 deletions(-) diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index fd4789f60..d53e86a25 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, drawn as an unpickable primitive rather than an entity so a click or tap on its edge reaches the contact underneath (headless viewers without a primitive collection get a static entity instead); it is rebuilt when the subject moves. Three dim sonar pulses (3 px with a 1 px dark outline, 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. One unpickable 250 km disc draws both the pulse lines and the gradient, its shader reading the pulse radii and opacities each frame, so a click or tap near a pulse still reaches the contact underneath; the disc follows the subject by a model-matrix rotation and is rebuilt only after the subject moves 20 km, and not at all while the pulses rest. While the camera is below the disc's 2.5 km height, where it would sweep across the sky, the pulses rest: the disc is hidden and there is no ring flash, no echo and no frame request. Terrain or 3D tiles higher than 2.5 km (the Alps, Andes, Rockies) still cut through the disc and the ring when seen from above, as the ring alone already did; the height check uses the camera's height above the ellipsoid, not above the ground. While pulses run, the ring dims to 40% and flashes to full strength as each pulse reaches it, fading over 1.8 s. As a pulse front crosses a flight, military aircraft or AIS vessel, the detection overlay draws a second bracket around that contact's own bracket, in the pulse blue on the default detection style and in the style's own line colour under NVG, FLIR and CRT, 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 are born 5 km out, so the subject itself and anything nearer never echo. Pulses run for 9 s (three ring flashes) after a selection or a camera move, fading in and out over 1.2 s, then rest: the ring stays on screen at full strength, the pulses, gradient and echoes are hidden, and nothing asks for frames, so a parked Contacts view is quiet again. Only a camera move within 1.5 s of the user handling the map (pointer, wheel or touch on the Cesium canvas, which covers a drag's inertia; keys typed elsewhere do not count) wakes them; a moving subject and the app's own camera moves (a tracked aircraft, the cockpit view, an orbit, a fly-to) do not, since they move on every refresh. While they run they never hold continuous rendering: each drawn frame arms one render request 80 ms later (the Cyber sonar cadence, `CYBER_SONAR_RENDER_INTERVAL_MS`), so they repaint at about 12.5 fps and never beat the detection lane's 80 ms gate. Under `prefers-reduced-motion`, or in a scene without primitives or preRender and postRender hooks, there are no pulses or echoes and the ring stays on screen; changing the preference mid-session rebuilds the ring. 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, drawn as an unpickable primitive rather than an entity so a click or tap on its edge reaches the contact underneath (headless viewers without a primitive collection get a static entity instead); it is rebuilt when the subject moves. Three dim sonar pulses (3 px with a 1 px dark outline, 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. One unpickable 250 km disc draws both the pulse lines and the gradient, its shader reading the pulse radii and opacities each frame, so a click or tap near a pulse still reaches the contact underneath; the disc follows the subject by a model-matrix rotation and is rebuilt only after the subject moves 20 km, and not at all while the pulses rest. While the camera is below the disc's 2.5 km height, where it would sweep across the sky, the pulses rest: the disc is hidden and there is no ring flash, no echo and no frame request. Terrain or 3D tiles higher than 2.5 km (the Alps, Andes, Rockies) still cut through the disc and the ring when seen from above, as the ring alone already did; the height check uses the camera's height above the ellipsoid, not above the ground. While pulses run, the ring dims to 40% and flashes to full strength as each pulse reaches it, fading over 1.8 s. As a pulse front crosses a flight, military aircraft or AIS vessel, the detection overlay draws a second bracket around that contact's own bracket, in the pulse blue on the default detection style and in the style's own line colour under NVG, FLIR and CRT, 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 are born 5 km out, so the subject itself and anything nearer never echo. Pulses run for 9 s (three ring flashes) after a selection or a camera move, fading in and out over 1.2 s, then rest: the ring stays on screen at full strength, the pulses, gradient and echoes are hidden, and nothing asks for frames, so a parked Contacts view is quiet again. Only a camera move within 1.5 s of the user handling the map (pointer, wheel or touch on the Cesium canvas, which covers a drag's inertia; keys typed elsewhere do not count, nor does input while Cesium's camera controls are off, as in the cockpit view, and a gesture still pending when the cockpit or another owner takes the camera is dropped) wakes them; a moving subject and the app's own camera moves (a tracked aircraft, the cockpit view, an orbit, a fly-to) do not, since they move on every refresh. While they run they never hold continuous rendering: each drawn frame arms one render request 80 ms later (the Cyber sonar cadence, `CYBER_SONAR_RENDER_INTERVAL_MS`), so they repaint at about 12.5 fps and never beat the detection lane's 80 ms gate. Under `prefers-reduced-motion`, or in a scene without primitives or preRender and postRender hooks, there are no pulses or echoes and the ring stays on screen; changing the preference mid-session rebuilds the ring. 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/src/layers/awareness/lifecycle.js b/src/layers/awareness/lifecycle.js index 272b85edc..57903eecb 100644 --- a/src/layers/awareness/lifecycle.js +++ b/src/layers/awareness/lifecycle.js @@ -14,13 +14,22 @@ export function createLifecycle({ * Cesium canvas, where its camera controller listens), so a camera move the * user made can wake the range pulses and one the app made (tracking, * cockpit, orbit, a fly-to) does not. Keys are left out: Cesium's camera - * controller reads none, and typing elsewhere must not count. + * controller reads none, and typing elsewhere must not count. Nor does + * input while the camera controller's inputs are off: the cockpit (or a + * gizmo drag, or a drawing tool) owns the camera then, and the user's + * gesture moves nothing. */ function noteCameraInput(event) { if (event?.type === 'pointermove' && !event.buttons) return; + if (!cameraInputsEnabled()) return; layerState.lastCameraInputMs = Date.now(); } + function cameraInputsEnabled() { + const controller = layerState.viewer?.scene?.screenSpaceCameraController; + return controller?.enableInputs !== false; + } + const CAMERA_INPUT_EVENTS = [ 'pointerdown', 'pointermove', @@ -79,6 +88,10 @@ export function createLifecycle({ layerState.viewer.scene.preRender.addEventListener(() => { if (!layerState.enabled) return; const now = Date.now(); + // Another owner took the camera: a gesture begun before it did must + // not wake the pulses on that owner's first move. + if (!cameraInputsEnabled()) + layerState.lastCameraInputMs = Number.NEGATIVE_INFINITY; // "Did the camera move" uses the SAME quantized pose signature the fleet // rotation pass gates on (iconOrientation.cameraPoseSignature) rather than // camera.changed, whose granularity is globally degraded by other layers diff --git a/src/layers/awareness/policy.js b/src/layers/awareness/policy.js index 1c1320768..494ee20b1 100644 --- a/src/layers/awareness/policy.js +++ b/src/layers/awareness/policy.js @@ -92,7 +92,7 @@ export const AWARENESS_RANGE_RING_STYLE = Object.freeze({ * stays on screen, static, and nothing asks for frames, so a parked view * goes quiet again. Only a camera move within `inputWakeMs` of the user * handling the map (pointer, wheel or touch on the canvas, which covers a - * drag's inertia) wakes them; a moving subject and the app's own camera moves + * drag's inertia, while the camera controls take input) wakes them; a moving subject and the app's own camera moves * (tracking, cockpit, orbit, a fly-to) do not. Below the disc's height they * rest too. While they run they never hold * continuous rendering; each drawn frame arms one more request after the diff --git a/src/layers/awareness/rangeRing.test.mjs b/src/layers/awareness/rangeRing.test.mjs index db2758f56..5c85a23eb 100644 --- a/src/layers/awareness/rangeRing.test.mjs +++ b/src/layers/awareness/rangeRing.test.mjs @@ -574,6 +574,99 @@ test('a camera move seen by the lifecycle wakes resting pulses; a parked camera assert.equal(state.lastCameraInputMs < Date.now() - inputWakeMs, true); }); +/** + * The lifecycle's camera-input listeners over `ring`, with a camera the test + * moves by longitude (`pan`) and a camera controller whose inputs it can + * switch off, as the cockpit view does. Pulses start resting. + */ +function inputLifecycle(t, ring) { + const { state, rendering } = ring; + const loop = frameLoop(t, ring, { date: true }); + state.enabled = true; + state.viewer.canvas = new EventTarget(); + state.viewer.scene.screenSpaceCameraController = { enableInputs: true }; + state.viewer.camera = { + positionWC: Cesium.Cartesian3.fromDegrees(3, 20, 800_000), + heading: 0, + pitch: -1, + roll: 0, + }; + const lifecycle = createLifecycle({ + state, + services: { + render: { holdContinuousRender() {}, releaseContinuousRender() {} }, + }, + parts: { + rendering, + model: { + awarenessRefreshDecision: () => ({ moving: false, refresh: false }), + awarenessNeedsContinuousRender: () => false, + }, + }, + }); + lifecycle.attachRuntimeListeners(); + t.after(() => lifecycle.detachRuntimeListeners()); + rendering.renderVisual(flight(3)); + loop.advance(AWARENESS_RANGE_PULSE.activeMs + 500); + let lon = 3; + return { + loop, + lifecycle, + controller: state.viewer.scene.screenSpaceCameraController, + dispatch: (type, init = {}) => + state.viewer.canvas.dispatchEvent(Object.assign(new Event(type), init)), + pan(stepDeg = 0.1) { + lon += stepDeg; + state.viewer.camera.positionWC = Cesium.Cartesian3.fromDegrees( + lon, + 20, + 800_000, + ); + loop.draw(); + }, + /** Whether the pulses drew frames during the next `ms`. */ + pulsing(ms = AWARENESS_RANGE_PULSE.activeMs) { + const before = ring.pulseFrames(); + loop.advance(ms); + return ring.pulseFrames() > before; + }, + }; +} + +test('input the camera controller ignores, as in the cockpit, does not wake the pulses', (t) => { + const ring = ringRendering(t); + const { activeMs } = AWARENESS_RANGE_PULSE; + const { controller, dispatch, pan, pulsing, loop } = inputLifecycle(t, ring); + // The cockpit takes the camera: clicks, drags and wheels on the canvas + // move nothing, while the cockpit itself moves the camera every frame. + controller.enableInputs = false; + for (const [type, init] of [ + ['pointerdown', {}], + ['pointermove', { buttons: 1 }], + ['wheel', {}], + ['touchstart', {}], + ]) { + dispatch(type, init); + pan(); + assert.equal(pulsing(), false, `${type} in the cockpit`); + } + // A gesture that began just before the cockpit took the camera is + // dropped too, rather than waking the pulses on the cockpit's first move. + controller.enableInputs = true; + loop.advance(activeMs); + dispatch('pointerdown'); + controller.enableInputs = false; + pan(); + assert.equal(pulsing(), false, 'a gesture cut off by the cockpit'); + controller.enableInputs = true; + pan(); + assert.equal(pulsing(), false, 'and stays dropped once it hands back'); + // Back in the user's hands, a drag wakes them again. + dispatch('pointerdown'); + pan(); + assert.equal(pulsing(), true, 'a drag after the cockpit'); +}); + test('disabling the layer tears the pulses down completely', (t) => { const ring = ringRendering(t); const { state, scene, rendering, motion, pulseFrames } = ring; From de634952ec9b5e3fea12a4f1e17315f3e260f651 Mon Sep 17 00:00:00 2001 From: Sameh Khamis Date: Tue, 3 Nov 2026 10:40:30 -0800 Subject: [PATCH 11/12] fix(awareness): keep a long drag or touch counting as camera input Only canvas events refreshed the 1.5 s input window, and a touch only on touchstart. Cesium follows a drag that leaves the canvas at document level (in Firefox, and wherever there are no pointer events), so a long gesture kept moving the camera after the window closed, and the pulses rested mid-gesture. From a pointer or touch start on the canvas until its end or cancel, moves at document level now count too; the listeners go when the gesture ends or the layer detaches. --- docs/CURRENT-STATE.md | 2 +- src/layers/awareness/lifecycle.js | 31 ++++++++++++++++++++ src/layers/awareness/rangeRing.test.mjs | 39 +++++++++++++++++++++++++ 3 files changed, 71 insertions(+), 1 deletion(-) diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index d53e86a25..b4ce97d81 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, drawn as an unpickable primitive rather than an entity so a click or tap on its edge reaches the contact underneath (headless viewers without a primitive collection get a static entity instead); it is rebuilt when the subject moves. Three dim sonar pulses (3 px with a 1 px dark outline, 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. One unpickable 250 km disc draws both the pulse lines and the gradient, its shader reading the pulse radii and opacities each frame, so a click or tap near a pulse still reaches the contact underneath; the disc follows the subject by a model-matrix rotation and is rebuilt only after the subject moves 20 km, and not at all while the pulses rest. While the camera is below the disc's 2.5 km height, where it would sweep across the sky, the pulses rest: the disc is hidden and there is no ring flash, no echo and no frame request. Terrain or 3D tiles higher than 2.5 km (the Alps, Andes, Rockies) still cut through the disc and the ring when seen from above, as the ring alone already did; the height check uses the camera's height above the ellipsoid, not above the ground. While pulses run, the ring dims to 40% and flashes to full strength as each pulse reaches it, fading over 1.8 s. As a pulse front crosses a flight, military aircraft or AIS vessel, the detection overlay draws a second bracket around that contact's own bracket, in the pulse blue on the default detection style and in the style's own line colour under NVG, FLIR and CRT, 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 are born 5 km out, so the subject itself and anything nearer never echo. Pulses run for 9 s (three ring flashes) after a selection or a camera move, fading in and out over 1.2 s, then rest: the ring stays on screen at full strength, the pulses, gradient and echoes are hidden, and nothing asks for frames, so a parked Contacts view is quiet again. Only a camera move within 1.5 s of the user handling the map (pointer, wheel or touch on the Cesium canvas, which covers a drag's inertia; keys typed elsewhere do not count, nor does input while Cesium's camera controls are off, as in the cockpit view, and a gesture still pending when the cockpit or another owner takes the camera is dropped) wakes them; a moving subject and the app's own camera moves (a tracked aircraft, the cockpit view, an orbit, a fly-to) do not, since they move on every refresh. While they run they never hold continuous rendering: each drawn frame arms one render request 80 ms later (the Cyber sonar cadence, `CYBER_SONAR_RENDER_INTERVAL_MS`), so they repaint at about 12.5 fps and never beat the detection lane's 80 ms gate. Under `prefers-reduced-motion`, or in a scene without primitives or preRender and postRender hooks, there are no pulses or echoes and the ring stays on screen; changing the preference mid-session rebuilds the ring. 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, drawn as an unpickable primitive rather than an entity so a click or tap on its edge reaches the contact underneath (headless viewers without a primitive collection get a static entity instead); it is rebuilt when the subject moves. Three dim sonar pulses (3 px with a 1 px dark outline, 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. One unpickable 250 km disc draws both the pulse lines and the gradient, its shader reading the pulse radii and opacities each frame, so a click or tap near a pulse still reaches the contact underneath; the disc follows the subject by a model-matrix rotation and is rebuilt only after the subject moves 20 km, and not at all while the pulses rest. While the camera is below the disc's 2.5 km height, where it would sweep across the sky, the pulses rest: the disc is hidden and there is no ring flash, no echo and no frame request. Terrain or 3D tiles higher than 2.5 km (the Alps, Andes, Rockies) still cut through the disc and the ring when seen from above, as the ring alone already did; the height check uses the camera's height above the ellipsoid, not above the ground. While pulses run, the ring dims to 40% and flashes to full strength as each pulse reaches it, fading over 1.8 s. As a pulse front crosses a flight, military aircraft or AIS vessel, the detection overlay draws a second bracket around that contact's own bracket, in the pulse blue on the default detection style and in the style's own line colour under NVG, FLIR and CRT, 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 are born 5 km out, so the subject itself and anything nearer never echo. Pulses run for 9 s (three ring flashes) after a selection or a camera move, fading in and out over 1.2 s, then rest: the ring stays on screen at full strength, the pulses, gradient and echoes are hidden, and nothing asks for frames, so a parked Contacts view is quiet again. Only a camera move within 1.5 s of the user handling the map (pointer, wheel or touch on the Cesium canvas, which covers a drag's inertia, and a drag or touch begun there keeps counting until it ends, even outside the canvas; keys typed elsewhere do not count, nor does input while Cesium's camera controls are off, as in the cockpit view, and a gesture still pending when the cockpit or another owner takes the camera is dropped) wakes them; a moving subject and the app's own camera moves (a tracked aircraft, the cockpit view, an orbit, a fly-to) do not, since they move on every refresh. While they run they never hold continuous rendering: each drawn frame arms one render request 80 ms later (the Cyber sonar cadence, `CYBER_SONAR_RENDER_INTERVAL_MS`), so they repaint at about 12.5 fps and never beat the detection lane's 80 ms gate. Under `prefers-reduced-motion`, or in a scene without primitives or preRender and postRender hooks, there are no pulses or echoes and the ring stays on screen; changing the preference mid-session rebuilds the ring. 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/src/layers/awareness/lifecycle.js b/src/layers/awareness/lifecycle.js index 57903eecb..042148c37 100644 --- a/src/layers/awareness/lifecycle.js +++ b/src/layers/awareness/lifecycle.js @@ -23,6 +23,36 @@ export function createLifecycle({ if (event?.type === 'pointermove' && !event.buttons) return; if (!cameraInputsEnabled()) return; layerState.lastCameraInputMs = Date.now(); + if (event?.type === 'pointerdown' || event?.type === 'touchstart') + followGesture(true); + } + + // A drag can leave the canvas, and Cesium follows it at document level + // (in Firefox, and wherever there are no pointer events). So does this, + // from a gesture's start on the canvas to its end, so a long gesture keeps + // the input window open the whole way. + const GESTURE_MOVE_EVENTS = ['pointermove', 'touchmove']; + const GESTURE_END_EVENTS = [ + 'pointerup', + 'pointercancel', + 'touchend', + 'touchcancel', + ]; + let followingGesture = false; + + function followGesture(on) { + if (followingGesture === on) return; + followingGesture = on; + const listen = on ? 'addEventListener' : 'removeEventListener'; + for (const type of GESTURE_MOVE_EVENTS) + globalThis.document?.[listen]?.(type, noteCameraInput, { passive: true }); + for (const type of GESTURE_END_EVENTS) + globalThis.document?.[listen]?.(type, endGesture, { passive: true }); + } + + function endGesture(event) { + noteCameraInput(event); + followGesture(false); } function cameraInputsEnabled() { @@ -160,6 +190,7 @@ export function createLifecycle({ noteCameraInput, ); layerState.cameraInputTarget = null; + followGesture(false); } layerState.preRenderRemover?.(); layerState.preRenderRemover = null; diff --git a/src/layers/awareness/rangeRing.test.mjs b/src/layers/awareness/rangeRing.test.mjs index 5c85a23eb..a24794401 100644 --- a/src/layers/awareness/rangeRing.test.mjs +++ b/src/layers/awareness/rangeRing.test.mjs @@ -667,6 +667,45 @@ test('input the camera controller ignores, as in the cockpit, does not wake the assert.equal(pulsing(), true, 'a drag after the cockpit'); }); +test('a long drag or touch keeps the pulses awake after it leaves the canvas', (t) => { + const ring = ringRendering(t); + const { inputWakeMs } = AWARENESS_RANGE_PULSE; + const { dispatch, pan, pulsing, loop, lifecycle } = inputLifecycle(t, ring); + const onDocument = (type, init = {}) => + document.dispatchEvent(Object.assign(new Event(type), init)); + // The gesture starts on the canvas and carries on past `inputWakeMs` + // outside it, where Cesium still follows it at document level. + const longGesture = (start, move, init) => { + dispatch(start, init); + for (let ms = 0; ms < inputWakeMs + 1000; ms += 250) { + loop.advance(250); + onDocument(move, init); + } + }; + for (const [start, move, end, init] of [ + ['pointerdown', 'pointermove', 'pointerup', { buttons: 1 }], + ['touchstart', 'touchmove', 'touchend', {}], + ['touchstart', 'touchmove', 'touchcancel', {}], + ]) { + longGesture(start, move, init); + pan(); + assert.equal(pulsing(), true, `${move} outside the canvas`); + // Once it ends, moves elsewhere are not handling the map. + onDocument(end); + loop.advance(inputWakeMs + 100); + onDocument(move, init); + pan(); + assert.equal(pulsing(), false, `${move} after ${end}`); + } + // Detaching mid-gesture drops the document listeners too. + dispatch('pointerdown'); + lifecycle.detachRuntimeListeners(); + loop.advance(inputWakeMs + 100); + const noted = ring.state.lastCameraInputMs; + onDocument('pointermove', { buttons: 1 }); + assert.equal(ring.state.lastCameraInputMs, noted); +}); + test('disabling the layer tears the pulses down completely', (t) => { const ring = ringRendering(t); const { state, scene, rendering, motion, pulseFrames } = ring; From 02a6739466ec714cbc97472edf144c41ab77091e Mon Sep 17 00:00:00 2001 From: Sameh Khamis Date: Tue, 3 Nov 2026 10:47:23 -0800 Subject: [PATCH 12/12] fix(awareness): follow a gesture to its last contact, and drop it on blur Lifting one finger of a pinch removed the document listeners, so the remaining finger's drag, which Cesium carries on with, stopped counting. A gesture now ends only when its last pointer lifts or no touches are left. And a gesture whose end never reaches the page (the window loses focus mid-drag, or the page is hidden) left the listeners attached, so a later unrelated drag counted as camera input; a blur or hidden page now stops following it and drops its pending input. --- docs/CURRENT-STATE.md | 2 +- src/layers/awareness/lifecycle.js | 21 +++++++ src/layers/awareness/rangeRing.test.mjs | 83 +++++++++++++++++++++++++ 3 files changed, 105 insertions(+), 1 deletion(-) diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index b4ce97d81..80c7c2bac 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, drawn as an unpickable primitive rather than an entity so a click or tap on its edge reaches the contact underneath (headless viewers without a primitive collection get a static entity instead); it is rebuilt when the subject moves. Three dim sonar pulses (3 px with a 1 px dark outline, 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. One unpickable 250 km disc draws both the pulse lines and the gradient, its shader reading the pulse radii and opacities each frame, so a click or tap near a pulse still reaches the contact underneath; the disc follows the subject by a model-matrix rotation and is rebuilt only after the subject moves 20 km, and not at all while the pulses rest. While the camera is below the disc's 2.5 km height, where it would sweep across the sky, the pulses rest: the disc is hidden and there is no ring flash, no echo and no frame request. Terrain or 3D tiles higher than 2.5 km (the Alps, Andes, Rockies) still cut through the disc and the ring when seen from above, as the ring alone already did; the height check uses the camera's height above the ellipsoid, not above the ground. While pulses run, the ring dims to 40% and flashes to full strength as each pulse reaches it, fading over 1.8 s. As a pulse front crosses a flight, military aircraft or AIS vessel, the detection overlay draws a second bracket around that contact's own bracket, in the pulse blue on the default detection style and in the style's own line colour under NVG, FLIR and CRT, 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 are born 5 km out, so the subject itself and anything nearer never echo. Pulses run for 9 s (three ring flashes) after a selection or a camera move, fading in and out over 1.2 s, then rest: the ring stays on screen at full strength, the pulses, gradient and echoes are hidden, and nothing asks for frames, so a parked Contacts view is quiet again. Only a camera move within 1.5 s of the user handling the map (pointer, wheel or touch on the Cesium canvas, which covers a drag's inertia, and a drag or touch begun there keeps counting until it ends, even outside the canvas; keys typed elsewhere do not count, nor does input while Cesium's camera controls are off, as in the cockpit view, and a gesture still pending when the cockpit or another owner takes the camera is dropped) wakes them; a moving subject and the app's own camera moves (a tracked aircraft, the cockpit view, an orbit, a fly-to) do not, since they move on every refresh. While they run they never hold continuous rendering: each drawn frame arms one render request 80 ms later (the Cyber sonar cadence, `CYBER_SONAR_RENDER_INTERVAL_MS`), so they repaint at about 12.5 fps and never beat the detection lane's 80 ms gate. Under `prefers-reduced-motion`, or in a scene without primitives or preRender and postRender hooks, there are no pulses or echoes and the ring stays on screen; changing the preference mid-session rebuilds the ring. 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, drawn as an unpickable primitive rather than an entity so a click or tap on its edge reaches the contact underneath (headless viewers without a primitive collection get a static entity instead); it is rebuilt when the subject moves. Three dim sonar pulses (3 px with a 1 px dark outline, 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. One unpickable 250 km disc draws both the pulse lines and the gradient, its shader reading the pulse radii and opacities each frame, so a click or tap near a pulse still reaches the contact underneath; the disc follows the subject by a model-matrix rotation and is rebuilt only after the subject moves 20 km, and not at all while the pulses rest. While the camera is below the disc's 2.5 km height, where it would sweep across the sky, the pulses rest: the disc is hidden and there is no ring flash, no echo and no frame request. Terrain or 3D tiles higher than 2.5 km (the Alps, Andes, Rockies) still cut through the disc and the ring when seen from above, as the ring alone already did; the height check uses the camera's height above the ellipsoid, not above the ground. While pulses run, the ring dims to 40% and flashes to full strength as each pulse reaches it, fading over 1.8 s. As a pulse front crosses a flight, military aircraft or AIS vessel, the detection overlay draws a second bracket around that contact's own bracket, in the pulse blue on the default detection style and in the style's own line colour under NVG, FLIR and CRT, 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 are born 5 km out, so the subject itself and anything nearer never echo. Pulses run for 9 s (three ring flashes) after a selection or a camera move, fading in and out over 1.2 s, then rest: the ring stays on screen at full strength, the pulses, gradient and echoes are hidden, and nothing asks for frames, so a parked Contacts view is quiet again. Only a camera move within 1.5 s of the user handling the map (pointer, wheel or touch on the Cesium canvas, which covers a drag's inertia, and a drag or touch begun there keeps counting until its last finger or button lifts, even outside the canvas, unless the window loses focus or the page is hidden first; keys typed elsewhere do not count, nor does input while Cesium's camera controls are off, as in the cockpit view, and a gesture still pending when the cockpit or another owner takes the camera is dropped) wakes them; a moving subject and the app's own camera moves (a tracked aircraft, the cockpit view, an orbit, a fly-to) do not, since they move on every refresh. While they run they never hold continuous rendering: each drawn frame arms one render request 80 ms later (the Cyber sonar cadence, `CYBER_SONAR_RENDER_INTERVAL_MS`), so they repaint at about 12.5 fps and never beat the detection lane's 80 ms gate. Under `prefers-reduced-motion`, or in a scene without primitives or preRender and postRender hooks, there are no pulses or echoes and the ring stays on screen; changing the preference mid-session rebuilds the ring. 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/src/layers/awareness/lifecycle.js b/src/layers/awareness/lifecycle.js index 042148c37..b2e7dfe30 100644 --- a/src/layers/awareness/lifecycle.js +++ b/src/layers/awareness/lifecycle.js @@ -23,6 +23,7 @@ export function createLifecycle({ if (event?.type === 'pointermove' && !event.buttons) return; if (!cameraInputsEnabled()) return; layerState.lastCameraInputMs = Date.now(); + if (event?.type === 'pointerdown') activePointers.add(event.pointerId); if (event?.type === 'pointerdown' || event?.type === 'touchstart') followGesture(true); } @@ -39,22 +40,42 @@ export function createLifecycle({ 'touchcancel', ]; let followingGesture = false; + const activePointers = new Set(); function followGesture(on) { if (followingGesture === on) return; followingGesture = on; + if (!on) activePointers.clear(); const listen = on ? 'addEventListener' : 'removeEventListener'; for (const type of GESTURE_MOVE_EVENTS) globalThis.document?.[listen]?.(type, noteCameraInput, { passive: true }); for (const type of GESTURE_END_EVENTS) globalThis.document?.[listen]?.(type, endGesture, { passive: true }); + globalThis[listen]?.('blur', dropGesture); + globalThis.document?.[listen]?.('visibilitychange', dropGesture); } + // A gesture ends with its last contact: lifting one finger of a pinch + // leaves Cesium a one-finger drag. function endGesture(event) { noteCameraInput(event); + activePointers.delete(event?.pointerId); + if (activePointers.size || event?.touches?.length) return; followGesture(false); } + // Losing the window or the page mid-gesture means its end may never + // arrive here, so stop following it and drop its pending input. + function dropGesture(event) { + if ( + event?.type === 'visibilitychange' && + document.visibilityState !== 'hidden' + ) + return; + followGesture(false); + layerState.lastCameraInputMs = Number.NEGATIVE_INFINITY; + } + function cameraInputsEnabled() { const controller = layerState.viewer?.scene?.screenSpaceCameraController; return controller?.enableInputs !== false; diff --git a/src/layers/awareness/rangeRing.test.mjs b/src/layers/awareness/rangeRing.test.mjs index a24794401..94124d36a 100644 --- a/src/layers/awareness/rangeRing.test.mjs +++ b/src/layers/awareness/rangeRing.test.mjs @@ -706,6 +706,89 @@ test('a long drag or touch keeps the pulses awake after it leaves the canvas', ( assert.equal(ring.state.lastCameraInputMs, noted); }); +test('lifting one finger of a pinch leaves the other finger counting', (t) => { + const ring = ringRendering(t); + const { inputWakeMs } = AWARENESS_RANGE_PULSE; + const { dispatch, pan, pulsing, loop } = inputLifecycle(t, ring); + const onDocument = (type, init = {}) => + document.dispatchEvent(Object.assign(new Event(type), init)); + // Cesium carries on with a one-finger drag after the other lifts. + const dragOn = (move, init) => { + for (let ms = 0; ms < inputWakeMs + 1000; ms += 250) { + loop.advance(250); + onDocument(move, init); + } + pan(); + return pulsing(); + }; + // Without pointer events: the touch list says who is still down. + dispatch('touchstart'); + onDocument('touchend', { touches: [{}] }); + assert.equal(dragOn('touchmove', {}), true, 'touch: one finger left'); + onDocument('touchend', { touches: [] }); + assert.equal(dragOn('touchmove', {}), false, 'touch: all lifted'); + // With pointer events: each contact has its own pointer id. + dispatch('pointerdown', { pointerId: 1 }); + dispatch('pointerdown', { pointerId: 2 }); + onDocument('pointerup', { pointerId: 1 }); + const drag = { pointerId: 2, buttons: 1 }; + assert.equal(dragOn('pointermove', drag), true, 'pointer: one left'); + onDocument('pointercancel', { pointerId: 2 }); + assert.equal(dragOn('pointermove', drag), false, 'pointer: all lifted'); +}); + +test('a gesture lost to a window blur or a hidden page stops counting', (t) => { + const ring = ringRendering(t); + const { inputWakeMs } = AWARENESS_RANGE_PULSE; + const { dispatch, pan, pulsing, loop } = inputLifecycle(t, ring); + const visibility = Object.getOwnPropertyDescriptor( + document, + 'visibilityState', + ); + t.after(() => { + if (visibility) + Object.defineProperty(document, 'visibilityState', visibility); + else delete document.visibilityState; + }); + const setVisibility = (value) => + Object.defineProperty(document, 'visibilityState', { + configurable: true, + get: () => value, + }); + for (const [name, lose] of [ + ['blur', () => window.dispatchEvent(new Event('blur'))], + [ + 'hidden', + () => { + setVisibility('hidden'); + document.dispatchEvent(new Event('visibilitychange')); + setVisibility('visible'); + }, + ], + ]) { + // The release happens outside the window, so no end ever arrives. + dispatch('pointerdown', { pointerId: 1 }); + lose(); + pan(); + assert.equal(pulsing(), false, `${name}: pending input dropped`); + loop.advance(inputWakeMs + 100); + const noted = ring.state.lastCameraInputMs; + document.dispatchEvent( + Object.assign(new Event('pointermove'), { pointerId: 1, buttons: 1 }), + ); + assert.equal(ring.state.lastCameraInputMs, noted, `${name}: no follow`); + } + // A page coming back into view is no reason to drop a gesture. + dispatch('pointerdown', { pointerId: 1 }); + document.dispatchEvent(new Event('visibilitychange')); + loop.advance(inputWakeMs + 100); + document.dispatchEvent( + Object.assign(new Event('pointermove'), { pointerId: 1, buttons: 1 }), + ); + pan(); + assert.equal(pulsing(), true, 'visible: still following'); +}); + test('disabling the layer tears the pulses down completely', (t) => { const ring = ringRendering(t); const { state, scene, rendering, motion, pulseFrames } = ring;