import * as L from 'leaflet'; import { template, getJSON } from '../util'; import { IGeocoder, GeocoderOptions, geocodingParams, GeocodingResult, reverseParams, SuggestUnsupportedError } from './api'; export type NominatimResponse = NominatimResult[]; export interface NominatimResult { place_id: number; licence: string; osm_type: string; osm_id: number; boundingbox: string[]; lat: string; lon: string; display_name: string; class?: string; type?: string; importance?: number; icon?: string; address: NominatimAddress; } export interface NominatimAddress { building?: string; city_district?: string; city?: string; country_code?: string; country?: string; county?: string; hamlet?: string; house_number?: string; neighbourhood?: string; postcode?: string; road?: string; state_district?: string; state?: string; suburb?: string; village?: string; } export interface NominatimOptions extends GeocoderOptions { /** * Additional URL parameters (strings) that will be added to geocoding requests; can be used to restrict results to a specific country for example, by providing the [`countrycodes`](https://wiki.openstreetmap.org/wiki/Nominatim#Parameters) parameter to Nominatim */ geocodingQueryParams?: Record; /** * A function that takes an GeocodingResult as argument and returns an HTML formatted string that represents the result. Default function breaks up address in parts from most to least specific, in attempt to increase readability compared to Nominatim's naming */ htmlTemplate: (r: NominatimResult) => string; } /** * The [Nominatim usage policy](https://operations.osmfoundation.org/policies/nominatim/) permits * "an absolute maximum of 1 request per second". * @internal */ const MIN_REQUEST_INTERVAL = 1000; /** * The public service the usage policy applies to, and the default {@link NominatimOptions.serviceUrl}. * Own installations are not rate limited. * @internal */ const PUBLIC_SERVICE_URL = 'https://nominatim.openstreetmap.org/'; /** * Implementation of the [Nominatim](https://wiki.openstreetmap.org/wiki/Nominatim) geocoder. * * This is the default geocoding service used by the control, unless otherwise specified in the options. * * Unless using your own Nominatim installation, please refer to the [Nominatim usage policy](https://operations.osmfoundation.org/policies/nominatim/). */ export class Nominatim implements IGeocoder { options: NominatimOptions = { serviceUrl: PUBLIC_SERVICE_URL, htmlTemplate(r: NominatimResult) { const address = r.address; let className: string; const parts: string[] = []; if (address.road || address.building) { parts.push('{building} {road} {house_number}'); } if (address.city || (address as any).town || address.village || address.hamlet) { className = parts.length > 0 ? 'leaflet-control-geocoder-address-detail' : ''; parts.push( '{postcode} {city} {town} {village} {hamlet}' ); } if (address.state || address.country) { className = parts.length > 0 ? 'leaflet-control-geocoder-address-context' : ''; parts.push('{state} {country}'); } return template(parts.join('
'), address); } }; /** * Start time of the most recent request, as a promise chain, used to space requests apart. */ private _lastRequestStart: Promise = Promise.resolve(0); /** * Previous responses, by request URL and parameters. */ private _cache = new Map>(); constructor(options?: Partial) { L.Util.setOptions(this, options || {}); } /** * Requests `endpoint`, reusing the response of an identical earlier request if there is one. * The usage policy asks to "enable caching of requests", and repeating identical queries may * result in blocking. Failed requests are not cached, so they can be retried. */ private _getJSON(endpoint: string, params: Record): Promise { const url = this.options.serviceUrl + endpoint; const key = url + JSON.stringify(params); let response = this._cache.get(key); if (!response) { response = this._rateLimit().then(() => getJSON(url, params)); response.catch(() => this._cache.delete(key)); this._cache.set(key, response); } return response; } /** * Resolves once the next request may be sent, that is at least * {@link MIN_REQUEST_INTERVAL} after the previous one was started. Callers are queued in * the order they call this, and are delayed rather than dropped. * * Resolves immediately for own installations, as the usage policy only governs * {@link PUBLIC_SERVICE_URL}. Hosts are compared, so that a differing path or trailing * slash still counts as the public service. */ private _rateLimit(): Promise { const { hostname } = new URL(this.options.serviceUrl); if (hostname !== new URL(PUBLIC_SERVICE_URL).hostname) { return Promise.resolve(); } const scheduled = this._lastRequestStart.then(async last => { const wait = MIN_REQUEST_INTERVAL - (Date.now() - last); if (wait > 0) { await new Promise(resolve => setTimeout(resolve, wait)); } return Date.now(); }); this._lastRequestStart = scheduled; return scheduled; } async geocode(query: string) { const params = geocodingParams(this.options, { q: query, limit: 5, format: 'json', addressdetails: 1 }); const data = await this._getJSON('search', params); return data.map((item): GeocodingResult => { const bbox = item.boundingbox; return { icon: item.icon, name: item.display_name, html: this.options.htmlTemplate ? this.options.htmlTemplate(item) : undefined, bbox: new L.LatLngBounds([+bbox[0], +bbox[2]], [+bbox[1], +bbox[3]]), center: new L.LatLng(+item.lat, +item.lon), properties: item }; }); } /** * Auto-complete is explicitly forbidden by the Nominatim usage policy, which states: * "Auto-complete search — This is not yet supported by Nominatim and you must not * implement such a service on the client side using the API." * * @see https://operations.osmfoundation.org/policies/nominatim/ * @throws {SuggestUnsupportedError} always */ async suggest(_query: string): Promise { throw new SuggestUnsupportedError( 'Nominatim forbids auto-complete search: "you must not implement such a service on ' + 'the client side using the API". See https://operations.osmfoundation.org/policies/nominatim/' ); } async reverse(location: L.LatLngLiteral, scale: number) { const params = reverseParams(this.options, { lat: location.lat, lon: location.lng, zoom: Math.round(Math.log(scale / 256) / Math.log(2)), addressdetails: 1, format: 'json' }); const data = await this._getJSON('reverse', params); if (!data?.lat || !data?.lon) { return []; } const center = new L.LatLng(+data.lat, +data.lon); const bbox = new L.LatLngBounds(center, center); return [ { name: data.display_name, html: this.options.htmlTemplate ? this.options.htmlTemplate(data) : undefined, center, bbox, properties: data } ]; } } /** * [Class factory method](https://leafletjs.com/reference.html#class-class-factories) for {@link Nominatim} * @param options the options */ export function nominatim(options?: Partial) { return new Nominatim(options); }