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}