mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-04 02:14:01 +00:00
fix(awareness): keep range pulses out of picking and in the style palette
Review follow-up for the sonar pulses (the non-blocking items). Unpickable pulses: the three pulse lines were polyline entities, which Cesium always picks, so near-miss and touch picks around them became unowned hits. They are now drawn by the pulse disc's shader, which already drew the gradient and is built with allowPicking false. Each line keeps the polyline outline material's look: 3 CSS px wide with a 1 px dark outline in the ring's colours, measured from the screen-space gradient of the disc's radius times czm_pixelRatio, as Cesium's contour material does. Without derivatives only the gradient band is drawn. Disc follow: with the lines on the disc, it is rebuilt after the subject moves 500 m instead of 2 km, so the pulses stay concentric with the ring. A resting disc is hidden and not rebuilt; it catches up when the pulses wake. Camera below the disc: the disc sits 2.5 km above the ellipsoid with faceForward, so from a lower camera it swept across the sky. It is now hidden while the camera is below that height. Echo palette: echo brackets keep the pulse blue on the default detection style and take the style's line colour under NVG, FLIR and CRT. Echo test: the detection renderer's echo maths moves into awarenessEchoBracket and awarenessEchoColor in src/awarenessEcho.js, tested directly instead of running a regex slice of detection.js through vm, and the one-sided spread assert is now two-sided. The positions reuse parameter of awarenessRangeRingPositions and the pulse segment count had no callers left and are removed.
This commit is contained in:
1 parent
0c025987ef
commit
013cfdf91d
7 files changed
+303
-234
No files matched your search
@@ -4957,7 +4957,7 @@ silently demoting every later lookup for the session.
|
||||
- Enabled Data Layer controls report normalized feed health on the button (`LOADING`, `DEGRADED`, `STALE`, `FALLBACK`, or `UNAVAILABLE`) while the metadata line retains the source and reason. A partial CelesTrak group failure keeps the usable catalog and reports `DEGRADED`; a total outage keeps last-good catalog data visible but reports `UNAVAILABLE`.
|
||||
- On activation it focuses the nearest currently observed aircraft across the civilian and military feeds, with military winning an exact distance tie; if none are available, it focuses an observed AIS vessel. The aircraft search is deliberately uncapped and one refresh-tick retry handles initially empty feeds. This is an attention-priority navigation shortcut, not a high-risk, affiliation, or threat classification.
|
||||
- A selected aircraft, AIS vessel, or mapped installation gets a 250 km **context window** with nearby cohort counts, nearest examples, source labels, and stale/unavailable reasons. It emits `NEARBY` or `UNKNOWN`; no detection, engagement, affiliation, or sensor-activity conclusion is calculated.
|
||||
- The window's edge is drawn on the globe as a 250 km range ring: a 4 px polyline with a 2 px Context-blue core inside a 1 px dark halo on each side, 2.5 km above the ellipsoid (`AWARENESS_RANGE_RING_STYLE` in `src/layers/awareness/policy.js`). The halo keeps it legible over bright desert and snow, dark ocean and forest, and under the FLIR, NVG and CRT styles, where the earlier 1 px ellipse outline disappeared. It is a polyline because WebGL draws ellipse outlines at 1 px whatever width is asked for. Three dim sonar pulses (3 px, same colours, at most 60% opacity) grow slowly from the subject at constant speed, one every 3 s and each taking 9 s, and arrive at the ring at about a third of that. Behind each pulse a radial gradient in the same blue fades inward over 35% of the radius, drawn by one unpickable 250 km disc whose shader reads the pulse radii and opacities each frame; the disc itself is rebuilt only after the subject moves 2 km. While pulses run, the ring dims to 40% and flashes to full strength as each pulse reaches it, fading over 1.8 s. As a pulse front crosses a flight, military aircraft or AIS vessel, the detection overlay draws a second bracket in the pulse blue around that contact's own bracket, which spreads outward as it fades over 1.4 s (`AWARENESS_RANGE_ECHO`, read through `src/awarenessEcho.js`; no layer's icon is restyled, and mapped installations have no detection bracket). Pulses run for 9 s (three ring flashes) after a selection or a camera move, fading in and out over 1.2 s, then rest: the ring stays on screen at full strength, the pulses, gradient and echoes are hidden, and nothing asks for frames, so a parked Contacts view is quiet again. A moving subject does not wake them, since a flying subject moves on every refresh; a camera that follows a tracked aircraft does keep them running, but that follow already renders every frame. While they run they never hold continuous rendering: each drawn frame arms one render request 80 ms later (the Cyber sonar cadence, `CYBER_SONAR_RENDER_INTERVAL_MS`), so they repaint at about 12.5 fps and never beat the detection lane's 80 ms gate. Under `prefers-reduced-motion`, or in a scene without primitives or preRender and postRender hooks, there are no pulses or echoes and the ring stays on screen; changing the preference mid-session rebuilds the ring. The ring follows a moving subject once it has moved 250 m.
|
||||
- The window's edge is drawn on the globe as a 250 km range ring: a 4 px polyline with a 2 px Context-blue core inside a 1 px dark halo on each side, 2.5 km above the ellipsoid (`AWARENESS_RANGE_RING_STYLE` in `src/layers/awareness/policy.js`). The halo keeps it legible over bright desert and snow, dark ocean and forest, and under the FLIR, NVG and CRT styles, where the earlier 1 px ellipse outline disappeared. It is a polyline because WebGL draws ellipse outlines at 1 px whatever width is asked for. Three dim sonar pulses (3 px with a 1 px dark outline, same colours, at most 60% opacity) grow slowly from the subject at constant speed, one every 3 s and each taking 9 s, and arrive at the ring at about a third of that. Behind each pulse a radial gradient in the same blue fades inward over 35% of the radius. One unpickable 250 km disc draws both the pulse lines and the gradient, its shader reading the pulse radii and opacities each frame, so a click or tap near a pulse still reaches the contact underneath; the disc is rebuilt only after the subject moves 500 m, and not at all while the pulses rest, and it is hidden while the camera is below its 2.5 km height, where it would sweep across the sky. While pulses run, the ring dims to 40% and flashes to full strength as each pulse reaches it, fading over 1.8 s. As a pulse front crosses a flight, military aircraft or AIS vessel, the detection overlay draws a second bracket around that contact's own bracket, in the pulse blue on the default detection style and in the style's own line colour under NVG, FLIR and CRT, which spreads outward as it fades over 1.4 s (`AWARENESS_RANGE_ECHO`, read through `src/awarenessEcho.js`; no layer's icon is restyled, and mapped installations have no detection bracket). Pulses run for 9 s (three ring flashes) after a selection or a camera move, fading in and out over 1.2 s, then rest: the ring stays on screen at full strength, the pulses, gradient and echoes are hidden, and nothing asks for frames, so a parked Contacts view is quiet again. A moving subject does not wake them, since a flying subject moves on every refresh; a camera that follows a tracked aircraft does keep them running, but that follow already renders every frame. While they run they never hold continuous rendering: each drawn frame arms one render request 80 ms later (the Cyber sonar cadence, `CYBER_SONAR_RENDER_INTERVAL_MS`), so they repaint at about 12.5 fps and never beat the detection lane's 80 ms gate. Under `prefers-reduced-motion`, or in a scene without primitives or preRender and postRender hooks, there are no pulses or echoes and the ring stays on screen; changing the preference mid-session rebuilds the ring. The ring follows a moving subject once it has moved 250 m.
|
||||
- For a selected live aircraft or vessel, the context window refreshes from the existing tracker/feed position every 750 ms, so nearest distances and cohort counts follow the subject without introducing a duplicate poll loop.
|
||||
- Aircraft cohort membership uses every locally loaded, selectable contact inside the 250 km window, including a plane hidden only by horizon culling or because its 3D model owns the visual. Counts and navigation therefore do not change with the current camera angle or billboard/model handoff.
|
||||
- Nearby examples in the context panel are focus controls: they use their owning layer's existing selection/tracking path, then frame that contact. Static-installation distances use ellipsoidal surface distance so the count matches the ground-projected context disk.
|
||||
|
||||
@@ -109,3 +109,44 @@ export function awarenessEchoAt(layerId, position, nowMs) {
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The echo bracket the detection overlay draws around one contact, or null
|
||||
* when it has no echo. `band` is the alpha band, 1 to `steps`, scaled by the
|
||||
* contact's own keyhole `admissionAlpha`; `spreadPx` is how far the echo
|
||||
* sits outside the contact's bracket, 0 as the front arrives, growing to the
|
||||
* field's `spreadPx` as it fades.
|
||||
* @param {string} layerId Owning layer of the contact.
|
||||
* @param {Cesium.Cartesian3} position Contact's world position.
|
||||
* @param {number} nowMs Monotonic clock in milliseconds.
|
||||
* @param {number} admissionAlpha The contact bracket's own fade, 0 to 1.
|
||||
* @param {number} steps Alpha bands the overlay batches by.
|
||||
* @returns {{band: number, spreadPx: number}|null}
|
||||
*/
|
||||
export function awarenessEchoBracket(
|
||||
layerId,
|
||||
position,
|
||||
nowMs,
|
||||
admissionAlpha,
|
||||
steps,
|
||||
) {
|
||||
const echo = awarenessEchoAt(layerId, position, nowMs);
|
||||
if (!(echo > 0.01)) return null;
|
||||
return {
|
||||
band: Math.max(1, Math.ceil(echo * admissionAlpha * steps)),
|
||||
spreadPx: field.echo.spreadPx * (1 - echo),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* The echo's stroke colour: the field's own colour on the default detection
|
||||
* style, and the style's line colour under the others (NVG, FLIR, CRT), so
|
||||
* an echo never stands out of a monochrome palette.
|
||||
* @param {string} styleLine The active detection style's line colour.
|
||||
* @param {boolean} defaultStyle Whether the default detection style is active.
|
||||
* @returns {string|null} Null when no field is set.
|
||||
*/
|
||||
export function awarenessEchoColor(styleLine, defaultStyle) {
|
||||
if (!field) return null;
|
||||
return defaultStyle ? field.echo.color : styleLine;
|
||||
}
|
||||
+21
-14
@@ -53,7 +53,11 @@ import {
|
||||
isCyberContactSonarActive as isCyberSonarActive,
|
||||
isCyberContactThemeActive as isCyberMapThemeActive,
|
||||
} from '../cyberSonar.js';
|
||||
import { awarenessEchoAt, getAwarenessEchoField } from '../awarenessEcho.js';
|
||||
import {
|
||||
awarenessEchoBracket,
|
||||
awarenessEchoColor,
|
||||
getAwarenessEchoField,
|
||||
} from '../awarenessEcho.js';
|
||||
|
||||
/**
|
||||
* @module detection
|
||||
@@ -1445,22 +1449,22 @@ function _drawOverlay(frame) {
|
||||
);
|
||||
visibleCount++;
|
||||
if (echoPaths) {
|
||||
const echo = awarenessEchoAt(obj._layerId, obj.position, now);
|
||||
if (echo > 0.01) {
|
||||
// Starts on the contact's bracket and spreads outward as it fades.
|
||||
const band = Math.max(
|
||||
1,
|
||||
Math.ceil(echo * admissionAlpha * ECHO_ALPHA_STEPS),
|
||||
);
|
||||
const spread = echoField.echo.spreadPx * (1 - echo);
|
||||
// Starts on the contact's bracket and spreads outward as it fades.
|
||||
const echo = awarenessEchoBracket(
|
||||
obj._layerId,
|
||||
obj.position,
|
||||
now,
|
||||
admissionAlpha,
|
||||
ECHO_ALPHA_STEPS,
|
||||
);
|
||||
if (echo)
|
||||
appendCornerBracket(
|
||||
(echoPaths[band] ??= new Path2D()),
|
||||
(echoPaths[echo.band] ??= new Path2D()),
|
||||
sx,
|
||||
sy,
|
||||
halfW + spread,
|
||||
halfH + spread,
|
||||
halfW + echo.spreadPx,
|
||||
halfH + echo.spreadPx,
|
||||
);
|
||||
}
|
||||
}
|
||||
if (obj.type === 'AIR')
|
||||
aircraftBracketSectors[detectionHorizontalSector(sx, width)]++;
|
||||
@@ -1603,7 +1607,10 @@ function _drawOverlay(frame) {
|
||||
}
|
||||
if (echoPaths) {
|
||||
_ctx.lineWidth = echoField.echo.lineWidth;
|
||||
_ctx.strokeStyle = echoField.echo.color;
|
||||
_ctx.strokeStyle = awarenessEchoColor(
|
||||
_theme.line,
|
||||
_theme === THEME_MAP._default,
|
||||
);
|
||||
for (let band = 1; band <= ECHO_ALPHA_STEPS; band++) {
|
||||
if (!echoPaths[band]) continue;
|
||||
_ctx.globalAlpha =
|
||||
|
||||
@@ -1,104 +1,96 @@
|
||||
// src/data/detectionAwarenessEcho.test.mjs — Global Context sonar echoes pulse
|
||||
// a contact's own detection bracket: a second bracket that starts on it and
|
||||
// spreads outward as it fades. Exercises the renderer's actual echo block.
|
||||
// spreads outward as it fades, in the detection style's own colour.
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import vm from 'node:vm';
|
||||
import * as Cesium from 'cesium';
|
||||
import {
|
||||
awarenessEchoAt,
|
||||
awarenessEchoBracket,
|
||||
awarenessEchoColor,
|
||||
clearAwarenessEchoField,
|
||||
setAwarenessEchoField,
|
||||
} from '../awarenessEcho.js';
|
||||
import { DETECTION_THEME_MAP } from '../overlays/worldOverlayTokens.js';
|
||||
|
||||
const source = readFileSync(new URL('./detection.js', import.meta.url), 'utf8');
|
||||
const echoBlock = source.match(
|
||||
/if \(echoPaths\) \{\s*const echo = awarenessEchoAt[\s\S]*?\n {6}\}\n/,
|
||||
)?.[0];
|
||||
|
||||
const STEPS = 8;
|
||||
const pulse = { count: 3, periodMs: 9000, startFraction: 0.02 };
|
||||
const echo = {
|
||||
fadeMs: 1400,
|
||||
layerIds: ['flights', 'military', 'ais-live-vessels'],
|
||||
spreadPx: 8,
|
||||
color: 'rgb(98, 181, 255)',
|
||||
};
|
||||
const center = Cesium.Cartesian3.fromDegrees(3, 27, 0);
|
||||
const field = { center, radiusM: 250_000, pulse, echo };
|
||||
|
||||
/** Run the renderer's echo block for one contact and record the bracket. */
|
||||
function paintEcho(field, obj, now, { admissionAlpha = 1 } = {}) {
|
||||
const brackets = [];
|
||||
const state = {
|
||||
echoPaths: field ? new Array(9) : null,
|
||||
echoField: field,
|
||||
ECHO_ALPHA_STEPS: 8,
|
||||
awarenessEchoAt,
|
||||
Path2D: class {},
|
||||
appendCornerBracket: (sink, sx, sy, halfW, halfH) =>
|
||||
brackets.push({ sink, sx, sy, halfW, halfH }),
|
||||
obj,
|
||||
now,
|
||||
admissionAlpha,
|
||||
sx: 100,
|
||||
sy: 50,
|
||||
halfW: 9,
|
||||
halfH: 7,
|
||||
};
|
||||
vm.runInNewContext(echoBlock, state);
|
||||
const band = state.echoPaths?.findIndex((path) => path) ?? -1;
|
||||
return { brackets, band };
|
||||
/** The moment pulse 0's front reaches the ground under `position`. */
|
||||
function arrivalAt(position) {
|
||||
const ground = Cesium.Ellipsoid.WGS84.scaleToGeodeticSurface(position);
|
||||
const d = Cesium.Cartesian3.distance(center, ground);
|
||||
return (
|
||||
((d / 250_000 - pulse.startFraction) / (1 - pulse.startFraction)) *
|
||||
pulse.periodMs +
|
||||
pulse.periodMs * 3
|
||||
);
|
||||
}
|
||||
|
||||
test('the echo bracket pulses outward from the contact bracket and fades', (t) => {
|
||||
assert.ok(echoBlock, 'renderer has an echo block');
|
||||
t.after(clearAwarenessEchoField);
|
||||
const center = Cesium.Cartesian3.fromDegrees(3, 27, 0);
|
||||
const field = { center, radiusM: 250_000, pulse, echo };
|
||||
setAwarenessEchoField(field);
|
||||
const position = Cesium.Cartesian3.fromDegrees(3.5, 27, 10_000);
|
||||
const ground = Cesium.Cartesian3.fromDegrees(3.5, 27, 0);
|
||||
const d = Cesium.Cartesian3.distance(center, ground);
|
||||
const arrival =
|
||||
((d / 250_000 - pulse.startFraction) / (1 - pulse.startFraction)) *
|
||||
pulse.periodMs +
|
||||
pulse.periodMs * 3;
|
||||
const obj = { _layerId: 'flights', position };
|
||||
const arrival = arrivalAt(position);
|
||||
const at = (nowMs, admissionAlpha = 1) =>
|
||||
awarenessEchoBracket('flights', position, nowMs, admissionAlpha, STEPS);
|
||||
|
||||
// The moment the front arrives: on the bracket, strongest band.
|
||||
const fresh = paintEcho(field, obj, arrival);
|
||||
assert.equal(fresh.brackets.length, 1);
|
||||
assert.ok(fresh.brackets[0].halfW - 9 < 0.01);
|
||||
assert.equal(fresh.band, 8);
|
||||
// Half way through the fade: dimmer and spread outward.
|
||||
const half = paintEcho(field, obj, arrival + echo.fadeMs / 2);
|
||||
assert.ok(half.brackets[0].halfW > 9 + echo.spreadPx * 0.5);
|
||||
assert.ok(half.brackets[0].halfH > 7 + echo.spreadPx * 0.5);
|
||||
assert.equal(half.brackets[0].sx, 100);
|
||||
assert.ok(half.band < 8 && half.band >= 1);
|
||||
const fresh = at(arrival);
|
||||
assert.ok(fresh);
|
||||
assert.ok(Math.abs(fresh.spreadPx) < 0.01, `${fresh.spreadPx}`);
|
||||
assert.equal(fresh.band, STEPS);
|
||||
// Half way through the fade: dimmer and spread outward, but not past
|
||||
// the field's spread.
|
||||
const half = at(arrival + echo.fadeMs / 2);
|
||||
assert.ok(half.spreadPx > echo.spreadPx * 0.5);
|
||||
assert.ok(half.spreadPx < echo.spreadPx);
|
||||
assert.ok(half.band < STEPS && half.band >= 1);
|
||||
// Keyhole fade carries over to the echo.
|
||||
assert.ok(paintEcho(field, obj, arrival, { admissionAlpha: 0.25 }).band <= 2);
|
||||
assert.ok(at(arrival, 0.25).band <= 2);
|
||||
// Faded out, or between fronts: no echo bracket.
|
||||
assert.equal(
|
||||
paintEcho(field, obj, arrival + echo.fadeMs + 5).brackets.length,
|
||||
0,
|
||||
);
|
||||
assert.equal(at(arrival + echo.fadeMs + 5), null);
|
||||
});
|
||||
|
||||
test('no echo field, or a layer that does not echo, draws nothing extra', (t) => {
|
||||
assert.ok(echoBlock);
|
||||
t.after(clearAwarenessEchoField);
|
||||
const center = Cesium.Cartesian3.fromDegrees(3, 27, 0);
|
||||
const position = Cesium.Cartesian3.fromDegrees(3.01, 27, 0);
|
||||
// No field: the renderer skips the echo block entirely.
|
||||
assert.equal(
|
||||
paintEcho(null, { _layerId: 'flights', position }, 0).brackets.length,
|
||||
0,
|
||||
);
|
||||
const field = { center, radiusM: 250_000, pulse, echo };
|
||||
clearAwarenessEchoField();
|
||||
for (let now = 0; now < pulse.periodMs; now += 25)
|
||||
assert.equal(
|
||||
awarenessEchoBracket('flights', position, now, 1, STEPS),
|
||||
null,
|
||||
);
|
||||
setAwarenessEchoField(field);
|
||||
for (let now = 0; now < pulse.periodMs; now += 25)
|
||||
for (const layerId of ['cctv', 'satellites', 'traffic'])
|
||||
assert.equal(
|
||||
paintEcho(field, { _layerId: layerId, position }, now).brackets.length,
|
||||
0,
|
||||
awarenessEchoBracket(layerId, position, now, 1, STEPS),
|
||||
null,
|
||||
);
|
||||
});
|
||||
|
||||
test('echoes take the detection style colour except on the default style', (t) => {
|
||||
t.after(clearAwarenessEchoField);
|
||||
assert.equal(
|
||||
awarenessEchoColor(DETECTION_THEME_MAP._default.line, true),
|
||||
null,
|
||||
);
|
||||
setAwarenessEchoField(field);
|
||||
assert.equal(
|
||||
awarenessEchoColor(DETECTION_THEME_MAP._default.line, true),
|
||||
echo.color,
|
||||
);
|
||||
// CRT, NVG and FLIR keep their monochrome palettes.
|
||||
for (const style of ['retro', 'surveillance', 'thermal']) {
|
||||
const { line } = DETECTION_THEME_MAP[style];
|
||||
assert.equal(awarenessEchoColor(line, false), line, style);
|
||||
}
|
||||
});
|
||||
@@ -72,11 +72,17 @@ export const AWARENESS_RANGE_RING_STYLE = Object.freeze({
|
||||
* `periodMs / count`. Each starts at `startFraction` of the radius, fades in
|
||||
* over `fadeInFraction` of its travel, then fades as (1 - travel) to the
|
||||
* `fadeOutPower` down to `endOpacityFraction` of `peakOpacity`, so a pulse
|
||||
* is dim but still visible when it reaches the ring and the ring flashes. Behind each line a radial gradient in the same blue fades
|
||||
* inward over `gradientDepthFraction` of the radius, at most
|
||||
* `gradientOpacity` strong. The gradient disc is rebuilt only after the
|
||||
* subject moves `gradientRebuildM`, since a soft band 2 km off centre on a
|
||||
* 250 km disc does not show. The gradient shader takes at most four pulses.
|
||||
* is dim but still visible when it reaches the ring and the ring flashes.
|
||||
* Each line is `width` CSS pixels wide, its outer `outlineWidth` a dark
|
||||
* halo, in the ring's colours, as Cesium's polyline outline material draws. Behind each line a radial gradient in the same blue
|
||||
* fades inward over `gradientDepthFraction` of the radius, at most
|
||||
* `gradientOpacity` strong. One unpickable disc draws the lines and the
|
||||
* gradient, so pulses never catch a click meant for a contact; its shader
|
||||
* takes at most four pulses. The disc is rebuilt only after the subject
|
||||
* moves `gradientRebuildM`, so the pulses sit at most that far off the ring's
|
||||
* centre, about a pixel when the ring fills the screen; while the pulses
|
||||
* rest the hidden disc is left alone and catches up when they wake. The disc is hidden
|
||||
* while the camera is below its height, where it would sweep the sky.
|
||||
* The pulses run for `activeMs` (three ring flashes) after a selection or
|
||||
* a camera move, fading in and out over `settleMs`, then rest: the ring
|
||||
* stays on screen, static, and nothing asks for frames, so a parked view
|
||||
@@ -98,11 +104,10 @@ export const AWARENESS_RANGE_PULSE = Object.freeze({
|
||||
peakOpacity: 0.6,
|
||||
width: 3,
|
||||
outlineWidth: 1,
|
||||
segments: 96,
|
||||
gradientDepthFraction: 0.35,
|
||||
gradientOpacity: 0.22,
|
||||
gradientGranularityDeg: 0.25,
|
||||
gradientRebuildM: 2000,
|
||||
gradientRebuildM: 500,
|
||||
activeMs: 9000,
|
||||
settleMs: 1200,
|
||||
});
|
||||
|
||||
@@ -2,9 +2,11 @@
|
||||
// ring is a haloed polyline that stays legible on light and dark ground,
|
||||
// sits where the old ellipse outline did, and follows the subject.
|
||||
// Dim sonar pulses grow slowly from the subject to the ring, each with an
|
||||
// inward-fading gradient behind it; the ring flashes as each one arrives and
|
||||
// the overlay's echo field follows the subject. Under reduced motion the ring
|
||||
// simply stays on screen.
|
||||
// inward-fading gradient behind it, drawn on one unpickable disc; the ring
|
||||
// dims and flashes as each one arrives and the overlay's echo field follows
|
||||
// the subject. The pulses run for a bounded time after a selection or camera
|
||||
// move, then rest with the ring steady. Under reduced motion the ring simply
|
||||
// stays on screen.
|
||||
import '../../testSupport/registerDom.mjs';
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
@@ -221,8 +223,7 @@ test('the ring core and halo contrast on light and dark ground', () => {
|
||||
test('renderVisual draws one haloed polyline ring, not an ellipse outline', (t) => {
|
||||
const { entities, rendering } = ringRendering(t);
|
||||
rendering.renderVisual(flight(3));
|
||||
assert.equal(entities.values.length, 1 + AWARENESS_RANGE_PULSE.count);
|
||||
assert.ok(entities.values.every((entity) => entity.ellipse === undefined));
|
||||
assert.equal(entities.values.length, 1);
|
||||
const [entity] = entities.values;
|
||||
assert.equal(entity.ellipse, undefined);
|
||||
const now = Cesium.JulianDate.now();
|
||||
@@ -251,12 +252,13 @@ test('renderVisual draws one haloed polyline ring, not an ellipse outline', (t)
|
||||
});
|
||||
|
||||
test('the ring and pulses follow the subject past 250 m and ignore smaller drift', (t) => {
|
||||
const { entities, rendering, renders } = ringRendering(t);
|
||||
const { entities, scene, rendering, renders } = ringRendering(t);
|
||||
const subject = flight;
|
||||
rendering.renderVisual(subject(3));
|
||||
const [entity] = entities.values;
|
||||
const now = Cesium.JulianDate.now();
|
||||
const first = entity.polyline.positions.getValue(now);
|
||||
const discBefore = pulsePrimitives(scene).disc;
|
||||
const rendersAfterAdd = renders();
|
||||
|
||||
// ~100 m east: under the threshold, nothing is rebuilt or rendered.
|
||||
@@ -266,7 +268,7 @@ test('the ring and pulses follow the subject past 250 m and ignore smaller drift
|
||||
|
||||
// ~1 km east: the same entity moves to the new centre.
|
||||
rendering.renderVisual(subject(3.01));
|
||||
assert.equal(entities.values.length, 1 + AWARENESS_RANGE_PULSE.count);
|
||||
assert.equal(entities.values.length, 1);
|
||||
assert.equal(entities.values[0], entity);
|
||||
const moved = entity.polyline.positions.getValue(now);
|
||||
assert.notEqual(moved, first);
|
||||
@@ -276,14 +278,9 @@ test('the ring and pulses follow the subject past 250 m and ignore smaller drift
|
||||
))
|
||||
assert.ok(Math.abs(distance - AWARENESS_RADIUS_M) < 100);
|
||||
assert.equal(renders(), rendersAfterAdd + 1);
|
||||
// Each pulse is a circle around the NEW centre: one radius all the way round.
|
||||
for (const pulse of entities.values.slice(1)) {
|
||||
const radii = surfaceDistances(
|
||||
Cesium.Cartesian3.fromDegrees(3.01, 27, 0),
|
||||
pulse.polyline.positions.getValue(now),
|
||||
);
|
||||
assert.ok(Math.max(...radii) - Math.min(...radii) < 100);
|
||||
}
|
||||
// The pulses are drawn on a disc, rebuilt around the NEW centre a
|
||||
// kilometre on, so they stay concentric with the ring.
|
||||
assert.notEqual(pulsePrimitives(scene).disc, discBefore);
|
||||
|
||||
rendering.clearVisual();
|
||||
assert.equal(entities.values.length, 0);
|
||||
@@ -356,29 +353,6 @@ test('pulses ask for a frame 80 ms after each drawn frame instead of holding con
|
||||
loop.advance(1); // the frame the new ring asked for arms the first request
|
||||
loop.advance(CYBER_SONAR_RENDER_INTERVAL_MS * 10);
|
||||
assert.ok(Math.abs(pulseFrames() - 10) <= 1, `${pulseFrames()} frames`);
|
||||
const now = Cesium.JulianDate.now();
|
||||
const core = Cesium.Color.fromCssColorString(
|
||||
AWARENESS_RANGE_RING_STYLE.color,
|
||||
);
|
||||
for (const pulse of entities.values.slice(1)) {
|
||||
assert.equal(pulse.polyline.positions.isConstant, false);
|
||||
assert.equal(
|
||||
pulse.polyline.width.getValue(now),
|
||||
AWARENESS_RANGE_PULSE.width,
|
||||
);
|
||||
const color = pulse.polyline.material.color.getValue(now);
|
||||
assert.ok(color.alpha >= 0 && color.alpha <= core.alpha);
|
||||
assert.equal(color.red, core.red);
|
||||
const radii = surfaceDistances(
|
||||
Cesium.Cartesian3.fromDegrees(3, 27, 0),
|
||||
pulse.polyline.positions.getValue(now),
|
||||
);
|
||||
assert.ok(
|
||||
Math.min(...radii) >=
|
||||
AWARENESS_RADIUS_M * AWARENESS_RANGE_PULSE.startFraction - 100,
|
||||
);
|
||||
assert.ok(Math.max(...radii) <= AWARENESS_RADIUS_M + 100);
|
||||
}
|
||||
const frames = pulseFrames();
|
||||
rendering.clearVisual();
|
||||
assert.equal(entities.values.length, 0);
|
||||
@@ -407,14 +381,13 @@ test('a parked view stops asking for frames once the pulses rest', (t) => {
|
||||
const core = Cesium.Color.fromCssColorString(
|
||||
AWARENESS_RANGE_RING_STYLE.color,
|
||||
);
|
||||
const [ringEntity, ...pulses] = entities.values;
|
||||
const [ringEntity] = entities.values;
|
||||
assert.ok(
|
||||
Math.abs(
|
||||
ringEntity.polyline.material.color.getValue(now).alpha - core.alpha,
|
||||
) < 1e-9,
|
||||
);
|
||||
for (const pulse of pulses)
|
||||
assert.equal(pulse.polyline.show.getValue(now), false);
|
||||
scene.preRender.raiseEvent(scene);
|
||||
assert.equal(pulsePrimitives(scene).disc.show, false);
|
||||
assert.equal(getAwarenessEchoField().level(loop.now()), 0);
|
||||
});
|
||||
@@ -573,6 +546,17 @@ test('a gradient disc behind the pulses fades inward and follows the subject', (
|
||||
material.uniforms.color.alpha - AWARENESS_RANGE_PULSE.gradientOpacity,
|
||||
) < 1e-6,
|
||||
);
|
||||
// The pulse lines are drawn in the ring's colours and widths.
|
||||
const core = Cesium.Color.fromCssColorString(
|
||||
AWARENESS_RANGE_RING_STYLE.color,
|
||||
);
|
||||
const halo = Cesium.Color.fromCssColorString(
|
||||
AWARENESS_RANGE_RING_STYLE.outlineColor,
|
||||
);
|
||||
assert.ok(material.uniforms.lineColor.equalsEpsilon(core, 1e-6));
|
||||
assert.ok(material.uniforms.haloColor.equalsEpsilon(halo, 1e-6));
|
||||
assert.equal(material.uniforms.lineWidth, AWARENESS_RANGE_PULSE.width);
|
||||
assert.equal(material.uniforms.haloWidth, AWARENESS_RANGE_PULSE.outlineWidth);
|
||||
// Each frame writes the pulses' radius (as a fraction of the range) and
|
||||
// opacity into the shader, from the same curve the lines use, once the
|
||||
// pulses have faded in.
|
||||
@@ -595,14 +579,14 @@ test('a gradient disc behind the pulses fades inward and follows the subject', (
|
||||
0,
|
||||
);
|
||||
|
||||
// The disc is rebuilt only past `gradientRebuildM` (2 km), not on every
|
||||
// 250 m step the lines take, and keeps its material.
|
||||
// The disc is rebuilt only past `gradientRebuildM`, not on every 250 m
|
||||
// step the ring takes, and keeps its material.
|
||||
rendering.renderVisual(flight(3.001));
|
||||
assert.equal(pulsePrimitives(scene).disc, disc);
|
||||
rendering.renderVisual(flight(3.01)); // ~1 km: the lines move, the disc stays
|
||||
rendering.renderVisual(flight(3.004)); // ~400 m: the ring moves, the disc stays
|
||||
assert.equal(pulsePrimitives(scene).disc, disc);
|
||||
assert.ok(!disc.isDestroyed());
|
||||
rendering.renderVisual(flight(3.03)); // ~3 km from the disc's centre
|
||||
rendering.renderVisual(flight(3.01)); // ~1 km from the disc's centre
|
||||
assert.equal(pulsePrimitives(scene).count, 1);
|
||||
const moved = pulsePrimitives(scene).disc;
|
||||
assert.notEqual(moved, disc);
|
||||
@@ -615,24 +599,55 @@ test('a gradient disc behind the pulses fades inward and follows the subject', (
|
||||
assert.equal(scene.preRender.numberOfListeners, 0);
|
||||
});
|
||||
|
||||
test('pulses evaluate every frame without breaking, reusing their positions', (t) => {
|
||||
const { entities, rendering } = ringRendering(t);
|
||||
test('a resting pulse disc is not rebuilt as the subject moves, and catches up on wake', (t) => {
|
||||
const ring = ringRendering(t);
|
||||
const { scene, rendering } = ring;
|
||||
const loop = frameLoop(t, ring);
|
||||
rendering.renderVisual(flight(3));
|
||||
const center = Cesium.Cartesian3.fromDegrees(3, 27, 0);
|
||||
for (const pulse of entities.values.slice(1)) {
|
||||
const property = pulse.polyline.positions;
|
||||
const first = property.getValue(Cesium.JulianDate.now());
|
||||
// Cesium calls this on every rendered frame; the second call is where a
|
||||
// reused Cartesian once landed in the ellipsoid argument and threw.
|
||||
for (let frame = 0; frame < 5; frame++) {
|
||||
const positions = property.getValue(Cesium.JulianDate.now());
|
||||
assert.equal(positions, first, 'the same array is reused');
|
||||
assert.equal(positions.length, AWARENESS_RANGE_PULSE.segments + 1);
|
||||
const radii = surfaceDistances(center, positions);
|
||||
assert.ok(radii.every(Number.isFinite));
|
||||
assert.ok(Math.max(...radii) - Math.min(...radii) < 100);
|
||||
}
|
||||
}
|
||||
loop.advance(AWARENESS_RANGE_PULSE.activeMs + 500);
|
||||
const resting = pulsePrimitives(scene).disc;
|
||||
// A flying subject keeps moving while the pulses rest: no rebuilds.
|
||||
for (let lon = 3.01; lon < 3.1; lon += 0.01)
|
||||
rendering.renderVisual(flight(lon));
|
||||
assert.equal(pulsePrimitives(scene).disc, resting);
|
||||
assert.ok(!resting.isDestroyed());
|
||||
// Woken, the disc moves to the subject at once.
|
||||
rendering.wakePulses();
|
||||
const woken = pulsePrimitives(scene).disc;
|
||||
assert.notEqual(woken, resting);
|
||||
assert.ok(resting.isDestroyed());
|
||||
assert.equal(pulsePrimitives(scene).count, 1);
|
||||
// Woken again without moving: the disc stays.
|
||||
rendering.wakePulses();
|
||||
assert.equal(pulsePrimitives(scene).disc, woken);
|
||||
});
|
||||
|
||||
test('pulses never catch a click: only the ring is an entity, and the disc is unpickable', (t) => {
|
||||
const { entities, scene, rendering } = ringRendering(t);
|
||||
rendering.renderVisual(flight(3));
|
||||
// The ring is #1518's entity; the pulses add no entity of their own.
|
||||
assert.equal(entities.values.length, 1);
|
||||
assert.equal(entities.values[0].polyline.positions.isConstant, true);
|
||||
for (let i = 0; i < scene.primitives.length; i++)
|
||||
assert.equal(scene.primitives.get(i).allowPicking, false);
|
||||
});
|
||||
|
||||
test('a camera below the rim height hides the pulse disc instead of sweeping the sky', (t) => {
|
||||
const ring = ringRendering(t);
|
||||
const { scene, rendering } = ring;
|
||||
const loop = frameLoop(t, ring);
|
||||
rendering.renderVisual(flight(3));
|
||||
loop.advance(AWARENESS_RANGE_PULSE.settleMs + 500);
|
||||
const cameraAt = (height) => {
|
||||
scene.camera = {
|
||||
positionCartographic: new Cesium.Cartographic(0, 0, height),
|
||||
};
|
||||
scene.preRender.raiseEvent(scene);
|
||||
return pulsePrimitives(scene).disc.show;
|
||||
};
|
||||
assert.equal(cameraAt(CONTEXT_RIM_HEIGHT_M + 500), true);
|
||||
assert.equal(cameraAt(CONTEXT_RIM_HEIGHT_M - 500), false, 'cockpit view');
|
||||
assert.equal(cameraAt(800_000), true);
|
||||
});
|
||||
|
||||
test('reduced motion keeps the static ring and drops the pulses', (t) => {
|
||||
@@ -656,7 +671,7 @@ test('turning reduced motion on mid-session stops the pulses, and off restarts t
|
||||
const loop = frameLoop(t, ring);
|
||||
state.subject = flight(3);
|
||||
rendering.renderVisual(state.subject);
|
||||
assert.equal(entities.values.length, 1 + AWARENESS_RANGE_PULSE.count);
|
||||
assert.equal(scene.primitives.length, 1);
|
||||
loop.advance(1000);
|
||||
// The OS setting changes while the same aircraft stays tracked.
|
||||
motion.set(true);
|
||||
@@ -670,7 +685,7 @@ test('turning reduced motion on mid-session stops the pulses, and off restarts t
|
||||
loop.advance(5000);
|
||||
assert.equal(ring.pulseFrames(), frames);
|
||||
motion.set(false);
|
||||
assert.equal(entities.values.length, 1 + AWARENESS_RANGE_PULSE.count);
|
||||
assert.equal(scene.primitives.length, 1);
|
||||
assert.ok(getAwarenessEchoField());
|
||||
// One media-query listener per visual; none once the ring is gone.
|
||||
assert.equal(motion.listeners(), 1);
|
||||
@@ -681,12 +696,12 @@ test('turning reduced motion on mid-session stops the pulses, and off restarts t
|
||||
test('a refresh after reduced motion changed rebuilds the visual too', (t) => {
|
||||
const { entities, rendering, motion } = ringRendering(t);
|
||||
rendering.renderVisual(flight(3));
|
||||
assert.equal(entities.values.length, 1 + AWARENESS_RANGE_PULSE.count);
|
||||
assert.equal(entities.values[0].polyline.material.color.isConstant, false);
|
||||
// No change event: the next same-subject refresh notices on its own.
|
||||
motion.setQuietly(true);
|
||||
rendering.renderVisual(flight(3));
|
||||
assert.equal(entities.values.length, 1);
|
||||
assert.equal(entities.values[0].polyline.positions.isConstant, true);
|
||||
assert.equal(entities.values[0].polyline.material.color.isConstant, true);
|
||||
});
|
||||
|
||||
test('while pulses run the ring flashes as each one arrives, then rests', (t) => {
|
||||
|
||||
@@ -34,7 +34,6 @@ import {
|
||||
* @param {number} radiusM Surface radius in metres.
|
||||
* @param {number} heightM Height of the ring above the ellipsoid.
|
||||
* @param {number} [segments] Chords around the ring.
|
||||
* @param {Cesium.Cartesian3[]} [result] Array whose Cartesians are reused.
|
||||
* @returns {Cesium.Cartesian3[]} `segments + 1` positions; the last repeats the first.
|
||||
*/
|
||||
export function awarenessRangeRingPositions(
|
||||
@@ -42,7 +41,6 @@ export function awarenessRangeRingPositions(
|
||||
radiusM,
|
||||
heightM,
|
||||
segments = AWARENESS_RANGE_RING_STYLE.segments,
|
||||
result = [],
|
||||
) {
|
||||
const { latitude, longitude } =
|
||||
Cesium.Cartographic.fromCartesian(groundCenter);
|
||||
@@ -53,8 +51,7 @@ export function awarenessRangeRingPositions(
|
||||
const w = 1 - e2 * sinLat * sinLat;
|
||||
const meridional = (a * (1 - e2)) / (w * Math.sqrt(w));
|
||||
const primeVertical = a / Math.sqrt(w);
|
||||
const positions = result;
|
||||
positions.length = segments + 1;
|
||||
const positions = new Array(segments + 1);
|
||||
for (let i = 0; i <= segments; i++) {
|
||||
const bearing = ((i % segments) / segments) * Cesium.Math.TWO_PI;
|
||||
const cosB = Math.cos(bearing);
|
||||
@@ -75,7 +72,6 @@ export function awarenessRangeRingPositions(
|
||||
lat,
|
||||
heightM,
|
||||
Cesium.Ellipsoid.WGS84,
|
||||
positions[i],
|
||||
);
|
||||
}
|
||||
return positions;
|
||||
@@ -177,37 +173,69 @@ export function awarenessRingFlash(nowMs) {
|
||||
);
|
||||
}
|
||||
|
||||
/** Most pulses the gradient shader draws; its uniforms are vec4s. */
|
||||
/** Most pulses the pulse shader draws; its uniforms are vec4s. */
|
||||
export const AWARENESS_PULSE_GRADIENT_MAX = 4;
|
||||
|
||||
// Ellipse texture coordinates put the centre at (0.5, 0.5) and the rim at
|
||||
// distance 0.5, so `r` is the fraction of the range. Behind each pulse line
|
||||
// the band is strongest at the line and fades to nothing `depth` inward.
|
||||
// distance 0.5, so `r` is the fraction of the range. Each pulse is a line
|
||||
// `lineWidth` CSS pixels wide whose outer `haloWidth` is a dark halo, half
|
||||
// on each side, as Cesium's polyline outline material draws it, with a band
|
||||
// behind it that is strongest at the line and fades to nothing `depth`
|
||||
// inward. Widths come from the screen-space gradient of `r`, as Cesium's
|
||||
// contour material measures its lines; without derivatives the lines are
|
||||
// left out and only the band shows.
|
||||
const PULSE_GRADIENT_SOURCE = `
|
||||
uniform vec4 color;
|
||||
uniform vec4 lineColor;
|
||||
uniform vec4 haloColor;
|
||||
uniform vec4 radii;
|
||||
uniform vec4 alphas;
|
||||
uniform float depth;
|
||||
uniform float lineWidth;
|
||||
uniform float haloWidth;
|
||||
vec4 over(vec4 top, vec4 under) {
|
||||
float a = top.a + under.a * (1.0 - top.a);
|
||||
if (a <= 0.0) return vec4(0.0);
|
||||
return vec4((top.rgb * top.a + under.rgb * under.a * (1.0 - top.a)) / a, a);
|
||||
}
|
||||
czm_material czm_getMaterial(czm_materialInput materialInput) {
|
||||
czm_material material = czm_getDefaultMaterial(materialInput);
|
||||
float r = length(materialInput.st - vec2(0.5)) * 2.0;
|
||||
float alpha = 0.0;
|
||||
float band = 0.0;
|
||||
for (int i = 0; i < 4; i++) {
|
||||
float inside = radii[i] - r;
|
||||
if (inside >= 0.0 && inside <= depth) {
|
||||
float t = 1.0 - inside / depth;
|
||||
alpha += alphas[i] * t * t;
|
||||
band += alphas[i] * t * t;
|
||||
}
|
||||
}
|
||||
material.diffuse = color.rgb;
|
||||
material.alpha = clamp(alpha * color.a, 0.0, 1.0);
|
||||
vec4 result = vec4(color.rgb, clamp(band * color.a, 0.0, 1.0));
|
||||
#if (__VERSION__ == 300 || defined(GL_OES_standard_derivatives))
|
||||
// Change in r per CSS pixel: the gradient's length per device pixel,
|
||||
// times the device pixels in one CSS pixel.
|
||||
float cssPx = max(length(vec2(dFdx(r), dFdy(r))), 1e-7) * czm_pixelRatio;
|
||||
float outerHalf = lineWidth * 0.5;
|
||||
float coreHalf = max(lineWidth - haloWidth, 0.0) * 0.5;
|
||||
for (int i = 0; i < 4; i++) {
|
||||
float d = abs(radii[i] - r) / cssPx;
|
||||
float cover = clamp(outerHalf + 0.5 - d, 0.0, 1.0);
|
||||
if (cover <= 0.0) continue;
|
||||
float core = clamp(coreHalf + 0.5 - d, 0.0, 1.0);
|
||||
vec4 pulse = mix(haloColor, lineColor, core);
|
||||
result = over(vec4(pulse.rgb, pulse.a * cover * alphas[i]), result);
|
||||
}
|
||||
#endif
|
||||
material.diffuse = result.rgb;
|
||||
material.alpha = result.a;
|
||||
return material;
|
||||
}
|
||||
`;
|
||||
|
||||
/**
|
||||
* The pulse gradient: one flat, translucent, unpickable disc the size of the
|
||||
* range ring whose shader draws a band behind each pulse. Moving pulses only
|
||||
* The pulses: one flat, translucent, unpickable disc the size of the range
|
||||
* ring whose shader draws each pulse line and the band behind it. Drawing
|
||||
* them here rather than as entities keeps them out of picking, so clicks and
|
||||
* taps near a pulse still reach the contact underneath. Moving pulses only
|
||||
* change its uniforms; the disc is rebuilt only when the subject moves.
|
||||
* @param {Cesium.Cartesian3} groundCenter Disc centre on the ellipsoid.
|
||||
* @param {Cesium.Material} material Shared pulse-gradient material.
|
||||
@@ -239,6 +267,11 @@ function createPulseGradient(groundCenter, material) {
|
||||
});
|
||||
}
|
||||
|
||||
/** Camera height above the ellipsoid, or NaN when the scene cannot say. */
|
||||
function cameraHeightM(scene) {
|
||||
return scene.camera?.positionCartographic?.height ?? Number.NaN;
|
||||
}
|
||||
|
||||
export function createRendering({
|
||||
state: layerState,
|
||||
services,
|
||||
@@ -504,7 +537,32 @@ export function createRendering({
|
||||
*/
|
||||
function wakePulses() {
|
||||
const wake = layerState.visual?.wake;
|
||||
if (wake) wakeAwarenessPulses(wake, performance.now());
|
||||
if (!wake) return;
|
||||
wakeAwarenessPulses(wake, performance.now());
|
||||
followPulseDisc();
|
||||
}
|
||||
|
||||
/**
|
||||
* Rebuild the pulse disc around the subject once it is `gradientRebuildM`
|
||||
* from the disc's centre. A build costs a few milliseconds, so a subject
|
||||
* that moves less keeps the disc it has.
|
||||
*/
|
||||
function followPulseDisc() {
|
||||
const { gradient, gradientCenter, center } = layerState.visual;
|
||||
if (
|
||||
!gradient ||
|
||||
Cesium.Cartesian3.distance(gradientCenter, center) <
|
||||
AWARENESS_RANGE_PULSE.gradientRebuildM
|
||||
)
|
||||
return;
|
||||
const primitives = layerState.viewer.scene.primitives;
|
||||
layerState.visual.gradient = primitives.add(
|
||||
createPulseGradient(center, gradient.appearance.material),
|
||||
);
|
||||
// Primitive.destroy leaves the material alone, so the new disc keeps
|
||||
// sharing it.
|
||||
primitives.remove(gradient);
|
||||
Cesium.Cartesian3.clone(center, gradientCenter);
|
||||
}
|
||||
|
||||
function clearVisual() {
|
||||
@@ -549,26 +607,14 @@ export function createRendering({
|
||||
AWARENESS_RADIUS_M,
|
||||
CONTEXT_RIM_HEIGHT_M,
|
||||
);
|
||||
// Pulse lines read `visual.center` every frame and follow on their
|
||||
// own. The gradient disc is geometry, so it is rebuilt, but only
|
||||
// every `gradientRebuildM`: a build costs a few milliseconds.
|
||||
// The echo field reads `visual.center` in place and follows on its
|
||||
// own. The pulse disc is geometry, so it is rebuilt, but only every
|
||||
// `gradientRebuildM`: a build costs a few milliseconds.
|
||||
layerState.visual.ring.polyline.positions.setValue(positions);
|
||||
Cesium.Cartesian3.clone(groundCenter, layerState.visual.center);
|
||||
const { gradient, gradientCenter } = layerState.visual;
|
||||
if (
|
||||
gradient &&
|
||||
Cesium.Cartesian3.distance(gradientCenter, groundCenter) >=
|
||||
AWARENESS_RANGE_PULSE.gradientRebuildM
|
||||
) {
|
||||
const primitives = layerState.viewer.scene.primitives;
|
||||
layerState.visual.gradient = primitives.add(
|
||||
createPulseGradient(groundCenter, gradient.appearance.material),
|
||||
);
|
||||
// Primitive.destroy leaves the material alone, so the new disc
|
||||
// keeps sharing it.
|
||||
primitives.remove(gradient);
|
||||
Cesium.Cartesian3.clone(groundCenter, gradientCenter);
|
||||
}
|
||||
// A resting disc is hidden, so it catches up when the pulses wake.
|
||||
if (performance.now() < layerState.visual.wake?.untilMs)
|
||||
followPulseDisc();
|
||||
// Content actually changed — buy exactly one frame instead of holding.
|
||||
governorRequestRender('awareness-visual');
|
||||
}
|
||||
@@ -637,57 +683,16 @@ export function createRendering({
|
||||
let stopPulses = null;
|
||||
if (animate) {
|
||||
const pulse = AWARENESS_RANGE_PULSE;
|
||||
for (let index = 0; index < pulse.count; index++) {
|
||||
const positions = [];
|
||||
const coreColor = core.clone();
|
||||
const haloColor = halo.clone();
|
||||
const stateAt = () => {
|
||||
const nowMs = performance.now();
|
||||
const state = awarenessPulseState(nowMs, index);
|
||||
state.alpha *= levelAt(nowMs);
|
||||
return state;
|
||||
};
|
||||
entities.push(
|
||||
layerState.viewer.entities.add({
|
||||
polyline: {
|
||||
show: new Cesium.CallbackProperty(
|
||||
() => levelAt(performance.now()) > 0,
|
||||
false,
|
||||
),
|
||||
positions: new Cesium.CallbackProperty(
|
||||
() =>
|
||||
awarenessRangeRingPositions(
|
||||
center,
|
||||
stateAt().radiusM,
|
||||
CONTEXT_RIM_HEIGHT_M,
|
||||
pulse.segments,
|
||||
positions,
|
||||
),
|
||||
false,
|
||||
),
|
||||
width: pulse.width,
|
||||
arcType: Cesium.ArcType.NONE,
|
||||
material: new Cesium.PolylineOutlineMaterialProperty({
|
||||
color: new Cesium.CallbackProperty(() => {
|
||||
coreColor.alpha = core.alpha * stateAt().alpha;
|
||||
return coreColor;
|
||||
}, false),
|
||||
outlineColor: new Cesium.CallbackProperty(() => {
|
||||
haloColor.alpha = halo.alpha * stateAt().alpha;
|
||||
return haloColor;
|
||||
}, false),
|
||||
outlineWidth: pulse.outlineWidth,
|
||||
}),
|
||||
},
|
||||
}),
|
||||
);
|
||||
}
|
||||
const count = Math.min(pulse.count, AWARENESS_PULSE_GRADIENT_MAX);
|
||||
const uniforms = {
|
||||
color: core.clone(),
|
||||
lineColor: core.clone(),
|
||||
haloColor: halo.clone(),
|
||||
radii: new Cesium.Cartesian4(),
|
||||
alphas: new Cesium.Cartesian4(),
|
||||
depth: pulse.gradientDepthFraction,
|
||||
lineWidth: pulse.width,
|
||||
haloWidth: pulse.outlineWidth,
|
||||
};
|
||||
uniforms.color.alpha = pulse.gradientOpacity;
|
||||
const material = new Cesium.Material({
|
||||
@@ -702,9 +707,13 @@ export function createRendering({
|
||||
const removePreRender = scene.preRender.addEventListener(() => {
|
||||
const nowMs = performance.now();
|
||||
const level = levelAt(nowMs);
|
||||
// The disc is looked up each frame: a subject move swaps it.
|
||||
// The disc is looked up each frame: a subject move swaps it. From a
|
||||
// camera below the rim height it would sweep across the sky, so it
|
||||
// is hidden there.
|
||||
const disc = layerState.visual?.gradient;
|
||||
if (disc) disc.show = level > 0;
|
||||
if (disc)
|
||||
disc.show =
|
||||
level > 0 && !(cameraHeightM(scene) < CONTEXT_RIM_HEIGHT_M);
|
||||
for (let index = 0; index < count; index++) {
|
||||
const state = awarenessPulseState(nowMs, index);
|
||||
radii[index] = state.radiusM / AWARENESS_RADIUS_M;
|
||||
|
||||
Reference in new issue
Block a user