diff --git a/CHANGELOG.md b/CHANGELOG.md
index 981a7803a..96850ce94 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,5 +1,7 @@
# Changelog
+- Separate navigation, share restoration, visual settings and panel state into focused UI owners with explicit dependencies and terminal cleanup.
+
- Separate layer lifecycle transactions from panel construction and render/detection reactions; retain existing transition and refresh behavior.
- Let CLI tools, development launchers and the setup doctor use an explicit project directory while retaining their existing default paths.
diff --git a/docs/CODE-BOUNDARIES.md b/docs/CODE-BOUNDARIES.md
index 62611d8af..f3a3105d3 100644
--- a/docs/CODE-BOUNDARIES.md
+++ b/docs/CODE-BOUNDARIES.md
@@ -461,3 +461,19 @@ render requests and detection invalidation. It owns hidden-panel refresh and
listener cleanup. Application data assembly constructs both owners explicitly;
`data/manager` is the compatibility facade for direct callers. The ordinary
state subscriptions retain their existing event contract.
+
+## UI state owners
+
+- `ui/navigation` owns navigation generations, pending search presentation and
+ tracking handoff. It reads Cockpit admission and uses supplied tracking operations.
+- `ui/share-restoration` owns the initial restore transaction, layer coordinator,
+ status notices and gesture/timer cleanup.
+- `ui/visual-settings` owns style preferences, detection overrides, display inputs
+ and IR cleanup. Its engine services and panel operations are explicit.
+- `ui/panel-chrome` owns disclosure, docking, collapse preferences and temporary
+ Cockpit panel snapshots, composing the existing positioning/layout controllers.
+
+These package groups contain their own dependencies and exclude application
+assembly. `applicationShell` retains composition and compatibility methods;
+those methods delegate to the state owner. Source-based regression checks inspect
+that implementation owner, and browser acceptance exercises the assembled UI.
diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md
index daccbb96c..842f22ac3 100644
--- a/docs/CURRENT-STATE.md
+++ b/docs/CURRENT-STATE.md
@@ -1,5 +1,14 @@
# God's Eye View Current State
+The UI facade composes separate owners for navigation authority, share restoration,
+visual settings and panel chrome. Navigation owns request generations and follow
+handoff; restoration owns its coordinator, deferred notices, gestures and timer;
+visual settings own style/detection overrides and sensor cleanup; panel chrome
+owns disclosure, docking and Cockpit panel snapshots. Each takes explicit
+operations and reads instead of the complete shell. Existing controls, share
+formats and one-application-per-page behavior are preserved.
+
+
Layer lifecycle transactions are owned by `data/lifecycle`, which has no panel,
render-governor or detection imports. Application composition mounts a separate
layer presentation owner that reacts to status, accepted visibility/parameter
diff --git a/package.json b/package.json
index d7adaae42..0bf4821af 100644
--- a/package.json
+++ b/package.json
@@ -196,6 +196,10 @@
"./standalone/catalog": "./src/standalone/catalog.js",
"./application/layers": "./src/app/constructCatalog.js",
"./application/operations": "./src/app/operations.js",
- "./data/lifecycle": "./src/data/lifecycle.js"
+ "./data/lifecycle": "./src/data/lifecycle.js",
+ "./ui/navigation": "./src/ui/navigationController.js",
+ "./ui/share-restoration": "./src/ui/shareRestoration.js",
+ "./ui/visual-settings": "./src/ui/visualSettings.js",
+ "./ui/panel-chrome": "./src/ui/panelChrome.js"
}
}
diff --git a/scripts/format-scope.json b/scripts/format-scope.json
index 3f37f1b28..8eb4517f8 100644
--- a/scripts/format-scope.json
+++ b/scripts/format-scope.json
@@ -589,5 +589,11 @@
"src/app/operations.test.mjs",
"src/data/lifecycle.js",
"src/data/manager.js",
- "src/app/layerPresentation.js"
+ "src/app/layerPresentation.js",
+ "src/ui/navigationController.js",
+ "src/ui/shareRestoration.js",
+ "src/ui/visualSettings.js",
+ "src/ui/panelChrome.js",
+ "src/testSupport/readShellSource.mjs",
+ "src/ui/stateOwners.test.mjs"
]
diff --git a/scripts/package-boundaries.json b/scripts/package-boundaries.json
index 9a2b7040a..130179411 100644
--- a/scripts/package-boundaries.json
+++ b/scripts/package-boundaries.json
@@ -439,6 +439,7 @@
"ui-shell": {
"exports": ["./ui/shell"],
"modules": [
+ "src/app/stateChannel.js",
"src/bloom.js",
"src/cctvFocusPolicy.js",
"src/cctvFocusRequest.js",
@@ -498,6 +499,7 @@
"src/ui/contextTransactions.js",
"src/ui/displayControls.js",
"src/ui/effects.js",
+ "src/ui/frameRateMonitor.js",
"src/ui/layerPanel.js",
"src/ui/layers.js",
"src/ui/leftPanelRail.js",
@@ -506,6 +508,8 @@
"src/ui/locationSearch.js",
"src/ui/mapSource.js",
"src/ui/mapSourceControls.js",
+ "src/ui/navigationController.js",
+ "src/ui/panelChrome.js",
"src/ui/panelDisclosure.js",
"src/ui/panelLayoutController.js",
"src/ui/panelMeasurement.js",
@@ -522,17 +526,17 @@
"src/ui/sceneControls.js",
"src/ui/scenePresentation.js",
"src/ui/scenes.js",
+ "src/ui/shareRestoration.js",
"src/ui/shellElements.js",
"src/ui/shellFeedback.js",
"src/ui/styleParameters.js",
"src/ui/surfaceKeyboard.js",
+ "src/ui/uiLifetime.js",
"src/ui/visualEffects.js",
"src/ui/visualInput.js",
"src/ui/visualPresets.js",
- "src/worldFocus.js",
- "src/ui/uiLifetime.js",
- "src/app/stateChannel.js",
- "src/ui/frameRateMonitor.js"
+ "src/ui/visualSettings.js",
+ "src/worldFocus.js"
],
"external": ["cesium"]
},
@@ -1498,6 +1502,8 @@
"src/ui/locationSearch.js",
"src/ui/mapSource.js",
"src/ui/mapSourceControls.js",
+ "src/ui/navigationController.js",
+ "src/ui/panelChrome.js",
"src/ui/panelDisclosure.js",
"src/ui/panelLayoutController.js",
"src/ui/panelMeasurement.js",
@@ -1514,6 +1520,7 @@
"src/ui/sceneControls.js",
"src/ui/scenePresentation.js",
"src/ui/scenes.js",
+ "src/ui/shareRestoration.js",
"src/ui/shellElements.js",
"src/ui/shellFeedback.js",
"src/ui/styleParameters.js",
@@ -1522,6 +1529,7 @@
"src/ui/visualEffects.js",
"src/ui/visualInput.js",
"src/ui/visualPresets.js",
+ "src/ui/visualSettings.js",
"src/voice/commands.js",
"src/voice/control.js",
"src/voice/gevActions.js",
@@ -1911,5 +1919,63 @@
"exports": ["./data/lifecycle"],
"modules": ["src/data/lifecycle.js"],
"external": []
+ },
+ "ui-navigation": {
+ "exports": ["./ui/navigation"],
+ "modules": ["src/navigationPolicy.js", "src/ui/navigationController.js"],
+ "external": ["cesium"]
+ },
+ "ui-share-restoration": {
+ "exports": ["./ui/share-restoration"],
+ "modules": [
+ "src/data/installationFeedback.js",
+ "src/data/layerState.js",
+ "src/loadingFeedback.js",
+ "src/navigationPolicy.js",
+ "src/ui/shareRestoration.js",
+ "src/ui/uiLifetime.js"
+ ],
+ "external": []
+ },
+ "ui-visual-settings": {
+ "exports": ["./ui/visual-settings"],
+ "modules": [
+ "src/bloom.js",
+ "src/cockpitVisionPolicy.js",
+ "src/contactsDetectionPolicy.js",
+ "src/data/detectionPolicy.js",
+ "src/styles/anime.js",
+ "src/styles/noir.js",
+ "src/styles/retro.js",
+ "src/styles/snow.js",
+ "src/styles/surveillance.js",
+ "src/styles/thermal.js",
+ "src/ui/applicationShortcuts.js",
+ "src/ui/effects.js",
+ "src/ui/styleParameters.js",
+ "src/ui/uiLifetime.js",
+ "src/ui/visualEffects.js",
+ "src/ui/visualInput.js",
+ "src/ui/visualPresets.js",
+ "src/ui/visualSettings.js"
+ ],
+ "external": ["cesium"]
+ },
+ "ui-panel-chrome": {
+ "exports": ["./ui/panel-chrome"],
+ "modules": [
+ "src/panelStackLayout.js",
+ "src/ui/leftPanelRail.js",
+ "src/ui/panelChrome.js",
+ "src/ui/panelDisclosure.js",
+ "src/ui/panelLayoutController.js",
+ "src/ui/panelMeasurement.js",
+ "src/ui/panelPositionControls.js",
+ "src/ui/panelRailGeometry.js",
+ "src/ui/panelRails.js",
+ "src/ui/rightPanelRail.js",
+ "src/ui/uiLifetime.js"
+ ],
+ "external": []
}
}
diff --git a/src/cameraHandoff.test.mjs b/src/cameraHandoff.test.mjs
index 2d2580120..9ba486f55 100644
--- a/src/cameraHandoff.test.mjs
+++ b/src/cameraHandoff.test.mjs
@@ -1,3 +1,4 @@
+import { readShellSource, shellMethod } from './testSupport/readShellSource.mjs';
import { readLayerSource } from './testSupport/readLayerSource.mjs';
import { StyleManager } from './ui/applicationShell.js';
import { enter as cockpitEnter, navigateContext } from './ui/cockpitTrackingController.js';
@@ -9,7 +10,7 @@ import test from 'node:test';
import { fileURLToPath } from 'node:url';
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
-const ui = fs.readFileSync(path.join(ROOT, 'src', 'ui', 'applicationShell.js'), 'utf8');
+const ui = readShellSource();
const firms = readLayerSource(path.join(ROOT, 'src', 'data', 'firmsHeatmap.js'));
const vessels = readLayerSource(path.join(ROOT, 'src', 'data', 'aisLiveVessels.js'));
const voice = fs.readFileSync(path.join(ROOT, 'src', 'voice', 'gevActions.js'), 'utf8');
@@ -18,8 +19,8 @@ const cockpitTracking = fs.readFileSync(path.join(ROOT, 'src', 'cockpitTracking.
function body(source, pattern, label) {
const name = pattern.source.match(/^(\w+)/)?.[1];
- if (source === ui && typeof StyleManager.prototype[name] === 'function') {
- const method = StyleManager.prototype[name].toString();
+ if (source === ui && typeof shellMethod(name) === 'function') {
+ const method = shellMethod(name).toString();
return method.slice(method.indexOf(') {') + 3, -1);
}
const match = source.match(pattern);
@@ -174,7 +175,7 @@ test('accepted navigation releases through PR15-aware ownership before flight',
'explicit navigation',
);
ordered(run, [
- 'cockpitActive: !!this.cockpitView?.active',
+ 'cockpitActive: this.isCockpitActive()',
'stamp: () => this._stampNavigation()',
'release: () => this._releaseFollowCamera(releaseOptions)',
'navigate,',
@@ -266,7 +267,7 @@ test('deferred search releases only after its final authority check', () => {
test('a direct globe gesture retires delayed camera and selection restore only', () => {
assert.match(
ui,
- /this\._initialShareGestureHandler = \(\) => \{[\s\S]*?!this\._resolveInitialShareRestore[\s\S]*?stampInitialShareGesture\(\(options\) => this\._stampNavigation\(options\)\);/,
+ /this\._initialShareGestureHandler = \(\) => \{[\s\S]*?!this\._resolveInitialShareRestore[\s\S]*?stampInitialShareGesture\(\(options\) =>\s*this\.navigation\._stampNavigation\(options\),?\s*\);/,
);
assert.match(
ui,
@@ -300,10 +301,11 @@ test('newer navigation, reset, Cockpit, and teardown share one generation', () =
const dispose = body(ui, /async dispose\(\) \{([\s\S]*?)\n \}/, 'dispose');
ordered(dispose, [
'this._disposed = true;',
- 'this._stampNavigation();',
+ 'this._navigation.destroy();',
'this._removeWorldRequestFocusListener?.();',
'await this._contextControls.restoreForDisposal();',
], 'dispose invalidation');
+ assert.match(shellMethod('destroy').toString(), /this\._stampNavigation\(\)/);
});
test('teardown synchronously closes immediate camera entry points', () => {
diff --git a/src/cctvFocusPolicy.test.mjs b/src/cctvFocusPolicy.test.mjs
index faf70f9f6..13fbff552 100644
--- a/src/cctvFocusPolicy.test.mjs
+++ b/src/cctvFocusPolicy.test.mjs
@@ -1,3 +1,4 @@
+import { readShellSource } from './testSupport/readShellSource.mjs';
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
@@ -73,7 +74,7 @@ test('CCTV enable retains a pre-await tracking snapshot when tracking clears dur
assert.match(diagnostics[0][0], /before setEnabled await/);
assert.match(diagnostics[1][0], /after setEnabled await/);
- const uiSource = readFileSync(new URL('./ui/applicationShell.js', import.meta.url), 'utf8');
+ const uiSource = readShellSource();
assert.match(uiSource, /await runCctvLayerEnableTransition\(\{/);
});
@@ -114,6 +115,6 @@ test('a disposed UI cannot activate a camera when enabling finishes late', async
release();
await pending;
assert.equal(activations, 0);
- const uiSource = readFileSync(new URL('./ui/applicationShell.js', import.meta.url), 'utf8');
+ const uiSource = readShellSource();
assert.match(uiSource, /shouldFocus: \(\) =>\s*!this\._disposed\s*&&\s*this\._dataManager\.isEnabled\('cctv'\)/);
});
diff --git a/src/cctvFocusRequest.test.mjs b/src/cctvFocusRequest.test.mjs
index 64b4c20de..be05ef7cb 100644
--- a/src/cctvFocusRequest.test.mjs
+++ b/src/cctvFocusRequest.test.mjs
@@ -1,3 +1,4 @@
+import { readShellSource } from './testSupport/readShellSource.mjs';
import test from 'node:test';
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
@@ -57,7 +58,7 @@ test('UI CCTV focus listener registration disposes the exact added callback once
assert.strictEqual(added[0].callback, listener);
assert.strictEqual(removed[0].callback, added[0].callback);
- const uiSource = readFileSync(new URL('./ui/applicationShell.js', import.meta.url), 'utf8');
+ const uiSource = readShellSource();
assert.match(
uiSource,
/_removeCctvRequestFocusListener = registerCctvFocusRequestListener\([\s\S]+this\._cctvRequestFocusHandler/,
diff --git a/src/cockpitMarkup.test.mjs b/src/cockpitMarkup.test.mjs
index 05803fa34..8acf28d16 100644
--- a/src/cockpitMarkup.test.mjs
+++ b/src/cockpitMarkup.test.mjs
@@ -1,3 +1,4 @@
+import { readShellSource, shellMethod } from './testSupport/readShellSource.mjs';
import { expandApplicationHtml } from '../build/application-html.js';
import { readLayerSource } from './testSupport/readLayerSource.mjs';
import { PanelLayoutController } from './ui/panelLayoutController.js';
@@ -28,7 +29,7 @@ import { fileURLToPath } from 'node:url';
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
const html = expandApplicationHtml(fs.readFileSync(path.join(ROOT, 'index.html'), 'utf8'));
-const ui = fs.readFileSync(path.join(ROOT, 'src', 'ui', 'applicationShell.js'), 'utf8');
+const ui = readShellSource();
const css = readStylesheet(path.join(ROOT, 'style.css'));
const sceneDirector = fs.readFileSync(path.join(ROOT, 'src', 'scenes', 'director.js'), 'utf8');
const manager = fs.readFileSync(path.join(ROOT, 'src', 'data', 'lifecycle.js'), 'utf8');
@@ -203,7 +204,8 @@ test('programmatic Context layer changes cannot bypass explicit expansion policy
});
test('share startup isolates panel defaults from recipient-local collapse preferences', () => {
- const parseIndex = ui.indexOf('this._initialShareState = this.shareLinkManager.parseInitialHash();');
+ const parseIndex = ui.indexOf('this._shareRestoration.attachLinks(this.shareLinkManager);');
+ assert.match(shellMethod('attachLinks').toString(), /this\._initialShareState = shareLinkManager\.parseInitialHash\(\)/);
const panelChromeIndex = ui.indexOf('this._initPanelChrome();');
assert.ok(parseIndex >= 0, 'initial share state must be parsed during UI construction');
assert.ok(
@@ -211,11 +213,11 @@ test('share startup isolates panel defaults from recipient-local collapse prefer
'share state must be known before panel chrome can read recipient-local preferences',
);
assert.equal(
- (ui.match(/this\.shareLinkManager\.parseInitialHash\(\)/g) || []).length,
+ (ui.match(/shareLinkManager\.parseInitialHash\(\)/g) || []).length,
1,
'startup must parse the incoming share exactly once',
);
- const panelChrome = ui.match(/_initPanelChrome\(\) \{([\s\S]*?)\n \}\n\n \/\*\*/);
+ const panelChrome = { 1: shellMethod('_initPanelChrome').toString() };
assert.ok(panelChrome, 'panel chrome initializer is missing');
assert.match(
panelChrome[1],
@@ -397,7 +399,8 @@ test('fresh Cockpit entry temporarily collapses map panels and exit restores the
assert.match(entryPanels[1], new RegExp(`'${panelId}'`), `${panelId} must collapse on entry`);
}
- const callback = ui.match(/onEntered: \(\) => \{([\s\S]*?)\n \},\n onExited:/);
+ assert.match(ui, /onEntered: \(\) => this\._panelChrome\.enterCockpit\(\)/);
+ const callback = { 1: shellMethod('enterCockpit').toString() };
assert.ok(callback, 'Cockpit onEntered callback is missing');
assert.match(
callback[1],
@@ -426,7 +429,8 @@ test('fresh Cockpit entry temporarily collapses map panels and exit restores the
'normal Context must not reopen over Cockpit',
);
- const exitCallback = ui.match(/onExited: \(\) => \{([\s\S]*?)\n \},\n restoreTrackingFrame:/);
+ assert.match(ui, /onExited: \(\) => this\._panelChrome\.exitCockpit\(\)/);
+ const exitCallback = { 1: shellMethod('exitCockpit').toString() };
assert.ok(exitCallback, 'Cockpit onExited callback is missing');
assert.match(
exitCallback[1],
diff --git a/src/contactsDetectionPolicy.test.mjs b/src/contactsDetectionPolicy.test.mjs
index 331151f8e..862d055ce 100644
--- a/src/contactsDetectionPolicy.test.mjs
+++ b/src/contactsDetectionPolicy.test.mjs
@@ -1,3 +1,4 @@
+import { readShellSource, shellMethod } from './testSupport/readShellSource.mjs';
import { _syncContextModeButtons } from './ui/contextPresentation.js';
// Contacts-scoped detection (owner playtest 2026-08-18: "when you click on
// Contacts, detections should just turn on, and they should stay on in Cockpit
@@ -28,7 +29,7 @@ import {
import { canonicalizeDensity } from './data/detectionPolicy.js';
// Follow the UI wiring and its extracted preset definitions.
-const uiSource = fs.readFileSync(new URL('./ui/applicationShell.js', import.meta.url), 'utf8')
+const uiSource = readShellSource()
+ '\n' + fs.readFileSync(new URL('./ui/visualPresets.js', import.meta.url), 'utf8');
/**
@@ -140,10 +141,7 @@ test('the Contacts preset is the very object the military styles apply', () => {
});
test('the shared preset applier ignores the style override flag — the caller owns it', () => {
- const applier = uiSource.slice(
- uiSource.indexOf('_applyDetectionPreset(det) {'),
- uiSource.indexOf('Applies the global post-processing baseline'),
- );
+ const applier = shellMethod('_applyDetectionPreset').toString();
assert.ok(applier.length > 0);
assert.doesNotMatch(applier, /_detectionUserOverridden/);
// The STYLE path still gates on it; Contacts deliberately does not.
diff --git a/src/contextSessionOrdering.test.mjs b/src/contextSessionOrdering.test.mjs
index af836c793..6b1c90904 100644
--- a/src/contextSessionOrdering.test.mjs
+++ b/src/contextSessionOrdering.test.mjs
@@ -1,3 +1,4 @@
+import { readShellSource } from './testSupport/readShellSource.mjs';
import { _handleContextLayerChange } from './ui/contextLayerChanges.js';
import { connectContextManager } from './ui/contextSubscriptions.js';
import { _restoreContextSession, _restoreContextSessionAfterLayerSettles } from './ui/contextSession.js';
@@ -23,7 +24,7 @@ import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import { fileURLToPath } from 'node:url';
-const src = readFileSync(fileURLToPath(new URL('./ui/applicationShell.js', import.meta.url)), 'utf8');
+const src = readShellSource();
const handler = _handleContextLayerChange.toString();
diff --git a/src/contextTabKeyboard.test.mjs b/src/contextTabKeyboard.test.mjs
index e71daabff..55ff429a2 100644
--- a/src/contextTabKeyboard.test.mjs
+++ b/src/contextTabKeyboard.test.mjs
@@ -1,3 +1,4 @@
+import { readShellSource } from './testSupport/readShellSource.mjs';
import { expandApplicationHtml } from '../build/application-html.js';
import { readStylesheet } from './testSupport/readStylesheet.mjs';
import { _selectContextMode } from './ui/contextTransactions.js';
@@ -13,7 +14,7 @@ import { test } from 'node:test';
import assert from 'node:assert/strict';
const html = expandApplicationHtml(readFileSync(new URL('../index.html', import.meta.url), 'utf8'));
-const ui = readFileSync(new URL('./ui/applicationShell.js', import.meta.url), 'utf8');
+const ui = readShellSource();
const css = readStylesheet(new URL('../style.css', import.meta.url));
test('Contacts and Space Missions both participate in the ordinary Tab sequence', () => {
diff --git a/src/creditAttribution.test.mjs b/src/creditAttribution.test.mjs
index 593fb5395..cdbdf8aaa 100644
--- a/src/creditAttribution.test.mjs
+++ b/src/creditAttribution.test.mjs
@@ -1,3 +1,4 @@
+import { readShellSource } from './testSupport/readShellSource.mjs';
import { readStylesheet } from './testSupport/readStylesheet.mjs';
import assert from 'node:assert/strict';
import fs from 'node:fs';
@@ -7,7 +8,7 @@ import { fileURLToPath } from 'node:url';
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
const css = readStylesheet(path.join(ROOT, 'style.css'));
-const ui = fs.readFileSync(path.join(ROOT, 'src', 'ui', 'applicationShell.js'), 'utf8');
+const ui = readShellSource();
/*
* Required-attribution keep-out pin.
diff --git a/src/data/detectionHost.test.mjs b/src/data/detectionHost.test.mjs
index e76f6a75f..7ad94c43b 100644
--- a/src/data/detectionHost.test.mjs
+++ b/src/data/detectionHost.test.mjs
@@ -1,3 +1,4 @@
+import { readShellSource } from '../testSupport/readShellSource.mjs';
import test from 'node:test';
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
@@ -671,7 +672,7 @@ test('pathological detection paint holds alternate frames without freezing share
test('detection cannot resurrect a private canvas, listener, matrix, resize, clear, or UI inventory', () => {
const source = readFileSync(new URL('./detection.js', import.meta.url), 'utf8');
- const uiSource = readFileSync(new URL('../ui/applicationShell.js', import.meta.url), 'utf8')
+ const uiSource = readShellSource()
+ '\n' + readFileSync(new URL('../ui/visualPresets.js', import.meta.url), 'utf8');
assert.doesNotMatch(source, /createElement\(\s*['"]canvas['"]\s*\)/);
assert.doesNotMatch(source, /postRender\.addEventListener/);
diff --git a/src/data/layerState.test.mjs b/src/data/layerState.test.mjs
index fe964d19f..4fada4dcb 100644
--- a/src/data/layerState.test.mjs
+++ b/src/data/layerState.test.mjs
@@ -1,3 +1,4 @@
+import { readShellSource } from '../testSupport/readShellSource.mjs';
import { expandApplicationHtml } from '../../build/application-html.js';
import { readLayerSource } from '../testSupport/readLayerSource.mjs';
import test from 'node:test';
@@ -373,7 +374,7 @@ test('a fresh boot starts 3D aircraft ON in proximity — codec, both layers, an
assert.match(source, /^\s*(?:let |flightState\.)_models3dMode = 'proximity';/m,
`${name}: and starts in proximity, matching the codec default`);
}
- const ui = await readFile(new URL('../ui/applicationShell.js', import.meta.url), 'utf8');
+ const ui = await readShellSource();
assert.match(ui, /^\s*this\.(?:flightState\.)?_models3dEnabled = true;$/m,
'ui.js: the DISPLAY rail believes 3D is on before any layer-state sync arrives');
assert.match(ui, /this\.(?:flightState\.)?_models3dMode = 'proximity';/,
diff --git a/src/loadingFeedback.test.mjs b/src/loadingFeedback.test.mjs
index b57c92eb1..01d00b1d6 100644
--- a/src/loadingFeedback.test.mjs
+++ b/src/loadingFeedback.test.mjs
@@ -1,3 +1,5 @@
+import { ShareRestoration } from './ui/shareRestoration.js';
+import { readShellSource, shellMethod } from './testSupport/readShellSource.mjs';
import { expandApplicationHtml } from '../build/application-html.js';
import { readStylesheet } from './testSupport/readStylesheet.mjs';
import test from 'node:test';
@@ -91,11 +93,9 @@ test('deferred terminal notices lose ownership to newer acquisition epochs and d
});
test('share-follow failures use the universal top-center status instead of the bottom toast', () => {
- const ui = readFileSync(new URL('./ui/applicationShell.js', import.meta.url), 'utf8');
- const start = ui.indexOf(' _handleShareTrackingRestoreStatus(result) {');
- const end = ui.indexOf('\n _initGlobalContextPanel() {', start);
- const handler = ui.slice(start, end);
- assert.match(handler, /this\._showGlobalStatusNotice\(message\)/);
+ const ui = readShellSource();
+ const handler = shellMethod('_handleShareTrackingRestoreStatus').toString();
+ assert.match(handler, /this\.showStatus\(message\)/);
assert.match(handler, /this\.initialRestorePromise\.then\(showAfterStartupCover\)/);
assert.match(handler, /this\._lifetime\.frame\(\(\) => \{/);
assert.match(handler, /this\._lifetime\.listen\(\s*startupCover,\s*'transitionend',\s*showOnce,\s*\{ once: true \},?\s*\)/);
@@ -197,14 +197,15 @@ test('replacement, repetition, and hidden-tab elapsed time use the newest fixed
});
test('universal notice lifecycle clears on dispose and uses the one top-center live region', () => {
- const ui = readFileSync(new URL('./ui/applicationShell.js', import.meta.url), 'utf8');
+ const ui = readShellSource();
const html = expandApplicationHtml(readFileSync(new URL('../index.html', import.meta.url), 'utf8'));
const disposeStart = ui.indexOf(' async dispose() {');
const disposeEnd = ui.indexOf('\n }\n', disposeStart);
const dispose = ui.slice(disposeStart, disposeEnd);
assert.match(dispose, /this\._feedback\._globalStatusNotice = null;/);
- assert.match(dispose, /this\._shareTrackingNoticeGeneration \+= 1;/);
+ assert.match(dispose, /this\._shareRestoration\.destroy\(\)/);
+ assert.match(ShareRestoration.prototype.destroy.toString(), /this\._shareTrackingNoticeGeneration \+= 1;/);
assert.match(html, /
/);
});
diff --git a/src/locationReadouts.test.mjs b/src/locationReadouts.test.mjs
index 7711de20b..64d79037d 100644
--- a/src/locationReadouts.test.mjs
+++ b/src/locationReadouts.test.mjs
@@ -1,3 +1,4 @@
+import { readShellSource, shellMethod } from './testSupport/readShellSource.mjs';
import { StyleManager } from './ui/applicationShell.js';
import assert from 'node:assert/strict';
import fs from 'node:fs';
@@ -6,7 +7,7 @@ import test from 'node:test';
import { fileURLToPath } from 'node:url';
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
-const ui = fs.readFileSync(path.join(ROOT, 'src', 'ui', 'applicationShell.js'), 'utf8');
+const ui = readShellSource();
const director = fs.readFileSync(path.join(ROOT, 'src', 'scenes', 'director.js'), 'utf8');
/** Source of the free-text LOCATION search handler (Enter on #location-search). */
@@ -69,13 +70,13 @@ test('any other camera destination clears the search label too', () => {
// label outlives the place it named.
const start = ui.indexOf(' _stampNavigation(');
assert.ok(start > 0, '_stampNavigation is missing');
- assert.match(StyleManager.prototype._stampNavigation.toString(), /if \(clearSearchedLocation\) this\.clearSearchedLocation\(\);/);
+ assert.match(shellMethod('_stampNavigation').toString(), /if \(clearSearchedLocation\) this\.clearLocation\(\);/);
// The shared funnel is what the reset and voice seams actually reach.
for (const seam of ['resetToGlobeView() {', 'beginLocationNavigation() {', '_runExplicitNavigation(']) {
const at = ui.indexOf(seam);
assert.ok(at > 0, `missing navigation seam "${seam}"`);
- assert.match(StyleManager.prototype[seam.match(/^(\w+)/)[1]].toString(), /_stampNavigation\(/, `"${seam}" must stamp navigation`);
+ assert.match(shellMethod(seam.match(/^(\w+)/)[1]).toString(), /_stampNavigation\(/, `"${seam}" must stamp navigation`);
}
// Public seam, so a camera owner that flies on its own can invalidate it.
@@ -90,7 +91,7 @@ test('a deferred lookup that never flies leaves the readout standing', () => {
const begin = ui.indexOf(' _beginDeferredNavigation(');
assert.ok(begin > 0, '_beginDeferredNavigation is missing');
assert.match(
- StyleManager.prototype._beginDeferredNavigation.toString(),
+ shellMethod('_beginDeferredNavigation').toString(),
/stamp: \(\) =>\s*this\._stampNavigation\(\{\s*cancelPendingSelection,\s*clearSearchedLocation: false,?\s*\}\)/,
);
@@ -98,7 +99,7 @@ test('a deferred lookup that never flies leaves the readout standing', () => {
assert.ok(reassert > 0, '_reassertNavigationHandoff is missing');
assert.match(
ui.slice(reassert, reassert + 900),
- /release: \(\) => \{[\s\S]{0,320}?this\.clearSearchedLocation\(\);[\s\S]{0,200}?this\._releaseFollowCamera\(\)/,
+ /release: \(\) => \{[\s\S]{0,320}?this\.clearLocation\(\);[\s\S]{0,200}?this\._releaseFollowCamera\(\)/,
);
// …and the policy only reaches `release` after its authority checks pass.
diff --git a/src/mapSourceFocus.test.mjs b/src/mapSourceFocus.test.mjs
index 548b4202f..3aff6c8d5 100644
--- a/src/mapSourceFocus.test.mjs
+++ b/src/mapSourceFocus.test.mjs
@@ -1,3 +1,4 @@
+import { readShellSource, shellMethod } from './testSupport/readShellSource.mjs';
import { expandApplicationHtml } from '../build/application-html.js';
import { StyleManager } from './ui/applicationShell.js';
import { createHoverDisclosure, collapsePanelOnEscape } from './ui/panelDisclosure.js';
@@ -6,7 +7,7 @@ import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
// Exercise the installed event routes and central close method, without WebGL.
-const source = readFileSync(new URL('./ui/applicationShell.js', import.meta.url), 'utf8');
+const source = readShellSource();
const markup = expandApplicationHtml(readFileSync(new URL('../index.html', import.meta.url), 'utf8'));
const locationMarkup = markup.slice(markup.indexOf('
]*\bid="location-bar-toggle"[^>]*)>([\s\S]*?)<\/button>/);
@@ -107,7 +108,7 @@ function harness({ hidden = false, selected = true, noChips = false } = {}) {
window.performance = { now: () => now };
document.defaultView = window;
const methods = new Function('createHoverDisclosure', 'collapsePanelOnEscape', 'document', 'window', 'clearTimeout', 'performance', 'requestAnimationFrame',
- `return ({${StyleManager.prototype._initAutoHoverPanel.toString()},\n${StyleManager.prototype._collapsePanelOnEscape.toString()},\n${StyleManager.prototype.setPanelCollapsed.toString()},\n${StyleManager.prototype._syncPanelCollapseButton.toString()}});`)(
+ `return ({${shellMethod('_initAutoHoverPanel').toString()},\n${shellMethod('_collapsePanelOnEscape').toString()},\n${shellMethod('setPanelCollapsed').toString()},\n${shellMethod('_syncPanelCollapseButton').toString()}});`)(
createHoverDisclosure, collapsePanelOnEscape, document, window, (id) => timers.delete(id), { now: () => now }, () => {},
);
const saves = [];
diff --git a/src/mapStackChips.test.mjs b/src/mapStackChips.test.mjs
index f1f2d12e5..e9fbd05e2 100644
--- a/src/mapStackChips.test.mjs
+++ b/src/mapStackChips.test.mjs
@@ -1,3 +1,4 @@
+import { readShellSource } from './testSupport/readShellSource.mjs';
import { expandApplicationHtml } from '../build/application-html.js';
import { readStylesheet } from './testSupport/readStylesheet.mjs';
// MAP STACK chip row — the dropdown's replacement control surface.
@@ -485,10 +486,7 @@ test('the Visual Presets tray owns Map Source and the retired left panel is abse
const html = expandApplicationHtml(
readFileSync(new URL('../index.html', import.meta.url), 'utf8'),
);
- const ui = readFileSync(
- new URL('./ui/applicationShell.js', import.meta.url),
- 'utf8',
- );
+ const ui = readShellSource();
assert.doesNotMatch(
html,
diff --git a/src/panelEscape.test.mjs b/src/panelEscape.test.mjs
index c63e084f7..a9549c85d 100644
--- a/src/panelEscape.test.mjs
+++ b/src/panelEscape.test.mjs
@@ -1,3 +1,4 @@
+import { readShellSource } from './testSupport/readShellSource.mjs';
import { onKeyDown as cockpitKeyDown } from './ui/cockpitInput.js';
import { readFileSync as readRadioSource } from 'node:fs';
const radioBindings = readRadioSource(new URL('./ui/radioBindings.js', import.meta.url), 'utf8');
@@ -8,7 +9,7 @@ import assert from 'node:assert/strict';
import fs from 'node:fs';
import test from 'node:test';
-const source = fs.readFileSync(new URL('./ui/applicationShell.js', import.meta.url), 'utf8');
+const source = readShellSource();
function method(name, nextName) {
const start = source.indexOf(` ${name}(`);
diff --git a/src/panelStackLayout.test.mjs b/src/panelStackLayout.test.mjs
index e05f045fb..ec1485cba 100644
--- a/src/panelStackLayout.test.mjs
+++ b/src/panelStackLayout.test.mjs
@@ -1,3 +1,4 @@
+import { readShellSource } from './testSupport/readShellSource.mjs';
import { expandApplicationHtml } from '../build/application-html.js';
import { readStylesheet } from './testSupport/readStylesheet.mjs';
import assert from 'node:assert/strict';
@@ -137,7 +138,7 @@ test('minimum panel corridor expands upward without crossing the lower obstacle
});
test('desktop panel lanes use per-panel allocations and presentation-only auto-collapse', () => {
- const ui = readFileSync(new URL('./ui/applicationShell.js', import.meta.url), 'utf8');
+ const ui = readShellSource();
const css = readStylesheet(new URL('../style.css', import.meta.url));
assert.doesNotMatch(ui, /_enforce(?:Left|Right)PanelAccordion/);
assert.match(rails, /classList\.add\('collapsed', 'layout-auto-collapsed'\)/);
@@ -191,7 +192,7 @@ test('desktop panel lanes use per-panel allocations and presentation-only auto-c
});
test('share-panel state excludes responsive collapse and preserves recipient preferences', () => {
- const ui = readFileSync(new URL('./ui/applicationShell.js', import.meta.url), 'utf8');
+ const ui = readShellSource();
const sharelink = readFileSync(new URL('./sharelink.js', import.meta.url), 'utf8');
assert.match(
@@ -214,7 +215,7 @@ test('share-panel state excludes responsive collapse and preserves recipient pre
});
test('parameterized Display presets keep one stable scroll owner', () => {
- const ui = readFileSync(new URL('./ui/applicationShell.js', import.meta.url), 'utf8');
+ const ui = readShellSource();
const css = readStylesheet(new URL('../style.css', import.meta.url));
assert.match(css, /#pp-toggles:not\(\.collapsed\) > #param-slider-panel\.active\s*\{[\s\S]*?flex:\s*0 0 auto;[\s\S]*?max-height:\s*none;[\s\S]*?overflow-y:\s*visible;/);
diff --git a/src/radioMarkup.test.mjs b/src/radioMarkup.test.mjs
index 06892f07d..d351dd271 100644
--- a/src/radioMarkup.test.mjs
+++ b/src/radioMarkup.test.mjs
@@ -1,3 +1,4 @@
+import { readShellSource } from './testSupport/readShellSource.mjs';
import { expandApplicationHtml } from '../build/application-html.js';
import { readLayerSource } from './testSupport/readLayerSource.mjs';
import { readStylesheet } from './testSupport/readStylesheet.mjs';
@@ -11,7 +12,7 @@ import { readFileSync } from 'node:fs';
import test from 'node:test';
const html = expandApplicationHtml(readFileSync(new URL('../index.html', import.meta.url), 'utf8'));
-const ui = readFileSync(new URL('./ui/applicationShell.js', import.meta.url), 'utf8');
+const ui = readShellSource();
const radio = ['playback', 'interaction'].map(name =>
readFileSync(new URL(`./layers/radio/${name}.js`, import.meta.url), 'utf8')
).join('\n').replace(/layerState\.|parts\.\w+\./g, '');
diff --git a/src/reasonableDefaults.test.mjs b/src/reasonableDefaults.test.mjs
index a3ee563c7..ce7e192d6 100644
--- a/src/reasonableDefaults.test.mjs
+++ b/src/reasonableDefaults.test.mjs
@@ -1,3 +1,4 @@
+import { readShellSource } from './testSupport/readShellSource.mjs';
import { expandApplicationHtml } from '../build/application-html.js';
// src/reasonableDefaults.test.mjs
//
@@ -43,7 +44,7 @@ import {
import { ShareLinkManager } from './sharelink.js';
// Follow the UI wiring and its extracted preset definitions.
-const uiSource = fs.readFileSync(new URL('./ui/applicationShell.js', import.meta.url), 'utf8')
+const uiSource = readShellSource()
+ '\n' + fs.readFileSync(new URL('./ui/visualPresets.js', import.meta.url), 'utf8');
const indexHtml = expandApplicationHtml(fs.readFileSync(new URL('../index.html', import.meta.url), 'utf8'));
const shareSource = fs.readFileSync(new URL('./sharelink.js', import.meta.url), 'utf8');
diff --git a/src/scenes/director.test.mjs b/src/scenes/director.test.mjs
index 5dd5be213..b6fc19454 100644
--- a/src/scenes/director.test.mjs
+++ b/src/scenes/director.test.mjs
@@ -1,3 +1,4 @@
+import { readShellSource } from '../testSupport/readShellSource.mjs';
// Director-level pins for scene playback.
//
// scenePolicy.test.mjs pins the pure decisions; these pin the wiring, which is
@@ -483,7 +484,7 @@ test('applyVisualState gates the map-stack switch on both sides of its await', (
// another setStack() arrives, and a winning state that omits `mapStack`
// never issues one — every normalized scene shot omits it — so a stale
// switch would otherwise stand on the globe.
- const source = fs.readFileSync(new URL('../ui/applicationShell.js', import.meta.url), 'utf8');
+ const source = readShellSource();
const method = source.match(/\n {2}async applyVisualState\([\s\S]*?\n {2}\}\n/);
assert.ok(method, 'applyVisualState is missing from ui.js');
assert.match(method[0], /async applyVisualState\(state = \{\}, \{ isCurrent = null \} = \{\}\)/);
diff --git a/src/sharelink.celestial.test.mjs b/src/sharelink.celestial.test.mjs
index 5defb11a6..fc9e3ae03 100644
--- a/src/sharelink.celestial.test.mjs
+++ b/src/sharelink.celestial.test.mjs
@@ -1,3 +1,5 @@
+import { VisualSettings } from './ui/visualSettings.js';
+import { readShellSource, shellMethod } from './testSupport/readShellSource.mjs';
import { StyleManager } from './ui/applicationShell.js';
import { _claimContextVisualAuthority, setContextMode } from './ui/contextActions.js';
import { _initGlobalContextPanel } from './ui/contextBindings.js';
@@ -7,11 +9,11 @@ import fs from 'node:fs';
import { ShareLinkManager, decodeShareCreatedAtMs } from './sharelink.js';
import { createDefaultLayerState } from './data/layerState.js';
-const uiSource = fs.readFileSync(new URL('./ui/applicationShell.js', import.meta.url), 'utf8');
+const uiSource = readShellSource();
function sourceBlock(start, end) {
const name = start.trim().match(/^(?:async )?(\w+)\(/)?.[1];
- if (typeof StyleManager.prototype[name] === 'function') return StyleManager.prototype[name].toString();
+ if (typeof shellMethod(name) === 'function') return shellMethod(name).toString();
const startIndex = uiSource.indexOf(start);
const endIndex = uiSource.indexOf(end, startIndex + start.length);
assert.ok(startIndex >= 0, `missing source block start: ${start}`);
@@ -189,7 +191,7 @@ test('incoming state suppresses premature hash replacement until restoration', (
test('a shared view reserves its own camera without cancelling its saved Follow', () => {
assert.match(
uiSource,
- /_beginDeferredNavigation\(\s*'shared view',\s*\{ cancelPendingSelection: false \},\s*\)/,
+ /_beginDeferredNavigation\(\s*'shared view',\s*\{\s*cancelPendingSelection: false,?\s*\},?\s*\)/,
);
const deferred = sourceBlock(
" _beginDeferredNavigation(noun = 'location', { cancelPendingSelection = true } = {}) {",
@@ -693,11 +695,12 @@ test('visual input listeners are revoked before asynchronous UI teardown', () =>
assert.ok(firstAwait > 0);
const synchronous = disposal.slice(0, firstAwait);
assert.match(synchronous, /this\._applicationShortcuts\?\.destroy\(\)/);
- assert.match(synchronous, /this\._visualEffects\.stop\(\)/);
+ assert.match(synchronous, /this\._visualSettings\.stop\(\)/);
assert.match(synchronous, /this\._mapSourceControls\?\.destroy\(\)/);
assert.match(synchronous, /this\._clearLayersControl\?\.destroy\(\)/);
assert.match(synchronous, /this\._locationControls\?\.destroy\(\)/);
assert.match(synchronous, /this\._locationLookup\?\.destroy\(\)/);
assert.match(synchronous, /this\._displayControls\?\.destroy\(\)/);
- assert.match(synchronous, /this\._styleParameters\?\.destroy\(\)/);
+ assert.match(VisualSettings.prototype.stop.toString(), /this\._styleParameters\?\.destroy\(\)/);
+ assert.match(VisualSettings.prototype.stop.toString(), /this\._visualEffects\.stop\(\)/);
});
diff --git a/src/testSupport/readShellSource.mjs b/src/testSupport/readShellSource.mjs
new file mode 100644
index 000000000..ba41cf98c
--- /dev/null
+++ b/src/testSupport/readShellSource.mjs
@@ -0,0 +1,39 @@
+import { readFileSync } from 'node:fs';
+import { StyleManager } from '../ui/applicationShell.js';
+import { NavigationController } from '../ui/navigationController.js';
+import { ShareRestoration } from '../ui/shareRestoration.js';
+import { VisualSettings } from '../ui/visualSettings.js';
+import { PanelChrome } from '../ui/panelChrome.js';
+
+/** Read the state owners as well as the compatibility/composition facade. */
+export function readShellSource() {
+ return [
+ 'navigationController',
+ 'shareRestoration',
+ 'visualSettings',
+ 'panelChrome',
+ 'applicationShell',
+ ]
+ .map((name) =>
+ readFileSync(new URL(`../ui/${name}.js`, import.meta.url), 'utf8'),
+ )
+ .join('\n');
+}
+
+/** Select the implementation owner instead of testing a forwarding facade. */
+export function shellMethod(name) {
+ for (const owner of [
+ NavigationController,
+ ShareRestoration,
+ VisualSettings,
+ PanelChrome,
+ StyleManager,
+ ]) {
+ const method = Object.getOwnPropertyDescriptor(
+ owner.prototype,
+ name,
+ )?.value;
+ if (typeof method === 'function') return method;
+ }
+ return null;
+}
diff --git a/src/ui/applicationShell.js b/src/ui/applicationShell.js
index 293b9d8e3..a9b81e2ba 100644
--- a/src/ui/applicationShell.js
+++ b/src/ui/applicationShell.js
@@ -1,3 +1,8 @@
+import { STYLE_STATUS_LABELS } from './visualPresets.js';
+import { PanelChrome } from './panelChrome.js';
+import { VisualSettings } from './visualSettings.js';
+import { NavigationController } from './navigationController.js';
+import { ShareRestoration } from './shareRestoration.js';
import { createFrameRateMonitor } from './frameRateMonitor.js';
import { createStateChannel } from '../app/stateChannel.js';
import { setSplitFlapText } from '../splitFlap.js';
@@ -11,65 +16,26 @@ import { RadioControls } from './radio.js';
import { LocationControls } from './location.js';
import { bindClearLayersControl } from './layers.js';
import { createMapSourceControls } from './mapSource.js';
-import {
- VisualEffects,
- STYLES,
- GLOBAL_POST_DEFAULTS,
- STYLE_PRESET_DEFAULTS,
- MILITARY_DETECTION_PRESET,
-} from './effects.js';
+import { STYLES } from './effects.js';
import { bindDisplayControls } from './displayControls.js';
-import {
- bindApplicationShortcuts,
- createStyleParameters,
-} from './visualInput.js';
-import { PanelLayoutController } from './panelLayoutController.js';
-import {
- bindPanelDisclosure,
- collapsePanelOnEscape,
- createHoverDisclosure,
-} from './panelDisclosure.js';
+import { bindApplicationShortcuts } from './visualInput.js';
+
import * as Cesium from 'cesium';
-import {
- BLOOM_SCALE_VERSION,
- clampBloomIntensity,
- decodeBloomIntensity,
-} from '../bloom.js';
+import { decodeBloomIntensity } from '../bloom.js';
import {
aircraftTrackingTarget,
enterCockpitWithTracking,
} from '../cockpitTracking.js';
-import {
- isExplicitLayerStateOrigin,
- LayerStateCoordinator,
-} from '../data/layerState.js';
+import { isExplicitLayerStateOrigin } from '../data/layerState.js';
-import {
- ALLOCATION_STRATEGIES,
- canonicalizeDensity,
- defaultDensityForProfile,
- normalizeAllocationStrategy,
- normalizeProfile,
- profileForDensity,
-} from '../data/detectionPolicy.js';
+import { canonicalizeDensity } from '../data/detectionPolicy.js';
-import { canPresentDeferredStatusNotice } from '../loadingFeedback.js';
import { ShellFeedback } from './shellFeedback.js';
-import { PanelPositionControls } from './panelPositionControls.js';
+
import { cockpitEntryAllowed } from '../contextModePolicy.js';
-import {
- applyCockpitVisionStageIntensities,
- captureCockpitVisionBaseline,
- normalizeCockpitVisionMode,
-} from '../cockpitVisionPolicy.js';
-import {
- applyContactsDetection,
- shareCacheNeedsHeal,
- shareableDetectionState,
-} from '../contactsDetectionPolicy.js';
import { formatAwarenessLabel } from '../data/militaryAwarenessEngine.js';
import { runCctvLayerEnableTransition } from '../cctvFocusPolicy.js';
import {
@@ -81,45 +47,9 @@ import {
registerWorldFocusRequestListener,
routeWorldFocusRequest,
} from '../worldFocus.js';
-import {
- beginDeferredNavigation,
- reassertNavigationHandoff,
- registerNavigationAuthorityListener,
- runExplicitNavigation,
- stampInitialShareGesture,
-} from '../navigationPolicy.js';
+import { registerNavigationAuthorityListener } from '../navigationPolicy.js';
-const SHARE_PANEL_STATE_SPECS = Object.freeze([
- { id: 'control-panel', pinnable: true },
- { id: 'location-bar', pinnable: true },
- { id: 'data-panel' },
- { id: 'cctv-panel' },
- { id: 'radio-panel' },
- { id: 'scene-panel' },
- { id: 'global-context-panel' },
- { id: 'pp-toggles' },
- { id: 'param-slider-panel' },
-]);
-/** Standard map-view panels cleared out of the way on a fresh Cockpit entry. */
-const COCKPIT_ENTRY_COLLAPSE_PANEL_IDS = Object.freeze([
- 'data-panel',
- 'cctv-panel',
- 'scene-panel',
- 'pp-toggles',
- 'global-context-panel',
- 'radio-panel',
-]);
-const DETECTION_ALLOCATION_STORAGE_KEY = 'gev:detection-allocation:v1';
/** Display labels shown in the mini-status readout for each active style. */
-const STYLE_STATUS_LABELS = {
- normal: 'NORMAL',
- retro: 'CRT',
- surveillance: 'NVG',
- thermal: 'FLIR',
- anime: 'ANIME',
- noir: 'NOIR',
- snow: 'SNOW',
-};
/**
* Central UI orchestrator for the God's Eye View application.
@@ -192,46 +122,147 @@ export class StyleManager {
syncShareState: () => this._syncShareState(),
});
Object.assign(this, readShellElements());
- this._panelPosition = new PanelPositionControls({
- syncPanelCollapseButton: (panel) => this._syncPanelCollapseButton(panel),
- layoutRightPanels: () => this._layoutRightPanels(),
- syncCctvPanelViewport: () => this._syncCctvPanelViewport(),
- showToast: (message) => this._showToast(message),
+ this._panelChrome = new PanelChrome({
+ elements: {
+ _contextRadioDetailsBtn: this._contextRadioDetailsBtn,
+ _contextRadioDock: this._contextRadioDock,
+ _leftPanelStack: this._leftPanelStack,
+ _locationSearch: this._locationSearch,
+ _ppToggles: this._ppToggles,
+ _rightPanelStack: this._rightPanelStack,
+ },
+ operations: {
+ _setRadioDisclosure: (...args) => this._setRadioDisclosure(...args),
+ _syncCctvPanelViewport: (...args) =>
+ this._syncCctvPanelViewport(...args),
+ _syncContextRadioLauncherState: (...args) =>
+ this._syncContextRadioLauncherState(...args),
+ _showToast: (...args) => this._showToast(...args),
+ },
+ readHud: () => this.hud,
+ readCockpit: () => this.cockpitView,
+ readShareLinks: () => this.shareLinkManager,
+ readInitialShare: () => this._initialShareState,
+ readScrollRestoreOwner: () => this._displayPortalScrollRestoreOwner,
+ readDisplayScrollTop: () => this._standardDisplayScrollTop,
});
this._feedback = new ShellFeedback({
readLayers: () => this._dataManager?.getAll?.() || [],
});
- this._panelLayout = new PanelLayoutController({
- readHud: () => ({
- visible: this.hud.visible,
- variant: this.hud.getVariant(),
- }),
- scheduleCockpitLayout: () => this.cockpitView?.scheduleContextLayout(),
- syncPanelCollapseButton: (panel) => this._syncPanelCollapseButton(panel),
- readDisplayScrollTop: () =>
- this._displayPortalScrollRestoreOwner === 'standard'
- ? this._standardDisplayScrollTop
- : this._ppToggles?.scrollTop || 0,
- });
this.viewer = viewer;
this.mapStackController = mapStackController;
this.placeSearch = placeSearch;
- this._visualEffects = new VisualEffects({
- viewer,
- requestRender: governorRequestRender,
- holdRender: holdContinuousRender,
- releaseRender: releaseContinuousRender,
- });
- this.activeStyle = 'normal';
- document.documentElement.dataset.gevStyle = this.activeStyle;
- // True once the user manually changes detection (button/key/slider/voice).
- // Gates per-style detection defaults so they never stomp an explicit choice.
- this._detectionUserOverridden = false;
+ this._navigation = new NavigationController({
+ viewer,
+ tracking: {
+ flightsLayer,
+ militaryFlightsLayer,
+ satellitesLayer,
+ aisLiveVesselsLayer,
+ militaryAwarenessLayer,
+ rocketLaunchesLayer: services.rocketLaunchesLayer,
+ },
+ searchInput: this._locationSearch,
+ interruptCameraMotion: services.interruptCameraMotion,
+ isCockpitActive: () => !!this.cockpitView?.active,
+ clearLocation: () => this.clearSearchedLocation(),
+ cancelShareSelection: () => this._shareRestoration.cancelSelection(),
+ getDataManager: () => this._dataManager,
+ stopOrbit: () => this._stopOrbit(),
+ showToast: (text) => this._showToast(text),
+ });
+ this._shareRestoration = new ShareRestoration({
+ viewer,
+ navigation: this._navigation,
+ syncShareState: () => this._syncShareState(),
+ syncModels3d: (state) => this._syncModels3dFromLayerState(state),
+ showStatus: (message, options) =>
+ this._showGlobalStatusNotice(message, options),
+ feedback: this._feedback,
+ updateFeedback: () => this._updateGlobalLoadingFeedback(),
+ });
+
+ this._visualSettings = new VisualSettings({
+ viewer,
+ mapStackController,
+ services: {
+ getDetectionMode: services.getDetectionMode,
+ getDetectionTuning: services.getDetectionTuning,
+ getKeyholeFadeTuning: services.getKeyholeFadeTuning,
+ getScopeMaskFeather: services.getScopeMaskFeather,
+ getScopeTerminusOverride: services.getScopeTerminusOverride,
+ governorRequestRender: services.governorRequestRender,
+ holdContinuousRender: services.holdContinuousRender,
+ isCelestialRingStyleSupported: services.isCelestialRingStyleSupported,
+ isScopeMaskEnabled: services.isScopeMaskEnabled,
+ readDetectionDiagnostics: services.readDetectionDiagnostics,
+ releaseContinuousRender: services.releaseContinuousRender,
+ setDetectionModeByLabel: services.setDetectionModeByLabel,
+ setDetectionStyle: services.setDetectionStyle,
+ setDetectionTuning: services.setDetectionTuning,
+ setKeyholeFadeTuning: services.setKeyholeFadeTuning,
+ setScopeMaskEnabled: services.setScopeMaskEnabled,
+ setScopeMaskFeather: services.setScopeMaskFeather,
+ },
+ elements: {
+ _bloomBtn: this._bloomBtn,
+ _bloomSlider: this._bloomSlider,
+ _bloomSliderRow: this._bloomSliderRow,
+ _bloomSliderValue: this._bloomSliderValue,
+ _celestialBtn: this._celestialBtn,
+ _cockpitDisplayToggleBtn: this._cockpitDisplayToggleBtn,
+ _detectionAllocationRow: this._detectionAllocationRow,
+ _detectionBtn: this._detectionBtn,
+ _detectionDensitySlider: this._detectionDensitySlider,
+ _detectionDensityValue: this._detectionDensityValue,
+ _detectionFadeRow: this._detectionFadeRow,
+ _detectionFadeSlider: this._detectionFadeSlider,
+ _detectionFadeValue: this._detectionFadeValue,
+ _detectionOpacityRow: this._detectionOpacityRow,
+ _detectionOpacitySlider: this._detectionOpacitySlider,
+ _detectionOpacityValue: this._detectionOpacityValue,
+ _detectionSliderRow: this._detectionSliderRow,
+ _hudBtn: this._hudBtn,
+ _hudLayoutRow: this._hudLayoutRow,
+ _hudLayoutSelect: this._hudLayoutSelect,
+ _ppToggles: this._ppToggles,
+ _scopeBtn: this._scopeBtn,
+ _scopeFeatherSlider: this._scopeFeatherSlider,
+ _scopeFeatherValue: this._scopeFeatherValue,
+ _sharpenBtn: this._sharpenBtn,
+ _sharpenSlider: this._sharpenSlider,
+ _sharpenSliderRow: this._sharpenSliderRow,
+ _sharpenSliderValue: this._sharpenSliderValue,
+ _sliderContainer: this._sliderContainer,
+ _sliderPanel: this._sliderPanel,
+ _styleIndicator: this._styleIndicator,
+ _styleMiniValue: this._styleMiniValue,
+ },
+ operations: {
+ _layoutRightPanels: (...args) => this._layoutRightPanels(...args),
+ _scheduleAdaptivePanelLayout: (...args) =>
+ this._scheduleAdaptivePanelLayout(...args),
+ _scheduleRightPanelLayout: (...args) =>
+ this._scheduleRightPanelLayout(...args),
+ _setCockpitDisclosure: (...args) => this._setCockpitDisclosure(...args),
+ _setMapStack: (...args) => this._setMapStack(...args),
+ _syncPanelCollapseButton: (...args) =>
+ this._syncPanelCollapseButton(...args),
+ _syncShareState: (...args) => this._syncShareState(...args),
+ setPanelCollapsed: (...args) => this.setPanelCollapsed(...args),
+ },
+ readHud: () => this.hud,
+ readCockpit: () => this.cockpitView,
+ readDataManager: () => this._dataManager,
+ readShareLinks: () => this.shareLinkManager,
+ readCelestialRing: () => this.celestialRing,
+ readContextMode: () => this._contextMode,
+ readContextChanging: () => this._contextModeChanging,
+ readDisplayPortalActive: () => this._cockpitDisplayPortalActive,
+ });
// Bloom/sharpen state
- this._shareTrackingAcquiringKey = null;
- this._shareTrackingNoticeGeneration = 0;
this._globeResetPromise = null;
this._dataManager = null;
@@ -242,35 +273,12 @@ export class StyleManager {
this._removeWorldRequestFocusListener = null;
this._removeNavigationAuthorityListener = null;
this._navigationOwnerChangedRemover = null;
- this._navigationGeneration = 0;
- this._activeLocationSearchGeneration = null;
- this._initialShareState = null;
- this._initialShareNavigationGeneration = null;
- this._initialShareRestoreTimeout = null;
- this._layerStateCoordinator = null;
- this._layerStateRestorePromise = null;
this._awarenessSelectedHandler = null;
this._awarenessClearedHandler = null;
this._disposed = false;
// DOM refs
- 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 {
- /* storage can be unavailable in privacy/test contexts */
- }
- this._detectionAllocationPreference = normalizeAllocationStrategy(
- storedDetectionAllocation,
- );
-
this._mapStackChangeHandler = null;
this._cockpitDisplayPortal = null;
@@ -298,15 +306,10 @@ export class StyleManager {
summaryService: requestServices?.summary,
});
this._recording.hud = this.hud;
- this._cockpitVisionMode = 'optical';
- this._cockpitVisionRestore = null;
- this._cockpitPanelRestore = null;
// True only while the open Data Layers panel is the reason Cockpit's
// Contact panel is collapsed. A user-collapsed Contact panel must remain
// collapsed when Data Layers closes.
- this._cockpitContextCollapsedForDataPanel = false;
/** Pre-Contacts detection state, restored on deactivation (see _syncContactsDetection). */
- this._contactsDetectionRestore = null;
this.cockpitView = new CockpitViewController(viewer, {
services: {
flightsLayer,
@@ -341,40 +344,8 @@ export class StyleManager {
flightsEnabled: !!this._dataManager?.isEnabled('flights'),
militaryEnabled: !!this._dataManager?.isEnabled('military'),
}),
- onEntered: () => {
- // A new Cockpit session owns both side rails. Clear standard map-view
- // panels once on entry; NEXT/PREVIOUS never reaches this callback, so
- // panels the operator opens while already inside remain untouched.
- this._cockpitPanelRestore = new Map();
- this._cockpitContextCollapsedForDataPanel = false;
- for (const panelId of COCKPIT_ENTRY_COLLAPSE_PANEL_IDS) {
- const panel = document.getElementById(panelId);
- if (panel) {
- this._cockpitPanelRestore.set(
- panelId,
- panel.classList.contains('collapsed'),
- );
- }
- this.setPanelCollapsed(panelId, true, {
- persist: false,
- syncShare: false,
- });
- }
- this.cockpitView?.setContextCollapsed(false);
- this.cockpitView?.setSignalCollapsed(false, { user: true });
- },
- onExited: () => {
- const restore = this._cockpitPanelRestore;
- this._cockpitPanelRestore = null;
- this._cockpitContextCollapsedForDataPanel = false;
- if (!restore) return;
- for (const [panelId, wasCollapsed] of restore) {
- this.setPanelCollapsed(panelId, wasCollapsed, {
- persist: false,
- syncShare: false,
- });
- }
- },
+ onEntered: () => this._panelChrome.enterCockpit(),
+ onExited: () => this._panelChrome.exitCockpit(),
restoreTrackingFrame: (entity) => {
const [layerId, ...idParts] = String(entity?.gevTrackedId || '').split(
':',
@@ -565,7 +536,7 @@ export class StyleManager {
// Parse before panel chrome initializes so every valid share URL starts
// from deterministic markup defaults instead of recipient-local panel
// preferences. Encoded panel fields are applied after all panels exist.
- this._initialShareState = this.shareLinkManager.parseInitialHash();
+ this._shareRestoration.attachLinks(this.shareLinkManager);
this._models3dModeBtns = [
document.getElementById('models3d-mode-proximity'),
@@ -629,94 +600,7 @@ export class StyleManager {
this._updateStyleMiniStatus();
this._updateLocationMiniStatus();
- // Restore from URL hash if present
- const savedState = this._initialShareState;
- this._initialShareRestorePromise = savedState
- ? new Promise((resolve) => {
- this._resolveInitialShareRestore = resolve;
- })
- : Promise.resolve({ status: 'not-requested', share: null, layers: [] });
- if (savedState) {
- this._hasShareState = true;
- // Reserve camera authority now; the delayed mesh-friendly flight may
- // run only if no newer user, voice, or tracking navigation has won.
- this._initialShareNavigationGeneration = this._beginDeferredNavigation(
- 'shared view',
- { cancelPendingSelection: false },
- );
- this._initialShareRestoreTimeout = setTimeout(() => {
- this._initialShareRestoreTimeout = null;
- if (this._disposed) return;
- const generation = this._initialShareNavigationGeneration;
- const applyCamera =
- Number.isInteger(generation) &&
- this._reassertNavigationHandoff(generation);
- void (async () => {
- try {
- const share = await this.shareLinkManager.applyState(savedState, {
- applyCamera,
- navigationToken: generation,
- });
- const layers = await (this._layerStateRestorePromise ||
- Promise.resolve([]));
- const tracking =
- share.camera === 'applied'
- ? await this._layerStateCoordinator?.restoreShareTrackingSelection?.()
- : {
- status: 'superseded',
- cleared:
- this._layerStateCoordinator?.cancelPendingShareTracking?.(
- 'shared-camera-superseded',
- { clearSelection: true },
- ) === true,
- };
- this.shareLinkManager.completeInitialRestore();
- this._settleInitialShareRestore({
- status: 'settled',
- share,
- layers,
- tracking,
- });
- } catch (error) {
- this.shareLinkManager.completeInitialRestore();
- this._settleInitialShareRestore({
- status: 'failed',
- error: String(error?.message || error),
- share: null,
- layers: [],
- });
- }
- })();
- }, 1500);
- } else {
- this._syncShareState();
- }
- // A recipient can orbit before or during the delayed share flight. That
- // gesture keeps ordinary layer state but revokes the passive base camera
- // and selected-subject Follow so delayed work cannot seize navigation.
- this._initialShareGestureHandler = () => {
- if (
- this._disposed ||
- !this._hasShareState ||
- !this._resolveInitialShareRestore
- )
- return;
- stampInitialShareGesture((options) => this._stampNavigation(options));
- };
- this.viewer?.canvas?.addEventListener(
- 'pointerdown',
- this._initialShareGestureHandler,
- {
- passive: true,
- },
- );
- this.viewer?.canvas?.addEventListener(
- 'wheel',
- this._initialShareGestureHandler,
- {
- passive: true,
- },
- );
+ this._shareRestoration.start();
// Keep the parameter panel from overlapping toggle controls.
this._layoutRightPanels();
@@ -770,17 +654,213 @@ export class StyleManager {
}
// Compatibility reads for existing controls, scene snapshots and Cockpit.
+
+ get _navigationGeneration() {
+ return this._navigation._navigationGeneration;
+ }
+ set _navigationGeneration(value) {
+ this._navigation._navigationGeneration = value;
+ }
+ get _activeLocationSearchGeneration() {
+ return this._navigation._activeLocationSearchGeneration;
+ }
+ set _activeLocationSearchGeneration(value) {
+ this._navigation._activeLocationSearchGeneration = value;
+ }
+ get _shareTrackingAcquiringKey() {
+ return this._shareRestoration._shareTrackingAcquiringKey;
+ }
+ set _shareTrackingAcquiringKey(value) {
+ this._shareRestoration._shareTrackingAcquiringKey = value;
+ }
+ get _shareTrackingNoticeGeneration() {
+ return this._shareRestoration._shareTrackingNoticeGeneration;
+ }
+ set _shareTrackingNoticeGeneration(value) {
+ this._shareRestoration._shareTrackingNoticeGeneration = value;
+ }
+ get _initialShareState() {
+ return this._shareRestoration._initialShareState;
+ }
+ set _initialShareState(value) {
+ this._shareRestoration._initialShareState = value;
+ }
+ get _initialShareNavigationGeneration() {
+ return this._shareRestoration._initialShareNavigationGeneration;
+ }
+ set _initialShareNavigationGeneration(value) {
+ this._shareRestoration._initialShareNavigationGeneration = value;
+ }
+ get _initialShareRestoreTimeout() {
+ return this._shareRestoration._initialShareRestoreTimeout;
+ }
+ set _initialShareRestoreTimeout(value) {
+ this._shareRestoration._initialShareRestoreTimeout = value;
+ }
+ get _layerStateCoordinator() {
+ return this._shareRestoration._layerStateCoordinator;
+ }
+ set _layerStateCoordinator(value) {
+ this._shareRestoration._layerStateCoordinator = value;
+ }
+ get _layerStateRestorePromise() {
+ return this._shareRestoration._layerStateRestorePromise;
+ }
+ set _layerStateRestorePromise(value) {
+ this._shareRestoration._layerStateRestorePromise = value;
+ }
+ get _initialShareRestorePromise() {
+ return this._shareRestoration._initialShareRestorePromise;
+ }
+ set _initialShareRestorePromise(value) {
+ this._shareRestoration._initialShareRestorePromise = value;
+ }
+ get _resolveInitialShareRestore() {
+ return this._shareRestoration._resolveInitialShareRestore;
+ }
+ set _resolveInitialShareRestore(value) {
+ this._shareRestoration._resolveInitialShareRestore = value;
+ }
+ get _hasShareState() {
+ return this._shareRestoration._hasShareState;
+ }
+ set _hasShareState(value) {
+ this._shareRestoration._hasShareState = value;
+ }
+ get _initialShareSelectionSuperseded() {
+ return this._shareRestoration._initialShareSelectionSuperseded;
+ }
+ set _initialShareSelectionSuperseded(value) {
+ this._shareRestoration._initialShareSelectionSuperseded = value;
+ }
+ get _initialShareGestureHandler() {
+ return this._shareRestoration._initialShareGestureHandler;
+ }
+ set _initialShareGestureHandler(value) {
+ this._shareRestoration._initialShareGestureHandler = value;
+ }
+
+ get _visualEffects() {
+ return this._visualSettings._visualEffects;
+ }
+ set _visualEffects(value) {
+ this._visualSettings._visualEffects = value;
+ }
+ get activeStyle() {
+ return this._visualSettings.activeStyle;
+ }
+ set activeStyle(value) {
+ this._visualSettings.activeStyle = value;
+ }
+ get _detectionUserOverridden() {
+ return this._visualSettings._detectionUserOverridden;
+ }
+ set _detectionUserOverridden(value) {
+ this._visualSettings._detectionUserOverridden = value;
+ }
+ get _cockpitVisionMode() {
+ return this._visualSettings._cockpitVisionMode;
+ }
+ set _cockpitVisionMode(value) {
+ this._visualSettings._cockpitVisionMode = value;
+ }
+ get _cockpitVisionRestore() {
+ return this._visualSettings._cockpitVisionRestore;
+ }
+ set _cockpitVisionRestore(value) {
+ this._visualSettings._cockpitVisionRestore = value;
+ }
+ get _contactsDetectionRestore() {
+ return this._visualSettings._contactsDetectionRestore;
+ }
+ set _contactsDetectionRestore(value) {
+ this._visualSettings._contactsDetectionRestore = value;
+ }
+ get _detectionAllocationBtns() {
+ return this._visualSettings._detectionAllocationBtns;
+ }
+ set _detectionAllocationBtns(value) {
+ this._visualSettings._detectionAllocationBtns = value;
+ }
+ get _detectionAllocationPreference() {
+ return this._visualSettings._detectionAllocationPreference;
+ }
+ set _detectionAllocationPreference(value) {
+ this._visualSettings._detectionAllocationPreference = value;
+ }
+ get _styleParameters() {
+ return this._visualSettings._styleParameters;
+ }
+ set _styleParameters(value) {
+ this._visualSettings._styleParameters = value;
+ }
+ get _irBoostActive() {
+ return this._visualSettings._irBoostActive;
+ }
+ set _irBoostActive(value) {
+ this._visualSettings._irBoostActive = value;
+ }
+ get _irFogWasEnabled() {
+ return this._visualSettings._irFogWasEnabled;
+ }
+ set _irFogWasEnabled(value) {
+ this._visualSettings._irFogWasEnabled = value;
+ }
+
+ get _panelDisclosureControls() {
+ return this._panelChrome._panelDisclosureControls;
+ }
+ set _panelDisclosureControls(value) {
+ this._panelChrome._panelDisclosureControls = value;
+ }
+ get _hoverPanelControls() {
+ return this._panelChrome._hoverPanelControls;
+ }
+ set _hoverPanelControls(value) {
+ this._panelChrome._hoverPanelControls = value;
+ }
+ get _cancelMapSourceFocus() {
+ return this._panelChrome._cancelMapSourceFocus;
+ }
+ set _cancelMapSourceFocus(value) {
+ this._panelChrome._cancelMapSourceFocus = value;
+ }
+ get _cockpitContextCollapsedForDataPanel() {
+ return this._panelChrome._cockpitContextCollapsedForDataPanel;
+ }
+ set _cockpitContextCollapsedForDataPanel(value) {
+ this._panelChrome._cockpitContextCollapsedForDataPanel = value;
+ }
+ get _cockpitPanelRestore() {
+ return this._panelChrome._cockpitPanelRestore;
+ }
+ set _cockpitPanelRestore(value) {
+ this._panelChrome._cockpitPanelRestore = value;
+ }
+ get _panelPosition() {
+ return this._panelChrome._panelPosition;
+ }
+ set _panelPosition(value) {
+ this._panelChrome._panelPosition = value;
+ }
+ get _panelLayout() {
+ return this._panelChrome._panelLayout;
+ }
+ set _panelLayout(value) {
+ this._panelChrome._panelLayout = value;
+ }
+
get stages() {
- return this._visualEffects.stages;
+ return this._visualSettings.stages;
}
get transitions() {
- return this._visualEffects.transitions;
+ return this._visualSettings.transitions;
}
get bloomEnabled() {
- return this._visualEffects.bloomEnabled;
+ return this._visualSettings.bloomEnabled;
}
get sharpenEnabled() {
- return this._visualEffects.sharpenEnabled;
+ return this._visualSettings.sharpenEnabled;
}
get _bloomStage() {
return this._visualEffects.bloomStage;
@@ -794,91 +874,12 @@ export class StyleManager {
cancelPendingSelection = true,
clearSearchedLocation = true,
} = {}) {
- const { flightsLayer, militaryFlightsLayer, satellitesLayer } =
- this.services;
- this._navigationGeneration += 1;
- // A newer destination owns the camera, so the last free-text search is no
- // longer where we are. DEFERRED navigation opts out here and clears at the
- // reassert seam instead: a geocode that never resolves moves no camera, and
- // a lookup that fails must not blank a readout that is still true.
- if (clearSearchedLocation) this.clearSearchedLocation();
- if (cancelPendingSelection) {
- if (
- this._hasShareState &&
- this._resolveInitialShareRestore &&
- !this._layerStateCoordinator
- ) {
- this._initialShareSelectionSuperseded = true;
- }
- const passivelyClearedShareSelection =
- this._layerStateCoordinator?.cancelPendingShareTracking?.(
- 'superseded-by-explicit-navigation',
- { clearSelection: true },
- ) === true;
- try {
- flightsLayer.cancelPendingTrackingRestore?.();
- } catch {
- /* best effort */
- }
- try {
- militaryFlightsLayer.cancelPendingTrackingRestore?.();
- } catch {
- /* best effort */
- }
- try {
- satellitesLayer.cancelPendingTrackingRestore?.();
- } catch {
- /* best effort */
- }
- // A deliberate destination supersedes share-selected entities that have
- // not arrived yet. Active owners publish their clear when released.
- if (!passivelyClearedShareSelection && !flightsLayer.getTrackedInfo?.()) {
- this._dataManager?.setLayerParams(
- 'flights',
- {
- selectedFlightsTrackingId: null,
- },
- { origin: 'tool' },
- );
- }
- if (
- !passivelyClearedShareSelection &&
- !militaryFlightsLayer.getTrackedInfo?.()
- ) {
- this._dataManager?.setLayerParams(
- 'military',
- {
- selectedMilitaryTrackingId: null,
- },
- { origin: 'tool' },
- );
- }
- if (
- !passivelyClearedShareSelection &&
- !satellitesLayer.getTrackedInfo?.()
- ) {
- this._dataManager?.setLayerParams(
- 'satellites',
- {
- selectedSatTrackingId: null,
- },
- { origin: 'tool' },
- );
- }
- }
- if (this._activeLocationSearchGeneration !== null) {
- this._settleLocationSearchUi(this._activeLocationSearchGeneration);
- }
- return this._navigationGeneration;
+ return this._navigation._stampNavigation(...arguments);
}
/** Settle only the search generation that still owns the shared input UI. */
_settleLocationSearchUi(generation) {
- if (this._activeLocationSearchGeneration !== generation) return;
- this._activeLocationSearchGeneration = null;
- this._locationSearch?.classList.remove('searching', 'expanded');
- if (this._locationSearch) this._locationSearch.value = '';
- this._locationSearch?.blur();
+ return this._navigation._settleLocationSearchUi(...arguments);
}
/** Release every follow owner while preserving Contact and vessel selection. */
@@ -887,75 +888,12 @@ export class StyleManager {
preserveCameraFlight = false,
trackingOrigin = 'tool',
} = {}) {
- const {
- interruptCameraMotion,
- flightsLayer,
- militaryFlightsLayer,
- satellitesLayer,
- aisLiveVesselsLayer,
- militaryAwarenessLayer,
- rocketLaunchesLayer,
- } = this.services;
- let contactSelected = false;
- try {
- contactSelected = Boolean(
- militaryAwarenessLayer.releaseCameraOwnership?.({
- preserveVesselSelection,
- origin: trackingOrigin,
- }),
- );
- } catch {
- try {
- flightsLayer.stopTracking?.({ origin: trackingOrigin });
- } catch {
- /* best-effort release */
- }
- try {
- militaryFlightsLayer.stopTracking?.({ origin: trackingOrigin });
- } catch {
- /* best-effort release */
- }
- if (!preserveVesselSelection) {
- try {
- aisLiveVesselsLayer.clearSelection?.();
- } catch {
- /* best-effort release */
- }
- }
- }
- try {
- satellitesLayer.stopTracking?.({ origin: trackingOrigin });
- } catch {
- /* best-effort release */
- }
- try {
- rocketLaunchesLayer.releaseCameraOwnership?.();
- } catch {
- /* best-effort release */
- }
- this.viewer.trackedEntity = undefined;
- interruptCameraMotion('explicit-navigation');
- this._stopOrbit();
- if (!preserveCameraFlight) this.viewer.camera.cancelFlight();
- try {
- this.viewer.camera.lookAtTransform(Cesium.Matrix4.IDENTITY);
- } catch {
- /* teardown race */
- }
- return contactSelected;
+ return this._navigation._releaseFollowCamera(...arguments);
}
/** Run one immediate destination through the shared ownership policy. */
_runExplicitNavigation(noun, navigate, releaseOptions = undefined) {
- return runExplicitNavigation({
- disposed: this._disposed,
- cockpitActive: !!this.cockpitView?.active,
- noun,
- showToast: (text) => this._showToast(text),
- stamp: () => this._stampNavigation(),
- release: () => this._releaseFollowCamera(releaseOptions),
- navigate,
- });
+ return this._navigation._runExplicitNavigation(...arguments);
}
/** Accept a delayed lookup without releasing its current camera owner. */
@@ -963,37 +901,12 @@ export class StyleManager {
noun = 'location',
{ cancelPendingSelection = true } = {},
) {
- return beginDeferredNavigation({
- disposed: this._disposed,
- cockpitActive: !!this.cockpitView?.active,
- noun,
- showToast: (text) => this._showToast(text),
- // The searched-location readout survives the STAMP; only a flight that
- // actually starts invalidates it (see the release hook below).
- stamp: () =>
- this._stampNavigation({
- cancelPendingSelection,
- clearSearchedLocation: false,
- }),
- });
+ return this._navigation._beginDeferredNavigation(...arguments);
}
/** Final authority check and release immediately before a delayed flight. */
_reassertNavigationHandoff(generation) {
- return reassertNavigationHandoff({
- generation,
- currentGeneration: this._navigationGeneration,
- cockpitActive: !!this.cockpitView?.active,
- disposed: this._disposed,
- showToast: (text) => this._showToast(text),
- // Reached only once the handoff is granted, immediately before the
- // deferred flight starts — so a lookup that failed, was superseded, or
- // was refused by the cockpit leaves the old readout standing.
- release: () => {
- this.clearSearchedLocation();
- return this._releaseFollowCamera();
- },
- });
+ return this._navigation._reassertNavigationHandoff(...arguments);
}
/** Public lifecycle seam used by voice location navigation. */
@@ -1048,7 +961,7 @@ export class StyleManager {
* @returns {void}
*/
_initStages() {
- this._visualEffects.initStyles();
+ return this._visualSettings._initStages(...arguments);
}
/**
@@ -1061,7 +974,7 @@ export class StyleManager {
* @returns {void}
*/
_setStageIntensity(stage, value) {
- this._visualEffects.setStageIntensity(stage, value);
+ return this._visualSettings._setStageIntensity(...arguments);
}
/**
@@ -1077,7 +990,7 @@ export class StyleManager {
* @returns {void}
*/
_syncStagesEnabledFromIntensity() {
- this._visualEffects.syncStagesEnabledFromIntensity();
+ return this._visualSettings._syncStagesEnabledFromIntensity(...arguments);
}
/**
@@ -1097,101 +1010,12 @@ export class StyleManager {
* @returns {void}
*/
_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();
+ return this._visualSettings._syncContactsDetection(...arguments);
}
/** Apply a temporary cockpit-only CRT/NVG/FLIR/NOIR post-process override. */
_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._cockpitVisionRestore,
- );
- this._syncStagesEnabledFromIntensity();
- this._cockpitVisionMode = next;
- this._syncIrBoost();
- this._updateSliderPanel(this.activeStyle, { reveal: false });
- this._revealCockpitStyleParameters({ openDisplay: revealParameters });
- return;
- }
- const target = applyCockpitVisionStageIntensities(
- this.stages,
- next,
- this._cockpitVisionRestore,
- );
- 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 });
+ return this._visualSettings._setCockpitVision(...arguments);
}
/** IR hot-target boost (owner playtest 2026-08-16): under the luminance-
@@ -1201,73 +1025,17 @@ export class StyleManager {
* ('nvg'/'thermal', which can differ from the map preset in BOTH
* directions), otherwise the map preset ('surveillance'/'thermal'). */
_syncIrBoost() {
- const cockpitMode = this.cockpitView?.active
- ? this._cockpitVisionMode
- : null;
- const effective =
- cockpitMode && cockpitMode !== 'optical' ? cockpitMode : this.activeStyle;
- const irBoost =
- effective === 'surveillance' ||
- effective === 'thermal' ||
- effective === 'nvg';
- this._dataManager?.setLayerParams('flights', { irBoost });
- this._dataManager?.setLayerParams('military', { irBoost });
- // 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?.();
- }
+ return this._visualSettings._syncIrBoost(...arguments);
}
/** Keep Cockpit's inherited label and restore target aligned with the active map preset. */
_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);
+ return this._visualSettings._syncCockpitInheritedStyle(...arguments);
}
/** Reveal shared style parameters, optionally opening Cockpit Display first. */
_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' });
- }),
- );
+ return this._visualSettings._revealCockpitStyleParameters(...arguments);
}
/**
@@ -1276,9 +1044,7 @@ export class StyleManager {
* @returns {void}
*/
_initBloomSharpen() {
- this._visualEffects.initPostProcess(
- this._sharpenSlider ? parseInt(this._sharpenSlider.value, 10) / 100 : 0.6,
- );
+ return this._visualSettings._initBloomSharpen(...arguments);
}
/**
@@ -1286,7 +1052,7 @@ export class StyleManager {
* @returns {number} Clamped bloom intensity (0-200).
*/
_getBloomIntensity() {
- return this._visualEffects.bloomIntensity;
+ return this._visualSettings._getBloomIntensity(...arguments);
}
/**
@@ -1295,7 +1061,7 @@ export class StyleManager {
* @returns {void}
*/
_syncBloomStageEnabled() {
- this._visualEffects.syncBloomEnabled();
+ return this._visualSettings._syncBloomStageEnabled(...arguments);
}
/**
@@ -1306,14 +1072,7 @@ export class StyleManager {
* @returns {void}
*/
_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();
+ return this._visualSettings._setBloomIntensity(...arguments);
}
/**
@@ -1324,7 +1083,7 @@ export class StyleManager {
* @returns {void}
*/
_applyBloomIntensity(intensity) {
- this._visualEffects.applyBloomIntensity(intensity);
+ return this._visualSettings._applyBloomIntensity(...arguments);
}
/**
@@ -1333,17 +1092,7 @@ export class StyleManager {
* @returns {void}
*/
_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();
+ return this._visualSettings._setBloomEnabled(...arguments);
}
/**
@@ -1353,7 +1102,7 @@ export class StyleManager {
* @returns {void}
*/
_applySharpenIntensity(val) {
- this._visualEffects.applySharpenIntensity(val);
+ return this._visualSettings._applySharpenIntensity(...arguments);
}
/**
@@ -1362,20 +1111,7 @@ export class StyleManager {
* @returns {void}
*/
_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();
+ return this._visualSettings._setSharpenEnabled(...arguments);
}
/**
@@ -1597,97 +1333,20 @@ export class StyleManager {
* @returns {void}
*/
_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());
+ return this._visualSettings._applyDetectionDensityFromUi(...arguments);
}
/** Apply responsive keyhole fade controls from normalized UI percentages. */
_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?.();
+ return this._visualSettings._applyDetectionFadeFromUi(...arguments);
}
_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 });
- for (const button of this._detectionAllocationBtns) {
- const active = button.dataset.allocation === normalized;
- button.classList.toggle('active', active);
- button.setAttribute('aria-checked', String(active));
- }
- if (persist) {
- try {
- localStorage.setItem(DETECTION_ALLOCATION_STORAGE_KEY, normalized);
- } catch {
- /* best effort */
- }
- }
- if (syncShare) this._syncShareState();
- return true;
+ return this._visualSettings._setDetectionAllocation(...arguments);
}
_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());
+ return this._visualSettings._syncDetectionUiFromEngine(...arguments);
}
/**
@@ -1696,11 +1355,7 @@ export class StyleManager {
* @returns {void}
*/
_setDetectionMode(modeLabel) {
- const { setDetectionModeByLabel } = this.services;
- if (!modeLabel) return;
- setDetectionModeByLabel(modeLabel);
- this._syncDetectionUiFromEngine();
- this._syncShareState();
+ return this._visualSettings._setDetectionMode(...arguments);
}
/**
@@ -1710,16 +1365,7 @@ export class StyleManager {
* @returns {void}
*/
_setHudVariant(variantName) {
- if (!variantName) return;
- this.hud.setVariant(variantName);
- if (
- this._hudLayoutSelect &&
- this._hudLayoutSelect.value !== this.hud.getVariant()
- ) {
- this._hudLayoutSelect.value = this.hud.getVariant();
- }
- this._syncShareState();
- this._scheduleAdaptivePanelLayout({ settle: true });
+ return this._visualSettings._setHudVariant(...arguments);
}
/**
@@ -1741,61 +1387,7 @@ export class StyleManager {
* @returns {void}
*/
_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);
- }
-
- if (preset.hudVariant) {
- 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);
- }
+ return this._visualSettings._applyStylePresetDefaults(...arguments);
}
/**
@@ -1809,15 +1401,7 @@ export class StyleManager {
* @returns {void}
*/
_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());
+ return this._visualSettings._applyDetectionPreset(...arguments);
}
/**
@@ -1827,73 +1411,7 @@ export class StyleManager {
* @returns {void}
*/
_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,
- });
- }
+ return this._visualSettings._applyGlobalPostDefaults(...arguments);
}
/**
@@ -1915,12 +1433,7 @@ export class StyleManager {
* Contacts does not own detection, so the live values are used normally.
*/
_shareableDetectionState() {
- const { getDetectionMode } = this.services;
- return shareableDetectionState({
- owned: this._contactsDetectionRestore,
- liveMode: getDetectionMode(),
- liveDensityPct: parseInt(this._detectionDensitySlider?.value || '50', 10),
- });
+ return this._visualSettings._shareableDetectionState(...arguments);
}
/** Current shareable visual preferences; subscriptions include an initial snapshot. */
@@ -1934,41 +1447,7 @@ export class StyleManager {
}
_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',
- },
- };
+ return this._visualSettings._readShareState(...arguments);
}
/**
@@ -1987,55 +1466,7 @@ export class StyleManager {
* @returns {void}
*/
_initPanelChrome() {
- for (const control of this._panelDisclosureControls || [])
- control.destroy();
- this._panelDisclosureControls = [];
- const targets = new Map();
- document
- .querySelectorAll('.panel-collapse-btn[data-collapse-target]')
- .forEach((button) => {
- const targetId = button.dataset.collapseTarget;
- if (!targetId) return;
- if (!targets.has(targetId)) targets.set(targetId, []);
- targets.get(targetId).push(button);
- });
- for (const [targetId, buttons] of targets) {
- const panel = document.getElementById(targetId);
- if (!panel) continue;
- this._panelDisclosureControls.push(
- bindPanelDisclosure({
- panel,
- buttons,
- onChange: (collapsed, options) =>
- this.setPanelCollapsed(targetId, collapsed, options),
- onEscape: (event) => this._collapsePanelOnEscape(event, targetId),
- }),
- );
- this._restorePanelCollapsedState(targetId, {
- allowStored: !this._initialShareState,
- });
- }
- // The command dock always starts compact; either wing reveals on hover,
- // focus, or click and collapses again after the interaction moves away.
- this.setPanelCollapsed('control-panel', true, {
- syncShare: false,
- persist: false,
- });
- this.setPanelCollapsed('location-bar', true, {
- syncShare: false,
- persist: false,
- });
- this._initAutoHoverPanel('control-panel', {
- openDelayMs: 140,
- closeDelayMs: 420,
- });
- this._initAutoHoverPanel('location-bar', {
- openDelayMs: 140,
- closeDelayMs: 420,
- });
- this._initCommandDockPins();
- this._initCommandDockTrayMetrics();
- this._maybeNotifyLayoutReset();
+ return this._panelChrome._initPanelChrome(...arguments);
}
/**
@@ -2049,17 +1480,7 @@ export class StyleManager {
* @returns {boolean} Whether this panel handled the key.
*/
_collapsePanelOnEscape(event, panelId) {
- return collapsePanelOnEscape(event, {
- panel: document.getElementById(panelId),
- onChange: (collapsed, options) =>
- this.setPanelCollapsed(panelId, collapsed, options),
- beforeCollapse: () => {
- if (panelId !== 'location-bar' || !this._locationSearch) return;
- this._locationSearch.classList.remove('expanded');
- this._locationSearch.value = '';
- this._locationSearch.blur();
- },
- });
+ return this._panelChrome._collapsePanelOnEscape(...arguments);
}
/**
@@ -2068,15 +1489,7 @@ export class StyleManager {
* @returns {void}
*/
_initCommandDockPins() {
- document
- .querySelectorAll('.dock-pin-btn[data-pin-target]')
- .forEach((button) => {
- this._lifetime.listen(button, 'click', (event) => {
- event.stopPropagation();
- const panelId = button.dataset.pinTarget;
- this._setCommandDockPanelPinState(panelId);
- });
- });
+ return this._panelChrome._initCommandDockPins(...arguments);
}
_setCommandDockPanelPinState(
@@ -2084,49 +1497,7 @@ export class StyleManager {
pin,
{ restore = false, persist = true, syncShare = true } = {},
) {
- const panelEl = document.getElementById(panelId);
- const button = document.querySelector(
- `.dock-pin-btn[data-pin-target="${panelId}"]`,
- );
- if (!panelEl || !button) return undefined;
- const shouldPin =
- typeof pin === 'boolean'
- ? pin
- : !panelEl.classList.contains('dock-pinned');
- panelEl.classList.toggle('dock-pinned', shouldPin);
- button.setAttribute('aria-pressed', String(shouldPin));
- document
- .querySelectorAll('#command-dock .dock-pinned-top')
- .forEach((pinnedPanel) => {
- pinnedPanel.classList.remove('dock-pinned-top');
- });
- if (shouldPin) {
- panelEl.classList.add('dock-pinned-top');
- this.setPanelCollapsed(panelId, false, {
- explicit: !restore,
- restore,
- persist,
- syncShare: false,
- });
- } else {
- const remainingPinnedPanel = document.querySelector(
- '#command-dock .dock-pinned',
- );
- remainingPinnedPanel?.classList.add('dock-pinned-top');
- if (!restore && !panelEl.matches(':hover')) {
- this.setPanelCollapsed(panelId, true, {
- explicit: true,
- persist,
- syncShare: false,
- });
- }
- }
- this._updateCommandDockTrayStack();
- if (syncShare) {
- if (!restore) this.shareLinkManager?.claimRestoreLane?.('panel', panelId);
- this.shareLinkManager?.onPanelStateChange?.();
- }
- return shouldPin;
+ return this._panelChrome._setCommandDockPanelPinState(...arguments);
}
/**
@@ -2135,7 +1506,7 @@ export class StyleManager {
* @returns {void}
*/
_initCommandDockTrayMetrics() {
- return this._panelLayout._initCommandDockTrayMetrics();
+ return this._panelChrome._initCommandDockTrayMetrics(...arguments);
}
/**
@@ -2144,7 +1515,7 @@ export class StyleManager {
* @returns {void}
*/
_updateCommandDockTrayStack() {
- return this._panelLayout._updateCommandDockTrayStack();
+ return this._panelChrome._updateCommandDockTrayStack(...arguments);
}
/**
@@ -2153,7 +1524,7 @@ export class StyleManager {
* @returns {void}
*/
_maybeNotifyLayoutReset() {
- return this._panelPosition._maybeNotifyLayoutReset();
+ return this._panelChrome._maybeNotifyLayoutReset(...arguments);
}
/**
@@ -2171,32 +1542,7 @@ export class StyleManager {
panelId,
{ openDelayMs = 850, closeDelayMs = 1000 } = {},
) {
- const panel = document.getElementById(panelId);
- if (!panel) return;
- this._hoverPanelControls ??= new Map();
- this._hoverPanelControls.get(panelId)?.destroy();
- const controller = createHoverDisclosure({
- panel,
- documentRef: document,
- disclosure: panel.querySelector(`[data-dock-toggle-target="${panelId}"]`),
- openDelayMs,
- closeDelayMs,
- isActive: () => !this._disposed,
- onChange: (collapsed, options) =>
- this.setPanelCollapsed(panelId, collapsed, options),
- onEscape: (event) => this._collapsePanelOnEscape(event, panelId),
- focusTarget:
- panelId === 'control-panel'
- ? () =>
- panel.querySelector('.map-stack-chip.active') ||
- panel.querySelector('.map-stack-chip')
- : null,
- });
- this._hoverPanelControls.set(panelId, controller);
- if (panelId === 'control-panel') {
- this._cancelMapSourceFocus?.();
- this._cancelMapSourceFocus = controller.cancelPendingFocus;
- }
+ return this._panelChrome._initAutoHoverPanel(...arguments);
}
/**
@@ -2322,126 +1668,13 @@ export class StyleManager {
this._awarenessClearedHandler,
);
}
- this._layerStateCoordinator?.destroy();
- this._layerStateCoordinator = null;
- this._layerStateRestorePromise = null;
- if (this._dataManager) {
- this._layerStateCoordinator = new LayerStateCoordinator(
- this._dataManager,
- this.shareLinkManager,
- {
- onDurableStateChange: (state) =>
- this._syncModels3dFromLayerState(state),
- onTrackingRestoreStatus: (result) =>
- this._handleShareTrackingRestoreStatus(result),
- },
- );
- this._layerStateRestorePromise = this._layerStateCoordinator.start({
- shareLayerState: this._initialShareState?.layerState || null,
- shareCreatedAtMs: this._initialShareState?.sharedAtMs ?? null,
- // Any valid camera/style share isolates recipient-local preferences,
- // including legacy and malformed-v2 layer payloads.
- allowLocalState: !this._initialShareState,
- });
- if (this._initialShareSelectionSuperseded) {
- this._layerStateCoordinator.cancelPendingShareTracking(
- 'superseded-before-layer-coordinator-start',
- { clearSelection: true },
- );
- }
- void this._layerStateRestorePromise.then(() => {
- this._syncModels3dFromLayerState(
- this._layerStateCoordinator?.getDurableState(),
- );
- });
- }
+ this._shareRestoration.connect(this._dataManager);
}
_handleShareTrackingRestoreStatus(result) {
- if (!result || this._disposed) return;
- const trackingKey = `${result.layerId || ''}:${result.targetId ?? ''}`;
- if (result.classification === 'pending') {
- this._shareTrackingNoticeGeneration += 1;
- this._shareTrackingAcquiringKey = trackingKey;
- this._showGlobalStatusNotice('ACQUIRING', {
- state: 'acquiring',
- detail: `SHARED ${String(result.label || 'SUBJECT').toUpperCase()}`,
- persistent: true,
- });
- return;
- }
- const ownsAcquiringNotice = this._shareTrackingAcquiringKey === trackingKey;
- if (ownsAcquiringNotice) {
- this._shareTrackingNoticeGeneration += 1;
- this._shareTrackingAcquiringKey = null;
- if (this._feedback._globalStatusNotice?.state === 'acquiring') {
- this._feedback._globalStatusNotice = null;
- this._updateGlobalLoadingFeedback();
- }
- }
- if (
- result.classification === 'followed' ||
- result.classification === 'cancelled'
- )
- return;
- // A stale terminal result must never replace a newer target's acquisition.
- if (this._shareTrackingAcquiringKey) return;
- const noticeGeneration = ownsAcquiringNotice
- ? this._shareTrackingNoticeGeneration
- : ++this._shareTrackingNoticeGeneration;
- const subject = result.label || 'entity';
- const message =
- result.classification === 'expired'
- ? `Shared ${subject} follow expired`
- : result.classification === 'source-unavailable'
- ? `Shared ${subject} could not be restored — feed unavailable`
- : `Shared ${subject} is unavailable`;
- const showAfterStartupCover = () => {
- this._lifetime.frame(() => {
- if (
- !canPresentDeferredStatusNotice(
- noticeGeneration,
- this._shareTrackingNoticeGeneration,
- this._disposed,
- )
- )
- return;
- const startupCover = document.getElementById('loading-screen');
- if (
- !startupCover ||
- getComputedStyle(startupCover).visibility === 'hidden'
- ) {
- this._showGlobalStatusNotice(message);
- return;
- }
- let fallbackTimer = null;
- let removeStartupListener = () => {};
- const showOnce = () => {
- removeStartupListener();
- if (fallbackTimer) this._lifetime.cancelTimeout(fallbackTimer);
- if (
- canPresentDeferredStatusNotice(
- noticeGeneration,
- this._shareTrackingNoticeGeneration,
- this._disposed,
- )
- )
- this._showGlobalStatusNotice(message);
- };
- removeStartupListener = this._lifetime.listen(
- startupCover,
- 'transitionend',
- showOnce,
- { once: true },
- );
- fallbackTimer = this._lifetime.timeout(showOnce, 1000);
- });
- };
- if (this._resolveInitialShareRestore) {
- void this.initialRestorePromise.then(showAfterStartupCover);
- return;
- }
- showAfterStartupCover();
+ return this._shareRestoration._handleShareTrackingRestoreStatus(
+ ...arguments,
+ );
}
get _contextMode() {
@@ -2713,7 +1946,7 @@ export class StyleManager {
* @returns {void}
*/
_restorePanelCollapsedState(panelId, options1) {
- return this._panelPosition._restorePanelCollapsedState(panelId, options1);
+ return this._panelChrome._restorePanelCollapsedState(...arguments);
}
/**
@@ -2723,7 +1956,7 @@ export class StyleManager {
* @returns {void}
*/
_savePanelCollapsedState(panelId, collapsed) {
- return this._panelPosition._savePanelCollapsedState(panelId, collapsed);
+ return this._panelChrome._savePanelCollapsedState(...arguments);
}
/**
@@ -2738,7 +1971,7 @@ export class StyleManager {
}
_scheduleRightPanelLayout(options0) {
- return this._panelLayout._scheduleRightPanelLayout(options0);
+ return this._panelChrome._scheduleRightPanelLayout(...arguments);
}
/**
@@ -2768,7 +2001,7 @@ export class StyleManager {
* @returns {void}
*/
_scheduleLeftPanelLayout(options0) {
- return this._panelLayout._scheduleLeftPanelLayout(options0);
+ return this._panelChrome._scheduleLeftPanelLayout(...arguments);
}
/**
@@ -2788,58 +2021,7 @@ export class StyleManager {
* @returns {void}
*/
_syncPanelCollapseButton(panelEl) {
- const isRightRail = [
- 'pp-toggles',
- 'cctv-panel',
- 'global-context-panel',
- ].includes(panelEl?.id);
- const collapsed = panelEl.classList.contains('collapsed');
- panelEl
- .querySelectorAll('.panel-collapse-btn[data-collapse-target]')
- .forEach((btn) => {
- const owner = btn.closest('[data-panel-id], #param-slider-panel');
- if (owner !== panelEl) return;
- if (isRightRail) {
- btn.textContent = collapsed ? '◀' : '▶';
- } else {
- btn.textContent = collapsed ? '+' : '−';
- }
- btn.setAttribute('aria-expanded', String(!collapsed));
- const panelName =
- panelEl
- .querySelector('.panel-title, .pp-header-label')
- ?.textContent?.trim() || 'panel';
- const action = collapsed ? 'Expand' : 'Collapse';
- btn.title = `${action} ${panelName}`;
- btn.setAttribute('aria-label', `${action} ${panelName}`);
- if (panelEl.id === 'radio-panel') {
- const action = collapsed ? 'Expand' : 'Collapse';
- btn.title = `${action} Radio`;
- btn.setAttribute('aria-label', `${action} Radio section`);
- }
- });
- const dockToggle = panelEl.querySelector(
- `[data-dock-toggle-target="${panelEl.id}"]`,
- );
- if (dockToggle) {
- const panelName =
- panelEl
- .querySelector('.panel-title, .location-toolbar-label')
- ?.textContent?.trim() || 'panel';
- const action = collapsed ? 'Expand' : 'Collapse';
- dockToggle.setAttribute('aria-expanded', String(!collapsed));
- dockToggle.setAttribute('aria-label', `${action} ${panelName}`);
- dockToggle.title = `${action} ${panelName}`;
- }
- if (panelEl.id === 'radio-panel' && this._contextRadioDetailsBtn) {
- this._contextRadioDetailsBtn.setAttribute(
- 'aria-expanded',
- String(!collapsed),
- );
- }
- if (panelEl.id === 'radio-panel' || panelEl.id === 'global-context-panel') {
- this._syncContextRadioLauncherState();
- }
+ return this._panelChrome._syncPanelCollapseButton(...arguments);
}
/**
@@ -2913,45 +2095,11 @@ export class StyleManager {
}
_buildSharePanelState() {
- const specs = [];
- for (const spec of SHARE_PANEL_STATE_SPECS) {
- const panelEl = document.getElementById(spec.id);
- if (!panelEl) continue;
- // Responsive auto-collapse is presentation only; the recipient should
- // restore the user's explicit expanded preference at its own viewport.
- const collapsed = panelEl.classList.contains('layout-auto-collapsed')
- ? false
- : panelEl.classList.contains('collapsed');
- const entry = { id: spec.id, collapsed };
- if (spec.pinnable)
- entry.pinned = panelEl.classList.contains('dock-pinned');
- specs.push(entry);
- }
- return specs.length ? { specs } : null;
+ return this._panelChrome._buildSharePanelState(...arguments);
}
_restorePanelState(panelState) {
- if (!panelState || !Array.isArray(panelState.specs)) return;
- const specsById = new Map(panelState.specs.map((spec) => [spec.id, spec]));
- for (const spec of SHARE_PANEL_STATE_SPECS) {
- const state = specsById.get(spec.id);
- if (!state || typeof state.collapsed !== 'boolean') continue;
- if (spec.pinnable && typeof state.pinned === 'boolean') {
- this._setCommandDockPanelPinState(spec.id, state.pinned, {
- restore: true,
- persist: false,
- syncShare: false,
- });
- }
- const nextCollapsed =
- state.pinned && spec.pinnable ? false : state.collapsed;
- this.setPanelCollapsed(spec.id, nextCollapsed, {
- restore: true,
- persist: false,
- syncShare: false,
- });
- }
- this.shareLinkManager?.onPanelStateChange?.();
+ return this._panelChrome._restorePanelState(...arguments);
}
/**
@@ -2973,138 +2121,7 @@ export class StyleManager {
syncShare = true,
} = {},
) {
- if (panelId === 'control-panel' && collapsed)
- this._cancelMapSourceFocus?.();
- const panelEl = document.getElementById(panelId);
- if (!panelEl) return;
- if (explicit && !restore)
- this.shareLinkManager?.claimRestoreLane?.('panel', panelId);
- const nextCollapsed = Boolean(collapsed);
- const wasAutoCollapsed = panelEl.classList.contains(
- 'layout-auto-collapsed',
- );
- const leftOwnerPanel = this._leftPanelStack?.contains(panelEl)
- ? panelEl
- : null;
- const rightOwnerPanel =
- panelId === 'radio-panel'
- ? document.getElementById('global-context-panel')
- : this._rightPanelStack?.contains(panelEl)
- ? panelEl
- : null;
- const priorLeftOwner = this._panelLayout._leftStackPreferredPanelId;
- const priorRightOwner = this._panelLayout._rightStackPreferredPanelId;
- if (explicit && !restore && !nextCollapsed && leftOwnerPanel) {
- this._panelLayout._leftStackPreferredPanelId = leftOwnerPanel.id;
- } else if (
- explicit &&
- !restore &&
- nextCollapsed &&
- leftOwnerPanel?.id === this._panelLayout._leftStackPreferredPanelId
- ) {
- this._panelLayout._leftStackPreferredPanelId = null;
- }
- if (explicit && !restore && !nextCollapsed && rightOwnerPanel) {
- this._panelLayout._rightStackPreferredPanelId = rightOwnerPanel.id;
- } else if (
- explicit &&
- !restore &&
- nextCollapsed &&
- rightOwnerPanel?.id === this._panelLayout._rightStackPreferredPanelId
- ) {
- this._panelLayout._rightStackPreferredPanelId = null;
- }
- if (
- panelEl.classList.contains('collapsed') === nextCollapsed &&
- !wasAutoCollapsed
- ) {
- this._syncPanelCollapseButton(panelEl);
- if (priorLeftOwner !== this._panelLayout._leftStackPreferredPanelId) {
- this._scheduleLeftPanelLayout({ reconsiderAutoCollapse: true });
- }
- if (priorRightOwner !== this._panelLayout._rightStackPreferredPanelId) {
- this._scheduleRightPanelLayout({ reconsiderAutoCollapse: true });
- }
- return;
- }
- panelEl.classList.remove('layout-auto-collapsed');
- if (
- !nextCollapsed &&
- this.cockpitView?.active &&
- panelId === 'data-panel'
- ) {
- this._cockpitContextCollapsedForDataPanel =
- !this.cockpitView.contextCollapsed;
- if (this._cockpitContextCollapsedForDataPanel) {
- this.cockpitView.setContextCollapsed(true);
- }
- }
- if (
- !nextCollapsed &&
- panelId === 'global-context-panel' &&
- this._contextRadioDock?.classList.contains('disclosure-open')
- ) {
- this._setRadioDisclosure?.(false);
- }
- if (
- !nextCollapsed &&
- panelId === 'radio-panel' &&
- document
- .getElementById('global-context-panel')
- ?.classList.contains('collapsed')
- ) {
- this.setPanelCollapsed('global-context-panel', false, {
- restore,
- persist,
- syncShare,
- });
- }
- if (!nextCollapsed && !restore && panelId === 'location-bar') {
- const otherPanel = document.getElementById('control-panel');
- if (otherPanel && !otherPanel.classList.contains('dock-pinned')) {
- this.setPanelCollapsed('control-panel', true, {
- restore,
- persist,
- syncShare,
- });
- }
- } else if (!nextCollapsed && !restore && panelId === 'control-panel') {
- const otherPanel = document.getElementById('location-bar');
- if (otherPanel && !otherPanel.classList.contains('dock-pinned')) {
- this.setPanelCollapsed('location-bar', true, {
- restore,
- persist,
- syncShare,
- });
- }
- }
- panelEl.classList.toggle('collapsed', nextCollapsed);
- if (
- nextCollapsed &&
- this.cockpitView?.active &&
- panelId === 'data-panel' &&
- this._cockpitContextCollapsedForDataPanel
- ) {
- this._cockpitContextCollapsedForDataPanel = false;
- this.cockpitView.setContextCollapsed(false);
- }
- this._syncPanelCollapseButton(panelEl);
- if (persist !== false)
- this._savePanelCollapsedState(panelId, nextCollapsed);
- if (panelId === 'pp-toggles') {
- this._layoutRightPanels();
- }
- if (this._rightPanelStack?.contains(panelEl)) {
- this._scheduleRightPanelLayout({ reconsiderAutoCollapse: true });
- }
- if (panelId === 'cctv-panel') {
- this._syncCctvPanelViewport();
- }
- this._lifetime.frame(() => this._updateCommandDockTrayStack());
- this._scheduleLeftPanelLayout({
- reconsiderAutoCollapse: this._leftPanelStack?.contains(panelEl) === true,
- });
- if (syncShare) this.shareLinkManager?.onPanelStateChange?.();
+ return this._panelChrome.setPanelCollapsed(...arguments);
}
/**
@@ -3175,26 +2192,12 @@ export class StyleManager {
* @returns {{detectionMode: string, densityPct: number|null, allocationStrategy:string, fadePct:number, outsideOpacityPct:number}}
*/
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,
- ),
- };
+ return this._visualSettings.getDetectionState(...arguments);
}
/** Read-only overlay diagnostics used by browser QA and regression harnesses. */
getDetectionDiagnostics() {
- const { readDetectionDiagnostics } = this.services;
- return readDetectionDiagnostics();
+ return this._visualSettings.getDetectionDiagnostics(...arguments);
}
/**
@@ -3218,133 +2221,7 @@ export class StyleManager {
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() };
+ return this._visualSettings.setDetection(...arguments);
}
/**
@@ -3390,43 +2267,7 @@ export class StyleManager {
* @returns {{ok: boolean, bloom: {enabled: boolean, intensityPct: number|null}}}
*/
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(),
- };
+ return this._visualSettings.setBloom(...arguments);
}
/**
@@ -3437,51 +2278,12 @@ export class StyleManager {
* @returns {{ok: boolean, sharpen: {enabled: boolean, intensityPct: number|null}}}
*/
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(),
- };
+ return this._visualSettings.setSharpen(...arguments);
}
/** Whether the full-globe celestial overlay is enabled by user preference. */
get celestialRingEnabled() {
- return !!this.celestialRing?.enabled;
+ return this._visualSettings.celestialRingEnabled;
}
/**
@@ -3495,58 +2297,7 @@ export class StyleManager {
* @returns {{ok:boolean, celestialRing:{enabled:boolean,visible:boolean}, cameraFocused:boolean, error?:string}}
*/
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
- ? 'Celestial ring — reveal the full globe'
- : '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,
- };
+ return this._visualSettings.setCelestialRingEnabled(...arguments);
}
/**
@@ -3980,54 +2731,7 @@ export class StyleManager {
* @returns {object} Serializable visual state object.
*/
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,
- };
+ return this._visualSettings.getVisualState(...arguments);
}
/**
@@ -4047,146 +2751,7 @@ export class StyleManager {
* @returns {Promise} Whether the state was committed.
*/
async applyVisualState(state = {}, { isCurrent = null } = {}) {
- const { setScopeMaskEnabled, setScopeMaskFeather } = this.services;
- const superseded = () => typeof isCurrent === 'function' && !isCurrent();
- if (superseded()) return false;
-
- if (state.style && state.style !== this.activeStyle) {
- this.setStyle(state.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);
- this._scopeBtn?.classList.toggle('active', scopeState.enabled);
- this._scopeBtn?.setAttribute('aria-pressed', String(scopeState.enabled));
- }
- if (typeof scopeState.featherPct === 'number' && this._scopeFeatherSlider) {
- const pct = Math.max(0, Math.min(100, Math.round(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;
+ return this._visualSettings.applyVisualState(...arguments);
}
/**
@@ -4202,82 +2767,7 @@ export class StyleManager {
* @param {object} preset
*/
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();
+ return this._visualSettings.applyCinematicPreset(...arguments);
}
/**
@@ -4306,54 +2796,12 @@ export class StyleManager {
* @returns {void}
*/
_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();
+ return this._visualSettings._updateSliderPanel(...arguments);
}
/** Reveal the map-only parameter surface in the standard Display scroll owner. */
_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;
- }),
- );
+ return this._visualSettings._revealStyleParameters(...arguments);
}
// ── Style switching ───────────────────────────
@@ -4377,76 +2825,7 @@ export class StyleManager {
restore = false,
} = {},
) {
- const { setDetectionStyle } = this.services;
- if (!restore) this.shareLinkManager?.claimRestoreLane?.('visual');
- 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
- const displayNames = { surveillance: 'NVG', thermal: 'FLIR', retro: 'CRT' };
- this._styleIndicator.textContent =
- displayNames[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();
+ return this._visualSettings.setStyle(...arguments);
}
// ── Shader transitions ────────────────────────
@@ -4460,7 +2839,7 @@ export class StyleManager {
* @returns {void}
*/
_startTransition(styleName, fromValue, toValue) {
- this._visualEffects.startTransition(styleName, fromValue, toValue);
+ return this._visualSettings._startTransition(...arguments);
}
/**
@@ -4807,10 +3186,7 @@ export class StyleManager {
* @returns {void}
*/
_updateStyleMiniStatus(styleName = this.activeStyle) {
- if (!this._styleMiniValue) return;
- this._styleMiniValue.textContent =
- STYLE_STATUS_LABELS[styleName] ||
- String(styleName || 'normal').toUpperCase();
+ return this._visualSettings._updateStyleMiniStatus(...arguments);
}
// ── Orbit Mode ──────────────────────────────
@@ -5157,11 +3533,7 @@ export class StyleManager {
* @returns {void}
*/
_updateHudButtonState() {
- this._hudBtn.classList.toggle('active', this.hud.visible);
- if (this._hudLayoutRow) {
- this._hudLayoutRow.classList.toggle('visible', this.hud.visible);
- }
- this._scheduleAdaptivePanelLayout({ settle: true });
+ return this._visualSettings._updateHudButtonState(...arguments);
}
/**
@@ -5172,48 +3544,7 @@ export class StyleManager {
* @returns {void}
*/
_updateDetectionButton(modeLabel) {
- const btn = this._detectionBtn;
- const enabled = modeLabel !== 'OFF';
- btn.setAttribute('aria-pressed', String(enabled));
- btn.setAttribute(
- 'aria-label',
- enabled
- ? `Detection overlay: ${String(modeLabel).toLowerCase()}`
- : 'Detection overlay: off',
- );
- btn.classList.remove('active', 'god', 'panoptic');
- if (modeLabel === 'SPARSE') {
- btn.querySelector('.pp-label').textContent = 'SPARSE';
- btn.classList.add('active');
- } else if (modeLabel === 'BALANCED') {
- btn.querySelector('.pp-label').textContent = 'BALANCED';
- btn.classList.add('active');
- } else if (modeLabel === 'DENSE') {
- btn.querySelector('.pp-label').textContent = 'DENSE';
- btn.classList.add('active', 'panoptic');
- } else {
- btn.querySelector('.pp-label').textContent = '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();
+ return this._visualSettings._updateDetectionButton(...arguments);
}
/**
@@ -5223,7 +3554,7 @@ export class StyleManager {
* @returns {void}
*/
_layoutRightPanels() {
- this._scheduleRightPanelLayout();
+ return this._panelChrome._layoutRightPanels(...arguments);
}
/**
@@ -5267,13 +3598,7 @@ export class StyleManager {
}
_settleInitialShareRestore(result) {
- if (!this._resolveInitialShareRestore) return;
- const resolve = this._resolveInitialShareRestore;
- this._resolveInitialShareRestore = null;
- resolve(result);
- window.dispatchEvent(
- new CustomEvent('gev:initial-share-restore-settled', { detail: result }),
- );
+ return this._shareRestoration._settleInitialShareRestore(...arguments);
}
/**
@@ -5286,20 +3611,20 @@ export class StyleManager {
const { destroyTrackedReadout, destroyWorldOverlay, destroyDetection } =
this.services;
if (this._disposed) return;
- this._shareTrackingNoticeGeneration += 1;
- this._shareTrackingAcquiringKey = null;
+ this._shareRestoration.destroy();
this._feedback._globalStatusNotice = null;
if (this._globalLoadingStatus) this._globalLoadingStatus.hidden = true;
this._disposed = true;
+ this._navigation.stop();
this._shareState.destroy();
this._locationState.destroy();
this._locationLookupUnsubscribe?.();
this._locationLookupUnsubscribe = null;
this._lifetime.destroy();
this._recording.destroy();
- this._panelPosition.destroy();
+ this._panelChrome.destroy();
this._feedback.destroy();
- this._panelLayout.destroy();
+
this._applicationShortcuts?.destroy();
this._displayControls?.destroy();
this._frameRateMonitor?.destroy();
@@ -5310,37 +3635,8 @@ export class StyleManager {
this._radioControls?.destroy();
this.cockpitView?.stop();
this._cockpitDisplayPortal?.stop();
- this._visualEffects.stop();
- this._styleParameters?.destroy();
- for (const control of this._panelDisclosureControls || [])
- control.destroy();
- this._panelDisclosureControls = [];
- this._hoverPanelControls?.forEach((control) => control.destroy());
- this._hoverPanelControls?.clear();
+ this._visualSettings.stop();
this._locationLookup?.destroy();
- this._cancelMapSourceFocus?.();
- // Revoke persistence/hash authority before teardown can emit manager changes.
- this._layerStateCoordinator?.destroy();
- this._layerStateCoordinator = null;
- this._layerStateRestorePromise = null;
- clearTimeout(this._initialShareRestoreTimeout);
- this._initialShareRestoreTimeout = null;
- this._settleInitialShareRestore({
- status: 'destroyed',
- share: null,
- layers: [],
- });
- if (this._initialShareGestureHandler) {
- this.viewer?.canvas?.removeEventListener(
- 'pointerdown',
- this._initialShareGestureHandler,
- );
- this.viewer?.canvas?.removeEventListener(
- 'wheel',
- this._initialShareGestureHandler,
- );
- this._initialShareGestureHandler = null;
- }
this.shareLinkManager?.destroy();
if (this._awarenessSelectedHandler) {
window.removeEventListener(
@@ -5362,7 +3658,7 @@ export class StyleManager {
// re-enable a mode's entry layer and republish `_contextMode` while the
// rest of teardown is tearing those very layers down.
this._contextControls.stop();
- this._stampNavigation();
+ this._navigation.destroy();
// Close camera-entry seams synchronously. Context restoration may await
// layer work, so leaving these listeners attached until afterward lets a
// focus event release tracking or start a flight during teardown.
@@ -5380,15 +3676,7 @@ export class StyleManager {
// IR boost teardown BEFORE detaching the data manager: restore fog and
// un-boost both aircraft layers so a surviving viewer or replacement
// manager doesn't inherit sensor state (review P2, 2026-08-16).
- if (this._irBoostActive) {
- 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;
- }
+ this._visualSettings.releaseIrBoost();
this.cockpitView?.dispose();
this._cockpitDisplayPortal?.destroy();
this._cockpitDisplayPortal = null;
@@ -5404,6 +3692,6 @@ export class StyleManager {
destroyDetection();
destroyWorldOverlay();
this.celestialRing?.destroy();
- this._visualEffects.destroy();
+ this._visualSettings.destroy();
}
}
diff --git a/src/ui/navigationController.js b/src/ui/navigationController.js
new file mode 100644
index 000000000..4fd3b02db
--- /dev/null
+++ b/src/ui/navigationController.js
@@ -0,0 +1,233 @@
+import * as Cesium from 'cesium';
+import {
+ beginDeferredNavigation,
+ reassertNavigationHandoff,
+ runExplicitNavigation,
+} from '../navigationPolicy.js';
+
+/** Own camera authority generations, pending search UI and tracking handoff. */
+export class NavigationController {
+ constructor({
+ viewer,
+ tracking,
+ searchInput,
+ interruptCameraMotion,
+ isCockpitActive,
+ clearLocation,
+ cancelShareSelection,
+ getDataManager,
+ stopOrbit,
+ showToast,
+ }) {
+ Object.assign(this, {
+ viewer,
+ tracking,
+ searchInput,
+ interruptCameraMotion,
+ isCockpitActive,
+ clearLocation,
+ cancelShareSelection,
+ getDataManager,
+ stopOrbit,
+ showToast,
+ });
+ this._navigationGeneration = 0;
+ this._activeLocationSearchGeneration = null;
+ this._disposed = false;
+ }
+ _stampNavigation({
+ cancelPendingSelection = true,
+ clearSearchedLocation = true,
+ } = {}) {
+ const { flightsLayer, militaryFlightsLayer, satellitesLayer } =
+ this.tracking;
+ this._navigationGeneration += 1;
+ // A newer destination owns the camera, so the last free-text search is no
+ // longer where we are. DEFERRED navigation opts out here and clears at the
+ // reassert seam instead: a geocode that never resolves moves no camera, and
+ // a lookup that fails must not blank a readout that is still true.
+ if (clearSearchedLocation) this.clearLocation();
+ if (cancelPendingSelection) {
+ const passivelyClearedShareSelection = this.cancelShareSelection();
+ try {
+ flightsLayer.cancelPendingTrackingRestore?.();
+ } catch {
+ /* best effort */
+ }
+ try {
+ militaryFlightsLayer.cancelPendingTrackingRestore?.();
+ } catch {
+ /* best effort */
+ }
+ try {
+ satellitesLayer.cancelPendingTrackingRestore?.();
+ } catch {
+ /* best effort */
+ }
+ // A deliberate destination supersedes share-selected entities that have
+ // not arrived yet. Active owners publish their clear when released.
+ if (!passivelyClearedShareSelection && !flightsLayer.getTrackedInfo?.()) {
+ this.getDataManager()?.setLayerParams(
+ 'flights',
+ {
+ selectedFlightsTrackingId: null,
+ },
+ { origin: 'tool' },
+ );
+ }
+ if (
+ !passivelyClearedShareSelection &&
+ !militaryFlightsLayer.getTrackedInfo?.()
+ ) {
+ this.getDataManager()?.setLayerParams(
+ 'military',
+ {
+ selectedMilitaryTrackingId: null,
+ },
+ { origin: 'tool' },
+ );
+ }
+ if (
+ !passivelyClearedShareSelection &&
+ !satellitesLayer.getTrackedInfo?.()
+ ) {
+ this.getDataManager()?.setLayerParams(
+ 'satellites',
+ {
+ selectedSatTrackingId: null,
+ },
+ { origin: 'tool' },
+ );
+ }
+ }
+ if (this._activeLocationSearchGeneration !== null) {
+ this._settleLocationSearchUi(this._activeLocationSearchGeneration);
+ }
+ return this._navigationGeneration;
+ }
+
+ _settleLocationSearchUi(generation) {
+ if (this._activeLocationSearchGeneration !== generation) return;
+ this._activeLocationSearchGeneration = null;
+ this.searchInput?.classList.remove('searching', 'expanded');
+ if (this.searchInput) this.searchInput.value = '';
+ this.searchInput?.blur();
+ }
+
+ _releaseFollowCamera({
+ preserveVesselSelection = true,
+ preserveCameraFlight = false,
+ trackingOrigin = 'tool',
+ } = {}) {
+ const {
+ flightsLayer,
+ militaryFlightsLayer,
+ satellitesLayer,
+ aisLiveVesselsLayer,
+ militaryAwarenessLayer,
+ rocketLaunchesLayer,
+ } = this.tracking;
+ let contactSelected = false;
+ try {
+ contactSelected = Boolean(
+ militaryAwarenessLayer.releaseCameraOwnership?.({
+ preserveVesselSelection,
+ origin: trackingOrigin,
+ }),
+ );
+ } catch {
+ try {
+ flightsLayer.stopTracking?.({ origin: trackingOrigin });
+ } catch {
+ /* best-effort release */
+ }
+ try {
+ militaryFlightsLayer.stopTracking?.({ origin: trackingOrigin });
+ } catch {
+ /* best-effort release */
+ }
+ if (!preserveVesselSelection) {
+ try {
+ aisLiveVesselsLayer.clearSelection?.();
+ } catch {
+ /* best-effort release */
+ }
+ }
+ }
+ try {
+ satellitesLayer.stopTracking?.({ origin: trackingOrigin });
+ } catch {
+ /* best-effort release */
+ }
+ try {
+ rocketLaunchesLayer.releaseCameraOwnership?.();
+ } catch {
+ /* best-effort release */
+ }
+ this.viewer.trackedEntity = undefined;
+ this.interruptCameraMotion('explicit-navigation');
+ this.stopOrbit();
+ if (!preserveCameraFlight) this.viewer.camera.cancelFlight();
+ try {
+ this.viewer.camera.lookAtTransform(Cesium.Matrix4.IDENTITY);
+ } catch {
+ /* teardown race */
+ }
+ return contactSelected;
+ }
+
+ _runExplicitNavigation(noun, navigate, releaseOptions = undefined) {
+ return runExplicitNavigation({
+ disposed: this._disposed,
+ cockpitActive: this.isCockpitActive(),
+ noun,
+ showToast: (text) => this.showToast(text),
+ stamp: () => this._stampNavigation(),
+ release: () => this._releaseFollowCamera(releaseOptions),
+ navigate,
+ });
+ }
+
+ _beginDeferredNavigation(
+ noun = 'location',
+ { cancelPendingSelection = true } = {},
+ ) {
+ return beginDeferredNavigation({
+ disposed: this._disposed,
+ cockpitActive: this.isCockpitActive(),
+ noun,
+ showToast: (text) => this.showToast(text),
+ // The searched-location readout survives the STAMP; only a flight that
+ // actually starts invalidates it (see the release hook below).
+ stamp: () =>
+ this._stampNavigation({
+ cancelPendingSelection,
+ clearSearchedLocation: false,
+ }),
+ });
+ }
+
+ _reassertNavigationHandoff(generation) {
+ return reassertNavigationHandoff({
+ generation,
+ currentGeneration: this._navigationGeneration,
+ cockpitActive: this.isCockpitActive(),
+ disposed: this._disposed,
+ showToast: (text) => this.showToast(text),
+ // Reached only once the handoff is granted, immediately before the
+ // deferred flight starts — so a lookup that failed, was superseded, or
+ // was refused by the cockpit leaves the old readout standing.
+ release: () => {
+ this.clearLocation();
+ return this._releaseFollowCamera();
+ },
+ });
+ }
+ stop() {
+ this._disposed = true;
+ }
+ destroy() {
+ this.stop();
+ this._stampNavigation();
+ }
+}
diff --git a/src/ui/panelChrome.js b/src/ui/panelChrome.js
new file mode 100644
index 000000000..2562f137b
--- /dev/null
+++ b/src/ui/panelChrome.js
@@ -0,0 +1,571 @@
+import { UiLifetime } from './uiLifetime.js';
+import { PanelPositionControls } from './panelPositionControls.js';
+import { PanelLayoutController } from './panelLayoutController.js';
+import {
+ bindPanelDisclosure,
+ collapsePanelOnEscape,
+ createHoverDisclosure,
+} from './panelDisclosure.js';
+const SHARE_PANEL_STATE_SPECS = Object.freeze([
+ { id: 'control-panel', pinnable: true },
+ { id: 'location-bar', pinnable: true },
+ { id: 'data-panel' },
+ { id: 'cctv-panel' },
+ { id: 'radio-panel' },
+ { id: 'scene-panel' },
+ { id: 'global-context-panel' },
+ { id: 'pp-toggles' },
+ { id: 'param-slider-panel' },
+]);
+/** Standard map-view panels cleared out of the way on a fresh Cockpit entry. */
+const COCKPIT_ENTRY_COLLAPSE_PANEL_IDS = Object.freeze([
+ 'data-panel',
+ 'cctv-panel',
+ 'scene-panel',
+ 'pp-toggles',
+ 'global-context-panel',
+ 'radio-panel',
+]);
+
+/** Own panel disclosure, docking, persistence and Cockpit rail restoration. */
+export class PanelChrome {
+ constructor({
+ elements,
+ operations,
+ readHud,
+ readCockpit,
+ readShareLinks,
+ readInitialShare,
+ readScrollRestoreOwner,
+ readDisplayScrollTop,
+ }) {
+ Object.assign(this, elements, operations, {
+ readHud,
+ readCockpit,
+ readShareLinks,
+ readInitialShare,
+ readScrollRestoreOwner,
+ readDisplayScrollTop,
+ });
+ this._disposed = false;
+ this._lifetime = new UiLifetime();
+ this._cockpitPanelRestore = null;
+ this._cockpitContextCollapsedForDataPanel = false;
+ this._panelPosition = new PanelPositionControls({
+ syncPanelCollapseButton: (panel) => this._syncPanelCollapseButton(panel),
+ layoutRightPanels: () => this._layoutRightPanels(),
+ syncCctvPanelViewport: () => this._syncCctvPanelViewport(),
+ showToast: (message) => this._showToast(message),
+ });
+ this._panelLayout = new PanelLayoutController({
+ readHud: () => ({
+ visible: this.hud.visible,
+ variant: this.hud.getVariant(),
+ }),
+ scheduleCockpitLayout: () => this.cockpitView?.scheduleContextLayout(),
+ syncPanelCollapseButton: (panel) => this._syncPanelCollapseButton(panel),
+ readDisplayScrollTop: () =>
+ this._displayPortalScrollRestoreOwner === 'standard'
+ ? this._standardDisplayScrollTop
+ : this._ppToggles?.scrollTop || 0,
+ });
+ }
+ get hud() {
+ return this.readHud();
+ }
+ get cockpitView() {
+ return this.readCockpit();
+ }
+ get shareLinkManager() {
+ return this.readShareLinks();
+ }
+ get _initialShareState() {
+ return this.readInitialShare();
+ }
+ get _displayPortalScrollRestoreOwner() {
+ return this.readScrollRestoreOwner();
+ }
+ get _standardDisplayScrollTop() {
+ return this.readDisplayScrollTop();
+ }
+
+ _initPanelChrome() {
+ for (const control of this._panelDisclosureControls || [])
+ control.destroy();
+ this._panelDisclosureControls = [];
+ const targets = new Map();
+ document
+ .querySelectorAll('.panel-collapse-btn[data-collapse-target]')
+ .forEach((button) => {
+ const targetId = button.dataset.collapseTarget;
+ if (!targetId) return;
+ if (!targets.has(targetId)) targets.set(targetId, []);
+ targets.get(targetId).push(button);
+ });
+ for (const [targetId, buttons] of targets) {
+ const panel = document.getElementById(targetId);
+ if (!panel) continue;
+ this._panelDisclosureControls.push(
+ bindPanelDisclosure({
+ panel,
+ buttons,
+ onChange: (collapsed, options) =>
+ this.setPanelCollapsed(targetId, collapsed, options),
+ onEscape: (event) => this._collapsePanelOnEscape(event, targetId),
+ }),
+ );
+ this._restorePanelCollapsedState(targetId, {
+ allowStored: !this._initialShareState,
+ });
+ }
+ // The command dock always starts compact; either wing reveals on hover,
+ // focus, or click and collapses again after the interaction moves away.
+ this.setPanelCollapsed('control-panel', true, {
+ syncShare: false,
+ persist: false,
+ });
+ this.setPanelCollapsed('location-bar', true, {
+ syncShare: false,
+ persist: false,
+ });
+ this._initAutoHoverPanel('control-panel', {
+ openDelayMs: 140,
+ closeDelayMs: 420,
+ });
+ this._initAutoHoverPanel('location-bar', {
+ openDelayMs: 140,
+ closeDelayMs: 420,
+ });
+ this._initCommandDockPins();
+ this._initCommandDockTrayMetrics();
+ this._maybeNotifyLayoutReset();
+ }
+
+ _collapsePanelOnEscape(event, panelId) {
+ return collapsePanelOnEscape(event, {
+ panel: document.getElementById(panelId),
+ onChange: (collapsed, options) =>
+ this.setPanelCollapsed(panelId, collapsed, options),
+ beforeCollapse: () => {
+ if (panelId !== 'location-bar' || !this._locationSearch) return;
+ this._locationSearch.classList.remove('expanded');
+ this._locationSearch.value = '';
+ this._locationSearch.blur();
+ },
+ });
+ }
+
+ _initCommandDockPins() {
+ document
+ .querySelectorAll('.dock-pin-btn[data-pin-target]')
+ .forEach((button) => {
+ this._lifetime.listen(button, 'click', (event) => {
+ event.stopPropagation();
+ const panelId = button.dataset.pinTarget;
+ this._setCommandDockPanelPinState(panelId);
+ });
+ });
+ }
+
+ _setCommandDockPanelPinState(
+ panelId,
+ pin,
+ { restore = false, persist = true, syncShare = true } = {},
+ ) {
+ const panelEl = document.getElementById(panelId);
+ const button = document.querySelector(
+ `.dock-pin-btn[data-pin-target="${panelId}"]`,
+ );
+ if (!panelEl || !button) return undefined;
+ const shouldPin =
+ typeof pin === 'boolean'
+ ? pin
+ : !panelEl.classList.contains('dock-pinned');
+ panelEl.classList.toggle('dock-pinned', shouldPin);
+ button.setAttribute('aria-pressed', String(shouldPin));
+ document
+ .querySelectorAll('#command-dock .dock-pinned-top')
+ .forEach((pinnedPanel) => {
+ pinnedPanel.classList.remove('dock-pinned-top');
+ });
+ if (shouldPin) {
+ panelEl.classList.add('dock-pinned-top');
+ this.setPanelCollapsed(panelId, false, {
+ explicit: !restore,
+ restore,
+ persist,
+ syncShare: false,
+ });
+ } else {
+ const remainingPinnedPanel = document.querySelector(
+ '#command-dock .dock-pinned',
+ );
+ remainingPinnedPanel?.classList.add('dock-pinned-top');
+ if (!restore && !panelEl.matches(':hover')) {
+ this.setPanelCollapsed(panelId, true, {
+ explicit: true,
+ persist,
+ syncShare: false,
+ });
+ }
+ }
+ this._updateCommandDockTrayStack();
+ if (syncShare) {
+ if (!restore) this.shareLinkManager?.claimRestoreLane?.('panel', panelId);
+ this.shareLinkManager?.onPanelStateChange?.();
+ }
+ return shouldPin;
+ }
+
+ _initCommandDockTrayMetrics() {
+ return this._panelLayout._initCommandDockTrayMetrics();
+ }
+
+ _updateCommandDockTrayStack() {
+ return this._panelLayout._updateCommandDockTrayStack();
+ }
+
+ _maybeNotifyLayoutReset() {
+ return this._panelPosition._maybeNotifyLayoutReset();
+ }
+
+ _initAutoHoverPanel(
+ panelId,
+ { openDelayMs = 850, closeDelayMs = 1000 } = {},
+ ) {
+ const panel = document.getElementById(panelId);
+ if (!panel) return;
+ this._hoverPanelControls ??= new Map();
+ this._hoverPanelControls.get(panelId)?.destroy();
+ const controller = createHoverDisclosure({
+ panel,
+ documentRef: document,
+ disclosure: panel.querySelector(`[data-dock-toggle-target="${panelId}"]`),
+ openDelayMs,
+ closeDelayMs,
+ isActive: () => !this._disposed,
+ onChange: (collapsed, options) =>
+ this.setPanelCollapsed(panelId, collapsed, options),
+ onEscape: (event) => this._collapsePanelOnEscape(event, panelId),
+ focusTarget:
+ panelId === 'control-panel'
+ ? () =>
+ panel.querySelector('.map-stack-chip.active') ||
+ panel.querySelector('.map-stack-chip')
+ : null,
+ });
+ this._hoverPanelControls.set(panelId, controller);
+ if (panelId === 'control-panel') {
+ this._cancelMapSourceFocus?.();
+ this._cancelMapSourceFocus = controller.cancelPendingFocus;
+ }
+ }
+
+ _restorePanelCollapsedState(panelId, options1) {
+ return this._panelPosition._restorePanelCollapsedState(panelId, options1);
+ }
+
+ _savePanelCollapsedState(panelId, collapsed) {
+ return this._panelPosition._savePanelCollapsedState(panelId, collapsed);
+ }
+
+ _scheduleRightPanelLayout(options0) {
+ return this._panelLayout._scheduleRightPanelLayout(options0);
+ }
+
+ _scheduleLeftPanelLayout(options0) {
+ return this._panelLayout._scheduleLeftPanelLayout(options0);
+ }
+
+ _syncPanelCollapseButton(panelEl) {
+ const isRightRail = [
+ 'pp-toggles',
+ 'cctv-panel',
+ 'global-context-panel',
+ ].includes(panelEl?.id);
+ const collapsed = panelEl.classList.contains('collapsed');
+ panelEl
+ .querySelectorAll('.panel-collapse-btn[data-collapse-target]')
+ .forEach((btn) => {
+ const owner = btn.closest('[data-panel-id], #param-slider-panel');
+ if (owner !== panelEl) return;
+ if (isRightRail) {
+ btn.textContent = collapsed ? '◀' : '▶';
+ } else {
+ btn.textContent = collapsed ? '+' : '−';
+ }
+ btn.setAttribute('aria-expanded', String(!collapsed));
+ const panelName =
+ panelEl
+ .querySelector('.panel-title, .pp-header-label')
+ ?.textContent?.trim() || 'panel';
+ const action = collapsed ? 'Expand' : 'Collapse';
+ btn.title = `${action} ${panelName}`;
+ btn.setAttribute('aria-label', `${action} ${panelName}`);
+ if (panelEl.id === 'radio-panel') {
+ const action = collapsed ? 'Expand' : 'Collapse';
+ btn.title = `${action} Radio`;
+ btn.setAttribute('aria-label', `${action} Radio section`);
+ }
+ });
+ const dockToggle = panelEl.querySelector(
+ `[data-dock-toggle-target="${panelEl.id}"]`,
+ );
+ if (dockToggle) {
+ const panelName =
+ panelEl
+ .querySelector('.panel-title, .location-toolbar-label')
+ ?.textContent?.trim() || 'panel';
+ const action = collapsed ? 'Expand' : 'Collapse';
+ dockToggle.setAttribute('aria-expanded', String(!collapsed));
+ dockToggle.setAttribute('aria-label', `${action} ${panelName}`);
+ dockToggle.title = `${action} ${panelName}`;
+ }
+ if (panelEl.id === 'radio-panel' && this._contextRadioDetailsBtn) {
+ this._contextRadioDetailsBtn.setAttribute(
+ 'aria-expanded',
+ String(!collapsed),
+ );
+ }
+ if (panelEl.id === 'radio-panel' || panelEl.id === 'global-context-panel') {
+ this._syncContextRadioLauncherState();
+ }
+ }
+
+ _buildSharePanelState() {
+ const specs = [];
+ for (const spec of SHARE_PANEL_STATE_SPECS) {
+ const panelEl = document.getElementById(spec.id);
+ if (!panelEl) continue;
+ // Responsive auto-collapse is presentation only; the recipient should
+ // restore the user's explicit expanded preference at its own viewport.
+ const collapsed = panelEl.classList.contains('layout-auto-collapsed')
+ ? false
+ : panelEl.classList.contains('collapsed');
+ const entry = { id: spec.id, collapsed };
+ if (spec.pinnable)
+ entry.pinned = panelEl.classList.contains('dock-pinned');
+ specs.push(entry);
+ }
+ return specs.length ? { specs } : null;
+ }
+
+ _restorePanelState(panelState) {
+ if (!panelState || !Array.isArray(panelState.specs)) return;
+ const specsById = new Map(panelState.specs.map((spec) => [spec.id, spec]));
+ for (const spec of SHARE_PANEL_STATE_SPECS) {
+ const state = specsById.get(spec.id);
+ if (!state || typeof state.collapsed !== 'boolean') continue;
+ if (spec.pinnable && typeof state.pinned === 'boolean') {
+ this._setCommandDockPanelPinState(spec.id, state.pinned, {
+ restore: true,
+ persist: false,
+ syncShare: false,
+ });
+ }
+ const nextCollapsed =
+ state.pinned && spec.pinnable ? false : state.collapsed;
+ this.setPanelCollapsed(spec.id, nextCollapsed, {
+ restore: true,
+ persist: false,
+ syncShare: false,
+ });
+ }
+ this.shareLinkManager?.onPanelStateChange?.();
+ }
+
+ setPanelCollapsed(
+ panelId,
+ collapsed,
+ {
+ explicit = false,
+ restore = false,
+ persist = true,
+ syncShare = true,
+ } = {},
+ ) {
+ if (panelId === 'control-panel' && collapsed)
+ this._cancelMapSourceFocus?.();
+ const panelEl = document.getElementById(panelId);
+ if (!panelEl) return;
+ if (explicit && !restore)
+ this.shareLinkManager?.claimRestoreLane?.('panel', panelId);
+ const nextCollapsed = Boolean(collapsed);
+ const wasAutoCollapsed = panelEl.classList.contains(
+ 'layout-auto-collapsed',
+ );
+ const leftOwnerPanel = this._leftPanelStack?.contains(panelEl)
+ ? panelEl
+ : null;
+ const rightOwnerPanel =
+ panelId === 'radio-panel'
+ ? document.getElementById('global-context-panel')
+ : this._rightPanelStack?.contains(panelEl)
+ ? panelEl
+ : null;
+ const priorLeftOwner = this._panelLayout._leftStackPreferredPanelId;
+ const priorRightOwner = this._panelLayout._rightStackPreferredPanelId;
+ if (explicit && !restore && !nextCollapsed && leftOwnerPanel) {
+ this._panelLayout._leftStackPreferredPanelId = leftOwnerPanel.id;
+ } else if (
+ explicit &&
+ !restore &&
+ nextCollapsed &&
+ leftOwnerPanel?.id === this._panelLayout._leftStackPreferredPanelId
+ ) {
+ this._panelLayout._leftStackPreferredPanelId = null;
+ }
+ if (explicit && !restore && !nextCollapsed && rightOwnerPanel) {
+ this._panelLayout._rightStackPreferredPanelId = rightOwnerPanel.id;
+ } else if (
+ explicit &&
+ !restore &&
+ nextCollapsed &&
+ rightOwnerPanel?.id === this._panelLayout._rightStackPreferredPanelId
+ ) {
+ this._panelLayout._rightStackPreferredPanelId = null;
+ }
+ if (
+ panelEl.classList.contains('collapsed') === nextCollapsed &&
+ !wasAutoCollapsed
+ ) {
+ this._syncPanelCollapseButton(panelEl);
+ if (priorLeftOwner !== this._panelLayout._leftStackPreferredPanelId) {
+ this._scheduleLeftPanelLayout({ reconsiderAutoCollapse: true });
+ }
+ if (priorRightOwner !== this._panelLayout._rightStackPreferredPanelId) {
+ this._scheduleRightPanelLayout({ reconsiderAutoCollapse: true });
+ }
+ return;
+ }
+ panelEl.classList.remove('layout-auto-collapsed');
+ if (
+ !nextCollapsed &&
+ this.cockpitView?.active &&
+ panelId === 'data-panel'
+ ) {
+ this._cockpitContextCollapsedForDataPanel =
+ !this.cockpitView.contextCollapsed;
+ if (this._cockpitContextCollapsedForDataPanel) {
+ this.cockpitView.setContextCollapsed(true);
+ }
+ }
+ if (
+ !nextCollapsed &&
+ panelId === 'global-context-panel' &&
+ this._contextRadioDock?.classList.contains('disclosure-open')
+ ) {
+ this._setRadioDisclosure?.(false);
+ }
+ if (
+ !nextCollapsed &&
+ panelId === 'radio-panel' &&
+ document
+ .getElementById('global-context-panel')
+ ?.classList.contains('collapsed')
+ ) {
+ this.setPanelCollapsed('global-context-panel', false, {
+ restore,
+ persist,
+ syncShare,
+ });
+ }
+ if (!nextCollapsed && !restore && panelId === 'location-bar') {
+ const otherPanel = document.getElementById('control-panel');
+ if (otherPanel && !otherPanel.classList.contains('dock-pinned')) {
+ this.setPanelCollapsed('control-panel', true, {
+ restore,
+ persist,
+ syncShare,
+ });
+ }
+ } else if (!nextCollapsed && !restore && panelId === 'control-panel') {
+ const otherPanel = document.getElementById('location-bar');
+ if (otherPanel && !otherPanel.classList.contains('dock-pinned')) {
+ this.setPanelCollapsed('location-bar', true, {
+ restore,
+ persist,
+ syncShare,
+ });
+ }
+ }
+ panelEl.classList.toggle('collapsed', nextCollapsed);
+ if (
+ nextCollapsed &&
+ this.cockpitView?.active &&
+ panelId === 'data-panel' &&
+ this._cockpitContextCollapsedForDataPanel
+ ) {
+ this._cockpitContextCollapsedForDataPanel = false;
+ this.cockpitView.setContextCollapsed(false);
+ }
+ this._syncPanelCollapseButton(panelEl);
+ if (persist !== false)
+ this._savePanelCollapsedState(panelId, nextCollapsed);
+ if (panelId === 'pp-toggles') {
+ this._layoutRightPanels();
+ }
+ if (this._rightPanelStack?.contains(panelEl)) {
+ this._scheduleRightPanelLayout({ reconsiderAutoCollapse: true });
+ }
+ if (panelId === 'cctv-panel') {
+ this._syncCctvPanelViewport();
+ }
+ this._lifetime.frame(() => this._updateCommandDockTrayStack());
+ this._scheduleLeftPanelLayout({
+ reconsiderAutoCollapse: this._leftPanelStack?.contains(panelEl) === true,
+ });
+ if (syncShare) this.shareLinkManager?.onPanelStateChange?.();
+ }
+
+ _layoutRightPanels() {
+ this._scheduleRightPanelLayout();
+ }
+ enterCockpit() {
+ // A new Cockpit session owns both side rails. Clear standard map-view
+ // panels once on entry; NEXT/PREVIOUS never reaches this callback, so
+ // panels the operator opens while already inside remain untouched.
+ this._cockpitPanelRestore = new Map();
+ this._cockpitContextCollapsedForDataPanel = false;
+ for (const panelId of COCKPIT_ENTRY_COLLAPSE_PANEL_IDS) {
+ const panel = document.getElementById(panelId);
+ if (panel) {
+ this._cockpitPanelRestore.set(
+ panelId,
+ panel.classList.contains('collapsed'),
+ );
+ }
+ this.setPanelCollapsed(panelId, true, {
+ persist: false,
+ syncShare: false,
+ });
+ }
+ this.cockpitView?.setContextCollapsed(false);
+ this.cockpitView?.setSignalCollapsed(false, { user: true });
+ }
+ exitCockpit() {
+ const restore = this._cockpitPanelRestore;
+ this._cockpitPanelRestore = null;
+ this._cockpitContextCollapsedForDataPanel = false;
+ if (!restore) return;
+ for (const [panelId, wasCollapsed] of restore) {
+ this.setPanelCollapsed(panelId, wasCollapsed, {
+ persist: false,
+ syncShare: false,
+ });
+ }
+ }
+ destroy() {
+ if (this._disposed) return;
+ this._disposed = true;
+ this._lifetime.destroy();
+ this._panelPosition.destroy();
+ this._panelLayout.destroy();
+ for (const control of this._panelDisclosureControls || [])
+ control.destroy();
+ this._panelDisclosureControls = [];
+ this._hoverPanelControls?.forEach((control) => control.destroy());
+ this._hoverPanelControls?.clear();
+ this._cancelMapSourceFocus?.();
+ }
+}
diff --git a/src/ui/shareRestoration.js b/src/ui/shareRestoration.js
new file mode 100644
index 000000000..7e14b339a
--- /dev/null
+++ b/src/ui/shareRestoration.js
@@ -0,0 +1,306 @@
+import { LayerStateCoordinator } from '../data/layerState.js';
+import { stampInitialShareGesture } from '../navigationPolicy.js';
+import { canPresentDeferredStatusNotice } from '../loadingFeedback.js';
+import { UiLifetime } from './uiLifetime.js';
+
+/** Own initial share restoration, durable layer state and restoration notices. */
+export class ShareRestoration {
+ constructor({
+ viewer,
+ navigation,
+ syncShareState,
+ syncModels3d,
+ showStatus,
+ feedback,
+ updateFeedback,
+ }) {
+ Object.assign(this, {
+ viewer,
+ navigation,
+ syncShareState,
+ syncModels3d,
+ showStatus,
+ feedback,
+ updateFeedback,
+ });
+ this._lifetime = new UiLifetime();
+ this._disposed = false;
+ this._shareTrackingAcquiringKey = null;
+ this._shareTrackingNoticeGeneration = 0;
+ this._initialShareState = null;
+ this._initialShareNavigationGeneration = null;
+ this._initialShareRestoreTimeout = null;
+ this._layerStateCoordinator = null;
+ this._layerStateRestorePromise = null;
+ }
+ attachLinks(shareLinkManager) {
+ this.shareLinkManager = shareLinkManager;
+ this._initialShareState = shareLinkManager.parseInitialHash();
+ }
+ start() {
+ // Restore from URL hash if present
+ const savedState = this._initialShareState;
+ this._initialShareRestorePromise = savedState
+ ? new Promise((resolve) => {
+ this._resolveInitialShareRestore = resolve;
+ })
+ : Promise.resolve({ status: 'not-requested', share: null, layers: [] });
+ if (savedState) {
+ this._hasShareState = true;
+ // Reserve camera authority now; the delayed mesh-friendly flight may
+ // run only if no newer user, voice, or tracking navigation has won.
+ this._initialShareNavigationGeneration =
+ this.navigation._beginDeferredNavigation('shared view', {
+ cancelPendingSelection: false,
+ });
+ this._initialShareRestoreTimeout = setTimeout(() => {
+ this._initialShareRestoreTimeout = null;
+ if (this._disposed) return;
+ const generation = this._initialShareNavigationGeneration;
+ const applyCamera =
+ Number.isInteger(generation) &&
+ this.navigation._reassertNavigationHandoff(generation);
+ void (async () => {
+ try {
+ const share = await this.shareLinkManager.applyState(savedState, {
+ applyCamera,
+ navigationToken: generation,
+ });
+ const layers = await (this._layerStateRestorePromise ||
+ Promise.resolve([]));
+ const tracking =
+ share.camera === 'applied'
+ ? await this._layerStateCoordinator?.restoreShareTrackingSelection?.()
+ : {
+ status: 'superseded',
+ cleared:
+ this._layerStateCoordinator?.cancelPendingShareTracking?.(
+ 'shared-camera-superseded',
+ { clearSelection: true },
+ ) === true,
+ };
+ this.shareLinkManager.completeInitialRestore();
+ this._settleInitialShareRestore({
+ status: 'settled',
+ share,
+ layers,
+ tracking,
+ });
+ } catch (error) {
+ this.shareLinkManager.completeInitialRestore();
+ this._settleInitialShareRestore({
+ status: 'failed',
+ error: String(error?.message || error),
+ share: null,
+ layers: [],
+ });
+ }
+ })();
+ }, 1500);
+ } else {
+ this.syncShareState();
+ }
+ // A recipient can orbit before or during the delayed share flight. That
+ // gesture keeps ordinary layer state but revokes the passive base camera
+ // and selected-subject Follow so delayed work cannot seize navigation.
+ this._initialShareGestureHandler = () => {
+ if (
+ this._disposed ||
+ !this._hasShareState ||
+ !this._resolveInitialShareRestore
+ )
+ return;
+ stampInitialShareGesture((options) =>
+ this.navigation._stampNavigation(options),
+ );
+ };
+ this.viewer?.canvas?.addEventListener(
+ 'pointerdown',
+ this._initialShareGestureHandler,
+ {
+ passive: true,
+ },
+ );
+ this.viewer?.canvas?.addEventListener(
+ 'wheel',
+ this._initialShareGestureHandler,
+ {
+ passive: true,
+ },
+ );
+ }
+ connect(dataManager) {
+ this._dataManager = dataManager;
+ this._layerStateCoordinator?.destroy();
+ this._layerStateCoordinator = null;
+ this._layerStateRestorePromise = null;
+ if (this._dataManager) {
+ this._layerStateCoordinator = new LayerStateCoordinator(
+ this._dataManager,
+ this.shareLinkManager,
+ {
+ onDurableStateChange: (state) => this.syncModels3d(state),
+ onTrackingRestoreStatus: (result) =>
+ this._handleShareTrackingRestoreStatus(result),
+ },
+ );
+ this._layerStateRestorePromise = this._layerStateCoordinator.start({
+ shareLayerState: this._initialShareState?.layerState || null,
+ shareCreatedAtMs: this._initialShareState?.sharedAtMs ?? null,
+ // Any valid camera/style share isolates recipient-local preferences,
+ // including legacy and malformed-v2 layer payloads.
+ allowLocalState: !this._initialShareState,
+ });
+ if (this._initialShareSelectionSuperseded) {
+ this._layerStateCoordinator.cancelPendingShareTracking(
+ 'superseded-before-layer-coordinator-start',
+ { clearSelection: true },
+ );
+ }
+ void this._layerStateRestorePromise.then(() => {
+ this.syncModels3d(this._layerStateCoordinator?.getDurableState());
+ });
+ }
+ }
+ cancelSelection() {
+ if (
+ this._hasShareState &&
+ this._resolveInitialShareRestore &&
+ !this._layerStateCoordinator
+ )
+ this._initialShareSelectionSuperseded = true;
+ return (
+ this._layerStateCoordinator?.cancelPendingShareTracking?.(
+ 'superseded-by-explicit-navigation',
+ { clearSelection: true },
+ ) === true
+ );
+ }
+ get initialRestorePromise() {
+ return (
+ this._initialShareRestorePromise ||
+ Promise.resolve({ status: 'not-requested' })
+ );
+ }
+ _handleShareTrackingRestoreStatus(result) {
+ if (!result || this._disposed) return;
+ const trackingKey = `${result.layerId || ''}:${result.targetId ?? ''}`;
+ if (result.classification === 'pending') {
+ this._shareTrackingNoticeGeneration += 1;
+ this._shareTrackingAcquiringKey = trackingKey;
+ this.showStatus('ACQUIRING', {
+ state: 'acquiring',
+ detail: `SHARED ${String(result.label || 'SUBJECT').toUpperCase()}`,
+ persistent: true,
+ });
+ return;
+ }
+ const ownsAcquiringNotice = this._shareTrackingAcquiringKey === trackingKey;
+ if (ownsAcquiringNotice) {
+ this._shareTrackingNoticeGeneration += 1;
+ this._shareTrackingAcquiringKey = null;
+ if (this.feedback._globalStatusNotice?.state === 'acquiring') {
+ this.feedback._globalStatusNotice = null;
+ this.updateFeedback();
+ }
+ }
+ if (
+ result.classification === 'followed' ||
+ result.classification === 'cancelled'
+ )
+ return;
+ // A stale terminal result must never replace a newer target's acquisition.
+ if (this._shareTrackingAcquiringKey) return;
+ const noticeGeneration = ownsAcquiringNotice
+ ? this._shareTrackingNoticeGeneration
+ : ++this._shareTrackingNoticeGeneration;
+ const subject = result.label || 'entity';
+ const message =
+ result.classification === 'expired'
+ ? `Shared ${subject} follow expired`
+ : result.classification === 'source-unavailable'
+ ? `Shared ${subject} could not be restored — feed unavailable`
+ : `Shared ${subject} is unavailable`;
+ const showAfterStartupCover = () => {
+ this._lifetime.frame(() => {
+ if (
+ !canPresentDeferredStatusNotice(
+ noticeGeneration,
+ this._shareTrackingNoticeGeneration,
+ this._disposed,
+ )
+ )
+ return;
+ const startupCover = document.getElementById('loading-screen');
+ if (
+ !startupCover ||
+ getComputedStyle(startupCover).visibility === 'hidden'
+ ) {
+ this.showStatus(message);
+ return;
+ }
+ let fallbackTimer = null;
+ let removeStartupListener = () => {};
+ const showOnce = () => {
+ removeStartupListener();
+ if (fallbackTimer) this._lifetime.cancelTimeout(fallbackTimer);
+ if (
+ canPresentDeferredStatusNotice(
+ noticeGeneration,
+ this._shareTrackingNoticeGeneration,
+ this._disposed,
+ )
+ )
+ this.showStatus(message);
+ };
+ removeStartupListener = this._lifetime.listen(
+ startupCover,
+ 'transitionend',
+ showOnce,
+ { once: true },
+ );
+ fallbackTimer = this._lifetime.timeout(showOnce, 1000);
+ });
+ };
+ if (this._resolveInitialShareRestore) {
+ void this.initialRestorePromise.then(showAfterStartupCover);
+ return;
+ }
+ showAfterStartupCover();
+ }
+ _settleInitialShareRestore(result) {
+ if (!this._resolveInitialShareRestore) return;
+ const resolve = this._resolveInitialShareRestore;
+ this._resolveInitialShareRestore = null;
+ resolve(result);
+ window.dispatchEvent(
+ new CustomEvent('gev:initial-share-restore-settled', { detail: result }),
+ );
+ }
+ destroy() {
+ if (this._disposed) return;
+ this._disposed = true;
+ this._shareTrackingNoticeGeneration += 1;
+ this._shareTrackingAcquiringKey = null;
+ this._layerStateCoordinator?.destroy();
+ this._layerStateCoordinator = null;
+ this._layerStateRestorePromise = null;
+ clearTimeout(this._initialShareRestoreTimeout);
+ this._initialShareRestoreTimeout = null;
+ this._settleInitialShareRestore({
+ status: 'destroyed',
+ share: null,
+ layers: [],
+ });
+ this.viewer?.canvas?.removeEventListener(
+ 'pointerdown',
+ this._initialShareGestureHandler,
+ );
+ this.viewer?.canvas?.removeEventListener(
+ 'wheel',
+ this._initialShareGestureHandler,
+ );
+ this._initialShareGestureHandler = null;
+ this._lifetime.destroy();
+ }
+}
diff --git a/src/ui/stateOwners.test.mjs b/src/ui/stateOwners.test.mjs
new file mode 100644
index 000000000..b3708ea73
--- /dev/null
+++ b/src/ui/stateOwners.test.mjs
@@ -0,0 +1,143 @@
+import assert from 'node:assert/strict';
+import test from 'node:test';
+import { NavigationController } from './navigationController.js';
+import { ShareRestoration } from './shareRestoration.js';
+
+function navigation() {
+ const tracking = Object.fromEntries(
+ [
+ 'flightsLayer',
+ 'militaryFlightsLayer',
+ 'satellitesLayer',
+ 'aisLiveVesselsLayer',
+ 'militaryAwarenessLayer',
+ 'rocketLaunchesLayer',
+ ].map((name) => [name, {}]),
+ );
+ return new NavigationController({
+ viewer: { camera: { cancelFlight() {}, lookAtTransform() {} } },
+ tracking,
+ searchInput: null,
+ interruptCameraMotion() {},
+ isCockpitActive: () => false,
+ clearLocation() {},
+ cancelShareSelection: () => false,
+ getDataManager: () => null,
+ stopOrbit() {},
+ showToast() {},
+ });
+}
+
+test('navigation generations belong to their owner and teardown closes both entry paths', () => {
+ const first = navigation();
+ const second = navigation();
+ const a = first._beginDeferredNavigation();
+ const b = second._beginDeferredNavigation();
+ first._stampNavigation();
+ assert.equal(first._reassertNavigationHandoff(a), false);
+ assert.equal(second._reassertNavigationHandoff(b), true);
+ let flights = 0;
+ second.stop();
+ assert.equal(second._beginDeferredNavigation(), false);
+ assert.equal(
+ second._runExplicitNavigation('location', () => {
+ flights += 1;
+ }),
+ false,
+ );
+ assert.equal(flights, 0);
+ const generation = second._navigationGeneration;
+ second.destroy();
+ assert.equal(second._navigationGeneration, generation + 1);
+});
+
+test('share teardown settles its promise, removes gestures and rejects a retained timer callback', async (t) => {
+ const prior = {
+ window: globalThis.window,
+ setTimeout: globalThis.setTimeout,
+ clearTimeout: globalThis.clearTimeout,
+ };
+ const timers = new Map();
+ let timerId = 0;
+ globalThis.window = new EventTarget();
+ globalThis.setTimeout = (fn) => {
+ timers.set(++timerId, fn);
+ return timerId;
+ };
+ globalThis.clearTimeout = (id) => timers.delete(id);
+ t.after(() => Object.assign(globalThis, prior));
+ const canvas = new EventTarget();
+ let stamps = 0;
+ let applies = 0;
+ const owner = new ShareRestoration({
+ viewer: { canvas },
+ navigation: {
+ _beginDeferredNavigation: () => 1,
+ _reassertNavigationHandoff: () => true,
+ _stampNavigation: () => {
+ stamps += 1;
+ },
+ },
+ syncShareState() {},
+ syncModels3d() {},
+ showStatus() {},
+ feedback: {},
+ updateFeedback() {},
+ });
+ owner.attachLinks({
+ parseInitialHash: () => ({ latitude: 30, longitude: -97 }),
+ applyState: async () => {
+ applies += 1;
+ return { camera: 'applied' };
+ },
+ completeInitialRestore() {},
+ });
+ owner.start();
+ const retained = [...timers.values()][0];
+ canvas.dispatchEvent(new Event('wheel'));
+ assert.equal(stamps, 1);
+ owner.destroy();
+ assert.equal((await owner.initialRestorePromise).status, 'destroyed');
+ assert.equal(timers.size, 0);
+ canvas.dispatchEvent(new Event('wheel'));
+ retained();
+ await Promise.resolve();
+ assert.equal(stamps, 1);
+ assert.equal(applies, 0);
+});
+
+test('visual teardown restores owned fog and aircraft sensor state once', async (t) => {
+ const { VisualSettings } = await import('./visualSettings.js');
+ const priorDocument = globalThis.document;
+ globalThis.document = {
+ documentElement: { dataset: {} },
+ getElementById: () => null,
+ };
+ t.after(() => {
+ globalThis.document = priorDocument;
+ });
+ const calls = [];
+ const viewer = { scene: { fog: { enabled: false } } };
+ const owner = new VisualSettings({
+ viewer,
+ elements: {},
+ operations: {},
+ services: {
+ governorRequestRender() {},
+ holdContinuousRender() {},
+ releaseContinuousRender() {},
+ },
+ readDataManager: () => ({ setLayerParams: (...args) => calls.push(args) }),
+ });
+ owner._irBoostActive = true;
+ owner._irFogWasEnabled = true;
+ owner.releaseIrBoost();
+ owner.releaseIrBoost();
+ assert.equal(viewer.scene.fog.enabled, true);
+ assert.deepEqual(calls, [
+ ['flights', { irBoost: false }],
+ ['military', { irBoost: false }],
+ ]);
+ assert.equal(owner._irBoostActive, false);
+ owner.destroy();
+});
diff --git a/src/ui/visualPresets.js b/src/ui/visualPresets.js
index 197605faa..fa5c57939 100644
--- a/src/ui/visualPresets.js
+++ b/src/ui/visualPresets.js
@@ -143,3 +143,14 @@ export const SHARPEN_SHADER = /* glsl */ `
out_FragColor = vec4(clamp(sharpened.rgb, 0.0, 1.0), center.a);
}
`;
+
+/** Stable display labels for the active style and inherited Cockpit vision. */
+export const STYLE_STATUS_LABELS = {
+ normal: 'NORMAL',
+ retro: 'CRT',
+ surveillance: 'NVG',
+ thermal: 'FLIR',
+ anime: 'ANIME',
+ noir: 'NOIR',
+ snow: 'SNOW',
+};
diff --git a/src/ui/visualSettings.js b/src/ui/visualSettings.js
new file mode 100644
index 000000000..24711caed
--- /dev/null
+++ b/src/ui/visualSettings.js
@@ -0,0 +1,1463 @@
+import { STYLE_STATUS_LABELS } from './visualPresets.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 * as Cesium from 'cesium';
+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';
+const DETECTION_ALLOCATION_STORAGE_KEY = 'gev:detection-allocation:v1';
+
+/** Own visual preferences, detection overrides and display-control state. */
+export class VisualSettings {
+ 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';
+ 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._cockpitVisionRestore,
+ );
+ this._syncStagesEnabledFromIntensity();
+ this._cockpitVisionMode = next;
+ this._syncIrBoost();
+ this._updateSliderPanel(this.activeStyle, { reveal: false });
+ this._revealCockpitStyleParameters({ openDisplay: revealParameters });
+ return;
+ }
+ const target = applyCockpitVisionStageIntensities(
+ this.stages,
+ next,
+ this._cockpitVisionRestore,
+ );
+ 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' ? cockpitMode : this.activeStyle;
+ const irBoost =
+ effective === 'surveillance' ||
+ effective === 'thermal' ||
+ effective === 'nvg';
+ this._dataManager?.setLayerParams('flights', { irBoost });
+ this._dataManager?.setLayerParams('military', { irBoost });
+ // 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 });
+ for (const button of this._detectionAllocationBtns) {
+ const active = button.dataset.allocation === normalized;
+ button.classList.toggle('active', active);
+ button.setAttribute('aria-checked', String(active));
+ }
+ 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) {
+ if (!variantName) return;
+ this.hud.setVariant(variantName);
+ if (
+ this._hudLayoutSelect &&
+ this._hudLayoutSelect.value !== this.hud.getVariant()
+ ) {
+ this._hudLayoutSelect.value = this.hud.getVariant();
+ }
+ this._syncShareState();
+ this._scheduleAdaptivePanelLayout({ settle: true });
+ }
+
+ _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);
+ }
+
+ if (preset.hudVariant) {
+ 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
+ ? 'Celestial ring — reveal the full globe'
+ : '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;
+
+ if (state.style && state.style !== this.activeStyle) {
+ this.setStyle(state.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);
+ this._scopeBtn?.classList.toggle('active', scopeState.enabled);
+ this._scopeBtn?.setAttribute('aria-pressed', String(scopeState.enabled));
+ }
+ if (typeof scopeState.featherPct === 'number' && this._scopeFeatherSlider) {
+ const pct = Math.max(0, Math.min(100, Math.round(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,
+ } = {},
+ ) {
+ const { setDetectionStyle } = this.services;
+ if (!restore) this.shareLinkManager?.claimRestoreLane?.('visual');
+ 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
+ const displayNames = { surveillance: 'NVG', thermal: 'FLIR', retro: 'CRT' };
+ this._styleIndicator.textContent =
+ displayNames[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] ||
+ 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
+ ? `Detection overlay: ${String(modeLabel).toLowerCase()}`
+ : 'Detection overlay: off',
+ );
+ btn.classList.remove('active', 'god', 'panoptic');
+ if (modeLabel === 'SPARSE') {
+ btn.querySelector('.pp-label').textContent = 'SPARSE';
+ btn.classList.add('active');
+ } else if (modeLabel === 'BALANCED') {
+ btn.querySelector('.pp-label').textContent = 'BALANCED';
+ btn.classList.add('active');
+ } else if (modeLabel === 'DENSE') {
+ btn.querySelector('.pp-label').textContent = 'DENSE';
+ btn.classList.add('active', 'panoptic');
+ } else {
+ btn.querySelector('.pp-label').textContent = '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();
+ }
+}