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..36ac2eb1c --- /dev/null +++ b/changelog.d/1519-range-ring-sonar-pulses.md @@ -0,0 +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. 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 e48dc20fc..87b918a4e 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -4997,7 +4997,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, 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/scripts/format-scope.json b/scripts/format-scope.json index 4d03ad6ed..ca9ce6d98 100644 --- a/scripts/format-scope.json +++ b/scripts/format-scope.json @@ -1184,6 +1184,9 @@ "src/overlays/worldOverlayActionsView.test.mjs", "src/ui/stores/awarenessDirection.js", "src/layers/awareness/directionOverlay.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 f5282880d..44c481152 100644 --- a/scripts/package-boundaries.json +++ b/scripts/package-boundaries.json @@ -1684,6 +1684,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", @@ -2234,6 +2235,7 @@ "src/ui/placeNames.js", "src/cyberSonarScene.js", "src/cyberSonar.js", + "src/awarenessEcho.js", "src/hudLayouts.js", "src/hudLayoutPolicy.js", "src/ui/radioPanelPlacement.js", @@ -3233,6 +3235,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..44a5e37fa --- /dev/null +++ b/src/awarenessEcho.js @@ -0,0 +1,172 @@ +/** + * @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, 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. + */ + +/** @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; +} + +/** + * 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 + * 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. 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, fadeInFraction?: number}} pulse + * @param {{fadeMs: number}} echo + * @returns {number} 0 to 1. + */ +export function awarenessEchoStrength(nowMs, distanceM, radiusM, pulse, echo) { + // 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 = + (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); + } + // 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; +} + +/** + * 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 level = field.level?.(nowMs) ?? 1; + if (!(level > 0)) return 0; + const ground = Cesium.Ellipsoid.WGS84.scaleToGeodeticSurface( + position, + groundScratch, + ); + if (!ground) return 0; + return ( + level * + awarenessEchoStrength( + nowMs, + Cesium.Cartesian3.distance(field.center, ground), + field.radiusM, + field.pulse, + field.echo, + ) + ); +} + +/** + * 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`, 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} 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} + */ +export function awarenessEchoBracket( + layerId, + position, + nowMs, + bracketAlpha, + steps, +) { + const echo = awarenessEchoAt(layerId, position, nowMs); + if (!(echo > 0.01)) return null; + const band = Math.min(steps, Math.round(echo * bracketAlpha * steps)); + if (!(band >= 1)) return null; + return { band, 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/awarenessEcho.test.mjs b/src/awarenessEcho.test.mjs new file mode 100644 index 000000000..b68e2eb96 --- /dev/null +++ b/src/awarenessEcho.test.mjs @@ -0,0 +1,141 @@ +// 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 { + awarenessEchoActive, + 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); + // 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); + 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: + // 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); + 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, + ); + 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', () => { + 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/detection.js b/src/data/detection.js index c2e63c3d9..36ed6372e 100644 --- a/src/data/detection.js +++ b/src/data/detection.js @@ -53,6 +53,12 @@ import { isCyberContactSonarActive as isCyberSonarActive, isCyberContactThemeActive as isCyberMapThemeActive, } from '../cyberSonar.js'; +import { + awarenessEchoActive, + awarenessEchoBracket, + awarenessEchoColor, + getAwarenessEchoField, +} from '../awarenessEcho.js'; /** * @module detection @@ -103,6 +109,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 +1343,11 @@ 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. + // 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( 1, @@ -1437,6 +1450,24 @@ function _drawOverlay(frame) { halfH, ); visibleCount++; + if (echoPaths) { + // Starts on the contact's bracket and spreads outward as it fades. + const echo = awarenessEchoBracket( + obj._layerId, + obj.position, + now, + bracketAlpha, + ECHO_ALPHA_STEPS, + ); + if (echo) + appendCornerBracket( + (echoPaths[echo.band] ??= new Path2D()), + sx, + sy, + halfW + echo.spreadPx, + halfH + echo.spreadPx, + ); + } if (obj.type === 'AIR') aircraftBracketSectors[detectionHorizontalSector(sx, width)]++; if (bracketAlpha >= 1) bracketOpacityCounts.full++; @@ -1576,6 +1607,20 @@ function _drawOverlay(frame) { _ctx.stroke(entry.path); } } + if (echoPaths) { + _ctx.lineWidth = echoField.echo.lineWidth; + _ctx.strokeStyle = awarenessEchoColor( + _theme.line, + _theme === THEME_MAP._default, + ); + 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..852ea7f2b --- /dev/null +++ b/src/data/detectionAwarenessEcho.test.mjs @@ -0,0 +1,107 @@ +// 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, in the detection style's own colour. +import test from 'node:test'; +import assert from 'node:assert/strict'; +import * as Cesium from 'cesium'; +import { + awarenessEchoBracket, + awarenessEchoColor, + clearAwarenessEchoField, + setAwarenessEchoField, +} from '../awarenessEcho.js'; +import { DETECTION_THEME_MAP } from '../overlays/worldOverlayTokens.js'; + +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 }; + +/** 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) => { + t.after(clearAwarenessEchoField); + setAwarenessEchoField(field); + const position = Cesium.Cartesian3.fromDegrees(3.5, 27, 10_000); + const arrival = arrivalAt(position); + 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); + 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); + // 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); +}); + +test('no echo field, or a layer that does not echo, draws nothing extra', (t) => { + t.after(clearAwarenessEchoField); + const position = Cesium.Cartesian3.fromDegrees(3.01, 27, 0); + 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( + 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/data/detectionHost.test.mjs b/src/data/detectionHost.test.mjs index 06bfe01af..f8858d602 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', () => { @@ -50,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() {} @@ -98,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); }, @@ -627,6 +632,231 @@ 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('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 + // 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; + 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`); + 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(); + } +}); + 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/lifecycle.js b/src/layers/awareness/lifecycle.js index 925a3a9ca..b2e7dfe30 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,86 @@ 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. 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(); + if (event?.type === 'pointerdown') activePointers.add(event.pointerId); + 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; + 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; + } + + 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,10 +131,18 @@ 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; 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 @@ -62,6 +151,14 @@ export function createLifecycle({ if (poseSig !== layerState.lastCameraPoseSig) { layerState.lastCameraPoseSig = poseSig; layerState.lastCameraPoseChangeMs = now; + // 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, @@ -108,6 +205,13 @@ export function createLifecycle({ 'gev:awareness-subject-cleared', layerState.subjectClearListener, ); + for (const type of CAMERA_INPUT_EVENTS) + layerState.cameraInputTarget?.removeEventListener?.( + type, + noteCameraInput, + ); + layerState.cameraInputTarget = null; + followGesture(false); } layerState.preRenderRemover?.(); layerState.preRenderRemover = null; diff --git a/src/layers/awareness/policy.js b/src/layers/awareness/policy.js index 492bbc238..2e973a473 100644 --- a/src/layers/awareness/policy.js +++ b/src/layers/awareness/policy.js @@ -49,13 +49,86 @@ 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. + * 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 + * 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)', outlineColor: 'rgba(4, 12, 16, 0.8)', width: 4, outlineWidth: 1, segments: 128, + flashMs: 1800, + restOpacity: 0.4, +}); + +/** @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. + * 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 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. 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, 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 + * 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, + startFraction: 0.02, + fadeInFraction: 0.08, + fadeOutPower: 1.5, + endOpacityFraction: 0.35, + peakOpacity: 0.6, + width: 3, + outlineWidth: 1, + gradientDepthFraction: 0.35, + gradientOpacity: 0.22, + gradientGranularityDeg: 0.25, + gradientRebuildM: 20000, + activeMs: 9000, + settleMs: 1200, + inputWakeMs: 1500, +}); + +/** @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 b17651090..bb26f8045 100644 --- a/src/layers/awareness/rangeRing.test.mjs +++ b/src/layers/awareness/rangeRing.test.mjs @@ -1,12 +1,36 @@ // 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, 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'; 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 { CYBER_SONAR_RENDER_INTERVAL_MS } from '../../cyberSonar.js'; +import { + AWARENESS_PULSE_GRADIENT_MAX, + awarenessPulseLevel, + awarenessPulseState, + awarenessRangeRingPositions, + awarenessRingFlash, + awarenessRingGeometryOptions, + 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. */ @@ -20,27 +44,156 @@ 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; `motion.set(on)` flips it later and tells its listeners, as the + * browser does. + */ +function ringRendering(t, { reducedMotion = false, legacyQuery = false } = {}) { + const previous = globalThis.matchMedia; + const listeners = new Set(); + // `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 + : { 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(); - state.viewer = { entities }; - let renders = 0; + const scene = { + primitives: new Cesium.PrimitiveCollection(), + preRender: new Cesium.Event(), + postRender: 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, + 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, { date = false } = {}) { + let clock = 1_000_000; + t.mock.method(performance, 'now', () => clock); + t.mock.timers.enable({ + apis: date + ? ['setTimeout', 'setInterval', 'Date'] + : ['setTimeout', 'setInterval'], + now: clock, + }); + 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 ring and the pulse disc among scene primitives. */ +function pulsePrimitives(scene) { + const all = Array.from({ length: scene.primitives.length }, (_, i) => + scene.primitives.get(i), + ); + 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 }; +} + +/** 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); + return pulsePrimitives(scene).ring.appearance.material.uniforms; +} + +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); @@ -134,63 +287,69 @@ 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); - 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); +test('renderVisual draws one haloed polyline ring, not an ellipse outline', (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 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. + 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( - material.color.getValue(now).toCssColorString(), - Cesium.Color.fromCssColorString( - AWARENESS_RANGE_RING_STYLE.color, - ).toCssColorString(), + options.vertexFormat, + Cesium.PolylineMaterialAppearance.VERTEX_FORMAT, ); - assert.equal( - material.outlineWidth.getValue(now), - AWARENESS_RANGE_RING_STYLE.outlineWidth, + const { material } = ringPrimitive.appearance; + assert.equal(material.type, 'PolylineOutline'); + // 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(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, - 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 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 { 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), @@ -198,7 +357,926 @@ 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); + // 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(entities.values.length, 0); + assert.equal(scene.primitives.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 80 ms after each drawn frame instead of holding continuous rendering', (t) => { + const ring = ringRendering(t); + 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 + // the pulses run, never every display frame. + assert.equal(pulseFrames(), 0); + 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 frames = pulseFrames(); + rendering.clearVisual(); + 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'); +}); + +test('a parked view stops asking for frames once the pulses rest', (t) => { + const ring = ringRendering(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, 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'); + // At rest the ring stands on screen at full, as without pulses, and the + // pulses, gradient and echoes are hidden. + const core = Cesium.Color.fromCssColorString( + AWARENESS_RANGE_RING_STYLE.color, + ); + 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); +}); + +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, { 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, + 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 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, + 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'); + // 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); +}); + +/** + * 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('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('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; + 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) => { + 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() {} } }, + { primitives: { add() {} }, preRender: new Cesium.Event() }, + undefined, + ]) { + 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); +}); + +test('a gradient disc behind the pulses fades inward and follows the subject', (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); + 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); + // 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, + ); + // 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. + assert.equal(scene.preRender.numberOfListeners, 1); + loop.advance(AWARENESS_RANGE_PULSE.settleMs + 500); + scene.preRender.raiseEvent(scene); + 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 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 - expected.alpha) < 1e-6); + } + // Unused shader slots stay empty. + assert.equal( + Cesium.Cartesian4.pack(alphas, [])[AWARENESS_PULSE_GRADIENT_MAX - 1], + 0, + ); + + // 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); + 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("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; + const loop = frameLoop(t, ring); + rendering.renderVisual(flight(3)); + loop.advance(AWARENESS_RANGE_PULSE.activeMs + 500); + const resting = pulsePrimitives(scene).disc; + 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()); + 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); + 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('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) => { + 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'); + // 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); +}); + +test('reduced motion keeps the static ring and drops the pulses', (t) => { + t.mock.timers.enable({ apis: ['setTimeout', 'setInterval'] }); + const { scene, rendering, pulseFrames } = ringRendering(t, { + reducedMotion: true, + }); + rendering.renderVisual(flight(3)); + assert.ok(pulsePrimitives(scene).ring); + t.mock.timers.tick(1000); + assert.equal(pulseFrames(), 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 { scene, rendering, motion, state } = ring; + const loop = frameLoop(t, ring); + state.subject = flight(3); + rendering.renderVisual(state.subject); + assert.equal(pulsePrimitives(scene).count, 1); + loop.advance(1000); + // The OS setting changes while the same aircraft stays tracked. + motion.set(true); + 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); + const frames = ring.pulseFrames(); + loop.advance(5000); + assert.equal(ring.pulseFrames(), frames); + motion.set(false); + 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); + rendering.clearVisual(); + assert.equal(motion.listeners(), 0); +}); + +test('a reduced-motion change reaches older Safari through addListener', (t) => { + const { scene, rendering, motion, state } = ringRendering(t, { + legacyQuery: true, + }); + state.subject = flight(3); + rendering.renderVisual(state.subject); + assert.equal(pulsePrimitives(scene).count, 1); + assert.equal(motion.listeners(), 1); + motion.set(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 { scene, rendering, motion } = ringRendering(t); + rendering.renderVisual(flight(3)); + 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.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 { scene, rendering } = ring; + const loop = frameLoop(t, ring); + rendering.renderVisual(flight(3)); + const core = Cesium.Color.fromCssColorString( + AWARENESS_RANGE_RING_STYLE.color, + ); + 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. + 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); + // 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; + 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); + // 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 { 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 { scene, rendering } = ringRendering(t, { reducedMotion: true }); + rendering.renderVisual(flight(3)); + assert.equal(scene.preRender.numberOfListeners, 0); + assert.equal( + ringColors(scene).color.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 be20f9d50..6f4d380b8 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'; @@ -12,10 +13,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`, @@ -70,6 +78,273 @@ export function awarenessRangeRingPositions( 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), + }; +} + +const REDUCED_MOTION_QUERY = '(prefers-reduced-motion: reduce)'; + +/** Whether the viewer asked the system for less motion. */ +function prefersReducedMotion() { + 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; +} + +/** + * 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 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 +// 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 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; + band += alphas[i] * t * t; + } + } + 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 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. + * @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, + }); +} + +/** + * 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, + }); +} + +/** + * 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; +} + export function createRendering({ state: layerState, services, @@ -328,7 +603,62 @@ 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) return; + wakeAwarenessPulses(wake, performance.now()); + followPulseDisc(); + } + + /** + * 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 ( + Cesium.Cartesian3.distance(gradientCenter, center) < + 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), + ); + // Primitive.destroy leaves the material alone, so the new disc keeps + // sharing it. + primitives.remove(gradient); + Cesium.Cartesian3.clone(center, gradientCenter); + } + function clearVisual() { + layerState.visual?.stopPulses?.(); + 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) layerState.viewer.entities.remove(entity); @@ -348,7 +678,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 ( @@ -360,9 +695,22 @@ export function createRendering({ AWARENESS_RADIUS_M, CONTEXT_RIM_HEIGHT_M, ); - for (const entity of layerState.visual.entities) - entity.polyline.positions.setValue(positions); + // The echo field reads `visual.center` in place and follows on its + // 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) { + 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) + followPulseDisc(); // Content actually changed — buy exactly one frame instead of holding. governorRequestRender('awareness-visual'); } @@ -371,35 +719,188 @@ 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( + !reducedMotion && + scene?.primitives?.add && + 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; + // 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. const ring = AWARENESS_RANGE_RING_STYLE; - entities.push( - layerState.viewer.entities.add({ + 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 ringAlpha = (nowMs) => { + const pulsed = Math.max(ring.restOpacity, awarenessRingFlash(nowMs)); + return 1 - levelAt(nowMs) * (1 - pulsed); + }; + 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( - awarenessRangeRingPositions( - groundCenter, - AWARENESS_RADIUS_M, - CONTEXT_RIM_HEIGHT_M, - ), - ), + positions: new Cesium.ConstantProperty(ringPositions), width: ring.width, arcType: Cesium.ArcType.NONE, material: new Cesium.PolylineOutlineMaterialProperty({ - color: Cesium.Color.fromCssColorString(ring.color), - outlineColor: Cesium.Color.fromCssColorString(ring.outlineColor), + color: core, + outlineColor: halo, outlineWidth: ring.outlineWidth, }), }, - }), - ); + }); + entities.push(ringEntity); + } + + const center = Cesium.Cartesian3.clone(groundCenter); + let gradient = null; + let stopPulses = null; + if (animate) { + const pulse = AWARENESS_RANGE_PULSE; + 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; + // 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: { + type: AWARENESS_PULSE_MATERIAL_TYPE, + uniforms, + source: PULSE_GRADIENT_SOURCE, + }, + translucent: true, + }); + Object.assign(material.uniforms, uniforms); + gradient = scene.primitives.add( + createPulseGradient(groundCenter, material), + ); + const radii = [0, 0, 0, 0]; + const alphas = [0, 0, 0, 0]; + 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. + 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 * 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 && !belowRim()) + 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({ + center, + radiusM: AWARENESS_RADIUS_M, + pulse, + echo: AWARENESS_RANGE_ECHO, + level: levelAt, + }); + } + + // 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); + }; + // Safari before 14 has only the older addListener / removeListener. + if (motionQuery?.addEventListener) + motionQuery.addEventListener('change', onMotionChange); + else motionQuery?.addListener?.(onMotionChange); layerState.visual = { key, entities, - center: Cesium.Cartesian3.clone(groundCenter), + ring: ringEntity, + ringPrimitive, + ringPositions, + center, + gradient, + gradientCenter: Cesium.Cartesian3.clone(groundCenter), + reducedMotion, + wake, + stopPulses, + removeMotionListener: () => { + if (motionQuery?.removeEventListener) + motionQuery.removeEventListener('change', onMotionChange); + else motionQuery?.removeListener?.(onMotionChange); + }, }; governorRequestRender('awareness-visual'); } @@ -413,5 +914,6 @@ export function createRendering({ updateDirectionOverlay, clearVisual, renderVisual, + wakePulses, }; } 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,