plotkit_core/artist.rs
1//! Artist types -- data + styling for each visual chart element.
2//!
3//! Artists are the data-carrying objects stored in [`Axes`]. Each artist type
4//! holds the data-space geometry and styling for one visual element. When the
5//! figure is rendered, the renderer iterates over the artist list and draws
6//! each one according to its variant.
7//!
8//! [`Axes`]: crate::axes::Axes
9//!
10//! # Variants
11//!
12//! | Variant | Description |
13//! |------------------|-------------------------------------------------|
14//! | [`Line`] | A polyline connecting (x, y) points. |
15//! | [`Scatter`] | Individual markers at (x, y) positions. |
16//! | [`Bar`] | Vertical or horizontal bars over categories. |
17//! | [`Histogram`] | Binned frequency distribution of a single series.|
18//! | [`FillBetween`] | Shaded region between two y-series. |
19//!
20//! [`Line`]: Artist::Line
21//! [`Scatter`]: Artist::Scatter
22//! [`Bar`]: Artist::Bar
23//! [`Histogram`]: Artist::Histogram
24//! [`FillBetween`]: Artist::FillBetween
25
26use crate::primitives::Color;
27use crate::series::{Categories, Series};
28use crate::theme::{LineStyle, Marker};
29
30// ---------------------------------------------------------------------------
31// Artist enum
32// ---------------------------------------------------------------------------
33
34/// A visual element drawn on an axes.
35///
36/// `Artist` is the primary unit of chart content. Each variant wraps a
37/// concrete artist struct that stores the data, colors, and styling needed
38/// to render one visual element. The enum provides convenience accessors
39/// ([`label`](Artist::label), [`color`](Artist::color),
40/// [`data_bounds`](Artist::data_bounds)) that dispatch to the inner type.
41#[derive(Debug, Clone)]
42pub enum Artist {
43 /// A line chart connecting (x, y) points.
44 Line(LineArtist),
45 /// A scatter plot of individual points.
46 Scatter(ScatterArtist),
47 /// A bar chart (vertical or horizontal).
48 Bar(BarArtist),
49 /// A histogram (binned frequency distribution).
50 Histogram(HistArtist),
51 /// A filled region between two y-series sharing a common x-series.
52 FillBetween(FillBetweenArtist),
53}
54
55impl Artist {
56 /// Returns the legend label for this artist, if one has been set.
57 ///
58 /// The legend renderer uses this to decide which artists appear in the
59 /// legend. Artists without a label are silently skipped.
60 pub fn label(&self) -> Option<&str> {
61 match self {
62 Artist::Line(a) => a.label.as_deref(),
63 Artist::Scatter(a) => a.label.as_deref(),
64 Artist::Bar(a) => a.label.as_deref(),
65 Artist::Histogram(a) => a.label.as_deref(),
66 Artist::FillBetween(a) => a.label.as_deref(),
67 }
68 }
69
70 /// Returns the primary color of this artist.
71 ///
72 /// Used by the legend to draw a color swatch next to the label, and by
73 /// any other component that needs to identify an artist's color (e.g.
74 /// tooltip rendering).
75 pub fn color(&self) -> Color {
76 match self {
77 Artist::Line(a) => a.color,
78 Artist::Scatter(a) => a.color,
79 Artist::Bar(a) => a.color,
80 Artist::Histogram(a) => a.color,
81 Artist::FillBetween(a) => a.color,
82 }
83 }
84
85 /// Returns the data-space bounding box as `(xmin, xmax, ymin, ymax)`.
86 ///
87 /// The axes autoscaling logic calls this on every artist to compute the
88 /// tightest axis limits that contain all visible data. If a series is
89 /// empty or contains no finite values, the corresponding min/max pair
90 /// falls back to `(0.0, 1.0)` so that the axes always have a non-zero
91 /// extent.
92 pub fn data_bounds(&self) -> (f64, f64, f64, f64) {
93 match self {
94 Artist::Line(a) => a.data_bounds(),
95 Artist::Scatter(a) => a.data_bounds(),
96 Artist::Bar(a) => a.data_bounds(),
97 Artist::Histogram(a) => a.data_bounds(),
98 Artist::FillBetween(a) => a.data_bounds(),
99 }
100 }
101}
102
103// ---------------------------------------------------------------------------
104// Helper: safe bounds with fallback
105// ---------------------------------------------------------------------------
106
107/// Returns `(min, max)` of the finite values in `series`, falling back to
108/// `(fallback_min, fallback_max)` when the series is empty or entirely
109/// non-finite.
110fn series_bounds_or(series: &Series, fallback_min: f64, fallback_max: f64) -> (f64, f64) {
111 match series.bounds() {
112 Some((lo, hi)) => (lo, hi),
113 None => (fallback_min, fallback_max),
114 }
115}
116
117// ---------------------------------------------------------------------------
118// LineArtist
119// ---------------------------------------------------------------------------
120
121/// A line chart connecting a sequence of (x, y) data points.
122///
123/// The `x` and `y` series must have the same length. Points are drawn in
124/// order, producing a single connected polyline with the configured stroke
125/// style.
126#[derive(Debug, Clone)]
127pub struct LineArtist {
128 /// X-coordinates of the data points.
129 pub x: Series,
130 /// Y-coordinates of the data points.
131 pub y: Series,
132 /// Stroke color of the line.
133 pub color: Color,
134 /// Stroke width in pixels.
135 pub width: f64,
136 /// Stroke pattern (solid, dashed, dotted, dash-dot).
137 pub style: LineStyle,
138 /// Optional legend label. When `Some`, the line appears in the legend.
139 pub label: Option<String>,
140 /// Opacity from 0.0 (fully transparent) to 1.0 (fully opaque).
141 pub alpha: f64,
142}
143
144impl LineArtist {
145 /// Computes the data-space bounding box `(xmin, xmax, ymin, ymax)`.
146 ///
147 /// Falls back to `(0.0, 1.0)` on each axis when the corresponding
148 /// series contains no finite values.
149 pub fn data_bounds(&self) -> (f64, f64, f64, f64) {
150 let (xmin, xmax) = series_bounds_or(&self.x, 0.0, 1.0);
151 let (ymin, ymax) = series_bounds_or(&self.y, 0.0, 1.0);
152 (xmin, xmax, ymin, ymax)
153 }
154}
155
156// ---------------------------------------------------------------------------
157// ScatterArtist
158// ---------------------------------------------------------------------------
159
160/// A scatter plot rendering individual markers at (x, y) positions.
161///
162/// Each data point is drawn as a marker whose shape, size, and color can be
163/// configured. An optional per-point `colors` vector overrides the uniform
164/// `color` field, enabling colormap-based visualizations.
165#[derive(Debug, Clone)]
166pub struct ScatterArtist {
167 /// X-coordinates of the data points.
168 pub x: Series,
169 /// Y-coordinates of the data points.
170 pub y: Series,
171 /// Default marker color (used when `colors` is `None`).
172 pub color: Color,
173 /// Marker shape.
174 pub marker: Marker,
175 /// Marker diameter in pixels.
176 pub size: f64,
177 /// Optional legend label. When `Some`, the scatter appears in the legend.
178 pub label: Option<String>,
179 /// Opacity from 0.0 (fully transparent) to 1.0 (fully opaque).
180 pub alpha: f64,
181 /// Optional per-point colors for colormap-driven scatter plots.
182 ///
183 /// When set, `colors.len()` must equal `x.len()` (and `y.len()`). Each
184 /// entry overrides `color` for the corresponding data point.
185 pub colors: Option<Vec<Color>>,
186}
187
188impl ScatterArtist {
189 /// Computes the data-space bounding box `(xmin, xmax, ymin, ymax)`.
190 ///
191 /// Falls back to `(0.0, 1.0)` on each axis when the corresponding
192 /// series contains no finite values.
193 pub fn data_bounds(&self) -> (f64, f64, f64, f64) {
194 let (xmin, xmax) = series_bounds_or(&self.x, 0.0, 1.0);
195 let (ymin, ymax) = series_bounds_or(&self.y, 0.0, 1.0);
196 (xmin, xmax, ymin, ymax)
197 }
198}
199
200// ---------------------------------------------------------------------------
201// BarArtist
202// ---------------------------------------------------------------------------
203
204/// A bar chart rendering vertical or horizontal bars over categorical data.
205///
206/// Categories are placed at integer positions `0, 1, 2, ...` on the
207/// category axis, with each bar centered on its position. The `bar_width`
208/// field controls the fraction of the inter-category spacing that the bar
209/// occupies (1.0 = bars touching, 0.5 = half-width with gaps).
210#[derive(Debug, Clone)]
211pub struct BarArtist {
212 /// Category labels for the bar axis.
213 pub categories: Categories,
214 /// Bar heights (or lengths, for horizontal bars).
215 pub heights: Series,
216 /// Fill color of the bars.
217 pub color: Color,
218 /// Optional legend label. When `Some`, the bar series appears in the legend.
219 pub label: Option<String>,
220 /// Opacity from 0.0 (fully transparent) to 1.0 (fully opaque).
221 pub alpha: f64,
222 /// When `true`, bars extend horizontally (categories on the y-axis).
223 pub horizontal: bool,
224 /// Bar width as a fraction of the category spacing (0.0, 1.0].
225 pub bar_width: f64,
226}
227
228impl BarArtist {
229 /// Computes the data-space bounding box `(xmin, xmax, ymin, ymax)`.
230 ///
231 /// For vertical bars, the x-axis spans from `-0.5` to `n - 0.5` (where
232 /// `n` is the number of categories) so that bars are centered on integer
233 /// positions. The y-axis spans from `0.0` to the tallest bar, with a
234 /// fallback of `(0.0, 1.0)` when the heights series is empty.
235 ///
236 /// For horizontal bars the axes are transposed: the y-axis holds the
237 /// category positions and the x-axis holds the bar lengths.
238 pub fn data_bounds(&self) -> (f64, f64, f64, f64) {
239 let n = self.categories.len() as f64;
240
241 // Determine the extent along the value axis (heights / lengths).
242 let height_min = self.heights.min().unwrap_or(0.0).min(0.0);
243 let height_max = self.heights.max().unwrap_or(1.0);
244
245 // Category axis runs from -0.5 to n-0.5 so bars are centered on 0..n-1.
246 let cat_min = -0.5;
247 let cat_max = if n > 0.0 { n - 0.5 } else { 0.5 };
248
249 if self.horizontal {
250 // Horizontal bars: x = value axis, y = category axis.
251 (height_min, height_max, cat_min, cat_max)
252 } else {
253 // Vertical bars: x = category axis, y = value axis.
254 (cat_min, cat_max, height_min, height_max)
255 }
256 }
257}
258
259// ---------------------------------------------------------------------------
260// HistArtist
261// ---------------------------------------------------------------------------
262
263/// A histogram showing the frequency distribution of a single data series.
264///
265/// The raw data is retained in `data`, but the binning results (`bin_edges`
266/// and `counts`) are expected to be pre-computed when the artist is created
267/// (typically by the histogram chart builder). This avoids re-binning during
268/// every render pass.
269///
270/// When `density` is `true`, the `counts` vector stores probability density
271/// values (each count divided by `n * bin_width`) rather than raw counts, so
272/// that the total area under the histogram integrates to 1.0.
273#[derive(Debug, Clone)]
274pub struct HistArtist {
275 /// The original (un-binned) data values.
276 pub data: Series,
277 /// The requested number of bins (used for display/debugging; the actual
278 /// bin count is `bin_edges.len() - 1`).
279 pub bins: usize,
280 /// Sorted bin edges of length `bins + 1`. The i-th bin spans
281 /// `[bin_edges[i], bin_edges[i+1])`.
282 pub bin_edges: Vec<f64>,
283 /// The count (or density) for each bin. Length equals `bin_edges.len() - 1`.
284 pub counts: Vec<f64>,
285 /// Fill color of the histogram bars.
286 pub color: Color,
287 /// Optional legend label. When `Some`, the histogram appears in the legend.
288 pub label: Option<String>,
289 /// Opacity from 0.0 (fully transparent) to 1.0 (fully opaque).
290 pub alpha: f64,
291 /// When `true`, `counts` stores probability density instead of raw counts.
292 pub density: bool,
293}
294
295impl HistArtist {
296 /// Computes the data-space bounding box `(xmin, xmax, ymin, ymax)`.
297 ///
298 /// The x-axis spans from the first bin edge to the last bin edge. The
299 /// y-axis spans from `0.0` to the tallest bin count (or density value).
300 /// Returns `(0.0, 1.0, 0.0, 1.0)` when there are no bin edges.
301 pub fn data_bounds(&self) -> (f64, f64, f64, f64) {
302 if self.bin_edges.len() < 2 {
303 return (0.0, 1.0, 0.0, 1.0);
304 }
305
306 // x-axis: first edge to last edge.
307 let xmin = self.bin_edges[0];
308 let xmax = self.bin_edges[self.bin_edges.len() - 1];
309
310 // y-axis: 0 to tallest bin.
311 let ymax = self
312 .counts
313 .iter()
314 .copied()
315 .filter(|v| v.is_finite())
316 .fold(0.0_f64, f64::max);
317
318 // Guarantee a non-zero y extent so the axes are always drawable.
319 let ymax = if ymax <= 0.0 { 1.0 } else { ymax };
320
321 (xmin, xmax, 0.0, ymax)
322 }
323}
324
325// ---------------------------------------------------------------------------
326// FillBetweenArtist
327// ---------------------------------------------------------------------------
328
329/// A filled region between two y-series that share a common x-series.
330///
331/// The renderer draws a closed polygon connecting `(x, y1)` forward and
332/// `(x, y2)` backward, then fills it with the configured color and opacity.
333/// This is commonly used for confidence bands, area charts, and shaded
334/// difference regions.
335#[derive(Debug, Clone)]
336pub struct FillBetweenArtist {
337 /// X-coordinates shared by both y-series.
338 pub x: Series,
339 /// Y-coordinates of the first boundary curve.
340 pub y1: Series,
341 /// Y-coordinates of the second boundary curve.
342 pub y2: Series,
343 /// Fill color of the shaded region.
344 pub color: Color,
345 /// Optional legend label. When `Some`, the fill region appears in the legend.
346 pub label: Option<String>,
347 /// Opacity from 0.0 (fully transparent) to 1.0 (fully opaque).
348 pub alpha: f64,
349}
350
351impl FillBetweenArtist {
352 /// Computes the data-space bounding box `(xmin, xmax, ymin, ymax)`.
353 ///
354 /// The x-bounds come from the shared `x` series. The y-bounds are the
355 /// union of `y1` and `y2` (i.e. the overall min and max across both
356 /// boundary curves). Falls back to `(0.0, 1.0)` on any axis that has
357 /// no finite values.
358 pub fn data_bounds(&self) -> (f64, f64, f64, f64) {
359 let (xmin, xmax) = series_bounds_or(&self.x, 0.0, 1.0);
360
361 // Union the y-bounds of both boundary series.
362 let y1_min = self.y1.min();
363 let y2_min = self.y2.min();
364 let y1_max = self.y1.max();
365 let y2_max = self.y2.max();
366
367 let ymin = match (y1_min, y2_min) {
368 (Some(a), Some(b)) => a.min(b),
369 (Some(a), None) => a,
370 (None, Some(b)) => b,
371 (None, None) => 0.0,
372 };
373
374 let ymax = match (y1_max, y2_max) {
375 (Some(a), Some(b)) => a.max(b),
376 (Some(a), None) => a,
377 (None, Some(b)) => b,
378 (None, None) => 1.0,
379 };
380
381 (xmin, xmax, ymin, ymax)
382 }
383}
384
385// ---------------------------------------------------------------------------
386// Tests
387// ---------------------------------------------------------------------------
388
389#[cfg(test)]
390mod tests {
391 use super::*;
392
393 /// Helper: build a simple `LineArtist` for testing.
394 fn sample_line() -> LineArtist {
395 LineArtist {
396 x: Series::new(vec![1.0, 2.0, 3.0]),
397 y: Series::new(vec![10.0, 20.0, 30.0]),
398 color: Color::TAB_BLUE,
399 width: 1.5,
400 style: LineStyle::Solid,
401 label: Some("line".to_string()),
402 alpha: 1.0,
403 }
404 }
405
406 /// Helper: build a simple `ScatterArtist` for testing.
407 fn sample_scatter() -> ScatterArtist {
408 ScatterArtist {
409 x: Series::new(vec![0.0, 5.0, 10.0]),
410 y: Series::new(vec![-1.0, 0.0, 1.0]),
411 color: Color::TAB_ORANGE,
412 marker: Marker::Circle,
413 size: 6.0,
414 label: None,
415 alpha: 0.8,
416 colors: None,
417 }
418 }
419
420 /// Helper: build a simple `BarArtist` for testing.
421 fn sample_bar() -> BarArtist {
422 BarArtist {
423 categories: Categories::new(vec!["A".into(), "B".into(), "C".into()]),
424 heights: Series::new(vec![4.0, 7.0, 2.0]),
425 color: Color::TAB_GREEN,
426 label: Some("bars".to_string()),
427 alpha: 1.0,
428 horizontal: false,
429 bar_width: 0.8,
430 }
431 }
432
433 /// Helper: build a simple `HistArtist` for testing.
434 fn sample_hist() -> HistArtist {
435 HistArtist {
436 data: Series::new(vec![1.0, 2.0, 2.5, 3.0, 3.5, 4.0]),
437 bins: 3,
438 bin_edges: vec![1.0, 2.0, 3.0, 4.0],
439 counts: vec![1.0, 2.0, 3.0],
440 color: Color::TAB_RED,
441 label: Some("hist".to_string()),
442 alpha: 0.7,
443 density: false,
444 }
445 }
446
447 /// Helper: build a simple `FillBetweenArtist` for testing.
448 fn sample_fill_between() -> FillBetweenArtist {
449 FillBetweenArtist {
450 x: Series::new(vec![0.0, 1.0, 2.0]),
451 y1: Series::new(vec![1.0, 3.0, 2.0]),
452 y2: Series::new(vec![0.0, 1.0, 0.5]),
453 color: Color::TAB_PURPLE,
454 label: Some("fill".to_string()),
455 alpha: 0.3,
456 }
457 }
458
459 // -- Artist enum dispatch -----------------------------------------------
460
461 #[test]
462 fn artist_label_returns_inner_label() {
463 let a = Artist::Line(sample_line());
464 assert_eq!(a.label(), Some("line"));
465
466 let a = Artist::Scatter(sample_scatter());
467 assert_eq!(a.label(), None);
468
469 let a = Artist::Bar(sample_bar());
470 assert_eq!(a.label(), Some("bars"));
471
472 let a = Artist::Histogram(sample_hist());
473 assert_eq!(a.label(), Some("hist"));
474
475 let a = Artist::FillBetween(sample_fill_between());
476 assert_eq!(a.label(), Some("fill"));
477 }
478
479 #[test]
480 fn artist_color_returns_inner_color() {
481 assert_eq!(Artist::Line(sample_line()).color(), Color::TAB_BLUE);
482 assert_eq!(Artist::Scatter(sample_scatter()).color(), Color::TAB_ORANGE);
483 assert_eq!(Artist::Bar(sample_bar()).color(), Color::TAB_GREEN);
484 assert_eq!(Artist::Histogram(sample_hist()).color(), Color::TAB_RED);
485 assert_eq!(
486 Artist::FillBetween(sample_fill_between()).color(),
487 Color::TAB_PURPLE
488 );
489 }
490
491 #[test]
492 fn artist_data_bounds_dispatches_correctly() {
493 let a = Artist::Line(sample_line());
494 assert_eq!(a.data_bounds(), (1.0, 3.0, 10.0, 30.0));
495 }
496
497 // -- LineArtist ---------------------------------------------------------
498
499 #[test]
500 fn line_data_bounds_basic() {
501 let a = sample_line();
502 assert_eq!(a.data_bounds(), (1.0, 3.0, 10.0, 30.0));
503 }
504
505 #[test]
506 fn line_data_bounds_empty_series() {
507 let a = LineArtist {
508 x: Series::new(vec![]),
509 y: Series::new(vec![]),
510 color: Color::BLACK,
511 width: 1.0,
512 style: LineStyle::Solid,
513 label: None,
514 alpha: 1.0,
515 };
516 assert_eq!(a.data_bounds(), (0.0, 1.0, 0.0, 1.0));
517 }
518
519 #[test]
520 fn line_data_bounds_with_nan() {
521 let a = LineArtist {
522 x: Series::new(vec![f64::NAN, 2.0, 5.0]),
523 y: Series::new(vec![1.0, f64::NAN, 3.0]),
524 color: Color::BLACK,
525 width: 1.0,
526 style: LineStyle::Solid,
527 label: None,
528 alpha: 1.0,
529 };
530 assert_eq!(a.data_bounds(), (2.0, 5.0, 1.0, 3.0));
531 }
532
533 // -- ScatterArtist ------------------------------------------------------
534
535 #[test]
536 fn scatter_data_bounds_basic() {
537 let a = sample_scatter();
538 assert_eq!(a.data_bounds(), (0.0, 10.0, -1.0, 1.0));
539 }
540
541 #[test]
542 fn scatter_data_bounds_empty() {
543 let a = ScatterArtist {
544 x: Series::new(vec![]),
545 y: Series::new(vec![]),
546 color: Color::BLACK,
547 marker: Marker::Circle,
548 size: 6.0,
549 label: None,
550 alpha: 1.0,
551 colors: None,
552 };
553 assert_eq!(a.data_bounds(), (0.0, 1.0, 0.0, 1.0));
554 }
555
556 // -- BarArtist ----------------------------------------------------------
557
558 #[test]
559 fn bar_data_bounds_vertical() {
560 let a = sample_bar();
561 let (xmin, xmax, ymin, ymax) = a.data_bounds();
562 assert!((xmin - (-0.5)).abs() < f64::EPSILON);
563 assert!((xmax - 2.5).abs() < f64::EPSILON);
564 assert!((ymin - 0.0).abs() < f64::EPSILON);
565 assert!((ymax - 7.0).abs() < f64::EPSILON);
566 }
567
568 #[test]
569 fn bar_data_bounds_horizontal() {
570 let mut a = sample_bar();
571 a.horizontal = true;
572 let (xmin, xmax, ymin, ymax) = a.data_bounds();
573 // Horizontal: x = value axis, y = category axis.
574 assert!((xmin - 0.0).abs() < f64::EPSILON);
575 assert!((xmax - 7.0).abs() < f64::EPSILON);
576 assert!((ymin - (-0.5)).abs() < f64::EPSILON);
577 assert!((ymax - 2.5).abs() < f64::EPSILON);
578 }
579
580 #[test]
581 fn bar_data_bounds_negative_heights() {
582 let a = BarArtist {
583 categories: Categories::new(vec!["A".into(), "B".into()]),
584 heights: Series::new(vec![-3.0, 5.0]),
585 color: Color::BLACK,
586 label: None,
587 alpha: 1.0,
588 horizontal: false,
589 bar_width: 0.8,
590 };
591 let (_, _, ymin, ymax) = a.data_bounds();
592 assert!((ymin - (-3.0)).abs() < f64::EPSILON);
593 assert!((ymax - 5.0).abs() < f64::EPSILON);
594 }
595
596 #[test]
597 fn bar_data_bounds_empty() {
598 let a = BarArtist {
599 categories: Categories::new(vec![]),
600 heights: Series::new(vec![]),
601 color: Color::BLACK,
602 label: None,
603 alpha: 1.0,
604 horizontal: false,
605 bar_width: 0.8,
606 };
607 let (xmin, xmax, ymin, ymax) = a.data_bounds();
608 assert!((xmin - (-0.5)).abs() < f64::EPSILON);
609 assert!((xmax - 0.5).abs() < f64::EPSILON);
610 assert!((ymin - 0.0).abs() < f64::EPSILON);
611 assert!((ymax - 1.0).abs() < f64::EPSILON);
612 }
613
614 // -- HistArtist ---------------------------------------------------------
615
616 #[test]
617 fn hist_data_bounds_basic() {
618 let a = sample_hist();
619 let (xmin, xmax, ymin, ymax) = a.data_bounds();
620 assert!((xmin - 1.0).abs() < f64::EPSILON);
621 assert!((xmax - 4.0).abs() < f64::EPSILON);
622 assert!((ymin - 0.0).abs() < f64::EPSILON);
623 assert!((ymax - 3.0).abs() < f64::EPSILON);
624 }
625
626 #[test]
627 fn hist_data_bounds_empty_bins() {
628 let a = HistArtist {
629 data: Series::new(vec![]),
630 bins: 0,
631 bin_edges: vec![],
632 counts: vec![],
633 color: Color::BLACK,
634 label: None,
635 alpha: 1.0,
636 density: false,
637 };
638 assert_eq!(a.data_bounds(), (0.0, 1.0, 0.0, 1.0));
639 }
640
641 #[test]
642 fn hist_data_bounds_single_edge_pair() {
643 let a = HistArtist {
644 data: Series::new(vec![1.0]),
645 bins: 1,
646 bin_edges: vec![0.5, 1.5],
647 counts: vec![1.0],
648 color: Color::BLACK,
649 label: None,
650 alpha: 1.0,
651 density: false,
652 };
653 let (xmin, xmax, ymin, ymax) = a.data_bounds();
654 assert!((xmin - 0.5).abs() < f64::EPSILON);
655 assert!((xmax - 1.5).abs() < f64::EPSILON);
656 assert!((ymin - 0.0).abs() < f64::EPSILON);
657 assert!((ymax - 1.0).abs() < f64::EPSILON);
658 }
659
660 #[test]
661 fn hist_data_bounds_all_zero_counts() {
662 let a = HistArtist {
663 data: Series::new(vec![]),
664 bins: 2,
665 bin_edges: vec![0.0, 1.0, 2.0],
666 counts: vec![0.0, 0.0],
667 color: Color::BLACK,
668 label: None,
669 alpha: 1.0,
670 density: false,
671 };
672 let (_, _, _, ymax) = a.data_bounds();
673 // All-zero counts should produce a fallback ymax of 1.0.
674 assert!((ymax - 1.0).abs() < f64::EPSILON);
675 }
676
677 // -- FillBetweenArtist --------------------------------------------------
678
679 #[test]
680 fn fill_between_data_bounds_basic() {
681 let a = sample_fill_between();
682 let (xmin, xmax, ymin, ymax) = a.data_bounds();
683 assert!((xmin - 0.0).abs() < f64::EPSILON);
684 assert!((xmax - 2.0).abs() < f64::EPSILON);
685 assert!((ymin - 0.0).abs() < f64::EPSILON);
686 assert!((ymax - 3.0).abs() < f64::EPSILON);
687 }
688
689 #[test]
690 fn fill_between_data_bounds_empty() {
691 let a = FillBetweenArtist {
692 x: Series::new(vec![]),
693 y1: Series::new(vec![]),
694 y2: Series::new(vec![]),
695 color: Color::BLACK,
696 label: None,
697 alpha: 1.0,
698 };
699 assert_eq!(a.data_bounds(), (0.0, 1.0, 0.0, 1.0));
700 }
701
702 #[test]
703 fn fill_between_data_bounds_y2_extends_beyond_y1() {
704 let a = FillBetweenArtist {
705 x: Series::new(vec![0.0, 1.0]),
706 y1: Series::new(vec![1.0, 2.0]),
707 y2: Series::new(vec![-5.0, 10.0]),
708 color: Color::BLACK,
709 label: None,
710 alpha: 1.0,
711 };
712 let (_, _, ymin, ymax) = a.data_bounds();
713 assert!((ymin - (-5.0)).abs() < f64::EPSILON);
714 assert!((ymax - 10.0).abs() < f64::EPSILON);
715 }
716
717 #[test]
718 fn fill_between_data_bounds_one_series_empty() {
719 // y1 has data, y2 is empty -- bounds should come from y1 alone.
720 let a = FillBetweenArtist {
721 x: Series::new(vec![0.0, 1.0]),
722 y1: Series::new(vec![2.0, 8.0]),
723 y2: Series::new(vec![]),
724 color: Color::BLACK,
725 label: None,
726 alpha: 1.0,
727 };
728 let (_, _, ymin, ymax) = a.data_bounds();
729 assert!((ymin - 2.0).abs() < f64::EPSILON);
730 assert!((ymax - 8.0).abs() < f64::EPSILON);
731 }
732}