GeocoderViewModel.js 16 KB

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