From 8023be452a057413ea4c3be2dbe748d4de16f3a6 Mon Sep 17 00:00:00 2001 From: Ibrahim Roshdy <22573766+ibrahimroshdy@users.noreply.github.com> Date: Tue, 10 Nov 2026 22:18:15 +0200 Subject: [PATCH] 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 --- server/src/utils/country-borders.ts | 52 ++++++++++++++++++- .../unit/nest/atlas-coastal-fallback.test.ts | 26 ++++++++++ .../unit/nest/atlas-country-index.test.ts | 26 +++++++++- 3 files changed, 101 insertions(+), 3 deletions(-) create mode 100644 server/tests/unit/nest/atlas-coastal-fallback.test.ts diff --git a/server/src/utils/country-borders.ts b/server/src/utils/country-borders.ts index c8f2f5ed4..e27027cdb 100644 --- a/server/src/utils/country-borders.ts +++ b/server/src/utils/country-borders.ts @@ -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; } diff --git a/server/tests/unit/nest/atlas-coastal-fallback.test.ts b/server/tests/unit/nest/atlas-coastal-fallback.test.ts new file mode 100644 index 000000000..30ff81d59 --- /dev/null +++ b/server/tests/unit/nest/atlas-coastal-fallback.test.ts @@ -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 + }); +}); diff --git a/server/tests/unit/nest/atlas-country-index.test.ts b/server/tests/unit/nest/atlas-country-index.test.ts index ba6a18e95..cfea29974 100644 --- a/server/tests/unit/nest/atlas-country-index.test.ts +++ b/server/tests/unit/nest/atlas-country-index.test.ts @@ -114,7 +114,31 @@ function oldLookup(old: ReturnType, 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,