mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-04 10:24:02 +00:00
feat(display): answer the Display controls through the store with UiSegmented
The Display panel, the dock's style buttons and the clean-view exit call the display store's actions, and DisplayBindings connects their handlers to it, replacing the bindDisplayControls listeners. The detection allocation and 3D model coverage choices render through a new kit UiSegmented, which takes v-model, options, label, stacked and disabled, and adds a keyboard policy, item classes and per-option attributes. Shell element fields and facade accessors that only the old listeners read are removed. The package boundaries now own UiSegmented and the UiPanel kit files, and the format scope drops the deleted test.
This commit is contained in:
1 parent
fc53682129
commit
3c3f8e3e67
24 files changed
+624
-562
No files matched your search
@@ -753,7 +753,7 @@ test('a fresh boot starts 3D aircraft ON in proximity — codec, both layers, an
|
||||
);
|
||||
assert.match(
|
||||
panel,
|
||||
/id="models3d-toggle" :aria-pressed="String\(models3dOn\)"/,
|
||||
/id="models3d-toggle" @click="act\('toggleModels'\)" :aria-pressed="String\(models3dOn\)"/,
|
||||
'DisplayPanel.vue: the view renders aria-pressed with the lit state',
|
||||
);
|
||||
assert.match(
|
||||
|
||||
@@ -263,7 +263,7 @@ test('detection-on-by-default is a default, not an operator override', () => {
|
||||
// preset consults the flag, and the detection button sets it.
|
||||
assert.match(uiSource, /if \(preset\.detection && !this\._detectionUserOverridden\) \{/,
|
||||
'a style preset still yields to an operator who changed detection by hand');
|
||||
const displayActions = uiSource.slice(uiSource.indexOf('this._displayControls ='));
|
||||
const displayActions = uiSource.slice(uiSource.indexOf('const token = store.connect('));
|
||||
const detectionButton = displayActions.slice(displayActions.indexOf('cycleDetection:'), displayActions.indexOf('toggleModels:'));
|
||||
assert.match(detectionButton, /cycleDetectionMode\(\)/, 'the button still invokes the detection action');
|
||||
assert.match(detectionButton, /this\.claimDetection\(\);/,
|
||||
|
||||
@@ -535,7 +535,7 @@ test('every explicit visual UI gesture claims restore authority before it mutate
|
||||
assertClaimsBefore(initUi.slice(startIndex, endIndex), mutation, label);
|
||||
}
|
||||
|
||||
const displayActions = initUi.slice(initUi.indexOf('this._displayControls ='));
|
||||
const displayActions = initUi.slice(initUi.indexOf('const token = store.connect('));
|
||||
assertClaimsBefore(displayActions.slice(displayActions.indexOf('toggleHud:'), displayActions.indexOf('cycleDetection:')), 'this.hud.toggle()', 'HUD button');
|
||||
assertClaimsBefore(displayActions.slice(displayActions.indexOf('cycleDetection:'), displayActions.indexOf('toggleModels:')), 'cycleDetectionMode()', 'detection button');
|
||||
|
||||
@@ -792,7 +792,7 @@ test('visual input listeners are revoked before asynchronous UI teardown', () =>
|
||||
assert.match(synchronous, /this\._locationNavigation\.destroy\(\)/);
|
||||
assert.match(LocationNavigation.prototype.destroy.toString(), /this\._locationControls\?\.destroy\(\)/);
|
||||
assert.match(LocationNavigation.prototype.destroy.toString(), /this\._locationLookup\?\.destroy\(\)/);
|
||||
assert.match(DisplayBindings.prototype.destroy.toString(), /this\._displayControls\?\.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\._visualEffects\.stop\(\)/);
|
||||
|
||||
@@ -9,7 +9,8 @@ import {
|
||||
detectOn,
|
||||
} from '../../scripts/qa-transit-controls.mjs';
|
||||
import { createStyleParameters } from '../ui/styleParameters.js';
|
||||
import { bindDisplayControls } from '../ui/displayControls.js';
|
||||
import { createPinia } from 'pinia';
|
||||
import { useDisplayStore } from '../ui/stores/display.js';
|
||||
import { thermalShader } from '../styles/thermal.js';
|
||||
import {
|
||||
cycleMode,
|
||||
@@ -189,20 +190,26 @@ test('DETECT uses the real OFF/restore button and density input to reach DENSE',
|
||||
setMode('BALANCED');
|
||||
setMode('OFF');
|
||||
const modes = [];
|
||||
const controls = bindDisplayControls({
|
||||
elements: { detectionButton: button, densitySlider: slider },
|
||||
actions: {
|
||||
cycleDetection() {
|
||||
cycleMode();
|
||||
modes.push(getMode());
|
||||
},
|
||||
setDensity(value) {
|
||||
setDetectionTuning({ densityPct: Number(value) });
|
||||
},
|
||||
// As DisplayPanel does: the toggle and the slider call the Display
|
||||
// controls' actions through the display store.
|
||||
const store = useDisplayStore(createPinia());
|
||||
const token = store.connect({
|
||||
cycleDetection() {
|
||||
cycleMode();
|
||||
modes.push(getMode());
|
||||
},
|
||||
setDensity(value) {
|
||||
setDetectionTuning({ densityPct: Number(value) });
|
||||
},
|
||||
});
|
||||
const onClick = () => store.act('cycleDetection');
|
||||
const onInput = () => store.act('setDensity', slider.value);
|
||||
button.addEventListener('click', onClick);
|
||||
slider.addEventListener('input', onInput);
|
||||
t.after(() => {
|
||||
controls.destroy();
|
||||
button.removeEventListener('click', onClick);
|
||||
slider.removeEventListener('input', onInput);
|
||||
store.detach(token);
|
||||
setMode('OFF');
|
||||
});
|
||||
assert.equal(await detectOn(page, pause), 'DENSE');
|
||||
|
||||
@@ -23,10 +23,6 @@ export class AircraftDisplay {
|
||||
this._models3dPressedShown = null;
|
||||
this._models3dRowShown = null;
|
||||
this._models3dModeShown = null;
|
||||
this._models3dModeBtns = [
|
||||
document.getElementById('models3d-mode-proximity'),
|
||||
document.getElementById('models3d-mode-all'),
|
||||
];
|
||||
}
|
||||
get _dataManager() {
|
||||
return this.readDataManager();
|
||||
|
||||
@@ -526,29 +526,6 @@ export class StyleManager extends ShellFacade {
|
||||
},
|
||||
elements: {
|
||||
_locationSearch: this._locationSearch,
|
||||
_bloomBtn: this._bloomBtn,
|
||||
_bloomSlider: this._bloomSlider,
|
||||
_sharpenBtn: this._sharpenBtn,
|
||||
_sharpenSlider: this._sharpenSlider,
|
||||
_scopeBtn: this._scopeBtn,
|
||||
_scopeFeatherSlider: this._scopeFeatherSlider,
|
||||
_hudLayoutSelect: this._hudLayoutSelect,
|
||||
_hudBtn: this._hudBtn,
|
||||
_cyberSonarBtn: this._cyberSonarBtn,
|
||||
_cyberSonarRings: this._cyberSonarRings,
|
||||
_cyberSonarRange: this._cyberSonarRange,
|
||||
_cyberSonarIntensity: this._cyberSonarIntensity,
|
||||
_cyberSonarOpacity: this._cyberSonarOpacity,
|
||||
_cyberSonarSector: this._cyberSonarSector,
|
||||
_cleanViewBtn: this._cleanViewBtn,
|
||||
_cleanViewExitBtn: this._cleanViewExitBtn,
|
||||
_detectionDensitySlider: this._detectionDensitySlider,
|
||||
_detectionBtn: this._detectionBtn,
|
||||
_detectionFadeSlider: this._detectionFadeSlider,
|
||||
_detectionOpacitySlider: this._detectionOpacitySlider,
|
||||
_detectionAllocationBtns: this._detectionAllocationBtns,
|
||||
_celestialBtn: this._celestialBtn,
|
||||
_models3dBtn: this._models3dBtn,
|
||||
},
|
||||
operations: {
|
||||
setStyle: (...args) => this.setStyle(...args),
|
||||
@@ -583,7 +560,6 @@ export class StyleManager extends ShellFacade {
|
||||
celestialRing: this.celestialRing,
|
||||
celestialRingEnabled: this.celestialRingEnabled,
|
||||
_models3dEnabled: this._models3dEnabled,
|
||||
_models3dModeBtns: this._models3dModeBtns,
|
||||
}),
|
||||
claimDetection: () => {
|
||||
this._visualSettings._detectionUserOverridden = true;
|
||||
|
||||
+12
-17
@@ -12,7 +12,8 @@ import {
|
||||
import { GEV_ACTION_SCHEMAS } from '../voice/actionSchemas.js';
|
||||
import { createGevActionRunner } from '../voice/gevActions.js';
|
||||
import { StyleManager } from './applicationShell.js';
|
||||
import { bindDisplayControls } from './displayControls.js';
|
||||
import { createPinia } from 'pinia';
|
||||
import { useDisplayStore } from './stores/display.js';
|
||||
import { VisualSettings } from './visualSettings.js';
|
||||
import { STYLES } from './visualPresets.js';
|
||||
|
||||
@@ -231,28 +232,22 @@ test('Display and voice HUD routes preserve explicit Cyber entry and exit author
|
||||
};
|
||||
|
||||
const display = makeOwner();
|
||||
const hudLayout = new EventTarget();
|
||||
hudLayout.value = 'cyber';
|
||||
const animeButton = new EventTarget();
|
||||
animeButton.dataset = { style: 'anime' };
|
||||
const controls = bindDisplayControls({
|
||||
elements: { hudLayout, styleButtons: [animeButton] },
|
||||
actions: {
|
||||
setHudLayout: (value) => display.owner.setHudLayout(value),
|
||||
setStyle: (value) =>
|
||||
display.owner.setStyle(value, { userInitiated: true }),
|
||||
},
|
||||
// The Display panel's layout select and the dock's style buttons call
|
||||
// these actions through the display store.
|
||||
const store = useDisplayStore(createPinia());
|
||||
const token = store.connect({
|
||||
setHudLayout: (value) => display.owner.setHudLayout(value),
|
||||
setStyle: (value) => display.owner.setStyle(value, { userInitiated: true }),
|
||||
});
|
||||
hudLayout.dispatchEvent(new Event('change'));
|
||||
store.act('setHudLayout', 'cyber');
|
||||
assert.equal(display.hud.getVariant(), 'cyber');
|
||||
assert.equal(display.owner.activeStyle, 'thermal');
|
||||
animeButton.dispatchEvent(new Event('click'));
|
||||
store.act('setStyle', 'anime');
|
||||
assert.equal(display.owner.activeStyle, 'anime');
|
||||
hudLayout.value = 'minimal';
|
||||
hudLayout.dispatchEvent(new Event('change'));
|
||||
store.act('setHudLayout', 'minimal');
|
||||
assert.equal(display.hud.getVariant(), 'minimal');
|
||||
assert.equal(display.owner.activeStyle, 'anime');
|
||||
controls.destroy();
|
||||
store.detach(token);
|
||||
|
||||
const voice = makeOwner();
|
||||
const eventSource = { addEventListener() {}, removeEventListener() {} };
|
||||
|
||||
+96
-123
@@ -1,6 +1,7 @@
|
||||
import { createFrameRateMonitor } from './frameRateMonitor.js';
|
||||
import { bindApplicationShortcuts } from './visualInput.js';
|
||||
import { bindDisplayControls } from './displayControls.js';
|
||||
import { useDisplayStore } from './stores/display.js';
|
||||
import { applicationPinia } from './vueRuntime.js';
|
||||
|
||||
/** Own keyboard/display event subscriptions; settings remain with their state owners. */
|
||||
export class DisplayBindings {
|
||||
@@ -11,10 +12,11 @@ export class DisplayBindings {
|
||||
operations,
|
||||
readState,
|
||||
claimDetection,
|
||||
displayStore,
|
||||
}) {
|
||||
Object.assign(
|
||||
this,
|
||||
{ viewer, services, readState, claimDetection },
|
||||
{ viewer, services, readState, claimDetection, displayStore },
|
||||
elements,
|
||||
operations,
|
||||
);
|
||||
@@ -40,9 +42,6 @@ export class DisplayBindings {
|
||||
get _models3dEnabled() {
|
||||
return this.readState()._models3dEnabled;
|
||||
}
|
||||
get _models3dModeBtns() {
|
||||
return this.readState()._models3dModeBtns;
|
||||
}
|
||||
_initUI() {
|
||||
const { cycleDetectionMode, isScopeMaskEnabled } = this.services;
|
||||
this._applicationShortcuts?.destroy();
|
||||
@@ -84,132 +83,106 @@ export class DisplayBindings {
|
||||
});
|
||||
|
||||
this._setScope({ enabled: isScopeMaskEnabled() }, { apply: false });
|
||||
this._displayControls?.destroy();
|
||||
this._displayControls = bindDisplayControls({
|
||||
elements: {
|
||||
styleButtons: document.querySelectorAll('.style-btn'),
|
||||
bloomButton: this._bloomBtn,
|
||||
bloomSlider: this._bloomSlider,
|
||||
sharpenButton: this._sharpenBtn,
|
||||
sharpenSlider: this._sharpenSlider,
|
||||
scopeToggle: this._scopeBtn,
|
||||
scopeFeatherSlider: this._scopeFeatherSlider,
|
||||
hudLayout: this._hudLayoutSelect,
|
||||
hudButton: this._hudBtn,
|
||||
sonarButton: this._cyberSonarBtn,
|
||||
sonarRingsSlider: this._cyberSonarRings,
|
||||
sonarRangeSlider: this._cyberSonarRange,
|
||||
sonarIntensitySlider: this._cyberSonarIntensity,
|
||||
sonarOpacitySlider: this._cyberSonarOpacity,
|
||||
sonarSectorSlider: this._cyberSonarSector,
|
||||
cleanViewButton: this._cleanViewBtn,
|
||||
cleanViewExitButton: this._cleanViewExitBtn,
|
||||
densitySlider: this._detectionDensitySlider,
|
||||
detectionButton: this._detectionBtn,
|
||||
allocationButtons: this._detectionAllocationBtns,
|
||||
fadeSlider: this._detectionFadeSlider,
|
||||
outsideOpacitySlider: this._detectionOpacitySlider,
|
||||
celestialButton: this._celestialBtn,
|
||||
modelsButton: this._models3dBtn,
|
||||
modelModeButtons: this._models3dBtn ? this._models3dModeBtns : [],
|
||||
// The Display controls (and the dock's style buttons) answer through
|
||||
// the display store; DisplayPanel's events call these actions.
|
||||
this._detachDisplayControls?.();
|
||||
const store = this.displayStore ?? useDisplayStore(applicationPinia());
|
||||
const token = store.connect({
|
||||
setStyle: (style) => this.setStyle(style, { userInitiated: true }),
|
||||
toggleBloom: () => {
|
||||
this.shareLinkManager?.claimRestoreLane?.('visual');
|
||||
this._setBloomEnabled(!this.bloomEnabled);
|
||||
},
|
||||
actions: {
|
||||
setStyle: (style) => this.setStyle(style, { userInitiated: true }),
|
||||
toggleBloom: () => {
|
||||
this.shareLinkManager?.claimRestoreLane?.('visual');
|
||||
this._setBloomEnabled(!this.bloomEnabled);
|
||||
},
|
||||
setBloomIntensity: (value) => {
|
||||
this.shareLinkManager?.claimRestoreLane?.('visual');
|
||||
this._setBloomIntensity(value);
|
||||
},
|
||||
toggleSharpen: () => {
|
||||
this.shareLinkManager?.claimRestoreLane?.('visual');
|
||||
this._setSharpenEnabled(!this.sharpenEnabled);
|
||||
},
|
||||
setScopeEnabled: (enabled) => {
|
||||
this.shareLinkManager?.claimRestoreLane?.('visual');
|
||||
this._setScope({ enabled });
|
||||
this._syncShareState();
|
||||
},
|
||||
setScopeFeather: (value) => {
|
||||
this.shareLinkManager?.claimRestoreLane?.('visual');
|
||||
this._setScope({ featherPct: value });
|
||||
this._syncShareState();
|
||||
},
|
||||
setSharpenIntensity: (pct) => {
|
||||
this.shareLinkManager?.claimRestoreLane?.('visual');
|
||||
this._setSharpenIntensity(pct);
|
||||
},
|
||||
setHudLayout: (value) => {
|
||||
this.shareLinkManager?.claimRestoreLane?.('visual');
|
||||
this._setHudVariant(value, { applyVisualDefaults: true });
|
||||
},
|
||||
toggleCleanView: () => this.toggleCleanView(),
|
||||
exitCleanView: () => this.toggleCleanView(false),
|
||||
setDensity: (value) => {
|
||||
this.shareLinkManager?.claimRestoreLane?.('visual');
|
||||
this.claimDetection();
|
||||
this._setDetectionDensity(value);
|
||||
this._syncShareState();
|
||||
},
|
||||
setAllocation: (value) => {
|
||||
this.shareLinkManager?.claimRestoreLane?.('visual');
|
||||
this.claimDetection();
|
||||
this._setDetectionAllocation(value);
|
||||
},
|
||||
setFade: (fadePct) => {
|
||||
this.shareLinkManager?.claimRestoreLane?.('visual');
|
||||
this._setDetectionFade({ fadePct });
|
||||
this._syncShareState();
|
||||
},
|
||||
setOutsideOpacity: (outsideOpacityPct) => {
|
||||
this.shareLinkManager?.claimRestoreLane?.('visual');
|
||||
this._setDetectionFade({ outsideOpacityPct });
|
||||
this._syncShareState();
|
||||
},
|
||||
toggleCelestial: () => {
|
||||
const ringIsVisible = !!this.celestialRing?.visible;
|
||||
if (!this.celestialRingEnabled || !ringIsVisible) {
|
||||
this.setCelestialRingEnabled(true, { focus: true });
|
||||
} else {
|
||||
this.setCelestialRingEnabled(false);
|
||||
}
|
||||
},
|
||||
toggleHud: () => {
|
||||
this.shareLinkManager?.claimRestoreLane?.('visual');
|
||||
this.hud.toggle();
|
||||
this._updateHudButtonState();
|
||||
this._syncShareState();
|
||||
},
|
||||
toggleSonar: () => this._setCyberSonarEnabled(),
|
||||
setSonarRings: (value) => this._setCyberSonarSetting('rings', value),
|
||||
setSonarRange: (value) => this._setCyberSonarSetting('range', value),
|
||||
setSonarIntensity: (value) =>
|
||||
this._setCyberSonarSetting('intensity', value),
|
||||
setSonarOpacity: (value) =>
|
||||
this._setCyberSonarSetting('opacity', value),
|
||||
setSonarSector: (value) => this._setCyberSonarSetting('sector', value),
|
||||
cycleDetection: () => {
|
||||
this.shareLinkManager?.claimRestoreLane?.('visual');
|
||||
this.claimDetection();
|
||||
cycleDetectionMode();
|
||||
this._syncShareState();
|
||||
},
|
||||
toggleModels: () => {
|
||||
this._setModels3dEnabled(!this._models3dEnabled);
|
||||
this._syncModels3dModeRow();
|
||||
},
|
||||
setModelsMode: (mode) => this._setModels3dMode(mode),
|
||||
setBloomIntensity: (value) => {
|
||||
this.shareLinkManager?.claimRestoreLane?.('visual');
|
||||
this._setBloomIntensity(value);
|
||||
},
|
||||
toggleSharpen: () => {
|
||||
this.shareLinkManager?.claimRestoreLane?.('visual');
|
||||
this._setSharpenEnabled(!this.sharpenEnabled);
|
||||
},
|
||||
setScopeEnabled: (enabled) => {
|
||||
this.shareLinkManager?.claimRestoreLane?.('visual');
|
||||
this._setScope({ enabled });
|
||||
this._syncShareState();
|
||||
},
|
||||
setScopeFeather: (value) => {
|
||||
this.shareLinkManager?.claimRestoreLane?.('visual');
|
||||
this._setScope({ featherPct: value });
|
||||
this._syncShareState();
|
||||
},
|
||||
setSharpenIntensity: (pct) => {
|
||||
this.shareLinkManager?.claimRestoreLane?.('visual');
|
||||
this._setSharpenIntensity(pct);
|
||||
},
|
||||
setHudLayout: (value) => {
|
||||
this.shareLinkManager?.claimRestoreLane?.('visual');
|
||||
this._setHudVariant(value, { applyVisualDefaults: true });
|
||||
},
|
||||
toggleCleanView: () => this.toggleCleanView(),
|
||||
exitCleanView: () => this.toggleCleanView(false),
|
||||
setDensity: (value) => {
|
||||
this.shareLinkManager?.claimRestoreLane?.('visual');
|
||||
this.claimDetection();
|
||||
this._setDetectionDensity(value);
|
||||
this._syncShareState();
|
||||
},
|
||||
setAllocation: (value) => {
|
||||
this.shareLinkManager?.claimRestoreLane?.('visual');
|
||||
this.claimDetection();
|
||||
this._setDetectionAllocation(value);
|
||||
},
|
||||
setFade: (fadePct) => {
|
||||
this.shareLinkManager?.claimRestoreLane?.('visual');
|
||||
this._setDetectionFade({ fadePct });
|
||||
this._syncShareState();
|
||||
},
|
||||
setOutsideOpacity: (outsideOpacityPct) => {
|
||||
this.shareLinkManager?.claimRestoreLane?.('visual');
|
||||
this._setDetectionFade({ outsideOpacityPct });
|
||||
this._syncShareState();
|
||||
},
|
||||
toggleCelestial: () => {
|
||||
const ringIsVisible = !!this.celestialRing?.visible;
|
||||
if (!this.celestialRingEnabled || !ringIsVisible) {
|
||||
this.setCelestialRingEnabled(true, { focus: true });
|
||||
} else {
|
||||
this.setCelestialRingEnabled(false);
|
||||
}
|
||||
},
|
||||
toggleHud: () => {
|
||||
this.shareLinkManager?.claimRestoreLane?.('visual');
|
||||
this.hud.toggle();
|
||||
this._updateHudButtonState();
|
||||
this._syncShareState();
|
||||
},
|
||||
toggleSonar: () => this._setCyberSonarEnabled(),
|
||||
setSonarRings: (value) => this._setCyberSonarSetting('rings', value),
|
||||
setSonarRange: (value) => this._setCyberSonarSetting('range', value),
|
||||
setSonarIntensity: (value) =>
|
||||
this._setCyberSonarSetting('intensity', value),
|
||||
setSonarOpacity: (value) => this._setCyberSonarSetting('opacity', value),
|
||||
setSonarSector: (value) => this._setCyberSonarSetting('sector', value),
|
||||
cycleDetection: () => {
|
||||
this.shareLinkManager?.claimRestoreLane?.('visual');
|
||||
this.claimDetection();
|
||||
cycleDetectionMode();
|
||||
this._syncShareState();
|
||||
},
|
||||
toggleModels: () => {
|
||||
this._setModels3dEnabled(!this._models3dEnabled);
|
||||
this._syncModels3dModeRow();
|
||||
},
|
||||
setModelsMode: (mode) => this._setModels3dMode(mode),
|
||||
});
|
||||
this._detachDisplayControls = () => store.detach(token);
|
||||
}
|
||||
destroy() {
|
||||
this._applicationShortcuts?.destroy();
|
||||
this._applicationShortcuts = null;
|
||||
this._frameRateMonitor?.destroy();
|
||||
this._frameRateMonitor = null;
|
||||
this._displayControls?.destroy();
|
||||
this._displayControls = null;
|
||||
this._detachDisplayControls?.();
|
||||
this._detachDisplayControls = null;
|
||||
}
|
||||
}
|
||||
@@ -1,86 +1,3 @@
|
||||
import { createSegmentedControl } from './segmentedControl.js';
|
||||
|
||||
/**
|
||||
* Bind Display controls to application actions. Inputs remain native DOM controls;
|
||||
* the caller owns settings and effect state. Destroy before replacing the view.
|
||||
* @param {{ elements: object, actions: object }} options
|
||||
* @returns {{ destroy(): void }}
|
||||
*/
|
||||
export function bindDisplayControls({ elements, actions }) {
|
||||
const removers = [];
|
||||
const listen = (element, type, action, read) => {
|
||||
if (!element) return;
|
||||
const handler = () => actions[action](...(read ? [read(element)] : []));
|
||||
element.addEventListener(type, handler);
|
||||
removers.push(() => element.removeEventListener(type, handler));
|
||||
};
|
||||
const integer = (element) => parseInt(element.value, 10);
|
||||
for (const [name, action] of [
|
||||
['bloomButton', 'toggleBloom'],
|
||||
['sharpenButton', 'toggleSharpen'],
|
||||
['cleanViewButton', 'toggleCleanView'],
|
||||
['cleanViewExitButton', 'exitCleanView'],
|
||||
['celestialButton', 'toggleCelestial'],
|
||||
['hudButton', 'toggleHud'],
|
||||
['sonarButton', 'toggleSonar'],
|
||||
['detectionButton', 'cycleDetection'],
|
||||
['modelsButton', 'toggleModels'],
|
||||
])
|
||||
listen(elements[name], 'click', action);
|
||||
for (const [name, action] of [
|
||||
['bloomSlider', 'setBloomIntensity'],
|
||||
['sharpenSlider', 'setSharpenIntensity'],
|
||||
['scopeFeatherSlider', 'setScopeFeather'],
|
||||
])
|
||||
listen(elements[name], 'input', action, integer);
|
||||
listen(elements.scopeToggle, 'change', 'setScopeEnabled', (el) => el.checked);
|
||||
listen(elements.densitySlider, 'input', 'setDensity', (el) => el.value);
|
||||
listen(elements.hudLayout, 'change', 'setHudLayout', (el) => el.value);
|
||||
for (const [name, action] of [
|
||||
['sonarRingsSlider', 'setSonarRings'],
|
||||
['sonarRangeSlider', 'setSonarRange'],
|
||||
['sonarIntensitySlider', 'setSonarIntensity'],
|
||||
['sonarOpacitySlider', 'setSonarOpacity'],
|
||||
['sonarSectorSlider', 'setSonarSector'],
|
||||
])
|
||||
listen(elements[name], 'input', action, integer);
|
||||
for (const el of elements.styleButtons || [])
|
||||
listen(el, 'click', 'setStyle', (el) => el.dataset.style);
|
||||
for (const [buttons, valueOf, action] of [
|
||||
[
|
||||
elements.allocationButtons || [],
|
||||
(el) => el.dataset.allocation,
|
||||
'setAllocation',
|
||||
],
|
||||
[
|
||||
elements.modelModeButtons || [],
|
||||
(el) => (el.dataset.mode === 'all' ? 'all' : 'proximity'),
|
||||
'setModelsMode',
|
||||
],
|
||||
]) {
|
||||
const control = createSegmentedControl({
|
||||
buttons,
|
||||
valueOf,
|
||||
onChange: (value) => actions[action](value),
|
||||
});
|
||||
removers.push(() => control.destroy());
|
||||
}
|
||||
listen(elements.fadeSlider, 'input', 'setFade', integer);
|
||||
listen(elements.outsideOpacitySlider, 'input', 'setOutsideOpacity', integer);
|
||||
// Hosts written before the two sliders were told apart pass `fadeSliders`
|
||||
// and read both values in `setFade` themselves.
|
||||
for (const el of elements.fadeSliders || []) {
|
||||
if (el === elements.fadeSlider || el === elements.outsideOpacitySlider)
|
||||
continue;
|
||||
listen(el, 'input', 'setFade');
|
||||
}
|
||||
return {
|
||||
destroy() {
|
||||
for (const remove of removers.splice(0)) remove();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Keep a native Scope checkbox and feather availability in step with the
|
||||
* mask. The Display view renders both from VisualSettings now; this stays
|
||||
|
||||
@@ -1,200 +0,0 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { bindDisplayControls } from './displayControls.js';
|
||||
|
||||
function element(value = '') {
|
||||
const el = new EventTarget();
|
||||
el.value = value;
|
||||
el.dataset = {};
|
||||
return el;
|
||||
}
|
||||
|
||||
test('sonar on/off control cannot fire after disposal', () => {
|
||||
const sonarButton = element();
|
||||
const calls = [];
|
||||
const control = bindDisplayControls({
|
||||
elements: { sonarButton },
|
||||
actions: { toggleSonar: () => calls.push('toggle') },
|
||||
});
|
||||
sonarButton.dispatchEvent(new Event('click'));
|
||||
control.destroy();
|
||||
sonarButton.dispatchEvent(new Event('click'));
|
||||
assert.deepEqual(calls, ['toggle']);
|
||||
});
|
||||
|
||||
test('controls read current values without preventing native input behavior', () => {
|
||||
const bloomSlider = element('24');
|
||||
const hudLayout = element('tactical');
|
||||
const calls = [];
|
||||
const control = bindDisplayControls({
|
||||
elements: { bloomSlider, hudLayout },
|
||||
actions: {
|
||||
setBloomIntensity: (value) => calls.push(value),
|
||||
setHudLayout: (value) => calls.push(value),
|
||||
},
|
||||
});
|
||||
const event = new Event('input', { cancelable: true });
|
||||
bloomSlider.dispatchEvent(event);
|
||||
bloomSlider.value = '37';
|
||||
bloomSlider.dispatchEvent(new Event('input'));
|
||||
hudLayout.value = 'minimal';
|
||||
hudLayout.dispatchEvent(new Event('change'));
|
||||
assert.deepEqual(calls, [24, 37, 'minimal']);
|
||||
assert.equal(event.defaultPrevented, false);
|
||||
control.destroy();
|
||||
});
|
||||
|
||||
test('destroyed and replaced controls cannot issue stale actions', () => {
|
||||
const bloomButton = element();
|
||||
let oldCalls = 0;
|
||||
let newCalls = 0;
|
||||
const first = bindDisplayControls({
|
||||
elements: { bloomButton },
|
||||
actions: { toggleBloom: () => oldCalls++ },
|
||||
});
|
||||
bloomButton.dispatchEvent(new Event('click'));
|
||||
first.destroy();
|
||||
const second = bindDisplayControls({
|
||||
elements: { bloomButton },
|
||||
actions: { toggleBloom: () => newCalls++ },
|
||||
});
|
||||
first.destroy();
|
||||
bloomButton.dispatchEvent(new Event('click'));
|
||||
assert.equal(oldCalls, 1);
|
||||
assert.equal(newCalls, 1);
|
||||
second.destroy();
|
||||
bloomButton.dispatchEvent(new Event('click'));
|
||||
assert.equal(newCalls, 1);
|
||||
});
|
||||
|
||||
test('the Cyber sonar control is an optional owned display action', () => {
|
||||
const sonarButton = element();
|
||||
const sonarRingsSlider = element('9');
|
||||
const sonarRangeSlider = element('110');
|
||||
const sonarIntensitySlider = element('65');
|
||||
const sonarOpacitySlider = element('84');
|
||||
const sonarSectorSlider = element('32');
|
||||
const calls = [];
|
||||
const control = bindDisplayControls({
|
||||
elements: {
|
||||
sonarButton,
|
||||
sonarRingsSlider,
|
||||
sonarRangeSlider,
|
||||
sonarIntensitySlider,
|
||||
sonarOpacitySlider,
|
||||
sonarSectorSlider,
|
||||
},
|
||||
actions: {
|
||||
toggleSonar: () => calls.push('toggle'),
|
||||
setSonarRings: (value) => calls.push(['rings', value]),
|
||||
setSonarRange: (value) => calls.push(['range', value]),
|
||||
setSonarIntensity: (value) => calls.push(['intensity', value]),
|
||||
setSonarOpacity: (value) => calls.push(['opacity', value]),
|
||||
setSonarSector: (value) => calls.push(['sector', value]),
|
||||
},
|
||||
});
|
||||
sonarButton.dispatchEvent(new Event('click'));
|
||||
sonarRingsSlider.dispatchEvent(new Event('input'));
|
||||
sonarRangeSlider.dispatchEvent(new Event('input'));
|
||||
sonarIntensitySlider.dispatchEvent(new Event('input'));
|
||||
sonarOpacitySlider.dispatchEvent(new Event('input'));
|
||||
sonarSectorSlider.dispatchEvent(new Event('input'));
|
||||
assert.deepEqual(calls, [
|
||||
'toggle',
|
||||
['rings', 9],
|
||||
['range', 110],
|
||||
['intensity', 65],
|
||||
['opacity', 84],
|
||||
['sector', 32],
|
||||
]);
|
||||
control.destroy();
|
||||
sonarButton.dispatchEvent(new Event('click'));
|
||||
assert.equal(calls.length, 6);
|
||||
});
|
||||
|
||||
test('style, allocation and model choices retain their current data attributes', () => {
|
||||
const style = element();
|
||||
const allocation = element();
|
||||
const mode = element();
|
||||
style.dataset.style = 'thermal';
|
||||
allocation.dataset.allocation = 'balanced';
|
||||
mode.dataset.mode = 'all';
|
||||
const calls = [];
|
||||
const control = bindDisplayControls({
|
||||
elements: {
|
||||
styleButtons: [style],
|
||||
allocationButtons: [allocation],
|
||||
modelModeButtons: [mode],
|
||||
},
|
||||
actions: {
|
||||
setStyle: (value) => calls.push(value),
|
||||
setAllocation: (value) => calls.push(value),
|
||||
setModelsMode: (value) => calls.push(value),
|
||||
},
|
||||
});
|
||||
for (const el of [style, allocation, mode])
|
||||
el.dispatchEvent(new Event('click'));
|
||||
mode.dataset.mode = 'unknown';
|
||||
mode.dispatchEvent(new Event('click'));
|
||||
assert.deepEqual(calls, ['thermal', 'balanced', 'all', 'proximity']);
|
||||
control.destroy();
|
||||
});
|
||||
|
||||
test('optional controls are absent safely and subscriptions stay instance-owned', () => {
|
||||
const calls = [];
|
||||
const a = element();
|
||||
const b = element();
|
||||
const one = bindDisplayControls({
|
||||
elements: { fadeSlider: null, outsideOpacitySlider: a },
|
||||
actions: { setOutsideOpacity: () => calls.push('a') },
|
||||
});
|
||||
const two = bindDisplayControls({
|
||||
elements: { fadeSlider: b },
|
||||
actions: { setFade: () => calls.push('b') },
|
||||
});
|
||||
one.destroy();
|
||||
a.dispatchEvent(new Event('input'));
|
||||
b.dispatchEvent(new Event('input'));
|
||||
assert.deepEqual(calls, ['b']);
|
||||
two.destroy();
|
||||
});
|
||||
|
||||
test('legacy fadeSliders bind once and skip sliders passed by name', () => {
|
||||
const calls = [];
|
||||
const fade = element('40');
|
||||
const legacy = element('25');
|
||||
const control = bindDisplayControls({
|
||||
elements: { fadeSlider: fade, fadeSliders: [fade, legacy] },
|
||||
actions: { setFade: (value) => calls.push(value) },
|
||||
});
|
||||
fade.dispatchEvent(new Event('input'));
|
||||
legacy.dispatchEvent(new Event('input'));
|
||||
assert.deepEqual(calls, [40, undefined]);
|
||||
control.destroy();
|
||||
});
|
||||
|
||||
test('the native Scope checkbox reads checked state on change and disposes its listener', () => {
|
||||
const scopeToggle = element();
|
||||
scopeToggle.checked = true;
|
||||
const scopeFeatherSlider = element('200');
|
||||
const calls = [];
|
||||
const controls = bindDisplayControls({
|
||||
elements: { scopeToggle, scopeFeatherSlider },
|
||||
actions: {
|
||||
setScopeEnabled: (value) => calls.push(['enabled', value]),
|
||||
setScopeFeather: (value) => calls.push(['feather', value]),
|
||||
},
|
||||
});
|
||||
scopeToggle.checked = false;
|
||||
scopeToggle.dispatchEvent(new Event('change'));
|
||||
scopeToggle.checked = true;
|
||||
scopeToggle.dispatchEvent(new Event('change'));
|
||||
scopeFeatherSlider.dispatchEvent(new Event('input'));
|
||||
controls.destroy();
|
||||
scopeToggle.dispatchEvent(new Event('change'));
|
||||
assert.deepEqual(calls, [
|
||||
['enabled', false],
|
||||
['enabled', true],
|
||||
['feather', 200],
|
||||
]);
|
||||
});
|
||||
@@ -0,0 +1,173 @@
|
||||
// src/ui/displayPanelActions.test.mjs — the Display panel's controls, the
|
||||
// clean-view exit and the dock's style buttons call the Display controls'
|
||||
// owner through the display store, and only while it is connected.
|
||||
import '../testSupport/registerDom.mjs';
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { createApp, h, nextTick } from 'vue';
|
||||
import { createPinia } from 'pinia';
|
||||
import { useDisplayStore } from './stores/display.js';
|
||||
import { DisplayBindings } from './displayBindings.js';
|
||||
import CommandDockView from './views/CommandDockView.vue';
|
||||
import DisplayControlsView from './views/DisplayControlsView.vue';
|
||||
|
||||
function mount(t) {
|
||||
document.body.replaceChildren();
|
||||
const pinia = createPinia();
|
||||
const store = useDisplayStore(pinia);
|
||||
const app = createApp({
|
||||
render: () => [h(DisplayControlsView), h(CommandDockView)],
|
||||
}).use(pinia);
|
||||
app.mount(document.body.appendChild(document.createElement('div')));
|
||||
t.after(() => app.unmount());
|
||||
return store;
|
||||
}
|
||||
const $ = (id) => document.getElementById(id);
|
||||
function recorder(store) {
|
||||
const calls = [];
|
||||
const actions = new Proxy(
|
||||
{},
|
||||
{
|
||||
get:
|
||||
(_, name) =>
|
||||
(...args) =>
|
||||
calls.push([name, ...args]),
|
||||
},
|
||||
);
|
||||
return { calls, token: store.connect(actions) };
|
||||
}
|
||||
function input(el, value, type = 'input') {
|
||||
el.value = String(value);
|
||||
el.dispatchEvent(new Event(type, { bubbles: true }));
|
||||
}
|
||||
|
||||
test('buttons, sliders, the layout select and the Scope checkbox call their actions', async (t) => {
|
||||
const store = mount(t);
|
||||
const { calls } = recorder(store);
|
||||
for (const id of [
|
||||
'hud-toggle',
|
||||
'cyber-sonar-toggle',
|
||||
'detection-toggle',
|
||||
'models3d-toggle',
|
||||
'celestial-toggle',
|
||||
'clean-view-toggle',
|
||||
'bloom-toggle',
|
||||
'sharpen-toggle',
|
||||
'clean-view-exit',
|
||||
])
|
||||
$(id).click();
|
||||
input($('hud-layout-select'), 'minimal', 'change');
|
||||
input($('cyber-sonar-rings'), 5);
|
||||
input($('cyber-sonar-range'), 90);
|
||||
input($('cyber-sonar-intensity'), 40);
|
||||
input($('cyber-sonar-opacity'), 60);
|
||||
input($('cyber-sonar-sector'), 30);
|
||||
input($('detection-density-slider'), 75);
|
||||
input($('detection-fade-slider'), 12);
|
||||
input($('detection-opacity-slider'), 3);
|
||||
input($('scope-feather-slider'), 150);
|
||||
input($('bloom-intensity-slider'), 24);
|
||||
input($('sharpen-intensity-slider'), 37);
|
||||
$('scope-toggle').checked = false;
|
||||
$('scope-toggle').dispatchEvent(new Event('change', { bubbles: true }));
|
||||
assert.deepEqual(calls, [
|
||||
['toggleHud'],
|
||||
['toggleSonar'],
|
||||
['cycleDetection'],
|
||||
['toggleModels'],
|
||||
['toggleCelestial'],
|
||||
['toggleCleanView'],
|
||||
['toggleBloom'],
|
||||
['toggleSharpen'],
|
||||
['exitCleanView'],
|
||||
['setHudLayout', 'minimal'],
|
||||
['setSonarRings', 5],
|
||||
['setSonarRange', 90],
|
||||
['setSonarIntensity', 40],
|
||||
['setSonarOpacity', 60],
|
||||
['setSonarSector', 30],
|
||||
// Density passes the slider's own value, as it always has.
|
||||
['setDensity', '75'],
|
||||
['setFade', 12],
|
||||
['setOutsideOpacity', 3],
|
||||
['setScopeFeather', 150],
|
||||
['setBloomIntensity', 24],
|
||||
['setSharpenIntensity', 37],
|
||||
['setScopeEnabled', false],
|
||||
]);
|
||||
});
|
||||
|
||||
test('style, allocation and model choices keep their ids and data attributes', async (t) => {
|
||||
const store = mount(t);
|
||||
const { calls } = recorder(store);
|
||||
document.querySelector('.style-btn[data-style="thermal"]').click();
|
||||
$('detection-allocation-weighted').click();
|
||||
$('models3d-mode-all').click();
|
||||
$('models3d-mode-proximity').click();
|
||||
assert.deepEqual(calls, [
|
||||
['setStyle', 'thermal'],
|
||||
['setAllocation', 'WEIGHTED'],
|
||||
['setModelsMode', 'all'],
|
||||
['setModelsMode', 'proximity'],
|
||||
]);
|
||||
assert.equal(
|
||||
$('detection-allocation-weighted').dataset.allocation,
|
||||
'WEIGHTED',
|
||||
);
|
||||
assert.equal($('models3d-mode-all').dataset.mode, 'all');
|
||||
// Their selection follows what the owners present.
|
||||
assert.equal(
|
||||
$('models3d-mode-proximity').getAttribute('aria-checked'),
|
||||
'true',
|
||||
);
|
||||
store.publishModels3d({ pressed: true, mode: 'all', rowVisible: true });
|
||||
await nextTick();
|
||||
assert.equal($('models3d-mode-all').getAttribute('aria-checked'), 'true');
|
||||
assert.ok($('models3d-mode-all').classList.contains('active'));
|
||||
});
|
||||
|
||||
test('a detached or replaced owner hears nothing', (t) => {
|
||||
const store = mount(t);
|
||||
const first = recorder(store);
|
||||
$('bloom-toggle').click();
|
||||
store.detach(first.token);
|
||||
$('bloom-toggle').click();
|
||||
const second = recorder(store);
|
||||
store.detach(first.token);
|
||||
$('bloom-toggle').click();
|
||||
assert.deepEqual(first.calls, [['toggleBloom']]);
|
||||
assert.deepEqual(second.calls, [['toggleBloom']]);
|
||||
});
|
||||
|
||||
test('DisplayBindings answers the controls from init until destroy', (t) => {
|
||||
const store = mount(t);
|
||||
const calls = [];
|
||||
const bindings = new DisplayBindings({
|
||||
viewer: null,
|
||||
services: { isScopeMaskEnabled: () => false, cycleDetectionMode: () => {} },
|
||||
elements: { _locationSearch: document.createElement('input') },
|
||||
operations: {
|
||||
_setScope: () => {},
|
||||
_setBloomEnabled: (enabled) => calls.push(['bloom', enabled]),
|
||||
},
|
||||
readState: () => ({ bloomEnabled: false }),
|
||||
claimDetection: () => {},
|
||||
displayStore: store,
|
||||
});
|
||||
bindings._initUI();
|
||||
// Initialising again replaces its own connection rather than stacking one.
|
||||
bindings._initUI();
|
||||
$('bloom-toggle').click();
|
||||
assert.deepEqual(calls, [['bloom', true]]);
|
||||
// A newer owner takes over; the old one's destroy leaves it connected.
|
||||
const next = recorder(store);
|
||||
bindings.destroy();
|
||||
$('bloom-toggle').click();
|
||||
assert.deepEqual(calls, [['bloom', true]]);
|
||||
assert.deepEqual(next.calls, [['toggleBloom']]);
|
||||
store.detach(next.token);
|
||||
bindings._initUI();
|
||||
bindings.destroy();
|
||||
$('bloom-toggle').click();
|
||||
assert.deepEqual(calls, [['bloom', true]]);
|
||||
});
|
||||
@@ -0,0 +1,97 @@
|
||||
import { after, before, test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { installVueDom } from '../../testSupport/vueDom.mjs';
|
||||
|
||||
let mount;
|
||||
let UiSegmented;
|
||||
let uninstall;
|
||||
before(async () => {
|
||||
uninstall = installVueDom();
|
||||
({ mount } = await import('@vue/test-utils'));
|
||||
UiSegmented = (await import('./UiSegmented.vue')).default;
|
||||
});
|
||||
after(() => uninstall());
|
||||
|
||||
const options = [
|
||||
{ value: 'a', label: 'Alpha', attrs: { id: 'seg-a', 'data-v': 'a' } },
|
||||
{ value: 'b', label: 'Beta', disabled: true },
|
||||
{ value: 'c', label: 'Gamma' },
|
||||
];
|
||||
|
||||
function mountGroup(props = {}) {
|
||||
return mount(UiSegmented, {
|
||||
attachTo: document.body,
|
||||
props: { options, label: 'Pick', modelValue: 'a', ...props },
|
||||
});
|
||||
}
|
||||
const tabStops = (wrapper) =>
|
||||
wrapper.findAll('button').map((b) => b.attributes('tabindex') ?? null);
|
||||
|
||||
test('a named radio group with Core and kit classes on every option', () => {
|
||||
const wrapper = mountGroup({ itemClass: 'pp-mode-btn' });
|
||||
const group = wrapper.get('[role="radiogroup"]');
|
||||
assert.equal(group.attributes('aria-label'), 'Pick');
|
||||
assert.ok(group.classes().includes('gev-segmented'));
|
||||
const [a, b, c] = wrapper.findAll('button');
|
||||
assert.equal(a.text(), 'Alpha');
|
||||
assert.equal(a.attributes('id'), 'seg-a');
|
||||
assert.equal(a.attributes('data-v'), 'a');
|
||||
assert.equal(a.attributes('type'), 'button');
|
||||
assert.equal(a.attributes('aria-checked'), 'true');
|
||||
assert.ok(a.classes().includes('active'));
|
||||
assert.ok(a.classes().includes('ui-segmented__item--on'));
|
||||
assert.ok(a.classes().includes('gev-segment'));
|
||||
assert.ok(a.classes().includes('pp-mode-btn'));
|
||||
assert.equal(b.attributes('disabled'), '');
|
||||
assert.equal(a.attributes('disabled'), undefined);
|
||||
assert.equal(c.attributes('aria-checked'), 'false');
|
||||
assert.equal(c.classes().includes('active'), false);
|
||||
wrapper.unmount();
|
||||
});
|
||||
|
||||
test('a click asks for its option; a disabled one asks for nothing', async () => {
|
||||
const wrapper = mountGroup();
|
||||
const [, b, c] = wrapper.findAll('button');
|
||||
await c.trigger('click');
|
||||
await b.trigger('click');
|
||||
assert.deepEqual(wrapper.emitted('update:modelValue'), [['c']]);
|
||||
wrapper.unmount();
|
||||
});
|
||||
|
||||
test('keyboard none: every option is a tab stop and arrows do nothing', async () => {
|
||||
const wrapper = mountGroup({ keyboard: 'none', activeClass: 'is-active' });
|
||||
assert.deepEqual(tabStops(wrapper), [null, null, null]);
|
||||
assert.ok(wrapper.get('#seg-a').classes().includes('is-active'));
|
||||
await wrapper.get('#seg-a').trigger('keydown', { key: 'ArrowRight' });
|
||||
assert.equal(wrapper.emitted('update:modelValue'), undefined);
|
||||
wrapper.unmount();
|
||||
});
|
||||
|
||||
test('keyboard arrows: one roving stop, arrows skip disabled options and wrap', async () => {
|
||||
const wrapper = mountGroup();
|
||||
assert.deepEqual(tabStops(wrapper), ['0', '-1', '-1']);
|
||||
await wrapper.get('#seg-a').trigger('keydown', { key: 'ArrowRight' });
|
||||
await wrapper.get('#seg-a').trigger('keydown', { key: 'ArrowLeft' });
|
||||
await wrapper.get('#seg-a').trigger('keydown', { key: 'Home' });
|
||||
assert.deepEqual(wrapper.emitted('update:modelValue'), [['c'], ['c']]);
|
||||
await wrapper.setProps({ modelValue: 'c' });
|
||||
assert.deepEqual(tabStops(wrapper), ['-1', '-1', '0']);
|
||||
wrapper.unmount();
|
||||
});
|
||||
|
||||
test('keyboard radio: Home and End reach the ends', async () => {
|
||||
const wrapper = mountGroup({ keyboard: 'radio', modelValue: 'c' });
|
||||
await wrapper.get('#seg-a').trigger('keydown', { key: 'Home' });
|
||||
await wrapper.get('#seg-a').trigger('keydown', { key: 'End' });
|
||||
assert.deepEqual(wrapper.emitted('update:modelValue'), [['a'], ['c']]);
|
||||
wrapper.unmount();
|
||||
});
|
||||
|
||||
test('a disabled group answers nothing and leaves the tab order', async () => {
|
||||
const wrapper = mountGroup({ disabled: true });
|
||||
assert.deepEqual(tabStops(wrapper), ['-1', '-1', '-1']);
|
||||
await wrapper.get('#seg-a').trigger('click');
|
||||
await wrapper.get('#seg-a').trigger('keydown', { key: 'ArrowRight' });
|
||||
assert.equal(wrapper.emitted('update:modelValue'), undefined);
|
||||
wrapper.unmount();
|
||||
});
|
||||
@@ -0,0 +1,118 @@
|
||||
<script setup>
|
||||
import { computed, nextTick } from 'vue';
|
||||
|
||||
// Core's segmented control, with @halfpixel/ui-vue UiSegmented's
|
||||
// v-model, options, label, stacked and disabled so the two kits can merge.
|
||||
// Core adds `keyboard`, because its groups keep their own policy: `none`
|
||||
// (Tab reaches every option and arrows do nothing), `arrows` (a roving tab
|
||||
// stop that arrows move, as in ui-vue) or `radio` (arrows plus Home and
|
||||
// End). Each option may carry `attrs` (an id, data attributes, a tip), and
|
||||
// Core's classes sit beside the kit's: `gev-segmented`, `gev-segment` and the
|
||||
// selected option's `activeClass`. Not mirrored yet: an option's icon.
|
||||
const props = defineProps({
|
||||
modelValue: { type: String, default: undefined },
|
||||
/** `{value, label, icon?, disabled?, attrs?}`, labels already translated. */
|
||||
options: { type: Array, required: true },
|
||||
/** The group's accessible name, already translated. */
|
||||
label: { type: String, required: true },
|
||||
stacked: { type: Boolean, default: false },
|
||||
/** The whole group is off: no option is in the tab order or answers. */
|
||||
disabled: { type: Boolean, default: false },
|
||||
keyboard: {
|
||||
type: String,
|
||||
default: 'arrows',
|
||||
validator: (value) => ['none', 'arrows', 'radio'].includes(value),
|
||||
},
|
||||
/** Core's class on every option, beside `gev-segment`. */
|
||||
itemClass: { type: String, default: undefined },
|
||||
/** Core's class on the selected option. */
|
||||
activeClass: { type: String, default: 'active' },
|
||||
});
|
||||
const emit = defineEmits(['update:modelValue']);
|
||||
// The option buttons, by index, for focus; not reactive state.
|
||||
const items = [];
|
||||
|
||||
const selectedIndex = computed(() =>
|
||||
props.options.findIndex((option) => option.value === props.modelValue),
|
||||
);
|
||||
const optionOff = (option) => props.disabled || !!option.disabled;
|
||||
/** The roving tab stop; with no selection, the first option. */
|
||||
const tabStop = computed(() =>
|
||||
props.disabled ? -1 : Math.max(0, selectedIndex.value),
|
||||
);
|
||||
function tabindex(index) {
|
||||
if (props.keyboard === 'none') return undefined;
|
||||
return index === tabStop.value ? 0 : -1;
|
||||
}
|
||||
|
||||
function choose(index) {
|
||||
const option = props.options[index];
|
||||
if (!option || optionOff(option)) return;
|
||||
emit('update:modelValue', option.value);
|
||||
}
|
||||
/** Select the option `delta` away, wrapping past disabled ones, or `to` an end. */
|
||||
function move({ delta = 0, to = null }) {
|
||||
const count = props.options.length;
|
||||
if (!count || props.disabled) return;
|
||||
const enabled = props.options
|
||||
.map((option, index) => (option.disabled ? -1 : index))
|
||||
.filter((index) => index >= 0);
|
||||
if (!enabled.length) return;
|
||||
let target;
|
||||
if (to === 'first') target = enabled[0];
|
||||
else if (to === 'last') target = enabled.at(-1);
|
||||
else {
|
||||
target =
|
||||
selectedIndex.value < 0 ? (delta > 0 ? -1 : count) : selectedIndex.value;
|
||||
do target = (((target + delta) % count) + count) % count;
|
||||
while (props.options[target].disabled);
|
||||
}
|
||||
choose(target);
|
||||
// Focus follows the selection once the options re-render.
|
||||
nextTick(() => items[target]?.focus());
|
||||
}
|
||||
const STEPS = { ArrowRight: 1, ArrowDown: 1, ArrowLeft: -1, ArrowUp: -1 };
|
||||
function onKey(event) {
|
||||
if (props.keyboard === 'none') return;
|
||||
if (STEPS[event.key]) move({ delta: STEPS[event.key] });
|
||||
else if (props.keyboard === 'radio' && event.key === 'Home')
|
||||
move({ to: 'first' });
|
||||
else if (props.keyboard === 'radio' && event.key === 'End')
|
||||
move({ to: 'last' });
|
||||
else return;
|
||||
event.preventDefault();
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
role="radiogroup"
|
||||
class="ui-segmented gev-segmented"
|
||||
:class="{ 'ui-segmented--stacked': props.stacked }"
|
||||
:aria-label="props.label"
|
||||
:aria-disabled="props.disabled ? 'true' : undefined"
|
||||
@keydown="onKey"
|
||||
>
|
||||
<button
|
||||
v-for="(option, index) in props.options"
|
||||
:key="option.value"
|
||||
v-bind="option.attrs"
|
||||
:ref="(el) => (items[index] = el)"
|
||||
type="button"
|
||||
role="radio"
|
||||
class="ui-segmented__item ui-focus gev-segment"
|
||||
:class="[
|
||||
props.itemClass,
|
||||
{
|
||||
'ui-segmented__item--on': option.value === props.modelValue,
|
||||
[props.activeClass]: option.value === props.modelValue,
|
||||
},
|
||||
]"
|
||||
:aria-checked="String(option.value === props.modelValue)"
|
||||
:disabled="optionOff(option)"
|
||||
:tabindex="tabindex(index)"
|
||||
@click="choose(index)"
|
||||
>{{ option.label }}</button
|
||||
>
|
||||
</div>
|
||||
</template>
|
||||
@@ -7,32 +7,8 @@ export function readShellElements(document = globalThis.document) {
|
||||
_ppToggles: document.getElementById('pp-toggles'),
|
||||
_bloomBtn: document.getElementById('bloom-toggle'),
|
||||
_bloomSlider: document.getElementById('bloom-intensity-slider'),
|
||||
_sharpenBtn: document.getElementById('sharpen-toggle'),
|
||||
_sharpenSlider: document.getElementById('sharpen-intensity-slider'),
|
||||
_hudBtn: document.getElementById('hud-toggle'),
|
||||
_hudLayoutSelect: document.getElementById('hud-layout-select'),
|
||||
_cyberSonarBtn: document.getElementById('cyber-sonar-toggle'),
|
||||
_cyberSonarRings: document.getElementById('cyber-sonar-rings'),
|
||||
_cyberSonarRange: document.getElementById('cyber-sonar-range'),
|
||||
_cyberSonarIntensity: document.getElementById('cyber-sonar-intensity'),
|
||||
_cyberSonarOpacity: document.getElementById('cyber-sonar-opacity'),
|
||||
_cyberSonarSector: document.getElementById('cyber-sonar-sector'),
|
||||
_detectionDensitySlider: document.getElementById(
|
||||
'detection-density-slider',
|
||||
),
|
||||
_detectionAllocationBtns: [
|
||||
document.getElementById('detection-allocation-elastic'),
|
||||
document.getElementById('detection-allocation-weighted'),
|
||||
].filter(Boolean),
|
||||
_detectionFadeSlider: document.getElementById('detection-fade-slider'),
|
||||
_detectionOpacitySlider: document.getElementById(
|
||||
'detection-opacity-slider',
|
||||
),
|
||||
_celestialBtn: document.getElementById('celestial-toggle'),
|
||||
_scopeBtn: document.getElementById('scope-toggle'),
|
||||
_scopeFeatherSlider: document.getElementById('scope-feather-slider'),
|
||||
_cleanViewBtn: document.getElementById('clean-view-toggle'),
|
||||
_cleanViewExitBtn: document.getElementById('clean-view-exit'),
|
||||
_dataPanel: document.getElementById('data-panel'),
|
||||
_scenePanel: document.getElementById('scene-panel'),
|
||||
_cctvPanel: document.getElementById('cctv-panel'),
|
||||
@@ -102,7 +78,6 @@ export function readShellElements(document = globalThis.document) {
|
||||
_clearSelectedLayersBtn: document.getElementById('clear-selected-layers'),
|
||||
_resetGlobeBtn: document.getElementById('reset-globe-view'),
|
||||
_cockpitResetGlobeBtn: document.getElementById('cockpit-reset-globe'),
|
||||
_styleButtons: document.getElementById('style-buttons'),
|
||||
_locationSearch: document.getElementById('location-search'),
|
||||
_searchToggle: document.getElementById('search-toggle'),
|
||||
_safeFrameOverlay: document.getElementById('safe-frame-overlay'),
|
||||
|
||||
@@ -9,14 +9,6 @@ export class ShellFacade {
|
||||
return this._navigation.subscribeCameraHandoff(listener);
|
||||
}
|
||||
|
||||
get _models3dModeBtns() {
|
||||
return this._aircraftDisplay._models3dModeBtns;
|
||||
}
|
||||
|
||||
set _models3dModeBtns(value) {
|
||||
this._aircraftDisplay._models3dModeBtns = value;
|
||||
}
|
||||
|
||||
get _models3dEnabled() {
|
||||
return this._aircraftDisplay._models3dEnabled;
|
||||
}
|
||||
@@ -137,14 +129,6 @@ export class ShellFacade {
|
||||
this._displayBindings._frameRateMonitor = value;
|
||||
}
|
||||
|
||||
get _displayControls() {
|
||||
return this._displayBindings?._displayControls;
|
||||
}
|
||||
|
||||
set _displayControls(value) {
|
||||
this._displayBindings._displayControls = value;
|
||||
}
|
||||
|
||||
get _activeLocationId() {
|
||||
return this._locationNavigation._activeLocationId;
|
||||
}
|
||||
|
||||
@@ -5,7 +5,9 @@ import { markRaw, shallowRef } from 'vue';
|
||||
* The Display panel as its view renders it. VisualSettings presents its
|
||||
* groups (`_presentDisplay()`), AircraftDisplay the 3D aircraft group, the
|
||||
* draw tool its controls and the file import its list, and each publishes
|
||||
* here; each keeps its listeners on the controls. VisualSettings also
|
||||
* here. The Display controls' changes reach their owner (DisplayBindings)
|
||||
* through act(); the draw tool and the file import keep their listeners on
|
||||
* their controls. VisualSettings also
|
||||
* publishes the active style, which the dock and the scene chrome show, and
|
||||
* the map source controls publish their chip row beside it.
|
||||
*/
|
||||
@@ -66,6 +68,34 @@ export const useDisplayStore = defineStore('gev-display', () => {
|
||||
mapSourceControls.value = controls && markRaw(controls);
|
||||
}
|
||||
|
||||
/** @type {{actions: object}|null} The Display controls' owner. */
|
||||
let owner = null;
|
||||
|
||||
/**
|
||||
* Answer the Display controls (and the dock's style buttons) with
|
||||
* `actions`, replacing any earlier owner.
|
||||
* @param {Record<string, Function>} actions
|
||||
* @returns {object} Token for detach().
|
||||
*/
|
||||
function connect(actions) {
|
||||
owner = { actions };
|
||||
return owner;
|
||||
}
|
||||
|
||||
/** Stop answering for the owner holding `token`; a later owner stays. */
|
||||
function detach(token) {
|
||||
if (token === owner) owner = null;
|
||||
}
|
||||
|
||||
/**
|
||||
* A control's change: call the owner's action, if one is connected.
|
||||
* @param {string} name
|
||||
* @param {...unknown} args
|
||||
*/
|
||||
function act(name, ...args) {
|
||||
owner?.actions[name]?.(...args);
|
||||
}
|
||||
|
||||
/** The phone shell's tab bar; null until the shell first presents it. */
|
||||
const phoneShell = shallowRef(null);
|
||||
|
||||
@@ -91,5 +121,8 @@ export const useDisplayStore = defineStore('gev-display', () => {
|
||||
publishMapSource,
|
||||
mapSourceControls,
|
||||
connectMapSource,
|
||||
connect,
|
||||
detach,
|
||||
act,
|
||||
};
|
||||
});
|
||||
@@ -14,9 +14,10 @@ import PhoneTabBar from './panels/PhoneTabBar.vue';
|
||||
import VoiceControl from './panels/VoiceControl.vue';
|
||||
|
||||
// VisualSettings publishes the active style and the map source controls
|
||||
// their chip row; until they do, the dock shows its initial markup. The
|
||||
// style buttons keep their owner's listeners; a chip calls the controls.
|
||||
const { style, mapSource, mapSourceControls } = storeToRefs(useDisplayStore());
|
||||
// their chip row; until they do, the dock shows its initial markup. A style
|
||||
// button calls the Display controls' owner, a chip the map source controls.
|
||||
const display = useDisplayStore();
|
||||
const { style, mapSource, mapSourceControls } = storeToRefs(display);
|
||||
// LocationControls publishes the location tray; pills call the controls,
|
||||
// and the search field keeps their listeners.
|
||||
const { view: loc, controls: locationControls } = storeToRefs(useLocationStore());
|
||||
@@ -80,32 +81,32 @@ function selectMapSource(chip) {
|
||||
<div id="control-panel-popover" class="dock-popover-content">
|
||||
<UiButton variant="icon" class="dock-pin-btn" data-pin-target="control-panel" :label="t('Pin visual presets')" :title="t('Keep visual presets open')" :pressed="!!panels['control-panel']?.pinned" @click="pin($event, 'control-panel')"><img src="/pin.svg" alt="" /></UiButton>
|
||||
<div id="style-buttons" class="button-grid">
|
||||
<button class="style-btn active" data-style="normal" v-class-toggle="{ ...(style && { active: style.name === 'normal' }) }" title="Show the globe without a visual filter." data-i18n-title>
|
||||
<button class="style-btn active" data-style="normal" @click="display.act('setStyle', 'normal')" v-class-toggle="{ ...(style && { active: style.name === 'normal' }) }" title="Show the globe without a visual filter." data-i18n-title>
|
||||
<span class="btn-icon">◯</span>
|
||||
<span class="btn-label" data-i18n>Normal</span>
|
||||
<span class="btn-key">1</span>
|
||||
</button>
|
||||
<button class="style-btn" data-style="retro" v-class-toggle="{ ...(style && { active: style.name === 'retro' }) }" title="Emulate a green phosphor CRT with scanlines and screen curvature." data-i18n-title>
|
||||
<button class="style-btn" data-style="retro" @click="display.act('setStyle', 'retro')" v-class-toggle="{ ...(style && { active: style.name === 'retro' }) }" title="Emulate a green phosphor CRT with scanlines and screen curvature." data-i18n-title>
|
||||
<span class="btn-icon">▦</span>
|
||||
<span class="btn-label" data-i18n>CRT</span>
|
||||
<span class="btn-key">2</span>
|
||||
</button>
|
||||
<button class="style-btn" data-style="surveillance" v-class-toggle="{ ...(style && { active: style.name === 'surveillance' }) }" title="Simulate night-vision goggles with green intensification and a tube vignette." data-i18n-title>
|
||||
<button class="style-btn" data-style="surveillance" @click="display.act('setStyle', 'surveillance')" v-class-toggle="{ ...(style && { active: style.name === 'surveillance' }) }" title="Simulate night-vision goggles with green intensification and a tube vignette." data-i18n-title>
|
||||
<span class="btn-icon">🌙</span>
|
||||
<span class="btn-label" data-i18n>NVG</span>
|
||||
<span class="btn-key">3</span>
|
||||
</button>
|
||||
<button class="style-btn" data-style="thermal" v-class-toggle="{ ...(style && { active: style.name === 'thermal' }) }" title="Simulate FLIR-style thermal contrast. Turn up Ironbow for color." data-i18n-title>
|
||||
<button class="style-btn" data-style="thermal" @click="display.act('setStyle', 'thermal')" v-class-toggle="{ ...(style && { active: style.name === 'thermal' }) }" title="Simulate FLIR-style thermal contrast. Turn up Ironbow for color." data-i18n-title>
|
||||
<span class="btn-icon">🌡️</span>
|
||||
<span class="btn-label" data-i18n>FLIR</span>
|
||||
<span class="btn-key">4</span>
|
||||
</button>
|
||||
<button class="style-btn" data-style="anime" v-class-toggle="{ ...(style && { active: style.name === 'anime' }) }" title="Apply bright cel-shaded color and illustrated outlines." data-i18n-title>
|
||||
<button class="style-btn" data-style="anime" @click="display.act('setStyle', 'anime')" v-class-toggle="{ ...(style && { active: style.name === 'anime' }) }" title="Apply bright cel-shaded color and illustrated outlines." data-i18n-title>
|
||||
<span class="btn-icon">✦</span>
|
||||
<span class="btn-label" data-i18n>Anime</span>
|
||||
<span class="btn-key">5</span>
|
||||
</button>
|
||||
<button class="style-btn" data-style="noir" v-class-toggle="{ ...(style && { active: style.name === 'noir' }) }" title="Apply high-contrast monochrome film-noir grading." data-i18n-title>
|
||||
<button class="style-btn" data-style="noir" @click="display.act('setStyle', 'noir')" v-class-toggle="{ ...(style && { active: style.name === 'noir' }) }" title="Apply high-contrast monochrome film-noir grading." data-i18n-title>
|
||||
<span class="btn-icon">◐</span>
|
||||
<span class="btn-label" data-i18n>Noir</span>
|
||||
<span class="btn-key">6</span>
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
<script setup>
|
||||
import { useDisplayStore } from '../stores/display.js';
|
||||
import DisplayPanel from './panels/DisplayPanel.vue';
|
||||
|
||||
const display = useDisplayStore();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<DisplayPanel />
|
||||
<button id="clean-view-exit" title="Return UI controls" data-i18n-title data-i18n>EXIT CLEAN VIEW</button>
|
||||
<button id="clean-view-exit" @click="display.act('exitCleanView')" title="Return UI controls" data-i18n-title data-i18n>EXIT CLEAN VIEW</button>
|
||||
</template>
|
||||
@@ -5,13 +5,18 @@ import { t } from '../../../i18n/index.js';
|
||||
import { vPanelState } from '../../rail/panelState.js';
|
||||
import PanelCollapseButton from '../../rail/PanelCollapseButton.vue';
|
||||
import { vRangeValue } from '../../rail/rangeValue.js';
|
||||
import UiSegmented from '../../kit/UiSegmented.vue';
|
||||
import { useDisplayStore } from '../../stores/display.js';
|
||||
|
||||
// The Display panel. VisualSettings presents the HUD, Sonar, Scope,
|
||||
// Celestial, detection, bloom and sharpen, AircraftDisplay the 3D group, the
|
||||
// draw tool its controls and the file import its list, and each publishes
|
||||
// them; until they do, the panel shows its initial markup.
|
||||
const { view, models3d, draw, geoImport } = storeToRefs(useDisplayStore());
|
||||
const display = useDisplayStore();
|
||||
const { view, models3d, draw, geoImport } = storeToRefs(display);
|
||||
// A control's change goes to the Display controls' owner (DisplayBindings).
|
||||
const act = (name, ...args) => display.act(name, ...args);
|
||||
const integer = (event) => parseInt(event.target.value, 10);
|
||||
const pct = (value) => `${value}%`;
|
||||
const hud = computed(() => view.value?.hud ?? null);
|
||||
const sonar = computed(() => view.value?.sonar ?? null);
|
||||
@@ -34,10 +39,16 @@ const segment = (current) => (value) => ({
|
||||
class: { active: current.value === value },
|
||||
'aria-checked': String(current.value === value),
|
||||
});
|
||||
const modelsSegment = segment(modelsMode);
|
||||
const modelsOptions = [
|
||||
{ value: 'proximity', label: t('Proximity'), attrs: { id: 'models3d-mode-proximity', 'data-mode': 'proximity' } },
|
||||
{ value: 'all', label: t('All'), attrs: { id: 'models3d-mode-all', 'data-mode': 'all' } },
|
||||
];
|
||||
const drawShape = computed(() => draw.value?.shape ?? 'area');
|
||||
const drawSegment = segment(drawShape);
|
||||
const allocationSegment = segment(allocation);
|
||||
const allocationOptions = [
|
||||
{ value: 'ELASTIC', label: t('Elastic'), attrs: { id: 'detection-allocation-elastic', 'data-allocation': 'ELASTIC' } },
|
||||
{ value: 'WEIGHTED', label: t('Weighted'), attrs: { id: 'detection-allocation-weighted', 'data-allocation': 'WEIGHTED' } },
|
||||
];
|
||||
const geoRows = computed(() => geoImport.value?.rows ?? []);
|
||||
const geoListed = computed(() => geoRows.value.length > 0);
|
||||
</script>
|
||||
@@ -52,13 +63,13 @@ const geoListed = computed(() => geoRows.value.length > 0);
|
||||
</div>
|
||||
<div class="pp-panel-body ui-scroll" data-rail-scroller>
|
||||
<div class="pp-toggle-group">
|
||||
<button class="pp-toggle-btn" :class="{ active: hud?.visible }" id="hud-toggle" title="Intelligence HUD (H)" data-i18n-title>
|
||||
<button class="pp-toggle-btn" :class="{ active: hud?.visible }" id="hud-toggle" @click="act('toggleHud')" title="Intelligence HUD (H)" data-i18n-title>
|
||||
<span class="pp-icon">◎</span>
|
||||
<span class="pp-label" translate="no">HUD</span>
|
||||
</button>
|
||||
<div class="pp-slider-row" :class="{ visible: hud?.visible }" id="hud-layout-row">
|
||||
<span class="pp-slider-mini-label" data-i18n>Layout</span>
|
||||
<select id="hud-layout-select" class="pp-select gev-field gev-field--compact" v-range-value="hud?.variant ?? 'tactical'" aria-label="HUD layout" data-i18n-aria-label>
|
||||
<select id="hud-layout-select" @change="act('setHudLayout', $event.target.value)" class="pp-select gev-field gev-field--compact" v-range-value="hud?.variant ?? 'tactical'" aria-label="HUD layout" data-i18n-aria-label>
|
||||
<option value="tactical" data-i18n>Tactical</option>
|
||||
<option value="minimal" data-i18n>Minimal</option>
|
||||
<option value="cyber" data-i18n>Cyber</option>
|
||||
@@ -66,60 +77,57 @@ const geoListed = computed(() => geoRows.value.length > 0);
|
||||
</div>
|
||||
<div class="pp-slider-row cyber-sonar-control cyber-sonar-toggle-row" id="cyber-sonar-row">
|
||||
<span class="pp-slider-mini-label" data-i18n>Sonar</span>
|
||||
<button class="pp-mode-btn" :class="{ active: sonarOn }" id="cyber-sonar-toggle" title="Cyber sonar contact scanner" aria-label="Cyber sonar contact scanner" :aria-pressed="String(sonarOn)" data-i18n-title data-i18n-aria-label>{{ sonarOn ? t('ON') : t('OFF') }}</button>
|
||||
<button class="pp-mode-btn" :class="{ active: sonarOn }" id="cyber-sonar-toggle" @click="act('toggleSonar')" title="Cyber sonar contact scanner" aria-label="Cyber sonar contact scanner" :aria-pressed="String(sonarOn)" data-i18n-title data-i18n-aria-label>{{ sonarOn ? t('ON') : t('OFF') }}</button>
|
||||
</div>
|
||||
<output id="cyber-sonar-status" class="cyber-sonar-control" role="status"></output>
|
||||
<div class="pp-slider-row cyber-sonar-control cyber-sonar-setting" id="cyber-sonar-rings-row">
|
||||
<label class="pp-slider-mini-label" for="cyber-sonar-rings" data-i18n>Rings</label>
|
||||
<input type="range" class="gev-quantitative-slider pp-slider" id="cyber-sonar-rings" min="3" max="12" step="1" value="8" v-range-value="sonar?.rings ?? 8" aria-label="Sonar ring count" data-i18n-aria-label />
|
||||
<input type="range" class="gev-quantitative-slider pp-slider" id="cyber-sonar-rings" @input="act('setSonarRings', integer($event))" min="3" max="12" step="1" value="8" v-range-value="sonar?.rings ?? 8" aria-label="Sonar ring count" data-i18n-aria-label />
|
||||
<span class="pp-slider-value" id="cyber-sonar-rings-value">{{ sonar?.rings ?? 8 }}</span>
|
||||
</div>
|
||||
<div class="pp-slider-row cyber-sonar-control cyber-sonar-setting" id="cyber-sonar-range-row">
|
||||
<label class="pp-slider-mini-label" for="cyber-sonar-range" data-i18n>Range</label>
|
||||
<input type="range" class="gev-quantitative-slider pp-slider" id="cyber-sonar-range" min="60" max="120" step="1" value="100" v-range-value="sonar?.range ?? 100" aria-label="Sonar ring range" data-i18n-aria-label />
|
||||
<input type="range" class="gev-quantitative-slider pp-slider" id="cyber-sonar-range" @input="act('setSonarRange', integer($event))" min="60" max="120" step="1" value="100" v-range-value="sonar?.range ?? 100" aria-label="Sonar ring range" data-i18n-aria-label />
|
||||
<span class="pp-slider-value" id="cyber-sonar-range-value">{{ pct(sonar?.range ?? 100) }}</span>
|
||||
</div>
|
||||
<div class="pp-slider-row cyber-sonar-control cyber-sonar-setting" id="cyber-sonar-intensity-row">
|
||||
<label class="pp-slider-mini-label" for="cyber-sonar-intensity" data-i18n>Power</label>
|
||||
<input type="range" class="gev-quantitative-slider pp-slider" id="cyber-sonar-intensity" min="0" max="100" step="1" value="70" v-range-value="sonar?.intensity ?? 70" aria-label="Sonar intensity" data-i18n-aria-label />
|
||||
<input type="range" class="gev-quantitative-slider pp-slider" id="cyber-sonar-intensity" @input="act('setSonarIntensity', integer($event))" min="0" max="100" step="1" value="70" v-range-value="sonar?.intensity ?? 70" aria-label="Sonar intensity" data-i18n-aria-label />
|
||||
<span class="pp-slider-value" id="cyber-sonar-intensity-value">{{ pct(sonar?.intensity ?? 70) }}</span>
|
||||
</div>
|
||||
<div class="pp-slider-row cyber-sonar-control cyber-sonar-setting" id="cyber-sonar-opacity-row">
|
||||
<label class="pp-slider-mini-label" for="cyber-sonar-opacity" data-i18n>Opacity</label>
|
||||
<input type="range" class="gev-quantitative-slider pp-slider" id="cyber-sonar-opacity" min="35" max="100" step="1" value="84" v-range-value="sonar?.opacity ?? 84" aria-label="Cyber contact and label opacity" title="Minimum opacity for map dots, icons, models, brackets, and labels between sonar passes" data-i18n-aria-label data-i18n-title />
|
||||
<input type="range" class="gev-quantitative-slider pp-slider" id="cyber-sonar-opacity" @input="act('setSonarOpacity', integer($event))" min="35" max="100" step="1" value="84" v-range-value="sonar?.opacity ?? 84" aria-label="Cyber contact and label opacity" title="Minimum opacity for map dots, icons, models, brackets, and labels between sonar passes" data-i18n-aria-label data-i18n-title />
|
||||
<span class="pp-slider-value" id="cyber-sonar-opacity-value">{{ pct(sonar?.opacity ?? 84) }}</span>
|
||||
</div>
|
||||
<div class="pp-slider-row cyber-sonar-control cyber-sonar-setting" id="cyber-sonar-sector-row">
|
||||
<label class="pp-slider-mini-label" for="cyber-sonar-sector" data-i18n>Sector</label>
|
||||
<input type="range" class="gev-quantitative-slider pp-slider" id="cyber-sonar-sector" min="8" max="60" step="1" value="24" v-range-value="sonar?.sector ?? 24" aria-label="Sonar sector width" data-i18n-aria-label />
|
||||
<input type="range" class="gev-quantitative-slider pp-slider" id="cyber-sonar-sector" @input="act('setSonarSector', integer($event))" min="8" max="60" step="1" value="24" v-range-value="sonar?.sector ?? 24" aria-label="Sonar sector width" data-i18n-aria-label />
|
||||
<span class="pp-slider-value" id="cyber-sonar-sector-value">{{ `${sonar?.sector ?? 24}°` }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="pp-toggle-group">
|
||||
<button class="pp-toggle-btn" :class="{ active: detectButton?.active, panoptic: detectButton?.panoptic }" id="detection-toggle" title="Detection Overlay (D)" :aria-label="detectButton?.ariaLabel ?? t('Detection overlay')" :aria-pressed="String(detectButton?.pressed ?? false)" data-i18n-title>
|
||||
<button class="pp-toggle-btn" :class="{ active: detectButton?.active, panoptic: detectButton?.panoptic }" id="detection-toggle" @click="act('cycleDetection')" title="Detection Overlay (D)" :aria-label="detectButton?.ariaLabel ?? t('Detection overlay')" :aria-pressed="String(detectButton?.pressed ?? false)" data-i18n-title>
|
||||
<span class="pp-icon">▣</span>
|
||||
<span class="pp-label">{{ detectButton?.label ?? t('DETECT') }}</span>
|
||||
</button>
|
||||
<div class="pp-slider-row detection-slider-row" :class="{ visible: detection?.rowsVisible }" id="detection-slider-row">
|
||||
<label class="pp-slider-mini-label" for="detection-density-slider" data-i18n>Density</label>
|
||||
<input type="range" class="gev-quantitative-slider pp-slider" id="detection-density-slider" min="0" max="100" step="25" value="50" v-range-value="detection?.densityPct ?? 50" aria-label="Detection label density" data-i18n-aria-label />
|
||||
<input type="range" class="gev-quantitative-slider pp-slider" id="detection-density-slider" @input="act('setDensity', $event.target.value)" min="0" max="100" step="25" value="50" v-range-value="detection?.densityPct ?? 50" aria-label="Detection label density" data-i18n-aria-label />
|
||||
<span class="pp-slider-value" id="detection-density-value">{{ pct(detection?.densityPct ?? 50) }}</span>
|
||||
</div>
|
||||
<div class="pp-slider-row detection-allocation-row" :class="{ visible: detection?.rowsVisible }" id="detection-allocation-row">
|
||||
<span class="pp-slider-mini-label" data-i18n>Allocation</span>
|
||||
<div class="gev-segmented pp-mode-seg detection-allocation-seg" role="radiogroup" aria-label="Detection label allocation" data-i18n-aria-label>
|
||||
<button class="gev-segment pp-mode-btn" v-bind="allocationSegment('ELASTIC')" id="detection-allocation-elastic" data-allocation="ELASTIC" role="radio" data-i18n>Elastic</button>
|
||||
<button class="gev-segment pp-mode-btn" v-bind="allocationSegment('WEIGHTED')" id="detection-allocation-weighted" data-allocation="WEIGHTED" role="radio" data-i18n>Weighted</button>
|
||||
</div>
|
||||
<UiSegmented class="pp-mode-seg detection-allocation-seg" item-class="pp-mode-btn" keyboard="none" :label="t('Detection label allocation')" :options="allocationOptions" :model-value="allocation" @update:model-value="act('setAllocation', $event)" />
|
||||
</div>
|
||||
<div class="pp-slider-row detection-fade-row" :class="{ visible: detection?.rowsVisible }" id="detection-fade-row">
|
||||
<label class="pp-slider-mini-label" for="detection-fade-slider" data-i18n>Fade</label>
|
||||
<input type="range" class="gev-quantitative-slider pp-slider" id="detection-fade-slider" min="0" max="40" step="1" value="7" v-range-value="detection?.fadePct ?? 7" aria-label="Detection fade distance" title="World-overlay fade distance outside the keyhole as a percentage of its radius" data-i18n-aria-label data-i18n-title />
|
||||
<input type="range" class="gev-quantitative-slider pp-slider" id="detection-fade-slider" @input="act('setFade', integer($event))" min="0" max="40" step="1" value="7" v-range-value="detection?.fadePct ?? 7" aria-label="Detection fade distance" title="World-overlay fade distance outside the keyhole as a percentage of its radius" data-i18n-aria-label data-i18n-title />
|
||||
<span class="pp-slider-value" id="detection-fade-value">{{ pct(detection?.fadePct ?? 7) }}</span>
|
||||
</div>
|
||||
<div class="pp-slider-row detection-opacity-row" :class="{ visible: detection?.rowsVisible }" id="detection-opacity-row">
|
||||
<label class="pp-slider-mini-label" for="detection-opacity-slider" data-i18n>Outside</label>
|
||||
<input type="range" class="gev-quantitative-slider pp-slider" id="detection-opacity-slider" min="0" max="100" step="1" value="1" v-range-value="detection?.outsideOpacityPct ?? 1" aria-label="Detection opacity outside the keyhole" title="World-overlay label and card opacity beyond the fade distance" data-i18n-aria-label data-i18n-title />
|
||||
<input type="range" class="gev-quantitative-slider pp-slider" id="detection-opacity-slider" @input="act('setOutsideOpacity', integer($event))" min="0" max="100" step="1" value="1" v-range-value="detection?.outsideOpacityPct ?? 1" aria-label="Detection opacity outside the keyhole" title="World-overlay label and card opacity beyond the fade distance" data-i18n-aria-label data-i18n-title />
|
||||
<span class="pp-slider-value" id="detection-opacity-value">{{ pct(detection?.outsideOpacityPct ?? 1) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -137,27 +145,24 @@ const geoListed = computed(() => geoRows.value.length > 0);
|
||||
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
|
||||
is what flips them back for a share link or a stored session carrying 3D off. -->
|
||||
<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>
|
||||
<button class="pp-toggle-btn" :class="{ active: models3dOn }" id="models3d-toggle" @click="act('toggleModels')" :aria-pressed="String(models3dOn)" title="3D aircraft — flat icons zoomed out, 3D models up close" data-i18n-title>
|
||||
<span class="pp-icon">✈</span>
|
||||
<span class="pp-label">3D</span>
|
||||
</button>
|
||||
<div class="pp-slider-row" :class="{ visible: models3d?.rowVisible ?? true }" id="models3d-mode-row">
|
||||
<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>
|
||||
<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" v-bind="modelsSegment('all')" data-i18n>All</button>
|
||||
</div>
|
||||
<UiSegmented class="pp-mode-seg" item-class="pp-mode-btn" keyboard="none" :label="t('3D model coverage')" :options="modelsOptions" :model-value="modelsMode" @update:model-value="act('setModelsMode', $event)" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="pp-toggle-group">
|
||||
<label class="pp-toggle-btn pp-scope-toggle" title="Scope — the circular viewport mask" data-i18n-title>
|
||||
<span class="pp-icon material-symbols-outlined" aria-hidden="true">adjust</span>
|
||||
<span class="pp-label" data-i18n>Scope</span>
|
||||
<input type="checkbox" id="scope-toggle" :checked="scopeOn" />
|
||||
<input type="checkbox" id="scope-toggle" @change="act('setScopeEnabled', $event.target.checked)" :checked="scopeOn" />
|
||||
</label>
|
||||
<div class="pp-slider-row visible" id="scope-slider-row">
|
||||
<span class="pp-slider-mini-label" data-i18n>Feather</span>
|
||||
<input type="range" class="gev-quantitative-slider pp-slider" id="scope-feather-slider" min="0" max="200" value="100" v-range-value="scopeFeather" :disabled="!scopeOn" aria-label="Scope edge feather" title="Scope edge feather as a percentage of the keyhole radius" data-i18n-aria-label data-i18n-title />
|
||||
<input type="range" class="gev-quantitative-slider pp-slider" id="scope-feather-slider" @input="act('setScopeFeather', integer($event))" min="0" max="200" value="100" v-range-value="scopeFeather" :disabled="!scopeOn" aria-label="Scope edge feather" title="Scope edge feather as a percentage of the keyhole radius" data-i18n-aria-label data-i18n-title />
|
||||
<span class="pp-slider-value" id="scope-feather-value">{{ pct(scopeFeather) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -197,32 +202,32 @@ const geoListed = computed(() => geoRows.value.length > 0);
|
||||
<span class="draw-hint geo-import-hint" id="geo-import-hint" aria-live="polite">{{ geoImport?.hint ?? '' }}</span>
|
||||
</div>
|
||||
<div class="pp-toggle-group">
|
||||
<button class="pp-toggle-btn" :class="{ active: celestial?.active }" id="celestial-toggle" :title="celestialTitle" :aria-pressed="String(celestial?.active ?? false)" :disabled="celestial ? !celestial.supported : undefined" :aria-disabled="celestial ? String(!celestial.supported) : undefined">
|
||||
<button class="pp-toggle-btn" :class="{ active: celestial?.active }" id="celestial-toggle" @click="act('toggleCelestial')" :title="celestialTitle" :aria-pressed="String(celestial?.active ?? false)" :disabled="celestial ? !celestial.supported : undefined" :aria-disabled="celestial ? String(!celestial.supported) : undefined">
|
||||
<span class="pp-icon material-symbols-outlined" aria-hidden="true">flare</span>
|
||||
<span class="pp-label" data-i18n>Celestial</span>
|
||||
</button>
|
||||
</div>
|
||||
<button class="pp-toggle-btn" id="clean-view-toggle" title="Hide UI chrome" data-i18n-title>
|
||||
<button class="pp-toggle-btn" id="clean-view-toggle" @click="act('toggleCleanView')" title="Hide UI chrome" data-i18n-title>
|
||||
<span class="pp-icon">□</span>
|
||||
<span class="pp-label" data-i18n>Clean UI</span>
|
||||
</button>
|
||||
<div class="pp-toggle-group">
|
||||
<button class="pp-toggle-btn" :class="{ active: view?.bloom.active }" id="bloom-toggle" title="Bloom / Glow" data-i18n-title>
|
||||
<button class="pp-toggle-btn" :class="{ active: view?.bloom.active }" id="bloom-toggle" @click="act('toggleBloom')" title="Bloom / Glow" data-i18n-title>
|
||||
<span class="pp-icon">✨</span>
|
||||
<span class="pp-label" data-i18n>Bloom</span>
|
||||
</button>
|
||||
<div class="pp-slider-row" :class="{ visible: view?.bloom.active }" id="bloom-slider-row">
|
||||
<input type="range" class="gev-quantitative-slider pp-slider" id="bloom-intensity-slider" min="0" max="200" value="0" v-range-value="view?.bloom.intensity ?? 0" aria-label="Bloom intensity" data-i18n-aria-label />
|
||||
<input type="range" class="gev-quantitative-slider pp-slider" id="bloom-intensity-slider" @input="act('setBloomIntensity', integer($event))" min="0" max="200" value="0" v-range-value="view?.bloom.intensity ?? 0" aria-label="Bloom intensity" data-i18n-aria-label />
|
||||
<span class="pp-slider-value" id="bloom-intensity-value">{{ pct(view?.bloom.intensity ?? 0) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="pp-toggle-group">
|
||||
<button class="pp-toggle-btn" :class="{ active: view?.sharpen.active }" id="sharpen-toggle" title="Sharpening" data-i18n-title>
|
||||
<button class="pp-toggle-btn" :class="{ active: view?.sharpen.active }" id="sharpen-toggle" @click="act('toggleSharpen')" title="Sharpening" data-i18n-title>
|
||||
<span class="pp-icon">🔍</span>
|
||||
<span class="pp-label" data-i18n>Sharpen</span>
|
||||
</button>
|
||||
<div class="pp-slider-row" :class="{ visible: view?.sharpen.active }" id="sharpen-slider-row">
|
||||
<input type="range" class="gev-quantitative-slider pp-slider" id="sharpen-intensity-slider" min="0" max="100" value="49" v-range-value="view?.sharpen.intensity ?? 49" aria-label="Sharpen intensity" data-i18n-aria-label />
|
||||
<input type="range" class="gev-quantitative-slider pp-slider" id="sharpen-intensity-slider" @input="act('setSharpenIntensity', integer($event))" min="0" max="100" value="49" v-range-value="view?.sharpen.intensity ?? 49" aria-label="Sharpen intensity" data-i18n-aria-label />
|
||||
<span class="pp-slider-value" id="sharpen-intensity-value">{{ pct(view?.sharpen.intensity ?? 49) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
Reference in new issue
Block a user