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