From c5b1d41cbade33efd598286a104e49b9cd1d2952 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Niccol=C3=B2=20Saggese?= Date: Mon, 21 Sep 2026 11:30:49 +0200 Subject: [PATCH 01/13] fix(search): geocode through the server, not the browser - Google's Geocoding web service refuses referrer-restricted keys, so the browser call forced GOOGLE_MAPS_API_KEY to ship unrestricted - add /api/google/geocode and /api/google/reverse-geocode, using the server key like Places and Street View already do - the browser key now needs the Map Tiles API only --- CHANGELOG.md | 9 ++ SECURITY.md | 6 +- docs/CURRENT-STATE.md | 19 ++- server/providers/local.js | 3 + server/providers/places.js | 7 +- server/providers/places/google-key.js | 33 +++- server/providers/places/google.js | 155 ++++++++++++++++++ src/annotations/annotationEngine.test.mjs | 2 +- src/annotations/annotationResolver.test.mjs | 20 +-- src/googleGeocodeProxy.test.mjs | 167 ++++++++++++++++++++ src/googleServerKey.test.mjs | 16 +- src/search/defaults.js | 23 +-- src/search/http.js | 15 +- src/search/placeSearch.test.mjs | 2 +- src/voice/gevActions.test.mjs | 6 +- 15 files changed, 436 insertions(+), 47 deletions(-) create mode 100644 src/googleGeocodeProxy.test.mjs diff --git a/CHANGELOG.md b/CHANGELOG.md index 744f4be0a..526a89bc4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,14 @@ # Changelog +- Move Google geocoding behind the local server. Forward and reverse lookups now + go through `/api/google/geocode` and `/api/google/reverse-geocode` with the + server key, joining Places and Street View. Google's Geocoding web service + refuses referrer-restricted keys, so the previous browser call left + `SECURITY.md`'s advice and working search mutually exclusive: the bundled + browser key had to be unrestricted for search to work at all. That key now + needs the Map Tiles API only. Keyless sessions and the Photon/Nominatim + fallback order are unchanged. + - Enable responsive trackpad pinch zoom on the globe. Browser pixel-mode `Ctrl+wheel` pinch gestures now reach Cesium with bounded amplification, while ordinary wheel, line-mode and touch-pinch inputs retain their existing diff --git a/SECURITY.md b/SECURITY.md index a21ea99a4..661e8764f 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -20,18 +20,18 @@ The golden rule: **secret-bearing API keys stay on the server side.** The dev/pr | `OPENAI_API_KEY` | Server only | Browser fetches a short-lived **ephemeral** Realtime session token from `/api/realtime/token`; the real key never ships | | `AISSTREAM_API_KEY` | Server only | Server holds the AISStream websocket; browser polls the same-origin `/api/ais-live` cache | | OpenSky OAuth (`OPENSKY_CLIENT_ID/SECRET`) | Server only | Server mints + refreshes the token behind `/api/opensky` | -| `GOOGLE_MAPS_SERVER_API_KEY` (optional, #33) | Server only | Server calls Places (`/api/google/nearby-places`, `/api/google/text-search`) and the Street View fallback with this key; falls back to `GOOGLE_MAPS_API_KEY` when unset | +| `GOOGLE_MAPS_SERVER_API_KEY` (optional, #33) | Server only | Server calls Places (`/api/google/nearby-places`, `/api/google/text-search`), Geocoding (`/api/google/geocode`, `/api/google/reverse-geocode`) and the Street View fallback with this key; falls back to `GOOGLE_MAPS_API_KEY` when unset | ### Two deliberately client-side keys — restrict them These are designed to be used directly in the browser (like a Mapbox public token). They are injected into the client bundle via Vite's `define`, so they **will** be visible in browser devtools. Scope and restrict them rather than trying to hide them: -1. **Google Maps API key** — loads Photorealistic 3D Tiles directly and powers GEV place search. **Restrict it** (HTTP referrer + API restriction to the required Google APIs) in the Google Cloud Console. An unrestricted key in a public deployment can be abused and billed to you. +1. **Google Maps API key** — loads Photorealistic 3D Tiles directly. **Restrict it** (HTTP referrer + API restriction to the required Google APIs) in the Google Cloud Console. An unrestricted key in a public deployment can be abused and billed to you. Restrict it to the **Map Tiles API only**: a referrer restriction applies to the Maps JavaScript API and the client SDKs, not to the web services, so a key carrying Geocoding under one is refused with `API keys with referer restrictions cannot be used with this API`. Search does not need it — geocoding goes through the server proxies above (#363). 2. **Cesium ion token** (`CESIUM_ION_TOKEN`, optional — for ion-hosted Google Photorealistic 3D Tiles, Bing world imagery, and world terrain) — used as `Cesium.Ion.defaultAccessToken` client-side. Use a public **`assets:read`** token with **URL restrictions** for any hosted deployment. The Community plan has eligibility and usage limits; a public token is not a secret, but it can still consume the account's quota. > The explicit browser `define` block in `build/vite.js` controls exactly what reaches the client: only these two keys. Everything else stays server-side. -**Places and Street View never needed to be on that list** (#33): they're called from the server-side proxies in the table above, which use `GOOGLE_MAPS_SERVER_API_KEY` when it's set. Splitting it from the browser-exposed key lets each key's Google Cloud restriction actually match what it does — the browser key referrer-restricted to the APIs the client loads, the server key IP-restricted (never a referrer, since it never leaves your server) to Places + Street View Static — instead of one key that has to be either over-permissioned or broken for one of its two jobs. A single shared `GOOGLE_MAPS_API_KEY` still works if you don't split them; it just has to cover every API both sides use. +**Places, Geocoding and Street View never needed to be on that list** (#33, #363): they're called from the server-side proxies in the table above, which use `GOOGLE_MAPS_SERVER_API_KEY` when it's set. Splitting it from the browser-exposed key lets each key's Google Cloud restriction actually match what it does — the browser key referrer-restricted to Map Tiles, the server key IP-restricted (never a referrer, since it never leaves your server) to Places + Geocoding + Street View Static — instead of one key that has to be either over-permissioned or broken for one of its jobs. A single shared `GOOGLE_MAPS_API_KEY` still works if you don't split them, but then it cannot carry a referrer restriction at all: the web services refuse one, and search would be the half that breaks. Never commit real keys. `.env` is gitignored; only `.env.example` (placeholder names) is tracked. On macOS `dev-fresh.sh` can read keys from the Keychain; plain Vite uses env vars or a local `.env`, and Pinokio uses its ignored app `ENVIRONMENT` file. diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index 9c381ac0c..e038bfe20 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -3445,7 +3445,24 @@ Providers normalize coordinates, canonical name, label, place types and optional bounds. Camera framing, nearby landmark recovery and footprint selection remain in their consumers, including the Capitol identity/containment safeguards. Radio keeps localized country names in labels rather than station filters. -Reverse geocoding and nearby/text-search endpoints retain their existing behavior. +Nearby/text-search endpoints retain their existing behavior. + +**Google geocoding is a server route, not a browser call** (#363). Forward and +reverse lookups go to `/api/google/geocode` and `/api/google/reverse-geocode`, +which carry `GOOGLE_MAPS_SERVER_API_KEY` (falling back to `GOOGLE_MAPS_API_KEY`) +and share the Places routes' opt-in `GEV_RATELIMIT_GOOGLE_PER_MIN` throttle. +Google's Geocoding **web service** refuses referrer-restricted keys, so the +previous browser call forced the bundled browser key — visible in devtools by +design — to be left unrestricted; it now needs the Map Tiles API only. Both +routes answer in Google's own `status`/`results` shape rather than the Places +routes' `places: []` contract, because the browser adapters normalize that shape +directly. The browser key still gates the provider client-side, so a keyless +session spends no request to learn it has no Google: the route's own keyless +answer reads as an unanswered provider, which would stop the chain caching the +negative Photon or Nominatim then produces. Its query is capped at 200 +characters, its reverse coordinates are validated against WGS84 bounds before +the request is paid for, and a malformed viewport bias is dropped rather than +refused, since a bias only ranks results. Only valid answers and definitive misses enter bounded caches; malformed replies, HTTP refusals and outages remain retryable. Searches share a 12-second total diff --git a/server/providers/local.js b/server/providers/local.js index 68f61a3cb..07e1a5bb8 100644 --- a/server/providers/local.js +++ b/server/providers/local.js @@ -66,6 +66,9 @@ export { LL2_CACHE_TTL_MS, launchLibraryRequestHeaders } from './space.js'; export { googlePlacesContextProxy } from './places.js'; export { googleServerApiKey } from './places.js'; export { keylessGooglePlacesResponse } from './places.js'; +export { keylessGoogleGeocodeResponse } from './places.js'; +export { validateGeocodeAddress } from './places.js'; +export { geocodeBounds } from './places.js'; export { adsbLolFallbackAnchor } from './aircraft/opensky.js'; export { readResponseTextCapped } from './common/http.js'; export { readResponseJsonCapped } from './common/http.js'; diff --git a/server/providers/places.js b/server/providers/places.js index 648be2aa8..6c36627e0 100644 --- a/server/providers/places.js +++ b/server/providers/places.js @@ -1,8 +1,13 @@ /** Place-search and route middleware for Node development servers. */ -export { googlePlacesContextProxy } from './places/google.js'; +export { + googlePlacesContextProxy, + validateGeocodeAddress, + geocodeBounds, +} from './places/google.js'; export { googleServerApiKey, keylessGooglePlacesResponse, + keylessGoogleGeocodeResponse, } from './places/google-key.js'; export { installRouteMiddleware } from './places/routes.js'; export { diff --git a/server/providers/places/google-key.js b/server/providers/places/google-key.js index 353bc9e24..a5c54b302 100644 --- a/server/providers/places/google-key.js +++ b/server/providers/places/google-key.js @@ -14,12 +14,33 @@ export function keylessGooglePlacesResponse(apiKey) { } /** - * Google API key for the SERVER-SIDE calls (Places nearby/text search, the - * CCTV Street View fallback). These never reach the browser, so this key can - * be restricted by server IP and scoped to Places API + Street View Static - * API — while GOOGLE_MAPS_API_KEY stays referrer-restricted to Map Tiles + - * Geocoding for the browser (#33). Splitting them is opt-in: unset, this - * falls back to the shared browser key and nothing changes. + * Geocoding speaks Google's own response shape rather than the `places: []` + * contract its two siblings use, because the browser adapters read `status` + * and `results` directly. The keyless capability reads the same way: a status + * that is not `OK` and no results, which the adapters already treat as "this + * provider did not answer" and pass to the next geocoder in the chain. + */ +export function keylessGoogleGeocodeResponse(apiKey) { + if (String(apiKey ?? '').trim()) return null; + return { + statusCode: 200, + payload: { + configured: false, + status: 'REQUEST_DENIED', + results: [], + error: null, + }, + }; +} + +/** + * Google API key for the SERVER-SIDE calls (Places nearby/text search, + * geocoding, the CCTV Street View fallback). These never reach the browser, so + * this key can be restricted by server IP and scoped to Places API + Geocoding + * API + Street View Static API — while GOOGLE_MAPS_API_KEY stays + * referrer-restricted to Map Tiles for the browser (#33, #363). Splitting them + * is opt-in: unset, this falls back to the shared browser key, which then has + * to carry every API both sides use and cannot take a referrer restriction. */ export function googleServerApiKey() { return resolveGoogleServerKey(process.env); diff --git a/server/providers/places/google.js b/server/providers/places/google.js index 1ee83a892..8895bc19d 100644 --- a/server/providers/places/google.js +++ b/server/providers/places/google.js @@ -1,6 +1,7 @@ import { googleServerApiKey, keylessGooglePlacesResponse, + keylessGoogleGeocodeResponse, } from './google-key.js'; import { makeOptInRateLimiter, clientKey } from '../common/rate-limit.js'; import { @@ -42,6 +43,37 @@ export function validatePlacesCoordinates(searchParams) { return { ok: true, latitude, longitude }; } +/** Longest accepted geocoder query. Real place names are far shorter. */ +const GEOCODE_MAX_QUERY = 200; + +/** Validate a free-text geocoder query before consuming request quota. */ +export function validateGeocodeAddress(searchParams) { + const address = String(searchParams.get('address') || '').trim(); + if (!address) return { ok: false, error: 'address is required' }; + if (address.length > GEOCODE_MAX_QUERY) + return { ok: false, error: 'address is too long' }; + return { ok: true, address }; +} + +/** + * Validate the optional viewport bias, which Google reads as + * `sw_lat,sw_lng|ne_lat,ne_lng`. An unparsed value is dropped rather than + * refused: the bias only ranks results, so a malformed one costs relevance, + * not an answer. + */ +export function geocodeBounds(searchParams) { + const raw = String(searchParams.get('bounds') || '').trim(); + if (!raw) return null; + const corners = raw.split('|'); + if (corners.length !== 2) return null; + const numbers = corners.flatMap((corner) => corner.split(',').map(Number)); + if (numbers.length !== 4 || !numbers.every(Number.isFinite)) return null; + const [swLat, swLon, neLat, neLon] = numbers; + if (Math.abs(swLat) > 90 || Math.abs(neLat) > 90) return null; + if (Math.abs(swLon) > 180 || Math.abs(neLon) > 180) return null; + return raw; +} + /** Nearby place labels and view-biased text search, with request-time key resolution. */ export function googlePlacesContextProxy({ resolveApiKey = googleServerApiKey, @@ -274,6 +306,129 @@ export function googlePlacesContextProxy({ ); } }); + + /** + * Geocoding belongs on the server, not in the page. Google's Geocoding web + * service refuses referrer-restricted keys, so calling it from the browser + * forces GOOGLE_MAPS_API_KEY — which ships in the bundle by design — to be + * left unrestricted (#363). Here the request carries the server key, and + * the browser key no longer needs the Geocoding API at all. + * + * Unlike the two Places routes above, these answer in Google's own shape + * (`status` + `results`) rather than the `places: []` contract: the browser + * normalizers read that shape directly, and keeping it means the proxy adds + * a hop without adding a translation nobody asked for. + */ + function installGeocodeRoute(path, readQuery) { + middlewares.use(path, async (req, res) => { + const refuse = (statusCode, error) => { + res.statusCode = statusCode; + res.setHeader('Content-Type', 'application/json'); + res.end( + JSON.stringify({ status: 'REQUEST_DENIED', results: [], error }), + ); + }; + + if (req.method !== 'GET') { + refuse(405, 'Method not allowed'); + return; + } + + // A keyless geocode has no provider cost, so it resolves before the + // paid-endpoint limiter can consume or exhaust quota (mirrors the + // Places routes above). + const apiKey = resolveApiKey(); + const keyless = keylessGoogleGeocodeResponse(apiKey); + if (keyless) { + res.statusCode = keyless.statusCode; + res.setHeader('Content-Type', 'application/json'); + res.setHeader('Cache-Control', 'no-store'); + res.end(JSON.stringify(keyless.payload)); + return; + } + + const requestUrl = new URL(req.url || '', 'http://localhost'); + const query = readQuery(requestUrl.searchParams); + if (!query.ok) { + refuse(400, query.error); + return; + } + + // Opt-in per-IP throttle (GEV_RATELIMIT_GOOGLE_PER_MIN), shared with + // the Places routes because both spend the same Google budget. + const _grl = googleRateLimiter(); + if (_grl && !_grl(clientKey(req))) { + res.statusCode = 429; + res.setHeader('Content-Type', 'application/json'); + res.setHeader('Retry-After', '5'); + res.end( + JSON.stringify({ + status: 'OVER_QUERY_LIMIT', + results: [], + error: 'Rate limit exceeded', + }), + ); + return; + } + + try { + const upstream = new URL( + endpoints.geocode || + 'https://maps.googleapis.com/maps/api/geocode/json', + ); + for (const [name, value] of Object.entries(query.params)) + upstream.searchParams.set(name, value); + upstream.searchParams.set('key', apiKey); + const response = await fetchImpl(upstream.toString(), { + redirect: 'error', + }); + const data = await response.json().catch(() => null); + + res.statusCode = response.ok ? 200 : response.status; + res.setHeader('Content-Type', 'application/json; charset=utf-8'); + res.setHeader('Cache-Control', 'private, max-age=300'); + res.end( + JSON.stringify( + data ?? { + status: 'UNKNOWN_ERROR', + results: [], + error: 'Google Geocoding request failed', + }, + ), + ); + } catch (error) { + res.statusCode = 502; + res.setHeader('Content-Type', 'application/json; charset=utf-8'); + res.end( + JSON.stringify({ + status: 'UNKNOWN_ERROR', + results: [], + error: error?.message || 'Google Geocoding request failed', + }), + ); + } + }); + } + + installGeocodeRoute('/api/google/geocode', (searchParams) => { + const address = validateGeocodeAddress(searchParams); + if (!address.ok) return address; + const bounds = geocodeBounds(searchParams); + return { + ok: true, + params: { + address: address.address, + ...(bounds ? { bounds } : {}), + }, + }; + }); + + installGeocodeRoute('/api/google/reverse-geocode', (searchParams) => { + const coordinates = validatePlacesCoordinates(searchParams); + if (!coordinates.ok) return coordinates; + const { latitude, longitude } = coordinates; + return { ok: true, params: { latlng: `${latitude},${longitude}` } }; + }); } return { diff --git a/src/annotations/annotationEngine.test.mjs b/src/annotations/annotationEngine.test.mjs index 0d26974ae..cc43b612a 100644 --- a/src/annotations/annotationEngine.test.mjs +++ b/src/annotations/annotationEngine.test.mjs @@ -400,7 +400,7 @@ test('outline upgrade updates the rendered element in place without remove/add', }; let overpassCall = 0; globalThis.fetch = async (url) => { - if (String(url).startsWith('https://maps.googleapis.com/')) { + if (String(url).startsWith('/api/google/geocode')) { return { json: async () => ({ status: 'OK', results: [{ diff --git a/src/annotations/annotationResolver.test.mjs b/src/annotations/annotationResolver.test.mjs index 452d6997c..2c51213c1 100644 --- a/src/annotations/annotationResolver.test.mjs +++ b/src/annotations/annotationResolver.test.mjs @@ -200,7 +200,7 @@ test('ask-side admin bypass: "the Texas Capitol" recovers near-view despite a fa const calls = []; installGoogleMocks(t, async (url) => { calls.push(String(url)); - if (String(url).startsWith('https://maps.googleapis.com/')) { + if (String(url).startsWith('/api/google/geocode')) { return { json: async () => geocodePayload({ lat: 31.0000, lon: -99.0000, @@ -235,7 +235,7 @@ test('ask-side admin bypass: explicit "state of Texas" skips recovery and proxim const calls = []; installGoogleMocks(t, async (url) => { calls.push(String(url)); - assert.match(String(url), /^https:\/\/maps\.googleapis\.com\/maps\/api\/geocode/); + assert.match(String(url), /^\/api\/google\/geocode\?/); return { json: async () => geocodePayload({ lat: 31.0000, lon: -99.0000, @@ -273,7 +273,7 @@ for (const fixture of [ const calls = []; installGoogleMocks(t, async (url) => { calls.push(String(url)); - if (String(url).startsWith('https://maps.googleapis.com/')) { + if (String(url).startsWith('/api/google/geocode')) { return { json: async () => geocodePayload({ lat: fixture.lat, lon: fixture.lon, @@ -299,7 +299,7 @@ test('ask-side admin bypass: bare "Texas" remains on the guarded recovery path', const calls = []; installGoogleMocks(t, async (url) => { calls.push(String(url)); - if (String(url).startsWith('https://maps.googleapis.com/')) { + if (String(url).startsWith('/api/google/geocode')) { return { json: async () => geocodePayload({ lat: 31.0000, lon: -99.0000, @@ -328,7 +328,7 @@ test('ask-side admin bypass: admin level 2/3 result types never grant a township const calls = []; installGoogleMocks(t, async (url) => { calls.push(String(url)); - if (String(url).startsWith('https://maps.googleapis.com/')) { + if (String(url).startsWith('/api/google/geocode')) { const query = new URL(String(url)).searchParams.get('address'); return { json: async () => geocodePayload({ lat: 39.7817, @@ -380,7 +380,7 @@ function installCapitolMocks(t, elements, components = [ { long_name: 'Capitol Hill', types: ['neighborhood', 'political'] }, ]) { installGoogleMocks(t, async (url) => { - if (String(url).startsWith('https://maps.googleapis.com/')) { + if (String(url).startsWith('/api/google/geocode')) { return { json: async () => ({ status: 'OK', results: [{ formatted_address: 'Washington, DC 20004, USA', types: ['establishment', 'landmark', 'point_of_interest', 'tourist_attraction'], @@ -458,7 +458,7 @@ test('keyless: a key that geocodes to nothing still anchors the annotation', asy const requests = []; installGoogleMocks(t, async (url) => { requests.push(String(url)); - if (String(url).startsWith('https://maps.googleapis.com/')) return GOOGLE_FOUND_NOTHING; + if (String(url).startsWith('/api/google/geocode')) return GOOGLE_FOUND_NOTHING; return { ok: true, json: async () => ({ @@ -495,7 +495,7 @@ test('keyless: a key that geocodes to nothing still anchors the annotation', asy high: { latitude: 30.28, longitude: -97.68 }, }); // Google is asked first and exactly once; one Photon call answers it. - assert.equal(requests.filter((url) => url.includes('maps.googleapis.com')).length, 1); + assert.equal(requests.filter((url) => url.includes('/api/google/geocode')).length, 1); assert.equal(requests.filter((url) => url.includes('photon.komoot.io')).length, 1); }); @@ -512,7 +512,7 @@ test('keyless: OSM matching uses the feature\'s canonical name, not the user\'s installGoogleMocks(t, async (url, init) => { const href = String(url); - if (href.startsWith('https://maps.googleapis.com/')) return GOOGLE_FOUND_NOTHING; + if (href.startsWith('/api/google/geocode')) return GOOGLE_FOUND_NOTHING; if (href.startsWith('/api/google/text-search')) return { ok: true, json: async () => ({ places: [] }) }; if (href.startsWith('https://photon.komoot.io/')) { return { @@ -575,7 +575,7 @@ test('keyless: a Photon outage is not remembered as "no such place"', async (t) const requests = []; installGoogleMocks(t, async (url) => { requests.push(String(url)); - if (String(url).startsWith('https://maps.googleapis.com/')) return GOOGLE_FOUND_NOTHING; + if (String(url).startsWith('/api/google/geocode')) return GOOGLE_FOUND_NOTHING; if (!photonReachable) throw new Error('network down'); return photonUp; }); diff --git a/src/googleGeocodeProxy.test.mjs b/src/googleGeocodeProxy.test.mjs new file mode 100644 index 000000000..2518d02cb --- /dev/null +++ b/src/googleGeocodeProxy.test.mjs @@ -0,0 +1,167 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { + googlePlacesContextProxy, + validateGeocodeAddress, + geocodeBounds, +} from '../vite.config.js'; + +const UPSTREAM = 'https://geocode.test/json'; + +function installRoutes({ apiKey = 'server-secret', fetchImpl } = {}) { + const routes = new Map(); + googlePlacesContextProxy({ + resolveApiKey: () => apiKey, + fetchImpl, + endpoints: { geocode: UPSTREAM }, + }).configureServer({ + middlewares: { use: (name, handler) => routes.set(name, handler) }, + }); + return routes; +} + +function invokeRoute(handler, { method = 'GET', url = '/' } = {}) { + return new Promise((resolve, reject) => { + const headers = new Map(); + const res = { + statusCode: 200, + setHeader(name, value) { + headers.set(String(name).toLowerCase(), String(value)); + }, + end(body = '') { + resolve({ + statusCode: this.statusCode, + headers: Object.fromEntries(headers), + body: body ? JSON.parse(String(body)) : null, + }); + }, + }; + Promise.resolve( + handler( + { method, url, headers: {}, socket: { remoteAddress: '127.0.0.1' } }, + res, + ), + ).catch(reject); + }); +} + +test('geocoder query and viewport bias are validated before Google is paid', () => { + assert.deepEqual(validateGeocodeAddress(new URLSearchParams()), { + ok: false, + error: 'address is required', + }); + assert.deepEqual(validateGeocodeAddress(new URLSearchParams({ address: ' ' })), { + ok: false, + error: 'address is required', + }); + assert.deepEqual( + validateGeocodeAddress(new URLSearchParams({ address: 'x'.repeat(201) })), + { ok: false, error: 'address is too long' }, + ); + assert.deepEqual( + validateGeocodeAddress(new URLSearchParams({ address: ' Austin ' })), + { ok: true, address: 'Austin' }, + ); + + // A bias only ranks results, so an unusable one is dropped, not refused. + const bias = '30.1,-97.9|30.4,-97.6'; + assert.equal(geocodeBounds(new URLSearchParams({ bounds: bias })), bias); + for (const bounds of [ + '', + '30.1,-97.9', + '30.1,-97.9|30.4', + 'north,-97.9|30.4,-97.6', + '91,-97.9|30.4,-97.6', + '30.1,-181|30.4,-97.6', + ]) + assert.equal(geocodeBounds(new URLSearchParams({ bounds })), null); +}); + +test('the geocode routes keep the key server-side and answer in Google shape', async () => { + const upstream = []; + const routes = installRoutes({ + fetchImpl: async (url) => { + upstream.push(new URL(String(url))); + return { + ok: true, + json: async () => ({ status: 'OK', results: [{ place_id: 'fixture' }] }), + }; + }, + }); + const forward = routes.get('/api/google/geocode'); + const reverse = routes.get('/api/google/reverse-geocode'); + assert.equal(typeof forward, 'function'); + assert.equal(typeof reverse, 'function'); + + const forwarded = await invokeRoute(forward, { + url: '/?address=Austin&bounds=30.1,-97.9|30.4,-97.6', + }); + assert.equal(forwarded.statusCode, 200); + assert.deepEqual(forwarded.body, { + status: 'OK', + results: [{ place_id: 'fixture' }], + }); + assert.equal(upstream.at(-1).searchParams.get('address'), 'Austin'); + assert.equal(upstream.at(-1).searchParams.get('bounds'), '30.1,-97.9|30.4,-97.6'); + assert.equal(upstream.at(-1).searchParams.get('key'), 'server-secret'); + + const reversed = await invokeRoute(reverse, { url: '/?lat=30.27&lon=-97.74' }); + assert.equal(reversed.statusCode, 200); + assert.equal(upstream.at(-1).searchParams.get('latlng'), '30.27,-97.74'); + assert.equal(upstream.at(-1).searchParams.get('key'), 'server-secret'); + + // The key travels to Google, never back to the page that asked. + assert.ok(!JSON.stringify(forwarded.body).includes('server-secret')); + assert.ok(!JSON.stringify(reversed.body).includes('server-secret')); + + // A malformed bias is dropped rather than forwarded. + await invokeRoute(forward, { url: '/?address=Austin&bounds=north' }); + assert.equal(upstream.at(-1).searchParams.has('bounds'), false); +}); + +test('a bad or keyless geocode request never reaches Google', async () => { + const upstream = []; + const fetchImpl = async (url) => { + upstream.push(String(url)); + return { ok: true, json: async () => ({ status: 'OK', results: [] }) }; + }; + + const routes = installRoutes({ fetchImpl }); + for (const [path, url] of [ + ['/api/google/geocode', '/?address='], + ['/api/google/reverse-geocode', '/?lat=91&lon=-97.74'], + ]) { + const response = await invokeRoute(routes.get(path), { url }); + assert.equal(response.statusCode, 400); + assert.deepEqual(response.body.results, []); + assert.equal(response.body.status, 'REQUEST_DENIED'); + } + + for (const path of ['/api/google/geocode', '/api/google/reverse-geocode']) { + const rejected = await invokeRoute(routes.get(path), { + method: 'POST', + url: '/?address=Austin&lat=30&lon=-97', + }); + assert.equal(rejected.statusCode, 405); + } + + // Keyless is an empty capability, not an outage: a 200 the browser adapters + // read as "this provider did not answer", so the chain moves on. It resolves + // ahead of validation, like the Places routes: with nothing configured there + // is no request to validate and no quota to protect. + const keyless = installRoutes({ apiKey: ' ', fetchImpl }); + for (const path of ['/api/google/geocode', '/api/google/reverse-geocode']) + for (const url of ['/?address=Austin&lat=30&lon=-97', '/?address=&lat=91']) { + const response = await invokeRoute(keyless.get(path), { url }); + assert.equal(response.statusCode, 200); + assert.equal(response.headers['cache-control'], 'no-store'); + assert.deepEqual(response.body, { + configured: false, + status: 'REQUEST_DENIED', + results: [], + error: null, + }); + } + + assert.deepEqual(upstream, []); +}); diff --git a/src/googleServerKey.test.mjs b/src/googleServerKey.test.mjs index 0851afcd5..2789219f5 100644 --- a/src/googleServerKey.test.mjs +++ b/src/googleServerKey.test.mjs @@ -67,11 +67,19 @@ test('the Street View tool resolves per-variable overrides before preferring the } }); -test('both Places routes select the intended key and keep it out of responses', async (t) => { +test('every Google route selects the intended key and keeps it out of responses', async (t) => { const original = { server: process.env.GOOGLE_MAPS_SERVER_API_KEY, browser: process.env.GOOGLE_MAPS_API_KEY, limit: process.env.GEV_RATELIMIT_GOOGLE_PER_MIN }; const calls = []; t.mock.method(globalThis, 'fetch', async (url, options) => { - calls.push({ url, key: options.headers['X-Goog-Api-Key'] }); + // Places sends the key as a header; Geocoding's web service takes it as a + // query parameter. Either way it must be the server key, and never the + // browser's copy of it. + calls.push({ + url, + key: + options?.headers?.['X-Goog-Api-Key'] ?? + new URL(String(url)).searchParams.get('key'), + }); return Response.json({ places: [] }); }); try { @@ -88,12 +96,12 @@ test('both Places routes select the intended key and keep it out of responses', for (const install of ['configureServer', 'configurePreviewServer']) { const routes = new Map(); googlePlacesContextProxy()[install]({ middlewares: { use: (name, handler) => routes.set(name, handler) } }); - assert.equal(routes.size, 2); + assert.equal(routes.size, 4); for (const handler of routes.values()) { const before = calls.length; let body; const res = { setHeader() {}, end(value) { body = value; } }; - await handler({ method: 'GET', url: '/?lat=30&lon=-97&q=capitol', headers: {}, socket: { remoteAddress: '127.0.0.1' } }, res); + await handler({ method: 'GET', url: '/?lat=30&lon=-97&q=capitol&address=capitol', headers: {}, socket: { remoteAddress: '127.0.0.1' } }, res); assert.equal(res.statusCode, 200); assert.ok(!body.includes('server-secret')); if (expected) { diff --git a/src/search/defaults.js b/src/search/defaults.js index 2b0428025..247512405 100644 --- a/src/search/defaults.js +++ b/src/search/defaults.js @@ -40,18 +40,23 @@ export function createDefaultPlaceSearch({ ...(selected ? [selected] : [ + // Google answers through the local proxy, which holds the server + // key: its Geocoding web service refuses referrer-restricted keys, + // so calling it from here would force the bundled browser key to be + // left unrestricted (#363). That key still gates the provider, + // because a keyless session must not spend a request to learn it + // has no Google: the proxy's "not configured" reads as an + // unanswered provider, which stops the chain caching the negative + // Photon or Nominatim then produces. createGoogleGeocoder({ request(query, { bias, signal }) { - const key = resolveApiKey?.(); - if (!key) return null; - const url = new URL( - endpoints.geocode || - 'https://maps.googleapis.com/maps/api/geocode/json', + if (!resolveApiKey?.()) return null; + const params = new URLSearchParams({ address: query }); + if (bias) params.set('bounds', bias); + return fetchImpl( + `${endpoints.geocode || '/api/google/geocode'}?${params}`, + { signal }, ); - url.searchParams.set('address', query); - url.searchParams.set('key', key); - if (bias) url.searchParams.set('bounds', bias); - return fetchImpl(url.toString(), { signal }); }, }), createPhotonGeocoder({ fetchImpl, endpoint: endpoints.photon }), diff --git a/src/search/http.js b/src/search/http.js index 5b1642c67..5afa35b75 100644 --- a/src/search/http.js +++ b/src/search/http.js @@ -53,7 +53,9 @@ export function createHttpGeospatialProvider({ endpoints = {}, } = {}) { const urls = { - reverse: 'https://maps.googleapis.com/maps/api/geocode/json', + // Same-origin like its siblings below: Google's Geocoding web service + // refuses referrer-restricted keys, so the key stays on the server (#363). + reverse: '/api/google/reverse-geocode', textSearch: '/api/google/text-search', nearby: '/api/google/nearby-places', route: '/api/route', @@ -88,14 +90,11 @@ export function createHttpGeospatialProvider({ }, routeProfiles: ['foot', 'car', 'bike'], async reverseGeocode(latitude, longitude, options) { - const key = resolveApiKey?.(); - if (resolveApiKey && !key) return null; + // The browser key gates the provider without travelling with the + // request: the proxy supplies the key Google actually sees. + if (resolveApiKey && !resolveApiKey()) return null; return normalizeGoogleReverse( - await json( - urls.reverse, - { latlng: `${latitude},${longitude}`, ...(key ? { key } : {}) }, - options, - ), + await json(urls.reverse, { lat: latitude, lon: longitude }, options), ); }, async textSearch(query, { latitude, longitude, radiusM = 6000 }, options) { diff --git a/src/search/placeSearch.test.mjs b/src/search/placeSearch.test.mjs index e609f9da9..05e1aaea0 100644 --- a/src/search/placeSearch.test.mjs +++ b/src/search/placeSearch.test.mjs @@ -20,7 +20,7 @@ test('standalone geocoding falls back after Google connection, JSON and refusal for (const fail of [() => { throw new Error('offline'); }, () => new Response('invalid json'), () => Response.json({ status: 'REQUEST_DENIED' })]) { const urls = []; const service = createStandalonePlaceSearch({ resolveApiKey: () => 'fixture', fetchImpl: async (url) => { - urls.push(url); return url.includes('maps.googleapis.com') ? fail() : hit(); + urls.push(url); return url.includes('/api/google/geocode') ? fail() : hit(); } }); const result = await service.geocode('Hanoi'); assert.equal(result.place.name, 'Hà Nội'); diff --git a/src/voice/gevActions.test.mjs b/src/voice/gevActions.test.mjs index ba0ed9cf7..1d8d28c97 100644 --- a/src/voice/gevActions.test.mjs +++ b/src/voice/gevActions.test.mjs @@ -3082,7 +3082,7 @@ test('voice Radio: a key that geocodes to nothing still places the station, keyl const requests = []; installKeyedFetch(t, async (url) => { requests.push(String(url)); - if (String(url).startsWith('https://maps.googleapis.com/')) { + if (String(url).startsWith('/api/google/geocode')) { return { ok: true, json: async () => ({ status: 'ZERO_RESULTS', results: [] }) }; } assert.match(String(url), /^https:\/\/photon\.komoot\.io\/api\/\?/); @@ -3107,7 +3107,7 @@ test('voice Radio: a key that geocodes to nothing still places the station, keyl assert.ok(Math.abs(calls[0].criteria.anchor.lat - 20.9101) < 1e-9); assert.ok(Math.abs(calls[0].criteria.anchor.lon - 107.1839) < 1e-9); // Google is asked first and exactly once; Photon answers unbiased, in one call. - assert.equal(requests.filter((url) => url.includes('maps.googleapis.com')).length, 1); + assert.equal(requests.filter((url) => url.includes('/api/google/geocode')).length, 1); assert.equal(requests.filter((url) => url.includes('photon.komoot.io')).length, 1); assert.match(requests.at(-1), /[?&]q=H%E1%BA%A1\+Long\+Bay/); assert.doesNotMatch(requests.at(-1), /[?&](lat|lon|bbox)=/, 'a named radio location is not viewport-biased'); @@ -3120,7 +3120,7 @@ test('voice Radio: the keyless path applies no country filter the keyed path wou // "No Radio station matched" for exactly the places it just resolved, while a // keyed install placed a station. The label may carry it; the filter may not. const { calls, dataManager } = radioSelectionHarness(); - installKeyedFetch(t, async (url) => (String(url).startsWith('https://maps.googleapis.com/') + installKeyedFetch(t, async (url) => (String(url).startsWith('/api/google/geocode') ? { ok: true, json: async () => ({ status: 'ZERO_RESULTS', results: [] }) } : { ok: true, From 771c9e5632ae6f0177adc09bc4ab4c34b4698765 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Niccol=C3=B2=20Saggese?= Date: Mon, 21 Sep 2026 11:56:33 +0200 Subject: [PATCH 02/13] fix(search): bound and sanitize the geocode proxy - reject blank viewport-bias components before Number() reads them as 0 - give the upstream call a 10s deadline and a 256 KiB capped body read - return fixed public text on failure instead of the upstream message --- server/providers/places/google.js | 60 +++++++++++++++++++++++-------- src/googleGeocodeProxy.test.mjs | 47 +++++++++++++++++++++--- 2 files changed, 88 insertions(+), 19 deletions(-) diff --git a/server/providers/places/google.js b/server/providers/places/google.js index 8895bc19d..a4aa48a78 100644 --- a/server/providers/places/google.js +++ b/server/providers/places/google.js @@ -4,6 +4,7 @@ import { keylessGoogleGeocodeResponse, } from './google-key.js'; import { makeOptInRateLimiter, clientKey } from '../common/rate-limit.js'; +import { readResponseJsonCapped } from '../common/http.js'; import { projectNearbyPlaces, projectTextSearchPlaces, @@ -46,6 +47,16 @@ export function validatePlacesCoordinates(searchParams) { /** Longest accepted geocoder query. Real place names are far shorter. */ const GEOCODE_MAX_QUERY = 200; +/** + * Upstream deadline and body ceiling for the geocode routes. The deadline stays + * armed through body consumption, so a stalled upstream cannot hold a handler + * open, and the cap bounds a chunked or length-omitted reply. A geocode answer + * is a handful of results; 256 KiB is far above any honest one. + */ +const GEOCODE_UPSTREAM_TIMEOUT_MS = 10_000; + +const GEOCODE_MAX_RESPONSE_BYTES = 256 * 1024; + /** Validate a free-text geocoder query before consuming request quota. */ export function validateGeocodeAddress(searchParams) { const address = String(searchParams.get('address') || '').trim(); @@ -66,8 +77,12 @@ export function geocodeBounds(searchParams) { if (!raw) return null; const corners = raw.split('|'); if (corners.length !== 2) return null; - const numbers = corners.flatMap((corner) => corner.split(',').map(Number)); - if (numbers.length !== 4 || !numbers.every(Number.isFinite)) return null; + const parts = corners.flatMap((corner) => corner.split(',')); + // A blank component must be refused before Number() turns it into 0 and a + // half-written corner like `30.4,` reads as the equator. + if (parts.length !== 4 || parts.some((part) => !part.trim())) return null; + const numbers = parts.map(Number); + if (!numbers.every(Number.isFinite)) return null; const [swLat, swLon, neLat, neLon] = numbers; if (Math.abs(swLat) > 90 || Math.abs(neLat) > 90) return null; if (Math.abs(swLon) > 180 || Math.abs(neLon) > 180) return null; @@ -379,31 +394,48 @@ export function googlePlacesContextProxy({ for (const [name, value] of Object.entries(query.params)) upstream.searchParams.set(name, value); upstream.searchParams.set('key', apiKey); + const deadline = AbortSignal.timeout(GEOCODE_UPSTREAM_TIMEOUT_MS); const response = await fetchImpl(upstream.toString(), { redirect: 'error', + signal: deadline, }); - const data = await response.json().catch(() => null); + // An unreadable, oversized or timed-out body is not an answer: it + // falls through to the same shaped failure as a refusal below. + const data = await readResponseJsonCapped( + response, + GEOCODE_MAX_RESPONSE_BYTES, + deadline, + ).catch(() => null); + + // A body we could not read is an upstream failure, not an answer with + // a status of its own, so it does not borrow the upstream's code. + if (!data) { + res.statusCode = 502; + res.setHeader('Content-Type', 'application/json; charset=utf-8'); + res.end( + JSON.stringify({ + status: 'UNKNOWN_ERROR', + results: [], + error: 'Google Geocoding request failed', + }), + ); + return; + } res.statusCode = response.ok ? 200 : response.status; res.setHeader('Content-Type', 'application/json; charset=utf-8'); res.setHeader('Cache-Control', 'private, max-age=300'); - res.end( - JSON.stringify( - data ?? { - status: 'UNKNOWN_ERROR', - results: [], - error: 'Google Geocoding request failed', - }, - ), - ); - } catch (error) { + res.end(JSON.stringify(data)); + } catch { + // Fixed public text: a transport, URL or runtime message would + // describe the server to the browser (SECURITY.md, sanitized errors). res.statusCode = 502; res.setHeader('Content-Type', 'application/json; charset=utf-8'); res.end( JSON.stringify({ status: 'UNKNOWN_ERROR', results: [], - error: error?.message || 'Google Geocoding request failed', + error: 'Google Geocoding request failed', }), ); } diff --git a/src/googleGeocodeProxy.test.mjs b/src/googleGeocodeProxy.test.mjs index 2518d02cb..98ffd876e 100644 --- a/src/googleGeocodeProxy.test.mjs +++ b/src/googleGeocodeProxy.test.mjs @@ -73,6 +73,10 @@ test('geocoder query and viewport bias are validated before Google is paid', () 'north,-97.9|30.4,-97.6', '91,-97.9|30.4,-97.6', '30.1,-181|30.4,-97.6', + // A blank component must not read as 0 — that is the equator, not a corner. + '30.1,-97.9|30.4,', + '30.1,|30.4,-97.6', + '30.1,-97.9| ,-97.6', ]) assert.equal(geocodeBounds(new URLSearchParams({ bounds })), null); }); @@ -82,10 +86,7 @@ test('the geocode routes keep the key server-side and answer in Google shape', a const routes = installRoutes({ fetchImpl: async (url) => { upstream.push(new URL(String(url))); - return { - ok: true, - json: async () => ({ status: 'OK', results: [{ place_id: 'fixture' }] }), - }; + return Response.json({ status: 'OK', results: [{ place_id: 'fixture' }] }); }, }); const forward = routes.get('/api/google/geocode'); @@ -123,7 +124,7 @@ test('a bad or keyless geocode request never reaches Google', async () => { const upstream = []; const fetchImpl = async (url) => { upstream.push(String(url)); - return { ok: true, json: async () => ({ status: 'OK', results: [] }) }; + return Response.json({ status: 'OK', results: [] }); }; const routes = installRoutes({ fetchImpl }); @@ -165,3 +166,39 @@ test('a bad or keyless geocode request never reaches Google', async () => { assert.deepEqual(upstream, []); }); + +test('an upstream failure reaches the browser as fixed public text', async () => { + const routes = installRoutes({ + fetchImpl: async () => { + throw new Error('connect ECONNREFUSED 10.0.0.7:443'); + }, + }); + for (const path of ['/api/google/geocode', '/api/google/reverse-geocode']) { + const response = await invokeRoute(routes.get(path), { + url: '/?address=Austin&lat=30&lon=-97', + }); + assert.equal(response.statusCode, 502); + assert.deepEqual(response.body, { + status: 'UNKNOWN_ERROR', + results: [], + error: 'Google Geocoding request failed', + }); + } +}); + +test('an oversized upstream body is dropped rather than buffered', async () => { + const routes = installRoutes({ + // Two hundred times the 256 KiB ceiling, declared honestly. + fetchImpl: async () => + Response.json({ status: 'OK', results: [{ pad: 'x'.repeat(52_428_800) }] }), + }); + const response = await invokeRoute(routes.get('/api/google/geocode'), { + url: '/?address=Austin', + }); + assert.equal(response.statusCode, 502); + assert.deepEqual(response.body, { + status: 'UNKNOWN_ERROR', + results: [], + error: 'Google Geocoding request failed', + }); +}); From 7485b16ded0158b804f1ac07da7e25b71eae0d5d Mon Sep 17 00:00:00 2001 From: daikaginza Date: Mon, 5 Oct 2026 17:12:41 +0700 Subject: [PATCH 03/13] fix(overpass): do not serve cached answers from a different upstream (#932) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Both disk caches fed by OVERPASS_UPSTREAMS key only on the query — the Overpass proxy on the sanitized request body, the installations proxy on the bbox — so an entry written against one instance is a hit for the same query against another. After an operator repoints OVERPASS_UPSTREAMS, the previous upstream's answers keep being served for 7 days, 30 for boundary-class queries, and indefinitely through the serve-stale path. An install that predates #742 has a cache written from the public instances that were the default then, and configuring your own instance does not displace it. Cached answers now carry a tag identifying the configured upstream set, and a disk read refuses an entry that does not match it, on the stale path too. With nothing configured any entry is still accepted: there is no instance to contradict, and serving last-good data at any age is what the not-configured path exists for. The tag is a hash of the resolved endpoint list, never the URLs; it is written to disk and compared on read, and is not sent to clients or surfaced in errors. One line is logged once per process when a foreign entry is ignored, naming the variable and what will happen, as local-receivers.js already does for a rejected feed. Cost worth naming: on upgrade every existing entry is untagged, so the first outage after upgrading serves nothing where it previously served stale data, until each query is fetched once from the configured instance. --- server/providers/military-installations.js | 13 +++- .../providers/military-installations/cache.js | 4 ++ server/providers/overpass/cache.js | 6 ++ server/providers/overpass/constants.js | 63 +++++++++++++++++-- server/providers/overpass/transport.js | 4 ++ src/overpassProxy.test.mjs | 52 ++++++++++++++- 6 files changed, 135 insertions(+), 7 deletions(-) diff --git a/server/providers/military-installations.js b/server/providers/military-installations.js index 08ad87d9f..7d252b8ed 100644 --- a/server/providers/military-installations.js +++ b/server/providers/military-installations.js @@ -9,7 +9,10 @@ import { fetchOverpassPayload, overpassNotConfigured, } from './overpass/transport.js'; -import { resolveOverpassUpstreams } from './overpass/constants.js'; +import { + resolveOverpassUpstreams, + resolveOverpassUpstreamTag, +} from './overpass/constants.js'; import { _militaryInstallationCache, trimMilitaryInstallationCache, @@ -74,7 +77,13 @@ function militaryInstallationsProxy() { retrievedAt: new Date().toISOString(), status: 'ready', }; - const entry = { payload, cachedAt: Date.now() }; + // Tagged with the configured upstream set so the disk layer does not serve + // this answer back after the operator repoints (see overpass/cache.js). + const entry = { + payload, + cachedAt: Date.now(), + upstreams: resolveOverpassUpstreamTag(), + }; _militaryInstallationCache.set(key, entry); trimMilitaryInstallationCache(); writeMilitaryInstallationDisk(key, entry); diff --git a/server/providers/military-installations/cache.js b/server/providers/military-installations/cache.js index c77627e29..4d77981e6 100644 --- a/server/providers/military-installations/cache.js +++ b/server/providers/military-installations/cache.js @@ -5,6 +5,7 @@ import { MILITARY_INSTALLATION_DISK_DIR, MILITARY_INSTALLATION_MAX_CACHE, } from './constants.js'; +import { cachedByConfiguredUpstream } from '../overpass/constants.js'; import path from 'node:path'; import { createHash } from 'node:crypto'; import { promises as fsp } from 'node:fs'; @@ -114,6 +115,9 @@ async function readMilitaryInstallationDisk( const entry = JSON.parse( await fsp.readFile(militaryInstallationDiskPath(cacheKey, dir), 'utf8'), ); + // Same reason as the Overpass disk cache: the key is the bbox alone, so an + // entry written against a different OVERPASS_UPSTREAMS is otherwise a hit. + if (!cachedByConfiguredUpstream(entry)) return null; if (!militaryInstallationDiskFresh(entry, maxAgeMs)) return null; return migrateMilitaryInstallationEntry(entry); } catch { diff --git a/server/providers/overpass/cache.js b/server/providers/overpass/cache.js index b4e53ab21..442f599a6 100644 --- a/server/providers/overpass/cache.js +++ b/server/providers/overpass/cache.js @@ -5,6 +5,7 @@ import { OVERPASS_DISK_DIR, OVERPASS_CACHE_MS, OVERPASS_CACHE_MAX_ENTRIES, + cachedByConfiguredUpstream, } from './constants.js'; import path from 'node:path'; import { createHash } from 'node:crypto'; @@ -47,6 +48,11 @@ async function readOverpassDisk(cacheKey, maxAgeMs) { // Older versions persisted 4xx refusals with normal data TTLs. Ignore // them on both fresh and stale reads so an upgrade can recover immediately. if (!overpassPayloadIsData(payload)) return null; + // The key is the query alone, so a file written by a different upstream — + // including the public instances that were the default before #742 — is a + // hit for the same query. Refuse it: the operator's configured instance + // must be what answers, on the stale path too. + if (!cachedByConfiguredUpstream(payload)) return null; if (Date.now() - payload.cachedAt > maxAgeMs) return null; return payload; } catch { diff --git a/server/providers/overpass/constants.js b/server/providers/overpass/constants.js index 40f244d11..2a0d4a3ef 100644 --- a/server/providers/overpass/constants.js +++ b/server/providers/overpass/constants.js @@ -1,4 +1,5 @@ import path from 'node:path'; +import { createHash } from 'node:crypto'; // --------------------------------------------------------------------------- // Overpass API proxy constants and cache state @@ -27,16 +28,68 @@ function parseOverpassUpstreams(raw) { return endpoints.slice(0, 8); } -let upstreamMemo = { raw: null, endpoints: [] }; +let upstreamMemo = { raw: null, endpoints: [], tag: 'none' }; + +/** + * Short, stable identifier for the configured upstream set. Cached responses + * carry it so an answer produced by a different instance is not served after + * the operator repoints `OVERPASS_UPSTREAMS` — the disk cache outlives the + * configuration change by 7 days, a month for boundary queries, and forever on + * the serve-stale path. It is a hash, never the URLs, and it is only ever + * compared on read: it is not sent to clients and does not appear in errors. + */ +function upstreamTag(endpoints) { + if (!endpoints.length) return 'none'; + return createHash('sha1') + .update(endpoints.join('\n')) + .digest('hex') + .slice(0, 12); +} /** Resolve after environment loading. Public Overpass instances are not used by default. */ function resolveOverpassUpstreams() { - const raw = process.env.OVERPASS_UPSTREAMS || ''; - if (upstreamMemo.raw !== raw) - upstreamMemo = { raw, endpoints: parseOverpassUpstreams(raw) }; + readUpstreamMemo(); return [...upstreamMemo.endpoints]; } +/** The configured-upstream tag stored alongside, and checked against, cache entries. */ +function resolveOverpassUpstreamTag() { + return readUpstreamMemo().tag; +} + +let reportedForeignCache = false; + +/** + * Was this cached entry produced by the upstream set configured now? + * + * With nothing configured there is no instance to contradict, and serving + * last-good data at any age is the point of the cache (the proxy's + * not-configured path depends on it), so any entry is accepted. Once an + * operator does configure an instance, only its own answers are served: an + * entry with no tag, or one from a different instance, is refused rather than + * matched on the query alone. Shared by both caches fed by these upstreams. + */ +function cachedByConfiguredUpstream(entry) { + const tag = resolveOverpassUpstreamTag(); + if (tag === 'none' || entry?.upstreams === tag) return true; + if (!reportedForeignCache) { + reportedForeignCache = true; + console.warn( + '[Overpass] ignoring cached responses produced by a different OVERPASS_UPSTREAMS; they will be refetched from the configured instance.', + ); + } + return false; +} + +function readUpstreamMemo() { + const raw = process.env.OVERPASS_UPSTREAMS || ''; + if (upstreamMemo.raw !== raw) { + const endpoints = parseOverpassUpstreams(raw); + upstreamMemo = { raw, endpoints, tag: upstreamTag(endpoints) }; + } + return upstreamMemo; +} + /** * TTL for FRESH cached Overpass responses (ms). Road geometry is static for * months — the original 45 s TTL forced a public-mirror round-trip on nearly @@ -153,6 +206,8 @@ export { OVERPASS_MAX_RESPONSE_BYTES, parseOverpassUpstreams, resolveOverpassUpstreams, + resolveOverpassUpstreamTag, + cachedByConfiguredUpstream, OVERPASS_USER_AGENT, OVERPASS_TIMEOUT_MS, }; diff --git a/server/providers/overpass/transport.js b/server/providers/overpass/transport.js index 86b9eae15..38c31bb08 100644 --- a/server/providers/overpass/transport.js +++ b/server/providers/overpass/transport.js @@ -1,6 +1,7 @@ import { OVERPASS_MAX_RESPONSE_BYTES, resolveOverpassUpstreams, + resolveOverpassUpstreamTag, OVERPASS_USER_AGENT, OVERPASS_TIMEOUT_MS, } from './constants.js'; @@ -187,6 +188,9 @@ async function fetchOverpassPayload( contentType: 'application/json', // Never retain a secret-bearing endpoint in cache or response metadata. endpoint: 'configured', + // Which configured instance produced this, as a hash rather than a URL, + // so a cached answer is not reused after the operator repoints. + upstreams: resolveOverpassUpstreamTag(), rateLimited: false, }; } catch { diff --git a/src/overpassProxy.test.mjs b/src/overpassProxy.test.mjs index 71b62aa2d..ed4539fda 100644 --- a/src/overpassProxy.test.mjs +++ b/src/overpassProxy.test.mjs @@ -12,6 +12,7 @@ import { createHash, randomUUID } from 'node:crypto'; import path from 'node:path'; import { Readable } from 'node:stream'; import createViteConfig, { fetchOverpassPayload, overpassPayloadIsData, readOverpassDisk } from '../vite.config.js'; +import { resolveOverpassUpstreamTag } from '../server/providers/overpass/constants.js'; const DATA = { status: 200, body: '{"elements":[]}' }; @@ -155,7 +156,13 @@ test('coalesced outage callers both receive last-good data, never a cached refus const directory = path.join(process.cwd(), '.gev-cache', 'overpass'); const file = path.join(directory, `${createHash('sha1').update(body).digest('hex')}.json`); await mkdir(directory, { recursive: true }); - const stale = { ...DATA, cachedAt: Date.now() - 40 * 86400000 }; + // Last-good data was fetched from the instance configured above, so it + // carries that instance's tag the way the transport now writes it. + const stale = { + ...DATA, + cachedAt: Date.now() - 40 * 86400000, + upstreams: resolveOverpassUpstreamTag(), + }; await writeFile(file, JSON.stringify(stale)); const entered = Promise.withResolvers(); const release = Promise.withResolvers(); @@ -188,3 +195,46 @@ test('coalesced outage callers both receive last-good data, never a cached refus } } }); + +test('a disk entry is not served after OVERPASS_UPSTREAMS is repointed', async (t) => { + const prior = process.env.OVERPASS_UPSTREAMS; + t.after(() => { + if (prior === undefined) delete process.env.OVERPASS_UPSTREAMS; + else process.env.OVERPASS_UPSTREAMS = prior; + }); + const key = `overpass-upstream-switch-${randomUUID()}`; + const directory = path.join(process.cwd(), '.gev-cache', 'overpass'); + const file = path.join( + directory, + `${createHash('sha1').update(key).digest('hex')}.json`, + ); + await mkdir(directory, { recursive: true }); + try { + // Written while one instance was configured... + process.env.OVERPASS_UPSTREAMS = 'https://first.example/api/interpreter'; + const first = resolveOverpassUpstreamTag(); + await writeFile( + file, + JSON.stringify({ ...DATA, cachedAt: Date.now(), upstreams: first }), + ); + assert.ok(await readOverpassDisk(key, 60000), 'its own entry is a hit'); + + // ...is not an answer once the operator points at a different one, on the + // stale path either: the key is the query alone, so it would otherwise hit. + process.env.OVERPASS_UPSTREAMS = 'https://second.example/api/interpreter'; + assert.notEqual(resolveOverpassUpstreamTag(), first, 'tag follows the env'); + assert.equal(await readOverpassDisk(key, 60000), null, 'fresh read refused'); + assert.equal( + await readOverpassDisk(key, Infinity), + null, + 'stale read refused', + ); + + // With nothing configured there is no instance to contradict, and serving + // last-good data is what the not-configured path exists for. + delete process.env.OVERPASS_UPSTREAMS; + assert.ok(await readOverpassDisk(key, Infinity), 'unconfigured still serves'); + } finally { + await unlink(file); + } +}); From a4d6181fb99764d2f5493c8238224bac4f7b125e Mon Sep 17 00:00:00 2001 From: Jeff Blakely <7987380+jeffblakely@users.noreply.github.com> Date: Mon, 12 Oct 2026 12:26:05 -0400 Subject: [PATCH 04/13] feat(bikeshare): add Detroit, Pittsburgh, Aspen and Kona & Hilo Register four live US systems on Lyft's PBSC platform: MoGo (Detroit, 81 stations), POGOH (Pittsburgh, 60), WE-cycle (Aspen, 101) and HIBike (Kona and Hilo, 23). The proxy already allows *.publicbikesystem.net, so no server change is needed. HIBike's centre sits between Kailua-Kona and Hilo so a camera over either town loads the whole system. The test checks each system is registered, passes the proxy's host and endpoint checks, and has an activation radius covering its farthest station as measured against the live feed. --- CHANGELOG.md | 4 ++ src/layers/bikeshare/registry.js | 50 ++++++++++++++++++++++ src/layers/bikeshare/usPbscCities.test.mjs | 31 ++++++++++++++ 3 files changed, 85 insertions(+) create mode 100644 src/layers/bikeshare/usPbscCities.test.mjs diff --git a/CHANGELOG.md b/CHANGELOG.md index b24ac95ed..5fd137e89 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,9 @@ # Changelog +- Add Detroit (MoGo), Pittsburgh (POGOH), Aspen (WE-cycle) and Kona & Hilo + (HIBike) to the GBFS bikeshare registry, the layer's first stations in + Michigan and Pennsylvania. + - Treat explicitly current, complete empty vessel coverage as a successful update, clearing obsolete contacts while retaining stale or incomplete snapshots. diff --git a/src/layers/bikeshare/registry.js b/src/layers/bikeshare/registry.js index ff752f664..38d417b04 100644 --- a/src/layers/bikeshare/registry.js +++ b/src/layers/bikeshare/registry.js @@ -48,6 +48,56 @@ const RAW_GBFS_CITY_REGISTRY = [ 'https://gbfs.lyft.com/gbfs/2.3/chi/en/station_status.json', provider: 'Divvy', }, + { + id: 'detroit-mogo', + city: 'Detroit, MI', + centerLat: 42.3314, + centerLon: -83.0458, + loadRadiusKm: 90, + stationInformationUrl: + 'https://detroit.publicbikesystem.net/customer/gbfs/v2/en/station_information.json', + stationStatusUrl: + 'https://detroit.publicbikesystem.net/customer/gbfs/v2/en/station_status.json', + provider: 'MoGo', + }, + { + id: 'pittsburgh-pogoh', + city: 'Pittsburgh, PA', + centerLat: 40.4406, + centerLon: -79.9959, + loadRadiusKm: 90, + stationInformationUrl: + 'https://pittsburgh.publicbikesystem.net/customer/gbfs/v2/en/station_information.json', + stationStatusUrl: + 'https://pittsburgh.publicbikesystem.net/customer/gbfs/v2/en/station_status.json', + provider: 'POGOH', + }, + { + id: 'aspen-we-cycle', + city: 'Aspen, CO', + centerLat: 39.1911, + centerLon: -106.8175, + loadRadiusKm: 90, + stationInformationUrl: + 'https://aspen.publicbikesystem.net/customer/gbfs/v2/en/station_information.json', + stationStatusUrl: + 'https://aspen.publicbikesystem.net/customer/gbfs/v2/en/station_status.json', + provider: 'WE-cycle', + }, + // HIBike serves both Kailua-Kona and Hilo, about 95 km apart; the centre sits + // between them so a camera over either town loads the whole system. + { + id: 'hawaii-island-hibike', + city: 'Kona & Hilo, HI', + centerLat: 19.68, + centerLon: -155.54, + loadRadiusKm: 90, + stationInformationUrl: + 'https://kona.publicbikesystem.net/customer/gbfs/v2/en/station_information.json', + stationStatusUrl: + 'https://kona.publicbikesystem.net/customer/gbfs/v2/en/station_status.json', + provider: 'HIBike', + }, { id: 'dc-capital-bikeshare', city: 'Washington, DC', diff --git a/src/layers/bikeshare/usPbscCities.test.mjs b/src/layers/bikeshare/usPbscCities.test.mjs new file mode 100644 index 000000000..4ff15c370 --- /dev/null +++ b/src/layers/bikeshare/usPbscCities.test.mjs @@ -0,0 +1,31 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { CITY_BY_ID } from './registry.js'; +import { isAllowedGbfsHost, isAllowedGbfsPath } from '../../data/gbfsSource.js'; +import { CITY_RANGE_BASE_KM } from './policy.js'; + +// Each city's PBSC host, and its farthest station from the registered centre +// as measured against the live feed (km), so a camera over any part of the +// system activates it. +const CITIES = { + 'detroit-mogo': { host: 'detroit.publicbikesystem.net', provider: 'MoGo', farthestKm: 22.4 }, + 'pittsburgh-pogoh': { host: 'pittsburgh.publicbikesystem.net', provider: 'POGOH', farthestKm: 9.1 }, + 'aspen-we-cycle': { host: 'aspen.publicbikesystem.net', provider: 'WE-cycle', farthestKm: 42.6 }, + 'hawaii-island-hibike': { host: 'kona.publicbikesystem.net', provider: 'HIBike', farthestKm: 53.8 }, +}; + +test('the added US PBSC systems are registered and readable through the proxy', () => { + for (const [id, expected] of Object.entries(CITIES)) { + const city = CITY_BY_ID.get(id); + assert.ok(city, `${id} is registered`); + assert.equal(city.provider, expected.provider); + assert.deepEqual(city.hosts, [expected.host]); + for (const feed of [city.stationInformationUrl, city.stationStatusUrl]) { + const url = new URL(feed); + assert.ok(isAllowedGbfsHost(url.hostname), `${id} host ${url.hostname}`); + assert.ok(isAllowedGbfsPath(url.pathname), `${id} path ${url.pathname}`); + } + const radius = Math.max(CITY_RANGE_BASE_KM, city.loadRadiusKm); + assert.ok(radius > expected.farthestKm, `${id} radius ${radius} covers its farthest station`); + } +}); From fc48eda113576cfcf9a4cc606c8a4554e4c6681e Mon Sep 17 00:00:00 2001 From: Aditya <205600203+Rohilalala@users.noreply.github.com> Date: Tue, 13 Oct 2026 16:31:11 +0530 Subject: [PATCH 05/13] fix(radio): reject null-body and out-of-range mirror statuses instead of crashing fetchPinnedRadioResponse built `new Response(stream, { status })` inside the raw https response callback, which runs outside the surrounding promise. A Radio Browser mirror answering 204, 205 or 304 (null-body statuses), or any status outside 200-599, made the Response constructor throw. Nothing caught it, so the whole dev/preview server process exited and every layer went down, not only radio. The catalog's mirror failover never saw the error. Mirror the guard local-receivers.js already uses: null-body statuses get a null body, statuses outside 200-599 are rejected, and anything thrown in the callback rejects the promise so the existing error handling and mirror failover run. --- CHANGELOG.md | 5 +++ docs/CURRENT-STATE.md | 9 ++-- server/providers/radio/transport.js | 41 ++++++++++++------ src/data/radioProxy.test.mjs | 64 +++++++++++++++++++++++++++++ 4 files changed, 104 insertions(+), 15 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b24ac95ed..66b87d770 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,10 @@ # Changelog +- A Radio Browser mirror answering 204, 205 or 304, or a status outside + 200–599, no longer crashes the local dev/preview server. Null-body statuses + get an empty body; anything else that cannot become a response fails that + mirror so the next one is tried. + - Treat explicitly current, complete empty vessel coverage as a successful update, clearing obsolete contacts while retaining stale or incomplete snapshots. diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index 26d56e66b..9de0ebf31 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -1135,9 +1135,12 @@ CCTV catalog acquisition, source normalization and frame/media delivery now live in separate modules. Each CCTV factory owns its catalog and health state; its `sourceRoot` option resolves relative source files against the application root. Radio Browser station normalization, restricted outbound transport and directory -caching are separate modules. Node-only package entries expose both provider -factories. Routes, payloads, fallback behavior and existing dev/preview hook -registration are preserved; browser rendering is unchanged. +caching are separate modules. The pinned transport gives a mirror's 204, 205 or +304 reply an empty body and fails any reply outside 200–599 without throwing +outside its promise, so an unusual mirror status cannot crash the server. +Node-only package entries expose both provider factories. Routes, payloads, +fallback behavior and existing dev/preview hook registration are preserved; +browser rendering is unchanged. ## Terrain, traffic, fire and bike-share provider modules diff --git a/server/providers/radio/transport.js b/server/providers/radio/transport.js index 7bbe33bcd..c9922bb2f 100644 --- a/server/providers/radio/transport.js +++ b/server/providers/radio/transport.js @@ -1,6 +1,9 @@ import https from 'node:https'; import { Readable } from 'node:stream'; import { isNonGlobalIpv4 } from './stations.js'; + +const NULL_BODY_STATUSES = new Set([204, 205, 304]); + export function radioMirrorOrigin(value) { const hostname = String(value ?? '') .toLowerCase() @@ -124,19 +127,33 @@ export function fetchPinnedRadioResponse(url, options, addresses) { }, }, (response) => { - const headers = new Headers(); - for (const [name, value] of Object.entries(response.headers)) { - if (Array.isArray(value)) - value.forEach((item) => headers.append(name, item)); - else if (value !== undefined) headers.set(name, String(value)); + // Nothing thrown here may escape: this callback runs outside the + // promise, so an exception would be uncaught and crash the server. + try { + const status = response.statusCode; + // A `Response` only represents statuses up to 599; Node never hands + // this callback a 1xx. + if (status > 599) + throw new Error( + `Radio Browser returned unsupported status ${status}`, + ); + const headers = new Headers(); + for (const [name, value] of Object.entries(response.headers)) { + if (Array.isArray(value)) + value.forEach((item) => headers.append(name, item)); + else if (value !== undefined) headers.set(name, String(value)); + } + const nullBody = NULL_BODY_STATUSES.has(status); + const upstream = new Response( + nullBody ? null : Readable.toWeb(response), + { status, statusText: response.statusMessage || '', headers }, + ); + if (nullBody) response.resume(); + resolve(upstream); + } catch (error) { + response.destroy(); + reject(error); } - resolve( - new Response(Readable.toWeb(response), { - status: response.statusCode || 500, - statusText: response.statusMessage || '', - headers, - }), - ); }, ); request.on('error', reject); diff --git a/src/data/radioProxy.test.mjs b/src/data/radioProxy.test.mjs index 2f090b5f9..60463205b 100644 --- a/src/data/radioProxy.test.mjs +++ b/src/data/radioProxy.test.mjs @@ -1,5 +1,9 @@ import assert from 'node:assert/strict'; +import { EventEmitter } from 'node:events'; +import https from 'node:https'; +import { Readable } from 'node:stream'; import test from 'node:test'; +import { fetchPinnedRadioResponse } from '../../server/providers/radio/transport.js'; import { createRadioProxyMiddleware, isPublicRadioAddress, @@ -469,3 +473,63 @@ test('proxy rejects any hostname resolution containing a forbidden address befor } assert.equal(fetchCount, 0); }); + +// Answer every pinned request with `status`, invoking the response callback +// asynchronously as `https.request` does, outside the caller's promise. +// The returned promise rejects on an uncaught exception, so a regression fails +// the race at once instead of leaving the fetch pending. +function mockMirrorStatus(t, status, statusMessage = '') { + let crash; + const crashed = new Promise((_resolve, reject) => (crash = reject)); + const onUncaught = (error) => + crash(new Error(`uncaught exception: ${error.message}`)); + process.on('uncaughtException', onUncaught); + t.after(() => process.off('uncaughtException', onUncaught)); + t.mock.method(https, 'request', (_url, _options, callback) => { + const request = new EventEmitter(); + request.end = () => + setImmediate(() => + callback( + Object.assign(Readable.from([]), { + statusCode: status, + statusMessage, + headers: { 'content-type': 'application/json' }, + }), + ), + ); + return request; + }); + return crashed; +} + +function fetchMirror() { + return fetchPinnedRadioResponse( + new URL('https://de1.api.radio-browser.info/json/servers'), + { headers: {} }, + [{ address: '93.184.216.34', family: 4 }], + ); +} + +for (const status of [204, 205, 304]) { + test(`a mirror answering null-body status ${status} resolves with a null body instead of crashing`, async (t) => { + const crashed = mockMirrorStatus(t, status); + const response = await Promise.race([fetchMirror(), crashed]); + assert.equal(response.status, status); + assert.equal(response.body, null); + }); +} + +for (const status of [600, 999]) { + test(`a mirror answering out-of-range status ${status} rejects instead of crashing`, async (t) => { + const crashed = mockMirrorStatus(t, status); + await assert.rejects( + Promise.race([fetchMirror(), crashed]), + new RegExp(`unsupported status ${status}`), + ); + }); +} + +test('a mirror answering an invalid reason phrase rejects instead of crashing', async (t) => { + const crashed = mockMirrorStatus(t, 200, 'OK\x7f'); + await assert.rejects(Promise.race([fetchMirror(), crashed]), TypeError); +}); From afa9e763f211d224c014f506899707cc254f2da6 Mon Sep 17 00:00:00 2001 From: Jeff Blakely <7987380+jeffblakely@users.noreply.github.com> Date: Tue, 13 Oct 2026 10:06:41 -0400 Subject: [PATCH 06/13] test(bikeshare): check PBSC activation from real station coordinates Assert through computeInRangeCities that a camera over each system's outermost live-feed station activates it, including both Kona and Hilo for HIBike, and that HIBike stays off over Honolulu. Drop Pennsylvania from the changelog: Philadelphia's Indego is already registered. --- CHANGELOG.md | 2 +- src/layers/bikeshare/usPbscCities.test.mjs | 66 ++++++++++++++++++---- 2 files changed, 57 insertions(+), 11 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5fd137e89..eedaf06d8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,7 +2,7 @@ - Add Detroit (MoGo), Pittsburgh (POGOH), Aspen (WE-cycle) and Kona & Hilo (HIBike) to the GBFS bikeshare registry, the layer's first stations in - Michigan and Pennsylvania. + Michigan. - Treat explicitly current, complete empty vessel coverage as a successful update, clearing obsolete contacts while retaining stale or incomplete snapshots. diff --git a/src/layers/bikeshare/usPbscCities.test.mjs b/src/layers/bikeshare/usPbscCities.test.mjs index 4ff15c370..aba2d20ec 100644 --- a/src/layers/bikeshare/usPbscCities.test.mjs +++ b/src/layers/bikeshare/usPbscCities.test.mjs @@ -1,19 +1,48 @@ import assert from 'node:assert/strict'; import test from 'node:test'; import { CITY_BY_ID } from './registry.js'; +import { createModel } from './model.js'; +import { createViewport } from './viewport.js'; import { isAllowedGbfsHost, isAllowedGbfsPath } from '../../data/gbfsSource.js'; -import { CITY_RANGE_BASE_KM } from './policy.js'; -// Each city's PBSC host, and its farthest station from the registered centre -// as measured against the live feed (km), so a camera over any part of the -// system activates it. +// Each city's PBSC host, and the stations farthest from its registered centre +// in the live feed (checked 2026-10-13). HIBike's centre sits between its two +// towns, so it lists the outermost station in each. const CITIES = { - 'detroit-mogo': { host: 'detroit.publicbikesystem.net', provider: 'MoGo', farthestKm: 22.4 }, - 'pittsburgh-pogoh': { host: 'pittsburgh.publicbikesystem.net', provider: 'POGOH', farthestKm: 9.1 }, - 'aspen-we-cycle': { host: 'aspen.publicbikesystem.net', provider: 'WE-cycle', farthestKm: 42.6 }, - 'hawaii-island-hibike': { host: 'kona.publicbikesystem.net', provider: 'HIBike', farthestKm: 53.8 }, + 'detroit-mogo': { + host: 'detroit.publicbikesystem.net', + provider: 'MoGo', + stations: [{ name: 'Robina Ave & W Twelve Mile Rd', lat: 42.50288, lon: -83.18921 }], + }, + 'pittsburgh-pogoh': { + host: 'pittsburgh.publicbikesystem.net', + provider: 'POGOH', + stations: [{ name: 'Wilkinsburg Park & Ride', lat: 40.446951, lon: -79.888593 }], + }, + 'aspen-we-cycle': { + host: 'aspen.publicbikesystem.net', + provider: 'WE-cycle', + stations: [{ name: 'Cowen Dr | Hwy 133', lat: 39.413115, lon: -107.220634 }], + }, + 'hawaii-island-hibike': { + host: 'kona.publicbikesystem.net', + provider: 'HIBike', + stations: [ + { name: 'Kona Community Center', lat: 19.642932, lon: -156.004962 }, + { name: 'Kealoha Beach Park, Hilo', lat: 19.733317, lon: -155.029202 }, + ], + }, }; +// The production range check, with no viewer or fetches behind it. +const parts = { model: createModel({ state: {}, services: {}, parts: {}, source: null }) }; +const { computeInRangeCities } = createViewport({ + state: {}, + services: { render: { governorRequestRender() {} } }, + parts, + source: null, +}); + test('the added US PBSC systems are registered and readable through the proxy', () => { for (const [id, expected] of Object.entries(CITIES)) { const city = CITY_BY_ID.get(id); @@ -25,7 +54,24 @@ test('the added US PBSC systems are registered and readable through the proxy', assert.ok(isAllowedGbfsHost(url.hostname), `${id} host ${url.hostname}`); assert.ok(isAllowedGbfsPath(url.pathname), `${id} path ${url.pathname}`); } - const radius = Math.max(CITY_RANGE_BASE_KM, city.loadRadiusKm); - assert.ok(radius > expected.farthestKm, `${id} radius ${radius} covers its farthest station`); } }); + +test('a camera over each outermost station activates its system', () => { + for (const [id, expected] of Object.entries(CITIES)) { + for (const station of expected.stations) { + assert.ok( + computeInRangeCities({ lat: station.lat, lon: station.lon }).has(id), + `${station.name} activates ${id}`, + ); + } + } +}); + +test('HIBike stays inactive away from Hawaii Island', () => { + // Honolulu is about 270 km from the HIBike centre. + assert.equal( + computeInRangeCities({ lat: 21.3069, lon: -157.8583 }).has('hawaii-island-hibike'), + false, + ); +}); From 360aa74f50c72cc485734978977bf0884de06282 Mon Sep 17 00:00:00 2001 From: Rudy Celekli Date: Wed, 14 Oct 2026 20:13:52 -0400 Subject: [PATCH 07/13] fix(imagery): paginate raw CMR pages within the record budget Rebased onto current main with review follow-ups folded in. Co-Authored-By: Claude Sonnet 5.5 --- CHANGELOG.md | 2 + docs/CURRENT-STATE.md | 2 + src/layers/recentImagery/catalog.js | 18 ++++-- .../cmr-raw-page-budget.test.mjs | 56 +++++++++++++++++++ 4 files changed, 72 insertions(+), 6 deletions(-) create mode 100644 src/layers/recentImagery/cmr-raw-page-budget.test.mjs diff --git a/CHANGELOG.md b/CHANGELOG.md index a770fbd35..9b4d82063 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -33,6 +33,8 @@ - Director timeline seeks cross the dateline along the short longitude arc. +- Recent Imagery continues past malformed records in full catalog pages and respects the requested record limit. + - Treat explicitly current, complete empty vessel coverage as a successful update, clearing obsolete contacts while retaining stale or incomplete snapshots. diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index 1f794a5b5..b65f90ca0 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -4449,6 +4449,8 @@ are omitted rather than framing the wrong part of the globe. ### Recent Imagery (September 2026) +CMR pagination counts raw rows when checking page completion and the record budget; catalog truncation means upstream hits exceed rows examined, so discarded malformed rows do not imply unseen imagery. + - The layer sits in the Data Layers panel's Cameras group beside Cameras (CCTV); Mapped ALPR Cameras moved to the Infrastructure group to make room. - DATA ▸ **Recent Imagery** (`recent-imagery`, `src/layers/recentImagery/`) lists the last 30 UTC days of imagery for a selected box and drapes the days you pick: HLS S30 (Sentinel-2) and L30 (Landsat 8/9) at 30 m, and the VIIRS NOAA-21 daily overview at 250 m. Keyless and browser-direct: NASA CMR for which days have HLS granules in the box (`catalog.js`, both collections concurrently, paged with `CMR-Search-After` up to 2,000 records each, `truncated` against CMR `hits`), GIBS WMTS for tiles, Worldview Snapshots for thumbnails and exports. Worldview's `Data-Present` header is the only availability signal: GIBS answers an empty tile with HTTP 200, and the JPEG overview would drape black, so a day confirmed empty never drapes. - **Box.** SELECT BOX (`src/ui/imageryBoxTool.js`) claims the pointer like Draw, borrows the stock click actions and freezes the camera only mid-drag; USE VIEW takes the camera rectangle; `boxFromPinAt` grows a 10 km box. Boxes must be ≤ 1,000 km a side, on one side of the dateline and inside the Web-Mercator limit (`validateBox`). A refusal (`boxError`, including the tool's own via `reportBoxRefusal`) stays at the top of the panel until a box succeeds; a view over the cap is refused with its size, never shrunk. An oversized box or view (`Box is … km wide · limit 1,000 km`, `View is … wide · …`) sets the hint to `Zoom in or draw a smaller box` and enables ZOOM IN: `zoomToFit()` flies top-down (1.2 s) to the refused box's centre, or the ground under the canvas centre for a view, at the height whose view is about 400 km wide for the canvas aspect and the camera's vertical FOV (`fitViewHeightM`, clamped 5–400 km), and returns that height. diff --git a/src/layers/recentImagery/catalog.js b/src/layers/recentImagery/catalog.js index 96b314690..fe9a7a021 100644 --- a/src/layers/recentImagery/catalog.js +++ b/src/layers/recentImagery/catalog.js @@ -40,6 +40,7 @@ export async function fetchCmrPages({ }) { const granules = []; let hits = 0; + let readCount = 0; let cursor = null; for (;;) { const init = { signal }; @@ -47,19 +48,24 @@ export async function fetchCmrPages({ const response = await fetchImpl(url, init); if (!response?.ok) throw new Error(`HTTP ${response?.status ?? 'error'} from CMR`); - const page = parseCmrUmm(await response.json(), product); - granules.push(...page.granules); + const json = await response.json(); + const rawCount = Array.isArray(json?.items) ? json.items.length : 0; + const page = parseCmrUmm(json, product); + readCount += rawCount; + granules.push( + ...page.granules.slice(0, Math.max(0, maxRecords - granules.length)), + ); hits = Math.max(hits, page.hits); cursor = response.headers?.get?.(CMR_SEARCH_AFTER) ?? null; if ( !cursor || - page.granules.length < CMR_PAGE_SIZE || - granules.length >= maxRecords || - granules.length >= hits + rawCount < CMR_PAGE_SIZE || + readCount >= maxRecords || + readCount >= hits ) break; } - return { granules, truncated: hits > granules.length }; + return { granules, truncated: hits > readCount }; } function finiteOrNull(value) { diff --git a/src/layers/recentImagery/cmr-raw-page-budget.test.mjs b/src/layers/recentImagery/cmr-raw-page-budget.test.mjs new file mode 100644 index 000000000..4925e7bec --- /dev/null +++ b/src/layers/recentImagery/cmr-raw-page-budget.test.mjs @@ -0,0 +1,56 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { fetchCmrPages } from './catalog.js'; +const item = (id) => ({ + meta: { 'concept-id': id }, + umm: { + TemporalExtent: { + RangeDateTime: { BeginningDateTime: '2026-09-18T12:00:00Z' }, + }, + }, +}); +test('discarded rows do not turn a full CMR page into a final page', async () => { + let calls = 0; + const result = await fetchCmrPages({ + product: 'S30', + url: 'fixture', + fetchImpl: async () => { + calls++; + const items = + calls === 1 + ? Array.from({ length: 200 }, (_, i) => + i === 0 ? {} : item(String(i)), + ) + : [item('last')]; + return Response.json( + { hits: 201, items }, + { headers: { 'CMR-Search-After': `cursor-${calls}` } }, + ); + }, + }); + assert.equal(calls, 2); + assert.equal(result.granules.length, 200); + assert.equal(result.granules.at(-1).id, 'last'); + assert.equal(result.truncated, false); +}); +test('a non-page-multiple limit never returns more admitted records than its budget', async () => { + let calls = 0; + const result = await fetchCmrPages({ + product: 'S30', + url: 'fixture', + maxRecords: 250, + fetchImpl: async () => { + calls++; + return Response.json( + { + hits: 1000, + items: Array.from({ length: 200 }, (_, i) => item(`${calls}:${i}`)), + }, + { headers: { 'CMR-Search-After': `cursor-${calls}` } }, + ); + }, + }); + assert.equal(calls, 2); + assert.equal(result.granules.length, 250); + assert.equal(result.truncated, true); +}); From 3533b323b0823e7c12e0d3de9a82d8f97467069c Mon Sep 17 00:00:00 2001 From: Said Turksever Date: Thu, 15 Oct 2026 14:28:37 +0100 Subject: [PATCH 08/13] fix(radio): keep the || 500 fallback for a missing mirror status Node always sets statusCode on client responses, but keeping the fallback stops a missing status from reading as 200, as main did. Co-Authored-By: Claude Opus 5.5 --- server/providers/radio/transport.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/server/providers/radio/transport.js b/server/providers/radio/transport.js index c9922bb2f..c4b7f2b5b 100644 --- a/server/providers/radio/transport.js +++ b/server/providers/radio/transport.js @@ -130,7 +130,7 @@ export function fetchPinnedRadioResponse(url, options, addresses) { // Nothing thrown here may escape: this callback runs outside the // promise, so an exception would be uncaught and crash the server. try { - const status = response.statusCode; + const status = response.statusCode || 500; // A `Response` only represents statuses up to 599; Node never hands // this callback a 1xx. if (status > 599) From 26694380fb1136c8b3e49fbc41396b2585727ab5 Mon Sep 17 00:00:00 2001 From: Said Turksever Date: Thu, 15 Oct 2026 14:36:06 +0100 Subject: [PATCH 09/13] test(track): stub the geocode proxy routes, not the browser call Reverse geocoding now goes to /api/google/reverse-geocode on the app server, so the harness stub for maps.googleapis.com never matched and a keyed run would reach Google live. Answer both proxy routes with ZERO_RESULTS instead. Co-Authored-By: Claude Opus 5.5 --- scripts/track-regression.mjs | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/scripts/track-regression.mjs b/scripts/track-regression.mjs index 84aa2494e..756daccfc 100644 --- a/scripts/track-regression.mjs +++ b/scripts/track-regression.mjs @@ -443,7 +443,10 @@ async function main() { // for nearby names. Answer both with empty results: the entity-context // assertions are about the SELECTED contact, and a live lookup would // make the run non-hermetic (and bill the owner's Google quota). - if (url.hostname === 'maps.googleapis.com' && url.pathname.startsWith('/maps/api/geocode')) { + if ( + isAppRequest && + (url.pathname === '/api/google/reverse-geocode' || url.pathname === '/api/google/geocode') + ) { return Promise.resolve(jsonResponse({ status: 'ZERO_RESULTS', results: [] })); } if (isAppRequest && url.pathname === '/api/google/nearby-places') { From 64988f3037e33e46c336f6a914e83411821729fb Mon Sep 17 00:00:00 2001 From: Said Turksever Date: Thu, 15 Oct 2026 15:43:38 +0100 Subject: [PATCH 10/13] fix(imagery): mark CMR results truncated when the record cap drops valid granules Co-Authored-By: Claude Opus 5.5 --- src/layers/recentImagery/catalog.js | 13 ++++-- .../cmr-raw-page-budget.test.mjs | 42 +++++++++++++++++++ 2 files changed, 51 insertions(+), 4 deletions(-) diff --git a/src/layers/recentImagery/catalog.js b/src/layers/recentImagery/catalog.js index fe9a7a021..14964b1d2 100644 --- a/src/layers/recentImagery/catalog.js +++ b/src/layers/recentImagery/catalog.js @@ -27,7 +27,8 @@ const CMR_SEARCH_AFTER = 'CMR-Search-After'; /** * Read one product's granules across CMR pages, sending back each response's * `CMR-Search-After` cursor until a short page, a missing cursor or the - * record cap ends the walk. `truncated` is measured against CMR's `hits`. + * record cap ends the walk. `truncated` means CMR's `hits` exceed the rows + * read, or the cap dropped valid granules from the last page read. * @param {{ product: string, url: string, fetchImpl: typeof fetch, signal?: AbortSignal, maxRecords?: number }} request * @returns {Promise<{ granules: Array, truncated: boolean }>} */ @@ -41,6 +42,7 @@ export async function fetchCmrPages({ const granules = []; let hits = 0; let readCount = 0; + let capped = 0; let cursor = null; for (;;) { const init = { signal }; @@ -52,9 +54,12 @@ export async function fetchCmrPages({ const rawCount = Array.isArray(json?.items) ? json.items.length : 0; const page = parseCmrUmm(json, product); readCount += rawCount; - granules.push( - ...page.granules.slice(0, Math.max(0, maxRecords - granules.length)), + const admitted = page.granules.slice( + 0, + Math.max(0, maxRecords - granules.length), ); + granules.push(...admitted); + capped += page.granules.length - admitted.length; hits = Math.max(hits, page.hits); cursor = response.headers?.get?.(CMR_SEARCH_AFTER) ?? null; if ( @@ -65,7 +70,7 @@ export async function fetchCmrPages({ ) break; } - return { granules, truncated: hits > readCount }; + return { granules, truncated: hits > readCount || capped > 0 }; } function finiteOrNull(value) { diff --git a/src/layers/recentImagery/cmr-raw-page-budget.test.mjs b/src/layers/recentImagery/cmr-raw-page-budget.test.mjs index 4925e7bec..c1709b9b2 100644 --- a/src/layers/recentImagery/cmr-raw-page-budget.test.mjs +++ b/src/layers/recentImagery/cmr-raw-page-budget.test.mjs @@ -54,3 +54,45 @@ test('a non-page-multiple limit never returns more admitted records than its bud assert.equal(result.granules.length, 250); assert.equal(result.truncated, true); }); +test('valid records dropped by the cap mark the result truncated even when every hit was read', async () => { + let calls = 0; + const result = await fetchCmrPages({ + product: 'S30', + url: 'fixture', + maxRecords: 250, + fetchImpl: async () => { + calls++; + return Response.json( + { + hits: 400, + items: Array.from({ length: 200 }, (_, i) => item(`${calls}:${i}`)), + }, + { headers: { 'CMR-Search-After': `cursor-${calls}` } }, + ); + }, + }); + assert.equal(calls, 2); + assert.equal(result.granules.length, 250); + assert.equal(result.truncated, true); +}); +test('malformed rows discarded under the cap leave a fully examined result untruncated', async () => { + let calls = 0; + const result = await fetchCmrPages({ + product: 'S30', + url: 'fixture', + maxRecords: 250, + fetchImpl: async () => { + calls++; + const items = Array.from({ length: 200 }, (_, i) => + calls === 2 && i >= 50 ? {} : item(`${calls}:${i}`), + ); + return Response.json( + { hits: 400, items }, + { headers: { 'CMR-Search-After': `cursor-${calls}` } }, + ); + }, + }); + assert.equal(calls, 2); + assert.equal(result.granules.length, 250); + assert.equal(result.truncated, false); +}); From c42b072e99e3a27dc6613ad1c52fa4931c00ba5a Mon Sep 17 00:00:00 2001 From: Sameh Khamis Date: Thu, 15 Oct 2026 10:48:38 -0700 Subject: [PATCH 11/13] fix(overpass): tag caches by the sorted upstream set and cover installations Uses a full SHA-256 of the sorted instance list, so reordering the same instances for failover keeps the cache. Adds the installation-cache repoint test (tagged, reordered, repointed, untagged, unconfigured) and the changelog and current-state notes. --- CHANGELOG.md | 3 +++ docs/CURRENT-STATE.md | 4 ++++ server/providers/overpass/constants.js | 8 ++++---- src/data/installationProxy.test.mjs | 28 ++++++++++++++++++++++++++ 4 files changed, 39 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 95eff69e9..a56c13575 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,8 @@ # Changelog +- Overpass and military-installation disk caches no longer serve answers from a + previously configured `OVERPASS_UPSTREAMS` instance after it is repointed. + - A Radio Browser mirror answering 204, 205 or 304, or a status outside 200–599, no longer crashes the local dev/preview server. Null-body statuses get an empty body; anything else that cannot become a response fails that diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index 1b9e3e845..60f7bfbbf 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -4132,6 +4132,10 @@ for, resolved lazily after env loading. It replaces the empty default list; there is no built-in fallback. Local/private instances are allowed. The stable `gods-eye-view/…` User-Agent is unchanged. URLs never appear in response headers or upstream errors, including old cached endpoint metadata. +Disk cache entries for both routes carry a SHA-256 tag of the configured +instance set (order does not matter) and are served only while it matches, so +repointing `OVERPASS_UPSTREAMS` refetches instead of serving another instance's +answers; untagged entries stop serving once an instance is configured. The shared transport guards `/api/overpass` and `/api/military-installations`. With no configured upstream, each route serves existing cache data (including diff --git a/server/providers/overpass/constants.js b/server/providers/overpass/constants.js index 2a0d4a3ef..6cfd7257a 100644 --- a/server/providers/overpass/constants.js +++ b/server/providers/overpass/constants.js @@ -40,10 +40,10 @@ let upstreamMemo = { raw: null, endpoints: [], tag: 'none' }; */ function upstreamTag(endpoints) { if (!endpoints.length) return 'none'; - return createHash('sha1') - .update(endpoints.join('\n')) - .digest('hex') - .slice(0, 12); + // Sorted, so reordering the same instances for failover keeps the cache. + return createHash('sha256') + .update([...endpoints].sort().join('\n')) + .digest('hex'); } /** Resolve after environment loading. Public Overpass instances are not used by default. */ diff --git a/src/data/installationProxy.test.mjs b/src/data/installationProxy.test.mjs index 08b14de99..308ca9dba 100644 --- a/src/data/installationProxy.test.mjs +++ b/src/data/installationProxy.test.mjs @@ -26,6 +26,7 @@ import { validMilitaryInstallationBox, writeMilitaryInstallationDisk, } from '../../vite.config.js'; +import { resolveOverpassUpstreamTag } from '../../server/providers/overpass/constants.js'; const DAY_MS = 86_400_000; test('installation failure reasons disclose no raw upstream error and do not guess overload', () => { @@ -274,3 +275,30 @@ test('an unwritable cache directory degrades to a miss instead of throwing', asy await fsp.rm(dir, { recursive: true, force: true }); } }); + +test('an installation entry is not served after OVERPASS_UPSTREAMS is repointed', async (t) => { + const prior = process.env.OVERPASS_UPSTREAMS; + t.after(() => { + if (prior === undefined) delete process.env.OVERPASS_UPSTREAMS; + else process.env.OVERPASS_UPSTREAMS = prior; + }); + const dir = tempCacheDir(); + const key = '30.250,-97.750,30.300,-97.700'; + try { + process.env.OVERPASS_UPSTREAMS = 'https://a.example/api/interpreter,https://b.example/api/interpreter'; + const tag = resolveOverpassUpstreamTag(); + await writeMilitaryInstallationDisk(key, { ...entry(Date.now()), upstreams: tag }, dir); + assert.ok(await readMilitaryInstallationDisk(key, TTL_MS, dir), 'its own entry is a hit'); + process.env.OVERPASS_UPSTREAMS = 'https://b.example/api/interpreter,https://a.example/api/interpreter'; + assert.equal(resolveOverpassUpstreamTag(), tag, 'reordering the same instances keeps the tag'); + process.env.OVERPASS_UPSTREAMS = 'https://c.example/api/interpreter'; + assert.equal(await readMilitaryInstallationDisk(key, TTL_MS, dir), null, 'fresh read refused'); + assert.equal(await readMilitaryInstallationDisk(key, Infinity, dir), null, 'stale read refused'); + await writeMilitaryInstallationDisk('legacy', entry(Date.now()), dir); + assert.equal(await readMilitaryInstallationDisk('legacy', Infinity, dir), null, 'untagged entry refused'); + delete process.env.OVERPASS_UPSTREAMS; + assert.ok(await readMilitaryInstallationDisk(key, Infinity, dir), 'nothing configured accepts any entry'); + } finally { + fs.rmSync(dir, { recursive: true, force: true }); + } +}); From 86b7dc8d3c922c562751f82245e6f7f999496250 Mon Sep 17 00:00:00 2001 From: NotProniss <71785359+NotProniss@users.noreply.github.com> Date: Mon, 12 Oct 2026 19:12:27 +0000 Subject: [PATCH 12/13] feat(weather): add satellite cloud filter strength slider Blend the existing infrared brightness key from Full (0%) to Clouds only (100%), preserving Soft/Vivid as independent opacity presets. Commit filter changes on slider release and retain legacy share-link mode codes. --- CHANGELOG.md | 6 ++ docs/CURRENT-STATE.md | 12 ++- src/data/layerState.js | 23 +++++- src/data/layerState.test.mjs | 64 +++++++++++++-- src/layers/weather/index.js | 45 ++++++++--- src/layers/weather/infraredAlpha.js | 17 +++- src/layers/weather/infraredAlpha.test.mjs | 15 ++++ src/layers/weather/infraredImage.test.mjs | 9 +++ src/layers/weather/weather.test.mjs | 70 +++++++++++++++-- src/ui/railCardBlocks.js | 94 ++++++++++++++++++++++- src/ui/railCards.test.mjs | 75 ++++++++++++++++++ src/ui/styles/weather.css | 29 +++++++ 12 files changed, 421 insertions(+), 38 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index dee202852..56f5c65a6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -147,6 +147,12 @@ ## [Unreleased] +- 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 + avoid restaging imagery for every drag event. Filter percentages round-trip in + share links; legacy mode codes retain their old endpoint meanings. + - Flights and Military Flights highlight emergency transponder broadcasts in fleet billboards, 3D models and Cockpit contacts. Colors update when a status starts or clears. Emergency squawks outrank ADS-B priority statuses; GEV diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index 60f7bfbbf..629d1416f 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -256,8 +256,13 @@ Clouds only applies a soft brightness ramp to decoded pixels once, using Cesium's sRGB-to-linear conversion (`channel ** 2.2`) and smoothstep from 0.40 to 0.70. The old 0.55 threshold is the ramp midpoint. RGB and source alpha are preserved in Full image mode; both modes use the chosen layer opacity. This -is a display filter, not a cloud mask. Satellite share links retain the display -mode; observation history remains transient and links open latest. +is a display filter, not a cloud mask. The cloud-filter slider is a continuous +0–100% strength: 0% is Full, 100% is the existing Clouds only key, and +intermediate values blend their alpha. The value commits on slider release to +avoid restaging for every pointer event. Satellite share links carry the filter +strength; legacy `f`/`a` values keep their Clouds only / Full meanings. Soft and +Vivid remain layer-opacity presets (40%/70%); history remains transient and links +open latest. Global infrared fetches one capped 4 MiB, 2048×1024 mosaic per frame and decodes and processes it before staging. Globe hosts crop that canvas into 256 px tiles to maximum level 3 on a geographic 2×1 root grid bounded to the manifest extent, @@ -302,7 +307,8 @@ release commits immediately. Latest selects the newest frame per product, and history labels the actual displayed frame as synced or nearest. Missing-frame messages use each product's eligibility gap (30 minutes, or three hours for the global mosaic). Wind shows forecast valid and issue times and does not follow -history. Satellite clouds uses Clouds only / Full image configuration labels. +history. Satellite clouds uses Clouds only / Full controls, a continuous cloud +filter slider and Soft/Vivid opacity presets in its right-rail settings. Left weather rows retain only the toggle and one status line with a source tooltip. Configuration and the cyclone Storms list live in the cards, as do the diff --git a/src/data/layerState.js b/src/data/layerState.js index 1db10494b..d7175fd67 100644 --- a/src/data/layerState.js +++ b/src/data/layerState.js @@ -296,6 +296,24 @@ function imageryPinOption(key, token) { }); } +function satelliteFilterOption() { + const percentage = boundedIntegerOption('infrared', 'i', 100, { + min: 0, + max: 100, + }); + return Object.freeze({ + ...percentage, + normalize: (value) => + value === 'filtered' + ? 100 + : value === 'full' + ? 0 + : percentage.normalize(value), + decode: (value) => + value === 'f' ? 100 : value === 'a' ? 0 : percentage.decode(value), + }); +} + const OPTION_GROUPS = Object.freeze({ traffic: Object.freeze([ enumOption('roadMode', 'r', null, ['tomtom', 'osm', 'hybrid'], { @@ -317,10 +335,7 @@ const OPTION_GROUPS = Object.freeze({ }), ]), 'weather-satellite': Object.freeze([ - enumOption('infrared', 'i', 'filtered', ['filtered', 'full'], { - filtered: 'f', - full: 'a', - }), + satelliteFilterOption(), enumOption('opacity', 'o', 'strong', ['light', 'strong'], { light: 'l', strong: 's', diff --git a/src/data/layerState.test.mjs b/src/data/layerState.test.mjs index 30f251b7b..631e5dfec 100644 --- a/src/data/layerState.test.mjs +++ b/src/data/layerState.test.mjs @@ -2422,23 +2422,74 @@ test('wind appearance shares round trip while old links retain weather defaults' assert.deepEqual(invalid.options.wind, defaults); }); -test('observed weather round trips product and opacity without persisting historical playback', () => { +test('observed weather round trips product and presets without persisting historical playback', () => { const state = normalizeLayerState({ enabledLayerIds: ['weather-radar', 'weather-satellite'], options: { 'weather-radar': { opacity: 'light', play: true }, - 'weather-satellite': { product: 'clouds', opacity: 'light', step: -1 }, + 'weather-satellite': { + product: 'clouds', + opacity: 'light', + infrared: 'filtered', + step: -1, + }, }, }); const params = new URLSearchParams(encode(state)); const decoded = decodeLayerStateParams(params); assert.deepEqual(decoded, state); assert.equal(state.options['weather-satellite'].product, 'clouds'); + assert.equal(state.options['weather-satellite'].opacity, 'light'); + assert.equal(state.options['weather-satellite'].infrared, 100); assert.equal(Object.hasOwn(state.options['weather-radar'], 'play'), false); }); -test('satellite infrared display mode round trips and invalid or absent values use filtered', () => { - for (const infrared of ['full', 'filtered', undefined, 'invalid']) { +test('satellite filter strength percentages round-trip and legacy mode codes remain readable', () => { + const state = normalizeLayerState({ + enabledLayerIds: ['weather-satellite'], + options: { 'weather-satellite': { infrared: 53, opacity: 'light' } }, + }); + const params = encodeLayerStateParams( + new URLSearchParams([['v', '2']]), + state, + ); + assert.deepEqual(decodeLayerStateParams(params), state); + assert.equal(state.options['weather-satellite'].infrared, 53); + assert.equal(state.options['weather-satellite'].opacity, 'light'); + for (const [legacy, expected] of [ + ['f', 100], + ['a', 0], + ]) { + const decoded = decodeLayerStateParams( + new URLSearchParams(`v=2&l=o&lo=o.i.${legacy}`), + ); + assert.equal(decoded.options['weather-satellite'].infrared, expected); + } + for (const [legacy, expected] of [ + ['filtered', 100], + ['full', 0], + ]) { + const normalized = normalizeLayerState({ + enabledLayerIds: ['weather-satellite'], + options: { 'weather-satellite': { infrared: legacy } }, + }); + assert.equal(normalized.options['weather-satellite'].infrared, expected); + } + const invalid = normalizeLayerState({ + enabledLayerIds: ['weather-satellite'], + options: { 'weather-satellite': { infrared: 101 } }, + }); + assert.equal(invalid.options['weather-satellite'].infrared, 100); +}); + +test('satellite cloud filter round trips and invalid or absent values default to Clouds only', () => { + for (const [infrared, expected] of [ + ['full', 0], + ['filtered', 100], + [53, 53], + [undefined, 100], + ['invalid', 100], + ]) { const state = normalizeLayerState({ enabledLayerIds: ['weather-satellite'], options: { @@ -2454,10 +2505,7 @@ test('satellite infrared display mode round trips and invalid or absent values u decodeLayerStateParams(new URLSearchParams(encode(state))), state, ); - assert.equal( - state.options['weather-satellite'].infrared, - infrared === 'full' ? 'full' : 'filtered', - ); + assert.equal(state.options['weather-satellite'].infrared, expected); assert.equal( Object.hasOwn(state.options['weather-satellite'], 'step'), false, diff --git a/src/layers/weather/index.js b/src/layers/weather/index.js index 8d6d331eb..beada1ef6 100644 --- a/src/layers/weather/index.js +++ b/src/layers/weather/index.js @@ -50,7 +50,7 @@ export function createWeatherLayer({ const satellite = !radar && !lightning; let product = radar ? 'radar' : lightning ? 'lightning' : 'clouds-regional'; let opacity = 'strong'; - let infrared = 'filtered'; + let infrared = 100; let viewer = null, rendering = null, manifest = null, @@ -382,11 +382,20 @@ export function createWeatherLayer({ } }, setParams(params = {}) { + const nextInfrared = satellite + ? params.infrared === 'full' + ? 0 + : params.infrared === 'filtered' + ? 100 + : params.infrared + : null; const infraredChanged = satellite && - ['filtered', 'full'].includes(params.infrared) && - params.infrared !== infrared; - if (infraredChanged) infrared = params.infrared; + Number.isInteger(nextInfrared) && + nextInfrared >= 0 && + nextInfrared <= 100 && + nextInfrared !== infrared; + if (infraredChanged) infrared = nextInfrared; if (['light', 'strong'].includes(params.opacity)) { opacity = params.opacity; rendering?.setAlpha(opacity === 'light' ? 0.4 : satellite ? 0.7 : 0.8); @@ -594,17 +603,17 @@ export function createWeatherLayer({ { id: 'filtered', label: 'Clouds only', - active: infrared === 'filtered', - params: { infrared: 'filtered' }, + active: infrared === 100, + params: { infrared: 100 }, title: - 'Dim everything but the bright, cold cloud tops; a brightness filter, not a cloud mask', + 'Apply the full cloud brightness key; 100% filter strength', }, { id: 'full', label: 'Full', - active: infrared === 'full', - params: { infrared: 'full' }, - title: 'The complete infrared image at the chosen opacity', + active: infrared === 0, + params: { infrared: 0 }, + title: 'Show the complete infrared image; 0% filter strength', }, ] : []), @@ -656,7 +665,21 @@ export function createWeatherLayer({ { id: 'image', label: 'IMAGE', - chips: controls.chips.filter(({ params }) => params.infrared), + chips: controls.chips.filter( + ({ params }) => params.infrared !== undefined, + ), + range: { + id: 'cloud-filter', + param: 'infrared', + min: 0, + max: 100, + step: 1, + value: infrared, + label: 'FILTER', + unit: '%', + ariaLabel: 'Cloud filter strength', + commitOnChange: true, + }, }, ] : []), diff --git a/src/layers/weather/infraredAlpha.js b/src/layers/weather/infraredAlpha.js index 007c7486f..f42bda603 100644 --- a/src/layers/weather/infraredAlpha.js +++ b/src/layers/weather/infraredAlpha.js @@ -1,10 +1,18 @@ export const INFRARED_ALPHA_T0 = 0.4; export const INFRARED_ALPHA_T1 = 0.7; -/** Return display pixels without changing RGB or the source RGBA bytes. */ -export function infraredAlpha(rgba, mode = 'filtered') { +/** Return display pixels without changing RGB or the source RGBA bytes. + * `strength` is 0–100: zero is the full image and 100 is the cloud key. + */ +export function infraredAlpha(rgba, strength = 'filtered') { const pixels = new Uint8ClampedArray(rgba); - if (mode === 'full') return pixels; + const amount = + strength === 'full' + ? 0 + : strength === 'filtered' || !Number.isFinite(strength) + ? 1 + : Math.max(0, Math.min(100, strength)) / 100; + if (amount === 0) return pixels; for (let i = 0; i < pixels.length; i += 4) { // Match Cesium's czm_srgbToLinear conversion in draped imagery. const linearMax = @@ -17,7 +25,8 @@ export function infraredAlpha(rgba, mode = 'filtered') { (INFRARED_ALPHA_T1 - INFRARED_ALPHA_T0), ), ); - pixels[i + 3] = rgba[i + 3] * t * t * (3 - 2 * t); + const filtered = t * t * (3 - 2 * t); + pixels[i + 3] = rgba[i + 3] * (1 - amount + filtered * amount); } return pixels; } diff --git a/src/layers/weather/infraredAlpha.test.mjs b/src/layers/weather/infraredAlpha.test.mjs index f0ee95516..950271a9d 100644 --- a/src/layers/weather/infraredAlpha.test.mjs +++ b/src/layers/weather/infraredAlpha.test.mjs @@ -40,6 +40,21 @@ test('filtered infrared preserves RGB and input bytes, scales existing alpha, an ); }); +test('filter strength interpolates between the full infrared image and the cloud key', () => { + const pixels = new Uint8ClampedArray([ + 0, 0, 0, 255, 194, 0, 0, 200, 12, 50, 217, 128, + ]); + assert.deepEqual([...infraredAlpha(pixels, 0)], [...pixels]); + assert.deepEqual( + infraredAlpha(pixels, 100), + infraredAlpha(pixels, 'filtered'), + ); + assert.deepEqual( + [...infraredAlpha(pixels, 50)], + [0, 0, 0, 128, 194, 0, 0, 149, 12, 50, 217, 128], + ); +}); + test('full infrared preserves every channel including partial and zero alpha', () => { const pixels = new Uint8ClampedArray([ 12, 50, 194, 80, 0, 0, 0, 255, 255, 200, 0, 0, diff --git a/src/layers/weather/infraredImage.test.mjs b/src/layers/weather/infraredImage.test.mjs index f3937abc3..61e532564 100644 --- a/src/layers/weather/infraredImage.test.mjs +++ b/src/layers/weather/infraredImage.test.mjs @@ -32,6 +32,15 @@ test('decoded pixels are processed once on a same-sized canvas', () => { assert.equal(output.height, 1); assert.equal(output.image, image); assert.deepEqual([...output.pixels], [194, 0, 0, 98]); + assert.deepEqual( + [...processInfraredImage(image, 50, canvas).pixels], + [194, 0, 0, 149], + 'half-strength filter retains a midpoint haze', + ); + assert.deepEqual( + [...processInfraredImage(image, 0, canvas).pixels], + [194, 0, 0, 200], + ); assert.deepEqual( [...processInfraredImage(image, 'full', canvas).pixels], [194, 0, 0, 200], diff --git a/src/layers/weather/weather.test.mjs b/src/layers/weather/weather.test.mjs index a80ad6560..1a4ba5177 100644 --- a/src/layers/weather/weather.test.mjs +++ b/src/layers/weather/weather.test.mjs @@ -444,11 +444,14 @@ function layerHarness({ let active = null; let clearCount = 0; let hidden = false; + let alpha = null; const rendering = { rehome() { active?.finish(false); }, - setAlpha() {}, + setAlpha(value) { + alpha = value; + }, setHidden(value) { hidden = value; if (value) active?.finish(false); @@ -514,6 +517,7 @@ function layerHarness({ documentRef, moveEnd, clears: () => clearCount, + getAlpha: () => alpha, }; } @@ -1591,8 +1595,8 @@ test('satellite mode chips restage history without changing time or latest-follo await flush(); h.layer.setParams({ infrared: 'full' }); assert.equal(h.stages[2].time, times[1]); - assert.equal(h.stages[2].infrared, 'full'); - assert.equal(h.layer.getParams().infrared, 'full'); + assert.equal(h.stages[2].infrared, 0); + assert.equal(h.layer.getParams().infrared, 0); assert.deepEqual( h.layer .getRowControls() @@ -1605,11 +1609,59 @@ test('satellite mode chips restage history without changing time or latest-follo assert.equal(h.layer.getDiagnostics().followLatest, false); assert.equal(h.layer.getDiagnostics().time, times[1]); h.layer.setParams({ infrared: 'invalid' }); - assert.equal(h.layer.getParams().infrared, 'full'); + assert.equal(h.layer.getParams().infrared, 0); assert.equal(h.stages.length, 3); h.layer.destroy(); }); +test('satellite cloud filter spans Full to Clouds only without changing layer opacity', () => { + const h = layerHarness({ id: 'weather-satellite' }); + assert.equal(h.layer.getParams().infrared, 100); + assert.equal(h.layer.getParams().opacity, 'strong'); + assert.equal(h.getAlpha(), 0.7); + const setting = h.layer + .getRowControls() + .summary.settings.find(({ id }) => id === 'image'); + assert.equal(setting.range.param, 'infrared'); + assert.equal(setting.range.value, 100); + assert.equal(setting.range.min, 0); + assert.equal(setting.range.max, 100); + assert.equal(setting.range.step, 1); + assert.equal(setting.range.commitOnChange, true); + + const full = h.layer.getRowControls().chips.find(({ id }) => id === 'full'); + h.layer.setParams(full.params); + assert.equal(h.layer.getParams().infrared, 0); + assert.equal(h.getAlpha(), 0.7); + assert.equal( + h.layer.getRowControls().chips.find(({ id }) => id === 'full').active, + true, + ); + + h.layer.setParams({ infrared: 53 }); + assert.equal(h.layer.getParams().infrared, 53); + assert.equal(h.getAlpha(), 0.7, 'filter strength is not overall opacity'); + assert.equal( + h.layer.getRowControls().summary.settings.find(({ id }) => id === 'image') + .range.value, + 53, + ); + h.layer.setParams({ opacity: 'light' }); + assert.equal(h.layer.getParams().opacity, 'light'); + assert.equal( + h.getAlpha(), + 0.4, + 'Soft remains the independent opacity preset', + ); + h.layer.setParams({ infrared: 101 }); + assert.equal( + h.layer.getParams().infrared, + 53, + 'invalid filter values are ignored', + ); + h.layer.destroy(); +}); + test('a rapid third selection waits for retirement before installing, and clear cancels that wait', async () => { for (const clear of [false, true]) { const h = renderingHarness(); @@ -1914,8 +1966,14 @@ test('observed descriptors keep configuration only and label satellite clouds by ); assert.equal( controls.chips.find(({ id }) => id === 'filtered').title, - 'Dim everything but the bright, cold cloud tops; a brightness filter, not a cloud mask', + 'Apply the full cloud brightness key; 100% filter strength', ); + const imageSetting = controls.summary.settings.find( + ({ id }) => id === 'image', + ); + assert.equal(imageSetting.range.param, 'infrared'); + assert.equal(imageSetting.range.value, 100); + assert.equal(imageSetting.range.commitOnChange, true); h.layer.setParams({ product: 'clouds' }); assert.equal( h.layer.getRowControls().summary.coverage, @@ -2196,7 +2254,7 @@ test('layer warms the next advertised observation only after a successful playin warmed.map(({ time }) => time), [times[0]], ); - assert.deepEqual(warmed[0].options, { infrared: 'filtered' }); + assert.deepEqual(warmed[0].options, { infrared: 100 }); const next = clock.setTarget(times[0]); await flush(); h.stages.at(-1).finish(); diff --git a/src/ui/railCardBlocks.js b/src/ui/railCardBlocks.js index a2132f7ad..b58e1134f 100644 --- a/src/ui/railCardBlocks.js +++ b/src/ui/railCardBlocks.js @@ -30,6 +30,7 @@ export function createRailCardBlocks({ container, cardId, onParams }) { }; const dispose = (block) => { block.removeClick?.(); + block.removeInput?.(); block.children?.destroy(); block.root.remove(); }; @@ -74,6 +75,64 @@ export function createRailCardBlocks({ container, cardId, onParams }) { ); block.chips = make('div', 'rail-card-chips', root); block.chips.setAttribute('role', 'group'); + block.rangeRow = make('div', 'rail-card-setting-range-row', root); + block.rangeLabel = make( + 'span', + 'rail-card-setting-range-label', + block.rangeRow, + ); + block.rangeInput = make( + 'input', + 'rail-card-setting-range', + block.rangeRow, + ); + block.rangeInput.type = 'range'; + block.rangeValue = make( + 'span', + 'rail-card-setting-range-value', + block.rangeRow, + ); + const rangeParams = () => { + const range = block.props.range; + const value = Number(block.rangeInput.value); + if ( + !range?.param || + range.disabled || + !Number.isFinite(value) || + value < Number(range.min) || + value > Number(range.max) + ) + return null; + return { params: { [range.param]: value } }; + }; + const onInput = () => { + const range = block.props.range; + const value = Number(block.rangeInput.value); + const params = rangeParams(); + if (!params) return; + set( + block.rangeValue, + 'textContent', + `${value}${range.unit || ''}`, + ); + attribute( + block.rangeInput, + 'aria-valuetext', + `${value}${range.unit || ''}`, + ); + if (!range.commitOnChange) dispatch(params); + }; + const onChange = () => { + if (!block.props.range?.commitOnChange) return; + const params = rangeParams(); + if (params) dispatch(params); + }; + block.rangeInput.addEventListener('input', onInput); + block.rangeInput.addEventListener('change', onChange); + block.removeInput = () => { + block.rangeInput.removeEventListener('input', onInput); + block.rangeInput.removeEventListener('change', onChange); + }; bind((event) => { const button = event.target?.closest?.('[data-chip-id]'); dispatch( @@ -124,17 +183,48 @@ export function createRailCardBlocks({ container, cardId, onParams }) { blocks.set(props.id, block); } block.props = props; - if (props.type === 'settings') + if (props.type === 'settings') { block.children.update( (props.settings || []).map((setting) => ({ ...setting, type: 'setting', })), ); - else if (props.type === 'setting') { + } else if (props.type === 'setting') { set(block.label, 'textContent', props.label); attribute(block.chips, 'aria-label', props.label); syncChipGroup(block.chips, props.chips); + const range = props.range; + set(block.rangeRow, 'hidden', !range); + if (range) { + const min = Number(range.min); + const max = Number(range.max); + const step = Number(range.step); + const candidate = Number(range.value); + const value = Math.min( + max, + Math.max(min, Number.isFinite(candidate) ? candidate : min), + ); + set(block.rangeLabel, 'textContent', range.label || ''); + set(block.rangeLabel, 'hidden', !range.label); + set(block.rangeInput.dataset, 'rangeId', range.id || props.id); + set(block.rangeInput, 'min', String(min)); + set(block.rangeInput, 'max', String(max)); + set(block.rangeInput, 'step', String(step)); + set(block.rangeInput, 'value', String(value)); + set(block.rangeInput, 'disabled', Boolean(range.disabled)); + attribute( + block.rangeInput, + 'aria-label', + range.ariaLabel || props.label, + ); + attribute( + block.rangeInput, + 'aria-valuetext', + `${value}${range.unit || ''}`, + ); + set(block.rangeValue, 'textContent', `${value}${range.unit || ''}`); + } } else if (props.type === 'list') syncRowList(block.root, props.list); else if (props.type === 'result') { set(block.label, 'textContent', props.label); diff --git a/src/ui/railCards.test.mjs b/src/ui/railCards.test.mjs index e465e3cc7..48fe93964 100644 --- a/src/ui/railCards.test.mjs +++ b/src/ui/railCards.test.mjs @@ -119,6 +119,81 @@ test('footer buttons retain focus and current callbacks; links use safe new tabs button.click(); assert.deepEqual(calls, [2]); }); +test('setting ranges stay below presets and commit expensive filters on release', () => { + const f = railFixture(); + const calls = []; + const view = createRailCards({ + ...f, + onParams: (...args) => calls.push(args), + }); + const model = (value) => ({ + ...card, + blocks: [ + { + id: 'settings', + type: 'settings', + settings: [ + { + id: 'image', + label: 'IMAGE', + chips: [ + { + id: 'clouds-only', + label: 'Clouds only', + params: { infrared: 100 }, + }, + { id: 'full', label: 'Full', params: { infrared: 0 } }, + ], + range: { + id: 'cloud-filter', + param: 'infrared', + min: 0, + max: 100, + step: 1, + value, + label: 'FILTER', + unit: '%', + ariaLabel: 'Cloud filter strength', + commitOnChange: true, + }, + }, + ], + }, + ], + }); + view.update([model(100)]); + const setting = f.find((node) => node.dataset.blockId === 'image'); + assert.equal(setting.children[1].className, 'rail-card-chips'); + assert.equal(setting.children[2].className, 'rail-card-setting-range-row'); + assert.equal(setting.children[2].children[0].textContent, 'FILTER'); + const slider = f.find((node) => node.dataset.rangeId === 'cloud-filter'); + assert.equal(slider.tagName, 'INPUT'); + assert.equal(slider.type, 'range'); + assert.equal(slider.min, '0'); + assert.equal(slider.max, '100'); + assert.equal(slider.step, '1'); + assert.equal(slider.value, '100'); + assert.equal(slider.getAttribute('aria-label'), 'Cloud filter strength'); + slider.focus(); + slider.value = '53'; + slider.dispatchEvent(new Event('input')); + assert.equal(calls.length, 0, 'expensive image work waits until release'); + assert.equal( + f.find((node) => node.className === 'rail-card-setting-range-value') + .textContent, + '53%', + ); + slider.dispatchEvent(new Event('change')); + assert.deepEqual(calls.at(-1), ['a', { infrared: 53 }]); + view.update([model(53)]); + assert.equal(slider.value, '53'); + assert.equal(f.document.activeElement, slider); + view.destroy(); + slider.value = '41'; + slider.dispatchEvent(new Event('change')); + assert.equal(calls.length, 1, 'destroy removes the range listener'); +}); + test('legend validates colors, preserves the freezing anchor and ignores CSSOM normalization', () => { let writes = 0; const f = railFixture(() => writes++); diff --git a/src/ui/styles/weather.css b/src/ui/styles/weather.css index 3e83561fb..02ee75ea6 100644 --- a/src/ui/styles/weather.css +++ b/src/ui/styles/weather.css @@ -327,6 +327,35 @@ body.recording-mode .gev-wind-inspection-marker { .rail-card-setting .rail-card-chips { margin-top: 0; } +.rail-card-setting-range-row { + display: flex; + grid-column: 2; + align-items: center; + gap: 7px; + min-width: 0; +} +.rail-card-setting-range-label { + flex: 0 0 6ch; + color: var(--text-dim); + font: 8px var(--font-mono); + white-space: nowrap; +} +.rail-card-setting-range { + width: 100%; + min-width: 0; + accent-color: var(--accent); + cursor: pointer; +} +.rail-card-setting-range:focus-visible { + outline: 2px solid var(--text-primary); + outline-offset: 3px; +} +.rail-card-setting-range-value { + min-width: 3ch; + color: var(--text-primary); + font: 9px var(--font-mono); + text-align: right; +} .rail-card-result { display: grid; gap: 4px; From 8959b5b71abe5927846c4fb42dac47fad74c0325 Mon Sep 17 00:00:00 2001 From: Mazeyar Moeini Feizabadi Date: Thu, 15 Oct 2026 19:32:28 +0200 Subject: [PATCH 13/13] fix(weather): preserve filter drag previews across panel refreshes --- CHANGELOG.md | 4 +++- docs/CURRENT-STATE.md | 4 +++- src/ui/railCardBlocks.js | 15 ++++++++++++++- src/ui/railCards.test.mjs | 15 +++++++++++++++ src/ui/styles/weather.css | 3 ++- 5 files changed, 37 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 56f5c65a6..351b768d4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -151,7 +151,9 @@ 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 avoid restaging imagery for every drag event. Filter percentages round-trip in - share links; legacy mode codes retain their old endpoint meanings. + share links; legacy mode codes retain their old endpoint meanings. Background + panel refreshes preserve the pending drag value until release. Contributed by + [NotProniss](https://github.com/NotProniss) (#1084). - Flights and Military Flights highlight emergency transponder broadcasts in fleet billboards, 3D models and Cockpit contacts. Colors update when a status diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index 629d1416f..09b065630 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -259,7 +259,9 @@ preserved in Full image mode; both modes use the chosen layer opacity. This is a display filter, not a cloud mask. The cloud-filter slider is a continuous 0–100% strength: 0% is Full, 100% is the existing Clouds only key, and intermediate values blend their alpha. The value commits on slider release to -avoid restaging for every pointer event. Satellite share links carry the filter +avoid restaging for every pointer event. Status refreshes retain the uncommitted +drag preview; changed model values or disabled controls supersede it. +Satellite share links carry the filter strength; legacy `f`/`a` values keep their Clouds only / Full meanings. Soft and Vivid remain layer-opacity presets (40%/70%); history remains transient and links open latest. diff --git a/src/ui/railCardBlocks.js b/src/ui/railCardBlocks.js index b58e1134f..1726860f9 100644 --- a/src/ui/railCardBlocks.js +++ b/src/ui/railCardBlocks.js @@ -110,6 +110,7 @@ export function createRailCardBlocks({ container, cardId, onParams }) { const value = Number(block.rangeInput.value); const params = rangeParams(); if (!params) return; + if (range.commitOnChange) block.rangeDraft = { range, value }; set( block.rangeValue, 'textContent', @@ -125,6 +126,7 @@ export function createRailCardBlocks({ container, cardId, onParams }) { const onChange = () => { if (!block.props.range?.commitOnChange) return; const params = rangeParams(); + block.rangeDraft = null; if (params) dispatch(params); }; block.rangeInput.addEventListener('input', onInput); @@ -196,11 +198,22 @@ export function createRailCardBlocks({ container, cardId, onParams }) { syncChipGroup(block.chips, props.chips); const range = props.range; set(block.rangeRow, 'hidden', !range); + const draft = block.rangeDraft; + if ( + draft && + (!range || + range.disabled || + !range.commitOnChange || + ['id', 'param', 'min', 'max', 'step', 'value'].some( + (key) => range[key] !== draft.range[key], + )) + ) + block.rangeDraft = null; if (range) { const min = Number(range.min); const max = Number(range.max); const step = Number(range.step); - const candidate = Number(range.value); + const candidate = Number(block.rangeDraft?.value ?? range.value); const value = Math.min( max, Math.max(min, Number.isFinite(candidate) ? candidate : min), diff --git a/src/ui/railCards.test.mjs b/src/ui/railCards.test.mjs index 48fe93964..74629bebb 100644 --- a/src/ui/railCards.test.mjs +++ b/src/ui/railCards.test.mjs @@ -183,11 +183,26 @@ test('setting ranges stay below presets and commit expensive filters on release' .textContent, '53%', ); + view.update([model(100)]); + assert.equal(slider.value, '53', 'a status refresh retains the pending drag'); + assert.equal( + slider.getAttribute('aria-valuetext'), + '53%', + 'the accessible preview also survives a refresh', + ); slider.dispatchEvent(new Event('change')); assert.deepEqual(calls.at(-1), ['a', { infrared: 53 }]); view.update([model(53)]); assert.equal(slider.value, '53'); assert.equal(f.document.activeElement, slider); + slider.value = '41'; + slider.dispatchEvent(new Event('input')); + view.update([model(70)]); + assert.equal( + slider.value, + '70', + 'an authoritative value supersedes the draft', + ); view.destroy(); slider.value = '41'; slider.dispatchEvent(new Event('change')); diff --git a/src/ui/styles/weather.css b/src/ui/styles/weather.css index 02ee75ea6..0540fac0a 100644 --- a/src/ui/styles/weather.css +++ b/src/ui/styles/weather.css @@ -351,7 +351,8 @@ body.recording-mode .gev-wind-inspection-marker { outline-offset: 3px; } .rail-card-setting-range-value { - min-width: 3ch; + flex: 0 0 4ch; + white-space: nowrap; color: var(--text-primary); font: 9px var(--font-mono); text-align: right;