UNPKG

universal-geocoder

Version:

Universal geocoding abstraction server-side and client-side with multiple built-in providers

559 lines (522 loc) 15.8 kB
import { ExternalLoaderBody, ExternalLoaderHeaders, ExternalLoaderInterface, ExternalLoaderParams, } from "ExternalLoader"; import { ErrorCallback, GeocodedResultsCallback, NominatimGeocoded, NominatimReverseQuery, NominatimReverseQueryObject, NominatimGeocodeQueryObject, NominatimGeocodeQuery, ProviderHelpers, ProviderInterface, ProviderOptionsInterface, defaultProviderOptions, } from "provider"; import AdminLevel from "AdminLevel"; import { ResponseError } from "error"; interface NominatimRequestParams { [param: string]: string | undefined; readonly q?: string; readonly lat?: string; readonly lon?: string; readonly street?: string; readonly city?: string; readonly county?: string; readonly state?: string; readonly country?: string; readonly postalcode?: string; readonly format: string; readonly addressdetails: string; readonly namedetails?: string; readonly extratags?: string; readonly zoom?: string; readonly limit?: string; // eslint-disable-next-line camelcase readonly "accept-language"?: string; readonly countrycodes?: string; // eslint-disable-next-line camelcase readonly exclude_place_ids?: string; readonly viewbox?: string; readonly bounded?: string; readonly dedupe?: string; // eslint-disable-next-line camelcase readonly polygon_geojson?: string; // eslint-disable-next-line camelcase readonly polygon_kml?: string; // eslint-disable-next-line camelcase readonly polygon_svg?: string; // eslint-disable-next-line camelcase readonly polygon_text?: string; // eslint-disable-next-line camelcase readonly polygon_threshold?: string; readonly jsonpCallback?: string; } interface NominatimErrorResponse { error: string; } export type NominatimOsmType = "node" | "way" | "relation"; export interface NominatimResult { // eslint-disable-next-line camelcase place_id: number; licence: string; // eslint-disable-next-line camelcase osm_type: NominatimOsmType; // eslint-disable-next-line camelcase osm_id: number; boundingbox: [string, string, string, string]; lat: string; lon: string; // eslint-disable-next-line camelcase display_name: string; category: string; type: string; importance: number; icon: string; address: { attraction?: string; pedestrian?: string; // eslint-disable-next-line camelcase house_name?: string; // eslint-disable-next-line camelcase house_number?: string; road?: string; retail?: string; commercial?: string; industrial?: string; farmyard?: string; farm?: string; residental?: string; // eslint-disable-next-line camelcase city_block?: string; quarter?: string; allotments?: string; neighbourhood?: string; // eslint-disable-next-line camelcase isolated_dwelling?: string; croft?: string; hamlet?: string; // eslint-disable-next-line camelcase city_district?: string; district?: string; borough?: string; subdivision?: string; suburb?: string; municipality?: string; city?: string; town?: string; village?: string; region?: string; // eslint-disable-next-line camelcase state_district?: string; state?: string; county?: string; postcode?: string; country?: string; // eslint-disable-next-line camelcase country_code?: string; continent?: string; }; extratags?: { phone?: string; website?: string; wikidata?: string; wikipedia?: string; wheelchair?: string; // eslint-disable-next-line camelcase opening_hours?: string; }; namedetails?: { name: string; [name: string]: string; }; geojson?: { type: "Point"; coordinates: [number, number]; }; geokml?: string; svg?: string; geotext?: string; } export type NominatimResponse = | NominatimErrorResponse | NominatimResult | NominatimResult[]; export interface NominatimProviderOptionsInterface extends ProviderOptionsInterface { readonly host?: string; readonly userAgent: string; readonly referer?: string; readonly countryCodes?: string[]; } export const defaultNominatimProviderOptions = { ...defaultProviderOptions, host: "nominatim.openstreetmap.org", userAgent: "", }; type NominatimGeocodedResultsCallback = GeocodedResultsCallback<NominatimGeocoded>; export default class NominatimProvider implements ProviderInterface<NominatimGeocoded> { private externalLoader: ExternalLoaderInterface; private options: NominatimProviderOptionsInterface; public constructor( _externalLoader: ExternalLoaderInterface, options: NominatimProviderOptionsInterface = defaultNominatimProviderOptions ) { this.externalLoader = _externalLoader; this.options = { ...defaultNominatimProviderOptions, ...options }; if ( this.options.host === defaultNominatimProviderOptions.host && !this.options.userAgent ) { throw new Error( 'An User-Agent identifying your application is required for the OpenStreetMap / Nominatim provider when using the default host. Please add it in the "userAgent" option.' ); } } public geocode( query: string | NominatimGeocodeQuery | NominatimGeocodeQueryObject ): Promise<NominatimGeocoded[]>; public geocode( query: string | NominatimGeocodeQuery | NominatimGeocodeQueryObject, callback: NominatimGeocodedResultsCallback, errorCallback?: ErrorCallback ): void; public geocode( query: string | NominatimGeocodeQuery | NominatimGeocodeQueryObject, callback?: NominatimGeocodedResultsCallback, errorCallback?: ErrorCallback ): void | Promise<NominatimGeocoded[]> { const geocodeQuery = ProviderHelpers.getGeocodeQueryFromParameter( query, NominatimGeocodeQuery ); if (geocodeQuery.getIp()) { throw new Error( "The OpenStreetMap / Nominatim provider does not support IP geolocation, only location geocoding." ); } this.externalLoader.setOptions({ protocol: this.options.useSsl ? "https" : "http", host: this.options.host, pathname: "search", }); const params: NominatimRequestParams = this.withCommonParams( { q: geocodeQuery.getText(), limit: geocodeQuery.getLimit().toString(), countrycodes: (<NominatimGeocodeQuery>geocodeQuery).getCountryCodes() ? (<NominatimGeocodeQuery>geocodeQuery).getCountryCodes()?.join(",") : this.options.countryCodes?.join(","), exclude_place_ids: (<NominatimGeocodeQuery>( geocodeQuery )).getExcludePlaceIds() ? (<NominatimGeocodeQuery>geocodeQuery) .getExcludePlaceIds() ?.join(",") : undefined, viewbox: geocodeQuery.getBounds() ? `${geocodeQuery.getBounds()?.longitudeSW},${ geocodeQuery.getBounds()?.latitudeSW },${geocodeQuery.getBounds()?.longitudeNE},${ geocodeQuery.getBounds()?.latitudeNE }` : undefined, bounded: (<NominatimGeocodeQuery>geocodeQuery).getBounded() ? (<NominatimGeocodeQuery>geocodeQuery).getBounded()?.toString() : undefined, dedupe: (<NominatimGeocodeQuery>geocodeQuery).getDedupe() ? (<NominatimGeocodeQuery>geocodeQuery).getDedupe()?.toString() : undefined, }, <NominatimGeocodeQuery>geocodeQuery ); if (!callback) { return new Promise((resolve, reject) => this.executeRequest( params, (results) => resolve(results), this.getHeaders(), {}, (error) => reject(error) ) ); } return this.executeRequest( params, callback, this.getHeaders(), {}, errorCallback ); } public geodecode( query: NominatimReverseQuery | NominatimReverseQueryObject ): Promise<NominatimGeocoded[]>; public geodecode( query: NominatimReverseQuery | NominatimReverseQueryObject, callback: NominatimGeocodedResultsCallback, errorCallback?: ErrorCallback ): void; public geodecode( latitude: number | string, longitude: number | string ): Promise<NominatimGeocoded[]>; public geodecode( latitude: number | string, longitude: number | string, callback: NominatimGeocodedResultsCallback, errorCallback?: ErrorCallback ): void; public geodecode( latitudeOrQuery: | number | string | NominatimReverseQuery | NominatimReverseQueryObject, longitudeOrCallback?: number | string | NominatimGeocodedResultsCallback, callbackOrErrorCallback?: NominatimGeocodedResultsCallback | ErrorCallback, errorCallback?: ErrorCallback ): void | Promise<NominatimGeocoded[]> { const reverseQuery = ProviderHelpers.getReverseQueryFromParameters( latitudeOrQuery, longitudeOrCallback, NominatimReverseQuery ); const reverseCallback = ProviderHelpers.getCallbackFromParameters( longitudeOrCallback, callbackOrErrorCallback ); const reverseErrorCallback = ProviderHelpers.getErrorCallbackFromParameters( longitudeOrCallback, callbackOrErrorCallback, errorCallback ); this.externalLoader.setOptions({ protocol: this.options.useSsl ? "https" : "http", host: this.options.host, pathname: "reverse", }); const params: NominatimRequestParams = this.withCommonParams( { lat: reverseQuery.getCoordinates().latitude.toString(), lon: reverseQuery.getCoordinates().longitude.toString(), zoom: (<NominatimReverseQuery>reverseQuery).getZoom()?.toString() || "18", }, <NominatimReverseQuery>reverseQuery ); if (!reverseCallback) { return new Promise((resolve, reject) => this.executeRequest( params, (results) => resolve(results), this.getHeaders(), {}, (error) => reject(error) ) ); } return this.executeRequest( params, reverseCallback, this.getHeaders(), {}, reverseErrorCallback ); } private withCommonParams( params: Partial<NominatimRequestParams>, query: NominatimGeocodeQuery | NominatimReverseQuery ): NominatimRequestParams { return { ...params, format: "jsonv2", addressdetails: "1", polygon_geojson: query.getShape() && query.getShape() === "geojson" ? "1" : undefined, polygon_kml: query.getShape() && query.getShape() === "kml" ? "1" : undefined, polygon_svg: query.getShape() && query.getShape() === "svg" ? "1" : undefined, polygon_text: query.getShape() && query.getShape() === "text" ? "1" : undefined, polygon_threshold: query.getShapeThreshold() ? query.getShapeThreshold()?.toString() : undefined, jsonpCallback: this.options.useJsonp ? "json_callback" : undefined, "accept-language": query.getLocale(), }; } private getHeaders(): ExternalLoaderHeaders { return { "User-Agent": this.options.userAgent || "", Referer: this.options.referer, }; } public executeRequest( params: ExternalLoaderParams, callback: NominatimGeocodedResultsCallback, headers?: ExternalLoaderHeaders, body?: ExternalLoaderBody, errorCallback?: ErrorCallback ): void { this.externalLoader.executeRequest( params, (data: NominatimResponse) => { let results = data; if (!Array.isArray(data)) { if ((<NominatimErrorResponse>data).error) { const errorMessage = `An error has occurred: ${ (<NominatimErrorResponse>data).error }`; if (errorCallback) { errorCallback(new ResponseError(errorMessage, data)); return; } setTimeout(() => { throw new Error(errorMessage); }); return; } results = [<NominatimResult>data]; } callback( (<NominatimResult[]>results).map((result: NominatimResult) => NominatimProvider.mapToGeocoded(result) ) ); }, headers, body, errorCallback ); } public static mapToGeocoded(result: NominatimResult): NominatimGeocoded { const latitude = parseFloat(result.lat); const longitude = parseFloat(result.lon); const displayName = result.display_name; const streetNumber = result.address.house_number; const streetName = result.address.road || result.address.pedestrian; const subLocality = result.address.suburb; let locality: string | undefined; const postalCode = result.address.postcode ? result.address.postcode.split(";")[0] : undefined; const region = result.address.state; const { country } = result.address; const countryCode = result.address.country_code; const osmId = result.osm_id; const osmType = result.osm_type; const categories = [result.category]; const types = [result.type]; const attribution = result.licence; const shape = result.geojson || result.geokml || result.svg || result.geotext; const localityTypes: ("city" | "town" | "village" | "hamlet")[] = [ "city", "town", "village", "hamlet", ]; localityTypes.forEach((localityType) => { if (result.address[localityType] && !locality) { locality = result.address[localityType]; } }); let geocoded = NominatimGeocoded.create({ coordinates: { latitude, longitude, }, displayName, streetNumber, streetName, subLocality, locality, postalCode, region, country, countryCode, osmId, osmType, categories, types, attribution, shape, }); geocoded = <NominatimGeocoded>geocoded.withBounds({ latitudeSW: parseFloat(result.boundingbox[0]), longitudeSW: parseFloat(result.boundingbox[2]), latitudeNE: parseFloat(result.boundingbox[1]), longitudeNE: parseFloat(result.boundingbox[3]), }); const adminLevels: ("state" | "county")[] = ["state", "county"]; adminLevels.forEach((adminLevel, level) => { if (result.address[adminLevel]) { geocoded.addAdminLevel( AdminLevel.create({ level: level + 1, name: result.address[adminLevel] || "", }) ); } }); const subLocalityLevels: ( | "city_district" | "district" | "borough" | "suburb" | "subdivision" | "hamlet" | "croft" | "isolated_dwelling" | "neighbourhood" | "allotments" | "quarter" | "city_block" | "residental" | "farm" | "farmyard" | "industrial" | "commercial" | "retail" | "road" | "house_name" )[][] = [ ["city_district", "district", "borough", "suburb", "subdivision"], ["hamlet", "croft", "isolated_dwelling"], ["neighbourhood", "allotments", "quarter"], [ "city_block", "residental", "farm", "farmyard", "industrial", "commercial", "retail", ], ["road"], ["house_name"], ]; subLocalityLevels.forEach((subLocalities, level) => { subLocalities.forEach((subLocalityLevel) => { if (result.address[subLocalityLevel]) { geocoded.addSubLocalityLevel( AdminLevel.create({ level: level + 1, name: result.address[subLocalityLevel] || "", }) ); } }); }); return geocoded; } }