geofirestore-core
Version:
Lightweight location-based querying and encoding of Firebase Firestore Documents for geofirestore.
606 lines (596 loc) • 23.8 kB
JavaScript
'use strict';
Object.defineProperty(exports, '__esModule', { value: true });
var geokit = require('geokit');
function validateGeoDocument(documentData, flag = false) {
let error;
if (Object.prototype.toString.call(documentData) !== '[object Object]') {
error = 'no document found';
}
else if ('g' in documentData) {
error = !geokit.validateHash(documentData.g.geohash, true)
? 'invalid geohash on object'
: error;
error = !validateLocation(documentData.g.geopoint, true)
? 'invalid location on object'
: error;
}
else {
error = 'no `g` field found in object';
}
if (typeof error !== 'undefined' && !flag) {
throw new Error('Invalid GeoFirestore object: ' + error);
}
else {
return !error;
}
}
function validateLimit(limit, flag = false) {
let error;
if (typeof limit !== 'number' || isNaN(limit)) {
error = 'limit must be a number';
}
else if (limit < 0) {
error = 'limit must be greater than or equal to 0';
}
if (typeof error !== 'undefined' && !flag) {
throw new Error(error);
}
else {
return !error;
}
}
function validateLocation(location, flag = false) {
let error;
if (!location) {
error = 'GeoPoint must exist';
}
else if (typeof location.latitude === 'undefined') {
error = 'latitude must exist on GeoPoint';
}
else if (typeof location.longitude === 'undefined') {
error = 'longitude must exist on GeoPoint';
}
else {
const latitude = location.latitude;
const longitude = location.longitude;
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' && !flag) {
throw new Error('Invalid location: ' + error);
}
else {
return !error;
}
}
function validateQueryCriteria(newQueryCriteria, requireCenterAndRadius = false) {
if (typeof newQueryCriteria !== 'object') {
throw new Error('QueryCriteria 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('QueryCriteria for a new query must contain both a center and a radius');
}
const keys = Object.keys(newQueryCriteria);
for (const key of keys) {
if (!['center', 'radius', 'limit'].includes(key)) {
throw new Error("Unexpected attribute '" + key + "' found in query criteria");
}
}
if (typeof newQueryCriteria.center !== 'undefined') {
validateLocation(newQueryCriteria.center);
}
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');
}
}
if (typeof newQueryCriteria.limit !== 'undefined') {
validateLimit(newQueryCriteria.limit);
}
}
const BASE32 = '0123456789bcdefghjkmnpqrstuvwxyz';
const BITS_PER_CHAR = 5;
const CUSTOM_KEY = 'coordinates';
const E2 = 0.00669447819799;
const EARTH_EQ_RADIUS = 6378137.0;
const EARTH_MERI_CIRCUMFERENCE = 40007860;
const EPSILON = 1e-12;
const MAXIMUM_BITS_PRECISION = 22 * BITS_PER_CHAR;
const METERS_PER_DEGREE_LATITUDE = 110574;
function boundingBoxBits(coordinate, size) {
const latDeltaDegrees = size / METERS_PER_DEGREE_LATITUDE;
const latitudeNorth = Math.min(90, coordinate.latitude + latDeltaDegrees);
const latitudeSouth = Math.max(-90, coordinate.latitude - latDeltaDegrees);
const bitsLat = Math.floor(latitudeBitsForResolution(size)) * 2;
const bitsLongNorth = Math.floor(longitudeBitsForResolution(size, latitudeNorth)) * 2 - 1;
const bitsLongSouth = Math.floor(longitudeBitsForResolution(size, latitudeSouth)) * 2 - 1;
return Math.min(bitsLat, bitsLongNorth, bitsLongSouth, MAXIMUM_BITS_PRECISION);
}
function boundingBoxCoordinates(center, radius) {
const latDegrees = radius / METERS_PER_DEGREE_LATITUDE;
const latitudeNorth = Math.min(90, center.latitude + latDegrees);
const latitudeSouth = Math.max(-90, center.latitude - latDegrees);
const longDegsNorth = metersToLongitudeDegrees(radius, latitudeNorth);
const longDegsSouth = metersToLongitudeDegrees(radius, latitudeSouth);
const longDegs = Math.max(longDegsNorth, longDegsSouth);
return [
toGeoPoint(center.latitude, center.longitude),
toGeoPoint(center.latitude, wrapLongitude(center.longitude - longDegs)),
toGeoPoint(center.latitude, wrapLongitude(center.longitude + longDegs)),
toGeoPoint(latitudeNorth, center.longitude),
toGeoPoint(latitudeNorth, wrapLongitude(center.longitude - longDegs)),
toGeoPoint(latitudeNorth, wrapLongitude(center.longitude + longDegs)),
toGeoPoint(latitudeSouth, center.longitude),
toGeoPoint(latitudeSouth, wrapLongitude(center.longitude - longDegs)),
toGeoPoint(latitudeSouth, wrapLongitude(center.longitude + longDegs)),
];
}
function calculateDistance(location1, location2) {
validateLocation(location1);
validateLocation(location2);
return geokit.distance({ lat: location1.latitude, lng: location1.longitude }, { lat: location2.latitude, lng: location2.longitude });
}
function decodeGeoQueryDocumentSnapshotData(data, center) {
if (validateGeoDocument(data, true)) {
const distance = center ? calculateDistance(data.g.geopoint, center) : null;
return { data: () => data, distance };
}
return { data: () => data, distance: null };
}
function degreesToRadians(degrees) {
if (typeof degrees !== 'number' || isNaN(degrees)) {
throw new Error('Error: degrees must be a number');
}
return (degrees * Math.PI) / 180;
}
function findGeoPoint(document, customKey, flag = false) {
customKey = customKey || CUSTOM_KEY;
let error;
let geopoint;
if (customKey in document) {
geopoint = document[customKey];
}
else {
const props = customKey.split('.');
geopoint = document;
for (const prop of props) {
if (!(prop in geopoint)) {
geopoint = document['coordinates'];
break;
}
geopoint = geopoint[prop];
}
}
if (!geopoint) {
error = 'could not find GeoPoint';
}
if (geopoint && !validateLocation(geopoint, true)) {
error = 'invalid GeoPoint';
}
if (error && !flag) {
throw new Error('Invalid GeoFirestore document: ' + error);
}
return geopoint;
}
function generateGeoQueryDocumentSnapshot(snapshot, center) {
const decoded = decodeGeoQueryDocumentSnapshotData(snapshot.data(), center);
return Object.assign({ exists: snapshot.exists, id: snapshot.id }, decoded);
}
function generateQuery(query, queryCriteria) {
let geohashesToQuery = geohashQueries(queryCriteria.center, queryCriteria.radius * 1000).map(queryToString);
geohashesToQuery = geohashesToQuery.filter((geohash, i) => geohashesToQuery.indexOf(geohash) === i);
return geohashesToQuery.map((toQueryStr) => {
const queries = stringToQuery(toQueryStr);
return query
.orderBy('g.geohash')
.startAt(queries[0])
.endAt(queries[1]);
});
}
function geohashQueries(center, radius) {
validateLocation(center);
const queryBits = Math.max(1, boundingBoxBits(center, radius));
const geohashPrecision = Math.ceil(queryBits / BITS_PER_CHAR);
const coordinates = boundingBoxCoordinates(center, radius);
const queries = coordinates.map(coordinate => {
return geohashQuery(geokit.hash({
lat: coordinate.latitude,
lng: coordinate.longitude,
}, geohashPrecision), queryBits);
});
return queries.filter((query, index) => {
return !queries.some((other, otherIndex) => {
return (index > otherIndex && query[0] === other[0] && query[1] === other[1]);
});
});
}
function geohashQuery(geohash, bits) {
geokit.validateHash(geohash);
const precision = Math.ceil(bits / BITS_PER_CHAR);
if (geohash.length < precision) {
return [geohash, geohash + '~'];
}
const ghash = geohash.substring(0, precision);
const base = ghash.substring(0, ghash.length - 1);
const lastValue = BASE32.indexOf(ghash.charAt(ghash.length - 1));
const significantBits = bits - base.length * BITS_PER_CHAR;
const unusedBits = BITS_PER_CHAR - significantBits;
const startValue = (lastValue >> unusedBits) << unusedBits;
const endValue = startValue + (1 << unusedBits);
if (endValue > 31) {
return [base + BASE32[startValue], base + '~'];
}
else {
return [base + BASE32[startValue], base + BASE32[endValue]];
}
}
function latitudeBitsForResolution(resolution) {
return Math.min(log2(EARTH_MERI_CIRCUMFERENCE / 2 / resolution), MAXIMUM_BITS_PRECISION);
}
function log2(x) {
return Math.log(x) / Math.log(2);
}
function longitudeBitsForResolution(resolution, latitude) {
const degs = metersToLongitudeDegrees(resolution, latitude);
return Math.abs(degs) > 0.000001 ? Math.max(1, log2(360 / degs)) : 1;
}
function metersToLongitudeDegrees(distance, latitude) {
const radians = degreesToRadians(latitude);
const num = (Math.cos(radians) * EARTH_EQ_RADIUS * Math.PI) / 180;
const denom = 1 / Math.sqrt(1 - E2 * Math.sin(radians) * Math.sin(radians));
const deltaDeg = num * denom;
if (deltaDeg < EPSILON) {
return distance > 0 ? 360 : 0;
}
else {
return Math.min(360, distance / deltaDeg);
}
}
function stringToQuery(str) {
const decoded = str.split(':');
if (decoded.length !== 2) {
throw new Error('Invalid internal state! Not a valid geohash query: ' + str);
}
return decoded;
}
function queryToString(query) {
if (query.length !== 2) {
throw new Error('Not a valid geohash query: ' + query);
}
return query[0] + ':' + query[1];
}
function toGeoPoint(latitude, longitude) {
const fakeGeoPoint = { latitude, longitude };
validateLocation(fakeGeoPoint);
return fakeGeoPoint;
}
function wrapLongitude(longitude) {
if (longitude <= 180 && longitude >= -180) {
return longitude;
}
const adjusted = longitude + 180;
if (adjusted > 0) {
return (adjusted % 360) - 180;
}
else {
return 180 - (-adjusted % 360);
}
}
function encodeDocumentAdd(documentData, customKey) {
if (Object.prototype.toString.call(documentData) !== '[object Object]') {
throw new Error('document must be an object');
}
const geopoint = findGeoPoint(documentData, customKey);
return encodeGeoDocument(geopoint, documentData);
}
function encodeDocumentSet(documentData, options) {
if (Object.prototype.toString.call(documentData) !== '[object Object]') {
throw new Error('document must be an object');
}
const customKey = options && options.customKey;
const geopoint = findGeoPoint(documentData, customKey, options && (options.merge || !!options.mergeFields));
return geopoint ? encodeGeoDocument(geopoint, documentData) : documentData;
}
function encodeDocumentUpdate(documentData, customKey) {
if (Object.prototype.toString.call(documentData) !== '[object Object]') {
throw new Error('document must be an object');
}
const geopoint = findGeoPoint(documentData, customKey, true);
return geopoint ? encodeGeoDocument(geopoint, documentData) : documentData;
}
function encodeGeoDocument(geopoint, documentData) {
validateLocation(geopoint);
const geohash = geokit.hash({
lat: geopoint.latitude,
lng: geopoint.longitude,
});
return Object.assign(Object.assign({}, documentData), { g: {
geopoint,
geohash,
} });
}
class GeoQuerySnapshot {
constructor(_querySnapshot, _center) {
this._querySnapshot = _querySnapshot;
this._center = _center;
if (_center) {
validateLocation(_center);
}
this._docs = _querySnapshot.docs.map((snapshot) => generateGeoQueryDocumentSnapshot(snapshot, _center));
}
get native() {
return this._querySnapshot;
}
get docs() {
return this._docs;
}
get size() {
return this._docs.length;
}
get empty() {
return !this._docs.length;
}
docChanges() {
const docChanges = Array.isArray(this._querySnapshot.docChanges)
? this._querySnapshot
.docChanges
: this._querySnapshot.docChanges();
return docChanges.map((change) => {
return {
doc: generateGeoQueryDocumentSnapshot(change.doc, this._center),
newIndex: change.newIndex,
oldIndex: change.oldIndex,
type: change.type,
};
});
}
forEach(callback, thisArg) {
this.docs.forEach(callback, thisArg);
}
}
function geoQueryGet(query, queryCriteria, options = { source: 'default' }) {
const isWeb = Object.prototype.toString.call(query.firestore
.enablePersistence) === '[object Function]';
if (queryCriteria.center && typeof queryCriteria.radius === 'number') {
const queries = generateQuery(query, queryCriteria).map(q => isWeb ? q.get(options) : q.get());
return Promise.all(queries).then(value => new GeoQueryGet(value, queryCriteria).getGeoQuerySnapshot());
}
else {
query = queryCriteria.limit ? query.limit(queryCriteria.limit) : query;
const promise = isWeb
? query.get(options)
: query.get();
return promise.then(snapshot => new GeoQuerySnapshot(snapshot));
}
}
class GeoQueryGet {
constructor(snapshots, _queryCriteria) {
this._queryCriteria = _queryCriteria;
this._docs = new Map();
validateQueryCriteria(_queryCriteria);
snapshots.forEach((snapshot) => {
snapshot.docs.forEach(doc => {
const distance = calculateDistance(this._queryCriteria.center, doc.data().g.geopoint);
if (this._queryCriteria.radius >= distance) {
this._docs.set(doc.id, doc);
}
});
});
if (this._queryCriteria.limit &&
this._docs.size > this._queryCriteria.limit) {
const arrayToLimit = Array.from(this._docs.values())
.map(doc => {
return {
distance: calculateDistance(this._queryCriteria.center, doc.data().g.geopoint),
id: doc.id,
};
})
.sort((a, b) => a.distance - b.distance);
for (let i = this._queryCriteria.limit; i < arrayToLimit.length; i++) {
this._docs.delete(arrayToLimit[i].id);
}
}
}
getGeoQuerySnapshot() {
const docs = Array.from(this._docs.values());
return new GeoQuerySnapshot({
docs,
docChanges: () => docs.map((doc, index) => {
return { doc, newIndex: index, oldIndex: -1, type: 'added' };
}),
}, this._queryCriteria.center);
}
}
function geoQueryOnSnapshot(query, queryCriteria) {
return (onNext, onError = () => { }) => {
if (queryCriteria.center && typeof queryCriteria.radius === 'number') {
return new GeoQueryOnSnapshot(generateQuery(query, queryCriteria), queryCriteria, onNext, onError).unsubscribe();
}
else {
query = queryCriteria.limit ? query.limit(queryCriteria.limit) : query;
return query.onSnapshot(snapshot => onNext(new GeoQuerySnapshot(snapshot)), onError);
}
};
}
class GeoQueryOnSnapshot {
constructor(_queries, _queryCriteria, _onNext, _onError = () => { }) {
this._queries = _queries;
this._queryCriteria = _queryCriteria;
this._onNext = _onNext;
this._onError = _onError;
this._docs = new Map();
this._firstRoundResolved = false;
this._firstEmitted = false;
this._newValues = false;
this._subscriptions = [];
this._queriesResolved = [];
validateQueryCriteria(_queryCriteria);
this._queriesResolved = new Array(_queries.length).fill(0);
_queries.forEach((value, index) => {
const subscription = value.onSnapshot(snapshot => this._processSnapshot(snapshot, index), error => (this._error = error));
this._subscriptions.push(subscription);
});
this._interval = setInterval(() => this._emit(), 100);
}
unsubscribe() {
return () => {
clearInterval(this._interval);
this._subscriptions.forEach(subscription => subscription());
};
}
_next() {
if (this._queryCriteria.limit &&
this._docs.size > this._queryCriteria.limit) {
const arrayToLimit = Array.from(this._docs.values()).sort((a, b) => a.distance - b.distance);
for (let i = this._queryCriteria.limit; i < arrayToLimit.length; i++) {
if (arrayToLimit[i].emitted) {
const result = {
change: Object.assign({}, arrayToLimit[i].change),
distance: arrayToLimit[i].distance,
emitted: arrayToLimit[i].emitted,
};
result.change.type = 'removed';
this._docs.set(result.change.doc.id, result);
}
else {
this._docs.delete(arrayToLimit[i].change.doc.id);
}
}
}
let deductIndexBy = 0;
const docChanges = Array.from(this._docs.values()).map((value, index) => {
const result = {
type: value.change.type,
doc: value.change.doc,
oldIndex: value.emitted ? value.change.newIndex : -1,
newIndex: value.change.type !== 'removed' ? index - deductIndexBy : -1,
};
if (result.type === 'removed') {
deductIndexBy--;
this._docs.delete(result.doc.id);
}
else {
this._docs.set(result.doc.id, {
change: result,
distance: value.distance,
emitted: true,
});
}
return result;
});
const docs = docChanges.reduce((filtered, change) => {
if (change.newIndex >= 0) {
filtered.push(change.doc);
}
else {
this._docs.delete(change.doc.id);
}
return filtered;
}, []);
this._firstEmitted = true;
this._onNext(new GeoQuerySnapshot({
docs,
docChanges: () => docChanges.reduce((reduced, change) => {
if (change.oldIndex === -1 || change.type !== 'added') {
reduced.push(change);
}
return reduced;
}, []),
}, this._queryCriteria.center));
}
_emit() {
if (this._error) {
this._onError(this._error);
this.unsubscribe()();
}
else if (this._newValues && this._firstRoundResolved) {
this._newValues = false;
this._next();
}
else if (!this._firstRoundResolved) {
this._firstRoundResolved =
this._queriesResolved.reduce((a, b) => a + b, 0) ===
this._queries.length;
}
}
_processSnapshot(snapshot, index) {
const docChanges = Array.isArray(snapshot.docChanges)
? snapshot.docChanges
: snapshot.docChanges();
if (!this._firstRoundResolved)
this._queriesResolved[index] = 1;
if (docChanges.length) {
docChanges.forEach(change => {
const docData = change.doc.data();
const geopoint = validateGeoDocument(docData, true)
? docData.g.geopoint
: null;
const distance = geopoint
? calculateDistance(this._queryCriteria.center, geopoint)
: null;
const id = change.doc.id;
const fromMap = this._docs.get(id);
const doc = {
change: {
doc: change.doc,
oldIndex: fromMap && this._firstEmitted ? fromMap.change.oldIndex : -1,
newIndex: fromMap && this._firstEmitted ? fromMap.change.newIndex : -1,
type: fromMap && this._firstEmitted ? change.type : 'added',
},
distance,
emitted: this._firstEmitted ? !!fromMap : false,
};
if (this._queryCriteria.radius >= distance) {
if (!fromMap && doc.change.type === 'removed')
return;
if (!fromMap && doc.change.type === 'modified')
doc.change.type = 'added';
this._newValues = true;
this._docs.set(id, doc);
}
else if (fromMap) {
doc.change.type = 'removed';
this._newValues = true;
this._docs.set(id, doc);
}
else if (!fromMap && !this._firstRoundResolved) {
this._newValues = true;
}
});
}
else if (!this._firstRoundResolved) {
this._newValues = true;
}
}
}
exports.GeoQuerySnapshot = GeoQuerySnapshot;
exports.encodeDocumentAdd = encodeDocumentAdd;
exports.encodeDocumentSet = encodeDocumentSet;
exports.encodeDocumentUpdate = encodeDocumentUpdate;
exports.encodeGeoDocument = encodeGeoDocument;
exports.geoQueryGet = geoQueryGet;
exports.geoQueryOnSnapshot = geoQueryOnSnapshot;
exports.validateGeoDocument = validateGeoDocument;
exports.validateLimit = validateLimit;
exports.validateLocation = validateLocation;
exports.validateQueryCriteria = validateQueryCriteria;