1use nodedb_types::BoundingBox;
21
22const BASE32: &[u8; 32] = b"0123456789bcdefghjkmnpqrstuvwxyz";
25
26const 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
40pub 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; 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
99pub 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 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
147pub 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#[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 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
184pub 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 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
224pub 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
235pub 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 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 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 let hash = geohash_encode(-73.9857, 40.7580, 9);
297 assert_eq!(hash.len(), 9);
298
299 let bb = geohash_decode(&hash).unwrap();
300 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"); }
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 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); 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()); assert!(geohash_decode("ailo").is_none()); }
334
335 #[test]
336 fn decode_center() {
337 let hash = geohash_encode(13.405, 52.52, 9); 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 let h1 = geohash_encode(-73.985, 40.758, 8);
348 let h2 = geohash_encode(-73.986, 40.759, 8);
349 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 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 assert_eq!(neighbors.len(), 8);
381 for (_, n) in &neighbors {
383 assert_ne!(n, &hash);
384 }
385 }
386
387 #[test]
388 fn neighbor_at_pole_excluded() {
389 let hash = geohash_encode(0.0, 89.99, 4);
391 let north = geohash_neighbor(&hash, Direction::North);
392 assert!(north.is_none(), "expected None at pole, got {north:?}");
395 }
396
397 #[test]
398 fn neighbor_wraps_longitude() {
399 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 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 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 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 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}