Skip to main content

ggplot_rs/geom/
funnel.rs

1//! Funnel and pyramid charts: `geom_funnel` / `geom_pyramid`. Stages are
2//! stacked top to bottom as centred polygons; labels show each stage's name,
3//! value and share. Layout by [`StatFunnel`].
4
5use crate::aes::Aesthetic;
6use crate::coord::Coord;
7use crate::data::{DataFrame, Value};
8use crate::position::identity::PositionIdentity;
9use crate::position::Position;
10use crate::render::backend::{DrawBackend, FontFace, RectStyle, TextAnchor, TextStyle};
11use crate::render::RenderError;
12use crate::scale::ScaleSet;
13use crate::stat::funnel::{FunnelShape, StatFunnel};
14use crate::stat::Stat;
15use crate::theme::Theme;
16
17use super::treemap::contrast_text;
18use super::{Geom, GeomParams};
19
20/// What a stage label shows after the stage name and value.
21#[derive(Clone, Copy, Debug, PartialEq)]
22pub enum FunnelPercent {
23    /// No percentage.
24    None,
25    /// Share of the first stage (conversion from the top).
26    OfFirst,
27    /// Share of the total.
28    OfTotal,
29}
30
31/// Funnel / pyramid chart. Map `x` (stage, in data order) and `y` (value).
32pub struct GeomFunnel {
33    pub shape: FunnelShape,
34    /// Vertical gap between stages as a fraction of the chart height.
35    pub gap: f64,
36    /// Fill when no fill scale applies.
37    pub fill: (u8, u8, u8),
38    pub alpha: f64,
39    /// Draw "name: value (pct)" labels inside the stages.
40    pub labels: bool,
41    pub percent: FunnelPercent,
42    pub label_size: f64,
43    /// Colour of labels drawn outside a stage too narrow to hold them.
44    pub label_color: (u8, u8, u8),
45}
46
47impl Default for GeomFunnel {
48    fn default() -> Self {
49        GeomFunnel {
50            shape: FunnelShape::Proportional,
51            gap: 0.012,
52            fill: (97, 156, 255),
53            alpha: 1.0,
54            labels: true,
55            percent: FunnelPercent::OfFirst,
56            label_size: 10.0,
57            label_color: (40, 40, 40),
58        }
59    }
60}
61
62impl GeomFunnel {
63    /// Highcharts-style funnel: fixed silhouette narrowing to a neck,
64    /// stage heights proportional to the values.
65    pub fn silhouette(neck_width: f64, neck_height: f64) -> Self {
66        GeomFunnel {
67            shape: FunnelShape::Silhouette {
68                neck_width,
69                neck_height,
70            },
71            percent: FunnelPercent::OfTotal,
72            ..Default::default()
73        }
74    }
75
76    /// Pyramid: apex on top, stage heights proportional to the values.
77    pub fn pyramid() -> Self {
78        GeomFunnel {
79            shape: FunnelShape::Pyramid,
80            percent: FunnelPercent::OfTotal,
81            ..Default::default()
82        }
83    }
84}
85
86impl Geom for GeomFunnel {
87    fn draw(
88        &self,
89        data: &DataFrame,
90        coord: &dyn Coord,
91        scales: &ScaleSet,
92        theme: &Theme,
93        backend: &mut dyn DrawBackend,
94    ) -> Result<(), RenderError> {
95        let col = |n: &str| {
96            data.column(n)
97                .ok_or_else(|| RenderError::MissingAesthetic(n.into()))
98        };
99        let (xc, yc, stage, value) = (col("fx")?, col("fy")?, col("stage")?, col("value")?);
100        let (ly, pf, pt) = (
101            col("label_y")?,
102            data.column("pct_first"),
103            data.column("pct_total"),
104        );
105        let fill_col = data.column("fill");
106        let area = backend.plot_area();
107        // The layout is already normalised (fx ∈ [-0.5, 0.5], fy ∈ [0, 1]):
108        // place it in the panel directly, with a small margin.
109        let map = |x: f64, y: f64| coord.transform((0.5 + 0.96 * x, 0.02 + 0.96 * y), &area);
110        let family = super::resolve_font_family("", theme);
111
112        // Rows come grouped by stage, in drawing order.
113        let mut i = 0;
114        let mut labels = Vec::new();
115        while i < data.nrows() {
116            let key = stage[i].key_str();
117            let mut j = i;
118            while j < data.nrows() && stage[j].key_str() == key {
119                j += 1;
120            }
121            let pts: Vec<(f64, f64)> = (i..j)
122                .filter_map(|k| Some(map(xc[k].as_f64()?, yc[k].as_f64()?)))
123                .collect();
124            let fill = fill_col
125                .and_then(|c| scales.map_color(&Aesthetic::Fill, &c[i]))
126                .unwrap_or(self.fill);
127            let pct = match self.percent {
128                FunnelPercent::None => None,
129                FunnelPercent::OfFirst => pf.and_then(|c| c[i].as_f64()),
130                FunnelPercent::OfTotal => pt.and_then(|c| c[i].as_f64()),
131            }
132            .map(|p| {
133                format!(
134                    " ({}%)",
135                    super::tip_value(&Value::Float((p * 1000.0).round() / 10.0))
136                )
137            })
138            .unwrap_or_default();
139            let name = super::tip_value(&stage[i]);
140            let text = format!("{name}: {}{pct}", super::tip_value(&value[i]));
141            super::set_mark(
142                backend,
143                Some(text.clone()),
144                Some(name),
145                super::series_key(data, i),
146                super::raw_value(&value[i]),
147            );
148            if pts.len() >= 3 {
149                backend.draw_polygon(
150                    &pts,
151                    &RectStyle {
152                        fill: Some(fill),
153                        stroke: None,
154                        stroke_width: 0.0,
155                        alpha: self.alpha,
156                        clip: true,
157                        corner_radius: 0.0,
158                    },
159                )?;
160            }
161            if self.labels {
162                if let Some(y) = ly[i].as_f64() {
163                    // Inside when it fits the stage's mean width, else to the
164                    // right of the shape in ink.
165                    let half = (i..j)
166                        .filter_map(|k| xc[k].as_f64())
167                        .map(f64::abs)
168                        .sum::<f64>()
169                        / (j - i).max(1) as f64;
170                    let (l, r) = (map(-half, y), map(half, y));
171                    let need = text.chars().count() as f64 * self.label_size * 0.6 + 8.0;
172                    if (r.0 - l.0).abs() >= need {
173                        labels.push((text, map(0.0, y), contrast_text(fill), TextAnchor::Middle));
174                    } else {
175                        let edge = (i..j).filter_map(|k| xc[k].as_f64()).fold(0.0, f64::max);
176                        let p = map(edge, y);
177                        labels.push((text, (p.0 + 6.0, p.1), self.label_color, TextAnchor::Start));
178                    }
179                }
180            }
181            i = j;
182        }
183        super::clear_mark(backend);
184        for (text, pos, color, anchor) in labels {
185            backend.draw_text(
186                &text,
187                pos,
188                &TextStyle {
189                    color,
190                    size: self.label_size,
191                    anchor,
192                    angle: 0.0,
193                    family: family.clone(),
194                    face: FontFace::Plain,
195                    bg_colour: None,
196                    bg_r: 0.0,
197                },
198            )?;
199        }
200        Ok(())
201    }
202
203    fn required_aes(&self) -> Vec<Aesthetic> {
204        vec![Aesthetic::X, Aesthetic::Y]
205    }
206
207    fn default_stat(&self) -> Box<dyn Stat> {
208        Box::new(StatFunnel {
209            shape: self.shape,
210            gap: self.gap,
211        })
212    }
213
214    fn default_position(&self) -> Box<dyn Position> {
215        Box::new(PositionIdentity)
216    }
217
218    fn default_params(&self) -> GeomParams {
219        GeomParams::default()
220    }
221
222    fn name(&self) -> &str {
223        "funnel"
224    }
225
226    fn apply_element_geom(&mut self, el: &crate::theme::elements::ElementGeom) {
227        if let Some(ink) = el.ink {
228            self.label_color = ink;
229        }
230    }
231}