Source: src/js/collections/maps/Geohashes.js

"use strict";

define([
  "jquery",
  "underscore",
  "backbone",
  "nGeohash",
  "models/maps/Geohash",
], function ($, _, Backbone, nGeohash, Geohash) {
  /**
   * @classdesc A collection of adjacent geohashes, potentially at mixed
   * precision levels.
   * @classcategory Collections/Geohashes
   * @class Geohashes
   * @name Geohashes
   * @extends Backbone.Collection
   * @since 2.25.0
   * @constructor
   */
  var Geohashes = Backbone.Collection.extend(
    /** @lends Geohashes.prototype */ {
      /**
       * The name of this type of collection
       * @type {string}
       */
      type: "Geohashes",

      /**
       * The model class for this collection
       * @type {Geohash}
       */
      model: Geohash,

      /**
       * Add a comparator to sort the geohashes by length.
       * @param {Geohash} model - Geohash model to compare.
       * @returns {number} Length of the geohash.
       */
      comparator: function (model) {
        return model.get("hashString")?.length || 0;
      },

      /**
       * Initialize the collection and set the min and max precision levels.
       * @param {Geohash[]} models - Array of Geohash models.
       * @param {Object} options - Options to pass to the collection.
       * @param {number} [options.maxPrecision=9] - The maximum precision level
       * to use when adding geohashes to the collection.
       * @param {number} [options.minPrecision=1] - The minimum precision level
       * to use when adding geohashes to the collection.
       */
      initialize: function (models, options) {
        // Set the absolute min and max precision levels. Min should not be
        // lower than 1. Max must be greater than or equal to min.
        this.MAX_PRECISION = options?.maxPrecision || 9;
        this.MIN_PRECISION = options?.minPrecision || 1;
        if (this.MIN_PRECISION < 1) this.MIN_PRECISION = 1;
        if (this.MAX_PRECISION < this.MIN_PRECISION) {
          this.MAX_PRECISION = this.MIN_PRECISION;
        }
      },

      /**
       * Ensure that a precision or list of precisions is valid. A valid precision
       * is a positive number in the range of the MIN_PRECISION and MAX_PRECISION
       * set on the collection.
       * @param {number|number[]} precision - Precision level or array of
       * precision levels.
       * @param {boolean} [fix=true] - Whether to fix the precision by setting it
       * to the min or max precision if it is out of range. If false, then the function
       * will throw an error if the precision is invalid.
       * @returns {number|number[]} The precision level or array of precision
       * levels, corrected if needed and if fix is true.
       */
      validatePrecision: function (p, fix = true) {
        if (Array.isArray(p)) {
          return p.map((p) => this.validatePrecision(p, fix));
        }
        const min = this.MIN_PRECISION;
        const max = this.MAX_PRECISION;
        const isValid = typeof p === "number" && p >= min && p <= max;
        if (isValid) return p;
        if (fix) return p < min ? min : max;
        throw new Error(
          `Precision must be a number between ${min} and ${max}` +
            ` (inclusive), but got ${p}`,
        );
      },

      /**
       * Checks if the geohashes in this model are empty or if there are no
       * models
       * @returns {boolean} True if this collection is empty.
       */
      isEmpty: function () {
        return (
          this.length === 0 || this.models.every((model) => model.isEmpty())
        );
      },

      /**
       * Returns true if the set of geohashes in this model collection are the
       * 32 geohashes at precision 1, i.e. [0-9a-v]
       * @returns {boolean} True if there are 32 geohashes with one character
       * each.
       */
      isCompleteRootLevel: function () {
        const hashStrings = this.getAllHashStrings();
        if (hashStrings.length !== 32) return false;
        if (hashStrings.some((hash) => hash.length !== 1)) return false;
        return true;
      },

      /**
       * Returns true if the geohashes in this model cover the entire earth.
       * @returns {boolean} True if the geohashes cover the entire earth.
       */
      coversEarth: function () {
        if (this.isEmpty()) return false;
        if (this.isCompleteRootLevel()) return true;
        return this.clone().consolidate().isCompleteRootLevel();
      },

      /**
       * Creates hashStrings for geohashes that are within the provided bounding
       * boxes at the given precision. The returned hashStrings are not
       * necessarily in the collection.
       * @param {GeoBoundingBox} bounds - Bounding box with north, south, east, and west
       * properties.
       * @param {number} precision - Geohash precision level.
       * @returns {string[]} Array of geohash hashStrings.
       */
      getHashStringsForBounds: function (bounds, precision) {
        this.validatePrecision(precision, false);
        if (!bounds.isValid()) {
          throw new Error("Bounds are invalid");
        }
        let hashStrings = [];
        bounds = bounds.split();
        bounds.forEach(function (b) {
          const c = b.getCoords();
          hashStrings = hashStrings.concat(
            nGeohash.bboxes(c.south, c.west, c.north, c.east, precision),
          );
        });
        return hashStrings;
      },

      /**
       * Returns a list of hashStrings in this collection. Optionally provide a
       * precision to only return hashes of that length.
       * @param {Number} precision - Geohash precision level.
       * @returns {string[]} Array of geohash hashStrings.
       */
      getAllHashStrings: function (precision) {
        const hashes = this.map((geohash) => geohash.get("hashString"));
        if (precision) {
          this.validatePrecision(precision, false);
          return hashes.filter((hash) => hash.length === precision);
        } else {
          return hashes;
        }
      },

      /**
       * Get an array of all the values for a given property in the geohash
       * models in this collection.
       * @param {string} attr The key of the property in the properties object
       * in each geohash model.
       */
      getAttr: function (attr) {
        return this.models.map((geohash) => geohash.get(attr));
      },

      /**
       * Add geohashes to the collection based on a bounding box.
       * @param {GeoBoundingBox} bounds - Bounding box with north, south, east, and west
       * properties.
       * @param {boolean} [consolidate=false] - Whether to consolidate the
       * geohashes into the smallest set of geohashes that cover the same area.
       * This will be performed before creating the new models in order to
       * improve performance.
       * @param {number} [maxGeohashes=Infinity] - The maximum number of
       * geohashes to add to the collection. This will limit the precision of
       * the geohashes for larger bounding boxes. Depending on constraints such
       * as the min and max precision, and the size of the bounding box, the
       * actual number of geohashes added may sometimes exceed this number.
       * @param {boolean} [overwrite=false] - Whether to overwrite the current
       * collection.
       * @param {number} [minPrecision] - The minimum precision of the
       * geohashes to add to the collection, defaults to the min precision
       * level set on the collection.
       * @param {number} [maxPrecision] - The maximum precision of the
       * geohashes to add to the collection, defaults to the max precision
       * level set on the collection.
       *
       */
      addGeohashesByBounds: function (
        bounds,
        consolidate = false,
        maxGeohashes = Infinity,
        overwrite = false,
        minPrecision = this.MIN_PRECISION,
        maxPrecision = this.MAX_PRECISION,
      ) {
        let hashStrings = [];
        if (consolidate) {
          hashStrings = this.getFewestHashStringsForBounds(
            bounds,
            minPrecision,
            maxPrecision,
            maxGeohashes,
          );
        } else {
          const area = bounds.getArea();
          const precision = this.getMaxPrecision(
            area,
            maxGeohashes,
            minPrecision,
            maxPrecision,
          );
          hashStrings = this.getHashStringsForBounds(bounds, precision);
        }
        this.addGeohashesByHashString(hashStrings, overwrite);
      },

      /**
       * Get the area in degrees squared of a geohash "tile" for a given
       * precision level. The area is considered the product of the geohash's
       * latitude and longitude error margins.
       * @param {number} precision - The precision level to get the area for.
       * @returns {number} The area in degrees squared.
       */
      getGeohashArea: function (precision) {
        precision = this.validatePrecision(precision);
        // Number of bits used for encoding both coords
        const totalBits = precision * 5;
        const lonBits = Math.floor(totalBits / 2);
        const latBits = totalBits - lonBits;
        // Lat and long precision in degrees.
        const latPrecision = 180 / 2 ** latBits;
        const lonPrecision = 360 / 2 ** lonBits;
        return latPrecision * lonPrecision;
      },

      /**
       * For a range of precisions levels, get the area in degrees squared for
       * geohash "tiles" at each precision level. See {@link getGeohashArea}.
       * @param {Number} minPrecision - The minimum precision level for which to
       * calculate the area, defaults to the min precision level set on the
       * collection.
       * @param {Number} maxPrecision - The maximum precision level for which to
       * calculate the area, defaults to the max precision level set on the
       * collection.
       * @returns {Object} An object with the precision level as the key and the
       * area in degrees as the value.
       */
      getGeohashAreas: function (
        minPrecision = this.MIN_PRECISION,
        maxPrecision = this.MAX_PRECISION,
      ) {
        minPrecision = this.validatePrecision(minPrecision);
        maxPrecision = this.validatePrecision(maxPrecision);
        if (!this.precisionAreas) this.precisionAreas = {};
        for (let i = minPrecision; i <= maxPrecision; i++) {
          if (!this.precisionAreas[i]) {
            this.precisionAreas[i] = this.getGeohashArea(i);
          }
        }
        return this.precisionAreas;
      },

      /**
       * Given a bounding box, estimate the maximum geohash precision that can
       * be used to cover the area without exceeding a specified number of
       * geohashes. The goal is to find the smallest and most precise geohashes
       * possible without surpassing the maximum allowed number of geohashes.
       * @param {Number} area - The area of the bounding box in degrees squared.
       * @param {Number} maxGeohashes - The maximum number of geohashes that can
       * be used to cover the area.
       * @param {Number} [absMin] - The absolute minimum precision level to
       * consider. Defaults to the min set on the collection).
       * @param {Number} [absMax] - The absolute maximum precision level to
       * consider. Defaults to the max set on the collection.
       * @returns {Number} The maximum precision level that can be used to cover
       * the area without surpassing the given number of geohashes.
       */
      getMaxPrecision: function (
        area,
        maxGeohashes,
        absMin = this.MIN_PRECISION,
        absMax = this.MAX_PRECISION,
      ) {
        absMin = this.validatePrecision(absMin);
        absMax = this.validatePrecision(absMax);
        const ghAreas = this.getGeohashAreas(absMin, absMax);

        // Start from the most precise level
        let precision = absMax;
        let conditionMet = false;

        // Work down to the lowest precision level
        while (precision >= absMin) {
          // Num of geohashes needed to cover the bounding box area
          const geohashesNeeded = area / ghAreas[precision];
          if (geohashesNeeded <= maxGeohashes) {
            conditionMet = true;
            break;
          }
          precision--;
        }

        if (!conditionMet) {
          console.warn(
            `The area is too large to cover with fewer than ${maxGeohashes} ` +
              `geohashes at the min precision level (${absMin}). Returning ` +
              `the min precision level, which may result in too many geohashes.`,
          );
        }

        return precision;
      },

      /**
       * Calculate the smallest possible geohash precision level that has
       * geohash "tiles" larger than a given area.
       * @param {Number} area - The area of the bounding box in degrees squared.
       * @param {Number} absMin - The absolute minimum precision level to
       * consider. Defaults to the min set on the collection.
       * @param {Number} absMax - The absolute maximum precision level to
       * consider. Defaults to the max set on the collection.
       * @returns {Number} The minimum precision level that can be used to cover
       * the area with a single geohash.
       */
      getMinPrecision: function (
        area,
        absMin = this.MIN_PRECISION,
        absMax = this.MAX_PRECISION,
      ) {
        absMin = this.validatePrecision(absMin);
        absMax = this.validatePrecision(absMax);
        const ghAreas = this.getGeohashAreas(absMin, absMax);

        // If area is a huge number and is larger than the largest geohash area,
        // return the min precision
        if (area >= ghAreas[absMin]) return absMin;

        // Start from the least precise level & work up
        let precision = absMin;
        while (precision < absMax) {
          if (ghAreas[precision] >= area && ghAreas[precision + 1] < area) {
            break;
          }
          precision++;
        }

        return precision;
      },

      /**
       * Get the optimal range of precision levels to consider using for a given
       * bounding box. See {@link getMaxPrecision} and {@link getMinPrecision}.
       * @param {GeoBoundingBox} bounds - Bounding box with north, south, east,
       * and west properties.
       * @param {Number} maxGeohashes - The maximum number of geohashes that can
       * be used to cover the area.
       * @param {Number} absMin - The absolute minimum precision level to
       * consider. Defaults to the min set on the collection.
       * @param {Number} absMax - The absolute maximum precision level to
       * consider. Defaults to the max set on the collection.
       * @returns {Array} An array with the min and max precision levels to
       * consider.
       */
      getOptimalPrecisionRange: function (
        bounds,
        maxGeohashes = Infinity,
        absMin = this.MIN_PRECISION,
        absMax = this.MAX_PRECISION,
      ) {
        if (!bounds.isValid()) {
          console.warn(
            `Bounds are invalid: ${JSON.stringify(bounds)}. ` +
              `Returning the min and max allowable precision levels.`,
          );
          return [absMin, absMax];
        }
        absMin = this.validatePrecision(absMin);
        absMax = this.validatePrecision(absMax);
        const area = bounds.getArea();
        const minP = this.getMinPrecision(area, absMin, absMax);
        if (minP === absMax || maxGeohashes === Infinity) return [minP, absMax];
        return [minP, this.getMaxPrecision(area, maxGeohashes, minP, absMax)];
      },

      /**
       * Get the fewest number of geohashes that can be used to cover a given
       * bounding box. This will return the optimal set of potentially
       * mixed-precision geohashes that cover the bounding box at the highest
       * precision possible without exceeding the maximum number of geohashes.
       * @param {GeoBoundingBox} bounds - Bounding box with north, south, east, and west
       * properties.
       * @param {Number} [minPrecision] - The minimum precision level to
       * consider when calculating the optimal set of geohashes. Defaults to the
       * min precision level set on the collection.
       * @param {Number} [maxPrecision] - The maximum precision level to
       * consider when calculating the optimal set of geohashes. Defaults to the
       * max precision level set on the collection.
       * @param {Number} [maxGeohashes=Infinity] - The maximum number of
       * geohashes to add to the collection. This will limit the precision of
       * the geohashes for larger bounding boxes. Depending on constraints such
       * as the min and max precision, and the size of the bounding box, the
       * actual number of geohashes added may sometimes exceed this number.
       * @returns {string[]} Array of geohash hashStrings.
       */
      getFewestHashStringsForBounds: function (
        bounds,
        minPrecision = this.MIN_PRECISION,
        maxPrecision = this.MAX_PRECISION,
        maxGeohashes = Infinity,
      ) {
        // Check the inputs
        if (!bounds.isValid()) return [];
        minPrecision = this.validatePrecision(minPrecision);
        maxPrecision = this.validatePrecision(maxPrecision);
        if (minPrecision > maxPrecision) minPrecision = maxPrecision;

        // Skip precision levels that result in too many geohashes or that have
        // geohash "tiles" larger than the bounding box.
        [minPrecision, maxPrecision] = this.getOptimalPrecisionRange(
          bounds,
          maxGeohashes,
          minPrecision,
          maxPrecision,
        );

        // Base32 is the set of characters used to encode geohashes
        const base32 = [..."0123456789bcdefghjkmnpqrstuvwxyz"];

        // If the bounds cover the world, return the base set of geohashes
        if (bounds.coversEarth()) {
          return base32;
        }

        // Checks if a hash is fully contained, fully outside, or overlapping
        // the bounding box. In case the bounding box crosses the prime
        // meridian, split it in two
        const allBounds = bounds.split();
        function hashPlacement(hash) {
          let [s, w, n, e] = nGeohash.decode_bbox(hash);
          let outside = [];
          for (const b of allBounds) {
            if (bounds.boundsAreFullyContained(n, e, s, w)) {
              return "inside";
            } else if (bounds.boundsAreFullyOutside(n, e, s, w)) {
              outside.push(true);
            }
          }
          if (outside.length === allBounds.length) return "outside";
          return "overlap";
        }

        // Start with all hashes at minPrecision
        let precision = minPrecision;
        let hashes = this.getHashStringsForBounds(bounds, precision);
        const optimalSet = new Set();

        while (precision < maxPrecision && hashes.length > 0) {
          // If hash is part overlapping but not fully contained, check the
          // children; If hash is fully contained, it's one of the optimal
          // geohashes. Hashes fully outside the bounding box ignored.
          let overlapHashes = [];
          for (const hash of hashes) {
            let placement = hashPlacement(hash);
            if (placement == "overlap") overlapHashes.push(hash);
            else if (placement == "inside") optimalSet.add(hash);
          }

          // At the next highest precision level, check the children of the
          // hashes that are partially overlapping the bounding box.
          hashes = overlapHashes.flatMap((hash) => {
            return base32.map((char) => {
              return hash + char;
            });
          });
          precision++;
        }

        // Since want precision to be at least maxPrecision, we can add any
        // remaining hashes at maxPrecision that at least partly overlap the
        // bounding box.
        if (precision == maxPrecision) {
          for (const hash of hashes) {
            if (hashPlacement(hash) != "outside") {
              optimalSet.add(hash);
            }
          }
        }

        return Array.from(optimalSet);
      },

      /**
       * Add geohashes to the collection based on an array of geohash
       * hashStrings.
       * @param {string[]} hashStrings - Array of geohash hashStrings.
       * @param {boolean} [overwrite=false] - Whether to overwrite the current
       * collection.
       */
      addGeohashesByHashString: function (hashStrings, overwrite = false) {
        const method = overwrite ? "reset" : "add";
        const geohashAttrs = hashStrings.map((gh) => {
          return { hashString: gh };
        });
        this[method](geohashAttrs);
      },

      /**
       * Get a subset of geohashes from this collection that are within the
       * provided bounding box.
       * @param {GeoBoundingBox} bounds - Bounding box with north, south, east, and west
       * properties.
       * @returns {Geohashes} Subset of geohashes.
       */
      getSubsetByBounds: function (bounds) {
        if (!bounds || !bounds.isValid()) {
          console.warn(
            `Bounds are invalid: ${JSON.stringify(bounds)}. ` +
              `Returning an empty Geohashes collection.`,
          );
          return new Geohashes();
        }
        const precisions = this.getPrecisions();
        let hashes = [];
        precisions.forEach((precision) => {
          hashes = hashes.concat(
            this.getHashStringsForBounds(bounds, precision),
          );
        });
        const subsetModels = this.filter((geohash) => {
          return hashes.includes(geohash.get("hashString"));
        });
        return new Geohashes(subsetModels);
      },

      /**
       * Check if a geohash is in the collection. This will only consider
       * geohash hashStrings, not properties or any other attributes on the
       * Geohash models.
       * @param {Geohash} target - Geohash model or geohash hashString.
       * @returns {boolean} Whether the target is part of this collection.
       */
      includes: function (geohash) {
        const allHashes = this.getAllHashStrings();
        const targetHash =
          geohash instanceof Geohash ? geohash.get("hashString") : geohash;
        return allHashes.includes(targetHash);
      },

      /**
       * Group the geohashes in the collection by their groupID. Their groupID
       * is the hashString of the parent geohash, i.e. the hashString of the
       * geohash with the last character removed.
       * @param {number} [level=1] - The level of the parent geohash to use to
       * group the geohashes. Defaults to 1, i.e. the parent geohash is one
       * level up.
       * @returns {Object} Object with groupIDs as keys and arrays of Geohash
       * models as values.
       */
      getGroups: function (level = 1) {
        const groups = {};
        this.forEach((geohash) => {
          const groupID = geohash.getGroupID(level);
          if (!groups[groupID]) {
            groups[groupID] = [];
          }
          groups[groupID].push(geohash);
        });
        return groups;
      },

      /**
       * Get the geohash groups in this collection that are complete, i.e. have
       * 32 child geohashes.
       * @returns {Object} Object with groupIDs as keys and arrays of Geohash
       * models as values.
       */
      getCompleteGroups: function () {
        const allGroups = {};
        const completeGroups = {};
        for (let i = 0; i < this.length; i++) {
          const geohash = this.at(i);
          const groupID = geohash.getGroupID();
          if (groupID) {
            if (!allGroups[groupID]) {
              allGroups[groupID] = [];
            }
            allGroups[groupID].push(geohash);
            if (allGroups[groupID].length === 32 && !completeGroups[groupID]) {
              completeGroups[groupID] = allGroups[groupID];
            }
          }
        }

        return completeGroups;
      },

      /**
       * Consolidate this collection: Merge complete groups of geohashes into a
       * single, lower precision "parent" geohash. Groups are complete if all 32
       * "child" geohashes that make up the "parent" geohash are in the
       * collection. Add and remove events will not be triggered during
       * consolidation.
       */
      consolidate: function () {
        let toMerge;

        if (this.length <= 1) return this;

        do {
          toMerge = this.getCompleteGroups();
          let toRemove = [];
          let toAdd = [];

          Object.keys(toMerge).forEach((groupID) => {
            const parent = new Geohash({ hashString: groupID });
            toRemove.push(...toMerge[groupID]);
            toAdd.push(parent);
          });

          if (toRemove.length > 0) {
            this.remove(toRemove, { silent: true });
          }

          if (toAdd.length > 0) {
            this.add(toAdd, { silent: true });
          }
        } while (Object.keys(toMerge).length > 0);

        return this;
      },

      /**
       * Get the unique geohash precision levels present in the collection.
       */
      getPrecisions: function () {
        return Array.from(new Set(this.map((gh) => gh.get("precision"))));
      },

      /**
       * Return the geohashes as a GeoJSON FeatureCollection, where each geohash
       * is represented as a GeoJSON Polygon (rectangle).
       * @returns {Object} GeoJSON FeatureCollection.
       */
      toGeoJSON: function () {
        return {
          type: "FeatureCollection",
          features: this.map(function (geohash) {
            return geohash.toGeoJSON();
          }),
        };
      },

      /**
       * Return the geohashes as a GeoJSON FeatureCollection, where each geohash
       * is represented as a GeoJSON Point.
       * @returns {Object} GeoJSON FeatureCollection.
       */
      toGeoJSONPoints: function () {
        return {
          type: "FeatureCollection",
          features: this.map(function (geohash) {
            return geohash.toGeoJSONPoint();
          }),
        };
      },

      /**
       * Return the geohashes as a CZML document, where each geohash is
       * represented as a CZML Polygon (rectangle) and a CZML Label.
       * @param {string} [label] - The key for the property that should be
       * displayed with a label for each geohash, e.g. "count"
       * @returns {Array} CZML document.
       */
      toCZML: function (label) {
        const czmlHeader = [
          {
            id: "document",
            version: "1.0",
            name: "Geohashes",
          },
        ];

        const czmlData = this.models.flatMap(function (geohash) {
          return geohash.toCZML(label);
        });

        return czmlHeader.concat(czmlData);
      },

      /**
       * Find the geohash from this collection that contains the provided
       * geohash hashString. If the hashString is already in the collection,
       * return that geohash. Otherwise, find the geohash that contains the
       * hashString.
       * @param {string} hashString - Geohash hashString.
       * @returns {Geohash} Parent geohash.
       */
      getContainingGeohash: function (hashString) {
        if (!hashString || hashString.length === 0) return null;
        // First check if the hashString is already in the collection
        let geohash = this.findWhere({ hashString: hashString });
        if (geohash) return geohash;
        geohash = this.find((gh) => {
          return gh.isParentOf(hashString);
        });
        return geohash;
      },
    },
  );

  return Geohashes;
});