323 lines
9.9 KiB
Rust
323 lines
9.9 KiB
Rust
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<String>,
|
|
) -> 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<f64> = bounds_str
|
|
.split(',')
|
|
.map(|part| part.trim().parse::<f64>())
|
|
.collect::<Result<Vec<_>, _>>()
|
|
.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());
|
|
}
|
|
}
|