mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-04 10:24:02 +00:00
createView threw on style 'snow', so an embed host or a show_in_gods_eye_view call holding a view from before Snow was retired was refused outright, camera and layers included. viewFromParams read style=snow as the recipient's own style, while the app opened the same link on Normal. Both now read a retired style as Normal; truly unknown names still throw. The retired list moves to a browser-free module shared by the view API, the share codec and visual settings.
1731 lines
58 KiB
JavaScript
1731 lines
58 KiB
JavaScript
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 { t } from '../i18n/index.js';
|
|
import { UiLifetime } from './uiLifetime.js';
|
|
import {
|
|
VisualEffects,
|
|
STYLES,
|
|
GLOBAL_POST_DEFAULTS,
|
|
STYLE_PRESET_DEFAULTS,
|
|
MILITARY_DETECTION_PRESET,
|
|
} from './effects.js';
|
|
import { createStyleParameters } from './visualInput.js';
|
|
import {
|
|
BLOOM_SCALE_VERSION,
|
|
clampBloomIntensity,
|
|
decodeBloomIntensity,
|
|
} from '../bloom.js';
|
|
import {
|
|
ALLOCATION_STRATEGIES,
|
|
canonicalizeDensity,
|
|
defaultDensityForProfile,
|
|
normalizeAllocationStrategy,
|
|
normalizeProfile,
|
|
profileForDensity,
|
|
} from '../data/detectionPolicy.js';
|
|
import {
|
|
applyCockpitVisionStageIntensities,
|
|
captureCockpitVisionBaseline,
|
|
normalizeCockpitVisionMode,
|
|
} from '../cockpitVisionPolicy.js';
|
|
import {
|
|
applyContactsDetection,
|
|
shareCacheNeedsHeal,
|
|
shareableDetectionState,
|
|
} from '../contactsDetectionPolicy.js';
|
|
import {
|
|
applyCyberSonarSettings,
|
|
isCyberSonarEnabled,
|
|
readCyberSonarSettings,
|
|
setCyberSonarEnabled,
|
|
} from '../cyberSonar.js';
|
|
import { cyberVisualDefaultsForHudTransition } from '../hudLayouts.js';
|
|
const DETECTION_ALLOCATION_STORAGE_KEY = 'gev:detection-allocation:v1';
|
|
|
|
/** Own visual preferences, detection overrides and display-control state. */
|
|
export class VisualSettings {
|
|
async restoreShareState(state) {
|
|
const {
|
|
setScopeMaskEnabled,
|
|
setScopeMaskFeather,
|
|
setScopeTerminusOverride,
|
|
clampScopeTerminusPct,
|
|
} = this.services;
|
|
const {
|
|
style,
|
|
bloom,
|
|
sharpen,
|
|
bloomIntensity,
|
|
bloomVersion,
|
|
sharpenIntensity,
|
|
hudVariant,
|
|
hudVisible,
|
|
detectionMode,
|
|
detectionDensity,
|
|
detectionAllocation,
|
|
detectionFadePct,
|
|
detectionOutsideOpacityPct,
|
|
celestialRing,
|
|
scopeEnabled,
|
|
scopeFeatherPct,
|
|
scopeTerminusPct,
|
|
mapStack,
|
|
panelState,
|
|
styleParams,
|
|
} = state || {};
|
|
// Ignore the retired 'ai-edit' style from older share links. Normal is
|
|
// the start-up style, but a share opened later in the tab must still leave
|
|
// a filtered style for it.
|
|
if (
|
|
style &&
|
|
style !== 'ai-edit' &&
|
|
(style !== 'normal' || this.activeStyle !== 'normal')
|
|
) {
|
|
const alreadyActive = style === this.activeStyle;
|
|
this.setStyle(style, {
|
|
applyPreset: true,
|
|
revealParameters: false,
|
|
restore: true,
|
|
});
|
|
// setStyle leaves an already-active style untouched; the incoming share
|
|
// still starts from that preset before its own parameters apply.
|
|
if (alreadyActive && style !== 'normal')
|
|
this._applyStylePresetDefaults(style);
|
|
}
|
|
if (styleParams && style && this.stages[style] && STYLES[style]?.uniforms) {
|
|
for (const [uniformName, uniformValue] of Object.entries(styleParams)) {
|
|
if (!Object.hasOwn(STYLES[style].uniforms, uniformName)) continue;
|
|
this.stages[style].uniforms[uniformName] = uniformValue;
|
|
}
|
|
this._updateSliderPanel(style, { reveal: false });
|
|
}
|
|
if (typeof bloomIntensity === 'number' && this._bloomSlider) {
|
|
const intensity = decodeBloomIntensity(bloomIntensity, bloomVersion);
|
|
this._setBloomIntensity(intensity, { syncShare: false });
|
|
}
|
|
if (typeof sharpenIntensity === 'number' && this._sharpenSlider) {
|
|
const pct = Math.max(0, Math.min(100, Math.round(sharpenIntensity)));
|
|
this._sharpenSlider.value = String(pct);
|
|
this._sharpenSliderValue.textContent = `${pct}%`;
|
|
this._applySharpenIntensity(pct / 100);
|
|
}
|
|
if (typeof bloom === 'boolean') this._setBloomEnabled(bloom);
|
|
if (typeof sharpen === 'boolean') this._setSharpenEnabled(sharpen);
|
|
if (hudVariant) this._setHudVariant(hudVariant);
|
|
if (typeof hudVisible === 'boolean') {
|
|
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 (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();
|
|
if (detectionMode) this._setDetectionMode(detectionMode);
|
|
if (typeof celestialRing === 'boolean') {
|
|
this.setCelestialRingEnabled(celestialRing, {
|
|
syncShare: false,
|
|
focus: false,
|
|
});
|
|
}
|
|
if (typeof scopeEnabled === 'boolean') {
|
|
setScopeMaskEnabled(scopeEnabled);
|
|
syncScopeControls(this._scopeBtn, this._scopeFeatherSlider, scopeEnabled);
|
|
}
|
|
if (typeof scopeFeatherPct === 'number' && this._scopeFeatherSlider) {
|
|
const pct = clampScopeFeatherPct(scopeFeatherPct);
|
|
this._scopeFeatherSlider.value = String(pct);
|
|
if (this._scopeFeatherValue)
|
|
this._scopeFeatherValue.textContent = `${pct}%`;
|
|
setScopeMaskFeather(pct / 100);
|
|
}
|
|
// null restores the altitude-adaptive ramp; a number pins the terminus
|
|
// (clamped to the supported 94..100 band, same as the `sce` hash key).
|
|
if (scopeTerminusPct === null) setScopeTerminusOverride(null);
|
|
else if (typeof scopeTerminusPct === 'number') {
|
|
const pinned = clampScopeTerminusPct(scopeTerminusPct);
|
|
setScopeTerminusOverride(pinned == null ? null : pinned / 100);
|
|
}
|
|
const mapStackRestore = mapStack
|
|
? this._setMapStack(mapStack, { syncShare: false })
|
|
: Promise.resolve();
|
|
if (panelState) this._restorePanelState(panelState);
|
|
await mapStackRestore;
|
|
this._syncShareState();
|
|
}
|
|
|
|
constructor({
|
|
viewer,
|
|
services,
|
|
elements,
|
|
operations,
|
|
mapStackController,
|
|
readHud,
|
|
readCockpit,
|
|
readDataManager,
|
|
readShareLinks,
|
|
readCelestialRing,
|
|
readContextMode,
|
|
readContextChanging,
|
|
readDisplayPortalActive,
|
|
}) {
|
|
Object.assign(this, elements, operations, {
|
|
viewer,
|
|
services,
|
|
mapStackController,
|
|
readHud,
|
|
readCockpit,
|
|
readDataManager,
|
|
readShareLinks,
|
|
readCelestialRing,
|
|
readContextMode,
|
|
readContextChanging,
|
|
readDisplayPortalActive,
|
|
});
|
|
this._lifetime = new UiLifetime();
|
|
this._visualEffects = new VisualEffects({
|
|
viewer,
|
|
requestRender: services.governorRequestRender,
|
|
holdRender: services.holdContinuousRender,
|
|
releaseRender: services.releaseContinuousRender,
|
|
});
|
|
this.activeStyle = 'normal';
|
|
this._preCyberStyle = null;
|
|
this._cyberStyleUserSelected = false;
|
|
document.documentElement.dataset.gevStyle = this.activeStyle;
|
|
this._detectionUserOverridden = false;
|
|
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 =
|
|
localStorage.getItem(DETECTION_ALLOCATION_STORAGE_KEY) || 'ELASTIC';
|
|
} catch {
|
|
/* unavailable storage */
|
|
}
|
|
this._detectionAllocationPreference = normalizeAllocationStrategy(
|
|
storedDetectionAllocation,
|
|
);
|
|
}
|
|
get hud() {
|
|
return this.readHud();
|
|
}
|
|
get cockpitView() {
|
|
return this.readCockpit();
|
|
}
|
|
get _dataManager() {
|
|
return this.readDataManager();
|
|
}
|
|
get shareLinkManager() {
|
|
return this.readShareLinks();
|
|
}
|
|
get celestialRing() {
|
|
return this.readCelestialRing();
|
|
}
|
|
get _contextMode() {
|
|
return this.readContextMode();
|
|
}
|
|
get _contextModeChanging() {
|
|
return this.readContextChanging();
|
|
}
|
|
get _cockpitDisplayPortalActive() {
|
|
return this.readDisplayPortalActive();
|
|
}
|
|
get stages() {
|
|
return this._visualEffects.stages;
|
|
}
|
|
|
|
get transitions() {
|
|
return this._visualEffects.transitions;
|
|
}
|
|
|
|
get bloomEnabled() {
|
|
return this._visualEffects.bloomEnabled;
|
|
}
|
|
|
|
get sharpenEnabled() {
|
|
return this._visualEffects.sharpenEnabled;
|
|
}
|
|
|
|
_initStages() {
|
|
this._visualEffects.initStyles();
|
|
}
|
|
|
|
_setStageIntensity(stage, value) {
|
|
this._visualEffects.setStageIntensity(stage, value);
|
|
}
|
|
|
|
_syncStagesEnabledFromIntensity() {
|
|
this._visualEffects.syncStagesEnabledFromIntensity();
|
|
}
|
|
|
|
_syncContactsDetection() {
|
|
if (this._contextModeChanging) return;
|
|
const result = applyContactsDetection({
|
|
active: this._contextMode === 'flights',
|
|
restore: this._contactsDetectionRestore,
|
|
// A map style picked DURING the session owns detection on the way out —
|
|
// its auto-enable preset is younger than the entry snapshot.
|
|
styleOwnsDetection:
|
|
!this._detectionUserOverridden &&
|
|
Boolean(STYLE_PRESET_DEFAULTS[this.activeStyle]?.detection),
|
|
// The snapshot must cover everything activation mutates — the preset
|
|
// writes DENSITY as well as mode, so a mode-only snapshot returned
|
|
// OFF @ 25% as OFF @ 75% and the next manual enable came back Dense.
|
|
getState: () => {
|
|
const state = this.getDetectionState();
|
|
return { mode: state.detectionMode, densityPct: state.densityPct };
|
|
},
|
|
// Owner playtest: the force-on lands on the tactical look the military
|
|
// styles apply — the SAME preset object — not on whatever profile the
|
|
// operator last happened to leave detection at.
|
|
applyPreset: () => this._applyDetectionPreset(MILITARY_DETECTION_PRESET),
|
|
// The preset applier IS the state replayer: same density-then-mode order,
|
|
// same slider writes, so a restore round-trips exactly.
|
|
restoreState: (state) => this._applyDetectionPreset(state),
|
|
});
|
|
const hadOwnership = Boolean(this._contactsDetectionRestore);
|
|
this._contactsDetectionRestore = result.restore;
|
|
// Serialization reads that ownership: while Contacts holds it the link
|
|
// carries the SAVED snapshot, and once released it carries live state. The
|
|
// share cache therefore goes stale on any ownership transition, whether or
|
|
// not the detection engine itself moved — and it does not always move.
|
|
// Exiting while a military style owns detection returns changed:false (the
|
|
// style's preset already matches), and returning early there left a copied
|
|
// link claiming the operator's pre-Contacts values while the map showed
|
|
// Dense @ 75%.
|
|
if (
|
|
!shareCacheNeedsHeal({
|
|
changed: result.changed,
|
|
hadOwnership,
|
|
hasOwnership: Boolean(result.restore),
|
|
})
|
|
)
|
|
return;
|
|
if (result.changed) this._syncDetectionUiFromEngine();
|
|
this._syncShareState();
|
|
}
|
|
|
|
_setCockpitVision(mode, active, { revealParameters = false } = {}) {
|
|
const next = active ? normalizeCockpitVisionMode(mode) : 'optical';
|
|
if (!this.stages) return;
|
|
if (!active) {
|
|
if (this._cockpitVisionRestore) {
|
|
for (const [name, intensity] of Object.entries(
|
|
this._cockpitVisionRestore,
|
|
)) {
|
|
if (this.stages[name])
|
|
this._setStageIntensity(this.stages[name], intensity);
|
|
}
|
|
}
|
|
this._cockpitVisionRestore = null;
|
|
this._cockpitVisionMode = 'optical';
|
|
this._syncIrBoost(); // Cockpit exit: fall back to the map preset's IR state
|
|
this._updateSliderPanel(this.activeStyle, { reveal: false });
|
|
this._revealCockpitStyleParameters({ openDisplay: revealParameters });
|
|
return;
|
|
}
|
|
if (!this._cockpitVisionRestore) {
|
|
this._cockpitVisionRestore = captureCockpitVisionBaseline(
|
|
this.stages,
|
|
this.transitions,
|
|
);
|
|
}
|
|
if (next === 'optical') {
|
|
applyCockpitVisionStageIntensities(this.stages, next);
|
|
this._syncStagesEnabledFromIntensity();
|
|
this._cockpitVisionMode = next;
|
|
this._syncIrBoost();
|
|
this._updateSliderPanel(null, { reveal: false });
|
|
this._revealCockpitStyleParameters({ openDisplay: revealParameters });
|
|
return;
|
|
}
|
|
const target = applyCockpitVisionStageIntensities(this.stages, next);
|
|
this._syncStagesEnabledFromIntensity();
|
|
this._cockpitVisionMode = next;
|
|
this._syncIrBoost(); // Cockpit vision override ('nvg'/'thermal' boost; CRT/NOIR clear)
|
|
this._updateSliderPanel(target || null, { reveal: false });
|
|
this._revealCockpitStyleParameters({ openDisplay: revealParameters });
|
|
}
|
|
|
|
_syncIrBoost() {
|
|
const cockpitMode = this.cockpitView?.active
|
|
? this._cockpitVisionMode
|
|
: null;
|
|
const effective = cockpitMode
|
|
? cockpitMode === 'optical'
|
|
? 'normal'
|
|
: cockpitMode
|
|
: this.activeStyle;
|
|
const irBoost =
|
|
effective === 'surveillance' ||
|
|
effective === 'thermal' ||
|
|
effective === 'nvg';
|
|
this._dataManager?.setLayerParams('flights', { irBoost });
|
|
this._dataManager?.setLayerParams('military', { irBoost });
|
|
// Layers whose in-scene sprites restyle for a sensor preset need the
|
|
// EFFECTIVE style — a cockpit vision override sets no map style, so the
|
|
// map's own style event never fires for it.
|
|
window.dispatchEvent(
|
|
new CustomEvent('gev:vision-change', {
|
|
detail: {
|
|
style: effective,
|
|
cockpit: Boolean(cockpitMode),
|
|
},
|
|
}),
|
|
);
|
|
// Fog blends distant geometry toward an effectively-BLACK color in this
|
|
// app (the Cesium globe is hidden), so beyond ~100 km every 3D aircraft
|
|
// fogs to a black silhouette — lighting and shaders can't reach past it
|
|
// (owner cockpit-FLIR field rounds, 2026-08-16). IR sensors see through
|
|
// haze, so the boost styles simply turn fog off; the prior state restores
|
|
// on exit. Transition-guarded so repeated syncs don't clobber the saved value.
|
|
const scene = this.viewer?.scene;
|
|
if (scene?.fog && irBoost !== this._irBoostActive) {
|
|
this._irBoostActive = irBoost;
|
|
if (irBoost) {
|
|
this._irFogWasEnabled = scene.fog.enabled;
|
|
scene.fog.enabled = false;
|
|
} else if (this._irFogWasEnabled != null) {
|
|
scene.fog.enabled = this._irFogWasEnabled;
|
|
this._irFogWasEnabled = null;
|
|
}
|
|
scene.requestRender?.();
|
|
}
|
|
}
|
|
|
|
_syncCockpitInheritedStyle() {
|
|
if (!this.cockpitView?.active || !this.stages) return;
|
|
this._cockpitVisionRestore = Object.fromEntries(
|
|
Object.keys(this.stages).map((name) => [
|
|
name,
|
|
name === this.activeStyle ? 1 : 0,
|
|
]),
|
|
);
|
|
for (const name of Object.keys(this.stages)) this.transitions.delete(name);
|
|
this.cockpitView.setVisionMode(this.cockpitView.visionMode);
|
|
}
|
|
|
|
_revealCockpitStyleParameters({ openDisplay = false } = {}) {
|
|
if (
|
|
!this.cockpitView?.active ||
|
|
!this._sliderPanel?.classList.contains('active')
|
|
)
|
|
return;
|
|
if (
|
|
openDisplay &&
|
|
this._cockpitDisplayToggleBtn?.getAttribute('aria-expanded') !== 'true'
|
|
) {
|
|
this._setCockpitDisclosure?.('display', true);
|
|
return;
|
|
}
|
|
if (this._cockpitDisplayToggleBtn?.getAttribute('aria-expanded') !== 'true')
|
|
return;
|
|
this._sliderPanel.classList.remove('collapsed');
|
|
this._syncPanelCollapseButton(this._sliderPanel);
|
|
this._lifetime.frame(() =>
|
|
this._lifetime.frame(() => {
|
|
this._sliderPanel?.scrollIntoView?.({ block: 'nearest' });
|
|
}),
|
|
);
|
|
}
|
|
|
|
_initBloomSharpen() {
|
|
this._visualEffects.initPostProcess(
|
|
this._sharpenSlider ? parseInt(this._sharpenSlider.value, 10) / 100 : 0.6,
|
|
);
|
|
}
|
|
|
|
_getBloomIntensity() {
|
|
return this._visualEffects.bloomIntensity;
|
|
}
|
|
|
|
_syncBloomStageEnabled() {
|
|
this._visualEffects.syncBloomEnabled();
|
|
}
|
|
|
|
_setBloomIntensity(intensity, { syncShare = true } = {}) {
|
|
const { governorRequestRender } = this.services;
|
|
governorRequestRender('bloom');
|
|
const clamped = clampBloomIntensity(intensity);
|
|
if (this._bloomSlider) this._bloomSlider.value = String(clamped);
|
|
if (this._bloomSliderValue)
|
|
this._bloomSliderValue.textContent = `${clamped}%`;
|
|
this._applyBloomIntensity(clamped);
|
|
if (syncShare) this._syncShareState();
|
|
}
|
|
|
|
_applyBloomIntensity(intensity) {
|
|
this._visualEffects.applyBloomIntensity(intensity);
|
|
}
|
|
|
|
_setBloomEnabled(enabled) {
|
|
const { governorRequestRender } = this.services;
|
|
governorRequestRender('bloom');
|
|
this._visualEffects.setBloomEnabled(enabled);
|
|
this._syncBloomStageEnabled();
|
|
this._bloomBtn.classList.toggle('active', this.bloomEnabled);
|
|
this._bloomSliderRow.classList.toggle('visible', this.bloomEnabled);
|
|
if (this.bloomEnabled) {
|
|
this._applyBloomIntensity(this._getBloomIntensity());
|
|
}
|
|
this._syncShareState();
|
|
this._layoutRightPanels();
|
|
}
|
|
|
|
_applySharpenIntensity(val) {
|
|
this._visualEffects.applySharpenIntensity(val);
|
|
}
|
|
|
|
_setSharpenEnabled(enabled) {
|
|
const { governorRequestRender } = this.services;
|
|
governorRequestRender('sharpen');
|
|
this._visualEffects.setSharpenEnabled(enabled);
|
|
this._sharpenBtn.classList.toggle('active', this.sharpenEnabled);
|
|
if (this._sharpenSliderRow) {
|
|
this._sharpenSliderRow.classList.toggle('visible', this.sharpenEnabled);
|
|
}
|
|
if (this.sharpenEnabled && this._sharpenSlider) {
|
|
this._applySharpenIntensity(
|
|
parseInt(this._sharpenSlider.value, 10) / 100,
|
|
);
|
|
}
|
|
this._syncShareState();
|
|
this._layoutRightPanels();
|
|
}
|
|
|
|
_applyDetectionDensityFromUi() {
|
|
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._updateDetectionButton(getDetectionMode());
|
|
}
|
|
|
|
_applyDetectionFadeFromUi() {
|
|
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}%`;
|
|
setKeyholeFadeTuning({
|
|
fadeRatio: fadePct / 100,
|
|
outsideOpacity: outsideOpacityPct / 100,
|
|
});
|
|
this.viewer.scene.requestRender?.();
|
|
}
|
|
|
|
_setDetectionAllocation(strategy, { syncShare = true, persist = true } = {}) {
|
|
const { setDetectionTuning } = this.services;
|
|
const raw = String(strategy || '')
|
|
.trim()
|
|
.toUpperCase();
|
|
if (!ALLOCATION_STRATEGIES.includes(raw)) return false;
|
|
const normalized = normalizeAllocationStrategy(raw);
|
|
this._detectionAllocationPreference = normalized;
|
|
setDetectionTuning({ allocationStrategy: normalized });
|
|
syncSegmentedControl(this._detectionAllocationBtns, normalized, {
|
|
valueOf: (button) => button.dataset.allocation,
|
|
});
|
|
if (persist) {
|
|
try {
|
|
localStorage.setItem(DETECTION_ALLOCATION_STORAGE_KEY, normalized);
|
|
} catch {
|
|
/* best effort */
|
|
}
|
|
}
|
|
if (syncShare) this._syncShareState();
|
|
return true;
|
|
}
|
|
|
|
_syncDetectionUiFromEngine() {
|
|
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._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._updateDetectionButton(getDetectionMode());
|
|
}
|
|
|
|
_setDetectionMode(modeLabel) {
|
|
const { setDetectionModeByLabel } = this.services;
|
|
if (!modeLabel) return;
|
|
setDetectionModeByLabel(modeLabel);
|
|
this._syncDetectionUiFromEngine();
|
|
this._syncShareState();
|
|
}
|
|
|
|
_setHudVariant(variantName, { applyVisualDefaults = false } = {}) {
|
|
if (!variantName) return;
|
|
const previousVariant = this.hud.getVariant();
|
|
this.hud.setVariant(variantName);
|
|
const nextVariant = this.hud.getVariant();
|
|
const enteredCyber = previousVariant !== 'cyber' && nextVariant === 'cyber';
|
|
const leftCyber = previousVariant === 'cyber' && nextVariant !== 'cyber';
|
|
if (!applyVisualDefaults) {
|
|
// Scene/share/programmatic state is authoritative even when it reapplies
|
|
// the current Cyber variant. Do not let an older explicit entry restore
|
|
// stale local preset memory on the next user-selected exit.
|
|
this._preCyberStyle = null;
|
|
this._cyberStyleUserSelected = false;
|
|
} else if (enteredCyber) {
|
|
this._preCyberStyle = this.activeStyle;
|
|
this._cyberStyleUserSelected = false;
|
|
}
|
|
if (this._hudLayoutSelect && this._hudLayoutSelect.value !== nextVariant) {
|
|
this._hudLayoutSelect.value = nextVariant;
|
|
}
|
|
const visualDefaults = cyberVisualDefaultsForHudTransition(
|
|
previousVariant,
|
|
nextVariant,
|
|
{ explicit: applyVisualDefaults },
|
|
);
|
|
if (visualDefaults) this._applyCyberVisualDefaults(visualDefaults);
|
|
if (leftCyber) {
|
|
const restoreStyle =
|
|
applyVisualDefaults && !this._cyberStyleUserSelected
|
|
? this._preCyberStyle
|
|
: null;
|
|
this._preCyberStyle = null;
|
|
this._cyberStyleUserSelected = false;
|
|
if (restoreStyle && restoreStyle !== this.activeStyle) {
|
|
this.setStyle(restoreStyle, {
|
|
applyPreset: false,
|
|
revealParameters: false,
|
|
preserveCyberRestore: true,
|
|
});
|
|
}
|
|
}
|
|
this._syncCyberSonarControl();
|
|
this._syncShareState();
|
|
this._scheduleAdaptivePanelLayout({ settle: true });
|
|
}
|
|
|
|
_applyCyberVisualDefaults({ style, ironbow }) {
|
|
this.setStyle(style, {
|
|
applyPreset: false,
|
|
revealParameters: false,
|
|
preserveCyberRestore: true,
|
|
});
|
|
const thermalUniforms = this.stages?.thermal?.uniforms;
|
|
if (!thermalUniforms || thermalUniforms.palette === undefined) return;
|
|
thermalUniforms.palette = ironbow;
|
|
this._updateSliderPanel('thermal', { reveal: false });
|
|
this.services.governorRequestRender('cyber-visual-defaults');
|
|
}
|
|
|
|
_syncCyberSonarControl() {
|
|
if (!this._cyberSonarBtn) return;
|
|
const enabled = isCyberSonarEnabled();
|
|
const settings = applyCyberSonarSettings(readCyberSonarSettings());
|
|
this._cyberSonarBtn.classList.toggle('active', enabled);
|
|
this._cyberSonarBtn.setAttribute('aria-pressed', String(enabled));
|
|
this._cyberSonarBtn.textContent = enabled ? t('ON') : t('OFF');
|
|
for (const [input, output, value, suffix] of [
|
|
[this._cyberSonarRings, this._cyberSonarRingsValue, settings.rings, ''],
|
|
[this._cyberSonarRange, this._cyberSonarRangeValue, settings.range, '%'],
|
|
[
|
|
this._cyberSonarIntensity,
|
|
this._cyberSonarIntensityValue,
|
|
settings.intensity,
|
|
'%',
|
|
],
|
|
[
|
|
this._cyberSonarOpacity,
|
|
this._cyberSonarOpacityValue,
|
|
settings.opacity,
|
|
'%',
|
|
],
|
|
[
|
|
this._cyberSonarSector,
|
|
this._cyberSonarSectorValue,
|
|
settings.sector,
|
|
'°',
|
|
],
|
|
]) {
|
|
if (input) input.value = String(value);
|
|
if (output) output.textContent = `${value}${suffix}`;
|
|
}
|
|
}
|
|
|
|
_setCyberSonarEnabled(enabled = !isCyberSonarEnabled()) {
|
|
const next = setCyberSonarEnabled(!!enabled);
|
|
this._syncCyberSonarControl();
|
|
this.services.governorRequestRender('cyber-sonar-toggle');
|
|
return next;
|
|
}
|
|
|
|
_setCyberSonarSetting(name, value) {
|
|
const settings = readCyberSonarSettings();
|
|
if (!Object.hasOwn(settings, name)) return settings;
|
|
const next = applyCyberSonarSettings({ ...settings, [name]: value });
|
|
this._syncCyberSonarControl();
|
|
this.services.governorRequestRender(`cyber-sonar-${name}`);
|
|
return next;
|
|
}
|
|
|
|
_applyStylePresetDefaults(styleName) {
|
|
const { governorRequestRender } = this.services;
|
|
const preset = STYLE_PRESET_DEFAULTS[styleName];
|
|
if (!preset) return;
|
|
|
|
if (preset.styleParams && typeof preset.styleParams === 'object') {
|
|
for (const [targetStyle, params] of Object.entries(preset.styleParams)) {
|
|
const stage = this.stages[targetStyle];
|
|
if (!stage || !params || typeof params !== 'object') continue;
|
|
for (const [uniformName, uniformValue] of Object.entries(params)) {
|
|
if (stage.uniforms[uniformName] === undefined) continue;
|
|
stage.uniforms[uniformName] = uniformValue;
|
|
governorRequestRender('style-param');
|
|
}
|
|
}
|
|
}
|
|
|
|
const bloomInput = preset.bloom || {};
|
|
if (typeof bloomInput.intensity === 'number' && this._bloomSlider) {
|
|
this._setBloomIntensity(clampBloomIntensity(bloomInput.intensity), {
|
|
syncShare: false,
|
|
});
|
|
}
|
|
if (typeof bloomInput.enabled === 'boolean') {
|
|
this._setBloomEnabled(bloomInput.enabled);
|
|
}
|
|
|
|
const sharpenInput = preset.sharpen || {};
|
|
if (typeof sharpenInput.intensity === 'number' && this._sharpenSlider) {
|
|
const sharpenPct = Math.max(
|
|
0,
|
|
Math.min(100, Math.round(sharpenInput.intensity)),
|
|
);
|
|
this._sharpenSlider.value = String(sharpenPct);
|
|
this._sharpenSliderValue.textContent = `${sharpenPct}%`;
|
|
this._applySharpenIntensity(sharpenPct / 100);
|
|
}
|
|
if (typeof sharpenInput.enabled === 'boolean') {
|
|
this._setSharpenEnabled(sharpenInput.enabled);
|
|
}
|
|
|
|
// Cyber is an explicit shell choice, independent of the imagery filter.
|
|
// Scene/share restoration still applies its own HUD through _setHudVariant.
|
|
if (preset.hudVariant && this.hud.getVariant() !== 'cyber') {
|
|
this._setHudVariant(preset.hudVariant);
|
|
}
|
|
if (typeof preset.hudVisible === 'boolean') {
|
|
this.hud.setMode(preset.hudVisible ? 'on' : 'off');
|
|
this._updateHudButtonState();
|
|
}
|
|
|
|
// A style may set a detection default (e.g. military styles -> Dense for
|
|
// the "epic" default view), but ONLY if the user hasn't manually changed
|
|
// detection this session. Detection is user-controlled and persists across
|
|
// style switches, so an explicit Sparse/Off choice is never stomped.
|
|
if (preset.detection && !this._detectionUserOverridden) {
|
|
this._applyDetectionPreset(preset.detection);
|
|
}
|
|
}
|
|
|
|
_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 (det.mode) this._setDetectionMode(String(det.mode).toUpperCase());
|
|
}
|
|
|
|
_applyGlobalPostDefaults() {
|
|
const defaults = GLOBAL_POST_DEFAULTS;
|
|
if (typeof defaults.bloom?.intensity === 'number' && this._bloomSlider) {
|
|
this._setBloomIntensity(clampBloomIntensity(defaults.bloom.intensity), {
|
|
syncShare: false,
|
|
});
|
|
}
|
|
if (typeof defaults.bloom?.enabled === 'boolean') {
|
|
this._setBloomEnabled(defaults.bloom.enabled);
|
|
}
|
|
|
|
if (
|
|
typeof defaults.sharpen?.intensity === 'number' &&
|
|
this._sharpenSlider
|
|
) {
|
|
const sharpenPct = Math.max(
|
|
0,
|
|
Math.min(100, Math.round(defaults.sharpen.intensity)),
|
|
);
|
|
this._sharpenSlider.value = String(sharpenPct);
|
|
this._sharpenSliderValue.textContent = `${sharpenPct}%`;
|
|
this._applySharpenIntensity(sharpenPct / 100);
|
|
}
|
|
if (typeof defaults.sharpen?.enabled === 'boolean') {
|
|
this._setSharpenEnabled(defaults.sharpen.enabled);
|
|
}
|
|
|
|
if (defaults.hudVariant) {
|
|
this._setHudVariant(defaults.hudVariant);
|
|
}
|
|
if (typeof defaults.hudVisible === 'boolean') {
|
|
this.hud.setMode(defaults.hudVisible ? 'on' : 'off');
|
|
this._updateHudButtonState();
|
|
}
|
|
|
|
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();
|
|
}
|
|
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();
|
|
if (typeof defaults.celestialRing === 'boolean') {
|
|
this.setCelestialRingEnabled(defaults.celestialRing, {
|
|
syncShare: false,
|
|
focus: false,
|
|
});
|
|
}
|
|
}
|
|
|
|
_shareableDetectionState() {
|
|
const { getDetectionMode } = this.services;
|
|
return shareableDetectionState({
|
|
owned: this._contactsDetectionRestore,
|
|
liveMode: getDetectionMode(),
|
|
liveDensityPct: parseInt(this._detectionDensitySlider?.value || '50', 10),
|
|
});
|
|
}
|
|
|
|
_readShareState() {
|
|
const {
|
|
getDetectionTuning,
|
|
isScopeMaskEnabled,
|
|
getScopeMaskFeather,
|
|
getScopeTerminusOverride,
|
|
} = this.services;
|
|
const detection = this._shareableDetectionState();
|
|
return {
|
|
bloomEnabled: this.bloomEnabled,
|
|
sharpenEnabled: this.sharpenEnabled,
|
|
options: {
|
|
bloomIntensity: this._getBloomIntensity(),
|
|
bloomVersion: BLOOM_SCALE_VERSION,
|
|
sharpenIntensity: parseInt(this._sharpenSlider?.value || '49', 10),
|
|
hudVariant: this.hud.getVariant(),
|
|
hudVisible: this.hud.visible,
|
|
detectionMode: detection.mode,
|
|
detectionDensity: detection.densityPct,
|
|
detectionAllocation: getDetectionTuning().allocationStrategy,
|
|
detectionFadePct: parseInt(this._detectionFadeSlider?.value || '7', 10),
|
|
detectionOutsideOpacityPct: parseInt(
|
|
this._detectionOpacitySlider?.value || '1',
|
|
10,
|
|
),
|
|
celestialRingEnabled: this.celestialRingEnabled,
|
|
scopeEnabled: isScopeMaskEnabled(),
|
|
scopeFeatherPct: Math.round(getScopeMaskFeather() * 100),
|
|
// null when adaptive — the share layer omits `sce` entirely in that case.
|
|
scopeTerminusPct:
|
|
getScopeTerminusOverride() == null
|
|
? null
|
|
: Math.round(getScopeTerminusOverride() * 100),
|
|
mapStack: this.mapStackController?.getActiveId?.() || 'photoreal',
|
|
},
|
|
};
|
|
}
|
|
|
|
getDetectionState() {
|
|
const { getDetectionTuning, getDetectionMode } = this.services;
|
|
const pct = this._detectionDensitySlider
|
|
? parseInt(this._detectionDensitySlider.value, 10)
|
|
: null;
|
|
return {
|
|
detectionMode: getDetectionMode(),
|
|
densityPct: pct,
|
|
allocationStrategy: getDetectionTuning().allocationStrategy,
|
|
fadePct: parseInt(this._detectionFadeSlider?.value || '7', 10),
|
|
outsideOpacityPct: parseInt(
|
|
this._detectionOpacitySlider?.value || '0',
|
|
10,
|
|
),
|
|
};
|
|
}
|
|
|
|
getDetectionDiagnostics() {
|
|
const { readDetectionDiagnostics } = this.services;
|
|
return readDetectionDiagnostics();
|
|
}
|
|
|
|
setDetection({
|
|
enabled,
|
|
mode,
|
|
densityPct,
|
|
allocationStrategy,
|
|
fadePct,
|
|
outsideOpacityPct,
|
|
} = {}) {
|
|
const { getDetectionMode, setDetectionModeByLabel } = this.services;
|
|
if (enabled !== undefined && typeof enabled !== 'boolean') {
|
|
return {
|
|
ok: false,
|
|
error: `Invalid detection enabled value: ${enabled}`,
|
|
...this.getDetectionState(),
|
|
};
|
|
}
|
|
let requestedProfile = null;
|
|
if (typeof mode === 'string' && mode.trim()) {
|
|
requestedProfile = normalizeProfile(mode);
|
|
if (!requestedProfile) {
|
|
return {
|
|
ok: false,
|
|
error: `Unknown detection mode: ${mode}`,
|
|
...this.getDetectionState(),
|
|
};
|
|
}
|
|
}
|
|
let requestedDensity = null;
|
|
if (densityPct != null) {
|
|
if (!Number.isFinite(Number(densityPct))) {
|
|
return {
|
|
ok: false,
|
|
error: `Invalid density: ${densityPct}`,
|
|
...this.getDetectionState(),
|
|
};
|
|
}
|
|
requestedDensity = canonicalizeDensity(Number(densityPct));
|
|
}
|
|
if (
|
|
requestedProfile &&
|
|
requestedProfile !== 'OFF' &&
|
|
requestedDensity != null &&
|
|
profileForDensity(requestedDensity) !== requestedProfile
|
|
) {
|
|
return {
|
|
ok: false,
|
|
error: `Detection mode ${requestedProfile} conflicts with density ${requestedDensity}%`,
|
|
...this.getDetectionState(),
|
|
};
|
|
}
|
|
let requestedAllocation = null;
|
|
if (allocationStrategy != null) {
|
|
requestedAllocation = String(allocationStrategy).trim().toUpperCase();
|
|
if (!ALLOCATION_STRATEGIES.includes(requestedAllocation)) {
|
|
return {
|
|
ok: false,
|
|
error: `Unknown allocation strategy: ${allocationStrategy}`,
|
|
...this.getDetectionState(),
|
|
};
|
|
}
|
|
}
|
|
if (fadePct != null) {
|
|
if (!Number.isFinite(Number(fadePct))) {
|
|
return {
|
|
ok: false,
|
|
error: `Invalid fade distance: ${fadePct}`,
|
|
...this.getDetectionState(),
|
|
};
|
|
}
|
|
}
|
|
if (outsideOpacityPct != null) {
|
|
if (!Number.isFinite(Number(outsideOpacityPct))) {
|
|
return {
|
|
ok: false,
|
|
error: `Invalid outside opacity: ${outsideOpacityPct}`,
|
|
...this.getDetectionState(),
|
|
};
|
|
}
|
|
}
|
|
const hasExplicitVisualChange =
|
|
typeof enabled === 'boolean' ||
|
|
requestedProfile !== null ||
|
|
requestedDensity !== null ||
|
|
requestedAllocation !== null ||
|
|
fadePct != null ||
|
|
outsideOpacityPct != null;
|
|
if (hasExplicitVisualChange) {
|
|
// Voice/scripted detection control counts as an explicit user choice, so
|
|
// neither style presets nor a still-pending shared visual restore can
|
|
// overwrite it afterward.
|
|
this.shareLinkManager?.claimRestoreLane?.('visual');
|
|
this._detectionUserOverridden = true;
|
|
}
|
|
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();
|
|
|
|
if (
|
|
requestedProfile &&
|
|
requestedProfile !== 'OFF' &&
|
|
requestedDensity == null
|
|
) {
|
|
requestedDensity = defaultDensityForProfile(requestedProfile);
|
|
}
|
|
if (requestedDensity != null && this._detectionDensitySlider) {
|
|
this._detectionDensitySlider.value = String(requestedDensity);
|
|
this._applyDetectionDensityFromUi();
|
|
}
|
|
|
|
if (enabled === false || requestedProfile === 'OFF') {
|
|
setDetectionModeByLabel('OFF');
|
|
} else if (requestedProfile) {
|
|
setDetectionModeByLabel(requestedProfile);
|
|
} else if (enabled === true && getDetectionMode() === 'OFF') {
|
|
setDetectionModeByLabel(
|
|
profileForDensity(
|
|
requestedDensity ?? this._detectionDensitySlider?.value ?? 50,
|
|
),
|
|
);
|
|
}
|
|
this._syncDetectionUiFromEngine();
|
|
this._syncShareState();
|
|
return { ok: true, ...this.getDetectionState() };
|
|
}
|
|
|
|
setBloom({ enabled, intensityPct } = {}) {
|
|
const current = () => ({
|
|
enabled: !!this.bloomEnabled,
|
|
intensityPct: this._bloomSlider
|
|
? parseInt(this._bloomSlider.value, 10)
|
|
: null,
|
|
});
|
|
if (enabled !== undefined && typeof enabled !== 'boolean') {
|
|
return {
|
|
ok: false,
|
|
error: `Invalid bloom enabled value: ${enabled}`,
|
|
bloom: current(),
|
|
};
|
|
}
|
|
if (
|
|
intensityPct !== undefined &&
|
|
(typeof intensityPct !== 'number' || !Number.isFinite(intensityPct))
|
|
) {
|
|
return {
|
|
ok: false,
|
|
error: `Invalid bloom intensity: ${intensityPct}`,
|
|
bloom: current(),
|
|
};
|
|
}
|
|
const hasExplicitVisualChange =
|
|
intensityPct !== undefined || enabled !== undefined;
|
|
if (hasExplicitVisualChange)
|
|
this.shareLinkManager?.claimRestoreLane?.('visual');
|
|
if (intensityPct !== undefined) {
|
|
this._setBloomIntensity(
|
|
Math.round(Math.max(0, Math.min(200, intensityPct))),
|
|
);
|
|
}
|
|
if (enabled !== undefined) this._setBloomEnabled(enabled);
|
|
return {
|
|
ok: true,
|
|
bloom: current(),
|
|
};
|
|
}
|
|
|
|
setSharpen({ enabled, intensityPct } = {}) {
|
|
const current = () => ({
|
|
enabled: !!this.sharpenEnabled,
|
|
intensityPct: this._sharpenSlider
|
|
? parseInt(this._sharpenSlider.value, 10)
|
|
: null,
|
|
});
|
|
if (enabled !== undefined && typeof enabled !== 'boolean') {
|
|
return {
|
|
ok: false,
|
|
error: `Invalid sharpen enabled value: ${enabled}`,
|
|
sharpen: current(),
|
|
};
|
|
}
|
|
if (
|
|
intensityPct !== undefined &&
|
|
(typeof intensityPct !== 'number' || !Number.isFinite(intensityPct))
|
|
) {
|
|
return {
|
|
ok: false,
|
|
error: `Invalid sharpen intensity: ${intensityPct}`,
|
|
sharpen: current(),
|
|
};
|
|
}
|
|
const hasExplicitVisualChange =
|
|
intensityPct !== undefined || enabled !== undefined;
|
|
if (hasExplicitVisualChange)
|
|
this.shareLinkManager?.claimRestoreLane?.('visual');
|
|
if (intensityPct !== undefined) {
|
|
const pct = Math.round(Math.max(0, Math.min(100, intensityPct)));
|
|
if (this._sharpenSlider) this._sharpenSlider.value = String(pct);
|
|
if (this._sharpenSliderValue)
|
|
this._sharpenSliderValue.textContent = `${pct}%`;
|
|
this._applySharpenIntensity(pct / 100);
|
|
this._syncShareState();
|
|
}
|
|
if (enabled !== undefined) this._setSharpenEnabled(enabled);
|
|
return {
|
|
ok: true,
|
|
sharpen: current(),
|
|
};
|
|
}
|
|
|
|
get celestialRingEnabled() {
|
|
return !!this.celestialRing?.enabled;
|
|
}
|
|
|
|
setCelestialRingEnabled(enabled, { syncShare = true, focus = false } = {}) {
|
|
const { isCelestialRingStyleSupported } = this.services;
|
|
const styleSupported = isCelestialRingStyleSupported(this.activeStyle);
|
|
const current = () => ({
|
|
enabled: this.celestialRingEnabled,
|
|
visible: !!this.celestialRing?.visible,
|
|
});
|
|
if (typeof enabled !== 'boolean') {
|
|
return {
|
|
ok: false,
|
|
celestialRing: current(),
|
|
cameraFocused: false,
|
|
error: `Invalid celestial ring enabled value: ${enabled}`,
|
|
};
|
|
}
|
|
if (typeof syncShare !== 'boolean' || typeof focus !== 'boolean') {
|
|
return {
|
|
ok: false,
|
|
celestialRing: current(),
|
|
cameraFocused: false,
|
|
error: 'Celestial ring options must be boolean',
|
|
};
|
|
}
|
|
if (!styleSupported && enabled) {
|
|
return {
|
|
ok: false,
|
|
celestialRing: current(),
|
|
cameraFocused: false,
|
|
error: 'Celestial ring is available only in Normal style',
|
|
};
|
|
}
|
|
if (syncShare) this.shareLinkManager?.claimRestoreLane?.('visual');
|
|
const nextEnabled = styleSupported && enabled;
|
|
this.celestialRing?.setEnabled(nextEnabled);
|
|
this._celestialBtn?.classList.toggle('active', nextEnabled);
|
|
this._celestialBtn?.setAttribute('aria-pressed', String(nextEnabled));
|
|
if (this._celestialBtn) {
|
|
this._celestialBtn.disabled = !styleSupported;
|
|
this._celestialBtn.setAttribute('aria-disabled', String(!styleSupported));
|
|
this._celestialBtn.title = styleSupported
|
|
? t('Celestial ring — reveal the full globe')
|
|
: t('Celestial ring — available in Normal style');
|
|
}
|
|
let cameraFocused = false;
|
|
if (nextEnabled && focus) {
|
|
cameraFocused = !!this.celestialRing?.focusFullGlobe();
|
|
}
|
|
if (syncShare) this._syncShareState();
|
|
return {
|
|
ok: styleSupported || !enabled,
|
|
celestialRing: current(),
|
|
cameraFocused,
|
|
};
|
|
}
|
|
|
|
getVisualState() {
|
|
const {
|
|
getDetectionTuning,
|
|
getDetectionMode,
|
|
isScopeMaskEnabled,
|
|
getScopeMaskFeather,
|
|
} = this.services;
|
|
const styleParams = {};
|
|
for (const [styleName, stage] of Object.entries(this.stages)) {
|
|
const shader = STYLES[styleName];
|
|
if (!shader?.uniforms) continue;
|
|
styleParams[styleName] = {};
|
|
for (const uniformName of Object.keys(shader.uniforms)) {
|
|
styleParams[styleName][uniformName] = stage.uniforms[uniformName];
|
|
}
|
|
}
|
|
|
|
return {
|
|
style: this.activeStyle,
|
|
bloom: {
|
|
enabled: this.bloomEnabled,
|
|
intensity: this._getBloomIntensity(),
|
|
version: BLOOM_SCALE_VERSION,
|
|
},
|
|
sharpen: {
|
|
enabled: this.sharpenEnabled,
|
|
intensity: parseInt(this._sharpenSlider?.value || '49', 10),
|
|
},
|
|
hud: {
|
|
visible: this.hud.visible,
|
|
variant: this.hud.getVariant(),
|
|
},
|
|
detection: {
|
|
mode: getDetectionMode(),
|
|
density: parseInt(this._detectionDensitySlider?.value || '50', 10),
|
|
allocation: getDetectionTuning().allocationStrategy,
|
|
fadePct: parseInt(this._detectionFadeSlider?.value || '7', 10),
|
|
outsideOpacityPct: parseInt(
|
|
this._detectionOpacitySlider?.value || '0',
|
|
10,
|
|
),
|
|
},
|
|
scope: {
|
|
enabled: isScopeMaskEnabled(),
|
|
featherPct: Math.round(getScopeMaskFeather() * 100),
|
|
},
|
|
mapStack: this.mapStackController?.getActiveId?.() || 'photoreal',
|
|
styleParams,
|
|
};
|
|
}
|
|
|
|
async applyVisualState(state = {}, { isCurrent = null } = {}) {
|
|
const { setScopeMaskEnabled, setScopeMaskFeather } = this.services;
|
|
const superseded = () => typeof isCurrent === 'function' && !isCurrent();
|
|
if (superseded()) return false;
|
|
|
|
const style = RETIRED_STYLES.includes(state.style) ? 'normal' : state.style;
|
|
if (style && style !== this.activeStyle) {
|
|
this.setStyle(style, { applyPreset: false });
|
|
}
|
|
|
|
const bloomState = state.bloom || {};
|
|
if (typeof bloomState.intensity === 'number' && this._bloomSlider) {
|
|
const intensity = decodeBloomIntensity(
|
|
bloomState.intensity,
|
|
bloomState.version ?? state.bloomVersion ?? BLOOM_SCALE_VERSION,
|
|
);
|
|
this._setBloomIntensity(intensity, { syncShare: false });
|
|
}
|
|
if (typeof bloomState.enabled === 'boolean') {
|
|
this._setBloomEnabled(bloomState.enabled);
|
|
}
|
|
|
|
const sharpenState = state.sharpen || {};
|
|
if (typeof sharpenState.intensity === 'number' && this._sharpenSlider) {
|
|
const pct = Math.max(
|
|
0,
|
|
Math.min(100, Math.round(sharpenState.intensity)),
|
|
);
|
|
this._sharpenSlider.value = String(pct);
|
|
this._sharpenSliderValue.textContent = `${pct}%`;
|
|
this._applySharpenIntensity(pct / 100);
|
|
}
|
|
if (typeof sharpenState.enabled === 'boolean') {
|
|
this._setSharpenEnabled(sharpenState.enabled);
|
|
}
|
|
|
|
const hudState = state.hud || {};
|
|
if (hudState.variant) {
|
|
this._setHudVariant(hudState.variant);
|
|
}
|
|
if (typeof hudState.visible === 'boolean') {
|
|
this.hud.setMode(hudState.visible ? 'on' : 'off');
|
|
this._updateHudButtonState();
|
|
}
|
|
|
|
const scopeState = state.scope || {};
|
|
if (typeof scopeState.enabled === 'boolean') {
|
|
setScopeMaskEnabled(scopeState.enabled);
|
|
syncScopeControls(
|
|
this._scopeBtn,
|
|
this._scopeFeatherSlider,
|
|
scopeState.enabled,
|
|
);
|
|
}
|
|
if (typeof scopeState.featherPct === 'number' && this._scopeFeatherSlider) {
|
|
const pct = clampScopeFeatherPct(scopeState.featherPct);
|
|
this._scopeFeatherSlider.value = String(pct);
|
|
if (this._scopeFeatherValue)
|
|
this._scopeFeatherValue.textContent = `${pct}%`;
|
|
setScopeMaskFeather(pct / 100);
|
|
}
|
|
|
|
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 (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();
|
|
if (detectionState.mode) {
|
|
this._setDetectionMode(detectionState.mode);
|
|
}
|
|
|
|
if (state.mapStack) {
|
|
// The stack switch is itself a MUTATION, not merely a suspension point,
|
|
// so it needs a gate on BOTH sides of the await.
|
|
if (superseded()) return false;
|
|
const stackBefore = this.mapStackController?.getActiveId?.() ?? null;
|
|
const genBefore =
|
|
this.mapStackController?.getSwitchGeneration?.() ?? null;
|
|
|
|
await this._setMapStack(state.mapStack, { syncShare: false });
|
|
|
|
if (superseded()) {
|
|
// Superseded DURING the switch, which the pre-check above cannot catch
|
|
// and which has already moved the globe. The controller only
|
|
// invalidates a switch when another setStack() arrives, and a winning
|
|
// state that omits `mapStack` never issues one — every normalized scene
|
|
// shot omits it — so this stale globe would simply stand. Put back what
|
|
// the winner inherited.
|
|
const genAfter =
|
|
this.mapStackController?.getSwitchGeneration?.() ?? null;
|
|
// _setMapStack issues exactly one setStack(), which advances the
|
|
// generation once, or not at all when the stack was unavailable and
|
|
// nothing was mutated. Anything past that is a NEWER switch whose
|
|
// caller owns the globe now, and reverting would stomp a live intent.
|
|
const globeIsStillOurs =
|
|
genBefore !== null && genAfter !== null && genAfter <= genBefore + 1;
|
|
const landed = this.mapStackController?.getActiveId?.() ?? null;
|
|
if (globeIsStillOurs && stackBefore && landed !== stackBefore) {
|
|
await this._setMapStack(stackBefore, { syncShare: false });
|
|
}
|
|
return false;
|
|
}
|
|
// Everything below is the uniform commit, already past its own gate.
|
|
}
|
|
|
|
if (state.styleParams && typeof state.styleParams === 'object') {
|
|
for (const [styleName, params] of Object.entries(state.styleParams)) {
|
|
const stage = this.stages[styleName];
|
|
if (!stage || !params) continue;
|
|
for (const [uniformName, uniformValue] of Object.entries(params)) {
|
|
if (stage.uniforms[uniformName] === undefined) continue;
|
|
stage.uniforms[uniformName] = uniformValue;
|
|
}
|
|
}
|
|
this._updateSliderPanel(this.activeStyle);
|
|
}
|
|
|
|
this._syncShareState();
|
|
return true;
|
|
}
|
|
|
|
applyCinematicPreset(preset = {}) {
|
|
const bloomInput =
|
|
typeof preset.bloom === 'object'
|
|
? preset.bloom
|
|
: { intensity: preset.bloom };
|
|
let decodedBloomIntensity = null;
|
|
if (typeof bloomInput.intensity === 'number') {
|
|
decodedBloomIntensity = decodeBloomIntensity(
|
|
bloomInput.intensity,
|
|
bloomInput.version ?? preset.bloomVersion ?? BLOOM_SCALE_VERSION,
|
|
);
|
|
this._setBloomIntensity(decodedBloomIntensity, { syncShare: false });
|
|
}
|
|
if (typeof bloomInput.enabled === 'boolean') {
|
|
this._setBloomEnabled(bloomInput.enabled);
|
|
} else if (typeof bloomInput.intensity === 'number') {
|
|
this._setBloomEnabled(
|
|
(decodedBloomIntensity ?? this._getBloomIntensity()) > 0,
|
|
);
|
|
}
|
|
|
|
const sharpenInput =
|
|
typeof preset.sharpen === 'object'
|
|
? preset.sharpen
|
|
: { enabled: preset.sharpen };
|
|
if (typeof sharpenInput.intensity === 'number' && this._sharpenSlider) {
|
|
const sharpenPct = Math.max(
|
|
0,
|
|
Math.min(100, Math.round(sharpenInput.intensity)),
|
|
);
|
|
this._sharpenSlider.value = String(sharpenPct);
|
|
this._sharpenSliderValue.textContent = `${sharpenPct}%`;
|
|
this._applySharpenIntensity(sharpenPct / 100);
|
|
}
|
|
if (typeof sharpenInput.enabled === 'boolean') {
|
|
this._setSharpenEnabled(sharpenInput.enabled);
|
|
} else if (typeof sharpenInput.intensity === 'number') {
|
|
this._setSharpenEnabled(sharpenInput.intensity > 0);
|
|
}
|
|
|
|
if (preset.hudVariant) {
|
|
this._setHudVariant(preset.hudVariant);
|
|
}
|
|
|
|
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 (preset.detectionAllocation) {
|
|
this._setDetectionAllocation(preset.detectionAllocation, {
|
|
syncShare: false,
|
|
});
|
|
}
|
|
|
|
if (preset.styleParams && typeof preset.styleParams === 'object') {
|
|
for (const [styleName, params] of Object.entries(preset.styleParams)) {
|
|
const stage = this.stages[styleName];
|
|
if (!stage || !params || typeof params !== 'object') continue;
|
|
for (const [uniformName, uniformValue] of Object.entries(params)) {
|
|
if (stage.uniforms[uniformName] === undefined) continue;
|
|
stage.uniforms[uniformName] = uniformValue;
|
|
}
|
|
}
|
|
|
|
// Keep slider panel values in sync when updating the active style.
|
|
this._updateSliderPanel(this.activeStyle);
|
|
}
|
|
|
|
this._syncShareState();
|
|
}
|
|
|
|
_updateSliderPanel(styleName, { reveal = false } = {}) {
|
|
const { governorRequestRender } = this.services;
|
|
this._styleParameters ||= createStyleParameters({
|
|
container: this._sliderContainer,
|
|
});
|
|
this._styleParameters.clear();
|
|
const shader = STYLES[styleName];
|
|
|
|
if (!shader || !shader.uniforms || styleName === 'normal') {
|
|
this._sliderPanel.classList.remove('active');
|
|
this._scheduleRightPanelLayout();
|
|
return;
|
|
}
|
|
|
|
this._styleParameters.render({
|
|
uniforms: shader.uniforms,
|
|
readValue: (uName) => this.stages[styleName].uniforms[uName],
|
|
writeValue: (uName, val) => {
|
|
this.shareLinkManager?.claimRestoreLane?.('visual');
|
|
this.stages[styleName].uniforms[uName] = val;
|
|
},
|
|
onChange: () => {
|
|
// Uniform writes need an explicit render under the idle governor.
|
|
governorRequestRender('style-param-slider');
|
|
this._syncShareState();
|
|
},
|
|
});
|
|
|
|
this._sliderPanel.classList.add('active');
|
|
this._scheduleRightPanelLayout();
|
|
if (reveal) this._revealStyleParameters();
|
|
}
|
|
|
|
_revealStyleParameters() {
|
|
if (!this._sliderPanel?.classList.contains('active')) return;
|
|
if (this._cockpitDisplayPortalActive) return;
|
|
this._sliderPanel.classList.remove('collapsed');
|
|
this._syncPanelCollapseButton(this._sliderPanel);
|
|
this.setPanelCollapsed('pp-toggles', false, { explicit: true });
|
|
this._lifetime.frame(() =>
|
|
this._lifetime.frame(() => {
|
|
const scrollOwner = this._ppToggles;
|
|
if (!scrollOwner) return;
|
|
const ownerRect = scrollOwner.getBoundingClientRect();
|
|
const panelRect = this._sliderPanel.getBoundingClientRect();
|
|
scrollOwner.scrollTop += panelRect.top - ownerRect.top - 8;
|
|
}),
|
|
);
|
|
}
|
|
|
|
setStyle(
|
|
styleName,
|
|
{
|
|
applyPreset = true,
|
|
revealParameters = applyPreset,
|
|
restore = false,
|
|
userInitiated = false,
|
|
preserveCyberRestore = false,
|
|
} = {},
|
|
) {
|
|
if (
|
|
typeof styleName !== 'string' ||
|
|
(styleName !== 'normal' && !Object.hasOwn(STYLES, styleName))
|
|
)
|
|
return;
|
|
const { setDetectionStyle } = this.services;
|
|
if (!restore) this.shareLinkManager?.claimRestoreLane?.('visual');
|
|
if (this.hud.getVariant() === 'cyber') {
|
|
if (userInitiated) {
|
|
// A direct preset choice owns the active style even when it selects the
|
|
// same value as Cyber's automatic FLIR default.
|
|
this._cyberStyleUserSelected = true;
|
|
} else if (!preserveCyberRestore) {
|
|
// Scene/share/reset state is authoritative and must not be overwritten
|
|
// by a remembered local Cyber entry when the user later exits.
|
|
this._preCyberStyle = null;
|
|
this._cyberStyleUserSelected = false;
|
|
}
|
|
}
|
|
if (styleName === this.activeStyle) {
|
|
if (revealParameters && styleName !== 'normal')
|
|
this._revealStyleParameters();
|
|
return;
|
|
}
|
|
|
|
const previousStyle = this.activeStyle;
|
|
this.activeStyle = styleName;
|
|
document.documentElement.dataset.gevStyle = styleName;
|
|
|
|
// The celestial optics treatment belongs to the unfiltered globe only.
|
|
// Leaving Normal turns it off; returning merely re-enables the control.
|
|
this.setCelestialRingEnabled(false, { syncShare: false, focus: false });
|
|
|
|
// Transition out the previous shader style
|
|
if (previousStyle !== 'normal' && this.stages[previousStyle]) {
|
|
this._startTransition(
|
|
previousStyle,
|
|
this.stages[previousStyle].uniforms.intensity,
|
|
0.0,
|
|
);
|
|
}
|
|
|
|
// Transition in the new shader style
|
|
if (styleName !== 'normal' && this.stages[styleName]) {
|
|
this._startTransition(
|
|
styleName,
|
|
this.stages[styleName].uniforms.intensity,
|
|
1.0,
|
|
);
|
|
}
|
|
|
|
if (applyPreset) {
|
|
this._applyStylePresetDefaults(styleName);
|
|
}
|
|
|
|
// Update button UI
|
|
document.querySelectorAll('.style-btn').forEach((btn) => {
|
|
btn.classList.toggle('active', btn.dataset.style === styleName);
|
|
});
|
|
|
|
// Update style indicator
|
|
this._styleIndicator.textContent = STYLE_STATUS_LABELS[styleName]
|
|
? t(STYLE_STATUS_LABELS[styleName])
|
|
: styleName.toUpperCase();
|
|
this._updateStyleMiniStatus(styleName);
|
|
|
|
// Update parameter sliders
|
|
this._updateSliderPanel(styleName, { reveal: revealParameters });
|
|
|
|
// Notify HUD (color adaptation + auto show/hide)
|
|
this.hud.onStyleChange(styleName);
|
|
this._updateHudButtonState();
|
|
|
|
// Sync detection overlay tone to active post-process style
|
|
setDetectionStyle(styleName);
|
|
this._syncIrBoost();
|
|
window.dispatchEvent(
|
|
new CustomEvent('gev:style-change', {
|
|
detail: { style: styleName },
|
|
}),
|
|
);
|
|
|
|
this._syncCockpitInheritedStyle();
|
|
|
|
// Notify share link manager
|
|
this.shareLinkManager.onStyleChange(styleName);
|
|
this._syncShareState();
|
|
}
|
|
|
|
_startTransition(styleName, fromValue, toValue) {
|
|
this._visualEffects.startTransition(styleName, fromValue, toValue);
|
|
}
|
|
|
|
_updateStyleMiniStatus(styleName = this.activeStyle) {
|
|
if (!this._styleMiniValue) return;
|
|
this._styleMiniValue.textContent = STYLE_STATUS_LABELS[styleName]
|
|
? t(STYLE_STATUS_LABELS[styleName])
|
|
: String(styleName || 'normal').toUpperCase();
|
|
}
|
|
|
|
_updateHudButtonState() {
|
|
this._hudBtn.classList.toggle('active', this.hud.visible);
|
|
if (this._hudLayoutRow) {
|
|
this._hudLayoutRow.classList.toggle('visible', this.hud.visible);
|
|
}
|
|
this._scheduleAdaptivePanelLayout({ settle: true });
|
|
}
|
|
|
|
_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._layoutRightPanels();
|
|
}
|
|
stop() {
|
|
this._lifetime.destroy();
|
|
this._styleParameters?.destroy();
|
|
this._visualEffects.stop();
|
|
}
|
|
releaseIrBoost() {
|
|
if (!this._irBoostActive) return;
|
|
if (this._irFogWasEnabled != null && this.viewer?.scene?.fog)
|
|
this.viewer.scene.fog.enabled = this._irFogWasEnabled;
|
|
this._dataManager?.setLayerParams('flights', { irBoost: false });
|
|
this._dataManager?.setLayerParams('military', { irBoost: false });
|
|
this._irBoostActive = false;
|
|
this._irFogWasEnabled = null;
|
|
}
|
|
destroy() {
|
|
this.stop();
|
|
this.releaseIrBoost();
|
|
this._visualEffects.destroy();
|
|
}
|
|
}
|