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.
|
||||
- **🔎 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)).
|
||||
|
||||
|
||||
@@ -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
|
||||
`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
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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]);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
Reference in new issue
Block a user