fix: refresh GNSS sampling automatically as the camera moves

This commit is contained in:
Mazeyar Moeini Feizabadi committed 2026-10-13 21:17:10 +02:00
1 parent c349a89bb2
commit 96534df807
6 files changed
+319 -15

No files matched your search

+109 -8
View File
@@ -23,12 +23,23 @@ const LEVEL_LABELS = Object.freeze({
/** Degrees at the centre of the current view, or null before the camera settles. */
export function defaultGnssViewAnchor(viewer) {
const rectangle = viewer?.camera?.computeViewRectangle?.(
viewer.scene?.globe?.ellipsoid,
);
const carto = rectangle
? Cesium.Rectangle.center(rectangle)
: viewer?.camera?.positionCartographic;
const canvas = viewer?.scene?.canvas;
const ellipsoid = viewer?.scene?.globe?.ellipsoid;
// At globe scale the visible rectangle can be the whole world, whose
// midpoint is unrelated to the place under the screen centre.
const centre =
canvas?.clientWidth > 0 &&
canvas.clientHeight > 0 &&
viewer.camera?.pickEllipsoid?.(
new Cesium.Cartesian2(canvas.clientWidth / 2, canvas.clientHeight / 2),
ellipsoid,
);
const rectangle = viewer?.camera?.computeViewRectangle?.(ellipsoid);
const carto = centre
? (ellipsoid || Cesium.Ellipsoid.WGS84).cartesianToCartographic(centre)
: rectangle
? Cesium.Rectangle.center(rectangle)
: viewer?.camera?.positionCartographic;
if (!carto) return null;
const latitude = Cesium.Math.toDegrees(carto.latitude);
const longitude = Cesium.Math.toDegrees(carto.longitude);
@@ -60,9 +71,60 @@ export function createGnssIntegrityLayer({
let _lastUpdate = null;
let _lastError = null;
let _stale = false;
let _manager = null;
let _cameraTimer = null;
let _cameraRefresh = null;
let _lastQueryKey = null;
let _lastRequestAt = -Infinity;
let _removeCameraEnd = null;
let _removeCameraChanged = null;
let _coverage = null;
const _observations = new Map();
const provenance = gnssProvenance({ windowMs });
const queryKey = (anchor) =>
anchor
? `${Math.round(anchor.latitude)},${Math.round(anchor.longitude)}`
: null;
function scheduleCameraRefresh() {
if (!_enabled || !_dataSource) return;
const key = queryKey(viewAnchor(_viewer));
if (!key || key === _lastQueryKey) return;
clearTimeout(_cameraTimer);
// Debounce drags and bound moving/follow cameras to at most 24 requests
// per minute. Nearby views share the proxy's whole-degree cache key.
_cameraTimer = setTimeout(
async () => {
_cameraTimer = null;
if (!_enabled || _cameraRefresh) return;
const key = queryKey(viewAnchor(_viewer));
if (!key || key === _lastQueryKey) return;
const controller = new AbortController();
_cameraRefresh = controller;
try {
if (_manager) {
// Use the normal refresh lane so counts, loading/errors and render
// demand settle together; wait out an older periodic request.
await _manager.refreshLayer(layer.id, {
signal: controller.signal,
});
} else {
await layer.update(undefined, { signal: controller.signal });
}
} finally {
if (_cameraRefresh === controller) {
_cameraRefresh = null;
// Movement during an in-flight request is folded into one update
// for the latest view, rather than lost until the minute tick.
scheduleCameraRefresh();
}
}
},
Math.max(600, 2500 - (now() - _lastRequestAt)),
);
}
/** Age the store without new evidence, so expired cells leave the globe. */
function expire() {
pruneGnssObservations(_observations, now(), { windowMs });
@@ -100,6 +162,7 @@ export function createGnssIntegrityLayer({
}),
);
}
_viewer?.scene?.requestRender?.();
}
const layer = {
@@ -111,6 +174,10 @@ export function createGnssIntegrityLayer({
provenance,
updateInterval: 60000,
attachDataManager(manager) {
_manager = manager;
},
init(viewer) {
if (_viewer)
throw new Error('GNSS integrity layer is already initialized');
@@ -118,6 +185,12 @@ export function createGnssIntegrityLayer({
_dataSource = new Cesium.CustomDataSource('gnss-interference');
_dataSource.show = false;
viewer.dataSources.add(_dataSource);
_removeCameraEnd = viewer.camera?.moveEnd?.addEventListener(
scheduleCameraRefresh,
);
_removeCameraChanged = viewer.camera?.changed?.addEventListener(
scheduleCameraRefresh,
);
},
enable() {
@@ -130,14 +203,18 @@ export function createGnssIntegrityLayer({
},
disable() {
clearTimeout(_cameraTimer);
_cameraTimer = null;
_cameraRefresh?.abort();
_cameraRefresh = null;
_request?.abort();
_request = null;
_enabled = false;
if (_dataSource) _dataSource.show = false;
},
async update() {
if (!_enabled || !_dataSource) return false;
async update(_viewerArg, { signal } = {}) {
if (!_enabled || !_dataSource || signal?.aborted) return false;
const anchor = viewAnchor(_viewer);
// No anchor yet (camera still settling) is not a failure: returning
// false would make the manager reject the enable. The next interval
@@ -149,6 +226,16 @@ export function createGnssIntegrityLayer({
_request?.abort();
const request = new AbortController();
_request = request;
const onAbort = () => request.abort(signal.reason);
signal?.addEventListener('abort', onAbort, { once: true });
_lastQueryKey = queryKey(anchor);
_lastRequestAt = now();
_coverage = {
kind: 'regional',
latitude: Math.round(anchor.latitude),
longitude: Math.round(anchor.longitude),
radiusNm: 250,
};
try {
const snapshot = await source.getSnapshot(anchor, {
signal: request.signal,
@@ -178,11 +265,18 @@ export function createGnssIntegrityLayer({
expire();
return false;
} finally {
signal?.removeEventListener('abort', onAbort);
if (_request === request) _request = null;
scheduleCameraRefresh();
}
},
destroy(viewer = _viewer) {
layer.disable();
_removeCameraEnd?.();
_removeCameraChanged?.();
_removeCameraEnd = null;
_removeCameraChanged = null;
_request?.abort();
_request = null;
_enabled = false;
@@ -197,11 +291,17 @@ export function createGnssIntegrityLayer({
_lastUpdate = null;
_lastError = null;
_stale = false;
_coverage = null;
_lastQueryKey = null;
_manager = null;
},
getRowControls() {
return {
chips: [],
info: 'Regional sample · 250 nm radius · pan to sample another area',
infoTitle:
'Updates automatically after pan or zoom and once a minute. The public feed covers a 250-nautical-mile circle around the view centre; zooming out does not make it a worldwide snapshot. Blank cells have insufficient observations.',
legend: ['high', 'medium', 'low'].map((level, index) => ({
label: LEVEL_LABELS[level],
color: GNSS_LEVEL_COLORS[level],
@@ -222,6 +322,7 @@ export function createGnssIntegrityLayer({
error: _lastError,
stale: _stale,
provenance,
coverage: _coverage,
// Load-bearing: without an explicit boolean, layerFeedState reads the
// adsb.lol source name as a flights fallback (src/data/feedState.js).
fallback: false,
+118 -3
View File
@@ -1,6 +1,8 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { createGnssIntegrityLayer } from './index.js';
import * as Cesium from 'cesium';
import { createGnssIntegrityLayer, defaultGnssViewAnchor } from './index.js';
import { LayerLifecycle } from '../../data/lifecycle.js';
const row = (hex, lat, degraded) => ({
hex,
@@ -14,11 +16,15 @@ const row = (hex, lat, degraded) => ({
function harness(
getSnapshot,
{ anchor: initialAnchor = { latitude: 50, longitude: 30 } } = {},
{
anchor: initialAnchor = { latitude: 50, longitude: 30 },
initialize = true,
} = {},
) {
let anchor = initialAnchor;
const sources = [];
const viewer = {
camera: { moveEnd: new Cesium.Event(), changed: new Cesium.Event() },
dataSources: {
add(value) {
sources.push(value);
@@ -44,11 +50,12 @@ function harness(
},
now: () => clock,
});
layer.init(viewer);
if (initialize) layer.init(viewer);
return {
layer,
sources,
anchors,
viewer,
advance(ms) {
clock += ms;
},
@@ -66,6 +73,114 @@ const threeHealthy = () => [
row('a3', 1.3, false),
];
const flush = () => new Promise((resolve) => setImmediate(resolve));
test('a globe-wide rectangle uses the visible screen centre as the query anchor', () => {
const anchor = defaultGnssViewAnchor({
scene: {
canvas: { clientWidth: 1000, clientHeight: 800 },
globe: { ellipsoid: Cesium.Ellipsoid.WGS84 },
},
camera: {
pickEllipsoid(pixel) {
assert.deepEqual(pixel, new Cesium.Cartesian2(500, 400));
return Cesium.Cartesian3.fromDegrees(-97, 30);
},
computeViewRectangle: () => Cesium.Rectangle.MAX_VALUE,
},
});
assert.ok(Math.abs(anchor.latitude - 30) < 1e-8);
assert.ok(Math.abs(anchor.longitude + 97) < 1e-8);
});
test('pan and zoom refresh through the manager without toggling; nearby changes are coalesced', async (t) => {
t.mock.timers.enable({ apis: ['setTimeout', 'setInterval'] });
let calls = 0;
const h = harness(
async () => {
calls++;
return { rows: threeHealthy() };
},
{ initialize: false },
);
const manager = new LayerLifecycle(h.viewer);
manager.register(h.layer);
h.layer.attachDataManager(manager);
const changes = [];
manager.subscribe((change) => changes.push(change.type));
await manager.setEnabled(h.layer.id, true);
h.setAnchor({ latitude: 54, longitude: 19 });
h.viewer.camera.changed.raiseEvent();
h.setAnchor({ latitude: 55, longitude: 20 });
h.viewer.camera.moveEnd.raiseEvent();
h.advance(2500);
t.mock.timers.tick(2500);
await flush();
assert.equal(calls, 2, 'one initial read and one read for the latest view');
assert.deepEqual(h.anchors.at(-1), { latitude: 55, longitude: 20 });
assert.ok(changes.includes('refresh-transition'));
assert.ok(changes.includes('refresh'));
assert.equal(manager.isEnabled(h.layer.id), true);
assert.deepEqual(h.layer.getStats().coverage, {
kind: 'regional',
latitude: 55,
longitude: 20,
radiusNm: 250,
});
assert.match(h.layer.getRowControls().info, /Regional sample.*250 nm radius/);
h.setAnchor({ latitude: 55.1, longitude: 20.1 });
h.viewer.camera.changed.raiseEvent();
h.advance(5000);
t.mock.timers.tick(5000);
await flush();
assert.equal(calls, 2, 'nearby views reuse the same query region');
await manager.destroyAll();
assert.equal(h.viewer.camera.moveEnd.numberOfListeners, 0);
assert.equal(h.viewer.camera.changed.numberOfListeners, 0);
});
test('movement during a pending refresh follows up at the newest view; disable cancels queued work', async (t) => {
t.mock.timers.enable({ apis: ['setTimeout'] });
const calls = [];
let resolvePending;
const h = harness((anchor) => {
calls.push(anchor);
if (calls.length === 2)
return new Promise((resolve) => {
resolvePending = resolve;
});
return { rows: threeHealthy() };
});
h.layer.enable();
await h.layer.update();
h.setAnchor({ latitude: 54, longitude: 19 });
h.viewer.camera.moveEnd.raiseEvent();
h.advance(2500);
t.mock.timers.tick(2500);
await flush();
h.setAnchor({ latitude: 35, longitude: 140 });
h.viewer.camera.changed.raiseEvent();
h.advance(2500);
t.mock.timers.tick(2500);
await flush();
assert.equal(calls.length, 2, 'only one camera request in flight');
resolvePending({ rows: threeHealthy() });
await flush();
h.advance(600);
t.mock.timers.tick(600);
await flush();
assert.equal(calls.length, 3);
assert.deepEqual(calls.at(-1), { latitude: 35, longitude: 140 });
h.setAnchor({ latitude: 10, longitude: 10 });
h.viewer.camera.moveEnd.raiseEvent();
h.layer.disable();
h.advance(5000);
t.mock.timers.tick(5000);
await flush();
assert.equal(calls.length, 3, 'disable drains the camera timer');
h.layer.destroy();
});
test('the layer requires a snapshot source and cannot be initialized twice', () => {
assert.throws(() => createGnssIntegrityLayer({}), TypeError);
const { layer } = harness(async () => ({ rows: [] }));