From 4cb9b18cc6f71bc12eb21116d25817dec34c4501 Mon Sep 17 00:00:00 2001 From: Maurice Date: Sat, 27 Jun 2026 16:15:53 +0200 Subject: [PATCH] fix(atlas): assign border places by polygon, not just bounding box (#1331) getCountryFromCoords picked the country with the smallest bounding box containing the point, so a place just across a border (e.g. Strasbourg, which sits inside both the FR and DE boxes) landed in the wrong, smaller-box country. When more than one country box matches, it now disambiguates with the real admin0 polygon via point-in-polygon, smallest-box-first; a micro-territory with no admin0 polygon (HK, MO, SM, VA, ...) keeps the smallest-box win, and an unmatched point falls back to the old behaviour. The common single-candidate case is unchanged. --- server/src/services/atlasService.ts | 77 ++++++++++++++++--- .../tests/unit/services/atlasService.test.ts | 17 ++++ 2 files changed, 83 insertions(+), 11 deletions(-) diff --git a/server/src/services/atlasService.ts b/server/src/services/atlasService.ts index 5577492c..72ee66e7 100644 --- a/server/src/services/atlasService.ts +++ b/server/src/services/atlasService.ts @@ -199,19 +199,74 @@ export async function reverseGeocodeCountry(lat: number, lng: number): Promise= minLat && lat <= maxLat && lng >= minLng && lng <= maxLng) { - const area = (maxLng - minLng) * (maxLat - minLat); - if (area < bestArea) { - bestArea = area; - bestCode = code; - } +// ── Point-in-polygon over the bundled admin0 borders (#1331) ───────────────── + +// Ray-casting (even-odd) test of (lng,lat) against a single GeoJSON ring. +function pointInRing(lng: number, lat: number, ring: number[][]): boolean { + let inside = false; + for (let i = 0, j = ring.length - 1; i < ring.length; j = i++) { + const xi = ring[i][0], yi = ring[i][1]; + const xj = ring[j][0], yj = ring[j][1]; + if (((yi > lat) !== (yj > lat)) && (lng < ((xj - xi) * (lat - yi)) / (yj - yi) + xi)) { + inside = !inside; } } - return bestCode; + return inside; +} + +// True when (lng,lat) falls inside a Polygon/MultiPolygon, honouring holes. +function pointInGeometry(lng: number, lat: number, geom: { type: string; coordinates: number[][][] | number[][][][] }): boolean { + const polygons = (geom.type === 'Polygon' ? [geom.coordinates] : geom.coordinates) as number[][][][]; + for (const poly of polygons) { + if (!pointInRing(lng, lat, poly[0])) continue; + let inHole = false; + for (let h = 1; h < poly.length; h++) { + if (pointInRing(lng, lat, poly[h])) { inHole = true; break; } + } + if (!inHole) return true; + } + return false; +} + +// ISO_A2 → admin0 geometry, built once. Micro-territories (HK, MO, SM, VA, …) aren't +// in admin0, so they stay absent and keep the smallest-box behaviour below. +let countryPolyIndex: Map | null = null; +function getCountryPolyIndex(): Map { + if (countryPolyIndex) return countryPolyIndex; + const idx = new Map(); + for (const f of loadGeoBundle('admin0').features ?? []) { + const code = f.properties?.ISO_A2; + if (code && code !== '-99' && f.geometry) idx.set(String(code).toUpperCase(), f.geometry); + } + countryPolyIndex = idx; + return idx; +} + +export function getCountryFromCoords(lat: number, lng: number): string | null { + // Cheap prefilter: every country whose bounding box contains the point. + const candidates: { code: string; area: number }[] = []; + for (const [code, [minLng, minLat, maxLng, maxLat]] of Object.entries(COUNTRY_BOXES)) { + if (lat >= minLat && lat <= maxLat && lng >= minLng && lng <= maxLng) { + candidates.push({ code, area: (maxLng - minLng) * (maxLat - minLat) }); + } + } + if (candidates.length === 0) return null; + if (candidates.length === 1) return candidates[0].code; + + // Boxes overlap near borders, so a single point can sit in several — picking the + // smallest box then 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 admin0 polygon (a + // micro-territory like HK/MO/SM/VA) keeps the smallest-box win. + candidates.sort((a, b) => a.area - b.area); + const polys = getCountryPolyIndex(); + for (const { code } of candidates) { + const poly = polys.get(code); + if (!poly) return code; + if (pointInGeometry(lng, lat, poly)) return code; + } + // No polygon contained the point (coastal slop / data gap) — fall back to smallest box. + return candidates[0].code; } export function getCountryFromAddress(address: string | null): string | null { diff --git a/server/tests/unit/services/atlasService.test.ts b/server/tests/unit/services/atlasService.test.ts index b3d9cb4d..0d659d07 100644 --- a/server/tests/unit/services/atlasService.test.ts +++ b/server/tests/unit/services/atlasService.test.ts @@ -171,6 +171,23 @@ describe('getCountryFromCoords', () => { const code = getCountryFromCoords(0.0, 0.0); expect(code).toBeNull(); }); + + it('ATLAS-SVC-005b: #1331 a point inside France near the German border resolves to FR, not the smaller overlapping box', () => { + // Strasbourg (48.573, 7.752) sits inside BOTH the FR and DE bounding boxes; the old + // smallest-box rule mis-picked DE (its box is smaller). Point-in-polygon picks FR. + expect(getCountryFromCoords(48.5734, 7.7521)).toBe('FR'); + }); + + it('ATLAS-SVC-005c: #1331 a point inside Germany near the French border resolves to DE', () => { + // Kehl (48.575, 7.815) — the German side of the same border. + expect(getCountryFromCoords(48.5750, 7.8150)).toBe('DE'); + }); + + it('ATLAS-SVC-005d: #1331 a micro-territory without an admin0 polygon keeps the smallest-box win (Hong Kong)', () => { + // HK is not a separate admin0 polygon (it falls inside CN there), so the smallest + // bounding box still wins for it. + expect(getCountryFromCoords(22.30, 114.17)).toBe('HK'); + }); }); // ── getCountryFromAddress ───────────────────────────────────────────────────