refactor: separate installation and proximity context components

This commit is contained in:
Sameh Khamis committed 2026-09-13 13:36:32 -07:00
1 parent 287493dfb7
commit 3b77277883
39 files changed
+4150 -2631

No files matched your search

+7
View File
@@ -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
+9
View File
@@ -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
View File
@@ -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"
}
}
+31 -1
View File
@@ -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"
]
+45
View File
@@ -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"]
}
}
+2 -1
View File
@@ -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', () => {
File diff suppressed because it is too large. Load diff
+1 -1
View File
@@ -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',
);
+21 -679
View File
@@ -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;
+5 -4
View File
@@ -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');
});
+126
View File
@@ -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 };
}
+118
View File
@@ -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,
};
}
+198
View File
@@ -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,
};
}
+239
View File
@@ -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,
};
}
+66
View File
@@ -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';
+15
View File
@@ -0,0 +1,15 @@
export function createIngestion({
state: layerState,
services,
parts,
source,
}) {
const methods = {
update() {
parts.subject.refreshSelectedSubject();
return Promise.resolve();
},
};
return { methods };
}
+263
View File
@@ -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,
};
}
+251
View File
@@ -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,
};
}
+274
View File
@@ -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,
};
}
+249
View File
@@ -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) =>
({
'&': '&amp;',
'<': '&lt;',
'>': '&gt;',
"'": '&#39;',
'"': '&quot;',
})[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,
};
}
+68
View File
@@ -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',
});
+164
View File
@@ -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,
};
}
+288
View File
@@ -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,
};
}
+70
View File
@@ -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;
}
+447
View File
@@ -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,
};
}
+15
View File
@@ -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 };
}
+126
View File
@@ -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 };
}
+43
View File
@@ -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';
+214
View File
@@ -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 };
}
+62
View File
@@ -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 };
}
+165
View File
@@ -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,
};
}
+21
View File
@@ -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;
+195
View File
@@ -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,
};
}
+43
View File
@@ -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 };
}
+78
View File
@@ -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;
},
};
}
+59
View File
@@ -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' },
);
});
+37
View File
@@ -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;
}
+84
View File
@@ -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,
};
}
+6
View File
@@ -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,6 +30,10 @@ export function readLayerSource(file) {
? '../layers/satellites'
: basename(path) === 'rocketLaunches.js'
? '../layers/launches'
: basename(path) === 'militaryInstallations.js'
? '../layers/installations'
: basename(path) === 'militaryAwareness.js'
? '../layers/awareness'
: '../layers/vessels',
);
return readdirSync(directory)