mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-05 10:53:59 +00:00
Traffic roads come from OpenFreeMap vector tiles, or TomTom flow tiles with a key (Hybrid by default). Military areas use OpenFreeMap polygons with a bundled worldwide name index; wide views show named points. ALPR reads an hourly OpenStreetMap extract. Cockpit place context and country, state and US county outlines use bundled Natural Earth and US Census data. Overpass is used only when an operator configures their own endpoint. Also: camera arrivals no longer land below ground, keyless terrain retries on 429, bounded relation geometry for configured Overpass, one shared OpenStreetMap credit, and separate tile and API transports for embedders. Co-authored-by: theGaryLarson <[email protected]> Co-authored-by: Milan Khanal <[email protected]>
This commit is contained in:
200 files changed
+26665
-1977
No files matched your search
Vendored
+93
-2
@@ -1,6 +1,10 @@
|
||||
import * as Cesium from 'cesium';
|
||||
import { installationFeedback } from '../../data/installationFeedback.js';
|
||||
import { LAYER_ID, DISTANCE_PREFILTER_MARGIN_M } from './policy.js';
|
||||
import {
|
||||
LAYER_ID,
|
||||
DISTANCE_PREFILTER_MARGIN_M,
|
||||
ANCHOR_REFRESH_M,
|
||||
} from './policy.js';
|
||||
|
||||
export function createControls({ state: layerState, services, parts, source }) {
|
||||
const methods = {
|
||||
@@ -16,12 +20,71 @@ export function createControls({ state: layerState, services, parts, source }) {
|
||||
|
||||
statsRefreshInterval: 1000,
|
||||
|
||||
visitNamedMarkers(visitor) {
|
||||
parts.namedMarkers.visit(visitor);
|
||||
},
|
||||
|
||||
/** Request a one-shot Google Maps Places search around the current map view. */
|
||||
searchNearby() {
|
||||
layerState.googleSearchRequested = true;
|
||||
return parts.ingestion.loadInstallations();
|
||||
},
|
||||
|
||||
/**
|
||||
* Load sites around a Contacts subject instead of the camera viewport.
|
||||
*
|
||||
* A follow or Cockpit camera moves every frame, so `moveEnd` never fires and
|
||||
* its view rectangle often reaches the horizon; a subject window is the
|
||||
* only area that answers "what is near this contact". The window moves
|
||||
* only after the subject travels ANCHOR_REFRESH_M, and cached tiles make
|
||||
* that cheap. Passing null returns the layer to viewport loading.
|
||||
* @param {Cesium.Cartesian3|{latitude:number, longitude:number}|null} position
|
||||
* Subject position.
|
||||
* @returns {boolean} Whether a new load was scheduled.
|
||||
*/
|
||||
setContextAnchor(position) {
|
||||
let next = null;
|
||||
if (position) {
|
||||
if (
|
||||
Number.isFinite(position.latitude) &&
|
||||
Number.isFinite(position.longitude)
|
||||
)
|
||||
next = { latitude: position.latitude, longitude: position.longitude };
|
||||
else {
|
||||
const carto = Cesium.Cartographic.fromCartesian(position);
|
||||
if (carto)
|
||||
next = {
|
||||
latitude: Cesium.Math.toDegrees(carto.latitude),
|
||||
longitude: Cesium.Math.toDegrees(carto.longitude),
|
||||
};
|
||||
}
|
||||
}
|
||||
// Keep the live centre even when the coarse fetch anchor has not moved.
|
||||
layerState.contextPosition = next
|
||||
? Cesium.Cartesian3.fromDegrees(next.longitude, next.latitude)
|
||||
: null;
|
||||
const previous = layerState.contextAnchor;
|
||||
if (!next) {
|
||||
if (!previous) return false;
|
||||
layerState.contextAnchor = null;
|
||||
parts.viewport.scheduleLoad();
|
||||
return true;
|
||||
}
|
||||
if (
|
||||
previous &&
|
||||
parts.model.approximateSurfaceDistanceM(
|
||||
Cesium.Math.toRadians(previous.latitude),
|
||||
Cesium.Math.toRadians(previous.longitude),
|
||||
next.latitude,
|
||||
next.longitude,
|
||||
) < ANCHOR_REFRESH_M
|
||||
)
|
||||
return false;
|
||||
layerState.contextAnchor = next;
|
||||
parts.viewport.scheduleLoad();
|
||||
return true;
|
||||
},
|
||||
|
||||
getNearby(center, rangeM, maxCount = 50) {
|
||||
if (!center) return [];
|
||||
const range = Number.isFinite(rangeM) ? rangeM : Infinity;
|
||||
@@ -100,8 +163,30 @@ export function createControls({ state: layerState, services, parts, source }) {
|
||||
},
|
||||
|
||||
getStats() {
|
||||
const namedMarkers = parts.namedMarkers?.stats();
|
||||
const subjectWindow =
|
||||
layerState.coverage?.kind === 'subject' &&
|
||||
layerState.contextPosition &&
|
||||
Number.isFinite(layerState.coverage.radiusM);
|
||||
const mappedCount = subjectWindow
|
||||
? methods.getNearby(
|
||||
layerState.contextPosition,
|
||||
layerState.coverage.radiusM,
|
||||
layerState.records.length || 1,
|
||||
).length
|
||||
: layerState.wide && namedMarkers
|
||||
? namedMarkers.pointsOnScreen
|
||||
: layerState.records.filter(
|
||||
(record) => record.kind === 'installation',
|
||||
).length;
|
||||
return {
|
||||
count: layerState.records.length,
|
||||
count:
|
||||
subjectWindow || layerState.wide
|
||||
? mappedCount
|
||||
: layerState.records.length,
|
||||
namedInView: layerState.namedInView || 0,
|
||||
wide: Boolean(layerState.wide),
|
||||
...namedMarkers,
|
||||
lastUpdate: layerState.lastUpdate,
|
||||
stale: layerState.stale,
|
||||
saturated: layerState.saturated,
|
||||
@@ -113,11 +198,17 @@ export function createControls({ state: layerState, services, parts, source }) {
|
||||
failureReason: layerState.failureReason,
|
||||
statusMessage: installationFeedback({
|
||||
...layerState,
|
||||
count: layerState.lastUpdate ? mappedCount : undefined,
|
||||
retrying: layerState.loading && Boolean(layerState.failureReason),
|
||||
}),
|
||||
loadingLabel: layerState.loading
|
||||
? 'loading mapped installation context'
|
||||
: '',
|
||||
coverage: layerState.coverage,
|
||||
coverageLabel:
|
||||
layerState.coverage?.kind === 'subject'
|
||||
? `WITHIN ${Math.round(layerState.coverage.radiusM / 1000)} KM`
|
||||
: 'CURRENT VIEWPORT ONLY',
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { createNamedMarkers } from './namedMarkers.js';
|
||||
import { createModel } from './model.js';
|
||||
import { createRendering } from './rendering.js';
|
||||
import { createIngestion } from './ingestion.js';
|
||||
@@ -8,7 +9,7 @@ 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 }) {
|
||||
export function createInstallationsLayer({ services, source, overlayHost }) {
|
||||
if (
|
||||
typeof source?.getMappedSites !== 'function' ||
|
||||
typeof source?.searchNearby !== 'function'
|
||||
@@ -16,9 +17,10 @@ export function createInstallationsLayer({ services, source }) {
|
||||
throw new TypeError('An installation source is required');
|
||||
const state = createState({ services });
|
||||
const parts = {};
|
||||
const context = { state, services, parts, source };
|
||||
const context = { state, services, parts, source, overlayHost };
|
||||
parts.model = createModel(context);
|
||||
parts.rendering = createRendering(context);
|
||||
parts.namedMarkers = createNamedMarkers(context);
|
||||
parts.ingestion = createIngestion(context);
|
||||
parts.viewport = createViewport(context);
|
||||
parts.selection = createSelection(context);
|
||||
|
||||
@@ -1,3 +1,50 @@
|
||||
import { militaryOsmKey } from '../../data/militaryNames.js';
|
||||
import { isUnavailableCapability } from '../../sources/capability.js';
|
||||
/** Keep named point coverage until its matching polygon takes over. */
|
||||
export function retainNamedHandoff(previous, records, box) {
|
||||
const named = new Map(
|
||||
previous.filter((r) => r.namedArea).map((r) => [r.id, r]),
|
||||
);
|
||||
const represented = new Set();
|
||||
const next = records.map((record) => {
|
||||
represented.add(record.id);
|
||||
for (const id of record.aliasIds || []) represented.add(id);
|
||||
for (const member of record.namedMembers || []) represented.add(member.id);
|
||||
for (const source of record.sources || []) {
|
||||
const key = militaryOsmKey(source.id);
|
||||
if (key) represented.add(`osm:military:${key}`);
|
||||
}
|
||||
const prior =
|
||||
named.get(record.id) ||
|
||||
(record.aliasIds || [])
|
||||
.map((id) => named.get(id))
|
||||
.filter(Boolean)
|
||||
.sort((a, b) => b.areaM2 - a.areaM2)[0];
|
||||
return !record.namedArea && prior
|
||||
? {
|
||||
...record,
|
||||
name: prior.name,
|
||||
namedArea: true,
|
||||
areaM2: prior.areaM2,
|
||||
class: prior.class,
|
||||
}
|
||||
: record;
|
||||
});
|
||||
for (const record of previous) {
|
||||
if (
|
||||
record.pointOnly &&
|
||||
record.namedArea &&
|
||||
!represented.has(record.id) &&
|
||||
record.longitude >= box.west &&
|
||||
record.longitude <= box.east &&
|
||||
record.latitude >= box.south &&
|
||||
record.latitude <= box.north
|
||||
)
|
||||
next.push(record);
|
||||
}
|
||||
return next;
|
||||
}
|
||||
|
||||
export function createIngestion({
|
||||
state: layerState,
|
||||
services,
|
||||
@@ -26,7 +73,7 @@ export function createIngestion({
|
||||
|
||||
async function loadInstallations() {
|
||||
if (!layerState.enabled || !layerState.viewer) return;
|
||||
const box = parts.viewport.viewportBox(layerState.viewer);
|
||||
const { box, coverage } = parts.viewport.loadArea(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
|
||||
@@ -43,6 +90,7 @@ export function createIngestion({
|
||||
}
|
||||
layerState.abort?.abort();
|
||||
const requestAbort = new AbortController();
|
||||
const keyOwner = (layerState.cameraLoadOwner ??= {});
|
||||
layerState.abort = requestAbort;
|
||||
layerState.loading = true;
|
||||
parts.viewport.clearUnavailableRetry({ resetBackoff: false });
|
||||
@@ -53,19 +101,34 @@ export function createIngestion({
|
||||
source.getMappedSites(box, { exact, signal: requestAbort.signal });
|
||||
|
||||
let payload = await fetchInstallations(false);
|
||||
// Map tiles carry military areas only at z9 and finer: a view too wide
|
||||
// for that is guidance, exactly like an unbounded view, never an empty
|
||||
// all-clear. Previously loaded sites stay on the map.
|
||||
if (payload.zoomIn) {
|
||||
if (
|
||||
requestAbort.signal.aborted ||
|
||||
layerState.abort !== requestAbort ||
|
||||
!layerState.enabled
|
||||
)
|
||||
return;
|
||||
parts.viewport.clearUnavailableRetry();
|
||||
setInstallationStatus('zoom-in');
|
||||
return;
|
||||
}
|
||||
// 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 = payload.saturated === true;
|
||||
if (saturated) {
|
||||
if (saturated && !payload.tileSource) {
|
||||
payload = await fetchInstallations(true);
|
||||
saturated = payload.saturated === true;
|
||||
}
|
||||
// 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 = payload.records.filter((record) =>
|
||||
parts.model.installationWithinViewport(record, box),
|
||||
const records = payload.records.filter(
|
||||
(record) =>
|
||||
payload.wide || parts.model.installationWithinViewport(record, box),
|
||||
);
|
||||
let placesError = null;
|
||||
if (layerState.googleSearchRequested) {
|
||||
@@ -139,43 +202,84 @@ export function createIngestion({
|
||||
'Google Places search unavailable; showing mapped sites';
|
||||
}
|
||||
}
|
||||
await resolveGroundFloorCellsBounded(
|
||||
records.map((record) => ({
|
||||
lat: record.latitude,
|
||||
lon: record.longitude,
|
||||
})),
|
||||
);
|
||||
let floorTimer;
|
||||
await Promise.race([
|
||||
resolveGroundFloorCellsBounded(
|
||||
records
|
||||
.filter((record) => !record.pointOnly)
|
||||
.map((record) => ({
|
||||
lat: record.latitude,
|
||||
lon: record.longitude,
|
||||
})),
|
||||
),
|
||||
new Promise((resolve) => {
|
||||
floorTimer = setTimeout(resolve, 120);
|
||||
}),
|
||||
]).finally(() => clearTimeout(floorTimer));
|
||||
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);
|
||||
const publish = (payload, records) => {
|
||||
if (
|
||||
requestAbort.signal.aborted ||
|
||||
layerState.abort !== requestAbort ||
|
||||
!layerState.enabled
|
||||
)
|
||||
return;
|
||||
if (!payload.wide)
|
||||
records = retainNamedHandoff(layerState.records, records, box);
|
||||
layerState.namedInView =
|
||||
payload.namedInView ?? records.filter((r) => r.namedArea).length;
|
||||
layerState.wide = Boolean(payload.wide);
|
||||
layerState.records = records;
|
||||
layerState.recordById = new Map(
|
||||
layerState.records.map((record) => [record.id, record]),
|
||||
);
|
||||
layerState.lastUpdate = Date.now();
|
||||
layerState.coverage = coverage;
|
||||
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 · ${payload.records[0]?.retrievedAt || 'date unknown'}`
|
||||
: saturated
|
||||
? 'Too many mapped sites in view to list them all'
|
||||
: placesError,
|
||||
);
|
||||
parts.rendering.renderRecords();
|
||||
parts.rendering.warmInstallationFloors(
|
||||
layerState.records.filter((r) => !r.pointOnly),
|
||||
);
|
||||
};
|
||||
publish(payload, records);
|
||||
payload.enrichment?.then((enriched) => {
|
||||
if (
|
||||
!enriched ||
|
||||
requestAbort.signal.aborted ||
|
||||
layerState.abort !== requestAbort ||
|
||||
!layerState.enabled
|
||||
)
|
||||
return;
|
||||
const named = enriched.records.filter((record) =>
|
||||
parts.model.installationWithinViewport(record, box),
|
||||
);
|
||||
publish(enriched, [
|
||||
...named,
|
||||
...records.filter((record) => record.id.startsWith('google:')),
|
||||
]);
|
||||
});
|
||||
} catch (error) {
|
||||
if (
|
||||
requestAbort.signal.aborted ||
|
||||
@@ -189,11 +293,17 @@ export function createIngestion({
|
||||
'unavailable',
|
||||
error?.message || 'Installation context unavailable',
|
||||
);
|
||||
parts.viewport.scheduleUnavailableRetry();
|
||||
if (!isUnavailableCapability(error))
|
||||
parts.viewport.scheduleUnavailableRetry(error?.retryAfterMs);
|
||||
} finally {
|
||||
// An older aborted request must not clear a newer request's busy state.
|
||||
if (layerState.abort === requestAbort) {
|
||||
layerState.abort = null;
|
||||
if (
|
||||
requestAbort.signal.aborted &&
|
||||
layerState.cameraLoadOwner === keyOwner
|
||||
)
|
||||
layerState.cameraLoadKey = null;
|
||||
// Keep ownership until superseded so late names cannot enrich an old view.
|
||||
layerState.loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
import * as Cesium from 'cesium';
|
||||
import { LAYER_ID } from './policy.js';
|
||||
import {
|
||||
claimCameraSensitivity,
|
||||
releaseCameraSensitivity,
|
||||
} from '../../data/cameraSensitivity.js';
|
||||
|
||||
export function createLifecycle({
|
||||
state: layerState,
|
||||
@@ -17,14 +21,37 @@ export function createLifecycle({
|
||||
'military-installations',
|
||||
);
|
||||
viewer.dataSources.add(layerState.dataSource);
|
||||
layerState.moveEndRemove = viewer.camera.moveEnd.addEventListener(
|
||||
parts.viewport.scheduleLoad,
|
||||
);
|
||||
const cameraChanged = () => {
|
||||
if (!layerState.contextAnchor) parts.viewport.scheduleLoad();
|
||||
};
|
||||
layerState.moveEndRemove =
|
||||
viewer.camera.moveEnd.addEventListener(cameraChanged);
|
||||
layerState.changedRemove =
|
||||
viewer.camera.changed?.addEventListener(cameraChanged);
|
||||
parts.selection.installInteraction(viewer);
|
||||
},
|
||||
|
||||
enable() {
|
||||
layerState.enabled = true;
|
||||
layerState.surfaceChangedRemove ||= services.maps?.subscribeMapStack(
|
||||
parts.rendering.syncSelectionFill,
|
||||
);
|
||||
// Cached markers and geometry become visible before the next fetch.
|
||||
if (
|
||||
layerState.records?.some((r) =>
|
||||
r.sources?.some((s) => s.name === 'OpenStreetMap'),
|
||||
)
|
||||
)
|
||||
services.credits?.showOsmCredit?.(layerState.viewer, LAYER_ID, {
|
||||
openMapTiles: layerState.records.some((r) =>
|
||||
r.sources?.some(
|
||||
(s) => s.name === 'OpenStreetMap' && s.id?.startsWith('tile:'),
|
||||
),
|
||||
),
|
||||
});
|
||||
parts.namedMarkers?.enable();
|
||||
parts.namedMarkers?.sync(layerState.records || []);
|
||||
claimCameraSensitivity(layerState.viewer.camera, LAYER_ID, 0.01);
|
||||
registerPickOwner(LAYER_ID, (id) => layerState.recordById.has(id));
|
||||
layerState.dataSource.show = true;
|
||||
// DataLayerManager invokes update() immediately after enable(), which owns
|
||||
@@ -33,6 +60,14 @@ export function createLifecycle({
|
||||
|
||||
disable() {
|
||||
layerState.enabled = false;
|
||||
services.credits?.hideOsmCredit?.(layerState.viewer, LAYER_ID);
|
||||
parts.namedMarkers?.hide();
|
||||
releaseCameraSensitivity(layerState.viewer?.camera, LAYER_ID);
|
||||
layerState.contextAnchor = null;
|
||||
layerState.contextPosition = null;
|
||||
layerState.coverage = { kind: 'viewport' };
|
||||
layerState.cameraLoadKey = null;
|
||||
layerState.cameraLoadOwner = null;
|
||||
unregisterPickOwner(LAYER_ID);
|
||||
parts.viewport.clearUnavailableRetry();
|
||||
clearTimeout(layerState.timer);
|
||||
@@ -42,15 +77,25 @@ export function createLifecycle({
|
||||
if (layerState.dataSource) layerState.dataSource.show = false;
|
||||
clearSelectedEntityContextForLayer(LAYER_ID);
|
||||
layerState.selectedId = null;
|
||||
parts.rendering.clearSelectionFill?.();
|
||||
layerState.surfaceChangedRemove?.();
|
||||
layerState.surfaceChangedRemove = null;
|
||||
layerState.failureReason = null;
|
||||
parts.ingestion.setInstallationStatus('idle');
|
||||
},
|
||||
|
||||
destroy(viewer) {
|
||||
source.destroy?.();
|
||||
this.disable();
|
||||
layerState.contextAnchor = null;
|
||||
layerState.contextPosition = null;
|
||||
layerState.coverage = { kind: 'viewport' };
|
||||
layerState.moveEndRemove?.();
|
||||
layerState.changedRemove?.();
|
||||
parts.selection.destroy?.();
|
||||
layerState.clickHandler?.destroy();
|
||||
layerState.clickHandler = null;
|
||||
parts.namedMarkers?.destroy();
|
||||
parts.rendering.clearRendered();
|
||||
if (layerState.dataSource && viewer)
|
||||
viewer.dataSources.remove(layerState.dataSource, true);
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import * as credits from '../../data/dataCredits.js';
|
||||
import { createLifecycle } from './lifecycle.js';
|
||||
import { LAYER_ID } from './policy.js';
|
||||
|
||||
for (const source of [
|
||||
{ name: 'OpenStreetMap', id: 'tile:base' },
|
||||
{ name: 'OpenStreetMap', id: 'way:123' },
|
||||
{ name: 'Google Places', id: 'place:base' },
|
||||
null,
|
||||
]) {
|
||||
test(`enable restores cached installation credits before a refresh: ${source?.id || 'empty'}`, () => {
|
||||
const visibleCredits = new Set();
|
||||
const noop = () => {};
|
||||
const viewer = {
|
||||
camera: {},
|
||||
creditDisplay: {
|
||||
addStaticCredit: (credit) => visibleCredits.add(credit),
|
||||
removeStaticCredit: (credit) => visibleCredits.delete(credit),
|
||||
},
|
||||
};
|
||||
const state = {
|
||||
viewer,
|
||||
records: source ? [{ id: 'base', sources: [source] }] : [],
|
||||
recordById: new Map(),
|
||||
dataSource: { show: false },
|
||||
};
|
||||
let markersVisible = false;
|
||||
const parts = {
|
||||
namedMarkers: {
|
||||
enable: () => {
|
||||
markersVisible = true;
|
||||
},
|
||||
sync: noop,
|
||||
hide: () => {
|
||||
markersVisible = false;
|
||||
},
|
||||
},
|
||||
rendering: {},
|
||||
viewport: { clearUnavailableRetry: noop },
|
||||
ingestion: { setInstallationStatus: noop },
|
||||
};
|
||||
const lifecycle = createLifecycle({
|
||||
state,
|
||||
services: {
|
||||
credits,
|
||||
picking: { registerPickOwner: noop, unregisterPickOwner: noop },
|
||||
context: { clearSelectedEntityContextForLayer: noop },
|
||||
},
|
||||
parts,
|
||||
source: {},
|
||||
}).methods;
|
||||
const expected =
|
||||
source?.name === 'OpenStreetMap'
|
||||
? source.id.startsWith('tile:')
|
||||
? 2
|
||||
: 1
|
||||
: 0;
|
||||
try {
|
||||
for (let cycle = 0; cycle < 2; cycle++) {
|
||||
lifecycle.enable();
|
||||
assert.equal(state.dataSource.show, true);
|
||||
assert.equal(markersVisible, true);
|
||||
// No renderRecords or successful fetch occurs between toggles.
|
||||
assert.equal(visibleCredits.size, expected);
|
||||
lifecycle.disable();
|
||||
assert.equal(state.dataSource.show, false);
|
||||
assert.equal(markersVisible, false);
|
||||
assert.equal(visibleCredits.size, 0);
|
||||
}
|
||||
credits.showOsmCredit(viewer, 'another-layer');
|
||||
lifecycle.enable();
|
||||
lifecycle.disable();
|
||||
assert.equal(visibleCredits.size, 1, 'another owner retains its credit');
|
||||
assert.equal(credits.hideOsmCredit(viewer, LAYER_ID), false);
|
||||
} finally {
|
||||
lifecycle.disable();
|
||||
credits.hideOsmCredit(viewer, 'another-layer');
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -116,24 +116,28 @@ export function createModel({ state: layerState, services, parts, source }) {
|
||||
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
|
||||
);
|
||||
}
|
||||
const footprints =
|
||||
record.footprints?.map((rings) => rings[0]) ||
|
||||
(footprint?.length ? [footprint] : []);
|
||||
if (footprints.length)
|
||||
return footprints.some((footprint) => {
|
||||
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';
|
||||
}
|
||||
|
||||
@@ -0,0 +1,224 @@
|
||||
import * as Cesium from 'cesium';
|
||||
import {
|
||||
MILITARY_LABEL_CAP,
|
||||
MILITARY_POINT_CAP,
|
||||
} from '../../data/militaryNames.js';
|
||||
import { LAYER_ID, COLOR_BY_CLASS } from './policy.js';
|
||||
import { createInfrastructureOverlayEntry } from '../../data/infrastructureOverlayEntry.js';
|
||||
|
||||
/** Bounded point markers; the shared overlay owns text projection and decluttering. */
|
||||
export function createNamedMarkers({ state, services, parts, overlayHost }) {
|
||||
let points = null;
|
||||
let entries = [];
|
||||
const markers = new Map();
|
||||
const screen = new Cesium.Cartesian2();
|
||||
const occluder = new Cesium.EllipsoidalOccluder(Cesium.Ellipsoid.WGS84);
|
||||
let shownPoints = 0;
|
||||
let ordered = [];
|
||||
const viewMatrix = new Cesium.Matrix4();
|
||||
let width = 0,
|
||||
height = 0;
|
||||
let removeChanged = null,
|
||||
removeEnd = null,
|
||||
removeFrame = null;
|
||||
|
||||
function refresh() {
|
||||
const viewer = state.viewer;
|
||||
if (!state.enabled || !points) return;
|
||||
width = viewer.scene.canvas.clientWidth;
|
||||
height = viewer.scene.canvas.clientHeight;
|
||||
if (viewer.camera.viewMatrix)
|
||||
Cesium.Matrix4.clone(viewer.camera.viewMatrix, viewMatrix);
|
||||
occluder.cameraPosition = viewer.camera.positionWC;
|
||||
shownPoints = 0;
|
||||
for (let index = 0; index < ordered.length; index++) {
|
||||
const marker = ordered[index];
|
||||
const point = marker.point;
|
||||
point.show = marker.named && occluder.isPointVisible(point.position);
|
||||
if (!point.show) continue;
|
||||
const projected = Cesium.SceneTransforms.worldToWindowCoordinates(
|
||||
viewer.scene,
|
||||
point.position,
|
||||
screen,
|
||||
);
|
||||
if (
|
||||
projected &&
|
||||
screen.x >= 0 &&
|
||||
screen.y >= 0 &&
|
||||
screen.x < width &&
|
||||
screen.y < height
|
||||
)
|
||||
shownPoints++;
|
||||
}
|
||||
}
|
||||
|
||||
function publishLabels() {
|
||||
overlayHost.setEntries(LAYER_ID, entries, {
|
||||
// Keep the bounded point cohort available while Contacts owns the window.
|
||||
// The arbiter spends at most 24 slots on currently visible, separated titles.
|
||||
cohortLimit: MILITARY_POINT_CAP,
|
||||
collisionCapacity: MILITARY_LABEL_CAP,
|
||||
maxVisible: MILITARY_LABEL_CAP - (state.selectedId ? 1 : 0),
|
||||
moving: false,
|
||||
visible: state.enabled,
|
||||
});
|
||||
}
|
||||
|
||||
function cameraChanged() {
|
||||
refresh();
|
||||
services.render.governorRequestRender('installation-names');
|
||||
}
|
||||
function frame() {
|
||||
const viewer = state.viewer;
|
||||
if (
|
||||
viewer?.camera.viewMatrix &&
|
||||
(!Cesium.Matrix4.equals(viewMatrix, viewer.camera.viewMatrix) ||
|
||||
width !== viewer.scene.canvas.clientWidth ||
|
||||
height !== viewer.scene.canvas.clientHeight)
|
||||
)
|
||||
refresh();
|
||||
}
|
||||
function enable() {
|
||||
const viewer = state.viewer;
|
||||
if (!viewer || removeChanged || removeEnd || removeFrame) return;
|
||||
removeChanged = viewer.camera.changed?.addEventListener(cameraChanged);
|
||||
removeEnd = viewer.camera.moveEnd?.addEventListener(cameraChanged);
|
||||
removeFrame = viewer.scene.preRender?.addEventListener(frame);
|
||||
if (points) points.show = true;
|
||||
publishLabels();
|
||||
overlayHost.setVisible(LAYER_ID, true);
|
||||
cameraChanged();
|
||||
}
|
||||
function hide() {
|
||||
removeChanged?.();
|
||||
removeEnd?.();
|
||||
removeFrame?.();
|
||||
removeChanged = removeEnd = removeFrame = null;
|
||||
if (points) points.show = false;
|
||||
overlayHost.clearSource(LAYER_ID);
|
||||
overlayHost.setVisible(LAYER_ID, false);
|
||||
shownPoints = 0;
|
||||
}
|
||||
|
||||
function sync(records) {
|
||||
const viewer = state.viewer;
|
||||
if (!viewer?.scene?.primitives) return;
|
||||
if (!points) {
|
||||
points = viewer.scene.primitives.add(
|
||||
new Cesium.PointPrimitiveCollection(),
|
||||
);
|
||||
}
|
||||
points.show = state.enabled;
|
||||
const named = records
|
||||
.filter((r) => r.namedArea || r.id === state.selectedId)
|
||||
.sort(
|
||||
(a, b) =>
|
||||
Number(b.id === state.selectedId) -
|
||||
Number(a.id === state.selectedId) || b.areaM2 - a.areaM2,
|
||||
)
|
||||
.slice(0, MILITARY_POINT_CAP);
|
||||
const keep = new Set(named.map((r) => r.id));
|
||||
for (const record of named) {
|
||||
if (markers.has(record.id)) continue;
|
||||
const alias = record.aliasIds?.find(
|
||||
(id) => !keep.has(id) && markers.has(id),
|
||||
);
|
||||
if (!alias) continue;
|
||||
const marker = markers.get(alias);
|
||||
markers.delete(alias);
|
||||
marker.point.id = { installationId: record.id };
|
||||
marker.entry.id = record.id;
|
||||
markers.set(record.id, marker);
|
||||
}
|
||||
for (const [id, marker] of markers)
|
||||
if (!keep.has(id)) {
|
||||
points.remove(marker.point);
|
||||
markers.delete(id);
|
||||
}
|
||||
ordered = [];
|
||||
entries = [];
|
||||
for (const record of named) {
|
||||
const position = Cesium.Cartesian3.fromDegrees(
|
||||
record.longitude,
|
||||
record.latitude,
|
||||
record.pointOnly
|
||||
? 0
|
||||
: parts.rendering.installationSurfaceHeightM(record),
|
||||
);
|
||||
let marker = markers.get(record.id);
|
||||
if (!marker) {
|
||||
const id = { installationId: record.id };
|
||||
marker = {
|
||||
point: points.add({
|
||||
id,
|
||||
position,
|
||||
pixelSize: 7,
|
||||
color: parts.model.colorFor(record),
|
||||
outlineColor: Cesium.Color.BLACK,
|
||||
outlineWidth: 1,
|
||||
disableDepthTestDistance: Infinity,
|
||||
}),
|
||||
entry: createInfrastructureOverlayEntry({
|
||||
id: record.id,
|
||||
source: LAYER_ID,
|
||||
position,
|
||||
title: record.name,
|
||||
priority: record.areaM2 || 0,
|
||||
accent: COLOR_BY_CLASS[record.class] || '#9ca6b0',
|
||||
}),
|
||||
};
|
||||
markers.set(record.id, marker);
|
||||
} else {
|
||||
marker.point.position = position;
|
||||
}
|
||||
marker.named = record.namedArea;
|
||||
marker.entry.variant =
|
||||
record.id === state.selectedId ? 'selected' : 'card';
|
||||
marker.entry.selected = record.id === state.selectedId;
|
||||
marker.entry.details = marker.entry.selected
|
||||
? [
|
||||
String(record.class || 'installation')
|
||||
.replaceAll('_', ' ')
|
||||
.toUpperCase(),
|
||||
...(record.memberNames || []).slice(0, 3),
|
||||
]
|
||||
: [];
|
||||
marker.entry.position = position;
|
||||
marker.entry.title = record.name;
|
||||
marker.entry.priority = record.areaM2 || 0;
|
||||
entries.push(marker.entry);
|
||||
ordered.push(marker);
|
||||
}
|
||||
if (state.enabled) publishLabels();
|
||||
refresh();
|
||||
services.render.governorRequestRender('installation-names');
|
||||
}
|
||||
return {
|
||||
sync,
|
||||
visit(visitor) {
|
||||
for (let i = 0; i < ordered.length; i++) {
|
||||
const marker = ordered[i];
|
||||
visitor(marker.point.id.installationId, marker.point);
|
||||
}
|
||||
},
|
||||
refresh,
|
||||
enable,
|
||||
hide,
|
||||
stats: () => ({
|
||||
namedMarkers: markers.size,
|
||||
pointsOnScreen: shownPoints,
|
||||
labelsOnScreen: state.enabled
|
||||
? overlayHost.getDiagnostics?.().paintedBySource[LAYER_ID] || 0
|
||||
: 0,
|
||||
labelCap: MILITARY_LABEL_CAP,
|
||||
}),
|
||||
destroy() {
|
||||
hide();
|
||||
ordered = [];
|
||||
if (points) state.viewer?.scene.primitives.remove(points);
|
||||
points = null;
|
||||
entries = [];
|
||||
markers.clear();
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,185 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import * as C from 'cesium';
|
||||
import { createNamedMarkers } from './namedMarkers.js';
|
||||
import { createLifecycle } from './lifecycle.js';
|
||||
import { createLocalInfrastructureOverlayEntry } from '../../data/localGeojsonCore.js';
|
||||
|
||||
function setup(t) {
|
||||
let labels = [], options, publications = 0, visible = false;
|
||||
const overlayHost = {
|
||||
setEntries(id, entries, policy) {
|
||||
assert.equal(id, 'military-installations');
|
||||
labels = entries; options = policy; publications++;
|
||||
},
|
||||
clearSource() { labels = []; },
|
||||
setVisible(_id, value) { visible = value; },
|
||||
};
|
||||
let x = -100;
|
||||
const projections = new Set();
|
||||
t.mock.method(C.SceneTransforms, 'worldToWindowCoordinates', (_scene, _point, scratch) => { projections.add(scratch); scratch.x = x; scratch.y = 300; return scratch; });
|
||||
const camera = { changed: new C.Event(), moveEnd: new C.Event(), positionWC: C.Cartesian3.fromDegrees(0, 0, 1e6), viewMatrix: C.Matrix4.clone(C.Matrix4.IDENTITY) };
|
||||
const state = { viewer: { camera, dataSources: { add() {}, remove() {} }, scene: { canvas: { clientWidth: 1280, clientHeight: 800 }, preRender: new C.Event(), primitives: { add: (p) => p, remove() {} } } }, enabled: true, contextAnchor: { latitude: 0, longitude: 0 } };
|
||||
const services = { render: { governorRequestRender() {} }, picking: { registerPickOwner() {}, unregisterPickOwner() {} }, context: { clearSelectedEntityContextForLayer() {} } };
|
||||
let loads = 0;
|
||||
const parts = { model: { colorFor: () => C.Color.ORANGE }, rendering: { installationSurfaceHeightM: () => 0, clearRendered() {} }, viewport: { scheduleLoad: () => loads++, clearUnavailableRetry() {} }, selection: { installInteraction() {} }, ingestion: { setInstallationStatus() {} } };
|
||||
const ctx = { state, services, parts, source: {}, overlayHost };
|
||||
parts.namedMarkers = createNamedMarkers(ctx);
|
||||
const lifecycle = createLifecycle(ctx).methods;
|
||||
lifecycle.init(state.viewer); lifecycle.enable();
|
||||
const records = [1, 2].map((n) => ({ id: `osm:military:w${n}`, name: `Site ${n}`, namedArea: true, areaM2: 100 / n, latitude: 0, longitude: 0, pointOnly: true }));
|
||||
return { state, camera, get labels() { return labels; }, get options() { return options; }, get visible() { return visible; }, projections, records, markers: parts.namedMarkers, lifecycle, setX: (value) => { x = value; }, publications: () => publications, loads: () => loads };
|
||||
}
|
||||
|
||||
test('Contacts camera motion reprojects and declutters existing labels without source loads or primitive replacement', (t) => {
|
||||
const h = setup(t);
|
||||
h.markers.sync(h.records);
|
||||
assert.equal(h.markers.stats().pointsOnScreen, 0);
|
||||
h.setX(600); h.camera.changed.raiseEvent(); h.camera.moveEnd.raiseEvent();
|
||||
assert.equal(h.markers.stats().pointsOnScreen, 2);
|
||||
assert.equal(h.labels.length, 2);
|
||||
assert.equal(h.options.collisionCapacity, 24);
|
||||
assert.equal(h.options.cohortLimit, 512);
|
||||
assert.equal(h.visible, true);
|
||||
assert.equal(h.loads(), 0);
|
||||
assert.equal(h.publications(), 3);
|
||||
assert.equal(h.projections.size, 1);
|
||||
h.setX(1400); h.camera.viewMatrix[12] = 1; h.state.viewer.scene.preRender.raiseEvent();
|
||||
assert.equal(h.markers.stats().pointsOnScreen, 0, 'small pose changes refresh before render');
|
||||
h.lifecycle.disable();
|
||||
assert.equal(h.camera.changed.numberOfListeners, 1, 'only the viewport listener remains');
|
||||
assert.equal(h.state.viewer.scene.preRender.numberOfListeners, 0);
|
||||
assert.equal(h.labels.length, 0);
|
||||
assert.equal(h.visible, false);
|
||||
h.state.records = h.records;
|
||||
h.setX(600); h.lifecycle.enable();
|
||||
assert.equal(h.labels.length, 2);
|
||||
assert.equal(h.publications(), 5);
|
||||
h.lifecycle.destroy(h.state.viewer);
|
||||
assert.equal(h.camera.changed.numberOfListeners, 0);
|
||||
assert.equal(h.camera.moveEnd.numberOfListeners, 0);
|
||||
assert.equal(h.state.viewer.scene.preRender.numberOfListeners, 0);
|
||||
});
|
||||
|
||||
test('wide named markers retain primitives until close polygons publish with the same identity', async (t) => {
|
||||
const { retainNamedHandoff } = await import('./ingestion.js');
|
||||
const h = setup(t);
|
||||
h.setX(600); h.markers.sync(h.records);
|
||||
const first = h.labels[0];
|
||||
const box = { west: -1, east: 1, south: -1, north: 1 };
|
||||
h.markers.sync(retainNamedHandoff(h.records, [], box));
|
||||
assert.equal(h.labels[0], first);
|
||||
assert.equal(first.title, 'Site 1');
|
||||
const polygon = { ...h.records[0], namedArea: false, name: 'Military area', pointOnly: false, footprint: [[0, 0], [0.1, 0], [0, 0.1], [0, 0]] };
|
||||
const handed = retainNamedHandoff(h.records, [polygon], box);
|
||||
assert.equal(handed.length, 2);
|
||||
assert.equal(handed[0].name, 'Site 1');
|
||||
assert.equal(handed[0].pointOnly, false);
|
||||
h.markers.sync(handed);
|
||||
assert.equal(h.labels[0], first);
|
||||
assert.equal(first.title, 'Site 1');
|
||||
assert.equal(h.labels.length, 2, 'no duplicate title at handoff');
|
||||
assert.equal(retainNamedHandoff(h.records, [], { west: 1, east: 2, south: 1, north: 2 }).length, 0);
|
||||
h.lifecycle.destroy(h.state.viewer);
|
||||
});
|
||||
|
||||
test('canonical aliases transfer named marker ownership without replacing primitives', (t) => {
|
||||
const h = setup(t);
|
||||
h.setX(600);
|
||||
const parent = h.records[1];
|
||||
h.markers.sync([parent]);
|
||||
let originalPoint;
|
||||
h.markers.visit((_id, point) => { originalPoint = point; });
|
||||
const label = h.labels[0];
|
||||
for (const record of [
|
||||
{ ...parent, id: h.records[0].id, aliasIds: [parent.id], pointOnly: false },
|
||||
{ ...parent, aliasIds: [h.records[0].id], pointOnly: false },
|
||||
parent,
|
||||
]) {
|
||||
h.markers.sync([record]);
|
||||
h.markers.visit((id, point) => {
|
||||
const currentLabel = h.labels[0];
|
||||
assert.equal(id, record.id);
|
||||
assert.equal(point, originalPoint);
|
||||
assert.equal(currentLabel, label);
|
||||
assert.equal(point.id.installationId, record.id);
|
||||
assert.equal(currentLabel.id, record.id);
|
||||
});
|
||||
}
|
||||
assert.equal(h.labels.length, 1);
|
||||
h.lifecycle.destroy(h.state.viewer);
|
||||
});
|
||||
|
||||
|
||||
test('the bounded host cohort ranks wide points and polygon titles by area', (t) => {
|
||||
const h = setup(t);
|
||||
h.markers.sync(Array.from({ length: 600 }, (_, n) => ({
|
||||
...h.records[0], id: `site:${n}`, name: `Site ${n}`, areaM2: n,
|
||||
pointOnly: n % 2 === 0,
|
||||
})));
|
||||
assert.equal(h.labels.length, 512);
|
||||
assert.equal(h.labels[0].id, 'site:599');
|
||||
assert.equal(h.labels.at(-1).id, 'site:88');
|
||||
assert.ok(h.labels.every((entry) => !entry.stateless && entry.horizonCull && entry.collisionGroup === 'ambient-card'));
|
||||
const first = h.labels[0];
|
||||
const publications = h.publications();
|
||||
for (let i = 0; i < 100; i++) {
|
||||
h.camera.viewMatrix[12] += 0.01;
|
||||
h.state.viewer.scene.preRender.raiseEvent();
|
||||
}
|
||||
assert.equal(h.labels[0], first);
|
||||
assert.equal(h.publications(), publications, 'camera frames never rebuild host entries');
|
||||
assert.equal(h.projections.size, 1, 'point projection reuses scratch storage');
|
||||
h.lifecycle.destroy(h.state.viewer);
|
||||
assert.equal(h.labels.length, 0);
|
||||
});
|
||||
|
||||
|
||||
test('wide and polygon titles use the Data Centers entry contract and persistent shared domain', (t) => {
|
||||
const h = setup(t);
|
||||
h.markers.sync([h.records[0], { ...h.records[1], pointOnly: false }]);
|
||||
assert.equal(h.options.moving, false);
|
||||
assert.equal(h.options.solveIntervalMs, undefined);
|
||||
assert.equal(h.options.maxVisible, 24);
|
||||
for (const entry of h.labels) {
|
||||
const reference = createLocalInfrastructureOverlayEntry({ id: entry.id, layerId: 'military-installations', position: entry.position, properties: { name: entry.title }, priority: entry.priority, accent: entry.accent });
|
||||
assert.deepEqual(entry, { ...reference, selected: false });
|
||||
}
|
||||
h.lifecycle.destroy(h.state.viewer);
|
||||
});
|
||||
|
||||
|
||||
test('selection promotes exactly one entry and deselection restores that same label', (t) => {
|
||||
const h = setup(t);
|
||||
h.markers.sync(h.records);
|
||||
const original = h.labels[0];
|
||||
h.state.selectedId = h.records[0].id;
|
||||
h.markers.sync(h.records);
|
||||
assert.equal(h.labels.length, 2);
|
||||
assert.equal(h.labels[0], original);
|
||||
assert.equal(original.variant, 'selected');
|
||||
assert.equal(h.labels.filter(e => e.selected).length, 1);
|
||||
assert.equal(h.options.maxVisible, 23);
|
||||
h.state.selectedId = null;
|
||||
h.markers.sync(h.records);
|
||||
assert.equal(h.labels[0], original);
|
||||
assert.equal(original.variant, 'card');
|
||||
assert.equal(original.selected, false);
|
||||
assert.deepEqual(original.details, []);
|
||||
h.lifecycle.destroy(h.state.viewer);
|
||||
});
|
||||
|
||||
test('Contacts can select a point outside the normal title cap or an unnamed polygon', (t) => {
|
||||
const h = setup(t);
|
||||
const records = Array.from({ length: 520 }, (_, i) => ({ ...h.records[0], id: `site:${i}`, areaM2: 1000 - i }));
|
||||
records.push({ ...h.records[0], id: 'unnamed', name: 'Military area', namedArea: false, pointOnly: false });
|
||||
for (const id of ['site:519', 'unnamed']) {
|
||||
h.state.selectedId = id;
|
||||
h.markers.sync(records);
|
||||
assert.equal(h.labels[0].id, id);
|
||||
assert.equal(h.labels[0].variant, 'selected');
|
||||
assert.equal(h.labels.filter(e => e.selected).length, 1);
|
||||
assert.equal(h.labels.length, 512);
|
||||
}
|
||||
h.lifecycle.destroy(h.state.viewer);
|
||||
});
|
||||
@@ -0,0 +1,79 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { createInstallationSource } from './source.js';
|
||||
import { createIngestion } from './ingestion.js';
|
||||
import { decodeOpenFreeMapMilitaryTile } from '../../sources/openFreeMap.js';
|
||||
import { createMilitaryNamesLoader, loadMilitaryNames } from '../../data/militaryNames.js';
|
||||
import { loadBundledJson } from '../../data/bundledJson.js';
|
||||
|
||||
const box = { south: 30.3, west: -97.8, north: 30.33, east: -97.74 };
|
||||
const wide = { south: 20, west: -130, north: 60, east: -60 };
|
||||
const tile = decodeOpenFreeMapMilitaryTile(readFileSync(new URL('../../data/fixtures/ofm-camp-mabry-12-935-1685.pbf', import.meta.url)), 12, 935, 1685);
|
||||
const flush = async () => { for (let i = 0; i < 30; i++) await Promise.resolve(); };
|
||||
function deferred() { let resolve; const promise = new Promise((r) => { resolve = r; }); return { promise, resolve }; }
|
||||
function sourceWith(job) { return createInstallationSource({
|
||||
loadNames: () => job.promise,
|
||||
fetchImpl: async () => Response.json({ unavailable: true }),
|
||||
mapTiles: { fetchBounds: async () => ({ tiles: [tile] }), clear() {} },
|
||||
}); }
|
||||
|
||||
test('close polygons publish immediately while a wide consumer can cancel its independent names wait', async () => {
|
||||
const job = deferred(), source = sourceWith(job), abort = new AbortController();
|
||||
const waiting = source.getMappedSites(wide, { signal: abort.signal });
|
||||
const rejection = assert.rejects(waiting, { name: 'AbortError' });
|
||||
const payload = await source.getMappedSites(box);
|
||||
assert.ok(payload.records.length);
|
||||
assert.ok(payload.records.every((r) => r.name === 'Military area'));
|
||||
abort.abort();
|
||||
await rejection;
|
||||
job.resolve(await loadMilitaryNames());
|
||||
const enriched = await payload.enrichment;
|
||||
assert.ok(enriched.records.some((r) => r.name === 'Camp Mabry'));
|
||||
});
|
||||
|
||||
for (const stale of [false, true]) test(`late names ${stale ? 'cannot publish to a superseded view' : 'enrich the current published polygons'}`, async () => {
|
||||
const job = deferred(), source = sourceWith(job), publications = [];
|
||||
let currentBox = box;
|
||||
const state = { enabled: true, viewer: {}, records: [], googleSearchRequested: false };
|
||||
const ingestion = createIngestion({ state, source,
|
||||
services: { render: { governorRequestRender() {} }, ground: { resolveGroundFloorCellsBounded: async () => {} } },
|
||||
parts: {
|
||||
viewport: { loadArea: () => ({ box: currentBox, coverage: {} }), clearUnavailableRetry() {}, scheduleUnavailableRetry() {} },
|
||||
model: { installationWithinViewport: () => true },
|
||||
rendering: { renderRecords: () => publications.push(state.records), warmInstallationFloors() {} },
|
||||
},
|
||||
});
|
||||
await ingestion.loadInstallations();
|
||||
assert.equal(state.loading, false);
|
||||
assert.ok(publications[0].length);
|
||||
if (stale) { currentBox = wide; state.enabled = false; state.abort.abort(); }
|
||||
job.resolve(await loadMilitaryNames());
|
||||
await flush();
|
||||
assert.equal(publications.length, stale ? 1 : 2);
|
||||
if (!stale) assert.ok(state.records.some((r) => r.name === 'Camp Mabry'));
|
||||
state.abort.abort();
|
||||
});
|
||||
|
||||
for (const stage of ['response', 'body']) test(`shared names ${stage} deadline aborts and releases the retryable loader`, async (t) => {
|
||||
t.mock.timers.enable({ apis: ['setTimeout', 'Date'] });
|
||||
let calls = 0, signal;
|
||||
t.mock.method(globalThis, 'fetch', async (_url, options) => {
|
||||
calls++; signal = options.signal;
|
||||
if (calls > 1) return Response.json({ records: [], classes: [] });
|
||||
if (stage === 'response') return new Promise(() => {});
|
||||
return { ok: true, json: () => new Promise(() => {}) };
|
||||
});
|
||||
const load = createMilitaryNamesLoader({ timeoutMs: 50, cooldownMs: 100,
|
||||
loadPack: (signal) => loadBundledJson(new URL('https://example.test/names.json'), null, { signal }) });
|
||||
const first = load();
|
||||
assert.equal(load(), first, 'shared acquisition');
|
||||
const rejected = assert.rejects(first, { name: 'TimeoutError' });
|
||||
await flush(); t.mock.timers.tick(51); await rejected;
|
||||
assert.equal(signal.aborted, true);
|
||||
await assert.rejects(load(), { name: 'TimeoutError' });
|
||||
assert.equal(calls, 1);
|
||||
t.mock.timers.tick(101);
|
||||
assert.deepEqual((await load()).records, []);
|
||||
assert.equal(calls, 2);
|
||||
});
|
||||
@@ -0,0 +1,191 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import * as Cesium from 'cesium';
|
||||
import { createViewport } from './viewport.js';
|
||||
import { createLifecycle } from './lifecycle.js';
|
||||
|
||||
test('camera changes debounce, deduplicate moveEnd, cancel superseded loads and recover from wide views', (t) => {
|
||||
t.mock.timers.enable({ apis: ['setTimeout'] });
|
||||
let rectangle = Cesium.Rectangle.fromDegrees(-117.2, 32.7, -117.1, 32.8);
|
||||
const camera = {
|
||||
changed: new Cesium.Event(),
|
||||
moveEnd: new Cesium.Event(),
|
||||
percentageChanged: 0.5,
|
||||
computeViewRectangle: () => rectangle,
|
||||
};
|
||||
const viewer = {
|
||||
camera,
|
||||
scene: { globe: { ellipsoid: Cesium.Ellipsoid.WGS84 } },
|
||||
dataSources: { add() {}, remove() {} },
|
||||
};
|
||||
const state = { enabled: false, renderedKeys: new Map() };
|
||||
const loads = [];
|
||||
const parts = {
|
||||
selection: { installInteraction() {} },
|
||||
rendering: { clearRendered() {} },
|
||||
ingestion: {
|
||||
loadInstallations() {
|
||||
state.abort = new AbortController();
|
||||
loads.push({
|
||||
...parts.viewport.loadArea(viewer),
|
||||
signal: state.abort.signal,
|
||||
});
|
||||
},
|
||||
setInstallationStatus() {},
|
||||
},
|
||||
};
|
||||
const services = {
|
||||
picking: { registerPickOwner() {}, unregisterPickOwner() {} },
|
||||
context: { clearSelectedEntityContextForLayer() {} },
|
||||
};
|
||||
const ctx = { state, parts, services, source: {} };
|
||||
parts.viewport = createViewport(ctx);
|
||||
const life = createLifecycle(ctx).methods;
|
||||
life.init(viewer);
|
||||
life.enable();
|
||||
for (let i = 0; i < 8; i++) {
|
||||
camera.changed.raiseEvent();
|
||||
t.mock.timers.tick(15);
|
||||
}
|
||||
t.mock.timers.tick(200);
|
||||
assert.equal(
|
||||
loads.length,
|
||||
1,
|
||||
'changed alone must load, even without moveEnd',
|
||||
);
|
||||
camera.moveEnd.raiseEvent();
|
||||
t.mock.timers.tick(200);
|
||||
assert.equal(loads.length, 1);
|
||||
assert.equal(
|
||||
loads[0].signal.aborted,
|
||||
false,
|
||||
'duplicate settle must not abort its own load',
|
||||
);
|
||||
rectangle = Cesium.Rectangle.fromDegrees(-117.5, 33.2, -117.3, 33.4);
|
||||
camera.changed.raiseEvent();
|
||||
assert.equal(loads[0].signal.aborted, true);
|
||||
t.mock.timers.tick(200);
|
||||
assert.equal(loads.length, 2);
|
||||
rectangle = Cesium.Rectangle.fromDegrees(-150, 10, -80, 60);
|
||||
camera.moveEnd.raiseEvent();
|
||||
t.mock.timers.tick(200);
|
||||
assert.equal(
|
||||
loads.at(-1).box.west,
|
||||
-150,
|
||||
'wide views reach the named-point source',
|
||||
);
|
||||
rectangle = Cesium.Rectangle.fromDegrees(-117.2, 32.7, -117.1, 32.8);
|
||||
camera.changed.raiseEvent();
|
||||
t.mock.timers.tick(200);
|
||||
assert.ok(loads.at(-1).box);
|
||||
state.contextAnchor = { latitude: 32.8, longitude: -117.1 };
|
||||
const count = loads.length;
|
||||
camera.moveEnd.raiseEvent();
|
||||
t.mock.timers.tick(200);
|
||||
assert.equal(loads.length, count, 'Contacts owns the anchor while active');
|
||||
life.disable();
|
||||
assert.equal(state.contextAnchor, null);
|
||||
life.enable();
|
||||
rectangle = Cesium.Rectangle.fromDegrees(-117.5, 33.2, -117.3, 33.4);
|
||||
camera.changed.raiseEvent();
|
||||
t.mock.timers.tick(200);
|
||||
assert.equal(loads.at(-1).coverage.kind, 'viewport');
|
||||
life.destroy(viewer);
|
||||
assert.equal(camera.changed.numberOfListeners, 0);
|
||||
assert.equal(camera.moveEnd.numberOfListeners, 0);
|
||||
assert.equal(camera.percentageChanged, 0.5);
|
||||
});
|
||||
|
||||
test('an aborting real ingestion cannot clear the next scheduled view key', async (t) => {
|
||||
const { createIngestion } = await import('./ingestion.js');
|
||||
t.mock.timers.enable({ apis: ['setTimeout'] });
|
||||
let rectangle = Cesium.Rectangle.fromDegrees(-117.2, 32.7, -117.1, 32.8);
|
||||
const viewer = {
|
||||
camera: { computeViewRectangle: () => rectangle },
|
||||
scene: { globe: { ellipsoid: Cesium.Ellipsoid.WGS84 } },
|
||||
};
|
||||
const state = { enabled: true, viewer };
|
||||
const pending = [];
|
||||
const parts = {
|
||||
rendering: { renderRecords() {}, warmInstallationFloors() {} },
|
||||
model: { installationWithinViewport: () => true },
|
||||
};
|
||||
const ctx = {
|
||||
state,
|
||||
parts,
|
||||
services: {
|
||||
render: { governorRequestRender() {} },
|
||||
ground: { resolveGroundFloorCellsBounded: async () => {} },
|
||||
},
|
||||
source: {
|
||||
getMappedSites(box, { signal }) {
|
||||
return new Promise((resolve, reject) => {
|
||||
signal.addEventListener('abort', () => reject(signal.reason), {
|
||||
once: true,
|
||||
});
|
||||
pending.push({ box, signal, resolve });
|
||||
});
|
||||
},
|
||||
},
|
||||
};
|
||||
parts.viewport = createViewport(ctx);
|
||||
parts.ingestion = createIngestion(ctx);
|
||||
const flush = async () => {
|
||||
for (let i = 0; i < 20; i++) await Promise.resolve();
|
||||
};
|
||||
parts.viewport.scheduleLoad();
|
||||
t.mock.timers.tick(200);
|
||||
assert.equal(pending.length, 1);
|
||||
rectangle = Cesium.Rectangle.fromDegrees(-117.5, 33.2, -117.3, 33.4);
|
||||
parts.viewport.scheduleLoad();
|
||||
const nextKey = state.cameraLoadKey;
|
||||
assert.equal(pending[0].signal.aborted, true);
|
||||
await flush();
|
||||
assert.equal(
|
||||
state.cameraLoadKey,
|
||||
nextKey,
|
||||
'A finally must not clear B schedule ownership',
|
||||
);
|
||||
t.mock.timers.tick(200);
|
||||
assert.equal(pending.length, 2);
|
||||
parts.viewport.scheduleLoad();
|
||||
t.mock.timers.tick(600);
|
||||
assert.equal(pending.length, 2);
|
||||
assert.equal(pending[1].signal.aborted, false);
|
||||
pending[1].resolve({ records: [], status: 'ready' });
|
||||
await flush();
|
||||
assert.equal(state.loading, false);
|
||||
assert.equal(state.cameraLoadKey, nextKey);
|
||||
});
|
||||
|
||||
test('wide views refresh screen decluttering when the globe rectangle stays unchanged', (t) => {
|
||||
t.mock.timers.enable({ apis: ['setTimeout'] });
|
||||
const camera = {
|
||||
positionWC: new Cesium.Cartesian3(10000000, 0, 0),
|
||||
directionWC: new Cesium.Cartesian3(-1, 0, 0),
|
||||
computeViewRectangle: () => Cesium.Rectangle.MAX_VALUE,
|
||||
};
|
||||
const state = {
|
||||
enabled: true,
|
||||
viewer: { camera, scene: { globe: { ellipsoid: Cesium.Ellipsoid.WGS84 } } },
|
||||
};
|
||||
let loads = 0;
|
||||
const parts = {
|
||||
ingestion: {
|
||||
loadInstallations() {
|
||||
loads++;
|
||||
},
|
||||
},
|
||||
};
|
||||
const viewport = createViewport({ state, parts, services: {}, source: {} });
|
||||
viewport.scheduleLoad();
|
||||
t.mock.timers.tick(200);
|
||||
viewport.scheduleLoad();
|
||||
t.mock.timers.tick(200);
|
||||
assert.equal(loads, 1, 'the same settled camera remains deduplicated');
|
||||
camera.positionWC = new Cesium.Cartesian3(0, 10000000, 0);
|
||||
camera.directionWC = new Cesium.Cartesian3(0, -1, 0);
|
||||
viewport.scheduleLoad();
|
||||
t.mock.timers.tick(200);
|
||||
assert.equal(loads, 2, 'a different continent needs fresh projected labels');
|
||||
});
|
||||
@@ -1,6 +1,6 @@
|
||||
export const LAYER_ID = 'military-installations';
|
||||
|
||||
export const REQUEST_DEBOUNCE_MS = 500;
|
||||
export const REQUEST_DEBOUNCE_MS = 180;
|
||||
|
||||
export const MAX_VIEWPORT_DEGREES = 10;
|
||||
|
||||
@@ -19,3 +19,6 @@ export const COLOR_BY_CLASS = {
|
||||
export const EARTH_MEAN_RADIUS_M = 6371008.8;
|
||||
|
||||
export const DISTANCE_PREFILTER_MARGIN_M = 5000;
|
||||
|
||||
/** A Contacts subject must move this far before its installation window moves. */
|
||||
export const ANCHOR_REFRESH_M = 20_000;
|
||||
@@ -32,12 +32,104 @@ export function createRendering({
|
||||
);
|
||||
}
|
||||
|
||||
let selectedFillKey = null;
|
||||
const selectedFillIds = [];
|
||||
|
||||
function clearSelectionFill() {
|
||||
for (const id of selectedFillIds)
|
||||
layerState.dataSource?.entities.removeById(id);
|
||||
selectedFillIds.length = 0;
|
||||
selectedFillKey = null;
|
||||
}
|
||||
|
||||
/** Ground geometry classifies the active surface; no height/extrusion plane. */
|
||||
function syncSelectionFill() {
|
||||
const record =
|
||||
layerState.enabled && layerState.recordById.get(layerState.selectedId);
|
||||
if (!record) {
|
||||
clearSelectionFill();
|
||||
return;
|
||||
}
|
||||
const classificationType =
|
||||
layerState.viewer.scene.globe?.show === false
|
||||
? Cesium.ClassificationType.CESIUM_3D_TILE
|
||||
: Cesium.ClassificationType.BOTH;
|
||||
const key = `${record.id}|${renderKey(record)}|${classificationType}`;
|
||||
if (key === selectedFillKey) return;
|
||||
clearSelectionFill();
|
||||
selectedFillKey = key;
|
||||
const footprints =
|
||||
record.footprints || (record.footprint ? [[record.footprint]] : []);
|
||||
const material = new Cesium.ColorMaterialProperty(
|
||||
parts.model.colorFor(record).withAlpha(0.22),
|
||||
);
|
||||
for (let i = 0; i < footprints.length; i++) {
|
||||
const rings = footprints[i];
|
||||
const positions = (ring) =>
|
||||
ring.map(([lon, lat]) => Cesium.Cartesian3.fromDegrees(lon, lat));
|
||||
const fill = layerState.dataSource.entities.add({
|
||||
id: `${record.id}:selected-fill:${i}`,
|
||||
polygon: {
|
||||
hierarchy: new Cesium.PolygonHierarchy(
|
||||
positions(rings[0]),
|
||||
rings
|
||||
.slice(1)
|
||||
.map((ring) => new Cesium.PolygonHierarchy(positions(ring))),
|
||||
),
|
||||
material,
|
||||
classificationType,
|
||||
},
|
||||
});
|
||||
fill.installationId = record.id;
|
||||
selectedFillIds.push(fill.id);
|
||||
}
|
||||
governorRequestRender('installation-selection-fill');
|
||||
}
|
||||
|
||||
function clearRendered() {
|
||||
clearSelectionFill();
|
||||
if (layerState.dataSource?.entities)
|
||||
layerState.dataSource.entities.removeAll();
|
||||
layerState.renderedKeys.clear();
|
||||
removeEntityContextsForLayer(LAYER_ID);
|
||||
}
|
||||
|
||||
/**
|
||||
* Geometry identity of one rendered record. Records whose key is unchanged
|
||||
* keep their Cesium entities across refreshes: ground-clamped outlines and
|
||||
* classification fills rebuild asynchronously, so recreating them on every
|
||||
* reload made unchanged sites blink.
|
||||
* @param {object} record Installation record.
|
||||
* @returns {string} Stable key for the record's drawn geometry.
|
||||
*/
|
||||
function renderKey(record) {
|
||||
const footprints =
|
||||
record.footprints || (record.footprint ? [[record.footprint]] : []);
|
||||
const outlines =
|
||||
record.outlineLines || (record.footprint ? [record.footprint] : []);
|
||||
let points = 0;
|
||||
for (const rings of footprints)
|
||||
for (const ring of rings) points += ring.length;
|
||||
for (const line of outlines) points += line.length;
|
||||
return [
|
||||
record.latitude.toFixed(6),
|
||||
record.longitude.toFixed(6),
|
||||
record.class,
|
||||
record.kind,
|
||||
record.name,
|
||||
footprints.length,
|
||||
outlines.length,
|
||||
points,
|
||||
].join('|');
|
||||
}
|
||||
|
||||
function removeRecordEntities(id) {
|
||||
const entities = layerState.dataSource.entities;
|
||||
for (const entityId of layerState.renderedKeys.get(id)?.entityIds || [])
|
||||
entities.removeById(entityId);
|
||||
layerState.renderedKeys.delete(id);
|
||||
}
|
||||
|
||||
/**
|
||||
* The records that get entities this paint: the nearest `MAX_RENDERED`, plus
|
||||
* the selected one when it falls outside that window.
|
||||
@@ -52,7 +144,9 @@ export function createRendering({
|
||||
*/
|
||||
|
||||
function renderableRecords() {
|
||||
const rendered = layerState.records.slice(0, MAX_RENDERED);
|
||||
const rendered = layerState.records
|
||||
.filter((r) => !r.pointOnly)
|
||||
.slice(0, MAX_RENDERED);
|
||||
if (!layerState.selectedId) return rendered;
|
||||
if (rendered.some((record) => record.id === layerState.selectedId))
|
||||
return rendered;
|
||||
@@ -67,16 +161,48 @@ export function createRendering({
|
||||
if (
|
||||
!claimSelection &&
|
||||
layerState.selectedId &&
|
||||
selectedContext &&
|
||||
selectedContext.id !== layerState.selectedId
|
||||
(!selectedContext || selectedContext.id !== layerState.selectedId)
|
||||
) {
|
||||
layerState.selectedId = null;
|
||||
}
|
||||
let migratedSelection = false;
|
||||
if (
|
||||
layerState.selectedId &&
|
||||
!layerState.recordById.has(layerState.selectedId)
|
||||
) {
|
||||
const replacement = layerState.records.find((record) =>
|
||||
record.aliasIds?.includes(layerState.selectedId),
|
||||
);
|
||||
if (replacement) {
|
||||
layerState.selectedId = replacement.id;
|
||||
migratedSelection = true;
|
||||
}
|
||||
}
|
||||
// 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()) {
|
||||
if (
|
||||
layerState.records.some((r) =>
|
||||
r.sources?.some((s) => s.name === 'OpenStreetMap'),
|
||||
)
|
||||
)
|
||||
services.credits?.showOsmCredit?.(layerState.viewer, LAYER_ID, {
|
||||
openMapTiles: layerState.records.some((r) =>
|
||||
r.sources?.some(
|
||||
(s) => s.name === 'OpenStreetMap' && s.id?.startsWith('tile:'),
|
||||
),
|
||||
),
|
||||
});
|
||||
else services.credits?.hideOsmCredit?.(layerState.viewer, LAYER_ID);
|
||||
const records = renderableRecords();
|
||||
const keys = new Map(
|
||||
records.map((record) => [record.id, renderKey(record)]),
|
||||
);
|
||||
const entities = layerState.dataSource.entities;
|
||||
entities.suspendEvents();
|
||||
for (const [id, rendered] of [...layerState.renderedKeys])
|
||||
if (keys.get(id) !== rendered.key) removeRecordEntities(id);
|
||||
for (const record of records) {
|
||||
const color = parts.model.colorFor(record);
|
||||
const surfaceHeightM = installationSurfaceHeightM(record);
|
||||
const displayPosition = Cesium.Cartesian3.fromDegrees(
|
||||
@@ -84,31 +210,63 @@ export function createRendering({
|
||||
record.latitude,
|
||||
surfaceHeightM,
|
||||
);
|
||||
const selectedRecord = record.id === layerState.selectedId;
|
||||
const retained = layerState.renderedKeys.has(record.id)
|
||||
? entities.getById(record.id)
|
||||
: null;
|
||||
if (retained) {
|
||||
// Same geometry: update the marker in place (selection, floor height).
|
||||
if (retained.gevInstallationHeightM !== surfaceHeightM) {
|
||||
retained.gevInstallationHeightM = surfaceHeightM;
|
||||
retained.position = displayPosition;
|
||||
retained.gevDisplayPosition = () => displayPosition;
|
||||
}
|
||||
if (retained.gevInstallationSelected !== selectedRecord) {
|
||||
retained.gevInstallationSelected = selectedRecord;
|
||||
retained.point.pixelSize = selectedRecord ? 13 : 9;
|
||||
retained.point.color = selectedRecord ? Cesium.Color.WHITE : color;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
const entityIds = [record.id];
|
||||
layerState.renderedKeys.set(record.id, {
|
||||
key: keys.get(record.id),
|
||||
entityIds,
|
||||
});
|
||||
const entity = layerState.dataSource.entities.add({
|
||||
id: record.id,
|
||||
position: displayPosition,
|
||||
point: {
|
||||
show: !record.namedArea,
|
||||
pixelSize: record.id === layerState.selectedId ? 13 : 9,
|
||||
heightReference: Cesium.HeightReference.CLAMP_TO_GROUND,
|
||||
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,
|
||||
});
|
||||
const outlines =
|
||||
record.outlineLines || (record.footprint ? [record.footprint] : []);
|
||||
for (let i = 0; i < outlines.length; i++) {
|
||||
const outline = layerState.dataSource.entities.add({
|
||||
id: `${record.id}:outline:${i}`,
|
||||
polyline: {
|
||||
positions: outlines[i].map(([lon, lat]) =>
|
||||
Cesium.Cartesian3.fromDegrees(lon, lat),
|
||||
),
|
||||
width: 2,
|
||||
material: color.withAlpha(0.85),
|
||||
clampToGround: true,
|
||||
classificationType: Cesium.ClassificationType.BOTH,
|
||||
},
|
||||
});
|
||||
outline.installationId = record.id;
|
||||
entityIds.push(outline.id);
|
||||
}
|
||||
entity.gevInstallationHeightM = surfaceHeightM;
|
||||
entity.gevInstallationSelected = selectedRecord;
|
||||
entity.gevTrackedId = `installations:${record.id}`;
|
||||
entity.gevDisplayPosition = () => displayPosition;
|
||||
entity.gevLabelModel = {
|
||||
@@ -117,6 +275,7 @@ export function createRendering({
|
||||
String(record.class || 'installation')
|
||||
.replaceAll('_', ' ')
|
||||
.toUpperCase(),
|
||||
...(record.memberNames || []).slice(0, 3),
|
||||
],
|
||||
accent: COLOR_BY_CLASS[record.class] || '#9ca6b0',
|
||||
};
|
||||
@@ -133,6 +292,7 @@ export function createRendering({
|
||||
longitude: record.longitude,
|
||||
properties: {
|
||||
class: record.class,
|
||||
memberNames: record.memberNames || [],
|
||||
primaryType: record.primaryType || null,
|
||||
placeTypes: Array.isArray(record.placeTypes) ? record.placeTypes : [],
|
||||
validation: record.validation,
|
||||
@@ -140,11 +300,17 @@ export function createRendering({
|
||||
},
|
||||
});
|
||||
}
|
||||
entities.resumeEvents();
|
||||
const selectedEntity = layerState.selectedId
|
||||
? layerState.dataSource.entities.getById(layerState.selectedId)
|
||||
: null;
|
||||
if (selectedEntity) selectEntityContext(selectedEntity);
|
||||
else layerState.selectedId = null;
|
||||
if (selectedEntity && (claimSelection || migratedSelection))
|
||||
selectEntityContext(selectedEntity);
|
||||
else if (!selectedEntity) layerState.selectedId = null;
|
||||
// Re-register rebuilt geometry and migrate aliases before pruning old context.
|
||||
removeEntityContextsForLayer(LAYER_ID, { retainIds: new Set(keys.keys()) });
|
||||
parts.namedMarkers?.sync(layerState.records);
|
||||
syncSelectionFill();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -187,6 +353,8 @@ export function createRendering({
|
||||
}
|
||||
return {
|
||||
installationSurfaceHeightM,
|
||||
clearSelectionFill,
|
||||
syncSelectionFill,
|
||||
clearRendered,
|
||||
renderableRecords,
|
||||
renderRecords,
|
||||
|
||||
@@ -10,6 +10,24 @@ export function createSelection({
|
||||
}) {
|
||||
const { clearSelectedEntityContextForLayer } = services.context;
|
||||
|
||||
let removeContextListeners = null;
|
||||
|
||||
function syncContextSelection(event) {
|
||||
if (!layerState.enabled) return;
|
||||
// Tracking layers publish this event before replacing the shared context.
|
||||
const selected =
|
||||
event?.type === 'gev:awareness-subject-selected'
|
||||
? event.detail
|
||||
: services.context.getSelectedEntityContext();
|
||||
const next =
|
||||
selected?.layerId === LAYER_ID && layerState.recordById.has(selected.id)
|
||||
? selected.id
|
||||
: null;
|
||||
if (next === layerState.selectedId) return;
|
||||
layerState.selectedId = next;
|
||||
parts.rendering.renderRecords();
|
||||
}
|
||||
|
||||
function selectRecord(id) {
|
||||
const record = layerState.recordById.get(id);
|
||||
if (!record || !layerState.dataSource) return false;
|
||||
@@ -21,6 +39,17 @@ export function createSelection({
|
||||
|
||||
function installInteraction(viewer) {
|
||||
if (layerState.clickHandler) return;
|
||||
const events = [
|
||||
'gev:entity-selected',
|
||||
'gev:entity-selection-cleared',
|
||||
'gev:awareness-subject-selected',
|
||||
];
|
||||
for (const event of events)
|
||||
globalThis.window?.addEventListener?.(event, syncContextSelection);
|
||||
removeContextListeners = () => {
|
||||
for (const event of events)
|
||||
globalThis.window?.removeEventListener?.(event, syncContextSelection);
|
||||
};
|
||||
layerState.clickHandler = new Cesium.ScreenSpaceEventHandler(
|
||||
viewer.scene.canvas,
|
||||
);
|
||||
@@ -29,7 +58,9 @@ export function createSelection({
|
||||
if (!isPointerFree()) return;
|
||||
if (!layerState.enabled) return;
|
||||
const picked = viewer.scene.pick(click.position);
|
||||
const id = typeof picked?.id?.id === 'string' ? picked.id.id : null;
|
||||
const id =
|
||||
picked?.id?.installationId ||
|
||||
(typeof picked?.id?.id === 'string' ? picked.id.id : null);
|
||||
if (id && layerState.recordById.has(id) && id !== layerState.selectedId) {
|
||||
selectRecord(id);
|
||||
} else if (layerState.selectedId) {
|
||||
@@ -42,5 +73,12 @@ export function createSelection({
|
||||
}
|
||||
}, Cesium.ScreenSpaceEventType.LEFT_CLICK);
|
||||
}
|
||||
return { selectRecord, installInteraction };
|
||||
return {
|
||||
selectRecord,
|
||||
installInteraction,
|
||||
destroy() {
|
||||
removeContextListeners?.();
|
||||
removeContextListeners = null;
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,155 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import * as Cesium from 'cesium';
|
||||
import * as context from '../../data/contextStore.js';
|
||||
import { createSelection } from './selection.js';
|
||||
import { createRendering } from './rendering.js';
|
||||
import { createNamedMarkers } from './namedMarkers.js';
|
||||
|
||||
function harness(t) {
|
||||
const previousWindow = globalThis.window;
|
||||
const previousDocument = globalThis.document;
|
||||
globalThis.window = new EventTarget();
|
||||
globalThis.document = new EventTarget();
|
||||
const record = {
|
||||
id: 'base',
|
||||
name: 'Base',
|
||||
class: 'base',
|
||||
namedArea: true,
|
||||
latitude: 0,
|
||||
longitude: 0,
|
||||
footprints: [
|
||||
[
|
||||
[
|
||||
[0, 0],
|
||||
[0.01, 0],
|
||||
[0.01, 0.01],
|
||||
[0, 0],
|
||||
],
|
||||
],
|
||||
],
|
||||
};
|
||||
const state = {
|
||||
enabled: true,
|
||||
selectedId: null,
|
||||
records: [record],
|
||||
recordById: new Map([[record.id, record]]),
|
||||
renderedKeys: new Map(),
|
||||
dataSource: new Cesium.CustomDataSource(),
|
||||
viewer: {
|
||||
camera: { positionWC: Cesium.Cartesian3.fromDegrees(0, 0, 1e6) },
|
||||
scene: {
|
||||
globe: { show: false },
|
||||
canvas: Object.assign(new EventTarget(), {
|
||||
clientWidth: 1280,
|
||||
clientHeight: 800,
|
||||
}),
|
||||
primitives: { add: (p) => p, remove() {} },
|
||||
},
|
||||
},
|
||||
};
|
||||
t.mock.method(
|
||||
Cesium.SceneTransforms,
|
||||
'worldToWindowCoordinates',
|
||||
(_scene, _point, scratch) => {
|
||||
scratch.x = 640;
|
||||
scratch.y = 400;
|
||||
return scratch;
|
||||
},
|
||||
);
|
||||
let labels = [];
|
||||
const services = {
|
||||
context,
|
||||
anchors: {},
|
||||
ground: { floorAltitudeM: () => 0, cachedGroundFloor: () => 0 },
|
||||
render: { governorRequestRender() {} },
|
||||
};
|
||||
const parts = {
|
||||
model: {
|
||||
colorFor: () => Cesium.Color.ORANGE,
|
||||
installationSourceLabel: () => 'OpenStreetMap',
|
||||
},
|
||||
};
|
||||
const ctx = {
|
||||
state,
|
||||
services,
|
||||
parts,
|
||||
overlayHost: {
|
||||
setEntries(_id, entries) {
|
||||
labels = entries;
|
||||
},
|
||||
clearSource() {},
|
||||
setVisible() {},
|
||||
},
|
||||
};
|
||||
parts.rendering = createRendering(ctx);
|
||||
parts.namedMarkers = createNamedMarkers(ctx);
|
||||
const selection = createSelection(ctx);
|
||||
selection.installInteraction(state.viewer);
|
||||
t.after(() => {
|
||||
selection.destroy();
|
||||
state.clickHandler.destroy();
|
||||
parts.namedMarkers.destroy();
|
||||
parts.rendering.clearRendered();
|
||||
if (previousWindow === undefined) delete globalThis.window;
|
||||
else globalThis.window = previousWindow;
|
||||
if (previousDocument === undefined) delete globalThis.document;
|
||||
else globalThis.document = previousDocument;
|
||||
});
|
||||
const assertSelected = (selected) => {
|
||||
assert.equal(state.selectedId, selected ? record.id : null);
|
||||
assert.equal(labels[0].selected, selected);
|
||||
assert.equal(labels[0].variant, selected ? 'selected' : 'card');
|
||||
assert.equal(
|
||||
state.dataSource.entities.values.filter((e) => e.polygon).length,
|
||||
selected ? 1 : 0,
|
||||
);
|
||||
};
|
||||
assert.equal(selection.selectRecord(record.id), true);
|
||||
assertSelected(true);
|
||||
return { state, selection, assertSelected };
|
||||
}
|
||||
|
||||
for (const layerId of ['flights', 'military', 'satellites']) {
|
||||
for (const publishBeforeEvent of [false, true]) {
|
||||
test(`${layerId} programmatic selection clears the installation card and fill (context first: ${publishBeforeEvent})`, (t) => {
|
||||
const h = harness(t);
|
||||
const subject = { id: 'other', layerId, origin: 'programmatic' };
|
||||
if (publishBeforeEvent) context.selectTrackedSubjectContext(subject);
|
||||
window.dispatchEvent(
|
||||
new CustomEvent('gev:awareness-subject-selected', { detail: subject }),
|
||||
);
|
||||
h.assertSelected(false);
|
||||
if (!publishBeforeEvent) context.selectTrackedSubjectContext(subject);
|
||||
assert.equal(context.getSelectedEntityContext().id, 'other');
|
||||
assert.equal(context.getSelectedEntityContext().layerId, layerId);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
test('entity selection and clear still release the installation card and fill', (t) => {
|
||||
const h = harness(t);
|
||||
const other = {};
|
||||
context.registerEntityContext(other, { id: 'other', layerId: 'vessels' });
|
||||
context.selectEntityContext(other);
|
||||
h.assertSelected(false);
|
||||
h.selection.selectRecord('base');
|
||||
context.clearSelectedEntityContextForLayer('military-installations');
|
||||
h.assertSelected(false);
|
||||
});
|
||||
|
||||
test('destroy removes all installation context listeners', (t) => {
|
||||
const h = harness(t);
|
||||
h.selection.destroy();
|
||||
context.selectTrackedSubjectContext({ id: 'other', layerId: 'flights' });
|
||||
for (const type of [
|
||||
'gev:entity-selected',
|
||||
'gev:entity-selection-cleared',
|
||||
'gev:awareness-subject-selected',
|
||||
]) {
|
||||
window.dispatchEvent(
|
||||
new CustomEvent(type, { detail: { id: 'other', layerId: 'flights' } }),
|
||||
);
|
||||
h.assertSelected(true);
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,80 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import * as C from 'cesium';
|
||||
import { createRendering } from './rendering.js';
|
||||
import { createLifecycle } from './lifecycle.js';
|
||||
|
||||
function harness() {
|
||||
const ring = [[-97, 30], [-96.99, 30], [-96.99, 30.01], [-97, 30]];
|
||||
const record = { id: 'base', latitude: 30, longitude: -97, name: 'Base', class: 'base', footprints: [[ring, ring.map(([x,y]) => [x+0.001,y+0.001])], [ring]] };
|
||||
const state = { enabled: true, selectedId: 'base', records: [record], recordById: new Map([['base', record]]), renderedKeys: new Map(), dataSource: new C.CustomDataSource(), viewer: { scene: { globe: { show: false } }, camera: {} } };
|
||||
let listener, removed = 0;
|
||||
const noop = () => {};
|
||||
const services = { ground: { floorAltitudeM: () => 0, cachedGroundFloor: () => 0 }, anchors: {}, render: { governorRequestRender: noop }, context: { removeEntityContextsForLayer: noop, clearSelectedEntityContextForLayer: noop }, picking: { registerPickOwner: noop, unregisterPickOwner: noop }, maps: { subscribeMapStack(cb) { listener = cb; return () => { listener = null; removed++; }; } } };
|
||||
const parts = { model: { colorFor: () => C.Color.ORANGE, installationSourceLabel: () => 'OpenStreetMap' }, viewport: { clearUnavailableRetry: noop }, ingestion: { setInstallationStatus: noop } };
|
||||
parts.rendering = createRendering({ state, services, parts });
|
||||
const lifecycle = createLifecycle({ state, services, parts, source: {} }).methods;
|
||||
return { state, record, parts, lifecycle, changeSurface: () => listener(), removed: () => removed };
|
||||
}
|
||||
|
||||
test('selected multipart footprints classify Google tiles with one constant translucent material and holes', () => {
|
||||
const h = harness(), render = h.parts.rendering;
|
||||
render.syncSelectionFill();
|
||||
const fills = h.state.dataSource.entities.values.slice();
|
||||
assert.equal(fills.length, 2);
|
||||
for (const fill of fills) {
|
||||
assert.equal(fill.installationId, 'base');
|
||||
assert.equal(fill.polygon.classificationType.getValue(), C.ClassificationType.CESIUM_3D_TILE);
|
||||
assert.equal(fill.polygon.height, undefined);
|
||||
assert.equal(fill.polygon.extrudedHeight, undefined);
|
||||
assert.equal(fill.polygon.material.isConstant, true);
|
||||
assert.equal(fill.polygon.material.color.getValue().alpha, 0.22);
|
||||
}
|
||||
assert.equal(fills[0].polygon.hierarchy.getValue().holes.length, 1);
|
||||
assert.equal(fills[0].polygon.material, fills[1].polygon.material);
|
||||
for (let i = 0; i < 100; i++) render.syncSelectionFill();
|
||||
assert.deepEqual(h.state.dataSource.entities.values, fills, 'repaints retain ground geometry');
|
||||
h.state.selectedId = null; render.syncSelectionFill();
|
||||
assert.equal(h.state.dataSource.entities.values.length, 0);
|
||||
});
|
||||
|
||||
test('surface changes reclassify selected geometry and disable tears down fills and subscription', () => {
|
||||
const h = harness();
|
||||
h.lifecycle.enable(); h.parts.rendering.syncSelectionFill();
|
||||
h.state.viewer.scene.globe.show = true; h.changeSurface();
|
||||
assert.ok(h.state.dataSource.entities.values.every(e => e.polygon.classificationType.getValue() === C.ClassificationType.BOTH));
|
||||
h.lifecycle.disable();
|
||||
assert.equal(h.state.dataSource.entities.values.length, 0);
|
||||
assert.equal(h.removed(), 1);
|
||||
h.lifecycle.enable(); h.parts.rendering.syncSelectionFill();
|
||||
assert.equal(h.state.dataSource.entities.values.length, 0, 'reenabling cannot revive a stale selection');
|
||||
h.lifecycle.disable();
|
||||
});
|
||||
|
||||
|
||||
test('geometry rebuild and canonical alias migration preserve shared selection before pruning', async () => {
|
||||
const context = await import('../../data/contextStore.js');
|
||||
const previousWindow = globalThis.window;
|
||||
globalThis.window = new EventTarget();
|
||||
try {
|
||||
const h = harness();
|
||||
const services = { context, render: { governorRequestRender() {} }, ground: { floorAltitudeM: () => 0, cachedGroundFloor: () => 0 }, anchors: {} };
|
||||
const rendering = createRendering({ state: h.state, services, parts: h.parts });
|
||||
const cleared = [];
|
||||
window.addEventListener('gev:entity-selection-cleared', e => { cleared.push(e.detail); h.state.selectedId = null; });
|
||||
rendering.renderRecords({ claimSelection: true });
|
||||
const oldEntity = context.getSelectedEntityContext().entity;
|
||||
h.record.latitude += 0.001;
|
||||
rendering.renderRecords();
|
||||
assert.equal(h.state.selectedId, 'base');
|
||||
assert.equal(context.getSelectedEntityContext().id, 'base');
|
||||
assert.notEqual(context.getSelectedEntityContext().entity, oldEntity);
|
||||
const replacement = { ...h.record, id: 'canonical', aliasIds: ['base'] };
|
||||
h.state.records = [replacement]; h.state.recordById = new Map([['canonical', replacement]]);
|
||||
rendering.renderRecords();
|
||||
assert.equal(h.state.selectedId, 'canonical');
|
||||
assert.equal(context.getSelectedEntityContext().id, 'canonical');
|
||||
assert.deepEqual(cleared, [], 'no transient eviction can tear down the selected card or fill');
|
||||
rendering.clearRendered();
|
||||
} finally { if (previousWindow === undefined) delete globalThis.window; else globalThis.window = previousWindow; }
|
||||
});
|
||||
@@ -1,3 +1,22 @@
|
||||
import { waitForSignal } from '../../sources/requestDeadline.js';
|
||||
import {
|
||||
loadMilitaryNames,
|
||||
militaryNamesInView,
|
||||
nameMilitaryFragment,
|
||||
} from '../../data/militaryNames.js';
|
||||
import {
|
||||
createOpenFreeMapSource,
|
||||
decodeOpenFreeMapMilitaryTile,
|
||||
} from '../../sources/openFreeMap.js';
|
||||
import {
|
||||
mergeMilitaryFragments,
|
||||
militaryNameInView,
|
||||
} from '../../sources/militaryTileGeometry.js';
|
||||
import { tilesForBounds } from '../../data/tomtomTiles.js';
|
||||
import {
|
||||
isUnavailableCapability,
|
||||
sourceResponseError,
|
||||
} from '../../sources/capability.js';
|
||||
import { normalizeMilitaryInstallations } from '../../data/militaryInstallationData.js';
|
||||
|
||||
/** Preserve legacy cache admission even when the explicit saturation flag is absent. */
|
||||
@@ -8,11 +27,160 @@ export function installationResponseSaturated(payload) {
|
||||
return Array.isArray(payload?.elements) && payload.elements.length >= cap;
|
||||
}
|
||||
|
||||
/**
|
||||
* OpenMapTiles carries `landuse=military` only from z9; coarser tiles omit it,
|
||||
* so a view that needs them would read as an empty (falsely all-clear) area.
|
||||
*/
|
||||
export const MILITARY_TILE_MIN_ZOOM = 9;
|
||||
|
||||
/** Per-view tile cap for installation tiles. */
|
||||
export const MILITARY_TILE_MAX = 16;
|
||||
|
||||
/**
|
||||
* Pick the finest installation tile zoom that fits the view, or null when even
|
||||
* z9 would exceed the tile cap (the caller uses bundled named points).
|
||||
* @param {{south:number, west:number, north:number, east:number}} box View box.
|
||||
* @returns {number|null} Tile zoom.
|
||||
*/
|
||||
export function installationTileZoom(box) {
|
||||
let zoom =
|
||||
Math.max(box.north - box.south, box.east - box.west) > 0.2 ? 10 : 12;
|
||||
const fits = (z) =>
|
||||
tilesForBounds(box, z, { maxTiles: MILITARY_TILE_MAX + 1 }).length <=
|
||||
MILITARY_TILE_MAX;
|
||||
while (zoom > MILITARY_TILE_MIN_ZOOM && !fits(zoom)) zoom -= 1;
|
||||
return fits(zoom) ? zoom : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Square window around a context subject that fits the installation tile cap
|
||||
* at the military minimum zoom: 100 km half-width at mid latitudes, shrinking
|
||||
* toward the poles where z9 tiles are shorter.
|
||||
* @param {{latitude:number, longitude:number}} anchor Subject position.
|
||||
* @returns {{south:number, west:number, north:number, east:number,
|
||||
* radiusM:number}|null} Bounded box, or null for an invalid anchor.
|
||||
*/
|
||||
export function installationAnchorBox(anchor) {
|
||||
const { latitude, longitude } = anchor || {};
|
||||
if (
|
||||
!Number.isFinite(latitude) ||
|
||||
!Number.isFinite(longitude) ||
|
||||
Math.abs(latitude) > 85 ||
|
||||
Math.abs(longitude) > 180
|
||||
)
|
||||
return null;
|
||||
for (let radiusM = 100_000; radiusM >= 10_000; radiusM *= 0.8) {
|
||||
const latSpan = radiusM / 111_320;
|
||||
const lonSpan =
|
||||
latSpan / Math.max(0.05, Math.cos((latitude * Math.PI) / 180));
|
||||
const box = {
|
||||
south: Math.max(-85, latitude - latSpan),
|
||||
north: Math.min(85, latitude + latSpan),
|
||||
west: Math.max(-180, longitude - lonSpan),
|
||||
east: Math.min(180, longitude + lonSpan),
|
||||
};
|
||||
if (
|
||||
tilesForBounds(box, MILITARY_TILE_MIN_ZOOM, {
|
||||
maxTiles: MILITARY_TILE_MAX + 1,
|
||||
}).length <= MILITARY_TILE_MAX
|
||||
)
|
||||
return { ...box, radiusM: Math.round(radiusM) };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Read mapped installations and explicit nearby-place searches through fixed endpoints. */
|
||||
export function createInstallationSource({
|
||||
fetchImpl = (...args) => globalThis.fetch(...args),
|
||||
tileFetchImpl = (...args) => globalThis.fetch(...args),
|
||||
loadNames = loadMilitaryNames,
|
||||
mapTiles = createOpenFreeMapSource({
|
||||
fetchImpl: tileFetchImpl,
|
||||
decode: decodeOpenFreeMapMilitaryTile,
|
||||
}),
|
||||
} = {}) {
|
||||
let overpassUnavailable = false;
|
||||
const installationIds = new Map();
|
||||
async function getNamedSites(box, signal) {
|
||||
const names = await waitForSignal(loadNames(), signal).catch((cause) => {
|
||||
signal?.throwIfAborted();
|
||||
throw Object.assign(
|
||||
new Error('Mapped names temporarily unavailable', { cause }),
|
||||
{
|
||||
failureReason: 'names_unavailable',
|
||||
},
|
||||
);
|
||||
});
|
||||
signal?.throwIfAborted();
|
||||
const { records, count } = militaryNamesInView(names, box);
|
||||
return {
|
||||
records,
|
||||
namedInView: count,
|
||||
wide: true,
|
||||
status: 'ready',
|
||||
tileSource: true,
|
||||
saturated: false,
|
||||
droppedCount: 0,
|
||||
source: 'OpenStreetMap',
|
||||
};
|
||||
}
|
||||
async function getTileSites(box, signal) {
|
||||
const zoom = installationTileZoom(box);
|
||||
if (zoom === null) return getNamedSites(box, signal);
|
||||
let names = null;
|
||||
const namesJob = waitForSignal(loadNames(), signal)
|
||||
.then((loaded) => {
|
||||
names = loaded;
|
||||
return loaded;
|
||||
})
|
||||
.catch(() => null);
|
||||
let result;
|
||||
try {
|
||||
result = await mapTiles.fetchBounds(box, { zoom, signal });
|
||||
} catch (error) {
|
||||
if (error?.name === 'AbortError' || signal?.aborted) throw error;
|
||||
// Name the tile source: an Overpass reason here would be untrue.
|
||||
throw Object.assign(
|
||||
error instanceof Error ? error : new Error(String(error)),
|
||||
{
|
||||
failureReason: 'tiles_unavailable',
|
||||
},
|
||||
);
|
||||
}
|
||||
signal?.throwIfAborted();
|
||||
const snapshot = (names) => {
|
||||
const fragments = result.tiles
|
||||
.flatMap((tile) => tile.military)
|
||||
.map((record) => nameMilitaryFragment(record, names));
|
||||
const retrievedAt = new Date().toISOString();
|
||||
return {
|
||||
records: mergeMilitaryFragments(
|
||||
fragments.slice(0, 2048),
|
||||
installationIds,
|
||||
).map((record) => ({
|
||||
...militaryNameInView(record, box),
|
||||
retrievedAt,
|
||||
sources: record.sources.map((source) => ({ ...source, retrievedAt })),
|
||||
})),
|
||||
status: 'ready',
|
||||
droppedCount: 0,
|
||||
saturated: result.partial || fragments.length > 2048,
|
||||
source: 'OpenStreetMap tiles',
|
||||
tileSource: true,
|
||||
};
|
||||
};
|
||||
const payload = snapshot(names);
|
||||
if (!names)
|
||||
payload.enrichment = namesJob.then((loaded) =>
|
||||
loaded && !signal?.aborted ? snapshot(loaded) : null,
|
||||
);
|
||||
return payload;
|
||||
}
|
||||
return {
|
||||
destroy() {
|
||||
mapTiles.clear();
|
||||
installationIds.clear();
|
||||
},
|
||||
async getMappedSites(box, { exact = false, signal } = {}) {
|
||||
const { south, west, north, east } = box || {};
|
||||
if (
|
||||
@@ -20,14 +188,17 @@ export function createInstallationSource({
|
||||
south < -90 ||
|
||||
north > 90 ||
|
||||
west < -180 ||
|
||||
west > 180 ||
|
||||
east < -180 ||
|
||||
east > 180 ||
|
||||
north <= south ||
|
||||
east <= west ||
|
||||
north - south > 10 ||
|
||||
east - west > 10
|
||||
east === west
|
||||
)
|
||||
throw new TypeError('A bounded installation viewport is required');
|
||||
signal?.throwIfAborted();
|
||||
if (east < west || north - south > 10 || east - west > 10)
|
||||
return getNamedSites(box, signal);
|
||||
if (overpassUnavailable) return getTileSites(box, signal);
|
||||
const query = new URLSearchParams(
|
||||
Object.entries({ south, west, north, east }).map(([key, value]) => [
|
||||
key,
|
||||
@@ -40,9 +211,17 @@ export function createInstallationSource({
|
||||
});
|
||||
const body = await response.json();
|
||||
signal?.throwIfAborted();
|
||||
if (isUnavailableCapability(body)) {
|
||||
overpassUnavailable = true;
|
||||
return getTileSites(box, signal);
|
||||
}
|
||||
if (!response.ok)
|
||||
throw Object.assign(
|
||||
new Error(body?.error || `Installation feed HTTP ${response.status}`),
|
||||
sourceResponseError(
|
||||
body,
|
||||
response,
|
||||
'Installation context unavailable',
|
||||
),
|
||||
{
|
||||
failureReason: ['rate_limited', 'timeout', 'query_failed'].includes(
|
||||
body?.reason,
|
||||
|
||||
@@ -1,8 +1,43 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { createInstallationSource } from './source.js';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { tilesForBounds } from '../../data/tomtomTiles.js';
|
||||
import { installationFeedback } from '../../data/installationFeedback.js';
|
||||
import {
|
||||
createInstallationSource,
|
||||
installationAnchorBox,
|
||||
installationTileZoom,
|
||||
MILITARY_TILE_MAX,
|
||||
MILITARY_TILE_MIN_ZOOM,
|
||||
} from './source.js';
|
||||
import {
|
||||
decodeOpenFreeMapMilitaryTile,
|
||||
decodeOpenFreeMapTile,
|
||||
} from '../../sources/openFreeMap.js';
|
||||
|
||||
const box = { south: 30.1, west: -97.9, north: 30.3, east: -97.6 };
|
||||
test('a wide-view pack failure names the unavailable data without an API fallback', async () => {
|
||||
const source = createInstallationSource({
|
||||
fetchImpl: async () => assert.fail('no API request for bundled names'),
|
||||
loadNames: async () => {
|
||||
throw new Error('asset unavailable');
|
||||
},
|
||||
});
|
||||
await assert.rejects(
|
||||
source.getMappedSites({ south: 20, west: -130, north: 60, east: -60 }),
|
||||
(error) => {
|
||||
assert.equal(error.failureReason, 'names_unavailable');
|
||||
assert.equal(
|
||||
installationFeedback({
|
||||
status: 'unavailable',
|
||||
failureReason: error.failureReason,
|
||||
}),
|
||||
'Mapped names temporarily unavailable',
|
||||
);
|
||||
return true;
|
||||
},
|
||||
);
|
||||
});
|
||||
test('mapped-site sources validate viewport bounds and preserve the exact retry key', async () => {
|
||||
const calls = [];
|
||||
const source = createInstallationSource({
|
||||
@@ -14,7 +49,7 @@ test('mapped-site sources validate viewport bounds and preserve the exact retry
|
||||
for (const invalid of [
|
||||
null,
|
||||
{ ...box, east: Infinity },
|
||||
{ ...box, north: 90 },
|
||||
{ ...box, north: 91 },
|
||||
{ ...box, south: 31 },
|
||||
])
|
||||
await assert.rejects(
|
||||
@@ -84,3 +119,146 @@ test('mapped-site sources normalize records and derive saturation from legacy ca
|
||||
assert.equal(payload.status, 'stale');
|
||||
assert.equal('elements' in payload, false);
|
||||
});
|
||||
|
||||
test('installation tiles never drop below z9, where military areas exist', () => {
|
||||
// OpenMapTiles omits landuse=military below z9; a z6-z8 view used to read as
|
||||
// an empty, "Mapped sites loaded" area in Cockpit and wide follow views.
|
||||
assert.equal(
|
||||
installationTileZoom({
|
||||
south: 30.3,
|
||||
west: -97.8,
|
||||
north: 30.33,
|
||||
east: -97.74,
|
||||
}),
|
||||
12,
|
||||
);
|
||||
assert.equal(
|
||||
installationTileZoom({ south: 30, west: -98, north: 31, east: -97 }),
|
||||
10,
|
||||
);
|
||||
assert.equal(
|
||||
installationTileZoom({ south: 30, west: -98.2, north: 31.4, east: -96.8 }),
|
||||
9,
|
||||
);
|
||||
assert.equal(
|
||||
installationTileZoom({ south: 28, west: -100, north: 32, east: -96 }),
|
||||
null,
|
||||
);
|
||||
});
|
||||
|
||||
test('a Contacts window fits the tile cap at z9 from the tropics to the Arctic', () => {
|
||||
for (const latitude of [0, 30.3125, 51.5, 60, 70]) {
|
||||
const box = installationAnchorBox({ latitude, longitude: -97.765 });
|
||||
assert.ok(box, `window at ${latitude}`);
|
||||
assert.ok(
|
||||
tilesForBounds(box, MILITARY_TILE_MIN_ZOOM, { maxTiles: 64 }).length <=
|
||||
MILITARY_TILE_MAX,
|
||||
);
|
||||
assert.ok(box.south < latitude && box.north > latitude);
|
||||
}
|
||||
assert.equal(
|
||||
installationAnchorBox({ latitude: 30.3125, longitude: -97.765 }).radiusM,
|
||||
100_000,
|
||||
);
|
||||
assert.ok(
|
||||
installationAnchorBox({ latitude: 60, longitude: 10 }).radiusM < 100_000,
|
||||
);
|
||||
assert.equal(
|
||||
installationAnchorBox({ latitude: Number.NaN, longitude: 0 }),
|
||||
null,
|
||||
);
|
||||
});
|
||||
|
||||
test('too-wide tile views use bundled named points without requesting tiles', async () => {
|
||||
const fetched = [];
|
||||
const source = createInstallationSource({
|
||||
fetchImpl: async () =>
|
||||
Response.json({ code: 'OVERPASS_NOT_CONFIGURED', retryable: false }),
|
||||
mapTiles: {
|
||||
clear() {},
|
||||
async fetchBounds(box, { zoom }) {
|
||||
fetched.push(zoom);
|
||||
return { tiles: [], partial: false };
|
||||
},
|
||||
},
|
||||
});
|
||||
const wide = await source.getMappedSites({
|
||||
south: 28,
|
||||
west: -100,
|
||||
north: 32,
|
||||
east: -96,
|
||||
});
|
||||
assert.equal(wide.wide, true);
|
||||
assert.equal(wide.status, 'ready');
|
||||
assert.ok(wide.records.some((r) => r.name === 'Fort Hood'));
|
||||
assert.ok(wide.records.every((r) => r.namedArea));
|
||||
assert.deepEqual(
|
||||
fetched,
|
||||
[],
|
||||
'no tile request for a view the tiles cannot answer',
|
||||
);
|
||||
await source.getMappedSites({
|
||||
south: 30,
|
||||
west: -98.2,
|
||||
north: 31.4,
|
||||
east: -96.8,
|
||||
});
|
||||
assert.deepEqual(fetched, [9]);
|
||||
});
|
||||
|
||||
test('the military-only decoder keeps Camp Mabry and skips road geometry', () => {
|
||||
const bytes = readFileSync(
|
||||
new URL(
|
||||
'../../data/fixtures/ofm-camp-mabry-12-935-1685.pbf',
|
||||
import.meta.url,
|
||||
),
|
||||
);
|
||||
const full = decodeOpenFreeMapTile(bytes, 12, 935, 1685);
|
||||
const military = decodeOpenFreeMapMilitaryTile(bytes, 12, 935, 1685);
|
||||
assert.ok(full.roads.length > 0);
|
||||
assert.equal(military.roads.length, 0);
|
||||
assert.deepEqual(
|
||||
military.military.map((record) => record.id),
|
||||
full.military.map((record) => record.id),
|
||||
);
|
||||
});
|
||||
|
||||
test('installation API transports never receive vector-tile URLs', async (t) => {
|
||||
const calls = [];
|
||||
const tileFetch = async (url) => {
|
||||
calls.push(url);
|
||||
return url.endsWith('/planet')
|
||||
? Response.json({
|
||||
tiles: ['https://tiles.openfreemap.org/test/{z}/{x}/{y}.pbf'],
|
||||
})
|
||||
: new Response(
|
||||
readFileSync(
|
||||
new URL(
|
||||
'../../data/fixtures/ofm-camp-mabry-12-935-1685.pbf',
|
||||
import.meta.url,
|
||||
),
|
||||
),
|
||||
);
|
||||
};
|
||||
t.mock.method(globalThis, 'fetch', tileFetch);
|
||||
for (const tileFetchImpl of [undefined, tileFetch]) {
|
||||
const source = createInstallationSource({
|
||||
tileFetchImpl,
|
||||
fetchImpl: async (url) => {
|
||||
assert.ok(url.startsWith('/api/'));
|
||||
return Response.json({
|
||||
code: 'OVERPASS_NOT_CONFIGURED',
|
||||
retryable: false,
|
||||
});
|
||||
},
|
||||
});
|
||||
const data = await source.getMappedSites({
|
||||
south: 30.3,
|
||||
west: -97.77,
|
||||
north: 30.32,
|
||||
east: -97.75,
|
||||
});
|
||||
assert.ok(data.records.length);
|
||||
}
|
||||
assert.ok(calls.length >= 4);
|
||||
});
|
||||
@@ -32,6 +32,14 @@ export function createState({ services }) {
|
||||
clickHandler: null,
|
||||
timer: null,
|
||||
googleSearchRequested: false,
|
||||
/** Contacts subject position; when set, it replaces the camera viewport. */
|
||||
contextAnchor: null,
|
||||
/** Latest subject centre, independent of the retained tile-fetch anchor. */
|
||||
contextPosition: null,
|
||||
/** Coverage of the last successful load: viewport, or radius around a subject. */
|
||||
coverage: { kind: 'viewport' },
|
||||
/** Per-record geometry keys of the rendered entities, for in-place updates. */
|
||||
renderedKeys: new Map(),
|
||||
});
|
||||
return state;
|
||||
}
|
||||
@@ -1,7 +1,27 @@
|
||||
import * as Cesium from 'cesium';
|
||||
import { MAX_VIEWPORT_DEGREES, REQUEST_DEBOUNCE_MS } from './policy.js';
|
||||
import { REQUEST_DEBOUNCE_MS } from './policy.js';
|
||||
import { installationAnchorBox } from './source.js';
|
||||
|
||||
export function createViewport({ state: layerState, services, parts, source }) {
|
||||
/**
|
||||
* The area this load describes: the window around a Contacts subject while
|
||||
* one is set (a follow or Cockpit camera never settles and often looks at the
|
||||
* horizon), otherwise the settled camera viewport.
|
||||
* @param {object} viewer Cesium viewer.
|
||||
* @returns {{box: object|null, coverage: object}} Query box and its coverage.
|
||||
*/
|
||||
function loadArea(viewer) {
|
||||
const anchor = layerState.contextAnchor;
|
||||
if (anchor) {
|
||||
const box = installationAnchorBox(anchor);
|
||||
if (box) {
|
||||
const { radiusM, ...bounds } = box;
|
||||
return { box: bounds, coverage: { kind: 'subject', radiusM } };
|
||||
}
|
||||
}
|
||||
return { box: viewportBox(viewer), coverage: { kind: 'viewport' } };
|
||||
}
|
||||
|
||||
function viewportBox(viewer) {
|
||||
const rectangle = viewer?.camera?.computeViewRectangle(
|
||||
viewer.scene.globe.ellipsoid,
|
||||
@@ -11,12 +31,11 @@ export function createViewport({ state: layerState, services, parts, source }) {
|
||||
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.
|
||||
// Wide and dateline views use the bundled point index.
|
||||
if (
|
||||
!Number.isFinite(south + north + west + east) ||
|
||||
east <= west ||
|
||||
north - south > MAX_VIEWPORT_DEGREES ||
|
||||
east - west > MAX_VIEWPORT_DEGREES
|
||||
east === west ||
|
||||
north <= south
|
||||
)
|
||||
return null;
|
||||
return { south, west, north, east };
|
||||
@@ -44,10 +63,13 @@ export function createViewport({ state: layerState, services, parts, source }) {
|
||||
* zoom-out, or disable cancels it.
|
||||
*/
|
||||
|
||||
function scheduleUnavailableRetry() {
|
||||
function scheduleUnavailableRetry(retryAfterMs = 0) {
|
||||
if (!layerState.enabled) return;
|
||||
clearTimeout(layerState.retryTimer);
|
||||
layerState.retryDelayMs = installationRetryDelayMs(layerState.retryDelayMs);
|
||||
layerState.retryDelayMs = Math.max(
|
||||
retryAfterMs,
|
||||
installationRetryDelayMs(layerState.retryDelayMs),
|
||||
);
|
||||
layerState.retryAt = Date.now() + layerState.retryDelayMs;
|
||||
layerState.retryTimer = setTimeout(() => {
|
||||
layerState.retryTimer = null;
|
||||
@@ -66,15 +88,36 @@ export function createViewport({ state: layerState, services, parts, source }) {
|
||||
|
||||
function scheduleLoad() {
|
||||
if (!layerState.enabled) return;
|
||||
const { box, coverage } = loadArea(layerState.viewer);
|
||||
const camera = layerState.viewer.camera;
|
||||
const widePose =
|
||||
coverage.kind === 'viewport' &&
|
||||
box &&
|
||||
(box.east < box.west ||
|
||||
box.east - box.west > 10 ||
|
||||
box.north - box.south > 10)
|
||||
? [camera.positionWC, camera.directionWC]
|
||||
.map((v) =>
|
||||
v ? [v.x, v.y, v.z].map((n) => n.toFixed(4)).join(',') : '',
|
||||
)
|
||||
.join(':')
|
||||
: '';
|
||||
const key = `${coverage.kind}:${box ? [box.south, box.west, box.north, box.east].map((v) => v.toFixed(6)).join(',') : 'wide'}:${widePose}`;
|
||||
if (layerState.cameraLoadKey === key && !layerState.error) return;
|
||||
layerState.cameraLoadKey = key;
|
||||
layerState.cameraLoadOwner = {};
|
||||
layerState.abort?.abort();
|
||||
// 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(() => {
|
||||
layerState.timer = null;
|
||||
parts.ingestion.loadInstallations();
|
||||
}, REQUEST_DEBOUNCE_MS);
|
||||
}
|
||||
return {
|
||||
loadArea,
|
||||
viewportBox,
|
||||
installationRetryDelayMs,
|
||||
scheduleUnavailableRetry,
|
||||
|
||||
Reference in new issue
Block a user