Merge upstream main into pr/panoramax

CHANGELOG: kept both Unreleased entries (satellite cloud filter #1084 and
Panoramax).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
Said TurkseverandClaude Opus 5.5 committed 2026-10-15 20:07:26 +01:00
commit 56b113397d
39 files changed
+1498 -118

No files matched your search

+50
View File
@@ -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',
@@ -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,
);
});
+18 -7
View File
@@ -27,7 +27,8 @@ const CMR_SEARCH_AFTER = 'CMR-Search-After';
/**
* Read one product's granules across CMR pages, sending back each response's
* `CMR-Search-After` cursor until a short page, a missing cursor or the
* record cap ends the walk. `truncated` is measured against CMR's `hits`.
* record cap ends the walk. `truncated` means CMR's `hits` exceed the rows
* read, or the cap dropped valid granules from the last page read.
* @param {{ product: string, url: string, fetchImpl: typeof fetch, signal?: AbortSignal, maxRecords?: number }} request
* @returns {Promise<{ granules: Array<object>, truncated: boolean }>}
*/
@@ -40,6 +41,8 @@ export async function fetchCmrPages({
}) {
const granules = [];
let hits = 0;
let readCount = 0;
let capped = 0;
let cursor = null;
for (;;) {
const init = { signal };
@@ -47,19 +50,27 @@ export async function fetchCmrPages({
const response = await fetchImpl(url, init);
if (!response?.ok)
throw new Error(`HTTP ${response?.status ?? 'error'} from CMR`);
const page = parseCmrUmm(await response.json(), product);
granules.push(...page.granules);
const json = await response.json();
const rawCount = Array.isArray(json?.items) ? json.items.length : 0;
const page = parseCmrUmm(json, product);
readCount += rawCount;
const admitted = page.granules.slice(
0,
Math.max(0, maxRecords - granules.length),
);
granules.push(...admitted);
capped += page.granules.length - admitted.length;
hits = Math.max(hits, page.hits);
cursor = response.headers?.get?.(CMR_SEARCH_AFTER) ?? null;
if (
!cursor ||
page.granules.length < CMR_PAGE_SIZE ||
granules.length >= maxRecords ||
granules.length >= hits
rawCount < CMR_PAGE_SIZE ||
readCount >= maxRecords ||
readCount >= hits
)
break;
}
return { granules, truncated: hits > granules.length };
return { granules, truncated: hits > readCount || capped > 0 };
}
function finiteOrNull(value) {
@@ -0,0 +1,98 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { fetchCmrPages } from './catalog.js';
const item = (id) => ({
meta: { 'concept-id': id },
umm: {
TemporalExtent: {
RangeDateTime: { BeginningDateTime: '2026-09-18T12:00:00Z' },
},
},
});
test('discarded rows do not turn a full CMR page into a final page', async () => {
let calls = 0;
const result = await fetchCmrPages({
product: 'S30',
url: 'fixture',
fetchImpl: async () => {
calls++;
const items =
calls === 1
? Array.from({ length: 200 }, (_, i) =>
i === 0 ? {} : item(String(i)),
)
: [item('last')];
return Response.json(
{ hits: 201, items },
{ headers: { 'CMR-Search-After': `cursor-${calls}` } },
);
},
});
assert.equal(calls, 2);
assert.equal(result.granules.length, 200);
assert.equal(result.granules.at(-1).id, 'last');
assert.equal(result.truncated, false);
});
test('a non-page-multiple limit never returns more admitted records than its budget', async () => {
let calls = 0;
const result = await fetchCmrPages({
product: 'S30',
url: 'fixture',
maxRecords: 250,
fetchImpl: async () => {
calls++;
return Response.json(
{
hits: 1000,
items: Array.from({ length: 200 }, (_, i) => item(`${calls}:${i}`)),
},
{ headers: { 'CMR-Search-After': `cursor-${calls}` } },
);
},
});
assert.equal(calls, 2);
assert.equal(result.granules.length, 250);
assert.equal(result.truncated, true);
});
test('valid records dropped by the cap mark the result truncated even when every hit was read', async () => {
let calls = 0;
const result = await fetchCmrPages({
product: 'S30',
url: 'fixture',
maxRecords: 250,
fetchImpl: async () => {
calls++;
return Response.json(
{
hits: 400,
items: Array.from({ length: 200 }, (_, i) => item(`${calls}:${i}`)),
},
{ headers: { 'CMR-Search-After': `cursor-${calls}` } },
);
},
});
assert.equal(calls, 2);
assert.equal(result.granules.length, 250);
assert.equal(result.truncated, true);
});
test('malformed rows discarded under the cap leave a fully examined result untruncated', async () => {
let calls = 0;
const result = await fetchCmrPages({
product: 'S30',
url: 'fixture',
maxRecords: 250,
fetchImpl: async () => {
calls++;
const items = Array.from({ length: 200 }, (_, i) =>
calls === 2 && i >= 50 ? {} : item(`${calls}:${i}`),
);
return Response.json(
{ hits: 400, items },
{ headers: { 'CMR-Search-After': `cursor-${calls}` } },
);
},
});
assert.equal(calls, 2);
assert.equal(result.granules.length, 250);
assert.equal(result.truncated, false);
});
+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();