feat(ui): render the Display panel's detection controls from the store

This commit is contained in:
Sameh Khamis committed 2026-10-30 22:51:47 -07:00
1 parent 343bdebe98
commit c99c0e8000
12 files changed
+273 -299

No files matched your search

+1 -1
View File
@@ -94,7 +94,7 @@ They contain no application state.
renders `LayerRows` synchronously; keyed chips and list items keep focus
through the refresh their own click triggers, and clicks re-read the live
descriptor before acting), Display (in progress: `DisplayPanel` renders
bloom and sharpen from the `gev-display` store, which VisualSettings
bloom, sharpen and detection from the `gev-display` store, which VisualSettings
publishes to; 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
+2 -2
View File
@@ -524,9 +524,9 @@ test('every explicit visual UI gesture claims restore authority before it mutate
['setScopeFeather:', 'setSharpenIntensity:', 'setScopeMaskFeather(', 'setScopeFeather control'],
['setSharpenIntensity:', 'setHudLayout:', 'this._setSharpenIntensity(', 'setSharpenIntensity control'],
['setHudLayout:', 'toggleCleanView:', 'this._setHudVariant(', 'setHudLayout control'],
['setDensity:', 'setAllocation:', 'this._applyDetectionDensityFromUi()', 'setDensity control'],
['setDensity:', 'setAllocation:', 'this._setDetectionDensity(', 'setDensity control'],
['setAllocation:', 'setFade:', 'this._setDetectionAllocation(', 'setAllocation control'],
['setFade:', 'toggleCelestial:', 'this._applyDetectionFadeFromUi()', 'setFade control'],
['setFade:', 'toggleCelestial:', 'this._setDetectionFade(', 'setFade control'],
];
for (const [start, end, mutation, label] of gestureRoutes) {
const startIndex = initUi.indexOf(start);
+4 -18
View File
@@ -209,17 +209,6 @@ export class StyleManager extends ShellFacade {
elements: {
_celestialBtn: this._celestialBtn,
_cockpitDisplayToggleBtn: this._cockpitDisplayToggleBtn,
_detectionAllocationRow: this._detectionAllocationRow,
_detectionBtn: this._detectionBtn,
_detectionDensitySlider: this._detectionDensitySlider,
_detectionDensityValue: this._detectionDensityValue,
_detectionFadeRow: this._detectionFadeRow,
_detectionFadeSlider: this._detectionFadeSlider,
_detectionFadeValue: this._detectionFadeValue,
_detectionOpacityRow: this._detectionOpacityRow,
_detectionOpacitySlider: this._detectionOpacitySlider,
_detectionOpacityValue: this._detectionOpacityValue,
_detectionSliderRow: this._detectionSliderRow,
_hudBtn: this._hudBtn,
_cyberSonarBtn: this._cyberSonarBtn,
_cyberSonarRings: this._cyberSonarRings,
@@ -507,7 +496,7 @@ export class StyleManager extends ShellFacade {
);
initTrackedReadout(viewer);
setDetectionStyle(this.activeStyle);
this._applyDetectionDensityFromUi();
this._setDetectionDensity();
this._initStages();
this._initBloomSharpen();
@@ -541,10 +530,10 @@ export class StyleManager extends ShellFacade {
_detectionBtn: this._detectionBtn,
_detectionFadeSlider: this._detectionFadeSlider,
_detectionOpacitySlider: this._detectionOpacitySlider,
_detectionAllocationBtns: this._detectionAllocationBtns,
_celestialBtn: this._celestialBtn,
_models3dBtn: this._models3dBtn,
_scopeFeatherValue: this._scopeFeatherValue,
_detectionDensityValue: this._detectionDensityValue,
},
operations: {
setStyle: (...args) => this.setStyle(...args),
@@ -560,12 +549,10 @@ export class StyleManager extends ShellFacade {
_setHudVariant: (...args) => this._setHudVariant(...args),
_setCyberSonarEnabled: (...args) => this._setCyberSonarEnabled(...args),
_setCyberSonarSetting: (...args) => this._setCyberSonarSetting(...args),
_applyDetectionDensityFromUi: (...args) =>
this._applyDetectionDensityFromUi(...args),
_setDetectionDensity: (...args) => this._setDetectionDensity(...args),
_setDetectionAllocation: (...args) =>
this._setDetectionAllocation(...args),
_applyDetectionFadeFromUi: (...args) =>
this._applyDetectionFadeFromUi(...args),
_setDetectionFade: (...args) => this._setDetectionFade(...args),
setCelestialRingEnabled: (...args) =>
this.setCelestialRingEnabled(...args),
_setModels3dEnabled: (...args) => this._setModels3dEnabled(...args),
@@ -581,7 +568,6 @@ export class StyleManager extends ShellFacade {
celestialRingEnabled: this.celestialRingEnabled,
_models3dEnabled: this._models3dEnabled,
_models3dModeBtns: this._models3dModeBtns,
_detectionAllocationBtns: this._detectionAllocationBtns,
}),
claimDetection: () => {
this._visualSettings._detectionUserOverridden = true;
+1 -1
View File
@@ -93,7 +93,7 @@ function styleOwner(t, initialVariant = 'cyber') {
'_syncCockpitInheritedStyle',
'_syncCyberSonarControl',
'_scheduleAdaptivePanelLayout',
'_applyDetectionFadeFromUi',
'_setDetectionFade',
// Intensities: the fixture has no post-process stages to drive.
'_setBloomIntensity',
'_setSharpenIntensity',
+10 -12
View File
@@ -2,7 +2,6 @@ import { clampScopeFeatherPct } from '../scopeMaskPolicy.js';
import { createFrameRateMonitor } from './frameRateMonitor.js';
import { bindApplicationShortcuts } from './visualInput.js';
import { bindDisplayControls, syncScopeControls } from './displayControls.js';
import { canonicalizeDensity } from '../data/detectionPolicy.js';
/** Own keyboard/display event subscriptions; settings remain with their state owners. */
export class DisplayBindings {
@@ -45,9 +44,6 @@ export class DisplayBindings {
get _models3dModeBtns() {
return this.readState()._models3dModeBtns;
}
get _detectionAllocationBtns() {
return this.readState()._detectionAllocationBtns;
}
_initUI() {
const {
cycleDetectionMode,
@@ -121,7 +117,8 @@ export class DisplayBindings {
densitySlider: this._detectionDensitySlider,
detectionButton: this._detectionBtn,
allocationButtons: this._detectionAllocationBtns,
fadeSliders: [this._detectionFadeSlider, this._detectionOpacitySlider],
fadeSlider: this._detectionFadeSlider,
outsideOpacitySlider: this._detectionOpacitySlider,
celestialButton: this._celestialBtn,
modelsButton: this._models3dBtn,
modelModeButtons: this._models3dBtn ? this._models3dModeBtns : [],
@@ -167,11 +164,7 @@ export class DisplayBindings {
setDensity: (value) => {
this.shareLinkManager?.claimRestoreLane?.('visual');
this.claimDetection();
const pct = canonicalizeDensity(value);
this._detectionDensitySlider.value = String(pct);
if (this._detectionDensityValue)
this._detectionDensityValue.textContent = `${pct}%`;
this._applyDetectionDensityFromUi();
this._setDetectionDensity(value);
this._syncShareState();
},
setAllocation: (value) => {
@@ -179,9 +172,14 @@ export class DisplayBindings {
this.claimDetection();
this._setDetectionAllocation(value);
},
setFade: () => {
setFade: (fadePct) => {
this.shareLinkManager?.claimRestoreLane?.('visual');
this._applyDetectionFadeFromUi();
this._setDetectionFade({ fadePct });
this._syncShareState();
},
setOutsideOpacity: (outsideOpacityPct) => {
this.shareLinkManager?.claimRestoreLane?.('visual');
this._setDetectionFade({ outsideOpacityPct });
this._syncShareState();
},
toggleCelestial: () => {
+2 -1
View File
@@ -65,7 +65,8 @@ export function bindDisplayControls({ elements, actions }) {
});
removers.push(() => control.destroy());
}
for (const el of elements.fadeSliders || []) listen(el, 'input', 'setFade');
listen(elements.fadeSlider, 'input', 'setFade', integer);
listen(elements.outsideOpacitySlider, 'input', 'setOutsideOpacity', integer);
return {
destroy() {
for (const remove of removers.splice(0)) remove();
+3 -3
View File
@@ -145,11 +145,11 @@ test('optional controls are absent safely and subscriptions stay instance-owned'
const a = element();
const b = element();
const one = bindDisplayControls({
elements: { fadeSliders: [null, a] },
actions: { setFade: () => calls.push('a') },
elements: { fadeSlider: null, outsideOpacitySlider: a },
actions: { setOutsideOpacity: () => calls.push('a') },
});
const two = bindDisplayControls({
elements: { fadeSliders: [b] },
elements: { fadeSlider: b },
actions: { setFade: () => calls.push('b') },
});
one.destroy();
+84
View File
@@ -121,3 +121,87 @@ test('a publish never rewrites a slider the user is dragging', async (t) => {
assert.equal(writes, 0);
assert.equal(slider.value, '30');
});
/** VisualSettings with a stand-in detection engine. */
function detectionSettings(store) {
const { owner } = settings(store);
const engine = {
mode: 'OFF',
tuning: { densityPct: 50, allocationStrategy: 'ELASTIC' },
fade: { fadeRatio: 0.07, outsideOpacity: 0.01 },
};
Object.assign(owner, {
_detectionDensityPct: 50,
_detectionFadePct: 7,
_detectionOutsidePct: 1,
_detectionModeLabel: null,
_detectionAllocationShown: null,
viewer: { scene: { requestRender() {} } },
});
owner.services = {
...owner.services,
getDetectionMode: () => engine.mode,
setDetectionModeByLabel: (label) => {
engine.mode = label;
},
getDetectionTuning: () => ({ ...engine.tuning }),
setDetectionTuning: (next) => Object.assign(engine.tuning, next),
getKeyholeFadeTuning: () => ({ ...engine.fade }),
setKeyholeFadeTuning: (next) => Object.assign(engine.fade, next),
};
return { owner, engine };
}
test('detection renders its mode, rows, sliders and allocation', async (t) => {
const { store, $ } = fixture(t);
const { owner, engine } = detectionSettings(store);
const button = $('detection-toggle');
assert.equal(button.querySelector('.pp-label').textContent, 'DETECT');
assert.equal(button.getAttribute('aria-label'), 'Detection overlay');
owner._setDetectionMode('DENSE');
owner._setDetectionDensity(80);
await nextTick();
assert.equal(button.querySelector('.pp-label').textContent, 'DENSE');
assert.equal(button.getAttribute('aria-pressed'), 'true');
assert.equal(button.getAttribute('aria-label'), 'Detection overlay: dense');
assert.equal(button.className, 'pp-toggle-btn active panoptic');
for (const id of [
'detection-slider-row',
'detection-allocation-row',
'detection-fade-row',
'detection-opacity-row',
])
assert.equal($(id).classList.contains('visible'), true, id);
assert.equal($('detection-density-slider').value, '75', 'nearest stop');
assert.equal($('detection-density-value').textContent, '75%');
assert.equal(engine.tuning.densityPct, 75);
owner._setDetectionFade({ fadePct: 55 });
owner._setDetectionAllocation('weighted', { persist: false });
await nextTick();
assert.equal($('detection-fade-slider').value, '40', 'clamped to 40');
assert.equal($('detection-fade-value').textContent, '40%');
assert.equal($('detection-opacity-value').textContent, '1%', 'kept');
assert.equal(engine.fade.fadeRatio, 0.4);
const weighted = $('detection-allocation-weighted');
const elastic = $('detection-allocation-elastic');
assert.equal(weighted.classList.contains('active'), true);
assert.equal(weighted.getAttribute('aria-checked'), 'true');
assert.equal(elastic.classList.contains('active'), false);
assert.equal(elastic.getAttribute('aria-checked'), 'false');
assert.deepEqual(owner.getDetectionState(), {
detectionMode: 'DENSE',
densityPct: 75,
allocationStrategy: 'WEIGHTED',
fadePct: 40,
outsideOpacityPct: 1,
});
owner._setDetectionMode('OFF');
await nextTick();
assert.equal(button.querySelector('.pp-label').textContent, 'DETECT');
assert.equal(button.getAttribute('aria-label'), 'Detection overlay: off');
assert.equal(button.className, 'pp-toggle-btn');
assert.equal($('detection-slider-row').classList.contains('visible'), false);
});
+4 -9
View File
@@ -28,22 +28,17 @@ export function readShellElements(document = globalThis.document) {
),
_cyberSonarSector: document.getElementById('cyber-sonar-sector'),
_cyberSonarSectorValue: document.getElementById('cyber-sonar-sector-value'),
_detectionSliderRow: document.getElementById('detection-slider-row'),
_detectionDensitySlider: document.getElementById(
'detection-density-slider',
),
_detectionDensityValue: document.getElementById('detection-density-value'),
_detectionAllocationRow: document.getElementById(
'detection-allocation-row',
),
_detectionFadeRow: document.getElementById('detection-fade-row'),
_detectionAllocationBtns: [
document.getElementById('detection-allocation-elastic'),
document.getElementById('detection-allocation-weighted'),
].filter(Boolean),
_detectionFadeSlider: document.getElementById('detection-fade-slider'),
_detectionFadeValue: document.getElementById('detection-fade-value'),
_detectionOpacityRow: document.getElementById('detection-opacity-row'),
_detectionOpacitySlider: document.getElementById(
'detection-opacity-slider',
),
_detectionOpacityValue: document.getElementById('detection-opacity-value'),
_celestialBtn: document.getElementById('celestial-toggle'),
_scopeBtn: document.getElementById('scope-toggle'),
_scopeFeatherSlider: document.getElementById('scope-feather-slider'),
+14 -15
View File
@@ -429,14 +429,6 @@ export class ShellFacade {
this._visualSettings._contactsDetectionRestore = value;
}
get _detectionAllocationBtns() {
return this._visualSettings._detectionAllocationBtns;
}
set _detectionAllocationBtns(value) {
this._visualSettings._detectionAllocationBtns = value;
}
get _detectionAllocationPreference() {
return this._visualSettings._detectionAllocationPreference;
}
@@ -716,17 +708,24 @@ export class ShellFacade {
}
/**
* Reads and canonicalizes the five-stop density control. The engine derives
* Sparse/Balanced/Dense from the same stop.
* Sets the five-stop density control, canonicalized; the engine derives
* Sparse/Balanced/Dense from the same stop. Without a value it reapplies
* the current stop.
* @param {number} [pct]
* @returns {void}
*/
_applyDetectionDensityFromUi() {
return this._visualSettings._applyDetectionDensityFromUi(...arguments);
_setDetectionDensity(pct) {
return this._visualSettings._setDetectionDensity(...arguments);
}
/** Apply responsive keyhole fade controls from normalized UI percentages. */
_applyDetectionFadeFromUi() {
return this._visualSettings._applyDetectionFadeFromUi(...arguments);
/**
* Sets the keyhole fade distance and outside opacity, in percent; either
* one left out keeps its current value.
* @param {{fadePct?: number, outsideOpacityPct?: number}} [tuning]
* @returns {void}
*/
_setDetectionFade(tuning) {
return this._visualSettings._setDetectionFade(...arguments);
}
_syncDetectionUiFromEngine() {
+29 -19
View File
@@ -1,12 +1,22 @@
<script setup>
import { storeToRefs } from 'pinia';
import { computed } from 'vue';
import { t } from '../../../i18n/index.js';
import { vRangeValue } from '../../rail/rangeValue.js';
import { useDisplayStore } from '../../stores/display.js';
// The Display panel. VisualSettings presents bloom and sharpen and
// publishes them; until it does, the panel shows its initial markup. The
// other groups keep their owners' element bindings.
// The Display panel. VisualSettings presents bloom, sharpen and detection
// and publishes them; until it does, the panel shows its initial markup.
// The other groups keep their owners' element bindings.
const { view } = storeToRefs(useDisplayStore());
const pct = (value) => `${value}%`;
const detection = computed(() => view.value?.detection ?? null);
const detectButton = computed(() => detection.value?.button ?? null);
const allocation = computed(() => detection.value?.allocation ?? 'ELASTIC');
const allocationSegment = (value) => ({
class: { active: allocation.value === value },
'aria-checked': String(allocation.value === value),
});
</script>
<template>
@@ -63,31 +73,31 @@ const { view } = storeToRefs(useDisplayStore());
</div>
</div>
<div class="pp-toggle-group">
<button class="pp-toggle-btn" id="detection-toggle" title="Detection Overlay (D)" aria-label="Detection overlay" aria-pressed="false" data-i18n-title data-i18n-aria-label>
<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>
<span class="pp-icon">&#x25A3;</span>
<span class="pp-label" data-i18n>DETECT</span>
<span class="pp-label">{{ detectButton?.label ?? t('DETECT') }}</span>
</button>
<div class="pp-slider-row detection-slider-row" id="detection-slider-row">
<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" aria-label="Detection label density" data-i18n-aria-label />
<span class="pp-slider-value" id="detection-density-value">50%</span>
<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 />
<span class="pp-slider-value" id="detection-density-value">{{ pct(detection?.densityPct ?? 50) }}</span>
</div>
<div class="pp-slider-row detection-allocation-row" id="detection-allocation-row">
<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 active" id="detection-allocation-elastic" data-allocation="ELASTIC" role="radio" aria-checked="true" data-i18n>Elastic</button>
<button class="gev-segment pp-mode-btn" id="detection-allocation-weighted" data-allocation="WEIGHTED" role="radio" aria-checked="false" data-i18n>Weighted</button>
<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>
</div>
<div class="pp-slider-row detection-fade-row" id="detection-fade-row">
<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" 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">7%</span>
<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 />
<span class="pp-slider-value" id="detection-fade-value">{{ pct(detection?.fadePct ?? 7) }}</span>
</div>
<div class="pp-slider-row detection-opacity-row" id="detection-opacity-row">
<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" 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">1%</span>
<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 />
<span class="pp-slider-value" id="detection-opacity-value">{{ pct(detection?.outsideOpacityPct ?? 1) }}</span>
</div>
</div>
<!-- The one shared preset-parameter surface is inserted here at runtime. -->
@@ -180,7 +190,7 @@ const { view } = storeToRefs(useDisplayStore());
</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 />
<span class="pp-slider-value" id="bloom-intensity-value">{{ view?.bloom.text ?? '0%' }}</span>
<span class="pp-slider-value" id="bloom-intensity-value">{{ pct(view?.bloom.intensity ?? 0) }}</span>
</div>
</div>
<div class="pp-toggle-group">
@@ -190,7 +200,7 @@ const { view } = storeToRefs(useDisplayStore());
</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 />
<span class="pp-slider-value" id="sharpen-intensity-value">{{ view?.sharpen.text ?? '49%' }}</span>
<span class="pp-slider-value" id="sharpen-intensity-value">{{ pct(view?.sharpen.intensity ?? 49) }}</span>
</div>
</div>
</div>
+119 -218
View File
@@ -2,7 +2,6 @@ import { clampScopeFeatherPct } from '../scopeMaskPolicy.js';
import { syncScopeControls } from './displayControls.js';
import { STYLE_STATUS_LABELS } from './visualPresets.js';
import { RETIRED_STYLES } from '../visualStylePolicy.js';
import { syncSegmentedControl } from './segmentedControl.js';
import { useDisplayStore } from './stores/display.js';
import { applicationPinia } from './vueRuntime.js';
import { t } from '../i18n/index.js';
@@ -117,28 +116,22 @@ export class VisualSettings {
this.hud.setMode(hudVisible ? 'on' : 'off');
this._updateHudButtonState();
}
if (typeof detectionDensity === 'number' && this._detectionDensitySlider) {
const pct = canonicalizeDensity(detectionDensity);
this._detectionDensitySlider.value = String(pct);
this._detectionDensityValue.textContent = `${pct}%`;
this._applyDetectionDensityFromUi();
}
if (typeof detectionDensity === 'number')
this._setDetectionDensity(detectionDensity);
if (detectionAllocation) {
this._setDetectionAllocation(detectionAllocation, {
syncShare: false,
persist: false,
});
}
if (typeof detectionFadePct === 'number' && this._detectionFadeSlider) {
this._detectionFadeSlider.value = String(detectionFadePct);
}
if (
typeof detectionOutsideOpacityPct === 'number' &&
this._detectionOpacitySlider
) {
this._detectionOpacitySlider.value = String(detectionOutsideOpacityPct);
}
this._applyDetectionFadeFromUi();
this._setDetectionFade({
fadePct:
typeof detectionFadePct === 'number' ? detectionFadePct : undefined,
outsideOpacityPct:
typeof detectionOutsideOpacityPct === 'number'
? detectionOutsideOpacityPct
: undefined,
});
if (detectionMode) this._setDetectionMode(detectionMode);
if (typeof celestialRing === 'boolean') {
this.setCelestialRingEnabled(celestialRing, {
@@ -212,6 +205,14 @@ export class VisualSettings {
// The sharpen amount as a whole percentage. VisualEffects keeps the
// fraction it applies, and drops writes once it is stopped.
this._sharpenPct = 49;
// Detection's density stop, keyhole fade and outside opacity, in whole
// percentages; the mode label the button shows (null until first
// synced) and the allocation its segments show (null until set).
this._detectionDensityPct = 50;
this._detectionFadePct = 7;
this._detectionOutsidePct = 1;
this._detectionModeLabel = null;
this._detectionAllocationShown = null;
this.activeStyle = 'normal';
this._preCyberStyle = null;
this._cyberStyleUserSelected = false;
@@ -220,10 +221,6 @@ export class VisualSettings {
this._cockpitVisionMode = 'optical';
this._cockpitVisionRestore = null;
this._contactsDetectionRestore = null;
this._detectionAllocationBtns = [
document.getElementById('detection-allocation-elastic'),
document.getElementById('detection-allocation-weighted'),
].filter(Boolean);
let storedDetectionAllocation = 'ELASTIC';
try {
storedDetectionAllocation =
@@ -475,14 +472,43 @@ export class VisualSettings {
/** Present the Display panel's post-processing groups. */
_presentDisplay() {
const bloom = this.bloomIntensityPct;
return {
bloom: { active: this.bloomEnabled, intensity: bloom, text: `${bloom}%` },
sharpen: {
active: this.sharpenEnabled,
intensity: this._sharpenPct,
text: `${this._sharpenPct}%`,
},
bloom: { active: this.bloomEnabled, intensity: this.bloomIntensityPct },
sharpen: { active: this.sharpenEnabled, intensity: this._sharpenPct },
detection: this._presentDetection(),
};
}
/** Detection's button, rows, sliders and allocation segments. */
_presentDetection() {
const mode = this._detectionModeLabel;
const enabled = mode != null && mode !== 'OFF';
const labels = {
SPARSE: t('SPARSE'),
BALANCED: t('BALANCED'),
DENSE: t('DENSE'),
};
return {
// Null until the first sync, so the initial markup stands.
button:
mode == null
? null
: {
pressed: enabled,
ariaLabel: enabled
? t('Detection overlay: {mode}', {
mode: String(mode).toLowerCase(),
})
: t('Detection overlay: off'),
label: labels[mode] ?? t('DETECT'),
active: Object.hasOwn(labels, mode),
panoptic: mode === 'DENSE',
},
rowsVisible: enabled,
densityPct: this._detectionDensityPct,
fadePct: this._detectionFadePct,
outsideOpacityPct: this._detectionOutsidePct,
allocation: this._detectionAllocationShown,
};
}
@@ -547,44 +573,31 @@ export class VisualSettings {
this._layoutRightPanels();
}
_applyDetectionDensityFromUi() {
/** Set detection's density to its nearest stop and apply it. */
_setDetectionDensity(pct = this._detectionDensityPct) {
const { getDetectionMode, setDetectionTuning } = this.services;
if (!this._detectionDensitySlider) return;
const pct = canonicalizeDensity(this._detectionDensitySlider.value);
this._detectionDensitySlider.value = String(pct);
if (this._detectionDensityValue)
this._detectionDensityValue.textContent = `${pct}%`;
setDetectionTuning({ densityPct: pct });
this._detectionDensityPct = canonicalizeDensity(pct);
setDetectionTuning({ densityPct: this._detectionDensityPct });
this._updateDetectionButton(getDetectionMode());
}
_applyDetectionFadeFromUi() {
/**
* Set the keyhole fade distance (0-40%) and the opacity outside it
* (0-100%), keeping either one that is not given, and apply them.
*/
_setDetectionFade({
fadePct = this._detectionFadePct,
outsideOpacityPct = this._detectionOutsidePct,
} = {}) {
const { setKeyholeFadeTuning } = this.services;
const fadePct = Math.max(
0,
Math.min(40, Math.round(Number(this._detectionFadeSlider?.value) || 0)),
);
const outsideOpacityValue = this._detectionOpacitySlider?.value;
const outsideOpacityPct = Math.max(
0,
Math.min(
100,
Math.round(
outsideOpacityValue == null ? 3 : Number(outsideOpacityValue) || 0,
),
),
);
if (this._detectionFadeSlider)
this._detectionFadeSlider.value = String(fadePct);
if (this._detectionFadeValue)
this._detectionFadeValue.textContent = `${fadePct}%`;
if (this._detectionOpacitySlider)
this._detectionOpacitySlider.value = String(outsideOpacityPct);
if (this._detectionOpacityValue)
this._detectionOpacityValue.textContent = `${outsideOpacityPct}%`;
const whole = (value, max) =>
Math.max(0, Math.min(max, Math.round(Number(value) || 0)));
this._detectionFadePct = whole(fadePct, 40);
this._detectionOutsidePct = whole(outsideOpacityPct, 100);
this._publishDisplay();
setKeyholeFadeTuning({
fadeRatio: fadePct / 100,
outsideOpacity: outsideOpacityPct / 100,
fadeRatio: this._detectionFadePct / 100,
outsideOpacity: this._detectionOutsidePct / 100,
});
this.viewer.scene.requestRender?.();
}
@@ -598,9 +611,8 @@ export class VisualSettings {
const normalized = normalizeAllocationStrategy(raw);
this._detectionAllocationPreference = normalized;
setDetectionTuning({ allocationStrategy: normalized });
syncSegmentedControl(this._detectionAllocationBtns, normalized, {
valueOf: (button) => button.dataset.allocation,
});
this._detectionAllocationShown = normalized;
this._publishDisplay();
if (persist) {
try {
localStorage.setItem(DETECTION_ALLOCATION_STORAGE_KEY, normalized);
@@ -616,25 +628,16 @@ export class VisualSettings {
const { getKeyholeFadeTuning, getDetectionTuning, getDetectionMode } =
this.services;
const tuning = getDetectionTuning();
if (this._detectionDensitySlider)
this._detectionDensitySlider.value = String(tuning.densityPct);
if (this._detectionDensityValue)
this._detectionDensityValue.textContent = `${tuning.densityPct}%`;
this._detectionDensityPct = canonicalizeDensity(tuning.densityPct);
this._setDetectionAllocation(tuning.allocationStrategy, {
syncShare: false,
persist: false,
});
const fadeTuning = getKeyholeFadeTuning();
if (this._detectionFadeSlider)
this._detectionFadeSlider.value = String(
Math.round(fadeTuning.fadeRatio * 100),
);
if (this._detectionOpacitySlider) {
this._detectionOpacitySlider.value = String(
Math.round(fadeTuning.outsideOpacity * 100),
);
}
this._applyDetectionFadeFromUi();
this._setDetectionFade({
fadePct: fadeTuning.fadeRatio * 100,
outsideOpacityPct: fadeTuning.outsideOpacity * 100,
});
this._updateDetectionButton(getDetectionMode());
}
@@ -810,13 +813,8 @@ export class VisualSettings {
_applyDetectionPreset(det) {
if (!det) return;
if (typeof det.densityPct === 'number' && this._detectionDensitySlider) {
const pct = canonicalizeDensity(det.densityPct);
this._detectionDensitySlider.value = String(pct);
if (this._detectionDensityValue)
this._detectionDensityValue.textContent = `${pct}%`;
this._applyDetectionDensityFromUi();
}
if (typeof det.densityPct === 'number')
this._setDetectionDensity(det.densityPct);
if (det.mode) this._setDetectionMode(String(det.mode).toUpperCase());
}
@@ -850,30 +848,18 @@ export class VisualSettings {
if (defaults.detectionMode) {
this._setDetectionMode(defaults.detectionMode);
}
if (
typeof defaults.detectionDensity === 'number' &&
this._detectionDensitySlider
) {
const density = canonicalizeDensity(defaults.detectionDensity);
this._detectionDensitySlider.value = String(density);
this._detectionDensityValue.textContent = `${density}%`;
this._applyDetectionDensityFromUi();
}
if (typeof defaults.detectionDensity === 'number')
this._setDetectionDensity(defaults.detectionDensity);
this._setDetectionAllocation(
this._detectionAllocationPreference ||
defaults.detectionAllocation ||
'ELASTIC',
{ syncShare: false, persist: false },
);
if (this._detectionFadeSlider) {
this._detectionFadeSlider.value = String(defaults.detectionFadePct ?? 7);
}
if (this._detectionOpacitySlider) {
this._detectionOpacitySlider.value = String(
defaults.detectionOutsideOpacityPct ?? 1,
);
}
this._applyDetectionFadeFromUi();
this._setDetectionFade({
fadePct: defaults.detectionFadePct ?? 7,
outsideOpacityPct: defaults.detectionOutsideOpacityPct ?? 1,
});
if (typeof defaults.celestialRing === 'boolean') {
this.setCelestialRingEnabled(defaults.celestialRing, {
syncShare: false,
@@ -887,7 +873,7 @@ export class VisualSettings {
return shareableDetectionState({
owned: this._contactsDetectionRestore,
liveMode: getDetectionMode(),
liveDensityPct: parseInt(this._detectionDensitySlider?.value || '50', 10),
liveDensityPct: this._detectionDensityPct,
});
}
@@ -911,11 +897,8 @@ export class VisualSettings {
detectionMode: detection.mode,
detectionDensity: detection.densityPct,
detectionAllocation: getDetectionTuning().allocationStrategy,
detectionFadePct: parseInt(this._detectionFadeSlider?.value || '7', 10),
detectionOutsideOpacityPct: parseInt(
this._detectionOpacitySlider?.value || '1',
10,
),
detectionFadePct: this._detectionFadePct,
detectionOutsideOpacityPct: this._detectionOutsidePct,
celestialRingEnabled: this.celestialRingEnabled,
scopeEnabled: isScopeMaskEnabled(),
scopeFeatherPct: Math.round(getScopeMaskFeather() * 100),
@@ -931,18 +914,12 @@ export class VisualSettings {
getDetectionState() {
const { getDetectionTuning, getDetectionMode } = this.services;
const pct = this._detectionDensitySlider
? parseInt(this._detectionDensitySlider.value, 10)
: null;
return {
detectionMode: getDetectionMode(),
densityPct: pct,
densityPct: this._detectionDensityPct,
allocationStrategy: getDetectionTuning().allocationStrategy,
fadePct: parseInt(this._detectionFadeSlider?.value || '7', 10),
outsideOpacityPct: parseInt(
this._detectionOpacitySlider?.value || '0',
10,
),
fadePct: this._detectionFadePct,
outsideOpacityPct: this._detectionOutsidePct,
};
}
@@ -1047,18 +1024,11 @@ export class VisualSettings {
if (requestedAllocation) {
this._setDetectionAllocation(requestedAllocation, { syncShare: false });
}
if (fadePct != null && this._detectionFadeSlider) {
this._detectionFadeSlider.value = String(
Math.max(0, Math.min(40, Math.round(Number(fadePct)))),
);
}
if (outsideOpacityPct != null && this._detectionOpacitySlider) {
this._detectionOpacitySlider.value = String(
Math.max(0, Math.min(100, Math.round(Number(outsideOpacityPct)))),
);
}
if (fadePct != null || outsideOpacityPct != null)
this._applyDetectionFadeFromUi();
this._setDetectionFade({
fadePct: fadePct ?? undefined,
outsideOpacityPct: outsideOpacityPct ?? undefined,
});
if (
requestedProfile &&
@@ -1067,10 +1037,7 @@ export class VisualSettings {
) {
requestedDensity = defaultDensityForProfile(requestedProfile);
}
if (requestedDensity != null && this._detectionDensitySlider) {
this._detectionDensitySlider.value = String(requestedDensity);
this._applyDetectionDensityFromUi();
}
if (requestedDensity != null) this._setDetectionDensity(requestedDensity);
if (enabled === false || requestedProfile === 'OFF') {
setDetectionModeByLabel('OFF');
@@ -1078,9 +1045,7 @@ export class VisualSettings {
setDetectionModeByLabel(requestedProfile);
} else if (enabled === true && getDetectionMode() === 'OFF') {
setDetectionModeByLabel(
profileForDensity(
requestedDensity ?? this._detectionDensitySlider?.value ?? 50,
),
profileForDensity(requestedDensity ?? this._detectionDensityPct),
);
}
this._syncDetectionUiFromEngine();
@@ -1255,13 +1220,10 @@ export class VisualSettings {
},
detection: {
mode: getDetectionMode(),
density: parseInt(this._detectionDensitySlider?.value || '50', 10),
density: this._detectionDensityPct,
allocation: getDetectionTuning().allocationStrategy,
fadePct: parseInt(this._detectionFadeSlider?.value || '7', 10),
outsideOpacityPct: parseInt(
this._detectionOpacitySlider?.value || '0',
10,
),
fadePct: this._detectionFadePct,
outsideOpacityPct: this._detectionOutsidePct,
},
scope: {
enabled: isScopeMaskEnabled(),
@@ -1328,36 +1290,23 @@ export class VisualSettings {
}
const detectionState = state.detection || {};
if (
typeof detectionState.density === 'number' &&
this._detectionDensitySlider
) {
const pct = canonicalizeDensity(detectionState.density);
this._detectionDensitySlider.value = String(pct);
if (this._detectionDensityValue)
this._detectionDensityValue.textContent = `${pct}%`;
this._applyDetectionDensityFromUi();
}
if (typeof detectionState.density === 'number')
this._setDetectionDensity(detectionState.density);
if (detectionState.allocation) {
this._setDetectionAllocation(detectionState.allocation, {
syncShare: false,
});
}
if (
typeof detectionState.fadePct === 'number' &&
this._detectionFadeSlider
) {
this._detectionFadeSlider.value = String(detectionState.fadePct);
}
if (
typeof detectionState.outsideOpacityPct === 'number' &&
this._detectionOpacitySlider
) {
this._detectionOpacitySlider.value = String(
detectionState.outsideOpacityPct,
);
}
this._applyDetectionFadeFromUi();
this._setDetectionFade({
fadePct:
typeof detectionState.fadePct === 'number'
? detectionState.fadePct
: undefined,
outsideOpacityPct:
typeof detectionState.outsideOpacityPct === 'number'
? detectionState.outsideOpacityPct
: undefined,
});
if (detectionState.mode) {
this._setDetectionMode(detectionState.mode);
}
@@ -1452,15 +1401,8 @@ export class VisualSettings {
if (preset.detectionMode) {
this._setDetectionMode(preset.detectionMode);
}
if (
typeof preset.detectionDensity === 'number' &&
this._detectionDensitySlider
) {
const density = canonicalizeDensity(preset.detectionDensity);
this._detectionDensitySlider.value = String(density);
this._detectionDensityValue.textContent = `${density}%`;
this._applyDetectionDensityFromUi();
}
if (typeof preset.detectionDensity === 'number')
this._setDetectionDensity(preset.detectionDensity);
if (preset.detectionAllocation) {
this._setDetectionAllocation(preset.detectionAllocation, {
syncShare: false,
@@ -1653,49 +1595,8 @@ export class VisualSettings {
}
_updateDetectionButton(modeLabel) {
const btn = this._detectionBtn;
const enabled = modeLabel !== 'OFF';
btn.setAttribute('aria-pressed', String(enabled));
btn.setAttribute(
'aria-label',
enabled
? t('Detection overlay: {mode}', {
mode: String(modeLabel).toLowerCase(),
})
: t('Detection overlay: off'),
);
btn.classList.remove('active', 'god', 'panoptic');
if (modeLabel === 'SPARSE') {
btn.querySelector('.pp-label').textContent = t('SPARSE');
btn.classList.add('active');
} else if (modeLabel === 'BALANCED') {
btn.querySelector('.pp-label').textContent = t('BALANCED');
btn.classList.add('active');
} else if (modeLabel === 'DENSE') {
btn.querySelector('.pp-label').textContent = t('DENSE');
btn.classList.add('active', 'panoptic');
} else {
btn.querySelector('.pp-label').textContent = t('DETECT');
}
if (this._detectionSliderRow) {
this._detectionSliderRow.classList.toggle('visible', modeLabel !== 'OFF');
}
if (this._detectionAllocationRow) {
this._detectionAllocationRow.classList.toggle(
'visible',
modeLabel !== 'OFF',
);
}
if (this._detectionFadeRow) {
this._detectionFadeRow.classList.toggle('visible', modeLabel !== 'OFF');
}
if (this._detectionOpacityRow) {
this._detectionOpacityRow.classList.toggle(
'visible',
modeLabel !== 'OFF',
);
}
this._detectionModeLabel = modeLabel;
this._publishDisplay();
this._layoutRightPanels();
}
stop() {