Merge pull request #1071 from cj-vana/feature/geo-uri-search

feat(search): resolve two-dimensional geo URIs offline
This commit is contained in:
Sameh Khamis authored and GitHub committed 2026-10-16 17:10:53 -07:00
commit cc84ab11b1
8 files changed
+197 -7

No files matched your search

+1 -1
View File
@@ -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. - **🔭 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. - **🔎 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. - **🧭 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. - **📦 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)). - **🌊 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)).
+3
View File
@@ -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).
+7 -2
View File
@@ -4720,12 +4720,17 @@ Coordinate input requires exactly two nonempty components; extra comma or semico
Location search/fly-to, annotations and Radio location lookup receive one Location search/fly-to, annotations and Radio location lookup receive one
`placeSearch.geocode(query, { bias, signal })` service. `src/standalone` composes `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 exact-match reader over the bundled city and landmark names — then Google when
configured and Photon/OpenStreetMap as fallback, including Google transport configured and Photon/OpenStreetMap as fallback, including Google transport
failures or declined requests. The offline pair answer with no request and no 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 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 must match exactly, so `12junk, 34oops` and `austin, tx` both reach the
geocoders instead of being answered from the bundle. Degrees/minutes/seconds and 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 grid references are not parsed. An answer either provider gives is exact, so
+58
View File
@@ -84,6 +84,63 @@ try {
() => window.__godsEyeView.styleManager._activePoiIndex === 1, () => window.__godsEyeView.styleManager._activePoiIndex === 1,
), ),
); );
const geocoderRequests = [];
const recordGeocoderRequest = (request) => {
const url = new URL(request.url());
if (
url.pathname === '/api/geocode' ||
url.pathname === '/api/google/geocode' ||
(url.hostname === 'photon.komoot.io' && url.pathname === '/api/')
)
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(() => { await page.evaluate(() => {
const lookup = window.__godsEyeView.styleManager._locationLookup; const lookup = window.__godsEyeView.styleManager._locationLookup;
window.__qaSearchRequests = []; window.__qaSearchRequests = [];
@@ -96,6 +153,7 @@ try {
window.__qaSearchRequests.push({ query, options, resolve }), window.__qaSearchRequests.push({ query, options, resolve }),
); );
document.getElementById('location-search').value = 'First'; document.getElementById('location-search').value = 'First';
document.getElementById('location-search').focus();
}); });
await page.keyboard.press('Enter'); await page.keyboard.press('Enter');
await page.waitForFunction(() => window.__qaSearchRequests.length === 1); await page.waitForFunction(() => window.__qaSearchRequests.length === 1);
+1 -1
View File
@@ -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 * 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 * works with no key. Anything that is not exactly a coordinate is passed on
+13 -3
View File
@@ -5,7 +5,8 @@
* as 12, 34 flies the operator somewhere they did not ask for and looks like a * 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 * successful search. So every rule here is exact — a component is a decimal
* number, optionally signed, optionally marked with one hemisphere letter, and * 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. * they fall through to the geocoders, which is what happens today.
*/ */
@@ -13,6 +14,10 @@
const COMPONENT = const COMPONENT =
/^([NSEW])?\s*([+-]?(?:\d+(?:\.\d+)?|\.\d+))\s*°?\s*([NSEW])?$/i; /^([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. */ /** Hemisphere letters, by the axis each one can name. */
const LATITUDE_LETTERS = new Set(['N', 'S']); 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 * 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 * (`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 * 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 * letter that fixes nothing, an out-of-range latitude or longitude, and any
* trailing text all decline. * 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 * @param {string} query
* @returns {{ lat: number, lon: number, label: string } | null} * @returns {{ lat: number, lon: number, label: string } | null}
*/ */
export function parseCoordinateQuery(query) { export function parseCoordinateQuery(query) {
if (typeof query !== 'string') return null; 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; if (!parts) return null;
const first = readComponent(parts[0]); const first = readComponent(parts[0]);
+64
View File
@@ -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')?.lat, 90);
assert.deepEqual(parseCoordinateQuery('-90, -180')?.lon, -180); 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);
}
});
+50
View File
@@ -9,6 +9,7 @@ import assert from 'node:assert/strict';
import { createCoordinateGeocoder } from './coordinateGeocoder.js'; import { createCoordinateGeocoder } from './coordinateGeocoder.js';
import { createPresetGeocoder } from './presetGeocoder.js'; import { createPresetGeocoder } from './presetGeocoder.js';
import { createPlaceSearch } from './placeSearch.js'; import { createPlaceSearch } from './placeSearch.js';
import { createDefaultPlaceSearch } from './defaults.js';
const PRESETS = { const PRESETS = {
austin: { 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);
});