mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-05 10:53:59 +00:00
feat(search): Nominatim as the last-resort geocoder (#573)
Supersedes #350 by @sendmebits, 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: sendmebits <[email protected]>
This commit is contained in:
1 parent
cca6442997
commit
7fa0cfbb26
15 files changed
+1077
-48
No files matched your search
@@ -0,0 +1,171 @@
|
||||
/**
|
||||
* Map a Nominatim search hit into the result shape the camera framing already
|
||||
* reads (types + viewport). Pure — no network.
|
||||
*
|
||||
* Every numeric field arrives as a string from the upstream JSON and may be
|
||||
* absent, blank, or nonsense. A coordinate that is not a real coordinate must
|
||||
* be refused rather than coerced: `Number(null)` is 0, so a missing pair read
|
||||
* as `(0, 0)` would fly the operator to the Atlantic and look like a hit.
|
||||
*/
|
||||
|
||||
const ADDRESS_TYPE_MAP = Object.freeze({
|
||||
country: ['country', 'political'],
|
||||
nation: ['country', 'political'],
|
||||
state: ['administrative_area_level_1', 'political'],
|
||||
province: ['administrative_area_level_1', 'political'],
|
||||
region: ['administrative_area_level_1', 'political'],
|
||||
county: ['administrative_area_level_2', 'political'],
|
||||
district: ['administrative_area_level_2', 'political'],
|
||||
municipality: ['locality', 'political'],
|
||||
city: ['locality', 'political'],
|
||||
town: ['locality', 'political'],
|
||||
village: ['locality', 'political'],
|
||||
hamlet: ['locality', 'political'],
|
||||
suburb: ['neighborhood', 'political'],
|
||||
neighbourhood: ['neighborhood', 'political'],
|
||||
neighborhood: ['neighborhood', 'political'],
|
||||
quarter: ['neighborhood', 'political'],
|
||||
postcode: ['postal_code'],
|
||||
road: ['route'],
|
||||
highway: ['route'],
|
||||
peak: ['natural_feature'],
|
||||
mountain_range: ['natural_feature'],
|
||||
wood: ['natural_feature'],
|
||||
forest: ['natural_feature'],
|
||||
park: ['park'],
|
||||
nature_reserve: ['park', 'natural_feature'],
|
||||
aerodrome: ['airport'],
|
||||
airport: ['airport'],
|
||||
university: ['university'],
|
||||
stadium: ['stadium'],
|
||||
island: ['natural_feature'],
|
||||
});
|
||||
|
||||
/**
|
||||
* Read a field that must be a finite number inside a range.
|
||||
*
|
||||
* Null, undefined, blank, whitespace, booleans, arrays and anything that does
|
||||
* not parse cleanly all return null. Only a string or number is considered, so
|
||||
* `Number([])` and `Number(true)` cannot become 0 and 1.
|
||||
*
|
||||
* @param {unknown} value
|
||||
* @param {number} min
|
||||
* @param {number} max
|
||||
* @returns {number|null}
|
||||
*/
|
||||
function boundedNumber(value, min, max) {
|
||||
if (typeof value !== 'number' && typeof value !== 'string') return null;
|
||||
if (typeof value === 'string' && value.trim() === '') return null;
|
||||
const parsed = Number(value);
|
||||
if (!Number.isFinite(parsed)) return null;
|
||||
return parsed >= min && parsed <= max ? parsed : null;
|
||||
}
|
||||
|
||||
/** A latitude that is really a latitude, or null. */
|
||||
export function nominatimLatitude(value) {
|
||||
return boundedNumber(value, -90, 90);
|
||||
}
|
||||
|
||||
/** A longitude that is really a longitude, or null. */
|
||||
export function nominatimLongitude(value) {
|
||||
return boundedNumber(value, -180, 180);
|
||||
}
|
||||
|
||||
/** Framing types for the camera, derived from the hit's class and type. */
|
||||
export function typesFromNominatim(hit) {
|
||||
const addresstype = String(hit?.addresstype || '').toLowerCase();
|
||||
const osmType = String(hit?.type || '').toLowerCase();
|
||||
const osmClass = String(hit?.class || '').toLowerCase();
|
||||
const mapped =
|
||||
ADDRESS_TYPE_MAP[addresstype] ||
|
||||
ADDRESS_TYPE_MAP[osmType] ||
|
||||
(osmClass === 'highway' ? ['route'] : null) ||
|
||||
(osmClass === 'leisure' ? ['park'] : null) ||
|
||||
(osmClass === 'natural' ? ['natural_feature'] : null) ||
|
||||
(osmClass === 'aeroway' ? ['airport'] : null) ||
|
||||
(osmClass === 'amenity' && osmType === 'university'
|
||||
? ['university']
|
||||
: null);
|
||||
return mapped ? [...mapped] : ['point_of_interest', 'establishment'];
|
||||
}
|
||||
|
||||
/**
|
||||
* The hit's bounding box as a viewport, or null.
|
||||
*
|
||||
* Nominatim gives `[south, north, west, east]` as strings. A box with an edge
|
||||
* outside the real world, an inverted edge, or a missing value is refused —
|
||||
* framing a viewport with a longitude of 999 sends the camera to nowhere.
|
||||
*
|
||||
* @param {object} hit
|
||||
* @returns {{southwest: {lat: number, lng: number}, northeast: {lat: number, lng: number}}|null}
|
||||
*/
|
||||
export function viewportFromNominatim(hit) {
|
||||
const box = Array.isArray(hit?.boundingbox) ? hit.boundingbox : [];
|
||||
if (box.length !== 4) return null;
|
||||
const south = nominatimLatitude(box[0]);
|
||||
const north = nominatimLatitude(box[1]);
|
||||
const west = nominatimLongitude(box[2]);
|
||||
const east = nominatimLongitude(box[3]);
|
||||
if (south === null || north === null || west === null || east === null)
|
||||
return null;
|
||||
if (south > north) return null;
|
||||
return {
|
||||
southwest: { lat: south, lng: west },
|
||||
northeast: { lat: north, lng: east },
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert one Nominatim hit into a geocoding result, or null.
|
||||
*
|
||||
* @param {object} hit
|
||||
* @returns {{geometry: object, formatted_address: string, types: string[], address_components: object[]}|null}
|
||||
*/
|
||||
export function nominatimToGeocodeResult(hit) {
|
||||
const lat = nominatimLatitude(hit?.lat);
|
||||
const lng = nominatimLongitude(hit?.lon);
|
||||
if (lat === null || lng === null) return null;
|
||||
const label = String(hit?.display_name || hit?.name || '').trim();
|
||||
if (!label) return null;
|
||||
const types = typesFromNominatim(hit);
|
||||
const viewport = viewportFromNominatim(hit);
|
||||
const canonical = String(hit?.name || '').trim();
|
||||
return {
|
||||
geometry: {
|
||||
location: { lat, lng },
|
||||
...(viewport ? { bounds: viewport, viewport } : {}),
|
||||
},
|
||||
formatted_address: label,
|
||||
types,
|
||||
address_components: canonical
|
||||
? [{ long_name: canonical, short_name: canonical, types: [...types] }]
|
||||
: [],
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Translate a `swLat,swLng|neLat,neLng` bias into Nominatim's
|
||||
* `viewbox=west,north,east,south`, or null when the string is unusable.
|
||||
*
|
||||
* Both edges are range-checked. A viewbox naming a longitude of 999 is not a
|
||||
* hint the upstream can use, and sending it is a malformed request rather than
|
||||
* a soft one.
|
||||
*
|
||||
* @param {string} bounds
|
||||
* @returns {string|null}
|
||||
*/
|
||||
export function nominatimViewboxFromBounds(bounds) {
|
||||
const match = String(bounds || '')
|
||||
.trim()
|
||||
.match(
|
||||
/^(-?\d+(?:\.\d+)?),(-?\d+(?:\.\d+)?)\|(-?\d+(?:\.\d+)?),(-?\d+(?:\.\d+)?)$/,
|
||||
);
|
||||
if (!match) return null;
|
||||
const swLat = nominatimLatitude(match[1]);
|
||||
const swLng = nominatimLongitude(match[2]);
|
||||
const neLat = nominatimLatitude(match[3]);
|
||||
const neLng = nominatimLongitude(match[4]);
|
||||
if ([swLat, swLng, neLat, neLng].some((value) => value === null)) return null;
|
||||
if (swLat >= neLat) return null;
|
||||
return `${swLng},${neLat},${neLng},${swLat}`;
|
||||
}
|
||||
Reference in new issue
Block a user