Merge branch 'main' into feat/bikeshare-norway

This commit is contained in:
Said Turksever authored and GitHub committed 2026-10-15 20:04:57 +01:00
commit 09bdc105a6
21 files changed
+755 -45

No files matched your search

+15
View File
@@ -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
@@ -45,6 +48,10 @@
- Regional articles retain unknown publication times when compact source dates do not identify a real calendar instant.
- Add Detroit (MoGo), Pittsburgh (POGOH), Aspen (WE-cycle) and Kona & Hilo
(HIBike) to the GBFS bikeshare registry, the layer's first stations in
Michigan.
- Treat explicitly current, complete empty vessel coverage as a successful update,
clearing obsolete contacts while retaining stale or incomplete snapshots.
@@ -140,6 +147,14 @@
## [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. 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
starts or clears. Emergency squawks outrank ADS-B priority statuses; GEV
+15 -3
View File
@@ -256,8 +256,15 @@ 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. 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.
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 +309,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
@@ -4141,6 +4149,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
+11 -2
View File
@@ -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);
@@ -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 {
+6
View File
@@ -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 {
+59 -4
View File
@@ -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';
// 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. */
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,
};
+4
View File
@@ -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 {
+28
View File
@@ -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 });
}
});
+19 -4
View File
@@ -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',
+56 -8
View File
@@ -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,
+50
View File
@@ -89,6 +89,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',
@@ -0,0 +1,77 @@
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';
// 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',
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);
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}`);
}
}
});
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,
);
});
+34 -11
View File
@@ -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,
},
},
]
: []),
+13 -4
View File
@@ -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;
}
+15
View File
@@ -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,
@@ -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],
+64 -6
View File
@@ -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();
+51 -1
View File
@@ -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);
}
});
+105 -2
View File
@@ -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,66 @@ 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;
if (range.commitOnChange) block.rangeDraft = { range, value };
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();
block.rangeDraft = null;
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 +185,59 @@ 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);
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(block.rangeDraft?.value ?? 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);
+90
View File
@@ -119,6 +119,96 @@ 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%',
);
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'));
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++);
+30
View File
@@ -327,6 +327,36 @@ 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 {
flex: 0 0 4ch;
white-space: nowrap;
color: var(--text-primary);
font: 9px var(--font-mono);
text-align: right;
}
.rail-card-result {
display: grid;
gap: 4px;