mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-06 03:14:00 +00:00
Keep Contacts, active layer intents and mapped sites across globe switches
This commit is contained in:
1 parent
81bcae907f
commit
87bbe14748
35 files changed
+465
-118
No files matched your search
@@ -1921,6 +1921,7 @@
|
||||
"src/layers/awareness/policy.js",
|
||||
"src/layers/awareness/presentation.js",
|
||||
"src/layers/awareness/queries.js",
|
||||
"src/layers/awareness/render/cesium.js",
|
||||
"src/layers/awareness/rendering.js",
|
||||
"src/layers/awareness/state.js",
|
||||
"src/layers/awareness/subject.js",
|
||||
@@ -2806,6 +2807,7 @@
|
||||
"src/layers/awareness/policy.js",
|
||||
"src/layers/awareness/presentation.js",
|
||||
"src/layers/awareness/queries.js",
|
||||
"src/layers/awareness/render/cesium.js",
|
||||
"src/layers/awareness/rendering.js",
|
||||
"src/layers/awareness/state.js",
|
||||
"src/layers/awareness/subject.js",
|
||||
@@ -3668,6 +3670,7 @@
|
||||
"src/layers/earthquakes/render/navaraGeometry.js",
|
||||
"src/layers/earthquakes/render/navaraGrid.js",
|
||||
"src/layers/installations/render/navara.js",
|
||||
"src/layers/awareness/render/navara.js",
|
||||
"src/layers/bikeshare/render/navara.js",
|
||||
"src/layers/installations/render/navaraGeometry.js",
|
||||
"src/layers/installations/render/navaraOutlineBatch.js",
|
||||
@@ -3866,6 +3869,7 @@
|
||||
"src/layers/awareness/policy.js",
|
||||
"src/layers/awareness/presentation.js",
|
||||
"src/layers/awareness/queries.js",
|
||||
"src/layers/awareness/render/cesium.js",
|
||||
"src/layers/awareness/rendering.js",
|
||||
"src/layers/awareness/state.js",
|
||||
"src/layers/awareness/subject.js",
|
||||
|
||||
@@ -0,0 +1,148 @@
|
||||
import puppeteer from 'puppeteer';
|
||||
import assert from 'node:assert/strict';
|
||||
import { blockPaidQaMaps } from './qa-map-policy.mjs';
|
||||
|
||||
const base = new URL(process.argv[2]);
|
||||
if (
|
||||
!['localhost', '127.0.0.1'].includes(base.hostname) ||
|
||||
['', '4173', '4185', '4186', '4187', '4188', '4189', '4194'].includes(
|
||||
base.port,
|
||||
)
|
||||
)
|
||||
throw new Error('Supply an unreserved local keyless preview URL');
|
||||
base.search = '?engine=navara&welcome=0&look=cinematic';
|
||||
base.hash =
|
||||
'v=2&lat=43.6500&lon=-79.3800&alt=46000&heading=0&pitch=-90&roll=0&map=sentinel2&l=4';
|
||||
const b = await puppeteer.launch({
|
||||
headless: true,
|
||||
args: ['--use-angle=metal', '--enable-gpu'],
|
||||
});
|
||||
try {
|
||||
const p = await b.newPage();
|
||||
await blockPaidQaMaps(p);
|
||||
await p.setViewport({ width: 1024, height: 768 });
|
||||
const errors = [];
|
||||
p.on('pageerror', (e) => {
|
||||
errors.push(e.message);
|
||||
console.log('error', e.message.split('?')[0]);
|
||||
});
|
||||
await p.evaluateOnNewDocument(() => {
|
||||
const fetch = window.fetch.bind(window);
|
||||
window.fetch = (input, options) => {
|
||||
const { pathname } = new URL(input.url ?? input, document.baseURI);
|
||||
if (pathname === '/api/vessels')
|
||||
return Promise.resolve(
|
||||
Response.json({
|
||||
status: 'live',
|
||||
rows: ['123456789', '223456789'].map((mmsi, i) => ({
|
||||
mmsi,
|
||||
name: 'Toronto ' + i,
|
||||
lat: 43.65,
|
||||
lon: -79.38 + i * 0.05,
|
||||
last_position_epoch: Date.now() / 1000,
|
||||
})),
|
||||
}),
|
||||
);
|
||||
if (pathname === '/api/vessels/track')
|
||||
return Promise.resolve(Response.json({ samples: [] }));
|
||||
if (pathname === '/api/flights')
|
||||
return Promise.resolve(
|
||||
Response.json({ time: Date.now() / 1000, states: [] }),
|
||||
);
|
||||
if (pathname === '/api/military')
|
||||
return Promise.resolve(Response.json({ now: Date.now(), ac: [] }));
|
||||
if (pathname === '/api/military-installations')
|
||||
return Promise.resolve(Response.json({ elements: [], status: 'ok' }));
|
||||
return fetch(input, options);
|
||||
};
|
||||
});
|
||||
await p.goto(base.href);
|
||||
const ready = async (kind) => {
|
||||
await p.waitForFunction(
|
||||
(k) =>
|
||||
window.__godsEyeView?.engine.kind === k &&
|
||||
!document.querySelector('#globe-engine')?.disabled &&
|
||||
!document.querySelector('.engine-switch-frame') &&
|
||||
document.querySelector('#loading-screen.hidden'),
|
||||
{ timeout: 90000 },
|
||||
kind,
|
||||
);
|
||||
await p.evaluate(
|
||||
() => window.__godsEyeView.styleManager.initialRestorePromise,
|
||||
);
|
||||
};
|
||||
await ready('navara');
|
||||
await p.evaluate(async () => {
|
||||
const { dataManager, styleManager, tileset } = window.__godsEyeView;
|
||||
if (tileset || window.__GOOGLE_MAPS_API_KEY__)
|
||||
throw Error('Require keyless preview');
|
||||
await dataManager.setEnabled('ais-live-vessels', true, { origin: 'user' });
|
||||
window.qaBefore = [...dataManager.getEnabledLayerIds()].sort();
|
||||
const outcome = await styleManager.setContextMode('contacts');
|
||||
if (!outcome.ok) throw Error(JSON.stringify(outcome));
|
||||
window.qaContext = dataManager.layers.get('military-awareness').module;
|
||||
window.qaSites = dataManager.layers.get('military-installations').module;
|
||||
const vessels = dataManager.layers.get('ais-live-vessels').module;
|
||||
if (!vessels.selectById('123456789')) throw Error('Missing first vessel');
|
||||
if (!window.qaContext.navigateNext({ targetLayer: 'ais-live-vessels' }))
|
||||
throw Error('Cannot navigate to the second vessel');
|
||||
});
|
||||
const sample = () =>
|
||||
p.evaluate(() => {
|
||||
const { dataManager, styleManager, engine } = window.__godsEyeView;
|
||||
return {
|
||||
context:
|
||||
window.qaContext ===
|
||||
dataManager.layers.get('military-awareness').module,
|
||||
sites:
|
||||
window.qaSites ===
|
||||
dataManager.layers.get('military-installations').module,
|
||||
mode: styleManager.getContextModeState().mode,
|
||||
subject: window.qaContext.getContextSnapshot()?.subject?.id,
|
||||
history:
|
||||
window.qaContext._getAwarenessNavigationStateForTest().historyKeys,
|
||||
enabled: [...dataManager.getEnabledLayerIds()].sort(),
|
||||
ring:
|
||||
engine.kind === 'navara'
|
||||
? engine.lines
|
||||
.list()
|
||||
.filter((line) => line.id === 'gev-awareness-rim').length
|
||||
: null,
|
||||
};
|
||||
});
|
||||
const baseline = await sample();
|
||||
console.log('baseline', JSON.stringify(baseline));
|
||||
assert.equal(baseline.mode, 'flights');
|
||||
assert.equal(baseline.subject, '223456789');
|
||||
assert.ok(baseline.history.includes('ais-live-vessels:123456789'));
|
||||
assert.ok(baseline.ring >= 1);
|
||||
for (const kind of ['cesium', 'navara']) {
|
||||
await p.select('#globe-engine', kind);
|
||||
await ready(kind);
|
||||
const current = await sample();
|
||||
console.log('handoff', kind, JSON.stringify(current));
|
||||
assert.deepEqual(current, {
|
||||
...baseline,
|
||||
ring: kind === 'navara' ? baseline.ring : null,
|
||||
});
|
||||
}
|
||||
const restored = await p.evaluate(async () => {
|
||||
const { dataManager, styleManager } = window.__godsEyeView;
|
||||
const result = await styleManager.setContextMode('off');
|
||||
return (
|
||||
result.ok &&
|
||||
JSON.stringify([...dataManager.getEnabledLayerIds()].sort()) ===
|
||||
JSON.stringify(window.qaBefore)
|
||||
);
|
||||
});
|
||||
assert.ok(
|
||||
restored,
|
||||
'Restore layers enabled by the user before entering Contacts',
|
||||
);
|
||||
assert.deepEqual(errors, []);
|
||||
console.log(
|
||||
'PASS Contacts history, dependency ownership and exit restore across both globes',
|
||||
);
|
||||
} finally {
|
||||
await b.close();
|
||||
}
|
||||
@@ -140,10 +140,12 @@ export function createApplicationCatalog({
|
||||
),
|
||||
);
|
||||
const installations = once(() =>
|
||||
createApplicationInstallations({
|
||||
surface,
|
||||
source: sources.installations,
|
||||
}),
|
||||
sessionModel('military-installations', () =>
|
||||
createApplicationInstallations({
|
||||
surface,
|
||||
source: sources.installations,
|
||||
}),
|
||||
),
|
||||
);
|
||||
const satellites = once(() =>
|
||||
createApplicationSatellites({
|
||||
@@ -261,13 +263,14 @@ export function createApplicationCatalog({
|
||||
['military-installations', installations],
|
||||
[
|
||||
'military-awareness',
|
||||
() =>
|
||||
() => sessionModel('military-awareness', () =>
|
||||
createApplicationAwareness({
|
||||
flights: flights(),
|
||||
military: military(),
|
||||
vessels: vessels(),
|
||||
installations: installations(),
|
||||
}),
|
||||
),
|
||||
],
|
||||
[
|
||||
'wind',
|
||||
|
||||
Vendored
+2
@@ -21,6 +21,7 @@ export function createApplicationControls({
|
||||
initialShareCode = null,
|
||||
localePicker = true,
|
||||
engineSwitch = false,
|
||||
layerSession,
|
||||
}) {
|
||||
// Initialize the style manager (post-processing, HUD, locations, share links)
|
||||
const styleManager = new Controls(viewer, {
|
||||
@@ -42,6 +43,7 @@ export function createApplicationControls({
|
||||
// A share code to open at start-up instead of the page's address.
|
||||
initialShareCode,
|
||||
engineSwitch,
|
||||
layerSession,
|
||||
localePicker,
|
||||
});
|
||||
defer(() => styleManager.orbitController.stop());
|
||||
|
||||
+3
-2
@@ -45,7 +45,7 @@ export function createApplicationData({
|
||||
onData?.(dataManager);
|
||||
if (!catalog?.layers || !catalog?.metadata)
|
||||
throw new TypeError('An application layer catalog is required');
|
||||
dataManager.bindCatalog(viewer, engine, catalog);
|
||||
const attached = dataManager.bindCatalog(viewer, engine, catalog);
|
||||
for (const layer of catalog.layers) layer.attachDataManager?.(dataManager);
|
||||
for (const layer of catalog.layers)
|
||||
layer.attachMapStackController?.(mapStackController);
|
||||
@@ -75,5 +75,6 @@ export function createApplicationData({
|
||||
// The sonar scene draws on the viewer; the Navara engine has none.
|
||||
if (viewer) defer(createCyberSonarScene(viewer, dataManager));
|
||||
|
||||
return { dataManager, catalog, presentation };
|
||||
const data = { dataManager, catalog, presentation };
|
||||
return attached ? attached.then(() => data) : data;
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
import { rendererFor } from './renderers.js';
|
||||
import { createAwarenessLayer } from '../../layers/awareness/index.js';
|
||||
import * as navigation from '../../navigationPolicy.js';
|
||||
import * as geometry from '../../celestialRing.js';
|
||||
@@ -17,6 +18,7 @@ export function createApplicationAwareness({
|
||||
installations,
|
||||
navigation,
|
||||
geometry,
|
||||
renderer: (engine) => rendererFor('military-awareness', engine),
|
||||
render,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -60,6 +60,7 @@ test('the Navara mode registers migrated renderers once', async () => {
|
||||
'recent-imagery',
|
||||
'ais-live-vessels',
|
||||
'military-installations',
|
||||
'military-awareness',
|
||||
'local-firms',
|
||||
],
|
||||
);
|
||||
|
||||
@@ -106,6 +106,8 @@ export async function registerNavaraLayerRenderers() {
|
||||
bikeshare: async () =>
|
||||
(await import('../layers/bikeshare/render/navara.js'))
|
||||
.createBikeshareNavaraRenderer,
|
||||
'military-awareness': async () =>
|
||||
(await import('../layers/awareness/render/navara.js')).createAwarenessNavaraRenderer,
|
||||
'military-installations': async () => {
|
||||
const { createInstallationsNavaraRenderer } =
|
||||
await import('../layers/installations/render/navara.js');
|
||||
|
||||
@@ -102,6 +102,7 @@ export class LayerStateCoordinator {
|
||||
start({
|
||||
shareLayerState = null,
|
||||
allowLocalState = true,
|
||||
handoffLayers = null,
|
||||
shareCreatedAtMs = null,
|
||||
} = {}) {
|
||||
if (this._destroyed)
|
||||
@@ -139,11 +140,12 @@ export class LayerStateCoordinator {
|
||||
);
|
||||
this.shareLinkManager?.onLayerStateChange?.();
|
||||
this._notifyDurableState();
|
||||
if (!selected) return this.restorePromise;
|
||||
if (!selected && !handoffLayers) return this.restorePromise;
|
||||
this.restorePromise = this._restoreSelectedState(
|
||||
this._source === 'share'
|
||||
? LAYER_RESTORE_ORIGINS.share
|
||||
: LAYER_RESTORE_ORIGINS.local,
|
||||
handoffLayers,
|
||||
);
|
||||
return this.restorePromise;
|
||||
}
|
||||
@@ -365,7 +367,7 @@ export class LayerStateCoordinator {
|
||||
: this.restoreGate);
|
||||
}
|
||||
|
||||
async _restoreSelectedState(origin) {
|
||||
async _restoreSelectedState(origin, handoffLayers = null) {
|
||||
// One controller set per run: a superseded run must not clear the newer
|
||||
// run's controllers when it settles.
|
||||
const controllers = new Map();
|
||||
@@ -379,7 +381,18 @@ export class LayerStateCoordinator {
|
||||
const settled = await Promise.allSettled(
|
||||
LAYER_STATE_REGISTRY.map(async (entry) => {
|
||||
const controller = controllers.get(entry.id);
|
||||
const targetEnabled = enabled.has(entry.id);
|
||||
const live = handoffLayers?.get(entry.id);
|
||||
const targetEnabled = live?.enabled ?? enabled.has(entry.id);
|
||||
// A same-document engine choice retains operational layer intents.
|
||||
// The share still owns durable preferences and later history visits.
|
||||
if (live && this.dataManager.layers.get(entry.id) === live.entry &&
|
||||
!live.entry.lifecycleUncertain && live.entry.enabled === targetEnabled) {
|
||||
return {
|
||||
layerId: entry.id, targetEnabled, origin, phase: 'retained',
|
||||
...currentLayerOutcome(this.dataManager, entry.id),
|
||||
appliedOptions: {}, persistenceWrite: false, succeeded: true,
|
||||
};
|
||||
}
|
||||
const options = layerOptionsForRestore(this._durableState, entry.id);
|
||||
if (origin === LAYER_RESTORE_ORIGINS.share && options) {
|
||||
for (const trackingKey of Object.values(
|
||||
|
||||
@@ -147,12 +147,16 @@ export class LayerLifecycle {
|
||||
this.viewer = viewer;
|
||||
this.engine = engine ?? engineFromViewer(viewer) ?? null;
|
||||
this._catalogSourceAvailability = catalog.getSourceAvailability;
|
||||
const attachments = [];
|
||||
for (const layer of catalog.layers) {
|
||||
const entry = this.layers.get(layer.id);
|
||||
if (!entry) this.register(layer);
|
||||
else {
|
||||
if (entry.module !== layer) throw new Error(`Layer owner changed: ${layer.id}`);
|
||||
if (entry.initialized) layer.attachRenderer(viewer, this._moduleContext());
|
||||
if (entry.initialized) {
|
||||
const ready = layer.attachRenderer(viewer, this._moduleContext());
|
||||
if (ready?.then) attachments.push(ready);
|
||||
}
|
||||
}
|
||||
}
|
||||
// Retained entries may appear anywhere in the directory. Preserve both
|
||||
@@ -163,6 +167,7 @@ export class LayerLifecycle {
|
||||
this.layers.set(id, entry);
|
||||
}
|
||||
this.finalizeRegistrations(catalog.metadata);
|
||||
if (attachments.length) return Promise.all(attachments);
|
||||
}
|
||||
|
||||
/** Register a synthetic layer after sealing in an explicitly dev-enabled manager. */
|
||||
|
||||
@@ -1984,18 +1984,6 @@ test('cockpit blocks only non-aircraft Context camera flights', () => {
|
||||
);
|
||||
});
|
||||
|
||||
test('vessel entry and selection framing both use the 3 km focus radius', () => {
|
||||
assert.match(
|
||||
militaryAwarenessSource,
|
||||
/const\s*VESSEL_FOCUS_RADIUS_M\s*=\s*3000;/,
|
||||
);
|
||||
const focusRadiusUses =
|
||||
militaryAwarenessSource.match(
|
||||
/new\s*Cesium\.BoundingSphere\(\s*vessel\.position,\s*VESSEL_FOCUS_RADIUS_M,?\s*\)/g,
|
||||
) || [];
|
||||
assert.equal(focusRadiusUses.length, 2);
|
||||
});
|
||||
|
||||
test('awareness rescans only for invalidation, source change, or meaningful movement', () => {
|
||||
assert.equal(
|
||||
awarenessRefreshRequired({
|
||||
@@ -3081,6 +3069,7 @@ test('every Context camera flight without a tracked entity takes navigation auth
|
||||
assert.deepEqual(taken, ['context-vessel-focus']);
|
||||
// The announcement precedes the flight, never trails it.
|
||||
assert.equal(runtime.cameraFlights.length, 1);
|
||||
assert.equal(runtime.cameraFlights[0].sphere.radius, 3000);
|
||||
|
||||
militaryAwarenessLayer.focusTarget('military-installations', 'inst-1');
|
||||
assert.deepEqual(taken, [
|
||||
@@ -3119,6 +3108,7 @@ test('Contacts vessel autofocus takes navigation authority before it frames', as
|
||||
// Activation enables dependencies before it auto-focuses.
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
assert.equal(runtime.cameraFlights.length, 1, 'autofocus framed a vessel');
|
||||
assert.equal(runtime.cameraFlights[0].sphere.radius, 3000);
|
||||
assert.deepEqual(taken, [
|
||||
{
|
||||
reason: 'context-vessel-autofocus',
|
||||
|
||||
@@ -18,9 +18,9 @@ function overlay(screenAngle) {
|
||||
const container = document.body.appendChild(document.createElement('div'));
|
||||
const subject = Cesium.Cartesian3.fromDegrees(0, 0, 1000);
|
||||
Object.assign(state, {
|
||||
viewer: {
|
||||
display: {
|
||||
container,
|
||||
scene: { canvas: { clientWidth: 800, clientHeight: 600 } },
|
||||
canvas: { clientWidth: 800, clientHeight: 600 },
|
||||
camera: {
|
||||
heading: 0,
|
||||
rightWC: new Cesium.Cartesian3(0, 1, 0),
|
||||
@@ -115,7 +115,7 @@ test('an unstable frame keeps the last stable bearing, until the overlay is remo
|
||||
rendering.updateDirectionOverlay();
|
||||
assert.equal(state.directionAngles[0], 1);
|
||||
// The heading turns, so the next frame recomputes the contact.
|
||||
state.viewer.camera.heading = 0.1;
|
||||
state.display.camera.heading = 0.1;
|
||||
rendering.updateDirectionOverlay();
|
||||
assert.equal(previous[1], 1, 'the unstable frame is given the last angle');
|
||||
assert.equal(state.directionAngles[0], 1, 'and does not replace it');
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import * as Cesium from 'cesium';
|
||||
import * as geo from '../../geo/index.js';
|
||||
import { VESSEL_FOCUS_RADIUS_M } from './policy.js';
|
||||
|
||||
@@ -42,14 +41,11 @@ export function createFocus({ state: layerState, services, parts, source }) {
|
||||
if (
|
||||
!parts.model.contextTargetFlyToAllowed(layerId) ||
|
||||
!vessel?.position ||
|
||||
!layerState.viewer
|
||||
!layerState.display
|
||||
)
|
||||
return true;
|
||||
announceNavigationAuthority('context-vessel-focus');
|
||||
layerState.viewer.camera.flyToBoundingSphere(
|
||||
new Cesium.BoundingSphere(vessel.position, VESSEL_FOCUS_RADIUS_M),
|
||||
{ duration: 1.4 },
|
||||
);
|
||||
layerState.display.frameContact(vessel.position, VESSEL_FOCUS_RADIUS_M, 1.4);
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -113,7 +109,7 @@ export function createFocus({ state: layerState, services, parts, source }) {
|
||||
|
||||
function closestToCurrentView(candidates) {
|
||||
if (!Array.isArray(candidates) || !candidates.length) return null;
|
||||
const cameraPosition = layerState.viewer?.camera?.positionWC;
|
||||
const cameraPosition = layerState.display?.camera?.positionWC;
|
||||
if (!cameraPosition)
|
||||
return candidates.find((candidate) => candidate?.position) || null;
|
||||
|
||||
@@ -146,7 +142,7 @@ export function createFocus({ state: layerState, services, parts, source }) {
|
||||
if (
|
||||
!layerState.enabled ||
|
||||
layerState.subject ||
|
||||
!layerState.viewer ||
|
||||
!layerState.display ||
|
||||
layerState.autoFocusAttempted
|
||||
)
|
||||
return false;
|
||||
@@ -181,10 +177,7 @@ export function createFocus({ state: layerState, services, parts, source }) {
|
||||
announceNavigationAuthority('context-vessel-autofocus', {
|
||||
cancelPendingSelection: false,
|
||||
});
|
||||
layerState.viewer.camera.flyToBoundingSphere(
|
||||
new Cesium.BoundingSphere(vessel.position, VESSEL_FOCUS_RADIUS_M),
|
||||
{ duration: 1.6 },
|
||||
);
|
||||
layerState.display.frameContact(vessel.position, VESSEL_FOCUS_RADIUS_M, 1.6);
|
||||
return true;
|
||||
}
|
||||
return {
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { createAwarenessCesiumRenderer } from './render/cesium.js';
|
||||
import { cameraPoseSignature } from '../../data/iconOrientation.js';
|
||||
|
||||
export function createLifecycle({
|
||||
@@ -36,7 +37,7 @@ export function createLifecycle({
|
||||
}
|
||||
|
||||
function attachRuntimeListeners() {
|
||||
if (layerState.runtimeListenersAttached || !layerState.viewer) return;
|
||||
if (layerState.runtimeListenersAttached || !layerState.display) return;
|
||||
window.addEventListener(
|
||||
'gev:awareness-subject-selected',
|
||||
layerState.subjectListener,
|
||||
@@ -51,14 +52,14 @@ export function createLifecycle({
|
||||
layerState.subjectClearListener,
|
||||
);
|
||||
layerState.preRenderRemover =
|
||||
layerState.viewer.scene.preRender.addEventListener(() => {
|
||||
layerState.display.onFrame(() => {
|
||||
if (!layerState.enabled) return;
|
||||
const now = Date.now();
|
||||
// "Did the camera move" uses the SAME quantized pose signature the fleet
|
||||
// rotation pass gates on (iconOrientation.cameraPoseSignature) rather than
|
||||
// camera.changed, whose granularity is globally degraded by other layers
|
||||
// mutating camera.percentageChanged.
|
||||
const poseSig = cameraMotionSignature(layerState.viewer.camera);
|
||||
const poseSig = cameraMotionSignature(layerState.display.camera);
|
||||
if (poseSig !== layerState.lastCameraPoseSig) {
|
||||
layerState.lastCameraPoseSig = poseSig;
|
||||
layerState.lastCameraPoseChangeMs = now;
|
||||
@@ -121,9 +122,7 @@ export function createLifecycle({
|
||||
layerState.runtimeListenersAttached = false;
|
||||
}
|
||||
const methods = {
|
||||
init(viewer) {
|
||||
detachRuntimeListeners();
|
||||
layerState.viewer = viewer;
|
||||
init(viewer, { engine = null } = {}) {
|
||||
layerState.subjectListener = (event) => {
|
||||
if (!layerState.enabled) return;
|
||||
parts.subject.selectSubject(event.detail);
|
||||
@@ -177,6 +176,28 @@ export function createLifecycle({
|
||||
parts.subject.clearAwarenessSubject();
|
||||
}
|
||||
};
|
||||
if (viewer || engine) this.attachRenderer(viewer, { engine });
|
||||
},
|
||||
|
||||
attachRenderer(viewer, { engine = null } = {}) {
|
||||
detachRuntimeListeners();
|
||||
layerState.display?.destroy?.();
|
||||
layerState.display = viewer
|
||||
? createAwarenessCesiumRenderer(viewer)
|
||||
: services.renderer?.(engine) ?? null;
|
||||
if (layerState.enabled) {
|
||||
attachRuntimeListeners();
|
||||
if (layerState.subject) parts.rendering.renderVisual(layerState.subject);
|
||||
parts.rendering.scheduleDirectionOverlayUpdate(true);
|
||||
}
|
||||
},
|
||||
|
||||
detachRenderer() {
|
||||
detachRuntimeListeners();
|
||||
parts.rendering.clearVisual();
|
||||
parts.rendering.removeDirectionOverlay();
|
||||
layerState.display?.destroy?.();
|
||||
layerState.display = null;
|
||||
},
|
||||
|
||||
enable() {
|
||||
@@ -235,7 +256,8 @@ export function createLifecycle({
|
||||
layerState.contextListener = null;
|
||||
layerState.clearListener = null;
|
||||
layerState.subjectClearListener = null;
|
||||
layerState.viewer = null;
|
||||
layerState.display?.destroy?.();
|
||||
layerState.display = null;
|
||||
layerState.dataManager = null;
|
||||
},
|
||||
};
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
import { BoundingSphere, Color } from 'cesium';
|
||||
import { AWARENESS_RADIUS_M } from '../../../data/militaryAwarenessEngine.js';
|
||||
import { CONTEXT_RIM_HEIGHT_M } from '../policy.js';
|
||||
|
||||
export function createAwarenessCesiumRenderer(viewer) {
|
||||
return {
|
||||
container: viewer.container,
|
||||
get canvas() {
|
||||
return viewer.scene?.canvas;
|
||||
},
|
||||
get camera() {
|
||||
return viewer.camera;
|
||||
},
|
||||
get trackedEntity() {
|
||||
return viewer.trackedEntity;
|
||||
},
|
||||
onFrame: (run) => viewer.scene.preRender.addEventListener(run),
|
||||
frameContact: (center, radius, duration) =>
|
||||
viewer.camera.flyToBoundingSphere(new BoundingSphere(center, radius), {
|
||||
duration,
|
||||
}),
|
||||
createRim(center) {
|
||||
const entity = viewer.entities.add({
|
||||
position: center,
|
||||
ellipse: {
|
||||
semiMajorAxis: AWARENESS_RADIUS_M,
|
||||
semiMinorAxis: AWARENESS_RADIUS_M,
|
||||
fill: false,
|
||||
outline: true,
|
||||
outlineColor: Color.fromCssColorString('#62b5ff').withAlpha(0.72),
|
||||
height: CONTEXT_RIM_HEIGHT_M,
|
||||
},
|
||||
});
|
||||
return {
|
||||
move: (next) => entity.position.setValue(next),
|
||||
destroy: () => viewer.entities.remove(entity),
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
import * as geo from '../../../geo/index.js';
|
||||
import { createEngineLayerView } from '../../shared/render/engineView.js';
|
||||
import { AWARENESS_RADIUS_M } from '../../../data/militaryAwarenessEngine.js';
|
||||
import { CONTEXT_RIM_HEIGHT_M } from '../policy.js';
|
||||
|
||||
// Sample in the subject's east/north plane, then lift each vertex onto
|
||||
// the ellipsoid. This also works when a ring crosses the date line or a pole.
|
||||
function rimPositions(center) {
|
||||
const frame = geo.transforms.eastNorthUpToFixedFrame(center);
|
||||
const positions = [];
|
||||
for (let i = 0; i < 180; i++) {
|
||||
const angle = (i * Math.PI) / 90;
|
||||
const local = geo.cartesian3.create(
|
||||
Math.cos(angle) * AWARENESS_RADIUS_M,
|
||||
Math.sin(angle) * AWARENESS_RADIUS_M,
|
||||
0,
|
||||
);
|
||||
const world = geo.matrix4.multiplyByPoint(
|
||||
frame,
|
||||
local,
|
||||
geo.cartesian3.create(),
|
||||
);
|
||||
const point = geo.cartographic.fromCartesian(world);
|
||||
positions.push(
|
||||
geo.cartesian3.fromRadians(
|
||||
point.longitude,
|
||||
point.latitude,
|
||||
CONTEXT_RIM_HEIGHT_M,
|
||||
),
|
||||
);
|
||||
}
|
||||
positions.push(positions[0]);
|
||||
return positions;
|
||||
}
|
||||
|
||||
export function createAwarenessNavaraRenderer(engine) {
|
||||
const binding = createEngineLayerView(engine, {
|
||||
layerId: 'military-awareness',
|
||||
});
|
||||
return {
|
||||
container: engine.host.container,
|
||||
canvas: engine.host.canvas,
|
||||
camera: binding.view.camera,
|
||||
get trackedEntity() {
|
||||
return engine.camera.following()?.native;
|
||||
},
|
||||
onFrame: (run) => binding.view.on('preRender', run),
|
||||
frameContact: (center, radius, duration) =>
|
||||
engine.camera.flyToSphere({ center, radius }, { duration }),
|
||||
createRim(center) {
|
||||
const line = engine.lines.createLine({
|
||||
id: 'gev-awareness-rim',
|
||||
color: [98 / 255, 181 / 255, 1, 0.72],
|
||||
widthPx: 1,
|
||||
arc: 'geodesic',
|
||||
});
|
||||
const move = (position) => line.setPositions(rimPositions(position));
|
||||
move(center);
|
||||
return { move, destroy: () => line.destroy() };
|
||||
},
|
||||
destroy: binding.destroy,
|
||||
};
|
||||
}
|
||||
@@ -1,4 +1,3 @@
|
||||
import * as Cesium from 'cesium';
|
||||
import * as geo from '../../geo/index.js';
|
||||
import { t } from '../../i18n/index.js';
|
||||
import {
|
||||
@@ -8,10 +7,9 @@ import {
|
||||
import {
|
||||
formatAwarenessLabel,
|
||||
formatAwarenessDistance,
|
||||
AWARENESS_RADIUS_M,
|
||||
} from '../../data/militaryAwarenessEngine.js';
|
||||
import { bearingBetweenCoordinates } from '../../cockpitMath.js';
|
||||
import { AWARENESS_PAGE_SIZE, CONTEXT_RIM_HEIGHT_M } from './policy.js';
|
||||
import { AWARENESS_PAGE_SIZE } from './policy.js';
|
||||
import { formatAwarenessDirection } from './presentation.js';
|
||||
|
||||
export function createRendering({
|
||||
@@ -36,10 +34,10 @@ export function createRendering({
|
||||
* viewer's container. Each part keeps its last placement while hidden.
|
||||
*/
|
||||
function ensureDirectionOverlay() {
|
||||
if (layerState.directionView || !layerState.viewer)
|
||||
if (layerState.directionView || !layerState.display)
|
||||
return layerState.directionView;
|
||||
presentDirection({
|
||||
container: layerState.viewer.container,
|
||||
container: layerState.display.container,
|
||||
hidden: true,
|
||||
ring: null,
|
||||
heading: { text: '', left: null, top: null },
|
||||
@@ -129,7 +127,7 @@ export function createRendering({
|
||||
return;
|
||||
}
|
||||
|
||||
const canvas = layerState.viewer.scene.canvas;
|
||||
const canvas = layerState.display.canvas;
|
||||
const width = canvas.clientWidth || canvas.width;
|
||||
const height = canvas.clientHeight || canvas.height;
|
||||
const sonar = isCyberSonarActive()
|
||||
@@ -147,7 +145,7 @@ export function createRendering({
|
||||
120,
|
||||
Math.min(geometry.radius - 48, geometry.radius * 0.66),
|
||||
);
|
||||
const cameraHeading = layerState.viewer.camera.heading || 0;
|
||||
const cameraHeading = layerState.display.camera.heading || 0;
|
||||
const headingDeg =
|
||||
(Math.round(geo.math.toDegrees(cameraHeading)) + 360) % 360;
|
||||
const ring = {
|
||||
@@ -205,8 +203,8 @@ export function createRendering({
|
||||
}
|
||||
geo.cartesian3.normalize(direction, direction);
|
||||
const projection = celestialScreenAngle(
|
||||
geo.cartesian3.dot(direction, layerState.viewer.camera.rightWC),
|
||||
geo.cartesian3.dot(direction, layerState.viewer.camera.upWC),
|
||||
geo.cartesian3.dot(direction, layerState.display.camera.rightWC),
|
||||
geo.cartesian3.dot(direction, layerState.display.camera.upWC),
|
||||
layerState.directionAngles[index],
|
||||
);
|
||||
if (projection.stable)
|
||||
@@ -234,12 +232,12 @@ export function createRendering({
|
||||
transform: `translate(-50%, -50%) translate(${-Math.cos(projection.angle) * 56}px, ${-Math.sin(projection.angle) * 56}px)`,
|
||||
};
|
||||
const label = formatAwarenessLabel(item);
|
||||
const subjectCartographic = Cesium.Cartographic.fromCartesian(
|
||||
const subjectCartographic = geo.cartographic.fromCartesian(
|
||||
layerState.subject.position,
|
||||
undefined,
|
||||
layerState.SUBJECT_CARTOGRAPHIC_SCRATCH,
|
||||
);
|
||||
const targetCartographic = Cesium.Cartographic.fromCartesian(
|
||||
const targetCartographic = geo.cartographic.fromCartesian(
|
||||
item.position,
|
||||
undefined,
|
||||
layerState.TARGET_CARTOGRAPHIC_SCRATCH[index],
|
||||
@@ -273,9 +271,8 @@ export function createRendering({
|
||||
}
|
||||
|
||||
function clearVisual() {
|
||||
if (layerState.visual?.entities && layerState.viewer) {
|
||||
for (const entity of layerState.visual.entities)
|
||||
layerState.viewer.entities.remove(entity);
|
||||
if (layerState.visual) {
|
||||
layerState.visual.rim.destroy();
|
||||
// Idle mode renders only on request; a removed ring must not linger.
|
||||
governorRequestRender('awareness-visual');
|
||||
}
|
||||
@@ -283,8 +280,8 @@ export function createRendering({
|
||||
}
|
||||
|
||||
function renderVisual(subject) {
|
||||
if (!layerState.viewer || !subject?.position) return;
|
||||
const cartographic = Cesium.Cartographic.fromCartesian(subject.position);
|
||||
if (!layerState.display || !subject?.position) return;
|
||||
const cartographic = geo.cartographic.fromCartesian(subject.position);
|
||||
if (!cartographic) return;
|
||||
const groundCenter = geo.cartesian3.fromRadians(
|
||||
cartographic.longitude,
|
||||
@@ -299,8 +296,7 @@ export function createRendering({
|
||||
if (
|
||||
geo.cartesian3.distance(layerState.visual.center, groundCenter) >= 250
|
||||
) {
|
||||
for (const entity of layerState.visual.entities)
|
||||
entity.position.setValue(groundCenter);
|
||||
layerState.visual.rim.move(groundCenter);
|
||||
geo.cartesian3.clone(groundCenter, layerState.visual.center);
|
||||
// Content actually changed — buy exactly one frame instead of holding.
|
||||
governorRequestRender('awareness-visual');
|
||||
@@ -309,26 +305,9 @@ export function createRendering({
|
||||
}
|
||||
clearVisual();
|
||||
|
||||
const entities = [];
|
||||
|
||||
entities.push(
|
||||
layerState.viewer.entities.add({
|
||||
position: groundCenter,
|
||||
ellipse: {
|
||||
semiMajorAxis: AWARENESS_RADIUS_M,
|
||||
semiMinorAxis: AWARENESS_RADIUS_M,
|
||||
fill: false,
|
||||
outline: true,
|
||||
outlineColor:
|
||||
Cesium.Color.fromCssColorString('#62b5ff').withAlpha(0.72),
|
||||
height: CONTEXT_RIM_HEIGHT_M,
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
layerState.visual = {
|
||||
key,
|
||||
entities,
|
||||
rim: layerState.display.createRim(groundCenter),
|
||||
center: geo.cartesian3.clone(groundCenter),
|
||||
};
|
||||
governorRequestRender('awareness-visual');
|
||||
|
||||
@@ -14,7 +14,7 @@ export function createState({ services }) {
|
||||
);
|
||||
|
||||
Object.assign(state, {
|
||||
viewer: null,
|
||||
display: null,
|
||||
dataManager: null,
|
||||
enabled: false,
|
||||
// Set once a refresh observes the subject gone from a still-reporting source.
|
||||
|
||||
@@ -193,7 +193,7 @@ export function createSubject({ state: layerState, services, parts, source }) {
|
||||
|
||||
function currentTrackedFlightSubject() {
|
||||
const trackedKey = normalizeContextId(
|
||||
layerState.viewer?.trackedEntity?.gevTrackedId,
|
||||
layerState.display?.trackedEntity?.gevTrackedId,
|
||||
);
|
||||
if (!trackedKey) return null;
|
||||
const subjects = [
|
||||
@@ -262,14 +262,14 @@ export function createSubject({ state: layerState, services, parts, source }) {
|
||||
if (!subject?.position) return null;
|
||||
if (subject.layerId === 'flights' || subject.layerId === 'military') {
|
||||
const trackedPosition =
|
||||
layerState.viewer?.trackedEntity?.gevDisplayPosition?.();
|
||||
layerState.display?.trackedEntity?.gevDisplayPosition?.();
|
||||
if (trackedPosition) {
|
||||
return {
|
||||
position: Cesium.Cartesian3.clone(trackedPosition),
|
||||
// Only the follow camera's own contact proves presence this way; a
|
||||
// different tracked entity says nothing about this subject.
|
||||
presence:
|
||||
String(layerState.viewer?.trackedEntity?.gevTrackedId || '') ===
|
||||
String(layerState.display?.trackedEntity?.gevTrackedId || '') ===
|
||||
subjectKey(subject)
|
||||
? SUBJECT_PRESENCE.LIVE
|
||||
: SUBJECT_PRESENCE.UNCHECKED,
|
||||
|
||||
Vendored
+3
-3
@@ -49,7 +49,7 @@ export function createControls({ state: layerState, services, parts, source }) {
|
||||
statsRefreshInterval: 1000,
|
||||
|
||||
visitNamedMarkers(visitor) {
|
||||
parts.namedMarkers.visit(visitor);
|
||||
parts.namedMarkers?.visit(visitor);
|
||||
},
|
||||
|
||||
/** Request a one-shot Google Maps Places search around the current map view. */
|
||||
@@ -153,7 +153,7 @@ export function createControls({ state: layerState, services, parts, source }) {
|
||||
position: geo.cartesian3.fromDegrees(
|
||||
record.longitude,
|
||||
record.latitude,
|
||||
parts.rendering.installationSurfaceHeightM(record),
|
||||
parts.model.installationSurfaceHeightM(record),
|
||||
),
|
||||
distanceM,
|
||||
});
|
||||
@@ -184,7 +184,7 @@ export function createControls({ state: layerState, services, parts, source }) {
|
||||
center: geo.cartesian3.fromDegrees(
|
||||
record.longitude,
|
||||
record.latitude,
|
||||
parts.rendering.installationSurfaceHeightM(record),
|
||||
parts.model.installationSurfaceHeightM(record),
|
||||
),
|
||||
radius: 18000,
|
||||
};
|
||||
|
||||
@@ -21,7 +21,7 @@ test('focus frames a selected installation through the globe engine camera', ()
|
||||
services: {},
|
||||
parts: {
|
||||
selection: { selectRecord: (id) => id === record.id },
|
||||
rendering: { installationSurfaceHeightM: () => 126 },
|
||||
model: { installationSurfaceHeightM: () => 126 },
|
||||
},
|
||||
source: {},
|
||||
}).methods;
|
||||
|
||||
@@ -28,6 +28,9 @@ export function createInstallationsLayer({
|
||||
let renderer = null;
|
||||
const lifecycle = {
|
||||
init(viewer, options = {}) {
|
||||
if (viewer || options.engine) return lifecycle.attachRenderer(viewer, options);
|
||||
},
|
||||
attachRenderer(viewer, options = {}) {
|
||||
if (renderer)
|
||||
throw new Error('Installations layer is already initialized');
|
||||
const engine = options.engine ?? null;
|
||||
@@ -35,27 +38,47 @@ export function createInstallationsLayer({
|
||||
if (!renderer?.lifecycle?.methods)
|
||||
throw new Error('Installation renderer is unavailable');
|
||||
Object.assign(parts, renderer);
|
||||
return renderer.lifecycle.methods.init(viewer, options);
|
||||
const ready = renderer.lifecycle.methods.init(viewer, options);
|
||||
if (state.enabled) {
|
||||
renderer.lifecycle.methods.enable();
|
||||
renderer.rendering.renderRecords({ claimSelection: Boolean(state.selectedId) });
|
||||
}
|
||||
return ready;
|
||||
},
|
||||
enable(...args) {
|
||||
state.enabled = true;
|
||||
return renderer?.lifecycle?.methods.enable(...args);
|
||||
},
|
||||
disable(...args) {
|
||||
return renderer?.lifecycle?.methods.disable(...args);
|
||||
if (renderer) return renderer.lifecycle.methods.disable(...args);
|
||||
state.enabled = false;
|
||||
state.abort?.abort();
|
||||
state.abort = null;
|
||||
state.loading = false;
|
||||
state.selectedId = null;
|
||||
state.contextAnchor = null;
|
||||
state.contextPosition = null;
|
||||
state.cameraLoadKey = null;
|
||||
state.cameraLoadOwner = null;
|
||||
state.coverage = { kind: 'viewport' };
|
||||
clearTimeout(state.timer);
|
||||
parts.viewport.clearUnavailableRetry();
|
||||
services.context.clearSelectedEntityContextForLayer('military-installations');
|
||||
parts.ingestion.setInstallationStatus('idle');
|
||||
},
|
||||
detachRenderer(viewer) {
|
||||
renderer?.lifecycle.methods.detach(viewer);
|
||||
renderer = null;
|
||||
for (const key of ['rendering', 'namedMarkers', 'selection', 'lifecycle'])
|
||||
delete parts[key];
|
||||
},
|
||||
destroy(...args) {
|
||||
try {
|
||||
return renderer?.lifecycle?.methods.destroy(...args);
|
||||
} finally {
|
||||
renderer = null;
|
||||
for (const key of [
|
||||
'rendering',
|
||||
'namedMarkers',
|
||||
'selection',
|
||||
'lifecycle',
|
||||
])
|
||||
delete parts[key];
|
||||
if (renderer) renderer.lifecycle.methods.destroy(...args);
|
||||
else {
|
||||
lifecycle.disable();
|
||||
source.destroy?.();
|
||||
}
|
||||
renderer = null;
|
||||
},
|
||||
};
|
||||
return Object.assign(
|
||||
|
||||
@@ -73,8 +73,7 @@ export function createIngestion({
|
||||
}
|
||||
|
||||
async function loadInstallations() {
|
||||
if (!layerState.enabled || (!layerState.viewer && !layerState.engine))
|
||||
return;
|
||||
if (!layerState.enabled) return;
|
||||
const { box, coverage } = parts.viewport.loadArea(layerState.viewer);
|
||||
// Guidance, not a fault: the layer chose not to query because the view is
|
||||
// unbounded (a global view, or Cockpit looking at the horizon). Keep it out
|
||||
@@ -261,8 +260,8 @@ export function createIngestion({
|
||||
? msg('Too many mapped sites in view to list them all')
|
||||
: placesError,
|
||||
);
|
||||
parts.rendering.renderRecords();
|
||||
parts.rendering.warmInstallationFloors(
|
||||
parts.rendering?.renderRecords();
|
||||
parts.rendering?.warmInstallationFloors(
|
||||
layerState.records.filter((r) => !r.pointOnly),
|
||||
);
|
||||
};
|
||||
|
||||
@@ -44,11 +44,11 @@ export function createModel({ state: layerState, services, parts, source }) {
|
||||
}
|
||||
|
||||
function installationSurfaceHeightM(record) {
|
||||
const floor = services.ground?.cachedGroundFloor?.(
|
||||
const floor = services?.ground?.cachedGroundFloor?.(
|
||||
record?.latitude,
|
||||
record?.longitude,
|
||||
);
|
||||
return services.ground?.floorAltitudeM?.(null, floor) ?? 0;
|
||||
return services?.ground?.floorAltitudeM?.(null, floor) ?? 0;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -55,10 +55,11 @@ test('Contacts camera motion reprojects and declutters existing labels without s
|
||||
h.setX(600); h.lifecycle.enable();
|
||||
assert.equal(h.labels.length, 2);
|
||||
assert.equal(h.publications(), 5);
|
||||
const oldScene = h.state.viewer.scene;
|
||||
h.lifecycle.destroy(h.state.viewer);
|
||||
assert.equal(h.camera.changed.numberOfListeners, 0);
|
||||
assert.equal(h.camera.moveEnd.numberOfListeners, 0);
|
||||
assert.equal(h.state.viewer.scene.preRender.numberOfListeners, 0);
|
||||
assert.equal(oldScene.preRender.numberOfListeners, 0);
|
||||
});
|
||||
|
||||
test('wide named markers retain primitives until close polygons publish with the same identity', async (t) => {
|
||||
|
||||
@@ -92,11 +92,19 @@ export function createLifecycle({
|
||||
destroy(viewer) {
|
||||
source.destroy?.();
|
||||
this.disable();
|
||||
layerState.contextAnchor = null;
|
||||
layerState.contextPosition = null;
|
||||
layerState.coverage = { kind: 'viewport' };
|
||||
this.detach(viewer);
|
||||
},
|
||||
|
||||
detach(viewer) {
|
||||
services.credits?.hideOsmCredit?.(viewer, LAYER_ID);
|
||||
services.credits?.hideGooglePlacesCredit?.(viewer, LAYER_ID);
|
||||
unregisterPickOwner(LAYER_ID);
|
||||
releaseCameraSensitivity(viewer?.camera, LAYER_ID);
|
||||
layerState.surfaceChangedRemove?.();
|
||||
layerState.surfaceChangedRemove = null;
|
||||
layerState.moveEndRemove?.();
|
||||
layerState.changedRemove?.();
|
||||
layerState.moveEndRemove = layerState.changedRemove = null;
|
||||
parts.selection.destroy?.();
|
||||
layerState.clickHandler?.destroy();
|
||||
layerState.clickHandler = null;
|
||||
@@ -105,6 +113,7 @@ export function createLifecycle({
|
||||
if (layerState.dataSource && viewer)
|
||||
viewer.dataSources.remove(layerState.dataSource, true);
|
||||
layerState.dataSource = null;
|
||||
layerState.viewer = null;
|
||||
layerState.engine = null;
|
||||
},
|
||||
};
|
||||
|
||||
@@ -353,8 +353,9 @@ export function createRendering({
|
||||
)
|
||||
.map((record) => ({ lat: record.latitude, lon: record.longitude }));
|
||||
if (!cold.length) return;
|
||||
const owner = layerState.dataSource;
|
||||
warmFireAnchorFloors(cold).then(() => {
|
||||
if (!layerState.enabled || !layerState.dataSource) return;
|
||||
if (!layerState.enabled || !owner || layerState.dataSource !== owner) return;
|
||||
if (
|
||||
!cold.some((point) => cachedGroundFloor(point.lat, point.lon) != null)
|
||||
)
|
||||
|
||||
@@ -608,7 +608,7 @@ export function createInstallationsNavaraRenderer(
|
||||
services.picking.registerPickOwner(LAYER_ID, (id) =>
|
||||
layerState.recordById.has(id),
|
||||
);
|
||||
renderRecords();
|
||||
renderRecords({ claimSelection: Boolean(layerState.selectedId) });
|
||||
},
|
||||
disable() {
|
||||
visible = false;
|
||||
@@ -640,6 +640,12 @@ export function createInstallationsNavaraRenderer(
|
||||
destroy() {
|
||||
source.destroy?.();
|
||||
this.disable();
|
||||
this.detach();
|
||||
},
|
||||
detach() {
|
||||
services.credits?.hideOsmCredit?.(engine, LAYER_ID);
|
||||
syncPlacesCredit([]);
|
||||
services.picking.unregisterPickOwner(LAYER_ID);
|
||||
layerState.moveEndRemove?.();
|
||||
layerState.changedRemove?.();
|
||||
layerState.moveEndRemove = layerState.changedRemove = null;
|
||||
|
||||
@@ -19,7 +19,9 @@ export function createViewport({ state: layerState, services, parts, source }) {
|
||||
return { box: bounds, coverage: { kind: 'subject', radiusM } };
|
||||
}
|
||||
}
|
||||
return { box: viewportBox(viewer), coverage: { kind: 'viewport' } };
|
||||
if (viewer || layerState.engine)
|
||||
layerState.lastViewBox = viewportBox(viewer);
|
||||
return { box: layerState.lastViewBox ?? null, coverage: { kind: 'viewport' } };
|
||||
}
|
||||
|
||||
function viewportBox(viewer) {
|
||||
|
||||
@@ -172,11 +172,11 @@ export function createLifecycle({
|
||||
components.rendering.installRuntime(scene);
|
||||
restoreSpriteOrder(viewer);
|
||||
components.rendering.setVisible(state.feed.enabled);
|
||||
if (!state.feed.enabled) return;
|
||||
bindVisibleScene(viewer, scene);
|
||||
const horizon = components.rendering.makeOccluder();
|
||||
for (const record of state.records.all)
|
||||
components.rendering.addRecordPrimitives(record, horizon);
|
||||
if (!state.feed.enabled) return;
|
||||
bindVisibleScene(viewer, scene);
|
||||
components.tracking.attachSelectedVesselTrail();
|
||||
components.rendering.updateVisibility(true);
|
||||
if (state.selectedRecord)
|
||||
|
||||
@@ -124,12 +124,19 @@ async function reconcile(remember = false) {
|
||||
const previous = kind;
|
||||
let cover;
|
||||
let cockpit;
|
||||
const handoffHash = remember ? location.hash : null;
|
||||
try {
|
||||
while (pageEngineKind() !== kind) {
|
||||
cover ??= keepGlobeFrame(application.getComponents().scene.engine);
|
||||
cockpit = application
|
||||
.getComponents()
|
||||
.controls.styleManager.getCockpitState();
|
||||
const manager = application.getComponents().data.dataManager;
|
||||
layerSession.handoffLayers = location.hash === handoffHash
|
||||
? new Map([...manager.layers].map(([id, entry]) => [
|
||||
id, { entry: entry.module.attachRenderer ? entry : null, enabled: manager.isEffectivelyEnabled(id) },
|
||||
]))
|
||||
: null;
|
||||
renderers.retain(kind);
|
||||
await application.destroy();
|
||||
await start(pageEngineKind(), true, cockpit);
|
||||
@@ -156,6 +163,7 @@ async function reconcile(remember = false) {
|
||||
report(recoveryError);
|
||||
}
|
||||
} finally {
|
||||
layerSession.handoffLayers = null;
|
||||
cover?.remove();
|
||||
busy = false;
|
||||
present();
|
||||
|
||||
@@ -130,6 +130,7 @@ export function createStandaloneApplication({
|
||||
createStandaloneControls({
|
||||
...context,
|
||||
engineSwitch,
|
||||
layerSession,
|
||||
loaderStatus,
|
||||
placeSearch,
|
||||
catalog,
|
||||
|
||||
@@ -105,11 +105,13 @@ export class StyleManager extends ShellFacade {
|
||||
requestServices,
|
||||
initialShareCode = null,
|
||||
engineSwitch = false,
|
||||
layerSession = null,
|
||||
localePicker = true,
|
||||
} = {},
|
||||
) {
|
||||
super();
|
||||
this._localePickerEnabled = localePicker;
|
||||
this._layerSession = layerSession;
|
||||
const {
|
||||
IntelHUD,
|
||||
ShareLinkManager,
|
||||
@@ -197,6 +199,7 @@ export class StyleManager extends ShellFacade {
|
||||
this._shareRestoration = new ShareRestoration({
|
||||
viewer,
|
||||
canvas: engine?.host.canvas ?? viewer?.canvas,
|
||||
handoffLayers: layerSession?.handoffLayers,
|
||||
navigation: this._navigation,
|
||||
syncShareState: () => this._syncShareState(),
|
||||
syncModels3d: (state) => this._syncModels3dFromLayerState(state),
|
||||
@@ -972,6 +975,8 @@ export class StyleManager extends ShellFacade {
|
||||
setClearBusy: (busy) => this._publishClearBusy(busy),
|
||||
},
|
||||
});
|
||||
if (this._layerSession?.handoffLayers)
|
||||
this._contextControls.resumeScene(this._layerSession.contacts);
|
||||
// The Contacts layer publishes its panel; the view calls its navigation.
|
||||
contextStore.connectAwareness(militaryAwarenessLayer);
|
||||
// A panel left by an earlier shell must not outlive its layer.
|
||||
@@ -1855,7 +1860,13 @@ export class StyleManager extends ShellFacade {
|
||||
// Close camera-entry seams synchronously. Context restoration may await
|
||||
// layer work, so leaving these listeners attached until afterward lets a
|
||||
// focus event release tracking or start a flight during teardown.
|
||||
await this._contextControls.restoreForDisposal();
|
||||
if (this._layerSession && !this._layerSession.lifetime.signal.aborted &&
|
||||
this._contextControls.canRetainContacts()) {
|
||||
this._layerSession.contacts = this._contextControls.captureScene();
|
||||
} else {
|
||||
if (this._layerSession) this._layerSession.contacts = null;
|
||||
await this._contextControls.restoreForDisposal();
|
||||
}
|
||||
// IR boost teardown BEFORE detaching the data manager: restore fog and
|
||||
// un-boost both aircraft layers so a surviving viewer or replacement
|
||||
// manager doesn't inherit sensor state (review P2, 2026-08-16).
|
||||
|
||||
@@ -85,6 +85,19 @@ export class ContextControls {
|
||||
this[field] = null;
|
||||
}
|
||||
}
|
||||
canRetainContacts() {
|
||||
return this._contextMode === 'flights' && !this._contextModeChanging &&
|
||||
!this._contextRestoreState && !this._clearSelectedLayersPromise &&
|
||||
!this._contextLayerReactionPromises.size;
|
||||
}
|
||||
captureScene() {
|
||||
return { mode: this._contextMode, snapshot: this._contextSessionSnapshot };
|
||||
}
|
||||
resumeScene(saved) {
|
||||
if (!saved) return;
|
||||
this._contextMode = saved.mode;
|
||||
this._contextSessionSnapshot = saved.snapshot;
|
||||
}
|
||||
stop() {
|
||||
if (this.destroyed) return;
|
||||
this.destroyed = true;
|
||||
|
||||
@@ -110,6 +110,7 @@ export class ShareRestoration {
|
||||
constructor({
|
||||
viewer,
|
||||
canvas = viewer?.canvas,
|
||||
handoffLayers = null,
|
||||
navigation,
|
||||
syncShareState,
|
||||
syncModels3d,
|
||||
@@ -121,6 +122,7 @@ export class ShareRestoration {
|
||||
Object.assign(this, {
|
||||
viewer,
|
||||
canvas,
|
||||
handoffLayers,
|
||||
navigation,
|
||||
syncShareState,
|
||||
syncModels3d,
|
||||
@@ -368,11 +370,13 @@ export class ShareRestoration {
|
||||
);
|
||||
this._layerStateRestorePromise = this._layerStateCoordinator.start({
|
||||
shareLayerState: this._initialShareState?.layerState || null,
|
||||
handoffLayers: this.handoffLayers,
|
||||
shareCreatedAtMs: this._initialShareState?.sharedAtMs ?? null,
|
||||
// Any valid camera/style share isolates recipient-local preferences,
|
||||
// including legacy and malformed-v2 layer payloads.
|
||||
allowLocalState: !this._initialShareState,
|
||||
});
|
||||
this.handoffLayers = null;
|
||||
if (this._initialShareSelectionSuperseded) {
|
||||
this._layerStateCoordinator.cancelPendingShareTracking(
|
||||
'superseded-before-layer-coordinator-start',
|
||||
@@ -425,6 +429,7 @@ export class ShareRestoration {
|
||||
try {
|
||||
while (live()) {
|
||||
const vessels = this._dataManager?.layers?.get?.(layerId)?.module;
|
||||
if (vessels?.getSelectedInfo?.()?.mmsi === id) return true;
|
||||
if (vessels?.hasContact?.(id) && vessels.selectById(id)) return true;
|
||||
if (Date.now() >= deadline) break;
|
||||
await new Promise((resolve) =>
|
||||
|
||||
Reference in new issue
Block a user