import { GeoCallbackRegistration } from './GeoCallbackRegistration' ; import { distanceBetween , geohashForLocation , geohashQueryBounds , validateLocation , Geopoint , Geohash , GeohashRange } from 'geofire-common' ; import { decodeGeoFireObject , geoFireGetKey } from './databaseUtils' ; import * as GeoFireTypes from './GeoFireTypes' ; import * as DatabaseTypes from '@firebase/database-types' ; export interface QueryCriteria { center ? : Geopoint ; radius ? : number ; } export type QueryStateCallback = ( a : DatabaseTypes . DataSnapshot | null , b ? : string ) => any ; export interface QueryState { active : boolean ; childAddedCallback : QueryStateCallback ; childRemovedCallback : QueryStateCallback ; childChangedCallback : QueryStateCallback ; valueCallback : QueryStateCallback ; } param newQueryCriteria param requireCenterAndRadius export function validateCriteria ( newQueryCriteria , requireCenterAndRadius = false ) { 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' ) ; } const keys = Object . keys ( newQueryCriteria ) ; for ( const key of keys ) { if ( key !== 'center' && key !== 'radius' ) { 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' ) ; } } } /**
 * Creates a GeoQuery instance.
 */ export class GeoQuery { private _callbacks : GeoFireTypes . QueryCallbacks = { ready : [ ] , key_entered : [ ] , key_exited : [ ] , key_moved : [ ] } ; private _cancelled = false ; private _center : Geopoint ; private _currentGeohashesQueried : { [ name ] : QueryState ; } = { } ; private _locationsTracked : { [ name ] : GeoFireTypes . LocationTracked ; } = { } ; private _radius : number ; private _valueEventFired = false ; private _outstandingGeohashReadyEvents : any ; private _geohashCleanupScheduled = false ; private _cleanUpCurrentGeohashesQueriedInterval : any ; private _cleanUpCurrentGeohashesQueriedTimeout = null ; param _firebaseRef param queryCriteria constructor ( private _firebaseRef , queryCriteria ) { if ( Object . prototype . toString . call ( this . _firebaseRef ) !== '[object Object]' ) { throw new Error ( 'firebaseRef must be an instance of Firebase' ) ; } this . _cleanUpCurrentGeohashesQueriedInterval = setInterval ( ( ) => { if ( this . _geohashCleanupScheduled === false ) { this . _cleanUpCurrentGeohashesQueried ( ) ; } } , 10000 ) ; validateCriteria ( queryCriteria , true ) ; this . _center = queryCriteria . center ; this . _radius = queryCriteria . radius ; this . _listenForNewGeohashes ( ) ; } /********************/ /********************/ /**
     * Terminates this query so that it no longer sends location updates. All callbacks attached to this
     * query via on() will be cancelled. This query can no longer be used in the future.
     */ public cancel ( ) { this . _cancelled = true ; this . _callbacks = { ready : [ ] , key_entered : [ ] , key_exited : [ ] , key_moved : [ ] } ; const keys = Object . keys ( this . _currentGeohashesQueried ) ; keys . forEach ( ( geohashQueryStr ) => { const query = this . _stringToQuery ( geohashQueryStr ) ; this . _cancelGeohashQuery ( query , this . _currentGeohashesQueried [ geohashQueryStr ] ) ; delete this . _currentGeohashesQueried [ geohashQueryStr ] ; } ) ; this . _locationsTracked = { } ; clearInterval ( this . _cleanUpCurrentGeohashesQueriedInterval ) ; } returns public center ( ) { return this . _center ; } param eventType param callback returns public on ( eventType , callback ) { if ( [ 'ready' , 'key_entered' , 'key_exited' , 'key_moved' ] . indexOf ( eventType ) === - 1 ) { throw new Error ( 'event type must be \'ready\', \'key_entered\', \'key_exited\', or \'key_moved\'' ) ; } if ( typeof callback !== 'function' ) { throw new Error ( 'callback must be a function' ) ; } this . _callbacks [ eventType ] . push ( callback ) ; if ( eventType === 'key_entered' ) { const keys = Object . keys ( this . _locationsTracked ) ; keys . forEach ( ( key ) => { const locationDict = this . _locationsTracked [ key ] ; if ( typeof locationDict !== 'undefined' && locationDict . isInQuery ) { callback ( key , locationDict . location , locationDict . distanceFromCenter ) ; } } ) ; } if ( eventType === 'ready' && this . _valueEventFired ) { callback ( ) ; } return new GeoCallbackRegistration ( ( ) => { this . _callbacks [ eventType ] . splice ( this . _callbacks [ eventType ] . indexOf ( callback ) , 1 ) ; } ) ; } returns public radius ( ) { return this . _radius ; } param newQueryCriteria public updateCriteria ( newQueryCriteria ) { validateCriteria ( newQueryCriteria ) ; this . _center = newQueryCriteria . center || this . _center ; this . _radius = newQueryCriteria . radius || this . _radius ; const keys = Object . keys ( this . _locationsTracked ) ; for ( const key of keys ) { if ( this . _cancelled === true ) { break ; } const locationDict = this . _locationsTracked [ key ] ; const wasAlreadyInQuery = locationDict . isInQuery ; locationDict . distanceFromCenter = distanceBetween ( locationDict . location , this . _center ) ; locationDict . isInQuery = ( locationDict . distanceFromCenter <= this . _radius ) ; if ( wasAlreadyInQuery && ! locationDict . isInQuery ) { this . _fireCallbacksForKey ( 'key_exited' , key , locationDict . location , locationDict . distanceFromCenter ) ; } else if ( ! wasAlreadyInQuery && locationDict . isInQuery ) { this . _fireCallbacksForKey ( 'key_entered' , key , locationDict . location , locationDict . distanceFromCenter ) ; } } this . _valueEventFired = false ; this . _listenForNewGeohashes ( ) ; } /*********************/ /*********************/ param query param queryState private _cancelGeohashQuery ( query , queryState ) { const queryRef = this . _firebaseRef . orderByChild ( 'g' ) . startAt ( query [ 0 ] ) . endAt ( query [ 1 ] ) ; queryRef . off ( 'child_added' , queryState . childAddedCallback ) ; queryRef . off ( 'child_removed' , queryState . childRemovedCallback ) ; queryRef . off ( 'child_changed' , queryState . childChangedCallback ) ; queryRef . off ( 'value' , queryState . valueCallback ) ; } param locationDataSnapshot private _childAddedCallback ( locationDataSnapshot ) { this . _updateLocation ( geoFireGetKey ( locationDataSnapshot ) , decodeGeoFireObject ( locationDataSnapshot . val ( ) ) ) ; } param locationDataSnapshot private _childChangedCallback ( locationDataSnapshot ) { this . _updateLocation ( geoFireGetKey ( locationDataSnapshot ) , decodeGeoFireObject ( locationDataSnapshot . val ( ) ) ) ; } param locationDataSnapshot private _childRemovedCallback ( locationDataSnapshot ) { const key = geoFireGetKey ( locationDataSnapshot ) ; if ( key in this . _locationsTracked ) { this . _firebaseRef . child ( key ) . once ( 'value' , ( snapshot ) => { const location = ( snapshot . val ( ) === null ) ? null : decodeGeoFireObject ( snapshot . val ( ) ) ; const geohash = ( location !== null ) ? geohashForLocation ( location ) : null ; if ( ! this . _geohashInSomeQuery ( geohash ) ) { this . _removeLocation ( key , location ) ; } } ) ; } } /**
     * Removes unnecessary Firebase queries which are currently being queried.
     */ private _cleanUpCurrentGeohashesQueried ( ) { let keys = Object . keys ( this . _currentGeohashesQueried ) ; keys . forEach ( ( geohashQueryStr ) => { const queryState = this . _currentGeohashesQueried [ geohashQueryStr ] ; if ( queryState . active === false ) { const query = this . _stringToQuery ( geohashQueryStr ) ; this . _cancelGeohashQuery ( query , queryState ) ; delete this . _currentGeohashesQueried [ geohashQueryStr ] ; } } ) ; keys = Object . keys ( this . _locationsTracked ) ; keys . forEach ( ( key ) => { if ( ! this . _geohashInSomeQuery ( this . _locationsTracked [ key ] . geohash ) ) { if ( this . _locationsTracked [ key ] . isInQuery ) { throw new Error ( 'Internal State error, trying to remove location that is still in query' ) ; } delete this . _locationsTracked [ key ] ; } } ) ; this . _geohashCleanupScheduled = false ; if ( this . _cleanUpCurrentGeohashesQueriedTimeout !== null ) { clearTimeout ( this . _cleanUpCurrentGeohashesQueriedTimeout ) ; this . _cleanUpCurrentGeohashesQueriedTimeout = null ; } } param eventType param key param location param distanceFromCenter private _fireCallbacksForKey ( eventType , key , location ? , distanceFromCenter ? ) { this . _callbacks [ eventType ] . forEach ( ( callback ) => { if ( typeof location === 'undefined' || location === null ) { callback ( key , null , null ) ; } else { callback ( key , location , distanceFromCenter ) ; } } ) ; } /**
     * Fires each callback for the 'ready' event.
     */ private _fireReadyEventCallbacks ( ) { this . _callbacks . ready . forEach ( ( callback ) => { callback ( ) ; } ) ; } param geohash returns private _geohashInSomeQuery ( geohash ) { const keys = Object . keys ( this . _currentGeohashesQueried ) ; for ( const queryStr of keys ) { if ( queryStr in this . _currentGeohashesQueried ) { const query = this . _stringToQuery ( queryStr ) ; if ( geohash >= query [ 0 ] && geohash <= query [ 1 ] ) { return true ; } } } return false ; } /**
     * Called once all geohash queries have received all child added events and fires the ready
     * event if necessary.
     */ private _geohashQueryReadyCallback ( queryStr ? ) { const index = this . _outstandingGeohashReadyEvents . indexOf ( queryStr ) ; if ( index > - 1 ) { this . _outstandingGeohashReadyEvents . splice ( index , 1 ) ; } this . _valueEventFired = ( this . _outstandingGeohashReadyEvents . length === 0 ) ; if ( this . _valueEventFired ) { this . _fireReadyEventCallbacks ( ) ; } } /**
     * Attaches listeners to Firebase which track when new geohashes are added within this query's
     * bounding box.
     */ private _listenForNewGeohashes ( ) { let geohashesToQuery = geohashQueryBounds ( this . _center , this . _radius * 1000 ) . map ( this . _queryToString ) ; geohashesToQuery = geohashesToQuery . filter ( ( geohash , i ) => geohashesToQuery . indexOf ( geohash ) === i ) ; const keys = Object . keys ( this . _currentGeohashesQueried ) ; keys . forEach ( ( geohashQueryStr ) => { const index = geohashesToQuery . indexOf ( geohashQueryStr ) ; if ( index === - 1 ) { this . _currentGeohashesQueried [ geohashQueryStr ] . active = false ; } else { this . _currentGeohashesQueried [ geohashQueryStr ] . active = true ; geohashesToQuery . splice ( index , 1 ) ; } } ) ; if ( this . _geohashCleanupScheduled === false && Object . keys ( this . _currentGeohashesQueried ) . length > 25 ) { this . _geohashCleanupScheduled = true ; this . _cleanUpCurrentGeohashesQueriedTimeout = setTimeout ( ( ) => { this . _cleanUpCurrentGeohashesQueried ( ) ; } , 10 ) ; } this . _outstandingGeohashReadyEvents = geohashesToQuery . slice ( ) ; geohashesToQuery . forEach ( ( toQueryStr ) => { const query = this . _stringToQuery ( toQueryStr ) ; const firebaseQuery = this . _firebaseRef . orderByChild ( 'g' ) . startAt ( query [ 0 ] ) . endAt ( query [ 1 ] ) ; const childAddedCallback = firebaseQuery . on ( 'child_added' , ( a ) => this . _childAddedCallback ( a ) ) ; const childRemovedCallback = firebaseQuery . on ( 'child_removed' , ( a ) => this . _childRemovedCallback ( a ) ) ; const childChangedCallback = firebaseQuery . on ( 'child_changed' , ( a ) => this . _childChangedCallback ( a ) ) ; const valueCallback = firebaseQuery . on ( 'value' , ( ) => { firebaseQuery . off ( 'value' , valueCallback ) ; this . _geohashQueryReadyCallback ( toQueryStr ) ; } ) ; this . _currentGeohashesQueried [ toQueryStr ] = { active : true , childAddedCallback , childRemovedCallback , childChangedCallback , valueCallback } ; } ) ; if ( geohashesToQuery . length === 0 ) { this . _geohashQueryReadyCallback ( ) ; } } param query returns private _queryToString ( query ) { if ( query . length !== 2 ) { throw new Error ( 'Not a valid geohash query: ' + query ) ; } return query [ 0 ] + ':' + query [ 1 ] ; } param key param currentLocation private _removeLocation ( key , currentLocation ? ) { const locationDict = this . _locationsTracked [ key ] ; delete this . _locationsTracked [ key ] ; if ( typeof locationDict !== 'undefined' && locationDict . isInQuery ) { const distanceFromCenter = ( currentLocation ) ? distanceBetween ( currentLocation , this . _center ) : null ; this . _fireCallbacksForKey ( 'key_exited' , key , currentLocation , distanceFromCenter ) ; } } param str returns private _stringToQuery ( str ) { const decoded = str . split ( ':' ) ; if ( decoded . length !== 2 ) { throw new Error ( 'Invalid internal state! Not a valid geohash query: ' + str ) ; } return decoded ; } param key param location private _updateLocation ( key , location ? ) { validateLocation ( location ) ; let distanceFromCenter , isInQuery ; const wasInQuery = ( key in this . _locationsTracked ) ? this . _locationsTracked [ key ] . isInQuery : false ; const oldLocation = ( key in this . _locationsTracked ) ? this . _locationsTracked [ key ] . location : null ; distanceFromCenter = distanceBetween ( location , this . _center ) ; isInQuery = ( distanceFromCenter <= this . _radius ) ; this . _locationsTracked [ key ] = { location , distanceFromCenter , isInQuery , geohash : geohashForLocation ( location ) } ; if ( isInQuery && ! wasInQuery ) { this . _fireCallbacksForKey ( 'key_entered' , key , location , distanceFromCenter ) ; } else if ( isInQuery && oldLocation !== null && ( location [ 0 ] !== oldLocation [ 0 ] || location [ 1 ] !== oldLocation [ 1 ] ) ) { this . _fireCallbacksForKey ( 'key_moved' , key , location , distanceFromCenter ) ; } else if ( ! isInQuery && wasInQuery ) { this . _fireCallbacksForKey ( 'key_exited' , key , location , distanceFromCenter ) ; } } }