mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-11-14 00:01:03 +00:00
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:
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user