mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-04 10:24:02 +00:00
- A building word ("Burj Khalifa Tower", "Sagrada Familia Church") passes
the last-word rule on a building-like result; enclosures stay out.
- The anchor chooses only among the best-named outlines, so a worse name
never wins by containment alone.
- An ask without a quadrant takes only the quadrant of the matching street
nearest the point; a leading quadrant ("SE Main St") may go unsaid too.
- On an unbuffered tile edge a fragment shows as an edge along the
boundary, so a whole building touching it at a corner is not cut.
- Drop two orphaned comments and correct the place-name key wording.
521 lines
16 KiB
JavaScript
521 lines
16 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,
|
|
outlineAt,
|
|
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}`;
|
|
}
|
|
|
|
/** A decimal coordinate query value, or NaN: never "" or " " as 0. */
|
|
function coordinateParam(value) {
|
|
return /^-?\d+(\.\d+)?$/.test(value ?? '') ? Number(value) : NaN;
|
|
}
|
|
|
|
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 results whose class suits the ask kind.
|
|
* Every accepted result is kept (90 days; genuine no-results 24 hours), and
|
|
* each ask is answered with the one holding its anchor, so a same-name place
|
|
* elsewhere neither answers nor repeats from the cache.
|
|
*/
|
|
export function createNominatimOutlineProvider({
|
|
gate = () => sharedNominatimGate(),
|
|
cache = createNominatimCache(),
|
|
} = {}) {
|
|
const inFlight = new Map();
|
|
const currentGate = () => (typeof gate === 'function' ? gate() : gate);
|
|
|
|
const answer = (payload, anchorLat, anchorLon, cached) => ({
|
|
status: payload.status,
|
|
outline: outlineAt(payload.outlines, anchorLat, anchorLon, payload.ranks),
|
|
...(payload.skipped ? { skipped: payload.skipped } : {}),
|
|
...(cached ? { cached: true } : {}),
|
|
});
|
|
|
|
return async function fetchNominatimOutline(
|
|
{ query, kind, lat, lon, anchorLat = null, anchorLon = null },
|
|
{ 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, `outlines-${kind}`, params);
|
|
const cached = await cache.get(cacheKey);
|
|
if (cached) return answer(cached.payload, anchorLat, anchorLon, true);
|
|
const payload = 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 { outlines, ranks, skipped } = selectOutlineResult(rows, {
|
|
kind,
|
|
query: sent,
|
|
});
|
|
const found = outlines.length
|
|
? { status: 'OK', outlines, ranks }
|
|
: { status: 'ZERO_RESULTS', outlines: [], skipped };
|
|
await cache.set(
|
|
cacheKey,
|
|
found,
|
|
outlines.length
|
|
? NOMINATIM_CACHE_TTL_MS.outline
|
|
: NOMINATIM_CACHE_TTL_MS.notFound,
|
|
);
|
|
return found;
|
|
},
|
|
);
|
|
return answer(payload, anchorLat, anchorLon, false);
|
|
};
|
|
}
|
|
|
|
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'),
|
|
anchorLat: coordinateParam(url.searchParams.get('alat')),
|
|
anchorLon: coordinateParam(url.searchParams.get('alon')),
|
|
},
|
|
{ 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');
|
|
// Capability probe, like /api/overpass/status: clients skip lookups a
|
|
// server without a Nominatim endpoint could only refuse.
|
|
if (url.pathname === '/status' && req.method === 'GET')
|
|
return sendJson(res, 200, {
|
|
configured: Boolean(currentGate().settings()?.endpoint),
|
|
});
|
|
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 };
|