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

+287
View File
@@ -0,0 +1,287 @@
import * as Cesium from 'cesium';
import { t } from '../../i18n/index.js';
import {
AIR_QUALITY_BANDS,
AIR_QUALITY_OVERLAY_SOURCE_ID,
AIR_QUALITY_OVERLAY_COHORT_LIMIT,
AIR_QUALITY_OVERLAY_COLLISION_CAPACITY,
AIR_QUALITY_PROVIDERS,
createAirQualityOverlayEntry,
mapAnalystRecord,
newestObservation,
readingsAreStale,
readingsInView,
selectAirQualityOverlayCohort,
} from './model.js';
export * from './model.js';
export { createEcccAirQualitySource } from './source.js';
/** Station markers are a point reading, not an extent. */
const STATION_MARKER_RADIUS_M = 6_000;
/** Readings listed in the readout card. */
const READOUT_LIMIT = 5;
/** Own one air-quality display, its refresh lifecycle, and its readout card. */
export function createAirQualityLayer({ source, overlayHost } = {}) {
if (typeof source?.getSnapshot !== 'function')
throw new TypeError('Air quality requires a snapshot source');
if (!overlayHost) throw new TypeError('Air quality requires an overlay host');
let _viewer = null;
let _request = null;
let _dataSource = null;
let _readings = [];
let _count = 0;
let _lastUpdate = null;
let _lastError = null;
let _enabled = false;
let rowControlsListener = null;
const notify = () => rowControlsListener?.();
/** The camera's lon/lat rectangle in degrees, or null when unresolvable. */
const viewRectangle = () => {
const rectangle = _viewer?.camera?.computeViewRectangle?.();
if (!rectangle) return null;
const toDeg = Cesium.Math.toDegrees;
return {
west: toDeg(rectangle.west),
south: toDeg(rectangle.south),
east: toDeg(rectangle.east),
north: toDeg(rectangle.north),
};
};
const layer = {
id: 'air-quality',
name: 'Air quality',
icon: '◍',
source: 'ECCC MSC GeoMet',
updateInterval: 1_200_000,
init(viewer) {
if (_viewer) throw new Error('Air quality layer is already initialized');
_viewer = viewer;
_dataSource = new Cesium.CustomDataSource('air-quality');
_dataSource.show = false;
viewer.dataSources.add(_dataSource);
_readings = [];
_count = 0;
_lastUpdate = null;
_lastError = null;
_enabled = false;
overlayHost.setVisible(AIR_QUALITY_OVERLAY_SOURCE_ID, false);
},
enable() {
_enabled = true;
if (_dataSource) _dataSource.show = true;
overlayHost.setVisible(AIR_QUALITY_OVERLAY_SOURCE_ID, true);
notify();
},
disable() {
// Abort in flight: a response arriving after disable must not redraw.
_request?.abort();
_request = null;
_enabled = false;
if (_dataSource) _dataSource.show = false;
overlayHost.clearSource(AIR_QUALITY_OVERLAY_SOURCE_ID);
overlayHost.setVisible(AIR_QUALITY_OVERLAY_SOURCE_ID, false);
notify();
},
async update() {
if (!_enabled || !_dataSource) return false;
_request?.abort();
const request = new AbortController();
_request = request;
try {
const readings = await source.getSnapshot({ signal: request.signal });
// A superseded or aborted request must not touch the scene, and neither
// must one that lands after the layer was switched off.
if (request.signal.aborted || _request !== request || !_enabled)
return false;
_readings = readings;
this._render();
_count = _readings.length;
_lastUpdate = Date.now();
_lastError = null;
notify();
return true;
} catch (error) {
if (request.signal.aborted || _request !== request) return false;
_lastError = error?.message || 'Air quality request failed';
console.warn('[Data:AirQuality] Refresh failed:', _lastError);
notify();
return false;
} finally {
if (_request === request) _request = null;
}
},
/** Rebuild entities and overlay labels from the current readings. */
_render() {
if (!_dataSource) return;
_dataSource.entities.removeAll();
const overlayEntries = [];
for (const reading of _readings) {
const position = Cesium.Cartesian3.fromDegrees(
reading.lon,
reading.lat,
);
const color = Cesium.Color.fromCssColorString(reading.color);
_dataSource.entities.add(
new Cesium.Entity({
id: `air-quality:${reading.id}`,
position,
ellipse: {
// Static axes, as in the earthquake layer: a per-frame axis
// re-tessellates the clamped ground primitive every frame.
semiMajorAxis: STATION_MARKER_RADIUS_M,
semiMinorAxis: STATION_MARKER_RADIUS_M,
material: new Cesium.ColorMaterialProperty(color.withAlpha(0.35)),
outline: true,
outlineColor: color.withAlpha(0.9),
outlineWidth: 2,
heightReference: Cesium.HeightReference.CLAMP_TO_GROUND,
},
properties: {
provider: reading.provider,
scale: reading.scale,
value: reading.value,
band: reading.band,
name: reading.name,
zone: reading.zone,
risk: reading.risk,
observedMs: reading.observedMs,
note: reading.note,
lat: reading.lat,
lon: reading.lon,
},
}),
);
overlayEntries.push(
createAirQualityOverlayEntry({
id: reading.id,
position,
title: `${reading.name} ${reading.label}`,
accent: reading.color,
value: reading.value,
}),
);
}
if (_enabled) {
overlayHost.setEntries(
AIR_QUALITY_OVERLAY_SOURCE_ID,
selectAirQualityOverlayCohort(overlayEntries),
{
cohortLimit: AIR_QUALITY_OVERLAY_COHORT_LIMIT,
collisionCapacity: AIR_QUALITY_OVERLAY_COLLISION_CAPACITY,
moving: false,
},
);
}
},
destroy(viewer = _viewer) {
_request?.abort();
_request = null;
_enabled = false;
overlayHost.clearSource(AIR_QUALITY_OVERLAY_SOURCE_ID);
overlayHost.setVisible(AIR_QUALITY_OVERLAY_SOURCE_ID, false);
if (_dataSource) {
viewer?.dataSources?.remove(_dataSource, true);
_dataSource = null;
}
_viewer = null;
_readings = [];
_count = 0;
_lastUpdate = null;
_lastError = null;
rowControlsListener = null;
},
/**
* Readout descriptor for the Weather panel: the band legend, and the worst
* readings currently in view.
*
* `readout: true` renders the toggle and source line only; the card owns
* the rest. Coverage is stated plainly because this layer is not global.
*/
getRowControls() {
const provider = AIR_QUALITY_PROVIDERS.eccc;
const inView = readingsInView(_readings, viewRectangle(), READOUT_LIMIT);
const newest = _readings.reduce(
(max, reading) => Math.max(max, reading.observedMs || 0),
0,
);
return {
readout: true,
summary: {
label: provider.label,
coverage: provider.coverage,
validTime: newest ? new Date(newest).toISOString() : undefined,
detail: `${provider.network} · ${provider.scale}`,
status: _lastError
? _lastError
: _count === 0
? _lastUpdate === null
? 'No readings available'
: 'No current readings'
: `${_count} stations reporting`,
units: provider.scale,
},
legend: AIR_QUALITY_BANDS.map((band) => ({
color: band.color,
label: band.label,
})),
legendCategorical: true,
list: {
ariaLabel: t('Highest air-quality readings in view'),
items: inView.map((reading, index) => ({
id: reading.id,
ordinal: index + 1,
lead: `${reading.scale} ${reading.label}`,
text: `${reading.name} · ${reading.risk}`,
accent: reading.color,
})),
},
};
},
setRowControlsListener(listener) {
rowControlsListener = typeof listener === 'function' ? listener : null;
},
/**
* Snapshot readings as JSON-safe records for the analyst query engine.
* @param {number} [maxCount=500]
* @returns {Array<object>}
*/
getAnalystRecords(maxCount = 500) {
if (!_enabled || !_readings.length) return [];
const limit = Number.isFinite(maxCount)
? Math.max(1, Math.floor(maxCount))
: 500;
return _readings
.slice(0, limit)
.map((reading, index) => mapAnalystRecord(reading, index));
},
getStats() {
// `lastUpdate` is when a response was received; `observedAt` is when the
// network actually measured. Freshness is judged on the latter, so a feed
// that keeps answering with hours-old observations reports STALE rather
// than being narrated as live by the HUD and voice provenance envelope.
return {
count: _count,
lastUpdate: _lastUpdate,
error: _lastError,
observedAt: newestObservation(_readings),
// Only a completed refresh can be stale; before one there is no feed.
stale: _lastUpdate !== null && readingsAreStale(_readings),
};
},
};
return layer;
}
+378
View File
@@ -0,0 +1,378 @@
/**
* @module layers/airQuality/model
*
* Air-quality readings, normalized across networks. Pure — no Cesium, no
* network.
*
* Air-quality indices are not interchangeable numbers. Canada's AQHI runs 1 to
* 10+ on health risk; the US AQI runs 0 to 500 on concentration breakpoints;
* Europe's EAQI is a five-step category. A reading is therefore carried as
* `{ provider, scale, value, band }` — who measured it, which index it is, the
* value on that index's own terms, and the band it falls in — so a second
* network can be added without retrofitting a shared numeric meaning that does
* not exist.
*
* Only ECCC's AQHI is implemented here; `AIR_QUALITY_PROVIDERS` is the seam a
* later provider registers against.
*/
import { msg } from '../../i18n/index.js';
/** Band identifiers shared across providers, in ascending severity. */
export const AIR_QUALITY_BANDS = Object.freeze([
Object.freeze({ id: 'low', label: msg('Low'), color: '#4cc9f0' }),
Object.freeze({ id: 'moderate', label: msg('Moderate'), color: '#ffd43b' }),
Object.freeze({ id: 'high', label: msg('High'), color: '#ff6b35' }),
Object.freeze({ id: 'very-high', label: msg('Very High'), color: '#c1121f' }),
]);
const BAND_BY_ID = new Map(AIR_QUALITY_BANDS.map((band) => [band.id, band]));
/** Colour used when a station reports no usable reading. */
export const AIR_QUALITY_UNKNOWN_COLOR = '#6b7785';
export const AIR_QUALITY_OVERLAY_SOURCE_ID = 'air-quality';
export const AIR_QUALITY_OVERLAY_COHORT_LIMIT = 48;
export const AIR_QUALITY_OVERLAY_COLLISION_CAPACITY = 32;
/**
* A reading this old is stale enough that displaying it would misinform.
* AQHI publishes hourly, so six hours is five missed publications.
*/
export const OBSERVATION_MAX_AGE_MS = 6 * 60 * 60 * 1000;
/**
* How old the newest observation may be before the layer reports itself stale.
*
* This is deliberately separate from the age at which a reading is dropped.
* `lastUpdate` records when we last *received* a response, which stays current
* even when the network publishes nothing new; freshness has to be judged on
* the source's own observation time instead. AQHI publishes hourly, so a newest
* reading beyond two hours means at least one publication was missed and the
* feed should not be described as live.
*/
export const STALE_AFTER_MS = 2 * 60 * 60 * 1000;
/**
* The newest observation time across a set of readings, or null when empty.
* @param {Array<object>} readings
* @returns {number|null} Epoch ms.
*/
export function newestObservation(readings) {
if (!Array.isArray(readings) || readings.length === 0) return null;
let newest = null;
for (const reading of readings) {
const at = Number(reading?.observedMs);
if (Number.isFinite(at) && (newest === null || at > newest)) newest = at;
}
return newest;
}
/**
* 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<object>} readings
* @param {number} [now=Date.now()]
* @returns {boolean}
*/
export function readingsAreStale(readings, now = Date.now()) {
const newest = newestObservation(readings);
// 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;
}
/**
* Registered provider descriptors. `coverage` is stated plainly because the
* layer is not global and the row must say so.
*/
export const AIR_QUALITY_PROVIDERS = Object.freeze({
eccc: Object.freeze({
id: 'eccc',
scale: 'AQHI',
label: msg('Air quality'),
network: 'Environment and Climate Change Canada',
coverage: 'Canada only · ECCC AQHI network',
/** ECCC's published band thresholds, evaluated in order. */
bands: Object.freeze([
Object.freeze({ id: 'low', max: 3 }),
Object.freeze({ id: 'moderate', max: 6 }),
Object.freeze({ id: 'high', max: 10 }),
Object.freeze({ id: 'very-high', max: Infinity }),
]),
}),
});
/** Band descriptor for an id, or null. */
export function airQualityBandById(bandId) {
return BAND_BY_ID.get(String(bandId ?? '')) || null;
}
/** Accent colour for a band id, falling back to neutral grey. */
export function airQualityColor(bandId) {
return airQualityBandById(bandId)?.color || AIR_QUALITY_UNKNOWN_COLOR;
}
/**
* The published AQHI value for a raw ECCC reading: an integer from 1 up.
*
* ECCC reports whole numbers with a floor of 1 — there is no "AQHI 0" and no
* "AQHI 1.08". A raw value below 1 is a legitimate low reading, not an error,
* and is published as 1.
*
* @param {number|string|null|undefined} raw - Raw `aqhi` value from the API.
* @returns {number|null} Published integer AQHI, or null when unusable.
*/
export function aqhiDisplayValue(raw) {
// Guard the empty forms BEFORE coercing: Number(null) and Number('') are both
// 0, which would otherwise floor to a confident "AQHI 1" for a station that
// reported nothing at all.
if (raw === null || raw === undefined || raw === '') return null;
if (typeof raw !== 'number' && typeof raw !== 'string') return null;
if (typeof raw === 'string' && raw.trim() === '') return null;
const value = Number(raw);
if (!Number.isFinite(value) || value < 0) return null;
return Math.max(1, Math.round(value));
}
/**
* The band id for a value on a provider's scale.
* @param {string} providerId
* @param {number|null} value - Already in the provider's published form.
* @returns {string|null}
*/
export function bandForValue(providerId, value) {
const provider = AIR_QUALITY_PROVIDERS[String(providerId ?? '')];
if (!provider || !Number.isFinite(value)) return null;
return provider.bands.find((band) => value <= band.max)?.id ?? null;
}
/**
* Display text for a value on a provider's scale.
* AQHI collapses everything above ten into the open-ended "10+".
* @param {string} providerId
* @param {number|null} value
* @returns {string}
*/
export function valueLabel(providerId, value) {
if (!Number.isFinite(value)) return '--';
if (providerId === 'eccc') return value > 10 ? '10+' : String(value);
return String(value);
}
/** Plain-language risk phrase for a band id. */
export function bandRiskText(bandId) {
const band = airQualityBandById(bandId);
return band ? `${band.label} health risk` : null;
}
/**
* Validate a station FeatureCollection into an id-keyed catalog.
*
* Atomic: a structurally broken response returns null so the caller keeps its
* last good catalog. A station missing coordinates is skipped, since one
* unusable station is not evidence the catalog is broken.
*
* @param {object} geojson - GeoJSON FeatureCollection from `aqhi-stations`.
* @returns {Map<string, object>|null} Stations by location_id, or null.
*/
export function normalizeAqhiStations(geojson) {
if (!Array.isArray(geojson?.features)) return null;
const stations = new Map();
for (const feature of geojson.features) {
const properties = feature?.properties;
if (
!properties ||
typeof properties !== 'object' ||
Array.isArray(properties)
)
return null;
const coordinates = feature?.geometry?.coordinates;
if (!Array.isArray(coordinates) || coordinates.length < 2) continue;
const [lon, lat] = coordinates.map(Number);
if (!Number.isFinite(lon) || !Number.isFinite(lat)) continue;
if (Math.abs(lat) > 90 || Math.abs(lon) > 180) return null;
const id = String(properties.location_id ?? feature.id ?? '').trim();
if (!id) continue;
stations.set(id, {
id,
name: String(properties.location_name_en || id).trim(),
// ECCC's administrative zone: atl, que, ont, pnr, pyr.
zone: String(properties['eccc_administrative-zone'] || '').trim(),
lat,
lon,
});
}
return stations;
}
/**
* Join latest ECCC observations onto known stations into provider-shaped rows.
*
* The catalog is the only thing that supplies a reading its coordinates, so an
* observation without a matching station cannot be placed and is dropped.
* Readings older than OBSERVATION_MAX_AGE_MS are dropped too — a six-hour-old
* air-quality number presented as current is worse than no number.
*
* @param {object} geojson - FeatureCollection from `aqhi-observations-realtime`.
* @param {Map<string, object>} stations - Known stations by location_id.
* @param {number} [now=Date.now()] - Clock seam for tests.
* @returns {Array<object>|null} Normalized readings, or null when malformed.
*/
export function normalizeAqhiObservations(geojson, stations, now = Date.now()) {
if (!Array.isArray(geojson?.features)) return null;
if (!(stations instanceof Map)) return null;
const provider = AIR_QUALITY_PROVIDERS.eccc;
const newest = new Map();
for (const feature of geojson.features) {
const properties = feature?.properties;
if (
!properties ||
typeof properties !== 'object' ||
Array.isArray(properties)
)
return null;
const stationId = String(properties.location_id ?? '').trim();
const station = stations.get(stationId);
if (!station) continue;
const value = aqhiDisplayValue(properties.aqhi);
if (value === null) continue;
const observedMs = Date.parse(properties.observation_datetime);
if (!Number.isFinite(observedMs)) continue;
if (now - observedMs > OBSERVATION_MAX_AGE_MS) continue;
const previous = newest.get(stationId);
if (previous && previous.observedMs >= observedMs) continue;
const band = bandForValue(provider.id, value);
newest.set(stationId, {
id: `${provider.id}:${stationId}`,
provider: provider.id,
scale: provider.scale,
value,
band,
stationId,
name: station.name,
zone: station.zone,
lat: station.lat,
lon: station.lon,
observedMs,
color: airQualityColor(band),
label: valueLabel(provider.id, value),
risk: bandRiskText(band),
note: String(properties.special_notes_en || '').trim() || null,
});
}
return [...newest.values()].sort(
(a, b) => b.value - a.value || a.stationId.localeCompare(b.stationId),
);
}
/**
* Build the source-owned presentation for one station label.
* @param {object} input
* @returns {object}
*/
export function createAirQualityOverlayEntry({
id,
position,
title,
accent,
value,
}) {
return {
id: String(id),
position,
variant: 'label',
title,
accent,
// Worse air wins the label budget — the reading people need to see first.
priority: Math.round(Number(value) || 0) * 1000,
collisionGroup: 'ambient-label',
paintLane: 'ambient-label',
interactive: false,
edgeFade: 'keyhole',
horizonCull: true,
terrainOcclusion: false,
gapPx: 14,
verticalOnly: true,
placement: 'above',
};
}
/** Keep the worst readings, with stable identity as the tie-break. */
export function selectAirQualityOverlayCohort(
entries,
limit = AIR_QUALITY_OVERLAY_COHORT_LIMIT,
) {
const cap = Math.max(
0,
Math.min(AIR_QUALITY_OVERLAY_COHORT_LIMIT, Math.floor(Number(limit) || 0)),
);
if (!Array.isArray(entries) || cap === 0) return [];
return entries
.slice()
.sort(
(a, b) =>
b.priority - a.priority || String(a.id).localeCompare(String(b.id)),
)
.slice(0, cap);
}
/**
* The worst readings inside a lon/lat rectangle, for the readout card.
*
* A null rectangle means the camera could not resolve one (a fully oblique or
* space-level view); everything is then in scope rather than nothing, so the
* card degrades to the worst readings overall instead of going blank.
*
* @param {Array<object>} readings
* @param {?{west:number, south:number, east:number, north:number}} rect - Degrees.
* @param {number} [limit=5]
* @returns {Array<object>}
*/
export function readingsInView(readings, rect, limit = 5) {
if (!Array.isArray(readings)) return [];
const cap = Math.max(0, Math.floor(Number(limit) || 0));
if (cap === 0) return [];
const inside = !rect
? readings
: readings.filter((reading) => {
if (reading.lat < rect.south || reading.lat > rect.north) return false;
// A rectangle crossing the antimeridian has west > east.
return rect.west <= rect.east
? reading.lon >= rect.west && reading.lon <= rect.east
: reading.lon >= rect.west || reading.lon <= rect.east;
});
return inside
.slice()
.sort((a, b) => b.value - a.value || a.stationId.localeCompare(b.stationId))
.slice(0, cap);
}
/**
* Map one reading to a JSON-safe analyst record. Pure — no Cesium types.
* @param {object|null|undefined} raw
* @param {number} [index=0]
* @returns {object}
*/
export function mapAnalystRecord(raw, index = 0) {
const num = (v) => (Number.isFinite(v) ? v : null);
const text = (v) => {
const t = String(v ?? '').trim();
return t || null;
};
return {
id: text(raw?.id) || `AIR-${String(index).padStart(4, '0')}`,
provider: text(raw?.provider),
scale: text(raw?.scale),
value: num(raw?.value),
band: text(raw?.band),
name: text(raw?.name),
zone: text(raw?.zone),
risk: text(raw?.risk),
lat: num(raw?.lat),
lon: num(raw?.lon),
observedMs: num(raw?.observedMs),
};
}
+269
View File
@@ -0,0 +1,269 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import {
AIR_QUALITY_BANDS,
AIR_QUALITY_PROVIDERS,
AIR_QUALITY_UNKNOWN_COLOR,
OBSERVATION_MAX_AGE_MS,
STALE_AFTER_MS,
airQualityColor,
aqhiDisplayValue,
bandForValue,
bandRiskText,
mapAnalystRecord,
normalizeAqhiObservations,
normalizeAqhiStations,
readingsAreStale,
readingsInView,
selectAirQualityOverlayCohort,
valueLabel,
} from './model.js';
const NOW = Date.parse('2026-09-24T18:30:00Z');
const RECENT = '2026-09-24T18:00:00Z';
const collection = (features) => ({ type: 'FeatureCollection', features });
const station = (id, name, zone, lon, lat) => ({
type: 'Feature',
geometry: { type: 'Point', coordinates: [lon, lat] },
properties: {
location_id: id,
location_name_en: name,
'eccc_administrative-zone': zone,
},
});
const observation = (id, aqhi, when = RECENT) => ({
type: 'Feature',
properties: { location_id: id, aqhi, observation_datetime: when },
});
const STATIONS = collection([
station('IAKID', 'Calgary', 'pnr', -114.0575, 51.0458),
station('OAAAA', 'Toronto', 'ont', -79.3832, 43.6532),
station('AAAAA', 'Halifax', 'atl', -63.5752, 44.6488),
]);
// ── provider-shaped records ────────────────────────────────────────────────
test('readings carry the provider-neutral {provider, scale, value, band} shape', () => {
// Indices are not interchangeable numbers: AQHI is 1-10+ on health risk,
// US AQI is 0-500 on concentration. The shape names which index a value is on
// so a second network can be added without inventing a shared meaning.
const stations = normalizeAqhiStations(STATIONS);
const [row] = normalizeAqhiObservations(
collection([observation('IAKID', 2.32)]),
stations,
NOW,
);
assert.equal(row.provider, 'eccc');
assert.equal(row.scale, 'AQHI');
assert.equal(row.value, 2);
assert.equal(row.band, 'low');
});
test('bands map from a value on a named provider scale', () => {
assert.equal(bandForValue('eccc', 1), 'low');
assert.equal(bandForValue('eccc', 3), 'low');
assert.equal(bandForValue('eccc', 4), 'moderate');
assert.equal(bandForValue('eccc', 7), 'high');
assert.equal(bandForValue('eccc', 11), 'very-high');
// An unregistered provider cannot be banded rather than being guessed at.
assert.equal(bandForValue('unknown-network', 5), null);
assert.equal(bandForValue('eccc', null), null);
});
test('the provider states its coverage plainly', () => {
assert.match(AIR_QUALITY_PROVIDERS.eccc.coverage, /Canada only/);
assert.equal(AIR_QUALITY_PROVIDERS.eccc.scale, 'AQHI');
});
test("values above ten collapse to the scale's open-ended label", () => {
assert.equal(valueLabel('eccc', 10), '10');
assert.equal(valueLabel('eccc', 11), '10+');
assert.equal(valueLabel('eccc', null), '--');
});
test('every band has a distinct colour, and an unknown band is neutral', () => {
const colors = AIR_QUALITY_BANDS.map((b) => b.color);
assert.equal(new Set(colors).size, colors.length);
assert.equal(airQualityColor('nonsense'), AIR_QUALITY_UNKNOWN_COLOR);
assert.equal(bandRiskText('high'), 'High health risk');
assert.equal(bandRiskText(null), null);
});
// ── published form ─────────────────────────────────────────────────────────
test('AQHI rounds to the published integer form and floors at 1', () => {
assert.equal(aqhiDisplayValue(1.08), 1);
assert.equal(aqhiDisplayValue(3.5), 4);
assert.equal(aqhiDisplayValue(0), 1);
assert.equal(aqhiDisplayValue('3'), 3);
});
test('an absent reading never becomes a confident AQHI 1', () => {
// Number(null) and Number('') are both 0, which would floor to 1.
for (const empty of [null, undefined, '', ' ', NaN, true, {}, [], -1]) {
assert.equal(aqhiDisplayValue(empty), null, `${JSON.stringify(empty)}`);
}
});
// ── snapshot validation ────────────────────────────────────────────────────
test('stations normalize into an id-keyed catalog carrying their zone', () => {
const stations = normalizeAqhiStations(STATIONS);
assert.equal(stations.size, 3);
assert.equal(stations.get('OAAAA').zone, 'ont');
});
test('malformed payloads are rejected atomically', () => {
assert.equal(normalizeAqhiStations(null), null);
assert.equal(normalizeAqhiStations(collection([{ properties: null }])), null);
const stations = normalizeAqhiStations(STATIONS);
assert.equal(normalizeAqhiObservations(null, stations, NOW), null);
assert.equal(
normalizeAqhiObservations(collection([]), 'not a map', NOW),
null,
);
});
test('an observation with no matching station is dropped', () => {
const stations = normalizeAqhiStations(STATIONS);
const rows = normalizeAqhiObservations(
collection([observation('IAKID', 2), observation('ZZZZZ', 9)]),
stations,
NOW,
);
assert.equal(rows.length, 1);
});
test('a stale reading is dropped rather than shown as current', () => {
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)]),
stations,
NOW,
);
assert.deepEqual(
rows.map((r) => r.stationId),
['OAAAA'],
);
});
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(
collection([
observation('IAKID', 3, '2026-09-24T16:00:00Z'),
observation('IAKID', 7, '2026-09-24T18:00:00Z'),
observation('OAAAA', 5),
]),
stations,
NOW,
);
assert.deepEqual(
rows.map((r) => r.value),
[7, 5],
);
});
// ── readout selection ──────────────────────────────────────────────────────
const reading = (id, value, lat, lon) => ({
id,
stationId: id,
value,
lat,
lon,
name: id,
});
test('the readout lists the worst readings inside the view rectangle', () => {
const rows = [
reading('west', 9, 51.0, -114.0),
reading('east', 4, 43.6, -79.3),
reading('mild', 2, 51.1, -114.1),
];
const inView = readingsInView(
rows,
{ west: -115, south: 50, east: -113, north: 52 },
5,
);
assert.deepEqual(
inView.map((r) => r.id),
['west', 'mild'],
);
});
test('an unresolvable view rectangle degrades to the worst overall, not to blank', () => {
// A space-level or fully oblique camera cannot produce a rectangle; the card
// should still say something useful.
const rows = [reading('a', 3, 0, 0), reading('b', 8, 10, 10)];
assert.deepEqual(
readingsInView(rows, null, 5).map((r) => r.id),
['b', 'a'],
);
assert.deepEqual(readingsInView(rows, null, 0), []);
assert.deepEqual(readingsInView(null, null, 5), []);
});
test('a view rectangle crossing the antimeridian still selects correctly', () => {
// west > east is how a dateline-crossing rectangle is expressed.
const rows = [reading('near', 5, 0, 179), reading('far', 9, 0, 100)];
const inView = readingsInView(
rows,
{ west: 170, south: -10, east: -170, north: 10 },
5,
);
assert.deepEqual(
inView.map((r) => r.id),
['near'],
);
});
test('the overlay cohort is capped and deterministic', () => {
const entries = Array.from({ length: 120 }, (_, i) => ({
id: `s${i}`,
priority: i % 11,
}));
const cohort = selectAirQualityOverlayCohort(entries);
assert.ok(cohort.length <= 48);
assert.deepEqual(
cohort.map((e) => e.id),
selectAirQualityOverlayCohort(entries).map((e) => e.id),
);
});
test('analyst records are JSON-safe and name the provider and scale', () => {
const stations = normalizeAqhiStations(STATIONS);
const [row] = normalizeAqhiObservations(
collection([observation('IAKID', 2)]),
stations,
NOW,
);
const record = mapAnalystRecord(row, 0);
assert.deepEqual(JSON.parse(JSON.stringify(record)), record);
assert.equal(record.provider, 'eccc');
assert.equal(record.scale, 'AQHI');
const empty = mapAnalystRecord(undefined, 7);
assert.equal(empty.id, 'AIR-0007');
for (const [key, value] of Object.entries(empty)) {
assert.notEqual(value, undefined, `${key} must not be undefined`);
}
});
+275
View File
@@ -0,0 +1,275 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { createAirQualityLayer } from './index.js';
import { layerFeedState } from '../../data/feedState.js';
import { STALE_AFTER_MS } from './model.js';
function harness(source) {
const sources = [];
const events = [];
const viewer = {
dataSources: {
add(value) {
sources.push(value);
},
remove(value) {
sources.splice(sources.indexOf(value), 1);
},
},
};
const layer = createAirQualityLayer({
source,
overlayHost: {
setEntries(...args) {
events.push(args);
},
setVisible() {},
clearSource() {},
},
});
layer.init(viewer);
layer.enable(viewer);
return { layer, viewer, sources, events };
}
const reading = (id, value, overrides = {}) => ({
id: `eccc:${id}`,
provider: 'eccc',
scale: 'AQHI',
value,
band: 'low',
stationId: id,
name: id,
zone: 'ont',
lat: 43.65,
lon: -79.38,
observedMs: 1_700_000_000_000,
color: '#4cc9f0',
label: String(value),
risk: 'Low health risk',
note: null,
...overrides,
});
const staticSource = (rows) => ({ getSnapshot: async () => rows });
test('a layer requires both a snapshot source and an overlay host', () => {
assert.throws(() => createAirQualityLayer(), /snapshot source/);
assert.throws(
() => createAirQualityLayer({ source: staticSource([]) }),
/overlay host/,
);
});
test('initialization is single-use and owns exactly one data source', () => {
const { layer, viewer, sources } = harness(staticSource([]));
assert.equal(sources.length, 1);
assert.throws(() => layer.init(viewer), /already initialized/);
layer.destroy(viewer);
assert.equal(sources.length, 0);
});
test('a refresh renders readings and publishes an overlay cohort', async () => {
const { layer, events } = harness(
staticSource([reading('A', 2), reading('B', 7)]),
);
assert.equal(await layer.update(), true);
assert.equal(layer.getStats().count, 2);
assert.equal(layer.getStats().error, null);
assert.equal(events.at(-1)[0], 'air-quality');
});
// ── the abort + enabled guard ──────────────────────────────────────────────
test('a response landing after disable cannot redraw the scene', async () => {
// The failure this prevents: disable() clears the overlay, then an in-flight
// response resolves and repopulates it behind the user's back.
let release;
const gate = new Promise((resolve) => {
release = resolve;
});
const { layer, events } = harness({
getSnapshot: async ({ signal } = {}) => {
await gate;
signal?.throwIfAborted();
return [reading('A', 5)];
},
});
const pending = layer.update();
layer.disable();
const publishedBefore = events.length;
release();
assert.equal(await pending, false, 'a late response must not be applied');
assert.equal(
events.length,
publishedBefore,
'no overlay write after disable',
);
assert.equal(layer.getStats().count, 0);
});
test('disable aborts the request in flight', async () => {
let seenSignal = null;
const { layer } = harness({
getSnapshot: async ({ signal } = {}) => {
seenSignal = signal;
await new Promise((resolve) => setTimeout(resolve, 40));
signal?.throwIfAborted();
return [];
},
});
const pending = layer.update();
layer.disable();
await pending;
assert.ok(seenSignal, 'a signal was passed through to the source');
assert.equal(seenSignal.aborted, true);
});
test('a superseded refresh does not clobber the newer one', async () => {
let call = 0;
const { layer } = harness({
getSnapshot: async ({ signal } = {}) => {
call += 1;
const mine = call;
await new Promise((resolve) => setTimeout(resolve, mine === 1 ? 40 : 1));
signal?.throwIfAborted();
return [reading(`call-${mine}`, mine)];
},
});
const first = layer.update();
const second = layer.update();
const [firstResult, secondResult] = await Promise.all([first, second]);
assert.equal(firstResult, false, 'the superseded call reports no update');
assert.equal(secondResult, true);
assert.equal(layer.getAnalystRecords()[0].name, 'call-2');
});
test('update is a no-op while the layer is disabled', async () => {
const { layer } = harness(staticSource([reading('A', 2)]));
layer.disable();
assert.equal(await layer.update(), false);
assert.equal(layer.getStats().count, 0);
});
// ── readout descriptor ─────────────────────────────────────────────────────
test('the readout descriptor carries the band legend and coverage', async () => {
const { layer } = harness(staticSource([reading('A', 2), reading('B', 9)]));
await layer.update();
const controls = layer.getRowControls();
assert.equal(controls.readout, true);
assert.match(controls.summary.coverage, /Canada only/);
// 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.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/);
});
test('the readout reports an error rather than a silent empty card', async () => {
const { layer } = harness({
getSnapshot: async () => {
throw new Error('ECCC HTTP 503');
},
});
await layer.update();
assert.match(layer.getRowControls().summary.status, /503/);
});
test('row-control listeners are notified on refresh and teardown-safe', async () => {
const { layer, viewer } = harness(staticSource([reading('A', 2)]));
let calls = 0;
layer.setRowControlsListener(() => {
calls += 1;
});
await layer.update();
assert.ok(calls >= 1, 'a refresh notifies the card');
layer.destroy(viewer);
const after = calls;
layer.setRowControlsListener(() => {
calls += 1;
});
assert.equal(calls, after);
});
// ── feed-state honesty ─────────────────────────────────────────────────────
test('freshness is judged on observation time, not on when we last fetched', async () => {
// lastUpdate stays current every time the network answers, even when it
// answers with nothing new. Judging freshness on it would let the HUD and
// voice describe hours-old air quality as live.
const aged = Date.now() - STALE_AFTER_MS - 60_000;
const { layer } = harness(
staticSource([reading('A', 2, { observedMs: aged })]),
);
await layer.update();
const stats = layer.getStats();
assert.ok(
stats.lastUpdate >= Date.now() - 5_000,
'we did just receive a response',
);
assert.equal(stats.observedAt, aged, 'but the observation itself is old');
assert.equal(stats.stale, true);
assert.equal(
layerFeedState(stats),
'stale',
'provenance must not read this as nominal',
);
});
test('a current reading reports nominal', async () => {
const { layer } = harness(
staticSource([reading('A', 2, { observedMs: Date.now() - 10 * 60_000 })]),
);
await layer.update();
const stats = layer.getStats();
assert.equal(stats.stale, false);
assert.equal(layerFeedState(stats), 'nominal');
});
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();
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 () => {
let fail = false;
const { layer } = harness({
getSnapshot: async () => {
if (fail) throw new Error('ECCC HTTP 503');
return [reading('A', 2, { observedMs: Date.now() - 10 * 60_000 })];
},
});
await layer.update();
assert.equal(layerFeedState(layer.getStats()), 'nominal');
fail = true;
await layer.update();
const stats = layer.getStats();
assert.ok(stats.count > 0, 'the previous readings are still displayed');
assert.equal(
layerFeedState(stats),
'degraded',
'showing old data after a failure is degraded',
);
});
+104
View File
@@ -0,0 +1,104 @@
import { readResponseJsonCapped } from '../../sources/httpBody.js';
import { normalizeAqhiStations, normalizeAqhiObservations } from './model.js';
const COLLECTIONS_ROOT = 'https://api.weather.gc.ca/collections';
const STATIONS_URL = `${COLLECTIONS_ROOT}/aqhi-stations/items`;
const OBSERVATIONS_URL = `${COLLECTIONS_ROOT}/aqhi-observations-realtime/items`;
/** Comfortably above Canada's ~134 stations without inviting a huge page. */
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
* displayed readings. The first registered air-quality provider.
*
* Two collections are involved. `aqhi-stations` is the catalog — it supplies
* every reading its coordinates and is cached for twelve hours because it
* barely changes. `aqhi-observations-realtime` is the hourly reading feed,
* requested with `latest=true` so the response is current values rather than
* the full observation history, which is roughly 2,000 rows nationally.
*
* The station catalog is only replaced when a refresh actually returns
* stations, so a transient empty response cannot blank the layer.
*
* @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<string, object>} */
let stations = new Map();
let stationsFetchedAt = 0;
/** 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 {
async getSnapshot({ signal } = {}) {
const clock = now();
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);
if (!next) throw new Error('Malformed AQHI station response');
// An empty refresh must not wipe a good catalog.
if (next.size > 0) {
stations = next;
stationsFetchedAt = clock;
}
}
if (stations.size === 0) throw new Error('No AQHI stations available');
const payload = await getJson(
`${OBSERVATIONS_URL}?f=json&latest=true&limit=${OBSERVATION_LIMIT}`,
OBSERVATION_LIMIT,
signal,
);
const readings = normalizeAqhiObservations(payload, stations, clock);
if (!readings) throw new Error('Malformed AQHI observation response');
return readings;
},
};
}
+197
View File
@@ -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/);
});
+1
View File
@@ -437,6 +437,7 @@ export function createCyclonesLayer({
})),
},
chips: [],
legendCategorical: true,
legend: storm
? [
{