Consultas geográficas

Muchas aplicaciones tienen documentos indexados por ubicaciones físicas. Por ejemplo, su aplicación podría permitir a los usuarios buscar tiendas cercanas a su ubicación actual.

Cloud Firestore solo permite una única cláusula de rango por consulta compuesta , lo que significa que no podemos realizar consultas geográficas simplemente almacenando la latitud y la longitud como campos separados y consultando un cuadro delimitador.

Solución: Geohashes

Geohash es un sistema para codificar un par (latitude, longitude) en una única cadena Base32. En el sistema Geohash, el mundo está dividido en una cuadrícula rectangular. Cada carácter de una cadena Geohash especifica una de las 32 subdivisiones del prefijo hash. Por ejemplo, Geohash abcd es uno de los 32 hashes de cuatro caracteres contenidos completamente en el Geohash abc más grande.

Cuanto más largo sea el prefijo compartido entre dos hashes, más cerca estarán entre sí. Por ejemplo abcdef está más cerca de abcdeg que abcdff . Sin embargo, ¡lo contrario no es cierto! Dos áreas pueden estar muy cerca una de la otra y tener Geohashes muy diferentes:

Geohashes muy separados

Podemos usar Geohashes para almacenar y consultar documentos por posición en Cloud Firestore con una eficiencia razonable y solo requerimos un único campo indexado.

Instalar biblioteca auxiliar

Crear y analizar Geohashes implica algunos cálculos complicados, por lo que creamos bibliotecas auxiliares para abstraer las partes más difíciles en Android, Apple y Web:

// Install from NPM. If you prefer to use a static .js file visit
// https://github.com/firebase/geofire-js/releases and download
// geofire-common.min.js from the latest version
npm install
--save geofire-common
// Install from NPM. If you prefer to use a static .js file visit
// https://github.com/firebase/geofire-js/releases and download
// geofire-common.min.js from the latest version
npm install
--save geofire-common

Nota: Este producto no está disponible en destinos watchOS y App Clip.
// Agrega esto a tu Podfile pod 'GeoFire/Utils'

// Add this to your app/build.gradle
implementation
'com.firebase:geofire-android-common:3.2.0'
// Add this to your app/build.gradle
implementation
'com.firebase:geofire-android-common:3.1.0'

Almacenar geohashes

Para cada documento que desee indexar por ubicación, deberá almacenar un campo Geohash:

import { doc, updateDoc } from 'firebase/firestore';

// Compute the GeoHash for a lat/lng point
const lat = 51.5074;
const lng = 0.1278;
const hash = geofire.geohashForLocation([lat, lng]);

// Add the hash and the lat/lng to the document. We will use the hash
// for queries and the lat/lng for distance comparisons.
const londonRef = doc(db, 'cities', 'LON');
await updateDoc
(londonRef, {
  geohash
: hash,
  lat
: lat,
  lng
: lng
});
// Compute the GeoHash for a lat/lng point
const lat = 51.5074;
const lng = 0.1278;
const hash = geofire.geohashForLocation([lat, lng]);

// Add the hash and the lat/lng to the document. We will use the hash
// for queries and the lat/lng for distance comparisons.
const londonRef = db.collection('cities').doc('LON');
londonRef
.update({
  geohash
: hash,
  lat
: lat,
  lng
: lng
}).then(() => {
 
// ...
});

Nota: Este producto no está disponible en destinos watchOS y App Clip.
// Compute the GeoHash for a lat/lng point
let latitude = 51.5074
let longitude = 0.12780
let location = CLLocationCoordinate2D(latitude: latitude, longitude: longitude)

let hash = GFUtils.geoHash(forLocation: location)

// Add the hash and the lat/lng to the document. We will use the hash
// for queries and the lat/lng for distance comparisons.
let documentData: [String: Any] = [
 
"geohash": hash,
 
"lat": latitude,
 
"lng": longitude
]

let londonRef = db.collection("cities").document("LON")
londonRef
.updateData(documentData) { error in
 
// ...
}

// Compute the GeoHash for a lat/lng point
val lat = 51.5074
val lng = 0.1278
val hash = GeoFireUtils.getGeoHashForLocation(GeoLocation(lat, lng))

// Add the hash and the lat/lng to the document. We will use the hash
// for queries and the lat/lng for distance comparisons.
val updates: MutableMap<String, Any> = mutableMapOf(
   
"geohash" to hash,
   
"lat" to lat,
   
"lng" to lng,
)
val londonRef = db.collection("cities").document("LON")
londonRef
.update(updates)
   
.addOnCompleteListener {
       
// ...
   
}
// Compute the GeoHash for a lat/lng point
double lat = 51.5074;
double lng = 0.1278;
String hash = GeoFireUtils.getGeoHashForLocation(new GeoLocation(lat, lng));

// Add the hash and the lat/lng to the document. We will use the hash
// for queries and the lat/lng for distance comparisons.
Map<String, Object> updates = new HashMap<>();
updates
.put("geohash", hash);
updates
.put("lat", lat);
updates
.put("lng", lng);

DocumentReference londonRef = db.collection("cities").document("LON");
londonRef
.update(updates)
       
.addOnCompleteListener(new OnCompleteListener<Void>() {
           
@Override
           
public void onComplete(@NonNull Task<Void> task) {
               
// ...
           
}
       
});

Consultar geohashes

Los Geohashes nos permiten aproximarnos a las consultas de área uniendo un conjunto de consultas en el campo Geohash y luego filtrando algunos falsos positivos:

import { collection, query, orderBy, startAt, endAt, getDocs } from 'firebase/firestore';

// Find cities within 50km of London
const center = [51.5074, 0.1278];
const radiusInM = 50 * 1000;

// Each item in 'bounds' represents a startAt/endAt pair. We have to issue
// a separate query for each pair. There can be up to 9 pairs of bounds
// depending on overlap, but in most cases there are 4.
const bounds = geofire.geohashQueryBounds(center, radiusInM);
const promises = [];
for (const b of bounds) {
 
const q = query(
    collection
(db, 'cities'),
    orderBy
('geohash'),
    startAt
(b[0]),
    endAt
(b[1]));

  promises
.push(getDocs(q));
}

// Collect all the query results together into a single list
const snapshots = await Promise.all(promises);

const matchingDocs = [];
for (const snap of snapshots) {
 
for (const doc of snap.docs) {
   
const lat = doc.get('lat');
   
const lng = doc.get('lng');

   
// We have to filter out a few false positives due to GeoHash
   
// accuracy, but most will match
   
const distanceInKm = geofire.distanceBetween([lat, lng], center);
   
const distanceInM = distanceInKm * 1000;
   
if (distanceInM <= radiusInM) {
      matchingDocs
.push(doc);
   
}
 
}
}
// Find cities within 50km of London
const center = [51.5074, 0.1278];
const radiusInM = 50 * 1000;

// Each item in 'bounds' represents a startAt/endAt pair. We have to issue
// a separate query for each pair. There can be up to 9 pairs of bounds
// depending on overlap, but in most cases there are 4.
const bounds = geofire.geohashQueryBounds(center, radiusInM);
const promises = [];
for (const b of bounds) {
 
const q = db.collection('cities')
   
.orderBy('geohash')
   
.startAt(b[0])
   
.endAt(b[1]);

  promises
.push(q.get());
}

// Collect all the query results together into a single list
Promise.all(promises).then((snapshots) => {
 
const matchingDocs = [];

 
for (const snap of snapshots) {
   
for (const doc of snap.docs) {
     
const lat = doc.get('lat');
     
const lng = doc.get('lng');

     
// We have to filter out a few false positives due to GeoHash
     
// accuracy, but most will match
     
const distanceInKm = geofire.distanceBetween([lat, lng], center);
     
const distanceInM = distanceInKm * 1000;
     
if (distanceInM <= radiusInM) {
        matchingDocs
.push(doc);
     
}
   
}
 
}

 
return matchingDocs;
}).then((matchingDocs) => {
 
// Process the matching documents
 
// ...
});

Nota: Este producto no está disponible en destinos watchOS y App Clip.
// Find cities within 50km of London
let center = CLLocationCoordinate2D(latitude: 51.5074, longitude: 0.1278)
let radiusInM: Double = 50 * 1000

// Each item in 'bounds' represents a startAt/endAt pair. We have to issue
// a separate query for each pair. There can be up to 9 pairs of bounds
// depending on overlap, but in most cases there are 4.
let queryBounds = GFUtils.queryBounds(forLocation: center,
                                      withRadius
: radiusInM)
let queries = queryBounds.map { bound -> Query in
 
return db.collection("cities")
   
.order(by: "geohash")
   
.start(at: [bound.startValue])
   
.end(at: [bound.endValue])
}

@Sendable func fetchMatchingDocs(from query: Query,
                       center
: CLLocationCoordinate2D,
                       radiusInMeters
: Double) async throws -> [QueryDocumentSnapshot] {
 
let snapshot = try await query.getDocuments()
 
// Collect all the query results together into a single list
  return snapshot.documents.filter { document in
   
let lat = document.data()["lat"] as? Double ?? 0
   
let lng = document.data()["lng"] as? Double ?? 0
   
let coordinates = CLLocation(latitude: lat, longitude: lng)
   
let centerPoint = CLLocation(latitude: center.latitude, longitude: center.longitude)

   
// We have to filter out a few false positives due to GeoHash accuracy, but
    // most will match
    let distance = GFUtils.distance(from: centerPoint, to: coordinates)
   
return distance <= radiusInM
 
}
}

// After all callbacks have executed, matchingDocs contains the result. Note that this code
// executes all queries serially, which may not be optimal for performance.
do {
 
let matchingDocs = try await withThrowingTaskGroup(of: [QueryDocumentSnapshot].self) { group -> [QueryDocumentSnapshot] in
   
for query in queries {
      group
.addTask {
        try await fetchMatchingDocs
(from: query, center: center, radiusInMeters: radiusInM)
     
}
   
}
   
var matchingDocs = [QueryDocumentSnapshot]()
   
for try await documents in group {
      matchingDocs
.append(contentsOf: documents)
   
}
   
return matchingDocs
 
}

  print
("Docs matching geoquery: \(matchingDocs)")
} catch {
  print
("Unable to fetch snapshot data. \(error)")
}

// Find cities within 50km of London
val center = GeoLocation(51.5074, 0.1278)
val radiusInM = 50.0 * 1000.0

// Each item in 'bounds' represents a startAt/endAt pair. We have to issue
// a separate query for each pair. There can be up to 9 pairs of bounds
// depending on overlap, but in most cases there are 4.
val bounds = GeoFireUtils.getGeoHashQueryBounds(center, radiusInM)
val tasks: MutableList<Task<QuerySnapshot>> = ArrayList()
for (b in bounds) {
   
val q = db.collection("cities")
       
.orderBy("geohash")
       
.startAt(b.startHash)
       
.endAt(b.endHash)
    tasks
.add(q.get())
}

// Collect all the query results together into a single list
Tasks.whenAllComplete(tasks)
   
.addOnCompleteListener {
       
val matchingDocs: MutableList<DocumentSnapshot> = ArrayList()
       
for (task in tasks) {
           
val snap = task.result
           
for (doc in snap!!.documents) {
               
val lat = doc.getDouble("lat")!!
               
val lng = doc.getDouble("lng")!!

               
// We have to filter out a few false positives due to GeoHash
               
// accuracy, but most will match
               
val docLocation = GeoLocation(lat, lng)
               
val distanceInM = GeoFireUtils.getDistanceBetween(docLocation, center)
               
if (distanceInM <= radiusInM) {
                    matchingDocs
.add(doc)
               
}
           
}
       
}

       
// matchingDocs contains the results
       
// ...
   
}
// Find cities within 50km of London
final GeoLocation center = new GeoLocation(51.5074, 0.1278);
final double radiusInM = 50 * 1000;

// Each item in 'bounds' represents a startAt/endAt pair. We have to issue
// a separate query for each pair. There can be up to 9 pairs of bounds
// depending on overlap, but in most cases there are 4.
List<GeoQueryBounds> bounds = GeoFireUtils.getGeoHashQueryBounds(center, radiusInM);
final List<Task<QuerySnapshot>> tasks = new ArrayList<>();
for (GeoQueryBounds b : bounds) {
   
Query q = db.collection("cities")
           
.orderBy("geohash")
           
.startAt(b.startHash)
           
.endAt(b.endHash);

    tasks
.add(q.get());
}

// Collect all the query results together into a single list
Tasks.whenAllComplete(tasks)
       
.addOnCompleteListener(new OnCompleteListener<List<Task<?>>>() {
           
@Override
           
public void onComplete(@NonNull Task<List<Task<?>>> t) {
               
List<DocumentSnapshot> matchingDocs = new ArrayList<>();

               
for (Task<QuerySnapshot> task : tasks) {
                   
QuerySnapshot snap = task.getResult();
                   
for (DocumentSnapshot doc : snap.getDocuments()) {
                       
double lat = doc.getDouble("lat");
                       
double lng = doc.getDouble("lng");

                       
// We have to filter out a few false positives due to GeoHash
                       
// accuracy, but most will match
                       
GeoLocation docLocation = new GeoLocation(lat, lng);
                       
double distanceInM = GeoFireUtils.getDistanceBetween(docLocation, center);
                       
if (distanceInM <= radiusInM) {
                            matchingDocs
.add(doc);
                       
}
                   
}
               
}

               
// matchingDocs contains the results
               
// ...
           
}
       
});

Limitaciones

El uso de Geohashes para consultar ubicaciones nos brinda nuevas capacidades, pero tiene sus propias limitaciones:

  • Falsos positivos : la consulta mediante Geohash no es exacta y debe filtrar los resultados falsos positivos en el lado del cliente. Estas lecturas adicionales agregan costo y latencia a su aplicación.
  • Casos extremos : este método de consulta se basa en estimar la distancia entre líneas de longitud/latitud. La precisión de esta estimación disminuye a medida que los puntos se acercan al Polo Norte o Sur, lo que significa que las consultas de Geohash tienen más falsos positivos en latitudes extremas.