mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-05 19:04:02 +00:00
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:
1 parent
046126eb36
commit
129174c3ab
17 files changed
+440
-148
No files matched your search
@@ -382,6 +382,7 @@ export class LayerPanel {
|
||||
'weather-satellite',
|
||||
'weather-lightning',
|
||||
'weather-cyclones',
|
||||
'air-quality',
|
||||
].includes(layer.id),
|
||||
)
|
||||
.map((layer) => ({
|
||||
|
||||
@@ -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'],
|
||||
],
|
||||
);
|
||||
});
|
||||
@@ -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
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
Reference in new issue
Block a user