diff --git a/CHANGELOG.md b/CHANGELOG.md index 56a8fb537..d79fb3129 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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). diff --git a/docs/VUE-MIGRATION.md b/docs/VUE-MIGRATION.md index 92bde9fa5..f6d28b099 100644 --- a/docs/VUE-MIGRATION.md +++ b/docs/VUE-MIGRATION.md @@ -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 diff --git a/scripts/package-boundaries.json b/scripts/package-boundaries.json index f2992d749..71acdb733 100644 --- a/scripts/package-boundaries.json +++ b/scripts/package-boundaries.json @@ -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"] }, diff --git a/src/panelStackLayout.test.mjs b/src/panelStackLayout.test.mjs index e9340963c..c7324fd2d 100644 --- a/src/panelStackLayout.test.mjs +++ b/src/panelStackLayout.test.mjs @@ -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, diff --git a/src/sharelink.celestial.test.mjs b/src/sharelink.celestial.test.mjs index 07f07f570..0e77e8303 100644 --- a/src/sharelink.celestial.test.mjs +++ b/src/sharelink.celestial.test.mjs @@ -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\(\)/); }); diff --git a/src/ui/applicationShell.js b/src/ui/applicationShell.js index 9df0b1f17..99ec6de86 100644 --- a/src/ui/applicationShell.js +++ b/src/ui/applicationShell.js @@ -225,7 +225,6 @@ export class StyleManager extends ShellFacade { }, elements: { _ppToggles: this._ppToggles, - _sliderContainer: this._sliderContainer, _sliderPanel: this._sliderPanel, }, operations: { diff --git a/src/ui/displayPanel.test.mjs b/src/ui/displayPanel.test.mjs index a9cce560e..3231ead0f 100644 --- a/src/ui/displayPanel.test.mjs +++ b/src/ui/displayPanel.test.mjs @@ -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); +}); diff --git a/src/ui/shellElements.js b/src/ui/shellElements.js index 4ed386a93..c994083d5 100644 --- a/src/ui/shellElements.js +++ b/src/ui/shellElements.js @@ -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'), diff --git a/src/ui/shellFacade.js b/src/ui/shellFacade.js index 133f9ec0c..95bf791d4 100644 --- a/src/ui/shellFacade.js +++ b/src/ui/shellFacade.js @@ -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; } diff --git a/src/ui/stores/display.js b/src/ui/stores/display.js index 7feb99ec4..5dd63580c 100644 --- a/src/ui/stores/display.js +++ b/src/ui/stores/display.js @@ -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, diff --git a/src/ui/styleParameters.js b/src/ui/styleParameters.js index df6efc84b..e4e81fe08 100644 --- a/src/ui/styleParameters.js +++ b/src/ui/styleParameters.js @@ -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() { diff --git a/src/ui/styleParameters.test.mjs b/src/ui/styleParameters.test.mjs index 65be80268..d2f92b16c 100644 --- a/src/ui/styleParameters.test.mjs +++ b/src/ui/styleParameters.test.mjs @@ -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'); +}); diff --git a/src/ui/views/panels/DisplayPanel.vue b/src/ui/views/panels/DisplayPanel.vue index 5c55ae2fc..259b64106 100644 --- a/src/ui/views/panels/DisplayPanel.vue +++ b/src/ui/views/panels/DisplayPanel.vue @@ -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 = [ - -