diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..24a8e87 --- /dev/null +++ b/.gitattributes @@ -0,0 +1 @@ +*.png filter=lfs diff=lfs merge=lfs -text diff --git a/.github/workflows/test.yml b/.github/workflows/ci.yml similarity index 81% rename from .github/workflows/test.yml rename to .github/workflows/ci.yml index 78cafc8..b58eaea 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/ci.yml @@ -16,12 +16,16 @@ jobs: MBTA_TOKEN: ${{ secrets.MBTA_TOKEN }} steps: - uses: actions/checkout@v3 - - run: cargo test --verbose + with: + lfs: 'true' + - run: cargo test --verbose --all-features lint: name: Lint runs-on: ubuntu-latest steps: - uses: actions/checkout@v3 + with: + lfs: 'true' - run: rustup component add clippy - run: rustup component add rustfmt - run: cargo clippy --all-features -- -D warnings @@ -31,4 +35,6 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/checkout@v3 + with: + lfs: 'true' - uses: webiny/action-conventional-commits@v1.0.3 diff --git a/.gitignore b/.gitignore index ad2f0ff..16d5636 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,3 @@ /target Cargo.lock -.DS_Store \ No newline at end of file +.DS_Store diff --git a/Cargo.toml b/Cargo.toml index 16687c5..2802292 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "mbta-rs" -version = "0.2.0" +version = "0.2.1" edition = "2021" authors = ["Robert Yin "] description = "Simple Rust client for interacting with the MBTA V3 API." @@ -11,12 +11,24 @@ documentation = "https://docs.rs/mbta-rs" keywords = ["mbta", "public-transit", "massachusetts"] categories = ["api-bindings", "web-programming::http-client"] +[package.metadata.docs.rs] +all-features = true + [dev-dependencies] +raster = "0.2.0" rstest = "0.12.0" [dependencies] +colors-transform = { version = "0.2.11", optional = true } chrono = "0.4.19" -ureq = { version = "2.4.0", features = ["json"] } +geo-types = { version = "0.7.4", optional = true } +polyline = { version = "0.9.0", optional = true } serde = { version = "1.0.136", features = ["derive"] } serde_json = "1.0.79" +staticmap = { version = "0.4.0", optional = true } thiserror = "1.0.31" +tiny-skia = { version = "0.6.3", optional = true } +ureq = { version = "2.4.0", features = ["json"] } + +[features] +map = ["dep:staticmap", "dep:polyline", "dep:geo-types", "dep:tiny-skia", "dep:colors-transform"] diff --git a/README.md b/README.md index 7af05cb..57b53cb 100644 --- a/README.md +++ b/README.md @@ -75,6 +75,66 @@ if let Ok(response) = alerts_response { } ``` +## Map Feature + +This library comes with an optional module for plotting location-related data models (stops, vehicles, shapes, etc.) onto a simple tile map. + +In your `Cargo.toml` file: +```toml +[dependencies] +mbta-rs = { version = "*", features = ["map"] } + +# necessary to create the map itself +staticmap = "*" +``` + +Simple example usage: +```rust +use std::{collections::HashMap, env}; +use staticmap::StaticMapBuilder; +use mbta_rs::{Client, map::{Plottable, PlotStyle}}; + +let client = match env::var("MBTA_TOKEN") { + Ok(token) => Client::with_key(token), + Err(_) => Client::without_key() +}; + +let routes = client.routes(HashMap::from([("filter[type]".into(), "0,1".into())])).expect("failed to get routes"); +let mut map = StaticMapBuilder::new() + .width(1000) + .height(1000) + .zoom(12) + .lat_center(42.326768) + .lon_center(-71.100099) + .build() + .expect("failed to build map"); + +for route in routes.data { + let query = HashMap::from([("filter[route]".into(), route.id)]); + let shapes = client + .shapes(query.clone()) + .expect("failed to get shapes"); + for shape in shapes.data { + shape + .plot(&mut map, true, PlotStyle::new((route.attributes.color.clone(), 3.0), Some(("#FFFFFF".into(), 1.0)))) + .expect("failed to plot shape"); + } + let stops = client + .stops(query.clone()) + .expect("failed to get stops"); + for stop in stops.data { + stop.plot( + &mut map, + true, + PlotStyle::new((route.attributes.color.clone(), 3.0), Some(("#FFFFFF".into(), 1.0))), + ) + .expect("failed to plot stop"); + } +} + +// save to file... +``` + ## Contribute diff --git a/resources/test/expected_map.png b/resources/test/expected_map.png new file mode 100644 index 0000000..4b15cba --- /dev/null +++ b/resources/test/expected_map.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:fc2df6626b4cfa47514f92d955db23a0d061e4f4e7d7f13f2870d0c513926cf1 +size 1533344 diff --git a/resources/test/train.png b/resources/test/train.png new file mode 100644 index 0000000..d095c5d --- /dev/null +++ b/resources/test/train.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:2f0dffd0277687964c71dedd4933bb628a3263f151395d32f9fd726600d523b5 +size 1391 diff --git a/src/lib.rs b/src/lib.rs index 388ba50..878c4e8 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -19,5 +19,7 @@ pub mod client; pub use client::*; pub mod error; pub use error::*; +#[cfg(feature = "map")] +pub mod map; pub mod models; pub use models::*; diff --git a/src/map/mod.rs b/src/map/mod.rs new file mode 100644 index 0000000..99bab3d --- /dev/null +++ b/src/map/mod.rs @@ -0,0 +1,255 @@ +//! Module for plotting models that contain location data onto a tile map. + +use std::path::PathBuf; + +use colors_transform::{Color, ParseError, Rgb}; +use polyline::decode_polyline; +use staticmap::{ + tools::{CircleBuilder, Color as MapColor, IconBuilder, LineBuilder}, + Error as MapError, StaticMap, +}; +use thiserror::Error; + +use super::*; + +/// Errors that can occur when plotting. +#[derive(Error, Debug)] +pub enum PlotError { + /// Color conversion parsing failed. + #[error("color conversion failed during parsing: `{0}`")] + ColorError(String), + /// Map related errors. + #[error("map error: `{0}`")] + MapError(#[from] MapError), + /// Polyline error. + #[error("polyline error: `{0}`")] + PolylineError(String), +} + +impl From for PlotError { + fn from(error: ParseError) -> Self { + PlotError::ColorError(error.message) + } +} + +/// Convert RGB color representation from the [colors_transform] crate into color representation from the [staticmap] crate. +/// Automatically sets alpha value to 100%, a.k.a this conversion does not support transparency values. +/// +/// # Arguments +/// +/// * `rgb` - RGB representation from [colors_transform] +/// * `anti_alias` - whether to use anti-aliasing +/// +/// ``` +/// use colors_transform::Rgb; +/// use staticmap::tools::Color; +/// use mbta_rs::map::*; +/// +/// // black RGB color +/// let rgb = Rgb::from(0.0, 0.0, 0.0); +/// // convert with anti-aliasing +/// let converted = rgb_to_map_color(rgb, true); +/// ``` +pub fn rgb_to_map_color(rgb: Rgb, anti_alias: bool) -> MapColor { + MapColor::new(anti_alias, rgb.get_red() as u8, rgb.get_green() as u8, rgb.get_blue() as u8, 255) +} + +/// Plotting styles for models; typically hex colors and widths/radii. +#[derive(Debug, Clone, PartialEq)] +pub struct PlotStyle { + /// Inner hex color string and width/radius. + pub inner: (String, f32), + /// Optional border hex color string and width. + pub border: Option<(String, f32)>, +} + +impl PlotStyle { + /// Create a new [PlotStyle]. + /// + /// # Arguments + /// + /// * `inner` - inner data + /// * `border` - border data + /// + /// ``` + /// use mbta_rs::map::*; + /// + /// // create a style with an inner color of white and 3.0 pixel width/radius + /// // and a black border of 1.0 pixel width + /// let style = PlotStyle::new(("#FFFFFF".into(), 3.0), Some(("#000000".into(), 1.0))); + /// ``` + pub fn new(inner: (String, f32), border: Option<(String, f32)>) -> Self { + Self { inner, border } + } +} + +/// Plotting style for model icons. +#[derive(Debug, Clone, PartialEq)] +pub struct IconStyle { + /// Path to icon file. + pub icon: PathBuf, + /// X-offset in pixels from the bottom-left of the map. + pub x_offset: f64, + /// Y-offset in pixels from the bottom-left of the map. + pub y_offset: f64, +} + +impl IconStyle { + /// Create a new [IconStyle]. + /// + /// # Arguments + /// + /// * `icon` - path to icon file + /// * `x_offset` - x-offset in pixels from bottom-left of the map + /// * `y_offset` - y-offset in pixels from bottom-left of the map + /// + /// ``` + /// use mbta_rs::map::*; + /// + /// // create a style from "foobar.png" with no offsets + /// let style = IconStyle::new("foobar.png", 0.0, 0.0); + /// ``` + pub fn new>(icon: P, x_offset: f64, y_offset: f64) -> Self { + Self { + icon: icon.into(), + x_offset, + y_offset, + } + } +} + +/// Trait for data models that can plotted onto a tile map. +pub trait Plottable { + /// Plot this model onto a tile map. + /// + /// # Arguments + /// + /// * `map` - mutable reference to a tile map + /// * `anti_alias` - whether to render with anti-aliasing or not + /// * `plot_style` - plot style for the model + fn plot(self, map: &mut StaticMap, anti_alias: bool, extra_data: D) -> Result<(), PlotError>; +} + +impl Plottable for Stop { + fn plot(self, map: &mut StaticMap, anti_alias: bool, plot_style: PlotStyle) -> Result<(), PlotError> { + if let Some(border_data) = plot_style.border { + let border = CircleBuilder::new() + .lat_coordinate(self.attributes.latitude) + .lon_coordinate(self.attributes.longitude) + .color(rgb_to_map_color(Rgb::from_hex_str(border_data.0.as_str())?, anti_alias)) + .radius(border_data.1 + plot_style.inner.1) + .build()?; + map.add_tool(border); + } + let inner_circle = CircleBuilder::new() + .lat_coordinate(self.attributes.latitude) + .lon_coordinate(self.attributes.longitude) + .color(rgb_to_map_color(Rgb::from_hex_str(plot_style.inner.0.as_str())?, anti_alias)) + .radius(plot_style.inner.1) + .build()?; + map.add_tool(inner_circle); + Ok(()) + } +} + +impl Plottable for Vehicle { + fn plot(self, map: &mut StaticMap, _anti_alias: bool, icon_style: IconStyle) -> Result<(), PlotError> { + let icon = IconBuilder::new() + .lat_coordinate(self.attributes.latitude) + .lon_coordinate(self.attributes.longitude) + .x_offset(icon_style.x_offset) + .y_offset(icon_style.y_offset) + .path(icon_style.icon)? + .build()?; + map.add_tool(icon); + Ok(()) + } +} + +impl Plottable for Shape { + fn plot(self, map: &mut StaticMap, anti_alias: bool, plot_style: PlotStyle) -> Result<(), PlotError> { + let points = decode_polyline(&self.attributes.polyline, 5).map_err(PlotError::PolylineError)?; + if let Some(border_data) = plot_style.border { + let border = LineBuilder::new() + .lat_coordinates(points.0.iter().map(|p| p.y)) + .lon_coordinates(points.0.iter().map(|p| p.x)) + .color(rgb_to_map_color(Rgb::from_hex_str(border_data.0.as_str())?, anti_alias)) + .width(border_data.1 + plot_style.inner.1) + .build()?; + map.add_tool(border); + } + let inner_line = LineBuilder::new() + .lat_coordinates(points.0.iter().map(|p| p.y)) + .lon_coordinates(points.0.iter().map(|p| p.x)) + .color(rgb_to_map_color(Rgb::from_hex_str(plot_style.inner.0.as_str())?, anti_alias)) + .width(plot_style.inner.1) + .build()?; + map.add_tool(inner_line); + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + use rstest::*; + + #[fixture] + fn color_error() -> ParseError { + ParseError { message: "foobar".into() } + } + + #[fixture] + fn map_error() -> MapError { + MapError::InvalidSize + } + + #[rstest] + fn test_plot_error_from_color_error(color_error: ParseError) { + // Arrange + + // Act + let actual = PlotError::from(color_error.clone()); + + // Assert + if let PlotError::ColorError(s) = actual { + assert_eq!(s, color_error.message); + } else { + panic!("incorrect plot error") + } + } + + #[rstest] + fn test_plot_error_from_map_error(map_error: MapError) { + // Arrange + + // Act + let actual = PlotError::from(map_error); + + // Assert + if let PlotError::MapError(e) = actual { + assert_eq!(format!("{:?}", e), format!("{:?}", MapError::InvalidSize)); + } else { + panic!("incorrect plot error") + } + } + + #[rstest] + fn test_plot_error_display(color_error: ParseError, map_error: MapError) { + // Arrange + let color_expected = format!("color conversion failed during parsing: `{}`", &color_error.message); + let map_expected = format!("map error: `{map_error}`"); + let polyline_expected = "polyline error: `foobar`"; + + // Act + let color_actual = format!("{}", PlotError::from(color_error)); + let map_actual = format!("{}", PlotError::from(map_error)); + let polyline_actual = format!("{}", PlotError::PolylineError("foobar".into())); + + // Assert + assert_eq!(color_actual, color_expected); + assert_eq!(map_actual, map_expected); + assert_eq!(polyline_actual, polyline_expected); + } +} diff --git a/tests/map.rs b/tests/map.rs new file mode 100644 index 0000000..9f173ba --- /dev/null +++ b/tests/map.rs @@ -0,0 +1,69 @@ +//! Simple tests for tile map plotting. + +use std::{collections::HashMap, fs::remove_file, path::PathBuf}; + +use mbta_rs::{map::*, *}; +use raster::{compare::similar, open}; +use staticmap::*; + +use rstest::*; + +#[fixture] +fn client() -> Client { + if let Ok(token) = std::env::var("MBTA_TOKEN") { + Client::with_key(token) + } else { + Client::without_key() + } +} + +fn image_file(relative_path: &str) -> PathBuf { + PathBuf::from(format!("{}/resources/test/{}", env!("CARGO_MANIFEST_DIR"), relative_path)) +} + +#[rstest] +fn test_simple_map_render(client: Client) { + // Arrange + let routes = client.routes(HashMap::from([("filter[type]".into(), "0,1".into())])).expect("failed to get routes"); + let mut map = StaticMapBuilder::new() + .width(1000) + .height(1000) + .zoom(12) + .lat_center(42.326768) + .lon_center(-71.100099) + .build() + .expect("failed to build map"); + let actual_path = image_file("actual_map.png"); + let actual_path = actual_path.to_str().expect("failed to load path: `actual_map.png`"); + let expected_path = image_file("expected_map.png"); + let expected_path = expected_path.to_str().expect("failed to load path: `expected_map.png`"); + + // Act + for route in routes.data { + let query = HashMap::from([("filter[route]".into(), route.id)]); + let shapes = client.shapes(query.clone()).expect("failed to get shapes"); + for shape in shapes.data { + shape + .plot(&mut map, true, PlotStyle::new((route.attributes.color.clone(), 3.0), Some(("#FFFFFF".into(), 1.0)))) + .expect("failed to plot shape"); + } + let stops = client.stops(query.clone()).expect("failed to get stops"); + for stop in stops.data { + stop.plot(&mut map, true, PlotStyle::new((route.attributes.color.clone(), 3.0), Some(("#FFFFFF".into(), 1.0)))) + .expect("failed to plot stop"); + } + let vehicles = client.vehicles(query).expect("failed to get vehicles"); + for vehicle in vehicles.data { + vehicle + .plot(&mut map, true, IconStyle::new(image_file("train.png"), 12.5, 12.5)) + .expect("failed to plot vehicle"); + } + } + map.save_png(image_file("actual_map.png")).expect("failed to save map to file"); + + // Assert + let actual = open(actual_path).expect("failed to open: `actual_map.png`"); + let expected = open(expected_path).expect("failed to open: `expected_map.png`"); + assert!(similar(&actual, &expected).expect("failed to compare images") <= 10); + remove_file(actual_path).expect("failed to remove: `actual_map.png`"); +}