UNPKG

geofirestore-core

Version:

Lightweight location-based querying and encoding of Firebase Firestore Documents for geofirestore.

592 lines (584 loc) 23.4 kB
import { validateHash, distance, hash } from '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 = !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 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(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) { 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 = 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; } } } export { GeoQuerySnapshot, encodeDocumentAdd, encodeDocumentSet, encodeDocumentUpdate, encodeGeoDocument, geoQueryGet, geoQueryOnSnapshot, validateGeoDocument, validateLimit, validateLocation, validateQueryCriteria };