feat(outlines): restore voice outlines without public Overpass

use bundled place boundaries and shared OpenFreeMap tiles before a guarded Nominatim fallback

preserve multipart shapes, holes and cancellation,
and keep pending annotations visible while ground sampling finishes across frames

keep non-Latin name distinctions when selecting provider results,
and retain an outline anchor only when it lies in filled geometry
This commit is contained in:
manjunath committed 2026-10-27 13:24:02 +00:00
commit d4434aa6d7
1489 files changed
+196442 -12353

No files matched your search

+72
View File
@@ -0,0 +1,72 @@
import { findNaturalRegion, pointInRing } from '../data/naturalEarthRegions.js';
/**
* The unqualified Alps ask names the European range, not a same-named peak
* or local POI. Keep this correction exact and Alps-only: other ranges and
* geographic qualifiers retain the caller's configured geocoder semantics.
*/
export function createAlpsGeocoder({ lookupRegion = findNaturalRegion } = {}) {
return {
async geocode(query, { signal } = {}) {
signal?.throwIfAborted();
const name = String(query ?? '')
.trim()
.replace(/\s+/g, ' ');
if (!/^(?:the )?alps(?: mountains)?$/i.test(name))
return { place: null, answered: true };
const region = await lookupRegion('Alps');
signal?.throwIfAborted();
if (
region?.name !== 'Alps' ||
region.kind !== 'natural' ||
!Array.isArray(region.polygons) ||
region.polygons.length === 0 ||
!region.polygons.every(
(ring) =>
Array.isArray(ring) &&
ring.length >= 3 &&
ring.every(
(point) =>
Array.isArray(point) &&
point.length === 2 &&
point.every(Number.isFinite) &&
Math.abs(point[0]) <= 180 &&
Math.abs(point[1]) <= 90,
),
)
)
throw new Error('Bundled Alps geometry unavailable');
const vertices = region.polygons.flat();
const west = Math.min(...vertices.map(([lon]) => lon));
const east = Math.max(...vertices.map(([lon]) => lon));
const south = Math.min(...vertices.map(([, lat]) => lat));
const north = Math.max(...vertices.map(([, lat]) => lat));
const lat = (south + north) / 2;
const lng = (west + east) / 2;
if (
![west, east, south, north, lat, lng].every(Number.isFinite) ||
!region.polygons.some((ring) => pointInRing(ring, lat, lng))
) {
// A broken pack is not permission to cache an unrelated network peak
// as the canonical range. Existing search error/retry handling owns it.
throw new Error('Bundled Alps geometry unavailable');
}
return {
answered: true,
place: {
lat,
lng,
name: region.name,
label: region.name,
types: ['natural_feature'],
exact: true,
viewport: {
southwest: { lat: south, lng: west },
northeast: { lat: north, lng: east },
},
},
};
},
};
}
@@ -0,0 +1,12 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { parseCoordinateQuery } from './coordinateParser.js';
test('search refuses omitted components and repeated separators', () => {
for (const q of ['30,, -97', ',30,-97', '30,-97,', '30;; -97', '30,; -97'])
assert.equal(parseCoordinateQuery(q), null, q);
for (const q of ['30,-97', '30; -97', '30 -97', 'N 30, W 97'])
assert.deepEqual(
[parseCoordinateQuery(q).lat, parseCoordinateQuery(q).lon],
[30, -97],
);
});
+1 -1
View File
@@ -22,7 +22,7 @@ function boundedBox(lat, lon, halfSpan) {
}
/**
* Answer a decimal-degree query without asking anyone.
* Answer a decimal-degree or geo-URI query without asking anyone.
*
* It sits ahead of the network geocoders, so a coordinate costs no request and
* works with no key. Anything that is not exactly a coordinate is passed on
+41 -9
View File
@@ -5,7 +5,8 @@
* as 12, 34 flies the operator somewhere they did not ask for and looks like a
* successful search. So every rule here is exact — a component is a decimal
* number, optionally signed, optionally marked with one hemisphere letter, and
* nothing else. Degrees/minutes/seconds and grid references are not handled;
* nothing else. Two-dimensional WGS84 geo URIs use their own strict grammar.
* Degrees/minutes/seconds and grid references are not handled;
* they fall through to the geocoders, which is what happens today.
*/
@@ -13,6 +14,10 @@
const COMPONENT =
/^([NSEW])?\s*([+-]?(?:\d+(?:\.\d+)?|\.\d+))\s*°?\s*([NSEW])?$/i;
/** RFC 5870's 2D WGS84 subset; never discard altitude or uncertainty. */
const GEO_URI =
/^geo:(-?\d{1,2}(?:\.\d+)?),(-?\d{1,3}(?:\.\d+)?)(?:;crs=wgs84)?$/i;
/** Hemisphere letters, by the axis each one can name. */
const LATITUDE_LETTERS = new Set(['N', 'S']);
@@ -42,15 +47,37 @@ function readComponent(text) {
};
}
/** Split on a comma, a semicolon, or whitespace — never on anything else. */
/**
* Split on a comma, a semicolon, or whitespace — never on anything else.
* A hemisphere letter stays with the number it names, so `40.7 N 74.0 W`
* is two components. Splitting on the space before that letter used to
* leave three pieces and the search declined the coordinate.
*/
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;
if (/[,;]/.test(text)) {
const trimmed = text.split(/[,;]/).map((part) => part.trim());
return trimmed.length === 2 && trimmed.every(Boolean) ? trimmed : null;
}
const words = text.split(/\s+/);
// Each component has at most a number, a degree sign and one hemisphere.
if (words.length > 6) return null;
// Keep every character: extracting tokens would turn `1e3` or `1.2.3`
// into invented coordinates. A lone letter between numbers can name either
// component; require an unambiguous split rather than guessing its owner.
let result = null;
for (let boundary = words.length - 1; boundary > 0; boundary -= 1) {
const parts = [
words.slice(0, boundary).join(' '),
words.slice(boundary).join(' '),
];
if (parts.every((part) => readComponent(part))) {
if (result) return null;
result = parts;
}
}
return result;
}
/** Present a parsed pair the way a coordinate is normally written. */
@@ -61,20 +88,25 @@ export function formatCoordinateLabel(lat, lon) {
}
/**
* Parse a decimal-degree query, or return null.
* Parse a decimal-degree pair or a two-dimensional WGS84 geo URI, or return null.
*
* Both orders are accepted when the hemisphere letters say which is which
* (`W 40, N 74` is longitude then latitude); with no letters the conventional
* latitude-then-longitude order applies. Two letters naming the same axis, a
* letter that fixes nothing, an out-of-range latitude or longitude, and any
* trailing text all decline.
* Geo URIs accept only latitude/longitude and an optional `;crs=wgs84`;
* altitude, uncertainty, other parameters and query/fragment suffixes decline.
*
* @param {string} query
* @returns {{ lat: number, lon: number, label: string } | null}
*/
export function parseCoordinateQuery(query) {
if (typeof query !== 'string') return null;
const parts = splitComponents(query);
const text = query.trim();
const parts = /^geo:/i.test(text)
? GEO_URI.exec(text)?.slice(1, 3)
: splitComponents(text);
if (!parts) return null;
const first = readComponent(parts[0]);
+114
View File
@@ -52,6 +52,19 @@ test('hemisphere letters fix the axis, in either order', () => {
label: '40.0000° N, 74.0000° W',
});
assert.deepEqual(parseCoordinateQuery('40° N, 74° W')?.lat, 40);
// Written the way a coordinate is usually pasted, with no comma.
assert.deepEqual(parseCoordinateQuery('40.7128 N 74.0060 W'), {
lat: 40.7128,
lon: -74.006,
label: '40.7128° N, 74.0060° W',
});
assert.deepEqual(parseCoordinateQuery('40.7128° N 74.0060° W')?.lon, -74.006);
assert.deepEqual(parseCoordinateQuery('N 40.7128 W 74.0060'), {
lat: 40.7128,
lon: -74.006,
label: '40.7128° N, 74.0060° W',
});
assert.deepEqual(parseCoordinateQuery('74.0060 W 40.7128 N')?.lat, 40.7128);
// 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'), {
@@ -66,6 +79,21 @@ test('malformed and ambiguous coordinates are refused, never guessed', () => {
// The reviewer's case: trailing text must not be discarded.
'12junk, 34oops',
'12junk,34',
'12junk 34',
'1e3 5',
'1e3',
'1E3',
'1e-3',
'1.2.3',
'12-34',
'40 NW 74',
'40 N 74',
'40 W 74',
'40 E 74',
'°40 74',
'40°° 74',
'40 74°°',
'40 26 46 N 79 58 56 W',
'43.1731, -79.0384x',
'abc, def',
'Paris',
@@ -132,3 +160,89 @@ test('the poles and the dateline are inside the accepted range', () => {
assert.deepEqual(parseCoordinateQuery('90, 180')?.lat, 90);
assert.deepEqual(parseCoordinateQuery('-90, -180')?.lon, -180);
});
test('two-dimensional WGS84 geo URIs resolve to the same decimal coordinate', () => {
for (const query of [
'geo:39.7392,-104.9903',
'geo:39.7392,-104.9903;crs=wgs84',
' GEO:39.7392,-104.9903;CRS=WGS84 ',
]) {
assert.deepEqual(parseCoordinateQuery(query), {
lat: 39.7392,
lon: -104.9903,
label: '39.7392° N, 104.9903° W',
});
}
for (const [query, decimal] of [
['geo:0,0', '0,0'],
['geo:-33.8568,151.2153', '-33.8568,151.2153'],
['geo:90,180', '90,180'],
['geo:-90,-180;crs=wgs84', '-90,-180'],
['geo:09,007', '9,7'],
['geo:-0,-0', '-0,-0'],
]) {
assert.deepEqual(
parseCoordinateQuery(query),
parseCoordinateQuery(decimal),
);
}
});
test('malformed or unsupported geo URIs are refused without losing their meaning', () => {
for (const query of [
'geo:',
'geo:39.7392',
'geo:,104.9903',
'geo:39.7392,,104.9903',
'geo:39.7392,-104.9903,',
'geo:91,0',
'geo:-90.1,0',
'geo:0,181',
'geo:0,-180.1',
'geo:000,0',
'geo:0,0000',
'geo:+39.7392,-104.9903',
'geo:.5,-.25',
'geo:1e1,2',
'geo:NaN,0',
'geo:Infinity,0',
'geo:39.7392, -104.9903',
'geo:39.7392;-104.9903',
'geo:39.7392N,104.9903W',
'geo:39.7392,-104.9903,1600',
'geo:39.7392,-104.9903;u=10',
'geo:39.7392,-104.9903;crs=unknown',
'geo:39.7392,-104.9903;crs=wgs84;crs=wgs84',
'geo:39.7392,-104.9903;crs=wgs84;u=0',
'geo:39.7392,-104.9903;custom=value',
'geo:0,0?q=39.7392,-104.9903(Denver)',
'geo:39.7392,-104.9903#Denver',
'geo:39.7392,-104.9903 trailing text',
'geo://39.7392,-104.9903',
'https://example.com/geo:39.7392,-104.9903',
]) {
assert.equal(parseCoordinateQuery(query), null, query);
}
});
test('whitespace boundaries preserve degree signs and hemisphere ownership', () => {
for (const query of ['40 ° 74', '40° 74', '40 N, 74', 'N 40 74']) {
assert.equal(parseCoordinateQuery(query)?.lat, 40, query);
assert.equal(parseCoordinateQuery(query)?.lon, 74, query);
}
for (const query of ['40 ° N 74 ° W', 'N 40 ° W 74 °', '40\tN\n74\tW']) {
assert.equal(parseCoordinateQuery(query)?.lat, 40, query);
assert.equal(parseCoordinateQuery(query)?.lon, -74, query);
}
});
test('a signed plain component leaves a spaced letter with the other component', () => {
assert.deepEqual(
parseCoordinateQuery('-40 N 74'),
parseCoordinateQuery('-40, N 74'),
);
assert.deepEqual(
parseCoordinateQuery('40 N -74'),
parseCoordinateQuery('40 N, -74'),
);
});
+17 -10
View File
@@ -6,9 +6,10 @@ import { createGoogleGeocoder } from './google.js';
import { createPhotonGeocoder } from '../keylessGeocoder.js';
import { createCoordinateGeocoder } from './coordinateGeocoder.js';
import { createPresetGeocoder } from './presetGeocoder.js';
import { createAlpsGeocoder } from './alpsGeocoder.js';
/**
* Coordinates and bundled names first — both answer offline and with no key —
* Coordinates, bundled names and the unqualified Alps first — all offline —
* then Google when configured, then keyless Photon, then the local Nominatim
* route as a last resort. Transport stays local to setup.
*
@@ -37,21 +38,27 @@ export function createDefaultPlaceSearch({
providers: providers.geocode || [
createCoordinateGeocoder(),
...(presets ? [createPresetGeocoder({ presets })] : []),
createAlpsGeocoder(),
...(selected
? [selected]
: [
// Google answers through the local proxy, which holds the server
// key: its Geocoding web service refuses referrer-restricted keys,
// so calling it from here would force the bundled browser key to be
// left unrestricted (#363). That key still gates the provider,
// because a keyless session must not spend a request to learn it
// has no Google: the proxy's "not configured" reads as an
// unanswered provider, which stops the chain caching the negative
// Photon or Nominatim then produces.
createGoogleGeocoder({
request(query, { bias, signal }) {
const key = resolveApiKey?.();
if (!key) return null;
const url = new URL(
endpoints.geocode ||
'https://maps.googleapis.com/maps/api/geocode/json',
if (!resolveApiKey?.()) return null;
const params = new URLSearchParams({ address: query });
if (bias) params.set('bounds', bias);
return fetchImpl(
`${endpoints.geocode || '/api/google/geocode'}?${params}`,
{ signal },
);
url.searchParams.set('address', query);
url.searchParams.set('key', key);
if (bias) url.searchParams.set('bounds', bias);
return fetchImpl(url.toString(), { signal });
},
}),
// `endpoints.photon === null` is the operator's "no Photon".
+7 -8
View File
@@ -53,7 +53,9 @@ export function createHttpGeospatialProvider({
endpoints = {},
} = {}) {
const urls = {
reverse: 'https://maps.googleapis.com/maps/api/geocode/json',
// Same-origin like its siblings below: Google's Geocoding web service
// refuses referrer-restricted keys, so the key stays on the server (#363).
reverse: '/api/google/reverse-geocode',
textSearch: '/api/google/text-search',
nearby: '/api/google/nearby-places',
route: '/api/route',
@@ -88,14 +90,11 @@ export function createHttpGeospatialProvider({
},
routeProfiles: ['foot', 'car', 'bike'],
async reverseGeocode(latitude, longitude, options) {
const key = resolveApiKey?.();
if (resolveApiKey && !key) return null;
// The browser key gates the provider without travelling with the
// request: the proxy supplies the key Google actually sees.
if (resolveApiKey && !resolveApiKey()) return null;
return normalizeGoogleReverse(
await json(
urls.reverse,
{ latlng: `${latitude},${longitude}`, ...(key ? { key } : {}) },
options,
),
await json(urls.reverse, { lat: latitude, lon: longitude }, options),
);
},
async textSearch(query, { latitude, longitude, radiusM = 6000 }, options) {
+50
View File
@@ -9,6 +9,7 @@ import assert from 'node:assert/strict';
import { createCoordinateGeocoder } from './coordinateGeocoder.js';
import { createPresetGeocoder } from './presetGeocoder.js';
import { createPlaceSearch } from './placeSearch.js';
import { createDefaultPlaceSearch } from './defaults.js';
const PRESETS = {
austin: {
@@ -189,3 +190,52 @@ test('a cancelled search stops the offline providers too', async () => {
);
}
});
test('default search resolves a geo URI exactly without requesting a geocoder', async () => {
const requests = [];
const search = createDefaultPlaceSearch({
resolveApiKey: () => 'test-key',
fetchImpl: async (url) => {
requests.push(url);
throw new Error('No network request should be needed');
},
});
const { place, fallbackUsed } = await search.geocode(
'geo:39.7392,-104.9903;crs=wgs84',
);
assert.deepEqual(requests, []);
assert.equal(fallbackUsed, false);
assert.equal(place.lat, 39.7392);
assert.equal(place.lng, -104.9903);
assert.equal(place.exact, true);
assert.deepEqual(place.types, ['coordinate']);
assert.ok(place.viewport.southwest.lat < place.lat);
assert.ok(place.viewport.northeast.lat > place.lat);
assert.ok(place.viewport.southwest.lng < place.lng);
assert.ok(place.viewport.northeast.lng > place.lng);
});
test('unsupported geo URIs reach the next search provider unchanged', async () => {
const queries = [
'geo:39.7392,-104.9903,1600',
'geo:39.7392,-104.9903;u=10',
'geo:39.7392,-104.9903;crs=unknown',
'geo:0,0?q=Denver',
];
const received = [];
const search = createPlaceSearch({
providers: [
createCoordinateGeocoder(),
{
async geocode(query) {
received.push(query);
return { place: null, answered: true };
},
},
],
});
for (const query of queries) {
assert.equal((await search.geocode(query)).place, null);
}
assert.deepEqual(received, queries);
});
+1 -21
View File
@@ -1,28 +1,8 @@
// PHOTON_URL: unset keeps the public default, empty disables, else a URL.
import test from 'node:test';
import assert from 'node:assert/strict';
import { photonEndpointSetting } from '../keylessGeocoder.js';
import { createDefaultPlaceSearch } from './defaults.js';
test('the Photon setting reads unset, disabled and operator values', () => {
assert.equal(photonEndpointSetting(undefined), undefined);
for (const off of [
'',
' ',
'off',
'none',
'disabled',
'not a url',
'ftp://x.test/',
'https://u:[email protected]/api/',
])
assert.equal(photonEndpointSetting(off), null, off);
assert.equal(
photonEndpointSetting('https://photon.example.test/api/'),
'https://photon.example.test/api/',
);
});
async function hosts(endpoints) {
const asked = [];
const search = createDefaultPlaceSearch({
@@ -35,7 +15,7 @@ async function hosts(endpoints) {
);
},
});
await search.geocode(`nowhere-${Math.random()}`);
await search.geocode('nowhere-photon-setting');
return asked;
}
+1 -1
View File
@@ -20,7 +20,7 @@ test('standalone geocoding falls back after Google connection, JSON and refusal
for (const fail of [() => { throw new Error('offline'); }, () => new Response('invalid json'), () => Response.json({ status: 'REQUEST_DENIED' })]) {
const urls = [];
const service = createStandalonePlaceSearch({ resolveApiKey: () => 'fixture', fetchImpl: async (url) => {
urls.push(url); return url.includes('maps.googleapis.com') ? fail() : hit();
urls.push(url); return url.includes('/api/google/geocode') ? fail() : hit();
} });
const result = await service.geocode('Hanoi');
assert.equal(result.place.name, 'Hà Nội');