Skip to main content

charts_rs/charts/
box_plot_chart.rs

1// Licensed under the Apache License, Version 2.0 (the "License");
2// you may not use this file except in compliance with the License.
3// You may obtain a copy of the License at
4//
5//     http://www.apache.org/licenses/LICENSE-2.0
6//
7// Unless required by applicable law or agreed to in writing, software
8// distributed under the License is distributed on an "AS IS" BASIS,
9// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
10// See the License for the specific language governing permissions and
11// limitations under the License.
12
13use super::Canvas;
14use super::canvas;
15use super::color::*;
16use super::common::*;
17use super::component::*;
18use super::params::*;
19use super::theme::{DEFAULT_Y_AXIS_WIDTH, Theme, get_default_theme_name, get_theme};
20use super::util::*;
21use crate::charts::measure_text_width_family;
22use charts_rs_derive::Chart;
23use std::sync::Arc;
24
25/// One data series for a box plot.
26///
27/// Each entry in `data` encodes one box as `[min, q1, median, q3, max]`.
28#[derive(Clone, Debug, Default)]
29pub struct BoxPlotSeries {
30    pub name: String,
31    /// `[min, q1, median, q3, max]` per x-axis category.
32    pub data: Vec<[f32; 5]>,
33    pub index: Option<usize>,
34}
35
36#[charts_rs_derive::chart_common_fields]
37#[derive(Clone, Debug, Default, Chart)]
38pub struct BoxPlotChart {
39    // x axis
40    pub x_axis_data: Vec<String>,
41    pub x_axis_height: f32,
42    pub x_axis_stroke_color: Color,
43    pub x_axis_font_size: f32,
44    pub x_axis_font_color: Color,
45    pub x_axis_font_weight: Option<String>,
46    pub x_axis_name_gap: f32,
47    pub x_axis_name_rotate: f32,
48    pub x_axis_margin: Option<Box>,
49    pub x_axis_hidden: bool,
50    pub x_boundary_gap: Option<bool>,
51
52    // y axis
53    pub y_axis_hidden: bool,
54    pub y_axis_configs: Vec<YAxisConfig>,
55
56    // grid
57    pub grid_stroke_color: Color,
58    pub grid_stroke_width: f32,
59
60    // series (required by #[derive(Chart)])
61    pub series_stroke_width: f32,
62    pub series_label_font_color: Color,
63    pub series_label_font_size: f32,
64    pub series_label_font_weight: Option<String>,
65    pub series_label_formatter: String,
66    pub series_colors: Vec<Color>,
67    pub series_symbol: Option<Symbol>,
68    pub series_smooth: bool,
69    pub series_fill: bool,
70
71    // box plot specific
72    pub box_series: Vec<BoxPlotSeries>,
73}
74
75impl BoxPlotChart {
76    fn fill_default(&mut self) {
77        // Sync series_list from box_series so legend and color-cycling work.
78        if self.series_list.is_empty() {
79            for (i, bs) in self.box_series.iter().enumerate() {
80                let mut s = Series::new(bs.name.clone(), vec![]);
81                s.index = Some(bs.index.unwrap_or(i));
82                self.series_list.push(s);
83            }
84        }
85        if self.y_axis_configs[0].axis_stroke_color.is_zero() {
86            self.y_axis_configs[0].axis_stroke_color = self.x_axis_stroke_color;
87        }
88    }
89
90    pub fn new_with_theme(
91        box_series: Vec<BoxPlotSeries>,
92        x_axis_data: Vec<String>,
93        theme: &str,
94    ) -> BoxPlotChart {
95        let mut c = BoxPlotChart {
96            box_series,
97            x_axis_data,
98            ..Default::default()
99        };
100        c.fill_theme(get_theme(theme));
101        c.fill_default();
102        c
103    }
104
105    pub fn new(box_series: Vec<BoxPlotSeries>, x_axis_data: Vec<String>) -> BoxPlotChart {
106        BoxPlotChart::new_with_theme(box_series, x_axis_data, &get_default_theme_name())
107    }
108
109    pub fn from_json(json: &str) -> canvas::Result<BoxPlotChart> {
110        let mut c = BoxPlotChart {
111            ..Default::default()
112        };
113        let value = c.fill_option(json)?;
114        // Parse box_series array
115        if let Some(arr) = value.get("box_series").and_then(|v| v.as_array()) {
116            for (i, item) in arr.iter().enumerate() {
117                let name = get_string_from_value(item, "name").unwrap_or_default();
118                let index = get_f32_from_value(item, "index").map(|v| v as usize);
119                let mut data: Vec<[f32; 5]> = vec![];
120                if let Some(rows) = item.get("data").and_then(|v| v.as_array()) {
121                    for row in rows {
122                        if let Some(vals) = row.as_array()
123                            && vals.len() >= 5
124                        {
125                            let f = |i: usize| vals[i].as_f64().unwrap_or(0.0) as f32;
126                            data.push([f(0), f(1), f(2), f(3), f(4)]);
127                        }
128                    }
129                }
130                c.box_series.push(BoxPlotSeries {
131                    name,
132                    data,
133                    index: index.or(Some(i)),
134                });
135            }
136        }
137        c.fill_default();
138        Ok(c)
139    }
140
141    pub fn svg(&self) -> canvas::Result<String> {
142        let mut c = Canvas::new_width_xy(self.width, self.height, self.x, self.y);
143        self.render_background(c.child(Box::default()));
144        let mut x_axis_height = self.x_axis_height;
145        if self.x_axis_hidden {
146            x_axis_height = 0.0;
147        }
148        c.margin = self.margin.clone();
149
150        let title_height = self.render_title(c.child(Box::default()));
151        let legend_height = self.render_legend(c.child(Box::default()));
152        let axis_top = title_height.max(legend_height);
153
154        // Collect all values to build y-axis range
155        let mut all_values: Vec<f32> = vec![];
156        for bs in &self.box_series {
157            for entry in &bs.data {
158                all_values.push(entry[0]); // min
159                all_values.push(entry[4]); // max
160            }
161        }
162        if all_values.is_empty() {
163            return c.svg();
164        }
165
166        let y_axis_config = self.get_y_axis_config(0);
167        let y_axis_values = get_axis_values(AxisValueParams {
168            data_list: all_values,
169            split_number: y_axis_config.axis_split_number,
170            reverse: Some(true),
171            min: y_axis_config.axis_min,
172            max: y_axis_config.axis_max,
173            ..Default::default()
174        });
175
176        let y_axis_width = if self.y_axis_hidden {
177            0.0
178        } else if let Some(w) = y_axis_config.axis_width {
179            w
180        } else {
181            let formatter = y_axis_config.axis_formatter.clone().unwrap_or_default();
182            let label = format_string(&y_axis_values.data[0], &formatter);
183            measure_text_width_family(&self.font_family, y_axis_config.axis_font_size, &label)
184                .map(|b| b.width() + 5.0)
185                .unwrap_or(DEFAULT_Y_AXIS_WIDTH)
186        };
187
188        let axis_height = c.height() - x_axis_height - axis_top;
189        let axis_width = c.width() - y_axis_width;
190
191        if axis_top > 0.0 {
192            c = c.child(Box {
193                top: axis_top,
194                ..Default::default()
195            });
196        }
197
198        // Grid
199        self.render_grid(
200            c.child(Box {
201                left: y_axis_width,
202                ..Default::default()
203            }),
204            axis_width,
205            axis_height,
206        );
207
208        // Y axis
209        if !self.y_axis_hidden {
210            self.render_y_axis(
211                c.child(Box::default()),
212                y_axis_values.data.clone(),
213                axis_height,
214                y_axis_width,
215                0,
216            );
217        }
218
219        // X axis
220        if !self.x_axis_hidden {
221            self.render_x_axis(
222                c.child(Box {
223                    top: c.height() - x_axis_height,
224                    left: y_axis_width,
225                    ..Default::default()
226                }),
227                self.x_axis_data.clone(),
228                axis_width,
229            );
230        }
231
232        let num_cats = self.x_axis_data.len().max(
233            self.box_series
234                .iter()
235                .map(|bs| bs.data.len())
236                .max()
237                .unwrap_or(0),
238        );
239        if num_cats == 0 {
240            return c.svg();
241        }
242
243        let num_series = self.box_series.len();
244        let col_w = axis_width / num_cats as f32;
245        // step = spacing between adjacent box centres; box_w = rendered width (80% of step)
246        let total_boxes_w = col_w * 0.6_f32;
247        let box_step = if num_series > 0 {
248            total_boxes_w / num_series as f32
249        } else {
250            total_boxes_w
251        };
252        let box_w = box_step * 0.8;
253        // Cap width for whisker line
254        let cap_half = box_w * 0.3;
255        let stroke_w = self.series_stroke_width.max(1.0);
256
257        let mut data_c = c.child(Box {
258            left: y_axis_width,
259            ..Default::default()
260        });
261
262        for (si, bs) in self.box_series.iter().enumerate() {
263            let color = get_color(&self.series_colors, bs.index.unwrap_or(si));
264            let fill_color = color.with_alpha(80);
265
266            for (ci, entry) in bs.data.iter().enumerate() {
267                if ci >= num_cats {
268                    break;
269                }
270                let [v_min, v_q1, v_med, v_q3, v_max] = *entry;
271
272                // Centre x of this box
273                let cat_cx = col_w * (ci as f32 + 0.5);
274                let series_offset = (si as f32 - (num_series as f32 - 1.0) / 2.0) * box_step;
275                let cx = cat_cx + series_offset;
276
277                let y_min = y_axis_values.get_offset_height(v_min, axis_height);
278                let y_q1 = y_axis_values.get_offset_height(v_q1, axis_height);
279                let y_med = y_axis_values.get_offset_height(v_med, axis_height);
280                let y_q3 = y_axis_values.get_offset_height(v_q3, axis_height);
281                let y_max = y_axis_values.get_offset_height(v_max, axis_height);
282
283                let box_left = cx - box_w / 2.0;
284                let box_top = y_q3; // Q3 is higher value → lower y pixel
285                let box_height = (y_q1 - y_q3).abs();
286
287                // IQR box (Q1..Q3)
288                data_c.rect(Rect {
289                    fill: Some(Fill::Solid(fill_color)),
290                    color: Some(color),
291                    left: box_left,
292                    top: box_top,
293                    width: box_w,
294                    height: box_height,
295                    ..Default::default()
296                });
297
298                // Median line
299                data_c.line(Line {
300                    color: Some(color),
301                    stroke_width: stroke_w + 1.0,
302                    left: box_left,
303                    right: box_left + box_w,
304                    top: y_med,
305                    bottom: y_med,
306                    ..Default::default()
307                });
308
309                // Upper whisker Q3 → max
310                data_c.line(Line {
311                    color: Some(color),
312                    stroke_width: stroke_w,
313                    left: cx,
314                    right: cx,
315                    top: y_max,
316                    bottom: y_q3,
317                    ..Default::default()
318                });
319
320                // Lower whisker min → Q1
321                data_c.line(Line {
322                    color: Some(color),
323                    stroke_width: stroke_w,
324                    left: cx,
325                    right: cx,
326                    top: y_q1,
327                    bottom: y_min,
328                    ..Default::default()
329                });
330
331                // Upper cap at max
332                data_c.line(Line {
333                    color: Some(color),
334                    stroke_width: stroke_w,
335                    left: cx - cap_half,
336                    right: cx + cap_half,
337                    top: y_max,
338                    bottom: y_max,
339                    ..Default::default()
340                });
341
342                // Lower cap at min
343                data_c.line(Line {
344                    color: Some(color),
345                    stroke_width: stroke_w,
346                    left: cx - cap_half,
347                    right: cx + cap_half,
348                    top: y_min,
349                    bottom: y_min,
350                    ..Default::default()
351                });
352            }
353        }
354
355        c.svg()
356    }
357}
358
359#[cfg(test)]
360mod tests {
361    use super::{BoxPlotChart, BoxPlotSeries};
362    use pretty_assertions::assert_eq;
363
364    fn make_box_plot() -> BoxPlotChart {
365        BoxPlotChart::new(
366            vec![
367                BoxPlotSeries {
368                    name: "Series A".to_string(),
369                    data: vec![
370                        [3.0, 10.0, 18.0, 28.0, 40.0],
371                        [5.0, 14.0, 22.0, 32.0, 45.0],
372                        [1.0, 8.0, 15.0, 24.0, 35.0],
373                        [6.0, 12.0, 20.0, 30.0, 42.0],
374                    ],
375                    index: None,
376                },
377                BoxPlotSeries {
378                    name: "Series B".to_string(),
379                    data: vec![
380                        [5.0, 13.0, 21.0, 31.0, 43.0],
381                        [2.0, 9.0, 17.0, 26.0, 38.0],
382                        [4.0, 11.0, 19.0, 29.0, 41.0],
383                        [7.0, 15.0, 23.0, 33.0, 46.0],
384                    ],
385                    index: None,
386                },
387            ],
388            vec![
389                "Category A".to_string(),
390                "Category B".to_string(),
391                "Category C".to_string(),
392                "Category D".to_string(),
393            ],
394        )
395    }
396
397    #[test]
398    fn box_plot_chart_basic() {
399        assert_eq!(
400            include_str!("../../asset/box_plot_chart/basic.svg"),
401            make_box_plot().svg().unwrap()
402        );
403    }
404
405    #[test]
406    fn box_plot_chart_basic_json() {
407        let chart = BoxPlotChart::from_json(
408            r##"{
409                "title_text": "Box Plot",
410                "x_axis_data": ["Cat A", "Cat B", "Cat C"],
411                "box_series": [
412                    {
413                        "name": "Group 1",
414                        "data": [
415                            [3, 10, 18, 28, 40],
416                            [5, 14, 22, 32, 45],
417                            [1,  8, 15, 24, 35]
418                        ]
419                    },
420                    {
421                        "name": "Group 2",
422                        "data": [
423                            [5, 13, 21, 31, 43],
424                            [2,  9, 17, 26, 38],
425                            [4, 11, 19, 29, 41]
426                        ]
427                    }
428                ]
429            }"##,
430        )
431        .unwrap();
432        assert_eq!(
433            include_str!("../../asset/box_plot_chart/basic_json.svg"),
434            chart.svg().unwrap()
435        );
436    }
437}