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