Merge pull request #1084 from NotProniss/feat/satellite-cloud-filter-strength

feat(weather): add satellite cloud filter strength slider
This commit is contained in:
Mazeyar Moeini Feizabadi authored and GitHub committed 2026-10-15 21:04:03 +02:00
commit 1c3dae39f2
12 files changed
+454 -38

No files matched your search

+8
View File
@@ -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
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
+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,
+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();
+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;