1use esoc_gfx::canvas::Canvas;
5use esoc_gfx::element::{DrawElement, Element};
6use esoc_gfx::geom::Rect;
7use esoc_gfx::layer::Layer;
8use esoc_gfx::style::{Fill, Stroke};
9use esoc_gfx::transform::CoordinateTransform;
10
11use crate::series::{DataBounds, SeriesRenderer};
12use crate::theme::Theme;
13
14#[derive(Clone, Debug)]
16pub struct BoxPlotSeries {
17 pub datasets: Vec<Vec<f64>>,
19 pub label: Option<String>,
21 pub labels: Option<Vec<String>>,
23}
24
25#[derive(Clone, Debug)]
27#[allow(dead_code)]
28struct BoxStats {
29 min: f64,
30 q1: f64,
31 median: f64,
32 q3: f64,
33 max: f64,
34 whisker_lo: f64,
35 whisker_hi: f64,
36}
37
38impl BoxPlotSeries {
39 pub fn new(datasets: Vec<Vec<f64>>) -> Self {
41 Self {
42 datasets,
43 label: None,
44 labels: None,
45 }
46 }
47
48 fn compute_stats(data: &[f64]) -> Option<BoxStats> {
49 if data.is_empty() {
50 return None;
51 }
52 let mut sorted = data.to_vec();
53 sorted.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
54
55 let min = sorted[0];
56 let max = sorted[sorted.len() - 1];
57 let q1 = percentile(&sorted, 25.0);
58 let median = percentile(&sorted, 50.0);
59 let q3 = percentile(&sorted, 75.0);
60 let iqr = q3 - q1;
61
62 let whisker_lo = sorted
64 .iter()
65 .copied()
66 .find(|&v| v >= q1 - 1.5 * iqr)
67 .unwrap_or(min);
68 let whisker_hi = sorted
69 .iter()
70 .rev()
71 .copied()
72 .find(|&v| v <= q3 + 1.5 * iqr)
73 .unwrap_or(max);
74
75 Some(BoxStats {
76 min,
77 q1,
78 median,
79 q3,
80 max,
81 whisker_lo,
82 whisker_hi,
83 })
84 }
85}
86
87impl SeriesRenderer for BoxPlotSeries {
88 fn data_bounds(&self) -> DataBounds {
89 let n = self.datasets.len();
90 let y_min = self
91 .datasets
92 .iter()
93 .flat_map(|d| d.iter().copied())
94 .fold(f64::INFINITY, f64::min);
95 let y_max = self
96 .datasets
97 .iter()
98 .flat_map(|d| d.iter().copied())
99 .fold(f64::NEG_INFINITY, f64::max);
100
101 DataBounds::new(-0.5, n as f64 - 0.5, y_min, y_max)
102 }
103
104 fn render(
105 &self,
106 canvas: &mut Canvas,
107 transform: &CoordinateTransform,
108 theme: &Theme,
109 series_index: usize,
110 ) {
111 let box_width = 0.6;
112 let color = theme.palette.get(series_index);
113
114 for (i, dataset) in self.datasets.iter().enumerate() {
115 let Some(stats) = Self::compute_stats(dataset) else {
116 continue;
117 };
118
119 let x = i as f64;
120 let half_w = box_width / 2.0;
121
122 let p_tl = transform.to_pixel(x - half_w, stats.q3);
124 let p_br = transform.to_pixel(x + half_w, stats.q1);
125 let rx = p_tl.x.min(p_br.x);
126 let ry = p_tl.y.min(p_br.y);
127 let rw = (p_br.x - p_tl.x).abs();
128 let rh = (p_br.y - p_tl.y).abs();
129 canvas.add(DrawElement::new(
130 Element::Rect {
131 rect: Rect::new(rx, ry, rw, rh),
132 fill: Fill::Solid(color.with_alpha(0.3)),
133 stroke: Some(Stroke::solid(color, 1.5)),
134 rx: 0.0,
135 },
136 Layer::Data,
137 ));
138
139 let p_ml = transform.to_pixel(x - half_w, stats.median);
141 let p_mr = transform.to_pixel(x + half_w, stats.median);
142 canvas.add(DrawElement::line(
143 p_ml.x,
144 p_ml.y,
145 p_mr.x,
146 p_mr.y,
147 Stroke::solid(color, 2.0),
148 Layer::Data,
149 ));
150
151 let p_wl_top = transform.to_pixel(x, stats.whisker_hi);
153 let p_wl_q3 = transform.to_pixel(x, stats.q3);
154 canvas.add(DrawElement::line(
155 p_wl_top.x,
156 p_wl_top.y,
157 p_wl_q3.x,
158 p_wl_q3.y,
159 Stroke::solid(color, 1.0),
160 Layer::Data,
161 ));
162
163 let p_wl_bot = transform.to_pixel(x, stats.whisker_lo);
164 let p_wl_q1 = transform.to_pixel(x, stats.q1);
165 canvas.add(DrawElement::line(
166 p_wl_bot.x,
167 p_wl_bot.y,
168 p_wl_q1.x,
169 p_wl_q1.y,
170 Stroke::solid(color, 1.0),
171 Layer::Data,
172 ));
173
174 let cap_w = half_w * 0.5;
176 let p_cap_hi_l = transform.to_pixel(x - cap_w, stats.whisker_hi);
177 let p_cap_hi_r = transform.to_pixel(x + cap_w, stats.whisker_hi);
178 canvas.add(DrawElement::line(
179 p_cap_hi_l.x,
180 p_cap_hi_l.y,
181 p_cap_hi_r.x,
182 p_cap_hi_r.y,
183 Stroke::solid(color, 1.0),
184 Layer::Data,
185 ));
186
187 let p_cap_lo_l = transform.to_pixel(x - cap_w, stats.whisker_lo);
188 let p_cap_lo_r = transform.to_pixel(x + cap_w, stats.whisker_lo);
189 canvas.add(DrawElement::line(
190 p_cap_lo_l.x,
191 p_cap_lo_l.y,
192 p_cap_lo_r.x,
193 p_cap_lo_r.y,
194 Stroke::solid(color, 1.0),
195 Layer::Data,
196 ));
197 }
198 }
199
200 fn label(&self) -> Option<&str> {
201 self.label.as_deref()
202 }
203}
204
205fn percentile(sorted: &[f64], p: f64) -> f64 {
206 if sorted.is_empty() {
207 return 0.0;
208 }
209 let idx = (p / 100.0 * (sorted.len() - 1) as f64).clamp(0.0, (sorted.len() - 1) as f64);
210 let lo = idx.floor() as usize;
211 let hi = idx.ceil() as usize;
212 let frac = idx - lo as f64;
213 sorted[lo] * (1.0 - frac) + sorted[hi] * frac
214}