diff --git a/CHANGELOG.md b/CHANGELOG.md
index e77e598ba..53a71d87e 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -191,6 +191,19 @@
## [Unreleased]
+- Add a keyless Air quality layer to the Weather group, with Environment and
+ Climate Change Canada's AQHI as the first registered provider. Readings carry
+ `{ provider, scale, value, band }` so another network can be added without
+ inventing a shared numeric meaning across indices that do not share one; the
+ row and readout both state that coverage is Canada only. The WEATHER card
+ shows the band legend and the highest readings currently in view. AQHI is
+ reported as an integer from 1 with an open-ended "10+", so readings round to
+ that published form rather than the decimal precision the API returns.
+ Readings older than six hours are dropped, and the layer reports `stale`
+ through `getStats()` (also when every observation was dropped) so feed
+ provenance cannot describe an aged feed as live. Voice can switch it on and
+ query readings by value, band, station and observation time.
+
- Bound six upstream body reads (FIRMS CSV, CelesTrak TLE groups, adsb.lol
`/v2/mil`, TomTom flow tiles, OpenSky `states/all` and adsbdb lookups) with
streaming size caps that cancel the body at the cap or the route's deadline;
diff --git a/DATA_SOURCES.md b/DATA_SOURCES.md
index e983a972d..eeb5645fa 100644
--- a/DATA_SOURCES.md
+++ b/DATA_SOURCES.md
@@ -35,6 +35,7 @@ How to read this:
| **The Space Devs — Launch Library 2 v2.3** | Recent launch, payload, stage, and recovery metadata for Space Missions (30d) | [The Space Devs terms of use](https://github.com/TheSpaceDevs/Tutorials/blob/main/faqs/faq_TSD.md#terms-of-use): data may be used and shared in any form; avoid forwarding it without added value; attribution is encouraged (not mandatory). [Official API limits](https://ll.thespacedevs.com/docs/): 15 unauthenticated calls/hour; optional token | "Launch Library 2 — The Space Devs" (courtesy attribution) |
| **Esri World Imagery** (ArcGIS Online tile service) | The keyless satellite basemap — the default landing when no Google/ion credential is configured, and the "Esri Satellite" map stack | [Esri Master Agreement](https://www.esri.com/en-us/legal/terms/full-master-agreement): the public World Imagery service is usable in public-facing apps with attribution; no key is required for this classic endpoint, but Esri governs and can change access — an app at scale should review current ArcGIS Location Platform terms | "Powered by Esri — Source: Esri, Maxar, Earthstar Geographics, and the GIS User Community" (provider carries the service's own credit line) |
| **USGS** | Earthquakes | U.S. public domain | "Data courtesy of the U.S. Geological Survey" |
+| **Environment and Climate Change Canada** (MSC GeoMet OGC API) | Air quality (AQHI) — Canadian station observations | [ECCC Data Servers End-use Licence](https://eccc-msc.github.io/open-data/licence/readme_en/) (open, attribution required, no endorsement implied) | "Contains information licensed under the Environment and Climate Change Canada Data Servers End-use Licence" |
| **NASA GIBS** (WMTS tiles + Worldview Snapshots) and **NASA CMR** (granule catalog) | Recent Imagery layer: HLS S30/L30 (Sentinel-2, Landsat 8/9) and VIIRS true-colour tiles draped on the map, per-day thumbnails and PNG exports; the HLS granule catalog (dates, cloud, footprints) for a selected box | U.S. public domain (NASA data); keyless, browser-direct (CORS `*`); no bulk use — one 30-day catalog query per box, tiles bounded to the box, six tile requests in flight | "We acknowledge the use of imagery provided by services from NASA's Global Imagery Browse Services (GIBS), part of NASA's Earth Science Data and Information System (ESDIS)." — [gibs.earthdata.nasa.gov](https://gibs.earthdata.nasa.gov), [HLS](https://lpdaac.usgs.gov/products/hlss30v002/) |
| **NIFC WFIGS** (Wildland Fire Interagency Geospatial Services, ArcGIS feature service) | Fire Perimeters layer: current interagency wildfire incident perimeters, containment, and incident facts | U.S. public domain (interagency wildland-fire data published through the [NIFC Open Data portal](https://data-nifc.opendata.arcgis.com/)); fetched keyless through `/api/fire-perimeters` (5-minute server cache) with `exceededTransferLimit` paging | "Wildfire perimeters: National Interagency Fire Center (WFIGS)" |
| **InciWeb** (inciweb.wildfire.gov) | Per-incident "InciWeb ↗" card links to official incident pages (narratives, evacuation/closure notices) | U.S. government public incident information. Links are matched best-effort by incident name and state, with incident page origin and update times checked on selection. Same-origin `/api/fire-perimeters/inciweb` routes cache the catalog for 1 hour and publication metadata for 30 minutes; linked pages remain InciWeb content | "Incident information: InciWeb (inciweb.wildfire.gov)" |
@@ -82,6 +83,14 @@ The optional Local ADS-B layer and the broadcast-FM receiver use radio you recei
### Notes on the live sources
+- **Air quality (ECCC AQHI).** Keyless. Read from the MSC GeoMet OGC API (`api.weather.gc.ca`): the `aqhi-stations` catalog plus `aqhi-observations-realtime`. **Coverage is Canada only** — ECCC is the single registered provider, and the layer row and readout card both say so. Roughly 134 stations across ECCC's five administrative zones (Atlantic, Quebec, Ontario, Prairies & North, Pacific & Yukon). AQHI publishes hourly, so readings poll every 20 minutes and the station catalog, which changes a few times a year, is refetched only every 12 hours. Observations are requested with `latest=true`; without it the collection returns the full history, roughly 2,000 rows nationally. Readings older than six hours are dropped rather than displayed as current.
+
+ Readings are stored as `{ provider, scale, value, band }` rather than a bare number, because air-quality indices are not interchangeable: AQHI runs 1-10+ on health risk, the US AQI runs 0-500 on concentration breakpoints, and Europe's EAQI is a five-step category. Naming the index a value belongs to is what lets another network be added later without inventing a shared numeric meaning that does not exist.
+
+ AQHI is reported by ECCC as an integer from 1 to 10 with an open-ended "10+" above that. The API returns the underlying decimal (1.08, 2.32); the layer rounds to the published form rather than displaying a precision the index does not claim.
+
+ **Quebec is thinly represented**, and that is upstream rather than a filter here: the province runs its own Info-Smog program, so only a handful of Quebec stations appear in ECCC's national feed. The reporting station count also varies hour to hour as stations report or fall silent.
+
- **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.
diff --git a/README.md b/README.md
index 554f9b57f..68da35027 100644
--- a/README.md
+++ b/README.md
@@ -309,6 +309,7 @@ Twenty layers and map sources. **Eighteen have a keyless path.** Some offer addi
| 🚢 **Live Vessels** | Thousands of ships worldwide | AISStream | 🟡 |
| 🛰️ **Satellites** | 838-object catalog, color-coded by class with a live legend — the **DENSE** chip drops in the whole Starlink shell | CelesTrak | 🟢 |
| 🌍 **Earthquakes** | Global seismic activity, last 24h | USGS | 🟢 |
+| ◍ **Air quality** | Live AQHI on the published 1–10+ health-risk scale, with a Weather-panel card showing the band legend and the highest readings in view. **Canada only** — ECCC is the first registered provider | ECCC MSC GeoMet | 🟢 |
| 🚗 **Traffic** | Simulated vehicles on OSM roads. With TomTom, live flow speeds drive the simulation and congestion colors below ~8 km; individual vehicle positions are not live observations | TomTom + OSM | 🟢 simulation · 🟡 live flow speeds |
| 📹 **CCTV Mesh** | ~3,900 public cameras projected _into_ the 3D space — Austin · Texas (TxDOT) · California (Caltrans) · London (TfL) · Ontario (511) · Finland (Fintraffic) · British Columbia (DriveBC) · Estonia (Tallinn, Tarktee) · Delaware (DelDOT live video) · New South Wales (Live Traffic NSW) · Calgary · Norway (Statens vegvesen), plus opt-in New York City (NYC DOT TMC, needs your own NYC DOT agreement). Positions are published; poses are estimated priors **you calibrate by dragging a gizmo on the camera itself** | City APIs | 🟢 |
| 📷 **Mapped ALPR Cameras** | License-plate-reader camera locations tagged by OpenStreetMap contributors, loaded one city-sized view at a time, with **SHOW NEAREST**. Locations and tags only: no plate data, no video | OpenStreetMap (incl. DeFlock mapping) | 🟢 |
diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md
index 7654e79fa..47267784d 100644
--- a/docs/CURRENT-STATE.md
+++ b/docs/CURRENT-STATE.md
@@ -3222,6 +3222,7 @@ its criteria cannot be silently ignored.
| Live AIS Vessels 🚢 | AISStream websocket | `src/data/aisLiveVessels.js` | `/api/vessels` | 60s (+800ms visibility pass) |
| Mapped Installations ⌖ | OpenFreeMap military areas; optional configured Overpass names and Google Places search | `src/data/militaryInstallations.js` | OpenFreeMap tiles, `/api/military-installations`, `/api/google/text-search` | viewport-driven; transient errors back off, missing capability switches to tiles |
| Earthquakes | USGS | `src/data/earthquakes.js` | — | 60s |
+| Air quality | ECCC MSC GeoMet OGC API (AQHI, Canada only) | `src/layers/airQuality/` | — | 20 min readings, 12 h station catalog |
| Satellites | CelesTrak | `src/data/satellites.js` | `/api/celestrak` | 120s |
| Space Missions (30d) | Launch Library 2 + CelesTrak | `src/data/rocketLaunches.js` | `/api/launches` + `/api/celestrak/active` | 5 min |
| Traffic | Selectable TomTom / OpenStreetMap / Hybrid roads; optional TomTom flow (BYOK) | `src/data/traffic.js` | browser-direct OpenFreeMap tiles; `/api/tomtom` for flow | viewport-driven; capped tile cache |
diff --git a/package.json b/package.json
index 1c2e5896d..9d8761215 100644
--- a/package.json
+++ b/package.json
@@ -178,6 +178,7 @@
"./layers/directions": "./src/layers/directions/index.js",
"./layers/directions/source": "./src/layers/directions/source.js",
"./layers/cctv": "./src/layers/cctv/index.js",
+ "./layers/air-quality": "./src/layers/airQuality/index.js",
"./layers/alpr": "./src/layers/alpr/index.js",
"./layers/radio": "./src/layers/radio/index.js",
"./layers/submarine-cables": "./src/layers/submarineCables/index.js",
@@ -238,6 +239,7 @@
"./layers/alpr/source": "./src/layers/alpr/source.js",
"./layers/bikeshare/source": "./src/layers/bikeshare/source.js",
"./layers/cctv/source": "./src/layers/cctv/source.js",
+ "./layers/air-quality/source": "./src/layers/airQuality/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",
diff --git a/scripts/format-scope.json b/scripts/format-scope.json
index 394f48d5c..1f3d5e507 100644
--- a/scripts/format-scope.json
+++ b/scripts/format-scope.json
@@ -366,6 +366,12 @@
"src/layers/vessels/tracking.js",
"src/layers/vessels/ownership.test.mjs",
"src/data/earthquakes.js",
+ "src/app/layers/airQuality.js",
+ "src/layers/airQuality/index.js",
+ "src/layers/airQuality/model.js",
+ "src/layers/airQuality/model.test.mjs",
+ "src/layers/airQuality/ownership.test.mjs",
+ "src/layers/airQuality/source.js",
"src/layers/earthquakes/index.js",
"src/layers/earthquakes/model.js",
"src/layers/earthquakes/source.js",
diff --git a/scripts/package-boundaries.json b/scripts/package-boundaries.json
index d61e4ed5d..590d79218 100644
--- a/scripts/package-boundaries.json
+++ b/scripts/package-boundaries.json
@@ -2427,7 +2427,11 @@
"src/layers/installations/classLabel.js",
"src/data/cameraFraming.js",
"src/data/geoImportPins.js",
- "src/data/geoImportImages.js"
+ "src/data/geoImportImages.js",
+ "src/app/layers/airQuality.js",
+ "src/layers/airQuality/index.js",
+ "src/layers/airQuality/model.js",
+ "src/layers/airQuality/source.js"
],
"external": [
"hls.js",
@@ -2986,7 +2990,11 @@
"src/ui/unitLabels.js",
"src/layers/recentImagery/display.js",
"src/layers/installations/classLabel.js",
- "src/data/cameraFraming.js"
+ "src/data/cameraFraming.js",
+ "src/app/layers/airQuality.js",
+ "src/layers/airQuality/index.js",
+ "src/layers/airQuality/model.js",
+ "src/layers/airQuality/source.js"
],
"external": [
"hls.js",
@@ -3398,7 +3406,9 @@
"src/layers/perimeters/source.js",
"src/layers/perimeters/records.js",
"src/layers/submarineCables/bundledSource.js",
- "src/layers/gnss/source.js"
+ "src/layers/gnss/source.js",
+ "src/layers/airQuality/model.js",
+ "src/layers/airQuality/source.js"
],
"external": []
},
@@ -3798,5 +3808,24 @@
"src/data/layerStateTokenReservations.json"
],
"external": []
+ },
+ "air-quality": {
+ "exports": ["./layers/air-quality"],
+ "modules": [
+ "src/layers/airQuality/index.js",
+ "src/layers/airQuality/model.js",
+ "src/layers/airQuality/source.js",
+ "src/sources/httpBody.js"
+ ],
+ "external": ["cesium"]
+ },
+ "air-quality-source": {
+ "exports": ["./layers/air-quality/source"],
+ "modules": [
+ "src/layers/airQuality/model.js",
+ "src/layers/airQuality/source.js",
+ "src/sources/httpBody.js"
+ ],
+ "external": []
}
}
diff --git a/scripts/qa-air-quality.mjs b/scripts/qa-air-quality.mjs
new file mode 100644
index 000000000..68e6bd505
--- /dev/null
+++ b/scripts/qa-air-quality.mjs
@@ -0,0 +1,171 @@
+/**
+ * qa-air-quality.mjs — headless proof for the Air quality layer.
+ *
+ * Checks what unit tests cannot: that the live ECCC feed answers, that readings
+ * reach the app in the provider-shaped form other networks will share, and that
+ * the Weather readout descriptor carries a legend and an in-view list.
+ *
+ * AQHI is reported by ECCC as an integer from 1 with "10+" above ten. The API
+ * returns the underlying decimal, so a reading arriving as 1.08 — or a station
+ * with no reading surfacing as a confident 1 — is a real defect this catches.
+ *
+ * Run: node scripts/qa-air-quality.mjs --url http://localhost:4173
+ */
+
+import puppeteer from 'puppeteer';
+
+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 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));
+
+async function main() {
+ let exitCode = 0;
+ const consoleErrors = [];
+ const browser = await puppeteer.launch({
+ headless: 'new',
+ args: [
+ '--use-gl=swiftshader',
+ '--enable-unsafe-swiftshader',
+ '--no-sandbox',
+ '--disable-dev-shm-usage',
+ ],
+ defaultViewport: { width: 1400, height: 900 },
+ });
+
+ try {
+ const page = await browser.newPage();
+ page.on('pageerror', (e) => consoleErrors.push(String(e).slice(0, 160)));
+ page.on('console', (m) => {
+ if (m.type() === 'error') consoleErrors.push(m.text().slice(0, 160));
+ });
+
+ await page.goto(APP_URL, { waitUntil: 'domcontentloaded', timeout: 90000 });
+ await page.waitForFunction(
+ () => window.__godsEyeView?.viewer && window.__godsEyeView?.dataManager,
+ { timeout: 90000 },
+ );
+ await sleep(1500);
+
+ const stats = await page.evaluate(async () => {
+ const dm = window.__godsEyeView.dataManager;
+ await dm.setEnabled('air-quality', true);
+ const mod = dm.layers.get('air-quality')?.module;
+ if (!mod) return { missing: true };
+ let s = null;
+ for (let i = 0; i < 45; i++) {
+ await new Promise((r) => setTimeout(r, 1000));
+ s = mod.getStats();
+ if (s.count > 0 || s.error) break;
+ }
+ return s;
+ });
+
+ const loaded = !stats?.missing && !stats?.error && stats?.count > 0;
+ record('stations report a current index', loaded,
+ `count=${stats?.count} error=${stats?.error}`);
+ if (!loaded) exitCode = 1;
+
+ const records = await page.evaluate(
+ () =>
+ window.__godsEyeView.dataManager
+ .layers.get('air-quality')
+ ?.module.getAnalystRecords(500) || [],
+ );
+
+ // The provider-neutral contract other networks will share.
+ const shaped = records.every(
+ (r) => r.provider && r.scale && Number.isFinite(r.value) && r.band,
+ );
+ record('every reading carries provider, scale, value and band', shaped,
+ records.length
+ ? `sample={provider:${records[0].provider}, scale:${records[0].scale}, value:${records[0].value}, band:${records[0].band}}`
+ : 'no records');
+ if (!shaped) exitCode = 1;
+
+ const published = records.every((r) => Number.isInteger(r.value) && r.value >= 1);
+ record('every AQHI value is in the published integer form, floored at 1', published,
+ `values=${[...new Set(records.map((r) => r.value))].sort((a, b) => a - b).join(',')}`);
+ if (!published) exitCode = 1;
+
+ const zones = [...new Set(records.map((r) => r.zone).filter(Boolean))].sort();
+ record('coverage spans multiple ECCC administrative zones', zones.length >= 3,
+ `zones=${zones.join(',') || 'none'} stations=${records.length}`);
+ if (zones.length < 3) exitCode = 1;
+
+ // The card's whole point is "highest readings in view", so put the camera
+ // over Canada before asserting on it — the default view is over Austin,
+ // where an empty in-view list is correct and proves nothing.
+ await page.evaluate(() => {
+ const gev = window.__godsEyeView;
+ const ellipsoid = gev.viewer.scene.globe.ellipsoid;
+ const d2r = Math.PI / 180;
+ try {
+ gev.viewer.camera.cancelFlight();
+ } catch {
+ /* no flight in progress */
+ }
+ gev.viewer.camera.setView({
+ destination: ellipsoid.cartographicToCartesian({
+ longitude: -100 * d2r,
+ latitude: 55 * d2r,
+ height: 4_000_000,
+ }),
+ orientation: { heading: 0, pitch: -Math.PI / 2, roll: 0 },
+ });
+ gev.viewer.scene.requestRender?.();
+ });
+ await sleep(2500);
+
+ const controls = await page.evaluate(
+ () =>
+ window.__godsEyeView.dataManager
+ .layers.get('air-quality')
+ ?.module.getRowControls?.() || null,
+ );
+ const readoutOk =
+ controls?.readout === true &&
+ Array.isArray(controls?.legend?.colors) &&
+ controls.legend.colors.length === controls.legend.labels.length &&
+ Array.isArray(controls?.list?.items) &&
+ controls.list.items.length > 0 &&
+ controls.list.items.every((item) => item.id && item.label && item.value) &&
+ /Canada only/.test(controls?.summary?.coverage || '');
+ record('the Weather readout lists the worst readings in view, with a legend', readoutOk,
+ controls
+ ? `bands=${controls.legend?.labels?.join('/')} inView=${controls.list?.items?.length} coverage="${controls.summary?.coverage}"`
+ : 'no descriptor');
+ if (!readoutOk) exitCode = 1;
+
+ record('no console errors', consoleErrors.length === 0,
+ consoleErrors.length ? consoleErrors.slice(0, 2).join(' | ') : 'clean');
+ if (consoleErrors.length) exitCode = 1;
+ } catch (e) {
+ console.error('\x1b[31mHarness error:\x1b[0m', e);
+ exitCode = 3;
+ } finally {
+ await browser.close();
+ }
+
+ const pass = results.filter((r) => r.ok === true).length;
+ const fail = results.filter((r) => r.ok === false).length;
+ console.log('\n' + '─'.repeat(60));
+ console.log(` RESULT: ${pass} passed, ${fail} failed`);
+ console.log('─'.repeat(60) + '\n');
+ process.exit(exitCode || (fail > 0 ? 1 : 0));
+}
+
+main().catch((e) => {
+ console.error(e);
+ process.exit(3);
+});
diff --git a/src/app/constructCatalog.js b/src/app/constructCatalog.js
index 184b51f17..832c174bc 100644
--- a/src/app/constructCatalog.js
+++ b/src/app/constructCatalog.js
@@ -24,6 +24,7 @@ import { createApplicationLocalAdsb } from './layers/localAdsb.js';
import { createApplicationAwareness } from './layers/militaryAwareness.js';
import { createApplicationFirms } from './layers/firms.js';
import { createApplicationEarthquakes } from './layers/earthquakes.js';
+import { createApplicationAirQuality } from './layers/airQuality.js';
import { createApplicationFirePerimeters } from './layers/perimeters.js';
import { createApplicationGnssIntegrity } from './layers/gnss.js';
import { createApplicationCables } from './layers/submarineCables.js';
@@ -118,6 +119,7 @@ export function createApplicationCatalog({
...(resolveAsset ? { resolveAsset } : {}),
}),
createApplicationEarthquakes({ source: sources.earthquakes }),
+ createApplicationAirQuality({ source: sources['air-quality'] }),
createApplicationFirePerimeters({
source: sources['fire-perimeters'],
}),
diff --git a/src/app/constructCatalog.test.mjs b/src/app/constructCatalog.test.mjs
index f8ebb1e73..9e1059c33 100644
--- a/src/app/constructCatalog.test.mjs
+++ b/src/app/constructCatalog.test.mjs
@@ -40,7 +40,7 @@ test('catalogs construct distinct layers and classification from their supplied
signal: b.signal,
surface: fixtureSurface(b.signal),
});
- assert.equal(first.layers.length, 31);
+ assert.equal(first.layers.length, 32);
assert.ok(first.get('local-adsb'), 'Local ADS-B is registered');
assert.deepEqual(
first.metadata.find(({ id }) => id === 'local-adsb'),
@@ -154,7 +154,7 @@ test('Mapillary can be omitted or replaced without changing catalog membership',
surface: fixtureSurface(lifetime.signal),
};
const catalog = createApplicationCatalog(options);
- assert.equal(catalog.layers.length, 31);
+ assert.equal(catalog.layers.length, 32);
assert.deepEqual(catalog.get('street-level').providerIds, []);
assert.equal(
catalogControlServices(catalog).streetLevelLayer,
diff --git a/src/app/layers/airQuality.js b/src/app/layers/airQuality.js
new file mode 100644
index 000000000..1f25c06d9
--- /dev/null
+++ b/src/app/layers/airQuality.js
@@ -0,0 +1,6 @@
+import { createAirQualityLayer } from '../../layers/airQuality/index.js';
+import { overlayHost } from './overlayHost.js';
+/** Wire air-quality readings to the application overlay host. */
+export function createApplicationAirQuality(options) {
+ return createAirQualityLayer({ overlayHost, ...options });
+}
diff --git a/src/app/sourceComposition.js b/src/app/sourceComposition.js
index 0b516f5b4..0fc619142 100644
--- a/src/app/sourceComposition.js
+++ b/src/app/sourceComposition.js
@@ -46,6 +46,7 @@ export const CATALOG_SOURCE_CONTRACTS = Object.freeze({
cyclones: contract(['weather-cyclones'], ['getSnapshot']),
earthquakes: contract(['earthquakes'], ['getSnapshot']),
'fire-perimeters': contract(['fire-perimeters'], ['getSnapshot']),
+ 'air-quality': contract(['air-quality'], ['getSnapshot']),
'gnss-integrity': contract(['gnss-integrity'], ['getSnapshot']),
cables: contract(['telegeography-submarine-cables'], ['fetch']),
transit: contract(['transit'], ['requestSnapshot', 'getHistory']),
diff --git a/src/app/sourceComposition.test.mjs b/src/app/sourceComposition.test.mjs
index cf8646e25..422308f1e 100644
--- a/src/app/sourceComposition.test.mjs
+++ b/src/app/sourceComposition.test.mjs
@@ -37,7 +37,7 @@ test('every acquisition source can be omitted without losing catalog membership'
test('all acquisition sources may be absent, and enable fails before initializing or fetching', async (t) => {
const actual = catalog({}, t);
- assert.equal(actual.layers.length, 31);
+ assert.equal(actual.layers.length, 32);
const manager = new LayerLifecycle(
{},
{ getSourceAvailability: actual.getSourceAvailability },
diff --git a/src/data/dataCredits.js b/src/data/dataCredits.js
index 670456f12..f95070d04 100644
--- a/src/data/dataCredits.js
+++ b/src/data/dataCredits.js
@@ -169,6 +169,13 @@ export const DATA_CREDITS = [
},
OSM_CREDIT,
OPENMAPTILES_CREDIT,
+ {
+ key: 'eccc-aqhi',
+ html:
+ 'Air quality (AQHI): Environment and Climate Change Canada — MSC GeoMet ' +
+ '(eccc-msc.github.io/open-data) ' +
+ '· ECCC Data Servers End-use Licence',
+ },
{
key: 'overture-military-names',
html: 'Military area names: Overture Maps Foundation (ODbL)',
diff --git a/src/data/layerState.js b/src/data/layerState.js
index 9428296aa..a07fac214 100644
--- a/src/data/layerState.js
+++ b/src/data/layerState.js
@@ -585,6 +585,11 @@ export const LAYER_STATE_TOKEN_RESERVATIONS =
* owns stable URL ordering.
*/
export const LAYER_STATE_REGISTRY = Object.freeze([
+ Object.freeze({
+ id: 'air-quality',
+ token: '4',
+ disposition: 'enabled-only',
+ }),
Object.freeze({
id: 'ais-live-vessels',
token: 'a',
diff --git a/src/data/layerState.test.mjs b/src/data/layerState.test.mjs
index 631e5dfec..77b8f902f 100644
--- a/src/data/layerState.test.mjs
+++ b/src/data/layerState.test.mjs
@@ -202,8 +202,8 @@ function encode(state) {
test('production registry is exact, canonical, and rejects incomplete contracts', async () => {
assert.equal(validateLayerStateRegistry(), true);
- assert.equal(REGISTERED_LAYER_IDS.length, 30);
- assert.equal(new Set(REGISTERED_LAYER_IDS).size, 30);
+ assert.equal(REGISTERED_LAYER_IDS.length, 31);
+ assert.equal(new Set(REGISTERED_LAYER_IDS).size, 31);
assert.ok(REGISTERED_LAYER_IDS.includes('transit'));
assert.deepEqual(REGISTERED_LAYER_IDS, [...REGISTERED_LAYER_IDS].sort());
assert.deepEqual(LEGACY_LAYER_STATE_TOKENS, {
@@ -242,19 +242,19 @@ test('production registry is exact, canonical, and rejects incomplete contracts'
for (const [id, token] of Object.entries(LEGACY_LAYER_STATE_TOKENS)) {
assert.equal(LAYER_STATE_TOKEN_RESERVATIONS[id], token);
}
- assert.equal(nextLayerStateToken(), '4');
+ assert.equal(nextLayerStateToken(), '5');
assert.equal(
nextLayerStateToken({
...LAYER_STATE_TOKEN_RESERVATIONS,
- alpha: '4',
- bravo: '5',
+ alpha: '5',
+ bravo: '6',
}),
- '6',
+ '7',
);
const digitsExhausted = {
...LAYER_STATE_TOKEN_RESERVATIONS,
...Object.fromEntries(
- [...'456789'].map((digit) => [`prior-${digit}`, digit]),
+ [...'56789'].map((digit) => [`prior-${digit}`, digit]),
),
};
assert.equal(nextLayerStateToken(digitsExhausted), '00');
@@ -307,8 +307,8 @@ test('production registry is exact, canonical, and rejects incomplete contracts'
assert.equal(
validateLayerStateAllocations(LAYER_STATE_TOKEN_RESERVATIONS, {
...LAYER_STATE_TOKEN_RESERVATIONS,
- future: '4',
- next: '5',
+ future: '5',
+ next: '6',
}),
true,
);
@@ -318,7 +318,7 @@ test('production registry is exact, canonical, and rejects incomplete contracts'
...LAYER_STATE_TOKEN_RESERVATIONS,
future: '00',
}),
- /next free token 4/,
+ /next free token 5/,
);
const beforeLastDigit = { ...digitsExhausted };
delete beforeLastDigit['prior-9'];
@@ -341,10 +341,10 @@ test('production registry is exact, canonical, and rejects incomplete contracts'
assert.throws(
() =>
validateLayerStateAllocations(
- { ...LAYER_STATE_TOKEN_RESERVATIONS, merged: '4' },
- { ...LAYER_STATE_TOKEN_RESERVATIONS, merged: '4', competing: '4' },
+ { ...LAYER_STATE_TOKEN_RESERVATIONS, merged: '5' },
+ { ...LAYER_STATE_TOKEN_RESERVATIONS, merged: '5', competing: '5' },
),
- /next free token 5/,
+ /next free token 6/,
);
assert.throws(
() => validateLayerStateAllocations({ future: '00' }, { future: '01' }),
diff --git a/src/data/layerStateTokenLedger.test.mjs b/src/data/layerStateTokenLedger.test.mjs
index a38a43d51..7bcfd96cb 100644
--- a/src/data/layerStateTokenLedger.test.mjs
+++ b/src/data/layerStateTokenLedger.test.mjs
@@ -128,17 +128,23 @@ test('reservation ledger is complete, pinned, and rejects duplicate or malformed
{ ...parseLayerStateTokenReservations(reservationRows) },
{ ...LAYER_STATE_TOKEN_RESERVATIONS },
);
- assert.equal(Object.keys(LAYER_STATE_TOKEN_RESERVATIONS).length, 30);
+ assert.equal(Object.keys(LAYER_STATE_TOKEN_RESERVATIONS).length, 31);
assert.deepEqual(
{ ...LAYER_STATE_TOKEN_RESERVATIONS },
{
...LEGACY_LAYER_STATE_TOKENS,
'street-level': '0',
'gnss-integrity': '3',
+ 'air-quality': '4',
},
);
assert.throws(
- () => parseLayerStateTokenReservations(reservationRows.slice(1)),
+ () =>
+ parseLayerStateTokenReservations(
+ // Drop a legacy row by id; the first row sorts as air-quality, which
+ // is not a legacy reservation.
+ reservationRows.filter(([id]) => id !== 'ais-live-vessels'),
+ ),
/Missing or changed legacy/,
);
assert.throws(
@@ -155,9 +161,9 @@ test('reservation ledger is complete, pinned, and rejects duplicate or malformed
/Duplicate layer-state token reservation/,
);
assert.equal(
- parseLayerStateTokenReservations([...reservationRows, ['future', '4']])
+ parseLayerStateTokenReservations([...reservationRows, ['future', '5']])
.future,
- '4',
+ '5',
);
assert.throws(
() =>
@@ -175,14 +181,14 @@ test('reservation ledger is complete, pinned, and rejects duplicate or malformed
test('published retired digits and competing PRs advance the merge-time allocation', () => {
withPublishedBase(
- { rows: [...reservationRows, ['retired-layer', '4']] },
+ { rows: [...reservationRows, ['retired-layer', '5']] },
(cwd) => {
const published = readPublishedLayerStateReservations('HEAD', cwd);
- assert.equal(nextLayerStateToken(published), '5');
+ assert.equal(nextLayerStateToken(published), '6');
assert.equal(
validateLayerStateAllocations(published, {
...published,
- newcomer: '5',
+ newcomer: '6',
}),
true,
);
@@ -190,15 +196,15 @@ test('published retired digits and competing PRs advance the merge-time allocati
() =>
validateLayerStateAllocations(published, {
...published,
- competing: '4',
+ competing: '5',
}),
- /next free token 5/,
+ /next free token 6/,
);
assert.throws(
() =>
validateLayerStateAllocations(published, {
...LAYER_STATE_TOKEN_RESERVATIONS,
- newcomer: '4',
+ newcomer: '5',
}),
/changed or removed: retired-layer/,
);
@@ -206,8 +212,8 @@ test('published retired digits and competing PRs advance the merge-time allocati
);
});
-test('PR B manually replaces provisional 4 with 5 after PR A publishes 4', () => {
- withPublishedBase({ rows: [...reservationRows, ['pr-a', '4']] }, (cwd) => {
+test('PR B manually replaces provisional 5 with 6 after PR A publishes 5', () => {
+ withPublishedBase({ rows: [...reservationRows, ['pr-a', '5']] }, (cwd) => {
mkdirSync(path.join(cwd, 'scripts'));
const fixtureChecker = path.join(
cwd,
@@ -228,8 +234,8 @@ test('PR B manually replaces provisional 4 with 5 after PR A publishes 4', () =>
writeCodecFixture(
cwd,
- [...reservationRows, ['pr-b', '4']],
- [{ id: 'pr-b', token: '4' }],
+ [...reservationRows, ['pr-b', '5']],
+ [{ id: 'pr-b', token: '5' }],
);
const beforeRebase = runCheck();
assert.equal(beforeRebase.status, 1);
@@ -237,10 +243,10 @@ test('PR B manually replaces provisional 4 with 5 after PR A publishes 4', () =>
writeCodecFixture(
cwd,
- [...reservationRows, ['pr-a', '4'], ['pr-b', '4']],
+ [...reservationRows, ['pr-a', '5'], ['pr-b', '5']],
[
- { id: 'pr-a', token: '4' },
- { id: 'pr-b', token: '4' },
+ { id: 'pr-a', token: '5' },
+ { id: 'pr-b', token: '5' },
],
);
const staleRebase = runCheck();
@@ -248,16 +254,16 @@ test('PR B manually replaces provisional 4 with 5 after PR A publishes 4', () =>
assert.match(staleRebase.stderr, /Duplicate layer-state token reservation/);
const published = readPublishedLayerStateReservations('HEAD', cwd);
- assert.equal(nextLayerStateToken(published), '5');
- const validBaselineRows = [...reservationRows, ['pr-a', '4']];
- writeCodecFixture(cwd, validBaselineRows, [{ id: 'pr-a', token: '4' }]);
+ assert.equal(nextLayerStateToken(published), '6');
+ const validBaselineRows = [...reservationRows, ['pr-a', '5']];
+ writeCodecFixture(cwd, validBaselineRows, [{ id: 'pr-a', token: '5' }]);
const helper = spawnSync(
process.execPath,
[realpathSync(fixtureNext), 'pr-b'],
{ cwd, encoding: 'utf8' },
);
assert.equal(helper.status, 0, helper.stderr);
- assert.match(helper.stdout, /pr-b: 5/);
+ assert.match(helper.stdout, /pr-b: 6/);
assert.deepEqual(
JSON.parse(readFileSync(path.join(cwd, LEDGER_PATH), 'utf8')),
validBaselineRows,
@@ -265,20 +271,20 @@ test('PR B manually replaces provisional 4 with 5 after PR A publishes 4', () =>
);
writeCodecFixture(
cwd,
- [...reservationRows, ['pr-a', '4'], ['pr-b', '5']],
+ [...reservationRows, ['pr-a', '5'], ['pr-b', '6']],
[
- { id: 'pr-a', token: '4' },
- { id: 'pr-b', token: '5' },
+ { id: 'pr-a', token: '5' },
+ { id: 'pr-b', token: '6' },
],
);
const corrected = runCheck();
assert.equal(corrected.status, 0, corrected.stderr);
- assert.match(corrected.stdout, /31 published, 1 new/);
+ assert.match(corrected.stdout, /32 published, 1 new/);
});
});
test('allocation batches cross the last digit and base-36 pair boundaries in order', () => {
- const digitRows = [...'456789'].map((digit) => [`retired-${digit}`, digit]);
+ const digitRows = [...'56789'].map((digit) => [`retired-${digit}`, digit]);
const allDigits = parseLayerStateTokenReservations([
...reservationRows,
...digitRows,
@@ -330,7 +336,7 @@ test('allocation batches cross the last digit and base-36 pair boundaries in ord
test('an isolated valid two-character fixture round-trips an l field beyond the old 64-character cap', async () => {
const cwd = mkdtempSync(path.join(tmpdir(), 'gev-layer-token-width-'));
try {
- const priorDigits = [...'456789'].map((digit) => [
+ const priorDigits = [...'56789'].map((digit) => [
`qa-prior-digit-${digit}`,
digit,
]);
@@ -367,7 +373,7 @@ test('an isolated valid two-character fixture round-trips an l field beyond the
const params = new URLSearchParams([['v', '2']]);
codec.encodeLayerStateParams(params, state);
assert.ok(params.get('l').length > 64, 'fixture must cross the old cap');
- assert.equal(params.get('l').length, 71);
+ assert.equal(params.get('l').length, 73);
const restored = codec.decodeLayerStateParams(params);
assert.deepEqual(restored?.enabledLayerIds, expectedLayerIds);
assert.deepEqual(restored?.options, state.options);
@@ -385,7 +391,7 @@ test('checker reads a complete future base ledger and rejects retired-token reus
},
(cwd) => {
const published = readPublishedLayerStateReservations('HEAD', cwd);
- assert.equal(Object.keys(published).length, 31);
+ assert.equal(Object.keys(published).length, 32);
assert.equal(published['retired-layer'], '00');
assert.throws(
() =>
diff --git a/src/data/layerStateTokenReservations.json b/src/data/layerStateTokenReservations.json
index 34ba725b5..dd8dcd7c4 100644
--- a/src/data/layerStateTokenReservations.json
+++ b/src/data/layerStateTokenReservations.json
@@ -1,4 +1,5 @@
[
+ ["air-quality", "4"],
["ais-live-vessels", "a"],
["alpr-cameras", "p"],
["bhote-koshi-2026", "h"],
diff --git a/src/firstRunExperience.test.mjs b/src/firstRunExperience.test.mjs
index 2615f9654..749123acd 100644
--- a/src/firstRunExperience.test.mjs
+++ b/src/firstRunExperience.test.mjs
@@ -994,10 +994,11 @@ test('the voice TOOL SCHEMA matches the pinned contract — the mission mapping
// schema, which may not carry top-level anyOf), and Contacts requested-radius
// list descriptions; the missions still ride existing tools.
// Street Level and GNSS extend the manifest; broadcast caveats describe tracked and analyst aircraft.
- assert.equal(block.length, 30511, 'serialized tool schema length drifted');
+ // Air quality (ECCC AQHI) extends the manifest.
+ assert.equal(block.length, 30655, 'serialized tool schema length drifted');
assert.equal(
crypto.createHash('sha256').update(block).digest('hex'),
- '62d5df9cedf413403e4fd0a7cbe94bb2cd13736c3d8fedc84bcccd63ad253eb6',
+ '3b1d5e77c10a92f6543a4f9d1ff1870101a0306b65e5b8a6113111c81eda371e',
'the first-run missions must ride EXISTING tools: no schema edit, no cache bust',
);
const instructions = fs.readFileSync(
diff --git a/src/i18n/locales/es.json b/src/i18n/locales/es.json
index 3242d8395..ec710d8e8 100644
--- a/src/i18n/locales/es.json
+++ b/src/i18n/locales/es.json
@@ -2779,6 +2779,12 @@
"FILTER": "FILTRO",
"Cloud filter strength": "Intensidad del filtro de nubes",
"Panoramax answered HTTP {status}": "Panoramax respondió HTTP {status}",
+ "Highest air-quality readings in view": "Lecturas de calidad del aire más altas a la vista",
+ "Low": "Baja",
+ "Moderate": "Moderada",
+ "High": "Alta",
+ "Very High": "Muy alta",
+ "Air quality": "Calidad del aire",
"{n} sequences in view · click a line for its nearest photo": {
"one": "{n} secuencia a la vista · haz clic en una línea para su foto más cercana",
"many": "{n} secuencias a la vista · haz clic en una línea para su foto más cercana",
diff --git a/src/i18n/locales/ru.json b/src/i18n/locales/ru.json
index 8226dafae..49f2361d9 100644
--- a/src/i18n/locales/ru.json
+++ b/src/i18n/locales/ru.json
@@ -2836,6 +2836,12 @@
"FILTER": "ФИЛЬТР",
"Cloud filter strength": "Сила фильтра облаков",
"Panoramax answered HTTP {status}": "Panoramax ответил: HTTP {status}",
+ "Highest air-quality readings in view": "Наивысшие показатели качества воздуха в поле зрения",
+ "Low": "Низкий",
+ "Moderate": "Умеренный",
+ "High": "Высокий",
+ "Very High": "Очень высокий",
+ "Air quality": "Качество воздуха",
"{n} sequences in view · click a line for its nearest photo": {
"one": "{n} съёмка в кадре · щёлкните линию, чтобы открыть ближайшую фотографию",
"few": "{n} съёмки в кадре · щёлкните линию, чтобы открыть ближайшую фотографию",
diff --git a/src/layers/airQuality/index.js b/src/layers/airQuality/index.js
new file mode 100644
index 000000000..9b7cd3876
--- /dev/null
+++ b/src/layers/airQuality/index.js
@@ -0,0 +1,287 @@
+import * as Cesium from 'cesium';
+import { t } from '../../i18n/index.js';
+import {
+ AIR_QUALITY_BANDS,
+ AIR_QUALITY_OVERLAY_SOURCE_ID,
+ AIR_QUALITY_OVERLAY_COHORT_LIMIT,
+ AIR_QUALITY_OVERLAY_COLLISION_CAPACITY,
+ AIR_QUALITY_PROVIDERS,
+ createAirQualityOverlayEntry,
+ mapAnalystRecord,
+ newestObservation,
+ readingsAreStale,
+ readingsInView,
+ selectAirQualityOverlayCohort,
+} from './model.js';
+export * from './model.js';
+export { createEcccAirQualitySource } from './source.js';
+
+/** Station markers are a point reading, not an extent. */
+const STATION_MARKER_RADIUS_M = 6_000;
+/** Readings listed in the readout card. */
+const READOUT_LIMIT = 5;
+
+/** Own one air-quality display, its refresh lifecycle, and its readout card. */
+export function createAirQualityLayer({ source, overlayHost } = {}) {
+ if (typeof source?.getSnapshot !== 'function')
+ throw new TypeError('Air quality requires a snapshot source');
+ if (!overlayHost) throw new TypeError('Air quality requires an overlay host');
+ let _viewer = null;
+ let _request = null;
+ let _dataSource = null;
+ let _readings = [];
+ let _count = 0;
+ let _lastUpdate = null;
+ let _lastError = null;
+ let _enabled = false;
+ let rowControlsListener = null;
+
+ const notify = () => rowControlsListener?.();
+
+ /** The camera's lon/lat rectangle in degrees, or null when unresolvable. */
+ const viewRectangle = () => {
+ const rectangle = _viewer?.camera?.computeViewRectangle?.();
+ if (!rectangle) return null;
+ const toDeg = Cesium.Math.toDegrees;
+ return {
+ west: toDeg(rectangle.west),
+ south: toDeg(rectangle.south),
+ east: toDeg(rectangle.east),
+ north: toDeg(rectangle.north),
+ };
+ };
+
+ const layer = {
+ id: 'air-quality',
+ name: 'Air quality',
+ icon: '◍',
+ source: 'ECCC MSC GeoMet',
+ updateInterval: 1_200_000,
+
+ init(viewer) {
+ if (_viewer) throw new Error('Air quality layer is already initialized');
+ _viewer = viewer;
+ _dataSource = new Cesium.CustomDataSource('air-quality');
+ _dataSource.show = false;
+ viewer.dataSources.add(_dataSource);
+ _readings = [];
+ _count = 0;
+ _lastUpdate = null;
+ _lastError = null;
+ _enabled = false;
+ overlayHost.setVisible(AIR_QUALITY_OVERLAY_SOURCE_ID, false);
+ },
+
+ enable() {
+ _enabled = true;
+ if (_dataSource) _dataSource.show = true;
+ overlayHost.setVisible(AIR_QUALITY_OVERLAY_SOURCE_ID, true);
+ notify();
+ },
+
+ disable() {
+ // Abort in flight: a response arriving after disable must not redraw.
+ _request?.abort();
+ _request = null;
+ _enabled = false;
+ if (_dataSource) _dataSource.show = false;
+ overlayHost.clearSource(AIR_QUALITY_OVERLAY_SOURCE_ID);
+ overlayHost.setVisible(AIR_QUALITY_OVERLAY_SOURCE_ID, false);
+ notify();
+ },
+
+ async update() {
+ if (!_enabled || !_dataSource) return false;
+ _request?.abort();
+ const request = new AbortController();
+ _request = request;
+ try {
+ const readings = await source.getSnapshot({ signal: request.signal });
+ // A superseded or aborted request must not touch the scene, and neither
+ // must one that lands after the layer was switched off.
+ if (request.signal.aborted || _request !== request || !_enabled)
+ return false;
+ _readings = readings;
+ this._render();
+ _count = _readings.length;
+ _lastUpdate = Date.now();
+ _lastError = null;
+ notify();
+ return true;
+ } catch (error) {
+ if (request.signal.aborted || _request !== request) return false;
+ _lastError = error?.message || 'Air quality request failed';
+ console.warn('[Data:AirQuality] Refresh failed:', _lastError);
+ notify();
+ return false;
+ } finally {
+ if (_request === request) _request = null;
+ }
+ },
+
+ /** Rebuild entities and overlay labels from the current readings. */
+ _render() {
+ if (!_dataSource) return;
+ _dataSource.entities.removeAll();
+ const overlayEntries = [];
+ for (const reading of _readings) {
+ const position = Cesium.Cartesian3.fromDegrees(
+ reading.lon,
+ reading.lat,
+ );
+ const color = Cesium.Color.fromCssColorString(reading.color);
+ _dataSource.entities.add(
+ new Cesium.Entity({
+ id: `air-quality:${reading.id}`,
+ position,
+ ellipse: {
+ // Static axes, as in the earthquake layer: a per-frame axis
+ // re-tessellates the clamped ground primitive every frame.
+ semiMajorAxis: STATION_MARKER_RADIUS_M,
+ semiMinorAxis: STATION_MARKER_RADIUS_M,
+ material: new Cesium.ColorMaterialProperty(color.withAlpha(0.35)),
+ outline: true,
+ outlineColor: color.withAlpha(0.9),
+ outlineWidth: 2,
+ heightReference: Cesium.HeightReference.CLAMP_TO_GROUND,
+ },
+ properties: {
+ provider: reading.provider,
+ scale: reading.scale,
+ value: reading.value,
+ band: reading.band,
+ name: reading.name,
+ zone: reading.zone,
+ risk: reading.risk,
+ observedMs: reading.observedMs,
+ note: reading.note,
+ lat: reading.lat,
+ lon: reading.lon,
+ },
+ }),
+ );
+ overlayEntries.push(
+ createAirQualityOverlayEntry({
+ id: reading.id,
+ position,
+ title: `${reading.name} ${reading.label}`,
+ accent: reading.color,
+ value: reading.value,
+ }),
+ );
+ }
+ if (_enabled) {
+ overlayHost.setEntries(
+ AIR_QUALITY_OVERLAY_SOURCE_ID,
+ selectAirQualityOverlayCohort(overlayEntries),
+ {
+ cohortLimit: AIR_QUALITY_OVERLAY_COHORT_LIMIT,
+ collisionCapacity: AIR_QUALITY_OVERLAY_COLLISION_CAPACITY,
+ moving: false,
+ },
+ );
+ }
+ },
+
+ destroy(viewer = _viewer) {
+ _request?.abort();
+ _request = null;
+ _enabled = false;
+ overlayHost.clearSource(AIR_QUALITY_OVERLAY_SOURCE_ID);
+ overlayHost.setVisible(AIR_QUALITY_OVERLAY_SOURCE_ID, false);
+ if (_dataSource) {
+ viewer?.dataSources?.remove(_dataSource, true);
+ _dataSource = null;
+ }
+ _viewer = null;
+ _readings = [];
+ _count = 0;
+ _lastUpdate = null;
+ _lastError = null;
+ rowControlsListener = null;
+ },
+
+ /**
+ * Readout descriptor for the Weather panel: the band legend, and the worst
+ * readings currently in view.
+ *
+ * `readout: true` renders the toggle and source line only; the card owns
+ * the rest. Coverage is stated plainly because this layer is not global.
+ */
+ getRowControls() {
+ const provider = AIR_QUALITY_PROVIDERS.eccc;
+ const inView = readingsInView(_readings, viewRectangle(), READOUT_LIMIT);
+ const newest = _readings.reduce(
+ (max, reading) => Math.max(max, reading.observedMs || 0),
+ 0,
+ );
+ return {
+ readout: true,
+ summary: {
+ label: provider.label,
+ coverage: provider.coverage,
+ validTime: newest ? new Date(newest).toISOString() : undefined,
+ detail: `${provider.network} · ${provider.scale}`,
+ status: _lastError
+ ? _lastError
+ : _count === 0
+ ? _lastUpdate === null
+ ? 'No readings available'
+ : 'No current readings'
+ : `${_count} stations reporting`,
+ units: provider.scale,
+ },
+ legend: AIR_QUALITY_BANDS.map((band) => ({
+ color: band.color,
+ label: band.label,
+ })),
+ legendCategorical: true,
+ list: {
+ ariaLabel: t('Highest air-quality readings in view'),
+ items: inView.map((reading, index) => ({
+ id: reading.id,
+ ordinal: index + 1,
+ lead: `${reading.scale} ${reading.label}`,
+ text: `${reading.name} · ${reading.risk}`,
+ accent: reading.color,
+ })),
+ },
+ };
+ },
+
+ setRowControlsListener(listener) {
+ rowControlsListener = typeof listener === 'function' ? listener : null;
+ },
+
+ /**
+ * Snapshot readings as JSON-safe records for the analyst query engine.
+ * @param {number} [maxCount=500]
+ * @returns {Array