diff --git a/bower.json b/bower.json index c261b276..a1b14e4b 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": "3.0.1", "authors": [ "Firebase (https://www.firebase.com/)" ], diff --git a/dist/geofire.js b/dist/geofire.js new file mode 100644 index 00000000..ed8d7b4f --- /dev/null +++ b/dist/geofire.js @@ -0,0 +1,1177 @@ +/*! + * 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 3.0.1 + * https://github.com/firebase/geofire-js/ + * License: MIT + */ + +// Include RSVP if this is being run in node +if (typeof module !== "undefined" && typeof process !== "undefined") { + // We need this `Firebase || require("firebase")` hack to get GeoFire to work properly in Node.js + // since otherwise, checking for `instanceof Firebase` fails. + var Firebase = Firebase || require("firebase"); + var RSVP = require("rsvp"); +} + +var GeoFire = (function() { + "use strict"; +/** + * Creates a GeoCallbackRegistration instance. + * + * @constructor + * @this {GeoCallbackRegistration} + * @param {function} 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 to Firebase. Returns an empty promise which is fulfilled when the write is complete. + * + * If the provided key already exists in this GeoFire, it will be overwritten with the new location value. + * + * @param {string} key The key representing the location to add. + * @param {array} location The [latitude, longitude] pair to add. + * @return {RSVP.Promise} A promise that is fulfilled when the write is complete. + */ + this.set = function(key, location) { + validateKey(key); + if (location !== null) { + // Setting location to null is valid since it will remove the key + validateLocation(location); + } + return new RSVP.Promise(function(resolve, reject) { + function onComplete(error) { + if (error) { + reject("Error: Firebase synchronization failed: " + error); + } + else { + resolve(); + } + } + if (location === null) { + _firebaseRef.child(key).remove(onComplete); + } else { + var geohash = encodeGeohash(location); + _firebaseRef.child(key).setWithPriority(encodeGeoFireObject(location, geohash), geohash, onComplete); + } + }); + }; + + /** + * 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 {RSVP.Promise} A promise that is fulfilled with the location of the given key. + */ + this.get = function(key) { + validateKey(key); + return new RSVP.Promise(function(resolve, reject) { + _firebaseRef.child(key).once("value", function(dataSnapshot) { + if (dataSnapshot.val() === null) { + resolve(null); + } else { + resolve(decodeGeoFireObject(dataSnapshot.val())); + } + }, function (error) { + reject("Error: Firebase synchronization failed: " + error); + }); + }); + }; + + /** + * 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 {RSVP.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 (firebaseRef instanceof Firebase === false) { + 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 (Object.prototype.toString.call(location) !== "[object Array]") { + 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 + for (var key in newQueryCriteria) { + if (newQueryCriteria.hasOwnProperty(key)) { + 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 + * @param {number} latitude + * @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 + * @param {number} latitude + * @return {number} + */ +var longitudeBitsForResolution = function(resolution, latitude) { + var degs = metersToLongitudeDegrees(resolution, latitude); + return (Math.abs(degs) > 3.0.1001) ? 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 + */ +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 + * @return {number} 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 8 points on the bounding box and the center of a given circle. + * At least one geohash of these 9 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 {number} The four 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 + * @param {number} bits + * @return {array} A [start,end] pair + */ +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); + if (significantBits === 0) { + return [base, base+"~"]; + } + 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 { + "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|null} location The location as latitude longitude pair + * @param {double|null} 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 geo 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.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() { + for (var geohashQueryStr in _currentGeohashesQueried) { + if (_currentGeohashesQueried.hasOwnProperty(geohashQueryStr)) { + 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 + for (var key in _locationsTracked) { + if (_locationsTracked.hasOwnProperty(key)) { + 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|null} 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) { + for (var queryStr in _currentGeohashesQueried) { + 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.name(), 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.name(), 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.name(); + 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. + for (var geohashQueryStr in _currentGeohashesQueried) { + if (_currentGeohashesQueried.hasOwnProperty(geohashQueryStr)) { + 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.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 {integer} 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 + for (var key in _locationsTracked) { + if (_locationsTracked.hasOwnProperty(key)) { + // 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". + * @param {function} 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") { + for (var key in _locationsTracked) { + if (_locationsTracked.hasOwnProperty(key)) { + var locationDict = _locationsTracked[key]; + if (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 + for (var geohashQueryStr in _currentGeohashesQueried) { + if (_currentGeohashesQueried.hasOwnProperty(geohashQueryStr)) { + 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 (firebaseRef instanceof Firebase === false) { + 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..f043a4ae --- /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 3.0.1 + * https://github.com/firebase/geofire-js/ + * License: MIT + */ +if("undefined"!=typeof module&&"undefined"!=typeof process)var Firebase=Firebase||require("firebase"),RSVP=require("rsvp");var GeoFire=function(){"use strict";function e(e,n){return y(e),v(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 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},t=function(r){if(this.ref=function(){return t},this.set=function(n,r){return d(n),null!==r&&y(r),new RSVP.Promise(function(i,a){function o(e){e?a("Error: Firebase synchronization failed: "+e):i()}if(null===r)t.child(n).remove(o);else{var u=w(r);t.child(n).setWithPriority(e(r,u),u,o)}})},this.get=function(e){return d(e),new RSVP.Promise(function(r,i){t.child(e).once("value",function(e){r(null===e.val()?null:n(e.val()))},function(e){i("Error: Firebase synchronization failed: "+e)})})},this.remove=function(e){return this.set(e,null)},this.query=function(e){return new O(t,e)},r instanceof Firebase==!1)throw new Error("firebaseRef must be an instance of Firebase");var t=r};t.distance=function(e,n){y(e),y(n);var r=6371,t=b(n[0]-e[0]),i=b(n[1]-e[1]),a=Math.sin(t/2)*Math.sin(t/2)+Math.cos(b(e[0]))*Math.cos(b(n[0]))*Math.sin(i/2)*Math.sin(i/2),o=2*Math.atan2(Math.sqrt(a),Math.sqrt(1-a));return r*o};var i=10,a="0123456789bcdefghjkmnpqrstuvwxyz",o=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("[object Array]"!==Object.prototype.toString.call(e))n="location must be an array";else if(2!==e.length)n="expected array of length 2, got length "+e.length;else{var r=e[0],t=e[1];"number"!=typeof r||isNaN(r)?n="latitude must be a number":-90>r||r>90?n="latitude must be within the range [-90, 90]":"number"!=typeof t||isNaN(t)?n="longitude must be a number":(-180>t||t>180)&&(n="longitude must be within the range [-180, 180]")}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 r=0,t=e.length;t>r;++r)-1===a.indexOf(e[r])&&(n='geohash cannot contain "'+e[r]+'"');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 r in e)if(e.hasOwnProperty(r)&&"center"!==r&&"radius"!==r)throw new Error("Unexpected attribute '"+r+"'' 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")}},b=function(e){if("number"!=typeof e||isNaN(e))throw new Error("Error: degrees must be a number");return e*Math.PI/180},w=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 r={min:-90,max:90},t={min:-180,max:180},o="",u=0,f=0,c=1;o.lengthh?(u=(u<<1)+1,s.min=h):(u=(u<<1)+0,s.max=h),c=!c,4>f?f++:(f=0,o+=a[u],u=0)}return o},p=function(e,n){var r=b(n),t=Math.cos(r)*l*Math.PI/180,i=1/Math.sqrt(1-s*Math.sin(r)*Math.sin(r)),a=t*i;return h>a?e>0?360:0:Math.min(360,e/a)},g=function(e,n){var r=p(e,n);return Math.abs(r)>1e-6?Math.max(1,Math.log2(360/r)):1},M=function(e){return Math.min(Math.log2(o/2/e),c)},k=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 r=n/u,t=Math.min(90,e[0]+r),i=Math.max(-90,e[0]-r),a=2*Math.floor(M(n)),o=2*Math.floor(g(n,t))-1,f=2*Math.floor(g(n,i))-1;return Math.min(a,o,f,c)},x=function(e,n){var r=n/u,t=Math.min(90,e[0]+r),i=Math.max(-90,e[0]-r),a=p(n,t),o=p(n,i),f=Math.max(a,o);return[[e[0],e[1]],[e[0],k(e[1]-f)],[e[0],k(e[1]+f)],[t,e[1]],[t,k(e[1]-f)],[t,k(e[1]+f)],[i,e[1]],[i,k(e[1]-f)],[i,k(e[1]+f)]]},_=function(e,n){v(e);var r=Math.ceil(n/f);if(e.length>u<31?[t+a[c],t+"~"]:[t+a[c],t+a[l]]},F=function(e,n){y(e);var r=Math.max(1,E(e,n)),t=Math.ceil(r/f),i=x(e,n),a=i.map(function(e){return _(w(e,t),r)});return a.filter(function(e,n){return!a.some(function(r,t){return n>t&&e[0]===r[0]&&e[1]===r[1]})})},O=function(e,a){function o(e,n,r,t){_[e].forEach(function(e){"undefined"==typeof r||null===r?e(n,null,null):e(n,r,t)})}function u(){_.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 r=x.startAt(e[0]).endAt(e[1]);r.off("child_added",n.childAddedCallback),r.off("child_removed",n.childRemovedCallback),r.off("child_changed",n.childChangedCallback),r.off("value",n.valueCallback)}function s(){for(var e in I)if(I.hasOwnProperty(e)){var n=I[e];if(n.active===!1){var r=f(e);l(r,n),delete I[e]}}for(var t in P)if(P.hasOwnProperty(t)&&!d(P[t].geohash)){if(P[t].isInQuery)throw new Error("Internal State error, trying to remove location that is still in query");delete P[t]}C=!1,null!==q&&(clearTimeout(q),q=null)}function h(e,n){y(n);var r,a,u=P.hasOwnProperty(e)?P[e].isInQuery:!1,f=P.hasOwnProperty(e)?P[e].location:null;r=t.distance(n,A),a=Q>=r,P[e]={location:n,distanceFromCenter:r,isInQuery:a,geohash:w(n,i)},a&&!u?o("key_entered",e,n,r):!a||null===f||n[0]===f[0]&&n[1]===f[1]?!a&&u&&o("key_exited",e,n,r):o("key_moved",e,n,r)}function d(e){for(var n in I)if(I.hasOwnProperty(n)){var r=f(n);if(e>=r[0]&&e<=r[1])return!0}return!1}function v(e,n){var r=P[e];if(delete P[e],"undefined"!=typeof r&&r.isInQuery){var i=n?t.distance(n,A):null;o("key_exited",e,n,i)}}function b(e){h(e.name(),n(e.val()))}function p(e){h(e.name(),n(e.val()))}function g(e){var r=e.name();P.hasOwnProperty(r)&&x.child(r).once("value",function(e){var t=null===e.val()?null:n(e.val()),i=null!==t?w(t):null;d(i)||v(r,t)})}function M(e){var n=E.indexOf(e);n>-1&&E.splice(n,1),O=0===E.length,O&&u()}function k(){var e=F(A,1e3*Q).map(c);e=e.filter(function(n,r){return e.indexOf(n)===r});for(var n in I)if(I.hasOwnProperty(n)){var r=e.indexOf(n);-1===r?I[n].active=!1:(I[n].active=!0,e.splice(r,1))}C===!1&&Object.keys(I).length>25&&(C=!0,q=setTimeout(s,10)),E=e.slice(),e.forEach(function(e){var n=f(e),r=x.startAt(n[0]).endAt(n[1]),t=r.on("child_added",b),i=r.on("child_removed",g),a=r.on("child_changed",p),o=r.on("value",function(){r.off("value",o),M(e)});I[e]={active:!0,childAddedCallback:t,childRemovedCallback:i,childChangedCallback:a,valueCallback:o}}),0===e.length&&M()}if(this.center=function(){return A},this.radius=function(){return Q},this.updateCriteria=function(e){m(e),A=e.center||A,Q=e.radius||Q;for(var n in P)if(P.hasOwnProperty(n)){var r=P[n],i=r.isInQuery;r.distanceFromCenter=t.distance(r.location,A),r.isInQuery=r.distanceFromCenter<=Q,i&&!r.isInQuery?o("key_exited",n,r.location,r.distanceFromCenter):!i&&r.isInQuery&&o("key_entered",n,r.location,r.distanceFromCenter)}O=!1,k()},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 in P)if(P.hasOwnProperty(t)){var i=P[t];i.isInQuery&&n(t,i.location,i.distanceFromCenter)}return"ready"===e&&O&&n(),new r(function(){_[e].splice(_[e].indexOf(n),1)})},this.cancel=function(){_={ready:[],key_entered:[],key_exited:[],key_moved:[]};for(var e in I)if(I.hasOwnProperty(e)){var n=f(e);l(n,I[e]),delete I[e]}P={},clearInterval(N)},e instanceof Firebase==!1)throw new Error("firebaseRef must be an instance of Firebase");var E,x=e,_={ready:[],key_entered:[],key_exited:[],key_moved:[]},O=!1,P={},I={},C=!1,q=null,N=setInterval(function(){C===!1&&s()},1e4);m(a,!0);var A=a.center,Q=a.radius;k()};return t}();"undefined"!=typeof module&&"undefined"!=typeof process&&(module.exports=GeoFire); \ No newline at end of file diff --git a/package.json b/package.json index 4942c46d..10a20b5d 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": "3.0.1", "author": "Firebase (https://www.firebase.com/)", "homepage": "https://github.com/firebase/geofire-js/", "repository": {