שאילתות גיאוגרפיות

לאפליקציות רבות יש מסמכים שנוספו לאינדקס לפי מיקומים פיזיים. לדוגמה, האפליקציה שלכם עשויה לאפשר למשתמשים לדפדף בחנויות בקרבת המיקום הנוכחי שלהם.

הפתרון: Geohashes

Geohash היא מערכת לקידוד של זוג (latitude, longitude) למחרוזת Base32 אחת. במערכת Geohash, העולם מחולק לרשת מלבנית. כל תו במחרוזת Geohash מציין אחת מ-32 חלוקות משנה של גיבוב הקידומת. לדוגמה, Geohash‏ abcd הוא אחד מ-32 גרסאות גיבוב של ארבעה תווים שמכילות את Geohash הגדול יותר abc.

ככל שהקידומת המשותפת בין שתי גרסאות של גיבוב ארוכה יותר, כך הן קרובות יותר זו לזו. לדוגמה, הערך abcdef קרוב יותר אל abcdeg מאשר אל abcdff. עם זאת, ההיפך לא נכון! יכול להיות ששני אזורים קרובים מאוד זה לזה, אבל יש להם מחרוזות Geohash שונות מאוד:

גיאוהאשים שנמצאים במרחק רב זה מזה

אנחנו יכולים להשתמש בגיבובים (Geohashes) כדי לאחסן מסמכים ולשלוח שאילתות לגביהם לפי מיקום ב-Cloud Firestore, ביעילות סבירה, תוך שימוש בשדה אחד בלבד באינדקס.

התקנת ספריית העזר

יצירת Geohashes ופענוח שלהם כוללים קצת מתמטיקה מסובכת, לכן יצרנו ספריות עזר כדי ליצור אבסטרקציה של החלקים הקשים ביותר ב-Android, ב-Apple ובאינטרנט:

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

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

Swift

הערה: המוצר הזה לא זמין ביעדים מסוגwatchOS ו-קליפ של אפליקציה.
// מוסיפים את הקטע הזה ל-Podfile ‎pod 'GeoFire/Utils'‎

Kotlin+KTX

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

Java

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

גיבובים (Geohash) של חנויות

לכל מסמך שרוצים להוסיף לאינדקס לפי מיקום, צריך לאחסן שדה Geohash:

Web

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
});

Web

// 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(() => {
  // ...
});

Swift

הערה: המוצר הזה לא זמין ליעדים של watchOS ו-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
  // ...
}

Kotlin+KTX

// 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 {
        // ...
    }

Java

// 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) {
                // ...
            }
        });

שאילתות של גיאוהאשים

באמצעות גיאוהאשים אנחנו יכולים לבצע שאילתות משוערות לפי אזור על ידי צירוף של קבוצת שאילתות בשדה Geohash, ולאחר מכן סינון של חלק מהתוצאות החיוביות השגויות:

Web

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);
    }
  }
}

Web

// 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
  // ...
});

Swift

הערה: המוצר הזה לא זמין ליעדים של watchOS ו-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)")
}

Kotlin+KTX

// 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
        // ...
    }

Java

// 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
                // ...
            }
        });

מגבלות

השימוש ב-Geohash לשליחת שאילתות לגבי מיקומים מעניק לנו יכולות חדשות, אבל יש לו גם מגבלות משלו:

  • תוצאות חיוביות מוטעות – השאילתה לפי Geohash לא מדויקת, וצריך לסנן תוצאות חיוביות מוטעות בצד הלקוח. הקריאות הנוספות האלה מוסיפות עלות וזמן אחזור לאפליקציה.
  • מקרים קיצוניים – שיטת השאילתה הזו מבוססת על אומדן המרחק בין קוי האורך/הרוחב. הדיוק של ההערכה הזו יורד ככל שהנקודות מתקרבות לקוטב הצפוני או לקוטב הדרומי. כלומר, כשמשתמשים בשאילתות Geohash, יש יותר תוצאות חיוביות שגויות בקווי רוחב קיצוניים.