From b07352ea6ad2513763bd7c75ebc53330891ed070 Mon Sep 17 00:00:00 2001 From: Rudy Mizrahi Celekli <47457359+rudycelekli@users.noreply.github.com> Date: Sun, 25 Oct 2026 02:39:51 -0400 Subject: [PATCH] fix(search): read hemisphere letters separated from the number (#1340) Support spaced hemisphere coordinates while rejecting malformed numeric fragments and ambiguous component assignments. Include regression tests and runtime documentation. Contribution by @rudycelekli. --- .../1340-search-separated-hemisphere.md | 3 ++ docs/CURRENT-STATE.md | 6 +++ src/search/coordinateParser.js | 34 ++++++++++--- src/search/coordinateParser.test.mjs | 50 +++++++++++++++++++ 4 files changed, 87 insertions(+), 6 deletions(-) create mode 100644 changelog.d/1340-search-separated-hemisphere.md diff --git a/changelog.d/1340-search-separated-hemisphere.md b/changelog.d/1340-search-separated-hemisphere.md new file mode 100644 index 000000000..5b89020e4 --- /dev/null +++ b/changelog.d/1340-search-separated-hemisphere.md @@ -0,0 +1,3 @@ +- Read a hemisphere letter that is separated from its number, so a search for + `40.7128 N 74.0060 W` flies there instead of falling through to the geocoders + (Rudy Celekli, @rudycelekli, #1340). diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index e4e0063a7..435df22c0 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -768,6 +768,12 @@ transports and cancellation through response parsing. Stop and application teardown abort pending connections; reconnect requests a new client secret. Microphone, radio handoff, tool schemas and default model behavior are preserved. +Coordinate search accepts whitespace-separated decimal degrees with prefix or +suffix hemisphere letters, including `40.7128 N 74.0060 W`. Components require +an actual separator and an unambiguous hemisphere assignment; malformed numeric +fragments and scientific notation fall through to geocoders instead of becoming +exact coordinate results. + Geospatial lookups are composed through `src/search`: forward/reverse geocoding, text/nearby search and routing use configurable providers/endpoints. Annotation, HUD and voice consumers share the configured service. Existing Google/Photon diff --git a/src/search/coordinateParser.js b/src/search/coordinateParser.js index 105f13934..948cc25e7 100644 --- a/src/search/coordinateParser.js +++ b/src/search/coordinateParser.js @@ -47,15 +47,37 @@ function readComponent(text) { }; } -/** Split on a comma, a semicolon, or whitespace — never on anything else. */ +/** + * Split on a comma, a semicolon, or whitespace — never on anything else. + * A hemisphere letter stays with the number it names, so `40.7 N 74.0 W` + * is two components. Splitting on the space before that letter used to + * leave three pieces and the search declined the coordinate. + */ function splitComponents(query) { const text = String(query).trim(); if (!text) return null; - const parts = /[,;]/.test(text) - ? text.split(/[,;]/) - : text.split(/\s+(?=[+-]?[.\d]|[NSEW]\s*[+-]?[.\d])/i); - const trimmed = parts.map((part) => part.trim()); - return trimmed.length === 2 && trimmed.every(Boolean) ? trimmed : null; + if (/[,;]/.test(text)) { + const trimmed = text.split(/[,;]/).map((part) => part.trim()); + return trimmed.length === 2 && trimmed.every(Boolean) ? trimmed : null; + } + const words = text.split(/\s+/); + // Each component has at most a number, a degree sign and one hemisphere. + if (words.length > 6) return null; + // Keep every character: extracting tokens would turn `1e3` or `1.2.3` + // into invented coordinates. A lone letter between numbers can name either + // component; require an unambiguous split rather than guessing its owner. + let result = null; + for (let boundary = words.length - 1; boundary > 0; boundary -= 1) { + const parts = [ + words.slice(0, boundary).join(' '), + words.slice(boundary).join(' '), + ]; + if (parts.every((part) => readComponent(part))) { + if (result) return null; + result = parts; + } + } + return result; } /** Present a parsed pair the way a coordinate is normally written. */ diff --git a/src/search/coordinateParser.test.mjs b/src/search/coordinateParser.test.mjs index 7007a9b61..6128d5f4c 100644 --- a/src/search/coordinateParser.test.mjs +++ b/src/search/coordinateParser.test.mjs @@ -52,6 +52,19 @@ test('hemisphere letters fix the axis, in either order', () => { label: '40.0000° N, 74.0000° W', }); assert.deepEqual(parseCoordinateQuery('40° N, 74° W')?.lat, 40); + // Written the way a coordinate is usually pasted, with no comma. + assert.deepEqual(parseCoordinateQuery('40.7128 N 74.0060 W'), { + lat: 40.7128, + lon: -74.006, + label: '40.7128° N, 74.0060° W', + }); + assert.deepEqual(parseCoordinateQuery('40.7128° N 74.0060° W')?.lon, -74.006); + assert.deepEqual(parseCoordinateQuery('N 40.7128 W 74.0060'), { + lat: 40.7128, + lon: -74.006, + label: '40.7128° N, 74.0060° W', + }); + assert.deepEqual(parseCoordinateQuery('74.0060 W 40.7128 N')?.lat, 40.7128); // One letter is enough: it fixes its own axis, the other takes what is left. assert.deepEqual(parseCoordinateQuery('40 N, -74')?.lon, -74); assert.deepEqual(parseCoordinateQuery('-74, 40 N'), { @@ -66,6 +79,21 @@ test('malformed and ambiguous coordinates are refused, never guessed', () => { // The reviewer's case: trailing text must not be discarded. '12junk, 34oops', '12junk,34', + '12junk 34', + '1e3 5', + '1e3', + '1E3', + '1e-3', + '1.2.3', + '12-34', + '40 NW 74', + '40 N 74', + '40 W 74', + '40 E 74', + '°40 74', + '40°° 74', + '40 74°°', + '40 26 46 N 79 58 56 W', '43.1731, -79.0384x', 'abc, def', 'Paris', @@ -196,3 +224,25 @@ test('malformed or unsupported geo URIs are refused without losing their meaning assert.equal(parseCoordinateQuery(query), null, query); } }); + +test('whitespace boundaries preserve degree signs and hemisphere ownership', () => { + for (const query of ['40 ° 74', '40° 74', '40 N, 74', 'N 40 74']) { + assert.equal(parseCoordinateQuery(query)?.lat, 40, query); + assert.equal(parseCoordinateQuery(query)?.lon, 74, query); + } + for (const query of ['40 ° N 74 ° W', 'N 40 ° W 74 °', '40\tN\n74\tW']) { + assert.equal(parseCoordinateQuery(query)?.lat, 40, query); + assert.equal(parseCoordinateQuery(query)?.lon, -74, query); + } +}); + +test('a signed plain component leaves a spaced letter with the other component', () => { + assert.deepEqual( + parseCoordinateQuery('-40 N 74'), + parseCoordinateQuery('-40, N 74'), + ); + assert.deepEqual( + parseCoordinateQuery('40 N -74'), + parseCoordinateQuery('40 N, -74'), + ); +});