diff --git a/CHANGELOG.md b/CHANGELOG.md
index bc10802f5..f511bf044 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -13,6 +13,10 @@ of current runtime behavior, see [`docs/CURRENT-STATE.md`](docs/CURRENT-STATE.md
## [Unreleased]
+- Separate UI assembly from standalone engine wiring, with dedicated panel layout, position, notice and recording owners.
+- Stop pending UI presentation and drag work during disposal; preserve accessible status text when stopping its decoration.
+- Organize component styles behind the same ordered stylesheet entry and include 3D model controls in the current-state snapshot.
+
- Separate Scene controls and text presentation from project/playback operations; revoke replaced row listeners and suppress stale completion feedback.
- Preserve shot-label identity on selection so double-click rename can complete.
diff --git a/docs/CODE-BOUNDARIES.md b/docs/CODE-BOUNDARIES.md
index 2ba08e77b..1fc301747 100644
--- a/docs/CODE-BOUNDARIES.md
+++ b/docs/CODE-BOUNDARIES.md
@@ -327,3 +327,14 @@ original Display groups, independent scroll positions and current focus owner.
presentation. It receives project reads and explicit actions, with no imports of
the director, source modules, camera engine or storage. Replacement and disposal
release listeners; pending action feedback is limited to its current owner.
+
+## UI assembly and styles
+
+`ui/shell` assembles controls from supplied existing layer, navigation, terrain,
+rendering, HUD and share operations. `src/standalone/ui.js` provides the running
+application's instances; `src/ui.js` remains the compatibility entry. The shell
+imports no standalone bootstrap or concrete live layer implementation.
+Panel layout, position/drag, notices, recording and deferred UI work have separate
+owners with synchronous cleanup. Existing scene, share and HUD engines retain
+their entry points. `ui/styles` loads the ordered stylesheet entry; component
+files retain the original cascade, including responsive and dock refinements.
diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md
index 7bf3b3b00..92f38babb 100644
--- a/docs/CURRENT-STATE.md
+++ b/docs/CURRENT-STATE.md
@@ -1,5 +1,19 @@
# God's Eye View Current State
+## UI shell and component ownership
+
+The standalone entry composes the UI with the application's existing layer,
+terrain, navigation and rendering operations. The shell receives those instances
+and assembles the controls. Panel layout scheduling, position preferences and
+active drags, loading notices, recording presentation and DOM lookup have focused
+owners. Disposal revokes queued presentation and listeners before asynchronous
+Context restoration, cancels an unfinished drag without saving it, restores the
+recording HUD and releases status decoration without replacing accessible text.
+The ordinary control snapshot includes the current 3D model toggle and mode.
+
+`style.css` imports component styles in their original cascade order. Scene,
+share, HUD and layer engines retain their existing behavior and entry points.
+
## Scene control ownership
Scene controls own creation/deletion prompts, panel listeners, shot rows, playback/recording presentation
diff --git a/package.json b/package.json
index d0bead40e..e7fa939f5 100644
--- a/package.json
+++ b/package.json
@@ -138,6 +138,8 @@
"./ui/cockpit/math": "./src/cockpitMath.js",
"./ui/cockpit/layout": "./src/cockpitUtilityLayout.js",
"./ui/cockpit/vision": "./src/cockpitVisionPolicy.js",
- "./ui/scenes": "./src/ui/scenes.js"
+ "./ui/scenes": "./src/ui/scenes.js",
+ "./ui/shell": "./src/ui/applicationShell.js",
+ "./ui/styles": "./src/ui/styles.js"
}
}
diff --git a/scripts/package-boundaries.json b/scripts/package-boundaries.json
index 16b60d839..0cc639119 100644
--- a/scripts/package-boundaries.json
+++ b/scripts/package-boundaries.json
@@ -423,5 +423,136 @@
"src/ui/scenePresentation.js"
],
"external": []
+ },
+ "ui-shell": {
+ "exports": [
+ "./ui/shell"
+ ],
+ "modules": [
+ "src/bloom.js",
+ "src/cctvFocusPolicy.js",
+ "src/cctvFocusRequest.js",
+ "src/cockpitMath.js",
+ "src/cockpitTracking.js",
+ "src/cockpitUtilityLayout.js",
+ "src/cockpitVisionPolicy.js",
+ "src/contactsDetectionPolicy.js",
+ "src/contextModePolicy.js",
+ "src/data/detectionPolicy.js",
+ "src/data/installationFeedback.js",
+ "src/data/layerState.js",
+ "src/data/militaryAwarenessEngine.js",
+ "src/keySetupCore.mjs",
+ "src/loadingFeedback.js",
+ "src/locationStatus.js",
+ "src/mapStackChips.js",
+ "src/navigationPolicy.js",
+ "src/panelStackLayout.js",
+ "src/rightRailPolicy.js",
+ "src/splitFlap.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/applicationShell.js",
+ "src/ui/applicationShortcuts.js",
+ "src/ui/cctv.js",
+ "src/ui/cctvBindings.js",
+ "src/ui/cctvCalibration.js",
+ "src/ui/cctvControls.js",
+ "src/ui/cctvFrames.js",
+ "src/ui/cctvPresentation.js",
+ "src/ui/clearLayersControl.js",
+ "src/ui/cockpit.js",
+ "src/ui/cockpitBriefing.js",
+ "src/ui/cockpitCamera.js",
+ "src/ui/cockpitContext.js",
+ "src/ui/cockpitController.js",
+ "src/ui/cockpitDisplayPortal.js",
+ "src/ui/cockpitInput.js",
+ "src/ui/cockpitInstruments.js",
+ "src/ui/cockpitLayout.js",
+ "src/ui/cockpitPresentation.js",
+ "src/ui/cockpitSignals.js",
+ "src/ui/cockpitTrackingController.js",
+ "src/ui/context.js",
+ "src/ui/contextActions.js",
+ "src/ui/contextBindings.js",
+ "src/ui/contextControls.js",
+ "src/ui/contextLayerChanges.js",
+ "src/ui/contextPresentation.js",
+ "src/ui/contextSession.js",
+ "src/ui/contextSubscriptions.js",
+ "src/ui/contextTransactions.js",
+ "src/ui/displayControls.js",
+ "src/ui/effects.js",
+ "src/ui/layerPanel.js",
+ "src/ui/layers.js",
+ "src/ui/leftPanelRail.js",
+ "src/ui/location.js",
+ "src/ui/locationControls.js",
+ "src/ui/locationSearch.js",
+ "src/ui/mapSource.js",
+ "src/ui/mapSourceControls.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/radio.js",
+ "src/ui/radioBindings.js",
+ "src/ui/radioControls.js",
+ "src/ui/radioPresentation.js",
+ "src/ui/radioTunerModel.js",
+ "src/ui/recordingControls.js",
+ "src/ui/rightPanelRail.js",
+ "src/ui/sceneControls.js",
+ "src/ui/scenePresentation.js",
+ "src/ui/scenes.js",
+ "src/ui/shellElements.js",
+ "src/ui/shellFeedback.js",
+ "src/ui/styleParameters.js",
+ "src/ui/surfaceKeyboard.js",
+ "src/ui/visualEffects.js",
+ "src/ui/visualInput.js",
+ "src/ui/visualPresets.js",
+ "src/worldFocus.js",
+ "src/ui/uiLifetime.js"
+ ],
+ "external": [
+ "cesium"
+ ]
+ },
+ "ui-styles": {
+ "exports": [
+ "./ui/styles"
+ ],
+ "modules": [
+ "src/ui/styles.js",
+ "style.css",
+ "src/ui/styles/foundation.css",
+ "src/ui/styles/controls.css",
+ "src/ui/styles/location.css",
+ "src/ui/styles/status.css",
+ "src/ui/styles/cockpit.css",
+ "src/ui/styles/overlays.css",
+ "src/ui/styles/layers.css",
+ "src/ui/styles/radio.css",
+ "src/ui/styles/cctv.css",
+ "src/ui/styles/scenes.css",
+ "src/ui/styles/recording.css",
+ "src/ui/styles/responsive.css",
+ "src/ui/styles/command-dock.css",
+ "src/ui/styles/command-dock-compact.css",
+ "src/ui/styles/command-dock-trays.css",
+ "src/ui/styles/command-dock-sliding.css",
+ "src/ui/styles/voice-cost.css",
+ "src/ui/styles/first-run.css",
+ "src/ui/styles/provider-settings.css"
+ ],
+ "external": []
}
}
diff --git a/scripts/qa-map-source-tray.mjs b/scripts/qa-map-source-tray.mjs
index 5d5854bc6..149290ce9 100644
--- a/scripts/qa-map-source-tray.mjs
+++ b/scripts/qa-map-source-tray.mjs
@@ -918,15 +918,15 @@ try {
});
const waitForQueuedNotice = async (label, timeoutMs = 1000) => {
const deadline = performance.now() + timeoutMs;
- while (styleManager._globalStatusNotice?.label !== label
+ while (styleManager._feedback._globalStatusNotice?.label !== label
&& performance.now() < deadline) {
await new Promise((resolve) => setTimeout(resolve, 16));
}
- return styleManager._globalStatusNotice?.label === label;
+ return styleManager._feedback._globalStatusNotice?.label === label;
};
const baseNow = performance.now();
try {
- styleManager._loadingFeedbackState = {
+ styleManager._feedback._loadingFeedbackState = {
phase: 'idle', visible: false, startedAt: 0, showAt: 0, hideAt: 0,
activeIds: [], batchOutcome: null, terminal: null, operation: null,
};
diff --git a/scripts/qa-radio.mjs b/scripts/qa-radio.mjs
index 40f34240f..d3c26471c 100644
--- a/scripts/qa-radio.mjs
+++ b/scripts/qa-radio.mjs
@@ -1702,8 +1702,8 @@ async function main() {
dataset: { ...stack.dataset },
}];
})),
- preferredLeftPanelId: manager._leftStackPreferredPanelId,
- preferredRightPanelId: manager._rightStackPreferredPanelId,
+ preferredLeftPanelId: manager._panelLayout._leftStackPreferredPanelId,
+ preferredRightPanelId: manager._panelLayout._rightStackPreferredPanelId,
hudMode: manager.hud.getMode(),
hudVariant: manager.hud.getVariant(),
focusId: document.activeElement?.id || null,
@@ -1767,7 +1767,7 @@ async function main() {
const scenePanel = document.getElementById('scene-panel');
const contextPanel = document.getElementById('global-context-panel');
result.tacticalAutoCollapse = {
- latestOpenedPanel: manager._leftStackPreferredPanelId,
+ latestOpenedPanel: manager._panelLayout._leftStackPreferredPanelId,
scenesExpanded: !scenePanel.classList.contains('collapsed'),
dataCollapsed: dataPanel.classList.contains('layout-auto-collapsed'),
dataHiddenWhileScenesOwnsLane: dataPanel.getBoundingClientRect().height === 0
@@ -1814,13 +1814,13 @@ async function main() {
manager.hud.setMode(prior.hudMode);
manager._updateHudButtonState();
await new Promise((resolve) => setTimeout(resolve, 320));
- if (manager._leftStackLayoutFrame !== null) {
- cancelAnimationFrame(manager._leftStackLayoutFrame);
- manager._leftStackLayoutFrame = null;
+ if (manager._panelLayout._leftStackLayoutFrame !== null) {
+ cancelAnimationFrame(manager._panelLayout._leftStackLayoutFrame);
+ manager._panelLayout._leftStackLayoutFrame = null;
}
- if (manager._rightStackLayoutFrame !== null) {
- cancelAnimationFrame(manager._rightStackLayoutFrame);
- manager._rightStackLayoutFrame = null;
+ if (manager._panelLayout._rightStackLayoutFrame !== null) {
+ cancelAnimationFrame(manager._panelLayout._rightStackLayoutFrame);
+ manager._panelLayout._rightStackLayoutFrame = null;
}
for (const [id, state] of Object.entries(prior.panels)) {
const panel = document.getElementById(id);
@@ -1839,18 +1839,18 @@ async function main() {
for (const key of Object.keys(stack.dataset)) delete stack.dataset[key];
Object.assign(stack.dataset, state.dataset);
}
- manager._leftStackPreferredPanelId = prior.preferredLeftPanelId;
- manager._rightStackPreferredPanelId = prior.preferredRightPanelId;
+ manager._panelLayout._leftStackPreferredPanelId = prior.preferredLeftPanelId;
+ manager._panelLayout._rightStackPreferredPanelId = prior.preferredRightPanelId;
localStorage.clear();
for (const [key, value] of Object.entries(prior.storage)) localStorage.setItem(key, value);
await new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve)));
- if (manager._leftStackLayoutFrame !== null) {
- cancelAnimationFrame(manager._leftStackLayoutFrame);
- manager._leftStackLayoutFrame = null;
+ if (manager._panelLayout._leftStackLayoutFrame !== null) {
+ cancelAnimationFrame(manager._panelLayout._leftStackLayoutFrame);
+ manager._panelLayout._leftStackLayoutFrame = null;
}
- if (manager._rightStackLayoutFrame !== null) {
- cancelAnimationFrame(manager._rightStackLayoutFrame);
- manager._rightStackLayoutFrame = null;
+ if (manager._panelLayout._rightStackLayoutFrame !== null) {
+ cancelAnimationFrame(manager._panelLayout._rightStackLayoutFrame);
+ manager._panelLayout._rightStackLayoutFrame = null;
}
// Disclosure restoration can wake the installed layout observers. Let
// those observers settle, then restore the captured stack presentation
@@ -1880,8 +1880,8 @@ async function main() {
result.restoreDetails = {
panelsRestored,
stacksRestored,
- preferredPanelRestored: manager._leftStackPreferredPanelId === prior.preferredLeftPanelId,
- preferredRightPanelRestored: manager._rightStackPreferredPanelId === prior.preferredRightPanelId,
+ preferredPanelRestored: manager._panelLayout._leftStackPreferredPanelId === prior.preferredLeftPanelId,
+ preferredRightPanelRestored: manager._panelLayout._rightStackPreferredPanelId === prior.preferredRightPanelId,
hudModeRestored: manager.hud.getMode() === prior.hudMode,
hudVariantRestored: manager.hud.getVariant() === prior.hudVariant,
storageRestored: JSON.stringify(Object.fromEntries(Object.entries(localStorage))) === JSON.stringify(prior.storage),
@@ -2055,7 +2055,7 @@ async function main() {
detectionMode: manager.getDetectionState().detectionMode,
models3dEnabled: manager._models3dEnabled,
models3dMode: manager._models3dMode,
- preferredLeftPanelId: manager._leftStackPreferredPanelId,
+ preferredLeftPanelId: manager._panelLayout._leftStackPreferredPanelId,
panels: Object.fromEntries([
'data-panel',
'global-context-panel',
@@ -2371,13 +2371,13 @@ async function main() {
if (prior.utilityTop) hud.style.setProperty('--cockpit-utility-top', prior.utilityTop);
else hud.style.removeProperty('--cockpit-utility-top');
await waitForLayout();
- if (manager._leftStackLayoutFrame !== null) {
- cancelAnimationFrame(manager._leftStackLayoutFrame);
- manager._leftStackLayoutFrame = null;
+ if (manager._panelLayout._leftStackLayoutFrame !== null) {
+ cancelAnimationFrame(manager._panelLayout._leftStackLayoutFrame);
+ manager._panelLayout._leftStackLayoutFrame = null;
}
- if (manager._rightStackLayoutFrame !== null) {
- cancelAnimationFrame(manager._rightStackLayoutFrame);
- manager._rightStackLayoutFrame = null;
+ if (manager._panelLayout._rightStackLayoutFrame !== null) {
+ cancelAnimationFrame(manager._panelLayout._rightStackLayoutFrame);
+ manager._panelLayout._rightStackLayoutFrame = null;
}
for (const [id, state] of Object.entries(prior.panels)) {
const panel = document.getElementById(id);
@@ -2388,7 +2388,7 @@ async function main() {
else panel.setAttribute('aria-hidden', state.ariaHidden);
manager._syncPanelCollapseButton(panel);
}
- manager._leftStackPreferredPanelId = prior.preferredLeftPanelId;
+ manager._panelLayout._leftStackPreferredPanelId = prior.preferredLeftPanelId;
localStorage.clear();
for (const [key, value] of Object.entries(prior.storage)) localStorage.setItem(key, value);
await new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve)));
@@ -2411,7 +2411,7 @@ async function main() {
&& panel.getAttribute('style') === state.style
&& panel.getAttribute('aria-hidden') === state.ariaHidden;
})
- && manager._leftStackPreferredPanelId === prior.preferredLeftPanelId
+ && manager._panelLayout._leftStackPreferredPanelId === prior.preferredLeftPanelId
&& JSON.stringify(Object.fromEntries(Object.entries(localStorage))) === JSON.stringify(prior.storage)
&& (!prior.focusId || document.activeElement?.id === prior.focusId);
}
diff --git a/scripts/qa-scene-controls.mjs b/scripts/qa-scene-controls.mjs
index 4c82c347f..916561b40 100644
--- a/scripts/qa-scene-controls.mjs
+++ b/scripts/qa-scene-controls.mjs
@@ -208,6 +208,11 @@ try {
!window.__godsEyeView.styleManager.getControlState().recording,
),
);
+ // Escape can also collapse the Scene accordion. Reopen it through its
+ // installed disclosure before testing the next visible user action.
+ if (await page.$eval('#scene-panel', (panel) => panel.classList.contains('collapsed')))
+ await page.click('[data-collapse-target="scene-panel"]');
+ await page.waitForSelector('#scene-download-btn', { visible: true });
await page.click('#scene-download-btn');
const metadata = await page.evaluate(async () =>
JSON.parse(await window.__sceneExports.at(-1)),
diff --git a/scripts/qa-ui-disposal.mjs b/scripts/qa-ui-disposal.mjs
index 2808adfa0..7bf0b7649 100644
--- a/scripts/qa-ui-disposal.mjs
+++ b/scripts/qa-ui-disposal.mjs
@@ -33,7 +33,8 @@ try {
const ui = window.__godsEyeView.styleManager;
const counts = {};
const watchObserver = (name) => {
- const observer = ui[name];
+ const owner = name === '_draggableResizeObserver' ? ui._panelPosition : ui._panelLayout;
+ const observer = owner[name];
if (!observer) return false;
const disconnect = observer.disconnect.bind(observer);
counts[name] = 0;
@@ -113,6 +114,10 @@ try {
const disposal = ui.dispose();
const focusBefore = document.activeElement;
const stoppedBeforeRestoration =
+ ui._panelLayout.destroyed &&
+ ui._panelPosition.destroyed &&
+ ui._feedback.destroyed &&
+ ui._recording.destroyed &&
cockpit.destroyed &&
portal.stopped &&
cockpit._listenerRemovers.length === 0 &&
diff --git a/src/cameraHandoff.test.mjs b/src/cameraHandoff.test.mjs
index 3ef065443..eecd53bbc 100644
--- a/src/cameraHandoff.test.mjs
+++ b/src/cameraHandoff.test.mjs
@@ -7,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.js'), 'utf8');
+const ui = fs.readFileSync(path.join(ROOT, 'src', 'ui', 'applicationShell.js'), 'utf8');
const firms = fs.readFileSync(path.join(ROOT, 'src', 'data', 'firmsHeatmap.js'), 'utf8');
const vessels = fs.readFileSync(path.join(ROOT, 'src', 'data', 'aisLiveVessels.js'), 'utf8');
const voice = fs.readFileSync(path.join(ROOT, 'src', 'voice', 'gevActions.js'), 'utf8');
diff --git a/src/cctvFocusPolicy.test.mjs b/src/cctvFocusPolicy.test.mjs
index 5f4abd82e..3574bdf78 100644
--- a/src/cctvFocusPolicy.test.mjs
+++ b/src/cctvFocusPolicy.test.mjs
@@ -73,7 +73,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.js', import.meta.url), 'utf8');
+ const uiSource = readFileSync(new URL('./ui/applicationShell.js', import.meta.url), 'utf8');
assert.match(uiSource, /await runCctvLayerEnableTransition\(\{/);
});
@@ -114,6 +114,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.js', import.meta.url), 'utf8');
+ const uiSource = readFileSync(new URL('./ui/applicationShell.js', import.meta.url), 'utf8');
assert.match(uiSource, /shouldFocus: \(\) => !this\._disposed && this\._dataManager\.isEnabled\('cctv'\)/);
});
diff --git a/src/cctvFocusRequest.test.mjs b/src/cctvFocusRequest.test.mjs
index fee23eb5e..64b4c20de 100644
--- a/src/cctvFocusRequest.test.mjs
+++ b/src/cctvFocusRequest.test.mjs
@@ -57,7 +57,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.js', import.meta.url), 'utf8');
+ const uiSource = readFileSync(new URL('./ui/applicationShell.js', import.meta.url), 'utf8');
assert.match(
uiSource,
/_removeCctvRequestFocusListener = registerCctvFocusRequestListener\([\s\S]+this\._cctvRequestFocusHandler/,
diff --git a/src/cockpitMarkup.test.mjs b/src/cockpitMarkup.test.mjs
index e67fae046..aa4b83792 100644
--- a/src/cockpitMarkup.test.mjs
+++ b/src/cockpitMarkup.test.mjs
@@ -1,3 +1,6 @@
+import { PanelLayoutController } from './ui/panelLayoutController.js';
+import { readShellElements } from './ui/shellElements.js';
+import { readStylesheet } from './testSupport/readStylesheet.mjs';
import { SceneControls } from './ui/sceneControls.js';
import { isRenderedOnScreen } from './ui/cockpitPresentation.js';
import { onKeyDown as cockpitKeyDown } from './ui/cockpitInput.js';
@@ -23,8 +26,8 @@ import { fileURLToPath } from 'node:url';
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
const html = fs.readFileSync(path.join(ROOT, 'index.html'), 'utf8');
-const ui = fs.readFileSync(path.join(ROOT, 'src', 'ui.js'), 'utf8');
-const css = fs.readFileSync(path.join(ROOT, 'style.css'), 'utf8');
+const ui = fs.readFileSync(path.join(ROOT, 'src', 'ui', 'applicationShell.js'), 'utf8');
+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', 'manager.js'), 'utf8');
const contextLayer = fs.readFileSync(path.join(ROOT, 'src', 'data', 'militaryAwareness.js'), 'utf8');
@@ -299,7 +302,7 @@ test('Display orders 3D above Celestial, Clean UI below it, and Parameters below
);
assert.equal((html.match(/id="param-slider-panel"/g) || []).length, 1, 'Parameters must have one DOM owner');
assert.match(
- ui,
+ PanelLayoutController.prototype._initRightPanelAdaptiveLayout.toString(),
/const detectionGroup = this\._detectionBtn\?\.closest\('\.pp-toggle-group'\);[\s\S]*?detectionGroup\.after\(this\._sliderPanel\)/,
);
assert.match(
@@ -476,7 +479,7 @@ test('Reset releases Contact camera ownership through its selection-preserving r
assert.ok(resetStart >= 0);
assert.ok(contextRelease > resetStart);
assert.ok(satelliteRelease > contextRelease);
- assert.match(ui, /this\._cockpitResetGlobeBtn = document\.getElementById\('cockpit-reset-globe'\)/);
+ assert.match(readShellElements.toString(), /_cockpitResetGlobeBtn: document\.getElementById\('cockpit-reset-globe'\)/);
const controls = fs.readFileSync(path.join(ROOT, 'src', 'ui', 'locationControls.js'), 'utf8');
assert.match(ui, /resetButtons: \[this\._resetGlobeBtn, this\._cockpitResetGlobeBtn\]/);
assert.match(ui, /onReset: \(\) => this\.resetToGlobeView\(\)/);
@@ -530,8 +533,9 @@ test('Cockpit Radio station changes preserve first-person camera ownership', ()
});
test('Cockpit panel corridors reserve the owned topline readouts', () => {
- const leftObstacles = ui.match(/const LEFT_STACK_OBSTACLE_SELECTOR = \[([\s\S]*?)\]\.join/);
- const rightObstacles = ui.match(/const RIGHT_STACK_OBSTACLE_SELECTOR = \[([\s\S]*?)\]\.join/);
+ const layout = fs.readFileSync(new URL('./ui/panelLayoutController.js', import.meta.url), 'utf8');
+ const leftObstacles = layout.match(/const LEFT_STACK_OBSTACLE_SELECTOR = \[([\s\S]*?)\]\.join/);
+ const rightObstacles = layout.match(/const RIGHT_STACK_OBSTACLE_SELECTOR = \[([\s\S]*?)\]\.join/);
assert.ok(leftObstacles && rightObstacles, 'responsive panel obstacle selectors are missing');
assert.match(leftObstacles[1], /#cockpit-hud \.cockpit-topline/);
assert.match(rightObstacles[1], /#cockpit-hud \.cockpit-topline/);
@@ -569,8 +573,8 @@ test('Cockpit panel corridors reserve the owned topline readouts', () => {
/function isRenderedOnScreen\(element\) \{[\s\S]*?style\.display === 'none'\s*\|\|\s*style\.visibility === 'hidden'\s*\|\|\s*Number\(style\.opacity\) === 0[\s\S]*?rect\.width > 0 && rect\.height > 0;/,
);
assert.match(
- ui,
- /_leftStackHudTransitionHandler = \(event\) => \{[\s\S]*?_scheduleLeftPanelLayout\(\{ reconsiderAutoCollapse: true \}\);[\s\S]*?this\.cockpitView\?\.scheduleContextLayout\(\);/,
+ layout,
+ /_leftStackHudTransitionHandler = \(event\) => \{[\s\S]*?_scheduleLeftPanelLayout\(\{ reconsiderAutoCollapse: true \}\);[\s\S]*?this\.scheduleCockpitLayout\(\);/,
'the strip must remeasure on the same HUD fade the accordion does — the REC '
+ 'readout keeps its rect until the transition ends',
);
diff --git a/src/contactsDetectionPolicy.test.mjs b/src/contactsDetectionPolicy.test.mjs
index 4307cc182..331151f8e 100644
--- a/src/contactsDetectionPolicy.test.mjs
+++ b/src/contactsDetectionPolicy.test.mjs
@@ -28,7 +28,7 @@ import {
import { canonicalizeDensity } from './data/detectionPolicy.js';
// Follow the UI wiring and its extracted preset definitions.
-const uiSource = fs.readFileSync(new URL('./ui.js', import.meta.url), 'utf8')
+const uiSource = fs.readFileSync(new URL('./ui/applicationShell.js', import.meta.url), 'utf8')
+ '\n' + fs.readFileSync(new URL('./ui/visualPresets.js', import.meta.url), 'utf8');
/**
diff --git a/src/contextSessionOrdering.test.mjs b/src/contextSessionOrdering.test.mjs
index 7fe6a8288..af836c793 100644
--- a/src/contextSessionOrdering.test.mjs
+++ b/src/contextSessionOrdering.test.mjs
@@ -23,7 +23,7 @@ import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import { fileURLToPath } from 'node:url';
-const src = readFileSync(fileURLToPath(new URL('./ui.js', import.meta.url)), 'utf8');
+const src = readFileSync(fileURLToPath(new URL('./ui/applicationShell.js', import.meta.url)), 'utf8');
const handler = _handleContextLayerChange.toString();
diff --git a/src/contextTabKeyboard.test.mjs b/src/contextTabKeyboard.test.mjs
index 20b972967..49a640d97 100644
--- a/src/contextTabKeyboard.test.mjs
+++ b/src/contextTabKeyboard.test.mjs
@@ -1,3 +1,4 @@
+import { readStylesheet } from './testSupport/readStylesheet.mjs';
import { _selectContextMode } from './ui/contextTransactions.js';
import { _syncContextModeButtons } from './ui/contextPresentation.js';
import { _initGlobalContextPanel } from './ui/contextBindings.js';
@@ -11,8 +12,8 @@ import { test } from 'node:test';
import assert from 'node:assert/strict';
const html = readFileSync(new URL('../index.html', import.meta.url), 'utf8');
-const ui = readFileSync(new URL('./ui.js', import.meta.url), 'utf8');
-const css = readFileSync(new URL('../style.css', import.meta.url), 'utf8');
+const ui = readFileSync(new URL('./ui/applicationShell.js', import.meta.url), 'utf8');
+const css = readStylesheet(new URL('../style.css', import.meta.url));
test('Contacts and Space Missions both participate in the ordinary Tab sequence', () => {
for (const id of ['global-context-flights-btn', 'global-context-missions-btn']) {
diff --git a/src/creditAttribution.test.mjs b/src/creditAttribution.test.mjs
index dc1c17136..7dd7d0b70 100644
--- a/src/creditAttribution.test.mjs
+++ b/src/creditAttribution.test.mjs
@@ -1,3 +1,4 @@
+import { readStylesheet } from './testSupport/readStylesheet.mjs';
import assert from 'node:assert/strict';
import fs from 'node:fs';
import path from 'node:path';
@@ -5,8 +6,8 @@ import test from 'node:test';
import { fileURLToPath } from 'node:url';
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
-const css = fs.readFileSync(path.join(ROOT, 'style.css'), 'utf8');
-const ui = fs.readFileSync(path.join(ROOT, 'src', 'ui.js'), 'utf8');
+const css = readStylesheet(path.join(ROOT, 'style.css'));
+const ui = fs.readFileSync(path.join(ROOT, 'src', 'ui', 'applicationShell.js'), 'utf8');
/*
* Required-attribution keep-out pin.
@@ -436,9 +437,10 @@ test('custom properties inside modelled offsets are provably non-negative', () =
assert.doesNotMatch(decl.value, /-\s*\d/, `${decl.prop} has a negative CSS default: ${decl.value}`);
assert.match(decl.value, /^(0|0px)$/, `${decl.prop} default is not a vetted shape: ${decl.value}`);
}
- const start = ui.indexOf('_updateCommandDockTrayStack() {');
+ const layout = fs.readFileSync(new URL('./ui/panelLayoutController.js', import.meta.url), 'utf8');
+ const start = layout.indexOf('_updateCommandDockTrayStack() {');
assert.ok(start > 0, '_updateCommandDockTrayStack is missing');
- const writer = ui.slice(start, start + 1800);
+ const writer = layout.slice(start, start + 1800);
// Every value traces back to a rect height, floored at 0 and rounded up.
assert.match(writer, /const locationHeight = [\s\S]{0,120}?getBoundingClientRect\(\)\.height \|\| 0;/);
assert.match(writer, /const presetsHeight = [\s\S]{0,120}?getBoundingClientRect\(\)\.height \|\| 0;/);
diff --git a/src/data/detectionHost.test.mjs b/src/data/detectionHost.test.mjs
index b51776ff5..e76f6a75f 100644
--- a/src/data/detectionHost.test.mjs
+++ b/src/data/detectionHost.test.mjs
@@ -671,7 +671,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.js', import.meta.url), 'utf8')
+ const uiSource = readFileSync(new URL('../ui/applicationShell.js', import.meta.url), 'utf8')
+ '\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 2c282e75e..0080ef9ab 100644
--- a/src/data/layerState.test.mjs
+++ b/src/data/layerState.test.mjs
@@ -371,7 +371,7 @@ test('a fresh boot starts 3D aircraft ON in proximity — codec, both layers, an
assert.match(source, /^let _models3dMode = 'proximity';/m,
`${name}: and starts in proximity, matching the codec default`);
}
- const ui = await readFile(new URL('../ui.js', import.meta.url), 'utf8');
+ const ui = await readFile(new URL('../ui/applicationShell.js', import.meta.url), 'utf8');
assert.match(ui, /^\s*this\._models3dEnabled = true;$/m,
'ui.js: the DISPLAY rail believes 3D is on before any layer-state sync arrives');
assert.match(ui, /this\._models3dMode = 'proximity';/,
diff --git a/src/data/tr3bRegistry.test.mjs b/src/data/tr3bRegistry.test.mjs
index f7f00b0bd..1099127fb 100644
--- a/src/data/tr3bRegistry.test.mjs
+++ b/src/data/tr3bRegistry.test.mjs
@@ -270,7 +270,7 @@ test('conversions are session-scoped and no lifecycle path clears them', async (
// personally clicked, and re-tracking the same aircraft after a layer restart
// should still show the triangle. Only a page reload resets it — so no
// production code may clear the registry.
- for (const name of ['flights.js', 'militaryFlights.js', '../ui.js']) {
+ for (const name of ['flights.js', 'militaryFlights.js', '../ui/applicationShell.js']) {
const source = await readFile(new URL(`./${name}`, import.meta.url), 'utf8');
assert.doesNotMatch(source, /clearTr3bRegistry/,
`${name}: teardown must not clear session conversions`);
diff --git a/src/firstRunExperience.test.mjs b/src/firstRunExperience.test.mjs
index b19f082ae..002959ba9 100644
--- a/src/firstRunExperience.test.mjs
+++ b/src/firstRunExperience.test.mjs
@@ -1,3 +1,4 @@
+import { readStylesheet } from './testSupport/readStylesheet.mjs';
import { GEV_REALTIME_TOOLS } from '../server/providers/openai/tools.js';
import test from 'node:test';
import assert from 'node:assert/strict';
@@ -183,7 +184,7 @@ test('no storage is touched from a default parameter position', () => {
test('the JS and CSS lists of screen-claiming surfaces stay in step', () => {
const module = fs.readFileSync(new URL('./firstRunExperience.js', import.meta.url), 'utf8');
- const css = fs.readFileSync(new URL('../style.css', import.meta.url), 'utf8');
+ const css = readStylesheet(new URL('../style.css', import.meta.url));
assert.deepEqual(
[...EXCLUSIVE_SURFACE_CLASSES].sort(),
@@ -233,7 +234,7 @@ test('the key handler refuses to act for a card that is not really on screen', (
test('an overlay with NO class to watch still disarms the launcher', () => {
const module = fs.readFileSync(new URL('./firstRunExperience.js', import.meta.url), 'utf8');
- const css = fs.readFileSync(new URL('../style.css', import.meta.url), 'utf8');
+ const css = readStylesheet(new URL('../style.css', import.meta.url));
// The repro, pinned as the stacking it actually is: the attribution lightbox
// is full-screen ABOVE the card and announces itself with nothing. The card
@@ -345,7 +346,7 @@ test('a surface class that never clears is an ACCEPTED no-show, not a timer', ()
test('the scroll fade only appears when the list really overflows', () => {
const module = fs.readFileSync(new URL('./firstRunExperience.js', import.meta.url), 'utf8');
- const css = fs.readFileSync(new URL('../style.css', import.meta.url), 'utf8');
+ const css = readStylesheet(new URL('../style.css', import.meta.url));
// A fade on a card where all five tiles fit promises a sixth mission that does
// not exist, which is worse than no affordance at all.
assert.match(module, /const overflows = choiceList\.scrollHeight > choiceList\.clientHeight \+ 1;/);
@@ -551,7 +552,7 @@ test('the decision table is written down where the next editor will read it', ()
test('markup, startup ordering and accessibility remain pinned', () => {
const html = fs.readFileSync(new URL('../index.html', import.meta.url), 'utf8');
const startup = fs.readFileSync(new URL('./standalone/startupChrome.js', import.meta.url), 'utf8');
- const css = fs.readFileSync(new URL('../style.css', import.meta.url), 'utf8');
+ const css = readStylesheet(new URL('../style.css', import.meta.url));
assert.match(html, /id="first-run-launcher" role="dialog"[^>]*aria-labelledby="first-run-title"[^>]*hidden/);
assert.equal((html.match(/data-first-run-choice=/g) || []).length, 4);
@@ -631,7 +632,7 @@ test('the launcher keeps focus, restores it, and never disables the focused butt
});
test('the DISPLAY rail starts collapsed on a first run, and a stored choice wins', () => {
- const ui = fs.readFileSync(new URL('./ui.js', import.meta.url), 'utf8');
+ const ui = fs.readFileSync(new URL('./ui/panelPositionControls.js', import.meta.url), 'utf8');
// The rail opened by default to advertise HUD / DETECT / 3D. Those default ON
// now, so it was opening to offer controls for things already happening —
// while competing with the mission card for the one first impression there is.
diff --git a/src/keyboardFocusStyles.test.mjs b/src/keyboardFocusStyles.test.mjs
index 3e6cfa541..5244cffdc 100644
--- a/src/keyboardFocusStyles.test.mjs
+++ b/src/keyboardFocusStyles.test.mjs
@@ -1,8 +1,9 @@
+import { readStylesheet } from './testSupport/readStylesheet.mjs';
import test from 'node:test';
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
-const css = readFileSync(new URL('../style.css', import.meta.url), 'utf8');
+const css = readStylesheet(new URL('../style.css', import.meta.url));
function ruleBody(selector) {
const start = css.indexOf(selector);
diff --git a/src/loadingFeedback.test.mjs b/src/loadingFeedback.test.mjs
index 7537efb06..cb26795e6 100644
--- a/src/loadingFeedback.test.mjs
+++ b/src/loadingFeedback.test.mjs
@@ -1,3 +1,4 @@
+import { readStylesheet } from './testSupport/readStylesheet.mjs';
import test from 'node:test';
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
@@ -89,15 +90,15 @@ 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.js', import.meta.url), 'utf8');
+ 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\)/);
assert.match(handler, /this\.initialRestorePromise\.then\(showAfterStartupCover\)/);
- assert.match(handler, /requestAnimationFrame\(\(\) => \{/);
- assert.match(handler, /startupCover\.addEventListener\('transitionend', showOnce, \{ once: true \}\)/);
- assert.match(handler, /fallbackTimer = setTimeout\(showOnce, 1000\)/);
+ assert.match(handler, /this\._lifetime\.frame\(\(\) => \{/);
+ assert.match(handler, /this\._lifetime\.listen\(startupCover, 'transitionend', showOnce, \{ once: true \}\)/);
+ assert.match(handler, /fallbackTimer = this\._lifetime\.timeout\(showOnce, 1000\)/);
assert.doesNotMatch(handler, /this\._showToast\(message\)/);
assert.doesNotMatch(handler, /pushCockpitSignal/);
assert.match(handler, /result\.classification === 'pending'/);
@@ -195,13 +196,13 @@ 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.js', import.meta.url), 'utf8');
+ const ui = readFileSync(new URL('./ui/applicationShell.js', import.meta.url), 'utf8');
const html = 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\._globalStatusNotice = null;/);
+ assert.match(dispose, /this\._feedback\._globalStatusNotice = null;/);
assert.match(dispose, /this\._shareTrackingNoticeGeneration \+= 1;/);
assert.match(html, /
/);
});
@@ -235,7 +236,7 @@ test('reveals sustained loading and then a bounded completion state', () => {
});
test('terminal loading feedback centers its label without an empty detail slot', () => {
- const css = readFileSync(new URL('../style.css', import.meta.url), 'utf8');
+ const css = readStylesheet(new URL('../style.css', import.meta.url));
assert.match(
css,
/#global-loading-status:is\(\[data-state='complete'\], \[data-state='cancelled'\], \[data-state='error'\]\)\s*\{[\s\S]*?justify-content:\s*center;[\s\S]*?text-align:\s*center;/,
@@ -531,8 +532,8 @@ test('the settled traffic chip shows exactly one percentage — the coverage it
});
test('the chip renderer clears the progress slot instead of stranding the last value', () => {
- const ui = readFileSync(new URL('./ui.js', import.meta.url), 'utf8');
- const css = readFileSync(new URL('../style.css', import.meta.url), 'utf8');
+ const ui = readFileSync(new URL('./ui/shellFeedback.js', import.meta.url), 'utf8');
+ const css = readStylesheet(new URL('../style.css', import.meta.url));
const start = ui.indexOf(' _updateTrafficSyncChip(');
assert.ok(start > 0, '_updateTrafficSyncChip is missing');
const body = ui.slice(start, ui.indexOf('\n }', start));
@@ -620,7 +621,7 @@ test('aggregates Mapped Installations refresh beside CCTV without changing eithe
// stack layout contract — the ticker's lifecycle is pinned against ui.js
// source. (perf rebase 2026-08-17)
test('the loading ticker never runs hidden and stops after loading and notices settle', () => {
- const ui = readFileSync(new URL('./ui.js', import.meta.url), 'utf8');
+ const ui = readFileSync(new URL('./ui/shellFeedback.js', import.meta.url), 'utf8');
// Scope every assertion to _armLoadingFeedbackTicker's own body. The
// neighbouring _startTrafficChipTicker is a deliberately PERMANENT 500ms
// safety-net poll, so its `if (document.hidden) return;` is correct there
@@ -667,17 +668,17 @@ test('the loading ticker never runs hidden and stops after loading and notices s
// 4. Returning to visible resamples, so a batch that finished while hidden
// is reconciled and a still-running one re-arms its ticker. The handler
// must live on the class that OWNS _updateGlobalLoadingFeedback
- // (StyleManager) — wiring it into a neighbouring controller instead
+ // (ShellFeedback) — wiring it into a neighbouring controller instead
// throws on every visibilitychange — and must be torn down with the rest.
assert.match(
ui,
- /this\._loadingVisibilityHandler = \(\) => \{\s*if \(!document\.hidden\) this\._updateGlobalLoadingFeedback\(\);\s*\};\s*document\.addEventListener\('visibilitychange', this\._loadingVisibilityHandler\);/,
+ /this\._loadingVisibilityHandler = \(\) => \{\s*if \(!document\.hidden\) this\._updateGlobalLoadingFeedback\(\);\s*else this\._stopLoadingFeedbackTicker\(\);\s*\};\s*document\.addEventListener\('visibilitychange', this\._loadingVisibilityHandler\);/,
'visibilitychange must resample the chip on return',
);
- const styleManager = ui.slice(ui.indexOf('export class StyleManager'));
+ const styleManager = ui.slice(ui.indexOf('export class ShellFeedback'));
assert.ok(
styleManager.includes('this._loadingVisibilityHandler'),
- 'the resample handler must be owned by StyleManager, which defines _updateGlobalLoadingFeedback',
+ 'the resample handler must be owned by ShellFeedback, which defines _updateGlobalLoadingFeedback',
);
assert.match(
ui,
diff --git a/src/locationReadouts.test.mjs b/src/locationReadouts.test.mjs
index 62c11e36f..cfe87640b 100644
--- a/src/locationReadouts.test.mjs
+++ b/src/locationReadouts.test.mjs
@@ -5,7 +5,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.js'), 'utf8');
+const ui = fs.readFileSync(path.join(ROOT, 'src', 'ui', 'applicationShell.js'), 'utf8');
const director = fs.readFileSync(path.join(ROOT, 'src', 'scenes', 'director.js'), 'utf8');
/** Source of the free-text LOCATION search handler (Enter on #location-search). */
diff --git a/src/mapSourceFocus.test.mjs b/src/mapSourceFocus.test.mjs
index 985bd5658..dd7b43ea2 100644
--- a/src/mapSourceFocus.test.mjs
+++ b/src/mapSourceFocus.test.mjs
@@ -4,7 +4,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.js', import.meta.url), 'utf8');
+const source = readFileSync(new URL('./ui/applicationShell.js', import.meta.url), 'utf8');
const markup = readFileSync(new URL('../index.html', import.meta.url), 'utf8');
const locationMarkup = markup.slice(markup.indexOf('
]*\bid="location-bar-toggle"[^>]*)>([\s\S]*?)<\/button>/);
@@ -123,6 +123,8 @@ function harness({ hidden = false, selected = true, noChips = false } = {}) {
const claims = [];
let shareSyncs = 0;
const manager = {
+ _lifetime: { frame() {} },
+ _panelLayout: { _leftStackPreferredPanelId: null, _rightStackPreferredPanelId: null },
...methods,
_savePanelCollapsedState(...args) { saves.push(args); },
_scheduleLeftPanelLayout() {}, _scheduleRightPanelLayout() {},
diff --git a/src/mapStackChips.test.mjs b/src/mapStackChips.test.mjs
index 8ede54da1..7bb2dd6e7 100644
--- a/src/mapStackChips.test.mjs
+++ b/src/mapStackChips.test.mjs
@@ -1,3 +1,4 @@
+import { readStylesheet } from './testSupport/readStylesheet.mjs';
// MAP STACK chip row — the dropdown's replacement control surface.
//
// The owner's complaint was two clicks (open panel → open dropdown) to change
@@ -262,7 +263,7 @@ test('a missing row or document is inert rather than throwing during boot', () =
});
test('the active cyan survives hover', () => {
- const css = readFileSync(new URL('../style.css', import.meta.url), 'utf8');
+ const css = readStylesheet(new URL('../style.css', import.meta.url));
const hover = css.indexOf('.map-stack-chip:hover');
const active = css.indexOf('.map-stack-chip.active {');
const unavailable = css.indexOf('.map-stack-chip.unavailable');
@@ -282,7 +283,7 @@ test('the keyboard focus ring survives on the ACTIVE chip', () => {
// `outline: none` — so a focus state built only from those properties is
// INVISIBLE on the active chip. The ring must live on a property no other
// chip-state rule sets.
- const css = readFileSync(new URL('../style.css', import.meta.url), 'utf8');
+ const css = readStylesheet(new URL('../style.css', import.meta.url));
const stripComments = (text) => text.replace(/\/\*[\s\S]*?\*\//g, '');
const chipRules = [...css.matchAll(/([^{}]*\.map-stack-chip[^{}]*)\{([^{}]*)\}/g)]
.map(([, selector, body], order) => ({
@@ -329,7 +330,7 @@ test('the keyboard focus ring survives on the ACTIVE chip', () => {
test('the Visual Presets tray owns Map Source and the retired left panel is absent', () => {
const html = readFileSync(new URL('../index.html', import.meta.url), 'utf8');
- const ui = readFileSync(new URL('./ui.js', import.meta.url), 'utf8');
+ const ui = readFileSync(new URL('./ui/applicationShell.js', import.meta.url), 'utf8');
assert.doesNotMatch(html, /map-stack-select/, 'the SOURCE dropdown is replaced by the chip row');
assert.match(
diff --git a/src/overlays/worldOverlay.test.mjs b/src/overlays/worldOverlay.test.mjs
index e24f4bda3..dac9abdcb 100644
--- a/src/overlays/worldOverlay.test.mjs
+++ b/src/overlays/worldOverlay.test.mjs
@@ -1,3 +1,4 @@
+import { readStylesheet } from '../testSupport/readStylesheet.mjs';
import test from 'node:test';
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
@@ -467,7 +468,7 @@ test('lifecycle is idempotent and teardown removes listeners, observers, and DOM
'the surface is runtime host-owned, not static markup',
);
assert.match(
- readFileSync(new URL('../../style.css', import.meta.url), 'utf8'),
+ readStylesheet(new URL('../../style.css', import.meta.url)),
/#world-overlay-detection-surface,\n#world-overlay-canvas \{\n position: absolute;\n inset: 0;[\s\S]*?pointer-events: none;/,
'both host surfaces share absolute positioning and pointer passthrough',
);
@@ -484,7 +485,7 @@ test('lifecycle is idempotent and teardown removes listeners, observers, and DOM
});
/** The shipped stylesheet, read once — fixtures must stack as production does. */
-const SHIPPED_CSS = readFileSync(new URL('../../style.css', import.meta.url), 'utf8');
+const SHIPPED_CSS = readStylesheet(new URL('../../style.css', import.meta.url));
/**
* Position + z-index the SHIPPED stylesheet gives a selector. Fixtures are built
@@ -551,7 +552,7 @@ test('no ancestor isolates the detection surface, so `screen` reaches the scene'
// builds and evaluates each node against the shipped stylesheet.
const env = installMockEnvironment();
initWorldOverlay(env.viewer);
- const css = readFileSync(new URL('../../style.css', import.meta.url), 'utf8');
+ const css = readStylesheet(new URL('../../style.css', import.meta.url));
const surface = env.document.getElementById('world-overlay-detection-surface');
assert.ok(surface, 'the host owns a detection surface');
diff --git a/src/panelEscape.test.mjs b/src/panelEscape.test.mjs
index cbcd4f591..c63e084f7 100644
--- a/src/panelEscape.test.mjs
+++ b/src/panelEscape.test.mjs
@@ -8,7 +8,7 @@ import assert from 'node:assert/strict';
import fs from 'node:fs';
import test from 'node:test';
-const source = fs.readFileSync(new URL('./ui.js', import.meta.url), 'utf8');
+const source = fs.readFileSync(new URL('./ui/applicationShell.js', import.meta.url), 'utf8');
function method(name, nextName) {
const start = source.indexOf(` ${name}(`);
diff --git a/src/panelStackLayout.test.mjs b/src/panelStackLayout.test.mjs
index f6e2b2644..29fa17599 100644
--- a/src/panelStackLayout.test.mjs
+++ b/src/panelStackLayout.test.mjs
@@ -1,3 +1,4 @@
+import { readStylesheet } from './testSupport/readStylesheet.mjs';
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import test from 'node:test';
@@ -135,11 +136,11 @@ 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.js', import.meta.url), 'utf8');
- const css = readFileSync(new URL('../style.css', import.meta.url), 'utf8');
+ const ui = readFileSync(new URL('./ui/applicationShell.js', import.meta.url), 'utf8');
+ 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'\)/);
- assert.match(ui, /classList\.remove\('collapsed', 'layout-auto-collapsed'\)/);
+ assert.match(readFileSync(new URL('./ui/panelLayoutController.js', import.meta.url), 'utf8'), /classList\.remove\('collapsed', 'layout-auto-collapsed'\)/);
assert.match(ui, /reconsiderAutoCollapse/);
assert.match(
ui,
@@ -150,13 +151,13 @@ test('desktop panel lanes use per-panel allocations and presentation-only auto-c
/_scheduleLeftPanelLayout\(\{[\s\S]*?reconsiderAutoCollapse: this\._leftPanelStack\?\.contains\(panelEl\) === true/,
);
assert.match(rails, /collapseLaterPanels: shouldFocus && hud\.variant === 'tactical'/);
- assert.match(ui, /this\._leftStackPreferredPanelId = leftOwnerPanel\.id;/);
+ assert.match(ui, /this\._panelLayout\._leftStackPreferredPanelId = leftOwnerPanel\.id;/);
assert.match(
leftRail,
/preferredExpandedPanel[\s\S]*?\[\s*preferredExpandedPanel,\s*\.\.\.expandedPanelsInDomOrder/,
'the latest explicitly opened left panel must receive primary allocation',
);
- assert.match(ui, /this\._rightStackPreferredPanelId = rightOwnerPanel\.id;/);
+ assert.match(ui, /this\._panelLayout\._rightStackPreferredPanelId = rightOwnerPanel\.id;/);
assert.match(
rightRail,
/panel\.id === preferredPanelId[\s\S]*?\[\s*preferredExpandedPanel,\s*\.\.\.expandedPanelsInDomOrder/,
@@ -189,7 +190,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.js', import.meta.url), 'utf8');
+ const ui = readFileSync(new URL('./ui/applicationShell.js', import.meta.url), 'utf8');
const sharelink = readFileSync(new URL('./sharelink.js', import.meta.url), 'utf8');
assert.match(
@@ -212,8 +213,8 @@ 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.js', import.meta.url), 'utf8');
- const css = readFileSync(new URL('../style.css', import.meta.url), 'utf8');
+ const ui = readFileSync(new URL('./ui/applicationShell.js', import.meta.url), 'utf8');
+ 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;/);
assert.match(ui, /readDisplayScrollTop: \(\) => this\._displayPortalScrollRestoreOwner === 'standard'[\s\S]*?this\._standardDisplayScrollTop[\s\S]*?this\._ppToggles\?\.scrollTop \|\| 0/);
@@ -234,7 +235,7 @@ test('parameterized Display presets keep one stable scroll owner', () => {
});
test('expanded Display uses its container shell instead of a nested header card', () => {
- const css = readFileSync(new URL('../style.css', import.meta.url), 'utf8');
+ const css = readStylesheet(new URL('../style.css', import.meta.url));
assert.match(
css,
@@ -248,7 +249,7 @@ test('expanded Display uses its container shell instead of a nested header card'
});
test('expanded left panels integrate their headers with the container shell', () => {
- const css = readFileSync(new URL('../style.css', import.meta.url), 'utf8');
+ const css = readStylesheet(new URL('../style.css', import.meta.url));
assert.match(
css,
@@ -262,7 +263,7 @@ test('expanded left panels integrate their headers with the container shell', ()
test('Map Source uses five compact tiles in the bottom Visual Presets tray', () => {
const html = readFileSync(new URL('../index.html', import.meta.url), 'utf8');
- const css = readFileSync(new URL('../style.css', import.meta.url), 'utf8');
+ const css = readStylesheet(new URL('../style.css', import.meta.url));
assert.doesNotMatch(html, /id="stack-panel"/);
assert.match(html, /id="control-panel"[\s\S]*?class="map-source-section"[\s\S]*?id="map-stack-chips"/);
@@ -275,7 +276,7 @@ test('Map Source uses five compact tiles in the bottom Visual Presets tray', ()
test('expanded right panels highlight the title divider without changing collapsed launchers', () => {
const html = readFileSync(new URL('../index.html', import.meta.url), 'utf8');
- const css = readFileSync(new URL('../style.css', import.meta.url), 'utf8');
+ const css = readStylesheet(new URL('../style.css', import.meta.url));
assert.match(
html,
diff --git a/src/radioMarkup.test.mjs b/src/radioMarkup.test.mjs
index 303a54246..42e722520 100644
--- a/src/radioMarkup.test.mjs
+++ b/src/radioMarkup.test.mjs
@@ -1,3 +1,4 @@
+import { readStylesheet } from './testSupport/readStylesheet.mjs';
import { readFileSync as readRadioSource } from 'node:fs';
const radioBindings = readRadioSource(new URL('./ui/radioBindings.js', import.meta.url), 'utf8');
const radioPresentation = readRadioSource(new URL('./ui/radioPresentation.js', import.meta.url), 'utf8');
@@ -8,12 +9,12 @@ import { readFileSync } from 'node:fs';
import test from 'node:test';
const html = readFileSync(new URL('../index.html', import.meta.url), 'utf8');
-const ui = readFileSync(new URL('./ui.js', import.meta.url), 'utf8');
+const ui = readFileSync(new URL('./ui/applicationShell.js', import.meta.url), 'utf8');
const radio = readFileSync(new URL('./data/radio.js', import.meta.url), 'utf8');
const rocketLaunches = readFileSync(new URL('./data/rocketLaunches.js', import.meta.url), 'utf8');
const realtime = readFileSync(new URL('./voice/gevRealtime.js', import.meta.url), 'utf8');
const voice = ['tools', 'instructions'].map(name => readFileSync(new URL(`../server/providers/openai/${name}.js`, import.meta.url), 'utf8')).join('\n');
-const css = readFileSync(new URL('../style.css', import.meta.url), 'utf8');
+const css = readStylesheet(new URL('../style.css', import.meta.url));
/** Parse the Realtime tool array out of the Vite config as real data. */
function realtimeTools() {
@@ -302,7 +303,7 @@ test('panel collapse is presentation-only and Radio exposes explicit voice playb
);
assert.doesNotMatch(disclosureBindings, /setPanelCollapsed\('radio-panel', !/);
assert.match(ui, /setAttribute\('aria-expanded', String\(/);
- assert.match(ui, /\.hidden = !/);
+ assert.match(readFileSync(new URL('./ui/shellFeedback.js', import.meta.url), 'utf8'), /\.hidden = !/);
assert.doesNotMatch(method, /panelId === 'global-context-panel'[\s\S]*?this\._radioState\?\.enabled[\s\S]*?setPanelCollapsed\('radio-panel', false\)/);
});
diff --git a/src/reasonableDefaults.test.mjs b/src/reasonableDefaults.test.mjs
index c8ccff310..fa745c1ca 100644
--- a/src/reasonableDefaults.test.mjs
+++ b/src/reasonableDefaults.test.mjs
@@ -42,7 +42,7 @@ import {
import { ShareLinkManager } from './sharelink.js';
// Follow the UI wiring and its extracted preset definitions.
-const uiSource = fs.readFileSync(new URL('./ui.js', import.meta.url), 'utf8')
+const uiSource = fs.readFileSync(new URL('./ui/applicationShell.js', import.meta.url), 'utf8')
+ '\n' + fs.readFileSync(new URL('./ui/visualPresets.js', import.meta.url), 'utf8');
const indexHtml = 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/rightRailPolicy.test.mjs b/src/rightRailPolicy.test.mjs
index 17d07f04c..2254ea24e 100644
--- a/src/rightRailPolicy.test.mjs
+++ b/src/rightRailPolicy.test.mjs
@@ -1,3 +1,4 @@
+import { readStylesheet } from './testSupport/readStylesheet.mjs';
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import test from 'node:test';
@@ -33,7 +34,7 @@ test('other HUD layouts keep collapsed right-rail launchers visible', () => {
test('desktop Display participates in Tactical exclusivity without changing mobile Display behavior', () => {
const ui = readFileSync(new URL('./ui/rightPanelRail.js', import.meta.url), 'utf8');
- const css = readFileSync(new URL('../style.css', import.meta.url), 'utf8');
+ const css = readStylesheet(new URL('../style.css', import.meta.url));
assert.match(ui, /const isMobile = windowRef\.matchMedia\('\(max-width: 720px\)'\)\.matches/);
assert.match(
ui,
diff --git a/src/scenes/director.test.mjs b/src/scenes/director.test.mjs
index e7e35ff38..3f2d0c7f4 100644
--- a/src/scenes/director.test.mjs
+++ b/src/scenes/director.test.mjs
@@ -483,7 +483,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.js', import.meta.url), 'utf8');
+ const source = fs.readFileSync(new URL('../ui/applicationShell.js', import.meta.url), 'utf8');
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 22f29c23b..0a2a3cefa 100644
--- a/src/sharelink.celestial.test.mjs
+++ b/src/sharelink.celestial.test.mjs
@@ -6,7 +6,7 @@ import fs from 'node:fs';
import { ShareLinkManager, decodeShareCreatedAtMs } from './sharelink.js';
import { createDefaultLayerState } from './data/layerState.js';
-const uiSource = fs.readFileSync(new URL('./ui.js', import.meta.url), 'utf8');
+const uiSource = fs.readFileSync(new URL('./ui/applicationShell.js', import.meta.url), 'utf8');
function sourceBlock(start, end) {
const startIndex = uiSource.indexOf(start);
diff --git a/src/splitFlap.js b/src/splitFlap.js
index c57da5f23..cdd84dfae 100644
--- a/src/splitFlap.js
+++ b/src/splitFlap.js
@@ -519,3 +519,13 @@ export function setSplitFlapText(element, text, options = {}) {
});
return true;
}
+
+/** Stop decoration without replacing the label's permanent accessible text. */
+export function disposeSplitFlap(element) {
+ if (!element) return;
+ clearFlapTimer(element);
+ cancelWidthEase(element);
+ clearSizing(element);
+ const cells = element.querySelector?.('.gev-flap-cells');
+ if (cells) rest(element, { cells });
+}
diff --git a/src/standalone/ui.js b/src/standalone/ui.js
new file mode 100644
index 000000000..56f989a69
--- /dev/null
+++ b/src/standalone/ui.js
@@ -0,0 +1,133 @@
+/** Compose UI controls with the application's existing engines and layer instances. */
+import { StyleManager as ApplicationShell } from '../ui/applicationShell.js';
+import { LocationSearch } from '../ui/location.js';
+import { CITY_POIS, GLOBE_VIEW, flyToGlobeView, flyToPresetLocation, flyToPOI, searchAndFlyTo } from '../locations.js';
+import { interruptCameraMotion } from '../cameraVerbs.js';
+import { IntelHUD } from '../hud.js';
+import { ShareLinkManager } from '../sharelink.js';
+import { OrbitController } from '../orbit.js';
+import {
+ CelestialRing,
+ getKeyholeFadeTuning,
+ isCelestialRingStyleSupported,
+ setKeyholeFadeTuning,
+} from '../celestialRing.js';
+import { destroyTrackedReadout, initTrackedReadout } from '../data/trackedReadout.js';
+import { destroyWorldOverlay, initWorldOverlay } from '../overlays/worldOverlay.js';
+import {
+ destroyDetection,
+ initDetection,
+ cycleMode as cycleDetectionMode,
+ getDetectionDiagnostics as readDetectionDiagnostics,
+ getDetectionTuning,
+ getMode as getDetectionMode,
+ setMode as setDetectionModeByLabel,
+ suspendDetection,
+ resumeDetection,
+ setDetectionStyle,
+ setDetectionTuning,
+} from '../data/detection.js';
+import trafficLayer from '../data/traffic.js';
+import flightsLayer from '../data/flights.js';
+import militaryFlightsLayer from '../data/militaryFlights.js';
+import { isTr3b, toggleTr3b } from '../data/tr3bRegistry.js';
+import satellitesLayer from '../data/satellites.js';
+import cctvLayer from '../data/cctv.js';
+import radioLayer from '../data/radio.js';
+import bikeshareLayer from '../data/bikeshare.js';
+import aisLiveVesselsLayer from '../data/aisLiveVessels.js';
+import militaryAwarenessLayer from '../data/militaryAwareness.js';
+import militaryInstallationsLayer from '../data/militaryInstallations.js';
+import rocketLaunchesLayer from '../data/rocketLaunches.js';
+import {
+ cachedGroundFloor,
+ cachedMeshFloor,
+ GROUND_FLOOR_LIFT_M,
+ meshFloorPreferred,
+ warmGroundFloor,
+} from '../data/groundFloor.js';
+import { sampleMeshFloorCells } from '../data/meshFloorSampler.js';
+import { holdContinuousRender, releaseContinuousRender, governorRequestRender } from '../renderGovernor.js';
+import {
+ setScopeMaskEnabled,
+ isScopeMaskEnabled,
+ setScopeMaskFeather,
+ getScopeMaskFeather,
+ setScopeTerminusOverride,
+ getScopeTerminusOverride,
+ clampScopeTerminusPct,
+} from '../scopeMask.js';
+import {
+ fetchRegionalBrief,
+ regionalDistanceM,
+ weatherCodeLabel,
+} from '../data/regionalBrief.js';
+
+export class StyleManager extends ApplicationShell {
+ constructor(viewer, options = {}) {
+ super(viewer, { ...options, services: {
+ CITY_POIS,
+ GLOBE_VIEW,
+ flyToGlobeView,
+ flyToPresetLocation,
+ flyToPOI,
+ searchAndFlyTo,
+ interruptCameraMotion,
+ IntelHUD,
+ ShareLinkManager,
+ OrbitController,
+ CelestialRing,
+ getKeyholeFadeTuning,
+ isCelestialRingStyleSupported,
+ setKeyholeFadeTuning,
+ destroyTrackedReadout,
+ initTrackedReadout,
+ destroyWorldOverlay,
+ initWorldOverlay,
+ destroyDetection,
+ initDetection,
+ cycleDetectionMode,
+ readDetectionDiagnostics,
+ getDetectionTuning,
+ getDetectionMode,
+ setDetectionModeByLabel,
+ suspendDetection,
+ resumeDetection,
+ setDetectionStyle,
+ setDetectionTuning,
+ trafficLayer,
+ flightsLayer,
+ militaryFlightsLayer,
+ isTr3b,
+ toggleTr3b,
+ satellitesLayer,
+ cctvLayer,
+ radioLayer,
+ bikeshareLayer,
+ aisLiveVesselsLayer,
+ militaryAwarenessLayer,
+ militaryInstallationsLayer,
+ rocketLaunchesLayer,
+ cachedGroundFloor,
+ cachedMeshFloor,
+ GROUND_FLOOR_LIFT_M,
+ meshFloorPreferred,
+ warmGroundFloor,
+ sampleMeshFloorCells,
+ holdContinuousRender,
+ releaseContinuousRender,
+ governorRequestRender,
+ setScopeMaskEnabled,
+ isScopeMaskEnabled,
+ setScopeMaskFeather,
+ getScopeMaskFeather,
+ setScopeTerminusOverride,
+ getScopeTerminusOverride,
+ clampScopeTerminusPct,
+ fetchRegionalBrief,
+ regionalDistanceM,
+ weatherCodeLabel,
+ LocationSearch
+ } });
+ }
+}
diff --git a/src/testSupport/readStylesheet.mjs b/src/testSupport/readStylesheet.mjs
new file mode 100644
index 000000000..d826bb30a
--- /dev/null
+++ b/src/testSupport/readStylesheet.mjs
@@ -0,0 +1,11 @@
+/** Resolve the actual ordered local CSS imports used by the application entry. */
+import { readFileSync } from 'node:fs';
+import path from 'node:path';
+import { fileURLToPath } from 'node:url';
+export function readStylesheet(file, ancestors = new Set()) {
+ const resolved = path.resolve(file instanceof URL ? fileURLToPath(file) : file);
+ if (ancestors.has(resolved)) throw new Error('Circular stylesheet import');
+ const next = new Set(ancestors).add(resolved);
+ return readFileSync(resolved, 'utf8').replace(/@import ['"]([^'"]+)['"];\s*/g,
+ (_, imported) => readStylesheet(path.resolve(path.dirname(resolved), imported), next));
+}
diff --git a/src/ui.js b/src/ui.js
index caae0a196..ada8689fe 100644
--- a/src/ui.js
+++ b/src/ui.js
@@ -1,5270 +1,2 @@
-import { CockpitViewController, CockpitDisplayPortal } from './ui/cockpit.js';
-import { ContextControls } from './ui/context.js';
-import { CctvControls } from './ui/cctv.js';
-import { RadioControls } from './ui/radio.js';
-import { LocationControls, LocationSearch } from './ui/location.js';
-import { bindClearLayersControl } from './ui/layers.js';
-import { createMapSourceControls } from './ui/mapSource.js';
-import { VisualEffects, STYLES, GLOBAL_POST_DEFAULTS, STYLE_PRESET_DEFAULTS, MILITARY_DETECTION_PRESET } from './ui/effects.js';
-import { bindDisplayControls } from './ui/displayControls.js';
-import { bindApplicationShortcuts, createStyleParameters } from './ui/visualInput.js';
-import { layoutLeftPanelRail, layoutRightPanelRail } from './ui/panelRails.js';
-import { bindPanelDisclosure, collapsePanelOnEscape, createHoverDisclosure } from './ui/panelDisclosure.js';
-import * as Cesium from 'cesium';
-import { BLOOM_SCALE_VERSION, clampBloomIntensity, decodeBloomIntensity } from './bloom.js';
-import { CITY_POIS, GLOBE_VIEW, flyToGlobeView, flyToPresetLocation, flyToPOI, searchAndFlyTo } from './locations.js';
-import { interruptCameraMotion } from './cameraVerbs.js';
-import {
- aircraftTrackingTarget,
- enterCockpitWithTracking,
-} from './cockpitTracking.js';
-import { IntelHUD } from './hud.js';
-import { ShareLinkManager } from './sharelink.js';
-import {
- isExplicitLayerStateOrigin,
- LayerStateCoordinator,
-} from './data/layerState.js';
-import { OrbitController } from './orbit.js';
-import {
- CelestialRing,
- getKeyholeFadeTuning,
- isCelestialRingStyleSupported,
- setKeyholeFadeTuning,
-} from './celestialRing.js';
-import { destroyTrackedReadout, initTrackedReadout } from './data/trackedReadout.js';
-import { destroyWorldOverlay, initWorldOverlay } from './overlays/worldOverlay.js';
-import {
- destroyDetection,
- initDetection,
- cycleMode as cycleDetectionMode,
- getDetectionDiagnostics as readDetectionDiagnostics,
- getDetectionTuning,
- getMode as getDetectionMode,
- setMode as setDetectionModeByLabel,
- suspendDetection,
- resumeDetection,
- setDetectionStyle,
- setDetectionTuning,
-} from './data/detection.js';
-import {
- ALLOCATION_STRATEGIES,
- canonicalizeDensity,
- defaultDensityForProfile,
- normalizeAllocationStrategy,
- normalizeProfile,
- profileForDensity,
-} from './data/detectionPolicy.js';
-import trafficLayer from './data/traffic.js';
-import flightsLayer from './data/flights.js';
-import militaryFlightsLayer from './data/militaryFlights.js';
-import { isTr3b, toggleTr3b } from './data/tr3bRegistry.js';
-import satellitesLayer from './data/satellites.js';
-import cctvLayer from './data/cctv.js';
-import radioLayer from './data/radio.js';
-import bikeshareLayer from './data/bikeshare.js';
-import aisLiveVesselsLayer from './data/aisLiveVessels.js';
-import militaryAwarenessLayer from './data/militaryAwareness.js';
-import militaryInstallationsLayer from './data/militaryInstallations.js';
-import rocketLaunchesLayer from './data/rocketLaunches.js';
-import { aggregateLayerLoading, canPresentDeferredStatusNotice, createGlobalStatusNotice, createLoadingFeedbackState, createTrafficSyncFeedbackState, presentGlobalLoadingStatus, reduceLoadingFeedback, reduceTrafficSyncFeedback } from './loadingFeedback.js';
-import { setSplitFlapText } from './splitFlap.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 {
- registerCctvFocusRequestListener,
- routeCctvFocusRequest,
-} from './cctvFocusRequest.js';
-import {
- flyToWorldTarget,
- registerWorldFocusRequestListener,
- routeWorldFocusRequest,
-} from './worldFocus.js';
-import {
- beginDeferredNavigation,
- reassertNavigationHandoff,
- registerNavigationAuthorityListener,
- runExplicitNavigation,
- stampInitialShareGesture,
-} from './navigationPolicy.js';
-import {
- cachedGroundFloor,
- cachedMeshFloor,
- GROUND_FLOOR_LIFT_M,
- meshFloorPreferred,
- warmGroundFloor,
-} from './data/groundFloor.js';
-import { sampleMeshFloorCells } from './data/meshFloorSampler.js';
-import { holdContinuousRender, releaseContinuousRender, governorRequestRender } from './renderGovernor.js';
-import {
- setScopeMaskEnabled,
- isScopeMaskEnabled,
- setScopeMaskFeather,
- getScopeMaskFeather,
- setScopeTerminusOverride,
- getScopeTerminusOverride,
- clampScopeTerminusPct,
-} from './scopeMask.js';
-import {
- fetchRegionalBrief,
- regionalDistanceM,
- weatherCodeLabel,
-} from './data/regionalBrief.js';
-
-/** Versioned localStorage namespace prefix to invalidate stale panel layouts. */
-const PANEL_LAYOUT_STORAGE_VERSION = 'v6';
-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',
-]);
-/**
- * Position keys are versioned separately from collapsed-state keys so layout
- * default changes (e.g. right-rail origin) can reset positions without also
- * resetting every panel's open/closed preference.
- */
-const PANEL_POSITION_STORAGE_VERSION = 'v8';
-const DETECTION_ALLOCATION_STORAGE_KEY = 'gev:detection-allocation:v1';
-/** Z ladder: panels promote within [100, 139]; voice pill 150, toast 200, clean-view-exit 300. */
-const PANEL_Z_BASE = 100;
-const PANEL_Z_MAX = 139;
-
-const COCKPIT_LAYOUT_SETTLE_MS = 240;
-
-/**
- * Fixed UI regions that can occupy the left accordion's vertical lane.
- * Rectangles are filtered at runtime for visibility and horizontal overlap,
- * so right-side/center controls do not reduce the lane unless they actually
- * intersect it at the current viewport size.
- */
-const LEFT_STACK_OBSTACLE_SELECTOR = [
- '#cockpit-hud .cockpit-topline',
- '#cockpit-hud .cockpit-topline > div',
- '#title-bar',
- '#style-indicator',
- '#top-center-actions',
- '#traffic-sync-chip',
- '#cctv-sync-chip',
- '#intel-hud .hud-top-left',
- '#intel-hud .hud-top-right',
- '#intel-hud .hud-bottom-left',
- '#intel-hud .hud-bottom-right',
- '#intel-hud .hud-top-bar',
- '#intel-hud .hud-bottom-bar',
- '#intel-hud .hud-left-edge',
- '#intel-hud .hud-right-edge',
- '#cockpit-context',
- '#cesium-credits .cesium-credit-logoContainer',
- '#cesium-credits .cesium-credit-textContainer',
- '#location-bar',
- '#control-panel',
- '#gev-voice-control',
- '#pp-toggles',
- '#param-slider-panel',
-].join(', ');
-/**
- * Fixed UI regions that can occupy the right control lane. Runtime rectangle
- * filtering keeps the rail clear of whichever HUD variant is currently
- * visible without tying the layout to one screen height.
- */
-const RIGHT_STACK_OBSTACLE_SELECTOR = [
- '#cockpit-hud .cockpit-topline',
- '#cockpit-hud .cockpit-topline > div',
- '#title-bar',
- '#style-indicator',
- '#top-center-actions',
- '#traffic-sync-chip',
- '#cctv-sync-chip',
- '#intel-hud .hud-top-left',
- '#intel-hud .hud-top-right',
- '#intel-hud .hud-bottom-left',
- '#intel-hud .hud-bottom-right',
- '#intel-hud .hud-top-bar',
- '#intel-hud .hud-bottom-bar',
- '#intel-hud .hud-left-edge',
- '#intel-hud .hud-right-edge',
- '#cockpit-context',
- '#cockpit-signal-stream',
- '#cesium-credits .cesium-credit-logoContainer',
- '#cesium-credits .cesium-credit-textContainer',
- '#command-dock',
- '#gev-voice-control',
-].join(', ');
-/** 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.
- *
- * Responsibilities:
- * - Visual controls and presets backed by the VisualEffects controller.
- * - Bloom and sharpen post-processing toggle/intensity control.
- * - Draggable/collapsible panel system with localStorage persistence,
- * z-order stacking, and viewport-clamped positioning.
- * - CCTV panel: camera selection, coverage toggle, projection, calibration
- * sliders, auto-hop, and summary typewriter effect.
- * - Location bar with city/POI preset pills, QWERTY key navigation,
- * geocoding search, and inter-city world-jump transitions.
- * - Orbit controller integration for POI fly-around.
- * - Recording mode with safe-frame overlay and HUD mode switching.
- * - Share link encoding/decoding (delegates to ShareLinkManager).
- * - Detection overlay mode cycling and density tuning.
- * - Toast notification system.
- * - Intel HUD lifecycle and variant switching.
- */
-
-export class StyleManager {
- /**
- * @param {Cesium.Viewer} viewer - The CesiumJS viewer instance.
- * @param {object} [options]
- */
- constructor(viewer, { mapStackController = null, placeSearch } = {}) {
- 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;
-
- // Bloom/sharpen state
- this._recordingMode = false;
- this._recordingConfig = { hidePanels: true, hudMode: 'minimal', safeFrame: '16:9' };
- this._preRecordingHudState = null;
- this._panelZCounter = PANEL_Z_BASE + 10;
- this._lastLoadingFeedbackUpdateAt = 0;
- this._loadingFeedbackState = createLoadingFeedbackState();
- this._loadingFeedbackEvent = null;
- this._loadingFeedbackTicker = null;
- this._globalStatusNotice = null;
- this._shareTrackingAcquiringKey = null;
- this._shareTrackingNoticeGeneration = 0;
- this._globeResetPromise = null;
- this._dataManager = null;
-
- this._leftStackLayoutFrame = null;
- this._leftStackReconsiderAutoCollapse = false;
- this._leftStackResizeObserver = null;
- this._leftStackMutationObserver = null;
- this._leftStackHudTransitionHandler = null;
- this._leftStackCollapsedHeights = new Map();
- this._leftStackPreferredPanelId = null;
- this._rightPanelStack = document.getElementById('right-context-rail');
- this._rightStackLayoutFrame = null;
- this._rightStackReconsiderAutoCollapse = false;
- this._rightStackResizeObserver = null;
- this._rightStackMutationObserver = null;
- this._rightStackHudTransitionHandler = null;
- this._rightStackPreferredPanelId = null;
- this._adaptivePanelSettleTimer = null;
- this._windowResizeHandler = null;
- this._loadingVisibilityHandler = null;
- this._cctvRequestFocusHandler = null;
- this._removeCctvRequestFocusListener = null;
- this._worldRequestFocusHandler = null;
- 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;
- this._draggableResizeObserver = null;
-
- // DOM refs
- this._styleIndicator = document.getElementById('active-style-name');
- this._sliderPanel = document.getElementById('param-slider-panel');
- this._sliderContainer = document.getElementById('param-sliders');
- this._ppToggles = document.getElementById('pp-toggles');
- this._bloomBtn = document.getElementById('bloom-toggle');
- this._bloomSliderRow = document.getElementById('bloom-slider-row');
- this._bloomSlider = document.getElementById('bloom-intensity-slider');
- this._bloomSliderValue = document.getElementById('bloom-intensity-value');
- this._sharpenBtn = document.getElementById('sharpen-toggle');
- this._sharpenSliderRow = document.getElementById('sharpen-slider-row');
- this._sharpenSlider = document.getElementById('sharpen-intensity-slider');
- this._sharpenSliderValue = document.getElementById('sharpen-intensity-value');
- this._hudBtn = document.getElementById('hud-toggle');
- this._hudLayoutRow = document.getElementById('hud-layout-row');
- this._hudLayoutSelect = document.getElementById('hud-layout-select');
- this._detectionSliderRow = document.getElementById('detection-slider-row');
- this._detectionDensitySlider = document.getElementById('detection-density-slider');
- this._detectionDensityValue = document.getElementById('detection-density-value');
- this._detectionAllocationRow = document.getElementById('detection-allocation-row');
- this._detectionAllocationBtns = [
- document.getElementById('detection-allocation-elastic'),
- document.getElementById('detection-allocation-weighted'),
- ].filter(Boolean);
- this._detectionFadeRow = document.getElementById('detection-fade-row');
- this._detectionFadeSlider = document.getElementById('detection-fade-slider');
- this._detectionFadeValue = document.getElementById('detection-fade-value');
- this._detectionOpacityRow = document.getElementById('detection-opacity-row');
- this._detectionOpacitySlider = document.getElementById('detection-opacity-slider');
- this._detectionOpacityValue = document.getElementById('detection-opacity-value');
- 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._celestialBtn = document.getElementById('celestial-toggle');
- this._scopeBtn = document.getElementById('scope-toggle');
- this._scopeFeatherSlider = document.getElementById('scope-feather-slider');
- this._scopeFeatherValue = document.getElementById('scope-feather-value');
- this._mapStackChips = document.getElementById('map-stack-chips');
- this._mapStackStatus = document.getElementById('map-stack-status');
- this._mapStackChangeHandler = null;
- this._cleanViewBtn = document.getElementById('clean-view-toggle');
- this._cleanViewExitBtn = document.getElementById('clean-view-exit');
- this._dataPanel = document.getElementById('data-panel');
- this._scenePanel = document.getElementById('scene-panel');
- this._cctvPanel = document.getElementById('cctv-panel');
- this._radioPanel = document.getElementById('radio-panel');
- this._contextRadioDock = document.getElementById('context-radio-dock');
- this._contextRadioToggleBtn = document.getElementById('context-radio-toggle-btn');
- this._contextRadioMini = document.getElementById('context-radio-mini');
- this._contextRadioMiniEnableBtn = document.getElementById('context-radio-mini-enable-btn');
- this._contextRadioDetailsBtn = document.getElementById('context-radio-details-btn');
- this._contextRadioMiniCloseBtn = document.getElementById('context-radio-mini-close-btn');
- this._contextRadioMiniStation = document.getElementById('context-radio-mini-station');
- this._contextRadioMiniPrevBtn = document.getElementById('context-radio-mini-prev-btn');
- this._contextRadioMiniPlayBtn = document.getElementById('context-radio-mini-play-btn');
- this._contextRadioMiniNextBtn = document.getElementById('context-radio-mini-next-btn');
- this._contextRadioMiniVolume = document.getElementById('context-radio-mini-volume');
- this._contextRadioMiniVolumeValue = document.getElementById('context-radio-mini-volume-value');
- this._cockpitUtilityControls = document.getElementById('cockpit-utility-controls');
- this._cockpitDisplayToggleBtn = document.getElementById('cockpit-display-toggle-btn');
- this._cockpitDisplayPanel = document.getElementById('cockpit-display-panel');
- this._cockpitDisplayPortal = null;
- this._cockpitDisplayModeHandler = null;
- this._cockpitRadioToggleBtn = document.getElementById('cockpit-radio-toggle-btn');
- this._cockpitRadioPanel = document.getElementById('cockpit-radio-panel');
- this._cockpitRadioEnableBtn = document.getElementById('cockpit-radio-enable-btn');
- this._cockpitRadioStation = document.getElementById('cockpit-radio-station');
- this._cockpitRadioPrevBtn = document.getElementById('cockpit-radio-prev-btn');
- this._cockpitRadioPlayBtn = document.getElementById('cockpit-radio-play-btn');
- this._cockpitRadioNextBtn = document.getElementById('cockpit-radio-next-btn');
- this._cockpitRadioVolume = document.getElementById('cockpit-radio-volume');
- this._cockpitRadioVolumeValue = document.getElementById('cockpit-radio-volume-value');
- this._radioLayerState = document.getElementById('radio-layer-state');
- this._radioEnableBtn = document.getElementById('radio-enable-btn');
- this._radioFilter = document.getElementById('radio-filter');
- this._radioStationName = document.getElementById('radio-station-name');
- this._radioStationMeta = document.getElementById('radio-station-meta');
- this._radioStationTags = document.getElementById('radio-station-tags');
- this._radioTuner = document.getElementById('radio-tuner');
- this._radioTunerSlider = document.getElementById('radio-tuner-slider');
- this._radioTunerNeedle = document.getElementById('radio-tuner-needle');
- this._radioTunerBandLabel = document.getElementById('radio-tuner-band-label');
- this._radioTunerValue = document.getElementById('radio-tuner-value');
- this._radioTunerStation = document.getElementById('radio-tuner-station');
- this._radioPrevBtn = document.getElementById('radio-prev-btn');
- this._radioPlayBtn = document.getElementById('radio-play-btn');
- this._radioNextBtn = document.getElementById('radio-next-btn');
- this._radioStopBtn = document.getElementById('radio-stop-btn');
- this._radioVolume = document.getElementById('radio-volume');
- this._radioVolumeValue = document.getElementById('radio-volume-value');
- this._radioPlaybackState = document.getElementById('radio-playback-state');
- this._radioStationHomepage = document.getElementById('radio-station-homepage');
- this._globalContextFlightsBtn = document.getElementById('global-context-flights-btn');
- this._globalContextMissionsBtn = document.getElementById('global-context-missions-btn');
- this._contextModeStandby = document.getElementById('context-mode-standby');
- this._contextFlightsView = document.getElementById('context-flights-view');
- this._contextMissionsView = document.getElementById('context-missions-view');
- this._installationsSearchBtn = document.getElementById('installations-search-btn');
- this._leftPanelStack = document.getElementById('left-panel-stack');
- this._cctvEnableBtn = document.getElementById('cctv-enable-btn');
- this._cctvNearestBtn = document.getElementById('cctv-nearest-btn');
- this._cctvPrevBtn = document.getElementById('cctv-prev-btn');
- this._cctvNextBtn = document.getElementById('cctv-next-btn');
- this._cctvSelect = document.getElementById('cctv-camera-select');
- this._cctvFocusBtn = document.getElementById('cctv-focus-btn');
- this._cctvCoverageBtn = document.getElementById('cctv-coverage-btn');
- this._cctvAutoHopBtn = document.getElementById('cctv-auto-hop-btn');
- this._cctvProjectionBtn = document.getElementById('cctv-projection-btn');
- this._cctvQualityChip = document.getElementById('cctv-quality-chip');
- this._cctvAdjustBtn = document.getElementById('cctv-adjust-btn');
- this._cctvCalReadout = document.getElementById('cctv-cal-readout');
- this._cctvCalibSaveBtn = document.getElementById('cctv-calib-save-btn');
- this._cctvCalibResetBtn = document.getElementById('cctv-calib-reset-btn');
- this._cctvFrame = document.getElementById('cctv-frame');
- this._cctvFrameWrap = document.getElementById('cctv-frame-wrap');
- this._cctvSourceBadge = document.getElementById('cctv-source-badge');
- this._cctvMeta = document.getElementById('cctv-meta');
- this._cctvSummary = document.getElementById('cctv-summary');
- this._shareBtn = document.getElementById('share-btn');
- this._clearSelectedLayersBtn = document.getElementById('clear-selected-layers');
- this._globalLoadingStatus = document.getElementById('global-loading-status');
- this._globalLoadingLabel = document.getElementById('global-loading-label');
- this._globalLoadingDetail = document.getElementById('global-loading-detail');
- this._resetGlobeBtn = document.getElementById('reset-globe-view');
- this._cockpitResetGlobeBtn = document.getElementById('cockpit-reset-globe');
- this._styleButtons = document.getElementById('style-buttons');
- this._trafficSyncChip = document.getElementById('traffic-sync-chip');
- this._trafficSyncLabel = document.getElementById('traffic-sync-label');
- this._trafficSyncProgress = document.getElementById('traffic-sync-progress');
- this._cctvSyncChip = document.getElementById('cctv-sync-chip');
- this._cctvSyncLabel = document.getElementById('cctv-sync-label');
- this._cctvSyncProgress = document.getElementById('cctv-sync-progress');
- this._toast = document.getElementById('toast');
- this._locationSearch = document.getElementById('location-search');
- this._searchToggle = document.getElementById('search-toggle');
- this._locationPills = document.getElementById('location-pills');
- this._poiRow = document.getElementById('poi-row');
- this._locationBarDivider = document.getElementById('location-bar-divider');
- this._styleMiniValue = document.getElementById('style-mini-value');
- this._locationMiniCity = document.getElementById('location-mini-city');
- this._locationMiniPoi = document.getElementById('location-mini-poi');
- this._safeFrameOverlay = document.getElementById('safe-frame-overlay');
- this._safeFrameBox = document.getElementById('safe-frame-box');
- this._activeLocationId = null;
- this._expandedCityId = null;
- this._activePoiIndex = null;
- this._currentTarget = null; // Cesium.Cartesian3 of current POI target
- this._currentPoi = null; // Current POI data object
- // Formatted address of the last free-text geocode search. Preset pills set
- // _activeLocationId instead; a search has no preset record, so this is the
- // only thing the mini-status can report for it.
- this._searchedLocationLabel = null;
- this._trafficSyncFeedbackState = createTrafficSyncFeedbackState();
- this._trafficTransitionTimer = null;
- this._lastTrafficChipUpdateAt = 0;
-
- // Orbit controller
- this.orbitController = new OrbitController(viewer);
- this._orbitIndicator = null;
-
- // Intel HUD
- this.hud = new IntelHUD(viewer);
- 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,
- militaryFlightsLayer,
- isTr3b,
- toggleTr3b,
- militaryAwarenessLayer,
- formatAwarenessLabel,
- cachedGroundFloor,
- cachedMeshFloor,
- GROUND_FLOOR_LIFT_M,
- meshFloorPreferred,
- warmGroundFloor,
- sampleMeshFloorCells,
- holdContinuousRender,
- releaseContinuousRender,
- fetchRegionalBrief,
- regionalDistanceM,
- weatherCodeLabel,
- },
- onVisionChange: (mode, active, options) => this._setCockpitVision(mode, active, options),
- onCameraTakeover: () => this._stampNavigation({ cancelPendingSelection: false }),
- getInheritedVisionLabel: () => (
- STYLE_STATUS_LABELS[this.activeStyle]
- || String(this.activeStyle || 'normal').toUpperCase()
- ),
- isEntryAllowed: () => cockpitEntryAllowed({
- contextMode: this._contextMode,
- contextModeChanging: this._contextModeChanging,
- 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,
- });
- }
- },
- restoreTrackingFrame: (entity) => {
- const [layerId, ...idParts] = String(entity?.gevTrackedId || '').split(':');
- const trackedId = idParts.join(':');
- if (!trackedId) return false;
- if (layerId === 'flights') return flightsLayer.refocusTrackedById?.(trackedId) === true;
- if (layerId === 'military') return militaryFlightsLayer.refocusTrackedById?.(trackedId) === true;
- return false;
- },
- });
-
- // Full-globe sun/moon ring. It is a crisp screen-space overlay above the
- // Cesium canvas but below the HUD/detection/readout z ladder.
- this.celestialRing = new CelestialRing(viewer, {
- enabled: false,
- onAutoDisable: () => this.setCelestialRingEnabled(false, {
- syncShare: !!this.shareLinkManager,
- focus: false,
- }),
- });
-
- // Share Link Manager
- this.shareLinkManager = new ShareLinkManager(viewer, {
- onRestore: async (state) => {
- const {
- style,
- bloom,
- sharpen,
- bloomIntensity,
- bloomVersion,
- sharpenIntensity,
- hudVariant,
- hudVisible,
- detectionMode,
- detectionDensity,
- detectionAllocation,
- detectionFadePct,
- detectionOutsideOpacityPct,
- celestialRing,
- scopeEnabled,
- scopeFeatherPct,
- scopeTerminusPct,
- mapStack,
- panelState,
- styleParams,
- } = state || {};
- // Ignore the retired 'ai-edit' style from older share links.
- if (style && style !== 'normal' && style !== 'ai-edit') {
- this.setStyle(style, { applyPreset: true, revealParameters: false, restore: true });
- }
- if (styleParams && style && this.stages[style] && STYLES[style]?.uniforms) {
- for (const [uniformName, uniformValue] of Object.entries(styleParams)) {
- if (!Object.hasOwn(STYLES[style].uniforms, uniformName)) continue;
- this.stages[style].uniforms[uniformName] = uniformValue;
- }
- this._updateSliderPanel(style, { reveal: false });
- }
- if (typeof bloomIntensity === 'number' && this._bloomSlider) {
- const intensity = decodeBloomIntensity(bloomIntensity, bloomVersion);
- this._setBloomIntensity(intensity, { syncShare: false });
- }
- if (typeof sharpenIntensity === 'number' && this._sharpenSlider) {
- const pct = Math.max(0, Math.min(100, Math.round(sharpenIntensity)));
- this._sharpenSlider.value = String(pct);
- this._sharpenSliderValue.textContent = `${pct}%`;
- this._applySharpenIntensity(pct / 100);
- }
- if (typeof bloom === 'boolean') this._setBloomEnabled(bloom);
- if (typeof sharpen === 'boolean') this._setSharpenEnabled(sharpen);
- if (hudVariant) this._setHudVariant(hudVariant);
- if (typeof hudVisible === 'boolean') {
- this.hud.setMode(hudVisible ? 'on' : 'off');
- this._updateHudButtonState();
- }
- if (typeof detectionDensity === 'number' && this._detectionDensitySlider) {
- const pct = canonicalizeDensity(detectionDensity);
- this._detectionDensitySlider.value = String(pct);
- this._detectionDensityValue.textContent = `${pct}%`;
- this._applyDetectionDensityFromUi();
- }
- if (detectionAllocation) {
- this._setDetectionAllocation(detectionAllocation, { syncShare: false, persist: false });
- }
- if (typeof detectionFadePct === 'number' && this._detectionFadeSlider) {
- this._detectionFadeSlider.value = String(detectionFadePct);
- }
- if (typeof detectionOutsideOpacityPct === 'number' && this._detectionOpacitySlider) {
- this._detectionOpacitySlider.value = String(detectionOutsideOpacityPct);
- }
- this._applyDetectionFadeFromUi();
- if (detectionMode) this._setDetectionMode(detectionMode);
- if (typeof celestialRing === 'boolean') {
- this.setCelestialRingEnabled(celestialRing, { syncShare: false, focus: false });
- }
- if (typeof scopeEnabled === 'boolean') {
- setScopeMaskEnabled(scopeEnabled);
- this._scopeBtn?.classList.toggle('active', scopeEnabled);
- this._scopeBtn?.setAttribute('aria-pressed', String(scopeEnabled));
- }
- if (typeof scopeFeatherPct === 'number' && this._scopeFeatherSlider) {
- const pct = Math.max(0, Math.min(100, Math.round(scopeFeatherPct)));
- this._scopeFeatherSlider.value = String(pct);
- if (this._scopeFeatherValue) this._scopeFeatherValue.textContent = `${pct}%`;
- setScopeMaskFeather(pct / 100);
- }
- // null restores the altitude-adaptive ramp; a number pins the terminus
- // (clamped to the supported 94..100 band, same as the `sce` hash key).
- if (scopeTerminusPct === null) setScopeTerminusOverride(null);
- else if (typeof scopeTerminusPct === 'number') {
- const pinned = clampScopeTerminusPct(scopeTerminusPct);
- setScopeTerminusOverride(pinned == null ? null : pinned / 100);
- }
- const mapStackRestore = mapStack
- ? this._setMapStack(mapStack, { syncShare: false })
- : Promise.resolve();
- if (panelState) this._restorePanelState(panelState);
- await mapStackRestore;
- this._syncShareState();
- },
- isNavigationCurrent: (generation) => generation === this._navigationGeneration,
- cancelOwnedNavigation: () => this.viewer.camera.cancelFlight(),
- });
- this.shareLinkManager.setPanelStateProvider(() => this._buildSharePanelState());
- this.shareLinkManager.setStyleParamStateProvider((styleName) => {
- const shader = STYLES[styleName];
- const stage = this.stages[styleName];
- if (!shader?.uniforms || !stage) return null;
- return Object.fromEntries(
- Object.keys(shader.uniforms).map((uniformName) => [uniformName, stage.uniforms[uniformName]]),
- );
- });
- // 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._detectionBtn = document.getElementById('detection-toggle');
- this._models3dBtn = document.getElementById('models3d-toggle');
- this._models3dModeRow = document.getElementById('models3d-mode-row');
- this._models3dModeBtns = [
- document.getElementById('models3d-mode-proximity'),
- document.getElementById('models3d-mode-all'),
- ];
- // DISPLAY-rail 3D-aircraft toggle (flights layer param). DEFAULT-ON in
- // PROXIMITY (owner directive 2026-08-22) — mirrors the `models3d` default in
- // layerState.js and `_models3dEnabled` in both flight layers, and the `active`
- // class the button carries in index.html. A fresh boot skips layer-state
- // restoration, so these initializers are the only thing keeping the lit
- // button and the armed layer in agreement.
- this._models3dEnabled = true;
- this._models3dMode = 'proximity'; // 'proximity' (nearest in view) | 'all' (every in-view plane)
-
- // The shared world-overlay host must own its one postRender lane before
- // detection and tracked-readout initialize. It stays transparent until a
- // production source explicitly registers entries.
- initWorldOverlay(viewer);
-
- // Initialize detection overlay BEFORE style stages so the composite
- // stage is first in the post-process pipeline
- initDetection(viewer, [trafficLayer, flightsLayer, militaryFlightsLayer, satellitesLayer, cctvLayer, bikeshareLayer, aisLiveVesselsLayer], (modeLabel) => {
- this._updateDetectionButton(modeLabel);
- });
- initTrackedReadout(viewer);
- setDetectionStyle(this.activeStyle);
- this._applyDetectionDensityFromUi();
-
- this._initStages();
- this._initBloomSharpen();
- this._initUI();
- this._initMapStackControl();
- this._initPanelChrome();
- this._initLeftPanelAdaptiveLayout();
- this._initRightPanelAdaptiveLayout();
- this._initRadioPanel();
- this._initCctvPanel();
- this._initGlobalContextPanel();
- this._initLocationBar();
- this._initShareButton();
- this._initClearSelectedLayersButton();
- this._initHUDToggle();
- this._initModels3dToggle();
- this._applyGlobalPostDefaults();
- this._initOrbit();
- this._initRecordingOverlay();
- this._startAnimationLoop();
- this._startTrafficChipTicker();
- 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,
- });
-
- // Keep the parameter panel from overlapping toggle controls.
- this._layoutRightPanels();
- this._syncCctvPanelViewport();
- this._windowResizeHandler = () => {
- this._scheduleRightPanelLayout({ reconsiderAutoCollapse: true });
- this._syncCctvPanelViewport();
- this._scheduleLeftPanelLayout({ reconsiderAutoCollapse: true });
- };
- window.addEventListener('resize', this._windowResizeHandler);
- // The loading-chip ticker is stopped while the tab is hidden (it can do no
- // useful work off-screen and must not hold a 60ms timer there). Resample on
- // return so the time-driven reducer catches up on real elapsed time — and
- // re-arms its own ticker if the batch is still running.
- this._loadingVisibilityHandler = () => {
- if (!document.hidden) this._updateGlobalLoadingFeedback();
- };
- document.addEventListener('visibilitychange', this._loadingVisibilityHandler);
- this._cctvRequestFocusHandler = (event) => routeCctvFocusRequest(
- event,
- (activate, focus) => this._runExplicitCctvFocus(activate, focus),
- (cameraId, durationSec) => cctvLayer.focusCamera(cameraId, durationSec),
- );
- this._removeCctvRequestFocusListener = registerCctvFocusRequestListener(
- window,
- this._cctvRequestFocusHandler,
- );
- this._worldRequestFocusHandler = (event) => routeWorldFocusRequest(
- event,
- (detail, fly) => this._runExplicitWorldFocus(detail, fly),
- (detail) => flyToWorldTarget(this.viewer, detail),
- );
- this._removeWorldRequestFocusListener = registerWorldFocusRequestListener(
- window,
- this._worldRequestFocusHandler,
- );
- this._navigationOwnerChangedRemover = viewer.trackedEntityChanged.addEventListener((entity) => {
- if (entity && !this._disposed) this._stampNavigation({ cancelPendingSelection: false });
- });
- // Vessel/installation focus flies without ever assigning a tracked entity,
- // so it cannot reach the listener above. It announces instead.
- this._removeNavigationAuthorityListener = registerNavigationAuthorityListener(
- window,
- (event) => {
- if (this._disposed) return;
- this._stampNavigation({
- cancelPendingSelection: event?.detail?.cancelPendingSelection !== false,
- });
- },
- );
- }
-
- // Compatibility reads for existing controls, scene snapshots and Cockpit.
- get stages() { return this._visualEffects.stages; }
- get transitions() { return this._visualEffects.transitions; }
- get bloomEnabled() { return this._visualEffects.bloomEnabled; }
- get sharpenEnabled() { return this._visualEffects.sharpenEnabled; }
- get _bloomStage() { return this._visualEffects.bloomStage; }
- get _sharpenStage() { return this._visualEffects.sharpenStage; }
-
- /** Advance camera authority and settle any older search UI immediately. */
- _stampNavigation({ cancelPendingSelection = true, clearSearchedLocation = true } = {}) {
- 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;
- }
-
- /** 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();
- }
-
- /** Release every follow owner while preserving Contact and vessel selection. */
- _releaseFollowCamera({
- preserveVesselSelection = true,
- preserveCameraFlight = false,
- trackingOrigin = 'tool',
- } = {}) {
- 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;
- }
-
- /** 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,
- });
- }
-
- /** Accept a delayed lookup without releasing its current camera owner. */
- _beginDeferredNavigation(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 }),
- });
- }
-
- /** 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();
- },
- });
- }
-
- /** Public lifecycle seam used by voice location navigation. */
- beginDeferredLocationNavigation() {
- return this._beginDeferredNavigation('location');
- }
-
- /** Public final-authority seam used by voice geocoding. */
- reassertDeferredLocationNavigation(generation) {
- return this._reassertNavigationHandoff(generation);
- }
-
- /** Public immediate route used by voice destinations. */
- runImmediateLocationNavigation(navigate) {
- return this.runImmediateNavigation('location', navigate);
- }
-
- /** Public authority facade used by validated voice camera destinations. */
- runImmediateNavigation(noun, navigate, releaseOptions = undefined) {
- return this._runExplicitNavigation(noun, navigate, releaseOptions);
- }
-
- /** Supersede deferred work when an owner-specific route handles release. */
- supersedeDeferredNavigation() {
- return this._stampNavigation();
- }
-
- /** Route a valid vessel/fire request through the shared navigation policy. */
- _runExplicitWorldFocus(detail, fly) {
- return this._runExplicitNavigation(detail?.kind || 'target', fly);
- }
-
- /** Return the aircraft tracker owned before a multi-step Cockpit transaction. */
- getAircraftTrackingTarget() {
- return aircraftTrackingTarget(this.cockpitView?.readAircraftInfo?.());
- }
-
- /**
- * On window resize, keep the draggable panel on-screen — a panel positioned near an edge can fall
- * outside a now-smaller viewport (audit U2). pp-toggles is right-pinned, so re-pin (horizontal) and
- * clamp its top. No-op until the panel has been positioned (explicit inline top).
- * @returns {void}
- */
- _reclampDraggablePanels() {
- const el = this._ppToggles;
- if (!el || !el.style.top || el.style.top === 'auto') return;
- const top = parseInt(el.style.top, 10);
- if (!Number.isFinite(top)) return;
- el.style.top = `${this._clampToViewport(0, top, el).top}px`;
- this._pinPanelToRight(el);
- }
-
- /**
- * Creates one CesiumJS PostProcessStage per visual style and registers
- * it with the scene. Each stage starts with intensity 0 (invisible)
- * so crossfade transitions can animate it in later.
- * @returns {void}
- */
- _initStages() {
- this._visualEffects.initStyles();
- }
-
- /**
- * Single write path for style-stage intensity: keeps `enabled` in
- * lockstep so zero-intensity stages cost nothing (safe now that the
- * scope is explicit — see _initStages). The stage enables on the same
- * frame the first non-zero intensity lands, so crossfades never pop.
- * @param {Cesium.PostProcessStage} stage - Style post-process stage.
- * @param {number} value - Intensity in [0, 1].
- * @returns {void}
- */
- _setStageIntensity(stage, value) {
- this._visualEffects.setStageIntensity(stage, value);
- }
-
- /**
- * Re-sync every stage's `enabled` flag from its CURRENT intensity.
- *
- * The cockpit-vision policy helpers (src/cockpitVisionPolicy.js) are pure
- * intensity math — they write `uniforms.intensity` directly and know
- * nothing about the enabled/intensity lockstep _setStageIntensity owns.
- * Without this sweep a stage the policy raised to 1 would stay DISABLED
- * and cockpit NVG/FLIR/CRT would render nothing at all. (Inert while the
- * chain is permanently enabled; load-bearing again once the explicit
- * scope frees the zero-intensity stages — see _initStages.)
- * @returns {void}
- */
- _syncStagesEnabledFromIntensity() {
- this._visualEffects.syncStagesEnabledFromIntensity();
- }
-
- /**
- * 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 or in third-person tracking inside Contacts").
- *
- * The scope is the CONTACTS SESSION, not Cockpit. Cockpit enter/exit and
- * third-person tracking are moves WITHIN that session and deliberately do not
- * touch detection — an earlier build hooked this to cockpit enter/exit, which
- * is exactly what turned detections off when the owner left the cockpit.
- *
- * Called from `_syncContextModeButtons`, the single funnel every
- * `_contextMode` mutation routes through, and gated on the transaction having
- * SETTLED (`!_contextModeChanging`) so a failed activation can never strand
- * detection on.
- * @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();
- }
-
- /** 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 });
- }
-
- /** IR hot-target boost (owner playtest 2026-08-16): under the luminance-
- * mapped NVG/FLIR looks the 3D fleets flip to flat white so contacts read
- * HOT instead of vanishing mid-gray; restored when the look exits. The
- * EFFECTIVE look is Cockpit's vision override while Cockpit is active
- * ('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?.();
- }
- }
-
- /** 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);
- }
-
- /** 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);
- requestAnimationFrame(() => requestAnimationFrame(() => {
- this._sliderPanel?.scrollIntoView?.({ block: 'nearest' });
- }));
- }
-
- /**
- * Configures Cesium's built-in bloom stage and adds a custom unsharp-mask
- * sharpen stage to the post-process pipeline. Both start disabled.
- * @returns {void}
- */
- _initBloomSharpen() {
- this._visualEffects.initPostProcess(this._sharpenSlider ? parseInt(this._sharpenSlider.value, 10) / 100 : 0.6);
- }
-
- /**
- * Reads the current bloom intensity percentage from the effects controller.
- * @returns {number} Clamped bloom intensity (0-200).
- */
- _getBloomIntensity() {
- return this._visualEffects.bloomIntensity;
- }
-
- /**
- * Enables or disables the Cesium bloom stage based on both the user toggle
- * and whether the computed strength exceeds the perceptual threshold (0.06).
- * @returns {void}
- */
- _syncBloomStageEnabled() {
- this._visualEffects.syncBloomEnabled();
- }
-
- /**
- * Sets the bloom intensity, updates the slider UI, and applies the value.
- * @param {number} intensity - Raw intensity percentage.
- * @param {object} [options]
- * @param {boolean} [options.syncShare=true] - Whether to push state to the share link.
- * @returns {void}
- */
- _setBloomIntensity(intensity, { syncShare = true } = {}) {
- 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();
- }
-
- /**
- * Maps a bloom intensity percentage to Cesium bloom stage uniforms.
- * Uses smoothstep easing (Hermite interpolation: 3t^2 - 2t^3) to
- * produce a perceptually linear glow ramp from zero to full strength.
- * @param {number} intensity - Bloom intensity percentage (0-200).
- * @returns {void}
- */
- _applyBloomIntensity(intensity) {
- this._visualEffects.applyBloomIntensity(intensity);
- }
-
- /**
- * Toggles bloom on/off, syncs button state, and reveals/hides the intensity slider row.
- * @param {boolean} enabled - Whether bloom should be active.
- * @returns {void}
- */
- _setBloomEnabled(enabled) {
- 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();
- }
-
- /**
- * Maps a normalized sharpen value (0-1) to the unsharp-mask `amount` uniform.
- * Range: 0.1 (subtle) to 2.1 (aggressive edge enhancement).
- * @param {number} val - Normalized sharpen intensity (0.0 to 1.0).
- * @returns {void}
- */
- _applySharpenIntensity(val) {
- this._visualEffects.applySharpenIntensity(val);
- }
-
- /**
- * Toggles sharpening on/off, syncs button state, and reveals/hides the intensity slider row.
- * @param {boolean} enabled - Whether sharpening should be active.
- * @returns {void}
- */
- _setSharpenEnabled(enabled) {
- 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();
- }
-
- /**
- * Wires up all primary UI event listeners: style buttons, keyboard shortcuts
- * (1-8 style keys, H/O/V/F/D/C hotkeys, Escape), AI prompt input with
- * debounce, bloom/sharpen/HUD toggles, detection density slider, and
- * clean-view toggle.
- * @returns {void}
- */
- _initUI() {
- this._applicationShortcuts?.destroy();
- this._applicationShortcuts = bindApplicationShortcuts({
- documentRef: document,
- searchInput: this._locationSearch,
- actions: {
- setStyle: (style) => this.setStyle(style),
- dismissSearch: () => {
- if (this._locationSearch.classList.contains('expanded')) {
- this._locationSearch.classList.remove('expanded');
- this._locationSearch.value = '';
- this._locationSearch.blur();
- }
- },
- toggleHud: () => {
- this.shareLinkManager?.claimRestoreLane?.('visual');
- this.hud.toggle();
- this._updateHudButtonState();
- this._syncShareState();
- },
- toggleOrbit: () => this._toggleOrbit(),
- toggleCleanView: () => this.toggleCleanView(),
- toggleLayers: () => document.getElementById('data-panel').classList.toggle('active'),
- cycleDetection: () => {
- this.shareLinkManager?.claimRestoreLane?.('visual');
- this._detectionUserOverridden = true;
- cycleDetectionMode();
- this._syncShareState();
- },
- toggleCctv: () => this._toggleCctvEnabled(),
- },
- });
-
- this._displayControls?.destroy();
- this._displayControls = bindDisplayControls({
- elements: {
- styleButtons: document.querySelectorAll('.style-btn'),
- bloomButton: this._bloomBtn, bloomSlider: this._bloomSlider,
- sharpenButton: this._sharpenBtn, sharpenSlider: this._sharpenSlider,
- scopeButton: this._scopeBtn, scopeFeatherSlider: this._scopeFeatherSlider,
- hudLayout: this._hudLayoutSelect, hudButton: this._hudBtn,
- cleanViewButton: this._cleanViewBtn, cleanViewExitButton: this._cleanViewExitBtn,
- densitySlider: this._detectionDensitySlider, detectionButton: this._detectionBtn,
- allocationButtons: this._detectionAllocationBtns,
- fadeSliders: [this._detectionFadeSlider, this._detectionOpacitySlider],
- celestialButton: this._celestialBtn,
- modelsButton: this._models3dBtn,
- modelModeButtons: this._models3dBtn ? this._models3dModeBtns : [],
- },
- actions: {
- setStyle: (style) => this.setStyle(style),
- toggleBloom: () => {
- this.shareLinkManager?.claimRestoreLane?.('visual');
- this._setBloomEnabled(!this.bloomEnabled);
- },
- setBloomIntensity: (value) => {
- this.shareLinkManager?.claimRestoreLane?.('visual');
- this._setBloomIntensity(value);
- },
- toggleSharpen: () => {
- this.shareLinkManager?.claimRestoreLane?.('visual');
- this._setSharpenEnabled(!this.sharpenEnabled);
- },
- toggleScope: () => {
- this.shareLinkManager?.claimRestoreLane?.('visual');
- const next = !isScopeMaskEnabled();
- setScopeMaskEnabled(next);
- this._scopeBtn.classList.toggle('active', next);
- this._scopeBtn.setAttribute('aria-pressed', String(next));
- this._syncShareState();
- },
- setScopeFeather: (value) => {
- this.shareLinkManager?.claimRestoreLane?.('visual');
- const pct = Math.max(0, Math.min(100, value || 0));
- if (this._scopeFeatherValue) this._scopeFeatherValue.textContent = `${pct}%`;
- setScopeMaskFeather(pct / 100);
- this._syncShareState();
- },
- setSharpenIntensity: (pct) => {
- this.shareLinkManager?.claimRestoreLane?.('visual');
- if (this._sharpenSliderValue) this._sharpenSliderValue.textContent = `${pct}%`;
- this._applySharpenIntensity(pct / 100);
- this._syncShareState();
- },
- setHudLayout: (value) => {
- this.shareLinkManager?.claimRestoreLane?.('visual');
- this._setHudVariant(value);
- },
- toggleCleanView: () => this.toggleCleanView(),
- exitCleanView: () => this.toggleCleanView(false),
- setDensity: (value) => {
- this.shareLinkManager?.claimRestoreLane?.('visual');
- this._detectionUserOverridden = true;
- const pct = canonicalizeDensity(value);
- this._detectionDensitySlider.value = String(pct);
- if (this._detectionDensityValue) this._detectionDensityValue.textContent = `${pct}%`;
- this._applyDetectionDensityFromUi();
- this._syncShareState();
- },
- setAllocation: (value) => {
- this.shareLinkManager?.claimRestoreLane?.('visual');
- this._detectionUserOverridden = true;
- this._setDetectionAllocation(value);
- },
- setFade: () => {
- this.shareLinkManager?.claimRestoreLane?.('visual');
- this._applyDetectionFadeFromUi();
- this._syncShareState();
- },
- toggleCelestial: () => {
- const ringIsVisible = !!this.celestialRing?.visible;
- if (!this.celestialRingEnabled || !ringIsVisible) {
- this.setCelestialRingEnabled(true, { focus: true });
- } else {
- this.setCelestialRingEnabled(false);
- }
- },
- toggleHud: () => {
- this.shareLinkManager?.claimRestoreLane?.('visual');
- this.hud.toggle();
- this._updateHudButtonState();
- this._syncShareState();
- },
- cycleDetection: () => {
- this.shareLinkManager?.claimRestoreLane?.('visual');
- this._detectionUserOverridden = true;
- cycleDetectionMode();
- this._syncShareState();
- },
- toggleModels: () => {
- this._setModels3dEnabled(!this._models3dEnabled);
- this._syncModels3dModeRow();
- },
- setModelsMode: (mode) => this._setModels3dMode(mode),
- },
- });
- }
-
- /**
- * Renders the owner-approved map stack chip row from the matching controller
- * entries. Cesium ion/Bing chips remain keyboard-focusable but unavailable,
- * with an accessible explanation, until a CESIUM_ION_TOKEN is configured.
- * @returns {void}
- */
- _initMapStackControl() {
- if (!this.mapStackController) return;
- this._mapSourceControls?.destroy();
- this._mapSourceControls = createMapSourceControls({
- container: this._mapStackChips,
- statusElement: this._mapStackStatus,
- controller: this.mapStackController,
- subscribe: (onChange) => {
- window.addEventListener('gev:map-stack-changed', onChange);
- return () => window.removeEventListener('gev:map-stack-changed', onChange);
- },
- claimSelection: () => this.shareLinkManager?.claimRestoreLane?.('map'),
- onStateChanged: () => this._syncShareState(),
- onError: (message) => this._showToast(message),
- });
- }
-
- /**
- * Switches the active map/globe source stack.
- * @param {string} stackId - Map stack id.
- * @param {object} [options]
- * @param {boolean} [options.syncShare=true] - Whether to update the share link.
- * @returns {Promise}
- */
- async _setMapStack(stackId, { syncShare = true } = {}) {
- if (!this.mapStackController) return;
- return this._mapSourceControls.select(stackId, { syncShare });
- }
-
- /**
- * Syncs the map stack chip row and status chip with controller state. The
- * lit chip always follows `state.activeId`, never the click — a rejected or
- * superseded switch therefore leaves the genuinely active stack lit.
- * @param {object} state - Map stack controller state.
- * @returns {void}
- */
- _renderMapStackState(state) {
- this._mapSourceControls?.render(state);
- }
-
- /**
- * Reads and canonicalizes the five-stop density control. The engine derives
- * Sparse/Balanced/Dense from the same stop.
- * @returns {void}
- */
- _applyDetectionDensityFromUi() {
- 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());
- }
-
- /** Apply responsive keyhole fade controls from normalized UI percentages. */
- _applyDetectionFadeFromUi() {
- 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 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 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());
- }
-
- /**
- * Activates a detection overlay mode by label (e.g. 'OFF', 'SPARSE', 'PANOPTIC').
- * @param {string} modeLabel - Detection mode label to set.
- * @returns {void}
- */
- _setDetectionMode(modeLabel) {
- if (!modeLabel) return;
- setDetectionModeByLabel(modeLabel);
- this._syncDetectionUiFromEngine();
- this._syncShareState();
- }
-
- /**
- * Switches the HUD layout variant (e.g. 'tactical', 'minimal') and syncs
- * the layout dropdown if present.
- * @param {string} variantName - HUD variant identifier.
- * @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 });
- }
-
- /**
- * Keeps both responsive panel lanes and Cockpit's utility strip on the same
- * measured layout commit. HUD visibility transitions can outlive the first
- * animation frame, so variant changes receive one bounded settling pass.
- * @param {{settle?: boolean}} [options] Whether to remeasure after transitions.
- * @returns {void}
- */
- _scheduleAdaptivePanelLayout({ settle = false } = {}) {
- this._scheduleLeftPanelLayout({ reconsiderAutoCollapse: true });
- this._scheduleRightPanelLayout({ reconsiderAutoCollapse: true });
- this.cockpitView?.scheduleContextLayout();
- if (!settle) return;
- clearTimeout(this._adaptivePanelSettleTimer);
- this._adaptivePanelSettleTimer = setTimeout(() => {
- this._adaptivePanelSettleTimer = null;
- this._scheduleLeftPanelLayout({ reconsiderAutoCollapse: true });
- this._scheduleRightPanelLayout({ reconsiderAutoCollapse: true });
- this.cockpitView?.scheduleContextLayout();
- }, COCKPIT_LAYOUT_SETTLE_MS);
- }
-
- /**
- * Applies preset defaults (bloom, sharpen, shader uniforms, HUD variant)
- * when a military-class style (CRT, NVG, FLIR) is selected. Does nothing
- * for styles without entries in STYLE_PRESET_DEFAULTS.
- * @param {string} styleName - The style whose defaults to apply.
- * @returns {void}
- */
- _applyStylePresetDefaults(styleName) {
- 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);
- }
- }
-
- /**
- * Apply a detection preset's density and mode through the real UI path.
- *
- * Deliberately does NOT consult `_detectionUserOverridden` — the CALLER owns
- * that decision. The style path checks it (an explicit Sparse/Off must
- * survive a style switch); Cockpit entry does not (owner: detection is on in
- * the cockpit "regardless").
- * @param {{mode?: string, densityPct?: number}} det Preset detection config.
- * @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());
- }
-
- /**
- * Applies the global post-processing baseline (GLOBAL_POST_DEFAULTS) at
- * startup before any share-link restore runs. Sets bloom, sharpen, HUD,
- * and detection to their factory defaults.
- * @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 });
- }
- }
-
- /**
- * Pushes the current visual state (bloom, sharpen, HUD, detection) to
- * the ShareLinkManager so the URL hash stays in sync.
- * @returns {void}
- */
- /**
- * Detection as a DURABLE preference, for serialization into a share link.
- *
- * While Contacts is active it OWNS detection and forces Dense @ 75%. That is
- * a session-scoped override, not something the operator chose: it is undone
- * verbatim on deactivation. Serializing the forced values shipped a link that
- * pinned Dense @ 75% on the recipient — as a durable preference, with no
- * Contacts mode present to explain or undo it — even though the author's own
- * setting was (say) OFF @ 50%. Publish what deactivation would restore.
- *
- * `_contactsDetectionRestore` is exactly that snapshot and is null whenever
- * Contacts does not own detection, so the live values are used normally.
- */
- _shareableDetectionState() {
- return shareableDetectionState({
- owned: this._contactsDetectionRestore,
- liveMode: getDetectionMode(),
- liveDensityPct: parseInt(this._detectionDensitySlider?.value || '50', 10),
- });
- }
-
- _syncShareState() {
- const detection = this._shareableDetectionState();
- this.shareLinkManager.onToggleChange(this.bloomEnabled, this.sharpenEnabled, {
- 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',
- });
- }
-
- /**
- * Updates the traffic sync status chip with loading phase label and progress.
- * Auto-hides after 1.5s when loading completes; stays visible while busy.
- * @param {boolean} [forceShow=false] - Force the chip visible regardless of busy state.
- * @returns {void}
- */
- _updateTrafficSyncChip(forceShow = false, now = performance.now()) {
- if (!this._trafficSyncChip || !this._trafficSyncLabel || !this._trafficSyncProgress) return;
- const layers = this._dataManager?.getAll?.();
- const traffic = Array.isArray(layers) ? layers.find((layer) => layer.id === 'traffic') : null;
- this._trafficSyncFeedbackState = reduceTrafficSyncFeedback(
- this._trafficSyncFeedbackState,
- { enabled: traffic?.enabled === true, stats: traffic?.stats || {}, forceShow },
- now,
- );
- const presentation = this._trafficSyncFeedbackState;
- // setSplitFlapText carries the same unchanged-text guard internally, and
- // the flap keeps textContent equal to the settled label throughout, so
- // this stays a no-op on the repeat ticks exactly as it did before.
- if (presentation.label) setSplitFlapText(this._trafficSyncLabel, presentation.label);
- // Written on every change INCLUDING the empty settled value — the reducer
- // clears the progress number once the sync lands, and a truthiness guard
- // here would strand the last "..." beside the settled label.
- if (this._trafficSyncProgress.textContent !== presentation.progressText) {
- this._trafficSyncProgress.textContent = presentation.progressText;
- }
- this._trafficSyncChip.classList.toggle('visible', presentation.visible);
- }
-
- /**
- * Initializes panel collapse buttons and restores persisted collapsed state.
- * Also sets up hover-expand behavior for the style presets and location bar panels.
- * @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();
- }
-
- /**
- * Collapses the nearest expanded panel that owns keyboard focus on Escape.
- * Nested panels consume the event first, so one key closes one level and
- * returns focus to that level's disclosure. If Escape was pressed on the
- * disclosure itself, remove focus after closing so the collapsed button does
- * not keep a stale keyboard ring.
- * @param {KeyboardEvent} event - Candidate Escape key event.
- * @param {string} panelId - Collapsible panel containing the listener.
- * @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();
- },
- });
- }
-
- /**
- * Allows either command-dock tray to remain open until explicitly unpinned.
- * Both trays may be pinned; transient and error trays stack above them.
- * @returns {void}
- */
- _initCommandDockPins() {
- document.querySelectorAll('.dock-pin-btn[data-pin-target]').forEach((button) => {
- button.addEventListener('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;
- }
-
- /**
- * Tracks the live pinned-tray height so a hovered sibling can stack above it
- * without hardcoded content dimensions.
- * @returns {void}
- */
- _initCommandDockTrayMetrics() {
- const dock = document.getElementById('command-dock');
- if (!dock) return;
- this._commandDockTrayObserver?.disconnect?.();
- if (typeof ResizeObserver === 'function') {
- this._commandDockTrayObserver = new ResizeObserver(() => this._updateCommandDockTrayStack());
- dock.querySelectorAll('.dock-popover-content').forEach((tray) => {
- this._commandDockTrayObserver.observe(tray);
- });
- }
- this._updateCommandDockTrayStack();
- }
-
- /**
- * Writes each pinned tray height and their combined stack height as CSS
- * variables. The most recently pinned tray forms the upper level.
- * @returns {void}
- */
- _updateCommandDockTrayStack() {
- const dock = document.getElementById('command-dock');
- if (!dock) return;
- const locationPanel = dock.querySelector('#location-bar.dock-pinned:not(.collapsed)');
- const presetsPanel = dock.querySelector('#control-panel.dock-pinned:not(.collapsed)');
- const locationHeight = locationPanel?.querySelector('.dock-popover-content')?.getBoundingClientRect().height || 0;
- const presetsHeight = presetsPanel?.querySelector('.dock-popover-content')?.getBoundingClientRect().height || 0;
- const pinnedCount = Number(locationHeight > 0) + Number(presetsHeight > 0);
- const locationHeightPx = Math.ceil(locationHeight);
- const presetsHeightPx = Math.ceil(presetsHeight);
- const topPinnedPanel = dock.querySelector('.dock-pinned-top.dock-pinned:not(.collapsed)');
- const lowerPinnedPanel = topPinnedPanel?.id === 'location-bar' ? presetsPanel : locationPanel;
- const lowerPinnedHeight = lowerPinnedPanel
- ?.querySelector('.dock-popover-content')
- ?.getBoundingClientRect().height || 0;
- const stackHeight = pinnedCount > 1
- ? `calc(${locationHeightPx}px + ${presetsHeightPx}px + 1.2rem)`
- : `${locationHeightPx + presetsHeightPx}px`;
- dock.style.setProperty('--dock-location-pinned-height', `${locationHeightPx}px`);
- dock.style.setProperty('--dock-presets-pinned-height', `${presetsHeightPx}px`);
- dock.style.setProperty('--dock-lower-pinned-height', `${Math.ceil(lowerPinnedHeight)}px`);
- dock.style.setProperty('--dock-pinned-stack-height', stackHeight);
- dock.classList.toggle('dock-has-pinned-tray', pinnedCount > 0);
- dock.classList.toggle('dock-has-two-pinned-trays', pinnedCount > 1);
- }
-
- /**
- * One-time toast when stored v6 panel positions are superseded by the v7
- * layout defaults (positions reset; collapsed states are preserved).
- * @returns {void}
- */
- _maybeNotifyLayoutReset() {
- try {
- const marker = `godsEyeView.${PANEL_POSITION_STORAGE_VERSION}.layoutResetNotified`;
- if (localStorage.getItem(marker)) return;
- localStorage.setItem(marker, '1');
- const hadOldPositions = Object.keys(localStorage)
- .some((key) => key.startsWith('godsEyeView.v6.panelPos.'));
- if (hadOldPositions) {
- this._showToast('Panel layout updated — positions reset to new defaults');
- }
- } catch {
- // storage unavailable
- }
- }
-
- /**
- * Configures intentional hover-expand / leave-collapse behavior on a panel.
- * Uses separate open/close timers to prevent accidental flicker from fast
- * mouse passes. Wheel events cancel pending opens to avoid surprise expansion
- * during scroll-through.
- * @param {string} panelId - DOM id of the panel element.
- * @param {object} [options]
- * @param {number} [options.openDelayMs=850] - Hover dwell time before auto-expanding.
- * @param {number} [options.closeDelayMs=1000] - Delay after pointer leaves before collapsing.
- * @returns {void}
- */
- _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;
- }
- }
-
- /**
- * Sets up drag-to-reposition for legacy floating controls. The right rail
- * and left accordion remain fixed so their HUD alignment is deterministic.
- * @returns {void}
- */
- _initPanelDrag() {
- const dragSpecs = [
- {
- id: 'pp-toggles',
- panel: this._ppToggles,
- handle: this._ppToggles?.querySelector('.panel-drag-handle.compact'),
- },
- ].filter(Boolean);
-
- for (const spec of dragSpecs) {
- if (!spec.panel || !spec.handle) continue;
- this._restorePanelPosition(spec.id, spec.panel);
- this._makePanelDraggable(spec.id, spec.panel, spec.handle);
- }
- // Keep a positioned panel on-screen when its HEIGHT changes after restore — it expands to its
- // full row set a frame or two later, so the restore-time clamp used a stale (shorter) height and
- // the panel could still hang off the bottom (audit U2). Re-clamp on every size change.
- if (this._ppToggles && typeof ResizeObserver !== 'undefined') {
- this._draggableResizeObserver = new ResizeObserver(() => this._reclampDraggablePanels());
- this._draggableResizeObserver.observe(this._ppToggles);
- }
- }
-
- _persistAwarenessSelection(event, cleared = false) {
- if (!this._dataManager) return;
- const origin = String(event?.detail?.origin || 'programmatic');
- if (!isExplicitLayerStateOrigin(origin)) return;
- const layerId = String(event?.detail?.layerId || '');
- const config = {
- flights: {
- key: 'selectedFlightsTrackingId',
- normalize: (value) => String(value ?? '').trim().toLowerCase() || null,
- },
- military: {
- key: 'selectedMilitaryTrackingId',
- normalize: (value) => String(value ?? '').trim().toLowerCase() || null,
- },
- satellites: {
- key: 'selectedSatTrackingId',
- normalize: (value) => {
- const candidate = Number(value);
- return Number.isFinite(candidate) && candidate > 0 ? Math.trunc(candidate) : null;
- },
- },
- }[layerId];
- if (!config) return;
- const selectedValue = cleared ? null : config.normalize(event?.detail?.id);
- if (cleared || selectedValue === null) {
- this._dataManager.adoptLayerParams?.(layerId, {
- [config.key]: selectedValue,
- }, { origin });
- return;
- }
- // A direct selection promotes a Context-owned tracker dependency into
- // durable visibility before its selected ID is normalized. Context exit
- // also keeps this adopted layer instead of tearing down the user's track.
- const visibilityAdopted = this._dataManager.adoptLayerVisibility?.(
- layerId,
- true,
- { origin, adoptedFromSelection: true },
- );
- if (visibilityAdopted === false) return;
- // Clear the prior family before publishing the replacement. Otherwise the
- // coordinator briefly sees two IDs and correctly treats them as an
- // ambiguous incoming state, which would discard the new durable target.
- for (const [otherLayerId, otherKey] of [
- ['flights', 'selectedFlightsTrackingId'],
- ['military', 'selectedMilitaryTrackingId'],
- ['satellites', 'selectedSatTrackingId'],
- ]) {
- if (otherLayerId === layerId) continue;
- this._dataManager.setLayerParams(otherLayerId, { [otherKey]: null }, { origin });
- }
- this._dataManager.adoptLayerParams?.(layerId, {
- [config.key]: selectedValue,
- }, { origin });
- }
-
- /**
- * Connects the layer data manager for traffic sync, CCTV state subscription,
- * and layer enable/disable operations.
- * @param {object|null} dataManager - The DataManager instance, or null to detach.
- * @returns {void}
- */
- attachDataManager(dataManager) {
- this._dataManager = dataManager || null;
- this.hud.attachDataManager(this._dataManager);
- this._updateTrafficSyncChip();
- if (this._dataManagerUnsubscribe) {
- this._dataManagerUnsubscribe();
- this._dataManagerUnsubscribe = null;
- }
- this._contextControls.connect(this._dataManager);
- if (typeof this._dataManager?.subscribe === 'function') {
- this._dataManagerUnsubscribe = this._dataManager.subscribe((change) => {
- this._loadingFeedbackEvent = change;
- this._updateGlobalLoadingFeedback(performance.now());
- });
- }
- this._updateGlobalLoadingFeedback(performance.now());
- this._syncContextModeButtons();
- this._cctvControls.connect();
- this._radioControls.connect();
- if (!this._awarenessSelectedHandler) {
- this._awarenessSelectedHandler = (event) => this._persistAwarenessSelection(event, false);
- this._awarenessClearedHandler = (event) => this._persistAwarenessSelection(event, true);
- window.addEventListener('gev:awareness-subject-selected', this._awarenessSelectedHandler);
- window.addEventListener('gev:awareness-subject-cleared', 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());
- });
- }
- }
-
- _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._globalStatusNotice?.state === 'acquiring') {
- this._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 = () => {
- requestAnimationFrame(() => {
- 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;
- const showOnce = () => {
- startupCover.removeEventListener('transitionend', showOnce);
- if (fallbackTimer) clearTimeout(fallbackTimer);
- if (canPresentDeferredStatusNotice(
- noticeGeneration,
- this._shareTrackingNoticeGeneration,
- this._disposed,
- )) this._showGlobalStatusNotice(message);
- };
- startupCover.addEventListener('transitionend', showOnce, { once: true });
- fallbackTimer = setTimeout(showOnce, 1000);
- });
- };
- if (this._resolveInitialShareRestore) {
- void this.initialRestorePromise.then(showAfterStartupCover);
- return;
- }
- showAfterStartupCover();
- }
-
- get _contextMode() { return this._contextControls?._contextMode ?? null; }
- get _contextModeChanging() { return this._contextControls?._contextModeChanging ?? false; }
- get _preservePanelStateDuringLayerClear() { return this._contextControls?._preservePanelStateDuringLayerClear ?? false; }
-
- _initGlobalContextPanel() {
- this._contextControls = new ContextControls({
- elements: {
- _globalContextPanel: document.getElementById('global-context-panel'),
- _globalContextFlightsBtn: this._globalContextFlightsBtn,
- _globalContextMissionsBtn: this._globalContextMissionsBtn,
- _contextModeStandby: this._contextModeStandby,
- _contextFlightsView: this._contextFlightsView,
- _contextMissionsView: this._contextMissionsView,
- _installationsSearchBtn: this._installationsSearchBtn,
- },
- installations: militaryInstallationsLayer,
- actions: {
- getCockpit: () => this.cockpitView,
- refreshRadio: () => this._renderRadioState(radioLayer.getUIState()),
- claimVisualAuthority: () => this.shareLinkManager?.claimRestoreLane?.('visual'),
- syncDetection: () => this._syncContactsDetection(),
- scheduleLayout: () => this._scheduleRightPanelLayout(),
- setPanelCollapsed: (...args) => this.setPanelCollapsed(...args),
- showToast: (message) => { if (!this._disposed) this._showToast(message); },
- setClearBusy: (busy) => { if (!this._disposed) this._clearLayersControl?.setBusy(busy); },
- },
- });
- }
-
- _runUserFacingContextAction(...args) { return this._contextControls?._runUserFacingContextAction(...args); }
-
- _waitForContextLayerSettlement(...args) { return this._contextControls?._waitForContextLayerSettlement(...args); }
-
- _syncContextModeButtons(...args) { return this._contextControls?._syncContextModeButtons(...args); }
-
- /** Wire the independent Radio companion controls. */
- _initRadioPanel() {
- this._radioControls?.destroy();
- this._radioControls = new RadioControls({
- elements: {
- _cockpitDisplayPanel: this._cockpitDisplayPanel,
- _cockpitDisplayToggleBtn: this._cockpitDisplayToggleBtn,
- _cockpitRadioEnableBtn: this._cockpitRadioEnableBtn,
- _cockpitRadioNextBtn: this._cockpitRadioNextBtn,
- _cockpitRadioPanel: this._cockpitRadioPanel,
- _cockpitRadioPlayBtn: this._cockpitRadioPlayBtn,
- _cockpitRadioPrevBtn: this._cockpitRadioPrevBtn,
- _cockpitRadioStation: this._cockpitRadioStation,
- _cockpitRadioToggleBtn: this._cockpitRadioToggleBtn,
- _cockpitRadioVolume: this._cockpitRadioVolume,
- _cockpitRadioVolumeValue: this._cockpitRadioVolumeValue,
- _cockpitUtilityControls: this._cockpitUtilityControls,
- _contextRadioDetailsBtn: this._contextRadioDetailsBtn,
- _contextRadioDock: this._contextRadioDock,
- _contextRadioMini: this._contextRadioMini,
- _contextRadioMiniCloseBtn: this._contextRadioMiniCloseBtn,
- _contextRadioMiniEnableBtn: this._contextRadioMiniEnableBtn,
- _contextRadioMiniNextBtn: this._contextRadioMiniNextBtn,
- _contextRadioMiniPlayBtn: this._contextRadioMiniPlayBtn,
- _contextRadioMiniPrevBtn: this._contextRadioMiniPrevBtn,
- _contextRadioMiniStation: this._contextRadioMiniStation,
- _contextRadioMiniVolume: this._contextRadioMiniVolume,
- _contextRadioMiniVolumeValue: this._contextRadioMiniVolumeValue,
- _contextRadioToggleBtn: this._contextRadioToggleBtn,
- _radioEnableBtn: this._radioEnableBtn,
- _radioFilter: this._radioFilter,
- _radioLayerState: this._radioLayerState,
- _radioNextBtn: this._radioNextBtn,
- _radioPanel: this._radioPanel,
- _radioPlayBtn: this._radioPlayBtn,
- _radioPlaybackState: this._radioPlaybackState,
- _radioPrevBtn: this._radioPrevBtn,
- _radioStationHomepage: this._radioStationHomepage,
- _radioStationMeta: this._radioStationMeta,
- _radioStationName: this._radioStationName,
- _radioStationTags: this._radioStationTags,
- _radioStopBtn: this._radioStopBtn,
- _radioTuner: this._radioTuner,
- _radioTunerBandLabel: this._radioTunerBandLabel,
- _radioTunerNeedle: this._radioTunerNeedle,
- _radioTunerSlider: this._radioTunerSlider,
- _radioTunerStation: this._radioTunerStation,
- _radioTunerValue: this._radioTunerValue,
- _radioVolume: this._radioVolume,
- _radioVolumeValue: this._radioVolumeValue,
- },
- radio: radioLayer,
- canvas: this.viewer?.canvas,
- actions: {
- isRegistered: () => this._dataManager?.layers?.has('radio'),
- isEnabled: () => this._dataManager?.isEnabled('radio'),
- setEnabled: (enabled, options) => this._dataManager.setEnabled('radio', enabled, options),
- setParams: (params, options) => this._dataManager?.setLayerParams('radio', params, options),
- getLifecycle: () => this._dataManager?.getLayerLifecycleState?.('radio'),
- runUserAction: (...args) => this._runUserFacingContextAction(...args),
- setPanelCollapsed: (...args) => this.setPanelCollapsed(...args),
- revealStyleParameters: () => this._revealCockpitStyleParameters(),
- setSignalCollapsed: (value) => this.cockpitView?.setSignalCollapsed(value),
- isCockpitActive: () => this.cockpitView?.active,
- signalUserCollapsed: () => this.cockpitView?.signalUserCollapsed,
- layoutCockpit: () => this.cockpitView?.scheduleContextLayout(),
- preservePanelStateDuringClear: () => this._preservePanelStateDuringLayerClear,
- scheduleLayout: () => this._scheduleRightPanelLayout(),
- },
- });
- }
-
- _setCockpitDisclosure(...args) { return this._radioControls?._setCockpitDisclosure?.(...args); }
-
- _setRadioDisclosure(...args) { return this._radioControls?._setRadioDisclosure?.(...args); }
-
- _syncContextRadioLauncherState(...args) { return this._radioControls?._syncContextRadioLauncherState?.(...args); }
-
- _renderRadioState(...args) { return this._radioControls?._renderRadioState?.(...args); }
-
- /**
- * Activates an explicit CCTV target, then releases tracking before its camera
- * flight. Cockpit mode keeps tracking and suppresses only the flight.
- * @param {Function} activate CCTV target activation returning its camera ID.
- * @param {Function} focus CCTV camera flight receiving the activated ID.
- * @returns {*} Focus operation result.
- */
- _runExplicitCctvFocus(activate, focus) {
- if (this._disposed) return false;
- const cameraId = activate();
- if (!cameraId) return false;
- return this._runExplicitNavigation('camera', () => focus(cameraId));
- }
-
- /** Compose camera panel controls from the existing camera port and application actions. */
- _initCctvPanel() {
- this._cctvControls?.destroy();
- this._cctvControls = new CctvControls({
- elements: {
- _cctvAdjustBtn: this._cctvAdjustBtn,
- _cctvAutoHopBtn: this._cctvAutoHopBtn,
- _cctvCalReadout: this._cctvCalReadout,
- _cctvCalibResetBtn: this._cctvCalibResetBtn,
- _cctvCalibSaveBtn: this._cctvCalibSaveBtn,
- _cctvCoverageBtn: this._cctvCoverageBtn,
- _cctvEnableBtn: this._cctvEnableBtn,
- _cctvFocusBtn: this._cctvFocusBtn,
- _cctvFrame: this._cctvFrame,
- _cctvFrameWrap: this._cctvFrameWrap,
- _cctvMeta: this._cctvMeta,
- _cctvNearestBtn: this._cctvNearestBtn,
- _cctvNextBtn: this._cctvNextBtn,
- _cctvPanel: this._cctvPanel,
- _cctvPrevBtn: this._cctvPrevBtn,
- _cctvProjectionBtn: this._cctvProjectionBtn,
- _cctvQualityChip: this._cctvQualityChip,
- _cctvSelect: this._cctvSelect,
- _cctvSourceBadge: this._cctvSourceBadge,
- _cctvSummary: this._cctvSummary,
- _cctvSyncChip: this._cctvSyncChip,
- _cctvSyncLabel: this._cctvSyncLabel,
- _cctvSyncProgress: this._cctvSyncProgress,
- },
- cctv: cctvLayer,
- actions: {
- isEnabled: () => this._dataManager?.isEnabled('cctv'),
- setParams: (params, options) => this._dataManager?.setLayerParams('cctv', params, options),
- toggleEnabled: (...args) => this._toggleCctvEnabled(...args),
- runExplicitFocus: (...args) => this._runExplicitCctvFocus(...args),
- setPanelCollapsed: (...args) => this.setPanelCollapsed(...args),
- showToast: (message) => this._showToast(message),
- syncViewport: () => this._syncCctvPanelViewport(),
- setSplitFlapText,
- },
- });
- }
-
- /**
- * Toggles the CCTV layer enabled state. When enabling and no camera is active,
- * auto-focuses on the nearest camera.
- * @param {boolean} [forceState] - Explicit on/off. Omit to toggle.
- * @returns {Promise} True if the layer is now in the requested state.
- */
- async _toggleCctvEnabled(forceState) {
- if (this._disposed) return false;
- if (!this._dataManager || !this._dataManager.layers?.has('cctv')) {
- this._showToast('CCTV layer unavailable');
- return false;
- }
- const enabled = this._dataManager.isEnabled('cctv');
- const target = typeof forceState === 'boolean' ? forceState : !enabled;
- if (target === enabled) return true;
- await runCctvLayerEnableTransition({
- target,
- setEnabled: (next) => this._dataManager.setEnabled('cctv', next, { origin: 'user' }),
- readOwnership: () => ({
- trackedEntity: this.viewer?.trackedEntity,
- cockpitActive: !!this.cockpitView?.active,
- }),
- shouldFocus: () => !this._disposed && this._dataManager.isEnabled('cctv')
- && !this._cctvControls?.getState()?.activeCameraId,
- activate: () => cctvLayer.focusNearest({ focus: false }),
- fly: (cameraId) => this._runExplicitCctvFocus(
- () => cameraId,
- (selectedId) => cctvLayer.focusCamera(selectedId, 1.6),
- ),
- });
- return true;
- }
-
- /**
- * Returns the versioned localStorage key for a panel's saved position.
- * @param {string} panelId - DOM id of the panel.
- * @returns {string} localStorage key.
- */
- _panelStorageKey(panelId) {
- return `godsEyeView.${PANEL_POSITION_STORAGE_VERSION}.panelPos.${panelId}`;
- }
-
- /**
- * Returns the versioned localStorage key for a panel's collapsed state.
- * @param {string} panelId - DOM id of the panel.
- * @returns {string} localStorage key.
- */
- _panelCollapseStorageKey(panelId) {
- return `godsEyeView.${PANEL_LAYOUT_STORAGE_VERSION}.panelCollapsed.${panelId}`;
- }
-
- /**
- * Restores a panel's collapsed/expanded state from localStorage.
- * Falls back to the CSS class default if no saved state exists.
- * @param {string} panelId - DOM id of the panel.
- * @returns {void}
- */
- _restorePanelCollapsedState(panelId, { allowStored = true } = {}) {
- const panelEl = document.getElementById(panelId);
- if (!panelEl) return;
- let collapsed = panelEl.classList.contains('collapsed');
- let stored = null;
- if (allowStored) {
- try {
- stored = localStorage.getItem(this._panelCollapseStorageKey(panelId));
- if (stored === '1') collapsed = true;
- if (stored === '0') collapsed = false;
- } catch {
- // storage unavailable
- }
- }
- // DISPLAY starts COLLAPSED for a first-time visitor, then respects the
- // user's persisted choice like every other panel.
- //
- // It used to start expanded, to advertise the HUD / DETECT / 3D toggles.
- // That reason expired when those became ON by default: the rail now opens
- // to offer controls for things already happening, while competing with the
- // first-run mission card for the one first impression there is. A stored
- // choice still wins in both directions, so anyone who opens it keeps it.
- if (panelId === 'pp-toggles' && stored === null) collapsed = true;
- panelEl.classList.toggle('collapsed', collapsed);
- this._syncPanelCollapseButton(panelEl);
- }
-
- /**
- * Persists a panel's collapsed state ('1' or '0') to localStorage.
- * @param {string} panelId - DOM id of the panel.
- * @param {boolean} collapsed - Whether the panel is collapsed.
- * @returns {void}
- */
- _savePanelCollapsedState(panelId, collapsed) {
- try {
- localStorage.setItem(this._panelCollapseStorageKey(panelId), collapsed ? '1' : '0');
- } catch {
- // storage unavailable
- }
- }
-
- /**
- * Builds one fixed right-side rail from Display, CCTV, its parameter
- * controls, and Global Context (which owns the nested Radio companion).
- * The rail then measures the live HUD chrome at runtime so it can stay
- * aligned and within the available vertical corridor.
- * @returns {void}
- */
- _initRightPanelAdaptiveLayout() {
- const stack = this._rightPanelStack;
- if (!stack || !this._ppToggles) return;
-
- this._ppToggles.style.removeProperty('top');
- this._ppToggles.style.removeProperty('right');
- this._ppToggles.style.removeProperty('bottom');
- this._ppToggles.style.removeProperty('left');
- this._ppToggles.style.removeProperty('z-index');
- this._ppToggles.classList.remove('panel-draggable', 'panel-dragging');
- this._ppToggles.querySelector('.pp-header-row')?.removeAttribute('title');
- stack.prepend(this._ppToggles);
- const globalContextPanel = document.getElementById('global-context-panel');
- if (this._cctvPanel) {
- this._cctvPanel.style.removeProperty('top');
- this._cctvPanel.style.removeProperty('right');
- this._cctvPanel.style.removeProperty('bottom');
- this._cctvPanel.style.removeProperty('left');
- this._cctvPanel.style.removeProperty('z-index');
- this._cctvPanel.classList.remove('panel-draggable', 'panel-dragging');
- stack.insertBefore(this._cctvPanel, globalContextPanel);
- this._syncPanelCollapseButton(this._cctvPanel);
- }
- if (this._sliderPanel) {
- this._sliderPanel.style.removeProperty('top');
- this._sliderPanel.style.removeProperty('right');
- this._sliderPanel.style.removeProperty('bottom');
- this._sliderPanel.style.removeProperty('left');
- this._sliderPanel.style.removeProperty('max-height');
- const detectionGroup = this._detectionBtn?.closest('.pp-toggle-group');
- if (detectionGroup) detectionGroup.after(this._sliderPanel);
- else this._ppToggles.append(this._sliderPanel);
- }
- if (typeof ResizeObserver !== 'undefined') {
- this._rightStackResizeObserver = new ResizeObserver(() => {
- this._scheduleRightPanelLayout();
- });
- this._rightStackResizeObserver.observe(stack);
- for (const panel of [this._ppToggles, this._cctvPanel, globalContextPanel]) {
- if (panel) this._rightStackResizeObserver.observe(panel);
- }
- document.querySelectorAll(RIGHT_STACK_OBSTACLE_SELECTOR).forEach((element) => {
- this._rightStackResizeObserver.observe(element);
- });
- }
-
- if (typeof MutationObserver !== 'undefined') {
- this._rightStackMutationObserver = new MutationObserver(() => {
- this._scheduleRightPanelLayout();
- });
- this._rightStackMutationObserver.observe(stack, {
- subtree: true,
- childList: true,
- characterData: true,
- attributes: true,
- attributeFilter: ['class', 'hidden', 'data-variant'],
- });
- const hud = document.getElementById('intel-hud');
- if (hud) {
- this._rightStackMutationObserver.observe(hud, {
- subtree: true,
- attributes: true,
- attributeFilter: ['class', 'hidden', 'data-variant'],
- });
- }
- }
-
- const transitionHud = document.getElementById('intel-hud');
- if (transitionHud) {
- this._rightStackHudTransitionHandler = (event) => {
- if (event.propertyName === 'opacity' || event.propertyName === 'visibility') {
- this._scheduleRightPanelLayout({ reconsiderAutoCollapse: true });
- }
- };
- transitionHud.addEventListener('transitionend', this._rightStackHudTransitionHandler);
- }
-
- this._scheduleRightPanelLayout();
- }
-
- _scheduleRightPanelLayout({ reconsiderAutoCollapse = false } = {}) {
- if (reconsiderAutoCollapse) this._rightStackReconsiderAutoCollapse = true;
- if (!this._rightPanelStack || this._rightStackLayoutFrame !== null) return;
- this._rightStackLayoutFrame = requestAnimationFrame(() => {
- this._rightStackLayoutFrame = null;
- if (this._rightStackReconsiderAutoCollapse) {
- this._rightStackReconsiderAutoCollapse = false;
- for (const panel of this._rightPanelStack.querySelectorAll('.layout-auto-collapsed')) {
- panel.classList.remove('collapsed', 'layout-auto-collapsed');
- this._syncPanelCollapseButton(panel);
- }
- }
- this._syncRightPanelAdaptiveLayout();
- });
- }
-
- /**
- * Places the right rail inside the visible HUD-safe corridor. When the
- * corridor is too short, the expanded panel receives the remaining height
- * with internal scrolling. Tactical HUD hides collapsed sibling launchers
- * while a panel is expanded; other HUD layouts keep them visible.
- * @returns {void}
- */
- _syncRightPanelAdaptiveLayout() {
- layoutRightPanelRail({
- stack: this._rightPanelStack,
- obstacles: document.querySelectorAll(RIGHT_STACK_OBSTACLE_SELECTOR),
- windowRef: window,
- hud: { visible: this.hud.visible, variant: this.hud.getVariant() },
- preferredPanelId: this._rightStackPreferredPanelId,
- onCollapse: (panel) => this._syncPanelCollapseButton(panel),
- onRetry: () => this._scheduleRightPanelLayout(),
- leftStack: this._leftPanelStack,
- displayPanel: this._ppToggles,
- readDisplayScrollTop: () => this._displayPortalScrollRestoreOwner === 'standard'
- ? this._standardDisplayScrollTop
- : (this._ppToggles?.scrollTop || 0),
- });
- }
-
- /**
- * Initializes the adaptive left accordion. The layout engine measures the
- * actual HUD/chrome rectangles that intersect the left lane, then decides
- * whether collapsed sibling labels can remain visible beside the expanded
- * panel. No decision is keyed to a specific panel or HUD variant.
- * @returns {void}
- */
- _initLeftPanelAdaptiveLayout() {
- const stack = this._leftPanelStack;
- if (!stack) return;
-
- if (typeof ResizeObserver !== 'undefined') {
- this._leftStackResizeObserver = new ResizeObserver(() => {
- this._scheduleLeftPanelLayout();
- });
- this._leftStackResizeObserver.observe(stack);
- stack.querySelectorAll(':scope > [data-panel-id]').forEach((panel) => {
- this._leftStackResizeObserver.observe(panel);
- const inner = [...panel.children].find((child) => !child.classList.contains('panel-glow'));
- if (inner) this._leftStackResizeObserver.observe(inner);
- });
- document.querySelectorAll(LEFT_STACK_OBSTACLE_SELECTOR).forEach((element) => {
- this._leftStackResizeObserver.observe(element);
- });
- }
-
- if (typeof MutationObserver !== 'undefined') {
- this._leftStackMutationObserver = new MutationObserver(() => {
- this._scheduleLeftPanelLayout();
- });
- this._leftStackMutationObserver.observe(stack, {
- subtree: true,
- childList: true,
- characterData: true,
- attributes: true,
- attributeFilter: ['class'],
- });
- const hud = document.getElementById('intel-hud');
- if (hud) {
- this._leftStackMutationObserver.observe(hud, {
- attributes: true,
- attributeFilter: ['class', 'data-variant'],
- });
- }
- const credits = document.getElementById('cesium-credits');
- if (credits) {
- this._leftStackMutationObserver.observe(credits, {
- subtree: true,
- childList: true,
- });
- }
- }
-
- const transitionHud = document.getElementById('intel-hud');
- if (transitionHud) {
- this._leftStackHudTransitionHandler = (event) => {
- if (event.propertyName === 'opacity' || event.propertyName === 'visibility') {
- this._scheduleLeftPanelLayout({ reconsiderAutoCollapse: true });
- // The Cockpit strip hangs off the HUD's REC readout, so it has to
- // remeasure on the same event: the readout keeps its rect through
- // the whole fade and only stops counting once the HUD has retired.
- this.cockpitView?.scheduleContextLayout();
- }
- };
- transitionHud.addEventListener('transitionend', this._leftStackHudTransitionHandler);
- }
-
- this._leftStackCockpitModeHandler = () => {
- // Cockpit mode repositions the peripheral HUD and reveals its own
- // bottom-left context card. Measure after those styles have committed so
- // the accordion remains in the same obstacle-safe lane instead of
- // jumping to a cockpit-specific top anchor.
- this._scheduleLeftPanelLayout();
- requestAnimationFrame(() => this._scheduleLeftPanelLayout());
- setTimeout(() => this._scheduleLeftPanelLayout(), 300);
- };
- window.addEventListener('gev:cockpit-mode-changed', this._leftStackCockpitModeHandler);
-
- this._scheduleLeftPanelLayout();
- }
-
- /**
- * Batches adaptive accordion work into one animation frame.
- * @returns {void}
- */
- _scheduleLeftPanelLayout({ reconsiderAutoCollapse = false } = {}) {
- if (reconsiderAutoCollapse) this._leftStackReconsiderAutoCollapse = true;
- if (!this._leftPanelStack || this._leftStackLayoutFrame !== null) return;
- this._leftStackLayoutFrame = requestAnimationFrame(() => {
- this._leftStackLayoutFrame = null;
- if (this._leftStackReconsiderAutoCollapse) {
- this._leftStackReconsiderAutoCollapse = false;
- for (const panel of this._leftPanelStack.querySelectorAll('.layout-auto-collapsed')) {
- panel.classList.remove('collapsed', 'layout-auto-collapsed');
- this._syncPanelCollapseButton(panel);
- }
- }
- this._syncLeftPanelAdaptiveLayout();
- });
- }
-
- /**
- * Measures a live obstacle-free corridor for the left accordion and toggles
- * focus mode only when the expanded panel plus sibling labels cannot fit.
- * Safe boundaries are written as viewport-relative CSS values.
- * @returns {void}
- */
- _syncLeftPanelAdaptiveLayout() {
- layoutLeftPanelRail({
- stack: this._leftPanelStack,
- obstacles: document.querySelectorAll(LEFT_STACK_OBSTACLE_SELECTOR),
- windowRef: window,
- hud: { visible: this.hud.visible, variant: this.hud.getVariant() },
- preferredPanelId: this._leftStackPreferredPanelId,
- onCollapse: (panel) => this._syncPanelCollapseButton(panel),
- onRetry: () => this._scheduleLeftPanelLayout(),
- collapsedHeights: this._leftStackCollapsedHeights,
- onAligned: () => this._scheduleRightPanelLayout(),
- });
- }
-
- /**
- * Updates collapse button glyphs based on panel state. Right-rail panels
- * use directional arrows; left-stack panels use +/- symbols.
- * @param {HTMLElement} panelEl - The panel DOM element.
- * @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();
- }
- }
-
- /**
- * Converts a panel from left-positioned to right-anchored so it expands
- * leftward on resize. Used for the right-rail parameter panel.
- * @param {HTMLElement} panelEl - The panel to re-anchor.
- * @returns {void}
- */
- _pinPanelToRight(panelEl) {
- if (!panelEl) return;
- const rect = panelEl.getBoundingClientRect();
- const rightOffset = Math.max(6, Math.round(window.innerWidth - rect.right));
- panelEl.style.right = `${rightOffset}px`;
- panelEl.style.left = 'auto';
- }
-
- /**
- * Restores a panel's top/left position from localStorage.
- * Right-rail panels are additionally pinned to the right edge.
- * @param {string} panelId - DOM id of the panel.
- * @param {HTMLElement} panelEl - The panel DOM element.
- * @returns {void}
- */
- _restorePanelPosition(panelId, panelEl) {
- try {
- const raw = localStorage.getItem(this._panelStorageKey(panelId));
- if (!raw) return;
- const pos = JSON.parse(raw);
- if (!pos || typeof pos.left !== 'number' || typeof pos.top !== 'number') return;
- // Clamp to the viewport: a position saved at one window size would otherwise land off-screen at
- // another (audit U2 — observed a panel at x:-192). The drag handler clamps; restore must too.
- const { left, top } = this._clampToViewport(Math.round(pos.left), Math.round(pos.top), panelEl);
- panelEl.style.left = `${left}px`;
- panelEl.style.top = `${top}px`;
- panelEl.style.right = 'auto';
- panelEl.style.bottom = 'auto';
- if (panelId === 'pp-toggles') {
- this._pinPanelToRight(panelEl);
- }
- } catch {
- // ignore malformed saved panel position
- }
- }
-
- /**
- * Clamp a desired left/top so the panel stays fully on-screen (6px inset), matching the drag
- * clamp (ui.js ~1822). Width/height are position-independent, so reading the rect first is safe.
- * @param {number} left - desired left (px)
- * @param {number} top - desired top (px)
- * @param {HTMLElement} panelEl - the panel element
- * @returns {{left:number, top:number}}
- */
- _clampToViewport(left, top, panelEl) {
- const rect = panelEl.getBoundingClientRect();
- const maxLeft = Math.max(6, window.innerWidth - rect.width - 6);
- const maxTop = Math.max(6, window.innerHeight - rect.height - 6);
- return {
- left: Math.max(6, Math.min(maxLeft, left)),
- top: Math.max(6, Math.min(maxTop, top)),
- };
- }
-
- /**
- * Persists a panel's current bounding-rect position to localStorage.
- * @param {string} panelId - DOM id of the panel.
- * @param {HTMLElement} panelEl - The panel DOM element.
- * @returns {void}
- */
- _savePanelPosition(panelId, panelEl) {
- const rect = panelEl.getBoundingClientRect();
- try {
- localStorage.setItem(this._panelStorageKey(panelId), JSON.stringify({
- left: Math.round(rect.left),
- top: Math.round(rect.top),
- }));
- } catch {
- // storage unavailable
- }
- }
-
- /**
- * Makes a panel draggable via its handle element. Implements:
- * - Z-order promotion: each pointerdown increments the global z-counter
- * so the clicked panel floats above siblings.
- * - Viewport clamping: drag moves are clamped to a 6px inset from all edges.
- * - Right-rail pinning: pp-toggles panel is re-anchored right after drag.
- * - CCTV viewport sync: cctv-panel recalculates scroll height after drag.
- * @param {string} panelId - DOM id of the panel.
- * @param {HTMLElement} panelEl - The panel DOM element.
- * @param {HTMLElement} handleEl - The drag handle element within the panel.
- * @returns {void}
- */
- /**
- * Promotes a panel to the top of the panel z band [PANEL_Z_BASE, PANEL_Z_MAX].
- * Renormalizes all promoted panels when the band is exhausted so panels can
- * never climb above the voice pill (150), toasts (200), or clean-view exit (300).
- * @param {HTMLElement} panelEl - Panel to bring to front.
- * @returns {void}
- */
- _promotePanelZ(panelEl) {
- this._panelZCounter += 1;
- if (this._panelZCounter > PANEL_Z_MAX) {
- const promoted = [...document.querySelectorAll('.panel-draggable')]
- .filter((el) => el.style.zIndex)
- .sort((a, b) => Number(a.style.zIndex) - Number(b.style.zIndex));
- let z = PANEL_Z_BASE + 1;
- for (const el of promoted) {
- el.style.zIndex = String(z);
- z += 1;
- }
- this._panelZCounter = z;
- }
- panelEl.style.zIndex = String(this._panelZCounter);
- }
-
- _makePanelDraggable(panelId, panelEl, handleEl) {
- // Z-order promotion: bring clicked panel to front of the stacking context
- panelEl.addEventListener('pointerdown', () => {
- this._promotePanelZ(panelEl);
- });
-
- handleEl.addEventListener('pointerdown', (event) => {
- if (event.button !== 0) return;
- if (event.target.closest('.panel-collapse-btn')) return;
- if (event.target.closest('input, select, option, button:not(.panel-collapse-btn)')) return;
-
- event.preventDefault();
- const rect = panelEl.getBoundingClientRect();
- const startX = event.clientX;
- const startY = event.clientY;
- const offsetX = startX - rect.left;
- const offsetY = startY - rect.top;
-
- panelEl.style.left = `${rect.left}px`;
- panelEl.style.top = `${rect.top}px`;
- panelEl.style.right = 'auto';
- panelEl.style.bottom = 'auto';
- panelEl.classList.add('panel-dragging');
- this._promotePanelZ(panelEl);
-
- const onMove = (moveEvent) => {
- const nextLeftRaw = moveEvent.clientX - offsetX;
- const nextTopRaw = moveEvent.clientY - offsetY;
- const maxLeft = Math.max(6, window.innerWidth - rect.width - 6);
- const maxTop = Math.max(6, window.innerHeight - rect.height - 6);
- const nextLeft = Math.max(6, Math.min(maxLeft, nextLeftRaw));
- const nextTop = Math.max(6, Math.min(maxTop, nextTopRaw));
- panelEl.style.left = `${nextLeft}px`;
- panelEl.style.top = `${nextTop}px`;
- if (panelId === 'pp-toggles') {
- this._layoutRightPanels();
- }
- if (panelId === 'cctv-panel') {
- this._syncCctvPanelViewport();
- }
- };
-
- const onUp = () => {
- panelEl.classList.remove('panel-dragging');
- window.removeEventListener('pointermove', onMove);
- window.removeEventListener('pointerup', onUp);
- window.removeEventListener('pointercancel', onUp);
- if (panelId === 'pp-toggles') {
- this._pinPanelToRight(panelEl);
- }
- this._savePanelPosition(panelId, panelEl);
- if (panelId === 'cctv-panel') {
- this._syncCctvPanelViewport();
- }
- };
-
- window.addEventListener('pointermove', onMove);
- window.addEventListener('pointerup', onUp);
- window.addEventListener('pointercancel', onUp);
- });
- }
-
- _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?.();
- }
-
- /**
- * Programmatically collapses or expands a panel, persists the state,
- * and triggers layout recalculation for dependent panels.
- * @param {string} panelId - DOM id of the panel.
- * @param {boolean} collapsed - Whether to collapse the panel.
- * @param {object} [options] Disclosure ownership options.
- * @param {boolean} [options.explicit=false] Whether a direct user action owns the panel lane.
- * @returns {void}
- */
- 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._leftStackPreferredPanelId;
- const priorRightOwner = this._rightStackPreferredPanelId;
- if (explicit && !restore && !nextCollapsed && leftOwnerPanel) {
- this._leftStackPreferredPanelId = leftOwnerPanel.id;
- } else if (explicit && !restore && nextCollapsed && leftOwnerPanel?.id === this._leftStackPreferredPanelId) {
- this._leftStackPreferredPanelId = null;
- }
- if (explicit && !restore && !nextCollapsed && rightOwnerPanel) {
- this._rightStackPreferredPanelId = rightOwnerPanel.id;
- } else if (
- explicit
- && !restore
- && nextCollapsed
- && rightOwnerPanel?.id === this._rightStackPreferredPanelId
- ) {
- this._rightStackPreferredPanelId = null;
- }
- if (panelEl.classList.contains('collapsed') === nextCollapsed && !wasAutoCollapsed) {
- this._syncPanelCollapseButton(panelEl);
- if (priorLeftOwner !== this._leftStackPreferredPanelId) {
- this._scheduleLeftPanelLayout({ reconsiderAutoCollapse: true });
- }
- if (priorRightOwner !== this._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();
- }
- requestAnimationFrame(() => this._updateCommandDockTrayStack());
- this._scheduleLeftPanelLayout({
- reconsiderAutoCollapse: this._leftPanelStack?.contains(panelEl) === true,
- });
- if (syncShare) this.shareLinkManager?.onPanelStateChange?.();
- }
-
- /**
- * Toggles "clean view" mode which hides all UI panels via a CSS body class.
- * @param {boolean} [forceEnabled] - Explicit on/off. Omit to toggle.
- * @returns {void}
- */
- toggleCleanView(forceEnabled) {
- const shouldEnable = typeof forceEnabled === 'boolean'
- ? forceEnabled
- : !document.body.classList.contains('ui-clean-view');
- document.body.classList.toggle('ui-clean-view', shouldEnable);
- if (this._cleanViewBtn) {
- this._cleanViewBtn.classList.toggle('active', shouldEnable);
- }
- this._scheduleLeftPanelLayout();
- }
-
- // ── Public control facade ──────────────────────────────────────────────
- // Deliberate API for voice tools and scripting. Every setter keeps the DOM
- // sliders, share-link state, and scene snapshots in sync, and returns
- // { ok, ...resultingState } so callers confirm only what actually happened.
-
- /**
- * Sets HUD visibility mode. 'auto' restores style-driven show/hide.
- * @param {'on'|'off'|'auto'} mode - Visibility mode.
- * @returns {{ok: boolean, visible?: boolean, layout?: string, error?: string}}
- */
- setHudVisible(mode) {
- const normalized = String(mode ?? '').toLowerCase();
- if (!['on', 'off', 'auto'].includes(normalized)) {
- return { ok: false, error: `Unknown HUD visibility mode: ${mode}` };
- }
- this.shareLinkManager?.claimRestoreLane?.('visual');
- this.hud.setMode(normalized);
- this._updateHudButtonState();
- this._syncShareState();
- return { ok: true, visible: !!this.hud.visible, mode: normalized, layout: this.hud.getVariant() };
- }
-
- /**
- * Switches the HUD layout variant.
- * @param {'tactical'|'operator'|'minimal'} variantName - Layout variant.
- * @returns {{ok: boolean, layout?: string, visible?: boolean, error?: string}}
- */
- setHudLayout(variantName) {
- const variant = String(variantName ?? '').toLowerCase();
- if (!['tactical', 'operator', 'minimal'].includes(variant)) {
- return { ok: false, error: `Unknown HUD layout: ${variantName}` };
- }
- this.shareLinkManager?.claimRestoreLane?.('visual');
- this._setHudVariant(variant);
- return { ok: true, layout: this.hud.getVariant(), visible: !!this.hud.visible };
- }
-
- /**
- * Reads current detection overlay state (engine mode + UI density percent).
- * @returns {{detectionMode: string, densityPct: number|null, allocationStrategy:string, fadePct:number, outsideOpacityPct:number}}
- */
- getDetectionState() {
- 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),
- };
- }
-
- /** Read-only overlay diagnostics used by browser QA and regression harnesses. */
- getDetectionDiagnostics() {
- return readDetectionDiagnostics();
- }
-
- /**
- * Controls the detection overlay: on/off, mode, and density percent.
- * Density writes the slider AND the engine so share links and scene
- * snapshots stay truthful.
- * @param {object} [options]
- * @param {boolean} [options.enabled] - false forces OFF; true restores the current density profile.
- * @param {'sparse'|'balanced'|'dense'|'panoptic'} [options.mode] - Profile (legacy aliases accepted).
- * @param {number} [options.densityPct] - 0-100 density percent.
- * @param {'elastic'|'weighted'} [options.allocationStrategy] - Layer-capacity policy.
- * @param {number} [options.fadePct] - Fade distance as 0-40% of the keyhole radius.
- * @param {number} [options.outsideOpacityPct] - Opacity beyond the fade distance, 0-100%.
- * @returns {{ok: boolean, detectionMode?: string, densityPct?: number|null, error?: string}}
- */
- setDetection({ enabled, mode, densityPct, allocationStrategy, fadePct, outsideOpacityPct } = {}) {
- 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() };
- }
-
- /**
- * Switches the basemap stack and reports whether the switch landed.
- * @param {string} stackId - One of mapStackController.getStacks() ids.
- * @returns {Promise<{ok: boolean, activeStack?: string, error?: string|null, available?: string[]}>}
- */
- async setMapStack(stackId) {
- if (!this.mapStackController) {
- return { ok: false, error: 'Map stack controller unavailable' };
- }
- const stacks = this.mapStackController.getStacks();
- const target = stacks.find((stack) => stack.id === stackId);
- if (!target) {
- return { ok: false, error: `Unknown map stack: ${stackId}`, available: stacks.map((s) => s.id) };
- }
- if (!target.available) {
- return { ok: false, error: `${target.label} requires a Cesium ion token`, activeStack: this.mapStackController.getActiveId() };
- }
- await this._setMapStack(stackId);
- const state = this.mapStackController.getState();
- const landed = state.activeId === stackId;
- return {
- ok: landed,
- activeStack: state.activeId,
- error: landed ? null : (state.lastError || 'Map stack did not switch'),
- };
- }
-
- /**
- * Controls bloom post-processing. Intensity is the UI percent (0-200).
- * @param {object} [options]
- * @param {boolean} [options.enabled]
- * @param {number} [options.intensityPct] - 0-200.
- * @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(),
- };
- }
-
- /**
- * Controls sharpen post-processing. Intensity is the UI percent (0-100).
- * @param {object} [options]
- * @param {boolean} [options.enabled]
- * @param {number} [options.intensityPct] - 0-100.
- * @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(),
- };
- }
-
- /** Whether the full-globe celestial overlay is enabled by user preference. */
- get celestialRingEnabled() {
- return !!this.celestialRing?.enabled;
- }
-
- /**
- * Controls the celestial ring. The Display button uses `focus=true` when the
- * ring is disabled or unavailable at the current zoom, turning the control
- * into a reveal action instead of requiring a separate globe-navigation step.
- * @param {boolean} enabled
- * @param {object} [options]
- * @param {boolean} [options.syncShare=true]
- * @param {boolean} [options.focus=false]
- * @returns {{ok:boolean, celestialRing:{enabled:boolean,visible:boolean}, cameraFocused:boolean, error?:string}}
- */
- setCelestialRingEnabled(enabled, { syncShare = true, focus = false } = {}) {
- 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,
- };
- }
-
- /**
- * Starts or stops orbiting the active POI.
- * @param {boolean} [enabled] - Omit to toggle.
- * @returns {{ok: boolean, orbiting: boolean, error?: string}}
- */
- setOrbit(enabled) {
- const active = !!this.orbitController?.active;
- if (typeof enabled === 'boolean' && enabled === active) {
- return { ok: true, orbiting: active };
- }
- if (enabled === false) {
- this._stopOrbit();
- return { ok: true, orbiting: false };
- }
- if (!this._currentTarget) {
- return { ok: false, orbiting: false, error: 'No active landmark to orbit — fly to a landmark first' };
- }
- this._toggleOrbit();
- return { ok: true, orbiting: !!this.orbitController?.active };
- }
-
- /**
- * Enables/disables clean view (hides all UI chrome).
- * @param {boolean} [enabled] - Omit to toggle.
- * @returns {{ok: boolean, cleanView: boolean}}
- */
- setCleanView(enabled) {
- this.toggleCleanView(enabled);
- return { ok: true, cleanView: document.body.classList.contains('ui-clean-view') };
- }
-
- /**
- * Reads global context mode state for voice/state-sync consumers.
- * @returns {{mode: 'flights'|'space-missions'|null, active: boolean, changing: boolean, entering: 'flights'|'space-missions'|null, snapshotCaptured: boolean}}
- */
- getContextModeState(...args) { return this._contextControls?.getContextModeState(...args); }
-
- /**
- * Sets global context mode (Contacts / Space Missions / off) for voice.
- * @param {'contacts'|'space-missions'|'off'|null} mode - Requested context target.
- * @param {object} [options]
- * @param {string|Symbol|null} [options.notificationToken]
- * @param {AbortSignal|null} [options.signal]
- * @param {Function|null} [options.isCurrent]
- * @param {boolean} [options.claimVisualAuthority] Whether this request is a
- * genuine operator/voice Context intent that should take the visual restore
- * lane. Cockpit choreography calls this facade INTERNALLY for its own
- * enter/rollback steps; those transitions are not a Context request by the
- * operator and must stay inert, so they pass `false`.
- * @returns {Promise<{ok:boolean, mode:'flights'|'space-missions'|null, active:boolean, action:string, error?:string}>}
- */
- setContextMode(...args) { return this._contextControls?.setContextMode(...args); }
-
- /**
- * Returns cockpit status for voice/state sync and navigation operations.
- * @returns {{active:boolean, entryAllowed:boolean, visionMode:string, subject:{id:string,layerId:string}|null, navigation:{canPrevious:boolean,canNext:boolean,canFocus:boolean}|null, awareness?: object}|null}
- */
- getCockpitState() {
- const snapshot = militaryAwarenessLayer.getContextSnapshot?.();
- const info = this.cockpitView?.readAircraftInfo?.();
- const active = Boolean(this.cockpitView?.active);
- const gateOpen = Boolean(this.cockpitView?.isEntryAllowed?.());
- // "Could Cockpit be ENTERED right now" — so it is false while already
- // inside, unconditionally. Cockpit takes the entity off
- // `viewer.trackedEntity` on entry and NEXT puts one back, which made this
- // flip true/false between calls while `active` stayed true; readers
- // (including the voice model) read that as a broken half-entered state.
- const entryAllowed = !active && Boolean(
- gateOpen
- && info
- && this.viewer?.trackedEntity?.position,
- );
- return {
- active,
- entryAllowed,
- // Why entry is unavailable, so a refusal can be explained rather than
- // guessed at.
- entryBlockedReason: entryAllowed || active
- ? null
- : (!gateOpen
- ? (this._contextModeChanging ? 'contacts-starting' : 'contacts-inactive')
- : 'no-tracked-aircraft'),
- visionMode: this.cockpitView?.visionMode || null,
- subject: info ? {
- id: info.icao24 || info.id || null,
- layerId: info.layerId || null,
- callsign: info.callsign || null,
- } : null,
- navigation: snapshot ? {
- canPrevious: Boolean(snapshot.navigation?.canPrevious),
- canNext: Boolean(snapshot.navigation?.canNext),
- canFocus: Boolean(snapshot.navigation?.canFocus),
- } : null,
- awareness: snapshot ? {
- radiusM: Number.isFinite(snapshot.radiusM) ? snapshot.radiusM : null,
- subject: snapshot.subject ? {
- id: snapshot.subject.id || null,
- layerId: snapshot.subject.layerId || null,
- } : null,
- cohorts: Array.isArray(snapshot.cohorts)
- ? snapshot.cohorts.map((cohort) => ({
- id: cohort?.id || null,
- source: cohort?.source || null,
- count: Number.isFinite(cohort?.count) ? cohort.count : null,
- relationship: cohort?.relationship || null,
- reason: cohort?.reason || null,
- coverage: cohort?.coverage || null,
- }))
- : [],
- navigation: snapshot.navigation ? {
- canPrevious: Boolean(snapshot.navigation.canPrevious),
- canNext: Boolean(snapshot.navigation.canNext),
- canFocus: Boolean(snapshot.navigation.canFocus),
- } : null,
- } : null,
- activeTracked: this.cockpitView?.active ? Boolean(this.cockpitView?.trackedEntity) : false,
- activeMapView: !this.cockpitView?.active && entryAllowed,
- };
- }
-
- /**
- * Point Cockpit entry at a requested contact layer before it enters.
- *
- * Reuses the filtered Context navigation NEXT already uses, so "cockpit in
- * that military helicopter" lands on the same contact "next military
- * helicopter" would. Cockpit flies aircraft only; vessel and installation
- * layers are refused by name rather than silently ignored.
- * @param {object} options Retarget request.
- * @param {string} options.targetLayer Requested contact layer.
- * @param {string|null} options.aircraftClass Optional class filter.
- * @param {{layerId: string}|null} options.currentTarget Current tracker.
- * @param {{layerId: string}|null} options.selectedTarget Pending selection.
- * @returns {{ok: boolean, retargeted?: boolean, error?: string}} Outcome.
- */
- _retargetCockpitEntryLayer({ targetLayer, aircraftClass, currentTarget, selectedTarget }) {
- if (!['flights', 'military'].includes(targetLayer)) {
- return {
- ok: false,
- error: `Cockpit flies aircraft only — ${targetLayer} contacts cannot be entered`,
- };
- }
- const activeLayer = selectedTarget?.layerId || currentTarget?.layerId || null;
- const alreadyOnLayer = activeLayer === targetLayer;
- if (alreadyOnLayer && !aircraftClass) return { ok: true, retargeted: false };
- const moved = militaryAwarenessLayer?.navigateNext
- ? !!militaryAwarenessLayer.navigateNext({
- targetLayer,
- aircraftClass,
- origin: 'voice',
- })
- : false;
- if (moved) return { ok: true, retargeted: true };
- // A filter that matched nothing still enters, as long as the layer is
- // already right — the operator asked for that layer and is on it.
- if (alreadyOnLayer) return { ok: true, retargeted: false };
- const label = targetLayer === 'military' ? 'military' : 'civilian';
- const filtered = aircraftClass ? `${aircraftClass} ` : '';
- return {
- ok: false,
- error: `No ${filtered}${label} contact is available to enter — track one first, or say "next ${label}"`,
- };
- }
-
- /**
- * Controls cockpit entry/exit and context navigation.
- * @param {'enter'|'exit'|'next'|'previous'|'status'} action - Cockpit action.
- * @param {object} [options]
- * @param {string|Symbol|null} [options.notificationToken]
- * @param {'flights'|'military'|'ais-live-vessels'|'military-installations'|null} [options.targetLayer]
- * @param {string|null} [options.aircraftClass]
- * @param {{layerId:'flights'|'military',id:string}|null} [options.selectedTarget]
- * @param {{layerId:'flights'|'military',id:string}|null} [options.rollbackTarget]
- * @returns {{ok:boolean, action:string, error?:string, state?:object}}
- */
- controlCockpit(action, {
- notificationToken = null,
- targetLayer = null,
- aircraftClass = null,
- selectedTarget = null,
- rollbackTarget = undefined,
- } = {}) {
- const normalized = String(action || '').toLowerCase();
- if (!this.cockpitView) {
- return {
- ok: false,
- action: 'control_cockpit',
- error: 'Cockpit controller unavailable',
- state: this.getCockpitState(),
- };
- }
- if (normalized === 'status') {
- return {
- ok: true,
- action: 'control_cockpit',
- state: this.getCockpitState(),
- notificationToken: notificationToken || null,
- };
- }
- if (normalized === 'enter') {
- // Entry is gated exactly as the manual entry chip is. Attempting it while
- // the gate is shut produced the half-entered look the operator reported
- // (a plane anchored under the camera with no cockpit around it), so
- // refuse with the reason instead of trying.
- if (!this.cockpitView.isEntryAllowed?.()) {
- return {
- ok: false,
- action: 'control_cockpit',
- error: this._contextModeChanging
- ? 'Contacts is still starting up — try Cockpit again in a moment'
- : 'Contacts must be active to enter Cockpit — say "open contacts" first',
- state: this.getCockpitState(),
- };
- }
- let currentTarget = this.getAircraftTrackingTarget();
- const layerForTarget = (target) => target?.layerId === 'military'
- ? militaryFlightsLayer
- : target?.layerId === 'flights' ? flightsLayer : null;
- // A requested layer retargets BEFORE entry, through the same filtered
- // navigation NEXT uses. Ignoring it entered on whatever was already
- // tracked and reported success, so "cockpit in that military helicopter"
- // silently put the operator in an airliner.
- if (targetLayer) {
- const requested = this._retargetCockpitEntryLayer({
- targetLayer,
- aircraftClass,
- currentTarget,
- selectedTarget,
- });
- if (!requested.ok) {
- return {
- ok: false,
- action: 'control_cockpit',
- error: requested.error,
- state: this.getCockpitState(),
- };
- }
- if (requested.retargeted) {
- // The retarget is now the authority; a selection sampled before it
- // would drag entry back to the wrong layer.
- selectedTarget = null;
- rollbackTarget = rollbackTarget === undefined ? currentTarget : rollbackTarget;
- currentTarget = this.getAircraftTrackingTarget();
- }
- }
- const selectedLayer = selectedTarget?.layerId === 'military'
- ? militaryFlightsLayer
- : selectedTarget?.layerId === 'flights' ? flightsLayer : null;
- const entry = enterCockpitWithTracking({
- cockpitView: this.cockpitView,
- selectedLayer,
- selectedTarget,
- currentLayer: layerForTarget(currentTarget),
- rollbackLayer: layerForTarget(
- rollbackTarget === undefined ? currentTarget : rollbackTarget,
- ),
- rollbackTarget,
- selectionOrigin: 'voice',
- });
- return {
- ok: entry.entered,
- action: 'control_cockpit',
- state: this.getCockpitState(),
- error: entry.error,
- };
- }
- if (normalized === 'exit') {
- const exited = !!this.cockpitView.exit();
- return {
- ok: exited,
- action: 'control_cockpit',
- state: this.getCockpitState(),
- error: exited ? null : 'Cockpit was already inactive',
- };
- }
- if (normalized === 'next' || normalized === 'previous') {
- const changed = this.cockpitView.navigateContext(
- normalized === 'next' ? 1 : -1,
- {
- targetLayer,
- aircraftClass,
- origin: 'voice',
- },
- );
- return {
- ok: changed,
- action: 'control_cockpit',
- state: this.getCockpitState(),
- error: changed ? null : 'No further context target was available',
- };
- }
- return {
- ok: false,
- action: 'control_cockpit',
- error: `Unknown cockpit action: ${action}`,
- state: this.getCockpitState(),
- };
- }
-
- /**
- * Full control-state snapshot — single source for voice read-back so the
- * agent confirms from the same state it acted on.
- * @returns {object} Current style/stack/HUD/detection/post-processing state.
- */
- getControlState() {
- return {
- style: this.activeStyle || 'normal',
- mapStack: this.mapStackController?.getActiveId?.() || null,
- hud: { visible: !!this.hud?.visible, layout: this.hud?.getVariant?.() || null },
- detection: this.getDetectionState(),
- bloom: {
- enabled: !!this.bloomEnabled,
- intensityPct: this._bloomSlider ? parseInt(this._bloomSlider.value, 10) : null,
- },
- sharpen: {
- enabled: !!this.sharpenEnabled,
- intensityPct: this._sharpenSlider ? parseInt(this._sharpenSlider.value, 10) : null,
- },
- celestialRing: {
- enabled: this.celestialRingEnabled,
- visible: !!this.celestialRing?.visible,
- },
- orbiting: !!this.orbitController?.active,
- recording: !!this._recordingMode,
- cleanView: document.body.classList.contains('ui-clean-view'),
- };
- }
-
- /**
- * Captures the current camera position and orientation as a serializable object.
- * @returns {{lat: number, lon: number, alt: number, heading: number, pitch: number, roll: number}|null}
- */
- getCameraState() {
- const carto = this.viewer.camera.positionCartographic;
- if (!carto) return null;
- return {
- lat: Cesium.Math.toDegrees(carto.latitude),
- lon: Cesium.Math.toDegrees(carto.longitude),
- alt: carto.height,
- heading: Cesium.Math.toDegrees(this.viewer.camera.heading),
- pitch: Cesium.Math.toDegrees(this.viewer.camera.pitch),
- roll: Cesium.Math.toDegrees(this.viewer.camera.roll),
- };
- }
-
- /**
- * Flies the camera to a previously captured camera state using cubic ease-in-out.
- * @param {{lat: number, lon: number, alt: number, heading?: number, pitch?: number, roll?: number}} cameraState
- * @param {number} [duration=2.8] - Flight duration in seconds.
- * @returns {void}
- */
- applyCameraState(cameraState, duration = 2.8) {
- if (!cameraState) return;
- this.viewer.camera.flyTo({
- destination: Cesium.Cartesian3.fromDegrees(
- cameraState.lon,
- cameraState.lat,
- cameraState.alt
- ),
- orientation: {
- heading: Cesium.Math.toRadians(cameraState.heading || 0),
- pitch: Cesium.Math.toRadians(cameraState.pitch || -35),
- roll: Cesium.Math.toRadians(cameraState.roll || 0),
- },
- duration: Math.max(0.2, duration || 0),
- easingFunction: Cesium.EasingFunction.CUBIC_IN_OUT,
- });
- }
-
- /**
- * Snapshots the full visual state (active style, bloom, sharpen, HUD, detection,
- * per-style shader uniform values) for serialization or scene recipe capture.
- * @returns {object} Serializable visual state object.
- */
- getVisualState() {
- 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,
- };
- }
-
- /**
- * Restores a full visual state snapshot, applying style, bloom, sharpen,
- * HUD, detection, and per-style shader uniforms. Used by scene recipes
- * and share-link restore. Async so the map-stack switch resolves before
- * the share state is synced; callers may fire-and-forget.
- * @param {object} [state={}] - Visual state object (as returned by getVisualState).
- * @param {object} [options]
- * @param {(() => boolean)|null} [options.isCurrent] Caller liveness predicate.
- * The map-stack switch is this method's ONLY suspension point, and the
- * shader-uniform writes come after it — so a caller superseded while that
- * switch is in flight would otherwise resume and commit the look of a
- * state the operator has already moved past. Scene playback reproduced
- * exactly that: a stale shot's uniforms landing on top of the live run.
- * Omit it and the method behaves as it always has.
- * @returns {Promise} Whether the state was committed.
- */
- async applyVisualState(state = {}, { isCurrent = null } = {}) {
- 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;
- }
-
- /**
- * Resets the safe-frame overlay to its inactive state on init.
- * @returns {void}
- */
- _initRecordingOverlay() {
- if (!this._safeFrameOverlay || !this._safeFrameBox) return;
- this._safeFrameOverlay.classList.remove('active', 'ratio-9-16', 'ratio-16-9');
- }
-
- /**
- * Applies recording-friendly post-processing and shader uniform overrides.
- * @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();
- }
-
- /**
- * Enters or exits recording mode. When active, hides UI chrome via a body class,
- * displays a safe-frame composition overlay (16:9 or 9:16), and switches
- * the HUD to the specified mode. Exiting restores the HUD mode and layout
- * variant that were active before recording started.
- * @param {boolean} enabled - Whether to enable recording mode.
- * @param {object} [options]
- * @param {boolean} [options.hidePanels=true] - Hide all panel chrome.
- * @param {string} [options.hudMode='minimal'] - HUD mode while recording ('off'|'minimal'|'full'|'auto').
- * @param {string} [options.safeFrame='16:9'] - Aspect ratio for the safe-frame overlay.
- * @returns {void}
- */
- setRecordingMode(enabled, options = {}) {
- const { hidePanels = true, hudMode = 'minimal', safeFrame = '16:9' } = options;
- this._recordingMode = !!enabled;
- this._recordingConfig = { hidePanels, hudMode, safeFrame };
-
- document.body.classList.toggle('recording-mode', this._recordingMode && hidePanels);
-
- if (this._safeFrameOverlay) {
- this._safeFrameOverlay.classList.remove('ratio-9-16', 'ratio-16-9');
- this._safeFrameOverlay.classList.toggle('active', this._recordingMode);
- this._safeFrameOverlay.classList.add(safeFrame === '9:16' ? 'ratio-9-16' : 'ratio-16-9');
- }
-
- if (this._recordingMode) {
- // Snapshot the user's HUD state once per recording session so exit can
- // restore it (re-entrant calls must not capture mid-recording state).
- if (!this._preRecordingHudState) {
- this._preRecordingHudState = {
- mode: this.hud.getMode(),
- variant: this.hud.getVariant(),
- };
- }
- if (hudMode === 'off') {
- this.hud.setMode('off');
- } else if (hudMode === 'full' || hudMode === 'minimal') {
- this.hud.setMode('on');
- this.hud.setVariant(hudMode === 'minimal' ? 'minimal' : 'tactical');
- if (this._hudLayoutSelect) this._hudLayoutSelect.value = this.hud.getVariant();
- } else {
- this.hud.setMode('auto');
- }
- } else {
- const saved = this._preRecordingHudState;
- this._preRecordingHudState = null;
- if (saved) {
- this.hud.setVariant(saved.variant);
- if (this._hudLayoutSelect) this._hudLayoutSelect.value = this.hud.getVariant();
- }
- this.hud.setMode(saved ? saved.mode : 'auto');
- if (this._safeFrameOverlay) {
- this._safeFrameOverlay.classList.remove('active', 'ratio-9-16', 'ratio-16-9');
- }
- }
- this._hudBtn.classList.toggle('active', this.hud.visible);
- this._syncShareState();
- }
-
- // ── Parameter Sliders ─────────────────────────
-
- /**
- * Rebuilds the parameter slider panel for the given style's shader uniforms.
- * Creates a labeled range input for each tunable uniform. Hides the panel
- * for 'normal' mode which has no shader parameters.
- * @param {string} styleName - Style name whose uniforms to display.
- * @returns {void}
- */
- _updateSliderPanel(styleName, { reveal = false } = {}) {
- 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();
- }
-
- /** 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 });
- requestAnimationFrame(() => requestAnimationFrame(() => {
- const scrollOwner = this._ppToggles;
- if (!scrollOwner) return;
- const ownerRect = scrollOwner.getBoundingClientRect();
- const panelRect = this._sliderPanel.getBoundingClientRect();
- scrollOwner.scrollTop += panelRect.top - ownerRect.top - 8;
- }));
- }
-
- // ── Style switching ───────────────────────────
-
- /**
- * Switches the active visual style. Handles full lifecycle:
- * 1. Crossfades the previous shader stage intensity to 0.
- * 2. Crossfades the new shader stage intensity to 1.
- * 3. Applies style preset defaults (bloom/sharpen/HUD) if applyPreset is true.
- * 4. Updates button highlights, style indicator, slider panel, HUD, and detection overlay.
- * @param {string} styleName - Target style ('normal'|'retro'|'surveillance'|'thermal'|'anime'|'noir'|'snow').
- * @param {object} [options]
- * @param {boolean} [options.applyPreset=true] - Whether to apply STYLE_PRESET_DEFAULTS for the new style.
- * @returns {void}
- */
- setStyle(styleName, {
- applyPreset = true,
- revealParameters = applyPreset,
- restore = false,
- } = {}) {
- 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();
- }
-
- // ── Shader transitions ────────────────────────
-
- /**
- * Enqueues a smooth intensity transition for a shader stage. The animation
- * loop interpolates from `fromValue` to `toValue` over TRANSITION_DURATION_MS.
- * @param {string} styleName - Name of the shader stage to transition.
- * @param {number} fromValue - Starting intensity (typically current value).
- * @param {number} toValue - Target intensity (0.0 to fade out, 1.0 to fade in).
- * @returns {void}
- */
- _startTransition(styleName, fromValue, toValue) {
- this._visualEffects.startTransition(styleName, fromValue, toValue);
- }
-
- /**
- * Sample the manager's layer set and paint the global loading chip.
- * Driven by manager events AND by a ticker, because the underlying state
- * machine is TIME-driven (reveal delay, long-load threshold, terminal
- * dwell) — see _armLoadingFeedbackTicker.
- * @param {number} [now] - performance.now() sample.
- * @returns {void}
- */
- _updateGlobalLoadingFeedback(now = performance.now()) {
- if (!this._globalLoadingStatus) return;
- const summary = aggregateLayerLoading(this._dataManager?.getAll?.() || []);
- this._loadingFeedbackState = reduceLoadingFeedback(
- this._loadingFeedbackState,
- summary,
- now,
- this._loadingFeedbackEvent,
- );
- this._loadingFeedbackEvent = null;
- const presentation = presentGlobalLoadingStatus(
- this._globalStatusNotice,
- this._loadingFeedbackState,
- summary,
- now,
- );
- if (this._globalStatusNotice?.persistent !== true
- && Number.isFinite(this._globalStatusNotice?.hideAt)
- && now >= this._globalStatusNotice.hideAt) {
- this._globalStatusNotice = null;
- }
- // Loading phases and universal notices both have time-driven transitions.
- // Compute this after arbitration: a queued finite notice starts its dwell
- // only on its first visible frame, then keeps the ticker alive to expiry.
- const noticeNeedsTicker = Number.isFinite(this._globalStatusNotice?.hideAt);
- if (this._loadingFeedbackState?.phase !== 'idle' || noticeNeedsTicker) {
- this._armLoadingFeedbackTicker();
- }
- this._globalLoadingStatus.hidden = !presentation;
- if (!presentation) {
- delete this._globalLoadingStatus.dataset.state;
- return;
- }
- this._globalLoadingStatus.dataset.state = presentation.state;
- // Split-flap the LABEL only ("LOADING LIVE DATA" -> "LOAD COMPLETE").
- // setSplitFlapText is a no-op when the text is unchanged, which matters
- // here: this runs on every 60 ms and 500 ms tick. The detail line is the
- // live layer roster inside an ellipsised, width-capped span — flapping a
- // list that churns as layers join would be noise, not delight.
- setSplitFlapText(this._globalLoadingLabel, presentation.label);
- this._globalLoadingDetail.textContent = presentation.detail;
- }
-
- /** Show a message in the universal top-center status banner. */
- _showGlobalStatusNotice(message, options = {}) {
- const now = performance.now();
- this._globalStatusNotice = createGlobalStatusNotice(message, now, options);
- this._updateGlobalLoadingFeedback(now);
- }
-
- /**
- * Style animation loop — self-stopping (perf wave 2). Runs only while a
- * crossfade is in flight or an animated (time-uniform) stage is visible,
- * holding continuous scene render for exactly that long. Re-armed by
- * _startTransition and by _setStageIntensity enabling an animated stage.
- * The traffic sync chip no longer rides this loop — it has its own 500 ms
- * interval (see _startTrafficChipTicker).
- */
- _startAnimationLoop() {
- this._visualEffects.startAnimationLoop();
- }
-
- /**
- * 500 ms DOM ticker for the traffic sync chip (was per-frame). It also
- * polls the loading chip as a safety net: a camera-driven layer can flip
- * its own `stats.loading` without emitting a manager event, and that is
- * the one loading start the event path cannot see.
- */
- _startTrafficChipTicker() {
- if (this._trafficChipTicker) return;
- this._trafficChipTicker = setInterval(() => {
- if (document.hidden) return;
- this._updateTrafficSyncChip();
- this._updateGlobalLoadingFeedback();
- }, 500);
- }
-
- /**
- * Self-stopping 60 ms ticker for the global loading chip.
- *
- * The chip used to ride the style rAF loop, which perf wave 2 made
- * self-stopping — leaving the chip frozen mid-state whenever no crossfade
- * or animated shader was running (it would never reveal, never cross the
- * long-load threshold, and never dwell out). Its reducer
- * (src/loadingFeedback.js) is time-driven, so it needs real ticks; it is
- * also pure DOM, so it takes NO governor hold and requests no render.
- * Armed by _updateGlobalLoadingFeedback whenever loading leaves idle or a
- * universal notice begins, and stops once both have settled.
- * (rebase 2026-08-16: main's loading chip vs wave 2's stopped loop)
- * @returns {void}
- */
- _armLoadingFeedbackTicker() {
- // Never arm behind a hidden tab: the reducer cannot usefully advance a
- // chip nobody can see, and the old `return` INSIDE the interval left the
- // 60ms timer scheduled for the entire hidden period (a batch completing
- // while hidden could never clear it — the idle check sat behind the
- // hidden guard). visibilitychange resamples and re-arms on return.
- if (this._loadingFeedbackTicker || document.hidden) return;
- this._loadingFeedbackTicker = setInterval(() => {
- if (document.hidden) {
- this._stopLoadingFeedbackTicker();
- return;
- }
- const now = performance.now();
- this._lastLoadingFeedbackUpdateAt = now;
- this._updateGlobalLoadingFeedback(now);
- const noticeNeedsTicker = Number.isFinite(this._globalStatusNotice?.hideAt);
- if (this._loadingFeedbackState?.phase === 'idle' && !noticeNeedsTicker) {
- this._stopLoadingFeedbackTicker();
- }
- }, 60);
- }
-
- /** Stop the loading-chip ticker if it is running. Idempotent. */
- _stopLoadingFeedbackTicker() {
- if (!this._loadingFeedbackTicker) return;
- clearInterval(this._loadingFeedbackTicker);
- this._loadingFeedbackTicker = null;
- }
-
- // ── Location Bar ─────────────────────────────
-
- /**
- * Initializes the location bar: renders city pills from CITY_POIS, sets up
- * QWERTY keyboard navigation for POI selection, wires the search toggle
- * and geocoding search input.
- * @returns {void}
- */
- _initLocationBar() {
- this._locationControls?.destroy();
- this._locationLookup?.destroy();
- this._locationLookup = new LocationSearch({
- input: this._locationSearch,
- begin: () => this._beginDeferredNavigation('location'),
- isCurrent: (generation) => !this._disposed && generation === this._navigationGeneration,
- beforeFly: (generation) => this._reassertNavigationHandoff(generation),
- search: (query, options) => searchAndFlyTo(this.viewer, query, {
- placeSearch: this.placeSearch,
- ...options,
- }),
- onStart: (generation) => { this._activeLocationSearchGeneration = generation; },
- onResult: (destination, query) => {
- this._searchedLocationLabel = destination.label || query;
- this._setActiveLocation(null);
- this._currentPoi = null;
- this._collapsePOIRow();
- this._updateLocationMiniStatus();
- },
- onMissing: () => this._showToast('Location not found'),
- onError: (error) => {
- console.error('[Search] Geocoding failed:', error);
- this._showToast('Search failed');
- },
- onSettled: (generation) => this._settleLocationSearchUi(generation),
- });
- this._locationControls = new LocationControls({
- elements: {
- pills: this._locationPills, poiRow: this._poiRow, divider: this._locationBarDivider,
- search: this._locationSearch, searchToggle: this._searchToggle,
- resetButtons: [this._resetGlobeBtn, this._cockpitResetGlobeBtn],
- statusCity: this._locationMiniCity, statusPoi: this._locationMiniPoi,
- },
- cities: CITY_POIS,
- getExpandedCity: () => this._expandedCityId,
- onCity: (id) => this._onCityPillClick(id),
- onPoi: (id, index) => this._onPoiClick(id, index),
- onSearch: (query) => this._locationLookup.run(query),
- onReset: () => this.resetToGlobeView(),
- });
- }
-
- /**
- * Signals the start of an inter-city world jump: notifies the traffic layer
- * to pause tile fetching and suspends detection overlays to prevent stale
- * rendering during the flight.
- * @returns {void}
- */
- _beginWorldJumpTransition() {
- clearTimeout(this._trafficTransitionTimer);
- trafficLayer.beginWorldJump?.();
- suspendDetection('intercity');
- }
-
- /**
- * Signals the end of an inter-city world jump: resumes traffic tile fetching,
- * resumes detection overlays, and forces a traffic sync chip update.
- * @returns {void}
- */
- _endWorldJumpTransition() {
- clearTimeout(this._trafficTransitionTimer);
- trafficLayer.endWorldJump?.();
- resumeDetection();
- this._updateTrafficSyncChip(true);
- }
-
- /**
- * Wraps a fly-to action with world-jump transition hooks when the target
- * city differs from the current one. Applies begin/end transition signals
- * with a 5.2s safety timeout to guarantee cleanup if the flight callback
- * never fires onComplete.
- * @param {boolean} cityChanged - Whether the destination is in a different city.
- * @param {function} flyAction - Callback receiving `{onStart, onComplete}` hooks; should return a result with targetPosition.
- * @returns {*} Return value from flyAction.
- */
- _flyWithTransition(cityChanged, flyAction) {
- return this._runExplicitNavigation('location', () => {
- if (!cityChanged) return flyAction({});
- let completed = false;
- const finalize = () => {
- if (completed) return;
- completed = true;
- this._endWorldJumpTransition();
- };
- const result = flyAction({
- onStart: () => this._beginWorldJumpTransition(),
- onComplete: finalize,
- });
- this._trafficTransitionTimer = window.setTimeout(finalize, 5200);
- return result;
- });
- }
-
- /**
- * Release camera ownership when a resolved Location destination starts.
- * Contact mode and its selected subject remain intact so FOCUS can return to
- * that subject after the user finishes inspecting the destination.
- * @returns {boolean} Whether a Contact subject remains selected.
- */
- beginLocationNavigation() {
- this._stampNavigation();
- this.cockpitView?.exit({ restoreTracking: false });
- return this._releaseFollowCamera({ preserveVesselSelection: false });
- }
-
- /**
- * Handles a city pill click: toggles POI row collapse if same city,
- * otherwise expands the POI row, flies to the city's first POI, and
- * tracks the target position for orbit mode.
- * @param {string} cityId - Identifier of the clicked city.
- * @returns {void}
- */
- _onCityPillClick(cityId) {
- if (this._expandedCityId === cityId) {
- // Same city clicked again — toggle collapse
- this._collapsePOIRow();
- return;
- }
-
- const isCityChanged = this._activeLocationId && this._activeLocationId !== cityId;
- const result = this._flyWithTransition(!!isCityChanged, (hooks) => flyToPresetLocation(this.viewer, cityId, hooks));
- if (result === false) return;
- this._expandPOIRow(cityId);
- this._setActiveLocation(cityId);
- this._activePoiIndex = 0;
- this._updatePoiHighlight();
-
- // Track current target + POI for orbit
- if (result) {
- this._currentTarget = result.targetPosition;
- this._currentPoi = CITY_POIS[cityId].pois[0];
- }
- this._updateLocationMiniStatus();
- }
-
- /**
- * Handles a POI pill click: stops orbit, flies to the POI, highlights it,
- * and saves the target position for future orbit activation.
- * @param {string} cityId - Parent city identifier.
- * @param {number} poiIndex - Index of the POI within the city's pois array.
- * @returns {void}
- */
- _onPoiClick(cityId, poiIndex) {
- const isCityChanged = this._activeLocationId && this._activeLocationId !== cityId;
- const result = this._flyWithTransition(!!isCityChanged, (hooks) => flyToPOI(this.viewer, cityId, poiIndex, hooks));
- if (result === false) return;
- this._setActiveLocation(cityId);
- this._activePoiIndex = poiIndex;
- this._updatePoiHighlight();
-
- // Track current target + POI for orbit
- if (result) {
- this._currentTarget = result.targetPosition;
- this._currentPoi = CITY_POIS[cityId].pois[poiIndex];
- }
- this._updateLocationMiniStatus();
- }
-
- /**
- * Builds and shows the POI pill row for a city. Each pill displays a
- * QWERTY keyboard shortcut key and the POI name.
- * @param {string} cityId - City whose POIs to render.
- * @returns {void}
- */
- _expandPOIRow(cityId) {
- if (!CITY_POIS[cityId]) return;
- this._expandedCityId = cityId;
- this._locationControls.showPois(cityId);
- }
-
- /**
- * Hides the POI pill row and clears the expanded city state.
- * @returns {void}
- */
- _collapsePOIRow() {
- this._expandedCityId = null;
- this._activePoiIndex = null;
- this._locationControls.hidePois();
- }
-
- /**
- * Highlights the active POI pill and removes highlight from all others.
- * @returns {void}
- */
- _updatePoiHighlight() {
- this._locationControls.highlightPoi(this._activePoiIndex);
- }
-
- /**
- * Forget the last free-text search destination and repaint the LOCATION
- * readout. Public so camera owners that fly on their own — scene playback
- * most of all — can invalidate it without reaching into private state.
- * @returns {void}
- */
- clearSearchedLocation() {
- if (this._searchedLocationLabel === null) return;
- this._searchedLocationLabel = null;
- this._updateLocationMiniStatus();
- }
-
- /**
- * Sets the active city location, highlights its pill, and updates the mini-status readout.
- * @param {string|null} locationId - City identifier, or null to clear.
- * @returns {void}
- */
- _setActiveLocation(locationId) {
- this._activeLocationId = locationId;
- // A preset city is now what the camera is framed on, so any earlier
- // free-text destination has been superseded. Clearing only on a real id
- // leaves the search path's own _setActiveLocation(null) untouched.
- if (locationId) this._searchedLocationLabel = null;
- this._locationControls?.highlightCity(locationId);
- this._updateLocationMiniStatus();
- }
-
- /**
- * Updates the collapsed mini-status readout with the current destination:
- * a preset city + POI/landmark, or the last free-text geocode search.
- * @returns {void}
- */
- _updateLocationMiniStatus() {
- this._locationControls?.renderStatus({
- city: this._activeLocationId ? CITY_POIS[this._activeLocationId] : null,
- currentPoi: this._currentPoi,
- searchedLabel: this._searchedLocationLabel,
- });
- }
-
- /**
- * Updates the collapsed mini-status readout with the active style label.
- * @param {string} [styleName=this.activeStyle] - Style name to display.
- * @returns {void}
- */
- _updateStyleMiniStatus(styleName = this.activeStyle) {
- if (!this._styleMiniValue) return;
- this._styleMiniValue.textContent = STYLE_STATUS_LABELS[styleName] || String(styleName || 'normal').toUpperCase();
- }
-
- // ── Orbit Mode ──────────────────────────────
-
- /**
- * Creates the orbit mode indicator DOM element and appends it to the body.
- * @returns {void}
- */
- _initOrbit() {
- this._orbitIndicator = this._locationControls.createOrbitIndicator();
- }
-
- /**
- * Toggles the orbit controller around the current POI target. Shows a toast
- * if no target position has been set (user must fly to a POI first).
- * @returns {void}
- */
- _toggleOrbit() {
- if (!this._currentTarget) {
- this._showToast('Fly to a POI first');
- return;
- }
-
- const isActive = this.orbitController.toggle(this._currentTarget, {
- radius: this._currentPoi?.alt || 500,
- pitch: this._currentPoi?.pitch || -30,
- });
-
- this._orbitIndicator.classList.toggle('active', isActive);
- }
-
- /**
- * Stops orbit mode if active and hides the orbit indicator.
- * @returns {void}
- */
- _stopOrbit() {
- if (this.orbitController.active) {
- this.orbitController.stop();
- this._orbitIndicator.classList.remove('active');
- }
- }
-
- /** Wire the top-center action that clears only manager-owned data layers. */
- _initClearSelectedLayersButton() {
- if (!this._clearSelectedLayersBtn) return;
- this._clearLayersControl?.destroy();
- this._clearLayersControl = bindClearLayersControl(this._clearSelectedLayersBtn, () => this.clearSelectedLayers());
- }
-
- /**
- * Clear every selected data layer without resetting visual, map, HUD, or
- * camera state. A layer may still release camera work it owns as part of its
- * established disable lifecycle.
- * @returns {Promise