Source: src/js/models/connectors/Filters-Map.js

/*global define */
define(["backbone", "collections/Filters", "models/maps/Map"], function (
  Backbone,
  Filters,
  Map
) {
  "use strict";

  /**
   * @class FiltersMapConnector
   * @name FiltersMapConnector
   * @classdesc A model that creates listeners between a Map model and a
   * collection of Filters. The Map will update any spatial filters in the
   * collection with map extent and zoom level changes. This connector does not
   * assume anything about how the map or filters will be displayed in the UI or
   * why those components need to be connected.
   * @name FiltersMapConnector
   * @extends Backbone.Model
   * @constructor
   * @classcategory Models/Connectors
   * @since 2.25.0
   */
  return Backbone.Model.extend(
    /** @lends FiltersMapConnector.prototype */ {
      /**
       * The type of Backbone.Model this is.
       * @type {string}
       * @since 2.25.0
       * @default "FiltersMapConnector"
       */
      type: "FiltersMapConnector",
      /**
       * @type {object}
       * @property {Filter[]} filtersList An array of Filter models to
       * optionally add to the Filters collection
       * @property {Filters} filters A Filters collection to connect to the Map
       * @property {SpatialFilter[]} spatialFilters An array of SpatialFilter
       * models present in the Filters collection.
       * @property {Map} map The Map model that will update the spatial filters
       * @property {boolean} isConnected Whether the connector is currently
       * listening to the Map model for changes. Set automatically when the
       * connector is started or stopped.
       * @property {function} onMoveEnd A function to call when the map is
       * finished moving. This function will be called with the connector as
       * 'this'.
       * @since 2.25.0
       */
      defaults: function () {
        return {
          filters: null,
          spatialFilters: [],
          map: null,
          isConnected: false,
          onMoveEnd: this.updateSpatialFilters,
        };
      },

      /**
       * Set up the model connections.
       * @param {object} attr - The attributes passed to the model, must include
       * a Filters collection and a Map model.
       * @param {object} options - The options passed to the model.
       * @param {boolean} [options.addSpatialFilter=true] - Whether to add a
       * SpatialFilter to the Filters collection if none are found. The
       * connector won't work without a SpatialFilter, but it will listen for
       * updates to the Filters collection and connect to any SpatialFilters
       * that are added.
       */
      initialize: function (attr, options) {
        try {
          if (!this.get("filters")) {
            this.set("filters",  new Filters([], { catalogSearch: true }));
          }
          if (!this.get("map")) {
            this.set("map", new Map());
          }
          const add = options?.addSpatialFilter ?? true;
          this.findAndSetSpatialFilters(add);
        } catch (e) {
          console.log("Error initializing Filters-Map connector: ", e);
        }
      },

      /**
       * Finds and sets the spatial filters within the Filters collection. Stops
       * any existing listeners, adds a new listener for collection updates, and
       * adds a spatial filter if needed.
       * @param {boolean} [add=false] - Whether to add a SpatialFilter if none
       * are found in the collection.
       */
      findAndSetSpatialFilters: function (add = false) {
        const wasConnected = this.get("isConnected");
        this.disconnect();
        this.setSpatialFilters();
        this.listenOnceToFiltersUpdates();
        this.addSpatialFilterIfNeeded(add);
        if (wasConnected) {
          this.connect();
        }
      },

      /**
       * Sets the SpatialFilter models found within the Filters collection to
       * the 'spatialFilters' attribute.
       */
      setSpatialFilters: function () {
        const spatialFilters = this.get("filters").where({
          filterType: "SpatialFilter",
        });
        this.set("spatialFilters", spatialFilters);
      },

      /**
       * Adds a listener to the Filters collection for updates, to re-run the
       * findAndSetSpatialFilters function.
       */
      listenOnceToFiltersUpdates: function () {
        this.listenToOnce(
          this.get("filters"),
          "add remove",
          this.findAndSetSpatialFilters
        );
      },

      /**
       * Adds a new SpatialFilter to the Filters collection if no spatial
       * filters are found and 'add' is true. This will trigger a collection
       * update, which will re-run the findAndSetSpatialFilters function.
       * @param {boolean} add - Whether to add a SpatialFilter if none are found
       * in the collection.
       */
      addSpatialFilterIfNeeded: function (add) {
        const spatialFilters = this.get("spatialFilters");
        if (!spatialFilters?.length && add) {
          this.get("filters").add({
            filterType: "SpatialFilter",
            isInvisible: true,
          });
        }
      },

      /**
       * Removes all SpatialFilter models from the Filters collection and
       * destroys them.
       */
      removeSpatialFilter: function () {
        const spatialFilters = this.get("spatialFilters");
        if (spatialFilters?.length) {
          this.stopListening(
            this.get("filters"),
            "add remove",
            this.findAndSetSpatialFilters
          );
          spatialFilters.forEach((filter) => {
            filter.collection.remove(filter);
          });
        }
        this.set("spatialFilters", []);
      },

      /**
       * Reset the spatial filter values to their defaults. This will remove
       * any spatial constraints from the search.
       */
      resetSpatialFilter: function () {
        const spatialFilters = this.get("spatialFilters");
        if (spatialFilters?.length) {
          spatialFilters.forEach((filter) => {
            filter.resetValue();
          });
        }
      },

      /**
       * Stops all Filter-Map listeners, including listeners on the Filters
       * collection and the Map model.
       * @param {boolean} [resetSpatialFilter=false] - Whether to reset the
       * spatial filter values to their defaults. This will remove any spatial
       * constraints from the search.
       */
      disconnect: function (resetSpatialFilter = false) {
        try {
          if (resetSpatialFilter) {
            this.resetSpatialFilter();
          }
          const interactions = this.get("map")?.get("interactions");
          this.stopListening(this.get("filters"), "add remove");
          this.stopListening(interactions, "moveEnd moveStartAndChanged");
          this.set("isConnected", false);
        } catch (e) {
          console.log("Error stopping Filter-Map listeners: ", e);
        }
      },

      /**
       * Starts listening to the Map Interaction model for changes in the
       * 'viewExtent' attribute, and calls the updateSpatialFilters function
       * when changes are detected. This method needs to be called for the
       * connector to work.
       */
      connect: function () {
        try {
          this.disconnect();
          const map = this.get("map");
          const interactions = map.get("interactions");
          // Constrain the spatial filter to the current map extent right away
          this.updateSpatialFilters();
          // Trigger a 'changing' event on the filters collection to
          // indicate that the spatial filter is being updated
          this.listenTo(interactions, "moveStartAndChanged", function () {
            this.get("filters").trigger("changing");
          });
          this.listenTo(interactions, "moveEnd", function () {
            const moveEndFunc = this.get("onMoveEnd");
            if (typeof moveEndFunc === "function") {
              moveEndFunc.call(this);
            }
          });
          this.set("isConnected", true);
        } catch (e) {
          console.log("Error starting Filter-Map listeners: ", e);
        }
      },

      /**
       * Updates the spatial filters with the current map extent and zoom level.
       */
      updateSpatialFilters: function () {
        try {
          const map = this.get("map");
          // TODO: If we update the spatialFilter to use the GeoBoundingBox
          // model (instead of north, south, east, west attributes), then we can
          // point directly to the MapInteraction model's 'viewExtent' attribute
          // instead of getting the extent from the map. They will stay in sync
          // automatically.
          const extent = map.get("interactions").get("viewExtent").toJSON();
          const spatialFilters = this.get("spatialFilters");

          if (!spatialFilters?.length) {
            return;
          }
          spatialFilters.forEach((spFilter) => {
            spFilter.set(extent, { silent: true });
            spFilter.trigger("change:height"); // Only trigger one change event
          });
        } catch (e) {
          console.log("Error updating spatial filters: ", e);
        }
      },
    }
  );
});