mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-04 10:24:02 +00:00
A panel's collapsed, dock-pinned, dock-pinned-top, layout-auto-collapsed
and accordion-collapsed classes are drawn by its root's v-panel-state from
the gev-panel-chrome store. PanelChrome, its restore, the layout
controller, both rails and the side accordion present each panel's state
there instead of writing its classes; the rails call onCollapse(panel,
{collapsed, auto}). The directive applies a change in a synchronous
watcher, since the rails measure a panel in the task that opens or closes
it.
Destroying the chrome inside Cockpit now puts back the panels Cockpit's
entry closed while it can still present them. Without a shell, the voice
tools' set_panel_open reports that there is no panel to open. QA scripts
present the states they set instead of writing the classes.
1595 lines
53 KiB
JavaScript
1595 lines
53 KiB
JavaScript
import { clampScopeFeatherPct } from '../scopeMaskPolicy.js';
|
|
import { STYLE_STATUS_LABELS } from './visualPresets.js';
|
|
import { RETIRED_STYLES } from '../visualStylePolicy.js';
|
|
import { useDisplayStore } from './stores/display.js';
|
|
import { applicationPinia } from './vueRuntime.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 { 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') {
|
|
const intensity = decodeBloomIntensity(bloomIntensity, bloomVersion);
|
|
this._setBloomIntensity(intensity, { syncShare: false });
|
|
}
|
|
if (typeof sharpenIntensity === 'number')
|
|
this._setSharpenIntensity(sharpenIntensity, { syncShare: false });
|
|
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._setDetectionDensity(detectionDensity);
|
|
if (detectionAllocation) {
|
|
this._setDetectionAllocation(detectionAllocation, {
|
|
syncShare: false,
|
|
persist: false,
|
|
});
|
|
}
|
|
this._setDetectionFade({
|
|
fadePct:
|
|
typeof detectionFadePct === 'number' ? detectionFadePct : undefined,
|
|
outsideOpacityPct:
|
|
typeof detectionOutsideOpacityPct === 'number'
|
|
? detectionOutsideOpacityPct
|
|
: undefined,
|
|
});
|
|
if (detectionMode) this._setDetectionMode(detectionMode);
|
|
if (typeof celestialRing === 'boolean') {
|
|
this.setCelestialRingEnabled(celestialRing, {
|
|
syncShare: false,
|
|
focus: false,
|
|
});
|
|
}
|
|
this._setScope({ enabled: scopeEnabled, featherPct: scopeFeatherPct });
|
|
// 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,
|
|
store = useDisplayStore(applicationPinia()),
|
|
}) {
|
|
this.store = store;
|
|
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,
|
|
});
|
|
// The sharpen amount as a whole percentage. VisualEffects keeps the
|
|
// fraction it applies, and drops writes once it is stopped.
|
|
this._sharpenPct = 49;
|
|
// Detection's density stop, keyhole fade and outside opacity, in whole
|
|
// percentages; the mode label the button shows (null until first
|
|
// synced) and the allocation its segments show (null until set).
|
|
this._detectionDensityPct = 50;
|
|
this._detectionFadePct = 7;
|
|
this._detectionOutsidePct = 1;
|
|
this._detectionModeLabel = null;
|
|
this._detectionAllocationShown = null;
|
|
// What the HUD and Sonar controls show; null until first synced, so the
|
|
// initial markup stands.
|
|
this._hudVisibleShown = null;
|
|
this._hudVariantShown = null;
|
|
this._sonarShown = null;
|
|
// What the Scope and Celestial controls show; null until first set.
|
|
this._scopeEnabledShown = null;
|
|
this._scopeFeatherShown = null;
|
|
this._celestialShown = null;
|
|
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;
|
|
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;
|
|
}
|
|
|
|
/** Bloom intensity as the Display slider shows it, 0-200. */
|
|
get bloomIntensityPct() {
|
|
return clampBloomIntensity(this._getBloomIntensity());
|
|
}
|
|
|
|
/** Sharpen amount as the Display slider shows it, 0-100. */
|
|
get sharpenIntensityPct() {
|
|
return this._sharpenPct;
|
|
}
|
|
|
|
_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._cockpitDisclosureOpen?.('display')) {
|
|
this._setCockpitDisclosure?.('display', true);
|
|
return;
|
|
}
|
|
if (!this._cockpitDisclosureOpen?.('display')) return;
|
|
this._presentPanel(this._sliderPanel, { collapsed: false });
|
|
this._lifetime.frame(() =>
|
|
this._lifetime.frame(() => {
|
|
this._sliderPanel?.scrollIntoView?.({ block: 'nearest' });
|
|
}),
|
|
);
|
|
}
|
|
|
|
_initBloomSharpen() {
|
|
this._visualEffects.initPostProcess(this._sharpenPct / 100);
|
|
this._publishDisplay();
|
|
}
|
|
|
|
/** Present the Display panel's post-processing groups. */
|
|
_presentDisplay() {
|
|
return {
|
|
bloom: { active: this.bloomEnabled, intensity: this.bloomIntensityPct },
|
|
sharpen: { active: this.sharpenEnabled, intensity: this._sharpenPct },
|
|
detection: this._presentDetection(),
|
|
hud: { visible: this._hudVisibleShown, variant: this._hudVariantShown },
|
|
sonar: this._sonarShown,
|
|
scope: {
|
|
enabled: this._scopeEnabledShown,
|
|
featherPct: this._scopeFeatherShown,
|
|
},
|
|
celestial: this._celestialShown,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Apply the Scope mask and show it on its controls; a field that is not
|
|
* given stays as it is. `apply: false` only shows the mask's state.
|
|
*/
|
|
_setScope({ enabled, featherPct } = {}, { apply = true } = {}) {
|
|
if (typeof enabled === 'boolean') {
|
|
if (apply) this.services.setScopeMaskEnabled(enabled);
|
|
this._scopeEnabledShown = enabled;
|
|
}
|
|
if (typeof featherPct === 'number') {
|
|
const pct = clampScopeFeatherPct(featherPct);
|
|
if (apply) this.services.setScopeMaskFeather(pct / 100);
|
|
this._scopeFeatherShown = pct;
|
|
}
|
|
this._publishDisplay();
|
|
}
|
|
|
|
/** Detection's button, rows, sliders and allocation segments. */
|
|
_presentDetection() {
|
|
const mode = this._detectionModeLabel;
|
|
const enabled = mode != null && mode !== 'OFF';
|
|
const labels = {
|
|
SPARSE: t('SPARSE'),
|
|
BALANCED: t('BALANCED'),
|
|
DENSE: t('DENSE'),
|
|
};
|
|
return {
|
|
// Null until the first sync, so the initial markup stands.
|
|
button:
|
|
mode == null
|
|
? null
|
|
: {
|
|
pressed: enabled,
|
|
ariaLabel: enabled
|
|
? t('Detection overlay: {mode}', {
|
|
mode: String(mode).toLowerCase(),
|
|
})
|
|
: t('Detection overlay: off'),
|
|
label: labels[mode] ?? t('DETECT'),
|
|
active: Object.hasOwn(labels, mode),
|
|
panoptic: mode === 'DENSE',
|
|
},
|
|
rowsVisible: enabled,
|
|
densityPct: this._detectionDensityPct,
|
|
fadePct: this._detectionFadePct,
|
|
outsideOpacityPct: this._detectionOutsidePct,
|
|
allocation: this._detectionAllocationShown,
|
|
};
|
|
}
|
|
|
|
_publishDisplay() {
|
|
this.store.publish(this._presentDisplay());
|
|
}
|
|
|
|
_getBloomIntensity() {
|
|
return this._visualEffects.bloomIntensity;
|
|
}
|
|
|
|
_syncBloomStageEnabled() {
|
|
this._visualEffects.syncBloomEnabled();
|
|
}
|
|
|
|
_setBloomIntensity(intensity, { syncShare = true } = {}) {
|
|
const { governorRequestRender } = this.services;
|
|
governorRequestRender('bloom');
|
|
this._applyBloomIntensity(clampBloomIntensity(intensity));
|
|
this._publishDisplay();
|
|
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._publishDisplay();
|
|
if (this.bloomEnabled) {
|
|
this._applyBloomIntensity(this._getBloomIntensity());
|
|
}
|
|
this._syncShareState();
|
|
this._layoutRightPanels();
|
|
}
|
|
|
|
_applySharpenIntensity(val) {
|
|
this._visualEffects.applySharpenIntensity(val);
|
|
}
|
|
|
|
/** Set the sharpen amount, a percentage clamped to 0-100, and apply it. */
|
|
_setSharpenIntensity(pct, { syncShare = true } = {}) {
|
|
if (!Number.isFinite(pct)) return;
|
|
this._sharpenPct = Math.max(0, Math.min(100, Math.round(pct)));
|
|
this._applySharpenIntensity(this._sharpenPct / 100);
|
|
this._publishDisplay();
|
|
if (syncShare) this._syncShareState();
|
|
}
|
|
|
|
_setSharpenEnabled(enabled) {
|
|
const { governorRequestRender } = this.services;
|
|
governorRequestRender('sharpen');
|
|
this._visualEffects.setSharpenEnabled(enabled);
|
|
this._publishDisplay();
|
|
if (this.sharpenEnabled)
|
|
this._applySharpenIntensity(this._sharpenPct / 100);
|
|
this._syncShareState();
|
|
this._layoutRightPanels();
|
|
}
|
|
|
|
/** Set detection's density to its nearest stop and apply it. */
|
|
_setDetectionDensity(pct = this._detectionDensityPct) {
|
|
const { getDetectionMode, setDetectionTuning } = this.services;
|
|
this._detectionDensityPct = canonicalizeDensity(pct);
|
|
setDetectionTuning({ densityPct: this._detectionDensityPct });
|
|
this._updateDetectionButton(getDetectionMode());
|
|
}
|
|
|
|
/**
|
|
* Set the keyhole fade distance (0-40%) and the opacity outside it
|
|
* (0-100%), keeping either one that is not given, and apply them.
|
|
*/
|
|
_setDetectionFade({
|
|
fadePct = this._detectionFadePct,
|
|
outsideOpacityPct = this._detectionOutsidePct,
|
|
} = {}) {
|
|
const { setKeyholeFadeTuning } = this.services;
|
|
const whole = (value, max) =>
|
|
Math.max(0, Math.min(max, Math.round(Number(value) || 0)));
|
|
this._detectionFadePct = whole(fadePct, 40);
|
|
this._detectionOutsidePct = whole(outsideOpacityPct, 100);
|
|
this._publishDisplay();
|
|
setKeyholeFadeTuning({
|
|
fadeRatio: this._detectionFadePct / 100,
|
|
outsideOpacity: this._detectionOutsidePct / 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 });
|
|
this._detectionAllocationShown = normalized;
|
|
this._publishDisplay();
|
|
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();
|
|
this._detectionDensityPct = canonicalizeDensity(tuning.densityPct);
|
|
this._setDetectionAllocation(tuning.allocationStrategy, {
|
|
syncShare: false,
|
|
persist: false,
|
|
});
|
|
const fadeTuning = getKeyholeFadeTuning();
|
|
this._setDetectionFade({
|
|
fadePct: fadeTuning.fadeRatio * 100,
|
|
outsideOpacityPct: fadeTuning.outsideOpacity * 100,
|
|
});
|
|
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;
|
|
}
|
|
this._hudVariantShown = nextVariant;
|
|
this._publishDisplay();
|
|
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() {
|
|
const settings = applyCyberSonarSettings(readCyberSonarSettings());
|
|
this._sonarShown = {
|
|
enabled: isCyberSonarEnabled(),
|
|
rings: settings.rings,
|
|
range: settings.range,
|
|
intensity: settings.intensity,
|
|
opacity: settings.opacity,
|
|
sector: settings.sector,
|
|
};
|
|
this._publishDisplay();
|
|
}
|
|
|
|
_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._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._setSharpenIntensity(sharpenInput.intensity, { syncShare: false });
|
|
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._setDetectionDensity(det.densityPct);
|
|
if (det.mode) this._setDetectionMode(String(det.mode).toUpperCase());
|
|
}
|
|
|
|
_applyGlobalPostDefaults() {
|
|
const defaults = GLOBAL_POST_DEFAULTS;
|
|
if (typeof defaults.bloom?.intensity === 'number') {
|
|
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._setSharpenIntensity(defaults.sharpen.intensity, {
|
|
syncShare: false,
|
|
});
|
|
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._setDetectionDensity(defaults.detectionDensity);
|
|
this._setDetectionAllocation(
|
|
this._detectionAllocationPreference ||
|
|
defaults.detectionAllocation ||
|
|
'ELASTIC',
|
|
{ syncShare: false, persist: false },
|
|
);
|
|
this._setDetectionFade({
|
|
fadePct: defaults.detectionFadePct ?? 7,
|
|
outsideOpacityPct: defaults.detectionOutsideOpacityPct ?? 1,
|
|
});
|
|
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: this._detectionDensityPct,
|
|
});
|
|
}
|
|
|
|
_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: this._sharpenPct,
|
|
hudVariant: this.hud.getVariant(),
|
|
hudVisible: this.hud.visible,
|
|
detectionMode: detection.mode,
|
|
detectionDensity: detection.densityPct,
|
|
detectionAllocation: getDetectionTuning().allocationStrategy,
|
|
detectionFadePct: this._detectionFadePct,
|
|
detectionOutsideOpacityPct: this._detectionOutsidePct,
|
|
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;
|
|
return {
|
|
detectionMode: getDetectionMode(),
|
|
densityPct: this._detectionDensityPct,
|
|
allocationStrategy: getDetectionTuning().allocationStrategy,
|
|
fadePct: this._detectionFadePct,
|
|
outsideOpacityPct: this._detectionOutsidePct,
|
|
};
|
|
}
|
|
|
|
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 || outsideOpacityPct != null)
|
|
this._setDetectionFade({
|
|
fadePct: fadePct ?? undefined,
|
|
outsideOpacityPct: outsideOpacityPct ?? undefined,
|
|
});
|
|
|
|
if (
|
|
requestedProfile &&
|
|
requestedProfile !== 'OFF' &&
|
|
requestedDensity == null
|
|
) {
|
|
requestedDensity = defaultDensityForProfile(requestedProfile);
|
|
}
|
|
if (requestedDensity != null) this._setDetectionDensity(requestedDensity);
|
|
|
|
if (enabled === false || requestedProfile === 'OFF') {
|
|
setDetectionModeByLabel('OFF');
|
|
} else if (requestedProfile) {
|
|
setDetectionModeByLabel(requestedProfile);
|
|
} else if (enabled === true && getDetectionMode() === 'OFF') {
|
|
setDetectionModeByLabel(
|
|
profileForDensity(requestedDensity ?? this._detectionDensityPct),
|
|
);
|
|
}
|
|
this._syncDetectionUiFromEngine();
|
|
this._syncShareState();
|
|
return { ok: true, ...this.getDetectionState() };
|
|
}
|
|
|
|
setBloom({ enabled, intensityPct } = {}) {
|
|
const current = () => ({
|
|
enabled: !!this.bloomEnabled,
|
|
intensityPct: this.bloomIntensityPct,
|
|
});
|
|
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.sharpenIntensityPct,
|
|
});
|
|
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) {
|
|
this._setSharpenIntensity(intensityPct);
|
|
}
|
|
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._celestialShown = { active: nextEnabled, supported: styleSupported };
|
|
this._publishDisplay();
|
|
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: this._sharpenPct,
|
|
},
|
|
hud: {
|
|
visible: this.hud.visible,
|
|
variant: this.hud.getVariant(),
|
|
},
|
|
detection: {
|
|
mode: getDetectionMode(),
|
|
density: this._detectionDensityPct,
|
|
allocation: getDetectionTuning().allocationStrategy,
|
|
fadePct: this._detectionFadePct,
|
|
outsideOpacityPct: this._detectionOutsidePct,
|
|
},
|
|
scope: {
|
|
enabled: isScopeMaskEnabled(),
|
|
featherPct: Math.round(getScopeMaskFeather() * 100),
|
|
},
|
|
mapStack: this.mapStackController?.getActiveId?.() || 'photoreal',
|
|
styleParams,
|
|
};
|
|
}
|
|
|
|
async applyVisualState(state = {}, { isCurrent = null } = {}) {
|
|
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') {
|
|
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._setSharpenIntensity(sharpenState.intensity, { syncShare: false });
|
|
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 || {};
|
|
this._setScope({
|
|
enabled: scopeState.enabled,
|
|
featherPct: scopeState.featherPct,
|
|
});
|
|
|
|
const detectionState = state.detection || {};
|
|
if (typeof detectionState.density === 'number')
|
|
this._setDetectionDensity(detectionState.density);
|
|
if (detectionState.allocation) {
|
|
this._setDetectionAllocation(detectionState.allocation, {
|
|
syncShare: false,
|
|
});
|
|
}
|
|
this._setDetectionFade({
|
|
fadePct:
|
|
typeof detectionState.fadePct === 'number'
|
|
? detectionState.fadePct
|
|
: undefined,
|
|
outsideOpacityPct:
|
|
typeof detectionState.outsideOpacityPct === 'number'
|
|
? detectionState.outsideOpacityPct
|
|
: undefined,
|
|
});
|
|
if (detectionState.mode) {
|
|
this._setDetectionMode(detectionState.mode);
|
|
}
|
|
|
|
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._setSharpenIntensity(sharpenInput.intensity, { syncShare: false });
|
|
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._setDetectionDensity(preset.detectionDensity);
|
|
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._presentPanel(this._sliderPanel, { collapsed: false });
|
|
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);
|
|
}
|
|
|
|
// The style buttons, the dock's mini status and the ACTIVE STYLE corner.
|
|
this._publishActiveStyle(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);
|
|
}
|
|
|
|
/** Publish the active style and its label in the UI language. */
|
|
_publishActiveStyle(styleName = this.activeStyle) {
|
|
const name = String(styleName || 'normal');
|
|
this.store.publishStyle({
|
|
name,
|
|
label: STYLE_STATUS_LABELS[name]
|
|
? t(STYLE_STATUS_LABELS[name])
|
|
: name.toUpperCase(),
|
|
});
|
|
}
|
|
|
|
_updateHudButtonState() {
|
|
this._syncHudControls();
|
|
}
|
|
|
|
/**
|
|
* Show the HUD's current visibility and layout on its controls; the layout
|
|
* row's visibility changes the panel's height, so the rails settle again.
|
|
*/
|
|
_syncHudControls() {
|
|
this._hudVisibleShown = Boolean(this.hud.visible);
|
|
this._hudVariantShown = this.hud.getVariant();
|
|
this._publishDisplay();
|
|
this._scheduleAdaptivePanelLayout({ settle: true });
|
|
}
|
|
|
|
_updateDetectionButton(modeLabel) {
|
|
this._detectionModeLabel = modeLabel;
|
|
this._publishDisplay();
|
|
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();
|
|
}
|
|
}
|