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