diff --git a/CHANGELOG.md b/CHANGELOG.md
index c9d15d897..c0623687c 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -155,6 +155,14 @@
largest cities) and `CCTV_VEGVESEN_VIDEO=0` keeps stills only. Attributed
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
+ 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.
+
## [0.2.1] — 2026-10-02 — God's Eye View through Your Agent (with security fixes and other features)
- Ship each bundled data pack once. The region, marine, admin-boundary,
diff --git a/DATA_SOURCES.md b/DATA_SOURCES.md
index 040e53afc..dd7dd37d9 100644
--- a/DATA_SOURCES.md
+++ b/DATA_SOURCES.md
@@ -25,7 +25,7 @@ How to read this:
| **OpenStreetMap** | Map and place data (roads, military areas and names, ALPR, place search, outlines); via vector tiles, hourly ALPR extract, bundled data, Nominatim and optional self-configured Overpass | [ODbL 1.0](https://opendatacommons.org/licenses/odbl/1-0/) | © OpenStreetMap contributors |
| **Google Map Tiles API** (Photorealistic 3D Tiles) + Places/Geocoding | The 3D globe, voice scene context, and on-demand nearby installation search | Google Maps Platform ToS (proprietary, your own key + billing) | "Google" / "Google Maps" logo — **shown in-app**, required |
| **OpenSky Network** | Primary worldwide live-flight snapshot | Non-commercial research/education license | Schäfer et al., _"Bringing Up OpenSky"_, IPSN 2014 + opensky-network.org |
-| **adsb.lol point API** | Bounded live-flight fallback when OpenSky has no usable snapshot | ODbL 1.0 | adsb.lol contributors; `api.adsb.lol/v2/lat/{lat}/lon/{lon}/dist/{radius}` |
+| **adsb.lol point API** | Bounded live-flight fallback when OpenSky has no usable snapshot; GNSS Interference layer (per-aircraft NIC/NACp integrity only, via `/api/gnss-integrity`, 60 s server cache) | ODbL 1.0 | adsb.lol contributors; `api.adsb.lol/v2/lat/{lat}/lon/{lon}/dist/{radius}` |
| **adsb.lol** | Military flights + aircraft traces | ODbL 1.0 | "adsb.lol" (ODbL) |
| **adsbdb** (`api.adsbdb.com`) | Aircraft type, model name and registration by ICAO hex for enriched flights; airline and origin/destination airports for the tracked flight's callsign | No licence or rate limit is published for the service. Its own credits: aircraft data from PlaneBase, ICAO-to-N-number conversion by Guillaume Michel, and flight route data that "is the work of David Taylor, Edinburgh and Jim Mason, Glasgow, and may not be copied, published, or incorporated into other databases without the explicit permission of David J Taylor, Edinburgh" | "adsbdb — api.adsbdb.com" (courtesy); route data credited to David Taylor, Edinburgh, and Jim Mason, Glasgow |
| **AISStream.io** | Live vessels (AIS) | Free, beta, no formal ToS; AIS is a public broadcast | "AISStream.io" (courtesy) |
@@ -78,6 +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.
- **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 a9aeeddbc..b1abd9f15 100644
--- a/docs/CURRENT-STATE.md
+++ b/docs/CURRENT-STATE.md
@@ -3069,8 +3069,11 @@ 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) |
| 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.
+
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.
Street Level is a collapsible right-rail panel (`#street-level-panel`, layer
diff --git a/package.json b/package.json
index 4b2829313..d5c900cbd 100644
--- a/package.json
+++ b/package.json
@@ -165,6 +165,7 @@
"./layers/recent-imagery": "./src/layers/recentImagery/index.js",
"./layers/recent-imagery/source": "./src/layers/recentImagery/source.js",
"./layers/perimeters": "./src/layers/perimeters/index.js",
+ "./layers/gnss": "./src/layers/gnss/index.js",
"./sources/firms-csv": "./src/data/firmsCsv.js",
"./layers/firms": "./src/layers/firms/index.js",
"./layers/satellites": "./src/layers/satellites/index.js",
@@ -238,6 +239,7 @@
"./layers/cctv/source": "./src/layers/cctv/source.js",
"./layers/earthquakes/source": "./src/layers/earthquakes/source.js",
"./layers/perimeters/source": "./src/layers/perimeters/source.js",
+ "./layers/gnss/source": "./src/layers/gnss/source.js",
"./layers/firms/source": "./src/layers/firms/source.js",
"./layers/installations/source": "./src/layers/installations/source.js",
"./layers/launches/source": "./src/layers/launches/source.js",
diff --git a/scripts/format-scope.json b/scripts/format-scope.json
index 4973bd484..f87618780 100644
--- a/scripts/format-scope.json
+++ b/scripts/format-scope.json
@@ -826,6 +826,15 @@
"server/providers/firePerimeters.js",
"src/data/cyclonesProxy.test.mjs",
"src/data/firePerimetersProxy.test.mjs",
+ "server/providers/gnssIntegrity.js",
+ "src/data/gnssIntegrityProxy.test.mjs",
+ "src/app/layers/gnss.js",
+ "src/layers/gnss/index.js",
+ "src/layers/gnss/records.js",
+ "src/layers/gnss/source.js",
+ "src/layers/gnss/records.test.mjs",
+ "src/layers/gnss/source.test.mjs",
+ "src/layers/gnss/ownership.test.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 fc7308ab5..18e8b4e3c 100644
--- a/scripts/package-boundaries.json
+++ b/scripts/package-boundaries.json
@@ -2029,6 +2029,10 @@
"src/layers/perimeters/records.js",
"src/layers/perimeters/cards.js",
"src/layers/perimeters/inciweb.js",
+ "src/app/layers/gnss.js",
+ "src/layers/gnss/index.js",
+ "src/layers/gnss/source.js",
+ "src/layers/gnss/records.js",
"src/layers/cctv/sourcePolicy.js",
"src/sources/reference.js",
"src/search/nominatim.js",
@@ -2613,6 +2617,10 @@
"src/layers/perimeters/records.js",
"src/layers/perimeters/cards.js",
"src/layers/perimeters/inciweb.js",
+ "src/app/layers/gnss.js",
+ "src/layers/gnss/index.js",
+ "src/layers/gnss/source.js",
+ "src/layers/gnss/records.js",
"src/layers/cctv/sourcePolicy.js",
"src/search/nominatim.js",
"src/nominatimGeocode.js",
@@ -3324,6 +3332,21 @@
],
"external": ["cesium"]
},
+ "gnss": {
+ "exports": ["./layers/gnss"],
+ "modules": [
+ "src/layers/gnss/index.js",
+ "src/sources/httpBody.js",
+ "src/layers/gnss/source.js",
+ "src/layers/gnss/records.js"
+ ],
+ "external": ["cesium"]
+ },
+ "gnss-source": {
+ "exports": ["./layers/gnss/source"],
+ "modules": ["src/sources/httpBody.js", "src/layers/gnss/source.js"],
+ "external": []
+ },
"perimeters-source": {
"exports": ["./layers/perimeters/source"],
"modules": [
diff --git a/scripts/qa-gnss.mjs b/scripts/qa-gnss.mjs
new file mode 100644
index 000000000..81b430f13
--- /dev/null
+++ b/scripts/qa-gnss.mjs
@@ -0,0 +1,212 @@
+#!/usr/bin/env node
+/**
+ * qa-gnss.mjs — headless proof for the GNSS Interference layer.
+ *
+ * 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.
+ *
+ * (i) LIVE — over the southern Baltic the layer settles with cells,
+ * no error, and legend band counts that sum to the cell count.
+ * (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
+ * 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).
+ *
+ * Run: node scripts/qa-gnss.mjs --url http://localhost:4173
+ * Exits non-zero on any FAIL. Does not commit anything.
+ */
+
+import puppeteer from 'puppeteer';
+import fs from 'node:fs';
+import path from 'node:path';
+import { fileURLToPath } from 'node:url';
+
+const __dirname = path.dirname(fileURLToPath(import.meta.url));
+const REPO_ROOT = path.resolve(__dirname, '..');
+const SHOTS_DIR = path.join(REPO_ROOT, 'qa-shots');
+
+const argv = process.argv.slice(2);
+const getOpt = (name, dflt) => {
+ const i = argv.indexOf(name);
+ return i >= 0 && argv[i + 1] ? argv[i + 1] : dflt;
+};
+const APP_URL = getOpt('--url', 'http://localhost:4173');
+const HEADFUL = argv.includes('--headful');
+const LAYER_ID = 'gnss-interference';
+
+const results = [];
+function record(name, ok, detail) {
+ results.push({ name, ok, detail });
+ const tag = ok ? '\x1b[32mPASS\x1b[0m' : '\x1b[31mFAIL\x1b[0m';
+ console.log(` [${tag}] ${name}${detail ? ` — ${detail}` : ''}`);
+}
+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.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);
+}
+
+/** 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));
+ }
+ 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);
+}
+
+const fixtureRow = (hex, lat, lon, 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). */
+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 };
+}
+
+async function main() {
+ console.log('\nGNSS Interference proof (qa-gnss)');
+ console.log(` App URL : ${APP_URL}\n`);
+ try {
+ 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`);
+ process.exit(2);
+ }
+ fs.mkdirSync(SHOTS_DIR, { recursive: true });
+ 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 } : {}),
+ 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',
+ ],
+ });
+
+ let exitCode = 0;
+ const errors = [];
+ try {
+ // ── (i) LIVE ──────────────────────────────────────────────────────────
+ console.log('(i) LIVE — adsb.lol integrity snapshot over 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 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') });
+ await live.close();
+
+ // ── (ii) BANDS ────────────────────────────────────────────────────────
+ 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' }) });
+ }
+ });
+ 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') });
+
+ // ── (iii) FAILURE ─────────────────────────────────────────────────────
+ console.log('\n(iii) FAILURE — /api/gnss-integrity intercepted to 502...');
+ mode = '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}`);
+
+ // ── (iv) DISABLE ──────────────────────────────────────────────────────
+ console.log('\n(iv) DISABLE...');
+ const shown = await page.evaluate(async (id) => {
+ await window.__godsEyeView.dataManager.setEnabled(id, false);
+ 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(' | '));
+ } catch (err) {
+ console.error('\x1b[31mHarness error:\x1b[0m', err);
+ exitCode = 3;
+ } finally {
+ await browser.close();
+ }
+
+ const pass = results.filter((r) => r.ok).length;
+ const fail = results.length - pass;
+ console.log('\n' + '─'.repeat(60));
+ console.log(` RESULT: ${pass} passed, ${fail} failed`);
+ console.log(` Shots : ${SHOTS_DIR}/gnss-*.png`);
+ console.log('─'.repeat(60) + '\n');
+ process.exit(exitCode || (fail > 0 ? 1 : 0));
+}
+
+main().catch((e) => { console.error(e); process.exit(3); });
diff --git a/server/providers/gnssIntegrity.js b/server/providers/gnssIntegrity.js
new file mode 100644
index 000000000..a275940f2
--- /dev/null
+++ b/server/providers/gnssIntegrity.js
@@ -0,0 +1,135 @@
+import { normalizeGnssAircraft } from '../../src/layers/gnss/records.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.
+const RADIUS_NM = 250;
+const CACHE_MS = 60_000;
+const CACHE_MAX = 64;
+const COOLDOWN_MS = 60_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) };
+}
+
+/** Fixed-origin, bounded adsb.lol integrity route for dev and preview. */
+export function gnssIntegrityProxy({
+ fetchImpl = (...args) => globalThis.fetch(...args),
+ now = () => Date.now(),
+} = {}) {
+ const cache = new Map();
+ const inFlight = new Map();
+ const allow = makeRateLimiter({ windowMs: 60_000, max: 30, globalMax: 600 });
+ let cooldownUntil = 0;
+
+ async function load({ lat, lon }) {
+ if (now() < cooldownUntil)
+ throw Object.assign(new Error('cooling_down'), { status: 429 });
+ const signal = AbortSignal.timeout(TIMEOUT_MS);
+ const response = await fetchImpl(
+ `https://api.adsb.lol/v2/lat/${lat}/lon/${lon}/dist/${RADIUS_NM}`,
+ {
+ signal,
+ redirect: 'error',
+ headers: {
+ Accept: 'application/json',
+ 'User-Agent': 'gods-eye-view-gnss-integrity/1.0',
+ },
+ },
+ );
+ 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);
+ }
+ throw new Error('upstream_unavailable');
+ }
+ const payload = await readResponseJsonCapped(
+ response,
+ MAX_RESPONSE_BYTES,
+ signal,
+ );
+ const rows = normalizeGnssAircraft(payload);
+ if (!rows) throw new Error('invalid_snapshot');
+ return {
+ fetchedAt: now(),
+ anchor: { lat, lon },
+ radiusNm: RADIUS_NM,
+ rows,
+ };
+ }
+
+ async function acquire(anchor) {
+ const key = `${anchor.lat},${anchor.lon}`;
+ const previous = cache.get(key);
+ if (previous && now() - previous.fetchedAt < CACHE_MS)
+ return { value: previous, stale: false };
+ try {
+ const { promise } = coalesceProxyRequest(inFlight, key, async () => {
+ const value = await load(anchor);
+ cache.delete(key);
+ cache.set(key, value);
+ while (cache.size > CACHE_MAX) cache.delete(cache.keys().next().value);
+ return value;
+ });
+ return { value: await promise, stale: false };
+ } catch (error) {
+ if (previous) return { value: previous, stale: true };
+ throw error;
+ }
+ }
+
+ async function handler(req, res) {
+ const json = (status, value, stale = false) => {
+ if (res.destroyed) return;
+ res.writeHead(status, {
+ 'Content-Type': 'application/json',
+ 'Cache-Control': 'no-store',
+ ...(status === 405 ? { Allow: 'GET' } : {}),
+ ...(status === 429 ? { 'Retry-After': '60' } : {}),
+ ...(stale ? { 'X-Data-Stale': 'true' } : {}),
+ });
+ res.end(JSON.stringify(value));
+ };
+ if (req.method !== 'GET') return json(405, { error: 'method_not_allowed' });
+ const path = (req.url || '/').split('?')[0];
+ if (path !== '/' && path !== '')
+ return json(404, { error: 'unknown_route' });
+ const anchor = gnssIntegrityAnchor(req.url);
+ if (!anchor) return json(400, { error: 'invalid_anchor' });
+ if (!allow(clientKey(req))) return json(429, { error: 'rate_limited' });
+ try {
+ 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',
+ });
+ }
+ }
+
+ return {
+ name: 'gnss-integrity',
+ configureServer({ middlewares }) {
+ middlewares.use('/api/gnss-integrity', handler);
+ },
+ configurePreviewServer({ middlewares }) {
+ middlewares.use('/api/gnss-integrity', handler);
+ },
+ };
+}
diff --git a/server/providers/local.js b/server/providers/local.js
index 9acbade31..9fb6ae9c0 100644
--- a/server/providers/local.js
+++ b/server/providers/local.js
@@ -24,6 +24,7 @@ import { mapillaryProxy } from './mapillary.js';
import { keySetupEndpoint } from '../standalone/key-setup.js';
import { weatherProxy } from './weather.js';
import { firePerimetersProxy } from './firePerimeters.js';
+import { gnssIntegrityProxy } from './gnssIntegrity.js';
import { cycloneProxy } from './cyclones.js';
import { windProxy } from './wind.js';
@@ -60,6 +61,7 @@ function localProviderPlugins({ realtime } = {}) {
cycloneProxy(),
mapillaryProxy(),
firePerimetersProxy(),
+ gnssIntegrityProxy(),
keySetupEndpoint(),
];
}
diff --git a/src/app/constructCatalog.js b/src/app/constructCatalog.js
index 80315fe85..832f0ef9e 100644
--- a/src/app/constructCatalog.js
+++ b/src/app/constructCatalog.js
@@ -25,6 +25,7 @@ import { createApplicationAwareness } from './layers/militaryAwareness.js';
import { createApplicationFirms } from './layers/firms.js';
import { createApplicationEarthquakes } from './layers/earthquakes.js';
import { createApplicationFirePerimeters } from './layers/perimeters.js';
+import { createApplicationGnssInterference } from './layers/gnss.js';
import { createApplicationCables } from './layers/submarineCables.js';
import { createApplicationStreetLevel } from './layers/streetLevel.js';
import { createInfrastructureLayers } from '../data/infrastructure.js';
@@ -120,6 +121,7 @@ export function createApplicationCatalog({
createApplicationFirePerimeters({
source: sources['fire-perimeters'],
}),
+ createApplicationGnssInterference(),
createApplicationAlpr({ surface, source: sources.alpr }),
createApplicationStreetLevel({
surface,
diff --git a/src/app/constructCatalog.test.mjs b/src/app/constructCatalog.test.mjs
index f42e82c7c..f1e4c8c4b 100644
--- a/src/app/constructCatalog.test.mjs
+++ b/src/app/constructCatalog.test.mjs
@@ -60,6 +60,7 @@ test('catalogs construct distinct layers and classification from their supplied
'2026-09-21T12:00:00.000Z',
);
assert.ok(first.get('fire-perimeters'));
+ assert.ok(first.get('gnss-interference'));
assert.ok(first.get('transit'));
const order = first.layers.map(({ id }) => id);
assert.deepEqual(
diff --git a/src/app/layers/gnss.js b/src/app/layers/gnss.js
new file mode 100644
index 000000000..49c74c268
--- /dev/null
+++ b/src/app/layers/gnss.js
@@ -0,0 +1,12 @@
+import {
+ createAdsbGnssSource,
+ 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,
+ });
+}
diff --git a/src/data/dataCredits.js b/src/data/dataCredits.js
index 2967f0a2b..bdfbb4cde 100644
--- a/src/data/dataCredits.js
+++ b/src/data/dataCredits.js
@@ -104,7 +104,7 @@ export const DATA_CREDITS = [
{
key: 'adsblol',
html:
- 'Military flights, aircraft traces & bounded regional flight fallback: ' +
+ 'Military flights, aircraft traces, bounded regional flight fallback & GNSS interference: ' +
'adsb.lol ' +
'(ODbL 1.0)',
},
diff --git a/src/data/gnssIntegrityProxy.test.mjs b/src/data/gnssIntegrityProxy.test.mjs
new file mode 100644
index 000000000..17370a3f6
--- /dev/null
+++ b/src/data/gnssIntegrityProxy.test.mjs
@@ -0,0 +1,183 @@
+import test from 'node:test';
+import assert from 'node:assert/strict';
+import {
+ gnssIntegrityAnchor,
+ gnssIntegrityProxy,
+} from '../../server/providers/gnssIntegrity.js';
+
+const snapshot = {
+ now: 1,
+ ac: [
+ {
+ hex: '4ca7b5',
+ type: 'adsb_icao',
+ version: 2,
+ alt_baro: 36000,
+ lat: 50.1,
+ lon: 19.9,
+ nic: 0,
+ nac_p: 0,
+ seen_pos: 1,
+ flight: 'RYR1AB ',
+ r: 'EI-ABC',
+ },
+ { hex: '000001', type: 'mlat', lat: 50, lon: 20, nic: 0, nac_p: 0 },
+ ],
+};
+
+function install(options = {}, hook = 'configureServer') {
+ let handler;
+ const plugin = gnssIntegrityProxy(options);
+ assert.equal(plugin.name, 'gnss-integrity');
+ plugin[hook]({
+ middlewares: {
+ use(path, callback) {
+ assert.equal(path, '/api/gnss-integrity');
+ handler = callback;
+ },
+ },
+ });
+ return async (url = '/?lat=50&lon=20', method = 'GET', peer = 'local') => {
+ const res = {
+ writeHead(status, headers) {
+ this.status = status;
+ this.headers = headers;
+ },
+ end(body) {
+ this.body = JSON.parse(body);
+ },
+ };
+ await handler({ url, method, socket: { remoteAddress: peer } }, res);
+ return res;
+ };
+}
+
+test('anchors snap to whole degrees and reject missing or out-of-range values', () => {
+ assert.deepEqual(gnssIntegrityAnchor('/?lat=50.44&lon=19.51'), {
+ lat: 50,
+ lon: 20,
+ });
+ assert.equal(gnssIntegrityAnchor('/?lat=91&lon=0'), null);
+ assert.equal(gnssIntegrityAnchor('/?lat=0&lon=-181'), null);
+ assert.equal(gnssIntegrityAnchor('/?lat=0'), null);
+ assert.equal(gnssIntegrityAnchor('/?lat=abc&lon=1'), null);
+});
+
+for (const hook of ['configureServer', 'configurePreviewServer']) {
+ test(`${hook}: fetches the fixed adsb.lol origin and returns only integrity facts`, async () => {
+ const calls = [];
+ const request = install(
+ {
+ now: () => 1234,
+ fetchImpl: async (url, options) => {
+ calls.push(url);
+ assert.ok(options.signal instanceof AbortSignal);
+ assert.equal(options.redirect, 'error');
+ return Response.json(snapshot);
+ },
+ },
+ hook,
+ );
+ const res = await request(
+ '/?lat=50.2&lon=19.8&url=https://invalid.example',
+ );
+ assert.equal(res.status, 200);
+ assert.deepEqual(calls, ['https://api.adsb.lol/v2/lat/50/lon/20/dist/250']);
+ assert.deepEqual(res.body, {
+ fetchedAt: 1234,
+ anchor: { lat: 50, lon: 20 },
+ radiusNm: 250,
+ rows: [
+ {
+ hex: '4ca7b5',
+ lat: 50.1,
+ lon: 19.9,
+ nic: 0,
+ nacp: 0,
+ gpsLost: false,
+ degraded: true,
+ },
+ ],
+ });
+ assert.equal(res.headers['Cache-Control'], 'no-store');
+ });
+}
+
+test('snapshots are cached per anchor and concurrent reads share one upstream call', async () => {
+ let clock = 0;
+ let calls = 0;
+ const request = install({
+ now: () => clock,
+ fetchImpl: async () => {
+ calls += 1;
+ return Response.json(snapshot);
+ },
+ });
+ await Promise.all([request(), request('/?lat=50.3&lon=20.2')]);
+ assert.equal(calls, 1);
+ clock = 59_000;
+ await request();
+ assert.equal(calls, 1);
+ clock = 61_000;
+ await request();
+ assert.equal(calls, 2);
+ await request('/?lat=10&lon=10');
+ assert.equal(calls, 3);
+});
+
+test('an upstream failure serves the last snapshot as stale, else 502', async () => {
+ let clock = 0;
+ let fail = false;
+ const request = install({
+ now: () => clock,
+ fetchImpl: async () =>
+ fail ? new Response('down', { status: 503 }) : Response.json(snapshot),
+ });
+ await request();
+ fail = true;
+ clock = 120_000;
+ const stale = await request();
+ assert.equal(stale.status, 200);
+ assert.equal(stale.body.stale, true);
+ assert.equal(stale.headers['X-Data-Stale'], 'true');
+ const missing = await request('/?lat=-10&lon=-10');
+ assert.equal(missing.status, 502);
+ assert.deepEqual(missing.body, { error: 'gnss_integrity_unavailable' });
+});
+
+test('an upstream 429 starts a cooldown with no further upstream calls', async () => {
+ let clock = 0;
+ let calls = 0;
+ const request = install({
+ now: () => clock,
+ fetchImpl: async () => {
+ calls += 1;
+ return new Response('slow down', {
+ status: 429,
+ headers: { 'retry-after': '120' },
+ });
+ },
+ });
+ assert.equal((await request()).status, 502);
+ const cooling = await request('/?lat=1&lon=1');
+ assert.equal(cooling.status, 429);
+ assert.equal(calls, 1);
+ clock = 121_000;
+ await request('/?lat=2&lon=2');
+ assert.equal(calls, 2);
+});
+
+test('the route rejects other methods, paths, bad anchors, a malformed feed and floods', async () => {
+ const request = install({
+ fetchImpl: async () => Response.json({ nope: true }),
+ });
+ assert.equal((await request('/', 'POST')).status, 405);
+ assert.equal((await request('/other?lat=1&lon=1')).status, 404);
+ assert.equal((await request('/?lat=1')).status, 400);
+ assert.equal((await request('/?lat=1&lon=1')).status, 502);
+ let limited = 0;
+ for (let i = 0; i < 40; i += 1)
+ if ((await request('/?lat=1&lon=1', 'GET', 'flood')).status === 429)
+ limited += 1;
+ assert.ok(limited > 0);
+});
diff --git a/src/data/layerState.js b/src/data/layerState.js
index 373eac20d..468e9d117 100644
--- a/src/data/layerState.js
+++ b/src/data/layerState.js
@@ -607,6 +607,11 @@ export const LAYER_STATE_REGISTRY = Object.freeze([
disposition: 'enabled+options',
optionOwner: 'flights',
}),
+ Object.freeze({
+ id: 'gnss-interference',
+ token: '0',
+ disposition: 'enabled-only',
+ }),
Object.freeze({ id: 'local-dams', token: 'q', disposition: 'enabled-only' }),
Object.freeze({
id: 'local-datacenters',
diff --git a/src/data/layerState.test.mjs b/src/data/layerState.test.mjs
index 2b94d81d7..9aa0d6e1d 100644
--- a/src/data/layerState.test.mjs
+++ b/src/data/layerState.test.mjs
@@ -2639,6 +2639,23 @@ test('the recent-imagery split is share-link only: never stored locally, and a s
);
});
+test('GNSS interference uses digit 0 without colliding with other digit tokens', () => {
+ const decoded = decodeLayerStateParams(new URLSearchParams('v=2&l=0.2.1'));
+ assert.deepEqual(decoded.enabledLayerIds, [
+ 'fire-perimeters',
+ 'gnss-interference',
+ 'recent-imagery',
+ ]);
+ assert.equal(
+ LAYER_STATE_REGISTRY.find(({ id }) => id === 'gnss-interference').token,
+ '0',
+ );
+ assert.deepEqual(
+ decodeLayerStateParams(new URLSearchParams(encode(decoded))),
+ decoded,
+ );
+});
+
test('fire perimeters uses digit 2 without colliding with wind or recent imagery', () => {
const decoded = decodeLayerStateParams(new URLSearchParams('v=2&l=2.k.1'));
assert.deepEqual(decoded.enabledLayerIds, [
diff --git a/src/data/layerStateTokenLedger.test.mjs b/src/data/layerStateTokenLedger.test.mjs
index 5a02d85cf..29a5d5eeb 100644
--- a/src/data/layerStateTokenLedger.test.mjs
+++ b/src/data/layerStateTokenLedger.test.mjs
@@ -131,7 +131,7 @@ test('reservation ledger is complete, pinned, and rejects duplicate or malformed
assert.equal(Object.keys(LAYER_STATE_TOKEN_RESERVATIONS).length, 29);
assert.deepEqual(
{ ...LAYER_STATE_TOKEN_RESERVATIONS },
- { ...LEGACY_LAYER_STATE_TOKENS, 'street-level': '0' },
+ { ...LEGACY_LAYER_STATE_TOKENS, 'street-level': '0', 'gnss-interference': '0' },
);
assert.throws(
() => parseLayerStateTokenReservations(reservationRows.slice(1)),
diff --git a/src/data/layerStateTokenReservations.json b/src/data/layerStateTokenReservations.json
index eb49eed27..b859de677 100644
--- a/src/data/layerStateTokenReservations.json
+++ b/src/data/layerStateTokenReservations.json
@@ -9,6 +9,7 @@
["earthquakes", "e"],
["fire-perimeters", "2"],
["flights", "f"],
+ ["gnss-interference", "0"],
["local-dams", "q"],
["local-datacenters", "d"],
["local-firms", "w"],
diff --git a/src/layers/gnss/index.js b/src/layers/gnss/index.js
new file mode 100644
index 000000000..7dec290db
--- /dev/null
+++ b/src/layers/gnss/index.js
@@ -0,0 +1,200 @@
+import * as Cesium from 'cesium';
+import {
+ GNSS_LEVEL_COLORS,
+ GNSS_WINDOW_MS,
+ accumulateGnssObservations,
+ binGnssCells,
+} from './records.js';
+export * from './records.js';
+export { createAdsbGnssSource } from './source.js';
+
+const FILL_ALPHA = Object.freeze({ low: 0.14, medium: 0.32, high: 0.45 });
+const LEVEL_LABELS = Object.freeze({
+ low: 'Under 2% degraded',
+ medium: '2–10% degraded',
+ high: 'Over 10% degraded',
+});
+
+/** 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;
+ if (!carto) return null;
+ const latitude = Cesium.Math.toDegrees(carto.latitude);
+ const longitude = Cesium.Math.toDegrees(carto.longitude);
+ return Number.isFinite(latitude) && Number.isFinite(longitude)
+ ? { latitude, longitude }
+ : null;
+}
+
+/**
+ * Own one GNSS-interference display: rolling integrity observations around
+ * the views the user visits, binned into cells coloured by the share of
+ * aircraft reporting degraded navigation integrity.
+ */
+export function createGnssInterferenceLayer({
+ source,
+ viewAnchor = defaultGnssViewAnchor,
+ now = () => Date.now(),
+ windowMs = GNSS_WINDOW_MS,
+} = {}) {
+ if (typeof source?.getSnapshot !== 'function')
+ throw new TypeError('GNSS interference requires a snapshot source');
+ let _viewer = null;
+ let _dataSource = null;
+ let _request = null;
+ let _enabled = false;
+ let _cells = [];
+ let _signature = null;
+ let _lastUpdate = null;
+ let _lastError = null;
+ let _stale = false;
+ const _observations = new Map();
+
+ function render() {
+ const signature = JSON.stringify(
+ _cells.map(({ id, level, aircraft, degraded }) => [
+ id,
+ level,
+ aircraft,
+ degraded,
+ ]),
+ );
+ if (signature === _signature) return;
+ _signature = signature;
+ _dataSource.entities.removeAll();
+ for (const cell of _cells) {
+ const color = Cesium.Color.fromCssColorString(
+ GNSS_LEVEL_COLORS[cell.level],
+ );
+ _dataSource.entities.add(
+ new Cesium.Entity({
+ id: `gnss-interference:${cell.id}`,
+ rectangle: {
+ coordinates: Cesium.Rectangle.fromDegrees(
+ cell.west,
+ cell.south,
+ cell.east,
+ cell.north,
+ ),
+ material: new Cesium.ColorMaterialProperty(
+ color.withAlpha(FILL_ALPHA[cell.level]),
+ ),
+ classificationType: Cesium.ClassificationType.BOTH,
+ },
+ }),
+ );
+ }
+ }
+
+ const layer = {
+ id: 'gnss-interference',
+ name: 'GNSS Interference',
+ icon: '📡',
+ source: 'adsb.lol',
+ updateInterval: 60000,
+
+ init(viewer) {
+ if (_viewer)
+ throw new Error('GNSS interference layer is already initialized');
+ _viewer = viewer;
+ _dataSource = new Cesium.CustomDataSource('gnss-interference');
+ _dataSource.show = false;
+ viewer.dataSources.add(_dataSource);
+ },
+
+ enable() {
+ _enabled = true;
+ if (_dataSource) _dataSource.show = true;
+ },
+
+ disable() {
+ _request?.abort();
+ _request = null;
+ _enabled = false;
+ if (_dataSource) _dataSource.show = false;
+ },
+
+ async update() {
+ if (!_enabled || !_dataSource) return false;
+ const anchor = viewAnchor(_viewer);
+ if (!anchor) return false;
+ _request?.abort();
+ const request = new AbortController();
+ _request = request;
+ try {
+ const snapshot = await source.getSnapshot(anchor, {
+ signal: request.signal,
+ });
+ if (request.signal.aborted || _request !== request || !_enabled)
+ return false;
+ accumulateGnssObservations(_observations, snapshot.rows, now(), {
+ windowMs,
+ });
+ _cells = binGnssCells(_observations);
+ render();
+ _stale = snapshot.stale;
+ _lastUpdate = now();
+ _lastError = null;
+ return true;
+ } catch (e) {
+ if (request.signal.aborted || _request !== request || !_enabled)
+ return false;
+ console.warn('[Data:GNSS] Fetch error:', e);
+ _lastError = e?.message || 'GNSS integrity source unavailable';
+ return false;
+ } finally {
+ if (_request === request) _request = null;
+ }
+ },
+
+ destroy(viewer = _viewer) {
+ _request?.abort();
+ _request = null;
+ _enabled = false;
+ _observations.clear();
+ _cells = [];
+ _signature = null;
+ if (_dataSource) {
+ viewer.dataSources.remove(_dataSource, true);
+ _dataSource = null;
+ }
+ _viewer = null;
+ _lastUpdate = null;
+ _lastError = null;
+ _stale = false;
+ },
+
+ getRowControls() {
+ return {
+ chips: [],
+ legend: ['high', 'medium', 'low'].map((level, index) => ({
+ label: LEVEL_LABELS[level],
+ color: GNSS_LEVEL_COLORS[level],
+ 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.',
+ }
+ : {}),
+ })),
+ };
+ },
+
+ getStats() {
+ return {
+ count: _cells.length,
+ lastUpdate: _lastUpdate,
+ error: _lastError,
+ stale: _stale,
+ fallback: false,
+ };
+ },
+ };
+ return layer;
+}
diff --git a/src/layers/gnss/ownership.test.mjs b/src/layers/gnss/ownership.test.mjs
new file mode 100644
index 000000000..0b579089f
--- /dev/null
+++ b/src/layers/gnss/ownership.test.mjs
@@ -0,0 +1,161 @@
+import assert from 'node:assert/strict';
+import test from 'node:test';
+import { createGnssInterferenceLayer } from './index.js';
+
+const row = (hex, lat, degraded) => ({
+ hex,
+ lat,
+ lon: 30.2,
+ nic: degraded ? 0 : 8,
+ nacp: degraded ? 0 : 10,
+ gpsLost: false,
+ degraded,
+});
+
+function harness(
+ getSnapshot,
+ { anchor = { latitude: 50, longitude: 30 } } = {},
+) {
+ const sources = [];
+ const viewer = {
+ dataSources: {
+ add(value) {
+ sources.push(value);
+ },
+ remove(value) {
+ sources.splice(sources.indexOf(value), 1);
+ },
+ },
+ };
+ let clock = 1_000;
+ const anchors = [];
+ const layer = createGnssInterferenceLayer({
+ source: { getSnapshot },
+ viewAnchor: () => {
+ anchors.push(anchor);
+ return anchor;
+ },
+ now: () => clock,
+ });
+ layer.init(viewer);
+ return {
+ layer,
+ sources,
+ anchors,
+ advance(ms) {
+ clock += ms;
+ },
+ };
+}
+
+test('the layer requires a snapshot source and cannot be initialized twice', () => {
+ assert.throws(() => createGnssInterferenceLayer({}), TypeError);
+ const { layer } = harness(async () => ({ rows: [] }));
+ assert.throws(() => layer.init({ dataSources: { add() {} } }), /already/);
+});
+
+test('an enabled refresh bins the snapshot into coloured ground cells', async () => {
+ const { layer, sources, anchors } = harness(async (anchor, { signal }) => {
+ assert.deepEqual(anchor, { latitude: 50, longitude: 30 });
+ assert.ok(signal instanceof AbortSignal);
+ return {
+ rows: [
+ row('a1', 50.1, true),
+ row('a2', 50.1, true),
+ row('a3', 50.2, false),
+ row('a4', 50.3, false),
+ row('b1', 55.1, false),
+ ],
+ stale: false,
+ };
+ });
+ assert.equal(await layer.update(), false, 'disabled layers do not fetch');
+ assert.equal(anchors.length, 0);
+ layer.enable();
+ assert.equal(sources[0].show, true);
+ assert.equal(await layer.update(), true);
+ const entities = sources[0].entities.values;
+ assert.equal(entities.length, 1, 'the one-aircraft cell is withheld');
+ assert.equal(entities[0].id, 'gnss-interference:280:420');
+ assert.ok(entities[0].rectangle);
+ const stats = layer.getStats();
+ assert.equal(stats.count, 1);
+ assert.equal(stats.error, null);
+ assert.equal(stats.fallback, false);
+ const { legend } = layer.getRowControls();
+ assert.deepEqual(
+ legend.map(({ count }) => count),
+ [1, 0, 0],
+ );
+ assert.match(legend[0].blurb, /not a detection/);
+});
+
+test('a failed refresh keeps the last cells and reports the error', async () => {
+ let fail = false;
+ const { layer, sources } = harness(async () => {
+ if (fail) throw new Error('adsb.lol HTTP 502');
+ return {
+ rows: [
+ row('a1', 1.1, false),
+ row('a2', 1.2, false),
+ row('a3', 1.3, false),
+ ],
+ stale: true,
+ };
+ });
+ layer.enable();
+ await layer.update();
+ assert.equal(layer.getStats().stale, true);
+ fail = true;
+ assert.equal(await layer.update(), false);
+ assert.equal(sources[0].entities.values.length, 1);
+ assert.equal(layer.getStats().error, 'adsb.lol HTTP 502');
+ assert.equal(layer.getStats().count, 1);
+});
+
+test('observations expire with the window, and disable aborts the request in flight', async () => {
+ let pending;
+ const { layer, sources, advance } = harness(
+ (anchor, { signal }) =>
+ new Promise((resolve, reject) => {
+ pending = { resolve };
+ signal.addEventListener('abort', () => reject(signal.reason));
+ }),
+ );
+ layer.enable();
+ let result = layer.update();
+ pending.resolve({
+ rows: [row('a1', 1.1, false), row('a2', 1.2, false), row('a3', 1.3, false)],
+ });
+ assert.equal(await result, true);
+ assert.equal(layer.getStats().count, 1);
+
+ advance(31 * 60_000);
+ result = layer.update();
+ pending.resolve({ rows: [] });
+ assert.equal(await result, true);
+ assert.equal(layer.getStats().count, 0);
+ assert.equal(sources[0].entities.values.length, 0);
+
+ result = layer.update();
+ layer.disable();
+ assert.equal(await result, false);
+ assert.equal(sources[0].show, false);
+});
+
+test('no view anchor means no request, and destroy releases the data source', async () => {
+ let calls = 0;
+ const { layer, sources } = harness(
+ async () => {
+ calls += 1;
+ return { rows: [] };
+ },
+ { anchor: null },
+ );
+ layer.enable();
+ assert.equal(await layer.update(), false);
+ assert.equal(calls, 0);
+ layer.destroy();
+ assert.equal(sources.length, 0);
+ assert.equal(layer.getStats().count, 0);
+});
diff --git a/src/layers/gnss/records.js b/src/layers/gnss/records.js
new file mode 100644
index 000000000..4cd6de8e8
--- /dev/null
+++ b/src/layers/gnss/records.js
@@ -0,0 +1,171 @@
+/**
+ * GNSS interference inferred from ADS-B navigation integrity.
+ *
+ * Aircraft broadcasting DO-260 version 1/2 ADS-B report how much they trust
+ * 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:
+ * 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). */
+export const GNSS_NIC_THRESHOLD = 7;
+/** NACp below 8 means an estimated position uncertainty of 93 m or more. */
+export const GNSS_NACP_THRESHOLD = 8;
+/** Positions older than this are not current evidence of the local sky. */
+const MAX_POSITION_AGE_S = 60;
+/** Cell edge in degrees: roughly 55 km north-south, similar in area to an H3 res-4 cell. */
+export const GNSS_CELL_DEG = 0.5;
+/** Fewer aircraft than this in a cell cannot distinguish one faulty transponder from interference. */
+export const GNSS_MIN_AIRCRAFT = 3;
+/** Rolling window over which aircraft seen in a cell are counted. */
+export const GNSS_WINDOW_MS = 30 * 60_000;
+
+const HEX_PATTERN = /^~?[0-9a-f]{6}$/;
+
+const finite = (value) => typeof value === 'number' && Number.isFinite(value);
+
+/**
+ * Reduce one adsb.lol / readsb `aircraft.json`-style payload to the integrity
+ * facts the layer needs. Returns null when the payload has no aircraft array.
+ *
+ * @param {{ac?: object[], now?: number}} payload - readsb-style snapshot.
+ * @returns {{hex: string, lat: number, lon: number, nic: number, nacp: number, gpsLost: boolean, degraded: boolean}[]|null}
+ */
+export function normalizeGnssAircraft(payload) {
+ if (!Array.isArray(payload?.ac)) return null;
+ const rows = [];
+ const seen = new Set();
+ for (const aircraft of payload.ac) {
+ if (!aircraft || typeof aircraft !== 'object') continue;
+ const hex = typeof aircraft.hex === 'string' ? aircraft.hex : '';
+ if (!HEX_PATTERN.test(hex) || seen.has(hex)) continue;
+ if (!QUALIFYING_TYPES.has(aircraft.type)) continue;
+ if (!finite(aircraft.version) || aircraft.version < 1) continue;
+ if (aircraft.alt_baro === 'ground' || !finite(aircraft.alt_baro)) continue;
+ const { lat, lon, nic, nac_p: nacp } = aircraft;
+ if (!finite(lat) || Math.abs(lat) > 90) continue;
+ if (!finite(lon) || Math.abs(lon) > 180) continue;
+ if (!finite(nic) || !finite(nacp)) continue;
+ if (finite(aircraft.seen_pos) && aircraft.seen_pos > MAX_POSITION_AGE_S)
+ continue;
+ const gpsLost = aircraft.gpsOkBefore != null;
+ seen.add(hex);
+ rows.push({
+ hex,
+ lat,
+ lon,
+ nic,
+ nacp,
+ gpsLost,
+ degraded:
+ gpsLost || nic < GNSS_NIC_THRESHOLD || nacp < GNSS_NACP_THRESHOLD,
+ });
+ }
+ return rows;
+}
+
+/** Stable key of the cell containing a position. */
+export function gnssCellKey(lat, lon, cellDeg = GNSS_CELL_DEG) {
+ const row = Math.floor((Math.min(lat, 89.999999) + 90) / cellDeg);
+ const col = Math.floor((Math.min(lon, 179.999999) + 180) / cellDeg);
+ return `${row}:${col}`;
+}
+
+/**
+ * Fold one snapshot into a rolling observation store. Each aircraft counts
+ * once per cell per window; a degraded report in the window keeps it degraded
+ * there, so an aircraft that loses GNSS mid-cell is not averaged away.
+ *
+ * @param {Map} store
+ * @param {ReturnType} rows
+ * @param {number} nowMs
+ * @param {{windowMs?: number, cellDeg?: number}} [options]
+ * @returns {Map} The same store, pruned to the window.
+ */
+export function accumulateGnssObservations(
+ store,
+ rows,
+ nowMs,
+ { windowMs = GNSS_WINDOW_MS, cellDeg = GNSS_CELL_DEG } = {},
+) {
+ for (const row of rows || []) {
+ const cell = gnssCellKey(row.lat, row.lon, cellDeg);
+ const key = `${row.hex}|${cell}`;
+ const previous = store.get(key);
+ store.set(key, {
+ cell,
+ degraded: row.degraded || (previous?.degraded ?? false),
+ seenAt: nowMs,
+ });
+ }
+ for (const [key, entry] of store) {
+ if (nowMs - entry.seenAt > windowMs) store.delete(key);
+ }
+ return store;
+}
+
+/**
+ * Interference level of a cell, using gpsjam.org's published 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.
+ */
+export function gnssInterferenceLevel(total, degraded) {
+ const percent = total > 0 ? (100 * Math.max(0, degraded - 1)) / total : 0;
+ return {
+ percent,
+ level: percent > 10 ? 'high' : percent >= 2 ? 'medium' : 'low',
+ };
+}
+
+/**
+ * Aggregate the observation store into displayable cells.
+ *
+ * @returns {{id: string, south: number, west: number, north: number, east: number, lat: number, lon: number, aircraft: number, degraded: number, percentDegraded: number, level: 'low'|'medium'|'high'}[]}
+ */
+export function binGnssCells(
+ store,
+ { cellDeg = GNSS_CELL_DEG, minAircraft = GNSS_MIN_AIRCRAFT } = {},
+) {
+ const tallies = new Map();
+ for (const { cell, degraded } of store.values()) {
+ const tally = tallies.get(cell) || { aircraft: 0, degraded: 0 };
+ tally.aircraft += 1;
+ if (degraded) tally.degraded += 1;
+ tallies.set(cell, tally);
+ }
+ const cells = [];
+ for (const [id, { aircraft, degraded }] of tallies) {
+ if (aircraft < minAircraft) continue;
+ const [row, col] = id.split(':').map(Number);
+ const south = row * cellDeg - 90;
+ const west = col * cellDeg - 180;
+ const { percent, level } = gnssInterferenceLevel(aircraft, degraded);
+ cells.push({
+ id,
+ south,
+ west,
+ north: south + cellDeg,
+ east: west + cellDeg,
+ lat: south + cellDeg / 2,
+ lon: west + cellDeg / 2,
+ aircraft,
+ degraded,
+ percentDegraded: Math.round(percent * 10) / 10,
+ level,
+ });
+ }
+ return cells.sort((a, b) => a.id.localeCompare(b.id));
+}
+
+/** Legend colors: gpsjam's green / amber / red convention. */
+export const GNSS_LEVEL_COLORS = Object.freeze({
+ low: '#3fb950',
+ medium: '#e3b341',
+ high: '#f85149',
+});
diff --git a/src/layers/gnss/records.test.mjs b/src/layers/gnss/records.test.mjs
new file mode 100644
index 000000000..b49a053d3
--- /dev/null
+++ b/src/layers/gnss/records.test.mjs
@@ -0,0 +1,133 @@
+import assert from 'node:assert/strict';
+import test from 'node:test';
+import {
+ accumulateGnssObservations,
+ binGnssCells,
+ gnssCellKey,
+ gnssInterferenceLevel,
+ normalizeGnssAircraft,
+} from './records.js';
+
+const aircraft = (overrides = {}) => ({
+ hex: '4ca7b5',
+ type: 'adsb_icao',
+ version: 2,
+ alt_baro: 36000,
+ lat: 50.1,
+ lon: 19.9,
+ nic: 8,
+ nac_p: 10,
+ seen_pos: 1.2,
+ ...overrides,
+});
+
+test('normalization keeps only airborne self-reported ADS-B v1/v2 positions', () => {
+ const rows = normalizeGnssAircraft({
+ ac: [
+ aircraft(),
+ aircraft({ hex: '000001', type: 'mlat', nic: 0 }),
+ aircraft({ hex: '000002', type: 'tisb_icao' }),
+ aircraft({ hex: '000003', version: 0 }),
+ aircraft({ hex: '000004', alt_baro: 'ground' }),
+ aircraft({ hex: '000005', seen_pos: 120 }),
+ aircraft({ hex: '000006', nic: undefined }),
+ aircraft({ hex: '000007', lat: 95 }),
+ aircraft({ hex: 'not-hex' }),
+ aircraft(),
+ null,
+ ],
+ });
+ assert.deepEqual(rows, [
+ {
+ hex: '4ca7b5',
+ lat: 50.1,
+ lon: 19.9,
+ nic: 8,
+ nacp: 10,
+ gpsLost: false,
+ degraded: false,
+ },
+ ]);
+ assert.equal(normalizeGnssAircraft({}), null);
+ assert.equal(normalizeGnssAircraft(null), null);
+});
+
+test('low NIC, low NACp or a readsb GPS-loss flag each mark an aircraft degraded', () => {
+ const rows = normalizeGnssAircraft({
+ ac: [
+ aircraft({ hex: 'a00001', nic: 6 }),
+ aircraft({ hex: 'a00002', nac_p: 7 }),
+ aircraft({ hex: 'a00003', gpsOkBefore: 1790690000.1 }),
+ aircraft({ hex: 'a00004', nic: 7, nac_p: 8 }),
+ ],
+ });
+ assert.deepEqual(
+ rows.map(({ hex, degraded, gpsLost }) => [hex, degraded, gpsLost]),
+ [
+ ['a00001', true, false],
+ ['a00002', true, false],
+ ['a00003', true, true],
+ ['a00004', false, false],
+ ],
+ );
+});
+
+test('cell keys are stable half-degree bins, clamped at the poles and antimeridian', () => {
+ assert.equal(gnssCellKey(50.1, 19.9), gnssCellKey(50.4, 19.6));
+ 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');
+});
+
+test('interference level follows the published gpsjam 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' });
+ assert.equal(gnssInterferenceLevel(50, 2).level, 'medium');
+ assert.equal(gnssInterferenceLevel(10, 2).level, 'medium');
+ assert.equal(gnssInterferenceLevel(10, 3).level, 'high');
+ assert.equal(gnssInterferenceLevel(0, 0).level, 'low');
+});
+
+test('observations count each aircraft once per cell and expire after the window', () => {
+ const store = new Map();
+ const at = (lat, hex, degraded) => ({ hex, lat, lon: 20.1, degraded });
+ accumulateGnssObservations(
+ store,
+ [at(50.1, 'a1', true), at(50.2, 'a2', false), at(50.3, 'a3', false)],
+ 0,
+ { windowMs: 1000 },
+ );
+ // a1 recovers inside the same cell: it stays degraded for the window.
+ accumulateGnssObservations(store, [at(50.2, 'a1', false)], 500, {
+ windowMs: 1000,
+ });
+ let [cell] = binGnssCells(store);
+ assert.equal(cell.aircraft, 3);
+ assert.equal(cell.degraded, 1);
+ assert.equal(cell.level, 'low');
+ assert.deepEqual(
+ [cell.south, cell.west, cell.north, cell.east],
+ [50, 20, 50.5, 20.5],
+ );
+
+ accumulateGnssObservations(store, [], 1200, { windowMs: 1000 });
+ assert.equal(store.size, 1);
+ assert.deepEqual(binGnssCells(store), []);
+});
+
+test('cells below the minimum aircraft count are withheld', () => {
+ const store = new Map();
+ accumulateGnssObservations(
+ store,
+ [
+ { hex: 'b1', lat: 10.1, lon: 10.1, degraded: true },
+ { hex: 'b2', lat: 10.2, lon: 10.2, degraded: true },
+ ],
+ 0,
+ );
+ assert.deepEqual(binGnssCells(store), []);
+ const [cell] = binGnssCells(store, { minAircraft: 2 });
+ assert.equal(cell.level, 'high');
+ assert.equal(cell.percentDegraded, 50);
+});
diff --git a/src/layers/gnss/source.js b/src/layers/gnss/source.js
new file mode 100644
index 000000000..4feba891e
--- /dev/null
+++ b/src/layers/gnss/source.js
@@ -0,0 +1,37 @@
+import { readResponseJsonCapped } from '../../sources/httpBody.js';
+
+/** Request integrity facts around a view anchor through the same-origin adsb.lol proxy. */
+export function createAdsbGnssSource({
+ fetchImpl = (...args) => globalThis.fetch(...args),
+} = {}) {
+ return {
+ async getSnapshot({ latitude, longitude } = {}, { signal } = {}) {
+ signal?.throwIfAborted();
+ if (!Number.isFinite(latitude) || !Number.isFinite(longitude))
+ throw new Error('GNSS snapshot needs a view anchor');
+ const params = new URLSearchParams({
+ lat: latitude.toFixed(2),
+ lon: longitude.toFixed(2),
+ });
+ const response = await fetchImpl(`/api/gnss-integrity?${params}`, {
+ signal,
+ });
+ if (!response.ok) throw new Error(`adsb.lol HTTP ${response.status}`);
+ const payload = await readResponseJsonCapped(
+ response,
+ 4 * 1024 * 1024,
+ signal,
+ );
+ signal?.throwIfAborted();
+ if (!Array.isArray(payload?.rows))
+ throw new Error('Malformed GNSS integrity snapshot');
+ return {
+ rows: payload.rows,
+ fetchedAt: Number.isFinite(payload.fetchedAt)
+ ? payload.fetchedAt
+ : null,
+ stale: payload.stale === true,
+ };
+ },
+ };
+}
diff --git a/src/layers/gnss/source.test.mjs b/src/layers/gnss/source.test.mjs
new file mode 100644
index 000000000..8aad9b988
--- /dev/null
+++ b/src/layers/gnss/source.test.mjs
@@ -0,0 +1,59 @@
+import assert from 'node:assert/strict';
+import test from 'node:test';
+import { createAdsbGnssSource } from './source.js';
+
+test('the source asks the same-origin proxy for the view anchor', async () => {
+ const calls = [];
+ const source = createAdsbGnssSource({
+ fetchImpl: async (url, options) => {
+ calls.push(url);
+ assert.ok(options.signal instanceof AbortSignal);
+ return Response.json({ fetchedAt: 5, rows: [{ hex: 'abc123' }] });
+ },
+ });
+ const snapshot = await source.getSnapshot(
+ { latitude: 50.06143, longitude: 19.93658 },
+ { signal: new AbortController().signal },
+ );
+ assert.deepEqual(calls, ['/api/gnss-integrity?lat=50.06&lon=19.94']);
+ assert.deepEqual(snapshot, {
+ rows: [{ hex: 'abc123' }],
+ fetchedAt: 5,
+ stale: false,
+ });
+});
+
+test('the source reports stale proxy data and rejects bad input or responses', async () => {
+ const reply = (body, init) => async () => Response.json(body, init);
+ const stale = await createAdsbGnssSource({
+ fetchImpl: reply({ rows: [], stale: true }),
+ }).getSnapshot({ latitude: 1, longitude: 2 });
+ assert.equal(stale.stale, true);
+ assert.equal(stale.fetchedAt, null);
+
+ await assert.rejects(
+ createAdsbGnssSource({ fetchImpl: reply({}) }).getSnapshot({}),
+ /view anchor/,
+ );
+ await assert.rejects(
+ createAdsbGnssSource({
+ fetchImpl: reply({ error: 'x' }, { status: 502 }),
+ }).getSnapshot({ latitude: 1, longitude: 2 }),
+ /HTTP 502/,
+ );
+ await assert.rejects(
+ createAdsbGnssSource({ fetchImpl: reply({ rows: 'no' }) }).getSnapshot({
+ latitude: 1,
+ longitude: 2,
+ }),
+ /Malformed/,
+ );
+ const controller = new AbortController();
+ controller.abort();
+ await assert.rejects(
+ createAdsbGnssSource({ fetchImpl: reply({ rows: [] }) }).getSnapshot(
+ { latitude: 1, longitude: 2 },
+ { signal: controller.signal },
+ ),
+ );
+});
diff --git a/src/tooling/previewServing.test.mjs b/src/tooling/previewServing.test.mjs
index d6eb80e20..04c24b92d 100644
--- a/src/tooling/previewServing.test.mjs
+++ b/src/tooling/previewServing.test.mjs
@@ -10,6 +10,7 @@ import { makeFixtureRoot } from './fixtureRoot.mjs';
test('data providers have both hooks; credential editing stays development-only', () => {
const plugins = localProviderPlugins();
assert.ok(plugins.some(({ name }) => name === 'fire-perimeters'));
+ assert.ok(plugins.some(({ name }) => name === 'gnss-integrity'));
for (const plugin of plugins) {
if (plugin.name === 'gev-key-setup') {
assert.equal(plugin.configurePreviewServer, undefined);
@@ -103,6 +104,7 @@ test('real dev and built-preview servers serve provider JSON and terminate unkno
['/api/adsbdb/type/invalid', 400],
['/api/firms/status', 200],
['/api/fire-perimeters/inciweb/publication/invalid', 400],
+ ['/api/gnss-integrity?lat=invalid', 400],
['/api/terrain/heights?points=invalid', 400],
['/api/overpass', 405],
['/api/overpass/status', 200],
diff --git a/src/ui/layerPanel.js b/src/ui/layerPanel.js
index 227cc41e1..c91771dca 100644
--- a/src/ui/layerPanel.js
+++ b/src/ui/layerPanel.js
@@ -49,7 +49,13 @@ const PANEL_GROUPS = [
},
{
label: 'Events',
- ids: ['rocket-launches', 'earthquakes', 'local-firms', 'fire-perimeters'],
+ ids: [
+ 'rocket-launches',
+ 'earthquakes',
+ 'local-firms',
+ 'fire-perimeters',
+ 'gnss-interference',
+ ],
},
{
label: 'Weather',