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