diff --git a/CHANGELOG.md b/CHANGELOG.md index c0623687c..26e9c4aca 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -156,9 +156,10 @@ under NLOD 2.0. - GNSS Interference layer (Events group): ADS-B aircraft that report degraded - navigation accuracy (NIC < 7 or NACp < 8, or readsb's GPS-loss flag) are - binned into 0.5° cells over a rolling 30-minute window and coloured with the - gpsjam.org bands. Keyless, worldwide wherever adsb.lol has receivers; reads + navigation accuracy (below the 14 CFR 91.227 ADS-B Out minima, NIC < 7 or + NACp < 8, or readsb's GPS-loss flag) are binned into 0.5° cells over a + rolling 30-minute window and scored with gpsjam.org's published formula and + bands. Keyless, worldwide wherever adsb.lol has receivers; reads a capped 250 nm snapshot around the camera through `/api/gnss-integrity`, which returns integrity fields only and serves the last snapshot when the upstream fails. diff --git a/DATA_SOURCES.md b/DATA_SOURCES.md index dd7dd37d9..217c18486 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 interference (adsb.lol integrity fields).** The GNSS Interference layer 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. Cells use the gpsjam.org method (NIC < 7 or NACp < 8 marks an aircraft degraded; `100 × (bad − 1) / total`; low < 2 %, medium 2–10 %, high > 10 %; at least 3 aircraft per 0.5° cell over a rolling 30-minute window). Degraded navigation accuracy indicates probable jamming or spoofing, not a confirmed source or location. Verified 2026-09-29. +- **GNSS interference (adsb.lol integrity fields).** The GNSS Interference layer 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. An aircraft is degraded when it reports below the 14 CFR 91.227(c) ADS-B Out minima (NIC < 7 or NACp < 8) or readsb flags GPS loss; cells use gpsjam.org's published formula and bands (`100 × (bad − 1) / total`; low < 2 %, medium 2–10 %, high > 10 %; at least 3 aircraft per 0.5° cell over a rolling 30-minute window). Degraded navigation accuracy indicates probable jamming or spoofing, not a confirmed source or location. Verified 2026-09-29. - **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. - **TfL JamCams.** The camera list comes from the keyless `api.tfl.gov.uk` endpoint (an optional `TFL_APP_KEY` raises its rate limit); frames come from TfL's public S3 bucket. The "Powered by TfL Open Data" attribution is required by TfL's terms and is registered in the Data attribution popover. diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index b1abd9f15..f9f3e6391 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -3069,7 +3069,7 @@ 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 Interference 📡 | adsb.lol point snapshot, ADS-B NIC/NACp integrity binned per 0.5° cell (gpsjam.org method, keyless, ODbL) | `src/layers/gnss/` via `src/app/layers/gnss.js` | `/api/gnss-integrity` | 60s (server cache 60 s; 30 min rolling window) | +| GNSS Interference 📡 | adsb.lol point snapshot, ADS-B NIC/NACp integrity binned per 0.5° cell (gpsjam.org formula and bands, 91.227 NIC/NACp minima, keyless, ODbL) | `src/layers/gnss/` via `src/app/layers/gnss.js` | `/api/gnss-integrity` | 60s (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 Interference 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. It shows probable interference, not a located emitter. diff --git a/scripts/format-scope.json b/scripts/format-scope.json index f87618780..5eaaa6d6a 100644 --- a/scripts/format-scope.json +++ b/scripts/format-scope.json @@ -835,6 +835,7 @@ "src/layers/gnss/records.test.mjs", "src/layers/gnss/source.test.mjs", "src/layers/gnss/ownership.test.mjs", + "scripts/qa-gnss.mjs", "src/layers/cyclones/index.js", "src/layers/cyclones/rendering.js", "src/layers/cyclones/labels.js", diff --git a/scripts/package-boundaries.json b/scripts/package-boundaries.json index 18e8b4e3c..b43565597 100644 --- a/scripts/package-boundaries.json +++ b/scripts/package-boundaries.json @@ -3121,7 +3121,8 @@ "src/sources/httpBody.js", "src/layers/perimeters/source.js", "src/layers/perimeters/records.js", - "src/layers/submarineCables/bundledSource.js" + "src/layers/submarineCables/bundledSource.js", + "src/layers/gnss/source.js" ], "external": [] }, diff --git a/scripts/qa-gnss.mjs b/scripts/qa-gnss.mjs index 81b430f13..08e9123b6 100644 --- a/scripts/qa-gnss.mjs +++ b/scripts/qa-gnss.mjs @@ -5,18 +5,24 @@ * Drives the REAL app in headless Chromium against a dev server (keyless). * The live section reads adsb.lol through `/api/gnss-integrity` (60 s server * cache, one upstream read per whole-degree anchor); the other sections - * intercept that route so they are deterministic. + * stub that route in the page so they are deterministic. * - * (i) LIVE — over the southern Baltic the layer settles with cells, - * no error, and legend band counts that sum to the cell count. + * (i) LIVE — over Poland and the southern Baltic the layer settles with no error, + * one entity per cell and legend band counts that sum to the cell + * count; cells are required only when adsb.lol actually returned + * enough aircraft (live traffic varies by hour). * (ii) BANDS (synthetic) — a 10-aircraft cell with 5 degraded is high, - * a 3-aircraft cell with 1 degraded is low (first bad discounted), - * a 2-aircraft cell is withheld; `stale: true` reaches getStats. - * (iii) FAILURE — the route intercepted to 502: stats.error is set and the + * a 30-aircraft cell with 2 degraded is medium, a 3-aircraft cell + * with 1 degraded is low (first bad discounted), a clean cell at sea + * is low, a 2-aircraft cell is withheld; `stale: true` reaches + * getStats. Every band is drawn and low is shown over land and sea. + * (iii) FAILURE — the route stubbed to 502: stats.error is set and the * cells already on the globe are kept. * (iv) DISABLE — the layer's data source is hidden. * - * Visual proof saved to qa-shots/gnss-*.png (gitignored). + * Screenshots wait until every cell's ground geometry is built and show the + * open Data Layers row with its legend. Visual proof saved to + * qa-shots/gnss-*.png (gitignored). * * Run: node scripts/qa-gnss.mjs --url http://localhost:4173 * Exits non-zero on any FAIL. Does not commit anything. @@ -39,6 +45,8 @@ const getOpt = (name, dflt) => { const APP_URL = getOpt('--url', 'http://localhost:4173'); const HEADFUL = argv.includes('--headful'); const LAYER_ID = 'gnss-interference'; +/** Live rows below this cannot reliably fill a 3-aircraft cell. */ +const LIVE_MIN_ROWS = 30; const results = []; function record(name, ok, detail) { @@ -50,59 +58,162 @@ const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); /** Boot the app, dismiss the intro and place the camera straight down. */ async function boot(page, { lon, lat, height }) { - await page.goto(`${APP_URL}/?welcome=0`, { waitUntil: 'domcontentloaded', timeout: 60000 }); + await page.goto(`${APP_URL}/?welcome=0`, { + waitUntil: 'domcontentloaded', + timeout: 60000, + }); await page.waitForFunction( () => window.__godsEyeView?.viewer && window.__godsEyeView?.dataManager, { timeout: 60000 }, ); await sleep(5000); await page.keyboard.press('Escape'); - await page.evaluate((lo, la, h) => { - const gev = window.__godsEyeView; - const d2r = Math.PI / 180; - // The app's intro flyTo animation clobbers a setView issued mid-flight. - try { gev.viewer.camera.cancelFlight(); } catch { /* no flight active */ } - gev.viewer.camera.setView({ - destination: gev.viewer.scene.globe.ellipsoid.cartographicToCartesian({ - longitude: lo * d2r, latitude: la * d2r, height: h, - }), - orientation: { heading: 0, pitch: -Math.PI / 2, roll: 0 }, - }); - gev.viewer.scene.requestRender?.(); - }, lon, lat, height); + await page.evaluate( + (lo, la, h) => { + const gev = window.__godsEyeView; + const d2r = Math.PI / 180; + // The app's intro flyTo animation clobbers a setView issued mid-flight. + try { + gev.viewer.camera.cancelFlight(); + } catch { + /* no flight active */ + } + gev.viewer.camera.setView({ + destination: gev.viewer.scene.globe.ellipsoid.cartographicToCartesian({ + longitude: lo * d2r, + latitude: la * d2r, + height: h, + }), + orientation: { heading: 0, pitch: -Math.PI / 2, roll: 0 }, + }); + gev.viewer.scene.requestRender?.(); + }, + lon, + lat, + height, + ); } /** Enable (or refresh) the layer and poll until it settles. */ async function enableAndSettle(page, { timeoutS = 30, refresh = false } = {}) { - return page.evaluate(async (id, tS, again) => { - const dm = window.__godsEyeView.dataManager; - const layer = dm.layers.get(id).module; - if (again) await layer.update(); - else await dm.setEnabled(id, true); - let stats = null; - for (let i = 0; i < tS; i++) { - stats = layer.getStats(); - if (stats.count > 0 || stats.error) break; - await new Promise((r) => setTimeout(r, 1000)); + return page.evaluate( + async (id, tS, again) => { + const dm = window.__godsEyeView.dataManager; + const layer = dm.layers.get(id).module; + if (again) await dm.refreshLayer(id); + else await dm.setEnabled(id, true); + let stats = null; + for (let i = 0; i < tS; i++) { + stats = layer.getStats(); + if (stats.count > 0 || stats.error) break; + await new Promise((r) => setTimeout(r, 1000)); + } + const legend = layer + .getRowControls() + .legend.map(({ label, count }) => ({ label, count })); + const ds = window.__godsEyeView.viewer.dataSources.getByName(id)[0]; + return { + stats, + legend, + entities: ds?.entities.values.length ?? -1, + shown: ds?.show ?? null, + }; + }, + LAYER_ID, + timeoutS, + refresh, + ); +} + +/** Open the Data Layers panel and bring the GNSS row into view. */ +async function showLayerRow(page) { + await page.evaluate((id) => { + const panel = document.getElementById('data-panel'); + if (panel.classList.contains('collapsed')) + panel.querySelector('[data-collapse-target="data-panel"]').click(); + document + .querySelector(`[data-layer-id="${id}"]`) + ?.scrollIntoView({ block: 'center' }); + }, LAYER_ID); + await sleep(800); +} + +async function shoot(page, name) { + await page + .waitForFunction( + () => window.__godsEyeView.viewer.scene.globe.tilesLoaded, + { timeout: 60000 }, + ) + .catch(() => {}); + await showLayerRow(page); + await page.screenshot({ path: path.join(SHOTS_DIR, `gnss-${name}.png`) }); +} + +/** Bounding-sphere state of every GNSS cell: DONE = 0, PENDING = 1, FAILED = 2. */ +function cellStates(id) { + const { viewer } = window.__godsEyeView; + const ds = viewer.dataSources.getByName(id)[0]; + const sphere = {}; + return ds.entities.values.map((entity) => { + try { + return viewer.dataSourceDisplay.getBoundingSphere(entity, false, sphere); + } catch { + return 1; // not yet seen by the geometry visualizer } - const legend = layer.getRowControls().legend.map(({ label, count }) => ({ label, count })); - const ds = window.__godsEyeView.viewer.dataSources.getByName(id)[0]; - return { stats, legend, entities: ds?.entities.values.length ?? -1, shown: ds?.show ?? null }; - }, LAYER_ID, timeoutS, refresh); + }); +} + +/** Wait until every GNSS cell's ground rectangle has finished building. */ +async function waitForBuiltCells(page, timeoutMs = 60000) { + const built = await page + .waitForFunction( + `(${cellStates})(${JSON.stringify(LAYER_ID)}).every((state) => state === 0)`, + { timeout: timeoutMs, polling: 250 }, + ) + .then( + () => true, + () => false, + ); + const states = await page.evaluate( + `(${cellStates})(${JSON.stringify(LAYER_ID)})`, + ); + return { built, detail: `states=${JSON.stringify(states)}` }; } const fixtureRow = (hex, lat, lon, degraded) => ({ - hex, lat, lon, nic: degraded ? 0 : 8, nacp: degraded ? 0 : 10, gpsLost: false, degraded, + hex, + lat, + lon, + nic: degraded ? 0 : 8, + nacp: degraded ? 0 : 10, + gpsLost: false, + degraded, }); -/** Three synthetic 0.5° cells: high (5/10 bad), low (1/3 bad), withheld (2 aircraft). */ +/** + * Synthetic 0.5° cells: high at sea (5/10 bad), medium on land (2/30 bad), + * low on land (1/3 bad), low at sea (0/4 bad), withheld (2 aircraft). + */ function fixturePayload() { const rows = []; const hex = (prefix, i) => `${prefix}${i.toString(16).padStart(2, '0')}`; - for (let i = 0; i < 10; i++) rows.push(fixtureRow(hex('a000', i), 55.1 + i * 0.01, 19.1, i < 5)); - for (let i = 0; i < 3; i++) rows.push(fixtureRow(hex('b000', i), 54.1 + i * 0.01, 18.1, i === 0)); - for (let i = 0; i < 2; i++) rows.push(fixtureRow(hex('c000', i), 53.1 + i * 0.01, 17.1, true)); - return { fetchedAt: Date.now(), anchor: { lat: 55, lon: 19 }, radiusNm: 250, rows, stale: true }; + for (let i = 0; i < 10; i++) + rows.push(fixtureRow(hex('a000', i), 55.1 + i * 0.01, 19.1, i < 5)); + for (let i = 0; i < 30; i++) + rows.push(fixtureRow(hex('d000', i), 54.1 + i * 0.01, 19.6, i < 2)); + for (let i = 0; i < 3; i++) + rows.push(fixtureRow(hex('b000', i), 54.1 + i * 0.01, 18.1, i === 0)); + for (let i = 0; i < 4; i++) + rows.push(fixtureRow(hex('e000', i), 55.1 + i * 0.01, 18.1, false)); + for (let i = 0; i < 2; i++) + rows.push(fixtureRow(hex('c000', i), 53.1 + i * 0.01, 17.1, true)); + return { + fetchedAt: Date.now(), + anchor: { lat: 55, lon: 19 }, + radiusNm: 250, + rows, + stale: true, + }; } async function main() { @@ -112,19 +223,30 @@ async function main() { const res = await fetch(APP_URL); if (!res.ok) throw new Error(`HTTP ${res.status}`); } catch (e) { - console.error(`\x1b[31mDev server not reachable at ${APP_URL} (${e.message}).\x1b[0m`); + console.error( + `\x1b[31mDev server not reachable at ${APP_URL} (${e.message}).\x1b[0m`, + ); process.exit(2); } fs.mkdirSync(SHOTS_DIR, { recursive: true }); - const executablePath = process.env.PUPPETEER_EXECUTABLE_PATH - || (await puppeteer.executablePath().catch(() => null)) || undefined; + const executablePath = + process.env.PUPPETEER_EXECUTABLE_PATH || + (await puppeteer.executablePath().catch(() => null)) || + undefined; const browser = await puppeteer.launch({ headless: HEADFUL ? false : 'new', - ...(executablePath && fs.existsSync(executablePath) ? { executablePath } : {}), + ...(executablePath && fs.existsSync(executablePath) + ? { executablePath } + : {}), args: [ - '--no-sandbox', '--disable-setuid-sandbox', '--use-gl=angle', '--use-angle=swiftshader', - '--disable-dev-shm-usage', '--disable-background-timer-throttling', - '--disable-renderer-backgrounding', '--window-size=1440,900', + '--no-sandbox', + '--disable-setuid-sandbox', + '--use-gl=angle', + '--use-angle=swiftshader', + '--disable-dev-shm-usage', + '--disable-background-timer-throttling', + '--disable-renderer-backgrounding', + '--window-size=1440,900', ], }); @@ -132,58 +254,123 @@ async function main() { const errors = []; try { // ── (i) LIVE ────────────────────────────────────────────────────────── - console.log('(i) LIVE — adsb.lol integrity snapshot over the southern Baltic...'); + console.log( + '(i) LIVE — adsb.lol integrity snapshot over Poland and the southern Baltic...', + ); const live = await browser.newPage(); await live.setViewport({ width: 1440, height: 900 }); live.on('pageerror', (e) => errors.push(e.message)); - await boot(live, { lon: 19.5, lat: 55.0, height: 1_600_000 }); + const liveBodies = []; + live.on('response', (res) => { + if (res.url().includes('/api/gnss-integrity') && res.ok()) + liveBodies.push(res.json().then((body) => body.rows?.length ?? 0)); + }); + await boot(live, { lon: 19.5, lat: 52.5, height: 1_100_000 }); const l = await enableAndSettle(live); const legendTotal = l.legend.reduce((sum, { count }) => sum + count, 0); - record('LIVE: cells drawn, no error', - l.stats.count > 0 && !l.stats.error && l.entities === l.stats.count, - `cells=${l.stats.count} entities=${l.entities} error=${JSON.stringify(l.stats.error)}`); - record('LIVE: legend band counts sum to the cell count', legendTotal === l.stats.count, - JSON.stringify(l.legend)); - await sleep(3000); - await live.screenshot({ path: path.join(SHOTS_DIR, 'gnss-live-baltic.png') }); + const liveRows = Math.max( + 0, + ...(await Promise.allSettled(liveBodies)) + .filter((r) => r.status === 'fulfilled') + .map((r) => r.value), + ); + record( + 'LIVE: no error, one entity per cell', + !l.stats.error && l.entities === l.stats.count, + `cells=${l.stats.count} entities=${l.entities} error=${JSON.stringify(l.stats.error)}`, + ); + if (liveRows >= LIVE_MIN_ROWS) { + record( + 'LIVE: a busy snapshot yields cells', + l.stats.count > 0, + `rows=${liveRows} cells=${l.stats.count}`, + ); + } else { + console.log( + ` [info] only ${liveRows} live rows — cell count not asserted`, + ); + } + record( + 'LIVE: legend band counts sum to the cell count', + legendTotal === l.stats.count, + JSON.stringify(l.legend), + ); + const liveBuilt = await waitForBuiltCells(live); + record( + 'LIVE: every cell rectangle is built on a parked camera', + liveBuilt.built, + liveBuilt.detail, + ); + await shoot(live, 'live-poland-baltic'); await live.close(); // ── (ii) BANDS ──────────────────────────────────────────────────────── - console.log('\n(ii) BANDS — synthetic integrity rows through the real layer...'); + console.log( + '\n(ii) BANDS — synthetic integrity rows through the real layer...', + ); const page = await browser.newPage(); await page.setViewport({ width: 1440, height: 900 }); page.on('pageerror', (e) => errors.push(e.message)); - await page.setRequestInterception(true); - let mode = 'fixture'; - page.on('request', (req) => { - if (!req.url().includes('/api/gnss-integrity')) { req.continue(); return; } - if (mode === 'fixture') { - req.respond({ status: 200, contentType: 'application/json', body: JSON.stringify(fixturePayload()) }); - } else { - req.respond({ status: 502, contentType: 'application/json', - body: JSON.stringify({ error: 'gnss_integrity_unavailable' }) }); - } - }); + // Stub the route in-page rather than with request interception: an + // intercepted page stalls Cesium's geometry workers, so ground rectangles + // never finish building and the screenshot would not show real behaviour. + await page.evaluateOnNewDocument((payload) => { + 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); + const ok = window.__gnssQaMode === 'fixture'; + const body = ok + ? { ...payload, fetchedAt: Date.now() } + : { error: 'gnss_integrity_unavailable' }; + return Promise.resolve( + new Response(JSON.stringify(body), { + status: ok ? 200 : 502, + headers: { 'Content-Type': 'application/json' }, + }), + ); + }; + }, fixturePayload()); await boot(page, { lon: 18.6, lat: 54.6, height: 450_000 }); const f = await enableAndSettle(page); - const band = (prefix) => f.legend.find(({ label }) => label.startsWith(prefix))?.count; - record('BANDS: 2 cells shown, the 2-aircraft cell withheld', - f.stats.count === 2 && f.entities === 2, `cells=${f.stats.count} entities=${f.entities}`); - record('BANDS: 5/10 degraded is high, 1/3 degraded is low', - band('Over') === 1 && band('Under') === 1 && band('2') === 0, JSON.stringify(f.legend)); - record('BANDS: stale snapshot is reported', f.stats.stale === true, `stale=${f.stats.stale}`); - await page.waitForFunction(() => window.__godsEyeView.viewer.scene.globe.tilesLoaded, - { timeout: 60000 }).catch(() => {}); - await sleep(2000); - await page.screenshot({ path: path.join(SHOTS_DIR, 'gnss-synthetic-bands.png') }); + const band = (prefix) => + f.legend.find(({ label }) => label.startsWith(prefix))?.count; + record( + 'BANDS: 4 cells shown, the 2-aircraft cell withheld', + f.stats.count === 4 && f.entities === 4, + `cells=${f.stats.count} entities=${f.entities}`, + ); + record( + 'BANDS: 5/10 is high, 2/30 is medium, 1/3 and 0/4 are low', + band('Over') === 1 && band('2') === 1 && band('Under') === 2, + JSON.stringify(f.legend), + ); + record( + 'BANDS: stale snapshot is reported', + f.stats.stale === true, + `stale=${f.stats.stale}`, + ); + const bandsBuilt = await waitForBuiltCells(page); + record( + 'BANDS: every cell rectangle is built on a parked camera', + bandsBuilt.built, + bandsBuilt.detail, + ); + await shoot(page, 'synthetic-bands'); // ── (iii) FAILURE ───────────────────────────────────────────────────── - console.log('\n(iii) FAILURE — /api/gnss-integrity intercepted to 502...'); - mode = 'error'; + console.log('\n(iii) FAILURE — /api/gnss-integrity stubbed to 502...'); + await page.evaluate(() => { + window.__gnssQaMode = 'error'; + }); const e = await enableAndSettle(page, { refresh: true, timeoutS: 1 }); - record('FAILURE: error surfaced, cells kept', - Boolean(e.stats.error) && e.stats.count === 2 && e.entities === 2, - `error=${JSON.stringify(e.stats.error)} cells=${e.stats.count}`); + record( + 'FAILURE: error surfaced, cells kept', + Boolean(e.stats.error) && e.stats.count === 4 && e.entities === 4, + `error=${JSON.stringify(e.stats.error)} cells=${e.stats.count}`, + ); + await shoot(page, 'failure'); // ── (iv) DISABLE ────────────────────────────────────────────────────── console.log('\n(iv) DISABLE...'); @@ -192,7 +379,12 @@ async function main() { return window.__godsEyeView.viewer.dataSources.getByName(id)[0]?.show; }, LAYER_ID); record('DISABLE: data source hidden', shown === false, `show=${shown}`); - record('no uncaught browser errors', errors.length === 0, errors.slice(0, 3).join(' | ')); + await shoot(page, 'disabled'); + record( + 'no uncaught browser errors', + errors.length === 0, + errors.slice(0, 3).join(' | '), + ); } catch (err) { console.error('\x1b[31mHarness error:\x1b[0m', err); exitCode = 3; @@ -209,4 +401,7 @@ async function main() { process.exit(exitCode || (fail > 0 ? 1 : 0)); } -main().catch((e) => { console.error(e); process.exit(3); }); +main().catch((e) => { + console.error(e); + process.exit(3); +}); diff --git a/server/providers/gnssIntegrity.js b/server/providers/gnssIntegrity.js index a275940f2..d0a96a98b 100644 --- a/server/providers/gnssIntegrity.js +++ b/server/providers/gnssIntegrity.js @@ -1,25 +1,46 @@ import { normalizeGnssAircraft } from '../../src/layers/gnss/records.js'; +import { adsbLolFallbackAnchor } from './aircraft/opensky.js'; import { coalesceProxyRequest, readResponseJsonCapped } from './common/http.js'; -import { requiredFiniteQueryNumber } from './common/query.js'; import { makeRateLimiter, clientKey } from './common/rate-limit.js'; // adsb.lol regional snapshot (keyless, ODbL). Only the integrity fields the // GNSS layer needs leave this proxy, so the browser never sees the full feed. +// This is a separate read from the flights fallback on purpose: that point +// cache keeps only the fields the flights layer renders (no nic / nac_p), is +// keyed on 0.25° anchors and lives for 12 s, while integrity cells need +// whole-degree anchors held for a minute. const RADIUS_NM = 250; const CACHE_MS = 60_000; const CACHE_MAX = 64; +/** Cooldown after a 429 or 5xx when upstream sends no usable Retry-After. */ const COOLDOWN_MS = 60_000; +/** Bounds for an upstream-supplied Retry-After. */ +const COOLDOWN_MIN_MS = 5_000; +const COOLDOWN_MAX_MS = 120_000; const MAX_RESPONSE_BYTES = 8 * 1024 * 1024; const TIMEOUT_MS = 10_000; /** Snap a view anchor to a whole degree so nearby views share one upstream read. */ export function gnssIntegrityAnchor(url) { - const params = new URL(url || '/', 'http://localhost').searchParams; - const lat = requiredFiniteQueryNumber(params, 'lat'); - const lon = requiredFiniteQueryNumber(params, 'lon'); - if (!Number.isFinite(lat) || lat < -90 || lat > 90) return null; - if (!Number.isFinite(lon) || lon < -180 || lon > 180) return null; - return { lat: Math.round(lat), lon: Math.round(lon) }; + const anchor = adsbLolFallbackAnchor({ url }); + if (!anchor) return null; + return { + lat: Math.round(anchor.latitude), + lon: Math.round(anchor.longitude), + }; +} + +/** Cooldown (ms) an upstream 429 earns, honouring Retry-After when sane. */ +export function gnssRetryCooldownMs(raw, nowMs) { + const clamp = (ms) => + Math.min(COOLDOWN_MAX_MS, Math.max(COOLDOWN_MIN_MS, ms)); + if (raw) { + const seconds = Number(raw); + if (Number.isFinite(seconds) && seconds > 0) return clamp(seconds * 1000); + const at = Date.parse(raw); + if (Number.isFinite(at) && at > nowMs) return clamp(at - nowMs); + } + return COOLDOWN_MS; } /** Fixed-origin, bounded adsb.lol integrity route for dev and preview. */ @@ -50,12 +71,12 @@ export function gnssIntegrityProxy({ if (!response.ok) { await response.body?.cancel(); if (response.status === 429) { - const retryS = Number(response.headers.get('retry-after')); cooldownUntil = now() + - (Number.isFinite(retryS) && retryS > 0 - ? Math.min(retryS * 1000, 10 * COOLDOWN_MS) - : COOLDOWN_MS); + gnssRetryCooldownMs(response.headers.get('retry-after'), now()); + throw Object.assign(new Error('upstream_rate_limited'), { + status: 429, + }); } throw new Error('upstream_unavailable'); } @@ -95,13 +116,13 @@ export function gnssIntegrityProxy({ } async function handler(req, res) { - const json = (status, value, stale = false) => { + const json = (status, value, stale = false, retryAfterS = 60) => { if (res.destroyed) return; res.writeHead(status, { 'Content-Type': 'application/json', 'Cache-Control': 'no-store', ...(status === 405 ? { Allow: 'GET' } : {}), - ...(status === 429 ? { 'Retry-After': '60' } : {}), + ...(status === 429 ? { 'Retry-After': String(retryAfterS) } : {}), ...(stale ? { 'X-Data-Stale': 'true' } : {}), }); res.end(JSON.stringify(value)); @@ -117,9 +138,20 @@ export function gnssIntegrityProxy({ const { value, stale } = await acquire(anchor); json(200, stale ? { ...value, stale: true } : value, stale); } catch (error) { - json(error.status === 429 ? 429 : 502, { - error: 'gnss_integrity_unavailable', - }); + if (error.status === 429) { + // Tell the browser how long the shared upstream cooldown has left. + const retryAfterS = Math.max( + 1, + Math.ceil((cooldownUntil - now()) / 1000), + ); + return json( + 429, + { error: 'gnss_integrity_rate_limited' }, + false, + retryAfterS, + ); + } + json(502, { error: 'gnss_integrity_unavailable' }); } } diff --git a/src/app/constructCatalog.js b/src/app/constructCatalog.js index 832f0ef9e..a9c9ca3d1 100644 --- a/src/app/constructCatalog.js +++ b/src/app/constructCatalog.js @@ -121,7 +121,9 @@ export function createApplicationCatalog({ createApplicationFirePerimeters({ source: sources['fire-perimeters'], }), - createApplicationGnssInterference(), + createApplicationGnssInterference({ + source: sources['gnss-interference'], + }), createApplicationAlpr({ surface, source: sources.alpr }), createApplicationStreetLevel({ surface, diff --git a/src/app/layers/gnss.js b/src/app/layers/gnss.js index 49c74c268..5c1b38916 100644 --- a/src/app/layers/gnss.js +++ b/src/app/layers/gnss.js @@ -1,12 +1,6 @@ -import { - createAdsbGnssSource, - createGnssInterferenceLayer, -} from '../../layers/gnss/index.js'; +import { createGnssInterferenceLayer } from '../../layers/gnss/index.js'; /** Wire the adsb.lol GNSS interference layer into the application catalog. */ -export function createApplicationGnssInterference(options = {}) { - return createGnssInterferenceLayer({ - source: createAdsbGnssSource(), - ...options, - }); +export function createApplicationGnssInterference(options) { + return createGnssInterferenceLayer(options); } diff --git a/src/data/gnssIntegrityProxy.test.mjs b/src/data/gnssIntegrityProxy.test.mjs index 17370a3f6..5575e64ce 100644 --- a/src/data/gnssIntegrityProxy.test.mjs +++ b/src/data/gnssIntegrityProxy.test.mjs @@ -3,6 +3,7 @@ import assert from 'node:assert/strict'; import { gnssIntegrityAnchor, gnssIntegrityProxy, + gnssRetryCooldownMs, } from '../../server/providers/gnssIntegrity.js'; const snapshot = { @@ -154,19 +155,81 @@ test('an upstream 429 starts a cooldown with no further upstream calls', async ( calls += 1; return new Response('slow down', { status: 429, - headers: { 'retry-after': '120' }, + headers: { 'retry-after': '90' }, }); }, }); - assert.equal((await request()).status, 502); + const first = await request(); + assert.equal(first.status, 429); + assert.equal(first.headers['Retry-After'], '90'); + clock = 30_500; const cooling = await request('/?lat=1&lon=1'); assert.equal(cooling.status, 429); + // The browser is told the remaining shared cooldown, rounded up. + assert.equal(cooling.headers['Retry-After'], '60'); assert.equal(calls, 1); - clock = 121_000; + clock = 91_000; await request('/?lat=2&lon=2'); assert.equal(calls, 2); }); +test('Retry-After accepts seconds or an HTTP date, clamped to 5 s – 120 s', () => { + const nowMs = Date.parse('2026-09-29T12:00:00Z'); + assert.equal(gnssRetryCooldownMs('30', nowMs), 30_000); + assert.equal(gnssRetryCooldownMs('1', nowMs), 5_000); + assert.equal(gnssRetryCooldownMs('3600', nowMs), 120_000); + assert.equal( + gnssRetryCooldownMs('Tue, 29 Sep 2026 12:00:45 GMT', nowMs), + 45_000, + ); + // Garbage, a past date or no header fall back to the default minute. + assert.equal(gnssRetryCooldownMs('soon', nowMs), 60_000); + assert.equal( + gnssRetryCooldownMs('Tue, 29 Sep 2026 11:00:00 GMT', nowMs), + 60_000, + ); + assert.equal(gnssRetryCooldownMs(null, nowMs), 60_000); +}); + +test('a cached anchor keeps serving its last snapshot as stale during a cooldown', async () => { + let clock = 0; + let limited = false; + let calls = 0; + const request = install({ + now: () => clock, + fetchImpl: async () => { + calls += 1; + return limited + ? new Response('slow down', { status: 429 }) + : Response.json(snapshot); + }, + }); + await request(); + limited = true; + clock = 61_000; + // Another anchor trips the shared cooldown… + assert.equal((await request('/?lat=5&lon=5')).status, 429); + clock = 62_000; + // …and the cached anchor is answered from cache without an upstream call. + const stale = await request(); + assert.equal(stale.status, 200); + assert.equal(stale.body.stale, true); + assert.equal(stale.body.rows.length, 1); + assert.equal(calls, 2); +}); + +test('an oversized upstream body is refused as 502 without being parsed', async () => { + const request = install({ + fetchImpl: async () => + new Response('{}', { + headers: { 'content-length': String(9 * 1024 * 1024) }, + }), + }); + const res = await request(); + assert.equal(res.status, 502); + assert.deepEqual(res.body, { error: 'gnss_integrity_unavailable' }); +}); + test('the route rejects other methods, paths, bad anchors, a malformed feed and floods', async () => { const request = install({ fetchImpl: async () => Response.json({ nope: true }), diff --git a/src/layers/gnss/index.js b/src/layers/gnss/index.js index 7dec290db..2308b0aae 100644 --- a/src/layers/gnss/index.js +++ b/src/layers/gnss/index.js @@ -1,6 +1,7 @@ import * as Cesium from 'cesium'; import { GNSS_LEVEL_COLORS, + GNSS_MIN_AIRCRAFT, GNSS_WINDOW_MS, accumulateGnssObservations, binGnssCells, @@ -8,7 +9,8 @@ import { export * from './records.js'; export { createAdsbGnssSource } from './source.js'; -const FILL_ALPHA = Object.freeze({ low: 0.14, medium: 0.32, high: 0.45 }); +/** Low stays visible over land so coverage reads apart from no data. */ +const FILL_ALPHA = Object.freeze({ low: 0.3, medium: 0.42, high: 0.55 }); const LEVEL_LABELS = Object.freeze({ low: 'Under 2% degraded', medium: '2–10% degraded', @@ -56,13 +58,9 @@ export function createGnssInterferenceLayer({ const _observations = new Map(); function render() { + // Only the id and band change what is drawn; counts alone do not. const signature = JSON.stringify( - _cells.map(({ id, level, aircraft, degraded }) => [ - id, - level, - aircraft, - degraded, - ]), + _cells.map(({ id, level }) => [id, level]), ); if (signature === _signature) return; _signature = signature; @@ -122,7 +120,10 @@ export function createGnssInterferenceLayer({ async update() { if (!_enabled || !_dataSource) return false; const anchor = viewAnchor(_viewer); - if (!anchor) return false; + // No anchor yet (camera still settling) is not a failure: returning + // false would make the manager reject the enable. The next interval + // retries. + if (!anchor) return true; _request?.abort(); const request = new AbortController(); _request = request; @@ -137,7 +138,7 @@ export function createGnssInterferenceLayer({ }); _cells = binGnssCells(_observations); render(); - _stale = snapshot.stale; + _stale = snapshot.stale === true; _lastUpdate = now(); _lastError = null; return true; @@ -178,8 +179,7 @@ export function createGnssInterferenceLayer({ count: _cells.filter((cell) => cell.level === level).length, ...(index === 0 ? { - blurb: - 'Share of ADS-B aircraft reporting low navigation integrity (NIC/NACp) over the last 30 minutes, around the views you visit. An inference of jamming or spoofing, not a detection.', + blurb: `Share of ADS-B aircraft reporting low navigation integrity (NIC/NACp) over the last 30 minutes, around the views you visit. Cells with fewer than ${GNSS_MIN_AIRCRAFT} aircraft are not drawn, so blank areas mean no data, not clean GNSS. An inference of jamming or spoofing, not a detection.`, } : {}), })), @@ -192,6 +192,8 @@ export function createGnssInterferenceLayer({ lastUpdate: _lastUpdate, error: _lastError, stale: _stale, + // 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 0b579089f..099a470f4 100644 --- a/src/layers/gnss/ownership.test.mjs +++ b/src/layers/gnss/ownership.test.mjs @@ -88,6 +88,7 @@ test('an enabled refresh bins the snapshot into coloured ground cells', async () [1, 0, 0], ); assert.match(legend[0].blurb, /not a detection/); + assert.match(legend[0].blurb, /fewer than 3 aircraft are not drawn/); }); test('a failed refresh keeps the last cells and reports the error', async () => { @@ -153,9 +154,28 @@ test('no view anchor means no request, and destroy releases the data source', as { anchor: null }, ); layer.enable(); - assert.equal(await layer.update(), false); + assert.equal( + await layer.update(), + true, + 'a settling camera is not a failure', + ); assert.equal(calls, 0); + assert.equal(layer.getStats().error, null); layer.destroy(); assert.equal(sources.length, 0); assert.equal(layer.getStats().count, 0); }); + +const threeCells = () => ({ + rows: [row('a1', 50.1, true), row('a2', 50.1, true), row('a3', 50.2, false)], +}); + +test('an identical snapshot leaves the drawn entities untouched', async () => { + const { layer, sources } = harness(async () => threeCells()); + layer.enable(); + await layer.update(); + const [first] = sources[0].entities.values; + await layer.update(); + assert.equal(sources[0].entities.values.length, 1); + assert.equal(sources[0].entities.values[0], first); +}); diff --git a/src/layers/gnss/records.js b/src/layers/gnss/records.js index 4cd6de8e8..25353fd28 100644 --- a/src/layers/gnss/records.js +++ b/src/layers/gnss/records.js @@ -5,17 +5,20 @@ * their own position: NIC (integrity containment radius) and NACp (position * accuracy). Both collapse when the GNSS receiver is jammed or spoofed, so a * cluster of aircraft reporting low values in one area is a well-known public - * proxy for interference (the method popularized by gpsjam.org and the - * readsb/tar1090 `gpsOkBefore` flag). It is an inference, not a detection: + * proxy for interference (popularized by gpsjam.org, whose published cell + * formula and bands this module uses, and by the readsb/tar1090 `gpsOkBefore` + * flag). gpsjam does not publish its per-aircraft thresholds; here an aircraft + * is degraded when it reports below the 14 CFR 91.227(c) ADS-B Out minima + * (NIC < 7 or NACp < 8). It is an inference, not a detection: * avionics faults and old transponders also report low values, which is why * cells need several aircraft and the first degraded aircraft is discounted. */ /** Direct 1090ES ADS-B only: MLAT, TIS-B and ADS-R positions are not self-reported. */ const QUALIFYING_TYPES = new Set(['adsb_icao']); -/** NIC below 7 means a containment radius above 0.2 NM (RNP 0.3 and worse). */ +/** NIC below 7 means a containment radius of 0.2 NM or more (below the 91.227 ADS-B Out minimum). */ export const GNSS_NIC_THRESHOLD = 7; -/** NACp below 8 means an estimated position uncertainty of 93 m or more. */ +/** NACp below 8 means an estimated position uncertainty of 93 m or more (below the 91.227 minimum). */ export const GNSS_NACP_THRESHOLD = 8; /** Positions older than this are not current evidence of the local sky. */ const MAX_POSITION_AGE_S = 60; @@ -54,6 +57,9 @@ export function normalizeGnssAircraft(payload) { if (!finite(nic) || !finite(nacp)) continue; if (finite(aircraft.seen_pos) && aircraft.seen_pos > MAX_POSITION_AGE_S) continue; + // readsb sets gpsOkBefore when an aircraft's integrity collapses and keeps + // it for roughly 15 minutes after GPS recovers, so it can outlast the + // current NIC/NACp; the rolling window treats that as recent evidence. const gpsLost = aircraft.gpsOkBefore != null; seen.add(hex); rows.push({ @@ -95,6 +101,7 @@ export function accumulateGnssObservations( { windowMs = GNSS_WINDOW_MS, cellDeg = GNSS_CELL_DEG } = {}, ) { for (const row of rows || []) { + if (!Number.isFinite(row?.lat) || !Number.isFinite(row?.lon)) continue; const cell = gnssCellKey(row.lat, row.lon, cellDeg); const key = `${row.hex}|${cell}`; const previous = store.get(key); @@ -111,7 +118,7 @@ export function accumulateGnssObservations( } /** - * Interference level of a cell, using gpsjam.org's published formula and + * Interference level of a cell, using gpsjam.org's published cell formula and * bands: the first degraded aircraft is discounted to limit false positives, * then under 2% is low, 2–10% medium and over 10% high. */ diff --git a/src/layers/gnss/records.test.mjs b/src/layers/gnss/records.test.mjs index b49a053d3..9188fd839 100644 --- a/src/layers/gnss/records.test.mjs +++ b/src/layers/gnss/records.test.mjs @@ -32,8 +32,11 @@ test('normalization keeps only airborne self-reported ADS-B v1/v2 positions', () aircraft({ hex: '000005', seen_pos: 120 }), aircraft({ hex: '000006', nic: undefined }), aircraft({ hex: '000007', lat: 95 }), + aircraft({ hex: '000008', version: undefined }), aircraft({ hex: 'not-hex' }), aircraft(), + // A non-ICAO (`~`) address still self-reports over 1090ES ADS-B. + aircraft({ hex: '~a1b2c3' }), null, ], }); @@ -47,6 +50,15 @@ test('normalization keeps only airborne self-reported ADS-B v1/v2 positions', () gpsLost: false, degraded: false, }, + { + hex: '~a1b2c3', + lat: 50.1, + lon: 19.9, + nic: 8, + nacp: 10, + gpsLost: false, + degraded: false, + }, ]); assert.equal(normalizeGnssAircraft({}), null); assert.equal(normalizeGnssAircraft(null), null); @@ -77,9 +89,11 @@ test('cell keys are stable half-degree bins, clamped at the poles and antimeridi assert.notEqual(gnssCellKey(50.1, 19.9), gnssCellKey(50.6, 19.9)); assert.equal(gnssCellKey(90, 180), gnssCellKey(89.9, 179.9)); assert.equal(gnssCellKey(-90, -180), '0:0'); + // Just south-west of the origin falls in the last cell before 0°, not cell 0. + assert.equal(gnssCellKey(-0.1, -0.1), '179:359'); }); -test('interference level follows the published gpsjam formula and bands', () => { +test('interference level follows the gpsjam.org published cell formula and bands', () => { assert.deepEqual(gnssInterferenceLevel(10, 0), { percent: 0, level: 'low' }); // One degraded aircraft is always discounted. assert.deepEqual(gnssInterferenceLevel(10, 1), { percent: 0, level: 'low' }); @@ -116,6 +130,20 @@ test('observations count each aircraft once per cell and expire after the window assert.deepEqual(binGnssCells(store), []); }); +test('rows without finite coordinates never key a cell', () => { + const store = new Map(); + accumulateGnssObservations( + store, + [ + { hex: 'n1', lat: Number.NaN, lon: 10.1, degraded: true }, + { hex: 'n2', lat: 10.1, lon: '10.1', degraded: true }, + null, + ], + 0, + ); + assert.equal(store.size, 0); +}); + test('cells below the minimum aircraft count are withheld', () => { const store = new Map(); accumulateGnssObservations( diff --git a/src/sources/reference.js b/src/sources/reference.js index 4bdf7e9ac..40870c1f7 100644 --- a/src/sources/reference.js +++ b/src/sources/reference.js @@ -1,6 +1,7 @@ import { createUsgsEarthquakeSource } from '../layers/earthquakes/source.js'; import { createWfigsPerimeterSource } from '../layers/perimeters/source.js'; import { createBundledCableSource } from '../layers/submarineCables/bundledSource.js'; +import { createAdsbGnssSource } from '../layers/gnss/source.js'; /** Construct the existing reference feeds independently of application setup. */ export function createReferenceSources() { @@ -8,5 +9,6 @@ export function createReferenceSources() { earthquakes: createUsgsEarthquakeSource(), 'fire-perimeters': createWfigsPerimeterSource(), cables: createBundledCableSource(), + 'gnss-interference': createAdsbGnssSource(), }; } diff --git a/src/sources/reference.test.mjs b/src/sources/reference.test.mjs index 902a72c83..fea1cb4f2 100644 --- a/src/sources/reference.test.mjs +++ b/src/sources/reference.test.mjs @@ -16,12 +16,15 @@ test('reference factories retain compatibility without starting acquisition or s 'earthquakes', 'fire-perimeters', 'cables', + 'gnss-interference', ]); assert.notEqual(first.earthquakes, second.earthquakes); assert.notEqual(first['fire-perimeters'], second['fire-perimeters']); assert.notEqual(first.cables, second.cables); + assert.notEqual(first['gnss-interference'], second['gnss-interference']); assert.equal(typeof first.earthquakes.getSnapshot, 'function'); assert.equal(typeof first['fire-perimeters'].getSnapshot, 'function'); assert.equal(typeof first.cables.fetch, 'function'); + assert.equal(typeof first['gnss-interference'].getSnapshot, 'function'); assert.equal(requests, 0); }); diff --git a/src/voice/gevActions.test.mjs b/src/voice/gevActions.test.mjs index 3d0e35b3e..b8d911767 100644 --- a/src/voice/gevActions.test.mjs +++ b/src/voice/gevActions.test.mjs @@ -3263,6 +3263,30 @@ test('ALPR common names toggle only the registered camera layer through the norm } }); +test('GNSS interference common names toggle only the GNSS layer through the normal voice action', async () => { + globalThis.window = globalThis.window || { clearTimeout, setTimeout, requestIdleCallback: null }; + const viewer = { clock: { onTick: { addEventListener: () => () => {} } }, + scene: { canvas: { addEventListener() {}, removeEventListener() {} } }, + camera: { moveEnd: { addEventListener() {} } } }; + const calls = []; + let enabled = false; + const dataManager = { + layers: new Map([['gnss-interference', { module: {} }]]), + getAll: () => [{ id: 'gnss-interference', name: 'GNSS Interference' }], + isEnabled: () => enabled, + setEnabled: async (id, value) => { calls.push([id, value]); enabled = value; return true; }, + }; + const runner = createGevActionRunner({ viewer, styleManager: {}, dataManager }); + for (const alias of ['gnss-interference', 'gnss interference', 'gps interference', 'gps jamming', 'gnss']) { + for (const value of [true, false]) { + const result = await runner('set_layer_visibility', { layerId: alias, enabled: value }); + assert.equal(result.ok, true); + assert.equal(result.layerId, 'gnss-interference'); + assert.deepEqual(calls.at(-1), ['gnss-interference', value]); + } + } +}); + test('Local ADS-B common names toggle only the receiver layer through the normal voice action', async () => { globalThis.window = globalThis.window || { clearTimeout, setTimeout, requestIdleCallback: null }; const viewer = { clock: { onTick: { addEventListener: () => () => {} } },