1use crate::color::Color;
17use crate::core::{Bounds, Canvas, Drawable, Point2D};
18use crate::error::Result;
19use crate::legend::LegendEntry;
20
21pub struct BoxPlot {
25 data: Vec<f64>,
26 position: f64,
27 width: f64,
28 color: Color,
29 label: Option<String>,
30 show_outliers: bool,
31 outlier_method: OutlierMethod,
32}
33
34#[derive(Debug, Clone, Copy)]
36pub enum OutlierMethod {
37 IQR,
39 None,
41}
42
43impl BoxPlot {
44 #[must_use]
54 pub fn new(data: Vec<f64>) -> Self {
55 Self {
56 data,
57 position: 1.0,
58 width: 0.6,
59 color: Color::from_hex("#3498db").unwrap(),
60 label: None,
61 show_outliers: true,
62 outlier_method: OutlierMethod::IQR,
63 }
64 }
65
66 #[must_use]
68 pub fn position(mut self, position: f64) -> Self {
69 self.position = position;
70 self
71 }
72
73 #[must_use]
75 pub fn width(mut self, width: f64) -> Self {
76 self.width = width.clamp(0.1, 2.0);
77 self
78 }
79
80 #[must_use]
82 pub fn color(mut self, color: Color) -> Self {
83 self.color = color;
84 self
85 }
86
87 #[must_use]
89 pub fn label(mut self, label: impl Into<String>) -> Self {
90 self.label = Some(label.into());
91 self
92 }
93
94 #[must_use]
96 pub fn show_outliers(mut self, show: bool) -> Self {
97 self.show_outliers = show;
98 self
99 }
100
101 #[must_use]
103 pub fn outlier_method(mut self, method: OutlierMethod) -> Self {
104 self.outlier_method = method;
105 self
106 }
107
108 fn calculate_stats(&self) -> BoxStats {
110 let mut sorted = self.data.clone();
111 sorted.sort_by(|a, b| a.partial_cmp(b).unwrap());
112
113 let n = sorted.len();
114 if n == 0 {
115 return BoxStats::default();
116 }
117
118 let q1 = percentile(&sorted, 25.0);
119 let median = percentile(&sorted, 50.0);
120 let q3 = percentile(&sorted, 75.0);
121 let iqr = q3 - q1;
122
123 let (lower_whisker, upper_whisker, outliers) = match self.outlier_method {
124 OutlierMethod::IQR => {
125 let lower_fence = q1 - 1.5 * iqr;
126 let upper_fence = q3 + 1.5 * iqr;
127
128 let lower_whisker = sorted
129 .iter()
130 .find(|&&x| x >= lower_fence)
131 .copied()
132 .unwrap_or(sorted[0]);
133
134 let upper_whisker = sorted
135 .iter()
136 .rev()
137 .find(|&&x| x <= upper_fence)
138 .copied()
139 .unwrap_or(sorted[n - 1]);
140
141 let outliers: Vec<f64> = sorted
142 .iter()
143 .filter(|&&x| x < lower_fence || x > upper_fence)
144 .copied()
145 .collect();
146
147 (lower_whisker, upper_whisker, outliers)
148 }
149 OutlierMethod::None => {
150 let lower_whisker = sorted[0];
151 let upper_whisker = sorted[n - 1];
152 (lower_whisker, upper_whisker, Vec::new())
153 }
154 };
155
156 BoxStats {
157 q1,
158 median,
159 q3,
160 lower_whisker,
161 upper_whisker,
162 outliers,
163 }
164 }
165
166 #[must_use]
168 pub fn legend_entry(&self) -> Option<LegendEntry> {
169 self.label.as_ref().map(|label| {
170 LegendEntry::new(label.clone())
171 .color(self.color)
172 .box_shape()
173 })
174 }
175}
176
177#[derive(Debug, Clone, Default)]
178struct BoxStats {
179 q1: f64,
180 median: f64,
181 q3: f64,
182 lower_whisker: f64,
183 upper_whisker: f64,
184 outliers: Vec<f64>,
185}
186
187fn percentile(sorted: &[f64], p: f64) -> f64 {
189 let n = sorted.len();
190 if n == 0 {
191 return 0.0;
192 }
193 if n == 1 {
194 return sorted[0];
195 }
196
197 let rank = (p / 100.0) * (n - 1) as f64;
198 let lower = rank.floor() as usize;
199 let upper = rank.ceil() as usize;
200 let fraction = rank - lower as f64;
201
202 sorted[lower] * (1.0 - fraction) + sorted[upper] * fraction
203}
204
205impl Drawable for BoxPlot {
206 fn draw(&self, canvas: &mut dyn Canvas) -> Result<()> {
207 let stats = self.calculate_stats();
208
209 let half_width = self.width / 2.0;
210 let left = self.position - half_width;
211 let right = self.position + half_width;
212
213 canvas.draw_line(
215 &Point2D::new(self.position, stats.lower_whisker),
216 &Point2D::new(self.position, stats.q1),
217 &self.color.to_rgba(),
218 1.5,
219 )?;
220
221 canvas.draw_line(
222 &Point2D::new(self.position, stats.q3),
223 &Point2D::new(self.position, stats.upper_whisker),
224 &self.color.to_rgba(),
225 1.5,
226 )?;
227
228 let cap_width = self.width * 0.3;
230 canvas.draw_line(
231 &Point2D::new(self.position - cap_width / 2.0, stats.lower_whisker),
232 &Point2D::new(self.position + cap_width / 2.0, stats.lower_whisker),
233 &self.color.to_rgba(),
234 1.5,
235 )?;
236
237 canvas.draw_line(
238 &Point2D::new(self.position - cap_width / 2.0, stats.upper_whisker),
239 &Point2D::new(self.position + cap_width / 2.0, stats.upper_whisker),
240 &self.color.to_rgba(),
241 1.5,
242 )?;
243
244 canvas.draw_line(
247 &Point2D::new(left, stats.q1),
248 &Point2D::new(left, stats.q3),
249 &self.color.to_rgba(),
250 2.0,
251 )?;
252 canvas.draw_line(
254 &Point2D::new(right, stats.q1),
255 &Point2D::new(right, stats.q3),
256 &self.color.to_rgba(),
257 2.0,
258 )?;
259 canvas.draw_line(
261 &Point2D::new(left, stats.q3),
262 &Point2D::new(right, stats.q3),
263 &self.color.to_rgba(),
264 2.0,
265 )?;
266 canvas.draw_line(
268 &Point2D::new(left, stats.q1),
269 &Point2D::new(right, stats.q1),
270 &self.color.to_rgba(),
271 2.0,
272 )?;
273
274 canvas.draw_line(
276 &Point2D::new(left, stats.median),
277 &Point2D::new(right, stats.median),
278 &self.color.to_rgba(),
279 2.5,
280 )?;
281
282 if self.show_outliers {
284 for &outlier in &stats.outliers {
285 canvas.draw_circle(
286 &Point2D::new(self.position, outlier),
287 3.0,
288 &self.color.to_rgba(),
289 true, )?;
291 }
292 }
293
294 Ok(())
295 }
296}
297
298impl BoxPlot {
299 #[must_use]
301 pub fn bounds(&self) -> Option<Bounds> {
302 if self.data.is_empty() {
303 return None;
304 }
305
306 let stats = self.calculate_stats();
307 let half_width = self.width / 2.0;
308
309 let y_min = if self.show_outliers && !stats.outliers.is_empty() {
310 stats
311 .outliers
312 .iter()
313 .copied()
314 .fold(stats.lower_whisker, f64::min)
315 } else {
316 stats.lower_whisker
317 };
318
319 let y_max = if self.show_outliers && !stats.outliers.is_empty() {
320 stats
321 .outliers
322 .iter()
323 .copied()
324 .fold(stats.upper_whisker, f64::max)
325 } else {
326 stats.upper_whisker
327 };
328
329 Some(Bounds::new(
330 self.position - half_width, self.position + half_width, y_min, y_max, ))
335 }
336}
337
338#[cfg(test)]
339mod tests {
340 use super::*;
341
342 #[test]
343 fn test_boxplot_creation() {
344 let data = vec![1.0, 2.0, 3.0, 4.0, 5.0];
345 let boxplot = BoxPlot::new(data);
346 assert!(boxplot.bounds().is_some());
347 }
348
349 #[test]
350 fn test_percentile() {
351 let data = vec![1.0, 2.0, 3.0, 4.0, 5.0];
352 assert_eq!(percentile(&data, 0.0), 1.0);
353 assert_eq!(percentile(&data, 50.0), 3.0);
354 assert_eq!(percentile(&data, 100.0), 5.0);
355 }
356
357 #[test]
358 fn test_boxplot_stats() {
359 let data = vec![1.0, 2.0, 3.0, 4.0, 5.0, 6.0, 7.0, 8.0, 9.0, 10.0];
360 let boxplot = BoxPlot::new(data);
361 let stats = boxplot.calculate_stats();
362
363 assert_eq!(stats.median, 5.5);
364 assert!(stats.q1 > 0.0 && stats.q1 < stats.median);
365 assert!(stats.q3 < 11.0 && stats.q3 > stats.median);
366 }
367
368 #[test]
369 fn test_boxplot_with_outliers() {
370 let data = vec![1.0, 2.0, 3.0, 4.0, 5.0, 100.0]; let boxplot = BoxPlot::new(data).outlier_method(OutlierMethod::IQR);
372 let stats = boxplot.calculate_stats();
373
374 assert!(!stats.outliers.is_empty());
375 }
376
377 #[test]
378 fn test_boxplot_bounds() {
379 let data = vec![1.0, 2.0, 3.0, 4.0, 5.0];
380 let boxplot = BoxPlot::new(data).position(2.0).width(0.8);
381 let bounds = boxplot.bounds().unwrap();
382
383 assert!(bounds.x_min > 0.0 && bounds.x_min < 2.0);
386 assert!(bounds.x_max > 2.0 && bounds.x_max < 3.0);
387 assert!(bounds.y_min >= 1.0);
388 assert!(bounds.y_max <= 5.0);
389 }
390}