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:
Bilawal SidhuandKuraPiee authored and GitHub committed 2026-09-14 21:18:07 -05:00
1 parent d9fac6d88e
commit cca6442997
13 files changed
+667 -9

No files matched your search

+7
View File
@@ -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 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 both follow Reset Globe out of Clean UI, recording, Scene playback and Cockpit
(#442 — thanks @yashveeeeeeer). (#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 - Add Open Calgary traffic cameras as a keyless CCTV source pack (thanks
@rileygramlich): the public City of Calgary catalog, frames pinned to the @rileygramlich): the public City of Calgary catalog, frames pinned to the
+11 -2
View File
@@ -3068,8 +3068,17 @@ silently demoting every later lookup for the session.
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
Google first when configured and Photon/OpenStreetMap as fallback, including two offline providers first — a strict decimal-degree coordinate reader and an
Google transport failures or declined requests. The portable `./search` export 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. provides the service and adapters; it reads no environment or application state.
Existing browser/server key setup is unchanged. Existing browser/server key setup is unchanged.
+5
View File
@@ -131,6 +131,11 @@
"src/search/index.js", "src/search/index.js",
"src/search/placeSearch.js", "src/search/placeSearch.js",
"src/search/google.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/keylessGeocoder.js",
"src/standalone/placeSearch.js", "src/standalone/placeSearch.js",
"src/ui/panelDisclosure.js", "src/ui/panelDisclosure.js",
+16 -4
View File
@@ -276,7 +276,10 @@
"src/keylessGeocoder.js", "src/keylessGeocoder.js",
"src/search/geospatial.js", "src/search/geospatial.js",
"src/search/http.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": [] "external": []
}, },
@@ -1587,7 +1590,10 @@
"src/data/routeSteps.js", "src/data/routeSteps.js",
"src/layers/directions/index.js", "src/layers/directions/index.js",
"src/data/directions.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": [ "external": [
"@mapbox/vector-tile", "@mapbox/vector-tile",
@@ -1932,7 +1938,10 @@
"src/data/terrainHeights.js", "src/data/terrainHeights.js",
"src/data/groundFloor.js", "src/data/groundFloor.js",
"src/data/directions.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": [ "external": [
"cesium", "cesium",
@@ -1973,7 +1982,10 @@
"src/services/groundSnap.js", "src/services/groundSnap.js",
"src/services/meshFloorSampler.js", "src/services/meshFloorSampler.js",
"src/services/requests.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": [ "external": [
"cesium", "cesium",
+8 -2
View File
@@ -793,8 +793,14 @@ export async function searchAndFlyTo(viewer, query, options = {}) {
let types = result?.types || []; let types = result?.types || [];
let viewport = result?.viewport || null; let viewport = result?.viewport || null;
// Nearby landmark recovery retains precedence over a fallback geocoder hit. // Nearby landmark recovery retains precedence over a fallback geocoder hit,
const recovered = await (options.recoverNearView || placesNearViewRecovery)( // 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, viewer,
query, query,
result && !outcome.fallbackUsed ? { lat, lon: lng } : null, result && !outcome.fallbackUsed ? { lat, lon: lng } : null,
+57
View File
@@ -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,
};
},
};
}
+100
View File
@@ -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) };
}
+134
View File
@@ -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
View File
@@ -3,18 +3,31 @@ import { createHttpGeospatialProvider } from './http.js';
import { createPlaceSearch } from './placeSearch.js'; import { createPlaceSearch } from './placeSearch.js';
import { createGoogleGeocoder } from './google.js'; import { createGoogleGeocoder } from './google.js';
import { createPhotonGeocoder } from '../keylessGeocoder.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({ export function createDefaultPlaceSearch({
resolveApiKey, resolveApiKey,
fetchImpl = (...args) => fetch(...args), fetchImpl = (...args) => fetch(...args),
signal, signal,
endpoints = {}, endpoints = {},
providers = {}, providers = {},
presets = null,
} = {}) { } = {}) {
const forward = createPlaceSearch({ const forward = createPlaceSearch({
signal, signal,
providers: providers.geocode || [ providers: providers.geocode || [
createCoordinateGeocoder(),
...(presets ? [createPresetGeocoder({ presets })] : []),
createGoogleGeocoder({ createGoogleGeocoder({
request(query, { bias, signal }) { request(query, { bias, signal }) {
const key = resolveApiKey?.(); const key = resolveApiKey?.();
+6
View File
@@ -7,3 +7,9 @@ export {
} from './http.js'; } from './http.js';
export { createPhotonGeocoder } from '../keylessGeocoder.js'; export { createPhotonGeocoder } from '../keylessGeocoder.js';
export { createDefaultPlaceSearch } from './defaults.js'; export { createDefaultPlaceSearch } from './defaults.js';
export { createCoordinateGeocoder } from './coordinateGeocoder.js';
export { createPresetGeocoder } from './presetGeocoder.js';
export {
parseCoordinateQuery,
formatCoordinateLabel,
} from './coordinateParser.js';
+191
View File
@@ -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',
);
}
});
+113
View File
@@ -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 };
},
};
}
+5
View File
@@ -1,5 +1,6 @@
import { createStandaloneCatalog } from './catalog.js'; import { createStandaloneCatalog } from './catalog.js';
import { createStandalonePlaceSearch } from './placeSearch.js'; import { createStandalonePlaceSearch } from './placeSearch.js';
import { CITY_POIS } from '../locations.js';
import { createApplication } from '../app/application.js'; import { createApplication } from '../app/application.js';
import { createStandaloneScene } from './scene.js'; import { createStandaloneScene } from './scene.js';
import { createStandaloneControls } from './controls.js'; import { createStandaloneControls } from './controls.js';
@@ -27,6 +28,10 @@ export function createStandaloneApplication({
return createApplication({ return createApplication({
createScene: async (context) => { createScene: async (context) => {
placeSearch = createStandalonePlaceSearch({ 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, ...geospatial,
resolveApiKey: () => googleApiKey, resolveApiKey: () => googleApiKey,
signal: context.signal, signal: context.signal,