fix(search): normalize geocoder bias across the antimeridian (#1272)

Keep geocoder viewport bias near the antimeridian and omit misleading wrapped viewboxes.

Contribution by @asturksever.
This commit is contained in:
Said Turksever authored and GitHub committed 2026-10-25 17:04:43 -07:00
1 parent ba292ce475
commit a43ff8619e
6 files changed
+50 -7

No files matched your search

+1
View File
@@ -0,0 +1 @@
- Normalize geocoder bias across the antimeridian (asturksever, #1272).
+6 -1
View File
@@ -5197,9 +5197,14 @@ HTTP refusals and outages remain retryable. Searches share a 12-second total
deadline, with Photon requests capped at six seconds each. Caller/application
cancellation stops retries and late cache writes. Replacing a location search
cancels the previous lookup; disposing its controls cancels the active lookup. Photon uses a soft proximity bias, up to five
candidates, and an unbiased retry for name mismatches. Invalid/wrapped bounds
candidates, and an unbiased retry for name mismatches. Invalid or wrapped bounds returned by providers
are omitted rather than framing the wrong part of the globe.
For request viewport bias, Photon unwraps antimeridian-crossing longitude bounds
before computing their midpoint, then wraps that midpoint back into ±180 degrees.
Malformed viewport bounds add no proximity bias. Nominatim omits a crossing
request viewbox and continues searching without that geographic preference.
### Map Stack Switcher (June 2026)
- `src/mapStackController.js` switches between Google Photorealistic 3D (`photoreal`, the default when a Google or ion key is present), keyless Esri World Imagery (the zero-key default landing, with keyless terrain), Bing Aerial / Aerial-with-Labels via Cesium ion world imagery (require `CESIUM_ION_TOKEN`), and OSM tile fallback. Bing Road is **retired**: it is gone from `MAP_STACKS`, from the `set_map_stack` enum, and from the voice aliases (road phrasings now resolve to OSM, the one shipped road basemap). An old `map=bing-road` link is simply an unknown id and takes `setStack()`'s existing photoreal fallback with the Google 3D tile lit — pinned live in `scripts/qa-map-source-tray.mjs`.
+20 -5
View File
@@ -206,12 +206,27 @@ export function photonSearchUrl(
url.searchParams.set('limit', String(limit));
const corners = String(bias ?? '').split('|');
if (corners.length === 2) {
const [swLat, swLng] = corners[0].split(',').map(Number);
const [neLat, neLng] = corners[1].split(',').map(Number);
if ([swLat, swLng, neLat, neLng].every(Number.isFinite)) {
const tokens = corners.flatMap((corner) => corner.split(','));
if (
corners.length === 2 &&
corners.every((corner) => corner.split(',').length === 2) &&
tokens.every((token) => token.trim() !== '')
) {
const [swLat, swLng, neLat, neLng] = tokens.map(Number);
if (
[swLat, swLng, neLat, neLng].every(Number.isFinite) &&
Math.abs(swLat) <= 90 &&
Math.abs(neLat) <= 90 &&
swLat <= neLat &&
Math.abs(swLng) <= 180 &&
Math.abs(neLng) <= 180
) {
const midpoint = (swLng + neLng + (swLng > neLng ? 360 : 0)) / 2;
url.searchParams.set('lat', String((swLat + neLat) / 2));
url.searchParams.set('lon', String((swLng + neLng) / 2));
url.searchParams.set(
'lon',
String(midpoint > 180 ? midpoint - 360 : midpoint),
);
}
}
return url.toString();
+11
View File
@@ -452,3 +452,14 @@ function readGeocodingConsumer(file) {
? [file, 'annotations/resolver.js'] : [file];
return files.map((name) => fs.readFileSync(path.join(ROOT, 'src', name), 'utf8')).join('\n');
}
test('wrapped view bias stays near the antimeridian and malformed coordinates are omitted', () => {
for (const [bias, expected] of [['-20,170|-10,-171', 179.5], ['50,175|60,-165', -175], ['0,180|10,-180', 180], ['0,-180|10,180', 0]]) {
const params = new URL(photonSearchUrl('island', { bias })).searchParams;
assert.equal(Number(params.get('lon')), expected);
}
for (const bias of ['-91,0|0,1', '0,181|1,0', '10,0|0,1', ',|,', '0,1,2|3,4', '0,1|Infinity,2']) {
assert.equal(new URL(photonSearchUrl('island', { bias })).searchParams.has('lon'), false);
}
});
+3 -1
View File
@@ -166,6 +166,8 @@ export function nominatimViewboxFromBounds(bounds) {
const neLat = nominatimLatitude(match[3]);
const neLng = nominatimLongitude(match[4]);
if ([swLat, swLng, neLat, neLng].some((value) => value === null)) return null;
if (swLat >= neLat) return null;
// One Nominatim viewbox cannot express a wrapped interval. Omit this soft
// preference rather than biasing the search toward the opposite hemisphere.
if (swLat >= neLat || swLng > neLng) return null;
return `${swLng},${neLat},${neLng},${swLat}`;
}
+9
View File
@@ -173,3 +173,12 @@ test('a viewport hint is sent only when every edge is a real coordinate', () =>
`${JSON.stringify(bounds)} must not become a viewbox`,
);
});
test('wrapped viewports omit the Nominatim bias without rejecting the search', () => {
assert.equal(nominatimViewboxFromBounds('-20,170|-10,-171'), null);
assert.equal(nominatimViewboxFromBounds('50,175|60,-165'), null);
assert.equal(
nominatimViewboxFromBounds('-20,170|-10,180'),
'170,-10,180,-20',
);
});