GeocoderViewModel.js 17 KB

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