From 7485b16ded0158b804f1ac07da7e25b71eae0d5d Mon Sep 17 00:00:00 2001 From: daikaginza Date: Mon, 5 Oct 2026 17:12:41 +0700 Subject: [PATCH 1/6] 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 2/6] 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 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 3/6] 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 c42b072e99e3a27dc6613ad1c52fa4931c00ba5a Mon Sep 17 00:00:00 2001 From: Sameh Khamis Date: Thu, 15 Oct 2026 10:48:38 -0700 Subject: [PATCH 4/6] 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 5/6] 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 6/6] 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;