619 lines
17 KiB
JavaScript
619 lines
17 KiB
JavaScript
import {
|
|
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 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;
|