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