From e95be225de9c0f79b5988dfb83162a603cae0261 Mon Sep 17 00:00:00 2001 From: Michael Solati Date: Sun, 21 Jun 2020 15:18:26 -0700 Subject: [PATCH] feat: add functions for `onSnapshot` and `get` for geoqueries --- package-lock.json | 6 +- package.json | 2 +- src/api/query-get.ts | 119 +++++++ src/api/query-on-snapshot.ts | 267 ++++++++++++++++ src/api/snapshot.ts | 96 ++++++ src/constants.ts | 1 - src/{types.ts => definitions.ts} | 0 src/index.ts | 11 +- src/utils.ts | 518 ++++++++++++++++++++++++++++++- 9 files changed, 1003 insertions(+), 17 deletions(-) create mode 100644 src/api/query-get.ts create mode 100644 src/api/query-on-snapshot.ts create mode 100644 src/api/snapshot.ts delete mode 100644 src/constants.ts rename src/{types.ts => definitions.ts} (100%) diff --git a/package-lock.json b/package-lock.json index d2d6219..33e230f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -3306,9 +3306,9 @@ } }, "geokit": { - "version": "0.1.5", - "resolved": "https://registry.npmjs.org/geokit/-/geokit-0.1.5.tgz", - "integrity": "sha512-V1jwJ8/OxYeGyJvIIjBUNxtMOEKPDDuJddKLqOTNcAm7aBO47ksk+SiMlBitw3GbxLxANu7FkZ5CwTd7PiyS1w==" + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/geokit/-/geokit-1.0.1.tgz", + "integrity": "sha512-n/Q7nZhdx8pqVSCU4tVsYfNb/4JyLvtDynZ4Y+MZmU3LNThT0rHkbQKkc/v4uGw/J3o0w4UvbpPnFvvfeI4bqw==" }, "get-stdin": { "version": "6.0.0", diff --git a/package.json b/package.json index 0e38ed5..0f6c7c0 100644 --- a/package.json +++ b/package.json @@ -47,7 +47,7 @@ "README.md" ], "dependencies": { - "geokit": "^0.1.5" + "geokit": "^1.0.1" }, "optionalDependencies": { "@google-cloud/firestore": ">= 2.0.0", diff --git a/src/api/query-get.ts b/src/api/query-get.ts new file mode 100644 index 0000000..96ef636 --- /dev/null +++ b/src/api/query-get.ts @@ -0,0 +1,119 @@ +import {GeoQuerySnapshot} from './snapshot'; +import {GeoFirestoreTypes} from '../definitions'; +import { + calculateDistance, + generateQuery, + validateQueryCriteria, +} from '../utils'; + +/** + * Executes a query and returns the result(s) as a GeoQuerySnapshot. + * + * WEB CLIENT ONLY + * Note: By default, get() attempts to provide up-to-date data when possible by waiting for data from the server, but it may return + * cached data or fail if you are offline and the server cannot be reached. This behavior can be altered via the `GetOptions` parameter. + * + * @param query The Firestore Query instance. + * @param queryCriteria The query criteria of geo based queries, includes field such as center, radius, and limit. + */ +export function geoQueryGet( + query: GeoFirestoreTypes.cloud.Query | GeoFirestoreTypes.web.Query, + queryCriteria: GeoFirestoreTypes.QueryCriteria, + options: GeoFirestoreTypes.web.GetOptions = {source: 'default'} +): Promise { + const isWeb = + Object.prototype.toString.call( + (query as GeoFirestoreTypes.web.CollectionReference).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 as GeoFirestoreTypes.web.Query).get(options) + : (query as GeoFirestoreTypes.web.Query).get(); + + return promise.then(snapshot => new GeoQuerySnapshot(snapshot)); + } +} +/** + * A `GeoJoinerGet` aggregates multiple `get` results. + */ +export class GeoQueryGet { + private _docs: Map< + string, + GeoFirestoreTypes.web.QueryDocumentSnapshot + > = new Map(); + + /** + * @param snapshots An array of snpashots from a Firestore Query `get` call. + * @param _queryCriteria The query criteria of geo based queries, includes field such as center, radius, and limit. + */ + constructor( + snapshots: GeoFirestoreTypes.web.QuerySnapshot[], + private _queryCriteria: GeoFirestoreTypes.QueryCriteria + ) { + validateQueryCriteria(_queryCriteria); + + snapshots.forEach((snapshot: GeoFirestoreTypes.web.QuerySnapshot) => { + snapshot.docs.forEach(doc => { + const distance = calculateDistance( + this._queryCriteria.center, + (doc.data() as GeoFirestoreTypes.GeoDocumentData).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() as GeoFirestoreTypes.GeoDocumentData).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); + } + } + } + + /** + * Returns parsed docs as a GeoQuerySnapshot. + * + * @return A new `GeoQuerySnapshot` of the filtered documents from the `get`. + */ + getGeoQuerySnapshot(): GeoQuerySnapshot { + const docs = Array.from(this._docs.values()); + return new GeoQuerySnapshot( + { + docs, + docChanges: () => + docs.map((doc, index) => { + return {doc, newIndex: index, oldIndex: -1, type: 'added'}; + }), + } as GeoFirestoreTypes.web.QuerySnapshot, + this._queryCriteria.center + ); + } +} diff --git a/src/api/query-on-snapshot.ts b/src/api/query-on-snapshot.ts new file mode 100644 index 0000000..8217388 --- /dev/null +++ b/src/api/query-on-snapshot.ts @@ -0,0 +1,267 @@ +import {GeoFirestoreTypes} from '../definitions'; +import {GeoQuerySnapshot} from './snapshot'; +import { + calculateDistance, + generateQuery, + validateQueryCriteria, + validateGeoDocument, +} from '../utils'; + +interface DocMap { + change: GeoFirestoreTypes.web.DocumentChange; + distance: number; + emitted: boolean; +} + +/** + * Executes a query and returns the result(s) as a GeoQuerySnapshot. + * + * WEB CLIENT ONLY + * Note: By default, get() attempts to provide up-to-date data when possible by waiting for data from the server, but it may return + * cached data or fail if you are offline and the server cannot be reached. This behavior can be altered via the `GetOptions` parameter. + * + * @param query The Firestore Query instance. + * @param queryCriteria The query criteria of geo based queries, includes field such as center, radius, and limit. + */ +export function geoQueryOnSnapshot( + query: GeoFirestoreTypes.cloud.Query | GeoFirestoreTypes.web.Query, + queryCriteria: GeoFirestoreTypes.QueryCriteria +): ( + onNext: (snapshot: GeoQuerySnapshot) => void, + onError?: (error: Error) => void +) => () => void { + return ( + onNext: (snapshot: GeoQuerySnapshot) => void, + onError: (error: Error) => void = () => {} + ): (() => void) => { + 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 as GeoFirestoreTypes.web.Query).onSnapshot( + snapshot => onNext(new GeoQuerySnapshot(snapshot)), + onError + ); + } + }; +} + +/** + * A `GeoJoinerOnSnapshot` subscribes and aggregates multiple `onSnapshot` listeners + * while filtering out documents not in query radius. + */ +export class GeoQueryOnSnapshot { + private _docs: Map = new Map(); + private _error: Error; + private _firstRoundResolved = false; + private _firstEmitted = false; + private _interval: any; + private _newValues = false; + private _subscriptions: Array<() => void> = []; + private _queriesResolved: number[] = []; + + /** + * @param _queries An array of Firestore Queries to aggregate. + * @param _queryCriteria The query criteria of geo based queries, includes field such as center, radius, and limit. + * @param _onNext A callback to be called every time a new `QuerySnapshot` is available. + * @param _onError A callback to be called if the listen fails or is cancelled. No further callbacks will occur. + */ + constructor( + private _queries: GeoFirestoreTypes.web.Query[], + private _queryCriteria: GeoFirestoreTypes.QueryCriteria, + private _onNext: (snapshot: GeoQuerySnapshot) => void, + private _onError: (error: Error) => void = () => {} + ) { + validateQueryCriteria(_queryCriteria); + this._queriesResolved = new Array(_queries.length).fill(0); + _queries.forEach((value: GeoFirestoreTypes.web.Query, index) => { + const subscription = value.onSnapshot( + snapshot => this._processSnapshot(snapshot, index), + error => (this._error = error) + ); + this._subscriptions.push(subscription); + }); + + this._interval = setInterval(() => this._emit(), 100); + } + + /** + * A functions that clears the interval and ends all query subscriptions. + * + * @return An unsubscribe function that can be called to cancel all snapshot listener. + */ + unsubscribe(): () => void { + return () => { + clearInterval(this._interval); + this._subscriptions.forEach(subscription => subscription()); + }; + } + + /** + * Runs through documents stored in map to set value to send in `next` function. + */ + private _next(): void { + // Sort docs based on distance if there is a limit so we can then limit it + if ( + this._queryCriteria.limit && + this._docs.size > this._queryCriteria.limit + ) { + const arrayToLimit = Array.from(this._docs.values()).sort( + (a, b) => a.distance - b.distance + ); + // Iterate over documents outside of limit + for (let i = this._queryCriteria.limit; i < arrayToLimit.length; i++) { + if (arrayToLimit[i].emitted) { + // Mark as removed if outside of query and previously emitted + const result = { + change: {...arrayToLimit[i].change}, + distance: arrayToLimit[i].distance, + emitted: arrayToLimit[i].emitted, + }; + result.change.type = 'removed'; + this._docs.set(result.change.doc.id, result); + } else { + // Remove if not previously in query + this._docs.delete(arrayToLimit[i].change.doc.id); + } + } + } + + let deductIndexBy = 0; + const docChanges = Array.from(this._docs.values()).map( + (value: DocMap, index: number) => { + const result: GeoFirestoreTypes.web.DocumentChange = { + 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; + }, []), + } as GeoFirestoreTypes.web.QuerySnapshot, + this._queryCriteria.center + ) + ); + } + + /** + * Determines if new values should be emitted via `next` or if subscription should be killed with `error`. + */ + private _emit(): void { + 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; + } + } + + /** + * Parses `snapshot` and filters out documents not in query radius. Sets new values to `_docs` map. + * + * @param snapshot The `QuerySnapshot` of the query. + * @param index Index of query who's snapshot has been triggered. + */ + private _processSnapshot( + snapshot: GeoFirestoreTypes.web.QuerySnapshot, + index: number + ): void { + const docChanges = Array.isArray(snapshot.docChanges) + ? ((snapshot.docChanges as any) as GeoFirestoreTypes.web.DocumentChange[]) + : snapshot.docChanges(); + if (!this._firstRoundResolved) this._queriesResolved[index] = 1; + if (docChanges.length) { + // Snapshot has data, key during first snapshot + docChanges.forEach(change => { + const docData = change.doc.data() as GeoFirestoreTypes.GeoDocumentData; + 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: any = { + 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) { + // Ensure doc in query radius + // Ignore doc since it wasn't in map and was already 'removed' + if (!fromMap && doc.change.type === 'removed') return; + // Mark doc as 'added' doc since it wasn't in map and was 'modified' to be + if (!fromMap && doc.change.type === 'modified') + doc.change.type = 'added'; + this._newValues = true; + this._docs.set(id, doc); + } else if (fromMap) { + // Document isn't in query, but is in map + doc.change.type = 'removed'; // Not in query anymore, mark for removal + this._newValues = true; + this._docs.set(id, doc); + } else if (!fromMap && !this._firstRoundResolved) { + // Document isn't in map and the first round hasn't resolved + // This is an empty query, but it has resolved + this._newValues = true; + } + }); + } else if (!this._firstRoundResolved) { + // Snapshot doesn't have data, key during first snapshot + this._newValues = true; + } + } +} diff --git a/src/api/snapshot.ts b/src/api/snapshot.ts new file mode 100644 index 0000000..8c75f6d --- /dev/null +++ b/src/api/snapshot.ts @@ -0,0 +1,96 @@ +import {GeoFirestoreTypes} from '../definitions'; +import {generateGeoQueryDocumentSnapshot, validateLocation} from '../utils'; + +/** + * A `GeoQuerySnapshot` contains zero or more `QueryDocumentSnapshot` objects + * representing the results of a query. The documents can be accessed as an + * array via the `docs` property or enumerated using the `forEach` method. The + * number of documents can be determined via the `empty` and `size` + * properties. + */ +export class GeoQuerySnapshot { + private _docs: GeoFirestoreTypes.QueryDocumentSnapshot[]; + + /** + * @param _querySnapshot The `QuerySnapshot` instance. + * @param geoQueryCriteria The center and radius of geo based queries. + */ + constructor( + private _querySnapshot: + | GeoFirestoreTypes.web.QuerySnapshot + | GeoFirestoreTypes.cloud.QuerySnapshot, + private _center?: + | GeoFirestoreTypes.cloud.GeoPoint + | GeoFirestoreTypes.web.GeoPoint + ) { + if (_center) { + // Validate the _center coordinates + validateLocation(_center); + } + + this._docs = (_querySnapshot as GeoFirestoreTypes.cloud.QuerySnapshot).docs.map( + (snapshot: GeoFirestoreTypes.cloud.QueryDocumentSnapshot) => + generateGeoQueryDocumentSnapshot(snapshot, _center) + ); + } + + /** The native `QuerySnapshot` instance. */ + get native(): + | GeoFirestoreTypes.cloud.QuerySnapshot + | GeoFirestoreTypes.web.QuerySnapshot { + return this._querySnapshot; + } + + /** An array of all the documents in the GeoQuerySnapshot. */ + get docs(): GeoFirestoreTypes.QueryDocumentSnapshot[] { + return this._docs; + } + + /** The number of documents in the GeoQuerySnapshot. */ + get size(): number { + return this._docs.length; + } + + /** True if there are no documents in the GeoQuerySnapshot. */ + get empty(): boolean { + return !this._docs.length; + } + + /** + * Returns an array of the documents changes since the last snapshot. If + * this is the first snapshot, all documents will be in the list as added + * changes. + * + * @returns Array of DocumentChanges. + */ + docChanges(): GeoFirestoreTypes.DocumentChange[] { + const docChanges = Array.isArray(this._querySnapshot.docChanges) + ? ((this._querySnapshot + .docChanges as any) as GeoFirestoreTypes.web.DocumentChange[]) + : this._querySnapshot.docChanges(); + return (docChanges as GeoFirestoreTypes.web.DocumentChange[]).map( + (change: GeoFirestoreTypes.web.DocumentChange) => { + return { + doc: generateGeoQueryDocumentSnapshot(change.doc, this._center), + newIndex: change.newIndex, + oldIndex: change.oldIndex, + type: change.type, + }; + } + ); + } + + /** + * Enumerates all of the documents in the GeoQuerySnapshot. + * + * @param callback A callback to be called with a `DocumentSnapshot` for + * each document in the snapshot. + * @param thisArg The `this` binding for the callback. + */ + forEach( + callback: (result: GeoFirestoreTypes.QueryDocumentSnapshot) => void, + thisArg?: any + ): void { + this.docs.forEach(callback, thisArg); + } +} diff --git a/src/constants.ts b/src/constants.ts deleted file mode 100644 index 3a756df..0000000 --- a/src/constants.ts +++ /dev/null @@ -1 +0,0 @@ -export const CUSTOM_KEY = 'coordinates'; diff --git a/src/types.ts b/src/definitions.ts similarity index 100% rename from src/types.ts rename to src/definitions.ts diff --git a/src/index.ts b/src/index.ts index 88b2fcb..b35e61f 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,6 +1,5 @@ -export * from './types'; -export { - encodeDocumentAdd, - encodeDocumentSet, - encodeDocumentUpdate, -} from './utils'; +export {geoQueryGet} from './api/query-get'; +export {geoQueryOnSnapshot} from './api/query-on-snapshot'; +export {GeoQuerySnapshot} from './api/snapshot'; +export * from './definitions'; +export * from './utils'; diff --git a/src/utils.ts b/src/utils.ts index 780fb0c..c539974 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -1,6 +1,146 @@ -import {Geokit} from 'geokit'; -import {GeoFirestoreTypes} from './types'; -import {CUSTOM_KEY} from './constants'; +import {distance as calcDistance, hash, validateHash} from 'geokit'; +import {GeoFirestoreTypes} from './definitions'; + +// Characters used in location geohashes +export const BASE32 = '0123456789bcdefghjkmnpqrstuvwxyz'; + +// Number of bits per geohash character +export const BITS_PER_CHAR = 5; + +// Default key for GeoPoint in a Firestore Document. +export const CUSTOM_KEY = 'coordinates'; + +// The following value assumes a polar radius of +// const EARTH_POL_RADIUS = 6356752.3; +// The formulate to calculate E2 is +// E2 == (EARTH_EQ_RADIUS^2-EARTH_POL_RADIUS^2)/(EARTH_EQ_RADIUS^2) +// The exact value is used here to avoid rounding errors +export const E2 = 0.00669447819799; + +// Equatorial radius of the earth in meters +export const EARTH_EQ_RADIUS = 6378137.0; + +// The meridional circumference of the earth in meters +export const EARTH_MERI_CIRCUMFERENCE = 40007860; + +// Cutoff for rounding errors on double calculations +export const EPSILON = 1e-12; + +// Maximum length of a geohash in bits +export const MAXIMUM_BITS_PRECISION = 22 * BITS_PER_CHAR; + +// Length of a degree latitude at the equator +export const METERS_PER_DEGREE_LATITUDE = 110574; + +/** + * 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 coordinate The coordinate as a Firestore GeoPoint. + * @param size The size of the bounding box. + * @return The number of bits necessary for the geohash. + */ +export function boundingBoxBits( + coordinate: GeoFirestoreTypes.cloud.GeoPoint | GeoFirestoreTypes.web.GeoPoint, + size: number +): number { + 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 + ); +} + +/** + * 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 center The center given as Firestore GeoPoint. + * @param radius The radius of the circle. + * @return The eight bounding box points. + */ +export function boundingBoxCoordinates( + center: GeoFirestoreTypes.cloud.GeoPoint | GeoFirestoreTypes.web.GeoPoint, + radius: number +): GeoFirestoreTypes.cloud.GeoPoint[] | GeoFirestoreTypes.web.GeoPoint[] { + 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 which validates GeoPoints then calculates the distance, in kilometers, between them. + * + * @param location1 The GeoPoint of the first location. + * @param location2 The GeoPoint of the second location. + * @return The distance, in kilometers, between the inputted locations. + */ +export function calculateDistance( + location1: GeoFirestoreTypes.cloud.GeoPoint | GeoFirestoreTypes.web.GeoPoint, + location2: GeoFirestoreTypes.cloud.GeoPoint | GeoFirestoreTypes.web.GeoPoint +): number { + validateLocation(location1); + validateLocation(location2); + + return calcDistance( + {lat: location1.latitude, lng: location1.longitude}, + {lat: location2.latitude, lng: location2.longitude} + ); +} + +/** + * Decodes the GeoDocument data. Returns non-decoded data if decoding fails. + * + * @param data The data encoded as a GeoDocument object. + * @param center The center to calculate the distance of the Document from the query origin. + * @return The decoded Firestore document or non-decoded data if decoding fails in an object including distance from origin. + */ +export function decodeGeoQueryDocumentSnapshotData( + data: GeoFirestoreTypes.GeoDocumentData, + center?: GeoFirestoreTypes.web.GeoPoint | GeoFirestoreTypes.cloud.GeoPoint +): {data: () => GeoFirestoreTypes.GeoDocumentData; distance: number} { + if (validateGeoDocument(data, true)) { + const distance = center ? calculateDistance(data.g.geopoint, center) : null; + return {data: () => data, distance}; + } + return {data: () => data, distance: null}; +} + +/** + * Converts degrees to radians. + * + * @param degrees The number of degrees to be converted to radians. + * @return The number of radians equal to the inputted number of degrees. + */ +export function degreesToRadians(degrees: number): number { + if (typeof degrees !== 'number' || isNaN(degrees)) { + throw new Error('Error: degrees must be a number'); + } + + return (degrees * Math.PI) / 180; +} /** * Encodes a Firestore Document to be added as a GeoDocument. @@ -34,7 +174,7 @@ export function encodeDocumentSet( if (Object.prototype.toString.call(documentData) !== '[object Object]') { throw new Error('document must be an object'); } - const customKey = (options && options.customKey) || CUSTOM_KEY; + const customKey = options && options.customKey; const geopoint = findGeoPoint( documentData, customKey, @@ -79,7 +219,7 @@ export function encodeGeoDocument( documentData: GeoFirestoreTypes.DocumentData ): GeoFirestoreTypes.GeoDocumentData { validateLocation(geopoint); - const geohash = Geokit.hash({ + const geohash = hash({ lat: geopoint.latitude, lng: geopoint.longitude, }); @@ -100,9 +240,10 @@ export function encodeGeoDocument( */ export function findGeoPoint( document: GeoFirestoreTypes.DocumentData, - customKey = CUSTOM_KEY, + customKey?: string, flag = false ): GeoFirestoreTypes.web.GeoPoint | GeoFirestoreTypes.cloud.GeoPoint { + customKey = customKey || CUSTOM_KEY; let error: string; let geopoint; @@ -135,6 +276,239 @@ export function findGeoPoint( return geopoint; } +/** + * Creates GeoFirestore QueryDocumentSnapshot by pulling data out of original Firestore QueryDocumentSnapshot and strip GeoFirsetore + * Document data, such as geohash and coordinates. + * + * @param snapshot The QueryDocumentSnapshot. + * @param center The center to calculate the distance of the Document from the query origin. + * @return The snapshot as a GeoFirestore QueryDocumentSnapshot. + */ +export function generateGeoQueryDocumentSnapshot( + snapshot: + | GeoFirestoreTypes.web.QueryDocumentSnapshot + | GeoFirestoreTypes.cloud.QueryDocumentSnapshot, + center?: GeoFirestoreTypes.web.GeoPoint | GeoFirestoreTypes.cloud.GeoPoint +): GeoFirestoreTypes.QueryDocumentSnapshot { + const decoded = decodeGeoQueryDocumentSnapshotData( + snapshot.data() as GeoFirestoreTypes.GeoDocumentData, + center + ); + return { + exists: snapshot.exists, + id: snapshot.id, + ...decoded, + }; +} + +/** + * Creates an array of `Query` objects that query the appropriate geohashes based on the radius and center GeoPoint of the query criteria. + * @param query The Firestore Query instance. + * @param queryCriteria The query criteria of geo based queries, includes field such as center, radius, and limit. + * @return Array of Queries to search against. + */ +export function generateQuery( + query: GeoFirestoreTypes.cloud.Query | GeoFirestoreTypes.web.Query, + queryCriteria: GeoFirestoreTypes.QueryCriteria +): GeoFirestoreTypes.web.Query[] { + // Get the list of geohashes to query + let geohashesToQuery: string[] = geohashQueries( + queryCriteria.center, + queryCriteria.radius * 1000 + ).map(queryToString); + // Filter out duplicate geohashes + geohashesToQuery = geohashesToQuery.filter( + (geohash: string, i: number) => geohashesToQuery.indexOf(geohash) === i + ); + + return geohashesToQuery.map((toQueryStr: string) => { + // decode the geohash query string + const queries = stringToQuery(toQueryStr); + // Create the Firebase query + return query + .orderBy('g.geohash') + .startAt(queries[0]) + .endAt(queries[1]) as GeoFirestoreTypes.web.Query; + }); +} + +/** + * Calculates a set of queries to fully contain a given circle. A query is a GeoPoint where any geohash is guaranteed to be + * lexiographically larger then start and smaller than end. + * + * @param center The center given as a GeoPoint. + * @param radius The radius of the circle. + * @return An array of geohashes containing a GeoPoint. + */ +export function geohashQueries( + center: GeoFirestoreTypes.cloud.GeoPoint | GeoFirestoreTypes.web.GeoPoint, + radius: number +): string[][] { + validateLocation(center); + const queryBits = Math.max(1, boundingBoxBits(center, radius)); + const geohashPrecision = Math.ceil(queryBits / BITS_PER_CHAR); + const coordinates: + | GeoFirestoreTypes.cloud.GeoPoint + | GeoFirestoreTypes.web.GeoPoint[] = boundingBoxCoordinates(center, radius); + const queries = coordinates.map(coordinate => { + return geohashQuery( + hash( + { + lat: coordinate.latitude, + lng: coordinate.longitude, + }, + geohashPrecision + ), + queryBits + ); + }); + // remove duplicates + return queries.filter((query, index) => { + return !queries.some((other, otherIndex) => { + return ( + index > otherIndex && query[0] === other[0] && query[1] === other[1] + ); + }); + }); +} + +/** + * Calculates the bounding box query for a geohash with x bits precision. + * + * @param geohash The geohash whose bounding box query to generate. + * @param bits The number of bits of precision. + * @return A [start, end] pair of geohashes. + */ +export function geohashQuery(geohash: string, bits: number): string[] { + 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; + // delete unused bits + 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]]; + } +} + +/** + * Calculates the bits necessary to reach a given resolution, in meters, for the latitude. + * + * @param resolution The bits necessary to reach a given resolution, in meters. + * @return Bits necessary to reach a given resolution, in meters, for the latitude. + */ +export function latitudeBitsForResolution(resolution: number): number { + return Math.min( + log2(EARTH_MERI_CIRCUMFERENCE / 2 / resolution), + MAXIMUM_BITS_PRECISION + ); +} + +/** + * Calculates the base 2 logarithm of the given number. + * + * @param x A number + * @return The base 2 logarithm of a number + */ +export function log2(x: number): number { + return Math.log(x) / Math.log(2); +} + +/** + * Calculates the bits necessary to reach a given resolution, in meters, for the longitude at a given latitude. + * + * @param resolution The desired resolution. + * @param latitude The latitude used in the conversion. + * @return The bits necessary to reach a given resolution, in meters. + */ +export function longitudeBitsForResolution( + resolution: number, + latitude: number +): number { + const degs = metersToLongitudeDegrees(resolution, latitude); + return Math.abs(degs) > 0.000001 ? Math.max(1, log2(360 / degs)) : 1; +} + +/** + * Calculates the number of degrees a given distance is at a given latitude. + * + * @param distance The distance to convert. + * @param latitude The latitude at which to calculate. + * @return The number of degrees the distance corresponds to. + */ +export function metersToLongitudeDegrees( + distance: number, + latitude: number +): number { + 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); + } +} + +/** + * Decodes a query string to a query + * + * @param str The encoded query. + * @return The decoded query as a [start, end] pair. + */ +export function stringToQuery(str: string): string[] { + const decoded: string[] = str.split(':'); + if (decoded.length !== 2) { + throw new Error( + 'Invalid internal state! Not a valid geohash query: ' + str + ); + } + return decoded; +} + +/** + * Encodes a query as a string for easier indexing and equality. + * + * @param query The query to encode. + * @return The encoded query as string. + */ +export function queryToString(query: string[]): string { + if (query.length !== 2) { + throw new Error('Not a valid geohash query: ' + query); + } + return query[0] + ':' + query[1]; +} + +/** + * Returns a 'GeoPoint.' (Kind of fake, but get's the job done!) + * + * @param latitude Latitude for GeoPoint. + * @param longitude Longitude for GeoPoint. + * @return Firestore "GeoPoint" + */ +export function toGeoPoint( + latitude: number, + longitude: number +): GeoFirestoreTypes.cloud.GeoPoint | GeoFirestoreTypes.web.GeoPoint { + const fakeGeoPoint: + | GeoFirestoreTypes.cloud.GeoPoint + | GeoFirestoreTypes.web.GeoPoint = {latitude, longitude} as + | GeoFirestoreTypes.cloud.GeoPoint + | GeoFirestoreTypes.web.GeoPoint; + validateLocation(fakeGeoPoint); + return fakeGeoPoint; +} + /** * Validates a GeoPoint object and returns a boolean if valid, or throws an error if invalid. * @@ -174,3 +548,135 @@ export function validateLocation( return !error; } } + +/** + * Validates the inputted limit and throws an error, or returns boolean, if it is invalid. + * + * @param limit The limit to be applied by `GeoQuery.limit()` + * @param flag Tells function to send up boolean if valid instead of throwing an error. + */ +export function validateLimit(limit: number, flag = false): boolean { + let error: string; + 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; + } +} + +/** + * Validates the inputted GeoDocument object and throws an error, or returns boolean, if it is invalid. + * + * @param documentData The GeoDocument object to be validated. + * @param flag Tells function to send up boolean if valid instead of throwing an error. + * @return Flag if data is valid + */ +export function validateGeoDocument( + documentData: GeoFirestoreTypes.GeoDocumentData, + flag = false +): boolean { + let error: string; + + if (!documentData) { + error = 'no document found'; + } else if ('g' in documentData) { + error = !validateHash(documentData.g.geohash, true) + ? 'invalid geohash on object' + : null; + error = !validateLocation(documentData.g.geopoint, true) + ? 'invalid location on object' + : error; + } else { + error = 'no `g` field found in object'; + } + + if (error && !flag) { + throw new Error('Invalid GeoFirestore object: ' + error); + } else { + return !error; + } +} + +/** + * Validates the inputted query criteria and throws an error if it is invalid. + * + * @param newQueryCriteria The criteria which specifies the query's center and/or radius. + * @param requireCenterAndRadius The criteria which center and radius required. + */ +export function validateQueryCriteria( + newQueryCriteria: GeoFirestoreTypes.QueryCriteria, + requireCenterAndRadius = false +): void { + 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' + ); + } + + // Throw an error if there are any extraneous attributes + const keys: string[] = Object.keys(newQueryCriteria); + for (const key of keys) { + if (!['center', 'radius', 'limit'].includes(key)) { + 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'); + } + } + + // Validate the 'limit' attribute + if (typeof newQueryCriteria.limit !== 'undefined') { + validateLimit(newQueryCriteria.limit); + } +} + +/** + * Wraps the longitude to [-180,180]. + * + * @param longitude The longitude to wrap. + * @return longitude The resulting longitude. + */ +export function wrapLongitude(longitude: number): number { + if (longitude <= 180 && longitude >= -180) { + return longitude; + } + const adjusted = longitude + 180; + if (adjusted > 0) { + return (adjusted % 360) - 180; + } else { + return 180 - (-adjusted % 360); + } +}