mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-04 02:14:01 +00:00
fix(awareness): bound the range ring pulses so a parked view goes quiet
Review follow-up for the sonar pulses. Bounded repaint: the pulses used to ask for a frame every 80 ms from a setInterval for as long as a subject was shown, so a parked view repainted at 12.5 fps indefinitely. They now run for 9 s (three ring flashes) after a selection or a camera move, fading in and out over 1.2 s, then rest: the ring stands on screen at full strength, the pulse lines and gradient disc are hidden, echoes stop, and nothing asks for frames. A moving subject does not wake them, since a flying subject moves on every refresh. The lifecycle's existing camera pose signature wakes them. No beat with the Cyber sonar: the setInterval is replaced by a postRender-armed setTimeout on CYBER_SONAR_RENDER_INTERVAL_MS, as in cyberSonarScene. Every drawn frame, whoever asked for it, re-arms the request 80 ms later, so requests never land inside the detection lane's 80 ms gate. Reduced motion mid-session: a change listener on the media query rebuilds the visual, and the same-subject refresh path rebuilds when the preference no longer matches. The listener is removed in clearVisual. Readable edge: restOpacity goes from 0 to 0.4, so the 250 km ring stays visible between flashes, in screenshots and for low-vision viewers. The echo field takes an optional level so echoes rest with the pulses. CURRENT-STATE and the changelog fragment describe the bounded run and the dimmed ring. Tests: render requests stop after the bound and stay stopped for a minute; a camera move seen by the lifecycle wakes them for another bounded run; a foreign frame postpones the next request to 80 ms after it; reduced motion flipped by event and by a later refresh; the ring never drops below restOpacity; echoes scale with the level; the level fades in, holds, fades out and keeps its value when woken.
This commit is contained in:
1 parent
20770e52a9
commit
0c025987ef
8 files changed
+491
-86
No files matched your search
@@ -1,6 +1,8 @@
|
||||
- The Global Context range ring pulses like a sonar: three dim rings with a
|
||||
soft inward glow grow from the subject to 250 km, and the ring flashes as
|
||||
each one arrives. Flights, military aircraft and AIS vessels a pulse crosses
|
||||
get a brief echo around their brackets. While a subject is shown, a parked
|
||||
Contacts view repaints about 12 times a second. Under reduced motion there
|
||||
are no pulses and the ring stays on screen (manjunath22466, #1519).
|
||||
get a brief echo around their brackets. The pulses run for about 9 s after
|
||||
a selection or camera move, dimming the ring between flashes, then rest
|
||||
with the ring steady on screen, so a parked view stops repainting. Under
|
||||
reduced motion there are no pulses and the ring stays on screen
|
||||
(manjunath22466, #1519).
|
||||
@@ -4957,7 +4957,7 @@ silently demoting every later lookup for the session.
|
||||
- Enabled Data Layer controls report normalized feed health on the button (`LOADING`, `DEGRADED`, `STALE`, `FALLBACK`, or `UNAVAILABLE`) while the metadata line retains the source and reason. A partial CelesTrak group failure keeps the usable catalog and reports `DEGRADED`; a total outage keeps last-good catalog data visible but reports `UNAVAILABLE`.
|
||||
- On activation it focuses the nearest currently observed aircraft across the civilian and military feeds, with military winning an exact distance tie; if none are available, it focuses an observed AIS vessel. The aircraft search is deliberately uncapped and one refresh-tick retry handles initially empty feeds. This is an attention-priority navigation shortcut, not a high-risk, affiliation, or threat classification.
|
||||
- A selected aircraft, AIS vessel, or mapped installation gets a 250 km **context window** with nearby cohort counts, nearest examples, source labels, and stale/unavailable reasons. It emits `NEARBY` or `UNKNOWN`; no detection, engagement, affiliation, or sensor-activity conclusion is calculated.
|
||||
- The window's edge is drawn on the globe as a 250 km range ring: a 4 px polyline with a 2 px Context-blue core inside a 1 px dark halo on each side, 2.5 km above the ellipsoid (`AWARENESS_RANGE_RING_STYLE` in `src/layers/awareness/policy.js`). The halo keeps it legible over bright desert and snow, dark ocean and forest, and under the FLIR, NVG and CRT styles, where the earlier 1 px ellipse outline disappeared. It is a polyline because WebGL draws ellipse outlines at 1 px whatever width is asked for. Three dim sonar pulses (3 px, same colours, at most 60% opacity) grow slowly from the subject at constant speed, one every 3 s and each taking 9 s, and arrive at the ring at about a third of that. Behind each pulse a radial gradient in the same blue fades inward over 35% of the radius, drawn by one unpickable 250 km disc whose shader reads the pulse radii and opacities each frame; the disc itself is rebuilt only after the subject moves 2 km. While pulses run, the ring is not shown on its own: it flashes to full strength as each pulse reaches it and fades over 1.8 s. As a pulse front crosses a flight, military aircraft or AIS vessel, the detection overlay draws a second bracket in the pulse blue around that contact's own bracket, which spreads outward as it fades over 1.4 s (`AWARENESS_RANGE_ECHO`, read through `src/awarenessEcho.js`; no layer's icon is restyled, and mapped installations have no detection bracket). Pulses never hold continuous rendering: they ask for a frame every 80 ms, the Cyber sonar's parked cadence, so a parked Contacts view repaints at about 12.5 fps while a subject is shown, and at full rate only when something else already renders continuously. Under `prefers-reduced-motion`, or in a scene without primitives or a preRender hook, there are no pulses or echoes and the ring stays on screen. The ring follows a moving subject once it has moved 250 m.
|
||||
- The window's edge is drawn on the globe as a 250 km range ring: a 4 px polyline with a 2 px Context-blue core inside a 1 px dark halo on each side, 2.5 km above the ellipsoid (`AWARENESS_RANGE_RING_STYLE` in `src/layers/awareness/policy.js`). The halo keeps it legible over bright desert and snow, dark ocean and forest, and under the FLIR, NVG and CRT styles, where the earlier 1 px ellipse outline disappeared. It is a polyline because WebGL draws ellipse outlines at 1 px whatever width is asked for. Three dim sonar pulses (3 px, same colours, at most 60% opacity) grow slowly from the subject at constant speed, one every 3 s and each taking 9 s, and arrive at the ring at about a third of that. Behind each pulse a radial gradient in the same blue fades inward over 35% of the radius, drawn by one unpickable 250 km disc whose shader reads the pulse radii and opacities each frame; the disc itself is rebuilt only after the subject moves 2 km. While pulses run, the ring dims to 40% and flashes to full strength as each pulse reaches it, fading over 1.8 s. As a pulse front crosses a flight, military aircraft or AIS vessel, the detection overlay draws a second bracket in the pulse blue around that contact's own bracket, which spreads outward as it fades over 1.4 s (`AWARENESS_RANGE_ECHO`, read through `src/awarenessEcho.js`; no layer's icon is restyled, and mapped installations have no detection bracket). Pulses run for 9 s (three ring flashes) after a selection or a camera move, fading in and out over 1.2 s, then rest: the ring stays on screen at full strength, the pulses, gradient and echoes are hidden, and nothing asks for frames, so a parked Contacts view is quiet again. A moving subject does not wake them, since a flying subject moves on every refresh; a camera that follows a tracked aircraft does keep them running, but that follow already renders every frame. While they run they never hold continuous rendering: each drawn frame arms one render request 80 ms later (the Cyber sonar cadence, `CYBER_SONAR_RENDER_INTERVAL_MS`), so they repaint at about 12.5 fps and never beat the detection lane's 80 ms gate. Under `prefers-reduced-motion`, or in a scene without primitives or preRender and postRender hooks, there are no pulses or echoes and the ring stays on screen; changing the preference mid-session rebuilds the ring. The ring follows a moving subject once it has moved 250 m.
|
||||
- For a selected live aircraft or vessel, the context window refreshes from the existing tracker/feed position every 750 ms, so nearest distances and cohort counts follow the subject without introducing a duplicate poll loop.
|
||||
- Aircraft cohort membership uses every locally loaded, selectable contact inside the 250 km window, including a plane hidden only by horizon culling or because its 3D model owns the visual. Counts and navigation therefore do not change with the current camera angle or billboard/model handoff.
|
||||
- Nearby examples in the context panel are focus controls: they use their owning layer's existing selection/tracking path, then frame that contact. Static-installation distances use ellipsoidal surface distance so the count matches the ground-projected context disk.
|
||||
|
||||
+13
-6
@@ -15,6 +15,8 @@ import * as Cesium from 'cesium';
|
||||
* @property {number} radiusM Range the pulses grow to.
|
||||
* @property {{count: number, periodMs: number, startFraction: number}} pulse
|
||||
* @property {{fadeMs: number, layerIds: string[]}} echo
|
||||
* @property {(nowMs: number) => number} [level] How far the pulses are
|
||||
* switched on, 0 to 1; echoes scale with it, so they rest with the pulses.
|
||||
*/
|
||||
|
||||
/** @type {AwarenessEchoField|null} */
|
||||
@@ -94,11 +96,16 @@ export function awarenessEchoAt(layerId, position, nowMs) {
|
||||
groundScratch,
|
||||
);
|
||||
if (!ground) return 0;
|
||||
return awarenessEchoStrength(
|
||||
nowMs,
|
||||
Cesium.Cartesian3.distance(field.center, ground),
|
||||
field.radiusM,
|
||||
field.pulse,
|
||||
field.echo,
|
||||
const level = field.level?.(nowMs) ?? 1;
|
||||
if (!(level > 0)) return 0;
|
||||
return (
|
||||
level *
|
||||
awarenessEchoStrength(
|
||||
nowMs,
|
||||
Cesium.Cartesian3.distance(field.center, ground),
|
||||
field.radiusM,
|
||||
field.pulse,
|
||||
field.echo,
|
||||
)
|
||||
);
|
||||
}
|
||||
@@ -82,3 +82,28 @@ test('awarenessEchoAt reads the published field, by layer, at ground level', (t)
|
||||
assert.equal(getAwarenessEchoField(), null);
|
||||
assert.equal(awarenessEchoAt('flights', contact, t0), 0);
|
||||
});
|
||||
|
||||
test('echoes scale with the field level and rest with the pulses', (t) => {
|
||||
t.after(clearAwarenessEchoField);
|
||||
const center = Cesium.Cartesian3.fromDegrees(3, 27, 0);
|
||||
// ~100 km east of the subject, on the ground.
|
||||
const contact = Cesium.Cartesian3.fromDegrees(4.008, 27, 0);
|
||||
let level = 1;
|
||||
setAwarenessEchoField({
|
||||
center,
|
||||
radiusM: R,
|
||||
pulse,
|
||||
echo,
|
||||
level: () => level,
|
||||
});
|
||||
const d = Cesium.Cartesian3.distance(center, contact);
|
||||
const t0 = arrival(d);
|
||||
const full = awarenessEchoAt('flights', contact, t0);
|
||||
assert.ok(full > 0.99);
|
||||
level = 0.5;
|
||||
assert.ok(
|
||||
Math.abs(awarenessEchoAt('flights', contact, t0) - full / 2) < 1e-9,
|
||||
);
|
||||
level = 0;
|
||||
assert.equal(awarenessEchoAt('flights', contact, t0), 0);
|
||||
});
|
||||
@@ -62,6 +62,8 @@ export function createLifecycle({
|
||||
if (poseSig !== layerState.lastCameraPoseSig) {
|
||||
layerState.lastCameraPoseSig = poseSig;
|
||||
layerState.lastCameraPoseChangeMs = now;
|
||||
// A camera move runs the range pulses again; parked, they rest.
|
||||
parts.rendering.wakePulses();
|
||||
}
|
||||
const decision = parts.model.awarenessRefreshDecision({
|
||||
nowMs: now,
|
||||
|
||||
@@ -50,9 +50,11 @@ export const CONTEXT_RIM_HEIGHT_M = 2500;
|
||||
* luminance contrast on light and dark ground alike, and luminance is what
|
||||
* the monochrome styles keep. The core is the Context panel's blue; the halo
|
||||
* is the map-card ink. Widths are screen pixels; `outlineWidth` is per side.
|
||||
* While the sonar pulses run, the ring rests at `restOpacity` and flashes
|
||||
* to full as each pulse reaches it, fading over `flashMs`. Without pulses
|
||||
* (reduced motion, or a scene that cannot animate) it stays on screen. */
|
||||
* While the sonar pulses run, the ring dims to `restOpacity` and flashes
|
||||
* to full as each pulse reaches it, fading over `flashMs`. `restOpacity`
|
||||
* keeps the 250 km edge readable between flashes, in screenshots and for
|
||||
* low-vision viewers. Once the pulses rest, and without pulses (reduced
|
||||
* motion, or a scene that cannot animate), it stays on screen at full. */
|
||||
|
||||
export const AWARENESS_RANGE_RING_STYLE = Object.freeze({
|
||||
color: 'rgba(98, 181, 255, 0.95)',
|
||||
@@ -61,7 +63,7 @@ export const AWARENESS_RANGE_RING_STYLE = Object.freeze({
|
||||
outlineWidth: 1,
|
||||
segments: 128,
|
||||
flashMs: 1800,
|
||||
restOpacity: 0,
|
||||
restOpacity: 0.4,
|
||||
});
|
||||
|
||||
/** @constant Sonar-style pulses that grow slowly from the subject to the
|
||||
@@ -75,11 +77,16 @@ export const AWARENESS_RANGE_RING_STYLE = Object.freeze({
|
||||
* `gradientOpacity` strong. The gradient disc is rebuilt only after the
|
||||
* subject moves `gradientRebuildM`, since a soft band 2 km off centre on a
|
||||
* 250 km disc does not show. The gradient shader takes at most four pulses.
|
||||
* The pulses do not hold continuous rendering: a parked Contacts view must
|
||||
* not repaint every frame. They ask for a frame every `frameIntervalMs`
|
||||
* instead, the cadence the Cyber sonar uses for parked scenes; when
|
||||
* something else already renders continuously they update every frame.
|
||||
* Skipped under prefers-reduced-motion. */
|
||||
* The pulses run for `activeMs` (three ring flashes) after a selection or
|
||||
* a camera move, fading in and out over `settleMs`, then rest: the ring
|
||||
* stays on screen, static, and nothing asks for frames, so a parked view
|
||||
* goes quiet again. A moving subject does not wake them, since a flying
|
||||
* subject moves on every refresh, but a camera that follows it does; the
|
||||
* follow already renders every frame. While they run they never hold
|
||||
* continuous rendering; each drawn frame arms one more request after the
|
||||
* Cyber sonar's 80 ms cadence, so the two never beat. When something else
|
||||
* already renders continuously they update every frame. Skipped under
|
||||
* prefers-reduced-motion. */
|
||||
|
||||
export const AWARENESS_RANGE_PULSE = Object.freeze({
|
||||
count: 3,
|
||||
@@ -96,7 +103,8 @@ export const AWARENESS_RANGE_PULSE = Object.freeze({
|
||||
gradientOpacity: 0.22,
|
||||
gradientGranularityDeg: 0.25,
|
||||
gradientRebuildM: 2000,
|
||||
frameIntervalMs: 80,
|
||||
activeMs: 9000,
|
||||
settleMs: 1200,
|
||||
});
|
||||
|
||||
/** @constant Sonar echoes on contact brackets. When a pulse front reaches a
|
||||
|
||||
@@ -16,14 +16,18 @@ import {
|
||||
AWARENESS_RANGE_RING_STYLE,
|
||||
CONTEXT_RIM_HEIGHT_M,
|
||||
} from './policy.js';
|
||||
import { CYBER_SONAR_RENDER_INTERVAL_MS } from '../../cyberSonar.js';
|
||||
import {
|
||||
AWARENESS_PULSE_GRADIENT_MAX,
|
||||
awarenessPulseLevel,
|
||||
awarenessPulseState,
|
||||
awarenessRangeRingPositions,
|
||||
awarenessRingFlash,
|
||||
createRendering,
|
||||
wakeAwarenessPulses,
|
||||
} from './rendering.js';
|
||||
import { getAwarenessEchoField } from '../../awarenessEcho.js';
|
||||
import { createLifecycle } from './lifecycle.js';
|
||||
import { createState } from './state.js';
|
||||
|
||||
/** WCAG relative luminance of a Cesium colour, ignoring alpha. */
|
||||
@@ -39,22 +43,48 @@ function luminance(color) {
|
||||
|
||||
/**
|
||||
* The rendering part over a bare entity collection, counting render asks and
|
||||
* by reason. `reducedMotion` stubs the media query for
|
||||
* the length of the test.
|
||||
* by reason. `reducedMotion` stubs the media query for the length of the
|
||||
* test; `motion.set(on)` flips it later and tells its listeners, as the
|
||||
* browser does.
|
||||
*/
|
||||
function ringRendering(t, { reducedMotion = false } = {}) {
|
||||
const previous = globalThis.matchMedia;
|
||||
globalThis.matchMedia = (query) => ({
|
||||
matches: query === '(prefers-reduced-motion: reduce)' && reducedMotion,
|
||||
});
|
||||
const listeners = new Set();
|
||||
const query = {
|
||||
get matches() {
|
||||
return reducedMotion;
|
||||
},
|
||||
addEventListener(type, listener) {
|
||||
if (type === 'change') listeners.add(listener);
|
||||
},
|
||||
removeEventListener(type, listener) {
|
||||
if (type === 'change') listeners.delete(listener);
|
||||
},
|
||||
};
|
||||
globalThis.matchMedia = (media) =>
|
||||
media === '(prefers-reduced-motion: reduce)'
|
||||
? query
|
||||
: { matches: false, addEventListener() {}, removeEventListener() {} };
|
||||
t.after(() => {
|
||||
globalThis.matchMedia = previous;
|
||||
});
|
||||
const motion = {
|
||||
set(on) {
|
||||
reducedMotion = on;
|
||||
for (const listener of [...listeners]) listener({ matches: on });
|
||||
},
|
||||
/** Change the preference without a change event, as old browsers do. */
|
||||
setQuietly(on) {
|
||||
reducedMotion = on;
|
||||
},
|
||||
listeners: () => listeners.size,
|
||||
};
|
||||
const state = createState({ services: {} });
|
||||
const entities = new Cesium.EntityCollection();
|
||||
const scene = {
|
||||
primitives: new Cesium.PrimitiveCollection(),
|
||||
preRender: new Cesium.Event(),
|
||||
postRender: new Cesium.Event(),
|
||||
};
|
||||
state.viewer = { entities, scene };
|
||||
const reasons = [];
|
||||
@@ -77,11 +107,42 @@ function ringRendering(t, { reducedMotion = false } = {}) {
|
||||
entities,
|
||||
scene,
|
||||
rendering,
|
||||
motion,
|
||||
reasons,
|
||||
renders: () => reasons.filter((r) => r === 'awareness-visual').length,
|
||||
pulseFrames: () => reasons.filter((r) => r === 'awareness-pulse').length,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* A request-render scene on a fake clock: `performance.now()` and the
|
||||
* timers advance together, and any render ask is drawn (preRender, then
|
||||
* postRender) on the next millisecond, as Cesium's request-render mode does.
|
||||
* `draw()` draws a frame someone else asked for.
|
||||
*/
|
||||
function frameLoop(t, ring) {
|
||||
let clock = 1_000_000;
|
||||
t.mock.method(performance, 'now', () => clock);
|
||||
t.mock.timers.enable({ apis: ['setTimeout', 'setInterval'] });
|
||||
let drawn = 0;
|
||||
const draw = () => {
|
||||
drawn = ring.reasons.length;
|
||||
ring.scene.preRender.raiseEvent(ring.scene);
|
||||
ring.scene.postRender.raiseEvent(ring.scene);
|
||||
};
|
||||
return {
|
||||
now: () => clock,
|
||||
draw,
|
||||
advance(ms) {
|
||||
for (let step = 0; step < ms; step++) {
|
||||
clock++;
|
||||
t.mock.timers.tick(1);
|
||||
if (ring.reasons.length > drawn) draw();
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** The pulse gradient disc and the echo collection among scene primitives. */
|
||||
function pulsePrimitives(scene) {
|
||||
const all = Array.from({ length: scene.primitives.length }, (_, i) =>
|
||||
@@ -284,15 +345,17 @@ test('a pulse grows slowly from the subject and arrives dim at the ring', () =>
|
||||
);
|
||||
});
|
||||
|
||||
test('pulses ask for a frame every 80 ms instead of holding continuous rendering', (t) => {
|
||||
t.mock.timers.enable({ apis: ['setInterval'] });
|
||||
const { entities, rendering, pulseFrames } = ringRendering(t);
|
||||
test('pulses ask for a frame 80 ms after each drawn frame instead of holding continuous rendering', (t) => {
|
||||
const ring = ringRendering(t);
|
||||
const { entities, rendering, pulseFrames } = ring;
|
||||
const loop = frameLoop(t, ring);
|
||||
rendering.renderVisual(flight(3));
|
||||
// A parked Contacts view repaints at the Cyber sonar cadence, ~12.5 fps,
|
||||
// never every display frame.
|
||||
// A Contacts view repaints at the Cyber sonar cadence, ~12.5 fps, while
|
||||
// the pulses run, never every display frame.
|
||||
assert.equal(pulseFrames(), 0);
|
||||
t.mock.timers.tick(AWARENESS_RANGE_PULSE.frameIntervalMs * 10);
|
||||
assert.equal(pulseFrames(), 10);
|
||||
loop.advance(1); // the frame the new ring asked for arms the first request
|
||||
loop.advance(CYBER_SONAR_RENDER_INTERVAL_MS * 10);
|
||||
assert.ok(Math.abs(pulseFrames() - 10) <= 1, `${pulseFrames()} frames`);
|
||||
const now = Cesium.JulianDate.now();
|
||||
const core = Cesium.Color.fromCssColorString(
|
||||
AWARENESS_RANGE_RING_STYLE.color,
|
||||
@@ -316,15 +379,167 @@ test('pulses ask for a frame every 80 ms instead of holding continuous rendering
|
||||
);
|
||||
assert.ok(Math.max(...radii) <= AWARENESS_RADIUS_M + 100);
|
||||
}
|
||||
const frames = pulseFrames();
|
||||
rendering.clearVisual();
|
||||
assert.equal(entities.values.length, 0);
|
||||
t.mock.timers.tick(AWARENESS_RANGE_PULSE.frameIntervalMs * 10);
|
||||
assert.equal(pulseFrames(), 10, 'no frames once the ring is gone');
|
||||
assert.equal(ring.scene.postRender.numberOfListeners, 0);
|
||||
loop.advance(CYBER_SONAR_RENDER_INTERVAL_MS * 10);
|
||||
assert.equal(pulseFrames(), frames, 'no frames once the ring is gone');
|
||||
});
|
||||
|
||||
test('a parked view stops asking for frames once the pulses rest', (t) => {
|
||||
const ring = ringRendering(t);
|
||||
const { entities, scene, rendering, pulseFrames } = ring;
|
||||
const loop = frameLoop(t, ring);
|
||||
const { activeMs, count, periodMs } = AWARENESS_RANGE_PULSE;
|
||||
// Long enough for three ring flashes, then quiet.
|
||||
assert.ok(activeMs >= 3 * (periodMs / count));
|
||||
rendering.renderVisual(flight(3));
|
||||
loop.advance(activeMs + 500);
|
||||
const frames = pulseFrames();
|
||||
assert.ok(frames >= activeMs / CYBER_SONAR_RENDER_INTERVAL_MS - 2);
|
||||
// Parked: a minute later not one more request.
|
||||
loop.advance(60_000);
|
||||
assert.equal(pulseFrames(), frames, 'render requests stop after the bound');
|
||||
// At rest the ring stands on screen at full, as without pulses, and the
|
||||
// pulses, gradient and echoes are hidden.
|
||||
const now = Cesium.JulianDate.now();
|
||||
const core = Cesium.Color.fromCssColorString(
|
||||
AWARENESS_RANGE_RING_STYLE.color,
|
||||
);
|
||||
const [ringEntity, ...pulses] = entities.values;
|
||||
assert.ok(
|
||||
Math.abs(
|
||||
ringEntity.polyline.material.color.getValue(now).alpha - core.alpha,
|
||||
) < 1e-9,
|
||||
);
|
||||
for (const pulse of pulses)
|
||||
assert.equal(pulse.polyline.show.getValue(now), false);
|
||||
assert.equal(pulsePrimitives(scene).disc.show, false);
|
||||
assert.equal(getAwarenessEchoField().level(loop.now()), 0);
|
||||
});
|
||||
|
||||
test('a camera move wakes resting pulses for another bounded run', (t) => {
|
||||
const ring = ringRendering(t);
|
||||
const { rendering, pulseFrames } = ring;
|
||||
const loop = frameLoop(t, ring);
|
||||
const { activeMs } = AWARENESS_RANGE_PULSE;
|
||||
rendering.renderVisual(flight(3));
|
||||
loop.advance(activeMs + 500);
|
||||
const rested = pulseFrames();
|
||||
// The camera moves: lifecycle wakes the pulses inside the frame it draws,
|
||||
// and that frame arms the next request. Waking asks for none of its own.
|
||||
rendering.wakePulses();
|
||||
assert.equal(pulseFrames(), rested);
|
||||
loop.draw();
|
||||
loop.advance(activeMs + 500);
|
||||
const woke = pulseFrames() - rested;
|
||||
assert.ok(woke >= activeMs / CYBER_SONAR_RENDER_INTERVAL_MS - 2, `${woke}`);
|
||||
loop.advance(60_000);
|
||||
assert.equal(pulseFrames(), rested + woke, 'and rest again');
|
||||
// The subject moving does not wake them: a flying subject moves on every
|
||||
// refresh, which would bring the endless repaint back.
|
||||
rendering.renderVisual(flight(3.01));
|
||||
loop.advance(10_000);
|
||||
assert.equal(pulseFrames(), rested + woke);
|
||||
});
|
||||
|
||||
test('a camera move seen by the lifecycle wakes resting pulses; a parked camera does not', (t) => {
|
||||
const ring = ringRendering(t);
|
||||
const { state, rendering, pulseFrames } = ring;
|
||||
const loop = frameLoop(t, ring);
|
||||
const { activeMs } = AWARENESS_RANGE_PULSE;
|
||||
state.enabled = true;
|
||||
state.viewer.camera = {
|
||||
positionWC: Cesium.Cartesian3.fromDegrees(3, 20, 800_000),
|
||||
heading: 0,
|
||||
pitch: -1,
|
||||
roll: 0,
|
||||
};
|
||||
const lifecycle = createLifecycle({
|
||||
state,
|
||||
services: {
|
||||
render: { holdContinuousRender() {}, releaseContinuousRender() {} },
|
||||
},
|
||||
parts: {
|
||||
rendering,
|
||||
model: {
|
||||
awarenessRefreshDecision: () => ({ moving: false, refresh: false }),
|
||||
awarenessNeedsContinuousRender: () => false,
|
||||
},
|
||||
},
|
||||
});
|
||||
lifecycle.attachRuntimeListeners();
|
||||
t.after(() => lifecycle.detachRuntimeListeners());
|
||||
rendering.renderVisual(flight(3));
|
||||
loop.advance(activeMs + 500);
|
||||
const rested = pulseFrames();
|
||||
// Parked: frames other owners draw do not wake the pulses.
|
||||
loop.draw();
|
||||
loop.advance(activeMs);
|
||||
assert.equal(pulseFrames(), rested);
|
||||
// The camera pans 50 km: the next frame wakes them for another run.
|
||||
state.viewer.camera.positionWC = Cesium.Cartesian3.fromDegrees(
|
||||
3.5,
|
||||
20,
|
||||
800_000,
|
||||
);
|
||||
loop.draw();
|
||||
loop.advance(activeMs + 500);
|
||||
assert.ok(
|
||||
pulseFrames() - rested >= activeMs / CYBER_SONAR_RENDER_INTERVAL_MS - 2,
|
||||
);
|
||||
const woke = pulseFrames();
|
||||
loop.advance(60_000);
|
||||
assert.equal(pulseFrames(), woke, 'and rest again once it parks');
|
||||
});
|
||||
|
||||
test('pulse requests wait 80 ms after any frame, so they cannot beat the Cyber sonar', (t) => {
|
||||
const ring = ringRendering(t);
|
||||
const { rendering, pulseFrames } = ring;
|
||||
const loop = frameLoop(t, ring);
|
||||
rendering.renderVisual(flight(3));
|
||||
loop.advance(1);
|
||||
const frames = pulseFrames();
|
||||
// Another owner (the Cyber sonar, a data update) draws 67 ms after ours.
|
||||
loop.advance(67);
|
||||
loop.draw();
|
||||
loop.advance(CYBER_SONAR_RENDER_INTERVAL_MS - 1);
|
||||
assert.equal(pulseFrames(), frames, 'no request 13 ms after that frame');
|
||||
loop.advance(1);
|
||||
assert.equal(pulseFrames(), frames + 1, 'one request 80 ms after it');
|
||||
});
|
||||
|
||||
test('the pulse level fades in, holds, fades out and keeps its level when woken', () => {
|
||||
const { activeMs, settleMs } = AWARENESS_RANGE_PULSE;
|
||||
const wake = wakeAwarenessPulses(
|
||||
{ startMs: -Infinity, untilMs: -Infinity },
|
||||
0,
|
||||
);
|
||||
assert.equal(awarenessPulseLevel(0, wake), 0);
|
||||
assert.ok(Math.abs(awarenessPulseLevel(settleMs / 2, wake) - 0.5) < 1e-9);
|
||||
assert.equal(awarenessPulseLevel(activeMs / 2, wake), 1);
|
||||
assert.ok(
|
||||
Math.abs(awarenessPulseLevel(activeMs - settleMs / 4, wake) - 0.25) < 1e-9,
|
||||
);
|
||||
assert.equal(awarenessPulseLevel(activeMs, wake), 0);
|
||||
assert.equal(awarenessPulseLevel(activeMs * 10, wake), 0);
|
||||
// Woken mid-fade-out: no jump back to full, then a fresh run.
|
||||
const at = activeMs - settleMs / 4;
|
||||
wakeAwarenessPulses(wake, at);
|
||||
assert.ok(Math.abs(awarenessPulseLevel(at, wake) - 0.25) < 1e-9);
|
||||
assert.equal(awarenessPulseLevel(at + settleMs, wake), 1);
|
||||
assert.equal(awarenessPulseLevel(at + activeMs, wake), 0);
|
||||
});
|
||||
|
||||
test('a scene without primitives or preRender gets the static ring only', (t) => {
|
||||
const { state, entities, rendering, pulseFrames } = ringRendering(t);
|
||||
for (const scene of [{}, { primitives: { add() {} } }, undefined]) {
|
||||
for (const scene of [
|
||||
{},
|
||||
{ primitives: { add() {} } },
|
||||
{ primitives: { add() {} }, preRender: new Cesium.Event() },
|
||||
undefined,
|
||||
]) {
|
||||
state.viewer = { entities, scene };
|
||||
rendering.clearVisual();
|
||||
rendering.renderVisual(flight(3));
|
||||
@@ -336,7 +551,9 @@ test('a scene without primitives or preRender gets the static ring only', (t) =>
|
||||
});
|
||||
|
||||
test('a gradient disc behind the pulses fades inward and follows the subject', (t) => {
|
||||
const { scene, rendering } = ringRendering(t);
|
||||
const ring = ringRendering(t);
|
||||
const { scene, rendering } = ring;
|
||||
const loop = frameLoop(t, ring);
|
||||
rendering.renderVisual(flight(3));
|
||||
assert.ok(AWARENESS_RANGE_PULSE.count <= AWARENESS_PULSE_GRADIENT_MAX);
|
||||
const { disc, count } = pulsePrimitives(scene);
|
||||
@@ -357,26 +574,20 @@ test('a gradient disc behind the pulses fades inward and follows the subject', (
|
||||
) < 1e-6,
|
||||
);
|
||||
// Each frame writes the pulses' radius (as a fraction of the range) and
|
||||
// opacity into the shader, from the same curve the lines use.
|
||||
// opacity into the shader, from the same curve the lines use, once the
|
||||
// pulses have faded in.
|
||||
assert.equal(scene.preRender.numberOfListeners, 1);
|
||||
const before = performance.now();
|
||||
loop.advance(AWARENESS_RANGE_PULSE.settleMs + 500);
|
||||
scene.preRender.raiseEvent(scene);
|
||||
const after = performance.now();
|
||||
assert.equal(disc.show, true);
|
||||
const { radii, alphas } = material.uniforms;
|
||||
for (let index = 0; index < AWARENESS_RANGE_PULSE.count; index++) {
|
||||
const radius = Cesium.Cartesian4.pack(radii, [])[index];
|
||||
const alpha = Cesium.Cartesian4.pack(alphas, [])[index];
|
||||
const low = awarenessPulseState(before, index);
|
||||
const high = awarenessPulseState(after, index);
|
||||
// A few milliseconds pass during the raise; allow for that drift.
|
||||
assert.ok(
|
||||
Math.abs(radius - low.radiusM / AWARENESS_RADIUS_M) < 0.01 ||
|
||||
Math.abs(radius - high.radiusM / AWARENESS_RADIUS_M) < 0.01,
|
||||
);
|
||||
const expected = awarenessPulseState(loop.now(), index);
|
||||
assert.ok(Math.abs(radius - expected.radiusM / AWARENESS_RADIUS_M) < 1e-6);
|
||||
assert.ok(alpha >= 0 && alpha <= AWARENESS_RANGE_PULSE.peakOpacity);
|
||||
assert.ok(
|
||||
Math.abs(alpha - low.alpha) < 0.02 || Math.abs(alpha - high.alpha) < 0.02,
|
||||
);
|
||||
assert.ok(Math.abs(alpha - expected.alpha) < 1e-6);
|
||||
}
|
||||
// Unused shader slots stay empty.
|
||||
assert.equal(
|
||||
@@ -425,7 +636,7 @@ test('pulses evaluate every frame without breaking, reusing their positions', (t
|
||||
});
|
||||
|
||||
test('reduced motion keeps the static ring and drops the pulses', (t) => {
|
||||
t.mock.timers.enable({ apis: ['setInterval'] });
|
||||
t.mock.timers.enable({ apis: ['setTimeout', 'setInterval'] });
|
||||
const { entities, scene, rendering, pulseFrames } = ringRendering(t, {
|
||||
reducedMotion: true,
|
||||
});
|
||||
@@ -436,16 +647,58 @@ test('reduced motion keeps the static ring and drops the pulses', (t) => {
|
||||
assert.equal(pulseFrames(), 0);
|
||||
assert.equal(scene.primitives.length, 0);
|
||||
assert.equal(scene.preRender.numberOfListeners, 0);
|
||||
assert.equal(scene.postRender.numberOfListeners, 0);
|
||||
});
|
||||
|
||||
test('turning reduced motion on mid-session stops the pulses, and off restarts them', (t) => {
|
||||
const ring = ringRendering(t);
|
||||
const { entities, scene, rendering, motion, state } = ring;
|
||||
const loop = frameLoop(t, ring);
|
||||
state.subject = flight(3);
|
||||
rendering.renderVisual(state.subject);
|
||||
assert.equal(entities.values.length, 1 + AWARENESS_RANGE_PULSE.count);
|
||||
loop.advance(1000);
|
||||
// The OS setting changes while the same aircraft stays tracked.
|
||||
motion.set(true);
|
||||
assert.equal(entities.values.length, 1);
|
||||
assert.equal(entities.values[0].polyline.material.color.isConstant, true);
|
||||
assert.equal(scene.primitives.length, 0);
|
||||
assert.equal(scene.preRender.numberOfListeners, 0);
|
||||
assert.equal(scene.postRender.numberOfListeners, 0);
|
||||
assert.equal(getAwarenessEchoField(), null);
|
||||
const frames = ring.pulseFrames();
|
||||
loop.advance(5000);
|
||||
assert.equal(ring.pulseFrames(), frames);
|
||||
motion.set(false);
|
||||
assert.equal(entities.values.length, 1 + AWARENESS_RANGE_PULSE.count);
|
||||
assert.ok(getAwarenessEchoField());
|
||||
// One media-query listener per visual; none once the ring is gone.
|
||||
assert.equal(motion.listeners(), 1);
|
||||
rendering.clearVisual();
|
||||
assert.equal(motion.listeners(), 0);
|
||||
});
|
||||
|
||||
test('a refresh after reduced motion changed rebuilds the visual too', (t) => {
|
||||
const { entities, rendering, motion } = ringRendering(t);
|
||||
rendering.renderVisual(flight(3));
|
||||
assert.equal(entities.values.length, 1 + AWARENESS_RANGE_PULSE.count);
|
||||
// No change event: the next same-subject refresh notices on its own.
|
||||
motion.setQuietly(true);
|
||||
rendering.renderVisual(flight(3));
|
||||
assert.equal(entities.values.length, 1);
|
||||
assert.equal(entities.values[0].polyline.positions.isConstant, true);
|
||||
});
|
||||
|
||||
test('while pulses run the ring flashes as each one arrives, then rests', (t) => {
|
||||
const { entities, rendering } = ringRendering(t);
|
||||
const ring = ringRendering(t);
|
||||
const { entities, rendering } = ring;
|
||||
const loop = frameLoop(t, ring);
|
||||
rendering.renderVisual(flight(3));
|
||||
const ring = entities.values[0];
|
||||
const ringEntity = entities.values[0];
|
||||
const core = Cesium.Color.fromCssColorString(
|
||||
AWARENESS_RANGE_RING_STYLE.color,
|
||||
);
|
||||
assert.equal(ring.polyline.material.color.isConstant, false);
|
||||
assert.equal(ringEntity.polyline.material.color.isConstant, false);
|
||||
const { periodMs, count } = AWARENESS_RANGE_PULSE;
|
||||
const { flashMs, restOpacity } = AWARENESS_RANGE_RING_STYLE;
|
||||
// Full the moment a front reaches 250 km, gone `flashMs` later.
|
||||
@@ -453,7 +706,8 @@ test('while pulses run the ring flashes as each one arrives, then rests', (t) =>
|
||||
assert.ok(Math.abs(awarenessRingFlash(arrival) - 1) < 1e-6);
|
||||
assert.ok(awarenessRingFlash(arrival + flashMs / 2) > 0.2);
|
||||
assert.equal(awarenessRingFlash(arrival + flashMs + 1), 0);
|
||||
assert.equal(restOpacity, 0);
|
||||
// Between flashes the 250 km edge stays readable, dimmed, not gone.
|
||||
assert.ok(restOpacity >= 0.35 && restOpacity <= 0.5, `${restOpacity}`);
|
||||
// Once per arriving pulse: `count` flashes in any one-period window that
|
||||
// starts after a flash has faded.
|
||||
let flashes = 0;
|
||||
@@ -465,16 +719,18 @@ test('while pulses run the ring flashes as each one arrives, then rests', (t) =>
|
||||
wasLit = lit;
|
||||
}
|
||||
assert.equal(flashes, count);
|
||||
// The live colour tracks the flash at this instant.
|
||||
const before = awarenessRingFlash(performance.now());
|
||||
const alpha = ring.polyline.material.color.getValue(
|
||||
Cesium.JulianDate.now(),
|
||||
).alpha;
|
||||
const after = awarenessRingFlash(performance.now());
|
||||
assert.ok(
|
||||
alpha >= core.alpha * Math.min(before, after) - 0.02 &&
|
||||
alpha <= core.alpha * Math.max(before, after) + 0.02,
|
||||
);
|
||||
// Once the pulses have faded in, the live colour tracks the flash, and
|
||||
// never drops below `restOpacity`.
|
||||
loop.advance(AWARENESS_RANGE_PULSE.settleMs);
|
||||
for (let step = 0; step < 40; step++) {
|
||||
loop.advance(100);
|
||||
const alpha = ringEntity.polyline.material.color.getValue(
|
||||
Cesium.JulianDate.now(),
|
||||
).alpha;
|
||||
const expected =
|
||||
core.alpha * Math.max(restOpacity, awarenessRingFlash(loop.now()));
|
||||
assert.ok(Math.abs(alpha - expected) < 1e-9, `${alpha} vs ${expected}`);
|
||||
}
|
||||
});
|
||||
|
||||
test('without pulses the ring stays on screen at full strength', (t) => {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import * as Cesium from 'cesium';
|
||||
import { t } from '../../i18n/index.js';
|
||||
import {
|
||||
CYBER_SONAR_RENDER_INTERVAL_MS,
|
||||
createCyberSonarSampler,
|
||||
isCyberContactSonarActive as isCyberSonarActive,
|
||||
} from '../../cyberSonar.js';
|
||||
@@ -112,12 +113,51 @@ export function awarenessPulseState(
|
||||
};
|
||||
}
|
||||
|
||||
const REDUCED_MOTION_QUERY = '(prefers-reduced-motion: reduce)';
|
||||
|
||||
/** Whether the viewer asked the system for less motion. */
|
||||
function prefersReducedMotion() {
|
||||
return (
|
||||
globalThis.matchMedia?.('(prefers-reduced-motion: reduce)')?.matches ??
|
||||
false
|
||||
return globalThis.matchMedia?.(REDUCED_MOTION_QUERY)?.matches ?? false;
|
||||
}
|
||||
|
||||
/**
|
||||
* How far the pulses are switched on at `nowMs`: 0 at rest, rising to 1 over
|
||||
* `settleMs` from `wake.startMs` and falling back to 0 over `settleMs` up to
|
||||
* `wake.untilMs`.
|
||||
* @param {number} nowMs Monotonic clock in milliseconds.
|
||||
* @param {{startMs: number, untilMs: number}} wake Window from `wakeAwarenessPulses`.
|
||||
* @param {object} [pulse] Timing, see `AWARENESS_RANGE_PULSE`.
|
||||
* @returns {number} 0 to 1.
|
||||
*/
|
||||
export function awarenessPulseLevel(
|
||||
nowMs,
|
||||
wake,
|
||||
pulse = AWARENESS_RANGE_PULSE,
|
||||
) {
|
||||
const level = Math.min(
|
||||
(nowMs - wake.startMs) / pulse.settleMs,
|
||||
(wake.untilMs - nowMs) / pulse.settleMs,
|
||||
);
|
||||
return Math.max(0, Math.min(1, level));
|
||||
}
|
||||
|
||||
/**
|
||||
* Run the pulses for `activeMs` from `nowMs`. A wake while they already run
|
||||
* keeps their current level, so they never jump back to full mid-fade.
|
||||
* @param {{startMs: number, untilMs: number}} wake Updated in place.
|
||||
* @param {number} nowMs Monotonic clock in milliseconds.
|
||||
* @param {object} [pulse] Timing, see `AWARENESS_RANGE_PULSE`.
|
||||
* @returns {{startMs: number, untilMs: number}} `wake`.
|
||||
*/
|
||||
export function wakeAwarenessPulses(
|
||||
wake,
|
||||
nowMs,
|
||||
pulse = AWARENESS_RANGE_PULSE,
|
||||
) {
|
||||
wake.startMs =
|
||||
nowMs - awarenessPulseLevel(nowMs, wake, pulse) * pulse.settleMs;
|
||||
wake.untilMs = nowMs + pulse.activeMs;
|
||||
return wake;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -457,9 +497,19 @@ export function createRendering({
|
||||
presentDirection({ hidden: false, ring, heading, labels, markers });
|
||||
}
|
||||
|
||||
/**
|
||||
* Run the range pulses again for `activeMs`, after a camera move. Call it
|
||||
* during a frame (lifecycle's preRender): that frame's postRender arms the
|
||||
* next request, so waking asks for no extra frame of its own.
|
||||
*/
|
||||
function wakePulses() {
|
||||
const wake = layerState.visual?.wake;
|
||||
if (wake) wakeAwarenessPulses(wake, performance.now());
|
||||
}
|
||||
|
||||
function clearVisual() {
|
||||
globalThis.clearInterval(layerState.visual?.pulseTimer);
|
||||
layerState.visual?.removePulseListener?.();
|
||||
layerState.visual?.stopPulses?.();
|
||||
layerState.visual?.removeMotionListener?.();
|
||||
if (layerState.visual?.gradient && layerState.viewer)
|
||||
layerState.viewer.scene.primitives.remove(layerState.visual.gradient);
|
||||
clearAwarenessEchoField();
|
||||
@@ -482,7 +532,12 @@ export function createRendering({
|
||||
0,
|
||||
);
|
||||
const key = `${subject.layerId}:${subject.id}`;
|
||||
if (layerState.visual?.key === key) {
|
||||
const reducedMotion = prefersReducedMotion();
|
||||
// A reduced-motion change rebuilds the visual, with or without pulses.
|
||||
if (
|
||||
layerState.visual?.key === key &&
|
||||
layerState.visual.reducedMotion === reducedMotion
|
||||
) {
|
||||
// Move the ring only after a meaningful displacement so live tracking
|
||||
// does not rebuild its geometry for sub-pixel motion every 750 ms.
|
||||
if (
|
||||
@@ -527,10 +582,19 @@ export function createRendering({
|
||||
// tools, partial stubs) gets the static ring only, as the Cyber sonar
|
||||
// scene does. So does a viewer who prefers reduced motion.
|
||||
const animate = Boolean(
|
||||
!prefersReducedMotion() &&
|
||||
!reducedMotion &&
|
||||
scene?.primitives?.add &&
|
||||
scene?.preRender?.addEventListener,
|
||||
scene?.preRender?.addEventListener &&
|
||||
scene?.postRender?.addEventListener,
|
||||
);
|
||||
// Pulses run from now until `wake.untilMs`, then rest.
|
||||
const wake = animate
|
||||
? wakeAwarenessPulses(
|
||||
{ startMs: -Infinity, untilMs: -Infinity },
|
||||
performance.now(),
|
||||
)
|
||||
: null;
|
||||
const levelAt = (nowMs) => (wake ? awarenessPulseLevel(nowMs, wake) : 0);
|
||||
|
||||
// A polyline, not an ellipse outline: WebGL draws ellipse outlines at
|
||||
// 1 px whatever `outlineWidth` says, and the ring needs a halo.
|
||||
@@ -538,12 +602,13 @@ export function createRendering({
|
||||
const core = Cesium.Color.fromCssColorString(ring.color);
|
||||
const halo = Cesium.Color.fromCssColorString(ring.outlineColor);
|
||||
// While pulses run, the ring flashes as each one arrives and otherwise
|
||||
// rests at `restOpacity`; without pulses it stays on screen.
|
||||
// dims to `restOpacity`; as they settle it returns to full and stays.
|
||||
const flashing = (base) => {
|
||||
const color = base.clone();
|
||||
return new Cesium.CallbackProperty(() => {
|
||||
const flash = awarenessRingFlash(performance.now());
|
||||
color.alpha = base.alpha * Math.max(ring.restOpacity, flash);
|
||||
const nowMs = performance.now();
|
||||
const pulsed = Math.max(ring.restOpacity, awarenessRingFlash(nowMs));
|
||||
color.alpha = base.alpha * (1 - levelAt(nowMs) * (1 - pulsed));
|
||||
return color;
|
||||
}, false);
|
||||
};
|
||||
@@ -569,18 +634,26 @@ export function createRendering({
|
||||
|
||||
const center = Cesium.Cartesian3.clone(groundCenter);
|
||||
let gradient = null;
|
||||
let removePulseListener = null;
|
||||
let pulseTimer = null;
|
||||
let stopPulses = null;
|
||||
if (animate) {
|
||||
const pulse = AWARENESS_RANGE_PULSE;
|
||||
for (let index = 0; index < pulse.count; index++) {
|
||||
const positions = [];
|
||||
const coreColor = core.clone();
|
||||
const haloColor = halo.clone();
|
||||
const stateAt = () => awarenessPulseState(performance.now(), index);
|
||||
const stateAt = () => {
|
||||
const nowMs = performance.now();
|
||||
const state = awarenessPulseState(nowMs, index);
|
||||
state.alpha *= levelAt(nowMs);
|
||||
return state;
|
||||
};
|
||||
entities.push(
|
||||
layerState.viewer.entities.add({
|
||||
polyline: {
|
||||
show: new Cesium.CallbackProperty(
|
||||
() => levelAt(performance.now()) > 0,
|
||||
false,
|
||||
),
|
||||
positions: new Cesium.CallbackProperty(
|
||||
() =>
|
||||
awarenessRangeRingPositions(
|
||||
@@ -626,16 +699,40 @@ export function createRendering({
|
||||
);
|
||||
const radii = [0, 0, 0, 0];
|
||||
const alphas = [0, 0, 0, 0];
|
||||
removePulseListener = scene.preRender.addEventListener(() => {
|
||||
const removePreRender = scene.preRender.addEventListener(() => {
|
||||
const nowMs = performance.now();
|
||||
const level = levelAt(nowMs);
|
||||
// The disc is looked up each frame: a subject move swaps it.
|
||||
const disc = layerState.visual?.gradient;
|
||||
if (disc) disc.show = level > 0;
|
||||
for (let index = 0; index < count; index++) {
|
||||
const state = awarenessPulseState(nowMs, index);
|
||||
radii[index] = state.radiusM / AWARENESS_RADIUS_M;
|
||||
alphas[index] = state.alpha;
|
||||
alphas[index] = state.alpha * level;
|
||||
}
|
||||
Cesium.Cartesian4.fromArray(radii, 0, material.uniforms.radii);
|
||||
Cesium.Cartesian4.fromArray(alphas, 0, material.uniforms.alphas);
|
||||
});
|
||||
// Each drawn frame, whoever asked for it, arms one request a Cyber
|
||||
// sonar interval later, as the Cyber sonar scene does. Requests never
|
||||
// land within 80 ms of a frame, so they cannot beat the detection
|
||||
// lane's 80 ms gate, and they stop once the pulses rest.
|
||||
let timer = null;
|
||||
const removePostRender = scene.postRender.addEventListener(() => {
|
||||
globalThis.clearTimeout(timer);
|
||||
timer = null;
|
||||
if (performance.now() < wake.untilMs)
|
||||
timer = globalThis.setTimeout(() => {
|
||||
timer = null;
|
||||
governorRequestRender('awareness-pulse');
|
||||
}, CYBER_SONAR_RENDER_INTERVAL_MS);
|
||||
});
|
||||
stopPulses = () => {
|
||||
removePreRender();
|
||||
removePostRender();
|
||||
globalThis.clearTimeout(timer);
|
||||
timer = null;
|
||||
};
|
||||
// The detection overlay pulses the brackets of flights and vessels a
|
||||
// front crosses; it reads the centre in place, so it follows too.
|
||||
setAwarenessEchoField({
|
||||
@@ -643,13 +740,17 @@ export function createRendering({
|
||||
radiusM: AWARENESS_RADIUS_M,
|
||||
pulse,
|
||||
echo: AWARENESS_RANGE_ECHO,
|
||||
level: levelAt,
|
||||
});
|
||||
pulseTimer = globalThis.setInterval(
|
||||
() => governorRequestRender('awareness-pulse'),
|
||||
pulse.frameIntervalMs,
|
||||
);
|
||||
}
|
||||
|
||||
// Turning reduced motion on or off mid-session rebuilds the visual.
|
||||
const motionQuery = globalThis.matchMedia?.(REDUCED_MOTION_QUERY);
|
||||
const onMotionChange = () => {
|
||||
if (layerState.subject) renderVisual(layerState.subject);
|
||||
};
|
||||
motionQuery?.addEventListener?.('change', onMotionChange);
|
||||
|
||||
layerState.visual = {
|
||||
key,
|
||||
entities,
|
||||
@@ -657,8 +758,11 @@ export function createRendering({
|
||||
center,
|
||||
gradient,
|
||||
gradientCenter: Cesium.Cartesian3.clone(groundCenter),
|
||||
removePulseListener,
|
||||
pulseTimer,
|
||||
reducedMotion,
|
||||
wake,
|
||||
stopPulses,
|
||||
removeMotionListener: () =>
|
||||
motionQuery?.removeEventListener?.('change', onMotionChange),
|
||||
};
|
||||
governorRequestRender('awareness-visual');
|
||||
}
|
||||
@@ -672,5 +776,6 @@ export function createRendering({
|
||||
updateDirectionOverlay,
|
||||
clearVisual,
|
||||
renderVisual,
|
||||
wakePulses,
|
||||
};
|
||||
}
|
||||
Reference in new issue
Block a user