mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-05 19:04:02 +00:00
refactor: separate installation and proximity context components
This commit is contained in:
1 parent
287493dfb7
commit
3b77277883
39 files changed
+4151
-2632
No files matched your search
Vendored
+126
@@ -0,0 +1,126 @@
|
||||
import * as Cesium from 'cesium';
|
||||
import { installationFeedback } from '../../data/installationFeedback.js';
|
||||
import { LAYER_ID, DISTANCE_PREFILTER_MARGIN_M } from './policy.js';
|
||||
|
||||
export function createControls({ state: layerState, services, parts, source }) {
|
||||
const methods = {
|
||||
id: LAYER_ID,
|
||||
|
||||
name: 'Mapped Installations',
|
||||
|
||||
icon: '⌖',
|
||||
|
||||
source: 'OpenStreetMap + optional Google Maps Places',
|
||||
|
||||
updateInterval: 0,
|
||||
|
||||
statsRefreshInterval: 1000,
|
||||
|
||||
/** Request a one-shot Google Maps Places search around the current map view. */
|
||||
searchNearby() {
|
||||
layerState.googleSearchRequested = true;
|
||||
return parts.ingestion.loadInstallations();
|
||||
},
|
||||
|
||||
getNearby(center, rangeM, maxCount = 50) {
|
||||
if (!center) return [];
|
||||
const range = Number.isFinite(rangeM) ? rangeM : Infinity;
|
||||
const centerCartographic = Cesium.Cartographic.fromCartesian(center);
|
||||
if (!centerCartographic) return [];
|
||||
const nearby = [];
|
||||
const approximateLimit = Number.isFinite(range)
|
||||
? range * 1.03 + DISTANCE_PREFILTER_MARGIN_M
|
||||
: Infinity;
|
||||
for (const record of layerState.records) {
|
||||
if (record.kind !== 'installation') continue;
|
||||
if (
|
||||
parts.model.approximateSurfaceDistanceM(
|
||||
centerCartographic.latitude,
|
||||
centerCartographic.longitude,
|
||||
record.latitude,
|
||||
record.longitude,
|
||||
) > approximateLimit
|
||||
)
|
||||
continue;
|
||||
// The awareness disk is projected onto the ground. Confirm candidates
|
||||
// with an exact ellipsoidal surface distance and reusable scratch state.
|
||||
layerState.distanceEndpointScratch.longitude = Cesium.Math.toRadians(
|
||||
record.longitude,
|
||||
);
|
||||
layerState.distanceEndpointScratch.latitude = Cesium.Math.toRadians(
|
||||
record.latitude,
|
||||
);
|
||||
layerState.distanceEndpointScratch.height = 0;
|
||||
layerState.distanceGeodesicScratch.setEndPoints(
|
||||
centerCartographic,
|
||||
layerState.distanceEndpointScratch,
|
||||
);
|
||||
const distanceM = layerState.distanceGeodesicScratch.surfaceDistance;
|
||||
if (!Number.isFinite(distanceM) || distanceM > range) continue;
|
||||
nearby.push({
|
||||
...record,
|
||||
position: Cesium.Cartesian3.fromDegrees(
|
||||
record.longitude,
|
||||
record.latitude,
|
||||
parts.rendering.installationSurfaceHeightM(record),
|
||||
),
|
||||
distanceM,
|
||||
});
|
||||
}
|
||||
nearby.sort((a, b) => a.distanceM - b.distanceM);
|
||||
return nearby.slice(
|
||||
0,
|
||||
Number.isFinite(maxCount) ? Math.max(1, Math.floor(maxCount)) : 50,
|
||||
);
|
||||
},
|
||||
|
||||
/**
|
||||
* Select and frame a mapped installation from another contextual UI.
|
||||
* @param {string} id Source-backed installation id.
|
||||
* @returns {boolean} True when an available installation was focused.
|
||||
*/
|
||||
focusById(id) {
|
||||
const record = layerState.recordById.get(String(id));
|
||||
if (!record || !layerState.viewer) return false;
|
||||
// No camera flight without a real selection: a flight plus a stale subject
|
||||
// reads as success to Context navigation and strands NEXT on this item.
|
||||
if (!parts.selection.selectRecord(record.id)) return false;
|
||||
layerState.viewer.camera.flyToBoundingSphere(
|
||||
new Cesium.BoundingSphere(
|
||||
Cesium.Cartesian3.fromDegrees(
|
||||
record.longitude,
|
||||
record.latitude,
|
||||
parts.rendering.installationSurfaceHeightM(record),
|
||||
),
|
||||
18000,
|
||||
),
|
||||
{ duration: 1.4 },
|
||||
);
|
||||
return true;
|
||||
},
|
||||
|
||||
getStats() {
|
||||
return {
|
||||
count: layerState.records.length,
|
||||
lastUpdate: layerState.lastUpdate,
|
||||
stale: layerState.stale,
|
||||
saturated: layerState.saturated,
|
||||
error: layerState.error,
|
||||
status: layerState.status,
|
||||
loading: layerState.loading,
|
||||
retryAt: layerState.retryAt,
|
||||
retrying: layerState.loading && Boolean(layerState.failureReason),
|
||||
failureReason: layerState.failureReason,
|
||||
statusMessage: installationFeedback({
|
||||
...layerState,
|
||||
retrying: layerState.loading && Boolean(layerState.failureReason),
|
||||
}),
|
||||
loadingLabel: layerState.loading
|
||||
? 'loading mapped installation context'
|
||||
: '',
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
return { methods };
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
import { createModel } from './model.js';
|
||||
import { createRendering } from './rendering.js';
|
||||
import { createIngestion } from './ingestion.js';
|
||||
import { createViewport } from './viewport.js';
|
||||
import { createSelection } from './selection.js';
|
||||
import { createControls } from './controls.js';
|
||||
import { createLifecycle } from './lifecycle.js';
|
||||
import { createState } from './state.js';
|
||||
|
||||
/** Construct one layer with its own scene state and supplied application services. */
|
||||
export function createInstallationsLayer({ services, source }) {
|
||||
if (
|
||||
typeof source?.getMappedSites !== 'function' ||
|
||||
typeof source?.searchNearby !== 'function'
|
||||
)
|
||||
throw new TypeError('An installation source is required');
|
||||
const state = createState({ services });
|
||||
const parts = {};
|
||||
const context = { state, services, parts, source };
|
||||
parts.model = createModel(context);
|
||||
parts.rendering = createRendering(context);
|
||||
parts.ingestion = createIngestion(context);
|
||||
parts.viewport = createViewport(context);
|
||||
parts.selection = createSelection(context);
|
||||
parts.controls = createControls(context);
|
||||
parts.lifecycle = createLifecycle(context);
|
||||
return Object.assign(
|
||||
{},
|
||||
parts.controls.methods,
|
||||
parts.lifecycle.methods,
|
||||
parts.ingestion?.methods,
|
||||
{
|
||||
approximateSurfaceDistanceM: parts.model.approximateSurfaceDistanceM,
|
||||
classifyGoogleMilitaryPlace: parts.model.classifyGoogleMilitaryPlace,
|
||||
installationSourceLabel: parts.model.installationSourceLabel,
|
||||
installationSurfaceHeightM: parts.rendering.installationSurfaceHeightM,
|
||||
installationWithinViewport: parts.model.installationWithinViewport,
|
||||
installationResponseSaturated: parts.model.installationResponseSaturated,
|
||||
installationRetryDelayMs: parts.viewport.installationRetryDelayMs,
|
||||
},
|
||||
);
|
||||
}
|
||||
export { createInstallationSource } from './source.js';
|
||||
@@ -0,0 +1,214 @@
|
||||
import { normalizeMilitaryInstallations } from '../../data/militaryInstallationData.js';
|
||||
|
||||
export function createIngestion({
|
||||
state: layerState,
|
||||
services,
|
||||
parts,
|
||||
source,
|
||||
}) {
|
||||
const { governorRequestRender } = services.render;
|
||||
const { resolveGroundFloorCellsBounded } = services.ground;
|
||||
|
||||
/**
|
||||
* Commit a status/error transition and buy the one frame it needs.
|
||||
*
|
||||
* With the render governor idle — Contacts has released its hold and nothing
|
||||
* else animates — no frame would otherwise arrive to re-read this, so a load
|
||||
* that fails after the scene went quiet would leave the last healthy readout on
|
||||
* screen indefinitely.
|
||||
* @param {string} status @param {?string} error
|
||||
*/
|
||||
|
||||
function setInstallationStatus(status, error = null) {
|
||||
if (layerState.status === status && layerState.error === error) return;
|
||||
layerState.status = status;
|
||||
layerState.error = error;
|
||||
governorRequestRender('installations-status');
|
||||
}
|
||||
|
||||
async function loadInstallations() {
|
||||
if (!layerState.enabled || !layerState.viewer) return;
|
||||
const box = parts.viewport.viewportBox(layerState.viewer);
|
||||
// Guidance, not a fault: the layer chose not to query because the view is
|
||||
// unbounded (a global view, or Cockpit looking at the horizon). Keep it out
|
||||
// of `error` — the manager derives refresh failures and the global status
|
||||
// chip derives LOAD FAILED from that field, and a "zoom in" prompt turned
|
||||
// every Cockpit refresh into a reported failure. The row and toast read the
|
||||
// prompt from `statusMessage` (installationFeedback) instead.
|
||||
if (!box) {
|
||||
layerState.abort?.abort();
|
||||
layerState.abort = null;
|
||||
layerState.loading = false;
|
||||
parts.viewport.clearUnavailableRetry();
|
||||
setInstallationStatus('zoom-in');
|
||||
return;
|
||||
}
|
||||
layerState.abort?.abort();
|
||||
const requestAbort = new AbortController();
|
||||
layerState.abort = requestAbort;
|
||||
layerState.loading = true;
|
||||
parts.viewport.clearUnavailableRetry({ resetBackoff: false });
|
||||
// The previous attempt's failure is not the outcome of this new attempt.
|
||||
setInstallationStatus('loading');
|
||||
try {
|
||||
const fetchInstallations = (exact) =>
|
||||
source.getMappedSites(box, { exact, signal: requestAbort.signal });
|
||||
|
||||
let payload = await fetchInstallations(false);
|
||||
// A SATURATED snapped tile was truncated upstream, so features from the
|
||||
// snap's extra ring may have crowded out sites actually on screen. Re-ask
|
||||
// for the exact viewport (separately keyed and cached) before rendering.
|
||||
let saturated = parts.model.installationResponseSaturated(payload);
|
||||
if (saturated) {
|
||||
payload = await fetchInstallations(true);
|
||||
saturated = parts.model.installationResponseSaturated(payload);
|
||||
}
|
||||
const normalized = normalizeMilitaryInstallations(
|
||||
payload,
|
||||
payload.retrievedAt || new Date().toISOString(),
|
||||
);
|
||||
// The proxy answers a bbox at least as large as the viewport; keep only what
|
||||
// was actually asked for so nothing off-screen reaches the map or the
|
||||
// "current viewport only" context claim.
|
||||
const records = normalized.records.filter((record) =>
|
||||
parts.model.installationWithinViewport(record, box),
|
||||
);
|
||||
let placesError = null;
|
||||
if (layerState.googleSearchRequested) {
|
||||
layerState.googleSearchRequested = false;
|
||||
const latitude = (box.south + box.north) / 2;
|
||||
const longitude = (box.west + box.east) / 2;
|
||||
const radiusM = Math.min(
|
||||
50000,
|
||||
Math.max(
|
||||
1000,
|
||||
Math.round(
|
||||
Math.max(box.north - box.south, box.east - box.west) * 55_000,
|
||||
),
|
||||
),
|
||||
);
|
||||
try {
|
||||
const placesPayload = await source.searchNearby(
|
||||
{ latitude, longitude, radiusM },
|
||||
{ signal: requestAbort.signal },
|
||||
);
|
||||
const seen = new Set(
|
||||
records.map(
|
||||
(record) =>
|
||||
`${record.name.toLowerCase()}|${record.latitude.toFixed(3)}|${record.longitude.toFixed(3)}`,
|
||||
),
|
||||
);
|
||||
for (const place of Array.isArray(placesPayload?.places)
|
||||
? placesPayload.places
|
||||
: []) {
|
||||
if (
|
||||
!place?.id ||
|
||||
!place?.name ||
|
||||
!Number.isFinite(place.latitude) ||
|
||||
!Number.isFinite(place.longitude)
|
||||
)
|
||||
continue;
|
||||
const placeClass = parts.model.classifyGoogleMilitaryPlace(place);
|
||||
const signature = `${String(place.name).toLowerCase()}|${place.latitude.toFixed(3)}|${place.longitude.toFixed(3)}`;
|
||||
if (seen.has(signature)) continue;
|
||||
seen.add(signature);
|
||||
const retrievedAt = new Date().toISOString();
|
||||
records.push({
|
||||
id: `google:${place.id}`,
|
||||
kind:
|
||||
placeClass === 'military_land'
|
||||
? 'installation'
|
||||
: 'place_candidate',
|
||||
class: placeClass,
|
||||
name: String(place.name).trim(),
|
||||
latitude: place.latitude,
|
||||
longitude: place.longitude,
|
||||
footprint: null,
|
||||
primaryType: place.primaryType || null,
|
||||
placeTypes: Array.isArray(place.types) ? place.types : [],
|
||||
sources: [
|
||||
{ name: 'Google Maps Places', id: place.id, retrievedAt },
|
||||
],
|
||||
validation: 'unreviewed',
|
||||
retrievedAt,
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
if (
|
||||
requestAbort.signal.aborted ||
|
||||
layerState.abort !== requestAbort ||
|
||||
!layerState.enabled ||
|
||||
error?.name === 'AbortError'
|
||||
)
|
||||
return;
|
||||
placesError =
|
||||
'Google Places search unavailable; showing mapped sites';
|
||||
}
|
||||
}
|
||||
await resolveGroundFloorCellsBounded(
|
||||
records.map((record) => ({
|
||||
lat: record.latitude,
|
||||
lon: record.longitude,
|
||||
})),
|
||||
);
|
||||
if (
|
||||
requestAbort.signal.aborted ||
|
||||
layerState.abort !== requestAbort ||
|
||||
!layerState.enabled
|
||||
)
|
||||
return;
|
||||
layerState.records = records;
|
||||
layerState.recordById = new Map(
|
||||
layerState.records.map((record) => [record.id, record]),
|
||||
);
|
||||
layerState.lastUpdate = Date.now();
|
||||
layerState.stale = payload.status === 'stale';
|
||||
// Even the exact-viewport retry can saturate in a dense area. Say so rather
|
||||
// than implying the view is completely surveyed.
|
||||
layerState.saturated = saturated;
|
||||
layerState.failureReason = null;
|
||||
parts.viewport.clearUnavailableRetry();
|
||||
setInstallationStatus(
|
||||
layerState.records.length
|
||||
? layerState.stale
|
||||
? 'stale'
|
||||
: 'ready'
|
||||
: 'empty',
|
||||
payload.status === 'stale'
|
||||
? 'Serving cached mapped context'
|
||||
: saturated
|
||||
? 'Too many mapped sites in view to list them all'
|
||||
: placesError,
|
||||
);
|
||||
parts.rendering.renderRecords();
|
||||
parts.rendering.warmInstallationFloors(layerState.records);
|
||||
} catch (error) {
|
||||
if (
|
||||
requestAbort.signal.aborted ||
|
||||
layerState.abort !== requestAbort ||
|
||||
!layerState.enabled ||
|
||||
error?.name === 'AbortError'
|
||||
)
|
||||
return;
|
||||
layerState.failureReason = error?.failureReason || 'unavailable';
|
||||
setInstallationStatus(
|
||||
'unavailable',
|
||||
error?.message || 'Installation context unavailable',
|
||||
);
|
||||
parts.viewport.scheduleUnavailableRetry();
|
||||
} finally {
|
||||
// An older aborted request must not clear a newer request's busy state.
|
||||
if (layerState.abort === requestAbort) {
|
||||
layerState.abort = null;
|
||||
layerState.loading = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
const methods = {
|
||||
update() {
|
||||
return loadInstallations();
|
||||
},
|
||||
};
|
||||
|
||||
return { setInstallationStatus, loadInstallations, methods };
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
import * as Cesium from 'cesium';
|
||||
import { LAYER_ID } from './policy.js';
|
||||
|
||||
export function createLifecycle({
|
||||
state: layerState,
|
||||
services,
|
||||
parts,
|
||||
source,
|
||||
}) {
|
||||
const { registerPickOwner, unregisterPickOwner } = services.picking;
|
||||
const { clearSelectedEntityContextForLayer } = services.context;
|
||||
|
||||
const methods = {
|
||||
init(viewer) {
|
||||
layerState.viewer = viewer;
|
||||
layerState.dataSource = new Cesium.CustomDataSource(
|
||||
'military-installations',
|
||||
);
|
||||
viewer.dataSources.add(layerState.dataSource);
|
||||
layerState.moveEndRemove = viewer.camera.moveEnd.addEventListener(
|
||||
parts.viewport.scheduleLoad,
|
||||
);
|
||||
parts.selection.installInteraction(viewer);
|
||||
},
|
||||
|
||||
enable() {
|
||||
layerState.enabled = true;
|
||||
registerPickOwner(LAYER_ID, (id) => layerState.recordById.has(id));
|
||||
layerState.dataSource.show = true;
|
||||
// DataLayerManager invokes update() immediately after enable(), which owns
|
||||
// the first fetch. Avoid racing it with a second aborting request here.
|
||||
},
|
||||
|
||||
disable() {
|
||||
layerState.enabled = false;
|
||||
unregisterPickOwner(LAYER_ID);
|
||||
parts.viewport.clearUnavailableRetry();
|
||||
clearTimeout(layerState.timer);
|
||||
layerState.abort?.abort();
|
||||
layerState.abort = null;
|
||||
layerState.loading = false;
|
||||
if (layerState.dataSource) layerState.dataSource.show = false;
|
||||
clearSelectedEntityContextForLayer(LAYER_ID);
|
||||
layerState.selectedId = null;
|
||||
layerState.failureReason = null;
|
||||
parts.ingestion.setInstallationStatus('idle');
|
||||
},
|
||||
|
||||
destroy(viewer) {
|
||||
this.disable();
|
||||
layerState.moveEndRemove?.();
|
||||
layerState.clickHandler?.destroy();
|
||||
layerState.clickHandler = null;
|
||||
parts.rendering.clearRendered();
|
||||
if (layerState.dataSource && viewer)
|
||||
viewer.dataSources.remove(layerState.dataSource, true);
|
||||
layerState.dataSource = null;
|
||||
},
|
||||
};
|
||||
|
||||
return { methods };
|
||||
}
|
||||
@@ -0,0 +1,165 @@
|
||||
import * as Cesium from 'cesium';
|
||||
import {
|
||||
EARTH_MEAN_RADIUS_M,
|
||||
COLOR_BY_CLASS,
|
||||
GOOGLE_MILITARY_PLACE_TYPES,
|
||||
} from './policy.js';
|
||||
|
||||
export function createModel({ state: layerState, services, parts, source }) {
|
||||
/**
|
||||
* Allocation-free spherical distance used only as a conservative rejection
|
||||
* pass before the exact ellipsoidal geodesic calculation.
|
||||
*/
|
||||
|
||||
function approximateSurfaceDistanceM(
|
||||
latitudeARad,
|
||||
longitudeARad,
|
||||
latitudeBDeg,
|
||||
longitudeBDeg,
|
||||
) {
|
||||
const latitudeBRad = Cesium.Math.toRadians(latitudeBDeg);
|
||||
const longitudeBRad = Cesium.Math.toRadians(longitudeBDeg);
|
||||
const latitudeDelta = latitudeBRad - latitudeARad;
|
||||
const longitudeDelta = Math.atan2(
|
||||
Math.sin(longitudeBRad - longitudeARad),
|
||||
Math.cos(longitudeBRad - longitudeARad),
|
||||
);
|
||||
const sinLatitude = Math.sin(latitudeDelta / 2);
|
||||
const sinLongitude = Math.sin(longitudeDelta / 2);
|
||||
const haversine =
|
||||
sinLatitude * sinLatitude +
|
||||
Math.cos(latitudeARad) *
|
||||
Math.cos(latitudeBRad) *
|
||||
sinLongitude *
|
||||
sinLongitude;
|
||||
return (
|
||||
2 * EARTH_MEAN_RADIUS_M * Math.asin(Math.min(1, Math.sqrt(haversine)))
|
||||
);
|
||||
}
|
||||
|
||||
function colorFor(record) {
|
||||
return Cesium.Color.fromCssColorString(
|
||||
COLOR_BY_CLASS[record.class] || '#9ca6b0',
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Classify a Places text-search result without turning a name match into a
|
||||
* mapped military-land claim. Google currently has no documented military
|
||||
* Places type, so ordinary results remain visually distinct candidates; the
|
||||
* explicit branch is retained for any source response that does carry one.
|
||||
* @param {object} place Google Places result.
|
||||
* @returns {string|null} Installation class, or null when not authoritative.
|
||||
*/
|
||||
|
||||
function classifyGoogleMilitaryPlace(place) {
|
||||
const types = new Set(
|
||||
[place?.primaryType, ...(Array.isArray(place?.types) ? place.types : [])]
|
||||
.map((value) =>
|
||||
String(value || '')
|
||||
.trim()
|
||||
.toLowerCase(),
|
||||
)
|
||||
.filter(Boolean),
|
||||
);
|
||||
return [...types].some((type) => GOOGLE_MILITARY_PLACE_TYPES.has(type))
|
||||
? 'military_land'
|
||||
: 'places_candidate';
|
||||
}
|
||||
|
||||
/** @param {object} record @returns {string} Human-readable source attribution. */
|
||||
|
||||
function installationSourceLabel(record) {
|
||||
const names = [
|
||||
...new Set(
|
||||
(Array.isArray(record?.sources) ? record.sources : [])
|
||||
.map((source) => String(source?.name || '').trim())
|
||||
.filter(Boolean),
|
||||
),
|
||||
];
|
||||
return names.join(' + ') || 'Unknown mapped source';
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a mapped record belongs to the REQUESTED viewport.
|
||||
*
|
||||
* The proxy snaps the request bbox outward onto a shared cache grid, so a
|
||||
* response is a SUPERSET of what was asked for, and rendering that superset
|
||||
* would put off-screen sites into the map and into the "CURRENT VIEWPORT ONLY"
|
||||
* context claim. What may be tested depends on how much of a feature's geometry
|
||||
* we actually hold:
|
||||
*
|
||||
* - A NODE is a point: its centre IS its whole geometry, so an exact
|
||||
* containment test is correct and loses nothing.
|
||||
* - A record WITH a footprint is tested by bounding-box overlap. Overpass bbox
|
||||
* queries return features that merely INTERSECT the box, so centre-testing
|
||||
* these would drop large bases whose centre sits just outside.
|
||||
* - A way or relation WITHOUT a footprint is KEPT. Relations carry geometry on
|
||||
* their members and ways beyond MAX_FOOTPRINT_POINTS are normalized without
|
||||
* one, so their true extent is unknown here — and Overpass already proved
|
||||
* they intersect the queried bbox. Centre-testing them would erase exactly
|
||||
* the biggest installations. The honest cost is slight over-inclusion,
|
||||
* bounded by one snap cell (~5.5 km) around the viewport.
|
||||
*
|
||||
* @param {{latitude:number, longitude:number, footprint:?Array, osmType:?string}} record
|
||||
* @param {{south:number, west:number, north:number, east:number}} box Requested viewport.
|
||||
* @returns {boolean}
|
||||
*/
|
||||
|
||||
function installationWithinViewport(record, box) {
|
||||
if (!record || !box) return false;
|
||||
const { latitude, longitude, footprint } = record;
|
||||
const centreInside =
|
||||
latitude >= box.south &&
|
||||
latitude <= box.north &&
|
||||
longitude >= box.west &&
|
||||
longitude <= box.east;
|
||||
if (centreInside) return true;
|
||||
if (Array.isArray(footprint) && footprint.length) {
|
||||
let minLat = Infinity;
|
||||
let maxLat = -Infinity;
|
||||
let minLon = Infinity;
|
||||
let maxLon = -Infinity;
|
||||
for (const [lon, lat] of footprint) {
|
||||
if (lat < minLat) minLat = lat;
|
||||
if (lat > maxLat) maxLat = lat;
|
||||
if (lon < minLon) minLon = lon;
|
||||
if (lon > maxLon) maxLon = lon;
|
||||
}
|
||||
return (
|
||||
maxLat >= box.south &&
|
||||
minLat <= box.north &&
|
||||
maxLon >= box.west &&
|
||||
minLon <= box.east
|
||||
);
|
||||
}
|
||||
// Unknown extent: inclusive. Only a point feature may be excluded on centre.
|
||||
return record.osmType !== 'node';
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a response was truncated at the upstream element cap.
|
||||
*
|
||||
* The proxy states this outright, but a `saturated`-less payload is NOT
|
||||
* evidence of a complete answer: entries cached before the saturation guard
|
||||
* shipped predate the field and live for 30 days. Fall back to deriving it from
|
||||
* the element count against the cap the payload itself reports.
|
||||
* @param {{saturated?: boolean, elements?: Array, elementCap?: number}} payload
|
||||
* @returns {boolean}
|
||||
*/
|
||||
|
||||
function installationResponseSaturated(payload) {
|
||||
if (typeof payload?.saturated === 'boolean') return payload.saturated;
|
||||
const cap = Number(payload?.elementCap);
|
||||
if (!Number.isFinite(cap) || cap <= 0) return false;
|
||||
return Array.isArray(payload?.elements) && payload.elements.length >= cap;
|
||||
}
|
||||
return {
|
||||
approximateSurfaceDistanceM,
|
||||
colorFor,
|
||||
classifyGoogleMilitaryPlace,
|
||||
installationSourceLabel,
|
||||
installationWithinViewport,
|
||||
installationResponseSaturated,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
export const LAYER_ID = 'military-installations';
|
||||
|
||||
export const REQUEST_DEBOUNCE_MS = 500;
|
||||
|
||||
export const MAX_VIEWPORT_DEGREES = 10;
|
||||
|
||||
export const MAX_RENDERED = 700;
|
||||
|
||||
export const GOOGLE_MILITARY_PLACE_TYPES = new Set(['military_base']);
|
||||
|
||||
export const COLOR_BY_CLASS = {
|
||||
airfield: '#5aa9ff',
|
||||
naval_base: '#48c7d5',
|
||||
range: '#d9a85d',
|
||||
military_land: '#9ca6b0',
|
||||
places_candidate: '#c58cff',
|
||||
};
|
||||
|
||||
export const EARTH_MEAN_RADIUS_M = 6371008.8;
|
||||
|
||||
export const DISTANCE_PREFILTER_MARGIN_M = 5000;
|
||||
@@ -0,0 +1,195 @@
|
||||
import * as Cesium from 'cesium';
|
||||
import { LAYER_ID, MAX_RENDERED, COLOR_BY_CLASS } from './policy.js';
|
||||
|
||||
export function createRendering({
|
||||
state: layerState,
|
||||
services,
|
||||
parts,
|
||||
source,
|
||||
}) {
|
||||
const { floorAltitudeM, cachedGroundFloor } = services.ground;
|
||||
const {
|
||||
removeEntityContextsForLayer,
|
||||
getSelectedEntityContext,
|
||||
registerEntityContext,
|
||||
selectEntityContext,
|
||||
} = services.context;
|
||||
const { governorRequestRender } = services.render;
|
||||
const { warmFireAnchorFloors } = services.anchors;
|
||||
|
||||
/**
|
||||
* Shared rendered-surface height for an installation anchor or footprint.
|
||||
* @param {{latitude:number, longitude:number}} record Installation record.
|
||||
* @returns {number} Ellipsoidal render height in metres.
|
||||
*/
|
||||
|
||||
function installationSurfaceHeightM(record) {
|
||||
return (
|
||||
floorAltitudeM(
|
||||
null,
|
||||
cachedGroundFloor(record?.latitude, record?.longitude),
|
||||
) ?? 0
|
||||
);
|
||||
}
|
||||
|
||||
function clearRendered() {
|
||||
if (layerState.dataSource?.entities)
|
||||
layerState.dataSource.entities.removeAll();
|
||||
removeEntityContextsForLayer(LAYER_ID);
|
||||
}
|
||||
|
||||
/**
|
||||
* The records that get entities this paint: the nearest `MAX_RENDERED`, plus
|
||||
* the selected one when it falls outside that window.
|
||||
*
|
||||
* Context navigation walks the FULL nearby cohort, which is not bounded by the
|
||||
* render cap, so selecting item 701+ used to produce no entity at all — the
|
||||
* camera flew, `getById` returned null, and the selection was silently dropped
|
||||
* on the floor, leaving the Context subject stale so NEXT offered the same
|
||||
* installation forever. One extra entity keeps every cohort item selectable
|
||||
* and the cohort count honest.
|
||||
* @returns {Array<object>} Records to render this paint.
|
||||
*/
|
||||
|
||||
function renderableRecords() {
|
||||
const rendered = layerState.records.slice(0, MAX_RENDERED);
|
||||
if (!layerState.selectedId) return rendered;
|
||||
if (rendered.some((record) => record.id === layerState.selectedId))
|
||||
return rendered;
|
||||
const selected = layerState.recordById.get(layerState.selectedId);
|
||||
return selected ? [...rendered, selected] : rendered;
|
||||
}
|
||||
|
||||
function renderRecords({ claimSelection = false } = {}) {
|
||||
// Context navigation can select another layer without a canvas click.
|
||||
// A delayed floor/data repaint must not steal that newer selection back.
|
||||
const selectedContext = getSelectedEntityContext();
|
||||
if (
|
||||
!claimSelection &&
|
||||
layerState.selectedId &&
|
||||
selectedContext &&
|
||||
selectedContext.id !== layerState.selectedId
|
||||
) {
|
||||
layerState.selectedId = null;
|
||||
}
|
||||
// Post-moveEnd debounced fetches commit after the camera settles; the
|
||||
// rebuilt entities need one frame in idle mode. (perf wave 2 fix)
|
||||
governorRequestRender('installations-render');
|
||||
clearRendered();
|
||||
for (const record of renderableRecords()) {
|
||||
const color = parts.model.colorFor(record);
|
||||
const surfaceHeightM = installationSurfaceHeightM(record);
|
||||
const displayPosition = Cesium.Cartesian3.fromDegrees(
|
||||
record.longitude,
|
||||
record.latitude,
|
||||
surfaceHeightM,
|
||||
);
|
||||
const entity = layerState.dataSource.entities.add({
|
||||
id: record.id,
|
||||
position: displayPosition,
|
||||
point: {
|
||||
pixelSize: record.id === layerState.selectedId ? 13 : 9,
|
||||
color:
|
||||
record.id === layerState.selectedId ? Cesium.Color.WHITE : color,
|
||||
outlineColor: Cesium.Color.BLACK.withAlpha(0.8),
|
||||
outlineWidth: 1,
|
||||
disableDepthTestDistance: Number.POSITIVE_INFINITY,
|
||||
},
|
||||
polygon: record.footprint
|
||||
? {
|
||||
hierarchy: new Cesium.PolygonHierarchy(
|
||||
record.footprint.map(([longitude, latitude]) =>
|
||||
Cesium.Cartesian3.fromDegrees(longitude, latitude),
|
||||
),
|
||||
),
|
||||
material: color.withAlpha(0.12),
|
||||
outline: true,
|
||||
outlineColor: color.withAlpha(0.65),
|
||||
height: surfaceHeightM,
|
||||
}
|
||||
: undefined,
|
||||
});
|
||||
entity.gevTrackedId = `installations:${record.id}`;
|
||||
entity.gevDisplayPosition = () => displayPosition;
|
||||
entity.gevLabelModel = {
|
||||
title: record.name || 'MAPPED INSTALLATION',
|
||||
details: [
|
||||
String(record.class || 'installation')
|
||||
.replaceAll('_', ' ')
|
||||
.toUpperCase(),
|
||||
],
|
||||
accent: COLOR_BY_CLASS[record.class] || '#9ca6b0',
|
||||
};
|
||||
registerEntityContext(entity, {
|
||||
id: record.id,
|
||||
layerId: LAYER_ID,
|
||||
layerName:
|
||||
record.kind === 'place_candidate'
|
||||
? 'Military Site Search Candidates'
|
||||
: 'Mapped Military Installations',
|
||||
source: parts.model.installationSourceLabel(record),
|
||||
label: record.name,
|
||||
latitude: record.latitude,
|
||||
longitude: record.longitude,
|
||||
properties: {
|
||||
class: record.class,
|
||||
primaryType: record.primaryType || null,
|
||||
placeTypes: Array.isArray(record.placeTypes) ? record.placeTypes : [],
|
||||
validation: record.validation,
|
||||
retrievedAt: record.retrievedAt,
|
||||
},
|
||||
});
|
||||
}
|
||||
const selectedEntity = layerState.selectedId
|
||||
? layerState.dataSource.entities.getById(layerState.selectedId)
|
||||
: null;
|
||||
if (selectedEntity) selectEntityContext(selectedEntity);
|
||||
else layerState.selectedId = null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Second paint for floors that missed the bounded pre-render deadline.
|
||||
*
|
||||
* `resolveGroundFloorCellsBounded` gives up after FLOOR_RESOLVE_DEADLINE_MS so
|
||||
* a cold DEM can never hold the dots hostage — but the resolve keeps running
|
||||
* and lands seconds later, and without this the records it covers stay pinned
|
||||
* at ellipsoid height 0, sitting visibly under the 3D tiles (owner playtest
|
||||
* 2026-08-18: "orange dots at the bottom").
|
||||
*
|
||||
* This is the render -> warm -> re-render chain FIRMS already uses, with one
|
||||
* difference the installations path forces: the trigger is whether a cell that
|
||||
* was COLD AT PAINT TIME is warm now, not whether this particular batch warmed
|
||||
* it. The bounded resolve above is still running against the same cells, so
|
||||
* asking "did MY batch warm anything" would answer false exactly when the other
|
||||
* resolve won the race — the common case. Still terminating: a set that is
|
||||
* wholly cold afterwards re-renders zero times and the next camera-driven load
|
||||
* retries.
|
||||
* @param {Array<object>} records Records just rendered.
|
||||
* @returns {void}
|
||||
*/
|
||||
|
||||
function warmInstallationFloors(records) {
|
||||
const cold = records
|
||||
.filter(
|
||||
(record) =>
|
||||
cachedGroundFloor(record.latitude, record.longitude) == null,
|
||||
)
|
||||
.map((record) => ({ lat: record.latitude, lon: record.longitude }));
|
||||
if (!cold.length) return;
|
||||
warmFireAnchorFloors(cold).then(() => {
|
||||
if (!layerState.enabled || !layerState.dataSource) return;
|
||||
if (
|
||||
!cold.some((point) => cachedGroundFloor(point.lat, point.lon) != null)
|
||||
)
|
||||
return;
|
||||
renderRecords();
|
||||
});
|
||||
}
|
||||
return {
|
||||
installationSurfaceHeightM,
|
||||
clearRendered,
|
||||
renderableRecords,
|
||||
renderRecords,
|
||||
warmInstallationFloors,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
import * as Cesium from 'cesium';
|
||||
import { LAYER_ID } from './policy.js';
|
||||
|
||||
export function createSelection({
|
||||
state: layerState,
|
||||
services,
|
||||
parts,
|
||||
source,
|
||||
}) {
|
||||
const { clearSelectedEntityContextForLayer } = services.context;
|
||||
|
||||
function selectRecord(id) {
|
||||
const record = layerState.recordById.get(id);
|
||||
if (!record || !layerState.dataSource) return false;
|
||||
layerState.selectedId = id;
|
||||
parts.rendering.renderRecords({ claimSelection: true });
|
||||
// renderRecords drops selectedId when the record produced no entity.
|
||||
return layerState.selectedId === id;
|
||||
}
|
||||
|
||||
function installInteraction(viewer) {
|
||||
if (layerState.clickHandler) return;
|
||||
layerState.clickHandler = new Cesium.ScreenSpaceEventHandler(
|
||||
viewer.scene.canvas,
|
||||
);
|
||||
layerState.clickHandler.setInputAction((click) => {
|
||||
if (!layerState.enabled) return;
|
||||
const picked = viewer.scene.pick(click.position);
|
||||
const id = typeof picked?.id?.id === 'string' ? picked.id.id : null;
|
||||
if (id && layerState.recordById.has(id) && id !== layerState.selectedId) {
|
||||
selectRecord(id);
|
||||
} else if (layerState.selectedId) {
|
||||
// Clicking the selected site again, empty map, or another contact
|
||||
// releases this layer's selection. Clear only our shared context so a
|
||||
// sibling click handler's newly selected aircraft/site stays intact.
|
||||
layerState.selectedId = null;
|
||||
clearSelectedEntityContextForLayer(LAYER_ID);
|
||||
parts.rendering.renderRecords();
|
||||
}
|
||||
}, Cesium.ScreenSpaceEventType.LEFT_CLICK);
|
||||
}
|
||||
return { selectRecord, installInteraction };
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
/** Read mapped installations and explicit nearby-place searches through fixed endpoints. */
|
||||
export function createInstallationSource({
|
||||
fetchImpl = (...args) => globalThis.fetch(...args),
|
||||
} = {}) {
|
||||
return {
|
||||
async getMappedSites(box, { exact = false, signal } = {}) {
|
||||
const { south, west, north, east } = box || {};
|
||||
if (
|
||||
![south, west, north, east].every(Number.isFinite) ||
|
||||
south < -90 ||
|
||||
north > 90 ||
|
||||
west < -180 ||
|
||||
east > 180 ||
|
||||
north <= south ||
|
||||
east <= west ||
|
||||
north - south > 10 ||
|
||||
east - west > 10
|
||||
)
|
||||
throw new TypeError('A bounded installation viewport is required');
|
||||
signal?.throwIfAborted();
|
||||
const query = new URLSearchParams(
|
||||
Object.entries({ south, west, north, east }).map(([key, value]) => [
|
||||
key,
|
||||
value.toFixed(5),
|
||||
]),
|
||||
);
|
||||
if (exact) query.set('exact', '1');
|
||||
const response = await fetchImpl(`/api/military-installations?${query}`, {
|
||||
signal,
|
||||
});
|
||||
const body = await response.json();
|
||||
signal?.throwIfAborted();
|
||||
if (!response.ok)
|
||||
throw Object.assign(
|
||||
new Error(body?.error || `Installation feed HTTP ${response.status}`),
|
||||
{
|
||||
failureReason: ['rate_limited', 'timeout', 'query_failed'].includes(
|
||||
body?.reason,
|
||||
)
|
||||
? body.reason
|
||||
: 'unavailable',
|
||||
},
|
||||
);
|
||||
if (!Array.isArray(body?.elements))
|
||||
throw new Error('Malformed installation snapshot');
|
||||
return body;
|
||||
},
|
||||
async searchNearby({ latitude, longitude, radiusM }, { signal } = {}) {
|
||||
if (
|
||||
![latitude, longitude, radiusM].every(Number.isFinite) ||
|
||||
Math.abs(latitude) > 90 ||
|
||||
Math.abs(longitude) > 180 ||
|
||||
radiusM < 1000 ||
|
||||
radiusM > 50000
|
||||
)
|
||||
throw new TypeError('Invalid nearby installation search');
|
||||
signal?.throwIfAborted();
|
||||
const response = await fetchImpl(
|
||||
`/api/google/text-search?${new URLSearchParams({
|
||||
q: 'military installation',
|
||||
lat: latitude.toFixed(5),
|
||||
lon: longitude.toFixed(5),
|
||||
radiusM: String(radiusM),
|
||||
})}`,
|
||||
{ signal },
|
||||
);
|
||||
const payload = await response.json();
|
||||
signal?.throwIfAborted();
|
||||
if (!response.ok)
|
||||
throw new Error(
|
||||
payload?.error || `Google Places HTTP ${response.status}`,
|
||||
);
|
||||
if (!Array.isArray(payload?.places))
|
||||
throw new Error('Malformed nearby-place snapshot');
|
||||
return payload;
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { createInstallationSource } from './source.js';
|
||||
|
||||
const box = { south: 30.1, west: -97.9, north: 30.3, east: -97.6 };
|
||||
test('mapped-site sources validate viewport bounds and preserve the exact retry key', async () => {
|
||||
const calls = [];
|
||||
const source = createInstallationSource({
|
||||
fetchImpl: async (url) => {
|
||||
calls.push(new URL(url, 'https://example.test'));
|
||||
return new Response(JSON.stringify({ elements: [], status: 'stale' }));
|
||||
},
|
||||
});
|
||||
for (const invalid of [
|
||||
null,
|
||||
{ ...box, east: Infinity },
|
||||
{ ...box, north: 90 },
|
||||
{ ...box, south: 31 },
|
||||
])
|
||||
await assert.rejects(
|
||||
source.getMappedSites(invalid),
|
||||
/bounded installation viewport/,
|
||||
);
|
||||
assert.equal(calls.length, 0);
|
||||
const payload = await source.getMappedSites(box, { exact: true });
|
||||
assert.equal(payload.status, 'stale');
|
||||
assert.equal(calls[0].pathname, '/api/military-installations');
|
||||
assert.equal(calls[0].searchParams.get('exact'), '1');
|
||||
assert.equal(calls[0].searchParams.get('south'), '30.10000');
|
||||
});
|
||||
test('malformed installation and place snapshots are never accepted as empty success', async () => {
|
||||
const source = createInstallationSource({
|
||||
fetchImpl: async () => new Response('{}'),
|
||||
});
|
||||
await assert.rejects(
|
||||
source.getMappedSites(box),
|
||||
/Malformed installation snapshot/,
|
||||
);
|
||||
await assert.rejects(
|
||||
source.searchNearby({ latitude: 30.2, longitude: -97.7, radiusM: 1000 }),
|
||||
/Malformed nearby-place snapshot/,
|
||||
);
|
||||
});
|
||||
test('installation response parsing respects cancellation before any follow-on search', async () => {
|
||||
const controller = new AbortController();
|
||||
const source = createInstallationSource({
|
||||
fetchImpl: async () => ({
|
||||
ok: true,
|
||||
json: async () => {
|
||||
controller.abort();
|
||||
return { elements: [] };
|
||||
},
|
||||
}),
|
||||
});
|
||||
await assert.rejects(
|
||||
source.getMappedSites(box, { signal: controller.signal }),
|
||||
{ name: 'AbortError' },
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,37 @@
|
||||
import * as Cesium from 'cesium';
|
||||
|
||||
export function createState({ services }) {
|
||||
const state = {};
|
||||
|
||||
state.distanceEndpointScratch = new Cesium.Cartographic();
|
||||
|
||||
state.distanceGeodesicScratch = new Cesium.EllipsoidGeodesic();
|
||||
|
||||
Object.assign(state, {
|
||||
viewer: null,
|
||||
dataSource: null,
|
||||
enabled: false,
|
||||
records: [],
|
||||
recordById: new Map(),
|
||||
selectedId: null,
|
||||
lastUpdate: null,
|
||||
error: null,
|
||||
status: 'idle',
|
||||
stale: false,
|
||||
/** Whether the upstream truncated at its element cap for the current view. */
|
||||
saturated: false,
|
||||
loading: false,
|
||||
abort: null,
|
||||
/** Pending timed retry while status is 'unavailable' (see scheduleUnavailableRetry). */
|
||||
retryTimer: null,
|
||||
/** Current backoff step for that retry; 0 = next failure starts at the minimum. */
|
||||
retryDelayMs: 0,
|
||||
retryAt: 0,
|
||||
failureReason: null,
|
||||
moveEndRemove: null,
|
||||
clickHandler: null,
|
||||
timer: null,
|
||||
googleSearchRequested: false,
|
||||
});
|
||||
return state;
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
import * as Cesium from 'cesium';
|
||||
import { MAX_VIEWPORT_DEGREES, REQUEST_DEBOUNCE_MS } from './policy.js';
|
||||
|
||||
export function createViewport({ state: layerState, services, parts, source }) {
|
||||
function viewportBox(viewer) {
|
||||
const rectangle = viewer?.camera?.computeViewRectangle(
|
||||
viewer.scene.globe.ellipsoid,
|
||||
);
|
||||
if (!rectangle) return null;
|
||||
const south = Cesium.Math.toDegrees(rectangle.south);
|
||||
const north = Cesium.Math.toDegrees(rectangle.north);
|
||||
const west = Cesium.Math.toDegrees(rectangle.west);
|
||||
const east = Cesium.Math.toDegrees(rectangle.east);
|
||||
// Cross-dateline/global views require a zoom before a bounded request.
|
||||
if (
|
||||
!Number.isFinite(south + north + west + east) ||
|
||||
east <= west ||
|
||||
north - south > MAX_VIEWPORT_DEGREES ||
|
||||
east - west > MAX_VIEWPORT_DEGREES
|
||||
)
|
||||
return null;
|
||||
return { south, west, north, east };
|
||||
}
|
||||
|
||||
/**
|
||||
* Backoff progression for the unavailable-state retry: 30 s, doubling to a
|
||||
* 240 s ceiling. Pure so the progression is pinnable without booting the layer.
|
||||
*/
|
||||
|
||||
function installationRetryDelayMs(prevDelayMs) {
|
||||
const RETRY_MIN_MS = 30000;
|
||||
const RETRY_CEIL_MS = 240000;
|
||||
if (!Number.isFinite(prevDelayMs) || prevDelayMs <= 0) return RETRY_MIN_MS;
|
||||
return Math.min(prevDelayMs * 2, RETRY_CEIL_MS);
|
||||
}
|
||||
|
||||
/**
|
||||
* 'Temporarily unavailable' must mean temporarily: fetches otherwise fire only
|
||||
* on enable and on camera moveEnd, so a parked camera whose first request died
|
||||
* (one flaky Overpass mirror is enough) stayed unavailable forever while the
|
||||
* proxy sat healthy while the layer refused to show its features. While the
|
||||
* layer is enabled and
|
||||
* unavailable, retry on a 30 s → 240 s backoff; any success, user-driven load,
|
||||
* zoom-out, or disable cancels it.
|
||||
*/
|
||||
|
||||
function scheduleUnavailableRetry() {
|
||||
if (!layerState.enabled) return;
|
||||
clearTimeout(layerState.retryTimer);
|
||||
layerState.retryDelayMs = installationRetryDelayMs(layerState.retryDelayMs);
|
||||
layerState.retryAt = Date.now() + layerState.retryDelayMs;
|
||||
layerState.retryTimer = setTimeout(() => {
|
||||
layerState.retryTimer = null;
|
||||
layerState.retryAt = 0;
|
||||
if (layerState.enabled && !layerState.loading)
|
||||
parts.ingestion.loadInstallations();
|
||||
}, layerState.retryDelayMs);
|
||||
}
|
||||
|
||||
function clearUnavailableRetry({ resetBackoff = true } = {}) {
|
||||
clearTimeout(layerState.retryTimer);
|
||||
layerState.retryTimer = null;
|
||||
layerState.retryAt = 0;
|
||||
if (resetBackoff) layerState.retryDelayMs = 0;
|
||||
}
|
||||
|
||||
function scheduleLoad() {
|
||||
if (!layerState.enabled) return;
|
||||
// A user-driven load supersedes any pending retry; the load reschedules on
|
||||
// failure, so the backoff step is kept rather than reset.
|
||||
clearUnavailableRetry({ resetBackoff: false });
|
||||
clearTimeout(layerState.timer);
|
||||
layerState.timer = setTimeout(() => {
|
||||
parts.ingestion.loadInstallations();
|
||||
}, REQUEST_DEBOUNCE_MS);
|
||||
}
|
||||
return {
|
||||
viewportBox,
|
||||
installationRetryDelayMs,
|
||||
scheduleUnavailableRetry,
|
||||
clearUnavailableRetry,
|
||||
scheduleLoad,
|
||||
};
|
||||
}
|
||||
Reference in new issue
Block a user