flutter_geo_hash
Geohashes for Dart and Flutter. Encode and decode locations, find neighboring cells, and query the locations within a radius in Cloud Firestore or any other database that supports string range queries.
The encoding and query algorithms follow Firebase's geofire-common, the library used in Firebase's geoqueries guide. The geohashes are identical to the ones geofire-common computes, so a Flutter app and a web app can store and query the same documents.
Table of contents
- Features
- Installation
- Usage
- Querying Cloud Firestore by distance
- Choosing a precision
- How geohashes work
- API overview
- Error handling
- Migrating from earlier versions
- Limitations
- Contributing
- License
- Author
Features
- Encode a location into a geohash of 1 to 22 characters.
- Query by radius: compute the geohash ranges that cover a circle, then discard the false positives by distance.
- Decode a geohash into the center or the bounds of its cell.
- Find neighbors: get the eight cells around a geohash.
- Validate geohashes, locations and GeoFire keys.
- No name clashes: the class names don't collide with the ones of
cloud_firestore, popular map and location packages, or other geohash packages. - Pure Dart: runs in Flutter apps, Dart servers and command-line tools, on every platform including the web.
Installation
In a Flutter project:
flutter pub add flutter_geo_hash
In a Dart project:
dart pub add flutter_geo_hash
Usage
import 'package:flutter_geo_hash/flutter_geo_hash.dart';
void main() {
final geoHash = GeoHashUtils();
const london = GeoHashPoint(51.5074, -0.1278);
const paris = GeoHashPoint(48.8566, 2.3522);
// Encode a location. Longer geohashes are more precise.
geoHash.geoHashForLocation(london); // 'gcpvj0duq5'
geoHash.geoHashForLocation(london, precision: 5); // 'gcpvj'
// Decode a geohash into the center or the bounds of its cell.
geoHash.decodeGeoHash('gcpvj'); // GeoHashPoint(51.52587890625, -0.10986328125)
geoHash.decodeGeoHashBounds('gcpvj').southWest; // GeoHashPoint(51.50390625, -0.1318359375)
// Get the cells around a geohash.
geoHash.geoHashNeighbors('gcpvj')[GeoHashDirection.north]; // 'gcpvm'
// Measure distances.
geoHash.distanceBetween(london, paris); // 343.556... (kilometers)
geoHash.isWithinRadius(london, paris, 350000); // true (meters)
// Validate input.
geoHash.isValidGeoHash('gcpvj'); // true
geoHash.isValidGeoHash('GCPVJ'); // false, geohashes are lowercase
}
A complete, runnable example is available in example/example.dart.
Querying Cloud Firestore by distance
Firestore can't filter documents by distance, but it can query a range of strings. Storing a geohash with each document lets you find the documents around a location with a few range queries.
Store a geohash with each document
import 'package:cloud_firestore/cloud_firestore.dart';
import 'package:flutter_geo_hash/flutter_geo_hash.dart';
final geoHash = GeoHashUtils();
Future<void> saveCity(String id, double lat, double lng) {
return FirebaseFirestore.instance.collection('cities').doc(id).set({
// Used to query by location.
'geohash': geoHash.geoHashForLocation(GeoHashPoint(lat, lng)),
// Used to discard false positives.
'lat': lat,
'lng': lng,
}, SetOptions(merge: true));
}
Query the documents within a radius
Future<List<DocumentSnapshot<Map<String, dynamic>>>> citiesWithin(
GeoHashPoint center,
double radiusInM,
) async {
// Each range needs its own query; there are at most nine.
final bounds = geoHash.geohashQueryBounds(center, radiusInM);
final snapshots = await Future.wait([
for (final range in bounds)
FirebaseFirestore.instance
.collection('cities')
.orderBy('geohash')
.startAt([range[0]])
.endAt([range[1]])
.get(),
]);
// The ranges cover rectangles, so some matches are outside the circle.
return [
for (final snapshot in snapshots)
for (final doc in snapshot.docs)
if (geoHash.isWithinRadius(
center,
GeoHashPoint(
(doc['lat'] as num).toDouble(),
(doc['lng'] as num).toDouble(),
),
radiusInM,
))
doc,
];
}
geohashQueryBounds and isWithinRadius take the radius in meters, while
distanceBetween returns kilometers.
Documents that store a Firestore GeoPoint
To use a Firestore GeoPoint field, convert it to a GeoHashPoint:
import 'package:cloud_firestore/cloud_firestore.dart';
import 'package:flutter_geo_hash/flutter_geo_hash.dart' hide GeoPoint;
GeoHashPoint toGeoHashPoint(GeoPoint location) =>
GeoHashPoint(location.latitude, location.longitude);
Hiding GeoPoint from this package avoids a clash with the deprecated alias of
the same name. It won't be needed once the alias is removed.
Choosing a precision
geoHashForLocation returns 10-character geohashes by default. Store geohashes
with that precision: a query only finds documents whose geohash is at least as
long as its ranges, which are at most 10 characters long for any radius of
1 meter or more. Shorter geohashes are useful to group nearby locations, for
example with geoHashNeighbors.
| Length | Approximate cell size at the equator (width × height) |
|---|---|
| 1 | 5,009 km × 5,009 km |
| 2 | 1,252 km × 626 km |
| 3 | 157 km × 157 km |
| 4 | 39.1 km × 19.6 km |
| 5 | 4.89 km × 4.89 km |
| 6 | 1.22 km × 611 m |
| 7 | 153 m × 153 m |
| 8 | 38.2 m × 19.1 m |
| 9 | 4.78 m × 4.78 m |
| 10 | 1.19 m × 597 mm |
| 11 | 149 mm × 149 mm |
| 12 | 37.3 mm × 18.7 mm |
Cells get narrower towards the poles.
How geohashes work
A geohash encodes a location as a short string. Each character splits the cell of the previous characters into 32 smaller cells, so the geohash of every location in a cell starts with the geohash of that cell, and locations that share a long prefix are close to each other.
The converse is not true: two locations on either side of a cell border can be
very close to each other and still have very different geohashes. That's why
geohashQueryBounds returns several ranges.
API overview
All the members below belong to GeoHashUtils, which has no state. Locations
are GeoHashPoints, decoded cells are GeoHashBounds, and neighbors are keyed
by GeoHashDirection.
| Member | Description |
|---|---|
geoHashForLocation(location, {precision}) |
Returns the geohash of a location. |
geohashQueryBounds(center, radius) |
Returns the geohash ranges covering a circle. |
isWithinRadius(center, location, radiusInM) |
Whether a location is within a radius. |
distanceBetween(location1, location2) |
Returns the distance between two locations, in kilometers. |
decodeGeoHash(geoHash) |
Returns the center of a geohash cell. |
decodeGeoHashBounds(geoHash) |
Returns the bounds of a geohash cell. |
geoHashNeighbors(geoHash) |
Returns the cells around a geohash, by direction. |
isValidGeoHash(geoHash) |
Whether a string is a valid geohash. |
validateLocation(location), validateKey(key) |
Throw if a location or a GeoFire key is invalid. |
Lower-level helpers, such as geohashQuery, boundingBoxCoordinates and
wrapLongitude, are described in the
API reference.
Error handling
| Invalid input | Members | Throws |
|---|---|---|
| Location | geoHashForLocation, geohashQueryBounds, distanceBetween, isWithinRadius, validateLocation |
String |
| Precision outside 1 to 22 | geoHashForLocation |
String |
| GeoFire key | validateKey |
String |
| Geohash | geohashQuery |
String |
| Negative, NaN or infinite radius | geohashQueryBounds, isWithinRadius |
ArgumentError |
| Geohash | decodeGeoHash, decodeGeoHashBounds, geoHashNeighbors |
FormatException |
Errors that earlier versions threw as a String are still thrown as a
String, so existing catch clauses keep working. In debug mode, the
GeoHashPoint constructor also asserts that its coordinates are within range.
Migrating from earlier versions
Version 0.0.7 is a drop-in upgrade: code written for earlier versions keeps compiling, geohashes are unchanged, and query ranges only change where earlier versions missed results. See the changelog for details.
Two classes have new names, which don't clash with other packages:
| Before | After |
|---|---|
MyGeoHash |
GeoHashUtils |
GeoPoint |
GeoHashPoint |
The old names still work, but they are deprecated and will be removed in a future breaking release. To update your code automatically, run:
dart fix --apply
Limitations
- False positives. The ranges cover rectangles, so queries also return some
documents outside the circle. Discard them with
isWithinRadius. With Firestore, they still count as document reads. - Poles and large radii. Near the poles and for radii of thousands of kilometers, the ranges become coarse, down to a whole hemisphere, so queries read more documents.
- Approximate distances.
distanceBetweenuses the haversine formula on a spherical earth, which can differ from the actual distance by about 0.5%.
Contributing
Issues and pull requests are welcome on GitHub. Before opening a pull request, please run:
dart pub get
dart format .
dart analyze
dart test
License
Author
Created and maintained by Miracle Okolo. Connect on LinkedIn.
Libraries
- flutter_geo_hash
- Geohash encoding and decoding, geoquery bounds and distance helpers.