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
+672 -14

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
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
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
`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.
+5
View File
@@ -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",
+16 -4
View File
@@ -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
View File
@@ -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;
+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 { 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?.();
+6
View File
@@ -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';
+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 { 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,