GeocoderViewModel.js 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555
  1. import CartographicGeocoderService from "../../Core/CartographicGeocoderService.js";
  2. import defaultValue from "../../Core/defaultValue.js";
  3. import defined from "../../Core/defined.js";
  4. import DeveloperError from "../../Core/DeveloperError.js";
  5. import Event from "../../Core/Event.js";
  6. import GeocodeType from "../../Core/GeocodeType.js";
  7. import IonGeocoderService from "../../Core/IonGeocoderService.js";
  8. import CesiumMath from "../../Core/Math.js";
  9. import Matrix4 from "../../Core/Matrix4.js";
  10. import Rectangle from "../../Core/Rectangle.js";
  11. import sampleTerrainMostDetailed from "../../Core/sampleTerrainMostDetailed.js";
  12. import computeFlyToLocationForRectangle from "../../Scene/computeFlyToLocationForRectangle.js";
  13. import knockout from "../../ThirdParty/knockout.js";
  14. import createCommand from "../createCommand.js";
  15. import getElement from "../getElement.js";
  16. // The height we use if geocoding to a specific point instead of an rectangle.
  17. const DEFAULT_HEIGHT = 1000;
  18. /**
  19. * The view model for the {@link Geocoder} widget.
  20. * @alias GeocoderViewModel
  21. * @constructor
  22. *
  23. * @param {Object} options Object with the following properties:
  24. * @param {Scene} options.scene The Scene instance to use.
  25. * @param {GeocoderService[]} [options.geocoderServices] Geocoder services to use for geocoding queries.
  26. * If more than one are supplied, suggestions will be gathered for the geocoders that support it,
  27. * and if no suggestion is selected the result from the first geocoder service wil be used.
  28. * @param {Number} [options.flightDuration] The duration of the camera flight to an entered location, in seconds.
  29. * @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.
  30. */
  31. function GeocoderViewModel(options) {
  32. //>>includeStart('debug', pragmas.debug);
  33. if (!defined(options) || !defined(options.scene)) {
  34. throw new DeveloperError("options.scene is required.");
  35. }
  36. //>>includeEnd('debug');
  37. if (defined(options.geocoderServices)) {
  38. this._geocoderServices = options.geocoderServices;
  39. } else {
  40. this._geocoderServices = [
  41. new CartographicGeocoderService(),
  42. new IonGeocoderService({ scene: options.scene }),
  43. ];
  44. }
  45. this._viewContainer = options.container;
  46. this._scene = options.scene;
  47. this._flightDuration = options.flightDuration;
  48. this._searchText = "";
  49. this._isSearchInProgress = false;
  50. this._geocodePromise = undefined;
  51. this._complete = new Event();
  52. this._suggestions = [];
  53. this._selectedSuggestion = undefined;
  54. this._showSuggestions = true;
  55. this._handleArrowDown = handleArrowDown;
  56. this._handleArrowUp = handleArrowUp;
  57. const that = this;
  58. this._suggestionsVisible = knockout.pureComputed(function () {
  59. const suggestions = knockout.getObservable(that, "_suggestions");
  60. const suggestionsNotEmpty = suggestions().length > 0;
  61. const showSuggestions = knockout.getObservable(that, "_showSuggestions")();
  62. return suggestionsNotEmpty && showSuggestions;
  63. });
  64. this._searchCommand = createCommand(function (geocodeType) {
  65. geocodeType = defaultValue(geocodeType, GeocodeType.SEARCH);
  66. that._focusTextbox = false;
  67. if (defined(that._selectedSuggestion)) {
  68. that.activateSuggestion(that._selectedSuggestion);
  69. return false;
  70. }
  71. that.hideSuggestions();
  72. if (that.isSearchInProgress) {
  73. cancelGeocode(that);
  74. } else {
  75. geocode(that, that._geocoderServices, geocodeType);
  76. }
  77. });
  78. this.deselectSuggestion = function () {
  79. that._selectedSuggestion = undefined;
  80. };
  81. this.handleKeyDown = function (data, event) {
  82. const downKey =
  83. event.key === "ArrowDown" || event.key === "Down" || event.keyCode === 40;
  84. const upKey =
  85. event.key === "ArrowUp" || event.key === "Up" || event.keyCode === 38;
  86. if (downKey || upKey) {
  87. event.preventDefault();
  88. }
  89. return true;
  90. };
  91. this.handleKeyUp = function (data, event) {
  92. const downKey =
  93. event.key === "ArrowDown" || event.key === "Down" || event.keyCode === 40;
  94. const upKey =
  95. event.key === "ArrowUp" || event.key === "Up" || event.keyCode === 38;
  96. const enterKey = event.key === "Enter" || event.keyCode === 13;
  97. if (upKey) {
  98. handleArrowUp(that);
  99. } else if (downKey) {
  100. handleArrowDown(that);
  101. } else if (enterKey) {
  102. that._searchCommand();
  103. }
  104. return true;
  105. };
  106. this.activateSuggestion = function (data) {
  107. that.hideSuggestions();
  108. that._searchText = data.displayName;
  109. const destination = data.destination;
  110. clearSuggestions(that);
  111. that.destinationFound(that, destination);
  112. };
  113. this.hideSuggestions = function () {
  114. that._showSuggestions = false;
  115. that._selectedSuggestion = undefined;
  116. };
  117. this.showSuggestions = function () {
  118. that._showSuggestions = true;
  119. };
  120. this.handleMouseover = function (data, event) {
  121. if (data !== that._selectedSuggestion) {
  122. that._selectedSuggestion = data;
  123. }
  124. };
  125. /**
  126. * Gets or sets a value indicating if this instance should always show its text input field.
  127. *
  128. * @type {Boolean}
  129. * @default false
  130. */
  131. this.keepExpanded = false;
  132. /**
  133. * True if the geocoder should query as the user types to autocomplete
  134. * @type {Boolean}
  135. * @default true
  136. */
  137. this.autoComplete = defaultValue(options.autocomplete, true);
  138. /**
  139. * Gets and sets the command called when a geocode destination is found
  140. * @type {Geocoder.DestinationFoundFunction}
  141. */
  142. this.destinationFound = defaultValue(
  143. options.destinationFound,
  144. GeocoderViewModel.flyToDestination
  145. );
  146. this._focusTextbox = false;
  147. knockout.track(this, [
  148. "_searchText",
  149. "_isSearchInProgress",
  150. "keepExpanded",
  151. "_suggestions",
  152. "_selectedSuggestion",
  153. "_showSuggestions",
  154. "_focusTextbox",
  155. ]);
  156. const searchTextObservable = knockout.getObservable(this, "_searchText");
  157. searchTextObservable.extend({ rateLimit: { timeout: 500 } });
  158. this._suggestionSubscription = searchTextObservable.subscribe(function () {
  159. GeocoderViewModel._updateSearchSuggestions(that);
  160. });
  161. /**
  162. * Gets a value indicating whether a search is currently in progress. This property is observable.
  163. *
  164. * @type {Boolean}
  165. */
  166. this.isSearchInProgress = undefined;
  167. knockout.defineProperty(this, "isSearchInProgress", {
  168. get: function () {
  169. return this._isSearchInProgress;
  170. },
  171. });
  172. /**
  173. * Gets or sets the text to search for. The text can be an address, or longitude, latitude,
  174. * and optional height, where longitude and latitude are in degrees and height is in meters.
  175. *
  176. * @type {String}
  177. */
  178. this.searchText = undefined;
  179. knockout.defineProperty(this, "searchText", {
  180. get: function () {
  181. if (this.isSearchInProgress) {
  182. return "Searching...";
  183. }
  184. return this._searchText;
  185. },
  186. set: function (value) {
  187. //>>includeStart('debug', pragmas.debug);
  188. if (typeof value !== "string") {
  189. throw new DeveloperError("value must be a valid string.");
  190. }
  191. //>>includeEnd('debug');
  192. this._searchText = value;
  193. },
  194. });
  195. /**
  196. * Gets or sets the the duration of the camera flight in seconds.
  197. * A value of zero causes the camera to instantly switch to the geocoding location.
  198. * The duration will be computed based on the distance when undefined.
  199. *
  200. * @type {Number|undefined}
  201. * @default undefined
  202. */
  203. this.flightDuration = undefined;
  204. knockout.defineProperty(this, "flightDuration", {
  205. get: function () {
  206. return this._flightDuration;
  207. },
  208. set: function (value) {
  209. //>>includeStart('debug', pragmas.debug);
  210. if (defined(value) && value < 0) {
  211. throw new DeveloperError("value must be positive.");
  212. }
  213. //>>includeEnd('debug');
  214. this._flightDuration = value;
  215. },
  216. });
  217. }
  218. Object.defineProperties(GeocoderViewModel.prototype, {
  219. /**
  220. * Gets the event triggered on flight completion.
  221. * @memberof GeocoderViewModel.prototype
  222. *
  223. * @type {Event}
  224. */
  225. complete: {
  226. get: function () {
  227. return this._complete;
  228. },
  229. },
  230. /**
  231. * Gets the scene to control.
  232. * @memberof GeocoderViewModel.prototype
  233. *
  234. * @type {Scene}
  235. */
  236. scene: {
  237. get: function () {
  238. return this._scene;
  239. },
  240. },
  241. /**
  242. * Gets the Command that is executed when the button is clicked.
  243. * @memberof GeocoderViewModel.prototype
  244. *
  245. * @type {Command}
  246. */
  247. search: {
  248. get: function () {
  249. return this._searchCommand;
  250. },
  251. },
  252. /**
  253. * Gets the currently selected geocoder search suggestion
  254. * @memberof GeocoderViewModel.prototype
  255. *
  256. * @type {Object}
  257. */
  258. selectedSuggestion: {
  259. get: function () {
  260. return this._selectedSuggestion;
  261. },
  262. },
  263. /**
  264. * Gets the list of geocoder search suggestions
  265. * @memberof GeocoderViewModel.prototype
  266. *
  267. * @type {Object[]}
  268. */
  269. suggestions: {
  270. get: function () {
  271. return this._suggestions;
  272. },
  273. },
  274. });
  275. /**
  276. * Destroys the widget. Should be called if permanently
  277. * removing the widget from layout.
  278. */
  279. GeocoderViewModel.prototype.destroy = function () {
  280. this._suggestionSubscription.dispose();
  281. };
  282. function handleArrowUp(viewModel) {
  283. if (viewModel._suggestions.length === 0) {
  284. return;
  285. }
  286. const currentIndex = viewModel._suggestions.indexOf(
  287. viewModel._selectedSuggestion
  288. );
  289. if (currentIndex === -1 || currentIndex === 0) {
  290. viewModel._selectedSuggestion = undefined;
  291. return;
  292. }
  293. const next = currentIndex - 1;
  294. viewModel._selectedSuggestion = viewModel._suggestions[next];
  295. GeocoderViewModel._adjustSuggestionsScroll(viewModel, next);
  296. }
  297. function handleArrowDown(viewModel) {
  298. if (viewModel._suggestions.length === 0) {
  299. return;
  300. }
  301. const numberOfSuggestions = viewModel._suggestions.length;
  302. const currentIndex = viewModel._suggestions.indexOf(
  303. viewModel._selectedSuggestion
  304. );
  305. const next = (currentIndex + 1) % numberOfSuggestions;
  306. viewModel._selectedSuggestion = viewModel._suggestions[next];
  307. GeocoderViewModel._adjustSuggestionsScroll(viewModel, next);
  308. }
  309. function computeFlyToLocationForCartographic(cartographic, terrainProvider) {
  310. const availability = defined(terrainProvider)
  311. ? terrainProvider.availability
  312. : undefined;
  313. if (!defined(availability)) {
  314. cartographic.height += DEFAULT_HEIGHT;
  315. return Promise.resolve(cartographic);
  316. }
  317. return sampleTerrainMostDetailed(terrainProvider, [cartographic]).then(
  318. function (positionOnTerrain) {
  319. cartographic = positionOnTerrain[0];
  320. cartographic.height += DEFAULT_HEIGHT;
  321. return cartographic;
  322. }
  323. );
  324. }
  325. function flyToDestination(viewModel, destination) {
  326. const scene = viewModel._scene;
  327. const mapProjection = scene.mapProjection;
  328. const ellipsoid = mapProjection.ellipsoid;
  329. const camera = scene.camera;
  330. const terrainProvider = scene.terrainProvider;
  331. let finalDestination = destination;
  332. let promise;
  333. if (destination instanceof Rectangle) {
  334. // Some geocoders return a Rectangle of zero width/height, treat it like a point instead.
  335. if (
  336. CesiumMath.equalsEpsilon(
  337. destination.south,
  338. destination.north,
  339. CesiumMath.EPSILON7
  340. ) &&
  341. CesiumMath.equalsEpsilon(
  342. destination.east,
  343. destination.west,
  344. CesiumMath.EPSILON7
  345. )
  346. ) {
  347. // destination is now a Cartographic
  348. destination = Rectangle.center(destination);
  349. } else {
  350. promise = computeFlyToLocationForRectangle(destination, scene);
  351. }
  352. } else {
  353. // destination is a Cartesian3
  354. destination = ellipsoid.cartesianToCartographic(destination);
  355. }
  356. if (!defined(promise)) {
  357. promise = computeFlyToLocationForCartographic(destination, terrainProvider);
  358. }
  359. return promise
  360. .then(function (result) {
  361. finalDestination = ellipsoid.cartographicToCartesian(result);
  362. })
  363. .finally(function () {
  364. // Whether terrain querying succeeded or not, fly to the destination.
  365. camera.flyTo({
  366. destination: finalDestination,
  367. complete: function () {
  368. viewModel._complete.raiseEvent();
  369. },
  370. duration: viewModel._flightDuration,
  371. endTransform: Matrix4.IDENTITY,
  372. });
  373. });
  374. }
  375. function chainPromise(promise, geocoderService, query, geocodeType) {
  376. return promise.then(function (result) {
  377. if (
  378. defined(result) &&
  379. result.state === "fulfilled" &&
  380. result.value.length > 0
  381. ) {
  382. return result;
  383. }
  384. const nextPromise = geocoderService
  385. .geocode(query, geocodeType)
  386. .then(function (result) {
  387. return { state: "fulfilled", value: result };
  388. })
  389. .catch(function (err) {
  390. return { state: "rejected", reason: err };
  391. });
  392. return nextPromise;
  393. });
  394. }
  395. function geocode(viewModel, geocoderServices, geocodeType) {
  396. const query = viewModel._searchText;
  397. if (hasOnlyWhitespace(query)) {
  398. viewModel.showSuggestions();
  399. return;
  400. }
  401. viewModel._isSearchInProgress = true;
  402. let promise = Promise.resolve();
  403. for (let i = 0; i < geocoderServices.length; i++) {
  404. promise = chainPromise(promise, geocoderServices[i], query, geocodeType);
  405. }
  406. viewModel._geocodePromise = promise;
  407. promise.then(function (result) {
  408. if (promise.cancel) {
  409. return;
  410. }
  411. viewModel._isSearchInProgress = false;
  412. const geocoderResults = result.value;
  413. if (
  414. result.state === "fulfilled" &&
  415. defined(geocoderResults) &&
  416. geocoderResults.length > 0
  417. ) {
  418. viewModel._searchText = geocoderResults[0].displayName;
  419. viewModel.destinationFound(viewModel, geocoderResults[0].destination);
  420. return;
  421. }
  422. viewModel._searchText = `${query} (not found)`;
  423. });
  424. }
  425. function adjustSuggestionsScroll(viewModel, focusedItemIndex) {
  426. const container = getElement(viewModel._viewContainer);
  427. const searchResults = container.getElementsByClassName("search-results")[0];
  428. const listItems = container.getElementsByTagName("li");
  429. const element = listItems[focusedItemIndex];
  430. if (focusedItemIndex === 0) {
  431. searchResults.scrollTop = 0;
  432. return;
  433. }
  434. const offsetTop = element.offsetTop;
  435. if (offsetTop + element.clientHeight > searchResults.clientHeight) {
  436. searchResults.scrollTop = offsetTop + element.clientHeight;
  437. } else if (offsetTop < searchResults.scrollTop) {
  438. searchResults.scrollTop = offsetTop;
  439. }
  440. }
  441. function cancelGeocode(viewModel) {
  442. viewModel._isSearchInProgress = false;
  443. if (defined(viewModel._geocodePromise)) {
  444. viewModel._geocodePromise.cancel = true;
  445. viewModel._geocodePromise = undefined;
  446. }
  447. }
  448. function hasOnlyWhitespace(string) {
  449. return /^\s*$/.test(string);
  450. }
  451. function clearSuggestions(viewModel) {
  452. knockout.getObservable(viewModel, "_suggestions").removeAll();
  453. }
  454. function updateSearchSuggestions(viewModel) {
  455. if (!viewModel.autoComplete) {
  456. return;
  457. }
  458. const query = viewModel._searchText;
  459. clearSuggestions(viewModel);
  460. if (hasOnlyWhitespace(query)) {
  461. return;
  462. }
  463. let promise = Promise.resolve([]);
  464. viewModel._geocoderServices.forEach(function (service) {
  465. promise = promise.then(function (results) {
  466. if (results.length >= 5) {
  467. return results;
  468. }
  469. return service
  470. .geocode(query, GeocodeType.AUTOCOMPLETE)
  471. .then(function (newResults) {
  472. results = results.concat(newResults);
  473. return results;
  474. });
  475. });
  476. });
  477. return promise.then(function (results) {
  478. const suggestions = viewModel._suggestions;
  479. for (let i = 0; i < results.length; i++) {
  480. suggestions.push(results[i]);
  481. }
  482. });
  483. }
  484. /**
  485. * A function to fly to the destination found by a successful geocode.
  486. * @type {Geocoder.DestinationFoundFunction}
  487. */
  488. GeocoderViewModel.flyToDestination = flyToDestination;
  489. //exposed for testing
  490. GeocoderViewModel._updateSearchSuggestions = updateSearchSuggestions;
  491. GeocoderViewModel._adjustSuggestionsScroll = adjustSuggestionsScroll;
  492. export default GeocoderViewModel;