diff --git a/.vscode/launch.json b/.vscode/launch.json new file mode 100644 index 0000000..ee3a5cd --- /dev/null +++ b/.vscode/launch.json @@ -0,0 +1,35 @@ +{ + // Use IntelliSense to learn about possible attributes. + // Hover to view descriptions of existing attributes. + // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 + "version": "0.2.0", + "configurations": [ + { + "args": [ + "-u", + "tdd", + "--timeout", + "999999", + "--colors", + "${workspaceFolder}/test" + ], + "internalConsoleOptions": "openOnSessionStart", + "name": "Mocha Tests", + "program": "${workspaceFolder}/node_modules/mocha/bin/_mocha", + "request": "launch", + "skipFiles": [ + "/**" + ], + "type": "pwa-node" + }, + { + "type": "pwa-node", + "request": "launch", + "name": "Launch Program", + "skipFiles": [ + "/**" + ], + "program": "${workspaceFolder}/lib/index.cjs" + } + ] +} \ No newline at end of file diff --git a/README.md b/README.md index 9dbb57f..9192726 100644 --- a/README.md +++ b/README.md @@ -36,6 +36,7 @@ Features: | [PeliasGeocoder](https://github.com/pelias/documentation/blob/master/README.md) | ✅ | ✅ | ❌ | Local or [Geocode.earth](https://geocode.earth/docs) | | [PickpointGeocoder](https://pickpoint.io/api-reference) | ✅ | ✅ | ❌ | Search Results based on OSM | | [TeleportGeocoder](https://developers.teleport.org/api/resources/) | ✅ | ✅ | ❌ | Searches only by city names, no addresses | +| [TomTomGeocoder](https://developer.tomtom.com/) | ✅ | ✅ | ❌ | | | [YandexGeocoder](https://yandex.com/dev/maps/geocoder/) | ✅ | ✅ | ❌ | | ## usage diff --git a/examples/tomtom.js b/examples/tomtom.js new file mode 100644 index 0000000..20c54ec --- /dev/null +++ b/examples/tomtom.js @@ -0,0 +1,18 @@ +import dotenv from 'dotenv' +import { argv } from './argv.js' +import { fetchAdapter, TomTomGeocoder } from '../src/index.js' + +dotenv.config() + +const { TOMTOM_APIKEY: apiKey, FORWARD, REVERSE, LANGUAGE } = process.env +const { forward, reverse, ...other } = argv({ forward: FORWARD, reverse: REVERSE }) + +const adapter = fetchAdapter() +const geocoder = new TomTomGeocoder(adapter, { apiKey, language: LANGUAGE, ...other }) + +const promise = reverse + ? geocoder.reverse(reverse) + : geocoder.forward(forward) +promise + .then(res => console.dir(res, { depth: null })) + .catch(console.error) diff --git a/src/geocoder/arcgis.js b/src/geocoder/arcgis.js index e92ebf2..e3d8c2a 100644 --- a/src/geocoder/arcgis.js +++ b/src/geocoder/arcgis.js @@ -1,5 +1,5 @@ import { AbstractGeocoder } from './abstract.js' -import { HttpError, countryCode, countryName } from '../utils/index.js' +import { HttpError, countryCode, countryName, toFixed } from '../utils/index.js' /** @typedef {import('../adapter').fetchAdapterFn} fetchAdapterFn */ @@ -179,7 +179,7 @@ export class ArcGisGeocoder extends AbstractGeocoder { const { xmin, ymin, xmax, ymax } = extent const extra = { - confidence: score / 100, + confidence: toFixed(score / 100), type: undef(Type), placeName: undef(PlaceName), addrType: undef(Addr_type), diff --git a/src/geocoder/here.js b/src/geocoder/here.js index d323bc8..2506ad5 100644 --- a/src/geocoder/here.js +++ b/src/geocoder/here.js @@ -1,5 +1,5 @@ import { AbstractGeocoder } from './abstract.js' -import { HttpError, countryCode } from '../utils/index.js' +import { HttpError, countryCode, toFixed } from '../utils/index.js' /** @typedef {import('../adapter').fetchAdapterFn} fetchAdapterFn */ @@ -130,7 +130,7 @@ export class HereGeocoder extends AbstractGeocoder { building: address.building, extra: { id: id, - confidence: scoring.queryScore || 0 + confidence: toFixed(scoring.queryScore || 0) } } diff --git a/src/geocoder/index.js b/src/geocoder/index.js index 2bf412a..708d608 100644 --- a/src/geocoder/index.js +++ b/src/geocoder/index.js @@ -17,4 +17,5 @@ export * from './osm.js' export * from './pelias.js' export * from './pickpoint.js' export * from './teleport.js' +export * from './tomtom.js' export * from './yandex.js' diff --git a/src/geocoder/locationiq.js b/src/geocoder/locationiq.js index 9bdea72..0195e7b 100644 --- a/src/geocoder/locationiq.js +++ b/src/geocoder/locationiq.js @@ -1,5 +1,5 @@ import { AbstractGeocoder } from './abstract.js' -import { HttpError, toUpperCase } from '../utils/index.js' +import { HttpError, toFixed, toUpperCase } from '../utils/index.js' /** @typedef {import('../adapter').fetchAdapterFn} fetchAdapterFn */ @@ -147,7 +147,7 @@ export class LocationIqGeocoder extends AbstractGeocoder { streetNumber: address.house_number, extra: { id, - confidence: importance || 0, + confidence: toFixed(importance || 0), type, addrType, bbox: toBbox(boundingbox) diff --git a/src/geocoder/opendatafrance.js b/src/geocoder/opendatafrance.js index 72fb4d3..69b224e 100644 --- a/src/geocoder/opendatafrance.js +++ b/src/geocoder/opendatafrance.js @@ -1,5 +1,5 @@ import { AbstractGeocoder } from './abstract.js' -import { HttpError } from '../utils/index.js' +import { HttpError, toFixed } from '../utils/index.js' /** @typedef {import('../adapter').fetchAdapterFn} fetchAdapterFn */ @@ -119,7 +119,7 @@ export class OpendataFranceGeocoder extends AbstractGeocoder { citycode: properties.citycode, extra: { id: properties.id, - confidence: properties.score || 0 + confidence: toFixed(properties.score || 0) } } diff --git a/src/geocoder/osm.js b/src/geocoder/osm.js index f73d293..0c259e4 100644 --- a/src/geocoder/osm.js +++ b/src/geocoder/osm.js @@ -1,5 +1,5 @@ import { AbstractGeocoder } from './abstract.js' -import { HttpError, toUpperCase } from '../utils/index.js' +import { HttpError, toFixed, toUpperCase } from '../utils/index.js' /** @typedef {import('../adapter').fetchAdapterFn} fetchAdapterFn */ @@ -161,7 +161,7 @@ export class OsmGeocoder extends AbstractGeocoder { neighbourhood: address.neighbourhood, extra: { id: result.osm_id, - confidence: result.importance || 0, + confidence: toFixed(result.importance || 0), bbox: toBbox(result.boundingbox) } } diff --git a/src/geocoder/pelias.js b/src/geocoder/pelias.js index a526c88..dc497b7 100644 --- a/src/geocoder/pelias.js +++ b/src/geocoder/pelias.js @@ -1,5 +1,5 @@ import { AbstractGeocoder } from './abstract.js' -import { HttpError, countryCode } from '../utils/index.js' +import { HttpError, countryCode, toFixed } from '../utils/index.js' /** @typedef {import('../adapter').fetchAdapterFn} fetchAdapterFn */ @@ -104,7 +104,7 @@ export class PeliasGeocoder extends AbstractGeocoder { _formatResult (result = {}) { const { geometry = {}, properties = {} } = result - const confidence = properties.confidence < 1 ? properties.confidence - 0.1 : 1 + const confidence = properties.confidence < 1 ? toFixed(properties.confidence - 0.1) : 1 const formatted = { formattedAddress: properties.label, diff --git a/src/geocoder/teleport.js b/src/geocoder/teleport.js index 8c7353a..95e0791 100644 --- a/src/geocoder/teleport.js +++ b/src/geocoder/teleport.js @@ -4,7 +4,7 @@ */ import { AbstractGeocoder } from './abstract.js' -import { HttpError } from '../utils/index.js' +import { HttpError, toFixed } from '../utils/index.js' /** @typedef {import('../adapter').fetchAdapterFn} fetchAdapterFn */ @@ -137,7 +137,7 @@ export class TeleportGeocoder extends AbstractGeocoder { } if (result.distance_km) { extra.distanceKm = result.distance_km - extra.confidence = Math.max(0, 25 - result.distance_km) / 25 * 10 + extra.confidence = toFixed(Math.max(0, 25 - result.distance_km) / 25 * 10) } if (result.matching_full_name) { extra.matchingFullName = result.matching_full_name diff --git a/src/geocoder/tomtom.js b/src/geocoder/tomtom.js new file mode 100644 index 0000000..40dfeea --- /dev/null +++ b/src/geocoder/tomtom.js @@ -0,0 +1,261 @@ +import { AbstractGeocoder } from './abstract.js' +import { HttpError, countryCode as countryCodeF, toFixed } from '../utils/index.js' + +/** @typedef {import('../adapter').fetchAdapterFn} fetchAdapterFn */ + +const undef = (s) => s === '' ? undefined : s + +const toLatLon = str => String(str).split(',') + .map(i => isNaN(Number(i)) ? undefined : Number(i)) + +/** + * see https://developer.tomtom.com/search-api/documentation/geocoding-service/geocode + * @typedef {object} TomTomForwardQuery + * @property {string} address + * @property {number} [limit] + * @property {string} [category] see for list of values + * @property {string} [preferredLabelValues] + * @property {string} [language] + */ + +/** + * see https://developer.tomtom.com/search-api/documentation/reverse-geocoding-service/reverse-geocode + * @typedef {object} TomTomReverseQuery + * @property {number} lat latitude + * @property {number} lng longitude + * @property {number} [limit] + * @property {boolean} [returnIntersection] + * @property {string} [locationType] + * @property {string} [preferredLabelValues] + * @property {string} [language] + */ + +export class TomTomGeocoder extends AbstractGeocoder { + /** + * available options + * @param {fetchAdapterFn} adapter + * @param {object} options + * @param {string} options.apiKey + * @param {number} [options.limit] + * @param {string} [options.language] + * @param {number} [options.radius] + */ + constructor (adapter, options = { apiKey: '' }) { + // @ts-ignore + super(adapter, options) + + const { apiKey, ...params } = options + + if (!apiKey) { + throw new Error(`You must specify apiKey to use ${this.constructor.name}`) + } + + this.params = { + ...params, + key: apiKey + } + } + + get endpoint () { + return 'https://api.tomtom.com/search/2/geocode' + } + + get revEndpoint () { + return 'https://api.tomtom.com/search/2/reverseGeocode' + } + + /** + * @param {string|TomTomForwardQuery} query + * @returns {Promise} + */ + async _forward (query = '') { + let params = this.params + let searchtext = query + + if (typeof query !== 'string' && query.address) { + const { address, ...other } = query + searchtext = address + params = { ...params, ...other } + } + + if (params.language === 'en') { + params.language = 'en-GB' + } + + const url = this.createUrl( + `${this.endpoint}/${encodeURIComponent(String(searchtext))}.json`, + params + ) + + const res = await this.adapter(url) + if (res.status !== 200) { + throw HttpError(res) + } + const result = await res.json() + // console.dir(result, { depth: null }) + if (!result?.results) { + return this.wrapRaw([], result) + } + const results = result.results.map(this._formatResult) + return this.wrapRaw(results, result) + } + + /** + * @param {TomTomReverseQuery} query + * @returns {Promise} + */ + async _reverse (query) { + const { + lat, + lng, + ...other + } = query + const params = { + ...this.params, + ...other + } + + const url = this.createUrl( + `${this.revEndpoint}/${encodeURIComponent(`${lat},${lng}`)}.json`, + params + ) + + const res = await this.adapter(url) + if (res.status !== 200) { + throw HttpError(res) + } + const result = await res.json() + // console.dir(result, { depth: null }) + + if (!result?.addresses) { + return this.wrapRaw([], result) + } + const results = result.addresses.map(this._formatResultRev) + return this.wrapRaw(results, result) + } + + /** + * format forward geocoding results + * @param {object} result + * @returns {object} + */ + _formatResult (result) { + const { + id, + position = {}, + address = {}, + score = 0, + type, + viewport = {} + } = result || {} + + const { + streetNumber, + streetName, + municipalitySubdivision, + municipality, + countrySecondarySubdivision, + countrySubdivision, + countrySubdivisionName, + postalCode, + // extendedPostalCode, + countryCode, + country, + // countryCodeISO3, + freeformAddress, + localName + } = address + + const xmin = viewport?.topLeftPoint?.lon + const xmax = viewport?.btmRightPoint?.lon + const ymin = viewport?.btmRightPoint?.lat + const ymax = viewport?.topLeftPoint?.lat + + const extra = { + id, + confidence: toFixed(score / 10), + placeName: localName, + type: type, + bbox: [xmin, ymin, xmax, ymax] + } + + const formatted = { + formattedAddress: undef(freeformAddress), + latitude: position.lat, + longitude: position.lon, + country: undef(country), + countryCode: countryCodeF(undef(countryCode)), + state: undef(countrySubdivisionName) || undef(countrySubdivision), + county: undef(countrySecondarySubdivision), + district: undef(municipalitySubdivision), + city: undef(municipality), + zipcode: undef(postalCode), + streetName: undef(streetName), + streetNumber: undef(streetNumber), + extra + } + + return formatted + } + + /** + * format reverse search result + * @param {object} result + * @returns {object} + */ + _formatResultRev (result) { + const { + address = {}, + position = '' + } = result || {} + + const { + // buildingNumber, + streetNumber, + // routeNumbers, + street, + streetName, + // streetNameAndNumber, + countryCode, + countrySubdivision, + countrySecondarySubdivision, + municipality, + postalCode, + municipalitySubdivision, + country, + // countryCodeISO3, + freeformAddress, + boundingBox = {}, + // extendedPostalCode, + countrySubdivisionName, + localName + } = address + + const [latitude, longitude] = toLatLon(position) + const [ymax, xmax] = toLatLon(boundingBox.northEast) + const [ymin, xmin] = toLatLon(boundingBox.southWest) + + const extra = { + placeName: undef(localName), + bbox: [xmin, ymin, xmax, ymax] + } + + const formatted = { + formattedAddress: undef(freeformAddress), + latitude, + longitude, + country, + countryCode: countryCodeF(undef(countryCode)), + state: undef(countrySubdivisionName) || undef(countrySubdivision), + county: undef(countrySecondarySubdivision), + district: undef(municipalitySubdivision), + city: undef(municipality), + zipcode: undef(postalCode), + streetName: undef(streetName) || undef(street), + streetNumber: undef(streetNumber), + extra + } + + return formatted + } +} diff --git a/src/types.d.ts b/src/types.d.ts index e13e6a2..d531b18 100644 --- a/src/types.d.ts +++ b/src/types.d.ts @@ -73,9 +73,13 @@ export type GeocoderResult = { streetNumber?: string; [key: string]: any; extra?: { + /** provider id */ id?: string | number; + /** Indicates for each result how good the result matches to the original query `[0 .. 1]` */ confidence?: number; + /** bounding box of the result object */ bbox?: number[]; + /** any other keys */ [key: string]: any; }; }; diff --git a/src/utils/index.js b/src/utils/index.js index d031ed7..946c29e 100644 --- a/src/utils/index.js +++ b/src/utils/index.js @@ -3,4 +3,5 @@ export * from './camelCase.js' export * from './httperror.js' export * from './isocode.js' export * from './isType.js' +export * from './toFixed.js' export * from './toUpperCase.js' diff --git a/src/utils/toFixed.js b/src/utils/toFixed.js new file mode 100644 index 0000000..b4397c6 --- /dev/null +++ b/src/utils/toFixed.js @@ -0,0 +1 @@ +export const toFixed = (num, len = 4) => Number(num.toFixed(len)) diff --git a/test/geocoder/fixtures/locationiq.js b/test/geocoder/fixtures/locationiq.js index 533ee8a..05d6921 100644 --- a/test/geocoder/fixtures/locationiq.js +++ b/test/geocoder/fixtures/locationiq.js @@ -113,7 +113,7 @@ export const fixtures = { streetNumber: undefined, extra: { id: '44871447', - confidence: 0.21100000000000002, + confidence: 0.211, type: 'hotel', addrType: 'tourism', bbox: ['2.3088388', '48.8723088', '2.3089388', '48.8724088'] diff --git a/test/geocoder/fixtures/mapbox.js b/test/geocoder/fixtures/mapbox.js index c780db5..8583f16 100644 --- a/test/geocoder/fixtures/mapbox.js +++ b/test/geocoder/fixtures/mapbox.js @@ -960,7 +960,7 @@ export const fixtures = { streetNumber: '1', neighbourhood: 'Plateau', extra: { - id: 'address.8413593260702956', + id: 'address.2516391937692428', category: undefined, bbox: undefined } diff --git a/test/geocoder/fixtures/opendatafrance.js b/test/geocoder/fixtures/opendatafrance.js index a06895c..1c6ca20 100644 --- a/test/geocoder/fixtures/opendatafrance.js +++ b/test/geocoder/fixtures/opendatafrance.js @@ -119,7 +119,7 @@ export const fixtures = { city: 'Paris', zipcode: '75008', citycode: '75108', - extra: { id: '75108_1733', confidence: 0.565457184750733 }, + extra: { id: '75108_1733', confidence: 0.5655 }, streetName: 'Avenue des Champs Elys\\u00e9es' }, { @@ -132,7 +132,7 @@ export const fixtures = { city: 'Paris', zipcode: '75008', citycode: '75108', - extra: { id: '75108_1734_00001', confidence: 0.5492108211143695 }, + extra: { id: '75108_1734_00001', confidence: 0.5492 }, streetName: 'Port des Champs Elys\\u00e9es', streetNumber: '1' }, @@ -146,7 +146,7 @@ export const fixtures = { city: 'Paris', zipcode: '75008', citycode: '75108', - extra: { id: '75108_1732_00001', confidence: 0.5049637967914438 }, + extra: { id: '75108_1732_00001', confidence: 0.505 }, streetName: 'Arcades des Champs Elys\\u00e9es', streetNumber: '1' }, @@ -161,7 +161,7 @@ export const fixtures = { city: 'Le Touquet-Paris-Plage', zipcode: '62520', citycode: '62826', - extra: { id: '62826_0239', confidence: 0.4399588714733542 }, + extra: { id: '62826_0239', confidence: 0.44 }, streetName: 'Allee des Champs Elysees' }, { @@ -174,7 +174,7 @@ export const fixtures = { city: 'Paris', zipcode: '75008', citycode: '75108', - extra: { id: '75108_3190', confidence: 0.40505935064935056 }, + extra: { id: '75108_3190', confidence: 0.4051 }, streetName: 'Rue de l\\u2019Elys\\u00e9e' } ] @@ -239,7 +239,7 @@ export const fixtures = { city: 'Lyon', zipcode: '69004', citycode: '69384', - extra: { id: '69384_0920', confidence: 0.43272491978609623 }, + extra: { id: '69384_0920', confidence: 0.4327 }, streetName: 'Avenue de Birmingham' }, { @@ -252,7 +252,7 @@ export const fixtures = { city: 'Lyon', zipcode: '69004', citycode: '69384', - extra: { id: '69384_0920', confidence: 0.43088219251336896 }, + extra: { id: '69384_0920', confidence: 0.4309 }, streetName: 'Avenue de Birmingham' } ] @@ -299,7 +299,7 @@ export const fixtures = { city: 'Prunay-sur-Essonne', zipcode: '91720', citycode: '91507', - extra: { id: '91507_b009_00023', confidence: 0.9999880927779257 }, + extra: { id: '91507_b009_00023', confidence: 1 }, streetName: 'Chemin de Pithiviers', streetNumber: '23' } @@ -315,7 +315,7 @@ export const fixtures = { city: 'Paris', zipcode: '75008', citycode: '75108', - extra: { id: '75108_1733', confidence: 0.565457184750733 }, + extra: { id: '75108_1733', confidence: 0.5657 }, streetName: 'Avenue des Champs Elysées' }, reverse: { @@ -328,7 +328,7 @@ export const fixtures = { city: 'Metz', zipcode: '57000', citycode: '57463', - extra: { id: '57463_5920_00012', confidence: 0.9999997242304688 }, + extra: { id: '57463_5920_00012', confidence: 1 }, streetName: 'Place Saint-Etienne', streetNumber: '12' } diff --git a/test/geocoder/fixtures/pelias.js b/test/geocoder/fixtures/pelias.js index 7a0a8be..2225da5 100644 --- a/test/geocoder/fixtures/pelias.js +++ b/test/geocoder/fixtures/pelias.js @@ -533,7 +533,7 @@ export const fixtures = { zipcode: '11211', streetName: 'Bedford Avenue', streetNumber: '275', - extra: { confidence: 0.7000000000000001 } + extra: { confidence: 0.7 } }, { formattedAddress: '281 Bedford Avenue, Brooklyn, NY, USA', @@ -547,7 +547,7 @@ export const fixtures = { zipcode: '11211', streetName: 'Bedford Avenue', streetNumber: '281', - extra: { confidence: 0.7000000000000001 } + extra: { confidence: 0.7 } }, { formattedAddress: '174 Grand Street, Brooklyn, NY, USA', @@ -561,7 +561,7 @@ export const fixtures = { zipcode: '11211', streetName: 'Grand Street', streetNumber: '174', - extra: { confidence: 0.7000000000000001 } + extra: { confidence: 0.7 } }, { formattedAddress: '176 Grand Street, Brooklyn, NY, USA', @@ -575,7 +575,7 @@ export const fixtures = { zipcode: '11211', streetName: 'Grand Street', streetNumber: '176', - extra: { confidence: 0.7000000000000001 } + extra: { confidence: 0.7 } }, { formattedAddress: '172 Grand Street, Brooklyn, NY, USA', @@ -589,7 +589,7 @@ export const fixtures = { zipcode: '11211', streetName: 'Grand Street', streetNumber: '172', - extra: { confidence: 0.7000000000000001 } + extra: { confidence: 0.7 } }, { formattedAddress: 'Puerh Brooklyn, Brooklyn, NY, USA', @@ -603,7 +603,7 @@ export const fixtures = { zipcode: '11211', streetName: 'Grand Street', streetNumber: '174', - extra: { confidence: 0.7000000000000001 } + extra: { confidence: 0.7 } }, { formattedAddress: 'Kam Sing, Brooklyn, NY, USA', @@ -617,7 +617,7 @@ export const fixtures = { zipcode: undefined, streetName: undefined, streetNumber: undefined, - extra: { confidence: 0.7000000000000001 } + extra: { confidence: 0.7 } }, { formattedAddress: '178 Grand Street, Brooklyn, NY, USA', @@ -631,7 +631,7 @@ export const fixtures = { zipcode: '11211', streetName: 'Grand Street', streetNumber: '178', - extra: { confidence: 0.7000000000000001 } + extra: { confidence: 0.7 } } ] } diff --git a/test/geocoder/fixtures/teleport.js b/test/geocoder/fixtures/teleport.js index ee80efc..61bc880 100644 --- a/test/geocoder/fixtures/teleport.js +++ b/test/geocoder/fixtures/teleport.js @@ -443,7 +443,7 @@ export const fixtures = { urbanAreaApiUrl: 'https://api.teleport.org/api/urban_areas/slug:new-york/', urbanAreaWebUrl: 'https://teleport.org/cities/new-york/', distanceKm: 3.7754433, - confidence: 8.48982268 + confidence: 8.4898 } } ] @@ -476,8 +476,7 @@ export const fixtures = { urbanAreaApiUrl: 'https://api.teleport.org/api/urban_areas/slug:new-york/', urbanAreaWebUrl: 'https://teleport.org/cities/new-york/', distanceKm: 3.7754433, - confidence: 8.48982268 + confidence: 8.4898 } } - } diff --git a/test/geocoder/fixtures/tomtom.js b/test/geocoder/fixtures/tomtom.js new file mode 100644 index 0000000..0587a94 --- /dev/null +++ b/test/geocoder/fixtures/tomtom.js @@ -0,0 +1,212 @@ +export const fixtures = { + '135 pilkington avenue, birmingham': { + query: '135 pilkington avenue, birmingham', + body: { + summary: { + query: '135 pilkington avenue birmingham', + queryType: 'NON_NEAR', + queryTime: 141, + numResults: 2, + offset: 0, + totalResults: 2, + fuzzyLevel: 1 + }, + results: [ + { + type: 'Address Range', + id: 'GB/ADDR/p0/2329262', + score: 8.1408014297, + address: { + streetNumber: '135', + streetName: 'Pilkington Avenue', + municipalitySubdivision: 'The Royal Town of Sutton Coldfield', + municipality: 'Birmingham', + countrySecondarySubdivision: 'West Midlands', + countrySubdivision: 'ENG', + countrySubdivisionName: 'England', + postalCode: 'B72', + extendedPostalCode: 'B72 1LH', + countryCode: 'GB', + country: 'United Kingdom', + countryCodeISO3: 'GBR', + freeformAddress: '135 Pilkington Avenue, The Royal Town of Sutton Coldfield, Birmingham, B72 1LH', + localName: 'Birmingham' + }, + position: { lat: 52.54864, lon: -1.81606 }, + viewport: { + topLeftPoint: { lat: 52.54873, lon: -1.81715 }, + btmRightPoint: { lat: 52.54851, lon: -1.81535 } + }, + addressRanges: { + rangeLeft: '129 - 139', + from: { lat: 52.54873, lon: -1.81715 }, + to: { lat: 52.54851, lon: -1.81535 } + } + }, + { + type: 'Cross Street', + id: 'GB/XSTR/p0/46974', + score: 5.6787977219, + address: { + streetName: 'Birmingham Road, A5127 & Pilkington Avenue', + municipalitySubdivision: 'The Royal Town of Sutton Coldfield', + municipality: 'Birmingham', + countrySecondarySubdivision: 'West Midlands', + countrySubdivision: 'ENG', + countrySubdivisionName: 'England', + postalCode: 'B72', + countryCode: 'GB', + country: 'United Kingdom', + countryCodeISO3: 'GBR', + freeformAddress: 'Birmingham Road & Pilkington Avenue, The Royal Town of Sutton Coldfield, Birmingham, B72', + localName: 'Birmingham' + }, + position: { lat: 52.55416, lon: -1.82787 }, + viewport: { + topLeftPoint: { lat: 52.55506, lon: -1.82935 }, + btmRightPoint: { lat: 52.55326, lon: -1.82639 } + } + } + ] + }, + expResults: [ + { + formattedAddress: '135 Pilkington Avenue, The Royal Town of Sutton Coldfield, Birmingham, B72 1LH', + latitude: 52.54864, + longitude: -1.81606, + country: 'United Kingdom', + countryCode: 'GB', + state: 'England', + county: 'West Midlands', + district: 'The Royal Town of Sutton Coldfield', + city: 'Birmingham', + zipcode: 'B72', + streetName: 'Pilkington Avenue', + streetNumber: '135', + extra: { + id: 'GB/ADDR/p0/2329262', + confidence: 0.8141, + placeName: 'Birmingham', + type: 'Address Range', + bbox: [-1.81715, 52.54851, -1.81535, 52.54873] + } + }, + { + formattedAddress: 'Birmingham Road & Pilkington Avenue, The Royal Town of Sutton Coldfield, Birmingham, B72', + latitude: 52.55416, + longitude: -1.82787, + country: 'United Kingdom', + countryCode: 'GB', + state: 'England', + county: 'West Midlands', + district: 'The Royal Town of Sutton Coldfield', + city: 'Birmingham', + zipcode: 'B72', + streetName: 'Birmingham Road, A5127 & Pilkington Avenue', + streetNumber: undefined, + extra: { + id: 'GB/XSTR/p0/46974', + confidence: 0.5679, + placeName: 'Birmingham', + type: 'Cross Street', + bbox: [-1.82935, 52.55326, -1.82639, 52.55506] + } + } + ] + }, + '40.714232,-73.9612889': { + query: { lat: 40.714232, lng: -73.9612889 }, + body: { + summary: { queryTime: 9, numResults: 1 }, + addresses: [ + { + address: { + buildingNumber: '277', + streetNumber: '277', + routeNumbers: [], + street: 'Bedford Avenue', + streetName: 'Bedford Avenue', + streetNameAndNumber: '277 Bedford Avenue', + countryCode: 'US', + countrySubdivision: 'NY', + countrySecondarySubdivision: 'Kings', + municipality: 'New York', + postalCode: '11211', + municipalitySubdivision: 'Brooklyn', + country: 'United States', + countryCodeISO3: 'USA', + freeformAddress: '277 Bedford Avenue, Brooklyn, NY 11211', + boundingBox: { + northEast: '40.714498,-73.961285', + southWest: '40.713892,-73.961696', + entity: 'position' + }, + extendedPostalCode: '11211-4003', + countrySubdivisionName: 'New York', + localName: 'Brooklyn' + }, + position: '40.714287,-73.961426' + } + ] + }, + expResults: [ + { + formattedAddress: '277 Bedford Avenue, Brooklyn, NY 11211', + latitude: 40.714287, + longitude: -73.961426, + country: 'United States', + countryCode: 'US', + state: 'New York', + county: 'Kings', + district: 'Brooklyn', + city: 'New York', + zipcode: '11211', + streetName: 'Bedford Avenue', + streetNumber: '277', + extra: { + placeName: 'Brooklyn', + bbox: [-73.961696, 40.713892, -73.961285, 40.714498] + } + } + ] + }, + forward: { + formattedAddress: 'Avenue des Champs-Élysées & Galerie Élysée 26, 75008 Paris', + latitude: 48.86943, + longitude: 2.30896, + country: 'France', + countryCode: 'FR', + state: 'Île-de-France', + county: 'Paris', + district: '8ème Arrondissement', + city: 'Paris', + zipcode: '75008', + streetName: 'Avenue des Champs-Élysées & Galerie Élysée 26', + streetNumber: undefined, + extra: { + id: 'FR/XSTR/p1/764701', + confidence: 0.495, + placeName: 'Paris', + type: 'Cross Street', + bbox: [2.30759, 48.86853, 2.31033, 48.87033] + } + }, + reverse: { + formattedAddress: '277 Bedford Avenue, Brooklyn, NY 11211', + latitude: 40.714287, + longitude: -73.961426, + country: 'United States', + countryCode: 'US', + state: 'New York', + county: 'Kings', + district: 'Brooklyn', + city: 'New York', + zipcode: '11211', + streetName: 'Bedford Avenue', + streetNumber: '277', + extra: { + placeName: 'Brooklyn', + bbox: [-73.961696, 40.713892, -73.961285, 40.714498] + } + } +} diff --git a/test/geocoder/tomtom.spec.js b/test/geocoder/tomtom.spec.js new file mode 100644 index 0000000..865a773 --- /dev/null +++ b/test/geocoder/tomtom.spec.js @@ -0,0 +1,206 @@ +import assert from 'assert' +import sinon from 'sinon' +import { TomTomGeocoder, fetchAdapter } from '../../src/index.js' +import { fixtures } from './fixtures/tomtom.js' +import { itWithApiKey } from './helper.js' + +const { SHOW_LOG } = process.env + +describe('TomTomGeocoder', function () { + const options = { apiKey: 'apiKey' } + const mockedAdapter = sinon.stub() + + describe('constructor', () => { + it('an adapter must be set', () => { + assert.throws(() => { + new TomTomGeocoder() + }, /TomTomGeocoder needs an adapter/) + }) + + it('needs an apiKey', () => { + assert.throws(() => { + new TomTomGeocoder(mockedAdapter) + }, /You must specify apiKey to use TomTomGeocoder/) + }) + + it('is an instance of TomTomGeocoder', () => { + const geocoder = new TomTomGeocoder(mockedAdapter, options) + assert.ok(geocoder instanceof TomTomGeocoder) + }) + }) + + describe('forward', () => { + it('should not accept IPv4', () => { + const geocoder = new TomTomGeocoder(mockedAdapter, options) + assert.throws(() => { + geocoder.forward('127.0.0.1') + }, /TomTomGeocoder does not support geocoding IPv4/) + }) + + it('should not accept IPv6', () => { + const geocoder = new TomTomGeocoder(mockedAdapter, options) + assert.throws(() => { + geocoder.forward('2001:0db8:0000:85a3:0000:0000:ac1f:8001') + }, /TomTomGeocoder does not support geocoding IPv6/) + }) + + it('should call api', async function () { + const mockedAdapter = sinon.stub().returns( + Promise.resolve({ + status: 200, + json: () => Promise.resolve({}) + }) + ) + + const geocoder = new TomTomGeocoder(mockedAdapter, options) + const results = await geocoder.forward('1 champs élysée Paris') + + assert.deepStrictEqual(results, []) + + sinon.assert.calledOnceWithExactly(mockedAdapter, 'https://api.tomtom.com/search/2/geocode/1%20champs%20%C3%A9lys%C3%A9e%20Paris.json?key=apiKey') + }) + + it('should throw on error', async function () { + const mockedAdapter = sinon.stub().returns( + ({ + status: 502, + json: () => Promise.resolve({}) + }) + ) + + const geocoder = new TomTomGeocoder(mockedAdapter, options) + try { + await geocoder.forward('1 champs élysée Paris') + assert.ok(false, 'shall not reach here') + } catch (e) { + assert.strictEqual(e.status, 502) + } + }) + + it('should return address', async function () { + const query = '135 pilkington avenue, birmingham' + const { body, expResults } = fixtures[query] + + const expUrl = 'https://api.tomtom.com/search/2/geocode/135%20pilkington%20avenue%2C%20birmingham.json?key=apiKey' + + const mockedAdapter = sinon.stub().returns( + Promise.resolve({ + status: 200, + json: () => Promise.resolve(body) + }) + ) + + const geocoder = new TomTomGeocoder(mockedAdapter, options) + const results = await geocoder.forward(query) + + // eslint-disable-next-line no-console + if (SHOW_LOG) console.dir(results, { depth: null }) + assert.deepStrictEqual(results, expResults) + + sinon.assert.calledOnceWithExactly(mockedAdapter, expUrl) + }) + + it('should return address when object', async function () { + const query = '135 pilkington avenue, birmingham' + const { body, expResults } = fixtures[query] + const expUrl = 'https://api.tomtom.com/search/2/geocode/135%20pilkington%20avenue%2C%20birmingham.json?key=apiKey' + + const mockedAdapter = sinon.stub().returns( + Promise.resolve({ + status: 200, + json: () => Promise.resolve(body) + }) + ) + + const geocoder = new TomTomGeocoder(mockedAdapter, options) + const results = await geocoder.forward({ address: query }) + + // eslint-disable-next-line no-console + if (SHOW_LOG) console.dir(results, { depth: null }) + assert.deepStrictEqual(results, expResults) + + sinon.assert.calledOnceWithExactly(mockedAdapter, expUrl) + }) + }) + + describe('reverse', () => { + it('should call api', async function () { + const mockedAdapter = sinon.stub().returns( + Promise.resolve({ + status: 200, + json: () => Promise.resolve({}) + }) + ) + + const geocoder = new TomTomGeocoder(mockedAdapter, options) + const results = await geocoder.reverse({ lat: 40.714232, lng: -73.9612889 }) + + assert.deepStrictEqual(results, []) + + sinon.assert.calledOnceWithExactly(mockedAdapter, 'https://api.tomtom.com/search/2/reverseGeocode/40.714232%2C-73.9612889.json?key=apiKey') + }) + + it('should throw on error', async function () { + const mockedAdapter = sinon.stub().returns( + ({ + status: 502, + json: () => Promise.resolve({}) + }) + ) + + const geocoder = new TomTomGeocoder(mockedAdapter, options) + try { + await geocoder.reverse({ lat: 40.714232, lon: -73.9612889 }) + assert.ok(false, 'shall not reach here') + } catch (e) { + assert.strictEqual(e.status, 502) + } + }) + + it('should return address', async function () { + const query = '40.714232,-73.9612889' + const { body, expResults } = fixtures[query] + const expUrl = 'https://api.tomtom.com/search/2/reverseGeocode/40.714232%2C-73.9612889.json?key=apiKey' + + const mockedAdapter = sinon.stub().returns( + Promise.resolve({ + status: 200, + json: () => Promise.resolve(body) + }) + ) + + const geocoder = new TomTomGeocoder(mockedAdapter, options) + const results = await geocoder.reverse(query) + // eslint-disable-next-line no-console + if (SHOW_LOG) console.dir(results, { depth: null }) + assert.deepStrictEqual(results, expResults) + + sinon.assert.calledOnceWithExactly(mockedAdapter, expUrl) + }) + }) + + describe('call api', () => { + const { TOMTOM_APIKEY: apiKey } = process.env + let geocoder + + before(function () { + geocoder = apiKey && new TomTomGeocoder(fetchAdapter(), { apiKey }) + }) + + itWithApiKey(apiKey, 'should call forward api', async function () { + const query = '1 champs élysée Paris' + const results = await geocoder.forward(query) + // eslint-disable-next-line no-console + if (SHOW_LOG) console.dir(results[0], { depth: null }) + assert.deepStrictEqual(fixtures.forward, results[0]) + }) + + itWithApiKey(apiKey, 'should call reverse api', async function () { + const query = '40.714232,-73.9612889' + const results = await geocoder.reverse(query) + // eslint-disable-next-line no-console + if (SHOW_LOG) console.dir(results[0], { depth: null }) + assert.deepStrictEqual(fixtures.reverse, results[0]) + }) + }) +}) diff --git a/types/geocoder/here.d.ts b/types/geocoder/here.d.ts index 2349316..dfdc7a7 100644 --- a/types/geocoder/here.d.ts +++ b/types/geocoder/here.d.ts @@ -36,7 +36,7 @@ export class HereGeocoder extends AbstractGeocoder { building: any; extra: { id: any; - confidence: any; + confidence: number; }; }; } diff --git a/types/geocoder/index.d.ts b/types/geocoder/index.d.ts index 09e2ea7..4085ca0 100644 --- a/types/geocoder/index.d.ts +++ b/types/geocoder/index.d.ts @@ -16,4 +16,5 @@ export * from "./osm.js"; export * from "./pelias.js"; export * from "./pickpoint.js"; export * from "./teleport.js"; +export * from "./tomtom.js"; export * from "./yandex.js"; diff --git a/types/geocoder/locationiq.d.ts b/types/geocoder/locationiq.d.ts index ec3f5af..cf336cd 100644 --- a/types/geocoder/locationiq.d.ts +++ b/types/geocoder/locationiq.d.ts @@ -39,7 +39,7 @@ export class LocationIqGeocoder extends AbstractGeocoder { streetNumber: any; extra: { id: any; - confidence: any; + confidence: number; type: any; addrType: any; bbox: any[] | undefined; diff --git a/types/geocoder/opendatafrance.d.ts b/types/geocoder/opendatafrance.d.ts index adbc770..7b435bb 100644 --- a/types/geocoder/opendatafrance.d.ts +++ b/types/geocoder/opendatafrance.d.ts @@ -43,7 +43,7 @@ export class OpendataFranceGeocoder extends AbstractGeocoder { citycode: any; extra: { id: any; - confidence: any; + confidence: number; }; }; } diff --git a/types/geocoder/osm.d.ts b/types/geocoder/osm.d.ts index 42d8cfa..bd7fa43 100644 --- a/types/geocoder/osm.d.ts +++ b/types/geocoder/osm.d.ts @@ -78,7 +78,7 @@ export class OsmGeocoder extends AbstractGeocoder { neighbourhood: any; extra: { id: any; - confidence: any; + confidence: number; bbox: number[] | undefined; }; }; diff --git a/types/geocoder/tomtom.d.ts b/types/geocoder/tomtom.d.ts new file mode 100644 index 0000000..84b54e3 --- /dev/null +++ b/types/geocoder/tomtom.d.ts @@ -0,0 +1,90 @@ +/** + * see https://developer.tomtom.com/search-api/documentation/geocoding-service/geocode + * @typedef {object} TomTomForwardQuery + * @property {string} address + * @property {number} [limit] + * @property {string} [category] see for list of values + * @property {string} [preferredLabelValues] + * @property {string} [language] + */ +/** + * see https://developer.tomtom.com/search-api/documentation/reverse-geocoding-service/reverse-geocode + * @typedef {object} TomTomReverseQuery + * @property {number} lat latitude + * @property {number} lng longitude + * @property {number} [limit] + * @property {boolean} [returnIntersection] + * @property {string} [locationType] + * @property {string} [preferredLabelValues] + * @property {string} [language] + */ +export class TomTomGeocoder extends AbstractGeocoder { + /** + * available options + * @param {fetchAdapterFn} adapter + * @param {object} options + * @param {string} options.apiKey + * @param {number} [options.limit] + * @param {string} [options.language] + * @param {number} [options.radius] + */ + constructor(adapter: fetchAdapterFn, options?: { + apiKey: string; + limit?: number | undefined; + language?: string | undefined; + radius?: number | undefined; + }); + params: { + key: string; + limit?: number | undefined; + language?: string | undefined; + radius?: number | undefined; + }; + get endpoint(): string; + get revEndpoint(): string; + /** + * format forward geocoding results + * @param {object} result + * @returns {object} + */ + _formatResult(result: object): object; + /** + * format reverse search result + * @param {object} result + * @returns {object} + */ + _formatResultRev(result: object): object; +} +export type fetchAdapterFn = import('../adapter').fetchAdapterFn; +/** + * see https://developer.tomtom.com/search-api/documentation/geocoding-service/geocode + */ +export type TomTomForwardQuery = { + address: string; + limit?: number | undefined; + /** + * see for list of values + */ + category?: string | undefined; + preferredLabelValues?: string | undefined; + language?: string | undefined; +}; +/** + * see https://developer.tomtom.com/search-api/documentation/reverse-geocoding-service/reverse-geocode + */ +export type TomTomReverseQuery = { + /** + * latitude + */ + lat: number; + /** + * longitude + */ + lng: number; + limit?: number | undefined; + returnIntersection?: boolean | undefined; + locationType?: string | undefined; + preferredLabelValues?: string | undefined; + language?: string | undefined; +}; +import { AbstractGeocoder } from "./abstract.js"; diff --git a/types/types.d.ts b/types/types.d.ts index e13e6a2..d531b18 100644 --- a/types/types.d.ts +++ b/types/types.d.ts @@ -73,9 +73,13 @@ export type GeocoderResult = { streetNumber?: string; [key: string]: any; extra?: { + /** provider id */ id?: string | number; + /** Indicates for each result how good the result matches to the original query `[0 .. 1]` */ confidence?: number; + /** bounding box of the result object */ bbox?: number[]; + /** any other keys */ [key: string]: any; }; }; diff --git a/types/utils/index.d.ts b/types/utils/index.d.ts index cd615aa..c010926 100644 --- a/types/utils/index.d.ts +++ b/types/utils/index.d.ts @@ -2,4 +2,5 @@ export * from "./camelCase.js"; export * from "./httperror.js"; export * from "./isocode.js"; export * from "./isType.js"; +export * from "./toFixed.js"; export * from "./toUpperCase.js"; diff --git a/types/utils/toFixed.d.ts b/types/utils/toFixed.d.ts new file mode 100644 index 0000000..123cf17 --- /dev/null +++ b/types/utils/toFixed.d.ts @@ -0,0 +1 @@ +export function toFixed(num: any, len?: number): number;