Files
gods-eye-view/src/ui/visualSettings.js
T
Said Turksever 3b6111be06 Open views naming the retired Snow style on Normal
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.
2026-10-29 11:34:19 +00:00

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();
}
}