use crate::error::{SpatialError, SpatialResult};
const BASE32: &[u8] = b"0123456789bcdefghjkmnpqrstuvwxyz";
fn decode_char(c: u8) -> SpatialResult<u8> {
BASE32
.iter()
.position(|&b| b == c.to_ascii_lowercase())
.map(|p| p as u8)
.ok_or_else(|| {
SpatialError::ValueError(format!("Invalid geohash character: '{}'", c as char))
})
}
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub struct GeoHash {
hash: String,
}
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct GeoHashCenter {
lat: f64,
lon: f64,
lat_err: f64,
lon_err: f64,
}
impl GeoHashCenter {
pub fn lat(&self) -> f64 {
self.lat
}
pub fn lon(&self) -> f64 {
self.lon
}
pub fn lat_err(&self) -> f64 {
self.lat_err
}
pub fn lon_err(&self) -> f64 {
self.lon_err
}
pub fn bbox(&self) -> (f64, f64, f64, f64) {
(
self.lat - self.lat_err,
self.lat + self.lat_err,
self.lon - self.lon_err,
self.lon + self.lon_err,
)
}
}
impl GeoHash {
pub fn encode(lat: f64, lon: f64, precision: usize) -> SpatialResult<Self> {
if !(-90.0..=90.0).contains(&lat) {
return Err(SpatialError::ValueError(format!(
"Latitude {lat} is outside valid range [-90, 90]"
)));
}
if !(-180.0..=180.0).contains(&lon) {
return Err(SpatialError::ValueError(format!(
"Longitude {lon} is outside valid range [-180, 180]"
)));
}
if precision == 0 || precision > 12 {
return Err(SpatialError::ValueError(format!(
"Precision {precision} is outside valid range [1, 12]"
)));
}
let hash = encode_internal(lat, lon, precision);
Ok(GeoHash { hash })
}
pub fn decode(hash: &str) -> SpatialResult<GeoHashCenter> {
if hash.is_empty() {
return Err(SpatialError::ValueError(
"Geohash string is empty".to_string(),
));
}
decode_internal(hash)
}
pub fn as_str(&self) -> &str {
&self.hash
}
pub fn center(&self) -> SpatialResult<GeoHashCenter> {
decode_internal(&self.hash)
}
pub fn neighbors(&self) -> SpatialResult<[String; 8]> {
compute_neighbors(&self.hash)
}
}
pub fn geohash_encode(lat: f64, lon: f64, precision: usize) -> SpatialResult<String> {
GeoHash::encode(lat, lon, precision).map(|g| g.hash)
}
pub fn geohash_decode(hash: &str) -> SpatialResult<(f64, f64)> {
GeoHash::decode(hash).map(|c| (c.lat, c.lon))
}
pub fn geohash_neighbors(hash: &str) -> SpatialResult<[String; 8]> {
compute_neighbors(hash)
}
fn encode_internal(lat: f64, lon: f64, precision: usize) -> String {
let mut lat_min = -90.0_f64;
let mut lat_max = 90.0_f64;
let mut lon_min = -180.0_f64;
let mut lon_max = 180.0_f64;
let total_bits = precision * 5;
let mut hash = Vec::with_capacity(precision);
let mut current_char: u8 = 0;
let mut bit_count = 0;
let mut is_lon = true;
for _ in 0..total_bits {
current_char <<= 1;
if is_lon {
let mid = (lon_min + lon_max) / 2.0;
if lon >= mid {
current_char |= 1;
lon_min = mid;
} else {
lon_max = mid;
}
} else {
let mid = (lat_min + lat_max) / 2.0;
if lat >= mid {
current_char |= 1;
lat_min = mid;
} else {
lat_max = mid;
}
}
is_lon = !is_lon;
bit_count += 1;
if bit_count == 5 {
hash.push(BASE32[current_char as usize]);
current_char = 0;
bit_count = 0;
}
}
String::from_utf8(hash).unwrap_or_default()
}
fn decode_internal(hash: &str) -> SpatialResult<GeoHashCenter> {
let mut lat_min = -90.0_f64;
let mut lat_max = 90.0_f64;
let mut lon_min = -180.0_f64;
let mut lon_max = 180.0_f64;
let mut is_lon = true;
for byte in hash.bytes() {
let value = decode_char(byte)?;
for shift in (0..5).rev() {
let bit = (value >> shift) & 1;
if is_lon {
let mid = (lon_min + lon_max) / 2.0;
if bit == 1 {
lon_min = mid;
} else {
lon_max = mid;
}
} else {
let mid = (lat_min + lat_max) / 2.0;
if bit == 1 {
lat_min = mid;
} else {
lat_max = mid;
}
}
is_lon = !is_lon;
}
}
Ok(GeoHashCenter {
lat: (lat_min + lat_max) / 2.0,
lon: (lon_min + lon_max) / 2.0,
lat_err: (lat_max - lat_min) / 2.0,
lon_err: (lon_max - lon_min) / 2.0,
})
}
fn compute_neighbors(hash: &str) -> SpatialResult<[String; 8]> {
if hash.is_empty() {
return Err(SpatialError::ValueError(
"Geohash string is empty".to_string(),
));
}
let precision = hash.len();
let center = decode_internal(hash)?;
let lat_step = center.lat_err * 2.0;
let lon_step = center.lon_err * 2.0;
let offsets: [(f64, f64); 8] = [
(lat_step, 0.0), (lat_step, lon_step), (0.0, lon_step), (-lat_step, lon_step), (-lat_step, 0.0), (-lat_step, -lon_step), (0.0, -lon_step), (lat_step, -lon_step), ];
let mut result: [String; 8] = Default::default();
for (i, (dlat, dlon)) in offsets.iter().enumerate() {
let nlat = (center.lat + dlat).clamp(-90.0, 90.0);
let raw_lon = center.lon + dlon;
let nlon = if raw_lon > 180.0 {
raw_lon - 360.0
} else if raw_lon < -180.0 {
raw_lon + 360.0
} else {
raw_lon
};
result[i] = encode_internal(nlat, nlon, precision);
}
Ok(result)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_encode_known_hash() {
let hash = geohash_encode(48.8566, 2.3522, 6).expect("encode");
let (lat, lon) = geohash_decode(&hash).expect("decode");
assert!((lat - 48.8566).abs() < 0.05, "lat={lat}");
assert!((lon - 2.3522).abs() < 0.05, "lon={lon}");
}
#[test]
fn test_encode_decode_roundtrip() {
let points = [
(0.0, 0.0),
(51.5074, -0.1278),
(-33.8688, 151.2093),
(40.7128, -74.0060),
(35.6762, 139.6503),
];
for (lat, lon) in points {
for precision in 3..=8 {
let hash = geohash_encode(lat, lon, precision).expect("encode");
let (dlat, dlon) = geohash_decode(&hash).expect("decode");
let tolerance = 180.0 / 2.0_f64.powi((5 * precision / 2) as i32);
assert!(
(dlat - lat).abs() <= tolerance * 1.5,
"lat mismatch at precision {precision}: {dlat} vs {lat}"
);
assert!(
(dlon - lon).abs() <= tolerance * 1.5,
"lon mismatch at precision {precision}: {dlon} vs {lon}"
);
}
}
}
#[test]
fn test_decode_invalid_char() {
assert!(geohash_decode("u0_bad").is_err());
assert!(geohash_decode("").is_err());
}
#[test]
fn test_encode_invalid_inputs() {
assert!(geohash_encode(91.0, 0.0, 5).is_err());
assert!(geohash_encode(0.0, 181.0, 5).is_err());
assert!(geohash_encode(0.0, 0.0, 0).is_err());
assert!(geohash_encode(0.0, 0.0, 13).is_err());
}
#[test]
fn test_neighbors_count() {
let neighbors = geohash_neighbors("u09tun").expect("neighbors");
assert_eq!(neighbors.len(), 8);
for n in &neighbors {
assert_eq!(n.len(), 6);
let result = geohash_decode(n);
assert!(result.is_ok(), "neighbor {n} is invalid");
}
}
#[test]
fn test_geohash_bbox() {
let center = GeoHash::decode("u09tun").expect("decode");
let (min_lat, max_lat, min_lon, max_lon) = center.bbox();
assert!(min_lat < center.lat());
assert!(max_lat > center.lat());
assert!(min_lon < center.lon());
assert!(max_lon > center.lon());
}
#[test]
fn test_neighbors_adjacency() {
let center = GeoHash::decode("u09tun").expect("decode");
let neighbors = geohash_neighbors("u09tun").expect("neighbors");
let north_center = geohash_decode(&neighbors[0]).expect("decode north");
assert!(
north_center.0 > center.lat(),
"North neighbor should be north"
);
let east_center = geohash_decode(&neighbors[2]).expect("decode east");
assert!(east_center.1 > center.lon(), "East neighbor should be east");
}
}