mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-06-30 18:46:00 +00:00
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:
@@ -199,19 +199,74 @@ export async function reverseGeocodeCountry(lat: number, lng: number): Promise<s
|
||||
}
|
||||
}
|
||||
|
||||
export function getCountryFromCoords(lat: number, lng: number): string | null {
|
||||
let bestCode: string | null = null;
|
||||
let bestArea = Infinity;
|
||||
for (const [code, [minLng, minLat, maxLng, maxLat]] of Object.entries(COUNTRY_BOXES)) {
|
||||
if (lat >= 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<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 {
|
||||
|
||||
@@ -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 ───────────────────────────────────────────────────
|
||||
|
||||
Reference in New Issue
Block a user