Skip to main content

plotkit_core/
axes.rs

1//! The Axes container -- holds chart data and renders a single subplot.
2//!
3//! `Axes` is the central workhorse of the plotting library. It stores a list of
4//! [`Artist`] values (one per chart call), manages auto-scaling, tick generation,
5//! and drives the full ten-step render pipeline that produces the final visual
6//! output within a subplot rectangle.
7
8use crate::artist::*;
9use crate::error::{PlotError, Result};
10use crate::layout::{self, LayoutConfig};
11use crate::legend::{self, LegendEntry, SwatchKind};
12use crate::primitives::*;
13use crate::renderer::Renderer;
14use crate::scale::Scale;
15use crate::series::{IntoCategories, IntoSeries};
16use crate::theme::{Loc, Marker, Theme, TickDirection};
17use crate::ticks;
18
19// ---------------------------------------------------------------------------
20// Constants
21// ---------------------------------------------------------------------------
22
23/// Default number of ticks to aim for on each axis.
24const DEFAULT_TICK_COUNT: usize = 7;
25
26/// Padding fraction applied to auto-computed data limits so that data points
27/// do not sit directly on the axis spines.
28const AUTOSCALE_PAD: f64 = 0.05;
29
30// ---------------------------------------------------------------------------
31// Axes
32// ---------------------------------------------------------------------------
33
34/// A single set of axes within a figure, containing chart artists and
35/// configuration.
36///
37/// Users do not construct `Axes` directly; they are created by
38/// [`Figure::add_subplot`] or the convenience function [`Figure::subplots`].
39/// Once an `Axes` handle is obtained, chart methods such as [`plot`](Axes::plot),
40/// [`scatter`](Axes::scatter), and [`bar`](Axes::bar) add data, and
41/// configuration methods like [`set_title`](Axes::set_title) control labels,
42/// limits, and styling.
43#[derive(Debug)]
44pub struct Axes {
45    /// The list of artists (one per chart call) in draw order.
46    pub(crate) artists: Vec<Artist>,
47    /// Optional title displayed above the plot area.
48    pub(crate) title: Option<String>,
49    /// Optional label for the x-axis.
50    pub(crate) xlabel: Option<String>,
51    /// Optional label for the y-axis.
52    pub(crate) ylabel: Option<String>,
53    /// User-specified x-axis limits; `None` means auto-scale.
54    pub(crate) xlim: Option<(f64, f64)>,
55    /// User-specified y-axis limits; `None` means auto-scale.
56    pub(crate) ylim: Option<(f64, f64)>,
57    /// Scale for the x-axis (linear, log, symlog).
58    pub(crate) xscale: Scale,
59    /// Scale for the y-axis (linear, log, symlog).
60    pub(crate) yscale: Scale,
61    /// Whether to show grid lines. `None` defers to the theme default.
62    pub(crate) show_grid: Option<bool>,
63    /// Whether the legend should be drawn.
64    pub(crate) show_legend: bool,
65    /// Where to place the legend.
66    pub(crate) legend_loc: Loc,
67    /// Per-axes theme override. `None` means use the figure theme.
68    pub(crate) theme_override: Option<Theme>,
69    /// Tracks the current position in the color cycle so that each successive
70    /// artist receives a distinct color automatically.
71    color_index: usize,
72}
73
74// ---------------------------------------------------------------------------
75// Construction
76// ---------------------------------------------------------------------------
77
78impl Axes {
79    /// Creates a new, empty axes with default settings.
80    pub(crate) fn new() -> Self {
81        Self {
82            artists: Vec::new(),
83            title: None,
84            xlabel: None,
85            ylabel: None,
86            xlim: None,
87            ylim: None,
88            xscale: Scale::default(),
89            yscale: Scale::default(),
90            show_grid: None,
91            show_legend: false,
92            legend_loc: Loc::Best,
93            theme_override: None,
94            color_index: 0,
95        }
96    }
97
98}
99
100// ---------------------------------------------------------------------------
101// Chart methods
102// ---------------------------------------------------------------------------
103
104impl Axes {
105    /// Plots a line connecting `(x, y)` data points.
106    ///
107    /// Returns a mutable reference to the newly created [`LineArtist`] so
108    /// that the caller can chain builder methods (`.color()`, `.width()`,
109    /// `.label()`, etc.).
110    ///
111    /// # Errors
112    ///
113    /// Returns [`PlotError::SeriesLengthMismatch`] if `x` and `y` have
114    /// different lengths, or [`PlotError::EmptyData`] if either is empty.
115    pub fn plot<X, Y>(&mut self, x: X, y: Y) -> Result<&mut LineArtist>
116    where
117        X: IntoSeries,
118        Y: IntoSeries,
119    {
120        let xs = x.into_series();
121        let ys = y.into_series();
122        if xs.len() != ys.len() {
123            return Err(PlotError::SeriesLengthMismatch {
124                expected: xs.len(),
125                got: ys.len(),
126            });
127        }
128        if xs.is_empty() {
129            return Err(PlotError::EmptyData);
130        }
131        let color = Color::TABLEAU_10[self.color_index % 10];
132        self.color_index += 1;
133        let artist = LineArtist {
134            x: xs,
135            y: ys,
136            color,
137            width: 1.5,
138            style: crate::theme::LineStyle::Solid,
139            label: None,
140            alpha: 1.0,
141        };
142        self.artists.push(Artist::Line(artist));
143        match self.artists.last_mut().expect("just pushed") {
144            Artist::Line(a) => Ok(a),
145            _ => unreachable!(),
146        }
147    }
148
149    /// Creates a scatter plot of `(x, y)` data points.
150    ///
151    /// Returns a mutable reference to the [`ScatterArtist`] for chaining.
152    ///
153    /// # Errors
154    ///
155    /// Returns [`PlotError::SeriesLengthMismatch`] or [`PlotError::EmptyData`]
156    /// on invalid input.
157    pub fn scatter<X, Y>(&mut self, x: X, y: Y) -> Result<&mut ScatterArtist>
158    where
159        X: IntoSeries,
160        Y: IntoSeries,
161    {
162        let xs = x.into_series();
163        let ys = y.into_series();
164        if xs.len() != ys.len() {
165            return Err(PlotError::SeriesLengthMismatch {
166                expected: xs.len(),
167                got: ys.len(),
168            });
169        }
170        if xs.is_empty() {
171            return Err(PlotError::EmptyData);
172        }
173        let color = Color::TABLEAU_10[self.color_index % 10];
174        self.color_index += 1;
175        let artist = ScatterArtist {
176            x: xs,
177            y: ys,
178            color,
179            marker: Marker::Circle,
180            size: 6.0,
181            label: None,
182            alpha: 0.8,
183            colors: None,
184        };
185        self.artists.push(Artist::Scatter(artist));
186        match self.artists.last_mut().expect("just pushed") {
187            Artist::Scatter(a) => Ok(a),
188            _ => unreachable!(),
189        }
190    }
191
192    /// Creates a vertical bar chart from categorical data and heights.
193    ///
194    /// Each category label maps to one bar whose height is the corresponding
195    /// value in `heights`.
196    ///
197    /// # Errors
198    ///
199    /// Returns [`PlotError::SeriesLengthMismatch`] if `categories` and
200    /// `heights` have different lengths, or [`PlotError::EmptyData`] if empty.
201    pub fn bar<C, H>(&mut self, categories: C, heights: H) -> Result<&mut BarArtist>
202    where
203        C: IntoCategories,
204        H: IntoSeries,
205    {
206        let cats = categories.into_categories();
207        let vals = heights.into_series();
208        if cats.len() != vals.len() {
209            return Err(PlotError::SeriesLengthMismatch {
210                expected: cats.len(),
211                got: vals.len(),
212            });
213        }
214        if cats.is_empty() {
215            return Err(PlotError::EmptyData);
216        }
217        let color = Color::TABLEAU_10[self.color_index % 10];
218        self.color_index += 1;
219        let artist = BarArtist {
220            categories: cats,
221            heights: vals,
222            color,
223            horizontal: false,
224            bar_width: 0.8,
225            label: None,
226            alpha: 1.0,
227        };
228        self.artists.push(Artist::Bar(artist));
229        match self.artists.last_mut().expect("just pushed") {
230            Artist::Bar(a) => Ok(a),
231            _ => unreachable!(),
232        }
233    }
234
235    /// Creates a horizontal bar chart from categorical data and widths.
236    ///
237    /// Behaves like [`bar`](Axes::bar) but draws horizontal bars from the
238    /// y-axis.
239    ///
240    /// # Errors
241    ///
242    /// Same as [`bar`](Axes::bar).
243    pub fn barh<C, W>(&mut self, categories: C, widths: W) -> Result<&mut BarArtist>
244    where
245        C: IntoCategories,
246        W: IntoSeries,
247    {
248        let cats = categories.into_categories();
249        let vals = widths.into_series();
250        if cats.len() != vals.len() {
251            return Err(PlotError::SeriesLengthMismatch {
252                expected: cats.len(),
253                got: vals.len(),
254            });
255        }
256        if cats.is_empty() {
257            return Err(PlotError::EmptyData);
258        }
259        let color = Color::TABLEAU_10[self.color_index % 10];
260        self.color_index += 1;
261        let artist = BarArtist {
262            categories: cats,
263            heights: vals,
264            color,
265            horizontal: true,
266            bar_width: 0.8,
267            label: None,
268            alpha: 1.0,
269        };
270        self.artists.push(Artist::Bar(artist));
271        match self.artists.last_mut().expect("just pushed") {
272            Artist::Bar(a) => Ok(a),
273            _ => unreachable!(),
274        }
275    }
276
277    /// Creates a histogram from raw data.
278    ///
279    /// The data is partitioned into `bins` equal-width bins spanning the data
280    /// range, and each bin is drawn as a vertical bar whose height equals the
281    /// count of values falling in that bin.
282    ///
283    /// # Errors
284    ///
285    /// Returns [`PlotError::EmptyData`] if `data` is empty.
286    pub fn hist<D>(&mut self, data: D, bins: usize) -> Result<&mut HistArtist>
287    where
288        D: IntoSeries,
289    {
290        let series = data.into_series();
291        if series.is_empty() {
292            return Err(PlotError::EmptyData);
293        }
294        let bins = bins.max(1);
295
296        // Compute range from finite values.
297        let (data_min, data_max) = series.bounds().unwrap_or((0.0, 1.0));
298
299        // Handle degenerate case where all values are identical.
300        let (lo, hi) = if (data_max - data_min).abs() < f64::EPSILON {
301            (data_min - 0.5, data_max + 0.5)
302        } else {
303            (data_min, data_max)
304        };
305
306        let bin_width = (hi - lo) / bins as f64;
307
308        // Build bin edges: bins+1 edges.
309        let mut edges: Vec<f64> = (0..=bins).map(|i| lo + i as f64 * bin_width).collect();
310        // Ensure the last edge exactly equals hi to avoid floating-point gaps.
311        *edges.last_mut().expect("edges is non-empty") = hi;
312
313        // Count values per bin.
314        let mut counts = vec![0.0f64; bins];
315        for &v in &series.data {
316            if !v.is_finite() {
317                continue;
318            }
319            // Determine bin index.
320            let idx = if v >= hi {
321                // Values exactly at the upper edge go into the last bin.
322                bins - 1
323            } else {
324                let raw = ((v - lo) / bin_width) as usize;
325                raw.min(bins - 1)
326            };
327            counts[idx] += 1.0;
328        }
329
330        let color = Color::TABLEAU_10[self.color_index % 10];
331        self.color_index += 1;
332        let artist = HistArtist {
333            data: series,
334            bins,
335            bin_edges: edges,
336            counts,
337            color,
338            label: None,
339            alpha: 0.85,
340            density: false,
341        };
342
343
344
345
346
347
348
349        self.artists.push(Artist::Histogram(artist));
350        match self.artists.last_mut().expect("just pushed") {
351            Artist::Histogram(a) => Ok(a),
352            _ => unreachable!(),
353        }
354    }
355
356    /// Fills the area between two y-series that share the same x values.
357    ///
358    /// Useful for confidence intervals, envelopes, and area charts.
359    ///
360    /// # Errors
361    ///
362    /// Returns [`PlotError::SeriesLengthMismatch`] if any of the three
363    /// series differ in length, or [`PlotError::EmptyData`] if empty.
364    pub fn fill_between<X, Y1, Y2>(
365        &mut self,
366        x: X,
367        y1: Y1,
368        y2: Y2,
369    ) -> Result<&mut FillBetweenArtist>
370    where
371        X: IntoSeries,
372        Y1: IntoSeries,
373        Y2: IntoSeries,
374    {
375        let xs = x.into_series();
376        let y1s = y1.into_series();
377        let y2s = y2.into_series();
378        if xs.len() != y1s.len() {
379            return Err(PlotError::SeriesLengthMismatch {
380                expected: xs.len(),
381                got: y1s.len(),
382            });
383        }
384        if xs.len() != y2s.len() {
385            return Err(PlotError::SeriesLengthMismatch {
386                expected: xs.len(),
387                got: y2s.len(),
388            });
389        }
390        if xs.is_empty() {
391            return Err(PlotError::EmptyData);
392        }
393        let color = Color::TABLEAU_10[self.color_index % 10];
394        self.color_index += 1;
395        let artist = FillBetweenArtist {
396            x: xs,
397            y1: y1s,
398            y2: y2s,
399            color,
400            label: None,
401            alpha: 0.3,
402        };
403        self.artists.push(Artist::FillBetween(artist));
404        match self.artists.last_mut().expect("just pushed") {
405            Artist::FillBetween(a) => Ok(a),
406            _ => unreachable!(),
407        }
408    }
409}
410
411// ---------------------------------------------------------------------------
412// Configuration methods (builder-style, return &mut Self)
413// ---------------------------------------------------------------------------
414
415impl Axes {
416    /// Sets the title displayed above the axes area.
417    pub fn set_title(&mut self, title: &str) -> &mut Self {
418        self.title = Some(title.to_string());
419        self
420    }
421
422    /// Sets the x-axis label.
423    pub fn set_xlabel(&mut self, label: &str) -> &mut Self {
424        self.xlabel = Some(label.to_string());
425        self
426    }
427
428    /// Sets the y-axis label.
429    pub fn set_ylabel(&mut self, label: &str) -> &mut Self {
430        self.ylabel = Some(label.to_string());
431        self
432    }
433
434    /// Sets explicit x-axis limits. Pass `(min, max)`.
435    pub fn set_xlim(&mut self, min: f64, max: f64) -> &mut Self {
436        self.xlim = Some((min, max));
437        self
438    }
439
440    /// Sets explicit y-axis limits. Pass `(min, max)`.
441    pub fn set_ylim(&mut self, min: f64, max: f64) -> &mut Self {
442        self.ylim = Some((min, max));
443        self
444    }
445
446    /// Sets the x-axis scale (linear, log10, symlog).
447    pub fn set_xscale(&mut self, scale: Scale) -> &mut Self {
448        self.xscale = scale;
449        self
450    }
451
452    /// Sets the y-axis scale (linear, log10, symlog).
453    pub fn set_yscale(&mut self, scale: Scale) -> &mut Self {
454        self.yscale = scale;
455        self
456    }
457
458    /// Enables or disables grid lines on this axes.
459    pub fn grid(&mut self, show: bool) -> &mut Self {
460        self.show_grid = Some(show);
461        self
462    }
463
464    /// Enables the legend on this axes.
465    pub fn legend(&mut self) -> &mut Self {
466        self.show_legend = true;
467        self
468    }
469
470    /// Sets the legend location.
471    pub fn set_legend_loc(&mut self, loc: Loc) -> &mut Self {
472        self.legend_loc = loc;
473        self
474    }
475
476    /// Overrides the figure theme for this axes only.
477    pub fn set_theme(&mut self, theme: Theme) -> &mut Self {
478        self.theme_override = Some(theme);
479        self
480    }
481}
482
483// ---------------------------------------------------------------------------
484// Rendering pipeline
485// ---------------------------------------------------------------------------
486
487#[allow(clippy::too_many_arguments)]
488impl Axes {
489    /// Renders this axes into the given rectangle on the renderer.
490    ///
491    /// This is called by the `Figure` during its render pass. The ten-step
492    /// pipeline is:
493    ///
494    /// 1. Compute data limits (autoscale or user-set).
495    /// 2. Generate tick positions and labels.
496    /// 3. Compute internal layout (margins for title, labels, ticks).
497    /// 4. Draw axes background.
498    /// 5. Draw grid lines (behind data).
499    /// 6. Clip to the plot area and draw each artist.
500    /// 7. Draw axis spines.
501    /// 8. Draw ticks and tick labels.
502    /// 9. Draw axis labels and title.
503    /// 10. Draw legend (if enabled).
504    pub(crate) fn render(&self, renderer: &mut impl Renderer, bounds: Rect, fig_theme: &Theme) {
505        let theme = self.theme_override.as_ref().unwrap_or(fig_theme);
506
507        // Step 1: Compute data limits.
508        let (xmin, xmax, ymin, ymax) = self.compute_data_limits();
509
510        // Step 2: Generate ticks.
511        let xticks = ticks::generate_ticks(xmin, xmax, DEFAULT_TICK_COUNT, &self.xscale);
512        let yticks = ticks::generate_ticks(ymin, ymax, DEFAULT_TICK_COUNT, &self.yscale);
513
514        // Step 3: Compute layout (reserve space for labels, ticks, title).
515        let mut layout_config = LayoutConfig::new(bounds.width, bounds.height);
516        layout_config.has_title = self.title.is_some();
517        layout_config.has_xlabel = self.xlabel.is_some();
518        layout_config.has_ylabel = self.ylabel.is_some();
519        layout_config.has_legend = self.show_legend;
520
521
522
523
524
525        let layout_result = layout::compute_layout(&layout_config);
526
527        // Offset the computed plot area by the bounds position.
528        let plot_area = Rect::new(
529            bounds.x + layout_result.plot_area.x,
530            bounds.y + layout_result.plot_area.y,
531            layout_result.plot_area.width,
532            layout_result.plot_area.height,
533        );
534
535        // Step 4: Draw axes background.
536        let bg_path = Path::rect(plot_area);
537        renderer.fill_path(&bg_path, &Paint::new(theme.axes_background), Affine::IDENTITY);
538
539        // Step 5: Draw grid (behind data).
540        if self.show_grid.unwrap_or(theme.show_grid) {
541            self.draw_grid(renderer, &plot_area, &xticks, &yticks, xmin, xmax, ymin, ymax, theme);
542        }
543
544        // Step 6: Clip to plot area and draw each artist.
545        let clip_path = Path::rect(plot_area);
546        renderer.push_clip(&clip_path, Affine::IDENTITY);
547        for artist in &self.artists {
548            self.draw_artist(renderer, artist, &plot_area, xmin, xmax, ymin, ymax, theme);
549        }
550        renderer.pop_clip();
551
552        // Step 7: Draw spines.
553        self.draw_spines(renderer, &plot_area, theme);
554
555        // Step 8: Draw ticks and tick labels.
556        self.draw_ticks(renderer, &plot_area, &xticks, &yticks, xmin, xmax, ymin, ymax, theme);
557
558        // Step 9: Draw axis labels and title.
559        self.draw_labels(renderer, &plot_area, &bounds, theme);
560
561        // Step 10: Draw legend if enabled.
562        if self.show_legend {
563            self.draw_legend(renderer, &plot_area, theme);
564        }
565    }
566
567    // -----------------------------------------------------------------------
568    // Step 1: Data limits
569    // -----------------------------------------------------------------------
570
571    /// Computes the data limits from all artists, applying user overrides
572    /// and padding. Returns `(xmin, xmax, ymin, ymax)`.
573    fn compute_data_limits(&self) -> (f64, f64, f64, f64) {
574        let mut x_lo = f64::INFINITY;
575        let mut x_hi = f64::NEG_INFINITY;
576        let mut y_lo = f64::INFINITY;
577        let mut y_hi = f64::NEG_INFINITY;
578
579        for artist in &self.artists {
580            match artist {
581                Artist::Line(a) => {
582                    if let Some((lo, hi)) = a.x.bounds() {
583                        x_lo = x_lo.min(lo);
584                        x_hi = x_hi.max(hi);
585                    }
586                    if let Some((lo, hi)) = a.y.bounds() {
587                        y_lo = y_lo.min(lo);
588                        y_hi = y_hi.max(hi);
589                    }
590                }
591                Artist::Scatter(a) => {
592                    if let Some((lo, hi)) = a.x.bounds() {
593                        x_lo = x_lo.min(lo);
594                        x_hi = x_hi.max(hi);
595                    }
596                    if let Some((lo, hi)) = a.y.bounds() {
597                        y_lo = y_lo.min(lo);
598                        y_hi = y_hi.max(hi);
599                    }
600                }
601                Artist::Bar(a) => {
602                    let n = a.categories.len() as f64;
603                    if a.horizontal {
604                        // x-axis is the value axis, y-axis is the category axis.
605                        y_lo = 0.0_f64.min(y_lo);
606                        y_hi = n.max(y_hi);
607                        x_lo = 0.0_f64.min(x_lo);
608                        if let Some((lo, hi)) = a.heights.bounds() {
609                            x_lo = x_lo.min(lo.min(0.0));
610                            x_hi = x_hi.max(hi);
611                        }
612                    } else {
613                        // x-axis is the category axis, y-axis is the value axis.
614                        x_lo = 0.0_f64.min(x_lo);
615                        x_hi = n.max(x_hi);
616                        y_lo = 0.0_f64.min(y_lo);
617                        if let Some((lo, hi)) = a.heights.bounds() {
618                            y_lo = y_lo.min(lo.min(0.0));
619                            y_hi = y_hi.max(hi);
620                        }
621                    }
622                }
623                Artist::Histogram(a) => {
624                    if let (Some(&first), Some(&last)) = (a.bin_edges.first(), a.bin_edges.last()) {
625                        x_lo = x_lo.min(first);
626                        x_hi = x_hi.max(last);
627                    }
628                    y_lo = 0.0_f64.min(y_lo);
629                    let max_count = a.counts.iter().fold(0.0f64, |a, &b| a.max(b));
630                    y_hi = y_hi.max(max_count);
631                }
632                Artist::FillBetween(a) => {
633                    if let Some((lo, hi)) = a.x.bounds() {
634                        x_lo = x_lo.min(lo);
635                        x_hi = x_hi.max(hi);
636                    }
637                    if let Some((lo, hi)) = a.y1.bounds() {
638                        y_lo = y_lo.min(lo);
639                        y_hi = y_hi.max(hi);
640                    }
641                    if let Some((lo, hi)) = a.y2.bounds() {
642                        y_lo = y_lo.min(lo);
643                        y_hi = y_hi.max(hi);
644                    }
645                }
646            }
647        }
648
649        // Handle the case where there are no artists or no finite data.
650        if !x_lo.is_finite() || !x_hi.is_finite() {
651            x_lo = 0.0;
652            x_hi = 1.0;
653        }
654        if !y_lo.is_finite() || !y_hi.is_finite() {
655            y_lo = 0.0;
656            y_hi = 1.0;
657        }
658
659        // Handle degenerate ranges (all data at a single point).
660        if (x_hi - x_lo).abs() < f64::EPSILON {
661            x_lo -= 0.5;
662            x_hi += 0.5;
663        }
664        if (y_hi - y_lo).abs() < f64::EPSILON {
665            y_lo -= 0.5;
666            y_hi += 0.5;
667        }
668
669        // Apply padding (5% on each side).
670        let x_pad = (x_hi - x_lo) * AUTOSCALE_PAD;
671        let y_pad = (y_hi - y_lo) * AUTOSCALE_PAD;
672        x_lo -= x_pad;
673        x_hi += x_pad;
674        y_lo -= y_pad;
675        y_hi += y_pad;
676
677        // Apply user-set limits, overriding auto-scale.
678        if let Some((lo, hi)) = self.xlim {
679            x_lo = lo;
680            x_hi = hi;
681        }
682        if let Some((lo, hi)) = self.ylim {
683            y_lo = lo;
684            y_hi = hi;
685        }
686
687        (x_lo, x_hi, y_lo, y_hi)
688    }
689
690    // -----------------------------------------------------------------------
691    // Step 5: Grid
692    // -----------------------------------------------------------------------
693
694    /// Draws major grid lines behind the data.
695    fn draw_grid(
696        &self,
697        renderer: &mut impl Renderer,
698        plot_area: &Rect,
699        xticks: &[ticks::Tick],
700        yticks: &[ticks::Tick],
701        xmin: f64,
702        xmax: f64,
703        ymin: f64,
704        ymax: f64,
705        theme: &Theme,
706    ) {
707        let paint = Paint::new(theme.grid_color);
708        let stroke = Stroke::new(theme.grid_width);
709
710        // Vertical grid lines at each x-tick.
711        for tick in xticks {
712            let pt = self.data_to_pixel(tick.value, ymin, plot_area, xmin, xmax, ymin, ymax);
713            let mut path = Path::new();
714            path.move_to(pt.x, plot_area.y);
715            path.line_to(pt.x, plot_area.bottom());
716            renderer.stroke_path(&path, &paint, &stroke, Affine::IDENTITY);
717        }
718
719        // Horizontal grid lines at each y-tick.
720        for tick in yticks {
721            let pt = self.data_to_pixel(xmin, tick.value, plot_area, xmin, xmax, ymin, ymax);
722            let mut path = Path::new();
723            path.move_to(plot_area.x, pt.y);
724            path.line_to(plot_area.right(), pt.y);
725            renderer.stroke_path(&path, &paint, &stroke, Affine::IDENTITY);
726        }
727    }
728
729    // -----------------------------------------------------------------------
730    // Step 6: Artist drawing
731    // -----------------------------------------------------------------------
732
733    /// Dispatches drawing to the appropriate artist-type-specific method.
734    fn draw_artist(
735        &self,
736        renderer: &mut impl Renderer,
737        artist: &Artist,
738        plot_area: &Rect,
739        xmin: f64,
740        xmax: f64,
741        ymin: f64,
742        ymax: f64,
743        theme: &Theme,
744    ) {
745        match artist {
746            Artist::Line(a) => self.draw_line(renderer, a, plot_area, xmin, xmax, ymin, ymax),
747            Artist::Scatter(a) => self.draw_scatter(renderer, a, plot_area, xmin, xmax, ymin, ymax, theme),
748            Artist::Bar(a) => self.draw_bar(renderer, a, plot_area, xmin, xmax, ymin, ymax),
749            Artist::Histogram(a) => self.draw_hist(renderer, a, plot_area, xmin, xmax, ymin, ymax),
750            Artist::FillBetween(a) => self.draw_fill_between(renderer, a, plot_area, xmin, xmax, ymin, ymax),
751        }
752    }
753
754    /// Draws a line chart: builds a polyline from data points and strokes it.
755    fn draw_line(
756        &self,
757        renderer: &mut impl Renderer,
758        artist: &LineArtist,
759        plot_area: &Rect,
760        xmin: f64,
761        xmax: f64,
762        ymin: f64,
763        ymax: f64,
764    ) {
765        if artist.x.is_empty() {
766            return;
767        }
768
769        let mut path = Path::new();
770        let first = self.data_to_pixel(
771            artist.x.data[0],
772            artist.y.data[0],
773            plot_area,
774            xmin, xmax, ymin, ymax,
775        );
776        path.move_to(first.x, first.y);
777
778        for i in 1..artist.x.len() {
779            let pt = self.data_to_pixel(
780                artist.x.data[i],
781                artist.y.data[i],
782                plot_area,
783                xmin, xmax, ymin, ymax,
784            );
785            path.line_to(pt.x, pt.y);
786        }
787
788        let color = artist.color.with_alpha((artist.alpha * 255.0) as u8);
789        let paint = Paint::new(color);
790        let mut stroke = Stroke::new(artist.width);
791
792        // Apply line style dash pattern.
793        match artist.style {
794            crate::theme::LineStyle::Solid => {}
795            crate::theme::LineStyle::Dashed => {
796                stroke = stroke.with_dash(DashPattern {
797                    dashes: vec![6.0, 4.0],
798                    offset: 0.0,
799                });
800            }
801            crate::theme::LineStyle::Dotted => {
802                stroke = stroke.with_dash(DashPattern {
803                    dashes: vec![2.0, 2.0],
804                    offset: 0.0,
805                });
806            }
807            crate::theme::LineStyle::DashDot => {
808                stroke = stroke.with_dash(DashPattern {
809                    dashes: vec![6.0, 3.0, 2.0, 3.0],
810                    offset: 0.0,
811                });
812            }
813        }
814
815        renderer.stroke_path(&path, &paint, &stroke, Affine::IDENTITY);
816    }
817
818    /// Draws a scatter plot: fills a marker shape at each data point.
819    fn draw_scatter(
820        &self,
821        renderer: &mut impl Renderer,
822        artist: &ScatterArtist,
823        plot_area: &Rect,
824        xmin: f64,
825        xmax: f64,
826        ymin: f64,
827        ymax: f64,
828        theme: &Theme,
829    ) {
830        let color = artist.color.with_alpha((artist.alpha * 255.0) as u8);
831        let paint = Paint::new(color);
832        let radius = artist.size / 2.0;
833
834        for i in 0..artist.x.len() {
835            let pt = self.data_to_pixel(
836                artist.x.data[i],
837                artist.y.data[i],
838                plot_area,
839                xmin, xmax, ymin, ymax,
840            );
841
842            let marker_path = match artist.marker {
843                Marker::Circle | Marker::Point => Path::circle(pt, radius),
844                Marker::Square => {
845                    Path::rect(Rect::new(pt.x - radius, pt.y - radius, radius * 2.0, radius * 2.0))
846                }
847                Marker::Diamond => {
848                    let mut p = Path::new();
849                    p.move_to(pt.x, pt.y - radius);
850                    p.line_to(pt.x + radius, pt.y);
851                    p.line_to(pt.x, pt.y + radius);
852                    p.line_to(pt.x - radius, pt.y);
853                    p.close();
854                    p
855                }
856                Marker::Triangle => {
857                    let mut p = Path::new();
858                    let h = radius * 1.1547; // 2/sqrt(3) for equilateral
859                    p.move_to(pt.x, pt.y - radius);
860                    p.line_to(pt.x + h * 0.5, pt.y + radius * 0.5);
861                    p.line_to(pt.x - h * 0.5, pt.y + radius * 0.5);
862                    p.close();
863                    p
864                }
865                Marker::Plus => {
866                    // Stroked marker: draw two perpendicular lines.
867                    let mut p = Path::new();
868                    p.move_to(pt.x - radius, pt.y);
869                    p.line_to(pt.x + radius, pt.y);
870                    p.move_to(pt.x, pt.y - radius);
871                    p.line_to(pt.x, pt.y + radius);
872                    let stroke = Stroke::new(theme.line_width.max(1.0));
873                    renderer.stroke_path(&p, &paint, &stroke, Affine::IDENTITY);
874                    continue;
875                }
876                Marker::Cross => {
877                    let mut p = Path::new();
878                    let d = radius * 0.707; // radius / sqrt(2)
879                    p.move_to(pt.x - d, pt.y - d);
880                    p.line_to(pt.x + d, pt.y + d);
881                    p.move_to(pt.x + d, pt.y - d);
882                    p.line_to(pt.x - d, pt.y + d);
883                    let stroke = Stroke::new(theme.line_width.max(1.0));
884                    renderer.stroke_path(&p, &paint, &stroke, Affine::IDENTITY);
885                    continue;
886                }
887                Marker::Star => {
888                    // 5-pointed star.
889                    let mut p = Path::new();
890                    let inner = radius * 0.382;
891                    for j in 0..10 {
892                        let angle = std::f64::consts::FRAC_PI_2
893                            + j as f64 * std::f64::consts::PI / 5.0;
894                        let r = if j % 2 == 0 { radius } else { inner };
895                        let sx = pt.x + r * angle.cos();
896                        let sy = pt.y - r * angle.sin();
897                        if j == 0 {
898                            p.move_to(sx, sy);
899                        } else {
900                            p.line_to(sx, sy);
901                        }
902                    }
903                    p.close();
904                    p
905                }
906            };
907
908            renderer.fill_path(&marker_path, &paint, Affine::IDENTITY);
909        }
910    }
911
912    /// Draws a bar chart: fills rectangles for each category.
913    fn draw_bar(
914        &self,
915        renderer: &mut impl Renderer,
916        artist: &BarArtist,
917        plot_area: &Rect,
918        xmin: f64,
919        xmax: f64,
920        ymin: f64,
921        ymax: f64,
922    ) {
923        let n = artist.categories.len();
924        let color = artist.color.with_alpha((artist.alpha * 255.0) as u8);
925        let paint = Paint::new(color);
926
927        if artist.horizontal {
928            // Horizontal bars: categories on y-axis, values on x-axis.
929            let cat_range = ymax - ymin;
930            let cat_step = cat_range / n as f64;
931            let bar_half = cat_step * artist.bar_width * 0.5;
932
933            for i in 0..n {
934                let cat_center = ymin + (i as f64 + 0.5) * cat_step;
935                let value = artist.heights.data[i];
936
937                let left_val = 0.0_f64.min(value);
938                let right_val = 0.0_f64.max(value);
939
940                let p_left = self.data_to_pixel(left_val, cat_center - bar_half, plot_area, xmin, xmax, ymin, ymax);
941                let p_right = self.data_to_pixel(right_val, cat_center + bar_half, plot_area, xmin, xmax, ymin, ymax);
942
943                let rect = Rect::from_points(p_left, p_right);
944                let bar_path = Path::rect(rect);
945                renderer.fill_path(&bar_path, &paint, Affine::IDENTITY);
946            }
947        } else {
948            // Vertical bars: categories on x-axis, values on y-axis.
949            let cat_range = xmax - xmin;
950            let cat_step = cat_range / n as f64;
951            let bar_half = cat_step * artist.bar_width * 0.5;
952
953            for i in 0..n {
954                let cat_center = xmin + (i as f64 + 0.5) * cat_step;
955                let value = artist.heights.data[i];
956
957                let bottom_val = 0.0_f64.min(value);
958                let top_val = 0.0_f64.max(value);
959
960                let p_bl = self.data_to_pixel(cat_center - bar_half, bottom_val, plot_area, xmin, xmax, ymin, ymax);
961                let p_tr = self.data_to_pixel(cat_center + bar_half, top_val, plot_area, xmin, xmax, ymin, ymax);
962
963                let rect = Rect::from_points(p_bl, p_tr);
964                let bar_path = Path::rect(rect);
965                renderer.fill_path(&bar_path, &paint, Affine::IDENTITY);
966            }
967        }
968    }
969
970    /// Draws a histogram: fills rectangles from bin edges and counts.
971    fn draw_hist(
972        &self,
973        renderer: &mut impl Renderer,
974        artist: &HistArtist,
975        plot_area: &Rect,
976        xmin: f64,
977        xmax: f64,
978        ymin: f64,
979        ymax: f64,
980    ) {
981        let color = artist.color.with_alpha((artist.alpha * 255.0) as u8);
982        let paint = Paint::new(color);
983        let stroke_paint = Paint::new(Color::WHITE);
984        let stroke = Stroke::new(0.5);
985
986        for i in 0..artist.counts.len() {
987            let left = artist.bin_edges[i];
988            let right = artist.bin_edges[i + 1];
989            let height = artist.counts[i];
990
991            if height <= 0.0 {
992                continue;
993            }
994
995            let p_bl = self.data_to_pixel(left, 0.0, plot_area, xmin, xmax, ymin, ymax);
996            let p_tr = self.data_to_pixel(right, height, plot_area, xmin, xmax, ymin, ymax);
997
998            let rect = Rect::from_points(p_bl, p_tr);
999            let bar_path = Path::rect(rect);
1000            renderer.fill_path(&bar_path, &paint, Affine::IDENTITY);
1001            // Thin white outline between adjacent bins for visual separation.
1002            renderer.stroke_path(&bar_path, &stroke_paint, &stroke, Affine::IDENTITY);
1003        }
1004    }
1005
1006    /// Draws a fill-between region: builds a closed path from y1 forward and
1007    /// y2 backward, then fills it.
1008    fn draw_fill_between(
1009        &self,
1010        renderer: &mut impl Renderer,
1011        artist: &FillBetweenArtist,
1012        plot_area: &Rect,
1013        xmin: f64,
1014        xmax: f64,
1015        ymin: f64,
1016        ymax: f64,
1017    ) {
1018        if artist.x.is_empty() {
1019            return;
1020        }
1021
1022        let n = artist.x.len();
1023        let mut path = Path::new();
1024
1025        // Forward pass along y1.
1026        let first = self.data_to_pixel(
1027            artist.x.data[0],
1028            artist.y1.data[0],
1029            plot_area,
1030            xmin, xmax, ymin, ymax,
1031        );
1032        path.move_to(first.x, first.y);
1033        for i in 1..n {
1034            let pt = self.data_to_pixel(
1035                artist.x.data[i],
1036                artist.y1.data[i],
1037                plot_area,
1038                xmin, xmax, ymin, ymax,
1039            );
1040            path.line_to(pt.x, pt.y);
1041        }
1042
1043        // Backward pass along y2 (in reverse order).
1044        for i in (0..n).rev() {
1045            let pt = self.data_to_pixel(
1046                artist.x.data[i],
1047                artist.y2.data[i],
1048                plot_area,
1049                xmin, xmax, ymin, ymax,
1050            );
1051            path.line_to(pt.x, pt.y);
1052        }
1053        path.close();
1054
1055        let color = artist.color.with_alpha((artist.alpha * 255.0) as u8);
1056        let paint = Paint::new(color);
1057        renderer.fill_path(&path, &paint, Affine::IDENTITY);
1058    }
1059
1060    // -----------------------------------------------------------------------
1061    // Step 7: Spines
1062    // -----------------------------------------------------------------------
1063
1064    /// Draws the axis spines (border lines around the plot area).
1065    fn draw_spines(
1066        &self,
1067        renderer: &mut impl Renderer,
1068        plot_area: &Rect,
1069        theme: &Theme,
1070    ) {
1071        let paint = Paint::new(theme.spine_color);
1072        let stroke = Stroke::new(theme.spine_width);
1073
1074        // Bottom spine.
1075        if theme.show_bottom_spine {
1076            let mut p = Path::new();
1077            p.move_to(plot_area.x, plot_area.bottom());
1078            p.line_to(plot_area.right(), plot_area.bottom());
1079            renderer.stroke_path(&p, &paint, &stroke, Affine::IDENTITY);
1080        }
1081        // Left spine.
1082        if theme.show_left_spine {
1083            let mut p = Path::new();
1084            p.move_to(plot_area.x, plot_area.y);
1085            p.line_to(plot_area.x, plot_area.bottom());
1086            renderer.stroke_path(&p, &paint, &stroke, Affine::IDENTITY);
1087        }
1088        // Top spine.
1089        if theme.show_top_spine {
1090            let mut p = Path::new();
1091            p.move_to(plot_area.x, plot_area.y);
1092            p.line_to(plot_area.right(), plot_area.y);
1093            renderer.stroke_path(&p, &paint, &stroke, Affine::IDENTITY);
1094        }
1095        // Right spine.
1096        if theme.show_right_spine {
1097            let mut p = Path::new();
1098            p.move_to(plot_area.right(), plot_area.y);
1099            p.line_to(plot_area.right(), plot_area.bottom());
1100            renderer.stroke_path(&p, &paint, &stroke, Affine::IDENTITY);
1101        }
1102    }
1103
1104    // -----------------------------------------------------------------------
1105    // Step 8: Ticks and tick labels
1106    // -----------------------------------------------------------------------
1107
1108    /// Draws tick marks and their labels along both axes.
1109    fn draw_ticks(
1110        &self,
1111        renderer: &mut impl Renderer,
1112        plot_area: &Rect,
1113        xticks: &[ticks::Tick],
1114        yticks: &[ticks::Tick],
1115        xmin: f64,
1116        xmax: f64,
1117        ymin: f64,
1118        ymax: f64,
1119        theme: &Theme,
1120    ) {
1121        let tick_paint = Paint::new(theme.tick_color);
1122        let tick_stroke = Stroke::new(1.0);
1123        let tick_len = theme.tick_length;
1124
1125        let label_style = TextStyle {
1126            size: theme.tick_label_size,
1127            color: theme.text_color,
1128            weight: FontWeight::Normal,
1129            family: theme.font_family.clone(),
1130            halign: HAlign::Center,
1131            valign: VAlign::Top,
1132        };
1133
1134        // Tick direction offset: outward goes away from plot, inward goes in.
1135        let outward = matches!(theme.tick_direction, TickDirection::Outward);
1136
1137        // --- X-axis ticks (bottom) ---
1138        for tick in xticks {
1139            let pt = self.data_to_pixel(tick.value, ymin, plot_area, xmin, xmax, ymin, ymax);
1140            // Clamp to plot area x-bounds.
1141            if pt.x < plot_area.x - 0.5 || pt.x > plot_area.right() + 0.5 {
1142                continue;
1143            }
1144            let x = pt.x;
1145            let y_base = plot_area.bottom();
1146
1147            // Draw tick mark.
1148            let (y_start, y_end) = if outward {
1149                (y_base, y_base + tick_len)
1150            } else {
1151                (y_base - tick_len, y_base)
1152            };
1153            let mut tp = Path::new();
1154            tp.move_to(x, y_start);
1155            tp.line_to(x, y_end);
1156            renderer.stroke_path(&tp, &tick_paint, &tick_stroke, Affine::IDENTITY);
1157
1158            // Draw tick label.
1159            let label_y = if outward {
1160                y_base + tick_len + 2.0
1161            } else {
1162                y_base + 2.0
1163            };
1164            renderer.draw_text(
1165                &tick.label,
1166                Point::new(x, label_y),
1167                &label_style,
1168                Affine::IDENTITY,
1169            );
1170        }
1171
1172        // --- Y-axis ticks (left) ---
1173        let y_label_style = TextStyle {
1174            halign: HAlign::Right,
1175            valign: VAlign::Middle,
1176            ..label_style.clone()
1177        };
1178
1179        for tick in yticks {
1180            let pt = self.data_to_pixel(xmin, tick.value, plot_area, xmin, xmax, ymin, ymax);
1181            // Clamp to plot area y-bounds.
1182            if pt.y < plot_area.y - 0.5 || pt.y > plot_area.bottom() + 0.5 {
1183                continue;
1184            }
1185            let y = pt.y;
1186            let x_base = plot_area.x;
1187
1188            // Draw tick mark.
1189            let (x_start, x_end) = if outward {
1190                (x_base - tick_len, x_base)
1191            } else {
1192                (x_base, x_base + tick_len)
1193            };
1194            let mut tp = Path::new();
1195            tp.move_to(x_start, y);
1196            tp.line_to(x_end, y);
1197            renderer.stroke_path(&tp, &tick_paint, &tick_stroke, Affine::IDENTITY);
1198
1199            // Draw tick label.
1200            let label_x = if outward {
1201                x_base - tick_len - 3.0
1202            } else {
1203                x_base - 3.0
1204            };
1205            renderer.draw_text(
1206                &tick.label,
1207                Point::new(label_x, y),
1208                &y_label_style,
1209                Affine::IDENTITY,
1210            );
1211        }
1212    }
1213
1214    // -----------------------------------------------------------------------
1215    // Step 9: Labels and title
1216    // -----------------------------------------------------------------------
1217
1218    /// Draws the x-axis label, y-axis label, and title.
1219    fn draw_labels(
1220        &self,
1221        renderer: &mut impl Renderer,
1222        plot_area: &Rect,
1223        bounds: &Rect,
1224        theme: &Theme,
1225    ) {
1226        // Title (centered above plot area).
1227        if let Some(title) = &self.title {
1228            let style = TextStyle {
1229                size: theme.title_size,
1230                color: theme.text_color,
1231                weight: theme.title_weight,
1232                family: theme.font_family.clone(),
1233                halign: HAlign::Center,
1234                valign: VAlign::Bottom,
1235            };
1236            let x = plot_area.x + plot_area.width / 2.0;
1237            let y = plot_area.y - 10.0;
1238            renderer.draw_text(title, Point::new(x, y), &style, Affine::IDENTITY);
1239        }
1240
1241        // X-axis label (centered below tick labels).
1242        if let Some(xlabel) = &self.xlabel {
1243            let style = TextStyle {
1244                size: theme.axis_label_size,
1245                color: theme.text_color,
1246                weight: FontWeight::Normal,
1247                family: theme.font_family.clone(),
1248                halign: HAlign::Center,
1249                valign: VAlign::Top,
1250            };
1251            let x = plot_area.x + plot_area.width / 2.0;
1252            // Position below the tick labels. Approximate tick label height.
1253            let y = plot_area.bottom() + theme.tick_length + theme.tick_label_size + 8.0;
1254            renderer.draw_text(xlabel, Point::new(x, y), &style, Affine::IDENTITY);
1255        }
1256
1257        // Y-axis label (centered to the left of tick labels, rotated 90 degrees).
1258        if let Some(ylabel) = &self.ylabel {
1259            let style = TextStyle {
1260                size: theme.axis_label_size,
1261                color: theme.text_color,
1262                weight: FontWeight::Normal,
1263                family: theme.font_family.clone(),
1264                halign: HAlign::Center,
1265                valign: VAlign::Bottom,
1266            };
1267            let x = bounds.x + 4.0;
1268            let y = plot_area.y + plot_area.height / 2.0;
1269            // Rotate -90 degrees around the label position for vertical text.
1270            let rotate = Affine::rotate(-std::f64::consts::FRAC_PI_2);
1271            let translate_to = Affine::translate(kurbo::Vec2::new(x, y));
1272            let translate_back = Affine::translate(kurbo::Vec2::new(-x, -y));
1273            let transform = translate_to * rotate * translate_back;
1274            renderer.draw_text(ylabel, Point::new(x, y), &style, transform);
1275        }
1276    }
1277
1278    // -----------------------------------------------------------------------
1279    // Step 10: Legend
1280    // -----------------------------------------------------------------------
1281
1282    /// Draws the legend box showing labeled artists.
1283    ///
1284    /// Builds [`LegendEntry`] items from the axes' artists and delegates to
1285    /// [`legend::draw_legend`] for measurement, positioning, and rendering.
1286    fn draw_legend(
1287        &self,
1288        renderer: &mut impl Renderer,
1289        plot_area: &Rect,
1290        theme: &Theme,
1291    ) {
1292        // Collect labeled artists into LegendEntry items, choosing the
1293        // appropriate swatch kind for each artist type.
1294        let entries: Vec<LegendEntry> = self
1295            .artists
1296            .iter()
1297            .filter_map(|a| {
1298                let (label, color, swatch) = match a {
1299                    Artist::Line(a) => (a.label.as_deref(), a.color, SwatchKind::Line),
1300                    Artist::Scatter(a) => (a.label.as_deref(), a.color, SwatchKind::Filled),
1301                    Artist::Bar(a) => (a.label.as_deref(), a.color, SwatchKind::Filled),
1302                    Artist::Histogram(a) => (a.label.as_deref(), a.color, SwatchKind::Filled),
1303                    Artist::FillBetween(a) => (a.label.as_deref(), a.color, SwatchKind::Filled),
1304                };
1305                label.map(|l| LegendEntry { label: l.to_string(), color, swatch })
1306            })
1307            .collect();
1308
1309        legend::draw_legend(renderer, &entries, plot_area, self.legend_loc, theme);
1310    }
1311
1312    // -----------------------------------------------------------------------
1313    // Coordinate transform
1314    // -----------------------------------------------------------------------
1315
1316    /// Maps a data-space `(x, y)` coordinate to a pixel-space [`Point`] within
1317    /// the given `plot_area` rectangle.
1318    ///
1319    /// The x-axis maps left-to-right and the y-axis maps bottom-to-top (i.e.,
1320    /// pixel y is inverted relative to data y).
1321    fn data_to_pixel(
1322        &self,
1323        x: f64,
1324        y: f64,
1325        plot_area: &Rect,
1326        xmin: f64,
1327        xmax: f64,
1328        ymin: f64,
1329        ymax: f64,
1330    ) -> Point {
1331        let tx = self.xscale.transform(x, xmin, xmax);
1332        let ty = self.yscale.transform(y, ymin, ymax);
1333        Point::new(
1334            plot_area.x + tx * plot_area.width,
1335            plot_area.y + (1.0 - ty) * plot_area.height, // y is inverted
1336        )
1337    }
1338}
1339
1340// ---------------------------------------------------------------------------
1341// Tests
1342// ---------------------------------------------------------------------------
1343
1344#[cfg(test)]
1345mod tests {
1346    use super::*;
1347
1348    #[test]
1349    fn new_axes_has_defaults() {
1350        let ax = Axes::new();
1351        assert!(ax.artists.is_empty());
1352        assert!(ax.title.is_none());
1353        assert!(ax.xlabel.is_none());
1354        assert!(ax.ylabel.is_none());
1355        assert!(ax.xlim.is_none());
1356        assert!(ax.ylim.is_none());
1357        assert!(!ax.show_legend);
1358        assert_eq!(ax.color_index, 0);
1359    }
1360
1361    #[test]
1362    fn plot_creates_line_artist() {
1363        let mut ax = Axes::new();
1364        let result = ax.plot(vec![1.0, 2.0, 3.0], vec![4.0, 5.0, 6.0]);
1365        assert!(result.is_ok());
1366        assert_eq!(ax.artists.len(), 1);
1367        assert!(matches!(&ax.artists[0], Artist::Line(_)));
1368        assert_eq!(ax.color_index, 1);
1369    }
1370
1371    #[test]
1372    fn plot_length_mismatch() {
1373        let mut ax = Axes::new();
1374        let result = ax.plot(vec![1.0, 2.0], vec![1.0]);
1375        assert!(matches!(
1376            result,
1377            Err(PlotError::SeriesLengthMismatch { expected: 2, got: 1 })
1378        ));
1379    }
1380
1381    #[test]
1382    fn plot_empty_data() {
1383        let mut ax = Axes::new();
1384        let result = ax.plot(Vec::<f64>::new(), Vec::<f64>::new());
1385        assert!(matches!(result, Err(PlotError::EmptyData)));
1386    }
1387
1388    #[test]
1389    fn scatter_creates_artist() {
1390        let mut ax = Axes::new();
1391        let result = ax.scatter(vec![1.0, 2.0], vec![3.0, 4.0]);
1392        assert!(result.is_ok());
1393        assert!(matches!(&ax.artists[0], Artist::Scatter(_)));
1394    }
1395
1396    #[test]
1397    fn bar_creates_artist() {
1398        let mut ax = Axes::new();
1399        let cats: &[&str] = &["a", "b", "c"];
1400        let result = ax.bar(cats, vec![10.0, 20.0, 30.0]);
1401        assert!(result.is_ok());
1402        match &ax.artists[0] {
1403            Artist::Bar(a) => {
1404                assert!(!a.horizontal);
1405                assert_eq!(a.categories.len(), 3);
1406            }
1407            _ => panic!("expected Bar artist"),
1408        }
1409    }
1410
1411    #[test]
1412    fn barh_creates_horizontal_artist() {
1413        let mut ax = Axes::new();
1414        let cats: &[&str] = &["x", "y"];
1415        let result = ax.barh(cats, vec![5.0, 10.0]);
1416        assert!(result.is_ok());
1417        match &ax.artists[0] {
1418            Artist::Bar(a) => assert!(a.horizontal),
1419            _ => panic!("expected Bar artist"),
1420        }
1421    }
1422
1423    #[test]
1424    fn hist_computes_bins() {
1425        let mut ax = Axes::new();
1426        let data = vec![1.0, 2.0, 3.0, 4.0, 5.0, 6.0, 7.0, 8.0, 9.0, 10.0];
1427        let result = ax.hist(data, 5);
1428        assert!(result.is_ok());
1429        match &ax.artists[0] {
1430            Artist::Histogram(a) => {
1431                assert_eq!(a.bin_edges.len(), 6); // 5 bins = 6 edges
1432                assert_eq!(a.counts.len(), 5);
1433                // Total count should equal number of data points.
1434                let total: f64 = a.counts.iter().sum();
1435                assert_eq!(total, 10.0);
1436            }
1437            _ => panic!("expected Hist artist"),
1438        }
1439    }
1440
1441    #[test]
1442    fn hist_single_value() {
1443        let mut ax = Axes::new();
1444        let result = ax.hist(vec![5.0, 5.0, 5.0], 3);
1445        assert!(result.is_ok());
1446        match &ax.artists[0] {
1447            Artist::Histogram(a) => {
1448                let total: f64 = a.counts.iter().sum();
1449                assert_eq!(total, 3.0);
1450            }
1451            _ => panic!("expected Hist artist"),
1452        }
1453    }
1454
1455    #[test]
1456    fn hist_empty_data() {
1457        let mut ax = Axes::new();
1458        let result = ax.hist(Vec::<f64>::new(), 10);
1459        assert!(matches!(result, Err(PlotError::EmptyData)));
1460    }
1461
1462    #[test]
1463    fn fill_between_creates_artist() {
1464        let mut ax = Axes::new();
1465        let result = ax.fill_between(
1466            vec![1.0, 2.0, 3.0],
1467            vec![1.0, 2.0, 1.0],
1468            vec![0.0, 0.0, 0.0],
1469        );
1470        assert!(result.is_ok());
1471        assert!(matches!(&ax.artists[0], Artist::FillBetween(_)));
1472    }
1473
1474    #[test]
1475    fn fill_between_length_mismatch() {
1476        let mut ax = Axes::new();
1477        let result = ax.fill_between(vec![1.0, 2.0], vec![1.0], vec![0.0, 0.0]);
1478        assert!(matches!(result, Err(PlotError::SeriesLengthMismatch { .. })));
1479    }
1480
1481    #[test]
1482    fn configuration_methods_return_self() {
1483        let mut ax = Axes::new();
1484        ax.set_title("Test")
1485            .set_xlabel("X")
1486            .set_ylabel("Y")
1487            .set_xlim(0.0, 10.0)
1488            .set_ylim(-1.0, 1.0)
1489            .set_xscale(Scale::Linear)
1490            .set_yscale(Scale::Log10)
1491            .grid(true)
1492            .legend();
1493
1494        assert_eq!(ax.title.as_deref(), Some("Test"));
1495        assert_eq!(ax.xlabel.as_deref(), Some("X"));
1496        assert_eq!(ax.ylabel.as_deref(), Some("Y"));
1497        assert_eq!(ax.xlim, Some((0.0, 10.0)));
1498        assert_eq!(ax.ylim, Some((-1.0, 1.0)));
1499        assert_eq!(ax.show_grid, Some(true));
1500        assert!(ax.show_legend);
1501    }
1502
1503    #[test]
1504    fn color_cycle_advances() {
1505        let mut ax = Axes::new();
1506        for _ in 0..12 {
1507            ax.plot(vec![1.0, 2.0], vec![1.0, 2.0]).unwrap();
1508        }
1509        assert_eq!(ax.color_index, 12);
1510        // 12th artist wraps around: index 10 % 10 == 0, so color should
1511        // be the same as the first.
1512        match (&ax.artists[0], &ax.artists[10]) {
1513            (Artist::Line(a), Artist::Line(b)) => {
1514                assert_eq!(a.color, b.color);
1515            }
1516            _ => panic!("expected Line artists"),
1517        }
1518    }
1519
1520    #[test]
1521    fn data_to_pixel_linear() {
1522        let ax = Axes::new();
1523        let plot_area = Rect::new(100.0, 50.0, 400.0, 300.0);
1524
1525        // Bottom-left corner of data space.
1526        let p = ax.data_to_pixel(0.0, 0.0, &plot_area, 0.0, 10.0, 0.0, 10.0);
1527        assert!((p.x - 100.0).abs() < 1e-10);
1528        assert!((p.y - 350.0).abs() < 1e-10); // bottom of plot
1529
1530        // Top-right corner of data space.
1531        let p = ax.data_to_pixel(10.0, 10.0, &plot_area, 0.0, 10.0, 0.0, 10.0);
1532        assert!((p.x - 500.0).abs() < 1e-10);
1533        assert!((p.y - 50.0).abs() < 1e-10); // top of plot
1534
1535        // Center.
1536        let p = ax.data_to_pixel(5.0, 5.0, &plot_area, 0.0, 10.0, 0.0, 10.0);
1537        assert!((p.x - 300.0).abs() < 1e-10);
1538        assert!((p.y - 200.0).abs() < 1e-10);
1539    }
1540
1541    #[test]
1542    fn compute_data_limits_no_artists() {
1543        let ax = Axes::new();
1544        let (xmin, xmax, ymin, ymax) = ax.compute_data_limits();
1545        // Should return sensible defaults.
1546        assert!(xmin < xmax);
1547        assert!(ymin < ymax);
1548    }
1549
1550    #[test]
1551    fn compute_data_limits_with_user_override() {
1552        let mut ax = Axes::new();
1553        ax.set_xlim(-5.0, 5.0).set_ylim(0.0, 100.0);
1554        let (xmin, xmax, ymin, ymax) = ax.compute_data_limits();
1555        assert!((xmin - (-5.0)).abs() < f64::EPSILON);
1556        assert!((xmax - 5.0).abs() < f64::EPSILON);
1557        assert!((ymin - 0.0).abs() < f64::EPSILON);
1558        assert!((ymax - 100.0).abs() < f64::EPSILON);
1559    }
1560
1561    #[test]
1562    fn compute_data_limits_from_line_data() {
1563        let mut ax = Axes::new();
1564        ax.plot(vec![1.0, 5.0, 10.0], vec![2.0, 8.0, 3.0]).unwrap();
1565        let (xmin, xmax, ymin, ymax) = ax.compute_data_limits();
1566        // Should encompass data with padding.
1567        assert!(xmin < 1.0);
1568        assert!(xmax > 10.0);
1569        assert!(ymin < 2.0);
1570        assert!(ymax > 8.0);
1571    }
1572}