velociplot/plots/
boxplot.rs

1//! Box plot implementation
2//!
3//! Box plots (box-and-whisker plots) display distribution statistics:
4//! minimum, first quartile (Q1), median (Q2), third quartile (Q3), and maximum.
5//!
6//! # Examples
7//!
8//! ```
9//! # use velociplot::prelude::*;
10//! let data = vec![1.0, 2.0, 3.0, 4.0, 5.0, 6.0, 7.0, 8.0, 9.0, 10.0];
11//! let boxplot = BoxPlot::new(data)
12//!     .color(Color::from_hex("#3498db").unwrap())
13//!     .label("Sample Data");
14//! ```
15
16use crate::color::Color;
17use crate::core::{Bounds, Canvas, Drawable, Point2D};
18use crate::error::Result;
19use crate::legend::LegendEntry;
20
21/// Box plot for displaying distribution statistics
22///
23/// Shows five-number summary: minimum, Q1, median, Q3, maximum
24pub 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/// Method for detecting outliers
35#[derive(Debug, Clone, Copy)]
36pub enum OutlierMethod {
37    /// Use 1.5 × IQR (Interquartile Range) - standard Tukey method
38    IQR,
39    /// No outlier detection, show full range
40    None,
41}
42
43impl BoxPlot {
44    /// Create a new box plot
45    ///
46    /// # Examples
47    ///
48    /// ```
49    /// # use velociplot::prelude::*;
50    /// let data = vec![1.0, 2.0, 3.0, 4.0, 5.0];
51    /// let boxplot = BoxPlot::new(data);
52    /// ```
53    #[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    /// Set the x-axis position of the box plot
67    #[must_use]
68    pub fn position(mut self, position: f64) -> Self {
69        self.position = position;
70        self
71    }
72
73    /// Set the width of the box
74    #[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    /// Set the color
81    #[must_use]
82    pub fn color(mut self, color: Color) -> Self {
83        self.color = color;
84        self
85    }
86
87    /// Set the label for legend
88    #[must_use]
89    pub fn label(mut self, label: impl Into<String>) -> Self {
90        self.label = Some(label.into());
91        self
92    }
93
94    /// Set whether to show outliers
95    #[must_use]
96    pub fn show_outliers(mut self, show: bool) -> Self {
97        self.show_outliers = show;
98        self
99    }
100
101    /// Set the outlier detection method
102    #[must_use]
103    pub fn outlier_method(mut self, method: OutlierMethod) -> Self {
104        self.outlier_method = method;
105        self
106    }
107
108    /// Calculate statistics for the box plot
109    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    /// Get legend entry for this box plot
167    #[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
187/// Calculate percentile of sorted data
188fn 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        // Draw whiskers (vertical lines)
214        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        // Draw whisker caps (horizontal lines)
229        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        // Draw box (Q1 to Q3) using four lines
245        // Left side
246        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        // Right side
253        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        // Top
260        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        // Bottom
267        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        // Draw median line
275        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        // Draw outliers if enabled
283        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, // filled
290                )?;
291            }
292        }
293
294        Ok(())
295    }
296}
297
298impl BoxPlot {
299    /// Get bounds for this box plot
300    #[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, // x_min
331            self.position + half_width, // x_max
332            y_min,                      // y_min
333            y_max,                      // y_max
334        ))
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]; // 100 is an outlier
371        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        // Check that bounds exist and are reasonable
384        // The box is centered at 2.0 with width 0.8, so it spans 1.6 to 2.4
385        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}