Merge upstream/main: air-quality layer (AQHI) meets i18n

Upstream's #568 adds the Air quality layer to the Weather group (ECCC
AQHI, Canada only) with a band legend, worst-first list and voice
support.

- package-boundaries: both sides' entries unioned.
- layerPanel/weatherPanel tests: both sides' new tests kept; the
  weatherPanel fixture follows upstream (legendCategorical: true, the
  'list' block id) while our localized-history (K-6) test stays.
- air-quality strings marked: band labels and the provider label as
  msg() data, the readings-list aria-label through t(); es/ru entries
  added (+6 keys each).
- The synthetic air-quality allocation-benchmark titles follow the
  file's own convention (// i18n-ignore) like TRK/VESSEL/CAMERA.

Also verified the auto-merged weatherPanel.js against upstream: ORDER
gains 'air-quality', OBSERVED stays slice(2, 5), and the data-driven
legendCategorical path is upstream's.

npm run i18n: complete (2559), no unmarked strings. npm test: only the
pre-existing environmental OPENAI_API_KEY leak fails. Build passes.
This commit is contained in:
Meir Michanie committed 2026-10-15 23:16:24 +02:00
commit f3da0916d9
38 files changed
+2061 -62

No files matched your search

+2
View File
@@ -90,6 +90,7 @@ const PANEL_GROUPS = [
{
label: msg('Weather'),
ids: [
'air-quality',
'wind',
'weather-radar',
'weather-satellite',
@@ -569,6 +570,7 @@ export class LayerPanel {
'weather-satellite',
'weather-lightning',
'weather-cyclones',
'air-quality',
].includes(layer.id),
)
.map((layer) => ({
+28
View File
@@ -329,3 +329,31 @@ test('the unavailable chip may abbreviate, but its accessible name keeps the ful
assert.equal(button.dataset.feedState, 'unavailable');
assert.match(attrs.get('aria-label'), /^Active Fires: UNAVAILABLE\./);
});
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
@@ -11,8 +11,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))
? `${formatUtcDayMonth(time)} ${utcClock(time)}`
@@ -166,7 +167,10 @@ export function createWeatherPanel({
? t('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)) {
@@ -207,17 +211,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
+68 -1
View File
@@ -3,6 +3,7 @@ import assert from 'node:assert/strict';
import { setLocale } from '../i18n/index.js';
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',
@@ -167,6 +168,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' },
@@ -411,7 +413,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');
@@ -686,3 +688,68 @@ test('history time keeps the hour whatever the length of the localized date (K-6
}
}
});
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();
});