From 0711efcb4b96eb5c4319bc3ff6c596d41065264b Mon Sep 17 00:00:00 2001 From: Maurice Date: Mon, 12 Oct 2026 21:13:58 +0000 Subject: [PATCH] refactor(maps): the country lookup moves to utils, so maps no longer reaches into atlas --- server/src/nest/atlas/atlas-geo.ts | 356 ++------------------- server/src/nest/atlas/atlas.service.ts | 2 +- server/src/nest/maps/maps-index.helpers.ts | 2 +- server/src/utils/country-borders.ts | 342 ++++++++++++++++++++ 4 files changed, 372 insertions(+), 330 deletions(-) create mode 100644 server/src/utils/country-borders.ts diff --git a/server/src/nest/atlas/atlas-geo.ts b/server/src/nest/atlas/atlas-geo.ts index b7c0540d9..7653fa506 100644 --- a/server/src/nest/atlas/atlas-geo.ts +++ b/server/src/nest/atlas/atlas-geo.ts @@ -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> { 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> { const file = assetPath('admin1'); if (!fs.existsSync(file)) { @@ -219,29 +128,7 @@ function buildAdmin1Store(): Promise> { }); } -// ── 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 = { - 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 = { germany: 'DE', @@ -507,52 +394,7 @@ export async function reverseGeocodeCountry(lat: number, lng: number): Promise 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 | null = null; -let countryBoxIndex: Map | null = null; - -function buildCountryIndexes(): void { - const polys = new Map(); - const boxes = new Map(); - - 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 { - if (!countryPolyIndex) buildCountryIndexes(); - return countryPolyIndex!; -} - -function getCountryBoxIndex(): Map { - 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 diff --git a/server/src/nest/atlas/atlas.service.ts b/server/src/nest/atlas/atlas.service.ts index bd8acf07f..6d9499f8b 100644 --- a/server/src/nest/atlas/atlas.service.ts +++ b/server/src/nest/atlas/atlas.service.ts @@ -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. */ diff --git a/server/src/nest/maps/maps-index.helpers.ts b/server/src/nest/maps/maps-index.helpers.ts index 62b907204..520b24b8d 100644 --- a/server/src/nest/maps/maps-index.helpers.ts +++ b/server/src/nest/maps/maps-index.helpers.ts @@ -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 { diff --git a/server/src/utils/country-borders.ts b/server/src/utils/country-borders.ts new file mode 100644 index 000000000..b184130b1 --- /dev/null +++ b/server/src/utils/country-borders.ts @@ -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 = { + 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 | null = null; +let countryBoxIndex: Map | null = null; + +function buildCountryIndexes(): void { + const polys = new Map(); + const boxes = new Map(); + + 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 { + if (!countryPolyIndex) buildCountryIndexes(); + return countryPolyIndex!; +} + +function getCountryBoxIndex(): Map { + 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; +}