Skip to main content

nodedb_spatial/
h3.rs

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