geohash_bounds

pub package CI

Hand-drawn globe pointing at a geohash

Pure-Dart geohash query bounds for spatial range queries — a port of the geofire-common npm package, plus viewport helpers for map-based apps.

Works with Firestore or any datastore that supports string range comparisons. Zero dependencies, no Flutter required.

Why this package?

Most geo-query packages bundle a full Firestore streaming layer. This package only does the math: it hands you geohash range bounds and lets you build the queries yourself — with your own converters, caching, and pagination.

It also solves two problems the original geofire-common doesn't:

  • Viewport clustering: cellsForViewport enumerates a bounded number of coarse geohash cells covering the visible map, so at low zoom you can run one cheap aggregation count() per cell instead of downloading thousands of documents.
  • Antimeridian handling: viewportToCircle and wrapLongitude deal with date-line-crossing viewports and world-copy longitudes (e.g. lng: 200) that map SDKs like Mapbox and Google Maps produce.

Usage

Store a geohash on each document alongside its coordinates:

import 'package:geohash_bounds/geohash_bounds.dart';

final geohash = GeohashUtil.encode(48.1351, 11.5820); // 'u281zd9z2h'

Radius query (Firestore example)

final bounds = GeohashUtil.queryBounds(
  centerLat: 48.1351,
  centerLng: 11.5820,
  radiusInMeters: 5000,
);

final snapshots = await Future.wait(bounds.map((b) {
  return FirebaseFirestore.instance
      .collection('places')
      .orderBy('geohash')
      .startAt([b[0]])
      .endAt([b[1]])
      .get();
}));

// The bounds cover a bounding box, not an exact circle — filter false
// positives by distance:
final results = snapshots
    .expand((s) => s.docs)
    .where((doc) {
      final d = doc.data();
      return GeohashUtil.distanceBetween(
            48.1351, 11.5820, d['lat'], d['lng'],
          ) <=
          5000;
    })
    .toList();

Query the visible map area

final circle = GeohashUtil.viewportToCircle(
  neLat: bounds.northeast.latitude,
  neLng: bounds.northeast.longitude,
  swLat: bounds.southwest.latitude,
  swLng: bounds.southwest.longitude,
);

final queryBounds = GeohashUtil.queryBounds(
  centerLat: circle.centerLat,
  centerLng: circle.centerLng,
  radiusInMeters: circle.radiusMeters,
);

Cheap clusters at low zoom

When the user zooms out, fetching every document gets expensive. Instead, split the viewport into at most maxCells geohash cells and run one aggregation count per cell:

final cells = GeohashUtil.cellsForViewport(
  neLat: 55, neLng: 15,
  swLat: 45, swLng: 5,
  maxCells: 30,
);

for (final cell in cells) {
  final count = await FirebaseFirestore.instance
      .collection('places')
      .where('geohash', isGreaterThanOrEqualTo: cell.prefix)
      .where('geohash', isLessThanOrEqualTo: '${cell.prefix}~')
      .count()
      .get();
  // Place a cluster marker at (cell.centerLat, cell.centerLng)
  // showing count.count.
}

cellsForViewport picks the finest geohash precision whose grid still fits within maxCells, so the number of queries stays bounded at every zoom level.

API

Method Description
encode(lat, lng, {precision}) Geohash string for a coordinate
queryBounds({centerLat, centerLng, radiusInMeters}) [start, end] hash pairs covering a circle
cellsForViewport({neLat, neLng, swLat, swLng, maxCells, maxPrefixLength}) Coarse cells covering a viewport, with cell centers
viewportToCircle({neLat, neLng, swLat, swLng}) Viewport corners → center + radius, antimeridian-safe
distanceBetween(lat1, lng1, lat2, lng2) Haversine distance in meters
wrapLongitude(lng) Normalize longitude into [-180, 180]

Additional information

Battle-tested in Pin Your Plate, a food-mapping app querying pins worldwide. Issues and contributions welcome on GitHub.

Libraries

geohash_bounds
Pure-Dart geohash query bounds for spatial range queries.