• Home
  • Line#
  • Scopes#
  • Navigate#
  • Raw
  • Download
1 use crate::{
2     element::{Drawable, PointCollection},
3     style::{IntoFont, RGBColor, TextStyle, BLACK},
4 };
5 use plotters_backend::{BackendCoord, DrawingBackend, DrawingErrorKind};
6 use std::{error::Error, f64::consts::PI, fmt::Display};
7 
8 #[derive(Debug)]
9 enum PieError {
10     LengthMismatch,
11 }
12 impl Display for PieError {
fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result13     fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
14         match self {
15             &PieError::LengthMismatch => write!(f, "Length Mismatch"),
16         }
17     }
18 }
19 
20 impl Error for PieError {}
21 
22 /// A Pie Graph
23 pub struct Pie<'a, Coord, Label: Display> {
24     center: &'a Coord, // cartesian coord
25     radius: &'a f64,
26     sizes: &'a [f64],
27     colors: &'a [RGBColor],
28     labels: &'a [Label],
29     total: f64,
30     start_radian: f64,
31     label_style: TextStyle<'a>,
32     label_offset: f64,
33     percentage_style: Option<TextStyle<'a>>,
34     donut_hole: f64, // radius of the hole in case of a donut chart
35 }
36 
37 impl<'a, Label: Display> Pie<'a, (i32, i32), Label> {
38     /// Build a Pie object.
39     /// Assumes a start angle at 0.0, which is aligned to the horizontal axis.
new( center: &'a (i32, i32), radius: &'a f64, sizes: &'a [f64], colors: &'a [RGBColor], labels: &'a [Label], ) -> Self40     pub fn new(
41         center: &'a (i32, i32),
42         radius: &'a f64,
43         sizes: &'a [f64],
44         colors: &'a [RGBColor],
45         labels: &'a [Label],
46     ) -> Self {
47         // fold iterator to pre-calculate total from given slice sizes
48         let total = sizes.iter().sum();
49 
50         // default label style and offset as 5% of the radius
51         let radius_5pct = radius * 0.05;
52 
53         // strong assumption that the background is white for legibility.
54         let label_style = TextStyle::from(("sans-serif", radius_5pct).into_font()).color(&BLACK);
55         Self {
56             center,
57             radius,
58             sizes,
59             colors,
60             labels,
61             total,
62             start_radian: 0.0,
63             label_style,
64             label_offset: radius_5pct,
65             percentage_style: None,
66             donut_hole: 0.0,
67         }
68     }
69 
70     /// Pass an angle in degrees to change the default.
71     /// Default is set to start at 0, which is aligned on the x axis.
72     /// ```
73     /// use plotters::prelude::*;
74     /// let mut pie = Pie::new(&(50,50), &10.0, &[50.0, 25.25, 20.0, 5.5], &[RED, BLUE, GREEN, WHITE], &["Red", "Blue", "Green", "White"]);
75     /// pie.start_angle(-90.0);  // retract to a right angle, so it starts aligned to a vertical Y axis.
76     /// ```
start_angle(&mut self, start_angle: f64)77     pub fn start_angle(&mut self, start_angle: f64) {
78         // angle is more intuitive in degrees as an API, but we use it as radian offset internally.
79         self.start_radian = start_angle.to_radians();
80     }
81 
82     /// Set the label style.
label_style<T: Into<TextStyle<'a>>>(&mut self, label_style: T)83     pub fn label_style<T: Into<TextStyle<'a>>>(&mut self, label_style: T) {
84         self.label_style = label_style.into();
85     }
86 
87     /// Sets the offset to labels, to distanciate them further/closer from the center.
label_offset(&mut self, offset_to_radius: f64)88     pub fn label_offset(&mut self, offset_to_radius: f64) {
89         self.label_offset = offset_to_radius
90     }
91 
92     /// enables drawing the wedge's percentage in the middle of the wedge, with the given style
percentages<T: Into<TextStyle<'a>>>(&mut self, label_style: T)93     pub fn percentages<T: Into<TextStyle<'a>>>(&mut self, label_style: T) {
94         self.percentage_style = Some(label_style.into());
95     }
96 
97     /// Enables creating a donut chart with a hole of the specified radius.
98     ///
99     /// The passed value must be greater than zero and lower than the chart overall radius, otherwise it'll be ignored.
donut_hole(&mut self, hole_radius: f64)100     pub fn donut_hole(&mut self, hole_radius: f64) {
101         if hole_radius > 0.0 && hole_radius < *self.radius {
102             self.donut_hole = hole_radius;
103         }
104     }
105 }
106 
107 impl<'a, DB: DrawingBackend, Label: Display> Drawable<DB> for Pie<'a, (i32, i32), Label> {
draw<I: Iterator<Item = BackendCoord>>( &self, _pos: I, backend: &mut DB, _parent_dim: (u32, u32), ) -> Result<(), DrawingErrorKind<DB::ErrorType>>108     fn draw<I: Iterator<Item = BackendCoord>>(
109         &self,
110         _pos: I,
111         backend: &mut DB,
112         _parent_dim: (u32, u32),
113     ) -> Result<(), DrawingErrorKind<DB::ErrorType>> {
114         let mut offset_theta = self.start_radian;
115 
116         // const reused for every radian calculation
117         // the bigger the radius, the more fine-grained it should calculate
118         // to avoid being aliasing from being too noticeable.
119         // this all could be avoided if backend could draw a curve/bezier line as part of a polygon.
120         let radian_increment = PI / 180.0 / self.radius.sqrt() * 2.0;
121         let mut perc_labels = Vec::new();
122         for (index, slice) in self.sizes.iter().enumerate() {
123             let slice_style = self
124                 .colors
125                 .get(index)
126                 .ok_or_else(|| DrawingErrorKind::FontError(Box::new(PieError::LengthMismatch)))?;
127             let label = self
128                 .labels
129                 .get(index)
130                 .ok_or_else(|| DrawingErrorKind::FontError(Box::new(PieError::LengthMismatch)))?;
131             // start building wedge line against the previous edge
132             let mut points = if self.donut_hole == 0.0 {
133                 vec![*self.center]
134             } else {
135                 vec![]
136             };
137             let ratio = slice / self.total;
138             let theta_final = ratio * 2.0 * PI + offset_theta; // end radian for the wedge
139 
140             // calculate middle for labels before mutating offset
141             let middle_theta = ratio * PI + offset_theta;
142 
143             let slice_start = offset_theta;
144 
145             // calculate every fraction of radian for the wedge, offsetting for every iteration, clockwise
146             //
147             // a custom Range such as `for theta in offset_theta..=theta_final` would be more elegant
148             // but f64 doesn't implement the Range trait, and it would requires the Step trait (increment by 1.0 or 0.0001?)
149             // which is unstable therefore cannot be implemented outside of std, even as a newtype for radians.
150             while offset_theta <= theta_final {
151                 let coord = theta_to_ordinal_coord(*self.radius, offset_theta, self.center);
152                 points.push(coord);
153                 offset_theta += radian_increment;
154             }
155             // final point of the wedge may not fall exactly on a radian, so add it extra
156             let final_coord = theta_to_ordinal_coord(*self.radius, theta_final, self.center);
157             points.push(final_coord);
158 
159             if self.donut_hole > 0.0 {
160                 while offset_theta >= slice_start {
161                     let coord = theta_to_ordinal_coord(self.donut_hole, offset_theta, self.center);
162                     points.push(coord);
163                     offset_theta -= radian_increment;
164                 }
165                 // final point of the wedge may not fall exactly on a radian, so add it extra
166                 let final_coord_inner =
167                     theta_to_ordinal_coord(self.donut_hole, slice_start, self.center);
168                 points.push(final_coord_inner);
169             }
170 
171             // next wedge calculation will start from previous wedges's last radian
172             offset_theta = theta_final;
173 
174             // draw wedge
175             // TODO: Currently the backend doesn't have API to draw an arc. We need add that in the
176             // future
177             backend.fill_polygon(points, slice_style)?;
178 
179             // label coords from the middle
180             let mut mid_coord =
181                 theta_to_ordinal_coord(self.radius + self.label_offset, middle_theta, self.center);
182 
183             // ensure label's doesn't fall in the circle
184             let label_size = backend.estimate_text_size(&label.to_string(), &self.label_style)?;
185             // if on the left hand side of the pie, offset whole label to the left
186             if mid_coord.0 <= self.center.0 {
187                 mid_coord.0 -= label_size.0 as i32;
188             }
189             // put label
190             backend.draw_text(&label.to_string(), &self.label_style, mid_coord)?;
191             if let Some(percentage_style) = &self.percentage_style {
192                 let perc_label = format!("{:.1}%", (ratio * 100.0));
193                 let label_size = backend.estimate_text_size(&perc_label, percentage_style)?;
194                 let text_x_mid = (label_size.0 as f64 / 2.0).round() as i32;
195                 let text_y_mid = (label_size.1 as f64 / 2.0).round() as i32;
196                 let perc_radius = (self.radius + self.donut_hole) / 2.0;
197                 let perc_coord = theta_to_ordinal_coord(
198                     perc_radius,
199                     middle_theta,
200                     &(self.center.0 - text_x_mid, self.center.1 - text_y_mid),
201                 );
202                 // perc_coord.0 -= middle_label_size.0.round() as i32;
203                 perc_labels.push((perc_label, perc_coord));
204             }
205         }
206         // while percentages are generated during the first main iterations,
207         // they have to go on top of the already drawn wedges, so require a new iteration.
208         for (label, coord) in perc_labels {
209             let style = self.percentage_style.as_ref().unwrap();
210             backend.draw_text(&label, style, coord)?;
211         }
212         Ok(())
213     }
214 }
215 
216 impl<'a, Label: Display> PointCollection<'a, (i32, i32)> for &'a Pie<'a, (i32, i32), Label> {
217     type Point = &'a (i32, i32);
218     type IntoIter = std::iter::Once<&'a (i32, i32)>;
point_iter(self) -> std::iter::Once<&'a (i32, i32)>219     fn point_iter(self) -> std::iter::Once<&'a (i32, i32)> {
220         std::iter::once(self.center)
221     }
222 }
223 
theta_to_ordinal_coord(radius: f64, theta: f64, ordinal_offset: &(i32, i32)) -> (i32, i32)224 fn theta_to_ordinal_coord(radius: f64, theta: f64, ordinal_offset: &(i32, i32)) -> (i32, i32) {
225     // polar coordinates are (r, theta)
226     // convert to (x, y) coord, with center as offset
227 
228     let (sin, cos) = theta.sin_cos();
229     (
230         // casting f64 to discrete i32 pixels coordinates is inevitably going to lose precision
231         // if plotters can support float coordinates, this place would surely benefit, especially for small sizes.
232         // so far, the result isn't so bad though
233         (radius * cos + ordinal_offset.0 as f64).round() as i32, // x
234         (radius * sin + ordinal_offset.1 as f64).round() as i32, // y
235     )
236 }
237 #[cfg(test)]
238 mod test {
239     use super::*;
240     // use crate::prelude::*;
241 
242     #[test]
polar_coord_to_cartestian_coord()243     fn polar_coord_to_cartestian_coord() {
244         let coord = theta_to_ordinal_coord(800.0, 1.5_f64.to_radians(), &(5, 5));
245         // rounded tends to be more accurate. this gets truncated to (804, 25) without rounding.
246         assert_eq!(coord, (805, 26)); //coord calculated from theta
247     }
248     #[test]
pie_calculations()249     fn pie_calculations() {
250         let mut center = (5, 5);
251         let mut radius = 800.0;
252 
253         let sizes = vec![50.0, 25.0];
254         // length isn't validated in new()
255         let colors = vec![];
256         let labels: Vec<&str> = vec![];
257         let pie = Pie::new(&center, &radius, &sizes, &colors, &labels);
258         assert_eq!(pie.total, 75.0); // total calculated from sizes
259 
260         // not ownership greedy
261         center.1 += 1;
262         radius += 1.0;
263         assert!(colors.get(0).is_none());
264         assert!(labels.first().is_none());
265         assert_eq!(radius, 801.0);
266     }
267 }
268