Source: src/js/models/maps/Geohash.js

"use strict";

define([
  "jquery",
  "underscore",
  "backbone",
  "nGeohash",
  "models/maps/GeoUtilities",
], function ($, _, Backbone, nGeohash, GeoUtilities) {
  /**
   * @classdesc A Geohash Model represents a single geohash.
   * @classcategory Models/Geohashes
   * @class Geohash
   * @name Geohash
   * @extends Backbone.Model
   * @since 2.25.0
   * @constructor
   */
  var Geohash = Backbone.Model.extend(
    /** @lends Geohash.prototype */ {
      /**
       * The name of this type of model
       * @type {string}
       */
      type: "Geohash",

      /**
       * Default attributes for Geohash models. Note that attributes like
       * precision, bounds, etc. are all calculated on the fly during the get
       * method.
       * @name Geohash#defaults
       * @type {Object}
       * @property {string} hashString The hashString of the geohash.
       * @property {Object} [properties] An object containing arbitrary
       * properties associated with the geohash. (e.g. count values from
       * SolrResults)
       */
      defaults: function () {
        return {
          hashString: "",
          properties: {},
        };
      },

      /**
       * Overwrite the get method to calculate bounds, point, precision, and
       * arbitrary properties on the fly.
       * @param {string} attr The attribute to get the value of.
       * @returns {*} The value of the attribute.
       */
      get: function (attr) {
        if (attr === "bounds") return this.getBounds();
        if (attr === "point") return this.getPoint();
        if (attr === "precision") return this.getPrecision();
        if (attr === "geojson") return this.toGeoJSON();
        if (attr === "czml") return this.toCZML();
        if (attr === "groupID") return this.getGroupID();
        if (this.isProperty(attr)) return this.getProperty(attr);
        return Backbone.Model.prototype.get.call(this, attr);
      },

      /**
       * Checks if the geohash is empty. It is considered empty if it has no
       * hashString set.
       * @returns {boolean} true if the geohash is empty, false otherwise.
       */
      isEmpty: function () {
        const hashString = this.get("hashString");
        return !hashString || hashString.length === 0;
      },

      /**
       * Checks if the geohash has a property with the given key.
       * @param {string} key The key to check for.
       * @returns {boolean} True if the geohash has a property with the given
       * key, false otherwise.
       */
      isProperty: function (key) {
        // Must use prototype.get to avoid infinite loop
        const properties = Backbone.Model.prototype.get.call(
          this,
          "properties",
        );
        return properties?.hasOwnProperty(key);
      },

      /**
       * Get a property from the geohash.
       * @param {string} key The key of the property to get.
       * @returns {*} The value of the property.
       */
      getProperty: function (key) {
        if (!key) return null;
        if (!this.isProperty(key)) return null;
        return this.get("properties")[key];
      },

      /**
       * Set a property on the geohash.
       * @param {string} key The key of the property to set.
       * @param {*} value The value of the property to set.
       */
      addProperty: function (key, value) {
        if (!key) return;
        const properties = this.get("properties");
        properties[key] = value;
        this.set("properties", properties);
      },

      /**
       * Remove a property from the geohash.
       * @param {string} key The key of the property to remove.
       */
      removeProperty: function (key) {
        if (!key) return;
        const properties = this.get("properties");
        delete properties[key];
        this.set("properties", properties);
      },

      /**
       * Get the bounds of the geohash "tile".
       * @returns {Array|null} An array containing the bounds of the geohash.
       */
      getBounds: function () {
        if (this.isEmpty()) return null;
        return nGeohash.decode_bbox(this.get("hashString"));
      },

      /**
       * Get the center point of the geohash.
       * @returns {Object|null} Returns object with latitude and longitude keys.
       */
      getPoint: function () {
        if (this.isEmpty()) return null;
        return nGeohash.decode(this.get("hashString"));
      },

      /**
       * Get the precision of the geohash.
       * @returns {number|null} The precision of the geohash.
       */
      getPrecision: function () {
        if (this.isEmpty()) return null;
        return this.get("hashString").length;
      },

      /**
       * Get the 32 child geohashes of the geohash.
       * @param {boolean} [keepProperties=false] If true, the child geohashes
       * will have the same properties as the parent geohash.
       * @returns {Geohash[]} An array of Geohash models.
       */
      getChildGeohashes: function (keepProperties = false) {
        if (this.isEmpty()) return null;
        const geohashes = [];
        const hashString = this.get("hashString");
        for (let i = 0; i < 32; i++) {
          geohashes.push(
            new Geohash({
              hashString: hashString + i.toString(32),
              properties: keepProperties ? this.get("properties") : {},
            }),
          );
        }
        return geohashes;
      },

      /**
       * Get the parent geohash of the geohash.
       * @param {boolean} [keepProperties=false] If true, the parent geohash
       * will have the same properties as this child geohash.
       * @returns {Geohash|null} A Geohash model or null if the geohash is empty.
       */
      getParentGeohash: function (keepProperties = false) {
        return new Geohash({
          hashString: this.getGroupID(),
          properties: keepProperties ? this.get("properties") : {},
        });
      },

      /**
       * Get all geohashes that belong in the same complete group of
       * 32 geohashes.
       */
      getGeohashGroup: function () {
        if (this.isEmpty()) return null;
        const parent = this.getParentGeohash();
        if (!parent) return null;
        return parent.getChildGeohashes();
      },

      /**
       * Get the group ID of the geohash at the specified level.
       * @param {number} level - The number of levels to go up from the current geohash.
       * @returns {string} The group ID of the geohash at the specified level.
       */
      getGroupID: function (level = 1) {
        if (this.isEmpty()) return "";
        const hashString = this.get("hashString");
        const newLength = Math.max(0, hashString.length - level);
        return hashString.slice(0, newLength);
      },

      /**
       * Checks if this geohash contains the given geohash
       * @param {string} hashString The hashString of the geohash to check.
       */
      isParentOf: function (hashString) {
        if (this.isEmpty()) return false;
        if (hashString.length < this.get("hashString").length) return false;
        return hashString.startsWith(this.get("hashString"));
      },

      /**
       * Get the data from this model that is needed to create geometries for various
       * formats of geospacial data, like GeoJSON and CZML.
       * @param {string} geometry The type of geometry to get. Can be "rectangle",
       * "point", or "both".
       * @returns {Object|null} An object with the keys "rectangle", "point", and
       * "properties".
       */
      getGeoData: function (geometry = "both") {
        if (this.isEmpty()) return null;

        const geoData = {};

        const properties = this.get("properties");
        properties["hashString"] = this.get("hashString");
        geoData["properties"] = properties;

        if (geometry === "rectangle" || geometry === "both") {
          const bounds = this.getBounds();
          if (bounds) {
            let [south, west, north, east] = bounds;
            // Set min latitude to -89.99999 for Geohashes. This is for Cesium.
            if (south && west && north && east) {
              if (south === -90) south = -89.99999;
            }
            geoData["rectangle"] = [
              [west, south],
              [east, south],
              [east, north],
              [west, north],
              [west, south],
            ];
          }
        }
        if (geometry === "point" || geometry === "both") {
          geoData["point"] = this.getPoint();
        }

        return geoData;
      },

      /**
       * Get the geohash as a GeoJSON Feature.
       * @returns {Object} A GeoJSON Feature representing the geohash.
       */
      toGeoJSON: function () {
        const geoData = this.getGeoData("rectangle");
        if (!geoData) return null;
        const { rectangle, properties } = geoData;
        if (!rectangle) return null;
        return {
          type: "Feature",
          geometry: {
            type: "Polygon",
            coordinates: [rectangle],
          },
          properties: properties,
        };
      },

      toGeoJSONPoint: function () {
        const geoData = this.getGeoData("point");
        if (!geoData) return null;
        const { point, properties } = geoData;
        return {
          type: "Feature",
          geometry: {
            type: "Point",
            coordinates: [point.longitude, point.latitude],
          },
          properties: properties,
        };
      },

      /**
       * Get the geohash as a CZML Feature.
       * @param {string} label The key for the property to display as a label.
       * @returns {Object} A CZML Feature representing the geohash, including
       * a polygon of the geohash area and a label with the value of the
       * property specified by the label parameter.
       */
      toCZML: function (label) {
        const geoData = this.getGeoData("both");
        if (!geoData) return null;
        const { rectangle, point, properties } = geoData;
        const id = properties["hashString"];

        const ecefCoordinates = rectangle.map((coord) =>
          this.geodeticToECEF(coord),
        );
        const ecefPosition = this.geodeticToECEF([
          point.longitude,
          point.latitude,
        ]);
        const feature = {
          id: id,
          polygon: {
            positions: {
              cartesian: ecefCoordinates.flat(),
            },
            height: 0,
          },
          properties: properties,
        };
        if (label && (properties[label] || properties[label] === 0)) {
          (feature["label"] = {
            text: properties[label]?.toString(),
            show: true,
            fillColor: {
              rgba: [255, 255, 255, 255],
            },
            outlineColor: {
              rgba: [0, 0, 0, 255],
            },
            outlineWidth: 1.5,
            style: "FILL_AND_OUTLINE",
            font: "bold 14pt Helvetica",
            horizontalOrigin: "CENTER",
            verticalOrigin: "CENTER",
            heightReference: "CLAMP_TO_GROUND",
            disableDepthTestDistance: 10000000,
          }),
            (feature["position"] = { cartesian: ecefPosition });
        }
        return [feature];
      },

      /**
       * Convert geodetic coordinates to Earth-Centered, Earth-Fixed (ECEF)
       * coordinates.
       * @param {Object} coord The geodetic coordinates, an array of longitude
       * and latitude.
       * @returns {Array} The ECEF coordinates.
       */
      geodeticToECEF: function (coord) {
        return GeoUtilities.prototype.geodeticToECEF(coord);
      },
    },
  );

  return Geohash;
});