Keep Contacts, active layer intents and mapped sites across globe switches

This commit is contained in:
German Khokhlov committed 2026-11-03 03:05:47 +04:00
1 parent 81bcae907f
commit 87bbe14748
35 files changed
+465 -118

No files matched your search

+4
View File
@@ -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",
+148
View File
@@ -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();
}
+8 -5
View File
@@ -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',
+2
View File
@@ -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
View File
@@ -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;
}
+2
View File
@@ -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,
},
});
+1
View File
@@ -60,6 +60,7 @@ test('the Navara mode registers migrated renderers once', async () => {
'recent-imagery',
'ais-live-vessels',
'military-installations',
'military-awareness',
'local-firms',
],
);
+2
View File
@@ -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');
+16 -3
View File
@@ -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(
+6 -1
View File
@@ -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. */
+2 -12
View File
@@ -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');
+5 -12
View File
@@ -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 {
+29 -7
View File
@@ -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;
},
};
+40
View File
@@ -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),
};
},
};
}
+63
View File
@@ -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,
};
}
+15 -36
View File
@@ -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');
+1 -1
View File
@@ -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.
+3 -3
View File
@@ -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,
+3 -3
View File
@@ -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,
};
+1 -1
View File
@@ -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;
+36 -13
View File
@@ -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(
+3 -4
View File
@@ -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),
);
};
+2 -2
View File
@@ -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)
)
+7 -1
View File
@@ -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;
+3 -1
View File
@@ -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) {
+2 -2
View File
@@ -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)
+8
View File
@@ -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();
+1
View File
@@ -130,6 +130,7 @@ export function createStandaloneApplication({
createStandaloneControls({
...context,
engineSwitch,
layerSession,
loaderStatus,
placeSearch,
catalog,
+12 -1
View File
@@ -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).
+13
View File
@@ -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;
+5
View File
@@ -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) =>