feat(ui): render the Display panel's 3D aircraft group from the store

This commit is contained in:
Sameh Khamis committed 2026-10-30 23:13:37 -07:00
1 parent 1139d364c7
commit 926253e8a3
9 files changed
+126 -40

No files matched your search

+3 -2
View File
@@ -95,8 +95,9 @@ They contain no application state.
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 and sharpen from the
`gev-display` store, which VisualSettings publishes to; the other groups `gev-display` store, which VisualSettings publishes to, and the 3D
follow one owner at a time), Context and Radio, the command dock, 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 the phone shell, then Cockpit. Layers that write into the right rail
directly gain a slot instead. directly gain a slot instead.
+1
View File
@@ -612,6 +612,7 @@ async function main() {
if (models3dButton) { if (models3dButton) {
if (!models3dButton.classList.contains('active')) models3dButton.click(); if (!models3dButton.classList.contains('active')) models3dButton.click();
models3dButton.click(); // → OFF: both layers must follow the one control 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.button = models3dButton.classList.contains('active');
models3dOff.fl = fl.getParams().models3d; models3dOff.fl = fl.getParams().models3d;
models3dOff.mil = mil.getParams().models3d; models3dOff.mil = mil.getParams().models3d;
+11 -2
View File
@@ -744,8 +744,17 @@ test('a fresh boot starts 3D aircraft ON in proximity — codec, both layers, an
); );
assert.match( assert.match(
ui, ui,
/this\._models3dBtn\?\.setAttribute\(\s*'aria-pressed',\s*String\(this\.(?:flightState\.)?_models3dEnabled\),?\s*\)/, /this\._models3dPressedShown = this\._models3dEnabled;/,
'ui.js: and keeps aria-pressed synchronized, so the lit state is not colour-only', '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( assert.match(
html, html,
+37 -20
View File
@@ -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. */ /** Own the shared aircraft display preference and its controls. */
export class AircraftDisplay { export class AircraftDisplay {
constructor({ elements, readDataManager, layout }) { constructor({
elements,
readDataManager,
layout,
store = useDisplayStore(applicationPinia()),
}) {
Object.assign(this, elements, { Object.assign(this, elements, {
readDataManager, readDataManager,
store,
_layoutRightPanels: layout, _layoutRightPanels: layout,
}); });
// Both aircraft layers default to models enabled in proximity mode. // Both aircraft layers default to models enabled in proximity mode.
this._models3dEnabled = true; this._models3dEnabled = true;
this._models3dMode = 'proximity'; 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 = [ this._models3dModeBtns = [
document.getElementById('models3d-mode-proximity'), document.getElementById('models3d-mode-proximity'),
document.getElementById('models3d-mode-all'), document.getElementById('models3d-mode-all'),
@@ -28,20 +41,28 @@ export class AircraftDisplay {
if (!options) return; if (!options) return;
this._models3dEnabled = options.models3d === true; this._models3dEnabled = options.models3d === true;
this._models3dMode = options.models3dMode === 'all' ? 'all' : 'proximity'; this._models3dMode = options.models3dMode === 'all' ? 'all' : 'proximity';
this._syncModels3dButtonState(); this._models3dPressedShown = this._models3dEnabled;
this._models3dModeRow?.classList.toggle('visible', this._models3dEnabled); this._models3dRowShown = this._models3dEnabled;
syncSegmentedControl(this._models3dModeBtns, this._models3dMode, { this._models3dModeShown = this._models3dMode;
valueOf: (button) => button.dataset.mode, this._publishModels3d();
});
this._layoutRightPanels(); this._layoutRightPanels();
} }
_syncModels3dModeRow() { _syncModels3dModeRow() {
if (this._models3dModeRow) this._models3dRowShown = this._models3dEnabled;
this._models3dModeRow.classList.toggle('visible', this._models3dEnabled); this._publishModels3d();
this._layoutRightPanels(); 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() { _initModels3dToggle() {
if (!this._models3dBtn) return; if (!this._models3dBtn) return;
this._syncModels3dButtonState(); this._syncModels3dButtonState();
@@ -58,21 +79,17 @@ export class AircraftDisplay {
const normalized = mode === 'all' ? 'all' : 'proximity'; const normalized = mode === 'all' ? 'all' : 'proximity';
this._models3dMode = normalized; this._models3dMode = normalized;
this._setModels3dParams({ models3dMode: normalized }); this._setModels3dParams({ models3dMode: normalized });
syncSegmentedControl(this._models3dModeBtns, normalized, { this._models3dModeShown = normalized;
valueOf: (button) => button.dataset.mode,
});
this._syncModels3dButtonState(); this._syncModels3dButtonState();
} }
_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 // 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 // a screen reader without aria-pressed, which the view renders with it. It
// ACTIVE from markup (default-on, 2026-08-22): the very first thing assistive // matters more now that the button ships ACTIVE from markup (default-on,
// tech reported was an unpressed-looking control over an armed layer. // 2026-08-22): the very first thing assistive tech reported was an
this._models3dBtn?.setAttribute( // unpressed-looking control over an armed layer.
'aria-pressed', this._models3dPressedShown = this._models3dEnabled;
String(this._models3dEnabled), this._publishModels3d();
);
} }
} }
-1
View File
@@ -447,7 +447,6 @@ export class StyleManager extends ShellFacade {
this._aircraftDisplay = new AircraftDisplay({ this._aircraftDisplay = new AircraftDisplay({
elements: { elements: {
_models3dBtn: this._models3dBtn, _models3dBtn: this._models3dBtn,
_models3dModeRow: this._models3dModeRow,
}, },
readDataManager: () => this._dataManager, readDataManager: () => this._dataManager,
layout: () => this._layoutRightPanels(), layout: () => this._layoutRightPanels(),
+46
View File
@@ -7,6 +7,7 @@ import { createPinia } from 'pinia';
import { createApp, nextTick } from 'vue'; import { createApp, nextTick } from 'vue';
import { useDisplayStore } from './stores/display.js'; import { useDisplayStore } from './stores/display.js';
import DisplayPanel from './views/panels/DisplayPanel.vue'; import DisplayPanel from './views/panels/DisplayPanel.vue';
import { AircraftDisplay } from './aircraftDisplay.js';
import { VisualSettings } from './visualSettings.js'; import { VisualSettings } from './visualSettings.js';
function fixture(t) { 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.classList.contains('active'), true);
assert.equal(celestial.title, 'Celestial ring — reveal the full globe'); 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');
});
-1
View File
@@ -142,6 +142,5 @@ export function readShellElements(document = globalThis.document) {
_safeFrameBox: document.getElementById('safe-frame-box'), _safeFrameBox: document.getElementById('safe-frame-box'),
_detectionBtn: document.getElementById('detection-toggle'), _detectionBtn: document.getElementById('detection-toggle'),
_models3dBtn: document.getElementById('models3d-toggle'), _models3dBtn: document.getElementById('models3d-toggle'),
_models3dModeRow: document.getElementById('models3d-mode-row'),
}; };
} }
+12 -4
View File
@@ -2,9 +2,9 @@ import { defineStore } from 'pinia';
import { markRaw, shallowRef } from 'vue'; import { markRaw, shallowRef } from 'vue';
/** /**
* The Display panel as its view renders it. VisualSettings presents the * The Display panel as its view renders it. VisualSettings presents its
* post-processing groups (`_presentDisplay()`) and publishes them here; * groups (`_presentDisplay()`) and AircraftDisplay the 3D aircraft group,
* it keeps its listeners on the panel's controls. * and each publishes here; both keep their listeners on the controls.
*/ */
export const useDisplayStore = defineStore('gev-display', () => { export const useDisplayStore = defineStore('gev-display', () => {
/** The presented panel; null until VisualSettings first renders. */ /** The presented panel; null until VisualSettings first renders. */
@@ -15,5 +15,13 @@ export const useDisplayStore = defineStore('gev-display', () => {
view.value = markRaw(next); 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 };
}); });
+16 -10
View File
@@ -6,9 +6,10 @@ import { vRangeValue } from '../../rail/rangeValue.js';
import { useDisplayStore } from '../../stores/display.js'; import { useDisplayStore } from '../../stores/display.js';
// The Display panel. VisualSettings presents the HUD, Sonar, Scope, // The Display panel. VisualSettings presents the HUD, Sonar, Scope,
// Celestial, detection, bloom and sharpen and publishes them; until it does, // Celestial, detection, bloom and sharpen and AircraftDisplay the 3D group,
// the panel shows its initial markup. The other groups keep their owners' element bindings. // and each publishes them; until they do, the panel shows its initial
const { view } = storeToRefs(useDisplayStore()); // markup. The other groups keep their owners' element bindings.
const { view, models3d } = storeToRefs(useDisplayStore());
const pct = (value) => `${value}%`; const pct = (value) => `${value}%`;
const hud = computed(() => view.value?.hud ?? null); const hud = computed(() => view.value?.hud ?? null);
const sonar = computed(() => view.value?.sonar ?? 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 — available in Normal style')
: t('Celestial ring — reveal the full globe'), : t('Celestial ring — reveal the full globe'),
); );
const allocationSegment = (value) => ({ const models3dOn = computed(() => models3d.value?.pressed ?? true);
class: { active: allocation.value === value }, const modelsMode = computed(() => models3d.value?.mode ?? 'proximity');
'aria-checked': String(allocation.value === value), // 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);
</script> </script>
<template> <template>
@@ -125,15 +131,15 @@ const allocationSegment = (value) => ({
button ships lit and the mode row ships open — the same markup-carries-the-default button ships lit and the mode row ships open — the same markup-carries-the-default
convention as #scope-toggle. ui.js re-syncs both from durable layer state, which convention as #scope-toggle. ui.js re-syncs both from durable layer state, which
is what flips them back for a share link or a stored session carrying 3D off. --> is what flips them back for a share link or a stored session carrying 3D off. -->
<button class="pp-toggle-btn active" id="models3d-toggle" aria-pressed="true" title="3D aircraft — flat icons zoomed out, 3D models up close" data-i18n-title> <button class="pp-toggle-btn" :class="{ active: models3dOn }" id="models3d-toggle" :aria-pressed="String(models3dOn)" title="3D aircraft — flat icons zoomed out, 3D models up close" data-i18n-title>
<span class="pp-icon">&#x2708;</span> <span class="pp-icon">&#x2708;</span>
<span class="pp-label">3D</span> <span class="pp-label">3D</span>
</button> </button>
<div class="pp-slider-row visible" id="models3d-mode-row"> <div class="pp-slider-row" :class="{ visible: models3d?.rowVisible ?? true }" id="models3d-mode-row">
<span class="pp-slider-mini-label" data-i18n>Models</span> <span class="pp-slider-mini-label" data-i18n>Models</span>
<div class="gev-segmented pp-mode-seg" role="radiogroup" aria-label="3D model coverage" data-i18n-aria-label> <div class="gev-segmented pp-mode-seg" role="radiogroup" aria-label="3D model coverage" data-i18n-aria-label>
<button class="gev-segment pp-mode-btn active" id="models3d-mode-proximity" data-mode="proximity" role="radio" aria-checked="true" data-i18n>Proximity</button> <button class="gev-segment pp-mode-btn" id="models3d-mode-proximity" data-mode="proximity" role="radio" v-bind="modelsSegment('proximity')" data-i18n>Proximity</button>
<button class="gev-segment pp-mode-btn" id="models3d-mode-all" data-mode="all" role="radio" aria-checked="false" data-i18n>All</button> <button class="gev-segment pp-mode-btn" id="models3d-mode-all" data-mode="all" role="radio" v-bind="modelsSegment('all')" data-i18n>All</button>
</div> </div>
</div> </div>
</div> </div>