Skip to main content

nodedb_spatial/
h3.rs

1//! H3 hexagonal hierarchical spatial index.
2//!
3//! Uber's H3 system maps the globe into hexagonal cells at 16 resolutions
4//! (0 = ~4.3M km² to 15 = ~0.9 m²). Advantages over geohash:
5//! - Uniform cell area (no pole distortion)
6//! - Hexagonal tessellation (6 equidistant neighbors vs 8 unequal for geohash)
7//! - Better for analytics: equal-area binning for heatmaps
8//!
9//! Uses the `h3o` crate (pure Rust H3 implementation).
10
11use h3o::{CellIndex, LatLng, Resolution};
12use nodedb_types::geometry::Geometry;
13
14/// Encode a (longitude, latitude) coordinate to an H3 cell index.
15///
16/// Resolution 0-15. Default 7 (~5.1 km² cells).
17/// Returns the H3 index as a u64.
18pub fn h3_encode(lng: f64, lat: f64, resolution: u8) -> Option<u64> {
19    let res = Resolution::try_from(resolution).ok()?;
20    let ll = LatLng::new(lat, lng).ok()?;
21    let cell = ll.to_cell(res);
22    Some(u64::from(cell))
23}
24
25/// Encode to H3 hex string (standard representation).
26pub fn h3_encode_string(lng: f64, lat: f64, resolution: u8) -> Option<String> {
27    let res = Resolution::try_from(resolution).ok()?;
28    let ll = LatLng::new(lat, lng).ok()?;
29    let cell = ll.to_cell(res);
30    Some(cell.to_string())
31}
32
33/// Decode an H3 cell index to its center point (lng, lat).
34pub fn h3_to_center(h3_index: u64) -> Option<(f64, f64)> {
35    let cell = CellIndex::try_from(h3_index).ok()?;
36    let ll = LatLng::from(cell);
37    Some((ll.lng(), ll.lat()))
38}
39
40/// Decode an H3 cell index to its boundary polygon.
41///
42/// Returns a closed ring of [lng, lat] coordinates.
43pub fn h3_to_boundary(h3_index: u64) -> Option<Geometry> {
44    let cell = CellIndex::try_from(h3_index).ok()?;
45    let boundary = cell.boundary();
46    let mut ring: Vec<[f64; 2]> = boundary.iter().map(|ll| [ll.lng(), ll.lat()]).collect();
47    // Close the ring.
48    if let Some(&first) = ring.first() {
49        ring.push(first);
50    }
51    Some(Geometry::Polygon {
52        coordinates: vec![ring],
53    })
54}
55
56/// Get the resolution of an H3 cell index.
57pub fn h3_resolution(h3_index: u64) -> Option<u8> {
58    let cell = CellIndex::try_from(h3_index).ok()?;
59    Some(cell.resolution() as u8)
60}
61
62/// Get the parent cell at a coarser resolution.
63pub fn h3_parent(h3_index: u64, parent_resolution: u8) -> Option<u64> {
64    let cell = CellIndex::try_from(h3_index).ok()?;
65    let res = Resolution::try_from(parent_resolution).ok()?;
66    cell.parent(res).map(u64::from)
67}
68
69/// Get all neighbor cells (k-ring of distance 1).
70pub fn h3_neighbors(h3_index: u64) -> Vec<u64> {
71    let Ok(cell) = CellIndex::try_from(h3_index) else {
72        return Vec::new();
73    };
74    cell.grid_disk::<Vec<_>>(1)
75        .into_iter()
76        .filter(|&c| c != cell)
77        .map(u64::from)
78        .collect()
79}
80
81/// Check if an H3 index is valid.
82pub fn h3_is_valid(h3_index: u64) -> bool {
83    CellIndex::try_from(h3_index).is_ok()
84}
85
86#[cfg(test)]
87mod tests {
88    use super::*;
89
90    #[test]
91    fn encode_nyc() {
92        let idx = h3_encode(-73.9857, 40.7484, 7).unwrap();
93        assert!(h3_is_valid(idx));
94        assert_eq!(h3_resolution(idx).unwrap(), 7);
95    }
96
97    #[test]
98    fn encode_string_roundtrip() {
99        let hex = h3_encode_string(0.0, 0.0, 5).unwrap();
100        assert!(!hex.is_empty());
101    }
102
103    #[test]
104    fn center_roundtrip() {
105        let idx = h3_encode(10.0, 50.0, 9).unwrap();
106        let (lng, lat) = h3_to_center(idx).unwrap();
107        assert!((lng - 10.0).abs() < 0.01, "lng={lng}");
108        assert!((lat - 50.0).abs() < 0.01, "lat={lat}");
109    }
110
111    #[test]
112    fn boundary_is_polygon() {
113        let idx = h3_encode(0.0, 0.0, 5).unwrap();
114        let poly = h3_to_boundary(idx).unwrap();
115        assert_eq!(poly.geometry_type(), "Polygon");
116        if let Geometry::Polygon { coordinates } = &poly {
117            // Hexagon has 6 vertices + close = 7 points.
118            assert!(coordinates[0].len() >= 7, "len={}", coordinates[0].len());
119        }
120    }
121
122    #[test]
123    fn resolution_accessor() {
124        for res in 0..=15 {
125            let idx = h3_encode(0.0, 0.0, res).unwrap();
126            assert_eq!(h3_resolution(idx).unwrap(), res);
127        }
128    }
129
130    #[test]
131    fn parent_is_coarser() {
132        let child = h3_encode(0.0, 0.0, 9).unwrap();
133        let parent = h3_parent(child, 7).unwrap();
134        assert_eq!(h3_resolution(parent).unwrap(), 7);
135    }
136
137    #[test]
138    fn neighbors_count() {
139        let idx = h3_encode(0.0, 0.0, 7).unwrap();
140        let nbrs = h3_neighbors(idx);
141        // Hexagon has 6 neighbors.
142        assert_eq!(nbrs.len(), 6, "got {} neighbors", nbrs.len());
143    }
144
145    #[test]
146    fn invalid_index() {
147        assert!(!h3_is_valid(0));
148        assert!(h3_to_center(0).is_none());
149    }
150
151    #[test]
152    fn nearby_points_same_cell() {
153        let a = h3_encode(-73.985, 40.758, 9).unwrap();
154        let b = h3_encode(-73.9851, 40.7581, 9).unwrap();
155        // Very close points should be in the same cell.
156        assert_eq!(a, b);
157    }
158
159    #[test]
160    fn different_resolutions_different_cells() {
161        let coarse = h3_encode(0.0, 0.0, 3).unwrap();
162        let fine = h3_encode(0.0, 0.0, 9).unwrap();
163        assert_ne!(coarse, fine);
164    }
165}