mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-04 02:14:01 +00:00
refactor: separate installation and proximity context components
This commit is contained in:
1 parent
287493dfb7
commit
3b77277883
39 files changed
+4151
-2632
No files matched your search
@@ -1,5 +1,12 @@
|
||||
# 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
|
||||
|
||||
- Separate catalog loading, orbit calculations, display, tracking and interaction
|
||||
|
||||
@@ -1,5 +1,14 @@
|
||||
# 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`
|
||||
and `./layers/launches`. Each owns its catalog, render state, tracking, interaction
|
||||
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/firms": "./src/layers/firms/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/rocketLaunches.js",
|
||||
"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"
|
||||
],
|
||||
"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 { readShellElements } from './ui/shellElements.js';
|
||||
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 sceneDirector = fs.readFileSync(path.join(ROOT, 'src', 'scenes', 'director.js'), 'utf8');
|
||||
const manager = fs.readFileSync(path.join(ROOT, 'src', 'data', 'manager.js'), 'utf8');
|
||||
const contextLayer = fs.readFileSync(path.join(ROOT, 'src', 'data', 'militaryAwareness.js'), 'utf8');
|
||||
const contextLayer = readLayerSource(path.join(ROOT, 'src', 'data', 'militaryAwareness.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', () => {
|
||||
|
||||
+42
-1944
File diff suppressed because it is too large.
Load diff
@@ -43,7 +43,7 @@ import {
|
||||
} from './militaryAwarenessEngine.js';
|
||||
import { NAVIGATION_AUTHORITY_EVENT } from '../navigationPolicy.js';
|
||||
|
||||
const militaryAwarenessSource = fs.readFileSync(
|
||||
const militaryAwarenessSource = readLayerSource(
|
||||
new URL('./militaryAwareness.js', import.meta.url),
|
||||
'utf8',
|
||||
);
|
||||
|
||||
@@ -1,681 +1,23 @@
|
||||
import * as Cesium from 'cesium';
|
||||
import { governorRequestRender } from '../renderGovernor.js';
|
||||
import {
|
||||
clearSelectedEntityContextForLayer,
|
||||
getSelectedEntityContext,
|
||||
registerEntityContext,
|
||||
removeEntityContextsForLayer,
|
||||
selectEntityContext,
|
||||
} from './contextStore.js';
|
||||
import {
|
||||
cachedGroundFloor,
|
||||
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';
|
||||
createInstallationsLayer,
|
||||
createInstallationSource,
|
||||
} from '../layers/installations/index.js';
|
||||
import * as render from '../renderGovernor.js';
|
||||
import * as context from './contextStore.js';
|
||||
import * as ground from './groundFloor.js';
|
||||
import * as anchors from './fireAnchors.js';
|
||||
import * as picking from './pickRegistry.js';
|
||||
|
||||
const LAYER_ID = 'military-installations';
|
||||
const REQUEST_DEBOUNCE_MS = 500;
|
||||
const MAX_VIEWPORT_DEGREES = 10;
|
||||
const MAX_RENDERED = 700;
|
||||
const GOOGLE_MILITARY_PLACE_TYPES = new Set(['military_base']);
|
||||
const COLOR_BY_CLASS = {
|
||||
airfield: '#5aa9ff',
|
||||
naval_base: '#48c7d5',
|
||||
range: '#d9a85d',
|
||||
military_land: '#9ca6b0',
|
||||
places_candidate: '#c58cff',
|
||||
};
|
||||
const EARTH_MEAN_RADIUS_M = 6371008.8;
|
||||
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;
|
||||
const layer = createInstallationsLayer({
|
||||
source: createInstallationSource(),
|
||||
services: { render, context, ground, anchors, picking },
|
||||
});
|
||||
export const approximateSurfaceDistanceM = layer.approximateSurfaceDistanceM;
|
||||
export const classifyGoogleMilitaryPlace = layer.classifyGoogleMilitaryPlace;
|
||||
export const installationSourceLabel = layer.installationSourceLabel;
|
||||
export const installationSurfaceHeightM = layer.installationSurfaceHeightM;
|
||||
export const installationWithinViewport = layer.installationWithinViewport;
|
||||
export const installationResponseSaturated =
|
||||
layer.installationResponseSaturated;
|
||||
export const installationRetryDelayMs = layer.installationRetryDelayMs;
|
||||
export default layer;
|
||||
@@ -1,3 +1,4 @@
|
||||
import { readLayerSource } from '../testSupport/readLayerSource.mjs';
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {
|
||||
@@ -782,7 +783,7 @@ test('zoom-out aborts an active installation request and returns non-loading gui
|
||||
import { installationRetryDelayMs } from './militaryInstallations.js';
|
||||
import fs from 'node:fs';
|
||||
|
||||
const installationsSource = fs.readFileSync(
|
||||
const installationsSource = readLayerSource(
|
||||
new URL('./militaryInstallations.js', import.meta.url), 'utf8');
|
||||
|
||||
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', () => {
|
||||
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');
|
||||
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');
|
||||
assert.match(installationsSource,
|
||||
/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 \}\)/,
|
||||
'a user-driven load supersedes the retry without resetting the backoff step');
|
||||
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');
|
||||
});
|
||||
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',
|
||||
'satellites.js',
|
||||
'rocketLaunches.js',
|
||||
'militaryInstallations.js',
|
||||
'militaryAwareness.js',
|
||||
].includes(basename(path))
|
||||
)
|
||||
return readFileSync(path, 'utf8');
|
||||
@@ -28,7 +30,11 @@ export function readLayerSource(file) {
|
||||
? '../layers/satellites'
|
||||
: basename(path) === 'rocketLaunches.js'
|
||||
? '../layers/launches'
|
||||
: '../layers/vessels',
|
||||
: basename(path) === 'militaryInstallations.js'
|
||||
? '../layers/installations'
|
||||
: basename(path) === 'militaryAwareness.js'
|
||||
? '../layers/awareness'
|
||||
: '../layers/vessels',
|
||||
);
|
||||
return readdirSync(directory)
|
||||
.filter((name) => name.endsWith('.js'))
|
||||
|
||||
Reference in new issue
Block a user