mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-04 10:24:02 +00:00
merge main f13c60c into feat/voice-outlines
reconcile voice outlines with main f13c60c, retaining German/Japanese,
Operator HUD retirement and Context/Radio cleanup, named-building
containment, courtyard-hole, non-Latin identity, attribution, cancellation
and antimeridian repairs
keep outline callouts above the open voice preview when an above-panel
slot fits without colliding with another callout,
and clip the compact dock location label on narrow screens
This commit is contained in:
commit
578ebdc030
449 files changed
+30400
-3429
No files matched your search
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,11 +6,11 @@ import { readResponseJsonCapped } from '../common/http.js';
|
||||
/**
|
||||
* One gate for every request this server sends to a Nominatim instance.
|
||||
*
|
||||
* Place search and voice outlines share it. Public requests reserve one start
|
||||
* at a time across processes sharing this installation's state file, remain at
|
||||
* least 1.1 s apart, use a short queue and daily ceiling, and pause after the
|
||||
* server says to stop. Independent installations do not share one app-wide
|
||||
* limiter; each must identify itself and enforce the public policy locally.
|
||||
* Place search and voice outlines share it. The service is off until an operator
|
||||
* configures an endpoint. Explicit public requests coordinate fetch invocation
|
||||
* across processes sharing this installation's state file, at least 1.1 s apart.
|
||||
* Independent installations do not share an app-wide limiter; these local guards
|
||||
* do not establish compliance for an application's combined public traffic.
|
||||
*/
|
||||
|
||||
/** The public instance. Only this host is subject to the public-use limits. */
|
||||
@@ -56,8 +56,8 @@ const DISABLED_VALUES = new Set(['', 'off', 'none', 'disabled', '0']);
|
||||
/**
|
||||
* Read the search endpoint setting.
|
||||
*
|
||||
* Unset means the public instance under its guards. Set to empty (or `off`)
|
||||
* disables every Nominatim request. Any other value must be an http(s) URL
|
||||
* Unset, empty or `off` disables every Nominatim request. An explicit endpoint
|
||||
* must be an http(s) URL
|
||||
* without credentials, query or fragment; a bare host gets `/search`. A value
|
||||
* that is not usable disables the service rather than falling back to the
|
||||
* public instance.
|
||||
@@ -68,8 +68,7 @@ const DISABLED_VALUES = new Set(['', 'off', 'none', 'disabled', '0']);
|
||||
export function resolveNominatimSettings(env = process.env) {
|
||||
const dailyCap = parseDailyCap(env.NOMINATIM_DAILY_CAP);
|
||||
const raw = env.NOMINATIM_URL;
|
||||
if (raw === undefined)
|
||||
return { endpoint: PUBLIC_NOMINATIM_SEARCH, isPublic: true, dailyCap };
|
||||
if (raw === undefined) return { endpoint: null, isPublic: false, dailyCap };
|
||||
const value = String(raw).trim();
|
||||
if (DISABLED_VALUES.has(value.toLowerCase()))
|
||||
return { endpoint: null, isPublic: false, dailyCap };
|
||||
@@ -255,6 +254,24 @@ export function createGateStateStore({
|
||||
};
|
||||
|
||||
const pauseMarkerPrefix = file ? `${path.basename(file)}.pause.` : null;
|
||||
const dispatchMarkerPrefix = file ? `${path.basename(file)}.dispatch.` : null;
|
||||
|
||||
// A crash or persistence failure during dispatch must never reopen admission
|
||||
// using an older timestamp. Only this dispatch removes its own marker after
|
||||
// persisting the post-invocation deadline; there is no timed stale reclaim.
|
||||
function pendingDispatches({ strict = false } = {}) {
|
||||
if (!file) return [];
|
||||
try {
|
||||
return fs
|
||||
.readdirSync(path.dirname(file))
|
||||
.filter((name) => name.startsWith(dispatchMarkerPrefix));
|
||||
} catch (error) {
|
||||
if (error?.code === 'ENOENT') return [];
|
||||
report(error);
|
||||
if (strict) throw error;
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A refusal must remain visible even while another process owns the SQLite
|
||||
@@ -483,6 +500,8 @@ export function createGateStateStore({
|
||||
try {
|
||||
let result;
|
||||
withLock(() => {
|
||||
if (pendingDispatches({ strict: true }).length)
|
||||
throw new Error('unfinished durable Nominatim dispatch');
|
||||
const disk = readDisk({ strict: true });
|
||||
if (disk) memory = merge(memory, disk);
|
||||
const pending = pendingPauses({ strict: true });
|
||||
@@ -527,6 +546,101 @@ export function createGateStateStore({
|
||||
return { status: 'unavailable', error };
|
||||
}
|
||||
},
|
||||
dispatch(
|
||||
day,
|
||||
{ clock, dailyCap, minSpacingMs, latestStartAt = Infinity },
|
||||
start,
|
||||
) {
|
||||
if (!file) {
|
||||
const error = new Error(
|
||||
'no durable Nominatim usage state is configured',
|
||||
);
|
||||
report(error);
|
||||
return { status: 'unavailable', error };
|
||||
}
|
||||
let value;
|
||||
try {
|
||||
let result;
|
||||
withLock(() => {
|
||||
if (pendingDispatches({ strict: true }).length)
|
||||
throw new Error('unfinished durable Nominatim dispatch');
|
||||
const disk = readDisk({ strict: true });
|
||||
if (disk) memory = merge(memory, disk);
|
||||
const pauses = pendingPauses({ strict: true });
|
||||
const state = merge(memory, {
|
||||
...memory,
|
||||
pausedUntil: Math.max(
|
||||
memory.pausedUntil,
|
||||
...pauses.map((p) => p.pausedUntil),
|
||||
),
|
||||
});
|
||||
const at = clock();
|
||||
if (at > latestStartAt) {
|
||||
result = { status: 'abandoned' };
|
||||
return;
|
||||
}
|
||||
if (state.pausedUntil > at) {
|
||||
result = { status: 'paused', retryAfterMs: state.pausedUntil - at };
|
||||
return;
|
||||
}
|
||||
const count = state.day === day ? state.count : 0;
|
||||
if (count >= dailyCap) {
|
||||
result = { status: 'cap' };
|
||||
return;
|
||||
}
|
||||
if (state.nextStartAt > at) {
|
||||
result =
|
||||
state.nextStartAt > latestStartAt
|
||||
? { status: 'abandoned' }
|
||||
: { status: 'wait', waitMs: state.nextStartAt - at };
|
||||
return;
|
||||
}
|
||||
const marker = `${file}.dispatch.${process.pid}.${process.hrtime.bigint()}`;
|
||||
fs.writeFileSync(marker, '', { flag: 'wx' });
|
||||
const next = {
|
||||
day,
|
||||
count: count + 1,
|
||||
pausedUntil: state.pausedUntil,
|
||||
nextStartAt: at + minSpacingMs,
|
||||
};
|
||||
writeDisk(next);
|
||||
memory = next;
|
||||
const latePause = Math.max(
|
||||
next.pausedUntil,
|
||||
...pendingPauses({ strict: true }).map((p) => p.pausedUntil),
|
||||
);
|
||||
if (clock() > latestStartAt) result = { status: 'abandoned' };
|
||||
else if (latePause > clock())
|
||||
result = { status: 'paused', retryAfterMs: latePause - clock() };
|
||||
else {
|
||||
// Invoke, but never await, the transport while still owning the lock.
|
||||
try {
|
||||
value = start();
|
||||
} catch (error) {
|
||||
value = Promise.reject(error);
|
||||
}
|
||||
result = { status: 'started', value };
|
||||
}
|
||||
const after = {
|
||||
...next,
|
||||
pausedUntil: latePause,
|
||||
nextStartAt: Math.max(next.nextStartAt, clock() + minSpacingMs),
|
||||
};
|
||||
writeDisk(after);
|
||||
memory = after;
|
||||
fs.unlinkSync(marker);
|
||||
clearPersistedPauses(after.pausedUntil);
|
||||
lastError = null;
|
||||
});
|
||||
return result;
|
||||
} catch (error) {
|
||||
// The caller aborts its transport on admission failure. Observe a started
|
||||
// promise even when persistence failed, without masking the store error.
|
||||
if (value?.then) Promise.resolve(value).catch(() => {});
|
||||
report(error);
|
||||
return { status: 'unavailable', error };
|
||||
}
|
||||
},
|
||||
pausedUntil() {
|
||||
return current().pausedUntil || 0;
|
||||
},
|
||||
@@ -540,12 +654,20 @@ export function createGateStateStore({
|
||||
}
|
||||
update((state) => ({ ...state, pausedUntil: at }));
|
||||
},
|
||||
status: () => ({
|
||||
file,
|
||||
durable: Boolean(file),
|
||||
persisted: Boolean(file) && !lastError,
|
||||
error: lastError ? String(lastError.message || lastError) : null,
|
||||
}),
|
||||
status: () => {
|
||||
const dispatchPending = pendingDispatches().length > 0;
|
||||
return {
|
||||
file,
|
||||
durable: Boolean(file),
|
||||
persisted: Boolean(file) && !lastError && !dispatchPending,
|
||||
dispatchPending,
|
||||
error: lastError
|
||||
? String(lastError.message || lastError)
|
||||
: dispatchPending
|
||||
? 'unfinished durable Nominatim dispatch'
|
||||
: null,
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -614,30 +736,52 @@ export function createNominatimGate({
|
||||
}
|
||||
|
||||
/** One upstream request; classifies refusals and pauses the gate. */
|
||||
async function send(url, config, maxBytes, signal) {
|
||||
upstreamRequests += 1;
|
||||
async function send(url, config, maxBytes, signal, queuedAt) {
|
||||
const controller = new AbortController();
|
||||
const onAbort = () => controller.abort();
|
||||
signal?.addEventListener('abort', onAbort, { once: true });
|
||||
// One deadline covers the headers AND the body: a server that answers its
|
||||
// headers and then stalls must not hold the shared queue.
|
||||
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
||||
let timer;
|
||||
const start = () => {
|
||||
upstreamRequests += 1;
|
||||
timer = setTimeout(() => controller.abort(), timeoutMs);
|
||||
return fetchImpl(url, {
|
||||
headers: NOMINATIM_HEADERS,
|
||||
redirect: 'error',
|
||||
signal: controller.signal,
|
||||
});
|
||||
};
|
||||
try {
|
||||
return await exchange(url, config, maxBytes, signal, controller.signal);
|
||||
return await exchange(
|
||||
config,
|
||||
maxBytes,
|
||||
signal,
|
||||
controller.signal,
|
||||
queuedAt,
|
||||
start,
|
||||
);
|
||||
} catch (error) {
|
||||
controller.abort();
|
||||
throw error;
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
signal?.removeEventListener('abort', onAbort);
|
||||
}
|
||||
}
|
||||
|
||||
async function exchange(url, config, maxBytes, signal, requestSignal) {
|
||||
async function exchange(
|
||||
config,
|
||||
maxBytes,
|
||||
signal,
|
||||
requestSignal,
|
||||
queuedAt,
|
||||
start,
|
||||
) {
|
||||
const turn = await takeTurn(config, queuedAt, signal, start);
|
||||
let response;
|
||||
try {
|
||||
response = await fetchImpl(url, {
|
||||
headers: NOMINATIM_HEADERS,
|
||||
redirect: 'error',
|
||||
signal: requestSignal,
|
||||
});
|
||||
response = await turn.value;
|
||||
} catch (cause) {
|
||||
if (signal?.aborted) throw gateError('NOMINATIM_ABANDONED', 'Abandoned');
|
||||
throw gateError('NOMINATIM_TRANSPORT', 'Place service unreachable', {
|
||||
@@ -684,45 +828,52 @@ export function createNominatimGate({
|
||||
}
|
||||
}
|
||||
|
||||
/** Atomically reserve the public slot, then wait until it may start. */
|
||||
async function takeTurn(config, queuedAt, signal) {
|
||||
if (signal?.aborted || now() - queuedAt > maxWaitMs)
|
||||
throw gateError('NOMINATIM_ABANDONED', 'Place search was abandoned');
|
||||
const refused = admissionError(config);
|
||||
if (refused) throw refused;
|
||||
// An operator-selected endpoint is not governed by the public instance's
|
||||
// shared budget, pacing, or durable-state requirement.
|
||||
if (!config.isPublic) return;
|
||||
const reserved = usageStore.reserve(utcDay(now()), {
|
||||
now: now(),
|
||||
dailyCap: config.dailyCap,
|
||||
minSpacingMs,
|
||||
latestStartAt: queuedAt + maxWaitMs,
|
||||
});
|
||||
if (reserved.status === 'unavailable')
|
||||
throw gateError(
|
||||
'NOMINATIM_STATE_UNAVAILABLE',
|
||||
'Public place service usage state is unavailable',
|
||||
{ cause: reserved.error },
|
||||
/** Revalidate public dispatch under shared ownership after every wake. */
|
||||
async function takeTurn(config, queuedAt, signal, start) {
|
||||
while (true) {
|
||||
if (signal?.aborted || now() - queuedAt > maxWaitMs)
|
||||
throw gateError('NOMINATIM_ABANDONED', 'Place search was abandoned');
|
||||
const refused = admissionError(config);
|
||||
if (refused) throw refused;
|
||||
// An operator-selected endpoint is not governed by the public instance's
|
||||
// shared budget, pacing, or durable-state requirement.
|
||||
if (!config.isPublic) {
|
||||
try {
|
||||
return { value: start() };
|
||||
} catch (error) {
|
||||
return { value: Promise.reject(error) };
|
||||
}
|
||||
}
|
||||
const reserved = usageStore.dispatch(
|
||||
utcDay(now()),
|
||||
{
|
||||
clock: now,
|
||||
dailyCap: config.dailyCap,
|
||||
minSpacingMs,
|
||||
latestStartAt: queuedAt + maxWaitMs,
|
||||
},
|
||||
start,
|
||||
);
|
||||
if (reserved.status === 'cap')
|
||||
throw gateError(
|
||||
'NOMINATIM_DAILY_CAP',
|
||||
'Daily place lookup allowance is used',
|
||||
);
|
||||
if (reserved.status === 'paused')
|
||||
throw gateError('NOMINATIM_PAUSED', 'Place service is pausing', {
|
||||
retryAfterMs: reserved.retryAfterMs,
|
||||
});
|
||||
if (reserved.status === 'abandoned')
|
||||
throw gateError('NOMINATIM_ABANDONED', 'Place search was abandoned');
|
||||
const waitMs = Math.max(0, reserved.startAt - now());
|
||||
if (waitMs) await sleep(waitMs);
|
||||
if (signal?.aborted || now() - queuedAt > maxWaitMs)
|
||||
throw gateError('NOMINATIM_ABANDONED', 'Place search was abandoned');
|
||||
// A refusal from another process after this reservation still stops it.
|
||||
// The reserved daily slot remains spent; it cannot safely be reassigned.
|
||||
if (now() < pauseEnd(config)) throw pausedError(config);
|
||||
if (reserved.status === 'unavailable')
|
||||
throw gateError(
|
||||
'NOMINATIM_STATE_UNAVAILABLE',
|
||||
'Public place service usage state is unavailable',
|
||||
{ cause: reserved.error },
|
||||
);
|
||||
if (reserved.status === 'cap')
|
||||
throw gateError(
|
||||
'NOMINATIM_DAILY_CAP',
|
||||
'Daily place lookup allowance is used',
|
||||
);
|
||||
if (reserved.status === 'paused')
|
||||
throw gateError('NOMINATIM_PAUSED', 'Place service is pausing', {
|
||||
retryAfterMs: reserved.retryAfterMs,
|
||||
});
|
||||
if (reserved.status === 'abandoned')
|
||||
throw gateError('NOMINATIM_ABANDONED', 'Place search was abandoned');
|
||||
if (reserved.status === 'started') return reserved;
|
||||
if (reserved.status === 'wait') await sleep(reserved.waitMs);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -749,14 +900,12 @@ export function createNominatimGate({
|
||||
const url = buildUrl(config.endpoint);
|
||||
const task = queue.then(async () => {
|
||||
try {
|
||||
await takeTurn(config, queuedAt, signal);
|
||||
try {
|
||||
return await send(url, config, maxBytes, signal);
|
||||
return await send(url, config, maxBytes, signal, queuedAt);
|
||||
} catch (error) {
|
||||
if (!error?.transport) throw error;
|
||||
await takeTurn(config, now(), signal);
|
||||
try {
|
||||
return await send(url, config, maxBytes, signal);
|
||||
return await send(url, config, maxBytes, signal, now());
|
||||
} catch (retryError) {
|
||||
if (retryError?.transport) pause(OUTAGE_BACKOFF_MS, config);
|
||||
throw retryError;
|
||||
|
||||
@@ -261,14 +261,10 @@ function queryWords(query) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a result's names answer the query. Most of the query's words must
|
||||
* appear among one name's words, or one name must contain the whole query
|
||||
* (for scripts written without spaces).
|
||||
* Settlements require one complete normalized name or explicit returned alias.
|
||||
* Landmark/building asks retain their word-overlap and unspaced-script shorthand.
|
||||
*/
|
||||
function outlineNameMatches(row, query) {
|
||||
const words = queryWords(query);
|
||||
if (!words.length) return false;
|
||||
const whole = words.join(' ');
|
||||
function outlineNameMatches(row, query, kind) {
|
||||
const names = new Set();
|
||||
const add = (value) => {
|
||||
const name = normalizePlaceName(value);
|
||||
@@ -278,6 +274,13 @@ function outlineNameMatches(row, query) {
|
||||
add(String(row?.display_name || '').split(',')[0]);
|
||||
if (row?.namedetails && typeof row.namedetails === 'object')
|
||||
for (const value of Object.values(row.namedetails)) add(value);
|
||||
if (kind === 'city' || kind === 'admin' || kind === 'neighborhood') {
|
||||
const head = normalizePlaceName(String(query ?? '').split(',')[0]);
|
||||
return Boolean(head) && names.has(head);
|
||||
}
|
||||
const words = queryWords(query);
|
||||
if (!words.length) return false;
|
||||
const whole = words.join(' ');
|
||||
const unspaced = UNSPACED_SCRIPT_RE.test(whole);
|
||||
for (const name of names) {
|
||||
const nameWords = name.split(' ');
|
||||
@@ -516,7 +519,7 @@ export function selectOutlineResult(rows, { kind, query }) {
|
||||
skipped.push(`${label}: level`);
|
||||
continue;
|
||||
}
|
||||
if (!outlineNameMatches(row, query)) {
|
||||
if (!outlineNameMatches(row, query, kind)) {
|
||||
skipped.push(`${label}: name`);
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -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