UNPKG

geofire-common

Version:

Location-based querying and filtering using Firebase

387 lines (383 loc) 15.1 kB
'use strict'; Object.defineProperty(exports, '__esModule', { value: true }); // Default geohash length var GEOHASH_PRECISION = 10; // Characters used in location geohashes var BASE32 = '0123456789bcdefghjkmnpqrstuvwxyz'; // The meridional circumference of the earth in meters var EARTH_MERI_CIRCUMFERENCE = 40007860; // Length of a degree latitude at the equator var METERS_PER_DEGREE_LATITUDE = 110574; // Number of bits per geohash character var BITS_PER_CHAR = 5; // Maximum length of a geohash in bits var MAXIMUM_BITS_PRECISION = 22 * BITS_PER_CHAR; // Equatorial radius of the earth in meters var EARTH_EQ_RADIUS = 6378137.0; // The following value assumes a polar radius of // const EARTH_POL_RADIUS = 6356752.3; // The formulate to calculate E2 is // E2 == (EARTH_EQ_RADIUS^2-EARTH_POL_RADIUS^2)/(EARTH_EQ_RADIUS^2) // The exact value is used here to avoid rounding errors var E2 = 0.00669447819799; // Cutoff for rounding errors on double calculations var EPSILON = 1e-12; function log2(x) { return Math.log(x) / Math.log(2); } /** * Validates the inputted key and throws an error if it is invalid. * * @param key The key to be verified. */ function validateKey(key) { var error; if (typeof key !== 'string') { error = 'key must be a string'; } else if (key.length === 0) { error = 'key cannot be the empty string'; } else if (1 + GEOHASH_PRECISION + key.length > 755) { // Firebase can only stored child paths up to 768 characters // The child path for this key is at the least: 'i/<geohash>key' error = 'key is too long to be stored in Firebase'; } else if (/[\[\].#$\/\u0000-\u001F\u007F]/.test(key)) { // Firebase does not allow node keys to contain the following characters error = 'key cannot contain any of the following characters: . # $ ] [ /'; } if (typeof error !== 'undefined') { throw new Error('Invalid GeoFire key \'' + key + '\': ' + error); } } /** * Validates the inputted location and throws an error if it is invalid. * * @param location The [latitude, longitude] pair to be verified. */ function validateLocation(location) { var error; if (!Array.isArray(location)) { error = 'location must be an array'; } else if (location.length !== 2) { error = 'expected array of length 2, got length ' + location.length; } else { var latitude = location[0]; var longitude = location[1]; if (typeof latitude !== 'number' || isNaN(latitude)) { error = 'latitude must be a number'; } else if (latitude < -90 || latitude > 90) { error = 'latitude must be within the range [-90, 90]'; } else if (typeof longitude !== 'number' || isNaN(longitude)) { error = 'longitude must be a number'; } else if (longitude < -180 || longitude > 180) { error = 'longitude must be within the range [-180, 180]'; } } if (typeof error !== 'undefined') { throw new Error('Invalid GeoFire location \'' + location + '\': ' + error); } } /** * Validates the inputted geohash and throws an error if it is invalid. * * @param geohash The geohash to be validated. */ function validateGeohash(geohash) { var error; if (typeof geohash !== 'string') { error = 'geohash must be a string'; } else if (geohash.length === 0) { error = 'geohash cannot be the empty string'; } else { for (var _i = 0, geohash_1 = geohash; _i < geohash_1.length; _i++) { var letter = geohash_1[_i]; if (BASE32.indexOf(letter) === -1) { error = 'geohash cannot contain \'' + letter + '\''; } } } if (typeof error !== 'undefined') { throw new Error('Invalid GeoFire geohash \'' + geohash + '\': ' + error); } } /** * Converts degrees to radians. * * @param degrees The number of degrees to be converted to radians. * @returns The number of radians equal to the inputted number of degrees. */ function degreesToRadians(degrees) { if (typeof degrees !== 'number' || isNaN(degrees)) { throw new Error('Error: degrees must be a number'); } return (degrees * Math.PI / 180); } /** * Generates a geohash of the specified precision/string length from the [latitude, longitude] * pair, specified as an array. * * @param location The [latitude, longitude] pair to encode into a geohash. * @param precision The length of the geohash to create. If no precision is specified, the * global default is used. * @returns The geohash of the inputted location. */ function geohashForLocation(location, precision) { if (precision === void 0) { precision = GEOHASH_PRECISION; } validateLocation(location); if (typeof precision !== 'undefined') { if (typeof precision !== 'number' || isNaN(precision)) { throw new Error('precision must be a number'); } else if (precision <= 0) { throw new Error('precision must be greater than 0'); } else if (precision > 22) { throw new Error('precision cannot be greater than 22'); } else if (Math.round(precision) !== precision) { throw new Error('precision must be an integer'); } } var latitudeRange = { min: -90, max: 90 }; var longitudeRange = { min: -180, max: 180 }; var hash = ''; var hashVal = 0; var bits = 0; var even = 1; while (hash.length < precision) { var val = even ? location[1] : location[0]; var range = even ? longitudeRange : latitudeRange; var mid = (range.min + range.max) / 2; if (val > mid) { hashVal = (hashVal << 1) + 1; range.min = mid; } else { hashVal = (hashVal << 1) + 0; range.max = mid; } even = !even; if (bits < 4) { bits++; } else { bits = 0; hash += BASE32[hashVal]; hashVal = 0; } } return hash; } /** * Calculates the number of degrees a given distance is at a given latitude. * * @param distance The distance to convert. * @param latitude The latitude at which to calculate. * @returns The number of degrees the distance corresponds to. */ function metersToLongitudeDegrees(distance, latitude) { var radians = degreesToRadians(latitude); var num = Math.cos(radians) * EARTH_EQ_RADIUS * Math.PI / 180; var denom = 1 / Math.sqrt(1 - E2 * Math.sin(radians) * Math.sin(radians)); var deltaDeg = num * denom; if (deltaDeg < EPSILON) { return distance > 0 ? 360 : 0; } else { return Math.min(360, distance / deltaDeg); } } /** * Calculates the bits necessary to reach a given resolution, in meters, for the longitude at a * given latitude. * * @param resolution The desired resolution. * @param latitude The latitude used in the conversion. * @return The bits necessary to reach a given resolution, in meters. */ function longitudeBitsForResolution(resolution, latitude) { var degs = metersToLongitudeDegrees(resolution, latitude); return (Math.abs(degs) > 0.000001) ? Math.max(1, log2(360 / degs)) : 1; } /** * Calculates the bits necessary to reach a given resolution, in meters, for the latitude. * * @param resolution The bits necessary to reach a given resolution, in meters. * @returns Bits necessary to reach a given resolution, in meters, for the latitude. */ function latitudeBitsForResolution(resolution) { return Math.min(log2(EARTH_MERI_CIRCUMFERENCE / 2 / resolution), MAXIMUM_BITS_PRECISION); } /** * Wraps the longitude to [-180,180]. * * @param longitude The longitude to wrap. * @returns longitude The resulting longitude. */ function wrapLongitude(longitude) { if (longitude <= 180 && longitude >= -180) { return longitude; } var adjusted = longitude + 180; if (adjusted > 0) { return (adjusted % 360) - 180; } else { return 180 - (-adjusted % 360); } } /** * Calculates the maximum number of bits of a geohash to get a bounding box that is larger than a * given size at the given coordinate. * * @param coordinate The coordinate as a [latitude, longitude] pair. * @param size The size of the bounding box. * @returns The number of bits necessary for the geohash. */ function boundingBoxBits(coordinate, size) { var latDeltaDegrees = size / METERS_PER_DEGREE_LATITUDE; var latitudeNorth = Math.min(90, coordinate[0] + latDeltaDegrees); var latitudeSouth = Math.max(-90, coordinate[0] - latDeltaDegrees); var bitsLat = Math.floor(latitudeBitsForResolution(size)) * 2; var bitsLongNorth = Math.floor(longitudeBitsForResolution(size, latitudeNorth)) * 2 - 1; var bitsLongSouth = Math.floor(longitudeBitsForResolution(size, latitudeSouth)) * 2 - 1; return Math.min(bitsLat, bitsLongNorth, bitsLongSouth, MAXIMUM_BITS_PRECISION); } /** * Calculates eight points on the bounding box and the center of a given circle. At least one * geohash of these nine coordinates, truncated to a precision of at most radius, are guaranteed * to be prefixes of any geohash that lies within the circle. * * @param center The center given as [latitude, longitude]. * @param radius The radius of the circle in meters. * @returns The center of the box, and the eight bounding box points. */ function boundingBoxCoordinates(center, radius) { var latDegrees = radius / METERS_PER_DEGREE_LATITUDE; var latitudeNorth = Math.min(90, center[0] + latDegrees); var latitudeSouth = Math.max(-90, center[0] - latDegrees); var longDegsNorth = metersToLongitudeDegrees(radius, latitudeNorth); var longDegsSouth = metersToLongitudeDegrees(radius, latitudeSouth); var longDegs = Math.max(longDegsNorth, longDegsSouth); return [ [center[0], center[1]], [center[0], wrapLongitude(center[1] - longDegs)], [center[0], wrapLongitude(center[1] + longDegs)], [latitudeNorth, center[1]], [latitudeNorth, wrapLongitude(center[1] - longDegs)], [latitudeNorth, wrapLongitude(center[1] + longDegs)], [latitudeSouth, center[1]], [latitudeSouth, wrapLongitude(center[1] - longDegs)], [latitudeSouth, wrapLongitude(center[1] + longDegs)] ]; } /** * Calculates the bounding box query for a geohash with x bits precision. * * @param geohash The geohash whose bounding box query to generate. * @param bits The number of bits of precision. * @returns A [start, end] pair of geohashes. */ function geohashQuery(geohash, bits) { validateGeohash(geohash); var precision = Math.ceil(bits / BITS_PER_CHAR); if (geohash.length < precision) { return [geohash, geohash + '~']; } geohash = geohash.substring(0, precision); var base = geohash.substring(0, geohash.length - 1); var lastValue = BASE32.indexOf(geohash.charAt(geohash.length - 1)); var significantBits = bits - (base.length * BITS_PER_CHAR); var unusedBits = (BITS_PER_CHAR - significantBits); // delete unused bits var startValue = (lastValue >> unusedBits) << unusedBits; var endValue = startValue + (1 << unusedBits); if (endValue > 31) { return [base + BASE32[startValue], base + '~']; } else { return [base + BASE32[startValue], base + BASE32[endValue]]; } } /** * Calculates a set of query bounds to fully contain a given circle, each being a [start, end] pair * where any geohash is guaranteed to be lexicographically larger than start and smaller than end. * * @param center The center given as [latitude, longitude] pair. * @param radius The radius of the circle. * @return An array of geohash query bounds, each containing a [start, end] pair. */ function geohashQueryBounds(center, radius) { validateLocation(center); var queryBits = Math.max(1, boundingBoxBits(center, radius)); var geohashPrecision = Math.ceil(queryBits / BITS_PER_CHAR); var coordinates = boundingBoxCoordinates(center, radius); var queries = coordinates.map(function (coordinate) { return geohashQuery(geohashForLocation(coordinate, geohashPrecision), queryBits); }); // remove duplicates return queries.filter(function (query, index) { return !queries.some(function (other, otherIndex) { return index > otherIndex && query[0] === other[0] && query[1] === other[1]; }); }); } /** * Method which calculates the distance, in kilometers, between two locations, * via the Haversine formula. Note that this is approximate due to the fact that the * Earth's radius varies between 6356.752 km and 6378.137 km. * * @param location1 The [latitude, longitude] pair of the first location. * @param location2 The [latitude, longitude] pair of the second location. * @returns The distance, in kilometers, between the inputted locations. */ function distanceBetween(location1, location2) { validateLocation(location1); validateLocation(location2); var radius = 6371; // Earth's radius in kilometers var latDelta = degreesToRadians(location2[0] - location1[0]); var lonDelta = degreesToRadians(location2[1] - location1[1]); var a = (Math.sin(latDelta / 2) * Math.sin(latDelta / 2)) + (Math.cos(degreesToRadians(location1[0])) * Math.cos(degreesToRadians(location2[0])) * Math.sin(lonDelta / 2) * Math.sin(lonDelta / 2)); var c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a)); return radius * c; } exports.BASE32 = BASE32; exports.BITS_PER_CHAR = BITS_PER_CHAR; exports.E2 = E2; exports.EARTH_EQ_RADIUS = EARTH_EQ_RADIUS; exports.EARTH_MERI_CIRCUMFERENCE = EARTH_MERI_CIRCUMFERENCE; exports.EPSILON = EPSILON; exports.GEOHASH_PRECISION = GEOHASH_PRECISION; exports.MAXIMUM_BITS_PRECISION = MAXIMUM_BITS_PRECISION; exports.METERS_PER_DEGREE_LATITUDE = METERS_PER_DEGREE_LATITUDE; exports.boundingBoxBits = boundingBoxBits; exports.boundingBoxCoordinates = boundingBoxCoordinates; exports.degreesToRadians = degreesToRadians; exports.distanceBetween = distanceBetween; exports.geohashForLocation = geohashForLocation; exports.geohashQuery = geohashQuery; exports.geohashQueryBounds = geohashQueryBounds; exports.latitudeBitsForResolution = latitudeBitsForResolution; exports.longitudeBitsForResolution = longitudeBitsForResolution; exports.metersToLongitudeDegrees = metersToLongitudeDegrees; exports.validateGeohash = validateGeohash; exports.validateKey = validateKey; exports.validateLocation = validateLocation; exports.wrapLongitude = wrapLongitude;