mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-04 10:24:02 +00:00
Merge upstream/main: air-quality layer (AQHI) meets i18n
Upstream's #568 adds the Air quality layer to the Weather group (ECCC AQHI, Canada only) with a band legend, worst-first list and voice support. - package-boundaries: both sides' entries unioned. - layerPanel/weatherPanel tests: both sides' new tests kept; the weatherPanel fixture follows upstream (legendCategorical: true, the 'list' block id) while our localized-history (K-6) test stays. - air-quality strings marked: band labels and the provider label as msg() data, the readings-list aria-label through t(); es/ru entries added (+6 keys each). - The synthetic air-quality allocation-benchmark titles follow the file's own convention (// i18n-ignore) like TRK/VESSEL/CAMERA. Also verified the auto-merged weatherPanel.js against upstream: ORDER gains 'air-quality', OBSERVED stays slice(2, 5), and the data-driven legendCategorical path is upstream's. npm run i18n: complete (2559), no unmarked strings. npm test: only the pre-existing environmental OPENAI_API_KEY leak fails. Build passes.
This commit is contained in:
commit
f3da0916d9
38 files changed
+2059
-60
No files matched your search
@@ -191,6 +191,19 @@
|
|||||||
|
|
||||||
## [Unreleased]
|
## [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
|
- Bound six upstream body reads (FIRMS CSV, CelesTrak TLE groups, adsb.lol
|
||||||
`/v2/mil`, TomTom flow tiles, OpenSky `states/all` and adsbdb lookups) with
|
`/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;
|
streaming size caps that cancel the body at the cap or the route's deadline;
|
||||||
|
|||||||
@@ -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) |
|
| **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) |
|
| **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" |
|
| **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/) |
|
| **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)" |
|
| **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)" |
|
| **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
|
### 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)).
|
- **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.
|
- **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.
|
- **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.
|
||||||
|
|||||||
@@ -309,6 +309,7 @@ Twenty layers and map sources. **Eighteen have a keyless path.** Some offer addi
|
|||||||
| 🚢 **Live Vessels** | Thousands of ships worldwide | AISStream | 🟡 |
|
| 🚢 **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 | 🟢 |
|
| 🛰️ **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 | 🟢 |
|
| 🌍 **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 |
|
| 🚗 **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 | 🟢 |
|
| 📹 **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) | 🟢 |
|
| 📷 **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) | 🟢 |
|
||||||
|
|||||||
@@ -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) |
|
| 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 |
|
| 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 |
|
| 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 |
|
| 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 |
|
| 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 |
|
| 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 |
|
||||||
|
|||||||
@@ -178,6 +178,7 @@
|
|||||||
"./layers/directions": "./src/layers/directions/index.js",
|
"./layers/directions": "./src/layers/directions/index.js",
|
||||||
"./layers/directions/source": "./src/layers/directions/source.js",
|
"./layers/directions/source": "./src/layers/directions/source.js",
|
||||||
"./layers/cctv": "./src/layers/cctv/index.js",
|
"./layers/cctv": "./src/layers/cctv/index.js",
|
||||||
|
"./layers/air-quality": "./src/layers/airQuality/index.js",
|
||||||
"./layers/alpr": "./src/layers/alpr/index.js",
|
"./layers/alpr": "./src/layers/alpr/index.js",
|
||||||
"./layers/radio": "./src/layers/radio/index.js",
|
"./layers/radio": "./src/layers/radio/index.js",
|
||||||
"./layers/submarine-cables": "./src/layers/submarineCables/index.js",
|
"./layers/submarine-cables": "./src/layers/submarineCables/index.js",
|
||||||
@@ -238,6 +239,7 @@
|
|||||||
"./layers/alpr/source": "./src/layers/alpr/source.js",
|
"./layers/alpr/source": "./src/layers/alpr/source.js",
|
||||||
"./layers/bikeshare/source": "./src/layers/bikeshare/source.js",
|
"./layers/bikeshare/source": "./src/layers/bikeshare/source.js",
|
||||||
"./layers/cctv/source": "./src/layers/cctv/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/earthquakes/source": "./src/layers/earthquakes/source.js",
|
||||||
"./layers/perimeters/source": "./src/layers/perimeters/source.js",
|
"./layers/perimeters/source": "./src/layers/perimeters/source.js",
|
||||||
"./layers/gnss/source": "./src/layers/gnss/source.js",
|
"./layers/gnss/source": "./src/layers/gnss/source.js",
|
||||||
|
|||||||
@@ -366,6 +366,12 @@
|
|||||||
"src/layers/vessels/tracking.js",
|
"src/layers/vessels/tracking.js",
|
||||||
"src/layers/vessels/ownership.test.mjs",
|
"src/layers/vessels/ownership.test.mjs",
|
||||||
"src/data/earthquakes.js",
|
"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/index.js",
|
||||||
"src/layers/earthquakes/model.js",
|
"src/layers/earthquakes/model.js",
|
||||||
"src/layers/earthquakes/source.js",
|
"src/layers/earthquakes/source.js",
|
||||||
|
|||||||
@@ -2427,7 +2427,11 @@
|
|||||||
"src/layers/installations/classLabel.js",
|
"src/layers/installations/classLabel.js",
|
||||||
"src/data/cameraFraming.js",
|
"src/data/cameraFraming.js",
|
||||||
"src/data/geoImportPins.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": [
|
"external": [
|
||||||
"hls.js",
|
"hls.js",
|
||||||
@@ -2986,7 +2990,11 @@
|
|||||||
"src/ui/unitLabels.js",
|
"src/ui/unitLabels.js",
|
||||||
"src/layers/recentImagery/display.js",
|
"src/layers/recentImagery/display.js",
|
||||||
"src/layers/installations/classLabel.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": [
|
"external": [
|
||||||
"hls.js",
|
"hls.js",
|
||||||
@@ -3398,7 +3406,9 @@
|
|||||||
"src/layers/perimeters/source.js",
|
"src/layers/perimeters/source.js",
|
||||||
"src/layers/perimeters/records.js",
|
"src/layers/perimeters/records.js",
|
||||||
"src/layers/submarineCables/bundledSource.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": []
|
"external": []
|
||||||
},
|
},
|
||||||
@@ -3798,5 +3808,24 @@
|
|||||||
"src/data/layerStateTokenReservations.json"
|
"src/data/layerStateTokenReservations.json"
|
||||||
],
|
],
|
||||||
"external": []
|
"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": []
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
@@ -24,6 +24,7 @@ import { createApplicationLocalAdsb } from './layers/localAdsb.js';
|
|||||||
import { createApplicationAwareness } from './layers/militaryAwareness.js';
|
import { createApplicationAwareness } from './layers/militaryAwareness.js';
|
||||||
import { createApplicationFirms } from './layers/firms.js';
|
import { createApplicationFirms } from './layers/firms.js';
|
||||||
import { createApplicationEarthquakes } from './layers/earthquakes.js';
|
import { createApplicationEarthquakes } from './layers/earthquakes.js';
|
||||||
|
import { createApplicationAirQuality } from './layers/airQuality.js';
|
||||||
import { createApplicationFirePerimeters } from './layers/perimeters.js';
|
import { createApplicationFirePerimeters } from './layers/perimeters.js';
|
||||||
import { createApplicationGnssIntegrity } from './layers/gnss.js';
|
import { createApplicationGnssIntegrity } from './layers/gnss.js';
|
||||||
import { createApplicationCables } from './layers/submarineCables.js';
|
import { createApplicationCables } from './layers/submarineCables.js';
|
||||||
@@ -118,6 +119,7 @@ export function createApplicationCatalog({
|
|||||||
...(resolveAsset ? { resolveAsset } : {}),
|
...(resolveAsset ? { resolveAsset } : {}),
|
||||||
}),
|
}),
|
||||||
createApplicationEarthquakes({ source: sources.earthquakes }),
|
createApplicationEarthquakes({ source: sources.earthquakes }),
|
||||||
|
createApplicationAirQuality({ source: sources['air-quality'] }),
|
||||||
createApplicationFirePerimeters({
|
createApplicationFirePerimeters({
|
||||||
source: sources['fire-perimeters'],
|
source: sources['fire-perimeters'],
|
||||||
}),
|
}),
|
||||||
|
|||||||
@@ -40,7 +40,7 @@ test('catalogs construct distinct layers and classification from their supplied
|
|||||||
signal: b.signal,
|
signal: b.signal,
|
||||||
surface: fixtureSurface(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.ok(first.get('local-adsb'), 'Local ADS-B is registered');
|
||||||
assert.deepEqual(
|
assert.deepEqual(
|
||||||
first.metadata.find(({ id }) => id === 'local-adsb'),
|
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),
|
surface: fixtureSurface(lifetime.signal),
|
||||||
};
|
};
|
||||||
const catalog = createApplicationCatalog(options);
|
const catalog = createApplicationCatalog(options);
|
||||||
assert.equal(catalog.layers.length, 31);
|
assert.equal(catalog.layers.length, 32);
|
||||||
assert.deepEqual(catalog.get('street-level').providerIds, []);
|
assert.deepEqual(catalog.get('street-level').providerIds, []);
|
||||||
assert.equal(
|
assert.equal(
|
||||||
catalogControlServices(catalog).streetLevelLayer,
|
catalogControlServices(catalog).streetLevelLayer,
|
||||||
|
|||||||
@@ -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 });
|
||||||
|
}
|
||||||
@@ -46,6 +46,7 @@ export const CATALOG_SOURCE_CONTRACTS = Object.freeze({
|
|||||||
cyclones: contract(['weather-cyclones'], ['getSnapshot']),
|
cyclones: contract(['weather-cyclones'], ['getSnapshot']),
|
||||||
earthquakes: contract(['earthquakes'], ['getSnapshot']),
|
earthquakes: contract(['earthquakes'], ['getSnapshot']),
|
||||||
'fire-perimeters': contract(['fire-perimeters'], ['getSnapshot']),
|
'fire-perimeters': contract(['fire-perimeters'], ['getSnapshot']),
|
||||||
|
'air-quality': contract(['air-quality'], ['getSnapshot']),
|
||||||
'gnss-integrity': contract(['gnss-integrity'], ['getSnapshot']),
|
'gnss-integrity': contract(['gnss-integrity'], ['getSnapshot']),
|
||||||
cables: contract(['telegeography-submarine-cables'], ['fetch']),
|
cables: contract(['telegeography-submarine-cables'], ['fetch']),
|
||||||
transit: contract(['transit'], ['requestSnapshot', 'getHistory']),
|
transit: contract(['transit'], ['requestSnapshot', 'getHistory']),
|
||||||
|
|||||||
@@ -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) => {
|
test('all acquisition sources may be absent, and enable fails before initializing or fetching', async (t) => {
|
||||||
const actual = catalog({}, t);
|
const actual = catalog({}, t);
|
||||||
assert.equal(actual.layers.length, 31);
|
assert.equal(actual.layers.length, 32);
|
||||||
const manager = new LayerLifecycle(
|
const manager = new LayerLifecycle(
|
||||||
{},
|
{},
|
||||||
{ getSourceAvailability: actual.getSourceAvailability },
|
{ getSourceAvailability: actual.getSourceAvailability },
|
||||||
|
|||||||
@@ -169,6 +169,13 @@ export const DATA_CREDITS = [
|
|||||||
},
|
},
|
||||||
OSM_CREDIT,
|
OSM_CREDIT,
|
||||||
OPENMAPTILES_CREDIT,
|
OPENMAPTILES_CREDIT,
|
||||||
|
{
|
||||||
|
key: 'eccc-aqhi',
|
||||||
|
html:
|
||||||
|
'Air quality (AQHI): Environment and Climate Change Canada — MSC GeoMet ' +
|
||||||
|
'(<a href="https://eccc-msc.github.io/open-data/" target="_blank" rel="noopener">eccc-msc.github.io/open-data</a>) ' +
|
||||||
|
'· <a href="https://eccc-msc.github.io/open-data/licence/readme_en/" target="_blank" rel="noopener">ECCC Data Servers End-use Licence</a>',
|
||||||
|
},
|
||||||
{
|
{
|
||||||
key: 'overture-military-names',
|
key: 'overture-military-names',
|
||||||
html: 'Military area names: <a href="https://overturemaps.org" target="_blank" rel="noopener">Overture Maps Foundation</a> (ODbL)',
|
html: 'Military area names: <a href="https://overturemaps.org" target="_blank" rel="noopener">Overture Maps Foundation</a> (ODbL)',
|
||||||
|
|||||||
@@ -585,6 +585,11 @@ export const LAYER_STATE_TOKEN_RESERVATIONS =
|
|||||||
* owns stable URL ordering.
|
* owns stable URL ordering.
|
||||||
*/
|
*/
|
||||||
export const LAYER_STATE_REGISTRY = Object.freeze([
|
export const LAYER_STATE_REGISTRY = Object.freeze([
|
||||||
|
Object.freeze({
|
||||||
|
id: 'air-quality',
|
||||||
|
token: '4',
|
||||||
|
disposition: 'enabled-only',
|
||||||
|
}),
|
||||||
Object.freeze({
|
Object.freeze({
|
||||||
id: 'ais-live-vessels',
|
id: 'ais-live-vessels',
|
||||||
token: 'a',
|
token: 'a',
|
||||||
|
|||||||
@@ -202,8 +202,8 @@ function encode(state) {
|
|||||||
|
|
||||||
test('production registry is exact, canonical, and rejects incomplete contracts', async () => {
|
test('production registry is exact, canonical, and rejects incomplete contracts', async () => {
|
||||||
assert.equal(validateLayerStateRegistry(), true);
|
assert.equal(validateLayerStateRegistry(), true);
|
||||||
assert.equal(REGISTERED_LAYER_IDS.length, 30);
|
assert.equal(REGISTERED_LAYER_IDS.length, 31);
|
||||||
assert.equal(new Set(REGISTERED_LAYER_IDS).size, 30);
|
assert.equal(new Set(REGISTERED_LAYER_IDS).size, 31);
|
||||||
assert.ok(REGISTERED_LAYER_IDS.includes('transit'));
|
assert.ok(REGISTERED_LAYER_IDS.includes('transit'));
|
||||||
assert.deepEqual(REGISTERED_LAYER_IDS, [...REGISTERED_LAYER_IDS].sort());
|
assert.deepEqual(REGISTERED_LAYER_IDS, [...REGISTERED_LAYER_IDS].sort());
|
||||||
assert.deepEqual(LEGACY_LAYER_STATE_TOKENS, {
|
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)) {
|
for (const [id, token] of Object.entries(LEGACY_LAYER_STATE_TOKENS)) {
|
||||||
assert.equal(LAYER_STATE_TOKEN_RESERVATIONS[id], token);
|
assert.equal(LAYER_STATE_TOKEN_RESERVATIONS[id], token);
|
||||||
}
|
}
|
||||||
assert.equal(nextLayerStateToken(), '4');
|
assert.equal(nextLayerStateToken(), '5');
|
||||||
assert.equal(
|
assert.equal(
|
||||||
nextLayerStateToken({
|
nextLayerStateToken({
|
||||||
...LAYER_STATE_TOKEN_RESERVATIONS,
|
...LAYER_STATE_TOKEN_RESERVATIONS,
|
||||||
alpha: '4',
|
alpha: '5',
|
||||||
bravo: '5',
|
bravo: '6',
|
||||||
}),
|
}),
|
||||||
'6',
|
'7',
|
||||||
);
|
);
|
||||||
const digitsExhausted = {
|
const digitsExhausted = {
|
||||||
...LAYER_STATE_TOKEN_RESERVATIONS,
|
...LAYER_STATE_TOKEN_RESERVATIONS,
|
||||||
...Object.fromEntries(
|
...Object.fromEntries(
|
||||||
[...'456789'].map((digit) => [`prior-${digit}`, digit]),
|
[...'56789'].map((digit) => [`prior-${digit}`, digit]),
|
||||||
),
|
),
|
||||||
};
|
};
|
||||||
assert.equal(nextLayerStateToken(digitsExhausted), '00');
|
assert.equal(nextLayerStateToken(digitsExhausted), '00');
|
||||||
@@ -307,8 +307,8 @@ test('production registry is exact, canonical, and rejects incomplete contracts'
|
|||||||
assert.equal(
|
assert.equal(
|
||||||
validateLayerStateAllocations(LAYER_STATE_TOKEN_RESERVATIONS, {
|
validateLayerStateAllocations(LAYER_STATE_TOKEN_RESERVATIONS, {
|
||||||
...LAYER_STATE_TOKEN_RESERVATIONS,
|
...LAYER_STATE_TOKEN_RESERVATIONS,
|
||||||
future: '4',
|
future: '5',
|
||||||
next: '5',
|
next: '6',
|
||||||
}),
|
}),
|
||||||
true,
|
true,
|
||||||
);
|
);
|
||||||
@@ -318,7 +318,7 @@ test('production registry is exact, canonical, and rejects incomplete contracts'
|
|||||||
...LAYER_STATE_TOKEN_RESERVATIONS,
|
...LAYER_STATE_TOKEN_RESERVATIONS,
|
||||||
future: '00',
|
future: '00',
|
||||||
}),
|
}),
|
||||||
/next free token 4/,
|
/next free token 5/,
|
||||||
);
|
);
|
||||||
const beforeLastDigit = { ...digitsExhausted };
|
const beforeLastDigit = { ...digitsExhausted };
|
||||||
delete beforeLastDigit['prior-9'];
|
delete beforeLastDigit['prior-9'];
|
||||||
@@ -341,10 +341,10 @@ test('production registry is exact, canonical, and rejects incomplete contracts'
|
|||||||
assert.throws(
|
assert.throws(
|
||||||
() =>
|
() =>
|
||||||
validateLayerStateAllocations(
|
validateLayerStateAllocations(
|
||||||
{ ...LAYER_STATE_TOKEN_RESERVATIONS, merged: '4' },
|
{ ...LAYER_STATE_TOKEN_RESERVATIONS, merged: '5' },
|
||||||
{ ...LAYER_STATE_TOKEN_RESERVATIONS, merged: '4', competing: '4' },
|
{ ...LAYER_STATE_TOKEN_RESERVATIONS, merged: '5', competing: '5' },
|
||||||
),
|
),
|
||||||
/next free token 5/,
|
/next free token 6/,
|
||||||
);
|
);
|
||||||
assert.throws(
|
assert.throws(
|
||||||
() => validateLayerStateAllocations({ future: '00' }, { future: '01' }),
|
() => validateLayerStateAllocations({ future: '00' }, { future: '01' }),
|
||||||
|
|||||||
@@ -128,17 +128,23 @@ test('reservation ledger is complete, pinned, and rejects duplicate or malformed
|
|||||||
{ ...parseLayerStateTokenReservations(reservationRows) },
|
{ ...parseLayerStateTokenReservations(reservationRows) },
|
||||||
{ ...LAYER_STATE_TOKEN_RESERVATIONS },
|
{ ...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(
|
assert.deepEqual(
|
||||||
{ ...LAYER_STATE_TOKEN_RESERVATIONS },
|
{ ...LAYER_STATE_TOKEN_RESERVATIONS },
|
||||||
{
|
{
|
||||||
...LEGACY_LAYER_STATE_TOKENS,
|
...LEGACY_LAYER_STATE_TOKENS,
|
||||||
'street-level': '0',
|
'street-level': '0',
|
||||||
'gnss-integrity': '3',
|
'gnss-integrity': '3',
|
||||||
|
'air-quality': '4',
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
assert.throws(
|
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/,
|
/Missing or changed legacy/,
|
||||||
);
|
);
|
||||||
assert.throws(
|
assert.throws(
|
||||||
@@ -155,9 +161,9 @@ test('reservation ledger is complete, pinned, and rejects duplicate or malformed
|
|||||||
/Duplicate layer-state token reservation/,
|
/Duplicate layer-state token reservation/,
|
||||||
);
|
);
|
||||||
assert.equal(
|
assert.equal(
|
||||||
parseLayerStateTokenReservations([...reservationRows, ['future', '4']])
|
parseLayerStateTokenReservations([...reservationRows, ['future', '5']])
|
||||||
.future,
|
.future,
|
||||||
'4',
|
'5',
|
||||||
);
|
);
|
||||||
assert.throws(
|
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', () => {
|
test('published retired digits and competing PRs advance the merge-time allocation', () => {
|
||||||
withPublishedBase(
|
withPublishedBase(
|
||||||
{ rows: [...reservationRows, ['retired-layer', '4']] },
|
{ rows: [...reservationRows, ['retired-layer', '5']] },
|
||||||
(cwd) => {
|
(cwd) => {
|
||||||
const published = readPublishedLayerStateReservations('HEAD', cwd);
|
const published = readPublishedLayerStateReservations('HEAD', cwd);
|
||||||
assert.equal(nextLayerStateToken(published), '5');
|
assert.equal(nextLayerStateToken(published), '6');
|
||||||
assert.equal(
|
assert.equal(
|
||||||
validateLayerStateAllocations(published, {
|
validateLayerStateAllocations(published, {
|
||||||
...published,
|
...published,
|
||||||
newcomer: '5',
|
newcomer: '6',
|
||||||
}),
|
}),
|
||||||
true,
|
true,
|
||||||
);
|
);
|
||||||
@@ -190,15 +196,15 @@ test('published retired digits and competing PRs advance the merge-time allocati
|
|||||||
() =>
|
() =>
|
||||||
validateLayerStateAllocations(published, {
|
validateLayerStateAllocations(published, {
|
||||||
...published,
|
...published,
|
||||||
competing: '4',
|
competing: '5',
|
||||||
}),
|
}),
|
||||||
/next free token 5/,
|
/next free token 6/,
|
||||||
);
|
);
|
||||||
assert.throws(
|
assert.throws(
|
||||||
() =>
|
() =>
|
||||||
validateLayerStateAllocations(published, {
|
validateLayerStateAllocations(published, {
|
||||||
...LAYER_STATE_TOKEN_RESERVATIONS,
|
...LAYER_STATE_TOKEN_RESERVATIONS,
|
||||||
newcomer: '4',
|
newcomer: '5',
|
||||||
}),
|
}),
|
||||||
/changed or removed: retired-layer/,
|
/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', () => {
|
test('PR B manually replaces provisional 5 with 6 after PR A publishes 5', () => {
|
||||||
withPublishedBase({ rows: [...reservationRows, ['pr-a', '4']] }, (cwd) => {
|
withPublishedBase({ rows: [...reservationRows, ['pr-a', '5']] }, (cwd) => {
|
||||||
mkdirSync(path.join(cwd, 'scripts'));
|
mkdirSync(path.join(cwd, 'scripts'));
|
||||||
const fixtureChecker = path.join(
|
const fixtureChecker = path.join(
|
||||||
cwd,
|
cwd,
|
||||||
@@ -228,8 +234,8 @@ test('PR B manually replaces provisional 4 with 5 after PR A publishes 4', () =>
|
|||||||
|
|
||||||
writeCodecFixture(
|
writeCodecFixture(
|
||||||
cwd,
|
cwd,
|
||||||
[...reservationRows, ['pr-b', '4']],
|
[...reservationRows, ['pr-b', '5']],
|
||||||
[{ id: 'pr-b', token: '4' }],
|
[{ id: 'pr-b', token: '5' }],
|
||||||
);
|
);
|
||||||
const beforeRebase = runCheck();
|
const beforeRebase = runCheck();
|
||||||
assert.equal(beforeRebase.status, 1);
|
assert.equal(beforeRebase.status, 1);
|
||||||
@@ -237,10 +243,10 @@ test('PR B manually replaces provisional 4 with 5 after PR A publishes 4', () =>
|
|||||||
|
|
||||||
writeCodecFixture(
|
writeCodecFixture(
|
||||||
cwd,
|
cwd,
|
||||||
[...reservationRows, ['pr-a', '4'], ['pr-b', '4']],
|
[...reservationRows, ['pr-a', '5'], ['pr-b', '5']],
|
||||||
[
|
[
|
||||||
{ id: 'pr-a', token: '4' },
|
{ id: 'pr-a', token: '5' },
|
||||||
{ id: 'pr-b', token: '4' },
|
{ id: 'pr-b', token: '5' },
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
const staleRebase = runCheck();
|
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/);
|
assert.match(staleRebase.stderr, /Duplicate layer-state token reservation/);
|
||||||
|
|
||||||
const published = readPublishedLayerStateReservations('HEAD', cwd);
|
const published = readPublishedLayerStateReservations('HEAD', cwd);
|
||||||
assert.equal(nextLayerStateToken(published), '5');
|
assert.equal(nextLayerStateToken(published), '6');
|
||||||
const validBaselineRows = [...reservationRows, ['pr-a', '4']];
|
const validBaselineRows = [...reservationRows, ['pr-a', '5']];
|
||||||
writeCodecFixture(cwd, validBaselineRows, [{ id: 'pr-a', token: '4' }]);
|
writeCodecFixture(cwd, validBaselineRows, [{ id: 'pr-a', token: '5' }]);
|
||||||
const helper = spawnSync(
|
const helper = spawnSync(
|
||||||
process.execPath,
|
process.execPath,
|
||||||
[realpathSync(fixtureNext), 'pr-b'],
|
[realpathSync(fixtureNext), 'pr-b'],
|
||||||
{ cwd, encoding: 'utf8' },
|
{ cwd, encoding: 'utf8' },
|
||||||
);
|
);
|
||||||
assert.equal(helper.status, 0, helper.stderr);
|
assert.equal(helper.status, 0, helper.stderr);
|
||||||
assert.match(helper.stdout, /pr-b: 5/);
|
assert.match(helper.stdout, /pr-b: 6/);
|
||||||
assert.deepEqual(
|
assert.deepEqual(
|
||||||
JSON.parse(readFileSync(path.join(cwd, LEDGER_PATH), 'utf8')),
|
JSON.parse(readFileSync(path.join(cwd, LEDGER_PATH), 'utf8')),
|
||||||
validBaselineRows,
|
validBaselineRows,
|
||||||
@@ -265,20 +271,20 @@ test('PR B manually replaces provisional 4 with 5 after PR A publishes 4', () =>
|
|||||||
);
|
);
|
||||||
writeCodecFixture(
|
writeCodecFixture(
|
||||||
cwd,
|
cwd,
|
||||||
[...reservationRows, ['pr-a', '4'], ['pr-b', '5']],
|
[...reservationRows, ['pr-a', '5'], ['pr-b', '6']],
|
||||||
[
|
[
|
||||||
{ id: 'pr-a', token: '4' },
|
{ id: 'pr-a', token: '5' },
|
||||||
{ id: 'pr-b', token: '5' },
|
{ id: 'pr-b', token: '6' },
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
const corrected = runCheck();
|
const corrected = runCheck();
|
||||||
assert.equal(corrected.status, 0, corrected.stderr);
|
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', () => {
|
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([
|
const allDigits = parseLayerStateTokenReservations([
|
||||||
...reservationRows,
|
...reservationRows,
|
||||||
...digitRows,
|
...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 () => {
|
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-'));
|
const cwd = mkdtempSync(path.join(tmpdir(), 'gev-layer-token-width-'));
|
||||||
try {
|
try {
|
||||||
const priorDigits = [...'456789'].map((digit) => [
|
const priorDigits = [...'56789'].map((digit) => [
|
||||||
`qa-prior-digit-${digit}`,
|
`qa-prior-digit-${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']]);
|
const params = new URLSearchParams([['v', '2']]);
|
||||||
codec.encodeLayerStateParams(params, state);
|
codec.encodeLayerStateParams(params, state);
|
||||||
assert.ok(params.get('l').length > 64, 'fixture must cross the old cap');
|
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);
|
const restored = codec.decodeLayerStateParams(params);
|
||||||
assert.deepEqual(restored?.enabledLayerIds, expectedLayerIds);
|
assert.deepEqual(restored?.enabledLayerIds, expectedLayerIds);
|
||||||
assert.deepEqual(restored?.options, state.options);
|
assert.deepEqual(restored?.options, state.options);
|
||||||
@@ -385,7 +391,7 @@ test('checker reads a complete future base ledger and rejects retired-token reus
|
|||||||
},
|
},
|
||||||
(cwd) => {
|
(cwd) => {
|
||||||
const published = readPublishedLayerStateReservations('HEAD', 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.equal(published['retired-layer'], '00');
|
||||||
assert.throws(
|
assert.throws(
|
||||||
() =>
|
() =>
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
[
|
[
|
||||||
|
["air-quality", "4"],
|
||||||
["ais-live-vessels", "a"],
|
["ais-live-vessels", "a"],
|
||||||
["alpr-cameras", "p"],
|
["alpr-cameras", "p"],
|
||||||
["bhote-koshi-2026", "h"],
|
["bhote-koshi-2026", "h"],
|
||||||
|
|||||||
@@ -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
|
// schema, which may not carry top-level anyOf), and Contacts requested-radius
|
||||||
// list descriptions; the missions still ride existing tools.
|
// list descriptions; the missions still ride existing tools.
|
||||||
// Street Level and GNSS extend the manifest; broadcast caveats describe tracked and analyst aircraft.
|
// 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(
|
assert.equal(
|
||||||
crypto.createHash('sha256').update(block).digest('hex'),
|
crypto.createHash('sha256').update(block).digest('hex'),
|
||||||
'62d5df9cedf413403e4fd0a7cbe94bb2cd13736c3d8fedc84bcccd63ad253eb6',
|
'3b1d5e77c10a92f6543a4f9d1ff1870101a0306b65e5b8a6113111c81eda371e',
|
||||||
'the first-run missions must ride EXISTING tools: no schema edit, no cache bust',
|
'the first-run missions must ride EXISTING tools: no schema edit, no cache bust',
|
||||||
);
|
);
|
||||||
const instructions = fs.readFileSync(
|
const instructions = fs.readFileSync(
|
||||||
|
|||||||
@@ -2779,6 +2779,12 @@
|
|||||||
"FILTER": "FILTRO",
|
"FILTER": "FILTRO",
|
||||||
"Cloud filter strength": "Intensidad del filtro de nubes",
|
"Cloud filter strength": "Intensidad del filtro de nubes",
|
||||||
"Panoramax answered HTTP {status}": "Panoramax respondió HTTP {status}",
|
"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": {
|
"{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",
|
"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",
|
"many": "{n} secuencias a la vista · haz clic en una línea para su foto más cercana",
|
||||||
|
|||||||
@@ -2836,6 +2836,12 @@
|
|||||||
"FILTER": "ФИЛЬТР",
|
"FILTER": "ФИЛЬТР",
|
||||||
"Cloud filter strength": "Сила фильтра облаков",
|
"Cloud filter strength": "Сила фильтра облаков",
|
||||||
"Panoramax answered HTTP {status}": "Panoramax ответил: HTTP {status}",
|
"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": {
|
"{n} sequences in view · click a line for its nearest photo": {
|
||||||
"one": "{n} съёмка в кадре · щёлкните линию, чтобы открыть ближайшую фотографию",
|
"one": "{n} съёмка в кадре · щёлкните линию, чтобы открыть ближайшую фотографию",
|
||||||
"few": "{n} съёмки в кадре · щёлкните линию, чтобы открыть ближайшую фотографию",
|
"few": "{n} съёмки в кадре · щёлкните линию, чтобы открыть ближайшую фотографию",
|
||||||
|
|||||||
@@ -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<object>}
|
||||||
|
*/
|
||||||
|
getAnalystRecords(maxCount = 500) {
|
||||||
|
if (!_enabled || !_readings.length) return [];
|
||||||
|
const limit = Number.isFinite(maxCount)
|
||||||
|
? Math.max(1, Math.floor(maxCount))
|
||||||
|
: 500;
|
||||||
|
return _readings
|
||||||
|
.slice(0, limit)
|
||||||
|
.map((reading, index) => mapAnalystRecord(reading, index));
|
||||||
|
},
|
||||||
|
|
||||||
|
getStats() {
|
||||||
|
// `lastUpdate` is when a response was received; `observedAt` is when the
|
||||||
|
// network actually measured. Freshness is judged on the latter, so a feed
|
||||||
|
// that keeps answering with hours-old observations reports STALE rather
|
||||||
|
// than being narrated as live by the HUD and voice provenance envelope.
|
||||||
|
return {
|
||||||
|
count: _count,
|
||||||
|
lastUpdate: _lastUpdate,
|
||||||
|
error: _lastError,
|
||||||
|
observedAt: newestObservation(_readings),
|
||||||
|
// Only a completed refresh can be stale; before one there is no feed.
|
||||||
|
stale: _lastUpdate !== null && readingsAreStale(_readings),
|
||||||
|
};
|
||||||
|
},
|
||||||
|
};
|
||||||
|
return layer;
|
||||||
|
}
|
||||||
@@ -0,0 +1,378 @@
|
|||||||
|
/**
|
||||||
|
* @module layers/airQuality/model
|
||||||
|
*
|
||||||
|
* Air-quality readings, normalized across networks. Pure — no Cesium, no
|
||||||
|
* network.
|
||||||
|
*
|
||||||
|
* Air-quality indices are not interchangeable numbers. Canada's AQHI runs 1 to
|
||||||
|
* 10+ on health risk; the US AQI runs 0 to 500 on concentration breakpoints;
|
||||||
|
* Europe's EAQI is a five-step category. A reading is therefore carried as
|
||||||
|
* `{ provider, scale, value, band }` — who measured it, which index it is, the
|
||||||
|
* value on that index's own terms, and the band it falls in — so a second
|
||||||
|
* network can be added without retrofitting a shared numeric meaning that does
|
||||||
|
* not exist.
|
||||||
|
*
|
||||||
|
* Only ECCC's AQHI is implemented here; `AIR_QUALITY_PROVIDERS` is the seam a
|
||||||
|
* later provider registers against.
|
||||||
|
*/
|
||||||
|
import { msg } from '../../i18n/index.js';
|
||||||
|
|
||||||
|
/** Band identifiers shared across providers, in ascending severity. */
|
||||||
|
export const AIR_QUALITY_BANDS = Object.freeze([
|
||||||
|
Object.freeze({ id: 'low', label: msg('Low'), color: '#4cc9f0' }),
|
||||||
|
Object.freeze({ id: 'moderate', label: msg('Moderate'), color: '#ffd43b' }),
|
||||||
|
Object.freeze({ id: 'high', label: msg('High'), color: '#ff6b35' }),
|
||||||
|
Object.freeze({ id: 'very-high', label: msg('Very High'), color: '#c1121f' }),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const BAND_BY_ID = new Map(AIR_QUALITY_BANDS.map((band) => [band.id, band]));
|
||||||
|
|
||||||
|
/** Colour used when a station reports no usable reading. */
|
||||||
|
export const AIR_QUALITY_UNKNOWN_COLOR = '#6b7785';
|
||||||
|
|
||||||
|
export const AIR_QUALITY_OVERLAY_SOURCE_ID = 'air-quality';
|
||||||
|
export const AIR_QUALITY_OVERLAY_COHORT_LIMIT = 48;
|
||||||
|
export const AIR_QUALITY_OVERLAY_COLLISION_CAPACITY = 32;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A reading this old is stale enough that displaying it would misinform.
|
||||||
|
* AQHI publishes hourly, so six hours is five missed publications.
|
||||||
|
*/
|
||||||
|
export const OBSERVATION_MAX_AGE_MS = 6 * 60 * 60 * 1000;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* How old the newest observation may be before the layer reports itself stale.
|
||||||
|
*
|
||||||
|
* This is deliberately separate from the age at which a reading is dropped.
|
||||||
|
* `lastUpdate` records when we last *received* a response, which stays current
|
||||||
|
* even when the network publishes nothing new; freshness has to be judged on
|
||||||
|
* the source's own observation time instead. AQHI publishes hourly, so a newest
|
||||||
|
* reading beyond two hours means at least one publication was missed and the
|
||||||
|
* feed should not be described as live.
|
||||||
|
*/
|
||||||
|
export const STALE_AFTER_MS = 2 * 60 * 60 * 1000;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The newest observation time across a set of readings, or null when empty.
|
||||||
|
* @param {Array<object>} readings
|
||||||
|
* @returns {number|null} Epoch ms.
|
||||||
|
*/
|
||||||
|
export function newestObservation(readings) {
|
||||||
|
if (!Array.isArray(readings) || readings.length === 0) return null;
|
||||||
|
let newest = null;
|
||||||
|
for (const reading of readings) {
|
||||||
|
const at = Number(reading?.observedMs);
|
||||||
|
if (Number.isFinite(at) && (newest === null || at > newest)) newest = at;
|
||||||
|
}
|
||||||
|
return newest;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Is a set of readings stale, judged on source observation time? An empty
|
||||||
|
* set is stale: a refresh that kept no current observation has nothing live.
|
||||||
|
* @param {Array<object>} readings
|
||||||
|
* @param {number} [now=Date.now()]
|
||||||
|
* @returns {boolean}
|
||||||
|
*/
|
||||||
|
export function readingsAreStale(readings, now = Date.now()) {
|
||||||
|
const newest = newestObservation(readings);
|
||||||
|
// No current observation at all — every row was dropped as too old or
|
||||||
|
// unusable — is the stalest a feed can be, not a nominal one.
|
||||||
|
if (newest === null) return true;
|
||||||
|
return now - newest > STALE_AFTER_MS;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Registered provider descriptors. `coverage` is stated plainly because the
|
||||||
|
* layer is not global and the row must say so.
|
||||||
|
*/
|
||||||
|
export const AIR_QUALITY_PROVIDERS = Object.freeze({
|
||||||
|
eccc: Object.freeze({
|
||||||
|
id: 'eccc',
|
||||||
|
scale: 'AQHI',
|
||||||
|
label: msg('Air quality'),
|
||||||
|
network: 'Environment and Climate Change Canada',
|
||||||
|
coverage: 'Canada only · ECCC AQHI network',
|
||||||
|
/** ECCC's published band thresholds, evaluated in order. */
|
||||||
|
bands: Object.freeze([
|
||||||
|
Object.freeze({ id: 'low', max: 3 }),
|
||||||
|
Object.freeze({ id: 'moderate', max: 6 }),
|
||||||
|
Object.freeze({ id: 'high', max: 10 }),
|
||||||
|
Object.freeze({ id: 'very-high', max: Infinity }),
|
||||||
|
]),
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
/** Band descriptor for an id, or null. */
|
||||||
|
export function airQualityBandById(bandId) {
|
||||||
|
return BAND_BY_ID.get(String(bandId ?? '')) || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Accent colour for a band id, falling back to neutral grey. */
|
||||||
|
export function airQualityColor(bandId) {
|
||||||
|
return airQualityBandById(bandId)?.color || AIR_QUALITY_UNKNOWN_COLOR;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The published AQHI value for a raw ECCC reading: an integer from 1 up.
|
||||||
|
*
|
||||||
|
* ECCC reports whole numbers with a floor of 1 — there is no "AQHI 0" and no
|
||||||
|
* "AQHI 1.08". A raw value below 1 is a legitimate low reading, not an error,
|
||||||
|
* and is published as 1.
|
||||||
|
*
|
||||||
|
* @param {number|string|null|undefined} raw - Raw `aqhi` value from the API.
|
||||||
|
* @returns {number|null} Published integer AQHI, or null when unusable.
|
||||||
|
*/
|
||||||
|
export function aqhiDisplayValue(raw) {
|
||||||
|
// Guard the empty forms BEFORE coercing: Number(null) and Number('') are both
|
||||||
|
// 0, which would otherwise floor to a confident "AQHI 1" for a station that
|
||||||
|
// reported nothing at all.
|
||||||
|
if (raw === null || raw === undefined || raw === '') return null;
|
||||||
|
if (typeof raw !== 'number' && typeof raw !== 'string') return null;
|
||||||
|
if (typeof raw === 'string' && raw.trim() === '') return null;
|
||||||
|
const value = Number(raw);
|
||||||
|
if (!Number.isFinite(value) || value < 0) return null;
|
||||||
|
return Math.max(1, Math.round(value));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The band id for a value on a provider's scale.
|
||||||
|
* @param {string} providerId
|
||||||
|
* @param {number|null} value - Already in the provider's published form.
|
||||||
|
* @returns {string|null}
|
||||||
|
*/
|
||||||
|
export function bandForValue(providerId, value) {
|
||||||
|
const provider = AIR_QUALITY_PROVIDERS[String(providerId ?? '')];
|
||||||
|
if (!provider || !Number.isFinite(value)) return null;
|
||||||
|
return provider.bands.find((band) => value <= band.max)?.id ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Display text for a value on a provider's scale.
|
||||||
|
* AQHI collapses everything above ten into the open-ended "10+".
|
||||||
|
* @param {string} providerId
|
||||||
|
* @param {number|null} value
|
||||||
|
* @returns {string}
|
||||||
|
*/
|
||||||
|
export function valueLabel(providerId, value) {
|
||||||
|
if (!Number.isFinite(value)) return '--';
|
||||||
|
if (providerId === 'eccc') return value > 10 ? '10+' : String(value);
|
||||||
|
return String(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Plain-language risk phrase for a band id. */
|
||||||
|
export function bandRiskText(bandId) {
|
||||||
|
const band = airQualityBandById(bandId);
|
||||||
|
return band ? `${band.label} health risk` : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validate a station FeatureCollection into an id-keyed catalog.
|
||||||
|
*
|
||||||
|
* Atomic: a structurally broken response returns null so the caller keeps its
|
||||||
|
* last good catalog. A station missing coordinates is skipped, since one
|
||||||
|
* unusable station is not evidence the catalog is broken.
|
||||||
|
*
|
||||||
|
* @param {object} geojson - GeoJSON FeatureCollection from `aqhi-stations`.
|
||||||
|
* @returns {Map<string, object>|null} Stations by location_id, or null.
|
||||||
|
*/
|
||||||
|
export function normalizeAqhiStations(geojson) {
|
||||||
|
if (!Array.isArray(geojson?.features)) return null;
|
||||||
|
const stations = new Map();
|
||||||
|
for (const feature of geojson.features) {
|
||||||
|
const properties = feature?.properties;
|
||||||
|
if (
|
||||||
|
!properties ||
|
||||||
|
typeof properties !== 'object' ||
|
||||||
|
Array.isArray(properties)
|
||||||
|
)
|
||||||
|
return null;
|
||||||
|
const coordinates = feature?.geometry?.coordinates;
|
||||||
|
if (!Array.isArray(coordinates) || coordinates.length < 2) continue;
|
||||||
|
const [lon, lat] = coordinates.map(Number);
|
||||||
|
if (!Number.isFinite(lon) || !Number.isFinite(lat)) continue;
|
||||||
|
if (Math.abs(lat) > 90 || Math.abs(lon) > 180) return null;
|
||||||
|
const id = String(properties.location_id ?? feature.id ?? '').trim();
|
||||||
|
if (!id) continue;
|
||||||
|
stations.set(id, {
|
||||||
|
id,
|
||||||
|
name: String(properties.location_name_en || id).trim(),
|
||||||
|
// ECCC's administrative zone: atl, que, ont, pnr, pyr.
|
||||||
|
zone: String(properties['eccc_administrative-zone'] || '').trim(),
|
||||||
|
lat,
|
||||||
|
lon,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return stations;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Join latest ECCC observations onto known stations into provider-shaped rows.
|
||||||
|
*
|
||||||
|
* The catalog is the only thing that supplies a reading its coordinates, so an
|
||||||
|
* observation without a matching station cannot be placed and is dropped.
|
||||||
|
* Readings older than OBSERVATION_MAX_AGE_MS are dropped too — a six-hour-old
|
||||||
|
* air-quality number presented as current is worse than no number.
|
||||||
|
*
|
||||||
|
* @param {object} geojson - FeatureCollection from `aqhi-observations-realtime`.
|
||||||
|
* @param {Map<string, object>} stations - Known stations by location_id.
|
||||||
|
* @param {number} [now=Date.now()] - Clock seam for tests.
|
||||||
|
* @returns {Array<object>|null} Normalized readings, or null when malformed.
|
||||||
|
*/
|
||||||
|
export function normalizeAqhiObservations(geojson, stations, now = Date.now()) {
|
||||||
|
if (!Array.isArray(geojson?.features)) return null;
|
||||||
|
if (!(stations instanceof Map)) return null;
|
||||||
|
const provider = AIR_QUALITY_PROVIDERS.eccc;
|
||||||
|
const newest = new Map();
|
||||||
|
for (const feature of geojson.features) {
|
||||||
|
const properties = feature?.properties;
|
||||||
|
if (
|
||||||
|
!properties ||
|
||||||
|
typeof properties !== 'object' ||
|
||||||
|
Array.isArray(properties)
|
||||||
|
)
|
||||||
|
return null;
|
||||||
|
const stationId = String(properties.location_id ?? '').trim();
|
||||||
|
const station = stations.get(stationId);
|
||||||
|
if (!station) continue;
|
||||||
|
|
||||||
|
const value = aqhiDisplayValue(properties.aqhi);
|
||||||
|
if (value === null) continue;
|
||||||
|
const observedMs = Date.parse(properties.observation_datetime);
|
||||||
|
if (!Number.isFinite(observedMs)) continue;
|
||||||
|
if (now - observedMs > OBSERVATION_MAX_AGE_MS) continue;
|
||||||
|
|
||||||
|
const previous = newest.get(stationId);
|
||||||
|
if (previous && previous.observedMs >= observedMs) continue;
|
||||||
|
const band = bandForValue(provider.id, value);
|
||||||
|
newest.set(stationId, {
|
||||||
|
id: `${provider.id}:${stationId}`,
|
||||||
|
provider: provider.id,
|
||||||
|
scale: provider.scale,
|
||||||
|
value,
|
||||||
|
band,
|
||||||
|
stationId,
|
||||||
|
name: station.name,
|
||||||
|
zone: station.zone,
|
||||||
|
lat: station.lat,
|
||||||
|
lon: station.lon,
|
||||||
|
observedMs,
|
||||||
|
color: airQualityColor(band),
|
||||||
|
label: valueLabel(provider.id, value),
|
||||||
|
risk: bandRiskText(band),
|
||||||
|
note: String(properties.special_notes_en || '').trim() || null,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return [...newest.values()].sort(
|
||||||
|
(a, b) => b.value - a.value || a.stationId.localeCompare(b.stationId),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build the source-owned presentation for one station label.
|
||||||
|
* @param {object} input
|
||||||
|
* @returns {object}
|
||||||
|
*/
|
||||||
|
export function createAirQualityOverlayEntry({
|
||||||
|
id,
|
||||||
|
position,
|
||||||
|
title,
|
||||||
|
accent,
|
||||||
|
value,
|
||||||
|
}) {
|
||||||
|
return {
|
||||||
|
id: String(id),
|
||||||
|
position,
|
||||||
|
variant: 'label',
|
||||||
|
title,
|
||||||
|
accent,
|
||||||
|
// Worse air wins the label budget — the reading people need to see first.
|
||||||
|
priority: Math.round(Number(value) || 0) * 1000,
|
||||||
|
collisionGroup: 'ambient-label',
|
||||||
|
paintLane: 'ambient-label',
|
||||||
|
interactive: false,
|
||||||
|
edgeFade: 'keyhole',
|
||||||
|
horizonCull: true,
|
||||||
|
terrainOcclusion: false,
|
||||||
|
gapPx: 14,
|
||||||
|
verticalOnly: true,
|
||||||
|
placement: 'above',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Keep the worst readings, with stable identity as the tie-break. */
|
||||||
|
export function selectAirQualityOverlayCohort(
|
||||||
|
entries,
|
||||||
|
limit = AIR_QUALITY_OVERLAY_COHORT_LIMIT,
|
||||||
|
) {
|
||||||
|
const cap = Math.max(
|
||||||
|
0,
|
||||||
|
Math.min(AIR_QUALITY_OVERLAY_COHORT_LIMIT, Math.floor(Number(limit) || 0)),
|
||||||
|
);
|
||||||
|
if (!Array.isArray(entries) || cap === 0) return [];
|
||||||
|
return entries
|
||||||
|
.slice()
|
||||||
|
.sort(
|
||||||
|
(a, b) =>
|
||||||
|
b.priority - a.priority || String(a.id).localeCompare(String(b.id)),
|
||||||
|
)
|
||||||
|
.slice(0, cap);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The worst readings inside a lon/lat rectangle, for the readout card.
|
||||||
|
*
|
||||||
|
* A null rectangle means the camera could not resolve one (a fully oblique or
|
||||||
|
* space-level view); everything is then in scope rather than nothing, so the
|
||||||
|
* card degrades to the worst readings overall instead of going blank.
|
||||||
|
*
|
||||||
|
* @param {Array<object>} readings
|
||||||
|
* @param {?{west:number, south:number, east:number, north:number}} rect - Degrees.
|
||||||
|
* @param {number} [limit=5]
|
||||||
|
* @returns {Array<object>}
|
||||||
|
*/
|
||||||
|
export function readingsInView(readings, rect, limit = 5) {
|
||||||
|
if (!Array.isArray(readings)) return [];
|
||||||
|
const cap = Math.max(0, Math.floor(Number(limit) || 0));
|
||||||
|
if (cap === 0) return [];
|
||||||
|
const inside = !rect
|
||||||
|
? readings
|
||||||
|
: readings.filter((reading) => {
|
||||||
|
if (reading.lat < rect.south || reading.lat > rect.north) return false;
|
||||||
|
// A rectangle crossing the antimeridian has west > east.
|
||||||
|
return rect.west <= rect.east
|
||||||
|
? reading.lon >= rect.west && reading.lon <= rect.east
|
||||||
|
: reading.lon >= rect.west || reading.lon <= rect.east;
|
||||||
|
});
|
||||||
|
return inside
|
||||||
|
.slice()
|
||||||
|
.sort((a, b) => b.value - a.value || a.stationId.localeCompare(b.stationId))
|
||||||
|
.slice(0, cap);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Map one reading to a JSON-safe analyst record. Pure — no Cesium types.
|
||||||
|
* @param {object|null|undefined} raw
|
||||||
|
* @param {number} [index=0]
|
||||||
|
* @returns {object}
|
||||||
|
*/
|
||||||
|
export function mapAnalystRecord(raw, index = 0) {
|
||||||
|
const num = (v) => (Number.isFinite(v) ? v : null);
|
||||||
|
const text = (v) => {
|
||||||
|
const t = String(v ?? '').trim();
|
||||||
|
return t || null;
|
||||||
|
};
|
||||||
|
return {
|
||||||
|
id: text(raw?.id) || `AIR-${String(index).padStart(4, '0')}`,
|
||||||
|
provider: text(raw?.provider),
|
||||||
|
scale: text(raw?.scale),
|
||||||
|
value: num(raw?.value),
|
||||||
|
band: text(raw?.band),
|
||||||
|
name: text(raw?.name),
|
||||||
|
zone: text(raw?.zone),
|
||||||
|
risk: text(raw?.risk),
|
||||||
|
lat: num(raw?.lat),
|
||||||
|
lon: num(raw?.lon),
|
||||||
|
observedMs: num(raw?.observedMs),
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,269 @@
|
|||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import test from 'node:test';
|
||||||
|
import {
|
||||||
|
AIR_QUALITY_BANDS,
|
||||||
|
AIR_QUALITY_PROVIDERS,
|
||||||
|
AIR_QUALITY_UNKNOWN_COLOR,
|
||||||
|
OBSERVATION_MAX_AGE_MS,
|
||||||
|
STALE_AFTER_MS,
|
||||||
|
airQualityColor,
|
||||||
|
aqhiDisplayValue,
|
||||||
|
bandForValue,
|
||||||
|
bandRiskText,
|
||||||
|
mapAnalystRecord,
|
||||||
|
normalizeAqhiObservations,
|
||||||
|
normalizeAqhiStations,
|
||||||
|
readingsAreStale,
|
||||||
|
readingsInView,
|
||||||
|
selectAirQualityOverlayCohort,
|
||||||
|
valueLabel,
|
||||||
|
} from './model.js';
|
||||||
|
|
||||||
|
const NOW = Date.parse('2026-09-24T18:30:00Z');
|
||||||
|
const RECENT = '2026-09-24T18:00:00Z';
|
||||||
|
const collection = (features) => ({ type: 'FeatureCollection', features });
|
||||||
|
const station = (id, name, zone, lon, lat) => ({
|
||||||
|
type: 'Feature',
|
||||||
|
geometry: { type: 'Point', coordinates: [lon, lat] },
|
||||||
|
properties: {
|
||||||
|
location_id: id,
|
||||||
|
location_name_en: name,
|
||||||
|
'eccc_administrative-zone': zone,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const observation = (id, aqhi, when = RECENT) => ({
|
||||||
|
type: 'Feature',
|
||||||
|
properties: { location_id: id, aqhi, observation_datetime: when },
|
||||||
|
});
|
||||||
|
|
||||||
|
const STATIONS = collection([
|
||||||
|
station('IAKID', 'Calgary', 'pnr', -114.0575, 51.0458),
|
||||||
|
station('OAAAA', 'Toronto', 'ont', -79.3832, 43.6532),
|
||||||
|
station('AAAAA', 'Halifax', 'atl', -63.5752, 44.6488),
|
||||||
|
]);
|
||||||
|
|
||||||
|
// ── provider-shaped records ────────────────────────────────────────────────
|
||||||
|
|
||||||
|
test('readings carry the provider-neutral {provider, scale, value, band} shape', () => {
|
||||||
|
// Indices are not interchangeable numbers: AQHI is 1-10+ on health risk,
|
||||||
|
// US AQI is 0-500 on concentration. The shape names which index a value is on
|
||||||
|
// so a second network can be added without inventing a shared meaning.
|
||||||
|
const stations = normalizeAqhiStations(STATIONS);
|
||||||
|
const [row] = normalizeAqhiObservations(
|
||||||
|
collection([observation('IAKID', 2.32)]),
|
||||||
|
stations,
|
||||||
|
NOW,
|
||||||
|
);
|
||||||
|
assert.equal(row.provider, 'eccc');
|
||||||
|
assert.equal(row.scale, 'AQHI');
|
||||||
|
assert.equal(row.value, 2);
|
||||||
|
assert.equal(row.band, 'low');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('bands map from a value on a named provider scale', () => {
|
||||||
|
assert.equal(bandForValue('eccc', 1), 'low');
|
||||||
|
assert.equal(bandForValue('eccc', 3), 'low');
|
||||||
|
assert.equal(bandForValue('eccc', 4), 'moderate');
|
||||||
|
assert.equal(bandForValue('eccc', 7), 'high');
|
||||||
|
assert.equal(bandForValue('eccc', 11), 'very-high');
|
||||||
|
// An unregistered provider cannot be banded rather than being guessed at.
|
||||||
|
assert.equal(bandForValue('unknown-network', 5), null);
|
||||||
|
assert.equal(bandForValue('eccc', null), null);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('the provider states its coverage plainly', () => {
|
||||||
|
assert.match(AIR_QUALITY_PROVIDERS.eccc.coverage, /Canada only/);
|
||||||
|
assert.equal(AIR_QUALITY_PROVIDERS.eccc.scale, 'AQHI');
|
||||||
|
});
|
||||||
|
|
||||||
|
test("values above ten collapse to the scale's open-ended label", () => {
|
||||||
|
assert.equal(valueLabel('eccc', 10), '10');
|
||||||
|
assert.equal(valueLabel('eccc', 11), '10+');
|
||||||
|
assert.equal(valueLabel('eccc', null), '--');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('every band has a distinct colour, and an unknown band is neutral', () => {
|
||||||
|
const colors = AIR_QUALITY_BANDS.map((b) => b.color);
|
||||||
|
assert.equal(new Set(colors).size, colors.length);
|
||||||
|
assert.equal(airQualityColor('nonsense'), AIR_QUALITY_UNKNOWN_COLOR);
|
||||||
|
assert.equal(bandRiskText('high'), 'High health risk');
|
||||||
|
assert.equal(bandRiskText(null), null);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── published form ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
test('AQHI rounds to the published integer form and floors at 1', () => {
|
||||||
|
assert.equal(aqhiDisplayValue(1.08), 1);
|
||||||
|
assert.equal(aqhiDisplayValue(3.5), 4);
|
||||||
|
assert.equal(aqhiDisplayValue(0), 1);
|
||||||
|
assert.equal(aqhiDisplayValue('3'), 3);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('an absent reading never becomes a confident AQHI 1', () => {
|
||||||
|
// Number(null) and Number('') are both 0, which would floor to 1.
|
||||||
|
for (const empty of [null, undefined, '', ' ', NaN, true, {}, [], -1]) {
|
||||||
|
assert.equal(aqhiDisplayValue(empty), null, `${JSON.stringify(empty)}`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── snapshot validation ────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
test('stations normalize into an id-keyed catalog carrying their zone', () => {
|
||||||
|
const stations = normalizeAqhiStations(STATIONS);
|
||||||
|
assert.equal(stations.size, 3);
|
||||||
|
assert.equal(stations.get('OAAAA').zone, 'ont');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('malformed payloads are rejected atomically', () => {
|
||||||
|
assert.equal(normalizeAqhiStations(null), null);
|
||||||
|
assert.equal(normalizeAqhiStations(collection([{ properties: null }])), null);
|
||||||
|
const stations = normalizeAqhiStations(STATIONS);
|
||||||
|
assert.equal(normalizeAqhiObservations(null, stations, NOW), null);
|
||||||
|
assert.equal(
|
||||||
|
normalizeAqhiObservations(collection([]), 'not a map', NOW),
|
||||||
|
null,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('an observation with no matching station is dropped', () => {
|
||||||
|
const stations = normalizeAqhiStations(STATIONS);
|
||||||
|
const rows = normalizeAqhiObservations(
|
||||||
|
collection([observation('IAKID', 2), observation('ZZZZZ', 9)]),
|
||||||
|
stations,
|
||||||
|
NOW,
|
||||||
|
);
|
||||||
|
assert.equal(rows.length, 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a stale reading is dropped rather than shown as current', () => {
|
||||||
|
const stations = normalizeAqhiStations(STATIONS);
|
||||||
|
const old = new Date(NOW - OBSERVATION_MAX_AGE_MS - 60_000).toISOString();
|
||||||
|
const rows = normalizeAqhiObservations(
|
||||||
|
collection([observation('IAKID', 3, old), observation('OAAAA', 4)]),
|
||||||
|
stations,
|
||||||
|
NOW,
|
||||||
|
);
|
||||||
|
assert.deepEqual(
|
||||||
|
rows.map((r) => r.stationId),
|
||||||
|
['OAAAA'],
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a snapshot whose every observation was too old is stale, not nominal', () => {
|
||||||
|
const stations = normalizeAqhiStations(STATIONS);
|
||||||
|
const old = new Date(NOW - OBSERVATION_MAX_AGE_MS - 60_000).toISOString();
|
||||||
|
const rows = normalizeAqhiObservations(
|
||||||
|
collection([observation('IAKID', 3, old), observation('OAAAA', 4, old)]),
|
||||||
|
stations,
|
||||||
|
NOW,
|
||||||
|
);
|
||||||
|
assert.deepEqual(rows, []);
|
||||||
|
assert.equal(readingsAreStale(rows, NOW), true);
|
||||||
|
assert.equal(readingsAreStale([{ observedMs: NOW - 60_000 }], NOW), false);
|
||||||
|
assert.equal(
|
||||||
|
readingsAreStale([{ observedMs: NOW - STALE_AFTER_MS - 1 }], NOW),
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('the newest reading per station wins and worst air sorts first', () => {
|
||||||
|
const stations = normalizeAqhiStations(STATIONS);
|
||||||
|
const rows = normalizeAqhiObservations(
|
||||||
|
collection([
|
||||||
|
observation('IAKID', 3, '2026-09-24T16:00:00Z'),
|
||||||
|
observation('IAKID', 7, '2026-09-24T18:00:00Z'),
|
||||||
|
observation('OAAAA', 5),
|
||||||
|
]),
|
||||||
|
stations,
|
||||||
|
NOW,
|
||||||
|
);
|
||||||
|
assert.deepEqual(
|
||||||
|
rows.map((r) => r.value),
|
||||||
|
[7, 5],
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── readout selection ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const reading = (id, value, lat, lon) => ({
|
||||||
|
id,
|
||||||
|
stationId: id,
|
||||||
|
value,
|
||||||
|
lat,
|
||||||
|
lon,
|
||||||
|
name: id,
|
||||||
|
});
|
||||||
|
|
||||||
|
test('the readout lists the worst readings inside the view rectangle', () => {
|
||||||
|
const rows = [
|
||||||
|
reading('west', 9, 51.0, -114.0),
|
||||||
|
reading('east', 4, 43.6, -79.3),
|
||||||
|
reading('mild', 2, 51.1, -114.1),
|
||||||
|
];
|
||||||
|
const inView = readingsInView(
|
||||||
|
rows,
|
||||||
|
{ west: -115, south: 50, east: -113, north: 52 },
|
||||||
|
5,
|
||||||
|
);
|
||||||
|
assert.deepEqual(
|
||||||
|
inView.map((r) => r.id),
|
||||||
|
['west', 'mild'],
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('an unresolvable view rectangle degrades to the worst overall, not to blank', () => {
|
||||||
|
// A space-level or fully oblique camera cannot produce a rectangle; the card
|
||||||
|
// should still say something useful.
|
||||||
|
const rows = [reading('a', 3, 0, 0), reading('b', 8, 10, 10)];
|
||||||
|
assert.deepEqual(
|
||||||
|
readingsInView(rows, null, 5).map((r) => r.id),
|
||||||
|
['b', 'a'],
|
||||||
|
);
|
||||||
|
assert.deepEqual(readingsInView(rows, null, 0), []);
|
||||||
|
assert.deepEqual(readingsInView(null, null, 5), []);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a view rectangle crossing the antimeridian still selects correctly', () => {
|
||||||
|
// west > east is how a dateline-crossing rectangle is expressed.
|
||||||
|
const rows = [reading('near', 5, 0, 179), reading('far', 9, 0, 100)];
|
||||||
|
const inView = readingsInView(
|
||||||
|
rows,
|
||||||
|
{ west: 170, south: -10, east: -170, north: 10 },
|
||||||
|
5,
|
||||||
|
);
|
||||||
|
assert.deepEqual(
|
||||||
|
inView.map((r) => r.id),
|
||||||
|
['near'],
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('the overlay cohort is capped and deterministic', () => {
|
||||||
|
const entries = Array.from({ length: 120 }, (_, i) => ({
|
||||||
|
id: `s${i}`,
|
||||||
|
priority: i % 11,
|
||||||
|
}));
|
||||||
|
const cohort = selectAirQualityOverlayCohort(entries);
|
||||||
|
assert.ok(cohort.length <= 48);
|
||||||
|
assert.deepEqual(
|
||||||
|
cohort.map((e) => e.id),
|
||||||
|
selectAirQualityOverlayCohort(entries).map((e) => e.id),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('analyst records are JSON-safe and name the provider and scale', () => {
|
||||||
|
const stations = normalizeAqhiStations(STATIONS);
|
||||||
|
const [row] = normalizeAqhiObservations(
|
||||||
|
collection([observation('IAKID', 2)]),
|
||||||
|
stations,
|
||||||
|
NOW,
|
||||||
|
);
|
||||||
|
const record = mapAnalystRecord(row, 0);
|
||||||
|
assert.deepEqual(JSON.parse(JSON.stringify(record)), record);
|
||||||
|
assert.equal(record.provider, 'eccc');
|
||||||
|
assert.equal(record.scale, 'AQHI');
|
||||||
|
const empty = mapAnalystRecord(undefined, 7);
|
||||||
|
assert.equal(empty.id, 'AIR-0007');
|
||||||
|
for (const [key, value] of Object.entries(empty)) {
|
||||||
|
assert.notEqual(value, undefined, `${key} must not be undefined`);
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -0,0 +1,275 @@
|
|||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import test from 'node:test';
|
||||||
|
import { createAirQualityLayer } from './index.js';
|
||||||
|
import { layerFeedState } from '../../data/feedState.js';
|
||||||
|
import { STALE_AFTER_MS } from './model.js';
|
||||||
|
|
||||||
|
function harness(source) {
|
||||||
|
const sources = [];
|
||||||
|
const events = [];
|
||||||
|
const viewer = {
|
||||||
|
dataSources: {
|
||||||
|
add(value) {
|
||||||
|
sources.push(value);
|
||||||
|
},
|
||||||
|
remove(value) {
|
||||||
|
sources.splice(sources.indexOf(value), 1);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const layer = createAirQualityLayer({
|
||||||
|
source,
|
||||||
|
overlayHost: {
|
||||||
|
setEntries(...args) {
|
||||||
|
events.push(args);
|
||||||
|
},
|
||||||
|
setVisible() {},
|
||||||
|
clearSource() {},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
layer.init(viewer);
|
||||||
|
layer.enable(viewer);
|
||||||
|
return { layer, viewer, sources, events };
|
||||||
|
}
|
||||||
|
|
||||||
|
const reading = (id, value, overrides = {}) => ({
|
||||||
|
id: `eccc:${id}`,
|
||||||
|
provider: 'eccc',
|
||||||
|
scale: 'AQHI',
|
||||||
|
value,
|
||||||
|
band: 'low',
|
||||||
|
stationId: id,
|
||||||
|
name: id,
|
||||||
|
zone: 'ont',
|
||||||
|
lat: 43.65,
|
||||||
|
lon: -79.38,
|
||||||
|
observedMs: 1_700_000_000_000,
|
||||||
|
color: '#4cc9f0',
|
||||||
|
label: String(value),
|
||||||
|
risk: 'Low health risk',
|
||||||
|
note: null,
|
||||||
|
...overrides,
|
||||||
|
});
|
||||||
|
|
||||||
|
const staticSource = (rows) => ({ getSnapshot: async () => rows });
|
||||||
|
|
||||||
|
test('a layer requires both a snapshot source and an overlay host', () => {
|
||||||
|
assert.throws(() => createAirQualityLayer(), /snapshot source/);
|
||||||
|
assert.throws(
|
||||||
|
() => createAirQualityLayer({ source: staticSource([]) }),
|
||||||
|
/overlay host/,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('initialization is single-use and owns exactly one data source', () => {
|
||||||
|
const { layer, viewer, sources } = harness(staticSource([]));
|
||||||
|
assert.equal(sources.length, 1);
|
||||||
|
assert.throws(() => layer.init(viewer), /already initialized/);
|
||||||
|
layer.destroy(viewer);
|
||||||
|
assert.equal(sources.length, 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a refresh renders readings and publishes an overlay cohort', async () => {
|
||||||
|
const { layer, events } = harness(
|
||||||
|
staticSource([reading('A', 2), reading('B', 7)]),
|
||||||
|
);
|
||||||
|
assert.equal(await layer.update(), true);
|
||||||
|
assert.equal(layer.getStats().count, 2);
|
||||||
|
assert.equal(layer.getStats().error, null);
|
||||||
|
assert.equal(events.at(-1)[0], 'air-quality');
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── the abort + enabled guard ──────────────────────────────────────────────
|
||||||
|
|
||||||
|
test('a response landing after disable cannot redraw the scene', async () => {
|
||||||
|
// The failure this prevents: disable() clears the overlay, then an in-flight
|
||||||
|
// response resolves and repopulates it behind the user's back.
|
||||||
|
let release;
|
||||||
|
const gate = new Promise((resolve) => {
|
||||||
|
release = resolve;
|
||||||
|
});
|
||||||
|
const { layer, events } = harness({
|
||||||
|
getSnapshot: async ({ signal } = {}) => {
|
||||||
|
await gate;
|
||||||
|
signal?.throwIfAborted();
|
||||||
|
return [reading('A', 5)];
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const pending = layer.update();
|
||||||
|
layer.disable();
|
||||||
|
const publishedBefore = events.length;
|
||||||
|
release();
|
||||||
|
assert.equal(await pending, false, 'a late response must not be applied');
|
||||||
|
assert.equal(
|
||||||
|
events.length,
|
||||||
|
publishedBefore,
|
||||||
|
'no overlay write after disable',
|
||||||
|
);
|
||||||
|
assert.equal(layer.getStats().count, 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('disable aborts the request in flight', async () => {
|
||||||
|
let seenSignal = null;
|
||||||
|
const { layer } = harness({
|
||||||
|
getSnapshot: async ({ signal } = {}) => {
|
||||||
|
seenSignal = signal;
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 40));
|
||||||
|
signal?.throwIfAborted();
|
||||||
|
return [];
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const pending = layer.update();
|
||||||
|
layer.disable();
|
||||||
|
await pending;
|
||||||
|
assert.ok(seenSignal, 'a signal was passed through to the source');
|
||||||
|
assert.equal(seenSignal.aborted, true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a superseded refresh does not clobber the newer one', async () => {
|
||||||
|
let call = 0;
|
||||||
|
const { layer } = harness({
|
||||||
|
getSnapshot: async ({ signal } = {}) => {
|
||||||
|
call += 1;
|
||||||
|
const mine = call;
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, mine === 1 ? 40 : 1));
|
||||||
|
signal?.throwIfAborted();
|
||||||
|
return [reading(`call-${mine}`, mine)];
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const first = layer.update();
|
||||||
|
const second = layer.update();
|
||||||
|
const [firstResult, secondResult] = await Promise.all([first, second]);
|
||||||
|
assert.equal(firstResult, false, 'the superseded call reports no update');
|
||||||
|
assert.equal(secondResult, true);
|
||||||
|
assert.equal(layer.getAnalystRecords()[0].name, 'call-2');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('update is a no-op while the layer is disabled', async () => {
|
||||||
|
const { layer } = harness(staticSource([reading('A', 2)]));
|
||||||
|
layer.disable();
|
||||||
|
assert.equal(await layer.update(), false);
|
||||||
|
assert.equal(layer.getStats().count, 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── readout descriptor ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
test('the readout descriptor carries the band legend and coverage', async () => {
|
||||||
|
const { layer } = harness(staticSource([reading('A', 2), reading('B', 9)]));
|
||||||
|
await layer.update();
|
||||||
|
const controls = layer.getRowControls();
|
||||||
|
assert.equal(controls.readout, true);
|
||||||
|
assert.match(controls.summary.coverage, /Canada only/);
|
||||||
|
// The Weather panel's legend shape: one {color, label} per band.
|
||||||
|
assert.equal(controls.legendCategorical, true);
|
||||||
|
assert.deepEqual(
|
||||||
|
controls.legend.map((entry) => entry.label),
|
||||||
|
['Low', 'Moderate', 'High', 'Very High'],
|
||||||
|
);
|
||||||
|
assert.ok(
|
||||||
|
controls.legend.every((entry) => /^#[0-9a-f]{6}$/i.test(entry.color)),
|
||||||
|
);
|
||||||
|
// Worst first, and every item keyed.
|
||||||
|
assert.deepEqual(
|
||||||
|
controls.list.items.map((i) => i.text),
|
||||||
|
['B · Low health risk', 'A · Low health risk'],
|
||||||
|
);
|
||||||
|
assert.ok(controls.list.items.every((i) => i.id));
|
||||||
|
assert.match(controls.summary.status, /2 stations reporting/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('the readout reports an error rather than a silent empty card', async () => {
|
||||||
|
const { layer } = harness({
|
||||||
|
getSnapshot: async () => {
|
||||||
|
throw new Error('ECCC HTTP 503');
|
||||||
|
},
|
||||||
|
});
|
||||||
|
await layer.update();
|
||||||
|
assert.match(layer.getRowControls().summary.status, /503/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('row-control listeners are notified on refresh and teardown-safe', async () => {
|
||||||
|
const { layer, viewer } = harness(staticSource([reading('A', 2)]));
|
||||||
|
let calls = 0;
|
||||||
|
layer.setRowControlsListener(() => {
|
||||||
|
calls += 1;
|
||||||
|
});
|
||||||
|
await layer.update();
|
||||||
|
assert.ok(calls >= 1, 'a refresh notifies the card');
|
||||||
|
layer.destroy(viewer);
|
||||||
|
const after = calls;
|
||||||
|
layer.setRowControlsListener(() => {
|
||||||
|
calls += 1;
|
||||||
|
});
|
||||||
|
assert.equal(calls, after);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── feed-state honesty ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
test('freshness is judged on observation time, not on when we last fetched', async () => {
|
||||||
|
// lastUpdate stays current every time the network answers, even when it
|
||||||
|
// answers with nothing new. Judging freshness on it would let the HUD and
|
||||||
|
// voice describe hours-old air quality as live.
|
||||||
|
const aged = Date.now() - STALE_AFTER_MS - 60_000;
|
||||||
|
const { layer } = harness(
|
||||||
|
staticSource([reading('A', 2, { observedMs: aged })]),
|
||||||
|
);
|
||||||
|
await layer.update();
|
||||||
|
const stats = layer.getStats();
|
||||||
|
assert.ok(
|
||||||
|
stats.lastUpdate >= Date.now() - 5_000,
|
||||||
|
'we did just receive a response',
|
||||||
|
);
|
||||||
|
assert.equal(stats.observedAt, aged, 'but the observation itself is old');
|
||||||
|
assert.equal(stats.stale, true);
|
||||||
|
assert.equal(
|
||||||
|
layerFeedState(stats),
|
||||||
|
'stale',
|
||||||
|
'provenance must not read this as nominal',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a current reading reports nominal', async () => {
|
||||||
|
const { layer } = harness(
|
||||||
|
staticSource([reading('A', 2, { observedMs: Date.now() - 10 * 60_000 })]),
|
||||||
|
);
|
||||||
|
await layer.update();
|
||||||
|
const stats = layer.getStats();
|
||||||
|
assert.equal(stats.stale, false);
|
||||||
|
assert.equal(layerFeedState(stats), 'nominal');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a refresh that kept no current observation reports stale, not nominal', async () => {
|
||||||
|
// Every observation older than OBSERVATION_MAX_AGE_MS is dropped, so a feed
|
||||||
|
// that only has hours-old rows yields an empty snapshot. That is the
|
||||||
|
// stalest a feed can be; it must not read as a healthy empty layer.
|
||||||
|
const { layer } = harness(staticSource([]));
|
||||||
|
assert.equal(layer.getStats().stale, false, 'no refresh yet, no verdict');
|
||||||
|
await layer.update();
|
||||||
|
const stats = layer.getStats();
|
||||||
|
assert.equal(stats.count, 0);
|
||||||
|
assert.equal(stats.observedAt, null);
|
||||||
|
assert.equal(stats.stale, true);
|
||||||
|
assert.equal(layerFeedState(stats), 'stale');
|
||||||
|
assert.equal(layer.getRowControls().summary.status, 'No current readings');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a failed refresh over good readings reports degraded, not nominal', async () => {
|
||||||
|
let fail = false;
|
||||||
|
const { layer } = harness({
|
||||||
|
getSnapshot: async () => {
|
||||||
|
if (fail) throw new Error('ECCC HTTP 503');
|
||||||
|
return [reading('A', 2, { observedMs: Date.now() - 10 * 60_000 })];
|
||||||
|
},
|
||||||
|
});
|
||||||
|
await layer.update();
|
||||||
|
assert.equal(layerFeedState(layer.getStats()), 'nominal');
|
||||||
|
fail = true;
|
||||||
|
await layer.update();
|
||||||
|
const stats = layer.getStats();
|
||||||
|
assert.ok(stats.count > 0, 'the previous readings are still displayed');
|
||||||
|
assert.equal(
|
||||||
|
layerFeedState(stats),
|
||||||
|
'degraded',
|
||||||
|
'showing old data after a failure is degraded',
|
||||||
|
);
|
||||||
|
});
|
||||||
@@ -0,0 +1,104 @@
|
|||||||
|
import { readResponseJsonCapped } from '../../sources/httpBody.js';
|
||||||
|
import { normalizeAqhiStations, normalizeAqhiObservations } from './model.js';
|
||||||
|
|
||||||
|
const COLLECTIONS_ROOT = 'https://api.weather.gc.ca/collections';
|
||||||
|
const STATIONS_URL = `${COLLECTIONS_ROOT}/aqhi-stations/items`;
|
||||||
|
const OBSERVATIONS_URL = `${COLLECTIONS_ROOT}/aqhi-observations-realtime/items`;
|
||||||
|
|
||||||
|
/** Comfortably above Canada's ~134 stations without inviting a huge page. */
|
||||||
|
const STATION_LIMIT = 500;
|
||||||
|
const OBSERVATION_LIMIT = 500;
|
||||||
|
/** Station geometry is near-static — a few changes a year. Refetch twice a day. */
|
||||||
|
const STATION_TTL_MS = 12 * 60 * 60 * 1000;
|
||||||
|
/** A full 500-row GeoJSON page is a few hundred KB; anything past this is not a page. */
|
||||||
|
const MAX_RESPONSE_BYTES = 4 * 1024 * 1024;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Request and validate a complete ECCC AQHI snapshot before it can replace
|
||||||
|
* displayed readings. The first registered air-quality provider.
|
||||||
|
*
|
||||||
|
* Two collections are involved. `aqhi-stations` is the catalog — it supplies
|
||||||
|
* every reading its coordinates and is cached for twelve hours because it
|
||||||
|
* barely changes. `aqhi-observations-realtime` is the hourly reading feed,
|
||||||
|
* requested with `latest=true` so the response is current values rather than
|
||||||
|
* the full observation history, which is roughly 2,000 rows nationally.
|
||||||
|
*
|
||||||
|
* The station catalog is only replaced when a refresh actually returns
|
||||||
|
* stations, so a transient empty response cannot blank the layer.
|
||||||
|
*
|
||||||
|
* @param {object} [options]
|
||||||
|
* @param {Function} [options.fetchImpl] - Injected fetch, for tests.
|
||||||
|
* @param {() => number} [options.now] - Clock seam, for tests.
|
||||||
|
* @param {number} [options.timeoutMs=15000] - Deadline for each request.
|
||||||
|
* @returns {{getSnapshot: Function}}
|
||||||
|
*/
|
||||||
|
export function createEcccAirQualitySource({
|
||||||
|
fetchImpl = (...args) => globalThis.fetch(...args),
|
||||||
|
now = () => Date.now(),
|
||||||
|
timeoutMs = 15_000,
|
||||||
|
} = {}) {
|
||||||
|
/** @type {Map<string, object>} */
|
||||||
|
let stations = new Map();
|
||||||
|
let stationsFetchedAt = 0;
|
||||||
|
|
||||||
|
/** One bounded GeoJSON page: deadline, caller abort, byte cap, row cap. */
|
||||||
|
const getJson = async (url, limit, signal) => {
|
||||||
|
const controller = new AbortController();
|
||||||
|
const abort = () => controller.abort(signal.reason);
|
||||||
|
signal?.addEventListener('abort', abort, { once: true });
|
||||||
|
const timer = setTimeout(
|
||||||
|
() => controller.abort(new Error('ECCC request timed out')),
|
||||||
|
timeoutMs,
|
||||||
|
);
|
||||||
|
try {
|
||||||
|
signal?.throwIfAborted();
|
||||||
|
const response = await fetchImpl(url, {
|
||||||
|
headers: { Accept: 'application/json' },
|
||||||
|
signal: controller.signal,
|
||||||
|
});
|
||||||
|
if (!response.ok) throw new Error(`ECCC HTTP ${response.status}`);
|
||||||
|
const payload = await readResponseJsonCapped(
|
||||||
|
response,
|
||||||
|
MAX_RESPONSE_BYTES,
|
||||||
|
controller.signal,
|
||||||
|
);
|
||||||
|
// The server's `limit` is a request, not a guarantee.
|
||||||
|
if (Array.isArray(payload?.features) && payload.features.length > limit)
|
||||||
|
return { ...payload, features: payload.features.slice(0, limit) };
|
||||||
|
return payload;
|
||||||
|
} finally {
|
||||||
|
clearTimeout(timer);
|
||||||
|
signal?.removeEventListener('abort', abort);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
async getSnapshot({ signal } = {}) {
|
||||||
|
const clock = now();
|
||||||
|
if (stations.size === 0 || clock - stationsFetchedAt > STATION_TTL_MS) {
|
||||||
|
const payload = await getJson(
|
||||||
|
`${STATIONS_URL}?f=json&limit=${STATION_LIMIT}`,
|
||||||
|
STATION_LIMIT,
|
||||||
|
signal,
|
||||||
|
);
|
||||||
|
const next = normalizeAqhiStations(payload);
|
||||||
|
if (!next) throw new Error('Malformed AQHI station response');
|
||||||
|
// An empty refresh must not wipe a good catalog.
|
||||||
|
if (next.size > 0) {
|
||||||
|
stations = next;
|
||||||
|
stationsFetchedAt = clock;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (stations.size === 0) throw new Error('No AQHI stations available');
|
||||||
|
|
||||||
|
const payload = await getJson(
|
||||||
|
`${OBSERVATIONS_URL}?f=json&latest=true&limit=${OBSERVATION_LIMIT}`,
|
||||||
|
OBSERVATION_LIMIT,
|
||||||
|
signal,
|
||||||
|
);
|
||||||
|
const readings = normalizeAqhiObservations(payload, stations, clock);
|
||||||
|
if (!readings) throw new Error('Malformed AQHI observation response');
|
||||||
|
return readings;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,197 @@
|
|||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import test from 'node:test';
|
||||||
|
import { createEcccAirQualitySource } from './source.js';
|
||||||
|
|
||||||
|
const NOW = Date.parse('2026-09-24T18:30:00Z');
|
||||||
|
const HOUR = 60 * 60 * 1000;
|
||||||
|
const station = (id) => ({
|
||||||
|
type: 'Feature',
|
||||||
|
geometry: { type: 'Point', coordinates: [-79.38, 43.65] },
|
||||||
|
properties: { location_id: id, location_name_en: id },
|
||||||
|
});
|
||||||
|
const observation = (id, at = NOW, aqhi = 3) => ({
|
||||||
|
type: 'Feature',
|
||||||
|
properties: {
|
||||||
|
location_id: id,
|
||||||
|
aqhi,
|
||||||
|
observation_datetime: new Date(at - 10 * 60_000).toISOString(),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const json = (body, init) =>
|
||||||
|
new Response(JSON.stringify(body), {
|
||||||
|
headers: { 'content-type': 'application/json' },
|
||||||
|
...init,
|
||||||
|
});
|
||||||
|
|
||||||
|
/** A fetch that serves the station catalog and observations from mutable state. */
|
||||||
|
function feed() {
|
||||||
|
const state = {
|
||||||
|
clock: NOW,
|
||||||
|
stations: [station('A')],
|
||||||
|
stationIds: null,
|
||||||
|
urls: [],
|
||||||
|
};
|
||||||
|
state.source = createEcccAirQualitySource({
|
||||||
|
now: () => state.clock,
|
||||||
|
fetchImpl: async (url) => {
|
||||||
|
state.urls.push(url);
|
||||||
|
return json({
|
||||||
|
type: 'FeatureCollection',
|
||||||
|
// Every known station reports ten minutes before the current clock.
|
||||||
|
features: url.includes('aqhi-stations')
|
||||||
|
? state.stations
|
||||||
|
: (state.stationIds || ['A']).map((id) =>
|
||||||
|
observation(id, state.clock),
|
||||||
|
),
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
state.stationRequests = () =>
|
||||||
|
state.urls.filter((url) => url.includes('aqhi-stations')).length;
|
||||||
|
return state;
|
||||||
|
}
|
||||||
|
|
||||||
|
test('the station catalog is cached for twelve hours, then refetched', async () => {
|
||||||
|
const state = feed();
|
||||||
|
await state.source.getSnapshot();
|
||||||
|
state.clock = NOW + 12 * HOUR;
|
||||||
|
await state.source.getSnapshot();
|
||||||
|
assert.equal(state.stationRequests(), 1, 'within the TTL the cache serves');
|
||||||
|
state.clock = NOW + 12 * HOUR + 1;
|
||||||
|
await state.source.getSnapshot();
|
||||||
|
assert.equal(
|
||||||
|
state.stationRequests(),
|
||||||
|
2,
|
||||||
|
'past the TTL the catalog refreshes',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('an empty catalog refresh keeps the previous catalog', async () => {
|
||||||
|
const state = feed();
|
||||||
|
await state.source.getSnapshot();
|
||||||
|
state.stations = [];
|
||||||
|
state.clock = NOW + 13 * HOUR;
|
||||||
|
const readings = await state.source.getSnapshot();
|
||||||
|
assert.deepEqual(
|
||||||
|
readings.map((reading) => reading.stationId),
|
||||||
|
['A'],
|
||||||
|
);
|
||||||
|
// The empty refresh did not count as fresh, so the next call retries it.
|
||||||
|
await state.source.getSnapshot();
|
||||||
|
assert.equal(state.stationRequests(), 3);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('an empty first catalog is an error, not an empty layer', async () => {
|
||||||
|
const state = feed();
|
||||||
|
state.stations = [];
|
||||||
|
await assert.rejects(state.source.getSnapshot(), /No AQHI stations/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('requests ask for JSON, the latest observations only, and a bounded page', async () => {
|
||||||
|
const state = feed();
|
||||||
|
await state.source.getSnapshot();
|
||||||
|
const [stations, observations] = state.urls.map((url) => new URL(url));
|
||||||
|
assert.equal(stations.pathname, '/collections/aqhi-stations/items');
|
||||||
|
assert.equal(stations.searchParams.get('f'), 'json');
|
||||||
|
assert.equal(stations.searchParams.get('limit'), '500');
|
||||||
|
assert.equal(
|
||||||
|
observations.pathname,
|
||||||
|
'/collections/aqhi-observations-realtime/items',
|
||||||
|
);
|
||||||
|
// Without latest=true the collection returns the full history (~2,000 rows).
|
||||||
|
assert.equal(observations.searchParams.get('latest'), 'true');
|
||||||
|
assert.equal(observations.searchParams.get('limit'), '500');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('rows past the requested limit are ignored', async () => {
|
||||||
|
const state = feed();
|
||||||
|
state.stations = Array.from({ length: 600 }, (_, index) =>
|
||||||
|
station(`S${String(index).padStart(3, '0')}`),
|
||||||
|
);
|
||||||
|
state.stationIds = state.stations.map((row) => row.properties.location_id);
|
||||||
|
const readings = await state.source.getSnapshot();
|
||||||
|
assert.equal(readings.length, 500);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a malformed catalog and HTTP errors reject', async () => {
|
||||||
|
await assert.rejects(
|
||||||
|
createEcccAirQualitySource({
|
||||||
|
fetchImpl: async () => json({ features: 'no' }),
|
||||||
|
}).getSnapshot(),
|
||||||
|
/Malformed AQHI station/,
|
||||||
|
);
|
||||||
|
await assert.rejects(
|
||||||
|
createEcccAirQualitySource({
|
||||||
|
fetchImpl: async () => json({}, { status: 503 }),
|
||||||
|
}).getSnapshot(),
|
||||||
|
/ECCC HTTP 503/,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('an oversized response is refused before it is parsed', async () => {
|
||||||
|
const big = 'x'.repeat(4 * 1024 * 1024 + 1);
|
||||||
|
await assert.rejects(
|
||||||
|
createEcccAirQualitySource({
|
||||||
|
fetchImpl: async () =>
|
||||||
|
new Response(new Blob([big]).stream(), {
|
||||||
|
headers: { 'content-type': 'application/json' },
|
||||||
|
}),
|
||||||
|
}).getSnapshot(),
|
||||||
|
(error) => error.code === 'RESPONSE_TOO_LARGE',
|
||||||
|
);
|
||||||
|
await assert.rejects(
|
||||||
|
createEcccAirQualitySource({
|
||||||
|
fetchImpl: async () =>
|
||||||
|
json({}, { headers: { 'content-length': String(5 * 1024 * 1024) } }),
|
||||||
|
}).getSnapshot(),
|
||||||
|
(error) => error.code === 'RESPONSE_TOO_LARGE',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a caller abort cancels the request and detaches its listener', async () => {
|
||||||
|
const caller = new AbortController();
|
||||||
|
let requestSignal = null;
|
||||||
|
const source = createEcccAirQualitySource({
|
||||||
|
fetchImpl: (_url, { signal }) => {
|
||||||
|
requestSignal = signal;
|
||||||
|
return new Promise((_resolve, reject) =>
|
||||||
|
signal.addEventListener('abort', () => reject(signal.reason), {
|
||||||
|
once: true,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const pending = source.getSnapshot({ signal: caller.signal });
|
||||||
|
caller.abort(new Error('layer disabled'));
|
||||||
|
await assert.rejects(pending, /layer disabled/);
|
||||||
|
assert.equal(requestSignal.aborted, true);
|
||||||
|
const already = new AbortController();
|
||||||
|
already.abort(new Error('before start'));
|
||||||
|
let calls = 0;
|
||||||
|
await assert.rejects(
|
||||||
|
createEcccAirQualitySource({
|
||||||
|
fetchImpl: async () => {
|
||||||
|
calls += 1;
|
||||||
|
return json({ features: [] });
|
||||||
|
},
|
||||||
|
}).getSnapshot({ signal: already.signal }),
|
||||||
|
/before start/,
|
||||||
|
);
|
||||||
|
assert.equal(calls, 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a request that outlives its deadline is aborted', async (t) => {
|
||||||
|
t.mock.timers.enable({ apis: ['setTimeout'] });
|
||||||
|
const source = createEcccAirQualitySource({
|
||||||
|
timeoutMs: 1_000,
|
||||||
|
fetchImpl: (_url, { signal }) =>
|
||||||
|
new Promise((_resolve, reject) =>
|
||||||
|
signal.addEventListener('abort', () => reject(signal.reason), {
|
||||||
|
once: true,
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
});
|
||||||
|
const pending = source.getSnapshot();
|
||||||
|
t.mock.timers.tick(1_000);
|
||||||
|
await assert.rejects(pending, /ECCC request timed out/);
|
||||||
|
});
|
||||||
@@ -437,6 +437,7 @@ export function createCyclonesLayer({
|
|||||||
})),
|
})),
|
||||||
},
|
},
|
||||||
chips: [],
|
chips: [],
|
||||||
|
legendCategorical: true,
|
||||||
legend: storm
|
legend: storm
|
||||||
? [
|
? [
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import { EARTHQUAKE_OVERLAY_COHORT_LIMIT } from '../data/earthquakes.js';
|
|||||||
import { ROCKET_MISSION_AMBIENT_OVERLAY_COHORT_LIMIT } from '../data/rocketLaunches.js';
|
import { ROCKET_MISSION_AMBIENT_OVERLAY_COHORT_LIMIT } from '../data/rocketLaunches.js';
|
||||||
import { RADIO_OVERLAY_COHORT_LIMIT } from '../data/radio.js';
|
import { RADIO_OVERLAY_COHORT_LIMIT } from '../data/radio.js';
|
||||||
import { CABLE_REFERENCE_LABEL_WINNER_CAP } from '../data/telegeographySubmarineCables.js';
|
import { CABLE_REFERENCE_LABEL_WINNER_CAP } from '../data/telegeographySubmarineCables.js';
|
||||||
|
import { AIR_QUALITY_OVERLAY_COHORT_LIMIT } from '../layers/airQuality/model.js';
|
||||||
import { isCalibratedAllocationRuntime } from '../../scripts/run-unit-tests.mjs';
|
import { isCalibratedAllocationRuntime } from '../../scripts/run-unit-tests.mjs';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -34,6 +35,7 @@ import { isCalibratedAllocationRuntime } from '../../scripts/run-unit-tests.mjs'
|
|||||||
* ---------------------+---------+------------+---------+----------------+-------------+-------------
|
* ---------------------+---------+------------+---------+----------------+-------------+-------------
|
||||||
* generic below cap | 60 | 60 | 60 | 3182 | 53.0 | 4,100
|
* generic below cap | 60 | 60 | 60 | 3182 | 53.0 | 4,100
|
||||||
* generic above cap | 250 | 250 | 96 | 10022 | 40.1 | 13,000
|
* generic above cap | 250 | 250 | 96 | 10022 | 40.1 | 13,000
|
||||||
|
* air quality | 48 | 48 | 32 | 3900 | 81.3 | 5,200
|
||||||
* local infrastructure | 320 | 320 | 192 | 39478 | 123.4 | 49,000
|
* local infrastructure | 320 | 320 | 192 | 39478 | 123.4 | 49,000
|
||||||
* Phase 3 + FIRMS | 338 | 338 | 210 | 41220 | 122.0 | 53,600
|
* Phase 3 + FIRMS | 338 | 338 | 210 | 41220 | 122.0 | 53,600
|
||||||
* Phase 3 + vessels | 451 | 451 | 311 | 67252 | 149.1 | 87,500
|
* Phase 3 + vessels | 451 | 451 | 311 | 67252 | 149.1 | 87,500
|
||||||
@@ -259,6 +261,23 @@ const WORKLOADS = [
|
|||||||
maxBytesPerFrame: 19_000,
|
maxBytesPerFrame: 19_000,
|
||||||
saturated: true,
|
saturated: true,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
// The air-quality station cohort on its own. Measured standalone rather
|
||||||
|
// than stacked on the Phase-5 host (the submarine-cable precedent): these
|
||||||
|
// stations are sparse and, with the single registered provider,
|
||||||
|
// continental, so what matters is its own cohort cost.
|
||||||
|
//
|
||||||
|
// Re-measured against the current worker: median 3,900 B/frame, max 4,114,
|
||||||
|
// and 85.7 B/candidate at the ceiling — inside the shared 154 B/candidate
|
||||||
|
// bound, so no per-source exception is taken. The 5,200 frame budget
|
||||||
|
// carries 26.4% headroom over the measured maximum.
|
||||||
|
name: 'with the air-quality station cohort live',
|
||||||
|
profile: 'air-quality',
|
||||||
|
entries: AIR_QUALITY_OVERLAY_COHORT_LIMIT,
|
||||||
|
candidates: AIR_QUALITY_OVERLAY_COHORT_LIMIT,
|
||||||
|
maxBytesPerFrame: 5_200,
|
||||||
|
saturated: true,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
name: 'with the Dense detection lane active over 5,000 observations',
|
name: 'with the Dense detection lane active over 5,000 observations',
|
||||||
profile: 'phase6-detection',
|
profile: 'phase6-detection',
|
||||||
|
|||||||
@@ -26,6 +26,12 @@ import {
|
|||||||
TRACKED_OVERLAY_SOURCE_OPTIONS,
|
TRACKED_OVERLAY_SOURCE_OPTIONS,
|
||||||
} from '../data/trackedReadout.js';
|
} from '../data/trackedReadout.js';
|
||||||
import { CCTV_AMBIENT_CARD_MAX } from '../data/cctvLod.js';
|
import { CCTV_AMBIENT_CARD_MAX } from '../data/cctvLod.js';
|
||||||
|
import {
|
||||||
|
AIR_QUALITY_OVERLAY_COHORT_LIMIT,
|
||||||
|
AIR_QUALITY_OVERLAY_COLLISION_CAPACITY,
|
||||||
|
AIR_QUALITY_OVERLAY_SOURCE_ID,
|
||||||
|
createAirQualityOverlayEntry,
|
||||||
|
} from '../layers/airQuality/model.js';
|
||||||
import {
|
import {
|
||||||
CCTV_OVERLAY_SOURCE_ID,
|
CCTV_OVERLAY_SOURCE_ID,
|
||||||
createCctvThumbnailOverlayEntry,
|
createCctvThumbnailOverlayEntry,
|
||||||
@@ -1110,6 +1116,69 @@ function buildSubmarineCablesWorkload(count) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The air-quality station cohort at full size.
|
||||||
|
*
|
||||||
|
* Measured standalone (the submarine-cable precedent) rather than stacked on
|
||||||
|
* the Phase-5 host: air-quality stations are sparse and, with the single
|
||||||
|
* currently-registered provider, continental — so the question a budget has to
|
||||||
|
* answer is what its own cohort costs, not what it adds to a worldwide worst
|
||||||
|
* case it never shares a viewport with. The entry shape is the same static
|
||||||
|
* `ambient-label` the earthquake layer publishes, so the per-candidate ceiling
|
||||||
|
* is the meaningful gate.
|
||||||
|
*/
|
||||||
|
function buildAirQualityWorkload(count) {
|
||||||
|
if (count !== AIR_QUALITY_OVERLAY_COHORT_LIMIT) {
|
||||||
|
throw new Error(
|
||||||
|
`air-quality requires ${AIR_QUALITY_OVERLAY_COHORT_LIMIT} entries`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const workload = {
|
||||||
|
entries: [],
|
||||||
|
positions: [],
|
||||||
|
drifts: [],
|
||||||
|
registrations: [],
|
||||||
|
};
|
||||||
|
const columns = Math.max(1, Math.ceil(Math.sqrt((count * 16) / 9)));
|
||||||
|
const rows = Math.max(1, Math.ceil(count / columns));
|
||||||
|
const spanX = columns > 1 ? 1.68 / (columns - 1) : 0;
|
||||||
|
const spanY = rows > 1 ? 1.56 / (rows - 1) : 0;
|
||||||
|
const stations = [];
|
||||||
|
for (let index = 0; index < count; index++) {
|
||||||
|
const column = index % columns;
|
||||||
|
const row = Math.floor(index / columns);
|
||||||
|
const baseX = -0.84 + column * spanX;
|
||||||
|
const baseY = -0.78 + row * spanY;
|
||||||
|
const position = new Cesium.Cartesian3(baseX, baseY, 0);
|
||||||
|
workload.positions.push(position);
|
||||||
|
workload.drifts.push({
|
||||||
|
baseX,
|
||||||
|
baseY,
|
||||||
|
phase: index * 0.27,
|
||||||
|
rate: 0.32 + (index % 6) * 0.04,
|
||||||
|
});
|
||||||
|
const entry = createAirQualityOverlayEntry({
|
||||||
|
id: `air-${index}`,
|
||||||
|
position,
|
||||||
|
// i18n-ignore: synthetic allocation-benchmark fixture, not shown as UI
|
||||||
|
title: `Station ${index} ${1 + (index % 10)}`,
|
||||||
|
accent: '#4cc9f0',
|
||||||
|
value: 1 + (index % 10),
|
||||||
|
});
|
||||||
|
entry.horizonCull = false;
|
||||||
|
stations.push(entry);
|
||||||
|
}
|
||||||
|
workload.registrations.push({
|
||||||
|
sourceId: AIR_QUALITY_OVERLAY_SOURCE_ID,
|
||||||
|
entries: stations,
|
||||||
|
options: {
|
||||||
|
cohortLimit: AIR_QUALITY_OVERLAY_COHORT_LIMIT,
|
||||||
|
collisionCapacity: AIR_QUALITY_OVERLAY_COLLISION_CAPACITY,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return workload;
|
||||||
|
}
|
||||||
|
|
||||||
function buildPhase6DetectionWorkload(count) {
|
function buildPhase6DetectionWorkload(count) {
|
||||||
const random = makeRandom(0xd37ec710);
|
const random = makeRandom(0xd37ec710);
|
||||||
const positions = [];
|
const positions = [];
|
||||||
@@ -1215,8 +1284,12 @@ function main() {
|
|||||||
? buildPhase5RocketMissionWorkload(ENTRY_COUNT)
|
? buildPhase5RocketMissionWorkload(ENTRY_COUNT)
|
||||||
: PROFILE === 'all-live-radio'
|
: PROFILE === 'all-live-radio'
|
||||||
? buildAllLiveRadioWorkload(ENTRY_COUNT)
|
? buildAllLiveRadioWorkload(ENTRY_COUNT)
|
||||||
|
: PROFILE === 'air-quality'
|
||||||
|
? buildAirQualityWorkload(ENTRY_COUNT)
|
||||||
: PROFILE === 'submarine-cables'
|
: PROFILE === 'submarine-cables'
|
||||||
? buildSubmarineCablesWorkload(ENTRY_COUNT)
|
? buildSubmarineCablesWorkload(
|
||||||
|
ENTRY_COUNT,
|
||||||
|
)
|
||||||
: buildWorkload(ENTRY_COUNT);
|
: buildWorkload(ENTRY_COUNT);
|
||||||
const { entries, positions, drifts } = workload;
|
const { entries, positions, drifts } = workload;
|
||||||
const solveIntervalMs = Number(process.env.GEV_ALLOC_SOLVE_MS) || 125;
|
const solveIntervalMs = Number(process.env.GEV_ALLOC_SOLVE_MS) || 125;
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { createUsgsEarthquakeSource } from '../layers/earthquakes/source.js';
|
import { createUsgsEarthquakeSource } from '../layers/earthquakes/source.js';
|
||||||
|
import { createEcccAirQualitySource } from '../layers/airQuality/source.js';
|
||||||
import { createWfigsPerimeterSource } from '../layers/perimeters/source.js';
|
import { createWfigsPerimeterSource } from '../layers/perimeters/source.js';
|
||||||
import { createBundledCableSource } from '../layers/submarineCables/bundledSource.js';
|
import { createBundledCableSource } from '../layers/submarineCables/bundledSource.js';
|
||||||
import { createAdsbGnssSource } from '../layers/gnss/source.js';
|
import { createAdsbGnssSource } from '../layers/gnss/source.js';
|
||||||
@@ -7,6 +8,7 @@ import { createAdsbGnssSource } from '../layers/gnss/source.js';
|
|||||||
export function createReferenceSources() {
|
export function createReferenceSources() {
|
||||||
return {
|
return {
|
||||||
earthquakes: createUsgsEarthquakeSource(),
|
earthquakes: createUsgsEarthquakeSource(),
|
||||||
|
'air-quality': createEcccAirQualitySource(),
|
||||||
'fire-perimeters': createWfigsPerimeterSource(),
|
'fire-perimeters': createWfigsPerimeterSource(),
|
||||||
cables: createBundledCableSource(),
|
cables: createBundledCableSource(),
|
||||||
'gnss-integrity': createAdsbGnssSource(),
|
'gnss-integrity': createAdsbGnssSource(),
|
||||||
|
|||||||
@@ -14,15 +14,18 @@ test('reference factories retain compatibility without starting acquisition or s
|
|||||||
const second = createReferenceSources();
|
const second = createReferenceSources();
|
||||||
assert.deepEqual(Object.keys(first), [
|
assert.deepEqual(Object.keys(first), [
|
||||||
'earthquakes',
|
'earthquakes',
|
||||||
|
'air-quality',
|
||||||
'fire-perimeters',
|
'fire-perimeters',
|
||||||
'cables',
|
'cables',
|
||||||
'gnss-integrity',
|
'gnss-integrity',
|
||||||
]);
|
]);
|
||||||
assert.notEqual(first.earthquakes, second.earthquakes);
|
assert.notEqual(first.earthquakes, second.earthquakes);
|
||||||
|
assert.notEqual(first['air-quality'], second['air-quality']);
|
||||||
assert.notEqual(first['fire-perimeters'], second['fire-perimeters']);
|
assert.notEqual(first['fire-perimeters'], second['fire-perimeters']);
|
||||||
assert.notEqual(first.cables, second.cables);
|
assert.notEqual(first.cables, second.cables);
|
||||||
assert.notEqual(first['gnss-integrity'], second['gnss-integrity']);
|
assert.notEqual(first['gnss-integrity'], second['gnss-integrity']);
|
||||||
assert.equal(typeof first.earthquakes.getSnapshot, 'function');
|
assert.equal(typeof first.earthquakes.getSnapshot, 'function');
|
||||||
|
assert.equal(typeof first['air-quality'].getSnapshot, 'function');
|
||||||
assert.equal(typeof first['fire-perimeters'].getSnapshot, 'function');
|
assert.equal(typeof first['fire-perimeters'].getSnapshot, 'function');
|
||||||
assert.equal(typeof first.cables.fetch, 'function');
|
assert.equal(typeof first.cables.fetch, 'function');
|
||||||
assert.equal(typeof first['gnss-integrity'].getSnapshot, 'function');
|
assert.equal(typeof first['gnss-integrity'].getSnapshot, 'function');
|
||||||
|
|||||||
@@ -90,6 +90,7 @@ const PANEL_GROUPS = [
|
|||||||
{
|
{
|
||||||
label: msg('Weather'),
|
label: msg('Weather'),
|
||||||
ids: [
|
ids: [
|
||||||
|
'air-quality',
|
||||||
'wind',
|
'wind',
|
||||||
'weather-radar',
|
'weather-radar',
|
||||||
'weather-satellite',
|
'weather-satellite',
|
||||||
@@ -569,6 +570,7 @@ export class LayerPanel {
|
|||||||
'weather-satellite',
|
'weather-satellite',
|
||||||
'weather-lightning',
|
'weather-lightning',
|
||||||
'weather-cyclones',
|
'weather-cyclones',
|
||||||
|
'air-quality',
|
||||||
].includes(layer.id),
|
].includes(layer.id),
|
||||||
)
|
)
|
||||||
.map((layer) => ({
|
.map((layer) => ({
|
||||||
|
|||||||
@@ -329,3 +329,31 @@ test('the unavailable chip may abbreviate, but its accessible name keeps the ful
|
|||||||
assert.equal(button.dataset.feedState, 'unavailable');
|
assert.equal(button.dataset.feedState, 'unavailable');
|
||||||
assert.match(attrs.get('aria-label'), /^Active Fires: UNAVAILABLE\./);
|
assert.match(attrs.get('aria-label'), /^Active Fires: UNAVAILABLE\./);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('the Weather readout receives enabled air-quality alongside weather layers', async () => {
|
||||||
|
const { LayerPanel } = await import('./layerPanel.js');
|
||||||
|
const layers = ['wind', 'air-quality', 'other'].map((id) => ({
|
||||||
|
id,
|
||||||
|
icon: '*',
|
||||||
|
enabled: true,
|
||||||
|
}));
|
||||||
|
layers.push({ id: 'weather-radar', icon: '*', enabled: false });
|
||||||
|
const panel = new LayerPanel({
|
||||||
|
getLayers: () => layers,
|
||||||
|
getRowControls: (id) => ({ readout: true, summary: { label: id } }),
|
||||||
|
});
|
||||||
|
let entries = null;
|
||||||
|
panel._weatherPanel = {
|
||||||
|
update: (next) => {
|
||||||
|
entries = next;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
panel._refreshWeatherPanel();
|
||||||
|
assert.deepEqual(
|
||||||
|
entries.map(({ id, summary }) => [id, summary.label]),
|
||||||
|
[
|
||||||
|
['wind', 'wind'],
|
||||||
|
['air-quality', 'air-quality'],
|
||||||
|
],
|
||||||
|
);
|
||||||
|
});
|
||||||
@@ -11,8 +11,9 @@ const ORDER = [
|
|||||||
'weather-radar',
|
'weather-radar',
|
||||||
'weather-satellite',
|
'weather-satellite',
|
||||||
'weather-lightning',
|
'weather-lightning',
|
||||||
|
'air-quality',
|
||||||
];
|
];
|
||||||
const OBSERVED = new Set(ORDER.slice(2));
|
const OBSERVED = new Set(ORDER.slice(2, 5));
|
||||||
const utc = (time) =>
|
const utc = (time) =>
|
||||||
Number.isFinite(Date.parse(time))
|
Number.isFinite(Date.parse(time))
|
||||||
? `${formatUtcDayMonth(time)} ${utcClock(time)}`
|
? `${formatUtcDayMonth(time)} ${utcClock(time)}`
|
||||||
@@ -166,7 +167,10 @@ export function createWeatherPanel({
|
|||||||
? t('LATEST · newest per product')
|
? t('LATEST · newest per product')
|
||||||
: historyTime(state.target),
|
: historyTime(state.target),
|
||||||
});
|
});
|
||||||
const models = active.map(({ id, icon, summary, legend = [], list }) => {
|
const models = active.map((entry) => {
|
||||||
|
const { id, icon, summary, legend = [], list } = entry;
|
||||||
|
// Discrete classes (storm geometry, AQHI bands) list swatches, not a ramp.
|
||||||
|
const categorical = entry.legendCategorical === true;
|
||||||
let detail = summary.detail;
|
let detail = summary.detail;
|
||||||
const product = state.products.find((item) => item.id === id);
|
const product = state.products.find((item) => item.id === id);
|
||||||
if (OBSERVED.has(id)) {
|
if (OBSERVED.has(id)) {
|
||||||
@@ -207,17 +211,15 @@ export function createWeatherPanel({
|
|||||||
id: 'legend',
|
id: 'legend',
|
||||||
type: 'legend',
|
type: 'legend',
|
||||||
legend: {
|
legend: {
|
||||||
categorical: id === 'weather-cyclones',
|
categorical,
|
||||||
colors: legend.map(({ color }) => color),
|
colors: legend.map(({ color }) => color),
|
||||||
labels: legend.map(({ label }) => label),
|
labels: legend.map(({ label }) => label),
|
||||||
units: id === 'weather-cyclones' ? '' : summary.units,
|
units: categorical ? '' : summary.units,
|
||||||
zeroIndex: legend.findIndex(({ label }) => label === '0'),
|
zeroIndex: legend.findIndex(({ label }) => label === '0'),
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
const blocks = [
|
const blocks = [
|
||||||
...(id === 'weather-cyclones' && list?.items?.length
|
...(list?.items?.length ? [{ id: 'list', type: 'list', list }] : []),
|
||||||
? [{ id: 'storms', type: 'list', list }]
|
|
||||||
: []),
|
|
||||||
{ id: 'details', type: 'lines', lines },
|
{ id: 'details', type: 'lines', lines },
|
||||||
...(legend.length ? [legendBlock] : []),
|
...(legend.length ? [legendBlock] : []),
|
||||||
...(summary.settings?.length
|
...(summary.settings?.length
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import assert from 'node:assert/strict';
|
|||||||
import { setLocale } from '../i18n/index.js';
|
import { setLocale } from '../i18n/index.js';
|
||||||
import { createWeatherPanel } from './weatherPanel.js';
|
import { createWeatherPanel } from './weatherPanel.js';
|
||||||
import { railFixture } from './railTestFixture.mjs';
|
import { railFixture } from './railTestFixture.mjs';
|
||||||
|
import { createAirQualityLayer } from '../layers/airQuality/index.js';
|
||||||
const ticks = ['2026-09-22T01:00:00Z', '2026-09-22T01:05:00Z'];
|
const ticks = ['2026-09-22T01:00:00Z', '2026-09-22T01:05:00Z'];
|
||||||
const wind = {
|
const wind = {
|
||||||
id: 'wind',
|
id: 'wind',
|
||||||
@@ -167,6 +168,7 @@ test('cyclone readout retains advisory, position, intensity, geometry, coverage,
|
|||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
legendCategorical: true,
|
||||||
legend: [
|
legend: [
|
||||||
{ label: 'Advisory center / forecast track', color: '#7fe6ed' },
|
{ label: 'Advisory center / forecast track', color: '#7fe6ed' },
|
||||||
{ label: 'Center-track uncertainty cone', color: '#7fe6ed44' },
|
{ label: 'Center-track uncertainty cone', color: '#7fe6ed44' },
|
||||||
@@ -411,7 +413,7 @@ test('cyclones lead, and observed history owns one bordered group with only acti
|
|||||||
assert.deepEqual(opened(f), ['weather-cyclones']);
|
assert.deepEqual(opened(f), ['weather-cyclones']);
|
||||||
assert.equal(
|
assert.equal(
|
||||||
card(f, cyclone.id).children[1].children[0].dataset.blockId,
|
card(f, cyclone.id).children[1].children[0].dataset.blockId,
|
||||||
'storms',
|
'list',
|
||||||
);
|
);
|
||||||
view.update([wind, satellite]);
|
view.update([wind, satellite]);
|
||||||
assert.equal(group.children[1].textContent, 'Satellite clouds');
|
assert.equal(group.children[1].textContent, 'Satellite clouds');
|
||||||
@@ -686,3 +688,68 @@ test('history time keeps the hour whatever the length of the localized date (K-6
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
||||||
|
test('air-quality readout renders its band legend, Canada coverage and worst-first list', async () => {
|
||||||
|
const observedMs = Date.now() - 10 * 60_000;
|
||||||
|
const reading = (stationId, value, band, name) => ({
|
||||||
|
id: `eccc:${stationId}`,
|
||||||
|
provider: 'eccc',
|
||||||
|
scale: 'AQHI',
|
||||||
|
value,
|
||||||
|
band,
|
||||||
|
stationId,
|
||||||
|
name,
|
||||||
|
zone: 'on',
|
||||||
|
lat: 43.65,
|
||||||
|
lon: -79.38,
|
||||||
|
observedMs,
|
||||||
|
color: '#4cc9f0',
|
||||||
|
label: String(value),
|
||||||
|
risk: `${band} risk`,
|
||||||
|
note: null,
|
||||||
|
});
|
||||||
|
const layer = createAirQualityLayer({
|
||||||
|
source: {
|
||||||
|
getSnapshot: async () => [
|
||||||
|
reading('B', 8, 'high', 'Hamilton'),
|
||||||
|
reading('A', 2, 'low', 'Toronto'),
|
||||||
|
],
|
||||||
|
},
|
||||||
|
overlayHost: { setEntries() {}, setVisible() {}, clearSource() {} },
|
||||||
|
});
|
||||||
|
layer.init({ dataSources: { add() {}, remove() {} } });
|
||||||
|
layer.enable();
|
||||||
|
await layer.update();
|
||||||
|
const f = fixture();
|
||||||
|
const view = createWeatherPanel(f);
|
||||||
|
view.update([{ id: layer.id, icon: layer.icon, ...layer.getRowControls() }]);
|
||||||
|
const aq = card(f, 'air-quality');
|
||||||
|
assert.ok(aq, 'the air-quality card is shown');
|
||||||
|
assert.equal(aq.dataset.open, 'true');
|
||||||
|
assert.match(
|
||||||
|
f.find((n) => n.classList?.contains?.('rail-card-badge'), aq).textContent,
|
||||||
|
/Canada only/,
|
||||||
|
);
|
||||||
|
assert.match(line(f, 'air-quality', 'status').textContent, /2 stations/);
|
||||||
|
const entries = f.find((n) => n.className === 'rail-card-legend-entries', aq);
|
||||||
|
assert.equal(entries.hidden, false);
|
||||||
|
assert.deepEqual(
|
||||||
|
entries.children.map((entry) => entry.children[1].textContent),
|
||||||
|
['Low', 'Moderate', 'High', 'Very High'],
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
f.find((n) => n.className === 'rail-card-ramp', aq).hidden,
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
const list = f.find((n) => n.dataset.blockId === 'list', aq);
|
||||||
|
assert.equal(list.hidden, false);
|
||||||
|
assert.deepEqual(
|
||||||
|
list.children.map((item) =>
|
||||||
|
item.children[0].children.map((part) => part.textContent).join(' '),
|
||||||
|
),
|
||||||
|
['AQHI 8 Hamilton · high risk', 'AQHI 2 Toronto · low risk'],
|
||||||
|
);
|
||||||
|
view.destroy();
|
||||||
|
layer.destroy();
|
||||||
|
});
|
||||||
@@ -37,7 +37,8 @@ test('the complete Realtime tool payload pins the manifest-generated layer relea
|
|||||||
// omit top-level anyOf (OpenAI rejects it). Contacts list routing preserves
|
// omit top-level anyOf (OpenAI rejects it). Contacts list routing preserves
|
||||||
// requested radius and center authority in descriptions only.
|
// requested radius and center authority in descriptions only.
|
||||||
// Street Level and GNSS extend the manifest; tracked and analyst aircraft describe broadcasts.
|
// Street Level and GNSS extend the manifest; tracked and analyst aircraft describe broadcasts.
|
||||||
'9398815a573ab29ecc3ce527f6e1a2ad883804e7696e0444aebb11f973700d4b',
|
// Air quality (ECCC AQHI) extends the manifest.
|
||||||
|
'775ba323384b4c1b62fb8415bac52aecc3fc668e20bedcfa57e426dcd1da3969',
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -250,6 +250,24 @@ const layers = [
|
|||||||
context: false,
|
context: false,
|
||||||
noQuery: 'map overlay; no countable records',
|
noQuery: 'map overlay; no countable records',
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
id: 'air-quality',
|
||||||
|
aliases: ['air quality', 'aqhi', 'smog', 'air pollution'],
|
||||||
|
context: false,
|
||||||
|
query: {
|
||||||
|
fields: {
|
||||||
|
value: ['n', 'AQHI'],
|
||||||
|
band: ['t'],
|
||||||
|
scale: ['t'],
|
||||||
|
provider: ['t'],
|
||||||
|
name: ['t'],
|
||||||
|
zone: ['t'],
|
||||||
|
risk: ['t'],
|
||||||
|
observedMs: ['ms'],
|
||||||
|
},
|
||||||
|
timeField: 'observedMs',
|
||||||
|
},
|
||||||
|
},
|
||||||
{
|
{
|
||||||
id: 'traffic',
|
id: 'traffic',
|
||||||
aliases: ['street traffic', 'road traffic', 'congestion'],
|
aliases: ['street traffic', 'road traffic', 'congestion'],
|
||||||
|
|||||||
Reference in new issue
Block a user