feat(display): render the style parameters from the display store

VisualSettings publishes the active style's parameter rows to the display
store, and the Display panel renders them with the shared row component;
a moved slider goes back through the store's setStyleParameter action.
The readout snaps a value to its step as the browser does, half-steps up
and an off-grid maximum down. A preset that resets the shown style's
uniforms republishes its rows, input for another style's parameters is
ignored, and a stopped owner takes its rows with it unless a newer owner
has published since. createStyleParameters is deprecated and builds its
rows the same way.
This commit is contained in:
Sameh Khamis committed 2026-11-01 14:28:02 -08:00
1 parent 3a750c9021
commit c3c69a91cd
14 files changed
+316 -80

No files matched your search

+5 -2
View File
@@ -77,8 +77,11 @@
which also carries the camera's tilt and compass heading. The recording
safe frame renders what `RecordingControls` publishes to the new
recording store. `syncScopeControls` (`./ui/display`),
`shouldHideCollapsedRightPanels` (`./ui/layout`) and
`bindClearLayersControl` (`./ui/layers`) are deprecated: the Display view,
`shouldHideCollapsedRightPanels` (`./ui/layout`),
`bindClearLayersControl` (`./ui/layers`) and `createStyleParameters`
(`./ui/input`; the Display panel renders the style parameters
VisualSettings publishes to the display store) are deprecated: the Display
view,
the rail and the globe actions view now own what they did. `./ui/input` imports Vue
(samehkhamis).
+3 -2
View File
@@ -170,8 +170,9 @@ a feature supplying only its accent.
renders `LayerRows` synchronously; keyed chips and list items keep focus
through the refresh their own click triggers, and clicks re-read the live
descriptor before acting), Display (in progress: `DisplayPanel` renders
the HUD, Sonar, Scope, Celestial, detection, bloom and sharpen from the
`gev-display` store, which VisualSettings publishes to, and the 3D
the HUD, Sonar, Scope, Celestial, detection, bloom, sharpen and the
active style's parameter sliders from the `gev-display` store, which
VisualSettings publishes to and answers the sliders through, and the 3D
aircraft group, the Draw controls and the imported file list, which
AircraftDisplay, the draw tool and the file import publish beside it;
the controls call DisplayBindings, the draw tool and the file import
+2 -1
View File
@@ -142,7 +142,8 @@
"src/ui/stores/globeActions.js",
"src/ui/views/panels/GlobeActions.vue",
"src/ui/views/panels/GlobeCompass.vue",
"src/ui/stores/recording.js"
"src/ui/stores/recording.js",
"src/ui/styleParameters.js"
],
"external": ["vue", "pinia"]
},
+3 -2
View File
@@ -224,13 +224,14 @@ test('parameterized Display presets keep one stable scroll owner', () => {
assert.match(css, /#pp-toggles:not\(\.collapsed\) > \.pp-panel-body > #param-slider-panel\.active\s*\{[\s\S]*?flex:\s*0 0 auto;[\s\S]*?max-height:\s*none;[\s\S]*?overflow-y:\s*visible;/);
assert.match(ui, /readDisplayScrollTop: \(\) =>\s*this\._displayPortalScrollRestoreOwner === 'standard'[\s\S]*?this\._standardDisplayScrollTop[\s\S]*?displayPanelScroller\(this\._ppToggles\)\?\.scrollTop \|\| 0/);
assert.match(rightRail, /scroller\.scrollTop = Math\.min\(displayScrollTop, maxScrollTop\);/);
// The panel's parameters change, and the rail lays out again.
assert.match(
ui,
/this\._sliderPanel\.classList\.remove\('active'\);\s*this\._scheduleRightPanelLayout\(\);/,
/this\._publishParameters\(null\);\s*this\._scheduleRightPanelLayout\(\);/,
);
assert.match(
ui,
/this\._sliderPanel\.classList\.add\('active'\);\s*this\._scheduleRightPanelLayout\(\);/,
/this\._publishParameters\(\{[\s\S]*?\}\);\s*this\._scheduleRightPanelLayout\(\);/,
);
assert.match(
css,
+3 -3
View File
@@ -641,8 +641,8 @@ test('every explicit visual control facade claims restore authority before mutat
const style = sourceBlock(' setStyle(styleName, {', ' _startTransition(styleName, fromValue, toValue) {');
assertClaimsBefore(style, 'this.activeStyle = styleName', 'setStyle');
const sliders = sourceBlock(' _updateSliderPanel(styleName, { reveal = false } = {}) {', ' _revealStyleParameters() {');
assertClaimsBefore(sliders, 'this.stages[styleName].uniforms[uName] = val', 'style parameter slider');
const sliders = sourceBlock(' setStyleParameter(name, value) {', ' _revealStyleParameters() {');
assertClaimsBefore(sliders, 'this.stages[parameters.style].uniforms[name] = value', 'style parameter slider');
});
test('public visual facades reject the complete invalid request before authority or mutation', () => {
@@ -794,7 +794,7 @@ test('visual input listeners are revoked before asynchronous UI teardown', () =>
assert.match(LocationNavigation.prototype.destroy.toString(), /this\._locationLookup\?\.destroy\(\)/);
assert.match(DisplayBindings.prototype.destroy.toString(), /this\._detachDisplayControls\?\.\(\)/);
assert.match(DisplayBindings.prototype.destroy.toString(), /this\._applicationShortcuts\?\.destroy\(\)/);
assert.match(VisualSettings.prototype.stop.toString(), /this\._styleParameters\?\.destroy\(\)/);
assert.match(VisualSettings.prototype.stop.toString(), /this\.store\.detach\(this\._parametersToken\)/);
assert.match(VisualSettings.prototype.stop.toString(), /this\._visualEffects\.stop\(\)/);
});
-1
View File
@@ -225,7 +225,6 @@ export class StyleManager extends ShellFacade {
},
elements: {
_ppToggles: this._ppToggles,
_sliderContainer: this._sliderContainer,
_sliderPanel: this._sliderPanel,
},
operations: {
+115
View File
@@ -460,3 +460,118 @@ test('the imported files render what the file import publishes and keep focus',
assert.equal(list.className, 'geo-import-list');
assert.equal(document.activeElement === $('geo-import-button'), true);
});
test('the parameters panel shows the active style’s rows and hands each change to its owner', async (t) => {
const { store, $ } = fixture(t);
const panel = $('param-slider-panel');
assert.equal(panel.classList.contains('active'), false);
assert.equal($('param-sliders').children.length, 0);
const changes = [];
store.connect({
setStyleParameter: (name, value) => changes.push([name, value]),
});
store.publishParameters({
style: 'noir',
rows: [
{
name: 'amount',
label: 'Amount',
min: 0,
max: 1,
step: '0.01',
digits: 2,
value: 0.25,
text: '0.25',
},
],
});
await nextTick();
assert.equal(panel.classList.contains('active'), true);
const row = $('param-sliders').querySelector('.param-slider-row');
assert.equal(row.querySelector('.param-label').textContent, 'Amount');
assert.equal(row.querySelector('.param-value').textContent, '0.25');
const slider = row.querySelector('input.param-slider');
assert.equal(slider.getAttribute('aria-label'), 'Amount');
assert.equal(slider.step, '0.01');
slider.value = '0.5';
slider.dispatchEvent(new Event('input', { bubbles: true }));
assert.deepEqual(changes, [['amount', 0.5]]);
store.publishParameters(null);
await nextTick();
assert.equal(panel.classList.contains('active'), false);
assert.equal($('param-sliders').children.length, 0);
});
test('VisualSettings publishes a style’s parameters and writes a moved slider’s uniform', async (t) => {
const { store, $ } = fixture(t);
const owner = Object.create(VisualSettings.prototype);
const renders = [];
let shared = 0;
Object.assign(owner, {
store,
_visualEffects: {
stages: {
noir: {
uniforms: { contrastAmt: 1.2, grainAmt: 0.5, vignetteAmt: 0.5 },
},
},
stop() {},
},
services: { governorRequestRender: (reason) => renders.push(reason) },
_parameters: null,
readShareLinks: () => null,
_scheduleRightPanelLayout() {},
_syncShareState: () => (shared += 1),
});
owner._parametersToken = store.connect({
setStyleParameter: (name, value) => owner.setStyleParameter(name, value),
});
owner._updateSliderPanel('noir');
await nextTick();
const labels = () =>
[...$('param-sliders').querySelectorAll('.param-label')].map(
(label) => label.textContent,
);
assert.deepEqual(labels(), ['Contrast', 'Grain', 'Vignette']);
const grain = $('param-sliders').querySelectorAll('input.param-slider')[1];
grain.value = '0.8';
grain.dispatchEvent(new Event('input', { bubbles: true }));
await nextTick();
assert.equal(owner.stages.noir.uniforms.grainAmt, 0.8);
assert.equal(
$('param-sliders').querySelectorAll('.param-value')[1].textContent,
'0.80',
);
assert.deepEqual(renders, ['style-param-slider']);
assert.equal(shared, 1);
// A name that isn't one of the shown style's parameters writes nothing.
owner.setStyleParameter('pixelation', 4);
assert.equal(owner.stages.noir.uniforms.pixelation, undefined);
assert.equal(shared, 1);
owner._updateSliderPanel('normal');
await nextTick();
assert.deepEqual(labels(), []);
assert.equal($('param-slider-panel').classList.contains('active'), false);
// A preset that resets the shown style's uniforms republishes its rows.
owner._updateSliderPanel('noir');
owner.stages.noir.uniforms.grainAmt = 0.3;
owner._refreshParameters();
await nextTick();
assert.equal(
$('param-sliders').querySelectorAll('.param-value')[1].textContent,
'0.30',
);
owner._lifetime = { destroy() {} };
owner.stop();
await nextTick();
assert.equal(store.parameters, null, 'the sliders go with their owner');
assert.equal($('param-slider-panel').classList.contains('active'), false);
assert.equal(store.act('setStyleParameter', 'grainAmt', 0.2), undefined);
assert.equal(owner.stages.noir.uniforms.grainAmt, 0.3, 'a stopped owner no longer answers');
// An older owner's stop leaves a newer owner's rows.
const newer = { style: 'noir', rows: [] };
owner._parameters = { style: 'noir', rows: [] };
store.publishParameters(newer);
owner.stop();
assert.equal(store.parameters, newer);
});
-1
View File
@@ -3,7 +3,6 @@ export function readShellElements(document = globalThis.document) {
return {
_rightPanelStack: document.getElementById('right-context-rail'),
_sliderPanel: document.getElementById('param-slider-panel'),
_sliderContainer: document.getElementById('param-sliders'),
_ppToggles: document.getElementById('pp-toggles'),
_bloomBtn: document.getElementById('bloom-toggle'),
_bloomSlider: document.getElementById('bloom-intensity-slider'),
-8
View File
@@ -413,14 +413,6 @@ export class ShellFacade {
this._visualSettings._detectionAllocationPreference = value;
}
get _styleParameters() {
return this._visualSettings._styleParameters;
}
set _styleParameters(value) {
this._visualSettings._styleParameters = value;
}
get _irBoostActive() {
return this._visualSettings._irBoostActive;
}
+13
View File
@@ -72,6 +72,17 @@ export const useDisplayStore = defineStore('gev-display', () => {
// buttons, the draw tool and the file import their own.
const { connect, detach, act } = createOwners();
/**
* The active style's parameter rows, `{style, rows}`; null while the style
* has none. Each row's change calls act('setStyleParameter', name, value).
*/
const parameters = shallowRef(null);
/** @param {?{style: string, rows: object[]}} next */
function publishParameters(next) {
parameters.value = next && markRaw(next);
}
/** The phone shell's tab bar; null until the shell first presents it. */
const phoneShell = shallowRef(null);
@@ -91,6 +102,8 @@ export const useDisplayStore = defineStore('gev-display', () => {
publishGeoImport,
style,
publishStyle,
parameters,
publishParameters,
phoneShell,
publishPhoneShell,
mapSource,
+69 -25
View File
@@ -13,9 +13,9 @@ const formatParam = (value, digits) =>
/**
* The generated rows: a label, the kit's slider and its value in the UI
* language. A render function, so the portable input package needs no
* single-file-component compiler.
* single-file-component compiler. The Display panel renders it too.
*/
const StyleParameterRows = {
export const StyleParameterRows = {
props: {
rows: { type: Array, required: true },
onInput: { type: Function, required: true },
@@ -39,6 +39,68 @@ const StyleParameterRows = {
},
};
/**
* A value as a range input holds it: clamped to its bounds and snapped to
* its step from the minimum, as the browser snaps the slider.
* @param {number} value
* @param {{min: number, max: number, step: string}} row
*/
export function snapToStep(value, { min, max, step }) {
const size = Number(step);
const decimals = (String(step).split('.')[1] || '').length;
// Rounded to nine places first, so a value halfway between two steps
// (1.15 from 1 by 0.1) rounds up as the browser does, not down through
// floating point error.
const count = (span) => Number((span / size).toFixed(9));
// A maximum off the step grid holds the last step below it, as the browser
// does.
const steps = Math.min(
Math.floor(count(max - min)),
Math.max(0, Math.round(count(Number(value) - min))),
);
return Number((min + steps * size).toFixed(decimals));
}
/**
* The parameter rows of a style's uniforms: each one's label, bounds, step,
* value and the value's text in the UI language.
* @param {object} uniforms The style's uniform metadata.
* @param {(name: string) => number} readValue A uniform's current value.
*/
export function styleParameterRows(uniforms, readValue) {
return Object.entries(uniforms).map(([name, metadata]) =>
withValue(
{
name,
label: t(metadata.label),
min: metadata.min,
max: metadata.max,
step: metadata.max <= 1 ? '0.01' : '0.1',
digits: metadata.max <= 1 ? 2 : 1,
},
readValue(name),
),
);
}
/**
* The rows with one parameter's value changed.
* @param {object[]} rows From {@link styleParameterRows}.
* @param {string} name
* @param {number} value
*/
export function withParameterValue(rows, name, value) {
return rows.map((row) => (row.name === name ? withValue(row, value) : row));
}
function withValue(row, value) {
return {
...row,
value,
text: formatParam(snapToStep(value, row), row.digits),
};
}
/**
* Own generated parameter rows and their input handling. Renders
* StyleParameterRows into `container` and patches it synchronously.
@@ -47,6 +109,9 @@ const StyleParameterRows = {
* @param {object} options
* @param {HTMLElement} options.container Container owned by this component.
* @returns {{render: Function, clear: Function, destroy: Function}}
* @deprecated The Display panel renders the rows VisualSettings publishes to
* the display store (styleParameterRows); this stays exported from
* `./ui/input` for hosts that imported it.
*/
export function createStyleParameters({ container }) {
let rows = [];
@@ -69,36 +134,15 @@ export function createStyleParameters({ container }) {
if (destroyed) return;
clear();
const current = generation;
rows = Object.entries(uniforms).map(([name, metadata]) => ({
name,
label: t(metadata.label),
min: metadata.min,
max: metadata.max,
step: metadata.max <= 1 ? '0.01' : '0.1',
digits: metadata.max <= 1 ? 2 : 1,
value: readValue(name),
text: '',
}));
rows = styleParameterRows(uniforms, readValue);
onInput = (name, value) => {
if (destroyed || current !== generation) return;
writeValue(name, value);
rows = rows.map((row) =>
row.name === name
? { ...row, value, text: formatParam(value, row.digits) }
: row,
);
rows = withParameterValue(rows, name, value);
paint();
onChange();
};
paint();
// The readout shows the value the slider holds once the browser has
// clamped and snapped it to its step.
const sliders = container.querySelectorAll('input.param-slider');
rows = rows.map((row, index) => ({
...row,
text: formatParam(parseFloat(sliders[index].value), row.digits),
}));
paint();
},
clear,
destroy() {
+47 -1
View File
@@ -3,7 +3,12 @@
import '../testSupport/registerDom.mjs';
import test from 'node:test';
import assert from 'node:assert/strict';
import { createStyleParameters } from './styleParameters.js';
import {
createStyleParameters,
snapToStep,
styleParameterRows,
withParameterValue,
} from './styleParameters.js';
function parameters(t, values = { amount: 0.25, scale: 2 }) {
document.body.replaceChildren();
@@ -120,3 +125,44 @@ test('destroy is final and cannot clear a subsequent owner’s DOM', (t) => {
assert.equal(f.container.firstElementChild === nextOwner, true);
assert.deepEqual(f.order, []);
});
test('a value snaps to its step from the minimum and stays in bounds', () => {
const row = { min: 0, max: 1, step: '0.01' };
assert.equal(snapToStep(0.256, row), 0.26);
assert.equal(snapToStep(1.4, row), 1);
assert.equal(snapToStep(-2, row), 0);
assert.equal(snapToStep(2.04, { min: 0.5, max: 4, step: '0.1' }), 2);
// Halfway between two steps rounds up, as the browser snaps it.
assert.equal(snapToStep(1.15, { min: 1, max: 2, step: '0.1' }), 1.2);
assert.equal(snapToStep(0.125, { min: 0, max: 1, step: '0.01' }), 0.13);
// A maximum off the grid snaps down to the last step within it.
assert.equal(snapToStep(1.06, { min: 0, max: 1.06, step: '0.1' }), 1);
assert.equal(snapToStep(9, { min: 0, max: 1.06, step: '0.1' }), 1);
});
test('rows carry each uniform, its step and its snapped value text', () => {
const rows = styleParameterRows(
{
amount: { label: 'Amount', min: 0, max: 1 },
scale: { label: 'Scale', min: 0.5, max: 4 },
},
(name) => ({ amount: 0.256, scale: 2.04 })[name],
);
assert.deepEqual(
rows.map(({ name, step, digits, value, text }) => [
name,
step,
digits,
value,
text,
]),
[
['amount', '0.01', 2, 0.256, '0.26'],
['scale', '0.1', 1, 2.04, '2.0'],
],
);
const next = withParameterValue(rows, 'scale', 3.33);
assert.equal(next[1].value, 3.33);
assert.equal(next[1].text, '3.3');
assert.equal(next[0], rows[0], 'the other rows are kept');
});
+6 -4
View File
@@ -3,6 +3,7 @@ import { storeToRefs } from 'pinia';
import { computed } from 'vue';
import { t } from '../../../i18n/index.js';
import { vPanelState } from '../../rail/panelState.js';
import { vClassToggle } from '../../rail/classToggle.js';
import PanelCollapseButton from '../../rail/PanelCollapseButton.vue';
import { vRangeValue } from '../../rail/rangeValue.js';
import UiSegmented from '../../kit/UiSegmented.vue';
@@ -11,13 +12,14 @@ import GeoImport from './GeoImport.vue';
import { useDisplayStore } from '../../stores/display.js';
import UiButton from '../../kit/UiButton.vue';
import UiSelect from '../../kit/UiSelect.js';
import { StyleParameterRows } from '../../styleParameters.js';
// The Display panel. VisualSettings presents the HUD, Sonar, Scope,
// Celestial, detection, bloom and sharpen, AircraftDisplay the 3D group and
// the draw tool its controls, and each publishes them; until they do, the
// panel shows its initial markup. GeoImport renders the file import.
const display = useDisplayStore();
const { view, models3d, draw } = storeToRefs(display);
const { view, models3d, draw, parameters } = storeToRefs(display);
// A control's change goes to its owner (DisplayBindings or the draw tool).
const act = (name, ...args) => display.act(name, ...args);
const pct = (value) => `${value}%`;
@@ -131,14 +133,14 @@ const allocationOptions = [
<span class="pp-slider-value" id="detection-opacity-value">{{ pct(detection?.outsideOpacityPct ?? 1) }}</span>
</div>
</div>
<!-- The one shared preset-parameter surface is inserted here at runtime. -->
<div id="param-slider-panel" v-panel-state="'param-slider-panel'">
<!-- The one shared preset-parameter surface: the active style's parameters, as VisualSettings publishes them. -->
<div id="param-slider-panel" v-panel-state="'param-slider-panel'" v-class-toggle="{ active: !!parameters }">
<div class="param-panel-header">
<span class="param-panel-title" data-i18n>PARAMETERS</span>
<span class="param-panel-divider"></span>
<PanelCollapseButton id="param-panel-collapse-btn" target="param-slider-panel" />
</div>
<div id="param-sliders"></div>
<div id="param-sliders"><StyleParameterRows v-if="parameters" :key="parameters.style" :rows="parameters.rows" :on-input="(name, value) => act('setStyleParameter', name, value)" /></div>
</div>
<div class="pp-toggle-group">
<!-- 3D aircraft is DEFAULT-ON in Proximity (owner directive 2026-08-22), so the
+50 -30
View File
@@ -12,7 +12,7 @@ import {
STYLE_PRESET_DEFAULTS,
MILITARY_DETECTION_PRESET,
} from './effects.js';
import { createStyleParameters } from './visualInput.js';
import { styleParameterRows, withParameterValue } from './styleParameters.js';
import {
BLOOM_SCALE_VERSION,
clampBloomIntensity,
@@ -180,6 +180,11 @@ export class VisualSettings {
readDisplayPortalActive,
});
this._lifetime = new UiLifetime();
// The active style's published parameter rows; null while it has none.
this._parameters = null;
this._parametersToken = store.connect({
setStyleParameter: (name, value) => this.setStyleParameter(name, value),
});
this._visualEffects = new VisualEffects({
viewer,
requestRender: services.governorRequestRender,
@@ -435,11 +440,7 @@ export class VisualSettings {
}
_revealCockpitStyleParameters({ openDisplay = false } = {}) {
if (
!this.cockpitView?.active ||
!this._sliderPanel?.classList.contains('active')
)
return;
if (!this.cockpitView?.active || !this._parameters) return;
if (openDisplay && !this._cockpitDisclosureOpen?.('display')) {
this._setCockpitDisclosure?.('display', true);
return;
@@ -763,6 +764,7 @@ export class VisualSettings {
governorRequestRender('style-param');
}
}
this._refreshParameters();
}
const bloomInput = preset.bloom || {};
@@ -1397,41 +1399,54 @@ export class VisualSettings {
this._syncShareState();
}
/** Publish the style's parameter rows, or none for a style without any. */
_updateSliderPanel(styleName, { reveal = false } = {}) {
const { governorRequestRender } = this.services;
this._styleParameters ||= createStyleParameters({
container: this._sliderContainer,
});
this._styleParameters.clear();
const shader = STYLES[styleName];
if (!shader || !shader.uniforms || styleName === 'normal') {
this._sliderPanel.classList.remove('active');
this._publishParameters(null);
this._scheduleRightPanelLayout();
return;
}
this._styleParameters.render({
uniforms: shader.uniforms,
readValue: (uName) => this.stages[styleName].uniforms[uName],
writeValue: (uName, val) => {
this.shareLinkManager?.claimRestoreLane?.('visual');
this.stages[styleName].uniforms[uName] = val;
},
onChange: () => {
// Uniform writes need an explicit render under the idle governor.
governorRequestRender('style-param-slider');
this._syncShareState();
},
this._publishParameters({
style: styleName,
rows: styleParameterRows(
shader.uniforms,
(name) => this.stages[styleName].uniforms[name],
),
});
this._sliderPanel.classList.add('active');
this._scheduleRightPanelLayout();
if (reveal) this._revealStyleParameters();
}
/** Publish the shown style's rows again from its uniforms as they stand. */
_refreshParameters() {
const style = this._parameters?.style;
if (style) this._updateSliderPanel(style);
}
_publishParameters(next) {
this._parameters = next;
this.store.publishParameters(next);
}
/** A parameter slider moved: write the active style's uniform. */
setStyleParameter(name, value) {
const parameters = this._parameters;
// Only the shown style's own parameters.
if (!parameters?.rows.some((row) => row.name === name)) return;
this.shareLinkManager?.claimRestoreLane?.('visual');
this.stages[parameters.style].uniforms[name] = value;
this._publishParameters({
...parameters,
rows: withParameterValue(parameters.rows, name, value),
});
// Uniform writes need an explicit render under the idle governor.
this.services.governorRequestRender('style-param-slider');
this._syncShareState();
}
_revealStyleParameters() {
if (!this._sliderPanel?.classList.contains('active')) return;
if (!this._parameters) return;
if (this._cockpitDisplayPortalActive) return;
this._presentPanel(this._sliderPanel, { collapsed: false });
this.setPanelCollapsed('pp-toggles', false, { explicit: true });
@@ -1574,7 +1589,12 @@ export class VisualSettings {
}
stop() {
this._lifetime.destroy();
this._styleParameters?.destroy();
if (this._parametersToken) this.store.detach(this._parametersToken);
this._parametersToken = null;
// The sliders go with their owner, unless a newer owner has published
// its own since.
if (this._parameters && this.store.parameters === this._parameters)
this._publishParameters(null);
this._visualEffects.stop();
}
releaseIrBoost() {