Skip to main content

plotkit_core/charts/
errorbar.rs

1//! Error bar chart builder methods.
2//!
3//! This module extends [`ErrorBarArtist`] with a fluent API for configuring
4//! error bar properties. Since [`Axes::errorbar`] returns
5//! `Result<ErrorBarArtist>`, these builder methods can be chained
6//! directly on the return value:
7//!
8//! ```ignore
9//! let eb = ax.errorbar(&x, &y)?
10//!     .yerr_symmetric(&errs)
11//!     .cap_size(5.0)
12//!     .line_width(1.5)
13//!     .color(Color::TAB_RED)
14//!     .label("Measurements");
15//! ax.add_errorbar(eb);
16//! ```
17//!
18//! [`Axes::errorbar`]: crate::axes::Axes::errorbar
19
20use crate::artist::ErrorBarArtist;
21use crate::primitives::Color;
22
23impl ErrorBarArtist {
24    /// Sets the color for the center line, error bars, and caps.
25    ///
26    /// Accepts any [`Color`] value, which can be constructed from RGB
27    /// components, hex strings, or named color constants.
28    ///
29    /// # Examples
30    ///
31    /// ```ignore
32    /// artist.color(Color::TAB_RED);
33    /// ```
34    pub fn color(&mut self, color: Color) -> &mut Self {
35        self.color = color;
36        self
37    }
38
39    /// Sets the legend label for this error bar plot.
40    ///
41    /// When a label is set, the error bar plot will appear in the legend if
42    /// one is displayed on the axes. Pass an empty string or omit this call
43    /// to exclude the plot from the legend.
44    ///
45    /// # Examples
46    ///
47    /// ```ignore
48    /// artist.label("Measurements");
49    /// ```
50    pub fn label(&mut self, label: &str) -> &mut Self {
51        self.label = Some(label.to_string());
52        self
53    }
54
55    /// Sets the cap size in pixels for the error bar ends.
56    ///
57    /// Caps are the small horizontal (or vertical) lines drawn at the tips
58    /// of the error bars. A value of `0.0` disables caps entirely. The
59    /// default is `4.0` pixels.
60    ///
61    /// # Examples
62    ///
63    /// ```ignore
64    /// artist.cap_size(6.0);
65    /// ```
66    pub fn cap_size(&mut self, size: f64) -> &mut Self {
67        self.cap_size = size.max(0.0);
68        self
69    }
70
71    /// Sets the stroke width of the error bar lines and caps.
72    ///
73    /// Also affects the center connecting line. A width of `1.0` is the
74    /// default hairline width.
75    ///
76    /// # Examples
77    ///
78    /// ```ignore
79    /// artist.line_width(2.0);
80    /// ```
81    pub fn line_width(&mut self, width: f64) -> &mut Self {
82        self.line_width = width.max(0.0);
83        self
84    }
85
86    /// Sets symmetric y-error values.
87    ///
88    /// Each error value `e` produces an error bar from `y - e` to `y + e`
89    /// at the corresponding data point. The length of `errs` must equal the
90    /// number of data points.
91    ///
92    /// # Examples
93    ///
94    /// ```ignore
95    /// artist.yerr_symmetric(&vec![0.5, 0.3, 0.4]);
96    /// ```
97    pub fn yerr_symmetric(mut self, errs: &[f64]) -> Self {
98        self.yerr = Some(crate::artist::ErrorBarData::Symmetric(errs.to_vec()));
99        self
100    }
101
102    /// Sets asymmetric y-error values.
103    ///
104    /// Each point gets a separate low and high error. The error bar spans
105    /// from `y - low[i]` to `y + high[i]`. Both slices must have the same
106    /// length as the data series.
107    ///
108    /// # Examples
109    ///
110    /// ```ignore
111    /// artist.yerr_asymmetric(&low_errs, &high_errs);
112    /// ```
113    pub fn yerr_asymmetric(mut self, low: &[f64], high: &[f64]) -> Self {
114        self.yerr = Some(crate::artist::ErrorBarData::Asymmetric {
115            low: low.to_vec(),
116            high: high.to_vec(),
117        });
118        self
119    }
120
121    /// Sets symmetric x-error values.
122    ///
123    /// Each error value `e` produces a horizontal error bar from `x - e`
124    /// to `x + e` at the corresponding data point. The length of `errs`
125    /// must equal the number of data points.
126    ///
127    /// # Examples
128    ///
129    /// ```ignore
130    /// artist.xerr_symmetric(&vec![0.2, 0.1, 0.15]);
131    /// ```
132    pub fn xerr_symmetric(mut self, errs: &[f64]) -> Self {
133        self.xerr = Some(crate::artist::ErrorBarData::Symmetric(errs.to_vec()));
134        self
135    }
136
137    /// Sets asymmetric x-error values.
138    ///
139    /// Each point gets a separate low and high horizontal error. The error
140    /// bar spans from `x - low[i]` to `x + high[i]`. Both slices must
141    /// have the same length as the data series.
142    ///
143    /// # Examples
144    ///
145    /// ```ignore
146    /// artist.xerr_asymmetric(&low_errs, &high_errs);
147    /// ```
148    pub fn xerr_asymmetric(mut self, low: &[f64], high: &[f64]) -> Self {
149        self.xerr = Some(crate::artist::ErrorBarData::Asymmetric {
150            low: low.to_vec(),
151            high: high.to_vec(),
152        });
153        self
154    }
155}
156
157// ---------------------------------------------------------------------------
158// Tests
159// ---------------------------------------------------------------------------
160
161#[cfg(test)]
162mod tests {
163    use super::*;
164    use crate::artist::ErrorBarData;
165    use crate::series::Series;
166
167    /// Tolerance for floating-point comparisons.
168    const TOL: f64 = 1e-12;
169
170    /// Returns true if `a` and `b` are within `TOL` of each other.
171    fn approx_eq(a: f64, b: f64) -> bool {
172        (a - b).abs() < TOL
173    }
174
175    /// Helper: build a minimal `ErrorBarArtist` for testing.
176    fn sample_errorbar() -> ErrorBarArtist {
177        ErrorBarArtist {
178            x: Series::new(vec![1.0, 2.0, 3.0]),
179            y: Series::new(vec![10.0, 20.0, 30.0]),
180            xerr: None,
181            yerr: None,
182            color: Color::TAB_BLUE,
183            label: None,
184            cap_size: 4.0,
185            line_width: 1.0,
186        }
187    }
188
189    #[test]
190    fn builder_color() {
191        let mut a = sample_errorbar();
192        a.color(Color::TAB_RED);
193        assert_eq!(a.color, Color::TAB_RED);
194    }
195
196    #[test]
197    fn builder_label() {
198        let mut a = sample_errorbar();
199        assert!(a.label.is_none());
200        a.label("Measurements");
201        assert_eq!(a.label.as_deref(), Some("Measurements"));
202    }
203
204    #[test]
205    fn builder_label_overwrite() {
206        let mut a = sample_errorbar();
207        a.label("first");
208        a.label("second");
209        assert_eq!(a.label.as_deref(), Some("second"));
210    }
211
212    #[test]
213    fn builder_cap_size() {
214        let mut a = sample_errorbar();
215        a.cap_size(8.0);
216        assert!(approx_eq(a.cap_size, 8.0));
217    }
218
219    #[test]
220    fn builder_cap_size_clamps_negative() {
221        let mut a = sample_errorbar();
222        a.cap_size(-5.0);
223        assert!(approx_eq(a.cap_size, 0.0));
224    }
225
226    #[test]
227    fn builder_line_width() {
228        let mut a = sample_errorbar();
229        a.line_width(2.5);
230        assert!(approx_eq(a.line_width, 2.5));
231    }
232
233    #[test]
234    fn builder_line_width_clamps_negative() {
235        let mut a = sample_errorbar();
236        a.line_width(-1.0);
237        assert!(approx_eq(a.line_width, 0.0));
238    }
239
240    #[test]
241    fn builder_yerr_symmetric() {
242        let a = sample_errorbar().yerr_symmetric(&[0.5, 1.0, 1.5]);
243        match &a.yerr {
244            Some(ErrorBarData::Symmetric(v)) => {
245                assert_eq!(v.len(), 3);
246                assert!(approx_eq(v[0], 0.5));
247                assert!(approx_eq(v[1], 1.0));
248                assert!(approx_eq(v[2], 1.5));
249            }
250            _ => panic!("expected Symmetric yerr"),
251        }
252    }
253
254    #[test]
255    fn builder_yerr_asymmetric() {
256        let a = sample_errorbar().yerr_asymmetric(&[0.3, 0.5, 0.7], &[1.0, 1.2, 1.4]);
257        match &a.yerr {
258            Some(ErrorBarData::Asymmetric { low, high }) => {
259                assert_eq!(low.len(), 3);
260                assert_eq!(high.len(), 3);
261                assert!(approx_eq(low[0], 0.3));
262                assert!(approx_eq(high[2], 1.4));
263            }
264            _ => panic!("expected Asymmetric yerr"),
265        }
266    }
267
268    #[test]
269    fn builder_xerr_symmetric() {
270        let a = sample_errorbar().xerr_symmetric(&[0.1, 0.2, 0.3]);
271        match &a.xerr {
272            Some(ErrorBarData::Symmetric(v)) => {
273                assert_eq!(v.len(), 3);
274                assert!(approx_eq(v[0], 0.1));
275            }
276            _ => panic!("expected Symmetric xerr"),
277        }
278    }
279
280    #[test]
281    fn builder_xerr_asymmetric() {
282        let a = sample_errorbar().xerr_asymmetric(&[0.1, 0.2, 0.3], &[0.4, 0.5, 0.6]);
283        match &a.xerr {
284            Some(ErrorBarData::Asymmetric { low, high }) => {
285                assert_eq!(low.len(), 3);
286                assert_eq!(high.len(), 3);
287                assert!(approx_eq(low[1], 0.2));
288                assert!(approx_eq(high[1], 0.5));
289            }
290            _ => panic!("expected Asymmetric xerr"),
291        }
292    }
293
294    #[test]
295    fn builder_chaining() {
296        let mut a = sample_errorbar();
297        a.color(Color::TAB_GREEN)
298            .label("Test")
299            .cap_size(6.0)
300            .line_width(2.0);
301
302        assert_eq!(a.color, Color::TAB_GREEN);
303        assert_eq!(a.label.as_deref(), Some("Test"));
304        assert!(approx_eq(a.cap_size, 6.0));
305        assert!(approx_eq(a.line_width, 2.0));
306    }
307
308    #[test]
309    fn data_bounds_no_errors() {
310        let a = sample_errorbar();
311        let (xmin, xmax, ymin, ymax) = a.data_bounds();
312        assert!(approx_eq(xmin, 1.0));
313        assert!(approx_eq(xmax, 3.0));
314        assert!(approx_eq(ymin, 10.0));
315        assert!(approx_eq(ymax, 30.0));
316    }
317
318    #[test]
319    fn data_bounds_with_symmetric_yerr() {
320        let a = sample_errorbar().yerr_symmetric(&[2.0, 3.0, 5.0]);
321        let (xmin, xmax, ymin, ymax) = a.data_bounds();
322        assert!(approx_eq(xmin, 1.0));
323        assert!(approx_eq(xmax, 3.0));
324        assert!(approx_eq(ymin, 8.0));
325        assert!(approx_eq(ymax, 35.0));
326    }
327
328    #[test]
329    fn data_bounds_with_asymmetric_yerr() {
330        let a = sample_errorbar().yerr_asymmetric(&[1.0, 2.0, 3.0], &[5.0, 6.0, 7.0]);
331        let (_, _, ymin, ymax) = a.data_bounds();
332        assert!(approx_eq(ymin, 9.0));
333        assert!(approx_eq(ymax, 37.0));
334    }
335
336    #[test]
337    fn data_bounds_with_symmetric_xerr() {
338        let a = sample_errorbar().xerr_symmetric(&[0.5, 0.5, 0.5]);
339        let (xmin, xmax, _, _) = a.data_bounds();
340        assert!(approx_eq(xmin, 0.5));
341        assert!(approx_eq(xmax, 3.5));
342    }
343
344    #[test]
345    fn data_bounds_with_both_errors() {
346        let a = sample_errorbar()
347            .xerr_symmetric(&[0.5, 0.5, 0.5])
348            .yerr_symmetric(&[2.0, 3.0, 5.0]);
349        let (xmin, xmax, ymin, ymax) = a.data_bounds();
350        assert!(approx_eq(xmin, 0.5));
351        assert!(approx_eq(xmax, 3.5));
352        assert!(approx_eq(ymin, 8.0));
353        assert!(approx_eq(ymax, 35.0));
354    }
355
356    #[test]
357    fn data_bounds_empty_series() {
358        let a = ErrorBarArtist {
359            x: Series::new(vec![]),
360            y: Series::new(vec![]),
361            xerr: None,
362            yerr: None,
363            color: Color::BLACK,
364            label: None,
365            cap_size: 4.0,
366            line_width: 1.0,
367        };
368        let (xmin, xmax, ymin, ymax) = a.data_bounds();
369        assert!(approx_eq(xmin, 0.0));
370        assert!(approx_eq(xmax, 1.0));
371        assert!(approx_eq(ymin, 0.0));
372        assert!(approx_eq(ymax, 1.0));
373    }
374}