Skip to main content

nodedb_spatial/
geohash.rs

1// SPDX-License-Identifier: Apache-2.0
2
3//! Geohash encoding/decoding for spatial indexing.
4//!
5//! Geohash is an interleaved Z-order curve over (longitude, latitude) that
6//! maps 2D coordinates to a 1D string using base-32 encoding. Nearby points
7//! share common prefixes, enabling fast proximity queries via string prefix
8//! matching on standard B-tree indexes.
9//!
10//! Properties:
11//! - Precision 1: ~5,000 km cells
12//! - Precision 6: ~1.2 km × 0.6 km cells (default)
13//! - Precision 8: ~19 m × 19 m cells
14//! - Precision 12: ~3.7 cm × 1.9 cm cells (maximum practical)
15//!
16//! References:
17//! - Gustavo Niemeyer, geohash.org (2008)
18//! - Wikipedia: Geohash
19
20use nodedb_types::BoundingBox;
21
22/// Base-32 alphabet for geohash encoding (lowercase, no a/i/l/o to avoid
23/// ambiguity with digits).
24const BASE32: &[u8; 32] = b"0123456789bcdefghjkmnpqrstuvwxyz";
25
26/// Reverse lookup: ASCII byte → base32 index. Invalid chars map to 255.
27const fn build_decode_table() -> [u8; 128] {
28    let mut table = [255u8; 128];
29    let chars = BASE32;
30    let mut i = 0;
31    while i < 32 {
32        table[chars[i] as usize] = i as u8;
33        i += 1;
34    }
35    table
36}
37
38static DECODE_TABLE: [u8; 128] = build_decode_table();
39
40/// Encode a (longitude, latitude) coordinate to a geohash string.
41///
42/// - `precision`: number of characters (1–12). Default 6 gives ~1.2 km cells.
43/// - Longitude range: [-180, 180]
44/// - Latitude range: [-90, 90]
45///
46/// Returns an empty string if precision is 0.
47pub fn geohash_encode(lng: f64, lat: f64, precision: u8) -> String {
48    let precision = precision.min(12) as usize;
49    if precision == 0 {
50        return String::new();
51    }
52
53    let mut min_lng = -180.0_f64;
54    let mut max_lng = 180.0_f64;
55    let mut min_lat = -90.0_f64;
56    let mut max_lat = 90.0_f64;
57
58    let mut result = String::with_capacity(precision);
59    let mut bits: u8 = 0;
60    let mut bit_count: u8 = 0;
61    let mut is_lng = true; // Longitude first (even bits).
62
63    // Each character encodes 5 bits. Total bits = precision * 5.
64    let total_bits = precision * 5;
65
66    for _ in 0..total_bits {
67        if is_lng {
68            let mid = (min_lng + max_lng) / 2.0;
69            if lng >= mid {
70                bits = (bits << 1) | 1;
71                min_lng = mid;
72            } else {
73                bits <<= 1;
74                max_lng = mid;
75            }
76        } else {
77            let mid = (min_lat + max_lat) / 2.0;
78            if lat >= mid {
79                bits = (bits << 1) | 1;
80                min_lat = mid;
81            } else {
82                bits <<= 1;
83                max_lat = mid;
84            }
85        }
86        is_lng = !is_lng;
87        bit_count += 1;
88
89        if bit_count == 5 {
90            result.push(BASE32[bits as usize] as char);
91            bits = 0;
92            bit_count = 0;
93        }
94    }
95
96    result
97}
98
99/// Decode a geohash string to its bounding box.
100///
101/// Returns `None` if the geohash contains invalid characters or is empty.
102pub fn geohash_decode(hash: &str) -> Option<BoundingBox> {
103    if hash.is_empty() {
104        return None;
105    }
106
107    let mut min_lng = -180.0_f64;
108    let mut max_lng = 180.0_f64;
109    let mut min_lat = -90.0_f64;
110    let mut max_lat = 90.0_f64;
111    let mut is_lng = true;
112
113    for byte in hash.bytes() {
114        if byte >= 128 {
115            return None;
116        }
117        let idx = DECODE_TABLE[byte as usize];
118        if idx == 255 {
119            return None;
120        }
121
122        // Each character encodes 5 bits, MSB first.
123        for bit in (0..5).rev() {
124            let on = (idx >> bit) & 1 == 1;
125            if is_lng {
126                let mid = (min_lng + max_lng) / 2.0;
127                if on {
128                    min_lng = mid;
129                } else {
130                    max_lng = mid;
131                }
132            } else {
133                let mid = (min_lat + max_lat) / 2.0;
134                if on {
135                    min_lat = mid;
136                } else {
137                    max_lat = mid;
138                }
139            }
140            is_lng = !is_lng;
141        }
142    }
143
144    Some(BoundingBox::new(min_lng, min_lat, max_lng, max_lat))
145}
146
147/// Decode a geohash to its center point (longitude, latitude).
148pub fn geohash_decode_center(hash: &str) -> Option<(f64, f64)> {
149    geohash_decode(hash).map(|bb| {
150        (
151            (bb.min_lng + bb.max_lng) / 2.0,
152            (bb.min_lat + bb.max_lat) / 2.0,
153        )
154    })
155}
156
157/// Direction for neighbor computation.
158#[derive(Debug, Clone, Copy, PartialEq, Eq)]
159pub enum Direction {
160    North,
161    South,
162    East,
163    West,
164    NorthEast,
165    NorthWest,
166    SouthEast,
167    SouthWest,
168}
169
170impl Direction {
171    /// All 8 directions.
172    pub const ALL: [Direction; 8] = [
173        Direction::North,
174        Direction::NorthEast,
175        Direction::East,
176        Direction::SouthEast,
177        Direction::South,
178        Direction::SouthWest,
179        Direction::West,
180        Direction::NorthWest,
181    ];
182}
183
184/// Compute the geohash of a neighbor cell in the given direction.
185///
186/// Returns `None` if the geohash is invalid or the neighbor would be
187/// outside valid coordinate bounds (e.g., north of 90°).
188pub fn geohash_neighbor(hash: &str, direction: Direction) -> Option<String> {
189    let bb = geohash_decode(hash)?;
190    let center_lng = (bb.min_lng + bb.max_lng) / 2.0;
191    let center_lat = (bb.min_lat + bb.max_lat) / 2.0;
192    let lng_span = bb.max_lng - bb.min_lng;
193    let lat_span = bb.max_lat - bb.min_lat;
194
195    let (dlng, dlat) = match direction {
196        Direction::North => (0.0, lat_span),
197        Direction::South => (0.0, -lat_span),
198        Direction::East => (lng_span, 0.0),
199        Direction::West => (-lng_span, 0.0),
200        Direction::NorthEast => (lng_span, lat_span),
201        Direction::NorthWest => (-lng_span, lat_span),
202        Direction::SouthEast => (lng_span, -lat_span),
203        Direction::SouthWest => (-lng_span, -lat_span),
204    };
205
206    let new_lng = center_lng + dlng;
207    let new_lat = center_lat + dlat;
208
209    // Clamp latitude. Wrap longitude.
210    if !(-90.0..=90.0).contains(&new_lat) {
211        return None;
212    }
213    let wrapped_lng = if new_lng > 180.0 {
214        new_lng - 360.0
215    } else if new_lng < -180.0 {
216        new_lng + 360.0
217    } else {
218        new_lng
219    };
220
221    Some(geohash_encode(wrapped_lng, new_lat, hash.len() as u8))
222}
223
224/// Compute all 8 neighbor geohashes.
225///
226/// Returns a vec of `(Direction, geohash_string)` pairs. Neighbors that
227/// would fall outside valid bounds (e.g., north of pole) are excluded.
228pub fn geohash_neighbors(hash: &str) -> Vec<(Direction, String)> {
229    Direction::ALL
230        .iter()
231        .filter_map(|&dir| geohash_neighbor(hash, dir).map(|h| (dir, h)))
232        .collect()
233}
234
235/// Compute the set of geohash cells that cover a bounding box at the
236/// given precision. Useful for range queries: "find all geohash prefixes
237/// that overlap this region."
238///
239/// Returns up to `max_cells` geohash strings. If the bbox is too large
240/// for the precision, returns fewer cells (the covering is approximate).
241pub fn geohash_cover(bbox: &BoundingBox, precision: u8, max_cells: usize) -> Vec<String> {
242    if precision == 0 || max_cells == 0 {
243        return Vec::new();
244    }
245
246    let mut cells = Vec::new();
247
248    // Decode a single geohash to find cell size at this precision.
249    let sample = geohash_encode(bbox.min_lng, bbox.min_lat, precision);
250    let sample_bb = match geohash_decode(&sample) {
251        Some(bb) => bb,
252        None => return Vec::new(),
253    };
254    let cell_lng = sample_bb.max_lng - sample_bb.min_lng;
255    let cell_lat = sample_bb.max_lat - sample_bb.min_lat;
256
257    if cell_lng <= 0.0 || cell_lat <= 0.0 {
258        return Vec::new();
259    }
260
261    // Iterate over the bounding box in cell-sized steps.
262    // Handle antimeridian crossing: when min_lng > max_lng, the bbox wraps
263    // around ±180° (e.g., 170°E to 170°W). Split into two ranges.
264    let lng_ranges: Vec<(f64, f64)> = if bbox.min_lng > bbox.max_lng {
265        vec![(bbox.min_lng, 180.0), (-180.0, bbox.max_lng)]
266    } else {
267        vec![(bbox.min_lng, bbox.max_lng)]
268    };
269
270    let mut seen = std::collections::HashSet::with_capacity(max_cells);
271    let mut lat = bbox.min_lat;
272    while lat <= bbox.max_lat && cells.len() < max_cells {
273        for &(lng_start, lng_end) in &lng_ranges {
274            let mut lng = lng_start;
275            while lng <= lng_end && cells.len() < max_cells {
276                let hash = geohash_encode(lng, lat, precision);
277                if seen.insert(hash.clone()) {
278                    cells.push(hash);
279                }
280                lng += cell_lng;
281            }
282        }
283        lat += cell_lat;
284    }
285
286    cells
287}
288
289#[cfg(test)]
290mod tests {
291    use super::*;
292
293    #[test]
294    fn encode_decode_roundtrip() {
295        // NYC: Times Square.
296        let hash = geohash_encode(-73.9857, 40.7580, 9);
297        assert_eq!(hash.len(), 9);
298
299        let bb = geohash_decode(&hash).unwrap();
300        // Center should be very close to original.
301        let center_lng = (bb.min_lng + bb.max_lng) / 2.0;
302        let center_lat = (bb.min_lat + bb.max_lat) / 2.0;
303        assert!((center_lng - (-73.9857)).abs() < 0.001);
304        assert!((center_lat - 40.7580).abs() < 0.001);
305    }
306
307    #[test]
308    fn encode_precision_1() {
309        let hash = geohash_encode(0.0, 0.0, 1);
310        assert_eq!(hash.len(), 1);
311        assert_eq!(hash, "s"); // origin maps to 's' cell
312    }
313
314    #[test]
315    fn encode_precision_6_default() {
316        let hash = geohash_encode(-73.9857, 40.7580, 6);
317        assert_eq!(hash.len(), 6);
318        // Should start with "dr5ru" (NYC area prefix).
319        assert!(hash.starts_with("dr5ru"), "got {hash}");
320    }
321
322    #[test]
323    fn encode_precision_12() {
324        let hash = geohash_encode(139.6917, 35.6895, 12); // Tokyo
325        assert_eq!(hash.len(), 12);
326    }
327
328    #[test]
329    fn decode_invalid_chars() {
330        assert!(geohash_decode("").is_none());
331        assert!(geohash_decode("abc!").is_none()); // '!' invalid
332        assert!(geohash_decode("ailo").is_none()); // 'a', 'i', 'l', 'o' not in base32
333    }
334
335    #[test]
336    fn decode_center() {
337        // Encode a known point, then decode and verify roundtrip.
338        let hash = geohash_encode(13.405, 52.52, 9); // Berlin
339        let (lng, lat) = geohash_decode_center(&hash).unwrap();
340        assert!((lng - 13.405).abs() < 0.001, "lng: {lng}");
341        assert!((lat - 52.52).abs() < 0.001, "lat: {lat}");
342    }
343
344    #[test]
345    fn nearby_points_share_prefix() {
346        // Two nearby points should have a common prefix.
347        let h1 = geohash_encode(-73.985, 40.758, 8);
348        let h2 = geohash_encode(-73.986, 40.759, 8);
349        // At least first 5 chars should match (nearby in NYC).
350        assert_eq!(&h1[..5], &h2[..5], "h1={h1}, h2={h2}");
351    }
352
353    #[test]
354    fn neighbor_north() {
355        let hash = geohash_encode(0.0, 0.0, 6);
356        let north = geohash_neighbor(&hash, Direction::North).unwrap();
357        let north_bb = geohash_decode(&north).unwrap();
358        let orig_bb = geohash_decode(&hash).unwrap();
359        // North neighbor's min_lat should be >= original's max_lat (approximately).
360        assert!(
361            north_bb.min_lat >= orig_bb.max_lat - 0.001,
362            "north: {north_bb:?}, orig: {orig_bb:?}"
363        );
364    }
365
366    #[test]
367    fn neighbor_south() {
368        let hash = geohash_encode(0.0, 0.0, 6);
369        let south = geohash_neighbor(&hash, Direction::South).unwrap();
370        let south_bb = geohash_decode(&south).unwrap();
371        let orig_bb = geohash_decode(&hash).unwrap();
372        assert!(south_bb.max_lat <= orig_bb.min_lat + 0.001);
373    }
374
375    #[test]
376    fn all_8_neighbors() {
377        let hash = geohash_encode(10.0, 50.0, 6);
378        let neighbors = geohash_neighbors(&hash);
379        // Should have 8 neighbors (well away from poles).
380        assert_eq!(neighbors.len(), 8);
381        // All should be different from the original.
382        for (_, n) in &neighbors {
383            assert_ne!(n, &hash);
384        }
385    }
386
387    #[test]
388    fn neighbor_at_pole_excluded() {
389        // Near north pole — north neighbor should be None.
390        let hash = geohash_encode(0.0, 89.99, 4);
391        let north = geohash_neighbor(&hash, Direction::North);
392        // Depending on cell size at precision 4, this may or may not be None.
393        // At precision 4, lat span is ~5.6°, so 89.99 + 5.6 > 90 → None.
394        assert!(north.is_none(), "expected None at pole, got {north:?}");
395    }
396
397    #[test]
398    fn neighbor_wraps_longitude() {
399        // Near date line — east neighbor should wrap.
400        let hash = geohash_encode(179.99, 0.0, 6);
401        let east = geohash_neighbor(&hash, Direction::East).unwrap();
402        let east_bb = geohash_decode(&east).unwrap();
403        // Should be in negative longitude (west side of date line).
404        assert!(
405            east_bb.min_lng < 0.0 || east_bb.max_lng < east_bb.min_lng,
406            "east: {east_bb:?}"
407        );
408    }
409
410    #[test]
411    fn cover_small_bbox() {
412        let bbox = BoundingBox::new(-0.01, -0.01, 0.01, 0.01);
413        let cells = geohash_cover(&bbox, 6, 100);
414        assert!(!cells.is_empty());
415        // All cells should overlap the bbox.
416        for cell in &cells {
417            let cell_bb = geohash_decode(cell).unwrap();
418            assert!(cell_bb.intersects(&bbox));
419        }
420    }
421
422    #[test]
423    fn cover_limits_max_cells() {
424        let bbox = BoundingBox::new(-10.0, -10.0, 10.0, 10.0);
425        let cells = geohash_cover(&bbox, 6, 5);
426        assert!(cells.len() <= 5);
427    }
428
429    #[test]
430    fn encode_extremes() {
431        // Corners of the world.
432        let ne = geohash_encode(180.0, 90.0, 6);
433        let sw = geohash_encode(-180.0, -90.0, 6);
434        assert!(!ne.is_empty());
435        assert!(!sw.is_empty());
436        assert_ne!(ne, sw);
437    }
438
439    #[test]
440    fn base32_all_chars_valid() {
441        // Every character in the base32 alphabet should decode successfully.
442        for &ch in BASE32.iter() {
443            let hash = String::from(ch as char);
444            assert!(
445                geohash_decode(&hash).is_some(),
446                "failed for '{}'",
447                ch as char
448            );
449        }
450    }
451}