Skip to main content

ggplot_rs/stat/
boxplot.rs

1use crate::aes::Aesthetic;
2use crate::data::{DataFrame, Value};
3use crate::scale::ScaleSet;
4
5use super::Stat;
6
7/// Computes boxplot statistics: quartiles, whiskers, outliers, and `n` (the
8/// group's observation count, for `varwidth`).
9#[derive(Clone, Debug, PartialEq)]
10pub struct StatBoxplot {
11    /// Whisker reach in IQRs (ggplot2's `coef`, default 1.5): points beyond
12    /// `[Q1 − coef·IQR, Q3 + coef·IQR]` are outliers.
13    pub coef: f64,
14}
15
16/// The default stat (`coef = 1.5`): keeps `.stat(StatBoxplot)` compiling now
17/// that the type has a field.
18#[allow(non_upper_case_globals)]
19pub const StatBoxplot: StatBoxplot = StatBoxplot { coef: 1.5 };
20
21impl Default for StatBoxplot {
22    fn default() -> Self {
23        StatBoxplot
24    }
25}
26
27impl StatBoxplot {
28    /// `stat_boxplot(coef = …)`.
29    pub fn with_coef(coef: f64) -> Self {
30        StatBoxplot { coef }
31    }
32}
33
34impl Stat for StatBoxplot {
35    fn compute_group(&self, data: &DataFrame, _scales: &ScaleSet) -> DataFrame {
36        let y_col = match data.column("y") {
37            Some(c) => c,
38            None => return DataFrame::new(),
39        };
40
41        let mut values: Vec<f64> = y_col.iter().filter_map(|v| v.as_f64()).collect();
42        if values.is_empty() {
43            return DataFrame::new();
44        }
45
46        values.sort_by(|a, b| a.total_cmp(b));
47
48        let n = values.len();
49        let q1 = percentile(&values, 25.0);
50        let median = percentile(&values, 50.0);
51        let q3 = percentile(&values, 75.0);
52        let iqr = q3 - q1;
53
54        let coef = if self.coef.is_finite() && self.coef >= 0.0 {
55            self.coef
56        } else {
57            1.5
58        };
59        let lower_fence = q1 - coef * iqr;
60        let upper_fence = q3 + coef * iqr;
61
62        // Whiskers extend to most extreme non-outlier
63        let ymin = values
64            .iter()
65            .find(|&&v| v >= lower_fence)
66            .copied()
67            .unwrap_or(q1);
68        let ymax = values
69            .iter()
70            .rev()
71            .find(|&&v| v <= upper_fence)
72            .copied()
73            .unwrap_or(q3);
74
75        // Outliers
76        let outliers: Vec<f64> = values
77            .iter()
78            .filter(|&&v| v < lower_fence || v > upper_fence)
79            .copied()
80            .collect();
81
82        // Get x value (group identifier)
83        let x_val = data
84            .column("x")
85            .and_then(|c| c.first())
86            .cloned()
87            .unwrap_or(Value::Float(0.0));
88
89        let mut result = DataFrame::new();
90        result.add_column("x".to_string(), vec![x_val.clone()]);
91        result.add_column("ymin".to_string(), vec![Value::Float(ymin)]);
92        result.add_column("lower".to_string(), vec![Value::Float(q1)]);
93        result.add_column("middle".to_string(), vec![Value::Float(median)]);
94        result.add_column("upper".to_string(), vec![Value::Float(q3)]);
95        result.add_column("ymax".to_string(), vec![Value::Float(ymax)]);
96        result.add_column(
97            "notchupper".to_string(),
98            vec![Value::Float(median + 1.58 * iqr / (n as f64).sqrt())],
99        );
100        result.add_column(
101            "notchlower".to_string(),
102            vec![Value::Float(median - 1.58 * iqr / (n as f64).sqrt())],
103        );
104        result.add_column("n".to_string(), vec![Value::Float(n as f64)]);
105
106        // Store outliers as separate rows in a companion column
107        if !outliers.is_empty() {
108            // We'll encode outlier count; actual outlier drawing handled by geom
109            let outlier_str = outliers
110                .iter()
111                .map(|v| v.to_string())
112                .collect::<Vec<_>>()
113                .join(",");
114            result.add_column("outliers".to_string(), vec![Value::Str(outlier_str)]);
115        }
116
117        // Carry over fill/color
118        for col_name in &["fill", "color"] {
119            if let Some(col) = data.column(col_name) {
120                if let Some(first) = col.first() {
121                    result.add_column(col_name.to_string(), vec![first.clone()]);
122                }
123            }
124        }
125
126        result
127    }
128
129    fn required_aes(&self) -> Vec<Aesthetic> {
130        vec![Aesthetic::X, Aesthetic::Y]
131    }
132
133    fn name(&self) -> &str {
134        "boxplot"
135    }
136}
137
138fn percentile(sorted: &[f64], p: f64) -> f64 {
139    if sorted.is_empty() {
140        return 0.0;
141    }
142    if sorted.len() == 1 {
143        return sorted[0];
144    }
145
146    let k = (p / 100.0) * (sorted.len() - 1) as f64;
147    let f = k.floor() as usize;
148    let c = k.ceil() as usize;
149
150    if f == c {
151        sorted[f]
152    } else {
153        let d = k - f as f64;
154        sorted[f] * (1.0 - d) + sorted[c] * d
155    }
156}