From 11bc2ec0ae848091c6ec16c74ecbd7980a89f91d Mon Sep 17 00:00:00 2001 From: cj-vana Date: Sun, 11 Oct 2026 20:58:02 -0600 Subject: [PATCH 1/2] feat(search): resolve two-dimensional geo URIs offline --- CHANGELOG.md | 4 ++ README.md | 2 +- docs/CURRENT-STATE.md | 9 +++- scripts/qa-location-controls.mjs | 59 +++++++++++++++++++++++++ src/search/coordinateParser.js | 16 +++++-- src/search/coordinateParser.test.mjs | 64 ++++++++++++++++++++++++++++ src/search/offlineGeocoders.test.mjs | 50 ++++++++++++++++++++++ 7 files changed, 198 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 68ab1c52d..09646fc49 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,9 @@ # Changelog +- Location search accepts two-dimensional WGS84 `geo:` URIs offline, with + the existing coordinate framing and label. Unsupported URI fields are passed + to the remaining search providers unchanged. + - Treat explicitly current, complete empty vessel coverage as a successful update, clearing obsolete contacts while retaining stale or incomplete snapshots. diff --git a/README.md b/README.md index 5d97dfb2f..0bbb84abb 100644 --- a/README.md +++ b/README.md @@ -73,7 +73,7 @@ Start with the included data sources, then add your own. Each layer is a separat - **🔭 Satellite passes:** Ask by voice when any loaded satellite next rises over you: rise, peak and set times, and whether you can see it. - **🔎 Analyst answers:** Count, filter and rank satellites, datacenters and dams by voice, alongside flights, ships, fires and quakes; answers say when a feed is stale or on a fallback. - **🧭 Tilt and North Up:** Switch between straight-down and a 35° oblique, or put north at the top, while a tracked target stays centered. -- **🔍 Keyless search:** Type coordinates or a bundled city or landmark to fly there with no network request; other names use Google when configured, then Photon and Nominatim. +- **🔍 Keyless search:** Type decimal coordinates, a two-dimensional WGS84 `geo:` URI (for example, `geo:39.7392,-104.9903`), or a bundled city or landmark to fly there with no geocoder request; other names use Google when configured, then Photon and Nominatim. - **📦 Shareable scenes:** Import, preview and share Director scenes as files or bundles, with camera anchors, authored moves and data packs. - **🌊 Nepal flood scene:** Replay the Bhote Koshi flood: flood path, witness sources and before-and-after imagery (bundled data is non-commercial; see [DATA_SOURCES.md](DATA_SOURCES.md)). diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index 274294b5d..190223dd3 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -4141,12 +4141,17 @@ and unreachable upstream (502/504) separately from road geometry. Location search/fly-to, annotations and Radio location lookup receive one `placeSearch.geocode(query, { bias, signal })` service. `src/standalone` composes -two offline providers first — a strict decimal-degree coordinate reader and an +two offline providers first — a strict decimal-degree/geo-URI coordinate reader and an exact-match reader over the bundled city and landmark names — then Google when configured and Photon/OpenStreetMap as fallback, including Google transport failures or declined requests. The offline pair answer with no request and no key, and decline everything they are not certain of: a coordinate must be a -decimal-degree pair (either order when N/S/E/W fix the axes), and a bundled name +decimal-degree pair (either order when N/S/E/W fix the axes), or a two-dimensional +WGS84 `geo:latitude,longitude` URI with an optional `;crs=wgs84`. URI scheme and +CRS names are case-insensitive; internal whitespace, altitude, uncertainty, other +parameters, query strings and fragments decline rather than silently dropping +information. For example, `geo:39.7392,-104.9903` resolves offline to the same exact +point, decimal label and framing box as `39.7392, -104.9903`. A bundled name must match exactly, so `12junk, 34oops` and `austin, tx` both reach the geocoders instead of being answered from the bundle. Degrees/minutes/seconds and grid references are not parsed. An answer either provider gives is exact, so diff --git a/scripts/qa-location-controls.mjs b/scripts/qa-location-controls.mjs index 606d67b83..842a433a4 100644 --- a/scripts/qa-location-controls.mjs +++ b/scripts/qa-location-controls.mjs @@ -84,6 +84,64 @@ try { () => window.__godsEyeView.styleManager._activePoiIndex === 1, ), ); + const geocoderRequests = []; + const recordGeocoderRequest = (request) => { + const url = new URL(request.url()); + if ( + url.pathname === '/api/geocode' || + (url.hostname === 'photon.komoot.io' && url.pathname === '/api/') || + (url.hostname === 'maps.googleapis.com' && + url.pathname === '/maps/api/geocode/json') + ) + geocoderRequests.push(request.url()); + }; + page.on('request', recordGeocoderRequest); + await page.$eval('#location-search', (input) => { + input.value = 'geo:39.7392,-104.9903'; + input.focus(); + }); + await page.keyboard.press('Enter'); + await page.waitForFunction( + () => { + const state = + window.__godsEyeView.styleManager._locationLookup.getState(); + return ( + state.query === 'geo:39.7392,-104.9903' && state.status === 'found' + ); + }, + { timeout: 30000 }, + ); + await page.waitForFunction( + () => !window.__godsEyeView.viewer.camera._currentFlight, + { timeout: 10000 }, + ); + check( + 'native geo URI search shows the decimal coordinate readout', + await page.evaluate(() => { + const ui = window.__godsEyeView.styleManager; + return ( + ui._searchedLocationLabel === '39.7392° N, 104.9903° W' && + ui._locationMiniCity.textContent.includes('39.7392° N') && + ui._locationMiniPoi.textContent === '104.9903° W' + ); + }), + ); + check( + 'geo URI navigation settles near the requested coordinate', + await page.evaluate(() => { + const position = window.__godsEyeView.viewer.camera.positionCartographic; + const degrees = 180 / Math.PI; + return ( + Math.abs(position.latitude * degrees - 39.7392) < 0.05 && + Math.abs(position.longitude * degrees + 104.9903) < 0.05 + ); + }), + ); + check( + 'geo URI search makes no geocoder request', + geocoderRequests.length === 0, + ); + page.off('request', recordGeocoderRequest); await page.evaluate(() => { const lookup = window.__godsEyeView.styleManager._locationLookup; window.__qaSearchRequests = []; @@ -96,6 +154,7 @@ try { window.__qaSearchRequests.push({ query, options, resolve }), ); document.getElementById('location-search').value = 'First'; + document.getElementById('location-search').focus(); }); await page.keyboard.press('Enter'); await page.waitForFunction(() => window.__qaSearchRequests.length === 1); diff --git a/src/search/coordinateParser.js b/src/search/coordinateParser.js index 261e2394c..ebb4aee3f 100644 --- a/src/search/coordinateParser.js +++ b/src/search/coordinateParser.js @@ -5,7 +5,8 @@ * as 12, 34 flies the operator somewhere they did not ask for and looks like a * successful search. So every rule here is exact — a component is a decimal * number, optionally signed, optionally marked with one hemisphere letter, and - * nothing else. Degrees/minutes/seconds and grid references are not handled; + * nothing else. Two-dimensional WGS84 geo URIs use their own strict grammar. + * Degrees/minutes/seconds and grid references are not handled; * they fall through to the geocoders, which is what happens today. */ @@ -13,6 +14,10 @@ const COMPONENT = /^([NSEW])?\s*([+-]?(?:\d+(?:\.\d+)?|\.\d+))\s*°?\s*([NSEW])?$/i; +/** RFC 5870's 2D WGS84 subset; never discard altitude or uncertainty. */ +const GEO_URI = + /^geo:(-?\d{1,2}(?:\.\d+)?),(-?\d{1,3}(?:\.\d+)?)(?:;crs=wgs84)?$/i; + /** Hemisphere letters, by the axis each one can name. */ const LATITUDE_LETTERS = new Set(['N', 'S']); @@ -61,20 +66,25 @@ export function formatCoordinateLabel(lat, lon) { } /** - * Parse a decimal-degree query, or return null. + * Parse a decimal-degree pair or a two-dimensional WGS84 geo URI, or return null. * * Both orders are accepted when the hemisphere letters say which is which * (`W 40, N 74` is longitude then latitude); with no letters the conventional * latitude-then-longitude order applies. Two letters naming the same axis, a * letter that fixes nothing, an out-of-range latitude or longitude, and any * trailing text all decline. + * Geo URIs accept only latitude/longitude and an optional `;crs=wgs84`; + * altitude, uncertainty, other parameters and query/fragment suffixes decline. * * @param {string} query * @returns {{ lat: number, lon: number, label: string } | null} */ export function parseCoordinateQuery(query) { if (typeof query !== 'string') return null; - const parts = splitComponents(query); + const text = query.trim(); + const parts = /^geo:/i.test(text) + ? GEO_URI.exec(text)?.slice(1, 3) + : splitComponents(text); if (!parts) return null; const first = readComponent(parts[0]); diff --git a/src/search/coordinateParser.test.mjs b/src/search/coordinateParser.test.mjs index 393de37a4..7007a9b61 100644 --- a/src/search/coordinateParser.test.mjs +++ b/src/search/coordinateParser.test.mjs @@ -132,3 +132,67 @@ test('the poles and the dateline are inside the accepted range', () => { assert.deepEqual(parseCoordinateQuery('90, 180')?.lat, 90); assert.deepEqual(parseCoordinateQuery('-90, -180')?.lon, -180); }); + +test('two-dimensional WGS84 geo URIs resolve to the same decimal coordinate', () => { + for (const query of [ + 'geo:39.7392,-104.9903', + 'geo:39.7392,-104.9903;crs=wgs84', + ' GEO:39.7392,-104.9903;CRS=WGS84 ', + ]) { + assert.deepEqual(parseCoordinateQuery(query), { + lat: 39.7392, + lon: -104.9903, + label: '39.7392° N, 104.9903° W', + }); + } + for (const [query, decimal] of [ + ['geo:0,0', '0,0'], + ['geo:-33.8568,151.2153', '-33.8568,151.2153'], + ['geo:90,180', '90,180'], + ['geo:-90,-180;crs=wgs84', '-90,-180'], + ['geo:09,007', '9,7'], + ['geo:-0,-0', '-0,-0'], + ]) { + assert.deepEqual( + parseCoordinateQuery(query), + parseCoordinateQuery(decimal), + ); + } +}); + +test('malformed or unsupported geo URIs are refused without losing their meaning', () => { + for (const query of [ + 'geo:', + 'geo:39.7392', + 'geo:,104.9903', + 'geo:39.7392,,104.9903', + 'geo:39.7392,-104.9903,', + 'geo:91,0', + 'geo:-90.1,0', + 'geo:0,181', + 'geo:0,-180.1', + 'geo:000,0', + 'geo:0,0000', + 'geo:+39.7392,-104.9903', + 'geo:.5,-.25', + 'geo:1e1,2', + 'geo:NaN,0', + 'geo:Infinity,0', + 'geo:39.7392, -104.9903', + 'geo:39.7392;-104.9903', + 'geo:39.7392N,104.9903W', + 'geo:39.7392,-104.9903,1600', + 'geo:39.7392,-104.9903;u=10', + 'geo:39.7392,-104.9903;crs=unknown', + 'geo:39.7392,-104.9903;crs=wgs84;crs=wgs84', + 'geo:39.7392,-104.9903;crs=wgs84;u=0', + 'geo:39.7392,-104.9903;custom=value', + 'geo:0,0?q=39.7392,-104.9903(Denver)', + 'geo:39.7392,-104.9903#Denver', + 'geo:39.7392,-104.9903 trailing text', + 'geo://39.7392,-104.9903', + 'https://example.com/geo:39.7392,-104.9903', + ]) { + assert.equal(parseCoordinateQuery(query), null, query); + } +}); diff --git a/src/search/offlineGeocoders.test.mjs b/src/search/offlineGeocoders.test.mjs index f0d6a507a..2a01eac69 100644 --- a/src/search/offlineGeocoders.test.mjs +++ b/src/search/offlineGeocoders.test.mjs @@ -9,6 +9,7 @@ import assert from 'node:assert/strict'; import { createCoordinateGeocoder } from './coordinateGeocoder.js'; import { createPresetGeocoder } from './presetGeocoder.js'; import { createPlaceSearch } from './placeSearch.js'; +import { createDefaultPlaceSearch } from './defaults.js'; const PRESETS = { austin: { @@ -189,3 +190,52 @@ test('a cancelled search stops the offline providers too', async () => { ); } }); + +test('default search resolves a geo URI exactly without requesting a geocoder', async () => { + const requests = []; + const search = createDefaultPlaceSearch({ + resolveApiKey: () => 'test-key', + fetchImpl: async (url) => { + requests.push(url); + throw new Error('No network request should be needed'); + }, + }); + const { place, fallbackUsed } = await search.geocode( + 'geo:39.7392,-104.9903;crs=wgs84', + ); + assert.deepEqual(requests, []); + assert.equal(fallbackUsed, false); + assert.equal(place.lat, 39.7392); + assert.equal(place.lng, -104.9903); + assert.equal(place.exact, true); + assert.deepEqual(place.types, ['coordinate']); + assert.ok(place.viewport.southwest.lat < place.lat); + assert.ok(place.viewport.northeast.lat > place.lat); + assert.ok(place.viewport.southwest.lng < place.lng); + assert.ok(place.viewport.northeast.lng > place.lng); +}); + +test('unsupported geo URIs reach the next search provider unchanged', async () => { + const queries = [ + 'geo:39.7392,-104.9903,1600', + 'geo:39.7392,-104.9903;u=10', + 'geo:39.7392,-104.9903;crs=unknown', + 'geo:0,0?q=Denver', + ]; + const received = []; + const search = createPlaceSearch({ + providers: [ + createCoordinateGeocoder(), + { + async geocode(query) { + received.push(query); + return { place: null, answered: true }; + }, + }, + ], + }); + for (const query of queries) { + assert.equal((await search.geocode(query)).place, null); + } + assert.deepEqual(received, queries); +}); From f0a04fbbaa9ab5ae0ef5be80055c67bff35ba372 Mon Sep 17 00:00:00 2001 From: Sameh Khamis Date: Fri, 16 Oct 2026 16:39:28 -0700 Subject: [PATCH 2/2] Move the changelog entry into changelog.d and watch the Google geocode proxy in QA Main now takes release notes as one fragment per pull request (#787). The browser asks Google through /api/google/geocode, so the no-geocoder-request check now watches that route. --- changelog.d/1071-geo-uri-search.md | 3 +++ scripts/qa-location-controls.mjs | 5 ++--- src/search/coordinateGeocoder.js | 2 +- 3 files changed, 6 insertions(+), 4 deletions(-) create mode 100644 changelog.d/1071-geo-uri-search.md diff --git a/changelog.d/1071-geo-uri-search.md b/changelog.d/1071-geo-uri-search.md new file mode 100644 index 000000000..eba251948 --- /dev/null +++ b/changelog.d/1071-geo-uri-search.md @@ -0,0 +1,3 @@ +- Location search accepts two-dimensional WGS84 `geo:` URIs offline, with + the existing coordinate framing and label. Unsupported URI fields are passed + to the remaining search providers unchanged (cj-vana, #1071). diff --git a/scripts/qa-location-controls.mjs b/scripts/qa-location-controls.mjs index 842a433a4..46da4fe37 100644 --- a/scripts/qa-location-controls.mjs +++ b/scripts/qa-location-controls.mjs @@ -89,9 +89,8 @@ try { const url = new URL(request.url()); if ( url.pathname === '/api/geocode' || - (url.hostname === 'photon.komoot.io' && url.pathname === '/api/') || - (url.hostname === 'maps.googleapis.com' && - url.pathname === '/maps/api/geocode/json') + url.pathname === '/api/google/geocode' || + (url.hostname === 'photon.komoot.io' && url.pathname === '/api/') ) geocoderRequests.push(request.url()); }; diff --git a/src/search/coordinateGeocoder.js b/src/search/coordinateGeocoder.js index 265a0e12c..aa145bb08 100644 --- a/src/search/coordinateGeocoder.js +++ b/src/search/coordinateGeocoder.js @@ -22,7 +22,7 @@ function boundedBox(lat, lon, halfSpan) { } /** - * Answer a decimal-degree query without asking anyone. + * Answer a decimal-degree or geo-URI query without asking anyone. * * It sits ahead of the network geocoders, so a coordinate costs no request and * works with no key. Anything that is not exactly a coordinate is passed on