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.
This commit is contained in:
Maurice
2026-06-27 16:15:53 +02:00
committed by Maurice
parent f3b54166fb
commit 4cb9b18cc6
2 changed files with 83 additions and 11 deletions
+66 -11
View File
@@ -199,19 +199,74 @@ export async function reverseGeocodeCountry(lat: number, lng: number): Promise<s
} }
} }
export function getCountryFromCoords(lat: number, lng: number): string | null { // ── Point-in-polygon over the bundled admin0 borders (#1331) ─────────────────
let bestCode: string | null = null;
let bestArea = Infinity; // Ray-casting (even-odd) test of (lng,lat) against a single GeoJSON ring.
for (const [code, [minLng, minLat, maxLng, maxLat]] of Object.entries(COUNTRY_BOXES)) { function pointInRing(lng: number, lat: number, ring: number[][]): boolean {
if (lat >= minLat && lat <= maxLat && lng >= minLng && lng <= maxLng) { let inside = false;
const area = (maxLng - minLng) * (maxLat - minLat); for (let i = 0, j = ring.length - 1; i < ring.length; j = i++) {
if (area < bestArea) { const xi = ring[i][0], yi = ring[i][1];
bestArea = area; const xj = ring[j][0], yj = ring[j][1];
bestCode = code; 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<string, { type: string; coordinates: number[][][] | number[][][][] }> | null = null;
function getCountryPolyIndex(): Map<string, { type: string; coordinates: number[][][] | number[][][][] }> {
if (countryPolyIndex) return countryPolyIndex;
const idx = new Map<string, { type: string; coordinates: number[][][] | number[][][][] }>();
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 { export function getCountryFromAddress(address: string | null): string | null {
@@ -171,6 +171,23 @@ describe('getCountryFromCoords', () => {
const code = getCountryFromCoords(0.0, 0.0); const code = getCountryFromCoords(0.0, 0.0);
expect(code).toBeNull(); 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 ─────────────────────────────────────────────────── // ── getCountryFromAddress ───────────────────────────────────────────────────