ggplot_rs/stat/
boxplot.rs1use crate::aes::Aesthetic;
2use crate::data::{DataFrame, Value};
3use crate::scale::ScaleSet;
4
5use super::Stat;
6
7#[derive(Clone, Debug, PartialEq)]
10pub struct StatBoxplot {
11 pub coef: f64,
14}
15
16#[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 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 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 let outliers: Vec<f64> = values
77 .iter()
78 .filter(|&&v| v < lower_fence || v > upper_fence)
79 .copied()
80 .collect();
81
82 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 if !outliers.is_empty() {
108 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 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}