diff --git a/README.md b/README.md
index 3c2d5414..77f67a4d 100644
--- a/README.md
+++ b/README.md
@@ -64,7 +64,7 @@ In order to use GeoFire in your project, you need to include the following files
-
+
```
Use the URL above to download both the minified and non-minified versions of GeoFire from the
diff --git a/bower.json b/bower.json
index 5fe1cd9e..b40a2efe 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.1.1",
"authors": [
"Firebase (https://www.firebase.com/)"
],
diff --git a/dist/geofire.js b/dist/geofire.js
new file mode 100644
index 00000000..f78e3847
--- /dev/null
+++ b/dist/geofire.js
@@ -0,0 +1,1214 @@
+/*!
+ * 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.1.1
+ * 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));
+ }
+}
+
+/**
+ * Returns the key of a Firebase snapshot across SDK versions.
+ *
+ * @param {DataSnapshot} snapshot A Firebase snapshot.
+ * @return {string|null} key The Firebase snapshot's key.
+ */
+ function getKey(snapshot) {
+ var key;
+ if (typeof snapshot.key === "function") {
+ key = snapshot.key();
+ } else if (typeof snapshot.key === "string" || snapshot.key === null) {
+ key = snapshot.key;
+ } else {
+ key = snapshot.name();
+ }
+ return key;
+ }
+
+/**
+ * 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(getKey(locationDataSnapshot), 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(getKey(locationDataSnapshot), 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 = getKey(locationDataSnapshot);
+ 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..d4f4daee
--- /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.1.1
+ * https://github.com/firebase/geofire-js/
+ * License: MIT
+ */
+var GeoFire=function(){"use strict";function e(e,n){return v(e),g(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))}function t(e){var n;return n="function"==typeof e.key?e.key():"string"==typeof e.key||null===e.key?e.key:e.name()}var r=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},i=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){y(n);var t=i[n];if(null===t)o[n]=null;else{v(t);var r=p(t);o[n]=e(t,r)}}),r.update(o)},this.get=function(e){return y(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 C(r,e)},"[object Object]"!==Object.prototype.toString.call(t))throw new Error("firebaseRef must be an instance of Firebase");var r=t};i.distance=function(e,n){v(e),v(n);var t=6371,r=b(n[0]-e[0]),i=b(n[1]-e[1]),o=Math.sin(r/2)*Math.sin(r/2)+Math.cos(b(e[0]))*Math.cos(b(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 o=10,a="0123456789bcdefghjkmnpqrstuvwxyz",u=40007860,f=110574,c=5,l=22*c,s=6378137,h=.00669447819799,d=1e-12;Math.log2=Math.log2||function(e){return Math.log(e)/Math.log(2)};var y=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+o+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)},v=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)},g=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===a.indexOf(e[t])&&(n='geohash cannot contain "'+e[t]+'"');if("undefined"!=typeof n)throw new Error("Invalid GeoFire geohash '"+e+"': "+n)},m=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&&v(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")}},b=function(e){if("number"!=typeof e||isNaN(e))throw new Error("Error: degrees must be a number");return e*Math.PI/180},p=function(e,n){if(v(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||o;for(var t={min:-90,max:90},r={min:-180,max:180},i="",u=0,f=0,c=1;i.lengthh?(u=(u<<1)+1,s.min=h):(u=(u<<1)+0,s.max=h),c=!c,4>f?f++:(f=0,i+=a[u],u=0)}return i},w=function(e,n){var t=b(n),r=Math.cos(t)*s*Math.PI/180,i=1/Math.sqrt(1-h*Math.sin(t)*Math.sin(t)),o=r*i;return d>o?e>0?360:0:Math.min(360,e/o)},k=function(e,n){var t=w(e,n);return Math.abs(t)>1e-6?Math.max(1,Math.log2(360/t)):1},M=function(e){return Math.min(Math.log2(u/2/e),l)},E=function(e){if(180>=e&&e>=-180)return e;var n=e+180;return n>0?n%360-180:180- -n%360},x=function(e,n){var t=n/f,r=Math.min(90,e[0]+t),i=Math.max(-90,e[0]-t),o=2*Math.floor(M(n)),a=2*Math.floor(k(n,r))-1,u=2*Math.floor(k(n,i))-1;return Math.min(o,a,u,l)},O=function(e,n){var t=n/f,r=Math.min(90,e[0]+t),i=Math.max(-90,e[0]-t),o=w(n,r),a=w(n,i),u=Math.max(o,a);return[[e[0],e[1]],[e[0],E(e[1]-u)],[e[0],E(e[1]+u)],[r,e[1]],[r,E(e[1]-u)],[r,E(e[1]+u)],[i,e[1]],[i,E(e[1]-u)],[i,E(e[1]+u)]]},_=function(e,n){g(e);var t=Math.ceil(n/c);if(e.length>u<31?[r+a[f],r+"~"]:[r+a[f],r+a[l]]},j=function(e,n){v(e);var t=Math.max(1,x(e,n)),r=Math.ceil(t/c),i=O(e,n),o=i.map(function(e){return _(p(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]})})},C=function(e,a){function u(e,n,t,r){_[e].forEach(function(e){"undefined"==typeof t||null===t?e(n,null,null):e(n,t,r)})}function f(){_.ready.forEach(function(e){e()})}function c(e){var n=e.split(":");if(2!==n.length)throw new Error("Invalid internal state! Not a valid geohash query: "+e);return n}function l(e){if(2!==e.length)throw new Error("Not a valid geohash query: "+e);return e[0]+":"+e[1]}function s(e,n){var t=O.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 h(){for(var e=Object.keys(F),n=e.length,t=0;n>t;++t){var r=e[t],i=F[r];if(i.active===!1){var o=c(r);s(o,i),delete F[r]}}for(e=Object.keys(I),n=e.length,t=0;n>t;++t){var a=e[t];if(!y(I[a].geohash)){if(I[a].isInQuery)throw new Error("Internal State error, trying to remove location that is still in query");delete I[a]}}q=!1,null!==N&&(clearTimeout(N),N=null)}function d(e,n){v(n);var t,r,a=I.hasOwnProperty(e)?I[e].isInQuery:!1,f=I.hasOwnProperty(e)?I[e].location:null;t=i.distance(n,Q),r=P>=t,I[e]={location:n,distanceFromCenter:t,isInQuery:r,geohash:p(n,o)},r&&!a?u("key_entered",e,n,t):!r||null===f||n[0]===f[0]&&n[1]===f[1]?!r&&a&&u("key_exited",e,n,t):u("key_moved",e,n,t)}function y(e){for(var n=Object.keys(F),t=n.length,r=0;t>r;++r){var i=n[r];if(F.hasOwnProperty(i)){var o=c(i);if(e>=o[0]&&e<=o[1])return!0}}return!1}function g(e,n){var t=I[e];if(delete I[e],"undefined"!=typeof t&&t.isInQuery){var r=n?i.distance(n,Q):null;u("key_exited",e,n,r)}}function b(e){d(t(e),n(e.val()))}function w(e){d(t(e),n(e.val()))}function k(e){var r=t(e);I.hasOwnProperty(r)&&O.child(r).once("value",function(e){var t=null===e.val()?null:n(e.val()),i=null!==t?p(t):null;y(i)||g(r,t)})}function M(e){var n=x.indexOf(e);n>-1&&x.splice(n,1),C=0===x.length,C&&f()}function E(){var e=j(Q,1e3*P).map(l);e=e.filter(function(n,t){return e.indexOf(n)===t});for(var n=Object.keys(F),t=n.length,r=0;t>r;++r){var i=n[r],o=e.indexOf(i);-1===o?F[i].active=!1:(F[i].active=!0,e.splice(o,1))}q===!1&&Object.keys(F).length>25&&(q=!0,N=setTimeout(h,10)),x=e.slice(),e.forEach(function(e){var n=c(e),t=O.orderByChild("g").startAt(n[0]).endAt(n[1]),r=t.on("child_added",b),i=t.on("child_removed",k),o=t.on("child_changed",w),a=t.on("value",function(){t.off("value",a),M(e)});F[e]={active:!0,childAddedCallback:r,childRemovedCallback:i,childChangedCallback:o,valueCallback:a}}),0===e.length&&M()}if(this.center=function(){return Q},this.radius=function(){return P},this.updateCriteria=function(e){m(e),Q=e.center||Q,P=e.radius||P;for(var n=Object.keys(I),t=n.length,r=0;t>r;++r){var o=n[r],a=I[o],f=a.isInQuery;a.distanceFromCenter=i.distance(a.location,Q),a.isInQuery=a.distanceFromCenter<=P,f&&!a.isInQuery?u("key_exited",o,a.location,a.distanceFromCenter):!f&&a.isInQuery&&u("key_entered",o,a.location,a.distanceFromCenter)}C=!1,E()},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(_[e].push(n),"key_entered"===e)for(var t=Object.keys(I),i=t.length,o=0;i>o;++o){var a=t[o],u=I[a];"undefined"!=typeof u&&u.isInQuery&&n(a,u.location,u.distanceFromCenter)}return"ready"===e&&C&&n(),new r(function(){_[e].splice(_[e].indexOf(n),1)})},this.cancel=function(){_={ready:[],key_entered:[],key_exited:[],key_moved:[]};for(var e=Object.keys(F),n=e.length,t=0;n>t;++t){var r=e[t],i=c(r);s(i,F[r]),delete F[r]}I={},clearInterval(A)},"[object Object]"!==Object.prototype.toString.call(e))throw new Error("firebaseRef must be an instance of Firebase");var x,O=e,_={ready:[],key_entered:[],key_exited:[],key_moved:[]},C=!1,I={},F={},q=!1,N=null,A=setInterval(function(){q===!1&&h()},1e4);m(a,!0);var Q=a.center,P=a.radius;E()};return i}();"undefined"!=typeof module&&"undefined"!=typeof process&&(module.exports=GeoFire);
\ No newline at end of file
diff --git a/package.json b/package.json
index d2400782..e2dd7d3c 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.1.1",
"author": "Firebase (https://www.firebase.com/)",
"homepage": "https://github.com/firebase/geofire-js/",
"repository": {