universal-geocoder
Version:
Universal geocoding abstraction server-side and client-side with multiple built-in providers
544 lines (509 loc) • 15.5 kB
text/typescript
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;
}
}