From 96534df80717b79dbda80269b561cc0d49c747c4 Mon Sep 17 00:00:00 2001 From: Mazeyar Moeini Feizabadi Date: Tue, 13 Oct 2026 21:17:10 +0200 Subject: [PATCH] fix: refresh GNSS sampling automatically as the camera moves --- CHANGELOG.md | 5 +- DATA_SOURCES.md | 2 +- docs/CURRENT-STATE.md | 4 +- scripts/qa-gnss.mjs | 85 ++++++++++++++++++++ src/layers/gnss/index.js | 117 ++++++++++++++++++++++++++-- src/layers/gnss/ownership.test.mjs | 121 ++++++++++++++++++++++++++++- 6 files changed, 319 insertions(+), 15 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f7bf0a65a..d81467a25 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -189,7 +189,10 @@ are navigation-integrity anomalies; suspected GNSS interference is a secondary, unvalidated interpretation. The layer and the proxy expose machine-readable provenance (`provenance`, `classifier: gev-nic-nacp-v1`). - Keyless, worldwide wherever adsb.lol has receivers; reads + Automatically refreshes after camera pan/zoom without toggling, coalesces + nearby views, and uses the Earth point under the screen centre at globe scale. + Keyless regional sampling wherever adsb.lol has receivers; the row labels + the 250 nm radius, which remains regional when zoomed out. Reads a capped 250 nm snapshot around the camera through `/api/gnss-integrity`, which returns integrity fields only and serves the last snapshot (with its original observation time and `ageMs`, for at most 30 minutes) when the diff --git a/DATA_SOURCES.md b/DATA_SOURCES.md index 9afeec60b..da262b43a 100644 --- a/DATA_SOURCES.md +++ b/DATA_SOURCES.md @@ -78,7 +78,7 @@ The optional Local ADS-B layer and the broadcast-FM receiver use radio you recei - **Google Maps Platform.** You supply your own API key and are bound by [Google's ToS](https://cloud.google.com/maps-platform/terms). Google Maps Content (tiles, geocodes, places) **may not be cached, stored, rehosted, or committed** — this app only ever uses it live, which is the compliant pattern. The "Google" attribution is displayed on the globe and must stay visible. Restrict your key (see [SECURITY.md](SECURITY.md)). - **OpenSky Network.** Its license is **non-commercial**, and operational use of the REST API in a live product can require a prior written agreement with OpenSky — even for non-profit/government use. If you deploy this commercially, contact OpenSky for your own terms. The flights layer is a toggle and runs anonymously by default. - **adsb.lol flight fallback.** When OpenSky is unavailable and no last-good OpenSky response exists, the server requests a cached, capped 250 nm adsb.lol point snapshot around the current camera subpoint. This is regional observed context, not worldwide completeness; provenance is exposed in the Flights stats/context row. Military ICAOs remain reconciled through the existing dedicated military registry rather than duplicated. -- **GNSS integrity (adsb.lol integrity fields).** The GNSS Integrity layer (id `gnss-interference`) reads the same keyless 250 nm point snapshot around the camera (anchor rounded to a whole degree, 60 s server cache, cooldown on upstream 429). The server returns only hex, position, NIC, NACp and readsb's GPS-loss flag for airborne ADS-B v1/v2 transponder positions seen in the last minute; callsigns and registrations are dropped. The method has three separately sourced parts, exposed as machine-readable `provenance` on the layer and its stats: (1) a GEV-defined per-aircraft classifier (`gev-nic-nacp-v1`): an aircraft has low navigation accuracy when it reports NIC < 7 or NACp < 8 or readsb flags GPS loss. The numbers are borrowed from the 14 CFR 91.227(c) ADS-B Out performance minima, a US regulation, not a published interference classifier; gpsjam.org does not publish its per-aircraft threshold ([FAQ](https://gpsjam.org/faq)), and this one is not yet validated against known events. (2) gpsjam.org's published cell formula and bands (`100 × (bad − 1) / total`; low < 2 %, medium 2–10 %, high > 10 %). (3) A GEV-defined window: at least 3 aircraft per 0.5° cell over a rolling 30 minutes around the views the user visits. Medium and high cells are navigation-integrity anomalies. Suspected jamming or spoofing is a secondary interpretation, not a confirmed source or location: avionics faults also report low values. Verified 2026-09-29. +- **GNSS integrity (adsb.lol integrity fields).** The GNSS Integrity layer (id `gnss-interference`) reads the same keyless 250 nm point snapshot around the camera (Earth point under the screen centre, anchor rounded to a whole degree, 60 s server cache, cooldown on upstream 429). Pan/zoom triggers a debounced automatic refresh, bounded to one read per 2.5 s; same-region movement shares the cache. This is a regional public point feed, not a worldwide snapshot at globe zoom. The layer row labels the sampling radius. The server returns only hex, position, NIC, NACp and readsb's GPS-loss flag for airborne ADS-B v1/v2 transponder positions seen in the last minute; callsigns and registrations are dropped. The method has three separately sourced parts, exposed as machine-readable `provenance` on the layer and its stats: (1) a GEV-defined per-aircraft classifier (`gev-nic-nacp-v1`): an aircraft has low navigation accuracy when it reports NIC < 7 or NACp < 8 or readsb flags GPS loss. The numbers are borrowed from the 14 CFR 91.227(c) ADS-B Out performance minima, a US regulation, not a published interference classifier; gpsjam.org does not publish its per-aircraft threshold ([FAQ](https://gpsjam.org/faq)), and this one is not yet validated against known events. (2) gpsjam.org's published cell formula and bands (`100 × (bad − 1) / total`; low < 2 %, medium 2–10 %, high > 10 %). (3) A GEV-defined window: at least 3 aircraft per 0.5° cell over a rolling 30 minutes around the views the user visits. Medium and high cells are navigation-integrity anomalies. Suspected jamming or spoofing is a secondary interpretation, not a confirmed source or location: avionics faults also report low values. Verified 2026-09-29. - **Transponder emergencies.** Emergency highlighting reads fields the flight feeds already return: OpenSky state vectors' `squawk` and adsb.lol/readsb's `squawk` and `emergency`. It makes no extra request. A broadcast code states what the transponder sends, not a verified incident; OpenSky has no ADS-B emergency field, so genuine OpenSky highlighting relies on squawk 7500/7600/7700 alone. The civil adsb.lol fallback preserves its additional ADS-B status beside the OpenSky-compatible vectors, without another request. - **adsbdb enrichment.** `/api/adsbdb` resolves an ICAO hex to aircraft type, model name and registration, and the tracked flight's callsign to an airline and an origin/destination pair. Fan-out is deliberately small because adsbdb is a free community API that publishes no quota: at most 4 requests in flight, dispatches dripped to roughly 5/s, and each key asked for at most once per session, with route lookups reserved for the tracked aircraft. The dev-server proxy caches each key for 24 hours in `.gev-cache/adsbdb.json`, negative answers included, so fresh entries survive server restarts. That gitignored cache remains local to the deployment and is not bundled with this repo. adsbdb publishes no general service license; its README says the route data may not be copied, published, or incorporated into other databases without the explicit permission of David J Taylor, Edinburgh. Deployers are responsible for obtaining any permission their use requires. Enrichment is best-effort and fail-silent — a flight that gets no answer keeps what the flight feed already gave it. - **Launch Library 2.** `/api/launches` makes a server-side rolling-30-day query against the supported v2.3 detailed launch endpoint, caches successful responses for 15 minutes in memory and on disk, and serves the last successful response during a throttle or transient outage. Anonymous access is limited to 15 calls/hour; deployments can provide `LL2_API_TOKEN` for authenticated access. The Space Devs' published terms permit using and sharing the API data in any form, ask users not to forward it without adding value, disclaim complete accuracy, and encourage—but do not require—attribution. This app keeps a courtesy credit. Payload and stage/recovery records are shown only when supplied. Failed launches expose their source status and never receive fallback orbit geometry or a live/estimated marker. LL2 supplies launch context and event timing, not continuous ascent telemetry or live orbital state. diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index c894d49de..d62e733e1 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -3098,10 +3098,10 @@ its criteria cannot be silently ignored. | FIRMS Active Fires ▲ | NASA FIRMS live (VIIRS ×3 NRT + MODIS NRT, trailing 24h) | `src/data/firmsHeatmap.js` | `/api/firms` (`FIRMS_MAP_KEY`) | 10 min (proxy TTL 30 min) | | Street Level 📷 | Street-level imagery; Mapillary is the first provider (vector tiles, Graph API, MapillaryJS) | `src/layers/streetLevel/` via `src/app/layers/streetLevel.js` | `/api/mapillary/status`, `/api/mapillary/tiles/coverage/{z}/{x}/{y}` (`MAPILLARY_CLIENT_TOKEN`) | camera-driven (320 ms debounce, ≤9 tiles, cached 24 h) | | Wind 🌬 | NOAA GFS 10 m wind (keyless, 0.25°→1° grid; animated particles) | `src/data/wind.js` | `/api/wind` | 1 h (forecast cycle) | -| GNSS Integrity 📡 (`gnss-interference`) | adsb.lol point snapshot, low navigation accuracy binned per 0.5° cell (GEV NIC/NACp threshold + gpsjam.org formula and bands, keyless, ODbL) | `src/layers/gnss/` via `src/app/layers/gnss.js` | `/api/gnss-integrity` | 60s (server cache 60 s; 30 min rolling window) | +| GNSS Integrity 📡 (`gnss-interference`) | adsb.lol point snapshot, low navigation accuracy binned per 0.5° cell (GEV NIC/NACp threshold + gpsjam.org formula and bands, keyless, ODbL) | `src/layers/gnss/` via `src/app/layers/gnss.js` | `/api/gnss-integrity` | camera-driven (600 ms debounce, minimum 2.5 s between reads) + 60s poll (server cache 60 s; 30 min rolling window) | | Fire Perimeters 🔥 | NIFC WFIGS current interagency perimeters (keyless, paged past the 2000-record cap); InciWeb catalog + incident page origin and update time checks for verified incident-page links | `src/layers/perimeters/` via `src/app/layers/perimeters.js` | `/api/fire-perimeters` + `/api/fire-perimeters/inciweb/*` | 5 min (server caches: catalog 1 h; publication 30 min) | -GNSS Integrity fetches a capped adsb.lol snapshot around the view anchor, keeps each aircraft once per cell over 30 minutes as the camera pans, and draws low/medium/high ground rectangles only for cells with at least 3 aircraft. The row legend counts cells per band. The method is a GEV threshold plus gpsjam.org aggregation, not the gpsjam method: the per-aircraft rule (NIC < 7 or NACp < 8, borrowed from the US 14 CFR 91.227 ADS-B Out minima, or readsb's GPS-loss flag) and the 30-minute visited-view window are GEV-defined and unvalidated; only the cell formula and 2% / 10% bands come from gpsjam.org. `layer.provenance` (also on `getStats()`) records each part, and the proxy tags its rows with `classifier: gev-nic-nacp-v1`. Cells carry `state: navigation-integrity-anomaly` (medium/high) or `nominal`; `interpretation: suspected-gnss-interference` is a secondary reading, not a detection or a located emitter. Only DO-260A/B (ADS-B versions 1 and 2) direct 1090ES positions with a known `seen_pos` of 0–60 s count. Presence and degraded evidence age separately: a degraded report keeps the aircraft degraded in its cell for 30 minutes after that report, however many healthy reports follow, and an aircraft unheard for 30 minutes starts fresh. Rows are dated by the snapshot's observation time, which the proxy keeps on cached and stale answers and reports as `ageMs` on its own clock (so browser clock skew does not move the window); a replayed snapshot never restarts the window; the proxy stops serving a stale snapshot once it is older than 30 minutes, the source refuses an undated one, and cells age out on schedule during fetch failures or while there is no view anchor. +GNSS Integrity automatically refreshes after camera pan/zoom (600 ms debounce, minimum 2.5 s between reads, shared whole-degree cache keys) through the manager refresh lane, so counts and loading/error state update without toggling. The anchor is the Earth point under the screen centre, including at globe scale where the visible rectangle can cover the entire world. It fetches a capped 250-nautical-mile-radius adsb.lol snapshot around that anchor, keeps each aircraft once per cell over 30 minutes as the camera pans, and draws low/medium/high ground rectangles only for cells with at least 3 aircraft. The row legend counts cells per band and visibly labels the regional 250 nm sampling radius. Zooming out does not turn this public point feed into worldwide coverage; blank areas can lie outside the sampled region or have fewer than three qualifying aircraft. The method is a GEV threshold plus gpsjam.org aggregation, not the gpsjam method: the per-aircraft rule (NIC < 7 or NACp < 8, borrowed from the US 14 CFR 91.227 ADS-B Out minima, or readsb's GPS-loss flag) and the 30-minute visited-view window are GEV-defined and unvalidated; only the cell formula and 2% / 10% bands come from gpsjam.org. `layer.provenance` (also on `getStats()`) records each part, and the proxy tags its rows with `classifier: gev-nic-nacp-v1`. Cells carry `state: navigation-integrity-anomaly` (medium/high) or `nominal`; `interpretation: suspected-gnss-interference` is a secondary reading, not a detection or a located emitter. Only DO-260A/B (ADS-B versions 1 and 2) direct 1090ES positions with a known `seen_pos` of 0–60 s count. Presence and degraded evidence age separately: a degraded report keeps the aircraft degraded in its cell for 30 minutes after that report, however many healthy reports follow, and an aircraft unheard for 30 minutes starts fresh. Rows are dated by the snapshot's observation time, which the proxy keeps on cached and stale answers and reports as `ageMs` on its own clock (so browser clock skew does not move the window); a replayed snapshot never restarts the window; the proxy stops serving a stale snapshot once it is older than 30 minutes, the source refuses an undated one, and cells age out on schedule during fetch failures or while there is no view anchor. Fire Perimeters uses capped, timed server reads with stale-on-error caching and a per-client limit. Unchanged snapshots retain geometry; link checks cancel on disable or selection change, and the row legend shows reported containment. diff --git a/scripts/qa-gnss.mjs b/scripts/qa-gnss.mjs index 12cab5ebd..bce30bad7 100644 --- a/scripts/qa-gnss.mjs +++ b/scripts/qa-gnss.mjs @@ -243,11 +243,13 @@ async function stubRoute(page, { ageMs = 0 } = {}) { await page.evaluateOnNewDocument( (payload, age) => { window.__gnssQaAgeMs = age; + window.__gnssQaRequests = []; const realFetch = window.fetch.bind(window); window.__gnssQaMode = 'fixture'; window.fetch = (input, init) => { const url = typeof input === 'string' ? input : input?.url || ''; if (!url.includes('/api/gnss-integrity')) return realFetch(input, init); + window.__gnssQaRequests.push(url); const ok = window.__gnssQaMode === 'fixture'; const body = ok ? { @@ -480,6 +482,86 @@ async function main() { ); await shoot(page, 'synthetic-bands'); + console.log('\nCAMERA — automatic regional refresh without toggling...'); + const beforeMove = await page.evaluate( + () => window.__gnssQaRequests.length, + ); + await page.evaluate(() => { + const { viewer } = window.__godsEyeView; + viewer.camera.setView({ + destination: viewer.scene.globe.ellipsoid.cartographicToCartesian({ + longitude: (139.7 * Math.PI) / 180, + latitude: (35.7 * Math.PI) / 180, + height: 1_100_000, + }), + orientation: { heading: 0, pitch: -Math.PI / 2, roll: 0 }, + }); + viewer.scene.requestRender(); + }); + await page.waitForFunction( + () => { + const url = window.__gnssQaRequests.at(-1); + return ( + url && + Math.abs( + Number(new URL(url, location.origin).searchParams.get('lon')) - + 139.7, + ) < 1 + ); + }, + { timeout: 15000 }, + ); + await sleep(3500); + const moved = await page.evaluate( + (id) => ({ + requests: window.__gnssQaRequests.length, + enabled: window.__godsEyeView.dataManager.isEnabled(id), + coverage: window.__godsEyeView.dataManager.layers + .get(id) + .module.getStats().coverage, + info: document.querySelector( + `[data-layer-id="${id}"] .data-toggle-controls-info`, + )?.textContent, + }), + LAYER_ID, + ); + record( + 'CAMERA: moving to Tokyo automatically refreshes the new region and updates the row', + moved.enabled && + moved.requests > beforeMove && + moved.requests <= beforeMove + 2 && + moved.coverage?.longitude === 140 && + /Regional sample.*250 nm radius/.test(moved.info), + JSON.stringify(moved), + ); + await shoot(page, 'camera-refresh'); + await page.evaluate(() => { + const { viewer } = window.__godsEyeView; + viewer.camera.setView({ + destination: viewer.scene.globe.ellipsoid.cartographicToCartesian({ + longitude: (18.6 * Math.PI) / 180, + latitude: (54.6 * Math.PI) / 180, + height: 450_000, + }), + orientation: { heading: 0, pitch: -Math.PI / 2, roll: 0 }, + }); + viewer.scene.requestRender(); + }); + await page.waitForFunction( + () => { + const url = window.__gnssQaRequests.at(-1); + return ( + url && + Math.abs( + Number(new URL(url, location.origin).searchParams.get('lon')) - + 18.6, + ) < 1 + ); + }, + { timeout: 15000 }, + ); + await sleep(3500); + // ── (iii) FAILURE ───────────────────────────────────────────────────── console.log('\n(iii) FAILURE — /api/gnss-integrity stubbed to 502...'); await page.evaluate(() => { @@ -550,6 +632,8 @@ async function main() { rowRight: Math.round(rect.right), overflow: row.scrollWidth - row.clientWidth, legendItems: legend.length, + coverageInfo: row.querySelector('.data-toggle-controls-info') + ?.textContent, legendInside: legend.every((item) => { const r = item.getBoundingClientRect(); return ( @@ -564,6 +648,7 @@ async function main() { fit.rowRight <= fit.width && fit.overflow <= 0 && fit.legendItems === 3 && + /Regional sample.*250 nm radius/.test(fit.coverageInfo) && fit.legendInside, JSON.stringify(fit), ); diff --git a/src/layers/gnss/index.js b/src/layers/gnss/index.js index 7be808d9f..5ad764d16 100644 --- a/src/layers/gnss/index.js +++ b/src/layers/gnss/index.js @@ -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, diff --git a/src/layers/gnss/ownership.test.mjs b/src/layers/gnss/ownership.test.mjs index 122489fa2..5996d5e2b 100644 --- a/src/layers/gnss/ownership.test.mjs +++ b/src/layers/gnss/ownership.test.mjs @@ -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: [] }));