Created
April 15, 2020 12:18
-
-
Save nuno/b7e07fff10cc6d4bb33e0e24de964160 to your computer and use it in GitHub Desktop.
Geoquerying on Firestore Geoquerying on Firestore // source https://jsbin.com/qejohos
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
<!DOCTYPE html> | |
<html> | |
<head> | |
<meta name="description" content="Geoquerying on Firestore"> | |
<meta charset="utf-8"> | |
<meta name="viewport" content="width=device-width"> | |
<script defer async src="https://maps.googleapis.com/maps/api/js?key=AIzaSyDefA6KLchy-KFDVkbFDFCzTvWW3ywcCyE"></script> | |
<script src="https://www.gstatic.com/firebasejs/4.7.0/firebase.js"></script> | |
<script src="https://www.gstatic.com/firebasejs/4.7.0/firebase-firestore.js"></script> | |
<title>Geoquerying on Firestore</title> | |
</head> | |
<body> | |
<select id="state"><option>[Pick a state]</option></select> | |
<select id="distance"><option value='10'>10km</option><option value='25'>25km</option><option value='50'>50km</option><option value='5000'>5000km</option></select> | |
<span>green pins are in range, red are out of range (but in geoquery)</span> | |
<div id="map" style="min-height: 500px"></div> | |
<span id="summary"></span> | |
<script>function initMap() {}</script> | |
<script id="jsbin-javascript"> | |
var config = { | |
apiKey: "AIzaSyDdoix2XvC2r_xyIDtOFL5Hzjb0uMP-XZU", | |
authDomain: "large-firestore.firebaseapp.com", | |
databaseURL: "https://large-firestore.firebaseio.com", | |
projectId: "large-firestore", | |
storageBucket: "large-firestore.appspot.com", | |
messagingSenderId: "238599175308" | |
}; | |
firebase.initializeApp(config); | |
//firebase.firestore.setLogLevel('debug'); | |
// data generator: https://jsbin.com/zogeyar/edit?js,console | |
/// geohashUtils.js | |
// 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/<geohash>key" | |
error = "key is too long to be stored in Firebase"; | |
} | |
else if (/[\[\].#$\/\u0000-\u001F\u007F]/.test(key)) { | |
// Firebase does not allow node keys to contain the following characters | |
error = "key cannot contain any of the following characters: . # $ ] [ /"; | |
} | |
if (typeof error !== "undefined") { | |
throw new Error("Invalid GeoFire key '" + key + "': " + error); | |
} | |
}; | |
/** | |
* Validates the inputted location and throws an error if it is invalid. | |
* | |
* @param {Array.<number>} 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.<number>} 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.<number>} 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.<number>} center The center given as [latitude, longitude]. | |
* @param {number} radius The radius of the circle. | |
* @return {Array.<Array.<number>>} 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.<string>} 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) { | |
console.warn("geohash.length < precision: "+geohash.length+" < "+precision+" bits="+bits+" g_BITS_PER_CHAR="+g_BITS_PER_CHAR); | |
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 >= g_BASE32.length) { | |
console.warn("endValue > 31: endValue="+endValue+" < "+precision+" bits="+bits+" g_BITS_PER_CHAR="+g_BITS_PER_CHAR); | |
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.<number>} center The center given as [latitude, longitude] pair. | |
* @param {number} radius The radius of the circle. | |
* @return {Array.<Array.<string>>} 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.<number>} 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.<number>} 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; | |
} | |
/// | |
/// Part from geoFire.js | |
/** | |
* 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.<number>} location1 The [latitude, longitude] pair of the first location. | |
* @param {Array.<number>} location2 The [latitude, longitude] pair of the second location. | |
* @return {number} The distance, in kilometers, between the inputted locations. | |
*/ | |
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; | |
}; | |
/// | |
var db = firebase.firestore(); | |
var collection = db.collection("10k"); | |
var states = {"Alabama":{"name":"Alabama","abbreviation":"AL","population":4858979,"capital":{"name":"Montgomery","location":{"lat":32.361538,"lon":-86.279118}}},"Alaska":{"name":"Alaska","abbreviation":"AK","population":738432,"capital":{"name":"Juneau","location":{"lat":58.301935,"lon":-134.419740}}},"Arizona":{"name":"Arizona","abbreviation":"AZ","population":6828065,"capital":{"name":"Phoenix","location":{"lat":33.448457,"lon":-112.073844}}},"Arkansas":{"name":"Arkansas","abbreviation":"AR","population":2978204,"capital":{"name":"Little Rock","location":{"lat":34.736009,"lon":-92.331122}}},"California":{"name":"California","abbreviation":"CA","population":39144818,"capital":{"name":"Sacramento","location":{"lat":38.555605,"lon":-121.468926}}},"Colorado":{"name":"Colorado","abbreviation":"CO","population":5456574,"capital":{"name":"Denver","location":{"lat":39.7391667,"lon":-104.984167}}},"Connecticut":{"name":"Connecticut","abbreviation":"CT","population":3590886,"capital":{"name":"Hartford","location":{"lat":41.767,"lon":-72.677}}},"Delaware":{"name":"Delaware","abbreviation":"DE","population":945934,"capital":{"name":"Dover","location":{"lat":39.161921,"lon":-75.526755}}},"Florida":{"name":"Florida","abbreviation":"FL","population":20271272,"capital":{"name":"Tallahassee","location":{"lat":30.4518,"lon":-84.27277}}},"Georgia":{"name":"Georgia","abbreviation":"GA","population":10214860,"capital":{"name":"Atlanta","location":{"lat":33.76,"lon":-84.39}}},"Hawaii":{"name":"Hawaii","abbreviation":"HI","population":1431603,"capital":{"name":"Honolulu","location":{"lat":21.30895,"lon":-157.826182}}},"Idaho":{"name":"Idaho","abbreviation":"ID","population":1654930,"capital":{"name":"Boise","location":{"lat":43.613739,"lon":-116.237651}}},"Illinois":{"name":"Illinois","abbreviation":"IL","population":12859995,"capital":{"name":"Springfield","location":{"lat":39.783250,"lon":-89.650373}}},"Indiana":{"name":"Indiana","abbreviation":"IN","population":6619680,"capital":{"name":"Indianapolis","location":{"lat":39.790942,"lon":-86.147685}}},"Iowa":{"name":"Iowa","abbreviation":"IA","population":3123899,"capital":{"name":"Des Moines","location":{"lat":41.590939,"lon":-93.620866}}},"Kansas":{"name":"Kansas","abbreviation":"KS","population":2911641,"capital":{"name":"Topeka","location":{"lat":39.04,"lon":-95.69}}},"Kentucky":{"name":"Kentucky","abbreviation":"KY","population":4425092,"capital":{"name":"Frankfort","location":{"lat":38.197274,"lon":-84.86311}}},"Louisiana":{"name":"Louisiana","abbreviation":"LA","population":4670724,"capital":{"name":"Baton Rouge","location":{"lat":30.45809,"lon":-91.140229}}},"Maine":{"name":"Maine","abbreviation":"ME","population":1329328,"capital":{"name":"Augusta","location":{"lat":44.323535,"lon":-69.765261}}},"Maryland":{"name":"Maryland","abbreviation":"MD","population":6006401,"capital":{"name":"Annapolis","location":{"lat":38.972945,"lon":-76.501157}}},"Massachusetts":{"name":"Massachusetts","abbreviation":"MA","population":6794422,"capital":{"name":"Boston","location":{"lat":42.2352,"lon":-71.0275}}},"Michigan":{"name":"Michigan","abbreviation":"MI","population":9922576,"capital":{"name":"Lansing","location":{"lat":42.7335,"lon":-84.5467}}},"Minnesota":{"name":"Minnesota","abbreviation":"MN","population":5489594,"capital":{"name":"Saint Paul","location":{"lat":44.95,"lon":-93.094}}},"Mississippi":{"name":"Mississippi","abbreviation":"MS","population":2992333,"capital":{"name":"Jackson","location":{"lat":32.320,"lon":-90.207}}},"Missouri":{"name":"Missouri","abbreviation":"MO","population":6083672,"capital":{"name":"Jefferson City","location":{"lat":38.572954,"lon":-92.189283}}},"Montana":{"name":"Montana","abbreviation":"MT","population":1032949,"capital":{"name":"Helana","location":{"lat":46.595805,"lon":-112.027031}}},"Nebraska":{"name":"Nebraska","abbreviation":"NE","population":1896190,"capital":{"name":"Lincoln","location":{"lat":40.809868,"lon":-96.675345}}},"Nevada":{"name":"Nevada","abbreviation":"NV","population":2890845,"capital":{"name":"Carson City","location":{"lat":39.160949,"lon":-119.753877}}},"New Hampshire":{"name":"New Hampshire","abbreviation":"NH","population":1330608,"capital":{"name":"Concord","location":{"lat":43.220093,"lon":-71.549127}}},"New Jersey":{"name":"New Jersey","abbreviation":"NJ","population":8958013,"capital":{"name":"Trenton","location":{"lat":40.221741,"lon":-74.756138}}},"New Mexico":{"name":"New Mexico","abbreviation":"NM","population":2085109,"capital":{"name":"Santa Fe","location":{"lat":35.667231,"lon":-105.964575}}},"New York":{"name":"New York","abbreviation":"NY","population":19795791,"capital":{"name":"Albany","location":{"lat":42.659829,"lon":-73.781339}}},"North Carolina":{"name":"North Carolina","abbreviation":"NC","population":10042802,"capital":{"name":"Raleigh","location":{"lat":35.771,"lon":-78.638}}},"North Dakota":{"name":"North Dakota","abbreviation":"ND","population":756927,"capital":{"name":"Bismarck","location":{"lat":48.813343,"lon":-100.779004}}},"Ohio":{"name":"Ohio","abbreviation":"OH","population":11613423,"capital":{"name":"Columbus","location":{"lat":39.962245,"lon":-83.000647}}},"Oklahoma":{"name":"Oklahoma","abbreviation":"OK","population":3911338,"capital":{"name":"Oklahoma City","location":{"lat":35.482309,"lon":-97.534994}}},"Oregon":{"name":"Oregon","abbreviation":"OR","population":4028977,"capital":{"name":"Salem","location":{"lat":44.931109,"lon":-123.029159}}},"Pennsylvania":{"name":"Pennsylvania","abbreviation":"PA","population":12802503,"capital":{"name":"Harrisburg","location":{"lat":40.269789,"lon":-76.875613}}},"Rhode Island":{"name":"Providence","abbreviation":"RI","population":1056298,"capital":{"name":"Providence","location":{"lat":41.82355,"lon":-71.422132}}},"South Carolina":{"name":"South Carolina","abbreviation":"SC","population":4896146,"capital":{"name":"Columbia","location":{"lat":34.000,"lon":-81.035}}},"South Dakota":{"name":"South Dakota","abbreviation":"SD","population":858469,"capital":{"name":"Pierre","location":{"lat":44.367966,"lon":-100.336378}}},"Tennessee":{"name":"Tennessee","abbreviation":"TN","population":6600299,"capital":{"name":"Nashville","location":{"lat":36.165,"lon":-86.784}}},"Texas":{"name":"Texas","abbreviation":"TX","population":27469114,"capital":{"name":"Austin","location":{"lat":30.266667,"lon":-97.75}}},"Utah":{"name":"Utah","abbreviation":"UT","population":2995919,"capital":{"name":"Salt Lake City","location":{"lat":40.7547,"lon":-111.892622}}},"Vermont":{"name":"Vermont","abbreviation":"VT","population":626042,"capital":{"name":"Montpelier","location":{"lat":44.26639,"lon":-72.57194}}},"Virginia":{"name":"Virginia","abbreviation":"VA","population":8382993,"capital":{"name":"Richmond","location":{"lat":37.54,"lon":-77.46}}},"Washington":{"name":"Washington","abbreviation":"WA","population":7170351,"capital":{"name":"Olympia","location":{"lat":47.042418,"lon":-122.893077}}},"West Virginia":{"name":"West Virginia","abbreviation":"WV","population":1844128,"capital":{"name":"Charleston","location":{"lat":38.349497,"lon":-81.633294}}},"Wisconsin":{"name":"Wisconsin","abbreviation":"WI","population":5771337,"capital":{"name":"Madison","location":{"lat":43.074722,"lon":-89.384444}}},"Wyoming":{"name":"Wyoming","abbreviation":"WY","population":586107,"capital":{"name":"Cheyenne","location":{"lat":41.145548,"lon":-104.802042}}}}; | |
var statesElm = document.getElementById("state"); | |
Object.keys(states).forEach(function(state) { | |
var optionElm = document.createElement("option"); | |
optionElm.innerText = state; | |
statesElm.appendChild(optionElm); | |
}); | |
statesElm.addEventListener("change", function(e) { | |
showMapForState(statesElm.value, distanceElm.value); | |
}); | |
var distanceElm = document.getElementById("distance"); | |
distanceElm.addEventListener("change", function(e) { | |
showMapForState(statesElm.value, distanceElm.value); | |
}); | |
var summaryElm = document.getElementById("summary"); | |
function getQueriesForDocumentsAround(ref, center, radiusInKm) { | |
var geohashesToQuery = geohashQueries([center.lat, center.lon], radiusInKm*1000); | |
console.log(JSON.stringify(geohashesToQuery)); | |
return geohashesToQuery.map(function(location) { | |
return ref.where("geohash", ">=", location[0]).where("geohash", "<=", location[1]); | |
}); | |
} | |
function showMapForState(state, MAX_DISTANCE) { | |
var map; | |
var resultCount = 0, totalDistanceKm = 0, matchCount = 0, matchDistanceKm = 0, docs = {}, maxDistanceKm = 0; | |
var center = states[state].capital.location; | |
document.getElementById('map').innerHTML = ""; | |
var queries = getQueriesForDocumentsAround(collection, center, MAX_DISTANCE); | |
queries.forEach(function(query) { | |
query.get().then(function(querySnapshot) { | |
if (!map) { | |
map = new google.maps.Map(document.getElementById('map'), { | |
center: {lat: center.lat, lng: center.lon}, | |
zoom: 8 | |
}); | |
} | |
querySnapshot.forEach(function(doc) { | |
var data = doc.data(); | |
resultCount++; | |
var dist = distance([center.lat, center.lon], [data.location.latitude,data.location.longitude]); | |
if (dist <= MAX_DISTANCE && !docs[doc.id]) { | |
docs[doc.id] = doc.data(); | |
matchCount++; | |
matchDistanceKm += dist; | |
//console.log("This one is IN range: "+data.location.latitude+","+data.location.longitude); | |
} | |
totalDistanceKm += dist; | |
if (dist > maxDistanceKm) { | |
maxDistanceKm = dist; | |
} | |
//console.log(doc.id+": "+data.location.latitude+","+data.location.longitude+" at "+dist); | |
var marker = new google.maps.Marker({ | |
position: new google.maps.LatLng(data.location.latitude, data.location.longitude), | |
map: map, | |
icon: 'https://maps.google.com/mapfiles/ms/icons/'+(dist <= MAX_DISTANCE?'green':'red')+'-dot.png', | |
title: doc.id | |
}); | |
}); | |
console.log("The total "+resultCount+" query results are a total of "+Math.round(totalDistanceKm)+"km from the center, docs.length="+Object.keys(docs).length+" matchCount="+matchCount+" matchDistance="+Math.round(matchDistanceKm)+"km"); | |
summaryElm.innerText = "The total "+resultCount+" query results are a total of "+Math.round(totalDistanceKm)+"km from the center, docs.length="+Object.keys(docs).length+" matchCount="+matchCount+" matchDistance="+Math.round(matchDistanceKm)+"km"; | |
//console.log(docs); | |
}); | |
}) | |
} | |
</script> | |
<script id="jsbin-source-javascript" type="text/javascript">var config = { | |
apiKey: "AIzaSyDdoix2XvC2r_xyIDtOFL5Hzjb0uMP-XZU", | |
authDomain: "large-firestore.firebaseapp.com", | |
databaseURL: "https://large-firestore.firebaseio.com", | |
projectId: "large-firestore", | |
storageBucket: "large-firestore.appspot.com", | |
messagingSenderId: "238599175308" | |
}; | |
firebase.initializeApp(config); | |
//firebase.firestore.setLogLevel('debug'); | |
// data generator: https://jsbin.com/zogeyar/edit?js,console | |
/// geohashUtils.js | |
// 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/<geohash>key" | |
error = "key is too long to be stored in Firebase"; | |
} | |
else if (/[\[\].#$\/\u0000-\u001F\u007F]/.test(key)) { | |
// Firebase does not allow node keys to contain the following characters | |
error = "key cannot contain any of the following characters: . # $ ] [ /"; | |
} | |
if (typeof error !== "undefined") { | |
throw new Error("Invalid GeoFire key '" + key + "': " + error); | |
} | |
}; | |
/** | |
* Validates the inputted location and throws an error if it is invalid. | |
* | |
* @param {Array.<number>} 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.<number>} 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.<number>} 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.<number>} center The center given as [latitude, longitude]. | |
* @param {number} radius The radius of the circle. | |
* @return {Array.<Array.<number>>} 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.<string>} 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) { | |
console.warn("geohash.length < precision: "+geohash.length+" < "+precision+" bits="+bits+" g_BITS_PER_CHAR="+g_BITS_PER_CHAR); | |
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 >= g_BASE32.length) { | |
console.warn("endValue > 31: endValue="+endValue+" < "+precision+" bits="+bits+" g_BITS_PER_CHAR="+g_BITS_PER_CHAR); | |
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.<number>} center The center given as [latitude, longitude] pair. | |
* @param {number} radius The radius of the circle. | |
* @return {Array.<Array.<string>>} 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.<number>} 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.<number>} 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; | |
} | |
/// | |
/// Part from geoFire.js | |
/** | |
* 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.<number>} location1 The [latitude, longitude] pair of the first location. | |
* @param {Array.<number>} location2 The [latitude, longitude] pair of the second location. | |
* @return {number} The distance, in kilometers, between the inputted locations. | |
*/ | |
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; | |
}; | |
/// | |
var db = firebase.firestore(); | |
var collection = db.collection("10k"); | |
var states = {"Alabama":{"name":"Alabama","abbreviation":"AL","population":4858979,"capital":{"name":"Montgomery","location":{"lat":32.361538,"lon":-86.279118}}},"Alaska":{"name":"Alaska","abbreviation":"AK","population":738432,"capital":{"name":"Juneau","location":{"lat":58.301935,"lon":-134.419740}}},"Arizona":{"name":"Arizona","abbreviation":"AZ","population":6828065,"capital":{"name":"Phoenix","location":{"lat":33.448457,"lon":-112.073844}}},"Arkansas":{"name":"Arkansas","abbreviation":"AR","population":2978204,"capital":{"name":"Little Rock","location":{"lat":34.736009,"lon":-92.331122}}},"California":{"name":"California","abbreviation":"CA","population":39144818,"capital":{"name":"Sacramento","location":{"lat":38.555605,"lon":-121.468926}}},"Colorado":{"name":"Colorado","abbreviation":"CO","population":5456574,"capital":{"name":"Denver","location":{"lat":39.7391667,"lon":-104.984167}}},"Connecticut":{"name":"Connecticut","abbreviation":"CT","population":3590886,"capital":{"name":"Hartford","location":{"lat":41.767,"lon":-72.677}}},"Delaware":{"name":"Delaware","abbreviation":"DE","population":945934,"capital":{"name":"Dover","location":{"lat":39.161921,"lon":-75.526755}}},"Florida":{"name":"Florida","abbreviation":"FL","population":20271272,"capital":{"name":"Tallahassee","location":{"lat":30.4518,"lon":-84.27277}}},"Georgia":{"name":"Georgia","abbreviation":"GA","population":10214860,"capital":{"name":"Atlanta","location":{"lat":33.76,"lon":-84.39}}},"Hawaii":{"name":"Hawaii","abbreviation":"HI","population":1431603,"capital":{"name":"Honolulu","location":{"lat":21.30895,"lon":-157.826182}}},"Idaho":{"name":"Idaho","abbreviation":"ID","population":1654930,"capital":{"name":"Boise","location":{"lat":43.613739,"lon":-116.237651}}},"Illinois":{"name":"Illinois","abbreviation":"IL","population":12859995,"capital":{"name":"Springfield","location":{"lat":39.783250,"lon":-89.650373}}},"Indiana":{"name":"Indiana","abbreviation":"IN","population":6619680,"capital":{"name":"Indianapolis","location":{"lat":39.790942,"lon":-86.147685}}},"Iowa":{"name":"Iowa","abbreviation":"IA","population":3123899,"capital":{"name":"Des Moines","location":{"lat":41.590939,"lon":-93.620866}}},"Kansas":{"name":"Kansas","abbreviation":"KS","population":2911641,"capital":{"name":"Topeka","location":{"lat":39.04,"lon":-95.69}}},"Kentucky":{"name":"Kentucky","abbreviation":"KY","population":4425092,"capital":{"name":"Frankfort","location":{"lat":38.197274,"lon":-84.86311}}},"Louisiana":{"name":"Louisiana","abbreviation":"LA","population":4670724,"capital":{"name":"Baton Rouge","location":{"lat":30.45809,"lon":-91.140229}}},"Maine":{"name":"Maine","abbreviation":"ME","population":1329328,"capital":{"name":"Augusta","location":{"lat":44.323535,"lon":-69.765261}}},"Maryland":{"name":"Maryland","abbreviation":"MD","population":6006401,"capital":{"name":"Annapolis","location":{"lat":38.972945,"lon":-76.501157}}},"Massachusetts":{"name":"Massachusetts","abbreviation":"MA","population":6794422,"capital":{"name":"Boston","location":{"lat":42.2352,"lon":-71.0275}}},"Michigan":{"name":"Michigan","abbreviation":"MI","population":9922576,"capital":{"name":"Lansing","location":{"lat":42.7335,"lon":-84.5467}}},"Minnesota":{"name":"Minnesota","abbreviation":"MN","population":5489594,"capital":{"name":"Saint Paul","location":{"lat":44.95,"lon":-93.094}}},"Mississippi":{"name":"Mississippi","abbreviation":"MS","population":2992333,"capital":{"name":"Jackson","location":{"lat":32.320,"lon":-90.207}}},"Missouri":{"name":"Missouri","abbreviation":"MO","population":6083672,"capital":{"name":"Jefferson City","location":{"lat":38.572954,"lon":-92.189283}}},"Montana":{"name":"Montana","abbreviation":"MT","population":1032949,"capital":{"name":"Helana","location":{"lat":46.595805,"lon":-112.027031}}},"Nebraska":{"name":"Nebraska","abbreviation":"NE","population":1896190,"capital":{"name":"Lincoln","location":{"lat":40.809868,"lon":-96.675345}}},"Nevada":{"name":"Nevada","abbreviation":"NV","population":2890845,"capital":{"name":"Carson City","location":{"lat":39.160949,"lon":-119.753877}}},"New Hampshire":{"name":"New Hampshire","abbreviation":"NH","population":1330608,"capital":{"name":"Concord","location":{"lat":43.220093,"lon":-71.549127}}},"New Jersey":{"name":"New Jersey","abbreviation":"NJ","population":8958013,"capital":{"name":"Trenton","location":{"lat":40.221741,"lon":-74.756138}}},"New Mexico":{"name":"New Mexico","abbreviation":"NM","population":2085109,"capital":{"name":"Santa Fe","location":{"lat":35.667231,"lon":-105.964575}}},"New York":{"name":"New York","abbreviation":"NY","population":19795791,"capital":{"name":"Albany","location":{"lat":42.659829,"lon":-73.781339}}},"North Carolina":{"name":"North Carolina","abbreviation":"NC","population":10042802,"capital":{"name":"Raleigh","location":{"lat":35.771,"lon":-78.638}}},"North Dakota":{"name":"North Dakota","abbreviation":"ND","population":756927,"capital":{"name":"Bismarck","location":{"lat":48.813343,"lon":-100.779004}}},"Ohio":{"name":"Ohio","abbreviation":"OH","population":11613423,"capital":{"name":"Columbus","location":{"lat":39.962245,"lon":-83.000647}}},"Oklahoma":{"name":"Oklahoma","abbreviation":"OK","population":3911338,"capital":{"name":"Oklahoma City","location":{"lat":35.482309,"lon":-97.534994}}},"Oregon":{"name":"Oregon","abbreviation":"OR","population":4028977,"capital":{"name":"Salem","location":{"lat":44.931109,"lon":-123.029159}}},"Pennsylvania":{"name":"Pennsylvania","abbreviation":"PA","population":12802503,"capital":{"name":"Harrisburg","location":{"lat":40.269789,"lon":-76.875613}}},"Rhode Island":{"name":"Providence","abbreviation":"RI","population":1056298,"capital":{"name":"Providence","location":{"lat":41.82355,"lon":-71.422132}}},"South Carolina":{"name":"South Carolina","abbreviation":"SC","population":4896146,"capital":{"name":"Columbia","location":{"lat":34.000,"lon":-81.035}}},"South Dakota":{"name":"South Dakota","abbreviation":"SD","population":858469,"capital":{"name":"Pierre","location":{"lat":44.367966,"lon":-100.336378}}},"Tennessee":{"name":"Tennessee","abbreviation":"TN","population":6600299,"capital":{"name":"Nashville","location":{"lat":36.165,"lon":-86.784}}},"Texas":{"name":"Texas","abbreviation":"TX","population":27469114,"capital":{"name":"Austin","location":{"lat":30.266667,"lon":-97.75}}},"Utah":{"name":"Utah","abbreviation":"UT","population":2995919,"capital":{"name":"Salt Lake City","location":{"lat":40.7547,"lon":-111.892622}}},"Vermont":{"name":"Vermont","abbreviation":"VT","population":626042,"capital":{"name":"Montpelier","location":{"lat":44.26639,"lon":-72.57194}}},"Virginia":{"name":"Virginia","abbreviation":"VA","population":8382993,"capital":{"name":"Richmond","location":{"lat":37.54,"lon":-77.46}}},"Washington":{"name":"Washington","abbreviation":"WA","population":7170351,"capital":{"name":"Olympia","location":{"lat":47.042418,"lon":-122.893077}}},"West Virginia":{"name":"West Virginia","abbreviation":"WV","population":1844128,"capital":{"name":"Charleston","location":{"lat":38.349497,"lon":-81.633294}}},"Wisconsin":{"name":"Wisconsin","abbreviation":"WI","population":5771337,"capital":{"name":"Madison","location":{"lat":43.074722,"lon":-89.384444}}},"Wyoming":{"name":"Wyoming","abbreviation":"WY","population":586107,"capital":{"name":"Cheyenne","location":{"lat":41.145548,"lon":-104.802042}}}}; | |
var statesElm = document.getElementById("state"); | |
Object.keys(states).forEach(function(state) { | |
var optionElm = document.createElement("option"); | |
optionElm.innerText = state; | |
statesElm.appendChild(optionElm); | |
}); | |
statesElm.addEventListener("change", function(e) { | |
showMapForState(statesElm.value, distanceElm.value); | |
}); | |
var distanceElm = document.getElementById("distance"); | |
distanceElm.addEventListener("change", function(e) { | |
showMapForState(statesElm.value, distanceElm.value); | |
}); | |
var summaryElm = document.getElementById("summary"); | |
function getQueriesForDocumentsAround(ref, center, radiusInKm) { | |
var geohashesToQuery = geohashQueries([center.lat, center.lon], radiusInKm*1000); | |
console.log(JSON.stringify(geohashesToQuery)); | |
return geohashesToQuery.map(function(location) { | |
return ref.where("geohash", ">=", location[0]).where("geohash", "<=", location[1]); | |
}); | |
} | |
function showMapForState(state, MAX_DISTANCE) { | |
var map; | |
var resultCount = 0, totalDistanceKm = 0, matchCount = 0, matchDistanceKm = 0, docs = {}, maxDistanceKm = 0; | |
var center = states[state].capital.location; | |
document.getElementById('map').innerHTML = ""; | |
var queries = getQueriesForDocumentsAround(collection, center, MAX_DISTANCE); | |
queries.forEach(function(query) { | |
query.get().then(function(querySnapshot) { | |
if (!map) { | |
map = new google.maps.Map(document.getElementById('map'), { | |
center: {lat: center.lat, lng: center.lon}, | |
zoom: 8 | |
}); | |
} | |
querySnapshot.forEach(function(doc) { | |
var data = doc.data(); | |
resultCount++; | |
var dist = distance([center.lat, center.lon], [data.location.latitude,data.location.longitude]); | |
if (dist <= MAX_DISTANCE && !docs[doc.id]) { | |
docs[doc.id] = doc.data(); | |
matchCount++; | |
matchDistanceKm += dist; | |
//console.log("This one is IN range: "+data.location.latitude+","+data.location.longitude); | |
} | |
totalDistanceKm += dist; | |
if (dist > maxDistanceKm) { | |
maxDistanceKm = dist; | |
} | |
//console.log(doc.id+": "+data.location.latitude+","+data.location.longitude+" at "+dist); | |
var marker = new google.maps.Marker({ | |
position: new google.maps.LatLng(data.location.latitude, data.location.longitude), | |
map: map, | |
icon: 'https://maps.google.com/mapfiles/ms/icons/'+(dist <= MAX_DISTANCE?'green':'red')+'-dot.png', | |
title: doc.id | |
}); | |
}); | |
console.log("The total "+resultCount+" query results are a total of "+Math.round(totalDistanceKm)+"km from the center, docs.length="+Object.keys(docs).length+" matchCount="+matchCount+" matchDistance="+Math.round(matchDistanceKm)+"km"); | |
summaryElm.innerText = "The total "+resultCount+" query results are a total of "+Math.round(totalDistanceKm)+"km from the center, docs.length="+Object.keys(docs).length+" matchCount="+matchCount+" matchDistance="+Math.round(matchDistanceKm)+"km"; | |
//console.log(docs); | |
}); | |
}) | |
} | |
</script></body> | |
</html> |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
var config = { | |
apiKey: "AIzaSyDdoix2XvC2r_xyIDtOFL5Hzjb0uMP-XZU", | |
authDomain: "large-firestore.firebaseapp.com", | |
databaseURL: "https://large-firestore.firebaseio.com", | |
projectId: "large-firestore", | |
storageBucket: "large-firestore.appspot.com", | |
messagingSenderId: "238599175308" | |
}; | |
firebase.initializeApp(config); | |
//firebase.firestore.setLogLevel('debug'); | |
// data generator: https://jsbin.com/zogeyar/edit?js,console | |
/// geohashUtils.js | |
// 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/<geohash>key" | |
error = "key is too long to be stored in Firebase"; | |
} | |
else if (/[\[\].#$\/\u0000-\u001F\u007F]/.test(key)) { | |
// Firebase does not allow node keys to contain the following characters | |
error = "key cannot contain any of the following characters: . # $ ] [ /"; | |
} | |
if (typeof error !== "undefined") { | |
throw new Error("Invalid GeoFire key '" + key + "': " + error); | |
} | |
}; | |
/** | |
* Validates the inputted location and throws an error if it is invalid. | |
* | |
* @param {Array.<number>} 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.<number>} 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.<number>} 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.<number>} center The center given as [latitude, longitude]. | |
* @param {number} radius The radius of the circle. | |
* @return {Array.<Array.<number>>} 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.<string>} 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) { | |
console.warn("geohash.length < precision: "+geohash.length+" < "+precision+" bits="+bits+" g_BITS_PER_CHAR="+g_BITS_PER_CHAR); | |
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 >= g_BASE32.length) { | |
console.warn("endValue > 31: endValue="+endValue+" < "+precision+" bits="+bits+" g_BITS_PER_CHAR="+g_BITS_PER_CHAR); | |
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.<number>} center The center given as [latitude, longitude] pair. | |
* @param {number} radius The radius of the circle. | |
* @return {Array.<Array.<string>>} 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.<number>} 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.<number>} 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; | |
} | |
/// | |
/// Part from geoFire.js | |
/** | |
* 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.<number>} location1 The [latitude, longitude] pair of the first location. | |
* @param {Array.<number>} location2 The [latitude, longitude] pair of the second location. | |
* @return {number} The distance, in kilometers, between the inputted locations. | |
*/ | |
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; | |
}; | |
/// | |
var db = firebase.firestore(); | |
var collection = db.collection("10k"); | |
var states = {"Alabama":{"name":"Alabama","abbreviation":"AL","population":4858979,"capital":{"name":"Montgomery","location":{"lat":32.361538,"lon":-86.279118}}},"Alaska":{"name":"Alaska","abbreviation":"AK","population":738432,"capital":{"name":"Juneau","location":{"lat":58.301935,"lon":-134.419740}}},"Arizona":{"name":"Arizona","abbreviation":"AZ","population":6828065,"capital":{"name":"Phoenix","location":{"lat":33.448457,"lon":-112.073844}}},"Arkansas":{"name":"Arkansas","abbreviation":"AR","population":2978204,"capital":{"name":"Little Rock","location":{"lat":34.736009,"lon":-92.331122}}},"California":{"name":"California","abbreviation":"CA","population":39144818,"capital":{"name":"Sacramento","location":{"lat":38.555605,"lon":-121.468926}}},"Colorado":{"name":"Colorado","abbreviation":"CO","population":5456574,"capital":{"name":"Denver","location":{"lat":39.7391667,"lon":-104.984167}}},"Connecticut":{"name":"Connecticut","abbreviation":"CT","population":3590886,"capital":{"name":"Hartford","location":{"lat":41.767,"lon":-72.677}}},"Delaware":{"name":"Delaware","abbreviation":"DE","population":945934,"capital":{"name":"Dover","location":{"lat":39.161921,"lon":-75.526755}}},"Florida":{"name":"Florida","abbreviation":"FL","population":20271272,"capital":{"name":"Tallahassee","location":{"lat":30.4518,"lon":-84.27277}}},"Georgia":{"name":"Georgia","abbreviation":"GA","population":10214860,"capital":{"name":"Atlanta","location":{"lat":33.76,"lon":-84.39}}},"Hawaii":{"name":"Hawaii","abbreviation":"HI","population":1431603,"capital":{"name":"Honolulu","location":{"lat":21.30895,"lon":-157.826182}}},"Idaho":{"name":"Idaho","abbreviation":"ID","population":1654930,"capital":{"name":"Boise","location":{"lat":43.613739,"lon":-116.237651}}},"Illinois":{"name":"Illinois","abbreviation":"IL","population":12859995,"capital":{"name":"Springfield","location":{"lat":39.783250,"lon":-89.650373}}},"Indiana":{"name":"Indiana","abbreviation":"IN","population":6619680,"capital":{"name":"Indianapolis","location":{"lat":39.790942,"lon":-86.147685}}},"Iowa":{"name":"Iowa","abbreviation":"IA","population":3123899,"capital":{"name":"Des Moines","location":{"lat":41.590939,"lon":-93.620866}}},"Kansas":{"name":"Kansas","abbreviation":"KS","population":2911641,"capital":{"name":"Topeka","location":{"lat":39.04,"lon":-95.69}}},"Kentucky":{"name":"Kentucky","abbreviation":"KY","population":4425092,"capital":{"name":"Frankfort","location":{"lat":38.197274,"lon":-84.86311}}},"Louisiana":{"name":"Louisiana","abbreviation":"LA","population":4670724,"capital":{"name":"Baton Rouge","location":{"lat":30.45809,"lon":-91.140229}}},"Maine":{"name":"Maine","abbreviation":"ME","population":1329328,"capital":{"name":"Augusta","location":{"lat":44.323535,"lon":-69.765261}}},"Maryland":{"name":"Maryland","abbreviation":"MD","population":6006401,"capital":{"name":"Annapolis","location":{"lat":38.972945,"lon":-76.501157}}},"Massachusetts":{"name":"Massachusetts","abbreviation":"MA","population":6794422,"capital":{"name":"Boston","location":{"lat":42.2352,"lon":-71.0275}}},"Michigan":{"name":"Michigan","abbreviation":"MI","population":9922576,"capital":{"name":"Lansing","location":{"lat":42.7335,"lon":-84.5467}}},"Minnesota":{"name":"Minnesota","abbreviation":"MN","population":5489594,"capital":{"name":"Saint Paul","location":{"lat":44.95,"lon":-93.094}}},"Mississippi":{"name":"Mississippi","abbreviation":"MS","population":2992333,"capital":{"name":"Jackson","location":{"lat":32.320,"lon":-90.207}}},"Missouri":{"name":"Missouri","abbreviation":"MO","population":6083672,"capital":{"name":"Jefferson City","location":{"lat":38.572954,"lon":-92.189283}}},"Montana":{"name":"Montana","abbreviation":"MT","population":1032949,"capital":{"name":"Helana","location":{"lat":46.595805,"lon":-112.027031}}},"Nebraska":{"name":"Nebraska","abbreviation":"NE","population":1896190,"capital":{"name":"Lincoln","location":{"lat":40.809868,"lon":-96.675345}}},"Nevada":{"name":"Nevada","abbreviation":"NV","population":2890845,"capital":{"name":"Carson City","location":{"lat":39.160949,"lon":-119.753877}}},"New Hampshire":{"name":"New Hampshire","abbreviation":"NH","population":1330608,"capital":{"name":"Concord","location":{"lat":43.220093,"lon":-71.549127}}},"New Jersey":{"name":"New Jersey","abbreviation":"NJ","population":8958013,"capital":{"name":"Trenton","location":{"lat":40.221741,"lon":-74.756138}}},"New Mexico":{"name":"New Mexico","abbreviation":"NM","population":2085109,"capital":{"name":"Santa Fe","location":{"lat":35.667231,"lon":-105.964575}}},"New York":{"name":"New York","abbreviation":"NY","population":19795791,"capital":{"name":"Albany","location":{"lat":42.659829,"lon":-73.781339}}},"North Carolina":{"name":"North Carolina","abbreviation":"NC","population":10042802,"capital":{"name":"Raleigh","location":{"lat":35.771,"lon":-78.638}}},"North Dakota":{"name":"North Dakota","abbreviation":"ND","population":756927,"capital":{"name":"Bismarck","location":{"lat":48.813343,"lon":-100.779004}}},"Ohio":{"name":"Ohio","abbreviation":"OH","population":11613423,"capital":{"name":"Columbus","location":{"lat":39.962245,"lon":-83.000647}}},"Oklahoma":{"name":"Oklahoma","abbreviation":"OK","population":3911338,"capital":{"name":"Oklahoma City","location":{"lat":35.482309,"lon":-97.534994}}},"Oregon":{"name":"Oregon","abbreviation":"OR","population":4028977,"capital":{"name":"Salem","location":{"lat":44.931109,"lon":-123.029159}}},"Pennsylvania":{"name":"Pennsylvania","abbreviation":"PA","population":12802503,"capital":{"name":"Harrisburg","location":{"lat":40.269789,"lon":-76.875613}}},"Rhode Island":{"name":"Providence","abbreviation":"RI","population":1056298,"capital":{"name":"Providence","location":{"lat":41.82355,"lon":-71.422132}}},"South Carolina":{"name":"South Carolina","abbreviation":"SC","population":4896146,"capital":{"name":"Columbia","location":{"lat":34.000,"lon":-81.035}}},"South Dakota":{"name":"South Dakota","abbreviation":"SD","population":858469,"capital":{"name":"Pierre","location":{"lat":44.367966,"lon":-100.336378}}},"Tennessee":{"name":"Tennessee","abbreviation":"TN","population":6600299,"capital":{"name":"Nashville","location":{"lat":36.165,"lon":-86.784}}},"Texas":{"name":"Texas","abbreviation":"TX","population":27469114,"capital":{"name":"Austin","location":{"lat":30.266667,"lon":-97.75}}},"Utah":{"name":"Utah","abbreviation":"UT","population":2995919,"capital":{"name":"Salt Lake City","location":{"lat":40.7547,"lon":-111.892622}}},"Vermont":{"name":"Vermont","abbreviation":"VT","population":626042,"capital":{"name":"Montpelier","location":{"lat":44.26639,"lon":-72.57194}}},"Virginia":{"name":"Virginia","abbreviation":"VA","population":8382993,"capital":{"name":"Richmond","location":{"lat":37.54,"lon":-77.46}}},"Washington":{"name":"Washington","abbreviation":"WA","population":7170351,"capital":{"name":"Olympia","location":{"lat":47.042418,"lon":-122.893077}}},"West Virginia":{"name":"West Virginia","abbreviation":"WV","population":1844128,"capital":{"name":"Charleston","location":{"lat":38.349497,"lon":-81.633294}}},"Wisconsin":{"name":"Wisconsin","abbreviation":"WI","population":5771337,"capital":{"name":"Madison","location":{"lat":43.074722,"lon":-89.384444}}},"Wyoming":{"name":"Wyoming","abbreviation":"WY","population":586107,"capital":{"name":"Cheyenne","location":{"lat":41.145548,"lon":-104.802042}}}}; | |
var statesElm = document.getElementById("state"); | |
Object.keys(states).forEach(function(state) { | |
var optionElm = document.createElement("option"); | |
optionElm.innerText = state; | |
statesElm.appendChild(optionElm); | |
}); | |
statesElm.addEventListener("change", function(e) { | |
showMapForState(statesElm.value, distanceElm.value); | |
}); | |
var distanceElm = document.getElementById("distance"); | |
distanceElm.addEventListener("change", function(e) { | |
showMapForState(statesElm.value, distanceElm.value); | |
}); | |
var summaryElm = document.getElementById("summary"); | |
function getQueriesForDocumentsAround(ref, center, radiusInKm) { | |
var geohashesToQuery = geohashQueries([center.lat, center.lon], radiusInKm*1000); | |
console.log(JSON.stringify(geohashesToQuery)); | |
return geohashesToQuery.map(function(location) { | |
return ref.where("geohash", ">=", location[0]).where("geohash", "<=", location[1]); | |
}); | |
} | |
function showMapForState(state, MAX_DISTANCE) { | |
var map; | |
var resultCount = 0, totalDistanceKm = 0, matchCount = 0, matchDistanceKm = 0, docs = {}, maxDistanceKm = 0; | |
var center = states[state].capital.location; | |
document.getElementById('map').innerHTML = ""; | |
var queries = getQueriesForDocumentsAround(collection, center, MAX_DISTANCE); | |
queries.forEach(function(query) { | |
query.get().then(function(querySnapshot) { | |
if (!map) { | |
map = new google.maps.Map(document.getElementById('map'), { | |
center: {lat: center.lat, lng: center.lon}, | |
zoom: 8 | |
}); | |
} | |
querySnapshot.forEach(function(doc) { | |
var data = doc.data(); | |
resultCount++; | |
var dist = distance([center.lat, center.lon], [data.location.latitude,data.location.longitude]); | |
if (dist <= MAX_DISTANCE && !docs[doc.id]) { | |
docs[doc.id] = doc.data(); | |
matchCount++; | |
matchDistanceKm += dist; | |
//console.log("This one is IN range: "+data.location.latitude+","+data.location.longitude); | |
} | |
totalDistanceKm += dist; | |
if (dist > maxDistanceKm) { | |
maxDistanceKm = dist; | |
} | |
//console.log(doc.id+": "+data.location.latitude+","+data.location.longitude+" at "+dist); | |
var marker = new google.maps.Marker({ | |
position: new google.maps.LatLng(data.location.latitude, data.location.longitude), | |
map: map, | |
icon: 'https://maps.google.com/mapfiles/ms/icons/'+(dist <= MAX_DISTANCE?'green':'red')+'-dot.png', | |
title: doc.id | |
}); | |
}); | |
console.log("The total "+resultCount+" query results are a total of "+Math.round(totalDistanceKm)+"km from the center, docs.length="+Object.keys(docs).length+" matchCount="+matchCount+" matchDistance="+Math.round(matchDistanceKm)+"km"); | |
summaryElm.innerText = "The total "+resultCount+" query results are a total of "+Math.round(totalDistanceKm)+"km from the center, docs.length="+Object.keys(docs).length+" matchCount="+matchCount+" matchDistance="+Math.round(matchDistanceKm)+"km"; | |
//console.log(docs); | |
}); | |
}) | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment