From 4f0951620ae8011055db8b563978247a71f19b74 Mon Sep 17 00:00:00 2001 From: Andreas Schawo Date: Sun, 28 Jun 2026 15:15:24 +0200 Subject: [PATCH] Added feature structs as a convenience abstraction --- .gitignore | 2 + Cargo.toml | 12 ++- README.md | 14 ++++ examples/example.rs | 33 ++++++++ src/error.rs | 27 ++++++ src/lib.rs | 28 ++----- src/structs.rs | 199 ++++++++++++++++++++++++++++++++++++++++++++ tarpaulin.toml | 6 ++ 8 files changed, 299 insertions(+), 22 deletions(-) create mode 100644 examples/example.rs create mode 100644 src/error.rs create mode 100644 src/structs.rs create mode 100644 tarpaulin.toml diff --git a/.gitignore b/.gitignore index 4fffb2f..64520fb 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,4 @@ /target /Cargo.lock +/tarpaulin-report.html +/.idea diff --git a/Cargo.toml b/Cargo.toml index ee405d1..0379edd 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "maidenhead" -version = "0.2.1" +version = "0.3.0" edition = "2021" authors = ["Olof Johansson "] description = "A simple package to convert to/from maidenhead squares to latitude/longitude" @@ -10,5 +10,15 @@ keywords = ["hamradio"] exclude = ["/.vscode"] # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html +[[example]] +name = "example" +path = "examples/example.rs" +required-features = ["structs"] + +[features] +structs = ["regex"] +structs_serde = ["serde"] [dependencies] +regex = { version ="1.12", optional = true } +serde = { version = "1.0", features = ["derive"], optional = true } diff --git a/README.md b/README.md index 98f5f54..9a65f56 100644 --- a/README.md +++ b/README.md @@ -27,6 +27,12 @@ Add this to your `Cargo.toml`: maidenhead = "0.2.1" ``` +The crate also features a struct representation to allow easier handling. +```toml +[dependencies] +maidenhead = { version = "0.3", features = ["structs"] } +``` + ### Examples ```rust @@ -51,6 +57,14 @@ println!("Bearing: {:.1}°", bearing); // Calculate both distance and bearing let (distance, bearing) = grid_dist_bearing("FM18lv", "EN91")?; println!("Distance: {:.2} km, Bearing: {:.1}°", distance, bearing); + +// With feature `structs` +let grid1 = GridSquare::new("jo30uj")?; +println!("Grid square: {}", grid1); +let coord1: Coordinate = grid1.clone().into(); +println!("As coordinate: {}", coord1); +let grid2 = GridSquare::new("JO30ui")?; +println!("{}", grid1 - grid2); ``` ## Grid Square Format diff --git a/examples/example.rs b/examples/example.rs new file mode 100644 index 0000000..f8df1c9 --- /dev/null +++ b/examples/example.rs @@ -0,0 +1,33 @@ +use maidenhead::*; + +fn main() -> Result<(), MHError>{ + // Convert grid square to coordinates + let (longitude, latitude) = grid_to_longlat("FM18lv")?; + println!("Coordinates: {}, {}", longitude, latitude); + + // Convert coordinates to grid square + let grid = longlat_to_grid(-77.035278, 38.889484, 6)?; + println!("Grid square: {}", grid); + + // Calculate distance between two grid squares + let distance = grid_distance("FM18lv", "EN91")?; + println!("Distance: {:.2} km", distance); + + // Calculate bearing between two grid squares + let bearing = grid_bearing("FM18lv", "EN91")?; + println!("Bearing: {:.1}°", bearing); + + // Calculate both distance and bearing + let (distance, bearing) = grid_dist_bearing("FM18lv", "EN91")?; + println!("Distance: {:.2} km, Bearing: {:.1}°", distance, bearing); + + // With feature `structs` + let grid1 = GridSquare::new("jo30uj")?; + println!("Grid square: {}", grid1); + let coord1: Coordinate = grid1.into(); + println!("As coordinate: {}", coord1); + let grid2 = GridSquare::new("JO30ui")?; + println!("{}", coord1 - grid2.into()); + + Ok(()) +} \ No newline at end of file diff --git a/src/error.rs b/src/error.rs new file mode 100644 index 0000000..0a658cb --- /dev/null +++ b/src/error.rs @@ -0,0 +1,27 @@ +use std::error::Error; +use std::fmt; + +#[derive(Debug)] +pub enum MHError { + InvalidGrid(String), + InvalidGridLength(usize), + InvalidLongLat(f64, f64), + Unknown, +} + +impl fmt::Display for MHError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::InvalidGrid(grid) => write!(f, "Invalid grid format `{grid}`"), + Self::InvalidGridLength(len) => { + write!(f, "Invalid grid length {len}, only 4/6/8/10 supported") + } + Self::InvalidLongLat(long, lat) => { + write!(f, "Invalid Longitude/Latitude: `{long}`/`{lat}`") + } + Self::Unknown => write!(f, "unknown error when generating grid string"), + } + } +} + +impl Error for MHError {} diff --git a/src/lib.rs b/src/lib.rs index ab5a097..3f947e1 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,26 +1,12 @@ -use std::error::Error; -use std::fmt; - -#[derive(Debug)] -pub enum MHError { - InvalidGrid(String), - InvalidGridLength(usize), - InvalidLongLat(f64, f64), - Unknown, -} +mod error; -impl fmt::Display for MHError { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - match self { - Self::InvalidGrid(grid) => write!(f, "Invalid grid format `{grid}`"), - Self::InvalidGridLength(len) => write!(f, "Invalid grid length {len}, only 4/6/8/10 supported"), - Self::InvalidLongLat(long, lat) => write!(f, "Invalid Longitude/Latitude: `{long}`/`{lat}`"), - Self::Unknown => write!(f, "unknown error when generating grid string"), - } - } -} +pub use error::MHError; + +#[cfg(feature = "structs")] +mod structs; -impl Error for MHError {} +#[cfg(feature = "structs")] +pub use structs::{GridSquare, Vector, Coordinate}; // Grid squares are string representations of the latitude and longitude. A good introduction to how to calculate them is in: // http://www.w8bh.net/grid_squares.pdf diff --git a/src/structs.rs b/src/structs.rs new file mode 100644 index 0000000..7894075 --- /dev/null +++ b/src/structs.rs @@ -0,0 +1,199 @@ +#![cfg(feature = "structs")] + +///! This module provides convenience access to maidenhead +///! Author: Andreas, DF1ASC@darc.de + +use crate::error::MHError; +use regex::Regex; +use std::fmt::{Display, Formatter}; +use std::ops::Sub; +use std::sync::LazyLock; + +static RE_GRIDSQUARE: LazyLock = LazyLock::new(|| { + Regex::new(r"^[a-rA-R]{2}[0-9]{2}([a-xA-X]{2}([0-9]{2}([a-xA-X]{2})?)?)?$").unwrap() +}); + +#[derive(Debug, PartialEq, Clone)] +#[cfg_attr( + feature = "structs_serde", + derive(serde::Serialize, serde::Deserialize) +)] +/// A coordinate with latitude and longitude. +pub struct Coordinate(f64, f64); + +impl Coordinate { + pub fn new(lat: f64, long: f64) -> Result { + if lat > 90.0 || lat < -90.0 { + return Err(MHError::InvalidLongLat(long, lat)); + } + if long > 180.0 || long < -180.0 { + return Err(MHError::InvalidLongLat(long, lat)); + } + Ok(Self(lat, long)) + } +} + +impl Display for Coordinate { + fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { + write!(f, "Latitude: {:.6}°, Longitude: {:.6}°", self.0, self.1) + } +} + +#[derive(Debug, PartialEq, Clone)] +#[cfg_attr( + feature = "structs_serde", + derive(serde::Serialize, serde::Deserialize) +)] +/// A GridSquare representation. +pub struct GridSquare(String); + +impl GridSquare { + /// Create a `GridSquare` from string. + pub fn new(grid: impl AsRef) -> Result { + if Regex::is_match(&RE_GRIDSQUARE, grid.as_ref()) { + let mut grid_str = String::new(); + grid.as_ref() + .chars() + .collect::>() + .chunks(2) + .enumerate() + .for_each(|(i, ch)| { + let part = String::from_iter(ch.iter()); + match i { + 0 => grid_str.push_str(&part.to_uppercase()), + 2 | 4 => grid_str.push_str(&part.to_lowercase()), + _ => grid_str.push_str(&part), + } + }); + Ok(Self(grid_str)) + } else { + Err(MHError::InvalidGrid(grid.as_ref().to_string())) + } + } + + /// Creates a `GridSquare` from latitude/longitude with a precision of 4, 6, 8 or 10 chars. + /// Any other precision value defaults to 6. + pub fn from_coordinate(coordinate: Coordinate, precision: u8) -> Result { + let precision = match precision { + 4 | 6 | 8 | 10 => precision, + _ => 6, + }; + Ok(Self(crate::longlat_to_grid( + coordinate.1, + coordinate.0, + precision as usize, + )?)) + } +} + +impl Display for GridSquare { + fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { + write!(f, "{}", self.0) + } +} + +impl From for GridSquare { + /// Creates a `GridSquare` from `Coordinate` with precision 6. + fn from(value: Coordinate) -> Self { + // Should not fail, due to coordinate is already checked + Self::from_coordinate(value, 6).unwrap() + } +} + +impl From for Coordinate { + fn from(value: GridSquare) -> Self { + // Should not fail with valid grid + let longlat = crate::grid_to_longlat(&value.to_string()).unwrap(); + // swap order to common convention + Coordinate(longlat.1, longlat.0) + } +} + +/// A `Vector` of distance and heading. +#[derive(Debug, PartialEq)] +pub struct Vector(f64, f64); + +impl Display for Vector { + fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { + write!(f, "Distance: {:.2} km, Heading: {:.1}°", self.0, self.1) + } +} + +impl Sub for GridSquare { + type Output = Vector; + + fn sub(self, rhs: GridSquare) -> Self::Output { + // Should not fail, due to valid grid + Vector( + crate::grid_distance(&self.to_string(), &rhs.to_string()).unwrap(), + crate::grid_bearing(&self.to_string(), &rhs.to_string()).unwrap(), + ) + } +} + +impl Sub for Coordinate { + type Output = Vector; + + fn sub(self, rhs: Coordinate) -> Self::Output { + let lhs: GridSquare = self.try_into().unwrap(); + lhs - rhs.try_into().unwrap() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_400_grid() { + assert_eq!( + "JO30ui".to_string(), + GridSquare::new("jo30ui").unwrap().to_string() + ); + assert_eq!( + "JO30ui".to_string(), + GridSquare::new("jo30UI").unwrap().to_string() + ); + assert_eq!( + "JO30".to_string(), + GridSquare::new("jo30").unwrap().to_string() + ); + assert_eq!( + "JO30ui44".to_string(), + GridSquare::new("jo30ui44").unwrap().to_string() + ); + + assert_eq!(GridSquare::new("jo30u").is_err(), true); + assert_eq!(GridSquare::new("j03ou").is_err(), true); + assert_eq!(GridSquare::new("jo30ue12er12").is_err(), true); + } + + #[test] + fn test_410_grid_latlong() { + assert_eq!( + Coordinate(50.354166666666686, 7.708333333333314), + GridSquare::new("jo30ui").unwrap().try_into().unwrap() + ); + + // From lat/long with precision + assert_eq!( + GridSquare::new("jo30").unwrap(), + GridSquare::from_coordinate(Coordinate(50.354166666666686, 7.708333333333314), 4) + .unwrap() + ); + assert_eq!( + GridSquare::new("jo30UI45").unwrap(), + GridSquare::from_coordinate(Coordinate(50.354166666666686, 7.708333333333314), 8) + .unwrap() + ); + + // Defaults to precision 6 + assert_eq!( + GridSquare::new("jo30ui").unwrap(), + GridSquare::from_coordinate(Coordinate(50.354166666666686, 7.708333333333314), 44) + .unwrap() + ); + + dbg!(GridSquare::new("JO30uj").unwrap() - GridSquare::new("JO30uk").unwrap()); + } +} diff --git a/tarpaulin.toml b/tarpaulin.toml new file mode 100644 index 0000000..8397549 --- /dev/null +++ b/tarpaulin.toml @@ -0,0 +1,6 @@ +[coverage] +engine = "Llvm" +features = "structs" + +[report] +out = ["Html"]