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

-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() {