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.
- **🔎 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)).
+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
`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
+58
View File
@@ -84,6 +84,63 @@ try {
() => 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(() => {
const lookup = window.__godsEyeView.styleManager._locationLookup;
window.__qaSearchRequests = [];
@@ -96,6 +153,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);
+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
* 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
* 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]);
+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')?.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 { 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);
});