Skip to main content

plotlars_core/plots/
piechart.rs

1use bon::bon;
2
3use polars::frame::DataFrame;
4
5use crate::{
6    components::{FacetConfig, Legend, Rgb, Text},
7    ir::data::ColumnData,
8    ir::layout::LayoutIR,
9    ir::trace::{PieChartIR, TraceIR},
10};
11
12/// A structure representing a pie chart.
13///
14/// The `PieChart` struct allows for the creation and customization of pie charts, supporting
15/// features such as labels, hole size for donut-style charts, slice pulling, rotation, faceting, and customizable plot titles.
16/// It is ideal for visualizing proportions and distributions in categorical data.
17///
18/// # Backend Support
19///
20/// | Backend | Supported |
21/// |---------|-----------|
22/// | Plotly  | Yes       |
23/// | Plotters| --        |
24///
25/// # Arguments
26///
27/// * `data` - A reference to the `DataFrame` containing the data to be plotted.
28/// * `labels` - A string slice specifying the column name to be used for slice labels.
29/// * `facet` - An optional string slice specifying the column name to be used for creating facets (small multiples).
30/// * `facet_config` - An optional reference to a `FacetConfig` struct for customizing facet layout and behavior.
31/// * `hole` - An optional `f64` value specifying the size of the hole in the center of the pie chart.
32///   A value of `0.0` creates a full pie chart, while a value closer to `1.0` creates a thinner ring.
33/// * `pull` - An optional `f64` value specifying the fraction by which each slice should be pulled out from the center.
34/// * `rotation` - An optional `f64` value specifying the starting angle (in degrees) of the first slice.
35/// * `colors` - An optional vector of `Rgb` values specifying colors for consistent slice colors across facets.
36/// * `plot_title` - An optional `Text` struct specifying the title of the plot.
37/// * `legend_title` - An optional `Text` struct specifying the title of the legend.
38/// * `legend` - An optional reference to a `Legend` struct for customizing the legend of the plot (e.g., positioning, font, etc.).
39///
40/// # Example
41///
42/// ## Basic Pie Chart with Customization
43///
44/// ```rust
45/// use plotlars::{PieChart, Plot, Text};
46/// use polars::prelude::*;
47///
48/// let dataset = LazyCsvReader::new(PlRefPath::new("data/penguins.csv"))
49///     .finish()
50///     .unwrap()
51///     .select([col("species")])
52///     .collect()
53///     .unwrap();
54///
55/// PieChart::builder()
56///     .data(&dataset)
57///     .labels("species")
58///     .hole(0.4)
59///     .pull(0.01)
60///     .rotation(20.0)
61///     .plot_title(
62///         Text::from("Pie Chart")
63///             .font("Arial")
64///             .size(18)
65///             .x(0.485)
66///     )
67///     .build()
68///     .plot();
69/// ```
70///
71/// ![Example](https://imgur.com/q44HDwT.png)
72#[derive(Clone)]
73#[allow(dead_code)]
74pub struct PieChart {
75    traces: Vec<TraceIR>,
76    layout: LayoutIR,
77}
78
79struct FacetCell {
80    pie_x_start: f64,
81    pie_x_end: f64,
82    pie_y_start: f64,
83    pie_y_end: f64,
84}
85
86#[bon]
87impl PieChart {
88    #[builder(on(String, into), on(Text, into))]
89    pub fn new(
90        data: &DataFrame,
91        labels: &str,
92        facet: Option<&str>,
93        facet_config: Option<&FacetConfig>,
94        hole: Option<f64>,
95        pull: Option<f64>,
96        rotation: Option<f64>,
97        colors: Option<Vec<Rgb>>,
98        plot_title: Option<Text>,
99        legend_title: Option<Text>,
100        legend: Option<&Legend>,
101    ) -> Self {
102        let grid = facet.map(|facet_column| {
103            let config = facet_config.cloned().unwrap_or_default();
104            let facet_categories =
105                crate::data::get_unique_groups(data, facet_column, config.sorter);
106            let n_facets = facet_categories.len();
107            let (ncols, nrows) =
108                crate::faceting::calculate_grid_dimensions(n_facets, config.cols, config.rows);
109            crate::ir::facet::GridSpec {
110                kind: crate::ir::facet::FacetKind::Domain,
111                rows: nrows,
112                cols: ncols,
113                h_gap: config.h_gap,
114                v_gap: config.v_gap,
115                scales: config.scales.clone(),
116                n_facets,
117                facet_categories,
118                title_style: config.title_style.clone(),
119                x_title: None,
120                y_title: None,
121                x_axis: None,
122                y_axis: None,
123                legend_title: legend_title.clone(),
124                legend: legend.cloned(),
125            }
126        });
127
128        let layout = LayoutIR {
129            title: plot_title,
130            x_title: None,
131            y_title: None,
132            y2_title: None,
133            z_title: None,
134            legend_title: if grid.is_some() { None } else { legend_title },
135            legend: if grid.is_some() {
136                None
137            } else {
138                legend.cloned()
139            },
140            dimensions: None,
141            bar_mode: None,
142            box_mode: None,
143            box_gap: None,
144            margin_bottom: None,
145            axes_2d: None,
146            scene_3d: None,
147            polar: None,
148            mapbox: None,
149            grid,
150            annotations: vec![],
151        };
152
153        let traces = match facet {
154            Some(facet_column) => {
155                let config = facet_config.cloned().unwrap_or_default();
156                Self::create_ir_traces_faceted(
157                    data,
158                    labels,
159                    facet_column,
160                    &config,
161                    hole,
162                    pull,
163                    rotation,
164                    colors,
165                )
166            }
167            None => Self::create_ir_traces(data, labels, hole, pull, rotation, colors),
168        };
169        Self { traces, layout }
170    }
171}
172
173#[bon]
174impl PieChart {
175    #[builder(
176        start_fn = try_builder,
177        finish_fn = try_build,
178        builder_type = PieChartTryBuilder,
179        on(String, into),
180        on(Text, into),
181    )]
182    pub fn try_new(
183        data: &DataFrame,
184        labels: &str,
185        facet: Option<&str>,
186        facet_config: Option<&FacetConfig>,
187        hole: Option<f64>,
188        pull: Option<f64>,
189        rotation: Option<f64>,
190        colors: Option<Vec<Rgb>>,
191        plot_title: Option<Text>,
192        legend_title: Option<Text>,
193        legend: Option<&Legend>,
194    ) -> Result<Self, crate::io::PlotlarsError> {
195        std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
196            Self::__orig_new(
197                data,
198                labels,
199                facet,
200                facet_config,
201                hole,
202                pull,
203                rotation,
204                colors,
205                plot_title,
206                legend_title,
207                legend,
208            )
209        }))
210        .map_err(|panic| {
211            let msg = panic
212                .downcast_ref::<String>()
213                .cloned()
214                .or_else(|| panic.downcast_ref::<&str>().map(|s| s.to_string()))
215                .unwrap_or_else(|| "unknown error".to_string());
216            crate::io::PlotlarsError::PlotBuild { message: msg }
217        })
218    }
219}
220
221impl PieChart {
222    fn create_ir_traces(
223        data: &DataFrame,
224        labels: &str,
225        hole: Option<f64>,
226        pull: Option<f64>,
227        rotation: Option<f64>,
228        colors: Option<Vec<Rgb>>,
229    ) -> Vec<TraceIR> {
230        vec![TraceIR::PieChart(PieChartIR {
231            labels: ColumnData::String(crate::data::get_string_column(data, labels)),
232            values: None,
233            name: None,
234            hole,
235            pull,
236            rotation,
237            colors,
238            domain_x: Some((0.0, 1.0)),
239            domain_y: Some((0.0, 0.9)),
240        })]
241    }
242
243    #[allow(clippy::too_many_arguments)]
244    fn create_ir_traces_faceted(
245        data: &DataFrame,
246        labels: &str,
247        facet_column: &str,
248        config: &FacetConfig,
249        hole: Option<f64>,
250        pull: Option<f64>,
251        rotation: Option<f64>,
252        colors: Option<Vec<Rgb>>,
253    ) -> Vec<TraceIR> {
254        const MAX_FACETS: usize = 8;
255
256        let facet_categories = crate::data::get_unique_groups(data, facet_column, config.sorter);
257
258        if facet_categories.len() > MAX_FACETS {
259            panic!(
260                "Facet column '{}' has {} unique values, but plotly.rs supports maximum {} subplots",
261                facet_column,
262                facet_categories.len(),
263                MAX_FACETS
264            );
265        }
266
267        let n_facets = facet_categories.len();
268        let (ncols, nrows) =
269            crate::faceting::calculate_grid_dimensions(n_facets, config.cols, config.rows);
270
271        let facet_categories_non_empty: Vec<String> = facet_categories
272            .iter()
273            .filter(|facet_value| {
274                let facet_data = crate::data::filter_data_by_group(data, facet_column, facet_value);
275                facet_data.height() > 0
276            })
277            .cloned()
278            .collect();
279
280        let mut traces = Vec::new();
281
282        for (idx, facet_value) in facet_categories_non_empty.iter().enumerate() {
283            let facet_data = crate::data::filter_data_by_group(data, facet_column, facet_value);
284
285            let cell = Self::calculate_facet_cell(idx, ncols, nrows, config.h_gap, config.v_gap);
286
287            traces.push(TraceIR::PieChart(PieChartIR {
288                labels: ColumnData::String(crate::data::get_string_column(&facet_data, labels)),
289                values: None,
290                name: None,
291                hole,
292                pull,
293                rotation,
294                colors: colors.clone(),
295                domain_x: Some((cell.pie_x_start, cell.pie_x_end)),
296                domain_y: Some((cell.pie_y_start, cell.pie_y_end)),
297            }));
298        }
299
300        traces
301    }
302    /// Calculates the grid cell positions for a subplot with reserved space for titles.
303    ///
304    /// This function computes both the pie chart domain and annotation position,
305    /// ensuring that space is reserved above each pie chart for the facet title.
306    /// The title space prevents overlap between annotations and adjacent pie charts.
307    fn calculate_facet_cell(
308        subplot_index: usize,
309        ncols: usize,
310        nrows: usize,
311        x_gap: Option<f64>,
312        y_gap: Option<f64>,
313    ) -> FacetCell {
314        let row = subplot_index / ncols;
315        let col = subplot_index % ncols;
316
317        let x_gap_val = x_gap.unwrap_or(0.05);
318        let y_gap_val = y_gap.unwrap_or(0.10);
319
320        // Reserve space for facet title (10% of each cell's height)
321        const TITLE_HEIGHT_RATIO: f64 = 0.10;
322
323        // Calculate total cell dimensions
324        let cell_width = (1.0 - x_gap_val * (ncols - 1) as f64) / ncols as f64;
325        let cell_height = (1.0 - y_gap_val * (nrows - 1) as f64) / nrows as f64;
326
327        // Calculate cell boundaries
328        let cell_x_start = col as f64 * (cell_width + x_gap_val);
329        let cell_y_top = 1.0 - row as f64 * (cell_height + y_gap_val);
330        let cell_y_bottom = cell_y_top - cell_height;
331
332        // Reserve title space at the top of the cell (maintains 90% pie size)
333        let title_height = cell_height * TITLE_HEIGHT_RATIO;
334        let pie_y_top = cell_y_top - title_height;
335
336        // Pie chart domain (bottom 90% of the cell - preserved from original)
337        let pie_x_start = cell_x_start;
338        let pie_x_end = cell_x_start + cell_width;
339        let pie_y_start = cell_y_bottom;
340        let pie_y_end = pie_y_top;
341
342        // Calculate annotation position with padding buffer
343        FacetCell {
344            pie_x_start,
345            pie_x_end,
346            pie_y_start,
347            pie_y_end,
348        }
349    }
350}
351
352impl crate::Plot for PieChart {
353    fn ir_traces(&self) -> &[TraceIR] {
354        &self.traces
355    }
356
357    fn ir_layout(&self) -> &LayoutIR {
358        &self.layout
359    }
360}
361
362#[cfg(test)]
363mod tests {
364    use super::*;
365    use crate::Plot;
366    use polars::prelude::*;
367
368    #[test]
369    fn test_facet_cell_single() {
370        let cell = PieChart::calculate_facet_cell(0, 1, 1, None, None);
371        assert!(cell.pie_x_start >= 0.0 && cell.pie_x_end <= 1.0);
372        assert!(cell.pie_y_start >= 0.0 && cell.pie_y_end <= 1.0);
373        assert!(cell.pie_x_start < cell.pie_x_end);
374        assert!(cell.pie_y_start < cell.pie_y_end);
375    }
376
377    #[test]
378    fn test_facet_cell_2x2_first() {
379        let cell = PieChart::calculate_facet_cell(0, 2, 2, None, None);
380        assert!(cell.pie_x_start < 0.01);
381    }
382
383    #[test]
384    fn test_facet_cell_2x2_last() {
385        let cell = PieChart::calculate_facet_cell(3, 2, 2, None, None);
386        assert!(cell.pie_x_start > 0.4);
387    }
388
389    #[test]
390    fn test_facet_cell_bounds() {
391        for idx in 0..4 {
392            let cell = PieChart::calculate_facet_cell(idx, 2, 2, None, None);
393            assert!(cell.pie_x_start < cell.pie_x_end);
394            assert!(cell.pie_y_start < cell.pie_y_end);
395        }
396    }
397
398    #[test]
399    fn test_basic_one_trace() {
400        let df = df!["labels" => ["a", "b", "c", "a", "b"]].unwrap();
401        let plot = PieChart::builder().data(&df).labels("labels").build();
402        assert_eq!(plot.ir_traces().len(), 1);
403    }
404
405    #[test]
406    fn test_faceted() {
407        let df = df![
408            "labels" => ["a", "b", "c", "a"],
409            "facet" => ["f1", "f1", "f2", "f2"]
410        ]
411        .unwrap();
412        let plot = PieChart::builder()
413            .data(&df)
414            .labels("labels")
415            .facet("facet")
416            .build();
417        assert_eq!(plot.ir_traces().len(), 2);
418    }
419}