timelog/chart/
pie.rs

1//! Represent the construction of an SVG [`PieChart`].
2//!
3//! # Examples
4//!
5//! ```rust, no_run
6//! use std::fs::File;
7//! use xml::{EventWriter, EmitterConfig};
8//! use timelog::chart::{ColorIter, Legend, Percentages, PieChart, PieData};
9//! # use std::fmt;
10//!
11//! # fn main() {
12//! let mut piedata = PieData::default();
13//! [("foo", 10), ("bar", 70), ("baz", 50), ("foobar", 35)].iter()
14//!     .for_each(|(l, p)| piedata.add_secs(l, *p));
15//! let percents: Percentages = piedata.percentages();
16//! let radius = 100.0;
17//! let mut pie = PieChart::new(
18//!     radius,
19//!     Legend::new(14.0, ColorIter::default()),
20//! );
21//! let mut file = File::create("output.svg").expect("Can't create file");
22//! let mut w = EmitterConfig::new().create_writer(&mut file);
23//! pie.write_pie(&mut w, &percents);
24//! # }
25//! ```
26
27use xml::writer::{EventWriter, XmlEvent};
28
29use crate::chart::colors::ColorIter;
30use crate::chart::legend::Legend;
31use crate::chart::tag_percent::TagPercent;
32use crate::chart::utils;
33use crate::emit_xml;
34
35/// Configuration of a pie chart.
36pub struct PieChart<'a> {
37    r:      f32,
38    legend: Legend<'a>
39}
40
41impl<'a> PieChart<'a> {
42    /// Create a new [`PieChart`] with the given radius and [`Legend`]
43    pub fn new(r: f32, legend: Legend<'a>) -> Self { Self { r, legend } }
44
45    /// Return the radius of the pie chart.
46    pub fn radius(&self) -> f32 { self.r }
47
48    /// Output a pie chart representing the supplied percentages.
49    ///
50    /// # Errors
51    ///
52    /// Could return any formatting error
53    pub fn write_pie<W>(&self, w: &mut EventWriter<W>, percents: &[TagPercent]) -> crate::Result<()>
54    where
55        W: std::io::Write
56    {
57        emit_xml!(w, div, class: "piechart" => {
58            emit_xml!(w, div, class: "pie" => {
59                let size = format!("{}", 2.0 * (self.r + 1.0));
60                let view = format!("{0:} {0:} {1:} {1:}", -(self.r + 1.0), size);
61                emit_xml!(w, svg, viewbox: &view, width: &size, height: &size => {
62                    emit_xml!(w, circle, r: &format!("{}", self.r), stroke: "black")?;
63                    let colors = ColorIter::default();
64                    let percents = colors.limit_percents(percents, "Other");
65                    let mut alpha = -90.0;
66                    for (p, clr) in percents.iter().zip(colors) {
67                        let theta = p.percent_val() * 3.6f32;
68
69                        emit_xml!(w, path, fill: clr, d: &self.pie_slice(alpha, theta))?;
70                        alpha += theta;
71                    }
72                    Ok(())
73                })
74            })?;
75            self.legend.write(w, percents.iter())?;
76            Ok(())
77        })
78    }
79
80    // Create a single pie slice between the angles of `alpha` and `theta`.
81    #[rustfmt::skip]
82    fn pie_slice(&self, alpha: f32, theta: f32) -> String {
83        let alpha_rad = alpha.to_radians();
84        let sx = utils::format_coord(self.r * alpha_rad.cos());
85        let sy = utils::format_coord(self.r * alpha_rad.sin());
86
87        if (theta - 360.0).abs() < 0.01 {
88            let rend = (alpha + 180.0).to_radians();
89            let ex = utils::format_coord(self.r * rend.cos());
90            let ey = utils::format_coord(self.r * rend.sin());
91
92            format!(
93                "M0,0 L{sx},{sy} A{r},{r} 0 1,1 {ex},{ey} A{r},{r} 0 1,1 {sx},{sy} z",
94                sx=sx, sy=sy,
95                r=self.r,
96                ex=ex, ey=ey
97            )
98        }
99        else {
100            let rend = (alpha + theta).to_radians();
101            let ex = utils::format_coord(self.r * rend.cos());
102            let ey = utils::format_coord(self.r * rend.sin());
103
104            let large = i32::from(theta >= 180.0);
105            format!(
106                "M0,0 L{sx},{sy} A{r},{r} 0 {lg},1 {ex},{ey} z",
107                sx=sx, sy=sy,
108                r=self.r,
109                lg=large,
110                ex=ex, ey=ey
111            )
112        }
113    }
114}
115
116#[cfg(test)]
117mod tests {
118    use spectral::prelude::*;
119    use xml::EmitterConfig;
120
121    use super::*;
122    use crate::chart::ColorIter;
123    use crate::chart::Legend;
124
125    #[test]
126    fn test_new() {
127        let legend = Legend::new(14.0, ColorIter::default());
128        let pie = PieChart::new(100.0, legend);
129
130        assert_that!(pie.radius()).is_equal_to(&100.0);
131    }
132
133    #[test]
134    fn test_pie_one_slice() {
135        let legend = Legend::new(14.0, ColorIter::default());
136        let pie = PieChart::new(100.0, legend);
137
138        let percents = [TagPercent::new("foo", 100.0).unwrap()];
139
140        let mut output: Vec<u8> = Vec::new();
141        let mut w = EmitterConfig::new()
142            .perform_indent(true)
143            .write_document_declaration(false)
144            .create_writer(&mut output);
145        assert_that!(pie.write_pie(&mut w, &percents)).is_ok();
146        let actual = String::from_utf8(output).unwrap();
147        let expected = r##"<div class="piechart">
148  <div class="pie">
149    <svg viewbox="-101 -101 202 202" width="202" height="202">
150      <circle r="100" stroke="black" />
151      <path fill="#1f78b4" d="M0,0 L-0,-100 A100,100 0 1,1 -0,100 A100,100 0 1,1 -0,-100 z" />
152    </svg>
153  </div>
154  <table class="legend" style="font-size: 14px">
155    <tr>
156      <td>
157        <svg height="14" width="14">
158          <rect height="14" width="14" fill="#1f78b4" />
159        </svg>
160        <span>100% - foo</span>
161      </td>
162    </tr>
163  </table>
164</div>"##;
165        assert_that!(actual.as_str()).is_equal_to(expected);
166    }
167
168    #[test]
169    fn test_pie_multiple_slices() {
170        let legend = Legend::new(14.0, ColorIter::default());
171        let pie = PieChart::new(100.0, legend);
172
173        #[rustfmt::skip]
174        let percents = [
175            TagPercent::new("david",   40.0).unwrap(),
176            TagPercent::new("connie",  30.0).unwrap(),
177            TagPercent::new("mark",    20.0).unwrap(),
178            TagPercent::new("kirsten", 10.0).unwrap()
179        ];
180
181        let mut output: Vec<u8> = Vec::new();
182        let mut w = EmitterConfig::new()
183            .perform_indent(true)
184            .write_document_declaration(false)
185            .create_writer(&mut output);
186        assert_that!(pie.write_pie(&mut w, &percents)).is_ok();
187        let actual = String::from_utf8(output).unwrap();
188        let expected = r##"<div class="piechart">
189  <div class="pie">
190    <svg viewbox="-101 -101 202 202" width="202" height="202">
191      <circle r="100" stroke="black" />
192      <path fill="#1f78b4" d="M0,0 L-0,-100 A100,100 0 0,1 58.779,80.902 z" />
193      <path fill="#a6cee3" d="M0,0 L58.779,80.902 A100,100 0 0,1 -95.106,30.902 z" />
194      <path fill="#33a02c" d="M0,0 L-95.106,30.902 A100,100 0 0,1 -58.779,-80.902 z" />
195      <path fill="#b2df8a" d="M0,0 L-58.779,-80.902 A100,100 0 0,1 0,-100 z" />
196    </svg>
197  </div>
198  <table class="legend" style="font-size: 14px">
199    <tr>
200      <td>
201        <svg height="14" width="14">
202          <rect height="14" width="14" fill="#1f78b4" />
203        </svg>
204        <span>40% - david</span>
205      </td>
206    </tr>
207    <tr>
208      <td>
209        <svg height="14" width="14">
210          <rect height="14" width="14" fill="#a6cee3" />
211        </svg>
212        <span>30% - connie</span>
213      </td>
214    </tr>
215    <tr>
216      <td>
217        <svg height="14" width="14">
218          <rect height="14" width="14" fill="#33a02c" />
219        </svg>
220        <span>20% - mark</span>
221      </td>
222    </tr>
223    <tr>
224      <td>
225        <svg height="14" width="14">
226          <rect height="14" width="14" fill="#b2df8a" />
227        </svg>
228        <span>10% - kirsten</span>
229      </td>
230    </tr>
231  </table>
232</div>"##;
233        assert_that!(actual.as_str()).is_equal_to(expected);
234    }
235}