GeocoderViewModel.js 19 KB

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