diff --git a/bower.json b/bower.json index 1440c211..4f30da0c 100644 --- a/bower.json +++ b/bower.json @@ -1,7 +1,7 @@ { "name": "geofire", "description": "Location-based querying and filtering using Firebase", - "version": "0.0.0", + "version": "4.0.0", "authors": [ "Firebase (https://www.firebase.com/)" ], diff --git a/dist/geofire.js b/dist/geofire.js new file mode 100644 index 00000000..6df7eeb6 --- /dev/null +++ b/dist/geofire.js @@ -0,0 +1,1196 @@ +/*! + * 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 4.0.0 + * https://github.com/firebase/geofire-js/ + * License: MIT + */ + +var GeoFire = (function() { + "use strict"; + +/** + * Creates a GeoCallbackRegistration instance. + * + * @constructor + * @this {GeoCallbackRegistration} + * @callback cancelCallback Callback to run when this callback registration is cancelled. + */ +var GeoCallbackRegistration = function(cancelCallback) { + /********************/ + /* 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. + */ + this.cancel = function() { + if (typeof _cancelCallback !== "undefined") { + _cancelCallback(); + _cancelCallback = undefined; + } + }; + + /*****************/ + /* CONSTRUCTOR */ + /*****************/ + if (typeof cancelCallback !== "function") { + throw new Error("callback must be a function"); + } + + var _cancelCallback = cancelCallback; +}; +/** + * Creates a GeoFire instance. + * + * @constructor + * @this {GeoFire} + * @param {Firebase} firebaseRef A Firebase reference where the GeoFire data will be stored. + */ +var GeoFire = function(firebaseRef) { + /********************/ + /* PUBLIC METHODS */ + /********************/ + /** + * Returns the Firebase instance used to create this GeoFire instance. + * + * @return {Firebase} The Firebase instance used to create this GeoFire instance. + */ + this.ref = function() { + return _firebaseRef; + }; + + /** + * 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 {string|Object} keyOrLocations The key representing the location to add or a mapping of key - location pairs which + * represent the locations to add. + * @param {Array.|undefined} location The [latitude, longitude] pair to add. + * @return {Promise.<>} A promise that is fulfilled when the write is complete. + */ + this.set = function(keyOrLocations, location) { + 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); + } + }); + + return _firebaseRef.update(newData); + }; + + /** + * 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 {string} key The key of the location to retrieve. + * @return {Promise.>} A promise that is fulfilled with the location of the given key. + */ + this.get = function(key) { + validateKey(key); + return _firebaseRef.child(key).once("value").then(function(dataSnapshot) { + var snapshotVal = dataSnapshot.val(); + if (snapshotVal === null) { + return null; + } else { + return decodeGeoFireObject(snapshotVal); + } + }); + }; + + /** + * 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 {string} key The key of the location to remove. + * @return {Promise.} A promise that is fulfilled after the inputted key is removed. + */ + this.remove = function(key) { + return this.set(key, null); + }; + + /** + * Returns a new GeoQuery instance with the provided queryCriteria. + * + * @param {Object} queryCriteria The criteria which specifies the GeoQuery's center and radius. + * @return {GeoQuery} A new GeoQuery object. + */ + this.query = function(queryCriteria) { + return new GeoQuery(_firebaseRef, queryCriteria); + }; + + /*****************/ + /* CONSTRUCTOR */ + /*****************/ + if (Object.prototype.toString.call(firebaseRef) !== "[object Object]") { + throw new Error("firebaseRef must be an instance of Firebase"); + } + + var _firebaseRef = firebaseRef; +}; + +/** + * 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 {Array.} location1 The [latitude, longitude] pair of the first location. + * @param {Array.} location2 The [latitude, longitude] pair of the second location. + * @return {number} The distance, in kilometers, between the inputted locations. + */ +GeoFire.distance = function(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; +}; + +// Default geohash length +var g_GEOHASH_PRECISION = 10; + +// Characters used in location geohashes +var g_BASE32 = "0123456789bcdefghjkmnpqrstuvwxyz"; + +// The meridional circumference of the earth in meters +var g_EARTH_MERI_CIRCUMFERENCE = 40007860; + +// Length of a degree latitude at the equator +var g_METERS_PER_DEGREE_LATITUDE = 110574; + +// Number of bits per geohash character +var g_BITS_PER_CHAR = 5; + +// Maximum length of a geohash in bits +var g_MAXIMUM_BITS_PRECISION = 22*g_BITS_PER_CHAR; + +// Equatorial radius of the earth in meters +var g_EARTH_EQ_RADIUS = 6378137.0; + +// The following value assumes a polar radius of +// var g_EARTH_POL_RADIUS = 6356752.3; +// The formulate to calculate g_E2 is +// g_E2 == (g_EARTH_EQ_RADIUS^2-g_EARTH_POL_RADIUS^2)/(g_EARTH_EQ_RADIUS^2) +// The exact value is used here to avoid rounding errors +var g_E2 = 0.00669447819799; + +// Cutoff for rounding errors on double calculations +var g_EPSILON = 1e-12; + +Math.log2 = Math.log2 || function(x) { + return Math.log(x)/Math.log(2); +}; + +/** + * Validates the inputted key and throws an error if it is invalid. + * + * @param {string} key The key to be verified. + */ +var validateKey = function(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 + g_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/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 {Array.} location The [latitude, longitude] pair to be verified. + */ +var validateLocation = function(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 {string} geohash The geohash to be validated. + */ +var validateGeohash = function(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, length = geohash.length; i < length; ++i) { + if (g_BASE32.indexOf(geohash[i]) === -1) { + error = "geohash cannot contain \"" + geohash[i] + "\""; + } + } + } + + 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 {Object} newQueryCriteria The criteria which specifies the query's center and/or radius. + */ +var validateCriteria = function(newQueryCriteria, requireCenterAndRadius) { + 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); + var numKeys = keys.length; + for (var i = 0; i < numKeys; ++i) { + var key = keys[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 {number} degrees The number of degrees to be converted to radians. + * @return {number} The number of radians equal to the inputted number of degrees. + */ +var degreesToRadians = function(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 {Array.} location The [latitude, longitude] pair to encode into a geohash. + * @param {number=} precision The length of the geohash to create. If no precision is + * specified, the global default is used. + * @return {string} The geohash of the inputted location. + */ +var encodeGeohash = function(location, 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"); + } + } + + // Use the global precision default if no precision is specified + precision = precision || g_GEOHASH_PRECISION; + + 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; + + /* jshint -W016 */ + if (val > mid) { + hashVal = (hashVal << 1) + 1; + range.min = mid; + } + else { + hashVal = (hashVal << 1) + 0; + range.max = mid; + } + /* jshint +W016 */ + + even = !even; + if (bits < 4) { + bits++; + } + else { + bits = 0; + hash += g_BASE32[hashVal]; + hashVal = 0; + } + } + + return hash; +}; + +/** + * Calculates the number of degrees a given distance is at a given latitude. + * + * @param {number} distance The distance to convert. + * @param {number} latitude The latitude at which to calculate. + * @return {number} The number of degrees the distance corresponds to. + */ +var metersToLongitudeDegrees = function(distance, latitude) { + var radians = degreesToRadians(latitude); + var num = Math.cos(radians)*g_EARTH_EQ_RADIUS*Math.PI/180; + var denom = 1/Math.sqrt(1-g_E2*Math.sin(radians)*Math.sin(radians)); + var deltaDeg = num*denom; + if (deltaDeg < g_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 {number} resolution The desired resolution. + * @param {number} latitude The latitude used in the conversion. + * @return {number} The bits necessary to reach a given resolution, in meters. + */ +var longitudeBitsForResolution = function(resolution, latitude) { + var degs = metersToLongitudeDegrees(resolution, latitude); + return (Math.abs(degs) > 0.000001) ? Math.max(1, Math.log2(360/degs)) : 1; +}; + +/** + * Calculates the bits necessary to reach a given resolution, in meters, for the latitude. + * + * @param {number} resolution The bits necessary to reach a given resolution, in meters. + */ +var latitudeBitsForResolution = function(resolution) { + return Math.min(Math.log2(g_EARTH_MERI_CIRCUMFERENCE/2/resolution), g_MAXIMUM_BITS_PRECISION); +}; + +/** + * Wraps the longitude to [-180,180]. + * + * @param {number} longitude The longitude to wrap. + * @return {number} longitude The resulting longitude. + */ +var wrapLongitude = function(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 {Array.} coordinate The coordinate as a [latitude, longitude] pair. + * @param {number} size The size of the bounding box. + * @return {number} The number of bits necessary for the geohash. + */ +var boundingBoxBits = function(coordinate, size) { + var latDeltaDegrees = size/g_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, g_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 {Array.} center The center given as [latitude, longitude]. + * @param {number} radius The radius of the circle. + * @return {Array.>} The eight bounding box points. + */ +var boundingBoxCoordinates = function(center, radius) { + var latDegrees = radius/g_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 {string} geohash The geohash whose bounding box query to generate. + * @param {number} bits The number of bits of precision. + * @return {Array.} A [start, end] pair of geohashes. + */ +var geohashQuery = function(geohash, bits) { + validateGeohash(geohash); + var precision = Math.ceil(bits/g_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 = g_BASE32.indexOf(geohash.charAt(geohash.length - 1)); + var significantBits = bits - (base.length*g_BITS_PER_CHAR); + var unusedBits = (g_BITS_PER_CHAR - significantBits); + /*jshint bitwise: false*/ + // delete unused bits + var startValue = (lastValue >> unusedBits) << unusedBits; + var endValue = startValue + (1 << unusedBits); + /*jshint bitwise: true*/ + if (endValue > 31) { + return [base+g_BASE32[startValue], base+"~"]; + } + else { + return [base+g_BASE32[startValue], base+g_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 {Array.} center The center given as [latitude, longitude] pair. + * @param {number} radius The radius of the circle. + * @return {Array.>} An array of geohashes containing a [start, end] pair. + */ +var geohashQueries = function(center, radius) { + validateLocation(center); + var queryBits = Math.max(1, boundingBoxBits(center, radius)); + var geohashPrecision = Math.ceil(queryBits/g_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 {Array.} location The location as [latitude, longitude] pair. + * @param {string} geohash The geohash of the location. + * @return {Object} 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 {Object} geoFireObj The location encoded as GeoFire object. + * @return {?Array.} location The location as [latitude, longitude] pair or null if + * decoding fails. + */ +function decodeGeoFireObject(geoFireObj) { + if (geoFireObj !== null && geoFireObj.hasOwnProperty("l") && Array.isArray(geoFireObj.l) && geoFireObj.l.length === 2) { + return geoFireObj.l; + } else { + throw new Error("Unexpected GeoFire location object encountered: " + JSON.stringify(geoFireObj)); + } +} + +/** + * Creates a GeoQuery instance. + * + * @constructor + * @this {GeoQuery} + * @param {Firebase} firebaseRef A Firebase reference. + * @param {Object} queryCriteria The criteria which specifies the query's center and radius. + */ +var GeoQuery = function (firebaseRef, queryCriteria) { + /*********************/ + /* PRIVATE METHODS */ + /*********************/ + /** + * Fires each callback for the provided eventType, passing it provided key's data. + * + * @param {string} eventType The event type whose callbacks to fire. One of "key_entered", "key_exited", or "key_moved". + * @param {string} key The key of the location for which to fire the callbacks. + * @param {?Array.} location The location as [latitude, longitude] pair + * @param {?double} distanceFromCenter The distance from the center or null. + */ + function _fireCallbacksForKey(eventType, key, location, distanceFromCenter) { + _callbacks[eventType].forEach(function(callback) { + if (typeof location === "undefined" || location === null) { + callback(key, null, null); + } + else { + callback(key, location, distanceFromCenter); + } + }); + } + + /** + * Fires each callback for the "ready" event. + */ + function _fireReadyEventCallbacks() { + _callbacks.ready.forEach(function(callback) { + callback(); + }); + } + + /** + * Decodes a query string to a query + * + * @param {string} str The encoded query. + * @return {Array.} The decoded query as a [start, end] pair. + */ + function _stringToQuery(string) { + var decoded = string.split(":"); + if (decoded.length !== 2) { + throw new Error("Invalid internal state! Not a valid geohash query: " + string); + } + return decoded; + } + + /** + * Encodes a query as a string for easier indexing and equality. + * + * @param {Array.} query The query to encode. + * @param {string} The encoded query as string. + */ + function _queryToString(query) { + if (query.length !== 2) { + throw new Error("Not a valid geohash query: " + query); + } + return query[0]+":"+query[1]; + } + + /** + * Turns off all callbacks for the provide geohash query. + * + * @param {Array.} query The geohash query. + * @param {Object} queryState An object storing the current state of the query. + */ + function _cancelGeohashQuery(query, queryState) { + var queryRef = _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); + } + + /** + * Removes unnecessary Firebase queries which are currently being queried. + */ + function _cleanUpCurrentGeohashesQueried() { + var keys = Object.keys(_currentGeohashesQueried); + var numKeys = keys.length; + for (var i = 0; i < numKeys; ++i) { + var geohashQueryStr = keys[i]; + var queryState = _currentGeohashesQueried[geohashQueryStr]; + if (queryState.active === false) { + var query = _stringToQuery(geohashQueryStr); + // Delete the geohash since it should no longer be queried + _cancelGeohashQuery(query, queryState); + delete _currentGeohashesQueried[geohashQueryStr]; + } + } + + // Delete each location which should no longer be queried + keys = Object.keys(_locationsTracked); + numKeys = keys.length; + for (i = 0; i < numKeys; ++i) { + var key = keys[i]; + if (!_geohashInSomeQuery(_locationsTracked[key].geohash)) { + if (_locationsTracked[key].isInQuery) { + throw new Error("Internal State error, trying to remove location that is still in query"); + } + delete _locationsTracked[key]; + } + } + + // Specify that this is done cleaning up the current geohashes queried + _geohashCleanupScheduled = false; + + // Cancel any outstanding scheduled cleanup + if (_cleanUpCurrentGeohashesQueriedTimeout !== null) { + clearTimeout(_cleanUpCurrentGeohashesQueriedTimeout); + _cleanUpCurrentGeohashesQueriedTimeout = null; + } + } + + /** + * 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 {string} key The key of the geofire location. + * @param {?Array.} location The location as [latitude, longitude] pair. + */ + function _updateLocation(key, location) { + validateLocation(location); + // Get the key and location + var distanceFromCenter, isInQuery; + var wasInQuery = (_locationsTracked.hasOwnProperty(key)) ? _locationsTracked[key].isInQuery : false; + var oldLocation = (_locationsTracked.hasOwnProperty(key)) ? _locationsTracked[key].location : null; + + // Determine if the location is within this query + distanceFromCenter = GeoFire.distance(location, _center); + isInQuery = (distanceFromCenter <= _radius); + + // Add this location to the locations queried dictionary even if it is not within this query + _locationsTracked[key] = { + location: location, + distanceFromCenter: distanceFromCenter, + isInQuery: isInQuery, + geohash: encodeGeohash(location, g_GEOHASH_PRECISION) + }; + + // Fire the "key_entered" event if the provided key has entered this query + if (isInQuery && !wasInQuery) { + _fireCallbacksForKey("key_entered", key, location, distanceFromCenter); + } else if (isInQuery && oldLocation !== null && (location[0] !== oldLocation[0] || location[1] !== oldLocation[1])) { + _fireCallbacksForKey("key_moved", key, location, distanceFromCenter); + } else if (!isInQuery && wasInQuery) { + _fireCallbacksForKey("key_exited", key, location, distanceFromCenter); + } + } + + /** + * Checks if this geohash is currently part of any of the geohash queries. + * + * @param {string} geohash The geohash. + * @param {boolean} Returns true if the geohash is part of any of the current geohash queries. + */ + function _geohashInSomeQuery(geohash) { + var keys = Object.keys(_currentGeohashesQueried); + var numKeys = keys.length; + for (var i = 0; i < numKeys; ++i) { + var queryStr = keys[i]; + if (_currentGeohashesQueried.hasOwnProperty(queryStr)) { + var query = _stringToQuery(queryStr); + if (geohash >= query[0] && geohash <= query[1]) { + return true; + } + } + } + return false; + } + + /** + * Removes the location from the local state and fires any events if necessary. + * + * @param {string} key The key to be removed. + * @param {?Array.} currentLocation The current location as [latitude, longitude] pair + * or null if removed. + */ + function _removeLocation(key, currentLocation) { + var locationDict = _locationsTracked[key]; + delete _locationsTracked[key]; + if (typeof locationDict !== "undefined" && locationDict.isInQuery) { + var distanceFromCenter = (currentLocation) ? GeoFire.distance(currentLocation, _center) : null; + _fireCallbacksForKey("key_exited", key, currentLocation, distanceFromCenter); + } + } + + /** + * Callback for child added events. + * + * @param {Firebase DataSnapshot} locationDataSnapshot A snapshot of the data stored for this location. + */ + function _childAddedCallback(locationDataSnapshot) { + _updateLocation(locationDataSnapshot.key(), decodeGeoFireObject(locationDataSnapshot.val())); + } + + /** + * Callback for child changed events + * + * @param {Firebase DataSnapshot} locationDataSnapshot A snapshot of the data stored for this location. + */ + function _childChangedCallback(locationDataSnapshot) { + _updateLocation(locationDataSnapshot.key(), decodeGeoFireObject(locationDataSnapshot.val())); + } + + /** + * Callback for child removed events + * + * @param {Firebase DataSnapshot} locationDataSnapshot A snapshot of the data stored for this location. + */ + function _childRemovedCallback(locationDataSnapshot) { + var key = locationDataSnapshot.key(); + if (_locationsTracked.hasOwnProperty(key)) { + _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 (!_geohashInSomeQuery(geohash)) { + _removeLocation(key, location); + } + }); + } + } + + /** + * Called once all geohash queries have received all child added events and fires the ready + * event if necessary. + */ + function _geohashQueryReadyCallback(queryStr) { + var index = _outstandingGeohashReadyEvents.indexOf(queryStr); + if (index > -1) { + _outstandingGeohashReadyEvents.splice(index, 1); + } + _valueEventFired = (_outstandingGeohashReadyEvents.length === 0); + + // If all queries have been processed, fire the ready event + if (_valueEventFired) { + _fireReadyEventCallbacks(); + } + } + + /** + * Attaches listeners to Firebase which track when new geohashes are added within this query's + * bounding box. + */ + function _listenForNewGeohashes() { + // Get the list of geohashes to query + var geohashesToQuery = geohashQueries(_center, _radius*1000).map(_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(_currentGeohashesQueried); + var numKeys = keys.length; + for (var i = 0; i < numKeys; ++i) { + var geohashQueryStr = keys[i]; + var index = geohashesToQuery.indexOf(geohashQueryStr); + if (index === -1) { + _currentGeohashesQueried[geohashQueryStr].active = false; + } + else { + _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 (_geohashCleanupScheduled === false && Object.keys(_currentGeohashesQueried).length > 25) { + _geohashCleanupScheduled = true; + _cleanUpCurrentGeohashesQueriedTimeout = setTimeout(_cleanUpCurrentGeohashesQueried, 10); + } + + // Keep track of which geohashes have been processed so we know when to fire the "ready" event + _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 = _stringToQuery(toQueryStr); + + // Create the Firebase query + var firebaseQuery = _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", _childAddedCallback); + var childRemovedCallback = firebaseQuery.on("child_removed", _childRemovedCallback); + var childChangedCallback = firebaseQuery.on("child_changed", _childChangedCallback); + + // 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); + _geohashQueryReadyCallback(toQueryStr); + }); + + // Add the geohash query to the current geohashes queried dictionary and save its state + _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 .updateQuery() call. + // Check to see if this is the case, and trigger the "READY" event. + if(geohashesToQuery.length === 0) { + _geohashQueryReadyCallback(); + } + } + + /********************/ + /* PUBLIC METHODS */ + /********************/ + /** + * Returns the location signifying the center of this query. + * + * @return {Array.} The [latitude, longitude] pair signifying the center of this query. + */ + this.center = function() { + return _center; + }; + + /** + * Returns the radius of this query, in kilometers. + * + * @return {number} The radius of this query, in kilometers. + */ + this.radius = function() { + return _radius; + }; + + /** + * Updates the criteria for this query. + * + * @param {Object} newQueryCriteria The criteria which specifies the query's center and radius. + */ + this.updateCriteria = function(newQueryCriteria) { + // Validate and save the new query criteria + validateCriteria(newQueryCriteria); + _center = newQueryCriteria.center || _center; + _radius = newQueryCriteria.radius || _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(_locationsTracked); + var numKeys = keys.length; + for (var i = 0; i < numKeys; ++i) { + var key = keys[i]; + + // Get the cached information for this location + var locationDict = _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 = GeoFire.distance(locationDict.location, _center); + + // Determine if the location is now in this query + locationDict.isInQuery = (locationDict.distanceFromCenter <= _radius); + + // If the location just left the query, fire the "key_exited" callbacks + if (wasAlreadyInQuery && !locationDict.isInQuery) { + _fireCallbacksForKey("key_exited", key, locationDict.location, locationDict.distanceFromCenter); + } + + // If the location just entered the query, fire the "key_entered" callbacks + else if (!wasAlreadyInQuery && locationDict.isInQuery) { + _fireCallbacksForKey("key_entered", key, locationDict.location, locationDict.distanceFromCenter); + } + } + + // Reset the variables which control when the "ready" event fires + _valueEventFired = false; + + // Listen for new geohashes being added to GeoFire and fire the appropriate events + _listenForNewGeohashes(); + }; + + /** + * 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 updateQuery() 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 {string} eventType The event type for which to attach the callback. One of "ready", "key_entered", + * "key_exited", or "key_moved". + * @callback callback Callback function to be called when an event of type eventType fires. + * @return {GeoCallbackRegistration} A callback registration which can be used to cancel the provided callback. + */ + this.on = function(eventType, callback) { + // 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 + _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(_locationsTracked); + var numKeys = keys.length; + for (var i = 0; i < numKeys; ++i) { + var key = keys[i]; + var locationDict = _locationsTracked[key]; + if (typeof locationDict !== "undefined" && locationDict.isInQuery) { + callback(key, locationDict.location, locationDict.distanceFromCenter); + } + } + } + + // If this is a "ready" callback, fire it if this query is already ready + if (eventType === "ready") { + if (_valueEventFired) { + callback(); + } + } + + // Return an event registration which can be used to cancel the callback + return new GeoCallbackRegistration(function() { + _callbacks[eventType].splice(_callbacks[eventType].indexOf(callback), 1); + }); + }; + + /** + * 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. + */ + this.cancel = function () { + // Cancel all callbacks in this query's callback list + _callbacks = { + ready: [], + key_entered: [], + key_exited: [], + key_moved: [] + }; + + // Turn off all Firebase listeners for the current geohashes being queried + var keys = Object.keys(_currentGeohashesQueried); + var numKeys = keys.length; + for (var i = 0; i < numKeys; ++i) { + var geohashQueryStr = keys[i]; + var query = _stringToQuery(geohashQueryStr); + _cancelGeohashQuery(query, _currentGeohashesQueried[geohashQueryStr]); + delete _currentGeohashesQueried[geohashQueryStr]; + } + + // Delete any stored locations + _locationsTracked = {}; + + // Turn off the current geohashes queried clean up interval + clearInterval(_cleanUpCurrentGeohashesQueriedInterval); + }; + + + /*****************/ + /* CONSTRUCTOR */ + /*****************/ + // Firebase reference of the GeoFire which created this query + if (Object.prototype.toString.call(firebaseRef) !== "[object Object]") { + throw new Error("firebaseRef must be an instance of Firebase"); + } + var _firebaseRef = firebaseRef; + + // Event callbacks + var _callbacks = { + ready: [], + key_entered: [], + key_exited: [], + key_moved: [] + }; + + // Variables used to keep track of when to fire the "ready" event + var _valueEventFired = false; + var _outstandingGeohashReadyEvents; + + // A dictionary of locations that a currently active in the queries + // Note that not all of these are currently within this query + var _locationsTracked = {}; + + // A dictionary of geohash queries which currently have an active callbacks + var _currentGeohashesQueried = {}; + + // 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. + var _geohashCleanupScheduled = false; + var _cleanUpCurrentGeohashesQueriedTimeout = null; + var _cleanUpCurrentGeohashesQueriedInterval = setInterval(function() { + if (_geohashCleanupScheduled === false) { + _cleanUpCurrentGeohashesQueried(); + } + }, 10000); + + // Validate and save the query criteria + validateCriteria(queryCriteria, /* requireCenterAndRadius */ true); + var _center = queryCriteria.center; + var _radius = queryCriteria.radius; + + // Listen for new geohashes being added around this query and fire the appropriate events + _listenForNewGeohashes(); +}; + + return GeoFire; +})(); + +// Export GeoFire if this is being run in node +if (typeof module !== "undefined" && typeof process !== "undefined") { + module.exports = GeoFire; +} \ No newline at end of file diff --git a/dist/geofire.min.js b/dist/geofire.min.js new file mode 100644 index 00000000..ace8341f --- /dev/null +++ b/dist/geofire.min.js @@ -0,0 +1,12 @@ +/*! + * 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 4.0.0 + * https://github.com/firebase/geofire-js/ + * License: MIT + */ +var GeoFire=function(){"use strict";function e(e,n){return y(e),v(n),{".priority":n,g:n,l:e}}function n(e){if(null!==e&&e.hasOwnProperty("l")&&Array.isArray(e.l)&&2===e.l.length)return e.l;throw new Error("Unexpected GeoFire location object encountered: "+JSON.stringify(e))}var t=function(e){if(this.cancel=function(){"undefined"!=typeof n&&(n(),n=void 0)},"function"!=typeof e)throw new Error("callback must be a function");var n=e},r=function(t){if(this.ref=function(){return r},this.set=function(n,t){var i;if("string"==typeof n&&0!==n.length)i={},i[n]=t;else{if("object"!=typeof n)throw new Error("keyOrLocations must be a string or a mapping of key - location pairs.");if("undefined"!=typeof t)throw new Error("The location argument should not be used if you pass an object to set().");i=n}var o={};return Object.keys(i).forEach(function(n){d(n);var t=i[n];if(null===t)o[n]=null;else{y(t);var r=b(t);o[n]=e(t,r)}}),r.update(o)},this.get=function(e){return d(e),r.child(e).once("value").then(function(e){var t=e.val();return null===t?null:n(t)})},this.remove=function(e){return this.set(e,null)},this.query=function(e){return new j(r,e)},"[object Object]"!==Object.prototype.toString.call(t))throw new Error("firebaseRef must be an instance of Firebase");var r=t};r.distance=function(e,n){y(e),y(n);var t=6371,r=m(n[0]-e[0]),i=m(n[1]-e[1]),o=Math.sin(r/2)*Math.sin(r/2)+Math.cos(m(e[0]))*Math.cos(m(n[0]))*Math.sin(i/2)*Math.sin(i/2),a=2*Math.atan2(Math.sqrt(o),Math.sqrt(1-o));return t*a};var i=10,o="0123456789bcdefghjkmnpqrstuvwxyz",a=40007860,u=110574,f=5,c=22*f,l=6378137,s=.00669447819799,h=1e-12;Math.log2=Math.log2||function(e){return Math.log(e)/Math.log(2)};var d=function(e){var n;if("string"!=typeof e?n="key must be a string":0===e.length?n="key cannot be the empty string":1+i+e.length>755?n="key is too long to be stored in Firebase":/[\[\].#$\/\u0000-\u001F\u007F]/.test(e)&&(n="key cannot contain any of the following characters: . # $ ] [ /"),"undefined"!=typeof n)throw new Error("Invalid GeoFire key '"+e+"': "+n)},y=function(e){var n;if(Array.isArray(e))if(2!==e.length)n="expected array of length 2, got length "+e.length;else{var t=e[0],r=e[1];"number"!=typeof t||isNaN(t)?n="latitude must be a number":-90>t||t>90?n="latitude must be within the range [-90, 90]":"number"!=typeof r||isNaN(r)?n="longitude must be a number":(-180>r||r>180)&&(n="longitude must be within the range [-180, 180]")}else n="location must be an array";if("undefined"!=typeof n)throw new Error("Invalid GeoFire location '"+e+"': "+n)},v=function(e){var n;if("string"!=typeof e)n="geohash must be a string";else if(0===e.length)n="geohash cannot be the empty string";else for(var t=0,r=e.length;r>t;++t)-1===o.indexOf(e[t])&&(n='geohash cannot contain "'+e[t]+'"');if("undefined"!=typeof n)throw new Error("Invalid GeoFire geohash '"+e+"': "+n)},g=function(e,n){if("object"!=typeof e)throw new Error("query criteria must be an object");if("undefined"==typeof e.center&&"undefined"==typeof e.radius)throw new Error("radius and/or center must be specified");if(n&&("undefined"==typeof e.center||"undefined"==typeof e.radius))throw new Error("query criteria for a new query must contain both a center and a radius");for(var t=Object.keys(e),r=t.length,i=0;r>i;++i){var o=t[i];if("center"!==o&&"radius"!==o)throw new Error("Unexpected attribute '"+o+"'' found in query criteria")}if("undefined"!=typeof e.center&&y(e.center),"undefined"!=typeof e.radius){if("number"!=typeof e.radius||isNaN(e.radius))throw new Error("radius must be a number");if(e.radius<0)throw new Error("radius must be greater than or equal to 0")}},m=function(e){if("number"!=typeof e||isNaN(e))throw new Error("Error: degrees must be a number");return e*Math.PI/180},b=function(e,n){if(y(e),"undefined"!=typeof n){if("number"!=typeof n||isNaN(n))throw new Error("precision must be a number");if(0>=n)throw new Error("precision must be greater than 0");if(n>22)throw new Error("precision cannot be greater than 22");if(Math.round(n)!==n)throw new Error("precision must be an integer")}n=n||i;for(var t={min:-90,max:90},r={min:-180,max:180},a="",u=0,f=0,c=1;a.lengthh?(u=(u<<1)+1,s.min=h):(u=(u<<1)+0,s.max=h),c=!c,4>f?f++:(f=0,a+=o[u],u=0)}return a},p=function(e,n){var t=m(n),r=Math.cos(t)*l*Math.PI/180,i=1/Math.sqrt(1-s*Math.sin(t)*Math.sin(t)),o=r*i;return h>o?e>0?360:0:Math.min(360,e/o)},w=function(e,n){var t=p(e,n);return Math.abs(t)>1e-6?Math.max(1,Math.log2(360/t)):1},k=function(e){return Math.min(Math.log2(a/2/e),c)},M=function(e){if(180>=e&&e>=-180)return e;var n=e+180;return n>0?n%360-180:180- -n%360},E=function(e,n){var t=n/u,r=Math.min(90,e[0]+t),i=Math.max(-90,e[0]-t),o=2*Math.floor(k(n)),a=2*Math.floor(w(n,r))-1,f=2*Math.floor(w(n,i))-1;return Math.min(o,a,f,c)},x=function(e,n){var t=n/u,r=Math.min(90,e[0]+t),i=Math.max(-90,e[0]-t),o=p(n,r),a=p(n,i),f=Math.max(o,a);return[[e[0],e[1]],[e[0],M(e[1]-f)],[e[0],M(e[1]+f)],[r,e[1]],[r,M(e[1]-f)],[r,M(e[1]+f)],[i,e[1]],[i,M(e[1]-f)],[i,M(e[1]+f)]]},O=function(e,n){v(e);var t=Math.ceil(n/f);if(e.length>u<31?[r+o[c],r+"~"]:[r+o[c],r+o[l]]},_=function(e,n){y(e);var t=Math.max(1,E(e,n)),r=Math.ceil(t/f),i=x(e,n),o=i.map(function(e){return O(b(e,r),t)});return o.filter(function(e,n){return!o.some(function(t,r){return n>r&&e[0]===t[0]&&e[1]===t[1]})})},j=function(e,o){function a(e,n,t,r){O[e].forEach(function(e){"undefined"==typeof t||null===t?e(n,null,null):e(n,t,r)})}function u(){O.ready.forEach(function(e){e()})}function f(e){var n=e.split(":");if(2!==n.length)throw new Error("Invalid internal state! Not a valid geohash query: "+e);return n}function c(e){if(2!==e.length)throw new Error("Not a valid geohash query: "+e);return e[0]+":"+e[1]}function l(e,n){var t=x.orderByChild("g").startAt(e[0]).endAt(e[1]);t.off("child_added",n.childAddedCallback),t.off("child_removed",n.childRemovedCallback),t.off("child_changed",n.childChangedCallback),t.off("value",n.valueCallback)}function s(){for(var e=Object.keys(I),n=e.length,t=0;n>t;++t){var r=e[t],i=I[r];if(i.active===!1){var o=f(r);l(o,i),delete I[r]}}for(e=Object.keys(C),n=e.length,t=0;n>t;++t){var a=e[t];if(!d(C[a].geohash)){if(C[a].isInQuery)throw new Error("Internal State error, trying to remove location that is still in query");delete C[a]}}F=!1,null!==q&&(clearTimeout(q),q=null)}function h(e,n){y(n);var t,o,u=C.hasOwnProperty(e)?C[e].isInQuery:!1,f=C.hasOwnProperty(e)?C[e].location:null;t=r.distance(n,A),o=Q>=t,C[e]={location:n,distanceFromCenter:t,isInQuery:o,geohash:b(n,i)},o&&!u?a("key_entered",e,n,t):!o||null===f||n[0]===f[0]&&n[1]===f[1]?!o&&u&&a("key_exited",e,n,t):a("key_moved",e,n,t)}function d(e){for(var n=Object.keys(I),t=n.length,r=0;t>r;++r){var i=n[r];if(I.hasOwnProperty(i)){var o=f(i);if(e>=o[0]&&e<=o[1])return!0}}return!1}function v(e,n){var t=C[e];if(delete C[e],"undefined"!=typeof t&&t.isInQuery){var i=n?r.distance(n,A):null;a("key_exited",e,n,i)}}function m(e){h(e.key(),n(e.val()))}function p(e){h(e.key(),n(e.val()))}function w(e){var t=e.key();C.hasOwnProperty(t)&&x.child(t).once("value",function(e){var r=null===e.val()?null:n(e.val()),i=null!==r?b(r):null;d(i)||v(t,r)})}function k(e){var n=E.indexOf(e);n>-1&&E.splice(n,1),j=0===E.length,j&&u()}function M(){var e=_(A,1e3*Q).map(c);e=e.filter(function(n,t){return e.indexOf(n)===t});for(var n=Object.keys(I),t=n.length,r=0;t>r;++r){var i=n[r],o=e.indexOf(i);-1===o?I[i].active=!1:(I[i].active=!0,e.splice(o,1))}F===!1&&Object.keys(I).length>25&&(F=!0,q=setTimeout(s,10)),E=e.slice(),e.forEach(function(e){var n=f(e),t=x.orderByChild("g").startAt(n[0]).endAt(n[1]),r=t.on("child_added",m),i=t.on("child_removed",w),o=t.on("child_changed",p),a=t.on("value",function(){t.off("value",a),k(e)});I[e]={active:!0,childAddedCallback:r,childRemovedCallback:i,childChangedCallback:o,valueCallback:a}}),0===e.length&&k()}if(this.center=function(){return A},this.radius=function(){return Q},this.updateCriteria=function(e){g(e),A=e.center||A,Q=e.radius||Q;for(var n=Object.keys(C),t=n.length,i=0;t>i;++i){var o=n[i],u=C[o],f=u.isInQuery;u.distanceFromCenter=r.distance(u.location,A),u.isInQuery=u.distanceFromCenter<=Q,f&&!u.isInQuery?a("key_exited",o,u.location,u.distanceFromCenter):!f&&u.isInQuery&&a("key_entered",o,u.location,u.distanceFromCenter)}j=!1,M()},this.on=function(e,n){if(-1===["ready","key_entered","key_exited","key_moved"].indexOf(e))throw new Error('event type must be "ready", "key_entered", "key_exited", or "key_moved"');if("function"!=typeof n)throw new Error("callback must be a function");if(O[e].push(n),"key_entered"===e)for(var r=Object.keys(C),i=r.length,o=0;i>o;++o){var a=r[o],u=C[a];"undefined"!=typeof u&&u.isInQuery&&n(a,u.location,u.distanceFromCenter)}return"ready"===e&&j&&n(),new t(function(){O[e].splice(O[e].indexOf(n),1)})},this.cancel=function(){O={ready:[],key_entered:[],key_exited:[],key_moved:[]};for(var e=Object.keys(I),n=e.length,t=0;n>t;++t){var r=e[t],i=f(r);l(i,I[r]),delete I[r]}C={},clearInterval(N)},"[object Object]"!==Object.prototype.toString.call(e))throw new Error("firebaseRef must be an instance of Firebase");var E,x=e,O={ready:[],key_entered:[],key_exited:[],key_moved:[]},j=!1,C={},I={},F=!1,q=null,N=setInterval(function(){F===!1&&s()},1e4);g(o,!0);var A=o.center,Q=o.radius;M()};return r}();"undefined"!=typeof module&&"undefined"!=typeof process&&(module.exports=GeoFire); \ No newline at end of file diff --git a/package.json b/package.json index 4e4bbb80..06a39545 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "geofire", "description": "Location-based querying and filtering using Firebase", - "version": "0.0.0", + "version": "4.0.0", "author": "Firebase (https://www.firebase.com/)", "homepage": "https://github.com/firebase/geofire-js/", "repository": {