mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-04 02:14:01 +00:00
feat(search): coordinates and bundled presets resolve keyless (#572)
Supersedes #388 by @KuraPiee, ported onto the current component layout with fixes from review. Verified before merge: format, package boundaries, unit suite, build, and the browser tracking suite; a browser harness for the change where one applies; adversarial review applied. Co-authored-by: KuraPiee <[email protected]>
This commit is contained in:
1 parent
d9fac6d88e
commit
cca6442997
13 files changed
+672
-14
No files matched your search
@@ -171,6 +171,13 @@ of current runtime behavior, see [`docs/CURRENT-STATE.md`](docs/CURRENT-STATE.md
|
||||
without moving the camera when nothing is under the centre of the view, and
|
||||
both follow Reset Globe out of Clean UI, recording, Scene playback and Cockpit
|
||||
(#442 — thanks @yashveeeeeeer).
|
||||
- The location search box answers two kinds of query without a network request
|
||||
or an API key. A decimal-degree coordinate — `43.1731, -79.0384`, or either
|
||||
order when N/S/E/W say which is which — flies straight there; a bundled city
|
||||
or landmark name typed exactly (`paris`, `sf`, `Golden Gate Bridge`) flies to
|
||||
the bundled place. Anything else, including anything malformed, goes to the
|
||||
existing geocoders unchanged. Degrees/minutes/seconds and grid references are
|
||||
not parsed and fall through the same way (#388 — thanks @KuraPiee).
|
||||
|
||||
- Add Open Calgary traffic cameras as a keyless CCTV source pack (thanks
|
||||
@rileygramlich): the public City of Calgary catalog, frames pinned to the
|
||||
|
||||
+11
-2
@@ -3068,8 +3068,17 @@ silently demoting every later lookup for the session.
|
||||
|
||||
Location search/fly-to, annotations and Radio location lookup receive one
|
||||
`placeSearch.geocode(query, { bias, signal })` service. `src/standalone` composes
|
||||
Google first when configured and Photon/OpenStreetMap as fallback, including
|
||||
Google transport failures or declined requests. The portable `./search` export
|
||||
two offline providers first — a strict decimal-degree 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
|
||||
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
|
||||
nearby-landmark recovery stands aside for it — otherwise a typed coordinate
|
||||
could be replaced by whatever Places finds near the current view. The portable `./search` export
|
||||
provides the service and adapters; it reads no environment or application state.
|
||||
Existing browser/server key setup is unchanged.
|
||||
|
||||
|
||||
@@ -131,6 +131,11 @@
|
||||
"src/search/index.js",
|
||||
"src/search/placeSearch.js",
|
||||
"src/search/google.js",
|
||||
"src/search/coordinateParser.js",
|
||||
"src/search/coordinateParser.test.mjs",
|
||||
"src/search/coordinateGeocoder.js",
|
||||
"src/search/presetGeocoder.js",
|
||||
"src/search/offlineGeocoders.test.mjs",
|
||||
"src/keylessGeocoder.js",
|
||||
"src/standalone/placeSearch.js",
|
||||
"src/ui/panelDisclosure.js",
|
||||
|
||||
@@ -276,7 +276,10 @@
|
||||
"src/keylessGeocoder.js",
|
||||
"src/search/geospatial.js",
|
||||
"src/search/http.js",
|
||||
"src/search/defaults.js"
|
||||
"src/search/defaults.js",
|
||||
"src/search/coordinateGeocoder.js",
|
||||
"src/search/coordinateParser.js",
|
||||
"src/search/presetGeocoder.js"
|
||||
],
|
||||
"external": []
|
||||
},
|
||||
@@ -1587,7 +1590,10 @@
|
||||
"src/data/routeSteps.js",
|
||||
"src/layers/directions/index.js",
|
||||
"src/data/directions.js",
|
||||
"src/app/layers/directions.js"
|
||||
"src/app/layers/directions.js",
|
||||
"src/search/coordinateGeocoder.js",
|
||||
"src/search/coordinateParser.js",
|
||||
"src/search/presetGeocoder.js"
|
||||
],
|
||||
"external": [
|
||||
"@mapbox/vector-tile",
|
||||
@@ -1932,7 +1938,10 @@
|
||||
"src/data/terrainHeights.js",
|
||||
"src/data/groundFloor.js",
|
||||
"src/data/directions.js",
|
||||
"src/app/layers/directions.js"
|
||||
"src/app/layers/directions.js",
|
||||
"src/search/coordinateGeocoder.js",
|
||||
"src/search/coordinateParser.js",
|
||||
"src/search/presetGeocoder.js"
|
||||
],
|
||||
"external": [
|
||||
"cesium",
|
||||
@@ -1973,7 +1982,10 @@
|
||||
"src/services/groundSnap.js",
|
||||
"src/services/meshFloorSampler.js",
|
||||
"src/services/requests.js",
|
||||
"src/services/terrainHeights.js"
|
||||
"src/services/terrainHeights.js",
|
||||
"src/search/coordinateGeocoder.js",
|
||||
"src/search/coordinateParser.js",
|
||||
"src/search/presetGeocoder.js"
|
||||
],
|
||||
"external": [
|
||||
"cesium",
|
||||
|
||||
+13
-7
@@ -793,13 +793,19 @@ export async function searchAndFlyTo(viewer, query, options = {}) {
|
||||
let types = result?.types || [];
|
||||
let viewport = result?.viewport || null;
|
||||
|
||||
// Nearby landmark recovery retains precedence over a fallback geocoder hit.
|
||||
const recovered = await (options.recoverNearView || placesNearViewRecovery)(
|
||||
viewer,
|
||||
query,
|
||||
result && !outcome.fallbackUsed ? { lat, lon: lng } : null,
|
||||
signal,
|
||||
);
|
||||
// Nearby landmark recovery retains precedence over a fallback geocoder hit,
|
||||
// but never over an exact answer. Recovery exists to rescue a name that a
|
||||
// geocoder read too broadly; a typed coordinate or a bundled name has no
|
||||
// ambiguity to rescue, and letting a nearby Places hit win would send an
|
||||
// operator who typed "43.1731, -79.0384" to whatever is closest instead.
|
||||
const recovered = result?.exact
|
||||
? null
|
||||
: await (options.recoverNearView || placesNearViewRecovery)(
|
||||
viewer,
|
||||
query,
|
||||
result && !outcome.fallbackUsed ? { lat, lon: lng } : null,
|
||||
signal,
|
||||
);
|
||||
signal?.throwIfAborted();
|
||||
if (recovered) {
|
||||
lat = recovered.lat;
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
import { parseCoordinateQuery } from './coordinateParser.js';
|
||||
|
||||
/** Half-width of the box a bare coordinate is framed with, in degrees. */
|
||||
const COORDINATE_HALF_SPAN_DEG = 0.015;
|
||||
|
||||
/** Keep a generated box inside the poles and inside the dateline. */
|
||||
function boundedBox(lat, lon, halfSpan) {
|
||||
const wrap = (value) => {
|
||||
const wrapped = ((((value + 180) % 360) + 360) % 360) - 180;
|
||||
return wrapped === -180 ? 180 : wrapped;
|
||||
};
|
||||
return {
|
||||
southwest: {
|
||||
lat: Math.max(-90, lat - halfSpan),
|
||||
lng: wrap(lon - halfSpan),
|
||||
},
|
||||
northeast: {
|
||||
lat: Math.min(90, lat + halfSpan),
|
||||
lng: wrap(lon + halfSpan),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Answer a decimal-degree 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
|
||||
* untouched, and `answered: true` on a decline means only that this provider
|
||||
* had nothing to say — it is not a verdict on the query.
|
||||
*/
|
||||
export function createCoordinateGeocoder() {
|
||||
return {
|
||||
async geocode(query, { signal } = {}) {
|
||||
signal?.throwIfAborted();
|
||||
const parsed = parseCoordinateQuery(query);
|
||||
if (!parsed) return { place: null, answered: true };
|
||||
return {
|
||||
place: {
|
||||
lat: parsed.lat,
|
||||
lng: parsed.lon,
|
||||
name: parsed.label,
|
||||
label: parsed.label,
|
||||
types: ['coordinate'],
|
||||
// The point is the answer; nothing nearby can improve on it.
|
||||
exact: true,
|
||||
viewport: boundedBox(
|
||||
parsed.lat,
|
||||
parsed.lon,
|
||||
COORDINATE_HALF_SPAN_DEG,
|
||||
),
|
||||
},
|
||||
answered: true,
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
/**
|
||||
* Strict decimal-degree coordinate parsing for the search box.
|
||||
*
|
||||
* A search that guesses is worse than one that declines: reading `12junk, 34oops`
|
||||
* 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;
|
||||
* they fall through to the geocoders, which is what happens today.
|
||||
*/
|
||||
|
||||
/** One decimal-degree component: an optional hemisphere letter on either side. */
|
||||
const COMPONENT =
|
||||
/^([NSEW])?\s*([+-]?(?:\d+(?:\.\d+)?|\.\d+))\s*°?\s*([NSEW])?$/i;
|
||||
|
||||
/** Hemisphere letters, by the axis each one can name. */
|
||||
const LATITUDE_LETTERS = new Set(['N', 'S']);
|
||||
|
||||
/**
|
||||
* Read one component into a signed value plus the axis its letter names.
|
||||
*
|
||||
* @param {string} text
|
||||
* @returns {{ value: number, axis: 'lat'|'lon'|null } | null}
|
||||
*/
|
||||
function readComponent(text) {
|
||||
const match = COMPONENT.exec(String(text).trim());
|
||||
if (!match) return null;
|
||||
const [, before, digits, after] = match;
|
||||
// Two letters on one component is a contradiction, not a coordinate.
|
||||
if (before && after) return null;
|
||||
const letter = (before || after || '').toUpperCase();
|
||||
const value = Number(digits);
|
||||
if (!Number.isFinite(value)) return null;
|
||||
// A sign and a hemisphere letter both claim the direction; "-40N" means
|
||||
// neither one thing nor the other, so it is refused rather than guessed.
|
||||
if (letter && /^[+-]/.test(digits)) return null;
|
||||
if (!letter) return { value, axis: null };
|
||||
const magnitude = Math.abs(value);
|
||||
return {
|
||||
value: letter === 'S' || letter === 'W' ? -magnitude : magnitude,
|
||||
axis: LATITUDE_LETTERS.has(letter) ? 'lat' : 'lon',
|
||||
};
|
||||
}
|
||||
|
||||
/** Split on a comma, a semicolon, or whitespace — never on anything else. */
|
||||
function splitComponents(query) {
|
||||
const text = String(query).trim();
|
||||
if (!text) return null;
|
||||
const parts = /[,;]/.test(text)
|
||||
? text.split(/[,;]/)
|
||||
: text.split(/\s+(?=[+-]?[.\d]|[NSEW]\s*[+-]?[.\d])/i);
|
||||
const trimmed = parts.map((part) => part.trim()).filter(Boolean);
|
||||
return trimmed.length === 2 ? trimmed : null;
|
||||
}
|
||||
|
||||
/** Present a parsed pair the way a coordinate is normally written. */
|
||||
export function formatCoordinateLabel(lat, lon) {
|
||||
const latLetter = lat >= 0 ? 'N' : 'S';
|
||||
const lonLetter = lon >= 0 ? 'E' : 'W';
|
||||
return `${Math.abs(lat).toFixed(4)}° ${latLetter}, ${Math.abs(lon).toFixed(4)}° ${lonLetter}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a decimal-degree query, 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.
|
||||
*
|
||||
* @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);
|
||||
if (!parts) return null;
|
||||
|
||||
const first = readComponent(parts[0]);
|
||||
const second = readComponent(parts[1]);
|
||||
if (!first || !second) return null;
|
||||
// Both components naming the same axis leaves the other one unstated.
|
||||
if (first.axis && first.axis === second.axis) return null;
|
||||
|
||||
let lat;
|
||||
let lon;
|
||||
if (first.axis === 'lon' || second.axis === 'lat') {
|
||||
lat = second.value;
|
||||
lon = first.value;
|
||||
} else {
|
||||
lat = first.value;
|
||||
lon = second.value;
|
||||
}
|
||||
|
||||
if (!Number.isFinite(lat) || !Number.isFinite(lon)) return null;
|
||||
if (lat < -90 || lat > 90) return null;
|
||||
if (lon < -180 || lon > 180) return null;
|
||||
return { lat, lon, label: formatCoordinateLabel(lat, lon) };
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
// COORDINATE PARSING — what the search box accepts as a coordinate, and what it
|
||||
// refuses. A refusal hands the text to the geocoders; a wrong accept flies the
|
||||
// operator somewhere they never asked for and looks like success, so every
|
||||
// malformed and ambiguous form below is pinned.
|
||||
//
|
||||
// Run with: npm test
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {
|
||||
parseCoordinateQuery,
|
||||
formatCoordinateLabel,
|
||||
} from './coordinateParser.js';
|
||||
|
||||
test('a plain decimal pair is latitude then longitude', () => {
|
||||
assert.deepEqual(parseCoordinateQuery('43.1731, -79.0384'), {
|
||||
lat: 43.1731,
|
||||
lon: -79.0384,
|
||||
label: '43.1731° N, 79.0384° W',
|
||||
});
|
||||
assert.deepEqual(parseCoordinateQuery('43.1731,-79.0384')?.lat, 43.1731);
|
||||
assert.deepEqual(
|
||||
parseCoordinateQuery(' 43.1731 ; -79.0384 ')?.lon,
|
||||
-79.0384,
|
||||
);
|
||||
assert.deepEqual(parseCoordinateQuery('43.1731 -79.0384')?.lon, -79.0384);
|
||||
assert.deepEqual(parseCoordinateQuery('0, 0'), {
|
||||
lat: 0,
|
||||
lon: 0,
|
||||
label: '0.0000° N, 0.0000° E',
|
||||
});
|
||||
assert.deepEqual(parseCoordinateQuery('-33.8568, 151.2153')?.lat, -33.8568);
|
||||
assert.deepEqual(parseCoordinateQuery('30, -97')?.lat, 30);
|
||||
assert.deepEqual(parseCoordinateQuery('.5, -.25')?.lon, -0.25);
|
||||
});
|
||||
|
||||
test('hemisphere letters fix the axis, in either order', () => {
|
||||
assert.deepEqual(parseCoordinateQuery('N 40, W 74'), {
|
||||
lat: 40,
|
||||
lon: -74,
|
||||
label: '40.0000° N, 74.0000° W',
|
||||
});
|
||||
// The reviewer's case: written longitude-first, it must not read as latitude.
|
||||
assert.deepEqual(parseCoordinateQuery('W 40, N 74'), {
|
||||
lat: 74,
|
||||
lon: -40,
|
||||
label: '74.0000° N, 40.0000° W',
|
||||
});
|
||||
assert.deepEqual(parseCoordinateQuery('40N, 74W')?.lon, -74);
|
||||
assert.deepEqual(parseCoordinateQuery('74w, 40n'), {
|
||||
lat: 40,
|
||||
lon: -74,
|
||||
label: '40.0000° N, 74.0000° W',
|
||||
});
|
||||
assert.deepEqual(parseCoordinateQuery('40° N, 74° W')?.lat, 40);
|
||||
// One letter is enough: it fixes its own axis, the other takes what is left.
|
||||
assert.deepEqual(parseCoordinateQuery('40 N, -74')?.lon, -74);
|
||||
assert.deepEqual(parseCoordinateQuery('-74, 40 N'), {
|
||||
lat: 40,
|
||||
lon: -74,
|
||||
label: '40.0000° N, 74.0000° W',
|
||||
});
|
||||
});
|
||||
|
||||
test('malformed and ambiguous coordinates are refused, never guessed', () => {
|
||||
for (const query of [
|
||||
// The reviewer's case: trailing text must not be discarded.
|
||||
'12junk, 34oops',
|
||||
'12junk,34',
|
||||
'43.1731, -79.0384x',
|
||||
'abc, def',
|
||||
'Paris',
|
||||
'New York, NY',
|
||||
'',
|
||||
' ',
|
||||
',',
|
||||
'40',
|
||||
'40,',
|
||||
', 74',
|
||||
'40, 74, 12',
|
||||
'40.0.0, 74',
|
||||
'1e3, 5',
|
||||
'0x40, 74',
|
||||
'40 74 12',
|
||||
// Two letters naming the same axis leaves the other axis unstated.
|
||||
'N 40, N 74',
|
||||
'E 40, W 74',
|
||||
'S 40, 40 S',
|
||||
// A sign and a letter both claim the direction.
|
||||
'-40N, 74W',
|
||||
'N -40, W 74',
|
||||
// Two letters on one component.
|
||||
'N40W, 74',
|
||||
// Out of range.
|
||||
'91, 0',
|
||||
'-90.1, 0',
|
||||
'0, 181',
|
||||
'0, -180.5',
|
||||
'N 100, W 74',
|
||||
// Degrees/minutes/seconds is out of scope and must fall through, not
|
||||
// be read as a decimal pair.
|
||||
'40°26\'46"N, 79°58\'56"W',
|
||||
'40 26 46, 79 58 56',
|
||||
// A grid reference is out of scope too.
|
||||
'33UXP0500444998',
|
||||
]) {
|
||||
assert.equal(
|
||||
parseCoordinateQuery(query),
|
||||
null,
|
||||
`${JSON.stringify(query)} must be refused`,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test('non-strings are refused without throwing', () => {
|
||||
for (const query of [null, undefined, 42, {}, [], ['40', '74']])
|
||||
assert.equal(parseCoordinateQuery(query), null);
|
||||
});
|
||||
|
||||
test('the label reads the way a coordinate is written', () => {
|
||||
assert.equal(
|
||||
formatCoordinateLabel(43.1731, -79.0384),
|
||||
'43.1731° N, 79.0384° W',
|
||||
);
|
||||
assert.equal(
|
||||
formatCoordinateLabel(-33.8568, 151.2153),
|
||||
'33.8568° S, 151.2153° E',
|
||||
);
|
||||
assert.equal(formatCoordinateLabel(0, 0), '0.0000° N, 0.0000° E');
|
||||
});
|
||||
|
||||
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);
|
||||
});
|
||||
+14
-1
@@ -3,18 +3,31 @@ import { createHttpGeospatialProvider } from './http.js';
|
||||
import { createPlaceSearch } from './placeSearch.js';
|
||||
import { createGoogleGeocoder } from './google.js';
|
||||
import { createPhotonGeocoder } from '../keylessGeocoder.js';
|
||||
import { createCoordinateGeocoder } from './coordinateGeocoder.js';
|
||||
import { createPresetGeocoder } from './presetGeocoder.js';
|
||||
|
||||
/** Google first when configured, then keyless Photon; transport stays local to setup. */
|
||||
/**
|
||||
* Coordinates and bundled names first — both answer offline and with no key —
|
||||
* then Google when configured, then keyless Photon. Transport stays local to
|
||||
* setup.
|
||||
*
|
||||
* `presets` is the caller's bundled place data. It is passed in rather than
|
||||
* imported so this package keeps reading no application state; with none
|
||||
* supplied there is simply no bundled-name provider.
|
||||
*/
|
||||
export function createDefaultPlaceSearch({
|
||||
resolveApiKey,
|
||||
fetchImpl = (...args) => fetch(...args),
|
||||
signal,
|
||||
endpoints = {},
|
||||
providers = {},
|
||||
presets = null,
|
||||
} = {}) {
|
||||
const forward = createPlaceSearch({
|
||||
signal,
|
||||
providers: providers.geocode || [
|
||||
createCoordinateGeocoder(),
|
||||
...(presets ? [createPresetGeocoder({ presets })] : []),
|
||||
createGoogleGeocoder({
|
||||
request(query, { bias, signal }) {
|
||||
const key = resolveApiKey?.();
|
||||
|
||||
@@ -7,3 +7,9 @@ export {
|
||||
} from './http.js';
|
||||
export { createPhotonGeocoder } from '../keylessGeocoder.js';
|
||||
export { createDefaultPlaceSearch } from './defaults.js';
|
||||
export { createCoordinateGeocoder } from './coordinateGeocoder.js';
|
||||
export { createPresetGeocoder } from './presetGeocoder.js';
|
||||
export {
|
||||
parseCoordinateQuery,
|
||||
formatCoordinateLabel,
|
||||
} from './coordinateParser.js';
|
||||
@@ -0,0 +1,191 @@
|
||||
// OFFLINE GEOCODERS — the two providers that answer with no key and no request,
|
||||
// and their place in the chain. What matters here is that they answer only what
|
||||
// they are certain of, hand everything else on, and produce the box shape the
|
||||
// camera framing actually reads.
|
||||
//
|
||||
// Run with: npm test
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { createCoordinateGeocoder } from './coordinateGeocoder.js';
|
||||
import { createPresetGeocoder } from './presetGeocoder.js';
|
||||
import { createPlaceSearch } from './placeSearch.js';
|
||||
|
||||
const PRESETS = {
|
||||
austin: {
|
||||
name: 'Austin',
|
||||
viewBounds: {
|
||||
southwest: { lat: 30.1, lng: -97.95 },
|
||||
northeast: { lat: 30.52, lng: -97.55 },
|
||||
},
|
||||
pois: [
|
||||
{ name: 'Texas State Capitol', lat: 30.2747, lon: -97.7403 },
|
||||
{ name: 'Pennybacker Bridge', lat: 30.3451, lon: -97.7951 },
|
||||
],
|
||||
},
|
||||
sf: {
|
||||
name: 'San Francisco',
|
||||
viewBounds: {
|
||||
southwest: { lat: 37.7, lng: -122.52 },
|
||||
northeast: { lat: 37.84, lng: -122.35 },
|
||||
},
|
||||
pois: [{ name: 'Golden Gate Bridge', lat: 37.8199, lon: -122.4783 }],
|
||||
},
|
||||
};
|
||||
|
||||
test('a coordinate answers with the point, a framing box, and no request', async () => {
|
||||
const geocoder = createCoordinateGeocoder();
|
||||
const { place, answered } = await geocoder.geocode('43.1731, -79.0384');
|
||||
assert.equal(answered, true);
|
||||
assert.equal(place.lat, 43.1731);
|
||||
assert.equal(place.lng, -79.0384);
|
||||
assert.deepEqual(place.types, ['coordinate']);
|
||||
assert.equal(place.exact, true, 'nothing nearby may replace a typed point');
|
||||
// The framing code reads southwest/northeast with lat/lng — any other shape
|
||||
// is silently ignored and the place is framed as if it had no box at all.
|
||||
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('a coordinate box stays inside the poles and wraps at the dateline', async () => {
|
||||
const geocoder = createCoordinateGeocoder();
|
||||
const pole = (await geocoder.geocode('90, 0')).place;
|
||||
assert.equal(
|
||||
pole.viewport.northeast.lat,
|
||||
90,
|
||||
'latitude cannot pass the pole',
|
||||
);
|
||||
assert.ok(pole.viewport.southwest.lat < 90);
|
||||
|
||||
const dateline = (await geocoder.geocode('0, 179.995')).place;
|
||||
assert.ok(
|
||||
dateline.viewport.northeast.lng <= 180 &&
|
||||
dateline.viewport.northeast.lng >= -180,
|
||||
`east edge ${dateline.viewport.northeast.lng} must be a real longitude`,
|
||||
);
|
||||
assert.ok(
|
||||
dateline.viewport.northeast.lng < 0,
|
||||
'the east edge wraps past 180',
|
||||
);
|
||||
});
|
||||
|
||||
test('a query that is not a coordinate is passed on, not declined', async () => {
|
||||
const geocoder = createCoordinateGeocoder();
|
||||
for (const query of ['paris', '12junk, 34oops', '']) {
|
||||
const outcome = await geocoder.geocode(query);
|
||||
assert.equal(outcome.place, null);
|
||||
assert.equal(
|
||||
outcome.answered,
|
||||
true,
|
||||
'having nothing to say is not a verdict on the query',
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test('bundled names match exactly: city id, city name, or landmark', async () => {
|
||||
const geocoder = createPresetGeocoder({ presets: PRESETS });
|
||||
const city = (await geocoder.geocode('austin')).place;
|
||||
assert.equal(city.label, 'Austin');
|
||||
assert.deepEqual(city.types, ['locality']);
|
||||
assert.deepEqual(city.viewport, PRESETS.austin.viewBounds);
|
||||
assert.equal(city.lat, 30.2747, 'a city anchors on its first landmark');
|
||||
|
||||
assert.equal((await geocoder.geocode('SF')).place.label, 'San Francisco');
|
||||
assert.equal(
|
||||
(await geocoder.geocode('san francisco')).place.label,
|
||||
'San Francisco',
|
||||
);
|
||||
assert.equal((await geocoder.geocode(' AUSTIN ')).place.label, 'Austin');
|
||||
|
||||
const landmark = (await geocoder.geocode('pennybacker bridge')).place;
|
||||
assert.equal(landmark.label, 'Pennybacker Bridge, Austin');
|
||||
assert.deepEqual(landmark.types, ['point_of_interest']);
|
||||
assert.equal(landmark.lat, 30.3451);
|
||||
assert.equal(landmark.exact, true);
|
||||
});
|
||||
|
||||
test('a near miss is handed on rather than answered from the bundle', async () => {
|
||||
const geocoder = createPresetGeocoder({ presets: PRESETS });
|
||||
for (const query of [
|
||||
'austin texas',
|
||||
'austin, tx',
|
||||
'aus',
|
||||
'new austin',
|
||||
'golden gate',
|
||||
'bridge',
|
||||
'',
|
||||
'a',
|
||||
]) {
|
||||
const outcome = await geocoder.geocode(query);
|
||||
assert.equal(outcome.place, null, `${query} must not match a bundled name`);
|
||||
assert.equal(outcome.answered, true);
|
||||
}
|
||||
});
|
||||
|
||||
test('an empty or malformed preset set answers nothing and throws nothing', async () => {
|
||||
for (const presets of [
|
||||
undefined,
|
||||
{},
|
||||
{ broken: {} },
|
||||
{ broken: { name: 'Broken', pois: [{ name: 'No place' }] } },
|
||||
]) {
|
||||
const geocoder = createPresetGeocoder({ presets });
|
||||
assert.equal((await geocoder.geocode('broken')).place, null);
|
||||
assert.equal((await geocoder.geocode('no place')).place, null);
|
||||
}
|
||||
});
|
||||
|
||||
test('the offline providers answer ahead of the network ones, which stay in order', async () => {
|
||||
const asked = [];
|
||||
const network = (name) => ({
|
||||
async geocode(query) {
|
||||
asked.push(`${name}:${query}`);
|
||||
return name === 'google'
|
||||
? { place: null, answered: true }
|
||||
: {
|
||||
place: { lat: 1, lng: 2, label: 'photon hit', types: [] },
|
||||
answered: true,
|
||||
};
|
||||
},
|
||||
});
|
||||
const search = createPlaceSearch({
|
||||
providers: [
|
||||
createCoordinateGeocoder(),
|
||||
createPresetGeocoder({ presets: PRESETS }),
|
||||
network('google'),
|
||||
network('photon'),
|
||||
],
|
||||
});
|
||||
|
||||
const coordinate = await search.geocode('43.1731, -79.0384');
|
||||
assert.equal(coordinate.place.lat, 43.1731);
|
||||
assert.deepEqual(asked, [], 'a coordinate reaches no network provider');
|
||||
|
||||
const preset = await search.geocode('paris');
|
||||
assert.equal(preset.place.label, 'photon hit');
|
||||
assert.deepEqual(
|
||||
asked,
|
||||
['google:paris', 'photon:paris'],
|
||||
'an unknown name still walks Google then Photon',
|
||||
);
|
||||
|
||||
asked.length = 0;
|
||||
const bundled = await search.geocode('Golden Gate Bridge');
|
||||
assert.equal(bundled.place.label, 'Golden Gate Bridge, San Francisco');
|
||||
assert.deepEqual(asked, [], 'a bundled name reaches no network provider');
|
||||
});
|
||||
|
||||
test('a cancelled search stops the offline providers too', async () => {
|
||||
const controller = new AbortController();
|
||||
controller.abort();
|
||||
for (const geocoder of [
|
||||
createCoordinateGeocoder(),
|
||||
createPresetGeocoder({ presets: PRESETS }),
|
||||
]) {
|
||||
await assert.rejects(
|
||||
geocoder.geocode('austin', { signal: controller.signal }),
|
||||
(error) => error.name === 'AbortError',
|
||||
);
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,113 @@
|
||||
/** Half-width of the box a bundled landmark is framed with, in degrees. */
|
||||
const LANDMARK_HALF_SPAN_DEG = 0.005;
|
||||
|
||||
/** Fold a name to the form an exact comparison uses: case, spacing, punctuation. */
|
||||
function normalizeName(value) {
|
||||
return String(value || '')
|
||||
.toLowerCase()
|
||||
.normalize('NFKD')
|
||||
.replace(/[^\p{L}\p{N}\s]/gu, ' ')
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim();
|
||||
}
|
||||
|
||||
/** Keep a generated box inside the poles and inside the dateline. */
|
||||
function boundedBox(lat, lon, halfSpan) {
|
||||
const wrap = (value) => {
|
||||
const wrapped = ((((value + 180) % 360) + 360) % 360) - 180;
|
||||
return wrapped === -180 ? 180 : wrapped;
|
||||
};
|
||||
return {
|
||||
southwest: {
|
||||
lat: Math.max(-90, lat - halfSpan),
|
||||
lng: wrap(lon - halfSpan),
|
||||
},
|
||||
northeast: { lat: Math.min(90, lat + halfSpan), lng: wrap(lon + halfSpan) },
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the exact-match index once, so a search is a lookup rather than a scan.
|
||||
*
|
||||
* Only exact names match. A bundled preset that answered a partial or fuzzy
|
||||
* query would shadow the real geocoders for everything that merely resembles a
|
||||
* bundled name, and the operator would have no way to reach the place they
|
||||
* meant.
|
||||
*/
|
||||
function indexPresets(presets) {
|
||||
const byName = new Map();
|
||||
const remember = (key, place) => {
|
||||
const name = normalizeName(key);
|
||||
if (name && !byName.has(name)) byName.set(name, place);
|
||||
};
|
||||
|
||||
for (const [cityId, city] of Object.entries(presets || {})) {
|
||||
const anchor = city?.pois?.[0];
|
||||
const bounds = city?.viewBounds;
|
||||
const lat = Number(anchor?.lat);
|
||||
const lon = Number(anchor?.lon);
|
||||
if (Number.isFinite(lat) && Number.isFinite(lon)) {
|
||||
const place = {
|
||||
lat,
|
||||
lng: lon,
|
||||
name: city.name,
|
||||
label: city.name,
|
||||
types: ['locality'],
|
||||
exact: true,
|
||||
viewport:
|
||||
Number.isFinite(bounds?.southwest?.lat) &&
|
||||
Number.isFinite(bounds?.southwest?.lng) &&
|
||||
Number.isFinite(bounds?.northeast?.lat) &&
|
||||
Number.isFinite(bounds?.northeast?.lng)
|
||||
? {
|
||||
southwest: {
|
||||
lat: bounds.southwest.lat,
|
||||
lng: bounds.southwest.lng,
|
||||
},
|
||||
northeast: {
|
||||
lat: bounds.northeast.lat,
|
||||
lng: bounds.northeast.lng,
|
||||
},
|
||||
}
|
||||
: boundedBox(lat, lon, 0.1),
|
||||
};
|
||||
remember(cityId, place);
|
||||
remember(city.name, place);
|
||||
}
|
||||
|
||||
for (const poi of city?.pois || []) {
|
||||
const poiLat = Number(poi?.lat);
|
||||
const poiLon = Number(poi?.lon);
|
||||
if (!Number.isFinite(poiLat) || !Number.isFinite(poiLon)) continue;
|
||||
remember(poi.name, {
|
||||
lat: poiLat,
|
||||
lng: poiLon,
|
||||
name: poi.name,
|
||||
label: city?.name ? `${poi.name}, ${city.name}` : poi.name,
|
||||
types: ['point_of_interest'],
|
||||
exact: true,
|
||||
viewport: boundedBox(poiLat, poiLon, LANDMARK_HALF_SPAN_DEG),
|
||||
});
|
||||
}
|
||||
}
|
||||
return byName;
|
||||
}
|
||||
|
||||
/**
|
||||
* Answer the bundled city and landmark names without asking anyone.
|
||||
*
|
||||
* Matching is exact on the city id, the city name, or a landmark name; anything
|
||||
* else is passed on to the network geocoders untouched.
|
||||
*/
|
||||
export function createPresetGeocoder({ presets = {} } = {}) {
|
||||
const byName = indexPresets(presets);
|
||||
return {
|
||||
async geocode(query, { signal } = {}) {
|
||||
signal?.throwIfAborted();
|
||||
const place = byName.get(normalizeName(query));
|
||||
return place
|
||||
? { place, answered: true }
|
||||
: { place: null, answered: true };
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import { createStandaloneCatalog } from './catalog.js';
|
||||
import { createStandalonePlaceSearch } from './placeSearch.js';
|
||||
import { CITY_POIS } from '../locations.js';
|
||||
import { createApplication } from '../app/application.js';
|
||||
import { createStandaloneScene } from './scene.js';
|
||||
import { createStandaloneControls } from './controls.js';
|
||||
@@ -27,6 +28,10 @@ export function createStandaloneApplication({
|
||||
return createApplication({
|
||||
createScene: async (context) => {
|
||||
placeSearch = createStandalonePlaceSearch({
|
||||
// The bundled city and landmark data the offline name provider reads.
|
||||
// The search package takes it as plain data rather than importing it,
|
||||
// so it stays free of application state.
|
||||
presets: CITY_POIS,
|
||||
...geospatial,
|
||||
resolveApiKey: () => googleApiKey,
|
||||
signal: context.signal,
|
||||
|
||||
Reference in new issue
Block a user