mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-05 10:53:59 +00:00
use bundled place boundaries and shared OpenFreeMap tiles before a guarded Nominatim fallback preserve multipart shapes, holes and cancellation, and keep pending annotations visible while ground sampling finishes across frames keep non-Latin name distinctions when selecting provider results, and retain an outline anchor only when it lies in filled geometry
497 lines
15 KiB
JavaScript
497 lines
15 KiB
JavaScript
import path from 'node:path';
|
|
import { makeRateLimiter, clientKey } from '../common/rate-limit.js';
|
|
import { admitSameSite } from '../common/same-site.js';
|
|
import { defaultSourceRoot } from '../common/source-root.js';
|
|
import { naturalRegionAtPoint } from '../../../src/data/naturalEarthRegions.js';
|
|
import {
|
|
nominatimToGeocodeResult,
|
|
nominatimViewboxFromBounds,
|
|
} from '../../../src/nominatimGeocode.js';
|
|
import {
|
|
NOMINATIM_MAX_PENDING,
|
|
isNominatimBusy,
|
|
sharedNominatimGate,
|
|
} from './nominatimGate.js';
|
|
import {
|
|
NOMINATIM_CACHE_TTL_MS,
|
|
createNominatimCache,
|
|
} from './nominatimCache.js';
|
|
import {
|
|
OUTLINE_KINDS,
|
|
OUTLINE_POLYGON_THRESHOLD,
|
|
outlineBias,
|
|
selectOutlineResult,
|
|
} from './outlineSelect.js';
|
|
|
|
const NOMINATIM_SEARCH_MAX_QUERY = 200;
|
|
|
|
/** Results considered per outline ask: enough to skip a node or a wrong type. */
|
|
const OUTLINE_RESULT_LIMIT = 3;
|
|
|
|
/** Largest upstream answer read for an outline (polygons are simplified upstream). */
|
|
const OUTLINE_MAX_RESPONSE_BYTES = 4 * 1024 * 1024;
|
|
|
|
/** Bump when cached answers change shape or selection authority. */
|
|
const CACHE_SCHEMA = 'v3';
|
|
|
|
/**
|
|
* The query text actually sent upstream: Unicode-composed, whitespace
|
|
* collapsed. Nothing else is changed, so two queries share a cache entry only
|
|
* when they are the same upstream request.
|
|
*/
|
|
export function upstreamQuery(query) {
|
|
return String(query ?? '')
|
|
.normalize('NFC')
|
|
.replace(/\s+/g, ' ')
|
|
.trim();
|
|
}
|
|
|
|
/** Cache key: endpoint, schema and the exact upstream parameters. */
|
|
function requestKey(gate, kind, params) {
|
|
const endpoint = gate.settings?.()?.endpoint || 'none';
|
|
return `${CACHE_SCHEMA}|${endpoint}|${kind}|${params}`;
|
|
}
|
|
|
|
function malformed() {
|
|
return Object.assign(new Error('Place answer was not understood'), {
|
|
code: 'NOMINATIM_MALFORMED',
|
|
});
|
|
}
|
|
|
|
function abandoned() {
|
|
return Object.assign(new Error('Place search was abandoned'), {
|
|
code: 'NOMINATIM_ABANDONED',
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Share one upstream call while keeping each caller's cancellation independent.
|
|
* The shared controller is aborted only after the last subscriber leaves.
|
|
*/
|
|
function subscribeSharedRequest(inFlight, key, signal, create) {
|
|
if (signal?.aborted) return Promise.reject(abandoned());
|
|
let flight = inFlight.get(key);
|
|
if (!flight) {
|
|
flight = {
|
|
controller: new AbortController(),
|
|
users: 0,
|
|
settled: false,
|
|
promise: null,
|
|
};
|
|
const owned = flight;
|
|
owned.promise = Promise.resolve()
|
|
.then(() => create(owned.controller.signal))
|
|
.finally(() => {
|
|
owned.settled = true;
|
|
if (inFlight.get(key) === owned) inFlight.delete(key);
|
|
});
|
|
inFlight.set(key, owned);
|
|
}
|
|
flight.users += 1;
|
|
return new Promise((resolve, reject) => {
|
|
let left = false;
|
|
const leave = () => {
|
|
if (left) return;
|
|
left = true;
|
|
signal?.removeEventListener('abort', onAbort);
|
|
flight.users -= 1;
|
|
if (
|
|
flight.users === 0 &&
|
|
!flight.settled &&
|
|
inFlight.get(key) === flight
|
|
) {
|
|
inFlight.delete(key);
|
|
flight.controller.abort();
|
|
}
|
|
};
|
|
const onAbort = () => {
|
|
leave();
|
|
reject(abandoned());
|
|
};
|
|
signal?.addEventListener('abort', onAbort, { once: true });
|
|
flight.promise.then(
|
|
(value) => {
|
|
leave();
|
|
resolve(value);
|
|
},
|
|
(error) => {
|
|
leave();
|
|
reject(error);
|
|
},
|
|
);
|
|
});
|
|
}
|
|
|
|
/** Where the dev server keeps Nominatim answers and the daily count. */
|
|
export const NOMINATIM_CACHE_DIR = path.join(
|
|
defaultSourceRoot,
|
|
'.gev-cache',
|
|
'nominatim',
|
|
);
|
|
|
|
/** Construct the offline regional context provider from bundled Natural Earth polygons. */
|
|
export function createRegionalPlaceProvider() {
|
|
return (point) => naturalRegionAtPoint(point.latitude, point.longitude);
|
|
}
|
|
|
|
export const fetchRegionalPlace = createRegionalPlaceProvider();
|
|
|
|
/**
|
|
* Snap a `"swLat,swLng|neLat,neLng"` view to a coarse grid so small camera
|
|
* moves reuse one cache entry. The viewbox is a soft preference upstream.
|
|
*/
|
|
function coarseBounds(bounds) {
|
|
// Validate the original hint before snapping or clamping can disguise a
|
|
// wrapped, inverted or out-of-range box as an unrelated valid viewbox.
|
|
if (!nominatimViewboxFromBounds(bounds)) return null;
|
|
const match = String(bounds || '')
|
|
.trim()
|
|
.split('|')
|
|
.map((corner) => corner.split(',').map(Number));
|
|
if (match.length !== 2 || match.some((c) => c.length !== 2)) return null;
|
|
const [[swLat, swLng], [neLat, neLng]] = match;
|
|
if (![swLat, swLng, neLat, neLng].every(Number.isFinite)) return null;
|
|
const span = Math.max(neLat - swLat, neLng - swLng, 0.01);
|
|
const step = 2 ** Math.ceil(Math.log2(span));
|
|
const cLat = Math.round((swLat + neLat) / 2 / (step / 4)) * (step / 4);
|
|
const cLng = Math.round((swLng + neLng) / 2 / (step / 4)) * (step / 4);
|
|
const clamp = (v, limit) => Math.max(-limit, Math.min(limit, v));
|
|
const fix = (v) => Number(v.toFixed(4));
|
|
return `${fix(clamp(cLat - step / 2, 90))},${fix(clamp(cLng - step / 2, 180))}|${fix(clamp(cLat + step / 2, 90))},${fix(clamp(cLng + step / 2, 180))}`;
|
|
}
|
|
|
|
/**
|
|
* Construct the forward search adapter over the shared gate.
|
|
*
|
|
* The policy asks that results be cached, and warns that a client repeating
|
|
* the same query may be treated as faulty, so answers are kept (30 days for a
|
|
* hit, 24 hours for a genuine no-result; a failure is never kept) and
|
|
* identical searches already in flight share one upstream call.
|
|
*/
|
|
export function createNominatimSearchProvider({
|
|
gate = () => sharedNominatimGate(),
|
|
cache = createNominatimCache(),
|
|
} = {}) {
|
|
const inFlight = new Map();
|
|
const currentGate = () => (typeof gate === 'function' ? gate() : gate);
|
|
|
|
return async function fetchNominatimSearch(query, bounds, { signal } = {}) {
|
|
const coarse = coarseBounds(bounds);
|
|
const params = new URLSearchParams({
|
|
format: 'jsonv2',
|
|
q: upstreamQuery(query),
|
|
addressdetails: '1',
|
|
limit: '1',
|
|
'accept-language': 'en',
|
|
});
|
|
const viewbox = nominatimViewboxFromBounds(coarse);
|
|
if (viewbox) params.set('viewbox', viewbox);
|
|
const gateNow = currentGate();
|
|
const cacheKey = requestKey(gateNow, 'search', params);
|
|
const cached = await cache.get(cacheKey);
|
|
if (cached) return { ...cached.payload, cached: true };
|
|
return await subscribeSharedRequest(
|
|
inFlight,
|
|
cacheKey,
|
|
signal,
|
|
async (sharedSignal) => {
|
|
const rows = await gateNow.requestJson(
|
|
(endpoint) => `${endpoint}?${params}`,
|
|
{ signal: sharedSignal },
|
|
);
|
|
// Only a well-formed empty list is "no such place"; anything else is a
|
|
// failure and is never cached.
|
|
if (!Array.isArray(rows)) throw malformed();
|
|
const result = nominatimToGeocodeResult(rows[0] ?? null);
|
|
if (rows.length && !result) throw malformed();
|
|
const payload = result
|
|
? { status: 'OK', results: [result] }
|
|
: { status: 'ZERO_RESULTS', results: [] };
|
|
await cache.set(
|
|
cacheKey,
|
|
payload,
|
|
result
|
|
? NOMINATIM_CACHE_TTL_MS.place
|
|
: NOMINATIM_CACHE_TTL_MS.notFound,
|
|
);
|
|
return payload;
|
|
},
|
|
);
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Construct the outline lookup: one guarded search with polygons for one
|
|
* explicit ask, accepting only a result whose class suits the ask kind.
|
|
* Found outlines are kept 90 days and genuine no-results 24 hours.
|
|
*/
|
|
export function createNominatimOutlineProvider({
|
|
gate = () => sharedNominatimGate(),
|
|
cache = createNominatimCache(),
|
|
} = {}) {
|
|
const inFlight = new Map();
|
|
const currentGate = () => (typeof gate === 'function' ? gate() : gate);
|
|
|
|
return async function fetchNominatimOutline(
|
|
{ query, kind, lat, lon },
|
|
{ signal } = {},
|
|
) {
|
|
const bias = outlineBias(kind, lat, lon);
|
|
const sent = upstreamQuery(query);
|
|
const params = new URLSearchParams({
|
|
format: 'jsonv2',
|
|
q: sent,
|
|
limit: String(OUTLINE_RESULT_LIMIT),
|
|
polygon_geojson: '1',
|
|
polygon_threshold: String(OUTLINE_POLYGON_THRESHOLD[kind]),
|
|
namedetails: '1',
|
|
'accept-language': 'en',
|
|
});
|
|
if (bias) params.set('viewbox', bias.viewbox);
|
|
const gateNow = currentGate();
|
|
const cacheKey = requestKey(gateNow, `outline-${kind}`, params);
|
|
const cached = await cache.get(cacheKey);
|
|
if (cached) return { ...cached.payload, cached: true };
|
|
return await subscribeSharedRequest(
|
|
inFlight,
|
|
cacheKey,
|
|
signal,
|
|
async (sharedSignal) => {
|
|
const rows = await gateNow.requestJson(
|
|
(endpoint) => `${endpoint}?${params}`,
|
|
{ signal: sharedSignal, maxBytes: OUTLINE_MAX_RESPONSE_BYTES },
|
|
);
|
|
if (!Array.isArray(rows)) throw malformed();
|
|
const { outline, skipped } = selectOutlineResult(rows, {
|
|
kind,
|
|
query: sent,
|
|
});
|
|
const payload = outline
|
|
? { status: 'OK', outline }
|
|
: { status: 'ZERO_RESULTS', outline: null, skipped };
|
|
await cache.set(
|
|
cacheKey,
|
|
payload,
|
|
outline
|
|
? NOMINATIM_CACHE_TTL_MS.outline
|
|
: NOMINATIM_CACHE_TTL_MS.notFound,
|
|
);
|
|
return payload;
|
|
},
|
|
);
|
|
};
|
|
}
|
|
|
|
export const fetchNominatimSearch = createNominatimSearchProvider();
|
|
|
|
function sendJson(res, status, body, headers = {}) {
|
|
if (res.writableEnded) return;
|
|
res.writeHead(status, {
|
|
'Content-Type': 'application/json',
|
|
'Cache-Control': 'no-store',
|
|
...headers,
|
|
});
|
|
res.end(JSON.stringify(body));
|
|
}
|
|
|
|
/** Map a gate refusal to the route's answer. Never "not found". */
|
|
function sendFailure(res, error, what) {
|
|
if (error?.code === 'NOMINATIM_DISABLED') {
|
|
sendJson(res, 503, {
|
|
error: `${what} is not configured`,
|
|
code: 'NOMINATIM_DISABLED',
|
|
retryable: false,
|
|
});
|
|
return;
|
|
}
|
|
if (error?.code === 'NOMINATIM_DAILY_CAP') {
|
|
sendJson(
|
|
res,
|
|
429,
|
|
{
|
|
error: `${what} daily allowance is used`,
|
|
code: 'NOMINATIM_DAILY_CAP',
|
|
retryable: false,
|
|
},
|
|
{ 'Retry-After': '3600' },
|
|
);
|
|
return;
|
|
}
|
|
if (isNominatimBusy(error)) {
|
|
const seconds = Math.max(5, Math.ceil((error.retryAfterMs || 0) / 1000));
|
|
sendJson(
|
|
res,
|
|
429,
|
|
{ error: `${what} is busy`, code: error.code },
|
|
{ 'Retry-After': String(seconds) },
|
|
);
|
|
return;
|
|
}
|
|
sendJson(res, 503, {
|
|
error: `${what} is temporarily unavailable`,
|
|
code: 'NOMINATIM_UNAVAILABLE',
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Vite plugin: last-resort place search (`/api/geocode`) and voice outlines
|
|
* (`/api/geocode/outline`) over the configured Nominatim endpoint.
|
|
*
|
|
* `storageDir` turns on the disk cache and the persisted daily count; the
|
|
* application composition supplies it, tests and embedders may omit it.
|
|
*/
|
|
export function geocodeProxy({
|
|
search,
|
|
outline,
|
|
storageDir = null,
|
|
gate = null,
|
|
} = {}) {
|
|
const currentGate = () => gate || sharedNominatimGate();
|
|
if (storageDir) {
|
|
// The shared count and pause live beside, never inside, the answer cache,
|
|
// so no cache sweep can reset them.
|
|
currentGate().persistUsage(path.join(storageDir, 'state.json'));
|
|
const diskCache = createNominatimCache({
|
|
dir: path.join(storageDir, 'answers'),
|
|
});
|
|
void diskCache.sweep();
|
|
search ||= createNominatimSearchProvider({
|
|
gate: currentGate,
|
|
cache: diskCache,
|
|
});
|
|
outline ||= createNominatimOutlineProvider({
|
|
gate: currentGate,
|
|
cache: diskCache,
|
|
});
|
|
}
|
|
search ||= gate
|
|
? createNominatimSearchProvider({ gate })
|
|
: fetchNominatimSearch;
|
|
outline ||= createNominatimOutlineProvider({ gate: currentGate });
|
|
|
|
const searchLimiter = makeRateLimiter({
|
|
windowMs: 60_000,
|
|
max: 30,
|
|
globalMax: 90,
|
|
});
|
|
// Outlines are explicit voice asks: a person cannot make many a minute.
|
|
const outlineLimiter = makeRateLimiter({
|
|
windowMs: 60_000,
|
|
max: 12,
|
|
globalMax: 30,
|
|
});
|
|
|
|
/** Common front door: method, same-site, rate limit, abandonment signal. */
|
|
function admit(req, res, limiter) {
|
|
if (admitSameSite(req, res)) return null;
|
|
if (req.method !== 'GET') {
|
|
sendJson(res, 405, { error: 'Method Not Allowed' });
|
|
return null;
|
|
}
|
|
if (!limiter(clientKey(req))) {
|
|
sendJson(
|
|
res,
|
|
429,
|
|
{ error: 'Rate limit exceeded' },
|
|
{ 'Retry-After': '10' },
|
|
);
|
|
return null;
|
|
}
|
|
// A browser that gave up is no longer waiting; the queue reads this
|
|
// before spending its slot.
|
|
const abandoned = new AbortController();
|
|
req.on?.('aborted', () => abandoned.abort());
|
|
res.on?.('close', () => abandoned.abort());
|
|
return abandoned.signal;
|
|
}
|
|
|
|
async function handleOutline(req, res, url) {
|
|
const signal = admit(req, res, outlineLimiter);
|
|
if (!signal) return;
|
|
const query = String(url.searchParams.get('q') || '').trim();
|
|
const kind = String(url.searchParams.get('kind') || '');
|
|
if (
|
|
!query ||
|
|
query.length > NOMINATIM_SEARCH_MAX_QUERY ||
|
|
!OUTLINE_KINDS.includes(kind)
|
|
) {
|
|
sendJson(res, 400, {
|
|
error:
|
|
'A place query of 1-200 characters and a known kind are required',
|
|
});
|
|
return;
|
|
}
|
|
try {
|
|
const payload = await outline(
|
|
{
|
|
query,
|
|
kind,
|
|
lat: url.searchParams.get('lat'),
|
|
lon: url.searchParams.get('lon'),
|
|
},
|
|
{ signal },
|
|
);
|
|
sendJson(
|
|
res,
|
|
200,
|
|
{ status: payload.status, outline: payload.outline || null },
|
|
{
|
|
'Cache-Control': payload.cached ? 'private, max-age=300' : 'no-store',
|
|
},
|
|
);
|
|
} catch (error) {
|
|
sendFailure(res, error, 'Outline lookup');
|
|
}
|
|
}
|
|
|
|
async function handleSearch(req, res, url) {
|
|
const signal = admit(req, res, searchLimiter);
|
|
if (!signal) return;
|
|
const query = String(url.searchParams.get('q') || '').trim();
|
|
if (!query || query.length > NOMINATIM_SEARCH_MAX_QUERY) {
|
|
sendJson(res, 400, {
|
|
error: 'A place query of 1-200 characters is required',
|
|
});
|
|
return;
|
|
}
|
|
try {
|
|
const payload = await search(query, url.searchParams.get('bounds'), {
|
|
signal,
|
|
});
|
|
sendJson(
|
|
res,
|
|
200,
|
|
{ status: payload.status, results: payload.results },
|
|
{
|
|
'Cache-Control': payload.cached ? 'private, max-age=60' : 'no-store',
|
|
},
|
|
);
|
|
} catch (error) {
|
|
sendFailure(res, error, 'Place search');
|
|
}
|
|
}
|
|
|
|
function install(middlewares) {
|
|
middlewares.use('/api/geocode', async (req, res, next) => {
|
|
const url = new URL(req.url || '', 'http://localhost');
|
|
if (url.pathname === '/outline') return handleOutline(req, res, url);
|
|
if (url.pathname === '/' || url.pathname === '')
|
|
return handleSearch(req, res, url);
|
|
if (typeof next === 'function') return next();
|
|
sendJson(res, 404, { error: 'Not Found' });
|
|
});
|
|
}
|
|
|
|
return {
|
|
name: 'geocode-proxy',
|
|
configureServer(server) {
|
|
install(server.middlewares);
|
|
},
|
|
configurePreviewServer(server) {
|
|
install(server.middlewares);
|
|
},
|
|
};
|
|
}
|
|
|
|
export { NOMINATIM_MAX_PENDING };
|