UNPKG

universal-geocoder

Version:

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

544 lines (509 loc) 15.5 kB
import { ExternalLoaderBody, ExternalLoaderHeaders, ExternalLoaderInterface, ExternalLoaderParams, } from "ExternalLoader"; import { ErrorCallback, GeocodedResultsCallback, LocationIQGeocoded, LocationIQGeocodeQuery, LocationIQGeocodeQueryObject, LocationIQReverseQuery, LocationIQReverseQueryObject, ProviderHelpers, ProviderInterface, ProviderOptionsInterface, defaultProviderOptions, } from "provider"; import AdminLevel from "AdminLevel"; import { ResponseError } from "error"; interface LocationIQRequestParams { [param: string]: string | undefined; readonly key: string; 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 zoom?: string; readonly format?: string; readonly viewbox?: string; readonly bounded?: string; readonly addressdetails?: string; readonly limit?: string; // eslint-disable-next-line camelcase readonly "accept-language"?: string; readonly countrycodes?: string; readonly namedetails?: string; readonly dedupe?: string; // eslint-disable-next-line camelcase readonly osm_type?: string; // eslint-disable-next-line camelcase readonly osm_id?: 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; readonly extratags?: string; // eslint-disable-next-line camelcase readonly exclude_place_ids?: string; readonly normalizeaddress?: string; readonly normalizecity?: string; readonly statecode?: string; readonly showdistance?: string; readonly matchquality?: string; readonly postaladdress?: string; readonly source?: string; readonly jsonpCallback?: string; } interface LocationIQErrorResponse { error: string; } export type LocationIQOsmType = "node" | "way" | "relation"; export type LocationIQPrecision = | "venue" | "building" | "street" | "neighbourhood" | "island" | "borough" | "city" | "county" | "state" | "country" | "marine" | "postalcode"; export type LocationIQPrecisionCode = "exact" | "fallback" | "approximate"; export type LocationIQPrecisionType = "point" | "centroid" | "interpolated"; export interface LocationIQResult { // eslint-disable-next-line camelcase place_id: string; licence: string; // eslint-disable-next-line camelcase osm_type: LocationIQOsmType; // eslint-disable-next-line camelcase osm_id: string; boundingbox: [string, string, string, string]; lat: string; lon: string; // eslint-disable-next-line camelcase display_name: string; class?: string; type?: string; importance: number; icon: string; address: { // eslint-disable-next-line camelcase house_number?: string; road?: string; neighbourhood?: string; hamlet?: string; suburb?: string; village?: string; town?: string; // eslint-disable-next-line camelcase city_district?: string; city?: string; region?: string; county?: string; // eslint-disable-next-line camelcase state_district?: string; state?: string; // eslint-disable-next-line camelcase state_code?: string; postcode?: string; country?: string; // eslint-disable-next-line camelcase country_code?: string; name?: 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; statecode?: string; distance?: number; matchquality?: { matchcode: LocationIQPrecisionCode; matchtype: LocationIQPrecisionType; matchlevel: LocationIQPrecision; }; // eslint-disable-next-line camelcase postal_address?: string; } export type LocationIQResponse = | LocationIQErrorResponse | LocationIQResult | LocationIQResult[]; export interface LocationIQProviderOptionsInterface extends ProviderOptionsInterface { readonly apiKey: string; readonly countryCodes?: string[]; readonly source?: "nominatim" | "locationiq"; } export const defaultLocationIQProviderOptions: LocationIQProviderOptionsInterface = { ...defaultProviderOptions, apiKey: "", source: "locationiq", }; type LocationIQGeocodedResultsCallback = GeocodedResultsCallback<LocationIQGeocoded>; export default class LocationIQProvider implements ProviderInterface<LocationIQGeocoded> { private externalLoader: ExternalLoaderInterface; private options: LocationIQProviderOptionsInterface; public constructor( _externalLoader: ExternalLoaderInterface, options: LocationIQProviderOptionsInterface = defaultLocationIQProviderOptions ) { this.externalLoader = _externalLoader; this.options = { ...defaultLocationIQProviderOptions, ...options }; if (!this.options.apiKey) { throw new Error( 'An API key is required for the LocationIQ provider. Please add it in the "apiKey" option.' ); } if (!["locationiq", "nominatim"].includes(this.options.source || "")) { throw new Error( 'The "source" option must either be "locationiq" or "nominatim".' ); } } public geocode( query: string | LocationIQGeocodeQuery | LocationIQGeocodeQueryObject ): Promise<LocationIQGeocoded[]>; public geocode( query: string | LocationIQGeocodeQuery | LocationIQGeocodeQueryObject, callback: LocationIQGeocodedResultsCallback, errorCallback?: ErrorCallback ): void; public geocode( query: string | LocationIQGeocodeQuery | LocationIQGeocodeQueryObject, callback?: LocationIQGeocodedResultsCallback, errorCallback?: ErrorCallback ): void | Promise<LocationIQGeocoded[]> { const geocodeQuery = ProviderHelpers.getGeocodeQueryFromParameter( query, LocationIQGeocodeQuery ); if (geocodeQuery.getIp()) { throw new Error( "The LocationIQ provider does not support IP geolocation, only location geocoding." ); } this.externalLoader.setOptions({ protocol: this.options.useSsl ? "https" : "http", host: "locationiq.com", pathname: "v1/search.php", }); const params: LocationIQRequestParams = this.withCommonParams( { q: geocodeQuery.getText(), limit: geocodeQuery.getLimit().toString(), countrycodes: (<LocationIQGeocodeQuery>geocodeQuery).getCountryCodes() ? (<LocationIQGeocodeQuery>geocodeQuery).getCountryCodes()?.join(",") : this.options.countryCodes?.join(","), exclude_place_ids: (<LocationIQGeocodeQuery>( geocodeQuery )).getExcludePlaceIds() ? (<LocationIQGeocodeQuery>geocodeQuery) .getExcludePlaceIds() ?.join(",") : undefined, viewbox: geocodeQuery.getBounds() ? `${geocodeQuery.getBounds()?.longitudeSW},${ geocodeQuery.getBounds()?.latitudeSW },${geocodeQuery.getBounds()?.longitudeNE},${ geocodeQuery.getBounds()?.latitudeNE }` : undefined, bounded: (<LocationIQGeocodeQuery>geocodeQuery).getBounded() ? (<LocationIQGeocodeQuery>geocodeQuery).getBounded()?.toString() : undefined, dedupe: (<LocationIQGeocodeQuery>geocodeQuery).getDedupe() ? (<LocationIQGeocodeQuery>geocodeQuery).getDedupe()?.toString() : undefined, matchquality: "1", }, <LocationIQGeocodeQuery>geocodeQuery ); if (!callback) { return new Promise((resolve, reject) => this.executeRequest( params, (results) => resolve(results), {}, {}, (error) => reject(error) ) ); } return this.executeRequest(params, callback, {}, {}, errorCallback); } public geodecode( query: LocationIQReverseQuery | LocationIQReverseQueryObject ): Promise<LocationIQGeocoded[]>; public geodecode( query: LocationIQReverseQuery | LocationIQReverseQueryObject, callback: LocationIQGeocodedResultsCallback, errorCallback?: ErrorCallback ): void; public geodecode( latitude: number | string, longitude: number | string ): Promise<LocationIQGeocoded[]>; public geodecode( latitude: number | string, longitude: number | string, callback: LocationIQGeocodedResultsCallback, errorCallback?: ErrorCallback ): void; public geodecode( latitudeOrQuery: | number | string | LocationIQReverseQuery | LocationIQReverseQueryObject, longitudeOrCallback?: number | string | LocationIQGeocodedResultsCallback, callbackOrErrorCallback?: LocationIQGeocodedResultsCallback | ErrorCallback, errorCallback?: ErrorCallback ): void | Promise<LocationIQGeocoded[]> { const reverseQuery = ProviderHelpers.getReverseQueryFromParameters( latitudeOrQuery, longitudeOrCallback, LocationIQReverseQuery ); const reverseCallback = ProviderHelpers.getCallbackFromParameters( longitudeOrCallback, callbackOrErrorCallback ); const reverseErrorCallback = ProviderHelpers.getErrorCallbackFromParameters( longitudeOrCallback, callbackOrErrorCallback, errorCallback ); this.externalLoader.setOptions({ protocol: this.options.useSsl ? "https" : "http", host: "locationiq.com", pathname: "v1/reverse.php", }); const params: LocationIQRequestParams = this.withCommonParams( { lat: reverseQuery.getCoordinates().latitude.toString(), lon: reverseQuery.getCoordinates().longitude.toString(), zoom: (<LocationIQReverseQuery>reverseQuery).getZoom()?.toString() || "18", showdistance: "1", }, <LocationIQReverseQuery>reverseQuery ); if (!reverseCallback) { return new Promise((resolve, reject) => this.executeRequest( params, (results) => resolve(results), {}, {}, (error) => reject(error) ) ); } return this.executeRequest( params, reverseCallback, {}, {}, reverseErrorCallback ); } private withCommonParams( params: Pick< LocationIQRequestParams, | "q" | "lat" | "lon" | "street" | "city" | "county" | "state" | "country" | "postalcode" | "zoom" | "viewbox" | "bounded" | "limit" | "countrycodes" | "dedupe" | "osm_type" | "osm_id" | "exclude_place_ids" | "showdistance" | "matchquality" >, query: LocationIQGeocodeQuery | LocationIQReverseQuery ): LocationIQRequestParams { return { ...params, key: this.options.apiKey || "", format: "json", addressdetails: "1", "accept-language": query.getLocale(), jsonpCallback: this.options.useJsonp ? "json_callback" : undefined, normalizeaddress: "1", normalizecity: "1", statecode: "1", postaladdress: "1", source: this.options.source === "nominatim" ? "nom" : undefined, }; } public executeRequest( params: ExternalLoaderParams, callback: LocationIQGeocodedResultsCallback, headers?: ExternalLoaderHeaders, body?: ExternalLoaderBody, errorCallback?: ErrorCallback ): void { this.externalLoader.executeRequest( params, (data: LocationIQResponse) => { let results = data; if (!Array.isArray(data)) { if ((<LocationIQErrorResponse>data).error) { const errorMessage = `An error has occurred: ${ (<LocationIQErrorResponse>data).error }`; if (errorCallback) { errorCallback(new ResponseError(errorMessage, data)); return; } setTimeout(() => { throw new Error(errorMessage); }); return; } results = [<LocationIQResult>data]; } callback( (<LocationIQResult[]>results).map((result) => LocationIQProvider.mapToGeocoded(result) ) ); }, headers, body, errorCallback ); } public static mapToGeocoded(result: LocationIQResult): LocationIQGeocoded { const latitude = parseFloat(result.lat); const longitude = parseFloat(result.lon); const formattedAddress = result.postal_address; const displayName = result.display_name; const streetNumber = result.address.house_number; const streetName = result.address.road; const subLocality = result.address.suburb; const locality = result.address.city; 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 placeId = result.place_id; const osmId = result.osm_id; const osmType = result.osm_type; const categories = result.class ? [result.class] : []; const { distance } = result; const types = result.type ? [result.type] : []; const precision = result.matchquality?.matchlevel; const precisionCode = result.matchquality?.matchcode; const precisionType = result.matchquality?.matchtype; const attribution = result.licence; let geocoded = LocationIQGeocoded.create({ coordinates: { latitude, longitude, }, formattedAddress, displayName, streetNumber, streetName, subLocality, locality, postalCode, region, country, countryCode, placeId, osmId, osmType, categories, types, distance, precision, precisionCode, precisionType, attribution, }); geocoded = <LocationIQGeocoded>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] || "", code: adminLevel === "state" ? result.address.state_code : undefined, }) ); } }); const subLocalityLevels: ("suburb" | "neighbourhood" | "road" | "name")[] = ["suburb", "neighbourhood", "road", "name"]; subLocalityLevels.forEach((subLocalityLevel, level) => { if (result.address[subLocalityLevel]) { geocoded.addSubLocalityLevel( AdminLevel.create({ level: level + 1, name: result.address[subLocalityLevel] || "", }) ); } }); return geocoded; } }