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'],