mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-05 10:53:59 +00:00
feat(air-quality): add the Air quality layer to the Weather group
Reworks #568 to the shape asked for in review. Generalized from "AQHI" to a provider-backed Air quality layer, with ECCC's AQHI as the first registered provider. Readings are carried as { provider, scale, value, band } rather than a bare number, because air-quality indices are not interchangeable: AQHI runs 1-10+ on health risk, the US AQI runs 0-500 on concentration breakpoints, and Europe's EAQI is a five-step category. Naming the index a value belongs to is what lets another network be added without inventing a shared numeric meaning. Coverage is stated as "Canada only" on the provider, the layer row and the card. Adds the WEATHER panel readout (readout: true) carrying the band legend and the worst readings currently in view, with getRowControls/setRowControlsListener following the wind and cyclone cards. An unresolvable view rectangle degrades to the worst readings overall rather than to a blank card, and a rectangle crossing the antimeridian is handled. update() now takes the earthquakes layer's abort-and-guard shape: it aborts any in-flight request, passes the signal through to the source, and refuses to touch the scene when the request was superseded, aborted, or the layer was disabled while it was outstanding. disable() and destroy() abort too. Tests cover the late-response, superseded-refresh and disabled-update paths. Rebased on main: the source is registered in src/sources/reference.js, and the allocation profile was re-measured against the current worker — median 3,900 B/frame, max 4,114, 85.7 B/candidate, inside the shared 154 ceiling, budgeted at 5,200 for 26% headroom. The earlier figures no longer held. Freshness is now judged on the observation time rather than on when the last response arrived. lastUpdate only says the feed answered; a station that keeps returning a reading from four hours ago would otherwise be narrated as live by the HUD and the voice provenance envelope. getStats() reports observedAt and stale, so layerFeedState() calls the layer stale past two missed hourly publications, and degraded when a refresh fails over readings that are still good. The share-link token is 0, which is what layer-token:next allocates. Taking it moves the ledger fixtures that had hard-coded 0 as the free digit and 3 as the one after; those shift to 3 and 4. The pinned equality between the reservation ledger and LEGACY_LAYER_STATE_TOKENS also had to become "the closed legacy set plus air-quality", since that set is documented as never growing. Happy to re-pin this differently if you would rather the fixtures derive the provisional digits from nextLayerStateToken() so the next layer does not move them again. The layer and its source are registered as ./layers/air-quality and ./layers/air-quality/source, each with its own boundary group, following the perimeters pair. check:boundaries requires the package exports and the group exports to be the same set, so a layer that ships a source needs both.
This commit is contained in:
1 parent
e7707d9a0f
commit
8887ca47eb
26 files changed
+1730
-41
No files matched your search
@@ -0,0 +1,308 @@
|
||||
import * as Cesium from 'cesium';
|
||||
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
|
||||
? 'No readings available'
|
||||
: `${_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,
|
||||
},
|
||||
list: {
|
||||
ariaLabel: 'Highest air-quality readings in view',
|
||||
items: inView.map((reading) => ({
|
||||
id: reading.id,
|
||||
label: reading.name,
|
||||
value: `${reading.scale} ${reading.label}`,
|
||||
accent: reading.color,
|
||||
detail: reading.risk,
|
||||
})),
|
||||
},
|
||||
};
|
||||
},
|
||||
|
||||
setRowControlsListener(listener) {
|
||||
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]
|
||||
* @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),
|
||||
stale: readingsAreStale(_readings),
|
||||
};
|
||||
},
|
||||
};
|
||||
return layer;
|
||||
}
|
||||
@@ -0,0 +1,374 @@
|
||||
/**
|
||||
* @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.
|
||||
*/
|
||||
|
||||
/** Band identifiers shared across providers, in ascending severity. */
|
||||
export const AIR_QUALITY_BANDS = Object.freeze([
|
||||
Object.freeze({ id: 'low', label: 'Low', color: '#4cc9f0' }),
|
||||
Object.freeze({ id: 'moderate', label: 'Moderate', color: '#ffd43b' }),
|
||||
Object.freeze({ id: 'high', label: 'High', color: '#ff6b35' }),
|
||||
Object.freeze({ id: 'very-high', label: '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?
|
||||
* @param {Array<object>} 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
|
||||
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: '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),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,250 @@
|
||||
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,
|
||||
airQualityColor,
|
||||
aqhiDisplayValue,
|
||||
bandForValue,
|
||||
bandRiskText,
|
||||
mapAnalystRecord,
|
||||
normalizeAqhiObservations,
|
||||
normalizeAqhiStations,
|
||||
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('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`);
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,326 @@
|
||||
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';
|
||||
|
||||
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/);
|
||||
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);
|
||||
// Worst first, and every item keyed.
|
||||
assert.deepEqual(
|
||||
controls.list.items.map((i) => i.label),
|
||||
['B', 'A'],
|
||||
);
|
||||
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);
|
||||
});
|
||||
|
||||
// ── 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 () => {
|
||||
// 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('an empty feed is not called stale', async () => {
|
||||
// Nothing to be stale about; the chip should not invent a fault.
|
||||
const { layer } = harness(staticSource([]));
|
||||
await layer.update();
|
||||
assert.equal(layer.getStats().stale, false);
|
||||
assert.equal(layer.getStats().observedAt, null);
|
||||
});
|
||||
|
||||
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',
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,78 @@
|
||||
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;
|
||||
|
||||
/**
|
||||
* 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.
|
||||
* @returns {{getSnapshot: Function}}
|
||||
*/
|
||||
export function createEcccAirQualitySource({
|
||||
fetchImpl = (...args) => globalThis.fetch(...args),
|
||||
now = () => Date.now(),
|
||||
} = {}) {
|
||||
/** @type {Map<string, object>} */
|
||||
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;
|
||||
};
|
||||
|
||||
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}`,
|
||||
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}`,
|
||||
signal,
|
||||
);
|
||||
const readings = normalizeAqhiObservations(payload, stations, clock);
|
||||
if (!readings) throw new Error('Malformed AQHI observation response');
|
||||
return readings;
|
||||
},
|
||||
};
|
||||
}
|
||||
Reference in new issue
Block a user