From a43ff8619ea2966cd4ab51382f6d77b0f9e7379f Mon Sep 17 00:00:00 2001 From: Said Turksever Date: Mon, 26 Oct 2026 00:04:43 +0000 Subject: [PATCH] fix(search): normalize geocoder bias across the antimeridian (#1272) Keep geocoder viewport bias near the antimeridian and omit misleading wrapped viewboxes. Contribution by @asturksever. --- changelog.d/1272-search-issue-fix.md | 1 + docs/CURRENT-STATE.md | 7 ++++++- src/keylessGeocoder.js | 25 ++++++++++++++++++++----- src/keylessGeocoder.test.mjs | 11 +++++++++++ src/nominatimGeocode.js | 4 +++- src/nominatimGeocode.test.mjs | 9 +++++++++ 6 files changed, 50 insertions(+), 7 deletions(-) create mode 100644 changelog.d/1272-search-issue-fix.md diff --git a/changelog.d/1272-search-issue-fix.md b/changelog.d/1272-search-issue-fix.md new file mode 100644 index 000000000..48b803534 --- /dev/null +++ b/changelog.d/1272-search-issue-fix.md @@ -0,0 +1 @@ +- Normalize geocoder bias across the antimeridian (asturksever, #1272). diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index c5cb8f905..8be9af204 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -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`. diff --git a/src/keylessGeocoder.js b/src/keylessGeocoder.js index 6033a6776..7ca99a96d 100644 --- a/src/keylessGeocoder.js +++ b/src/keylessGeocoder.js @@ -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(); diff --git a/src/keylessGeocoder.test.mjs b/src/keylessGeocoder.test.mjs index 2ce8a70b4..1a702fd02 100644 --- a/src/keylessGeocoder.test.mjs +++ b/src/keylessGeocoder.test.mjs @@ -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); + } +}); diff --git a/src/nominatimGeocode.js b/src/nominatimGeocode.js index b7dd6145a..4a5a1948e 100644 --- a/src/nominatimGeocode.js +++ b/src/nominatimGeocode.js @@ -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}`; } diff --git a/src/nominatimGeocode.test.mjs b/src/nominatimGeocode.test.mjs index b139ad820..8517649d5 100644 --- a/src/nominatimGeocode.test.mjs +++ b/src/nominatimGeocode.test.mjs @@ -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', + ); +});