mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-05 10:53:59 +00:00
feat(awareness): add calm sonar pulses that flash the range ring and echo contacts
Pulses: three dim rings grow from the Global Context subject to the 250 km range ring at constant speed, one every 3 s, each taking 9 s. Each fades in over its first 8% of travel, then fades as (1 - travel)^1.5 to about a third of its 60% peak, so it arrives at the ring dim but visible. They are 3 px in the ring's colours, as polylines with CallbackProperty positions and colours that share the ring's centre object, so they follow a moving subject. Ring: while pulses run, the 250 km ring rests unseen and flashes to full strength as each pulse reaches it, fading over 1.8 s. Without pulses (reduced motion, or a scene with no primitives or preRender hook) it stays on screen as before. Gradient: behind each pulse a radial band in the same blue fades inward over 35% of the radius. One flat, translucent, unpickable 250 km disc draws it with a small Fabric material. A preRender listener writes the pulse radii and opacities into two vec4 uniforms each frame. The disc (0.25 degree granularity, ~3.5 ms to build) is rebuilt only after the subject moves 2 km. Echoes: as a pulse front crosses a flight, military aircraft or AIS vessel, the detection overlay draws a second bracket in the pulse blue around that contact's own bracket, spreading outward as it fades over 1.4 s. Awareness publishes the field (centre, radius, pulse timing, echo styling) through the new src/awarenessEcho.js. The overlay reads it, so no layer's icon or colour is touched. Mapped installations have no detection bracket, so they do not echo. Rendering cost: pulses never hold continuous rendering. They ask for a frame every 80 ms, the Cyber sonar's parked cadence, so a parked Contacts view repaints at ~12.5 fps while a subject is shown. This changes the "parked Contacts stays quiet" behaviour on purpose. clearVisual stops the timer and removes the disc, the listener and the echo field. awarenessRangeRingPositions can now reuse a result array, and passes the WGS84 ellipsoid explicitly so a reused Cartesian never lands in the ellipsoid argument. Tuning lives in AWARENESS_RANGE_PULSE, AWARENESS_RANGE_ECHO and AWARENESS_RANGE_RING_STYLE. src/awarenessEcho.js is registered in the awareness-layer, application-components and application-layer-construction packages. Tests cover: - the pulse curve and calm rate; - the ring flash once per arrival, and the static ring without pulses; - the 80 ms frame cadence and its stop; - the partial-scene fallback; - the gradient disc: unpickable, uniform values, rebuild and cleanup; - per-frame evaluation, and the echo field following the subject; - awarenessEcho strength, rim wrap, layer filter and altitude handling; - the detection renderer's echo-bracket band and outward spread.
This commit is contained in:
1 parent
2a629f0987
commit
e80f583e66
10 files changed
+1064
-56
No files matched your search
@@ -49,7 +49,10 @@ export const CONTEXT_RIM_HEIGHT_M = 2500;
|
||||
* the FLIR, NVG and CRT styles. A bright core inside a dark halo keeps
|
||||
* luminance contrast on light and dark ground alike, and luminance is what
|
||||
* the monochrome styles keep. The core is the Context panel's blue; the halo
|
||||
* is the map-card ink. Widths are screen pixels; `outlineWidth` is per side. */
|
||||
* is the map-card ink. Widths are screen pixels; `outlineWidth` is per side.
|
||||
* While the sonar pulses run, the ring rests at `restOpacity` and flashes
|
||||
* to full as each pulse reaches it, fading over `flashMs`. Without pulses
|
||||
* (reduced motion, or a scene that cannot animate) it stays on screen. */
|
||||
|
||||
export const AWARENESS_RANGE_RING_STYLE = Object.freeze({
|
||||
color: 'rgba(98, 181, 255, 0.95)',
|
||||
@@ -57,6 +60,58 @@ export const AWARENESS_RANGE_RING_STYLE = Object.freeze({
|
||||
width: 4,
|
||||
outlineWidth: 1,
|
||||
segments: 128,
|
||||
flashMs: 1800,
|
||||
restOpacity: 0,
|
||||
});
|
||||
|
||||
/** @constant Sonar-style pulses that grow slowly from the subject to the
|
||||
* range ring and are almost gone when they reach it. `count` pulses share
|
||||
* one `periodMs`, evenly staggered, so a new one starts every
|
||||
* `periodMs / count`. Each starts at `startFraction` of the radius, fades in
|
||||
* over `fadeInFraction` of its travel, then fades as (1 - travel) to the
|
||||
* `fadeOutPower` down to `endOpacityFraction` of `peakOpacity`, so a pulse
|
||||
* is dim but still visible when it reaches the ring and the ring flashes. Behind each line a radial gradient in the same blue fades
|
||||
* inward over `gradientDepthFraction` of the radius, at most
|
||||
* `gradientOpacity` strong. The gradient disc is rebuilt only after the
|
||||
* subject moves `gradientRebuildM`, since a soft band 2 km off centre on a
|
||||
* 250 km disc does not show. The gradient shader takes at most four pulses.
|
||||
* The pulses do not hold continuous rendering: a parked Contacts view must
|
||||
* not repaint every frame. They ask for a frame every `frameIntervalMs`
|
||||
* instead, the cadence the Cyber sonar uses for parked scenes; when
|
||||
* something else already renders continuously they update every frame.
|
||||
* Skipped under prefers-reduced-motion. */
|
||||
|
||||
export const AWARENESS_RANGE_PULSE = Object.freeze({
|
||||
count: 3,
|
||||
periodMs: 9000,
|
||||
startFraction: 0.02,
|
||||
fadeInFraction: 0.08,
|
||||
fadeOutPower: 1.5,
|
||||
endOpacityFraction: 0.35,
|
||||
peakOpacity: 0.6,
|
||||
width: 3,
|
||||
outlineWidth: 1,
|
||||
segments: 96,
|
||||
gradientDepthFraction: 0.35,
|
||||
gradientOpacity: 0.22,
|
||||
gradientGranularityDeg: 0.25,
|
||||
gradientRebuildM: 2000,
|
||||
frameIntervalMs: 80,
|
||||
});
|
||||
|
||||
/** @constant Sonar echoes on contact brackets. When a pulse front reaches a
|
||||
* flight, military aircraft or AIS vessel in the window, the detection
|
||||
* overlay draws a second bracket in `color` around that contact's own
|
||||
* bracket. It starts on the bracket and spreads outward by up to `spreadPx`
|
||||
* as it fades over `fadeMs`, so the frame pulses. Only `layerIds` echo;
|
||||
* mapped installations have no detection bracket. */
|
||||
|
||||
export const AWARENESS_RANGE_ECHO = Object.freeze({
|
||||
layerIds: ['flights', 'military', 'ais-live-vessels'],
|
||||
fadeMs: 1400,
|
||||
color: 'rgb(98, 181, 255)',
|
||||
spreadPx: 8,
|
||||
lineWidth: 1.75,
|
||||
});
|
||||
|
||||
export const VESSEL_FOCUS_RADIUS_M = 3000;
|
||||
|
||||
@@ -1,12 +1,29 @@
|
||||
// src/layers/awareness/rangeRing.test.mjs — the 250 km Global Context range
|
||||
// ring is a haloed polyline that stays legible on light and dark ground,
|
||||
// sits where the old ellipse outline did, and follows the subject.
|
||||
// Dim sonar pulses grow slowly from the subject to the ring, each with an
|
||||
// inward-fading gradient behind it; the ring flashes as each one arrives and
|
||||
// the overlay's echo field follows the subject. Under reduced motion the ring
|
||||
// simply stays on screen.
|
||||
import '../../testSupport/registerDom.mjs';
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import * as Cesium from 'cesium';
|
||||
import { AWARENESS_RADIUS_M } from '../../data/militaryAwarenessEngine.js';
|
||||
import { AWARENESS_RANGE_RING_STYLE, CONTEXT_RIM_HEIGHT_M } from './policy.js';
|
||||
import { awarenessRangeRingPositions, createRendering } from './rendering.js';
|
||||
import {
|
||||
AWARENESS_RANGE_ECHO,
|
||||
AWARENESS_RANGE_PULSE,
|
||||
AWARENESS_RANGE_RING_STYLE,
|
||||
CONTEXT_RIM_HEIGHT_M,
|
||||
} from './policy.js';
|
||||
import {
|
||||
AWARENESS_PULSE_GRADIENT_MAX,
|
||||
awarenessPulseState,
|
||||
awarenessRangeRingPositions,
|
||||
awarenessRingFlash,
|
||||
createRendering,
|
||||
} from './rendering.js';
|
||||
import { getAwarenessEchoField } from '../../awarenessEcho.js';
|
||||
import { createState } from './state.js';
|
||||
|
||||
/** WCAG relative luminance of a Cesium colour, ignoring alpha. */
|
||||
@@ -20,27 +37,68 @@ function luminance(color) {
|
||||
);
|
||||
}
|
||||
|
||||
/** The rendering part over a bare entity collection, counting render asks. */
|
||||
function ringRendering() {
|
||||
/**
|
||||
* The rendering part over a bare entity collection, counting render asks and
|
||||
* by reason. `reducedMotion` stubs the media query for
|
||||
* the length of the test.
|
||||
*/
|
||||
function ringRendering(t, { reducedMotion = false } = {}) {
|
||||
const previous = globalThis.matchMedia;
|
||||
globalThis.matchMedia = (query) => ({
|
||||
matches: query === '(prefers-reduced-motion: reduce)' && reducedMotion,
|
||||
});
|
||||
t.after(() => {
|
||||
globalThis.matchMedia = previous;
|
||||
});
|
||||
const state = createState({ services: {} });
|
||||
const entities = new Cesium.EntityCollection();
|
||||
state.viewer = { entities };
|
||||
let renders = 0;
|
||||
const scene = {
|
||||
primitives: new Cesium.PrimitiveCollection(),
|
||||
preRender: new Cesium.Event(),
|
||||
};
|
||||
state.viewer = { entities, scene };
|
||||
const reasons = [];
|
||||
const rendering = createRendering({
|
||||
state,
|
||||
services: {
|
||||
geometry: {},
|
||||
render: {
|
||||
governorRequestRender() {
|
||||
renders += 1;
|
||||
governorRequestRender(reason) {
|
||||
reasons.push(reason);
|
||||
},
|
||||
},
|
||||
},
|
||||
parts: {},
|
||||
});
|
||||
return { entities, rendering, renders: () => renders };
|
||||
// A shown ring keeps a pulse timer; never leave one running past a test.
|
||||
t.after(() => rendering.clearVisual());
|
||||
return {
|
||||
state,
|
||||
entities,
|
||||
scene,
|
||||
rendering,
|
||||
renders: () => reasons.filter((r) => r === 'awareness-visual').length,
|
||||
pulseFrames: () => reasons.filter((r) => r === 'awareness-pulse').length,
|
||||
};
|
||||
}
|
||||
|
||||
/** The pulse gradient disc and the echo collection among scene primitives. */
|
||||
function pulsePrimitives(scene) {
|
||||
const all = Array.from({ length: scene.primitives.length }, (_, i) =>
|
||||
scene.primitives.get(i),
|
||||
);
|
||||
return {
|
||||
disc: all.find((p) => p instanceof Cesium.Primitive),
|
||||
count: all.length,
|
||||
};
|
||||
}
|
||||
|
||||
const flight = (lon) => ({
|
||||
layerId: 'flights',
|
||||
id: 'abc123',
|
||||
position: Cesium.Cartesian3.fromDegrees(lon, 27, 9000),
|
||||
});
|
||||
|
||||
/** Surface distance from `center` to each ring position. */
|
||||
function surfaceDistances(center, positions) {
|
||||
const from = Cesium.Cartographic.fromCartesian(center);
|
||||
@@ -99,15 +157,11 @@ test('the ring core and halo contrast on light and dark ground', () => {
|
||||
assert.ok(width - 2 * outlineWidth >= 2);
|
||||
});
|
||||
|
||||
test('renderVisual draws one haloed polyline ring, not an ellipse outline', () => {
|
||||
const { entities, rendering } = ringRendering();
|
||||
const subject = {
|
||||
layerId: 'flights',
|
||||
id: 'abc123',
|
||||
position: Cesium.Cartesian3.fromDegrees(3, 27, 9000),
|
||||
};
|
||||
rendering.renderVisual(subject);
|
||||
assert.equal(entities.values.length, 1);
|
||||
test('renderVisual draws one haloed polyline ring, not an ellipse outline', (t) => {
|
||||
const { entities, rendering } = ringRendering(t);
|
||||
rendering.renderVisual(flight(3));
|
||||
assert.equal(entities.values.length, 1 + AWARENESS_RANGE_PULSE.count);
|
||||
assert.ok(entities.values.every((entity) => entity.ellipse === undefined));
|
||||
const [entity] = entities.values;
|
||||
assert.equal(entity.ellipse, undefined);
|
||||
const now = Cesium.JulianDate.now();
|
||||
@@ -115,12 +169,14 @@ test('renderVisual draws one haloed polyline ring, not an ellipse outline', () =
|
||||
assert.equal(entity.polyline.arcType.getValue(now), Cesium.ArcType.NONE);
|
||||
const material = entity.polyline.material;
|
||||
assert.ok(material instanceof Cesium.PolylineOutlineMaterialProperty);
|
||||
assert.equal(
|
||||
material.color.getValue(now).toCssColorString(),
|
||||
Cesium.Color.fromCssColorString(
|
||||
AWARENESS_RANGE_RING_STYLE.color,
|
||||
).toCssColorString(),
|
||||
// The ring's hue is fixed; its opacity flashes with arriving pulses.
|
||||
const core = Cesium.Color.fromCssColorString(
|
||||
AWARENESS_RANGE_RING_STYLE.color,
|
||||
);
|
||||
const color = material.color.getValue(now);
|
||||
assert.equal(color.red, core.red);
|
||||
assert.equal(color.blue, core.blue);
|
||||
assert.ok(color.alpha >= 0 && color.alpha <= core.alpha + 1e-9);
|
||||
assert.equal(
|
||||
material.outlineWidth.getValue(now),
|
||||
AWARENESS_RANGE_RING_STYLE.outlineWidth,
|
||||
@@ -133,13 +189,9 @@ test('renderVisual draws one haloed polyline ring, not an ellipse outline', () =
|
||||
assert.ok(Math.abs(distance - AWARENESS_RADIUS_M) < 100);
|
||||
});
|
||||
|
||||
test('the ring follows the subject past 250 m and ignores smaller drift', () => {
|
||||
const { entities, rendering, renders } = ringRendering();
|
||||
const subject = (lon) => ({
|
||||
layerId: 'flights',
|
||||
id: 'abc123',
|
||||
position: Cesium.Cartesian3.fromDegrees(lon, 27, 9000),
|
||||
});
|
||||
test('the ring and pulses follow the subject past 250 m and ignore smaller drift', (t) => {
|
||||
const { entities, rendering, renders } = ringRendering(t);
|
||||
const subject = flight;
|
||||
rendering.renderVisual(subject(3));
|
||||
const [entity] = entities.values;
|
||||
const now = Cesium.JulianDate.now();
|
||||
@@ -153,7 +205,7 @@ test('the ring follows the subject past 250 m and ignores smaller drift', () =>
|
||||
|
||||
// ~1 km east: the same entity moves to the new centre.
|
||||
rendering.renderVisual(subject(3.01));
|
||||
assert.equal(entities.values.length, 1);
|
||||
assert.equal(entities.values.length, 1 + AWARENESS_RANGE_PULSE.count);
|
||||
assert.equal(entities.values[0], entity);
|
||||
const moved = entity.polyline.positions.getValue(now);
|
||||
assert.notEqual(moved, first);
|
||||
@@ -163,7 +215,309 @@ test('the ring follows the subject past 250 m and ignores smaller drift', () =>
|
||||
))
|
||||
assert.ok(Math.abs(distance - AWARENESS_RADIUS_M) < 100);
|
||||
assert.equal(renders(), rendersAfterAdd + 1);
|
||||
// Each pulse is a circle around the NEW centre: one radius all the way round.
|
||||
for (const pulse of entities.values.slice(1)) {
|
||||
const radii = surfaceDistances(
|
||||
Cesium.Cartesian3.fromDegrees(3.01, 27, 0),
|
||||
pulse.polyline.positions.getValue(now),
|
||||
);
|
||||
assert.ok(Math.max(...radii) - Math.min(...radii) < 100);
|
||||
}
|
||||
|
||||
rendering.clearVisual();
|
||||
assert.equal(entities.values.length, 0);
|
||||
});
|
||||
|
||||
test('a pulse grows slowly from the subject and arrives dim at the ring', () => {
|
||||
const {
|
||||
periodMs,
|
||||
count,
|
||||
startFraction,
|
||||
fadeInFraction,
|
||||
peakOpacity,
|
||||
endOpacityFraction,
|
||||
} = AWARENESS_RANGE_PULSE;
|
||||
const at = (phase) => awarenessPulseState(phase * periodMs, 0);
|
||||
// Starts small and invisible, so nothing pops in at the centre.
|
||||
assert.equal(at(0).radiusM, AWARENESS_RADIUS_M * startFraction);
|
||||
assert.equal(at(0).alpha, 0);
|
||||
// Dim: never brighter than `peakOpacity`, which sits well under the ring.
|
||||
let brightest = 0;
|
||||
for (let phase = 0; phase < 1; phase += 0.005) {
|
||||
const { alpha } = at(phase);
|
||||
assert.ok(alpha >= 0 && alpha <= peakOpacity, `alpha ${alpha} at ${phase}`);
|
||||
brightest = Math.max(brightest, alpha);
|
||||
}
|
||||
assert.ok(Math.abs(brightest - at(fadeInFraction).alpha) < 0.01);
|
||||
assert.ok(peakOpacity <= 0.6);
|
||||
// Fading as it travels, but still visible when it reaches the ring, where
|
||||
// the ring takes over with its flash.
|
||||
assert.ok(at(0.5).alpha < at(0.25).alpha);
|
||||
assert.ok(at(0.9).alpha < at(0.5).alpha);
|
||||
assert.ok(
|
||||
Math.abs(at(0.999).alpha - peakOpacity * endOpacityFraction) < 0.01,
|
||||
);
|
||||
assert.ok(at(0.999).alpha >= 0.15, 'the arriving pulse is still visible');
|
||||
assert.ok(Math.abs(at(0.999).radiusM - AWARENESS_RADIUS_M) < 300);
|
||||
// Constant speed: equal time steps add equal radius.
|
||||
const step = at(0.4).radiusM - at(0.3).radiusM;
|
||||
assert.ok(Math.abs(at(0.7).radiusM - at(0.6).radiusM - step) < 1e-6);
|
||||
// Calm: a new pulse at most every 2.5 s, crossing 250 km in 8 s or more.
|
||||
assert.ok(periodMs / count >= 2500);
|
||||
assert.ok(periodMs >= 8000);
|
||||
// Pulses are evenly staggered across one period, and the cycle repeats.
|
||||
const near = (a, b) =>
|
||||
Math.abs(a.radiusM - b.radiusM) < 1e-6 &&
|
||||
Math.abs(a.alpha - b.alpha) < 1e-9;
|
||||
for (let index = 1; index < count; index++)
|
||||
assert.ok(
|
||||
near(
|
||||
awarenessPulseState(0, index),
|
||||
awarenessPulseState((index / count) * periodMs, 0),
|
||||
),
|
||||
);
|
||||
assert.ok(
|
||||
near(
|
||||
awarenessPulseState(periodMs * 7 + 123, 1),
|
||||
awarenessPulseState(123, 1),
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
test('pulses ask for a frame every 80 ms instead of holding continuous rendering', (t) => {
|
||||
t.mock.timers.enable({ apis: ['setInterval'] });
|
||||
const { entities, rendering, pulseFrames } = ringRendering(t);
|
||||
rendering.renderVisual(flight(3));
|
||||
// A parked Contacts view repaints at the Cyber sonar cadence, ~12.5 fps,
|
||||
// never every display frame.
|
||||
assert.equal(pulseFrames(), 0);
|
||||
t.mock.timers.tick(AWARENESS_RANGE_PULSE.frameIntervalMs * 10);
|
||||
assert.equal(pulseFrames(), 10);
|
||||
const now = Cesium.JulianDate.now();
|
||||
const core = Cesium.Color.fromCssColorString(
|
||||
AWARENESS_RANGE_RING_STYLE.color,
|
||||
);
|
||||
for (const pulse of entities.values.slice(1)) {
|
||||
assert.equal(pulse.polyline.positions.isConstant, false);
|
||||
assert.equal(
|
||||
pulse.polyline.width.getValue(now),
|
||||
AWARENESS_RANGE_PULSE.width,
|
||||
);
|
||||
const color = pulse.polyline.material.color.getValue(now);
|
||||
assert.ok(color.alpha >= 0 && color.alpha <= core.alpha);
|
||||
assert.equal(color.red, core.red);
|
||||
const radii = surfaceDistances(
|
||||
Cesium.Cartesian3.fromDegrees(3, 27, 0),
|
||||
pulse.polyline.positions.getValue(now),
|
||||
);
|
||||
assert.ok(
|
||||
Math.min(...radii) >=
|
||||
AWARENESS_RADIUS_M * AWARENESS_RANGE_PULSE.startFraction - 100,
|
||||
);
|
||||
assert.ok(Math.max(...radii) <= AWARENESS_RADIUS_M + 100);
|
||||
}
|
||||
rendering.clearVisual();
|
||||
assert.equal(entities.values.length, 0);
|
||||
t.mock.timers.tick(AWARENESS_RANGE_PULSE.frameIntervalMs * 10);
|
||||
assert.equal(pulseFrames(), 10, 'no frames once the ring is gone');
|
||||
});
|
||||
|
||||
test('a scene without primitives or preRender gets the static ring only', (t) => {
|
||||
const { state, entities, rendering, pulseFrames } = ringRendering(t);
|
||||
for (const scene of [{}, { primitives: { add() {} } }, undefined]) {
|
||||
state.viewer = { entities, scene };
|
||||
rendering.clearVisual();
|
||||
rendering.renderVisual(flight(3));
|
||||
assert.equal(entities.values.length, 1);
|
||||
assert.equal(entities.values[0].polyline.positions.isConstant, true);
|
||||
}
|
||||
rendering.clearVisual();
|
||||
assert.equal(pulseFrames(), 0);
|
||||
});
|
||||
|
||||
test('a gradient disc behind the pulses fades inward and follows the subject', (t) => {
|
||||
const { scene, rendering } = ringRendering(t);
|
||||
rendering.renderVisual(flight(3));
|
||||
assert.ok(AWARENESS_RANGE_PULSE.count <= AWARENESS_PULSE_GRADIENT_MAX);
|
||||
const { disc, count } = pulsePrimitives(scene);
|
||||
assert.equal(count, 1, 'the gradient disc');
|
||||
assert.ok(disc);
|
||||
// Never a click or tap target, and see-through.
|
||||
assert.equal(disc.allowPicking, false);
|
||||
assert.equal(disc.appearance.translucent, true);
|
||||
const { material } = disc.appearance;
|
||||
assert.ok(
|
||||
Math.abs(
|
||||
material.uniforms.depth - AWARENESS_RANGE_PULSE.gradientDepthFraction,
|
||||
) < 1e-9,
|
||||
);
|
||||
assert.ok(
|
||||
Math.abs(
|
||||
material.uniforms.color.alpha - AWARENESS_RANGE_PULSE.gradientOpacity,
|
||||
) < 1e-6,
|
||||
);
|
||||
// Each frame writes the pulses' radius (as a fraction of the range) and
|
||||
// opacity into the shader, from the same curve the lines use.
|
||||
assert.equal(scene.preRender.numberOfListeners, 1);
|
||||
const before = performance.now();
|
||||
scene.preRender.raiseEvent(scene);
|
||||
const after = performance.now();
|
||||
const { radii, alphas } = material.uniforms;
|
||||
for (let index = 0; index < AWARENESS_RANGE_PULSE.count; index++) {
|
||||
const radius = Cesium.Cartesian4.pack(radii, [])[index];
|
||||
const alpha = Cesium.Cartesian4.pack(alphas, [])[index];
|
||||
const low = awarenessPulseState(before, index);
|
||||
const high = awarenessPulseState(after, index);
|
||||
// A few milliseconds pass during the raise; allow for that drift.
|
||||
assert.ok(
|
||||
Math.abs(radius - low.radiusM / AWARENESS_RADIUS_M) < 0.01 ||
|
||||
Math.abs(radius - high.radiusM / AWARENESS_RADIUS_M) < 0.01,
|
||||
);
|
||||
assert.ok(alpha >= 0 && alpha <= AWARENESS_RANGE_PULSE.peakOpacity);
|
||||
assert.ok(
|
||||
Math.abs(alpha - low.alpha) < 0.02 || Math.abs(alpha - high.alpha) < 0.02,
|
||||
);
|
||||
}
|
||||
// Unused shader slots stay empty.
|
||||
assert.equal(
|
||||
Cesium.Cartesian4.pack(alphas, [])[AWARENESS_PULSE_GRADIENT_MAX - 1],
|
||||
0,
|
||||
);
|
||||
|
||||
// The disc is rebuilt only past `gradientRebuildM` (2 km), not on every
|
||||
// 250 m step the lines take, and keeps its material.
|
||||
rendering.renderVisual(flight(3.001));
|
||||
assert.equal(pulsePrimitives(scene).disc, disc);
|
||||
rendering.renderVisual(flight(3.01)); // ~1 km: the lines move, the disc stays
|
||||
assert.equal(pulsePrimitives(scene).disc, disc);
|
||||
assert.ok(!disc.isDestroyed());
|
||||
rendering.renderVisual(flight(3.03)); // ~3 km from the disc's centre
|
||||
assert.equal(pulsePrimitives(scene).count, 1);
|
||||
const moved = pulsePrimitives(scene).disc;
|
||||
assert.notEqual(moved, disc);
|
||||
assert.ok(disc.isDestroyed());
|
||||
assert.equal(moved.appearance.material, material);
|
||||
assert.equal(moved.allowPicking, false);
|
||||
|
||||
rendering.clearVisual();
|
||||
assert.equal(scene.primitives.length, 0);
|
||||
assert.equal(scene.preRender.numberOfListeners, 0);
|
||||
});
|
||||
|
||||
test('pulses evaluate every frame without breaking, reusing their positions', (t) => {
|
||||
const { entities, rendering } = ringRendering(t);
|
||||
rendering.renderVisual(flight(3));
|
||||
const center = Cesium.Cartesian3.fromDegrees(3, 27, 0);
|
||||
for (const pulse of entities.values.slice(1)) {
|
||||
const property = pulse.polyline.positions;
|
||||
const first = property.getValue(Cesium.JulianDate.now());
|
||||
// Cesium calls this on every rendered frame; the second call is where a
|
||||
// reused Cartesian once landed in the ellipsoid argument and threw.
|
||||
for (let frame = 0; frame < 5; frame++) {
|
||||
const positions = property.getValue(Cesium.JulianDate.now());
|
||||
assert.equal(positions, first, 'the same array is reused');
|
||||
assert.equal(positions.length, AWARENESS_RANGE_PULSE.segments + 1);
|
||||
const radii = surfaceDistances(center, positions);
|
||||
assert.ok(radii.every(Number.isFinite));
|
||||
assert.ok(Math.max(...radii) - Math.min(...radii) < 100);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test('reduced motion keeps the static ring and drops the pulses', (t) => {
|
||||
t.mock.timers.enable({ apis: ['setInterval'] });
|
||||
const { entities, scene, rendering, pulseFrames } = ringRendering(t, {
|
||||
reducedMotion: true,
|
||||
});
|
||||
rendering.renderVisual(flight(3));
|
||||
assert.equal(entities.values.length, 1);
|
||||
assert.equal(entities.values[0].polyline.positions.isConstant, true);
|
||||
t.mock.timers.tick(1000);
|
||||
assert.equal(pulseFrames(), 0);
|
||||
assert.equal(scene.primitives.length, 0);
|
||||
assert.equal(scene.preRender.numberOfListeners, 0);
|
||||
});
|
||||
|
||||
test('while pulses run the ring flashes as each one arrives, then rests', (t) => {
|
||||
const { entities, rendering } = ringRendering(t);
|
||||
rendering.renderVisual(flight(3));
|
||||
const ring = entities.values[0];
|
||||
const core = Cesium.Color.fromCssColorString(
|
||||
AWARENESS_RANGE_RING_STYLE.color,
|
||||
);
|
||||
assert.equal(ring.polyline.material.color.isConstant, false);
|
||||
const { periodMs, count } = AWARENESS_RANGE_PULSE;
|
||||
const { flashMs, restOpacity } = AWARENESS_RANGE_RING_STYLE;
|
||||
// Full the moment a front reaches 250 km, gone `flashMs` later.
|
||||
const arrival = periodMs * 6; // pulse 0 wraps here
|
||||
assert.ok(Math.abs(awarenessRingFlash(arrival) - 1) < 1e-6);
|
||||
assert.ok(awarenessRingFlash(arrival + flashMs / 2) > 0.2);
|
||||
assert.equal(awarenessRingFlash(arrival + flashMs + 1), 0);
|
||||
assert.equal(restOpacity, 0);
|
||||
// Once per arriving pulse: `count` flashes in any one-period window that
|
||||
// starts after a flash has faded.
|
||||
let flashes = 0;
|
||||
let wasLit = false;
|
||||
const from = arrival + flashMs + 1;
|
||||
for (let ms = 0; ms < periodMs; ms += 10) {
|
||||
const lit = awarenessRingFlash(from + ms) > 0.5;
|
||||
if (lit && !wasLit) flashes++;
|
||||
wasLit = lit;
|
||||
}
|
||||
assert.equal(flashes, count);
|
||||
// The live colour tracks the flash at this instant.
|
||||
const before = awarenessRingFlash(performance.now());
|
||||
const alpha = ring.polyline.material.color.getValue(
|
||||
Cesium.JulianDate.now(),
|
||||
).alpha;
|
||||
const after = awarenessRingFlash(performance.now());
|
||||
assert.ok(
|
||||
alpha >= core.alpha * Math.min(before, after) - 0.02 &&
|
||||
alpha <= core.alpha * Math.max(before, after) + 0.02,
|
||||
);
|
||||
});
|
||||
|
||||
test('without pulses the ring stays on screen at full strength', (t) => {
|
||||
const { entities, rendering } = ringRendering(t, { reducedMotion: true });
|
||||
rendering.renderVisual(flight(3));
|
||||
const color = entities.values[0].polyline.material.color;
|
||||
assert.equal(color.isConstant, true);
|
||||
assert.equal(
|
||||
color.getValue(Cesium.JulianDate.now()).toCssColorString(),
|
||||
Cesium.Color.fromCssColorString(
|
||||
AWARENESS_RANGE_RING_STYLE.color,
|
||||
).toCssColorString(),
|
||||
);
|
||||
assert.equal(getAwarenessEchoField(), null);
|
||||
});
|
||||
|
||||
test('the echo field follows the subject while pulses run and clears with the ring', (t) => {
|
||||
const { rendering } = ringRendering(t);
|
||||
rendering.renderVisual(flight(3));
|
||||
const field = getAwarenessEchoField();
|
||||
assert.ok(field);
|
||||
assert.equal(field.radiusM, AWARENESS_RADIUS_M);
|
||||
assert.equal(field.pulse, AWARENESS_RANGE_PULSE);
|
||||
assert.equal(field.echo, AWARENESS_RANGE_ECHO);
|
||||
assert.ok(
|
||||
Cesium.Cartesian3.equalsEpsilon(
|
||||
field.center,
|
||||
Cesium.Cartesian3.fromDegrees(3, 27, 0),
|
||||
0,
|
||||
1e-3,
|
||||
),
|
||||
);
|
||||
// ~1 km later the same field has moved with the subject.
|
||||
rendering.renderVisual(flight(3.01));
|
||||
assert.equal(getAwarenessEchoField(), field);
|
||||
assert.ok(
|
||||
Cesium.Cartesian3.equalsEpsilon(
|
||||
field.center,
|
||||
Cesium.Cartesian3.fromDegrees(3.01, 27, 0),
|
||||
0,
|
||||
1e-3,
|
||||
),
|
||||
);
|
||||
rendering.clearVisual();
|
||||
assert.equal(getAwarenessEchoField(), null);
|
||||
});
|
||||
@@ -12,10 +12,17 @@ import {
|
||||
import { bearingBetweenCoordinates } from '../../cockpitMath.js';
|
||||
import {
|
||||
AWARENESS_PAGE_SIZE,
|
||||
AWARENESS_RANGE_ECHO,
|
||||
AWARENESS_RANGE_PULSE,
|
||||
AWARENESS_RANGE_RING_STYLE,
|
||||
CONTEXT_RIM_HEIGHT_M,
|
||||
} from './policy.js';
|
||||
import { formatAwarenessDirection } from './presentation.js';
|
||||
import {
|
||||
awarenessEchoStrength,
|
||||
clearAwarenessEchoField,
|
||||
setAwarenessEchoField,
|
||||
} from '../../awarenessEcho.js';
|
||||
|
||||
/**
|
||||
* A closed ring of positions `radiusM` along the surface from `groundCenter`,
|
||||
@@ -26,6 +33,7 @@ import { formatAwarenessDirection } from './presentation.js';
|
||||
* @param {number} radiusM Surface radius in metres.
|
||||
* @param {number} heightM Height of the ring above the ellipsoid.
|
||||
* @param {number} [segments] Chords around the ring.
|
||||
* @param {Cesium.Cartesian3[]} [result] Array whose Cartesians are reused.
|
||||
* @returns {Cesium.Cartesian3[]} `segments + 1` positions; the last repeats the first.
|
||||
*/
|
||||
export function awarenessRangeRingPositions(
|
||||
@@ -33,6 +41,7 @@ export function awarenessRangeRingPositions(
|
||||
radiusM,
|
||||
heightM,
|
||||
segments = AWARENESS_RANGE_RING_STYLE.segments,
|
||||
result = [],
|
||||
) {
|
||||
const { latitude, longitude } =
|
||||
Cesium.Cartographic.fromCartesian(groundCenter);
|
||||
@@ -43,7 +52,8 @@ export function awarenessRangeRingPositions(
|
||||
const w = 1 - e2 * sinLat * sinLat;
|
||||
const meridional = (a * (1 - e2)) / (w * Math.sqrt(w));
|
||||
const primeVertical = a / Math.sqrt(w);
|
||||
const positions = [];
|
||||
const positions = result;
|
||||
positions.length = segments + 1;
|
||||
for (let i = 0; i <= segments; i++) {
|
||||
const bearing = ((i % segments) / segments) * Cesium.Math.TWO_PI;
|
||||
const cosB = Math.cos(bearing);
|
||||
@@ -59,11 +69,136 @@ export function awarenessRangeRingPositions(
|
||||
sinB * Math.sin(angular) * cosLat,
|
||||
Math.cos(angular) - sinLat * Math.sin(lat),
|
||||
);
|
||||
positions.push(Cesium.Cartesian3.fromRadians(lon, lat, heightM));
|
||||
positions[i] = Cesium.Cartesian3.fromRadians(
|
||||
lon,
|
||||
lat,
|
||||
heightM,
|
||||
Cesium.Ellipsoid.WGS84,
|
||||
positions[i],
|
||||
);
|
||||
}
|
||||
return positions;
|
||||
}
|
||||
|
||||
/**
|
||||
* Where pulse `index` is at `nowMs`: its radius and opacity. Pulses travel at
|
||||
* constant speed from `startFraction` of the range to the full range, fade in
|
||||
* over `fadeInFraction` of the way, then fade as (1 - travel) to the
|
||||
* `fadeOutPower` down to `endOpacityFraction` of the peak, so each is dim
|
||||
* but still visible when it reaches the range ring. The opacity already
|
||||
* includes `peakOpacity`.
|
||||
* @param {number} nowMs Monotonic clock in milliseconds.
|
||||
* @param {number} index Pulse number, 0 to `count - 1`.
|
||||
* @param {number} [radiusM] Range the pulses grow to.
|
||||
* @param {object} [pulse] Timing and shape, see `AWARENESS_RANGE_PULSE`.
|
||||
* @returns {{radiusM: number, alpha: number}}
|
||||
*/
|
||||
export function awarenessPulseState(
|
||||
nowMs,
|
||||
index,
|
||||
radiusM = AWARENESS_RADIUS_M,
|
||||
pulse = AWARENESS_RANGE_PULSE,
|
||||
) {
|
||||
const cycle = nowMs / pulse.periodMs + index / pulse.count;
|
||||
const phase = cycle - Math.floor(cycle);
|
||||
return {
|
||||
radiusM:
|
||||
radiusM * (pulse.startFraction + (1 - pulse.startFraction) * phase),
|
||||
alpha:
|
||||
pulse.peakOpacity *
|
||||
Math.min(1, phase / pulse.fadeInFraction) *
|
||||
(pulse.endOpacityFraction +
|
||||
(1 - pulse.endOpacityFraction) * (1 - phase) ** pulse.fadeOutPower),
|
||||
};
|
||||
}
|
||||
|
||||
/** Whether the viewer asked the system for less motion. */
|
||||
function prefersReducedMotion() {
|
||||
return (
|
||||
globalThis.matchMedia?.('(prefers-reduced-motion: reduce)')?.matches ??
|
||||
false
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* How strongly the 250 km ring shows at `nowMs` while pulses run: 1 as a
|
||||
* pulse front reaches it, fading over `flashMs`, so the ring appears once
|
||||
* per arriving pulse instead of standing on screen.
|
||||
* @param {number} nowMs Monotonic clock in milliseconds.
|
||||
* @returns {number} 0 to 1.
|
||||
*/
|
||||
export function awarenessRingFlash(nowMs) {
|
||||
return awarenessEchoStrength(
|
||||
nowMs,
|
||||
AWARENESS_RADIUS_M,
|
||||
AWARENESS_RADIUS_M,
|
||||
AWARENESS_RANGE_PULSE,
|
||||
{ fadeMs: AWARENESS_RANGE_RING_STYLE.flashMs },
|
||||
);
|
||||
}
|
||||
|
||||
/** Most pulses the gradient shader draws; its uniforms are vec4s. */
|
||||
export const AWARENESS_PULSE_GRADIENT_MAX = 4;
|
||||
|
||||
// Ellipse texture coordinates put the centre at (0.5, 0.5) and the rim at
|
||||
// distance 0.5, so `r` is the fraction of the range. Behind each pulse line
|
||||
// the band is strongest at the line and fades to nothing `depth` inward.
|
||||
const PULSE_GRADIENT_SOURCE = `
|
||||
uniform vec4 color;
|
||||
uniform vec4 radii;
|
||||
uniform vec4 alphas;
|
||||
uniform float depth;
|
||||
czm_material czm_getMaterial(czm_materialInput materialInput) {
|
||||
czm_material material = czm_getDefaultMaterial(materialInput);
|
||||
float r = length(materialInput.st - vec2(0.5)) * 2.0;
|
||||
float alpha = 0.0;
|
||||
for (int i = 0; i < 4; i++) {
|
||||
float inside = radii[i] - r;
|
||||
if (inside >= 0.0 && inside <= depth) {
|
||||
float t = 1.0 - inside / depth;
|
||||
alpha += alphas[i] * t * t;
|
||||
}
|
||||
}
|
||||
material.diffuse = color.rgb;
|
||||
material.alpha = clamp(alpha * color.a, 0.0, 1.0);
|
||||
return material;
|
||||
}
|
||||
`;
|
||||
|
||||
/**
|
||||
* The pulse gradient: one flat, translucent, unpickable disc the size of the
|
||||
* range ring whose shader draws a band behind each pulse. Moving pulses only
|
||||
* change its uniforms; the disc is rebuilt only when the subject moves.
|
||||
* @param {Cesium.Cartesian3} groundCenter Disc centre on the ellipsoid.
|
||||
* @param {Cesium.Material} material Shared pulse-gradient material.
|
||||
* @returns {Cesium.Primitive}
|
||||
*/
|
||||
function createPulseGradient(groundCenter, material) {
|
||||
return new Cesium.Primitive({
|
||||
geometryInstances: new Cesium.GeometryInstance({
|
||||
geometry: new Cesium.EllipseGeometry({
|
||||
center: groundCenter,
|
||||
semiMajorAxis: AWARENESS_RADIUS_M,
|
||||
semiMinorAxis: AWARENESS_RADIUS_M,
|
||||
height: CONTEXT_RIM_HEIGHT_M,
|
||||
granularity: Cesium.Math.toRadians(
|
||||
AWARENESS_RANGE_PULSE.gradientGranularityDeg,
|
||||
),
|
||||
vertexFormat:
|
||||
Cesium.MaterialAppearance.MaterialSupport.TEXTURED.vertexFormat,
|
||||
}),
|
||||
}),
|
||||
appearance: new Cesium.MaterialAppearance({
|
||||
material,
|
||||
flat: true,
|
||||
translucent: true,
|
||||
faceForward: true,
|
||||
}),
|
||||
asynchronous: false,
|
||||
allowPicking: false,
|
||||
});
|
||||
}
|
||||
|
||||
export function createRendering({
|
||||
state: layerState,
|
||||
services,
|
||||
@@ -323,6 +458,11 @@ export function createRendering({
|
||||
}
|
||||
|
||||
function clearVisual() {
|
||||
globalThis.clearInterval(layerState.visual?.pulseTimer);
|
||||
layerState.visual?.removePulseListener?.();
|
||||
if (layerState.visual?.gradient && layerState.viewer)
|
||||
layerState.viewer.scene.primitives.remove(layerState.visual.gradient);
|
||||
clearAwarenessEchoField();
|
||||
if (layerState.visual?.entities && layerState.viewer) {
|
||||
for (const entity of layerState.visual.entities)
|
||||
layerState.viewer.entities.remove(entity);
|
||||
@@ -354,9 +494,26 @@ export function createRendering({
|
||||
AWARENESS_RADIUS_M,
|
||||
CONTEXT_RIM_HEIGHT_M,
|
||||
);
|
||||
for (const entity of layerState.visual.entities)
|
||||
entity.polyline.positions.setValue(positions);
|
||||
// Pulse lines read `visual.center` every frame and follow on their
|
||||
// own. The gradient disc is geometry, so it is rebuilt, but only
|
||||
// every `gradientRebuildM`: a build costs a few milliseconds.
|
||||
layerState.visual.ring.polyline.positions.setValue(positions);
|
||||
Cesium.Cartesian3.clone(groundCenter, layerState.visual.center);
|
||||
const { gradient, gradientCenter } = layerState.visual;
|
||||
if (
|
||||
gradient &&
|
||||
Cesium.Cartesian3.distance(gradientCenter, groundCenter) >=
|
||||
AWARENESS_RANGE_PULSE.gradientRebuildM
|
||||
) {
|
||||
const primitives = layerState.viewer.scene.primitives;
|
||||
layerState.visual.gradient = primitives.add(
|
||||
createPulseGradient(groundCenter, gradient.appearance.material),
|
||||
);
|
||||
// Primitive.destroy leaves the material alone, so the new disc
|
||||
// keeps sharing it.
|
||||
primitives.remove(gradient);
|
||||
Cesium.Cartesian3.clone(groundCenter, gradientCenter);
|
||||
}
|
||||
// Content actually changed — buy exactly one frame instead of holding.
|
||||
governorRequestRender('awareness-visual');
|
||||
}
|
||||
@@ -365,35 +522,143 @@ export function createRendering({
|
||||
clearVisual();
|
||||
|
||||
const entities = [];
|
||||
const scene = layerState.viewer.scene;
|
||||
// A viewer without a primitive collection or a preRender hook (headless
|
||||
// tools, partial stubs) gets the static ring only, as the Cyber sonar
|
||||
// scene does. So does a viewer who prefers reduced motion.
|
||||
const animate = Boolean(
|
||||
!prefersReducedMotion() &&
|
||||
scene?.primitives?.add &&
|
||||
scene?.preRender?.addEventListener,
|
||||
);
|
||||
|
||||
// A polyline, not an ellipse outline: WebGL draws ellipse outlines at
|
||||
// 1 px whatever `outlineWidth` says, and the ring needs a halo.
|
||||
const ring = AWARENESS_RANGE_RING_STYLE;
|
||||
entities.push(
|
||||
layerState.viewer.entities.add({
|
||||
polyline: {
|
||||
positions: new Cesium.ConstantProperty(
|
||||
awarenessRangeRingPositions(
|
||||
groundCenter,
|
||||
AWARENESS_RADIUS_M,
|
||||
CONTEXT_RIM_HEIGHT_M,
|
||||
),
|
||||
const core = Cesium.Color.fromCssColorString(ring.color);
|
||||
const halo = Cesium.Color.fromCssColorString(ring.outlineColor);
|
||||
// While pulses run, the ring flashes as each one arrives and otherwise
|
||||
// rests at `restOpacity`; without pulses it stays on screen.
|
||||
const flashing = (base) => {
|
||||
const color = base.clone();
|
||||
return new Cesium.CallbackProperty(() => {
|
||||
const flash = awarenessRingFlash(performance.now());
|
||||
color.alpha = base.alpha * Math.max(ring.restOpacity, flash);
|
||||
return color;
|
||||
}, false);
|
||||
};
|
||||
const ringEntity = layerState.viewer.entities.add({
|
||||
polyline: {
|
||||
positions: new Cesium.ConstantProperty(
|
||||
awarenessRangeRingPositions(
|
||||
groundCenter,
|
||||
AWARENESS_RADIUS_M,
|
||||
CONTEXT_RIM_HEIGHT_M,
|
||||
),
|
||||
width: ring.width,
|
||||
arcType: Cesium.ArcType.NONE,
|
||||
material: new Cesium.PolylineOutlineMaterialProperty({
|
||||
color: Cesium.Color.fromCssColorString(ring.color),
|
||||
outlineColor: Cesium.Color.fromCssColorString(ring.outlineColor),
|
||||
outlineWidth: ring.outlineWidth,
|
||||
),
|
||||
width: ring.width,
|
||||
arcType: Cesium.ArcType.NONE,
|
||||
material: new Cesium.PolylineOutlineMaterialProperty({
|
||||
color: animate ? flashing(core) : core,
|
||||
outlineColor: animate ? flashing(halo) : halo,
|
||||
outlineWidth: ring.outlineWidth,
|
||||
}),
|
||||
},
|
||||
});
|
||||
entities.push(ringEntity);
|
||||
|
||||
const center = Cesium.Cartesian3.clone(groundCenter);
|
||||
let gradient = null;
|
||||
let removePulseListener = null;
|
||||
let pulseTimer = null;
|
||||
if (animate) {
|
||||
const pulse = AWARENESS_RANGE_PULSE;
|
||||
for (let index = 0; index < pulse.count; index++) {
|
||||
const positions = [];
|
||||
const coreColor = core.clone();
|
||||
const haloColor = halo.clone();
|
||||
const stateAt = () => awarenessPulseState(performance.now(), index);
|
||||
entities.push(
|
||||
layerState.viewer.entities.add({
|
||||
polyline: {
|
||||
positions: new Cesium.CallbackProperty(
|
||||
() =>
|
||||
awarenessRangeRingPositions(
|
||||
center,
|
||||
stateAt().radiusM,
|
||||
CONTEXT_RIM_HEIGHT_M,
|
||||
pulse.segments,
|
||||
positions,
|
||||
),
|
||||
false,
|
||||
),
|
||||
width: pulse.width,
|
||||
arcType: Cesium.ArcType.NONE,
|
||||
material: new Cesium.PolylineOutlineMaterialProperty({
|
||||
color: new Cesium.CallbackProperty(() => {
|
||||
coreColor.alpha = core.alpha * stateAt().alpha;
|
||||
return coreColor;
|
||||
}, false),
|
||||
outlineColor: new Cesium.CallbackProperty(() => {
|
||||
haloColor.alpha = halo.alpha * stateAt().alpha;
|
||||
return haloColor;
|
||||
}, false),
|
||||
outlineWidth: pulse.outlineWidth,
|
||||
}),
|
||||
},
|
||||
}),
|
||||
},
|
||||
}),
|
||||
);
|
||||
);
|
||||
}
|
||||
const count = Math.min(pulse.count, AWARENESS_PULSE_GRADIENT_MAX);
|
||||
const uniforms = {
|
||||
color: core.clone(),
|
||||
radii: new Cesium.Cartesian4(),
|
||||
alphas: new Cesium.Cartesian4(),
|
||||
depth: pulse.gradientDepthFraction,
|
||||
};
|
||||
uniforms.color.alpha = pulse.gradientOpacity;
|
||||
const material = new Cesium.Material({
|
||||
fabric: { uniforms, source: PULSE_GRADIENT_SOURCE },
|
||||
translucent: true,
|
||||
});
|
||||
gradient = scene.primitives.add(
|
||||
createPulseGradient(groundCenter, material),
|
||||
);
|
||||
const radii = [0, 0, 0, 0];
|
||||
const alphas = [0, 0, 0, 0];
|
||||
removePulseListener = scene.preRender.addEventListener(() => {
|
||||
const nowMs = performance.now();
|
||||
for (let index = 0; index < count; index++) {
|
||||
const state = awarenessPulseState(nowMs, index);
|
||||
radii[index] = state.radiusM / AWARENESS_RADIUS_M;
|
||||
alphas[index] = state.alpha;
|
||||
}
|
||||
Cesium.Cartesian4.fromArray(radii, 0, material.uniforms.radii);
|
||||
Cesium.Cartesian4.fromArray(alphas, 0, material.uniforms.alphas);
|
||||
});
|
||||
// The detection overlay pulses the brackets of flights and vessels a
|
||||
// front crosses; it reads the centre in place, so it follows too.
|
||||
setAwarenessEchoField({
|
||||
center,
|
||||
radiusM: AWARENESS_RADIUS_M,
|
||||
pulse,
|
||||
echo: AWARENESS_RANGE_ECHO,
|
||||
});
|
||||
pulseTimer = globalThis.setInterval(
|
||||
() => governorRequestRender('awareness-pulse'),
|
||||
pulse.frameIntervalMs,
|
||||
);
|
||||
}
|
||||
|
||||
layerState.visual = {
|
||||
key,
|
||||
entities,
|
||||
center: Cesium.Cartesian3.clone(groundCenter),
|
||||
ring: ringEntity,
|
||||
center,
|
||||
gradient,
|
||||
gradientCenter: Cesium.Cartesian3.clone(groundCenter),
|
||||
removePulseListener,
|
||||
pulseTimer,
|
||||
};
|
||||
governorRequestRender('awareness-visual');
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user