diff --git a/Cargo.toml b/Cargo.toml index f675967a..2529cb7e 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -61,7 +61,7 @@ default = [ "deprecated_items", "all_series", "all_elements" ] all_series = ["area_series", "line_series", "point_series", "surface_series"] -all_elements = ["errorbar", "candlestick", "boxplot", "histogram"] +all_elements = ["errorbar", "candlestick", "boxplot", "boxplot_outliers", "histogram"] # Tier 1 Backends bitmap_backend = ["plotters-bitmap", "ttf"] @@ -73,6 +73,7 @@ svg_backend = ["plotters-svg"] errorbar = [] candlestick = [] boxplot = [] +boxplot_outliers = [] # Series histogram = [] diff --git a/src/element/boxplot_outliers.rs b/src/element/boxplot_outliers.rs new file mode 100644 index 00000000..d8284892 --- /dev/null +++ b/src/element/boxplot_outliers.rs @@ -0,0 +1,423 @@ +use std::{marker::PhantomData}; + +use super::boxplot::{BoxplotOrient, BoxplotOrientH, BoxplotOrientV}; +use crate::element::{Drawable, PointCollection}; +use crate::style::{Color, ShapeStyle, BLACK}; +use plotters_backend::{BackendCoord, DrawingBackend, DrawingErrorKind}; + +const DEFAULT_WIDTH: u32 = 10; + +///Structure to contain the boxplot data with outliers +#[derive(Clone, Debug)] +pub struct BoxplotData { + minimum: f64, + lower_quartile: f64, + median: f64, + upper_quartile: f64, + maximum: f64, + outliers: Vec, +} + +impl BoxplotData { + // Extract a value representing the `pct` percentile of a + // sorted `s`, using linear interpolation. + // Copied from Quartiles. + fn percentile_of_sorted + Copy>(s: &[T], pct: f64) -> f64 { + assert!(!s.is_empty()); + if s.len() == 1 { + return s[0].into(); + } + assert!(0_f64 <= pct); + let hundred = 100_f64; + assert!(pct <= hundred); + if (pct - hundred).abs() < std::f64::EPSILON { + return s[s.len() - 1].into(); + } + let length = (s.len() - 1) as f64; + let rank = (pct / hundred) * length; + let lower_rank = rank.floor(); + let d = rank - lower_rank; + let n = lower_rank as usize; + let lo = s[n].into(); + let hi = s[n + 1].into(); + lo + (hi - lo) * d + } + + /// Create a new BoxplotData struct with the values calculated from the argument. + /// + /// - `s`: The array of the original values + /// - **returns** The newly created BoxplotData struct + /// + /// ```rust + /// use plotters::prelude::*; + /// + /// let boxplot_data = BoxplotData::new(&[7, 15, 36, 39, 40, 41]); + /// assert_eq!(boxplot_data.median(), 37.5); + /// ``` + pub fn new + Copy + PartialOrd>(s: &[T]) -> Self { + let mut s = s.to_owned(); + s.sort_unstable_by(|a, b| a.partial_cmp(b).unwrap()); + + let lower = BoxplotData::percentile_of_sorted(&s, 25_f64); + let median = BoxplotData::percentile_of_sorted(&s, 50_f64); + let upper = BoxplotData::percentile_of_sorted(&s, 75_f64); + let iqr = upper - lower; + let lower_fence = lower - 1.5 * iqr; + let upper_fence = upper + 1.5 * iqr; + + let mut outliers = Vec::with_capacity(s.len() / 2); + + let mut minimum = None; + let mut maximum = None; + + for v in s { + if v.into() < lower_fence || v.into() > upper_fence { + outliers.push(v.into()); + } else { + if minimum.is_none() { + minimum = Some(v.into()); + } + maximum = Some(v.into()); + } + } + + assert!(minimum.is_some()); + assert!(maximum.is_some()); + + let mut minimum = minimum.unwrap(); + let mut maximum = maximum.unwrap(); + + //Make sure whiskers don't overlap with body of the upper-median-lower + if minimum > lower { + minimum = lower; + } + if maximum < upper { + maximum = upper; + } + + Self { + minimum, + lower_quartile: lower, + median, + upper_quartile: upper, + maximum, + outliers + } + } + + /// Get the Boxplot values (without outliers). + /// + /// - **returns** The array [minimum, lower quartile, median, upper quartile, maximum] + /// + /// ```rust + /// use plotters::prelude::*; + /// + /// let boxplot_data = BoxplotData::new(&[7, 15, 36, 39, 40, 41]); + /// let values = boxplot_data.values(); + /// assert_eq!(values, [7.0, 20.25, 37.5, 39.75, 41.0]); + /// ``` + pub fn values(&self) -> [f32; 5] { + [ + self.minimum as f32, + self.lower_quartile as f32, + self.median as f32, + self.upper_quartile as f32, + self.maximum as f32, + ] + } + + /// Get the Boxplot data median. + /// + /// - **returns** The median + /// + /// ```rust + /// use plotters::prelude::*; + /// + /// let boxplot_data = BoxplotData::new(&[7, 15, 36, 39, 40, 41]); + /// assert_eq!(boxplot_data.median(), 37.5); + /// ``` + pub fn median(&self) -> f64 { + self.median + } +} +/// The BoxplotOutliers element +pub struct BoxplotOutliers> { + style: ShapeStyle, + width: u32, + whisker_width: f64, + offset: f64, + key: K, + values: [f32; 5], + outliers: Vec, + _p: PhantomData, +} + +impl BoxplotOutliers> { + /// Create a new vertical BoxplotOutliers element. + /// + /// - `key`: The key (the X axis value) + /// - `boxplot_data`: The boxplot_data for the Y axis + /// - **returns** The newly created BoxplotOutliers element + /// + /// ```rust + /// use plotters::prelude::*; + /// + /// let boxplot_data = BoxplotData::new(&[7, 15, 36, 39, 40, 41]); + /// let plot = BoxplotOutliers::new_vertical("group", &boxplot_data); + /// ``` + pub fn new_vertical(key: K, boxplot_data: &BoxplotData) -> Self { + let outliers = boxplot_data.outliers.iter().map(|o| *o as f32).collect(); + Self { + style: Into::::into(&BLACK), + width: DEFAULT_WIDTH, + whisker_width: 1.0, + offset: 0.0, + key, + values: boxplot_data.values(), + outliers, + _p: PhantomData, + } + } +} + +impl BoxplotOutliers> { + /// Create a new horizontal BoxplotOutliers element. + /// + /// - `key`: The key (the Y axis value) + /// - `boxplot_data`: The boxplot_data for the X axis + /// - **returns** The newly created BoxplotOutliers element + /// + /// ```rust + /// use plotters::prelude::*; + /// + /// let boxplot_data = BoxplotData::new(&[7, 15, 36, 39, 40, 41]); + /// let plot = BoxplotOutliers::new_vertical("group", &boxplot_data); + /// ``` + pub fn new_horizontal(key: K, boxplot_data: &BoxplotData) -> Self { + let outliers = boxplot_data.outliers.iter().map(|o| *o as f32).collect(); + Self { + style: Into::::into(&BLACK), + width: DEFAULT_WIDTH, + whisker_width: 1.0, + offset: 0.0, + key, + values: boxplot_data.values(), + outliers, + _p: PhantomData, + } + } +} + +impl> BoxplotOutliers { + /// Set the style of the BoxplotOutliers. + /// + /// - `S`: The required style + /// - **returns** The up-to-dated BoxplotOutliers element + /// + /// ```rust + /// use plotters::prelude::*; + /// + /// let boxplot_data = BoxplotData::new(&[7, 15, 36, 39, 40, 41]); + /// let plot = BoxplotOutliers::new_horizontal("group", &boxplot_data).style(&BLUE); + /// ``` + pub fn style>(mut self, style: S) -> Self { + self.style = style.into(); + self + } + + /// Set the bar width. + /// + /// - `width`: The required width + /// - **returns** The up-to-dated BoxplotOutliers element + /// + /// ```rust + /// use plotters::prelude::*; + /// + /// let boxplot_data = BoxplotData::new(&[7, 15, 36, 39, 40, 41]); + /// let plot = BoxplotOutliers::new_horizontal("group", &boxplot_data).width(10); + /// ``` + pub fn width(mut self, width: u32) -> Self { + self.width = width; + self + } + + /// Set the width of the whiskers as a fraction of the bar width. + /// + /// - `whisker_width`: The required fraction + /// - **returns** The up-to-dated boxplot element + /// + /// ```rust + /// use plotters::prelude::*; + /// + /// let boxplot_data = BoxplotData::new(&[7, 15, 36, 39, 40, 41]); + /// let plot = BoxplotOutliers::new_horizontal("group", &boxplot_data).whisker_width(0.5); + /// ``` + pub fn whisker_width(mut self, whisker_width: f64) -> Self { + self.whisker_width = whisker_width; + self + } + + /// Set the element offset on the key axis. + /// + /// - `offset`: The required offset (on the X axis for vertical, on the Y axis for horizontal) + /// - **returns** The up-to-dated boxplot element + /// + /// ```rust + /// use plotters::prelude::*; + /// + /// let boxplot_data = BoxplotData::new(&[7, 15, 36, 39, 40, 41]); + /// let plot = BoxplotOutliers::new_horizontal("group", &boxplot_data).offset(-5); + /// ``` + pub fn offset + Copy>(mut self, offset: T) -> Self { + self.offset = offset.into(); + self + } +} + +impl<'a, K: Clone, O: BoxplotOrient> PointCollection<'a, (O::XType, O::YType)> + for &'a BoxplotOutliers +{ + type Point = (O::XType, O::YType); + type IntoIter = Vec; + fn point_iter(self) -> Self::IntoIter { + let mut points: Vec = self.values + .iter() + .map(|v| O::make_coord(self.key.clone(), *v)) + .collect(); + for i in 0..self.outliers.len() { + points.push(O::make_coord(self.key.clone(), self.outliers[i])); + } + points + } +} + +impl> Drawable for BoxplotOutliers { + fn draw>( + &self, + points: I, + backend: &mut DB, + _: (u32, u32), + ) -> Result<(), DrawingErrorKind> { + let points: Vec<_> = points.collect(); + if points.len() >= 5 { + let width = f64::from(self.width); + let moved = |coord| O::with_offset(coord, self.offset); + let start_bar = |coord| O::with_offset(moved(coord), -width / 2.0); + let end_bar = |coord| O::with_offset(moved(coord), width / 2.0); + let start_whisker = + |coord| O::with_offset(moved(coord), -width * self.whisker_width / 2.0); + let end_whisker = + |coord| O::with_offset(moved(coord), width * self.whisker_width / 2.0); + + // |---[ | ]----| + // ^________________ + backend.draw_line( + start_whisker(points[0]), + end_whisker(points[0]), + &self.style, + )?; + + // |---[ | ]----| + // _^^^_____________ + + backend.draw_line( + moved(points[0]), + moved(points[1]), + &self.style.color.to_backend_color(), + )?; + + // |---[ | ]----| + // ____^______^_____ + let corner1 = start_bar(points[3]); + let corner2 = end_bar(points[1]); + let upper_left = (corner1.0.min(corner2.0), corner1.1.min(corner2.1)); + let bottom_right = (corner1.0.max(corner2.0), corner1.1.max(corner2.1)); + backend.draw_rect(upper_left, bottom_right, &self.style, false)?; + + // |---[ | ]----| + // ________^________ + backend.draw_line(start_bar(points[2]), end_bar(points[2]), &self.style)?; + + // |---[ | ]----| + // ____________^^^^_ + backend.draw_line(moved(points[3]), moved(points[4]), &self.style)?; + + // |---[ | ]----| + // ________________^ + backend.draw_line( + start_whisker(points[4]), + end_whisker(points[4]), + &self.style, + )?; + + // o o o |---[ | ]----| oo o + // ^__^_^_____________________^^_^ + for i in 5..points.len() { + backend.draw_circle(moved(points[i]), (width / 2.0) as u32, &self.style, false)?; + } + } + Ok(()) + } +} + +#[cfg(test)] +mod test { + use super::*; + use crate::prelude::*; + + #[test] + fn test_draw_v() { + let root = MockedBackend::new(1024, 768).into_drawing_area(); + let chart = ChartBuilder::on(&root) + .build_cartesian_2d(0..2, 0f32..100f32) + .unwrap(); + + let values = BoxplotData::new(&[6]); + assert!(chart + .plotting_area() + .draw(&BoxplotOutliers::new_vertical(1, &values)) + .is_ok()); + } + + #[test] + fn test_draw_h() { + let root = MockedBackend::new(1024, 768).into_drawing_area(); + let chart = ChartBuilder::on(&root) + .build_cartesian_2d(0f32..100f32, 0..2) + .unwrap(); + + let values = BoxplotData::new(&[6]); + assert!(chart + .plotting_area() + .draw(&BoxplotOutliers::new_horizontal(1, &values)) + .is_ok()); + } + + #[test] + fn test_draw_with_outliers() { + let root = MockedBackend::new(1024, 768).into_drawing_area(); + let chart = ChartBuilder::on(&root) + .build_cartesian_2d(0..2, 0f32..100f32) + .unwrap(); + + let values = BoxplotData::new(&[1,50,50,50,50,50,50,50,50,50,50,50,50,50]); + assert!(chart + .plotting_area() + .draw(&BoxplotOutliers::new_vertical(1, &values)) + .is_ok()); + } + + #[test] + fn test_draw_with_outliers_two_sides() { + let root = MockedBackend::new(1024, 768).into_drawing_area(); + let chart = ChartBuilder::on(&root) + .build_cartesian_2d(0..2, 0f32..100f32) + .unwrap(); + + let values = BoxplotData::new(&[1,50,50,50,50,50,100]); + assert!(chart + .plotting_area() + .draw(&BoxplotOutliers::new_vertical(1, &values)) + .is_ok()); + } +} diff --git a/src/element/mod.rs b/src/element/mod.rs index 41f95fdb..5df61979 100644 --- a/src/element/mod.rs +++ b/src/element/mod.rs @@ -187,6 +187,13 @@ mod boxplot; #[cfg(feature = "boxplot")] pub use boxplot::Boxplot; +#[cfg(feature = "boxplot_outliers")] +mod boxplot_outliers; +#[cfg(feature = "boxplot_outliers")] +pub use boxplot_outliers::BoxplotOutliers; +#[cfg(feature = "boxplot_outliers")] +pub use boxplot_outliers::BoxplotData; + #[cfg(feature = "bitmap_backend")] mod image; #[cfg(feature = "bitmap_backend")] diff --git a/src/lib.rs b/src/lib.rs index 81a297db..1f436609 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -799,6 +799,10 @@ pub mod prelude { #[cfg(feature = "boxplot")] pub use crate::element::Boxplot; + #[cfg(feature = "boxplot_outliers")] + pub use crate::element::BoxplotOutliers; + #[cfg(feature = "boxplot_outliers")] + pub use crate::element::BoxplotData; #[cfg(feature = "candlestick")] pub use crate::element::CandleStick; #[cfg(feature = "errorbar")]