Source: src/js/models/maps/viewfinder/ViewfinderModel.js

'use strict';

define(
  [
    'underscore',
    'backbone',
    'cesium',
    'models/geocoder/GeocoderSearch',
    'models/maps/GeoPoint'
  ],
  (_, Backbone, Cesium, GeocoderSearch, GeoPoint) => {
    const EMAIL = MetacatUI.appModel.get('emailContact');
    const NO_RESULTS_MESSAGE = 'No search results found, try using another place name.';
    const API_ERROR = 'We\'re having trouble identifying locations on the map right now. Please reach out to support for help with this issue' + (EMAIL ? `: ${EMAIL}` : '.');
    const PLACES_API_ERROR = API_ERROR;
    const GEOCODING_API_ERROR = API_ERROR;

    /**
    * @class ViewfinderModel
    * @classdesc ViewfinderModel maintains state for the ViewfinderView and
    * interfaces with location searching services.
    * @classcategory Models/Maps
    * @since 2.28.0
    * @extends Backbone.Model
    */
    const ViewfinderModel = Backbone.Model.extend(
      /** @lends ViewfinderModel.prototype */{
        /**
         * @name ViewfinderModel#defaults
         * @type {Object}
         * @property {string} error is the current error string to be displayed
         * in the UI.
         * @property {number} focusIndex is the index of the element
         * in the list of predictions that shoudl be highlighted as focus.
         * @property {Prediction[]} predictions a list of Predictions models that
         * correspond to the user's search query.
         * @property {string} query the user's search query.
         * @since 2.28.0
         */
        defaults() {
          return {
            error: '',
            focusIndex: -1,
            predictions: [],
            query: '',
            zoomPresets: [],
          }
        },

        /**
         * @param {Map} mapModel is the Map model that the ViewfinderModel is
         * managing for the corresponding ViewfinderView.
         */
        initialize({ mapModel }) {
          this.geocoderSearch = new GeocoderSearch();
          this.mapModel = mapModel;
          this.allLayers = this.mapModel.getAllLayers();

          this.set('zoomPresets', mapModel.get('zoomPresetsCollection')?.models || []);
        },

        /** 
         * Get autocompletion predictions from the GeocoderSearch model. 
         * @param {string} rawQuery is the user's search query with spaces.
         */
        async autocompleteSearch(rawQuery) {
          const query = rawQuery.trim();
          if (this.get('query') === query) {
            return;
          } else if (!query) {
            this.set({ error: '', predictions: [], query: '', focusIndex: -1, });
            return;
          } else if (GeoPoint.couldBeLatLong(query)) {
            this.set({ predictions: [], query: '', focusIndex: -1, });
            return;
          }

          // Unset error so the error will fire a change event even if it is the
          // same error as already exists.
          this.unset('error', { silent: true });

          try {
            // User is looking for autocompletions.
            const predictions = await this.geocoderSearch.autocomplete(query);
            const error = predictions.length === 0 ? NO_RESULTS_MESSAGE : '';
            this.set({ error, focusIndex: -1, predictions, query, });
          } catch (e) {
            if (e.code === 'REQUEST_DENIED' && e.endpoint === 'PLACES_AUTOCOMPLETE') {
              this.set({
                error: PLACES_API_ERROR,
                focusIndex: -1,
                predictions: [],
                query,
              });
            } else {
              this.set({
                error: NO_RESULTS_MESSAGE,
                focusIndex: -1,
                predictions: [],
                query,
              });
            }
          }
        },

        /**
         * Decrement the focused index with a minimum value of 0. This corresponds
         * to an ArrowUp key down event. 
         * Note: An ArrowUp key press while the current index is -1 will
         * result in highlighting the first element in the list.
         */
        decrementFocusIndex() {
          const currentIndex = this.get('focusIndex');
          this.set('focusIndex', Math.max(0, currentIndex - 1));
        },

        /**
         * Increment the focused index with a maximum value of the last value in
         * the list. This corresponds to an ArrowDown key down event. 
         */
        incrementFocusIndex() {
          const currentIndex = this.get('focusIndex');
          this.set(
            'focusIndex',
            Math.min(currentIndex + 1, this.get('predictions').length - 1)
          );
        },

        /**
         * Reset the focused index back to the initial value so that no element
         * in the UI is highlighted.
         */
        resetFocusIndex() {
          this.set('focusIndex', -1);
        },

        /** 
         * Navigate to the GeocodedLocation. 
         * @param {GeocodedLocation} geocoding is the location that corresponds
         * to the the selected prediction.
         */
        goToLocation(geocoding) {
          if (!geocoding) return;

          const coords = geocoding.get('box').getCoords();
          this.mapModel.zoomTo({
            destination: Cesium.Rectangle.fromDegrees(
              coords.west,
              coords.south,
              coords.east,
              coords.north,
            )
          });
        },

        /**
         * Select a ZoomPresetModel from the list of presets and navigate there.
         * This function hides all layers that are not to be visible according to
         * the ZoomPresetModel configuration.
         * @param {ZoomPresetModel} preset A user selected preset for which to 
         * enable layers and navigate.
         */
        selectZoomPreset(preset) {
          const enabledLayerIds = preset.get('enabledLayerIds');
          for (const layer of this.allLayers) {
            const isVisible = enabledLayerIds.includes(layer.get('layerId'));
            // Show or hide the layer according to the preset.
            layer.set('visible', isVisible);
          }

          this.mapModel.zoomTo(preset.get('geoPoint'));
        },

        /**
         * Select a prediction from the list of predictions and navigate there.
         * @param {Prediction} prediction is the user-selected Prediction that
         * needs to be geocoded and navigated to.
         */
        async selectPrediction(prediction) {
          if (!prediction) return;

          try {
            const geocodings = await this.geocoderSearch.geocode(prediction);

            if (geocodings.length === 0) {
              this.set('error', NO_RESULTS_MESSAGE)
              return;
            }

            this.trigger('selection-made', prediction.get('description'));
            this.goToLocation(geocodings[0]);
          } catch (e) {
            if (e.code === 'REQUEST_DENIED' && e.endpoint === 'GEOCODER_GEOCODE') {
              this.set({ error: GEOCODING_API_ERROR, focusIndex: -1, predictions: [] });
            } else {
              this.set('error', NO_RESULTS_MESSAGE)
            }
          }
        },

        /**
         * Event handler for Backbone.View configuration that is called whenever 
         * the user clicks the search button or hits the Enter key.
         * @param {string} value is the query string.
         */
        async search(value) {
          if (!value) return;

          // This is not a lat,long value, so geocode the prediction instead.
          if (!GeoPoint.couldBeLatLong(value)) {
            const focusedIndex = Math.max(0, this.get("focusIndex"));
            this.selectPrediction(this.get('predictions')[focusedIndex]);
            return;
          }

          // Unset error so the error will fire a change event even if it is the
          // same error as already exists.
          this.unset('error', { silent: true });

          try {
            const geoPoint = new GeoPoint(value, { parse: true });
            geoPoint.set("height", 10000 /* meters */);
            if (geoPoint.isValid()) {
              this.set('error', '');
              this.mapModel.zoomTo(geoPoint);
              return;
            }

            const errors = geoPoint.validationError;
            if (errors.latitude) {
              this.set('error', errors.latitude);
            } else if (errors.longitude) {
              this.set('error', errors.longitude);
            }
          } catch (e) {
            this.set('error', e.message);
          }
        },
      });

    return ViewfinderModel;
  });