UNPKG

geofire-with-custom-data

Version:

Location-based querying and filtering using Firebase

1,104 lines (1,097 loc) 49.3 kB
'use strict'; Object.defineProperty(exports, '__esModule', { value: true }); /** * Creates a GeoCallbackRegistration instance. */ var GeoCallbackRegistration = /** @class */ (function () { /** * @param _cancelCallback Callback to run when this callback registration is cancelled. */ function GeoCallbackRegistration(_cancelCallback) { this._cancelCallback = _cancelCallback; if (Object.prototype.toString.call(this._cancelCallback) !== '[object Function]') { throw new Error('callback must be a function'); } } /********************/ /* PUBLIC METHODS */ /********************/ /** * Cancels this callback registration so that it no longer fires its callback. This * has no effect on any other callback registrations you may have created. */ GeoCallbackRegistration.prototype.cancel = function () { if (typeof this._cancelCallback !== 'undefined') { this._cancelCallback(); this._cancelCallback = undefined; } }; return GeoCallbackRegistration; }()); // 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); } } /** * Validates the inputted query criteria and throws an error if it is invalid. * * @param newQueryCriteria The criteria which specifies the query's center and/or radius. * @param requireCenterAndRadius The criteria which center and radius required. */ function validateCriteria(newQueryCriteria, requireCenterAndRadius) { if (requireCenterAndRadius === void 0) { requireCenterAndRadius = false; } if (typeof newQueryCriteria !== 'object') { throw new Error('query criteria must be an object'); } else if (typeof newQueryCriteria.center === 'undefined' && typeof newQueryCriteria.radius === 'undefined') { throw new Error('radius and/or center must be specified'); } else if (requireCenterAndRadius && (typeof newQueryCriteria.center === 'undefined' || typeof newQueryCriteria.radius === 'undefined')) { throw new Error('query criteria for a new query must contain both a center and a radius'); } // Throw an error if there are any extraneous attributes var keys = Object.keys(newQueryCriteria); for (var _i = 0, keys_1 = keys; _i < keys_1.length; _i++) { var key = keys_1[_i]; if (key !== 'center' && key !== 'radius') { throw new Error('Unexpected attribute \'' + key + '\' found in query criteria'); } } // Validate the 'center' attribute if (typeof newQueryCriteria.center !== 'undefined') { validateLocation(newQueryCriteria.center); } // Validate the 'radius' attribute if (typeof newQueryCriteria.radius !== 'undefined') { if (typeof newQueryCriteria.radius !== 'number' || isNaN(newQueryCriteria.radius)) { throw new Error('radius must be a number'); } else if (newQueryCriteria.radius < 0) { throw new Error('radius must be greater than or equal to 0'); } } } /** * 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 encodeGeohash(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. * @returns 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 queries to fully contain a given circle. A query is a [start, end] pair * where any geohash is guaranteed to be lexiographically larger then 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 geohashes containing a [start, end] pair. */ function geohashQueries(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(encodeGeohash(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]; }); }); } /** * Encodes a location and geohash as a GeoFire object. * * @param location The location as [latitude, longitude] pair. * @param geohash The geohash of the location. * @returns The location encoded as GeoFire object. */ function encodeGeoFireObject(location, geohash) { validateLocation(location); validateGeohash(geohash); return { '.priority': geohash, 'g': geohash, 'l': location }; } /** * Decodes the location given as GeoFire object. Returns null if decoding fails. * * @param geoFireObj The location encoded as GeoFire object. * @returns The location as [latitude, longitude] pair or null if decoding fails. */ function decodeGeoFireObject(geoFireObj) { if (geoFireObj && 'l' in geoFireObj && Array.isArray(geoFireObj.l) && geoFireObj.l.length === 2) { return geoFireObj.l; } else { throw new Error('Unexpected location object encountered: ' + JSON.stringify(geoFireObj)); } } /** * Returns the key of a Firebase snapshot across SDK versions. * * @param A Firebase snapshot. * @returns The Firebase snapshot's key. */ function geoFireGetKey(snapshot) { var key; if (typeof snapshot.key === 'string' || snapshot.key === null) { key = snapshot.key; } else if (typeof snapshot.key === 'function') { // @ts-ignore key = snapshot.key(); } else { // @ts-ignore key = snapshot.name(); } return key; } /** * 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 distance(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; } /** * Creates a GeoQuery instance. */ var GeoQuery = /** @class */ (function () { /** * @param _firebaseRef A Firebase reference where the GeoFire data will be stored. * @param queryCriteria The criteria which specifies the query's center and radius. */ function GeoQuery(_firebaseRef, queryCriteria) { var _this = this; this._firebaseRef = _firebaseRef; // Event callbacks this._callbacks = { ready: [], key_entered: [], key_exited: [], key_moved: [] }; // Variable to track when the query is cancelled this._cancelled = false; // A dictionary of geohash queries which currently have an active callbacks this._currentGeohashesQueried = {}; // A dictionary of locations that a currently active in the queries // Note that not all of these are currently within this query this._locationsTracked = {}; // Variables used to keep track of when to fire the 'ready' event this._valueEventFired = false; this._geohashCleanupScheduled = false; this._cleanUpCurrentGeohashesQueriedTimeout = null; // Firebase reference of the GeoFire which created this query if (Object.prototype.toString.call(this._firebaseRef) !== '[object Object]') { throw new Error('firebaseRef must be an instance of Firebase'); } // Every ten seconds, clean up the geohashes we are currently querying for. We keep these around // for a little while since it's likely that they will need to be re-queried shortly after they // move outside of the query's bounding box. this._cleanUpCurrentGeohashesQueriedInterval = setInterval(function () { if (_this._geohashCleanupScheduled === false) { _this._cleanUpCurrentGeohashesQueried(); } }, 10000); // Validate and save the query criteria validateCriteria(queryCriteria, true); this._center = queryCriteria.center; this._radius = queryCriteria.radius; // Listen for new geohashes being added around this query and fire the appropriate events this._listenForNewGeohashes(); } /********************/ /* PUBLIC METHODS */ /********************/ /** * Terminates this query so that it no longer sends location updates. All callbacks attached to this * query via on() will be cancelled. This query can no longer be used in the future. */ GeoQuery.prototype.cancel = function () { var _this = this; // Mark this query as cancelled this._cancelled = true; // Cancel all callbacks in this query's callback list this._callbacks = { ready: [], key_entered: [], key_exited: [], key_moved: [] }; // Turn off all Firebase listeners for the current geohashes being queried var keys = Object.keys(this._currentGeohashesQueried); keys.forEach(function (geohashQueryStr) { var query = _this._stringToQuery(geohashQueryStr); _this._cancelGeohashQuery(query, _this._currentGeohashesQueried[geohashQueryStr]); delete _this._currentGeohashesQueried[geohashQueryStr]; }); // Delete any stored locations this._locationsTracked = {}; // Turn off the current geohashes queried clean up interval clearInterval(this._cleanUpCurrentGeohashesQueriedInterval); }; /** * Returns the location signifying the center of this query. * * @returns The [latitude, longitude] pair signifying the center of this query. */ GeoQuery.prototype.center = function () { return this._center; }; /** * Attaches a callback to this query which will be run when the provided eventType fires. Valid eventType * values are 'ready', 'key_entered', 'key_exited', and 'key_moved'. The ready event callback is passed no * parameters. All other callbacks will be passed three parameters: (1) the location's key, (2) the location's * [latitude, longitude] pair, and (3) the distance, in kilometers, from the location to this query's center * * 'ready' is used to signify that this query has loaded its initial state and is up-to-date with its corresponding * GeoFire instance. 'ready' fires when this query has loaded all of the initial data from GeoFire and fired all * other events for that data. It also fires every time updateCriteria() is called, after all other events have * fired for the updated query. * * 'key_entered' fires when a key enters this query. This can happen when a key moves from a location outside of * this query to one inside of it or when a key is written to GeoFire for the first time and it falls within * this query. * * 'key_exited' fires when a key moves from a location inside of this query to one outside of it. If the key was * entirely removed from GeoFire, both the location and distance passed to the callback will be null. * * 'key_moved' fires when a key which is already in this query moves to another location inside of it. * * Returns a GeoCallbackRegistration which can be used to cancel the callback. You can add as many callbacks * as you would like for the same eventType by repeatedly calling on(). Each one will get called when its * corresponding eventType fires. Each callback must be cancelled individually. * * @param eventType The event type for which to attach the callback. One of 'ready', 'key_entered', * 'key_exited', or 'key_moved'. * @param callback Callback function to be called when an event of type eventType fires. * @returns A callback registration which can be used to cancel the provided callback. */ GeoQuery.prototype.on = function (eventType, callback) { var _this = this; // Validate the inputs if (['ready', 'key_entered', 'key_exited', 'key_moved'].indexOf(eventType) === -1) { throw new Error('event type must be \'ready\', \'key_entered\', \'key_exited\', or \'key_moved\''); } if (typeof callback !== 'function') { throw new Error('callback must be a function'); } // Add the callback to this query's callbacks list this._callbacks[eventType].push(callback); // If this is a 'key_entered' callback, fire it for every location already within this query if (eventType === 'key_entered') { var keys = Object.keys(this._locationsTracked); keys.forEach(function (key) { var locationDict = _this._locationsTracked[key]; if (typeof locationDict !== 'undefined' && locationDict.isInQuery) { callback(key, locationDict.location, locationDict.distanceFromCenter, locationDict.data); } }); } // If this is a 'ready' callback, fire it if this query is already ready if (eventType === 'ready' && this._valueEventFired) { callback(); } // Return an event registration which can be used to cancel the callback return new GeoCallbackRegistration(function () { _this._callbacks[eventType].splice(_this._callbacks[eventType].indexOf(callback), 1); }); }; /** * Returns the radius of this query, in kilometers. * * @returns The radius of this query, in kilometers. */ GeoQuery.prototype.radius = function () { return this._radius; }; /** * Updates the criteria for this query. * * @param newQueryCriteria The criteria which specifies the query's center and radius. */ GeoQuery.prototype.updateCriteria = function (newQueryCriteria) { // Validate and save the new query criteria validateCriteria(newQueryCriteria); this._center = newQueryCriteria.center || this._center; this._radius = newQueryCriteria.radius || this._radius; // Loop through all of the locations in the query, update their distance from the center of the // query, and fire any appropriate events var keys = Object.keys(this._locationsTracked); for (var _i = 0, keys_1 = keys; _i < keys_1.length; _i++) { var key = keys_1[_i]; // If the query was cancelled while going through this loop, stop updating locations and stop // firing events if (this._cancelled === true) { break; } // Get the cached information for this location var locationDict = this._locationsTracked[key]; // Save if the location was already in the query var wasAlreadyInQuery = locationDict.isInQuery; // Update the location's distance to the new query center locationDict.distanceFromCenter = distance(locationDict.location, this._center); // Determine if the location is now in this query locationDict.isInQuery = (locationDict.distanceFromCenter <= this._radius); // If the location just left the query, fire the 'key_exited' callbacks // Else if the location just entered the query, fire the 'key_entered' callbacks if (wasAlreadyInQuery && !locationDict.isInQuery) { this._fireCallbacksForKey('key_exited', key, locationDict.location, locationDict.distanceFromCenter); } else if (!wasAlreadyInQuery && locationDict.isInQuery) { this._fireCallbacksForKey('key_entered', key, locationDict.location, locationDict.distanceFromCenter); } } // Reset the variables which control when the 'ready' event fires this._valueEventFired = false; // Listen for new geohashes being added to GeoFire and fire the appropriate events this._listenForNewGeohashes(); }; /*********************/ /* PRIVATE METHODS */ /*********************/ /** * Turns off all callbacks for the provide geohash query. * * @param query The geohash query. * @param queryState An object storing the current state of the query. */ GeoQuery.prototype._cancelGeohashQuery = function (query, queryState) { var queryRef = this._firebaseRef.orderByChild('g').startAt(query[0]).endAt(query[1]); queryRef.off('child_added', queryState.childAddedCallback); queryRef.off('child_removed', queryState.childRemovedCallback); queryRef.off('child_changed', queryState.childChangedCallback); queryRef.off('value', queryState.valueCallback); }; /** * Callback for child added events. * * @param locationDataSnapshot A snapshot of the data stored for this location. */ GeoQuery.prototype._childAddedCallback = function (locationDataSnapshot) { var allInfo = locationDataSnapshot.val(); this._updateLocation(geoFireGetKey(locationDataSnapshot), decodeGeoFireObject(allInfo), allInfo.data); }; /** * Callback for child changed events * * @param locationDataSnapshot A snapshot of the data stored for this location. */ GeoQuery.prototype._childChangedCallback = function (locationDataSnapshot) { var allInfo = locationDataSnapshot.val(); this._updateLocation(geoFireGetKey(locationDataSnapshot), decodeGeoFireObject(allInfo), allInfo.data); }; /** * Callback for child removed events * * @param locationDataSnapshot A snapshot of the data stored for this location. */ GeoQuery.prototype._childRemovedCallback = function (locationDataSnapshot) { var _this = this; var key = geoFireGetKey(locationDataSnapshot); if (key in this._locationsTracked) { this._firebaseRef.child(key).once('value', function (snapshot) { var location = (snapshot.val() === null) ? null : decodeGeoFireObject(snapshot.val()); var geohash = (location !== null) ? encodeGeohash(location) : null; // Only notify observers if key is not part of any other geohash query or this actually might not be // a key exited event, but a key moved or entered event. These events will be triggered by updates // to a different query if (!_this._geohashInSomeQuery(geohash)) { _this._removeLocation(key, location); } }); } }; /** * Removes unnecessary Firebase queries which are currently being queried. */ GeoQuery.prototype._cleanUpCurrentGeohashesQueried = function () { var _this = this; var keys = Object.keys(this._currentGeohashesQueried); keys.forEach(function (geohashQueryStr) { var queryState = _this._currentGeohashesQueried[geohashQueryStr]; if (queryState.active === false) { var query = _this._stringToQuery(geohashQueryStr); // Delete the geohash since it should no longer be queried _this._cancelGeohashQuery(query, queryState); delete _this._currentGeohashesQueried[geohashQueryStr]; } }); // Delete each location which should no longer be queried keys = Object.keys(this._locationsTracked); keys.forEach(function (key) { if (!_this._geohashInSomeQuery(_this._locationsTracked[key].geohash)) { if (_this._locationsTracked[key].isInQuery) { throw new Error('Internal State error, trying to remove location that is still in query'); } delete _this._locationsTracked[key]; } }); // Specify that this is done cleaning up the current geohashes queried this._geohashCleanupScheduled = false; // Cancel any outstanding scheduled cleanup if (this._cleanUpCurrentGeohashesQueriedTimeout !== null) { clearTimeout(this._cleanUpCurrentGeohashesQueriedTimeout); this._cleanUpCurrentGeohashesQueriedTimeout = null; } }; /** * Fires each callback for the provided eventType, passing it provided key's data. * * @param eventType The event type whose callbacks to fire. One of 'key_entered', 'key_exited', or 'key_moved'. * @param key The key of the location for which to fire the callbacks. * @param location The location as [latitude, longitude] pair * @param distanceFromCenter The distance from the center or null. */ GeoQuery.prototype._fireCallbacksForKey = function (eventType, key, location, distanceFromCenter, data) { this._callbacks[eventType].forEach(function (callback) { if (typeof location === 'undefined' || location === null) { callback(key, null, null, null); } else { callback(key, location, distanceFromCenter, data); } }); }; /** * Fires each callback for the 'ready' event. */ GeoQuery.prototype._fireReadyEventCallbacks = function () { this._callbacks.ready.forEach(function (callback) { callback(); }); }; /** * Checks if this geohash is currently part of any of the geohash queries. * * @param geohash The geohash. * @returns Returns true if the geohash is part of any of the current geohash queries. */ GeoQuery.prototype._geohashInSomeQuery = function (geohash) { var keys = Object.keys(this._currentGeohashesQueried); for (var _i = 0, keys_2 = keys; _i < keys_2.length; _i++) { var queryStr = keys_2[_i]; if (queryStr in this._currentGeohashesQueried) { var query = this._stringToQuery(queryStr); if (geohash >= query[0] && geohash <= query[1]) { return true; } } } return false; }; /** * Called once all geohash queries have received all child added events and fires the ready * event if necessary. */ GeoQuery.prototype._geohashQueryReadyCallback = function (queryStr) { var index = this._outstandingGeohashReadyEvents.indexOf(queryStr); if (index > -1) { this._outstandingGeohashReadyEvents.splice(index, 1); } this._valueEventFired = (this._outstandingGeohashReadyEvents.length === 0); // If all queries have been processed, fire the ready event if (this._valueEventFired) { this._fireReadyEventCallbacks(); } }; /** * Attaches listeners to Firebase which track when new geohashes are added within this query's * bounding box. */ GeoQuery.prototype._listenForNewGeohashes = function () { var _this = this; // Get the list of geohashes to query var geohashesToQuery = geohashQueries(this._center, this._radius * 1000).map(this._queryToString); // Filter out duplicate geohashes geohashesToQuery = geohashesToQuery.filter(function (geohash, i) { return geohashesToQuery.indexOf(geohash) === i; }); // For all of the geohashes that we are already currently querying, check if they are still // supposed to be queried. If so, don't re-query them. Otherwise, mark them to be un-queried // next time we clean up the current geohashes queried dictionary. var keys = Object.keys(this._currentGeohashesQueried); keys.forEach(function (geohashQueryStr) { var index = geohashesToQuery.indexOf(geohashQueryStr); if (index === -1) { _this._currentGeohashesQueried[geohashQueryStr].active = false; } else { _this._currentGeohashesQueried[geohashQueryStr].active = true; geohashesToQuery.splice(index, 1); } }); // If we are not already cleaning up the current geohashes queried and we have more than 25 of them, // kick off a timeout to clean them up so we don't create an infinite number of unneeded queries. if (this._geohashCleanupScheduled === false && Object.keys(this._currentGeohashesQueried).length > 25) { this._geohashCleanupScheduled = true; this._cleanUpCurrentGeohashesQueriedTimeout = setTimeout(function () { _this._cleanUpCurrentGeohashesQueried(); }, 10); } // Keep track of which geohashes have been processed so we know when to fire the 'ready' event this._outstandingGeohashReadyEvents = geohashesToQuery.slice(); // Loop through each geohash to query for and listen for new geohashes which have the same prefix. // For every match, attach a value callback which will fire the appropriate events. // Once every geohash to query is processed, fire the 'ready' event. geohashesToQuery.forEach(function (toQueryStr) { // decode the geohash query string var query = _this._stringToQuery(toQueryStr); // Create the Firebase query var firebaseQuery = _this._firebaseRef.orderByChild('g').startAt(query[0]).endAt(query[1]); // For every new matching geohash, determine if we should fire the 'key_entered' event var childAddedCallback = firebaseQuery.on('child_added', function (a) { return _this._childAddedCallback(a); }); var childRemovedCallback = firebaseQuery.on('child_removed', function (a) { return _this._childRemovedCallback(a); }); var childChangedCallback = firebaseQuery.on('child_changed', function (a) { return _this._childChangedCallback(a); }); // Once the current geohash to query is processed, see if it is the last one to be processed // and, if so, mark the value event as fired. // Note that Firebase fires the 'value' event after every 'child_added' event fires. var valueCallback = firebaseQuery.on('value', function () { firebaseQuery.off('value', valueCallback); _this._geohashQueryReadyCallback(toQueryStr); }); // Add the geohash query to the current geohashes queried dictionary and save its state _this._currentGeohashesQueried[toQueryStr] = { active: true, childAddedCallback: childAddedCallback, childRemovedCallback: childRemovedCallback, childChangedCallback: childChangedCallback, valueCallback: valueCallback }; }); // Based upon the algorithm to calculate geohashes, it's possible that no 'new' // geohashes were queried even if the client updates the radius of the query. // This results in no 'READY' event being fired after the .updateCriteria() call. // Check to see if this is the case, and trigger the 'READY' event. if (geohashesToQuery.length === 0) { this._geohashQueryReadyCallback(); } }; /** * Encodes a query as a string for easier indexing and equality. * * @param query The query to encode. * @returns The encoded query as string. */ GeoQuery.prototype._queryToString = function (query) { if (query.length !== 2) { throw new Error('Not a valid geohash query: ' + query); } return query[0] + ':' + query[1]; }; /** * Removes the location from the local state and fires any events if necessary. * * @param key The key to be removed. * @param currentLocation The current location as [latitude, longitude] pair or null if removed. */ GeoQuery.prototype._removeLocation = function (key, currentLocation) { var locationDict = this._locationsTracked[key]; delete this._locationsTracked[key]; if (typeof locationDict !== 'undefined' && locationDict.isInQuery) { var distanceFromCenter = (currentLocation) ? distance(currentLocation, this._center) : null; this._fireCallbacksForKey('key_exited', key, currentLocation, distanceFromCenter); } }; /** * Decodes a query string to a query * * @param str The encoded query. * @returns The decoded query as a [start, end] pair. */ GeoQuery.prototype._stringToQuery = function (str) { var decoded = str.split(':'); if (decoded.length !== 2) { throw new Error('Invalid internal state! Not a valid geohash query: ' + str); } return decoded; }; /** * Callback for any updates to locations. Will update the information about a key and fire any necessary * events every time the key's location changes. * * When a key is removed from GeoFire or the query, this function will be called with null and performs * any necessary cleanup. * * @param key The key of the geofire location. * @param location The location as [latitude, longitude] pair. */ GeoQuery.prototype._updateLocation = function (key, location, data) { validateLocation(location); // Get the key and location var distanceFromCenter, isInQuery; var wasInQuery = (key in this._locationsTracked) ? this._locationsTracked[key].isInQuery : false; var oldLocation = (key in this._locationsTracked) ? this._locationsTracked[key].location : null; // Determine if the location is within this query distanceFromCenter = distance(location, this._center); isInQuery = (distanceFromCenter <= this._radius); // Add this location to the locations queried dictionary even if it is not within this query this._locationsTracked[key] = { location: location, distanceFromCenter: distanceFromCenter, isInQuery: isInQuery, geohash: encodeGeohash(location), data: data, }; // Fire the 'key_entered' event if the provided key has entered this query if (isInQuery && !wasInQuery) { this._fireCallbacksForKey('key_entered', key, location, distanceFromCenter, data); } else if (isInQuery && oldLocation !== null && (location[0] !== oldLocation[0] || location[1] !== oldLocation[1])) { this._fireCallbacksForKey('key_moved', key, location, distanceFromCenter, data); } else if (!isInQuery && wasInQuery) { this._fireCallbacksForKey('key_exited', key, location, distanceFromCenter, data); } }; return GeoQuery; }()); /** * GeoFire is an open-source library that allows you to store and query a set * of keys based on their geographic location. At its heart, GeoFire simply * stores locations with string keys. Its main benefit, however, is the * possibility of retrieving only those keys within a given geographic area - * all in realtime. * * GeoFire 0.0.0 * https://github.com/firebase/geofire-js/ * License: MIT */ /** * Creates a GeoFire instance. */ var GeoFire = /** @class */ (function () { /** * @param _firebaseRef A Firebase reference where the GeoFire data will be stored. */ function GeoFire(_firebaseRef) { this._firebaseRef = _firebaseRef; if (Object.prototype.toString.call(this._firebaseRef) !== '[object Object]') { throw new Error('firebaseRef must be an instance of Firebase'); } } /********************/ /* PUBLIC METHODS */ /********************/ /** * Returns a promise fulfilled with the location corresponding to the provided key. * * If the provided key does not exist, the returned promise is fulfilled with null. * * @param key The key of the location to retrieve. * @returns A promise that is fulfilled with the location of the given key. */ GeoFire.prototype.get = function (key) { validateKey(key); return this._firebaseRef.child(key).once('value').then(function (dataSnapshot) { var snapshotVal = dataSnapshot.val(); if (snapshotVal === null) { return null; } else { return { location: decodeGeoFireObject(snapshotVal), data: snapshotVal.data, }; } }); }; /** * Returns the Firebase instance used to create this GeoFire instance. * * @returns The Firebase instance used to create this GeoFire instance. */ GeoFire.prototype.ref = function () { return this._firebaseRef; }; /** * Removes the provided key from this GeoFire. Returns an empty promise fulfilled when the key has been removed. * * If the provided key is not in this GeoFire, the promise will still successfully resolve. * * @param key The key of the location to remove. * @returns A promise that is fulfilled after the inputted key is removed. */ GeoFire.prototype.remove = function (key) { return this.set(key, null); }; /** * Adds the provided key - location pair(s) to Firebase. Returns an empty promise which is fulfilled when the write is complete. * * If any provided key already exists in this GeoFire, it will be overwritten with the new location value. * * @param keyOrLocations The key representing the location to add or a mapping of key - location pairs which * represent the locations to add. * @param location The [latitude, longitude] pair to add. * @returns A promise that is fulfilled when the write is complete. */ GeoFire.prototype.set = function (keyOrLocations, location, customData) { var locations; if (typeof keyOrLocations === 'string' && keyOrLocations.length !== 0) { // If this is a set for a single location, convert it into a object locations = {}; locations[keyOrLocations] = location; } else if (typeof keyOrLocations === 'object') { if (typeof location !== 'undefined') { throw new Error('The location argument should not be used if you pass an object to set().'); } locations = keyOrLocations; } else { throw new Error('keyOrLocations must be a string or a mapping of key - location pairs.'); } var newData = {}; Object.keys(locations).forEach(function (key) { validateKey(key); var location = locations[key]; if (location === null) { // Setting location to null is valid since it will remove the key newData[key] = null; } else { validateLocation(location); var geohash = encodeGeohash(location); newData[key] = encodeGeoFireObject(location, geohash); newData[key].data = customData; } }); return this._firebaseRef.update(newData); }; /** * Returns a new GeoQuery instance with the provided queryCriteria. * * @param queryCriteria The criteria which specifies the GeoQuery's center and radius. * @return A new GeoQuery object. */ GeoFire.prototype.query = function (queryCriteria) { return new GeoQuery(this._firebaseRef, queryCriteria); }; /********************/ /* STATIC METHODS */ /********************/ /** * Static 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. */ GeoFire.distance = function (location1, location2) { return distance(location1, location2); }; return GeoFire; }()); exports.GeoCallbackRegistration = GeoCallbackRegistration; exports.GeoFire = GeoFire; exports.GeoQuery = GeoQuery;