Files
gods-eye-view/src/layers/installations/source.js
T
Sameh Khamis 7ddee426e7 fix: count every mapped installation in tool answers
The installation source keeps one named site per display cell for the map.
Queries now ask for every site and the exact box, and report when the
source returned only part of the mapped sites.
2026-10-02 15:21:36 -07:00

281 lines
9.2 KiB
JavaScript

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. */
export function installationResponseSaturated(payload) {
if (typeof payload?.saturated === 'boolean') return payload.saturated;
const cap = Number(payload?.elementCap);
if (!Number.isFinite(cap) || cap <= 0) return false;
return Array.isArray(payload?.elements) && payload.elements.length >= cap;
}
/**
* 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, thinned = true) {
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();
// The map keeps one site per display cell; queries can ask for all.
const { records, count } = militaryNamesInView(
names,
box,
thinned ? undefined : Infinity,
thinned,
);
return {
records,
namedInView: count,
wide: true,
status: 'ready',
tileSource: true,
saturated: false,
droppedCount: 0,
source: 'OpenStreetMap',
};
}
async function getTileSites(box, signal, thinned) {
const zoom = installationTileZoom(box);
if (zoom === null) return getNamedSites(box, signal, thinned);
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, thinned = true, signal } = {}) {
const { south, west, north, east } = box || {};
if (
![south, west, north, east].every(Number.isFinite) ||
south < -90 ||
north > 90 ||
west < -180 ||
west > 180 ||
east < -180 ||
east > 180 ||
north <= south ||
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, thinned);
if (overpassUnavailable) return getTileSites(box, signal, thinned);
const query = new URLSearchParams(
Object.entries({ south, west, north, east }).map(([key, value]) => [
key,
value.toFixed(5),
]),
);
if (exact) query.set('exact', '1');
const response = await fetchImpl(`/api/military-installations?${query}`, {
signal,
});
const body = await response.json();
signal?.throwIfAborted();
if (isUnavailableCapability(body)) {
overpassUnavailable = true;
return getTileSites(box, signal, thinned);
}
if (!response.ok)
throw Object.assign(
sourceResponseError(
body,
response,
'Installation context unavailable',
),
{
failureReason: ['rate_limited', 'timeout', 'query_failed'].includes(
body?.reason,
)
? body.reason
: 'unavailable',
},
);
if (!Array.isArray(body?.elements))
throw new Error('Malformed installation snapshot');
return {
...normalizeMilitaryInstallations(
body,
body.retrievedAt || new Date().toISOString(),
),
status: body.status,
saturated: installationResponseSaturated(body),
};
},
async searchNearby({ latitude, longitude, radiusM }, { signal } = {}) {
if (
![latitude, longitude, radiusM].every(Number.isFinite) ||
Math.abs(latitude) > 90 ||
Math.abs(longitude) > 180 ||
radiusM < 1000 ||
radiusM > 50000
)
throw new TypeError('Invalid nearby installation search');
signal?.throwIfAborted();
const response = await fetchImpl(
`/api/google/text-search?${new URLSearchParams({
q: 'military installation',
lat: latitude.toFixed(5),
lon: longitude.toFixed(5),
radiusM: String(radiusM),
})}`,
{ signal },
);
const payload = await response.json();
signal?.throwIfAborted();
if (!response.ok)
throw new Error(
payload?.error || `Google Places HTTP ${response.status}`,
);
if (!Array.isArray(payload?.places))
throw new Error('Malformed nearby-place snapshot');
return payload;
},
};
}