Source: src/js/views/DataCatalogView.js

define([
  "jquery",
  "underscore",
  "backbone",
  "collections/SolrResults",
  "models/Search",
  "models/MetricsModel",
  "common/Utilities",
  "views/SearchResultView",
  "views/searchSelect/BioontologySelectView",
  "text!templates/search.html",
  "text!templates/statCounts.html",
  "text!templates/pager.html",
  "text!templates/mainContent.html",
  "text!templates/currentFilter.html",
  "text!templates/loading.html",
  "gmaps",
  "nGeohash",
], (
  $,
  _,
  Backbone,
  SearchResults,
  SearchModel,
  MetricsModel,
  Utilities,
  SearchResultView,
  BioontologySelectView,
  CatalogTemplate,
  CountTemplate,
  PagerTemplate,
  MainContentTemplate,
  CurrentFilterTemplate,
  LoadingTemplate,
  gmaps,
  nGeohash,
) => {
  "use strict";

  /**
   * @class DataCatalogView
   * @classcategory Views
   * @augments Backbone.View
   * @class
   * @deprecated
   * @description This view is deprecated and will eventually be removed in a future version (likely 3.0.0)
   */
  const DataCatalogView = Backbone.View.extend(
    /** @lends DataCatalogView.prototype */ {
      el: "#Content",

      isSubView: false,
      filters: true, // Turn on/off the filters in this view

      /**
       * If true, the view height will be adjusted to fit the height of the window
       * If false, the view height will be fixed via CSS
       * @type {boolean}
       */
      fixedHeight: false,

      // The default global models for searching
      searchModel: null,
      searchResults: null,
      statsModel: null,
      mapModel: null,

      /**
       * The templates for this view
       * @type {Underscore.template}
       */
      template: _.template(CatalogTemplate),
      statsTemplate: _.template(CountTemplate),
      pagerTemplate: _.template(PagerTemplate),
      mainContentTemplate: _.template(MainContentTemplate),
      currentFilterTemplate: _.template(CurrentFilterTemplate),
      loadingTemplate: _.template(LoadingTemplate),
      metricStatTemplate: _.template(
        "<span class='metric-icon'> <i class='icon" +
          " <%=metricIcon%>'></i> </span>" +
          "<span class='metric-value'> <i class='icon metric-icon'>" +
          "</i> </span>",
      ),

      // Search mode
      mode: "map",

      // Map settings and storage
      map: null,
      ready: false,
      allowSearch: true,
      hasZoomed: false,
      hasDragged: false,
      markers: {},
      tiles: [],
      tileCounts: [],

      /**
       * The general error message to show as a title in the error box when there
       * is an error fetching results from solr
       * @type {string}
       * @default "Something went wrong while getting the list of datasets"
       * @since 2.15.0
       */
      solrErrorTitle: "Something went wrong while getting the list of datasets",

      /**
       * The user-friendly text to show when a solr request gives a status 500
       * error. If none is provided, then the error message that is returned from
       * solr will be displayed.
       * @type {string}
       * @since 2.15.0
       */
      solrError500Message: null,

      // Contains the geohashes for all the markers on the map (if turned on in the Map model)
      markerGeohashes: [],
      // Contains all the info windows for all the markers on the map (if turned on in the Map model)
      markerInfoWindows: [],
      // Contains all the info windows for each document in the search result list - to display on hover
      tileInfoWindows: [],
      // Contains all the currently visible markers on the map
      resultMarkers: [],
      // The geohash value for each tile drawn on the map
      tileGeohashes: [],
      mapFilterToggle: ".toggle-map-filter",

      // Delegated events for creating new items, and clearing completed ones.
      events: {
        "click #results_prev": "prevpage",
        "click #results_next": "nextpage",
        "click #results_prev_bottom": "prevpage",
        "click #results_next_bottom": "nextpage",
        "click .pagerLink": "navigateToPage",
        "click .filter.btn": "updateTextFilters",
        "keypress input[type='text'].filter": "triggerOnEnter",
        "focus input[type='text'].filter": "getAutocompletes",
        "change #sortOrder": "triggerSearch",
        "change #min_year": "updateYearRange",
        "change #max_year": "updateYearRange",
        "click #publish_year": "updateYearRange",
        "click #data_year": "updateYearRange",
        "click .remove-filter": "removeFilter",
        "click input[type='checkbox'].filter": "updateBooleanFilters",
        "click #clear-all": "resetFilters",
        "click .remove-addtl-criteria": "removeAdditionalCriteria",
        "click .collapse-me": "collapse",
        "click .filter-contain .expand-collapse-control":
          "toggleFilterCollapse",
        "click #toggle-map": "toggleMapMode",
        "click .toggle-map": "toggleMapMode",
        "click .toggle-list": "toggleList",
        "click .toggle-map-filter": "toggleMapFilter",
        "mouseover .open-marker": "showResultOnMap",
        "mouseout .open-marker": "hideResultOnMap",
        "mouseover .prevent-popover-runoff": "preventPopoverRunoff",
      },

      initialize(options) {
        const view = this;

        // Get all the options and apply them to this view
        if (options) {
          const optionKeys = Object.keys(options);
          _.each(optionKeys, (key, i) => {
            view[key] = options[key];
          });
        }
      },

      // Render the main view and/or re-render subviews. Don't call .html() here
      // so we don't lose state, rather use .setElement(). Delegate rendering
      // and event handling to sub views
      render() {
        // Use the global models if there are no other models specified at time of render
        if (
          MetacatUI.appModel.get("searchHistory").length > 0 &&
          (!this.searchModel || Object.keys(this.searchModel).length == 0)
        ) {
          const lastSearchModels = _.last(
            MetacatUI.appModel.get("searchHistory"),
          );

          if (lastSearchModels) {
            if (lastSearchModels.search) {
              this.searchModel = lastSearchModels.search.clone();
            }

            if (lastSearchModels.map) {
              this.mapModel = lastSearchModels.map.clone();
            }
          }
        } else if (
          typeof MetacatUI.appSearchModel !== "undefined" &&
          (!this.searchModel || Object.keys(this.searchModel).length == 0)
        ) {
          this.searchModel = MetacatUI.appSearchModel;
          this.mapModel = MetacatUI.mapModel;
          this.statsModel = MetacatUI.statsModel;
        }

        if (!this.mapModel && gmaps) {
          this.mapModel = MetacatUI.mapModel;
        }

        if (
          (typeof this.searchResults === "undefined" ||
            !this.searchResults ||
            Object.keys(this.searchResults).length == 0) &&
          MetacatUI.appSearchResults &&
          Object.keys(MetacatUI.appSearchResults).length > 0
        ) {
          this.searchResults = MetacatUI.appSearchResults;

          if (!this.statsModel) {
            this.statsModel = MetacatUI.statsModel;
          }

          if (!this.mapModel) {
            this.mapModel = MetacatUI.mapModel;
          }
        }

        // Get the search mode - either "map" or "list"
        if (typeof this.mode === "undefined" || !this.mode) {
          this.mode = MetacatUI.appModel.get("searchMode");
          if (typeof this.mode === "undefined" || !this.mode) {
            this.mode = "map";
          }
          MetacatUI.appModel.set("searchMode", this.mode);
        }
        if ($(window).outerWidth() <= 600) {
          this.mode = "list";
          MetacatUI.appModel.set("searchMode", "list");
          gmaps = null;
        }

        if (!this.isSubView) {
          MetacatUI.appModel.set("headerType", "default");
          $("body").addClass("DataCatalog");
        } else {
          this.$el.addClass("DataCatalog");
        }

        // Populate the search template with some model attributes
        const loadingHTML = this.loadingTemplate({
          msg: "Retrieving member nodes...",
        });

        const templateVars = {
          gmaps,
          mode: MetacatUI.appModel.get("searchMode"),
          useMapBounds: this.searchModel.get("useGeohash"),
          username: MetacatUI.appUserModel.get("username"),
          isMySearch:
            _.indexOf(
              this.searchModel.get("username"),
              MetacatUI.appUserModel.get("username"),
            ) > -1,
          loading: loadingHTML,
          searchModelRef: this.searchModel,
          searchResultsRef: this.searchResults,
          dataSourceTitle:
            MetacatUI.theme == "dataone" ? "Member Node" : "Data source",
        };
        const cel = this.template(
          _.extend(this.searchModel.toJSON(), templateVars),
        );

        this.$el.html(cel);

        // Hide the filters that are disabled in the AppModel settings
        _.each(
          this.$(".filter-contain[data-category]"),
          (filterEl) => {
            if (
              !_.contains(
                MetacatUI.appModel.get("defaultSearchFilters"),
                $(filterEl).attr("data-category"),
              )
            ) {
              $(filterEl).hide();
            }
          },
          this,
        );

        // Store some references to key views that we use repeatedly
        this.$resultsview = this.$("#results-view");
        this.$results = this.$("#results");

        // Update stats
        this.updateStats();

        // Render the Google Map
        this.renderMap();

        // Initialize the tooltips
        const tooltips = $(".tooltip-this");

        // Find the tooltips that are on filter labels - add a slight delay to those
        const groupedTooltips = _.groupBy(
          tooltips,
          (t) =>
            ($(t).prop("tagName") == "LABEL" ||
              $(t).parent().prop("tagName") == "LABEL") &&
            $(t).parents(".filter-container").length > 0,
        );
        const forFilterLabel = true;
        const forOtherElements = false;

        $(groupedTooltips[forFilterLabel]).tooltip({
          delay: {
            show: "800",
          },
        });
        $(groupedTooltips[forOtherElements]).tooltip();

        // Initialize all popover elements
        $(".popover-this").popover();

        // Initialize the resizeable content div
        $("#content").resizable({
          handles: "n,s,e,w",
        });

        // Collapse the filters
        this.toggleFilterCollapse();

        // Iterate through each search model text attribute and show UI filter for each
        const categories = [
          "all",
          "attribute",
          "creator",
          "id",
          "taxon",
          "spatial",
          "additionalCriteria",
          "annotation",
          "isPrivate",
        ];
        let thisTerm = null;

        for (let i = 0; i < categories.length; i++) {
          thisTerm = this.searchModel.get(categories[i]);

          if (thisTerm === undefined || thisTerm === null) break;

          for (let x = 0; x < thisTerm.length; x++) {
            this.showFilter(categories[i], thisTerm[x]);
          }
        }

        // List the Member Node filters
        const view = this;
        _.each(
          _.contains(
            MetacatUI.appModel.get("defaultSearchFilters"),
            "dataSource",
          ),
          (source, i) => {
            view.showFilter("dataSource", source);
          },
        );

        // Add the custom query under the "Anything" filter
        if (this.searchModel.get("customQuery")) {
          this.showFilter("all", this.searchModel.get("customQuery"));
        }

        // Register listeners; this is done here in render because the HTML
        // needs to be bound before the listenTo call can be made
        this.stopListening(this.searchResults);
        this.stopListening(this.searchModel);
        this.stopListening(MetacatUI.appModel);
        this.listenTo(this.searchResults, "reset", this.cacheSearch);
        this.listenTo(this.searchResults, "add", this.addOne);
        this.listenTo(this.searchResults, "reset", this.addAll);
        this.listenTo(this.searchResults, "reset", this.checkForProv);
        this.listenTo(this.searchResults, "error", this.showError);

        // List data sources
        this.listDataSources();
        this.listenTo(
          MetacatUI.nodeModel,
          "change:members",
          this.listDataSources,
        );

        // listen to the MetacatUI.appModel for the search trigger
        this.listenTo(MetacatUI.appModel, "search", this.getResults);

        this.listenTo(
          MetacatUI.appUserModel,
          "change:loggedIn",
          this.triggerSearch,
        );

        // and go to a certain page if we have it
        this.getResults();

        // Set a custom height on any elements that have the .auto-height class
        if ($(".auto-height").length > 0 && !this.fixedHeight) {
          // Readjust the height whenever the window is resized
          $(window).resize(this.setAutoHeight);
          $(".auto-height-member").resize(this.setAutoHeight);
        }

        this.addAnnotationFilter();

        return this;
      },

      /**
       * addAnnotationFilter - Add the annotation filter to the view
       */
      addAnnotationFilter() {
        const view = this;
        if (!MetacatUI.appModel.get("bioportalAPIKey")) return;
        const containerSelector =
          "[data-category='annotation'] .expand-collapse-control + .filter-input-contain";
        const container = this.$el.find(containerSelector);
        if (!container) return;
        const annotationFilter = new BioontologySelectView({
          inputLabel: "",
          compact: true,
        }).render();
        container.append(annotationFilter.el);
        const bioModel = annotationFilter.model;
        this.stopListening(bioModel, "change:selected");
        this.listenTo(bioModel, "change:selected", (model, selected) => {
          const value = selected?.[0] || "";
          const option = model.get("options").getOptionByLabelOrValue(value);
          const filterLabel = option?.get("label") || "";
          const mockEvent = { target: annotationFilter.el };
          const item = { value, filterLabel };
          view.updateTextFilters(mockEvent, item);
        });
      },

      // Linked Data Object for appending the jsonld into the browser DOM
      getLinkedData() {
        // Find the MN info from the CN Node list
        const members = MetacatUI.nodeModel.get("members");
        for (let i = 0; i < members.length; i++) {
          if (
            members[i].identifier ==
            MetacatUI.nodeModel.get("currentMemberNode")
          ) {
            var nodeModelObject = members[i];
          }
        }

        // JSON Linked Data Object
        const elJSON = {
          "@context": {
            "@vocab": "http://schema.org/",
          },
          "@type": "DataCatalog",
        };
        if (nodeModelObject) {
          // "keywords": "",
          // "provider": "",
          const conditionalData = {
            description: nodeModelObject.description,
            identifier: nodeModelObject.identifier,
            image: nodeModelObject.logo,
            name: nodeModelObject.name,
            url: nodeModelObject.url,
          };
          $.extend(elJSON, conditionalData);
        }

        // Check if the jsonld already exists from the previous data view
        // If not create a new script tag and append otherwise replace the text for the script
        if (!document.getElementById("jsonld")) {
          const el = document.createElement("script");
          el.type = "application/ld+json";
          el.id = "jsonld";
          el.text = JSON.stringify(elJSON);
          document.querySelector("head").appendChild(el);
        } else {
          const script = document.getElementById("jsonld");
          script.text = JSON.stringify(elJSON);
        }
      },

      /*
       * Sets the height on elements in the main content area to fill up the entire area minus header and footer
       */
      setAutoHeight() {
        // If we are in list mode, don't determine the height of any elements because we are not "full screen"
        if (
          MetacatUI.appModel.get("searchMode") == "list" ||
          this.fixedHeight
        ) {
          MetacatUI.appView.$(".auto-height").height("auto");
          return;
        }

        // Get the heights of the header, navbar, and footer
        var otherHeight = 0;
        $(".auto-height-member").each((i, el) => {
          if ($(el).css("display") != "none") {
            otherHeight += $(el).outerHeight(true);
          }
        });

        // Get the remaining height left based on the window size
        let remainingHeight = $(window).outerHeight(true) - otherHeight;
        if (remainingHeight < 0)
          remainingHeight = $(window).outerHeight(true) || 300;
        else if (remainingHeight <= 120)
          remainingHeight =
            $(window).outerHeight(true) - remainingHeight || 300;

        // Adjust all elements with the .auto-height class
        $(".auto-height").height(remainingHeight);

        if (
          $("#map-container.auto-height").length > 0 &&
          $("#map-canvas").length > 0
        ) {
          var otherHeight = 0;
          $("#map-container.auto-height")
            .children()
            .each((i, el) => {
              if ($(el).attr("id") != "map-canvas") {
                otherHeight += $(el).outerHeight(true);
              }
            });
          const newMapHeight = remainingHeight - otherHeight;
          if (newMapHeight > 100) {
            $("#map-canvas").height(remainingHeight - otherHeight);
          }
        }

        // Trigger a resize for the map so that all of the map background images are loaded
        if (gmaps && this.mapModel && this.mapModel.get("map")) {
          google.maps.event.trigger(this.mapModel.get("map"), "resize");
        }
      },

      /*
       * ==================================================================================================
       *                                         PERFORMING SEARCH
       * ==================================================================================================
       */
      triggerSearch() {
        // Set the sort order
        const sortOrder = $("#sortOrder").val();
        if (sortOrder) {
          this.searchModel.set("sortOrder", sortOrder);
        }

        // Trigger a search to load the results
        MetacatUI.appModel.trigger("search");

        if (!this.isSubView) {
          // make sure the browser knows where we are
          const route = Backbone.history.fragment;
          if (route.indexOf("data") < 0) {
            MetacatUI.uiRouter.navigate("data", {
              trigger: false,
              replace: true,
            });
          } else {
            MetacatUI.uiRouter.navigate(route);
          }
        }

        // ...but don't want to follow links
        return false;
      },

      triggerOnEnter(e) {
        if (e.keyCode != 13) return;

        // Update the filters
        this.updateTextFilters(e);
      },

      /**
       * getResults gets all the current search filters from the searchModel, creates a Solr query, and runs that query.
       * @param {number} page - The page of search results to get results for
       */
      getResults(page) {
        // Set the sort order based on user choice
        const sortOrder = this.searchModel.get("sortOrder");
        if (sortOrder) {
          this.searchResults.setSort(sortOrder);
        }

        // Specify which fields to retrieve
        let fields = "";
        fields += "id,";
        fields += "seriesId,";
        fields += "title,";
        fields += "origin,";
        fields += "pubDate,";
        fields += "dateUploaded,";
        fields += "abstract,";
        fields += "resourceMap,";
        fields += "beginDate,";
        fields += "endDate,";
        fields += "read_count_i,";
        fields += "geohash_9,";
        fields += "datasource,";
        fields += "isPublic,";
        fields += "documents,";
        fields += "sem_annotation,";
        // Add spatial fields if the map is present
        if (gmaps) {
          fields += "northBoundCoord,";
          fields += "southBoundCoord,";
          fields += "eastBoundCoord,";
          fields += "westBoundCoord";
        }
        // Strip the last trailing comma if needed
        if (fields[fields.length - 1] === ",") {
          fields = fields.substr(0, fields.length - 1);
        }
        this.searchResults.setfields(fields);

        // Get the query
        const query = this.searchModel.getQuery();

        // Specify which facets to retrieve
        if (gmaps && this.map) {
          // If we have Google Maps enabled
          const geohashLevel = `geohash_${this.mapModel.determineGeohashLevel(this.map.zoom)}`;
          this.searchResults.facet.push(geohashLevel);
        }

        // Run the query
        this.searchResults.setQuery(query);

        // Get the page number
        if (this.isSubView) {
          var page = 0;
        } else {
          var page = MetacatUI.appModel.get("page");
          if (page == null) {
            page = 0;
          }
        }
        this.searchResults.start = page * this.searchResults.rows;

        // Show or hide the reset filters button
        this.toggleClearButton();

        // go to the page
        this.showPage(page);

        // don't want to follow links
        return false;
      },

      /*
       * After the search results have been returned,
       * check if any of them are derived data or have derivations
       */
      checkForProv() {
        let maps = [];
        let hasSources = [];
        let hasDerivations = [];
        const mainSearchResults = this.searchResults;

        // Get a list of all the resource map IDs from the SolrResults collection
        maps = this.searchResults.pluck("resourceMap");
        maps = _.compact(_.flatten(maps));

        // Create a new Search model with a search that finds all members of these packages/resource maps
        const provSearchModel = new SearchModel({
          formatType: [
            {
              value: "DATA",
              label: "data",
              description: null,
            },
          ],
          exclude: [],
          resourceMap: maps,
        });

        // Create a new Solr Results model to store the results of this supplemental query
        const provSearchResults = new SearchResults(null, {
          query: provSearchModel.getQuery(),
          searchLogs: false,
          usePOST: true,
          rows: 150,
          fields: `${provSearchModel.getProvFlList()},id,resourceMap`,
        });

        // Trigger a search on that Solr Results model
        this.listenTo(provSearchResults, "reset", (results) => {
          if (results.models.length == 0) return;

          // See if any of the results have a value for a prov field
          results.forEach((result) => {
            if (!result.getSources().length || !result.getDerivations()) return;
            _.each(result.get("resourceMap"), (rMapID) => {
              if (_.contains(maps, rMapID)) {
                const match = mainSearchResults.filter((mainSearchResult) =>
                  _.contains(mainSearchResult.get("resourceMap"), rMapID),
                );
                if (match && match.length && result.getSources().length > 0)
                  hasSources.push(match[0].get("id"));
                if (match && match.length && result.getDerivations().length > 0)
                  hasDerivations.push(match[0].get("id"));
              }
            });
          });

          // Filter out the duplicates
          hasSources = _.uniq(hasSources);
          hasDerivations = _.uniq(hasDerivations);

          // If they do, find their corresponding result row here and add
          // the prov icon (or just change the class to active)
          _.each(hasSources, (metadataID) => {
            const metadataDoc = mainSearchResults.findWhere({
              id: metadataID,
            });
            if (metadataDoc) {
              metadataDoc.set("prov_hasSources", true);
            }
          });
          _.each(hasDerivations, (metadataID) => {
            const metadataDoc = mainSearchResults.findWhere({
              id: metadataID,
            });
            if (metadataDoc) {
              metadataDoc.set("prov_hasDerivations", true);
            }
          });
        });
        provSearchResults.toPage(0);
      },

      cacheSearch() {
        MetacatUI.appModel.get("searchHistory").push({
          search: this.searchModel.clone(),
          map: this.mapModel ? this.mapModel.clone() : null,
        });
        MetacatUI.appModel.trigger("change:searchHistory");
      },

      /*
       * ==================================================================================================
       *                                             FILTERS
       * ==================================================================================================
       */
      updateCheckboxFilter(e, category, value) {
        if (!this.filters) return;

        const checkbox = e.target;
        const checked = $(checkbox).prop("checked");

        if (typeof category === "undefined")
          var category = $(checkbox).attr("data-category");
        if (typeof value === "undefined") var value = $(checkbox).attr("value");

        // If the user just unchecked the box, then remove this filter
        if (!checked) {
          this.searchModel.removeFromModel(category, value);
          this.hideFilter(category, value);
        }
        // If the user just checked the box, then add this filter
        else {
          const currentValue = this.searchModel.get(category);

          // Get the description
          let desc =
            $(checkbox).attr("data-description") || $(checkbox).attr("title");
          if (typeof desc === "undefined" || !desc) desc = "";
          // Get the label
          let labl = $(checkbox).attr("data-label");
          if (typeof labl === "undefined" || !labl) labl = "";

          // Make the filter object
          const filter = {
            description: desc,
            label: labl,
            value,
          };

          // If this filter category is an array, add this value to the array
          if (Array.isArray(currentValue)) {
            currentValue.push(filter);
            this.searchModel.set(category, currentValue);
            this.searchModel.trigger(`change:${category}`);
          } else {
            // If it isn't an array, then just update the model with a simple value
            this.searchModel.set(category, filter);
          }

          // Show the filter element
          this.showFilter(category, value, true, labl);

          // Show the reset button
          this.showClearButton();
        }

        // Route to page 1
        this.updatePageNumber(0);

        // Trigger a new search
        this.triggerSearch();
      },

      updateBooleanFilters(e) {
        if (!this.filters) return;

        // Get the category
        const checkbox = e.target;
        const category = $(checkbox).attr("data-category");
        const currentValue = this.searchModel.get(category);

        // If this filter is not enabled, exit this function
        if (
          !_.contains(MetacatUI.appModel.get("defaultSearchFilters"), category)
        ) {
          return false;
        }

        // The year filter is handled in a different way
        if (category == "pubYear" || category == "dataYear") return;

        // If the checkbox has a value, then update as a string value not boolean
        let value = $(checkbox).attr("value");
        if (value) {
          this.updateCheckboxFilter(e, category, value);
          return;
        }
        value = $(checkbox).prop("checked");

        this.searchModel.set(category, value);

        // Add the filter to the UI
        if (value) {
          this.showFilter(category, "", true);
        } else {
          // Remove the filter from the UI
          value = "";
          this.hideFilter(category, value);
        }

        // Show the reset button
        this.showClearButton();

        // Route to page 1
        this.updatePageNumber(0);

        // Trigger a new search
        this.triggerSearch();

        // Track this event
        MetacatUI.analytics?.trackEvent("search", `filter, ${category}`, value);
      },

      // Update the UI year slider and input values
      // Also update the model
      updateYearRange(e) {
        if (!this.filters) return;

        const viewRef = this;
        const userAction = !(typeof e === "undefined");
        const model = this.searchModel;
        const pubYearChecked = $("#publish_year").prop("checked");
        const dataYearChecked = $("#data_year").prop("checked");

        // If the year range slider has not been created yet
        if (!userAction && !$("#year-range").hasClass("ui-slider")) {
          var defaultMin =
            typeof this.searchModel.defaults === "function"
              ? this.searchModel.defaults().yearMin
              : 1800;
          var defaultMax =
            typeof this.searchModel.defaults === "function"
              ? this.searchModel.defaults().yearMax
              : new Date().getUTCFullYear();

          // jQueryUI slider
          $("#year-range").slider({
            range: true,
            disabled: false,
            min: defaultMin, // sets the minimum on the UI slider on initialization
            max: defaultMax, // sets the maximum on the UI slider on initialization
            values: [
              this.searchModel.get("yearMin"),
              this.searchModel.get("yearMax"),
            ], // where the left and right slider handles are
            stop(event, ui) {
              // When the slider is changed, update the input values
              $("#min_year").val(ui.values[0]);
              $("#max_year").val(ui.values[1]);

              // Also update the search model
              model.set("yearMin", ui.values[0]);
              model.set("yearMax", ui.values[1]);

              // If neither the publish year or data coverage year are checked
              if (
                !$("#publish_year").prop("checked") &&
                !$("#data_year").prop("checked")
              ) {
                // We want to check the data coverage year on the user's behalf
                $("#data_year").prop("checked", "true");

                // And update the search model
                model.set("dataYear", true);
              }

              // Add the filter elements
              if ($("#publish_year").prop("checked")) {
                viewRef.showFilter(
                  $("#publish_year").attr("data-category"),
                  true,
                  false,
                  `${ui.values[0]} to ${ui.values[1]}`,
                  {
                    replace: true,
                  },
                );
              }
              if ($("#data_year").prop("checked")) {
                viewRef.showFilter(
                  $("#data_year").attr("data-category"),
                  true,
                  false,
                  `${ui.values[0]} to ${ui.values[1]}`,
                  {
                    replace: true,
                  },
                );
              }

              // Route to page 1
              viewRef.updatePageNumber(0);

              // Trigger a new search
              viewRef.triggerSearch();
            },
          });

          // Get the minimum and maximum years of this current search and use those as the min and max values in the slider
          this.statsModel.set("query", this.searchModel.getQuery());
          this.listenTo(this.statsModel, "change:firstBeginDate", function () {
            if (
              this.statsModel.get("firstBeginDate") == 0 ||
              !this.statsModel.get("firstBeginDate")
            ) {
              $("#year-range").slider({
                min: defaultMin,
              });
              return;
            }
            const year = new Date(
              this.statsModel.get("firstBeginDate"),
            ).getUTCFullYear();
            if (typeof year !== "undefined") {
              $("#min_year").val(year);
              $("#year-range").slider({
                values: [year, $("#max_year").val()],
              });

              // If the slider min is still at the default value, then update with the min value found at this search
              if ($("#year-range").slider("option", "min") == defaultMin) {
                $("#year-range").slider({
                  min: year,
                });
              }

              // Add the filter elements if this is set
              if (viewRef.searchModel.get("pubYear")) {
                viewRef.showFilter(
                  "pubYear",
                  true,
                  false,
                  `${$("#min_year").val()} to ${$("#max_year").val()}`,
                  {
                    replace: true,
                  },
                );
              }
              if (viewRef.searchModel.get("dataYear")) {
                viewRef.showFilter(
                  "dataYear",
                  true,
                  false,
                  `${$("#min_year").val()} to ${$("#max_year").val()}`,
                  {
                    replace: true,
                  },
                );
              }
            }
          });
          // Only when the first begin date is retrieved, set the slider min and max values
          this.listenTo(this.statsModel, "change:lastEndDate", function () {
            if (
              this.statsModel.get("lastEndDate") == 0 ||
              !this.statsModel.get("lastEndDate")
            ) {
              $("#year-range").slider({
                max: defaultMax,
              });
              return;
            }
            const year = new Date(
              this.statsModel.get("lastEndDate"),
            ).getUTCFullYear();
            if (typeof year !== "undefined") {
              $("#max_year").val(year);
              $("#year-range").slider({
                values: [$("#min_year").val(), year],
              });

              // If the slider max is still at the default value, then update with the max value found at this search
              if ($("#year-range").slider("option", "max") == defaultMax) {
                $("#year-range").slider({
                  max: year,
                });
              }

              // Add the filter elements if this is set
              if (viewRef.searchModel.get("pubYear")) {
                viewRef.showFilter(
                  "pubYear",
                  true,
                  false,
                  `${$("#min_year").val()} to ${$("#max_year").val()}`,
                  {
                    replace: true,
                  },
                );
              }
              if (viewRef.searchModel.get("dataYear")) {
                viewRef.showFilter(
                  "dataYear",
                  true,
                  false,
                  `${$("#min_year").val()} to ${$("#max_year").val()}`,
                  {
                    replace: true,
                  },
                );
              }
            }
          });
          this.statsModel.getFirstBeginDate();
          this.statsModel.getLastEndDate();
        }
        // If the year slider has been created and the user initiated a new search using other filters
        else if (
          !userAction &&
          !this.searchModel.get("dataYear") &&
          !this.searchModel.get("pubYear")
        ) {
          // Reset the min and max year based on this search
          this.statsModel.set("query", this.searchModel.getQuery());
          this.statsModel.getFirstBeginDate();
          this.statsModel.getLastEndDate();
        }
        // If either of the year type selectors is what brought us here, then determine whether the user
        // is completely removing both (reset both year filters) or just one (remove just that one filter)
        else if (userAction) {
          // When both year types were unchecked, assume user wants to reset the year filter
          if (
            ($(e.target).attr("id") == "data_year" ||
              $(e.target).attr("id") == "publish_year") &&
            !pubYearChecked &&
            !dataYearChecked
          ) {
            // Reset the search model
            this.searchModel.set("yearMin", defaultMin);
            this.searchModel.set("yearMax", defaultMax);
            this.searchModel.set("dataYear", false);
            this.searchModel.set("pubYear", false);

            // Reset the min and max year based on this search
            this.statsModel.set("query", this.searchModel.getQuery());
            this.statsModel.getFirstBeginDate();
            this.statsModel.getLastEndDate();

            // Slide the handles back to the defaults
            $("#year-range").slider("values", [defaultMin, defaultMax]);

            // Hide the filters
            this.hideFilter("dataYear");
            this.hideFilter("pubYear");
          }
          // If either of the year inputs have changed or if just one of the year types were unchecked
          else {
            const minVal = $("#min_year").val();
            const maxVal = $("#max_year").val();

            // Update the search model to match what is in the text inputs
            this.searchModel.set("yearMin", minVal);
            this.searchModel.set("yearMax", maxVal);
            this.searchModel.set("dataYear", dataYearChecked);
            this.searchModel.set("pubYear", pubYearChecked);

            // If neither the publish year or data coverage year are checked
            if (!pubYearChecked && !dataYearChecked) {
              // We want to check the data coverage year on the user's behalf
              $("#data_year").prop("checked", "true");

              // And update the search model
              model.set("dataYear", true);

              // Add the filter elements
              this.showFilter(
                $("#data_year").attr("data-category"),
                true,
                true,
                `${minVal} to ${maxVal}`,
                {
                  replace: true,
                },
              );

              // Track this event
              MetacatUI.analytics?.trackEvent(
                "search",
                "filter, Data Year",
                `${minVal} to ${maxVal}`,
              );
            } else {
              // Add the filter elements
              if (pubYearChecked) {
                this.showFilter(
                  $("#publish_year").attr("data-category"),
                  true,
                  true,
                  `${minVal} to ${maxVal}`,
                  {
                    replace: true,
                  },
                );

                // Track this event
                MetacatUI.analytics?.trackEvent(
                  "search",
                  "filter, Publication Year",
                  `${minVal} to ${maxVal}`,
                );
              } else {
                this.hideFilter($("#publish_year").attr("data-category"), true);
              }

              if (dataYearChecked) {
                this.showFilter(
                  $("#data_year").attr("data-category"),
                  true,
                  true,
                  `${minVal} to ${maxVal}`,
                  {
                    replace: true,
                  },
                );

                // Track this event
                MetacatUI.analytics?.trackEvent(
                  "search",
                  "filter, Data Year",
                  `${minVal} to ${maxVal}`,
                );
              } else {
                this.hideFilter($("#data_year").attr("data-category"), true);
              }
            }
          }

          // Route to page 1
          this.updatePageNumber(0);

          // Trigger a new search
          this.triggerSearch();
        }
      },

      updateTextFilters(e, item) {
        if (!this.filters) return;

        // Get the search/filter category
        let category = $(e.target).attr("data-category");

        // Try the parent elements if not found
        if (!category) {
          const parents = $(e.target)
            .parents()
            .each(function () {
              category = $(this).attr("data-category");
              if (category) {
                return false;
              }
            });
        }

        if (!category) {
          return false;
        }

        // Get the input element
        const input = this.$el.find(`#${category}_input`);

        // Get the value of the associated input
        const term = !item || !item.value ? input.val() : item.value;
        const label = !item || !item.filterLabel ? null : item.filterLabel;
        const filterDesc = !item || !item.desc ? null : item.desc;

        // Check that something was actually entered
        if (term == "" || term == " ") {
          return false;
        }

        // Close the autocomplete box
        if (e.type == "hoverautocompleteselect") {
          $(input).hoverAutocomplete("close");
        } else if ($(input).data("ui-autocomplete") != undefined) {
          // If the autocomplete has been initialized, then close it
          $(input).autocomplete("close");
        }

        // Get the current searchModel array for this category
        const filtersArray = _.clone(this.searchModel.get(category));

        if (typeof filtersArray === "undefined") {
          console.error(
            `The filter category '${category}' does not exist in the Search model. Not sending this search term.`,
          );
          return false;
        }

        // Check if this entry is a duplicate
        const duplicate = (function () {
          for (let i = 0; i < filtersArray.length; i++) {
            if (filtersArray[i].value === term) {
              return true;
            }
          }
        })();

        if (duplicate) {
          // Display a quick message
          if ($(`#duplicate-${category}-alert`).length <= 0) {
            $(`#current-${category}-filters`).prepend(
              "<div class='alert alert-block' id='duplicate-' + category + '-alert'>" +
                "You are already using that filter" +
                "</div>",
            );

            $(`#duplicate-${category}-alert`)
              .delay(2000)
              .fadeOut(500, function () {
                this.remove();
              });
          }

          return false;
        }

        // Add the new entry to the array of current filters
        const filter = {
          value: term,
          filterLabel: label,
          label,
          description: filterDesc,
        };
        filtersArray.push(filter);

        // Replace the current array with the new one in the search model
        this.searchModel.set(category, filtersArray);

        // Show the UI filter
        this.showFilter(category, filter, false, label);

        // Clear the input
        input.val("");

        // Route to page 1
        this.updatePageNumber(0);

        // Trigger a new search
        this.triggerSearch();

        // Track this event
        MetacatUI.analytics?.trackEvent("search", `filter, ${category}`, term);
      },

      // Removes a specific filter term from the searchModel
      removeFilter(e) {
        // Get the parent element that stores the filter term
        const filterNode = $(e.target).parent();

        // Find this filter's category and value
        const category =
          filterNode.attr("data-category") ||
          filterNode.parent().attr("data-category");
        const value = $(filterNode).attr("data-term");

        // Remove this filter from the searchModel
        this.searchModel.removeFromModel(category, value);

        // Hide the filter from the UI
        this.hideFilter(category, value);

        // If there is an associated checkbox with this filter, uncheck it
        let assocCheckbox;
        const checkboxes = this.$(
          `input[type='checkbox'][data-category='${category}']`,
        );

        // If there are more than one checkboxes in this category, match by value
        if (checkboxes.length > 1) {
          assocCheckbox = _.find(
            checkboxes,
            (checkbox) => $(checkbox).val() == value,
          );
        }
        // If there is only one checkbox in this category, default to it
        else if (checkboxes.length == 1) {
          assocCheckbox = checkboxes[0];
        }

        // If there is an associated checkbox, uncheck it
        if (assocCheckbox) {
          // Uncheck it
          $(assocCheckbox).prop("checked", false);
        }

        // Route to page 1
        this.updatePageNumber(0);

        // Trigger a new search
        this.triggerSearch();
      },

      // Clear all the currently applied filters
      resetFilters() {
        const viewRef = this;

        this.allowSearch = true;

        // Hide all the filters in the UI
        $.each(this.$(".current-filter"), function () {
          viewRef.hideEl(this);
        });

        // Hide the clear button
        this.hideClearButton();

        // Then reset the model
        this.searchModel.clear();

        // Reset the map model
        if (this.mapModel) {
          this.mapModel.clear();
        }

        // Reset the year slider handles
        $("#year-range").slider("values", [
          this.searchModel.get("yearMin"),
          this.searchModel.get("yearMax"),
        ]);
        // and the year inputs
        $("#min_year").val(this.searchModel.get("yearMin"));
        $("#max_year").val(this.searchModel.get("yearMax"));

        // Reset the checkboxes
        $("#includes_data").prop("checked", this.searchModel.get("documents"));
        $("#data_year").prop("checked", this.searchModel.get("dataYear"));
        $("#publish_year").prop("checked", this.searchModel.get("pubYear"));
        $("#is_private_data").prop(
          "checked",
          this.searchModel.get("isPrivate"),
        );
        this.listDataSources();

        // Zoom out the Google Map
        this.resetMap();
        this.renderMap();

        // Route to page 1
        this.updatePageNumber(0);

        // Trigger a new search
        this.triggerSearch();
      },

      hideEl(element) {
        // Fade out and remove the element
        $(element).fadeOut("slow", () => {
          $(element).remove();
        });
      },

      // Removes a specified filter node from the DOM
      hideFilter(category, value) {
        if (!this.filters) return;

        if (typeof value === "undefined") {
          var filterNode = this.$(
            `.current-filters[data-category='${category}']`,
          ).children(".current-filter");
        } else {
          var filterNode = this.$(
            `.current-filters[data-category='${category}']`,
          ).children(`[data-term='${value}']`);
        }

        // Try finding it a different way
        if (!filterNode || !filterNode.length) {
          filterNode = this.$(`.current-filter[data-category='${category}']`);
        }

        // Remove the filter node from the DOM
        this.hideEl(filterNode);
      },

      // Adds a specified filter node to the DOM
      showFilter(category, term, checkForDuplicates, label, options) {
        if (!this.filters) return;

        const viewRef = this;

        if (typeof term === "undefined") return false;

        // Get the element to add the UI filter node to
        // The pattern is #current-<category>-filters
        const filterContainer = this.$el.find(`#current-${category}-filters`);

        // Allow the option to only display this exact filter category and term once to the DOM
        // Helpful when adding a filter that is not stored in the search model (for display only)
        if (checkForDuplicates) {
          let duplicate = false;

          // Get the current terms from the DOM and check against the new term
          filterContainer.children().each(function () {
            if ($(this).attr("data-term") == term) {
              duplicate = true;
            }
          });

          // If there is a duplicate, exit without adding it
          if (duplicate) {
            return;
          }
        }

        let value = null;
        let desc = null;

        // See if this filter is an object and extract the filter attributes
        if (typeof term === "object") {
          if (typeof term.description !== "undefined") {
            desc = term.description;
          }
          if (typeof term.filterLabel !== "undefined") {
            label = term.filterLabel;
          } else if (typeof term.label !== "undefined" && term.label) {
            label = term.label;
          } else {
            label = null;
          }
          if (typeof term.value !== "undefined") {
            value = term.value;
          }
        } else {
          value = term;

          // Find the filter label
          if (typeof label === "undefined" || !label) {
            // Use the filter value for the label, sans any leading # character
            if (value.indexOf("#") > 0) {
              label = value.substring(value.indexOf("#"));
            }
          }

          desc = label;
        }

        let categoryLabel = this.searchModel.fieldLabels[category];
        if (
          typeof categoryLabel === "undefined" &&
          category == "additionalCriteria"
        )
          categoryLabel = "";
        if (typeof categoryLabel === "undefined") categoryLabel = category;

        // Add a filter node to the DOM
        const filterEl = viewRef.currentFilterTemplate({
          category: Utilities.encodeHTML(categoryLabel),
          value: Utilities.encodeHTML(value),
          label: Utilities.encodeHTML(label),
          description: Utilities.encodeHTML(desc),
        });

        // Add the filter to the page - either replace or tack on
        if (options && options.replace) {
          const currentFilter = filterContainer.find(".current-filter");
          if (currentFilter.length > 0) {
            currentFilter.replaceWith(filterEl);
          } else {
            filterContainer.prepend(filterEl);
          }
        } else {
          filterContainer.prepend(filterEl);
        }

        // Tooltips and Popovers
        $(filterEl).tooltip({
          delay: {
            show: 800,
          },
        });
      },

      /*
       * Get the member node list from the model and list the members in the filter list
       */
      listDataSources() {
        if (!this.filters) return;

        if (MetacatUI.nodeModel.get("members").length < 1) return;

        // Get the member nodes
        const members = _.sortBy(MetacatUI.nodeModel.get("members"), (m) => {
          if (m.name) {
            return m.name.toLowerCase();
          }
          return "";
        });
        const filteredMembers = _.reject(
          members,
          (m) => m.status != "operational",
        );

        // Get the current search filters for data source
        const currentFilters = this.searchModel.get("dataSource");

        // Create an HTML list
        const listMax = 4;
        const numHidden = filteredMembers.length - listMax;
        const list = $(document.createElement("ul")).addClass("checkbox-list");

        // Add a checkbox and label for each member node in the node model
        _.each(filteredMembers, (member, i) => {
          const listItem = document.createElement("li");
          const input = document.createElement("input");
          const label = document.createElement("label");

          // If this member node is already a data source filter, then the checkbox is checked
          const checked = !!_.findWhere(currentFilters, {
            value: member.identifier,
          });

          // Create a textual label for this data source
          $(label)
            .addClass("ellipsis")
            .attr("for", member.identifier)
            .html(member.name);

          // Create a checkbox for this data source
          $(input)
            .addClass("filter")
            .attr("type", "checkbox")
            .attr("data-category", "dataSource")
            .attr("id", member.identifier)
            .attr("name", "dataSource")
            .attr("value", member.identifier)
            .attr("data-label", member.name)
            .attr("data-description", member.description);

          // Add tooltips to the label element
          $(label).tooltip({
            placement: "top",
            delay: {
              show: 900,
            },
            trigger: "hover",
            viewport: "#sidebar",
            title: member.description,
          });

          // If this data source is already selected as a filter (from the search model), then check the checkbox
          if (checked) $(input).prop("checked", "checked");

          // Collapse some of the checkboxes and labels after a certain amount
          if (i > listMax - 1) {
            $(listItem).addClass("hidden");
          }

          // Insert a "More" link after a certain amount to enable users to expand the list
          if (i == listMax) {
            const moreLink = document.createElement("a");
            $(moreLink)
              .html(`Show ${numHidden} more`)
              .addClass("more-link pointer toggle-list")
              .append(
                $(document.createElement("i")).addClass("icon icon-expand-alt"),
              );
            $(list).append(moreLink);
          }

          // Add this checkbox and laebl to the list
          $(listItem).append(input).append(label);
          $(list).append(listItem);
        });

        if (numHidden > 0) {
          const lessLink = document.createElement("a");
          $(lessLink)
            .html("Collapse member nodes")
            .addClass("less-link toggle-list pointer hidden")
            .append(
              $(document.createElement("i")).addClass("icon icon-collapse-alt"),
            );

          $(list).append(lessLink);
        }

        // Add the list of checkboxes to the placeholder
        const container = $(".member-nodes-placeholder");
        $(container).html(list);
        $(".tooltip-this").tooltip();
      },

      resetDataSourceList() {
        if (!this.filters) return;

        // Reset the Member Nodes checkboxes
        const mnFilterContainer = $("#member-nodes-container");
        const defaultMNs = this.searchModel.get("dataSource");

        // Make sure the member node filter exists
        if (!mnFilterContainer || mnFilterContainer.length == 0) return false;
        if (typeof defaultMNs === "undefined" || !defaultMNs) return false;

        // Reset each member node checkbox
        const boxes = $(mnFilterContainer)
          .find(".filter")
          .prop("checked", false);

        // Check the member node checkboxes that are defaults in the search model
        _.each(defaultMNs, (member, i) => {
          let value = null;

          // Allow for string search model filter values and object filter values
          if (typeof member !== "object" && member) value = member;
          else if (typeof member.value === "undefined" || !member.value)
            value = "";
          else value = member.value;

          $(mnFilterContainer)
            .find(`checkbox[value='${value}']`)
            .prop("checked", true);
        });

        return true;
      },

      toggleList(e) {
        if (!this.filters) return;

        const link = e.target;
        const controls = $(link).parents("ul").find(".toggle-list");
        const list = $(link).parents("ul");
        const isHidden = !list.find(".more-link").is(".hidden");

        // Hide/Show the list
        if (isHidden) {
          list.children("li").slideDown();
        } else {
          list.children("li.hidden").slideUp();
        }

        // Hide/Show the control links
        controls.toggleClass("hidden");
      },

      // add additional criteria to the search model based on link click
      additionalCriteria(e) {
        // Get the clicked node
        const targetNode = $(e.target);

        // If this additional criteria is already applied, remove it
        if (targetNode.hasClass("active")) {
          this.removeAdditionalCriteria(e);
          return false;
        }

        // Get the filter criteria
        const term = targetNode.attr("data-term");

        // Find this element's category in the data-category attribute
        const category = targetNode.attr("data-category");

        // style the selection
        $(".keyword-search-link").removeClass("active");
        $(".keyword-search-link").parent().removeClass("active");
        targetNode.addClass("active");
        targetNode.parent().addClass("active");

        // Add this criteria to the search model
        this.searchModel.set(category, [term]);

        // Trigger the search
        this.triggerSearch();

        // prevent default action of click
        return false;
      },

      removeAdditionalCriteria(e) {
        // Get the clicked node
        const targetNode = $(e.target);

        // Reference to model
        const model = this.searchModel;

        // remove the styling
        $(".keyword-search-link").removeClass("active");
        $(".keyword-search-link").parent().removeClass("active");

        // Get the term
        const term = targetNode.attr("data-term");

        // Get the current search model additional criteria
        const current = this.searchModel.get("additionalCriteria");
        // If this term is in the current search model (should be)...
        if (_.contains(current, term)) {
          // then remove it
          const newTerms = _.without(current, term);
          model.set("additionalCriteria", newTerms);
        }

        // Route to page 1
        this.updatePageNumber(0);

        // Trigger a new search
        this.triggerSearch();
      },

      // Get the facet counts
      getAutocompletes(e) {
        if (!e) return;

        // Get the text input to determine the filter type
        const input = $(e.target);
        const category = input.attr("data-category");

        if (!this.filters || !category) return;

        const viewRef = this;

        // Create the facet query by using our current search query
        const facetQuery = `q=${
          this.searchResults.currentquery
        }&rows=0${this.searchModel.getFacetQuery(category)}&wt=json&`;

        // If we've cached these filter results, then use the cache instead of sending a new request
        if (!MetacatUI.appSearchModel.autocompleteCache)
          MetacatUI.appSearchModel.autocompleteCache = {};
        else if (MetacatUI.appSearchModel.autocompleteCache[facetQuery]) {
          this.setupAutocomplete(
            input,
            MetacatUI.appSearchModel.autocompleteCache[facetQuery],
          );
          return;
        }

        // Get the facet counts for the autocomplete
        const requestSettings = {
          url: MetacatUI.appModel.get("queryServiceUrl") + facetQuery,
          type: "GET",
          dataType: "json",
          success(data, textStatus, xhr) {
            let suggestions = [];
            const facetLimit = 999;

            // Get all the facet counts
            _.each(category.split(","), (c) => {
              if (typeof c === "string") c = [c];
              _.each(c, (thisCategory) => {
                // Get the field name(s)
                let fieldNames =
                  MetacatUI.appSearchModel.facetNameMap[thisCategory];
                if (typeof fieldNames === "string") fieldNames = [fieldNames];

                // Get the facet counts
                _.each(fieldNames, (fieldName) => {
                  suggestions.push(data.facet_counts.facet_fields[fieldName]);
                });
              });
            });
            suggestions = _.flatten(suggestions);

            // Format the suggestions
            const rankedSuggestions = new Array();
            for (
              let i = 0;
              i < Math.min(suggestions.length - 1, facetLimit);
              i += 2
            ) {
              // The label is the item value
              let label = suggestions[i];

              // For all categories except the 'all' category, display the facet count
              if (category != "all") {
                label += ` (${suggestions[i + 1]})`;
              }

              // Push the autocomplete item to the array
              rankedSuggestions.push({
                value: suggestions[i],
                label,
              });
            }

            // Save these facets in the app so we don't have to send another query
            MetacatUI.appSearchModel.autocompleteCache[facetQuery] =
              rankedSuggestions;

            // Now setup the actual autocomplete menu
            viewRef.setupAutocomplete(input, rankedSuggestions);
          },
        };
        $.ajax(
          _.extend(
            requestSettings,
            MetacatUI.appUserModel.createAjaxSettings(),
          ),
        );
      },

      setupAutocomplete(input, rankedSuggestions) {
        const viewRef = this;

        // Override the _renderItem() function which renders a single autocomplete item.
        // We want to use the 'title' HTML attribute on each item.
        // This method must create a new <li> element, append it to the menu, and return it.
        $.widget("custom.autocomplete", $.ui.autocomplete, {
          _renderItem(ul, item) {
            return $(document.createElement("li"))
              .attr("title", item.label)
              .append(item.label)
              .appendTo(ul);
          },
        });
        input.autocomplete({
          source(request, response) {
            const term = $.ui.autocomplete.escapeRegex(request.term);
            const startsWithMatcher = new RegExp(`^${term}`, "i");
            const startsWith = $.grep(rankedSuggestions, (value) =>
              startsWithMatcher.test(value.label || value.value || value),
            );
            const containsMatcher = new RegExp(term, "i");
            const contains = $.grep(
              rankedSuggestions,
              (value) =>
                $.inArray(value, startsWith) < 0 &&
                containsMatcher.test(value.label || value.value || value),
            );

            response(startsWith.concat(contains));
          },
          select(event, ui) {
            // set the text field
            input.val(ui.item.value);
            // add to the filter immediately
            viewRef.updateTextFilters(event, ui.item);
            // prevent default action
            return false;
          },
          position: {
            my: "left top",
            at: "left bottom",
            collision: "flipfit",
          },
        });
      },

      hideClearButton() {
        if (!this.filters) return;

        // Hide the current filters panel
        this.$(".current-filters-container").slideUp();

        // Hide the reset button
        $("#clear-all").addClass("hidden");
        this.setAutoHeight();
      },

      showClearButton() {
        if (!this.filters) return;

        // Show the current filters panel
        if (
          _.difference(
            this.searchModel.getCurrentFilters(),
            this.searchModel.spatialFilters,
          ).length > 0
        ) {
          this.$(".current-filters-container").slideDown();
        }

        // Show the reset button
        $("#clear-all").removeClass("hidden");
        this.setAutoHeight();
      },

      /*
       * ==================================================================================================
       *                                             NAVIGATING THE UI
       * ==================================================================================================
       */
      // Update all the statistics throughout the page
      updateStats() {
        if (this.searchResults.header != null) {
          this.$statcounts = this.$("#statcounts");
          this.$statcounts.html(
            this.statsTemplate({
              start: this.searchResults.header.get("start") + 1,
              end:
                this.searchResults.header.get("start") +
                this.searchResults.length,
              numFound: this.searchResults.header.get("numFound"),
            }),
          );
        }

        // piggy back here
        this.updatePager();
      },

      updatePager() {
        if (this.searchResults.header != null) {
          const pageCount = Math.ceil(
            this.searchResults.header.get("numFound") /
              this.searchResults.header.get("rows"),
          );

          // If no results were found, display a message instead of the list and clear the pagination.
          if (pageCount == 0) {
            this.$results.html(
              "<p id='no-results-found'>No results found.</p>",
            );

            this.$("#resultspager").html("");
            this.$(".resultspager").html("");
          }
          // Do not display the pagination if there is only one page
          else if (pageCount == 1) {
            this.$("#resultspager").html("");
            this.$(".resultspager").html("");
          } else {
            const pages = new Array(pageCount);

            // mark current page correctly, avoid NaN
            let currentPage = -1;
            try {
              currentPage = Math.floor(
                (this.searchResults.header.get("start") /
                  this.searchResults.header.get("numFound")) *
                  pageCount,
              );
            } catch (ex) {
              console.log(`Exception when calculating pages:${ex.message}`);
            }

            // Populate the pagination element in the UI
            this.$(".resultspager").html(
              this.pagerTemplate({
                pages,
                currentPage,
              }),
            );
            this.$("#resultspager").html(
              this.pagerTemplate({
                pages,
                currentPage,
              }),
            );
          }
        }
      },

      updatePageNumber(page) {
        MetacatUI.appModel.set("page", page);

        if (!this.isSubView) {
          let route = Backbone.history.fragment;
          const subroutePos = route.indexOf("/page/");
          const newPage = parseInt(page) + 1;

          // replace the last number with the new one
          if (page > 0 && subroutePos > -1) {
            route = route.replace(/\d+$/, newPage);
          } else if (page > 0) {
            route += `/page/${newPage}`;
          } else if (subroutePos >= 0) {
            route = route.substring(0, subroutePos);
          }

          MetacatUI.uiRouter.navigate(route);
        }
      },

      // Next page of results
      nextpage() {
        this.loading();
        this.searchResults.nextpage();
        this.$resultsview.show();
        this.updateStats();

        let page = MetacatUI.appModel.get("page");
        page++;
        this.updatePageNumber(page);
      },

      // Previous page of results
      prevpage() {
        this.loading();
        this.searchResults.prevpage();
        this.$resultsview.show();
        this.updateStats();

        let page = MetacatUI.appModel.get("page");
        page--;
        this.updatePageNumber(page);
      },

      navigateToPage(event) {
        const page = $(event.target).attr("page");
        this.showPage(page);
      },

      showPage(page) {
        this.loading();
        this.searchResults.toPage(page);
        this.$resultsview.show();
        this.updateStats();
        this.updatePageNumber(page);
        this.updateYearRange();
      },

      /*
       * ==================================================================================================
       *                                             THE MAP
       * ==================================================================================================
       */
      renderMap() {
        // If gmaps isn't enabled or loaded with an error, use list mode
        if (!gmaps || this.mode == "list") {
          this.ready = true;
          this.mode = "list";
          return;
        }

        if (this.isSubView) {
          this.$el.addClass("mapMode");
        } else {
          $("body").addClass("mapMode");
        }

        // Get the map options and create the map
        gmaps.visualRefresh = true;
        const mapOptions = this.mapModel.get("mapOptions");
        const defaultZoom = mapOptions.zoom;
        $("#map-container").append("<div id='map-canvas'></div>");
        this.map = new gmaps.Map($("#map-canvas")[0], mapOptions);
        this.mapModel.set("map", this.map);
        this.hasZoomed = false;
        this.hasDragged = false;

        // Hide the map filter toggle element
        this.$(this.mapFilterToggle).hide();

        // Store references
        const mapRef = this.map;
        const viewRef = this;

        google.maps.event.addListener(mapRef, "zoom_changed", () => {
          // If the map is zoomed in further than the default zoom level,
          // than we want to mark the map as zoomed in
          if (viewRef.map.getZoom() > defaultZoom) {
            viewRef.hasZoomed = true;
          }
          // If we are at the default zoom level or higher, than do not mark the map
          // as zoomed in
          else {
            viewRef.hasZoomed = false;
          }
        });

        google.maps.event.addListener(mapRef, "dragend", () => {
          viewRef.hasDragged = true;
        });

        google.maps.event.addListener(mapRef, "idle", () => {
          // Remove all markers from the map
          for (let i = 0; i < viewRef.resultMarkers.length; i++) {
            viewRef.resultMarkers[i].setMap(null);
          }
          viewRef.resultMarkers = new Array();

          // Check if the user has interacted with the map just now, and if so, we
          // want to alter the geohash filter (changing the geohash values or resetting it completely)
          const alterGeohashFilter =
            viewRef.allowSearch || viewRef.hasZoomed || viewRef.hasDragged;
          if (!alterGeohashFilter) {
            return;
          }

          // Determine if the map needs to be recentered. The map only needs to be
          // recentered if it is not at the default lat,long center point AND it
          // is not zoomed in or dragged to a new center point
          const setGeohashFilter =
            viewRef.hasZoomed && viewRef.isMapFilterEnabled();

          // If we are using the geohash filter defined by this map, then
          // apply the filter and trigger a new search
          if (setGeohashFilter) {
            viewRef.$(viewRef.mapFilterToggle).show();

            // Get the Google map bounding box
            const boundingBox = mapRef.getBounds();

            // Set the search model spatial filters
            // Encode the Google Map bounding box into geohash
            const north = boundingBox.getNorthEast().lat();
            const west = boundingBox.getSouthWest().lng();
            const south = boundingBox.getSouthWest().lat();
            const east = boundingBox.getNorthEast().lng();

            viewRef.searchModel.set("north", north);
            viewRef.searchModel.set("west", west);
            viewRef.searchModel.set("south", south);
            viewRef.searchModel.set("east", east);

            // Save the center position and zoom level of the map
            viewRef.mapModel.get("mapOptions").center = mapRef.getCenter();
            viewRef.mapModel.get("mapOptions").zoom = mapRef.getZoom();

            // Determine the precision of geohashes to search for
            const zoom = mapRef.getZoom();

            const precision = viewRef.mapModel.getSearchPrecision(zoom);

            // Get all the geohash tiles contained in the map bounds
            const geohashBBoxes = nGeohash.bboxes(
              south,
              west,
              north,
              east,
              precision,
            );

            // Save our geohash search settings
            viewRef.searchModel.set("geohashes", geohashBBoxes);
            viewRef.searchModel.set("geohashLevel", precision);

            // Start back at page 0
            MetacatUI.appModel.set("page", 0);

            // Mark the view as ready to start a search
            viewRef.ready = true;

            // Trigger a new search
            viewRef.triggerSearch();

            viewRef.allowSearch = false;
          } else {
            // Reset the map filter
            viewRef.resetMap();

            // Start back at page 0
            MetacatUI.appModel.set("page", 0);

            // Mark the view as ready to start a search
            viewRef.ready = true;

            // Trigger a new search
            viewRef.triggerSearch();

            viewRef.allowSearch = false;
          }
        });
      },

      // Resets the model and view settings related to the map
      resetMap() {
        if (!gmaps) {
          return;
        }

        // First reset the model
        // The categories pertaining to the map
        const categories = ["east", "west", "north", "south"];

        // Loop through each and remove the filters from the model
        for (let i = 0; i < categories.length; i++) {
          this.searchModel.set(categories[i], null);
        }

        // Reset the map settings
        this.searchModel.resetGeohash();
        this.mapModel.set("mapOptions", this.mapModel.defaults().mapOptions);

        this.allowSearch = false;
      },

      isMapFilterEnabled() {
        const toggleInput = this.$(`input${this.mapFilterToggle}`);
        if (typeof toggleInput === "undefined" || !toggleInput) return;

        return $(toggleInput).prop("checked");
      },

      toggleMapFilter(e, a) {
        const toggleInput = this.$(`input${this.mapFilterToggle}`);
        if (typeof toggleInput === "undefined" || !toggleInput) return;

        let isOn = $(toggleInput).prop("checked");

        // If the user clicked on the label, then change the checkbox for them
        if (e.target.tagName != "INPUT") {
          isOn = !isOn;
          toggleInput.prop("checked", isOn);
        }

        google.maps.event.trigger(this.mapModel.get("map"), "idle");

        // Track this event
        MetacatUI.analytics?.trackEvent("map", isOn ? "on" : "off");
      },

      /**
       * Show the marker, infoWindow, and bounding coordinates polygon on
             the map when the user hovers on the marker icon in the result list
       * @param {Event} e
       */
      showResultOnMap(e) {
        // Exit if maps are not in use
        if (this.mode != "map" || !gmaps) {
          return false;
        }

        // Get the attributes about this dataset
        let resultRow = e.target;
        let id = $(resultRow).attr("data-id");
        // The mouseover event might be triggered by a nested element, so loop through the parents to find the id
        if (typeof id === "undefined") {
          $(resultRow)
            .parents()
            .each(function () {
              if (typeof $(this).attr("data-id") !== "undefined") {
                id = $(this).attr("data-id");
                resultRow = this;
              }
            });
        }

        // Find the tile for this data set and highlight it on the map
        const resultGeohashes = this.searchResults
          .findWhere({
            id,
          })
          .get("geohash_9");
        for (let i = 0; i < resultGeohashes.length; i++) {
          var thisGeohash = resultGeohashes[i];
          const latLong = nGeohash.decode(thisGeohash);
          const position = new google.maps.LatLng(
            latLong.latitude,
            latLong.longitude,
          );
          const containingTileGeohash = _.find(
            this.tileGeohashes,
            (g) => thisGeohash.indexOf(g) == 0,
          );
          const containingTile = _.findWhere(this.tiles, {
            geohash: containingTileGeohash,
          });

          // If this is a geohash for a georegion outside the map, do not highlight a tile or display a marker
          if (typeof containingTile === "undefined") continue;

          this.highlightTile(containingTile);

          // Set up the options for each marker
          const markerOptions = {
            position,
            icon: this.mapModel.get("markerImage"),
            zIndex: 99999,
            map: this.map,
          };

          // Create the marker and add to the map
          const marker = new google.maps.Marker(markerOptions);

          this.resultMarkers.push(marker);
        }
      },

      /**
       * Hide the marker, infoWindow, and bounding coordinates polygon on
             the map when the user stops hovering on the marker icon in the result list
       * @param {Event} e - The event that brought us to this function
       */
      hideResultOnMap(e) {
        // Exit if maps are not in use
        if (this.mode != "map" || !gmaps) {
          return false;
        }

        // Get the attributes about this dataset
        let resultRow = e.target;
        let id = $(resultRow).attr("data-id");
        // The mouseover event might be triggered by a nested element, so loop through the parents to find the id
        if (typeof id === "undefined") {
          $(e.target)
            .parents()
            .each(function () {
              if (typeof $(this).attr("data-id") !== "undefined") {
                id = $(this).attr("data-id");
                resultRow = this;
              }
            });
        }

        // Get the map tile for this result and un-highlight it
        const resultGeohashes = this.searchResults
          .findWhere({
            id,
          })
          .get("geohash_9");
        for (let i = 0; i < resultGeohashes.length; i++) {
          var thisGeohash = resultGeohashes[i];
          const containingTileGeohash = _.find(
            this.tileGeohashes,
            (g) => thisGeohash.indexOf(g) == 0,
          );
          const containingTile = _.findWhere(this.tiles, {
            geohash: containingTileGeohash,
          });

          // If this is a geohash for a georegion outside the map, do not unhighlight a tile
          if (typeof containingTile === "undefined") continue;

          // Unhighlight the tile
          this.unhighlightTile(containingTile);
        }

        // Remove all markers from the map
        _.each(this.resultMarkers, (marker) => {
          marker.setMap(null);
        });
        this.resultMarkers = new Array();
      },

      /**
       * Create a tile for each geohash facet. A separate tile label is added to the map with the count of the facet.
       */
      drawTiles() {
        // Exit if maps are not in use
        if (this.mode != "map" || !gmaps) {
          return false;
        }

        TextOverlay.prototype = new google.maps.OverlayView();

        /**
         *
         * @param options
         */
        function TextOverlay(options) {
          // Now initialize all properties.
          this.bounds_ = options.bounds;
          this.map_ = options.map;
          this.text = options.text;
          this.color = options.color;

          const { length } = options.text.toString();
          if (length == 1) this.width = 8;
          else if (length == 2) this.width = 17;
          else if (length == 3) this.width = 25;
          else if (length == 4) this.width = 32;
          else if (length == 5) this.width = 40;

          // We define a property to hold the image's div. We'll
          // actually create this div upon receipt of the onAdd()
          // method so we'll leave it null for now.
          this.div_ = null;

          // Explicitly call setMap on this overlay
          this.setMap(options.map);
        }

        TextOverlay.prototype.onAdd = function () {
          // Create the DIV and set some basic attributes.
          const div = document.createElement("div");
          div.style.color = this.color;
          div.style.fontSize = "15px";
          div.style.position = "absolute";
          div.style.zIndex = "999";
          div.style.fontWeight = "bold";

          // Create an IMG element and attach it to the DIV.
          div.innerHTML = this.text;

          // Set the overlay's div_ property to this DIV
          this.div_ = div;

          // We add an overlay to a map via one of the map's panes.
          // We'll add this overlay to the overlayLayer pane.
          const panes = this.getPanes();
          panes.overlayLayer.appendChild(div);
        };

        TextOverlay.prototype.draw = function () {
          // Size and position the overlay. We use a southwest and northeast
          // position of the overlay to peg it to the correct position and size.
          // We need to retrieve the projection from this overlay to do this.
          const overlayProjection = this.getProjection();

          // Retrieve the southwest and northeast coordinates of this overlay
          // in latlngs and convert them to pixels coordinates.
          // We'll use these coordinates to resize the DIV.
          const sw = overlayProjection.fromLatLngToDivPixel(
            this.bounds_.getSouthWest(),
          );
          const ne = overlayProjection.fromLatLngToDivPixel(
            this.bounds_.getNorthEast(),
          );
          // Resize the image's DIV to fit the indicated dimensions.
          const div = this.div_;
          const { width } = this;
          const height = 20;

          div.style.left = `${sw.x - width / 2}px`;
          div.style.top = `${ne.y - height / 2}px`;
          div.style.width = `${width}px`;
          div.style.height = `${height}px`;
          div.style.width = `${width}px`;
          div.style.height = `${height}px`;
        };

        TextOverlay.prototype.onRemove = function () {
          this.div_.parentNode.removeChild(this.div_);
          this.div_ = null;
        };

        // Determine the geohash level we will use to draw tiles
        const currentZoom = this.map.getZoom();
        const geohashLevelNum =
          this.mapModel.determineGeohashLevel(currentZoom);
        const geohashLevel = `geohash_${geohashLevelNum}`;
        const geohashes = this.searchResults.facetCounts[geohashLevel];

        // Save the current geohash level in the map model
        this.mapModel.set("tileGeohashLevel", geohashLevelNum);

        // Get all the geohashes contained in the map
        const mapBBoxes = _.flatten(
          _.values(this.searchModel.get("geohashGroups")),
        );

        // Geohashes may be returned that are part of datasets with multiple geographic areas. Some of these may be outside this map.
        // So we will want to filter out geohashes that are not contained in this map.
        if (mapBBoxes.length == 0) {
          var filteredTileGeohashes = geohashes;
        } else if (geohashes) {
          var filteredTileGeohashes = [];
          for (var i = 0; i < geohashes.length - 1; i += 2) {
            // Get the geohash for this tile
            var tileGeohash = geohashes[i];
            let isInsideMap = false;
            let index = 0;
            let searchString = tileGeohash;

            // Find if any of the bounding boxes/geohashes inside our map contain this tile geohash
            while (!isInsideMap && searchString.length > 0) {
              searchString = tileGeohash.substring(
                0,
                tileGeohash.length - index,
              );
              if (_.contains(mapBBoxes, searchString)) isInsideMap = true;
              index++;
            }

            if (isInsideMap) {
              filteredTileGeohashes.push(tileGeohash);
              filteredTileGeohashes.push(geohashes[i + 1]);
            }
          }
        }

        // If there are no tiles on the page, the map may have failed to render, so exit.
        if (
          typeof filteredTileGeohashes === "undefined" ||
          !filteredTileGeohashes.length
        ) {
          return;
        }

        // Make a copy of the array that is geohash counts only
        const countsOnly = [];
        for (var i = 1; i < filteredTileGeohashes.length; i += 2) {
          countsOnly.push(filteredTileGeohashes[i]);
        }

        // Create a range of lightness to make different colors on the tiles
        const lightnessMin = this.mapModel.get("tileLightnessMin");
        const lightnessMax = this.mapModel.get("tileLightnessMax");
        const lightnessRange = lightnessMax - lightnessMin;

        // Get some stats on our tile counts so we can normalize them to create a color scale
        const findMedian = function (nums) {
          if (nums.length % 2 == 0) {
            return (nums[nums.length / 2 - 1] + nums[nums.length / 2]) / 2;
          }
          return nums[nums.length / 2 - 0.5];
        };
        const sortedCounts = countsOnly.sort((a, b) => a - b);
        const maxCount = sortedCounts[sortedCounts.length - 1];
        const minCount = sortedCounts[0];

        const viewRef = this;

        // Now draw a tile for each geohash facet
        for (var i = 0; i < filteredTileGeohashes.length - 1; i += 2) {
          // Convert this geohash to lat,long values
          var tileGeohash = filteredTileGeohashes[i];
          const decodedGeohash = nGeohash.decode(tileGeohash);
          const latLngCenter = new google.maps.LatLng(
            decodedGeohash.latitude,
            decodedGeohash.longitude,
          );
          const geohashBox = nGeohash.decode_bbox(tileGeohash);
          const swLatLng = new google.maps.LatLng(geohashBox[0], geohashBox[1]);
          const neLatLng = new google.maps.LatLng(geohashBox[2], geohashBox[3]);
          const bounds = new google.maps.LatLngBounds(swLatLng, neLatLng);
          const tileCount = filteredTileGeohashes[i + 1];
          const drawMarkers = this.mapModel.get("drawMarkers");
          var marker;
          var count;
          var color;

          // Normalize the range of tiles counts and convert them to a lightness domain of 20-70% lightness.
          if (maxCount - minCount == 0) {
            var lightness = lightnessRange;
          } else {
            var lightness =
              ((tileCount - minCount) / (maxCount - minCount)) *
                lightnessRange +
              lightnessMin;
          }

          var color = `hsl(${this.mapModel.get("tileHue")},${lightness}%,50%)`;

          // Add the count to the tile
          const countLocation = new google.maps.LatLngBounds(
            latLngCenter,
            latLngCenter,
          );

          // Draw the tile label with the dataset count
          count = new TextOverlay({
            bounds: countLocation,
            map: this.map,
            text: tileCount,
            color: this.mapModel.get("tileLabelColor"),
          });

          // Set up the default tile options
          const tileOptions = {
            fillColor: color,
            strokeColor: color,
            map: this.map,
            visible: true,
            bounds,
          };

          // Merge these options with any tile options set in the map model
          const modelTileOptions = this.mapModel.get("tileOptions");
          for (const attr in modelTileOptions) {
            tileOptions[attr] = modelTileOptions[attr];
          }

          // Draw this tile
          const tile = this.drawTile(tileOptions, tileGeohash, count);

          // Save the geohashes for tiles in the view for later
          this.tileGeohashes.push(tileGeohash);
        }

        // Create an info window for each marker that is on the map, to display when it is clicked on
        if (this.markerGeohashes.length > 0) this.addMarkers();

        // If the map is zoomed all the way in, draw info windows for each tile that will be displayed when they are clicked on
        if (this.mapModel.isMaxZoom(this.map)) this.addTileInfoWindows();
      },

      /**
       * With the options and label object given, add a single tile to the map and set its event listeners
       * @param {object} options
       * @param {string} geohash
       * @param {string} label
       */
      drawTile(options, geohash, label) {
        // Exit if maps are not in use
        if (this.mode != "map" || !gmaps) {
          return false;
        }

        // Add the tile for these datasets to the map
        const tile = new google.maps.Rectangle(options);

        const viewRef = this;

        // Save our tiles in the view
        const tileObject = {
          text: label,
          shape: tile,
          geohash,
          options,
        };
        this.tiles.push(tileObject);

        // Change styles when the tile is hovered on
        google.maps.event.addListener(tile, "mouseover", (event) => {
          viewRef.highlightTile(tileObject);
        });

        // Change the styles back after the tile is hovered on
        google.maps.event.addListener(tile, "mouseout", (event) => {
          viewRef.unhighlightTile(tileObject);
        });

        // If we are at the max zoom, we will display an info window. If not, we will zoom in.
        if (!this.mapModel.isMaxZoom(viewRef.map)) {
          /**
           * Set up some helper functions for zooming in on the map
           * @param myMap
           * @param bounds
           */
          const myFitBounds = function (myMap, bounds) {
            myMap.fitBounds(bounds); // calling fitBounds() here to center the map for the bounds

            const overlayHelper = new google.maps.OverlayView();
            overlayHelper.draw = function () {
              if (!this.ready) {
                const extraZoom = getExtraZoom(
                  this.getProjection(),
                  bounds,
                  myMap.getBounds(),
                );
                if (extraZoom > 0) {
                  myMap.setZoom(myMap.getZoom() + extraZoom);
                }
                this.ready = true;
                google.maps.event.trigger(this, "ready");
              }
            };
            overlayHelper.setMap(myMap);
          };
          var getExtraZoom = function (
            projection,
            expectedBounds,
            actualBounds,
          ) {
            // in: LatLngBounds bounds -> out: height and width as a Point
            const getSizeInPixels = function (bounds) {
              const sw = projection.fromLatLngToContainerPixel(
                bounds.getSouthWest(),
              );
              const ne = projection.fromLatLngToContainerPixel(
                bounds.getNorthEast(),
              );
              return new google.maps.Point(
                Math.abs(sw.y - ne.y),
                Math.abs(sw.x - ne.x),
              );
            };

            const expectedSize = getSizeInPixels(expectedBounds);
            const actualSize = getSizeInPixels(actualBounds);

            if (
              Math.floor(expectedSize.x) == 0 ||
              Math.floor(expectedSize.y) == 0
            ) {
              return 0;
            }

            const qx = actualSize.x / expectedSize.x;
            const qy = actualSize.y / expectedSize.y;
            const min = Math.min(qx, qy);

            if (min < 1) {
              return 0;
            }

            return Math.floor(Math.log(min) / Math.LN2 /* = log2(min) */);
          };

          // Zoom in when the tile is clicked on
          gmaps.event.addListener(tile, "click", (clickEvent) => {
            // Change the center
            viewRef.map.panTo(clickEvent.latLng);

            // Get this tile's bounds
            const tileBounds = tile.getBounds();
            // Get the current map bounds
            const mapBounds = viewRef.map.getBounds();

            // Change the zoom
            // viewRef.map.fitBounds(tileBounds);
            myFitBounds(viewRef.map, tileBounds);

            // Track this event
            MetacatUI.analytics?.trackEvent(
              "map",
              "clickTile",
              `geohash : ${tileObject.geohash}`,
            );
          });
        }

        return tile;
      },

      highlightTile(tile) {
        // Change the tile style on hover
        tile.shape.setOptions(this.mapModel.get("tileOnHover"));

        // Change the label color on hover
        const div = tile.text.div_;
        if (div) {
          div.style.color = this.mapModel.get("tileLabelColorOnHover");
          tile.text.div_ = div;
          $(div).css("color", this.mapModel.get("tileLabelColorOnHover"));
        }
      },

      unhighlightTile(tile) {
        // Change back the tile to it's original styling
        tile.shape.setOptions(tile.options);

        // Change back the label color
        const div = tile.text.div_;
        div.style.color = this.mapModel.get("tileLabelColor");
        tile.text.div_ = div;
        $(div).css("color", this.mapModel.get("tileLabelColor"));
      },

      /**
       * Get the details on each marker
       * And create an infowindow for that marker
       */
      addMarkers() {
        // Exit if maps are not in use
        if (this.mode != "map" || !gmaps) {
          return false;
        }

        // Clone the Search model
        const searchModelClone = this.searchModel.clone();
        const geohashLevel = this.mapModel.get("tileGeohashLevel");
        const viewRef = this;
        const { markers } = this;

        // Change the geohash filter to match our tiles
        searchModelClone.set("geohashLevel", geohashLevel);
        searchModelClone.set("geohashes", this.markerGeohashes);

        // Now run a query to get a list of documents that are represented by our markers
        const query =
          `q=${searchModelClone.getQuery()}&fl=id,title,geohash_9,abstract,geohash_${geohashLevel}&rows=1000` +
          `&wt=json`;

        const requestSettings = {
          url: MetacatUI.appModel.get("queryServiceUrl") + query,
          success(data, textStatus, xhr) {
            const { docs } = data.response;
            let uniqueGeohashes = viewRef.markerGeohashes;

            // Create a marker and infoWindow for each document
            _.each(docs, (doc, key, list) => {
              let marker;
              const drawMarkersAt = [];

              // Find the tile place that this document belongs to
              // For each geohash value at the current geohash level for this document,
              _.each(doc.geohash_9, (geohash, key, list) => {
                // Loop through each unique tile location to find its match
                for (let i = 0; i <= uniqueGeohashes.length; i++) {
                  if (uniqueGeohashes[i] == geohash.substr(0, geohashLevel)) {
                    drawMarkersAt.push(geohash);
                    uniqueGeohashes = _.without(uniqueGeohashes, geohash);
                  }
                }
              });

              _.each(drawMarkersAt, function (markerGeohash, key, list) {
                const decodedGeohash = nGeohash.decode(markerGeohash);
                const latLng = new google.maps.LatLng(
                  decodedGeohash.latitude,
                  decodedGeohash.longitude,
                );

                // Set up the options for each marker
                const markerOptions = {
                  position: latLng,
                  icon: this.mapModel.get("markerImage"),
                  zIndex: 99999,
                  map: viewRef.map,
                };

                // Create the marker and add to the map
                const marker = new google.maps.Marker(markerOptions);
              });
            });
          },
        };
        $.ajax(
          _.extend(
            requestSettings,
            MetacatUI.appUserModel.createAjaxSettings(),
          ),
        );
      },

      /**
       * Get the details on each tile - a list of ids and titles for each dataset contained in that tile
       * And create an infowindow for that tile
       */
      addTileInfoWindows() {
        // Exit if maps are not in use
        if (this.mode != "map" || !gmaps) {
          return false;
        }

        // Clone the Search model
        const searchModelClone = this.searchModel.clone();
        const geohashLevel = this.mapModel.get("tileGeohashLevel");
        const geohashName = `geohash_${geohashLevel}`;
        const viewRef = this;
        const infoWindows = [];

        // Change the geohash filter to match our tiles
        searchModelClone.set("geohashLevel", geohashLevel);
        searchModelClone.set("geohashes", this.tileGeohashes);

        // Now run a query to get a list of documents that are represented by our tiles
        const query =
          `q=${searchModelClone.getQuery()}&fl=id,title,geohash_9,${geohashName}&rows=1000` +
          `&wt=json`;

        const requestSettings = {
          url: MetacatUI.appModel.get("queryServiceUrl") + query,
          success(data, textStatus, xhr) {
            // Make an infoWindow for each doc
            const { docs } = data.response;

            // For each tile, loop through the docs to find which ones to include in its infoWindow
            _.each(viewRef.tiles, (tile, key, list) => {
              let infoWindowContent = "";

              _.each(docs, (doc, key, list) => {
                const docGeohashes = doc[geohashName];

                if (docGeohashes) {
                  // Is this document in this tile?
                  for (let i = 0; i < docGeohashes.length; i++) {
                    if (docGeohashes[i] == tile.geohash) {
                      // Add this doc to the infoWindow content
                      infoWindowContent += `<a href='${
                        MetacatUI.root
                      }/view/${encodeURIComponent(doc.id)}'>${doc.title}</a> (${
                        doc.id
                      }) <br/>`;
                      break;
                    }
                  }
                }
              });

              // The center of the tile
              const decodedGeohash = nGeohash.decode(tile.geohash);
              const tileCenter = new google.maps.LatLng(
                decodedGeohash.latitude,
                decodedGeohash.longitude,
              );

              // The infowindow
              const infoWindow = new gmaps.InfoWindow({
                content:
                  `<div class='gmaps-infowindow'>` +
                  `<h4> Datasets located here </h4>` +
                  `<p>${infoWindowContent}</p>` +
                  `</div>`,
                isOpen: false,
                disableAutoPan: false,
                maxWidth: 250,
                position: tileCenter,
              });

              viewRef.tileInfoWindows.push(infoWindow);

              // Zoom in when the tile is clicked on
              gmaps.event.addListener(
                tile.shape,
                "click",
                function (clickEvent) {
                  // --- We are at max zoom, display an infowindow ----//
                  if (this.mapModel.isMaxZoom(viewRef.map)) {
                    // Find the infowindow that belongs to this tile in the view
                    infoWindow.open(viewRef.map);
                    infoWindow.isOpen = true;

                    // Close all other infowindows
                    viewRef.closeInfoWindows(infoWindow);
                  }

                  // ------ We are not at max zoom, so zoom into this tile ----//
                  else {
                    // Change the center
                    viewRef.map.panTo(clickEvent.latLng);

                    // Get this tile's bounds
                    const bounds = tile.shape.getBounds();

                    // Change the zoom
                    viewRef.map.fitBounds(bounds);
                  }
                },
              );

              // Close the infowindow upon any click on the map
              gmaps.event.addListener(viewRef.map, "click", () => {
                infoWindow.close();
                infoWindow.isOpen = false;
              });

              infoWindows[tile.geohash] = infoWindow;
            });

            viewRef.infoWindows = infoWindows;
          },
        };
        $.ajax(
          _.extend(
            requestSettings,
            MetacatUI.appUserModel.createAjaxSettings(),
          ),
        );
      },

      /**
       * Iterate over each infowindow that we have stored in the view and close it.
       * Pass an infoWindow object to this function to keep that infoWindow open/skip it
       * @param {infoWindow} - An infoWindow to keep open
       * @param except
       */
      closeInfoWindows(except) {
        const infoWindowLists = [this.markerInfoWindows, this.tileInfoWindows];

        _.each(infoWindowLists, (infoWindows, key, list) => {
          // Iterate over all the marker infowindows and close all of them except for this one
          for (let i = 0; i < infoWindows.length; i++) {
            if (infoWindows[i].isOpen && infoWindows[i] != except) {
              // Close this info window and stop looking, since only one of each kind should be open anyway
              infoWindows[i].close();
              infoWindows[i].isOpen = false;
              i = infoWindows.length;
            }
          }
        });
      },

      /**
       * Remove all the tiles and text from the map
       */
      removeTiles() {
        // Exit if maps are not in use
        if (this.mode != "map" || !gmaps) {
          return false;
        }

        // Remove the tile from the map
        _.each(this.tiles, (tile, key, list) => {
          if (tile.shape) tile.shape.setMap(null);
          if (tile.text) tile.text.setMap(null);
        });

        // Reset the tile storage in the view
        this.tiles = [];
        this.tileGeohashes = [];
        this.tileInfoWindows = [];
      },

      /**
       * Iterate over all the markers in the view and remove them from the map and view
       */
      removeMarkers() {
        // Exit if maps are not in use
        if (this.mode != "map" || !gmaps) {
          return false;
        }

        // Remove the marker from the map
        _.each(this.markers, (marker, key, list) => {
          marker.marker.setMap(null);
        });

        // Reset the marker storage in the view
        this.markers = [];
        this.markerGeohashes = [];
        this.markerInfoWindows = [];
      },

      /*
       * ==================================================================================================
       *                                             ADDING RESULTS
       * ==================================================================================================
       */

      /**
       * Add all items in the **SearchResults** collection
       * This loads the first 25, then waits for the map to be
       * fully loaded and then loads the remaining items.
       * Without this delay, the app waits until all records are processed
       */
      addAll() {
        // After the map is done loading, then load the rest of the results into the list
        if (this.ready) this.renderAll();
        else {
          const viewRef = this;
          var intervalID = setInterval(() => {
            if (viewRef.ready) {
              clearInterval(intervalID);
              viewRef.renderAll();
            }
          }, 500);
        }

        // After all the results are loaded, query for our facet counts in the background
        // this.getAutocompletes();
      },

      renderAll() {
        // do this first to indicate coming results
        this.updateStats();

        // Remove all the existing tiles on the map
        this.removeTiles();
        this.removeMarkers();

        // Remove the loading class and styling
        this.$results.removeClass("loading");

        // If there are no results, display so
        const numFound = this.searchResults.length;
        if (numFound == 0) {
          // Add a No Results Found message
          this.$results.html("<p id='no-results-found'>No results found.</p>");

          // Remove the loading styles from the map
          if (gmaps && this.mapModel) {
            $("#map-container").removeClass("loading");
          }

          if (MetacatUI.theme == "arctic") {
            // When we get new results, check if the user is searching for their own datasets and display a message
            if (
              MetacatUI.appView.dataCatalogView &&
              MetacatUI.appView.dataCatalogView.searchModel.getQuery() ==
                MetacatUI.appUserModel.get("searchModel").getQuery() &&
              !MetacatUI.appSearchResults.length
            ) {
              $("#no-results-found").after(
                `<h3>Where are my data sets?</h3><p>If you are a previous ACADIS Gateway user, ` +
                  `you will need to take additional steps to access your data sets in the new NSF Arctic Data Center.` +
                  `<a href='mailto:support@arcticdata.io'>Send us a message at support@arcticdata.io</a> with your old ACADIS ` +
                  `Gateway username and your ORCID identifier (${MetacatUI.appUserModel.get(
                    "username",
                  )}), we will help.</p>`,
              );
            }
          }
          return;
        }

        // Clear the results list before we start adding new rows
        this.$results.html("");

        // --First map all the results--
        if (gmaps && this.mapModel) {
          // Draw all the tiles on the map to represent the datasets
          this.drawTiles();

          // Remove the loading styles from the map
          $("#map-container").removeClass("loading");
        }

        const pid_list = new Array();

        // --- Add all the results to the list ---
        for (i = 0; i < this.searchResults.length; i++) {
          pid_list.push(this.searchResults.models[i].get("id"));
        }

        if (MetacatUI.appModel.get("displayDatasetMetrics")) {
          const metricsModel = new MetricsModel({
            pid_list,
            type: "catalog",
          });
          metricsModel.fetch();
          this.metricsModel = metricsModel;
        }

        // --- Add all the results to the list ---
        for (i = 0; i < this.searchResults.length; i++) {
          const element = this.searchResults.models[i];
          if (typeof element !== "undefined")
            this.addOne(element, this.metricsModel);
        }

        // Initialize any tooltips within the result item
        $(".tooltip-this").tooltip();
        $(".popover-this").popover();

        // Set the autoheight
        this.setAutoHeight();
      },

      /**
       * Add a single SolrResult item to the list by creating a view for it and appending its element to the DOM.
       * @param result
       */
      addOne(result) {
        // Get the view and package service URL's
        this.$view_service = MetacatUI.appModel.get("viewServiceUrl");
        this.$package_service = MetacatUI.appModel.get("packageServiceUrl");
        result.set({
          view_service: this.$view_service,
          package_service: this.$package_service,
        });

        const view = new SearchResultView({
          model: result,
          metricsModel: this.metricsModel,
        });

        // Add this item to the list
        this.$results.append(view.render().el);

        // map it
        if (
          gmaps &&
          this.mapModel &&
          typeof result.get("geohash_9") !== "undefined" &&
          result.get("geohash_9") != null
        ) {
          const title = result.get("title");

          for (let i = 0; i < result.get("geohash_9").length; i++) {
            const centerGeohash = result.get("geohash_9")[i];
            const decodedGeohash = nGeohash.decode(centerGeohash);
            const position = new google.maps.LatLng(
              decodedGeohash.latitude,
              decodedGeohash.longitude,
            );
            const marker = new gmaps.Marker({
              position,
              icon: this.mapModel.get("markerImage"),
              zIndex: 99999,
            });
          }
        }
      },

      /**
       * When the SearchResults collection has an error getting the results,
       * show an error message instead of search results
       * @param {SolrResult} model
       * @param {XMLHttpRequest.response} response
       */
      showError(model, response) {
        let errorMessage = "";
        let statusCode = response.status;

        if (!statusCode) {
          statusCode = parseInt(response.statusText);
        }

        if (statusCode == 500 && this.solrError500Message) {
          errorMessage = this.solrError500Message;
        } else {
          try {
            errorMessage = $(response.responseText).text();
          } catch (e) {
            try {
              errorMessage = JSON.parse(response.responseText).error.msg;
            } catch (e) {
              errorMessage = "";
            }
          } finally {
            if (typeof errorMessage === "string" && errorMessage.length) {
              errorMessage = `<p>Error details: ${errorMessage}</p>`;
            }
          }
        }

        MetacatUI.appView.showAlert(
          `<h4><i class='icon icon-frown'></i>${this.solrErrorTitle}.</h4>${errorMessage}`,
          "alert-error",
          this.$results,
        );

        this.$results.find(".loading").remove();
      },

      /*
       * ==================================================================================================
       *                                             STYLING THE UI
       * ==================================================================================================
       */
      toggleMapMode(e) {
        if (typeof e === "object") {
          e.preventDefault();
        }

        if (gmaps) {
          $(".mapMode").toggleClass("mapMode");
        }

        if (this.mode == "map") {
          MetacatUI.appModel.set("searchMode", "list");
          this.mode = "list";
          this.$("#map-canvas").detach();
          this.setAutoHeight();
          this.getResults();
        } else if (this.mode == "list") {
          MetacatUI.appModel.set("searchMode", "map");
          this.mode = "map";
          this.renderMap();
          this.setAutoHeight();
          this.getResults();
        }
      },

      // Communicate that the page is loading
      loading() {
        $("#map-container").addClass("loading");
        this.$results.addClass("loading");

        this.$results.html(
          this.loadingTemplate({
            msg: "Searching for data...",
          }),
        );
      },

      // Toggles the collapseable filters sidebar and result list in the default theme
      collapse(e) {
        const id = $(e.target).attr("data-collapse");

        $(`#${id}`).toggleClass("collapsed");
      },

      toggleFilterCollapse(e) {
        let container = this.$(".filter-contain.collapsable");
        if (typeof e !== "undefined") {
          container = $(e.target).parents(".filter-contain.collapsable");
        }

        // If we can't find a container, then don't do anything
        if (container.length < 1) return;

        const isAnnoContainer =
          $(container).attr("data-category") === "annotation";

        // Expand
        if ($(container).is(".collapsed")) {
          // Toggle the visibility of the collapse/expand icons
          $(container).find(".expand").hide();
          $(container).find(".collapse").show();

          // Cache the height of this element so we can reset it on collapse
          $(container).attr("data-height", $(container).css("height"));

          // Increase the height of the container to expand it
          $(container).css("max-height", "3000px");

          // For annotation container, allow overflow for the dropdown
          if (isAnnoContainer) {
            $(container).css("overflow", "visible");
          }
        }
        // Collapse
        else {
          // Toggle the visibility of the collapse/expand icons
          $(container).find(".collapse").hide();
          $(container).find(".expand").show();

          // Decrease the height of the container to collapse it
          if ($(container).attr("data-height")) {
            $(container).css("max-height", $(container).attr("data-height"));
          } else {
            $(container).css("max-height", "1.5em");
          }
          if (isAnnoContainer) {
            $(container).css("overflow", "hidden");
          }
        }

        $(container).toggleClass("collapsed");
      },
      /*
       * Either hides or shows the "clear all filters" button
       */
      toggleClearButton() {
        if (this.searchModel.filterCount() > 0) {
          this.showClearButton();
        } else {
          this.hideClearButton();
        }
      },

      // Move the popover element up the page a bit if it runs off the bottom of the page
      preventPopoverRunoff(e) {
        // In map view only (because all elements are fixed and you can't scroll)
        if (this.mode == "map") {
          var viewportHeight = $("#map-container").outerHeight();
        } else {
          return false;
        }

        if ($(".popover").length > 0) {
          const offset = $(".popover").offset();
          const popoverHeight = $(".popover").outerHeight();
          const topPosition = offset.top;

          // If pixels are cut off the top of the page, readjust its vertical position
          if (topPosition < 0) {
            $(".popover").offset({
              top: 10,
            });
          } else {
            // Else, let's check if it is cut off at the bottom
            const totalHeight = topPosition + popoverHeight;

            const pixelsHidden = totalHeight - viewportHeight;

            const newTopPosition = topPosition - pixelsHidden - 40;

            // If pixels are cut off the bottom of the page, readjust its vertical position
            if (pixelsHidden > 0) {
              $(".popover").offset({
                top: newTopPosition,
              });
            }
          }
        }
      },

      onClose() {
        this.stopListening();

        $(".DataCatalog").removeClass("DataCatalog");
        $(".mapMode").removeClass("mapMode");

        if (gmaps) {
          // unset map mode
          $("body").removeClass("mapMode");
          $("#map-canvas").remove();
        }

        // remove everything so we don't get a flicker
        this.$el.html("");
      },
    },
  );
  return DataCatalogView;
});