diff --git a/CHANGELOG.md b/CHANGELOG.md
index f905cb369..88657ed10 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -147,6 +147,19 @@
## [Unreleased]
+- Add a keyless Air quality layer to the Weather group, with Environment and
+ Climate Change Canada's AQHI as the first registered provider. Readings carry
+ `{ provider, scale, value, band }` so another network can be added without
+ inventing a shared numeric meaning across indices that do not share one; the
+ row and readout both state that coverage is Canada only. The WEATHER card
+ shows the band legend and the highest readings currently in view. AQHI is
+ reported as an integer from 1 with an open-ended "10+", so readings round to
+ that published form rather than the decimal precision the API returns.
+ Readings older than six hours are dropped, and the layer reports `stale`
+ through `getStats()` (also when every observation was dropped) so feed
+ provenance cannot describe an aged feed as live. Voice can switch it on and
+ query readings by value, band, station and observation time.
+
- Satellite clouds keep Clouds only / Full as quick endpoints and add a continuous
cloud-filter slider: 0% is Full, 100% is the existing brightness key, and
intermediate values blend the keyed alpha. The slider commits on release to
@@ -1041,19 +1054,6 @@ Launch payloads with missing records now say PAYLOAD DATA UNAVAILABLE. Missing n
This changelog records public product changes. For the authoritative description
of current runtime behavior, see [`docs/CURRENT-STATE.md`](docs/CURRENT-STATE.md).
-## [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()` so feed provenance cannot describe an aged feed as live.
-
### Other changes
- Add ECMWF IFS model selection to Wind (#464, thanks @beneduzi), with model-scoped forecast-step caches, cancellation of replaced requests, and separate issue/valid timestamps.
diff --git a/scripts/package-boundaries.json b/scripts/package-boundaries.json
index 9739b1e66..d0e604336 100644
--- a/scripts/package-boundaries.json
+++ b/scripts/package-boundaries.json
@@ -3587,7 +3587,8 @@
"modules": [
"src/layers/airQuality/index.js",
"src/layers/airQuality/model.js",
- "src/layers/airQuality/source.js"
+ "src/layers/airQuality/source.js",
+ "src/sources/httpBody.js"
],
"external": ["cesium"]
},
@@ -3595,7 +3596,8 @@
"exports": ["./layers/air-quality/source"],
"modules": [
"src/layers/airQuality/model.js",
- "src/layers/airQuality/source.js"
+ "src/layers/airQuality/source.js",
+ "src/sources/httpBody.js"
],
"external": []
}
diff --git a/src/data/dataCredits.js b/src/data/dataCredits.js
index 6311f2eec..9c9dee71f 100644
--- a/src/data/dataCredits.js
+++ b/src/data/dataCredits.js
@@ -174,7 +174,7 @@ export const DATA_CREDITS = [
html:
'Air quality (AQHI): Environment and Climate Change Canada — MSC GeoMet ' +
'(eccc-msc.github.io/open-data ) ' +
- '· ECCC Data Servers End-use Licence',
+ '· ECCC Data Servers End-use Licence ',
},
{
key: 'overture-military-names',
diff --git a/src/firstRunExperience.test.mjs b/src/firstRunExperience.test.mjs
index a75ac91fd..50dbb15c9 100644
--- a/src/firstRunExperience.test.mjs
+++ b/src/firstRunExperience.test.mjs
@@ -674,10 +674,11 @@ test('the voice TOOL SCHEMA matches the pinned contract — the mission mapping
// schema, which may not carry top-level anyOf), and Contacts requested-radius
// list descriptions; the missions still ride existing tools.
// Street Level and GNSS extend the manifest; broadcast caveats describe tracked and analyst aircraft.
- assert.equal(block.length, 30511, 'serialized tool schema length drifted');
+ // Air quality (ECCC AQHI) extends the manifest.
+ assert.equal(block.length, 30655, 'serialized tool schema length drifted');
assert.equal(
crypto.createHash('sha256').update(block).digest('hex'),
- '62d5df9cedf413403e4fd0a7cbe94bb2cd13736c3d8fedc84bcccd63ad253eb6',
+ '3b1d5e77c10a92f6543a4f9d1ff1870101a0306b65e5b8a6113111c81eda371e',
'the first-run missions must ride EXISTING tools: no schema edit, no cache bust',
);
const instructions = fs.readFileSync(new URL('../server/providers/openai/instructions.js', import.meta.url), 'utf8');
diff --git a/src/layers/airQuality/index.js b/src/layers/airQuality/index.js
index 43a76f857..804f9a795 100644
--- a/src/layers/airQuality/index.js
+++ b/src/layers/airQuality/index.js
@@ -224,24 +224,25 @@ export function createAirQualityLayer({ source, overlayHost } = {}) {
status: _lastError
? _lastError
: _count === 0
- ? 'No readings available'
+ ? _lastUpdate === null
+ ? 'No readings available'
+ : 'No current readings'
: `${_count} stations reporting`,
units: provider.scale,
},
- legend: {
- colors: AIR_QUALITY_BANDS.map((band) => band.color),
- labels: AIR_QUALITY_BANDS.map((band) => band.label),
- units: provider.scale,
- categorical: true,
- },
+ legend: AIR_QUALITY_BANDS.map((band) => ({
+ color: band.color,
+ label: band.label,
+ })),
+ legendCategorical: true,
list: {
ariaLabel: 'Highest air-quality readings in view',
- items: inView.map((reading) => ({
+ items: inView.map((reading, index) => ({
id: reading.id,
- label: reading.name,
- value: `${reading.scale} ${reading.label}`,
+ ordinal: index + 1,
+ lead: `${reading.scale} ${reading.label}`,
+ text: `${reading.name} · ${reading.risk}`,
accent: reading.color,
- detail: reading.risk,
})),
},
};
@@ -251,30 +252,6 @@ export function createAirQualityLayer({ source, overlayHost } = {}) {
rowControlsListener = typeof listener === 'function' ? listener : null;
},
- /**
- * The current reading nearest a point — what "the air quality here" means.
- * @param {number} lat
- * @param {number} lon
- * @returns {object|null} Reading with distanceKm, or null when none loaded.
- */
- nearestReading(lat, lon) {
- if (!Number.isFinite(lat) || !Number.isFinite(lon) || !_readings.length)
- return null;
- const cosLat = Math.cos((lat * Math.PI) / 180);
- let best = null;
- let bestDistance = Infinity;
- for (const reading of _readings) {
- const dLat = (reading.lat - lat) * 111.32;
- const dLon = (reading.lon - lon) * 111.32 * cosLat;
- const distance = Math.hypot(dLat, dLon);
- if (distance < bestDistance) {
- bestDistance = distance;
- best = reading;
- }
- }
- return best ? { ...best, distanceKm: bestDistance } : null;
- },
-
/**
* Snapshot readings as JSON-safe records for the analyst query engine.
* @param {number} [maxCount=500]
@@ -300,7 +277,8 @@ export function createAirQualityLayer({ source, overlayHost } = {}) {
lastUpdate: _lastUpdate,
error: _lastError,
observedAt: newestObservation(_readings),
- stale: readingsAreStale(_readings),
+ // Only a completed refresh can be stale; before one there is no feed.
+ stale: _lastUpdate !== null && readingsAreStale(_readings),
};
},
};
diff --git a/src/layers/airQuality/model.js b/src/layers/airQuality/model.js
index 93f13404f..1e0eab47e 100644
--- a/src/layers/airQuality/model.js
+++ b/src/layers/airQuality/model.js
@@ -67,14 +67,17 @@ export function newestObservation(readings) {
}
/**
- * Is a set of readings stale, judged on source observation time?
+ * 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} readings
* @param {number} [now=Date.now()]
* @returns {boolean}
*/
export function readingsAreStale(readings, now = Date.now()) {
const newest = newestObservation(readings);
- if (newest === null) return false; // nothing to be stale about
+ // 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;
}
diff --git a/src/layers/airQuality/model.test.mjs b/src/layers/airQuality/model.test.mjs
index 56130c30e..b4f1a643e 100644
--- a/src/layers/airQuality/model.test.mjs
+++ b/src/layers/airQuality/model.test.mjs
@@ -5,6 +5,7 @@ import {
AIR_QUALITY_PROVIDERS,
AIR_QUALITY_UNKNOWN_COLOR,
OBSERVATION_MAX_AGE_MS,
+ STALE_AFTER_MS,
airQualityColor,
aqhiDisplayValue,
bandForValue,
@@ -12,6 +13,7 @@ import {
mapAnalystRecord,
normalizeAqhiObservations,
normalizeAqhiStations,
+ readingsAreStale,
readingsInView,
selectAirQualityOverlayCohort,
valueLabel,
@@ -147,6 +149,23 @@ test('a stale reading is dropped rather than shown as current', () => {
);
});
+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(
diff --git a/src/layers/airQuality/ownership.test.mjs b/src/layers/airQuality/ownership.test.mjs
index 5eeaae0e1..78ab8a0bb 100644
--- a/src/layers/airQuality/ownership.test.mjs
+++ b/src/layers/airQuality/ownership.test.mjs
@@ -1,7 +1,6 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { createAirQualityLayer } from './index.js';
-import { createEcccAirQualitySource } from './source.js';
import { layerFeedState } from '../../data/feedState.js';
import { STALE_AFTER_MS } from './model.js';
@@ -160,18 +159,19 @@ test('the readout descriptor carries the band legend and coverage', async () =>
const controls = layer.getRowControls();
assert.equal(controls.readout, true);
assert.match(controls.summary.coverage, /Canada only/);
- assert.equal(controls.legend.categorical, true);
- assert.deepEqual(controls.legend.labels, [
- 'Low',
- 'Moderate',
- 'High',
- 'Very High',
- ]);
- assert.equal(controls.legend.colors.length, controls.legend.labels.length);
+ // 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.label),
- ['B', 'A'],
+ 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/);
@@ -203,64 +203,6 @@ test('row-control listeners are notified on refresh and teardown-safe', async ()
assert.equal(calls, after);
});
-// ── source contract ────────────────────────────────────────────────────────
-
-test('the source rejects a malformed station catalog and surfaces HTTP errors', async () => {
- await assert.rejects(
- () =>
- createEcccAirQualitySource({
- fetchImpl: async () => ({
- ok: true,
- json: async () => ({ features: 'no' }),
- }),
- }).getSnapshot(),
- /Malformed AQHI station/,
- );
- await assert.rejects(
- () =>
- createEcccAirQualitySource({
- fetchImpl: async () => ({
- ok: false,
- status: 503,
- json: async () => ({}),
- }),
- }).getSnapshot(),
- /ECCC HTTP 503/,
- );
-});
-
-test('the station catalog is cached and observations ask for latest values only', async () => {
- const urls = [];
- const source = createEcccAirQualitySource({
- fetchImpl: async (url) => {
- urls.push(url);
- return {
- ok: true,
- json: async () =>
- url.includes('aqhi-stations')
- ? {
- features: [
- {
- geometry: { type: 'Point', coordinates: [-79.38, 43.65] },
- properties: { location_id: 'A', location_name_en: 'A' },
- },
- ],
- }
- : { features: [] },
- };
- },
- now: () => 1_700_000_000_000,
- });
- await source.getSnapshot();
- await source.getSnapshot();
- assert.equal(urls.filter((u) => u.includes('aqhi-stations')).length, 1);
- // Without latest=true the collection returns the full history (~2,000 rows).
- assert.match(
- urls.find((u) => u.includes('observations')),
- /latest=true/,
- );
-});
-
// ── feed-state honesty ─────────────────────────────────────────────────────
test('freshness is judged on observation time, not on when we last fetched', async () => {
@@ -296,12 +238,19 @@ test('a current reading reports nominal', async () => {
assert.equal(layerFeedState(stats), 'nominal');
});
-test('an empty feed is not called stale', async () => {
- // Nothing to be stale about; the chip should not invent a fault.
+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();
- assert.equal(layer.getStats().stale, false);
- assert.equal(layer.getStats().observedAt, null);
+ 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 () => {
diff --git a/src/layers/airQuality/source.js b/src/layers/airQuality/source.js
index b5f98894c..8fb4808ec 100644
--- a/src/layers/airQuality/source.js
+++ b/src/layers/airQuality/source.js
@@ -1,3 +1,4 @@
+import { readResponseJsonCapped } from '../../sources/httpBody.js';
import { normalizeAqhiStations, normalizeAqhiObservations } from './model.js';
const COLLECTIONS_ROOT = 'https://api.weather.gc.ca/collections';
@@ -9,6 +10,8 @@ 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
@@ -26,26 +29,47 @@ const STATION_TTL_MS = 12 * 60 * 60 * 1000;
* @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} */
let stations = new Map();
let stationsFetchedAt = 0;
- const getJson = async (url, signal) => {
- signal?.throwIfAborted();
- const response = await fetchImpl(url, {
- headers: { Accept: 'application/json' },
- signal,
- });
- if (!response.ok) throw new Error(`ECCC HTTP ${response.status}`);
- const payload = await response.json();
- signal?.throwIfAborted();
- return payload;
+ /** 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 {
@@ -54,6 +78,7 @@ export function createEcccAirQualitySource({
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);
@@ -68,6 +93,7 @@ export function createEcccAirQualitySource({
const payload = await getJson(
`${OBSERVATIONS_URL}?f=json&latest=true&limit=${OBSERVATION_LIMIT}`,
+ OBSERVATION_LIMIT,
signal,
);
const readings = normalizeAqhiObservations(payload, stations, clock);
diff --git a/src/layers/airQuality/source.test.mjs b/src/layers/airQuality/source.test.mjs
new file mode 100644
index 000000000..b9ffe34a6
--- /dev/null
+++ b/src/layers/airQuality/source.test.mjs
@@ -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/);
+});
diff --git a/src/layers/cyclones/index.js b/src/layers/cyclones/index.js
index 7664bc821..da67afe1d 100644
--- a/src/layers/cyclones/index.js
+++ b/src/layers/cyclones/index.js
@@ -386,6 +386,7 @@ export function createCyclonesLayer({
})),
},
chips: [],
+ legendCategorical: true,
legend: storm
? [
{ label: 'Advisory center / forecast track', color: '#7fe6ed' },
diff --git a/src/ui/layerPanel.js b/src/ui/layerPanel.js
index c2dafbe61..37269fa2b 100644
--- a/src/ui/layerPanel.js
+++ b/src/ui/layerPanel.js
@@ -382,6 +382,7 @@ export class LayerPanel {
'weather-satellite',
'weather-lightning',
'weather-cyclones',
+ 'air-quality',
].includes(layer.id),
)
.map((layer) => ({
diff --git a/src/ui/layerPanel.test.mjs b/src/ui/layerPanel.test.mjs
index 1258cc76a..40c5fc81e 100644
--- a/src/ui/layerPanel.test.mjs
+++ b/src/ui/layerPanel.test.mjs
@@ -258,3 +258,31 @@ test('an omitted source is visibly unavailable and cannot be toggled on', async
'an active layer can always be switched off',
);
});
+
+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'],
+ ],
+ );
+});
diff --git a/src/ui/weatherPanel.js b/src/ui/weatherPanel.js
index 908423cb7..8eac004f8 100644
--- a/src/ui/weatherPanel.js
+++ b/src/ui/weatherPanel.js
@@ -10,8 +10,9 @@ const ORDER = [
'weather-radar',
'weather-satellite',
'weather-lightning',
+ 'air-quality',
];
-const OBSERVED = new Set(ORDER.slice(2));
+const OBSERVED = new Set(ORDER.slice(2, 5));
const utc = (time) =>
Number.isFinite(Date.parse(time))
? `${new Date(time).toISOString().slice(5, 16).replace('T', ' ')} UTC`
@@ -158,7 +159,10 @@ export function createWeatherPanel({
? 'LATEST · newest per product'
: 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;
const product = state.products.find((item) => item.id === id);
if (OBSERVED.has(id)) {
@@ -191,17 +195,15 @@ export function createWeatherPanel({
id: 'legend',
type: 'legend',
legend: {
- categorical: id === 'weather-cyclones',
+ categorical,
colors: legend.map(({ color }) => color),
labels: legend.map(({ label }) => label),
- units: id === 'weather-cyclones' ? '' : summary.units,
+ units: categorical ? '' : summary.units,
zeroIndex: legend.findIndex(({ label }) => label === '0'),
},
};
const blocks = [
- ...(id === 'weather-cyclones' && list?.items?.length
- ? [{ id: 'storms', type: 'list', list }]
- : []),
+ ...(list?.items?.length ? [{ id: 'list', type: 'list', list }] : []),
{ id: 'details', type: 'lines', lines },
...(legend.length ? [legendBlock] : []),
...(summary.settings?.length
diff --git a/src/ui/weatherPanel.test.mjs b/src/ui/weatherPanel.test.mjs
index 20d463229..7015ee873 100644
--- a/src/ui/weatherPanel.test.mjs
+++ b/src/ui/weatherPanel.test.mjs
@@ -2,6 +2,7 @@ import test from 'node:test';
import assert from 'node:assert/strict';
import { createWeatherPanel } from './weatherPanel.js';
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 wind = {
id: 'wind',
@@ -166,6 +167,7 @@ test('cyclone readout retains advisory, position, intensity, geometry, coverage,
},
],
},
+ legendCategorical: true,
legend: [
{ label: 'Advisory center / forecast track', color: '#7fe6ed' },
{ label: 'Center-track uncertainty cone', color: '#7fe6ed44' },
@@ -410,7 +412,7 @@ test('cyclones lead, and observed history owns one bordered group with only acti
assert.deepEqual(opened(f), ['weather-cyclones']);
assert.equal(
card(f, cyclone.id).children[1].children[0].dataset.blockId,
- 'storms',
+ 'list',
);
view.update([wind, satellite]);
assert.equal(group.children[1].textContent, 'Satellite clouds');
@@ -654,3 +656,67 @@ test('a change of open card scrolls its header into view once; refreshes never m
assert.deepEqual(intoView, [], 'ancestor scrollers are never moved');
view.destroy();
});
+
+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();
+});
diff --git a/src/voice/actionSchemas.test.mjs b/src/voice/actionSchemas.test.mjs
index c57b4a1ab..550d725e4 100644
--- a/src/voice/actionSchemas.test.mjs
+++ b/src/voice/actionSchemas.test.mjs
@@ -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
// requested radius and center authority in descriptions only.
// Street Level and GNSS extend the manifest; tracked and analyst aircraft describe broadcasts.
- '9398815a573ab29ecc3ce527f6e1a2ad883804e7696e0444aebb11f973700d4b',
+ // Air quality (ECCC AQHI) extends the manifest.
+ '775ba323384b4c1b62fb8415bac52aecc3fc668e20bedcfa57e426dcd1da3969',
);
});
diff --git a/src/voice/layerManifest.js b/src/voice/layerManifest.js
index 286b8a5d8..eb0f09909 100644
--- a/src/voice/layerManifest.js
+++ b/src/voice/layerManifest.js
@@ -250,6 +250,24 @@ const layers = [
context: false,
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',
aliases: ['street traffic', 'road traffic', 'congestion'],