mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-06 11:24:00 +00:00
Merge pull request #426 from bilawalsidhu/refactor/installation-context-components
refactor: separate installation and proximity context components
This commit is contained in:
39 files changed
+4151
-2632
No files matched your search
@@ -1,5 +1,12 @@
|
|||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
|
## Installation and context components
|
||||||
|
|
||||||
|
- Separate mapped-site requests, records, placement, selection and viewport lifecycle.
|
||||||
|
- Separate proximity queries, subject tracking, navigation/history, panel and direction rendering.
|
||||||
|
- Retain source and ground-floor ownership in standalone composition; reject malformed
|
||||||
|
installation snapshots and ignore failures from cancelled requests.
|
||||||
|
|
||||||
## Satellite and mission layer components
|
## Satellite and mission layer components
|
||||||
|
|
||||||
- Separate catalog loading, orbit calculations, display, tracking and interaction
|
- Separate catalog loading, orbit calculations, display, tracking and interaction
|
||||||
|
|||||||
@@ -1,5 +1,14 @@
|
|||||||
# God's Eye View Current State
|
# God's Eye View Current State
|
||||||
|
|
||||||
|
Installations and proximity context now expose `./layers/installations` and
|
||||||
|
`./layers/awareness` factories. Each owns its records, selection, presentation,
|
||||||
|
navigation and lifecycle. The standalone entries supply existing scene operations
|
||||||
|
and the aircraft/vessel/installation instances used for proximity queries.
|
||||||
|
Installation requests use a bounded source adapter; explicit nearby-place search
|
||||||
|
remains separate from ordinary mapped-site loading. Invalid snapshots retain the
|
||||||
|
previous display, and cancelled requests cannot publish a later failure state.
|
||||||
|
Viewport limits, saturation retry, placement, query caps and controls are retained.
|
||||||
|
|
||||||
Satellite and mission layers expose separate factories through `./layers/satellites`
|
Satellite and mission layers expose separate factories through `./layers/satellites`
|
||||||
and `./layers/launches`. Each owns its catalog, render state, tracking, interaction
|
and `./layers/launches`. Each owns its catalog, render state, tracking, interaction
|
||||||
and teardown. Source adapters retain the existing CelesTrak and Launch Library
|
and teardown. Source adapters retain the existing CelesTrak and Launch Library
|
||||||
|
|||||||
+3
-1
@@ -150,6 +150,8 @@
|
|||||||
"./layers/earthquakes": "./src/layers/earthquakes/index.js",
|
"./layers/earthquakes": "./src/layers/earthquakes/index.js",
|
||||||
"./layers/firms": "./src/layers/firms/index.js",
|
"./layers/firms": "./src/layers/firms/index.js",
|
||||||
"./layers/satellites": "./src/layers/satellites/index.js",
|
"./layers/satellites": "./src/layers/satellites/index.js",
|
||||||
"./layers/launches": "./src/layers/launches/index.js"
|
"./layers/launches": "./src/layers/launches/index.js",
|
||||||
|
"./layers/installations": "./src/layers/installations/index.js",
|
||||||
|
"./layers/awareness": "./src/layers/awareness/index.js"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -360,5 +360,35 @@
|
|||||||
"src/data/satellites.js",
|
"src/data/satellites.js",
|
||||||
"src/data/rocketLaunches.js",
|
"src/data/rocketLaunches.js",
|
||||||
"src/layers/satellites/source.test.mjs",
|
"src/layers/satellites/source.test.mjs",
|
||||||
"src/layers/launches/source.test.mjs"
|
"src/layers/launches/source.test.mjs",
|
||||||
|
"src/layers/installations/lifecycle.js",
|
||||||
|
"src/layers/installations/viewport.js",
|
||||||
|
"src/layers/installations/controls.js",
|
||||||
|
"src/layers/installations/source.js",
|
||||||
|
"src/layers/installations/state.js",
|
||||||
|
"src/layers/installations/policy.js",
|
||||||
|
"src/layers/installations/rendering.js",
|
||||||
|
"src/layers/installations/ingestion.js",
|
||||||
|
"src/layers/installations/index.js",
|
||||||
|
"src/layers/installations/model.js",
|
||||||
|
"src/layers/installations/selection.js",
|
||||||
|
"src/layers/awareness/focus.js",
|
||||||
|
"src/layers/awareness/lifecycle.js",
|
||||||
|
"src/layers/awareness/dependencies.js",
|
||||||
|
"src/layers/awareness/controls.js",
|
||||||
|
"src/layers/awareness/panel.js",
|
||||||
|
"src/layers/awareness/state.js",
|
||||||
|
"src/layers/awareness/policy.js",
|
||||||
|
"src/layers/awareness/rendering.js",
|
||||||
|
"src/layers/awareness/testing.js",
|
||||||
|
"src/layers/awareness/ingestion.js",
|
||||||
|
"src/layers/awareness/queries.js",
|
||||||
|
"src/layers/awareness/navigation.js",
|
||||||
|
"src/layers/awareness/index.js",
|
||||||
|
"src/layers/awareness/model.js",
|
||||||
|
"src/layers/awareness/history.js",
|
||||||
|
"src/layers/awareness/subject.js",
|
||||||
|
"src/data/militaryInstallations.js",
|
||||||
|
"src/data/militaryAwareness.js",
|
||||||
|
"src/layers/installations/source.test.mjs"
|
||||||
]
|
]
|
||||||
@@ -763,5 +763,50 @@
|
|||||||
"src/layers/launches/testing.js"
|
"src/layers/launches/testing.js"
|
||||||
],
|
],
|
||||||
"external": ["cesium"]
|
"external": ["cesium"]
|
||||||
|
},
|
||||||
|
"installations-layer": {
|
||||||
|
"exports": ["./layers/installations"],
|
||||||
|
"modules": [
|
||||||
|
"src/data/installationFeedback.js",
|
||||||
|
"src/data/militaryInstallationData.js",
|
||||||
|
"src/layers/installations/controls.js",
|
||||||
|
"src/layers/installations/index.js",
|
||||||
|
"src/layers/installations/ingestion.js",
|
||||||
|
"src/layers/installations/lifecycle.js",
|
||||||
|
"src/layers/installations/model.js",
|
||||||
|
"src/layers/installations/policy.js",
|
||||||
|
"src/layers/installations/rendering.js",
|
||||||
|
"src/layers/installations/selection.js",
|
||||||
|
"src/layers/installations/source.js",
|
||||||
|
"src/layers/installations/state.js",
|
||||||
|
"src/layers/installations/viewport.js"
|
||||||
|
],
|
||||||
|
"external": ["cesium"]
|
||||||
|
},
|
||||||
|
"awareness-layer": {
|
||||||
|
"exports": ["./layers/awareness"],
|
||||||
|
"modules": [
|
||||||
|
"src/cockpitMath.js",
|
||||||
|
"src/data/iconOrientation.js",
|
||||||
|
"src/data/militaryAwarenessEngine.js",
|
||||||
|
"src/layers/awareness/controls.js",
|
||||||
|
"src/layers/awareness/dependencies.js",
|
||||||
|
"src/layers/awareness/focus.js",
|
||||||
|
"src/layers/awareness/history.js",
|
||||||
|
"src/layers/awareness/index.js",
|
||||||
|
"src/layers/awareness/ingestion.js",
|
||||||
|
"src/layers/awareness/lifecycle.js",
|
||||||
|
"src/layers/awareness/model.js",
|
||||||
|
"src/layers/awareness/navigation.js",
|
||||||
|
"src/layers/awareness/panel.js",
|
||||||
|
"src/layers/awareness/policy.js",
|
||||||
|
"src/layers/awareness/queries.js",
|
||||||
|
"src/layers/awareness/rendering.js",
|
||||||
|
"src/layers/awareness/state.js",
|
||||||
|
"src/layers/awareness/subject.js",
|
||||||
|
"src/layers/awareness/testing.js",
|
||||||
|
"src/ui/panelRailGeometry.js"
|
||||||
|
],
|
||||||
|
"external": ["cesium"]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { readLayerSource } from './testSupport/readLayerSource.mjs';
|
||||||
import { PanelLayoutController } from './ui/panelLayoutController.js';
|
import { PanelLayoutController } from './ui/panelLayoutController.js';
|
||||||
import { readShellElements } from './ui/shellElements.js';
|
import { readShellElements } from './ui/shellElements.js';
|
||||||
import { readStylesheet } from './testSupport/readStylesheet.mjs';
|
import { readStylesheet } from './testSupport/readStylesheet.mjs';
|
||||||
@@ -30,7 +31,7 @@ const ui = fs.readFileSync(path.join(ROOT, 'src', 'ui', 'applicationShell.js'),
|
|||||||
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', 'manager.js'), 'utf8');
|
const manager = fs.readFileSync(path.join(ROOT, 'src', 'data', 'manager.js'), 'utf8');
|
||||||
const contextLayer = fs.readFileSync(path.join(ROOT, 'src', 'data', 'militaryAwareness.js'), 'utf8');
|
const contextLayer = readLayerSource(path.join(ROOT, 'src', 'data', 'militaryAwareness.js'), 'utf8');
|
||||||
const voiceActions = fs.readFileSync(path.join(ROOT, 'src', 'voice', 'gevActions.js'), 'utf8');
|
const voiceActions = fs.readFileSync(path.join(ROOT, 'src', 'voice', 'gevActions.js'), 'utf8');
|
||||||
|
|
||||||
test('Cockpit has one reset action beside its bottom exit path', () => {
|
test('Cockpit has one reset action beside its bottom exit path', () => {
|
||||||
|
|||||||
+42
-1944
File diff suppressed because it is too large.
Load diff
@@ -43,7 +43,7 @@ import {
|
|||||||
} from './militaryAwarenessEngine.js';
|
} from './militaryAwarenessEngine.js';
|
||||||
import { NAVIGATION_AUTHORITY_EVENT } from '../navigationPolicy.js';
|
import { NAVIGATION_AUTHORITY_EVENT } from '../navigationPolicy.js';
|
||||||
|
|
||||||
const militaryAwarenessSource = fs.readFileSync(
|
const militaryAwarenessSource = readLayerSource(
|
||||||
new URL('./militaryAwareness.js', import.meta.url),
|
new URL('./militaryAwareness.js', import.meta.url),
|
||||||
'utf8',
|
'utf8',
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,681 +1,23 @@
|
|||||||
import * as Cesium from 'cesium';
|
|
||||||
import { governorRequestRender } from '../renderGovernor.js';
|
|
||||||
import {
|
import {
|
||||||
clearSelectedEntityContextForLayer,
|
createInstallationsLayer,
|
||||||
getSelectedEntityContext,
|
createInstallationSource,
|
||||||
registerEntityContext,
|
} from '../layers/installations/index.js';
|
||||||
removeEntityContextsForLayer,
|
import * as render from '../renderGovernor.js';
|
||||||
selectEntityContext,
|
import * as context from './contextStore.js';
|
||||||
} from './contextStore.js';
|
import * as ground from './groundFloor.js';
|
||||||
import {
|
import * as anchors from './fireAnchors.js';
|
||||||
cachedGroundFloor,
|
import * as picking from './pickRegistry.js';
|
||||||
floorAltitudeM,
|
|
||||||
resolveGroundFloorCellsBounded,
|
|
||||||
} from './groundFloor.js';
|
|
||||||
// The shared batched/chunked/session-cached DEM warm chain. The module name is
|
|
||||||
// historical (it shipped with the FIRMS fire anchors); the mechanism itself is
|
|
||||||
// generic — cold coarse floor cells for a rendered point set, resolved strictly
|
|
||||||
// sequentially so overlapping renders cannot stack requests on the proxy.
|
|
||||||
import { warmFireAnchorFloors } from './fireAnchors.js';
|
|
||||||
import { normalizeMilitaryInstallations } from './militaryInstallationData.js';
|
|
||||||
import { installationFeedback } from './installationFeedback.js';
|
|
||||||
import { registerPickOwner, unregisterPickOwner } from './pickRegistry.js';
|
|
||||||
|
|
||||||
const LAYER_ID = 'military-installations';
|
const layer = createInstallationsLayer({
|
||||||
const REQUEST_DEBOUNCE_MS = 500;
|
source: createInstallationSource(),
|
||||||
const MAX_VIEWPORT_DEGREES = 10;
|
services: { render, context, ground, anchors, picking },
|
||||||
const MAX_RENDERED = 700;
|
});
|
||||||
const GOOGLE_MILITARY_PLACE_TYPES = new Set(['military_base']);
|
export const approximateSurfaceDistanceM = layer.approximateSurfaceDistanceM;
|
||||||
const COLOR_BY_CLASS = {
|
export const classifyGoogleMilitaryPlace = layer.classifyGoogleMilitaryPlace;
|
||||||
airfield: '#5aa9ff',
|
export const installationSourceLabel = layer.installationSourceLabel;
|
||||||
naval_base: '#48c7d5',
|
export const installationSurfaceHeightM = layer.installationSurfaceHeightM;
|
||||||
range: '#d9a85d',
|
export const installationWithinViewport = layer.installationWithinViewport;
|
||||||
military_land: '#9ca6b0',
|
export const installationResponseSaturated =
|
||||||
places_candidate: '#c58cff',
|
layer.installationResponseSaturated;
|
||||||
};
|
export const installationRetryDelayMs = layer.installationRetryDelayMs;
|
||||||
const EARTH_MEAN_RADIUS_M = 6371008.8;
|
export default layer;
|
||||||
const DISTANCE_PREFILTER_MARGIN_M = 5000;
|
|
||||||
const distanceEndpointScratch = new Cesium.Cartographic();
|
|
||||||
const distanceGeodesicScratch = new Cesium.EllipsoidGeodesic();
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Allocation-free spherical distance used only as a conservative rejection
|
|
||||||
* pass before the exact ellipsoidal geodesic calculation.
|
|
||||||
*/
|
|
||||||
export function approximateSurfaceDistanceM(latitudeARad, longitudeARad, latitudeBDeg, longitudeBDeg) {
|
|
||||||
const latitudeBRad = Cesium.Math.toRadians(latitudeBDeg);
|
|
||||||
const longitudeBRad = Cesium.Math.toRadians(longitudeBDeg);
|
|
||||||
const latitudeDelta = latitudeBRad - latitudeARad;
|
|
||||||
const longitudeDelta = Math.atan2(
|
|
||||||
Math.sin(longitudeBRad - longitudeARad),
|
|
||||||
Math.cos(longitudeBRad - longitudeARad),
|
|
||||||
);
|
|
||||||
const sinLatitude = Math.sin(latitudeDelta / 2);
|
|
||||||
const sinLongitude = Math.sin(longitudeDelta / 2);
|
|
||||||
const haversine = sinLatitude * sinLatitude
|
|
||||||
+ Math.cos(latitudeARad) * Math.cos(latitudeBRad) * sinLongitude * sinLongitude;
|
|
||||||
return 2 * EARTH_MEAN_RADIUS_M * Math.asin(Math.min(1, Math.sqrt(haversine)));
|
|
||||||
}
|
|
||||||
|
|
||||||
const state = {
|
|
||||||
viewer: null,
|
|
||||||
dataSource: null,
|
|
||||||
enabled: false,
|
|
||||||
records: [],
|
|
||||||
recordById: new Map(),
|
|
||||||
selectedId: null,
|
|
||||||
lastUpdate: null,
|
|
||||||
error: null,
|
|
||||||
status: 'idle',
|
|
||||||
stale: false,
|
|
||||||
/** Whether the upstream truncated at its element cap for the current view. */
|
|
||||||
saturated: false,
|
|
||||||
loading: false,
|
|
||||||
abort: null,
|
|
||||||
/** Pending timed retry while status is 'unavailable' (see scheduleUnavailableRetry). */
|
|
||||||
retryTimer: null,
|
|
||||||
/** Current backoff step for that retry; 0 = next failure starts at the minimum. */
|
|
||||||
retryDelayMs: 0,
|
|
||||||
retryAt: 0,
|
|
||||||
failureReason: null,
|
|
||||||
moveEndRemove: null,
|
|
||||||
clickHandler: null,
|
|
||||||
timer: null,
|
|
||||||
googleSearchRequested: false,
|
|
||||||
};
|
|
||||||
|
|
||||||
function colorFor(record) {
|
|
||||||
return Cesium.Color.fromCssColorString(COLOR_BY_CLASS[record.class] || '#9ca6b0');
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Classify a Places text-search result without turning a name match into a
|
|
||||||
* mapped military-land claim. Google currently has no documented military
|
|
||||||
* Places type, so ordinary results remain visually distinct candidates; the
|
|
||||||
* explicit branch is retained for any source response that does carry one.
|
|
||||||
* @param {object} place Google Places result.
|
|
||||||
* @returns {string|null} Installation class, or null when not authoritative.
|
|
||||||
*/
|
|
||||||
export function classifyGoogleMilitaryPlace(place) {
|
|
||||||
const types = new Set([
|
|
||||||
place?.primaryType,
|
|
||||||
...(Array.isArray(place?.types) ? place.types : []),
|
|
||||||
].map((value) => String(value || '').trim().toLowerCase()).filter(Boolean));
|
|
||||||
return [...types].some((type) => GOOGLE_MILITARY_PLACE_TYPES.has(type))
|
|
||||||
? 'military_land'
|
|
||||||
: 'places_candidate';
|
|
||||||
}
|
|
||||||
|
|
||||||
/** @param {object} record @returns {string} Human-readable source attribution. */
|
|
||||||
export function installationSourceLabel(record) {
|
|
||||||
const names = [...new Set((Array.isArray(record?.sources) ? record.sources : [])
|
|
||||||
.map((source) => String(source?.name || '').trim())
|
|
||||||
.filter(Boolean))];
|
|
||||||
return names.join(' + ') || 'Unknown mapped source';
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Shared rendered-surface height for an installation anchor or footprint.
|
|
||||||
* @param {{latitude:number, longitude:number}} record Installation record.
|
|
||||||
* @returns {number} Ellipsoidal render height in metres.
|
|
||||||
*/
|
|
||||||
export function installationSurfaceHeightM(record) {
|
|
||||||
return floorAltitudeM(
|
|
||||||
null,
|
|
||||||
cachedGroundFloor(record?.latitude, record?.longitude),
|
|
||||||
) ?? 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Whether a mapped record belongs to the REQUESTED viewport.
|
|
||||||
*
|
|
||||||
* The proxy snaps the request bbox outward onto a shared cache grid, so a
|
|
||||||
* response is a SUPERSET of what was asked for, and rendering that superset
|
|
||||||
* would put off-screen sites into the map and into the "CURRENT VIEWPORT ONLY"
|
|
||||||
* context claim. What may be tested depends on how much of a feature's geometry
|
|
||||||
* we actually hold:
|
|
||||||
*
|
|
||||||
* - A NODE is a point: its centre IS its whole geometry, so an exact
|
|
||||||
* containment test is correct and loses nothing.
|
|
||||||
* - A record WITH a footprint is tested by bounding-box overlap. Overpass bbox
|
|
||||||
* queries return features that merely INTERSECT the box, so centre-testing
|
|
||||||
* these would drop large bases whose centre sits just outside.
|
|
||||||
* - A way or relation WITHOUT a footprint is KEPT. Relations carry geometry on
|
|
||||||
* their members and ways beyond MAX_FOOTPRINT_POINTS are normalized without
|
|
||||||
* one, so their true extent is unknown here — and Overpass already proved
|
|
||||||
* they intersect the queried bbox. Centre-testing them would erase exactly
|
|
||||||
* the biggest installations. The honest cost is slight over-inclusion,
|
|
||||||
* bounded by one snap cell (~5.5 km) around the viewport.
|
|
||||||
*
|
|
||||||
* @param {{latitude:number, longitude:number, footprint:?Array, osmType:?string}} record
|
|
||||||
* @param {{south:number, west:number, north:number, east:number}} box Requested viewport.
|
|
||||||
* @returns {boolean}
|
|
||||||
*/
|
|
||||||
export function installationWithinViewport(record, box) {
|
|
||||||
if (!record || !box) return false;
|
|
||||||
const { latitude, longitude, footprint } = record;
|
|
||||||
const centreInside = latitude >= box.south && latitude <= box.north
|
|
||||||
&& longitude >= box.west && longitude <= box.east;
|
|
||||||
if (centreInside) return true;
|
|
||||||
if (Array.isArray(footprint) && footprint.length) {
|
|
||||||
let minLat = Infinity; let maxLat = -Infinity;
|
|
||||||
let minLon = Infinity; let maxLon = -Infinity;
|
|
||||||
for (const [lon, lat] of footprint) {
|
|
||||||
if (lat < minLat) minLat = lat;
|
|
||||||
if (lat > maxLat) maxLat = lat;
|
|
||||||
if (lon < minLon) minLon = lon;
|
|
||||||
if (lon > maxLon) maxLon = lon;
|
|
||||||
}
|
|
||||||
return maxLat >= box.south && minLat <= box.north
|
|
||||||
&& maxLon >= box.west && minLon <= box.east;
|
|
||||||
}
|
|
||||||
// Unknown extent: inclusive. Only a point feature may be excluded on centre.
|
|
||||||
return record.osmType !== 'node';
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Whether a response was truncated at the upstream element cap.
|
|
||||||
*
|
|
||||||
* The proxy states this outright, but a `saturated`-less payload is NOT
|
|
||||||
* evidence of a complete answer: entries cached before the saturation guard
|
|
||||||
* shipped predate the field and live for 30 days. Fall back to deriving it from
|
|
||||||
* the element count against the cap the payload itself reports.
|
|
||||||
* @param {{saturated?: boolean, elements?: Array, elementCap?: number}} payload
|
|
||||||
* @returns {boolean}
|
|
||||||
*/
|
|
||||||
export function installationResponseSaturated(payload) {
|
|
||||||
if (typeof payload?.saturated === 'boolean') return payload.saturated;
|
|
||||||
const cap = Number(payload?.elementCap);
|
|
||||||
if (!Number.isFinite(cap) || cap <= 0) return false;
|
|
||||||
return Array.isArray(payload?.elements) && payload.elements.length >= cap;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Commit a status/error transition and buy the one frame it needs.
|
|
||||||
*
|
|
||||||
* With the render governor idle — Contacts has released its hold and nothing
|
|
||||||
* else animates — no frame would otherwise arrive to re-read this, so a load
|
|
||||||
* that fails after the scene went quiet would leave the last healthy readout on
|
|
||||||
* screen indefinitely.
|
|
||||||
* @param {string} status @param {?string} error
|
|
||||||
*/
|
|
||||||
function setInstallationStatus(status, error = null) {
|
|
||||||
if (state.status === status && state.error === error) return;
|
|
||||||
state.status = status;
|
|
||||||
state.error = error;
|
|
||||||
governorRequestRender('installations-status');
|
|
||||||
}
|
|
||||||
|
|
||||||
function viewportBox(viewer) {
|
|
||||||
const rectangle = viewer?.camera?.computeViewRectangle(viewer.scene.globe.ellipsoid);
|
|
||||||
if (!rectangle) return null;
|
|
||||||
const south = Cesium.Math.toDegrees(rectangle.south);
|
|
||||||
const north = Cesium.Math.toDegrees(rectangle.north);
|
|
||||||
const west = Cesium.Math.toDegrees(rectangle.west);
|
|
||||||
const east = Cesium.Math.toDegrees(rectangle.east);
|
|
||||||
// Cross-dateline/global views require a zoom before a bounded request.
|
|
||||||
if (!Number.isFinite(south + north + west + east) || east <= west || north - south > MAX_VIEWPORT_DEGREES || east - west > MAX_VIEWPORT_DEGREES) return null;
|
|
||||||
return { south, west, north, east };
|
|
||||||
}
|
|
||||||
|
|
||||||
function clearRendered() {
|
|
||||||
if (state.dataSource?.entities) state.dataSource.entities.removeAll();
|
|
||||||
removeEntityContextsForLayer(LAYER_ID);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The records that get entities this paint: the nearest `MAX_RENDERED`, plus
|
|
||||||
* the selected one when it falls outside that window.
|
|
||||||
*
|
|
||||||
* Context navigation walks the FULL nearby cohort, which is not bounded by the
|
|
||||||
* render cap, so selecting item 701+ used to produce no entity at all — the
|
|
||||||
* camera flew, `getById` returned null, and the selection was silently dropped
|
|
||||||
* on the floor, leaving the Context subject stale so NEXT offered the same
|
|
||||||
* installation forever. One extra entity keeps every cohort item selectable
|
|
||||||
* and the cohort count honest.
|
|
||||||
* @returns {Array<object>} Records to render this paint.
|
|
||||||
*/
|
|
||||||
function renderableRecords() {
|
|
||||||
const rendered = state.records.slice(0, MAX_RENDERED);
|
|
||||||
if (!state.selectedId) return rendered;
|
|
||||||
if (rendered.some((record) => record.id === state.selectedId)) return rendered;
|
|
||||||
const selected = state.recordById.get(state.selectedId);
|
|
||||||
return selected ? [...rendered, selected] : rendered;
|
|
||||||
}
|
|
||||||
|
|
||||||
function renderRecords({ claimSelection = false } = {}) {
|
|
||||||
// Context navigation can select another layer without a canvas click.
|
|
||||||
// A delayed floor/data repaint must not steal that newer selection back.
|
|
||||||
const selectedContext = getSelectedEntityContext();
|
|
||||||
if (!claimSelection && state.selectedId && selectedContext && selectedContext.id !== state.selectedId) {
|
|
||||||
state.selectedId = null;
|
|
||||||
}
|
|
||||||
// Post-moveEnd debounced fetches commit after the camera settles; the
|
|
||||||
// rebuilt entities need one frame in idle mode. (perf wave 2 fix)
|
|
||||||
governorRequestRender('installations-render');
|
|
||||||
clearRendered();
|
|
||||||
for (const record of renderableRecords()) {
|
|
||||||
const color = colorFor(record);
|
|
||||||
const surfaceHeightM = installationSurfaceHeightM(record);
|
|
||||||
const displayPosition = Cesium.Cartesian3.fromDegrees(
|
|
||||||
record.longitude,
|
|
||||||
record.latitude,
|
|
||||||
surfaceHeightM,
|
|
||||||
);
|
|
||||||
const entity = state.dataSource.entities.add({
|
|
||||||
id: record.id,
|
|
||||||
position: displayPosition,
|
|
||||||
point: {
|
|
||||||
pixelSize: record.id === state.selectedId ? 13 : 9,
|
|
||||||
color: record.id === state.selectedId ? Cesium.Color.WHITE : color,
|
|
||||||
outlineColor: Cesium.Color.BLACK.withAlpha(0.8),
|
|
||||||
outlineWidth: 1,
|
|
||||||
disableDepthTestDistance: Number.POSITIVE_INFINITY,
|
|
||||||
},
|
|
||||||
polygon: record.footprint ? {
|
|
||||||
hierarchy: new Cesium.PolygonHierarchy(record.footprint.map(([longitude, latitude]) => Cesium.Cartesian3.fromDegrees(longitude, latitude))),
|
|
||||||
material: color.withAlpha(0.12),
|
|
||||||
outline: true,
|
|
||||||
outlineColor: color.withAlpha(0.65),
|
|
||||||
height: surfaceHeightM,
|
|
||||||
} : undefined,
|
|
||||||
});
|
|
||||||
entity.gevTrackedId = `installations:${record.id}`;
|
|
||||||
entity.gevDisplayPosition = () => displayPosition;
|
|
||||||
entity.gevLabelModel = {
|
|
||||||
title: record.name || 'MAPPED INSTALLATION',
|
|
||||||
details: [String(record.class || 'installation').replaceAll('_', ' ').toUpperCase()],
|
|
||||||
accent: COLOR_BY_CLASS[record.class] || '#9ca6b0',
|
|
||||||
};
|
|
||||||
registerEntityContext(entity, {
|
|
||||||
id: record.id,
|
|
||||||
layerId: LAYER_ID,
|
|
||||||
layerName: record.kind === 'place_candidate'
|
|
||||||
? 'Military Site Search Candidates'
|
|
||||||
: 'Mapped Military Installations',
|
|
||||||
source: installationSourceLabel(record),
|
|
||||||
label: record.name,
|
|
||||||
latitude: record.latitude,
|
|
||||||
longitude: record.longitude,
|
|
||||||
properties: {
|
|
||||||
class: record.class,
|
|
||||||
primaryType: record.primaryType || null,
|
|
||||||
placeTypes: Array.isArray(record.placeTypes) ? record.placeTypes : [],
|
|
||||||
validation: record.validation,
|
|
||||||
retrievedAt: record.retrievedAt,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
const selectedEntity = state.selectedId
|
|
||||||
? state.dataSource.entities.getById(state.selectedId)
|
|
||||||
: null;
|
|
||||||
if (selectedEntity) selectEntityContext(selectedEntity);
|
|
||||||
else state.selectedId = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Second paint for floors that missed the bounded pre-render deadline.
|
|
||||||
*
|
|
||||||
* `resolveGroundFloorCellsBounded` gives up after FLOOR_RESOLVE_DEADLINE_MS so
|
|
||||||
* a cold DEM can never hold the dots hostage — but the resolve keeps running
|
|
||||||
* and lands seconds later, and without this the records it covers stay pinned
|
|
||||||
* at ellipsoid height 0, sitting visibly under the 3D tiles (owner playtest
|
|
||||||
* 2026-08-18: "orange dots at the bottom").
|
|
||||||
*
|
|
||||||
* This is the render -> warm -> re-render chain FIRMS already uses, with one
|
|
||||||
* difference the installations path forces: the trigger is whether a cell that
|
|
||||||
* was COLD AT PAINT TIME is warm now, not whether this particular batch warmed
|
|
||||||
* it. The bounded resolve above is still running against the same cells, so
|
|
||||||
* asking "did MY batch warm anything" would answer false exactly when the other
|
|
||||||
* resolve won the race — the common case. Still terminating: a set that is
|
|
||||||
* wholly cold afterwards re-renders zero times and the next camera-driven load
|
|
||||||
* retries.
|
|
||||||
* @param {Array<object>} records Records just rendered.
|
|
||||||
* @returns {void}
|
|
||||||
*/
|
|
||||||
function warmInstallationFloors(records) {
|
|
||||||
const cold = records
|
|
||||||
.filter((record) => cachedGroundFloor(record.latitude, record.longitude) == null)
|
|
||||||
.map((record) => ({ lat: record.latitude, lon: record.longitude }));
|
|
||||||
if (!cold.length) return;
|
|
||||||
warmFireAnchorFloors(cold).then(() => {
|
|
||||||
if (!state.enabled || !state.dataSource) return;
|
|
||||||
if (!cold.some((point) => cachedGroundFloor(point.lat, point.lon) != null)) return;
|
|
||||||
renderRecords();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function selectRecord(id) {
|
|
||||||
const record = state.recordById.get(id);
|
|
||||||
if (!record || !state.dataSource) return false;
|
|
||||||
state.selectedId = id;
|
|
||||||
renderRecords({ claimSelection: true });
|
|
||||||
// renderRecords drops selectedId when the record produced no entity.
|
|
||||||
return state.selectedId === id;
|
|
||||||
}
|
|
||||||
|
|
||||||
function installInteraction(viewer) {
|
|
||||||
if (state.clickHandler) return;
|
|
||||||
state.clickHandler = new Cesium.ScreenSpaceEventHandler(viewer.scene.canvas);
|
|
||||||
state.clickHandler.setInputAction((click) => {
|
|
||||||
if (!state.enabled) return;
|
|
||||||
const picked = viewer.scene.pick(click.position);
|
|
||||||
const id = typeof picked?.id?.id === 'string' ? picked.id.id : null;
|
|
||||||
if (id && state.recordById.has(id) && id !== state.selectedId) {
|
|
||||||
selectRecord(id);
|
|
||||||
} else if (state.selectedId) {
|
|
||||||
// Clicking the selected site again, empty map, or another contact
|
|
||||||
// releases this layer's selection. Clear only our shared context so a
|
|
||||||
// sibling click handler's newly selected aircraft/site stays intact.
|
|
||||||
state.selectedId = null;
|
|
||||||
clearSelectedEntityContextForLayer(LAYER_ID);
|
|
||||||
renderRecords();
|
|
||||||
}
|
|
||||||
}, Cesium.ScreenSpaceEventType.LEFT_CLICK);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Backoff progression for the unavailable-state retry: 30 s, doubling to a
|
|
||||||
* 240 s ceiling. Pure so the progression is pinnable without booting the layer.
|
|
||||||
*/
|
|
||||||
export function installationRetryDelayMs(prevDelayMs) {
|
|
||||||
const RETRY_MIN_MS = 30000;
|
|
||||||
const RETRY_CEIL_MS = 240000;
|
|
||||||
if (!Number.isFinite(prevDelayMs) || prevDelayMs <= 0) return RETRY_MIN_MS;
|
|
||||||
return Math.min(prevDelayMs * 2, RETRY_CEIL_MS);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 'Temporarily unavailable' must mean temporarily: fetches otherwise fire only
|
|
||||||
* on enable and on camera moveEnd, so a parked camera whose first request died
|
|
||||||
* (one flaky Overpass mirror is enough) stayed unavailable forever while the
|
|
||||||
* proxy sat healthy while the layer refused to show its features. While the
|
|
||||||
* layer is enabled and
|
|
||||||
* unavailable, retry on a 30 s → 240 s backoff; any success, user-driven load,
|
|
||||||
* zoom-out, or disable cancels it.
|
|
||||||
*/
|
|
||||||
function scheduleUnavailableRetry() {
|
|
||||||
if (!state.enabled) return;
|
|
||||||
clearTimeout(state.retryTimer);
|
|
||||||
state.retryDelayMs = installationRetryDelayMs(state.retryDelayMs);
|
|
||||||
state.retryAt = Date.now() + state.retryDelayMs;
|
|
||||||
state.retryTimer = setTimeout(() => {
|
|
||||||
state.retryTimer = null;
|
|
||||||
state.retryAt = 0;
|
|
||||||
if (state.enabled && !state.loading) loadInstallations();
|
|
||||||
}, state.retryDelayMs);
|
|
||||||
}
|
|
||||||
|
|
||||||
function clearUnavailableRetry({ resetBackoff = true } = {}) {
|
|
||||||
clearTimeout(state.retryTimer);
|
|
||||||
state.retryTimer = null;
|
|
||||||
state.retryAt = 0;
|
|
||||||
if (resetBackoff) state.retryDelayMs = 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
function scheduleLoad() {
|
|
||||||
if (!state.enabled) return;
|
|
||||||
// A user-driven load supersedes any pending retry; the load reschedules on
|
|
||||||
// failure, so the backoff step is kept rather than reset.
|
|
||||||
clearUnavailableRetry({ resetBackoff: false });
|
|
||||||
clearTimeout(state.timer);
|
|
||||||
state.timer = setTimeout(() => { loadInstallations(); }, REQUEST_DEBOUNCE_MS);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function loadInstallations() {
|
|
||||||
if (!state.enabled || !state.viewer) return;
|
|
||||||
const box = viewportBox(state.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
|
|
||||||
// of `error` — the manager derives refresh failures and the global status
|
|
||||||
// chip derives LOAD FAILED from that field, and a "zoom in" prompt turned
|
|
||||||
// every Cockpit refresh into a reported failure. The row and toast read the
|
|
||||||
// prompt from `statusMessage` (installationFeedback) instead.
|
|
||||||
if (!box) {
|
|
||||||
state.abort?.abort();
|
|
||||||
state.abort = null;
|
|
||||||
state.loading = false;
|
|
||||||
clearUnavailableRetry();
|
|
||||||
setInstallationStatus('zoom-in');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
state.abort?.abort();
|
|
||||||
const requestAbort = new AbortController();
|
|
||||||
state.abort = requestAbort;
|
|
||||||
state.loading = true;
|
|
||||||
clearUnavailableRetry({ resetBackoff: false });
|
|
||||||
// The previous attempt's failure is not the outcome of this new attempt.
|
|
||||||
setInstallationStatus('loading');
|
|
||||||
try {
|
|
||||||
const fetchInstallations = async (exact) => {
|
|
||||||
const query = new URLSearchParams(Object.entries(box).map(([key, value]) => [key, value.toFixed(5)]));
|
|
||||||
if (exact) query.set('exact', '1');
|
|
||||||
const response = await fetch(`/api/military-installations?${query}`, { signal: requestAbort.signal });
|
|
||||||
const body = await response.json();
|
|
||||||
if (!response.ok) throw Object.assign(new Error(body?.error || `Installation feed HTTP ${response.status}`), {
|
|
||||||
failureReason: ['rate_limited', 'timeout', 'query_failed'].includes(body?.reason) ? body.reason : 'unavailable',
|
|
||||||
});
|
|
||||||
return body;
|
|
||||||
};
|
|
||||||
|
|
||||||
let payload = await fetchInstallations(false);
|
|
||||||
// A SATURATED snapped tile was truncated upstream, so features from the
|
|
||||||
// snap's extra ring may have crowded out sites actually on screen. Re-ask
|
|
||||||
// for the exact viewport (separately keyed and cached) before rendering.
|
|
||||||
let saturated = installationResponseSaturated(payload);
|
|
||||||
if (saturated) {
|
|
||||||
payload = await fetchInstallations(true);
|
|
||||||
saturated = installationResponseSaturated(payload);
|
|
||||||
}
|
|
||||||
const normalized = normalizeMilitaryInstallations(payload, payload.retrievedAt || new Date().toISOString());
|
|
||||||
// The proxy answers a bbox at least as large as the viewport; keep only what
|
|
||||||
// was actually asked for so nothing off-screen reaches the map or the
|
|
||||||
// "current viewport only" context claim.
|
|
||||||
const records = normalized.records.filter((record) => installationWithinViewport(record, box));
|
|
||||||
let placesError = null;
|
|
||||||
if (state.googleSearchRequested) {
|
|
||||||
state.googleSearchRequested = false;
|
|
||||||
const latitude = (box.south + box.north) / 2;
|
|
||||||
const longitude = (box.west + box.east) / 2;
|
|
||||||
const radiusM = Math.min(50000, Math.max(1000, Math.round(Math.max(box.north - box.south, box.east - box.west) * 55_000)));
|
|
||||||
try {
|
|
||||||
const placesResponse = await fetch(`/api/google/text-search?${new URLSearchParams({
|
|
||||||
q: 'military installation', lat: latitude.toFixed(5), lon: longitude.toFixed(5), radiusM: String(radiusM),
|
|
||||||
})}`, { signal: requestAbort.signal });
|
|
||||||
const placesPayload = await placesResponse.json();
|
|
||||||
if (!placesResponse.ok) throw new Error(placesPayload?.error || `Google Places HTTP ${placesResponse.status}`);
|
|
||||||
const seen = new Set(records.map((record) => `${record.name.toLowerCase()}|${record.latitude.toFixed(3)}|${record.longitude.toFixed(3)}`));
|
|
||||||
for (const place of Array.isArray(placesPayload?.places) ? placesPayload.places : []) {
|
|
||||||
if (!place?.id || !place?.name || !Number.isFinite(place.latitude) || !Number.isFinite(place.longitude)) continue;
|
|
||||||
const placeClass = classifyGoogleMilitaryPlace(place);
|
|
||||||
const signature = `${String(place.name).toLowerCase()}|${place.latitude.toFixed(3)}|${place.longitude.toFixed(3)}`;
|
|
||||||
if (seen.has(signature)) continue;
|
|
||||||
seen.add(signature);
|
|
||||||
const retrievedAt = new Date().toISOString();
|
|
||||||
records.push({
|
|
||||||
id: `google:${place.id}`,
|
|
||||||
kind: placeClass === 'military_land' ? 'installation' : 'place_candidate',
|
|
||||||
class: placeClass,
|
|
||||||
name: String(place.name).trim(),
|
|
||||||
latitude: place.latitude,
|
|
||||||
longitude: place.longitude,
|
|
||||||
footprint: null,
|
|
||||||
primaryType: place.primaryType || null,
|
|
||||||
placeTypes: Array.isArray(place.types) ? place.types : [],
|
|
||||||
sources: [{ name: 'Google Maps Places', id: place.id, retrievedAt }],
|
|
||||||
validation: 'unreviewed',
|
|
||||||
retrievedAt,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
if (error?.name === 'AbortError') return;
|
|
||||||
placesError = 'Google Places search unavailable; showing mapped sites';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
await resolveGroundFloorCellsBounded(records.map((record) => ({
|
|
||||||
lat: record.latitude,
|
|
||||||
lon: record.longitude,
|
|
||||||
})));
|
|
||||||
if (requestAbort.signal.aborted || state.abort !== requestAbort || !state.enabled) return;
|
|
||||||
state.records = records;
|
|
||||||
state.recordById = new Map(state.records.map((record) => [record.id, record]));
|
|
||||||
state.lastUpdate = Date.now();
|
|
||||||
state.stale = payload.status === 'stale';
|
|
||||||
// Even the exact-viewport retry can saturate in a dense area. Say so rather
|
|
||||||
// than implying the view is completely surveyed.
|
|
||||||
state.saturated = saturated;
|
|
||||||
state.failureReason = null;
|
|
||||||
clearUnavailableRetry();
|
|
||||||
setInstallationStatus(
|
|
||||||
state.records.length ? (state.stale ? 'stale' : 'ready') : 'empty',
|
|
||||||
payload.status === 'stale'
|
|
||||||
? 'Serving cached mapped context'
|
|
||||||
: (saturated ? 'Too many mapped sites in view to list them all' : placesError),
|
|
||||||
);
|
|
||||||
renderRecords();
|
|
||||||
warmInstallationFloors(state.records);
|
|
||||||
} catch (error) {
|
|
||||||
if (error?.name === 'AbortError') return;
|
|
||||||
state.failureReason = error?.failureReason || 'unavailable';
|
|
||||||
setInstallationStatus('unavailable', error?.message || 'Installation context unavailable');
|
|
||||||
scheduleUnavailableRetry();
|
|
||||||
} finally {
|
|
||||||
// An older aborted request must not clear a newer request's busy state.
|
|
||||||
if (state.abort === requestAbort) {
|
|
||||||
state.abort = null;
|
|
||||||
state.loading = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const militaryInstallationsLayer = {
|
|
||||||
id: LAYER_ID,
|
|
||||||
name: 'Mapped Installations',
|
|
||||||
icon: '⌖',
|
|
||||||
source: 'OpenStreetMap + optional Google Maps Places',
|
|
||||||
updateInterval: 0,
|
|
||||||
statsRefreshInterval: 1000,
|
|
||||||
init(viewer) {
|
|
||||||
state.viewer = viewer;
|
|
||||||
state.dataSource = new Cesium.CustomDataSource('military-installations');
|
|
||||||
viewer.dataSources.add(state.dataSource);
|
|
||||||
state.moveEndRemove = viewer.camera.moveEnd.addEventListener(scheduleLoad);
|
|
||||||
installInteraction(viewer);
|
|
||||||
},
|
|
||||||
enable() {
|
|
||||||
state.enabled = true;
|
|
||||||
registerPickOwner(LAYER_ID, (id) => state.recordById.has(id));
|
|
||||||
state.dataSource.show = true;
|
|
||||||
// DataLayerManager invokes update() immediately after enable(), which owns
|
|
||||||
// the first fetch. Avoid racing it with a second aborting request here.
|
|
||||||
},
|
|
||||||
disable() {
|
|
||||||
state.enabled = false;
|
|
||||||
unregisterPickOwner(LAYER_ID);
|
|
||||||
clearUnavailableRetry();
|
|
||||||
clearTimeout(state.timer);
|
|
||||||
state.abort?.abort();
|
|
||||||
state.abort = null;
|
|
||||||
state.loading = false;
|
|
||||||
if (state.dataSource) state.dataSource.show = false;
|
|
||||||
clearSelectedEntityContextForLayer(LAYER_ID);
|
|
||||||
state.selectedId = null;
|
|
||||||
state.failureReason = null;
|
|
||||||
setInstallationStatus('idle');
|
|
||||||
},
|
|
||||||
update() { return loadInstallations(); },
|
|
||||||
/** Request a one-shot Google Maps Places search around the current map view. */
|
|
||||||
searchNearby() {
|
|
||||||
state.googleSearchRequested = true;
|
|
||||||
return loadInstallations();
|
|
||||||
},
|
|
||||||
destroy(viewer) {
|
|
||||||
this.disable();
|
|
||||||
state.moveEndRemove?.();
|
|
||||||
state.clickHandler?.destroy();
|
|
||||||
state.clickHandler = null;
|
|
||||||
clearRendered();
|
|
||||||
if (state.dataSource && viewer) viewer.dataSources.remove(state.dataSource, true);
|
|
||||||
state.dataSource = null;
|
|
||||||
},
|
|
||||||
getNearby(center, rangeM, maxCount = 50) {
|
|
||||||
if (!center) return [];
|
|
||||||
const range = Number.isFinite(rangeM) ? rangeM : Infinity;
|
|
||||||
const centerCartographic = Cesium.Cartographic.fromCartesian(center);
|
|
||||||
if (!centerCartographic) return [];
|
|
||||||
const nearby = [];
|
|
||||||
const approximateLimit = Number.isFinite(range)
|
|
||||||
? range * 1.03 + DISTANCE_PREFILTER_MARGIN_M
|
|
||||||
: Infinity;
|
|
||||||
for (const record of state.records) {
|
|
||||||
if (record.kind !== 'installation') continue;
|
|
||||||
if (approximateSurfaceDistanceM(
|
|
||||||
centerCartographic.latitude,
|
|
||||||
centerCartographic.longitude,
|
|
||||||
record.latitude,
|
|
||||||
record.longitude,
|
|
||||||
) > approximateLimit) continue;
|
|
||||||
// The awareness disk is projected onto the ground. Confirm candidates
|
|
||||||
// with an exact ellipsoidal surface distance and reusable scratch state.
|
|
||||||
distanceEndpointScratch.longitude = Cesium.Math.toRadians(record.longitude);
|
|
||||||
distanceEndpointScratch.latitude = Cesium.Math.toRadians(record.latitude);
|
|
||||||
distanceEndpointScratch.height = 0;
|
|
||||||
distanceGeodesicScratch.setEndPoints(centerCartographic, distanceEndpointScratch);
|
|
||||||
const distanceM = distanceGeodesicScratch.surfaceDistance;
|
|
||||||
if (!Number.isFinite(distanceM) || distanceM > range) continue;
|
|
||||||
nearby.push({
|
|
||||||
...record,
|
|
||||||
position: Cesium.Cartesian3.fromDegrees(
|
|
||||||
record.longitude,
|
|
||||||
record.latitude,
|
|
||||||
installationSurfaceHeightM(record),
|
|
||||||
),
|
|
||||||
distanceM,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
nearby.sort((a, b) => a.distanceM - b.distanceM);
|
|
||||||
return nearby.slice(0, Number.isFinite(maxCount) ? Math.max(1, Math.floor(maxCount)) : 50);
|
|
||||||
},
|
|
||||||
/**
|
|
||||||
* Select and frame a mapped installation from another contextual UI.
|
|
||||||
* @param {string} id Source-backed installation id.
|
|
||||||
* @returns {boolean} True when an available installation was focused.
|
|
||||||
*/
|
|
||||||
focusById(id) {
|
|
||||||
const record = state.recordById.get(String(id));
|
|
||||||
if (!record || !state.viewer) return false;
|
|
||||||
// No camera flight without a real selection: a flight plus a stale subject
|
|
||||||
// reads as success to Context navigation and strands NEXT on this item.
|
|
||||||
if (!selectRecord(record.id)) return false;
|
|
||||||
state.viewer.camera.flyToBoundingSphere(
|
|
||||||
new Cesium.BoundingSphere(
|
|
||||||
Cesium.Cartesian3.fromDegrees(
|
|
||||||
record.longitude,
|
|
||||||
record.latitude,
|
|
||||||
installationSurfaceHeightM(record),
|
|
||||||
),
|
|
||||||
18000,
|
|
||||||
),
|
|
||||||
{ duration: 1.4 },
|
|
||||||
);
|
|
||||||
return true;
|
|
||||||
},
|
|
||||||
getStats() {
|
|
||||||
return {
|
|
||||||
count: state.records.length,
|
|
||||||
lastUpdate: state.lastUpdate,
|
|
||||||
stale: state.stale,
|
|
||||||
saturated: state.saturated,
|
|
||||||
error: state.error,
|
|
||||||
status: state.status,
|
|
||||||
loading: state.loading,
|
|
||||||
retryAt: state.retryAt,
|
|
||||||
retrying: state.loading && Boolean(state.failureReason),
|
|
||||||
failureReason: state.failureReason,
|
|
||||||
statusMessage: installationFeedback({ ...state, retrying: state.loading && Boolean(state.failureReason) }),
|
|
||||||
loadingLabel: state.loading ? 'loading mapped installation context' : '',
|
|
||||||
};
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
export default militaryInstallationsLayer;
|
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { readLayerSource } from '../testSupport/readLayerSource.mjs';
|
||||||
import test from 'node:test';
|
import test from 'node:test';
|
||||||
import assert from 'node:assert/strict';
|
import assert from 'node:assert/strict';
|
||||||
import {
|
import {
|
||||||
@@ -782,7 +783,7 @@ test('zoom-out aborts an active installation request and returns non-loading gui
|
|||||||
import { installationRetryDelayMs } from './militaryInstallations.js';
|
import { installationRetryDelayMs } from './militaryInstallations.js';
|
||||||
import fs from 'node:fs';
|
import fs from 'node:fs';
|
||||||
|
|
||||||
const installationsSource = fs.readFileSync(
|
const installationsSource = readLayerSource(
|
||||||
new URL('./militaryInstallations.js', import.meta.url), 'utf8');
|
new URL('./militaryInstallations.js', import.meta.url), 'utf8');
|
||||||
|
|
||||||
test('the unavailable retry backs off 30s to a 240s ceiling and restarts clean', () => {
|
test('the unavailable retry backs off 30s to a 240s ceiling and restarts clean', () => {
|
||||||
@@ -797,10 +798,10 @@ test('the unavailable retry backs off 30s to a 240s ceiling and restarts clean',
|
|||||||
|
|
||||||
test('the retry is wired to every lifecycle edge, not just declared', () => {
|
test('the retry is wired to every lifecycle edge, not just declared', () => {
|
||||||
assert.match(installationsSource,
|
assert.match(installationsSource,
|
||||||
/setInstallationStatus\('unavailable',[^]*?\);\n\s*scheduleUnavailableRetry\(\);/,
|
/setInstallationStatus\(\s*'unavailable',[^]*?\);\n\s*parts\.viewport\.scheduleUnavailableRetry\(\);/,
|
||||||
'a failed load schedules the retry immediately after reporting unavailable');
|
'a failed load schedules the retry immediately after reporting unavailable');
|
||||||
assert.match(installationsSource,
|
assert.match(installationsSource,
|
||||||
/clearUnavailableRetry\(\);\n\s*setInstallationStatus\(\n?\s*state\.records\.length/,
|
/clearUnavailableRetry\(\);\n\s*setInstallationStatus\(\n?\s*layerState\.records\.length/,
|
||||||
'a successful load clears the pending retry and resets the backoff');
|
'a successful load clears the pending retry and resets the backoff');
|
||||||
assert.match(installationsSource,
|
assert.match(installationsSource,
|
||||||
/clearUnavailableRetry\(\);\n\s*setInstallationStatus\('zoom-in'/,
|
/clearUnavailableRetry\(\);\n\s*setInstallationStatus\('zoom-in'/,
|
||||||
@@ -811,6 +812,6 @@ test('the retry is wired to every lifecycle edge, not just declared', () => {
|
|||||||
/function scheduleLoad\(\) \{[^]*?clearUnavailableRetry\(\{ resetBackoff: false \}\)/,
|
/function scheduleLoad\(\) \{[^]*?clearUnavailableRetry\(\{ resetBackoff: false \}\)/,
|
||||||
'a user-driven load supersedes the retry without resetting the backoff step');
|
'a user-driven load supersedes the retry without resetting the backoff step');
|
||||||
assert.match(installationsSource,
|
assert.match(installationsSource,
|
||||||
/state\.enabled && !state\.loading\) loadInstallations\(\)/,
|
/layerState\.enabled && !layerState\.loading\)\s*parts\.ingestion\.loadInstallations\(\)/,
|
||||||
'the fired retry re-checks enablement and never races an in-flight load');
|
'the fired retry re-checks enablement and never races an in-flight load');
|
||||||
});
|
});
|
||||||
Vendored
+126
@@ -0,0 +1,126 @@
|
|||||||
|
import { AWARENESS_RADIUS_M } from '../../data/militaryAwarenessEngine.js';
|
||||||
|
|
||||||
|
export function createControls({ state: layerState, services, parts, source }) {
|
||||||
|
const flightsLayer = services.flights;
|
||||||
|
const militaryFlightsLayer = services.military;
|
||||||
|
const aisLiveVesselsLayer = services.vessels;
|
||||||
|
|
||||||
|
const methods = {
|
||||||
|
id: 'military-awareness',
|
||||||
|
|
||||||
|
name: 'Global Context',
|
||||||
|
|
||||||
|
icon: '◎',
|
||||||
|
|
||||||
|
source: 'Open-source proximity context',
|
||||||
|
|
||||||
|
// Context is entered from its dedicated right rail, not as a raw layer.
|
||||||
|
showInTogglePanel: false,
|
||||||
|
|
||||||
|
updateInterval: 0,
|
||||||
|
|
||||||
|
statsRefreshInterval: 1000,
|
||||||
|
|
||||||
|
attachDataManager(dataManager) {
|
||||||
|
layerState.dataManager = dataManager;
|
||||||
|
},
|
||||||
|
|
||||||
|
setParams(params = {}) {
|
||||||
|
if (typeof params.passive !== 'boolean') return;
|
||||||
|
const wasPassive = layerState.passive;
|
||||||
|
layerState.passive = params.passive;
|
||||||
|
if (layerState.enabled && wasPassive && !layerState.passive)
|
||||||
|
parts.dependencies.activateOperationalContext();
|
||||||
|
},
|
||||||
|
|
||||||
|
/** @returns {{ passive: boolean }} Current runtime parameters. */
|
||||||
|
getParams() {
|
||||||
|
return { passive: layerState.passive };
|
||||||
|
},
|
||||||
|
|
||||||
|
getStats() {
|
||||||
|
return {
|
||||||
|
count: layerState.results ? 1 : 0,
|
||||||
|
lastUpdate: layerState.results?.evaluatedAt || null,
|
||||||
|
stale: false,
|
||||||
|
error: null,
|
||||||
|
status: layerState.enabled ? 'ready' : 'idle',
|
||||||
|
};
|
||||||
|
},
|
||||||
|
|
||||||
|
/** Return the latest read-only context result for compact HUD consumers. */
|
||||||
|
getContextSnapshot() {
|
||||||
|
if (!layerState.enabled || !layerState.subject) return null;
|
||||||
|
if (!layerState.results) {
|
||||||
|
return parts.model.buildAwarenessContextSnapshot(
|
||||||
|
{
|
||||||
|
subject: { ...layerState.subject },
|
||||||
|
evaluatedAt: null,
|
||||||
|
radiusM: AWARENESS_RADIUS_M,
|
||||||
|
cohorts: [],
|
||||||
|
},
|
||||||
|
parts.model.navigationState(),
|
||||||
|
{
|
||||||
|
subjectPresent: !layerState.subjectMissing,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return parts.model.buildAwarenessContextSnapshot(
|
||||||
|
layerState.results,
|
||||||
|
parts.model.navigationState(),
|
||||||
|
{
|
||||||
|
subjectPresent: !layerState.subjectMissing,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Release Contact-owned camera tracking without discarding the selected
|
||||||
|
* subject. Reset-to-globe uses this route so the normal Context FOCUS action
|
||||||
|
* can explicitly return to the same contact, while delayed activation work
|
||||||
|
* cannot silently reclaim the camera after the reset.
|
||||||
|
* @returns {boolean} Whether a Contact subject remains selected.
|
||||||
|
*/
|
||||||
|
releaseCameraOwnership({
|
||||||
|
preserveVesselSelection = false,
|
||||||
|
origin = 'programmatic',
|
||||||
|
} = {}) {
|
||||||
|
++layerState.activationId;
|
||||||
|
layerState.autoFocusAttempted = true;
|
||||||
|
layerState.autoFocusRetryPending = false;
|
||||||
|
|
||||||
|
const preservedSelectionKey =
|
||||||
|
parts.subject.subjectKey(layerState.subject) || 'camera-release';
|
||||||
|
layerState.pendingSelectionKey = preservedSelectionKey;
|
||||||
|
try {
|
||||||
|
flightsLayer.stopTracking?.({ origin });
|
||||||
|
militaryFlightsLayer.stopTracking?.({ origin });
|
||||||
|
if (!preserveVesselSelection) aisLiveVesselsLayer.clearSelection?.();
|
||||||
|
} finally {
|
||||||
|
if (layerState.pendingSelectionKey === preservedSelectionKey) {
|
||||||
|
layerState.pendingSelectionKey = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return Boolean(layerState.subject);
|
||||||
|
},
|
||||||
|
|
||||||
|
navigatePrevious(options = {}) {
|
||||||
|
return parts.history.navigateHistory(-1, options);
|
||||||
|
},
|
||||||
|
|
||||||
|
focusCurrent(options = {}) {
|
||||||
|
return parts.focus.focusCurrentSubject(options);
|
||||||
|
},
|
||||||
|
|
||||||
|
navigateNext(options = {}) {
|
||||||
|
return parts.history.navigateHistory(1, options);
|
||||||
|
},
|
||||||
|
|
||||||
|
/** Select a context target through its owning layer's established tracker. */
|
||||||
|
focusTarget(layerId, id, options = {}) {
|
||||||
|
return parts.focus.requestFocus(layerId, id, false, options);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
return { methods };
|
||||||
|
}
|
||||||
@@ -0,0 +1,118 @@
|
|||||||
|
import { DEPENDENCIES, AIRCRAFT_DEPENDENCIES } from './policy.js';
|
||||||
|
|
||||||
|
export function createDependencies({
|
||||||
|
state: layerState,
|
||||||
|
services,
|
||||||
|
parts,
|
||||||
|
source,
|
||||||
|
}) {
|
||||||
|
function refreshAfterDeferredDependency(activationId) {
|
||||||
|
if (
|
||||||
|
!layerState.enabled ||
|
||||||
|
layerState.passive ||
|
||||||
|
activationId !== layerState.activationId
|
||||||
|
)
|
||||||
|
return;
|
||||||
|
if (layerState.subject) {
|
||||||
|
parts.subject.refreshSelectedSubject(true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (layerState.autoFocusRetryPending) {
|
||||||
|
layerState.autoFocusRetryPending = !parts.focus.focusAttentionTarget();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function enableDependencies(activationId = layerState.activationId) {
|
||||||
|
const aircraftPending = [];
|
||||||
|
for (const layerId of DEPENDENCIES) {
|
||||||
|
if (!layerState.enabled) return;
|
||||||
|
if (!layerState.dataManager) continue;
|
||||||
|
const alreadyEnabled =
|
||||||
|
layerState.dataManager.isEffectivelyEnabled?.(layerId) ??
|
||||||
|
layerState.dataManager.isEnabled(layerId);
|
||||||
|
if (alreadyEnabled) continue;
|
||||||
|
layerState.ownedDependencies.add(layerId);
|
||||||
|
// Each layer serializes its own lifecycle internally. Start them together
|
||||||
|
// so a slow or unavailable live feed cannot prevent the remaining context
|
||||||
|
// sources from turning on.
|
||||||
|
let pending;
|
||||||
|
try {
|
||||||
|
// Register the ON intent synchronously. If Contacts is turned OFF in the
|
||||||
|
// same turn, DataLayerManager then sees OFF as the newest intent and a
|
||||||
|
// stale deferred ON cannot resurrect a released dependency.
|
||||||
|
pending = Promise.resolve(
|
||||||
|
layerState.dataManager.setEnabled(layerId, true),
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
pending = Promise.reject(error);
|
||||||
|
}
|
||||||
|
if (AIRCRAFT_DEPENDENCIES.includes(layerId)) {
|
||||||
|
aircraftPending.push(pending);
|
||||||
|
} else {
|
||||||
|
// Vessels and mapped installations enrich an already usable aircraft
|
||||||
|
// context. Their settlement must never hold the Contacts transaction or
|
||||||
|
// Cockpit gate open, and the activation token prevents a late result
|
||||||
|
// from repainting or refocusing a newer/closed session.
|
||||||
|
pending.then(
|
||||||
|
() => refreshAfterDeferredDependency(activationId),
|
||||||
|
() => refreshAfterDeferredDependency(activationId),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
await Promise.allSettled(aircraftPending);
|
||||||
|
}
|
||||||
|
|
||||||
|
function releaseOwnedDependencies(releaseActivationId) {
|
||||||
|
const owned = [...layerState.ownedDependencies];
|
||||||
|
layerState.ownedDependencies.clear();
|
||||||
|
if (!layerState.dataManager || !owned.length) return Promise.resolve();
|
||||||
|
return Promise.allSettled(
|
||||||
|
owned.map((layerId) => layerState.dataManager.setEnabled(layerId, false)),
|
||||||
|
).then(() => {
|
||||||
|
if (
|
||||||
|
layerState.enabled &&
|
||||||
|
layerState.activationId !== releaseActivationId
|
||||||
|
) {
|
||||||
|
return enableDependencies(layerState.activationId);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function activateOperationalContext() {
|
||||||
|
const activationId = ++layerState.activationId;
|
||||||
|
layerState.autoFocusRetryPending = false;
|
||||||
|
const initialTrackedSubject = parts.subject.currentTrackedFlightSubject();
|
||||||
|
if (initialTrackedSubject)
|
||||||
|
parts.subject.selectSubject(initialTrackedSubject);
|
||||||
|
return enableDependencies(activationId)
|
||||||
|
.then(() => {
|
||||||
|
if (
|
||||||
|
!layerState.enabled ||
|
||||||
|
layerState.passive ||
|
||||||
|
activationId !== layerState.activationId
|
||||||
|
)
|
||||||
|
return;
|
||||||
|
const trackedSubject = parts.subject.currentTrackedFlightSubject();
|
||||||
|
if (trackedSubject) {
|
||||||
|
parts.subject.selectSubject(trackedSubject);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (layerState.subject) {
|
||||||
|
parts.subject.selectSubject(layerState.subject);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (layerState.autoFocusAttempted) return;
|
||||||
|
layerState.autoFocusRetryPending = !parts.focus.focusAttentionTarget();
|
||||||
|
})
|
||||||
|
.catch((error) =>
|
||||||
|
console.warn('[Global Context] dependency enable failed', error),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
refreshAfterDeferredDependency,
|
||||||
|
enableDependencies,
|
||||||
|
releaseOwnedDependencies,
|
||||||
|
activateOperationalContext,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,198 @@
|
|||||||
|
import * as Cesium from 'cesium';
|
||||||
|
import { VESSEL_FOCUS_RADIUS_M } from './policy.js';
|
||||||
|
|
||||||
|
export function createFocus({ state: layerState, services, parts, source }) {
|
||||||
|
const flightsLayer = services.flights;
|
||||||
|
const militaryFlightsLayer = services.military;
|
||||||
|
const { announceNavigationAuthority } = services.navigation;
|
||||||
|
const militaryInstallationsLayer = services.installations;
|
||||||
|
const aisLiveVesselsLayer = services.vessels;
|
||||||
|
|
||||||
|
/** Focus a nearby example through the source layer that owns its selection. */
|
||||||
|
|
||||||
|
function focusNearbyTarget(layerId, id, { origin = 'programmatic' } = {}) {
|
||||||
|
if (!layerId || !id) return false;
|
||||||
|
if (layerId === 'flights') {
|
||||||
|
return (
|
||||||
|
flightsLayer.refocusTrackedById?.(id, { origin }) ||
|
||||||
|
flightsLayer.trackById(id, { origin })
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (layerId === 'military') {
|
||||||
|
return (
|
||||||
|
militaryFlightsLayer.refocusTrackedById?.(id, { origin }) ||
|
||||||
|
militaryFlightsLayer.trackById(id, { origin })
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (layerId === 'military-installations') {
|
||||||
|
if (!parts.model.contextTargetFlyToAllowed(layerId))
|
||||||
|
return selectKnownContextTarget(layerId, id);
|
||||||
|
// focusById flies the camera; it never assigns a tracked entity, so the
|
||||||
|
// stamp has to be announced here.
|
||||||
|
announceNavigationAuthority('context-installation-focus');
|
||||||
|
return militaryInstallationsLayer.focusById(id);
|
||||||
|
}
|
||||||
|
if (layerId !== 'ais-live-vessels' || !aisLiveVesselsLayer.selectById(id))
|
||||||
|
return false;
|
||||||
|
|
||||||
|
const vessel = aisLiveVesselsLayer
|
||||||
|
.getAllPositions(12000)
|
||||||
|
.find((item) => String(item.id) === String(id));
|
||||||
|
if (
|
||||||
|
!parts.model.contextTargetFlyToAllowed(layerId) ||
|
||||||
|
!vessel?.position ||
|
||||||
|
!layerState.viewer
|
||||||
|
)
|
||||||
|
return true;
|
||||||
|
announceNavigationAuthority('context-vessel-focus');
|
||||||
|
layerState.viewer.camera.flyToBoundingSphere(
|
||||||
|
new Cesium.BoundingSphere(vessel.position, VESSEL_FOCUS_RADIUS_M),
|
||||||
|
{ duration: 1.4 },
|
||||||
|
);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function selectKnownContextTarget(layerId, id) {
|
||||||
|
const key = `${layerId}:${id}`;
|
||||||
|
const known = [
|
||||||
|
layerState.subject,
|
||||||
|
...layerState.navigationHistory,
|
||||||
|
...(layerState.results?.cohorts || []).flatMap((cohort) =>
|
||||||
|
cohort.id === layerId
|
||||||
|
? cohort.summary.navigationNearest || cohort.summary.nearest || []
|
||||||
|
: [],
|
||||||
|
),
|
||||||
|
].find(
|
||||||
|
(item) =>
|
||||||
|
item && `${item.layerId || layerId}:${item.id || item.mmsi}` === key,
|
||||||
|
);
|
||||||
|
if (!known?.position) return false;
|
||||||
|
if (parts.model.contextTargetFlyToAllowed(layerId))
|
||||||
|
parts.model.releaseAircraftTracking();
|
||||||
|
parts.subject.selectSubject({
|
||||||
|
...known,
|
||||||
|
layerId,
|
||||||
|
id: String(known.id || known.mmsi),
|
||||||
|
label: known.label || known.name || known.callsign || String(id),
|
||||||
|
position: Cesium.Cartesian3.clone(known.position),
|
||||||
|
});
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function requestFocus(
|
||||||
|
layerId,
|
||||||
|
id,
|
||||||
|
preserveHistory = false,
|
||||||
|
{ origin = 'programmatic' } = {},
|
||||||
|
) {
|
||||||
|
const key = `${layerId}:${id}`;
|
||||||
|
layerState.pendingSelectionKey = key;
|
||||||
|
if (preserveHistory) layerState.suppressedHistoryKey = key;
|
||||||
|
const focused = focusNearbyTarget(layerId, id, { origin });
|
||||||
|
layerState.pendingSelectionKey = null;
|
||||||
|
layerState.suppressedHistoryKey = null;
|
||||||
|
return focused;
|
||||||
|
}
|
||||||
|
|
||||||
|
function focusSubject(subject, preserveHistory = false, options = {}) {
|
||||||
|
if (!subject) return false;
|
||||||
|
return requestFocus(subject.layerId, subject.id, preserveHistory, options);
|
||||||
|
}
|
||||||
|
|
||||||
|
function focusCurrentSubject(options = {}) {
|
||||||
|
return focusSubject(layerState.subject, true, options);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pick the observed candidate closest to the current view. This is intentionally
|
||||||
|
* a navigation preference, not a risk, capability, or affiliation calculation.
|
||||||
|
* @param {Array<{position: Cesium.Cartesian3}>} candidates Observed candidates.
|
||||||
|
* @returns {Object|null} The best currently observable candidate.
|
||||||
|
*/
|
||||||
|
|
||||||
|
function closestToCurrentView(candidates) {
|
||||||
|
if (!Array.isArray(candidates) || !candidates.length) return null;
|
||||||
|
const cameraPosition = layerState.viewer?.camera?.positionWC;
|
||||||
|
if (!cameraPosition)
|
||||||
|
return candidates.find((candidate) => candidate?.position) || null;
|
||||||
|
|
||||||
|
let closest = null;
|
||||||
|
let closestDistance = Infinity;
|
||||||
|
for (const candidate of candidates) {
|
||||||
|
if (!candidate?.position) continue;
|
||||||
|
const distance = Cesium.Cartesian3.distance(
|
||||||
|
cameraPosition,
|
||||||
|
candidate.position,
|
||||||
|
);
|
||||||
|
if (Number.isFinite(distance) && distance < closestDistance) {
|
||||||
|
closest = candidate;
|
||||||
|
closestDistance = distance;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return closest;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Focus a single observed, context-priority target after activation. Civilian
|
||||||
|
* and military aircraft compete in one nearest-to-view pool; military is
|
||||||
|
* concatenated first so exact distance ties resolve to military under the
|
||||||
|
* strict comparison. An AIS vessel is only a fallback and is never inferred
|
||||||
|
* to be military. There is deliberately no distance cap.
|
||||||
|
* @returns {boolean} Whether a target was selected and framed.
|
||||||
|
*/
|
||||||
|
|
||||||
|
function focusAttentionTarget() {
|
||||||
|
if (
|
||||||
|
!layerState.enabled ||
|
||||||
|
layerState.subject ||
|
||||||
|
!layerState.viewer ||
|
||||||
|
layerState.autoFocusAttempted
|
||||||
|
)
|
||||||
|
return false;
|
||||||
|
|
||||||
|
const nearestFlight = closestToCurrentView([
|
||||||
|
...militaryFlightsLayer
|
||||||
|
.getAllPositions(800)
|
||||||
|
.map((item) => ({ ...item, layerId: 'military' })),
|
||||||
|
...flightsLayer
|
||||||
|
.getAllPositions(1000)
|
||||||
|
.map((item) => ({ ...item, layerId: 'flights' })),
|
||||||
|
]);
|
||||||
|
if (nearestFlight) {
|
||||||
|
const layer =
|
||||||
|
nearestFlight.layerId === 'military'
|
||||||
|
? militaryFlightsLayer
|
||||||
|
: flightsLayer;
|
||||||
|
if (layer.trackById(nearestFlight.id, { origin: 'programmatic' })) {
|
||||||
|
layerState.autoFocusAttempted = true;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const vessel = closestToCurrentView(
|
||||||
|
aisLiveVesselsLayer.getAllPositions(12000),
|
||||||
|
);
|
||||||
|
if (!vessel || !aisLiveVesselsLayer.selectById(vessel.id)) return false;
|
||||||
|
|
||||||
|
layerState.autoFocusAttempted = true;
|
||||||
|
if (!parts.model.contextTargetFlyToAllowed('ais-live-vessels')) return true;
|
||||||
|
|
||||||
|
announceNavigationAuthority('context-vessel-autofocus', {
|
||||||
|
cancelPendingSelection: false,
|
||||||
|
});
|
||||||
|
layerState.viewer.camera.flyToBoundingSphere(
|
||||||
|
new Cesium.BoundingSphere(vessel.position, VESSEL_FOCUS_RADIUS_M),
|
||||||
|
{ duration: 1.6 },
|
||||||
|
);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
focusNearbyTarget,
|
||||||
|
selectKnownContextTarget,
|
||||||
|
requestFocus,
|
||||||
|
focusSubject,
|
||||||
|
focusCurrentSubject,
|
||||||
|
closestToCurrentView,
|
||||||
|
focusAttentionTarget,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,239 @@
|
|||||||
|
export function createHistory({ state: layerState, services, parts, source }) {
|
||||||
|
const militaryFlightsLayer = services.military;
|
||||||
|
const flightsLayer = services.flights;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Walk navigation history for the next entry that can still be focused.
|
||||||
|
*
|
||||||
|
* A history entry only holds a SNAPSHOT of a contact; the live contact behind
|
||||||
|
* it can be evicted from its layer at any time (the cull that drives
|
||||||
|
* CONTACT LOST). Focusing an evicted entry fails, so stepping over it is the
|
||||||
|
* only way PREVIOUS/NEXT survives a lost subject — stopping at the first dead
|
||||||
|
* entry strands the operator there for the rest of the session, because the
|
||||||
|
* index never advances past it.
|
||||||
|
*
|
||||||
|
* `state.navigationIndex` moves only on a successful focus: a caller that
|
||||||
|
* falls through to the live cohort must still splice forward history from the
|
||||||
|
* position the operator actually occupies.
|
||||||
|
* @param {number} direction Walk direction (negative = previous).
|
||||||
|
* @param {{targetLayer: string|null, aircraftClass: string|null}} filters Navigation filters.
|
||||||
|
* @returns {boolean} Whether a history entry was focused.
|
||||||
|
*/
|
||||||
|
|
||||||
|
function focusCompatibleHistory(
|
||||||
|
direction,
|
||||||
|
{ targetLayer, aircraftClass, aircraftOnly, origin = 'programmatic' },
|
||||||
|
) {
|
||||||
|
let cursor = layerState.navigationIndex;
|
||||||
|
for (;;) {
|
||||||
|
const historyIndex = findCompatibleHistoryIndex(
|
||||||
|
layerState.navigationHistory,
|
||||||
|
cursor,
|
||||||
|
direction,
|
||||||
|
{
|
||||||
|
targetLayer,
|
||||||
|
aircraftClass,
|
||||||
|
aircraftOnly,
|
||||||
|
resolveItem: historySubjectItem,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
if (historyIndex === -1) return false;
|
||||||
|
if (
|
||||||
|
parts.focus.focusSubject(
|
||||||
|
layerState.navigationHistory[historyIndex],
|
||||||
|
true,
|
||||||
|
{ origin },
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
layerState.navigationIndex = historyIndex;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
// Contact evicted since it entered history — step over it. The scan is
|
||||||
|
// strictly monotonic, so this terminates at the end of history.
|
||||||
|
cursor = historyIndex;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function navigateHistory(
|
||||||
|
direction,
|
||||||
|
{
|
||||||
|
targetLayer = null,
|
||||||
|
aircraftClass = null,
|
||||||
|
aircraftOnly = false,
|
||||||
|
origin = 'programmatic',
|
||||||
|
} = {},
|
||||||
|
) {
|
||||||
|
if (
|
||||||
|
focusCompatibleHistory(direction, {
|
||||||
|
targetLayer,
|
||||||
|
aircraftClass,
|
||||||
|
aircraftOnly,
|
||||||
|
origin,
|
||||||
|
})
|
||||||
|
)
|
||||||
|
return true;
|
||||||
|
|
||||||
|
if (direction < 0) return false;
|
||||||
|
|
||||||
|
let targets = parts.navigation.selectNavigationTargets(
|
||||||
|
layerState.results?.cohorts,
|
||||||
|
layerState.subject,
|
||||||
|
[...layerState.navigationVisited],
|
||||||
|
{ targetLayer, aircraftClass, aircraftOnly },
|
||||||
|
);
|
||||||
|
const targetHistoryFullyVisited =
|
||||||
|
targets.length > 0 && targets.every((target) => target.visited);
|
||||||
|
const subjectCohortUnknown = parts.subject.subjectCohortFeedUnknown();
|
||||||
|
if (
|
||||||
|
targetHistoryFullyVisited &&
|
||||||
|
parts.navigation.isFlightLayer(layerState.subject?.layerId) &&
|
||||||
|
!subjectCohortUnknown
|
||||||
|
) {
|
||||||
|
const expanded = parts.navigation.findExpandedFlightTarget({
|
||||||
|
targetLayer,
|
||||||
|
aircraftClass,
|
||||||
|
});
|
||||||
|
if (expanded) {
|
||||||
|
return parts.focus.requestFocus(
|
||||||
|
expanded.candidate.layerId,
|
||||||
|
expanded.candidate.id,
|
||||||
|
false,
|
||||||
|
{ origin },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (targetHistoryFullyVisited) {
|
||||||
|
parts.navigation.resetNavigationVisitedCycle();
|
||||||
|
targets = parts.navigation.selectNavigationTargets(
|
||||||
|
layerState.results?.cohorts,
|
||||||
|
layerState.subject,
|
||||||
|
[...layerState.navigationVisited],
|
||||||
|
{ targetLayer, aircraftClass, aircraftOnly },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const hasNearbyFlight = targets.some((target) =>
|
||||||
|
parts.navigation.isFlightLayer(target.layerId),
|
||||||
|
);
|
||||||
|
if (
|
||||||
|
parts.navigation.isFlightLayer(layerState.subject?.layerId) &&
|
||||||
|
!hasNearbyFlight &&
|
||||||
|
!subjectCohortUnknown
|
||||||
|
) {
|
||||||
|
const expanded = parts.navigation.findExpandedFlightTarget({
|
||||||
|
targetLayer,
|
||||||
|
aircraftClass,
|
||||||
|
});
|
||||||
|
if (expanded) {
|
||||||
|
return parts.focus.requestFocus(
|
||||||
|
expanded.candidate.layerId,
|
||||||
|
expanded.candidate.id,
|
||||||
|
false,
|
||||||
|
{ origin },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (parts.subject.subjectCohortFeedUnknown()) return false;
|
||||||
|
const candidate = targets[0];
|
||||||
|
if (!candidate) return false;
|
||||||
|
return parts.focus.requestFocus(candidate.layerId, candidate.id, false, {
|
||||||
|
origin,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function historySubjectItem(subject) {
|
||||||
|
if (!subject) return null;
|
||||||
|
const cohort = layerState.results?.cohorts?.find(
|
||||||
|
(item) => item.id === subject.layerId,
|
||||||
|
);
|
||||||
|
const items =
|
||||||
|
cohort?.summary?.navigationNearest || cohort?.summary?.nearest || [];
|
||||||
|
return (
|
||||||
|
items.find(
|
||||||
|
(item) =>
|
||||||
|
String(item?.icao24 || item?.mmsi || item?.id) === String(subject.id),
|
||||||
|
) || subject
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Retain filter metadata when a production subject enters navigation history. */
|
||||||
|
|
||||||
|
function historySubjectSnapshot(subject, sourceItem = null) {
|
||||||
|
const aircraftClass =
|
||||||
|
sourceItem?.aircraftClass ||
|
||||||
|
sourceItem?.klass ||
|
||||||
|
sourceItem?.type ||
|
||||||
|
subject?.aircraftClass ||
|
||||||
|
null;
|
||||||
|
return aircraftClass ? { ...subject, aircraftClass } : { ...subject };
|
||||||
|
}
|
||||||
|
|
||||||
|
function historySourceItem(subject) {
|
||||||
|
if (!parts.navigation.isFlightLayer(subject?.layerId) || !subject?.position)
|
||||||
|
return null;
|
||||||
|
// The current sweep already holds this contact's record. Reusing it keeps
|
||||||
|
// selection O(1) against the cohort instead of paying a fresh full-layer
|
||||||
|
// proximity scan per selection — the burst cost when NEXT walks a cohort.
|
||||||
|
const cohort = layerState.results?.cohorts?.find(
|
||||||
|
(item) => item.id === subject.layerId,
|
||||||
|
);
|
||||||
|
const items =
|
||||||
|
cohort?.summary?.navigationNearest || cohort?.summary?.nearest || [];
|
||||||
|
const fromSweep = items.find(
|
||||||
|
(item) => String(item?.icao24 || item?.id) === String(subject.id),
|
||||||
|
);
|
||||||
|
if (fromSweep) return fromSweep;
|
||||||
|
const layer =
|
||||||
|
subject.layerId === 'military' ? militaryFlightsLayer : flightsLayer;
|
||||||
|
return (
|
||||||
|
layer
|
||||||
|
.getNearby(subject.position, 1000, 25, { includeHidden: true })
|
||||||
|
.find(
|
||||||
|
(item) => String(item?.icao24 || item?.id) === String(subject.id),
|
||||||
|
) || null
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Find the next history entry compatible with requested navigation filters. */
|
||||||
|
|
||||||
|
function findCompatibleHistoryIndex(
|
||||||
|
history,
|
||||||
|
startIndex,
|
||||||
|
direction,
|
||||||
|
{
|
||||||
|
targetLayer = null,
|
||||||
|
aircraftClass = null,
|
||||||
|
aircraftOnly = false,
|
||||||
|
resolveItem = (subject) => subject,
|
||||||
|
} = {},
|
||||||
|
) {
|
||||||
|
const step = direction < 0 ? -1 : 1;
|
||||||
|
for (
|
||||||
|
let index = startIndex + step;
|
||||||
|
index >= 0 && index < history.length;
|
||||||
|
index += step
|
||||||
|
) {
|
||||||
|
const subject = history[index];
|
||||||
|
if (aircraftOnly && !parts.navigation.isFlightLayer(subject?.layerId))
|
||||||
|
continue;
|
||||||
|
if (targetLayer && subject?.layerId !== targetLayer) continue;
|
||||||
|
if (
|
||||||
|
aircraftClass &&
|
||||||
|
!parts.navigation.aircraftClassMatchesFilter(
|
||||||
|
resolveItem(subject),
|
||||||
|
aircraftClass,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
continue;
|
||||||
|
return index;
|
||||||
|
}
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
focusCompatibleHistory,
|
||||||
|
navigateHistory,
|
||||||
|
historySubjectItem,
|
||||||
|
historySubjectSnapshot,
|
||||||
|
historySourceItem,
|
||||||
|
findCompatibleHistoryIndex,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
import { createModel } from './model.js';
|
||||||
|
import { createSubject } from './subject.js';
|
||||||
|
import { createPanel } from './panel.js';
|
||||||
|
import { createQueries } from './queries.js';
|
||||||
|
import { createNavigation } from './navigation.js';
|
||||||
|
import { createFocus } from './focus.js';
|
||||||
|
import { createTesting } from './testing.js';
|
||||||
|
import { createHistory } from './history.js';
|
||||||
|
import { createRendering } from './rendering.js';
|
||||||
|
import { createLifecycle } from './lifecycle.js';
|
||||||
|
import { createDependencies } from './dependencies.js';
|
||||||
|
import { createControls } from './controls.js';
|
||||||
|
import { createIngestion } from './ingestion.js';
|
||||||
|
import { createState } from './state.js';
|
||||||
|
|
||||||
|
/** Construct one layer with its own scene state and supplied application services. */
|
||||||
|
export function createAwarenessLayer({ services, source }) {
|
||||||
|
const state = createState({ services });
|
||||||
|
const parts = {};
|
||||||
|
const context = { state, services, parts, source };
|
||||||
|
parts.model = createModel(context);
|
||||||
|
parts.subject = createSubject(context);
|
||||||
|
parts.panel = createPanel(context);
|
||||||
|
parts.queries = createQueries(context);
|
||||||
|
parts.navigation = createNavigation(context);
|
||||||
|
parts.focus = createFocus(context);
|
||||||
|
parts.testing = createTesting(context);
|
||||||
|
parts.history = createHistory(context);
|
||||||
|
parts.rendering = createRendering(context);
|
||||||
|
parts.lifecycle = createLifecycle(context);
|
||||||
|
parts.dependencies = createDependencies(context);
|
||||||
|
parts.controls = createControls(context);
|
||||||
|
parts.ingestion = createIngestion(context);
|
||||||
|
return Object.assign(
|
||||||
|
{},
|
||||||
|
parts.controls.methods,
|
||||||
|
parts.lifecycle.methods,
|
||||||
|
parts.ingestion?.methods,
|
||||||
|
{
|
||||||
|
contextTargetFlyToAllowed: parts.model.contextTargetFlyToAllowed,
|
||||||
|
awarenessClearMatchesSubject: parts.subject.awarenessClearMatchesSubject,
|
||||||
|
awarenessRefreshIntervalMs: parts.model.awarenessRefreshIntervalMs,
|
||||||
|
awarenessRefreshDecision: parts.model.awarenessRefreshDecision,
|
||||||
|
awarenessClearIsEviction: parts.model.awarenessClearIsEviction,
|
||||||
|
awarenessRefreshRequired: parts.model.awarenessRefreshRequired,
|
||||||
|
summarizeInstallationViewport:
|
||||||
|
parts.queries.summarizeInstallationViewport,
|
||||||
|
contactsWindowFromSnapshot: parts.model.contactsWindowFromSnapshot,
|
||||||
|
buildAwarenessContextSnapshot: parts.model.buildAwarenessContextSnapshot,
|
||||||
|
collectAircraftProximityWindow:
|
||||||
|
parts.queries.collectAircraftProximityWindow,
|
||||||
|
_getAwarenessNavigationStateForTest:
|
||||||
|
parts.testing._getAwarenessNavigationStateForTest,
|
||||||
|
canNavigateAwarenessNext: parts.navigation.canNavigateAwarenessNext,
|
||||||
|
historySubjectSnapshot: parts.history.historySubjectSnapshot,
|
||||||
|
findCompatibleHistoryIndex: parts.history.findCompatibleHistoryIndex,
|
||||||
|
awarenessPanelControlKey: parts.panel.awarenessPanelControlKey,
|
||||||
|
captureAwarenessPanelFocus: parts.panel.captureAwarenessPanelFocus,
|
||||||
|
restoreAwarenessPanelFocus: parts.panel.restoreAwarenessPanelFocus,
|
||||||
|
awarenessResultsAreLive: parts.panel.awarenessResultsAreLive,
|
||||||
|
awarenessNeedsContinuousRender:
|
||||||
|
parts.model.awarenessNeedsContinuousRender,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
export { AWARENESS_QUERY_LIMIT } from './policy.js';
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
export function createIngestion({
|
||||||
|
state: layerState,
|
||||||
|
services,
|
||||||
|
parts,
|
||||||
|
source,
|
||||||
|
}) {
|
||||||
|
const methods = {
|
||||||
|
update() {
|
||||||
|
parts.subject.refreshSelectedSubject();
|
||||||
|
return Promise.resolve();
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
return { methods };
|
||||||
|
}
|
||||||
@@ -0,0 +1,263 @@
|
|||||||
|
import { cameraPoseSignature } from '../../data/iconOrientation.js';
|
||||||
|
|
||||||
|
export function createLifecycle({
|
||||||
|
state: layerState,
|
||||||
|
services,
|
||||||
|
parts,
|
||||||
|
source,
|
||||||
|
}) {
|
||||||
|
const { holdContinuousRender, releaseContinuousRender } = services.render;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Quantized camera-pose signature, or '' when the camera cannot report a full
|
||||||
|
* pose yet. An unknown pose reads as PARKED, so a camera that is still coming
|
||||||
|
* up can never be mistaken for continuous movement.
|
||||||
|
* @param {Cesium.Camera|null|undefined} camera
|
||||||
|
* @returns {string}
|
||||||
|
*/
|
||||||
|
|
||||||
|
function cameraMotionSignature(camera) {
|
||||||
|
if (!camera?.positionWC || !Number.isFinite(camera.heading)) return '';
|
||||||
|
return cameraPoseSignature(camera);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Take or drop the continuous-render hold to match the current need. Idempotent
|
||||||
|
* (the governor is identity-keyed), and self-healing: the release decision is
|
||||||
|
* made on a frame the hold itself guaranteed, so a hold can never strand the
|
||||||
|
* scene in continuous mode.
|
||||||
|
* @returns {void}
|
||||||
|
*/
|
||||||
|
|
||||||
|
function syncAwarenessRenderHold() {
|
||||||
|
if (layerState.enabled && parts.model.awarenessNeedsContinuousRender())
|
||||||
|
holdContinuousRender('military-awareness');
|
||||||
|
else releaseContinuousRender('military-awareness');
|
||||||
|
}
|
||||||
|
|
||||||
|
function attachRuntimeListeners() {
|
||||||
|
if (layerState.runtimeListenersAttached || !layerState.viewer) return;
|
||||||
|
window.addEventListener(
|
||||||
|
'gev:awareness-subject-selected',
|
||||||
|
layerState.subjectListener,
|
||||||
|
);
|
||||||
|
window.addEventListener('gev:entity-selected', layerState.contextListener);
|
||||||
|
window.addEventListener(
|
||||||
|
'gev:entity-selection-cleared',
|
||||||
|
layerState.clearListener,
|
||||||
|
);
|
||||||
|
window.addEventListener(
|
||||||
|
'gev:awareness-subject-cleared',
|
||||||
|
layerState.subjectClearListener,
|
||||||
|
);
|
||||||
|
layerState.preRenderRemover =
|
||||||
|
layerState.viewer.scene.preRender.addEventListener(() => {
|
||||||
|
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);
|
||||||
|
if (poseSig !== layerState.lastCameraPoseSig) {
|
||||||
|
layerState.lastCameraPoseSig = poseSig;
|
||||||
|
layerState.lastCameraPoseChangeMs = now;
|
||||||
|
}
|
||||||
|
const decision = parts.model.awarenessRefreshDecision({
|
||||||
|
nowMs: now,
|
||||||
|
lastRefreshMs: layerState.lastSubjectRefreshMs,
|
||||||
|
lastPoseChangeMs: layerState.lastCameraPoseChangeMs,
|
||||||
|
wasMoving: layerState.cameraMoving,
|
||||||
|
});
|
||||||
|
layerState.cameraMoving = decision.moving;
|
||||||
|
syncAwarenessRenderHold();
|
||||||
|
if (!decision.refresh) return;
|
||||||
|
layerState.lastSubjectRefreshMs = now;
|
||||||
|
if (
|
||||||
|
layerState.subject &&
|
||||||
|
['flights', 'military', 'ais-live-vessels'].includes(
|
||||||
|
layerState.subject.layerId,
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
parts.subject.refreshSelectedSubject();
|
||||||
|
} else if (!layerState.passive && layerState.autoFocusRetryPending) {
|
||||||
|
layerState.autoFocusRetryPending = false;
|
||||||
|
parts.focus.focusAttentionTarget();
|
||||||
|
} else {
|
||||||
|
parts.rendering.scheduleDirectionOverlayUpdate();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
layerState.runtimeListenersAttached = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function detachRuntimeListeners() {
|
||||||
|
if (layerState.runtimeListenersAttached) {
|
||||||
|
window.removeEventListener(
|
||||||
|
'gev:awareness-subject-selected',
|
||||||
|
layerState.subjectListener,
|
||||||
|
);
|
||||||
|
window.removeEventListener(
|
||||||
|
'gev:entity-selected',
|
||||||
|
layerState.contextListener,
|
||||||
|
);
|
||||||
|
window.removeEventListener(
|
||||||
|
'gev:entity-selection-cleared',
|
||||||
|
layerState.clearListener,
|
||||||
|
);
|
||||||
|
window.removeEventListener(
|
||||||
|
'gev:awareness-subject-cleared',
|
||||||
|
layerState.subjectClearListener,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
layerState.preRenderRemover?.();
|
||||||
|
layerState.preRenderRemover = null;
|
||||||
|
layerState.lastCameraPoseSig = '';
|
||||||
|
layerState.lastCameraPoseChangeMs = 0;
|
||||||
|
layerState.cameraMoving = false;
|
||||||
|
// No frames will arrive to run the per-frame release once the listener is
|
||||||
|
// gone, so drop the hold here.
|
||||||
|
releaseContinuousRender('military-awareness');
|
||||||
|
parts.rendering.cancelDirectionOverlayUpdate();
|
||||||
|
layerState.runtimeListenersAttached = false;
|
||||||
|
}
|
||||||
|
const methods = {
|
||||||
|
init(viewer) {
|
||||||
|
detachRuntimeListeners();
|
||||||
|
layerState.viewer = viewer;
|
||||||
|
layerState.subjectListener = (event) => {
|
||||||
|
if (!layerState.enabled) return;
|
||||||
|
parts.subject.selectSubject(event.detail);
|
||||||
|
};
|
||||||
|
layerState.contextListener = (event) => {
|
||||||
|
if (!layerState.enabled) return;
|
||||||
|
const subject = parts.subject.subjectFromContext(event.detail);
|
||||||
|
if (!subject) return;
|
||||||
|
if (parts.model.contextTargetFlyToAllowed(subject.layerId))
|
||||||
|
parts.model.releaseAircraftTracking();
|
||||||
|
parts.subject.selectSubject(subject);
|
||||||
|
};
|
||||||
|
layerState.clearListener = (event) => {
|
||||||
|
if (!layerState.enabled || layerState.pendingSelectionKey) return;
|
||||||
|
if (
|
||||||
|
!parts.subject.awarenessClearMatchesSubject(
|
||||||
|
layerState.subject,
|
||||||
|
event.detail,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return;
|
||||||
|
// An eviction keeps the subject so the readout can hold last-known
|
||||||
|
// values; only a deliberate clear tears the selection down.
|
||||||
|
if (parts.model.awarenessClearIsEviction(event.detail)) {
|
||||||
|
parts.subject.markSubjectEvicted();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
parts.subject.clearAwarenessSubject();
|
||||||
|
};
|
||||||
|
layerState.subjectClearListener = (event) => {
|
||||||
|
if (!layerState.enabled) return;
|
||||||
|
const cleared = event.detail;
|
||||||
|
if (layerState.pendingSelectionKey) return;
|
||||||
|
if (
|
||||||
|
parts.subject.awarenessClearMatchesSubject(
|
||||||
|
layerState.subject,
|
||||||
|
cleared,
|
||||||
|
) &&
|
||||||
|
String(layerState.subject?.id) === String(cleared?.id)
|
||||||
|
) {
|
||||||
|
if (parts.model.awarenessClearIsEviction(cleared)) {
|
||||||
|
// Deliberately does NOT set autoFocusAttempted: the subject survives,
|
||||||
|
// so the entry fallback has nothing to replace and the settlement
|
||||||
|
// guard below stays scoped to real deselects.
|
||||||
|
parts.subject.markSubjectEvicted();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// A deliberate clear during dependency settlement must win over the
|
||||||
|
// entry fallback; otherwise Contacts can silently select a replacement.
|
||||||
|
layerState.autoFocusAttempted = true;
|
||||||
|
parts.subject.clearAwarenessSubject();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
},
|
||||||
|
|
||||||
|
enable() {
|
||||||
|
layerState.enabled = true;
|
||||||
|
// NO unconditional continuous-render hold here. Contacts animates per frame
|
||||||
|
// only while the VIEW is moving (the direction arrows are screen-projected);
|
||||||
|
// parked, it is a throttled readout with nothing to animate. The hold is
|
||||||
|
// taken and released per frame by syncAwarenessRenderHold().
|
||||||
|
attachRuntimeListeners();
|
||||||
|
parts.panel.startAwarenessPageRotation();
|
||||||
|
layerState.autoFocusAttempted = false;
|
||||||
|
layerState.autoFocusRetryPending = false;
|
||||||
|
parts.panel.ensurePanel();
|
||||||
|
parts.panel.hidePanel();
|
||||||
|
if (!layerState.passive)
|
||||||
|
return parts.dependencies.activateOperationalContext();
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
|
||||||
|
disable() {
|
||||||
|
layerState.enabled = false;
|
||||||
|
releaseContinuousRender('military-awareness');
|
||||||
|
detachRuntimeListeners();
|
||||||
|
parts.panel.stopAwarenessPageRotation();
|
||||||
|
layerState.cohortPages.clear();
|
||||||
|
const releaseActivationId = ++layerState.activationId;
|
||||||
|
layerState.autoFocusAttempted = false;
|
||||||
|
layerState.autoFocusRetryPending = false;
|
||||||
|
layerState.subject = null;
|
||||||
|
layerState.results = null;
|
||||||
|
layerState.lastSubjectRefreshMs = 0;
|
||||||
|
layerState.lastEvaluatedPosition = null;
|
||||||
|
layerState.sourceRevision = '';
|
||||||
|
layerState.navigationHistory = [];
|
||||||
|
layerState.navigationVisited.clear();
|
||||||
|
layerState.navigationIndex = -1;
|
||||||
|
layerState.suppressedHistoryKey = null;
|
||||||
|
layerState.pendingSelectionKey = null;
|
||||||
|
parts.rendering.clearVisual();
|
||||||
|
parts.panel.hidePanel();
|
||||||
|
// The manager must not publish this coordinator as settled OFF while its
|
||||||
|
// owned dependency releases are still issuing newer absolute intents.
|
||||||
|
// Context mode handoffs restore the pre-entry snapshot only after this
|
||||||
|
// promise resolves, preventing those releases from superseding restore.
|
||||||
|
layerState.passive = true;
|
||||||
|
return parts.dependencies.releaseOwnedDependencies(releaseActivationId);
|
||||||
|
},
|
||||||
|
|
||||||
|
destroy() {
|
||||||
|
this.disable();
|
||||||
|
detachRuntimeListeners();
|
||||||
|
layerState.panel?.removeEventListener(
|
||||||
|
'click',
|
||||||
|
layerState.panelClickListener,
|
||||||
|
);
|
||||||
|
layerState.panelClickListener = null;
|
||||||
|
if (layerState.panelOwned) layerState.panel?.remove();
|
||||||
|
else if (layerState.panel) layerState.panel.replaceChildren();
|
||||||
|
layerState.panel = null;
|
||||||
|
layerState.panelOwned = false;
|
||||||
|
layerState.directionRoot?.remove();
|
||||||
|
layerState.directionRoot = null;
|
||||||
|
layerState.compassRing = null;
|
||||||
|
layerState.compassLabels = [];
|
||||||
|
layerState.compassHeading = null;
|
||||||
|
layerState.directionMarkers = [];
|
||||||
|
layerState.panelMarkup = '';
|
||||||
|
layerState.subjectListener = null;
|
||||||
|
layerState.contextListener = null;
|
||||||
|
layerState.clearListener = null;
|
||||||
|
layerState.subjectClearListener = null;
|
||||||
|
layerState.viewer = null;
|
||||||
|
layerState.dataManager = null;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
cameraMotionSignature,
|
||||||
|
syncAwarenessRenderHold,
|
||||||
|
attachRuntimeListeners,
|
||||||
|
detachRuntimeListeners,
|
||||||
|
methods,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,251 @@
|
|||||||
|
import {
|
||||||
|
AWARENESS_MOTION_REFRESH_MS,
|
||||||
|
AWARENESS_REFRESH_MS,
|
||||||
|
AWARENESS_MOTION_SETTLE_MS,
|
||||||
|
AWARENESS_REEVALUATE_DISTANCE_M,
|
||||||
|
} from './policy.js';
|
||||||
|
|
||||||
|
export function createModel({ state: layerState, services, parts, source }) {
|
||||||
|
const flightsLayer = services.flights;
|
||||||
|
const militaryFlightsLayer = services.military;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether focusing a Context target may take ownership of the camera.
|
||||||
|
* Cockpit owns the camera at 20 Hz, so non-aircraft targets remain selectable
|
||||||
|
* there but must not start a competing flyTo animation.
|
||||||
|
*
|
||||||
|
* @param {string} layerId Context target layer id.
|
||||||
|
* @param {HTMLElement|null} [body=document.body] Document body to inspect.
|
||||||
|
* @returns {boolean} Whether the target may start a camera flight.
|
||||||
|
*/
|
||||||
|
|
||||||
|
function contextTargetFlyToAllowed(
|
||||||
|
layerId,
|
||||||
|
body = globalThis.document?.body,
|
||||||
|
) {
|
||||||
|
const nonAircraft =
|
||||||
|
layerId === 'ais-live-vessels' || layerId === 'military-installations';
|
||||||
|
return !nonAircraft || !body?.classList?.contains('cockpit-mode');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Refresh cadence for the Contacts direction arrows and card readouts.
|
||||||
|
*
|
||||||
|
* A MOVING camera earns the snappy cadence; a parked one keeps the cheap 750 ms
|
||||||
|
* one. This is safe for requestRenderMode because the cadence is applied inside
|
||||||
|
* `scene.preRender`: a faster interval can only ever consume frames the camera
|
||||||
|
* movement is ALREADY forcing. A parked scene renders no frames at all, so no
|
||||||
|
* extra refresh happens and no timer is introduced — the render governor and
|
||||||
|
* the idle-leak fix both depend on parked staying quiet.
|
||||||
|
* @param {boolean} cameraMoving Whether the camera pose changed since the last frame.
|
||||||
|
* @returns {number} Minimum ms between refreshes.
|
||||||
|
*/
|
||||||
|
|
||||||
|
function awarenessRefreshIntervalMs(cameraMoving) {
|
||||||
|
return cameraMoving ? AWARENESS_MOTION_REFRESH_MS : AWARENESS_REFRESH_MS;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Decide, for one rendered frame, whether the view counts as MOVING and whether
|
||||||
|
* the Contacts readout may refresh.
|
||||||
|
*
|
||||||
|
* Motion is HYSTERETIC. The pose signature is quantized (~10 m / ~0.06°), so a
|
||||||
|
* slow drag crosses a bin only every few frames: treating a single unchanged
|
||||||
|
* frame as "parked" made every crossing look like a motion-end and refreshed at
|
||||||
|
* nearly display rate. Motion therefore ends only after the pose has been
|
||||||
|
* unchanged for AWARENESS_MOTION_SETTLE_MS, and the motion cadence is a HARD
|
||||||
|
* floor in every state — no bin-crossing pattern, and no motion-end settle, can
|
||||||
|
* refresh faster than AWARENESS_MOTION_REFRESH_MS.
|
||||||
|
*
|
||||||
|
* @param {object} input
|
||||||
|
* @param {number} input.nowMs This frame's clock.
|
||||||
|
* @param {number} input.lastRefreshMs When the readout last refreshed.
|
||||||
|
* @param {number} input.lastPoseChangeMs When the camera pose last changed bins.
|
||||||
|
* @param {boolean} input.wasMoving Whether the previous frame counted as moving.
|
||||||
|
* @returns {{moving: boolean, refresh: boolean}}
|
||||||
|
*/
|
||||||
|
|
||||||
|
function awarenessRefreshDecision({
|
||||||
|
nowMs,
|
||||||
|
lastRefreshMs,
|
||||||
|
lastPoseChangeMs,
|
||||||
|
wasMoving,
|
||||||
|
}) {
|
||||||
|
const moving = nowMs - lastPoseChangeMs < AWARENESS_MOTION_SETTLE_MS;
|
||||||
|
// The single frame where hysteresis expires: settle the readout at rest
|
||||||
|
// instead of leaving it up to a parked interval stale.
|
||||||
|
const settling = Boolean(wasMoving) && !moving;
|
||||||
|
const sinceRefresh = nowMs - lastRefreshMs;
|
||||||
|
const refresh =
|
||||||
|
sinceRefresh >= awarenessRefreshIntervalMs(true) &&
|
||||||
|
(moving || settling || sinceRefresh >= awarenessRefreshIntervalMs(false));
|
||||||
|
return { moving, refresh };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Decide whether a source-scoped clear was an eviction rather than a deselect.
|
||||||
|
*
|
||||||
|
* Both arrive on the same events: the owning layer calls the same teardown for
|
||||||
|
* "the user clicked away" and "this contact aged out of the feed". Only the
|
||||||
|
* origin separates them, and it matters — a deliberate clear takes the Contact
|
||||||
|
* panel down, while an eviction must keep it up in its CONTACT LOST state, or
|
||||||
|
* the panel disappears out from under its own PREVIOUS/NEXT controls.
|
||||||
|
* @param {{reason?: string}|null} cleared Cleared source detail.
|
||||||
|
* @returns {boolean} Whether the subject left the feed on its own.
|
||||||
|
*/
|
||||||
|
|
||||||
|
function awarenessClearIsEviction(cleared) {
|
||||||
|
return cleared?.reason === 'evicted';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Decide whether proximity cohorts need an expensive rescan.
|
||||||
|
* @param {object} input Refresh evidence.
|
||||||
|
* @param {boolean} input.force Explicit invalidation.
|
||||||
|
* @param {boolean} input.hasResults Whether a prior evaluation exists.
|
||||||
|
* @param {number} input.movementM Subject displacement since evaluation.
|
||||||
|
* @param {boolean} input.sourceRevisionChanged Whether any source state changed.
|
||||||
|
* @returns {boolean} Whether cohorts should be evaluated again.
|
||||||
|
*/
|
||||||
|
|
||||||
|
function awarenessRefreshRequired({
|
||||||
|
force,
|
||||||
|
hasResults,
|
||||||
|
movementM,
|
||||||
|
sourceRevisionChanged,
|
||||||
|
}) {
|
||||||
|
return Boolean(
|
||||||
|
force ||
|
||||||
|
!hasResults ||
|
||||||
|
sourceRevisionChanged ||
|
||||||
|
!Number.isFinite(movementM) ||
|
||||||
|
movementM >= AWARENESS_REEVALUATE_DISTANCE_M,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The per-layer counts the Contacts panel is showing, as one flat block.
|
||||||
|
*
|
||||||
|
* Three honest numbers were reaching the operator at once: this cohort count
|
||||||
|
* (the panel), `analyst_query`'s count of CURRENTLY-LOADED records, and the
|
||||||
|
* layer-wide loaded total in the coverage note. After the camera dives to a
|
||||||
|
* tracked contact the flights layer reloads by viewport, so the loaded set can
|
||||||
|
* hold a fraction of the cohort — 8 against the panel's 42 in the field. The
|
||||||
|
* numbers are all correct and the disagreement still reads as chaos.
|
||||||
|
*
|
||||||
|
* Derived from the same snapshot the panel renders (`cohort.summary.count` via
|
||||||
|
* `buildAwarenessContextSnapshot`), so the two cannot drift apart. A cohort
|
||||||
|
* whose feed cannot answer reports 'unknown' rather than a misleading zero.
|
||||||
|
* @param {object|null} snapshot `getContextSnapshot()` result.
|
||||||
|
* @returns {{centeredOn: string|null, radiusKm: number|null, aircraft: number|string,
|
||||||
|
* flights: number|string, military: number|string, vessels: number|string}|null}
|
||||||
|
* Panel-equivalent counts.
|
||||||
|
*/
|
||||||
|
|
||||||
|
function contactsWindowFromSnapshot(snapshot) {
|
||||||
|
if (!snapshot?.subject) return null;
|
||||||
|
const countFor = (cohortId) => {
|
||||||
|
const cohort = Array.isArray(snapshot.cohorts)
|
||||||
|
? snapshot.cohorts.find((item) => item?.id === cohortId)
|
||||||
|
: null;
|
||||||
|
return Number.isFinite(cohort?.count) ? cohort.count : 'unknown';
|
||||||
|
};
|
||||||
|
const flights = countFor('flights');
|
||||||
|
const military = countFor('military');
|
||||||
|
return {
|
||||||
|
centeredOn: snapshot.subject.label || snapshot.subject.id || null,
|
||||||
|
radiusKm: Number.isFinite(snapshot.radiusM)
|
||||||
|
? Math.round(snapshot.radiusM / 1000)
|
||||||
|
: null,
|
||||||
|
aircraft:
|
||||||
|
Number.isFinite(flights) && Number.isFinite(military)
|
||||||
|
? flights + military
|
||||||
|
: 'unknown',
|
||||||
|
flights,
|
||||||
|
military,
|
||||||
|
vessels: countFor('ais-live-vessels'),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Build the read-only Awareness snapshot shared with compact HUD consumers. */
|
||||||
|
|
||||||
|
function buildAwarenessContextSnapshot(
|
||||||
|
results,
|
||||||
|
navigation = {},
|
||||||
|
{ subjectPresent = true } = {},
|
||||||
|
) {
|
||||||
|
if (!results) return null;
|
||||||
|
return {
|
||||||
|
subject: { ...results.subject },
|
||||||
|
// Whether the subject is still reported by its source. The cockpit Contact
|
||||||
|
// readout holds its last-known values behind a CONTACT LOST cue when this
|
||||||
|
// is false, rather than presenting frozen geometry as a live reading.
|
||||||
|
subjectPresent: subjectPresent !== false,
|
||||||
|
evaluatedAt: results.evaluatedAt,
|
||||||
|
radiusM: results.radiusM,
|
||||||
|
cohorts: results.cohorts.map((cohort) => ({
|
||||||
|
id: cohort.id,
|
||||||
|
label: cohort.label,
|
||||||
|
source: cohort.source,
|
||||||
|
coverage: cohort.coverage || null,
|
||||||
|
relationship: cohort.summary.relationship,
|
||||||
|
count: cohort.summary.count,
|
||||||
|
reason: cohort.summary.reason,
|
||||||
|
nearest: cohort.summary.nearest.slice(),
|
||||||
|
})),
|
||||||
|
navigation: { ...navigation },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function navigationState() {
|
||||||
|
return {
|
||||||
|
canPrevious: layerState.navigationIndex > 0,
|
||||||
|
canFocus: Boolean(layerState.subject),
|
||||||
|
canNext: parts.navigation.canNavigateNext(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether Contacts genuinely has per-frame work right now.
|
||||||
|
*
|
||||||
|
* The direction arrows are screen-projected against the camera basis, so they
|
||||||
|
* must be redrawn every frame while the view MOVES. Parked, or with no live
|
||||||
|
* cohort to point at — nothing selected, every feed empty, every feed failed —
|
||||||
|
* there is nothing to animate and Contacts must not be the reason the whole
|
||||||
|
* scene keeps repainting.
|
||||||
|
* @param {object} [snapshot] Explicit state, for tests.
|
||||||
|
* @returns {boolean}
|
||||||
|
*/
|
||||||
|
|
||||||
|
function awarenessNeedsContinuousRender({
|
||||||
|
cameraMoving = layerState.cameraMoving,
|
||||||
|
hasSubject = Boolean(layerState.subject),
|
||||||
|
hasLiveResults = parts.panel.awarenessResultsAreLive(layerState.results),
|
||||||
|
} = {}) {
|
||||||
|
return Boolean(cameraMoving && hasSubject && hasLiveResults);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Release any aircraft-owned follow camera before a non-aircraft context
|
||||||
|
* selection takes ownership. Both layers are safe no-ops when idle and release
|
||||||
|
* Cesium tracking in place, so the subsequent vessel/site framing starts from
|
||||||
|
* the current view without the previous aircraft continuing to drag it.
|
||||||
|
*/
|
||||||
|
|
||||||
|
function releaseAircraftTracking() {
|
||||||
|
flightsLayer.stopTracking?.();
|
||||||
|
militaryFlightsLayer.stopTracking?.();
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
contextTargetFlyToAllowed,
|
||||||
|
awarenessRefreshIntervalMs,
|
||||||
|
awarenessRefreshDecision,
|
||||||
|
awarenessClearIsEviction,
|
||||||
|
awarenessRefreshRequired,
|
||||||
|
contactsWindowFromSnapshot,
|
||||||
|
buildAwarenessContextSnapshot,
|
||||||
|
navigationState,
|
||||||
|
awarenessNeedsContinuousRender,
|
||||||
|
releaseAircraftTracking,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,274 @@
|
|||||||
|
import {
|
||||||
|
summarizeAwarenessCohort,
|
||||||
|
getAwarenessNavigationTargets,
|
||||||
|
AWARENESS_MAX_FLIGHT_SEARCH_RADIUS_M,
|
||||||
|
findByDoublingRadius,
|
||||||
|
AWARENESS_RADIUS_M,
|
||||||
|
} from '../../data/militaryAwarenessEngine.js';
|
||||||
|
import {
|
||||||
|
AWARENESS_MAX_EXAMPLES,
|
||||||
|
AWARENESS_MAX_NAVIGATION_EXAMPLES,
|
||||||
|
} from './policy.js';
|
||||||
|
|
||||||
|
export function createNavigation({
|
||||||
|
state: layerState,
|
||||||
|
services,
|
||||||
|
parts,
|
||||||
|
source,
|
||||||
|
}) {
|
||||||
|
const flightsLayer = services.flights;
|
||||||
|
const militaryFlightsLayer = services.military;
|
||||||
|
|
||||||
|
function summarizeAwarenessCohortForNavigation(
|
||||||
|
items,
|
||||||
|
source,
|
||||||
|
{
|
||||||
|
displayLimit = AWARENESS_MAX_EXAMPLES,
|
||||||
|
navigationLimit = AWARENESS_MAX_NAVIGATION_EXAMPLES,
|
||||||
|
} = {},
|
||||||
|
) {
|
||||||
|
const summary = summarizeAwarenessCohort(items, {
|
||||||
|
...source,
|
||||||
|
limit: navigationLimit,
|
||||||
|
});
|
||||||
|
if (summary.count === null) return summary;
|
||||||
|
return {
|
||||||
|
...summary,
|
||||||
|
nearest: summary.nearest.slice(0, displayLimit),
|
||||||
|
navigationNearest: summary.nearest,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function resetNavigationVisitedCycle() {
|
||||||
|
layerState.navigationVisited.clear();
|
||||||
|
const currentKey = parts.subject.subjectKey(layerState.subject);
|
||||||
|
if (currentKey) layerState.navigationVisited.add(currentKey);
|
||||||
|
}
|
||||||
|
|
||||||
|
function isFlightLayer(layerId) {
|
||||||
|
return layerId === 'flights' || layerId === 'military';
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeAircraftClass(value) {
|
||||||
|
return String(value || '')
|
||||||
|
.trim()
|
||||||
|
.toLowerCase();
|
||||||
|
}
|
||||||
|
|
||||||
|
function aircraftClassMatchesFilter(item, aircraftClass) {
|
||||||
|
const requested = normalizeAircraftClass(aircraftClass);
|
||||||
|
if (!requested) return true;
|
||||||
|
const candidates = [
|
||||||
|
item?.aircraftClass,
|
||||||
|
item?.klass,
|
||||||
|
item?.type,
|
||||||
|
item?.typeCode,
|
||||||
|
item?.typeName,
|
||||||
|
]
|
||||||
|
.map((value) => normalizeAircraftClass(value))
|
||||||
|
.filter(Boolean);
|
||||||
|
if (!candidates.length) return false;
|
||||||
|
return candidates.some(
|
||||||
|
(candidate) => candidate === requested || candidate.includes(requested),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function selectNavigationTargets(
|
||||||
|
sourceCohorts,
|
||||||
|
subject,
|
||||||
|
visitedKeys,
|
||||||
|
{ targetLayer = null, aircraftClass = null, aircraftOnly = false } = {},
|
||||||
|
) {
|
||||||
|
let targets = getAwarenessNavigationTargets(
|
||||||
|
sourceCohorts,
|
||||||
|
subject,
|
||||||
|
visitedKeys,
|
||||||
|
);
|
||||||
|
if (aircraftOnly) {
|
||||||
|
targets = targets.filter((target) => isFlightLayer(target.layerId));
|
||||||
|
}
|
||||||
|
if (targetLayer) {
|
||||||
|
targets = targets.filter((target) => target.layerId === targetLayer);
|
||||||
|
}
|
||||||
|
if (aircraftClass) {
|
||||||
|
targets = targets.filter((target) =>
|
||||||
|
aircraftClassMatchesFilter(target.item, aircraftClass),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return targets;
|
||||||
|
}
|
||||||
|
|
||||||
|
function alternativeFlightAvailable({
|
||||||
|
targetLayer = null,
|
||||||
|
aircraftClass = null,
|
||||||
|
} = {}) {
|
||||||
|
if (
|
||||||
|
!isFlightLayer(layerState.subject?.layerId) ||
|
||||||
|
!layerState.subject?.position
|
||||||
|
)
|
||||||
|
return false;
|
||||||
|
if (targetLayer && !isFlightLayer(targetLayer)) return false;
|
||||||
|
|
||||||
|
const flights =
|
||||||
|
targetLayer === 'military'
|
||||||
|
? []
|
||||||
|
: flightsLayer
|
||||||
|
.getNearby(
|
||||||
|
layerState.subject.position,
|
||||||
|
AWARENESS_MAX_FLIGHT_SEARCH_RADIUS_M,
|
||||||
|
2,
|
||||||
|
{ includeHidden: true },
|
||||||
|
)
|
||||||
|
.filter((item) => aircraftClassMatchesFilter(item, aircraftClass));
|
||||||
|
|
||||||
|
const military =
|
||||||
|
targetLayer === 'flights'
|
||||||
|
? []
|
||||||
|
: militaryFlightsLayer
|
||||||
|
.getNearby(
|
||||||
|
layerState.subject.position,
|
||||||
|
AWARENESS_MAX_FLIGHT_SEARCH_RADIUS_M,
|
||||||
|
2,
|
||||||
|
{ includeHidden: true },
|
||||||
|
)
|
||||||
|
.filter((item) => aircraftClassMatchesFilter(item, aircraftClass));
|
||||||
|
|
||||||
|
return [
|
||||||
|
['flights', flights],
|
||||||
|
['military', military],
|
||||||
|
].some(([layerId, items]) =>
|
||||||
|
items.some(
|
||||||
|
(item) =>
|
||||||
|
`${layerId}:${item.icao24 || item.id}` !==
|
||||||
|
parts.subject.subjectKey(layerState.subject),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function closestFlightWithinRadius(
|
||||||
|
radiusM,
|
||||||
|
visitedKeys,
|
||||||
|
excludeVisited,
|
||||||
|
{ targetLayer = null, aircraftClass = null } = {},
|
||||||
|
) {
|
||||||
|
if (!layerState.subject?.position) return null;
|
||||||
|
const visited = new Set(visitedKeys);
|
||||||
|
if (targetLayer && !isFlightLayer(targetLayer)) return null;
|
||||||
|
const candidates = [
|
||||||
|
...(targetLayer === 'military'
|
||||||
|
? []
|
||||||
|
: flightsLayer
|
||||||
|
.getNearby(layerState.subject.position, radiusM, 25000, {
|
||||||
|
includeHidden: true,
|
||||||
|
})
|
||||||
|
.filter((item) => aircraftClassMatchesFilter(item, aircraftClass))
|
||||||
|
.map((item) => ({
|
||||||
|
layerId: 'flights',
|
||||||
|
id: String(item.icao24),
|
||||||
|
item,
|
||||||
|
}))),
|
||||||
|
...(targetLayer === 'flights'
|
||||||
|
? []
|
||||||
|
: militaryFlightsLayer
|
||||||
|
.getNearby(layerState.subject.position, radiusM, 5000, {
|
||||||
|
includeHidden: true,
|
||||||
|
})
|
||||||
|
.filter((item) => aircraftClassMatchesFilter(item, aircraftClass))
|
||||||
|
.map((item) => ({
|
||||||
|
layerId: 'military',
|
||||||
|
id: String(item.icao24),
|
||||||
|
item,
|
||||||
|
}))),
|
||||||
|
].filter((target) => {
|
||||||
|
const key = `${target.layerId}:${target.id}`;
|
||||||
|
if (key === parts.subject.subjectKey(layerState.subject)) return false;
|
||||||
|
return !excludeVisited || !visited.has(key);
|
||||||
|
});
|
||||||
|
candidates.sort(
|
||||||
|
(a, b) =>
|
||||||
|
(a.item.distanceM ?? a.item.distance ?? Infinity) -
|
||||||
|
(b.item.distanceM ?? b.item.distance ?? Infinity),
|
||||||
|
);
|
||||||
|
return candidates[0] || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function findExpandedFlightTarget(options = {}) {
|
||||||
|
if (!isFlightLayer(layerState.subject?.layerId)) return null;
|
||||||
|
let visitedKeys = [...layerState.navigationVisited];
|
||||||
|
const search = () =>
|
||||||
|
findByDoublingRadius(
|
||||||
|
(radiusM) =>
|
||||||
|
closestFlightWithinRadius(radiusM, visitedKeys, true, options),
|
||||||
|
{
|
||||||
|
initialRadiusM: AWARENESS_RADIUS_M,
|
||||||
|
maxRadiusM: AWARENESS_MAX_FLIGHT_SEARCH_RADIUS_M,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
const target = search();
|
||||||
|
if (target) return target;
|
||||||
|
resetNavigationVisitedCycle();
|
||||||
|
visitedKeys = [...layerState.navigationVisited];
|
||||||
|
return search();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolves NEXT availability from the same three branches used by navigation.
|
||||||
|
* Forward history remains usable while a feed is unavailable; discovering a
|
||||||
|
* new nearby or expanded target requires a known subject cohort.
|
||||||
|
* @param {Object} availability Navigation branch availability.
|
||||||
|
* @returns {boolean} Whether NEXT has a path that can run.
|
||||||
|
*/
|
||||||
|
|
||||||
|
function canNavigateAwarenessNext({
|
||||||
|
hasForwardHistory = false,
|
||||||
|
hasExpandedFlightTarget = false,
|
||||||
|
hasNearbyTarget = false,
|
||||||
|
subjectCohortUnknown = false,
|
||||||
|
} = {}) {
|
||||||
|
return Boolean(
|
||||||
|
hasForwardHistory ||
|
||||||
|
(!subjectCohortUnknown && (hasExpandedFlightTarget || hasNearbyTarget)),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function canNavigateNext({ targetLayer = null, aircraftClass = null } = {}) {
|
||||||
|
return canNavigateAwarenessNext({
|
||||||
|
hasForwardHistory:
|
||||||
|
parts.history.findCompatibleHistoryIndex(
|
||||||
|
layerState.navigationHistory,
|
||||||
|
layerState.navigationIndex,
|
||||||
|
1,
|
||||||
|
{
|
||||||
|
targetLayer,
|
||||||
|
aircraftClass,
|
||||||
|
resolveItem: parts.history.historySubjectItem,
|
||||||
|
},
|
||||||
|
) !== -1,
|
||||||
|
hasNearbyTarget:
|
||||||
|
selectNavigationTargets(
|
||||||
|
layerState.results?.cohorts,
|
||||||
|
layerState.subject,
|
||||||
|
[...layerState.navigationVisited],
|
||||||
|
{ targetLayer, aircraftClass },
|
||||||
|
).length > 0,
|
||||||
|
hasExpandedFlightTarget: alternativeFlightAvailable({
|
||||||
|
targetLayer,
|
||||||
|
aircraftClass,
|
||||||
|
}),
|
||||||
|
subjectCohortUnknown: parts.subject.subjectCohortFeedUnknown(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
summarizeAwarenessCohortForNavigation,
|
||||||
|
resetNavigationVisitedCycle,
|
||||||
|
isFlightLayer,
|
||||||
|
normalizeAircraftClass,
|
||||||
|
aircraftClassMatchesFilter,
|
||||||
|
selectNavigationTargets,
|
||||||
|
alternativeFlightAvailable,
|
||||||
|
closestFlightWithinRadius,
|
||||||
|
findExpandedFlightTarget,
|
||||||
|
canNavigateAwarenessNext,
|
||||||
|
canNavigateNext,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,249 @@
|
|||||||
|
import {
|
||||||
|
formatAwarenessLabel,
|
||||||
|
formatAwarenessDistance,
|
||||||
|
AWARENESS_RADIUS_M,
|
||||||
|
} from '../../data/militaryAwarenessEngine.js';
|
||||||
|
import { AWARENESS_PAGE_SIZE, AWARENESS_PAGE_ROTATE_MS } from './policy.js';
|
||||||
|
|
||||||
|
export function createPanel({ state: layerState, services, parts, source }) {
|
||||||
|
function ensurePanel() {
|
||||||
|
if (layerState.panel) return layerState.panel;
|
||||||
|
const existing = document.getElementById('military-awareness-panel');
|
||||||
|
const panel = existing || document.createElement('aside');
|
||||||
|
layerState.panelOwned = !existing;
|
||||||
|
if (!existing) {
|
||||||
|
panel.id = 'military-awareness-panel';
|
||||||
|
}
|
||||||
|
layerState.panelClickListener = (event) => {
|
||||||
|
const action = event.target.closest('button[data-awareness-action]');
|
||||||
|
if (action) {
|
||||||
|
event.preventDefault();
|
||||||
|
if (action.dataset.awarenessAction === 'previous')
|
||||||
|
parts.history.navigateHistory(-1, { origin: 'user' });
|
||||||
|
if (action.dataset.awarenessAction === 'next')
|
||||||
|
parts.history.navigateHistory(1, { origin: 'user' });
|
||||||
|
if (action.dataset.awarenessAction === 'focus')
|
||||||
|
parts.focus.focusCurrentSubject({ origin: 'user' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const target = event.target.closest(
|
||||||
|
'button[data-awareness-layer][data-awareness-id]',
|
||||||
|
);
|
||||||
|
if (!target) return;
|
||||||
|
event.preventDefault();
|
||||||
|
parts.focus.requestFocus(
|
||||||
|
target.dataset.awarenessLayer,
|
||||||
|
target.dataset.awarenessId,
|
||||||
|
false,
|
||||||
|
{ origin: 'user' },
|
||||||
|
);
|
||||||
|
};
|
||||||
|
panel.addEventListener('click', layerState.panelClickListener);
|
||||||
|
if (!existing) document.body.appendChild(panel);
|
||||||
|
layerState.panel = panel;
|
||||||
|
return panel;
|
||||||
|
}
|
||||||
|
|
||||||
|
function hidePanel() {
|
||||||
|
if (layerState.panel) {
|
||||||
|
const markup = `<div class="military-awareness-standby">
|
||||||
|
<strong>${layerState.enabled ? 'CONTEXT READY' : 'GLOBAL CONTEXT OFF'}</strong>
|
||||||
|
<span>${layerState.enabled ? 'SELECT A FLIGHT, VESSEL, OR MAPPED INSTALLATION' : 'ENABLE TO LOAD OBSERVED / MAPPED PROXIMITY'}</span>
|
||||||
|
</div>`;
|
||||||
|
layerState.panel.hidden = false;
|
||||||
|
if (layerState.panelMarkup !== markup) {
|
||||||
|
layerState.panel.innerHTML = markup;
|
||||||
|
layerState.panelMarkup = markup;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (layerState.directionRoot) layerState.directionRoot.hidden = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function rowHtml(cohort) {
|
||||||
|
const summary = cohort.summary;
|
||||||
|
const count = summary.count === null ? '?' : String(summary.count);
|
||||||
|
const page = layerState.cohortPages.get(cohort.id) || 0;
|
||||||
|
const nearest = summary.nearest
|
||||||
|
.slice(page, page + AWARENESS_PAGE_SIZE)
|
||||||
|
.map((item) => {
|
||||||
|
const label = formatAwarenessLabel(item);
|
||||||
|
const targetId = item.icao24 || item.mmsi || item.id;
|
||||||
|
if (!targetId) {
|
||||||
|
return `<li><span class="military-awareness-target unavailable" aria-label="Unavailable">${escapeHtml(label)} <span>${formatAwarenessDistance(item.distanceM)}</span></span></li>`;
|
||||||
|
}
|
||||||
|
const accessibleLabel = label === '—' ? 'Unavailable' : label;
|
||||||
|
return `<li><button type="button" class="military-awareness-target" data-awareness-layer="${escapeHtml(cohort.id)}" data-awareness-id="${escapeHtml(targetId)}" aria-label="Focus ${escapeHtml(accessibleLabel)}">${escapeHtml(label)} <span>${formatAwarenessDistance(item.distanceM)}</span></button></li>`;
|
||||||
|
})
|
||||||
|
.join('');
|
||||||
|
const pageCount = Math.max(
|
||||||
|
1,
|
||||||
|
Math.ceil(summary.nearest.length / AWARENESS_PAGE_SIZE),
|
||||||
|
);
|
||||||
|
const pageLabel =
|
||||||
|
pageCount > 1
|
||||||
|
? ` · ${Math.floor(page / AWARENESS_PAGE_SIZE) + 1}/${pageCount}`
|
||||||
|
: '';
|
||||||
|
const coverage = cohort.coverage ? ` · ${escapeHtml(cohort.coverage)}` : '';
|
||||||
|
return `<section class="military-awareness-row ${summary.relationship.toLowerCase()}">
|
||||||
|
<div><strong>${escapeHtml(cohort.label)}</strong><b aria-live="polite">${count}${pageLabel}</b></div>
|
||||||
|
<small>${escapeHtml(cohort.source)}${coverage} · ${escapeHtml(summary.reason)}</small>
|
||||||
|
${nearest ? `<ul>${nearest}</ul>` : ''}
|
||||||
|
</section>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function navigationControlsHtml() {
|
||||||
|
const canPrevious = layerState.navigationIndex > 0;
|
||||||
|
return `<div class="military-awareness-controls" role="group" aria-label="Global Context navigation">
|
||||||
|
<button type="button" data-awareness-action="previous" title="Previous — prior visited contact in the 250 km window"${canPrevious ? '' : ' disabled'}>PREVIOUS</button>
|
||||||
|
<button type="button" data-awareness-action="focus">FOCUS</button>
|
||||||
|
<button type="button" data-awareness-action="next" title="Next — nearest unvisited contact in the 250 km window"${parts.navigation.canNavigateNext() ? '' : ' disabled'}>NEXT</button>
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Stable identity for a delegated Contacts-panel button across live repaints. */
|
||||||
|
|
||||||
|
function awarenessPanelControlKey(element) {
|
||||||
|
const control = element?.closest?.(
|
||||||
|
'button[data-awareness-action], button[data-awareness-layer][data-awareness-id]',
|
||||||
|
);
|
||||||
|
if (!control) return null;
|
||||||
|
if (control.dataset.awarenessAction)
|
||||||
|
return `action:${control.dataset.awarenessAction}`;
|
||||||
|
return `target:${control.dataset.awarenessLayer}:${control.dataset.awarenessId}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Capture stable identity so a live repaint can restore only the same control. */
|
||||||
|
|
||||||
|
function captureAwarenessPanelFocus(
|
||||||
|
panel,
|
||||||
|
activeElement = document.activeElement,
|
||||||
|
) {
|
||||||
|
if (!panel?.contains?.(activeElement)) return null;
|
||||||
|
if (activeElement?.matches?.('[data-awareness-focus-continuation]')) {
|
||||||
|
return { key: 'continuation' };
|
||||||
|
}
|
||||||
|
const key = awarenessPanelControlKey(activeElement);
|
||||||
|
if (!key) return null;
|
||||||
|
return { key };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Restore the same control, or continue beyond the list when it is no longer rendered. */
|
||||||
|
|
||||||
|
function restoreAwarenessPanelFocus(panel, snapshot) {
|
||||||
|
if (!panel || !snapshot) return null;
|
||||||
|
const continuation = panel.querySelector(
|
||||||
|
'[data-awareness-focus-continuation]',
|
||||||
|
);
|
||||||
|
const controls = [
|
||||||
|
...panel.querySelectorAll(
|
||||||
|
'button[data-awareness-action], button[data-awareness-layer][data-awareness-id]',
|
||||||
|
),
|
||||||
|
].filter((control) => !control.disabled);
|
||||||
|
const retained =
|
||||||
|
snapshot.key === 'continuation'
|
||||||
|
? continuation
|
||||||
|
: controls.find(
|
||||||
|
(control) => awarenessPanelControlKey(control) === snapshot.key,
|
||||||
|
);
|
||||||
|
const target = retained || continuation;
|
||||||
|
target?.focus?.({ preventScroll: true });
|
||||||
|
return target || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function escapeHtml(value) {
|
||||||
|
return String(value ?? '').replace(
|
||||||
|
/[&<>'"]/g,
|
||||||
|
(char) =>
|
||||||
|
({
|
||||||
|
'&': '&',
|
||||||
|
'<': '<',
|
||||||
|
'>': '>',
|
||||||
|
"'": ''',
|
||||||
|
'"': '"',
|
||||||
|
})[char],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderResults() {
|
||||||
|
if (!layerState.enabled || !layerState.results) return hidePanel();
|
||||||
|
const panel = ensurePanel();
|
||||||
|
const { subject, cohorts } = layerState.results;
|
||||||
|
const markup = `<div class="military-awareness-subject">${escapeHtml(subject.label)} · ${formatAwarenessDistance(AWARENESS_RADIUS_M)} FLIGHT / VESSEL WINDOW</div>
|
||||||
|
${navigationControlsHtml()}
|
||||||
|
${cohorts.map(rowHtml).join('')}
|
||||||
|
<p class="military-awareness-note" tabindex="-1" data-awareness-focus-continuation>Open-source mapped/observed context. Missing broadcasts, unloaded map areas, or unmapped sites are not evidence of absence.</p>`;
|
||||||
|
panel.hidden = false;
|
||||||
|
if (layerState.panelMarkup !== markup) {
|
||||||
|
const focusSnapshot = captureAwarenessPanelFocus(panel);
|
||||||
|
panel.innerHTML = markup;
|
||||||
|
layerState.panelMarkup = markup;
|
||||||
|
restoreAwarenessPanelFocus(panel, focusSnapshot);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function rotateAwarenessPages() {
|
||||||
|
if (!layerState.enabled || !layerState.results) return;
|
||||||
|
let changed = false;
|
||||||
|
for (const cohort of layerState.results.cohorts) {
|
||||||
|
const pageCount = Math.max(
|
||||||
|
1,
|
||||||
|
Math.ceil(cohort.summary.nearest.length / AWARENESS_PAGE_SIZE),
|
||||||
|
);
|
||||||
|
if (pageCount <= 1) continue;
|
||||||
|
const current = layerState.cohortPages.get(cohort.id) || 0;
|
||||||
|
layerState.cohortPages.set(
|
||||||
|
cohort.id,
|
||||||
|
((Math.floor(current / AWARENESS_PAGE_SIZE) + 1) % pageCount) *
|
||||||
|
AWARENESS_PAGE_SIZE,
|
||||||
|
);
|
||||||
|
changed = true;
|
||||||
|
}
|
||||||
|
if (changed) {
|
||||||
|
renderResults();
|
||||||
|
parts.rendering.scheduleDirectionOverlayUpdate(true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function startAwarenessPageRotation() {
|
||||||
|
if (!layerState.pageTimer)
|
||||||
|
layerState.pageTimer = window.setInterval(
|
||||||
|
rotateAwarenessPages,
|
||||||
|
AWARENESS_PAGE_ROTATE_MS,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function stopAwarenessPageRotation() {
|
||||||
|
if (layerState.pageTimer) window.clearInterval(layerState.pageTimer);
|
||||||
|
layerState.pageTimer = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether an evaluation actually has a contact to point an arrow at.
|
||||||
|
*
|
||||||
|
* A cohort reports `count: null` when its feed is unavailable or stale, and `0`
|
||||||
|
* when the feed is healthy but empty. Only a positive count puts a marker on
|
||||||
|
* the compass rim, so anything else means there is nothing to animate.
|
||||||
|
* @param {?{cohorts?: Array<{summary?: {count: ?number}}>}} results
|
||||||
|
* @returns {boolean}
|
||||||
|
*/
|
||||||
|
|
||||||
|
function awarenessResultsAreLive(results) {
|
||||||
|
const cohorts = Array.isArray(results?.cohorts) ? results.cohorts : [];
|
||||||
|
return cohorts.some((cohort) => Number(cohort?.summary?.count) > 0);
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
ensurePanel,
|
||||||
|
hidePanel,
|
||||||
|
rowHtml,
|
||||||
|
navigationControlsHtml,
|
||||||
|
awarenessPanelControlKey,
|
||||||
|
captureAwarenessPanelFocus,
|
||||||
|
restoreAwarenessPanelFocus,
|
||||||
|
escapeHtml,
|
||||||
|
renderResults,
|
||||||
|
rotateAwarenessPages,
|
||||||
|
startAwarenessPageRotation,
|
||||||
|
stopAwarenessPageRotation,
|
||||||
|
awarenessResultsAreLive,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
export const AIRCRAFT_DEPENDENCIES = ['flights', 'military'];
|
||||||
|
|
||||||
|
export const DEFERRED_DEPENDENCIES = [
|
||||||
|
'ais-live-vessels',
|
||||||
|
'military-installations',
|
||||||
|
];
|
||||||
|
|
||||||
|
export const DEPENDENCIES = [
|
||||||
|
...AIRCRAFT_DEPENDENCIES,
|
||||||
|
...DEFERRED_DEPENDENCIES,
|
||||||
|
];
|
||||||
|
|
||||||
|
export const AWARENESS_REFRESH_MS = 750;
|
||||||
|
|
||||||
|
/** @constant {number} Refresh cadence while the camera pose is CHANGING.
|
||||||
|
* Owner playtest 2026-08-18: the Contacts direction arrows and card readouts
|
||||||
|
* "feel sluggish when you look around" — at the parked 750 ms cadence the
|
||||||
|
* arrows lag the view by up to three quarters of a second. */
|
||||||
|
|
||||||
|
export const AWARENESS_MOTION_REFRESH_MS = 175;
|
||||||
|
|
||||||
|
/** @constant {number} How long the pose signature must stay UNCHANGED before
|
||||||
|
* the view counts as parked again. The signature is quantized, so a slow drag
|
||||||
|
* crosses a bin only every few frames; without this hysteresis every crossing
|
||||||
|
* read as a motion-end and refreshed at nearly display rate. */
|
||||||
|
|
||||||
|
export const AWARENESS_MOTION_SETTLE_MS = 250;
|
||||||
|
|
||||||
|
export const AWARENESS_REEVALUATE_DISTANCE_M = 250;
|
||||||
|
|
||||||
|
export const AWARENESS_PAGE_ROTATE_MS = 10000;
|
||||||
|
|
||||||
|
export const AWARENESS_PAGE_SIZE = 3;
|
||||||
|
|
||||||
|
export const AWARENESS_MAX_EXAMPLES = 10;
|
||||||
|
|
||||||
|
// Navigation walks the whole 250 km cohort, not the ten rows the panel shows,
|
||||||
|
// so these caps sit far above any realistic in-range population rather than at
|
||||||
|
// the display limit. They are still FINITE: an unbounded materialization would
|
||||||
|
// let a pathological feed sort an eleven-thousand-contact array on every
|
||||||
|
// refresh. Truthfulness does not depend on them — `summarizeAwarenessCohort`
|
||||||
|
// derives `count` from the full in-range set before any slice.
|
||||||
|
|
||||||
|
export const AWARENESS_QUERY_LIMIT = 20000;
|
||||||
|
|
||||||
|
export const AWARENESS_MAX_NAVIGATION_EXAMPLES = 10000;
|
||||||
|
|
||||||
|
export const CONTEXT_RIM_HEIGHT_M = 2500;
|
||||||
|
|
||||||
|
export const VESSEL_FOCUS_RADIUS_M = 3000;
|
||||||
|
|
||||||
|
export const SOURCE_LABEL = {
|
||||||
|
flights: 'OpenSky',
|
||||||
|
military: 'adsb.lol',
|
||||||
|
'ais-live-vessels': 'AISStream',
|
||||||
|
'military-installations': 'OpenStreetMap',
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether a refresh tick could confirm the subject in its source collection.
|
||||||
|
* UNCHECKED means the tick did not look — it must never change the verdict.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export const SUBJECT_PRESENCE = Object.freeze({
|
||||||
|
LIVE: 'live',
|
||||||
|
MISSING: 'missing',
|
||||||
|
UNCHECKED: 'unchecked',
|
||||||
|
});
|
||||||
@@ -0,0 +1,164 @@
|
|||||||
|
import { AWARENESS_RADIUS_M } from '../../data/militaryAwarenessEngine.js';
|
||||||
|
import { DEPENDENCIES, AWARENESS_QUERY_LIMIT } from './policy.js';
|
||||||
|
|
||||||
|
export function createQueries({ state: layerState, services, parts, source }) {
|
||||||
|
const flightsLayer = services.flights;
|
||||||
|
const militaryFlightsLayer = services.military;
|
||||||
|
|
||||||
|
function sourceState(layerId) {
|
||||||
|
const lifecycle =
|
||||||
|
layerState.dataManager?.getLayerLifecycleState?.(layerId) || null;
|
||||||
|
const enabled =
|
||||||
|
lifecycle?.enabled === true ||
|
||||||
|
layerState.dataManager?.isEnabled(layerId) === true;
|
||||||
|
const enabling = lifecycle?.lifecycleState === 'enabling';
|
||||||
|
const moduleStats =
|
||||||
|
layerState.dataManager?.layers?.get(layerId)?.module?.getStats?.() || {};
|
||||||
|
const stats = enabling
|
||||||
|
? { ...moduleStats, loading: true, status: 'loading' }
|
||||||
|
: moduleStats;
|
||||||
|
// A deferred source that has started but not settled is not evidence of an
|
||||||
|
// empty cohort. Keep it explicitly unavailable so the panel cannot flash a
|
||||||
|
// false all-clear, even if a lifecycle adapter briefly reports the requested
|
||||||
|
// visibility intent as enabled.
|
||||||
|
//
|
||||||
|
// `enabling` and the never-answered predicate cover two DIFFERENT windows, and
|
||||||
|
// both are needed because these dependencies do not settle the same way.
|
||||||
|
//
|
||||||
|
// - military-installations is covered by `enabling` alone. Its enable() is
|
||||||
|
// SYNCHRONOUS and the manager awaits update(), which owns the first
|
||||||
|
// Overpass fetch, so the lifecycle stays `enabling` for that whole window
|
||||||
|
// however long the fetch takes. Confirmed live: a held 17 s first fetch
|
||||||
|
// read `enabling` across 34 samples with the panel non-numeric throughout,
|
||||||
|
// and a failing one settled to `enabled` with status 'unavailable'. Its
|
||||||
|
// getStats() also reports loading while a request is in progress —
|
||||||
|
// setInstallationStatus is only ever called with
|
||||||
|
// loading/zoom-in/ready/stale/empty/unavailable.
|
||||||
|
// - ais-live-vessels is what the predicate below is FOR. Its enable() and
|
||||||
|
// update() both resolve as soon as the first /api/ais-live poll answers,
|
||||||
|
// so the lifecycle settles to `enabled` — but until the server-side socket
|
||||||
|
// delivers a position, firstConnectPhase is 'loading' and getStats()
|
||||||
|
// reports loading: true, lastUpdate: null, count 0, and an UNDEFINED
|
||||||
|
// status. That zero is an absence of evidence, not evidence of absence,
|
||||||
|
// and neither the lifecycle nor the status list can tell.
|
||||||
|
//
|
||||||
|
// Deliberately `loading === true` and not truthiness: layers that never report
|
||||||
|
// a busy flag (flights, military) must keep answering for themselves, and a
|
||||||
|
// source that HAS answered once (lastUpdate set) keeps its last real count
|
||||||
|
// through every later poll rather than blanking to `?` on each refresh.
|
||||||
|
const neverAnswered = stats.loading === true && !stats.lastUpdate;
|
||||||
|
const unavailable =
|
||||||
|
enabling ||
|
||||||
|
!enabled ||
|
||||||
|
neverAnswered ||
|
||||||
|
['unavailable', 'zoom-in'].includes(stats.status) ||
|
||||||
|
Boolean(stats.error && stats.count === 0);
|
||||||
|
return { available: !unavailable, stale: Boolean(stats.stale), stats };
|
||||||
|
}
|
||||||
|
|
||||||
|
function collectSourceStates() {
|
||||||
|
return Object.fromEntries(
|
||||||
|
DEPENDENCIES.map((layerId) => [layerId, sourceState(layerId)]),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function sourceRevision(sourceStates) {
|
||||||
|
return DEPENDENCIES.map((layerId) => {
|
||||||
|
const source = sourceStates[layerId];
|
||||||
|
const stats = source?.stats || {};
|
||||||
|
return [
|
||||||
|
layerId,
|
||||||
|
source?.available,
|
||||||
|
source?.stale,
|
||||||
|
stats.lastUpdate || null,
|
||||||
|
stats.count ?? null,
|
||||||
|
stats.status || null,
|
||||||
|
stats.error || null,
|
||||||
|
];
|
||||||
|
})
|
||||||
|
.map((parts) => parts.join(':'))
|
||||||
|
.join('|');
|
||||||
|
}
|
||||||
|
|
||||||
|
function isSame(subject, item, prefix, key) {
|
||||||
|
return (
|
||||||
|
subject?.layerId === prefix && String(subject.id) === String(item?.[key])
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Summarize viewport-backed installations without implying radius-complete coverage.
|
||||||
|
* @param {Array<object>} items Loaded installation matches.
|
||||||
|
* @param {object} source Current source availability and staleness.
|
||||||
|
* @returns {object} Awareness summary with viewport-honest reasoning.
|
||||||
|
*/
|
||||||
|
|
||||||
|
function summarizeInstallationViewport(items, source) {
|
||||||
|
const summary = parts.navigation.summarizeAwarenessCohortForNavigation(
|
||||||
|
items,
|
||||||
|
source,
|
||||||
|
);
|
||||||
|
if (summary.count === null)
|
||||||
|
return source.stats?.statusMessage
|
||||||
|
? { ...summary, reason: source.stats.statusMessage }
|
||||||
|
: summary;
|
||||||
|
return {
|
||||||
|
...summary,
|
||||||
|
reason: summary.count
|
||||||
|
? 'mapped matches from the loaded viewport'
|
||||||
|
: 'viewport feed is not a complete 250 km survey',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* THE aircraft-proximity engine. One computation, two consumers: the Contacts
|
||||||
|
* panel window and the voice analyst's entity-centred "how many nearby".
|
||||||
|
*
|
||||||
|
* They used to be separate. The panel read live billboard positions through
|
||||||
|
* `getNearby` with a 20 000 cap; the analyst re-derived its own answer from
|
||||||
|
* last-fix coordinates over a 2 000-record slice. Same question, same centre,
|
||||||
|
* two numbers — and in the owner's trial the spoken answer (15) and the panel
|
||||||
|
* (111) disagreed badly enough that the model narrated the difference away.
|
||||||
|
* Routing both through here makes them the same number BY CONSTRUCTION, so
|
||||||
|
* they cannot drift again.
|
||||||
|
*
|
||||||
|
* The subject is excluded from its own window, which is why the panel reads
|
||||||
|
* "contacts around X" rather than "including X".
|
||||||
|
* @param {Cesium.Cartesian3} position Window centre.
|
||||||
|
* @param {object} [options]
|
||||||
|
* @param {number} [options.radiusM=AWARENESS_RADIUS_M] Window radius.
|
||||||
|
* @param {object|null} [options.subject=null] Contact at the centre, excluded.
|
||||||
|
* @returns {{flights: Array, military: Array, aircraft: number}|null} Cohorts
|
||||||
|
* plus the combined aircraft count, or null without a position.
|
||||||
|
*/
|
||||||
|
|
||||||
|
function collectAircraftProximityWindow(
|
||||||
|
position,
|
||||||
|
{ radiusM = AWARENESS_RADIUS_M, subject = null } = {},
|
||||||
|
) {
|
||||||
|
if (!position) return null;
|
||||||
|
const flights = flightsLayer
|
||||||
|
.getNearby(position, radiusM, AWARENESS_QUERY_LIMIT, {
|
||||||
|
includeHidden: true,
|
||||||
|
})
|
||||||
|
.filter(
|
||||||
|
(item) => !subject || !isSame(subject, item, 'flights', 'icao24'),
|
||||||
|
);
|
||||||
|
const military = militaryFlightsLayer
|
||||||
|
.getNearby(position, radiusM, AWARENESS_QUERY_LIMIT, {
|
||||||
|
includeHidden: true,
|
||||||
|
})
|
||||||
|
.filter(
|
||||||
|
(item) => !subject || !isSame(subject, item, 'military', 'icao24'),
|
||||||
|
);
|
||||||
|
return { flights, military, aircraft: flights.length + military.length };
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
sourceState,
|
||||||
|
collectSourceStates,
|
||||||
|
sourceRevision,
|
||||||
|
isSame,
|
||||||
|
summarizeInstallationViewport,
|
||||||
|
collectAircraftProximityWindow,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,288 @@
|
|||||||
|
import * as Cesium from 'cesium';
|
||||||
|
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';
|
||||||
|
|
||||||
|
export function createRendering({
|
||||||
|
state: layerState,
|
||||||
|
services,
|
||||||
|
parts,
|
||||||
|
source,
|
||||||
|
}) {
|
||||||
|
const { getKeyholeGeometry, celestialScreenAngle } = services.geometry;
|
||||||
|
const { governorRequestRender } = services.render;
|
||||||
|
|
||||||
|
function ensureDirectionOverlay() {
|
||||||
|
if (layerState.directionRoot || !layerState.viewer)
|
||||||
|
return layerState.directionRoot;
|
||||||
|
const root = document.createElement('div');
|
||||||
|
root.id = 'military-awareness-direction-overlay';
|
||||||
|
root.hidden = true;
|
||||||
|
root.setAttribute('aria-hidden', 'true');
|
||||||
|
|
||||||
|
const compass = document.createElement('div');
|
||||||
|
compass.className = 'military-awareness-compass-ring';
|
||||||
|
const cardinals = [
|
||||||
|
['N', 0],
|
||||||
|
['E', 90],
|
||||||
|
['S', 180],
|
||||||
|
['W', 270],
|
||||||
|
];
|
||||||
|
layerState.compassLabels = cardinals.map(([label, bearing]) => {
|
||||||
|
const element = document.createElement('span');
|
||||||
|
element.className = `military-awareness-compass-label cardinal-${label.toLowerCase()}`;
|
||||||
|
element.textContent = label;
|
||||||
|
element.dataset.bearing = String(bearing);
|
||||||
|
root.appendChild(element);
|
||||||
|
return element;
|
||||||
|
});
|
||||||
|
const heading = document.createElement('span');
|
||||||
|
heading.className = 'military-awareness-compass-heading';
|
||||||
|
root.append(compass, heading);
|
||||||
|
|
||||||
|
layerState.directionMarkers = Array.from({ length: 3 }, () => {
|
||||||
|
const marker = document.createElement('div');
|
||||||
|
marker.className = 'military-awareness-direction-marker';
|
||||||
|
const arrow = document.createElement('span');
|
||||||
|
arrow.className = 'military-awareness-direction-arrow';
|
||||||
|
arrow.textContent = '➜';
|
||||||
|
const label = document.createElement('span');
|
||||||
|
label.className = 'military-awareness-direction-label';
|
||||||
|
marker.append(arrow, label);
|
||||||
|
marker._arrow = arrow;
|
||||||
|
marker._label = label;
|
||||||
|
marker._lastAngle = 0;
|
||||||
|
root.appendChild(marker);
|
||||||
|
return marker;
|
||||||
|
});
|
||||||
|
|
||||||
|
layerState.compassRing = compass;
|
||||||
|
layerState.compassHeading = heading;
|
||||||
|
layerState.viewer.container.appendChild(root);
|
||||||
|
layerState.directionRoot = root;
|
||||||
|
return root;
|
||||||
|
}
|
||||||
|
|
||||||
|
function scheduleDirectionOverlayUpdate(force = false) {
|
||||||
|
if (layerState.directionFrame !== null || !layerState.enabled) return;
|
||||||
|
const now = Date.now();
|
||||||
|
if (
|
||||||
|
!force &&
|
||||||
|
now - layerState.lastDirectionUpdateMs <
|
||||||
|
parts.model.awarenessRefreshIntervalMs(layerState.cameraMoving)
|
||||||
|
)
|
||||||
|
return;
|
||||||
|
layerState.directionFrame = window.requestAnimationFrame(() => {
|
||||||
|
layerState.directionFrame = null;
|
||||||
|
layerState.lastDirectionUpdateMs = Date.now();
|
||||||
|
updateDirectionOverlay();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function cancelDirectionOverlayUpdate() {
|
||||||
|
if (layerState.directionFrame !== null)
|
||||||
|
window.cancelAnimationFrame(layerState.directionFrame);
|
||||||
|
layerState.directionFrame = null;
|
||||||
|
layerState.lastDirectionUpdateMs = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateDirectionOverlay() {
|
||||||
|
const root = ensureDirectionOverlay();
|
||||||
|
if (
|
||||||
|
!root ||
|
||||||
|
!layerState.enabled ||
|
||||||
|
!layerState.subject?.position ||
|
||||||
|
!layerState.results
|
||||||
|
) {
|
||||||
|
if (root) root.hidden = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const canvas = layerState.viewer.scene.canvas;
|
||||||
|
const width = canvas.clientWidth || canvas.width;
|
||||||
|
const height = canvas.clientHeight || canvas.height;
|
||||||
|
const geometry = getKeyholeGeometry(width, height);
|
||||||
|
if (!(geometry.radius > 0)) {
|
||||||
|
root.hidden = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
root.hidden = false;
|
||||||
|
// Keep the compass comfortably inside the shader keyhole so its cardinals
|
||||||
|
// remain visible around the subject instead of hiding under edge panels.
|
||||||
|
const compassRadius = Math.max(
|
||||||
|
120,
|
||||||
|
Math.min(geometry.radius - 48, geometry.radius * 0.66),
|
||||||
|
);
|
||||||
|
layerState.compassRing.style.left = `${geometry.centerX - compassRadius}px`;
|
||||||
|
layerState.compassRing.style.top = `${geometry.centerY - compassRadius}px`;
|
||||||
|
layerState.compassRing.style.width = `${compassRadius * 2}px`;
|
||||||
|
layerState.compassRing.style.height = `${compassRadius * 2}px`;
|
||||||
|
|
||||||
|
const cameraHeading = layerState.viewer.camera.heading || 0;
|
||||||
|
const headingDeg =
|
||||||
|
(Math.round(Cesium.Math.toDegrees(cameraHeading)) + 360) % 360;
|
||||||
|
layerState.compassRing.style.setProperty(
|
||||||
|
'--compass-rotation',
|
||||||
|
`${-headingDeg}deg`,
|
||||||
|
);
|
||||||
|
layerState.compassHeading.textContent = `HDG ${String(headingDeg).padStart(3, '0')}°`;
|
||||||
|
layerState.compassHeading.style.left = `${geometry.centerX}px`;
|
||||||
|
layerState.compassHeading.style.top = `${geometry.centerY - compassRadius + 39}px`;
|
||||||
|
for (const label of layerState.compassLabels) {
|
||||||
|
const bearing = Cesium.Math.toRadians(Number(label.dataset.bearing));
|
||||||
|
const angle = bearing - cameraHeading - Cesium.Math.PI_OVER_TWO;
|
||||||
|
const labelRadius = compassRadius - 17;
|
||||||
|
label.style.left = `${geometry.centerX + Math.cos(angle) * labelRadius}px`;
|
||||||
|
label.style.top = `${geometry.centerY + Math.sin(angle) * labelRadius}px`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const directionalCohort =
|
||||||
|
layerState.results.cohorts.find(
|
||||||
|
(cohort) => cohort.id === layerState.subject.layerId,
|
||||||
|
) ||
|
||||||
|
layerState.results.cohorts.find((cohort) => cohort.id === 'military');
|
||||||
|
const page = layerState.cohortPages.get(directionalCohort?.id) || 0;
|
||||||
|
const military =
|
||||||
|
directionalCohort?.summary.nearest.slice(
|
||||||
|
page,
|
||||||
|
page + AWARENESS_PAGE_SIZE,
|
||||||
|
) || [];
|
||||||
|
for (let index = 0; index < layerState.directionMarkers.length; index++) {
|
||||||
|
const marker = layerState.directionMarkers[index];
|
||||||
|
const item = military[index];
|
||||||
|
if (!item?.position) {
|
||||||
|
marker.hidden = true;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const direction = Cesium.Cartesian3.subtract(
|
||||||
|
item.position,
|
||||||
|
layerState.subject.position,
|
||||||
|
layerState.DIRECTION_SCRATCH[index],
|
||||||
|
);
|
||||||
|
if (Cesium.Cartesian3.magnitudeSquared(direction) < 1) {
|
||||||
|
marker.hidden = true;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
Cesium.Cartesian3.normalize(direction, direction);
|
||||||
|
const projection = celestialScreenAngle(
|
||||||
|
Cesium.Cartesian3.dot(direction, layerState.viewer.camera.rightWC),
|
||||||
|
Cesium.Cartesian3.dot(direction, layerState.viewer.camera.upWC),
|
||||||
|
marker._lastAngle,
|
||||||
|
);
|
||||||
|
if (projection.stable) marker._lastAngle = projection.angle;
|
||||||
|
// Every detected contact shares the compass rim. Keeping the bearing
|
||||||
|
// markers on one radius makes them read as compass contacts rather than
|
||||||
|
// unconstrained labels floating over the map.
|
||||||
|
const markerRadius = compassRadius;
|
||||||
|
marker.style.left = `${geometry.centerX + Math.cos(projection.angle) * markerRadius}px`;
|
||||||
|
marker.style.top = `${geometry.centerY + Math.sin(projection.angle) * markerRadius}px`;
|
||||||
|
marker.style.opacity = String(projection.opacity);
|
||||||
|
marker._arrow.style.transform = `translate(-50%, -50%) rotate(${projection.angle}rad)`;
|
||||||
|
marker._label.style.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(
|
||||||
|
layerState.subject.position,
|
||||||
|
undefined,
|
||||||
|
layerState.SUBJECT_CARTOGRAPHIC_SCRATCH,
|
||||||
|
);
|
||||||
|
const targetCartographic = Cesium.Cartographic.fromCartesian(
|
||||||
|
item.position,
|
||||||
|
undefined,
|
||||||
|
layerState.TARGET_CARTOGRAPHIC_SCRATCH[index],
|
||||||
|
);
|
||||||
|
const bearing =
|
||||||
|
subjectCartographic && targetCartographic
|
||||||
|
? bearingBetweenCoordinates(
|
||||||
|
Cesium.Math.toDegrees(subjectCartographic.latitude),
|
||||||
|
Cesium.Math.toDegrees(subjectCartographic.longitude),
|
||||||
|
Cesium.Math.toDegrees(targetCartographic.latitude),
|
||||||
|
Cesium.Math.toDegrees(targetCartographic.longitude),
|
||||||
|
)
|
||||||
|
: null;
|
||||||
|
const bearingText = Number.isFinite(bearing)
|
||||||
|
? `BRG ${String(Math.round(bearing)).padStart(3, '0')}°`
|
||||||
|
: 'BRG —';
|
||||||
|
const courseText = Number.isFinite(item.track)
|
||||||
|
? ` · CRS ${String(Math.round(item.track)).padStart(3, '0')}°`
|
||||||
|
: '';
|
||||||
|
marker._label.textContent = `${label} · ${formatAwarenessDistance(item.distanceM)}\n${bearingText}${courseText}`;
|
||||||
|
marker.hidden = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearVisual() {
|
||||||
|
if (layerState.visual?.entities && layerState.viewer) {
|
||||||
|
for (const entity of layerState.visual.entities)
|
||||||
|
layerState.viewer.entities.remove(entity);
|
||||||
|
// Idle mode renders only on request; a removed ring must not linger.
|
||||||
|
governorRequestRender('awareness-visual');
|
||||||
|
}
|
||||||
|
layerState.visual = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderVisual(subject) {
|
||||||
|
if (!layerState.viewer || !subject?.position) return;
|
||||||
|
const cartographic = Cesium.Cartographic.fromCartesian(subject.position);
|
||||||
|
if (!cartographic) return;
|
||||||
|
const groundCenter = Cesium.Cartesian3.fromRadians(
|
||||||
|
cartographic.longitude,
|
||||||
|
cartographic.latitude,
|
||||||
|
0,
|
||||||
|
);
|
||||||
|
const key = `${subject.layerId}:${subject.id}`;
|
||||||
|
if (layerState.visual?.key === key) {
|
||||||
|
// Large ellipse geometry is more reliable as a ConstantPositionProperty.
|
||||||
|
// Move it only after a meaningful displacement so live tracking does not
|
||||||
|
// churn ten large geometries for sub-pixel motion every 750 ms.
|
||||||
|
if (
|
||||||
|
Cesium.Cartesian3.distance(layerState.visual.center, groundCenter) >=
|
||||||
|
250
|
||||||
|
) {
|
||||||
|
for (const entity of layerState.visual.entities)
|
||||||
|
entity.position.setValue(groundCenter);
|
||||||
|
Cesium.Cartesian3.clone(groundCenter, layerState.visual.center);
|
||||||
|
// Content actually changed — buy exactly one frame instead of holding.
|
||||||
|
governorRequestRender('awareness-visual');
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
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,
|
||||||
|
center: Cesium.Cartesian3.clone(groundCenter),
|
||||||
|
};
|
||||||
|
governorRequestRender('awareness-visual');
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
ensureDirectionOverlay,
|
||||||
|
scheduleDirectionOverlayUpdate,
|
||||||
|
cancelDirectionOverlayUpdate,
|
||||||
|
updateDirectionOverlay,
|
||||||
|
clearVisual,
|
||||||
|
renderVisual,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
import * as Cesium from 'cesium';
|
||||||
|
|
||||||
|
export function createState({ services }) {
|
||||||
|
const state = {};
|
||||||
|
|
||||||
|
state.DIRECTION_SCRATCH = Array.from(
|
||||||
|
{ length: 3 },
|
||||||
|
() => new Cesium.Cartesian3(),
|
||||||
|
);
|
||||||
|
|
||||||
|
state.SUBJECT_CARTOGRAPHIC_SCRATCH = new Cesium.Cartographic();
|
||||||
|
|
||||||
|
state.TARGET_CARTOGRAPHIC_SCRATCH = Array.from(
|
||||||
|
{ length: 3 },
|
||||||
|
() => new Cesium.Cartographic(),
|
||||||
|
);
|
||||||
|
|
||||||
|
Object.assign(state, {
|
||||||
|
viewer: null,
|
||||||
|
dataManager: null,
|
||||||
|
enabled: false,
|
||||||
|
// Set once a refresh observes the subject gone from a still-reporting source.
|
||||||
|
// The Contact readout turns this into its CONTACT LOST hold state.
|
||||||
|
subjectMissing: false,
|
||||||
|
// The dedicated Context chooser exposes a passive shell. Operational source
|
||||||
|
// layers activate only after Contacts is explicitly selected.
|
||||||
|
passive: true,
|
||||||
|
ownedDependencies: new Set(),
|
||||||
|
subject: null,
|
||||||
|
results: null,
|
||||||
|
visual: null,
|
||||||
|
panel: null,
|
||||||
|
panelOwned: false,
|
||||||
|
subjectListener: null,
|
||||||
|
contextListener: null,
|
||||||
|
clearListener: null,
|
||||||
|
subjectClearListener: null,
|
||||||
|
runtimeListenersAttached: false,
|
||||||
|
preRenderRemover: null,
|
||||||
|
panelClickListener: null,
|
||||||
|
directionRoot: null,
|
||||||
|
compassRing: null,
|
||||||
|
compassLabels: [],
|
||||||
|
compassHeading: null,
|
||||||
|
directionMarkers: [],
|
||||||
|
navigationHistory: [],
|
||||||
|
navigationVisited: new Set(),
|
||||||
|
navigationIndex: -1,
|
||||||
|
suppressedHistoryKey: null,
|
||||||
|
pendingSelectionKey: null,
|
||||||
|
lastSubjectRefreshMs: 0,
|
||||||
|
/** Camera-pose signature observed on the previous rendered frame. */
|
||||||
|
lastCameraPoseSig: '',
|
||||||
|
/** When the pose signature last changed bins (hysteresis anchor). */
|
||||||
|
lastCameraPoseChangeMs: 0,
|
||||||
|
/** Whether the view currently counts as moving (hysteretic, not per-frame). */
|
||||||
|
cameraMoving: false,
|
||||||
|
lastEvaluatedPosition: null,
|
||||||
|
sourceRevision: '',
|
||||||
|
panelMarkup: '',
|
||||||
|
directionFrame: null,
|
||||||
|
lastDirectionUpdateMs: 0,
|
||||||
|
activationId: 0,
|
||||||
|
autoFocusAttempted: false,
|
||||||
|
autoFocusRetryPending: false,
|
||||||
|
pageTimer: null,
|
||||||
|
cohortPages: new Map(),
|
||||||
|
});
|
||||||
|
return state;
|
||||||
|
}
|
||||||
@@ -0,0 +1,447 @@
|
|||||||
|
import {
|
||||||
|
AWARENESS_RADIUS_M,
|
||||||
|
AWARENESS_RELATIONSHIP,
|
||||||
|
} from '../../data/militaryAwarenessEngine.js';
|
||||||
|
import * as Cesium from 'cesium';
|
||||||
|
import {
|
||||||
|
AWARENESS_QUERY_LIMIT,
|
||||||
|
SOURCE_LABEL,
|
||||||
|
SUBJECT_PRESENCE,
|
||||||
|
AWARENESS_PAGE_SIZE,
|
||||||
|
} from './policy.js';
|
||||||
|
|
||||||
|
export function createSubject({ state: layerState, services, parts, source }) {
|
||||||
|
const aisLiveVesselsLayer = services.vessels;
|
||||||
|
const militaryInstallationsLayer = services.installations;
|
||||||
|
const flightsLayer = services.flights;
|
||||||
|
const militaryFlightsLayer = services.military;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Decide whether a source-scoped context clear belongs to the current subject.
|
||||||
|
* @param {{layerId?: string}|null} subject Current awareness subject.
|
||||||
|
* @param {{layerId?: string}|null} cleared Cleared source detail.
|
||||||
|
* @returns {boolean} Whether the current subject should be cleared.
|
||||||
|
*/
|
||||||
|
|
||||||
|
function awarenessClearMatchesSubject(subject, cleared) {
|
||||||
|
return Boolean(subject?.layerId && cleared?.layerId === subject.layerId);
|
||||||
|
}
|
||||||
|
|
||||||
|
function evaluateSubject(
|
||||||
|
subject,
|
||||||
|
sourceStates = parts.queries.collectSourceStates(),
|
||||||
|
) {
|
||||||
|
const position = subject?.position;
|
||||||
|
if (!position) return null;
|
||||||
|
const flightsState = sourceStates.flights;
|
||||||
|
const militaryState = sourceStates.military;
|
||||||
|
const vesselsState = sourceStates['ais-live-vessels'];
|
||||||
|
const installationsState = sourceStates['military-installations'];
|
||||||
|
// Same engine the voice analyst calls — see collectAircraftProximityWindow.
|
||||||
|
const { flights, military } = parts.queries.collectAircraftProximityWindow(
|
||||||
|
position,
|
||||||
|
{ subject },
|
||||||
|
);
|
||||||
|
const vessels = aisLiveVesselsLayer
|
||||||
|
.getNearby(position, AWARENESS_RADIUS_M, AWARENESS_QUERY_LIMIT)
|
||||||
|
.filter(
|
||||||
|
(item) =>
|
||||||
|
!parts.queries.isSame(subject, item, 'ais-live-vessels', 'mmsi'),
|
||||||
|
);
|
||||||
|
const installations = militaryInstallationsLayer
|
||||||
|
.getNearby(position, AWARENESS_RADIUS_M, AWARENESS_QUERY_LIMIT)
|
||||||
|
.filter(
|
||||||
|
(item) =>
|
||||||
|
!parts.queries.isSame(subject, item, 'military-installations', 'id'),
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
subject,
|
||||||
|
evaluatedAt: Date.now(),
|
||||||
|
radiusM: AWARENESS_RADIUS_M,
|
||||||
|
cohorts: [
|
||||||
|
{
|
||||||
|
id: 'flights',
|
||||||
|
label: 'Flights',
|
||||||
|
source: flightsState.stats.source || SOURCE_LABEL.flights,
|
||||||
|
summary: parts.navigation.summarizeAwarenessCohortForNavigation(
|
||||||
|
flights,
|
||||||
|
flightsState,
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'military',
|
||||||
|
label: 'Military flights',
|
||||||
|
source: militaryState.stats.source || SOURCE_LABEL.military,
|
||||||
|
summary: parts.navigation.summarizeAwarenessCohortForNavigation(
|
||||||
|
military,
|
||||||
|
militaryState,
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'ais-live-vessels',
|
||||||
|
label: 'AIS vessels',
|
||||||
|
source: vesselsState.stats.source || SOURCE_LABEL['ais-live-vessels'],
|
||||||
|
summary: parts.navigation.summarizeAwarenessCohortForNavigation(
|
||||||
|
vessels,
|
||||||
|
vesselsState,
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'military-installations',
|
||||||
|
label: 'Mapped installations',
|
||||||
|
source:
|
||||||
|
installationsState.stats.source ||
|
||||||
|
SOURCE_LABEL['military-installations'],
|
||||||
|
coverage: 'CURRENT VIEWPORT ONLY',
|
||||||
|
summary: parts.queries.summarizeInstallationViewport(
|
||||||
|
installations,
|
||||||
|
installationsState,
|
||||||
|
),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function subjectKey(subject) {
|
||||||
|
return subject ? `${subject.layerId}:${subject.id}` : '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeContextId(value) {
|
||||||
|
return String(value || '')
|
||||||
|
.trim()
|
||||||
|
.toLowerCase();
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizedSubjectKey(subject) {
|
||||||
|
if (!subject) return '';
|
||||||
|
return `${normalizeContextId(subject.layerId)}:${normalizeContextId(subject.id)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve the source-owned aircraft that currently owns Cesium tracking.
|
||||||
|
* Both flight layers may briefly retain local state during a cross-layer
|
||||||
|
* handoff, so the viewer's normalized tracked identity is the tie-breaker.
|
||||||
|
* @returns {{layerId: string, id: string, label: string, position: Cesium.Cartesian3}|null}
|
||||||
|
* Detached awareness subject, or null when no flight owns the follow camera.
|
||||||
|
*/
|
||||||
|
|
||||||
|
function currentTrackedFlightSubject() {
|
||||||
|
const trackedKey = normalizeContextId(
|
||||||
|
layerState.viewer?.trackedEntity?.gevTrackedId,
|
||||||
|
);
|
||||||
|
if (!trackedKey) return null;
|
||||||
|
const subjects = [
|
||||||
|
flightsLayer.getTrackedSubject?.(),
|
||||||
|
militaryFlightsLayer.getTrackedSubject?.(),
|
||||||
|
];
|
||||||
|
return (
|
||||||
|
subjects.find(
|
||||||
|
(subject) => normalizedSubjectKey(subject) === trackedKey,
|
||||||
|
) || null
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function subjectCohortFeedUnknown() {
|
||||||
|
const cohort = layerState.results?.cohorts?.find(
|
||||||
|
(item) => item.id === layerState.subject?.layerId,
|
||||||
|
);
|
||||||
|
return (
|
||||||
|
cohort?.summary?.relationship === AWARENESS_RELATIONSHIP.UNKNOWN &&
|
||||||
|
cohort.summary.count === null
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function selectSubject(subject) {
|
||||||
|
if (!layerState.enabled || !subject?.position) return;
|
||||||
|
parts.panel.startAwarenessPageRotation();
|
||||||
|
const key = subjectKey(subject);
|
||||||
|
layerState.navigationVisited.add(key);
|
||||||
|
const suppressHistory = layerState.suppressedHistoryKey === key;
|
||||||
|
layerState.pendingSelectionKey = null;
|
||||||
|
layerState.suppressedHistoryKey = null;
|
||||||
|
if (!suppressHistory) {
|
||||||
|
const current = layerState.navigationHistory[layerState.navigationIndex];
|
||||||
|
if (subjectKey(current) !== key) {
|
||||||
|
layerState.navigationHistory.splice(layerState.navigationIndex + 1);
|
||||||
|
layerState.navigationHistory.push({
|
||||||
|
...parts.history.historySubjectSnapshot(
|
||||||
|
subject,
|
||||||
|
parts.history.historySourceItem(subject),
|
||||||
|
),
|
||||||
|
position: Cesium.Cartesian3.clone(subject.position),
|
||||||
|
});
|
||||||
|
layerState.navigationIndex = layerState.navigationHistory.length - 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
layerState.subject = subject;
|
||||||
|
// A newly chosen contact starts present; the next refresh re-decides.
|
||||||
|
layerState.subjectMissing = false;
|
||||||
|
refreshSelectedSubject(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve the currently displayed position for a selected live subject. Flight
|
||||||
|
* tracking owns the motion callback, so awareness consumes its cached display
|
||||||
|
* position instead of creating a second tracker or extrapolation path.
|
||||||
|
* @param {{layerId: string, id: string, position: Cesium.Cartesian3}} subject Current subject.
|
||||||
|
* @param {object} [options] Position materialization controls.
|
||||||
|
* @param {boolean} [options.allowCollectionMaterialization=true] Whether a layer-wide fallback may run.
|
||||||
|
* @returns {Cesium.Cartesian3|null} A cloned live position when one is available.
|
||||||
|
*/
|
||||||
|
|
||||||
|
function resolveSubjectPosition(
|
||||||
|
subject,
|
||||||
|
{ allowCollectionMaterialization = true } = {},
|
||||||
|
) {
|
||||||
|
if (!subject?.position) return null;
|
||||||
|
if (subject.layerId === 'flights' || subject.layerId === 'military') {
|
||||||
|
const trackedPosition =
|
||||||
|
layerState.viewer?.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 || '') ===
|
||||||
|
subjectKey(subject)
|
||||||
|
? SUBJECT_PRESENCE.LIVE
|
||||||
|
: SUBJECT_PRESENCE.UNCHECKED,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
// Cockpit already materializes its tracked position on a fixed 20 Hz loop.
|
||||||
|
// If that frame-owned cache is temporarily unavailable, keep the last
|
||||||
|
// awareness position instead of allocating/scanning up to 1,000 contacts.
|
||||||
|
if (!allowCollectionMaterialization) {
|
||||||
|
return {
|
||||||
|
position: Cesium.Cartesian3.clone(subject.position),
|
||||||
|
presence: SUBJECT_PRESENCE.UNCHECKED,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
const layer =
|
||||||
|
subject.layerId === 'flights' ? flightsLayer : militaryFlightsLayer;
|
||||||
|
return collectionSubjectPosition(
|
||||||
|
subject,
|
||||||
|
layer.getAllPositions(1000),
|
||||||
|
layer,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (subject.layerId === 'ais-live-vessels') {
|
||||||
|
if (!allowCollectionMaterialization) {
|
||||||
|
return {
|
||||||
|
position: Cesium.Cartesian3.clone(subject.position),
|
||||||
|
presence: SUBJECT_PRESENCE.UNCHECKED,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return collectionSubjectPosition(
|
||||||
|
subject,
|
||||||
|
aisLiveVesselsLayer.getAllPositions(12000),
|
||||||
|
aisLiveVesselsLayer,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
// Mapped installations come from static geometry, not a live feed: there is
|
||||||
|
// nothing to be culled from.
|
||||||
|
return {
|
||||||
|
position: Cesium.Cartesian3.clone(subject.position),
|
||||||
|
presence: SUBJECT_PRESENCE.LIVE,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Locate a subject inside its layer's live collection, keeping the last known
|
||||||
|
* position when it is gone.
|
||||||
|
*
|
||||||
|
* Presence comes from the layer's O(1) `hasContact`, never from the rows:
|
||||||
|
* `getAllPositions` stops at its cap and the flights layer routinely carries
|
||||||
|
* ~11k contacts against a 1,000-row cap, so "not in the rows" is not "gone".
|
||||||
|
* The rows are only consulted for a fresher position — a contact past the cap
|
||||||
|
* keeps its last known position while still reading as present.
|
||||||
|
* @param {{id: string, position: Cesium.Cartesian3}} subject Current subject.
|
||||||
|
* @param {Array<{id: string, position: Cesium.Cartesian3}>} rows Live collection rows.
|
||||||
|
* @param {{hasContact?: function}} layer The owning source layer.
|
||||||
|
* @returns {{position: Cesium.Cartesian3, presence: string}} Position plus presence verdict.
|
||||||
|
*/
|
||||||
|
|
||||||
|
function collectionSubjectPosition(subject, rows, layer) {
|
||||||
|
const collection = Array.isArray(rows) ? rows : [];
|
||||||
|
const current = collection.find(
|
||||||
|
(item) => String(item.id) === String(subject.id),
|
||||||
|
);
|
||||||
|
// null/undefined means the layer cannot answer (disabled or not yet loaded).
|
||||||
|
const known = layer?.hasContact?.(subject.id);
|
||||||
|
const presence =
|
||||||
|
known === true
|
||||||
|
? SUBJECT_PRESENCE.LIVE
|
||||||
|
: known === false
|
||||||
|
? SUBJECT_PRESENCE.MISSING
|
||||||
|
: SUBJECT_PRESENCE.UNCHECKED;
|
||||||
|
return {
|
||||||
|
position: current?.position
|
||||||
|
? Cesium.Cartesian3.clone(current.position)
|
||||||
|
: Cesium.Cartesian3.clone(subject.position),
|
||||||
|
presence,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Re-resolve a live subject's DISPLAY label from the layer that owns it.
|
||||||
|
*
|
||||||
|
* The subject snapshot is captured once at selection time, but a contact's
|
||||||
|
* label INPUTS can arrive later: adsbdb enrichment supplies a registration
|
||||||
|
* seconds after a callsign-less aircraft is selected, so every other surface
|
||||||
|
* swapped `ae1fa4` → `N123AB` while the cached Context subject kept the hex.
|
||||||
|
* The owning layer's tracked-subject accessor already applies the layer's
|
||||||
|
* label convention (callsign → registration → icao24), so ask it rather than
|
||||||
|
* re-deriving the chain here.
|
||||||
|
*
|
||||||
|
* Identity (`subject.id`) is NEVER re-derived — only the rendered string.
|
||||||
|
* @param {{layerId: string, id: string, label: string}} subject Current subject.
|
||||||
|
* @returns {string} The current label, or the cached one when the owning layer
|
||||||
|
* has no tracked subject (e.g. a cross-layer handoff is in flight).
|
||||||
|
*/
|
||||||
|
|
||||||
|
function resolveSubjectLabel(subject) {
|
||||||
|
if (!parts.navigation.isFlightLayer(subject?.layerId))
|
||||||
|
return subject?.label;
|
||||||
|
const layer =
|
||||||
|
subject.layerId === 'flights' ? flightsLayer : militaryFlightsLayer;
|
||||||
|
const tracked = layer.getTrackedSubject?.();
|
||||||
|
if (tracked?.label && String(tracked.id) === String(subject.id))
|
||||||
|
return tracked.label;
|
||||||
|
return subject.label;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Refresh proximity counts/distances against the subject's current live position. */
|
||||||
|
|
||||||
|
function refreshSelectedSubject(force = false) {
|
||||||
|
if (!layerState.enabled || !layerState.subject) return;
|
||||||
|
const sources = parts.queries.collectSourceStates();
|
||||||
|
const nextSourceRevision = parts.queries.sourceRevision(sources);
|
||||||
|
const sourceRevisionChanged =
|
||||||
|
nextSourceRevision !== layerState.sourceRevision;
|
||||||
|
const resolved = resolveSubjectPosition(layerState.subject, {
|
||||||
|
allowCollectionMaterialization:
|
||||||
|
!document.body?.classList?.contains('cockpit-mode') ||
|
||||||
|
sourceRevisionChanged,
|
||||||
|
});
|
||||||
|
if (!resolved) return;
|
||||||
|
const { position, presence } = resolved;
|
||||||
|
// UNCHECKED leaves the verdict alone: this tick simply did not look.
|
||||||
|
if (presence === SUBJECT_PRESENCE.LIVE) layerState.subjectMissing = false;
|
||||||
|
else if (presence === SUBJECT_PRESENCE.MISSING)
|
||||||
|
layerState.subjectMissing = true;
|
||||||
|
const nextLabel = resolveSubjectLabel(layerState.subject);
|
||||||
|
const labelChanged = nextLabel !== layerState.subject.label;
|
||||||
|
layerState.subject = { ...layerState.subject, position, label: nextLabel };
|
||||||
|
const movementM = layerState.lastEvaluatedPosition
|
||||||
|
? Cesium.Cartesian3.distance(layerState.lastEvaluatedPosition, position)
|
||||||
|
: Infinity;
|
||||||
|
if (
|
||||||
|
!parts.model.awarenessRefreshRequired({
|
||||||
|
force,
|
||||||
|
hasResults: Boolean(layerState.results),
|
||||||
|
movementM,
|
||||||
|
sourceRevisionChanged,
|
||||||
|
})
|
||||||
|
) {
|
||||||
|
layerState.results.subject = layerState.subject;
|
||||||
|
parts.rendering.renderVisual(layerState.subject);
|
||||||
|
// The panel markup embeds `subject.label`, so a label that changed while
|
||||||
|
// the contact was stationary (async enrichment answering after selection)
|
||||||
|
// is render-worthy ON ITS OWN. Without this the standalone Context panel
|
||||||
|
// kept the ICAO hex until unrelated movement, a source-revision bump, or
|
||||||
|
// page rotation happened to repaint it. Gated on an ACTUAL change so the
|
||||||
|
// common no-op refresh (every 750 ms) still costs no repaint.
|
||||||
|
if (labelChanged) parts.panel.renderResults();
|
||||||
|
parts.rendering.scheduleDirectionOverlayUpdate(force);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
layerState.results = evaluateSubject(layerState.subject, sources);
|
||||||
|
layerState.sourceRevision = nextSourceRevision;
|
||||||
|
layerState.lastEvaluatedPosition = Cesium.Cartesian3.clone(
|
||||||
|
position,
|
||||||
|
layerState.lastEvaluatedPosition,
|
||||||
|
);
|
||||||
|
for (const cohort of layerState.results.cohorts) {
|
||||||
|
const maxPage = Math.max(
|
||||||
|
0,
|
||||||
|
Math.floor(
|
||||||
|
Math.max(0, cohort.summary.nearest.length - 1) / AWARENESS_PAGE_SIZE,
|
||||||
|
) * AWARENESS_PAGE_SIZE,
|
||||||
|
);
|
||||||
|
layerState.cohortPages.set(
|
||||||
|
cohort.id,
|
||||||
|
Math.min(layerState.cohortPages.get(cohort.id) || 0, maxPage),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
parts.rendering.renderVisual(layerState.subject);
|
||||||
|
parts.panel.renderResults();
|
||||||
|
parts.rendering.scheduleDirectionOverlayUpdate(force);
|
||||||
|
}
|
||||||
|
|
||||||
|
function subjectFromContext(record) {
|
||||||
|
if (
|
||||||
|
!record ||
|
||||||
|
!['ais-live-vessels', 'military-installations'].includes(record.layerId)
|
||||||
|
)
|
||||||
|
return null;
|
||||||
|
const latitude = Number(record.latitude);
|
||||||
|
const longitude = Number(record.longitude);
|
||||||
|
if (!Number.isFinite(latitude) || !Number.isFinite(longitude)) return null;
|
||||||
|
return {
|
||||||
|
layerId: record.layerId,
|
||||||
|
id: record.properties?.mmsi || record.id,
|
||||||
|
label: record.label || record.id,
|
||||||
|
position: Cesium.Cartesian3.fromDegrees(longitude, latitude, 0),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Record that the current subject left its feed on its own.
|
||||||
|
*
|
||||||
|
* The subject, its cohort results and its navigation history all survive: the
|
||||||
|
* Contact panel keeps rendering them behind a CONTACT LOST cue, and PREVIOUS /
|
||||||
|
* NEXT stay operable so the operator can step off. Nulling the subject here
|
||||||
|
* instead would take the panel — and those controls — off screen.
|
||||||
|
*/
|
||||||
|
|
||||||
|
function markSubjectEvicted() {
|
||||||
|
if (!layerState.subject) return;
|
||||||
|
layerState.subjectMissing = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearAwarenessSubject() {
|
||||||
|
layerState.autoFocusRetryPending = false;
|
||||||
|
layerState.subject = null;
|
||||||
|
layerState.subjectMissing = false;
|
||||||
|
layerState.results = null;
|
||||||
|
layerState.navigationHistory = [];
|
||||||
|
layerState.navigationVisited.clear();
|
||||||
|
layerState.navigationIndex = -1;
|
||||||
|
layerState.suppressedHistoryKey = null;
|
||||||
|
layerState.pendingSelectionKey = null;
|
||||||
|
layerState.lastEvaluatedPosition = null;
|
||||||
|
layerState.sourceRevision = '';
|
||||||
|
layerState.cohortPages.clear();
|
||||||
|
parts.panel.stopAwarenessPageRotation();
|
||||||
|
parts.rendering.clearVisual();
|
||||||
|
parts.panel.hidePanel();
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
awarenessClearMatchesSubject,
|
||||||
|
evaluateSubject,
|
||||||
|
subjectKey,
|
||||||
|
normalizeContextId,
|
||||||
|
normalizedSubjectKey,
|
||||||
|
currentTrackedFlightSubject,
|
||||||
|
subjectCohortFeedUnknown,
|
||||||
|
selectSubject,
|
||||||
|
resolveSubjectPosition,
|
||||||
|
collectionSubjectPosition,
|
||||||
|
resolveSubjectLabel,
|
||||||
|
refreshSelectedSubject,
|
||||||
|
subjectFromContext,
|
||||||
|
markSubjectEvicted,
|
||||||
|
clearAwarenessSubject,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
export function createTesting({ state: layerState, services, parts, source }) {
|
||||||
|
/** @returns {{historyKeys: string[], navigationVisitedKeys: string[], historyLength: number, navigationIndex: number, suppressedHistoryKey: string|null, pendingSelectionKey: string|null}} */
|
||||||
|
|
||||||
|
function _getAwarenessNavigationStateForTest() {
|
||||||
|
return {
|
||||||
|
historyKeys: layerState.navigationHistory.map(parts.subject.subjectKey),
|
||||||
|
navigationVisitedKeys: [...layerState.navigationVisited],
|
||||||
|
historyLength: layerState.navigationHistory.length,
|
||||||
|
navigationIndex: layerState.navigationIndex,
|
||||||
|
suppressedHistoryKey: layerState.suppressedHistoryKey,
|
||||||
|
pendingSelectionKey: layerState.pendingSelectionKey,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return { _getAwarenessNavigationStateForTest };
|
||||||
|
}
|
||||||
Vendored
+126
@@ -0,0 +1,126 @@
|
|||||||
|
import * as Cesium from 'cesium';
|
||||||
|
import { installationFeedback } from '../../data/installationFeedback.js';
|
||||||
|
import { LAYER_ID, DISTANCE_PREFILTER_MARGIN_M } from './policy.js';
|
||||||
|
|
||||||
|
export function createControls({ state: layerState, services, parts, source }) {
|
||||||
|
const methods = {
|
||||||
|
id: LAYER_ID,
|
||||||
|
|
||||||
|
name: 'Mapped Installations',
|
||||||
|
|
||||||
|
icon: '⌖',
|
||||||
|
|
||||||
|
source: 'OpenStreetMap + optional Google Maps Places',
|
||||||
|
|
||||||
|
updateInterval: 0,
|
||||||
|
|
||||||
|
statsRefreshInterval: 1000,
|
||||||
|
|
||||||
|
/** Request a one-shot Google Maps Places search around the current map view. */
|
||||||
|
searchNearby() {
|
||||||
|
layerState.googleSearchRequested = true;
|
||||||
|
return parts.ingestion.loadInstallations();
|
||||||
|
},
|
||||||
|
|
||||||
|
getNearby(center, rangeM, maxCount = 50) {
|
||||||
|
if (!center) return [];
|
||||||
|
const range = Number.isFinite(rangeM) ? rangeM : Infinity;
|
||||||
|
const centerCartographic = Cesium.Cartographic.fromCartesian(center);
|
||||||
|
if (!centerCartographic) return [];
|
||||||
|
const nearby = [];
|
||||||
|
const approximateLimit = Number.isFinite(range)
|
||||||
|
? range * 1.03 + DISTANCE_PREFILTER_MARGIN_M
|
||||||
|
: Infinity;
|
||||||
|
for (const record of layerState.records) {
|
||||||
|
if (record.kind !== 'installation') continue;
|
||||||
|
if (
|
||||||
|
parts.model.approximateSurfaceDistanceM(
|
||||||
|
centerCartographic.latitude,
|
||||||
|
centerCartographic.longitude,
|
||||||
|
record.latitude,
|
||||||
|
record.longitude,
|
||||||
|
) > approximateLimit
|
||||||
|
)
|
||||||
|
continue;
|
||||||
|
// The awareness disk is projected onto the ground. Confirm candidates
|
||||||
|
// with an exact ellipsoidal surface distance and reusable scratch state.
|
||||||
|
layerState.distanceEndpointScratch.longitude = Cesium.Math.toRadians(
|
||||||
|
record.longitude,
|
||||||
|
);
|
||||||
|
layerState.distanceEndpointScratch.latitude = Cesium.Math.toRadians(
|
||||||
|
record.latitude,
|
||||||
|
);
|
||||||
|
layerState.distanceEndpointScratch.height = 0;
|
||||||
|
layerState.distanceGeodesicScratch.setEndPoints(
|
||||||
|
centerCartographic,
|
||||||
|
layerState.distanceEndpointScratch,
|
||||||
|
);
|
||||||
|
const distanceM = layerState.distanceGeodesicScratch.surfaceDistance;
|
||||||
|
if (!Number.isFinite(distanceM) || distanceM > range) continue;
|
||||||
|
nearby.push({
|
||||||
|
...record,
|
||||||
|
position: Cesium.Cartesian3.fromDegrees(
|
||||||
|
record.longitude,
|
||||||
|
record.latitude,
|
||||||
|
parts.rendering.installationSurfaceHeightM(record),
|
||||||
|
),
|
||||||
|
distanceM,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
nearby.sort((a, b) => a.distanceM - b.distanceM);
|
||||||
|
return nearby.slice(
|
||||||
|
0,
|
||||||
|
Number.isFinite(maxCount) ? Math.max(1, Math.floor(maxCount)) : 50,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Select and frame a mapped installation from another contextual UI.
|
||||||
|
* @param {string} id Source-backed installation id.
|
||||||
|
* @returns {boolean} True when an available installation was focused.
|
||||||
|
*/
|
||||||
|
focusById(id) {
|
||||||
|
const record = layerState.recordById.get(String(id));
|
||||||
|
if (!record || !layerState.viewer) return false;
|
||||||
|
// No camera flight without a real selection: a flight plus a stale subject
|
||||||
|
// reads as success to Context navigation and strands NEXT on this item.
|
||||||
|
if (!parts.selection.selectRecord(record.id)) return false;
|
||||||
|
layerState.viewer.camera.flyToBoundingSphere(
|
||||||
|
new Cesium.BoundingSphere(
|
||||||
|
Cesium.Cartesian3.fromDegrees(
|
||||||
|
record.longitude,
|
||||||
|
record.latitude,
|
||||||
|
parts.rendering.installationSurfaceHeightM(record),
|
||||||
|
),
|
||||||
|
18000,
|
||||||
|
),
|
||||||
|
{ duration: 1.4 },
|
||||||
|
);
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
|
||||||
|
getStats() {
|
||||||
|
return {
|
||||||
|
count: layerState.records.length,
|
||||||
|
lastUpdate: layerState.lastUpdate,
|
||||||
|
stale: layerState.stale,
|
||||||
|
saturated: layerState.saturated,
|
||||||
|
error: layerState.error,
|
||||||
|
status: layerState.status,
|
||||||
|
loading: layerState.loading,
|
||||||
|
retryAt: layerState.retryAt,
|
||||||
|
retrying: layerState.loading && Boolean(layerState.failureReason),
|
||||||
|
failureReason: layerState.failureReason,
|
||||||
|
statusMessage: installationFeedback({
|
||||||
|
...layerState,
|
||||||
|
retrying: layerState.loading && Boolean(layerState.failureReason),
|
||||||
|
}),
|
||||||
|
loadingLabel: layerState.loading
|
||||||
|
? 'loading mapped installation context'
|
||||||
|
: '',
|
||||||
|
};
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
return { methods };
|
||||||
|
}
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
import { createModel } from './model.js';
|
||||||
|
import { createRendering } from './rendering.js';
|
||||||
|
import { createIngestion } from './ingestion.js';
|
||||||
|
import { createViewport } from './viewport.js';
|
||||||
|
import { createSelection } from './selection.js';
|
||||||
|
import { createControls } from './controls.js';
|
||||||
|
import { createLifecycle } from './lifecycle.js';
|
||||||
|
import { createState } from './state.js';
|
||||||
|
|
||||||
|
/** Construct one layer with its own scene state and supplied application services. */
|
||||||
|
export function createInstallationsLayer({ services, source }) {
|
||||||
|
if (
|
||||||
|
typeof source?.getMappedSites !== 'function' ||
|
||||||
|
typeof source?.searchNearby !== 'function'
|
||||||
|
)
|
||||||
|
throw new TypeError('An installation source is required');
|
||||||
|
const state = createState({ services });
|
||||||
|
const parts = {};
|
||||||
|
const context = { state, services, parts, source };
|
||||||
|
parts.model = createModel(context);
|
||||||
|
parts.rendering = createRendering(context);
|
||||||
|
parts.ingestion = createIngestion(context);
|
||||||
|
parts.viewport = createViewport(context);
|
||||||
|
parts.selection = createSelection(context);
|
||||||
|
parts.controls = createControls(context);
|
||||||
|
parts.lifecycle = createLifecycle(context);
|
||||||
|
return Object.assign(
|
||||||
|
{},
|
||||||
|
parts.controls.methods,
|
||||||
|
parts.lifecycle.methods,
|
||||||
|
parts.ingestion?.methods,
|
||||||
|
{
|
||||||
|
approximateSurfaceDistanceM: parts.model.approximateSurfaceDistanceM,
|
||||||
|
classifyGoogleMilitaryPlace: parts.model.classifyGoogleMilitaryPlace,
|
||||||
|
installationSourceLabel: parts.model.installationSourceLabel,
|
||||||
|
installationSurfaceHeightM: parts.rendering.installationSurfaceHeightM,
|
||||||
|
installationWithinViewport: parts.model.installationWithinViewport,
|
||||||
|
installationResponseSaturated: parts.model.installationResponseSaturated,
|
||||||
|
installationRetryDelayMs: parts.viewport.installationRetryDelayMs,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
export { createInstallationSource } from './source.js';
|
||||||
@@ -0,0 +1,214 @@
|
|||||||
|
import { normalizeMilitaryInstallations } from '../../data/militaryInstallationData.js';
|
||||||
|
|
||||||
|
export function createIngestion({
|
||||||
|
state: layerState,
|
||||||
|
services,
|
||||||
|
parts,
|
||||||
|
source,
|
||||||
|
}) {
|
||||||
|
const { governorRequestRender } = services.render;
|
||||||
|
const { resolveGroundFloorCellsBounded } = services.ground;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Commit a status/error transition and buy the one frame it needs.
|
||||||
|
*
|
||||||
|
* With the render governor idle — Contacts has released its hold and nothing
|
||||||
|
* else animates — no frame would otherwise arrive to re-read this, so a load
|
||||||
|
* that fails after the scene went quiet would leave the last healthy readout on
|
||||||
|
* screen indefinitely.
|
||||||
|
* @param {string} status @param {?string} error
|
||||||
|
*/
|
||||||
|
|
||||||
|
function setInstallationStatus(status, error = null) {
|
||||||
|
if (layerState.status === status && layerState.error === error) return;
|
||||||
|
layerState.status = status;
|
||||||
|
layerState.error = error;
|
||||||
|
governorRequestRender('installations-status');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadInstallations() {
|
||||||
|
if (!layerState.enabled || !layerState.viewer) return;
|
||||||
|
const box = parts.viewport.viewportBox(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
|
||||||
|
// of `error` — the manager derives refresh failures and the global status
|
||||||
|
// chip derives LOAD FAILED from that field, and a "zoom in" prompt turned
|
||||||
|
// every Cockpit refresh into a reported failure. The row and toast read the
|
||||||
|
// prompt from `statusMessage` (installationFeedback) instead.
|
||||||
|
if (!box) {
|
||||||
|
layerState.abort?.abort();
|
||||||
|
layerState.abort = null;
|
||||||
|
layerState.loading = false;
|
||||||
|
parts.viewport.clearUnavailableRetry();
|
||||||
|
setInstallationStatus('zoom-in');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
layerState.abort?.abort();
|
||||||
|
const requestAbort = new AbortController();
|
||||||
|
layerState.abort = requestAbort;
|
||||||
|
layerState.loading = true;
|
||||||
|
parts.viewport.clearUnavailableRetry({ resetBackoff: false });
|
||||||
|
// The previous attempt's failure is not the outcome of this new attempt.
|
||||||
|
setInstallationStatus('loading');
|
||||||
|
try {
|
||||||
|
const fetchInstallations = (exact) =>
|
||||||
|
source.getMappedSites(box, { exact, signal: requestAbort.signal });
|
||||||
|
|
||||||
|
let payload = await fetchInstallations(false);
|
||||||
|
// A SATURATED snapped tile was truncated upstream, so features from the
|
||||||
|
// snap's extra ring may have crowded out sites actually on screen. Re-ask
|
||||||
|
// for the exact viewport (separately keyed and cached) before rendering.
|
||||||
|
let saturated = parts.model.installationResponseSaturated(payload);
|
||||||
|
if (saturated) {
|
||||||
|
payload = await fetchInstallations(true);
|
||||||
|
saturated = parts.model.installationResponseSaturated(payload);
|
||||||
|
}
|
||||||
|
const normalized = normalizeMilitaryInstallations(
|
||||||
|
payload,
|
||||||
|
payload.retrievedAt || new Date().toISOString(),
|
||||||
|
);
|
||||||
|
// The proxy answers a bbox at least as large as the viewport; keep only what
|
||||||
|
// was actually asked for so nothing off-screen reaches the map or the
|
||||||
|
// "current viewport only" context claim.
|
||||||
|
const records = normalized.records.filter((record) =>
|
||||||
|
parts.model.installationWithinViewport(record, box),
|
||||||
|
);
|
||||||
|
let placesError = null;
|
||||||
|
if (layerState.googleSearchRequested) {
|
||||||
|
layerState.googleSearchRequested = false;
|
||||||
|
const latitude = (box.south + box.north) / 2;
|
||||||
|
const longitude = (box.west + box.east) / 2;
|
||||||
|
const radiusM = Math.min(
|
||||||
|
50000,
|
||||||
|
Math.max(
|
||||||
|
1000,
|
||||||
|
Math.round(
|
||||||
|
Math.max(box.north - box.south, box.east - box.west) * 55_000,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
try {
|
||||||
|
const placesPayload = await source.searchNearby(
|
||||||
|
{ latitude, longitude, radiusM },
|
||||||
|
{ signal: requestAbort.signal },
|
||||||
|
);
|
||||||
|
const seen = new Set(
|
||||||
|
records.map(
|
||||||
|
(record) =>
|
||||||
|
`${record.name.toLowerCase()}|${record.latitude.toFixed(3)}|${record.longitude.toFixed(3)}`,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
for (const place of Array.isArray(placesPayload?.places)
|
||||||
|
? placesPayload.places
|
||||||
|
: []) {
|
||||||
|
if (
|
||||||
|
!place?.id ||
|
||||||
|
!place?.name ||
|
||||||
|
!Number.isFinite(place.latitude) ||
|
||||||
|
!Number.isFinite(place.longitude)
|
||||||
|
)
|
||||||
|
continue;
|
||||||
|
const placeClass = parts.model.classifyGoogleMilitaryPlace(place);
|
||||||
|
const signature = `${String(place.name).toLowerCase()}|${place.latitude.toFixed(3)}|${place.longitude.toFixed(3)}`;
|
||||||
|
if (seen.has(signature)) continue;
|
||||||
|
seen.add(signature);
|
||||||
|
const retrievedAt = new Date().toISOString();
|
||||||
|
records.push({
|
||||||
|
id: `google:${place.id}`,
|
||||||
|
kind:
|
||||||
|
placeClass === 'military_land'
|
||||||
|
? 'installation'
|
||||||
|
: 'place_candidate',
|
||||||
|
class: placeClass,
|
||||||
|
name: String(place.name).trim(),
|
||||||
|
latitude: place.latitude,
|
||||||
|
longitude: place.longitude,
|
||||||
|
footprint: null,
|
||||||
|
primaryType: place.primaryType || null,
|
||||||
|
placeTypes: Array.isArray(place.types) ? place.types : [],
|
||||||
|
sources: [
|
||||||
|
{ name: 'Google Maps Places', id: place.id, retrievedAt },
|
||||||
|
],
|
||||||
|
validation: 'unreviewed',
|
||||||
|
retrievedAt,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
if (
|
||||||
|
requestAbort.signal.aborted ||
|
||||||
|
layerState.abort !== requestAbort ||
|
||||||
|
!layerState.enabled ||
|
||||||
|
error?.name === 'AbortError'
|
||||||
|
)
|
||||||
|
return;
|
||||||
|
placesError =
|
||||||
|
'Google Places search unavailable; showing mapped sites';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
await resolveGroundFloorCellsBounded(
|
||||||
|
records.map((record) => ({
|
||||||
|
lat: record.latitude,
|
||||||
|
lon: record.longitude,
|
||||||
|
})),
|
||||||
|
);
|
||||||
|
if (
|
||||||
|
requestAbort.signal.aborted ||
|
||||||
|
layerState.abort !== requestAbort ||
|
||||||
|
!layerState.enabled
|
||||||
|
)
|
||||||
|
return;
|
||||||
|
layerState.records = records;
|
||||||
|
layerState.recordById = new Map(
|
||||||
|
layerState.records.map((record) => [record.id, record]),
|
||||||
|
);
|
||||||
|
layerState.lastUpdate = Date.now();
|
||||||
|
layerState.stale = payload.status === 'stale';
|
||||||
|
// Even the exact-viewport retry can saturate in a dense area. Say so rather
|
||||||
|
// than implying the view is completely surveyed.
|
||||||
|
layerState.saturated = saturated;
|
||||||
|
layerState.failureReason = null;
|
||||||
|
parts.viewport.clearUnavailableRetry();
|
||||||
|
setInstallationStatus(
|
||||||
|
layerState.records.length
|
||||||
|
? layerState.stale
|
||||||
|
? 'stale'
|
||||||
|
: 'ready'
|
||||||
|
: 'empty',
|
||||||
|
payload.status === 'stale'
|
||||||
|
? 'Serving cached mapped context'
|
||||||
|
: saturated
|
||||||
|
? 'Too many mapped sites in view to list them all'
|
||||||
|
: placesError,
|
||||||
|
);
|
||||||
|
parts.rendering.renderRecords();
|
||||||
|
parts.rendering.warmInstallationFloors(layerState.records);
|
||||||
|
} catch (error) {
|
||||||
|
if (
|
||||||
|
requestAbort.signal.aborted ||
|
||||||
|
layerState.abort !== requestAbort ||
|
||||||
|
!layerState.enabled ||
|
||||||
|
error?.name === 'AbortError'
|
||||||
|
)
|
||||||
|
return;
|
||||||
|
layerState.failureReason = error?.failureReason || 'unavailable';
|
||||||
|
setInstallationStatus(
|
||||||
|
'unavailable',
|
||||||
|
error?.message || 'Installation context unavailable',
|
||||||
|
);
|
||||||
|
parts.viewport.scheduleUnavailableRetry();
|
||||||
|
} finally {
|
||||||
|
// An older aborted request must not clear a newer request's busy state.
|
||||||
|
if (layerState.abort === requestAbort) {
|
||||||
|
layerState.abort = null;
|
||||||
|
layerState.loading = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const methods = {
|
||||||
|
update() {
|
||||||
|
return loadInstallations();
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
return { setInstallationStatus, loadInstallations, methods };
|
||||||
|
}
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
import * as Cesium from 'cesium';
|
||||||
|
import { LAYER_ID } from './policy.js';
|
||||||
|
|
||||||
|
export function createLifecycle({
|
||||||
|
state: layerState,
|
||||||
|
services,
|
||||||
|
parts,
|
||||||
|
source,
|
||||||
|
}) {
|
||||||
|
const { registerPickOwner, unregisterPickOwner } = services.picking;
|
||||||
|
const { clearSelectedEntityContextForLayer } = services.context;
|
||||||
|
|
||||||
|
const methods = {
|
||||||
|
init(viewer) {
|
||||||
|
layerState.viewer = viewer;
|
||||||
|
layerState.dataSource = new Cesium.CustomDataSource(
|
||||||
|
'military-installations',
|
||||||
|
);
|
||||||
|
viewer.dataSources.add(layerState.dataSource);
|
||||||
|
layerState.moveEndRemove = viewer.camera.moveEnd.addEventListener(
|
||||||
|
parts.viewport.scheduleLoad,
|
||||||
|
);
|
||||||
|
parts.selection.installInteraction(viewer);
|
||||||
|
},
|
||||||
|
|
||||||
|
enable() {
|
||||||
|
layerState.enabled = true;
|
||||||
|
registerPickOwner(LAYER_ID, (id) => layerState.recordById.has(id));
|
||||||
|
layerState.dataSource.show = true;
|
||||||
|
// DataLayerManager invokes update() immediately after enable(), which owns
|
||||||
|
// the first fetch. Avoid racing it with a second aborting request here.
|
||||||
|
},
|
||||||
|
|
||||||
|
disable() {
|
||||||
|
layerState.enabled = false;
|
||||||
|
unregisterPickOwner(LAYER_ID);
|
||||||
|
parts.viewport.clearUnavailableRetry();
|
||||||
|
clearTimeout(layerState.timer);
|
||||||
|
layerState.abort?.abort();
|
||||||
|
layerState.abort = null;
|
||||||
|
layerState.loading = false;
|
||||||
|
if (layerState.dataSource) layerState.dataSource.show = false;
|
||||||
|
clearSelectedEntityContextForLayer(LAYER_ID);
|
||||||
|
layerState.selectedId = null;
|
||||||
|
layerState.failureReason = null;
|
||||||
|
parts.ingestion.setInstallationStatus('idle');
|
||||||
|
},
|
||||||
|
|
||||||
|
destroy(viewer) {
|
||||||
|
this.disable();
|
||||||
|
layerState.moveEndRemove?.();
|
||||||
|
layerState.clickHandler?.destroy();
|
||||||
|
layerState.clickHandler = null;
|
||||||
|
parts.rendering.clearRendered();
|
||||||
|
if (layerState.dataSource && viewer)
|
||||||
|
viewer.dataSources.remove(layerState.dataSource, true);
|
||||||
|
layerState.dataSource = null;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
return { methods };
|
||||||
|
}
|
||||||
@@ -0,0 +1,165 @@
|
|||||||
|
import * as Cesium from 'cesium';
|
||||||
|
import {
|
||||||
|
EARTH_MEAN_RADIUS_M,
|
||||||
|
COLOR_BY_CLASS,
|
||||||
|
GOOGLE_MILITARY_PLACE_TYPES,
|
||||||
|
} from './policy.js';
|
||||||
|
|
||||||
|
export function createModel({ state: layerState, services, parts, source }) {
|
||||||
|
/**
|
||||||
|
* Allocation-free spherical distance used only as a conservative rejection
|
||||||
|
* pass before the exact ellipsoidal geodesic calculation.
|
||||||
|
*/
|
||||||
|
|
||||||
|
function approximateSurfaceDistanceM(
|
||||||
|
latitudeARad,
|
||||||
|
longitudeARad,
|
||||||
|
latitudeBDeg,
|
||||||
|
longitudeBDeg,
|
||||||
|
) {
|
||||||
|
const latitudeBRad = Cesium.Math.toRadians(latitudeBDeg);
|
||||||
|
const longitudeBRad = Cesium.Math.toRadians(longitudeBDeg);
|
||||||
|
const latitudeDelta = latitudeBRad - latitudeARad;
|
||||||
|
const longitudeDelta = Math.atan2(
|
||||||
|
Math.sin(longitudeBRad - longitudeARad),
|
||||||
|
Math.cos(longitudeBRad - longitudeARad),
|
||||||
|
);
|
||||||
|
const sinLatitude = Math.sin(latitudeDelta / 2);
|
||||||
|
const sinLongitude = Math.sin(longitudeDelta / 2);
|
||||||
|
const haversine =
|
||||||
|
sinLatitude * sinLatitude +
|
||||||
|
Math.cos(latitudeARad) *
|
||||||
|
Math.cos(latitudeBRad) *
|
||||||
|
sinLongitude *
|
||||||
|
sinLongitude;
|
||||||
|
return (
|
||||||
|
2 * EARTH_MEAN_RADIUS_M * Math.asin(Math.min(1, Math.sqrt(haversine)))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function colorFor(record) {
|
||||||
|
return Cesium.Color.fromCssColorString(
|
||||||
|
COLOR_BY_CLASS[record.class] || '#9ca6b0',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Classify a Places text-search result without turning a name match into a
|
||||||
|
* mapped military-land claim. Google currently has no documented military
|
||||||
|
* Places type, so ordinary results remain visually distinct candidates; the
|
||||||
|
* explicit branch is retained for any source response that does carry one.
|
||||||
|
* @param {object} place Google Places result.
|
||||||
|
* @returns {string|null} Installation class, or null when not authoritative.
|
||||||
|
*/
|
||||||
|
|
||||||
|
function classifyGoogleMilitaryPlace(place) {
|
||||||
|
const types = new Set(
|
||||||
|
[place?.primaryType, ...(Array.isArray(place?.types) ? place.types : [])]
|
||||||
|
.map((value) =>
|
||||||
|
String(value || '')
|
||||||
|
.trim()
|
||||||
|
.toLowerCase(),
|
||||||
|
)
|
||||||
|
.filter(Boolean),
|
||||||
|
);
|
||||||
|
return [...types].some((type) => GOOGLE_MILITARY_PLACE_TYPES.has(type))
|
||||||
|
? 'military_land'
|
||||||
|
: 'places_candidate';
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @param {object} record @returns {string} Human-readable source attribution. */
|
||||||
|
|
||||||
|
function installationSourceLabel(record) {
|
||||||
|
const names = [
|
||||||
|
...new Set(
|
||||||
|
(Array.isArray(record?.sources) ? record.sources : [])
|
||||||
|
.map((source) => String(source?.name || '').trim())
|
||||||
|
.filter(Boolean),
|
||||||
|
),
|
||||||
|
];
|
||||||
|
return names.join(' + ') || 'Unknown mapped source';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether a mapped record belongs to the REQUESTED viewport.
|
||||||
|
*
|
||||||
|
* The proxy snaps the request bbox outward onto a shared cache grid, so a
|
||||||
|
* response is a SUPERSET of what was asked for, and rendering that superset
|
||||||
|
* would put off-screen sites into the map and into the "CURRENT VIEWPORT ONLY"
|
||||||
|
* context claim. What may be tested depends on how much of a feature's geometry
|
||||||
|
* we actually hold:
|
||||||
|
*
|
||||||
|
* - A NODE is a point: its centre IS its whole geometry, so an exact
|
||||||
|
* containment test is correct and loses nothing.
|
||||||
|
* - A record WITH a footprint is tested by bounding-box overlap. Overpass bbox
|
||||||
|
* queries return features that merely INTERSECT the box, so centre-testing
|
||||||
|
* these would drop large bases whose centre sits just outside.
|
||||||
|
* - A way or relation WITHOUT a footprint is KEPT. Relations carry geometry on
|
||||||
|
* their members and ways beyond MAX_FOOTPRINT_POINTS are normalized without
|
||||||
|
* one, so their true extent is unknown here — and Overpass already proved
|
||||||
|
* they intersect the queried bbox. Centre-testing them would erase exactly
|
||||||
|
* the biggest installations. The honest cost is slight over-inclusion,
|
||||||
|
* bounded by one snap cell (~5.5 km) around the viewport.
|
||||||
|
*
|
||||||
|
* @param {{latitude:number, longitude:number, footprint:?Array, osmType:?string}} record
|
||||||
|
* @param {{south:number, west:number, north:number, east:number}} box Requested viewport.
|
||||||
|
* @returns {boolean}
|
||||||
|
*/
|
||||||
|
|
||||||
|
function installationWithinViewport(record, box) {
|
||||||
|
if (!record || !box) return false;
|
||||||
|
const { latitude, longitude, footprint } = record;
|
||||||
|
const centreInside =
|
||||||
|
latitude >= box.south &&
|
||||||
|
latitude <= box.north &&
|
||||||
|
longitude >= box.west &&
|
||||||
|
longitude <= box.east;
|
||||||
|
if (centreInside) return true;
|
||||||
|
if (Array.isArray(footprint) && footprint.length) {
|
||||||
|
let minLat = Infinity;
|
||||||
|
let maxLat = -Infinity;
|
||||||
|
let minLon = Infinity;
|
||||||
|
let maxLon = -Infinity;
|
||||||
|
for (const [lon, lat] of footprint) {
|
||||||
|
if (lat < minLat) minLat = lat;
|
||||||
|
if (lat > maxLat) maxLat = lat;
|
||||||
|
if (lon < minLon) minLon = lon;
|
||||||
|
if (lon > maxLon) maxLon = lon;
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
maxLat >= box.south &&
|
||||||
|
minLat <= box.north &&
|
||||||
|
maxLon >= box.west &&
|
||||||
|
minLon <= box.east
|
||||||
|
);
|
||||||
|
}
|
||||||
|
// Unknown extent: inclusive. Only a point feature may be excluded on centre.
|
||||||
|
return record.osmType !== 'node';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether a response was truncated at the upstream element cap.
|
||||||
|
*
|
||||||
|
* The proxy states this outright, but a `saturated`-less payload is NOT
|
||||||
|
* evidence of a complete answer: entries cached before the saturation guard
|
||||||
|
* shipped predate the field and live for 30 days. Fall back to deriving it from
|
||||||
|
* the element count against the cap the payload itself reports.
|
||||||
|
* @param {{saturated?: boolean, elements?: Array, elementCap?: number}} payload
|
||||||
|
* @returns {boolean}
|
||||||
|
*/
|
||||||
|
|
||||||
|
function installationResponseSaturated(payload) {
|
||||||
|
if (typeof payload?.saturated === 'boolean') return payload.saturated;
|
||||||
|
const cap = Number(payload?.elementCap);
|
||||||
|
if (!Number.isFinite(cap) || cap <= 0) return false;
|
||||||
|
return Array.isArray(payload?.elements) && payload.elements.length >= cap;
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
approximateSurfaceDistanceM,
|
||||||
|
colorFor,
|
||||||
|
classifyGoogleMilitaryPlace,
|
||||||
|
installationSourceLabel,
|
||||||
|
installationWithinViewport,
|
||||||
|
installationResponseSaturated,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
export const LAYER_ID = 'military-installations';
|
||||||
|
|
||||||
|
export const REQUEST_DEBOUNCE_MS = 500;
|
||||||
|
|
||||||
|
export const MAX_VIEWPORT_DEGREES = 10;
|
||||||
|
|
||||||
|
export const MAX_RENDERED = 700;
|
||||||
|
|
||||||
|
export const GOOGLE_MILITARY_PLACE_TYPES = new Set(['military_base']);
|
||||||
|
|
||||||
|
export const COLOR_BY_CLASS = {
|
||||||
|
airfield: '#5aa9ff',
|
||||||
|
naval_base: '#48c7d5',
|
||||||
|
range: '#d9a85d',
|
||||||
|
military_land: '#9ca6b0',
|
||||||
|
places_candidate: '#c58cff',
|
||||||
|
};
|
||||||
|
|
||||||
|
export const EARTH_MEAN_RADIUS_M = 6371008.8;
|
||||||
|
|
||||||
|
export const DISTANCE_PREFILTER_MARGIN_M = 5000;
|
||||||
@@ -0,0 +1,195 @@
|
|||||||
|
import * as Cesium from 'cesium';
|
||||||
|
import { LAYER_ID, MAX_RENDERED, COLOR_BY_CLASS } from './policy.js';
|
||||||
|
|
||||||
|
export function createRendering({
|
||||||
|
state: layerState,
|
||||||
|
services,
|
||||||
|
parts,
|
||||||
|
source,
|
||||||
|
}) {
|
||||||
|
const { floorAltitudeM, cachedGroundFloor } = services.ground;
|
||||||
|
const {
|
||||||
|
removeEntityContextsForLayer,
|
||||||
|
getSelectedEntityContext,
|
||||||
|
registerEntityContext,
|
||||||
|
selectEntityContext,
|
||||||
|
} = services.context;
|
||||||
|
const { governorRequestRender } = services.render;
|
||||||
|
const { warmFireAnchorFloors } = services.anchors;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Shared rendered-surface height for an installation anchor or footprint.
|
||||||
|
* @param {{latitude:number, longitude:number}} record Installation record.
|
||||||
|
* @returns {number} Ellipsoidal render height in metres.
|
||||||
|
*/
|
||||||
|
|
||||||
|
function installationSurfaceHeightM(record) {
|
||||||
|
return (
|
||||||
|
floorAltitudeM(
|
||||||
|
null,
|
||||||
|
cachedGroundFloor(record?.latitude, record?.longitude),
|
||||||
|
) ?? 0
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearRendered() {
|
||||||
|
if (layerState.dataSource?.entities)
|
||||||
|
layerState.dataSource.entities.removeAll();
|
||||||
|
removeEntityContextsForLayer(LAYER_ID);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The records that get entities this paint: the nearest `MAX_RENDERED`, plus
|
||||||
|
* the selected one when it falls outside that window.
|
||||||
|
*
|
||||||
|
* Context navigation walks the FULL nearby cohort, which is not bounded by the
|
||||||
|
* render cap, so selecting item 701+ used to produce no entity at all — the
|
||||||
|
* camera flew, `getById` returned null, and the selection was silently dropped
|
||||||
|
* on the floor, leaving the Context subject stale so NEXT offered the same
|
||||||
|
* installation forever. One extra entity keeps every cohort item selectable
|
||||||
|
* and the cohort count honest.
|
||||||
|
* @returns {Array<object>} Records to render this paint.
|
||||||
|
*/
|
||||||
|
|
||||||
|
function renderableRecords() {
|
||||||
|
const rendered = layerState.records.slice(0, MAX_RENDERED);
|
||||||
|
if (!layerState.selectedId) return rendered;
|
||||||
|
if (rendered.some((record) => record.id === layerState.selectedId))
|
||||||
|
return rendered;
|
||||||
|
const selected = layerState.recordById.get(layerState.selectedId);
|
||||||
|
return selected ? [...rendered, selected] : rendered;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderRecords({ claimSelection = false } = {}) {
|
||||||
|
// Context navigation can select another layer without a canvas click.
|
||||||
|
// A delayed floor/data repaint must not steal that newer selection back.
|
||||||
|
const selectedContext = getSelectedEntityContext();
|
||||||
|
if (
|
||||||
|
!claimSelection &&
|
||||||
|
layerState.selectedId &&
|
||||||
|
selectedContext &&
|
||||||
|
selectedContext.id !== layerState.selectedId
|
||||||
|
) {
|
||||||
|
layerState.selectedId = null;
|
||||||
|
}
|
||||||
|
// Post-moveEnd debounced fetches commit after the camera settles; the
|
||||||
|
// rebuilt entities need one frame in idle mode. (perf wave 2 fix)
|
||||||
|
governorRequestRender('installations-render');
|
||||||
|
clearRendered();
|
||||||
|
for (const record of renderableRecords()) {
|
||||||
|
const color = parts.model.colorFor(record);
|
||||||
|
const surfaceHeightM = installationSurfaceHeightM(record);
|
||||||
|
const displayPosition = Cesium.Cartesian3.fromDegrees(
|
||||||
|
record.longitude,
|
||||||
|
record.latitude,
|
||||||
|
surfaceHeightM,
|
||||||
|
);
|
||||||
|
const entity = layerState.dataSource.entities.add({
|
||||||
|
id: record.id,
|
||||||
|
position: displayPosition,
|
||||||
|
point: {
|
||||||
|
pixelSize: record.id === layerState.selectedId ? 13 : 9,
|
||||||
|
color:
|
||||||
|
record.id === layerState.selectedId ? Cesium.Color.WHITE : color,
|
||||||
|
outlineColor: Cesium.Color.BLACK.withAlpha(0.8),
|
||||||
|
outlineWidth: 1,
|
||||||
|
disableDepthTestDistance: Number.POSITIVE_INFINITY,
|
||||||
|
},
|
||||||
|
polygon: record.footprint
|
||||||
|
? {
|
||||||
|
hierarchy: new Cesium.PolygonHierarchy(
|
||||||
|
record.footprint.map(([longitude, latitude]) =>
|
||||||
|
Cesium.Cartesian3.fromDegrees(longitude, latitude),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
material: color.withAlpha(0.12),
|
||||||
|
outline: true,
|
||||||
|
outlineColor: color.withAlpha(0.65),
|
||||||
|
height: surfaceHeightM,
|
||||||
|
}
|
||||||
|
: undefined,
|
||||||
|
});
|
||||||
|
entity.gevTrackedId = `installations:${record.id}`;
|
||||||
|
entity.gevDisplayPosition = () => displayPosition;
|
||||||
|
entity.gevLabelModel = {
|
||||||
|
title: record.name || 'MAPPED INSTALLATION',
|
||||||
|
details: [
|
||||||
|
String(record.class || 'installation')
|
||||||
|
.replaceAll('_', ' ')
|
||||||
|
.toUpperCase(),
|
||||||
|
],
|
||||||
|
accent: COLOR_BY_CLASS[record.class] || '#9ca6b0',
|
||||||
|
};
|
||||||
|
registerEntityContext(entity, {
|
||||||
|
id: record.id,
|
||||||
|
layerId: LAYER_ID,
|
||||||
|
layerName:
|
||||||
|
record.kind === 'place_candidate'
|
||||||
|
? 'Military Site Search Candidates'
|
||||||
|
: 'Mapped Military Installations',
|
||||||
|
source: parts.model.installationSourceLabel(record),
|
||||||
|
label: record.name,
|
||||||
|
latitude: record.latitude,
|
||||||
|
longitude: record.longitude,
|
||||||
|
properties: {
|
||||||
|
class: record.class,
|
||||||
|
primaryType: record.primaryType || null,
|
||||||
|
placeTypes: Array.isArray(record.placeTypes) ? record.placeTypes : [],
|
||||||
|
validation: record.validation,
|
||||||
|
retrievedAt: record.retrievedAt,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const selectedEntity = layerState.selectedId
|
||||||
|
? layerState.dataSource.entities.getById(layerState.selectedId)
|
||||||
|
: null;
|
||||||
|
if (selectedEntity) selectEntityContext(selectedEntity);
|
||||||
|
else layerState.selectedId = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Second paint for floors that missed the bounded pre-render deadline.
|
||||||
|
*
|
||||||
|
* `resolveGroundFloorCellsBounded` gives up after FLOOR_RESOLVE_DEADLINE_MS so
|
||||||
|
* a cold DEM can never hold the dots hostage — but the resolve keeps running
|
||||||
|
* and lands seconds later, and without this the records it covers stay pinned
|
||||||
|
* at ellipsoid height 0, sitting visibly under the 3D tiles (owner playtest
|
||||||
|
* 2026-08-18: "orange dots at the bottom").
|
||||||
|
*
|
||||||
|
* This is the render -> warm -> re-render chain FIRMS already uses, with one
|
||||||
|
* difference the installations path forces: the trigger is whether a cell that
|
||||||
|
* was COLD AT PAINT TIME is warm now, not whether this particular batch warmed
|
||||||
|
* it. The bounded resolve above is still running against the same cells, so
|
||||||
|
* asking "did MY batch warm anything" would answer false exactly when the other
|
||||||
|
* resolve won the race — the common case. Still terminating: a set that is
|
||||||
|
* wholly cold afterwards re-renders zero times and the next camera-driven load
|
||||||
|
* retries.
|
||||||
|
* @param {Array<object>} records Records just rendered.
|
||||||
|
* @returns {void}
|
||||||
|
*/
|
||||||
|
|
||||||
|
function warmInstallationFloors(records) {
|
||||||
|
const cold = records
|
||||||
|
.filter(
|
||||||
|
(record) =>
|
||||||
|
cachedGroundFloor(record.latitude, record.longitude) == null,
|
||||||
|
)
|
||||||
|
.map((record) => ({ lat: record.latitude, lon: record.longitude }));
|
||||||
|
if (!cold.length) return;
|
||||||
|
warmFireAnchorFloors(cold).then(() => {
|
||||||
|
if (!layerState.enabled || !layerState.dataSource) return;
|
||||||
|
if (
|
||||||
|
!cold.some((point) => cachedGroundFloor(point.lat, point.lon) != null)
|
||||||
|
)
|
||||||
|
return;
|
||||||
|
renderRecords();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
installationSurfaceHeightM,
|
||||||
|
clearRendered,
|
||||||
|
renderableRecords,
|
||||||
|
renderRecords,
|
||||||
|
warmInstallationFloors,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
import * as Cesium from 'cesium';
|
||||||
|
import { LAYER_ID } from './policy.js';
|
||||||
|
|
||||||
|
export function createSelection({
|
||||||
|
state: layerState,
|
||||||
|
services,
|
||||||
|
parts,
|
||||||
|
source,
|
||||||
|
}) {
|
||||||
|
const { clearSelectedEntityContextForLayer } = services.context;
|
||||||
|
|
||||||
|
function selectRecord(id) {
|
||||||
|
const record = layerState.recordById.get(id);
|
||||||
|
if (!record || !layerState.dataSource) return false;
|
||||||
|
layerState.selectedId = id;
|
||||||
|
parts.rendering.renderRecords({ claimSelection: true });
|
||||||
|
// renderRecords drops selectedId when the record produced no entity.
|
||||||
|
return layerState.selectedId === id;
|
||||||
|
}
|
||||||
|
|
||||||
|
function installInteraction(viewer) {
|
||||||
|
if (layerState.clickHandler) return;
|
||||||
|
layerState.clickHandler = new Cesium.ScreenSpaceEventHandler(
|
||||||
|
viewer.scene.canvas,
|
||||||
|
);
|
||||||
|
layerState.clickHandler.setInputAction((click) => {
|
||||||
|
if (!layerState.enabled) return;
|
||||||
|
const picked = viewer.scene.pick(click.position);
|
||||||
|
const id = typeof picked?.id?.id === 'string' ? picked.id.id : null;
|
||||||
|
if (id && layerState.recordById.has(id) && id !== layerState.selectedId) {
|
||||||
|
selectRecord(id);
|
||||||
|
} else if (layerState.selectedId) {
|
||||||
|
// Clicking the selected site again, empty map, or another contact
|
||||||
|
// releases this layer's selection. Clear only our shared context so a
|
||||||
|
// sibling click handler's newly selected aircraft/site stays intact.
|
||||||
|
layerState.selectedId = null;
|
||||||
|
clearSelectedEntityContextForLayer(LAYER_ID);
|
||||||
|
parts.rendering.renderRecords();
|
||||||
|
}
|
||||||
|
}, Cesium.ScreenSpaceEventType.LEFT_CLICK);
|
||||||
|
}
|
||||||
|
return { selectRecord, installInteraction };
|
||||||
|
}
|
||||||
@@ -0,0 +1,78 @@
|
|||||||
|
/** Read mapped installations and explicit nearby-place searches through fixed endpoints. */
|
||||||
|
export function createInstallationSource({
|
||||||
|
fetchImpl = (...args) => globalThis.fetch(...args),
|
||||||
|
} = {}) {
|
||||||
|
return {
|
||||||
|
async getMappedSites(box, { exact = false, signal } = {}) {
|
||||||
|
const { south, west, north, east } = box || {};
|
||||||
|
if (
|
||||||
|
![south, west, north, east].every(Number.isFinite) ||
|
||||||
|
south < -90 ||
|
||||||
|
north > 90 ||
|
||||||
|
west < -180 ||
|
||||||
|
east > 180 ||
|
||||||
|
north <= south ||
|
||||||
|
east <= west ||
|
||||||
|
north - south > 10 ||
|
||||||
|
east - west > 10
|
||||||
|
)
|
||||||
|
throw new TypeError('A bounded installation viewport is required');
|
||||||
|
signal?.throwIfAborted();
|
||||||
|
const query = new URLSearchParams(
|
||||||
|
Object.entries({ south, west, north, east }).map(([key, value]) => [
|
||||||
|
key,
|
||||||
|
value.toFixed(5),
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
if (exact) query.set('exact', '1');
|
||||||
|
const response = await fetchImpl(`/api/military-installations?${query}`, {
|
||||||
|
signal,
|
||||||
|
});
|
||||||
|
const body = await response.json();
|
||||||
|
signal?.throwIfAborted();
|
||||||
|
if (!response.ok)
|
||||||
|
throw Object.assign(
|
||||||
|
new Error(body?.error || `Installation feed HTTP ${response.status}`),
|
||||||
|
{
|
||||||
|
failureReason: ['rate_limited', 'timeout', 'query_failed'].includes(
|
||||||
|
body?.reason,
|
||||||
|
)
|
||||||
|
? body.reason
|
||||||
|
: 'unavailable',
|
||||||
|
},
|
||||||
|
);
|
||||||
|
if (!Array.isArray(body?.elements))
|
||||||
|
throw new Error('Malformed installation snapshot');
|
||||||
|
return body;
|
||||||
|
},
|
||||||
|
async searchNearby({ latitude, longitude, radiusM }, { signal } = {}) {
|
||||||
|
if (
|
||||||
|
![latitude, longitude, radiusM].every(Number.isFinite) ||
|
||||||
|
Math.abs(latitude) > 90 ||
|
||||||
|
Math.abs(longitude) > 180 ||
|
||||||
|
radiusM < 1000 ||
|
||||||
|
radiusM > 50000
|
||||||
|
)
|
||||||
|
throw new TypeError('Invalid nearby installation search');
|
||||||
|
signal?.throwIfAborted();
|
||||||
|
const response = await fetchImpl(
|
||||||
|
`/api/google/text-search?${new URLSearchParams({
|
||||||
|
q: 'military installation',
|
||||||
|
lat: latitude.toFixed(5),
|
||||||
|
lon: longitude.toFixed(5),
|
||||||
|
radiusM: String(radiusM),
|
||||||
|
})}`,
|
||||||
|
{ signal },
|
||||||
|
);
|
||||||
|
const payload = await response.json();
|
||||||
|
signal?.throwIfAborted();
|
||||||
|
if (!response.ok)
|
||||||
|
throw new Error(
|
||||||
|
payload?.error || `Google Places HTTP ${response.status}`,
|
||||||
|
);
|
||||||
|
if (!Array.isArray(payload?.places))
|
||||||
|
throw new Error('Malformed nearby-place snapshot');
|
||||||
|
return payload;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import test from 'node:test';
|
||||||
|
import { createInstallationSource } from './source.js';
|
||||||
|
|
||||||
|
const box = { south: 30.1, west: -97.9, north: 30.3, east: -97.6 };
|
||||||
|
test('mapped-site sources validate viewport bounds and preserve the exact retry key', async () => {
|
||||||
|
const calls = [];
|
||||||
|
const source = createInstallationSource({
|
||||||
|
fetchImpl: async (url) => {
|
||||||
|
calls.push(new URL(url, 'https://example.test'));
|
||||||
|
return new Response(JSON.stringify({ elements: [], status: 'stale' }));
|
||||||
|
},
|
||||||
|
});
|
||||||
|
for (const invalid of [
|
||||||
|
null,
|
||||||
|
{ ...box, east: Infinity },
|
||||||
|
{ ...box, north: 90 },
|
||||||
|
{ ...box, south: 31 },
|
||||||
|
])
|
||||||
|
await assert.rejects(
|
||||||
|
source.getMappedSites(invalid),
|
||||||
|
/bounded installation viewport/,
|
||||||
|
);
|
||||||
|
assert.equal(calls.length, 0);
|
||||||
|
const payload = await source.getMappedSites(box, { exact: true });
|
||||||
|
assert.equal(payload.status, 'stale');
|
||||||
|
assert.equal(calls[0].pathname, '/api/military-installations');
|
||||||
|
assert.equal(calls[0].searchParams.get('exact'), '1');
|
||||||
|
assert.equal(calls[0].searchParams.get('south'), '30.10000');
|
||||||
|
});
|
||||||
|
test('malformed installation and place snapshots are never accepted as empty success', async () => {
|
||||||
|
const source = createInstallationSource({
|
||||||
|
fetchImpl: async () => new Response('{}'),
|
||||||
|
});
|
||||||
|
await assert.rejects(
|
||||||
|
source.getMappedSites(box),
|
||||||
|
/Malformed installation snapshot/,
|
||||||
|
);
|
||||||
|
await assert.rejects(
|
||||||
|
source.searchNearby({ latitude: 30.2, longitude: -97.7, radiusM: 1000 }),
|
||||||
|
/Malformed nearby-place snapshot/,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
test('installation response parsing respects cancellation before any follow-on search', async () => {
|
||||||
|
const controller = new AbortController();
|
||||||
|
const source = createInstallationSource({
|
||||||
|
fetchImpl: async () => ({
|
||||||
|
ok: true,
|
||||||
|
json: async () => {
|
||||||
|
controller.abort();
|
||||||
|
return { elements: [] };
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
await assert.rejects(
|
||||||
|
source.getMappedSites(box, { signal: controller.signal }),
|
||||||
|
{ name: 'AbortError' },
|
||||||
|
);
|
||||||
|
});
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
import * as Cesium from 'cesium';
|
||||||
|
|
||||||
|
export function createState({ services }) {
|
||||||
|
const state = {};
|
||||||
|
|
||||||
|
state.distanceEndpointScratch = new Cesium.Cartographic();
|
||||||
|
|
||||||
|
state.distanceGeodesicScratch = new Cesium.EllipsoidGeodesic();
|
||||||
|
|
||||||
|
Object.assign(state, {
|
||||||
|
viewer: null,
|
||||||
|
dataSource: null,
|
||||||
|
enabled: false,
|
||||||
|
records: [],
|
||||||
|
recordById: new Map(),
|
||||||
|
selectedId: null,
|
||||||
|
lastUpdate: null,
|
||||||
|
error: null,
|
||||||
|
status: 'idle',
|
||||||
|
stale: false,
|
||||||
|
/** Whether the upstream truncated at its element cap for the current view. */
|
||||||
|
saturated: false,
|
||||||
|
loading: false,
|
||||||
|
abort: null,
|
||||||
|
/** Pending timed retry while status is 'unavailable' (see scheduleUnavailableRetry). */
|
||||||
|
retryTimer: null,
|
||||||
|
/** Current backoff step for that retry; 0 = next failure starts at the minimum. */
|
||||||
|
retryDelayMs: 0,
|
||||||
|
retryAt: 0,
|
||||||
|
failureReason: null,
|
||||||
|
moveEndRemove: null,
|
||||||
|
clickHandler: null,
|
||||||
|
timer: null,
|
||||||
|
googleSearchRequested: false,
|
||||||
|
});
|
||||||
|
return state;
|
||||||
|
}
|
||||||
@@ -0,0 +1,84 @@
|
|||||||
|
import * as Cesium from 'cesium';
|
||||||
|
import { MAX_VIEWPORT_DEGREES, REQUEST_DEBOUNCE_MS } from './policy.js';
|
||||||
|
|
||||||
|
export function createViewport({ state: layerState, services, parts, source }) {
|
||||||
|
function viewportBox(viewer) {
|
||||||
|
const rectangle = viewer?.camera?.computeViewRectangle(
|
||||||
|
viewer.scene.globe.ellipsoid,
|
||||||
|
);
|
||||||
|
if (!rectangle) return null;
|
||||||
|
const south = Cesium.Math.toDegrees(rectangle.south);
|
||||||
|
const north = Cesium.Math.toDegrees(rectangle.north);
|
||||||
|
const west = Cesium.Math.toDegrees(rectangle.west);
|
||||||
|
const east = Cesium.Math.toDegrees(rectangle.east);
|
||||||
|
// Cross-dateline/global views require a zoom before a bounded request.
|
||||||
|
if (
|
||||||
|
!Number.isFinite(south + north + west + east) ||
|
||||||
|
east <= west ||
|
||||||
|
north - south > MAX_VIEWPORT_DEGREES ||
|
||||||
|
east - west > MAX_VIEWPORT_DEGREES
|
||||||
|
)
|
||||||
|
return null;
|
||||||
|
return { south, west, north, east };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Backoff progression for the unavailable-state retry: 30 s, doubling to a
|
||||||
|
* 240 s ceiling. Pure so the progression is pinnable without booting the layer.
|
||||||
|
*/
|
||||||
|
|
||||||
|
function installationRetryDelayMs(prevDelayMs) {
|
||||||
|
const RETRY_MIN_MS = 30000;
|
||||||
|
const RETRY_CEIL_MS = 240000;
|
||||||
|
if (!Number.isFinite(prevDelayMs) || prevDelayMs <= 0) return RETRY_MIN_MS;
|
||||||
|
return Math.min(prevDelayMs * 2, RETRY_CEIL_MS);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 'Temporarily unavailable' must mean temporarily: fetches otherwise fire only
|
||||||
|
* on enable and on camera moveEnd, so a parked camera whose first request died
|
||||||
|
* (one flaky Overpass mirror is enough) stayed unavailable forever while the
|
||||||
|
* proxy sat healthy while the layer refused to show its features. While the
|
||||||
|
* layer is enabled and
|
||||||
|
* unavailable, retry on a 30 s → 240 s backoff; any success, user-driven load,
|
||||||
|
* zoom-out, or disable cancels it.
|
||||||
|
*/
|
||||||
|
|
||||||
|
function scheduleUnavailableRetry() {
|
||||||
|
if (!layerState.enabled) return;
|
||||||
|
clearTimeout(layerState.retryTimer);
|
||||||
|
layerState.retryDelayMs = installationRetryDelayMs(layerState.retryDelayMs);
|
||||||
|
layerState.retryAt = Date.now() + layerState.retryDelayMs;
|
||||||
|
layerState.retryTimer = setTimeout(() => {
|
||||||
|
layerState.retryTimer = null;
|
||||||
|
layerState.retryAt = 0;
|
||||||
|
if (layerState.enabled && !layerState.loading)
|
||||||
|
parts.ingestion.loadInstallations();
|
||||||
|
}, layerState.retryDelayMs);
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearUnavailableRetry({ resetBackoff = true } = {}) {
|
||||||
|
clearTimeout(layerState.retryTimer);
|
||||||
|
layerState.retryTimer = null;
|
||||||
|
layerState.retryAt = 0;
|
||||||
|
if (resetBackoff) layerState.retryDelayMs = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
function scheduleLoad() {
|
||||||
|
if (!layerState.enabled) return;
|
||||||
|
// A user-driven load supersedes any pending retry; the load reschedules on
|
||||||
|
// failure, so the backoff step is kept rather than reset.
|
||||||
|
clearUnavailableRetry({ resetBackoff: false });
|
||||||
|
clearTimeout(layerState.timer);
|
||||||
|
layerState.timer = setTimeout(() => {
|
||||||
|
parts.ingestion.loadInstallations();
|
||||||
|
}, REQUEST_DEBOUNCE_MS);
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
viewportBox,
|
||||||
|
installationRetryDelayMs,
|
||||||
|
scheduleUnavailableRetry,
|
||||||
|
clearUnavailableRetry,
|
||||||
|
scheduleLoad,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -13,6 +13,8 @@ export function readLayerSource(file) {
|
|||||||
'firmsHeatmap.js',
|
'firmsHeatmap.js',
|
||||||
'satellites.js',
|
'satellites.js',
|
||||||
'rocketLaunches.js',
|
'rocketLaunches.js',
|
||||||
|
'militaryInstallations.js',
|
||||||
|
'militaryAwareness.js',
|
||||||
].includes(basename(path))
|
].includes(basename(path))
|
||||||
)
|
)
|
||||||
return readFileSync(path, 'utf8');
|
return readFileSync(path, 'utf8');
|
||||||
@@ -28,7 +30,11 @@ export function readLayerSource(file) {
|
|||||||
? '../layers/satellites'
|
? '../layers/satellites'
|
||||||
: basename(path) === 'rocketLaunches.js'
|
: basename(path) === 'rocketLaunches.js'
|
||||||
? '../layers/launches'
|
? '../layers/launches'
|
||||||
: '../layers/vessels',
|
: basename(path) === 'militaryInstallations.js'
|
||||||
|
? '../layers/installations'
|
||||||
|
: basename(path) === 'militaryAwareness.js'
|
||||||
|
? '../layers/awareness'
|
||||||
|
: '../layers/vessels',
|
||||||
);
|
);
|
||||||
return readdirSync(directory)
|
return readdirSync(directory)
|
||||||
.filter((name) => name.endsWith('.js'))
|
.filter((name) => name.endsWith('.js'))
|
||||||
|
|||||||
Reference in new issue
Block a user