Skip to main content

nodedb_spatial/
geohash.rs

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