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

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

  /**
   * @class MapSearchConnector
   * @classdesc A model that updates the counts on a Geohash layer in a Map
   * model when the search results from a search model are reset.
   * @name MapSearchConnector
   * @extends Backbone.Model
   * @constructor
   * @classcategory Models/Connectors
   */
  return Backbone.Model.extend(
    /** @lends MapSearchConnector.prototype */ {

      /**
       * The type of Backbone.Model this is.
       * @type {string}
       * @since 2.25.0
       * @default "MapSearchConnector"
      */
      type: "MapSearchConnector",

      /**
       * @type {object}
       * @property {SolrResults} searchResults
       * @property {Map} map
       * @property {function} onMoveEnd A function to call when the map is
       * finished moving. This function will be called with the connector as
       * 'this'.
       */
      defaults: function () {
        return {
          searchResults: null,
          map: null,
          onMoveEnd: this.onMoveEnd
        };
      },

      /**
       * Initialize the model.
       * @param {Object} attrs - The attributes for this model.
       * @param {SolrResults | Object} [attributes.searchResults] - The
       * SolrResults model to use for this connector or a JSON object with
       * options to create a new SolrResults model. If not provided, a new
       * SolrResults model will be created.
       * @param {Map | Object} [attributes.map] - The Map model to use for this
       * connector or a JSON object with options to create a new Map model. If
       * not provided, a new Map model will be created.
       * @param {Object} [options] - The options for this model.
       * @param {boolean} [addGeohashLayer=true] - If true, a Geohash layer will
       * be added to the Map model if there is not already a Geohash layer in
       * the Map model's Layers collection. If false, no Geohash layer will be
       * added. A geohash layer is required for this connector to work.
       */
      initialize: function (attrs, options) {
        if (!this.get("map")) {
          this.set("map", new Map());
        }
        if (!this.get("searchResults")) {
          this.set("searchResults", new SearchResults());
        }
        const add = options?.addGeohashLayer ?? true;
        this.findAndSetGeohashLayer(add);
      },

      /**
       * Find the first Geohash layer in the Map model's layers collection.
       * @returns {CesiumGeohash} The first Geohash layer in the Map model's
       * layers collection or null if there is no Layers collection set on this
       * model or no Geohash layer in the collection.
       */
      findGeohash: function () {
        const layerGroups = this.get("layerGroups");
        if (!layerGroups) return null;

        // TODO: Since only the first Geohash is needed, create a getFirst
        // function in MapAssets.
        let geohashes = _.reduce(layerGroups, (memo, layers) => {
          const geohashes = layers.getAll("CesiumGeohash");
          if (geohashes && geohashes.length) {
            memo.push(...geohashes);
          }
          return memo;
        }, []);
        if (!geohashes || !geohashes.length) {
          return null;
        } else {
          return geohashes[0] || null;
        }
      },

      /**
       * Find the array of MapAssets collection from the Map model.
       * @returns {MapAssets[]} An array of MapAssets collection from the Map
       * model. Return null if no map or layers are found.
       */
      findLayerGroups: function () {
        const map = this.get("map");
        if (!map) return null;
        const layerGroups = map.getLayerGroups();
        return layerGroups.length > 0 ? layerGroups : null;
      },

      /**
       * Create a new array of MapAssets collection and set it on the Map model.
       * @returns {MapAssets[]} The new array of MapAssets collection.
       */
      createLayerGroups: function () {
        const map = this.get("map");
        if (!map) return null;
        return [map.resetLayers()];
      },

      /**
       * Create a new Geohash layer and add it to the Layers collection.
       * @returns {CesiumGeohash} The new Geohash layer or null if there is no
       * Layers collection set on this model.
       * @fires Layers#add
       */
      createGeohash: function () {
        const map = this.get("map");
        return map.addAsset({ type: "CesiumGeohash" });
      },

      /**
       * Find the Geohash layer in the Map model's layers collection and
       * optionally create one if it doesn't exist. This will also create and
       * set a map and a layers collection from that map if they don't exist.
       * @param {boolean} [add=true] - If true, create a new Geohash layer if
       * one doesn't exist.
       * @returns {CesiumGeohash} The Geohash layer in the Map model's layers
       * collection or null if there is no Layers collection set on this model
       * and `add` is false.
       * @fires Layers#add
       */
      findAndSetGeohashLayer: function (add = true) {
        const wasConnected = this.get("isConnected");
        this.disconnect();
        const layerGroups = this.findLayerGroups() || this.createLayerGroups();
        this.set("layerGroups", layerGroups);
        let geohash = this.findGeohash() || (add ? this.createGeohash() : null);
        this.set("geohashLayer", geohash);
        if (wasConnected) {
          this.connect();
        }
        // If there is still no Geohash layer, then we should wait for one to
        // be added to the Layers collection, then try to find it again.
        _.each(layerGroups, layers => {
          this.stopListening(layers, "add", this.findAndSetGeohashLayer);
          if (!geohash) {
            this.listenTo(layers, "add", this.findAndSetGeohashLayer);
          }
        });
        return geohash;
      },

      /**
       * Connect the Map to the Search. When a new search is performed, the
       * Search will set the new facet counts on the GeoHash layer in the Map.
       * When the view extent on the map has changed, the geohash facet on the
       * search will be updated to reflect the new height/altitude of the view.
       * This will trigger a new search, which will update the counts on the
       * GeoHash layer in the Map. When connected, the Geohash layer will also
       * be hidden during search requests, and while the user is panning/zooming
       * the map.
       */
      connect: function () {
        this.disconnect();

        const searchResults = this.get("searchResults");
        const map = this.get("map");
        const interactions = map.get("interactions");

        // Pass the facet counts to the GeoHash layer when the search results
        // are returned.
        this.listenTo(searchResults, "update reset", function () {
          this.updateGeohashCounts();
          this.showGeoHashLayer();
        });

        // When the search result should be shown on the map (e.g. a user hovers
        // over the map icon), highlight the GeoHash on the map.
        this.listenTo(searchResults, "change:showOnMap", this.selectGeohash);

        // When the user is panning/zooming in the map, hide the GeoHash layer
        // to indicate that the map is not up to date with the search results,
        // which are about to be updated.
        this.listenTo(interactions, "moveStartAndChanged", this.hideGeoHashLayer);

        // When the user is done panning/zooming in the map, show the GeoHash
        // layer again and update the search results (thereby updating the
        // facet counts on the GeoHash layer)
        this.listenTo(interactions, "moveEnd", function () {
          const moveEndFunc = this.get("onMoveEnd");
          if (typeof moveEndFunc === "function") {
            moveEndFunc.call(this);
          }
        });

        // When a new search is being performed, hide the GeoHash layer to
        // indicate that the map is not up to date with the search results,
        // which are about to be updated.
        this.listenTo(searchResults, "request", function () {
          this.hideGeoHashLayer();
        });

        this.set("isConnected", true);
      },

      /**
       * Functions to perform when the map has finished moving. This is separated into its own method
       * so that external models can manipulate the behavior of this function.
       * See {@link MapSearchFiltersConnector#onMoveEnd}
       */
      onMoveEnd: function () {
        this.showGeoHashLayer();
        this.updateFacet();
      },

      /**
       * Make the geoHashLayer invisible temporarily. This will override the
       * visible property on the layer until the showGeoHashLayer method is
       * called.
       * @fires CesiumGeohash#change:visible
       */
      hideGeoHashLayer: function () {
        this.get("geohashLayer")?.set("temporarilyHidden", true);
      },

      /**
       * Make the geoHashLayer visible again after hiding it with the
       * hideGeoHashLayer method.
       * @fires CesiumGeohash#change:visible
       */
      showGeoHashLayer: function () {
        this.get("geohashLayer")?.set("temporarilyHidden", false);
      },

      /**
       * Disconnect the Map from the Search. Stops listening to the Search
       * results collection.
       */
      disconnect: function () {
        const map = this.get("map");
        const interactions = map?.get("interactions");
        const searchResults = this.get("searchResults");
        this.stopListening(searchResults, "update reset");
        this.stopListening(searchResults, "change:showOnMap");
        this.stopListening(interactions, "moveStartAndChanged moveEnd");
        this.stopListening(searchResults, "request");
        this.set("isConnected", false);
      },

      /**
       * Given the counts results in the format returned by the SolrResults
       * model, return an array of objects with a geohash and a count property,
       * formatted for the CesiumGeohash layer.
       * @param {Array} counts - The facet counts from the SolrResults model.
       * Given as an array of alternating keys and values.
       * @returns {Array} An array of objects with a geohash and a count
       * property.
       */
      facetCountsToGeohashAttrs: function (counts) {
        if (!counts) return [];
        const props = [];
        for (let i = 0; i < counts.length; i += 2) {
          props.push({
            hashString: counts[i],
            properties: {
              count: counts[i + 1],
            },
          });
        }
        return props;
      },

      /**
       * Look in the Search results for the facet counts for the Geohash layer.
       * @returns {Array} An array of objects with a geohash and a count
       * property or null if there are no Search results or no facet counts.
       */
      getGeohashCounts: function () {
        const searchResults = this.get("searchResults");
        const facetCounts = searchResults?.facetCounts;
        if (!facetCounts) return null;
        const geohashFacets = Object.keys(facetCounts).filter((key) =>
          key.startsWith("geohash_")
        );
        return geohashFacets.flatMap((key) => facetCounts[key]);
      },

      /**
       * Update the Geohash layer in the Map model with the new facet counts
       * from the Search results.
       * @fires CesiumGeohash#change:counts
       * @fires CesiumGeohash#change:totalCount
       */
      updateGeohashCounts: function () {
        const geohashLayer = this.get("geohashLayer");
        const counts = this.getGeohashCounts();
        const modelAttrs = this.facetCountsToGeohashAttrs(counts);
        geohashLayer.replaceGeohashes(modelAttrs);
      },

      /**
       * Update the facet on the Search results to match the current Geohash
       * level.
       * @fires SolrResults#change:facet
       */
      updateFacet: function () {
        const searchResults = this.get("searchResults");
        const geohashLayer = this.get("geohashLayer");
        const precision = geohashLayer.getPrecision();
        if (precision && typeof Number(precision) === "number") {
          searchResults.setFacet([`geohash_${precision}`]);
        } else {
          searchResults.setFacet(null);
        }
      },

      /**
       * Highlight the geohashes for the given search result on the map, or
       * remove highlighting if the search result is not selected.
       * @param {SolrResult} searchResult - The search result to highlight.
       */
      selectGeohash: function (searchResult) {
        // remove highlighting on geohashes by clearing all selected features
        if (!searchResult.get("showOnMap")) {
          this.get("map").selectFeatures(null);
          return;
        }
        // Get the highest precision geohashes for the given search result
        // and pass them to the geohash layer to highlight them.
        const geohashes9 = searchResult.get("geohash_9");
        this.get("geohashLayer").selectGeohashes(geohashes9);
      },
    }
  );
});