geopoint.js

var _ = require('underscore');

/*global navigator: false */
module.exports = function(AV) {
  /**
   * Creates a new GeoPoint with any of the following forms:<br>
   * @example
   * new GeoPoint(otherGeoPoint)
   * new GeoPoint(30, 30)
   * new GeoPoint([30, 30])
   * new GeoPoint({latitude: 30, longitude: 30})
   * new GeoPoint()  // defaults to (0, 0)
   * @class
   *
   * <p>Represents a latitude / longitude point that may be associated
   * with a key in a AVObject or used as a reference point for geo queries.
   * This allows proximity-based queries on the key.</p>
   *
   * <p>Only one key in a class may contain a GeoPoint.</p>
   *
   * <p>Example:<pre>
   *   var point = new AV.GeoPoint(30.0, -20.0);
   *   var object = new AV.Object("PlaceObject");
   *   object.set("location", point);
   *   object.save();</pre></p>
   */
  AV.GeoPoint = function(arg1, arg2) {
    if (_.isArray(arg1)) {
      AV.GeoPoint._validate(arg1[0], arg1[1]);
      this.latitude = arg1[0];
      this.longitude = arg1[1];
    } else if (_.isObject(arg1)) {
      AV.GeoPoint._validate(arg1.latitude, arg1.longitude);
      this.latitude = arg1.latitude;
      this.longitude = arg1.longitude;
    } else if (_.isNumber(arg1) && _.isNumber(arg2)) {
      AV.GeoPoint._validate(arg1, arg2);
      this.latitude = arg1;
      this.longitude = arg2;
    } else {
      this.latitude = 0;
      this.longitude = 0;
    }

    // Add properties so that anyone using Webkit or Mozilla will get an error
    // if they try to set values that are out of bounds.
    var self = this;
    if (this.__defineGetter__ && this.__defineSetter__) {
      // Use _latitude and _longitude to actually store the values, and add
      // getters and setters for latitude and longitude.
      this._latitude = this.latitude;
      this._longitude = this.longitude;
      this.__defineGetter__('latitude', function() {
        return self._latitude;
      });
      this.__defineGetter__('longitude', function() {
        return self._longitude;
      });
      this.__defineSetter__('latitude', function(val) {
        AV.GeoPoint._validate(val, self.longitude);
        self._latitude = val;
      });
      this.__defineSetter__('longitude', function(val) {
        AV.GeoPoint._validate(self.latitude, val);
        self._longitude = val;
      });
    }
  };

  /**
   * @lends AV.GeoPoint.prototype
   * @property {float} latitude North-south portion of the coordinate, in range
   *   [-90, 90].  Throws an exception if set out of range in a modern browser.
   * @property {float} longitude East-west portion of the coordinate, in range
   *   [-180, 180].  Throws if set out of range in a modern browser.
   */

  /**
   * Throws an exception if the given lat-long is out of bounds.
   * @private
   */
  AV.GeoPoint._validate = function(latitude, longitude) {
    if (latitude < -90.0) {
      throw new Error('AV.GeoPoint latitude ' + latitude + ' < -90.0.');
    }
    if (latitude > 90.0) {
      throw new Error('AV.GeoPoint latitude ' + latitude + ' > 90.0.');
    }
    if (longitude < -180.0) {
      throw new Error('AV.GeoPoint longitude ' + longitude + ' < -180.0.');
    }
    if (longitude > 180.0) {
      throw new Error('AV.GeoPoint longitude ' + longitude + ' > 180.0.');
    }
  };

  /**
   * Creates a GeoPoint with the user's current location, if available.
   * @return {Promise.<AV.GeoPoint>}
   */
  AV.GeoPoint.current = () =>
    new Promise((resolve, reject) => {
      navigator.geolocation.getCurrentPosition(function(location) {
        resolve(
          new AV.GeoPoint({
            latitude: location.coords.latitude,
            longitude: location.coords.longitude,
          })
        );
      }, reject);
    });

  _.extend(
    AV.GeoPoint.prototype,
    /** @lends AV.GeoPoint.prototype */ {
      /**
       * Returns a JSON representation of the GeoPoint, suitable for AV.
       * @return {Object}
       */
      toJSON: function() {
        AV.GeoPoint._validate(this.latitude, this.longitude);
        return {
          __type: 'GeoPoint',
          latitude: this.latitude,
          longitude: this.longitude,
        };
      },

      /**
       * Returns the distance from this GeoPoint to another in radians.
       * @param {AV.GeoPoint} point the other AV.GeoPoint.
       * @return {Number}
       */
      radiansTo: function(point) {
        var d2r = Math.PI / 180.0;
        var lat1rad = this.latitude * d2r;
        var long1rad = this.longitude * d2r;
        var lat2rad = point.latitude * d2r;
        var long2rad = point.longitude * d2r;
        var deltaLat = lat1rad - lat2rad;
        var deltaLong = long1rad - long2rad;
        var sinDeltaLatDiv2 = Math.sin(deltaLat / 2);
        var sinDeltaLongDiv2 = Math.sin(deltaLong / 2);
        // Square of half the straight line chord distance between both points.
        var a =
          sinDeltaLatDiv2 * sinDeltaLatDiv2 +
          Math.cos(lat1rad) *
            Math.cos(lat2rad) *
            sinDeltaLongDiv2 *
            sinDeltaLongDiv2;
        a = Math.min(1.0, a);
        return 2 * Math.asin(Math.sqrt(a));
      },

      /**
       * Returns the distance from this GeoPoint to another in kilometers.
       * @param {AV.GeoPoint} point the other AV.GeoPoint.
       * @return {Number}
       */
      kilometersTo: function(point) {
        return this.radiansTo(point) * 6371.0;
      },

      /**
       * Returns the distance from this GeoPoint to another in miles.
       * @param {AV.GeoPoint} point the other AV.GeoPoint.
       * @return {Number}
       */
      milesTo: function(point) {
        return this.radiansTo(point) * 3958.8;
      },
    }
  );
};