use axum::http::StatusCode; /// Check if two bounding boxes intersect. /// Both boxes are (south, west, north, east) / (min_lat, min_lon, max_lat, max_lon). #[inline] #[allow(clippy::too_many_arguments)] pub fn bounds_intersect( a_south: f64, a_west: f64, a_north: f64, a_east: f64, b_south: f64, b_west: f64, b_north: f64, b_east: f64, ) -> bool { a_west <= b_east && a_east >= b_west && a_south <= b_north && a_north >= b_south } /// Compute the lat/lon bounding box of an H3 cell in degrees, with a configurable buffer in degrees. /// Returns (south, west, north, east) / (min_lat, min_lon, max_lat, max_lon). pub fn h3_cell_bounds(cell: h3o::CellIndex, buffer: f64) -> (f64, f64, f64, f64) { let boundary = cell.boundary(); let (mut min_lat, mut max_lat) = (f64::INFINITY, f64::NEG_INFINITY); let (mut min_lon, mut max_lon) = (f64::INFINITY, f64::NEG_INFINITY); for vertex in boundary.iter() { // h3o LatLng::lat()/lng() return degrees let lat = vertex.lat(); let lon = vertex.lng(); if lat < min_lat { min_lat = lat; } if lat > max_lat { max_lat = lat; } if lon < min_lon { min_lon = lon; } if lon > max_lon { max_lon = lon; } } ( min_lat - buffer, min_lon - buffer, max_lat + buffer, max_lon + buffer, ) } /// Require a bounds parameter, returning an error if absent, then parse it. pub fn require_bounds( bounds: Option, ) -> Result<(f64, f64, f64, f64), (StatusCode, String)> { let bounds_str = bounds.ok_or(( StatusCode::BAD_REQUEST, "bounds parameter is required".into(), ))?; parse_bounds(&bounds_str) } pub fn parse_bounds(bounds_str: &str) -> Result<(f64, f64, f64, f64), (StatusCode, String)> { let parts: Vec = bounds_str .split(',') .map(|part| part.trim().parse::()) .collect::, _>>() .map_err(|_| { ( StatusCode::BAD_REQUEST, "Invalid bounds format. Use: south,west,north,east".into(), ) })?; if parts.len() != 4 { return Err(( StatusCode::BAD_REQUEST, "Invalid bounds format. Use: south,west,north,east".into(), )); } let (south, west, north, east) = (parts[0], parts[1], parts[2], parts[3]); if ![south, west, north, east] .iter() .all(|value| value.is_finite()) { return Err(( StatusCode::BAD_REQUEST, "Invalid bounds: values must be finite numbers".into(), )); } if !(-90.0..=90.0).contains(&south) || !(-90.0..=90.0).contains(&north) { return Err(( StatusCode::BAD_REQUEST, "Invalid bounds: latitude must be between -90 and 90".into(), )); } if !(-180.0..=180.0).contains(&west) || !(-180.0..=180.0).contains(&east) { return Err(( StatusCode::BAD_REQUEST, "Invalid bounds: longitude must be between -180 and 180".into(), )); } // Validate that bounds are not inverted if south > north { return Err(( StatusCode::BAD_REQUEST, format!( "Invalid bounds: south ({}) must be <= north ({})", south, north ), )); } if west > east { return Err(( StatusCode::BAD_REQUEST, format!("Invalid bounds: west ({}) must be <= east ({})", west, east), )); } Ok((south, west, north, east)) } #[cfg(test)] mod tests { use super::*; use std::str::FromStr; #[test] fn parse_bounds_valid() { assert_eq!( parse_bounds("1.0,2.0,3.0,4.0").unwrap(), (1.0, 2.0, 3.0, 4.0) ); assert_eq!( parse_bounds("51.5, -0.1, 51.6, 0.2").unwrap(), (51.5, -0.1, 51.6, 0.2) ); } #[test] fn parse_bounds_invalid() { assert!(parse_bounds("1.0,2.0,3.0").is_err()); assert!(parse_bounds("1.0,2.0,3.0,4.0,5.0").is_err()); assert!(parse_bounds("a,b,c,d").is_err()); assert!(parse_bounds("").is_err()); } #[test] fn parse_bounds_inverted_rejected() { // south > north is rejected assert!(parse_bounds("52.0,-0.5,51.0,0.5").is_err()); // west > east is rejected assert!(parse_bounds("51.0,0.5,52.0,-0.5").is_err()); } #[test] fn parse_bounds_rejects_non_finite_values() { assert!(parse_bounds("NaN,0,1,1").is_err()); assert!(parse_bounds("0,0,inf,1").is_err()); } #[test] fn parse_bounds_accepts_world_sized_bounds() { assert!(parse_bounds("-90,-180,90,180").is_ok()); assert!(parse_bounds("35.8,-45.0,67.2,45.0").is_ok()); } #[test] fn h3_cell_bounds_applies_buffer() { let cell = h3o::CellIndex::from_str("8928308280fffff").unwrap(); let (min_lat, min_lon, max_lat, max_lon) = h3_cell_bounds(cell, 0.0); let (buf_min_lat, buf_min_lon, buf_max_lat, buf_max_lon) = h3_cell_bounds(cell, 0.1); assert!((min_lat - buf_min_lat - 0.1).abs() < 1e-10); assert!((min_lon - buf_min_lon - 0.1).abs() < 1e-10); assert!((buf_max_lat - max_lat - 0.1).abs() < 1e-10); assert!((buf_max_lon - max_lon - 0.1).abs() < 1e-10); } #[test] fn h3_cell_bounds_returns_degrees_not_radians() { let cell = h3o::CellIndex::from_str("8928308280fffff").unwrap(); let (min_lat, min_lon, max_lat, max_lon) = h3_cell_bounds(cell, 0.0); assert!( min_lat > 30.0 && min_lat < 45.0, "min_lat {} should be ~37° (degrees)", min_lat ); assert!( max_lat > 30.0 && max_lat < 45.0, "max_lat {} should be ~37° (degrees)", max_lat ); assert!( min_lon < -100.0, "min_lon {} should be ~-122° (degrees)", min_lon ); assert!( max_lon < -100.0, "max_lon {} should be ~-122° (degrees)", max_lon ); } #[test] fn bounds_intersect_overlapping() { assert!(bounds_intersect(0.0, 0.0, 2.0, 2.0, 1.0, 1.0, 3.0, 3.0)); assert!(bounds_intersect(0.0, 0.0, 10.0, 10.0, 2.0, 2.0, 5.0, 5.0)); assert!(bounds_intersect(2.0, 2.0, 5.0, 5.0, 0.0, 0.0, 10.0, 10.0)); assert!(bounds_intersect(0.0, 0.0, 1.0, 1.0, 1.0, 0.0, 2.0, 1.0)); } #[test] fn bounds_intersect_non_overlapping() { assert!(!bounds_intersect(0.0, 0.0, 1.0, 1.0, 0.0, 2.0, 1.0, 3.0)); assert!(!bounds_intersect(0.0, 0.0, 1.0, 1.0, 2.0, 0.0, 3.0, 1.0)); assert!(!bounds_intersect(0.0, 0.0, 1.0, 1.0, 5.0, 5.0, 6.0, 6.0)); } #[test] fn parse_bounds_with_spaces() { let (south, west, _north, _east) = parse_bounds("51.0, -0.5, 52.0, 0.5").unwrap(); assert_eq!(south, 51.0); assert_eq!(west, -0.5); } #[test] fn parse_bounds_negative_values() { let (south, _west, north, _east) = parse_bounds("-51.5,-0.5,-50.0,0.5").unwrap(); assert_eq!(south, -51.5); assert_eq!(north, -50.0); } #[test] fn touching_at_corner_intersects() { assert!(bounds_intersect( 0.0, 0.0, 1.0, 1.0, // Box A 1.0, 1.0, 2.0, 2.0 // Box B touches at (1,1) )); } #[test] fn touching_at_edge_intersects() { assert!(bounds_intersect( 0.0, 0.0, 1.0, 1.0, // Box A 1.0, 0.0, 2.0, 1.0 // Box B touches along right edge )); } #[test] fn disjoint_diagonally_no_intersect() { assert!(!bounds_intersect( 0.0, 0.0, 1.0, 1.0, // Box A 2.0, 2.0, 3.0, 3.0 // Box B diagonally away )); } #[test] fn negative_coordinates_intersect() { assert!(bounds_intersect( -2.0, -2.0, -1.0, -1.0, // Box A (negative coords) -1.5, -1.5, -0.5, -0.5 // Box B overlaps )); } #[test] fn h3_cell_bounds_zero_buffer() { let cell = h3o::CellIndex::from_str("8928308280fffff").unwrap(); let (south, west, north, east) = h3_cell_bounds(cell, 0.0); assert!(south < north, "south {} should be < north {}", south, north); assert!(west < east, "west {} should be < east {}", west, east); assert!(south > 30.0 && south < 45.0); assert!(west < -100.0); } #[test] fn h3_cell_bounds_different_resolutions() { let cell_high = h3o::CellIndex::from_str("8928308280fffff").unwrap(); let res5 = h3o::Resolution::try_from(5).unwrap(); let cell_low = cell_high.parent(res5).unwrap(); let (s_low, w_low, n_low, e_low) = h3_cell_bounds(cell_low, 0.0); let (s_high, w_high, n_high, e_high) = h3_cell_bounds(cell_high, 0.0); let area_low = (n_low - s_low) * (e_low - w_low); let area_high = (n_high - s_high) * (e_high - w_high); assert!(area_low > area_high, "Lower res should have larger area"); } #[test] fn parent_cell_at_lower_resolution() { let child = h3o::CellIndex::from_str("8928308280fffff").unwrap(); let parent_res = h3o::Resolution::try_from(7).unwrap(); let parent = child.parent(parent_res).unwrap(); assert_eq!(parent.resolution(), parent_res); assert!(parent.children(child.resolution()).any(|c| c == child)); } #[test] fn same_resolution_returns_self() { let cell = h3o::CellIndex::from_str("8928308280fffff").unwrap(); let res = cell.resolution(); let parent = cell.parent(res); assert_eq!(parent, Some(cell)); } #[test] fn higher_resolution_parent_fails() { let cell = h3o::CellIndex::from_str("8928308280fffff").unwrap(); let higher_res = h3o::Resolution::try_from(10).unwrap(); let parent = cell.parent(higher_res); assert!(parent.is_none()); } }