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