Skip to main content

plotkit_core/
artist.rs

1//! Artist types -- data + styling for each visual chart element.
2//!
3//! Artists are the data-carrying objects stored in [`Axes`]. Each artist type
4//! holds the data-space geometry and styling for one visual element. When the
5//! figure is rendered, the renderer iterates over the artist list and draws
6//! each one according to its variant.
7//!
8//! [`Axes`]: crate::axes::Axes
9//!
10//! # Variants
11//!
12//! | Variant          | Description                                     |
13//! |------------------|-------------------------------------------------|
14//! | [`Line`]         | A polyline connecting (x, y) points.             |
15//! | [`Scatter`]      | Individual markers at (x, y) positions.          |
16//! | [`Bar`]          | Vertical or horizontal bars over categories.     |
17//! | [`Histogram`]    | Binned frequency distribution of a single series.|
18//! | [`FillBetween`]  | Shaded region between two y-series.              |
19//!
20//! [`Line`]: Artist::Line
21//! [`Scatter`]: Artist::Scatter
22//! [`Bar`]: Artist::Bar
23//! [`Histogram`]: Artist::Histogram
24//! [`FillBetween`]: Artist::FillBetween
25
26use crate::charts::boxplot::BoxStats;
27use crate::colormap::Colormap;
28use crate::primitives::Color;
29use crate::series::{Categories, Series};
30use crate::theme::{LineStyle, Marker};
31
32// ---------------------------------------------------------------------------
33// Artist enum
34// ---------------------------------------------------------------------------
35
36/// A visual element drawn on an axes.
37///
38/// `Artist` is the primary unit of chart content. Each variant wraps a
39/// concrete artist struct that stores the data, colors, and styling needed
40/// to render one visual element. The enum provides convenience accessors
41/// ([`label`](Artist::label), [`color`](Artist::color),
42/// [`data_bounds`](Artist::data_bounds)) that dispatch to the inner type.
43#[derive(Debug, Clone)]
44pub enum Artist {
45    /// A line chart connecting (x, y) points.
46    Line(LineArtist),
47    /// A scatter plot of individual points.
48    Scatter(ScatterArtist),
49    /// A bar chart (vertical or horizontal).
50    Bar(BarArtist),
51    /// A histogram (binned frequency distribution).
52    Histogram(HistArtist),
53    /// A filled region between two y-series sharing a common x-series.
54    FillBetween(FillBetweenArtist),
55    /// A step (staircase) chart connecting data points.
56    Step(StepArtist),
57    /// A stem (lollipop) chart from data points.
58    Stem(StemArtist),
59    /// A box-and-whisker plot showing distribution summaries.
60    BoxPlot(BoxPlotArtist),
61    /// An error bar plot showing data points with uncertainty bars.
62    ErrorBar(ErrorBarArtist),
63    /// A heatmap showing a 2D grid of values mapped to colors.
64    Heatmap(HeatmapArtist),
65}
66
67
68
69impl Artist {
70    /// Returns the legend label for this artist, if one has been set.
71    ///
72    /// The legend renderer uses this to decide which artists appear in the
73    /// legend. Artists without a label are silently skipped.
74    pub fn label(&self) -> Option<&str> {
75        match self {
76            Artist::Line(a) => a.label.as_deref(),
77            Artist::Scatter(a) => a.label.as_deref(),
78            Artist::Bar(a) => a.label.as_deref(),
79            Artist::Histogram(a) => a.label.as_deref(),
80            Artist::FillBetween(a) => a.label.as_deref(),
81            Artist::Step(a) => a.label.as_deref(),
82            Artist::Stem(a) => a.label.as_deref(),
83            Artist::BoxPlot(a) => a.label.as_deref(),
84            Artist::ErrorBar(a) => a.label.as_deref(),
85            Artist::Heatmap(a) => a.label.as_deref(),
86        }
87    }
88
89    /// Returns the primary color of this artist.
90    ///
91    /// Used by the legend to draw a color swatch next to the label, and by
92    /// any other component that needs to identify an artist's color (e.g.
93    /// tooltip rendering).
94    pub fn color(&self) -> Color {
95        match self {
96            Artist::Line(a) => a.color,
97            Artist::Scatter(a) => a.color,
98            Artist::Bar(a) => a.color,
99            Artist::Histogram(a) => a.color,
100            Artist::FillBetween(a) => a.color,
101            Artist::Step(a) => a.color,
102            Artist::Stem(a) => a.color,
103            Artist::BoxPlot(a) => a.color,
104            Artist::ErrorBar(a) => a.color,
105            Artist::Heatmap(a) => a.color,
106        }
107    }
108
109    /// Returns the data-space bounding box as `(xmin, xmax, ymin, ymax)`.
110    ///
111    /// The axes autoscaling logic calls this on every artist to compute the
112    /// tightest axis limits that contain all visible data. If a series is
113    /// empty or contains no finite values, the corresponding min/max pair
114    /// falls back to `(0.0, 1.0)` so that the axes always have a non-zero
115    /// extent.
116    pub fn data_bounds(&self) -> (f64, f64, f64, f64) {
117        match self {
118            Artist::Line(a) => a.data_bounds(),
119            Artist::Scatter(a) => a.data_bounds(),
120            Artist::Bar(a) => a.data_bounds(),
121            Artist::Histogram(a) => a.data_bounds(),
122            Artist::FillBetween(a) => a.data_bounds(),
123            Artist::Step(a) => a.data_bounds(),
124            Artist::Stem(a) => a.data_bounds(),
125            Artist::BoxPlot(a) => a.data_bounds(),
126            Artist::ErrorBar(a) => a.data_bounds(),
127            Artist::Heatmap(a) => a.data_bounds(),
128        }
129    }
130}
131
132// ---------------------------------------------------------------------------
133// Helper: safe bounds with fallback
134// ---------------------------------------------------------------------------
135
136/// Returns `(min, max)` of the finite values in `series`, falling back to
137/// `(fallback_min, fallback_max)` when the series is empty or entirely
138/// non-finite.
139fn series_bounds_or(series: &Series, fallback_min: f64, fallback_max: f64) -> (f64, f64) {
140    match series.bounds() {
141        Some((lo, hi)) => (lo, hi),
142        None => (fallback_min, fallback_max),
143    }
144}
145
146// ---------------------------------------------------------------------------
147// LineArtist
148// ---------------------------------------------------------------------------
149
150/// A line chart connecting a sequence of (x, y) data points.
151///
152/// The `x` and `y` series must have the same length. Points are drawn in
153/// order, producing a single connected polyline with the configured stroke
154/// style.
155#[derive(Debug, Clone)]
156pub struct LineArtist {
157    /// X-coordinates of the data points.
158    pub x: Series,
159    /// Y-coordinates of the data points.
160    pub y: Series,
161    /// Stroke color of the line.
162    pub color: Color,
163    /// Stroke width in pixels.
164    pub width: f64,
165    /// Stroke pattern (solid, dashed, dotted, dash-dot).
166    pub style: LineStyle,
167    /// Optional legend label. When `Some`, the line appears in the legend.
168    pub label: Option<String>,
169    /// Opacity from 0.0 (fully transparent) to 1.0 (fully opaque).
170    pub alpha: f64,
171}
172
173impl LineArtist {
174    /// Computes the data-space bounding box `(xmin, xmax, ymin, ymax)`.
175    ///
176    /// Falls back to `(0.0, 1.0)` on each axis when the corresponding
177    /// series contains no finite values.
178    pub fn data_bounds(&self) -> (f64, f64, f64, f64) {
179        let (xmin, xmax) = series_bounds_or(&self.x, 0.0, 1.0);
180        let (ymin, ymax) = series_bounds_or(&self.y, 0.0, 1.0);
181        (xmin, xmax, ymin, ymax)
182    }
183}
184
185// ---------------------------------------------------------------------------
186// ScatterArtist
187// ---------------------------------------------------------------------------
188
189/// A scatter plot rendering individual markers at (x, y) positions.
190///
191/// Each data point is drawn as a marker whose shape, size, and color can be
192/// configured. An optional per-point `colors` vector overrides the uniform
193/// `color` field, enabling colormap-based visualizations.
194#[derive(Debug, Clone)]
195pub struct ScatterArtist {
196    /// X-coordinates of the data points.
197    pub x: Series,
198    /// Y-coordinates of the data points.
199    pub y: Series,
200    /// Default marker color (used when `colors` is `None`).
201    pub color: Color,
202    /// Marker shape.
203    pub marker: Marker,
204    /// Marker diameter in pixels.
205    pub size: f64,
206    /// Optional legend label. When `Some`, the scatter 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    /// Optional per-point colors for colormap-driven scatter plots.
211    ///
212    /// When set, `colors.len()` must equal `x.len()` (and `y.len()`). Each
213    /// entry overrides `color` for the corresponding data point.
214    pub colors: Option<Vec<Color>>,
215    /// Optional per-point scalar values for colormap-driven coloring.
216    ///
217    /// When set together with `cmap`, each value is mapped through the
218    /// colormap to produce per-point colors. Takes precedence over `colors`.
219    pub c: Option<Vec<f64>>,
220    /// Optional colormap used to map `c` values to colors.
221    pub cmap: Option<Colormap>,
222}
223
224impl ScatterArtist {
225    /// Computes the data-space bounding box `(xmin, xmax, ymin, ymax)`.
226    ///
227    /// Falls back to `(0.0, 1.0)` on each axis when the corresponding
228    /// series contains no finite values.
229    pub fn data_bounds(&self) -> (f64, f64, f64, f64) {
230        let (xmin, xmax) = series_bounds_or(&self.x, 0.0, 1.0);
231        let (ymin, ymax) = series_bounds_or(&self.y, 0.0, 1.0);
232        (xmin, xmax, ymin, ymax)
233    }
234}
235
236// ---------------------------------------------------------------------------
237// BarArtist
238// ---------------------------------------------------------------------------
239
240/// A bar chart rendering vertical or horizontal bars over categorical data.
241///
242/// Categories are placed at integer positions `0, 1, 2, ...` on the
243/// category axis, with each bar centered on its position. The `bar_width`
244/// field controls the fraction of the inter-category spacing that the bar
245/// occupies (1.0 = bars touching, 0.5 = half-width with gaps).
246#[derive(Debug, Clone)]
247pub struct BarArtist {
248    /// Category labels for the bar axis.
249    pub categories: Categories,
250    /// Bar heights (or lengths, for horizontal bars).
251    pub heights: Series,
252    /// Fill color of the bars.
253    pub color: Color,
254    /// Optional legend label. When `Some`, the bar series 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    /// When `true`, bars extend horizontally (categories on the y-axis).
259    pub horizontal: bool,
260    /// Bar width as a fraction of the category spacing (0.0, 1.0].
261    pub bar_width: f64,
262}
263
264impl BarArtist {
265    /// Computes the data-space bounding box `(xmin, xmax, ymin, ymax)`.
266    ///
267    /// For vertical bars, the x-axis spans from `-0.5` to `n - 0.5` (where
268    /// `n` is the number of categories) so that bars are centered on integer
269    /// positions. The y-axis spans from `0.0` to the tallest bar, with a
270    /// fallback of `(0.0, 1.0)` when the heights series is empty.
271    ///
272    /// For horizontal bars the axes are transposed: the y-axis holds the
273    /// category positions and the x-axis holds the bar lengths.
274    pub fn data_bounds(&self) -> (f64, f64, f64, f64) {
275        let n = self.categories.len() as f64;
276
277        // Determine the extent along the value axis (heights / lengths).
278        let height_min = self.heights.min().unwrap_or(0.0).min(0.0);
279        let height_max = self.heights.max().unwrap_or(1.0);
280
281        // Category axis runs from -0.5 to n-0.5 so bars are centered on 0..n-1.
282        let cat_min = -0.5;
283        let cat_max = if n > 0.0 { n - 0.5 } else { 0.5 };
284
285        if self.horizontal {
286            // Horizontal bars: x = value axis, y = category axis.
287            (height_min, height_max, cat_min, cat_max)
288        } else {
289            // Vertical bars: x = category axis, y = value axis.
290            (cat_min, cat_max, height_min, height_max)
291        }
292    }
293}
294
295// ---------------------------------------------------------------------------
296// HistArtist
297// ---------------------------------------------------------------------------
298
299/// A histogram showing the frequency distribution of a single data series.
300///
301/// The raw data is retained in `data`, but the binning results (`bin_edges`
302/// and `counts`) are expected to be pre-computed when the artist is created
303/// (typically by the histogram chart builder). This avoids re-binning during
304/// every render pass.
305///
306/// When `density` is `true`, the `counts` vector stores probability density
307/// values (each count divided by `n * bin_width`) rather than raw counts, so
308/// that the total area under the histogram integrates to 1.0.
309#[derive(Debug, Clone)]
310pub struct HistArtist {
311    /// The original (un-binned) data values.
312    pub data: Series,
313    /// The requested number of bins (used for display/debugging; the actual
314    /// bin count is `bin_edges.len() - 1`).
315    pub bins: usize,
316    /// Sorted bin edges of length `bins + 1`. The i-th bin spans
317    /// `[bin_edges[i], bin_edges[i+1])`.
318    pub bin_edges: Vec<f64>,
319    /// The count (or density) for each bin. Length equals `bin_edges.len() - 1`.
320    pub counts: Vec<f64>,
321    /// Fill color of the histogram bars.
322    pub color: Color,
323    /// Optional legend label. When `Some`, the histogram appears in the legend.
324    pub label: Option<String>,
325    /// Opacity from 0.0 (fully transparent) to 1.0 (fully opaque).
326    pub alpha: f64,
327    /// When `true`, `counts` stores probability density instead of raw counts.
328    pub density: bool,
329}
330
331impl HistArtist {
332    /// Computes the data-space bounding box `(xmin, xmax, ymin, ymax)`.
333    ///
334    /// The x-axis spans from the first bin edge to the last bin edge. The
335    /// y-axis spans from `0.0` to the tallest bin count (or density value).
336    /// Returns `(0.0, 1.0, 0.0, 1.0)` when there are no bin edges.
337    pub fn data_bounds(&self) -> (f64, f64, f64, f64) {
338        if self.bin_edges.len() < 2 {
339            return (0.0, 1.0, 0.0, 1.0);
340        }
341
342        // x-axis: first edge to last edge.
343        let xmin = self.bin_edges[0];
344        let xmax = self.bin_edges[self.bin_edges.len() - 1];
345
346        // y-axis: 0 to tallest bin.
347        let ymax = self
348            .counts
349            .iter()
350            .copied()
351            .filter(|v| v.is_finite())
352            .fold(0.0_f64, f64::max);
353
354        // Guarantee a non-zero y extent so the axes are always drawable.
355        let ymax = if ymax <= 0.0 { 1.0 } else { ymax };
356
357        (xmin, xmax, 0.0, ymax)
358    }
359}
360
361// ---------------------------------------------------------------------------
362// FillBetweenArtist
363// ---------------------------------------------------------------------------
364
365/// A filled region between two y-series that share a common x-series.
366///
367/// The renderer draws a closed polygon connecting `(x, y1)` forward and
368/// `(x, y2)` backward, then fills it with the configured color and opacity.
369/// This is commonly used for confidence bands, area charts, and shaded
370/// difference regions.
371#[derive(Debug, Clone)]
372pub struct FillBetweenArtist {
373    /// X-coordinates shared by both y-series.
374    pub x: Series,
375    /// Y-coordinates of the first boundary curve.
376    pub y1: Series,
377    /// Y-coordinates of the second boundary curve.
378    pub y2: Series,
379    /// Fill color of the shaded region.
380    pub color: Color,
381    /// Optional legend label. When `Some`, the fill region appears in the legend.
382    pub label: Option<String>,
383    /// Opacity from 0.0 (fully transparent) to 1.0 (fully opaque).
384    pub alpha: f64,
385}
386
387impl FillBetweenArtist {
388    /// Computes the data-space bounding box `(xmin, xmax, ymin, ymax)`.
389    ///
390    /// The x-bounds come from the shared `x` series. The y-bounds are the
391    /// union of `y1` and `y2` (i.e. the overall min and max across both
392    /// boundary curves). Falls back to `(0.0, 1.0)` on any axis that has
393    /// no finite values.
394    pub fn data_bounds(&self) -> (f64, f64, f64, f64) {
395        let (xmin, xmax) = series_bounds_or(&self.x, 0.0, 1.0);
396
397        // Union the y-bounds of both boundary series.
398        let y1_min = self.y1.min();
399        let y2_min = self.y2.min();
400        let y1_max = self.y1.max();
401        let y2_max = self.y2.max();
402
403        let ymin = match (y1_min, y2_min) {
404            (Some(a), Some(b)) => a.min(b),
405            (Some(a), None) => a,
406            (None, Some(b)) => b,
407            (None, None) => 0.0,
408        };
409
410        let ymax = match (y1_max, y2_max) {
411            (Some(a), Some(b)) => a.max(b),
412            (Some(a), None) => a,
413            (None, Some(b)) => b,
414            (None, None) => 1.0,
415        };
416
417        (xmin, xmax, ymin, ymax)
418    }
419}
420
421// ---------------------------------------------------------------------------
422// BoxPlotArtist
423// ---------------------------------------------------------------------------
424
425/// A box-and-whisker plot showing distribution summaries for one or more
426/// groups of data.
427///
428/// Each group produces a box spanning Q1 to Q3 with a median line, whiskers
429/// extending to the most extreme data points within the configured fence, and
430/// optional outlier dots beyond the whiskers.
431#[derive(Debug, Clone)]
432pub struct BoxPlotArtist {
433    /// Pre-computed summary statistics for each group.
434    pub stats: Vec<BoxStats>,
435    /// Category labels for the x-axis (one per group).
436    pub labels: Vec<String>,
437    /// Fill color of the boxes.
438    pub color: Color,
439    /// Optional legend label.
440    pub label: Option<String>,
441    /// Opacity from 0.0 (fully transparent) to 1.0 (fully opaque).
442    pub alpha: f64,
443    /// Box width as a fraction of the category spacing.
444    pub box_width: f64,
445    /// Whether to draw outlier dots.
446    pub show_outliers: bool,
447    /// Whisker extent as a multiple of IQR.
448    pub whisker_iq_factor: f64,
449    /// Raw data retained for re-computing stats when parameters change.
450    pub raw_data: Vec<Vec<f64>>,
451}
452
453impl BoxPlotArtist {
454    /// Computes the data-space bounding box `(xmin, xmax, ymin, ymax)`.
455    ///
456    /// The x-axis spans from `-0.5` to `n - 0.5` (where `n` is the number
457    /// of groups), centering each box on an integer position. The y-axis
458    /// spans from the lowest whisker (or outlier) to the highest.
459    pub fn data_bounds(&self) -> (f64, f64, f64, f64) {
460        let n = self.stats.len();
461        if n == 0 {
462            return (0.0, 1.0, 0.0, 1.0);
463        }
464        let xmin = -0.5;
465        let xmax = n as f64 - 0.5;
466        let mut ymin = f64::INFINITY;
467        let mut ymax = f64::NEG_INFINITY;
468        for s in &self.stats {
469            ymin = ymin.min(s.whisker_low);
470            ymax = ymax.max(s.whisker_high);
471            for &o in &s.outliers {
472                ymin = ymin.min(o);
473                ymax = ymax.max(o);
474            }
475        }
476        if !ymin.is_finite() {
477            ymin = 0.0;
478        }
479        if !ymax.is_finite() {
480            ymax = 1.0;
481        }
482        (xmin, xmax, ymin, ymax)
483    }
484}
485
486
487// ---------------------------------------------------------------------------
488// ErrorBarData
489// ---------------------------------------------------------------------------
490
491/// The error data for one axis of an error bar plot.
492///
493/// Symmetric errors apply the same magnitude on both sides of the data point.
494/// Asymmetric errors allow separate low and high magnitudes.
495#[derive(Debug, Clone)]
496pub enum ErrorBarData {
497    /// Equal error on both sides: `y - e` to `y + e`.
498    Symmetric(Vec<f64>),
499    /// Separate low and high errors: `y - low[i]` to `y + high[i]`.
500    Asymmetric {
501        /// Error magnitudes below each data point.
502        low: Vec<f64>,
503        /// Error magnitudes above each data point.
504        high: Vec<f64>,
505    },
506}
507
508// ---------------------------------------------------------------------------
509// ErrorBarArtist
510// ---------------------------------------------------------------------------
511
512/// An error bar plot showing data points with uncertainty bars.
513///
514/// Each data point `(x, y)` can have optional horizontal (`xerr`) and/or
515/// vertical (`yerr`) error bars. The error bars are drawn as lines with
516/// optional caps at the ends.
517#[derive(Debug, Clone)]
518pub struct ErrorBarArtist {
519    /// X-coordinates of the data points.
520    pub x: Series,
521    /// Y-coordinates of the data points.
522    pub y: Series,
523    /// Optional x-axis error data.
524    pub xerr: Option<ErrorBarData>,
525    /// Optional y-axis error data.
526    pub yerr: Option<ErrorBarData>,
527    /// Color for the center line, error bars, and caps.
528    pub color: Color,
529    /// Optional legend label.
530    pub label: Option<String>,
531    /// Cap size in pixels for the error bar ends.
532    pub cap_size: f64,
533    /// Stroke width of the error bar lines and caps.
534    pub line_width: f64,
535}
536
537impl ErrorBarArtist {
538    /// Computes the data-space bounding box `(xmin, xmax, ymin, ymax)`.
539    ///
540    /// Includes the extent of error bars when present, so that auto-scaling
541    /// shows the full error range.
542    pub fn data_bounds(&self) -> (f64, f64, f64, f64) {
543        let (mut xmin, mut xmax) = series_bounds_or(&self.x, 0.0, 1.0);
544        let (mut ymin, mut ymax) = series_bounds_or(&self.y, 0.0, 1.0);
545
546        // Expand x-bounds by xerr.
547        if let Some(ref xerr) = self.xerr {
548            for i in 0..self.x.len() {
549                let xv = self.x.data[i];
550                let (lo, hi) = match xerr {
551                    ErrorBarData::Symmetric(e) => (xv - e[i], xv + e[i]),
552                    ErrorBarData::Asymmetric { low, high } => (xv - low[i], xv + high[i]),
553                };
554                xmin = xmin.min(lo);
555                xmax = xmax.max(hi);
556            }
557        }
558
559        // Expand y-bounds by yerr.
560        if let Some(ref yerr) = self.yerr {
561            for i in 0..self.y.len() {
562                let yv = self.y.data[i];
563                let (lo, hi) = match yerr {
564                    ErrorBarData::Symmetric(e) => (yv - e[i], yv + e[i]),
565                    ErrorBarData::Asymmetric { low, high } => (yv - low[i], yv + high[i]),
566                };
567                ymin = ymin.min(lo);
568                ymax = ymax.max(hi);
569            }
570        }
571
572        (xmin, xmax, ymin, ymax)
573    }
574}
575
576// ---------------------------------------------------------------------------
577// HeatmapArtist
578// ---------------------------------------------------------------------------
579
580/// A heatmap showing a 2D grid of values mapped to colors via a colormap.
581///
582/// Each cell in the grid is filled with a color determined by mapping its
583/// value through the configured [`Colormap`]. Optional text annotations
584/// can display the numeric value inside each cell.
585#[derive(Debug, Clone)]
586pub struct HeatmapArtist {
587    /// Row-major grid of values. `data[row][col]`.
588    pub data: Vec<Vec<f64>>,
589    /// Optional column labels for the x-axis.
590    pub x_labels: Option<Vec<String>>,
591    /// Optional row labels for the y-axis.
592    pub y_labels: Option<Vec<String>>,
593    /// Colormap used to map cell values to colors.
594    pub cmap: Colormap,
595    /// Minimum value for colormap normalisation. `None` means auto.
596    pub vmin: Option<f64>,
597    /// Maximum value for colormap normalisation. `None` means auto.
598    pub vmax: Option<f64>,
599    /// Whether to draw cell values as text.
600    pub show_values: bool,
601    /// Primary color (used for legend swatch).
602    pub color: Color,
603    /// Optional legend label.
604    pub label: Option<String>,
605}
606
607impl HeatmapArtist {
608    /// Computes the data-space bounding box `(xmin, xmax, ymin, ymax)`.
609    ///
610    /// The grid spans from `(0, 0)` to `(ncols, nrows)`. Returns
611    /// `(0.0, 1.0, 0.0, 1.0)` when the data is empty.
612    pub fn data_bounds(&self) -> (f64, f64, f64, f64) {
613        let nrows = self.data.len();
614        if nrows == 0 {
615            return (0.0, 1.0, 0.0, 1.0);
616        }
617        let ncols = self.data[0].len();
618        if ncols == 0 {
619            return (0.0, 1.0, 0.0, 1.0);
620        }
621        (0.0, ncols as f64, 0.0, nrows as f64)
622    }
623}
624
625// ---------------------------------------------------------------------------
626// StepWhere
627// ---------------------------------------------------------------------------
628
629/// Controls where the horizontal segment of a step chart is placed.
630#[derive(Debug, Clone, Copy, PartialEq, Eq)]
631pub enum StepWhere {
632    /// The y-value changes *before* the x-value (vertical then horizontal).
633    Pre,
634    /// The y-value changes *after* the x-value (horizontal then vertical).
635    Post,
636    /// The y-value changes at the midpoint between consecutive x-values.
637    Mid,
638}
639
640// ---------------------------------------------------------------------------
641// StepArtist
642// ---------------------------------------------------------------------------
643
644/// A step (staircase) chart.
645#[derive(Debug, Clone)]
646pub struct StepArtist {
647    /// X-coordinates of the data points.
648    pub x: Series,
649    /// Y-coordinates of the data points.
650    pub y: Series,
651    /// Stroke color of the step line.
652    pub color: Color,
653    /// Stroke width in pixels.
654    pub width: f64,
655    /// Step alignment mode.
656    pub where_step: StepWhere,
657    /// Optional legend label.
658    pub label: Option<String>,
659    /// Opacity from 0.0 (fully transparent) to 1.0 (fully opaque).
660    pub alpha: f64,
661}
662
663impl StepArtist {
664    /// Computes the data-space bounding box `(xmin, xmax, ymin, ymax)`.
665    pub fn data_bounds(&self) -> (f64, f64, f64, f64) {
666        let (xmin, xmax) = series_bounds_or(&self.x, 0.0, 1.0);
667        let (ymin, ymax) = series_bounds_or(&self.y, 0.0, 1.0);
668        (xmin, xmax, ymin, ymax)
669    }
670}
671
672// ---------------------------------------------------------------------------
673// StemArtist
674// ---------------------------------------------------------------------------
675
676/// A stem (lollipop) chart.
677#[derive(Debug, Clone)]
678pub struct StemArtist {
679    /// X-coordinates of the data points.
680    pub x: Series,
681    /// Y-coordinates of the data points.
682    pub y: Series,
683    /// Color of the stem lines and markers.
684    pub color: Color,
685    /// Stroke width of the stem lines in pixels.
686    pub line_width: f64,
687    /// Diameter of the marker circle in pixels.
688    pub marker_size: f64,
689    /// The y-value from which stems originate.
690    pub baseline: f64,
691    /// Optional legend label.
692    pub label: Option<String>,
693    /// Opacity from 0.0 (fully transparent) to 1.0 (fully opaque).
694    pub alpha: f64,
695}
696
697impl StemArtist {
698    /// Computes the data-space bounding box `(xmin, xmax, ymin, ymax)`.
699    ///
700    /// The y-bounds include the baseline.
701    pub fn data_bounds(&self) -> (f64, f64, f64, f64) {
702        let (xmin, xmax) = series_bounds_or(&self.x, 0.0, 1.0);
703        let (ymin, ymax) = series_bounds_or(&self.y, 0.0, 1.0);
704        (xmin, xmax, ymin.min(self.baseline), ymax.max(self.baseline))
705    }
706}
707
708// ---------------------------------------------------------------------------
709// Tests
710// ---------------------------------------------------------------------------
711
712#[cfg(test)]
713mod tests {
714    use super::*;
715
716    /// Helper: build a simple `LineArtist` for testing.
717    fn sample_line() -> LineArtist {
718        LineArtist {
719            x: Series::new(vec![1.0, 2.0, 3.0]),
720            y: Series::new(vec![10.0, 20.0, 30.0]),
721            color: Color::TAB_BLUE,
722            width: 1.5,
723            style: LineStyle::Solid,
724            label: Some("line".to_string()),
725            alpha: 1.0,
726        }
727    }
728
729    /// Helper: build a simple `ScatterArtist` for testing.
730    fn sample_scatter() -> ScatterArtist {
731        ScatterArtist {
732            x: Series::new(vec![0.0, 5.0, 10.0]),
733            y: Series::new(vec![-1.0, 0.0, 1.0]),
734            color: Color::TAB_ORANGE,
735            marker: Marker::Circle,
736            size: 6.0,
737            label: None,
738            alpha: 0.8,
739            colors: None,
740            c: None,
741            cmap: None,
742        }
743    }
744
745    /// Helper: build a simple `BarArtist` for testing.
746    fn sample_bar() -> BarArtist {
747        BarArtist {
748            categories: Categories::new(vec!["A".into(), "B".into(), "C".into()]),
749            heights: Series::new(vec![4.0, 7.0, 2.0]),
750            color: Color::TAB_GREEN,
751            label: Some("bars".to_string()),
752            alpha: 1.0,
753            horizontal: false,
754            bar_width: 0.8,
755        }
756    }
757
758    /// Helper: build a simple `HistArtist` for testing.
759    fn sample_hist() -> HistArtist {
760        HistArtist {
761            data: Series::new(vec![1.0, 2.0, 2.5, 3.0, 3.5, 4.0]),
762            bins: 3,
763            bin_edges: vec![1.0, 2.0, 3.0, 4.0],
764            counts: vec![1.0, 2.0, 3.0],
765            color: Color::TAB_RED,
766            label: Some("hist".to_string()),
767            alpha: 0.7,
768            density: false,
769        }
770    }
771
772    /// Helper: build a simple `FillBetweenArtist` for testing.
773    fn sample_fill_between() -> FillBetweenArtist {
774        FillBetweenArtist {
775            x: Series::new(vec![0.0, 1.0, 2.0]),
776            y1: Series::new(vec![1.0, 3.0, 2.0]),
777            y2: Series::new(vec![0.0, 1.0, 0.5]),
778            color: Color::TAB_PURPLE,
779            label: Some("fill".to_string()),
780            alpha: 0.3,
781        }
782    }
783
784    // -- Artist enum dispatch -----------------------------------------------
785
786    #[test]
787    fn artist_label_returns_inner_label() {
788        let a = Artist::Line(sample_line());
789        assert_eq!(a.label(), Some("line"));
790
791        let a = Artist::Scatter(sample_scatter());
792        assert_eq!(a.label(), None);
793
794        let a = Artist::Bar(sample_bar());
795        assert_eq!(a.label(), Some("bars"));
796
797        let a = Artist::Histogram(sample_hist());
798        assert_eq!(a.label(), Some("hist"));
799
800        let a = Artist::FillBetween(sample_fill_between());
801        assert_eq!(a.label(), Some("fill"));
802    }
803
804    #[test]
805    fn artist_color_returns_inner_color() {
806        assert_eq!(Artist::Line(sample_line()).color(), Color::TAB_BLUE);
807        assert_eq!(Artist::Scatter(sample_scatter()).color(), Color::TAB_ORANGE);
808        assert_eq!(Artist::Bar(sample_bar()).color(), Color::TAB_GREEN);
809        assert_eq!(Artist::Histogram(sample_hist()).color(), Color::TAB_RED);
810        assert_eq!(
811            Artist::FillBetween(sample_fill_between()).color(),
812            Color::TAB_PURPLE
813        );
814    }
815
816    #[test]
817    fn artist_data_bounds_dispatches_correctly() {
818        let a = Artist::Line(sample_line());
819        assert_eq!(a.data_bounds(), (1.0, 3.0, 10.0, 30.0));
820    }
821
822    // -- LineArtist ---------------------------------------------------------
823
824    #[test]
825    fn line_data_bounds_basic() {
826        let a = sample_line();
827        assert_eq!(a.data_bounds(), (1.0, 3.0, 10.0, 30.0));
828    }
829
830    #[test]
831    fn line_data_bounds_empty_series() {
832        let a = LineArtist {
833            x: Series::new(vec![]),
834            y: Series::new(vec![]),
835            color: Color::BLACK,
836            width: 1.0,
837            style: LineStyle::Solid,
838            label: None,
839            alpha: 1.0,
840        };
841        assert_eq!(a.data_bounds(), (0.0, 1.0, 0.0, 1.0));
842    }
843
844    #[test]
845    fn line_data_bounds_with_nan() {
846        let a = LineArtist {
847            x: Series::new(vec![f64::NAN, 2.0, 5.0]),
848            y: Series::new(vec![1.0, f64::NAN, 3.0]),
849            color: Color::BLACK,
850            width: 1.0,
851            style: LineStyle::Solid,
852            label: None,
853            alpha: 1.0,
854        };
855        assert_eq!(a.data_bounds(), (2.0, 5.0, 1.0, 3.0));
856    }
857
858    // -- ScatterArtist ------------------------------------------------------
859
860    #[test]
861    fn scatter_data_bounds_basic() {
862        let a = sample_scatter();
863        assert_eq!(a.data_bounds(), (0.0, 10.0, -1.0, 1.0));
864    }
865
866    #[test]
867    fn scatter_data_bounds_empty() {
868        let a = ScatterArtist {
869            x: Series::new(vec![]),
870            y: Series::new(vec![]),
871            color: Color::BLACK,
872            marker: Marker::Circle,
873            size: 6.0,
874            label: None,
875            alpha: 1.0,
876            colors: None,
877            c: None,
878            cmap: None,
879        };
880        assert_eq!(a.data_bounds(), (0.0, 1.0, 0.0, 1.0));
881    }
882
883    // -- BarArtist ----------------------------------------------------------
884
885    #[test]
886    fn bar_data_bounds_vertical() {
887        let a = sample_bar();
888        let (xmin, xmax, ymin, ymax) = a.data_bounds();
889        assert!((xmin - (-0.5)).abs() < f64::EPSILON);
890        assert!((xmax - 2.5).abs() < f64::EPSILON);
891        assert!((ymin - 0.0).abs() < f64::EPSILON);
892        assert!((ymax - 7.0).abs() < f64::EPSILON);
893    }
894
895    #[test]
896    fn bar_data_bounds_horizontal() {
897        let mut a = sample_bar();
898        a.horizontal = true;
899        let (xmin, xmax, ymin, ymax) = a.data_bounds();
900        // Horizontal: x = value axis, y = category axis.
901        assert!((xmin - 0.0).abs() < f64::EPSILON);
902        assert!((xmax - 7.0).abs() < f64::EPSILON);
903        assert!((ymin - (-0.5)).abs() < f64::EPSILON);
904        assert!((ymax - 2.5).abs() < f64::EPSILON);
905    }
906
907    #[test]
908    fn bar_data_bounds_negative_heights() {
909        let a = BarArtist {
910            categories: Categories::new(vec!["A".into(), "B".into()]),
911            heights: Series::new(vec![-3.0, 5.0]),
912            color: Color::BLACK,
913            label: None,
914            alpha: 1.0,
915            horizontal: false,
916            bar_width: 0.8,
917        };
918        let (_, _, ymin, ymax) = a.data_bounds();
919        assert!((ymin - (-3.0)).abs() < f64::EPSILON);
920        assert!((ymax - 5.0).abs() < f64::EPSILON);
921    }
922
923    #[test]
924    fn bar_data_bounds_empty() {
925        let a = BarArtist {
926            categories: Categories::new(vec![]),
927            heights: Series::new(vec![]),
928            color: Color::BLACK,
929            label: None,
930            alpha: 1.0,
931            horizontal: false,
932            bar_width: 0.8,
933        };
934        let (xmin, xmax, ymin, ymax) = a.data_bounds();
935        assert!((xmin - (-0.5)).abs() < f64::EPSILON);
936        assert!((xmax - 0.5).abs() < f64::EPSILON);
937        assert!((ymin - 0.0).abs() < f64::EPSILON);
938        assert!((ymax - 1.0).abs() < f64::EPSILON);
939    }
940
941    // -- HistArtist ---------------------------------------------------------
942
943    #[test]
944    fn hist_data_bounds_basic() {
945        let a = sample_hist();
946        let (xmin, xmax, ymin, ymax) = a.data_bounds();
947        assert!((xmin - 1.0).abs() < f64::EPSILON);
948        assert!((xmax - 4.0).abs() < f64::EPSILON);
949        assert!((ymin - 0.0).abs() < f64::EPSILON);
950        assert!((ymax - 3.0).abs() < f64::EPSILON);
951    }
952
953    #[test]
954    fn hist_data_bounds_empty_bins() {
955        let a = HistArtist {
956            data: Series::new(vec![]),
957            bins: 0,
958            bin_edges: vec![],
959            counts: vec![],
960            color: Color::BLACK,
961            label: None,
962            alpha: 1.0,
963            density: false,
964        };
965        assert_eq!(a.data_bounds(), (0.0, 1.0, 0.0, 1.0));
966    }
967
968    #[test]
969    fn hist_data_bounds_single_edge_pair() {
970        let a = HistArtist {
971            data: Series::new(vec![1.0]),
972            bins: 1,
973            bin_edges: vec![0.5, 1.5],
974            counts: vec![1.0],
975            color: Color::BLACK,
976            label: None,
977            alpha: 1.0,
978            density: false,
979        };
980        let (xmin, xmax, ymin, ymax) = a.data_bounds();
981        assert!((xmin - 0.5).abs() < f64::EPSILON);
982        assert!((xmax - 1.5).abs() < f64::EPSILON);
983        assert!((ymin - 0.0).abs() < f64::EPSILON);
984        assert!((ymax - 1.0).abs() < f64::EPSILON);
985    }
986
987    #[test]
988    fn hist_data_bounds_all_zero_counts() {
989        let a = HistArtist {
990            data: Series::new(vec![]),
991            bins: 2,
992            bin_edges: vec![0.0, 1.0, 2.0],
993            counts: vec![0.0, 0.0],
994            color: Color::BLACK,
995            label: None,
996            alpha: 1.0,
997            density: false,
998        };
999        let (_, _, _, ymax) = a.data_bounds();
1000        // All-zero counts should produce a fallback ymax of 1.0.
1001        assert!((ymax - 1.0).abs() < f64::EPSILON);
1002    }
1003
1004    // -- FillBetweenArtist --------------------------------------------------
1005
1006    #[test]
1007    fn fill_between_data_bounds_basic() {
1008        let a = sample_fill_between();
1009        let (xmin, xmax, ymin, ymax) = a.data_bounds();
1010        assert!((xmin - 0.0).abs() < f64::EPSILON);
1011        assert!((xmax - 2.0).abs() < f64::EPSILON);
1012        assert!((ymin - 0.0).abs() < f64::EPSILON);
1013        assert!((ymax - 3.0).abs() < f64::EPSILON);
1014    }
1015
1016    #[test]
1017    fn fill_between_data_bounds_empty() {
1018        let a = FillBetweenArtist {
1019            x: Series::new(vec![]),
1020            y1: Series::new(vec![]),
1021            y2: Series::new(vec![]),
1022            color: Color::BLACK,
1023            label: None,
1024            alpha: 1.0,
1025        };
1026        assert_eq!(a.data_bounds(), (0.0, 1.0, 0.0, 1.0));
1027    }
1028
1029    #[test]
1030    fn fill_between_data_bounds_y2_extends_beyond_y1() {
1031        let a = FillBetweenArtist {
1032            x: Series::new(vec![0.0, 1.0]),
1033            y1: Series::new(vec![1.0, 2.0]),
1034            y2: Series::new(vec![-5.0, 10.0]),
1035            color: Color::BLACK,
1036            label: None,
1037            alpha: 1.0,
1038        };
1039        let (_, _, ymin, ymax) = a.data_bounds();
1040        assert!((ymin - (-5.0)).abs() < f64::EPSILON);
1041        assert!((ymax - 10.0).abs() < f64::EPSILON);
1042    }
1043
1044    #[test]
1045    fn fill_between_data_bounds_one_series_empty() {
1046        // y1 has data, y2 is empty -- bounds should come from y1 alone.
1047        let a = FillBetweenArtist {
1048            x: Series::new(vec![0.0, 1.0]),
1049            y1: Series::new(vec![2.0, 8.0]),
1050            y2: Series::new(vec![]),
1051            color: Color::BLACK,
1052            label: None,
1053            alpha: 1.0,
1054        };
1055        let (_, _, ymin, ymax) = a.data_bounds();
1056        assert!((ymin - 2.0).abs() < f64::EPSILON);
1057        assert!((ymax - 8.0).abs() < f64::EPSILON);
1058    }
1059}