1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
use crate::plot::strip::StripStyle;
/// Builder for a box plot (box-and-whisker plot).
///
/// Displays the five-number summary for one or more groups of values.
/// Whiskers use the Tukey 1.5×IQR rule; values outside the whiskers
/// are not drawn automatically (use an overlay to show individual
/// points). Groups are rendered side-by-side in the order they are
/// added.
///
/// # Example
///
/// ```rust,no_run
/// use kuva::plot::BoxPlot;
/// use kuva::backend::svg::SvgBackend;
/// use kuva::render::render::render_multiple;
/// use kuva::render::layout::Layout;
/// use kuva::render::plots::Plot;
///
/// let plot = BoxPlot::new()
/// .with_group("Control", vec![4.1, 5.0, 5.3, 5.8, 6.2, 7.0])
/// .with_group("Treated", vec![5.5, 6.1, 6.4, 7.2, 7.8, 8.5])
/// .with_color("steelblue");
///
/// let plots = vec![Plot::Box(plot)];
/// let layout = Layout::auto_from_plots(&plots)
/// .with_title("Control vs. Treated")
/// .with_x_label("Group")
/// .with_y_label("Value");
///
/// let svg = SvgBackend.render_scene(&render_multiple(plots, layout));
/// std::fs::write("boxplot.svg", svg).unwrap();
/// ```
pub struct BoxPlot {
pub groups: Vec<BoxGroup>,
pub color: String,
pub width: f64,
pub legend_label: Option<String>,
pub group_colors: Option<Vec<String>>,
pub overlay: Option<StripStyle>,
pub overlay_color: String,
pub overlay_size: f64,
pub overlay_seed: u64,
}
/// A single group (one box) with a category label and raw values.
pub struct BoxGroup {
pub label: String,
pub values: Vec<f64>,
}
impl Default for BoxPlot {
fn default() -> Self {
Self::new()
}
}
impl BoxPlot {
/// Create a box plot with default settings.
///
/// Defaults: color `"black"`, box width `0.8`,
/// overlay color `"rgba(0,0,0,0.45)"`, overlay point size `3.0`.
pub fn new() -> Self {
Self {
groups: vec![],
color: "black".into(),
width: 0.8,
legend_label: None,
group_colors: None,
overlay: None,
overlay_color: "rgba(0,0,0,0.45)".into(),
overlay_size: 3.0,
overlay_seed: 42,
}
}
/// Add a group (one box) with a label and raw values.
///
/// Groups are rendered left-to-right in the order they are added.
/// The renderer computes Q1, median, Q3, and Tukey 1.5×IQR whiskers
/// from the supplied values.
///
/// ```rust,no_run
/// # use kuva::plot::BoxPlot;
/// let plot = BoxPlot::new()
/// .with_group("A", vec![1.0, 2.5, 3.0, 3.5, 4.0, 5.0])
/// .with_group("B", vec![2.0, 3.0, 3.8, 4.2, 4.8, 6.0]);
/// ```
pub fn with_group<T, U, I>(mut self, label: T, values: I) -> Self
where
T: Into<String>,
I: IntoIterator<Item = U>,
U: Into<f64>,
{
self.groups.push(BoxGroup {
label: label.into(),
values: values.into_iter().map(Into::into).collect(),
});
self
}
/// Set the box fill color (CSS color string, e.g. `"steelblue"`).
///
/// This color is applied to all boxes. Use the same color for all
/// groups and distinguish them by position, or layer multiple
/// `BoxPlot` instances in a `Vec<Plot>` with different colors.
pub fn with_color<S: Into<String>>(mut self, color: S) -> Self {
self.color = color.into();
self
}
/// Set per-group fill colors.
///
/// Colors are matched to groups by position. If the list is shorter than
/// the number of groups, the uniform color from [`with_color`](Self::with_color)
/// is used as a fallback.
pub fn with_group_colors<S, I>(mut self, colors: I) -> Self
where
I: IntoIterator<Item = S>,
S: Into<String>,
{
self.group_colors = Some(colors.into_iter().map(|s| s.into()).collect());
self
}
/// Set the box width as a fraction of the category slot (default `0.8`).
/// Complement of [`with_gap`](Self::with_gap): `width = 1.0 - gap`.
pub fn with_width(mut self, width: f64) -> Self {
self.width = width;
self
}
/// Set the gap between boxes as a fraction of the category slot (default `0.2`).
///
/// Complement of [`with_width`](Self::with_width): `gap = 1.0 - width`.
pub fn with_gap(mut self, gap: f64) -> Self {
self.width = (1.0 - gap).clamp(0.0, 1.0);
self
}
/// Attach a legend label to this box plot.
pub fn with_legend<S: Into<String>>(mut self, label: S) -> Self {
self.legend_label = Some(label.into());
self
}
/// Overlay individual data points as a jittered strip.
///
/// `jitter` controls the horizontal spread of the points (in data
/// units). A value of `0.2` is a reasonable default. Points are
/// placed on top of the box — use a semi-transparent
/// [`with_overlay_color`](Self::with_overlay_color) so the box
/// remains visible underneath.
pub fn with_strip(mut self, jitter: f64) -> Self {
self.overlay = Some(StripStyle::Strip { jitter });
self
}
/// Overlay individual data points as a beeswarm.
///
/// Points are spread horizontally to avoid overlap, giving a clearer
/// view of the data density than a jittered strip. Useful for smaller
/// datasets (N < ~200 per group) where individual points are
/// meaningful.
pub fn with_swarm_overlay(mut self) -> Self {
self.overlay = Some(StripStyle::Swarm);
self
}
/// Set the fill color for overlay points (default `"rgba(0,0,0,0.45)"`).
///
/// A semi-transparent color is recommended so the box underneath
/// remains visible.
pub fn with_overlay_color<S: Into<String>>(mut self, color: S) -> Self {
self.overlay_color = color.into();
self
}
/// Set the radius of overlay points in pixels (default `3.0`).
pub fn with_overlay_size(mut self, size: f64) -> Self {
self.overlay_size = size;
self
}
}