mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-06 03:14:00 +00:00
feat(outlines): restore voice outlines without public Overpass
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
This commit is contained in:
commit
d4434aa6d7
1489 files changed
+196442
-12353
No files matched your search
@@ -15,7 +15,7 @@ export const NOMINATIM_CACHE_TTL_MS = Object.freeze({
|
||||
});
|
||||
|
||||
/**
|
||||
* Comparable form of a place name: Unicode-normalized (NFKC), accents
|
||||
* Comparable form of a place name: Unicode-normalized (NFKC), Latin accents
|
||||
* removed, case folded, punctuation and symbols collapsed to single spaces,
|
||||
* and a leading article dropped. Non-Latin scripts are kept as they are:
|
||||
* "東京" stays "東京", while "São Paulo", "sao paulo" and "SÃO PAULO!" meet.
|
||||
@@ -26,7 +26,7 @@ export const NOMINATIM_CACHE_TTL_MS = Object.freeze({
|
||||
export function normalizePlaceName(value) {
|
||||
return String(value ?? '')
|
||||
.normalize('NFKD')
|
||||
.replace(/\p{M}+/gu, '')
|
||||
.replace(/(\p{Script=Latin})\p{M}+/gu, '$1')
|
||||
.normalize('NFKC')
|
||||
.toLocaleLowerCase('en-US')
|
||||
.replace(/[\p{P}\p{S}\p{Z}\s]+/gu, ' ')
|
||||
@@ -55,11 +55,11 @@ export function createNominatimCache({
|
||||
let writesSinceSweep = 0;
|
||||
let sweeping = null;
|
||||
|
||||
const digestFor = (key) =>
|
||||
crypto.createHash('sha256').update(key).digest('hex');
|
||||
|
||||
const fileFor = (key) =>
|
||||
path.join(
|
||||
dir,
|
||||
`${crypto.createHash('sha256').update(key).digest('hex').slice(0, 40)}.json`,
|
||||
);
|
||||
path.join(dir, `${digestFor(key).slice(0, 40)}.json`);
|
||||
|
||||
function remember(key, entry) {
|
||||
memory.delete(key);
|
||||
@@ -80,7 +80,8 @@ export function createNominatimCache({
|
||||
if (!dir) return null;
|
||||
try {
|
||||
const entry = JSON.parse(await fs.readFile(fileFor(key), 'utf8'));
|
||||
if (entry?.key !== key || !(entry.expiresAt > now())) return null;
|
||||
if (entry?.keyDigest !== digestFor(key) || !(entry.expiresAt > now()))
|
||||
return null;
|
||||
remember(key, entry);
|
||||
return entry;
|
||||
} catch {
|
||||
@@ -89,19 +90,29 @@ export function createNominatimCache({
|
||||
}
|
||||
|
||||
async function set(key, payload, ttlMs) {
|
||||
const entry = { key, payload, storedAt: now(), expiresAt: now() + ttlMs };
|
||||
const entry = {
|
||||
keyDigest: digestFor(key),
|
||||
payload,
|
||||
storedAt: now(),
|
||||
expiresAt: now() + ttlMs,
|
||||
};
|
||||
remember(key, entry);
|
||||
if (!dir) return;
|
||||
const body = JSON.stringify(entry);
|
||||
if (Buffer.byteLength(body) > maxEntryBytes) return;
|
||||
let temp = null;
|
||||
try {
|
||||
await fs.mkdir(dir, { recursive: true });
|
||||
await fs.mkdir(dir, { recursive: true, mode: 0o700 });
|
||||
await fs.chmod(dir, 0o700);
|
||||
const file = fileFor(key);
|
||||
const temp = `${file}.${process.pid}.tmp`;
|
||||
await fs.writeFile(temp, body);
|
||||
temp = `${file}.${process.pid}.${crypto.randomBytes(6).toString('hex')}.tmp`;
|
||||
await fs.writeFile(temp, body, { mode: 0o600, flag: 'wx' });
|
||||
await fs.rename(temp, file);
|
||||
temp = null;
|
||||
} catch {
|
||||
return;
|
||||
} finally {
|
||||
if (temp) await fs.rm(temp, { force: true }).catch(() => {});
|
||||
}
|
||||
writesSinceSweep += 1;
|
||||
if (writesSinceSweep >= sweepEvery) {
|
||||
@@ -167,5 +178,5 @@ export function createNominatimCache({
|
||||
return sweeping;
|
||||
}
|
||||
|
||||
return { get, set, sweep, memorySize: () => memory.size };
|
||||
return { get, set, sweep };
|
||||
}
|
||||
@@ -23,23 +23,23 @@ const PUBLIC_NOMINATIM_HOST = 'nominatim.openstreetmap.org';
|
||||
* The public usage policy asks for a User-Agent or Referer that identifies the
|
||||
* application. The identity is stable: it is never rotated or varied.
|
||||
*/
|
||||
export const NOMINATIM_HEADERS = Object.freeze({
|
||||
const NOMINATIM_HEADERS = Object.freeze({
|
||||
'User-Agent':
|
||||
'gods-eye-view/0.1 (+https://github.com/bilawalsidhu/gods-eye-view)',
|
||||
Referer: 'https://github.com/bilawalsidhu/gods-eye-view',
|
||||
});
|
||||
|
||||
/** Absolute policy maximum is one request per second; 1.1 s absorbs jitter. */
|
||||
export const NOMINATIM_MIN_SPACING_MS = 1100;
|
||||
const NOMINATIM_MIN_SPACING_MS = 1100;
|
||||
|
||||
/** Requests allowed to wait for their turn; past this, refuse at once. */
|
||||
export const NOMINATIM_MAX_PENDING = 4;
|
||||
|
||||
/** A queued request older than this has no reader left; drop it unsent. */
|
||||
export const NOMINATIM_MAX_WAIT_MS = 10_000;
|
||||
const NOMINATIM_MAX_WAIT_MS = 10_000;
|
||||
|
||||
/** Default public requests per install per UTC day, outlines and search together. */
|
||||
export const NOMINATIM_DEFAULT_DAILY_CAP = 50;
|
||||
const NOMINATIM_DEFAULT_DAILY_CAP = 50;
|
||||
|
||||
/**
|
||||
* Pause after a refusal without Retry-After. A Retry-After from the server is
|
||||
@@ -88,12 +88,15 @@ export function resolveNominatimSettings(env = process.env) {
|
||||
url.hash
|
||||
)
|
||||
return { endpoint: null, isPublic: false, dailyCap, invalid: true };
|
||||
const publicHost =
|
||||
url.hostname.toLowerCase().replace(/\.$/, '') === PUBLIC_NOMINATIM_HOST;
|
||||
if (publicHost && url.protocol !== 'https:')
|
||||
return { endpoint: null, isPublic: false, dailyCap, invalid: true };
|
||||
if (url.pathname === '/' || url.pathname === '') url.pathname = '/search';
|
||||
return {
|
||||
endpoint: url.href.replace(/\/$/, ''),
|
||||
// A trailing DNS dot names the same host.
|
||||
isPublic:
|
||||
url.hostname.toLowerCase().replace(/\.$/, '') === PUBLIC_NOMINATIM_HOST,
|
||||
isPublic: publicHost,
|
||||
dailyCap,
|
||||
};
|
||||
}
|
||||
@@ -546,9 +549,6 @@ export function createGateStateStore({
|
||||
};
|
||||
}
|
||||
|
||||
/** Compatibility name for the state store. */
|
||||
export const createUsageStore = createGateStateStore;
|
||||
|
||||
/**
|
||||
* Construct a gate. `settings` may be a function so the environment is read
|
||||
* after the dev server loads `.env`.
|
||||
@@ -558,7 +558,7 @@ export const createUsageStore = createGateStateStore;
|
||||
* @param {typeof fetch} [options.fetchImpl]
|
||||
* @param {() => number} [options.now]
|
||||
* @param {(ms: number) => Promise<void>} [options.sleep]
|
||||
* @param {ReturnType<typeof createUsageStore>} [options.usage]
|
||||
* @param {ReturnType<typeof createGateStateStore>} [options.usage]
|
||||
*/
|
||||
export function createNominatimGate({
|
||||
settings = () => resolveNominatimSettings(),
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { normalizePlaceName } from './nominatimCache.js';
|
||||
import { pointInRing } from '../../../src/data/adminBoundaries.js';
|
||||
|
||||
/**
|
||||
* Which Nominatim results may answer which voice ask, and how their polygons
|
||||
@@ -30,7 +31,7 @@ export const OUTLINE_POLYGON_THRESHOLD = Object.freeze({
|
||||
});
|
||||
|
||||
/** Total vertices returned for one outline, and parts/holes kept. */
|
||||
export const OUTLINE_MAX_VERTICES = 6000;
|
||||
const OUTLINE_MAX_VERTICES = 6000;
|
||||
const OUTLINE_MAX_PARTS = 24;
|
||||
const OUTLINE_MAX_HOLES = 16;
|
||||
|
||||
@@ -106,7 +107,7 @@ const MAN_MADE_STRUCTURES = [
|
||||
* class. Anything absent is rejected: roads, railways, stations, shops,
|
||||
* guideposts and single facilities never answer an area ask.
|
||||
*/
|
||||
export const OUTLINE_ACCEPT = Object.freeze({
|
||||
const OUTLINE_ACCEPT = Object.freeze({
|
||||
city: {
|
||||
boundary: ['administrative'],
|
||||
place: SETTLEMENT_PLACES,
|
||||
@@ -165,9 +166,9 @@ export const OUTLINE_ACCEPT = Object.freeze({
|
||||
});
|
||||
|
||||
/**
|
||||
* Administrative evidence per ask kind: an address type, or a Nominatim
|
||||
* place_rank band (country 4, state 8, county 10-12, city 12-16, suburb 18-20,
|
||||
* neighbourhood 20-22). An administrative boundary with neither is refused.
|
||||
* Administrative evidence per ask kind: a recognized address type is required.
|
||||
* A supplied numeric place_rank must also fit the ask's broad band (country 4,
|
||||
* state 8, county 10-12, city 12-16, suburb 18-20, neighbourhood 20-22).
|
||||
*/
|
||||
const ADMIN_EVIDENCE = Object.freeze({
|
||||
city: {
|
||||
@@ -192,99 +193,31 @@ const ADMIN_EVIDENCE = Object.freeze({
|
||||
},
|
||||
});
|
||||
|
||||
const KNOWN_ADMIN_TYPES = new Set(
|
||||
Object.values(ADMIN_EVIDENCE).flatMap((evidence) => evidence.types),
|
||||
);
|
||||
|
||||
/**
|
||||
* Nominatim address types whose meaning is stable enough for the area consumer's
|
||||
* country/admin1/admin2 contract. `region`, `district` and numeric admin levels
|
||||
* vary by country, so they remain unverified instead of being guessed.
|
||||
*/
|
||||
const VERIFIED_ADMIN_LEVEL = Object.freeze({
|
||||
country: 'country',
|
||||
state: 'admin1',
|
||||
province: 'admin1',
|
||||
county: 'admin2',
|
||||
state_district: 'admin2',
|
||||
});
|
||||
|
||||
const EXPLICIT_NAME_LEVEL = Object.freeze({
|
||||
country: 'country',
|
||||
nation: 'country',
|
||||
province: 'admin1',
|
||||
state: 'admin1',
|
||||
region: 'admin1',
|
||||
oblast: 'admin1',
|
||||
prefecture: 'admin1',
|
||||
canton: 'admin1',
|
||||
governorate: 'admin1',
|
||||
county: 'admin2',
|
||||
district: 'admin2',
|
||||
department: 'admin2',
|
||||
zone: 'retired',
|
||||
});
|
||||
|
||||
function explicitNameEvidence(value) {
|
||||
const normalized = String(value || '')
|
||||
.normalize('NFKC')
|
||||
.toLocaleLowerCase()
|
||||
.replace(/[^\p{L}\p{M}\p{N}]+/gu, ' ')
|
||||
.trim();
|
||||
const prefix = normalized.match(
|
||||
/^(country|nation|province|state|region|zone|oblast|prefecture|canton|governorate|county|district|department)\s+of\b/,
|
||||
)?.[1];
|
||||
const suffix = normalized.match(
|
||||
/\b(country|nation|province|state|region|zone|oblast|prefecture|canton|governorate|county|district|department)$/,
|
||||
)?.[1];
|
||||
const levels = new Set(
|
||||
[prefix, suffix]
|
||||
.filter(Boolean)
|
||||
.map((type) => EXPLICIT_NAME_LEVEL[type])
|
||||
.filter(Boolean),
|
||||
);
|
||||
if (levels.size > 1) return { contradictory: true, level: null };
|
||||
return { contradictory: false, level: levels.values().next().value || null };
|
||||
}
|
||||
|
||||
/**
|
||||
* Verified administrative identity carried by the selected result itself.
|
||||
* Never infer it from the request, desired kind, rank, or a separate geocoder
|
||||
* anchor. A retired name is preserved without a level so downstream policy can
|
||||
* reject it explicitly; every other contradiction stays wholly unverified.
|
||||
*/
|
||||
function selectedAdminIdentity(kind, row) {
|
||||
if (kind !== 'admin') return null;
|
||||
const adminLevel =
|
||||
VERIFIED_ADMIN_LEVEL[String(row?.addresstype || '').toLowerCase()];
|
||||
const authoritativeAdminArea = String(row?.name || '').trim();
|
||||
if (!adminLevel || !authoritativeAdminArea) return null;
|
||||
// Validate the complete authoritative name. `adminArea` is truncated only
|
||||
// for display after every prefix/suffix signal has been considered.
|
||||
const { contradictory, level: nameLevel } = explicitNameEvidence(
|
||||
authoritativeAdminArea,
|
||||
);
|
||||
if (contradictory) return null;
|
||||
const adminArea = authoritativeAdminArea.slice(0, 160);
|
||||
if (nameLevel === 'retired') return { adminArea };
|
||||
if (nameLevel && nameLevel !== adminLevel) return null;
|
||||
return { adminArea, adminLevel };
|
||||
}
|
||||
|
||||
/** Whether an administrative boundary is at the level the ask names. */
|
||||
export function adminLevelFits(kind, row) {
|
||||
/** Whether supplied administrative evidence contradicts the accepted identity. */
|
||||
function adminLevelFits(kind, row) {
|
||||
const evidence = ADMIN_EVIDENCE[kind];
|
||||
if (!evidence) return true;
|
||||
const type = String(row?.addresstype || '').toLowerCase();
|
||||
if (type && evidence.types.includes(type)) return true;
|
||||
// A recognized type is direct evidence. Rank is only a fallback when the
|
||||
// upstream answer omits the type or supplies a value we do not understand;
|
||||
// the overlapping county/city rank bands must not override `county`.
|
||||
if (KNOWN_ADMIN_TYPES.has(type)) return false;
|
||||
const rank = Number(row?.place_rank);
|
||||
if (Number.isFinite(rank))
|
||||
return rank >= evidence.ranks[0] && rank <= evidence.ranks[1];
|
||||
return false;
|
||||
const place = String(row?.category || row?.class).toLowerCase() === 'place';
|
||||
// A place row already has an accepted type. Missing address evidence must
|
||||
// not erase it, but explicit contradictory evidence must not bypass checks.
|
||||
if (!place || Object.hasOwn(row, 'addresstype')) {
|
||||
if (typeof row?.addresstype !== 'string') return false;
|
||||
const type = row.addresstype.toLowerCase();
|
||||
if (
|
||||
!evidence.types.includes(type) &&
|
||||
!(place && type === String(row.type).toLowerCase())
|
||||
)
|
||||
return false;
|
||||
}
|
||||
// Rank can corroborate or contradict an explicit identity, never invent it.
|
||||
// JSONv2 supplies a number; coercing arrays or strings hides malformed data.
|
||||
if (!Object.hasOwn(row, 'place_rank')) return true;
|
||||
const rank = row.place_rank;
|
||||
return (
|
||||
Number.isInteger(rank) &&
|
||||
// Boundary rank bands do not describe every accepted place type.
|
||||
(place || (rank >= evidence.ranks[0] && rank <= evidence.ranks[1]))
|
||||
);
|
||||
}
|
||||
|
||||
/** Largest footprint (m²) a non-`building` result may have for a building ask. */
|
||||
@@ -332,7 +265,7 @@ function queryWords(query) {
|
||||
* appear among one name's words, or one name must contain the whole query
|
||||
* (for scripts written without spaces).
|
||||
*/
|
||||
export function outlineNameMatches(row, query) {
|
||||
function outlineNameMatches(row, query) {
|
||||
const words = queryWords(query);
|
||||
if (!words.length) return false;
|
||||
const whole = words.join(' ');
|
||||
@@ -371,8 +304,7 @@ function cleanRing(ring) {
|
||||
const out = [];
|
||||
for (const point of ring) {
|
||||
if (!Array.isArray(point)) return null;
|
||||
const lon = Number(point[0]);
|
||||
const lat = Number(point[1]);
|
||||
const [lon, lat] = point;
|
||||
if (!finiteLon(lon) || !finiteLat(lat)) return null;
|
||||
out.push([lon, lat]);
|
||||
}
|
||||
@@ -391,30 +323,31 @@ function ringArea(ring) {
|
||||
}
|
||||
|
||||
/** Polygon/MultiPolygon GeoJSON → `[[outer, ...holes], …]`, largest first. */
|
||||
export function polygonsFromGeoJson(geometry) {
|
||||
function polygonsFromGeoJson(geometry) {
|
||||
const raw =
|
||||
geometry?.type === 'Polygon'
|
||||
? [geometry.coordinates]
|
||||
: geometry?.type === 'MultiPolygon'
|
||||
? geometry.coordinates
|
||||
: null;
|
||||
if (!Array.isArray(raw)) return null;
|
||||
if (!Array.isArray(raw) || !raw.length || raw.length > OUTLINE_MAX_PARTS)
|
||||
return null;
|
||||
const polygons = [];
|
||||
for (const polygon of raw) {
|
||||
if (!Array.isArray(polygon)) continue;
|
||||
if (
|
||||
!Array.isArray(polygon) ||
|
||||
!polygon.length ||
|
||||
polygon.length - 1 > OUTLINE_MAX_HOLES
|
||||
)
|
||||
return null;
|
||||
const outer = cleanRing(polygon[0]);
|
||||
if (!outer) continue;
|
||||
const holes = polygon
|
||||
.slice(1)
|
||||
.map(cleanRing)
|
||||
.filter(Boolean)
|
||||
.sort((a, b) => ringArea(b) - ringArea(a))
|
||||
.slice(0, OUTLINE_MAX_HOLES);
|
||||
if (!outer) return null;
|
||||
const holes = polygon.slice(1).map(cleanRing);
|
||||
if (holes.some((hole) => !hole)) return null;
|
||||
polygons.push({ rings: [outer, ...holes], area: ringArea(outer) });
|
||||
}
|
||||
if (!polygons.length) return null;
|
||||
polygons.sort((a, b) => b.area - a.area);
|
||||
return polygons.slice(0, OUTLINE_MAX_PARTS).map((entry) => entry.rings);
|
||||
return polygons.map((entry) => entry.rings);
|
||||
}
|
||||
|
||||
/** Iterative Douglas–Peucker on a closed ring (keeps it closed, ≥ 4 points). */
|
||||
@@ -460,6 +393,79 @@ const countVertices = (polygons) =>
|
||||
0,
|
||||
);
|
||||
|
||||
const edgeSide = (p, q, r) =>
|
||||
(q[0] - p[0]) * (r[1] - p[1]) - (q[1] - p[1]) * (r[0] - p[0]);
|
||||
const onEdge = (p, q, r) =>
|
||||
r[0] >= Math.min(p[0], q[0]) &&
|
||||
r[0] <= Math.max(p[0], q[0]) &&
|
||||
r[1] >= Math.min(p[1], q[1]) &&
|
||||
r[1] <= Math.max(p[1], q[1]);
|
||||
|
||||
/** Intersection, including touching, of two planar closed-ring edges. */
|
||||
function edgesIntersect(a, b, c, d) {
|
||||
if (
|
||||
Math.max(a[0], b[0]) < Math.min(c[0], d[0]) ||
|
||||
Math.max(c[0], d[0]) < Math.min(a[0], b[0]) ||
|
||||
Math.max(a[1], b[1]) < Math.min(c[1], d[1]) ||
|
||||
Math.max(c[1], d[1]) < Math.min(a[1], b[1])
|
||||
)
|
||||
return false;
|
||||
const ac = edgeSide(a, b, c);
|
||||
const ad = edgeSide(a, b, d);
|
||||
const ca = edgeSide(c, d, a);
|
||||
const cb = edgeSide(c, d, b);
|
||||
return (
|
||||
(ac === 0 && onEdge(a, b, c)) ||
|
||||
(ad === 0 && onEdge(a, b, d)) ||
|
||||
(ca === 0 && onEdge(c, d, a)) ||
|
||||
(cb === 0 && onEdge(c, d, b)) ||
|
||||
(ac > 0 !== ad > 0 && ca > 0 !== cb > 0)
|
||||
);
|
||||
}
|
||||
|
||||
function ringsIntersect(a, b) {
|
||||
for (let i = 1; i < a.length; i++)
|
||||
for (let j = 1; j < b.length; j++)
|
||||
if (edgesIntersect(a[i - 1], a[i], b[j - 1], b[j])) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check only generated bounded output, not a new input-geometry policy.
|
||||
* Touching generated boundaries are conservatively unsupported here.
|
||||
*/
|
||||
function simplifiedTopologyFits(polygons) {
|
||||
for (const [outer, ...holes] of polygons) {
|
||||
for (const ring of [outer, ...holes]) {
|
||||
if (ringArea(ring) === 0) return false;
|
||||
for (let i = 1; i < ring.length; i++)
|
||||
for (let j = i + 2; j < ring.length; j++) {
|
||||
if (i === 1 && j === ring.length - 1) continue;
|
||||
if (edgesIntersect(ring[i - 1], ring[i], ring[j - 1], ring[j]))
|
||||
return false;
|
||||
}
|
||||
}
|
||||
for (let i = 0; i < holes.length; i++) {
|
||||
const hole = holes[i];
|
||||
if (
|
||||
!hole.every(([lon, lat]) => pointInRing(outer, lat, lon)) ||
|
||||
ringsIntersect(outer, hole)
|
||||
)
|
||||
return false;
|
||||
for (let j = 0; j < i; j++) {
|
||||
const other = holes[j];
|
||||
if (
|
||||
ringsIntersect(hole, other) ||
|
||||
pointInRing(other, hole[0][1], hole[0][0]) ||
|
||||
pointInRing(hole, other[0][1], other[0][0])
|
||||
)
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/** Simplify until the whole outline fits the vertex cap. */
|
||||
export function capOutlineVertices(
|
||||
polygons,
|
||||
@@ -468,22 +474,20 @@ export function capOutlineVertices(
|
||||
let current = polygons;
|
||||
let tolerance = 0.00002;
|
||||
for (let round = 0; round < 12; round++) {
|
||||
if (countVertices(current) <= maxVertices) return current;
|
||||
if (countVertices(current) <= maxVertices)
|
||||
return current === polygons || simplifiedTopologyFits(current)
|
||||
? current
|
||||
: null;
|
||||
current = polygons.map((rings) =>
|
||||
rings.map((ring) => simplifyClosedRing(ring, tolerance)),
|
||||
);
|
||||
tolerance *= 2;
|
||||
}
|
||||
// Still over: keep the largest parts that fit, outer rings only.
|
||||
const kept = [];
|
||||
let total = 0;
|
||||
for (const rings of current) {
|
||||
const outer = rings[0];
|
||||
if (total + outer.length > maxVertices) break;
|
||||
kept.push([outer]);
|
||||
total += outer.length;
|
||||
}
|
||||
return kept.length ? kept : null;
|
||||
// Never turn a resource refusal into an apparently complete partial outline.
|
||||
return countVertices(current) <= maxVertices &&
|
||||
simplifiedTopologyFits(current)
|
||||
? current
|
||||
: null;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -504,8 +508,9 @@ export function selectOutlineResult(rows, { kind, query }) {
|
||||
continue;
|
||||
}
|
||||
if (
|
||||
String(osmClass).toLowerCase() === 'boundary' &&
|
||||
String(osmType).toLowerCase() === 'administrative' &&
|
||||
(String(osmClass).toLowerCase() === 'place' ||
|
||||
(String(osmClass).toLowerCase() === 'boundary' &&
|
||||
String(osmType).toLowerCase() === 'administrative')) &&
|
||||
!adminLevelFits(kind, row)
|
||||
) {
|
||||
skipped.push(`${label}: level`);
|
||||
@@ -535,7 +540,6 @@ export function selectOutlineResult(rows, { kind, query }) {
|
||||
skipped.push(`${label}: too large`);
|
||||
continue;
|
||||
}
|
||||
const adminIdentity = selectedAdminIdentity(kind, row);
|
||||
const lat = Number(row.lat);
|
||||
const lon = Number(row.lon);
|
||||
return {
|
||||
@@ -548,7 +552,6 @@ export function selectOutlineResult(rows, { kind, query }) {
|
||||
row.osm_type && row.osm_id != null
|
||||
? `${row.osm_type}/${row.osm_id}`
|
||||
: null,
|
||||
...(adminIdentity || {}),
|
||||
center: finiteLat(lat) && finiteLon(lon) ? { lat, lon } : null,
|
||||
polygons,
|
||||
},
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import path from 'node:path';
|
||||
import { makeRateLimiter, clientKey } from '../common/rate-limit.js';
|
||||
import { coalesceProxyRequest } from '../common/http.js';
|
||||
import { admitSameSite } from '../common/same-site.js';
|
||||
import { defaultSourceRoot } from '../common/source-root.js';
|
||||
import { naturalRegionAtPoint } from '../../../src/data/naturalEarthRegions.js';
|
||||
import {
|
||||
nominatimToGeocodeResult,
|
||||
@@ -30,8 +31,8 @@ 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 the cached payload shape changes, so old entries are never read. */
|
||||
const CACHE_SCHEMA = 'v2';
|
||||
/** Bump when cached answers change shape or selection authority. */
|
||||
const CACHE_SCHEMA = 'v3';
|
||||
|
||||
/**
|
||||
* The query text actually sent upstream: Unicode-composed, whitespace
|
||||
@@ -57,9 +58,73 @@ function 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(
|
||||
process.cwd(),
|
||||
defaultSourceRoot,
|
||||
'.gev-cache',
|
||||
'nominatim',
|
||||
);
|
||||
@@ -76,6 +141,9 @@ export const fetchRegionalPlace = createRegionalPlaceProvider();
|
||||
* 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('|')
|
||||
@@ -122,27 +190,33 @@ export function createNominatimSearchProvider({
|
||||
const cacheKey = requestKey(gateNow, 'search', params);
|
||||
const cached = await cache.get(cacheKey);
|
||||
if (cached) return { ...cached.payload, cached: true };
|
||||
const { promise } = coalesceProxyRequest(inFlight, cacheKey, async () => {
|
||||
const rows = await gateNow.requestJson(
|
||||
(endpoint) => `${endpoint}?${params}`,
|
||||
{ signal },
|
||||
);
|
||||
// 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;
|
||||
});
|
||||
return await promise;
|
||||
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;
|
||||
},
|
||||
);
|
||||
};
|
||||
}
|
||||
|
||||
@@ -178,41 +252,38 @@ export function createNominatimOutlineProvider({
|
||||
const cacheKey = requestKey(gateNow, `outline-${kind}`, params);
|
||||
const cached = await cache.get(cacheKey);
|
||||
if (cached) return { ...cached.payload, cached: true };
|
||||
const { promise } = coalesceProxyRequest(inFlight, cacheKey, async () => {
|
||||
const rows = await gateNow.requestJson(
|
||||
(endpoint) => `${endpoint}?${params}`,
|
||||
{ signal, 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;
|
||||
});
|
||||
return await promise;
|
||||
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();
|
||||
|
||||
/** A browser on another site may not spend this install's allowance. */
|
||||
function crossSite(req) {
|
||||
return (
|
||||
String(req.headers?.['sec-fetch-site'] || '').toLowerCase() === 'cross-site'
|
||||
);
|
||||
}
|
||||
|
||||
function sendJson(res, status, body, headers = {}) {
|
||||
if (res.writableEnded) return;
|
||||
res.writeHead(status, {
|
||||
@@ -312,14 +383,11 @@ export function geocodeProxy({
|
||||
|
||||
/** 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 (crossSite(req)) {
|
||||
sendJson(res, 403, { error: 'Cross-site requests are not accepted' });
|
||||
return null;
|
||||
}
|
||||
if (!limiter(clientKey(req))) {
|
||||
sendJson(
|
||||
res,
|
||||
@@ -394,7 +462,9 @@ export function geocodeProxy({
|
||||
res,
|
||||
200,
|
||||
{ status: payload.status, results: payload.results },
|
||||
{ 'Cache-Control': payload.cached ? 'public, max-age=60' : 'no-store' },
|
||||
{
|
||||
'Cache-Control': payload.cached ? 'private, max-age=60' : 'no-store',
|
||||
},
|
||||
);
|
||||
} catch (error) {
|
||||
sendFailure(res, error, 'Place search');
|
||||
|
||||
Reference in new issue
Block a user