mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-04 02:14:01 +00:00
Merge pull request #1071 from cj-vana/feature/geo-uri-search
feat(search): resolve two-dimensional geo URIs offline
This commit is contained in:
8 files changed
+197
-7
No files matched your search
@@ -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)).
|
||||||
|
|
||||||
|
|||||||
@@ -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).
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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]);
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
Reference in new issue
Block a user