mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-04 10:24:02 +00:00
Merge branch 'main' into security/cross-site-gate-overpass-military
This commit is contained in:
commit
31013d2201
950 files changed
+74485
-22110
No files matched your search
@@ -28,6 +28,7 @@ import {
|
||||
loadNycSourcesFromOpenData,
|
||||
loadTorontoSourcesFromOpenData,
|
||||
loadFloridaSourcesFromOpenData,
|
||||
loadA22SourcesFromOpenDataHub,
|
||||
} from './sources.js';
|
||||
|
||||
/** Env kill switch: unset or anything but "0" means enabled. */
|
||||
@@ -165,6 +166,11 @@ const LIVE_PACKS = [
|
||||
enabled: () => envEnabled('CCTV_TAIWAN_ENABLED'),
|
||||
load: loadTaiwanSources,
|
||||
},
|
||||
{
|
||||
name: 'a22',
|
||||
enabled: () => envEnabled('CCTV_A22_ENABLED'),
|
||||
load: loadA22SourcesFromOpenDataHub,
|
||||
},
|
||||
];
|
||||
/**
|
||||
* Load CCTV sources from a local JSON file (CCTV_SOURCES_FILE env or default).
|
||||
|
||||
@@ -443,6 +443,39 @@ export const NYC_ANCHORS = [
|
||||
{ lat: 40.5795, lon: -74.1502 }, // Staten Island
|
||||
];
|
||||
|
||||
/** Autostrada del Brennero (A22, Brenner–Modena) webcams: the keyless Open
|
||||
* Data Hub (NOI Techpark, South Tyrol) tourism API republishes Autobrennero's
|
||||
* own camera list with coordinates, one request for the whole motorway
|
||||
* (13 cameras). Frames are stills on the operator's host, refreshed about
|
||||
* once a minute. */
|
||||
export const DEFAULT_A22_CCTV_URL =
|
||||
'https://tourism.api.opendatahub.com/v1/WebcamInfo?source=a22&pagesize=200&removenullvalues=true';
|
||||
/** The only origin A22 camera frames may come from: `km<N>.jpg` per camera. */
|
||||
export const A22_IMAGE_ORIGIN = 'https://www.autobrennero.it/WebCamImg/';
|
||||
/** Hard ceiling on the catalog body. The whole motorway is ~45 KB. */
|
||||
export const A22_MAX_CATALOG_BYTES = 2 * 1024 * 1024;
|
||||
/** Orthometric ground elevation (m) at each camera, keyed by its motorway
|
||||
* kilometre, from the Copernicus GLO-90 DEM via Open-Meteo's elevation API.
|
||||
* The feed's coordinates are 2D and the road climbs from the Po valley (~20 m)
|
||||
* to the Brenner Pass (~1,370 m), so one flat prior would bury the Alpine
|
||||
* cameras. A camera not listed here uses A22_DEFAULT_GROUND_ELEVATION_M. */
|
||||
export const A22_GROUND_ELEVATION_BY_KM = Object.freeze({
|
||||
1: 1365,
|
||||
68: 375,
|
||||
77: 301,
|
||||
100: 219,
|
||||
129: 206,
|
||||
138: 204,
|
||||
159: 175,
|
||||
205: 188,
|
||||
228: 72,
|
||||
240: 38,
|
||||
272: 16,
|
||||
285: 19,
|
||||
313: 40,
|
||||
});
|
||||
export const A22_DEFAULT_GROUND_ELEVATION_M = 200;
|
||||
|
||||
/** DelDOT CCTV: one keyless statewide JSON catalog; live video via RTMP-over-HTTP (rtmpt:80). */
|
||||
export const DELDOT_CCTV_URL = 'https://tmc.deldot.gov/json/videocamera.json';
|
||||
export const DEFAULT_DELDOT_MAX_SOURCES = 300;
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { Readable } from 'node:stream';
|
||||
import { hashSeed, escapeXml, pinnedCameraUrl } from './normalize.js';
|
||||
import {
|
||||
A22_IMAGE_ORIGIN,
|
||||
CALGARY_IMAGE_ORIGIN,
|
||||
CCTV_FRAME_FETCH_TIMEOUT_MS,
|
||||
CCTV_FRAME_MAX_BODY_BYTES,
|
||||
@@ -528,6 +529,7 @@ for (const prefix of [
|
||||
HK_IMAGE_ORIGIN,
|
||||
KING_COUNTY_IMAGE_ORIGIN,
|
||||
WSDOT_IMAGE_ORIGIN,
|
||||
A22_IMAGE_ORIGIN,
|
||||
]) {
|
||||
const host = new URL(prefix).hostname;
|
||||
CCTV_PINNED_PREFIXES_BY_HOST.set(host, [
|
||||
|
||||
@@ -421,6 +421,17 @@ export function isLikelyNorwayCoordinate(lat, lon) {
|
||||
);
|
||||
}
|
||||
|
||||
/** The A22 corridor, Brenner Pass to Modena, with slack either side. */
|
||||
export function isLikelyA22Coordinate(lat, lon) {
|
||||
return (
|
||||
isPlausibleLatLon(lat, lon) &&
|
||||
lat >= 44.5 &&
|
||||
lat <= 47.1 &&
|
||||
lon >= 10.5 &&
|
||||
lon <= 11.8
|
||||
);
|
||||
}
|
||||
|
||||
export function isLikelyFinlandCoordinate(lat, lon) {
|
||||
if (!Number.isFinite(lat) || !Number.isFinite(lon)) return false;
|
||||
return lat >= 59.5 && lat <= 70.5 && lon >= 19 && lon <= 32;
|
||||
|
||||
@@ -122,6 +122,11 @@ import {
|
||||
NYC_MAX_CATALOG_BYTES,
|
||||
NYC_GROUND_ELEVATION_M,
|
||||
NYC_ANCHORS,
|
||||
DEFAULT_A22_CCTV_URL,
|
||||
A22_IMAGE_ORIGIN,
|
||||
A22_MAX_CATALOG_BYTES,
|
||||
A22_GROUND_ELEVATION_BY_KM,
|
||||
A22_DEFAULT_GROUND_ELEVATION_M,
|
||||
} from './constants.js';
|
||||
import {
|
||||
toFiniteNumber,
|
||||
@@ -145,6 +150,7 @@ import {
|
||||
isLikelyHongKongCoordinate,
|
||||
isLikelyNorwayCoordinate,
|
||||
isLikelyTorontoCoordinate,
|
||||
isLikelyA22Coordinate,
|
||||
cameraDisplayCode,
|
||||
rowArrayToObject,
|
||||
prioritizeSources,
|
||||
@@ -3645,3 +3651,123 @@ export async function loadFloridaSourcesFromOpenData() {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* One Open Data Hub `WebcamInfo` item (source `a22`) -> one catalog source,
|
||||
* or null.
|
||||
*
|
||||
* The frame must be the operator's own `WebCamImg/km<N>.jpg` for the
|
||||
* camera's motorway kilometre. The feed names the place and the kilometre but
|
||||
* no bearing, so the heading uses the shared id-hash fallback at low
|
||||
* confidence, like Calgary and Vegvesen; the ground elevation comes from the
|
||||
* per-kilometre DEM table because the road spans ~1,350 m of altitude.
|
||||
*
|
||||
* @param {object} item - One element of the API's `Items` array.
|
||||
* @returns {?object}
|
||||
*/
|
||||
export function a22WebcamToSource(item) {
|
||||
if (!item || typeof item !== 'object') return null;
|
||||
if (item.Active === false) return null;
|
||||
if (String(item.Source ?? '').toLowerCase() !== 'a22') return null;
|
||||
|
||||
const gps = Array.isArray(item.GpsInfo) ? item.GpsInfo[0] : null;
|
||||
const lat = toFiniteNumber(gps?.Latitude);
|
||||
const lon = toFiniteNumber(gps?.Longitude);
|
||||
if (!isLikelyA22Coordinate(lat, lon)) return null;
|
||||
|
||||
const km = String(item.Mapping?.a22?.km ?? '').trim();
|
||||
if (!/^\d{1,3}$/.test(km)) return null;
|
||||
const imageUrl = `${A22_IMAGE_ORIGIN}km${Number(km)}.jpg`;
|
||||
let image;
|
||||
try {
|
||||
image = new URL(String(item.Webcamurl ?? ''));
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
if (image.username || image.password || image.href !== imageUrl) return null;
|
||||
|
||||
const place =
|
||||
String(item.Shortname ?? item.Webcamname?.it ?? '')
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim() || `Km ${Number(km)}`;
|
||||
const cameraId = `it-a22-km${Number(km)}`;
|
||||
const ground = A22_GROUND_ELEVATION_BY_KM[Number(km)];
|
||||
|
||||
return {
|
||||
id: cameraId,
|
||||
name: `A22 ${place} (km ${Number(km)})`,
|
||||
// One motorway-wide category in the camera picker; the place is already
|
||||
// in the name.
|
||||
city: 'A22 Brennero',
|
||||
cityId: 'a22',
|
||||
provider: 'Autostrada del Brennero',
|
||||
lat,
|
||||
lon,
|
||||
headingDeg: fallbackHeadingFromId(cameraId),
|
||||
headingConfidence: 'low',
|
||||
pitchDeg: -15,
|
||||
fovDeg: 50,
|
||||
rangeM: 160,
|
||||
mountHeightM: 8,
|
||||
groundElevationM: Number.isFinite(ground)
|
||||
? ground
|
||||
: A22_DEFAULT_GROUND_ELEVATION_M,
|
||||
feedType: 'image',
|
||||
url: imageUrl,
|
||||
snapshotUrl: imageUrl,
|
||||
sourceKind: 'a22-opendatahub',
|
||||
license:
|
||||
'Webcam images © Autostrada del Brennero S.p.A., via the Open Data Hub (NOI Techpark)',
|
||||
code: cameraDisplayCode(place.toUpperCase()),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch the Autostrada del Brennero (A22) webcams from the keyless Open Data
|
||||
* Hub tourism API. Frames are stills on www.autobrennero.it.
|
||||
*
|
||||
* @returns {Promise<Array<object>>} Normalized camera source objects.
|
||||
*/
|
||||
export async function loadA22SourcesFromOpenDataHub() {
|
||||
try {
|
||||
const endpoint = process.env.CCTV_A22_URL || DEFAULT_A22_CCTV_URL;
|
||||
const resp = await fetch(endpoint, {
|
||||
headers: { Accept: 'application/json' },
|
||||
redirect: 'manual',
|
||||
signal: AbortSignal.timeout(CCTV_SOURCE_FETCH_TIMEOUT_MS),
|
||||
});
|
||||
const discard = async () => {
|
||||
try {
|
||||
await resp.body?.cancel();
|
||||
} catch {
|
||||
/* no-op */
|
||||
}
|
||||
return [];
|
||||
};
|
||||
if (resp.status >= 300 && resp.status < 400) {
|
||||
console.warn('[CCTV] A22 catalog redirected; redirects are not followed');
|
||||
return discard();
|
||||
}
|
||||
if (!resp.ok) {
|
||||
console.warn('[CCTV] A22 camera download failed:', resp.status);
|
||||
return discard();
|
||||
}
|
||||
const payload = await readResponseJsonCapped(resp, A22_MAX_CATALOG_BYTES);
|
||||
const items = Array.isArray(payload?.Items) ? payload.Items : [];
|
||||
const cameras = [];
|
||||
const seen = new Set();
|
||||
for (const item of items) {
|
||||
const camera = a22WebcamToSource(item);
|
||||
if (!camera || seen.has(camera.id)) continue;
|
||||
seen.add(camera.id);
|
||||
cameras.push(camera);
|
||||
}
|
||||
// North to south along the motorway, so the picker reads in road order.
|
||||
cameras.sort((a, b) => b.lat - a.lat);
|
||||
console.log(`[CCTV] Loaded A22 camera sources: ${cameras.length}`);
|
||||
return cameras;
|
||||
} catch (error) {
|
||||
console.warn('[CCTV] A22 camera download error:', error?.message || error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
@@ -39,6 +39,8 @@ const API_URL =
|
||||
// The service caps a single response at its maxRecordCount (2000); a peak
|
||||
// season can exceed that, so follow exceededTransferLimit with offset pages.
|
||||
const MAX_PAGES = 5;
|
||||
/** Longest a failed refresh keeps serving the last perimeter snapshot, marked stale. */
|
||||
const STALE_MAX_MS = 24 * 60 * 60 * 1000;
|
||||
|
||||
const exceededTransferLimit = (payload) =>
|
||||
payload?.exceededTransferLimit === true ||
|
||||
@@ -165,7 +167,14 @@ export function firePerimetersProxy({
|
||||
});
|
||||
return { value: await promise, stale: false };
|
||||
} catch (error) {
|
||||
if (previous) return { value: previous.value, stale: true };
|
||||
// A failed refresh serves the last answer, marked stale. A perimeter
|
||||
// snapshot only for a day: older than that, it is no picture of
|
||||
// current fires. (InciWeb entries keep their previous behaviour.)
|
||||
if (
|
||||
previous &&
|
||||
(key !== 'perimeters' || now() - previous.savedAt <= STALE_MAX_MS)
|
||||
)
|
||||
return { value: previous.value, stale: true };
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
+138
-6
@@ -1,9 +1,132 @@
|
||||
import path from 'node:path';
|
||||
import https from 'node:https';
|
||||
import { promises as fsp } from 'node:fs';
|
||||
import { Readable } from 'node:stream';
|
||||
|
||||
import { filterTrailing24h, parseFirmsCsv } from '../../src/data/firmsCsv.js';
|
||||
import { readResponseTextCapped } from './common/http.js';
|
||||
|
||||
const NULL_BODY_STATUSES = new Set([204, 205, 304]);
|
||||
|
||||
/**
|
||||
* The per-address connection errors behind a fetch failure, or null. Node 24
|
||||
* puts the AggregateError directly on `err.cause`; Node 26's fetch wraps it in
|
||||
* a ConnectTimeoutError one level deeper.
|
||||
* @param {unknown} err
|
||||
* @returns {?Array<unknown>}
|
||||
*/
|
||||
function connectAttemptErrors(err) {
|
||||
let cause = err?.cause;
|
||||
for (let depth = 0; cause && depth < 3; depth++) {
|
||||
if (Array.isArray(cause.errors)) return cause.errors;
|
||||
cause = cause.cause;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Describe a fetch failure with its socket-level cause. Node reports every
|
||||
* connection failure as `fetch failed`; the cause carries the error code and
|
||||
* the address that failed (never the URL, which embeds the MAP_KEY).
|
||||
* @param {unknown} err
|
||||
* @returns {string}
|
||||
*/
|
||||
export function describeFetchError(err) {
|
||||
const message = err?.message || String(err);
|
||||
const cause = err?.cause;
|
||||
if (!cause) return message;
|
||||
const attempts = connectAttemptErrors(err);
|
||||
const parts = attempts?.length
|
||||
? attempts.map((inner) => inner?.message || inner?.code || String(inner))
|
||||
: [cause.message || cause.code || String(cause)];
|
||||
return `${message} (${parts.join('; ')})`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a fetch failed because every address it tried failed to connect.
|
||||
* Node's network-family auto-selection gives each address a short attempt
|
||||
* timeout, so a slow IPv4 handshake is abandoned in favour of an unreachable
|
||||
* IPv6 address and the whole request fails (#68).
|
||||
* @param {unknown} err
|
||||
* @returns {boolean}
|
||||
*/
|
||||
export function isAddressSelectionFailure(err) {
|
||||
return err instanceof TypeError && connectAttemptErrors(err)?.length > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* GET over IPv4 only, with one connection attempt bounded by the caller's
|
||||
* signal rather than by the auto-selection attempt timeout.
|
||||
* @param {string} url
|
||||
* @param {{signal?: AbortSignal}} [options]
|
||||
* @returns {Promise<Response>}
|
||||
*/
|
||||
export function fetchIpv4(url, { signal } = {}) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const request = https.request(
|
||||
url,
|
||||
{
|
||||
method: 'GET',
|
||||
signal,
|
||||
family: 4,
|
||||
autoSelectFamily: false,
|
||||
agent: false,
|
||||
},
|
||||
(response) => {
|
||||
// Nothing thrown here may escape: this callback runs outside the
|
||||
// promise, so an exception would be uncaught and crash the server.
|
||||
try {
|
||||
const status = response.statusCode || 500;
|
||||
// A `Response` only represents statuses up to 599; Node never hands
|
||||
// this callback a 1xx.
|
||||
if (status > 599)
|
||||
throw new Error(`FIRMS returned unsupported status ${status}`);
|
||||
const headers = new Headers();
|
||||
for (const [name, value] of Object.entries(response.headers)) {
|
||||
if (Array.isArray(value))
|
||||
value.forEach((item) => headers.append(name, item));
|
||||
else if (value !== undefined) headers.set(name, String(value));
|
||||
}
|
||||
const nullBody = NULL_BODY_STATUSES.has(status);
|
||||
const upstream = new Response(
|
||||
nullBody ? null : Readable.toWeb(response),
|
||||
{ status, statusText: response.statusMessage || '', headers },
|
||||
);
|
||||
if (nullBody) response.resume();
|
||||
resolve(upstream);
|
||||
} catch (error) {
|
||||
response.destroy();
|
||||
reject(error);
|
||||
}
|
||||
},
|
||||
);
|
||||
request.on('error', reject);
|
||||
request.end();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch a FIRMS URL, retrying once over IPv4 when address selection fails.
|
||||
* Other failures (HTTP status, timeout, DNS) are returned or thrown as is.
|
||||
* @param {string} url
|
||||
* @param {{signal?: AbortSignal}} options
|
||||
* @param {{fetchImpl?: typeof fetch, fetchIpv4Impl?: typeof fetchIpv4, warn?: Function}} [deps]
|
||||
* @returns {Promise<Response>}
|
||||
*/
|
||||
export async function fetchFirms(
|
||||
url,
|
||||
options,
|
||||
{ fetchImpl = fetch, fetchIpv4Impl = fetchIpv4, warn = console.warn } = {},
|
||||
) {
|
||||
try {
|
||||
return await fetchImpl(url, options);
|
||||
} catch (err) {
|
||||
if (!isAddressSelectionFailure(err) || options?.signal?.aborted) throw err;
|
||||
warn(`[firms-proxy] ${describeFetchError(err)} — retrying over IPv4`);
|
||||
return fetchIpv4Impl(url, options);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* NASA FIRMS live active-fire proxy with a memory + disk cache.
|
||||
* Upstream: https://firms.modaps.eosdis.nasa.gov/api/area/csv/{KEY}/{SOURCE}/world/2
|
||||
@@ -91,8 +214,12 @@ export function firmsProxy() {
|
||||
async function fetchSource(key, source) {
|
||||
const url = `https://firms.modaps.eosdis.nasa.gov/api/area/csv/${encodeURIComponent(key)}/${source}/world/2`;
|
||||
const signal = AbortSignal.timeout(60_000);
|
||||
const res = await fetch(url, { signal });
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
const res = await fetchFirms(url, { signal });
|
||||
if (!res.ok) {
|
||||
// Release the connection now rather than when the deadline fires.
|
||||
await res.body?.cancel().catch(() => {});
|
||||
throw new Error(`HTTP ${res.status}`);
|
||||
}
|
||||
const records = parseFirmsCsv(
|
||||
await readResponseTextCapped(res, FIRMS_MAX_RESPONSE_BYTES, signal),
|
||||
);
|
||||
@@ -121,7 +248,7 @@ export function firmsProxy() {
|
||||
} catch (err) {
|
||||
console.warn(
|
||||
`[firms-proxy] ${source} fetch failed:`,
|
||||
err?.message || err,
|
||||
describeFetchError(err),
|
||||
);
|
||||
sources.push({ source, count: 0, ok: false });
|
||||
}
|
||||
@@ -156,8 +283,13 @@ export function firmsProxy() {
|
||||
statusInflight = (async () => {
|
||||
try {
|
||||
const url = `https://firms.modaps.eosdis.nasa.gov/mapserver/mapkey_status/?MAP_KEY=${encodeURIComponent(key)}`;
|
||||
const res = await fetch(url, { signal: AbortSignal.timeout(10_000) });
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
const res = await fetchFirms(url, {
|
||||
signal: AbortSignal.timeout(10_000),
|
||||
});
|
||||
if (!res.ok) {
|
||||
await res.body?.cancel().catch(() => {});
|
||||
throw new Error(`HTTP ${res.status}`);
|
||||
}
|
||||
const body = await res.json();
|
||||
const used = Number(body?.current_transactions);
|
||||
const limit = Number(body?.transaction_limit);
|
||||
@@ -167,7 +299,7 @@ export function firmsProxy() {
|
||||
} catch (err) {
|
||||
console.warn(
|
||||
'[firms-proxy] mapkey status failed:',
|
||||
err?.message || err,
|
||||
describeFetchError(err),
|
||||
);
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -29,6 +29,19 @@ function toFiveWordHudSummary(value) {
|
||||
}
|
||||
|
||||
async function handleHudSummary(req, res) {
|
||||
// Capability probe: lets the HUD skip its paid place lookups when there is
|
||||
// no summary to feed them to.
|
||||
if (req.method === 'GET' && req.url?.split('?')[0] === '/status') {
|
||||
res.statusCode = 200;
|
||||
res.setHeader('Content-Type', 'application/json; charset=utf-8');
|
||||
res.setHeader('Cache-Control', 'no-store');
|
||||
res.end(
|
||||
JSON.stringify({
|
||||
configured: !keylessHudSummaryResponse(process.env.OPENAI_API_KEY),
|
||||
}),
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (req.method !== 'POST') {
|
||||
res.statusCode = 405;
|
||||
res.setHeader('Content-Type', 'application/json');
|
||||
|
||||
@@ -46,15 +46,15 @@ function realtimeInstructions(
|
||||
// string is the whole rollback.
|
||||
'NAMED VIEWS are shorthand for tool calls you already have — there is no "mode" tool for them. Treat ONLY these as the shorthand: "infrastructure mode" / "the infrastructure view" / "show me global infrastructure" means three set_layer_visibility calls (local-datacenters, local-dams, telegeography-submarine-cables) plus zoom_to_globe; "environmental mode" / "earth watch" / "active events", said as the name of a view, means set_layer_visibility for local-firms and earthquakes plus zoom_to_globe. Anything vaguer is NOT this shorthand — an open-ended question about the world or the news is an ordinary question: answer it, or use analyst_query over the layers already on. Never switch a whole view on to answer a question nobody asked to see. When you do run one, make every call before speaking, then give one confirmation naming the resulting state; if the fires layer comes back unavailable because no FIRMS key is configured, say so plainly — the earthquakes still loaded. "Live contacts" and "space missions" are NOT this pattern: they stay set_context_mode{mode:"contacts"} and set_context_mode{mode:"space-missions"}.',
|
||||
'For visual filter requests, call set_visual_style with one of the allowed style IDs.',
|
||||
'SHIPS vs STYLES: "vessels" is easily heard as "visuals". "Show me vessels", "turn on vessels", "the vessels", "visuals" or "vestibules" said WITHOUT a style name means the ships layer: set_layer_visibility{layerId:"ais-live-vessels"}, exactly as "ships" or "boats" does. Call set_visual_style only when the user names a style (retro/CRT, surveillance/night vision, thermal, anime, noir, snow, normal) or says filter, effect or look. Never turn on a different data layer (such as fires) for a ships request.',
|
||||
'SHIPS vs STYLES: "vessels" is easily heard as "visuals". "Show me vessels", "turn on vessels", "the vessels", "visuals" or "vestibules" said WITHOUT a style name means the ships layer: set_layer_visibility{layerId:"ais-live-vessels"}, exactly as "ships" or "boats" does. Call set_visual_style only when the user names a style (retro/CRT, surveillance/night vision, thermal, anime, noir, normal) or says filter, effect or look. Never turn on a different data layer (such as fires) for a ships request.',
|
||||
'Disambiguation table — basemap vs layer vs style: basemap switching requires an explicit stack name — "Bing aerial" means set_map_stack bing-aerial, "aerial with labels" means bing-labels, "OSM"/"road map" means osm, "Esri"/"Esri imagery" means esri-imagery, "Google 3D"/"photorealistic" means photoreal. Any other mention of "satellite" or "satellites" means the satellites DATA LAYER via set_layer_visibility, never a basemap — except "recent/latest satellite imagery" (the recent-imagery layer) and "weather satellite"/clouds (weather-satellite). "surveillance"/"night vision"/"thermal" are visual STYLES via set_visual_style.',
|
||||
'HUD requests ("hud on/off", "switch to operator/minimal/tactical/cyber layout") use set_hud. Detection requests ("detection on", "dense mode", "balanced mode", "sparse mode", "set density to 25", "use weighted allocation") use set_detection. Density snaps to 0/25/50/75/100 and derives Sparse/Balanced/Dense; panoptic is a legacy alias for Dense.',
|
||||
'HUD requests ("hud on/off", "switch to minimal/tactical/cyber layout") use set_hud. Detection requests ("detection on", "dense mode", "balanced mode", "sparse mode", "set density to 25", "use weighted allocation") use set_detection. Density snaps to 0/25/50/75/100 and derives Sparse/Balanced/Dense; panoptic is a legacy alias for Dense.',
|
||||
'Cyber sonar requests use set_cyber_sonar: enabled, rings, rangePct, intensityPct (Power), opacityPct, sectorDeg. For settings questions read get_current_view_state.controls.hud.sonar. Sonar is not detection. Do not silently switch themes, reveal the HUD or enable sonar when changing a slider. If the user explicitly requests Cyber plus sonar settings, call set_hud first, wait for success, then set_cyber_sonar. Confirm returned values; mapSweepActive and contactSweepActive distinguish saved settings from active map effects, including in Cockpit or reduced motion. Do not claim visible contacts solely because enabled is true.',
|
||||
'Bloom/sharpen requests use set_post_processing. Scene requests ("play orbital watch", "stop the scene", "what scenes are there") use control_scene. CCTV camera requests ("next camera", "nearest camera", "select the Congress camera", "show coverage") use control_cctv — the CCTV layer must be enabled first.',
|
||||
'Radio playback requests use control_radio. "Turn on/start the radio" means action=play; action=enable only reveals Radio markers and must be reserved for explicit "show/enable the Radio layer/markers" requests. After a prepared playback result, briefly confirm any other completed actions and say "Turning on the radio"—never claim it is already playing. The client keeps Radio muted until playback is verified, then closes voice before restoring Radio volume. Examples: "play news near Austin" → select category=news locationId=austin; "play US news" → select category=news country=US; "Radio volume 30" → volume; pause/resume/stop/next/previous use the matching action. Radio selection never moves the camera.',
|
||||
'"Track/follow <something specific>" (a callsign, ship name, satellite name) uses track_entity. "Take me to the biggest fire" uses track_entity with query "biggest fire" (the fires layer must be enabled). Bare "orbit" means camera orbit of the current landmark. "Stop following/tracking" uses stop_tracking.',
|
||||
'"Show me which planes are overhead"/"frame the ships"/"show me the satellites above" use frame_overhead with the matching target.',
|
||||
'Confirmations echo the RESULTING state, never the request: "HUD operator layout", "Density twenty-five percent", "Bing aerial imagery", "Tracking UAL428", "Framed fourteen aircraft". On ok=false, state the failure plainly: "Nothing matched UAL999", "No ships within 120 kilometers". Never claim an action without ok=true in the tool result.',
|
||||
'Confirmations echo the RESULTING state, never the request: "HUD minimal layout", "Density twenty-five percent", "Bing aerial imagery", "Tracking UAL428", "Framed fourteen aircraft". On ok=false, state the failure plainly: "Nothing matched UAL999", "No ships within 120 kilometers". Never claim an action without ok=true in the tool result.',
|
||||
'For destination requests such as "take me to Italy", "go to NYC", or "show me the Eiffel Tower", call fly_to_location. Prefer known city IDs when available; otherwise pass the plain place query.',
|
||||
'Navigation-only requests ("take me to X", "go to X", "fly to X") are NOT descriptions: call fly_to_location alone and do NOT also call annotate_map, unless the user explicitly asks to mark the place or you go on to explain specific places there. Never drop a point pin on a region-scale natural feature (a mountain range, desert, sea, or forest) — a single point in the middle of the Rockies is meaningless. If the user explicitly asks to mark such a region, prefer type=area.',
|
||||
'For country and city destinations, omit rangeM so GEV frames the whole country or city in view. For landmarks and buildings, omit rangeM so GEV chooses a close landmark view.',
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
parseTerrainPoints,
|
||||
resolveTerrainHeightRequest,
|
||||
terrainPointKey,
|
||||
terrainRetryAfterMs,
|
||||
validTerrainResult,
|
||||
} from '../../src/data/terrainHeightsProxy.js';
|
||||
import { makeRateLimiter, clientKey } from './common/rate-limit.js';
|
||||
@@ -55,6 +56,15 @@ export function terrainHeightsProxy(options = {}) {
|
||||
// 2026-08-21). 64 points costs 5.6-12 s, inside the timeout at both ends of
|
||||
// that range.
|
||||
const UPSTREAM_CHUNK = 64;
|
||||
// After a 429 the upstream is left alone for its Retry-After (at most
|
||||
// RATE_LIMIT_PAUSE_MAX_MS, so a far-off date can't stop terrain until a
|
||||
// restart), or RATE_LIMIT_PAUSE_MS when it gives none. Every page poll for
|
||||
// new points (about once a second while the camera moves) otherwise asks
|
||||
// again with its own retries, which keeps the limit in force and logs each
|
||||
// time.
|
||||
const RATE_LIMIT_PAUSE_MS = 60_000;
|
||||
const RATE_LIMIT_PAUSE_MAX_MS = 10 * 60_000;
|
||||
let rateLimitedUntil = 0;
|
||||
const MAX_POINTS = UPSTREAM_CHUNK;
|
||||
/**
|
||||
* Entry ceiling for the point cache, and with it the file written from it.
|
||||
@@ -199,7 +209,8 @@ export function terrainHeightsProxy(options = {}) {
|
||||
*
|
||||
* The first try keeps its empirically required 30s timeout; network errors,
|
||||
* 429, and 5xx receive up to three jittered retries sharing a 10s added-time
|
||||
* budget, with Retry-After honored within that bound.
|
||||
* budget, with Retry-After honored within that bound; a longer Retry-After
|
||||
* ends the retries at once so the 429 pause below starts without delay.
|
||||
*
|
||||
* A chunk that still fails yields nulls for its own positions rather than
|
||||
* rejecting: with several chunks per request, throwing would discard every
|
||||
@@ -211,15 +222,37 @@ export function terrainHeightsProxy(options = {}) {
|
||||
* @returns {Promise<Array<object>>}
|
||||
*/
|
||||
async function fetchUpstreamAll(points) {
|
||||
if (Date.now() < rateLimitedUntil) {
|
||||
// Rate-limited: asking again only extends the limit. Cached points are
|
||||
// still served; the rest resolve on the page through its geoid.
|
||||
const error = new Error('HTTP 429');
|
||||
error.status = 429;
|
||||
error.coolingDown = true;
|
||||
throw error;
|
||||
}
|
||||
const results = [];
|
||||
let firstError = null;
|
||||
for (let i = 0; i < points.length; i += UPSTREAM_CHUNK) {
|
||||
const chunk = points.slice(i, i + UPSTREAM_CHUNK);
|
||||
let chunkResults = [];
|
||||
try {
|
||||
chunkResults = await fetchTerrainChunkWithRetry(chunk);
|
||||
} catch (error) {
|
||||
firstError = firstError || error;
|
||||
// Re-checked per chunk for when a request spans several chunks again;
|
||||
// today requests are capped at one chunk (MAX_POINTS).
|
||||
if (Date.now() >= rateLimitedUntil) {
|
||||
try {
|
||||
chunkResults = await fetchTerrainChunkWithRetry(chunk);
|
||||
} catch (error) {
|
||||
firstError = firstError || error;
|
||||
// Concurrent 429s extend the pause, never cut it short.
|
||||
if (error?.status === 429)
|
||||
rateLimitedUntil = Math.max(
|
||||
rateLimitedUntil,
|
||||
Date.now() +
|
||||
Math.min(
|
||||
terrainRetryAfterMs(error.retryAfter) ?? RATE_LIMIT_PAUSE_MS,
|
||||
RATE_LIMIT_PAUSE_MAX_MS,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
// Keep later chunks aligned even if a malformed upstream response omits
|
||||
// trailing positions. The resolver will reject each null individually.
|
||||
@@ -292,9 +325,17 @@ export function terrainHeightsProxy(options = {}) {
|
||||
evictOverflow();
|
||||
diskDirty = true;
|
||||
}
|
||||
if (outcome.upstreamError) {
|
||||
if (outcome.upstreamError?.coolingDown) {
|
||||
// Logged once, when the pause began.
|
||||
} else if (outcome.upstreamError) {
|
||||
const pause =
|
||||
outcome.upstreamError.status === 429 &&
|
||||
Date.now() < rateLimitedUntil
|
||||
? ` (HTTP 429; pausing upstream for ${Math.ceil((rateLimitedUntil - Date.now()) / 1000)} s)`
|
||||
: '';
|
||||
console.warn(
|
||||
'[terrain-heights-proxy] refresh incomplete' +
|
||||
pause +
|
||||
' — serving stale points when available',
|
||||
);
|
||||
} else if (outcome.absentPoints > 0) {
|
||||
|
||||
Reference in new issue
Block a user