use swarmkit_sailing::spherical::{
LatLon, LonLatBbox, POLE_LATITUDE_LIMIT_DEG, signed_lon_delta, wrap_lon_deg,
};
use swarmkit_sailing::{LandmassSource, RouteBounds, SeaPathBias};
use crate::bounds::MapBounds;
const PAD_FRACTION: f64 = 0.20;
const PAD_MIN_DEGREES: f64 = 3.0;
pub fn derive_route_bbox<L: LandmassSource>(
origin: (f64, f64),
destination: (f64, f64),
landmass: &L,
clamp: Option<MapBounds>,
) -> Option<MapBounds> {
if origin == destination {
return None;
}
let origin_ll = LatLon::new(origin.0, origin.1);
let destination_ll = LatLon::new(destination.0, destination.1);
let probe_bounds = global_probe_bounds(origin_ll, destination_ll);
let polyline = landmass
.find_sea_path(origin_ll, destination_ll, &probe_bounds, SeaPathBias::None)
.unwrap_or_else(|| {
vec![origin_ll, destination_ll]
});
let (lon_unwrap_min, lon_unwrap_max, lat_min, lat_max) = polyline_unwrapped_bbox(&polyline);
let lon_span = lon_unwrap_max - lon_unwrap_min;
let lat_span = lat_max - lat_min;
let lon_pad = (PAD_FRACTION * lon_span).max(PAD_MIN_DEGREES);
let lat_pad = (PAD_FRACTION * lat_span).max(PAD_MIN_DEGREES);
let padded_lon_unwrap_min = lon_unwrap_min - lon_pad;
let padded_lon_unwrap_max = lon_unwrap_max + lon_pad;
let padded_lat_min = (lat_min - lat_pad).max(-POLE_LATITUDE_LIMIT_DEG);
let padded_lat_max = (lat_max + lat_pad).min(POLE_LATITUDE_LIMIT_DEG);
let derived_bbox = if padded_lon_unwrap_max - padded_lon_unwrap_min >= 360.0 {
LonLatBbox::new(-180.0, 180.0, padded_lat_min, padded_lat_max)
} else {
LonLatBbox::new(
wrap_lon_deg(padded_lon_unwrap_min),
wrap_lon_deg(padded_lon_unwrap_max),
padded_lat_min,
padded_lat_max,
)
};
let derived = MapBounds { bbox: derived_bbox };
let result = match clamp {
Some(c) => derived.clamp_to(Some((
c.bbox.lon_min,
c.bbox.lon_max,
c.bbox.lat_min,
c.bbox.lat_max,
))),
None => derived,
};
Some(result)
}
fn global_probe_bounds(origin: LatLon, destination: LatLon) -> RouteBounds {
RouteBounds::new(
origin,
destination,
LonLatBbox::new(
-180.0,
180.0,
-POLE_LATITUDE_LIMIT_DEG,
POLE_LATITUDE_LIMIT_DEG,
),
)
}
fn polyline_unwrapped_bbox(polyline: &[LatLon]) -> (f64, f64, f64, f64) {
debug_assert!(
!polyline.is_empty(),
"polyline must have at least one point"
);
let mut current_lon_unwrap = polyline[0].lon;
let mut lon_min = current_lon_unwrap;
let mut lon_max = current_lon_unwrap;
let mut lat_min = polyline[0].lat;
let mut lat_max = polyline[0].lat;
for p in polyline.iter().skip(1) {
let dlon = signed_lon_delta(wrap_lon_deg(current_lon_unwrap), p.lon);
current_lon_unwrap += dlon;
if current_lon_unwrap < lon_min {
lon_min = current_lon_unwrap;
}
if current_lon_unwrap > lon_max {
lon_max = current_lon_unwrap;
}
if p.lat < lat_min {
lat_min = p.lat;
}
if p.lat > lat_max {
lat_max = p.lat;
}
}
(lon_min, lon_max, lat_min, lat_max)
}
pub fn format_bbox_flag(bounds: MapBounds) -> String {
let b = bounds.bbox;
format!(
"{:.4},{:.4},{:.4},{:.4}",
b.lon_min, b.lat_min, b.lon_max, b.lat_max
)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::landmass::landmass_grid;
#[test]
fn open_ocean_route_pads_around_endpoint_hint() {
let bounds =
derive_route_bbox((-30.0, 0.0), (-150.0, 0.0), landmass_grid(), None).expect("derived");
let bbox = bounds.bbox;
assert!(bbox.lat_max >= 3.0);
assert!(bbox.lat_min <= -3.0);
assert!(bbox.lon_min <= -30.0);
assert!(bbox.lon_max >= -30.0 - PAD_MIN_DEGREES);
assert!(!bounds.lon_wraps());
}
#[test]
fn route_around_continent_uses_astar_bbox() {
let origin = (-5.5, 36.0); let dest = (32.0, 30.0); let bbox = derive_route_bbox(origin, dest, landmass_grid(), None)
.expect("derived")
.bbox;
assert!(bbox.lat_min <= 30.0);
assert!(bbox.lat_max >= 36.0);
assert!(bbox.lon_min <= -5.5);
assert!(bbox.lon_max >= 32.0);
}
#[test]
fn antimeridian_route_produces_wrapping_bbox() {
let origin = (139.7, 35.7); let dest = (-122.4, 37.8); let bounds = derive_route_bbox(origin, dest, landmass_grid(), None).expect("derived");
let bbox = bounds.bbox;
assert!(
bounds.lon_wraps(),
"Tokyo→SF auto-bbox must wrap, got [{}, {}]",
bbox.lon_min,
bbox.lon_max,
);
let in_bbox = |lon: f64| lon >= bbox.lon_min || lon <= bbox.lon_max;
assert!(
in_bbox(origin.0),
"origin lon {} not in {:?}",
origin.0,
bbox.lon_min..bbox.lon_max
);
assert!(
in_bbox(dest.0),
"dest lon {} not in {:?}",
dest.0,
bbox.lon_min..bbox.lon_max
);
}
#[test]
fn identical_endpoints_returns_none() {
let bbox = derive_route_bbox((0.0, 0.0), (0.0, 0.0), landmass_grid(), None);
assert!(bbox.is_none());
}
#[test]
fn clamp_intersects_with_supplied_bounds() {
let clamp = MapBounds {
bbox: LonLatBbox::new(-100.0, -40.0, -10.0, 10.0),
};
let bbox = derive_route_bbox((-30.0, 0.0), (-150.0, 0.0), landmass_grid(), Some(clamp))
.expect("derived")
.bbox;
assert!(bbox.lon_min >= -100.0);
assert!(bbox.lon_max <= -40.0);
assert!(bbox.lat_min >= -10.0);
assert!(bbox.lat_max <= 10.0);
}
#[test]
fn padded_bbox_is_well_formed() {
let bounds =
derive_route_bbox((10.0, 5.0), (20.0, 10.0), landmass_grid(), None).expect("derived");
assert!(bounds.is_non_degenerate());
}
#[test]
fn format_bbox_flag_round_trips_via_cli_parser() {
let bounds = MapBounds {
bbox: LonLatBbox::new(-75.0, -10.0, 25.0, 60.0),
};
let s = format_bbox_flag(bounds);
assert_eq!(s, "-75.0000,25.0000,-10.0000,60.0000");
}
}