Merge upstream/main into fix/363-geocode-proxy

Resolve SECURITY.md against the Street View provider (#966): the browser
key now loads the Maps JavaScript API as well as Map Tiles, so the
'Map Tiles only' guidance becomes 'Map Tiles + Maps JavaScript API'
here and in CHANGELOG, CURRENT-STATE and google-key.js. The CHANGELOG
entry moves to [Unreleased]; its old section shipped in 0.2.0.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
Said TurkseverandClaude Opus 5.5 committed 2026-10-15 14:28:26 +01:00
commit 1fffdf7600
446 files changed
+72653 -2261

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],
);
});
+2 -2
View File
@@ -49,8 +49,8 @@ function splitComponents(query) {
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;
const trimmed = parts.map((part) => part.trim());
return trimmed.length === 2 && trimmed.every(Boolean) ? trimmed : null;
}
/** Present a parsed pair the way a coordinate is normally written. */
+3 -1
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,6 +38,7 @@ export function createDefaultPlaceSearch({
providers: providers.geocode || [
createCoordinateGeocoder(),
...(presets ? [createPresetGeocoder({ presets })] : []),
createAlpsGeocoder(),
...(selected
? [selected]
: [