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