From 0c74ff8bc2a9f7cb458ffaa1af2b9cf257174261 Mon Sep 17 00:00:00 2001 From: Matthew Amato Date: Mon, 16 Apr 2018 21:08:19 -0400 Subject: [PATCH] Add support for the Pelias geocoder [Pelias](https://pelias.io/) is an open source geocoder originally built by Mapzen. This adds `PeliasGeocoderService` for use with the Geocoder widget. Because Pelias (and some other geocoders) differentiate between an `autocomplete` request and a `search` request, I added an optional parameter to `GeocoderService.geocode` to provide that information. The widget will us auto-complete as you are typing and `search` once you hit enter or select a value from the dropdown. --- CHANGES.md | 2 + Source/Core/GeocodeType.js | 32 ++++++ Source/Core/GeocoderService.js | 2 + Source/Core/PeliasGeocoderService.js | 103 +++++++++++++++++++ Source/Widgets/Geocoder/GeocoderViewModel.js | 17 +-- Specs/Core/PeliasGeocoderServiceSpec.js | 93 +++++++++++++++++ 6 files changed, 242 insertions(+), 7 deletions(-) create mode 100644 Source/Core/GeocodeType.js create mode 100644 Source/Core/PeliasGeocoderService.js create mode 100644 Specs/Core/PeliasGeocoderServiceSpec.js diff --git a/CHANGES.md b/CHANGES.md index 34607c3ed702..8f4a15a772c5 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -10,6 +10,8 @@ Change Log * Added option `logDepthBuffer` to `Viewer`. With this option there is typically a single frustum using logarithmic depth rendered. This increases performance by issuing less draw calls to the GPU and helps to avoid artifacts on the connection of two frustums. [#5851](https://github.com/AnalyticalGraphicsInc/cesium/pull/5851) * When a log depth buffer is supported, the frustum near and far planes default to `0.1` and `1e10` respectively. * Added `Math.log2` to compute the base 2 logarithm of a number. +* Added 'PeliasGeocoderService', which provides geocoding via a [Pelias](https://pelias.io) server. +* Added `GeocodeType` enum and use it as an optional parameter to all `GeocoderService` instances to differentiate between autocomplete and search requests. ##### Fixes :wrench: * Fixed bugs in `TimeIntervalCollection.removeInterval`. [#6418](https://github.com/AnalyticalGraphicsInc/cesium/pull/6418). diff --git a/Source/Core/GeocodeType.js b/Source/Core/GeocodeType.js new file mode 100644 index 000000000000..2f27fb3e0780 --- /dev/null +++ b/Source/Core/GeocodeType.js @@ -0,0 +1,32 @@ +define([ + '../Core/freezeObject' +], function( + freezeObject) { + 'use strict'; + + /** + * The type of geocoding to be performed by a {@link GeocoderService}. + * @exports GeocodeType + * @see Geocoder + */ + var GeocodeType = { + /** + * Perform a search where the input is considered complete. + * + * @type {Number} + * @constant + */ + SEARCH: 0, + + /** + * Perform an auto-complete using partial input, typically + * reserved for providing possible results as a user is typing. + * + * @type {Number} + * @constant + */ + AUTOCOMPLETE: 1 + }; + + return freezeObject(GeocodeType); +}); diff --git a/Source/Core/GeocoderService.js b/Source/Core/GeocoderService.js index e2ac837c36a3..60d41eb597cc 100644 --- a/Source/Core/GeocoderService.js +++ b/Source/Core/GeocoderService.js @@ -17,6 +17,7 @@ define([ * @constructor * * @see BingMapsGeocoderService + * @see PeliasGeocoderService */ function GeocoderService() { } @@ -25,6 +26,7 @@ define([ * @function * * @param {String} query The query to be sent to the geocoder service + * @param {GeocodeType} [type=GeocodeType.SEARCH] The type of geocode to perform. * @returns {Promise} */ GeocoderService.prototype.geocode = DeveloperError.throwInstantiationError; diff --git a/Source/Core/PeliasGeocoderService.js b/Source/Core/PeliasGeocoderService.js new file mode 100644 index 000000000000..b357db862c8f --- /dev/null +++ b/Source/Core/PeliasGeocoderService.js @@ -0,0 +1,103 @@ +define([ + './Check', + './defined', + './defineProperties', + './GeocodeType', + './Rectangle', + './Resource' +], function ( + Check, + defined, + defineProperties, + GeocodeType, + Rectangle, + Resource) { + 'use strict'; + + /** + * Provides geocoding via a {@link https://pelias.io/|Pelias} server. + * @alias PeliasGeocoderService + * @constructor + * + * @param {Resource|String} url The endpoint to the Pelias server. + * + * @example + * // Configure a Viewer to use the Pelias server hosted by https://geocode.earth/ + * var viewer = new Cesium.Viewer('cesiumContainer', { + * geocoder: new Cesium.PeliasGeocoderService(new Cesium.Resource({ + * url: 'https://api.geocode.earth/v1/', + * queryParameters: { + * api_key: '' + * } + * })) + * }); + */ + function PeliasGeocoderService(url) { + //>>includeStart('debug', pragmas.debug); + Check.defined('url', url); + //>>includeEnd('debug'); + + this._url = Resource.createIfNeeded(url); + } + + defineProperties(PeliasGeocoderService.prototype, { + /** + * The Resource used to access the Pelias endpoint. + * @type {Resource} + * @memberof {PeliasGeocoderService.prototype} + * @readonly + */ + url: { + get: function () { + return this._url; + } + } + }); + + /** + * @function + * + * @param {String} query The query to be sent to the geocoder service + * @param {GeocodeType} [type=GeocodeType.SEARCH] The type of geocode to perform. + * @returns {Promise} + */ + PeliasGeocoderService.prototype.geocode = function(query, type) { + //>>includeStart('debug', pragmas.debug); + Check.typeOf.string('query', query); + //>>includeEnd('debug'); + + var resource = this._url.getDerivedResource({ + url: type === GeocodeType.AUTOCOMPLETE ? 'autocomplete' : 'search', + queryParameters: { + text: query + } + }); + + return resource.fetchJson() + .then(function (results) { + return results.features.map(function (resultObject) { + var bboxDegrees = resultObject.bbox; + + // Pelias does not always provide bounding information + // so just expand the location slightly. + if (!defined(bboxDegrees)) { + var lon = resultObject.geometry.coordinates[0]; + var lat = resultObject.geometry.coordinates[1]; + bboxDegrees = [ + lon - 0.001, + lat - 0.001, + lon + 0.001, + lat + 0.001 + ]; + } + + return { + displayName: resultObject.properties.label, + destination: Rectangle.fromDegrees(bboxDegrees[0], bboxDegrees[1], bboxDegrees[2], bboxDegrees[3]) + }; + }); + }); + }; + + return PeliasGeocoderService; +}); diff --git a/Source/Widgets/Geocoder/GeocoderViewModel.js b/Source/Widgets/Geocoder/GeocoderViewModel.js index f81fd709f213..a2c8c975be54 100644 --- a/Source/Widgets/Geocoder/GeocoderViewModel.js +++ b/Source/Widgets/Geocoder/GeocoderViewModel.js @@ -6,6 +6,7 @@ define([ '../../Core/defineProperties', '../../Core/DeveloperError', '../../Core/Event', + '../../Core/GeocodeType', '../../Core/Matrix4', '../../ThirdParty/knockout', '../../ThirdParty/when', @@ -19,6 +20,7 @@ define([ defineProperties, DeveloperError, Event, + GeocodeType, Matrix4, knockout, when, @@ -79,7 +81,8 @@ define([ return suggestionsNotEmpty && showSuggestions; }); - this._searchCommand = createCommand(function() { + this._searchCommand = createCommand(function(geocodeType) { + geocodeType = defaultValue(geocodeType, GeocodeType.SEARCH); that._focusTextbox = false; if (defined(that._selectedSuggestion)) { that.activateSuggestion(that._selectedSuggestion); @@ -89,7 +92,7 @@ define([ if (that.isSearchInProgress) { cancelGeocode(that); } else { - geocode(that, that._geocoderServices); + geocode(that, that._geocoderServices, geocodeType); } }); @@ -338,13 +341,13 @@ define([ }); } - function chainPromise(promise, geocoderService, query) { + function chainPromise(promise, geocoderService, query, geocodeType) { return promise .then(function(result) { if (defined(result) && result.state === 'fulfilled' && result.value.length > 0){ return result; } - var nextPromise = geocoderService.geocode(query) + var nextPromise = geocoderService.geocode(query, geocodeType) .then(function (result) { return {state: 'fulfilled', value: result}; }) @@ -356,7 +359,7 @@ define([ }); } - function geocode(viewModel, geocoderServices) { + function geocode(viewModel, geocoderServices, geocodeType) { var query = viewModel._searchText; if (hasOnlyWhitespace(query)) { @@ -368,7 +371,7 @@ define([ var promise = when.resolve(); for (var i = 0; i < geocoderServices.length; i++) { - promise = chainPromise(promise, geocoderServices[i], query); + promise = chainPromise(promise, geocoderServices[i], query, geocodeType); } viewModel._geocodePromise = promise; @@ -442,7 +445,7 @@ define([ if (results.length >= 5) { return results; } - return service.geocode(query) + return service.geocode(query, GeocodeType.AUTOCOMPLETE) .then(function(newResults) { results = results.concat(newResults); return results; diff --git a/Specs/Core/PeliasGeocoderServiceSpec.js b/Specs/Core/PeliasGeocoderServiceSpec.js new file mode 100644 index 000000000000..a96993c03466 --- /dev/null +++ b/Specs/Core/PeliasGeocoderServiceSpec.js @@ -0,0 +1,93 @@ +defineSuite([ + 'Core/PeliasGeocoderService', + 'Core/GeocodeType', + 'Core/Rectangle', + 'Core/Resource', + 'ThirdParty/when' + ], function( + PeliasGeocoderService, + GeocodeType, + Rectangle, + Resource, + when) { + 'use strict'; + + it('constructor throws without url', function() { + expect(function() { + return new PeliasGeocoderService(undefined); + }).toThrowDeveloperError(); + }); + + it('returns geocoder results', function () { + var service = new PeliasGeocoderService('http://test.invalid/v1/'); + + var query = 'some query'; + var data = { + features: [{ + type: "Feature", + geometry: { + type: "Point", + coordinates: [-75.172489, 39.927828] + }, + properties: { + label: "1826 S 16th St, Philadelphia, PA, USA" + } + }] + }; + spyOn(Resource.prototype, 'fetchJson').and.returnValue(when.resolve(data)); + + return service.geocode(query) + .then(function(results) { + expect(results.length).toEqual(1); + expect(results[0].displayName).toEqual(data.features[0].properties.label); + expect(results[0].destination).toBeInstanceOf(Rectangle); + }); + }); + + it('returns no geocoder results if Pelias has no results', function() { + var service = new PeliasGeocoderService('http://test.invalid/v1/'); + + var query = 'some query'; + var data = { features: [] }; + spyOn(Resource.prototype, 'fetchJson').and.returnValue(when.resolve(data)); + + return service.geocode(query) + .then(function(results) { + expect(results.length).toEqual(0); + }); + }); + + it('calls search endpoint if specified', function () { + var service = new PeliasGeocoderService('http://test.invalid/v1/'); + + var query = 'some query'; + var data = { features: [] }; + spyOn(Resource.prototype, 'fetchJson').and.returnValue(when.resolve(data)); + var getDerivedResource = spyOn(service._url, 'getDerivedResource').and.callThrough(); + + service.geocode(query, GeocodeType.SEARCH); + expect(getDerivedResource).toHaveBeenCalledWith({ + url: 'search', + queryParameters: { + text: query + } + }); + }); + + it('calls autocomplete endpoint if specified', function () { + var service = new PeliasGeocoderService('http://test.invalid/v1/'); + + var query = 'some query'; + var data = { features: [] }; + spyOn(Resource.prototype, 'fetchJson').and.returnValue(when.resolve(data)); + var getDerivedResource = spyOn(service._url, 'getDerivedResource').and.callThrough(); + + service.geocode(query, GeocodeType.AUTOCOMPLETE); + expect(getDerivedResource).toHaveBeenCalledWith({ + url: 'autocomplete', + queryParameters: { + text: query + } + }); + }); +});