Skip to main content

ggplot_rs/geom/
variablepie.rs

1//! Variable-radius pie: `geom_variable_pie`. Each wedge's angle encodes
2//! `y` (its share of the total) and its outer radius encodes `radius`
3//! (linearly between `min_radius` and the full radius — Highcharts'
4//! `variablepie`). Wedges run clockwise from 12 o'clock in data order;
5//! drawn directly in the panel, so the pie stays round.
6
7use std::f64::consts::{FRAC_PI_2, TAU};
8
9use crate::aes::Aesthetic;
10use crate::coord::Coord;
11use crate::data::DataFrame;
12use crate::position::identity::PositionIdentity;
13use crate::position::Position;
14use crate::render::backend::{DrawBackend, FontFace, RectStyle, TextAnchor, TextStyle};
15use crate::render::RenderError;
16use crate::scale::ScaleSet;
17use crate::stat::identity::StatIdentity;
18use crate::stat::Stat;
19use crate::theme::Theme;
20
21use super::{Geom, GeomParams};
22
23/// Variable-radius pie. Map `y` (angle value), `radius` and `fill`
24/// (category); optionally `label`.
25pub struct GeomVariablePie {
26    /// Inner radius as a fraction of the full radius (0 = pie, > 0 = donut).
27    pub inner: f64,
28    /// Outer radius of the smallest `radius` value, as a fraction of the full
29    /// radius.
30    pub min_radius: f64,
31    /// Fill when `fill` is unmapped.
32    pub fill: (u8, u8, u8),
33    pub border: (u8, u8, u8),
34    /// Label wedges (the `label`, else `fill` value) outside their rim.
35    pub labels: bool,
36    pub label_size: f64,
37    pub label_color: (u8, u8, u8),
38}
39
40impl Default for GeomVariablePie {
41    fn default() -> Self {
42        GeomVariablePie {
43            inner: 0.0,
44            min_radius: 0.35,
45            fill: (97, 156, 255),
46            border: (255, 255, 255),
47            labels: true,
48            label_size: 10.0,
49            label_color: (40, 40, 40),
50        }
51    }
52}
53
54/// Wedge outer radius fraction for `v` in `[lo, hi]`, linear onto
55/// `[min_r, 1]` (all equal → 1).
56pub(crate) fn radius_fraction(v: f64, lo: f64, hi: f64, min_r: f64) -> f64 {
57    if hi > lo {
58        min_r + (1.0 - min_r) * ((v - lo) / (hi - lo)).clamp(0.0, 1.0)
59    } else {
60        1.0
61    }
62}
63
64impl Geom for GeomVariablePie {
65    fn draw(
66        &self,
67        data: &DataFrame,
68        _coord: &dyn Coord,
69        scales: &ScaleSet,
70        theme: &Theme,
71        backend: &mut dyn DrawBackend,
72    ) -> Result<(), RenderError> {
73        let y_col = data
74            .column("y")
75            .ok_or(RenderError::MissingAesthetic("y".into()))?;
76        let r_col = data
77            .column("radius")
78            .ok_or(RenderError::MissingAesthetic("radius".into()))?;
79        let (fill_col, label_col) = (data.column("fill"), data.column("label"));
80        let rows: Vec<(usize, f64, f64)> = (0..data.nrows())
81            .filter_map(|i| {
82                let (y, r) = (y_col[i].as_f64()?, r_col[i].as_f64()?);
83                (y.is_finite() && y > 0.0 && r.is_finite()).then_some((i, y, r))
84            })
85            .collect();
86        let total: f64 = rows.iter().map(|r| r.1).sum();
87        if total <= 0.0 {
88            return Ok(());
89        }
90        let (lo, hi) = rows
91            .iter()
92            .fold((f64::MAX, f64::MIN), |(a, b), r| (a.min(r.2), b.max(r.2)));
93        let area = backend.plot_area();
94        let c = (area.x + area.width / 2.0, area.y + area.height / 2.0);
95        let room = if self.labels { 0.8 } else { 0.95 };
96        let full = room * area.width.min(area.height) / 2.0;
97        let pt = |a: f64, r: f64| (c.0 + r * a.cos(), c.1 + r * a.sin());
98        let family = super::resolve_font_family("", theme);
99        let mut labels = Vec::new();
100        let mut start = -FRAC_PI_2;
101        for (i, y, rv) in rows {
102            let sweep = TAU * y / total;
103            let outer = full * radius_fraction(rv, lo, hi, self.min_radius);
104            let inner = full * self.inner.clamp(0.0, 0.95);
105            let steps = ((sweep / 0.04).ceil() as usize).max(2);
106            let mut poly: Vec<(f64, f64)> = (0..=steps)
107                .map(|k| pt(start + sweep * k as f64 / steps as f64, outer))
108                .collect();
109            if inner > 0.0 {
110                poly.extend(
111                    (0..=steps)
112                        .rev()
113                        .map(|k| pt(start + sweep * k as f64 / steps as f64, inner)),
114                );
115            } else {
116                poly.push(c);
117            }
118            let fill = fill_col
119                .and_then(|f| scales.map_color(&Aesthetic::Fill, &f[i]))
120                .unwrap_or(self.fill);
121            let name = label_col
122                .or(fill_col)
123                .map(|l| super::tip_value(&l[i]))
124                .unwrap_or_default();
125            let tip = format!(
126                "{name}: {} (radius {})",
127                super::tip_value(&y_col[i]),
128                super::tip_value(&r_col[i])
129            );
130            super::set_mark(
131                backend,
132                Some(tip),
133                Some(name.clone()),
134                super::series_key(data, i),
135                super::raw_value(&y_col[i]),
136            );
137            super::set_row_extras(backend, data, i);
138            backend.draw_polygon(
139                &poly,
140                &RectStyle {
141                    fill: Some(fill),
142                    stroke: Some(self.border),
143                    stroke_width: 1.0,
144                    alpha: 1.0,
145                    clip: false,
146                    corner_radius: 0.0,
147                },
148            )?;
149            if self.labels && !name.is_empty() {
150                let mid = start + sweep / 2.0;
151                let p = pt(mid, outer + 8.0);
152                let anchor = if mid.cos().abs() < 0.15 {
153                    TextAnchor::Middle
154                } else if mid.cos() > 0.0 {
155                    TextAnchor::Start
156                } else {
157                    TextAnchor::End
158                };
159                labels.push((name, p, anchor));
160            }
161            start += sweep;
162        }
163        super::clear_mark(backend);
164        super::clear_row_extras(backend);
165        for (text, pos, anchor) in labels {
166            backend.draw_text(
167                &text,
168                pos,
169                &TextStyle {
170                    color: self.label_color,
171                    size: self.label_size,
172                    anchor,
173                    angle: 0.0,
174                    family: family.clone(),
175                    face: FontFace::Plain,
176                    bg_colour: None,
177                    bg_r: 0.0,
178                },
179            )?;
180        }
181        Ok(())
182    }
183
184    fn required_aes(&self) -> Vec<Aesthetic> {
185        vec![Aesthetic::Y, Aesthetic::Radius]
186    }
187    fn default_stat(&self) -> Box<dyn Stat> {
188        Box::new(StatIdentity)
189    }
190    fn default_position(&self) -> Box<dyn Position> {
191        Box::new(PositionIdentity)
192    }
193    fn default_params(&self) -> GeomParams {
194        GeomParams::default()
195    }
196    fn name(&self) -> &str {
197        "variable_pie"
198    }
199    fn consumes_tooltips(&self) -> bool {
200        true
201    }
202    fn apply_element_geom(&mut self, el: &crate::theme::elements::ElementGeom) {
203        if let Some(ink) = el.ink {
204            self.label_color = ink;
205        }
206        if let (true, Some(paper)) = (self.border == (255, 255, 255), el.paper) {
207            self.border = paper;
208        }
209    }
210    fn set_series_color(&mut self, color: (u8, u8, u8)) {
211        self.fill = color;
212    }
213}
214
215#[cfg(test)]
216mod tests {
217    use super::*;
218
219    #[test]
220    fn radius_maps_linearly() {
221        assert_eq!(radius_fraction(10.0, 10.0, 20.0, 0.4), 0.4);
222        assert_eq!(radius_fraction(20.0, 10.0, 20.0, 0.4), 1.0);
223        assert!((radius_fraction(15.0, 10.0, 20.0, 0.4) - 0.7).abs() < 1e-12);
224        assert_eq!(radius_fraction(5.0, 5.0, 5.0, 0.4), 1.0);
225    }
226}