import CartographicGeocoderService from "../../Core/CartographicGeocoderService.js"; import defaultValue from "../../Core/defaultValue.js"; import defined from "../../Core/defined.js"; import DeveloperError from "../../Core/DeveloperError.js"; import Event from "../../Core/Event.js"; import GeocodeType from "../../Core/GeocodeType.js"; import IonGeocoderService from "../../Core/IonGeocoderService.js"; import CesiumMath from "../../Core/Math.js"; import Matrix4 from "../../Core/Matrix4.js"; import Rectangle from "../../Core/Rectangle.js"; import sampleTerrainMostDetailed from "../../Core/sampleTerrainMostDetailed.js"; import computeFlyToLocationForRectangle from "../../Scene/computeFlyToLocationForRectangle.js"; import knockout from "../../ThirdParty/knockout.js"; import createCommand from "../createCommand.js"; import getElement from "../getElement.js"; // The height we use if geocoding to a specific point instead of an rectangle. const DEFAULT_HEIGHT = 1000; /** * 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. * @param {Geocoder.DestinationFoundFunction} [options.destinationFound=GeocoderViewModel.flyToDestination] A callback function that is called after a successful geocode. If not supplied, the default behavior is to fly the camera to the result destination. */ 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 IonGeocoderService({ 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._handleArrowDown = handleArrowDown; this._handleArrowUp = handleArrowUp; const that = this; this._suggestionsVisible = knockout.pureComputed(function () { const suggestions = knockout.getObservable(that, "_suggestions"); const suggestionsNotEmpty = suggestions().length > 0; const showSuggestions = knockout.getObservable(that, "_showSuggestions")(); return suggestionsNotEmpty && showSuggestions; }); this._searchCommand = createCommand(function (geocodeType) { geocodeType = defaultValue(geocodeType, GeocodeType.SEARCH); 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, geocodeType); } }); this.deselectSuggestion = function () { that._selectedSuggestion = undefined; }; this.handleKeyDown = function (data, event) { const downKey = event.key === "ArrowDown" || event.key === "Down" || event.keyCode === 40; const upKey = event.key === "ArrowUp" || event.key === "Up" || event.keyCode === 38; if (downKey || upKey) { event.preventDefault(); } return true; }; this.handleKeyUp = function (data, event) { const downKey = event.key === "ArrowDown" || event.key === "Down" || event.keyCode === 40; const upKey = event.key === "ArrowUp" || event.key === "Up" || event.keyCode === 38; const 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; const destination = data.destination; clearSuggestions(that); that.destinationFound(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); /** * Gets and sets the command called when a geocode destination is found * @type {Geocoder.DestinationFoundFunction} */ this.destinationFound = defaultValue( options.destinationFound, GeocoderViewModel.flyToDestination ); this._focusTextbox = false; knockout.track(this, [ "_searchText", "_isSearchInProgress", "keepExpanded", "_suggestions", "_selectedSuggestion", "_showSuggestions", "_focusTextbox", ]); const searchTextObservable = knockout.getObservable(this, "_searchText"); searchTextObservable.extend({ rateLimit: { timeout: 500 } }); this._suggestionSubscription = searchTextObservable.subscribe(function () { GeocoderViewModel._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; }, }); } Object.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; } const currentIndex = viewModel._suggestions.indexOf( viewModel._selectedSuggestion ); if (currentIndex === -1 || currentIndex === 0) { viewModel._selectedSuggestion = undefined; return; } const next = currentIndex - 1; viewModel._selectedSuggestion = viewModel._suggestions[next]; GeocoderViewModel._adjustSuggestionsScroll(viewModel, next); } function handleArrowDown(viewModel) { if (viewModel._suggestions.length === 0) { return; } const numberOfSuggestions = viewModel._suggestions.length; const currentIndex = viewModel._suggestions.indexOf( viewModel._selectedSuggestion ); const next = (currentIndex + 1) % numberOfSuggestions; viewModel._selectedSuggestion = viewModel._suggestions[next]; GeocoderViewModel._adjustSuggestionsScroll(viewModel, next); } function computeFlyToLocationForCartographic(cartographic, terrainProvider) { const availability = defined(terrainProvider) ? terrainProvider.availability : undefined; if (!defined(availability)) { cartographic.height += DEFAULT_HEIGHT; return Promise.resolve(cartographic); } return sampleTerrainMostDetailed(terrainProvider, [cartographic]).then( function (positionOnTerrain) { cartographic = positionOnTerrain[0]; cartographic.height += DEFAULT_HEIGHT; return cartographic; } ); } function flyToDestination(viewModel, destination) { const scene = viewModel._scene; const mapProjection = scene.mapProjection; const ellipsoid = mapProjection.ellipsoid; const camera = scene.camera; const terrainProvider = scene.terrainProvider; let finalDestination = destination; let promise; if (destination instanceof Rectangle) { // Some geocoders return a Rectangle of zero width/height, treat it like a point instead. if ( CesiumMath.equalsEpsilon( destination.south, destination.north, CesiumMath.EPSILON7 ) && CesiumMath.equalsEpsilon( destination.east, destination.west, CesiumMath.EPSILON7 ) ) { // destination is now a Cartographic destination = Rectangle.center(destination); } else { promise = computeFlyToLocationForRectangle(destination, scene); } } else { // destination is a Cartesian3 destination = ellipsoid.cartesianToCartographic(destination); } if (!defined(promise)) { promise = computeFlyToLocationForCartographic(destination, terrainProvider); } return promise .then(function (result) { finalDestination = ellipsoid.cartographicToCartesian(result); }) .finally(function () { // Whether terrain querying succeeded or not, fly to the destination. camera.flyTo({ destination: finalDestination, complete: function () { viewModel._complete.raiseEvent(); }, duration: viewModel._flightDuration, endTransform: Matrix4.IDENTITY, }); }); } function chainPromise(promise, geocoderService, query, geocodeType) { return promise.then(function (result) { if ( defined(result) && result.state === "fulfilled" && result.value.length > 0 ) { return result; } const nextPromise = geocoderService .geocode(query, geocodeType) .then(function (result) { return { state: "fulfilled", value: result }; }) .catch(function (err) { return { state: "rejected", reason: err }; }); return nextPromise; }); } function geocode(viewModel, geocoderServices, geocodeType) { const query = viewModel._searchText; if (hasOnlyWhitespace(query)) { viewModel.showSuggestions(); return; } viewModel._isSearchInProgress = true; let promise = Promise.resolve(); for (let i = 0; i < geocoderServices.length; i++) { promise = chainPromise(promise, geocoderServices[i], query, geocodeType); } viewModel._geocodePromise = promise; promise.then(function (result) { if (promise.cancel) { return; } viewModel._isSearchInProgress = false; const geocoderResults = result.value; if ( result.state === "fulfilled" && defined(geocoderResults) && geocoderResults.length > 0 ) { viewModel._searchText = geocoderResults[0].displayName; viewModel.destinationFound(viewModel, geocoderResults[0].destination); return; } viewModel._searchText = `${query} (not found)`; }); } function adjustSuggestionsScroll(viewModel, focusedItemIndex) { const container = getElement(viewModel._viewContainer); const searchResults = container.getElementsByClassName("search-results")[0]; const listItems = container.getElementsByTagName("li"); const element = listItems[focusedItemIndex]; if (focusedItemIndex === 0) { searchResults.scrollTop = 0; return; } const 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; } const query = viewModel._searchText; clearSuggestions(viewModel); if (hasOnlyWhitespace(query)) { return; } let promise = Promise.resolve([]); viewModel._geocoderServices.forEach(function (service) { promise = promise.then(function (results) { if (results.length >= 5) { return results; } return service .geocode(query, GeocodeType.AUTOCOMPLETE) .then(function (newResults) { results = results.concat(newResults); return results; }); }); }); return promise.then(function (results) { const suggestions = viewModel._suggestions; for (let i = 0; i < results.length; i++) { suggestions.push(results[i]); } }); } /** * A function to fly to the destination found by a successful geocode. * @type {Geocoder.DestinationFoundFunction} */ GeocoderViewModel.flyToDestination = flyToDestination; //exposed for testing GeocoderViewModel._updateSearchSuggestions = updateSearchSuggestions; GeocoderViewModel._adjustSuggestionsScroll = adjustSuggestionsScroll; export default GeocoderViewModel;