mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-04 02:14:01 +00:00
Merge pull request #1084 from NotProniss/feat/satellite-cloud-filter-strength
feat(weather): add satellite cloud filter strength slider
This commit is contained in:
12 files changed
+454
-38
No files matched your search
@@ -147,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
|
||||
|
||||
+11
-3
@@ -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
|
||||
|
||||
+19
-4
@@ -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',
|
||||
|
||||
@@ -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,
|
||||
|
||||
+34
-11
@@ -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,
|
||||
},
|
||||
},
|
||||
]
|
||||
: []),
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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],
|
||||
|
||||
@@ -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();
|
||||
|
||||
+105
-2
@@ -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);
|
||||
|
||||
@@ -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++);
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in new issue
Block a user