diff --git a/CHANGELOG.md b/CHANGELOG.md index d78afc657..6ae9bcfbd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index 9fc27133a..e0fad54b8 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -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. diff --git a/scripts/format-scope.json b/scripts/format-scope.json index 054639107..e2d4521b7 100644 --- a/scripts/format-scope.json +++ b/scripts/format-scope.json @@ -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", diff --git a/scripts/package-boundaries.json b/scripts/package-boundaries.json index 6ca0583d9..d1a993cdd 100644 --- a/scripts/package-boundaries.json +++ b/scripts/package-boundaries.json @@ -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", diff --git a/src/locations.js b/src/locations.js index d8af47430..1752a0cfe 100644 --- a/src/locations.js +++ b/src/locations.js @@ -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; diff --git a/src/search/coordinateGeocoder.js b/src/search/coordinateGeocoder.js new file mode 100644 index 000000000..265a0e12c --- /dev/null +++ b/src/search/coordinateGeocoder.js @@ -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, + }; + }, + }; +} diff --git a/src/search/coordinateParser.js b/src/search/coordinateParser.js new file mode 100644 index 000000000..261e2394c --- /dev/null +++ b/src/search/coordinateParser.js @@ -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) }; +} diff --git a/src/search/coordinateParser.test.mjs b/src/search/coordinateParser.test.mjs new file mode 100644 index 000000000..393de37a4 --- /dev/null +++ b/src/search/coordinateParser.test.mjs @@ -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); +}); diff --git a/src/search/defaults.js b/src/search/defaults.js index 4668ed7ce..eab20333f 100644 --- a/src/search/defaults.js +++ b/src/search/defaults.js @@ -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?.(); diff --git a/src/search/index.js b/src/search/index.js index 8e0d4dbd1..119d95160 100644 --- a/src/search/index.js +++ b/src/search/index.js @@ -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'; diff --git a/src/search/offlineGeocoders.test.mjs b/src/search/offlineGeocoders.test.mjs new file mode 100644 index 000000000..f0d6a507a --- /dev/null +++ b/src/search/offlineGeocoders.test.mjs @@ -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', + ); + } +}); diff --git a/src/search/presetGeocoder.js b/src/search/presetGeocoder.js new file mode 100644 index 000000000..f0e192c98 --- /dev/null +++ b/src/search/presetGeocoder.js @@ -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 }; + }, + }; +} diff --git a/src/standalone/application.js b/src/standalone/application.js index 45cd9e721..599e004c9 100644 --- a/src/standalone/application.js +++ b/src/standalone/application.js @@ -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,