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