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
+4151 -2632

No files matched your search

+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,
};
}