fix(awareness): rest the range pulses unless the user moves the camera

Review follow-ups for the sonar pulses.

Wake on user input only: a camera move wakes the pulses only within
1.5 s of a pointer, wheel or touch on the Cesium canvas, where its
camera controller listens. Tracking, the cockpit view, an orbit or a
fly-to move the camera on every frame and no longer keep the pulses
running. Keys don't count: the camera controller reads none.

Below the disc: with the camera under 2.5 km the pulses rest
completely, with no disc, ring flash, echoes or frame requests.

Echo brightness: echoes scale by the contact bracket's drawn alpha,
which includes the Cyber sonar sweep, rounded to the nearest band with
no floor, so an echo is never brighter than its bracket.

Disc follow: the pulse disc follows the subject by a model-matrix
rotation about the Earth's centre and is rebuilt only every 20 km
instead of every 500 m.

Tests: a cap on burst length; teardown through disable(); the echo's
spread and alpha on the canvas; echoes under the Cyber sonar never
brighter than their bracket; waking by pointer, wheel, touch and drag
but not by keys, app camera moves or stale input; the rest below the
disc; and tighter asserts. CURRENT-STATE notes that terrain above
2.5 km still cuts through the disc and ring.
This commit is contained in:
Manjunath committed 2026-11-03 22:50:32 +05:30
1 parent 3c6c342b2b
commit a851199f5b
10 files changed
+457 -73

No files matched your search

+1 -1
View File
@@ -4957,7 +4957,7 @@ silently demoting every later lookup for the session.
- Enabled Data Layer controls report normalized feed health on the button (`LOADING`, `DEGRADED`, `STALE`, `FALLBACK`, or `UNAVAILABLE`) while the metadata line retains the source and reason. A partial CelesTrak group failure keeps the usable catalog and reports `DEGRADED`; a total outage keeps last-good catalog data visible but reports `UNAVAILABLE`.
- On activation it focuses the nearest currently observed aircraft across the civilian and military feeds, with military winning an exact distance tie; if none are available, it focuses an observed AIS vessel. The aircraft search is deliberately uncapped and one refresh-tick retry handles initially empty feeds. This is an attention-priority navigation shortcut, not a high-risk, affiliation, or threat classification.
- A selected aircraft, AIS vessel, or mapped installation gets a 250 km **context window** with nearby cohort counts, nearest examples, source labels, and stale/unavailable reasons. It emits `NEARBY` or `UNKNOWN`; no detection, engagement, affiliation, or sensor-activity conclusion is calculated.
- The window's edge is drawn on the globe as a 250 km range ring: a 4 px polyline with a 2 px Context-blue core inside a 1 px dark halo on each side, 2.5 km above the ellipsoid (`AWARENESS_RANGE_RING_STYLE` in `src/layers/awareness/policy.js`). The halo keeps it legible over bright desert and snow, dark ocean and forest, and under the FLIR, NVG and CRT styles, where the earlier 1 px ellipse outline disappeared. It is a polyline because WebGL draws ellipse outlines at 1 px whatever width is asked for, drawn as an unpickable primitive rather than an entity so a click or tap on its edge reaches the contact underneath (headless viewers without a primitive collection get a static entity instead); it is rebuilt when the subject moves. Three dim sonar pulses (3 px with a 1 px dark outline, same colours, at most 60% opacity) grow slowly from the subject at constant speed, one every 3 s and each taking 9 s, and arrive at the ring at about a third of that. Behind each pulse a radial gradient in the same blue fades inward over 35% of the radius. One unpickable 250 km disc draws both the pulse lines and the gradient, its shader reading the pulse radii and opacities each frame, so a click or tap near a pulse still reaches the contact underneath; the disc is rebuilt only after the subject moves 500 m, and not at all while the pulses rest, and it is hidden while the camera is below its 2.5 km height, where it would sweep across the sky. While pulses run, the ring dims to 40% and flashes to full strength as each pulse reaches it, fading over 1.8 s. As a pulse front crosses a flight, military aircraft or AIS vessel, the detection overlay draws a second bracket around that contact's own bracket, in the pulse blue on the default detection style and in the style's own line colour under NVG, FLIR and CRT, which spreads outward as it fades over 1.4 s (`AWARENESS_RANGE_ECHO`, read through `src/awarenessEcho.js`; no layer's icon is restyled, and mapped installations have no detection bracket). Pulses are born 5 km out, so the subject itself and anything nearer never echo. Pulses run for 9 s (three ring flashes) after a selection or a camera move, fading in and out over 1.2 s, then rest: the ring stays on screen at full strength, the pulses, gradient and echoes are hidden, and nothing asks for frames, so a parked Contacts view is quiet again. A moving subject does not wake them, since a flying subject moves on every refresh; a camera that follows a tracked aircraft does keep them running, but that follow already renders every frame. While they run they never hold continuous rendering: each drawn frame arms one render request 80 ms later (the Cyber sonar cadence, `CYBER_SONAR_RENDER_INTERVAL_MS`), so they repaint at about 12.5 fps and never beat the detection lane's 80 ms gate. Under `prefers-reduced-motion`, or in a scene without primitives or preRender and postRender hooks, there are no pulses or echoes and the ring stays on screen; changing the preference mid-session rebuilds the ring. The ring follows a moving subject once it has moved 250 m.
- The window's edge is drawn on the globe as a 250 km range ring: a 4 px polyline with a 2 px Context-blue core inside a 1 px dark halo on each side, 2.5 km above the ellipsoid (`AWARENESS_RANGE_RING_STYLE` in `src/layers/awareness/policy.js`). The halo keeps it legible over bright desert and snow, dark ocean and forest, and under the FLIR, NVG and CRT styles, where the earlier 1 px ellipse outline disappeared. It is a polyline because WebGL draws ellipse outlines at 1 px whatever width is asked for, drawn as an unpickable primitive rather than an entity so a click or tap on its edge reaches the contact underneath (headless viewers without a primitive collection get a static entity instead); it is rebuilt when the subject moves. Three dim sonar pulses (3 px with a 1 px dark outline, same colours, at most 60% opacity) grow slowly from the subject at constant speed, one every 3 s and each taking 9 s, and arrive at the ring at about a third of that. Behind each pulse a radial gradient in the same blue fades inward over 35% of the radius. One unpickable 250 km disc draws both the pulse lines and the gradient, its shader reading the pulse radii and opacities each frame, so a click or tap near a pulse still reaches the contact underneath; the disc follows the subject by a model-matrix rotation and is rebuilt only after the subject moves 20 km, and not at all while the pulses rest. While the camera is below the disc's 2.5 km height, where it would sweep across the sky, the pulses rest: the disc is hidden and there is no ring flash, no echo and no frame request. Terrain or 3D tiles higher than 2.5 km (the Alps, Andes, Rockies) still cut through the disc and the ring when seen from above, as the ring alone already did; the height check uses the camera's height above the ellipsoid, not above the ground. While pulses run, the ring dims to 40% and flashes to full strength as each pulse reaches it, fading over 1.8 s. As a pulse front crosses a flight, military aircraft or AIS vessel, the detection overlay draws a second bracket around that contact's own bracket, in the pulse blue on the default detection style and in the style's own line colour under NVG, FLIR and CRT, which spreads outward as it fades over 1.4 s (`AWARENESS_RANGE_ECHO`, read through `src/awarenessEcho.js`; no layer's icon is restyled, and mapped installations have no detection bracket). Pulses are born 5 km out, so the subject itself and anything nearer never echo. Pulses run for 9 s (three ring flashes) after a selection or a camera move, fading in and out over 1.2 s, then rest: the ring stays on screen at full strength, the pulses, gradient and echoes are hidden, and nothing asks for frames, so a parked Contacts view is quiet again. Only a camera move within 1.5 s of the user handling the map (pointer, wheel or touch on the Cesium canvas, which covers a drag's inertia; keys typed elsewhere do not count) wakes them; a moving subject and the app's own camera moves (a tracked aircraft, the cockpit view, an orbit, a fly-to) do not, since they move on every refresh. While they run they never hold continuous rendering: each drawn frame arms one render request 80 ms later (the Cyber sonar cadence, `CYBER_SONAR_RENDER_INTERVAL_MS`), so they repaint at about 12.5 fps and never beat the detection lane's 80 ms gate. Under `prefers-reduced-motion`, or in a scene without primitives or preRender and postRender hooks, there are no pulses or echoes and the ring stays on screen; changing the preference mid-session rebuilds the ring. The ring follows a moving subject once it has moved 250 m.
- For a selected live aircraft or vessel, the context window refreshes from the existing tracker/feed position every 750 ms, so nearest distances and cohort counts follow the subject without introducing a duplicate poll loop.
- Aircraft cohort membership uses every locally loaded, selectable contact inside the 250 km window, including a plane hidden only by horizon culling or because its 3D model owns the visual. Counts and navigation therefore do not change with the current camera angle or billboard/model handoff.
- Nearby examples in the context panel are focus controls: they use their owning layer's existing selection/tracking path, then frame that contact. Static-installation distances use ellipsoidal surface distance so the count matches the ground-projected context disk.
+10 -9
View File
@@ -131,14 +131,16 @@ export function awarenessEchoAt(layerId, position, nowMs) {
/**
* The echo bracket the detection overlay draws around one contact, or null
* when it has no echo. `band` is the alpha band, 1 to `steps`, scaled by the
* contact's own keyhole `admissionAlpha`; `spreadPx` is how far the echo
* sits outside the contact's bracket, 0 as the front arrives, growing to the
* when it has no echo. `band` is the alpha band, 1 to `steps`, the echo's
* strength times the contact bracket's own `bracketAlpha` rounded to the
* nearest band, so an echo is never drawn brighter than its bracket; one
* that rounds to nothing is not drawn. `spreadPx` is how far the echo sits
* outside the contact's bracket, 0 as the front arrives, growing to the
* field's `spreadPx` as it fades.
* @param {string} layerId Owning layer of the contact.
* @param {Cesium.Cartesian3} position Contact's world position.
* @param {number} nowMs Monotonic clock in milliseconds.
* @param {number} admissionAlpha The contact bracket's own fade, 0 to 1.
* @param {number} bracketAlpha The contact bracket's drawn alpha, 0 to 1.
* @param {number} steps Alpha bands the overlay batches by.
* @returns {{band: number, spreadPx: number}|null}
*/
@@ -146,15 +148,14 @@ export function awarenessEchoBracket(
layerId,
position,
nowMs,
admissionAlpha,
bracketAlpha,
steps,
) {
const echo = awarenessEchoAt(layerId, position, nowMs);
if (!(echo > 0.01)) return null;
return {
band: Math.max(1, Math.ceil(echo * admissionAlpha * steps)),
spreadPx: field.echo.spreadPx * (1 - echo),
};
const band = Math.min(steps, Math.round(echo * bracketAlpha * steps));
if (!(band >= 1)) return null;
return { band, spreadPx: field.echo.spreadPx * (1 - echo) };
}
/**
+1 -1
View File
@@ -1456,7 +1456,7 @@ function _drawOverlay(frame) {
obj._layerId,
obj.position,
now,
admissionAlpha,
bracketAlpha,
ECHO_ALPHA_STEPS,
);
if (echo)
+15 -4
View File
@@ -39,8 +39,8 @@ test('the echo bracket pulses outward from the contact bracket and fades', (t) =
setAwarenessEchoField(field);
const position = Cesium.Cartesian3.fromDegrees(3.5, 27, 10_000);
const arrival = arrivalAt(position);
const at = (nowMs, admissionAlpha = 1) =>
awarenessEchoBracket('flights', position, nowMs, admissionAlpha, STEPS);
const at = (nowMs, bracketAlpha = 1) =>
awarenessEchoBracket('flights', position, nowMs, bracketAlpha, STEPS);
// The moment the front arrives: on the bracket, strongest band.
const fresh = at(arrival);
@@ -53,8 +53,19 @@ test('the echo bracket pulses outward from the contact bracket and fades', (t) =
assert.ok(half.spreadPx > echo.spreadPx * 0.5);
assert.ok(half.spreadPx < echo.spreadPx);
assert.ok(half.band < STEPS && half.band >= 1);
// Keyhole fade carries over to the echo.
assert.ok(at(arrival, 0.25).band <= 2);
// The bracket's own dimming (keyhole fade, Cyber sonar sweep) carries
// over, and the echo is never drawn brighter than its bracket: a bracket
// too dim for even one band draws no echo.
assert.equal(at(arrival, 0.25).band, 2);
assert.equal(at(arrival, 0.05), null);
for (let alpha = 0.05; alpha <= 1; alpha += 0.05) {
const echoed = at(arrival + echo.fadeMs / 3, alpha);
if (echoed)
assert.ok(
echoed.band / STEPS <= alpha + 0.5 / STEPS + 1e-9,
`band ${echoed.band} for a bracket at ${alpha}`,
);
}
// Faded out, or between fronts: no echo bracket.
assert.equal(at(arrival + echo.fadeMs + 5), null);
});
+129 -5
View File
@@ -54,8 +54,9 @@ class MockEvent {
}
class MockPath2D {
moveTo() {}
lineTo() {}
constructor() { this.points = []; }
moveTo(x, y) { this.points.push([x, y]); }
lineTo(x, y) { this.points.push([x, y]); }
roundRect() {}
arcTo() {}
closePath() {}
@@ -102,7 +103,7 @@ function mockContext(target, trace) {
// set at some point during the frame. globalAlpha rides along for the same
// reason: the backdrop feather is an alpha, not a colour.
fill(path) { record('fill', path, this.fillStyle, this.globalAlpha); },
stroke(path) { record('stroke', path, this.strokeStyle, this.lineWidth); },
stroke(path) { record('stroke', path, this.strokeStyle, this.lineWidth, this.globalAlpha); },
fillRect(...args) { record('fillRect', ...args); },
fillText(...args) { record('fillText', ...args); },
drawImage(...args) { record('drawImage', ...args); },
@@ -680,6 +681,129 @@ test('Global Context echo brackets reach the canvas in the detection style colou
}
});
test('a Global Context echo bracket is drawn outside its contact bracket and dimmer than it', () => {
const env = installEnvironment();
const echo = {
layerIds: ['military'],
fadeMs: 60_000,
color: 'rgb(1, 2, 3)',
spreadPx: 8,
lineWidth: 3.25,
};
// Fronts every millisecond keep the echo at full strength; a level of 0.5
// halves it, so it sits 4 px out and draws at half the bracket's alpha.
try {
setAwarenessEchoField({
center: Cesium.Cartesian3.fromDegrees(0, 89, 0),
radiusM: 250_000,
pulse: { count: 1000, periodMs: 1000, startFraction: 0 },
echo,
level: () => 0.5,
});
initWorldOverlay(env.viewer);
initDetection(env.viewer, [militaryLayer()], () => {});
setMode('DENSE');
setDetectionStyle('normal');
settleFrame(env);
env.detectionCtx.calls.length = 0;
settleFrame(env);
const strokes = env.detectionCtx.calls.filter(([name]) => name === 'stroke');
const echoStroke = strokes.find(([, , style, width]) => style === echo.color && width === echo.lineWidth);
assert.ok(echoStroke, 'the echo bracket is painted');
const bracketStroke = strokes.find(([, path, style, width]) =>
path instanceof MockPath2D && path.points.length && width !== echo.lineWidth && style !== echo.color);
assert.ok(bracketStroke, 'the contact bracket is painted');
const extent = (path) => {
const xs = path.points.map(([x]) => x);
const ys = path.points.map(([, y]) => y);
return { halfW: (Math.max(...xs) - Math.min(...xs)) / 2, halfH: (Math.max(...ys) - Math.min(...ys)) / 2 };
};
const echoBox = extent(echoStroke[1]);
const bracketBox = extent(bracketStroke[1]);
assert.ok(Math.abs(echoBox.halfW - bracketBox.halfW - echo.spreadPx / 2) < 1e-3,
`echo ${echoBox.halfW} vs bracket ${bracketBox.halfW}`);
assert.ok(Math.abs(echoBox.halfH - bracketBox.halfH - echo.spreadPx / 2) < 1e-3);
const echoAlpha = echoStroke[4];
const bracketAlpha = bracketStroke[4];
assert.ok(echoAlpha > 0 && echoAlpha < bracketAlpha, `${echoAlpha} vs ${bracketAlpha}`);
assert.ok(Math.abs(echoAlpha - bracketAlpha / 2) < 1e-6, `${echoAlpha} vs ${bracketAlpha}`);
} finally {
clearAwarenessEchoField();
env.cleanup();
}
});
test('under the Cyber sonar sweep an echo is never brighter than its own bracket', () => {
const env = installEnvironment();
// The cyber theme with its HUD open turns the contact sonar on; its sweep
// dims brackets it is not passing over.
env.document.documentElement = {
// The lowest sweep floor, so dimmed brackets drop a whole alpha band.
dataset: { uiTheme: 'cyber', cyberSonarOpacity: '35' },
style: { setProperty() {}, removeProperty() {} },
};
const hud = env.document.createElement('div');
hud.id = 'intel-hud';
hud.classList = { contains: (name) => name === 'active' };
env.document.body.appendChild(hud);
const echo = {
layerIds: ['military'],
fadeMs: 60_000,
color: 'rgb(1, 2, 3)',
spreadPx: 8,
lineWidth: 3.25,
};
const contact = {
id: 'military',
getDetectableObjects: () => [{
position: new Cesium.Cartesian3(0.5, 0.3, 6_356_752),
sourceId: 'sonar-pin',
id: 'SONAR',
metric: 'FL300',
type: 'AIR',
tier: 'military',
}],
};
try {
setAwarenessEchoField({
center: Cesium.Cartesian3.fromDegrees(0, 89, 0),
radiusM: 250_000,
pulse: { count: 1000, periodMs: 1000, startFraction: 0 },
echo,
level: () => 1,
});
initWorldOverlay(env.viewer);
initDetection(env.viewer, [contact], () => {});
setMode('DENSE');
setDetectionStyle('normal');
settleFrame(env);
let dimmed = 0;
let compared = 0;
for (let frame = 0; frame < 40; frame++) {
env.detectionCtx.calls.length = 0;
settleFrame(env);
const strokes = env.detectionCtx.calls.filter(([name]) => name === 'stroke');
const echoStroke = strokes.find(([, , style, width]) => style === echo.color && width === echo.lineWidth);
const bracketStroke = strokes.find(([, path, style, width]) =>
path instanceof MockPath2D && path.points.length && width !== echo.lineWidth && style !== echo.color);
if (!bracketStroke) continue;
const bracketAlpha = bracketStroke[4];
if (bracketAlpha < 0.99) dimmed++;
if (!echoStroke) continue;
compared++;
assert.ok(
echoStroke[4] <= bracketAlpha + 1e-9,
`echo ${echoStroke[4]} over bracket ${bracketAlpha} on frame ${frame}`,
);
}
assert.ok(dimmed > 0, 'the sweep dimmed the bracket on some frame');
assert.ok(compared > 0, 'an echo was painted');
} finally {
clearAwarenessEchoField();
env.cleanup();
}
});
test('resting Global Context pulses skip echo work for the whole frame, not per contact', () => {
const env = installEnvironment();
// Six echo-eligible flights on screen; the identity camera maps x/y
@@ -716,8 +840,8 @@ test('resting Global Context pulses skip echo work for the whole frame, not per
settleFrame(env);
levelCalls = 0;
settleFrame(env);
const painted = getDetectionDiagnostics()?.visibleCount ?? flights.length;
assert.ok(painted >= 2, `contacts on screen: ${painted}`);
const painted = getDetectionDiagnostics().visibleCount;
assert.equal(painted, flights.length, 'every flight is on screen');
// One check per painted frame, however many contacts are on screen.
assert.ok(levelCalls >= 1, 'the frame asks whether echoes are live');
assert.ok(levelCalls < painted, `${levelCalls} level checks for ${painted} contacts`);
+39 -2
View File
@@ -1,4 +1,5 @@
import { cameraPoseSignature } from '../../data/iconOrientation.js';
import { AWARENESS_RANGE_PULSE } from './policy.js';
export function createLifecycle({
state: layerState,
@@ -8,6 +9,26 @@ export function createLifecycle({
}) {
const { holdContinuousRender, releaseContinuousRender } = services.render;
/**
* Note the user handling the map's camera (pointer, wheel or touch on the
* Cesium canvas, where its camera controller listens), so a camera move the
* user made can wake the range pulses and one the app made (tracking,
* cockpit, orbit, a fly-to) does not. Keys are left out: Cesium's camera
* controller reads none, and typing elsewhere must not count.
*/
function noteCameraInput(event) {
if (event?.type === 'pointermove' && !event.buttons) return;
layerState.lastCameraInputMs = Date.now();
}
const CAMERA_INPUT_EVENTS = [
'pointerdown',
'pointermove',
'pointerup',
'wheel',
'touchstart',
];
/**
* Quantized camera-pose signature, or '' when the camera cannot report a full
* pose yet. An unknown pose reads as PARKED, so a camera that is still coming
@@ -50,6 +71,10 @@ export function createLifecycle({
'gev:awareness-subject-cleared',
layerState.subjectClearListener,
);
const input = layerState.viewer.canvas ?? layerState.viewer.scene?.canvas;
for (const type of CAMERA_INPUT_EVENTS)
input?.addEventListener?.(type, noteCameraInput, { passive: true });
layerState.cameraInputTarget = input ?? null;
layerState.preRenderRemover =
layerState.viewer.scene.preRender.addEventListener(() => {
if (!layerState.enabled) return;
@@ -62,8 +87,14 @@ export function createLifecycle({
if (poseSig !== layerState.lastCameraPoseSig) {
layerState.lastCameraPoseSig = poseSig;
layerState.lastCameraPoseChangeMs = now;
// A camera move runs the range pulses again; parked, they rest.
parts.rendering.wakePulses();
// A camera move the user made runs the range pulses again; parked,
// or moved by the app (tracking, cockpit, orbit, a fly-to), they
// rest.
if (
now - layerState.lastCameraInputMs <
AWARENESS_RANGE_PULSE.inputWakeMs
)
parts.rendering.wakePulses();
}
const decision = parts.model.awarenessRefreshDecision({
nowMs: now,
@@ -110,6 +141,12 @@ export function createLifecycle({
'gev:awareness-subject-cleared',
layerState.subjectClearListener,
);
for (const type of CAMERA_INPUT_EVENTS)
layerState.cameraInputTarget?.removeEventListener?.(
type,
noteCameraInput,
);
layerState.cameraInputTarget = null;
}
layerState.preRenderRemover?.();
layerState.preRenderRemover = null;
+11 -8
View File
@@ -81,18 +81,20 @@ export const AWARENESS_RANGE_RING_STYLE = Object.freeze({
* `gradientDepthFraction` of the radius, at most
* `gradientOpacity` strong. One unpickable disc draws the lines and the
* gradient, so pulses never catch a click meant for a contact; its shader
* takes at most four pulses. The disc is rebuilt only after the subject
* moves `gradientRebuildM`, so the pulses sit at most that far off the ring's
* centre, about a pixel when the ring fills the screen; while the pulses
* rest the hidden disc is left alone and catches up when they wake. The
* takes at most four pulses. The disc follows the subject by a model-matrix
* rotation, so it stays concentric with the ring, and is rebuilt only after
* the subject moves `gradientRebuildM` from where it was built; while the
* pulses rest the hidden disc is left alone and catches up when they wake. The
* disc is hidden while the camera is below its height, where it would sweep
* the sky.
* The pulses run for `activeMs` (three ring flashes) after a selection or
* a camera move, fading in and out over `settleMs`, then rest: the ring
* stays on screen, static, and nothing asks for frames, so a parked view
* goes quiet again. A moving subject does not wake them, since a flying
* subject moves on every refresh, but a camera that follows it does; the
* follow already renders every frame. While they run they never hold
* goes quiet again. Only a camera move within `inputWakeMs` of the user
* handling the map (pointer, wheel or touch on the canvas, which covers a
* drag's inertia) wakes them; a moving subject and the app's own camera moves
* (tracking, cockpit, orbit, a fly-to) do not. Below the disc's height they
* rest too. While they run they never hold
* continuous rendering; each drawn frame arms one more request after the
* Cyber sonar's 80 ms cadence, so the two never beat. When something else
* already renders continuously they update every frame. Skipped under
@@ -110,9 +112,10 @@ export const AWARENESS_RANGE_PULSE = Object.freeze({
gradientDepthFraction: 0.35,
gradientOpacity: 0.22,
gradientGranularityDeg: 0.25,
gradientRebuildM: 500,
gradientRebuildM: 20000,
activeMs: 9000,
settleMs: 1200,
inputWakeMs: 1500,
});
/** @constant Sonar echoes on contact brackets. When a pulse front reaches a
+193 -27
View File
@@ -133,10 +133,15 @@ function ringRendering(t, { reducedMotion = false, legacyQuery = false } = {}) {
* postRender) on the next millisecond, as Cesium's request-render mode does.
* `draw()` draws a frame someone else asked for.
*/
function frameLoop(t, ring) {
function frameLoop(t, ring, { date = false } = {}) {
let clock = 1_000_000;
t.mock.method(performance, 'now', () => clock);
t.mock.timers.enable({ apis: ['setTimeout', 'setInterval'] });
t.mock.timers.enable({
apis: date
? ['setTimeout', 'setInterval', 'Date']
: ['setTimeout', 'setInterval'],
now: clock,
});
let drawn = 0;
const draw = () => {
drawn = ring.reasons.length;
@@ -168,6 +173,15 @@ function pulsePrimitives(scene) {
return { ring, disc: discs[0], count: discs.length };
}
/** Where a disc built around `built` sits after its model matrix. */
function discCenter(disc, built) {
return Cesium.Matrix4.multiplyByPoint(
disc.modelMatrix,
built,
new Cesium.Cartesian3(),
);
}
/** The ring's material uniforms as the next drawn frame sets them. */
function ringColors(scene) {
scene.preRender.raiseEvent(scene);
@@ -239,13 +253,15 @@ test('the ring core and halo contrast on light and dark ground', () => {
});
test('renderVisual draws one haloed polyline ring, not an ellipse outline', (t) => {
const { entities, scene, state, rendering } = ringRendering(t);
const ring = ringRendering(t);
const { entities, scene, state, rendering } = ring;
frameLoop(t, ring); // freeze the clock: the pulses have not faded in yet
rendering.renderVisual(flight(3));
// A polyline primitive, never an entity or an ellipse outline.
assert.equal(entities.values.length, 0);
const { ring } = pulsePrimitives(scene);
assert.ok(ring);
const geometry = ring.geometryInstances.geometry;
const ringPrimitive = pulsePrimitives(scene).ring;
assert.ok(ringPrimitive);
const geometry = ringPrimitive.geometryInstances.geometry;
assert.ok(geometry instanceof Cesium.PolylineGeometry);
// 4 px wide, straight chords between surface-following positions, as the
// entity ring was drawn.
@@ -258,16 +274,17 @@ test('renderVisual draws one haloed polyline ring, not an ellipse outline', (t)
options.vertexFormat,
Cesium.PolylineMaterialAppearance.VERTEX_FORMAT,
);
const { material } = ring.appearance;
const { material } = ringPrimitive.appearance;
assert.equal(material.type, 'PolylineOutline');
// The ring's hue is fixed; its opacity flashes with arriving pulses.
// The ring's hue is fixed; its opacity flashes with arriving pulses, and
// at the moment it appears, before the pulses fade in, it is at full.
const core = Cesium.Color.fromCssColorString(
AWARENESS_RANGE_RING_STYLE.color,
);
const { color, outlineWidth } = ringColors(scene);
assert.equal(color.red, core.red);
assert.equal(color.blue, core.blue);
assert.ok(color.alpha >= 0 && color.alpha <= core.alpha + 1e-9);
assert.ok(Math.abs(color.alpha - core.alpha) < 1e-9, `${color.alpha}`);
assert.equal(outlineWidth, AWARENESS_RANGE_RING_STYLE.outlineWidth);
const ground = Cesium.Cartesian3.fromDegrees(3, 27, 0);
for (const distance of surfaceDistances(ground, state.visual.ringPositions))
@@ -305,9 +322,15 @@ test('the ring and pulses follow the subject past 250 m and ignore smaller drift
))
assert.ok(Math.abs(distance - AWARENESS_RADIUS_M) < 100);
assert.equal(renders(), rendersAfterAdd + 1);
// The pulses are drawn on a disc, rebuilt around the NEW centre a
// kilometre on, so they stay concentric with the ring.
assert.notEqual(pulsePrimitives(scene).disc, discBefore);
// The pulses are drawn on a disc that follows to the NEW centre by its
// model matrix, so they stay concentric with the ring without a rebuild.
assert.equal(pulsePrimitives(scene).disc, discBefore);
assert.ok(
Cesium.Cartesian3.distance(
discCenter(discBefore, Cesium.Cartesian3.fromDegrees(3, 27, 0)),
Cesium.Cartesian3.fromDegrees(3.01, 27, 0),
) < 1,
);
rendering.clearVisual();
assert.equal(scene.primitives.length, 0);
@@ -393,12 +416,18 @@ test('a parked view stops asking for frames once the pulses rest', (t) => {
const { scene, rendering, pulseFrames } = ring;
const loop = frameLoop(t, ring);
const { activeMs, count, periodMs } = AWARENESS_RANGE_PULSE;
// Long enough for three ring flashes, then quiet.
// Long enough for three ring flashes, then quiet, and short: a burst is
// seconds, not minutes.
assert.ok(activeMs >= 3 * (periodMs / count));
assert.ok(activeMs <= 15_000, `${activeMs} ms burst`);
rendering.renderVisual(flight(3));
loop.advance(activeMs + 500);
const frames = pulseFrames();
assert.ok(frames >= activeMs / CYBER_SONAR_RENDER_INTERVAL_MS - 2);
assert.ok(
frames <= activeMs / CYBER_SONAR_RENDER_INTERVAL_MS + 2,
`${frames} frames in one burst`,
);
// Parked: a minute later not one more request.
loop.advance(60_000);
assert.equal(pulseFrames(), frames, 'render requests stop after the bound');
@@ -440,9 +469,12 @@ test('a camera move wakes resting pulses for another bounded run', (t) => {
test('a camera move seen by the lifecycle wakes resting pulses; a parked camera does not', (t) => {
const ring = ringRendering(t);
const { state, rendering, pulseFrames } = ring;
const loop = frameLoop(t, ring);
const { activeMs } = AWARENESS_RANGE_PULSE;
const loop = frameLoop(t, ring, { date: true });
const { activeMs, inputWakeMs } = AWARENESS_RANGE_PULSE;
state.enabled = true;
state.viewer.canvas = new EventTarget();
const touch = () =>
state.viewer.canvas.dispatchEvent(new Event('pointerdown'));
state.viewer.camera = {
positionWC: Cesium.Cartesian3.fromDegrees(3, 20, 800_000),
heading: 0,
@@ -471,7 +503,9 @@ test('a camera move seen by the lifecycle wakes resting pulses; a parked camera
loop.draw();
loop.advance(activeMs);
assert.equal(pulseFrames(), rested);
// The camera pans 50 km: the next frame wakes them for another run.
// The user drags the camera 50 km: the next frame wakes them for another
// run.
touch();
state.viewer.camera.positionWC = Cesium.Cartesian3.fromDegrees(
3.5,
20,
@@ -485,6 +519,115 @@ test('a camera move seen by the lifecycle wakes resting pulses; a parked camera
const woke = pulseFrames();
loop.advance(60_000);
assert.equal(pulseFrames(), woke, 'and rest again once it parks');
// The app moving the camera, with no hand on it, does not wake them: a
// tracking follow, the cockpit view, an orbit or a fly-to all move it on
// every frame.
const follow = (lon) => {
state.viewer.camera.positionWC = Cesium.Cartesian3.fromDegrees(
lon,
20,
800_000,
);
loop.draw();
loop.advance(500);
};
for (let lon = 3.6; lon < 5.6; lon += 0.1) follow(lon);
assert.equal(pulseFrames(), woke, 'app camera moves do not wake them');
// Input long past (beyond `inputWakeMs`) does not count either; a hover
// without a button pressed is not handling the camera.
touch();
loop.advance(inputWakeMs + 100);
state.viewer.canvas.dispatchEvent(
Object.assign(new Event('pointermove'), { buttons: 0 }),
);
follow(5.7);
assert.equal(pulseFrames(), woke, 'stale input does not wake them');
// Each way of handling the map counts: a wheel, a touch, a drag; keys
// typed anywhere in the window do not.
const panWith = (handle, lon) => {
loop.advance(activeMs + 500);
const before = pulseFrames();
handle();
follow(lon);
loop.advance(activeMs);
return pulseFrames() > before;
};
const onCanvas = (event) => () => state.viewer.canvas.dispatchEvent(event);
assert.equal(panWith(onCanvas(new Event('wheel')), 5.8), true, 'wheel');
assert.equal(panWith(onCanvas(new Event('touchstart')), 5.9), true, 'touch');
assert.equal(
panWith(
onCanvas(Object.assign(new Event('pointermove'), { buttons: 1 })),
6.0,
),
true,
'drag',
);
assert.equal(
panWith(() => window.dispatchEvent(new Event('keydown')), 6.1),
false,
'keys do not count',
);
// Detached, the canvas listeners are gone.
lifecycle.detachRuntimeListeners();
touch();
assert.equal(state.lastCameraInputMs < Date.now() - inputWakeMs, true);
});
test('disabling the layer tears the pulses down completely', (t) => {
const ring = ringRendering(t);
const { state, scene, rendering, motion, pulseFrames } = ring;
const loop = frameLoop(t, ring);
state.viewer.camera = {
positionWC: Cesium.Cartesian3.fromDegrees(3, 20, 800_000),
heading: 0,
pitch: -1,
roll: 0,
};
const lifecycle = createLifecycle({
state,
services: {
render: { holdContinuousRender() {}, releaseContinuousRender() {} },
},
parts: {
rendering,
model: {
awarenessRefreshDecision: () => ({ moving: false, refresh: false }),
awarenessNeedsContinuousRender: () => false,
},
panel: {
startAwarenessPageRotation() {},
stopAwarenessPageRotation() {},
ensurePanel() {},
hidePanel() {},
},
dependencies: {
activateOperationalContext: () => true,
releaseOwnedDependencies: () => true,
},
},
});
state.passive = true;
lifecycle.methods.enable();
state.subject = flight(3);
rendering.renderVisual(state.subject);
loop.advance(1000);
assert.ok(pulseFrames() > 0);
assert.equal(scene.primitives.length, 2);
lifecycle.methods.disable();
assert.equal(scene.primitives.length, 0, 'ring and disc removed');
assert.equal(scene.preRender.numberOfListeners, 0);
assert.equal(scene.postRender.numberOfListeners, 0);
assert.equal(motion.listeners(), 0, 'no media-query listener');
assert.equal(getAwarenessEchoField(), null);
assert.equal(state.visual, null);
const frames = pulseFrames();
loop.advance(60_000);
assert.equal(
pulseFrames(),
frames,
'no awareness-pulse requests after disable',
);
});
test('pulse requests wait 80 ms after any frame, so they cannot beat the Cyber sonar', (t) => {
@@ -602,14 +745,23 @@ test('a gradient disc behind the pulses fades inward and follows the subject', (
0,
);
// The disc is rebuilt only past `gradientRebuildM`, not on every 250 m
// step the ring takes, and keeps its material.
rendering.renderVisual(flight(3.001));
assert.equal(pulsePrimitives(scene).disc, disc);
rendering.renderVisual(flight(3.004)); // ~400 m: the ring moves, the disc stays
assert.equal(pulsePrimitives(scene).disc, disc);
assert.ok(!disc.isDestroyed());
rendering.renderVisual(flight(3.01)); // ~1 km from the disc's centre
// The disc follows by its model matrix and is rebuilt only past
// `gradientRebuildM` (20 km), keeping its material.
const built = Cesium.Cartesian3.fromDegrees(3, 27, 0);
for (const lon of [3.001, 3.01, 3.1, 3.19]) {
rendering.renderVisual(flight(lon));
assert.equal(pulsePrimitives(scene).disc, disc, `${lon}`);
assert.ok(!disc.isDestroyed());
// Concentric with the ring wherever the subject went, within a metre
// across the ground; the ellipsoid curves away from the rotation's
// sphere by tens of metres in height at most.
const at = Cesium.Cartographic.fromCartesian(discCenter(disc, built));
const want = Cesium.Cartographic.fromCartesian(ring.state.visual.center);
const across = new Cesium.EllipsoidGeodesic(at, want).surfaceDistance;
assert.ok(across < 1, `${across} m off at ${lon}`);
assert.ok(Math.abs(at.height) < 50, `${at.height} m off the ground`);
}
rendering.renderVisual(flight(3.25)); // ~25 km from where it was built
assert.equal(pulsePrimitives(scene).count, 1);
const moved = pulsePrimitives(scene).disc;
assert.notEqual(moved, disc);
@@ -629,12 +781,15 @@ test('a resting pulse disc is not rebuilt as the subject moves, and catches up o
rendering.renderVisual(flight(3));
loop.advance(AWARENESS_RANGE_PULSE.activeMs + 500);
const resting = pulsePrimitives(scene).disc;
// A flying subject keeps moving while the pulses rest: no rebuilds.
for (let lon = 3.01; lon < 3.1; lon += 0.01)
const restingMatrix = Cesium.Matrix4.clone(resting.modelMatrix);
// A flying subject keeps moving while the pulses rest: the hidden disc is
// neither moved nor rebuilt, even well past the rebuild distance.
for (let lon = 3.01; lon < 3.4; lon += 0.01)
rendering.renderVisual(flight(lon));
assert.equal(pulsePrimitives(scene).disc, resting);
assert.ok(!resting.isDestroyed());
// Woken, the disc moves to the subject at once.
assert.ok(Cesium.Matrix4.equals(resting.modelMatrix, restingMatrix));
// Woken, the disc catches up at once: rebuilt around the subject.
rendering.wakePulses();
const woken = pulsePrimitives(scene).disc;
assert.notEqual(woken, resting);
@@ -673,6 +828,17 @@ test('a camera below the rim height hides the pulse disc instead of sweeping the
};
assert.equal(cameraAt(CONTEXT_RIM_HEIGHT_M + 500), true);
assert.equal(cameraAt(CONTEXT_RIM_HEIGHT_M - 500), false, 'cockpit view');
// Below the disc the pulses rest with it: the ring is steady at full, no
// echo lights and no frame is asked for.
const core = Cesium.Color.fromCssColorString(
AWARENESS_RANGE_RING_STYLE.color,
);
assert.ok(Math.abs(ringColors(scene).color.alpha - core.alpha) < 1e-9);
assert.equal(getAwarenessEchoField().level(loop.now()), 0);
const frames = ring.pulseFrames();
loop.draw();
loop.advance(2000);
assert.equal(ring.pulseFrames(), frames, 'no requests below the rim');
assert.equal(cameraAt(800_000), true);
});
+54 -16
View File
@@ -308,6 +308,34 @@ function createRingPrimitive(positions, material) {
});
}
/**
* The rotation about the Earth's centre that carries `from` onto `to`, as a
* model matrix, so a disc built around `from` sits around `to`.
* @param {Cesium.Cartesian3} from Where the disc was built.
* @param {Cesium.Cartesian3} to Where it should be.
* @param {Cesium.Matrix4} [result]
* @returns {Cesium.Matrix4}
*/
export function discFollowMatrix(from, to, result = new Cesium.Matrix4()) {
const a = Cesium.Cartesian3.normalize(from, new Cesium.Cartesian3());
const b = Cesium.Cartesian3.normalize(to, new Cesium.Cartesian3());
const axis = Cesium.Cartesian3.cross(a, b, new Cesium.Cartesian3());
const sin = Cesium.Cartesian3.magnitude(axis);
if (sin < 1e-12) return Cesium.Matrix4.clone(Cesium.Matrix4.IDENTITY, result);
const angle = Math.atan2(sin, Cesium.Cartesian3.dot(a, b));
const rotation = Cesium.Matrix3.fromQuaternion(
Cesium.Quaternion.fromAxisAngle(
Cesium.Cartesian3.divideByScalar(axis, sin, axis),
angle,
),
);
return Cesium.Matrix4.fromRotationTranslation(
rotation,
Cesium.Cartesian3.ZERO,
result,
);
}
/** Camera height above the ellipsoid, or NaN when the scene cannot say. */
function cameraHeightM(scene) {
return scene.camera?.positionCartographic?.height ?? Number.NaN;
@@ -584,18 +612,27 @@ export function createRendering({
}
/**
* Rebuild the pulse disc around the subject once it is `gradientRebuildM`
* from the disc's centre. A build costs a few milliseconds, so a subject
* that moves less keeps the disc it has.
* Keep the pulse disc centred on the subject. Within `gradientRebuildM` of
* where it was built, the disc is rotated about the Earth's centre onto
* the subject by its model matrix, which keeps it concentric with the ring
* at no build cost; the ellipsoid curves a little differently from that
* sphere, a few tens of metres in height at 20 km. Further away it is
* rebuilt, which costs a few milliseconds.
*/
function followPulseDisc() {
const { gradient, gradientCenter, center } = layerState.visual;
if (!gradient) return;
if (
!gradient ||
Cesium.Cartesian3.distance(gradientCenter, center) <
AWARENESS_RANGE_PULSE.gradientRebuildM
)
AWARENESS_RANGE_PULSE.gradientRebuildM
) {
gradient.modelMatrix = discFollowMatrix(
gradientCenter,
center,
gradient.modelMatrix,
);
return;
}
const primitives = layerState.viewer.scene.primitives;
layerState.visual.gradient = primitives.add(
createPulseGradient(center, gradient.appearance.material),
@@ -655,8 +692,8 @@ export function createRendering({
CONTEXT_RIM_HEIGHT_M,
);
// The echo field reads `visual.center` in place and follows on its
// own. The pulse disc is geometry, so it is rebuilt, but only every
// `gradientRebuildM`: a build costs a few milliseconds.
// own. The pulse disc follows by its model matrix and is rebuilt only
// every `gradientRebuildM`: a build costs a few milliseconds.
layerState.visual.ringPositions = positions;
const { ringPrimitive } = layerState.visual;
if (ringPrimitive) {
@@ -695,7 +732,12 @@ export function createRendering({
performance.now(),
)
: null;
const levelAt = (nowMs) => (wake ? awarenessPulseLevel(nowMs, wake) : 0);
// From a camera below the rim height the disc would sweep across the sky,
// so it is hidden there, and the pulses rest with it: no flash, no
// echoes, no frames.
const belowRim = () => cameraHeightM(scene) < CONTEXT_RIM_HEIGHT_M;
const levelAt = (nowMs) =>
wake && !belowRim() ? awarenessPulseLevel(nowMs, wake) : 0;
// A polyline, not an ellipse outline: WebGL draws ellipse outlines at
// 1 px whatever `outlineWidth` says, and the ring needs a halo.
@@ -777,13 +819,9 @@ export function createRendering({
const alpha = ringAlpha(nowMs);
ringMaterial.uniforms.color.alpha = core.alpha * alpha;
ringMaterial.uniforms.outlineColor.alpha = halo.alpha * alpha;
// The disc is looked up each frame: a subject move swaps it. From a
// camera below the rim height it would sweep across the sky, so it
// is hidden there.
// The disc is looked up each frame: a subject move swaps it.
const disc = layerState.visual?.gradient;
if (disc)
disc.show =
level > 0 && !(cameraHeightM(scene) < CONTEXT_RIM_HEIGHT_M);
if (disc) disc.show = level > 0;
for (let index = 0; index < count; index++) {
const state = awarenessPulseState(nowMs, index);
radii[index] = state.radiusM / AWARENESS_RADIUS_M;
@@ -800,7 +838,7 @@ export function createRendering({
const removePostRender = scene.postRender.addEventListener(() => {
globalThis.clearTimeout(timer);
timer = null;
if (performance.now() < wake.untilMs)
if (performance.now() < wake.untilMs && !belowRim())
timer = globalThis.setTimeout(() => {
timer = null;
governorRequestRender('awareness-pulse');
+4
View File
@@ -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,