You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

558 lines
16 KiB
JavaScript

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 when from "../../ThirdParty/when.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.
var 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;
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 (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) {
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);
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",
]);
var 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;
}
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];
GeocoderViewModel._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];
GeocoderViewModel._adjustSuggestionsScroll(viewModel, next);
}
function computeFlyToLocationForCartographic(cartographic, terrainProvider) {
var availability = defined(terrainProvider)
? terrainProvider.availability
: undefined;
if (!defined(availability)) {
cartographic.height += DEFAULT_HEIGHT;
return when.resolve(cartographic);
}
return sampleTerrainMostDetailed(terrainProvider, [cartographic]).then(
function (positionOnTerrain) {
cartographic = positionOnTerrain[0];
cartographic.height += DEFAULT_HEIGHT;
return cartographic;
}
);
}
function flyToDestination(viewModel, destination) {
var scene = viewModel._scene;
var mapProjection = scene.mapProjection;
var ellipsoid = mapProjection.ellipsoid;
var camera = scene.camera;
var terrainProvider = scene.terrainProvider;
var finalDestination = destination;
var 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);
}
promise
.then(function (result) {
finalDestination = ellipsoid.cartographicToCartesian(result);
})
.always(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;
}
var nextPromise = geocoderService
.geocode(query, geocodeType)
.then(function (result) {
return { state: "fulfilled", value: result };
})
.otherwise(function (err) {
return { state: "rejected", reason: err };
});
return nextPromise;
});
}
function geocode(viewModel, geocoderServices, geocodeType) {
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, geocodeType);
}
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;
viewModel.destinationFound(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, GeocodeType.AUTOCOMPLETE)
.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]);
}
});
}
/**
* 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;