From 129174c3ab7ad61e88cbabee67ce6d1d5dc6c810 Mon Sep 17 00:00:00 2001 From: Sameh Khamis Date: Thu, 15 Oct 2026 13:13:17 -0700 Subject: [PATCH] fix(air-quality): show the Weather card, add voice support and bound the feed The card never appeared: the Weather panel only passed five fixed ids and only rendered lists and categorical legends for cyclones. Air quality now reaches the panel, the legend style comes from the layer's descriptor, and any layer with list items gets its list. Adds the voice manifest entry main now requires, reads ECCC through the shared capped JSON reader with a 15 s deadline and the row limit enforced, reports stale when every reading was too old, adds source tests, links the ECCC licence and moves the changelog entry into Unreleased. --- CHANGELOG.md | 26 +-- scripts/package-boundaries.json | 6 +- src/data/dataCredits.js | 2 +- src/firstRunExperience.test.mjs | 5 +- src/layers/airQuality/index.js | 50 ++---- src/layers/airQuality/model.js | 7 +- src/layers/airQuality/model.test.mjs | 19 +++ src/layers/airQuality/ownership.test.mjs | 95 +++-------- src/layers/airQuality/source.js | 46 ++++-- src/layers/airQuality/source.test.mjs | 197 +++++++++++++++++++++++ src/layers/cyclones/index.js | 1 + src/ui/layerPanel.js | 1 + src/ui/layerPanel.test.mjs | 28 ++++ src/ui/weatherPanel.js | 16 +- src/ui/weatherPanel.test.mjs | 68 +++++++- src/voice/actionSchemas.test.mjs | 3 +- src/voice/layerManifest.js | 18 +++ 17 files changed, 440 insertions(+), 148 deletions(-) create mode 100644 src/layers/airQuality/source.test.mjs 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'],