import { CartographicGeocoderService, computeFlyToLocationForRectangle, defaultValue, defined, DeveloperError, destroyObject, Event, GeocoderService, GeocodeType, getElement, IonGeocoderService, Math as CesiumMath, Matrix4, Rectangle, sampleTerrainMostDetailed, } from "@cesium/engine"; import knockout from "../ThirdParty/knockout.js"; import createCommand from "../createCommand.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._wasGeocodeCancelled = false; this._previousCredits = []; 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 { return 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, }); }); } async function attemptGeocode(geocoderService, query, geocodeType) { try { const result = await geocoderService.geocode(query, geocodeType); return { state: "fulfilled", value: result, credits: geocoderService.credit, }; } catch (error) { return { state: "rejected", reason: error }; } } async function geocode(viewModel, geocoderServices, geocodeType) { const query = viewModel._searchText; if (hasOnlyWhitespace(query)) { viewModel.showSuggestions(); return; } viewModel._isSearchInProgress = true; viewModel._wasGeocodeCancelled = false; let i; let result; for (i = 0; i < geocoderServices.length; i++) { if (viewModel._wasGeocodeCancelled) { return; } result = await attemptGeocode(geocoderServices[i], query, geocodeType); if ( defined(result) && result.state === "fulfilled" && result.value.length > 0 ) { break; } } if (viewModel._wasGeocodeCancelled) { return; } viewModel._isSearchInProgress = false; clearCredits(viewModel); const geocoderResults = result.value; if ( result.state === "fulfilled" && defined(geocoderResults) && geocoderResults.length > 0 ) { viewModel._searchText = geocoderResults[0].displayName; viewModel.destinationFound(viewModel, geocoderResults[0].destination); const credits = updateCredits( viewModel, GeocoderService.getCreditsFromResult(geocoderResults[0]) ); // If the result does not contain any credits, default to the service credit. if (!defined(credits)) { updateCredit(viewModel, geocoderServices[i].credit); } return; } viewModel._searchText = `${query} (not found)`; } function updateCredit(viewModel, credit) { if ( defined(credit) && !viewModel._scene.isDestroyed() && !viewModel._scene.frameState.creditDisplay.isDestroyed() ) { viewModel._scene.frameState.creditDisplay.addStaticCredit(credit); viewModel._previousCredits.push(credit); } } function updateCredits(viewModel, credits) { if (defined(credits)) { credits.forEach((credit) => updateCredit(viewModel, credit)); } return credits; } function clearCredits(viewModel) { if ( !viewModel._scene.isDestroyed() && !viewModel._scene.frameState.creditDisplay.isDestroyed() ) { viewModel._previousCredits.forEach((credit) => { viewModel._scene.frameState.creditDisplay.removeStaticCredit(credit); }); } viewModel._previousCredits.length = 0; } 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) { if (viewModel._isSearchInProgress) { viewModel._isSearchInProgress = false; viewModel._wasGeocodeCancelled = true; } } function hasOnlyWhitespace(string) { return /^\s*$/.test(string); } function clearSuggestions(viewModel) { knockout.getObservable(viewModel, "_suggestions").removeAll(); } async function updateSearchSuggestions(viewModel) { if (!viewModel.autoComplete) { return; } const query = viewModel._searchText; clearSuggestions(viewModel); clearCredits(viewModel); if (hasOnlyWhitespace(query)) { return; } for (const service of viewModel._geocoderServices) { const newResults = await service.geocode(query, GeocodeType.AUTOCOMPLETE); viewModel._suggestions = viewModel._suggestions.concat(newResults); if (newResults.length > 0) { let useDefaultCredit = true; newResults.forEach((result) => { const credits = GeocoderService.getCreditsFromResult(result); useDefaultCredit = useDefaultCredit && !defined(credits); updateCredits(viewModel, credits); }); // Use the service credit if there were no attributions in the results if (useDefaultCredit) { updateCredit(viewModel, service.credit); } } if (viewModel._suggestions.length >= 5) { return; } } } /** * 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; /** * @returns {boolean} true if the object has been destroyed, false otherwise. */ GeocoderViewModel.prototype.isDestroyed = function () { return false; }; /** * Destroys the widget. Should be called if permanently * removing the widget from layout. */ GeocoderViewModel.prototype.destroy = function () { clearCredits(this); return destroyObject(this); }; export default GeocoderViewModel;