Skip to main content

oxigdal_noalloc/
geohash.rs

1//! Geohash encoding and decoding for `no_std`, `no_alloc` environments.
2//!
3//! Geohashes encode a (latitude, longitude) pair as a short string using
4//! base32 encoding over interleaved bit streams.
5
6use crate::BBox2D;
7
8/// The standard geohash base32 alphabet.
9pub const BASE32_CHARS: &[u8; 32] = b"0123456789bcdefghjkmnpqrstuvwxyz";
10
11/// A geohash encoded as a fixed-size byte array.
12///
13/// Supports precision 1–12 (matching the standard range).
14#[derive(Debug, Clone, Copy, PartialEq, Eq)]
15pub struct GeoHashFixed {
16    chars: [u8; 12],
17    len: u8,
18}
19
20impl GeoHashFixed {
21    /// Encodes a (latitude, longitude) pair to the given precision (1–12).
22    ///
23    /// If `precision` is outside 1–12, precision is clamped to the valid range.
24    #[must_use]
25    pub fn encode(lat: f64, lon: f64, precision: u8) -> Self {
26        let precision = precision.clamp(1, 12) as usize;
27
28        let mut chars = [0u8; 12];
29        // Interleaved bit encoding: even bits = lon, odd bits = lat
30        let mut min_lat = -90.0_f64;
31        let mut max_lat = 90.0_f64;
32        let mut min_lon = -180.0_f64;
33        let mut max_lon = 180.0_f64;
34
35        let mut is_lon = true; // start with longitude
36        let mut bits = 0u8; // current 5-bit accumulator
37        let mut bit_count = 0u8;
38        let mut char_idx = 0usize;
39
40        // Total bits needed = precision * 5
41        let total_bits = precision * 5;
42
43        for _ in 0..total_bits {
44            if is_lon {
45                let mid = (min_lon + max_lon) * 0.5;
46                if lon >= mid {
47                    bits = (bits << 1) | 1;
48                    min_lon = mid;
49                } else {
50                    bits <<= 1;
51                    max_lon = mid;
52                }
53            } else {
54                let mid = (min_lat + max_lat) * 0.5;
55                if lat >= mid {
56                    bits = (bits << 1) | 1;
57                    min_lat = mid;
58                } else {
59                    bits <<= 1;
60                    max_lat = mid;
61                }
62            }
63            is_lon = !is_lon;
64            bit_count += 1;
65
66            if bit_count == 5 {
67                chars[char_idx] = BASE32_CHARS[bits as usize];
68                char_idx += 1;
69                bits = 0;
70                bit_count = 0;
71            }
72        }
73
74        Self {
75            chars,
76            len: precision as u8,
77        }
78    }
79
80    /// Returns the encoded bytes (ASCII characters of the geohash).
81    #[must_use]
82    #[inline]
83    pub fn as_bytes(&self) -> &[u8] {
84        &self.chars[..self.len as usize]
85    }
86
87    /// Returns the precision of this geohash (1–12).
88    #[must_use]
89    #[inline]
90    pub fn precision(&self) -> u8 {
91        self.len
92    }
93
94    /// Decodes the geohash to the (latitude, longitude) of the cell center.
95    #[must_use]
96    pub fn decode(&self) -> (f64, f64) {
97        let bbox = self.bbox();
98        let lat = (bbox.min_y + bbox.max_y) * 0.5;
99        let lon = (bbox.min_x + bbox.max_x) * 0.5;
100        (lat, lon)
101    }
102
103    /// Computes the 8 neighbouring geohash cells at the same precision.
104    ///
105    /// Returns neighbours in the order:
106    /// `[N, NE, E, SE, S, SW, W, NW]`.
107    ///
108    /// At the antimeridian (lon ±180) longitude wraps. At the poles (lat ±90)
109    /// latitude is clamped.
110    #[must_use]
111    pub fn neighbours(&self) -> [GeoHashFixed; 8] {
112        let bbox = self.bbox();
113        let center_lat = (bbox.min_y + bbox.max_y) * 0.5;
114        let center_lon = (bbox.min_x + bbox.max_x) * 0.5;
115        let dlat = bbox.max_y - bbox.min_y;
116        let dlon = bbox.max_x - bbox.min_x;
117        let prec = self.len;
118
119        // Direction offsets: (dlat_mult, dlon_mult)
120        // N, NE, E, SE, S, SW, W, NW
121        let offsets: [(f64, f64); 8] = [
122            (1.0, 0.0),   // N
123            (1.0, 1.0),   // NE
124            (0.0, 1.0),   // E
125            (-1.0, 1.0),  // SE
126            (-1.0, 0.0),  // S
127            (-1.0, -1.0), // SW
128            (0.0, -1.0),  // W
129            (1.0, -1.0),  // NW
130        ];
131
132        let mut result = [*self; 8];
133        let mut i = 0;
134        while i < 8 {
135            let (dy, dx) = offsets[i];
136            let mut nlat = center_lat + dy * dlat;
137            let mut nlon = center_lon + dx * dlon;
138
139            // Clamp latitude
140            nlat = nlat.clamp(-90.0, 90.0);
141            // Wrap longitude
142            if nlon > 180.0 {
143                nlon -= 360.0;
144            }
145            if nlon < -180.0 {
146                nlon += 360.0;
147            }
148
149            result[i] = GeoHashFixed::encode(nlat, nlon, prec);
150            i += 1;
151        }
152        result
153    }
154
155    /// Returns the bounding box of the geohash cell.
156    ///
157    /// The box is `BBox2D { min_x: min_lon, min_y: min_lat, max_x: max_lon, max_y: max_lat }`.
158    #[must_use]
159    pub fn bbox(&self) -> BBox2D {
160        let mut min_lat = -90.0_f64;
161        let mut max_lat = 90.0_f64;
162        let mut min_lon = -180.0_f64;
163        let mut max_lon = 180.0_f64;
164
165        let bytes = self.as_bytes();
166        let mut is_lon = true;
167
168        for &byte in bytes {
169            // Find the index of this character in BASE32_CHARS
170            let idx = base32_decode_char(byte);
171            // Decode 5 bits, MSB first
172            for bit_pos in (0..5).rev() {
173                let bit = (idx >> bit_pos) & 1;
174                if is_lon {
175                    let mid = (min_lon + max_lon) * 0.5;
176                    if bit == 1 {
177                        min_lon = mid;
178                    } else {
179                        max_lon = mid;
180                    }
181                } else {
182                    let mid = (min_lat + max_lat) * 0.5;
183                    if bit == 1 {
184                        min_lat = mid;
185                    } else {
186                        max_lat = mid;
187                    }
188                }
189                is_lon = !is_lon;
190            }
191        }
192
193        BBox2D::new(min_lon, min_lat, max_lon, max_lat)
194    }
195}
196
197/// Decodes a single base32 character to its 5-bit value.
198/// Returns 0 for unrecognised characters.
199#[inline]
200fn base32_decode_char(c: u8) -> u8 {
201    for (i, &ch) in BASE32_CHARS.iter().enumerate() {
202        if ch == c {
203            return i as u8;
204        }
205    }
206    0
207}
208
209#[cfg(test)]
210mod tests {
211    use super::*;
212
213    #[test]
214    fn test_neighbours_count() {
215        let gh = GeoHashFixed::encode(48.858, 2.294, 6); // Paris
216        let nbrs = gh.neighbours();
217        assert_eq!(nbrs.len(), 8);
218    }
219
220    #[test]
221    fn test_neighbours_different_from_center() {
222        let gh = GeoHashFixed::encode(48.858, 2.294, 6);
223        let nbrs = gh.neighbours();
224        for nbr in &nbrs {
225            assert_ne!(
226                nbr.as_bytes(),
227                gh.as_bytes(),
228                "neighbour should differ from center"
229            );
230        }
231    }
232
233    #[test]
234    fn test_neighbours_same_precision() {
235        let gh = GeoHashFixed::encode(35.68, 139.69, 5);
236        let nbrs = gh.neighbours();
237        for nbr in &nbrs {
238            assert_eq!(nbr.precision(), gh.precision());
239        }
240    }
241
242    #[test]
243    fn test_neighbours_adjacency() {
244        // Encode a geohash, get its bbox, get north neighbour, check that
245        // north neighbour's bbox is above (higher min_y)
246        let gh = GeoHashFixed::encode(40.0, -74.0, 5);
247        let gh_bbox = gh.bbox();
248        let nbrs = gh.neighbours();
249        let north = &nbrs[0];
250        let n_bbox = north.bbox();
251        // North neighbour's min_y should be approximately equal to center's max_y
252        let diff = (n_bbox.min_y - gh_bbox.max_y).abs();
253        assert!(
254            diff < 0.01,
255            "North neighbour should be adjacent: diff={diff}"
256        );
257    }
258
259    #[test]
260    fn test_neighbours_south_adjacency() {
261        let gh = GeoHashFixed::encode(40.0, -74.0, 5);
262        let gh_bbox = gh.bbox();
263        let nbrs = gh.neighbours();
264        let south = &nbrs[4];
265        let s_bbox = south.bbox();
266        let diff = (s_bbox.max_y - gh_bbox.min_y).abs();
267        assert!(
268            diff < 0.01,
269            "South neighbour should be adjacent: diff={diff}"
270        );
271    }
272
273    #[test]
274    fn test_neighbours_east_adjacency() {
275        let gh = GeoHashFixed::encode(40.0, -74.0, 5);
276        let gh_bbox = gh.bbox();
277        let nbrs = gh.neighbours();
278        let east = &nbrs[2];
279        let e_bbox = east.bbox();
280        let diff = (e_bbox.min_x - gh_bbox.max_x).abs();
281        assert!(
282            diff < 0.01,
283            "East neighbour should be adjacent: diff={diff}"
284        );
285    }
286
287    #[test]
288    fn test_neighbours_wrap_antimeridian() {
289        // Cell near +180 longitude
290        let gh = GeoHashFixed::encode(0.0, 179.99, 3);
291        let nbrs = gh.neighbours();
292        // East neighbour should wrap around
293        let east = &nbrs[2];
294        let (_, east_lon) = east.decode();
295        // Should be near -180
296        assert!(
297            !(0.0..=170.0).contains(&east_lon),
298            "East of lon~180 should wrap or stay high, got {east_lon}"
299        );
300    }
301
302    #[test]
303    fn test_neighbours_pole_clamp() {
304        // Cell near north pole
305        let gh = GeoHashFixed::encode(89.9, 0.0, 3);
306        let nbrs = gh.neighbours();
307        let north = &nbrs[0];
308        let (n_lat, _) = north.decode();
309        assert!(n_lat <= 90.0, "North of pole should be clamped to 90");
310    }
311
312    #[test]
313    fn test_neighbours_all_unique() {
314        let gh = GeoHashFixed::encode(51.5, -0.1, 6);
315        let nbrs = gh.neighbours();
316        // Check all 8 are distinct
317        let mut i = 0;
318        while i < 8 {
319            let mut j = i + 1;
320            while j < 8 {
321                assert_ne!(
322                    nbrs[i].as_bytes(),
323                    nbrs[j].as_bytes(),
324                    "Neighbours {i} and {j} should be distinct"
325                );
326                j += 1;
327            }
328            i += 1;
329        }
330    }
331}