diff --git a/contribs/gmf/apps/desktop_alt/index.html b/contribs/gmf/apps/desktop_alt/index.html index c1ad2618c072..b5c7b6632ad5 100644 --- a/contribs/gmf/apps/desktop_alt/index.html +++ b/contribs/gmf/apps/desktop_alt/index.html @@ -86,6 +86,10 @@ data-toggle="tooltip" data-placement="left" data-original-title="{{'Import Layer'|translate}}"> +

@@ -228,6 +232,17 @@ +
+
+
+ {{'Routing'|translate}} + × +
+ + +
+
@@ -398,6 +413,19 @@ module.constant('gmfTreeUrl', 'https://geomapfish-demo.camptocamp.com/2.3/wsgi/themes?version=2&background=background&interface=desktop'); module.constant('gmfLayersUrl', 'https://geomapfish-demo.camptocamp.com/2.3/wsgi/layers/'); module.constant('gmfShortenerCreateUrl', ''); + module.constant('ngeoRoutingOptions', { + 'backendUrl': 'http://routing.osm.ch/', + 'profiles': [ + {label : 'Car', profile: 'routed-car'}, + {label : 'Bike (City)', profile: 'routed-bike'}, + {label : 'Bike (Touring)', profile: 'routed-bike-touring'}, + {label : 'Foot', profile: 'routed-foot'}, + {label : 'Hiking', profile: 'routed-hiking'} + ] + }); + module.constant('ngeoNominatimSearchDefaultParams', { + 'countrycodes': 'CH' + }); module.constant('gmfSearchGroups', ['osm','district']); // Requires that the gmfSearchGroups is specified module.constant('gmfSearchActions', [ diff --git a/contribs/gmf/apps/desktop_alt/js/controller.js b/contribs/gmf/apps/desktop_alt/js/controller.js index daf8e2fdcaac..d8f2be7498f3 100644 --- a/contribs/gmf/apps/desktop_alt/js/controller.js +++ b/contribs/gmf/apps/desktop_alt/js/controller.js @@ -11,6 +11,7 @@ goog.require('gmf.controllers.AbstractDesktopController'); goog.require('gmf.import.module'); goog.require('ngeo.googlestreetview.module'); goog.require('ngeo.statemanager.WfsPermalink'); +goog.require('ngeo.routing.module'); goog.require('ngeo.proj.EPSG2056'); goog.require('ngeo.proj.EPSG21781'); goog.require('ol'); @@ -20,6 +21,7 @@ app.desktop_alt.module = angular.module('AppDesktopAlt', [ app.module.name, gmf.controllers.AbstractDesktopController.module.name, gmf.import.module.name, + ngeo.routing.module.name, ngeo.googlestreetview.module.name, ngeo.statemanager.WfsPermalink.module.name, ]); diff --git a/contribs/gmf/less/desktop.less b/contribs/gmf/less/desktop.less index ae96629a9ac1..569fadd8f8eb 100644 --- a/contribs/gmf/less/desktop.less +++ b/contribs/gmf/less/desktop.less @@ -19,6 +19,7 @@ @import 'fullscreenpopup.less'; @import 'print.less'; @import 'importdatasource.less'; +@import 'routing.less'; @map-tools-size: 3rem; @button-size: 4rem; diff --git a/contribs/gmf/less/routing.less b/contribs/gmf/less/routing.less new file mode 100644 index 000000000000..09de866c113c --- /dev/null +++ b/contribs/gmf/less/routing.less @@ -0,0 +1,48 @@ +.ngeo-routing-error { + color: darkred; +} + +.ngeo-routing-start .fa-map-marker { + color: #6BE62E; + text-shadow: -1px 0 #4CB01E, 0 1px #4CB01E, 1px 0 #4CB01E, 0 -1px #4CB01E; +} + +.ngeo-routing-destination .fa-map-marker { + color: #FF3E13; + text-shadow: -1px 0 #CD3412, 0 1px #CD3412, 1px 0 #CD3412, 0 -1px #CD3412; +} + +.ngeo-routing-vias .fa-map-marker { + color: #767676; + text-shadow: -1px 0 #000000, 0 1px #000000, 1px 0 #000000, 0 -1px #000000; +} + +.ngeo-nominatim-input { + height: 30px; +} + +/** + * Typeahead + */ +.tt-menu { + background-color: #fff; + border: 1px solid #ccc; +} + +.tt-suggestion.tt-is-under-cursor, .tt-suggestion:hover { + color: #fff; + background-color: #0097cf; +} + +.tt-suggestion p { + margin: 0; +} + +.ngeo-nominatim-input .twitter-typeahead { + width: 100%; + background: #fff; +} + +.ngeo-routing-vias .form-inline .input-group .form-control { + width: 185px; +} diff --git a/contribs/gmf/src/controllers/AbstractDesktopController.js b/contribs/gmf/src/controllers/AbstractDesktopController.js index 951ab7cf1118..cf9cd14f1bfc 100644 --- a/contribs/gmf/src/controllers/AbstractDesktopController.js +++ b/contribs/gmf/src/controllers/AbstractDesktopController.js @@ -108,6 +108,12 @@ gmf.controllers.AbstractDesktopController = function(config, $scope, $injector) */ this.editFeatureActive = false; + /** + * @type {boolean} + * @export + */ + this.routingfeatureActive = false; + /** * @type {boolean} * @export diff --git a/examples/routing.html b/examples/routing.html new file mode 100644 index 000000000000..f1b03e6f94a4 --- /dev/null +++ b/examples/routing.html @@ -0,0 +1,94 @@ + + + + Routing example + + + + + + + + + +
+
+
+
+
+
+ + +
+
+
+ +

This example shows how to use ngeo's routing component (ngeo-routingComponent)..

+ + + + + + + + + + + + + diff --git a/examples/routing.js b/examples/routing.js new file mode 100644 index 000000000000..bec547bf67ca --- /dev/null +++ b/examples/routing.js @@ -0,0 +1,46 @@ +/** + * This example shows the ngeo routing directive. + */ + +goog.provide('app.routing'); + +goog.require('ol.Map'); +goog.require('ol.View'); +goog.require('ol.layer.Tile'); +goog.require('ol.source.OSM'); + +/** @type {!angular.Module} */ +app.module = angular.module('app', ['ngeo']); + + +/** + * The application's main directive. + * @constructor + * @ngInject + */ +app.MainController = function() { + + /** + * @type {ol.Map} + * @export + */ + this.map = new ol.Map({ + layers: [ + new ol.layer.Tile({ + source: new ol.source.OSM() + }) + ], + view: new ol.View({ + center: [931010.1535989442, 5961705.842297254], + zoom: 9 + }) + }); + + /** + * @type {boolean} + * @export + */ + this.routingfeatureActive = true; +}; + +app.module.controller('MainController', app.MainController); diff --git a/externs/typeahead.js b/externs/typeahead.js index b5ee9911bcbc..d13e4587b2ea 100644 --- a/externs/typeahead.js +++ b/externs/typeahead.js @@ -139,7 +139,7 @@ let TypeaheadTemplates; /** * @typedef {{ - * source: function(string,function(Array.)), + * source: function(string,function(Array.),(function(Array.)|undefined)), * async: (boolean|undefined), * name: (string|undefined), * limit: (number|undefined), diff --git a/options/ngeox.js b/options/ngeox.js index 8592dc673f3e..864b581bc760 100644 --- a/options/ngeox.js +++ b/options/ngeox.js @@ -2605,3 +2605,55 @@ ngeox.CreatePrint; * }} */ ngeox.FilterCondition; + + +/** + * Format a duration in seconds to a more readable form. + * Arguments: + * - duration The duration in seconds. + * @typedef {function(number): string} + */ +ngeox.duration; + + + +/** + * @typedef {{ + * name: (string), + * coordinate: (ol.Coordinate) + * }} + */ +ngeox.NominatimSearchResult; + +/** + * @typedef {{ + * display_name: (string), + * lon: (number), + * lat: (number) + * }} + */ +ngeox.NominatimSearchResponseResult; + +/** + * @typedef {{ + * feature: (?ol.Feature), + * onSelect: (function(ngeox.NominatimSearchResult)) + * }} + */ +ngeox.RoutingVia; + +/** + * @typedef {{ + * label: (string), + * profile: (string) + * }} + */ +ngeox.RoutingProfile; + +/** + * @typedef {{ + * backendUrl: (string|undefined), + * profiles: (Array.|undefined) + * }} + */ +ngeox.RoutingOptions; diff --git a/src/misc/filters.js b/src/misc/filters.js index 6864c09cb608..2f2e1c4b8988 100644 --- a/src/misc/filters.js +++ b/src/misc/filters.js @@ -321,3 +321,108 @@ ngeo.misc.filters.trustHtmlFilter = function($sce) { }; ngeo.misc.filters.filter('ngeoTrustHtml', ngeo.misc.filters.trustHtmlFilter); + + +/** + * A filter used to format a time duration in seconds into a more + * readable form. + * Only the two largest units will be shown. + * + * Examples: + * {{42 | ngeoDuration}} => 42 seconds + * {{132 | ngeoDuration}} => 2 minutes 12 seconds + * {{3910 | ngeoDuration}} => 1 hour 5 minutes + * -> Note: the remaining 10 seconds will be dropped + * + * @param {angularGettext.Catalog} gettextCatalog Gettext catalog. + * @return {ngeox.duration} Function used to format a time duration + * in seconds into a string. + * @ngInject + * @ngdoc filter + * @ngname ngeoDuration + */ +ngeo.misc.filters.Duration = function(gettextCatalog) { + // time unit enum + const TimeUnits = Object.freeze({ + SECONDS: Symbol('seconds'), + MINUTES: Symbol('minutes'), + HOURS: Symbol('hours'), + DAYS: Symbol('days') + }); + + /** + * @param {number} amount Amount of time. + * @param {symbol} unit Unit of time. + * @return {string} formatted and translated string + */ + const pluralize = function(amount, unit) { + let formattedUnit = ''; + switch (unit) { + case TimeUnits.SECONDS: + formattedUnit = amount !== 1 ? gettextCatalog.getString('seconds') : gettextCatalog.getString('second'); + break; + case TimeUnits.MINUTES: + formattedUnit = amount !== 1 ? gettextCatalog.getString('minutes') : gettextCatalog.getString('minute'); + break; + case TimeUnits.HOURS: + formattedUnit = amount !== 1 ? gettextCatalog.getString('hours') : gettextCatalog.getString('hour'); + break; + case TimeUnits.DAYS: + formattedUnit = amount !== 1 ? gettextCatalog.getString('days') : gettextCatalog.getString('day'); + break; + default: + break; + } + return `${amount} ${formattedUnit}`; + }; + + /** + * @param {number} duration The duration in seconds. + * @return {string} The formatted string. + */ + const result = function(duration) { + // round to next integer + duration = Math.round(duration); + + // just seconds + let output; + if (duration < 60) { + return pluralize(duration, TimeUnits.SECONDS); + } + + // minutes (+ seconds) + let remainder = duration % 60; // seconds + duration = Math.floor(duration / 60); // minutes + if (duration < 60) { // less than an hour + output = pluralize(duration, TimeUnits.MINUTES); + if (remainder > 0) { + output += ` ${pluralize(remainder, TimeUnits.SECONDS)}`; + } + return output; + } + + // hours (+ minutes) + remainder = duration % 60; // minutes + duration = Math.floor(duration / 60); // hours + if (duration < 24) { // less than a day + output = pluralize(duration, TimeUnits.HOURS); + if (remainder > 0) { + output += ` ${pluralize(remainder, TimeUnits.MINUTES)}`; + } + return output; + } + + // days (+ hours) + remainder = duration % 24; // hours + duration = Math.floor(duration / 24); // days + output = pluralize(duration, TimeUnits.DAYS); + if (remainder > 0) { + output += ` ${pluralize(remainder, TimeUnits.HOURS)}`; + } + return output; + }; + + return result; +}; + +ngeo.misc.filters.filter('ngeoDuration', ngeo.misc.filters.Duration); diff --git a/src/routing/NominatimInputComponent.js b/src/routing/NominatimInputComponent.js new file mode 100644 index 000000000000..5154db8c2b59 --- /dev/null +++ b/src/routing/NominatimInputComponent.js @@ -0,0 +1,167 @@ +goog.provide('ngeo.routing.NominatimInputComponent'); + +goog.require('ngeo.search.searchDirective'); +goog.require('ngeo.routing.NominatimService'); + + +/** + * @type {!angular.Module} + */ +ngeo.routing.NominatimInputComponent.module = angular.module('ngeoRoutingNominatimInputComponent', [ + ngeo.search.searchDirective.module.name, + ngeo.routing.NominatimService.module.name +]); + +// webpack: exports.run(/* @ngInject */ ($templateCache) => { +// webpack: $templateCache.put('ngeo/routing/nominatiminput', require('./nominatiminput.html')); +// webpack: }); + + +ngeo.routing.NominatimInputComponent.module.value('ngeoRoutingNominatimInputComponentTemplateUrl', + /** + * @param {!angular.Attributes} $attrs Attributes. + * @return {string} Template URL. + */ + ($attrs) => { + const templateUrl = $attrs['ngeoRoutingNominatimInputComponentTemplateUrl']; + return templateUrl !== undefined ? templateUrl : + `${ngeo.baseModuleTemplateUrl}/routing/nominatiminput.html`; // nowebpack + // webpack: 'ngeo/routing/nominatiminput'; + } +); + + +/** + * @param {!angular.Attributes} $attrs Attributes. + * @param {!function(!angular.Attributes): string} ngeoRoutingNominatimInputComponentTemplateUrl Template function. + * @return {string} Template URL. + * @ngInject + */ +function ngeoRoutingNominatimInputComponentTemplateUrl($attrs, ngeoRoutingNominatimInputComponentTemplateUrl) { + return ngeoRoutingNominatimInputComponentTemplateUrl($attrs); +} + + +/** + * @param {!angular.JQLite} $element Element. + * @param {angular.$injector} $injector Main injector. + * @param {!angular.Scope} $scope Scope. + * @param {!ngeo.NominatimService} ngeoNominatimService service for Nominatim + * @constructor + * @private + * @ngInject + * @ngdoc controller + * @ngname NgeoNominatimInputController + */ +ngeo.NgeoNominatimInputController = function($element, $injector, $scope, ngeoNominatimService) { + + /** + * @type {!angular.JQLite} + * @private + */ + this.element_ = $element; + + /** + * @type {angular.Scope} + * @private + */ + this.$scope_ = $scope; + + /** + * @type {ngeo.NominatimService} + * @export + */ + this.ngeoNominatimService = ngeoNominatimService; + + /** + * @type {(function(Object)|undefined)} + * @export + */ + this.onSelect; + + /** + * @type {string} + * @export + */ + this.inputValue; + + /** + * @type {TypeaheadOptions} + * @export + */ + this.options = /** @type {TypeaheadOptions} */ ({ + }); + + /** + * @type {Array.} + * @export + */ + this.datasets = [/** @type {TypeaheadDataset} */({ + name: 'nominatim', + display: 'name', + source: this.ngeoNominatimService.typeaheadSourceDebounced + })]; + + /** + * @type {ngeox.SearchDirectiveListeners} + * @export + */ + this.listeners = /** @type {ngeox.SearchDirectiveListeners} */({ + select: this.select_.bind(this) + }); + + /** + * @type {string} + * @export + */ + this.placeholder = ''; + +}; + +/** + * @param {jQuery.Event} event Event. + * @param {ngeox.NominatimSearchResult} suggestion Suggestion. + * @param {TypeaheadDataset} dataset Dataset. + * @this {ngeo.NgeoNominatimInputController} + * @private + */ +ngeo.NgeoNominatimInputController.prototype.select_ = function(event, suggestion, dataset) { + if (this.onSelect) { + this.onSelect(suggestion); + } +}; + +/** + * Input form field which provides Nominatim typeahead lookup using {@link ngeo.NominatimService}. + * + * Example: + * + * + * + * Is used in in the partial of {@link ngeo.routingFeatureComponent}. + * + * See the [../examples/routing.html](../examples/routing.html) example to see it in action. + * + * @htmlAttribute {function(ngeox.NominatimSearchResult)} ngeo-nominatim-input-on-select + * Event fired when user selects a new suggestion. + * @htmlAttribute {string} ngeo-nominatim-input-value + * Value of input field, will be set to the label of the search result. + * @htmlAttribute {string} ngeo-nominatim-input-placeholder + * Placeholder text, when field is empty. + * @ngdoc directive + * @ngname ngeoNominatimInput + */ +ngeo.routing.NominatimInputComponent.component_ = { + controller: ngeo.NgeoNominatimInputController, + bindings: { + 'onSelect': '=?ngeoNominatimInputOnSelect', + 'inputValue': '=?ngeoNominatimInputValue', + 'placeholder': '@?ngeoNominatimInputPlaceholder' + }, + templateUrl: ngeoRoutingNominatimInputComponentTemplateUrl +}; + +ngeo.routing.NominatimInputComponent.module.component('ngeoNominatimInput', ngeo.routing.NominatimInputComponent.component_); diff --git a/src/routing/NominatimService.js b/src/routing/NominatimService.js new file mode 100644 index 000000000000..26b2ed555b09 --- /dev/null +++ b/src/routing/NominatimService.js @@ -0,0 +1,176 @@ +goog.provide('ngeo.routing.NominatimService'); + +goog.require('ngeo.misc.debounce'); + + +/** + * Service to provide access to Nominatim, which allows to search for + * OSM data by name and address. + * @param {angular.$http} $http Angular http service. + * @param {angular.$injector} $injector Main injector. + * @param {ngeo.Debounce} ngeoDebounce ngeo Debounce service. + * @constructor + * @struct + * @ngdoc service + * @ngInject + * @export + * @ngname ngeoNominatimService + * @see https://wiki.openstreetmap.org/wiki/Nominatim + */ +ngeo.routing.NominatimService = function($http, $injector, ngeoDebounce) { + + /** + * @type {angular.$http} + * @private + */ + this.$http_ = $http; + + /** + * @type {ngeo.Debounce} + * @private + */ + this.ngeoDebounce_ = ngeoDebounce; + + /** + * URL for Nominatim backend + * Defaults openstreetmap instance. + * @type {string} + * @private + */ + this.nominatimUrl_ = 'http://nominatim.openstreetmap.org/'; + + if ($injector.has('ngeoNominatimUrl')) { + this.nominatimUrl_ = $injector.get('ngeoNominatimUrl'); + + // the url is expected to end with a slash + if (this.nominatimUrl_.substr(-1) !== '/') { + this.nominatimUrl_ += '/'; + } + } + + /** + * @type {Object} + * @private + */ + this.searchDefaultParams_ = {}; + + if ($injector.has('ngeoNominatimSearchDefaultParams')) { + this.searchDefaultParams_ = $injector.get('ngeoNominatimSearchDefaultParams'); + } + + /** + * Delay (in milliseconds) to avoid calling the API too often. + * Only if there were no calls for that many milliseconds, + * the last call will be executed. + * @type {number} + * @private + */ + this.typeaheadDebounceDelay_ = 500; + + /** + * @export + * @type {function(string,function(Array.),(function(Array.)|undefined))} + */ + this.typeaheadSourceDebounced = + /** @type{function(string,function(Array.),(function(Array.)|undefined))} */ + (this.ngeoDebounce_(/** @type {function(?)} */ (this.typeaheadSource_.bind(this)), this.typeaheadDebounceDelay_, true)); +}; + +/** + * Search by name + * @param {string} query Search query + * @param {?Object} params Optional parameters + * @return {!angular.$http.HttpPromise} promise of the Nominatim API request + * @see https://wiki.openstreetmap.org/wiki/Nominatim#Search + * @export + */ +ngeo.routing.NominatimService.prototype.search = function(query, params) { + let url = `${this.nominatimUrl_}search?q=${query}`; + + params = params || {}; + params = Object.assign({}, this.searchDefaultParams_, params); + + // require JSON response + params['format'] = 'json'; + + if (params) { + url += '&'; + const options = []; + for (const option of Object.keys(params)) { + options.push(`${option}=${params[option]}`); + } + url += options.join('&'); + } + + return this.$http_.get(url); +}; + +/** + * Reverse Geocoding + * @param {ol.Coordinate} coordinate Search coordinate in LonLat projection + * @param {(Object|undefined)} params Optional parameters + * @return {!angular.$http.HttpPromise} promise of the Nominatim API request + * @see https://wiki.openstreetmap.org/wiki/Nominatim#Reverse_Geocoding + * @export + */ +ngeo.routing.NominatimService.prototype.reverse = function(coordinate, params) { + let url = `${this.nominatimUrl_}reverse`; + + params = Object.assign({}, params); + + // coordinate + params['lon'] = coordinate[0]; + params['lat'] = coordinate[1]; + + // require JSON response + params['format'] = 'json'; + + if (params) { + url += '?'; + const options = []; + for (const option of Object.keys(params)) { + options.push(`${option}=${params[option]}`); + } + url += options.join('&'); + } + + return this.$http_.get(url); +}; + +/** + * @param {string} query Search query + * @param {function(Array.)} syncResults Callback for synchronous execution, unused + * @param {function(Array.)} asyncResults Callback for asynchronous execution + * @private + */ +ngeo.routing.NominatimService.prototype.typeaheadSource_ = function(query, syncResults, asyncResults) { + const onSuccess_ = function(resp) { + /** + * Parses result response. + * @param {ngeox.NominatimSearchResponseResult} result Result + * @return {ngeox.NominatimSearchResult} Parsed result + */ + const parse = function(result) { + return /** @type{ngeox.NominatimSearchResult} */({ + coordinate: [result.lon, result.lat], + name: result.display_name + }); + }; + asyncResults(resp.data.map(parse)); + }; + + const onError_ = function(resp) { + asyncResults([]); + }; + + this.search(query, {}).then(onSuccess_, onError_); +}; + +/** + * @type {!angular.Module} + */ +ngeo.routing.NominatimService.module = angular.module('ngeoNominatimService', [ + ngeo.misc.debounce.name +]); + +ngeo.routing.NominatimService.module.service('ngeoNominatimService', ngeo.routing.NominatimService); diff --git a/src/routing/RoutingComponent.js b/src/routing/RoutingComponent.js new file mode 100644 index 000000000000..061a7eba517b --- /dev/null +++ b/src/routing/RoutingComponent.js @@ -0,0 +1,403 @@ +goog.provide('ngeo.routing.RoutingComponent'); + +goog.require('ngeo.misc.debounce'); +goog.require('ngeo.misc.filters'); +goog.require('ngeo.routing.NominatimService'); +goog.require('ngeo.routing.RoutingService'); +goog.require('ngeo.routing.RoutingFeatureComponent'); +goog.require('ol.format.GeoJSON'); +goog.require('ol.source.Vector'); +goog.require('ol.layer.Vector'); +goog.require('ol.style.Style'); +goog.require('ol.style.Fill'); +goog.require('ol.style.Stroke'); +goog.require('ol.proj'); +goog.require('ol.Feature'); +goog.require('ol.geom.LineString'); + +ngeo.routing.RoutingComponent.module = angular.module('ngeoRoutingFeatureComponent', [ + ngeo.misc.debounce.name, + ngeo.misc.filters.name, + ngeo.routing.NominatimService.module.name, + ngeo.routing.RoutingService.module.name, + ngeo.routing.RoutingFeatureComponent.module.name +]); + + +// webpack: exports.run(/* @ngInject */ ($templateCache) => { +// webpack: $templateCache.put('ngeo/routing/routingfeature', require('./routingfeature.html')); +// webpack: }); + + +ngeo.routing.RoutingComponent.module.value('ngeoRoutingTemplateUrl', + /** + * @param {!angular.Attributes} $attrs Attributes. + * @return {string} Template URL. + */ + ($attrs) => { + const templateUrl = $attrs['ngeoRoutingTemplateUrl']; + return templateUrl !== undefined ? templateUrl : + `${ngeo.baseModuleTemplateUrl}/routing/routing.html`; // nowebpack + // webpack: 'ngeo/routing/routing'; + } +); + + +/** + * @param {!angular.Attributes} $attrs Attributes. + * @param {!function(!angular.Attributes): string} ngeoRoutingTemplateUrl Template function. + * @return {string} Template URL. + * @ngInject + */ +function ngeoRoutingTemplateUrl($attrs, ngeoRoutingTemplateUrl) { + return ngeoRoutingTemplateUrl($attrs); +} + + +/** + * The controller for the routing directive. + * @param {angular.$injector} $injector Main injector. + * @param {!angular.Scope} $scope Scope. + * @param {!ngeo.RoutingService} ngeoRoutingService service for OSRM routing + * @param {!ngeo.NominatimService} ngeoNominatimService service for Nominatim + * @param {!angular.$q} $q Angular q service + * @param {ngeo.Debounce} ngeoDebounce ngeo Debounce service. + * @constructor + * @private + * @ngInject + * @ngdoc controller + * @ngname NgeoRoutingController + */ +ngeo.routing.RoutingComponent.Controller = function($injector, $scope, ngeoRoutingService, ngeoNominatimService, $q, ngeoDebounce) { + + /** + * @type {angular.Scope} + * @private + */ + this.$scope_ = $scope; + + /** + * @type {ngeo.RoutingService} + * @private + */ + this.ngeoRoutingService_ = ngeoRoutingService; + + /** + * @type {ngeo.NominatimService} + * @private + */ + this.ngeoNominatimService_ = ngeoNominatimService; + + /** + * @type {ngeox.RoutingOptions} + * @private + */ + this.routingOptions_ = $injector.has('ngeoRoutingOptions') ? $injector.get('ngeoRoutingOptions') : {}; + + /** + * Available routing profiles. + * Example: [ + * { + * label: 'Car', // used as label in the UI + * profile: 'routed-car' // used as part of the query + * } + * ] + * @type {Array} + * @export + */ + this.routingProfiles = this.routingOptions_.profiles || []; + + /** + * @type {?ngeox.RoutingProfile} + * @export + */ + this.selectedRoutingProfile = this.routingProfiles.length > 0 ? this.routingProfiles[0] : null; + + $scope.$watch( + () => this.selectedRoutingProfile, + this.calculateRoute.bind(this) + ); + + /** + * @type {angular.$q} + * @private + */ + this.$q_ = $q; + + /** + * @type {ol.Map} + * @export + */ + this.map; + + /** + * @type {string} + * @export + */ + this.errorMessage = ''; + + /** + * @type {ol.Feature} + * @export + */ + this.startFeature_ = null; + + /** + * @type {ol.Feature} + * @export + */ + this.targetFeature_ = null; + + /** + * @type {Array.} + * @export + */ + this.viaArray = []; + + /** + * @type {Object} + * @export + */ + this.colors = { + 'start.fill': '#6BE62E', + 'start.stroke': '#4CB01E', + 'destination.fill': '#FF3E13', + 'destination.stroke': '#CD3412', + 'via.fill': '#767676', + 'via.stroke': '#000000' + }; + + /** + * @type {ol.source.Vector} + * @private + */ + this.routeSource_ = new ol.source.Vector({ + features: [] + }); + + /** + * @type {ol.layer.Vector} + * @private + */ + this.routeLayer_ = new ol.layer.Vector({ + source: this.routeSource_, + style: new ol.style.Style({ + fill: new ol.style.Fill({ + color: 'rgba(16, 112, 29, 0.6)' + }), + stroke: new ol.style.Stroke({ + color: 'rgba(16, 112, 29, 0.6)', + width: 5 + }) + }) + }); + + /** + * Distance of route in meters + * @type {number} + * @export + */ + this.routeDistance = 0; + + /** + * Duration of route in minutes. + * @type {?number} + * @export + */ + this.routeDuration = null; + + /** + * @type {RegExp} + * @private + */ + this.regexIsFormattedCoord = /\d+\.\d+\/\d+\.\d+/; + + /** + * @type {ol.interaction.Draw} + * @private + */ + this.draw_ = null; + + const debounceDelay = 200; // in milliseconds + + /** + * Debounced because in some cases (reverse route) multiple changes are done + * at once and spam this function. + * @export + * @type {function()} + */ + this.handleChange = /** @type {function()} */ + (ngeoDebounce( + /** @type {function(?)} */ (this.calculateRoute.bind(this)), + debounceDelay, + true)); +}; + +/** + * Init the controller + */ +ngeo.routing.RoutingComponent.Controller.prototype.$onInit = function() { + this.map.addLayer(this.routeLayer_); +}; + +/** + * Clears start, end and vias. Removes features from map. + * @export + */ +ngeo.routing.RoutingComponent.Controller.prototype.clearRoute = function() { + this.startFeature_ = null; + this.targetFeature_ = null; + this.viaArray = []; + this.routeDistance = 0; + this.routeDuration = null; + this.routeSource_.clear(); + this.errorMessage = ''; +}; + +/** + * Converts feature point into LonLat coordinate. + * @param {ol.Feature} point Feature point to convert + * @return {ol.Coordinate} LonLat coordinate + * @private + */ +ngeo.routing.RoutingComponent.Controller.prototype.getLonLatFromPoint_ = function(point) { + const geometry = /** @type {ol.geom.Point} */ (point.getGeometry()); + const coords = geometry.getCoordinates(); + const projection = this.map.getView().getProjection(); + return ol.proj.toLonLat(coords, projection); +}; + +/** + * Flip start and target and re-calculate route. + * @export + */ +ngeo.routing.RoutingComponent.Controller.prototype.reverseRoute = function() { + // swap start and target + const tmpFeature = this.startFeature_; + this.startFeature_ = this.targetFeature_; + this.targetFeature_ = tmpFeature; + + // reverse vias + this.viaArray = this.viaArray.reverse(); + + // recalculation is done by the debounced handleChange +}; + +/** + * @param {Object} route Routes of OSRM response + * @returns {Array} parsed route features + * @private + */ +ngeo.routing.RoutingComponent.Controller.prototype.parseRoute_ = function(route) { + let parsedRoutes = []; + const format = new ol.format.GeoJSON(); + const formatConfig = { + dataProjection: 'EPSG:4326', + featureProjection: this.map.getView().getProjection() + }; + // if there are is useful "legs" data, parse this + if (route.legs) { + parsedRoutes = route.legs.map(leg => leg.steps.map(step => new ol.Feature({geometry: format.readGeometry(step.geometry, formatConfig)}))); + // flatten + parsedRoutes = [].concat(...parsedRoutes); + } else if (route.geometry) { + // otherwise parse (overview) geometry + parsedRoutes.push(new ol.Feature({geometry: format.readGeometry(route.geometry, formatConfig)})); + } + return parsedRoutes; +}; + +/** + * @export + */ +ngeo.routing.RoutingComponent.Controller.prototype.calculateRoute = function() { + if (this.startFeature_ && this.targetFeature_) { + // remove rendered routes + this.routeSource_.clear(); + + const coordFrom = this.getLonLatFromPoint_(this.startFeature_); + const coordTo = this.getLonLatFromPoint_(this.targetFeature_); + const vias = this.viaArray.filter(via => via.feature !== null).map(via => this.getLonLatFromPoint_(via.feature)); + const route = [coordFrom].concat(vias, [coordTo]); + + const onSuccess_ = (function(resp) { + const features = this.parseRoute_(resp.data.routes[0]); + if (features.length === 0) { + console.log('No route or not supported format.'); + return; + } + this.routeSource_.addFeatures(features); + + // recenter map on route + this.map.getView().fit(this.routeSource_.getExtent()); + + this.routeDistance = parseInt(resp.data.routes[0].distance, 10); + this.routeDuration = resp.data.routes[0].duration; + + // get first and last coordinate of route + const startRoute = /** @type{ol.geom.LineString} */(features[0].getGeometry()).getCoordinateAt(0); + const endRoute = /** @type{ol.geom.LineString} */(features[features.length - 1].getGeometry()).getCoordinateAt(1); + + // build geometries to connect route to start and end point of query + const startToRoute = [/** @type {ol.geom.Point} */(this.startFeature_.getGeometry()).getCoordinates(), startRoute]; + const routeToEnd = [endRoute, /** @type {ol.geom.Point} */(this.targetFeature_.getGeometry()).getCoordinates()]; + const routeConnections = [ + new ol.Feature(new ol.geom.LineString(startToRoute)), + new ol.Feature(new ol.geom.LineString(routeToEnd)) + ]; + + // add them to the source + this.routeSource_.addFeatures(routeConnections); + }).bind(this); + + const onError_ = (function(resp) { + this.errorMessage = 'Error: routing server not responding.'; + console.log(resp); + }).bind(this); + + const options = {}; + options['steps'] = true; + options['overview'] = false; + options['geometries'] = 'geojson'; + + const config = {}; + config['options'] = options; + + if (this.selectedRoutingProfile) { + config['instance'] = this.selectedRoutingProfile['profile']; + } + + this.$q_.when(this.ngeoRoutingService_.getRoute(route, config)) + .then(onSuccess_.bind(this), onError_.bind(this)); + } +}; + +/** + * @export + */ +ngeo.routing.RoutingComponent.Controller.prototype.addVia = function() { + this.viaArray.push(/** @type{ngeox.RoutingVia} */({ + feature: null, + onSelect: null + })); +}; + +/** + * @param {number} index Array index. + * @export + */ +ngeo.routing.RoutingComponent.Controller.prototype.deleteVia = function(index) { + if (this.viaArray.length > index) { + this.viaArray.splice(index, 1); + this.calculateRoute(); + } +}; + + +ngeo.routing.RoutingComponent.module.component('ngeoRouting', { + controller: ngeo.routing.RoutingComponent.Controller, + bindings: { + 'map': ' { +// webpack: $templateCache.put('ngeo/routing/routingfeature', require('./routingfeature.html')); +// webpack: }); + + +ngeo.routing.RoutingFeatureComponent.module.value('ngeoRoutingFeatureTemplateUrl', + /** + * @param {!angular.Attributes} $attrs Attributes. + * @return {string} Template URL. + */ + ($attrs) => { + const templateUrl = $attrs['ngeoRoutingFeatureTemplateUrl']; + return templateUrl !== undefined ? templateUrl : + `${ngeo.baseModuleTemplateUrl}/routing/routingfeature.html`; // nowebpack + // webpack: 'ngeo/routing/routingfeature'; + } +); + +/** + * @param {!angular.Attributes} $attrs Attributes. + * @param {!function(!angular.Attributes): string} ngeoRoutingFeatureTemplateUrl Template function. + * @return {string} Template URL. + * @ngInject + */ +function ngeoRoutingFeatureTemplateUrl($attrs, ngeoRoutingFeatureTemplateUrl) { + return ngeoRoutingFeatureTemplateUrl($attrs); +} + + +/** + * @param {!angular.Scope} $scope Angular scope. + * @param {angular.$timeout} $timeout Angular timeout service. + * @param {!angular.$q} $q Angular q service + * @param {!ngeo.NominatimService} ngeoNominatimService service for Nominatim + * @constructor + * @private + * @ngInject + * @ngdoc controller + * @ngname NgeoRoutingFeatureController + */ +ngeo.routing.RoutingFeatureComponent.Controller = function($scope, $timeout, $q, ngeoNominatimService) { + + /** + * @type {!angular.Scope} + * @private + */ + this.scope_ = $scope; + + /** + * @type {angular.$timeout} + * @private + */ + this.timeout_ = $timeout; + + /** + * @type {angular.$q} + * @private + */ + this.$q_ = $q; + + /** + * @type {ngeo.NominatimService} + * @private + */ + this.ngeoNominatimService_ = ngeoNominatimService; + + /** + * @type {ol.Map} + * @private + */ + this.map; + + /** + * @type {ol.Feature} + * @export + */ + this.feature; + + /** + * @type {string} + * @export + */ + this.featureLabel = ''; + + /** + * @type {string} + * @export + */ + this.fillColor; + + /** + * @type {string} + * @export + */ + this.strokeColor; + + /** + * @type {function(ol.Feature)} + * @export + */ + this.onChange; + + /** + * @type {ol.Collection} + * @private + */ + this.vectorFeatures_ = new ol.Collection(); + + /** + * @type {ol.source.Vector} + * @private + */ + this.vectorSource_ = new ol.source.Vector({ + features: this.vectorFeatures_ + }); + + /** + * @type {ol.layer.Vector} + * @private + */ + this.vectorLayer_ = new ol.layer.Vector({ + source: this.vectorSource_, + style: (function(feature, resolution) { + return [new ol.style.Style({ + text: new ol.style.Text({ + fill: new ol.style.Fill({ + color: this.fillColor || '#000000' + }), + font: 'normal 30px FontAwesome', + offsetY: -15, + stroke: new ol.style.Stroke({ + width: 3, + color: this.strokeColor || '#000000' + }), + text: '\uf041' + }) + })]; + }).bind(this) + }); + + /** + * Interaction for moving start and end. + * @type {ol.interaction.Modify} + * @private + */ + this.modifyFeature_ = new ol.interaction.Modify({ + features: this.vectorFeatures_ + }); + + /** + * @type {ol.interaction.Draw} + * @private + */ + this.draw_ = null; + + /** + * @param {ngeox.NominatimSearchResult} selected Selected result. + * @export + */ + this.onSelect = this.onSelect_.bind(this); + + /** + * @type {string} + * @export + */ + this.errorMessage = ''; +}; + +ngeo.routing.RoutingFeatureComponent.Controller.prototype.$onInit = function() { + this.map.addLayer(this.vectorLayer_); + + // setup modify interaction + this.modifyFeature_.setActive(true); + this.map.addInteraction(this.modifyFeature_); + + this.modifyFeature_.on('modifyend', (event) => { + const feature = event.features.getArray()[0]; + this.vectorSource_.clear(); + this.snapFeature_(feature); + }); + + this.scope_.$watch( + () => this.feature, + (newVal, oldVal) => { + if (newVal) { + this.onFeatureChange_(); + } + if (newVal === null) { + this.vectorSource_.clear(); + this.featureLabel = ''; + } + } + ); +}; + +/** + * Cleanup, mostly relevant for vias. + */ +ngeo.routing.RoutingFeatureComponent.Controller.prototype.$onDestroy = function() { + this.map.removeLayer(this.vectorLayer_); + this.modifyFeature_.setActive(false); + this.map.removeInteraction(this.modifyFeature_); +}; + +/** + * @export + */ +ngeo.routing.RoutingFeatureComponent.Controller.prototype.set = function() { + if (this.draw_) { + this.map.removeInteraction(this.draw_); + } + + this.draw_ = new ol.interaction.Draw({ + features: this.vectorFeatures_, + type: /** @type {ol.geom.GeometryType} */ ('Point') + }); + + this.draw_.on('drawstart', () => { + if (this.feature) { + this.vectorSource_.removeFeature(this.feature); + } + }); + + this.draw_.on('drawend', (event) => { + if (this.draw_) { + this.map.removeInteraction(this.draw_); + } + this.snapFeature_(event.feature); + this.modifyFeature_.setActive(true); + }); + + this.modifyFeature_.setActive(false); + this.map.addInteraction(this.draw_); +}; + +/** + * @param {ol.Coordinate} coordinate LonLat coordinate. + * @param {string} label Feature name/label. + * @private + */ +ngeo.routing.RoutingFeatureComponent.Controller.prototype.setFeature_ = function(coordinate, label) { + const transformedCoordinate = ol.proj.fromLonLat(coordinate, this.map.getView().getProjection()); + if (label === '') { + label = transformedCoordinate.join('/'); + } + this.feature = new ol.Feature({ + geometry: new ol.geom.Point(transformedCoordinate), + name: label + }); +}; + +ngeo.routing.RoutingFeatureComponent.Controller.prototype.onFeatureChange_ = function() { + // update label + this.featureLabel = /** @type{string} */(this.feature.get('name') || ''); + + //update vector source + this.vectorSource_.clear(); + this.vectorSource_.addFeature(this.feature); + + // notify others + if (this.onChange) { + this.timeout_(() => { + this.onChange(this.feature); + }); + } +}; + +/** + * @param {ngeox.NominatimSearchResult} selected Selected result. + * @private + */ +ngeo.routing.RoutingFeatureComponent.Controller.prototype.onSelect_ = function(selected) { + const coordinate = selected.coordinate.map(parseFloat); + const label = selected.label; + this.setFeature_(coordinate, label); + const newCoordinates = /** @type{ol.geom.Point} */(this.feature.getGeometry()).getCoordinates(); + this.map.getView().setCenter(newCoordinates); +}; + +/** + * Snaps a feature to the street network using the getNearest + * function of the routing service. Replaces the feature. + * @param {ol.Feature} feature Feature to snap + * @private + */ +ngeo.routing.RoutingFeatureComponent.Controller.prototype.snapFeature_ = function(feature) { + const coord = this.getLonLatFromPoint_(feature); + const config = {}; + + const onSuccess = (function(resp) { + const lon = parseFloat(resp['data']['lon']); + const lat = parseFloat(resp['data']['lat']); + const coordinate = [lon, lat]; + const label = resp['data']['display_name']; + this.setFeature_(coordinate, label); + }).bind(this); + + const onError = (function(resp) { + this.errorMessage = 'Error: nominatim server not responding.'; + console.log(resp); + }).bind(this); + + this.$q_.when(this.ngeoNominatimService_.reverse(coord, config)) + .then(onSuccess.bind(this), onError.bind(this)); +}; + +/** + * Converts feature point into LonLat coordinate. + * @param {ol.Feature} point Feature point to convert + * @return {ol.Coordinate} LonLat coordinate + * @private + */ +ngeo.routing.RoutingFeatureComponent.Controller.prototype.getLonLatFromPoint_ = function(point) { + const geometry = /** @type {ol.geom.Point} */ (point.getGeometry()); + const coords = geometry.getCoordinates(); + const projection = this.map.getView().getProjection(); + return ol.proj.toLonLat(coords, projection); +}; + + +/** + * Provides a text input and draw interaction to allow a user to create and modify a ol.Feature (point geometry). + * + * The text input is provided by {@link ngeo.nominatimInputComponent} and includes Nominatim search. + * + * Example: + * + * + * + * Is used in in the partial of {@link ngeo.routingComponent}. + * + * See the [../examples/routing.html](../examples/routing.html) example for a usage sample. + * + * @htmlAttribute {ol.Map} ngeo-routing-feature-map The map. + * @htmlAttribute {ol.Feature} ngeo-routing-feature-feature The feature. + * @htmlAttribute {string} ngeo-routing-feature-fill-color The marker fill color. + * @htmlAttribute {string} ngeo-routing-feature-stroke-color The marker stroke color. + * @htmlAttribute {function(ol.Feature)} ngeo-routing-feature-on-change Event fired when feature changes. + * @ngdoc directive + * @ngname ngeoRoutingFeature + */ +ngeo.routing.RoutingFeatureComponent.component_ = { + controller: ngeo.routing.RoutingFeatureComponent.Controller, + bindings: { + 'map': '} coordinates coordinates of the route (at least two!) + * @param {?Object} config optional configuration + * @return {!angular.$http.HttpPromise} promise of the OSRM API request + */ +ngeo.routing.RoutingService.prototype.getRoute = function(coordinates, config) { + + config = config || {}; + + // Service + // see: https://github.com/Project-OSRM/osrm-backend/blob/master/docs/http.md#requests + if (!config['service']) { + config['service'] = 'route'; // default to route + } + + // Mode of transportation, + // can be: car, bike, foot + // see: https://github.com/Project-OSRM/osrm-backend/blob/master/docs/http.md#requests + // + // As of version 5.8.0, OSRM (server) does not support multiple profiles simultaneously. + // This means the value actually does not matter. + if (!config['profile']) { + config['profile'] = 'car'; // default to car + } + + // build request URL + let url = this.ngeoOsrmBackendUrl_; + + // Common workaround to provide multiple profiles (since it is not supported yet) + // Every profile runs on its own instance. + if (config['instance']) { + url += `${config['instance']}/`; + } + + url += `${config['service']}/${this.protocolVersion_}/${config['profile']}/`; + + // [ [a,b] , [c,d] ] -> 'a,b;c,d' + const coordinateString = coordinates.map(c => c.join(',')).join(';'); + + url += coordinateString; + + // look for route service options + // see: https://github.com/Project-OSRM/osrm-backend/blob/master/docs/http.md#route-service + if (config.options) { + url += '?'; + const options = []; + for (const option of Object.keys(config.options)) { + options.push(`${option}=${config.options[option]}`); + } + url += options.join('&'); + } + + return this.$http_.get(url); +}; + +/** + * Snaps a coordinate to the street network and returns the nearest match + * @param {ol.Coordinate} coordinate coordinate to query + * @param {?Object} config optional configuration + * @return {!angular.$http.HttpPromise} promise of the OSRM API request + * @see https://github.com/Project-OSRM/osrm-backend/blob/master/docs/http.md#nearest-service + */ +ngeo.routing.RoutingService.prototype.getNearest = function(coordinate, config) { + config = config || {}; + + // service is always nearest + config['service'] = 'nearest'; + + // Mode of transportation + // If used in combination with a getRoute request, choose the same profile. + if (!config['profile']) { + config['profile'] = 'car'; // default to car + } + + // build request URL + let url = this.ngeoOsrmBackendUrl_; + + // Common workaround to provide multiple profiles (since it is not supported yet) + // Every profile runs on its own instance. + if (config['instance']) { + url += `${config['instance']}/`; + } + + url += `${config['service']}/${this.protocolVersion_}/${config['profile']}/`; + + // [a,b] -> 'a,b' + const coordinateString = coordinate.join(','); + url += coordinateString; + + // look for nearest service options + if (config.options) { + url += '?'; + const options = []; + for (const option of Object.keys(config.options)) { + options.push(`${option}=${config.options[option]}`); + } + url += options.join('&'); + } + + return this.$http_.get(url); +}; + + +/** + * @type {!angular.Module} + */ +ngeo.routing.RoutingService.module = angular.module('ngeoRoutingService', [ +]); + +ngeo.routing.RoutingService.module.service('ngeoRoutingService', ngeo.routing.RoutingService); diff --git a/src/routing/module.js b/src/routing/module.js new file mode 100644 index 000000000000..1de54b30f246 --- /dev/null +++ b/src/routing/module.js @@ -0,0 +1,10 @@ +goog.provide('ngeo.routing.module'); + +goog.require('ngeo.routing.RoutingComponent'); + +/** + * @type {angular.Module} + */ +ngeo.routing.module = angular.module('ngeoRoutingModule', [ + ngeo.routing.RoutingComponent.module.name +]); diff --git a/src/routing/nominatiminput.html b/src/routing/nominatiminput.html new file mode 100644 index 000000000000..e1bc3491180f --- /dev/null +++ b/src/routing/nominatiminput.html @@ -0,0 +1,9 @@ +
+ +
diff --git a/src/routing/routing.html b/src/routing/routing.html new file mode 100644 index 000000000000..2fa13b199ad1 --- /dev/null +++ b/src/routing/routing.html @@ -0,0 +1,96 @@ +
+
+ + +
+ +
+
+
+ + +
+ +
+
+ +
+ + +
+ +
+ + + +
+ +
+ +
+
+ +
+ +
+
+
+ +
+ {{$ctrl.errorMessage}} +
+ +
+ +
+
+
+ Route statistics +
+
+
+
+ Duration +
+
+ {{$ctrl.routeDuration | ngeoDuration}} +
+
+ +
+
+ Distance +
+
+ {{$ctrl.routeDistance | ngeoUnitPrefix:'m'}} +
+
+
+
diff --git a/src/routing/routingfeature.html b/src/routing/routingfeature.html new file mode 100644 index 000000000000..a39719106ebd --- /dev/null +++ b/src/routing/routingfeature.html @@ -0,0 +1,12 @@ +
+
+ + +
+ +
+
+
diff --git a/test/spec/services/routing.spec.js b/test/spec/services/routing.spec.js new file mode 100644 index 000000000000..28d170e1ae4f --- /dev/null +++ b/test/spec/services/routing.spec.js @@ -0,0 +1,75 @@ +goog.require('ngeo.RoutingService'); + +describe('ngeo.RoutingService', () => { + let $httpBackend; + + beforeEach(() => { + module('ngeo', ($provide) => { + $provide.value('ngeoRoutingOptions', { + 'backendUrl': 'http://example.com/' + }); + }); + }); + + afterEach(() => { + $httpBackend.verifyNoOutstandingExpectation(); + $httpBackend.verifyNoOutstandingRequest(); + }); + + it('Should get a route', () => { + let ngeoOsrmBackendUrl; + let ngeoRoutingService; + const successResponse = { + route: true + }; + + inject(($injector) => { + $httpBackend = $injector.get('$httpBackend'); + ngeoRoutingService = $injector.get('ngeoRoutingService'); + ngeoOsrmBackendUrl = $injector.get('ngeoRoutingOptions').backendUrl; + }); + + const coordinates = /** @type {Array.} */ [[6.455, 46.648], [6.532, 6.532]]; + const coordString = `${coordinates[0][0]},${coordinates[0][1]};${coordinates[1][0]},${coordinates[1][1]}`; + const config = { + profile: 'jetpack', + options: { + option: 'value' + } + }; + const requestUrl = `${ngeoOsrmBackendUrl}route/v1/${config.profile}/${coordString}?option=${config.options.option}`; + + $httpBackend.when('GET', requestUrl).respond(successResponse); + $httpBackend.expectGET(requestUrl); + ngeoRoutingService.getRoute(coordinates, config); + $httpBackend.flush(); + }); + + it('Should get a nearest object', () => { + let ngeoOsrmBackendUrl; + let ngeoRoutingService; + const successResponse = { + nearest: 'Streetname' + }; + + inject(($injector) => { + $httpBackend = $injector.get('$httpBackend'); + ngeoRoutingService = $injector.get('ngeoRoutingService'); + ngeoOsrmBackendUrl = $injector.get('ngeoRoutingOptions').backendUrl; + }); + + const coordinate = /** @type {ol.Coordinate} */ [6.455, 46.648]; + const coordString = `${coordinate[0]},${coordinate[1]}`; + const config = { + options: { + option: 'value' + } + }; + const requestUrl = `${ngeoOsrmBackendUrl}nearest/v1/car/${coordString}?option=${config.options.option}`; + + $httpBackend.when('GET', requestUrl).respond(successResponse); + $httpBackend.expectGET(requestUrl); + ngeoRoutingService.getNearest(coordinate, config); + $httpBackend.flush(); + }); +});