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

+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;