universal-geocoder
Version:
Universal geocoding abstraction server-side and client-side with multiple built-in providers
440 lines (406 loc) • 12 kB
text/typescript
import {
ExternalLoaderBody,
ExternalLoaderHeaders,
ExternalLoaderInterface,
ExternalLoaderParams,
} from "ExternalLoader";
import {
ErrorCallback,
GeocodedResultsCallback,
MapboxGeocoded,
MapboxGeocodeQuery,
MapboxGeocodeQueryObject,
MapboxReverseQuery,
MapboxReverseQueryObject,
ProviderHelpers,
ProviderInterface,
ProviderOptionsInterface,
defaultProviderOptions,
} from "provider";
import { FlatBoundingBox, FlatCoordinates } from "types";
import AdminLevel, { ADMIN_LEVEL_CODES } from "AdminLevel";
interface MapboxRequestParams {
[param: string]: string | undefined;
// eslint-disable-next-line camelcase
readonly access_token: string;
readonly country?: string;
readonly language?: string;
readonly limit?: string;
readonly bbox?: string;
readonly fuzzyMatch?: string;
readonly proximity?: string;
readonly reverseMode?: "distance" | "score";
readonly types?: string;
}
interface MapboxFeatureContextProperties {
id: string;
text: string;
wikidata?: string;
// eslint-disable-next-line camelcase
short_code?: string;
}
interface MapboxFeatureProperties {
accuracy?: string;
address?: string;
category?: string;
maki?: string;
wikidata?: string;
// eslint-disable-next-line camelcase
short_code?: string;
}
export interface MapboxResult {
id: string;
type: "Feature";
// eslint-disable-next-line camelcase
place_type: (
| "country"
| "region"
| "postcode"
| "district"
| "place"
| "locality"
| "neighborhood"
| "address"
| "poi"
)[];
relevance: number;
address?: string;
properties: MapboxFeatureProperties;
text: string;
// eslint-disable-next-line camelcase
place_name: string;
// eslint-disable-next-line camelcase
matching_text?: string;
// eslint-disable-next-line camelcase
matching_place_name?: string;
language?: string;
bbox?: FlatBoundingBox;
center: FlatCoordinates;
geometry: {
type: "Point";
coordinates: FlatCoordinates;
};
context?: MapboxFeatureContextProperties[];
// eslint-disable-next-line camelcase
routable_points?: {
points?: {
coordinates: FlatCoordinates;
}[];
};
}
export interface MapboxResponse {
type: "FeatureCollection";
query: string[];
features: MapboxResult[];
attribution: string;
}
// eslint-disable-next-line no-shadow
export enum MAPBOX_SOURCES {
SOURCE_PLACES = "mapbox.places",
SOURCE_PLACES_PERMANENT = "mapbox.places-permanent",
}
export interface MapboxProviderOptionsInterface
extends ProviderOptionsInterface {
readonly apiKey: string;
readonly source?: MAPBOX_SOURCES;
readonly countryCodes?: string[];
}
export const defaultMapboxProviderOptions = {
...defaultProviderOptions,
apiKey: "",
source: MAPBOX_SOURCES.SOURCE_PLACES,
};
type MapboxGeocodedResultsCallback = GeocodedResultsCallback<MapboxGeocoded>;
export default class MapboxProvider
implements ProviderInterface<MapboxGeocoded>
{
private externalLoader: ExternalLoaderInterface;
private options: MapboxProviderOptionsInterface;
public constructor(
_externalLoader: ExternalLoaderInterface,
options: MapboxProviderOptionsInterface = defaultMapboxProviderOptions
) {
this.externalLoader = _externalLoader;
this.options = { ...defaultMapboxProviderOptions, ...options };
if (!this.options.apiKey) {
throw new Error(
'An API key is required for the Mapbox provider. Please add it in the "apiKey" option.'
);
}
if (
!(<string[]>[
MAPBOX_SOURCES.SOURCE_PLACES,
MAPBOX_SOURCES.SOURCE_PLACES_PERMANENT,
]).includes(this.options.source || "")
) {
throw new Error(
`The "source" option must either be "${MAPBOX_SOURCES.SOURCE_PLACES}" or "${MAPBOX_SOURCES.SOURCE_PLACES_PERMANENT}".`
);
}
}
public geocode(
query: string | MapboxGeocodeQuery | MapboxGeocodeQueryObject
): Promise<MapboxGeocoded[]>;
public geocode(
query: string | MapboxGeocodeQuery | MapboxGeocodeQueryObject,
callback: MapboxGeocodedResultsCallback,
errorCallback?: ErrorCallback
): void;
public geocode(
query: string | MapboxGeocodeQuery | MapboxGeocodeQueryObject,
callback?: MapboxGeocodedResultsCallback,
errorCallback?: ErrorCallback
): void | Promise<MapboxGeocoded[]> {
const geocodeQuery = ProviderHelpers.getGeocodeQueryFromParameter(
query,
MapboxGeocodeQuery
);
if (geocodeQuery.getIp()) {
throw new Error(
"The Mapbox provider does not support IP geolocation, only location geocoding."
);
}
this.externalLoader.setOptions({
protocol: this.options.useSsl ? "https" : "http",
host: "api.mapbox.com",
pathname: `geocoding/v5/${
this.options.source
}/${geocodeQuery.getText()}.json`,
});
const fuzzyMatch = (<MapboxGeocodeQuery>geocodeQuery).getFuzzyMatch()
? "true"
: "false";
const params: MapboxRequestParams = this.withCommonParams(
{
bbox: geocodeQuery.getBounds()
? `${geocodeQuery.getBounds()?.longitudeSW},${
geocodeQuery.getBounds()?.latitudeSW
},${geocodeQuery.getBounds()?.longitudeNE},${
geocodeQuery.getBounds()?.latitudeNE
}`
: undefined,
fuzzyMatch:
(<MapboxGeocodeQuery>geocodeQuery).getFuzzyMatch() !== undefined
? fuzzyMatch
: undefined,
proximity: (<MapboxGeocodeQuery>geocodeQuery).getProximity()
? `${(<MapboxGeocodeQuery>geocodeQuery).getProximity()?.longitude},${
(<MapboxGeocodeQuery>geocodeQuery).getProximity()?.latitude
}`
: undefined,
types: (<MapboxGeocodeQuery>geocodeQuery).getTypes()
? (<MapboxGeocodeQuery>geocodeQuery).getTypes()?.join(",")
: undefined,
},
<MapboxGeocodeQuery>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: MapboxReverseQuery | MapboxReverseQueryObject
): Promise<MapboxGeocoded[]>;
public geodecode(
query: MapboxReverseQuery | MapboxReverseQueryObject,
callback: MapboxGeocodedResultsCallback,
errorCallback?: ErrorCallback
): void;
public geodecode(
latitude: number | string,
longitude: number | string
): Promise<MapboxGeocoded[]>;
public geodecode(
latitude: number | string,
longitude: number | string,
callback: MapboxGeocodedResultsCallback,
errorCallback?: ErrorCallback
): void;
public geodecode(
latitudeOrQuery:
| number
| string
| MapboxReverseQuery
| MapboxReverseQueryObject,
longitudeOrCallback?: number | string | MapboxGeocodedResultsCallback,
callbackOrErrorCallback?: MapboxGeocodedResultsCallback | ErrorCallback,
errorCallback?: ErrorCallback
): void | Promise<MapboxGeocoded[]> {
const reverseQuery = ProviderHelpers.getReverseQueryFromParameters(
latitudeOrQuery,
longitudeOrCallback,
MapboxReverseQuery
);
const reverseCallback = ProviderHelpers.getCallbackFromParameters(
longitudeOrCallback,
callbackOrErrorCallback
);
const reverseErrorCallback = ProviderHelpers.getErrorCallbackFromParameters(
longitudeOrCallback,
callbackOrErrorCallback,
errorCallback
);
this.externalLoader.setOptions({
protocol: this.options.useSsl ? "https" : "http",
host: "api.mapbox.com",
pathname: `geocoding/v5/${this.options.source}/${
reverseQuery.getCoordinates().longitude
},${reverseQuery.getCoordinates().latitude}.json`,
});
const params: MapboxRequestParams = this.withCommonParams(
{
reverseMode: (<MapboxReverseQuery>reverseQuery).getReverseMode()
? (<MapboxReverseQuery>reverseQuery).getReverseMode()
: undefined,
types: (<MapboxReverseQuery>reverseQuery).getTypes()
? (<MapboxReverseQuery>reverseQuery).getTypes()?.join(",")
: "address",
},
<MapboxReverseQuery>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<
MapboxRequestParams,
"bbox" | "fuzzyMatch" | "proximity" | "reverseMode" | "types"
>,
query: MapboxGeocodeQuery | MapboxReverseQuery
): MapboxRequestParams {
return {
...params,
access_token: this.options.apiKey || "",
country: query.getCountryCodes()
? query.getCountryCodes()?.join(",")
: this.options.countryCodes?.join(","),
language: query.getLocale(),
limit: query.getLimit().toString(),
};
}
public executeRequest(
params: ExternalLoaderParams,
callback: MapboxGeocodedResultsCallback,
headers?: ExternalLoaderHeaders,
body?: ExternalLoaderBody,
errorCallback?: ErrorCallback
): void {
this.externalLoader.executeRequest(
params,
(data: MapboxResponse) => {
callback(
data.features.map((result: MapboxResult) =>
MapboxProvider.mapToGeocoded(result)
)
);
},
headers,
body,
errorCallback
);
}
public static mapToGeocoded(result: MapboxResult): MapboxGeocoded {
const latitude = result.geometry.coordinates[1];
const longitude = result.geometry.coordinates[0];
const formattedAddress = result.place_name;
const streetNumber = result.address;
const streetName = result.text;
let locality;
let postalCode;
let region;
let country;
let countryCode;
const adminLevels: AdminLevel[] = [];
const types = result.place_type;
let adminLevelCode: undefined | string;
(result.context || []).forEach((feature) => {
const type = feature.id.split(".")[0];
switch (type) {
case "locality":
locality = feature.text;
break;
case "place":
locality = feature.text;
adminLevels.push(
AdminLevel.create({
level: ADMIN_LEVEL_CODES.COUNTY_CODE,
name: locality,
})
);
break;
case "postcode":
postalCode = feature.text;
break;
case "region":
region = feature.text;
adminLevelCode = undefined;
if (feature.short_code && feature.short_code.match(/[A-z]{2}-/)) {
adminLevelCode = feature.short_code.replace(/[A-z]{2}-/, "");
}
adminLevels.push(
AdminLevel.create({
level: ADMIN_LEVEL_CODES.STATE_CODE,
name: region,
code: adminLevelCode,
})
);
break;
case "country":
country = feature.text;
countryCode = feature.short_code;
break;
default:
}
});
let geocoded = MapboxGeocoded.create({
coordinates: {
latitude,
longitude,
},
formattedAddress,
streetNumber,
streetName,
locality,
postalCode,
region,
adminLevels,
country,
countryCode,
types,
});
if (result.bbox) {
geocoded = <MapboxGeocoded>geocoded.withBounds({
latitudeSW: result.bbox[1],
longitudeSW: result.bbox[0],
latitudeNE: result.bbox[3],
longitudeNE: result.bbox[2],
});
}
return geocoded;
}
}