From 5f8a275487338d3fc08d8e3b91dbc8d856148676 Mon Sep 17 00:00:00 2001 From: Michael Johnston Date: Tue, 15 Sep 2026 22:08:53 -0400 Subject: [PATCH 1/9] fix(providers): bound the remaining upstream body reads Route the six proxied fetches that still buffered upstream bodies without a limit through the shared capped readers in src/sources/httpBody.js: FIRMS CSV, CelesTrak TLE groups, adsb.lol /v2/mil (both the OK and relayed-status paths), TomTom flow tiles, OpenSky states/all and adsbdb enrichment lookups. Each cap is a bound sized well above the measured payload, not a budget. readResponseBytesCapped gains the same optional abort signal as the text reader, with the same cancel-on-abort plumbing and reader release, so the TomTom tile path can share the route deadline instead of reading arrayBuffer() unbounded. Where a route already had a deadline, the same signal now covers the body read as well as the headers. Two polls had no deadline at all: the adsb.lol military poll gains a 12-second one and the OpenSky states/all exchange, including its Basic-auth retry, a 20-second one; both miss into their existing STALE paths. FIRMS map-key status, the OpenSky token exchange and CCTV media are left as they are. Co-Authored-By: Claude Opus 5 --- CHANGELOG.md | 2 + docs/CURRENT-STATE.md | 20 ++++ scripts/package-boundaries.json | 14 ++- server/providers/aircraft/adsb-lol.js | 20 +++- server/providers/aircraft/enrichment.js | 16 ++- server/providers/aircraft/opensky.js | 21 +++- server/providers/firms.js | 14 ++- server/providers/space/celestrak.js | 13 ++- server/providers/traffic.js | 15 ++- src/sources/httpBody.js | 44 +++++--- src/sources/httpBody.test.mjs | 133 ++++++++++++++++++++++++ 11 files changed, 278 insertions(+), 34 deletions(-) create mode 100644 src/sources/httpBody.test.mjs diff --git a/CHANGELOG.md b/CHANGELOG.md index 29dbedfaa..f5433b87f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,7 @@ # Changelog +- Bound the remaining upstream body reads (FIRMS, CelesTrak, adsb.lol, TomTom, OpenSky, adsbdb) with streaming size caps and shared deadlines; the adsb.lol military and OpenSky `states/all` polls gain deadlines of their own. + - CCTV cameras whose bearing is a guess now say so. Packs mark bearings derived from a hash of the camera id as `headingConfidence: 'low'`, but nothing read the flag, so roughly 70% of a default catalog rendered like surveyed facings. The HUD diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index fdb42fc0c..7853d590e 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -827,6 +827,26 @@ feed address is read from the path, and a query-string form is refused with a 40 streaming. The 12-second deadline includes reading the body, and rejected or stalled downloads are cancelled. Development and preview use the same handler. +## Upstream body bounds + +Every proxied fetch outside CCTV media now reads its body through the shared +streaming readers in `src/sources/httpBody.js`, so a declared or observed body +over the cap is refused with `RESPONSE_TOO_LARGE` and cancelled rather than +buffered. `readResponseBytesCapped` accepts the same optional abort signal as +the text reader and cancels a body still streaming when it fires; TomTom flow +tiles use it alongside GTFS-Realtime. Caps are bounds, not budgets, sized well +above measured payloads: FIRMS 64 MiB per satellite source, CelesTrak 16 MiB +per group, OpenSky `states/all` 8 MiB, adsb.lol `/v2/mil` 8 MiB, TomTom tiles +8 MiB and adsbdb lookups 1 MiB. Where a route already had a deadline (FIRMS +60 s, CelesTrak 20 s, TomTom 15 s, adsbdb 8 s) that deadline now covers the +body read as well as the headers. Two polls that had no deadline gain one: the +adsb.lol military poll waits at most 12 seconds, and the OpenSky `states/all` +exchange, including its Basic-auth retry, at most 20. A rejected or timed-out +body is an upstream miss and follows each route's existing error and +cached-data paths. FIRMS map-key status, the OpenSky token exchange and CCTV +media keep their current handling. + + ## Remaining local service modules Overpass query validation, geometry simplification, disk caching and upstream diff --git a/scripts/package-boundaries.json b/scripts/package-boundaries.json index 189192f10..e47e5bb16 100644 --- a/scripts/package-boundaries.json +++ b/scripts/package-boundaries.json @@ -113,7 +113,12 @@ "traffic-provider": { "runtime": "node", "exports": ["./server/providers/traffic"], - "modules": ["server/providers/traffic.js", "src/data/tomtomTiles.js"], + "modules": [ + "server/providers/traffic.js", + "server/providers/common/http.js", + "src/sources/httpBody.js", + "src/data/tomtomTiles.js" + ], "external": [] }, "traffic-source": { @@ -129,7 +134,12 @@ "firms-provider": { "runtime": "node", "exports": ["./server/providers/firms"], - "modules": ["server/providers/firms.js", "src/data/firmsCsv.js"], + "modules": [ + "server/providers/firms.js", + "server/providers/common/http.js", + "src/sources/httpBody.js", + "src/data/firmsCsv.js" + ], "external": [] }, "gbfs-provider": { diff --git a/server/providers/aircraft/adsb-lol.js b/server/providers/aircraft/adsb-lol.js index fc5ab0bd3..606b598cd 100644 --- a/server/providers/aircraft/adsb-lol.js +++ b/server/providers/aircraft/adsb-lol.js @@ -1,3 +1,5 @@ +import { readResponseTextCapped } from '../common/http.js'; + /** * Vite plugin: adsb.lol military aircraft proxy with 12 s response cache. * @@ -25,6 +27,10 @@ export function adsbLolProxy() { let _cooldownStatus = 0; /** Response cache TTL (ms). */ const CACHE_MS = 12000; + /** Upstream deadline (ms); the fetch previously had none and could hang a poll. */ + const UPSTREAM_TIMEOUT_MS = 12000; + /** /v2/mil measures ~67 KB; a relayed error page is bounded by the same cap. */ + const ADSBLOL_MIL_MAX_RESPONSE_BYTES = 8 * 1024 * 1024; /** Cooldown after a 429 when upstream sends no usable Retry-After (ms). */ const RATE_LIMIT_COOLDOWN_MS = 30000; /** Cooldown after a 5xx (ms). */ @@ -94,11 +100,17 @@ export function adsbLolProxy() { ); return; } + const signal = AbortSignal.timeout(UPSTREAM_TIMEOUT_MS); const upstream = await fetch('https://api.adsb.lol/v2/mil', { headers: { 'User-Agent': 'gods-eye-view-adsblol-proxy/1.0' }, + signal, }); if (upstream.ok) { - const body = await upstream.text(); + const body = await readResponseTextCapped( + upstream, + ADSBLOL_MIL_MAX_RESPONSE_BYTES, + signal, + ); _cache = body; _cacheAt = Date.now(); _cooldownUntil = 0; @@ -125,7 +137,11 @@ export function adsbLolProxy() { return; } } - const body = await upstream.text(); + const body = await readResponseTextCapped( + upstream, + ADSBLOL_MIL_MAX_RESPONSE_BYTES, + signal, + ); serve( res, upstream.status, diff --git a/server/providers/aircraft/enrichment.js b/server/providers/aircraft/enrichment.js index 400050151..9b7c1b1bb 100644 --- a/server/providers/aircraft/enrichment.js +++ b/server/providers/aircraft/enrichment.js @@ -1,5 +1,9 @@ import path from 'node:path'; import { promises as fsp } from 'node:fs'; +import { readResponseJsonCapped } from '../common/http.js'; + +/** Route and aircraft lookups are a few hundred bytes; 1 MiB is a hard bound. */ +const ADSBDB_MAX_RESPONSE_BYTES = 1024 * 1024; /** * adsbdb.com enrichment proxy: callsign → route (airline + origin/destination * airports) and hex → aircraft type/registration. Free community API — cached @@ -80,12 +84,16 @@ export function adsbdbProxy() { kind === 'route' ? `https://api.adsbdb.com/v0/callsign/${encodeURIComponent(key)}` : `https://api.adsbdb.com/v0/aircraft/${encodeURIComponent(key)}`; - const res = await fetch(url, { signal: AbortSignal.timeout(8000) }); + const signal = AbortSignal.timeout(8000); + const res = await fetch(url, { signal }); if (res.ok) { + const payload = await readResponseJsonCapped( + res, + ADSBDB_MAX_RESPONSE_BYTES, + signal, + ); const data = - kind === 'route' - ? parseRoute(await res.json()) - : parseAircraft(await res.json()); + kind === 'route' ? parseRoute(payload) : parseAircraft(payload); store[key] = { at: Date.now(), data }; // data may be null — negative cache dirty = true; return data; diff --git a/server/providers/aircraft/opensky.js b/server/providers/aircraft/opensky.js index 612d01e11..f1ded6401 100644 --- a/server/providers/aircraft/opensky.js +++ b/server/providers/aircraft/opensky.js @@ -2,6 +2,7 @@ import { normalizeAdsbLolPointResponse } from '../../../src/data/adsbLolFallback import { coalesceProxyRequest, readResponseJsonCapped, + readResponseTextCapped, } from '../common/http.js'; import { requiredFiniteQueryNumber } from '../common/query.js'; // --------------------------------------------------------------------------- @@ -72,6 +73,15 @@ const ADSBLOL_POINT_CACHE_MS = 12000; const ADSBLOL_POINT_CACHE_MAX = 80; const ADSBLOL_POINT_RADIUS_NM = 250; const ADSBLOL_POINT_MAX_RESPONSE_BYTES = 8 * 1024 * 1024; +/** states/all?extended=1 measures ~0.8 MB anonymously; bounded, not budgeted. */ +const OPENSKY_STATES_MAX_RESPONSE_BYTES = 8 * 1024 * 1024; +/** + * Deadline for the whole states/all exchange: headers, the Basic-auth retry + * and the body. The fetch previously had none, so a hung upstream connection + * stalled the worldwide poll indefinitely; a miss here serves STALE like any + * other upstream failure. + */ +const OPENSKY_STATES_TIMEOUT_MS = 20000; // A 200 response can still contain an old OpenSky snapshot. Past this point // the viewport-scoped adsb.lol source is more honest and keeps local motion // current instead of coasting a stale worldwide frame indefinitely. @@ -470,9 +480,10 @@ export function openSkyProxy() { } } + const signal = AbortSignal.timeout(OPENSKY_STATES_TIMEOUT_MS); let upstream = await fetch( 'https://opensky-network.org/api/states/all?extended=1', - { headers }, + { headers, signal }, ); // Auto-mode fallback: if OAuth was rejected, retry with Basic credentials if ( @@ -487,13 +498,17 @@ export function openSkyProxy() { }; upstream = await fetch( 'https://opensky-network.org/api/states/all?extended=1', - { headers: retryHeaders }, + { headers: retryHeaders, signal }, ); usedMode = 'basic'; reason = 'oauth_rejected_fallback_basic'; } - let body = await upstream.text(); + let body = await readResponseTextCapped( + upstream, + OPENSKY_STATES_MAX_RESPONSE_BYTES, + signal, + ); const sourceEpochMs = upstream.ok ? openSkySourceEpochMs(body) : null; if ( upstream.ok && diff --git a/server/providers/firms.js b/server/providers/firms.js index 636968348..c73ca10ce 100644 --- a/server/providers/firms.js +++ b/server/providers/firms.js @@ -2,6 +2,7 @@ import path from 'node:path'; import { promises as fsp } from 'node:fs'; import { filterTrailing24h, parseFirmsCsv } from '../../src/data/firmsCsv.js'; +import { readResponseTextCapped } from './common/http.js'; /** * NASA FIRMS live active-fire proxy with a memory + disk cache. @@ -35,6 +36,12 @@ export function firmsProxy() { 'VIIRS_SNPP_NRT', 'MODIS_NRT', ]; + /** + * Per-source body cap. A VIIRS world/2 pull is ~130k rows x ~100 B, about + * 13 MB per satellite in heavy fire season (MODIS runs smaller), so this is a + * bound, not a budget. + */ + const FIRMS_MAX_RESPONSE_BYTES = 64 * 1024 * 1024; const CACHE_DIR = path.join(process.cwd(), '.gev-cache'); const CACHE_PATH = path.join(CACHE_DIR, 'firms.json'); @@ -83,9 +90,12 @@ export function firmsProxy() { */ async function fetchSource(key, source) { const url = `https://firms.modaps.eosdis.nasa.gov/api/area/csv/${encodeURIComponent(key)}/${source}/world/2`; - const res = await fetch(url, { signal: AbortSignal.timeout(60_000) }); + const signal = AbortSignal.timeout(60_000); + const res = await fetch(url, { signal }); if (!res.ok) throw new Error(`HTTP ${res.status}`); - const records = parseFirmsCsv(await res.text()); + const records = parseFirmsCsv( + await readResponseTextCapped(res, FIRMS_MAX_RESPONSE_BYTES, signal), + ); if (records === null) throw new Error('non-CSV upstream response'); return records; } diff --git a/server/providers/space/celestrak.js b/server/providers/space/celestrak.js index a76cc590d..8baf59ba7 100644 --- a/server/providers/space/celestrak.js +++ b/server/providers/space/celestrak.js @@ -1,6 +1,7 @@ import path from 'node:path'; import { promises as fsp } from 'node:fs'; import { celestrakTleUrl } from '../../../src/data/spaceProviderRequests.js'; +import { readResponseTextCapped } from '../common/http.js'; /** * Vite plugin: CelesTrak TLE proxy. @@ -48,10 +49,14 @@ export function celestrakProxy() { } } + /** The largest group (`active`) measures ~2.7 MB; this bounds a runaway body. */ + const CELESTRAK_MAX_RESPONSE_BYTES = 16 * 1024 * 1024; + async function fetchUpstream(group) { const url = celestrakTleUrl(group); + const signal = AbortSignal.timeout(20000); const res = await fetch(url.toString(), { - signal: AbortSignal.timeout(20000), + signal, // CelesTrak 403s bulk groups (e.g. `active`) unless the request carries a // descriptive User-Agent with a contact point. headers: { @@ -60,7 +65,11 @@ export function celestrakProxy() { }, }); if (!res.ok) throw new Error(`HTTP ${res.status}`); - const body = await res.text(); + const body = await readResponseTextCapped( + res, + CELESTRAK_MAX_RESPONSE_BYTES, + signal, + ); // An upstream error page parses to zero TLEs — treat as failure, keep cache. if (!/^1 /m.test(body)) throw new Error('no TLE lines in response'); return { at: Date.now(), body }; diff --git a/server/providers/traffic.js b/server/providers/traffic.js index 2a3c2e60a..d53db6530 100644 --- a/server/providers/traffic.js +++ b/server/providers/traffic.js @@ -1,6 +1,7 @@ import path from 'node:path'; import { promises as fsp } from 'node:fs'; +import { readResponseBytesCapped } from './common/http.js'; import { isValidTileCoord as isValidTomTomTile, utcDayKey as tomtomUtcDayKey, @@ -48,6 +49,8 @@ export function tomtomProxy() { const DEFAULT_DAILY_BUDGET = 6000; const MEM_MAX_ENTRIES = 256; const UPSTREAM_TIMEOUT_MS = 15000; + /** Flow tiles are well under 1 MB; this bounds a runaway body, not a budget. */ + const TOMTOM_TILE_MAX_RESPONSE_BYTES = 8 * 1024 * 1024; /** @type {Map} tile key `z/x/y` -> cached tile (kept past TTL for serve-stale). */ const mem = new Map(); @@ -143,11 +146,15 @@ export function tomtomProxy() { 'https://api.tomtom.com/traffic/map/4/tile/flow/relative/' + `${z}/${x}/${y}.pbf?key=${encodeURIComponent(process.env.TOMTOM_API_KEY)}`; recordUpstreamFetch(); // attempts count — upstream bills the request either way - const res = await fetch(url, { - signal: AbortSignal.timeout(UPSTREAM_TIMEOUT_MS), - }); + const signal = AbortSignal.timeout(UPSTREAM_TIMEOUT_MS); + const res = await fetch(url, { signal }); if (!res.ok) throw new Error(`HTTP ${res.status}`); - const buf = Buffer.from(await res.arrayBuffer()); + const bytes = await readResponseBytesCapped( + res, + TOMTOM_TILE_MAX_RESPONSE_BYTES, + signal, + ); + const buf = Buffer.from(bytes.buffer, bytes.byteOffset, bytes.byteLength); if (buf.length === 0) throw new Error('empty tile body'); return buf; } diff --git a/src/sources/httpBody.js b/src/sources/httpBody.js index 6f176ca52..481dd5b7f 100644 --- a/src/sources/httpBody.js +++ b/src/sources/httpBody.js @@ -55,38 +55,52 @@ export async function readResponseJsonCapped(response, maxBytes, signal) { /** * Read a fetch() Response body as bytes with the same hard cap as - * readResponseTextCapped — for protobuf upstreams (GTFS-Realtime). + * readResponseTextCapped — for protobuf and tile upstreams (GTFS-Realtime, + * TomTom flow). Same early Content-Length rejection, running cap while + * streaming and cancellation on abort, so callers can treat the two alike. * Throws { code:'RESPONSE_TOO_LARGE' }. */ -export async function readResponseBytesCapped(response, maxBytes) { +export async function readResponseBytesCapped(response, maxBytes, signal) { const tooLarge = () => { const err = new Error('Upstream response too large'); err.code = 'RESPONSE_TOO_LARGE'; return err; }; const declared = Number(response.headers.get('content-length')); - if (Number.isFinite(declared) && declared > maxBytes) throw tooLarge(); + if (Number.isFinite(declared) && declared > maxBytes) { + void response.body?.cancel().catch(() => {}); + throw tooLarge(); + } + signal?.throwIfAborted(); const reader = response.body?.getReader?.(); if (!reader) { const bytes = new Uint8Array(await response.arrayBuffer()); + signal?.throwIfAborted(); if (bytes.byteLength > maxBytes) throw tooLarge(); return bytes; } + const cancel = () => { + void reader.cancel().catch(() => {}); + }; + signal?.addEventListener('abort', cancel, { once: true }); const chunks = []; let total = 0; - for (;;) { - const { done, value } = await reader.read(); - if (done) break; - total += value.byteLength; - if (total > maxBytes) { - try { - await reader.cancel(); - } catch { - /* no-op */ - } - throw tooLarge(); + try { + for (;;) { + signal?.throwIfAborted(); + const { done, value } = await reader.read(); + signal?.throwIfAborted(); + if (done) break; + total += value.byteLength; + if (total > maxBytes) throw tooLarge(); + chunks.push(value); } - chunks.push(value); + } catch (error) { + cancel(); + throw error; + } finally { + signal?.removeEventListener('abort', cancel); + reader.releaseLock(); } const out = new Uint8Array(total); let offset = 0; diff --git a/src/sources/httpBody.test.mjs b/src/sources/httpBody.test.mjs new file mode 100644 index 000000000..aa8e77d1a --- /dev/null +++ b/src/sources/httpBody.test.mjs @@ -0,0 +1,133 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { readResponseBytesCapped } from './httpBody.js'; + +/** + * A Response that streams `chunk` forever until the reader cancels it. The + * zero high-water mark keeps the stream from pulling eagerly at construction, + * so `pulls()` counts only what a reader actually asked for. + */ +function endlessResponse(chunk, onCancel, headers = {}) { + let pulls = 0; + const stream = new ReadableStream( + { + pull(controller) { + pulls += 1; + controller.enqueue(chunk); + }, + cancel: onCancel, + }, + { highWaterMark: 0 }, + ); + return { response: new Response(stream, { headers }), pulls: () => pulls }; +} + +function chunkedResponse(chunks, headers = {}) { + const stream = new ReadableStream( + { + start(controller) { + for (const chunk of chunks) controller.enqueue(chunk); + controller.close(); + }, + }, + { highWaterMark: 0 }, + ); + return new Response(stream, { headers }); +} + +const bytes = (n, fill) => new Uint8Array(n).fill(fill); + +test('returns the exact bytes of a body that fits, untouched by text decoding', async () => { + // 0xFF and 0xFE are invalid UTF-8 lead bytes; a text path would mangle them. + const payload = Uint8Array.from([0xff, 0xfe, 0x00, 0x7f, 0x80, 0x01]); + const buf = await readResponseBytesCapped(chunkedResponse([payload]), 1024); + assert.ok(buf instanceof Uint8Array); + assert.deepEqual([...buf], [...payload]); +}); + +test('joins chunks in order and reports the total length', async () => { + const buf = await readResponseBytesCapped( + chunkedResponse([bytes(3, 1), bytes(2, 2), bytes(1, 3)]), + 1024, + ); + assert.deepEqual([...buf], [1, 1, 1, 2, 2, 3]); +}); + +test('refuses on a declared Content-Length without pulling the body', async () => { + let cancelled = false; + const { response, pulls } = endlessResponse( + bytes(512, 0), + () => { + cancelled = true; + }, + { 'content-length': '4096' }, + ); + await assert.rejects(readResponseBytesCapped(response, 1024), { + code: 'RESPONSE_TOO_LARGE', + }); + assert.equal(pulls(), 0); + assert.ok(cancelled); +}); + +test('refuses a chunked body the moment it passes the cap and stops pulling', async () => { + let cancelled = false; + const { response, pulls } = endlessResponse(bytes(512, 0), () => { + cancelled = true; + }); + await assert.rejects(readResponseBytesCapped(response, 1024), { + code: 'RESPONSE_TOO_LARGE', + }); + // Three 512-byte chunks is the first total over 1024; an uncapped read of + // this never-ending stream would not terminate at all. + assert.ok(pulls() <= 3, `pulled ${pulls()} chunks`); + assert.ok(cancelled); +}); + +test('accepts a body exactly at the cap', async () => { + const buf = await readResponseBytesCapped( + chunkedResponse([bytes(512, 7), bytes(512, 7)]), + 1024, + ); + assert.equal(buf.byteLength, 1024); +}); + +test('cancels a body still streaming when the signal aborts, and never returns a partial one', async () => { + const controller = new AbortController(); + let cancelled = false; + let pulls = 0; + const stream = new ReadableStream( + { + pull(ctrl) { + pulls += 1; + ctrl.enqueue(bytes(64, 0)); + if (pulls === 2) controller.abort(); + }, + cancel() { + cancelled = true; + }, + }, + { highWaterMark: 0 }, + ); + await assert.rejects( + readResponseBytesCapped(new Response(stream), 1 << 20, controller.signal), + ); + assert.ok(cancelled); + assert.ok(pulls <= 3, `pulled ${pulls} chunks after abort`); +}); + +test('refuses immediately when the signal is already aborted', async () => { + const controller = new AbortController(); + controller.abort(); + const { response, pulls } = endlessResponse(bytes(8, 0), () => {}); + await assert.rejects( + readResponseBytesCapped(response, 1024, controller.signal), + ); + assert.equal(pulls(), 0); +}); + +test('falls back to arrayBuffer() for a body with no stream reader', async () => { + const response = new Response(bytes(16, 9)); + const buf = await readResponseBytesCapped(response, 1024); + assert.equal(buf.byteLength, 16); + assert.equal(buf[0], 9); +}); From d585d25b19c0f41746268e07ea840f11365bd0e3 Mon Sep 17 00:00:00 2001 From: Michael Johnston Date: Tue, 15 Sep 2026 22:08:54 -0400 Subject: [PATCH 2/9] chore(format): adopt the httpBody reader test Co-Authored-By: Claude Opus 5 --- scripts/format-scope.json | 1 + 1 file changed, 1 insertion(+) diff --git a/scripts/format-scope.json b/scripts/format-scope.json index eee546dd5..d661d7a6b 100644 --- a/scripts/format-scope.json +++ b/scripts/format-scope.json @@ -699,6 +699,7 @@ "src/search/nominatim.js", "src/search/nominatim.test.mjs", "src/sources/httpBody.js", + "src/sources/httpBody.test.mjs", "src/sources/overpass.js", "src/sources/nominatim.js", "server/providers/transit.js", From 8887ca47ebea3ca593a5c8d1956652f1739c8b8a Mon Sep 17 00:00:00 2001 From: rileygramlich Date: Wed, 23 Sep 2026 22:40:03 -0600 Subject: [PATCH 3/9] feat(air-quality): add the Air quality layer to the Weather group MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- CHANGELOG.md | 10 + DATA_SOURCES.md | 9 + README.md | 1 + docs/CURRENT-STATE.md | 1 + package.json | 2 + scripts/format-scope.json | 6 + scripts/package-boundaries.json | 33 +- scripts/qa-air-quality.mjs | 171 ++++++++ src/app/constructCatalog.js | 3 + src/app/constructCatalog.test.mjs | 2 +- src/app/layers/airQuality.js | 6 + src/data/dataCredits.js | 7 + src/data/layerState.js | 5 + src/data/layerState.test.mjs | 12 +- src/data/layerStateTokenLedger.test.mjs | 63 +-- src/data/layerStateTokenReservations.json | 1 + src/layers/airQuality/index.js | 308 +++++++++++++++ src/layers/airQuality/model.js | 374 ++++++++++++++++++ src/layers/airQuality/model.test.mjs | 250 ++++++++++++ src/layers/airQuality/ownership.test.mjs | 326 +++++++++++++++ src/layers/airQuality/source.js | 78 ++++ src/overlays/worldOverlayAllocation.test.mjs | 19 + .../worldOverlayAllocation.worker.mjs | 78 +++- src/sources/reference.js | 2 + src/sources/reference.test.mjs | 3 + src/ui/layerPanel.js | 1 + 26 files changed, 1730 insertions(+), 41 deletions(-) create mode 100644 scripts/qa-air-quality.mjs create mode 100644 src/app/layers/airQuality.js create mode 100644 src/layers/airQuality/index.js create mode 100644 src/layers/airQuality/model.js create mode 100644 src/layers/airQuality/model.test.mjs create mode 100644 src/layers/airQuality/ownership.test.mjs create mode 100644 src/layers/airQuality/source.js diff --git a/CHANGELOG.md b/CHANGELOG.md index 58103d04d..10a525546 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -600,6 +600,16 @@ of current runtime behavior, see [`docs/CURRENT-STATE.md`](docs/CURRENT-STATE.md ## [Unreleased] +- Add a keyless Air quality layer to the Weather group, with Environment and + Climate Change Canada's AQHI as the first registered provider. Readings carry + `{ provider, scale, value, band }` so another network can be added without + inventing a shared numeric meaning across indices that do not share one; the + row and readout both state that coverage is Canada only. The WEATHER card + shows the band legend and the highest readings currently in view. AQHI is + reported as an integer from 1 with an open-ended "10+", so readings round to + that published form rather than the decimal precision the API returns. + Readings older than six hours are dropped, and the layer reports `stale` + through `getStats()` so feed provenance cannot describe an aged feed as live. - Add ECMWF IFS model selection to Wind (#464, thanks @beneduzi), with model-scoped forecast-step caches, cancellation of replaced requests, and separate issue/valid timestamps. diff --git a/DATA_SOURCES.md b/DATA_SOURCES.md index ab059cebb..4e498cb6b 100644 --- a/DATA_SOURCES.md +++ b/DATA_SOURCES.md @@ -31,6 +31,7 @@ How to read this: | **The Space Devs — Launch Library 2 v2.3** | Recent launch, payload, stage, and recovery metadata for Space Missions (30d) | [The Space Devs terms of use](https://github.com/TheSpaceDevs/Tutorials/blob/main/faqs/faq_TSD.md#terms-of-use): data may be used and shared in any form; avoid forwarding it without added value; attribution is encouraged (not mandatory). [Official API limits](https://ll.thespacedevs.com/docs/): 15 unauthenticated calls/hour; optional token | "Launch Library 2 — The Space Devs" (courtesy attribution) | | **Esri World Imagery** (ArcGIS Online tile service) | The keyless satellite basemap — the default landing when no Google/ion credential is configured, and the "Esri Satellite" map stack | [Esri Master Agreement](https://www.esri.com/en-us/legal/terms/full-master-agreement): the public World Imagery service is usable in public-facing apps with attribution; no key is required for this classic endpoint, but Esri governs and can change access — an app at scale should review current ArcGIS Location Platform terms | "Powered by Esri — Source: Esri, Maxar, Earthstar Geographics, and the GIS User Community" (provider carries the service's own credit line) | | **USGS** | Earthquakes | U.S. public domain | "Data courtesy of the U.S. Geological Survey" | +| **Environment and Climate Change Canada** (MSC GeoMet OGC API) | Air quality (AQHI) — Canadian station observations | [ECCC Data Servers End-use Licence](https://eccc-msc.github.io/open-data/licence/readme_en/) (open, attribution required, no endorsement implied) | "Contains information licensed under the Environment and Climate Change Canada Data Servers End-use Licence" | | **NASA GIBS** (WMTS tiles + Worldview Snapshots) and **NASA CMR** (granule catalog) | Recent Imagery layer: HLS S30/L30 (Sentinel-2, Landsat 8/9) and VIIRS true-colour tiles draped on the map, per-day thumbnails and PNG exports; the HLS granule catalog (dates, cloud, footprints) for a selected box | U.S. public domain (NASA data); keyless, browser-direct (CORS `*`); no bulk use — one 30-day catalog query per box, tiles bounded to the box, six tile requests in flight | "We acknowledge the use of imagery provided by services from NASA's Global Imagery Browse Services (GIBS), part of NASA's Earth Science Data and Information System (ESDIS)." — [gibs.earthdata.nasa.gov](https://gibs.earthdata.nasa.gov), [HLS](https://lpdaac.usgs.gov/products/hlss30v002/) | | **NIFC WFIGS** (Wildland Fire Interagency Geospatial Services, ArcGIS feature service) | Fire Perimeters layer: current interagency wildfire incident perimeters, containment, and incident facts | U.S. public domain (interagency wildland-fire data published through the [NIFC Open Data portal](https://data-nifc.opendata.arcgis.com/)); fetched keyless through `/api/fire-perimeters` (5-minute server cache) with `exceededTransferLimit` paging | "Wildfire perimeters: National Interagency Fire Center (WFIGS)" | | **InciWeb** (inciweb.wildfire.gov) | Per-incident "InciWeb ↗" card links to official incident pages (narratives, evacuation/closure notices) | U.S. government public incident information. Links are matched best-effort by incident name and state, with incident page origin and update times checked on selection. Same-origin `/api/fire-perimeters/inciweb` routes cache the catalog for 1 hour and publication metadata for 30 minutes; linked pages remain InciWeb content | "Incident information: InciWeb (inciweb.wildfire.gov)" | @@ -72,6 +73,14 @@ The optional Local ADS-B layer and the broadcast-FM receiver use radio you recei ### Notes on the live sources +- **Air quality (ECCC AQHI).** Keyless. Read from the MSC GeoMet OGC API (`api.weather.gc.ca`): the `aqhi-stations` catalog plus `aqhi-observations-realtime`. **Coverage is Canada only** — ECCC is the single registered provider, and the layer row and readout card both say so. Roughly 134 stations across ECCC's five administrative zones (Atlantic, Quebec, Ontario, Prairies & North, Pacific & Yukon). AQHI publishes hourly, so readings poll every 20 minutes and the station catalog, which changes a few times a year, is refetched only every 12 hours. Observations are requested with `latest=true`; without it the collection returns the full history, roughly 2,000 rows nationally. Readings older than six hours are dropped rather than displayed as current. + + Readings are stored 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 later without inventing a shared numeric meaning that does not exist. + + AQHI is reported by ECCC as an integer from 1 to 10 with an open-ended "10+" above that. The API returns the underlying decimal (1.08, 2.32); the layer rounds to the published form rather than displaying a precision the index does not claim. + + **Quebec is thinly represented**, and that is upstream rather than a filter here: the province runs its own Info-Smog program, so only a handful of Quebec stations appear in ECCC's national feed. The reporting station count also varies hour to hour as stations report or fall silent. + - **Google Maps Platform.** You supply your own API key and are bound by [Google's ToS](https://cloud.google.com/maps-platform/terms). Google Maps Content (tiles, geocodes, places) **may not be cached, stored, rehosted, or committed** — this app only ever uses it live, which is the compliant pattern. The "Google" attribution is displayed on the globe and must stay visible. Restrict your key (see [SECURITY.md](SECURITY.md)). - **OpenSky Network.** Its license is **non-commercial**, and operational use of the REST API in a live product can require a prior written agreement with OpenSky — even for non-profit/government use. If you deploy this commercially, contact OpenSky for your own terms. The flights layer is a toggle and runs anonymously by default. - **adsb.lol flight fallback.** When OpenSky is unavailable and no last-good OpenSky response exists, the server requests a cached, capped 250 nm adsb.lol point snapshot around the current camera subpoint. This is regional observed context, not worldwide completeness; provenance is exposed in the Flights stats/context row. Military ICAOs remain reconciled through the existing dedicated military registry rather than duplicated. diff --git a/README.md b/README.md index f6a3ae886..9f69fdbd6 100644 --- a/README.md +++ b/README.md @@ -291,6 +291,7 @@ Nineteen layers and map sources. **Seventeen have a keyless path.** Some offer a | 🚢 **Live Vessels** | Thousands of ships worldwide | AISStream | 🟡 | | 🛰️ **Satellites** | 838-object catalog, color-coded by class with a live legend — the **DENSE** chip drops in the whole Starlink shell | CelesTrak | 🟢 | | 🌍 **Earthquakes** | Global seismic activity, last 24h | USGS | 🟢 | +| ◍ **Air quality** | Live AQHI on the published 1–10+ health-risk scale, with a Weather-panel card showing the band legend and the highest readings in view. **Canada only** — ECCC is the first registered provider | ECCC MSC GeoMet | 🟢 | | 🚗 **Traffic** | Simulated vehicles on OSM roads. With TomTom, live flow speeds drive the simulation and congestion colors below ~8 km; individual vehicle positions are not live observations | TomTom + OSM | 🟢 simulation · 🟡 live flow speeds | | 📹 **CCTV Mesh** | ~3,600 public cameras projected _into_ the 3D space — Austin · Texas (TxDOT) · California (Caltrans) · London (TfL) · Ontario (511) · Finland (Fintraffic) · British Columbia (DriveBC) · Estonia (Tallinn, Tarktee) · Delaware (DelDOT live video) · New South Wales (Live Traffic NSW) · Calgary. Positions are published; poses are estimated priors **you calibrate by dragging a gizmo on the camera itself** | City APIs | 🟢 | | 📷 **Mapped ALPR Cameras** | License-plate-reader camera locations tagged by OpenStreetMap contributors, loaded one city-sized view at a time, with **SHOW NEAREST**. Locations and tags only: no plate data, no video | OpenStreetMap (incl. DeFlock mapping) | 🟢 | diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index 22ce1eb3b..cdaa687b8 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -2848,6 +2848,7 @@ its criteria cannot be silently ignored. | Live AIS Vessels 🚢 | AISStream websocket | `src/data/aisLiveVessels.js` | `/api/ais-live` | 60s (+800ms visibility pass) | | Mapped Installations ⌖ | OpenFreeMap military areas; optional configured Overpass names and Google Places search | `src/data/militaryInstallations.js` | OpenFreeMap tiles, `/api/military-installations`, `/api/google/text-search` | viewport-driven; transient errors back off, missing capability switches to tiles | | Earthquakes | USGS | `src/data/earthquakes.js` | — | 60s | +| Air quality | ECCC MSC GeoMet OGC API (AQHI, Canada only) | `src/layers/airQuality/` | — | 20 min readings, 12 h station catalog | | Satellites | CelesTrak | `src/data/satellites.js` | `/api/celestrak` | 120s | | Space Missions (30d) | Launch Library 2 + CelesTrak | `src/data/rocketLaunches.js` | `/api/launches` + `/api/celestrak/active` | 5 min | | Traffic | Selectable TomTom / OpenStreetMap / Hybrid roads; optional TomTom flow (BYOK) | `src/data/traffic.js` | browser-direct OpenFreeMap tiles; `/api/tomtom` for flow | viewport-driven; capped tile cache | diff --git a/package.json b/package.json index e48881c6d..eca8aa8b7 100644 --- a/package.json +++ b/package.json @@ -168,6 +168,7 @@ "./layers/bikeshare": "./src/layers/bikeshare/index.js", "./layers/directions": "./src/layers/directions/index.js", "./layers/cctv": "./src/layers/cctv/index.js", + "./layers/air-quality": "./src/layers/airQuality/index.js", "./layers/alpr": "./src/layers/alpr/index.js", "./layers/radio": "./src/layers/radio/index.js", "./layers/submarine-cables": "./src/layers/submarineCables/index.js", @@ -228,6 +229,7 @@ "./layers/alpr/source": "./src/layers/alpr/source.js", "./layers/bikeshare/source": "./src/layers/bikeshare/source.js", "./layers/cctv/source": "./src/layers/cctv/source.js", + "./layers/air-quality/source": "./src/layers/airQuality/source.js", "./layers/earthquakes/source": "./src/layers/earthquakes/source.js", "./layers/perimeters/source": "./src/layers/perimeters/source.js", "./layers/firms/source": "./src/layers/firms/source.js", diff --git a/scripts/format-scope.json b/scripts/format-scope.json index cd836a5cb..2e27bf753 100644 --- a/scripts/format-scope.json +++ b/scripts/format-scope.json @@ -346,6 +346,12 @@ "src/layers/vessels/tracking.js", "src/layers/vessels/ownership.test.mjs", "src/data/earthquakes.js", + "src/app/layers/airQuality.js", + "src/layers/airQuality/index.js", + "src/layers/airQuality/model.js", + "src/layers/airQuality/model.test.mjs", + "src/layers/airQuality/ownership.test.mjs", + "src/layers/airQuality/source.js", "src/layers/earthquakes/index.js", "src/layers/earthquakes/model.js", "src/layers/earthquakes/source.js", diff --git a/scripts/package-boundaries.json b/scripts/package-boundaries.json index 276b9e0db..67f304f3f 100644 --- a/scripts/package-boundaries.json +++ b/scripts/package-boundaries.json @@ -2027,7 +2027,11 @@ "src/sources/requestDeadline.js", "src/data/local_data/osm_military_names/names.json", "src/layers/installations/namedMarkers.js", - "src/data/infrastructureOverlayEntry.js" + "src/data/infrastructureOverlayEntry.js", + "src/app/layers/airQuality.js", + "src/layers/airQuality/index.js", + "src/layers/airQuality/model.js", + "src/layers/airQuality/source.js" ], "external": [ "hls.js", @@ -2511,7 +2515,11 @@ "src/sources/requestDeadline.js", "src/data/local_data/osm_military_names/names.json", "src/layers/installations/namedMarkers.js", - "src/data/infrastructureOverlayEntry.js" + "src/data/infrastructureOverlayEntry.js", + "src/app/layers/airQuality.js", + "src/layers/airQuality/index.js", + "src/layers/airQuality/model.js", + "src/layers/airQuality/source.js" ], "external": [ "hls.js", @@ -2853,7 +2861,9 @@ "src/sources/httpBody.js", "src/layers/perimeters/source.js", "src/layers/perimeters/records.js", - "src/layers/submarineCables/bundledSource.js" + "src/layers/submarineCables/bundledSource.js", + "src/layers/airQuality/model.js", + "src/layers/airQuality/source.js" ], "external": [] }, @@ -3072,5 +3082,22 @@ "src/layers/perimeters/source.js" ], "external": [] + }, + "air-quality": { + "exports": ["./layers/air-quality"], + "modules": [ + "src/layers/airQuality/index.js", + "src/layers/airQuality/model.js", + "src/layers/airQuality/source.js" + ], + "external": ["cesium"] + }, + "air-quality-source": { + "exports": ["./layers/air-quality/source"], + "modules": [ + "src/layers/airQuality/model.js", + "src/layers/airQuality/source.js" + ], + "external": [] } } diff --git a/scripts/qa-air-quality.mjs b/scripts/qa-air-quality.mjs new file mode 100644 index 000000000..68e6bd505 --- /dev/null +++ b/scripts/qa-air-quality.mjs @@ -0,0 +1,171 @@ +/** + * qa-air-quality.mjs — headless proof for the Air quality layer. + * + * Checks what unit tests cannot: that the live ECCC feed answers, that readings + * reach the app in the provider-shaped form other networks will share, and that + * the Weather readout descriptor carries a legend and an in-view list. + * + * AQHI is reported by ECCC as an integer from 1 with "10+" above ten. The API + * returns the underlying decimal, so a reading arriving as 1.08 — or a station + * with no reading surfacing as a confident 1 — is a real defect this catches. + * + * Run: node scripts/qa-air-quality.mjs --url http://localhost:4173 + */ + +import puppeteer from 'puppeteer'; + +const argv = process.argv.slice(2); +const getOpt = (name, dflt) => { + const i = argv.indexOf(name); + return i >= 0 && argv[i + 1] ? argv[i + 1] : dflt; +}; +const APP_URL = getOpt('--url', 'http://localhost:4173'); + +const results = []; +function record(name, ok, detail) { + results.push({ name, ok, detail }); + const tag = ok ? '\x1b[32mPASS\x1b[0m' : '\x1b[31mFAIL\x1b[0m'; + console.log(` [${tag}] ${name}${detail ? ` — ${detail}` : ''}`); +} +const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); + +async function main() { + let exitCode = 0; + const consoleErrors = []; + const browser = await puppeteer.launch({ + headless: 'new', + args: [ + '--use-gl=swiftshader', + '--enable-unsafe-swiftshader', + '--no-sandbox', + '--disable-dev-shm-usage', + ], + defaultViewport: { width: 1400, height: 900 }, + }); + + try { + const page = await browser.newPage(); + page.on('pageerror', (e) => consoleErrors.push(String(e).slice(0, 160))); + page.on('console', (m) => { + if (m.type() === 'error') consoleErrors.push(m.text().slice(0, 160)); + }); + + await page.goto(APP_URL, { waitUntil: 'domcontentloaded', timeout: 90000 }); + await page.waitForFunction( + () => window.__godsEyeView?.viewer && window.__godsEyeView?.dataManager, + { timeout: 90000 }, + ); + await sleep(1500); + + const stats = await page.evaluate(async () => { + const dm = window.__godsEyeView.dataManager; + await dm.setEnabled('air-quality', true); + const mod = dm.layers.get('air-quality')?.module; + if (!mod) return { missing: true }; + let s = null; + for (let i = 0; i < 45; i++) { + await new Promise((r) => setTimeout(r, 1000)); + s = mod.getStats(); + if (s.count > 0 || s.error) break; + } + return s; + }); + + const loaded = !stats?.missing && !stats?.error && stats?.count > 0; + record('stations report a current index', loaded, + `count=${stats?.count} error=${stats?.error}`); + if (!loaded) exitCode = 1; + + const records = await page.evaluate( + () => + window.__godsEyeView.dataManager + .layers.get('air-quality') + ?.module.getAnalystRecords(500) || [], + ); + + // The provider-neutral contract other networks will share. + const shaped = records.every( + (r) => r.provider && r.scale && Number.isFinite(r.value) && r.band, + ); + record('every reading carries provider, scale, value and band', shaped, + records.length + ? `sample={provider:${records[0].provider}, scale:${records[0].scale}, value:${records[0].value}, band:${records[0].band}}` + : 'no records'); + if (!shaped) exitCode = 1; + + const published = records.every((r) => Number.isInteger(r.value) && r.value >= 1); + record('every AQHI value is in the published integer form, floored at 1', published, + `values=${[...new Set(records.map((r) => r.value))].sort((a, b) => a - b).join(',')}`); + if (!published) exitCode = 1; + + const zones = [...new Set(records.map((r) => r.zone).filter(Boolean))].sort(); + record('coverage spans multiple ECCC administrative zones', zones.length >= 3, + `zones=${zones.join(',') || 'none'} stations=${records.length}`); + if (zones.length < 3) exitCode = 1; + + // The card's whole point is "highest readings in view", so put the camera + // over Canada before asserting on it — the default view is over Austin, + // where an empty in-view list is correct and proves nothing. + await page.evaluate(() => { + const gev = window.__godsEyeView; + const ellipsoid = gev.viewer.scene.globe.ellipsoid; + const d2r = Math.PI / 180; + try { + gev.viewer.camera.cancelFlight(); + } catch { + /* no flight in progress */ + } + gev.viewer.camera.setView({ + destination: ellipsoid.cartographicToCartesian({ + longitude: -100 * d2r, + latitude: 55 * d2r, + height: 4_000_000, + }), + orientation: { heading: 0, pitch: -Math.PI / 2, roll: 0 }, + }); + gev.viewer.scene.requestRender?.(); + }); + await sleep(2500); + + const controls = await page.evaluate( + () => + window.__godsEyeView.dataManager + .layers.get('air-quality') + ?.module.getRowControls?.() || null, + ); + const readoutOk = + controls?.readout === true && + Array.isArray(controls?.legend?.colors) && + controls.legend.colors.length === controls.legend.labels.length && + Array.isArray(controls?.list?.items) && + controls.list.items.length > 0 && + controls.list.items.every((item) => item.id && item.label && item.value) && + /Canada only/.test(controls?.summary?.coverage || ''); + record('the Weather readout lists the worst readings in view, with a legend', readoutOk, + controls + ? `bands=${controls.legend?.labels?.join('/')} inView=${controls.list?.items?.length} coverage="${controls.summary?.coverage}"` + : 'no descriptor'); + if (!readoutOk) exitCode = 1; + + record('no console errors', consoleErrors.length === 0, + consoleErrors.length ? consoleErrors.slice(0, 2).join(' | ') : 'clean'); + if (consoleErrors.length) exitCode = 1; + } catch (e) { + console.error('\x1b[31mHarness error:\x1b[0m', e); + exitCode = 3; + } finally { + await browser.close(); + } + + const pass = results.filter((r) => r.ok === true).length; + const fail = results.filter((r) => r.ok === false).length; + console.log('\n' + '─'.repeat(60)); + console.log(` RESULT: ${pass} passed, ${fail} failed`); + console.log('─'.repeat(60) + '\n'); + process.exit(exitCode || (fail > 0 ? 1 : 0)); +} + +main().catch((e) => { + console.error(e); + process.exit(3); +}); diff --git a/src/app/constructCatalog.js b/src/app/constructCatalog.js index 7ed03d0d2..a9728f317 100644 --- a/src/app/constructCatalog.js +++ b/src/app/constructCatalog.js @@ -23,6 +23,7 @@ import { createApplicationLocalAdsb } from './layers/localAdsb.js'; import { createApplicationAwareness } from './layers/militaryAwareness.js'; import { createApplicationFirms } from './layers/firms.js'; import { createApplicationEarthquakes } from './layers/earthquakes.js'; +import { createApplicationAirQuality } from './layers/airQuality.js'; import { createApplicationFirePerimeters } from './layers/perimeters.js'; import { createApplicationCables } from './layers/submarineCables.js'; import { createInfrastructureLayers } from '../data/infrastructure.js'; @@ -53,6 +54,7 @@ const SOURCE_METHODS = Object.freeze({ weather: ['getSnapshot'], cyclones: ['getSnapshot'], earthquakes: ['getSnapshot'], + 'air-quality': ['getSnapshot'], 'fire-perimeters': ['getSnapshot'], cables: ['fetch'], }); @@ -144,6 +146,7 @@ export function createApplicationCatalog({ ...(resolveAsset ? { resolveAsset } : {}), }), createApplicationEarthquakes({ source: sources.earthquakes }), + createApplicationAirQuality({ source: sources['air-quality'] }), createApplicationFirePerimeters({ source: sources['fire-perimeters'], }), diff --git a/src/app/constructCatalog.test.mjs b/src/app/constructCatalog.test.mjs index 6043b1262..3a802edbe 100644 --- a/src/app/constructCatalog.test.mjs +++ b/src/app/constructCatalog.test.mjs @@ -39,7 +39,7 @@ test('catalogs construct distinct layers and classification from their supplied signal: b.signal, surface: fixtureSurface(b.signal), }); - assert.equal(first.layers.length, 29); + assert.equal(first.layers.length, 30); assert.ok(first.get('local-adsb'), 'Local ADS-B is registered'); assert.deepEqual( first.metadata.find(({ id }) => id === 'local-adsb'), diff --git a/src/app/layers/airQuality.js b/src/app/layers/airQuality.js new file mode 100644 index 000000000..1f25c06d9 --- /dev/null +++ b/src/app/layers/airQuality.js @@ -0,0 +1,6 @@ +import { createAirQualityLayer } from '../../layers/airQuality/index.js'; +import { overlayHost } from './overlayHost.js'; +/** Wire air-quality readings to the application overlay host. */ +export function createApplicationAirQuality(options) { + return createAirQualityLayer({ overlayHost, ...options }); +} diff --git a/src/data/dataCredits.js b/src/data/dataCredits.js index f9fc23b70..f47c454a2 100644 --- a/src/data/dataCredits.js +++ b/src/data/dataCredits.js @@ -162,6 +162,13 @@ export const DATA_CREDITS = [ }, OSM_CREDIT, OPENMAPTILES_CREDIT, + { + key: 'eccc-aqhi', + html: + 'Air quality (AQHI): Environment and Climate Change Canada — MSC GeoMet ' + + '(eccc-msc.github.io/open-data) ' + + '· ECCC Data Servers End-use Licence', + }, { key: 'overture-military-names', html: 'Military area names: Overture Maps Foundation (ODbL)', diff --git a/src/data/layerState.js b/src/data/layerState.js index eccc555f6..ea43cfd15 100644 --- a/src/data/layerState.js +++ b/src/data/layerState.js @@ -553,6 +553,11 @@ export const LAYER_STATE_TOKEN_RESERVATIONS = * owns stable URL ordering. */ export const LAYER_STATE_REGISTRY = Object.freeze([ + Object.freeze({ + id: 'air-quality', + token: '0', + disposition: 'enabled-only', + }), Object.freeze({ id: 'ais-live-vessels', token: 'a', diff --git a/src/data/layerState.test.mjs b/src/data/layerState.test.mjs index 1b504a439..3bebbc7b3 100644 --- a/src/data/layerState.test.mjs +++ b/src/data/layerState.test.mjs @@ -199,8 +199,8 @@ function encode(state) { test('production registry is exact, canonical, and rejects incomplete contracts', async () => { assert.equal(validateLayerStateRegistry(), true); - assert.equal(REGISTERED_LAYER_IDS.length, 28); - assert.equal(new Set(REGISTERED_LAYER_IDS).size, 28); + assert.equal(REGISTERED_LAYER_IDS.length, 29); + assert.equal(new Set(REGISTERED_LAYER_IDS).size, 29); assert.ok(REGISTERED_LAYER_IDS.includes('transit')); assert.deepEqual(REGISTERED_LAYER_IDS, [...REGISTERED_LAYER_IDS].sort()); assert.deepEqual(LEGACY_LAYER_STATE_TOKENS, { @@ -239,7 +239,7 @@ test('production registry is exact, canonical, and rejects incomplete contracts' for (const [id, token] of Object.entries(LEGACY_LAYER_STATE_TOKENS)) { assert.equal(LAYER_STATE_TOKEN_RESERVATIONS[id], token); } - assert.equal(nextLayerStateToken(), '0'); + assert.equal(nextLayerStateToken(), '3'); assert.equal( nextLayerStateToken({ ...LAYER_STATE_TOKEN_RESERVATIONS, alpha: '0', bravo: '3' }), '4', @@ -247,7 +247,7 @@ test('production registry is exact, canonical, and rejects incomplete contracts' const digitsExhausted = { ...LAYER_STATE_TOKEN_RESERVATIONS, ...Object.fromEntries( - [...'03456789'].map((digit) => [`prior-${digit}`, digit]), + [...'3456789'].map((digit) => [`prior-${digit}`, digit]), ), }; assert.equal(nextLayerStateToken(digitsExhausted), '00'); @@ -298,7 +298,7 @@ test('production registry is exact, canonical, and rejects incomplete contracts' assert.equal( validateLayerStateAllocations( LAYER_STATE_TOKEN_RESERVATIONS, - { ...LAYER_STATE_TOKEN_RESERVATIONS, future: '0', next: '3' }, + { ...LAYER_STATE_TOKEN_RESERVATIONS, future: '3', next: '4' }, ), true, ); @@ -307,7 +307,7 @@ test('production registry is exact, canonical, and rejects incomplete contracts' ...LAYER_STATE_TOKEN_RESERVATIONS, future: '00', }), - /next free token 0/, + /next free token 3/, ); const beforeLastDigit = { ...digitsExhausted }; delete beforeLastDigit['prior-9']; diff --git a/src/data/layerStateTokenLedger.test.mjs b/src/data/layerStateTokenLedger.test.mjs index 7b0a9263c..28223762e 100644 --- a/src/data/layerStateTokenLedger.test.mjs +++ b/src/data/layerStateTokenLedger.test.mjs @@ -122,13 +122,20 @@ test('reservation ledger is complete, pinned, and rejects duplicate or malformed { ...parseLayerStateTokenReservations(reservationRows) }, { ...LAYER_STATE_TOKEN_RESERVATIONS }, ); - assert.equal(Object.keys(LAYER_STATE_TOKEN_RESERVATIONS).length, 28); + assert.equal(Object.keys(LAYER_STATE_TOKEN_RESERVATIONS).length, 29); + // The legacy assignments are a closed compatibility set that never grows, so + // the ledger is exactly that set plus every token reserved since. assert.deepEqual( { ...LAYER_STATE_TOKEN_RESERVATIONS }, - { ...LEGACY_LAYER_STATE_TOKENS }, + { ...LEGACY_LAYER_STATE_TOKENS, 'air-quality': '0' }, ); assert.throws( - () => parseLayerStateTokenReservations(reservationRows.slice(1)), + () => + parseLayerStateTokenReservations( + // Dropping any legacy row must fail. This used to slice off row 0, + // which is no longer a legacy reservation. + reservationRows.filter(([id]) => id !== 'ais-live-vessels'), + ), /Missing or changed legacy/, ); assert.throws( @@ -145,9 +152,9 @@ test('reservation ledger is complete, pinned, and rejects duplicate or malformed /Duplicate layer-state token reservation/, ); assert.equal( - parseLayerStateTokenReservations([...reservationRows, ['future', '0']]) + parseLayerStateTokenReservations([...reservationRows, ['future', '3']]) .future, - '0', + '3', ); assert.throws( () => @@ -165,14 +172,14 @@ test('reservation ledger is complete, pinned, and rejects duplicate or malformed test('published retired digits and competing PRs advance the merge-time allocation', () => { withPublishedBase( - { rows: [...reservationRows, ['retired-layer', '0']] }, + { rows: [...reservationRows, ['retired-layer', '3']] }, (cwd) => { const published = readPublishedLayerStateReservations('HEAD', cwd); - assert.equal(nextLayerStateToken(published), '3'); + assert.equal(nextLayerStateToken(published), '4'); assert.equal( validateLayerStateAllocations(published, { ...published, - newcomer: '3', + newcomer: '4', }), true, ); @@ -182,7 +189,7 @@ test('published retired digits and competing PRs advance the merge-time allocati ...published, competing: '0', }), - /next free token 3/, + /next free token 4/, ); assert.throws( () => @@ -196,8 +203,8 @@ test('published retired digits and competing PRs advance the merge-time allocati ); }); -test('PR B manually replaces provisional 0 with 3 after PR A publishes 0', () => { - withPublishedBase({ rows: [...reservationRows, ['pr-a', '0']] }, (cwd) => { +test('PR B manually replaces provisional 3 with 4 after PR A publishes 3', () => { + withPublishedBase({ rows: [...reservationRows, ['pr-a', '3']] }, (cwd) => { mkdirSync(path.join(cwd, 'scripts')); const fixtureChecker = path.join( cwd, @@ -218,8 +225,8 @@ test('PR B manually replaces provisional 0 with 3 after PR A publishes 0', () => writeCodecFixture( cwd, - [...reservationRows, ['pr-b', '0']], - [{ id: 'pr-b', token: '0' }], + [...reservationRows, ['pr-b', '3']], + [{ id: 'pr-b', token: '3' }], ); const beforeRebase = runCheck(); assert.equal(beforeRebase.status, 1); @@ -227,10 +234,10 @@ test('PR B manually replaces provisional 0 with 3 after PR A publishes 0', () => writeCodecFixture( cwd, - [...reservationRows, ['pr-a', '0'], ['pr-b', '0']], + [...reservationRows, ['pr-a', '3'], ['pr-b', '3']], [ - { id: 'pr-a', token: '0' }, - { id: 'pr-b', token: '0' }, + { id: 'pr-a', token: '3' }, + { id: 'pr-b', token: '3' }, ], ); const staleRebase = runCheck(); @@ -238,16 +245,16 @@ test('PR B manually replaces provisional 0 with 3 after PR A publishes 0', () => assert.match(staleRebase.stderr, /Duplicate layer-state token reservation/); const published = readPublishedLayerStateReservations('HEAD', cwd); - assert.equal(nextLayerStateToken(published), '3'); - const validBaselineRows = [...reservationRows, ['pr-a', '0']]; - writeCodecFixture(cwd, validBaselineRows, [{ id: 'pr-a', token: '0' }]); + assert.equal(nextLayerStateToken(published), '4'); + const validBaselineRows = [...reservationRows, ['pr-a', '3']]; + writeCodecFixture(cwd, validBaselineRows, [{ id: 'pr-a', token: '3' }]); const helper = spawnSync( process.execPath, [realpathSync(fixtureNext), 'pr-b'], { cwd, encoding: 'utf8' }, ); assert.equal(helper.status, 0, helper.stderr); - assert.match(helper.stdout, /pr-b: 3/); + assert.match(helper.stdout, /pr-b: 4/); assert.deepEqual( JSON.parse(readFileSync(path.join(cwd, LEDGER_PATH), 'utf8')), validBaselineRows, @@ -255,20 +262,20 @@ test('PR B manually replaces provisional 0 with 3 after PR A publishes 0', () => ); writeCodecFixture( cwd, - [...reservationRows, ['pr-a', '0'], ['pr-b', '3']], + [...reservationRows, ['pr-a', '3'], ['pr-b', '4']], [ - { id: 'pr-a', token: '0' }, - { id: 'pr-b', token: '3' }, + { id: 'pr-a', token: '3' }, + { id: 'pr-b', token: '4' }, ], ); const corrected = runCheck(); assert.equal(corrected.status, 0, corrected.stderr); - assert.match(corrected.stdout, /29 published, 1 new/); + assert.match(corrected.stdout, /30 published, 1 new/); }); }); test('allocation batches cross the last digit and base-36 pair boundaries in order', () => { - const digitRows = [...'03456789'].map((digit) => [`retired-${digit}`, digit]); + const digitRows = [...'3456789'].map((digit) => [`retired-${digit}`, digit]); const allDigits = parseLayerStateTokenReservations([ ...reservationRows, ...digitRows, @@ -320,7 +327,7 @@ test('allocation batches cross the last digit and base-36 pair boundaries in ord test('an isolated valid two-character fixture round-trips an l field beyond the old 64-character cap', async () => { const cwd = mkdtempSync(path.join(tmpdir(), 'gev-layer-token-width-')); try { - const priorDigits = [...'03456789'].map((digit) => [ + const priorDigits = [...'3456789'].map((digit) => [ `qa-prior-digit-${digit}`, digit, ]); @@ -357,7 +364,7 @@ test('an isolated valid two-character fixture round-trips an l field beyond the const params = new URLSearchParams([['v', '2']]); codec.encodeLayerStateParams(params, state); assert.ok(params.get('l').length > 64, 'fixture must cross the old cap'); - assert.equal(params.get('l').length, 67); + assert.equal(params.get('l').length, 69); const restored = codec.decodeLayerStateParams(params); assert.deepEqual(restored?.enabledLayerIds, expectedLayerIds); assert.deepEqual(restored?.options, state.options); @@ -375,7 +382,7 @@ test('checker reads a complete future base ledger and rejects retired-token reus }, (cwd) => { const published = readPublishedLayerStateReservations('HEAD', cwd); - assert.equal(Object.keys(published).length, 29); + assert.equal(Object.keys(published).length, 30); assert.equal(published['retired-layer'], '00'); assert.throws( () => diff --git a/src/data/layerStateTokenReservations.json b/src/data/layerStateTokenReservations.json index 600358c7c..7329ad59f 100644 --- a/src/data/layerStateTokenReservations.json +++ b/src/data/layerStateTokenReservations.json @@ -1,4 +1,5 @@ [ + ["air-quality", "0"], ["ais-live-vessels", "a"], ["alpr-cameras", "p"], ["bhote-koshi-2026", "h"], diff --git a/src/layers/airQuality/index.js b/src/layers/airQuality/index.js new file mode 100644 index 000000000..43a76f857 --- /dev/null +++ b/src/layers/airQuality/index.js @@ -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} + */ + 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; +} diff --git a/src/layers/airQuality/model.js b/src/layers/airQuality/model.js new file mode 100644 index 000000000..93f13404f --- /dev/null +++ b/src/layers/airQuality/model.js @@ -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} 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} 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|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} stations - Known stations by location_id. + * @param {number} [now=Date.now()] - Clock seam for tests. + * @returns {Array|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} readings + * @param {?{west:number, south:number, east:number, north:number}} rect - Degrees. + * @param {number} [limit=5] + * @returns {Array} + */ +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), + }; +} diff --git a/src/layers/airQuality/model.test.mjs b/src/layers/airQuality/model.test.mjs new file mode 100644 index 000000000..56130c30e --- /dev/null +++ b/src/layers/airQuality/model.test.mjs @@ -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`); + } +}); diff --git a/src/layers/airQuality/ownership.test.mjs b/src/layers/airQuality/ownership.test.mjs new file mode 100644 index 000000000..5eeaae0e1 --- /dev/null +++ b/src/layers/airQuality/ownership.test.mjs @@ -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', + ); +}); diff --git a/src/layers/airQuality/source.js b/src/layers/airQuality/source.js new file mode 100644 index 000000000..b5f98894c --- /dev/null +++ b/src/layers/airQuality/source.js @@ -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} */ + 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; + }, + }; +} diff --git a/src/overlays/worldOverlayAllocation.test.mjs b/src/overlays/worldOverlayAllocation.test.mjs index bbaa4f168..e29785d48 100644 --- a/src/overlays/worldOverlayAllocation.test.mjs +++ b/src/overlays/worldOverlayAllocation.test.mjs @@ -11,6 +11,7 @@ import { EARTHQUAKE_OVERLAY_COHORT_LIMIT } from '../data/earthquakes.js'; import { ROCKET_MISSION_AMBIENT_OVERLAY_COHORT_LIMIT } from '../data/rocketLaunches.js'; import { RADIO_OVERLAY_COHORT_LIMIT } from '../data/radio.js'; import { CABLE_REFERENCE_LABEL_WINNER_CAP } from '../data/telegeographySubmarineCables.js'; +import { AIR_QUALITY_OVERLAY_COHORT_LIMIT } from '../layers/airQuality/model.js'; import { isCalibratedAllocationRuntime } from '../../scripts/run-unit-tests.mjs'; /** @@ -34,6 +35,7 @@ import { isCalibratedAllocationRuntime } from '../../scripts/run-unit-tests.mjs' * ---------------------+---------+------------+---------+----------------+-------------+------------- * generic below cap | 60 | 60 | 60 | 3182 | 53.0 | 4,100 * generic above cap | 250 | 250 | 96 | 10022 | 40.1 | 13,000 + * air quality | 48 | 48 | 32 | 3900 | 81.3 | 5,200 * local infrastructure | 320 | 320 | 192 | 39478 | 123.4 | 49,000 * Phase 3 + FIRMS | 338 | 338 | 210 | 41220 | 122.0 | 53,600 * Phase 3 + vessels | 451 | 451 | 311 | 67252 | 149.1 | 87,500 @@ -259,6 +261,23 @@ const WORKLOADS = [ maxBytesPerFrame: 19_000, saturated: true, }, + { + // The air-quality station cohort on its own. Measured standalone rather + // than stacked on the Phase-5 host (the submarine-cable precedent): these + // stations are sparse and, with the single registered provider, + // continental, so what matters is its own cohort cost. + // + // Re-measured against the current worker: median 3,900 B/frame, max 4,114, + // and 85.7 B/candidate at the ceiling — inside the shared 154 B/candidate + // bound, so no per-source exception is taken. The 5,200 frame budget + // carries 26.4% headroom over the measured maximum. + name: 'with the air-quality station cohort live', + profile: 'air-quality', + entries: AIR_QUALITY_OVERLAY_COHORT_LIMIT, + candidates: AIR_QUALITY_OVERLAY_COHORT_LIMIT, + maxBytesPerFrame: 5_200, + saturated: true, + }, { name: 'with the Dense detection lane active over 5,000 observations', profile: 'phase6-detection', diff --git a/src/overlays/worldOverlayAllocation.worker.mjs b/src/overlays/worldOverlayAllocation.worker.mjs index ea463f080..0314ec41d 100644 --- a/src/overlays/worldOverlayAllocation.worker.mjs +++ b/src/overlays/worldOverlayAllocation.worker.mjs @@ -26,6 +26,12 @@ import { TRACKED_OVERLAY_SOURCE_OPTIONS, } from '../data/trackedReadout.js'; import { CCTV_AMBIENT_CARD_MAX } from '../data/cctvLod.js'; +import { + AIR_QUALITY_OVERLAY_COHORT_LIMIT, + AIR_QUALITY_OVERLAY_COLLISION_CAPACITY, + AIR_QUALITY_OVERLAY_SOURCE_ID, + createAirQualityOverlayEntry, +} from '../layers/airQuality/model.js'; import { CCTV_OVERLAY_SOURCE_ID, createCctvThumbnailOverlayEntry, @@ -1101,6 +1107,68 @@ function buildSubmarineCablesWorkload(count) { ); } +/** + * The air-quality station cohort at full size. + * + * Measured standalone (the submarine-cable precedent) rather than stacked on + * the Phase-5 host: air-quality stations are sparse and, with the single + * currently-registered provider, continental — so the question a budget has to + * answer is what its own cohort costs, not what it adds to a worldwide worst + * case it never shares a viewport with. The entry shape is the same static + * `ambient-label` the earthquake layer publishes, so the per-candidate ceiling + * is the meaningful gate. + */ +function buildAirQualityWorkload(count) { + if (count !== AIR_QUALITY_OVERLAY_COHORT_LIMIT) { + throw new Error( + `air-quality requires ${AIR_QUALITY_OVERLAY_COHORT_LIMIT} entries`, + ); + } + const workload = { + entries: [], + positions: [], + drifts: [], + registrations: [], + }; + const columns = Math.max(1, Math.ceil(Math.sqrt((count * 16) / 9))); + const rows = Math.max(1, Math.ceil(count / columns)); + const spanX = columns > 1 ? 1.68 / (columns - 1) : 0; + const spanY = rows > 1 ? 1.56 / (rows - 1) : 0; + const stations = []; + for (let index = 0; index < count; index++) { + const column = index % columns; + const row = Math.floor(index / columns); + const baseX = -0.84 + column * spanX; + const baseY = -0.78 + row * spanY; + const position = new Cesium.Cartesian3(baseX, baseY, 0); + workload.positions.push(position); + workload.drifts.push({ + baseX, + baseY, + phase: index * 0.27, + rate: 0.32 + (index % 6) * 0.04, + }); + const entry = createAirQualityOverlayEntry({ + id: `air-${index}`, + position, + title: `Station ${index} ${1 + (index % 10)}`, + accent: '#4cc9f0', + value: 1 + (index % 10), + }); + entry.horizonCull = false; + stations.push(entry); + } + workload.registrations.push({ + sourceId: AIR_QUALITY_OVERLAY_SOURCE_ID, + entries: stations, + options: { + cohortLimit: AIR_QUALITY_OVERLAY_COHORT_LIMIT, + collisionCapacity: AIR_QUALITY_OVERLAY_COLLISION_CAPACITY, + }, + }); + return workload; +} + function buildPhase6DetectionWorkload(count) { const random = makeRandom(0xd37ec710); const positions = []; @@ -1206,9 +1274,13 @@ function main() { ? buildPhase5RocketMissionWorkload(ENTRY_COUNT) : PROFILE === 'all-live-radio' ? buildAllLiveRadioWorkload(ENTRY_COUNT) - : PROFILE === 'submarine-cables' - ? buildSubmarineCablesWorkload(ENTRY_COUNT) - : buildWorkload(ENTRY_COUNT); + : PROFILE === 'air-quality' + ? buildAirQualityWorkload(ENTRY_COUNT) + : PROFILE === 'submarine-cables' + ? buildSubmarineCablesWorkload( + ENTRY_COUNT, + ) + : buildWorkload(ENTRY_COUNT); const { entries, positions, drifts } = workload; const solveIntervalMs = Number(process.env.GEV_ALLOC_SOLVE_MS) || 125; const detectionActive = !!workload.detectionLayer; diff --git a/src/sources/reference.js b/src/sources/reference.js index 4bdf7e9ac..34c776164 100644 --- a/src/sources/reference.js +++ b/src/sources/reference.js @@ -1,4 +1,5 @@ import { createUsgsEarthquakeSource } from '../layers/earthquakes/source.js'; +import { createEcccAirQualitySource } from '../layers/airQuality/source.js'; import { createWfigsPerimeterSource } from '../layers/perimeters/source.js'; import { createBundledCableSource } from '../layers/submarineCables/bundledSource.js'; @@ -6,6 +7,7 @@ import { createBundledCableSource } from '../layers/submarineCables/bundledSourc export function createReferenceSources() { return { earthquakes: createUsgsEarthquakeSource(), + 'air-quality': createEcccAirQualitySource(), 'fire-perimeters': createWfigsPerimeterSource(), cables: createBundledCableSource(), }; diff --git a/src/sources/reference.test.mjs b/src/sources/reference.test.mjs index 902a72c83..285ee6d66 100644 --- a/src/sources/reference.test.mjs +++ b/src/sources/reference.test.mjs @@ -14,13 +14,16 @@ test('reference factories retain compatibility without starting acquisition or s const second = createReferenceSources(); assert.deepEqual(Object.keys(first), [ 'earthquakes', + 'air-quality', 'fire-perimeters', 'cables', ]); assert.notEqual(first.earthquakes, second.earthquakes); + assert.notEqual(first['air-quality'], second['air-quality']); assert.notEqual(first['fire-perimeters'], second['fire-perimeters']); assert.notEqual(first.cables, second.cables); assert.equal(typeof first.earthquakes.getSnapshot, 'function'); + assert.equal(typeof first['air-quality'].getSnapshot, 'function'); assert.equal(typeof first['fire-perimeters'].getSnapshot, 'function'); assert.equal(typeof first.cables.fetch, 'function'); assert.equal(requests, 0); diff --git a/src/ui/layerPanel.js b/src/ui/layerPanel.js index 57fd595f0..81ced6cf8 100644 --- a/src/ui/layerPanel.js +++ b/src/ui/layerPanel.js @@ -51,6 +51,7 @@ const PANEL_GROUPS = [ { label: 'Weather', ids: [ + 'air-quality', 'wind', 'weather-radar', 'weather-satellite', From b8ccfd1648755d31d18644cbeec9d7768ffedb0a Mon Sep 17 00:00:00 2001 From: Cliff Flood Date: Wed, 16 Sep 2026 20:22:45 -0400 Subject: [PATCH 4/9] feat(bikeshare): add Bike Share Toronto MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Toronto runs the same PBSC platform as the Austin, Honolulu and Chattanooga systems already in the registry, and publishes the identical GBFS v2 endpoint pair under `toronto.publicbikesystem.net`. The proxy allowlist already admits the host through its `*.publicbikesystem.net` wildcard and the paths end in the expected `station_information.json` / `station_status.json`, so the system needs no new plumbing — only the registry entry that names it. Verified against the live feeds: 1,069 stations in both documents, all 1,069 joining on `station_id`. The layer reads `num_bikes_available`, which Toronto populates normally, so availability renders without a vendor-specific mapping. It does not populate the standard `num_bikes_available_types`, reporting per-type counts under PBSC ids (ICONIC/BOOST/EFIT/ASTRO) instead; nothing reads that field today, and this entry deliberately does not translate it rather than adding a mapping with no consumer. The center is downtown at 43.6532, -79.3832 with the group's 90 km load radius. The stations span roughly 37 km east-west, from Etobicoke to Scarborough, so a tighter radius would drop the outer ends on a downtown pan. The new registry test is the reason this was cheap to be sure about: it holds every city's feed URLs against the same host and path predicates the proxy enforces, so a city the proxy would silently refuse now fails the suite instead of rendering an empty layer. Credited to Bike Share Toronto (Toronto Parking Authority). The City's open-data package declares no licence and the feed publishes no `license_url`, so it is treated as attribution-only, like the other GBFS operators, rather than claiming a licence that was never granted. --- CHANGELOG.md | 6 +++ DATA_SOURCES.md | 1 + docs/CURRENT-STATE.md | 2 +- src/data/dataCredits.js | 4 +- src/layers/bikeshare/registry.js | 12 ++++++ src/layers/bikeshare/registry.test.mjs | 58 ++++++++++++++++++++++++++ 6 files changed, 81 insertions(+), 2 deletions(-) create mode 100644 src/layers/bikeshare/registry.test.mjs diff --git a/CHANGELOG.md b/CHANGELOG.md index 032c6fe2a..98e0066f6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,11 @@ # Changelog +- Add Bike Share Toronto (1,069 stations) to the bikeshare registry. The system + publishes the same PBSC GBFS v2 feeds as the Austin, Honolulu and Chattanooga + entries, so it needs no new plumbing; a registry test now holds every city's + feed URLs against the proxy host and path allowlists, so a city the proxy + would refuse fails the suite instead of rendering an empty layer. + - GBFS v3 station names use English translations when available, then the first usable translation or short name. Empty or malformed names no longer appear as `[object Object]` or interrupt station loading; legacy string names remain diff --git a/DATA_SOURCES.md b/DATA_SOURCES.md index 040e53afc..3e1e0f114 100644 --- a/DATA_SOURCES.md +++ b/DATA_SOURCES.md @@ -58,6 +58,7 @@ How to read this: | **Open Calgary** (`data.calgary.ca`) | CCTV camera catalog + frames, Calgary | [Open Government Licence – City of Calgary](https://data.calgary.ca/stories/s/Open-Calgary-Terms-of-Use/u45n-7awa) — attribution REQUIRED | "Contains information licensed under the Open Government Licence – City of Calgary" | | **Statens vegvesen** (catalog `ogckart-sn1.atlas.vegvesen.no`, frames `kamera.atlas.vegvesen.no`, HLS `kamera.vegvesen.no`) | CCTV camera catalog + frames + live HLS video, Norway road cameras | [NLOD 2.0](https://data.norge.no/nlod/en/2.0) — attribution REQUIRED | "Contains data under the Norwegian licence for Open Government data (NLOD) distributed by Statens vegvesen" | | **GBFS (Lyft / BCycle)** | Bikeshare availability | Per-feed (attribution-only) | Credit the operator (e.g. Austin BCycle) + its `license_url` | +| **Bike Share Toronto** (`toronto.publicbikesystem.net`) | Bikeshare station availability, Toronto | Operator GBFS feed. The City's open-data package declares no licence and the feed publishes no `license_url`, so it is treated as attribution-only like the other GBFS operators. | "Bike Share Toronto (Toronto Parking Authority)" | | **MBTA / MassDOT** (cdn.mbta.com) | Transit layer live vehicles and up to 15 minutes of recently observed positions, Greater Boston | MBTA / MassDOT open realtime vehicle data, used for the live view and up to fifteen minutes of recently observed positions; published for developer use and not against their use. | Attribution to MBTA / MassDOT (courtesy text) | | **CapMetro** (data.texas.gov) | Transit layer live vehicles, Austin TX | CapMetro open realtime vehicle data, used for the live view; published for developer use and not against their use. | Attribution to CapMetro (courtesy text) | | **Metro Transit** (Metropolitan Council) | Transit layer live vehicles, Minneapolis–St Paul | Metro Transit / Metropolitan Council open realtime vehicle data, used for the live view; published for developer use and not against their use. | Attribution to Metro Transit / Metropolitan Council (courtesy text) | diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index 0d3ef31e0..550d76d57 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -3074,7 +3074,7 @@ its criteria cannot be silently ignored. | CCTV | Austin + Caltrans (CA) + TfL London + Ontario 511 + Fintraffic (FI) + DriveBC (BC) + TxDOT (TX) + Estonia (Tallinn, Tarktee) + Live Traffic NSW + Open Calgary Open Data + Statens vegvesen (NO, stills + live HLS) + Street View fallback | `src/data/cctv.js` | `/api/cctv` | 10s (active) | | Radio | Radio Browser (public-domain station directory) | `src/data/radio.js` | `/api/radio/stations`, `/api/radio/click/:uuid` | 45 min directory refresh | | Transit 🚌 | Operator GTFS-Realtime VehiclePositions (7 keyless regions, `src/data/transitFeeds.js`) | `src/layers/transit/` via `src/app/layers/transit.js` | `/api/transit` | 15s (poll + delayed playback) | -| Bikeshare 🚲 | GBFS (Lyft + BCycle) | `src/data/bikeshare.js` | `/api/gbfs` | 60s | +| Bikeshare 🚲 | GBFS (Lyft + BCycle + Bike Share Toronto) | `src/data/bikeshare.js` | `/api/gbfs` | 60s | | Directions 🧭 | OSRM on FOSSGIS servers (OpenStreetMap) | `src/data/directions.js` | `/api/route` (`steps=1`) | on placement / mode change | | Datacenters ▣ | OSM extract (bundled) | `src/data/localLayers.js` | — | static | | Dams ▰ | OpenInfraMap/OSM extract (bundled) | `src/data/localLayers.js` | — | static | diff --git a/src/data/dataCredits.js b/src/data/dataCredits.js index 2967f0a2b..2277abf4a 100644 --- a/src/data/dataCredits.js +++ b/src/data/dataCredits.js @@ -249,7 +249,9 @@ export const DATA_CREDITS = [ }, { key: 'gbfs', - html: 'Bikeshare availability: GBFS operator feeds (e.g. Austin BCycle)', + html: + 'Bikeshare availability: GBFS operator feeds ' + + '(e.g. Austin BCycle; Bike Share Toronto, operated by the Toronto Parking Authority)', }, { key: 'osrm-routing', diff --git a/src/layers/bikeshare/registry.js b/src/layers/bikeshare/registry.js index ff752f664..e9096b4a9 100644 --- a/src/layers/bikeshare/registry.js +++ b/src/layers/bikeshare/registry.js @@ -167,6 +167,18 @@ const RAW_GBFS_CITY_REGISTRY = [ 'https://chat.publicbikesystem.net/customer/gbfs/v2/en/station_status.json', provider: 'Bike Chattanooga', }, + { + id: 'toronto-bike-share', + city: 'Toronto, ON', + centerLat: 43.6532, + centerLon: -79.3832, + loadRadiusKm: 90, + stationInformationUrl: + 'https://toronto.publicbikesystem.net/customer/gbfs/v2/en/station_information.json', + stationStatusUrl: + 'https://toronto.publicbikesystem.net/customer/gbfs/v2/en/station_status.json', + provider: 'Bike Share Toronto', + }, bcycleEntry({ id: 'boulder-bcycle', city: 'Boulder, CO', diff --git a/src/layers/bikeshare/registry.test.mjs b/src/layers/bikeshare/registry.test.mjs new file mode 100644 index 000000000..30e708957 --- /dev/null +++ b/src/layers/bikeshare/registry.test.mjs @@ -0,0 +1,58 @@ +// BIKESHARE REGISTRY — a city entry is only useful if the proxy will serve it. +// +// A registry entry and the GBFS proxy allowlist are edited in different files, +// so a city can look correctly registered while every one of its requests is +// rejected at the proxy. These cases hold the two together: each entry is +// checked against the same host and path predicates the proxy enforces, so a +// new city that the proxy would refuse fails here rather than silently +// rendering an empty layer. +// +// Run with: npm test +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { GBFS_CITY_REGISTRY, CITY_BY_ID } from './registry.js'; +import { isAllowedGbfsHost, isAllowedGbfsPath } from '../../data/gbfsSource.js'; + +test('every registered city is reachable through the GBFS proxy allowlist', () => { + for (const city of GBFS_CITY_REGISTRY) { + for (const raw of [city.stationInformationUrl, city.stationStatusUrl]) { + const url = new URL(raw); + assert.equal( + isAllowedGbfsHost(url.hostname), + true, + `${city.id}: host ${url.hostname} is not allowlisted`, + ); + assert.equal( + isAllowedGbfsPath(url.pathname), + true, + `${city.id}: path ${url.pathname} is not allowlisted`, + ); + } + } +}); + +test('Bike Share Toronto is registered against the PBSC v2 feeds', () => { + const toronto = CITY_BY_ID.get('toronto-bike-share'); + assert.ok(toronto, 'toronto-bike-share is missing from the registry'); + assert.equal(toronto.city, 'Toronto, ON'); + assert.equal(toronto.provider, 'Bike Share Toronto'); + assert.equal( + toronto.stationInformationUrl, + 'https://toronto.publicbikesystem.net/customer/gbfs/v2/en/station_information.json', + ); + assert.equal( + toronto.stationStatusUrl, + 'https://toronto.publicbikesystem.net/customer/gbfs/v2/en/station_status.json', + ); +}); + +test('the Toronto center sits inside the system service area', () => { + const toronto = CITY_BY_ID.get('toronto-bike-share'); + // Bounding box of the 1,069 live stations, sampled from the published + // station_information feed: 43.588..43.813 N, -79.603..-79.123 W. + assert.ok(toronto.centerLat > 43.588 && toronto.centerLat < 43.813); + assert.ok(toronto.centerLon > -79.603 && toronto.centerLon < -79.123); + // The system spans roughly 37 km east-west, so the load radius has to clear + // half that from the center or downtown pans lose the outer stations. + assert.ok(toronto.loadRadiusKm >= 25); +}); From 001043f74eb8db26cd9c9f41edd798dfbb2b180d Mon Sep 17 00:00:00 2001 From: Cliff Flood Date: Wed, 16 Sep 2026 20:55:04 -0400 Subject: [PATCH 5/9] fix(bikeshare): let one dead city feed fail without failing the layer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The layer recorded failure in a single `_error` string. Every city feed is fetched independently, so one retired operator set that flag for the whole layer — and the only code that cleared it ran on a successful *activation*, which `runProximityCheck` skips for a city that already has rendered points. A dead feed that re-entered camera range therefore set the flag on every camera move and nothing ever cleared it: a permanent LOAD FAILED over a map drawing every other city's stations. Failure is now tracked per city in `_failedCityIds`, and `getStats()` reports it only when the failures left nothing to draw. A city that recovers removes its own entry rather than waiting for a neighbour to succeed. This was reachable only because Buffalo sits 95 km from Toronto, inside the 100 km activation radius — the one dead city in the registry with a healthy neighbour in range. The other four failed alone, where LOAD FAILED with an empty map reads as correct, which is why the latch went unnoticed. Probing all 33 registry systems found five dead. Three had moved and are repointed: Reddy Bikeshare (Buffalo) to Social Bicycles, Tugo (Tucson) and Trinity Metro (Fort Worth) to PBSC, all verified serving stations. Two are dropped because the systems wound down rather than moved — Broward B-cycle 404s at every documented endpoint, and CoGo's replacement Lyft feed publishes zero stations with status last reported in March 2025. `gbfs.cogobikeshare.com` no longer resolves, so its allowlist entry goes too. All 31 remaining systems were verified live, and every registry URL was checked against the proxy host and path allowlists. --- CHANGELOG.md | 14 ++++ src/data/gbfsSource.js | 2 +- src/layers/bikeshare/controls.js | 9 ++- .../bikeshare/failureReporting.test.mjs | 80 +++++++++++++++++++ src/layers/bikeshare/ingestion.js | 3 +- src/layers/bikeshare/lifecycle.js | 4 +- src/layers/bikeshare/registry.js | 54 ++++++------- src/layers/bikeshare/state.js | 11 ++- src/layers/bikeshare/viewport.js | 4 +- src/layers/transit/lifecycle.test.mjs | 1 + 10 files changed, 144 insertions(+), 38 deletions(-) create mode 100644 src/layers/bikeshare/failureReporting.test.mjs diff --git a/CHANGELOG.md b/CHANGELOG.md index 98e0066f6..344e32952 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,19 @@ # Changelog +- Stop one retired bikeshare feed from reporting the whole layer as failed. + City feeds are fetched independently, but failure was recorded in a single + flag that only a successful *activation* cleared — and an already-loaded city + never activates again. A dead feed re-entering camera range therefore latched + LOAD FAILED permanently over a map still drawing every other city's stations. + Failure is now tracked per city and reported only when it leaves the layer + with nothing to draw; a city that recovers clears its own failure. + + Repointed the three feeds whose operators had moved: Reddy Bikeshare (Buffalo) + to Social Bicycles, Tugo (Tucson) and Trinity Metro (Fort Worth) to PBSC. + Dropped CoGo (Columbus) and Broward B-cycle (Fort Lauderdale), whose systems + have wound down — CoGo's feed reports no stations and last moved a bike in + March 2025. All 31 remaining systems were verified live. + - Add Bike Share Toronto (1,069 stations) to the bikeshare registry. The system publishes the same PBSC GBFS v2 feeds as the Austin, Honolulu and Chattanooga entries, so it needs no new plumbing; a registry test now holds every city's diff --git a/src/data/gbfsSource.js b/src/data/gbfsSource.js index a15a806d9..f0a94d789 100644 --- a/src/data/gbfsSource.js +++ b/src/data/gbfsSource.js @@ -4,7 +4,7 @@ const GBFS_ALLOWED_HOSTS = new Set([ 'gbfs.bluebikes.com', 'gbfs.bcycle.com', 'gbfs.biketownpdx.com', - 'gbfs.cogobikeshare.com', + 'reddybikeshare.socialbicycles.com', 'austin.publicbikesystem.net', 'hon.publicbikesystem.net', 'chat.publicbikesystem.net', diff --git a/src/layers/bikeshare/controls.js b/src/layers/bikeshare/controls.js index 8bbe70120..9e9389c58 100644 --- a/src/layers/bikeshare/controls.js +++ b/src/layers/bikeshare/controls.js @@ -70,7 +70,14 @@ export function createControls({ state: layerState, services, parts, source }) { ? `syncing ${layerState._activeCityIds.size} city feeds...` : 'scanning nearby systems...'; } - if (layerState._error) stats.error = layerState._error; + // A failed city is only the layer's failure when it left nothing on the + // map. While any city still renders, the layer is working and says so. + if ( + layerState._failedCityIds.size > 0 && + layerState._stationRenderMap.size === 0 + ) { + stats.error = 'GBFS fetch error'; + } return stats; }, }; diff --git a/src/layers/bikeshare/failureReporting.test.mjs b/src/layers/bikeshare/failureReporting.test.mjs new file mode 100644 index 000000000..63034078b --- /dev/null +++ b/src/layers/bikeshare/failureReporting.test.mjs @@ -0,0 +1,80 @@ +// BIKESHARE FAILURE REPORTING — one dead city is not a dead layer. +// +// City feeds are fetched independently, but the layer reported failure through +// a single flag. A city whose operator had retired its feed set that flag on +// every proximity check, and the only code that cleared it ran on a successful +// *activation* — which an already-loaded city never repeats. The result was a +// permanent LOAD FAILED over a map drawing a thousand live stations. +// +// These cases pin the rule that replaced it: the layer reports failure only +// when it has nothing to show, and a city that comes back clears its own +// failure rather than waiting for a neighbour to succeed. +// +// Run with: npm test +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { createState } from './state.js'; +import { createControls } from './controls.js'; + +function makeLayer() { + const services = { + overlays: { + setOverlayEntries() {}, + setOverlaySourceVisible() {}, + clearOverlaySource() {}, + }, + }; + const state = createState({ services }); + const parts = { queries: { collectDetectableStations: () => [] } }; + const { methods } = createControls({ state, services, parts, source: {} }); + return { state, methods }; +} + +/** Stand in for a rendered station without reaching into the renderer. */ +function renderStations(state, count) { + for (let i = 0; i < count; i += 1) state._stationRenderMap.set(`s${i}`, {}); + state._count = state._stationRenderMap.size; +} + +test('a failed city does not fail a layer that is drawing stations', () => { + const { state, methods } = makeLayer(); + renderStations(state, 1069); + state._failedCityIds.add('buffalo-reddy'); + + const stats = methods.getStats(); + assert.equal(stats.count, 1069); + assert.equal( + stats.error, + undefined, + 'a layer drawing 1069 stations reported LOAD FAILED', + ); +}); + +test('the layer reports failure when a failed city left it with nothing', () => { + const { state, methods } = makeLayer(); + state._failedCityIds.add('buffalo-reddy'); + + const stats = methods.getStats(); + assert.equal(stats.count, 0); + assert.equal(stats.error, 'GBFS fetch error'); +}); + +test('a layer with nothing to draw and nothing failed is not an error', () => { + const { state, methods } = makeLayer(); + + // Out of range of every city is empty, not broken. + assert.equal(methods.getStats().error, undefined); +}); + +test('a recovered city clears its own failure', () => { + const { state, methods } = makeLayer(); + state._failedCityIds.add('buffalo-reddy'); + assert.equal(methods.getStats().error, 'GBFS fetch error'); + + state._failedCityIds.delete('buffalo-reddy'); + assert.equal( + methods.getStats().error, + undefined, + 'the failure outlived the city that caused it', + ); +}); diff --git a/src/layers/bikeshare/ingestion.js b/src/layers/bikeshare/ingestion.js index 732ec9c90..cda677766 100644 --- a/src/layers/bikeshare/ingestion.js +++ b/src/layers/bikeshare/ingestion.js @@ -182,13 +182,14 @@ export function createIngestion({ ) return; parts.rendering.applyStatusToPoints(cityId, statusMap); + layerState._failedCityIds.delete(cityId); } catch (error) { if (error?.name === 'AbortError') return; console.warn( `[Data:Bikeshare] ${cityId} status update error:`, error, ); - layerState._error = 'GBFS status update failed'; + layerState._failedCityIds.add(cityId); } }), ); diff --git a/src/layers/bikeshare/lifecycle.js b/src/layers/bikeshare/lifecycle.js index 17af85f11..3e46b5972 100644 --- a/src/layers/bikeshare/lifecycle.js +++ b/src/layers/bikeshare/lifecycle.js @@ -51,7 +51,7 @@ export function createLifecycle({ layerState._lastUpdate = null; layerState._loading = false; layerState._loadingOps = 0; - layerState._error = null; + layerState._failedCityIds.clear(); layerState._limitWarned = false; layerState._overlayHost.setVisible( @@ -75,7 +75,7 @@ export function createLifecycle({ */ enable(viewer) { layerState._enabled = true; - layerState._error = null; + layerState._failedCityIds.clear(); layerState._pointCollection.show = true; layerState._overlayHost.setVisible( BIKESHARE_SELECTED_OVERLAY_SOURCE_ID, diff --git a/src/layers/bikeshare/registry.js b/src/layers/bikeshare/registry.js index e9096b4a9..5cdf971ad 100644 --- a/src/layers/bikeshare/registry.js +++ b/src/layers/bikeshare/registry.js @@ -143,18 +143,6 @@ const RAW_GBFS_CITY_REGISTRY = [ 'https://hon.publicbikesystem.net/customer/gbfs/v2/en/station_status.json', provider: 'Biki', }, - { - id: 'columbus-cogo', - city: 'Columbus, OH', - centerLat: 39.9612, - centerLon: -82.9988, - loadRadiusKm: 90, - stationInformationUrl: - 'https://gbfs.cogobikeshare.com/gbfs/2.3/en/station_information.json', - stationStatusUrl: - 'https://gbfs.cogobikeshare.com/gbfs/2.3/en/station_status.json', - provider: 'CoGo', - }, { id: 'chattanooga-bikechatt', city: 'Chattanooga, TN', @@ -246,14 +234,6 @@ const RAW_GBFS_CITY_REGISTRY = [ systemId: 'bcycle_pacersbikeshare', provider: 'Pacers Bikeshare', }), - bcycleEntry({ - id: 'fort-lauderdale-broward', - city: 'Fort Lauderdale, FL', - centerLat: 26.1224, - centerLon: -80.1373, - systemId: 'bcycle_broward', - provider: 'Broward B-cycle', - }), bcycleEntry({ id: 'memphis-bcycle', city: 'Memphis, TN', @@ -268,22 +248,32 @@ const RAW_GBFS_CITY_REGISTRY = [ centerLon: -93.625, systemId: 'bcycle_desmoines', }), - bcycleEntry({ + { + // Tugo moved off BCycle to PBSC; the bcycle_tugo feed is gone (404). id: 'tucson-tugo', city: 'Tucson, AZ', centerLat: 32.2226, centerLon: -110.9747, - systemId: 'bcycle_tugo', + loadRadiusKm: 100, + stationInformationUrl: + 'https://tucson.publicbikesystem.net/customer/gbfs/v2/en/station_information.json', + stationStatusUrl: + 'https://tucson.publicbikesystem.net/customer/gbfs/v2/en/station_status.json', provider: 'Tugo', - }), - bcycleEntry({ + }, + { + // Trinity Metro moved off BCycle to PBSC; the bcycle_fortworth feed is gone. id: 'fort-worth-trinity', city: 'Fort Worth, TX', centerLat: 32.7555, centerLon: -97.3308, - systemId: 'bcycle_fortworth', + loadRadiusKm: 100, + stationInformationUrl: + 'https://fortworth.publicbikesystem.net/customer/gbfs/v2/en/station_information.json', + stationStatusUrl: + 'https://fortworth.publicbikesystem.net/customer/gbfs/v2/en/station_status.json', provider: 'Trinity Metro', - }), + }, bcycleEntry({ id: 'omaha-heartland', city: 'Omaha, NE', @@ -307,14 +297,20 @@ const RAW_GBFS_CITY_REGISTRY = [ centerLon: -82.394, systemId: 'bcycle_greenville', }), - bcycleEntry({ + { + // Reddy left BCycle for Social Bicycles; the bcycle_reddy feed is gone + // (404). This is the endpoint the MobilityData GBFS registry lists. id: 'buffalo-reddy', city: 'Buffalo, NY', centerLat: 42.8864, centerLon: -78.8784, - systemId: 'bcycle_reddy', + loadRadiusKm: 100, + stationInformationUrl: + 'https://reddybikeshare.socialbicycles.com/opendata/station_information.json', + stationStatusUrl: + 'https://reddybikeshare.socialbicycles.com/opendata/station_status.json', provider: 'Reddy Bikeshare', - }), + }, bcycleEntry({ id: 'las-vegas-rtc-bike-share', city: 'Las Vegas, NV', diff --git a/src/layers/bikeshare/state.js b/src/layers/bikeshare/state.js index 381384147..728c1c124 100644 --- a/src/layers/bikeshare/state.js +++ b/src/layers/bikeshare/state.js @@ -99,9 +99,16 @@ export function createState({ services }) { layerState._loadingOps = 0; - /** Most recent error message string, or null. */ + /** + * @type {Set} City ids whose most recent fetch failed. + * + * Per city rather than one layer-wide flag: feeds are fetched independently, + * so a retired operator must not mark a layer that other cities are still + * populating as failed. The layer reports failure only when these cost it + * everything it had to draw — see `getStats()` in controls.js. + */ - layerState._error = null; + layerState._failedCityIds = new Set(); /** Whether the MAX_TOTAL_POINTS cap warning has already been logged. */ diff --git a/src/layers/bikeshare/viewport.js b/src/layers/bikeshare/viewport.js index e4c03a690..b4e63e1dd 100644 --- a/src/layers/bikeshare/viewport.js +++ b/src/layers/bikeshare/viewport.js @@ -168,11 +168,11 @@ export function createViewport({ state: layerState, services, parts, source }) { return; parts.rendering.applyStatusToPoints(cityId, statusMap); layerState._lastUpdate = Date.now(); - layerState._error = null; + layerState._failedCityIds.delete(cityId); } catch (error) { if (error?.name === 'AbortError') return; console.warn(`[Data:Bikeshare] ${cityId} activate error:`, error); - layerState._error = 'GBFS fetch error'; + layerState._failedCityIds.add(cityId); deactivateCity(cityId); layerState._activeCityIds.delete(cityId); } finally { diff --git a/src/layers/transit/lifecycle.test.mjs b/src/layers/transit/lifecycle.test.mjs index 795bbc654..8a2d39edf 100644 --- a/src/layers/transit/lifecycle.test.mjs +++ b/src/layers/transit/lifecycle.test.mjs @@ -3586,6 +3586,7 @@ test('actual Traffic, Bikeshare and Transit lifecycles retain sensitivity in eve _pointCollection: {}, _overlayHost: { setVisible: noop }, _cityRuntime: new Map(), + _failedCityIds: new Set(), }, services: { sprites: { restoreSpriteOrder: noop }, From a02f729191f01d340eb58e13f60e36ec421beaaa Mon Sep 17 00:00:00 2001 From: Sameh Khamis Date: Thu, 15 Oct 2026 13:08:37 -0700 Subject: [PATCH 6/9] fix(opensky): cool down before reading a 429, and test the six caps A 429 now starts the credit cooldown from its headers and drops the body unread, so a stalled or oversized 429 cannot skip the governor; an oversized snapshot is not retried. A 429 without retry-after now waits the intended 2 minutes (headers.get() returns null, which Number() read as 0 s). Adds oversized-stream tests for OpenSky, adsb.lol, CelesTrak and TomTom that fail on main, names the six reads in the changelog, and corrects the docs on what was already bounded. --- CHANGELOG.md | 9 +- docs/CURRENT-STATE.md | 19 +-- server/providers/aircraft/opensky.js | 52 +++++--- src/tooling/environmentProviders.test.mjs | 46 +++++++ src/tooling/liveProviders.test.mjs | 148 ++++++++++++++++++++++ src/tooling/spaceProviders.test.mjs | 44 +++++++ 6 files changed, 292 insertions(+), 26 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e02267966..ca996ac04 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -147,9 +147,12 @@ ## [Unreleased] -- Bound the remaining upstream body reads (FIRMS, CelesTrak, adsb.lol, - TomTom, OpenSky, adsbdb) with streaming size caps and shared deadlines; the - adsb.lol military poll gains a deadline of its own. +- Bound six upstream body reads (FIRMS CSV, CelesTrak TLE groups, adsb.lol + `/v2/mil`, TomTom flow tiles, OpenSky `states/all` and adsbdb lookups) with + streaming size caps that cancel the body at the cap or the route's deadline; + the adsb.lol military poll gains a deadline of its own. An oversized OpenSky + snapshot is not retried, and an OpenSky 429 starts its cooldown before the + body is read. - Keep later satellites when a TLE catalog entry is missing an orbital line. The parser skips the incomplete set and resumes at the next complete named diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index 59ed6b37a..958148258 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -1198,18 +1198,21 @@ FIRMS CSV, CelesTrak TLE groups, adsb.lol military responses, TomTom flow tiles, OpenSky states/all and adsbdb lookups now read their bodies through the shared streaming readers in `src/sources/httpBody.js`, so a declared or observed body over the cap is refused with `RESPONSE_TOO_LARGE` and cancelled rather than -buffered. `readResponseBytesCapped` accepts the same optional abort signal as -the text reader and cancels a body still streaming when it fires; TomTom flow -tiles use it alongside GTFS-Realtime. Caps are bounds, not budgets, sized well +buffered. TomTom flow tiles use `readResponseBytesCapped` alongside +GTFS-Realtime. Each read takes the route's abort signal, so a body still +streaming when the deadline fires is cancelled. Caps are bounds, not budgets, sized well above measured payloads: FIRMS 64 MiB per satellite source, CelesTrak 16 MiB per group, OpenSky `states/all` 8 MiB, adsb.lol `/v2/mil` 8 MiB, TomTom tiles 8 MiB and adsbdb lookups 1 MiB. Where a route already had a deadline (FIRMS 60 s, CelesTrak 20 s, TomTom 15 s, adsbdb 8 s, and each 10 s OpenSky -`states/all` attempt) that deadline now covers the body read as well as the -headers. The adsb.lol military poll, which had no deadline, now waits at most -12 seconds. A rejected or timed-out body is an upstream miss and follows each -route's existing error and cached-data paths. FIRMS map-key status, the OpenSky token exchange and CCTV -media keep their current handling. +`states/all` attempt) it already bounded the body read, because aborting a +fetch signal also aborts its body; the capped reader takes the same signal. +The adsb.lol military poll, which had no deadline, now waits at most 12 +seconds for headers and body together. An oversized OpenSky snapshot is not +retried, and an OpenSky 429 starts its cooldown from the response headers and +cancels the body unread. A rejected or timed-out body is an upstream miss and +follows each route's existing error and cached-data paths. FIRMS map-key +status, the OpenSky token exchange and CCTV media keep their current handling. ## Remaining local service modules diff --git a/server/providers/aircraft/opensky.js b/server/providers/aircraft/opensky.js index 5c88ac832..752b3cf64 100644 --- a/server/providers/aircraft/opensky.js +++ b/server/providers/aircraft/opensky.js @@ -33,10 +33,31 @@ const OPENSKY_STATES_URL = /** states/all?extended=1 measures ~0.8 MB anonymously; bounded, not budgeted. */ const OPENSKY_STATES_MAX_RESPONSE_BYTES = 8 * 1024 * 1024; +/** + * Credit-governor cooldown for a 429: OpenSky's retry-after bounded to + * 30 s … 30 min, or 2 min when the header is absent. + * @param {Response} upstream Rate-limited response. + * @returns {number} Cooldown (ms). + */ +function openSkyRateLimitCooldownMs(upstream) { + // headers.get() gives null when absent, and Number(null) is 0, not NaN. + const header = upstream.headers.get('x-rate-limit-retry-after-seconds'); + const retryAfterSec = + header == null || header.trim() === '' ? NaN : Number(header); + return Math.min( + Math.max( + Number.isFinite(retryAfterSec) ? retryAfterSec * 1000 : 120_000, + 30_000, + ), + 30 * 60_000, + ); +} + /** * Fetch and read the global snapshot, giving each attempt * OPENSKY_ATTEMPT_TIMEOUT_MS and retrying once when an attempt times out or - * the connection fails. A second failure throws, which the proxy answers + * the connection fails. An oversized body is not retried: it would fail again + * and spend more credits. A second failure throws, which the proxy answers * from its stale cache or the regional fallback. * @param {Record} headers Request headers. * @returns {Promise<{upstream: Response, body: string}>} @@ -46,6 +67,17 @@ async function fetchOpenSkyStates(headers) { try { const signal = AbortSignal.timeout(OPENSKY_ATTEMPT_TIMEOUT_MS); const upstream = await fetch(OPENSKY_STATES_URL, { headers, signal }); + if (upstream.status === 429) { + // Start the cooldown from the headers and drop the rejected body + // unread, so a stalled or oversized 429 body cannot skip it. + _openskyCooldownUntil = + Date.now() + openSkyRateLimitCooldownMs(upstream); + void upstream.body?.cancel().catch(() => {}); + return { + upstream, + body: JSON.stringify({ error: 'OpenSky rate limited.' }), + }; + } const body = await readResponseTextCapped( upstream, OPENSKY_STATES_MAX_RESPONSE_BYTES, @@ -53,7 +85,7 @@ async function fetchOpenSkyStates(headers) { ); return { upstream, body }; } catch (error) { - if (attempt >= 2) throw error; + if (attempt >= 2 || error?.code === 'RESPONSE_TOO_LARGE') throw error; console.warn( '[OpenSky] global snapshot attempt failed, retrying:', error?.message || error, @@ -550,19 +582,9 @@ export function openSkyProxy() { } if (upstream.status === 429) { reason = 'rate_limited'; - // Credit governor: honor OpenSky's retry-after (bounded 30 s … 30 min; - // 2 min when the header is absent) — no upstream attempts until then. - const retryAfterSec = Number( - upstream.headers.get('x-rate-limit-retry-after-seconds'), - ); - const cooldownMs = Math.min( - Math.max( - Number.isFinite(retryAfterSec) ? retryAfterSec * 1000 : 120_000, - 30_000, - ), - 30 * 60_000, - ); - _openskyCooldownUntil = now + cooldownMs; + // Credit governor: fetchOpenSkyStates already started the cooldown + // from OpenSky's retry-after — no upstream attempts until then. + const cooldownMs = openSkyRateLimitCooldownMs(upstream); // Serve the last-good body instead of the 429 when we have one — // the layer keeps rendering (STALE-cued) instead of dying. if (_openskyCacheBody && _openskyCacheStatus === 200) { diff --git a/src/tooling/environmentProviders.test.mjs b/src/tooling/environmentProviders.test.mjs index f8f02595c..63795c16d 100644 --- a/src/tooling/environmentProviders.test.mjs +++ b/src/tooling/environmentProviders.test.mjs @@ -53,6 +53,29 @@ function isolate(t, env = {}) { } const json = (res) => JSON.parse(res.body); +/** A response that streams 1 MiB chunks of `text` well past every cap, then ends. */ +function oversizedResponse(text) { + const state = { pulled: 0, cancelled: false }; + const chunk = new TextEncoder().encode( + text.repeat(Math.ceil((1 << 20) / text.length)), + ); + const response = new Response( + new ReadableStream( + { + pull(controller) { + if (++state.pulled > 40) controller.close(); + else controller.enqueue(chunk); + }, + cancel() { + state.cancelled = true; + }, + }, + { highWaterMark: 0 }, + ), + ); + return { response, state }; +} + test('standalone composition mounts every extracted provider exactly once without acquisition', (t) => { t.mock.method(globalThis, 'fetch', () => { throw Error('construction must not fetch'); @@ -168,6 +191,29 @@ test('traffic middleware preserves keyless mode, caching, stale budget fallback assert.equal(calls, 2); }); +test('traffic middleware answers 502 to an oversized tile without caching it', async (t) => { + isolate(t, { TOMTOM_API_KEY: 'fixture-key', TOMTOM_DAILY_TILE_BUDGET: '' }); + const bodies = []; + t.mock.method(globalThis, 'fetch', async () => { + const oversized = oversizedResponse('\x01'); + bodies.push(oversized.state); + return oversized.response; + }); + const request = install(tomtomProxy()); + const first = await request('/flow/8/1/1.pbf'); + assert.equal(first.status, 502); + assert.equal(bodies[0].cancelled, true); + assert.ok(bodies[0].pulled <= 9, 'reading stops at the 8 MiB cap'); + assert.ok( + !fsp.writeFile.mock.calls.some(({ arguments: [file] }) => + String(file).endsWith('.pbf'), + ), + 'no tile reached the disk cache', + ); + assert.equal((await request('/flow/8/1/1.pbf')).status, 502); + assert.equal(bodies.length, 2, 'nothing was cached in memory'); +}); + test('FIRMS retains a large successful source during partial failure and filters stale data at serve time', async (t) => { isolate(t, { FIRMS_MAP_KEY: '' }); let now = Date.UTC(2026, 8, 12, 12); diff --git a/src/tooling/liveProviders.test.mjs b/src/tooling/liveProviders.test.mjs index d7b740668..a60a37cea 100644 --- a/src/tooling/liveProviders.test.mjs +++ b/src/tooling/liveProviders.test.mjs @@ -37,6 +37,30 @@ function install(plugin, preview = false) { }; } +/** A response that streams 1 MiB chunks of `text` well past every cap, then ends. */ +function oversizedResponse(text, init = {}) { + const state = { pulled: 0, cancelled: false }; + const chunk = new TextEncoder().encode( + text.repeat(Math.ceil((1 << 20) / text.length)), + ); + const response = new Response( + new ReadableStream( + { + pull(controller) { + if (++state.pulled > 40) controller.close(); + else controller.enqueue(chunk); + }, + cancel() { + state.cancelled = true; + }, + }, + { highWaterMark: 0 }, + ), + init, + ); + return { response, state }; +} + function environment(t, values) { for (const [key, value] of Object.entries(values)) { const original = process.env[key]; @@ -193,6 +217,130 @@ test('OpenSky global snapshot attempts are time-limited, retried once, then fall assert.equal(stalled.response.headers['x-flight-source'], 'adsb.lol'); }); +const openSkyAnonEnv = { + OPENSKY_CLIENT_ID: undefined, + OPENSKY_CLIENT_SECRET: undefined, + OPENSKY_AUTH_MODE: 'anon', + OPENSKY_USERNAME: undefined, + OPENSKY_PASSWORD: undefined, +}; +const regionalFallback = () => + Response.json({ + now: Date.now() / 1000, + ac: [{ hex: 'abc123', lat: 30, lon: -97, alt_baro: 10000 }], + }); + +test('OpenSky 429 starts the cooldown from headers and cancels the body unread', async (t) => { + environment(t, openSkyAnonEnv); + t.mock.method(console, 'warn', () => {}); + t.mock.method(console, 'error', () => {}); + let states = 0; + let limited; + t.mock.method(globalThis, 'fetch', async (url) => { + if (url.includes('/states/')) { + states++; + limited = oversizedResponse('x', { + status: 429, + headers: { 'x-rate-limit-retry-after-seconds': '600' }, + }); + return limited.response; + } + if (url.includes('/lat/')) return regionalFallback(); + throw Error(`Unexpected URL: ${url}`); + }); + const fresh = await import( + `../../server/providers/aircraft/opensky.js?ratelimit=${Math.random()}` + ); + const request = install(fresh.openSkyProxy()); + const first = await request('/api/flights', '?lat=30&lon=-97'); + assert.equal(first.statusCode, 200); + assert.equal(first.headers['x-flight-source'], 'adsb.lol'); + assert.equal(states, 1, 'a rate-limited snapshot is not retried'); + assert.equal(limited.state.cancelled, true); + assert.ok(limited.state.pulled <= 1, 'the 429 body is not read'); + const cooling = await request('/api/flights', '?lat=30&lon=-97'); + assert.equal(cooling.statusCode, 200); + assert.equal( + cooling.headers['x-opensky-auth-reason'], + 'opensky_cooldown_regional_fallback', + ); + assert.equal(states, 1, 'the cooldown holds off the next upstream attempt'); +}); + +test('OpenSky 429 without retry-after cools down for the 2 minute default', async (t) => { + environment(t, openSkyAnonEnv); + t.mock.method(console, 'warn', () => {}); + t.mock.method(console, 'error', () => {}); + const start = Date.now(); + let now = start; + t.mock.method(Date, 'now', () => now); + let states = 0; + t.mock.method(globalThis, 'fetch', async (url) => { + if (url.includes('/states/')) { + states++; + return new Response('{}', { status: 429 }); + } + if (url.includes('/lat/')) return regionalFallback(); + throw Error(`Unexpected URL: ${url}`); + }); + const fresh = await import( + `../../server/providers/aircraft/opensky.js?ratelimit-default=${Math.random()}` + ); + const request = install(fresh.openSkyProxy()); + await request('/api/flights', '?lat=30&lon=-97'); + assert.equal(states, 1); + // Past the 30 s floor but inside the 2 minute default. + now = start + 90_000; + await request('/api/flights', '?lat=30&lon=-97'); + assert.equal(states, 1, 'a missing header is not read as a zero wait'); +}); + +test('OpenSky does not retry a snapshot body over the cap', async (t) => { + environment(t, openSkyAnonEnv); + t.mock.method(console, 'warn', () => {}); + t.mock.method(console, 'error', () => {}); + const bodies = []; + t.mock.method(globalThis, 'fetch', async (url) => { + if (url.includes('/states/')) { + const oversized = oversizedResponse(' '); + bodies.push(oversized.state); + return oversized.response; + } + if (url.includes('/lat/')) return regionalFallback(); + throw Error(`Unexpected URL: ${url}`); + }); + const fresh = await import( + `../../server/providers/aircraft/opensky.js?oversize=${Math.random()}` + ); + const response = await install(fresh.openSkyProxy())( + '/api/flights', + '?lat=30&lon=-97', + ); + assert.equal(response.statusCode, 200); + assert.equal(response.headers['x-flight-source'], 'adsb.lol'); + assert.equal(bodies.length, 1, 'an oversized body is not fetched twice'); + assert.equal(bodies[0].cancelled, true); + assert.ok(bodies[0].pulled <= 9, 'reading stops at the 8 MiB cap'); +}); + +test('military aircraft route answers 502 to an oversized body without caching it', async (t) => { + t.mock.method(console, 'error', () => {}); + const bodies = []; + t.mock.method(globalThis, 'fetch', async () => { + const oversized = oversizedResponse('{"ac":[]}'); + bodies.push(oversized.state); + return oversized.response; + }); + const request = install(providers.adsbLolProxy()); + const first = await request('/api/military'); + assert.equal(first.statusCode, 502); + assert.equal(bodies[0].cancelled, true); + assert.ok(bodies[0].pulled <= 9, 'reading stops at the 8 MiB cap'); + const again = await request('/api/military'); + assert.equal(again.statusCode, 502, 'nothing was cached'); + assert.equal(bodies.length, 2); +}); + test('military aircraft route preserves fresh cache and stale response after upstream failure', async (t) => { let now = Date.now(); let calls = 0; diff --git a/src/tooling/spaceProviders.test.mjs b/src/tooling/spaceProviders.test.mjs index 6ccd8d57c..443ca5b96 100644 --- a/src/tooling/spaceProviders.test.mjs +++ b/src/tooling/spaceProviders.test.mjs @@ -47,6 +47,29 @@ function isolateDisk(t) { t.mock.method(fsp, 'writeFile', async () => {}); } +/** A response that streams 1 MiB chunks of `text` well past every cap, then ends. */ +function oversizedResponse(text) { + const state = { pulled: 0, cancelled: false }; + const chunk = new TextEncoder().encode( + text.repeat(Math.ceil((1 << 20) / text.length)), + ); + const response = new Response( + new ReadableStream( + { + pull(controller) { + if (++state.pulled > 40) controller.close(); + else controller.enqueue(chunk); + }, + cancel() { + state.cancelled = true; + }, + }, + { highWaterMark: 0 }, + ), + ); + return { response, state }; +} + test('portable requests keep fixed origins, encode group data and preserve the 30-day UTC window', () => { const tle = celestrakTleUrl('stations&FORMAT=json'); assert.equal(tle.origin, 'https://celestrak.org'); @@ -120,6 +143,27 @@ test('exported CelesTrak plugin coalesces refreshes, retains stale TLEs, and rea assert.equal(disk.body, tle); }); +test('CelesTrak answers 502 to an oversized TLE body without caching it', async (t) => { + isolateDisk(t); + t.mock.method(console, 'warn', () => {}); + const bodies = []; + t.mock.method(globalThis, 'fetch', async () => { + const oversized = oversizedResponse( + 'ISS\n1 25544U fixture\n2 25544 fixture\n', + ); + bodies.push(oversized.state); + return oversized.response; + }); + const request = install(celestrakProxy()); + const first = await request('/api/celestrak', '/stations'); + assert.equal(first.status, 502); + assert.equal(bodies[0].cancelled, true); + assert.ok(bodies[0].pulled <= 17, 'reading stops at the 16 MiB cap'); + assert.equal(fsp.writeFile.mock.callCount(), 0); + assert.equal((await request('/api/celestrak', '/stations')).status, 502); + assert.equal(bodies.length, 2, 'nothing was cached in memory'); +}); + for (const preview of [false, true]) test(`exported launch plugin preserves optional server auth and cache in ${preview ? 'preview' : 'development'}`, async (t) => { isolateDisk(t); From 51e32a30f20ccbb5e7c7ec7878ac444fcd009dd1 Mon Sep 17 00:00:00 2001 From: Sameh Khamis Date: Thu, 15 Oct 2026 13:08:45 -0700 Subject: [PATCH 7/9] fix(bikeshare): scope city failures to range and report partial outages A failure now ends when its city leaves range or the camera rises above the activation gate, so an empty map no longer reads LOAD FAILED. Late activation and status rejections are ignored like late successes. Partial failure reads DEGRADED, every in-range city failing over cached points reads STALE, and freshness advances only on a refresh that succeeded. Behavioural tests drive the real proximity, update and enable paths. Docs say no licence is published for Toronto and list Social Bicycles and PBSC. The merge also restored the #61 GBFS v3 changelog entry that an earlier merge on main dropped. --- CHANGELOG.md | 11 +- DATA_SOURCES.md | 4 +- docs/CURRENT-STATE.md | 2 +- src/layers/bikeshare/controls.js | 21 +- .../bikeshare/failureReporting.test.mjs | 296 ++++++++++++++++++ src/layers/bikeshare/ingestion.js | 19 +- src/layers/bikeshare/registry.test.mjs | 2 +- src/layers/bikeshare/state.js | 5 +- src/layers/bikeshare/viewport.js | 15 + 9 files changed, 351 insertions(+), 24 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 31f232443..c851c88d4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,14 +5,19 @@ flag that only a successful *activation* cleared — and an already-loaded city never activates again. A dead feed re-entering camera range therefore latched LOAD FAILED permanently over a map still drawing every other city's stations. - Failure is now tracked per city and reported only when it leaves the layer - with nothing to draw; a city that recovers clears its own failure. + Failure is now tracked per city: a city that fails beside others still + drawing marks the layer DEGRADED, polls that all fail over previously drawn + points read STALE without advancing freshness, and LOAD FAILED is reported + only when nothing is left to draw. A city that recovers clears its own + failure, a failure is dropped once its city leaves range or the camera rises + above the activation altitude, and late results from superseded requests are + ignored. Repointed the three feeds whose operators had moved: Reddy Bikeshare (Buffalo) to Social Bicycles, Tugo (Tucson) and Trinity Metro (Fort Worth) to PBSC. Dropped CoGo (Columbus) and Broward B-cycle (Fort Lauderdale), whose systems have wound down — CoGo's feed reports no stations and last moved a bike in - March 2025. All 31 remaining systems were verified live. + March 2025. The three repointed feeds were verified live. - Add Bike Share Toronto (1,069 stations) to the bikeshare registry. The system publishes the same PBSC GBFS v2 feeds as the Austin, Honolulu and Chattanooga diff --git a/DATA_SOURCES.md b/DATA_SOURCES.md index 157bab7b8..e983a972d 100644 --- a/DATA_SOURCES.md +++ b/DATA_SOURCES.md @@ -60,8 +60,8 @@ How to read this: | **Open Calgary** (`data.calgary.ca`) | CCTV camera catalog + frames, Calgary | [Open Government Licence – City of Calgary](https://data.calgary.ca/stories/s/Open-Calgary-Terms-of-Use/u45n-7awa) — attribution REQUIRED | "Contains information licensed under the Open Government Licence – City of Calgary" | | **Statens vegvesen** (catalog `ogckart-sn1.atlas.vegvesen.no`, frames `kamera.atlas.vegvesen.no`, HLS `kamera.vegvesen.no`) | CCTV camera catalog + frames + live HLS video, Norway road cameras | [NLOD 2.0](https://data.norge.no/nlod/en/2.0) — attribution REQUIRED | "Contains data under the Norwegian licence for Open Government data (NLOD) distributed by Statens vegvesen" | | **NYC DOT Traffic Management Center** (`webcams.nyctmc.org`) | CCTV camera catalog + frames, New York City five boroughs | NYC DOT requires developers to sign a [data-sharing agreement](https://www.nyc.gov/html/dot/html/about/datafeeds.shtml) for the camera feed. **Off by default**; enable only under your own agreement | "NYC DOT Traffic Management Center" + NYC DOT's required application disclaimer (in the Data attribution popover) | -| **GBFS (Lyft / BCycle)** | Bikeshare availability | Per-feed (attribution-only) | Credit the operator (e.g. Austin BCycle) + its `license_url` | -| **Bike Share Toronto** (`toronto.publicbikesystem.net`) | Bikeshare station availability, Toronto | Operator GBFS feed. The City's open-data package declares no licence and the feed publishes no `license_url`, so it is treated as attribution-only like the other GBFS operators. | "Bike Share Toronto (Toronto Parking Authority)" | +| **GBFS (Lyft / BCycle / Social Bicycles / PBSC)** | Bikeshare availability | Per-feed (attribution-only) | Credit the operator (e.g. Austin BCycle) + its `license_url` | +| **Bike Share Toronto** (`toronto.publicbikesystem.net`) | Bikeshare station availability, Toronto | Operator GBFS feed. No licence is published: the City's open-data package declares none and the feed has no `license_url`. Attribution is given to the operator. | "Bike Share Toronto (Toronto Parking Authority)" | | **Oslo / Bergen / Trondheim Bysykkel** (Urban Sharing, `gbfs.urbansharing.com`) | Bikeshare availability, Oslo, Bergen and Trondheim city bikes. Keyless GBFS; the proxy sends the `Client-Identifier` header the operators ask for | [NLOD 2.0](https://data.norge.no/nlod/en/2.0) — attribution REQUIRED ([Oslo](https://oslobysykkel.no/en/open-data/realtime), [Bergen](https://bergenbysykkel.no/en/apne-data/sanntid), [Trondheim](https://trondheimbysykkel.no/en/open-data/realtime)) | "Contains data under the Norwegian licence for Open Government data (NLOD) made available by Oslo Bysykkel, Bergen Bysykkel and Trondheim Bysykkel" | | **Kolumbus / Farte Bysykkel** (Entur Mobility, `api.entur.io/mobility/v2/gbfs`) | Bikeshare availability, Kolumbus Bysykkel (Stavanger/Nord-Jæren) and Farte Bysykkel (Skien/Porsgrunn). Keyless GBFS; the proxy sends the `ET-Client-Name` header Entur requires and is limited to Entur's GBFS paths | [NLOD](https://data.norge.no/nlod/en/2.0) per the [Entur Mobility API docs](https://developer.entur.org/pages-mobility-docs-mobility-v2) — attribution REQUIRED | "Contains data under the Norwegian licence for Open Government data (NLOD) made available by Entur (Kolumbus Bysykkel, Farte Bysykkel)" | | **JCDecaux Cyclocity** (`api.cyclocity.fr`) | Bikeshare availability, Bysykkel Lillestrøm. Keyless GBFS; the proxy is limited to JCDecaux contract paths | [Licence Ouverte / Etalab](https://developer.jcdecaux.com/files/Open-Licence-fr.pdf) (the feed's `license_url`) — attribution REQUIRED | "Bysykkel Lillestrøm: JCDecaux, Licence Ouverte" | diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index 10a11f234..0cc9c8ae5 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -3172,7 +3172,7 @@ its criteria cannot be silently ignored. | CCTV | Austin + Caltrans (CA) + TfL London + Ontario 511 + Fintraffic (FI) + DriveBC (BC) + TxDOT (TX) + Estonia (Tallinn, Tarktee) + Live Traffic NSW + Open Calgary Open Data + Statens vegvesen (NO, stills + live HLS) + NYC DOT TMC (opt-in) + Street View fallback | `src/data/cctv.js` | `/api/cctv` | 10s (active) | | Radio | Radio Browser (public-domain station directory) | `src/data/radio.js` | `/api/radio/stations`, `/api/radio/click/:uuid` | 45 min directory refresh | | Transit 🚌 | Operator GTFS-Realtime VehiclePositions (7 keyless regions, `src/data/transitFeeds.js`) | `src/layers/transit/` via `src/app/layers/transit.js` | `/api/transit` | 15s (poll + delayed playback) | -| Bikeshare 🚲 | GBFS (Lyft + BCycle + Bike Share Toronto + Urban Sharing, Entur, JCDecaux NO) | `src/data/bikeshare.js` | `/api/gbfs` | 60s | +| Bikeshare 🚲 | GBFS (Lyft + BCycle + Social Bicycles + PBSC incl. Bike Share Toronto + Urban Sharing, Entur, JCDecaux NO) | `src/data/bikeshare.js` | `/api/gbfs` | 60s | | Directions 🧭 | OSRM on FOSSGIS servers (OpenStreetMap) | `src/data/directions.js` | `/api/route` (`steps=1`) | on placement / mode change | | Datacenters ▣ | OSM extract (bundled) | `src/data/localLayers.js` | — | static | | Dams ▰ | OpenInfraMap/OSM extract (bundled) | `src/data/localLayers.js` | — | static | diff --git a/src/layers/bikeshare/controls.js b/src/layers/bikeshare/controls.js index 9e9389c58..58c5f003a 100644 --- a/src/layers/bikeshare/controls.js +++ b/src/layers/bikeshare/controls.js @@ -56,7 +56,7 @@ export function createControls({ state: layerState, services, parts, source }) { /** * Return current layer statistics for the UI status display. - * @returns {{ count: number, lastUpdate: number|null, loading: boolean, loadingLabel?: string, error?: string }} + * @returns {{ count: number, lastUpdate: number|null, loading: boolean, loadingLabel?: string, error?: string, degraded?: boolean, stale?: boolean }} */ getStats() { const stats = { @@ -71,12 +71,19 @@ export function createControls({ state: layerState, services, parts, source }) { : 'scanning nearby systems...'; } // A failed city is only the layer's failure when it left nothing on the - // map. While any city still renders, the layer is working and says so. - if ( - layerState._failedCityIds.size > 0 && - layerState._stationRenderMap.size === 0 - ) { - stats.error = 'GBFS fetch error'; + // map. While other cities still render, the partial failure reads + // DEGRADED; when every in-range city failed and only previously drawn + // points remain, they read STALE. + if (layerState._failedCityIds.size > 0) { + if (layerState._stationRenderMap.size === 0) { + stats.error = 'GBFS fetch error'; + } else { + stats.degraded = true; + const allFailed = Array.from(layerState._activeCityIds).every( + (cityId) => layerState._failedCityIds.has(cityId), + ); + if (allFailed) stats.stale = true; + } } return stats; }, diff --git a/src/layers/bikeshare/failureReporting.test.mjs b/src/layers/bikeshare/failureReporting.test.mjs index 63034078b..89a87bdad 100644 --- a/src/layers/bikeshare/failureReporting.test.mjs +++ b/src/layers/bikeshare/failureReporting.test.mjs @@ -78,3 +78,299 @@ test('a recovered city clears its own failure', () => { 'the failure outlived the city that caused it', ); }); + +// The cases above pin how getStats() reads failure state. The cases below +// drive the real proximity check, status poll and lifecycle against a fake +// GBFS source, so the failure state they read is the state the layer builds. + +import * as Cesium from 'cesium'; +import { CITY_BY_ID } from './registry.js'; +import { createIngestion } from './ingestion.js'; +import { createModel } from './model.js'; +import { createViewport } from './viewport.js'; +import { createRendering } from './rendering.js'; +import { createLifecycle } from './lifecycle.js'; + +const TORONTO = 'toronto-bike-share'; +const BUFFALO = 'buffalo-reddy'; +// Within range of both Toronto and Buffalo. +const BETWEEN = { lat: 43.25, lon: -79.1 }; +// Within range of Toronto only. +const TORONTO_ONLY = { lat: 43.7, lon: -79.6 }; +// Within range of Buffalo only. +const BUFFALO_ONLY = { lat: 42.5, lon: -78.0 }; +const LOW_M = 10_000; + +// disable() detaches a keydown listener; node has no document. +globalThis.document ??= { + addEventListener() {}, + removeEventListener() {}, +}; + +/** A GBFS source whose per-city answers each test can change. */ +function makeSource() { + const cityOf = (url) => { + for (const [id, city] of CITY_BY_ID) { + if (url === city.stationInformationUrl) return { id, kind: 'info' }; + if (url === city.stationStatusUrl) return { id, kind: 'status' }; + } + throw new Error(`unexpected GBFS url ${url}`); + }; + const source = { + dead: new Set(), + deadStatus: new Set(), + /** Optional per-call override: (cityId, kind) => Promise | undefined. */ + hook: null, + async getStations(url, { signal } = {}) { + const { id, kind } = cityOf(url); + const hooked = source.hook?.(id, kind); + if (hooked) return hooked; + await new Promise((resolve) => setTimeout(resolve, 1)); + signal?.throwIfAborted(); + if (source.dead.has(id)) throw new Error('GBFS HTTP 404'); + if (kind === 'status' && source.deadStatus.has(id)) + throw new Error('GBFS HTTP 503'); + const city = CITY_BY_ID.get(id); + if (kind === 'info') { + return { + data: { + stations: [ + { + station_id: `${id}-1`, + name: `${id} dock`, + lat: city.centerLat, + lon: city.centerLon, + capacity: 10, + }, + ], + }, + }; + } + return { + data: { + stations: [ + { + station_id: `${id}-1`, + num_bikes_available: 3, + num_docks_available: 7, + is_installed: true, + is_renting: true, + is_returning: true, + }, + ], + }, + }; + }, + }; + return source; +} + +/** Assemble the layer from its real parts around a camera the test moves. */ +function makeLiveLayer() { + const source = makeSource(); + const services = { + render: { governorRequestRender() {} }, + overlays: { + setOverlayEntries() {}, + setOverlaySourceVisible() {}, + clearOverlaySource() {}, + }, + sprites: { registerSpriteCollection() {}, restoreSpriteOrder() {} }, + picking: { registerPickOwner() {}, unregisterPickOwner() {} }, + }; + const state = createState({ services }); + const parts = {}; + const context = { state, services, parts, source }; + parts.ingestion = createIngestion(context); + parts.model = createModel(context); + parts.viewport = createViewport(context); + parts.rendering = createRendering(context); + // Selection owns a DOM click handler; nothing here selects a station. + parts.selection = { + _installClickHandler() {}, + _clearSelection() {}, + _onKeyDown() {}, + }; + parts.queries = { collectDetectableStations: () => [] }; + parts.lifecycle = createLifecycle(context); + const { methods: controls } = createControls(context); + + const camera = { ...BETWEEN, height: LOW_M }; + const viewer = { + camera: { + get positionCartographic() { + return Cesium.Cartographic.fromDegrees( + camera.lon, + camera.lat, + camera.height, + ); + }, + computeViewRectangle: () => undefined, + changed: { addEventListener() {}, removeEventListener() {} }, + }, + scene: { globe: {}, primitives: { add() {}, remove() {} } }, + }; + parts.lifecycle.methods.init(viewer); + + return { + state, + source, + stats: () => controls.getStats(), + moveTo(place, height = LOW_M) { + Object.assign(camera, place, { height }); + return parts.viewport.runProximityCheck(); + }, + async enable() { + parts.lifecycle.methods.enable(viewer); + await settle(); + }, + disable: () => parts.lifecycle.methods.disable(viewer), + poll: () => parts.ingestion.methods.update(), + }; +} + +/** Let fire-and-forget activation started by enable() finish. */ +async function settle() { + for (let i = 0; i < 5; i += 1) + await new Promise((resolve) => setTimeout(resolve, 2)); +} + +function deferred() { + let resolve, reject; + const promise = new Promise((res, rej) => { + resolve = res; + reject = rej; + }); + return { promise, resolve, reject }; +} + +test('a city that fails beside one that renders reads DEGRADED, not healthy', async () => { + const layer = makeLiveLayer(); + layer.source.dead.add(BUFFALO); + await layer.enable(); + + const stats = layer.stats(); + assert.equal(stats.count, 1, 'Toronto should still draw'); + assert.equal(stats.error, undefined); + assert.equal(stats.degraded, true, 'the Buffalo failure was hidden'); + assert.equal(stats.stale, undefined); +}); + +test('a failed city that comes back clears its own failure', async () => { + const layer = makeLiveLayer(); + layer.source.dead.add(BUFFALO); + await layer.enable(); + assert.equal(layer.stats().degraded, true); + + layer.source.dead.clear(); + await layer.moveTo(BETWEEN); + const stats = layer.stats(); + assert.equal(stats.count, 2); + assert.equal(stats.degraded, undefined); + assert.equal(stats.error, undefined); +}); + +test('a status poll that recovers clears the failure the last poll set', async () => { + const layer = makeLiveLayer(); + await layer.moveTo(TORONTO_ONLY); + await layer.enable(); + layer.source.deadStatus.add(TORONTO); + await layer.poll(); + assert.equal(layer.stats().degraded, true); + + layer.source.deadStatus.clear(); + await layer.poll(); + assert.equal(layer.stats().degraded, undefined); + assert.equal(layer.stats().stale, undefined); +}); + +test('a failure does not outlive its city leaving range', async () => { + const layer = makeLiveLayer(); + layer.source.dead.add(BUFFALO); + await layer.enable(); + await layer.moveTo(BUFFALO_ONLY); + assert.equal(layer.stats().error, 'GBFS fetch error'); + + // Low over the open Atlantic: nothing in range is empty, not broken. + await layer.moveTo({ lat: 30, lon: -40 }); + const stats = layer.stats(); + assert.equal(stats.count, 0); + assert.equal(stats.error, undefined, 'LOAD FAILED over empty ocean'); + assert.equal(stats.degraded, undefined); +}); + +test('zooming out above the altitude gate drops a city failure', async () => { + const layer = makeLiveLayer(); + layer.source.dead.add(BUFFALO); + await layer.enable(); + await layer.moveTo(BUFFALO_ONLY); + assert.equal(layer.stats().error, 'GBFS fetch error'); + + await layer.moveTo(BUFFALO_ONLY, 200_000); + const stats = layer.stats(); + assert.equal(stats.count, 0); + assert.equal(stats.error, undefined, 'LOAD FAILED above the gate'); +}); + +test('a late rejection from before a disable/re-enable is ignored', async () => { + const layer = makeLiveLayer(); + await layer.moveTo(BUFFALO_ONLY); + const first = deferred(); + // The first info request hangs and ignores its abort signal. + layer.source.hook = (id, kind) => { + if (id !== BUFFALO || kind !== 'info') return undefined; + layer.source.hook = null; + return first.promise; + }; + await layer.enable(); + layer.disable(); + await layer.enable(); + assert.equal(layer.stats().count, 1, 're-enable should draw Buffalo'); + + first.reject(new Error('GBFS HTTP 500')); + await settle(); + const stats = layer.stats(); + assert.equal(stats.count, 1, 'a stale rejection tore down live points'); + assert.equal(stats.error, undefined); + assert.equal(stats.degraded, undefined); +}); + +test('a late status poll rejection from before a disable/re-enable is ignored', async () => { + const layer = makeLiveLayer(); + await layer.moveTo(TORONTO_ONLY); + await layer.enable(); + const first = deferred(); + layer.source.hook = (id, kind) => { + if (id !== TORONTO || kind !== 'status') return undefined; + layer.source.hook = null; + return first.promise; + }; + const poll = layer.poll(); + layer.disable(); + await layer.enable(); + + first.reject(new Error('GBFS HTTP 500')); + await poll; + const stats = layer.stats(); + assert.equal(stats.count, 1); + assert.equal(stats.degraded, undefined); + assert.equal(stats.error, undefined); +}); + +test('every status poll failing over cached points reads STALE, not fresh', async () => { + const layer = makeLiveLayer(); + await layer.moveTo(TORONTO_ONLY); + await layer.enable(); + const loadedAt = layer.stats().lastUpdate; + assert.ok(loadedAt, 'activation should record freshness'); + + layer.source.deadStatus.add(TORONTO); + await new Promise((resolve) => setTimeout(resolve, 5)); + await layer.poll(); + const stats = layer.stats(); + assert.equal(stats.count, 1, 'the cached points stay drawn'); + assert.equal(stats.error, undefined); + assert.equal(stats.degraded, true); + assert.equal(stats.stale, true); + assert.equal(stats.lastUpdate, loadedAt, 'a failed poll advanced freshness'); +}); diff --git a/src/layers/bikeshare/ingestion.js b/src/layers/bikeshare/ingestion.js index cda677766..f2127e53e 100644 --- a/src/layers/bikeshare/ingestion.js +++ b/src/layers/bikeshare/ingestion.js @@ -171,20 +171,21 @@ export function createIngestion({ const generation = layerState._proximityGeneration; const cityIds = Array.from(layerState._activeCityIds); + const isCurrent = (cityId) => + layerState._enabled && + layerState._activeCityIds.has(cityId) && + generation === layerState._proximityGeneration; + let refreshed = false; await Promise.all( cityIds.map(async (cityId) => { try { const statusMap = await loadCityStationStatus(cityId, generation); - if ( - !layerState._enabled || - !layerState._activeCityIds.has(cityId) || - generation !== layerState._proximityGeneration - ) - return; + if (!isCurrent(cityId)) return; parts.rendering.applyStatusToPoints(cityId, statusMap); layerState._failedCityIds.delete(cityId); + refreshed = true; } catch (error) { - if (error?.name === 'AbortError') return; + if (error?.name === 'AbortError' || !isCurrent(cityId)) return; console.warn( `[Data:Bikeshare] ${cityId} status update error:`, error, @@ -195,7 +196,9 @@ export function createIngestion({ ); layerState._count = layerState._stationRenderMap.size; - if (layerState._count > 0) layerState._lastUpdate = Date.now(); + // Freshness advances only on a poll that actually refreshed a city. + if (refreshed && layerState._count > 0) + layerState._lastUpdate = Date.now(); }, }; diff --git a/src/layers/bikeshare/registry.test.mjs b/src/layers/bikeshare/registry.test.mjs index 30e708957..74a103079 100644 --- a/src/layers/bikeshare/registry.test.mjs +++ b/src/layers/bikeshare/registry.test.mjs @@ -23,7 +23,7 @@ test('every registered city is reachable through the GBFS proxy allowlist', () = `${city.id}: host ${url.hostname} is not allowlisted`, ); assert.equal( - isAllowedGbfsPath(url.pathname), + isAllowedGbfsPath(url.pathname, url.hostname), true, `${city.id}: path ${url.pathname} is not allowlisted`, ); diff --git a/src/layers/bikeshare/state.js b/src/layers/bikeshare/state.js index 728c1c124..3617914bf 100644 --- a/src/layers/bikeshare/state.js +++ b/src/layers/bikeshare/state.js @@ -104,8 +104,9 @@ export function createState({ services }) { * * Per city rather than one layer-wide flag: feeds are fetched independently, * so a retired operator must not mark a layer that other cities are still - * populating as failed. The layer reports failure only when these cost it - * everything it had to draw — see `getStats()` in controls.js. + * populating as failed. Entries are dropped when their city leaves range. + * The layer reports failure only when these cost it everything it had to + * draw, and degraded otherwise — see `getStats()` in controls.js. */ layerState._failedCityIds = new Set(); diff --git a/src/layers/bikeshare/viewport.js b/src/layers/bikeshare/viewport.js index b4e63e1dd..25a4df7d0 100644 --- a/src/layers/bikeshare/viewport.js +++ b/src/layers/bikeshare/viewport.js @@ -122,6 +122,9 @@ export function createViewport({ state: layerState, services, parts, source }) { const cityIds = Array.from(layerState._activeCityIds); for (const cityId of cityIds) deactivateCity(cityId); layerState._activeCityIds.clear(); + // A failure belongs to a city in range; none are once everything is off. + layerState._failedCityIds.clear(); + layerState._count = 0; } /** @@ -171,6 +174,14 @@ export function createViewport({ state: layerState, services, parts, source }) { layerState._failedCityIds.delete(cityId); } catch (error) { if (error?.name === 'AbortError') return; + // A rejection from superseded work must not mark (or tear down) a city + // that a newer proximity check or re-enable now owns. + if ( + !layerState._enabled || + !layerState._activeCityIds.has(cityId) || + generation !== layerState._proximityGeneration + ) + return; console.warn(`[Data:Bikeshare] ${cityId} activate error:`, error); layerState._failedCityIds.add(cityId); deactivateCity(cityId); @@ -208,6 +219,10 @@ export function createViewport({ state: layerState, services, parts, source }) { deactivateCity(cityId); } } + // A failure never outlives its city's range. + for (const cityId of layerState._failedCityIds) { + if (!nextActive.has(cityId)) layerState._failedCityIds.delete(cityId); + } layerState._activeCityIds = nextActive; if (layerState._activeCityIds.size === 0) { From 129174c3ab7ad61e88cbabee67ce6d1d5dc6c810 Mon Sep 17 00:00:00 2001 From: Sameh Khamis Date: Thu, 15 Oct 2026 13:13:17 -0700 Subject: [PATCH 8/9] fix(air-quality): show the Weather card, add voice support and bound the feed The card never appeared: the Weather panel only passed five fixed ids and only rendered lists and categorical legends for cyclones. Air quality now reaches the panel, the legend style comes from the layer's descriptor, and any layer with list items gets its list. Adds the voice manifest entry main now requires, reads ECCC through the shared capped JSON reader with a 15 s deadline and the row limit enforced, reports stale when every reading was too old, adds source tests, links the ECCC licence and moves the changelog entry into Unreleased. --- CHANGELOG.md | 26 +-- scripts/package-boundaries.json | 6 +- src/data/dataCredits.js | 2 +- src/firstRunExperience.test.mjs | 5 +- src/layers/airQuality/index.js | 50 ++---- src/layers/airQuality/model.js | 7 +- src/layers/airQuality/model.test.mjs | 19 +++ src/layers/airQuality/ownership.test.mjs | 95 +++-------- src/layers/airQuality/source.js | 46 ++++-- src/layers/airQuality/source.test.mjs | 197 +++++++++++++++++++++++ src/layers/cyclones/index.js | 1 + src/ui/layerPanel.js | 1 + src/ui/layerPanel.test.mjs | 28 ++++ src/ui/weatherPanel.js | 16 +- src/ui/weatherPanel.test.mjs | 68 +++++++- src/voice/actionSchemas.test.mjs | 3 +- src/voice/layerManifest.js | 18 +++ 17 files changed, 440 insertions(+), 148 deletions(-) create mode 100644 src/layers/airQuality/source.test.mjs diff --git a/CHANGELOG.md b/CHANGELOG.md index f905cb369..88657ed10 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -147,6 +147,19 @@ ## [Unreleased] +- Add a keyless Air quality layer to the Weather group, with Environment and + Climate Change Canada's AQHI as the first registered provider. Readings carry + `{ provider, scale, value, band }` so another network can be added without + inventing a shared numeric meaning across indices that do not share one; the + row and readout both state that coverage is Canada only. The WEATHER card + shows the band legend and the highest readings currently in view. AQHI is + reported as an integer from 1 with an open-ended "10+", so readings round to + that published form rather than the decimal precision the API returns. + Readings older than six hours are dropped, and the layer reports `stale` + through `getStats()` (also when every observation was dropped) so feed + provenance cannot describe an aged feed as live. Voice can switch it on and + query readings by value, band, station and observation time. + - Satellite clouds keep Clouds only / Full as quick endpoints and add a continuous cloud-filter slider: 0% is Full, 100% is the existing brightness key, and intermediate values blend the keyed alpha. The slider commits on release to @@ -1041,19 +1054,6 @@ Launch payloads with missing records now say PAYLOAD DATA UNAVAILABLE. Missing n This changelog records public product changes. For the authoritative description of current runtime behavior, see [`docs/CURRENT-STATE.md`](docs/CURRENT-STATE.md). -## [Unreleased] - -- Add a keyless Air quality layer to the Weather group, with Environment and - Climate Change Canada's AQHI as the first registered provider. Readings carry - `{ provider, scale, value, band }` so another network can be added without - inventing a shared numeric meaning across indices that do not share one; the - row and readout both state that coverage is Canada only. The WEATHER card - shows the band legend and the highest readings currently in view. AQHI is - reported as an integer from 1 with an open-ended "10+", so readings round to - that published form rather than the decimal precision the API returns. - Readings older than six hours are dropped, and the layer reports `stale` - through `getStats()` so feed provenance cannot describe an aged feed as live. - ### Other changes - Add ECMWF IFS model selection to Wind (#464, thanks @beneduzi), with model-scoped forecast-step caches, cancellation of replaced requests, and separate issue/valid timestamps. diff --git a/scripts/package-boundaries.json b/scripts/package-boundaries.json index 9739b1e66..d0e604336 100644 --- a/scripts/package-boundaries.json +++ b/scripts/package-boundaries.json @@ -3587,7 +3587,8 @@ "modules": [ "src/layers/airQuality/index.js", "src/layers/airQuality/model.js", - "src/layers/airQuality/source.js" + "src/layers/airQuality/source.js", + "src/sources/httpBody.js" ], "external": ["cesium"] }, @@ -3595,7 +3596,8 @@ "exports": ["./layers/air-quality/source"], "modules": [ "src/layers/airQuality/model.js", - "src/layers/airQuality/source.js" + "src/layers/airQuality/source.js", + "src/sources/httpBody.js" ], "external": [] } diff --git a/src/data/dataCredits.js b/src/data/dataCredits.js index 6311f2eec..9c9dee71f 100644 --- a/src/data/dataCredits.js +++ b/src/data/dataCredits.js @@ -174,7 +174,7 @@ export const DATA_CREDITS = [ html: 'Air quality (AQHI): Environment and Climate Change Canada — MSC GeoMet ' + '(eccc-msc.github.io/open-data) ' + - '· ECCC Data Servers End-use Licence', + '· ECCC Data Servers End-use Licence', }, { key: 'overture-military-names', diff --git a/src/firstRunExperience.test.mjs b/src/firstRunExperience.test.mjs index a75ac91fd..50dbb15c9 100644 --- a/src/firstRunExperience.test.mjs +++ b/src/firstRunExperience.test.mjs @@ -674,10 +674,11 @@ test('the voice TOOL SCHEMA matches the pinned contract — the mission mapping // schema, which may not carry top-level anyOf), and Contacts requested-radius // list descriptions; the missions still ride existing tools. // Street Level and GNSS extend the manifest; broadcast caveats describe tracked and analyst aircraft. - assert.equal(block.length, 30511, 'serialized tool schema length drifted'); + // Air quality (ECCC AQHI) extends the manifest. + assert.equal(block.length, 30655, 'serialized tool schema length drifted'); assert.equal( crypto.createHash('sha256').update(block).digest('hex'), - '62d5df9cedf413403e4fd0a7cbe94bb2cd13736c3d8fedc84bcccd63ad253eb6', + '3b1d5e77c10a92f6543a4f9d1ff1870101a0306b65e5b8a6113111c81eda371e', 'the first-run missions must ride EXISTING tools: no schema edit, no cache bust', ); const instructions = fs.readFileSync(new URL('../server/providers/openai/instructions.js', import.meta.url), 'utf8'); diff --git a/src/layers/airQuality/index.js b/src/layers/airQuality/index.js index 43a76f857..804f9a795 100644 --- a/src/layers/airQuality/index.js +++ b/src/layers/airQuality/index.js @@ -224,24 +224,25 @@ export function createAirQualityLayer({ source, overlayHost } = {}) { status: _lastError ? _lastError : _count === 0 - ? 'No readings available' + ? _lastUpdate === null + ? 'No readings available' + : 'No current readings' : `${_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, - }, + legend: AIR_QUALITY_BANDS.map((band) => ({ + color: band.color, + label: band.label, + })), + legendCategorical: true, list: { ariaLabel: 'Highest air-quality readings in view', - items: inView.map((reading) => ({ + items: inView.map((reading, index) => ({ id: reading.id, - label: reading.name, - value: `${reading.scale} ${reading.label}`, + ordinal: index + 1, + lead: `${reading.scale} ${reading.label}`, + text: `${reading.name} · ${reading.risk}`, accent: reading.color, - detail: reading.risk, })), }, }; @@ -251,30 +252,6 @@ export function createAirQualityLayer({ source, overlayHost } = {}) { 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] @@ -300,7 +277,8 @@ export function createAirQualityLayer({ source, overlayHost } = {}) { lastUpdate: _lastUpdate, error: _lastError, observedAt: newestObservation(_readings), - stale: readingsAreStale(_readings), + // Only a completed refresh can be stale; before one there is no feed. + stale: _lastUpdate !== null && readingsAreStale(_readings), }; }, }; diff --git a/src/layers/airQuality/model.js b/src/layers/airQuality/model.js index 93f13404f..1e0eab47e 100644 --- a/src/layers/airQuality/model.js +++ b/src/layers/airQuality/model.js @@ -67,14 +67,17 @@ export function newestObservation(readings) { } /** - * Is a set of readings stale, judged on source observation time? + * 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} 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 + // 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; } diff --git a/src/layers/airQuality/model.test.mjs b/src/layers/airQuality/model.test.mjs index 56130c30e..b4f1a643e 100644 --- a/src/layers/airQuality/model.test.mjs +++ b/src/layers/airQuality/model.test.mjs @@ -5,6 +5,7 @@ import { AIR_QUALITY_PROVIDERS, AIR_QUALITY_UNKNOWN_COLOR, OBSERVATION_MAX_AGE_MS, + STALE_AFTER_MS, airQualityColor, aqhiDisplayValue, bandForValue, @@ -12,6 +13,7 @@ import { mapAnalystRecord, normalizeAqhiObservations, normalizeAqhiStations, + readingsAreStale, readingsInView, selectAirQualityOverlayCohort, valueLabel, @@ -147,6 +149,23 @@ test('a stale reading is dropped rather than shown as current', () => { ); }); +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( diff --git a/src/layers/airQuality/ownership.test.mjs b/src/layers/airQuality/ownership.test.mjs index 5eeaae0e1..78ab8a0bb 100644 --- a/src/layers/airQuality/ownership.test.mjs +++ b/src/layers/airQuality/ownership.test.mjs @@ -1,7 +1,6 @@ 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'; @@ -160,18 +159,19 @@ test('the readout descriptor carries the band legend and coverage', async () => 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); + // 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.label), - ['B', 'A'], + 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/); @@ -203,64 +203,6 @@ test('row-control listeners are notified on refresh and teardown-safe', async () 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 () => { @@ -296,12 +238,19 @@ test('a current reading reports nominal', async () => { 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. +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(); - assert.equal(layer.getStats().stale, false); - assert.equal(layer.getStats().observedAt, null); + 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 () => { diff --git a/src/layers/airQuality/source.js b/src/layers/airQuality/source.js index b5f98894c..8fb4808ec 100644 --- a/src/layers/airQuality/source.js +++ b/src/layers/airQuality/source.js @@ -1,3 +1,4 @@ +import { readResponseJsonCapped } from '../../sources/httpBody.js'; import { normalizeAqhiStations, normalizeAqhiObservations } from './model.js'; const COLLECTIONS_ROOT = 'https://api.weather.gc.ca/collections'; @@ -9,6 +10,8 @@ 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 @@ -26,26 +29,47 @@ const STATION_TTL_MS = 12 * 60 * 60 * 1000; * @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} */ 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; + /** 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 { @@ -54,6 +78,7 @@ export function createEcccAirQualitySource({ 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); @@ -68,6 +93,7 @@ export function createEcccAirQualitySource({ const payload = await getJson( `${OBSERVATIONS_URL}?f=json&latest=true&limit=${OBSERVATION_LIMIT}`, + OBSERVATION_LIMIT, signal, ); const readings = normalizeAqhiObservations(payload, stations, clock); diff --git a/src/layers/airQuality/source.test.mjs b/src/layers/airQuality/source.test.mjs new file mode 100644 index 000000000..b9ffe34a6 --- /dev/null +++ b/src/layers/airQuality/source.test.mjs @@ -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/); +}); diff --git a/src/layers/cyclones/index.js b/src/layers/cyclones/index.js index 7664bc821..da67afe1d 100644 --- a/src/layers/cyclones/index.js +++ b/src/layers/cyclones/index.js @@ -386,6 +386,7 @@ export function createCyclonesLayer({ })), }, chips: [], + legendCategorical: true, legend: storm ? [ { label: 'Advisory center / forecast track', color: '#7fe6ed' }, diff --git a/src/ui/layerPanel.js b/src/ui/layerPanel.js index c2dafbe61..37269fa2b 100644 --- a/src/ui/layerPanel.js +++ b/src/ui/layerPanel.js @@ -382,6 +382,7 @@ export class LayerPanel { 'weather-satellite', 'weather-lightning', 'weather-cyclones', + 'air-quality', ].includes(layer.id), ) .map((layer) => ({ diff --git a/src/ui/layerPanel.test.mjs b/src/ui/layerPanel.test.mjs index 1258cc76a..40c5fc81e 100644 --- a/src/ui/layerPanel.test.mjs +++ b/src/ui/layerPanel.test.mjs @@ -258,3 +258,31 @@ test('an omitted source is visibly unavailable and cannot be toggled on', async 'an active layer can always be switched off', ); }); + +test('the Weather readout receives enabled air-quality alongside weather layers', async () => { + const { LayerPanel } = await import('./layerPanel.js'); + const layers = ['wind', 'air-quality', 'other'].map((id) => ({ + id, + icon: '*', + enabled: true, + })); + layers.push({ id: 'weather-radar', icon: '*', enabled: false }); + const panel = new LayerPanel({ + getLayers: () => layers, + getRowControls: (id) => ({ readout: true, summary: { label: id } }), + }); + let entries = null; + panel._weatherPanel = { + update: (next) => { + entries = next; + }, + }; + panel._refreshWeatherPanel(); + assert.deepEqual( + entries.map(({ id, summary }) => [id, summary.label]), + [ + ['wind', 'wind'], + ['air-quality', 'air-quality'], + ], + ); +}); diff --git a/src/ui/weatherPanel.js b/src/ui/weatherPanel.js index 908423cb7..8eac004f8 100644 --- a/src/ui/weatherPanel.js +++ b/src/ui/weatherPanel.js @@ -10,8 +10,9 @@ const ORDER = [ 'weather-radar', 'weather-satellite', 'weather-lightning', + 'air-quality', ]; -const OBSERVED = new Set(ORDER.slice(2)); +const OBSERVED = new Set(ORDER.slice(2, 5)); const utc = (time) => Number.isFinite(Date.parse(time)) ? `${new Date(time).toISOString().slice(5, 16).replace('T', ' ')} UTC` @@ -158,7 +159,10 @@ export function createWeatherPanel({ ? 'LATEST · newest per product' : historyTime(state.target), }); - const models = active.map(({ id, icon, summary, legend = [], list }) => { + const models = active.map((entry) => { + const { id, icon, summary, legend = [], list } = entry; + // Discrete classes (storm geometry, AQHI bands) list swatches, not a ramp. + const categorical = entry.legendCategorical === true; let detail = summary.detail; const product = state.products.find((item) => item.id === id); if (OBSERVED.has(id)) { @@ -191,17 +195,15 @@ export function createWeatherPanel({ id: 'legend', type: 'legend', legend: { - categorical: id === 'weather-cyclones', + categorical, colors: legend.map(({ color }) => color), labels: legend.map(({ label }) => label), - units: id === 'weather-cyclones' ? '' : summary.units, + units: categorical ? '' : summary.units, zeroIndex: legend.findIndex(({ label }) => label === '0'), }, }; const blocks = [ - ...(id === 'weather-cyclones' && list?.items?.length - ? [{ id: 'storms', type: 'list', list }] - : []), + ...(list?.items?.length ? [{ id: 'list', type: 'list', list }] : []), { id: 'details', type: 'lines', lines }, ...(legend.length ? [legendBlock] : []), ...(summary.settings?.length diff --git a/src/ui/weatherPanel.test.mjs b/src/ui/weatherPanel.test.mjs index 20d463229..7015ee873 100644 --- a/src/ui/weatherPanel.test.mjs +++ b/src/ui/weatherPanel.test.mjs @@ -2,6 +2,7 @@ import test from 'node:test'; import assert from 'node:assert/strict'; import { createWeatherPanel } from './weatherPanel.js'; import { railFixture } from './railTestFixture.mjs'; +import { createAirQualityLayer } from '../layers/airQuality/index.js'; const ticks = ['2026-09-22T01:00:00Z', '2026-09-22T01:05:00Z']; const wind = { id: 'wind', @@ -166,6 +167,7 @@ test('cyclone readout retains advisory, position, intensity, geometry, coverage, }, ], }, + legendCategorical: true, legend: [ { label: 'Advisory center / forecast track', color: '#7fe6ed' }, { label: 'Center-track uncertainty cone', color: '#7fe6ed44' }, @@ -410,7 +412,7 @@ test('cyclones lead, and observed history owns one bordered group with only acti assert.deepEqual(opened(f), ['weather-cyclones']); assert.equal( card(f, cyclone.id).children[1].children[0].dataset.blockId, - 'storms', + 'list', ); view.update([wind, satellite]); assert.equal(group.children[1].textContent, 'Satellite clouds'); @@ -654,3 +656,67 @@ test('a change of open card scrolls its header into view once; refreshes never m assert.deepEqual(intoView, [], 'ancestor scrollers are never moved'); view.destroy(); }); + +test('air-quality readout renders its band legend, Canada coverage and worst-first list', async () => { + const observedMs = Date.now() - 10 * 60_000; + const reading = (stationId, value, band, name) => ({ + id: `eccc:${stationId}`, + provider: 'eccc', + scale: 'AQHI', + value, + band, + stationId, + name, + zone: 'on', + lat: 43.65, + lon: -79.38, + observedMs, + color: '#4cc9f0', + label: String(value), + risk: `${band} risk`, + note: null, + }); + const layer = createAirQualityLayer({ + source: { + getSnapshot: async () => [ + reading('B', 8, 'high', 'Hamilton'), + reading('A', 2, 'low', 'Toronto'), + ], + }, + overlayHost: { setEntries() {}, setVisible() {}, clearSource() {} }, + }); + layer.init({ dataSources: { add() {}, remove() {} } }); + layer.enable(); + await layer.update(); + const f = fixture(); + const view = createWeatherPanel(f); + view.update([{ id: layer.id, icon: layer.icon, ...layer.getRowControls() }]); + const aq = card(f, 'air-quality'); + assert.ok(aq, 'the air-quality card is shown'); + assert.equal(aq.dataset.open, 'true'); + assert.match( + f.find((n) => n.classList?.contains?.('rail-card-badge'), aq).textContent, + /Canada only/, + ); + assert.match(line(f, 'air-quality', 'status').textContent, /2 stations/); + const entries = f.find((n) => n.className === 'rail-card-legend-entries', aq); + assert.equal(entries.hidden, false); + assert.deepEqual( + entries.children.map((entry) => entry.children[1].textContent), + ['Low', 'Moderate', 'High', 'Very High'], + ); + assert.equal( + f.find((n) => n.className === 'rail-card-ramp', aq).hidden, + true, + ); + const list = f.find((n) => n.dataset.blockId === 'list', aq); + assert.equal(list.hidden, false); + assert.deepEqual( + list.children.map((item) => + item.children[0].children.map((part) => part.textContent).join(' '), + ), + ['AQHI 8 Hamilton · high risk', 'AQHI 2 Toronto · low risk'], + ); + view.destroy(); + layer.destroy(); +}); diff --git a/src/voice/actionSchemas.test.mjs b/src/voice/actionSchemas.test.mjs index c57b4a1ab..550d725e4 100644 --- a/src/voice/actionSchemas.test.mjs +++ b/src/voice/actionSchemas.test.mjs @@ -37,7 +37,8 @@ test('the complete Realtime tool payload pins the manifest-generated layer relea // omit top-level anyOf (OpenAI rejects it). Contacts list routing preserves // requested radius and center authority in descriptions only. // Street Level and GNSS extend the manifest; tracked and analyst aircraft describe broadcasts. - '9398815a573ab29ecc3ce527f6e1a2ad883804e7696e0444aebb11f973700d4b', + // Air quality (ECCC AQHI) extends the manifest. + '775ba323384b4c1b62fb8415bac52aecc3fc668e20bedcfa57e426dcd1da3969', ); }); diff --git a/src/voice/layerManifest.js b/src/voice/layerManifest.js index 286b8a5d8..eb0f09909 100644 --- a/src/voice/layerManifest.js +++ b/src/voice/layerManifest.js @@ -250,6 +250,24 @@ const layers = [ context: false, noQuery: 'map overlay; no countable records', }, + { + id: 'air-quality', + aliases: ['air quality', 'aqhi', 'smog', 'air pollution'], + context: false, + query: { + fields: { + value: ['n', 'AQHI'], + band: ['t'], + scale: ['t'], + provider: ['t'], + name: ['t'], + zone: ['t'], + risk: ['t'], + observedMs: ['ms'], + }, + timeField: 'observedMs', + }, + }, { id: 'traffic', aliases: ['street traffic', 'road traffic', 'congestion'], From 2cea4f84044764c125180a0785a144e8856c8de9 Mon Sep 17 00:00:00 2001 From: Meir Michanie Date: Thu, 15 Oct 2026 22:27:19 +0200 Subject: [PATCH 9/9] fix(i18n): Panoramax count line says nearest photo, not its photos Panoramax's coverageLabel action is 'click a line for its nearest photo' (like KartaView's) while it counts sequences, but the count-line discriminator keyed only off the noun, so Panoramax fell through to the default pair and promised 'its photos'. Discriminate on the action first, then the noun, and add the sequences + nearest-photo pair (KartaView keeps streets + nearest-photo; the default keeps sequences + photos). --- src/i18n/locales/es.json | 7 ++++- src/i18n/locales/ru.json | 8 ++++- src/ui/streetLevelPresentation.js | 52 ++++++++++++++++++++----------- 3 files changed, 47 insertions(+), 20 deletions(-) diff --git a/src/i18n/locales/es.json b/src/i18n/locales/es.json index d9d0315e2..7d36002ee 100644 --- a/src/i18n/locales/es.json +++ b/src/i18n/locales/es.json @@ -2779,5 +2779,10 @@ "Show the complete infrared image; 0% filter strength": "Muestra la imagen infrarroja completa; intensidad del filtro 0%", "FILTER": "FILTRO", "Cloud filter strength": "Intensidad del filtro de nubes", - "Panoramax answered HTTP {status}": "Panoramax respondió HTTP {status}" + "Panoramax answered HTTP {status}": "Panoramax respondió HTTP {status}", + "{n} sequences in view · click a line for its nearest photo": { + "one": "{n} secuencia a la vista · haz clic en una línea para su foto más cercana", + "many": "{n} secuencias a la vista · haz clic en una línea para su foto más cercana", + "other": "{n} secuencias a la vista · haz clic en una línea para su foto más cercana" + } } diff --git a/src/i18n/locales/ru.json b/src/i18n/locales/ru.json index ab61f56cc..918edac62 100644 --- a/src/i18n/locales/ru.json +++ b/src/i18n/locales/ru.json @@ -2836,5 +2836,11 @@ "Show the complete infrared image; 0% filter strength": "Показать полное ИК-изображение; сила фильтра 0%", "FILTER": "ФИЛЬТР", "Cloud filter strength": "Сила фильтра облаков", - "Panoramax answered HTTP {status}": "Panoramax ответил: HTTP {status}" + "Panoramax answered HTTP {status}": "Panoramax ответил: HTTP {status}", + "{n} sequences in view · click a line for its nearest photo": { + "one": "{n} съёмка в кадре · щёлкните линию, чтобы открыть ближайшую фотографию", + "few": "{n} съёмки в кадре · щёлкните линию, чтобы открыть ближайшую фотографию", + "many": "{n} съёмок в кадре · щёлкните линию, чтобы открыть ближайшую фотографию", + "other": "{n} съёмки в кадре · щёлкните линию, чтобы открыть ближайшую фотографию" + } } diff --git a/src/ui/streetLevelPresentation.js b/src/ui/streetLevelPresentation.js index 579452336..0f468edd6 100644 --- a/src/ui/streetLevelPresentation.js +++ b/src/ui/streetLevelPresentation.js @@ -257,29 +257,45 @@ function presentMeta(state) { ); const hint = translateHint(state.coverage.hint); if (state.coverage.count > 0) { - // A provider whose lines are not sequences carries its wording as data - // (coverageLabel); its count line needs its own tn pair so every language - // gets real plural forms. New wording: add a pair here and to the catalogs. - const count = state.coverage.count; - const countLine = - state.coverage.label?.noun === 'streets' - ? tn( - count, - '{n} street in view · click a line for its nearest photo', - '{n} streets in view · click a line for its nearest photo', - { n: formatNumber(count) }, - ) - : tn( - count, - '{n} sequence in view · click a line for its photos', - '{n} sequences in view · click a line for its photos', - { n: formatNumber(count) }, - ); + const countLine = coverageCountLine(state.coverage); return [countLine, hint].filter(Boolean).join(' · '); } return hint; } +/** + * The coverage count line. Providers carry their wording as data + * (coverageLabel noun/action); each combination needs its own literal tn + * pair so every language gets real plural forms — the noun cannot inflect + * through a placeholder in Russian. New wording: add a pair here and to the + * catalogs (es.json/ru.json). + * @param {{count: number, label?: {noun?: string, action?: string}|null}} coverage + */ +function coverageCountLine(coverage) { + const count = coverage.count; + const values = { n: formatNumber(count) }; + if (coverage.label?.action === 'click a line for its nearest photo') + return coverage.label.noun === 'streets' + ? tn( + count, + '{n} street in view · click a line for its nearest photo', + '{n} streets in view · click a line for its nearest photo', + values, + ) + : tn( + count, + '{n} sequence in view · click a line for its nearest photo', + '{n} sequences in view · click a line for its nearest photo', + values, + ); + return tn( + count, + '{n} sequence in view · click a line for its photos', + '{n} sequences in view · click a line for its photos', + values, + ); +} + /** @param {{now?: number}} [options] Clock for the SINCE readout. */ export function presentStreetLevelPanel(state, { now = Date.now() } = {}) { const enabled = state.enabled === true;