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