define([ '../../Core/BingMapsGeocoderService', '../../Core/CartographicGeocoderService', '../../Core/defaultValue', '../../Core/defined', '../../Core/defineProperties', '../../Core/DeveloperError', '../../Core/Event', '../../Core/Matrix4', '../../ThirdParty/knockout', '../../ThirdParty/when', '../createCommand', '../getElement' ], function( BingMapsGeocoderService, CartographicGeocoderService, defaultValue, defined, defineProperties, DeveloperError, Event, Matrix4, knockout, when, createCommand, getElement) { 'use strict'; /** * The view model for the {@link Geocoder} widget. * @alias GeocoderViewModel * @constructor * * @param {Object} options Object with the following properties: * @param {Scene} options.scene The Scene instance to use. * @param {GeocoderService[]} [options.geocoderServices] Geocoder services to use for geocoding queries. * If more than one are supplied, suggestions will be gathered for the geocoders that support it, * and if no suggestion is selected the result from the first geocoder service wil be used. * @param {Number} [options.flightDuration] The duration of the camera flight to an entered location, in seconds. */ function GeocoderViewModel(options) { //>>includeStart('debug', pragmas.debug); if (!defined(options) || !defined(options.scene)) { throw new DeveloperError('options.scene is required.'); } //>>includeEnd('debug'); if (defined(options.geocoderServices)) { this._geocoderServices = options.geocoderServices; } else { this._geocoderServices = [ new CartographicGeocoderService(), new BingMapsGeocoderService({scene: options.scene}) ]; } this._viewContainer = options.container; this._scene = options.scene; this._flightDuration = options.flightDuration; this._searchText = ''; this._isSearchInProgress = false; this._geocodePromise = undefined; this._complete = new Event(); this._suggestions = []; this._selectedSuggestion = undefined; this._showSuggestions = true; this._updateCamera = updateCamera; this._adjustSuggestionsScroll = adjustSuggestionsScroll; this._updateSearchSuggestions = updateSearchSuggestions; this._handleArrowDown = handleArrowDown; this._handleArrowUp = handleArrowUp; var that = this; this._suggestionsVisible = knockout.pureComputed(function () { var suggestions = knockout.getObservable(that, '_suggestions'); var suggestionsNotEmpty = suggestions().length > 0; var showSuggestions = knockout.getObservable(that, '_showSuggestions')(); return suggestionsNotEmpty && showSuggestions; }); this._searchCommand = createCommand(function() { that._focusTextbox = false; if (defined(that._selectedSuggestion)) { that.activateSuggestion(that._selectedSuggestion); return false; } that.hideSuggestions(); if (that.isSearchInProgress) { cancelGeocode(that); } else { geocode(that, that._geocoderServices); } }); this.deselectSuggestion = function () { that._selectedSuggestion = undefined; }; this.handleKeyDown = function(data, event) { var downKey = event.key === 'ArrowDown' || event.key === 'Down' || event.keyCode === 40; var upKey = event.key === 'ArrowUp' || event.key === 'Up' || event.keyCode === 38; if (downKey || upKey) { event.preventDefault(); } return true; }; this.handleKeyUp = function (data, event) { var downKey = event.key === 'ArrowDown' || event.key === 'Down' || event.keyCode === 40; var upKey = event.key === 'ArrowUp' || event.key === 'Up' || event.keyCode === 38; var enterKey = event.key === 'Enter' || event.keyCode === 13; if (upKey) { handleArrowUp(that); } else if (downKey) { handleArrowDown(that); } else if (enterKey) { that._searchCommand(); } return true; }; this.activateSuggestion = function (data) { that.hideSuggestions(); that._searchText = data.displayName; var destination = data.destination; clearSuggestions(that); updateCamera(that, destination); }; this.hideSuggestions = function () { that._showSuggestions = false; that._selectedSuggestion = undefined; }; this.showSuggestions = function () { that._showSuggestions = true; }; this.handleMouseover = function (data, event) { if (data !== that._selectedSuggestion) { that._selectedSuggestion = data; } }; /** * Gets or sets a value indicating if this instance should always show its text input field. * * @type {Boolean} * @default false */ this.keepExpanded = false; /** * True if the geocoder should query as the user types to autocomplete * @type {Boolean} * @default true */ this.autoComplete = defaultValue(options.autocomplete, true); this._focusTextbox = false; knockout.track(this, ['_searchText', '_isSearchInProgress', 'keepExpanded', '_suggestions', '_selectedSuggestion', '_showSuggestions', '_focusTextbox']); var searchTextObservable = knockout.getObservable(this, '_searchText'); searchTextObservable.extend({ rateLimit: { timeout: 500 } }); this._suggestionSubscription = searchTextObservable.subscribe(function() { updateSearchSuggestions(that); }); /** * Gets a value indicating whether a search is currently in progress. This property is observable. * * @type {Boolean} */ this.isSearchInProgress = undefined; knockout.defineProperty(this, 'isSearchInProgress', { get : function() { return this._isSearchInProgress; } }); /** * Gets or sets the text to search for. The text can be an address, or longitude, latitude, * and optional height, where longitude and latitude are in degrees and height is in meters. * * @type {String} */ this.searchText = undefined; knockout.defineProperty(this, 'searchText', { get : function() { if (this.isSearchInProgress) { return 'Searching...'; } return this._searchText; }, set : function(value) { //>>includeStart('debug', pragmas.debug); if (typeof value !== 'string') { throw new DeveloperError('value must be a valid string.'); } //>>includeEnd('debug'); this._searchText = value; } }); /** * Gets or sets the the duration of the camera flight in seconds. * A value of zero causes the camera to instantly switch to the geocoding location. * The duration will be computed based on the distance when undefined. * * @type {Number|undefined} * @default undefined */ this.flightDuration = undefined; knockout.defineProperty(this, 'flightDuration', { get : function() { return this._flightDuration; }, set : function(value) { //>>includeStart('debug', pragmas.debug); if (defined(value) && value < 0) { throw new DeveloperError('value must be positive.'); } //>>includeEnd('debug'); this._flightDuration = value; } }); } defineProperties(GeocoderViewModel.prototype, { /** * Gets the event triggered on flight completion. * @memberof GeocoderViewModel.prototype * * @type {Event} */ complete : { get : function() { return this._complete; } }, /** * Gets the scene to control. * @memberof GeocoderViewModel.prototype * * @type {Scene} */ scene : { get : function() { return this._scene; } }, /** * Gets the Command that is executed when the button is clicked. * @memberof GeocoderViewModel.prototype * * @type {Command} */ search : { get : function() { return this._searchCommand; } }, /** * Gets the currently selected geocoder search suggestion * @memberof GeocoderViewModel.prototype * * @type {Object} */ selectedSuggestion : { get : function() { return this._selectedSuggestion; } }, /** * Gets the list of geocoder search suggestions * @memberof GeocoderViewModel.prototype * * @type {Object[]} */ suggestions : { get : function() { return this._suggestions; } } }); /** * Destroys the widget. Should be called if permanently * removing the widget from layout. */ GeocoderViewModel.prototype.destroy = function() { this._suggestionSubscription.dispose(); }; function handleArrowUp(viewModel) { if (viewModel._suggestions.length === 0) { return; } var next; var currentIndex = viewModel._suggestions.indexOf(viewModel._selectedSuggestion); if (currentIndex === -1 || currentIndex === 0) { viewModel._selectedSuggestion = undefined; return; } next = currentIndex - 1; viewModel._selectedSuggestion = viewModel._suggestions[next]; adjustSuggestionsScroll(viewModel, next); } function handleArrowDown(viewModel) { if (viewModel._suggestions.length === 0) { return; } var numberOfSuggestions = viewModel._suggestions.length; var currentIndex = viewModel._suggestions.indexOf(viewModel._selectedSuggestion); var next = (currentIndex + 1) % numberOfSuggestions; viewModel._selectedSuggestion = viewModel._suggestions[next]; adjustSuggestionsScroll(viewModel, next); } function updateCamera(viewModel, destination) { viewModel._scene.camera.flyTo({ destination : destination, complete: function() { viewModel._complete.raiseEvent(); }, duration : viewModel._flightDuration, endTransform : Matrix4.IDENTITY }); } function chainPromise(promise, geocoderService, query) { return promise .then(function(result) { if (defined(result) && result.state === 'fulfilled' && result.value.length > 0){ return result; } var nextPromise = geocoderService.geocode(query) .then(function (result) { return {state: 'fulfilled', value: result}; }) .otherwise(function (err) { return {state: 'rejected', reason: err}; }); return nextPromise; }); } function geocode(viewModel, geocoderServices) { var query = viewModel._searchText; if (hasOnlyWhitespace(query)) { viewModel.showSuggestions(); return; } viewModel._isSearchInProgress = true; var promise = when.resolve(); for (var i = 0; i < geocoderServices.length; i++) { promise = chainPromise(promise, geocoderServices[i], query); } viewModel._geocodePromise = promise; promise .then(function (result) { if (promise.cancel) { return; } viewModel._isSearchInProgress = false; var geocoderResults = result.value; if (result.state === 'fulfilled' && defined(geocoderResults) && geocoderResults.length > 0) { viewModel._searchText = geocoderResults[0].displayName; updateCamera(viewModel, geocoderResults[0].destination); return; } viewModel._searchText = query + ' (not found)'; }); } function adjustSuggestionsScroll(viewModel, focusedItemIndex) { var container = getElement(viewModel._viewContainer); var searchResults = container.getElementsByClassName('search-results')[0]; var listItems = container.getElementsByTagName('li'); var element = listItems[focusedItemIndex]; if (focusedItemIndex === 0) { searchResults.scrollTop = 0; return; } var offsetTop = element.offsetTop; if (offsetTop + element.clientHeight > searchResults.clientHeight) { searchResults.scrollTop = offsetTop + element.clientHeight; } else if (offsetTop < searchResults.scrollTop) { searchResults.scrollTop = offsetTop; } } function cancelGeocode(viewModel) { viewModel._isSearchInProgress = false; if (defined(viewModel._geocodePromise)) { viewModel._geocodePromise.cancel = true; viewModel._geocodePromise = undefined; } } function hasOnlyWhitespace(string) { return /^\s*$/.test(string); } function clearSuggestions(viewModel) { knockout.getObservable(viewModel, '_suggestions').removeAll(); } function updateSearchSuggestions(viewModel) { if (!viewModel.autoComplete) { return; } var query = viewModel._searchText; clearSuggestions(viewModel); if (hasOnlyWhitespace(query)) { return; } var promise = when.resolve([]); viewModel._geocoderServices.forEach(function (service) { promise = promise.then(function(results) { if (results.length >= 5) { return results; } return service.geocode(query) .then(function(newResults) { results = results.concat(newResults); return results; }); }); }); promise .then(function (results) { var suggestions = viewModel._suggestions; for (var i = 0; i < results.length; i++) { suggestions.push(results[i]); } }); } return GeocoderViewModel; });