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.
This commit is contained in:
Sameh Khamis committed 2026-10-15 13:13:17 -07:00
1 parent 046126eb36
commit 129174c3ab
17 files changed
+440 -148

No files matched your search

+1
View File
@@ -382,6 +382,7 @@ export class LayerPanel {
'weather-satellite',
'weather-lightning',
'weather-cyclones',
'air-quality',
].includes(layer.id),
)
.map((layer) => ({
+28
View File
@@ -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'],
],
);
});
+9 -7
View File
@@ -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
+67 -1
View File
@@ -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();
});