fix(atlas): give a point no polygon contains to the nearest border

A point just off a simplified coastline lies in no admin0 polygon, and the
fallback then took the candidate with the smallest bounding box. Nice and
Menton sit ~80 m outside France's coast and 20+ km from Italy, but Italy's
box is the smaller one, so they resolved to IT. The fallback now ranks the
candidates by distance to their border. A deferred loose box (PS/XK) keeps
its priority. The parity test's reference lookup mirrors the new rule.

Closes #3048
This commit is contained in:
Ibrahim Roshdy
2026-11-10 22:18:15 +02:00
committed by Maurice
parent 5112c96c05
commit 8023be452a
3 changed files with 101 additions and 3 deletions
+50 -2
View File
@@ -260,6 +260,38 @@ export function pointInPackedGeometry(lng: number, lat: number, geom: PackedGeom
return false;
}
// Squared distance from (lng,lat) to the nearest edge of a packed geometry, holes
// included, in degrees with longitude scaled by cos(lat) so east-west and north-south
// count alike. Only the ranking matters, so the square root is never taken.
function squaredDistanceToPackedGeometry(lng: number, lat: number, geom: PackedGeom, scale: number): number {
const { coords, ringStarts } = geom;
const kx = Math.cos((lat * Math.PI) / 180);
let best = Infinity;
for (let r = 0; r + 1 < ringStarts.length; r++) {
const from = ringStarts[r];
const to = ringStarts[r + 1];
let ax = (coords[2 * from] / scale - lng) * kx;
let ay = coords[2 * from + 1] / scale - lat;
for (let i = from + 1; i < to; i++) {
const bx = (coords[2 * i] / scale - lng) * kx;
const by = coords[2 * i + 1] / scale - lat;
const dx = bx - ax;
const dy = by - ay;
const len = dx * dx + dy * dy;
let t = len > 0 ? -(ax * dx + ay * dy) / len : 0;
if (t < 0) t = 0;
else if (t > 1) t = 1;
const ex = ax + t * dx;
const ey = ay + t * dy;
const d = ex * ex + ey * ey;
if (d < best) best = d;
ax = bx;
ay = by;
}
}
return best;
}
// Packed polygon geometry: every ring's [lng,lat] pairs back to back in one typed array, each
// value times a scale. Ring r is pairs ringStarts[r] up to ringStarts[r + 1]; polyRingCounts[k]
// is how many rings polygon k owns. Three typed arrays per geometry instead of one per ring,
@@ -444,6 +476,22 @@ function lookupCountryFromCoords(lat: number, lng: number): string | null {
if (pointInPackedGeometry(lng, lat, poly, ADMIN0_SCALE)) 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;
// (a real PS/XK point).
if (looseBoxFallback !== null) return looseBoxFallback;
// Otherwise the point sits just off a simplified coastline or in a data gap. Give it to
// the candidate whose border is nearest, not to the smallest box: on the Riviera,
// central Nice lies about 80 m outside France's simplified coast and 20+ km from Italy,
// yet Italy's box is the smaller of the two, so smallest-box put Nice in Italy.
let nearest = candidates[0].code;
let nearestDistance = Infinity;
for (const { code } of candidates) {
const poly = polys.get(code);
if (!poly) continue;
const d = squaredDistanceToPackedGeometry(lng, lat, poly, ADMIN0_SCALE);
if (d < nearestDistance) {
nearestDistance = d;
nearest = code;
}
}
return nearest;
}
@@ -0,0 +1,26 @@
import { getCountryFromCoords } from '../../../src/utils/country-borders';
import { describe, expect, it } from 'vitest';
// A point that no admin0 polygon contains (just off a simplified coastline, or in a data
// gap) used to go to the candidate with the smallest bounding box. It now goes to the
// candidate whose border is nearest.
describe('getCountryFromCoords, a point no polygon contains', () => {
it('ATLAS-SVC-056: a point just off a simplified coastline goes to the nearest border, not the smallest box', () => {
// Central Nice lies about 80 m outside France's simplified coast and 20+ km from Italy,
// so no polygon contains it. Both boxes cover it and Italy's is the smaller one, so the
// smallest-box fallback put the whole Nice-Menton strip in Italy.
expect(getCountryFromCoords(43.6947, 7.2578)).toBe('FR'); // Hôtel Negresco, Nice
expect(getCountryFromCoords(43.6969, 7.2703)).toBe('FR'); // Place Masséna, Nice
expect(getCountryFromCoords(43.6598, 7.2137)).toBe('FR'); // Nice Côte d'Azur airport
expect(getCountryFromCoords(43.7753, 7.5029)).toBe('FR'); // Menton
});
it('ATLAS-SVC-057: the Riviera neighbours stay where they were', () => {
expect(getCountryFromCoords(43.7392, 7.428)).toBe('MC'); // Casino de Monte-Carlo
expect(getCountryFromCoords(43.7918, 7.6033)).toBe('IT'); // Ventimiglia
expect(getCountryFromCoords(43.817, 7.7751)).toBe('IT'); // Sanremo
expect(getCountryFromCoords(43.5814, 7.1237)).toBe('FR'); // Antibes
});
});
@@ -114,7 +114,31 @@ function oldLookup(old: ReturnType<typeof oldIndexes>, lat: number, lng: number)
}
if (oldPointInGeometry(lng, lat, poly)) return code;
}
return looseBoxFallback ?? candidates[0].code;
if (looseBoxFallback !== null) return looseBoxFallback;
// Nearest border among the candidates, as country-borders.ts ranks a point no polygon holds.
let nearest = candidates[0].code;
let nearestDistance = Infinity;
const kx = Math.cos((lat * Math.PI) / 180);
for (const { code } of candidates) {
const poly = old.polys.get(code);
if (!poly) continue;
for (const ring of poly.rings) {
for (let i = 1; i < ring.length / 2; i++) {
const ax = (ring[2 * (i - 1)] - lng) * kx;
const ay = ring[2 * (i - 1) + 1] - lat;
const dx = (ring[2 * i] - lng) * kx - ax;
const dy = ring[2 * i + 1] - lat - ay;
const len = dx * dx + dy * dy;
const t = len > 0 ? Math.min(1, Math.max(0, -(ax * dx + ay * dy) / len)) : 0;
const d = (ax + t * dx) ** 2 + (ay + t * dy) ** 2;
if (d < nearestDistance) {
nearestDistance = d;
nearest = code;
}
}
}
}
return nearest;
}
// Deterministic points per country: about `perCountry` vertices spread over all its rings,