Coverage Report

Created: 2025-10-29 07:05

next uncovered line (L), next uncovered region (R), next uncovered branch (B)
/rust/registry/src/index.crates.io-1949cf8c6b5b557f/plotters-0.3.7/src/element/pie.rs
Line
Count
Source
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 {
13
0
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
14
0
        match self {
15
0
            &PieError::LengthMismatch => write!(f, "Length Mismatch"),
16
        }
17
0
    }
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.
40
0
    pub fn new(
41
0
        center: &'a (i32, i32),
42
0
        radius: &'a f64,
43
0
        sizes: &'a [f64],
44
0
        colors: &'a [RGBColor],
45
0
        labels: &'a [Label],
46
0
    ) -> Self {
47
        // fold iterator to pre-calculate total from given slice sizes
48
0
        let total = sizes.iter().sum();
49
50
        // default label style and offset as 5% of the radius
51
0
        let radius_5pct = radius * 0.05;
52
53
        // strong assumption that the background is white for legibility.
54
0
        let label_style = TextStyle::from(("sans-serif", radius_5pct).into_font()).color(&BLACK);
55
0
        Self {
56
0
            center,
57
0
            radius,
58
0
            sizes,
59
0
            colors,
60
0
            labels,
61
0
            total,
62
0
            start_radian: 0.0,
63
0
            label_style,
64
0
            label_offset: radius_5pct,
65
0
            percentage_style: None,
66
0
            donut_hole: 0.0,
67
0
        }
68
0
    }
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
    /// ```
77
0
    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
0
        self.start_radian = start_angle.to_radians();
80
0
    }
81
82
    /// Set the label style.
83
0
    pub fn label_style<T: Into<TextStyle<'a>>>(&mut self, label_style: T) {
84
0
        self.label_style = label_style.into();
85
0
    }
86
87
    /// Sets the offset to labels, to distanciate them further/closer from the center.
88
0
    pub fn label_offset(&mut self, offset_to_radius: f64) {
89
0
        self.label_offset = offset_to_radius
90
0
    }
91
92
    /// enables drawing the wedge's percentage in the middle of the wedge, with the given style
93
0
    pub fn percentages<T: Into<TextStyle<'a>>>(&mut self, label_style: T) {
94
0
        self.percentage_style = Some(label_style.into());
95
0
    }
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.
100
0
    pub fn donut_hole(&mut self, hole_radius: f64) {
101
0
        if hole_radius > 0.0 && hole_radius < *self.radius {
102
0
            self.donut_hole = hole_radius;
103
0
        }
104
0
    }
105
}
106
107
impl<'a, DB: DrawingBackend, Label: Display> Drawable<DB> for Pie<'a, (i32, i32), Label> {
108
0
    fn draw<I: Iterator<Item = BackendCoord>>(
109
0
        &self,
110
0
        _pos: I,
111
0
        backend: &mut DB,
112
0
        _parent_dim: (u32, u32),
113
0
    ) -> Result<(), DrawingErrorKind<DB::ErrorType>> {
114
0
        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
0
        let radian_increment = PI / 180.0 / self.radius.sqrt() * 2.0;
121
0
        let mut perc_labels = Vec::new();
122
0
        for (index, slice) in self.sizes.iter().enumerate() {
123
0
            let slice_style = self
124
0
                .colors
125
0
                .get(index)
126
0
                .ok_or_else(|| DrawingErrorKind::FontError(Box::new(PieError::LengthMismatch)))?;
127
0
            let label = self
128
0
                .labels
129
0
                .get(index)
130
0
                .ok_or_else(|| DrawingErrorKind::FontError(Box::new(PieError::LengthMismatch)))?;
131
            // start building wedge line against the previous edge
132
0
            let mut points = if self.donut_hole == 0.0 {
133
0
                vec![*self.center]
134
            } else {
135
0
                vec![]
136
            };
137
0
            let ratio = slice / self.total;
138
0
            let theta_final = ratio * 2.0 * PI + offset_theta; // end radian for the wedge
139
140
            // calculate middle for labels before mutating offset
141
0
            let middle_theta = ratio * PI + offset_theta;
142
143
0
            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
0
            while offset_theta <= theta_final {
151
0
                let coord = theta_to_ordinal_coord(*self.radius, offset_theta, self.center);
152
0
                points.push(coord);
153
0
                offset_theta += radian_increment;
154
0
            }
155
            // final point of the wedge may not fall exactly on a radian, so add it extra
156
0
            let final_coord = theta_to_ordinal_coord(*self.radius, theta_final, self.center);
157
0
            points.push(final_coord);
158
159
0
            if self.donut_hole > 0.0 {
160
0
                while offset_theta >= slice_start {
161
0
                    let coord = theta_to_ordinal_coord(self.donut_hole, offset_theta, self.center);
162
0
                    points.push(coord);
163
0
                    offset_theta -= radian_increment;
164
0
                }
165
                // final point of the wedge may not fall exactly on a radian, so add it extra
166
0
                let final_coord_inner =
167
0
                    theta_to_ordinal_coord(self.donut_hole, slice_start, self.center);
168
0
                points.push(final_coord_inner);
169
0
            }
170
171
            // next wedge calculation will start from previous wedges's last radian
172
0
            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
0
            backend.fill_polygon(points, slice_style)?;
178
179
            // label coords from the middle
180
0
            let mut mid_coord =
181
0
                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
0
            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
0
            if mid_coord.0 <= self.center.0 {
187
0
                mid_coord.0 -= label_size.0 as i32;
188
0
            }
189
            // put label
190
0
            backend.draw_text(&label.to_string(), &self.label_style, mid_coord)?;
191
0
            if let Some(percentage_style) = &self.percentage_style {
192
0
                let perc_label = format!("{:.1}%", (ratio * 100.0));
193
0
                let label_size = backend.estimate_text_size(&perc_label, percentage_style)?;
194
0
                let text_x_mid = (label_size.0 as f64 / 2.0).round() as i32;
195
0
                let text_y_mid = (label_size.1 as f64 / 2.0).round() as i32;
196
0
                let perc_radius = (self.radius + self.donut_hole) / 2.0;
197
0
                let perc_coord = theta_to_ordinal_coord(
198
0
                    perc_radius,
199
0
                    middle_theta,
200
0
                    &(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
0
                perc_labels.push((perc_label, perc_coord));
204
0
            }
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
0
        for (label, coord) in perc_labels {
209
0
            let style = self.percentage_style.as_ref().unwrap();
210
0
            backend.draw_text(&label, style, coord)?;
211
        }
212
0
        Ok(())
213
0
    }
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)>;
219
0
    fn point_iter(self) -> std::iter::Once<&'a (i32, i32)> {
220
0
        std::iter::once(self.center)
221
0
    }
222
}
223
224
0
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
0
    let (sin, cos) = theta.sin_cos();
229
0
    (
230
0
        // casting f64 to discrete i32 pixels coordinates is inevitably going to lose precision
231
0
        // if plotters can support float coordinates, this place would surely benefit, especially for small sizes.
232
0
        // so far, the result isn't so bad though
233
0
        (radius * cos + ordinal_offset.0 as f64).round() as i32, // x
234
0
        (radius * sin + ordinal_offset.1 as f64).round() as i32, // y
235
0
    )
236
0
}
237
#[cfg(test)]
238
mod test {
239
    use super::*;
240
    // use crate::prelude::*;
241
242
    #[test]
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]
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
}