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//! | [`Pie`]          | A pie chart showing proportional wedge slices.   |
20//! | [`Violin`]       | A violin plot showing kernel density estimates.  |
21//!
22//! [`Line`]: Artist::Line
23//! [`Scatter`]: Artist::Scatter
24//! [`Bar`]: Artist::Bar
25//! [`Histogram`]: Artist::Histogram
26//! [`FillBetween`]: Artist::FillBetween
27//! [`Pie`]: Artist::Pie
28//! [`Violin`]: Artist::Violin
29
30use crate::charts::boxplot::BoxStats;
31use crate::colormap::Colormap;
32use crate::primitives::Color;
33use crate::series::{Categories, Series};
34use crate::theme::{LineStyle, Marker};
35
36// ---------------------------------------------------------------------------
37// Artist enum
38// ---------------------------------------------------------------------------
39
40/// A visual element drawn on an axes.
41///
42/// `Artist` is the primary unit of chart content. Each variant wraps a
43/// concrete artist struct that stores the data, colors, and styling needed
44/// to render one visual element. The enum provides convenience accessors
45/// ([`label`](Artist::label), [`color`](Artist::color),
46/// [`data_bounds`](Artist::data_bounds)) that dispatch to the inner type.
47#[derive(Debug, Clone)]
48pub enum Artist {
49    /// A line chart connecting (x, y) points.
50    Line(LineArtist),
51    /// A scatter plot of individual points.
52    Scatter(ScatterArtist),
53    /// A bar chart (vertical or horizontal).
54    Bar(BarArtist),
55    /// A histogram (binned frequency distribution).
56    Histogram(HistArtist),
57    /// A filled region between two y-series sharing a common x-series.
58    FillBetween(FillBetweenArtist),
59    /// A step (staircase) chart connecting data points.
60    Step(StepArtist),
61    /// A stem (lollipop) chart from data points.
62    Stem(StemArtist),
63    /// A box-and-whisker plot showing distribution summaries.
64    BoxPlot(BoxPlotArtist),
65    /// An error bar plot showing data points with uncertainty bars.
66    ErrorBar(ErrorBarArtist),
67    /// A heatmap showing a 2D grid of values mapped to colors.
68    Heatmap(HeatmapArtist),
69    /// A pie chart showing proportional wedge slices.
70    Pie(PieArtist),
71    /// A violin plot showing kernel density estimates of distributions.
72    Violin(ViolinArtist),
73    /// A contour or filled contour plot over a 2D grid.
74    Contour(ContourArtist),
75}
76
77
78
79impl Artist {
80    /// Returns the legend label for this artist, if one has been set.
81    ///
82    /// The legend renderer uses this to decide which artists appear in the
83    /// legend. Artists without a label are silently skipped.
84    pub fn label(&self) -> Option<&str> {
85        match self {
86            Artist::Line(a) => a.label.as_deref(),
87            Artist::Scatter(a) => a.label.as_deref(),
88            Artist::Bar(a) => a.label.as_deref(),
89            Artist::Histogram(a) => a.label.as_deref(),
90            Artist::FillBetween(a) => a.label.as_deref(),
91            Artist::Step(a) => a.label.as_deref(),
92            Artist::Stem(a) => a.label.as_deref(),
93            Artist::BoxPlot(a) => a.label.as_deref(),
94            Artist::ErrorBar(a) => a.label.as_deref(),
95            Artist::Heatmap(a) => a.label.as_deref(),
96            Artist::Pie(a) => a.label.as_deref(),
97            Artist::Violin(a) => a.label.as_deref(),
98            Artist::Contour(a) => a.label.as_deref(),
99        }
100    }
101
102    /// Returns the primary color of this artist.
103    ///
104    /// Used by the legend to draw a color swatch next to the label, and by
105    /// any other component that needs to identify an artist's color (e.g.
106    /// tooltip rendering).
107    pub fn color(&self) -> Color {
108        match self {
109            Artist::Line(a) => a.color,
110            Artist::Scatter(a) => a.color,
111            Artist::Bar(a) => a.color,
112            Artist::Histogram(a) => a.color,
113            Artist::FillBetween(a) => a.color,
114            Artist::Step(a) => a.color,
115            Artist::Stem(a) => a.color,
116            Artist::BoxPlot(a) => a.color,
117            Artist::ErrorBar(a) => a.color,
118            Artist::Heatmap(a) => a.color,
119            Artist::Pie(a) => a.color,
120            Artist::Violin(a) => a.color,
121            Artist::Contour(a) => a.color,
122        }
123    }
124
125    /// Returns the data-space bounding box as `(xmin, xmax, ymin, ymax)`.
126    ///
127    /// The axes autoscaling logic calls this on every artist to compute the
128    /// tightest axis limits that contain all visible data. If a series is
129    /// empty or contains no finite values, the corresponding min/max pair
130    /// falls back to `(0.0, 1.0)` so that the axes always have a non-zero
131    /// extent.
132    pub fn data_bounds(&self) -> (f64, f64, f64, f64) {
133        match self {
134            Artist::Line(a) => a.data_bounds(),
135            Artist::Scatter(a) => a.data_bounds(),
136            Artist::Bar(a) => a.data_bounds(),
137            Artist::Histogram(a) => a.data_bounds(),
138            Artist::FillBetween(a) => a.data_bounds(),
139            Artist::Step(a) => a.data_bounds(),
140            Artist::Stem(a) => a.data_bounds(),
141            Artist::BoxPlot(a) => a.data_bounds(),
142            Artist::ErrorBar(a) => a.data_bounds(),
143            Artist::Heatmap(a) => a.data_bounds(),
144            Artist::Pie(a) => a.data_bounds(),
145            Artist::Violin(a) => a.data_bounds(),
146            Artist::Contour(a) => a.data_bounds(),
147        }
148    }
149}
150
151// ---------------------------------------------------------------------------
152// Helper: safe bounds with fallback
153// ---------------------------------------------------------------------------
154
155/// Returns `(min, max)` of the finite values in `series`, falling back to
156/// `(fallback_min, fallback_max)` when the series is empty or entirely
157/// non-finite.
158fn series_bounds_or(series: &Series, fallback_min: f64, fallback_max: f64) -> (f64, f64) {
159    match series.bounds() {
160        Some((lo, hi)) => (lo, hi),
161        None => (fallback_min, fallback_max),
162    }
163}
164
165// ---------------------------------------------------------------------------
166// LineArtist
167// ---------------------------------------------------------------------------
168
169/// A line chart connecting a sequence of (x, y) data points.
170///
171/// The `x` and `y` series must have the same length. Points are drawn in
172/// order, producing a single connected polyline with the configured stroke
173/// style.
174#[derive(Debug, Clone)]
175pub struct LineArtist {
176    /// X-coordinates of the data points.
177    pub x: Series,
178    /// Y-coordinates of the data points.
179    pub y: Series,
180    /// Stroke color of the line.
181    pub color: Color,
182    /// Stroke width in pixels.
183    pub width: f64,
184    /// Stroke pattern (solid, dashed, dotted, dash-dot).
185    pub style: LineStyle,
186    /// Optional legend label. When `Some`, the line appears in the legend.
187    pub label: Option<String>,
188    /// Opacity from 0.0 (fully transparent) to 1.0 (fully opaque).
189    pub alpha: f64,
190}
191
192impl LineArtist {
193    /// Computes the data-space bounding box `(xmin, xmax, ymin, ymax)`.
194    ///
195    /// Falls back to `(0.0, 1.0)` on each axis when the corresponding
196    /// series contains no finite values.
197    pub fn data_bounds(&self) -> (f64, f64, f64, f64) {
198        let (xmin, xmax) = series_bounds_or(&self.x, 0.0, 1.0);
199        let (ymin, ymax) = series_bounds_or(&self.y, 0.0, 1.0);
200        (xmin, xmax, ymin, ymax)
201    }
202}
203
204// ---------------------------------------------------------------------------
205// ScatterArtist
206// ---------------------------------------------------------------------------
207
208/// A scatter plot rendering individual markers at (x, y) positions.
209///
210/// Each data point is drawn as a marker whose shape, size, and color can be
211/// configured. An optional per-point `colors` vector overrides the uniform
212/// `color` field, enabling colormap-based visualizations.
213#[derive(Debug, Clone)]
214pub struct ScatterArtist {
215    /// X-coordinates of the data points.
216    pub x: Series,
217    /// Y-coordinates of the data points.
218    pub y: Series,
219    /// Default marker color (used when `colors` is `None`).
220    pub color: Color,
221    /// Marker shape.
222    pub marker: Marker,
223    /// Marker diameter in pixels.
224    pub size: f64,
225    /// Optional legend label. When `Some`, the scatter appears in the legend.
226    pub label: Option<String>,
227    /// Opacity from 0.0 (fully transparent) to 1.0 (fully opaque).
228    pub alpha: f64,
229    /// Optional per-point colors for colormap-driven scatter plots.
230    ///
231    /// When set, `colors.len()` must equal `x.len()` (and `y.len()`). Each
232    /// entry overrides `color` for the corresponding data point.
233    pub colors: Option<Vec<Color>>,
234    /// Optional per-point scalar values for colormap-driven coloring.
235    ///
236    /// When set together with `cmap`, each value is mapped through the
237    /// colormap to produce per-point colors. Takes precedence over `colors`.
238    pub c: Option<Vec<f64>>,
239    /// Optional colormap used to map `c` values to colors.
240    pub cmap: Option<Colormap>,
241}
242
243impl ScatterArtist {
244    /// Computes the data-space bounding box `(xmin, xmax, ymin, ymax)`.
245    ///
246    /// Falls back to `(0.0, 1.0)` on each axis when the corresponding
247    /// series contains no finite values.
248    pub fn data_bounds(&self) -> (f64, f64, f64, f64) {
249        let (xmin, xmax) = series_bounds_or(&self.x, 0.0, 1.0);
250        let (ymin, ymax) = series_bounds_or(&self.y, 0.0, 1.0);
251        (xmin, xmax, ymin, ymax)
252    }
253}
254
255// ---------------------------------------------------------------------------
256// BarArtist
257// ---------------------------------------------------------------------------
258
259/// A bar chart rendering vertical or horizontal bars over categorical data.
260///
261/// Categories are placed at integer positions `0, 1, 2, ...` on the
262/// category axis, with each bar centered on its position. The `bar_width`
263/// field controls the fraction of the inter-category spacing that the bar
264/// occupies (1.0 = bars touching, 0.5 = half-width with gaps).
265///
266/// For stacked bars, set `bottom` to offset each bar from a baseline other
267/// than zero. For grouped (side-by-side) bars, adjust category positions and
268/// `bar_width` for each series.
269#[derive(Debug, Clone)]
270pub struct BarArtist {
271    /// Category labels for the bar axis.
272    pub categories: Categories,
273    /// Bar heights (or lengths, for horizontal bars).
274    pub heights: Series,
275    /// Fill color of the bars.
276    pub color: Color,
277    /// Optional legend label. When `Some`, the bar series appears in the legend.
278    pub label: Option<String>,
279    /// Opacity from 0.0 (fully transparent) to 1.0 (fully opaque).
280    pub alpha: f64,
281    /// When `true`, bars extend horizontally (categories on the y-axis).
282    pub horizontal: bool,
283    /// Bar width as a fraction of the category spacing (0.0, 1.0].
284    pub bar_width: f64,
285    /// Optional per-bar base offset for stacking.
286    ///
287    /// When `Some`, each bar starts at `bottom[i]` instead of `0.0` and extends
288    /// to `bottom[i] + heights[i]`. The length must equal `heights.len()`.
289    pub bottom: Option<Vec<f64>>,
290    /// Optional per-bar x-position offset for grouped (side-by-side) bars.
291    ///
292    /// When `Some`, each bar's category center is shifted by `offset[i]` data
293    /// units. The length must equal `heights.len()`.
294    pub offset: Option<Vec<f64>>,
295}
296
297impl BarArtist {
298    /// Computes the data-space bounding box `(xmin, xmax, ymin, ymax)`.
299    ///
300    /// For vertical bars, the x-axis spans from `-0.5` to `n - 0.5` (where
301    /// `n` is the number of categories) so that bars are centered on integer
302    /// positions. The y-axis spans from `0.0` to the tallest bar, with a
303    /// fallback of `(0.0, 1.0)` when the heights series is empty.
304    ///
305    /// When `bottom` is set, the value axis includes both the bottom offsets
306    /// and `bottom + height` values. When `offset` is set, the category axis
307    /// is expanded to accommodate shifted bar positions.
308    ///
309    /// For horizontal bars the axes are transposed: the y-axis holds the
310    /// category positions and the x-axis holds the bar lengths.
311    pub fn data_bounds(&self) -> (f64, f64, f64, f64) {
312        let n = self.categories.len() as f64;
313
314        // Determine the extent along the value axis (heights / lengths),
315        // accounting for an optional bottom offset.
316        let (height_min, height_max) = if let Some(ref bot) = self.bottom {
317            let mut vmin = f64::INFINITY;
318            let mut vmax = f64::NEG_INFINITY;
319            for i in 0..self.heights.len() {
320                let b = if i < bot.len() { bot[i] } else { 0.0 };
321                let h = self.heights.data[i];
322                let top = b + h;
323                vmin = vmin.min(b).min(top);
324                vmax = vmax.max(b).max(top);
325            }
326            if !vmin.is_finite() {
327                vmin = 0.0;
328            }
329            if !vmax.is_finite() {
330                vmax = 1.0;
331            }
332            // Ensure 0.0 is included when all values are positive or negative.
333            (vmin.min(0.0), vmax)
334        } else {
335            let hmin = self.heights.min().unwrap_or(0.0).min(0.0);
336            let hmax = self.heights.max().unwrap_or(1.0);
337            (hmin, hmax)
338        };
339
340        // Category axis runs from -0.5 to n-0.5 so bars are centered on 0..n-1.
341        // Expand if offsets push bars outside this range.
342        let mut cat_min: f64 = -0.5;
343        let mut cat_max: f64 = if n > 0.0 { n - 0.5 } else { 0.5 };
344        if let Some(ref off) = self.offset {
345            let half_bar = self.bar_width * 0.5;
346            for i in 0..self.heights.len() {
347                let o = if i < off.len() { off[i] } else { 0.0 };
348                let center = i as f64 + o;
349                cat_min = cat_min.min(center - half_bar);
350                cat_max = cat_max.max(center + half_bar);
351            }
352        }
353
354        if self.horizontal {
355            // Horizontal bars: x = value axis, y = category axis.
356            (height_min, height_max, cat_min, cat_max)
357        } else {
358            // Vertical bars: x = category axis, y = value axis.
359            (cat_min, cat_max, height_min, height_max)
360        }
361    }
362}
363
364// ---------------------------------------------------------------------------
365// HistArtist
366// ---------------------------------------------------------------------------
367
368/// A histogram showing the frequency distribution of a single data series.
369///
370/// The raw data is retained in `data`, but the binning results (`bin_edges`
371/// and `counts`) are expected to be pre-computed when the artist is created
372/// (typically by the histogram chart builder). This avoids re-binning during
373/// every render pass.
374///
375/// When `density` is `true`, the `counts` vector stores probability density
376/// values (each count divided by `n * bin_width`) rather than raw counts, so
377/// that the total area under the histogram integrates to 1.0.
378#[derive(Debug, Clone)]
379pub struct HistArtist {
380    /// The original (un-binned) data values.
381    pub data: Series,
382    /// The requested number of bins (used for display/debugging; the actual
383    /// bin count is `bin_edges.len() - 1`).
384    pub bins: usize,
385    /// Sorted bin edges of length `bins + 1`. The i-th bin spans
386    /// `[bin_edges[i], bin_edges[i+1])`.
387    pub bin_edges: Vec<f64>,
388    /// The count (or density) for each bin. Length equals `bin_edges.len() - 1`.
389    pub counts: Vec<f64>,
390    /// Fill color of the histogram bars.
391    pub color: Color,
392    /// Optional legend label. When `Some`, the histogram appears in the legend.
393    pub label: Option<String>,
394    /// Opacity from 0.0 (fully transparent) to 1.0 (fully opaque).
395    pub alpha: f64,
396    /// When `true`, `counts` stores probability density instead of raw counts.
397    pub density: bool,
398}
399
400impl HistArtist {
401    /// Computes the data-space bounding box `(xmin, xmax, ymin, ymax)`.
402    ///
403    /// The x-axis spans from the first bin edge to the last bin edge. The
404    /// y-axis spans from `0.0` to the tallest bin count (or density value).
405    /// Returns `(0.0, 1.0, 0.0, 1.0)` when there are no bin edges.
406    pub fn data_bounds(&self) -> (f64, f64, f64, f64) {
407        if self.bin_edges.len() < 2 {
408            return (0.0, 1.0, 0.0, 1.0);
409        }
410
411        // x-axis: first edge to last edge.
412        let xmin = self.bin_edges[0];
413        let xmax = self.bin_edges[self.bin_edges.len() - 1];
414
415        // y-axis: 0 to tallest bin.
416        let ymax = self
417            .counts
418            .iter()
419            .copied()
420            .filter(|v| v.is_finite())
421            .fold(0.0_f64, f64::max);
422
423        // Guarantee a non-zero y extent so the axes are always drawable.
424        let ymax = if ymax <= 0.0 { 1.0 } else { ymax };
425
426        (xmin, xmax, 0.0, ymax)
427    }
428}
429
430// ---------------------------------------------------------------------------
431// FillBetweenArtist
432// ---------------------------------------------------------------------------
433
434/// A filled region between two y-series that share a common x-series.
435///
436/// The renderer draws a closed polygon connecting `(x, y1)` forward and
437/// `(x, y2)` backward, then fills it with the configured color and opacity.
438/// This is commonly used for confidence bands, area charts, and shaded
439/// difference regions.
440#[derive(Debug, Clone)]
441pub struct FillBetweenArtist {
442    /// X-coordinates shared by both y-series.
443    pub x: Series,
444    /// Y-coordinates of the first boundary curve.
445    pub y1: Series,
446    /// Y-coordinates of the second boundary curve.
447    pub y2: Series,
448    /// Fill color of the shaded region.
449    pub color: Color,
450    /// Optional legend label. When `Some`, the fill region appears in the legend.
451    pub label: Option<String>,
452    /// Opacity from 0.0 (fully transparent) to 1.0 (fully opaque).
453    pub alpha: f64,
454}
455
456impl FillBetweenArtist {
457    /// Computes the data-space bounding box `(xmin, xmax, ymin, ymax)`.
458    ///
459    /// The x-bounds come from the shared `x` series. The y-bounds are the
460    /// union of `y1` and `y2` (i.e. the overall min and max across both
461    /// boundary curves). Falls back to `(0.0, 1.0)` on any axis that has
462    /// no finite values.
463    pub fn data_bounds(&self) -> (f64, f64, f64, f64) {
464        let (xmin, xmax) = series_bounds_or(&self.x, 0.0, 1.0);
465
466        // Union the y-bounds of both boundary series.
467        let y1_min = self.y1.min();
468        let y2_min = self.y2.min();
469        let y1_max = self.y1.max();
470        let y2_max = self.y2.max();
471
472        let ymin = match (y1_min, y2_min) {
473            (Some(a), Some(b)) => a.min(b),
474            (Some(a), None) => a,
475            (None, Some(b)) => b,
476            (None, None) => 0.0,
477        };
478
479        let ymax = match (y1_max, y2_max) {
480            (Some(a), Some(b)) => a.max(b),
481            (Some(a), None) => a,
482            (None, Some(b)) => b,
483            (None, None) => 1.0,
484        };
485
486        (xmin, xmax, ymin, ymax)
487    }
488}
489
490// ---------------------------------------------------------------------------
491// BoxPlotArtist
492// ---------------------------------------------------------------------------
493
494/// A box-and-whisker plot showing distribution summaries for one or more
495/// groups of data.
496///
497/// Each group produces a box spanning Q1 to Q3 with a median line, whiskers
498/// extending to the most extreme data points within the configured fence, and
499/// optional outlier dots beyond the whiskers.
500#[derive(Debug, Clone)]
501pub struct BoxPlotArtist {
502    /// Pre-computed summary statistics for each group.
503    pub stats: Vec<BoxStats>,
504    /// Category labels for the x-axis (one per group).
505    pub labels: Vec<String>,
506    /// Fill color of the boxes.
507    pub color: Color,
508    /// Optional legend label.
509    pub label: Option<String>,
510    /// Opacity from 0.0 (fully transparent) to 1.0 (fully opaque).
511    pub alpha: f64,
512    /// Box width as a fraction of the category spacing.
513    pub box_width: f64,
514    /// Whether to draw outlier dots.
515    pub show_outliers: bool,
516    /// Whisker extent as a multiple of IQR.
517    pub whisker_iq_factor: f64,
518    /// Raw data retained for re-computing stats when parameters change.
519    pub raw_data: Vec<Vec<f64>>,
520}
521
522impl BoxPlotArtist {
523    /// Computes the data-space bounding box `(xmin, xmax, ymin, ymax)`.
524    ///
525    /// The x-axis spans from `-0.5` to `n - 0.5` (where `n` is the number
526    /// of groups), centering each box on an integer position. The y-axis
527    /// spans from the lowest whisker (or outlier) to the highest.
528    pub fn data_bounds(&self) -> (f64, f64, f64, f64) {
529        let n = self.stats.len();
530        if n == 0 {
531            return (0.0, 1.0, 0.0, 1.0);
532        }
533        let xmin = -0.5;
534        let xmax = n as f64 - 0.5;
535        let mut ymin = f64::INFINITY;
536        let mut ymax = f64::NEG_INFINITY;
537        for s in &self.stats {
538            ymin = ymin.min(s.whisker_low);
539            ymax = ymax.max(s.whisker_high);
540            for &o in &s.outliers {
541                ymin = ymin.min(o);
542                ymax = ymax.max(o);
543            }
544        }
545        if !ymin.is_finite() {
546            ymin = 0.0;
547        }
548        if !ymax.is_finite() {
549            ymax = 1.0;
550        }
551        (xmin, xmax, ymin, ymax)
552    }
553}
554
555
556// ---------------------------------------------------------------------------
557// ErrorBarData
558// ---------------------------------------------------------------------------
559
560/// The error data for one axis of an error bar plot.
561///
562/// Symmetric errors apply the same magnitude on both sides of the data point.
563/// Asymmetric errors allow separate low and high magnitudes.
564#[derive(Debug, Clone)]
565pub enum ErrorBarData {
566    /// Equal error on both sides: `y - e` to `y + e`.
567    Symmetric(Vec<f64>),
568    /// Separate low and high errors: `y - low[i]` to `y + high[i]`.
569    Asymmetric {
570        /// Error magnitudes below each data point.
571        low: Vec<f64>,
572        /// Error magnitudes above each data point.
573        high: Vec<f64>,
574    },
575}
576
577// ---------------------------------------------------------------------------
578// ErrorBarArtist
579// ---------------------------------------------------------------------------
580
581/// An error bar plot showing data points with uncertainty bars.
582///
583/// Each data point `(x, y)` can have optional horizontal (`xerr`) and/or
584/// vertical (`yerr`) error bars. The error bars are drawn as lines with
585/// optional caps at the ends.
586#[derive(Debug, Clone)]
587pub struct ErrorBarArtist {
588    /// X-coordinates of the data points.
589    pub x: Series,
590    /// Y-coordinates of the data points.
591    pub y: Series,
592    /// Optional x-axis error data.
593    pub xerr: Option<ErrorBarData>,
594    /// Optional y-axis error data.
595    pub yerr: Option<ErrorBarData>,
596    /// Color for the center line, error bars, and caps.
597    pub color: Color,
598    /// Optional legend label.
599    pub label: Option<String>,
600    /// Cap size in pixels for the error bar ends.
601    pub cap_size: f64,
602    /// Stroke width of the error bar lines and caps.
603    pub line_width: f64,
604}
605
606impl ErrorBarArtist {
607    /// Computes the data-space bounding box `(xmin, xmax, ymin, ymax)`.
608    ///
609    /// Includes the extent of error bars when present, so that auto-scaling
610    /// shows the full error range.
611    pub fn data_bounds(&self) -> (f64, f64, f64, f64) {
612        let (mut xmin, mut xmax) = series_bounds_or(&self.x, 0.0, 1.0);
613        let (mut ymin, mut ymax) = series_bounds_or(&self.y, 0.0, 1.0);
614
615        // Expand x-bounds by xerr.
616        if let Some(ref xerr) = self.xerr {
617            for i in 0..self.x.len() {
618                let xv = self.x.data[i];
619                let (lo, hi) = match xerr {
620                    ErrorBarData::Symmetric(e) => (xv - e[i], xv + e[i]),
621                    ErrorBarData::Asymmetric { low, high } => (xv - low[i], xv + high[i]),
622                };
623                xmin = xmin.min(lo);
624                xmax = xmax.max(hi);
625            }
626        }
627
628        // Expand y-bounds by yerr.
629        if let Some(ref yerr) = self.yerr {
630            for i in 0..self.y.len() {
631                let yv = self.y.data[i];
632                let (lo, hi) = match yerr {
633                    ErrorBarData::Symmetric(e) => (yv - e[i], yv + e[i]),
634                    ErrorBarData::Asymmetric { low, high } => (yv - low[i], yv + high[i]),
635                };
636                ymin = ymin.min(lo);
637                ymax = ymax.max(hi);
638            }
639        }
640
641        (xmin, xmax, ymin, ymax)
642    }
643}
644
645// ---------------------------------------------------------------------------
646// HeatmapArtist
647// ---------------------------------------------------------------------------
648
649/// A heatmap showing a 2D grid of values mapped to colors via a colormap.
650///
651/// Each cell in the grid is filled with a color determined by mapping its
652/// value through the configured [`Colormap`]. Optional text annotations
653/// can display the numeric value inside each cell.
654#[derive(Debug, Clone)]
655pub struct HeatmapArtist {
656    /// Row-major grid of values. `data[row][col]`.
657    pub data: Vec<Vec<f64>>,
658    /// Optional column labels for the x-axis.
659    pub x_labels: Option<Vec<String>>,
660    /// Optional row labels for the y-axis.
661    pub y_labels: Option<Vec<String>>,
662    /// Colormap used to map cell values to colors.
663    pub cmap: Colormap,
664    /// Minimum value for colormap normalisation. `None` means auto.
665    pub vmin: Option<f64>,
666    /// Maximum value for colormap normalisation. `None` means auto.
667    pub vmax: Option<f64>,
668    /// Whether to draw cell values as text.
669    pub show_values: bool,
670    /// Primary color (used for legend swatch).
671    pub color: Color,
672    /// Optional legend label.
673    pub label: Option<String>,
674    /// Whether to auto-attach a colorbar when this heatmap is drawn.
675    pub show_colorbar: bool,
676}
677
678impl HeatmapArtist {
679    /// Computes the data-space bounding box `(xmin, xmax, ymin, ymax)`.
680    ///
681    /// The grid spans from `(0, 0)` to `(ncols, nrows)`. Returns
682    /// `(0.0, 1.0, 0.0, 1.0)` when the data is empty.
683    pub fn data_bounds(&self) -> (f64, f64, f64, f64) {
684        let nrows = self.data.len();
685        if nrows == 0 {
686            return (0.0, 1.0, 0.0, 1.0);
687        }
688        let ncols = self.data[0].len();
689        if ncols == 0 {
690            return (0.0, 1.0, 0.0, 1.0);
691        }
692        (0.0, ncols as f64, 0.0, nrows as f64)
693    }
694}
695
696// ---------------------------------------------------------------------------
697// PieArtist
698// ---------------------------------------------------------------------------
699
700/// A pie chart rendering proportional wedge slices from a set of sizes.
701///
702/// Each entry in `sizes` is automatically normalised so that the wedges
703/// sum to a full circle. The starting angle, explode offsets, and colors
704/// can be customised through the builder API.
705#[derive(Debug, Clone)]
706pub struct PieArtist {
707    /// Wedge sizes (auto-normalised to sum to 1.0 during rendering).
708    pub sizes: Vec<f64>,
709    /// Optional labels for each wedge, drawn outside the wedge arc.
710    pub labels: Option<Vec<String>>,
711    /// Optional custom colors for each wedge. When `None`, the theme
712    /// color cycle is used.
713    pub colors: Option<Vec<Color>>,
714    /// Optional explode offset for each wedge, as a fraction of the
715    /// radius. A value of `0.0` means no offset.
716    pub explode: Option<Vec<f64>>,
717    /// When `true`, percentage labels are drawn at the midpoint of each
718    /// wedge arc.
719    pub autopct: bool,
720    /// Starting angle in degrees, counter-clockwise from the positive
721    /// x-axis. Default is `90.0` (top of the circle).
722    pub start_angle: f64,
723    /// Radius of the pie in data-space units. Default is `1.0`.
724    pub radius: f64,
725    /// Optional legend label. When `Some`, the pie appears in the legend.
726    pub label: Option<String>,
727    /// Primary color (used for legend swatch when no custom colors are set).
728    pub color: Color,
729}
730
731impl PieArtist {
732    /// Computes the data-space bounding box `(xmin, xmax, ymin, ymax)`.
733    ///
734    /// Returns a fixed square region that accommodates the pie radius plus
735    /// any explode offsets, with a small margin so that labels do not get
736    /// clipped.
737    pub fn data_bounds(&self) -> (f64, f64, f64, f64) {
738        let max_explode = self
739            .explode
740            .as_ref()
741            .map(|e| e.iter().copied().fold(0.0_f64, f64::max))
742            .unwrap_or(0.0);
743        let extent = self.radius * (1.0 + max_explode) + 0.1 * self.radius;
744        (-extent, extent, -extent, extent)
745    }
746}
747
748// ---------------------------------------------------------------------------
749// ViolinArtist
750// ---------------------------------------------------------------------------
751
752/// A violin plot showing the probability density of data distributions.
753///
754/// Each dataset produces a mirrored kernel density estimate (KDE) shape,
755/// similar to a boxplot but showing the full distribution. Optional median
756/// and quartile lines can be drawn inside the violin.
757#[derive(Debug, Clone)]
758pub struct ViolinArtist {
759    /// One dataset per violin. Each inner `Vec<f64>` contains the raw values.
760    pub datasets: Vec<Vec<f64>>,
761    /// Optional x-positions for each violin. Defaults to 1, 2, 3, etc.
762    pub positions: Option<Vec<f64>>,
763    /// Maximum width of each violin shape.
764    pub widths: f64,
765    /// Whether to draw a median line inside each violin.
766    pub show_median: bool,
767    /// Whether to draw Q1/Q3 quartile lines inside each violin.
768    pub show_quartiles: bool,
769    /// Fill color of the violin shapes.
770    pub color: Color,
771    /// Opacity from 0.0 (fully transparent) to 1.0 (fully opaque).
772    pub alpha: f64,
773    /// Optional legend label.
774    pub label: Option<String>,
775    /// KDE bandwidth override. When <= 0.0, Silverman's rule is used.
776    pub bw_method: f64,
777}
778
779// ---------------------------------------------------------------------------
780// ContourArtist
781// ---------------------------------------------------------------------------
782
783/// A contour or filled contour plot over a 2D grid of z = f(x, y) values.
784///
785/// In unfilled mode (`filled = false`), iso-lines are drawn at each contour
786/// level using the marching squares algorithm. In filled mode (`filled = true`),
787/// the regions between contour levels are filled with colors from a colormap.
788#[derive(Debug, Clone)]
789pub struct ContourArtist {
790    /// X grid coordinates (length `nx`).
791    pub x: Vec<f64>,
792    /// Y grid coordinates (length `ny`).
793    pub y: Vec<f64>,
794    /// Z values on the grid, shape `[ny][nx]` (row-major).
795    pub z: Vec<Vec<f64>>,
796    /// Explicit contour levels. When `None`, levels are auto-computed.
797    pub levels: Option<Vec<f64>>,
798    /// Whether to fill regions between levels (`true` for contourf).
799    pub filled: bool,
800    /// Colormap used to map contour levels to colors.
801    pub cmap: Colormap,
802    /// Optional explicit colors for each contour level, overriding the colormap.
803    pub colors: Option<Vec<Color>>,
804    /// Stroke width for contour lines (unfilled mode). Default `1.0`.
805    pub linewidths: f64,
806    /// Optional legend label.
807    pub label: Option<String>,
808    /// Primary color (used for legend swatch).
809    pub color: Color,
810    /// Number of auto-computed levels when `levels` is `None`. Default `10`.
811    pub num_levels: usize,
812}
813
814impl ContourArtist {
815    /// Computes the data-space bounding box `(xmin, xmax, ymin, ymax)`.
816    ///
817    /// Returns the extent of the x and y grid coordinates. Falls back to
818    /// `(0.0, 1.0, 0.0, 1.0)` when either coordinate vector is empty.
819    pub fn data_bounds(&self) -> (f64, f64, f64, f64) {
820        if self.x.is_empty() || self.y.is_empty() {
821            return (0.0, 1.0, 0.0, 1.0);
822        }
823        let xmin = self.x.iter().copied().fold(f64::INFINITY, f64::min);
824        let xmax = self.x.iter().copied().fold(f64::NEG_INFINITY, f64::max);
825        let ymin = self.y.iter().copied().fold(f64::INFINITY, f64::min);
826        let ymax = self.y.iter().copied().fold(f64::NEG_INFINITY, f64::max);
827
828        let (xmin, xmax) = if xmin.is_finite() && xmax.is_finite() {
829            (xmin, xmax)
830        } else {
831            (0.0, 1.0)
832        };
833        let (ymin, ymax) = if ymin.is_finite() && ymax.is_finite() {
834            (ymin, ymax)
835        } else {
836            (0.0, 1.0)
837        };
838        (xmin, xmax, ymin, ymax)
839    }
840}
841
842// ---------------------------------------------------------------------------
843// StepWhere
844// ---------------------------------------------------------------------------
845
846/// Controls where the horizontal segment of a step chart is placed.
847#[derive(Debug, Clone, Copy, PartialEq, Eq)]
848pub enum StepWhere {
849    /// The y-value changes *before* the x-value (vertical then horizontal).
850    Pre,
851    /// The y-value changes *after* the x-value (horizontal then vertical).
852    Post,
853    /// The y-value changes at the midpoint between consecutive x-values.
854    Mid,
855}
856
857// ---------------------------------------------------------------------------
858// StepArtist
859// ---------------------------------------------------------------------------
860
861/// A step (staircase) chart.
862#[derive(Debug, Clone)]
863pub struct StepArtist {
864    /// X-coordinates of the data points.
865    pub x: Series,
866    /// Y-coordinates of the data points.
867    pub y: Series,
868    /// Stroke color of the step line.
869    pub color: Color,
870    /// Stroke width in pixels.
871    pub width: f64,
872    /// Step alignment mode.
873    pub where_step: StepWhere,
874    /// Optional legend label.
875    pub label: Option<String>,
876    /// Opacity from 0.0 (fully transparent) to 1.0 (fully opaque).
877    pub alpha: f64,
878}
879
880impl StepArtist {
881    /// Computes the data-space bounding box `(xmin, xmax, ymin, ymax)`.
882    pub fn data_bounds(&self) -> (f64, f64, f64, f64) {
883        let (xmin, xmax) = series_bounds_or(&self.x, 0.0, 1.0);
884        let (ymin, ymax) = series_bounds_or(&self.y, 0.0, 1.0);
885        (xmin, xmax, ymin, ymax)
886    }
887}
888
889// ---------------------------------------------------------------------------
890// StemArtist
891// ---------------------------------------------------------------------------
892
893/// A stem (lollipop) chart.
894#[derive(Debug, Clone)]
895pub struct StemArtist {
896    /// X-coordinates of the data points.
897    pub x: Series,
898    /// Y-coordinates of the data points.
899    pub y: Series,
900    /// Color of the stem lines and markers.
901    pub color: Color,
902    /// Stroke width of the stem lines in pixels.
903    pub line_width: f64,
904    /// Diameter of the marker circle in pixels.
905    pub marker_size: f64,
906    /// The y-value from which stems originate.
907    pub baseline: f64,
908    /// Optional legend label.
909    pub label: Option<String>,
910    /// Opacity from 0.0 (fully transparent) to 1.0 (fully opaque).
911    pub alpha: f64,
912}
913
914impl StemArtist {
915    /// Computes the data-space bounding box `(xmin, xmax, ymin, ymax)`.
916    ///
917    /// The y-bounds include the baseline.
918    pub fn data_bounds(&self) -> (f64, f64, f64, f64) {
919        let (xmin, xmax) = series_bounds_or(&self.x, 0.0, 1.0);
920        let (ymin, ymax) = series_bounds_or(&self.y, 0.0, 1.0);
921        (xmin, xmax, ymin.min(self.baseline), ymax.max(self.baseline))
922    }
923}
924
925// ---------------------------------------------------------------------------
926// Tests
927// ---------------------------------------------------------------------------
928
929#[cfg(test)]
930mod tests {
931    use super::*;
932
933    /// Helper: build a simple `LineArtist` for testing.
934    fn sample_line() -> LineArtist {
935        LineArtist {
936            x: Series::new(vec![1.0, 2.0, 3.0]),
937            y: Series::new(vec![10.0, 20.0, 30.0]),
938            color: Color::TAB_BLUE,
939            width: 1.5,
940            style: LineStyle::Solid,
941            label: Some("line".to_string()),
942            alpha: 1.0,
943        }
944    }
945
946    /// Helper: build a simple `ScatterArtist` for testing.
947    fn sample_scatter() -> ScatterArtist {
948        ScatterArtist {
949            x: Series::new(vec![0.0, 5.0, 10.0]),
950            y: Series::new(vec![-1.0, 0.0, 1.0]),
951            color: Color::TAB_ORANGE,
952            marker: Marker::Circle,
953            size: 6.0,
954            label: None,
955            alpha: 0.8,
956            colors: None,
957            c: None,
958            cmap: None,
959        }
960    }
961
962    /// Helper: build a simple `BarArtist` for testing.
963    fn sample_bar() -> BarArtist {
964        BarArtist {
965            categories: Categories::new(vec!["A".into(), "B".into(), "C".into()]),
966            heights: Series::new(vec![4.0, 7.0, 2.0]),
967            color: Color::TAB_GREEN,
968            label: Some("bars".to_string()),
969            alpha: 1.0,
970            horizontal: false,
971            bar_width: 0.8,
972           bottom: None,
973            offset: None,
974        }
975    }
976
977    /// Helper: build a simple `HistArtist` for testing.
978    fn sample_hist() -> HistArtist {
979        HistArtist {
980            data: Series::new(vec![1.0, 2.0, 2.5, 3.0, 3.5, 4.0]),
981            bins: 3,
982            bin_edges: vec![1.0, 2.0, 3.0, 4.0],
983            counts: vec![1.0, 2.0, 3.0],
984            color: Color::TAB_RED,
985            label: Some("hist".to_string()),
986            alpha: 0.7,
987            density: false,
988        }
989    }
990
991    /// Helper: build a simple `FillBetweenArtist` for testing.
992    fn sample_fill_between() -> FillBetweenArtist {
993        FillBetweenArtist {
994            x: Series::new(vec![0.0, 1.0, 2.0]),
995            y1: Series::new(vec![1.0, 3.0, 2.0]),
996            y2: Series::new(vec![0.0, 1.0, 0.5]),
997            color: Color::TAB_PURPLE,
998            label: Some("fill".to_string()),
999            alpha: 0.3,
1000        }
1001    }
1002
1003    // -- Artist enum dispatch -----------------------------------------------
1004
1005    #[test]
1006    fn artist_label_returns_inner_label() {
1007        let a = Artist::Line(sample_line());
1008        assert_eq!(a.label(), Some("line"));
1009
1010        let a = Artist::Scatter(sample_scatter());
1011        assert_eq!(a.label(), None);
1012
1013        let a = Artist::Bar(sample_bar());
1014        assert_eq!(a.label(), Some("bars"));
1015
1016        let a = Artist::Histogram(sample_hist());
1017        assert_eq!(a.label(), Some("hist"));
1018
1019        let a = Artist::FillBetween(sample_fill_between());
1020        assert_eq!(a.label(), Some("fill"));
1021    }
1022
1023    #[test]
1024    fn artist_color_returns_inner_color() {
1025        assert_eq!(Artist::Line(sample_line()).color(), Color::TAB_BLUE);
1026        assert_eq!(Artist::Scatter(sample_scatter()).color(), Color::TAB_ORANGE);
1027        assert_eq!(Artist::Bar(sample_bar()).color(), Color::TAB_GREEN);
1028        assert_eq!(Artist::Histogram(sample_hist()).color(), Color::TAB_RED);
1029        assert_eq!(
1030            Artist::FillBetween(sample_fill_between()).color(),
1031            Color::TAB_PURPLE
1032        );
1033    }
1034
1035    #[test]
1036    fn artist_data_bounds_dispatches_correctly() {
1037        let a = Artist::Line(sample_line());
1038        assert_eq!(a.data_bounds(), (1.0, 3.0, 10.0, 30.0));
1039    }
1040
1041    // -- LineArtist ---------------------------------------------------------
1042
1043    #[test]
1044    fn line_data_bounds_basic() {
1045        let a = sample_line();
1046        assert_eq!(a.data_bounds(), (1.0, 3.0, 10.0, 30.0));
1047    }
1048
1049    #[test]
1050    fn line_data_bounds_empty_series() {
1051        let a = LineArtist {
1052            x: Series::new(vec![]),
1053            y: Series::new(vec![]),
1054            color: Color::BLACK,
1055            width: 1.0,
1056            style: LineStyle::Solid,
1057            label: None,
1058            alpha: 1.0,
1059        };
1060        assert_eq!(a.data_bounds(), (0.0, 1.0, 0.0, 1.0));
1061    }
1062
1063    #[test]
1064    fn line_data_bounds_with_nan() {
1065        let a = LineArtist {
1066            x: Series::new(vec![f64::NAN, 2.0, 5.0]),
1067            y: Series::new(vec![1.0, f64::NAN, 3.0]),
1068            color: Color::BLACK,
1069            width: 1.0,
1070            style: LineStyle::Solid,
1071            label: None,
1072            alpha: 1.0,
1073        };
1074        assert_eq!(a.data_bounds(), (2.0, 5.0, 1.0, 3.0));
1075    }
1076
1077    // -- ScatterArtist ------------------------------------------------------
1078
1079    #[test]
1080    fn scatter_data_bounds_basic() {
1081        let a = sample_scatter();
1082        assert_eq!(a.data_bounds(), (0.0, 10.0, -1.0, 1.0));
1083    }
1084
1085    #[test]
1086    fn scatter_data_bounds_empty() {
1087        let a = ScatterArtist {
1088            x: Series::new(vec![]),
1089            y: Series::new(vec![]),
1090            color: Color::BLACK,
1091            marker: Marker::Circle,
1092            size: 6.0,
1093            label: None,
1094            alpha: 1.0,
1095            colors: None,
1096            c: None,
1097            cmap: None,
1098        };
1099        assert_eq!(a.data_bounds(), (0.0, 1.0, 0.0, 1.0));
1100    }
1101
1102    // -- BarArtist ----------------------------------------------------------
1103
1104    #[test]
1105    fn bar_data_bounds_vertical() {
1106        let a = sample_bar();
1107        let (xmin, xmax, ymin, ymax) = a.data_bounds();
1108        assert!((xmin - (-0.5)).abs() < f64::EPSILON);
1109        assert!((xmax - 2.5).abs() < f64::EPSILON);
1110        assert!((ymin - 0.0).abs() < f64::EPSILON);
1111        assert!((ymax - 7.0).abs() < f64::EPSILON);
1112    }
1113
1114    #[test]
1115    fn bar_data_bounds_horizontal() {
1116        let mut a = sample_bar();
1117        a.horizontal = true;
1118        let (xmin, xmax, ymin, ymax) = a.data_bounds();
1119        // Horizontal: x = value axis, y = category axis.
1120        assert!((xmin - 0.0).abs() < f64::EPSILON);
1121        assert!((xmax - 7.0).abs() < f64::EPSILON);
1122        assert!((ymin - (-0.5)).abs() < f64::EPSILON);
1123        assert!((ymax - 2.5).abs() < f64::EPSILON);
1124    }
1125
1126    #[test]
1127    fn bar_data_bounds_negative_heights() {
1128        let a = BarArtist {
1129            categories: Categories::new(vec!["A".into(), "B".into()]),
1130            heights: Series::new(vec![-3.0, 5.0]),
1131            color: Color::BLACK,
1132            label: None,
1133            alpha: 1.0,
1134            horizontal: false,
1135            bar_width: 0.8,
1136           bottom: None,
1137            offset: None,
1138        };
1139        let (_, _, ymin, ymax) = a.data_bounds();
1140        assert!((ymin - (-3.0)).abs() < f64::EPSILON);
1141        assert!((ymax - 5.0).abs() < f64::EPSILON);
1142    }
1143
1144    #[test]
1145    fn bar_data_bounds_empty() {
1146        let a = BarArtist {
1147            categories: Categories::new(vec![]),
1148            heights: Series::new(vec![]),
1149            color: Color::BLACK,
1150            label: None,
1151            alpha: 1.0,
1152            horizontal: false,
1153            bar_width: 0.8,
1154           bottom: None,
1155            offset: None,
1156        };
1157        let (xmin, xmax, ymin, ymax) = a.data_bounds();
1158        assert!((xmin - (-0.5)).abs() < f64::EPSILON);
1159        assert!((xmax - 0.5).abs() < f64::EPSILON);
1160        assert!((ymin - 0.0).abs() < f64::EPSILON);
1161        assert!((ymax - 1.0).abs() < f64::EPSILON);
1162    }
1163
1164    // -- BarArtist with bottom (stacking) -----------------------------------
1165
1166    #[test]
1167    fn bar_data_bounds_with_bottom() {
1168        let a = BarArtist {
1169            categories: Categories::new(vec!["A".into(), "B".into(), "C".into()]),
1170            heights: Series::new(vec![3.0, 4.0, 2.0]),
1171            color: Color::BLACK,
1172            label: None,
1173            alpha: 1.0,
1174            horizontal: false,
1175            bar_width: 0.8,
1176            bottom: Some(vec![1.0, 2.0, 3.0]),
1177            offset: None,
1178        };
1179        let (_, _, ymin, ymax) = a.data_bounds();
1180        // bottom[0]=1, top[0]=4; bottom[1]=2, top[1]=6; bottom[2]=3, top[2]=5
1181        // min includes 0.0 (ensured), max = 6.0
1182        assert!((ymin - 0.0).abs() < f64::EPSILON);
1183        assert!((ymax - 6.0).abs() < f64::EPSILON);
1184    }
1185
1186    #[test]
1187    fn bar_data_bounds_with_bottom_negative_base() {
1188        let a = BarArtist {
1189            categories: Categories::new(vec!["A".into(), "B".into()]),
1190            heights: Series::new(vec![5.0, 3.0]),
1191            color: Color::BLACK,
1192            label: None,
1193            alpha: 1.0,
1194            horizontal: false,
1195            bar_width: 0.8,
1196            bottom: Some(vec![-2.0, 1.0]),
1197            offset: None,
1198        };
1199        let (_, _, ymin, ymax) = a.data_bounds();
1200        assert!((ymin - (-2.0)).abs() < f64::EPSILON);
1201        assert!((ymax - 4.0).abs() < f64::EPSILON);
1202    }
1203
1204    #[test]
1205    fn bar_data_bounds_with_bottom_horizontal() {
1206        let a = BarArtist {
1207            categories: Categories::new(vec!["X".into(), "Y".into()]),
1208            heights: Series::new(vec![4.0, 6.0]),
1209            color: Color::BLACK,
1210            label: None,
1211            alpha: 1.0,
1212            horizontal: true,
1213            bar_width: 0.8,
1214            bottom: Some(vec![1.0, 2.0]),
1215            offset: None,
1216        };
1217        let (xmin, xmax, ymin, ymax) = a.data_bounds();
1218        // Horizontal: x = value axis, y = category axis.
1219        assert!((xmin - 0.0).abs() < f64::EPSILON);
1220        assert!((xmax - 8.0).abs() < f64::EPSILON);
1221        assert!((ymin - (-0.5)).abs() < f64::EPSILON);
1222        assert!((ymax - 1.5).abs() < f64::EPSILON);
1223    }
1224
1225    #[test]
1226    fn bar_data_bounds_with_offset() {
1227        let a = BarArtist {
1228            categories: Categories::new(vec!["A".into(), "B".into()]),
1229            heights: Series::new(vec![5.0, 3.0]),
1230            color: Color::BLACK,
1231            label: None,
1232            alpha: 1.0,
1233            horizontal: false,
1234            bar_width: 0.4,
1235            bottom: None,
1236            offset: Some(vec![-0.2, -0.2]),
1237        };
1238        let (xmin, _xmax, _, _) = a.data_bounds();
1239        // center for bar 0 = 0 + (-0.2) = -0.2, left edge = -0.2 - 0.2 = -0.4
1240        assert!(xmin <= -0.4);
1241    }
1242
1243    #[test]
1244    fn bar_data_bounds_bottom_and_offset_combined() {
1245        let a = BarArtist {
1246            categories: Categories::new(vec!["A".into(), "B".into()]),
1247            heights: Series::new(vec![3.0, 4.0]),
1248            color: Color::BLACK,
1249            label: None,
1250            alpha: 1.0,
1251            horizontal: false,
1252            bar_width: 0.4,
1253            bottom: Some(vec![2.0, 1.0]),
1254            offset: Some(vec![0.2, 0.2]),
1255        };
1256        let (_, _, ymin, ymax) = a.data_bounds();
1257        // bottoms: 2,1; tops: 5,5; min(all,0)=0; max=5
1258        assert!((ymin - 0.0).abs() < f64::EPSILON);
1259        assert!((ymax - 5.0).abs() < f64::EPSILON);
1260    }
1261
1262    #[test]
1263    fn bar_data_bounds_single_bar_with_bottom() {
1264        let a = BarArtist {
1265            categories: Categories::new(vec!["Solo".into()]),
1266            heights: Series::new(vec![10.0]),
1267            color: Color::BLACK,
1268            label: None,
1269            alpha: 1.0,
1270            horizontal: false,
1271            bar_width: 0.8,
1272            bottom: Some(vec![5.0]),
1273            offset: None,
1274        };
1275        let (_, _, ymin, ymax) = a.data_bounds();
1276        assert!((ymin - 0.0).abs() < f64::EPSILON);
1277        assert!((ymax - 15.0).abs() < f64::EPSILON);
1278    }
1279
1280    #[test]
1281    fn bar_data_bounds_zero_bottom() {
1282        // Setting bottom to all zeros should behave identically to no bottom.
1283        let a = BarArtist {
1284            categories: Categories::new(vec!["A".into(), "B".into()]),
1285            heights: Series::new(vec![3.0, 5.0]),
1286            color: Color::BLACK,
1287            label: None,
1288            alpha: 1.0,
1289            horizontal: false,
1290            bar_width: 0.8,
1291            bottom: Some(vec![0.0, 0.0]),
1292            offset: None,
1293        };
1294        let (_, _, ymin, ymax) = a.data_bounds();
1295        assert!((ymin - 0.0).abs() < f64::EPSILON);
1296        assert!((ymax - 5.0).abs() < f64::EPSILON);
1297    }
1298
1299    #[test]
1300    fn bar_data_bounds_empty_with_bottom() {
1301        let a = BarArtist {
1302            categories: Categories::new(vec![]),
1303            heights: Series::new(vec![]),
1304            color: Color::BLACK,
1305            label: None,
1306            alpha: 1.0,
1307            horizontal: false,
1308            bar_width: 0.8,
1309            bottom: Some(vec![]),
1310            offset: None,
1311        };
1312        let (xmin, xmax, ymin, ymax) = a.data_bounds();
1313        assert!((xmin - (-0.5)).abs() < f64::EPSILON);
1314        assert!((xmax - 0.5).abs() < f64::EPSILON);
1315        assert!((ymin - 0.0).abs() < f64::EPSILON);
1316        assert!((ymax - 1.0).abs() < f64::EPSILON);
1317    }
1318
1319    #[test]
1320    fn bar_data_bounds_stacked_three_layers() {
1321        // Simulates top layer of a 3-layer stack: bottom=5, height=1 => top=6.
1322        let a = BarArtist {
1323            categories: Categories::new(vec!["A".into()]),
1324            heights: Series::new(vec![1.0]),
1325            color: Color::BLACK,
1326            label: None,
1327            alpha: 1.0,
1328            horizontal: false,
1329            bar_width: 0.8,
1330            bottom: Some(vec![5.0]),
1331            offset: None,
1332        };
1333        let (_, _, ymin, ymax) = a.data_bounds();
1334        assert!((ymin - 0.0).abs() < f64::EPSILON);
1335        assert!((ymax - 6.0).abs() < f64::EPSILON);
1336    }
1337
1338    #[test]
1339    fn bar_builder_bottom_sets_field() {
1340        let mut a = sample_bar();
1341        a.bottom(vec![1.0, 2.0, 3.0]);
1342        assert_eq!(a.bottom.as_ref().unwrap(), &vec![1.0, 2.0, 3.0]);
1343    }
1344
1345    #[test]
1346    fn bar_builder_offset_sets_field() {
1347        let mut a = sample_bar();
1348        a.offset(vec![0.1, 0.2, 0.3]);
1349        assert_eq!(a.offset.as_ref().unwrap(), &vec![0.1, 0.2, 0.3]);
1350    }
1351
1352    // -- HistArtist ---------------------------------------------------------
1353
1354    #[test]
1355    fn hist_data_bounds_basic() {
1356        let a = sample_hist();
1357        let (xmin, xmax, ymin, ymax) = a.data_bounds();
1358        assert!((xmin - 1.0).abs() < f64::EPSILON);
1359        assert!((xmax - 4.0).abs() < f64::EPSILON);
1360        assert!((ymin - 0.0).abs() < f64::EPSILON);
1361        assert!((ymax - 3.0).abs() < f64::EPSILON);
1362    }
1363
1364    #[test]
1365    fn hist_data_bounds_empty_bins() {
1366        let a = HistArtist {
1367            data: Series::new(vec![]),
1368            bins: 0,
1369            bin_edges: vec![],
1370            counts: vec![],
1371            color: Color::BLACK,
1372            label: None,
1373            alpha: 1.0,
1374            density: false,
1375        };
1376        assert_eq!(a.data_bounds(), (0.0, 1.0, 0.0, 1.0));
1377    }
1378
1379    #[test]
1380    fn hist_data_bounds_single_edge_pair() {
1381        let a = HistArtist {
1382            data: Series::new(vec![1.0]),
1383            bins: 1,
1384            bin_edges: vec![0.5, 1.5],
1385            counts: vec![1.0],
1386            color: Color::BLACK,
1387            label: None,
1388            alpha: 1.0,
1389            density: false,
1390        };
1391        let (xmin, xmax, ymin, ymax) = a.data_bounds();
1392        assert!((xmin - 0.5).abs() < f64::EPSILON);
1393        assert!((xmax - 1.5).abs() < f64::EPSILON);
1394        assert!((ymin - 0.0).abs() < f64::EPSILON);
1395        assert!((ymax - 1.0).abs() < f64::EPSILON);
1396    }
1397
1398    #[test]
1399    fn hist_data_bounds_all_zero_counts() {
1400        let a = HistArtist {
1401            data: Series::new(vec![]),
1402            bins: 2,
1403            bin_edges: vec![0.0, 1.0, 2.0],
1404            counts: vec![0.0, 0.0],
1405            color: Color::BLACK,
1406            label: None,
1407            alpha: 1.0,
1408            density: false,
1409        };
1410        let (_, _, _, ymax) = a.data_bounds();
1411        // All-zero counts should produce a fallback ymax of 1.0.
1412        assert!((ymax - 1.0).abs() < f64::EPSILON);
1413    }
1414
1415    // -- FillBetweenArtist --------------------------------------------------
1416
1417    #[test]
1418    fn fill_between_data_bounds_basic() {
1419        let a = sample_fill_between();
1420        let (xmin, xmax, ymin, ymax) = a.data_bounds();
1421        assert!((xmin - 0.0).abs() < f64::EPSILON);
1422        assert!((xmax - 2.0).abs() < f64::EPSILON);
1423        assert!((ymin - 0.0).abs() < f64::EPSILON);
1424        assert!((ymax - 3.0).abs() < f64::EPSILON);
1425    }
1426
1427    #[test]
1428    fn fill_between_data_bounds_empty() {
1429        let a = FillBetweenArtist {
1430            x: Series::new(vec![]),
1431            y1: Series::new(vec![]),
1432            y2: Series::new(vec![]),
1433            color: Color::BLACK,
1434            label: None,
1435            alpha: 1.0,
1436        };
1437        assert_eq!(a.data_bounds(), (0.0, 1.0, 0.0, 1.0));
1438    }
1439
1440    #[test]
1441    fn fill_between_data_bounds_y2_extends_beyond_y1() {
1442        let a = FillBetweenArtist {
1443            x: Series::new(vec![0.0, 1.0]),
1444            y1: Series::new(vec![1.0, 2.0]),
1445            y2: Series::new(vec![-5.0, 10.0]),
1446            color: Color::BLACK,
1447            label: None,
1448            alpha: 1.0,
1449        };
1450        let (_, _, ymin, ymax) = a.data_bounds();
1451        assert!((ymin - (-5.0)).abs() < f64::EPSILON);
1452        assert!((ymax - 10.0).abs() < f64::EPSILON);
1453    }
1454
1455    #[test]
1456    fn fill_between_data_bounds_one_series_empty() {
1457        // y1 has data, y2 is empty -- bounds should come from y1 alone.
1458        let a = FillBetweenArtist {
1459            x: Series::new(vec![0.0, 1.0]),
1460            y1: Series::new(vec![2.0, 8.0]),
1461            y2: Series::new(vec![]),
1462            color: Color::BLACK,
1463            label: None,
1464            alpha: 1.0,
1465        };
1466        let (_, _, ymin, ymax) = a.data_bounds();
1467        assert!((ymin - 2.0).abs() < f64::EPSILON);
1468        assert!((ymax - 8.0).abs() < f64::EPSILON);
1469    }
1470}