From 926253e8a3b5e1edbd9cb529b42a837233638fde Mon Sep 17 00:00:00 2001 From: Sameh Khamis Date: Fri, 30 Oct 2026 23:13:37 -0700 Subject: [PATCH] feat(ui): render the Display panel's 3D aircraft group from the store --- docs/VUE-MIGRATION.md | 5 ++- scripts/track-regression.mjs | 1 + src/data/layerState.test.mjs | 13 ++++++- src/ui/aircraftDisplay.js | 57 ++++++++++++++++++---------- src/ui/applicationShell.js | 1 - src/ui/displayPanel.test.mjs | 46 ++++++++++++++++++++++ src/ui/shellElements.js | 1 - src/ui/stores/display.js | 16 ++++++-- src/ui/views/panels/DisplayPanel.vue | 26 ++++++++----- 9 files changed, 126 insertions(+), 40 deletions(-) diff --git a/docs/VUE-MIGRATION.md b/docs/VUE-MIGRATION.md index fdc538b12..afef85b13 100644 --- a/docs/VUE-MIGRATION.md +++ b/docs/VUE-MIGRATION.md @@ -95,8 +95,9 @@ They contain no application state. 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; the other groups - follow one owner at a time), Context and Radio, the command dock, + `gev-display` store, which VisualSettings publishes to, and the 3D + aircraft group, which AircraftDisplay publishes beside it; the other + groups follow one owner at a time), Context and Radio, the command dock, the phone shell, then Cockpit. Layers that write into the right rail directly gain a slot instead. diff --git a/scripts/track-regression.mjs b/scripts/track-regression.mjs index 044fdf74f..6b6f0f5f4 100644 --- a/scripts/track-regression.mjs +++ b/scripts/track-regression.mjs @@ -612,6 +612,7 @@ async function main() { if (models3dButton) { if (!models3dButton.classList.contains('active')) models3dButton.click(); models3dButton.click(); // → OFF: both layers must follow the one control + await new Promise((resolve) => setTimeout(resolve)); // the view renders models3dOff.button = models3dButton.classList.contains('active'); models3dOff.fl = fl.getParams().models3d; models3dOff.mil = mil.getParams().models3d; diff --git a/src/data/layerState.test.mjs b/src/data/layerState.test.mjs index ba2c9dfcc..f093a3c37 100644 --- a/src/data/layerState.test.mjs +++ b/src/data/layerState.test.mjs @@ -744,8 +744,17 @@ test('a fresh boot starts 3D aircraft ON in proximity — codec, both layers, an ); assert.match( ui, - /this\._models3dBtn\?\.setAttribute\(\s*'aria-pressed',\s*String\(this\.(?:flightState\.)?_models3dEnabled\),?\s*\)/, - 'ui.js: and keeps aria-pressed synchronized, so the lit state is not colour-only', + /this\._models3dPressedShown = this\._models3dEnabled;/, + 'ui.js: and keeps the pressed state synchronized, so the lit state is not colour-only', + ); + const panel = await readFile( + new URL('../ui/views/panels/DisplayPanel.vue', import.meta.url), + 'utf8', + ); + assert.match( + panel, + /id="models3d-toggle" :aria-pressed="String\(models3dOn\)"/, + 'DisplayPanel.vue: the view renders aria-pressed with the lit state', ); assert.match( html, diff --git a/src/ui/aircraftDisplay.js b/src/ui/aircraftDisplay.js index 6e14e0adf..bd7cce089 100644 --- a/src/ui/aircraftDisplay.js +++ b/src/ui/aircraftDisplay.js @@ -1,15 +1,28 @@ -import { syncSegmentedControl } from './segmentedControl.js'; +import { useDisplayStore } from './stores/display.js'; +import { applicationPinia } from './vueRuntime.js'; /** Own the shared aircraft display preference and its controls. */ export class AircraftDisplay { - constructor({ elements, readDataManager, layout }) { + constructor({ + elements, + readDataManager, + layout, + store = useDisplayStore(applicationPinia()), + }) { Object.assign(this, elements, { readDataManager, + store, _layoutRightPanels: layout, }); // Both aircraft layers default to models enabled in proximity mode. this._models3dEnabled = true; this._models3dMode = 'proximity'; + // What the button, mode row and mode segments show; null until first + // synced, so the initial markup stands. Enabling from the button leaves + // the row to its own sync, so each is held apart. + this._models3dPressedShown = null; + this._models3dRowShown = null; + this._models3dModeShown = null; this._models3dModeBtns = [ document.getElementById('models3d-mode-proximity'), document.getElementById('models3d-mode-all'), @@ -28,20 +41,28 @@ export class AircraftDisplay { if (!options) return; this._models3dEnabled = options.models3d === true; this._models3dMode = options.models3dMode === 'all' ? 'all' : 'proximity'; - this._syncModels3dButtonState(); - this._models3dModeRow?.classList.toggle('visible', this._models3dEnabled); - syncSegmentedControl(this._models3dModeBtns, this._models3dMode, { - valueOf: (button) => button.dataset.mode, - }); + this._models3dPressedShown = this._models3dEnabled; + this._models3dRowShown = this._models3dEnabled; + this._models3dModeShown = this._models3dMode; + this._publishModels3d(); this._layoutRightPanels(); } _syncModels3dModeRow() { - if (this._models3dModeRow) - this._models3dModeRow.classList.toggle('visible', this._models3dEnabled); + this._models3dRowShown = this._models3dEnabled; + this._publishModels3d(); this._layoutRightPanels(); } + /** Present the 3D aircraft group as its controls show it. */ + _publishModels3d() { + this.store.publishModels3d({ + pressed: this._models3dPressedShown, + rowVisible: this._models3dRowShown, + mode: this._models3dModeShown, + }); + } + _initModels3dToggle() { if (!this._models3dBtn) return; this._syncModels3dButtonState(); @@ -58,21 +79,17 @@ export class AircraftDisplay { const normalized = mode === 'all' ? 'all' : 'proximity'; this._models3dMode = normalized; this._setModels3dParams({ models3dMode: normalized }); - syncSegmentedControl(this._models3dModeBtns, normalized, { - valueOf: (button) => button.dataset.mode, - }); + this._models3dModeShown = normalized; this._syncModels3dButtonState(); } _syncModels3dButtonState() { - this._models3dBtn?.classList.toggle('active', this._models3dEnabled); // The lit/dark state is a colour to a sighted operator and nothing at all to - // a screen reader without this. It matters more now that the button ships - // ACTIVE from markup (default-on, 2026-08-22): the very first thing assistive - // tech reported was an unpressed-looking control over an armed layer. - this._models3dBtn?.setAttribute( - 'aria-pressed', - String(this._models3dEnabled), - ); + // a screen reader without aria-pressed, which the view renders with it. It + // matters more now that the button ships ACTIVE from markup (default-on, + // 2026-08-22): the very first thing assistive tech reported was an + // unpressed-looking control over an armed layer. + this._models3dPressedShown = this._models3dEnabled; + this._publishModels3d(); } } diff --git a/src/ui/applicationShell.js b/src/ui/applicationShell.js index 284d7dc01..b7ec8f761 100644 --- a/src/ui/applicationShell.js +++ b/src/ui/applicationShell.js @@ -447,7 +447,6 @@ export class StyleManager extends ShellFacade { this._aircraftDisplay = new AircraftDisplay({ elements: { _models3dBtn: this._models3dBtn, - _models3dModeRow: this._models3dModeRow, }, readDataManager: () => this._dataManager, layout: () => this._layoutRightPanels(), diff --git a/src/ui/displayPanel.test.mjs b/src/ui/displayPanel.test.mjs index 47a0ab7da..bc225c992 100644 --- a/src/ui/displayPanel.test.mjs +++ b/src/ui/displayPanel.test.mjs @@ -7,6 +7,7 @@ import { createPinia } from 'pinia'; import { createApp, nextTick } from 'vue'; import { useDisplayStore } from './stores/display.js'; import DisplayPanel from './views/panels/DisplayPanel.vue'; +import { AircraftDisplay } from './aircraftDisplay.js'; import { VisualSettings } from './visualSettings.js'; function fixture(t) { @@ -297,3 +298,48 @@ test('Scope and Celestial render what VisualSettings shows', async (t) => { assert.equal(celestial.classList.contains('active'), true); assert.equal(celestial.title, 'Celestial ring — reveal the full globe'); }); + +test('the 3D aircraft group renders what AircraftDisplay shows', async (t) => { + const { store, $ } = fixture(t); + const params = []; + const owner = new AircraftDisplay({ + elements: { _models3dBtn: $('models3d-toggle') }, + readDataManager: () => ({ + setLayerParams: (id, next) => params.push([id, next]), + }), + layout() {}, + store, + }); + const button = $('models3d-toggle'); + const row = $('models3d-mode-row'); + const all = $('models3d-mode-all'); + assert.equal(button.className, 'pp-toggle-btn active'); + assert.equal(button.getAttribute('aria-pressed'), 'true'); + assert.equal(row.classList.contains('visible'), true); + assert.equal($('models3d-mode-proximity').getAttribute('aria-checked'), 'true'); + + // The button leaves the row to its own sync. + owner._setModels3dEnabled(false); + await nextTick(); + assert.equal(button.className, 'pp-toggle-btn'); + assert.equal(button.getAttribute('aria-pressed'), 'false'); + assert.equal(row.classList.contains('visible'), true); + owner._syncModels3dModeRow(); + await nextTick(); + assert.equal(row.classList.contains('visible'), false); + + owner._setModels3dMode('all'); + await nextTick(); + assert.equal(all.classList.contains('active'), true); + assert.equal(all.getAttribute('aria-checked'), 'true'); + assert.equal($('models3d-mode-proximity').classList.contains('active'), false); + assert.deepEqual(params.at(-1), ['military', { models3dMode: 'all' }]); + + owner._syncModels3dFromLayerState({ + options: { flights: { models3d: true, models3dMode: 'proximity' } }, + }); + await nextTick(); + assert.equal(button.getAttribute('aria-pressed'), 'true'); + assert.equal(row.classList.contains('visible'), true); + assert.equal(all.getAttribute('aria-checked'), 'false'); +}); diff --git a/src/ui/shellElements.js b/src/ui/shellElements.js index 92f68ce36..4c8756e8a 100644 --- a/src/ui/shellElements.js +++ b/src/ui/shellElements.js @@ -142,6 +142,5 @@ export function readShellElements(document = globalThis.document) { _safeFrameBox: document.getElementById('safe-frame-box'), _detectionBtn: document.getElementById('detection-toggle'), _models3dBtn: document.getElementById('models3d-toggle'), - _models3dModeRow: document.getElementById('models3d-mode-row'), }; } diff --git a/src/ui/stores/display.js b/src/ui/stores/display.js index 37ec45a89..9e6db0804 100644 --- a/src/ui/stores/display.js +++ b/src/ui/stores/display.js @@ -2,9 +2,9 @@ import { defineStore } from 'pinia'; import { markRaw, shallowRef } from 'vue'; /** - * The Display panel as its view renders it. VisualSettings presents the - * post-processing groups (`_presentDisplay()`) and publishes them here; - * it keeps its listeners on the panel's controls. + * The Display panel as its view renders it. VisualSettings presents its + * groups (`_presentDisplay()`) and AircraftDisplay the 3D aircraft group, + * and each publishes here; both keep their listeners on the controls. */ export const useDisplayStore = defineStore('gev-display', () => { /** The presented panel; null until VisualSettings first renders. */ @@ -15,5 +15,13 @@ export const useDisplayStore = defineStore('gev-display', () => { view.value = markRaw(next); } - return { view, publish }; + /** The 3D aircraft group; null until AircraftDisplay first renders. */ + const models3d = shallowRef(null); + + /** @param {object} next A fresh presentation of the 3D aircraft group. */ + function publishModels3d(next) { + models3d.value = markRaw(next); + } + + return { view, publish, models3d, publishModels3d }; }); diff --git a/src/ui/views/panels/DisplayPanel.vue b/src/ui/views/panels/DisplayPanel.vue index 008c84d33..9534c294c 100644 --- a/src/ui/views/panels/DisplayPanel.vue +++ b/src/ui/views/panels/DisplayPanel.vue @@ -6,9 +6,10 @@ import { vRangeValue } from '../../rail/rangeValue.js'; import { useDisplayStore } from '../../stores/display.js'; // The Display panel. VisualSettings presents the HUD, Sonar, Scope, -// Celestial, detection, bloom and sharpen and publishes them; until it does, -// the panel shows its initial markup. The other groups keep their owners' element bindings. -const { view } = storeToRefs(useDisplayStore()); +// Celestial, detection, bloom and sharpen and AircraftDisplay the 3D group, +// and each publishes them; until they do, the panel shows its initial +// markup. The other groups keep their owners' element bindings. +const { view, models3d } = storeToRefs(useDisplayStore()); const pct = (value) => `${value}%`; const hud = computed(() => view.value?.hud ?? null); const sonar = computed(() => view.value?.sonar ?? null); @@ -24,10 +25,15 @@ const celestialTitle = computed(() => ? t('Celestial ring — available in Normal style') : t('Celestial ring — reveal the full globe'), ); -const allocationSegment = (value) => ({ - class: { active: allocation.value === value }, - 'aria-checked': String(allocation.value === value), +const models3dOn = computed(() => models3d.value?.pressed ?? true); +const modelsMode = computed(() => models3d.value?.mode ?? 'proximity'); +// A segmented control's segment, selected when `current` holds its value. +const segment = (current) => (value) => ({ + class: { active: current.value === value }, + 'aria-checked': String(current.value === value), }); +const modelsSegment = segment(modelsMode); +const allocationSegment = segment(allocation);