Skip to main content

plotkit_core/
artist.rs

1//! Artist types -- data + styling for each visual chart element.
2//!
3//! Artists are the data-carrying objects stored in [`Axes`]. Each artist type
4//! holds the data-space geometry and styling for one visual element. When the
5//! figure is rendered, the renderer iterates over the artist list and draws
6//! each one according to its variant.
7//!
8//! [`Axes`]: crate::axes::Axes
9//!
10//! # Variants
11//!
12//! | Variant          | Description                                     |
13//! |------------------|-------------------------------------------------|
14//! | [`Line`]         | A polyline connecting (x, y) points.             |
15//! | [`Scatter`]      | Individual markers at (x, y) positions.          |
16//! | [`Bar`]          | Vertical or horizontal bars over categories.     |
17//! | [`Histogram`]    | Binned frequency distribution of a single series.|
18//! | [`FillBetween`]  | Shaded region between two y-series.              |
19//!
20//! [`Line`]: Artist::Line
21//! [`Scatter`]: Artist::Scatter
22//! [`Bar`]: Artist::Bar
23//! [`Histogram`]: Artist::Histogram
24//! [`FillBetween`]: Artist::FillBetween
25
26use crate::primitives::Color;
27use crate::series::{Categories, Series};
28use crate::theme::{LineStyle, Marker};
29
30// ---------------------------------------------------------------------------
31// Artist enum
32// ---------------------------------------------------------------------------
33
34/// A visual element drawn on an axes.
35///
36/// `Artist` is the primary unit of chart content. Each variant wraps a
37/// concrete artist struct that stores the data, colors, and styling needed
38/// to render one visual element. The enum provides convenience accessors
39/// ([`label`](Artist::label), [`color`](Artist::color),
40/// [`data_bounds`](Artist::data_bounds)) that dispatch to the inner type.
41#[derive(Debug, Clone)]
42pub enum Artist {
43    /// A line chart connecting (x, y) points.
44    Line(LineArtist),
45    /// A scatter plot of individual points.
46    Scatter(ScatterArtist),
47    /// A bar chart (vertical or horizontal).
48    Bar(BarArtist),
49    /// A histogram (binned frequency distribution).
50    Histogram(HistArtist),
51    /// A filled region between two y-series sharing a common x-series.
52    FillBetween(FillBetweenArtist),
53}
54
55impl Artist {
56    /// Returns the legend label for this artist, if one has been set.
57    ///
58    /// The legend renderer uses this to decide which artists appear in the
59    /// legend. Artists without a label are silently skipped.
60    pub fn label(&self) -> Option<&str> {
61        match self {
62            Artist::Line(a) => a.label.as_deref(),
63            Artist::Scatter(a) => a.label.as_deref(),
64            Artist::Bar(a) => a.label.as_deref(),
65            Artist::Histogram(a) => a.label.as_deref(),
66            Artist::FillBetween(a) => a.label.as_deref(),
67        }
68    }
69
70    /// Returns the primary color of this artist.
71    ///
72    /// Used by the legend to draw a color swatch next to the label, and by
73    /// any other component that needs to identify an artist's color (e.g.
74    /// tooltip rendering).
75    pub fn color(&self) -> Color {
76        match self {
77            Artist::Line(a) => a.color,
78            Artist::Scatter(a) => a.color,
79            Artist::Bar(a) => a.color,
80            Artist::Histogram(a) => a.color,
81            Artist::FillBetween(a) => a.color,
82        }
83    }
84
85    /// Returns the data-space bounding box as `(xmin, xmax, ymin, ymax)`.
86    ///
87    /// The axes autoscaling logic calls this on every artist to compute the
88    /// tightest axis limits that contain all visible data. If a series is
89    /// empty or contains no finite values, the corresponding min/max pair
90    /// falls back to `(0.0, 1.0)` so that the axes always have a non-zero
91    /// extent.
92    pub fn data_bounds(&self) -> (f64, f64, f64, f64) {
93        match self {
94            Artist::Line(a) => a.data_bounds(),
95            Artist::Scatter(a) => a.data_bounds(),
96            Artist::Bar(a) => a.data_bounds(),
97            Artist::Histogram(a) => a.data_bounds(),
98            Artist::FillBetween(a) => a.data_bounds(),
99        }
100    }
101}
102
103// ---------------------------------------------------------------------------
104// Helper: safe bounds with fallback
105// ---------------------------------------------------------------------------
106
107/// Returns `(min, max)` of the finite values in `series`, falling back to
108/// `(fallback_min, fallback_max)` when the series is empty or entirely
109/// non-finite.
110fn series_bounds_or(series: &Series, fallback_min: f64, fallback_max: f64) -> (f64, f64) {
111    match series.bounds() {
112        Some((lo, hi)) => (lo, hi),
113        None => (fallback_min, fallback_max),
114    }
115}
116
117// ---------------------------------------------------------------------------
118// LineArtist
119// ---------------------------------------------------------------------------
120
121/// A line chart connecting a sequence of (x, y) data points.
122///
123/// The `x` and `y` series must have the same length. Points are drawn in
124/// order, producing a single connected polyline with the configured stroke
125/// style.
126#[derive(Debug, Clone)]
127pub struct LineArtist {
128    /// X-coordinates of the data points.
129    pub x: Series,
130    /// Y-coordinates of the data points.
131    pub y: Series,
132    /// Stroke color of the line.
133    pub color: Color,
134    /// Stroke width in pixels.
135    pub width: f64,
136    /// Stroke pattern (solid, dashed, dotted, dash-dot).
137    pub style: LineStyle,
138    /// Optional legend label. When `Some`, the line appears in the legend.
139    pub label: Option<String>,
140    /// Opacity from 0.0 (fully transparent) to 1.0 (fully opaque).
141    pub alpha: f64,
142}
143
144impl LineArtist {
145    /// Computes the data-space bounding box `(xmin, xmax, ymin, ymax)`.
146    ///
147    /// Falls back to `(0.0, 1.0)` on each axis when the corresponding
148    /// series contains no finite values.
149    pub fn data_bounds(&self) -> (f64, f64, f64, f64) {
150        let (xmin, xmax) = series_bounds_or(&self.x, 0.0, 1.0);
151        let (ymin, ymax) = series_bounds_or(&self.y, 0.0, 1.0);
152        (xmin, xmax, ymin, ymax)
153    }
154}
155
156// ---------------------------------------------------------------------------
157// ScatterArtist
158// ---------------------------------------------------------------------------
159
160/// A scatter plot rendering individual markers at (x, y) positions.
161///
162/// Each data point is drawn as a marker whose shape, size, and color can be
163/// configured. An optional per-point `colors` vector overrides the uniform
164/// `color` field, enabling colormap-based visualizations.
165#[derive(Debug, Clone)]
166pub struct ScatterArtist {
167    /// X-coordinates of the data points.
168    pub x: Series,
169    /// Y-coordinates of the data points.
170    pub y: Series,
171    /// Default marker color (used when `colors` is `None`).
172    pub color: Color,
173    /// Marker shape.
174    pub marker: Marker,
175    /// Marker diameter in pixels.
176    pub size: f64,
177    /// Optional legend label. When `Some`, the scatter appears in the legend.
178    pub label: Option<String>,
179    /// Opacity from 0.0 (fully transparent) to 1.0 (fully opaque).
180    pub alpha: f64,
181    /// Optional per-point colors for colormap-driven scatter plots.
182    ///
183    /// When set, `colors.len()` must equal `x.len()` (and `y.len()`). Each
184    /// entry overrides `color` for the corresponding data point.
185    pub colors: Option<Vec<Color>>,
186}
187
188impl ScatterArtist {
189    /// Computes the data-space bounding box `(xmin, xmax, ymin, ymax)`.
190    ///
191    /// Falls back to `(0.0, 1.0)` on each axis when the corresponding
192    /// series contains no finite values.
193    pub fn data_bounds(&self) -> (f64, f64, f64, f64) {
194        let (xmin, xmax) = series_bounds_or(&self.x, 0.0, 1.0);
195        let (ymin, ymax) = series_bounds_or(&self.y, 0.0, 1.0);
196        (xmin, xmax, ymin, ymax)
197    }
198}
199
200// ---------------------------------------------------------------------------
201// BarArtist
202// ---------------------------------------------------------------------------
203
204/// A bar chart rendering vertical or horizontal bars over categorical data.
205///
206/// Categories are placed at integer positions `0, 1, 2, ...` on the
207/// category axis, with each bar centered on its position. The `bar_width`
208/// field controls the fraction of the inter-category spacing that the bar
209/// occupies (1.0 = bars touching, 0.5 = half-width with gaps).
210#[derive(Debug, Clone)]
211pub struct BarArtist {
212    /// Category labels for the bar axis.
213    pub categories: Categories,
214    /// Bar heights (or lengths, for horizontal bars).
215    pub heights: Series,
216    /// Fill color of the bars.
217    pub color: Color,
218    /// Optional legend label. When `Some`, the bar series appears in the legend.
219    pub label: Option<String>,
220    /// Opacity from 0.0 (fully transparent) to 1.0 (fully opaque).
221    pub alpha: f64,
222    /// When `true`, bars extend horizontally (categories on the y-axis).
223    pub horizontal: bool,
224    /// Bar width as a fraction of the category spacing (0.0, 1.0].
225    pub bar_width: f64,
226}
227
228impl BarArtist {
229    /// Computes the data-space bounding box `(xmin, xmax, ymin, ymax)`.
230    ///
231    /// For vertical bars, the x-axis spans from `-0.5` to `n - 0.5` (where
232    /// `n` is the number of categories) so that bars are centered on integer
233    /// positions. The y-axis spans from `0.0` to the tallest bar, with a
234    /// fallback of `(0.0, 1.0)` when the heights series is empty.
235    ///
236    /// For horizontal bars the axes are transposed: the y-axis holds the
237    /// category positions and the x-axis holds the bar lengths.
238    pub fn data_bounds(&self) -> (f64, f64, f64, f64) {
239        let n = self.categories.len() as f64;
240
241        // Determine the extent along the value axis (heights / lengths).
242        let height_min = self.heights.min().unwrap_or(0.0).min(0.0);
243        let height_max = self.heights.max().unwrap_or(1.0);
244
245        // Category axis runs from -0.5 to n-0.5 so bars are centered on 0..n-1.
246        let cat_min = -0.5;
247        let cat_max = if n > 0.0 { n - 0.5 } else { 0.5 };
248
249        if self.horizontal {
250            // Horizontal bars: x = value axis, y = category axis.
251            (height_min, height_max, cat_min, cat_max)
252        } else {
253            // Vertical bars: x = category axis, y = value axis.
254            (cat_min, cat_max, height_min, height_max)
255        }
256    }
257}
258
259// ---------------------------------------------------------------------------
260// HistArtist
261// ---------------------------------------------------------------------------
262
263/// A histogram showing the frequency distribution of a single data series.
264///
265/// The raw data is retained in `data`, but the binning results (`bin_edges`
266/// and `counts`) are expected to be pre-computed when the artist is created
267/// (typically by the histogram chart builder). This avoids re-binning during
268/// every render pass.
269///
270/// When `density` is `true`, the `counts` vector stores probability density
271/// values (each count divided by `n * bin_width`) rather than raw counts, so
272/// that the total area under the histogram integrates to 1.0.
273#[derive(Debug, Clone)]
274pub struct HistArtist {
275    /// The original (un-binned) data values.
276    pub data: Series,
277    /// The requested number of bins (used for display/debugging; the actual
278    /// bin count is `bin_edges.len() - 1`).
279    pub bins: usize,
280    /// Sorted bin edges of length `bins + 1`. The i-th bin spans
281    /// `[bin_edges[i], bin_edges[i+1])`.
282    pub bin_edges: Vec<f64>,
283    /// The count (or density) for each bin. Length equals `bin_edges.len() - 1`.
284    pub counts: Vec<f64>,
285    /// Fill color of the histogram bars.
286    pub color: Color,
287    /// Optional legend label. When `Some`, the histogram appears in the legend.
288    pub label: Option<String>,
289    /// Opacity from 0.0 (fully transparent) to 1.0 (fully opaque).
290    pub alpha: f64,
291    /// When `true`, `counts` stores probability density instead of raw counts.
292    pub density: bool,
293}
294
295impl HistArtist {
296    /// Computes the data-space bounding box `(xmin, xmax, ymin, ymax)`.
297    ///
298    /// The x-axis spans from the first bin edge to the last bin edge. The
299    /// y-axis spans from `0.0` to the tallest bin count (or density value).
300    /// Returns `(0.0, 1.0, 0.0, 1.0)` when there are no bin edges.
301    pub fn data_bounds(&self) -> (f64, f64, f64, f64) {
302        if self.bin_edges.len() < 2 {
303            return (0.0, 1.0, 0.0, 1.0);
304        }
305
306        // x-axis: first edge to last edge.
307        let xmin = self.bin_edges[0];
308        let xmax = self.bin_edges[self.bin_edges.len() - 1];
309
310        // y-axis: 0 to tallest bin.
311        let ymax = self
312            .counts
313            .iter()
314            .copied()
315            .filter(|v| v.is_finite())
316            .fold(0.0_f64, f64::max);
317
318        // Guarantee a non-zero y extent so the axes are always drawable.
319        let ymax = if ymax <= 0.0 { 1.0 } else { ymax };
320
321        (xmin, xmax, 0.0, ymax)
322    }
323}
324
325// ---------------------------------------------------------------------------
326// FillBetweenArtist
327// ---------------------------------------------------------------------------
328
329/// A filled region between two y-series that share a common x-series.
330///
331/// The renderer draws a closed polygon connecting `(x, y1)` forward and
332/// `(x, y2)` backward, then fills it with the configured color and opacity.
333/// This is commonly used for confidence bands, area charts, and shaded
334/// difference regions.
335#[derive(Debug, Clone)]
336pub struct FillBetweenArtist {
337    /// X-coordinates shared by both y-series.
338    pub x: Series,
339    /// Y-coordinates of the first boundary curve.
340    pub y1: Series,
341    /// Y-coordinates of the second boundary curve.
342    pub y2: Series,
343    /// Fill color of the shaded region.
344    pub color: Color,
345    /// Optional legend label. When `Some`, the fill region appears in the legend.
346    pub label: Option<String>,
347    /// Opacity from 0.0 (fully transparent) to 1.0 (fully opaque).
348    pub alpha: f64,
349}
350
351impl FillBetweenArtist {
352    /// Computes the data-space bounding box `(xmin, xmax, ymin, ymax)`.
353    ///
354    /// The x-bounds come from the shared `x` series. The y-bounds are the
355    /// union of `y1` and `y2` (i.e. the overall min and max across both
356    /// boundary curves). Falls back to `(0.0, 1.0)` on any axis that has
357    /// no finite values.
358    pub fn data_bounds(&self) -> (f64, f64, f64, f64) {
359        let (xmin, xmax) = series_bounds_or(&self.x, 0.0, 1.0);
360
361        // Union the y-bounds of both boundary series.
362        let y1_min = self.y1.min();
363        let y2_min = self.y2.min();
364        let y1_max = self.y1.max();
365        let y2_max = self.y2.max();
366
367        let ymin = match (y1_min, y2_min) {
368            (Some(a), Some(b)) => a.min(b),
369            (Some(a), None) => a,
370            (None, Some(b)) => b,
371            (None, None) => 0.0,
372        };
373
374        let ymax = match (y1_max, y2_max) {
375            (Some(a), Some(b)) => a.max(b),
376            (Some(a), None) => a,
377            (None, Some(b)) => b,
378            (None, None) => 1.0,
379        };
380
381        (xmin, xmax, ymin, ymax)
382    }
383}
384
385// ---------------------------------------------------------------------------
386// Tests
387// ---------------------------------------------------------------------------
388
389#[cfg(test)]
390mod tests {
391    use super::*;
392
393    /// Helper: build a simple `LineArtist` for testing.
394    fn sample_line() -> LineArtist {
395        LineArtist {
396            x: Series::new(vec![1.0, 2.0, 3.0]),
397            y: Series::new(vec![10.0, 20.0, 30.0]),
398            color: Color::TAB_BLUE,
399            width: 1.5,
400            style: LineStyle::Solid,
401            label: Some("line".to_string()),
402            alpha: 1.0,
403        }
404    }
405
406    /// Helper: build a simple `ScatterArtist` for testing.
407    fn sample_scatter() -> ScatterArtist {
408        ScatterArtist {
409            x: Series::new(vec![0.0, 5.0, 10.0]),
410            y: Series::new(vec![-1.0, 0.0, 1.0]),
411            color: Color::TAB_ORANGE,
412            marker: Marker::Circle,
413            size: 6.0,
414            label: None,
415            alpha: 0.8,
416            colors: None,
417        }
418    }
419
420    /// Helper: build a simple `BarArtist` for testing.
421    fn sample_bar() -> BarArtist {
422        BarArtist {
423            categories: Categories::new(vec!["A".into(), "B".into(), "C".into()]),
424            heights: Series::new(vec![4.0, 7.0, 2.0]),
425            color: Color::TAB_GREEN,
426            label: Some("bars".to_string()),
427            alpha: 1.0,
428            horizontal: false,
429            bar_width: 0.8,
430        }
431    }
432
433    /// Helper: build a simple `HistArtist` for testing.
434    fn sample_hist() -> HistArtist {
435        HistArtist {
436            data: Series::new(vec![1.0, 2.0, 2.5, 3.0, 3.5, 4.0]),
437            bins: 3,
438            bin_edges: vec![1.0, 2.0, 3.0, 4.0],
439            counts: vec![1.0, 2.0, 3.0],
440            color: Color::TAB_RED,
441            label: Some("hist".to_string()),
442            alpha: 0.7,
443            density: false,
444        }
445    }
446
447    /// Helper: build a simple `FillBetweenArtist` for testing.
448    fn sample_fill_between() -> FillBetweenArtist {
449        FillBetweenArtist {
450            x: Series::new(vec![0.0, 1.0, 2.0]),
451            y1: Series::new(vec![1.0, 3.0, 2.0]),
452            y2: Series::new(vec![0.0, 1.0, 0.5]),
453            color: Color::TAB_PURPLE,
454            label: Some("fill".to_string()),
455            alpha: 0.3,
456        }
457    }
458
459    // -- Artist enum dispatch -----------------------------------------------
460
461    #[test]
462    fn artist_label_returns_inner_label() {
463        let a = Artist::Line(sample_line());
464        assert_eq!(a.label(), Some("line"));
465
466        let a = Artist::Scatter(sample_scatter());
467        assert_eq!(a.label(), None);
468
469        let a = Artist::Bar(sample_bar());
470        assert_eq!(a.label(), Some("bars"));
471
472        let a = Artist::Histogram(sample_hist());
473        assert_eq!(a.label(), Some("hist"));
474
475        let a = Artist::FillBetween(sample_fill_between());
476        assert_eq!(a.label(), Some("fill"));
477    }
478
479    #[test]
480    fn artist_color_returns_inner_color() {
481        assert_eq!(Artist::Line(sample_line()).color(), Color::TAB_BLUE);
482        assert_eq!(Artist::Scatter(sample_scatter()).color(), Color::TAB_ORANGE);
483        assert_eq!(Artist::Bar(sample_bar()).color(), Color::TAB_GREEN);
484        assert_eq!(Artist::Histogram(sample_hist()).color(), Color::TAB_RED);
485        assert_eq!(
486            Artist::FillBetween(sample_fill_between()).color(),
487            Color::TAB_PURPLE
488        );
489    }
490
491    #[test]
492    fn artist_data_bounds_dispatches_correctly() {
493        let a = Artist::Line(sample_line());
494        assert_eq!(a.data_bounds(), (1.0, 3.0, 10.0, 30.0));
495    }
496
497    // -- LineArtist ---------------------------------------------------------
498
499    #[test]
500    fn line_data_bounds_basic() {
501        let a = sample_line();
502        assert_eq!(a.data_bounds(), (1.0, 3.0, 10.0, 30.0));
503    }
504
505    #[test]
506    fn line_data_bounds_empty_series() {
507        let a = LineArtist {
508            x: Series::new(vec![]),
509            y: Series::new(vec![]),
510            color: Color::BLACK,
511            width: 1.0,
512            style: LineStyle::Solid,
513            label: None,
514            alpha: 1.0,
515        };
516        assert_eq!(a.data_bounds(), (0.0, 1.0, 0.0, 1.0));
517    }
518
519    #[test]
520    fn line_data_bounds_with_nan() {
521        let a = LineArtist {
522            x: Series::new(vec![f64::NAN, 2.0, 5.0]),
523            y: Series::new(vec![1.0, f64::NAN, 3.0]),
524            color: Color::BLACK,
525            width: 1.0,
526            style: LineStyle::Solid,
527            label: None,
528            alpha: 1.0,
529        };
530        assert_eq!(a.data_bounds(), (2.0, 5.0, 1.0, 3.0));
531    }
532
533    // -- ScatterArtist ------------------------------------------------------
534
535    #[test]
536    fn scatter_data_bounds_basic() {
537        let a = sample_scatter();
538        assert_eq!(a.data_bounds(), (0.0, 10.0, -1.0, 1.0));
539    }
540
541    #[test]
542    fn scatter_data_bounds_empty() {
543        let a = ScatterArtist {
544            x: Series::new(vec![]),
545            y: Series::new(vec![]),
546            color: Color::BLACK,
547            marker: Marker::Circle,
548            size: 6.0,
549            label: None,
550            alpha: 1.0,
551            colors: None,
552        };
553        assert_eq!(a.data_bounds(), (0.0, 1.0, 0.0, 1.0));
554    }
555
556    // -- BarArtist ----------------------------------------------------------
557
558    #[test]
559    fn bar_data_bounds_vertical() {
560        let a = sample_bar();
561        let (xmin, xmax, ymin, ymax) = a.data_bounds();
562        assert!((xmin - (-0.5)).abs() < f64::EPSILON);
563        assert!((xmax - 2.5).abs() < f64::EPSILON);
564        assert!((ymin - 0.0).abs() < f64::EPSILON);
565        assert!((ymax - 7.0).abs() < f64::EPSILON);
566    }
567
568    #[test]
569    fn bar_data_bounds_horizontal() {
570        let mut a = sample_bar();
571        a.horizontal = true;
572        let (xmin, xmax, ymin, ymax) = a.data_bounds();
573        // Horizontal: x = value axis, y = category axis.
574        assert!((xmin - 0.0).abs() < f64::EPSILON);
575        assert!((xmax - 7.0).abs() < f64::EPSILON);
576        assert!((ymin - (-0.5)).abs() < f64::EPSILON);
577        assert!((ymax - 2.5).abs() < f64::EPSILON);
578    }
579
580    #[test]
581    fn bar_data_bounds_negative_heights() {
582        let a = BarArtist {
583            categories: Categories::new(vec!["A".into(), "B".into()]),
584            heights: Series::new(vec![-3.0, 5.0]),
585            color: Color::BLACK,
586            label: None,
587            alpha: 1.0,
588            horizontal: false,
589            bar_width: 0.8,
590        };
591        let (_, _, ymin, ymax) = a.data_bounds();
592        assert!((ymin - (-3.0)).abs() < f64::EPSILON);
593        assert!((ymax - 5.0).abs() < f64::EPSILON);
594    }
595
596    #[test]
597    fn bar_data_bounds_empty() {
598        let a = BarArtist {
599            categories: Categories::new(vec![]),
600            heights: Series::new(vec![]),
601            color: Color::BLACK,
602            label: None,
603            alpha: 1.0,
604            horizontal: false,
605            bar_width: 0.8,
606        };
607        let (xmin, xmax, ymin, ymax) = a.data_bounds();
608        assert!((xmin - (-0.5)).abs() < f64::EPSILON);
609        assert!((xmax - 0.5).abs() < f64::EPSILON);
610        assert!((ymin - 0.0).abs() < f64::EPSILON);
611        assert!((ymax - 1.0).abs() < f64::EPSILON);
612    }
613
614    // -- HistArtist ---------------------------------------------------------
615
616    #[test]
617    fn hist_data_bounds_basic() {
618        let a = sample_hist();
619        let (xmin, xmax, ymin, ymax) = a.data_bounds();
620        assert!((xmin - 1.0).abs() < f64::EPSILON);
621        assert!((xmax - 4.0).abs() < f64::EPSILON);
622        assert!((ymin - 0.0).abs() < f64::EPSILON);
623        assert!((ymax - 3.0).abs() < f64::EPSILON);
624    }
625
626    #[test]
627    fn hist_data_bounds_empty_bins() {
628        let a = HistArtist {
629            data: Series::new(vec![]),
630            bins: 0,
631            bin_edges: vec![],
632            counts: vec![],
633            color: Color::BLACK,
634            label: None,
635            alpha: 1.0,
636            density: false,
637        };
638        assert_eq!(a.data_bounds(), (0.0, 1.0, 0.0, 1.0));
639    }
640
641    #[test]
642    fn hist_data_bounds_single_edge_pair() {
643        let a = HistArtist {
644            data: Series::new(vec![1.0]),
645            bins: 1,
646            bin_edges: vec![0.5, 1.5],
647            counts: vec![1.0],
648            color: Color::BLACK,
649            label: None,
650            alpha: 1.0,
651            density: false,
652        };
653        let (xmin, xmax, ymin, ymax) = a.data_bounds();
654        assert!((xmin - 0.5).abs() < f64::EPSILON);
655        assert!((xmax - 1.5).abs() < f64::EPSILON);
656        assert!((ymin - 0.0).abs() < f64::EPSILON);
657        assert!((ymax - 1.0).abs() < f64::EPSILON);
658    }
659
660    #[test]
661    fn hist_data_bounds_all_zero_counts() {
662        let a = HistArtist {
663            data: Series::new(vec![]),
664            bins: 2,
665            bin_edges: vec![0.0, 1.0, 2.0],
666            counts: vec![0.0, 0.0],
667            color: Color::BLACK,
668            label: None,
669            alpha: 1.0,
670            density: false,
671        };
672        let (_, _, _, ymax) = a.data_bounds();
673        // All-zero counts should produce a fallback ymax of 1.0.
674        assert!((ymax - 1.0).abs() < f64::EPSILON);
675    }
676
677    // -- FillBetweenArtist --------------------------------------------------
678
679    #[test]
680    fn fill_between_data_bounds_basic() {
681        let a = sample_fill_between();
682        let (xmin, xmax, ymin, ymax) = a.data_bounds();
683        assert!((xmin - 0.0).abs() < f64::EPSILON);
684        assert!((xmax - 2.0).abs() < f64::EPSILON);
685        assert!((ymin - 0.0).abs() < f64::EPSILON);
686        assert!((ymax - 3.0).abs() < f64::EPSILON);
687    }
688
689    #[test]
690    fn fill_between_data_bounds_empty() {
691        let a = FillBetweenArtist {
692            x: Series::new(vec![]),
693            y1: Series::new(vec![]),
694            y2: Series::new(vec![]),
695            color: Color::BLACK,
696            label: None,
697            alpha: 1.0,
698        };
699        assert_eq!(a.data_bounds(), (0.0, 1.0, 0.0, 1.0));
700    }
701
702    #[test]
703    fn fill_between_data_bounds_y2_extends_beyond_y1() {
704        let a = FillBetweenArtist {
705            x: Series::new(vec![0.0, 1.0]),
706            y1: Series::new(vec![1.0, 2.0]),
707            y2: Series::new(vec![-5.0, 10.0]),
708            color: Color::BLACK,
709            label: None,
710            alpha: 1.0,
711        };
712        let (_, _, ymin, ymax) = a.data_bounds();
713        assert!((ymin - (-5.0)).abs() < f64::EPSILON);
714        assert!((ymax - 10.0).abs() < f64::EPSILON);
715    }
716
717    #[test]
718    fn fill_between_data_bounds_one_series_empty() {
719        // y1 has data, y2 is empty -- bounds should come from y1 alone.
720        let a = FillBetweenArtist {
721            x: Series::new(vec![0.0, 1.0]),
722            y1: Series::new(vec![2.0, 8.0]),
723            y2: Series::new(vec![]),
724            color: Color::BLACK,
725            label: None,
726            alpha: 1.0,
727        };
728        let (_, _, ymin, ymax) = a.data_bounds();
729        assert!((ymin - 2.0).abs() < f64::EPSILON);
730        assert!((ymax - 8.0).abs() < f64::EPSILON);
731    }
732}