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 which also carries the camera's tilt and compass heading. The recording
safe frame renders what `RecordingControls` publishes to the new safe frame renders what `RecordingControls` publishes to the new
recording store. `syncScopeControls` (`./ui/display`), recording store. `syncScopeControls` (`./ui/display`),
`shouldHideCollapsedRightPanels` (`./ui/layout`) and `shouldHideCollapsedRightPanels` (`./ui/layout`),
`bindClearLayersControl` (`./ui/layers`) are deprecated: the Display view, `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 the rail and the globe actions view now own what they did. `./ui/input` imports Vue
(samehkhamis). (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 renders `LayerRows` synchronously; keyed chips and list items keep focus
through the refresh their own click triggers, and clicks re-read the live through the refresh their own click triggers, and clicks re-read the live
descriptor before acting), Display (in progress: `DisplayPanel` renders descriptor before acting), Display (in progress: `DisplayPanel` renders
the HUD, Sonar, Scope, Celestial, detection, bloom and sharpen from the the HUD, Sonar, Scope, Celestial, detection, bloom, sharpen and the
`gev-display` store, which VisualSettings publishes to, and the 3D 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 aircraft group, the Draw controls and the imported file list, which
AircraftDisplay, the draw tool and the file import publish beside it; AircraftDisplay, the draw tool and the file import publish beside it;
the controls call DisplayBindings, the draw tool and the file import 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/stores/globeActions.js",
"src/ui/views/panels/GlobeActions.vue", "src/ui/views/panels/GlobeActions.vue",
"src/ui/views/panels/GlobeCompass.vue", "src/ui/views/panels/GlobeCompass.vue",
"src/ui/stores/recording.js" "src/ui/stores/recording.js",
"src/ui/styleParameters.js"
], ],
"external": ["vue", "pinia"] "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(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(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\);/); assert.match(rightRail, /scroller\.scrollTop = Math\.min\(displayScrollTop, maxScrollTop\);/);
// The panel's parameters change, and the rail lays out again.
assert.match( assert.match(
ui, ui,
/this\._sliderPanel\.classList\.remove\('active'\);\s*this\._scheduleRightPanelLayout\(\);/, /this\._publishParameters\(null\);\s*this\._scheduleRightPanelLayout\(\);/,
); );
assert.match( assert.match(
ui, ui,
/this\._sliderPanel\.classList\.add\('active'\);\s*this\._scheduleRightPanelLayout\(\);/, /this\._publishParameters\(\{[\s\S]*?\}\);\s*this\._scheduleRightPanelLayout\(\);/,
); );
assert.match( assert.match(
css, 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) {'); const style = sourceBlock(' setStyle(styleName, {', ' _startTransition(styleName, fromValue, toValue) {');
assertClaimsBefore(style, 'this.activeStyle = styleName', 'setStyle'); assertClaimsBefore(style, 'this.activeStyle = styleName', 'setStyle');
const sliders = sourceBlock(' _updateSliderPanel(styleName, { reveal = false } = {}) {', ' _revealStyleParameters() {'); const sliders = sourceBlock(' setStyleParameter(name, value) {', ' _revealStyleParameters() {');
assertClaimsBefore(sliders, 'this.stages[styleName].uniforms[uName] = val', 'style parameter slider'); 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', () => { 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(LocationNavigation.prototype.destroy.toString(), /this\._locationLookup\?\.destroy\(\)/);
assert.match(DisplayBindings.prototype.destroy.toString(), /this\._detachDisplayControls\?\.\(\)/); assert.match(DisplayBindings.prototype.destroy.toString(), /this\._detachDisplayControls\?\.\(\)/);
assert.match(DisplayBindings.prototype.destroy.toString(), /this\._applicationShortcuts\?\.destroy\(\)/); 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\(\)/); assert.match(VisualSettings.prototype.stop.toString(), /this\._visualEffects\.stop\(\)/);
}); });
-1
View File
@@ -225,7 +225,6 @@ export class StyleManager extends ShellFacade {
}, },
elements: { elements: {
_ppToggles: this._ppToggles, _ppToggles: this._ppToggles,
_sliderContainer: this._sliderContainer,
_sliderPanel: this._sliderPanel, _sliderPanel: this._sliderPanel,
}, },
operations: { 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(list.className, 'geo-import-list');
assert.equal(document.activeElement === $('geo-import-button'), true); 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 { return {
_rightPanelStack: document.getElementById('right-context-rail'), _rightPanelStack: document.getElementById('right-context-rail'),
_sliderPanel: document.getElementById('param-slider-panel'), _sliderPanel: document.getElementById('param-slider-panel'),
_sliderContainer: document.getElementById('param-sliders'),
_ppToggles: document.getElementById('pp-toggles'), _ppToggles: document.getElementById('pp-toggles'),
_bloomBtn: document.getElementById('bloom-toggle'), _bloomBtn: document.getElementById('bloom-toggle'),
_bloomSlider: document.getElementById('bloom-intensity-slider'), _bloomSlider: document.getElementById('bloom-intensity-slider'),
-8
View File
@@ -413,14 +413,6 @@ export class ShellFacade {
this._visualSettings._detectionAllocationPreference = value; this._visualSettings._detectionAllocationPreference = value;
} }
get _styleParameters() {
return this._visualSettings._styleParameters;
}
set _styleParameters(value) {
this._visualSettings._styleParameters = value;
}
get _irBoostActive() { get _irBoostActive() {
return this._visualSettings._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. // buttons, the draw tool and the file import their own.
const { connect, detach, act } = createOwners(); 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. */ /** The phone shell's tab bar; null until the shell first presents it. */
const phoneShell = shallowRef(null); const phoneShell = shallowRef(null);
@@ -91,6 +102,8 @@ export const useDisplayStore = defineStore('gev-display', () => {
publishGeoImport, publishGeoImport,
style, style,
publishStyle, publishStyle,
parameters,
publishParameters,
phoneShell, phoneShell,
publishPhoneShell, publishPhoneShell,
mapSource, 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 * 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 * 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: { props: {
rows: { type: Array, required: true }, rows: { type: Array, required: true },
onInput: { type: Function, 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 * Own generated parameter rows and their input handling. Renders
* StyleParameterRows into `container` and patches it synchronously. * StyleParameterRows into `container` and patches it synchronously.
@@ -47,6 +109,9 @@ const StyleParameterRows = {
* @param {object} options * @param {object} options
* @param {HTMLElement} options.container Container owned by this component. * @param {HTMLElement} options.container Container owned by this component.
* @returns {{render: Function, clear: Function, destroy: Function}} * @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 }) { export function createStyleParameters({ container }) {
let rows = []; let rows = [];
@@ -69,36 +134,15 @@ export function createStyleParameters({ container }) {
if (destroyed) return; if (destroyed) return;
clear(); clear();
const current = generation; const current = generation;
rows = Object.entries(uniforms).map(([name, metadata]) => ({ rows = styleParameterRows(uniforms, readValue);
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: '',
}));
onInput = (name, value) => { onInput = (name, value) => {
if (destroyed || current !== generation) return; if (destroyed || current !== generation) return;
writeValue(name, value); writeValue(name, value);
rows = rows.map((row) => rows = withParameterValue(rows, name, value);
row.name === name
? { ...row, value, text: formatParam(value, row.digits) }
: row,
);
paint(); paint();
onChange(); onChange();
}; };
paint(); 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, clear,
destroy() { destroy() {
+47 -1
View File
@@ -3,7 +3,12 @@
import '../testSupport/registerDom.mjs'; import '../testSupport/registerDom.mjs';
import test from 'node:test'; import test from 'node:test';
import assert from 'node:assert/strict'; 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 }) { function parameters(t, values = { amount: 0.25, scale: 2 }) {
document.body.replaceChildren(); 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.equal(f.container.firstElementChild === nextOwner, true);
assert.deepEqual(f.order, []); 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 { computed } from 'vue';
import { t } from '../../../i18n/index.js'; import { t } from '../../../i18n/index.js';
import { vPanelState } from '../../rail/panelState.js'; import { vPanelState } from '../../rail/panelState.js';
import { vClassToggle } from '../../rail/classToggle.js';
import PanelCollapseButton from '../../rail/PanelCollapseButton.vue'; import PanelCollapseButton from '../../rail/PanelCollapseButton.vue';
import { vRangeValue } from '../../rail/rangeValue.js'; import { vRangeValue } from '../../rail/rangeValue.js';
import UiSegmented from '../../kit/UiSegmented.vue'; import UiSegmented from '../../kit/UiSegmented.vue';
@@ -11,13 +12,14 @@ import GeoImport from './GeoImport.vue';
import { useDisplayStore } from '../../stores/display.js'; import { useDisplayStore } from '../../stores/display.js';
import UiButton from '../../kit/UiButton.vue'; import UiButton from '../../kit/UiButton.vue';
import UiSelect from '../../kit/UiSelect.js'; import UiSelect from '../../kit/UiSelect.js';
import { StyleParameterRows } from '../../styleParameters.js';
// The Display panel. VisualSettings presents the HUD, Sonar, Scope, // The Display panel. VisualSettings presents the HUD, Sonar, Scope,
// Celestial, detection, bloom and sharpen, AircraftDisplay the 3D group and // Celestial, detection, bloom and sharpen, AircraftDisplay the 3D group and
// the draw tool its controls, and each publishes them; until they do, the // the draw tool its controls, and each publishes them; until they do, the
// panel shows its initial markup. GeoImport renders the file import. // panel shows its initial markup. GeoImport renders the file import.
const display = useDisplayStore(); 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). // A control's change goes to its owner (DisplayBindings or the draw tool).
const act = (name, ...args) => display.act(name, ...args); const act = (name, ...args) => display.act(name, ...args);
const pct = (value) => `${value}%`; const pct = (value) => `${value}%`;
@@ -131,14 +133,14 @@ const allocationOptions = [
<span class="pp-slider-value" id="detection-opacity-value">{{ pct(detection?.outsideOpacityPct ?? 1) }}</span> <span class="pp-slider-value" id="detection-opacity-value">{{ pct(detection?.outsideOpacityPct ?? 1) }}</span>
</div> </div>
</div> </div>
<!-- The one shared preset-parameter surface is inserted here at runtime. --> <!-- 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'"> <div id="param-slider-panel" v-panel-state="'param-slider-panel'" v-class-toggle="{ active: !!parameters }">
<div class="param-panel-header"> <div class="param-panel-header">
<span class="param-panel-title" data-i18n>PARAMETERS</span> <span class="param-panel-title" data-i18n>PARAMETERS</span>
<span class="param-panel-divider"></span> <span class="param-panel-divider"></span>
<PanelCollapseButton id="param-panel-collapse-btn" target="param-slider-panel" /> <PanelCollapseButton id="param-panel-collapse-btn" target="param-slider-panel" />
</div> </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>
<div class="pp-toggle-group"> <div class="pp-toggle-group">
<!-- 3D aircraft is DEFAULT-ON in Proximity (owner directive 2026-08-22), so the <!-- 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, STYLE_PRESET_DEFAULTS,
MILITARY_DETECTION_PRESET, MILITARY_DETECTION_PRESET,
} from './effects.js'; } from './effects.js';
import { createStyleParameters } from './visualInput.js'; import { styleParameterRows, withParameterValue } from './styleParameters.js';
import { import {
BLOOM_SCALE_VERSION, BLOOM_SCALE_VERSION,
clampBloomIntensity, clampBloomIntensity,
@@ -180,6 +180,11 @@ export class VisualSettings {
readDisplayPortalActive, readDisplayPortalActive,
}); });
this._lifetime = new UiLifetime(); 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({ this._visualEffects = new VisualEffects({
viewer, viewer,
requestRender: services.governorRequestRender, requestRender: services.governorRequestRender,
@@ -435,11 +440,7 @@ export class VisualSettings {
} }
_revealCockpitStyleParameters({ openDisplay = false } = {}) { _revealCockpitStyleParameters({ openDisplay = false } = {}) {
if ( if (!this.cockpitView?.active || !this._parameters) return;
!this.cockpitView?.active ||
!this._sliderPanel?.classList.contains('active')
)
return;
if (openDisplay && !this._cockpitDisclosureOpen?.('display')) { if (openDisplay && !this._cockpitDisclosureOpen?.('display')) {
this._setCockpitDisclosure?.('display', true); this._setCockpitDisclosure?.('display', true);
return; return;
@@ -763,6 +764,7 @@ export class VisualSettings {
governorRequestRender('style-param'); governorRequestRender('style-param');
} }
} }
this._refreshParameters();
} }
const bloomInput = preset.bloom || {}; const bloomInput = preset.bloom || {};
@@ -1397,41 +1399,54 @@ export class VisualSettings {
this._syncShareState(); this._syncShareState();
} }
/** Publish the style's parameter rows, or none for a style without any. */
_updateSliderPanel(styleName, { reveal = false } = {}) { _updateSliderPanel(styleName, { reveal = false } = {}) {
const { governorRequestRender } = this.services;
this._styleParameters ||= createStyleParameters({
container: this._sliderContainer,
});
this._styleParameters.clear();
const shader = STYLES[styleName]; const shader = STYLES[styleName];
if (!shader || !shader.uniforms || styleName === 'normal') { if (!shader || !shader.uniforms || styleName === 'normal') {
this._sliderPanel.classList.remove('active'); this._publishParameters(null);
this._scheduleRightPanelLayout(); this._scheduleRightPanelLayout();
return; return;
} }
this._publishParameters({
this._styleParameters.render({ style: styleName,
uniforms: shader.uniforms, rows: styleParameterRows(
readValue: (uName) => this.stages[styleName].uniforms[uName], shader.uniforms,
writeValue: (uName, val) => { (name) => this.stages[styleName].uniforms[name],
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._sliderPanel.classList.add('active');
this._scheduleRightPanelLayout(); this._scheduleRightPanelLayout();
if (reveal) this._revealStyleParameters(); 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() { _revealStyleParameters() {
if (!this._sliderPanel?.classList.contains('active')) return; if (!this._parameters) return;
if (this._cockpitDisplayPortalActive) return; if (this._cockpitDisplayPortalActive) return;
this._presentPanel(this._sliderPanel, { collapsed: false }); this._presentPanel(this._sliderPanel, { collapsed: false });
this.setPanelCollapsed('pp-toggles', false, { explicit: true }); this.setPanelCollapsed('pp-toggles', false, { explicit: true });
@@ -1574,7 +1589,12 @@ export class VisualSettings {
} }
stop() { stop() {
this._lifetime.destroy(); 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(); this._visualEffects.stop();
} }
releaseIrBoost() { releaseIrBoost() {