mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-05 19:04:02 +00:00
refactor: separate application UI state owners
This commit is contained in:
1 parent
ddc1edf3af
commit
dc81cf585c
34 files changed
+3378
-2201
No files matched your search
@@ -1,5 +1,7 @@
|
|||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
|
- Separate navigation, share restoration, visual settings and panel state into focused UI owners with explicit dependencies and terminal cleanup.
|
||||||
|
|
||||||
- Separate layer lifecycle transactions from panel construction and render/detection reactions; retain existing transition and refresh behavior.
|
- Separate layer lifecycle transactions from panel construction and render/detection reactions; retain existing transition and refresh behavior.
|
||||||
|
|
||||||
- Let CLI tools, development launchers and the setup doctor use an explicit project directory while retaining their existing default paths.
|
- Let CLI tools, development launchers and the setup doctor use an explicit project directory while retaining their existing default paths.
|
||||||
|
|||||||
@@ -461,3 +461,19 @@ render requests and detection invalidation. It owns hidden-panel refresh and
|
|||||||
listener cleanup. Application data assembly constructs both owners explicitly;
|
listener cleanup. Application data assembly constructs both owners explicitly;
|
||||||
`data/manager` is the compatibility facade for direct callers. The ordinary
|
`data/manager` is the compatibility facade for direct callers. The ordinary
|
||||||
state subscriptions retain their existing event contract.
|
state subscriptions retain their existing event contract.
|
||||||
|
|
||||||
|
## UI state owners
|
||||||
|
|
||||||
|
- `ui/navigation` owns navigation generations, pending search presentation and
|
||||||
|
tracking handoff. It reads Cockpit admission and uses supplied tracking operations.
|
||||||
|
- `ui/share-restoration` owns the initial restore transaction, layer coordinator,
|
||||||
|
status notices and gesture/timer cleanup.
|
||||||
|
- `ui/visual-settings` owns style preferences, detection overrides, display inputs
|
||||||
|
and IR cleanup. Its engine services and panel operations are explicit.
|
||||||
|
- `ui/panel-chrome` owns disclosure, docking, collapse preferences and temporary
|
||||||
|
Cockpit panel snapshots, composing the existing positioning/layout controllers.
|
||||||
|
|
||||||
|
These package groups contain their own dependencies and exclude application
|
||||||
|
assembly. `applicationShell` retains composition and compatibility methods;
|
||||||
|
those methods delegate to the state owner. Source-based regression checks inspect
|
||||||
|
that implementation owner, and browser acceptance exercises the assembled UI.
|
||||||
@@ -1,5 +1,14 @@
|
|||||||
# God's Eye View Current State
|
# God's Eye View Current State
|
||||||
|
|
||||||
|
The UI facade composes separate owners for navigation authority, share restoration,
|
||||||
|
visual settings and panel chrome. Navigation owns request generations and follow
|
||||||
|
handoff; restoration owns its coordinator, deferred notices, gestures and timer;
|
||||||
|
visual settings own style/detection overrides and sensor cleanup; panel chrome
|
||||||
|
owns disclosure, docking and Cockpit panel snapshots. Each takes explicit
|
||||||
|
operations and reads instead of the complete shell. Existing controls, share
|
||||||
|
formats and one-application-per-page behavior are preserved.
|
||||||
|
|
||||||
|
|
||||||
Layer lifecycle transactions are owned by `data/lifecycle`, which has no panel,
|
Layer lifecycle transactions are owned by `data/lifecycle`, which has no panel,
|
||||||
render-governor or detection imports. Application composition mounts a separate
|
render-governor or detection imports. Application composition mounts a separate
|
||||||
layer presentation owner that reacts to status, accepted visibility/parameter
|
layer presentation owner that reacts to status, accepted visibility/parameter
|
||||||
|
|||||||
+5
-1
@@ -196,6 +196,10 @@
|
|||||||
"./standalone/catalog": "./src/standalone/catalog.js",
|
"./standalone/catalog": "./src/standalone/catalog.js",
|
||||||
"./application/layers": "./src/app/constructCatalog.js",
|
"./application/layers": "./src/app/constructCatalog.js",
|
||||||
"./application/operations": "./src/app/operations.js",
|
"./application/operations": "./src/app/operations.js",
|
||||||
"./data/lifecycle": "./src/data/lifecycle.js"
|
"./data/lifecycle": "./src/data/lifecycle.js",
|
||||||
|
"./ui/navigation": "./src/ui/navigationController.js",
|
||||||
|
"./ui/share-restoration": "./src/ui/shareRestoration.js",
|
||||||
|
"./ui/visual-settings": "./src/ui/visualSettings.js",
|
||||||
|
"./ui/panel-chrome": "./src/ui/panelChrome.js"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -589,5 +589,11 @@
|
|||||||
"src/app/operations.test.mjs",
|
"src/app/operations.test.mjs",
|
||||||
"src/data/lifecycle.js",
|
"src/data/lifecycle.js",
|
||||||
"src/data/manager.js",
|
"src/data/manager.js",
|
||||||
"src/app/layerPresentation.js"
|
"src/app/layerPresentation.js",
|
||||||
|
"src/ui/navigationController.js",
|
||||||
|
"src/ui/shareRestoration.js",
|
||||||
|
"src/ui/visualSettings.js",
|
||||||
|
"src/ui/panelChrome.js",
|
||||||
|
"src/testSupport/readShellSource.mjs",
|
||||||
|
"src/ui/stateOwners.test.mjs"
|
||||||
]
|
]
|
||||||
@@ -439,6 +439,7 @@
|
|||||||
"ui-shell": {
|
"ui-shell": {
|
||||||
"exports": ["./ui/shell"],
|
"exports": ["./ui/shell"],
|
||||||
"modules": [
|
"modules": [
|
||||||
|
"src/app/stateChannel.js",
|
||||||
"src/bloom.js",
|
"src/bloom.js",
|
||||||
"src/cctvFocusPolicy.js",
|
"src/cctvFocusPolicy.js",
|
||||||
"src/cctvFocusRequest.js",
|
"src/cctvFocusRequest.js",
|
||||||
@@ -498,6 +499,7 @@
|
|||||||
"src/ui/contextTransactions.js",
|
"src/ui/contextTransactions.js",
|
||||||
"src/ui/displayControls.js",
|
"src/ui/displayControls.js",
|
||||||
"src/ui/effects.js",
|
"src/ui/effects.js",
|
||||||
|
"src/ui/frameRateMonitor.js",
|
||||||
"src/ui/layerPanel.js",
|
"src/ui/layerPanel.js",
|
||||||
"src/ui/layers.js",
|
"src/ui/layers.js",
|
||||||
"src/ui/leftPanelRail.js",
|
"src/ui/leftPanelRail.js",
|
||||||
@@ -506,6 +508,8 @@
|
|||||||
"src/ui/locationSearch.js",
|
"src/ui/locationSearch.js",
|
||||||
"src/ui/mapSource.js",
|
"src/ui/mapSource.js",
|
||||||
"src/ui/mapSourceControls.js",
|
"src/ui/mapSourceControls.js",
|
||||||
|
"src/ui/navigationController.js",
|
||||||
|
"src/ui/panelChrome.js",
|
||||||
"src/ui/panelDisclosure.js",
|
"src/ui/panelDisclosure.js",
|
||||||
"src/ui/panelLayoutController.js",
|
"src/ui/panelLayoutController.js",
|
||||||
"src/ui/panelMeasurement.js",
|
"src/ui/panelMeasurement.js",
|
||||||
@@ -522,17 +526,17 @@
|
|||||||
"src/ui/sceneControls.js",
|
"src/ui/sceneControls.js",
|
||||||
"src/ui/scenePresentation.js",
|
"src/ui/scenePresentation.js",
|
||||||
"src/ui/scenes.js",
|
"src/ui/scenes.js",
|
||||||
|
"src/ui/shareRestoration.js",
|
||||||
"src/ui/shellElements.js",
|
"src/ui/shellElements.js",
|
||||||
"src/ui/shellFeedback.js",
|
"src/ui/shellFeedback.js",
|
||||||
"src/ui/styleParameters.js",
|
"src/ui/styleParameters.js",
|
||||||
"src/ui/surfaceKeyboard.js",
|
"src/ui/surfaceKeyboard.js",
|
||||||
|
"src/ui/uiLifetime.js",
|
||||||
"src/ui/visualEffects.js",
|
"src/ui/visualEffects.js",
|
||||||
"src/ui/visualInput.js",
|
"src/ui/visualInput.js",
|
||||||
"src/ui/visualPresets.js",
|
"src/ui/visualPresets.js",
|
||||||
"src/worldFocus.js",
|
"src/ui/visualSettings.js",
|
||||||
"src/ui/uiLifetime.js",
|
"src/worldFocus.js"
|
||||||
"src/app/stateChannel.js",
|
|
||||||
"src/ui/frameRateMonitor.js"
|
|
||||||
],
|
],
|
||||||
"external": ["cesium"]
|
"external": ["cesium"]
|
||||||
},
|
},
|
||||||
@@ -1498,6 +1502,8 @@
|
|||||||
"src/ui/locationSearch.js",
|
"src/ui/locationSearch.js",
|
||||||
"src/ui/mapSource.js",
|
"src/ui/mapSource.js",
|
||||||
"src/ui/mapSourceControls.js",
|
"src/ui/mapSourceControls.js",
|
||||||
|
"src/ui/navigationController.js",
|
||||||
|
"src/ui/panelChrome.js",
|
||||||
"src/ui/panelDisclosure.js",
|
"src/ui/panelDisclosure.js",
|
||||||
"src/ui/panelLayoutController.js",
|
"src/ui/panelLayoutController.js",
|
||||||
"src/ui/panelMeasurement.js",
|
"src/ui/panelMeasurement.js",
|
||||||
@@ -1514,6 +1520,7 @@
|
|||||||
"src/ui/sceneControls.js",
|
"src/ui/sceneControls.js",
|
||||||
"src/ui/scenePresentation.js",
|
"src/ui/scenePresentation.js",
|
||||||
"src/ui/scenes.js",
|
"src/ui/scenes.js",
|
||||||
|
"src/ui/shareRestoration.js",
|
||||||
"src/ui/shellElements.js",
|
"src/ui/shellElements.js",
|
||||||
"src/ui/shellFeedback.js",
|
"src/ui/shellFeedback.js",
|
||||||
"src/ui/styleParameters.js",
|
"src/ui/styleParameters.js",
|
||||||
@@ -1522,6 +1529,7 @@
|
|||||||
"src/ui/visualEffects.js",
|
"src/ui/visualEffects.js",
|
||||||
"src/ui/visualInput.js",
|
"src/ui/visualInput.js",
|
||||||
"src/ui/visualPresets.js",
|
"src/ui/visualPresets.js",
|
||||||
|
"src/ui/visualSettings.js",
|
||||||
"src/voice/commands.js",
|
"src/voice/commands.js",
|
||||||
"src/voice/control.js",
|
"src/voice/control.js",
|
||||||
"src/voice/gevActions.js",
|
"src/voice/gevActions.js",
|
||||||
@@ -1911,5 +1919,63 @@
|
|||||||
"exports": ["./data/lifecycle"],
|
"exports": ["./data/lifecycle"],
|
||||||
"modules": ["src/data/lifecycle.js"],
|
"modules": ["src/data/lifecycle.js"],
|
||||||
"external": []
|
"external": []
|
||||||
|
},
|
||||||
|
"ui-navigation": {
|
||||||
|
"exports": ["./ui/navigation"],
|
||||||
|
"modules": ["src/navigationPolicy.js", "src/ui/navigationController.js"],
|
||||||
|
"external": ["cesium"]
|
||||||
|
},
|
||||||
|
"ui-share-restoration": {
|
||||||
|
"exports": ["./ui/share-restoration"],
|
||||||
|
"modules": [
|
||||||
|
"src/data/installationFeedback.js",
|
||||||
|
"src/data/layerState.js",
|
||||||
|
"src/loadingFeedback.js",
|
||||||
|
"src/navigationPolicy.js",
|
||||||
|
"src/ui/shareRestoration.js",
|
||||||
|
"src/ui/uiLifetime.js"
|
||||||
|
],
|
||||||
|
"external": []
|
||||||
|
},
|
||||||
|
"ui-visual-settings": {
|
||||||
|
"exports": ["./ui/visual-settings"],
|
||||||
|
"modules": [
|
||||||
|
"src/bloom.js",
|
||||||
|
"src/cockpitVisionPolicy.js",
|
||||||
|
"src/contactsDetectionPolicy.js",
|
||||||
|
"src/data/detectionPolicy.js",
|
||||||
|
"src/styles/anime.js",
|
||||||
|
"src/styles/noir.js",
|
||||||
|
"src/styles/retro.js",
|
||||||
|
"src/styles/snow.js",
|
||||||
|
"src/styles/surveillance.js",
|
||||||
|
"src/styles/thermal.js",
|
||||||
|
"src/ui/applicationShortcuts.js",
|
||||||
|
"src/ui/effects.js",
|
||||||
|
"src/ui/styleParameters.js",
|
||||||
|
"src/ui/uiLifetime.js",
|
||||||
|
"src/ui/visualEffects.js",
|
||||||
|
"src/ui/visualInput.js",
|
||||||
|
"src/ui/visualPresets.js",
|
||||||
|
"src/ui/visualSettings.js"
|
||||||
|
],
|
||||||
|
"external": ["cesium"]
|
||||||
|
},
|
||||||
|
"ui-panel-chrome": {
|
||||||
|
"exports": ["./ui/panel-chrome"],
|
||||||
|
"modules": [
|
||||||
|
"src/panelStackLayout.js",
|
||||||
|
"src/ui/leftPanelRail.js",
|
||||||
|
"src/ui/panelChrome.js",
|
||||||
|
"src/ui/panelDisclosure.js",
|
||||||
|
"src/ui/panelLayoutController.js",
|
||||||
|
"src/ui/panelMeasurement.js",
|
||||||
|
"src/ui/panelPositionControls.js",
|
||||||
|
"src/ui/panelRailGeometry.js",
|
||||||
|
"src/ui/panelRails.js",
|
||||||
|
"src/ui/rightPanelRail.js",
|
||||||
|
"src/ui/uiLifetime.js"
|
||||||
|
],
|
||||||
|
"external": []
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { readShellSource, shellMethod } from './testSupport/readShellSource.mjs';
|
||||||
import { readLayerSource } from './testSupport/readLayerSource.mjs';
|
import { readLayerSource } from './testSupport/readLayerSource.mjs';
|
||||||
import { StyleManager } from './ui/applicationShell.js';
|
import { StyleManager } from './ui/applicationShell.js';
|
||||||
import { enter as cockpitEnter, navigateContext } from './ui/cockpitTrackingController.js';
|
import { enter as cockpitEnter, navigateContext } from './ui/cockpitTrackingController.js';
|
||||||
@@ -9,7 +10,7 @@ import test from 'node:test';
|
|||||||
import { fileURLToPath } from 'node:url';
|
import { fileURLToPath } from 'node:url';
|
||||||
|
|
||||||
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
||||||
const ui = fs.readFileSync(path.join(ROOT, 'src', 'ui', 'applicationShell.js'), 'utf8');
|
const ui = readShellSource();
|
||||||
const firms = readLayerSource(path.join(ROOT, 'src', 'data', 'firmsHeatmap.js'));
|
const firms = readLayerSource(path.join(ROOT, 'src', 'data', 'firmsHeatmap.js'));
|
||||||
const vessels = readLayerSource(path.join(ROOT, 'src', 'data', 'aisLiveVessels.js'));
|
const vessels = readLayerSource(path.join(ROOT, 'src', 'data', 'aisLiveVessels.js'));
|
||||||
const voice = fs.readFileSync(path.join(ROOT, 'src', 'voice', 'gevActions.js'), 'utf8');
|
const voice = fs.readFileSync(path.join(ROOT, 'src', 'voice', 'gevActions.js'), 'utf8');
|
||||||
@@ -18,8 +19,8 @@ const cockpitTracking = fs.readFileSync(path.join(ROOT, 'src', 'cockpitTracking.
|
|||||||
|
|
||||||
function body(source, pattern, label) {
|
function body(source, pattern, label) {
|
||||||
const name = pattern.source.match(/^(\w+)/)?.[1];
|
const name = pattern.source.match(/^(\w+)/)?.[1];
|
||||||
if (source === ui && typeof StyleManager.prototype[name] === 'function') {
|
if (source === ui && typeof shellMethod(name) === 'function') {
|
||||||
const method = StyleManager.prototype[name].toString();
|
const method = shellMethod(name).toString();
|
||||||
return method.slice(method.indexOf(') {') + 3, -1);
|
return method.slice(method.indexOf(') {') + 3, -1);
|
||||||
}
|
}
|
||||||
const match = source.match(pattern);
|
const match = source.match(pattern);
|
||||||
@@ -174,7 +175,7 @@ test('accepted navigation releases through PR15-aware ownership before flight',
|
|||||||
'explicit navigation',
|
'explicit navigation',
|
||||||
);
|
);
|
||||||
ordered(run, [
|
ordered(run, [
|
||||||
'cockpitActive: !!this.cockpitView?.active',
|
'cockpitActive: this.isCockpitActive()',
|
||||||
'stamp: () => this._stampNavigation()',
|
'stamp: () => this._stampNavigation()',
|
||||||
'release: () => this._releaseFollowCamera(releaseOptions)',
|
'release: () => this._releaseFollowCamera(releaseOptions)',
|
||||||
'navigate,',
|
'navigate,',
|
||||||
@@ -266,7 +267,7 @@ test('deferred search releases only after its final authority check', () => {
|
|||||||
test('a direct globe gesture retires delayed camera and selection restore only', () => {
|
test('a direct globe gesture retires delayed camera and selection restore only', () => {
|
||||||
assert.match(
|
assert.match(
|
||||||
ui,
|
ui,
|
||||||
/this\._initialShareGestureHandler = \(\) => \{[\s\S]*?!this\._resolveInitialShareRestore[\s\S]*?stampInitialShareGesture\(\(options\) => this\._stampNavigation\(options\)\);/,
|
/this\._initialShareGestureHandler = \(\) => \{[\s\S]*?!this\._resolveInitialShareRestore[\s\S]*?stampInitialShareGesture\(\(options\) =>\s*this\.navigation\._stampNavigation\(options\),?\s*\);/,
|
||||||
);
|
);
|
||||||
assert.match(
|
assert.match(
|
||||||
ui,
|
ui,
|
||||||
@@ -300,10 +301,11 @@ test('newer navigation, reset, Cockpit, and teardown share one generation', () =
|
|||||||
const dispose = body(ui, /async dispose\(\) \{([\s\S]*?)\n \}/, 'dispose');
|
const dispose = body(ui, /async dispose\(\) \{([\s\S]*?)\n \}/, 'dispose');
|
||||||
ordered(dispose, [
|
ordered(dispose, [
|
||||||
'this._disposed = true;',
|
'this._disposed = true;',
|
||||||
'this._stampNavigation();',
|
'this._navigation.destroy();',
|
||||||
'this._removeWorldRequestFocusListener?.();',
|
'this._removeWorldRequestFocusListener?.();',
|
||||||
'await this._contextControls.restoreForDisposal();',
|
'await this._contextControls.restoreForDisposal();',
|
||||||
], 'dispose invalidation');
|
], 'dispose invalidation');
|
||||||
|
assert.match(shellMethod('destroy').toString(), /this\._stampNavigation\(\)/);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('teardown synchronously closes immediate camera entry points', () => {
|
test('teardown synchronously closes immediate camera entry points', () => {
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { readShellSource } from './testSupport/readShellSource.mjs';
|
||||||
import { test } from 'node:test';
|
import { test } from 'node:test';
|
||||||
import assert from 'node:assert/strict';
|
import assert from 'node:assert/strict';
|
||||||
import { readFileSync } from 'node:fs';
|
import { readFileSync } from 'node:fs';
|
||||||
@@ -73,7 +74,7 @@ test('CCTV enable retains a pre-await tracking snapshot when tracking clears dur
|
|||||||
assert.match(diagnostics[0][0], /before setEnabled await/);
|
assert.match(diagnostics[0][0], /before setEnabled await/);
|
||||||
assert.match(diagnostics[1][0], /after setEnabled await/);
|
assert.match(diagnostics[1][0], /after setEnabled await/);
|
||||||
|
|
||||||
const uiSource = readFileSync(new URL('./ui/applicationShell.js', import.meta.url), 'utf8');
|
const uiSource = readShellSource();
|
||||||
assert.match(uiSource, /await runCctvLayerEnableTransition\(\{/);
|
assert.match(uiSource, /await runCctvLayerEnableTransition\(\{/);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -114,6 +115,6 @@ test('a disposed UI cannot activate a camera when enabling finishes late', async
|
|||||||
release();
|
release();
|
||||||
await pending;
|
await pending;
|
||||||
assert.equal(activations, 0);
|
assert.equal(activations, 0);
|
||||||
const uiSource = readFileSync(new URL('./ui/applicationShell.js', import.meta.url), 'utf8');
|
const uiSource = readShellSource();
|
||||||
assert.match(uiSource, /shouldFocus: \(\) =>\s*!this\._disposed\s*&&\s*this\._dataManager\.isEnabled\('cctv'\)/);
|
assert.match(uiSource, /shouldFocus: \(\) =>\s*!this\._disposed\s*&&\s*this\._dataManager\.isEnabled\('cctv'\)/);
|
||||||
});
|
});
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { readShellSource } from './testSupport/readShellSource.mjs';
|
||||||
import test from 'node:test';
|
import test from 'node:test';
|
||||||
import assert from 'node:assert/strict';
|
import assert from 'node:assert/strict';
|
||||||
import { readFileSync } from 'node:fs';
|
import { readFileSync } from 'node:fs';
|
||||||
@@ -57,7 +58,7 @@ test('UI CCTV focus listener registration disposes the exact added callback once
|
|||||||
assert.strictEqual(added[0].callback, listener);
|
assert.strictEqual(added[0].callback, listener);
|
||||||
assert.strictEqual(removed[0].callback, added[0].callback);
|
assert.strictEqual(removed[0].callback, added[0].callback);
|
||||||
|
|
||||||
const uiSource = readFileSync(new URL('./ui/applicationShell.js', import.meta.url), 'utf8');
|
const uiSource = readShellSource();
|
||||||
assert.match(
|
assert.match(
|
||||||
uiSource,
|
uiSource,
|
||||||
/_removeCctvRequestFocusListener = registerCctvFocusRequestListener\([\s\S]+this\._cctvRequestFocusHandler/,
|
/_removeCctvRequestFocusListener = registerCctvFocusRequestListener\([\s\S]+this\._cctvRequestFocusHandler/,
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { readShellSource, shellMethod } from './testSupport/readShellSource.mjs';
|
||||||
import { expandApplicationHtml } from '../build/application-html.js';
|
import { expandApplicationHtml } from '../build/application-html.js';
|
||||||
import { readLayerSource } from './testSupport/readLayerSource.mjs';
|
import { readLayerSource } from './testSupport/readLayerSource.mjs';
|
||||||
import { PanelLayoutController } from './ui/panelLayoutController.js';
|
import { PanelLayoutController } from './ui/panelLayoutController.js';
|
||||||
@@ -28,7 +29,7 @@ import { fileURLToPath } from 'node:url';
|
|||||||
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
||||||
|
|
||||||
const html = expandApplicationHtml(fs.readFileSync(path.join(ROOT, 'index.html'), 'utf8'));
|
const html = expandApplicationHtml(fs.readFileSync(path.join(ROOT, 'index.html'), 'utf8'));
|
||||||
const ui = fs.readFileSync(path.join(ROOT, 'src', 'ui', 'applicationShell.js'), 'utf8');
|
const ui = readShellSource();
|
||||||
const css = readStylesheet(path.join(ROOT, 'style.css'));
|
const css = readStylesheet(path.join(ROOT, 'style.css'));
|
||||||
const sceneDirector = fs.readFileSync(path.join(ROOT, 'src', 'scenes', 'director.js'), 'utf8');
|
const sceneDirector = fs.readFileSync(path.join(ROOT, 'src', 'scenes', 'director.js'), 'utf8');
|
||||||
const manager = fs.readFileSync(path.join(ROOT, 'src', 'data', 'lifecycle.js'), 'utf8');
|
const manager = fs.readFileSync(path.join(ROOT, 'src', 'data', 'lifecycle.js'), 'utf8');
|
||||||
@@ -203,7 +204,8 @@ test('programmatic Context layer changes cannot bypass explicit expansion policy
|
|||||||
});
|
});
|
||||||
|
|
||||||
test('share startup isolates panel defaults from recipient-local collapse preferences', () => {
|
test('share startup isolates panel defaults from recipient-local collapse preferences', () => {
|
||||||
const parseIndex = ui.indexOf('this._initialShareState = this.shareLinkManager.parseInitialHash();');
|
const parseIndex = ui.indexOf('this._shareRestoration.attachLinks(this.shareLinkManager);');
|
||||||
|
assert.match(shellMethod('attachLinks').toString(), /this\._initialShareState = shareLinkManager\.parseInitialHash\(\)/);
|
||||||
const panelChromeIndex = ui.indexOf('this._initPanelChrome();');
|
const panelChromeIndex = ui.indexOf('this._initPanelChrome();');
|
||||||
assert.ok(parseIndex >= 0, 'initial share state must be parsed during UI construction');
|
assert.ok(parseIndex >= 0, 'initial share state must be parsed during UI construction');
|
||||||
assert.ok(
|
assert.ok(
|
||||||
@@ -211,11 +213,11 @@ test('share startup isolates panel defaults from recipient-local collapse prefer
|
|||||||
'share state must be known before panel chrome can read recipient-local preferences',
|
'share state must be known before panel chrome can read recipient-local preferences',
|
||||||
);
|
);
|
||||||
assert.equal(
|
assert.equal(
|
||||||
(ui.match(/this\.shareLinkManager\.parseInitialHash\(\)/g) || []).length,
|
(ui.match(/shareLinkManager\.parseInitialHash\(\)/g) || []).length,
|
||||||
1,
|
1,
|
||||||
'startup must parse the incoming share exactly once',
|
'startup must parse the incoming share exactly once',
|
||||||
);
|
);
|
||||||
const panelChrome = ui.match(/_initPanelChrome\(\) \{([\s\S]*?)\n \}\n\n \/\*\*/);
|
const panelChrome = { 1: shellMethod('_initPanelChrome').toString() };
|
||||||
assert.ok(panelChrome, 'panel chrome initializer is missing');
|
assert.ok(panelChrome, 'panel chrome initializer is missing');
|
||||||
assert.match(
|
assert.match(
|
||||||
panelChrome[1],
|
panelChrome[1],
|
||||||
@@ -397,7 +399,8 @@ test('fresh Cockpit entry temporarily collapses map panels and exit restores the
|
|||||||
assert.match(entryPanels[1], new RegExp(`'${panelId}'`), `${panelId} must collapse on entry`);
|
assert.match(entryPanels[1], new RegExp(`'${panelId}'`), `${panelId} must collapse on entry`);
|
||||||
}
|
}
|
||||||
|
|
||||||
const callback = ui.match(/onEntered: \(\) => \{([\s\S]*?)\n \},\n onExited:/);
|
assert.match(ui, /onEntered: \(\) => this\._panelChrome\.enterCockpit\(\)/);
|
||||||
|
const callback = { 1: shellMethod('enterCockpit').toString() };
|
||||||
assert.ok(callback, 'Cockpit onEntered callback is missing');
|
assert.ok(callback, 'Cockpit onEntered callback is missing');
|
||||||
assert.match(
|
assert.match(
|
||||||
callback[1],
|
callback[1],
|
||||||
@@ -426,7 +429,8 @@ test('fresh Cockpit entry temporarily collapses map panels and exit restores the
|
|||||||
'normal Context must not reopen over Cockpit',
|
'normal Context must not reopen over Cockpit',
|
||||||
);
|
);
|
||||||
|
|
||||||
const exitCallback = ui.match(/onExited: \(\) => \{([\s\S]*?)\n \},\n restoreTrackingFrame:/);
|
assert.match(ui, /onExited: \(\) => this\._panelChrome\.exitCockpit\(\)/);
|
||||||
|
const exitCallback = { 1: shellMethod('exitCockpit').toString() };
|
||||||
assert.ok(exitCallback, 'Cockpit onExited callback is missing');
|
assert.ok(exitCallback, 'Cockpit onExited callback is missing');
|
||||||
assert.match(
|
assert.match(
|
||||||
exitCallback[1],
|
exitCallback[1],
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { readShellSource, shellMethod } from './testSupport/readShellSource.mjs';
|
||||||
import { _syncContextModeButtons } from './ui/contextPresentation.js';
|
import { _syncContextModeButtons } from './ui/contextPresentation.js';
|
||||||
// Contacts-scoped detection (owner playtest 2026-08-18: "when you click on
|
// 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
|
// Contacts, detections should just turn on, and they should stay on in Cockpit
|
||||||
@@ -28,7 +29,7 @@ import {
|
|||||||
import { canonicalizeDensity } from './data/detectionPolicy.js';
|
import { canonicalizeDensity } from './data/detectionPolicy.js';
|
||||||
|
|
||||||
// Follow the UI wiring and its extracted preset definitions.
|
// Follow the UI wiring and its extracted preset definitions.
|
||||||
const uiSource = fs.readFileSync(new URL('./ui/applicationShell.js', import.meta.url), 'utf8')
|
const uiSource = readShellSource()
|
||||||
+ '\n' + fs.readFileSync(new URL('./ui/visualPresets.js', import.meta.url), 'utf8');
|
+ '\n' + fs.readFileSync(new URL('./ui/visualPresets.js', import.meta.url), 'utf8');
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -140,10 +141,7 @@ test('the Contacts preset is the very object the military styles apply', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test('the shared preset applier ignores the style override flag — the caller owns it', () => {
|
test('the shared preset applier ignores the style override flag — the caller owns it', () => {
|
||||||
const applier = uiSource.slice(
|
const applier = shellMethod('_applyDetectionPreset').toString();
|
||||||
uiSource.indexOf('_applyDetectionPreset(det) {'),
|
|
||||||
uiSource.indexOf('Applies the global post-processing baseline'),
|
|
||||||
);
|
|
||||||
assert.ok(applier.length > 0);
|
assert.ok(applier.length > 0);
|
||||||
assert.doesNotMatch(applier, /_detectionUserOverridden/);
|
assert.doesNotMatch(applier, /_detectionUserOverridden/);
|
||||||
// The STYLE path still gates on it; Contacts deliberately does not.
|
// The STYLE path still gates on it; Contacts deliberately does not.
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { readShellSource } from './testSupport/readShellSource.mjs';
|
||||||
import { _handleContextLayerChange } from './ui/contextLayerChanges.js';
|
import { _handleContextLayerChange } from './ui/contextLayerChanges.js';
|
||||||
import { connectContextManager } from './ui/contextSubscriptions.js';
|
import { connectContextManager } from './ui/contextSubscriptions.js';
|
||||||
import { _restoreContextSession, _restoreContextSessionAfterLayerSettles } from './ui/contextSession.js';
|
import { _restoreContextSession, _restoreContextSessionAfterLayerSettles } from './ui/contextSession.js';
|
||||||
@@ -23,7 +24,7 @@ import assert from 'node:assert/strict';
|
|||||||
import { readFileSync } from 'node:fs';
|
import { readFileSync } from 'node:fs';
|
||||||
import { fileURLToPath } from 'node:url';
|
import { fileURLToPath } from 'node:url';
|
||||||
|
|
||||||
const src = readFileSync(fileURLToPath(new URL('./ui/applicationShell.js', import.meta.url)), 'utf8');
|
const src = readShellSource();
|
||||||
|
|
||||||
const handler = _handleContextLayerChange.toString();
|
const handler = _handleContextLayerChange.toString();
|
||||||
|
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { readShellSource } from './testSupport/readShellSource.mjs';
|
||||||
import { expandApplicationHtml } from '../build/application-html.js';
|
import { expandApplicationHtml } from '../build/application-html.js';
|
||||||
import { readStylesheet } from './testSupport/readStylesheet.mjs';
|
import { readStylesheet } from './testSupport/readStylesheet.mjs';
|
||||||
import { _selectContextMode } from './ui/contextTransactions.js';
|
import { _selectContextMode } from './ui/contextTransactions.js';
|
||||||
@@ -13,7 +14,7 @@ import { test } from 'node:test';
|
|||||||
import assert from 'node:assert/strict';
|
import assert from 'node:assert/strict';
|
||||||
|
|
||||||
const html = expandApplicationHtml(readFileSync(new URL('../index.html', import.meta.url), 'utf8'));
|
const html = expandApplicationHtml(readFileSync(new URL('../index.html', import.meta.url), 'utf8'));
|
||||||
const ui = readFileSync(new URL('./ui/applicationShell.js', import.meta.url), 'utf8');
|
const ui = readShellSource();
|
||||||
const css = readStylesheet(new URL('../style.css', import.meta.url));
|
const css = readStylesheet(new URL('../style.css', import.meta.url));
|
||||||
|
|
||||||
test('Contacts and Space Missions both participate in the ordinary Tab sequence', () => {
|
test('Contacts and Space Missions both participate in the ordinary Tab sequence', () => {
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { readShellSource } from './testSupport/readShellSource.mjs';
|
||||||
import { readStylesheet } from './testSupport/readStylesheet.mjs';
|
import { readStylesheet } from './testSupport/readStylesheet.mjs';
|
||||||
import assert from 'node:assert/strict';
|
import assert from 'node:assert/strict';
|
||||||
import fs from 'node:fs';
|
import fs from 'node:fs';
|
||||||
@@ -7,7 +8,7 @@ import { fileURLToPath } from 'node:url';
|
|||||||
|
|
||||||
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
||||||
const css = readStylesheet(path.join(ROOT, 'style.css'));
|
const css = readStylesheet(path.join(ROOT, 'style.css'));
|
||||||
const ui = fs.readFileSync(path.join(ROOT, 'src', 'ui', 'applicationShell.js'), 'utf8');
|
const ui = readShellSource();
|
||||||
|
|
||||||
/*
|
/*
|
||||||
* Required-attribution keep-out pin.
|
* Required-attribution keep-out pin.
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { readShellSource } from '../testSupport/readShellSource.mjs';
|
||||||
import test from 'node:test';
|
import test from 'node:test';
|
||||||
import assert from 'node:assert/strict';
|
import assert from 'node:assert/strict';
|
||||||
import { readFileSync } from 'node:fs';
|
import { readFileSync } from 'node:fs';
|
||||||
@@ -671,7 +672,7 @@ test('pathological detection paint holds alternate frames without freezing share
|
|||||||
|
|
||||||
test('detection cannot resurrect a private canvas, listener, matrix, resize, clear, or UI inventory', () => {
|
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 source = readFileSync(new URL('./detection.js', import.meta.url), 'utf8');
|
||||||
const uiSource = readFileSync(new URL('../ui/applicationShell.js', import.meta.url), 'utf8')
|
const uiSource = readShellSource()
|
||||||
+ '\n' + readFileSync(new URL('../ui/visualPresets.js', import.meta.url), 'utf8');
|
+ '\n' + readFileSync(new URL('../ui/visualPresets.js', import.meta.url), 'utf8');
|
||||||
assert.doesNotMatch(source, /createElement\(\s*['"]canvas['"]\s*\)/);
|
assert.doesNotMatch(source, /createElement\(\s*['"]canvas['"]\s*\)/);
|
||||||
assert.doesNotMatch(source, /postRender\.addEventListener/);
|
assert.doesNotMatch(source, /postRender\.addEventListener/);
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { readShellSource } from '../testSupport/readShellSource.mjs';
|
||||||
import { expandApplicationHtml } from '../../build/application-html.js';
|
import { expandApplicationHtml } from '../../build/application-html.js';
|
||||||
import { readLayerSource } from '../testSupport/readLayerSource.mjs';
|
import { readLayerSource } from '../testSupport/readLayerSource.mjs';
|
||||||
import test from 'node:test';
|
import test from 'node:test';
|
||||||
@@ -373,7 +374,7 @@ test('a fresh boot starts 3D aircraft ON in proximity — codec, both layers, an
|
|||||||
assert.match(source, /^\s*(?:let |flightState\.)_models3dMode = 'proximity';/m,
|
assert.match(source, /^\s*(?:let |flightState\.)_models3dMode = 'proximity';/m,
|
||||||
`${name}: and starts in proximity, matching the codec default`);
|
`${name}: and starts in proximity, matching the codec default`);
|
||||||
}
|
}
|
||||||
const ui = await readFile(new URL('../ui/applicationShell.js', import.meta.url), 'utf8');
|
const ui = await readShellSource();
|
||||||
assert.match(ui, /^\s*this\.(?:flightState\.)?_models3dEnabled = true;$/m,
|
assert.match(ui, /^\s*this\.(?:flightState\.)?_models3dEnabled = true;$/m,
|
||||||
'ui.js: the DISPLAY rail believes 3D is on before any layer-state sync arrives');
|
'ui.js: the DISPLAY rail believes 3D is on before any layer-state sync arrives');
|
||||||
assert.match(ui, /this\.(?:flightState\.)?_models3dMode = 'proximity';/,
|
assert.match(ui, /this\.(?:flightState\.)?_models3dMode = 'proximity';/,
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import { ShareRestoration } from './ui/shareRestoration.js';
|
||||||
|
import { readShellSource, shellMethod } from './testSupport/readShellSource.mjs';
|
||||||
import { expandApplicationHtml } from '../build/application-html.js';
|
import { expandApplicationHtml } from '../build/application-html.js';
|
||||||
import { readStylesheet } from './testSupport/readStylesheet.mjs';
|
import { readStylesheet } from './testSupport/readStylesheet.mjs';
|
||||||
import test from 'node:test';
|
import test from 'node:test';
|
||||||
@@ -91,11 +93,9 @@ test('deferred terminal notices lose ownership to newer acquisition epochs and d
|
|||||||
});
|
});
|
||||||
|
|
||||||
test('share-follow failures use the universal top-center status instead of the bottom toast', () => {
|
test('share-follow failures use the universal top-center status instead of the bottom toast', () => {
|
||||||
const ui = readFileSync(new URL('./ui/applicationShell.js', import.meta.url), 'utf8');
|
const ui = readShellSource();
|
||||||
const start = ui.indexOf(' _handleShareTrackingRestoreStatus(result) {');
|
const handler = shellMethod('_handleShareTrackingRestoreStatus').toString();
|
||||||
const end = ui.indexOf('\n _initGlobalContextPanel() {', start);
|
assert.match(handler, /this\.showStatus\(message\)/);
|
||||||
const handler = ui.slice(start, end);
|
|
||||||
assert.match(handler, /this\._showGlobalStatusNotice\(message\)/);
|
|
||||||
assert.match(handler, /this\.initialRestorePromise\.then\(showAfterStartupCover\)/);
|
assert.match(handler, /this\.initialRestorePromise\.then\(showAfterStartupCover\)/);
|
||||||
assert.match(handler, /this\._lifetime\.frame\(\(\) => \{/);
|
assert.match(handler, /this\._lifetime\.frame\(\(\) => \{/);
|
||||||
assert.match(handler, /this\._lifetime\.listen\(\s*startupCover,\s*'transitionend',\s*showOnce,\s*\{ once: true \},?\s*\)/);
|
assert.match(handler, /this\._lifetime\.listen\(\s*startupCover,\s*'transitionend',\s*showOnce,\s*\{ once: true \},?\s*\)/);
|
||||||
@@ -197,14 +197,15 @@ test('replacement, repetition, and hidden-tab elapsed time use the newest fixed
|
|||||||
});
|
});
|
||||||
|
|
||||||
test('universal notice lifecycle clears on dispose and uses the one top-center live region', () => {
|
test('universal notice lifecycle clears on dispose and uses the one top-center live region', () => {
|
||||||
const ui = readFileSync(new URL('./ui/applicationShell.js', import.meta.url), 'utf8');
|
const ui = readShellSource();
|
||||||
const html = expandApplicationHtml(readFileSync(new URL('../index.html', import.meta.url), 'utf8'));
|
const html = expandApplicationHtml(readFileSync(new URL('../index.html', import.meta.url), 'utf8'));
|
||||||
const disposeStart = ui.indexOf(' async dispose() {');
|
const disposeStart = ui.indexOf(' async dispose() {');
|
||||||
const disposeEnd = ui.indexOf('\n }\n', disposeStart);
|
const disposeEnd = ui.indexOf('\n }\n', disposeStart);
|
||||||
const dispose = ui.slice(disposeStart, disposeEnd);
|
const dispose = ui.slice(disposeStart, disposeEnd);
|
||||||
|
|
||||||
assert.match(dispose, /this\._feedback\._globalStatusNotice = null;/);
|
assert.match(dispose, /this\._feedback\._globalStatusNotice = null;/);
|
||||||
assert.match(dispose, /this\._shareTrackingNoticeGeneration \+= 1;/);
|
assert.match(dispose, /this\._shareRestoration\.destroy\(\)/);
|
||||||
|
assert.match(ShareRestoration.prototype.destroy.toString(), /this\._shareTrackingNoticeGeneration \+= 1;/);
|
||||||
assert.match(html, /<div id="global-loading-status" role="status" aria-live="polite" aria-atomic="true" hidden>/);
|
assert.match(html, /<div id="global-loading-status" role="status" aria-live="polite" aria-atomic="true" hidden>/);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { readShellSource, shellMethod } from './testSupport/readShellSource.mjs';
|
||||||
import { StyleManager } from './ui/applicationShell.js';
|
import { StyleManager } from './ui/applicationShell.js';
|
||||||
import assert from 'node:assert/strict';
|
import assert from 'node:assert/strict';
|
||||||
import fs from 'node:fs';
|
import fs from 'node:fs';
|
||||||
@@ -6,7 +7,7 @@ import test from 'node:test';
|
|||||||
import { fileURLToPath } from 'node:url';
|
import { fileURLToPath } from 'node:url';
|
||||||
|
|
||||||
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
||||||
const ui = fs.readFileSync(path.join(ROOT, 'src', 'ui', 'applicationShell.js'), 'utf8');
|
const ui = readShellSource();
|
||||||
const director = fs.readFileSync(path.join(ROOT, 'src', 'scenes', 'director.js'), 'utf8');
|
const director = fs.readFileSync(path.join(ROOT, 'src', 'scenes', 'director.js'), 'utf8');
|
||||||
|
|
||||||
/** Source of the free-text LOCATION search handler (Enter on #location-search). */
|
/** Source of the free-text LOCATION search handler (Enter on #location-search). */
|
||||||
@@ -69,13 +70,13 @@ test('any other camera destination clears the search label too', () => {
|
|||||||
// label outlives the place it named.
|
// label outlives the place it named.
|
||||||
const start = ui.indexOf(' _stampNavigation(');
|
const start = ui.indexOf(' _stampNavigation(');
|
||||||
assert.ok(start > 0, '_stampNavigation is missing');
|
assert.ok(start > 0, '_stampNavigation is missing');
|
||||||
assert.match(StyleManager.prototype._stampNavigation.toString(), /if \(clearSearchedLocation\) this\.clearSearchedLocation\(\);/);
|
assert.match(shellMethod('_stampNavigation').toString(), /if \(clearSearchedLocation\) this\.clearLocation\(\);/);
|
||||||
|
|
||||||
// The shared funnel is what the reset and voice seams actually reach.
|
// The shared funnel is what the reset and voice seams actually reach.
|
||||||
for (const seam of ['resetToGlobeView() {', 'beginLocationNavigation() {', '_runExplicitNavigation(']) {
|
for (const seam of ['resetToGlobeView() {', 'beginLocationNavigation() {', '_runExplicitNavigation(']) {
|
||||||
const at = ui.indexOf(seam);
|
const at = ui.indexOf(seam);
|
||||||
assert.ok(at > 0, `missing navigation seam "${seam}"`);
|
assert.ok(at > 0, `missing navigation seam "${seam}"`);
|
||||||
assert.match(StyleManager.prototype[seam.match(/^(\w+)/)[1]].toString(), /_stampNavigation\(/, `"${seam}" must stamp navigation`);
|
assert.match(shellMethod(seam.match(/^(\w+)/)[1]).toString(), /_stampNavigation\(/, `"${seam}" must stamp navigation`);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Public seam, so a camera owner that flies on its own can invalidate it.
|
// Public seam, so a camera owner that flies on its own can invalidate it.
|
||||||
@@ -90,7 +91,7 @@ test('a deferred lookup that never flies leaves the readout standing', () => {
|
|||||||
const begin = ui.indexOf(' _beginDeferredNavigation(');
|
const begin = ui.indexOf(' _beginDeferredNavigation(');
|
||||||
assert.ok(begin > 0, '_beginDeferredNavigation is missing');
|
assert.ok(begin > 0, '_beginDeferredNavigation is missing');
|
||||||
assert.match(
|
assert.match(
|
||||||
StyleManager.prototype._beginDeferredNavigation.toString(),
|
shellMethod('_beginDeferredNavigation').toString(),
|
||||||
/stamp: \(\) =>\s*this\._stampNavigation\(\{\s*cancelPendingSelection,\s*clearSearchedLocation: false,?\s*\}\)/,
|
/stamp: \(\) =>\s*this\._stampNavigation\(\{\s*cancelPendingSelection,\s*clearSearchedLocation: false,?\s*\}\)/,
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -98,7 +99,7 @@ test('a deferred lookup that never flies leaves the readout standing', () => {
|
|||||||
assert.ok(reassert > 0, '_reassertNavigationHandoff is missing');
|
assert.ok(reassert > 0, '_reassertNavigationHandoff is missing');
|
||||||
assert.match(
|
assert.match(
|
||||||
ui.slice(reassert, reassert + 900),
|
ui.slice(reassert, reassert + 900),
|
||||||
/release: \(\) => \{[\s\S]{0,320}?this\.clearSearchedLocation\(\);[\s\S]{0,200}?this\._releaseFollowCamera\(\)/,
|
/release: \(\) => \{[\s\S]{0,320}?this\.clearLocation\(\);[\s\S]{0,200}?this\._releaseFollowCamera\(\)/,
|
||||||
);
|
);
|
||||||
|
|
||||||
// …and the policy only reaches `release` after its authority checks pass.
|
// …and the policy only reaches `release` after its authority checks pass.
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { readShellSource, shellMethod } from './testSupport/readShellSource.mjs';
|
||||||
import { expandApplicationHtml } from '../build/application-html.js';
|
import { expandApplicationHtml } from '../build/application-html.js';
|
||||||
import { StyleManager } from './ui/applicationShell.js';
|
import { StyleManager } from './ui/applicationShell.js';
|
||||||
import { createHoverDisclosure, collapsePanelOnEscape } from './ui/panelDisclosure.js';
|
import { createHoverDisclosure, collapsePanelOnEscape } from './ui/panelDisclosure.js';
|
||||||
@@ -6,7 +7,7 @@ import assert from 'node:assert/strict';
|
|||||||
import { readFileSync } from 'node:fs';
|
import { readFileSync } from 'node:fs';
|
||||||
|
|
||||||
// Exercise the installed event routes and central close method, without WebGL.
|
// Exercise the installed event routes and central close method, without WebGL.
|
||||||
const source = readFileSync(new URL('./ui/applicationShell.js', import.meta.url), 'utf8');
|
const source = readShellSource();
|
||||||
const markup = expandApplicationHtml(readFileSync(new URL('../index.html', import.meta.url), 'utf8'));
|
const markup = expandApplicationHtml(readFileSync(new URL('../index.html', import.meta.url), 'utf8'));
|
||||||
const locationMarkup = markup.slice(markup.indexOf('<div id="location-bar"'), markup.indexOf('<div id="left-panel-stack"'));
|
const locationMarkup = markup.slice(markup.indexOf('<div id="location-bar"'), markup.indexOf('<div id="left-panel-stack"'));
|
||||||
const locationToggleMarkup = locationMarkup.match(/<button\b([^>]*\bid="location-bar-toggle"[^>]*)>([\s\S]*?)<\/button>/);
|
const locationToggleMarkup = locationMarkup.match(/<button\b([^>]*\bid="location-bar-toggle"[^>]*)>([\s\S]*?)<\/button>/);
|
||||||
@@ -107,7 +108,7 @@ function harness({ hidden = false, selected = true, noChips = false } = {}) {
|
|||||||
window.performance = { now: () => now };
|
window.performance = { now: () => now };
|
||||||
document.defaultView = window;
|
document.defaultView = window;
|
||||||
const methods = new Function('createHoverDisclosure', 'collapsePanelOnEscape', 'document', 'window', 'clearTimeout', 'performance', 'requestAnimationFrame',
|
const methods = new Function('createHoverDisclosure', 'collapsePanelOnEscape', 'document', 'window', 'clearTimeout', 'performance', 'requestAnimationFrame',
|
||||||
`return ({${StyleManager.prototype._initAutoHoverPanel.toString()},\n${StyleManager.prototype._collapsePanelOnEscape.toString()},\n${StyleManager.prototype.setPanelCollapsed.toString()},\n${StyleManager.prototype._syncPanelCollapseButton.toString()}});`)(
|
`return ({${shellMethod('_initAutoHoverPanel').toString()},\n${shellMethod('_collapsePanelOnEscape').toString()},\n${shellMethod('setPanelCollapsed').toString()},\n${shellMethod('_syncPanelCollapseButton').toString()}});`)(
|
||||||
createHoverDisclosure, collapsePanelOnEscape, document, window, (id) => timers.delete(id), { now: () => now }, () => {},
|
createHoverDisclosure, collapsePanelOnEscape, document, window, (id) => timers.delete(id), { now: () => now }, () => {},
|
||||||
);
|
);
|
||||||
const saves = [];
|
const saves = [];
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { readShellSource } from './testSupport/readShellSource.mjs';
|
||||||
import { expandApplicationHtml } from '../build/application-html.js';
|
import { expandApplicationHtml } from '../build/application-html.js';
|
||||||
import { readStylesheet } from './testSupport/readStylesheet.mjs';
|
import { readStylesheet } from './testSupport/readStylesheet.mjs';
|
||||||
// MAP STACK chip row — the dropdown's replacement control surface.
|
// MAP STACK chip row — the dropdown's replacement control surface.
|
||||||
@@ -485,10 +486,7 @@ test('the Visual Presets tray owns Map Source and the retired left panel is abse
|
|||||||
const html = expandApplicationHtml(
|
const html = expandApplicationHtml(
|
||||||
readFileSync(new URL('../index.html', import.meta.url), 'utf8'),
|
readFileSync(new URL('../index.html', import.meta.url), 'utf8'),
|
||||||
);
|
);
|
||||||
const ui = readFileSync(
|
const ui = readShellSource();
|
||||||
new URL('./ui/applicationShell.js', import.meta.url),
|
|
||||||
'utf8',
|
|
||||||
);
|
|
||||||
|
|
||||||
assert.doesNotMatch(
|
assert.doesNotMatch(
|
||||||
html,
|
html,
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { readShellSource } from './testSupport/readShellSource.mjs';
|
||||||
import { onKeyDown as cockpitKeyDown } from './ui/cockpitInput.js';
|
import { onKeyDown as cockpitKeyDown } from './ui/cockpitInput.js';
|
||||||
import { readFileSync as readRadioSource } from 'node:fs';
|
import { readFileSync as readRadioSource } from 'node:fs';
|
||||||
const radioBindings = readRadioSource(new URL('./ui/radioBindings.js', import.meta.url), 'utf8');
|
const radioBindings = readRadioSource(new URL('./ui/radioBindings.js', import.meta.url), 'utf8');
|
||||||
@@ -8,7 +9,7 @@ import assert from 'node:assert/strict';
|
|||||||
import fs from 'node:fs';
|
import fs from 'node:fs';
|
||||||
import test from 'node:test';
|
import test from 'node:test';
|
||||||
|
|
||||||
const source = fs.readFileSync(new URL('./ui/applicationShell.js', import.meta.url), 'utf8');
|
const source = readShellSource();
|
||||||
|
|
||||||
function method(name, nextName) {
|
function method(name, nextName) {
|
||||||
const start = source.indexOf(` ${name}(`);
|
const start = source.indexOf(` ${name}(`);
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { readShellSource } from './testSupport/readShellSource.mjs';
|
||||||
import { expandApplicationHtml } from '../build/application-html.js';
|
import { expandApplicationHtml } from '../build/application-html.js';
|
||||||
import { readStylesheet } from './testSupport/readStylesheet.mjs';
|
import { readStylesheet } from './testSupport/readStylesheet.mjs';
|
||||||
import assert from 'node:assert/strict';
|
import assert from 'node:assert/strict';
|
||||||
@@ -137,7 +138,7 @@ test('minimum panel corridor expands upward without crossing the lower obstacle
|
|||||||
});
|
});
|
||||||
|
|
||||||
test('desktop panel lanes use per-panel allocations and presentation-only auto-collapse', () => {
|
test('desktop panel lanes use per-panel allocations and presentation-only auto-collapse', () => {
|
||||||
const ui = readFileSync(new URL('./ui/applicationShell.js', import.meta.url), 'utf8');
|
const ui = readShellSource();
|
||||||
const css = readStylesheet(new URL('../style.css', import.meta.url));
|
const css = readStylesheet(new URL('../style.css', import.meta.url));
|
||||||
assert.doesNotMatch(ui, /_enforce(?:Left|Right)PanelAccordion/);
|
assert.doesNotMatch(ui, /_enforce(?:Left|Right)PanelAccordion/);
|
||||||
assert.match(rails, /classList\.add\('collapsed', 'layout-auto-collapsed'\)/);
|
assert.match(rails, /classList\.add\('collapsed', 'layout-auto-collapsed'\)/);
|
||||||
@@ -191,7 +192,7 @@ test('desktop panel lanes use per-panel allocations and presentation-only auto-c
|
|||||||
});
|
});
|
||||||
|
|
||||||
test('share-panel state excludes responsive collapse and preserves recipient preferences', () => {
|
test('share-panel state excludes responsive collapse and preserves recipient preferences', () => {
|
||||||
const ui = readFileSync(new URL('./ui/applicationShell.js', import.meta.url), 'utf8');
|
const ui = readShellSource();
|
||||||
const sharelink = readFileSync(new URL('./sharelink.js', import.meta.url), 'utf8');
|
const sharelink = readFileSync(new URL('./sharelink.js', import.meta.url), 'utf8');
|
||||||
|
|
||||||
assert.match(
|
assert.match(
|
||||||
@@ -214,7 +215,7 @@ test('share-panel state excludes responsive collapse and preserves recipient pre
|
|||||||
});
|
});
|
||||||
|
|
||||||
test('parameterized Display presets keep one stable scroll owner', () => {
|
test('parameterized Display presets keep one stable scroll owner', () => {
|
||||||
const ui = readFileSync(new URL('./ui/applicationShell.js', import.meta.url), 'utf8');
|
const ui = readShellSource();
|
||||||
const css = readStylesheet(new URL('../style.css', import.meta.url));
|
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(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;/);
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { readShellSource } from './testSupport/readShellSource.mjs';
|
||||||
import { expandApplicationHtml } from '../build/application-html.js';
|
import { expandApplicationHtml } from '../build/application-html.js';
|
||||||
import { readLayerSource } from './testSupport/readLayerSource.mjs';
|
import { readLayerSource } from './testSupport/readLayerSource.mjs';
|
||||||
import { readStylesheet } from './testSupport/readStylesheet.mjs';
|
import { readStylesheet } from './testSupport/readStylesheet.mjs';
|
||||||
@@ -11,7 +12,7 @@ import { readFileSync } from 'node:fs';
|
|||||||
import test from 'node:test';
|
import test from 'node:test';
|
||||||
|
|
||||||
const html = expandApplicationHtml(readFileSync(new URL('../index.html', import.meta.url), 'utf8'));
|
const html = expandApplicationHtml(readFileSync(new URL('../index.html', import.meta.url), 'utf8'));
|
||||||
const ui = readFileSync(new URL('./ui/applicationShell.js', import.meta.url), 'utf8');
|
const ui = readShellSource();
|
||||||
const radio = ['playback', 'interaction'].map(name =>
|
const radio = ['playback', 'interaction'].map(name =>
|
||||||
readFileSync(new URL(`./layers/radio/${name}.js`, import.meta.url), 'utf8')
|
readFileSync(new URL(`./layers/radio/${name}.js`, import.meta.url), 'utf8')
|
||||||
).join('\n').replace(/layerState\.|parts\.\w+\./g, '');
|
).join('\n').replace(/layerState\.|parts\.\w+\./g, '');
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { readShellSource } from './testSupport/readShellSource.mjs';
|
||||||
import { expandApplicationHtml } from '../build/application-html.js';
|
import { expandApplicationHtml } from '../build/application-html.js';
|
||||||
// src/reasonableDefaults.test.mjs
|
// src/reasonableDefaults.test.mjs
|
||||||
//
|
//
|
||||||
@@ -43,7 +44,7 @@ import {
|
|||||||
import { ShareLinkManager } from './sharelink.js';
|
import { ShareLinkManager } from './sharelink.js';
|
||||||
|
|
||||||
// Follow the UI wiring and its extracted preset definitions.
|
// Follow the UI wiring and its extracted preset definitions.
|
||||||
const uiSource = fs.readFileSync(new URL('./ui/applicationShell.js', import.meta.url), 'utf8')
|
const uiSource = readShellSource()
|
||||||
+ '\n' + fs.readFileSync(new URL('./ui/visualPresets.js', import.meta.url), 'utf8');
|
+ '\n' + fs.readFileSync(new URL('./ui/visualPresets.js', import.meta.url), 'utf8');
|
||||||
const indexHtml = expandApplicationHtml(fs.readFileSync(new URL('../index.html', import.meta.url), 'utf8'));
|
const indexHtml = expandApplicationHtml(fs.readFileSync(new URL('../index.html', import.meta.url), 'utf8'));
|
||||||
const shareSource = fs.readFileSync(new URL('./sharelink.js', import.meta.url), 'utf8');
|
const shareSource = fs.readFileSync(new URL('./sharelink.js', import.meta.url), 'utf8');
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { readShellSource } from '../testSupport/readShellSource.mjs';
|
||||||
// Director-level pins for scene playback.
|
// Director-level pins for scene playback.
|
||||||
//
|
//
|
||||||
// scenePolicy.test.mjs pins the pure decisions; these pin the wiring, which is
|
// scenePolicy.test.mjs pins the pure decisions; these pin the wiring, which is
|
||||||
@@ -483,7 +484,7 @@ test('applyVisualState gates the map-stack switch on both sides of its await', (
|
|||||||
// another setStack() arrives, and a winning state that omits `mapStack`
|
// another setStack() arrives, and a winning state that omits `mapStack`
|
||||||
// never issues one — every normalized scene shot omits it — so a stale
|
// never issues one — every normalized scene shot omits it — so a stale
|
||||||
// switch would otherwise stand on the globe.
|
// switch would otherwise stand on the globe.
|
||||||
const source = fs.readFileSync(new URL('../ui/applicationShell.js', import.meta.url), 'utf8');
|
const source = readShellSource();
|
||||||
const method = source.match(/\n {2}async applyVisualState\([\s\S]*?\n {2}\}\n/);
|
const method = source.match(/\n {2}async applyVisualState\([\s\S]*?\n {2}\}\n/);
|
||||||
assert.ok(method, 'applyVisualState is missing from ui.js');
|
assert.ok(method, 'applyVisualState is missing from ui.js');
|
||||||
assert.match(method[0], /async applyVisualState\(state = \{\}, \{ isCurrent = null \} = \{\}\)/);
|
assert.match(method[0], /async applyVisualState\(state = \{\}, \{ isCurrent = null \} = \{\}\)/);
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import { VisualSettings } from './ui/visualSettings.js';
|
||||||
|
import { readShellSource, shellMethod } from './testSupport/readShellSource.mjs';
|
||||||
import { StyleManager } from './ui/applicationShell.js';
|
import { StyleManager } from './ui/applicationShell.js';
|
||||||
import { _claimContextVisualAuthority, setContextMode } from './ui/contextActions.js';
|
import { _claimContextVisualAuthority, setContextMode } from './ui/contextActions.js';
|
||||||
import { _initGlobalContextPanel } from './ui/contextBindings.js';
|
import { _initGlobalContextPanel } from './ui/contextBindings.js';
|
||||||
@@ -7,11 +9,11 @@ import fs from 'node:fs';
|
|||||||
import { ShareLinkManager, decodeShareCreatedAtMs } from './sharelink.js';
|
import { ShareLinkManager, decodeShareCreatedAtMs } from './sharelink.js';
|
||||||
import { createDefaultLayerState } from './data/layerState.js';
|
import { createDefaultLayerState } from './data/layerState.js';
|
||||||
|
|
||||||
const uiSource = fs.readFileSync(new URL('./ui/applicationShell.js', import.meta.url), 'utf8');
|
const uiSource = readShellSource();
|
||||||
|
|
||||||
function sourceBlock(start, end) {
|
function sourceBlock(start, end) {
|
||||||
const name = start.trim().match(/^(?:async )?(\w+)\(/)?.[1];
|
const name = start.trim().match(/^(?:async )?(\w+)\(/)?.[1];
|
||||||
if (typeof StyleManager.prototype[name] === 'function') return StyleManager.prototype[name].toString();
|
if (typeof shellMethod(name) === 'function') return shellMethod(name).toString();
|
||||||
const startIndex = uiSource.indexOf(start);
|
const startIndex = uiSource.indexOf(start);
|
||||||
const endIndex = uiSource.indexOf(end, startIndex + start.length);
|
const endIndex = uiSource.indexOf(end, startIndex + start.length);
|
||||||
assert.ok(startIndex >= 0, `missing source block start: ${start}`);
|
assert.ok(startIndex >= 0, `missing source block start: ${start}`);
|
||||||
@@ -189,7 +191,7 @@ test('incoming state suppresses premature hash replacement until restoration', (
|
|||||||
test('a shared view reserves its own camera without cancelling its saved Follow', () => {
|
test('a shared view reserves its own camera without cancelling its saved Follow', () => {
|
||||||
assert.match(
|
assert.match(
|
||||||
uiSource,
|
uiSource,
|
||||||
/_beginDeferredNavigation\(\s*'shared view',\s*\{ cancelPendingSelection: false \},\s*\)/,
|
/_beginDeferredNavigation\(\s*'shared view',\s*\{\s*cancelPendingSelection: false,?\s*\},?\s*\)/,
|
||||||
);
|
);
|
||||||
const deferred = sourceBlock(
|
const deferred = sourceBlock(
|
||||||
" _beginDeferredNavigation(noun = 'location', { cancelPendingSelection = true } = {}) {",
|
" _beginDeferredNavigation(noun = 'location', { cancelPendingSelection = true } = {}) {",
|
||||||
@@ -693,11 +695,12 @@ test('visual input listeners are revoked before asynchronous UI teardown', () =>
|
|||||||
assert.ok(firstAwait > 0);
|
assert.ok(firstAwait > 0);
|
||||||
const synchronous = disposal.slice(0, firstAwait);
|
const synchronous = disposal.slice(0, firstAwait);
|
||||||
assert.match(synchronous, /this\._applicationShortcuts\?\.destroy\(\)/);
|
assert.match(synchronous, /this\._applicationShortcuts\?\.destroy\(\)/);
|
||||||
assert.match(synchronous, /this\._visualEffects\.stop\(\)/);
|
assert.match(synchronous, /this\._visualSettings\.stop\(\)/);
|
||||||
assert.match(synchronous, /this\._mapSourceControls\?\.destroy\(\)/);
|
assert.match(synchronous, /this\._mapSourceControls\?\.destroy\(\)/);
|
||||||
assert.match(synchronous, /this\._clearLayersControl\?\.destroy\(\)/);
|
assert.match(synchronous, /this\._clearLayersControl\?\.destroy\(\)/);
|
||||||
assert.match(synchronous, /this\._locationControls\?\.destroy\(\)/);
|
assert.match(synchronous, /this\._locationControls\?\.destroy\(\)/);
|
||||||
assert.match(synchronous, /this\._locationLookup\?\.destroy\(\)/);
|
assert.match(synchronous, /this\._locationLookup\?\.destroy\(\)/);
|
||||||
assert.match(synchronous, /this\._displayControls\?\.destroy\(\)/);
|
assert.match(synchronous, /this\._displayControls\?\.destroy\(\)/);
|
||||||
assert.match(synchronous, /this\._styleParameters\?\.destroy\(\)/);
|
assert.match(VisualSettings.prototype.stop.toString(), /this\._styleParameters\?\.destroy\(\)/);
|
||||||
|
assert.match(VisualSettings.prototype.stop.toString(), /this\._visualEffects\.stop\(\)/);
|
||||||
});
|
});
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
import { readFileSync } from 'node:fs';
|
||||||
|
import { StyleManager } from '../ui/applicationShell.js';
|
||||||
|
import { NavigationController } from '../ui/navigationController.js';
|
||||||
|
import { ShareRestoration } from '../ui/shareRestoration.js';
|
||||||
|
import { VisualSettings } from '../ui/visualSettings.js';
|
||||||
|
import { PanelChrome } from '../ui/panelChrome.js';
|
||||||
|
|
||||||
|
/** Read the state owners as well as the compatibility/composition facade. */
|
||||||
|
export function readShellSource() {
|
||||||
|
return [
|
||||||
|
'navigationController',
|
||||||
|
'shareRestoration',
|
||||||
|
'visualSettings',
|
||||||
|
'panelChrome',
|
||||||
|
'applicationShell',
|
||||||
|
]
|
||||||
|
.map((name) =>
|
||||||
|
readFileSync(new URL(`../ui/${name}.js`, import.meta.url), 'utf8'),
|
||||||
|
)
|
||||||
|
.join('\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Select the implementation owner instead of testing a forwarding facade. */
|
||||||
|
export function shellMethod(name) {
|
||||||
|
for (const owner of [
|
||||||
|
NavigationController,
|
||||||
|
ShareRestoration,
|
||||||
|
VisualSettings,
|
||||||
|
PanelChrome,
|
||||||
|
StyleManager,
|
||||||
|
]) {
|
||||||
|
const method = Object.getOwnPropertyDescriptor(
|
||||||
|
owner.prototype,
|
||||||
|
name,
|
||||||
|
)?.value;
|
||||||
|
if (typeof method === 'function') return method;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
+428
-2140
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,233 @@
|
|||||||
|
import * as Cesium from 'cesium';
|
||||||
|
import {
|
||||||
|
beginDeferredNavigation,
|
||||||
|
reassertNavigationHandoff,
|
||||||
|
runExplicitNavigation,
|
||||||
|
} from '../navigationPolicy.js';
|
||||||
|
|
||||||
|
/** Own camera authority generations, pending search UI and tracking handoff. */
|
||||||
|
export class NavigationController {
|
||||||
|
constructor({
|
||||||
|
viewer,
|
||||||
|
tracking,
|
||||||
|
searchInput,
|
||||||
|
interruptCameraMotion,
|
||||||
|
isCockpitActive,
|
||||||
|
clearLocation,
|
||||||
|
cancelShareSelection,
|
||||||
|
getDataManager,
|
||||||
|
stopOrbit,
|
||||||
|
showToast,
|
||||||
|
}) {
|
||||||
|
Object.assign(this, {
|
||||||
|
viewer,
|
||||||
|
tracking,
|
||||||
|
searchInput,
|
||||||
|
interruptCameraMotion,
|
||||||
|
isCockpitActive,
|
||||||
|
clearLocation,
|
||||||
|
cancelShareSelection,
|
||||||
|
getDataManager,
|
||||||
|
stopOrbit,
|
||||||
|
showToast,
|
||||||
|
});
|
||||||
|
this._navigationGeneration = 0;
|
||||||
|
this._activeLocationSearchGeneration = null;
|
||||||
|
this._disposed = false;
|
||||||
|
}
|
||||||
|
_stampNavigation({
|
||||||
|
cancelPendingSelection = true,
|
||||||
|
clearSearchedLocation = true,
|
||||||
|
} = {}) {
|
||||||
|
const { flightsLayer, militaryFlightsLayer, satellitesLayer } =
|
||||||
|
this.tracking;
|
||||||
|
this._navigationGeneration += 1;
|
||||||
|
// A newer destination owns the camera, so the last free-text search is no
|
||||||
|
// longer where we are. DEFERRED navigation opts out here and clears at the
|
||||||
|
// reassert seam instead: a geocode that never resolves moves no camera, and
|
||||||
|
// a lookup that fails must not blank a readout that is still true.
|
||||||
|
if (clearSearchedLocation) this.clearLocation();
|
||||||
|
if (cancelPendingSelection) {
|
||||||
|
const passivelyClearedShareSelection = this.cancelShareSelection();
|
||||||
|
try {
|
||||||
|
flightsLayer.cancelPendingTrackingRestore?.();
|
||||||
|
} catch {
|
||||||
|
/* best effort */
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
militaryFlightsLayer.cancelPendingTrackingRestore?.();
|
||||||
|
} catch {
|
||||||
|
/* best effort */
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
satellitesLayer.cancelPendingTrackingRestore?.();
|
||||||
|
} catch {
|
||||||
|
/* best effort */
|
||||||
|
}
|
||||||
|
// A deliberate destination supersedes share-selected entities that have
|
||||||
|
// not arrived yet. Active owners publish their clear when released.
|
||||||
|
if (!passivelyClearedShareSelection && !flightsLayer.getTrackedInfo?.()) {
|
||||||
|
this.getDataManager()?.setLayerParams(
|
||||||
|
'flights',
|
||||||
|
{
|
||||||
|
selectedFlightsTrackingId: null,
|
||||||
|
},
|
||||||
|
{ origin: 'tool' },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
!passivelyClearedShareSelection &&
|
||||||
|
!militaryFlightsLayer.getTrackedInfo?.()
|
||||||
|
) {
|
||||||
|
this.getDataManager()?.setLayerParams(
|
||||||
|
'military',
|
||||||
|
{
|
||||||
|
selectedMilitaryTrackingId: null,
|
||||||
|
},
|
||||||
|
{ origin: 'tool' },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
!passivelyClearedShareSelection &&
|
||||||
|
!satellitesLayer.getTrackedInfo?.()
|
||||||
|
) {
|
||||||
|
this.getDataManager()?.setLayerParams(
|
||||||
|
'satellites',
|
||||||
|
{
|
||||||
|
selectedSatTrackingId: null,
|
||||||
|
},
|
||||||
|
{ origin: 'tool' },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (this._activeLocationSearchGeneration !== null) {
|
||||||
|
this._settleLocationSearchUi(this._activeLocationSearchGeneration);
|
||||||
|
}
|
||||||
|
return this._navigationGeneration;
|
||||||
|
}
|
||||||
|
|
||||||
|
_settleLocationSearchUi(generation) {
|
||||||
|
if (this._activeLocationSearchGeneration !== generation) return;
|
||||||
|
this._activeLocationSearchGeneration = null;
|
||||||
|
this.searchInput?.classList.remove('searching', 'expanded');
|
||||||
|
if (this.searchInput) this.searchInput.value = '';
|
||||||
|
this.searchInput?.blur();
|
||||||
|
}
|
||||||
|
|
||||||
|
_releaseFollowCamera({
|
||||||
|
preserveVesselSelection = true,
|
||||||
|
preserveCameraFlight = false,
|
||||||
|
trackingOrigin = 'tool',
|
||||||
|
} = {}) {
|
||||||
|
const {
|
||||||
|
flightsLayer,
|
||||||
|
militaryFlightsLayer,
|
||||||
|
satellitesLayer,
|
||||||
|
aisLiveVesselsLayer,
|
||||||
|
militaryAwarenessLayer,
|
||||||
|
rocketLaunchesLayer,
|
||||||
|
} = this.tracking;
|
||||||
|
let contactSelected = false;
|
||||||
|
try {
|
||||||
|
contactSelected = Boolean(
|
||||||
|
militaryAwarenessLayer.releaseCameraOwnership?.({
|
||||||
|
preserveVesselSelection,
|
||||||
|
origin: trackingOrigin,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
} catch {
|
||||||
|
try {
|
||||||
|
flightsLayer.stopTracking?.({ origin: trackingOrigin });
|
||||||
|
} catch {
|
||||||
|
/* best-effort release */
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
militaryFlightsLayer.stopTracking?.({ origin: trackingOrigin });
|
||||||
|
} catch {
|
||||||
|
/* best-effort release */
|
||||||
|
}
|
||||||
|
if (!preserveVesselSelection) {
|
||||||
|
try {
|
||||||
|
aisLiveVesselsLayer.clearSelection?.();
|
||||||
|
} catch {
|
||||||
|
/* best-effort release */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
satellitesLayer.stopTracking?.({ origin: trackingOrigin });
|
||||||
|
} catch {
|
||||||
|
/* best-effort release */
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
rocketLaunchesLayer.releaseCameraOwnership?.();
|
||||||
|
} catch {
|
||||||
|
/* best-effort release */
|
||||||
|
}
|
||||||
|
this.viewer.trackedEntity = undefined;
|
||||||
|
this.interruptCameraMotion('explicit-navigation');
|
||||||
|
this.stopOrbit();
|
||||||
|
if (!preserveCameraFlight) this.viewer.camera.cancelFlight();
|
||||||
|
try {
|
||||||
|
this.viewer.camera.lookAtTransform(Cesium.Matrix4.IDENTITY);
|
||||||
|
} catch {
|
||||||
|
/* teardown race */
|
||||||
|
}
|
||||||
|
return contactSelected;
|
||||||
|
}
|
||||||
|
|
||||||
|
_runExplicitNavigation(noun, navigate, releaseOptions = undefined) {
|
||||||
|
return runExplicitNavigation({
|
||||||
|
disposed: this._disposed,
|
||||||
|
cockpitActive: this.isCockpitActive(),
|
||||||
|
noun,
|
||||||
|
showToast: (text) => this.showToast(text),
|
||||||
|
stamp: () => this._stampNavigation(),
|
||||||
|
release: () => this._releaseFollowCamera(releaseOptions),
|
||||||
|
navigate,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
_beginDeferredNavigation(
|
||||||
|
noun = 'location',
|
||||||
|
{ cancelPendingSelection = true } = {},
|
||||||
|
) {
|
||||||
|
return beginDeferredNavigation({
|
||||||
|
disposed: this._disposed,
|
||||||
|
cockpitActive: this.isCockpitActive(),
|
||||||
|
noun,
|
||||||
|
showToast: (text) => this.showToast(text),
|
||||||
|
// The searched-location readout survives the STAMP; only a flight that
|
||||||
|
// actually starts invalidates it (see the release hook below).
|
||||||
|
stamp: () =>
|
||||||
|
this._stampNavigation({
|
||||||
|
cancelPendingSelection,
|
||||||
|
clearSearchedLocation: false,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
_reassertNavigationHandoff(generation) {
|
||||||
|
return reassertNavigationHandoff({
|
||||||
|
generation,
|
||||||
|
currentGeneration: this._navigationGeneration,
|
||||||
|
cockpitActive: this.isCockpitActive(),
|
||||||
|
disposed: this._disposed,
|
||||||
|
showToast: (text) => this.showToast(text),
|
||||||
|
// Reached only once the handoff is granted, immediately before the
|
||||||
|
// deferred flight starts — so a lookup that failed, was superseded, or
|
||||||
|
// was refused by the cockpit leaves the old readout standing.
|
||||||
|
release: () => {
|
||||||
|
this.clearLocation();
|
||||||
|
return this._releaseFollowCamera();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
stop() {
|
||||||
|
this._disposed = true;
|
||||||
|
}
|
||||||
|
destroy() {
|
||||||
|
this.stop();
|
||||||
|
this._stampNavigation();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,571 @@
|
|||||||
|
import { UiLifetime } from './uiLifetime.js';
|
||||||
|
import { PanelPositionControls } from './panelPositionControls.js';
|
||||||
|
import { PanelLayoutController } from './panelLayoutController.js';
|
||||||
|
import {
|
||||||
|
bindPanelDisclosure,
|
||||||
|
collapsePanelOnEscape,
|
||||||
|
createHoverDisclosure,
|
||||||
|
} from './panelDisclosure.js';
|
||||||
|
const SHARE_PANEL_STATE_SPECS = Object.freeze([
|
||||||
|
{ id: 'control-panel', pinnable: true },
|
||||||
|
{ id: 'location-bar', pinnable: true },
|
||||||
|
{ id: 'data-panel' },
|
||||||
|
{ id: 'cctv-panel' },
|
||||||
|
{ id: 'radio-panel' },
|
||||||
|
{ id: 'scene-panel' },
|
||||||
|
{ id: 'global-context-panel' },
|
||||||
|
{ id: 'pp-toggles' },
|
||||||
|
{ id: 'param-slider-panel' },
|
||||||
|
]);
|
||||||
|
/** Standard map-view panels cleared out of the way on a fresh Cockpit entry. */
|
||||||
|
const COCKPIT_ENTRY_COLLAPSE_PANEL_IDS = Object.freeze([
|
||||||
|
'data-panel',
|
||||||
|
'cctv-panel',
|
||||||
|
'scene-panel',
|
||||||
|
'pp-toggles',
|
||||||
|
'global-context-panel',
|
||||||
|
'radio-panel',
|
||||||
|
]);
|
||||||
|
|
||||||
|
/** Own panel disclosure, docking, persistence and Cockpit rail restoration. */
|
||||||
|
export class PanelChrome {
|
||||||
|
constructor({
|
||||||
|
elements,
|
||||||
|
operations,
|
||||||
|
readHud,
|
||||||
|
readCockpit,
|
||||||
|
readShareLinks,
|
||||||
|
readInitialShare,
|
||||||
|
readScrollRestoreOwner,
|
||||||
|
readDisplayScrollTop,
|
||||||
|
}) {
|
||||||
|
Object.assign(this, elements, operations, {
|
||||||
|
readHud,
|
||||||
|
readCockpit,
|
||||||
|
readShareLinks,
|
||||||
|
readInitialShare,
|
||||||
|
readScrollRestoreOwner,
|
||||||
|
readDisplayScrollTop,
|
||||||
|
});
|
||||||
|
this._disposed = false;
|
||||||
|
this._lifetime = new UiLifetime();
|
||||||
|
this._cockpitPanelRestore = null;
|
||||||
|
this._cockpitContextCollapsedForDataPanel = false;
|
||||||
|
this._panelPosition = new PanelPositionControls({
|
||||||
|
syncPanelCollapseButton: (panel) => this._syncPanelCollapseButton(panel),
|
||||||
|
layoutRightPanels: () => this._layoutRightPanels(),
|
||||||
|
syncCctvPanelViewport: () => this._syncCctvPanelViewport(),
|
||||||
|
showToast: (message) => this._showToast(message),
|
||||||
|
});
|
||||||
|
this._panelLayout = new PanelLayoutController({
|
||||||
|
readHud: () => ({
|
||||||
|
visible: this.hud.visible,
|
||||||
|
variant: this.hud.getVariant(),
|
||||||
|
}),
|
||||||
|
scheduleCockpitLayout: () => this.cockpitView?.scheduleContextLayout(),
|
||||||
|
syncPanelCollapseButton: (panel) => this._syncPanelCollapseButton(panel),
|
||||||
|
readDisplayScrollTop: () =>
|
||||||
|
this._displayPortalScrollRestoreOwner === 'standard'
|
||||||
|
? this._standardDisplayScrollTop
|
||||||
|
: this._ppToggles?.scrollTop || 0,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
get hud() {
|
||||||
|
return this.readHud();
|
||||||
|
}
|
||||||
|
get cockpitView() {
|
||||||
|
return this.readCockpit();
|
||||||
|
}
|
||||||
|
get shareLinkManager() {
|
||||||
|
return this.readShareLinks();
|
||||||
|
}
|
||||||
|
get _initialShareState() {
|
||||||
|
return this.readInitialShare();
|
||||||
|
}
|
||||||
|
get _displayPortalScrollRestoreOwner() {
|
||||||
|
return this.readScrollRestoreOwner();
|
||||||
|
}
|
||||||
|
get _standardDisplayScrollTop() {
|
||||||
|
return this.readDisplayScrollTop();
|
||||||
|
}
|
||||||
|
|
||||||
|
_initPanelChrome() {
|
||||||
|
for (const control of this._panelDisclosureControls || [])
|
||||||
|
control.destroy();
|
||||||
|
this._panelDisclosureControls = [];
|
||||||
|
const targets = new Map();
|
||||||
|
document
|
||||||
|
.querySelectorAll('.panel-collapse-btn[data-collapse-target]')
|
||||||
|
.forEach((button) => {
|
||||||
|
const targetId = button.dataset.collapseTarget;
|
||||||
|
if (!targetId) return;
|
||||||
|
if (!targets.has(targetId)) targets.set(targetId, []);
|
||||||
|
targets.get(targetId).push(button);
|
||||||
|
});
|
||||||
|
for (const [targetId, buttons] of targets) {
|
||||||
|
const panel = document.getElementById(targetId);
|
||||||
|
if (!panel) continue;
|
||||||
|
this._panelDisclosureControls.push(
|
||||||
|
bindPanelDisclosure({
|
||||||
|
panel,
|
||||||
|
buttons,
|
||||||
|
onChange: (collapsed, options) =>
|
||||||
|
this.setPanelCollapsed(targetId, collapsed, options),
|
||||||
|
onEscape: (event) => this._collapsePanelOnEscape(event, targetId),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
this._restorePanelCollapsedState(targetId, {
|
||||||
|
allowStored: !this._initialShareState,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
// The command dock always starts compact; either wing reveals on hover,
|
||||||
|
// focus, or click and collapses again after the interaction moves away.
|
||||||
|
this.setPanelCollapsed('control-panel', true, {
|
||||||
|
syncShare: false,
|
||||||
|
persist: false,
|
||||||
|
});
|
||||||
|
this.setPanelCollapsed('location-bar', true, {
|
||||||
|
syncShare: false,
|
||||||
|
persist: false,
|
||||||
|
});
|
||||||
|
this._initAutoHoverPanel('control-panel', {
|
||||||
|
openDelayMs: 140,
|
||||||
|
closeDelayMs: 420,
|
||||||
|
});
|
||||||
|
this._initAutoHoverPanel('location-bar', {
|
||||||
|
openDelayMs: 140,
|
||||||
|
closeDelayMs: 420,
|
||||||
|
});
|
||||||
|
this._initCommandDockPins();
|
||||||
|
this._initCommandDockTrayMetrics();
|
||||||
|
this._maybeNotifyLayoutReset();
|
||||||
|
}
|
||||||
|
|
||||||
|
_collapsePanelOnEscape(event, panelId) {
|
||||||
|
return collapsePanelOnEscape(event, {
|
||||||
|
panel: document.getElementById(panelId),
|
||||||
|
onChange: (collapsed, options) =>
|
||||||
|
this.setPanelCollapsed(panelId, collapsed, options),
|
||||||
|
beforeCollapse: () => {
|
||||||
|
if (panelId !== 'location-bar' || !this._locationSearch) return;
|
||||||
|
this._locationSearch.classList.remove('expanded');
|
||||||
|
this._locationSearch.value = '';
|
||||||
|
this._locationSearch.blur();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
_initCommandDockPins() {
|
||||||
|
document
|
||||||
|
.querySelectorAll('.dock-pin-btn[data-pin-target]')
|
||||||
|
.forEach((button) => {
|
||||||
|
this._lifetime.listen(button, 'click', (event) => {
|
||||||
|
event.stopPropagation();
|
||||||
|
const panelId = button.dataset.pinTarget;
|
||||||
|
this._setCommandDockPanelPinState(panelId);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
_setCommandDockPanelPinState(
|
||||||
|
panelId,
|
||||||
|
pin,
|
||||||
|
{ restore = false, persist = true, syncShare = true } = {},
|
||||||
|
) {
|
||||||
|
const panelEl = document.getElementById(panelId);
|
||||||
|
const button = document.querySelector(
|
||||||
|
`.dock-pin-btn[data-pin-target="${panelId}"]`,
|
||||||
|
);
|
||||||
|
if (!panelEl || !button) return undefined;
|
||||||
|
const shouldPin =
|
||||||
|
typeof pin === 'boolean'
|
||||||
|
? pin
|
||||||
|
: !panelEl.classList.contains('dock-pinned');
|
||||||
|
panelEl.classList.toggle('dock-pinned', shouldPin);
|
||||||
|
button.setAttribute('aria-pressed', String(shouldPin));
|
||||||
|
document
|
||||||
|
.querySelectorAll('#command-dock .dock-pinned-top')
|
||||||
|
.forEach((pinnedPanel) => {
|
||||||
|
pinnedPanel.classList.remove('dock-pinned-top');
|
||||||
|
});
|
||||||
|
if (shouldPin) {
|
||||||
|
panelEl.classList.add('dock-pinned-top');
|
||||||
|
this.setPanelCollapsed(panelId, false, {
|
||||||
|
explicit: !restore,
|
||||||
|
restore,
|
||||||
|
persist,
|
||||||
|
syncShare: false,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
const remainingPinnedPanel = document.querySelector(
|
||||||
|
'#command-dock .dock-pinned',
|
||||||
|
);
|
||||||
|
remainingPinnedPanel?.classList.add('dock-pinned-top');
|
||||||
|
if (!restore && !panelEl.matches(':hover')) {
|
||||||
|
this.setPanelCollapsed(panelId, true, {
|
||||||
|
explicit: true,
|
||||||
|
persist,
|
||||||
|
syncShare: false,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
this._updateCommandDockTrayStack();
|
||||||
|
if (syncShare) {
|
||||||
|
if (!restore) this.shareLinkManager?.claimRestoreLane?.('panel', panelId);
|
||||||
|
this.shareLinkManager?.onPanelStateChange?.();
|
||||||
|
}
|
||||||
|
return shouldPin;
|
||||||
|
}
|
||||||
|
|
||||||
|
_initCommandDockTrayMetrics() {
|
||||||
|
return this._panelLayout._initCommandDockTrayMetrics();
|
||||||
|
}
|
||||||
|
|
||||||
|
_updateCommandDockTrayStack() {
|
||||||
|
return this._panelLayout._updateCommandDockTrayStack();
|
||||||
|
}
|
||||||
|
|
||||||
|
_maybeNotifyLayoutReset() {
|
||||||
|
return this._panelPosition._maybeNotifyLayoutReset();
|
||||||
|
}
|
||||||
|
|
||||||
|
_initAutoHoverPanel(
|
||||||
|
panelId,
|
||||||
|
{ openDelayMs = 850, closeDelayMs = 1000 } = {},
|
||||||
|
) {
|
||||||
|
const panel = document.getElementById(panelId);
|
||||||
|
if (!panel) return;
|
||||||
|
this._hoverPanelControls ??= new Map();
|
||||||
|
this._hoverPanelControls.get(panelId)?.destroy();
|
||||||
|
const controller = createHoverDisclosure({
|
||||||
|
panel,
|
||||||
|
documentRef: document,
|
||||||
|
disclosure: panel.querySelector(`[data-dock-toggle-target="${panelId}"]`),
|
||||||
|
openDelayMs,
|
||||||
|
closeDelayMs,
|
||||||
|
isActive: () => !this._disposed,
|
||||||
|
onChange: (collapsed, options) =>
|
||||||
|
this.setPanelCollapsed(panelId, collapsed, options),
|
||||||
|
onEscape: (event) => this._collapsePanelOnEscape(event, panelId),
|
||||||
|
focusTarget:
|
||||||
|
panelId === 'control-panel'
|
||||||
|
? () =>
|
||||||
|
panel.querySelector('.map-stack-chip.active') ||
|
||||||
|
panel.querySelector('.map-stack-chip')
|
||||||
|
: null,
|
||||||
|
});
|
||||||
|
this._hoverPanelControls.set(panelId, controller);
|
||||||
|
if (panelId === 'control-panel') {
|
||||||
|
this._cancelMapSourceFocus?.();
|
||||||
|
this._cancelMapSourceFocus = controller.cancelPendingFocus;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_restorePanelCollapsedState(panelId, options1) {
|
||||||
|
return this._panelPosition._restorePanelCollapsedState(panelId, options1);
|
||||||
|
}
|
||||||
|
|
||||||
|
_savePanelCollapsedState(panelId, collapsed) {
|
||||||
|
return this._panelPosition._savePanelCollapsedState(panelId, collapsed);
|
||||||
|
}
|
||||||
|
|
||||||
|
_scheduleRightPanelLayout(options0) {
|
||||||
|
return this._panelLayout._scheduleRightPanelLayout(options0);
|
||||||
|
}
|
||||||
|
|
||||||
|
_scheduleLeftPanelLayout(options0) {
|
||||||
|
return this._panelLayout._scheduleLeftPanelLayout(options0);
|
||||||
|
}
|
||||||
|
|
||||||
|
_syncPanelCollapseButton(panelEl) {
|
||||||
|
const isRightRail = [
|
||||||
|
'pp-toggles',
|
||||||
|
'cctv-panel',
|
||||||
|
'global-context-panel',
|
||||||
|
].includes(panelEl?.id);
|
||||||
|
const collapsed = panelEl.classList.contains('collapsed');
|
||||||
|
panelEl
|
||||||
|
.querySelectorAll('.panel-collapse-btn[data-collapse-target]')
|
||||||
|
.forEach((btn) => {
|
||||||
|
const owner = btn.closest('[data-panel-id], #param-slider-panel');
|
||||||
|
if (owner !== panelEl) return;
|
||||||
|
if (isRightRail) {
|
||||||
|
btn.textContent = collapsed ? '◀' : '▶';
|
||||||
|
} else {
|
||||||
|
btn.textContent = collapsed ? '+' : '−';
|
||||||
|
}
|
||||||
|
btn.setAttribute('aria-expanded', String(!collapsed));
|
||||||
|
const panelName =
|
||||||
|
panelEl
|
||||||
|
.querySelector('.panel-title, .pp-header-label')
|
||||||
|
?.textContent?.trim() || 'panel';
|
||||||
|
const action = collapsed ? 'Expand' : 'Collapse';
|
||||||
|
btn.title = `${action} ${panelName}`;
|
||||||
|
btn.setAttribute('aria-label', `${action} ${panelName}`);
|
||||||
|
if (panelEl.id === 'radio-panel') {
|
||||||
|
const action = collapsed ? 'Expand' : 'Collapse';
|
||||||
|
btn.title = `${action} Radio`;
|
||||||
|
btn.setAttribute('aria-label', `${action} Radio section`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
const dockToggle = panelEl.querySelector(
|
||||||
|
`[data-dock-toggle-target="${panelEl.id}"]`,
|
||||||
|
);
|
||||||
|
if (dockToggle) {
|
||||||
|
const panelName =
|
||||||
|
panelEl
|
||||||
|
.querySelector('.panel-title, .location-toolbar-label')
|
||||||
|
?.textContent?.trim() || 'panel';
|
||||||
|
const action = collapsed ? 'Expand' : 'Collapse';
|
||||||
|
dockToggle.setAttribute('aria-expanded', String(!collapsed));
|
||||||
|
dockToggle.setAttribute('aria-label', `${action} ${panelName}`);
|
||||||
|
dockToggle.title = `${action} ${panelName}`;
|
||||||
|
}
|
||||||
|
if (panelEl.id === 'radio-panel' && this._contextRadioDetailsBtn) {
|
||||||
|
this._contextRadioDetailsBtn.setAttribute(
|
||||||
|
'aria-expanded',
|
||||||
|
String(!collapsed),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (panelEl.id === 'radio-panel' || panelEl.id === 'global-context-panel') {
|
||||||
|
this._syncContextRadioLauncherState();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_buildSharePanelState() {
|
||||||
|
const specs = [];
|
||||||
|
for (const spec of SHARE_PANEL_STATE_SPECS) {
|
||||||
|
const panelEl = document.getElementById(spec.id);
|
||||||
|
if (!panelEl) continue;
|
||||||
|
// Responsive auto-collapse is presentation only; the recipient should
|
||||||
|
// restore the user's explicit expanded preference at its own viewport.
|
||||||
|
const collapsed = panelEl.classList.contains('layout-auto-collapsed')
|
||||||
|
? false
|
||||||
|
: panelEl.classList.contains('collapsed');
|
||||||
|
const entry = { id: spec.id, collapsed };
|
||||||
|
if (spec.pinnable)
|
||||||
|
entry.pinned = panelEl.classList.contains('dock-pinned');
|
||||||
|
specs.push(entry);
|
||||||
|
}
|
||||||
|
return specs.length ? { specs } : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
_restorePanelState(panelState) {
|
||||||
|
if (!panelState || !Array.isArray(panelState.specs)) return;
|
||||||
|
const specsById = new Map(panelState.specs.map((spec) => [spec.id, spec]));
|
||||||
|
for (const spec of SHARE_PANEL_STATE_SPECS) {
|
||||||
|
const state = specsById.get(spec.id);
|
||||||
|
if (!state || typeof state.collapsed !== 'boolean') continue;
|
||||||
|
if (spec.pinnable && typeof state.pinned === 'boolean') {
|
||||||
|
this._setCommandDockPanelPinState(spec.id, state.pinned, {
|
||||||
|
restore: true,
|
||||||
|
persist: false,
|
||||||
|
syncShare: false,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const nextCollapsed =
|
||||||
|
state.pinned && spec.pinnable ? false : state.collapsed;
|
||||||
|
this.setPanelCollapsed(spec.id, nextCollapsed, {
|
||||||
|
restore: true,
|
||||||
|
persist: false,
|
||||||
|
syncShare: false,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
this.shareLinkManager?.onPanelStateChange?.();
|
||||||
|
}
|
||||||
|
|
||||||
|
setPanelCollapsed(
|
||||||
|
panelId,
|
||||||
|
collapsed,
|
||||||
|
{
|
||||||
|
explicit = false,
|
||||||
|
restore = false,
|
||||||
|
persist = true,
|
||||||
|
syncShare = true,
|
||||||
|
} = {},
|
||||||
|
) {
|
||||||
|
if (panelId === 'control-panel' && collapsed)
|
||||||
|
this._cancelMapSourceFocus?.();
|
||||||
|
const panelEl = document.getElementById(panelId);
|
||||||
|
if (!panelEl) return;
|
||||||
|
if (explicit && !restore)
|
||||||
|
this.shareLinkManager?.claimRestoreLane?.('panel', panelId);
|
||||||
|
const nextCollapsed = Boolean(collapsed);
|
||||||
|
const wasAutoCollapsed = panelEl.classList.contains(
|
||||||
|
'layout-auto-collapsed',
|
||||||
|
);
|
||||||
|
const leftOwnerPanel = this._leftPanelStack?.contains(panelEl)
|
||||||
|
? panelEl
|
||||||
|
: null;
|
||||||
|
const rightOwnerPanel =
|
||||||
|
panelId === 'radio-panel'
|
||||||
|
? document.getElementById('global-context-panel')
|
||||||
|
: this._rightPanelStack?.contains(panelEl)
|
||||||
|
? panelEl
|
||||||
|
: null;
|
||||||
|
const priorLeftOwner = this._panelLayout._leftStackPreferredPanelId;
|
||||||
|
const priorRightOwner = this._panelLayout._rightStackPreferredPanelId;
|
||||||
|
if (explicit && !restore && !nextCollapsed && leftOwnerPanel) {
|
||||||
|
this._panelLayout._leftStackPreferredPanelId = leftOwnerPanel.id;
|
||||||
|
} else if (
|
||||||
|
explicit &&
|
||||||
|
!restore &&
|
||||||
|
nextCollapsed &&
|
||||||
|
leftOwnerPanel?.id === this._panelLayout._leftStackPreferredPanelId
|
||||||
|
) {
|
||||||
|
this._panelLayout._leftStackPreferredPanelId = null;
|
||||||
|
}
|
||||||
|
if (explicit && !restore && !nextCollapsed && rightOwnerPanel) {
|
||||||
|
this._panelLayout._rightStackPreferredPanelId = rightOwnerPanel.id;
|
||||||
|
} else if (
|
||||||
|
explicit &&
|
||||||
|
!restore &&
|
||||||
|
nextCollapsed &&
|
||||||
|
rightOwnerPanel?.id === this._panelLayout._rightStackPreferredPanelId
|
||||||
|
) {
|
||||||
|
this._panelLayout._rightStackPreferredPanelId = null;
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
panelEl.classList.contains('collapsed') === nextCollapsed &&
|
||||||
|
!wasAutoCollapsed
|
||||||
|
) {
|
||||||
|
this._syncPanelCollapseButton(panelEl);
|
||||||
|
if (priorLeftOwner !== this._panelLayout._leftStackPreferredPanelId) {
|
||||||
|
this._scheduleLeftPanelLayout({ reconsiderAutoCollapse: true });
|
||||||
|
}
|
||||||
|
if (priorRightOwner !== this._panelLayout._rightStackPreferredPanelId) {
|
||||||
|
this._scheduleRightPanelLayout({ reconsiderAutoCollapse: true });
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
panelEl.classList.remove('layout-auto-collapsed');
|
||||||
|
if (
|
||||||
|
!nextCollapsed &&
|
||||||
|
this.cockpitView?.active &&
|
||||||
|
panelId === 'data-panel'
|
||||||
|
) {
|
||||||
|
this._cockpitContextCollapsedForDataPanel =
|
||||||
|
!this.cockpitView.contextCollapsed;
|
||||||
|
if (this._cockpitContextCollapsedForDataPanel) {
|
||||||
|
this.cockpitView.setContextCollapsed(true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
!nextCollapsed &&
|
||||||
|
panelId === 'global-context-panel' &&
|
||||||
|
this._contextRadioDock?.classList.contains('disclosure-open')
|
||||||
|
) {
|
||||||
|
this._setRadioDisclosure?.(false);
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
!nextCollapsed &&
|
||||||
|
panelId === 'radio-panel' &&
|
||||||
|
document
|
||||||
|
.getElementById('global-context-panel')
|
||||||
|
?.classList.contains('collapsed')
|
||||||
|
) {
|
||||||
|
this.setPanelCollapsed('global-context-panel', false, {
|
||||||
|
restore,
|
||||||
|
persist,
|
||||||
|
syncShare,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (!nextCollapsed && !restore && panelId === 'location-bar') {
|
||||||
|
const otherPanel = document.getElementById('control-panel');
|
||||||
|
if (otherPanel && !otherPanel.classList.contains('dock-pinned')) {
|
||||||
|
this.setPanelCollapsed('control-panel', true, {
|
||||||
|
restore,
|
||||||
|
persist,
|
||||||
|
syncShare,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} else if (!nextCollapsed && !restore && panelId === 'control-panel') {
|
||||||
|
const otherPanel = document.getElementById('location-bar');
|
||||||
|
if (otherPanel && !otherPanel.classList.contains('dock-pinned')) {
|
||||||
|
this.setPanelCollapsed('location-bar', true, {
|
||||||
|
restore,
|
||||||
|
persist,
|
||||||
|
syncShare,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
panelEl.classList.toggle('collapsed', nextCollapsed);
|
||||||
|
if (
|
||||||
|
nextCollapsed &&
|
||||||
|
this.cockpitView?.active &&
|
||||||
|
panelId === 'data-panel' &&
|
||||||
|
this._cockpitContextCollapsedForDataPanel
|
||||||
|
) {
|
||||||
|
this._cockpitContextCollapsedForDataPanel = false;
|
||||||
|
this.cockpitView.setContextCollapsed(false);
|
||||||
|
}
|
||||||
|
this._syncPanelCollapseButton(panelEl);
|
||||||
|
if (persist !== false)
|
||||||
|
this._savePanelCollapsedState(panelId, nextCollapsed);
|
||||||
|
if (panelId === 'pp-toggles') {
|
||||||
|
this._layoutRightPanels();
|
||||||
|
}
|
||||||
|
if (this._rightPanelStack?.contains(panelEl)) {
|
||||||
|
this._scheduleRightPanelLayout({ reconsiderAutoCollapse: true });
|
||||||
|
}
|
||||||
|
if (panelId === 'cctv-panel') {
|
||||||
|
this._syncCctvPanelViewport();
|
||||||
|
}
|
||||||
|
this._lifetime.frame(() => this._updateCommandDockTrayStack());
|
||||||
|
this._scheduleLeftPanelLayout({
|
||||||
|
reconsiderAutoCollapse: this._leftPanelStack?.contains(panelEl) === true,
|
||||||
|
});
|
||||||
|
if (syncShare) this.shareLinkManager?.onPanelStateChange?.();
|
||||||
|
}
|
||||||
|
|
||||||
|
_layoutRightPanels() {
|
||||||
|
this._scheduleRightPanelLayout();
|
||||||
|
}
|
||||||
|
enterCockpit() {
|
||||||
|
// A new Cockpit session owns both side rails. Clear standard map-view
|
||||||
|
// panels once on entry; NEXT/PREVIOUS never reaches this callback, so
|
||||||
|
// panels the operator opens while already inside remain untouched.
|
||||||
|
this._cockpitPanelRestore = new Map();
|
||||||
|
this._cockpitContextCollapsedForDataPanel = false;
|
||||||
|
for (const panelId of COCKPIT_ENTRY_COLLAPSE_PANEL_IDS) {
|
||||||
|
const panel = document.getElementById(panelId);
|
||||||
|
if (panel) {
|
||||||
|
this._cockpitPanelRestore.set(
|
||||||
|
panelId,
|
||||||
|
panel.classList.contains('collapsed'),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
this.setPanelCollapsed(panelId, true, {
|
||||||
|
persist: false,
|
||||||
|
syncShare: false,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
this.cockpitView?.setContextCollapsed(false);
|
||||||
|
this.cockpitView?.setSignalCollapsed(false, { user: true });
|
||||||
|
}
|
||||||
|
exitCockpit() {
|
||||||
|
const restore = this._cockpitPanelRestore;
|
||||||
|
this._cockpitPanelRestore = null;
|
||||||
|
this._cockpitContextCollapsedForDataPanel = false;
|
||||||
|
if (!restore) return;
|
||||||
|
for (const [panelId, wasCollapsed] of restore) {
|
||||||
|
this.setPanelCollapsed(panelId, wasCollapsed, {
|
||||||
|
persist: false,
|
||||||
|
syncShare: false,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
destroy() {
|
||||||
|
if (this._disposed) return;
|
||||||
|
this._disposed = true;
|
||||||
|
this._lifetime.destroy();
|
||||||
|
this._panelPosition.destroy();
|
||||||
|
this._panelLayout.destroy();
|
||||||
|
for (const control of this._panelDisclosureControls || [])
|
||||||
|
control.destroy();
|
||||||
|
this._panelDisclosureControls = [];
|
||||||
|
this._hoverPanelControls?.forEach((control) => control.destroy());
|
||||||
|
this._hoverPanelControls?.clear();
|
||||||
|
this._cancelMapSourceFocus?.();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,306 @@
|
|||||||
|
import { LayerStateCoordinator } from '../data/layerState.js';
|
||||||
|
import { stampInitialShareGesture } from '../navigationPolicy.js';
|
||||||
|
import { canPresentDeferredStatusNotice } from '../loadingFeedback.js';
|
||||||
|
import { UiLifetime } from './uiLifetime.js';
|
||||||
|
|
||||||
|
/** Own initial share restoration, durable layer state and restoration notices. */
|
||||||
|
export class ShareRestoration {
|
||||||
|
constructor({
|
||||||
|
viewer,
|
||||||
|
navigation,
|
||||||
|
syncShareState,
|
||||||
|
syncModels3d,
|
||||||
|
showStatus,
|
||||||
|
feedback,
|
||||||
|
updateFeedback,
|
||||||
|
}) {
|
||||||
|
Object.assign(this, {
|
||||||
|
viewer,
|
||||||
|
navigation,
|
||||||
|
syncShareState,
|
||||||
|
syncModels3d,
|
||||||
|
showStatus,
|
||||||
|
feedback,
|
||||||
|
updateFeedback,
|
||||||
|
});
|
||||||
|
this._lifetime = new UiLifetime();
|
||||||
|
this._disposed = false;
|
||||||
|
this._shareTrackingAcquiringKey = null;
|
||||||
|
this._shareTrackingNoticeGeneration = 0;
|
||||||
|
this._initialShareState = null;
|
||||||
|
this._initialShareNavigationGeneration = null;
|
||||||
|
this._initialShareRestoreTimeout = null;
|
||||||
|
this._layerStateCoordinator = null;
|
||||||
|
this._layerStateRestorePromise = null;
|
||||||
|
}
|
||||||
|
attachLinks(shareLinkManager) {
|
||||||
|
this.shareLinkManager = shareLinkManager;
|
||||||
|
this._initialShareState = shareLinkManager.parseInitialHash();
|
||||||
|
}
|
||||||
|
start() {
|
||||||
|
// Restore from URL hash if present
|
||||||
|
const savedState = this._initialShareState;
|
||||||
|
this._initialShareRestorePromise = savedState
|
||||||
|
? new Promise((resolve) => {
|
||||||
|
this._resolveInitialShareRestore = resolve;
|
||||||
|
})
|
||||||
|
: Promise.resolve({ status: 'not-requested', share: null, layers: [] });
|
||||||
|
if (savedState) {
|
||||||
|
this._hasShareState = true;
|
||||||
|
// Reserve camera authority now; the delayed mesh-friendly flight may
|
||||||
|
// run only if no newer user, voice, or tracking navigation has won.
|
||||||
|
this._initialShareNavigationGeneration =
|
||||||
|
this.navigation._beginDeferredNavigation('shared view', {
|
||||||
|
cancelPendingSelection: false,
|
||||||
|
});
|
||||||
|
this._initialShareRestoreTimeout = setTimeout(() => {
|
||||||
|
this._initialShareRestoreTimeout = null;
|
||||||
|
if (this._disposed) return;
|
||||||
|
const generation = this._initialShareNavigationGeneration;
|
||||||
|
const applyCamera =
|
||||||
|
Number.isInteger(generation) &&
|
||||||
|
this.navigation._reassertNavigationHandoff(generation);
|
||||||
|
void (async () => {
|
||||||
|
try {
|
||||||
|
const share = await this.shareLinkManager.applyState(savedState, {
|
||||||
|
applyCamera,
|
||||||
|
navigationToken: generation,
|
||||||
|
});
|
||||||
|
const layers = await (this._layerStateRestorePromise ||
|
||||||
|
Promise.resolve([]));
|
||||||
|
const tracking =
|
||||||
|
share.camera === 'applied'
|
||||||
|
? await this._layerStateCoordinator?.restoreShareTrackingSelection?.()
|
||||||
|
: {
|
||||||
|
status: 'superseded',
|
||||||
|
cleared:
|
||||||
|
this._layerStateCoordinator?.cancelPendingShareTracking?.(
|
||||||
|
'shared-camera-superseded',
|
||||||
|
{ clearSelection: true },
|
||||||
|
) === true,
|
||||||
|
};
|
||||||
|
this.shareLinkManager.completeInitialRestore();
|
||||||
|
this._settleInitialShareRestore({
|
||||||
|
status: 'settled',
|
||||||
|
share,
|
||||||
|
layers,
|
||||||
|
tracking,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
this.shareLinkManager.completeInitialRestore();
|
||||||
|
this._settleInitialShareRestore({
|
||||||
|
status: 'failed',
|
||||||
|
error: String(error?.message || error),
|
||||||
|
share: null,
|
||||||
|
layers: [],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
}, 1500);
|
||||||
|
} else {
|
||||||
|
this.syncShareState();
|
||||||
|
}
|
||||||
|
// A recipient can orbit before or during the delayed share flight. That
|
||||||
|
// gesture keeps ordinary layer state but revokes the passive base camera
|
||||||
|
// and selected-subject Follow so delayed work cannot seize navigation.
|
||||||
|
this._initialShareGestureHandler = () => {
|
||||||
|
if (
|
||||||
|
this._disposed ||
|
||||||
|
!this._hasShareState ||
|
||||||
|
!this._resolveInitialShareRestore
|
||||||
|
)
|
||||||
|
return;
|
||||||
|
stampInitialShareGesture((options) =>
|
||||||
|
this.navigation._stampNavigation(options),
|
||||||
|
);
|
||||||
|
};
|
||||||
|
this.viewer?.canvas?.addEventListener(
|
||||||
|
'pointerdown',
|
||||||
|
this._initialShareGestureHandler,
|
||||||
|
{
|
||||||
|
passive: true,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
this.viewer?.canvas?.addEventListener(
|
||||||
|
'wheel',
|
||||||
|
this._initialShareGestureHandler,
|
||||||
|
{
|
||||||
|
passive: true,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
connect(dataManager) {
|
||||||
|
this._dataManager = dataManager;
|
||||||
|
this._layerStateCoordinator?.destroy();
|
||||||
|
this._layerStateCoordinator = null;
|
||||||
|
this._layerStateRestorePromise = null;
|
||||||
|
if (this._dataManager) {
|
||||||
|
this._layerStateCoordinator = new LayerStateCoordinator(
|
||||||
|
this._dataManager,
|
||||||
|
this.shareLinkManager,
|
||||||
|
{
|
||||||
|
onDurableStateChange: (state) => this.syncModels3d(state),
|
||||||
|
onTrackingRestoreStatus: (result) =>
|
||||||
|
this._handleShareTrackingRestoreStatus(result),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
this._layerStateRestorePromise = this._layerStateCoordinator.start({
|
||||||
|
shareLayerState: this._initialShareState?.layerState || null,
|
||||||
|
shareCreatedAtMs: this._initialShareState?.sharedAtMs ?? null,
|
||||||
|
// Any valid camera/style share isolates recipient-local preferences,
|
||||||
|
// including legacy and malformed-v2 layer payloads.
|
||||||
|
allowLocalState: !this._initialShareState,
|
||||||
|
});
|
||||||
|
if (this._initialShareSelectionSuperseded) {
|
||||||
|
this._layerStateCoordinator.cancelPendingShareTracking(
|
||||||
|
'superseded-before-layer-coordinator-start',
|
||||||
|
{ clearSelection: true },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
void this._layerStateRestorePromise.then(() => {
|
||||||
|
this.syncModels3d(this._layerStateCoordinator?.getDurableState());
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
cancelSelection() {
|
||||||
|
if (
|
||||||
|
this._hasShareState &&
|
||||||
|
this._resolveInitialShareRestore &&
|
||||||
|
!this._layerStateCoordinator
|
||||||
|
)
|
||||||
|
this._initialShareSelectionSuperseded = true;
|
||||||
|
return (
|
||||||
|
this._layerStateCoordinator?.cancelPendingShareTracking?.(
|
||||||
|
'superseded-by-explicit-navigation',
|
||||||
|
{ clearSelection: true },
|
||||||
|
) === true
|
||||||
|
);
|
||||||
|
}
|
||||||
|
get initialRestorePromise() {
|
||||||
|
return (
|
||||||
|
this._initialShareRestorePromise ||
|
||||||
|
Promise.resolve({ status: 'not-requested' })
|
||||||
|
);
|
||||||
|
}
|
||||||
|
_handleShareTrackingRestoreStatus(result) {
|
||||||
|
if (!result || this._disposed) return;
|
||||||
|
const trackingKey = `${result.layerId || ''}:${result.targetId ?? ''}`;
|
||||||
|
if (result.classification === 'pending') {
|
||||||
|
this._shareTrackingNoticeGeneration += 1;
|
||||||
|
this._shareTrackingAcquiringKey = trackingKey;
|
||||||
|
this.showStatus('ACQUIRING', {
|
||||||
|
state: 'acquiring',
|
||||||
|
detail: `SHARED ${String(result.label || 'SUBJECT').toUpperCase()}`,
|
||||||
|
persistent: true,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const ownsAcquiringNotice = this._shareTrackingAcquiringKey === trackingKey;
|
||||||
|
if (ownsAcquiringNotice) {
|
||||||
|
this._shareTrackingNoticeGeneration += 1;
|
||||||
|
this._shareTrackingAcquiringKey = null;
|
||||||
|
if (this.feedback._globalStatusNotice?.state === 'acquiring') {
|
||||||
|
this.feedback._globalStatusNotice = null;
|
||||||
|
this.updateFeedback();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
result.classification === 'followed' ||
|
||||||
|
result.classification === 'cancelled'
|
||||||
|
)
|
||||||
|
return;
|
||||||
|
// A stale terminal result must never replace a newer target's acquisition.
|
||||||
|
if (this._shareTrackingAcquiringKey) return;
|
||||||
|
const noticeGeneration = ownsAcquiringNotice
|
||||||
|
? this._shareTrackingNoticeGeneration
|
||||||
|
: ++this._shareTrackingNoticeGeneration;
|
||||||
|
const subject = result.label || 'entity';
|
||||||
|
const message =
|
||||||
|
result.classification === 'expired'
|
||||||
|
? `Shared ${subject} follow expired`
|
||||||
|
: result.classification === 'source-unavailable'
|
||||||
|
? `Shared ${subject} could not be restored — feed unavailable`
|
||||||
|
: `Shared ${subject} is unavailable`;
|
||||||
|
const showAfterStartupCover = () => {
|
||||||
|
this._lifetime.frame(() => {
|
||||||
|
if (
|
||||||
|
!canPresentDeferredStatusNotice(
|
||||||
|
noticeGeneration,
|
||||||
|
this._shareTrackingNoticeGeneration,
|
||||||
|
this._disposed,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return;
|
||||||
|
const startupCover = document.getElementById('loading-screen');
|
||||||
|
if (
|
||||||
|
!startupCover ||
|
||||||
|
getComputedStyle(startupCover).visibility === 'hidden'
|
||||||
|
) {
|
||||||
|
this.showStatus(message);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let fallbackTimer = null;
|
||||||
|
let removeStartupListener = () => {};
|
||||||
|
const showOnce = () => {
|
||||||
|
removeStartupListener();
|
||||||
|
if (fallbackTimer) this._lifetime.cancelTimeout(fallbackTimer);
|
||||||
|
if (
|
||||||
|
canPresentDeferredStatusNotice(
|
||||||
|
noticeGeneration,
|
||||||
|
this._shareTrackingNoticeGeneration,
|
||||||
|
this._disposed,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
this.showStatus(message);
|
||||||
|
};
|
||||||
|
removeStartupListener = this._lifetime.listen(
|
||||||
|
startupCover,
|
||||||
|
'transitionend',
|
||||||
|
showOnce,
|
||||||
|
{ once: true },
|
||||||
|
);
|
||||||
|
fallbackTimer = this._lifetime.timeout(showOnce, 1000);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
if (this._resolveInitialShareRestore) {
|
||||||
|
void this.initialRestorePromise.then(showAfterStartupCover);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
showAfterStartupCover();
|
||||||
|
}
|
||||||
|
_settleInitialShareRestore(result) {
|
||||||
|
if (!this._resolveInitialShareRestore) return;
|
||||||
|
const resolve = this._resolveInitialShareRestore;
|
||||||
|
this._resolveInitialShareRestore = null;
|
||||||
|
resolve(result);
|
||||||
|
window.dispatchEvent(
|
||||||
|
new CustomEvent('gev:initial-share-restore-settled', { detail: result }),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
destroy() {
|
||||||
|
if (this._disposed) return;
|
||||||
|
this._disposed = true;
|
||||||
|
this._shareTrackingNoticeGeneration += 1;
|
||||||
|
this._shareTrackingAcquiringKey = null;
|
||||||
|
this._layerStateCoordinator?.destroy();
|
||||||
|
this._layerStateCoordinator = null;
|
||||||
|
this._layerStateRestorePromise = null;
|
||||||
|
clearTimeout(this._initialShareRestoreTimeout);
|
||||||
|
this._initialShareRestoreTimeout = null;
|
||||||
|
this._settleInitialShareRestore({
|
||||||
|
status: 'destroyed',
|
||||||
|
share: null,
|
||||||
|
layers: [],
|
||||||
|
});
|
||||||
|
this.viewer?.canvas?.removeEventListener(
|
||||||
|
'pointerdown',
|
||||||
|
this._initialShareGestureHandler,
|
||||||
|
);
|
||||||
|
this.viewer?.canvas?.removeEventListener(
|
||||||
|
'wheel',
|
||||||
|
this._initialShareGestureHandler,
|
||||||
|
);
|
||||||
|
this._initialShareGestureHandler = null;
|
||||||
|
this._lifetime.destroy();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,143 @@
|
|||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import test from 'node:test';
|
||||||
|
import { NavigationController } from './navigationController.js';
|
||||||
|
import { ShareRestoration } from './shareRestoration.js';
|
||||||
|
|
||||||
|
function navigation() {
|
||||||
|
const tracking = Object.fromEntries(
|
||||||
|
[
|
||||||
|
'flightsLayer',
|
||||||
|
'militaryFlightsLayer',
|
||||||
|
'satellitesLayer',
|
||||||
|
'aisLiveVesselsLayer',
|
||||||
|
'militaryAwarenessLayer',
|
||||||
|
'rocketLaunchesLayer',
|
||||||
|
].map((name) => [name, {}]),
|
||||||
|
);
|
||||||
|
return new NavigationController({
|
||||||
|
viewer: { camera: { cancelFlight() {}, lookAtTransform() {} } },
|
||||||
|
tracking,
|
||||||
|
searchInput: null,
|
||||||
|
interruptCameraMotion() {},
|
||||||
|
isCockpitActive: () => false,
|
||||||
|
clearLocation() {},
|
||||||
|
cancelShareSelection: () => false,
|
||||||
|
getDataManager: () => null,
|
||||||
|
stopOrbit() {},
|
||||||
|
showToast() {},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
test('navigation generations belong to their owner and teardown closes both entry paths', () => {
|
||||||
|
const first = navigation();
|
||||||
|
const second = navigation();
|
||||||
|
const a = first._beginDeferredNavigation();
|
||||||
|
const b = second._beginDeferredNavigation();
|
||||||
|
first._stampNavigation();
|
||||||
|
assert.equal(first._reassertNavigationHandoff(a), false);
|
||||||
|
assert.equal(second._reassertNavigationHandoff(b), true);
|
||||||
|
let flights = 0;
|
||||||
|
second.stop();
|
||||||
|
assert.equal(second._beginDeferredNavigation(), false);
|
||||||
|
assert.equal(
|
||||||
|
second._runExplicitNavigation('location', () => {
|
||||||
|
flights += 1;
|
||||||
|
}),
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
assert.equal(flights, 0);
|
||||||
|
const generation = second._navigationGeneration;
|
||||||
|
second.destroy();
|
||||||
|
assert.equal(second._navigationGeneration, generation + 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('share teardown settles its promise, removes gestures and rejects a retained timer callback', async (t) => {
|
||||||
|
const prior = {
|
||||||
|
window: globalThis.window,
|
||||||
|
setTimeout: globalThis.setTimeout,
|
||||||
|
clearTimeout: globalThis.clearTimeout,
|
||||||
|
};
|
||||||
|
const timers = new Map();
|
||||||
|
let timerId = 0;
|
||||||
|
globalThis.window = new EventTarget();
|
||||||
|
globalThis.setTimeout = (fn) => {
|
||||||
|
timers.set(++timerId, fn);
|
||||||
|
return timerId;
|
||||||
|
};
|
||||||
|
globalThis.clearTimeout = (id) => timers.delete(id);
|
||||||
|
t.after(() => Object.assign(globalThis, prior));
|
||||||
|
const canvas = new EventTarget();
|
||||||
|
let stamps = 0;
|
||||||
|
let applies = 0;
|
||||||
|
const owner = new ShareRestoration({
|
||||||
|
viewer: { canvas },
|
||||||
|
navigation: {
|
||||||
|
_beginDeferredNavigation: () => 1,
|
||||||
|
_reassertNavigationHandoff: () => true,
|
||||||
|
_stampNavigation: () => {
|
||||||
|
stamps += 1;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
syncShareState() {},
|
||||||
|
syncModels3d() {},
|
||||||
|
showStatus() {},
|
||||||
|
feedback: {},
|
||||||
|
updateFeedback() {},
|
||||||
|
});
|
||||||
|
owner.attachLinks({
|
||||||
|
parseInitialHash: () => ({ latitude: 30, longitude: -97 }),
|
||||||
|
applyState: async () => {
|
||||||
|
applies += 1;
|
||||||
|
return { camera: 'applied' };
|
||||||
|
},
|
||||||
|
completeInitialRestore() {},
|
||||||
|
});
|
||||||
|
owner.start();
|
||||||
|
const retained = [...timers.values()][0];
|
||||||
|
canvas.dispatchEvent(new Event('wheel'));
|
||||||
|
assert.equal(stamps, 1);
|
||||||
|
owner.destroy();
|
||||||
|
assert.equal((await owner.initialRestorePromise).status, 'destroyed');
|
||||||
|
assert.equal(timers.size, 0);
|
||||||
|
canvas.dispatchEvent(new Event('wheel'));
|
||||||
|
retained();
|
||||||
|
await Promise.resolve();
|
||||||
|
assert.equal(stamps, 1);
|
||||||
|
assert.equal(applies, 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('visual teardown restores owned fog and aircraft sensor state once', async (t) => {
|
||||||
|
const { VisualSettings } = await import('./visualSettings.js');
|
||||||
|
const priorDocument = globalThis.document;
|
||||||
|
globalThis.document = {
|
||||||
|
documentElement: { dataset: {} },
|
||||||
|
getElementById: () => null,
|
||||||
|
};
|
||||||
|
t.after(() => {
|
||||||
|
globalThis.document = priorDocument;
|
||||||
|
});
|
||||||
|
const calls = [];
|
||||||
|
const viewer = { scene: { fog: { enabled: false } } };
|
||||||
|
const owner = new VisualSettings({
|
||||||
|
viewer,
|
||||||
|
elements: {},
|
||||||
|
operations: {},
|
||||||
|
services: {
|
||||||
|
governorRequestRender() {},
|
||||||
|
holdContinuousRender() {},
|
||||||
|
releaseContinuousRender() {},
|
||||||
|
},
|
||||||
|
readDataManager: () => ({ setLayerParams: (...args) => calls.push(args) }),
|
||||||
|
});
|
||||||
|
owner._irBoostActive = true;
|
||||||
|
owner._irFogWasEnabled = true;
|
||||||
|
owner.releaseIrBoost();
|
||||||
|
owner.releaseIrBoost();
|
||||||
|
assert.equal(viewer.scene.fog.enabled, true);
|
||||||
|
assert.deepEqual(calls, [
|
||||||
|
['flights', { irBoost: false }],
|
||||||
|
['military', { irBoost: false }],
|
||||||
|
]);
|
||||||
|
assert.equal(owner._irBoostActive, false);
|
||||||
|
owner.destroy();
|
||||||
|
});
|
||||||
@@ -143,3 +143,14 @@ export const SHARPEN_SHADER = /* glsl */ `
|
|||||||
out_FragColor = vec4(clamp(sharpened.rgb, 0.0, 1.0), center.a);
|
out_FragColor = vec4(clamp(sharpened.rgb, 0.0, 1.0), center.a);
|
||||||
}
|
}
|
||||||
`;
|
`;
|
||||||
|
|
||||||
|
/** Stable display labels for the active style and inherited Cockpit vision. */
|
||||||
|
export const STYLE_STATUS_LABELS = {
|
||||||
|
normal: 'NORMAL',
|
||||||
|
retro: 'CRT',
|
||||||
|
surveillance: 'NVG',
|
||||||
|
thermal: 'FLIR',
|
||||||
|
anime: 'ANIME',
|
||||||
|
noir: 'NOIR',
|
||||||
|
snow: 'SNOW',
|
||||||
|
};
|
||||||
File diff suppressed because it is too large.
Load diff
Reference in new issue
Block a user