mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-18 21:16:47 +00:00
refactor(maps): the country lookup moves to utils, so maps no longer reaches into atlas
This commit is contained in:
@@ -1,54 +1,46 @@
|
||||
import { Place } from '../../types';
|
||||
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import zlib from 'zlib';
|
||||
import {
|
||||
assetPath,
|
||||
createFeatureSplitter,
|
||||
getCountryFromCoords,
|
||||
isPointInCountryBox,
|
||||
loadAdmin0Gz,
|
||||
pointInGeometry,
|
||||
type Box,
|
||||
type CompactGeom,
|
||||
} from '../../utils/country-borders';
|
||||
import { cacheKeyFor, getCached, nominatimFetch, setCached } from '../geo/nominatim.client';
|
||||
|
||||
// ── Pure geo machinery for the atlas domain ─────────────────────────────────
|
||||
//
|
||||
// Plain module on purpose (same class as maps.helpers.ts / tripAccess): no DI,
|
||||
// no DB access, no Nest imports. The caches below (admin0 gz bytes, admin1
|
||||
// per-country store, geocode/region caches, country poly/box indexes, the
|
||||
// shared Nominatim throttle and the geocodingInFlight dedup set) are
|
||||
// file-scoped so their lifetime stays process-global no matter how many
|
||||
// AtlasService instances exist (container singleton + test helpers) —
|
||||
// instance state would duplicate the multi-MB indexes the
|
||||
// #1576 OOM fix exists to avoid and split the ≥1.1s Nominatim throttle.
|
||||
// no DB access, no Nest imports. The caches below (admin1 per-country store,
|
||||
// geocode/region caches, the shared Nominatim throttle and the geocodingInFlight
|
||||
// dedup set) are file-scoped so their lifetime stays process-global no matter how
|
||||
// many AtlasService instances exist (container singleton + test helpers) —
|
||||
// instance state would duplicate the multi-MB indexes the #1576 OOM fix exists
|
||||
// to avoid and split the ≥1.1s Nominatim throttle.
|
||||
// Relocated verbatim from the legacy services/atlasService.ts fold.
|
||||
|
||||
// The country lookup lives in utils/country-borders.ts, which the maps domain imports
|
||||
// as well; it is re-exported here so its existing callers keep importing it from atlas-geo.
|
||||
export { getCountryFromCoords, isPointInCountryBox };
|
||||
|
||||
// ── Bundled boundary GeoJSON (admin-0 countries + admin-1 regions) ─────────
|
||||
//
|
||||
// Sourced from geoBoundaries (CC BY 4.0), normalized + quantized offline by
|
||||
// scripts/build-atlas-geo.mjs into gzipped FeatureCollections under server/assets.
|
||||
// They are read + decompressed once and cached in memory — no network at runtime.
|
||||
// (Replaces the previous runtime fetch of Natural Earth, which was stale for recent
|
||||
// sub-national reforms and depicts some contested borders in unwanted ways.)
|
||||
// Where the bundles come from, the path they are read from, the admin-0 gz bytes and
|
||||
// the country index built on them are in utils/country-borders.ts. The admin-1 half
|
||||
// is below.
|
||||
//
|
||||
// __dirname is server/dist/nest/atlas at runtime and server/src/nest/atlas under
|
||||
// vitest; both resolve ../../../assets to server/assets.
|
||||
|
||||
// Neither parsed bundle is cached. admin0 (~145MB) and admin1 (~260MB) parsed at once are
|
||||
// what exhausted a 512MB host while using Atlas (#1576). Instead we retain only compact
|
||||
// derivatives: the raw admin0 .gz bytes for direct serving, a Float64Array poly/box index
|
||||
// for server-side point-in-polygon (see buildCountryIndexes), and admin1 pre-split per
|
||||
// country into ready-to-serve GeoJSON strings, built by streaming the gz so the full
|
||||
// bundle is never materialised in one piece.
|
||||
|
||||
function assetPath(name: 'admin0' | 'admin1'): string {
|
||||
return path.join(__dirname, '..', '..', '..', 'assets', 'atlas', `${name}.geojson.gz`);
|
||||
}
|
||||
|
||||
let admin0Gz: Buffer | null | undefined;
|
||||
function loadAdmin0Gz(): Buffer | null {
|
||||
if (admin0Gz !== undefined) return admin0Gz;
|
||||
const file = assetPath('admin0');
|
||||
if (!fs.existsSync(file)) {
|
||||
console.warn(`[Atlas] admin0.geojson.gz missing — run \`node scripts/build-atlas-geo.mjs\``);
|
||||
return (admin0Gz = null);
|
||||
}
|
||||
return (admin0Gz = fs.readFileSync(file));
|
||||
}
|
||||
// for server-side point-in-polygon (see buildCountryIndexes in utils/country-borders.ts),
|
||||
// and admin1 pre-split per country into ready-to-serve GeoJSON strings, built by
|
||||
// streaming the gz so the full bundle is never materialised in one piece.
|
||||
|
||||
/** admin-0 country borders as gzipped GeoJSON bytes, served to the client map with
|
||||
* Content-Encoding: gzip so the server never holds the parsed FeatureCollection. */
|
||||
@@ -104,89 +96,6 @@ function getAdmin1Store(): Promise<Map<string, string>> {
|
||||
return admin1Building;
|
||||
}
|
||||
|
||||
// Feed arbitrary gunzip chunks; invokes onFeature(text) once per top-level Feature object
|
||||
// inside "features":[ … ]. `pending` holds only the unconsumed tail (at most one partial
|
||||
// feature + the current chunk), keeping memory flat regardless of bundle size.
|
||||
function createFeatureSplitter(onFeature: (text: string) => void): (chunk: string) => void {
|
||||
let pending = '';
|
||||
let started = false;
|
||||
// Scan progress is carried across chunks so each character is examined exactly once.
|
||||
// A large Feature (e.g. Canada, ~5.5MB) spans hundreds of gunzip chunks; re-scanning the
|
||||
// accumulated partial from the start on every chunk was O(n²) per feature and pushed the
|
||||
// one-time admin1 build past the 15s test timeout under coverage/CI (#1576-followup).
|
||||
let scanning = false; // currently inside a Feature object?
|
||||
let depth = 0,
|
||||
inStr = false,
|
||||
esc = false;
|
||||
let scanPos = 0,
|
||||
featStart = 0; // resume point + current feature start, in `pending`
|
||||
return (chunk: string) => {
|
||||
pending += chunk;
|
||||
if (!started) {
|
||||
const fi = pending.indexOf('"features"');
|
||||
if (fi === -1) return;
|
||||
const br = pending.indexOf('[', fi);
|
||||
if (br === -1) return;
|
||||
pending = pending.slice(br + 1);
|
||||
started = true;
|
||||
scanPos = 0;
|
||||
}
|
||||
const n = pending.length;
|
||||
while (scanPos < n) {
|
||||
if (!scanning) {
|
||||
const c = pending[scanPos];
|
||||
if (c === ' ' || c === '\n' || c === '\r' || c === '\t' || c === ',') {
|
||||
scanPos++;
|
||||
continue;
|
||||
}
|
||||
if (c === ']') {
|
||||
scanPos = n;
|
||||
break;
|
||||
}
|
||||
if (c !== '{') {
|
||||
scanPos++;
|
||||
continue;
|
||||
}
|
||||
scanning = true;
|
||||
depth = 0;
|
||||
inStr = false;
|
||||
esc = false;
|
||||
featStart = scanPos;
|
||||
}
|
||||
let end = -1;
|
||||
for (let j = scanPos; j < n; j++) {
|
||||
const c = pending[j];
|
||||
if (inStr) {
|
||||
if (esc) esc = false;
|
||||
else if (c === '\\') esc = true;
|
||||
else if (c === '"') inStr = false;
|
||||
} else if (c === '"') inStr = true;
|
||||
else if (c === '{') depth++;
|
||||
else if (c === '}') {
|
||||
if (--depth === 0) {
|
||||
end = j + 1;
|
||||
scanPos = j + 1;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (end === -1) {
|
||||
scanPos = n;
|
||||
break;
|
||||
} // partial feature — resume from n next chunk
|
||||
onFeature(pending.slice(featStart, end));
|
||||
scanning = false;
|
||||
}
|
||||
// Drop the fully-consumed prefix, keeping at most the current partial feature.
|
||||
const keepFrom = scanning ? featStart : scanPos;
|
||||
if (keepFrom > 0) {
|
||||
pending = pending.slice(keepFrom);
|
||||
scanPos -= keepFrom;
|
||||
if (scanning) featStart -= keepFrom;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
function buildAdmin1Store(): Promise<Map<string, string>> {
|
||||
const file = assetPath('admin1');
|
||||
if (!fs.existsSync(file)) {
|
||||
@@ -219,29 +128,7 @@ function buildAdmin1Store(): Promise<Map<string, string>> {
|
||||
});
|
||||
}
|
||||
|
||||
// ── Bounding-box lookup tables ──────────────────────────────────────────────
|
||||
|
||||
// Territories that have their own ISO code but no admin0 polygon in the bundle.
|
||||
// Without a polygon they can't be point-in-polygon tested, so they rely purely on
|
||||
// their box and win via the smallest-box tie-break in getCountryFromCoords()
|
||||
// (e.g. Hong Kong/Macau over China, Gibraltar over Spain).
|
||||
const MICRO_TERRITORY_BOXES: Record<string, [number, number, number, number]> = {
|
||||
HK: [113.83, 22.15, 114.43, 22.56],
|
||||
MO: [113.53, 22.1, 113.6, 22.21],
|
||||
GI: [-5.36, 36.11, -5.33, 36.16],
|
||||
PR: [-67.3, 17.88, -65.22, 18.53],
|
||||
PS: [34.2, 29.5, 35.6, 32.6],
|
||||
XK: [20.0, 41.9, 21.8, 43.3],
|
||||
};
|
||||
|
||||
// A polygon-less micro-territory box only auto-wins the smallest-box tie-break when it is
|
||||
// TIGHT around the enclave. HK(0.25°²), MO(0.008), GI(0.0015) and PR(1.35) hug their
|
||||
// territory, so a point inside them really is in that territory. PS(4.34) and XK(2.52) are
|
||||
// loose regional rectangles that sprawl across a sovereign neighbour (PS over Israel, XK
|
||||
// over North Macedonia) — a point there usually belongs to the neighbour, so those boxes
|
||||
// must NOT auto-win; they defer to the neighbour's real polygon first (see #1490-class fix
|
||||
// below). This threshold sits between PR and XK.
|
||||
const MICRO_BOX_MAX_AREA = 2.0;
|
||||
// ── Country names, for the address fallback ─────────────────────────────────
|
||||
|
||||
export const NAME_TO_CODE: Record<string, string> = {
|
||||
germany: 'DE',
|
||||
@@ -507,52 +394,7 @@ export async function reverseGeocodeCountry(lat: number, lng: number): Promise<s
|
||||
}
|
||||
}
|
||||
|
||||
// ── Point-in-polygon over the bundled admin0 borders (#1331) ─────────────────
|
||||
|
||||
// Ray-casting (even-odd) test of (lng,lat) against a single GeoJSON ring.
|
||||
// Ray-cast on a flat [lng,lat,lng,lat,…] ring. Same algorithm as the classic number[][]
|
||||
// version, but the coordinates live in a Float64Array so the parsed admin0 geometry (with
|
||||
// its millions of tiny [lng,lat] arrays, ~145MB) need not be retained — only these rings.
|
||||
function pointInFlatRing(lng: number, lat: number, ring: Float64Array): boolean {
|
||||
let inside = false;
|
||||
const n = ring.length / 2;
|
||||
for (let i = 0, j = n - 1; i < n; j = i++) {
|
||||
const xi = ring[2 * i],
|
||||
yi = ring[2 * i + 1];
|
||||
const xj = ring[2 * j],
|
||||
yj = ring[2 * j + 1];
|
||||
if (yi > lat !== yj > lat && lng < ((xj - xi) * (lat - yi)) / (yj - yi) + xi) {
|
||||
inside = !inside;
|
||||
}
|
||||
}
|
||||
return inside;
|
||||
}
|
||||
|
||||
// True when (lng,lat) falls inside a compact Polygon/MultiPolygon, honouring holes.
|
||||
// `rings` is every ring flattened; `polyRingCounts[k]` is how many rings polygon k owns
|
||||
// (its first ring is the outer boundary, the rest are holes).
|
||||
function pointInGeometry(lng: number, lat: number, geom: CompactGeom): boolean {
|
||||
let ri = 0;
|
||||
for (const rc of geom.polyRingCounts) {
|
||||
if (pointInFlatRing(lng, lat, geom.rings[ri])) {
|
||||
let inHole = false;
|
||||
for (let h = 1; h < rc; h++) {
|
||||
if (pointInFlatRing(lng, lat, geom.rings[ri + h])) {
|
||||
inHole = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!inHole) return true;
|
||||
}
|
||||
ri += rc;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
// Compact polygon geometry: rings flattened into Float64Arrays, grouped into polygons by
|
||||
// polyRingCounts. Replaces the retained parsed GeoJSON geometry.
|
||||
type CompactGeom = { rings: Float64Array[]; polyRingCounts: number[] };
|
||||
type Box = [number, number, number, number]; // [minLng, minLat, maxLng, maxLat]
|
||||
// ── Region geometry, for the ring test in utils/country-borders.ts ──────────
|
||||
|
||||
// Flatten a GeoJSON Polygon/MultiPolygon into the same compact form the country index uses,
|
||||
// plus one bounding box per part (a part-box, like buildCountryIndexes builds, so an
|
||||
@@ -586,148 +428,6 @@ function compactGeomFromGeometry(geometry: { type: string; coordinates: unknown
|
||||
return { geom: { rings, polyRingCounts }, boxes };
|
||||
}
|
||||
|
||||
// ISO_A2 → compact admin0 geometry + bounding boxes, derived from the bundled admin0
|
||||
// borders on first use. The parsed FeatureCollection is dropped after this runs; only the
|
||||
// Float64Array rings and boxes are retained (≈1MB vs the ≈145MB parsed geometry, #1576).
|
||||
//
|
||||
// The boxes used to be a hand-maintained table, which drifted: 43 countries (NG, BY,
|
||||
// GL, KP, TD, SS, …) had no box at all, so their coordinates fell into a *neighbour's*
|
||||
// box instead and resolved to the wrong country — Lagos came out as Benin, Minsk as
|
||||
// Russia (#1490). Deriving them from the same polygons we already ship keeps the two
|
||||
// in lockstep and can't drift again.
|
||||
//
|
||||
// One box is stored PER GEOMETRY PART, not per country. A single box around a country
|
||||
// that straddles the antimeridian (RU, US, FJ, KI) would span nearly the whole globe;
|
||||
// per-part boxes keep Alaska and Chukotka separate and handle the ±180 wrap for free.
|
||||
let countryPolyIndex: Map<string, CompactGeom> | null = null;
|
||||
let countryBoxIndex: Map<string, Box[]> | null = null;
|
||||
|
||||
function buildCountryIndexes(): void {
|
||||
const polys = new Map<string, CompactGeom>();
|
||||
const boxes = new Map<string, Box[]>();
|
||||
|
||||
const gz = loadAdmin0Gz();
|
||||
if (gz) {
|
||||
// Parse ONE feature at a time off the gunzipped string rather than JSON.parse-ing the
|
||||
// whole FeatureCollection: the full parse transiently allocates ~285MB (the 145MB
|
||||
// object graph plus intermediates) and V8 keeps those pages, which alone exhausts a
|
||||
// 512MB host before admin1 even loads (#1576).
|
||||
const json = zlib.gunzipSync(gz).toString('utf8');
|
||||
const consume = createFeatureSplitter((text) => {
|
||||
const f = JSON.parse(text);
|
||||
const raw = f.properties?.ISO_A2;
|
||||
if (!raw || raw === '-99' || !f.geometry) return;
|
||||
const code = String(raw).toUpperCase();
|
||||
|
||||
const parts = (
|
||||
f.geometry.type === 'Polygon' ? [f.geometry.coordinates] : f.geometry.coordinates
|
||||
) as number[][][][];
|
||||
const rings: Float64Array[] = [];
|
||||
const polyRingCounts: number[] = [];
|
||||
const codeBoxes = boxes.get(code) ?? [];
|
||||
for (const part of parts) {
|
||||
polyRingCounts.push(part.length);
|
||||
for (const ring of part) {
|
||||
const flat = new Float64Array(ring.length * 2);
|
||||
for (let i = 0; i < ring.length; i++) {
|
||||
flat[2 * i] = ring[i][0];
|
||||
flat[2 * i + 1] = ring[i][1];
|
||||
}
|
||||
rings.push(flat);
|
||||
}
|
||||
// Bounding box from the part's outer ring (part[0]).
|
||||
let minLng = Infinity,
|
||||
minLat = Infinity,
|
||||
maxLng = -Infinity,
|
||||
maxLat = -Infinity;
|
||||
for (const [lng, lat] of part[0]) {
|
||||
if (lng < minLng) minLng = lng;
|
||||
if (lng > maxLng) maxLng = lng;
|
||||
if (lat < minLat) minLat = lat;
|
||||
if (lat > maxLat) maxLat = lat;
|
||||
}
|
||||
codeBoxes.push([minLng, minLat, maxLng, maxLat]);
|
||||
}
|
||||
// Matches the previous index exactly: geometry is overwritten (last feature for a
|
||||
// code wins) while boxes accumulate across a code's features.
|
||||
polys.set(code, { rings, polyRingCounts });
|
||||
boxes.set(code, codeBoxes);
|
||||
});
|
||||
consume(json);
|
||||
}
|
||||
|
||||
// Micro-territories aren't in admin0 — give them their box, but no polygon.
|
||||
for (const [code, box] of Object.entries(MICRO_TERRITORY_BOXES)) {
|
||||
if (!boxes.has(code)) boxes.set(code, [box]);
|
||||
}
|
||||
|
||||
countryPolyIndex = polys;
|
||||
countryBoxIndex = boxes;
|
||||
}
|
||||
|
||||
function getCountryPolyIndex(): Map<string, CompactGeom> {
|
||||
if (!countryPolyIndex) buildCountryIndexes();
|
||||
return countryPolyIndex!;
|
||||
}
|
||||
|
||||
function getCountryBoxIndex(): Map<string, Box[]> {
|
||||
if (!countryBoxIndex) buildCountryIndexes();
|
||||
return countryBoxIndex!;
|
||||
}
|
||||
|
||||
// Broad sanity check — is (lat,lng) anywhere within the country's own admin0 bounding
|
||||
// box(es)? Deliberately looser than the polygon test in getCountryFromCoords: a genuine
|
||||
// border-simplification miss (a point just outside the exact border) still needs to pass
|
||||
// this, so it only rejects a country that isn't even in the right part of the globe. Used
|
||||
// to gate the address-derived fallback in both country and region resolution.
|
||||
export function isPointInCountryBox(countryCode: string, lat: number, lng: number): boolean {
|
||||
const boxes = getCountryBoxIndex().get(countryCode.toUpperCase());
|
||||
if (!boxes) return false;
|
||||
return boxes.some(([minLng, minLat, maxLng, maxLat]) => lat >= minLat && lat <= maxLat && lng >= minLng && lng <= maxLng);
|
||||
}
|
||||
|
||||
export function getCountryFromCoords(lat: number, lng: number): string | null {
|
||||
// Cheap prefilter: every country with a part-box containing the point. Keep the
|
||||
// area of the matching part so overlapping candidates can be ranked below.
|
||||
const candidates: { code: string; area: number }[] = [];
|
||||
for (const [code, boxes] of getCountryBoxIndex()) {
|
||||
for (const [minLng, minLat, maxLng, maxLat] of boxes) {
|
||||
if (lat >= minLat && lat <= maxLat && lng >= minLng && lng <= maxLng) {
|
||||
candidates.push({ code, area: (maxLng - minLng) * (maxLat - minLat) });
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (candidates.length === 0) return null;
|
||||
|
||||
// Boxes overlap near borders, so a point can sit in several — picking the smallest
|
||||
// box alone mis-assigns a point just across the border (#1331). Disambiguate with
|
||||
// the real admin0 polygon: try candidates smallest-box-first and return the one whose
|
||||
// polygon actually contains the point. A candidate with no polygon (a micro-territory
|
||||
// like HK/MO/GI) keeps the smallest-box win — but only when its box is tight enough to
|
||||
// trust (MICRO_BOX_MAX_AREA); a loose regional box (PS/XK) defers to a real neighbour
|
||||
// polygon so it can't steal a point that lies inside that sovereign (Tel Aviv → IL,
|
||||
// Skopje → MK), while a genuine PS/XK point still lands on the deferred box below.
|
||||
//
|
||||
// This runs even for a lone candidate. Short-circuiting a single match was what let a
|
||||
// point resolve to a country whose polygon plainly excludes it (#1490).
|
||||
candidates.sort((a, b) => a.area - b.area);
|
||||
const polys = getCountryPolyIndex();
|
||||
let looseBoxFallback: string | null = null;
|
||||
for (const { code, area } of candidates) {
|
||||
const poly = polys.get(code);
|
||||
if (!poly) {
|
||||
if (area <= MICRO_BOX_MAX_AREA) return code;
|
||||
if (looseBoxFallback === null) looseBoxFallback = code;
|
||||
continue;
|
||||
}
|
||||
if (pointInGeometry(lng, lat, poly)) return code;
|
||||
}
|
||||
// No tight micro-box and no polygon contained the point — prefer a deferred loose box
|
||||
// (a real PS/XK point), else fall back to the smallest box (coastal slop / data gap).
|
||||
return looseBoxFallback ?? candidates[0].code;
|
||||
}
|
||||
|
||||
/**
|
||||
* `allowBareCode` decides whether a trailing two-letter uppercase segment counts as
|
||||
* a country. It only does when the caller can sanity-check the answer against
|
||||
|
||||
@@ -164,7 +164,7 @@ function markedSource(row: { source?: string | null } | undefined): string | nul
|
||||
* The pure-geo machinery (bundled admin0/admin1 boundaries, point-in-polygon
|
||||
* indexes, Nominatim geocoding and its caches) lives in ./atlas-geo — a plain
|
||||
* module so the multi-MB caches stay process-global across the container
|
||||
* instance and test helpers (#1576).
|
||||
* instance and test helpers (#1576). The country lookup on the admin-0 borders sits in utils/country-borders.ts.
|
||||
* Returns native service shapes; the client-facing contracts live in
|
||||
* @trek/shared. No broadcasts: atlas rows are uid-scoped, not trip-scoped.
|
||||
*/
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
* need no branch per source. Pure: the calls themselves are trek-places.client's,
|
||||
* the decision to make them is MapsService's.
|
||||
*/
|
||||
import { getCountryFromCoords } from '../atlas/atlas-geo';
|
||||
import { getCountryFromCoords } from '../../utils/country-borders';
|
||||
import { buildOsmDetails, parsePoiCategories, samePlace, toApiLang } from './maps.helpers';
|
||||
import { POI_RESULT_CAP, type PoiSearchResult } from './providers/osm.client';
|
||||
import {
|
||||
|
||||
@@ -0,0 +1,342 @@
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import zlib from 'zlib';
|
||||
|
||||
// ── The bundled admin-0 borders and the country a point lies in ─────────────
|
||||
//
|
||||
// Plain module on purpose, like nest/atlas/atlas-geo.ts it was taken out of: no DI,
|
||||
// no DB access, no Nest imports. It sits outside the domains because more than one
|
||||
// of them asks which country a coordinate lies in: atlas, journey and integrations
|
||||
// (through the re-export in atlas-geo) for the countries of places and visits, maps
|
||||
// for the country an index suggestion names. Importing it from atlas tied maps into
|
||||
// an import cycle with atlas (lint:boundaries).
|
||||
//
|
||||
// atlas-geo builds its admin-1 region index on the asset path, the feature splitter
|
||||
// and the ring test exported here. The admin0 gz bytes and the country poly/box
|
||||
// indexes below are file-scoped for the reason atlas-geo gives: their lifetime stays
|
||||
// process-global however many callers there are, so the indexes are built once per
|
||||
// process, which the #1576 OOM fix depends on.
|
||||
|
||||
// ── Bundled boundary GeoJSON (admin-0 countries + admin-1 regions) ─────────
|
||||
//
|
||||
// Sourced from geoBoundaries (CC BY 4.0), normalized + quantized offline by
|
||||
// scripts/build-atlas-geo.mjs into gzipped FeatureCollections under server/assets.
|
||||
// They are read + decompressed once and cached in memory — no network at runtime.
|
||||
// (Replaces the previous runtime fetch of Natural Earth, which was stale for recent
|
||||
// sub-national reforms and depicts some contested borders in unwanted ways.)
|
||||
//
|
||||
// __dirname is server/dist/utils at runtime and server/src/utils under vitest;
|
||||
// both resolve ../../assets to server/assets.
|
||||
|
||||
export function assetPath(name: 'admin0' | 'admin1'): string {
|
||||
return path.join(__dirname, '..', '..', 'assets', 'atlas', `${name}.geojson.gz`);
|
||||
}
|
||||
|
||||
let admin0Gz: Buffer | null | undefined;
|
||||
export function loadAdmin0Gz(): Buffer | null {
|
||||
if (admin0Gz !== undefined) return admin0Gz;
|
||||
const file = assetPath('admin0');
|
||||
if (!fs.existsSync(file)) {
|
||||
console.warn(`[Atlas] admin0.geojson.gz missing — run \`node scripts/build-atlas-geo.mjs\``);
|
||||
return (admin0Gz = null);
|
||||
}
|
||||
return (admin0Gz = fs.readFileSync(file));
|
||||
}
|
||||
|
||||
// Feed arbitrary gunzip chunks; invokes onFeature(text) once per top-level Feature object
|
||||
// inside "features":[ … ]. `pending` holds only the unconsumed tail (at most one partial
|
||||
// feature + the current chunk), keeping memory flat regardless of bundle size.
|
||||
export function createFeatureSplitter(onFeature: (text: string) => void): (chunk: string) => void {
|
||||
let pending = '';
|
||||
let started = false;
|
||||
// Scan progress is carried across chunks so each character is examined exactly once.
|
||||
// A large Feature (e.g. Canada, ~5.5MB) spans hundreds of gunzip chunks; re-scanning the
|
||||
// accumulated partial from the start on every chunk was O(n²) per feature and pushed the
|
||||
// one-time admin1 build past the 15s test timeout under coverage/CI (#1576-followup).
|
||||
let scanning = false; // currently inside a Feature object?
|
||||
let depth = 0,
|
||||
inStr = false,
|
||||
esc = false;
|
||||
let scanPos = 0,
|
||||
featStart = 0; // resume point + current feature start, in `pending`
|
||||
return (chunk: string) => {
|
||||
pending += chunk;
|
||||
if (!started) {
|
||||
const fi = pending.indexOf('"features"');
|
||||
if (fi === -1) return;
|
||||
const br = pending.indexOf('[', fi);
|
||||
if (br === -1) return;
|
||||
pending = pending.slice(br + 1);
|
||||
started = true;
|
||||
scanPos = 0;
|
||||
}
|
||||
const n = pending.length;
|
||||
while (scanPos < n) {
|
||||
if (!scanning) {
|
||||
const c = pending[scanPos];
|
||||
if (c === ' ' || c === '\n' || c === '\r' || c === '\t' || c === ',') {
|
||||
scanPos++;
|
||||
continue;
|
||||
}
|
||||
if (c === ']') {
|
||||
scanPos = n;
|
||||
break;
|
||||
}
|
||||
if (c !== '{') {
|
||||
scanPos++;
|
||||
continue;
|
||||
}
|
||||
scanning = true;
|
||||
depth = 0;
|
||||
inStr = false;
|
||||
esc = false;
|
||||
featStart = scanPos;
|
||||
}
|
||||
let end = -1;
|
||||
for (let j = scanPos; j < n; j++) {
|
||||
const c = pending[j];
|
||||
if (inStr) {
|
||||
if (esc) esc = false;
|
||||
else if (c === '\\') esc = true;
|
||||
else if (c === '"') inStr = false;
|
||||
} else if (c === '"') inStr = true;
|
||||
else if (c === '{') depth++;
|
||||
else if (c === '}') {
|
||||
if (--depth === 0) {
|
||||
end = j + 1;
|
||||
scanPos = j + 1;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (end === -1) {
|
||||
scanPos = n;
|
||||
break;
|
||||
} // partial feature — resume from n next chunk
|
||||
onFeature(pending.slice(featStart, end));
|
||||
scanning = false;
|
||||
}
|
||||
// Drop the fully-consumed prefix, keeping at most the current partial feature.
|
||||
const keepFrom = scanning ? featStart : scanPos;
|
||||
if (keepFrom > 0) {
|
||||
pending = pending.slice(keepFrom);
|
||||
scanPos -= keepFrom;
|
||||
if (scanning) featStart -= keepFrom;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
// ── Bounding-box lookup tables ──────────────────────────────────────────────
|
||||
|
||||
// Territories that have their own ISO code but no admin0 polygon in the bundle.
|
||||
// Without a polygon they can't be point-in-polygon tested, so they rely purely on
|
||||
// their box and win via the smallest-box tie-break in getCountryFromCoords()
|
||||
// (e.g. Hong Kong/Macau over China, Gibraltar over Spain).
|
||||
const MICRO_TERRITORY_BOXES: Record<string, [number, number, number, number]> = {
|
||||
HK: [113.83, 22.15, 114.43, 22.56],
|
||||
MO: [113.53, 22.1, 113.6, 22.21],
|
||||
GI: [-5.36, 36.11, -5.33, 36.16],
|
||||
PR: [-67.3, 17.88, -65.22, 18.53],
|
||||
PS: [34.2, 29.5, 35.6, 32.6],
|
||||
XK: [20.0, 41.9, 21.8, 43.3],
|
||||
};
|
||||
|
||||
// A polygon-less micro-territory box only auto-wins the smallest-box tie-break when it is
|
||||
// TIGHT around the enclave. HK(0.25°²), MO(0.008), GI(0.0015) and PR(1.35) hug their
|
||||
// territory, so a point inside them really is in that territory. PS(4.34) and XK(2.52) are
|
||||
// loose regional rectangles that sprawl across a sovereign neighbour (PS over Israel, XK
|
||||
// over North Macedonia) — a point there usually belongs to the neighbour, so those boxes
|
||||
// must NOT auto-win; they defer to the neighbour's real polygon first (see #1490-class fix
|
||||
// below). This threshold sits between PR and XK.
|
||||
const MICRO_BOX_MAX_AREA = 2.0;
|
||||
|
||||
// ── Point-in-polygon over the bundled admin0 borders (#1331) ─────────────────
|
||||
|
||||
// Ray-casting (even-odd) test of (lng,lat) against a single GeoJSON ring.
|
||||
// Ray-cast on a flat [lng,lat,lng,lat,…] ring. Same algorithm as the classic number[][]
|
||||
// version, but the coordinates live in a Float64Array so the parsed admin0 geometry (with
|
||||
// its millions of tiny [lng,lat] arrays, ~145MB) need not be retained — only these rings.
|
||||
function pointInFlatRing(lng: number, lat: number, ring: Float64Array): boolean {
|
||||
let inside = false;
|
||||
const n = ring.length / 2;
|
||||
for (let i = 0, j = n - 1; i < n; j = i++) {
|
||||
const xi = ring[2 * i],
|
||||
yi = ring[2 * i + 1];
|
||||
const xj = ring[2 * j],
|
||||
yj = ring[2 * j + 1];
|
||||
if (yi > lat !== yj > lat && lng < ((xj - xi) * (lat - yi)) / (yj - yi) + xi) {
|
||||
inside = !inside;
|
||||
}
|
||||
}
|
||||
return inside;
|
||||
}
|
||||
|
||||
// True when (lng,lat) falls inside a compact Polygon/MultiPolygon, honouring holes.
|
||||
// `rings` is every ring flattened; `polyRingCounts[k]` is how many rings polygon k owns
|
||||
// (its first ring is the outer boundary, the rest are holes).
|
||||
export function pointInGeometry(lng: number, lat: number, geom: CompactGeom): boolean {
|
||||
let ri = 0;
|
||||
for (const rc of geom.polyRingCounts) {
|
||||
if (pointInFlatRing(lng, lat, geom.rings[ri])) {
|
||||
let inHole = false;
|
||||
for (let h = 1; h < rc; h++) {
|
||||
if (pointInFlatRing(lng, lat, geom.rings[ri + h])) {
|
||||
inHole = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!inHole) return true;
|
||||
}
|
||||
ri += rc;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
// Compact polygon geometry: rings flattened into Float64Arrays, grouped into polygons by
|
||||
// polyRingCounts. Replaces the retained parsed GeoJSON geometry.
|
||||
export type CompactGeom = { rings: Float64Array[]; polyRingCounts: number[] };
|
||||
export type Box = [number, number, number, number]; // [minLng, minLat, maxLng, maxLat]
|
||||
|
||||
// ISO_A2 → compact admin0 geometry + bounding boxes, derived from the bundled admin0
|
||||
// borders on first use. The parsed FeatureCollection is dropped after this runs; only the
|
||||
// Float64Array rings and boxes are retained (≈1MB vs the ≈145MB parsed geometry, #1576).
|
||||
//
|
||||
// The boxes used to be a hand-maintained table, which drifted: 43 countries (NG, BY,
|
||||
// GL, KP, TD, SS, …) had no box at all, so their coordinates fell into a *neighbour's*
|
||||
// box instead and resolved to the wrong country — Lagos came out as Benin, Minsk as
|
||||
// Russia (#1490). Deriving them from the same polygons we already ship keeps the two
|
||||
// in lockstep and can't drift again.
|
||||
//
|
||||
// One box is stored PER GEOMETRY PART, not per country. A single box around a country
|
||||
// that straddles the antimeridian (RU, US, FJ, KI) would span nearly the whole globe;
|
||||
// per-part boxes keep Alaska and Chukotka separate and handle the ±180 wrap for free.
|
||||
let countryPolyIndex: Map<string, CompactGeom> | null = null;
|
||||
let countryBoxIndex: Map<string, Box[]> | null = null;
|
||||
|
||||
function buildCountryIndexes(): void {
|
||||
const polys = new Map<string, CompactGeom>();
|
||||
const boxes = new Map<string, Box[]>();
|
||||
|
||||
const gz = loadAdmin0Gz();
|
||||
if (gz) {
|
||||
// Parse ONE feature at a time off the gunzipped string rather than JSON.parse-ing the
|
||||
// whole FeatureCollection: the full parse transiently allocates ~285MB (the 145MB
|
||||
// object graph plus intermediates) and V8 keeps those pages, which alone exhausts a
|
||||
// 512MB host before admin1 even loads (#1576).
|
||||
const json = zlib.gunzipSync(gz).toString('utf8');
|
||||
const consume = createFeatureSplitter((text) => {
|
||||
const f = JSON.parse(text);
|
||||
const raw = f.properties?.ISO_A2;
|
||||
if (!raw || raw === '-99' || !f.geometry) return;
|
||||
const code = String(raw).toUpperCase();
|
||||
|
||||
const parts = (
|
||||
f.geometry.type === 'Polygon' ? [f.geometry.coordinates] : f.geometry.coordinates
|
||||
) as number[][][][];
|
||||
const rings: Float64Array[] = [];
|
||||
const polyRingCounts: number[] = [];
|
||||
const codeBoxes = boxes.get(code) ?? [];
|
||||
for (const part of parts) {
|
||||
polyRingCounts.push(part.length);
|
||||
for (const ring of part) {
|
||||
const flat = new Float64Array(ring.length * 2);
|
||||
for (let i = 0; i < ring.length; i++) {
|
||||
flat[2 * i] = ring[i][0];
|
||||
flat[2 * i + 1] = ring[i][1];
|
||||
}
|
||||
rings.push(flat);
|
||||
}
|
||||
// Bounding box from the part's outer ring (part[0]).
|
||||
let minLng = Infinity,
|
||||
minLat = Infinity,
|
||||
maxLng = -Infinity,
|
||||
maxLat = -Infinity;
|
||||
for (const [lng, lat] of part[0]) {
|
||||
if (lng < minLng) minLng = lng;
|
||||
if (lng > maxLng) maxLng = lng;
|
||||
if (lat < minLat) minLat = lat;
|
||||
if (lat > maxLat) maxLat = lat;
|
||||
}
|
||||
codeBoxes.push([minLng, minLat, maxLng, maxLat]);
|
||||
}
|
||||
// Matches the previous index exactly: geometry is overwritten (last feature for a
|
||||
// code wins) while boxes accumulate across a code's features.
|
||||
polys.set(code, { rings, polyRingCounts });
|
||||
boxes.set(code, codeBoxes);
|
||||
});
|
||||
consume(json);
|
||||
}
|
||||
|
||||
// Micro-territories aren't in admin0 — give them their box, but no polygon.
|
||||
for (const [code, box] of Object.entries(MICRO_TERRITORY_BOXES)) {
|
||||
if (!boxes.has(code)) boxes.set(code, [box]);
|
||||
}
|
||||
|
||||
countryPolyIndex = polys;
|
||||
countryBoxIndex = boxes;
|
||||
}
|
||||
|
||||
function getCountryPolyIndex(): Map<string, CompactGeom> {
|
||||
if (!countryPolyIndex) buildCountryIndexes();
|
||||
return countryPolyIndex!;
|
||||
}
|
||||
|
||||
function getCountryBoxIndex(): Map<string, Box[]> {
|
||||
if (!countryBoxIndex) buildCountryIndexes();
|
||||
return countryBoxIndex!;
|
||||
}
|
||||
|
||||
// Broad sanity check — is (lat,lng) anywhere within the country's own admin0 bounding
|
||||
// box(es)? Deliberately looser than the polygon test in getCountryFromCoords: a genuine
|
||||
// border-simplification miss (a point just outside the exact border) still needs to pass
|
||||
// this, so it only rejects a country that isn't even in the right part of the globe. Used
|
||||
// to gate the address-derived fallback in both country and region resolution.
|
||||
export function isPointInCountryBox(countryCode: string, lat: number, lng: number): boolean {
|
||||
const boxes = getCountryBoxIndex().get(countryCode.toUpperCase());
|
||||
if (!boxes) return false;
|
||||
return boxes.some(
|
||||
([minLng, minLat, maxLng, maxLat]) => lat >= minLat && lat <= maxLat && lng >= minLng && lng <= maxLng,
|
||||
);
|
||||
}
|
||||
|
||||
export function getCountryFromCoords(lat: number, lng: number): string | null {
|
||||
// Cheap prefilter: every country with a part-box containing the point. Keep the
|
||||
// area of the matching part so overlapping candidates can be ranked below.
|
||||
const candidates: { code: string; area: number }[] = [];
|
||||
for (const [code, boxes] of getCountryBoxIndex()) {
|
||||
for (const [minLng, minLat, maxLng, maxLat] of boxes) {
|
||||
if (lat >= minLat && lat <= maxLat && lng >= minLng && lng <= maxLng) {
|
||||
candidates.push({ code, area: (maxLng - minLng) * (maxLat - minLat) });
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (candidates.length === 0) return null;
|
||||
|
||||
// Boxes overlap near borders, so a point can sit in several — picking the smallest
|
||||
// box alone mis-assigns a point just across the border (#1331). Disambiguate with
|
||||
// the real admin0 polygon: try candidates smallest-box-first and return the one whose
|
||||
// polygon actually contains the point. A candidate with no polygon (a micro-territory
|
||||
// like HK/MO/GI) keeps the smallest-box win — but only when its box is tight enough to
|
||||
// trust (MICRO_BOX_MAX_AREA); a loose regional box (PS/XK) defers to a real neighbour
|
||||
// polygon so it can't steal a point that lies inside that sovereign (Tel Aviv → IL,
|
||||
// Skopje → MK), while a genuine PS/XK point still lands on the deferred box below.
|
||||
//
|
||||
// This runs even for a lone candidate. Short-circuiting a single match was what let a
|
||||
// point resolve to a country whose polygon plainly excludes it (#1490).
|
||||
candidates.sort((a, b) => a.area - b.area);
|
||||
const polys = getCountryPolyIndex();
|
||||
let looseBoxFallback: string | null = null;
|
||||
for (const { code, area } of candidates) {
|
||||
const poly = polys.get(code);
|
||||
if (!poly) {
|
||||
if (area <= MICRO_BOX_MAX_AREA) return code;
|
||||
if (looseBoxFallback === null) looseBoxFallback = code;
|
||||
continue;
|
||||
}
|
||||
if (pointInGeometry(lng, lat, poly)) return code;
|
||||
}
|
||||
// No tight micro-box and no polygon contained the point — prefer a deferred loose box
|
||||
// (a real PS/XK point), else fall back to the smallest box (coastal slop / data gap).
|
||||
return looseBoxFallback ?? candidates[0].code;
|
||||
}
|
||||
Reference in New Issue
Block a user