fix(atlas): stop counting address abbreviations as countries

The Atlas hero counted more countries than the continent bars below it drew,
and more than the dashboard widget. The extra ones were invented: a place
without coordinates fell back to parsing its address, and any two-letter
uppercase segment at the end became a country code. "New York, NY" produced NY,
"Toronto, ON" produced ON.

Those phantoms only ever showed up in the one number. The continent strip files
an unknown code under 'Other' and never draws it, the map skips codes it cannot
resolve, and the dashboard builds its set from stored regions instead, which is
why it stayed right.

Filtering against a list of valid codes would not have been enough, and would
have hidden the worse half of this: 26 US state abbreviations are real ISO
country codes. CA is California far more often than Canada, DE is Delaware, IN
is Indiana. The existing comment above resolveCountryCode already describes that
collision and solves it by trusting coordinates first and sanity-checking the
address against the country's own bounding box. The gap it names in its last
line is this bug: with no coordinates there is nothing to check against.

So the bare-code branch is now only offered to callers that have coordinates. A
spelled-out country name still resolves either way. A lone "NY" or "CA" resolves
to nothing, which is the honest answer.

Fixes #2111
This commit is contained in:
Maurice
2026-08-28 19:37:37 +02:00
committed by Maurice
parent 4f15d87cbe
commit 63a57b9269
2 changed files with 31 additions and 5 deletions
+17 -5
View File
@@ -725,7 +725,17 @@ export function getCountryFromCoords(lat: number, lng: number): string | null {
return looseBoxFallback ?? candidates[0].code;
}
export function getCountryFromAddress(address: string | null): string | null {
/**
* `allowBareCode` decides whether a trailing two-letter uppercase segment counts as
* a country. It only does when the caller can sanity-check the answer against
* coordinates, because that segment is far more often a state or province than a
* country: "…, New York, NY", "…, Toronto, ON". Half of those abbreviations are
* real ISO codes as well (CA, DE, LA, IN, MD, GA, PA, VA), so a list of valid
* country codes does not separate them, and the other half are codes for nothing
* at all, which used to inflate the Atlas country count with places that could
* never appear on the map or in the continent bars (#2111).
*/
export function getCountryFromAddress(address: string | null, allowBareCode = true): string | null {
if (!address) return null;
const parts = address
.split(',')
@@ -736,7 +746,7 @@ export function getCountryFromAddress(address: string | null): string | null {
const normalized = last.toLowerCase();
if (NAME_TO_CODE[normalized]) return NAME_TO_CODE[normalized];
if (NAME_TO_CODE[last]) return NAME_TO_CODE[last];
if (last.length === 2 && last === last.toUpperCase()) return last;
if (allowBareCode && last.length === 2 && last === last.toUpperCase()) return last;
return null;
}
@@ -752,14 +762,16 @@ export function getCountryFromAddress(address: string | null): string | null {
// to any country, the address result is sanity-gated against that country's own admin0
// bounding box (isPointInCountryBox) before being trusted — the same guard the region-level
// address fallback uses. A place with no coordinates at all has nothing to gate against, so
// the address is trusted directly there, as before.
// it does not get the bare-code branch at all (#2111): a spelled-out country name still
// resolves there, a lone "NY" or "CA" no longer does. Guessing produced both phantom
// countries that nothing downstream could draw and confidently wrong ones.
async function resolveCountryCode(place: Place): Promise<string | null> {
const hasCoords = !!(place.lat && place.lng);
if (hasCoords) {
const fromCoords = getCountryFromCoords(place.lat!, place.lng!);
if (fromCoords) return fromCoords;
}
const fromAddress = getCountryFromAddress(place.address);
const fromAddress = getCountryFromAddress(place.address, hasCoords);
if (fromAddress && (!hasCoords || isPointInCountryBox(fromAddress, place.lat!, place.lng!))) {
return fromAddress;
}
@@ -775,7 +787,7 @@ export function resolveCountryCodeSync(place: Place): string | null {
const fromCoords = getCountryFromCoords(place.lat!, place.lng!);
if (fromCoords) return fromCoords;
}
const fromAddress = getCountryFromAddress(place.address);
const fromAddress = getCountryFromAddress(place.address, hasCoords);
if (fromAddress && (!hasCoords || isPointInCountryBox(fromAddress, place.lat!, place.lng!))) {
return fromAddress;
}
@@ -604,6 +604,20 @@ describe('getCountryFromAddress', () => {
it('ATLAS-SVC-012: returns null for unrecognized country name', () => {
expect(getCountryFromAddress('Unknown City, Unknown Country')).toBeNull();
});
// #2111 — the bare-code branch is a guess, and it is only allowed where the caller
// can check it against coordinates.
it('ATLAS-SVC-046: a bare trailing code is refused when the caller cannot verify it', () => {
expect(getCountryFromAddress('11 W 53rd St, New York, NY', false)).toBeNull();
// Half of these abbreviations are real ISO codes, so a list of valid country
// codes would not have caught them: CA is California here, not Canada.
expect(getCountryFromAddress('1 Market St, San Francisco, CA', false)).toBeNull();
expect(getCountryFromAddress('100 King St, Toronto, ON', false)).toBeNull();
});
it('ATLAS-SVC-047: a spelled-out country still resolves without coordinates', () => {
expect(getCountryFromAddress('Eiffel Tower, Paris, France', false)).toBe('FR');
});
});
// ── reverseGeocodeCountry ───────────────────────────────────────────────────