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
   * @augments 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.
       * @property {ViewfinderCardCategories|null} viewfinderCards is the
       * collection of ViewfinderCards available in the current map. Renamed from
       * zoomPresets when zoom presets were deprecated in favor of more generalized
       * viewfinder cards.
       * @since 2.28.0
       */
      defaults() {
        return {
          error: "",
          focusIndex: -1,
          predictions: [],
          query: "",
          viewfinderCards: null,
        };
      },

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

        this.set("viewfinderCards", mapModel.get("viewfinderCardsCollection"));
      },

      /**
       * 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;
        }
        if (!query) {
          this.set({ error: "", predictions: [], query: "", focusIndex: -1 });
          return;
        }
        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,
          ),
        });
      },

      /**
       * Open a visualization app in the full-screen iframe overlay by setting
       * the activeVisualizationUrl attribute on the map model.
       * @param {string} url The URL to load in the iframe overlay.
       * @since 2.37.0
       */
      openVisualization(url) {
        this.mapModel.set({ activeVisualizationUrl: url });
      },

      /**
       * Close the visualization overlay by clearing activeVisualizationUrl on
       * the map model.
       * @since 2.37.0
       */
      closeVisualization() {
        this.mapModel.set({ activeVisualizationUrl: null });
      },

      /**
       * Extended from ZoomPresetModel's selectZoomPreset() when zoom presets
       * were deprecated in favor of generalized viewfinder cards. Select a
       * ViewfinderCardModel from the list of cards and navigate there.
       * When `action` is a 'map' type ctaAction, its `layerIds` and coordinates
       * are used directly. Otherwise falls back to the preset's `enabledLayerIds`
       * and `geoPoint` for backward compatibility.
       * @param {ViewfinderCardModel} preset A user selected card.
       * @param {ViewfinderCardAction} [action] The specific 'map' ctaAction
       * that was activated. When omitted, the preset's own fields are used.
       */
      selectViewfinderCard(preset, action) {
        let enabledLayerIds;
        let geoPoint;

        if (action && action.type === "map") {
          enabledLayerIds = action.layerIds || [];
          if (action.latitude != null && action.longitude != null) {
            geoPoint = new GeoPoint({
              latitude: action.latitude,
              longitude: action.longitude,
              height: action.height,
            });
          }
        } else {
          // Backward compat: read directly from the legacy zoom preset model attributes.
          enabledLayerIds = preset.get("enabledLayerIds") || [];
          geoPoint = preset.get("geoPoint");
        }

        this.mapModel.get("allLayers").forEach((layer) => {
          const isVisible = enabledLayerIds.includes(layer.get("layerId"));
          // Show or hide the layer according to the preset.
          layer.set("visible", isVisible);
        });

        if (geoPoint) {
          this.mapModel.zoomTo(geoPoint);
        }

        // If this preset corresponds to a specific feature, select it on the
        // map.
        const layer = preset.get("featureLayer");
        const featId = preset.get("featureId");
        if (layer && featId) {
          const feature = layer.getFeatureById(featId);
          if (feature) {
            const featAttrs = layer.getFeatureAttributes(feature);
            this.mapModel.selectFeatures([featAttrs]);
          }
        } else {
          // close any open feature panels
          this.mapModel.selectFeatures([]);
        }
      },

      /**
       * Backward-compatibility alias for selectViewfinderCard.
       * @deprecated Use selectViewfinderCard instead.
       * @param {ViewfinderCardModel} preset The card to select.
       * @returns {void}
       */
      selectZoomPreset(preset) {
        return this.selectViewfinderCard(preset);
      },

      /**
       * 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;
          }
          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;
});