1use nodedb_types::BoundingBox;
19
20const BASE32: &[u8; 32] = b"0123456789bcdefghjkmnpqrstuvwxyz";
23
24const 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
38pub 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; 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
97pub 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 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
145pub 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#[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 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
182pub 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 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
222pub 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
233pub 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 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 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 let hash = geohash_encode(-73.9857, 40.7580, 9);
295 assert_eq!(hash.len(), 9);
296
297 let bb = geohash_decode(&hash).unwrap();
298 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"); }
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 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); 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()); assert!(geohash_decode("ailo").is_none()); }
332
333 #[test]
334 fn decode_center() {
335 let hash = geohash_encode(13.405, 52.52, 9); 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 let h1 = geohash_encode(-73.985, 40.758, 8);
346 let h2 = geohash_encode(-73.986, 40.759, 8);
347 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 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 assert_eq!(neighbors.len(), 8);
379 for (_, n) in &neighbors {
381 assert_ne!(n, &hash);
382 }
383 }
384
385 #[test]
386 fn neighbor_at_pole_excluded() {
387 let hash = geohash_encode(0.0, 89.99, 4);
389 let north = geohash_neighbor(&hash, Direction::North);
390 assert!(north.is_none(), "expected None at pole, got {north:?}");
393 }
394
395 #[test]
396 fn neighbor_wraps_longitude() {
397 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 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 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 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 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}