diff --git a/.cargo/config b/.cargo/config new file mode 100644 index 00000000..4ec2f3b8 --- /dev/null +++ b/.cargo/config @@ -0,0 +1,2 @@ +[target.wasm32-unknown-unknown] +runner = 'wasm-bindgen-test-runner' diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 64a485a6..c95e6710 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -56,10 +56,61 @@ fn foo(param_1: u32, param_2: u32) -> u32{ 0 }d ## Top Level Documentation and Readme -Please notice we put almost same content for top level rustdoc and `README.md`. Thus the both part are gennerated by script. +Please notice we put almost same content for top level rustdoc and `README.md`. Thus the both part are generated by script. If you need to modify the readme and documentation, please change the template at [doc-template/readme.template.md](https://github.com/38/plotters/blob/master/doc-template/readme.template.md) and use the following command to synchronize the doc to both `src/lib.rs` and `README.md`. ```bash bash doc-template/update-readme.sh ``` + +## Testing Notes + +As the project is intended to work in various environments, it's important to test its all features. The notes below may help you with that task. + +### Native + +Testing all features: + +```bash +cargo test --all-features +``` + +### WebAssembly + +Wasm target is not tested by default, and you may want to use [wasm-bindgen](https://rustwasm.github.io/docs/wasm-bindgen/wasm-bindgen-test/usage.html) CLI tool. + +Installation: + +```bash +rustup target add wasm32-unknown-unknown +cargo install wasm-bindgen-cli +``` + +Additionally, the web browser and its driver should be available, please see [Configuring Which Browser is Used](https://rustwasm.github.io/wasm-bindgen/wasm-bindgen-test/browsers.html#configuring-which-browser-is-used-1). For example, to use Firefox, its binary (`firefox`) and [geckodriver](https://github.com/mozilla/geckodriver/releases) must be on your `$PATH`. + +Usage (only library tests are supported for now): + +```bash +cargo test --lib --target wasm32-unknown-unknown +``` + +For the debugging you could set the `NO_HEADLESS=1` environment variable to run the tests using the local server instead of the headless browser. + +### Code Coverage + +For for the code coverage information you may want to use [cargo-tarpaulin](https://crates.io/crates/cargo-tarpaulin). Please note that it works with x86_64 GNU/Linux only, and the doc tests coverage require nightly Rust. + +Installation ([pycobertura](https://pypi.python.org/pypi/pycobertura) is used to get the detailed report about the coverage): + +```bash +cargo install cargo-tarpaulin +pip install pycobertura +``` + +Usage: + +```bash +cargo tarpaulin --all-features --run-types Tests Doctests -o Xml --output-dir target/test +pycobertura show target/test/cobertura.xml +``` diff --git a/Cargo.toml b/Cargo.toml index dd22dfbc..683d6b70 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -17,7 +17,6 @@ chrono = { version = "0.4.9", optional = true } svg = { version = "0.6.0", optional = true } palette = { version = "^0.4", default-features = false, optional = true } gif = { version = "^0.10.3", optional = true } -cairo-rs = { version = "0.7.1", optional = true } [target.'cfg(not(target_arch = "wasm32"))'.dependencies] rusttype = "0.8.1" @@ -31,6 +30,11 @@ optional = true default_features = false features = ["jpeg", "png_codec", "bmp"] +[target.'cfg(not(target_arch = "wasm32"))'.dependencies.cairo-rs] +version = "0.7.1" +optional = true +features = ["ps"] + [target.'cfg(target_arch = "wasm32")'.dependencies] js-sys= "0.3.29" wasm-bindgen = "0.2.52" @@ -59,6 +63,9 @@ criterion = "0.3.0" rayon = "1.2.0" rand_xorshift = "0.2.0" +[target.'cfg(target_arch = "wasm32")'.dev-dependencies] +wasm-bindgen-test = "0.3.4" + [[bench]] name = "benchmark" harness = false diff --git a/benches/benches/mod.rs b/benches/benches/mod.rs index 241c3a83..0967b2de 100644 --- a/benches/benches/mod.rs +++ b/benches/benches/mod.rs @@ -1,2 +1,3 @@ +pub mod data; pub mod parallel; pub mod rasterizer; diff --git a/benches/main.rs b/benches/main.rs index c2a90ef0..3ebf8e87 100644 --- a/benches/main.rs +++ b/benches/main.rs @@ -4,5 +4,6 @@ mod benches; criterion_main! { benches::parallel::parallel_group, - benches::rasterizer::rasterizer_group + benches::rasterizer::rasterizer_group, + benches::data::quartiles_group } diff --git a/examples/boxplot.rs b/examples/boxplot.rs new file mode 100644 index 00000000..a28c260c --- /dev/null +++ b/examples/boxplot.rs @@ -0,0 +1,219 @@ +use itertools::Itertools; +use plotters::data::fitting_range; +use plotters::prelude::*; +use std::collections::BTreeMap; +use std::collections::HashMap; +use std::env; +use std::fs; +use std::io::{self, prelude::*, BufReader}; + +fn read_data(reader: BR) -> HashMap<(String, String), Vec> { + let mut ds = HashMap::new(); + for l in reader.lines() { + let line = l.unwrap(); + let tuple: Vec<&str> = line.split('\t').collect(); + if tuple.len() == 3 { + let key = (String::from(tuple[0]), String::from(tuple[1])); + let entry = ds.entry(key).or_insert_with(Vec::new); + entry.push(tuple[2].parse::().unwrap()); + } + } + ds +} + +fn main() -> Result<(), Box> { + let root = SVGBackend::new("plotters-doc-data/boxplot.svg", (1024, 768)).into_drawing_area(); + root.fill(&WHITE)?; + + let (upper, lower) = root.split_vertically(512); + + let args: Vec = env::args().collect(); + + let ds = if args.len() < 2 { + read_data(io::Cursor::new(get_data())) + } else { + let file = fs::File::open(&args[1])?; + read_data(BufReader::new(file)) + }; + let dataset: Vec<(String, String, Quartiles)> = ds + .iter() + .map(|(k, v)| (k.0.clone(), k.1.clone(), Quartiles::new(&v))) + .collect(); + + let category = Category::new( + "Host", + dataset + .iter() + .unique_by(|x| x.0.clone()) + .sorted_by(|a, b| b.2.median().partial_cmp(&a.2.median()).unwrap()) + .map(|x| x.0.clone()) + .collect(), + ); + + let mut colors = (0..).map(Palette99::pick); + let mut offsets = (-12..).step_by(24); + let mut series = BTreeMap::new(); + for x in dataset.iter() { + let entry = series + .entry(x.1.clone()) + .or_insert_with(|| (Vec::new(), colors.next().unwrap(), offsets.next().unwrap())); + entry.0.push((x.0.clone(), &x.2)); + } + + let values: Vec = dataset + .iter() + .map(|x| x.2.values().to_vec()) + .flatten() + .collect(); + let values_range = fitting_range(values.iter()); + + let mut chart = ChartBuilder::on(&upper) + .x_label_area_size(40) + .y_label_area_size(120) + .caption("Ping Boxplot", ("sans-serif", 20).into_font()) + .build_ranged( + values_range.start - 1.0..values_range.end + 1.0, + category.range(), + )?; + + chart + .configure_mesh() + .x_desc("Ping, ms") + .y_desc(category.name()) + .y_labels(category.len()) + .line_style_2(&WHITE) + .draw()?; + + for (label, (values, style, offset)) in &series { + chart + .draw_series(values.iter().map(|x| { + Boxplot::new_horizontal(category.get(&x.0).unwrap(), &x.1) + .width(20) + .whisker_width(0.5) + .style(style) + .offset(*offset) + }))? + .label(label) + .legend(move |(x, y)| Rectangle::new([(x - 5, y - 5), (x + 5, y + 5)], style.filled())); + } + chart + .configure_series_labels() + .position(SeriesLabelPosition::UpperRight) + .background_style(WHITE.filled()) + .border_style(&BLACK.mix(0.5)) + .draw()?; + + let drawing_areas = lower.split_evenly((1, 2)); + let (left, right) = (&drawing_areas[0], &drawing_areas[1]); + + let quartiles_a = Quartiles::new(&[ + 6.0, 7.0, 15.9, 36.9, 39.0, 40.0, 41.0, 42.0, 43.0, 47.0, 49.0, + ]); + let quartiles_b = Quartiles::new(&[16.0, 17.0, 50.0, 60.0, 40.2, 41.3, 42.7, 43.3, 47.0]); + let category_ab = Category::new("", vec!["a", "b"]); + let values_range = fitting_range( + quartiles_a + .values() + .iter() + .chain(quartiles_b.values().iter()), + ); + let mut chart = ChartBuilder::on(&left) + .x_label_area_size(40) + .y_label_area_size(40) + .caption("Vertical Boxplot", ("sans-serif", 20).into_font()) + .build_ranged( + category_ab.get(&"a").unwrap()..category_ab.get(&"b").unwrap(), + values_range.start - 10.0..values_range.end + 10.0, + )?; + + chart.configure_mesh().line_style_2(&WHITE).draw()?; + chart.draw_series(vec![ + Boxplot::new_vertical(category_ab.get(&"a").unwrap(), &quartiles_a), + Boxplot::new_vertical(category_ab.get(&"b").unwrap(), &quartiles_b), + ])?; + + let mut chart = ChartBuilder::on(&right) + .x_label_area_size(40) + .y_label_area_size(40) + .caption("Horizontal Boxplot", ("sans-serif", 20).into_font()) + .build_ranged(-30f32..90f32, 0..3)?; + + chart.configure_mesh().line_style_2(&WHITE).draw()?; + chart.draw_series(vec![ + Boxplot::new_horizontal(1, &quartiles_a), + Boxplot::new_horizontal(2, &Quartiles::new(&[30])), + ])?; + + Ok(()) +} + +fn get_data() -> String { + String::from( + " + 1.1.1.1 wireless 41.6 + 1.1.1.1 wireless 32.5 + 1.1.1.1 wireless 33.1 + 1.1.1.1 wireless 32.3 + 1.1.1.1 wireless 36.7 + 1.1.1.1 wireless 32.0 + 1.1.1.1 wireless 33.1 + 1.1.1.1 wireless 32.0 + 1.1.1.1 wireless 32.9 + 1.1.1.1 wireless 32.7 + 1.1.1.1 wireless 34.5 + 1.1.1.1 wireless 36.5 + 1.1.1.1 wireless 31.9 + 1.1.1.1 wireless 33.7 + 1.1.1.1 wireless 32.6 + 1.1.1.1 wireless 35.1 + 8.8.8.8 wireless 42.3 + 8.8.8.8 wireless 32.9 + 8.8.8.8 wireless 32.9 + 8.8.8.8 wireless 34.3 + 8.8.8.8 wireless 32.0 + 8.8.8.8 wireless 33.3 + 8.8.8.8 wireless 31.5 + 8.8.8.8 wireless 33.1 + 8.8.8.8 wireless 33.2 + 8.8.8.8 wireless 35.9 + 8.8.8.8 wireless 42.3 + 8.8.8.8 wireless 34.1 + 8.8.8.8 wireless 34.2 + 8.8.8.8 wireless 34.2 + 8.8.8.8 wireless 32.4 + 8.8.8.8 wireless 33.0 + 1.1.1.1 wired 31.8 + 1.1.1.1 wired 28.6 + 1.1.1.1 wired 29.4 + 1.1.1.1 wired 28.8 + 1.1.1.1 wired 28.2 + 1.1.1.1 wired 28.8 + 1.1.1.1 wired 28.4 + 1.1.1.1 wired 28.6 + 1.1.1.1 wired 28.3 + 1.1.1.1 wired 28.5 + 1.1.1.1 wired 28.5 + 1.1.1.1 wired 28.5 + 1.1.1.1 wired 28.4 + 1.1.1.1 wired 28.6 + 1.1.1.1 wired 28.4 + 1.1.1.1 wired 28.9 + 8.8.8.8 wired 33.3 + 8.8.8.8 wired 28.4 + 8.8.8.8 wired 28.7 + 8.8.8.8 wired 29.1 + 8.8.8.8 wired 29.6 + 8.8.8.8 wired 28.9 + 8.8.8.8 wired 28.6 + 8.8.8.8 wired 29.3 + 8.8.8.8 wired 28.6 + 8.8.8.8 wired 29.1 + 8.8.8.8 wired 28.7 + 8.8.8.8 wired 28.3 + 8.8.8.8 wired 28.3 + 8.8.8.8 wired 28.6 + 8.8.8.8 wired 29.4 + 8.8.8.8 wired 33.1 +", + ) +} diff --git a/examples/console.rs b/examples/console.rs index 75f2a6b0..1a487565 100644 --- a/examples/console.rs +++ b/examples/console.rs @@ -18,16 +18,16 @@ enum PixelState { } impl PixelState { - fn to_char(&self) -> char { + fn to_char(self) -> char { match self { Self::Empty => ' ', Self::HLine => '-', Self::VLine => '|', Self::Cross => '+', Self::Pixel => '.', - Self::Text(c) => *c, + Self::Text(c) => c, Self::Circle(filled) => { - if *filled { + if filled { '@' } else { 'O' @@ -35,6 +35,7 @@ impl PixelState { } } } + fn update(&mut self, new_state: PixelState) { let next_state = match (*self, new_state) { (Self::HLine, Self::VLine) => Self::Cross, @@ -123,12 +124,11 @@ impl DrawingBackend for TextDrawingBackend { Ok((text.len() as u32, 1)) } - fn draw_text<'a>( + fn draw_text( &mut self, text: &str, - _font: &FontDesc<'a>, + _style: &TextStyle, pos: (i32, i32), - _color: &RGBAColor, ) -> Result<(), DrawingErrorKind> { let offset = pos.1.max(0) * 100 + pos.0.max(0); for (idx, chr) in (offset..).zip(text.chars()) { @@ -150,7 +150,7 @@ where .set_label_area_size(LabelAreaPosition::Left, (5i32).percent_width()) .set_label_area_size(LabelAreaPosition::Bottom, (10i32).percent_height()) .set_label_area_size(LabelAreaPosition::Bottom, (10i32).percent_height()) - .build_ranged(-3.14..3.14, -1.2..1.2)?; + .build_ranged(-std::f64::consts::PI..std::f64::consts::PI, -1.2..1.2)?; chart .configure_mesh() diff --git a/examples/piston-demo/README.md b/examples/piston-demo/README.md index a45cb360..b99d348d 100644 --- a/examples/piston-demo/README.md +++ b/examples/piston-demo/README.md @@ -1,6 +1,6 @@ # Realtime CPU Usage by Plotters + Piston -This is a demo that demonstrate using Plotters along with Piston for dynmaic rendering. +This is a demo that demonstrate using Plotters along with Piston for dynamic rendering. To try the demo use diff --git a/examples/wasm-demo/www/package.json b/examples/wasm-demo/www/package.json index 9f0d16ff..48b3dbe2 100644 --- a/examples/wasm-demo/www/package.json +++ b/examples/wasm-demo/www/package.json @@ -16,7 +16,7 @@ "wasm", "rust", "webpack", - "visualization" + "visualization" ], "author": "Plotters Developers", "license": "MIT", diff --git a/src/chart/context.rs b/src/chart/context.rs index 3ac102bb..34bf7ca7 100644 --- a/src/chart/context.rs +++ b/src/chart/context.rs @@ -14,7 +14,7 @@ use crate::coord::{ use crate::drawing::backend::{BackendCoord, DrawingBackend}; use crate::drawing::{DrawingArea, DrawingAreaErrorKind}; use crate::element::{Drawable, DynElement, IntoDynElement, PathElement, PointCollection}; -use crate::style::{AsRelative, FontTransform, ShapeStyle, SizeDesc, TextStyle}; +use crate::style::{AsRelative, FontTransform, ShapeStyle, SizeDesc, TextAlignment, TextStyle}; /// The annotations (such as the label of the series, the legend element, etc) /// When a series is drawn onto a drawing area, an series annotation object @@ -181,23 +181,21 @@ impl< > ChartContext<'a, DB, RangedCoord> { fn is_overlapping_drawing_area(&self, area: Option<&DrawingArea>) -> bool { - let area = if area.is_none() { - return false; + if let Some(area) = area { + let (x0, y0) = area.get_base_pixel(); + let (w, h) = area.dim_in_pixel(); + let (x1, y1) = (x0 + w as i32, y0 + h as i32); + let (dx0, dy0) = self.drawing_area.get_base_pixel(); + let (w, h) = self.drawing_area.dim_in_pixel(); + let (dx1, dy1) = (dx0 + w as i32, dy0 + h as i32); + + let (ox0, ox1) = (x0.max(dx0), x1.min(dx1)); + let (oy0, oy1) = (y0.max(dy0), y1.min(dy1)); + + ox1 > ox0 && oy1 > oy0 } else { - area.unwrap() - }; - - let (x0, y0) = area.get_base_pixel(); - let (w, h) = area.dim_in_pixel(); - let (x1, y1) = (x0 + w as i32, y0 + h as i32); - let (dx0, dy0) = self.drawing_area.get_base_pixel(); - let (w, h) = self.drawing_area.dim_in_pixel(); - let (dx1, dy1) = (dx0 + w as i32, dy0 + h as i32); - - let (ox0, ox1) = (x0.max(dx0), x1.min(dx1)); - let (oy0, oy1) = (y0.max(dy0), y1.min(dy1)); - - ox1 > ox0 && oy1 > oy0 + false + } } /// Initialize a mesh configuration object and mesh drawing can be finalized by calling @@ -524,6 +522,9 @@ impl<'a, DB: DrawingBackend, X: Ranged, Y: Ranged> ChartContext<'a, DB, RangedCo tick_size.abs() * 2 }; + /* All labels are right-aligned. */ + let label_style = &label_style.alignment(TextAlignment::Right); + /* Draw the axis and get the axis range so that we can do further label * and tick mark drawing */ let axis_range = self.draw_axis(area, axis_style, orientation, tick_size < 0)?; diff --git a/src/coord/category.rs b/src/coord/category.rs new file mode 100644 index 00000000..931dc8e5 --- /dev/null +++ b/src/coord/category.rs @@ -0,0 +1,238 @@ +use std::fmt; +use std::ops::Range; +use std::rc::Rc; + +use super::{AsRangedCoord, Ranged}; + +/// The category coordinate +pub struct Category { + name: String, + elements: Rc>, + // i32 type is required for the empty ref (having -1 value) + idx: i32, +} + +/// The category elements range +pub struct CategoryElementsRange(Category, Category); + +impl Clone for Category { + fn clone(&self) -> Self { + Category { + name: self.name.clone(), + elements: Rc::clone(&self.elements), + idx: self.idx, + } + } +} + +impl fmt::Debug for Category { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + let element = &self.elements[self.idx as usize]; + write!(f, "{}", element) + } +} + +impl Category { + /// Create a new category coordinate. + /// + /// - `name`: The name of the category + /// - `elements`: The vector of category elements + /// - **returns** The newly created category coordinate + /// + /// ```rust + /// use plotters::prelude::*; + /// + /// let category = Category::new("color", vec!["red", "green", "blue"]); + /// ``` + pub fn new>(name: S, elements: Vec) -> Self { + Self { + name: name.into(), + elements: Rc::new(elements), + idx: -1, + } + } + + /// Get an element reference (tick) by its value. + /// + /// - `val`: The value of the element + /// - **returns** The optional reference + /// + /// ```rust + /// use plotters::prelude::*; + /// + /// let category = Category::new("color", vec!["red", "green", "blue"]); + /// let red = category.get(&"red"); + /// assert!(red.is_some()); + /// let unknown = category.get(&"unknown"); + /// assert!(unknown.is_none()); + /// ``` + pub fn get(&self, val: &T) -> Option> { + match self.elements.iter().position(|x| x == val) { + Some(pos) => { + let element_ref = Category { + name: self.name.clone(), + elements: Rc::clone(&self.elements), + idx: pos as i32, + }; + Some(element_ref) + } + _ => None, + } + } + + /// Create a full range over the category elements. + /// + /// - **returns** The range including all category elements + /// + /// ```rust + /// use plotters::prelude::*; + /// + /// let category = Category::new("color", vec!["red", "green", "blue"]); + /// let range = category.range(); + /// ``` + pub fn range(&self) -> CategoryElementsRange { + let start = 0; + let end = self.elements.len() as i32 - 1; + CategoryElementsRange( + Category { + name: self.name.clone(), + elements: Rc::clone(&self.elements), + idx: start, + }, + Category { + name: self.name.clone(), + elements: Rc::clone(&self.elements), + idx: end, + }, + ) + } + + /// Get the number of elements in the category. + /// + /// - **returns** The number of elements + /// + /// ```rust + /// use plotters::prelude::*; + /// + /// let category = Category::new("color", vec!["red", "green", "blue"]); + /// assert_eq!(category.len(), 3); + /// ``` + pub fn len(&self) -> usize { + self.elements.len() + } + + /// Returns `true` if the category contains no elements. + /// + /// - **returns** `true` is no elements, otherwise - `false` + /// + /// ```rust + /// use plotters::prelude::*; + /// + /// let category = Category::new("color", vec!["red", "green", "blue"]); + /// assert_eq!(category.is_empty(), false); + /// + /// let category = Category::new("empty", Vec::<&str>::new()); + /// assert_eq!(category.is_empty(), true); + /// ``` + pub fn is_empty(&self) -> bool { + self.elements.is_empty() + } + + /// Get the category name. + /// + /// - **returns** The name of the category + /// + /// ```rust + /// use plotters::prelude::*; + /// + /// let category = Category::new("color", vec!["red", "green", "blue"]); + /// assert_eq!(category.name(), "color"); + /// ``` + pub fn name(&self) -> String { + self.name.clone() + } +} + +impl From>> for CategoryElementsRange { + fn from(range: Range>) -> Self { + Self(range.start, range.end) + } +} + +impl Ranged for CategoryElementsRange { + type ValueType = Category; + + fn range(&self) -> Range> { + self.0.clone()..self.1.clone() + } + + fn map(&self, value: &Self::ValueType, limit: (i32, i32)) -> i32 { + // Add margins to spans as edge values are not applicable to category + let total_span = (self.1.idx - self.0.idx + 2) as f64; + let value_span = (value.idx - self.0.idx + 1) as f64; + (f64::from(limit.1 - limit.0) * value_span / total_span) as i32 + limit.0 + } + + fn key_points(&self, max_points: usize) -> Vec { + let mut ret = vec![]; + let intervals = (self.1.idx - self.0.idx) as f64; + let elements = &self.0.elements; + let name = &self.0.name; + let step = (intervals / max_points as f64 + 1.0) as usize; + for idx in (self.0.idx..=self.1.idx).step_by(step) { + ret.push(Category { + name: name.clone(), + elements: Rc::clone(&elements), + idx, + }); + } + ret + } +} + +impl AsRangedCoord for Range> { + type CoordDescType = CategoryElementsRange; + type Value = Category; +} + +#[cfg(test)] +mod test { + use super::*; + + #[test] + fn test_clone_trait() { + let category = Category::new("color", vec!["red", "green", "blue"]); + let red = category.get(&"red").unwrap(); + assert_eq!(red.idx, 0); + let clone = red.clone(); + assert_eq!(clone.idx, 0); + } + + #[test] + fn test_debug_trait() { + let category = Category::new("color", vec!["red", "green", "blue"]); + let red = category.get(&"red").unwrap(); + assert_eq!(format!("{:?}", red), "red"); + } + + #[test] + fn test_from_range_trait() { + let category = Category::new("color", vec!["red", "green", "blue"]); + let range = category.get(&"red").unwrap()..category.get(&"blue").unwrap(); + let elements_range = CategoryElementsRange::from(range); + assert_eq!(elements_range.0.idx, 0); + assert_eq!(elements_range.1.idx, 2); + } + + #[test] + fn test_ranged_trait() { + let category = Category::new("color", vec!["red", "green", "blue"]); + let elements_range = category.range(); + let range = elements_range.range(); + let elements_range = CategoryElementsRange::from(range); + assert_eq!(elements_range.0.idx, 0); + assert_eq!(elements_range.1.idx, 2); + assert_eq!(elements_range.map(&elements_range.0, (10, 20)), 12); + assert_eq!(elements_range.key_points(5).len(), 3); + } +} diff --git a/src/coord/datetime.rs b/src/coord/datetime.rs index c94eb3ee..cb96f937 100644 --- a/src/coord/datetime.rs +++ b/src/coord/datetime.rs @@ -136,7 +136,7 @@ impl Ranged for RangedDate { impl DiscreteRanged for RangedDate { type RangeParameter = (); - fn get_range_parameter(&self) -> () {} + fn get_range_parameter(&self) {} fn next_value(this: &Date, _: &()) -> Date { this.clone() + Duration::days(1) } @@ -268,7 +268,7 @@ impl Ranged for Monthly { impl DiscreteRanged for Monthly { type RangeParameter = (); - fn get_range_parameter(&self) -> () {} + fn get_range_parameter(&self) {} fn next_value(this: &T, _: &()) -> T { let mut year = this.date_ceil().year(); let mut month = this.date_ceil().month(); @@ -381,7 +381,7 @@ impl Ranged for Yearly { impl DiscreteRanged for Yearly { type RangeParameter = (); - fn get_range_parameter(&self) -> () {} + fn get_range_parameter(&self) {} fn next_value(this: &T, _: &()) -> T { T::earliest_after_date(this.timezone().ymd(this.date_floor().year() + 1, 1, 1)) } diff --git a/src/coord/mod.rs b/src/coord/mod.rs index 055591f5..0afafa32 100644 --- a/src/coord/mod.rs +++ b/src/coord/mod.rs @@ -22,6 +22,7 @@ Also, the ranged axis can be deserted, and this is required by the histogram ser */ use crate::drawing::backend::BackendCoord; +mod category; #[cfg(feature = "chrono")] mod datetime; mod logarithmic; @@ -47,6 +48,8 @@ pub use numeric::group_integer_by::{GroupBy, ToGroupByRange}; use std::rc::Rc; use std::sync::Arc; +pub use category::Category; + /// The trait that translates some customized object to the backend coordinate pub trait CoordTranslate { type From; diff --git a/src/data/data_range.rs b/src/data/data_range.rs index 4be7f2fc..445260b9 100644 --- a/src/data/data_range.rs +++ b/src/data/data_range.rs @@ -5,6 +5,17 @@ use std::ops::Range; use num_traits::{One, Zero}; /// Build a range that fits the data +/// +/// - `iter`: the iterator over the data +/// - **returns** The resulting range +/// +/// ```rust +/// use plotters::data::fitting_range; +/// +/// let data = [4, 14, -2, 2, 5]; +/// let range = fitting_range(&data); +/// assert_eq!(range, std::ops::Range { start: -2, end: 14 }); +/// ``` pub fn fitting_range<'a, T: 'a, I: IntoIterator>(iter: I) -> Range where T: Zero + One + PartialOrd + Clone, @@ -12,22 +23,20 @@ where let (mut lb, mut ub) = (None, None); for value in iter.into_iter() { - match lb + if let Some(Ordering::Greater) = lb .as_ref() .map_or(Some(Ordering::Greater), |lbv: &T| lbv.partial_cmp(value)) { - Some(Ordering::Greater) => lb = Some(value.clone()), - _ => {} + lb = Some(value.clone()); } - match ub + if let Some(Ordering::Less) = ub .as_ref() .map_or(Some(Ordering::Less), |ubv: &T| ubv.partial_cmp(value)) { - Some(Ordering::Less) => ub = Some(value.clone()), - _ => {} + ub = Some(value.clone()); } } - lb.unwrap_or(Zero::zero())..ub.unwrap_or(One::one()) + lb.unwrap_or_else(Zero::zero)..ub.unwrap_or_else(One::one) } diff --git a/src/data/mod.rs b/src/data/mod.rs index 4ec6acbe..589dbb39 100644 --- a/src/data/mod.rs +++ b/src/data/mod.rs @@ -1,8 +1,10 @@ /*! -The data processing module, which implements algorithm related to visualization of data. +The data processing module, which implements algorithms related to visualization of data. Such as, down-sampling, etc. */ mod data_range; - pub use data_range::fitting_range; + +mod quartiles; +pub use quartiles::Quartiles; diff --git a/src/data/quartiles.rs b/src/data/quartiles.rs new file mode 100644 index 00000000..054f51d1 --- /dev/null +++ b/src/data/quartiles.rs @@ -0,0 +1,127 @@ +/// The quartiles +#[derive(Clone, Debug)] +pub struct Quartiles { + lower_fence: f64, + lower: f64, + median: f64, + upper: f64, + upper_fence: f64, +} + +impl Quartiles { + // Extract a value representing the `pct` percentile of a + // sorted `s`, using linear interpolation. + 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 quartiles struct with the values calculated from the argument. + /// + /// - `s`: The array of the original values + /// - **returns** The newly created quartiles + /// + /// ```rust + /// use plotters::prelude::*; + /// + /// let quartiles = Quartiles::new(&[7, 15, 36, 39, 40, 41]); + /// assert_eq!(quartiles.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 = Quartiles::percentile_of_sorted(&s, 25_f64); + let median = Quartiles::percentile_of_sorted(&s, 50_f64); + let upper = Quartiles::percentile_of_sorted(&s, 75_f64); + let iqr = upper - lower; + let lower_fence = lower - 1.5 * iqr; + let upper_fence = upper + 1.5 * iqr; + Self { + lower_fence, + lower, + median, + upper, + upper_fence, + } + } + + /// Get the quartiles values. + /// + /// - **returns** The array [lower fence, lower quartile, median, upper quartile, upper fence] + /// + /// ```rust + /// use plotters::prelude::*; + /// + /// let quartiles = Quartiles::new(&[7, 15, 36, 39, 40, 41]); + /// let values = quartiles.values(); + /// assert_eq!(values, [-9.0, 20.25, 37.5, 39.75, 69.0]); + /// ``` + pub fn values(&self) -> [f32; 5] { + [ + self.lower_fence as f32, + self.lower as f32, + self.median as f32, + self.upper as f32, + self.upper_fence as f32, + ] + } + + /// Get the quartiles median. + /// + /// - **returns** The median + /// + /// ```rust + /// use plotters::prelude::*; + /// + /// let quartiles = Quartiles::new(&[7, 15, 36, 39, 40, 41]); + /// assert_eq!(quartiles.median(), 37.5); + /// ``` + pub fn median(&self) -> f64 { + self.median + } +} + +#[cfg(test)] +mod test { + use super::*; + + #[test] + #[should_panic] + fn test_empty_input() { + let empty_array: [i32; 0] = []; + Quartiles::new(&empty_array); + } + + #[test] + fn test_low_inputs() { + assert_eq!( + Quartiles::new(&[15.0]).values(), + [15.0, 15.0, 15.0, 15.0, 15.0] + ); + assert_eq!( + Quartiles::new(&[10, 20]).values(), + [5.0, 12.5, 15.0, 17.5, 25.0] + ); + assert_eq!( + Quartiles::new(&[10, 20, 30]).values(), + [0.0, 15.0, 20.0, 25.0, 40.0] + ); + } +} diff --git a/src/drawing/area.rs b/src/drawing/area.rs index 1cebfe63..4886da8c 100644 --- a/src/drawing/area.rs +++ b/src/drawing/area.rs @@ -2,7 +2,7 @@ use super::backend::{BackendCoord, DrawingBackend, DrawingErrorKind}; use crate::coord::{CoordTranslate, MeshLine, Ranged, RangedCoord, Shift}; use crate::element::{Drawable, PointCollection}; -use crate::style::{Color, FontDesc, SizeDesc, TextStyle}; +use crate::style::{Color, FontDesc, SizeDesc, TextAlignment, TextStyle}; use std::borrow::Borrow; use std::cell::RefCell; @@ -481,13 +481,13 @@ impl DrawingArea { }; let y_padding = (text_h / 2).min(5) as i32; + let style = &style.alignment(TextAlignment::Center); self.backend_ops(|b| { b.draw_text( text, - &style.font, + &style, (self.rect.x0 + x_padding, self.rect.y0 + y_padding), - &style.color, ) })?; @@ -511,12 +511,7 @@ impl DrawingArea { pos: BackendCoord, ) -> Result<(), DrawingAreaError> { self.backend_ops(|b| { - b.draw_text( - text, - &style.font, - (pos.0 + self.rect.x0, pos.1 + self.rect.y0), - &style.color, - ) + b.draw_text(text, &style, (pos.0 + self.rect.x0, pos.1 + self.rect.y0)) }) } } diff --git a/src/drawing/backend.rs b/src/drawing/backend.rs index 9b27ba69..d75675e9 100644 --- a/src/drawing/backend.rs +++ b/src/drawing/backend.rs @@ -1,4 +1,4 @@ -use crate::style::{Color, FontDesc, FontError, RGBAColor, ShapeStyle}; +use crate::style::{Color, FontDesc, FontError, RGBAColor, ShapeStyle, TextStyle}; use std::error::Error; /// A coordinate in the image @@ -177,16 +177,16 @@ pub trait DrawingBackend: Sized { /// Draw a text on the drawing backend /// - `text`: The text to draw - /// - `font`: The description of the font + /// - `style`: The text style /// - `pos` : The position backend - /// - `color`: The color of the text - fn draw_text<'a>( + fn draw_text( &mut self, text: &str, - font: &FontDesc<'a>, + style: &TextStyle, pos: BackendCoord, - color: &RGBAColor, ) -> Result<(), DrawingErrorKind> { + let font = &style.font; + let color = &style.color; if color.alpha() == 0.0 { return Ok(()); } diff --git a/src/drawing/backend_impl/cairo.rs b/src/drawing/backend_impl/cairo.rs index d3155cb1..c0504996 100644 --- a/src/drawing/backend_impl/cairo.rs +++ b/src/drawing/backend_impl/cairo.rs @@ -3,7 +3,7 @@ use cairo::{Context as CairoContext, FontSlant, FontWeight, Status as CairoStatu #[allow(unused_imports)] use crate::drawing::backend::{BackendCoord, BackendStyle, DrawingBackend, DrawingErrorKind}; #[allow(unused_imports)] -use crate::style::{Color, FontDesc, FontStyle, FontTransform, RGBAColor}; +use crate::style::{Color, FontStyle, FontTransform, RGBAColor, TextStyle}; /// The drawing backend that is backed with a Cairo context pub struct CairoBackend<'a> { @@ -227,13 +227,14 @@ impl<'a> DrawingBackend for CairoBackend<'a> { Ok(()) } - fn draw_text<'b>( + fn draw_text( &mut self, text: &str, - font: &FontDesc<'b>, + style: &TextStyle, pos: BackendCoord, - color: &RGBAColor, ) -> Result<(), DrawingErrorKind> { + let font = &style.font; + let color = &style.color; let (mut x, mut y) = (pos.0, pos.1); let degree = match font.get_transform() { @@ -281,3 +282,49 @@ impl<'a> DrawingBackend for CairoBackend<'a> { Ok(()) } } + +#[cfg(test)] +mod test { + use super::*; + use crate::prelude::*; + use std::fs; + use std::path::Path; + + static DST_DIR: &str = "target/test/cairo"; + + #[test] + fn test_draw_mesh() { + let buffer: Vec = vec![]; + let surface = cairo::PsSurface::for_stream(1024.0, 768.0, buffer); + let cr = CairoContext::new(&surface); + let root = CairoBackend::new(&cr, (500, 500)) + .unwrap() + .into_drawing_area(); + root.fill(&WHITE).unwrap(); + + // Text could be rendered to different elements if has whitespaces + let mut chart = ChartBuilder::on(&root) + .caption("this-is-a-test", ("sans-serif", 20)) + .x_label_area_size(40) + .y_label_area_size(40) + .build_ranged(0..100, 0..100) + .unwrap(); + + chart.configure_mesh().draw().unwrap(); + + let buffer = *surface.finish_output_stream().unwrap().downcast().unwrap(); + let content = String::from_utf8(buffer).unwrap(); + + /* + Please use the PS file to manually verify the results. + + You may want to use `ps2pdf` to get the readable PDF file. + */ + fs::create_dir_all(DST_DIR).unwrap(); + let file_path = Path::new(DST_DIR).join("test_draw_mesh.ps"); + println!("{:?} created", file_path); + fs::write(file_path, &content).unwrap(); + + assert!(content.contains("this-is-a-test")); + } +} diff --git a/src/drawing/backend_impl/canvas.rs b/src/drawing/backend_impl/canvas.rs index 6a981826..3a334c98 100644 --- a/src/drawing/backend_impl/canvas.rs +++ b/src/drawing/backend_impl/canvas.rs @@ -3,7 +3,7 @@ use wasm_bindgen::{JsCast, JsValue}; use web_sys::{window, CanvasRenderingContext2d, HtmlCanvasElement}; use crate::drawing::backend::{BackendCoord, BackendStyle, DrawingBackend, DrawingErrorKind}; -use crate::style::{Color, FontDesc, FontTransform, RGBAColor}; +use crate::style::{Color, FontTransform, RGBAColor, TextStyle}; /// The backend that is drawing on the HTML canvas /// TODO: Support double buffering @@ -230,13 +230,14 @@ impl DrawingBackend for CanvasBackend { Ok(()) } - fn draw_text<'b>( + fn draw_text( &mut self, text: &str, - font: &FontDesc<'b>, + style: &TextStyle, pos: BackendCoord, - color: &RGBAColor, ) -> Result<(), DrawingErrorKind> { + let font = &style.font; + let color = &style.color; if color.alpha() == 0.0 { return Ok(()); } @@ -281,3 +282,54 @@ impl DrawingBackend for CanvasBackend { Ok(()) } } + +#[cfg(test)] +mod test { + use super::*; + use crate::prelude::*; + use wasm_bindgen_test::wasm_bindgen_test_configure; + use wasm_bindgen_test::*; + + wasm_bindgen_test_configure!(run_in_browser); + + #[wasm_bindgen_test] + fn test_draw_mesh() { + let document = web_sys::window().unwrap().document().unwrap(); + let canvas = document + .create_element("canvas") + .unwrap() + .dyn_into::() + .unwrap(); + canvas.set_attribute("id", "canvas-id").unwrap(); + document.body().unwrap().append_child(&canvas).unwrap(); + canvas.set_width(100); + canvas.set_height(100); + + let backend = CanvasBackend::with_canvas_object(canvas).expect("cannot find canvas"); + let root = backend.into_drawing_area(); + + let mut chart = ChartBuilder::on(&root) + .caption("This is a test", ("sans-serif", 10)) + .x_label_area_size(30) + .y_label_area_size(30) + .build_ranged(-1f32..1f32, -1.2f32..1.2f32) + .unwrap(); + + chart + .configure_mesh() + .x_labels(3) + .y_labels(3) + .draw() + .unwrap(); + + let canvas = document + .get_element_by_id("canvas-id") + .unwrap() + .dyn_into::() + .unwrap(); + + let data_uri = canvas.to_data_url().unwrap(); + let prefix = "data:image/png;base64,"; + assert!(&data_uri.starts_with(prefix)); + } +} diff --git a/src/drawing/backend_impl/mocked.rs b/src/drawing/backend_impl/mocked.rs index 3eef971e..b416ba9c 100644 --- a/src/drawing/backend_impl/mocked.rs +++ b/src/drawing/backend_impl/mocked.rs @@ -2,7 +2,7 @@ use crate::coord::Shift; use crate::drawing::area::IntoDrawingArea; use crate::drawing::backend::{BackendCoord, BackendStyle, DrawingBackend, DrawingErrorKind}; use crate::drawing::DrawingArea; -use crate::style::{Color, FontDesc, RGBAColor}; +use crate::style::{Color, RGBAColor, TextStyle}; use std::collections::VecDeque; @@ -232,13 +232,14 @@ impl DrawingBackend for MockedBackend { Ok(()) } - fn draw_text<'a>( + fn draw_text( &mut self, text: &str, - font: &FontDesc<'a>, + style: &TextStyle, pos: BackendCoord, - color: &RGBAColor, ) -> Result<(), DrawingErrorKind> { + let font = &style.font; + let color = &style.color; self.check_before_draw(); self.num_draw_text_call += 1; let color = color.to_rgba(); diff --git a/src/drawing/backend_impl/svg.rs b/src/drawing/backend_impl/svg.rs index a91f7b4e..8598ac0d 100644 --- a/src/drawing/backend_impl/svg.rs +++ b/src/drawing/backend_impl/svg.rs @@ -7,7 +7,7 @@ use svg::node::element::{Circle, Line, Polygon, Polyline, Rectangle, Text}; use svg::Document; use crate::drawing::backend::{BackendCoord, BackendStyle, DrawingBackend, DrawingErrorKind}; -use crate::style::{Color, FontDesc, FontStyle, FontTransform, RGBAColor}; +use crate::style::{Color, FontStyle, FontTransform, RGBAColor, TextAlignment, TextStyle}; use std::io::{Cursor, Error}; use std::path::Path; @@ -235,13 +235,15 @@ impl<'a> DrawingBackend for SVGBackend<'a> { self.update_document(|d| d.add(node)); Ok(()) } - fn draw_text<'b>( + + fn draw_text( &mut self, text: &str, - font: &FontDesc<'b>, + style: &TextStyle, pos: BackendCoord, - color: &RGBAColor, ) -> Result<(), DrawingErrorKind> { + let font = &style.font; + let color = &style.color; if color.alpha() == 0.0 { return Ok(()); } @@ -253,9 +255,16 @@ impl<'a> DrawingBackend for SVGBackend<'a> { let x0 = pos.0 + offset.0; let y0 = pos.1 + offset.1; + let max_x = (layout.1).0; + let (dx, anchor) = match style.alignment { + TextAlignment::Left => (0, "start"), + TextAlignment::Right => (max_x, "end"), + TextAlignment::Center => (max_x / 2, "middle"), + }; let node = Text::new() - .set("x", x0) + .set("x", x0 + dx) .set("y", y0 - (layout.0).1) + .set("text-anchor", anchor) .set("font-family", font.get_name()) .set("font-size", font.get_size()) .set("opacity", make_svg_opacity(color)) @@ -375,3 +384,81 @@ impl Drop for SVGBackend<'_> { } } } + +#[cfg(test)] +mod test { + use super::*; + use crate::prelude::*; + use std::fs; + use std::path::Path; + + static DST_DIR: &str = "target/test/svg"; + + fn save_file(name: &str, content: &str) { + /* + Please use the SVG file to manually verify the results. + */ + fs::create_dir_all(DST_DIR).unwrap(); + let file_name = format!("{}.svg", name); + let file_path = Path::new(DST_DIR).join(file_name); + println!("{:?} created", file_path); + fs::write(file_path, &content).unwrap(); + } + + #[test] + fn test_draw_mesh() { + let mut buffer: Vec = vec![]; + { + let root = SVGBackend::with_buffer(&mut buffer, (500, 500)).into_drawing_area(); + + let mut chart = ChartBuilder::on(&root) + .caption("This is a test", ("sans-serif", 20)) + .x_label_area_size(40) + .y_label_area_size(40) + .build_ranged(0..100, 0..100) + .unwrap(); + + chart.configure_mesh().draw().unwrap(); + } + + let content = String::from_utf8(buffer).unwrap(); + save_file("test_draw_mesh", &content); + + assert!(content.contains("This is a test")); + } + + #[test] + fn test_text_alignments() { + let mut buffer: Vec = vec![]; + { + let mut root = SVGBackend::with_buffer(&mut buffer, (500, 500)); + + let style = + TextStyle::from(("sans-serif", 20).into_font()).alignment(TextAlignment::Right); + root.draw_text("right-align", &style, (150, 50)).unwrap(); + + let style = style.alignment(TextAlignment::Center); + root.draw_text("center-align", &style, (150, 150)).unwrap(); + + let style = style.alignment(TextAlignment::Left); + root.draw_text("left-align", &style, (150, 200)).unwrap(); + } + + let content = String::from_utf8(buffer).unwrap(); + save_file("test_text_alignments", &content); + + for svg_line in content.split("") { + if let Some(anchor_and_rest) = svg_line.split("text-anchor=\"").nth(1) { + if anchor_and_rest.starts_with("end") { + assert!(anchor_and_rest.contains("right-align")) + } + if anchor_and_rest.starts_with("middle") { + assert!(anchor_and_rest.contains("center-align")) + } + if anchor_and_rest.starts_with("start") { + assert!(anchor_and_rest.contains("left-align")) + } + } + } + } +} diff --git a/src/element/boxplot.rs b/src/element/boxplot.rs new file mode 100644 index 00000000..6b0689f7 --- /dev/null +++ b/src/element/boxplot.rs @@ -0,0 +1,283 @@ +use std::marker::PhantomData; + +use crate::data::Quartiles; +use crate::drawing::backend::{BackendCoord, DrawingBackend, DrawingErrorKind}; +use crate::element::{Drawable, PointCollection}; +use crate::style::{ShapeStyle, BLACK}; + +/// The boxplot orientation trait +pub trait BoxplotOrient { + type XType; + type YType; + + fn make_coord(key: K, val: V) -> (Self::XType, Self::YType); + fn with_offset(coord: BackendCoord, offset: f64) -> BackendCoord; +} + +/// The vertical boxplot phantom +pub struct BoxplotOrientV(PhantomData<(K, V)>); + +/// The horizontal boxplot phantom +pub struct BoxplotOrientH(PhantomData<(K, V)>); + +impl BoxplotOrient for BoxplotOrientV { + type XType = K; + type YType = V; + + fn make_coord(key: K, val: V) -> (K, V) { + (key, val) + } + + fn with_offset(coord: BackendCoord, offset: f64) -> BackendCoord { + (coord.0 + offset as i32, coord.1) + } +} + +impl BoxplotOrient for BoxplotOrientH { + type XType = V; + type YType = K; + + fn make_coord(key: K, val: V) -> (V, K) { + (val, key) + } + + fn with_offset(coord: BackendCoord, offset: f64) -> BackendCoord { + (coord.0, coord.1 + offset as i32) + } +} + +const DEFAULT_WIDTH: u32 = 10; + +/// The boxplot element +pub struct Boxplot> { + style: ShapeStyle, + width: u32, + whisker_width: f64, + offset: f64, + key: K, + values: [f32; 5], + _p: PhantomData, +} + +impl Boxplot> { + /// Create a new vertical boxplot element. + /// + /// - `key`: The key (the X axis value) + /// - `quartiles`: The quartiles values for the Y axis + /// - **returns** The newly created boxplot element + /// + /// ```rust + /// use plotters::prelude::*; + /// + /// let quartiles = Quartiles::new(&[7, 15, 36, 39, 40, 41]); + /// let plot = Boxplot::new_vertical("group", &quartiles); + /// ``` + pub fn new_vertical(key: K, quartiles: &Quartiles) -> Self { + Self { + style: Into::::into(&BLACK), + width: DEFAULT_WIDTH, + whisker_width: 1.0, + offset: 0.0, + key, + values: quartiles.values(), + _p: PhantomData, + } + } +} + +impl Boxplot> { + /// Create a new horizontal boxplot element. + /// + /// - `key`: The key (the Y axis value) + /// - `quartiles`: The quartiles values for the X axis + /// - **returns** The newly created boxplot element + /// + /// ```rust + /// use plotters::prelude::*; + /// + /// let quartiles = Quartiles::new(&[7, 15, 36, 39, 40, 41]); + /// let plot = Boxplot::new_horizontal("group", &quartiles); + /// ``` + pub fn new_horizontal(key: K, quartiles: &Quartiles) -> Self { + Self { + style: Into::::into(&BLACK), + width: DEFAULT_WIDTH, + whisker_width: 1.0, + offset: 0.0, + key, + values: quartiles.values(), + _p: PhantomData, + } + } +} + +impl> Boxplot { + /// Set the style of the boxplot. + /// + /// - `S`: The required style + /// - **returns** The up-to-dated boxplot element + /// + /// ```rust + /// use plotters::prelude::*; + /// + /// let quartiles = Quartiles::new(&[7, 15, 36, 39, 40, 41]); + /// let plot = Boxplot::new_horizontal("group", &quartiles).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 boxplot element + /// + /// ```rust + /// use plotters::prelude::*; + /// + /// let quartiles = Quartiles::new(&[7, 15, 36, 39, 40, 41]); + /// let plot = Boxplot::new_horizontal("group", &quartiles).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 quartiles = Quartiles::new(&[7, 15, 36, 39, 40, 41]); + /// let plot = Boxplot::new_horizontal("group", &quartiles).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 quartiles = Quartiles::new(&[7, 15, 36, 39, 40, 41]); + /// let plot = Boxplot::new_horizontal("group", &quartiles).offset(-5); + /// ``` + pub fn offset + Copy>(mut self, offset: T) -> Self { + self.offset = offset.into(); + self + } +} + +impl<'a, K: 'a + Clone, O: BoxplotOrient> PointCollection<'a, (O::XType, O::YType)> + for &'a Boxplot +{ + type Borrow = (O::XType, O::YType); + type IntoIter = Vec; + fn point_iter(self) -> Self::IntoIter { + self.values + .iter() + .map(|v| O::make_coord(self.key.clone(), *v)) + .collect() + } +} + +impl> Drawable for Boxplot { + fn draw>( + &self, + points: I, + backend: &mut DB, + _: (u32, u32), + ) -> Result<(), DrawingErrorKind> { + let points: Vec<_> = points.take(5).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.color, + )?; + + // |---[ | ]----| + // _^^^_____________ + backend.draw_line(moved(points[0]), moved(points[1]), &self.style.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.color, false)?; + + // |---[ | ]----| + // ________^________ + backend.draw_line(start_bar(points[2]), end_bar(points[2]), &self.style.color)?; + + // |---[ | ]----| + // ____________^^^^_ + backend.draw_line(moved(points[3]), moved(points[4]), &self.style.color)?; + + // |---[ | ]----| + // ________________^ + backend.draw_line( + start_whisker(points[4]), + end_whisker(points[4]), + &self.style.color, + )?; + } + 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_ranged(0..2, 0f32..100f32) + .unwrap(); + + let values = Quartiles::new(&[6]); + assert!(chart + .plotting_area() + .draw(&Boxplot::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_ranged(0f32..100f32, 0..2) + .unwrap(); + + let values = Quartiles::new(&[6]); + assert!(chart + .plotting_area() + .draw(&Boxplot::new_horizontal(1, &values)) + .is_ok()); + } +} diff --git a/src/element/candlestick.rs b/src/element/candlestick.rs index a8ed2409..6849bbcf 100644 --- a/src/element/candlestick.rs +++ b/src/element/candlestick.rs @@ -17,6 +17,23 @@ pub struct CandleStick { impl CandleStick { /// Create a new candlestick element, which requires the Y coordinate can be compared + /// + /// - `x`: The x coordinate + /// - `open`: The open value + /// - `high`: The high value + /// - `low`: The low value + /// - `close`: The close value + /// - `gain_style`: The style for gain + /// - `loss_style`: The style for loss + /// - `width`: The width + /// - **returns** The newly created candlestick element + /// + /// ```rust + /// use chrono::prelude::*; + /// use plotters::prelude::*; + /// + /// let candlestick = CandleStick::new(Local::now(), 130.0600, 131.3700, 128.8300, 129.1500, &GREEN, &RED, 15); + /// ``` #[allow(clippy::too_many_arguments)] pub fn new, LS: Into>( x: X, @@ -38,7 +55,7 @@ impl CandleStick { (x.clone(), open), (x.clone(), high), (x.clone(), low), - (x.clone(), close), + (x, close), ], } } diff --git a/src/element/mod.rs b/src/element/mod.rs index 17defaec..8f01a35b 100644 --- a/src/element/mod.rs +++ b/src/element/mod.rs @@ -22,6 +22,7 @@ use std::iter::{Once, once}; use plotters::element::{PointCollection, Drawable}; use plotters::drawing::backend::{BackendCoord, DrawingErrorKind}; + use plotters::style::IntoTextStyle; use plotters::prelude::*; // Any example drawing a red X @@ -45,8 +46,10 @@ _: (u32, u32), ) -> Result<(), DrawingErrorKind> { let pos = pos.next().unwrap(); - backend.draw_rect(pos, (pos.0 + 10, pos.1 + 12), &RED.to_rgba(), false)?; - backend.draw_text("X", &("sans-serif", 20).into(), pos, &RED.to_rgba()) + let color = RED.to_rgba(); + backend.draw_rect(pos, (pos.0 + 10, pos.1 + 12), &color, false)?; + let text_style = &("sans-serif", 20).into_text_style(backend).color(&color); + backend.draw_text("X", &text_style, pos) } } @@ -173,6 +176,9 @@ pub use candlestick::CandleStick; mod errorbar; pub use errorbar::{ErrorBar, ErrorBarOrientH, ErrorBarOrientV}; +mod boxplot; +pub use boxplot::Boxplot; + mod image; pub use self::image::BitMapElement; diff --git a/src/element/text.rs b/src/element/text.rs index 10808234..09e0816c 100644 --- a/src/element/text.rs +++ b/src/element/text.rs @@ -44,7 +44,7 @@ impl<'a, Coord: 'a, DB: DrawingBackend, T: Borrow> Drawable for Text<'a _: (u32, u32), ) -> Result<(), DrawingErrorKind> { if let Some(a) = points.next() { - return backend.draw_text(self.text.borrow(), &self.style.font, a, &self.style.color); + return backend.draw_text(self.text.borrow(), &self.style, a); } Ok(()) } @@ -238,7 +238,7 @@ impl<'a, Coord: 'a, DB: DrawingBackend, T: Borrow> Drawable ) -> Result<(), DrawingErrorKind> { if let Some(a) = points.next() { for (point, text) in self.layout_lines(a).zip(self.lines.iter()) { - backend.draw_text(text.borrow(), &self.style.font, point, &self.style.color)?; + backend.draw_text(text.borrow(), &self.style, point)?; } } Ok(()) diff --git a/src/lib.rs b/src/lib.rs index df263839..bcdb734f 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -659,9 +659,9 @@ pub use palette; pub mod prelude { pub use crate::chart::{ChartBuilder, ChartContext, LabelAreaPosition, SeriesLabelPosition}; pub use crate::coord::{ - CoordTranslate, GroupBy, IntoCentric, IntoPartialAxis, LogCoord, LogRange, LogScalable, - Ranged, RangedCoord, RangedCoordf32, RangedCoordf64, RangedCoordi32, RangedCoordi64, - RangedCoordu32, RangedCoordu64, ToGroupByRange, + Category, CoordTranslate, GroupBy, IntoCentric, IntoPartialAxis, LogCoord, LogRange, + LogScalable, Ranged, RangedCoord, RangedCoordf32, RangedCoordf64, RangedCoordi32, + RangedCoordi64, RangedCoordu32, RangedCoordu64, ToGroupByRange, }; #[cfg(feature = "chrono")] @@ -677,11 +677,13 @@ pub mod prelude { pub use crate::style::{BLACK, BLUE, CYAN, GREEN, MAGENTA, RED, TRANSPARENT, WHITE, YELLOW}; pub use crate::element::{ - BitMapElement, CandleStick, Circle, Cross, DynElement, EmptyElement, ErrorBar, + BitMapElement, Boxplot, CandleStick, Circle, Cross, DynElement, EmptyElement, ErrorBar, IntoDynElement, MultiLineText, PathElement, Pixel, Polygon, Rectangle, Text, TriangleMarker, }; + pub use crate::data::Quartiles; + // TODO: This should be deprecated and completely removed #[cfg(feature = "deprecated_items")] #[allow(deprecated)] diff --git a/src/series/line_series.rs b/src/series/line_series.rs index 7203f374..d3a59716 100644 --- a/src/series/line_series.rs +++ b/src/series/line_series.rs @@ -1,7 +1,7 @@ -use crate::element::{PathElement, DynElement, IntoDynElement, Circle}; +use crate::drawing::DrawingBackend; +use crate::element::{Circle, DynElement, IntoDynElement, PathElement}; use crate::style::ShapeStyle; use std::marker::PhantomData; -use crate::drawing::DrawingBackend; /// The line series object, which takes an iterator of points in guest coordinate system /// and creates the element rendering the line plot @@ -13,21 +13,21 @@ pub struct LineSeries { phantom: PhantomData, } -impl Iterator for LineSeries { - type Item = DynElement<'static, DB, Coord>; +impl Iterator for LineSeries { + type Item = DynElement<'static, DB, Coord>; fn next(&mut self) -> Option { if !self.data.is_empty() { if self.point_size > 0 && self.point_idx < self.data.len() { let idx = self.point_idx; self.point_idx += 1; - return Some(Circle::new(self.data[idx].clone(), self.point_size, self.style.clone()).into_dyn()); + return Some( + Circle::new(self.data[idx].clone(), self.point_size, self.style.clone()) + .into_dyn(), + ); } let mut data = vec![]; std::mem::swap(&mut self.data, &mut data); - Some(PathElement::new( - data, - self.style.clone(), - ).into_dyn()) + Some(PathElement::new(data, self.style.clone()).into_dyn()) } else { None } diff --git a/src/style/color.rs b/src/style/color.rs index de7484dd..f43f863a 100644 --- a/src/style/color.rs +++ b/src/style/color.rs @@ -94,7 +94,8 @@ impl SimpleColor for PaletteColor

{ } } -/// The color described by it's RGB value +/// The color described by its RGB value +#[derive(Debug)] pub struct RGBColor(pub u8, pub u8, pub u8); impl SimpleColor for RGBColor { diff --git a/src/style/font/font_desc.rs b/src/style/font/font_desc.rs index 16ed9972..1e000109 100644 --- a/src/style/font/font_desc.rs +++ b/src/style/font/font_desc.rs @@ -1,5 +1,5 @@ use super::{FontData, FontDataInternal}; -use crate::style::{Color, LayoutBox, TextStyle}; +use crate::style::{Color, LayoutBox, TextAlignment, TextStyle}; use std::convert::From; @@ -255,6 +255,7 @@ impl<'a> FontDesc<'a> { TextStyle { font: self.clone(), color: color.to_rgba(), + alignment: TextAlignment::Left, } } diff --git a/src/style/mod.rs b/src/style/mod.rs index 4d75093e..5ae9bb06 100644 --- a/src/style/mod.rs +++ b/src/style/mod.rs @@ -21,4 +21,4 @@ pub use font::{ }; pub use shape::ShapeStyle; pub use size::{AsRelative, RelativeSize, SizeDesc}; -pub use text::{IntoTextStyle, TextStyle}; +pub use text::{IntoTextStyle, TextAlignment, TextStyle}; diff --git a/src/style/text.rs b/src/style/text.rs index 593eb442..0bcfa6f8 100644 --- a/src/style/text.rs +++ b/src/style/text.rs @@ -3,11 +3,31 @@ use super::font::{FontDesc, FontFamily, FontStyle, FontTransform}; use super::size::{HasDimension, SizeDesc}; use super::BLACK; +/// The alignment of the text. +/// +/// Used to determine the invariant (anchor) points for backends +/// which render the text on the client side like SVG. The current +/// implementation calculates the font inked rectangle using +/// fontkit-based glyphs, but the client side (i.e. web browsers) +/// may use different fonts for the same font family. As the calculations +/// assume some invariant points, we may use the same on the client +/// side to properly position the text. +#[derive(Copy, Clone)] +pub enum TextAlignment { + /// Left alignment + Left, + /// Right alignment + Right, + /// Center alignment + Center, +} + /// Style of a text #[derive(Clone)] pub struct TextStyle<'a> { pub font: FontDesc<'a>, pub color: RGBAColor, + pub alignment: TextAlignment, } pub trait IntoTextStyle<'a> { @@ -57,18 +77,58 @@ impl<'a, T: SizeDesc> IntoTextStyle<'a> for (FontFamily<'a>, T, FontStyle) { } impl<'a> TextStyle<'a> { - /// Determine the color of the style + /// Sets the color of the style. + /// + /// - `color`: The required color + /// - **returns** The up-to-dated text style + /// + /// ```rust + /// use plotters::prelude::*; + /// + /// let style = TextStyle::from(("sans-serif", 20).into_font()).color(&RED); + /// ``` pub fn color(&self, color: &'a C) -> Self { Self { font: self.font.clone(), color: color.to_rgba(), + alignment: self.alignment, } } + /// Sets the font transformation of the style. + /// + /// - `trans`: The required transformation + /// - **returns** The up-to-dated text style + /// + /// ```rust + /// use plotters::prelude::*; + /// + /// let style = TextStyle::from(("sans-serif", 20).into_font()).transform(FontTransform::Rotate90); + /// ``` pub fn transform(&self, trans: FontTransform) -> Self { Self { font: self.font.clone().transform(trans), color: self.color.clone(), + alignment: self.alignment, + } + } + + /// Sets the text alignment of the style. + /// + /// - `color`: The required alignment + /// - **returns** The up-to-dated text style + /// + /// ```rust + /// use plotters::prelude::*; + /// use plotters::style::TextAlignment; + /// + /// let style = TextStyle::from(("sans-serif", 20).into_font()).alignment(TextAlignment::Right); + /// ``` + pub fn alignment(&self, alignment: TextAlignment) -> Self { + Self { + font: self.font.clone(), + color: self.color.clone(), + alignment, } } } @@ -85,6 +145,7 @@ impl<'a, T: Into>> From for TextStyle<'a> { Self { font: font.into(), color: BLACK.to_rgba(), + alignment: TextAlignment::Left, } } }