mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-04 10:24:02 +00:00
Fixes the P1 and the confirmed P2 findings of the final review of the
i18n branch.
- CI: the air-quality model no longer imports i18n (portable module),
so check:boundaries passes again.
- Air quality layer: named in the layer panel, HUD and voice; its
Weather card, bands and errors translate. A test keeps the layer-name
table in step with the layer registry.
- Layer statuses that still reached ru/es in English: GNSS, CelesTrak,
Transit loading and feed errors, launches, perimeters, ALPR, ECCC,
wind "field unavailable", Local ADS-B streaming lines.
- Voice and HUD: preset ids show city names ("Fly to San Francisco"),
the HUD city translates, cockpit refusals cover frame, aircraft and
satellite; empty Transit notes translate on the analyst card.
- tn() keeps a caller's formatted count ("12,345 map objects").
- Cyber HUD: the language button no longer covers the bottom-right
card or the cockpit position readout.
- CCTV buttons wrap only in non-English locales; English layout matches
main again.
- Switching language asks first when Provider Settings holds unsaved
keys.
Catalogs: es/ru 2611 messages each, complete.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
400 lines
13 KiB
JavaScript
400 lines
13 KiB
JavaScript
/**
|
|
* @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.
|
|
*/
|
|
// A portable export (package.json): no i18n here. The labels stay English data
|
|
// and the Layers panel shows them translated.
|
|
const msg = (text) => text;
|
|
|
|
/** Band identifiers shared across providers, in ascending severity. */
|
|
export const AIR_QUALITY_BANDS = Object.freeze([
|
|
Object.freeze({
|
|
id: 'low',
|
|
label: msg('Low'),
|
|
risk: msg('Low health risk'),
|
|
color: '#4cc9f0',
|
|
}),
|
|
Object.freeze({
|
|
id: 'moderate',
|
|
label: msg('Moderate'),
|
|
risk: msg('Moderate health risk'),
|
|
color: '#ffd43b',
|
|
}),
|
|
Object.freeze({
|
|
id: 'high',
|
|
label: msg('High'),
|
|
risk: msg('High health risk'),
|
|
color: '#ff6b35',
|
|
}),
|
|
Object.freeze({
|
|
id: 'very-high',
|
|
label: msg('Very High'),
|
|
risk: msg('Very High health risk'),
|
|
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: msg('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) {
|
|
return airQualityBandById(bandId)?.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),
|
|
};
|
|
}
|