|
| 1 | +export interface MapBoxContextItem { |
| 2 | + id: string; |
| 3 | + // eslint-disable-next-line @typescript-eslint/naming-convention |
| 4 | + mapbox_id: string; |
| 5 | + text: string; |
| 6 | + wikidata: string; |
| 7 | + // eslint-disable-next-line @typescript-eslint/naming-convention |
| 8 | + short_code?: string; |
| 9 | +} |
| 10 | + |
| 11 | +// The field names here come from MapBox |
| 12 | +export interface MapBoxFeature { |
| 13 | + // eslint-disable-next-line @typescript-eslint/naming-convention |
| 14 | + place_type: string[]; |
| 15 | + // eslint-disable-next-line @typescript-eslint/naming-convention |
| 16 | + place_name: string; |
| 17 | + text?: string; |
| 18 | + // eslint-disable-next-line @typescript-eslint/naming-convention |
| 19 | + properties: { short_code: string; }; |
| 20 | + center: [number, number]; |
| 21 | + context: MapBoxContextItem[]; |
| 22 | +} |
| 23 | + |
| 24 | +export interface MapBoxFeatureCollection { |
| 25 | + type: "FeatureCollection"; |
| 26 | + features: MapBoxFeature[]; |
| 27 | +} |
| 28 | + |
| 29 | +export type MapBoxFeatureType = "country" | "region" | "postcode" | "district" | "place" | "locality" | "neighborhood" | "street" | "address"; |
| 30 | +export type MapBoxWorldviewType = "ar" | "cn" | "in" | "jp" | "ma" | "ru" | "tr" | "us"; |
| 31 | + |
| 32 | +// For countries, use the ISO 3166-1 alpha-2 country codes: |
| 33 | +// https://en.wikipedia.org/wiki/ISO_3166-1_alpha-2 |
| 34 | +interface BaseMapBoxGeocodingOptions { |
| 35 | + permanent?: boolean; |
| 36 | + language?: string; |
| 37 | + limit?: number; |
| 38 | + countries?: string[]; |
| 39 | + types?: MapBoxFeatureType[]; |
| 40 | + |
| 41 | + // MapBox API access token |
| 42 | + // eslint-disable-next-line @typescript-eslint/naming-convention |
| 43 | + access_token: string; |
| 44 | +} |
| 45 | + |
| 46 | +interface MapBoxForwardGeocodingOptions extends BaseMapBoxGeocodingOptions { |
| 47 | + autocomplete?: boolean; |
| 48 | + bbox?: number; |
| 49 | + format?: "geojson" | "v5"; |
| 50 | + proximity?: string; |
| 51 | +} |
| 52 | + |
| 53 | +interface MapBoxReverseGeocodingOptions extends BaseMapBoxGeocodingOptions { |
| 54 | + worldview?: MapBoxWorldviewType[]; |
| 55 | +} |
| 56 | + |
| 57 | +interface MapBoxAdjustedGeocodingParams { |
| 58 | + countries?: string; |
| 59 | + types?: string; |
| 60 | + } |
| 61 | + |
| 62 | +type MapBoxQueryOptions<T extends BaseMapBoxGeocodingOptions> = { |
| 63 | + [K in keyof Omit<T, "countries" | "types">]: T[K]; |
| 64 | +} & MapBoxAdjustedGeocodingParams; |
| 65 | + |
| 66 | +const DEFAULT_RELEVANT_FEATURE_TYPES = ["postcode", "place", "region", "country"]; |
| 67 | +const NA_COUNTRIES = ["United States", "Canada", "Mexico"]; |
| 68 | +const NA_ABBREVIATIONS = ["US-", "CA-", "MX-"]; |
| 69 | + |
| 70 | +export function findBestFeature(collection: MapBoxFeatureCollection, relevantTypes?: MapBoxFeatureType[]): MapBoxFeature | null { |
| 71 | + const types = relevantTypes ?? DEFAULT_RELEVANT_FEATURE_TYPES; |
| 72 | + const relevantFeatures = collection.features.filter(feature => types.some(type => feature.place_type.includes(type))); |
| 73 | + const placeFeature = relevantFeatures.find(feature => feature.place_type.includes("place")) ?? (relevantFeatures.find(feature => feature.place_type.includes("postcode")) ?? undefined); |
| 74 | + if (placeFeature !== undefined) { |
| 75 | + return placeFeature; |
| 76 | + } |
| 77 | + const regionFeature = relevantFeatures.find(feature => feature.place_type.includes("region")); |
| 78 | + if (regionFeature !== undefined) { |
| 79 | + return regionFeature; |
| 80 | + } |
| 81 | + const countryFeature = relevantFeatures.find(feature => feature.place_type.includes("country")); |
| 82 | + if (countryFeature !== undefined) { |
| 83 | + return countryFeature; |
| 84 | + } |
| 85 | + return null; |
| 86 | +} |
| 87 | + |
| 88 | +export function textForMapboxFeature(feature: MapBoxFeature, relevantTypes?: MapBoxFeatureType[]): string { |
| 89 | + const types = relevantTypes ?? DEFAULT_RELEVANT_FEATURE_TYPES; |
| 90 | + const pieces: string[] = []; |
| 91 | + if (feature.text) { |
| 92 | + pieces.push(feature.text); |
| 93 | + } |
| 94 | + feature.context.forEach(item => { |
| 95 | + const itemType = item.id.split(".")[0]; |
| 96 | + if (!types.includes(itemType)) { |
| 97 | + return; |
| 98 | + } |
| 99 | + let text = null as string | null; |
| 100 | + const shortCode = item.short_code; |
| 101 | + if (itemType === "region" && shortCode != null) { |
| 102 | + if (NA_ABBREVIATIONS.some(abbr => shortCode.startsWith(abbr))) { |
| 103 | + text = shortCode.substring(3); |
| 104 | + } |
| 105 | + } else if (itemType === "country") { |
| 106 | + const itemText = item.text; |
| 107 | + if (!NA_COUNTRIES.includes(itemText)) { |
| 108 | + text = itemText; |
| 109 | + } |
| 110 | + } |
| 111 | + if (text !== null) { |
| 112 | + pieces.push(text); |
| 113 | + } |
| 114 | + }); |
| 115 | + return pieces.join(", "); |
| 116 | +} |
| 117 | + |
| 118 | +export function textForMapboxResults(results: MapBoxFeatureCollection, relevantTypes?: MapBoxFeatureType[]): string { |
| 119 | + const feature = findBestFeature(results, relevantTypes); |
| 120 | + return feature !== null ? textForMapboxFeature(feature) : ""; |
| 121 | +} |
| 122 | + |
| 123 | +function convertOptionsToQueryParams<T extends BaseMapBoxGeocodingOptions>(options: T): MapBoxQueryOptions<T> { |
| 124 | + const { types, countries, ...params } = options; |
| 125 | + const queryParams = params as MapBoxQueryOptions<T>; |
| 126 | + queryParams.types = (types ?? ["place", "postcode"]).join(","); |
| 127 | + if (countries) { |
| 128 | + queryParams.countries = countries.join(","); |
| 129 | + } |
| 130 | + return queryParams; |
| 131 | +} |
| 132 | + |
| 133 | +function searchParams(options: MapBoxForwardGeocodingOptions | MapBoxReverseGeocodingOptions): URLSearchParams { |
| 134 | + const search = new URLSearchParams(); |
| 135 | + const queryParams = convertOptionsToQueryParams(options); |
| 136 | + Object.entries(queryParams).forEach(([key, value]) => search.set(key, value.toString())); |
| 137 | + return search; |
| 138 | +} |
| 139 | + |
| 140 | +export async function textForLocation(longitudeDeg: number, latitudeDeg: number, options: MapBoxReverseGeocodingOptions): Promise<string> { |
| 141 | + const search = searchParams(options); |
| 142 | + const url = `https://api.mapbox.com/geocoding/v5/mapbox.places/${longitudeDeg},${latitudeDeg}.json?${search.toString()}`; |
| 143 | + return fetch(url) |
| 144 | + .then(response => response.json()) |
| 145 | + .then((result: MapBoxFeatureCollection) => { |
| 146 | + if (result.features.length === 0) { |
| 147 | + const ns = latitudeDeg >= 0 ? 'N' : 'S'; |
| 148 | + const ew = longitudeDeg >= 0 ? 'E' : 'W'; |
| 149 | + const lat = Math.abs(latitudeDeg).toFixed(3); |
| 150 | + const lon = Math.abs(longitudeDeg).toFixed(3); |
| 151 | + return `${lat}° ${ns}, ${lon}° ${ew}`; |
| 152 | + } |
| 153 | + return textForMapboxResults(result); |
| 154 | + }); |
| 155 | +} |
| 156 | + |
| 157 | +export async function geocodingInfoForSearch(searchText: string, options: MapBoxForwardGeocodingOptions): Promise<MapBoxFeatureCollection> { |
| 158 | + const search = searchParams(options); |
| 159 | + const url = `https://api.mapbox.com/geocoding/v5/mapbox.places/${searchText}.json?${search.toString()}`; |
| 160 | + return fetch(url).then(response => response.json()); |
| 161 | +} |
0 commit comments