UNPKG

@thetsf/geofirex

Version:

Realtime Firestore GeoQueries with RxJS

130 lines (129 loc) 5.36 kB
import { collection, endAt, getFirestore, orderBy, query, startAt, onSnapshot } from "firebase/firestore"; import { combineLatest, Observable, Subject } from 'rxjs'; import { finalize, first, map, shareReplay, takeUntil } from 'rxjs/operators'; import { bearing, distance, neighbors, setPrecision, toGeoJSONFeature } from './util'; const defaultOpts = { units: 'km', log: false }; export class GeoFireQuery { app; refString; ref; constructor(app, refString) { this.app = app; this.refString = refString; if (typeof refString === 'string') { const db = getFirestore(app); this.ref = collection(db, refString); // this.ref = this.app.firestore().collection(ref); } else { this.ref = refString; } } // GEO QUERIES /** * Queries the Firestore collection based on geograpic radius * @param {FirePoint} center the starting point for the query, i.e gfx.point(lat, lng) * @param {number} radius the radius to search from the centerpoint * @param {string} field the document field that contains the FirePoint data * @param {GeoQueryOptions} opts=defaultOpts * @returns {Observable<GeoQueryDocument>} sorted by nearest to farthest */ within(center, radius, field, opts) { opts = { ...defaultOpts, ...opts }; const tick = Date.now(); const precision = setPrecision(radius); const radiusBuffer = radius * 1.02; // buffer for edge distances const centerHash = center.geohash.substr(0, precision); const area = neighbors(centerHash).concat(centerHash); const { latitude: centerLat, longitude: centerLng } = center.geopoint; // Used to cancel the individual geohash subscriptions const complete = new Subject(); // Map geohash neighbors to individual queries const queries = area.map(hash => { const query = this.queryPoint(hash, field); return createStream(query).pipe(snapToData(), takeUntil(complete)); }); // Combine all queries concurrently const combo = combineLatest(...queries).pipe(map(arr => { // Combine results into a single array const reduced = arr.reduce((acc, cur) => acc.concat(cur)); // Filter by radius const filtered = reduced.filter(val => { const { latitude, longitude } = val[field].geopoint; return (distance([centerLat, centerLng], [latitude, longitude]) <= radiusBuffer); }); // Optional logging if (opts.log) { console.group('GeoFireX Query'); console.log(`🌐 Center ${[centerLat, centerLng]}. Radius ${radius}`); console.log(`📍 Hits: ${reduced.length}`); console.log(`⌚ Elapsed time: ${Date.now() - tick}ms`); console.log(`🟢 Within Radius: ${filtered.length}`); console.groupEnd(); } // Map and sort to final output return filtered .map(val => { const { latitude, longitude } = val[field].geopoint; const hitMetadata = { distance: distance([centerLat, centerLng], [latitude, longitude]), bearing: bearing([centerLat, centerLng], [latitude, longitude]) }; return { ...val, hitMetadata }; }) .sort((a, b) => a.hitMetadata.distance - b.hitMetadata.distance); }), shareReplay(1), finalize(() => { opts.log && console.log('✋ Query complete'); complete.next(true); })); return combo; } queryPoint(geohash, field) { const end = geohash + '~'; return query(this.ref, orderBy(`${field}.geohash`), startAt(geohash), endAt(end)); /*return (this.ref as CollectionReference) .orderBy(`${field}.geohash`) .startAt(geohash) .endAt(end);*/ } } function snapToData(id = 'id') { return map((querySnapshot) => querySnapshot.docs.map(v => { return { ...(id ? { [id]: v.id } : null), ...v.data() }; })); } /** internal, do not use. Converts callback to Observable. */ function createStream(input) { return new Observable(observer => { const unsubscribe = onSnapshot(input, val => observer.next(val), err => observer.error(err)); return { unsubscribe }; }); } /** * RxJS operator that converts a collection to a GeoJSON FeatureCollection * @param {string} field the document field that contains the FirePoint * @param {boolean=false} includeProps */ export function toGeoJSON(field, includeProps = false) { return map((data) => { return { type: 'FeatureCollection', features: data.map(v => toGeoJSONFeature([v[field].geopoint.latitude, v[field].geopoint.longitude], includeProps ? { ...v } : {})) }; }); } /** * Helper function to convert any query from an RxJS Observable to a Promise * Example usage: await get( collection.within(a, b, c) ) * @param {Observable<any>} observable * @returns {Promise<any>} */ export function get(observable) { return observable.pipe(first()).toPromise(); }