Skip to main content

ggplot_rs/
plot.rs

1#[cfg(all(feature = "plotters", not(target_arch = "wasm32")))]
2use plotters::prelude::IntoDrawingArea;
3
4use crate::aes::Aes;
5use crate::annotate::Annotation;
6use crate::build::PlotBuilder;
7use crate::coord::cartesian::CoordCartesian;
8use crate::coord::fixed::CoordFixed;
9use crate::coord::flip::CoordFlip;
10use crate::coord::polar::CoordPolar;
11use crate::coord::Coord;
12use crate::data::{DataFrame, GGData, Value};
13use crate::facet::{Facet, FacetLabeller, FacetScales, FacetSpace};
14use crate::geom::area::GeomArea;
15use crate::geom::bar::GeomBar;
16use crate::geom::bin2d::GeomBin2d;
17use crate::geom::blank::GeomBlank;
18use crate::geom::boxplot::GeomBoxplot;
19use crate::geom::col::GeomCol;
20use crate::geom::contour::GeomContour;
21use crate::geom::count::GeomCount;
22use crate::geom::crossbar::GeomCrossbar;
23use crate::geom::curve::GeomCurve;
24use crate::geom::density::GeomDensity;
25use crate::geom::density2d::GeomDensity2d;
26use crate::geom::dotplot::GeomDotplot;
27use crate::geom::errorbar::GeomErrorbar;
28use crate::geom::freqpoly::GeomFreqpoly;
29use crate::geom::hex::GeomHex;
30use crate::geom::histogram::GeomHistogram;
31use crate::geom::jitter::GeomJitter;
32use crate::geom::line::GeomLine;
33use crate::geom::linerange::GeomLinerange;
34use crate::geom::path::GeomPath;
35use crate::geom::point::GeomPoint;
36use crate::geom::pointrange::GeomPointrange;
37use crate::geom::polygon::GeomPolygon;
38use crate::geom::qq::{GeomQQ, GeomQQLine};
39use crate::geom::rect::GeomRect;
40use crate::geom::refline::{GeomAbline, GeomHline, GeomVline};
41use crate::geom::ribbon::GeomRibbon;
42use crate::geom::rug::GeomRug;
43use crate::geom::segment::GeomSegment;
44use crate::geom::smooth::GeomSmooth;
45use crate::geom::spoke::GeomSpoke;
46use crate::geom::step::GeomStep;
47use crate::geom::text::{GeomLabel, GeomText};
48use crate::geom::tile::GeomTile;
49use crate::geom::violin::GeomViolin;
50use crate::geom::{Geom, GeomParams};
51use crate::position::Position;
52use crate::render::layout::PlotLayout;
53#[cfg(all(feature = "plotters", not(target_arch = "wasm32")))]
54use crate::render::plotters_backend::PlottersAdapter;
55use crate::render::renderer::PlotRenderer;
56use crate::render::RenderError;
57use crate::scale::continuous::ScaleContinuous;
58use crate::scale::transform::ScaleTransform;
59use crate::scale::Scale;
60use crate::stat::Stat;
61use crate::theme::Theme;
62
63// Builder methods for the 0.17 statistical-graphics layers (QQ, step ribbons,
64// ECDF bands, Cook's contours, horizontal error bars).
65mod stat_layers;
66
67/// Labels for the plot.
68#[derive(Clone, Debug, Default)]
69pub struct Labels {
70    pub title: Option<String>,
71    pub subtitle: Option<String>,
72    pub x: Option<String>,
73    pub y: Option<String>,
74    pub caption: Option<String>,
75    /// Corner tag (R's `labs(tag = ...)`), e.g. "A" for figure panels.
76    pub tag: Option<String>,
77}
78
79/// A single layer in the plot.
80pub struct Layer {
81    pub data: Option<DataFrame>,
82    pub mapping: Aes,
83    pub geom: Box<dyn Geom>,
84    pub stat: Box<dyn Stat>,
85    pub position: Box<dyn Position>,
86    pub params: GeomParams,
87    pub show_legend: Option<bool>,
88    /// The geom was configured explicitly (`geom_*_with(...)`), so its colours
89    /// are deliberate and the theme's `primary_color` must not override them.
90    pub explicit_style: bool,
91    /// A `{column}` tooltip template ([`GGPlot::tooltip`]), rendered per row
92    /// from the *built* layer data (post-stat) once that's available.
93    pub tooltip_template: Option<String>,
94}
95
96/// The top-level plot specification — builder pattern.
97pub struct GGPlot {
98    pub(crate) data: DataFrame,
99    pub(crate) mapping: Aes,
100    pub(crate) layers: Vec<Layer>,
101    pub(crate) scales: Vec<Box<dyn Scale>>,
102    pub(crate) coord: Box<dyn Coord>,
103    pub(crate) theme: Theme,
104    pub(crate) labels: Labels,
105    pub(crate) facet: Facet,
106    pub(crate) annotations: Vec<Annotation>,
107    pub(crate) guide_legend: crate::guide::config::GuideLegend,
108    /// Warnings raised while specifying the plot (e.g. a clipped calendar
109    /// span); prepended to the build warnings.
110    pub(crate) warnings: Vec<String>,
111    /// Panel aspect ratio used when the theme sets none (helpers such as
112    /// `geom_calendar` need square cells; survives theme presets).
113    pub(crate) default_aspect_ratio: Option<f64>,
114    /// Plot-wide default locale for axis / legend labels ([`GGPlot::locale`]).
115    pub(crate) locale: Option<crate::locale::Locale>,
116    /// Whether the rendered root `<svg>` carries `data-plot`/`data-domain`/…
117    /// ([`svg_root_data`](Self::svg_root_data)). Default `true`; a consumer
118    /// overlaying a fragment on its own basemap (`render_svg_native_at`)
119    /// turns it off so the fragment adds no root attributes of its own.
120    pub(crate) svg_root_data: bool,
121    /// Extra root `<svg>` attributes requested via [`svg_attr`](Self::svg_attr)/
122    /// [`svg_attrs`](Self::svg_attrs), validated and applied at render time.
123    pub(crate) root_attrs: Vec<(String, String)>,
124    /// Whether to emit a `data-warnings` JSON array of the build warnings
125    /// on the root `<svg>` ([`svg_warnings_attr`](Self::svg_warnings_attr)).
126    /// Default off; byte-identical output when off.
127    pub(crate) svg_warnings_attr: bool,
128    /// Whether to emit `data-fvalue`/`data-fx` on marks
129    /// ([`svg_formatted_values`](Self::svg_formatted_values)). Default off;
130    /// byte-identical output when off.
131    pub(crate) svg_formatted_values: bool,
132    /// #221: validated [`layer_svg_attr`](Self::layer_svg_attr)s as
133    /// `(layer index, name, value)`, applied by wrapping that layer's geom
134    /// at build time.
135    pub(crate) layer_svg_attrs: Vec<(usize, String, String)>,
136    /// #255: accessible name for the rendered root `<svg>`
137    /// ([`svg_title`](Self::svg_title)) — a `<title>` first child plus
138    /// `role="img"`/`aria-labelledby`. Default `None`; byte-identical
139    /// output when not set.
140    pub(crate) svg_title: Option<String>,
141    /// #255: accessible description for the rendered root `<svg>`
142    /// ([`svg_desc`](Self::svg_desc)) — a `<desc>` first child plus
143    /// `role="img"`/`aria-describedby`. Default `None`; byte-identical
144    /// output when not set.
145    pub(crate) svg_desc: Option<String>,
146}
147
148impl GGPlot {
149    /// Create a new plot with the given data source.
150    ///
151    /// Never panics on malformed input: e.g. column-oriented data whose columns
152    /// have different lengths is padded with `NA` and reported as a
153    /// [`GGError::ValidationError`] by [`try_build`](Self::try_build) and the
154    /// `render_*`/`save*` methods.
155    pub fn new(data: impl GGData) -> Self {
156        GGPlot {
157            data: data.into_dataframe(),
158            mapping: Aes::default(),
159            layers: Vec::new(),
160            scales: Vec::new(),
161            coord: Box::new(CoordCartesian::new()),
162            theme: Theme::default(),
163            labels: Labels::default(),
164            facet: Facet::default(),
165            annotations: Vec::new(),
166            guide_legend: crate::guide::config::GuideLegend::default(),
167            warnings: Vec::new(),
168            default_aspect_ratio: None,
169            locale: None,
170            svg_root_data: true,
171            root_attrs: Vec::new(),
172            svg_warnings_attr: false,
173            svg_formatted_values: false,
174            layer_svg_attrs: Vec::new(),
175            svg_title: None,
176            svg_desc: None,
177        }
178    }
179
180    /// Set the plot-level aesthetic mapping.
181    pub fn aes(mut self, mapping: Aes) -> Self {
182        self.warnings.extend(mapping.warnings.iter().cloned());
183        self.mapping = mapping;
184        self
185    }
186
187    // ─── Geom shortcuts ──────────────────────────────────────────
188
189    pub fn geom_point(self) -> Self {
190        self.add_geom(GeomPoint::default())
191    }
192
193    pub fn geom_point_with(self, geom: GeomPoint) -> Self {
194        self.add_geom_with(geom)
195    }
196
197    pub fn geom_line(self) -> Self {
198        self.add_geom(GeomLine::default())
199    }
200
201    pub fn geom_line_with(self, geom: GeomLine) -> Self {
202        self.add_geom_with(geom)
203    }
204
205    pub fn geom_bar(self) -> Self {
206        self.add_geom(GeomBar::default())
207    }
208
209    pub fn geom_bar_with(self, geom: GeomBar) -> Self {
210        self.add_geom_with(geom)
211    }
212
213    pub fn geom_histogram(self) -> Self {
214        self.add_geom(GeomHistogram::default())
215    }
216
217    pub fn geom_histogram_with(self, geom: GeomHistogram) -> Self {
218        self.add_geom_with(geom)
219    }
220
221    pub fn geom_boxplot(self) -> Self {
222        self.add_geom(GeomBoxplot::default())
223    }
224
225    pub fn geom_boxplot_with(self, geom: GeomBoxplot) -> Self {
226        self.add_geom_with(geom)
227    }
228
229    pub fn geom_smooth(self) -> Self {
230        self.add_geom(GeomSmooth::default())
231    }
232
233    pub fn geom_smooth_with(self, geom: GeomSmooth) -> Self {
234        self.add_geom_with(geom)
235    }
236
237    pub fn geom_col(self) -> Self {
238        self.add_geom(GeomCol::default())
239    }
240
241    pub fn geom_col_with(self, geom: GeomCol) -> Self {
242        self.add_geom_with(geom)
243    }
244
245    /// Horizontal reference line at a constant `yintercept`. Like ggplot2 the
246    /// intercept trains the y scale (the line is always visible) and the line
247    /// appears in every facet panel.
248    pub fn geom_hline(self, yintercept: f64) -> Self {
249        self.add_refline(
250            GeomHline::new(yintercept),
251            false,
252            &[("yintercept", yintercept)],
253        )
254    }
255
256    /// Add a horizontal reference line with custom styling (color/linetype/width).
257    pub fn geom_hline_with(self, geom: GeomHline) -> Self {
258        let y = geom.yintercept;
259        self.add_refline(geom, true, &[("yintercept", y)])
260    }
261
262    /// Data-mapped horizontal lines (ggplot2's `geom_hline(aes(yintercept =
263    /// …))`): one line per row of the layer data (the plot data unless
264    /// [`layer_data`](Self::layer_data) follows), per facet panel, styled by
265    /// any `color`/`linetype`/`alpha` in `mapping`. The plot-level mapping is
266    /// not inherited.
267    ///
268    /// ```
269    /// # use ggplot_rs::prelude::*;
270    /// let thresholds: Vec<(String, Vec<Value>)> = vec![
271    ///     ("bound".into(), vec![Value::Float(-0.2), Value::Float(0.2)]),
272    /// ];
273    /// let svg = GGPlot::new(vec![
274    ///         ("lag".to_string(), vec![Value::Float(1.0), Value::Float(2.0)]),
275    ///         ("acf".to_string(), vec![Value::Float(0.5), Value::Float(0.1)]),
276    ///     ])
277    ///     .aes(Aes::new().x("lag").y("acf"))
278    ///     .geom_col()
279    ///     .geom_hline_aes(Aes::new().yintercept("bound"))
280    ///     .layer_data(thresholds)
281    ///     .render_svg_native()
282    ///     .unwrap();
283    /// assert!(svg.contains("data-value=\"0.2\""));
284    /// ```
285    pub fn geom_hline_aes(self, mapping: Aes) -> Self {
286        self.add_geom(GeomHline::mapped()).layer_aes(mapping)
287    }
288
289    /// [`geom_hline_aes`](Self::geom_hline_aes) with custom default styling.
290    pub fn geom_hline_aes_with(self, geom: GeomHline, mapping: Aes) -> Self {
291        self.add_geom_with(geom).layer_aes(mapping)
292    }
293
294    /// Vertical reference line at a constant `xintercept` (trains the x
295    /// scale, appears in every panel).
296    pub fn geom_vline(self, xintercept: f64) -> Self {
297        self.add_refline(
298            GeomVline::new(xintercept),
299            false,
300            &[("xintercept", xintercept)],
301        )
302    }
303
304    /// Add a vertical reference line with custom styling (color/linetype/width).
305    pub fn geom_vline_with(self, geom: GeomVline) -> Self {
306        let x = geom.xintercept;
307        self.add_refline(geom, true, &[("xintercept", x)])
308    }
309
310    /// Data-mapped vertical lines (`aes(xintercept = …)`), one per row; see
311    /// [`geom_hline_aes`](Self::geom_hline_aes).
312    pub fn geom_vline_aes(self, mapping: Aes) -> Self {
313        self.add_geom(GeomVline::mapped()).layer_aes(mapping)
314    }
315
316    /// [`geom_vline_aes`](Self::geom_vline_aes) with custom default styling.
317    pub fn geom_vline_aes_with(self, geom: GeomVline, mapping: Aes) -> Self {
318        self.add_geom_with(geom).layer_aes(mapping)
319    }
320
321    /// Line `y = intercept + slope · x` in data space, clipped to the panel.
322    /// It does not train any scale (as in ggplot2).
323    pub fn geom_abline(self, slope: f64, intercept: f64) -> Self {
324        self.add_refline(
325            GeomAbline::new(slope, intercept),
326            false,
327            &[("slope", slope), ("intercept", intercept)],
328        )
329    }
330
331    /// Add a slope/intercept reference line with custom styling.
332    pub fn geom_abline_with(self, geom: GeomAbline) -> Self {
333        let (b, a) = (geom.slope, geom.intercept);
334        self.add_refline(geom, true, &[("slope", b), ("intercept", a)])
335    }
336
337    /// Data-mapped ablines (`aes(slope = …, intercept = …)`), one per row; a
338    /// missing aesthetic defaults to slope 1 / intercept 0.
339    pub fn geom_abline_aes(self, mapping: Aes) -> Self {
340        self.add_geom(GeomAbline::mapped()).layer_aes(mapping)
341    }
342
343    /// [`geom_abline_aes`](Self::geom_abline_aes) with custom default styling.
344    pub fn geom_abline_aes_with(self, geom: GeomAbline, mapping: Aes) -> Self {
345        self.add_geom_with(geom).layer_aes(mapping)
346    }
347
348    /// A constant reference line as ggplot2 builds it: a one-row layer frame
349    /// holding the constants, mapped to their aesthetics.
350    fn add_refline(
351        self,
352        geom: impl Geom + 'static,
353        explicit: bool,
354        values: &[(&str, f64)],
355    ) -> Self {
356        let mut data = DataFrame::new();
357        let mut mapping = Aes::new();
358        for (col, v) in values {
359            data.add_column(col.to_string(), vec![Value::Float(*v)]);
360            mapping = match *col {
361                "xintercept" => mapping.xintercept(col),
362                "yintercept" => mapping.yintercept(col),
363                "slope" => mapping.slope(col),
364                _ => mapping.intercept(col),
365            };
366        }
367        let plot = if explicit {
368            self.add_geom_with(geom)
369        } else {
370            self.add_geom(geom)
371        };
372        plot.layer_aes(mapping).layer_data(data)
373    }
374
375    pub fn geom_text(self) -> Self {
376        self.add_geom(GeomText::default())
377    }
378
379    pub fn geom_text_with(self, geom: GeomText) -> Self {
380        self.add_geom_with(geom)
381    }
382
383    /// Annotate with a correlation coefficient + p-value (`ggpubr::stat_cor()`).
384    /// Adds a text layer whose statistic computes Pearson correlation via
385    /// anofox-statistics; use [`GGPlot::stat_cor_with`] to pick Spearman or set
386    /// the label position.
387    #[cfg(feature = "ggpubr")]
388    pub fn stat_cor(self) -> Self {
389        self.geom_text().stat(crate::stat::cor::StatCor::default())
390    }
391
392    /// [`GGPlot::stat_cor`] with an explicit [`StatCor`](crate::stat::cor::StatCor)
393    /// (method / label position).
394    #[cfg(feature = "ggpubr")]
395    pub fn stat_cor_with(self, stat: crate::stat::cor::StatCor) -> Self {
396        self.geom_text().stat(stat)
397    }
398
399    /// Annotate grouped data with a group-comparison p-value
400    /// (`ggpubr::stat_compare_means()`). Adds a text layer whose statistic
401    /// compares y across the discrete x groups (Wilcoxon for two groups,
402    /// Kruskal-Wallis for more) via anofox-statistics.
403    #[cfg(feature = "ggpubr")]
404    pub fn stat_compare_means(self) -> Self {
405        self.geom_text()
406            .stat(crate::stat::compare_means::StatCompareMeans::default())
407    }
408
409    /// [`GGPlot::stat_compare_means`] with an explicit
410    /// [`StatCompareMeans`](crate::stat::compare_means::StatCompareMeans).
411    #[cfg(feature = "ggpubr")]
412    pub fn stat_compare_means_with(
413        self,
414        stat: crate::stat::compare_means::StatCompareMeans,
415    ) -> Self {
416        self.geom_text().stat(stat)
417    }
418
419    /// Auto-draw pairwise significance brackets (`ggpubr::stat_compare_means`
420    /// with `comparisons`). For each `(group_a, group_b)` pair it runs a
421    /// two-sample test (Wilcoxon by default) on the plot's y-values, formats the
422    /// p-value, and stacks a labelled [`geom_bracket`](GGPlot::geom_bracket)
423    /// above the data. Uses the plot's x/y mapping and data.
424    #[cfg(feature = "ggpubr")]
425    pub fn stat_compare_means_pairwise(self, comparisons: &[(&str, &str)]) -> Self {
426        self.stat_compare_means_pairwise_with(
427            crate::stat::compare_means::CompareMethod::Auto,
428            comparisons,
429        )
430    }
431
432    /// [`stat_compare_means_pairwise`](GGPlot::stat_compare_means_pairwise) with
433    /// an explicit test method.
434    #[cfg(feature = "ggpubr")]
435    pub fn stat_compare_means_pairwise_with(
436        self,
437        method: crate::stat::compare_means::CompareMethod,
438        comparisons: &[(&str, &str)],
439    ) -> Self {
440        // Resolve the x/y source columns from the plot mapping + data.
441        let col_for = |a: crate::aes::Aesthetic| {
442            self.mapping
443                .mappings
444                .iter()
445                .find(|m| m.aesthetic == a)
446                .map(|m| m.column.clone())
447        };
448        let (xcol, ycol) = match (
449            col_for(crate::aes::Aesthetic::X),
450            col_for(crate::aes::Aesthetic::Y),
451        ) {
452            (Some(x), Some(y)) => (x, y),
453            _ => return self,
454        };
455        let (xc, yc) = match (self.data.column(&xcol), self.data.column(&ycol)) {
456            (Some(x), Some(y)) => (x, y),
457            _ => return self,
458        };
459
460        // Bucket y by x category (first-seen order) and find the data range.
461        let mut groups: Vec<(String, Vec<f64>)> = Vec::new();
462        let mut ymax = f64::NEG_INFINITY;
463        let mut ymin = f64::INFINITY;
464        for (xv, yv) in xc.iter().zip(yc.iter()) {
465            let y = match yv.as_f64() {
466                Some(y) if y.is_finite() => y,
467                _ => continue,
468            };
469            ymax = ymax.max(y);
470            ymin = ymin.min(y);
471            let key = xv.to_group_key();
472            if let Some(g) = groups.iter_mut().find(|(k, _)| *k == key) {
473                g.1.push(y);
474            } else {
475                groups.push((key, vec![y]));
476            }
477        }
478        if groups.len() < 2 {
479            return self;
480        }
481        let step = if ymax > ymin {
482            (ymax - ymin) * 0.12
483        } else {
484            1.0
485        };
486
487        // One stacked bracket per resolvable comparison.
488        let (mut xmin_v, mut xmax_v, mut y_v, mut label_v) =
489            (Vec::new(), Vec::new(), Vec::new(), Vec::new());
490        let mut placed = 0usize;
491        for (a, b) in comparisons {
492            let ga = groups.iter().find(|(k, _)| k == a).map(|g| &g.1);
493            let gb = groups.iter().find(|(k, _)| k == b).map(|g| &g.1);
494            let (ga, gb) = match (ga, gb) {
495                (Some(x), Some(y)) if x.len() >= 2 && y.len() >= 2 => (x, y),
496                _ => continue,
497            };
498            let p = match crate::stat::compare_means::pairwise_p(method, ga, gb) {
499                Some(p) => p,
500                None => continue,
501            };
502            xmin_v.push(Value::Str((*a).to_string()));
503            xmax_v.push(Value::Str((*b).to_string()));
504            y_v.push(Value::Float(ymax + step * (placed as f64 + 1.0)));
505            label_v.push(Value::Str(crate::stat::cor::format_p_value(p)));
506            placed += 1;
507        }
508        if xmin_v.is_empty() {
509            return self;
510        }
511
512        let data = vec![
513            ("xmin".to_string(), xmin_v),
514            ("xmax".to_string(), xmax_v),
515            ("y".to_string(), y_v),
516            ("label".to_string(), label_v),
517        ];
518        self.add_geom(crate::geom::bracket::GeomBracket::default())
519            .layer_data(data)
520            .layer_aes(Aes::new().xmin("xmin").xmax("xmax").y("y").label("label"))
521    }
522
523    /// Draw a significance bracket over the plot (`ggpubr::geom_bracket`): a bar
524    /// spanning the categorical positions `xmin`..`xmax` at height `y`, captioned
525    /// with `label` (e.g. a p-value or significance stars). Pairs with a boxplot
526    /// and [`stat_compare_means`](GGPlot::stat_compare_means).
527    pub fn geom_bracket(self, xmin: &str, xmax: &str, y: f64, label: &str) -> Self {
528        self.geom_bracket_many(
529            crate::geom::bracket::GeomBracket::default(),
530            &[(xmin, xmax, y, label)],
531        )
532    }
533
534    /// Draw several significance brackets at once with a configured
535    /// [`GeomBracket`](crate::geom::bracket::GeomBracket). Each tuple is
536    /// `(xmin, xmax, y, label)`.
537    pub fn geom_bracket_many(
538        self,
539        geom: crate::geom::bracket::GeomBracket,
540        brackets: &[(&str, &str, f64, &str)],
541    ) -> Self {
542        let xmin = brackets
543            .iter()
544            .map(|b| Value::Str(b.0.to_string()))
545            .collect::<Vec<_>>();
546        let xmax = brackets
547            .iter()
548            .map(|b| Value::Str(b.1.to_string()))
549            .collect::<Vec<_>>();
550        let y = brackets
551            .iter()
552            .map(|b| Value::Float(b.2))
553            .collect::<Vec<_>>();
554        let label = brackets
555            .iter()
556            .map(|b| Value::Str(b.3.to_string()))
557            .collect::<Vec<_>>();
558        let data = vec![
559            ("xmin".to_string(), xmin),
560            ("xmax".to_string(), xmax),
561            ("y".to_string(), y),
562            ("label".to_string(), label),
563        ];
564        self.add_geom(geom)
565            .layer_data(data)
566            .layer_aes(Aes::new().xmin("xmin").xmax("xmax").y("y").label("label"))
567    }
568
569    /// Significance brackets from a precomputed test table (ggpubr's
570    /// `stat_pvalue_manual` / `geom_bracket(data = …)`) — e.g. the anofox
571    /// `test` contract output (`group1`, `group2`, `p_adj`/`p_value`, optional
572    /// `y_position`, `label`). No test is recomputed. See
573    /// [`BracketTable`](crate::geom::bracket::BracketTable) for the column
574    /// rules, label templates (`"p = {p_adj}"`, `"{p.signif}"`) and the
575    /// automatic stacking of rows without a `y_position`. Rows that cannot be
576    /// drawn are dropped with a build warning.
577    pub fn geom_bracket_table(
578        mut self,
579        table: impl GGData,
580        spec: crate::geom::bracket::BracketTable,
581    ) -> Self {
582        let table = table.into_dataframe();
583        let col_for = |a: crate::aes::Aesthetic| {
584            self.mapping
585                .mappings
586                .iter()
587                .find(|m| m.aesthetic == a)
588                .map(|m| m.column.clone())
589        };
590        // The plot's x categories (to reject unknown groups) and finite y
591        // range (to stack brackets above the data).
592        let x_levels: Vec<String> = col_for(crate::aes::Aesthetic::X)
593            .and_then(|c| self.data.column(&c))
594            .filter(|col| col.iter().any(|v| matches!(v, Value::Str(_))))
595            .map(|col| {
596                let mut seen = std::collections::HashSet::new();
597                col.iter()
598                    .filter(|v| !v.is_na())
599                    .map(|v| v.to_group_key())
600                    .filter(|k| seen.insert(k.clone()))
601                    .collect()
602            })
603            .unwrap_or_default();
604        let y_range = col_for(crate::aes::Aesthetic::Y)
605            .and_then(|c| self.data.column(&c))
606            .and_then(|col| {
607                let (lo, hi) = col
608                    .iter()
609                    .filter_map(|v| v.as_f64())
610                    .filter(|v| v.is_finite())
611                    .fold((f64::INFINITY, f64::NEG_INFINITY), |(lo, hi), v| {
612                        (lo.min(v), hi.max(v))
613                    });
614                (lo <= hi).then_some((lo, hi))
615            });
616        let mut warnings = Vec::new();
617        let resolved = spec.resolve(&table, &x_levels, y_range, &mut warnings);
618        self.warnings.extend(warnings);
619        match resolved {
620            Some(data) => self
621                .add_geom(crate::geom::bracket::GeomBracket::from(spec.geom))
622                .layer_data(data)
623                .layer_aes(Aes::new().xmin("xmin").xmax("xmax").y("y").label("label")),
624            None => self,
625        }
626    }
627
628    /// Text labels that repel each other and their points
629    /// (`ggrepel::geom_text_repel`) — deterministic (seeded) layout; see
630    /// [`GeomTextRepel`](crate::geom::repel::GeomTextRepel). Requires `x`,
631    /// `y` and `label`.
632    pub fn geom_text_repel(self) -> Self {
633        self.add_geom(crate::geom::repel::GeomTextRepel::default())
634    }
635
636    /// [`geom_text_repel`](Self::geom_text_repel) with a configured geom
637    /// (padding, nudge, `max_overlaps`, seed, …).
638    pub fn geom_text_repel_with(self, geom: crate::geom::repel::GeomTextRepel) -> Self {
639        self.add_geom_with(geom)
640    }
641
642    /// Boxed labels that repel each other and their points
643    /// (`ggrepel::geom_label_repel`).
644    pub fn geom_label_repel(self) -> Self {
645        self.add_geom(crate::geom::repel::GeomLabelRepel::default())
646    }
647
648    /// [`geom_label_repel`](Self::geom_label_repel) with a configured geom.
649    pub fn geom_label_repel_with(self, geom: crate::geom::repel::GeomLabelRepel) -> Self {
650        self.add_geom_with(geom)
651    }
652
653    pub fn geom_label(self) -> Self {
654        self.add_geom(GeomLabel::default())
655    }
656
657    pub fn geom_label_with(self, geom: GeomLabel) -> Self {
658        self.add_geom_with(geom)
659    }
660
661    pub fn geom_area(self) -> Self {
662        self.add_geom(GeomArea::default())
663    }
664
665    pub fn geom_area_with(self, geom: GeomArea) -> Self {
666        self.add_geom_with(geom)
667    }
668
669    pub fn geom_ribbon(self) -> Self {
670        self.add_geom(GeomRibbon::default())
671    }
672
673    pub fn geom_ribbon_with(self, geom: GeomRibbon) -> Self {
674        self.add_geom_with(geom)
675    }
676
677    pub fn geom_errorbar(self) -> Self {
678        self.add_geom(GeomErrorbar::default())
679    }
680
681    pub fn geom_errorbar_with(self, geom: GeomErrorbar) -> Self {
682        self.add_geom_with(geom)
683    }
684
685    pub fn geom_segment(self) -> Self {
686        self.add_geom(GeomSegment::default())
687    }
688
689    pub fn geom_segment_with(self, geom: GeomSegment) -> Self {
690        self.add_geom_with(geom)
691    }
692
693    pub fn geom_density(self) -> Self {
694        self.add_geom(GeomDensity::default())
695    }
696
697    pub fn geom_density_with(self, geom: GeomDensity) -> Self {
698        self.add_geom_with(geom)
699    }
700
701    pub fn geom_rug(self) -> Self {
702        self.add_geom(GeomRug::default())
703    }
704
705    pub fn geom_rug_with(self, geom: GeomRug) -> Self {
706        self.add_geom_with(geom)
707    }
708
709    pub fn geom_jitter(self) -> Self {
710        self.add_geom(GeomJitter::default())
711    }
712
713    pub fn geom_jitter_with(self, geom: GeomJitter) -> Self {
714        self.add_geom_with(geom)
715    }
716
717    pub fn geom_path(self) -> Self {
718        self.add_geom(GeomPath::default())
719    }
720
721    pub fn geom_path_with(self, geom: GeomPath) -> Self {
722        self.add_geom_with(geom)
723    }
724
725    /// Add a confidence-ellipse layer (default 95%) as a path per group.
726    pub fn stat_ellipse(self) -> Self {
727        self.geom_path()
728            .stat(crate::stat::ellipse::StatEllipse::default())
729    }
730
731    /// Add a confidence-ellipse layer at the given level (0, 1).
732    pub fn stat_ellipse_level(self, level: f64) -> Self {
733        self.geom_path()
734            .stat(crate::stat::ellipse::StatEllipse::new(level))
735    }
736
737    /// Add a quantile-regression line for each `tau` as a separate path layer
738    /// (R's `stat_quantile`). Backed by anofox-regression (feature `regression`).
739    #[cfg(feature = "regression")]
740    pub fn stat_quantile(mut self, taus: &[f64]) -> Self {
741        for &tau in taus {
742            self = self
743                .geom_path()
744                .stat(crate::stat::quantile::StatQuantile::new(tau));
745        }
746        self
747    }
748
749    /// Quantile-regression lines at the quartiles (0.25, 0.5, 0.75).
750    #[cfg(feature = "regression")]
751    pub fn geom_quantile(self) -> Self {
752        self.stat_quantile(&[0.25, 0.5, 0.75])
753    }
754
755    pub fn geom_step(self) -> Self {
756        self.add_geom(GeomStep::default())
757    }
758
759    pub fn geom_step_with(self, geom: GeomStep) -> Self {
760        self.add_geom_with(geom)
761    }
762
763    pub fn geom_freqpoly(self) -> Self {
764        self.add_geom(GeomFreqpoly::default())
765    }
766
767    pub fn geom_freqpoly_with(self, geom: GeomFreqpoly) -> Self {
768        self.add_geom_with(geom)
769    }
770
771    pub fn geom_linerange(self) -> Self {
772        self.add_geom(GeomLinerange::default())
773    }
774
775    pub fn geom_linerange_with(self, geom: GeomLinerange) -> Self {
776        self.add_geom_with(geom)
777    }
778
779    pub fn geom_pointrange(self) -> Self {
780        self.add_geom(GeomPointrange::default())
781    }
782
783    pub fn geom_pointrange_with(self, geom: GeomPointrange) -> Self {
784        self.add_geom_with(geom)
785    }
786
787    pub fn geom_crossbar(self) -> Self {
788        self.add_geom(GeomCrossbar::default())
789    }
790
791    pub fn geom_crossbar_with(self, geom: GeomCrossbar) -> Self {
792        self.add_geom_with(geom)
793    }
794
795    pub fn geom_spoke(self) -> Self {
796        self.add_geom(GeomSpoke::default())
797    }
798
799    pub fn geom_spoke_with(self, geom: GeomSpoke) -> Self {
800        self.add_geom_with(geom)
801    }
802
803    pub fn geom_rect(self) -> Self {
804        self.add_geom(GeomRect::default())
805    }
806
807    pub fn geom_rect_with(self, geom: GeomRect) -> Self {
808        self.add_geom_with(geom)
809    }
810
811    pub fn geom_tile(self) -> Self {
812        self.add_geom(GeomTile::default())
813    }
814
815    pub fn geom_tile_with(self, geom: GeomTile) -> Self {
816        self.add_geom_with(geom)
817    }
818
819    /// Dense regular grid of filled cells (heatmap/raster) from x, y, fill.
820    pub fn geom_raster(self) -> Self {
821        self.add_geom(crate::geom::raster::GeomRaster::default())
822    }
823
824    pub fn geom_raster_with(self, geom: crate::geom::raster::GeomRaster) -> Self {
825        self.add_geom_with(geom)
826    }
827
828    pub fn geom_polygon(self) -> Self {
829        self.add_geom(GeomPolygon::default())
830    }
831
832    pub fn geom_polygon_with(self, geom: GeomPolygon) -> Self {
833        self.add_geom_with(geom)
834    }
835
836    /// Render simple-features geometry from a WKT `geometry` column (feature `sf`).
837    #[cfg(feature = "sf")]
838    pub fn geom_sf(self) -> Self {
839        self.add_geom(crate::geom::sf::GeomSf::default())
840    }
841
842    #[cfg(feature = "sf")]
843    pub fn geom_sf_with(self, geom: crate::geom::sf::GeomSf) -> Self {
844        self.add_geom_with(geom)
845    }
846
847    /// Labels placed inside `geom_sf` polygons (feature `sf`): `label`
848    /// aesthetic, `geometry` column — ggplot2's `geom_sf_text`.
849    #[cfg(feature = "sf")]
850    pub fn geom_sf_text(self) -> Self {
851        self.add_geom(crate::geom::sf_text::GeomSfText::default())
852    }
853
854    #[cfg(feature = "sf")]
855    pub fn geom_sf_text_with(self, geom: crate::geom::sf_text::GeomSfText) -> Self {
856        self.add_geom_with(geom)
857    }
858
859    /// Like [`geom_sf_text`](Self::geom_sf_text), on a background box —
860    /// ggplot2's `geom_sf_label`.
861    #[cfg(feature = "sf")]
862    pub fn geom_sf_label(self) -> Self {
863        self.add_geom(crate::geom::sf_text::GeomSfLabel::default())
864    }
865
866    #[cfg(feature = "sf")]
867    pub fn geom_sf_label_with(self, geom: crate::geom::sf_text::GeomSfLabel) -> Self {
868        self.add_geom_with(geom)
869    }
870
871    /// A metric scale bar in a panel corner (feature `sf`) — ggspatial's
872    /// `annotation_scale()`. Assumes the X/Y domain is in metres (any
873    /// `geom_sf` projection other than `SfProjection::PlateCarree`).
874    #[cfg(feature = "sf")]
875    pub fn annotation_scale(self) -> Self {
876        self.add_geom(crate::geom::annotation_scale::GeomAnnotationScale::default())
877    }
878
879    #[cfg(feature = "sf")]
880    pub fn annotation_scale_with(
881        self,
882        geom: crate::geom::annotation_scale::GeomAnnotationScale,
883    ) -> Self {
884        self.add_geom_with(geom)
885    }
886
887    /// A small, muted attribution text in a panel corner (feature `sf`) —
888    /// e.g. `"© Natural Earth"`.
889    #[cfg(feature = "sf")]
890    pub fn annotation_attribution(self, text: impl Into<String>) -> Self {
891        self.add_geom(crate::geom::annotation_scale::GeomAttribution::new(text))
892    }
893
894    #[cfg(feature = "sf")]
895    pub fn annotation_attribution_with(
896        self,
897        geom: crate::geom::annotation_scale::GeomAttribution,
898    ) -> Self {
899        self.add_geom_with(geom)
900    }
901
902    /// The square `n × m` guide for a
903    /// [`ScaleFillBivariate`](crate::scale::bivariate::ScaleFillBivariate)
904    /// fill scale (feature `sf`): two axis captions, biscale's own legend
905    /// shape — drawn as a furniture layer, not through the ordinary
906    /// per-aesthetic legend (set `.legend_position(LegendPosition::None)`
907    /// to hide that one). [`GeomBivariateLegend::from_scale`] builds this
908    /// from the same scale used for the fill, so the swatches match.
909    #[cfg(feature = "sf")]
910    pub fn bivariate_legend(
911        self,
912        geom: crate::geom::bivariate_legend::GeomBivariateLegend,
913    ) -> Self {
914        self.add_geom(geom)
915    }
916
917    /// Like [`Self::bivariate_legend`], but with each cell's row count
918    /// drawn centred in its swatch — build with
919    /// [`GeomBivariateLegend::counts`](crate::geom::bivariate_legend::GeomBivariateLegend::counts)
920    /// (#259).
921    #[cfg(feature = "sf")]
922    pub fn bivariate_legend_counts(
923        self,
924        geom: crate::geom::bivariate_legend::GeomBivariateLegendCounts,
925    ) -> Self {
926        self.add_geom(geom)
927    }
928
929    pub fn geom_curve(self) -> Self {
930        self.add_geom(GeomCurve::default())
931    }
932
933    pub fn geom_curve_with(self, geom: GeomCurve) -> Self {
934        self.add_geom_with(geom)
935    }
936
937    pub fn geom_violin(self) -> Self {
938        self.add_geom(GeomViolin::default())
939    }
940
941    pub fn geom_violin_with(self, geom: GeomViolin) -> Self {
942        self.add_geom_with(geom)
943    }
944
945    pub fn geom_dotplot(self) -> Self {
946        self.add_geom(GeomDotplot::default())
947    }
948
949    pub fn geom_dotplot_with(self, geom: GeomDotplot) -> Self {
950        self.add_geom_with(geom)
951    }
952
953    pub fn geom_qq(self) -> Self {
954        self.add_geom(GeomQQ::default())
955    }
956
957    pub fn geom_qq_with(self, geom: GeomQQ) -> Self {
958        self.add_geom_with(geom)
959    }
960
961    pub fn geom_qq_line(self) -> Self {
962        self.add_geom(GeomQQLine::default())
963    }
964
965    pub fn geom_qq_line_with(self, geom: GeomQQLine) -> Self {
966        self.add_geom_with(geom)
967    }
968
969    pub fn geom_bin2d(self) -> Self {
970        self.add_geom(GeomBin2d::default())
971    }
972
973    pub fn geom_bin2d_with(self, geom: GeomBin2d) -> Self {
974        self.add_geom_with(geom)
975    }
976
977    pub fn geom_hex(self) -> Self {
978        self.add_geom(GeomHex::default())
979    }
980
981    pub fn geom_hex_with(self, geom: GeomHex) -> Self {
982        self.add_geom_with(geom)
983    }
984
985    pub fn geom_count(self) -> Self {
986        self.add_geom(GeomCount::default())
987    }
988
989    pub fn geom_count_with(self, geom: GeomCount) -> Self {
990        self.add_geom_with(geom)
991    }
992
993    pub fn geom_contour(self) -> Self {
994        self.add_geom(GeomContour::default())
995    }
996
997    pub fn geom_contour_with(self, geom: GeomContour) -> Self {
998        self.add_geom_with(geom)
999    }
1000
1001    /// Filled contour bands from gridded (x, y, z) data — draws polygons filled by
1002    /// band level. Pair with a continuous fill scale (e.g. `scale_fill_viridis_c`).
1003    pub fn geom_contour_filled(self) -> Self {
1004        self.add_geom(GeomPolygon {
1005            line_width: 0.0,
1006            alpha: 1.0,
1007            ..GeomPolygon::default()
1008        })
1009        .stat(crate::stat::contour_filled::StatContourFilled::default())
1010    }
1011
1012    pub fn geom_density2d(self) -> Self {
1013        self.add_geom(GeomDensity2d::default())
1014    }
1015
1016    pub fn geom_density2d_with(self, geom: GeomDensity2d) -> Self {
1017        self.add_geom_with(geom)
1018    }
1019
1020    /// Candlestick chart: map `x` and `open`/`high`/`low`/`close`
1021    /// (`Aes::new().x("date").open("o").high("h").low("l").close("c")`).
1022    pub fn geom_candlestick(self) -> Self {
1023        self.add_geom(crate::geom::candlestick::GeomCandlestick::default())
1024    }
1025
1026    pub fn geom_candlestick_with(self, geom: crate::geom::candlestick::GeomCandlestick) -> Self {
1027        self.add_geom_with(geom)
1028    }
1029
1030    /// OHLC bar chart (high–low bar with open/close ticks); same aesthetics as
1031    /// [`geom_candlestick`](Self::geom_candlestick).
1032    pub fn geom_ohlc(self) -> Self {
1033        self.add_geom(crate::geom::candlestick::GeomOhlc::default())
1034    }
1035
1036    pub fn geom_ohlc_with(self, geom: crate::geom::candlestick::GeomOhlc) -> Self {
1037        self.add_geom_with(geom)
1038    }
1039
1040    /// Calendar heatmap (GitHub/ECharts style): the plot's `x` (a date —
1041    /// `DateTime`, epoch seconds or `"YYYY-MM-DD"`) is laid out as week
1042    /// columns × weekday rows (Sunday on top) via [`StatCalendar`], coloured
1043    /// by `fill`, with month labels along x, Mon/Wed/Fri along y, month
1044    /// boundary outlines and square cells. Spans over
1045    /// [`MAX_CALENDAR_YEARS`] are clipped to the most recent years (with a
1046    /// build warning). Uses the plot-level data and `x` mapping.
1047    ///
1048    /// [`StatCalendar`]: crate::stat::calendar::StatCalendar
1049    /// [`MAX_CALENDAR_YEARS`]: crate::stat::calendar::MAX_CALENDAR_YEARS
1050    pub fn geom_calendar(self) -> Self {
1051        self.geom_calendar_with(
1052            GeomTile {
1053                color: (255, 255, 255),
1054                line_width: 1.5,
1055                ..Default::default()
1056            },
1057            false,
1058        )
1059    }
1060
1061    /// [`geom_calendar`](Self::geom_calendar) with a custom cell style and
1062    /// optionally Monday-first weeks.
1063    pub fn geom_calendar_with(mut self, tile: GeomTile, monday_first: bool) -> Self {
1064        use crate::stat::calendar::{
1065            day_number, month_boundaries, month_breaks, month_breaks_localized, weekday_labels,
1066            CalendarGrid, StatCalendar, MAX_CALENDAR_YEARS,
1067        };
1068        let grid = self
1069            .mapping
1070            .get_mapping(&crate::aes::Aesthetic::X)
1071            .and_then(|c| self.data.column(c))
1072            .and_then(|col| {
1073                CalendarGrid::from_days(col.iter().filter_map(day_number), monday_first)
1074            });
1075        let Some((grid, clipped)) = grid else {
1076            // No dates: an empty calendar layer (renders an empty panel).
1077            return self.add_geom_with(tile).stat(StatCalendar {
1078                grid: None,
1079                monday_first,
1080            });
1081        };
1082        if clipped {
1083            self.warnings.push(format!(
1084                "geom_calendar: dates more than {MAX_CALENDAR_YEARS} years before the newest were dropped"
1085            ));
1086        }
1087        let (xb, _) = month_breaks(&grid);
1088        // Rows: y = 6 - weekday; label Mon/Wed/Fri.
1089        let yb = if monday_first {
1090            vec![6.0, 4.0, 2.0]
1091        } else {
1092            vec![5.0, 3.0, 1.0]
1093        };
1094        let segs = month_boundaries(&grid);
1095        let col = |f: fn(&(f64, f64, f64, f64)) -> f64| -> Vec<Value> {
1096            segs.iter().map(|s| Value::Float(f(s))).collect()
1097        };
1098        let boundaries = vec![
1099            ("x".to_string(), col(|s| s.0)),
1100            ("y".to_string(), col(|s| s.1)),
1101            ("xend".to_string(), col(|s| s.2)),
1102            ("yend".to_string(), col(|s| s.3)),
1103        ];
1104        self.default_aspect_ratio = Some(7.0 / grid.n_weeks().max(1) as f64);
1105        let plot = self
1106            .add_geom_with(tile)
1107            .stat(StatCalendar {
1108                grid: Some(grid),
1109                monday_first,
1110            })
1111            .scale_x_continuous(
1112                ScaleContinuous::new()
1113                    .with_breaks(xb)
1114                    // Month / weekday names follow the plot locale.
1115                    .with_localized_labels(move |l| month_breaks_localized(&grid, l).1)
1116                    .with_expand(0.0, 0.0),
1117            )
1118            .scale_y_continuous(
1119                ScaleContinuous::new()
1120                    .with_breaks(yb)
1121                    .with_localized_labels(weekday_labels)
1122                    .with_expand(0.0, 0.0),
1123            );
1124        if segs.is_empty() {
1125            return plot;
1126        }
1127        // Styled by the theme's `calendar_month_border` at draw time.
1128        plot.add_geom_with(crate::geom::segment::GeomCalendarBorder)
1129            .layer_data(boundaries)
1130            .layer_aes(Aes::new().x("x").y("y").xend("xend").yend("yend"))
1131            .show_legend(false)
1132    }
1133
1134    pub fn geom_blank(self) -> Self {
1135        self.add_geom(GeomBlank)
1136    }
1137
1138    /// Add a geom configured explicitly by the caller: its colours win over
1139    /// the theme's `primary_color`.
1140    fn add_geom_with(self, geom: impl Geom + 'static) -> Self {
1141        let mut plot = self.add_geom(geom);
1142        if let Some(layer) = plot.layers.last_mut() {
1143            layer.explicit_style = true;
1144        }
1145        plot
1146    }
1147
1148    fn add_geom(mut self, geom: impl Geom + 'static) -> Self {
1149        let stat = geom.default_stat();
1150        let position = geom.default_position();
1151        let params = geom.default_params();
1152        self.layers.push(Layer {
1153            data: None,
1154            mapping: Aes::default(),
1155            geom: Box::new(geom),
1156            stat,
1157            position,
1158            params,
1159            show_legend: None,
1160            explicit_style: false,
1161            tooltip_template: None,
1162        });
1163        self
1164    }
1165
1166    // ─── Layer-level overrides ──────────────────────────────────
1167
1168    /// Override the stat for the most recently added layer.
1169    pub fn stat(mut self, stat: impl Stat + 'static) -> Self {
1170        if let Some(layer) = self.layers.last_mut() {
1171            layer.stat = Box::new(stat);
1172        }
1173        self
1174    }
1175
1176    /// Override the position for the most recently added layer.
1177    pub fn position(mut self, pos: impl Position + 'static) -> Self {
1178        if let Some(layer) = self.layers.last_mut() {
1179            layer.position = Box::new(pos);
1180        }
1181        self
1182    }
1183
1184    /// Override the data for the most recently added layer.
1185    pub fn layer_data(mut self, data: impl GGData) -> Self {
1186        if let Some(layer) = self.layers.last_mut() {
1187            layer.data = Some(data.into_dataframe());
1188        }
1189        self
1190    }
1191
1192    /// Override the aesthetic mapping for the most recently added layer.
1193    pub fn layer_aes(mut self, mapping: Aes) -> Self {
1194        self.warnings.extend(mapping.warnings.iter().cloned());
1195        if let Some(layer) = self.layers.last_mut() {
1196            layer.mapping = mapping;
1197        }
1198        self
1199    }
1200
1201    /// A `{column}` tooltip template for the most recently added layer
1202    /// (B2-5), replacing its default `<title>`. Each placeholder is filled
1203    /// from the *built* layer data (so it can reference a stat-computed
1204    /// column, e.g. a hex's `count`) at that row, formatted through the
1205    /// column's scale when it has one
1206    /// ([`Scale::format_value`](crate::scale::Scale::format_value)) — the same
1207    /// `data-fvalue` logic, but written into the tooltip text. A column
1208    /// that doesn't exist on this layer renders as empty, not literally
1209    /// `{column}`. `{{`/`}}` are literal braces. The result is escaped the
1210    /// same way any other tooltip is — this is plain text, not markup.
1211    pub fn tooltip(mut self, template: &str) -> Self {
1212        if let Some(layer) = self.layers.last_mut() {
1213            layer.tooltip_template = Some(template.to_string());
1214        }
1215        self
1216    }
1217
1218    /// Control whether the most recently added layer contributes to the legend.
1219    /// `true` = always show, `false` = always hide, default (None) = auto.
1220    pub fn show_legend(mut self, show: bool) -> Self {
1221        if let Some(layer) = self.layers.last_mut() {
1222            layer.show_legend = Some(show);
1223        }
1224        self
1225    }
1226
1227    /// Mark the most recently added layer as purely decorative (#161): its
1228    /// drawn marks keep their own visual style but carry **no** per-mark
1229    /// metadata — no `<title>`, no `data-x`/`data-series`/`data-value`/
1230    /// `data-fvalue`, and no [`data_attr`](crate::aes::Aes::data_attr)
1231    /// attributes — so an SVG consumer that treats "has a non-empty
1232    /// `<title>`" as "is a selectable data point" doesn't pick it up. Meant
1233    /// for a layer that annotates another layer's mark rather than drawing
1234    /// data in its own right (e.g. a boxplot's own mean dot, drawn by a
1235    /// separate `geom_point` layer purely to mark the spot the box's own
1236    /// tooltip already names). Each mark also carries
1237    /// `data-part="decorative"` and `aria-hidden="true"` (`data-geom`
1238    /// still names the real geom), so a consumer can select decorative
1239    /// marks positively rather than only infer them from what they lack.
1240    /// Their class is `gg-mark gg-decorative`, not the geom's own
1241    /// (`gg-point`, `gg-line`, …).
1242    ///
1243    /// A `.tooltip(...)` template on a decorative layer has no visible
1244    /// effect — the geom's marks carry no tooltip regardless — and warns,
1245    /// like the existing has-no-effect warning for a geom that doesn't
1246    /// read tooltips at all.
1247    ///
1248    /// A decorative layer also defaults to hidden in the legend (it's
1249    /// annotating, not its own series), unless
1250    /// [`show_legend`](Self::show_legend)`(true)` is called — before or
1251    /// after `decorative()` — to opt back in; an explicit
1252    /// `show_legend(false)` is left untouched either way.
1253    pub fn decorative(mut self) -> Self {
1254        if let Some(layer) = self.layers.last_mut() {
1255            let geom = std::mem::replace(&mut layer.geom, Box::new(GeomBlank));
1256            layer.geom = Box::new(crate::geom::decorative::Decorative(geom));
1257            if layer.show_legend.is_none() {
1258                layer.show_legend = Some(false);
1259            }
1260        }
1261        self
1262    }
1263
1264    /// Add one SVG attribute to **every mark** of the most recently added
1265    /// layer (#221) — e.g. to make a label layer drawn over node boxes inert,
1266    /// so hover/click and screen readers pass through to the box below:
1267    ///
1268    /// ```
1269    /// # use ggplot_rs::{aes::Aes, data::Value, GGPlot};
1270    /// # let data = vec![("x".to_string(), vec![Value::Float(1.0)]),
1271    /// #   ("y".to_string(), vec![Value::Float(1.0)]),
1272    /// #   ("l".to_string(), vec![Value::Str("a".into())])];
1273    /// let svg = GGPlot::new(data)
1274    ///     .aes(Aes::new().x("x").y("y").label("l"))
1275    ///     .geom_text()
1276    ///     .layer_svg_attr("aria-hidden", "true")
1277    ///     .layer_svg_attr("pointer-events", "none")
1278    ///     .layer_svg_attr("class", "node-label")
1279    ///     .render_svg_native_with_size(200, 200)
1280    ///     .unwrap();
1281    /// assert!(svg.contains(r#"aria-hidden="true" pointer-events="none""#));
1282    /// ```
1283    ///
1284    /// The allow-list is deliberately small: `class` (space-separated
1285    /// tokens, each `[A-Za-z][A-Za-z0-9_-]*`, appended to the mark's own
1286    /// `gg-mark gg-<kind>` class; invalid tokens are dropped), `aria-hidden`
1287    /// (`"true"`/`"false"`), `pointer-events` (an SVG keyword such as
1288    /// `"none"`), and `data-*` (`Aes::data_attr`'s name rules; a row's own
1289    /// `data_attr` of the same name wins). Anything else — in particular
1290    /// `style` and `on*` handlers, so a strict CSP without `unsafe-inline`
1291    /// still holds — is ignored with a build warning, as is a bad value or a
1292    /// call before any layer. A later call with the same name replaces the
1293    /// earlier value. Values are escaped on output. For a per-row class use
1294    /// [`Aes::class`](crate::aes::Aes::class); to wrap several layers' marks
1295    /// per key in one `<g>`, [`Aes::group_wrap`](crate::aes::Aes::group_wrap).
1296    /// SVG backend only.
1297    pub fn layer_svg_attr(mut self, name: impl Into<String>, value: impl Into<String>) -> Self {
1298        let (name, value) = (name.into(), value.into());
1299        let Some(idx) = self.layers.len().checked_sub(1) else {
1300            self.warnings.push(format!(
1301                "layer_svg_attr(\"{name}\") called before any layer was added — ignored"
1302            ));
1303            return self;
1304        };
1305        if let Some((n, v)) =
1306            crate::render::svg_backend::validate_layer_svg_attr(&name, &value, &mut self.warnings)
1307        {
1308            self.layer_svg_attrs
1309                .retain(|(i, k, _)| !(*i == idx && *k == n));
1310            self.layer_svg_attrs.push((idx, n, v));
1311        }
1312        self
1313    }
1314
1315    /// [`layer_svg_attr`](Self::layer_svg_attr) for several attributes at once.
1316    pub fn layer_svg_attrs<N: Into<String>, V: Into<String>>(
1317        self,
1318        attrs: impl IntoIterator<Item = (N, V)>,
1319    ) -> Self {
1320        attrs
1321            .into_iter()
1322            .fold(self, |p, (n, v)| p.layer_svg_attr(n, v))
1323    }
1324
1325    // ─── Scales ──────────────────────────────────────────────────
1326
1327    pub fn scale_x_continuous(mut self, s: ScaleContinuous) -> Self {
1328        let s = s.for_aesthetic(crate::aes::Aesthetic::X);
1329        self.scales.push(Box::new(s));
1330        self
1331    }
1332
1333    pub fn scale_y_continuous(mut self, s: ScaleContinuous) -> Self {
1334        let s = s.for_aesthetic(crate::aes::Aesthetic::Y);
1335        self.scales.push(Box::new(s));
1336        self
1337    }
1338
1339    pub fn scale_x_discrete(mut self, s: crate::scale::discrete::ScaleDiscrete) -> Self {
1340        let s = s.for_aesthetic(crate::aes::Aesthetic::X);
1341        self.scales.push(Box::new(s));
1342        self
1343    }
1344
1345    pub fn scale_y_discrete(mut self, s: crate::scale::discrete::ScaleDiscrete) -> Self {
1346        let s = s.for_aesthetic(crate::aes::Aesthetic::Y);
1347        self.scales.push(Box::new(s));
1348        self
1349    }
1350
1351    pub fn scale_color(mut self, s: impl Scale + 'static) -> Self {
1352        self.scales.push(Box::new(s));
1353        self
1354    }
1355
1356    pub fn scale_fill(mut self, s: impl Scale + 'static) -> Self {
1357        self.scales.push(Box::new(s));
1358        self
1359    }
1360
1361    pub fn scale_color_manual(self, values: Vec<(&str, crate::scale::color::RGBAColor)>) -> Self {
1362        let s = crate::scale::manual::ScaleManual::new(crate::aes::Aesthetic::Color, values);
1363        self.scale_color(s)
1364    }
1365
1366    pub fn scale_fill_manual(self, values: Vec<(&str, crate::scale::color::RGBAColor)>) -> Self {
1367        let s = crate::scale::manual::ScaleManual::new(crate::aes::Aesthetic::Fill, values);
1368        self.scale_fill(s)
1369    }
1370
1371    /// The `colour`/`color` column already holds `#RRGGBB`/`#RGB` hex
1372    /// strings, used as-is — no legend (R's `scale_colour_identity`).
1373    pub fn scale_colour_identity(self) -> Self {
1374        let s = crate::scale::identity::ScaleColorIdentity::new(crate::aes::Aesthetic::Color);
1375        self.scale_color(s)
1376    }
1377
1378    /// American-spelling alias for [`scale_colour_identity`](Self::scale_colour_identity).
1379    pub fn scale_color_identity(self) -> Self {
1380        self.scale_colour_identity()
1381    }
1382
1383    /// The `fill` column already holds `#RRGGBB`/`#RGB` hex strings, used
1384    /// as-is — no legend (R's `scale_fill_identity`).
1385    pub fn scale_fill_identity(self) -> Self {
1386        let s = crate::scale::identity::ScaleColorIdentity::new(crate::aes::Aesthetic::Fill);
1387        self.scale_fill(s)
1388    }
1389
1390    pub fn scale_color_viridis(self) -> Self {
1391        use crate::scale::color::ScaleColorDiscrete;
1392        use crate::scale::palettes::PaletteName;
1393        let s = ScaleColorDiscrete::new(crate::aes::Aesthetic::Color)
1394            .with_named_palette(&PaletteName::Viridis);
1395        self.scale_color(s)
1396    }
1397
1398    pub fn scale_color_brewer(self, name: crate::scale::palettes::PaletteName) -> Self {
1399        use crate::scale::color::ScaleColorDiscrete;
1400        let s = ScaleColorDiscrete::new(crate::aes::Aesthetic::Color).with_named_palette(&name);
1401        self.scale_color(s)
1402    }
1403
1404    pub fn scale_color_gradient(
1405        self,
1406        low: crate::scale::color::RGBAColor,
1407        high: crate::scale::color::RGBAColor,
1408    ) -> Self {
1409        use crate::scale::color::ScaleColorContinuous;
1410        let s = ScaleColorContinuous::new(crate::aes::Aesthetic::Color).with_colors(low, high);
1411        self.scale_color(s)
1412    }
1413
1414    pub fn scale_fill_gradient(
1415        self,
1416        low: crate::scale::color::RGBAColor,
1417        high: crate::scale::color::RGBAColor,
1418    ) -> Self {
1419        use crate::scale::color::ScaleColorContinuous;
1420        let s = ScaleColorContinuous::new(crate::aes::Aesthetic::Fill).with_colors(low, high);
1421        self.scale_fill(s)
1422    }
1423
1424    pub fn scale_color_gradient2(
1425        self,
1426        low: crate::scale::color::RGBAColor,
1427        mid: crate::scale::color::RGBAColor,
1428        high: crate::scale::color::RGBAColor,
1429    ) -> Self {
1430        use crate::scale::gradient::ScaleColorGradient2;
1431        let s = ScaleColorGradient2::new(crate::aes::Aesthetic::Color).with_colors(low, mid, high);
1432        self.scale_color(s)
1433    }
1434
1435    pub fn scale_fill_gradient2(
1436        self,
1437        low: crate::scale::color::RGBAColor,
1438        mid: crate::scale::color::RGBAColor,
1439        high: crate::scale::color::RGBAColor,
1440    ) -> Self {
1441        use crate::scale::gradient::ScaleColorGradient2;
1442        let s = ScaleColorGradient2::new(crate::aes::Aesthetic::Fill).with_colors(low, mid, high);
1443        self.scale_fill(s)
1444    }
1445
1446    pub fn scale_fill_viridis(self) -> Self {
1447        use crate::scale::color::ScaleColorDiscrete;
1448        use crate::scale::palettes::PaletteName;
1449        let s = ScaleColorDiscrete::new(crate::aes::Aesthetic::Fill)
1450            .with_named_palette(&PaletteName::Viridis);
1451        self.scale_fill(s)
1452    }
1453
1454    /// Continuous viridis color scale (for numeric data).
1455    pub fn scale_color_viridis_c(self) -> Self {
1456        use crate::scale::gradient_n::ScaleColorGradientN;
1457        let s = ScaleColorGradientN::viridis(crate::aes::Aesthetic::Color);
1458        self.scale_color(s)
1459    }
1460
1461    /// Continuous viridis fill scale (for numeric data).
1462    pub fn scale_fill_viridis_c(self) -> Self {
1463        use crate::scale::gradient_n::ScaleColorGradientN;
1464        let s = ScaleColorGradientN::viridis(crate::aes::Aesthetic::Fill);
1465        self.scale_fill(s)
1466    }
1467
1468    /// N-stop continuous color gradient.
1469    pub fn scale_color_gradientn(self, stops: Vec<(f64, crate::scale::color::RGBAColor)>) -> Self {
1470        use crate::scale::gradient_n::ScaleColorGradientN;
1471        let s = ScaleColorGradientN::new(crate::aes::Aesthetic::Color, stops);
1472        self.scale_color(s)
1473    }
1474
1475    /// N-stop continuous fill gradient.
1476    pub fn scale_fill_gradientn(self, stops: Vec<(f64, crate::scale::color::RGBAColor)>) -> Self {
1477        use crate::scale::gradient_n::ScaleColorGradientN;
1478        let s = ScaleColorGradientN::new(crate::aes::Aesthetic::Fill, stops);
1479        self.scale_fill(s)
1480    }
1481
1482    /// Binned (stepped) two-colour continuous colour scale — buckets the mapped
1483    /// variable into `n_bins` bins, each a discrete colour, with a stepped legend.
1484    pub fn scale_color_steps(
1485        self,
1486        low: crate::scale::color::RGBAColor,
1487        high: crate::scale::color::RGBAColor,
1488        n_bins: usize,
1489    ) -> Self {
1490        let s = crate::scale::steps::ScaleColorSteps::two(
1491            crate::aes::Aesthetic::Color,
1492            (low.r, low.g, low.b),
1493            (high.r, high.g, high.b),
1494            n_bins,
1495        );
1496        self.scale_color(s)
1497    }
1498
1499    /// Binned N-stop continuous colour scale.
1500    pub fn scale_color_stepsn(
1501        self,
1502        stops: Vec<crate::scale::color::RGBAColor>,
1503        n_bins: usize,
1504    ) -> Self {
1505        let s = crate::scale::steps::ScaleColorSteps::new(
1506            crate::aes::Aesthetic::Color,
1507            stops.iter().map(|c| (c.r, c.g, c.b)).collect(),
1508            n_bins,
1509        );
1510        self.scale_color(s)
1511    }
1512
1513    /// Binned ColorBrewer colour scale (R's `scale_color_fermenter`).
1514    pub fn scale_color_fermenter(
1515        self,
1516        name: crate::scale::palettes::PaletteName,
1517        n_bins: usize,
1518    ) -> Self {
1519        let stops = crate::scale::palettes::palette(&name)
1520            .iter()
1521            .map(|c| (c.r, c.g, c.b))
1522            .collect();
1523        let s =
1524            crate::scale::steps::ScaleColorSteps::new(crate::aes::Aesthetic::Color, stops, n_bins);
1525        self.scale_color(s)
1526    }
1527
1528    /// Binned (stepped) two-colour continuous fill scale.
1529    pub fn scale_fill_steps(
1530        self,
1531        low: crate::scale::color::RGBAColor,
1532        high: crate::scale::color::RGBAColor,
1533        n_bins: usize,
1534    ) -> Self {
1535        let s = crate::scale::steps::ScaleColorSteps::two(
1536            crate::aes::Aesthetic::Fill,
1537            (low.r, low.g, low.b),
1538            (high.r, high.g, high.b),
1539            n_bins,
1540        );
1541        self.scale_fill(s)
1542    }
1543
1544    /// Binned fill scale with *explicit* class breaks (`n + 1` increasing
1545    /// edges, `n` colours interpolated `low`→`high`) — R's
1546    /// `scale_fill_steps(breaks = ...)`, backing a `::CLASSES` choropleth
1547    /// (sipemu/ggplot-rs#180). The legend shows each class's trained count
1548    /// (`"10–50 (12)"`) and, when any value is `Value::Na`, a "no data" key
1549    /// in `na_color` (default grey50) — see
1550    /// [`ScaleFillBinned`](crate::scale::binned::ScaleFillBinned) for the
1551    /// `.na_color(..)`/`.na_label(..)` builders. The break maths itself
1552    /// (quantile/Jenks/equal-width/custom edges) is the caller's job; this
1553    /// only bins against the edges it's given.
1554    pub fn scale_fill_binned(
1555        self,
1556        breaks: Vec<f64>,
1557        low: crate::scale::color::RGBAColor,
1558        high: crate::scale::color::RGBAColor,
1559    ) -> Self {
1560        let s = crate::scale::binned::ScaleFillBinned::two(
1561            crate::aes::Aesthetic::Fill,
1562            breaks,
1563            (low.r, low.g, low.b),
1564            (high.r, high.g, high.b),
1565        );
1566        self.scale_fill(s)
1567    }
1568
1569    /// Like [`scale_fill_binned`](Self::scale_fill_binned), with an
1570    /// explicit colour per bin (`colors.len()` should be
1571    /// `breaks.len() - 1`) instead of a two-colour interpolation.
1572    pub fn scale_fill_binned_with(
1573        self,
1574        breaks: Vec<f64>,
1575        colors: Vec<crate::scale::color::RGBAColor>,
1576    ) -> Self {
1577        let s = crate::scale::binned::ScaleFillBinned::new(
1578            crate::aes::Aesthetic::Fill,
1579            breaks,
1580            colors.iter().map(|c| (c.r, c.g, c.b)).collect(),
1581        );
1582        self.scale_fill(s)
1583    }
1584
1585    /// Binned ColorBrewer fill scale.
1586    pub fn scale_fill_fermenter(
1587        self,
1588        name: crate::scale::palettes::PaletteName,
1589        n_bins: usize,
1590    ) -> Self {
1591        let stops = crate::scale::palettes::palette(&name)
1592            .iter()
1593            .map(|c| (c.r, c.g, c.b))
1594            .collect();
1595        let s =
1596            crate::scale::steps::ScaleColorSteps::new(crate::aes::Aesthetic::Fill, stops, n_bins);
1597        self.scale_fill(s)
1598    }
1599
1600    /// Default discrete fill palette with levels in sorted order, so a level
1601    /// keeps its colour across charts regardless of data order. For a custom
1602    /// palette: `scale_fill(ScaleColorDiscrete::new(Aesthetic::Fill)
1603    /// .with_palette(p).sorted())`.
1604    pub fn scale_fill_discrete_sorted(self) -> Self {
1605        use crate::scale::color::ScaleColorDiscrete;
1606        self.scale_fill(ScaleColorDiscrete::new(crate::aes::Aesthetic::Fill).sorted())
1607    }
1608
1609    /// Default discrete colour palette with levels in sorted order (see
1610    /// [`scale_fill_discrete_sorted`](Self::scale_fill_discrete_sorted)).
1611    pub fn scale_color_discrete_sorted(self) -> Self {
1612        use crate::scale::color::ScaleColorDiscrete;
1613        self.scale_color(ScaleColorDiscrete::new(crate::aes::Aesthetic::Color).sorted())
1614    }
1615
1616    pub fn scale_fill_brewer(self, name: crate::scale::palettes::PaletteName) -> Self {
1617        use crate::scale::color::ScaleColorDiscrete;
1618        let s = ScaleColorDiscrete::new(crate::aes::Aesthetic::Fill).with_named_palette(&name);
1619        self.scale_fill(s)
1620    }
1621
1622    pub fn scale_linetype_manual(
1623        self,
1624        values: Vec<(&str, crate::render::backend::Linetype)>,
1625    ) -> Self {
1626        let s = crate::scale::linetype_manual::ScaleLinetypeManual::new(values);
1627        self.scale_color(s)
1628    }
1629
1630    pub fn scale_shape_manual(
1631        self,
1632        values: Vec<(&str, crate::render::backend::PointShape)>,
1633    ) -> Self {
1634        let s = crate::scale::shape_manual::ScaleShapeManual::new(values);
1635        self.scale_color(s)
1636    }
1637
1638    pub fn scale_color_grey(self) -> Self {
1639        let s = crate::scale::grey::ScaleColorGrey::new(crate::aes::Aesthetic::Color);
1640        self.scale_color(s)
1641    }
1642
1643    pub fn scale_fill_grey(self) -> Self {
1644        let s = crate::scale::grey::ScaleColorGrey::new(crate::aes::Aesthetic::Fill);
1645        self.scale_fill(s)
1646    }
1647
1648    pub fn scale_color_grey_with(self, s: crate::scale::grey::ScaleColorGrey) -> Self {
1649        self.scale_color(s)
1650    }
1651
1652    pub fn scale_fill_grey_with(self, s: crate::scale::grey::ScaleColorGrey) -> Self {
1653        self.scale_fill(s)
1654    }
1655
1656    pub fn scale_x_reverse(self) -> Self {
1657        self.scale_x_continuous(ScaleContinuous::new().with_transform(ScaleTransform::Reverse))
1658    }
1659
1660    pub fn scale_y_reverse(self) -> Self {
1661        self.scale_y_continuous(ScaleContinuous::new().with_transform(ScaleTransform::Reverse))
1662    }
1663
1664    pub fn scale_x_datetime(mut self, s: crate::scale::datetime::ScaleDateTime) -> Self {
1665        let s = s.for_aesthetic(crate::aes::Aesthetic::X);
1666        self.scales.push(Box::new(s));
1667        self
1668    }
1669
1670    pub fn scale_y_datetime(mut self, s: crate::scale::datetime::ScaleDateTime) -> Self {
1671        let s = s.for_aesthetic(crate::aes::Aesthetic::Y);
1672        self.scales.push(Box::new(s));
1673        self
1674    }
1675
1676    pub fn scale_size(mut self, s: crate::scale::size::ScaleSizeContinuous) -> Self {
1677        self.scales.push(Box::new(s));
1678        self
1679    }
1680
1681    /// `scale_size_area(max_size)` (ggplot2, B3-6): size scale where
1682    /// *area*, not radius, is proportional to the value, and 0 maps to 0.
1683    /// See [`ScaleSizeContinuous::area`](crate::scale::size::ScaleSizeContinuous::area).
1684    pub fn scale_size_area(mut self, max_size: f64) -> Self {
1685        self.scales.push(Box::new(
1686            crate::scale::size::ScaleSizeContinuous::new().area(max_size),
1687        ));
1688        self
1689    }
1690
1691    pub fn scale_alpha(mut self, s: crate::scale::alpha::ScaleAlphaContinuous) -> Self {
1692        self.scales.push(Box::new(s));
1693        self
1694    }
1695
1696    /// Continuous linewidth scale for the `linewidth` aesthetic (segment,
1697    /// curve, path, line) — see
1698    /// [`ScaleLinewidthContinuous`](crate::scale::linewidth::ScaleLinewidthContinuous).
1699    pub fn scale_linewidth(mut self, s: crate::scale::linewidth::ScaleLinewidthContinuous) -> Self {
1700        self.scales.push(Box::new(s));
1701        self
1702    }
1703
1704    pub fn xlim(self, min: f64, max: f64) -> Self {
1705        self.scale_x_continuous(ScaleContinuous::new().with_limits(min, max))
1706    }
1707
1708    pub fn ylim(self, min: f64, max: f64) -> Self {
1709        self.scale_y_continuous(ScaleContinuous::new().with_limits(min, max))
1710    }
1711
1712    pub fn scale_x_log10(self) -> Self {
1713        self.scale_x_continuous(ScaleContinuous::new().with_transform(ScaleTransform::Log10))
1714    }
1715
1716    pub fn scale_y_log10(self) -> Self {
1717        self.scale_y_continuous(ScaleContinuous::new().with_transform(ScaleTransform::Log10))
1718    }
1719
1720    pub fn scale_x_sqrt(self) -> Self {
1721        self.scale_x_continuous(ScaleContinuous::new().with_transform(ScaleTransform::Sqrt))
1722    }
1723
1724    pub fn scale_y_sqrt(self) -> Self {
1725        self.scale_y_continuous(ScaleContinuous::new().with_transform(ScaleTransform::Sqrt))
1726    }
1727
1728    pub fn scale_x_log2(self) -> Self {
1729        self.scale_x_continuous(ScaleContinuous::new().with_transform(ScaleTransform::Log2))
1730    }
1731
1732    pub fn scale_y_log2(self) -> Self {
1733        self.scale_y_continuous(ScaleContinuous::new().with_transform(ScaleTransform::Log2))
1734    }
1735
1736    pub fn scale_x_ln(self) -> Self {
1737        self.scale_x_continuous(ScaleContinuous::new().with_transform(ScaleTransform::Ln))
1738    }
1739
1740    pub fn scale_y_ln(self) -> Self {
1741        self.scale_y_continuous(ScaleContinuous::new().with_transform(ScaleTransform::Ln))
1742    }
1743
1744    /// Logit-transformed x axis (for proportions in (0, 1)).
1745    pub fn scale_x_logit(self) -> Self {
1746        self.scale_x_continuous(ScaleContinuous::new().with_transform(ScaleTransform::Logit))
1747    }
1748
1749    pub fn scale_y_logit(self) -> Self {
1750        self.scale_y_continuous(ScaleContinuous::new().with_transform(ScaleTransform::Logit))
1751    }
1752
1753    /// Probit-transformed x axis (inverse normal CDF, for proportions in (0, 1)).
1754    pub fn scale_x_probit(self) -> Self {
1755        self.scale_x_continuous(ScaleContinuous::new().with_transform(ScaleTransform::Probit))
1756    }
1757
1758    pub fn scale_y_probit(self) -> Self {
1759        self.scale_y_continuous(ScaleContinuous::new().with_transform(ScaleTransform::Probit))
1760    }
1761
1762    /// Sign-preserving pseudo-log x axis (handles zero and negative values).
1763    pub fn scale_x_pseudo_log(self) -> Self {
1764        self.scale_x_continuous(ScaleContinuous::new().with_transform(ScaleTransform::PseudoLog))
1765    }
1766
1767    pub fn scale_y_pseudo_log(self) -> Self {
1768        self.scale_y_continuous(ScaleContinuous::new().with_transform(ScaleTransform::PseudoLog))
1769    }
1770
1771    /// Reciprocal (1/x) x axis.
1772    pub fn scale_x_reciprocal(self) -> Self {
1773        self.scale_x_continuous(ScaleContinuous::new().with_transform(ScaleTransform::Reciprocal))
1774    }
1775
1776    pub fn scale_y_reciprocal(self) -> Self {
1777        self.scale_y_continuous(ScaleContinuous::new().with_transform(ScaleTransform::Reciprocal))
1778    }
1779
1780    /// Exponential x axis (labels spaced logarithmically).
1781    pub fn scale_x_exp(self) -> Self {
1782        self.scale_x_continuous(ScaleContinuous::new().with_transform(ScaleTransform::Exp))
1783    }
1784
1785    pub fn scale_y_exp(self) -> Self {
1786        self.scale_y_continuous(ScaleContinuous::new().with_transform(ScaleTransform::Exp))
1787    }
1788
1789    /// Box–Cox x axis with the given lambda (x > 0).
1790    pub fn scale_x_boxcox(self, lambda: f64) -> Self {
1791        self.scale_x_continuous(
1792            ScaleContinuous::new().with_transform(ScaleTransform::BoxCox(lambda)),
1793        )
1794    }
1795
1796    pub fn scale_y_boxcox(self, lambda: f64) -> Self {
1797        self.scale_y_continuous(
1798            ScaleContinuous::new().with_transform(ScaleTransform::BoxCox(lambda)),
1799        )
1800    }
1801
1802    // ─── Faceting ─────────────────────────────────────────────────
1803
1804    pub fn facet_wrap(mut self, var: &str, ncol: Option<usize>) -> Self {
1805        self.facet = Facet::Wrap {
1806            var: var.to_string(),
1807            ncol,
1808            scales: FacetScales::Fixed,
1809            labeller: FacetLabeller::default(),
1810        };
1811        self
1812    }
1813
1814    pub fn facet_wrap_free(mut self, var: &str, ncol: Option<usize>, scales: FacetScales) -> Self {
1815        self.facet = Facet::Wrap {
1816            var: var.to_string(),
1817            ncol,
1818            scales,
1819            labeller: FacetLabeller::default(),
1820        };
1821        self
1822    }
1823
1824    pub fn facet_wrap_labeller(
1825        mut self,
1826        var: &str,
1827        ncol: Option<usize>,
1828        labeller: FacetLabeller,
1829    ) -> Self {
1830        self.facet = Facet::Wrap {
1831            var: var.to_string(),
1832            ncol,
1833            scales: FacetScales::Fixed,
1834            labeller,
1835        };
1836        self
1837    }
1838
1839    pub fn facet_grid(mut self, row: Option<&str>, col: Option<&str>) -> Self {
1840        self.facet = Facet::Grid {
1841            row_var: row.map(String::from),
1842            col_var: col.map(String::from),
1843            scales: FacetScales::Fixed,
1844            labeller: FacetLabeller::default(),
1845            space: FacetSpace::Fixed,
1846        };
1847        self
1848    }
1849
1850    pub fn facet_grid_free(
1851        mut self,
1852        row: Option<&str>,
1853        col: Option<&str>,
1854        scales: FacetScales,
1855    ) -> Self {
1856        self.facet = Facet::Grid {
1857            row_var: row.map(String::from),
1858            col_var: col.map(String::from),
1859            scales,
1860            labeller: FacetLabeller::default(),
1861            space: FacetSpace::Fixed,
1862        };
1863        self
1864    }
1865
1866    /// `facet_grid` over **multiple** column variables (R's `rows ~ b + c`):
1867    /// the columns become the combination of the given variables' values.
1868    /// Also accepts free scales + proportional `space` for full parity.
1869    pub fn facet_grid_multi(
1870        mut self,
1871        row: Option<&str>,
1872        cols: &[&str],
1873        scales: FacetScales,
1874        space: FacetSpace,
1875    ) -> Self {
1876        let col_var = if cols.len() > 1 {
1877            // Build a synthetic column that is the interaction of the col vars.
1878            let n = self.data.nrows();
1879            let mut combined = Vec::with_capacity(n);
1880            for i in 0..n {
1881                let parts: Vec<String> = cols
1882                    .iter()
1883                    .map(|c| {
1884                        self.data
1885                            .column(c)
1886                            .and_then(|col| col.get(i))
1887                            .map(|v| v.to_group_key())
1888                            .unwrap_or_default()
1889                    })
1890                    .collect();
1891                combined.push(crate::data::Value::Str(parts.join(" . ")));
1892            }
1893            let name = "__facet_cols__".to_string();
1894            self.data.add_column(name.clone(), combined);
1895            Some(name)
1896        } else {
1897            cols.first().map(|s| s.to_string())
1898        };
1899        self.facet = Facet::Grid {
1900            row_var: row.map(String::from),
1901            col_var,
1902            scales,
1903            labeller: FacetLabeller::default(),
1904            space,
1905        };
1906        self
1907    }
1908
1909    /// `facet_grid` with proportional panel sizing (R's `space =`): panels are
1910    /// sized to their data range. Typically paired with free scales.
1911    pub fn facet_grid_space(
1912        mut self,
1913        row: Option<&str>,
1914        col: Option<&str>,
1915        scales: FacetScales,
1916        space: FacetSpace,
1917    ) -> Self {
1918        self.facet = Facet::Grid {
1919            row_var: row.map(String::from),
1920            col_var: col.map(String::from),
1921            scales,
1922            labeller: FacetLabeller::default(),
1923            space,
1924        };
1925        self
1926    }
1927
1928    pub fn facet_grid_labeller(
1929        mut self,
1930        row: Option<&str>,
1931        col: Option<&str>,
1932        labeller: FacetLabeller,
1933    ) -> Self {
1934        self.facet = Facet::Grid {
1935            row_var: row.map(String::from),
1936            col_var: col.map(String::from),
1937            scales: FacetScales::Fixed,
1938            labeller,
1939            space: FacetSpace::Fixed,
1940        };
1941        self
1942    }
1943
1944    // ─── Coordinates ─────────────────────────────────────────────
1945
1946    pub fn coord_flip(mut self) -> Self {
1947        self.coord = Box::new(CoordFlip);
1948        self
1949    }
1950
1951    pub fn coord_fixed(mut self, ratio: f64) -> Self {
1952        self.coord = Box::new(CoordFixed::new(ratio));
1953        self
1954    }
1955
1956    /// Spatial coordinate system (feature `sf`): equal aspect derived from the
1957    /// data extent, so projected geometry keeps its shape. Pair with a `geom_sf`
1958    /// projection (e.g. Mercator) for a conformal map.
1959    #[cfg(feature = "sf")]
1960    pub fn coord_sf(mut self) -> Self {
1961        self.coord = Box::new(crate::coord::sf::CoordSf::new());
1962        self
1963    }
1964
1965    /// Spatial coordinate system with explicit settings (`xlim`/`ylim`/
1966    /// `expand`) — see [`CoordSf`](crate::coord::sf::CoordSf). Used by
1967    /// consumers overlaying a fragment on their own projected basemap: a
1968    /// fixed pixel window via `xlim`/`ylim` and `expand(false)`.
1969    #[cfg(feature = "sf")]
1970    pub fn coord_sf_with(mut self, coord: crate::coord::sf::CoordSf) -> Self {
1971        self.coord = Box::new(coord);
1972        self
1973    }
1974
1975    /// Whether the rendered root `<svg>` carries `data-plot`/`data-domain`/
1976    /// `data-xdomain`/… (see [`root_data_attrs`](crate::render::svg_backend::root_data_attrs)).
1977    /// Default `true`. A consumer that overlays a rendered fragment
1978    /// (`render_svg_native_at`) on its own basemap — which already has its
1979    /// own root attributes and window — turns this off so the fragment adds
1980    /// none of its own.
1981    pub fn svg_root_data(mut self, on: bool) -> Self {
1982        self.svg_root_data = on;
1983        self
1984    }
1985
1986    /// Add one extra attribute to the root `<svg>` element — a host
1987    /// identifier, an ARIA label, a CSS hook, and so on. Allowed names:
1988    /// `data-*`, `aria-*`, `role`, `id`, `class`, `lang`; anything else, or
1989    /// a name ggplot-rs already writes to the root (`data-plot`,
1990    /// `data-domain`, …, or `data-warnings` when
1991    /// [`svg_warnings_attr`](Self::svg_warnings_attr) is also on), is
1992    /// ignored with a build warning instead of being silently dropped or
1993    /// duplicated. Values are escaped on output. PlotGrid has the same API
1994    /// ([`PlotGrid::svg_attr`](crate::compose::PlotGrid::svg_attr)).
1995    pub fn svg_attr(mut self, name: impl Into<String>, value: impl Into<String>) -> Self {
1996        self.root_attrs.push((name.into(), value.into()));
1997        self
1998    }
1999
2000    /// [`svg_attr`](Self::svg_attr) for several attributes at once.
2001    pub fn svg_attrs(mut self, attrs: impl IntoIterator<Item = (String, String)>) -> Self {
2002        self.root_attrs.extend(attrs);
2003        self
2004    }
2005
2006    /// Emit a `data-warnings` attribute on the root `<svg>` — a JSON array
2007    /// of the build warnings (rows dropped for non-finite positions, an
2008    /// `svg_attr` name rejected, …), `[]` when there are none. Default off,
2009    /// and omitted entirely when off (byte-identical to not calling this).
2010    pub fn svg_warnings_attr(mut self, on: bool) -> Self {
2011        self.svg_warnings_attr = on;
2012        self
2013    }
2014
2015    /// Emit `data-fvalue`/`data-fx` on marks: the value aesthetic's raw
2016    /// data value (y for most geoms, fill for hex/tile) and the x value,
2017    /// each formatted through its scale (currency/locale numbers, ISO or
2018    /// locale dates — [`Scale::format_value`](crate::scale::Scale::format_value))
2019    /// rather than the plain display string `data-value`/`data-x` already
2020    /// carry. `data-fx` is omitted when it would just duplicate `data-x`.
2021    /// Default off, and omitted entirely when off (byte-identical to not
2022    /// calling this).
2023    pub fn svg_formatted_values(mut self, on: bool) -> Self {
2024        self.svg_formatted_values = on;
2025        self
2026    }
2027
2028    /// Give the rendered root `<svg>` an accessible name: a `<title>`
2029    /// written (escaped) as its first child, plus `role="img"` and
2030    /// `aria-labelledby` pointing at that `<title>`'s id — the SVG
2031    /// accessibility practice of naming an image via `role="img"` +
2032    /// `aria-labelledby`/`-describedby` rather than a bare `<title>`,
2033    /// which some screen readers treat as a tooltip instead of a name.
2034    /// Pairs with [`svg_desc`](Self::svg_desc) for a longer description;
2035    /// either may be set alone. Default off (`None`), so output stays
2036    /// byte-identical when this is never called.
2037    pub fn svg_title(mut self, title: impl Into<String>) -> Self {
2038        self.svg_title = Some(title.into());
2039        self
2040    }
2041
2042    /// Give the rendered root `<svg>` an accessible description: a
2043    /// `<desc>` written (escaped) as a first child (after `<title>`, if
2044    /// [`svg_title`](Self::svg_title) is also set), plus `role="img"` and
2045    /// `aria-describedby` pointing at that `<desc>`'s id — e.g. a data
2046    /// summary like "4 regions; highest Bavaria (5.2); 1 without data".
2047    /// Default off (`None`), so output stays byte-identical when this is
2048    /// never called.
2049    pub fn svg_desc(mut self, desc: impl Into<String>) -> Self {
2050        self.svg_desc = Some(desc.into());
2051        self
2052    }
2053
2054    /// Transform the coordinate space at draw time (R's `coord_trans`) — stats are
2055    /// computed on raw data but drawn on non-linear axes. Pass a per-axis
2056    /// [`ScaleTransform`] (e.g. `Some(ScaleTransform::Log10)`), `None` to leave an
2057    /// axis linear.
2058    pub fn coord_trans(mut self, x: Option<ScaleTransform>, y: Option<ScaleTransform>) -> Self {
2059        self.coord = Box::new(crate::coord::trans::CoordTrans::new(x, y));
2060        self
2061    }
2062
2063    /// `coord_trans` on the y-axis only.
2064    pub fn coord_trans_y(self, y: ScaleTransform) -> Self {
2065        self.coord_trans(None, Some(y))
2066    }
2067
2068    /// `coord_trans` on the x-axis only.
2069    pub fn coord_trans_x(self, x: ScaleTransform) -> Self {
2070        self.coord_trans(Some(x), None)
2071    }
2072
2073    /// Zoom into a region without filtering data (unlike xlim/ylim which filter).
2074    pub fn coord_cartesian_zoom(
2075        mut self,
2076        xlim: Option<(f64, f64)>,
2077        ylim: Option<(f64, f64)>,
2078    ) -> Self {
2079        let mut c = CoordCartesian::new();
2080        if let Some((min, max)) = xlim {
2081            c = c.xlim(min, max);
2082        }
2083        if let Some((min, max)) = ylim {
2084            c = c.ylim(min, max);
2085        }
2086        self.coord = Box::new(c);
2087        self
2088    }
2089
2090    pub fn coord_polar(mut self) -> Self {
2091        self.coord = Box::new(CoordPolar::new());
2092        self
2093    }
2094
2095    /// Radar / spider chart coordinates: discrete `x` → spokes, `y` → distance
2096    /// from the centre, straight segments, ring/spoke guides. Pair with
2097    /// `geom_polygon` (closed series) and `geom_point`.
2098    pub fn coord_radar(mut self) -> Self {
2099        self.coord = Box::new(crate::coord::radar::CoordRadar::new());
2100        self
2101    }
2102
2103    pub fn coord_radar_with(mut self, coord: crate::coord::radar::CoordRadar) -> Self {
2104        self.coord = Box::new(coord);
2105        self
2106    }
2107
2108    pub fn coord_polar_with(mut self, coord: CoordPolar) -> Self {
2109        self.coord = Box::new(coord);
2110        self
2111    }
2112
2113    // ─── Theme ───────────────────────────────────────────────────
2114
2115    /// Replace the theme. A `primary_color` set earlier is kept unless the new
2116    /// theme sets its own, so preset/`primary_color` call order doesn't matter.
2117    pub fn theme(mut self, theme: Theme) -> Self {
2118        self.apply_preset(theme);
2119        self
2120    }
2121
2122    fn apply_preset(&mut self, theme: Theme) {
2123        let primary = self.theme.primary;
2124        self.theme = theme;
2125        if self.theme.primary.is_none() {
2126            self.theme.primary = primary;
2127        }
2128    }
2129
2130    /// Rotate the x-axis tick labels by `degrees` (R's
2131    /// `guides(x = guide_axis(angle = ...))` / `axis.text.x = element_text(angle)`).
2132    /// Useful for long category labels. Call after any `theme_*()` preset.
2133    pub fn axis_text_x_angle(mut self, degrees: f64) -> Self {
2134        self.theme.axis_text_x.angle = degrees;
2135        self
2136    }
2137
2138    /// Rotate the y-axis tick labels by `degrees`.
2139    pub fn axis_text_y_angle(mut self, degrees: f64) -> Self {
2140        self.theme.axis_text_y.angle = degrees;
2141        self
2142    }
2143
2144    /// Stagger x-axis tick labels across `n` rows to avoid overlap (R's
2145    /// `guides(x = guide_axis(n.dodge = n))`). `1` = no dodging.
2146    pub fn axis_text_x_dodge(mut self, n: usize) -> Self {
2147        self.theme.axis_text_x_dodge = n.max(1);
2148        self
2149    }
2150
2151    /// Fix the panel's height:width ratio (R's `aspect.ratio`). Call after any
2152    /// `theme_*()` preset.
2153    pub fn aspect_ratio(mut self, ratio: f64) -> Self {
2154        self.theme.aspect_ratio = Some(ratio);
2155        self
2156    }
2157
2158    /// Draw gridlines on top of the data layers (R's `panel.ontop`).
2159    pub fn panel_ontop(mut self) -> Self {
2160        self.theme.panel_ontop = true;
2161        self
2162    }
2163
2164    /// Draw minor tick marks between major ticks (R's `axis.minor.ticks`).
2165    pub fn axis_minor_ticks(mut self) -> Self {
2166        self.theme.axis_minor_ticks = true;
2167        self
2168    }
2169
2170    /// How an outer x-axis label that would cross the SVG edge is handled
2171    /// (B3-1, D6): [`AxisLabelFit::Reserve`](crate::theme::AxisLabelFit::Reserve) (default), `Shift`, or `Clip`
2172    /// (the 0.21 behaviour). Use `Shift` for panels that must stay
2173    /// pixel-aligned with other elements (e.g. a host's own aligned strip).
2174    pub fn axis_text_x_fit(mut self, fit: crate::theme::AxisLabelFit) -> Self {
2175        self.theme.axis_text_x_fit = fit;
2176        self
2177    }
2178
2179    /// Thin crowded x-axis labels in ggplot2's `check.overlap` priority
2180    /// order (B3-1, D5). Off by default.
2181    pub fn axis_text_x_check_overlap(mut self, on: bool) -> Self {
2182        self.theme.axis_text_x_check_overlap = on;
2183        self
2184    }
2185
2186    /// Align title/subtitle/caption to the panel or the whole plot width
2187    /// (R's `plot.title.position`).
2188    pub fn title_position(mut self, pos: crate::theme::TitlePosition) -> Self {
2189        self.theme.title_position = pos;
2190        self
2191    }
2192
2193    /// Corner for the `tag()` label (R's `plot.tag.position`).
2194    pub fn tag_position(mut self, pos: crate::theme::TagPosition) -> Self {
2195        self.theme.tag_position = pos;
2196        self
2197    }
2198
2199    /// Force the legend key layout direction (R's `legend.direction`).
2200    pub fn legend_direction(mut self, dir: crate::theme::LegendDirection) -> Self {
2201        self.theme.legend_direction = Some(dir);
2202        self
2203    }
2204
2205    /// Set the brand/primary color used as the default for single-series geoms
2206    /// that have no color/fill aesthetic mapped. Composes with any theme — one
2207    /// render process can serve different tenants' brands at render time.
2208    /// Place the legend inside the panel at panel-relative coordinates
2209    /// (0..1, 0..1) — `(0,0)` bottom-left, `(1,1)` top-right (R's
2210    /// `legend.position = c(x, y)`).
2211    pub fn legend_position_inside(mut self, x: f64, y: f64) -> Self {
2212        self.theme.legend_position = crate::theme::LegendPosition::Inside(x, y);
2213        self
2214    }
2215
2216    /// Set the legend position (R's `legend.position`, e.g. `Top`/`Bottom`).
2217    pub fn legend_position(mut self, pos: crate::theme::LegendPosition) -> Self {
2218        self.theme.legend_position = pos;
2219        self
2220    }
2221
2222    pub fn primary_color(mut self, color: (u8, u8, u8)) -> Self {
2223        self.theme.primary = Some(color);
2224        self
2225    }
2226
2227    pub fn theme_minimal(mut self) -> Self {
2228        self.apply_preset(crate::theme::presets::theme_minimal());
2229        self
2230    }
2231
2232    pub fn theme_bw(mut self) -> Self {
2233        self.apply_preset(crate::theme::presets::theme_bw());
2234        self
2235    }
2236
2237    pub fn theme_gray(mut self) -> Self {
2238        self.apply_preset(crate::theme::presets::theme_gray());
2239        self
2240    }
2241
2242    pub fn theme_classic(mut self) -> Self {
2243        self.apply_preset(crate::theme::presets::theme_classic());
2244        self
2245    }
2246
2247    /// Apply the publication-ready `theme_pubr()` (ggpubr style).
2248    pub fn theme_pubr(mut self) -> Self {
2249        self.apply_preset(crate::theme::presets::theme_pubr());
2250        self
2251    }
2252
2253    pub fn theme_linedraw(mut self) -> Self {
2254        self.apply_preset(crate::theme::presets::theme_linedraw());
2255        self
2256    }
2257
2258    pub fn theme_light(mut self) -> Self {
2259        self.apply_preset(crate::theme::presets::theme_light());
2260        self
2261    }
2262
2263    pub fn theme_dark(mut self) -> Self {
2264        self.apply_preset(crate::theme::presets::theme_dark());
2265        self
2266    }
2267
2268    pub fn theme_void(mut self) -> Self {
2269        self.apply_preset(crate::theme::presets::theme_void());
2270        self
2271    }
2272
2273    /// Apply incremental theme modifications on top of the current theme.
2274    /// Like R's `+ theme(axis.text.x = element_text(...))`.
2275    pub fn theme_update(mut self, update: crate::theme::ThemeUpdate) -> Self {
2276        self.theme = self.theme.update(update);
2277        self
2278    }
2279
2280    // ─── Guides ──────────────────────────────────────────────────
2281
2282    /// Configure legend guide (title, ncol, reverse).
2283    pub fn guides(mut self, guide: crate::guide::config::GuideLegend) -> Self {
2284        self.guide_legend = guide;
2285        self
2286    }
2287
2288    // ─── Labels ──────────────────────────────────────────────────
2289
2290    pub fn labs(mut self, labels: Labels) -> Self {
2291        if labels.title.is_some() {
2292            self.labels.title = labels.title;
2293        }
2294        if labels.subtitle.is_some() {
2295            self.labels.subtitle = labels.subtitle;
2296        }
2297        if labels.x.is_some() {
2298            self.labels.x = labels.x;
2299        }
2300        if labels.y.is_some() {
2301            self.labels.y = labels.y;
2302        }
2303        if labels.caption.is_some() {
2304            self.labels.caption = labels.caption;
2305        }
2306        if labels.tag.is_some() {
2307            self.labels.tag = labels.tag;
2308        }
2309        self
2310    }
2311
2312    pub fn title(mut self, title: &str) -> Self {
2313        self.labels.title = Some(title.to_string());
2314        self
2315    }
2316
2317    /// Corner tag label (R's `labs(tag = ...)`), drawn at the top-left — handy
2318    /// for labelling figure panels ("A", "B", …).
2319    pub fn tag(mut self, tag: &str) -> Self {
2320        self.labels.tag = Some(tag.to_string());
2321        self
2322    }
2323
2324    pub fn subtitle(mut self, subtitle: &str) -> Self {
2325        self.labels.subtitle = Some(subtitle.to_string());
2326        self
2327    }
2328
2329    pub fn xlab(mut self, label: &str) -> Self {
2330        self.labels.x = Some(label.to_string());
2331        self
2332    }
2333
2334    pub fn ylab(mut self, label: &str) -> Self {
2335        self.labels.y = Some(label.to_string());
2336        self
2337    }
2338
2339    pub fn caption(mut self, caption: &str) -> Self {
2340        self.labels.caption = Some(caption.to_string());
2341        self
2342    }
2343
2344    // ─── Annotations ──────────────────────────────────────────────
2345
2346    /// Add an annotation to the plot.
2347    pub fn annotate(mut self, annotation: Annotation) -> Self {
2348        self.annotations.push(annotation);
2349        self
2350    }
2351
2352    /// Add a text annotation at data coordinates.
2353    pub fn annotate_text(self, label: &str, x: f64, y: f64) -> Self {
2354        self.annotate(Annotation::text(label, x, y))
2355    }
2356
2357    /// Add a rectangle annotation at data coordinates.
2358    pub fn annotate_rect(self, xmin: f64, xmax: f64, ymin: f64, ymax: f64) -> Self {
2359        self.annotate(Annotation::rect(xmin, xmax, ymin, ymax))
2360    }
2361
2362    /// Add a segment annotation between data coordinates.
2363    pub fn annotate_segment(self, x: f64, y: f64, xend: f64, yend: f64) -> Self {
2364        self.annotate(Annotation::segment(x, y, xend, yend))
2365    }
2366
2367    /// Default [`Locale`](crate::locale::Locale) for the plot's axis and
2368    /// legend labels: every continuous position / colour / size / alpha scale
2369    /// labels its default breaks with the locale's decimal and thousands marks
2370    /// (`1.000`, `2,5` in de-DE), and date/datetime scales use its month names
2371    /// and date formats (`Okt. 2026`, `15. Okt. 2026`). A scale's own
2372    /// `with_locale` and custom `label_*` formatters take precedence.
2373    /// Without this call labels are unchanged (`.` decimal mark, no grouping,
2374    /// ISO dates).
2375    ///
2376    /// ```
2377    /// use ggplot_rs::prelude::*;
2378    /// let svg = GGPlot::new(vec![
2379    ///         ("x".to_string(), vec![Value::Float(0.0), Value::Float(3000.0)]),
2380    ///         ("y".to_string(), vec![Value::Float(0.5), Value::Float(2.5)]),
2381    ///     ])
2382    ///     .aes(Aes::new().x("x").y("y"))
2383    ///     .geom_point()
2384    ///     .locale(Locale::from_tag("de-DE"))
2385    ///     .render_svg_native()
2386    ///     .unwrap();
2387    /// assert!(svg.contains(">1.000<") && svg.contains(">1,5<"));
2388    /// ```
2389    pub fn locale(mut self, locale: crate::locale::Locale) -> Self {
2390        self.locale = Some(locale);
2391        self
2392    }
2393
2394    // ─── Build and Render ────────────────────────────────────────
2395
2396    /// Build the plot without rendering, returning errors on validation failure.
2397    pub fn try_build(self) -> Result<crate::build::BuiltPlot, GGError> {
2398        PlotBuilder::build(self)
2399    }
2400
2401    /// Build the plot without rendering (analogous to R's ggplot_build()).
2402    /// Returns the fully computed BuiltPlot with layer data ready for inspection.
2403    /// Panics on validation errors — use `try_build()` for error handling.
2404    pub fn build(self) -> crate::build::BuiltPlot {
2405        self.try_build().expect("plot build failed")
2406    }
2407
2408    /// Build and save the plot to a file. Format determined by extension.
2409    /// (Requires the `plotters` feature, on by default; native only — wasm has
2410    /// no filesystem/plotters backend.)
2411    #[cfg(all(feature = "plotters", not(target_arch = "wasm32")))]
2412    pub fn save(self, path: &str) -> Result<(), GGError> {
2413        self.save_with_size(path, 800, 600)
2414    }
2415
2416    /// Build and save with custom dimensions. (Feature `plotters`; native only.)
2417    #[cfg(all(feature = "plotters", not(target_arch = "wasm32")))]
2418    pub fn save_with_size(self, path: &str, w: u32, h: u32) -> Result<(), GGError> {
2419        let (built, layout) = self.prepare(w, h)?;
2420
2421        // Determine backend from file extension
2422        let ext = path.rsplit('.').next().unwrap_or("svg").to_lowercase();
2423
2424        match ext.as_str() {
2425            "svg" => {
2426                let backend = plotters::prelude::SVGBackend::new(path, (w, h));
2427                Self::render_into(backend.into_drawing_area(), &built, &layout)?;
2428            }
2429            "png" | "bmp" | "gif" | "jpeg" | "jpg" | "tiff" => {
2430                let backend = plotters::prelude::BitMapBackend::new(path, (w, h));
2431                Self::render_into(backend.into_drawing_area(), &built, &layout)?;
2432            }
2433            _ => {
2434                return Err(GGError::UnsupportedFormat(ext));
2435            }
2436        }
2437
2438        Ok(())
2439    }
2440
2441    /// Render the plot to an in-memory SVG document (default 800x600).
2442    ///
2443    /// Unlike [`save`](Self::save), this writes nothing to disk — handy for
2444    /// serving charts from a web/MCP service. Requires the `plotters` feature
2445    /// (on by default); without it use [`render_svg_native`](Self::render_svg_native).
2446    #[cfg(all(feature = "plotters", not(target_arch = "wasm32")))]
2447    pub fn render_svg(self) -> Result<String, GGError> {
2448        self.render_svg_with_size(800, 600)
2449    }
2450
2451    /// Render the plot to an in-memory SVG document with custom dimensions.
2452    /// (Feature `plotters`; native only — on wasm or without `plotters` use
2453    /// [`render_svg_native_with_size`](Self::render_svg_native_with_size).)
2454    #[cfg(all(feature = "plotters", not(target_arch = "wasm32")))]
2455    pub fn render_svg_with_size(self, w: u32, h: u32) -> Result<String, GGError> {
2456        let (built, layout) = self.prepare(w, h)?;
2457        let mut buf = String::new();
2458        {
2459            let backend = plotters::prelude::SVGBackend::with_string(&mut buf, (w, h));
2460            Self::render_into(backend.into_drawing_area(), &built, &layout)?;
2461        }
2462        Ok(buf)
2463    }
2464
2465    /// Render to SVG through the self-contained [`SvgBackend`](crate::render::svg_backend::SvgBackend)
2466    /// — a plotters-free path that drives the same `PlotRenderer`, proving the
2467    /// `DrawBackend` abstraction (and needing no glyph rasterization).
2468    pub fn render_svg_native(self) -> Result<String, GGError> {
2469        self.render_svg_native_with_size(800, 600)
2470    }
2471
2472    /// [`render_svg_native`](Self::render_svg_native) with an explicit size.
2473    ///
2474    /// The root `<svg>` carries `data-plot="x y w h"` (the panel rect) and the
2475    /// trained position domains (`data-domain`, `data-xdomain`, `data-ydomain`,
2476    /// `data-xlevels`, `data-ylevels` — see
2477    /// [`root_data_attrs`](crate::render::svg_backend::root_data_attrs)).
2478    /// Hoverable marks carry `data-x`, `data-series` and `data-value`.
2479    pub fn render_svg_native_with_size(self, w: u32, h: u32) -> Result<String, GGError> {
2480        Ok(self.render_native(w, h)?.0.finish())
2481    }
2482
2483    /// Like [`render_svg_native_with_size`](Self::render_svg_native_with_size),
2484    /// but also returns the build warnings (rows dropped for non-finite
2485    /// positions, layers whose stat produced no data, …) — see
2486    /// [`BuiltPlot::warnings`](crate::build::BuiltPlot::warnings).
2487    pub fn render_svg_native_with_warnings(
2488        self,
2489        w: u32,
2490        h: u32,
2491    ) -> Result<(String, Vec<String>), GGError> {
2492        let (backend, warnings, _) = self.render_native(w, h)?;
2493        Ok((backend.finish(), warnings))
2494    }
2495
2496    /// Render a `w`×`h` chart as a *nested* `<svg x=.. y=.. width=.. height=..
2497    /// viewBox="0 0 w h">` fragment for composition into a larger SVG (a
2498    /// dashboard): no `xmlns` (inherited from the parent), positioned at
2499    /// `(x, y)` in the parent's user space. No string surgery needed:
2500    ///
2501    /// ```ignore
2502    /// let mut page = String::from(r#"<svg xmlns="http://www.w3.org/2000/svg" width="900" height="400">"#);
2503    /// page += &plot_a.render_svg_native_at(0.0, 0.0, 450, 400)?;
2504    /// page += &plot_b.render_svg_native_at(450.0, 0.0, 450, 400)?;
2505    /// page += "</svg>";
2506    /// ```
2507    pub fn render_svg_native_at(self, x: f64, y: f64, w: u32, h: u32) -> Result<String, GGError> {
2508        Ok(self.render_native(w, h)?.0.finish_fragment(x, y))
2509    }
2510
2511    /// Like [`render_svg_native_with_size`](Self::render_svg_native_with_size),
2512    /// but also returns the panel rect in pixels `[x, y, w, h]` — enough to
2513    /// overlay a WebGL/canvas layer that draws marks in data coordinates.
2514    pub fn render_svg_area_with_size(self, w: u32, h: u32) -> Result<(String, [f64; 4]), GGError> {
2515        let (backend, _, pa) = self.render_native(w, h)?;
2516        Ok((backend.finish(), [pa.x, pa.y, pa.width, pa.height]))
2517    }
2518
2519    /// Shared native-SVG pipeline: build, lay out, render into an
2520    /// [`SvgBackend`](crate::render::svg_backend::SvgBackend) (not yet
2521    /// finished), returning it with the build warnings and the panel rect.
2522    fn render_native(
2523        self,
2524        w: u32,
2525        h: u32,
2526    ) -> Result<
2527        (
2528            crate::render::svg_backend::SvgBackend,
2529            Vec<String>,
2530            crate::render::Rect,
2531        ),
2532        GGError,
2533    > {
2534        let svg_root_data = self.svg_root_data;
2535        let root_attrs = self.root_attrs.clone();
2536        let svg_warnings_attr = self.svg_warnings_attr;
2537        let svg_formatted_values = self.svg_formatted_values;
2538        let svg_title = self.svg_title.clone();
2539        let svg_desc = self.svg_desc.clone();
2540        let (built, layout) = self.prepare(w, h)?;
2541        let pa = layout.plot_area.clone();
2542        let mut backend = crate::render::svg_backend::SvgBackend::new(w, h, pa.clone());
2543        backend.set_formatted_values(svg_formatted_values);
2544        backend.set_svg_title(svg_title.clone());
2545        backend.set_svg_desc(svg_desc.clone());
2546        let existing_root_attrs = if svg_root_data {
2547            let attrs = crate::render::svg_backend::root_data_attrs(&built);
2548            backend.set_root_attrs(attrs.clone());
2549            // `data-plot` itself is a literal in `root_open`, not part of
2550            // `root_data_attrs`'s output, but it is written whenever
2551            // `svg_root_data` is on — reserve it too.
2552            let mut reserved = attrs;
2553            reserved.push(("data-plot".to_string(), String::new()));
2554            reserved
2555        } else {
2556            backend.without_plot_attr();
2557            Vec::new()
2558        };
2559        PlotRenderer::render(&built, &mut backend).map_err(GGError::Render)?;
2560        let mut warnings = built.warnings;
2561        warnings.extend(backend.take_warnings());
2562
2563        let mut reserved = existing_root_attrs;
2564        if svg_warnings_attr {
2565            reserved.push(("data-warnings".to_string(), String::new()));
2566        }
2567        // #255: `role`/`aria-labelledby`/`aria-describedby` are written
2568        // directly by `SvgBackend::root_open` when a title/desc was set —
2569        // reserve them so a colliding `svg_attr` call is dropped with a
2570        // warning instead of duplicating the attribute.
2571        if svg_title.is_some() || svg_desc.is_some() {
2572            reserved.push(("role".to_string(), String::new()));
2573        }
2574        if svg_title.is_some() {
2575            reserved.push(("aria-labelledby".to_string(), String::new()));
2576        }
2577        if svg_desc.is_some() {
2578            reserved.push(("aria-describedby".to_string(), String::new()));
2579        }
2580        let extra = crate::render::svg_backend::validate_root_attrs(
2581            &root_attrs,
2582            &reserved,
2583            "plot",
2584            &mut warnings,
2585        );
2586        backend.extend_root_attrs(extra);
2587        if svg_warnings_attr {
2588            backend.extend_root_attrs(vec![(
2589                "data-warnings".to_string(),
2590                crate::render::svg_backend::warnings_json(&warnings),
2591            )]);
2592        }
2593        Ok((backend, warnings, pa))
2594    }
2595
2596    /// Render to a raw RGBA pixel buffer via the self-contained raster
2597    /// [`PixelBackend`](crate::render::pixel_backend::PixelBackend) (feature
2598    /// `canvas`) — fast for large-N and wasm-compatible. Returns `(w, h, rgba)`,
2599    /// ready for a `<canvas>` `putImageData`.
2600    #[cfg(feature = "canvas")]
2601    pub fn render_rgba_with_size(self, w: u32, h: u32) -> Result<(u32, u32, Vec<u8>), GGError> {
2602        let (built, layout) = self.prepare(w, h)?;
2603        let mut backend =
2604            crate::render::pixel_backend::PixelBackend::new(w, h, layout.plot_area.clone());
2605        PlotRenderer::render(&built, &mut backend).map_err(GGError::Render)?;
2606        let (ow, oh) = backend.dimensions();
2607        Ok((ow, oh, backend.into_rgba()))
2608    }
2609
2610    /// Like [`render_rgba_with_size`](Self::render_rgba_with_size), but also
2611    /// returns the panel rect in pixels `[x, y, w, h]` — enough to map a canvas
2612    /// pixel back to data coordinates for interactive hover.
2613    #[cfg(feature = "canvas")]
2614    pub fn render_rgba_area_with_size(
2615        self,
2616        w: u32,
2617        h: u32,
2618    ) -> Result<(Vec<u8>, [f64; 4]), GGError> {
2619        let (built, layout) = self.prepare(w, h)?;
2620        let pa = layout.plot_area.clone();
2621        let mut backend = crate::render::pixel_backend::PixelBackend::new(w, h, pa.clone());
2622        PlotRenderer::render(&built, &mut backend).map_err(GGError::Render)?;
2623        Ok((backend.into_rgba(), [pa.x, pa.y, pa.width, pa.height]))
2624    }
2625
2626    /// Render to PNG bytes via the raster [`PixelBackend`](crate::render::pixel_backend::PixelBackend)
2627    /// (feature `canvas`). Unlike [`render_png`](Self::render_png) this needs no
2628    /// plotters bitmap backend, so it also works on wasm.
2629    #[cfg(feature = "canvas")]
2630    pub fn render_png_raster_with_size(self, w: u32, h: u32) -> Result<Vec<u8>, GGError> {
2631        let (built, layout) = self.prepare(w, h)?;
2632        let mut backend =
2633            crate::render::pixel_backend::PixelBackend::new(w, h, layout.plot_area.clone());
2634        PlotRenderer::render(&built, &mut backend).map_err(GGError::Render)?;
2635        backend.into_png().map_err(GGError::Render)
2636    }
2637
2638    /// Render the plot to in-memory PNG bytes (default 800x600).
2639    ///
2640    /// Returns a fully-encoded PNG, ready to write to an HTTP response or
2641    /// embed as a data URI — no temp files involved. Requires the `plotters`
2642    /// feature (on by default); see also `render_png_raster_with_size`
2643    /// (feature `canvas`).
2644    #[cfg(all(feature = "plotters", not(target_arch = "wasm32")))]
2645    pub fn render_png(self) -> Result<Vec<u8>, GGError> {
2646        self.render_png_with_size(800, 600)
2647    }
2648
2649    /// Render the plot to in-memory PNG bytes with custom dimensions. (Feature
2650    /// `plotters`; native only — PNG needs the plotters bitmap backend.)
2651    #[cfg(all(feature = "plotters", not(target_arch = "wasm32")))]
2652    pub fn render_png_with_size(self, w: u32, h: u32) -> Result<Vec<u8>, GGError> {
2653        let (built, layout) = self.prepare(w, h)?;
2654
2655        // plotters' BitMapBackend draws into a raw RGB buffer; we then encode
2656        // that buffer to PNG via the `image` crate.
2657        let mut rgb = vec![0u8; (w as usize) * (h as usize) * 3];
2658        {
2659            let backend = plotters::prelude::BitMapBackend::with_buffer(&mut rgb, (w, h));
2660            Self::render_into(backend.into_drawing_area(), &built, &layout)?;
2661        }
2662
2663        let img = image::RgbImage::from_raw(w, h, rgb).ok_or_else(|| {
2664            GGError::Render(RenderError::BackendError(
2665                "PNG buffer size mismatch".to_string(),
2666            ))
2667        })?;
2668        let mut out = std::io::Cursor::new(Vec::new());
2669        img.write_to(&mut out, image::ImageOutputFormat::Png)
2670            .map_err(|e| GGError::Render(RenderError::BackendError(format!("{:?}", e))))?;
2671        Ok(out.into_inner())
2672    }
2673
2674    /// Shared pipeline: build the plot, apply label overrides, compute layout.
2675    pub(crate) fn prepare(
2676        self,
2677        w: u32,
2678        h: u32,
2679    ) -> Result<(crate::build::BuiltPlot, PlotLayout), GGError> {
2680        let (mut built, meta) = self.build_for_render()?;
2681        let layout = Self::layout_built(&mut built, &meta, w, h);
2682        Ok((built, layout))
2683    }
2684
2685    /// The size-independent half of [`prepare`](Self::prepare): build the plot,
2686    /// resolve theme inheritance and apply axis-label overrides.
2687    pub(crate) fn build_for_render(self) -> Result<(crate::build::BuiltPlot, RenderMeta), GGError> {
2688        let plot = self;
2689
2690        let has_title = plot.labels.title.is_some();
2691        let has_subtitle = plot.labels.subtitle.is_some();
2692        let has_caption = plot.labels.caption.is_some();
2693        let has_legend = plot.has_legend_mapping();
2694        let x_label = plot.labels.x.clone();
2695        let y_label = plot.labels.y.clone();
2696
2697        let mut built = PlotBuilder::build(plot)?;
2698
2699        // Resolve R-style theme inheritance (root `text` → child text elements).
2700        built.theme.resolve_inheritance();
2701
2702        // Apply user label overrides to scales
2703        if let Some(ref label) = x_label {
2704            if let Some(s) = built.scales.get_mut(&crate::aes::Aesthetic::X) {
2705                s.set_name(label);
2706            }
2707        }
2708        if let Some(ref label) = y_label {
2709            if let Some(s) = built.scales.get_mut(&crate::aes::Aesthetic::Y) {
2710                s.set_name(label);
2711            }
2712        }
2713
2714        Ok((
2715            built,
2716            RenderMeta {
2717                has_title,
2718                has_subtitle,
2719                has_caption,
2720                has_legend,
2721            },
2722        ))
2723    }
2724
2725    /// The size-dependent half of [`prepare`](Self::prepare): auto-tune axis
2726    /// labels for `w`×`h` and compute the layout.
2727    pub(crate) fn layout_built(
2728        built: &mut crate::build::BuiltPlot,
2729        meta: &RenderMeta,
2730        w: u32,
2731        h: u32,
2732    ) -> PlotLayout {
2733        let RenderMeta {
2734            has_title,
2735            has_subtitle,
2736            has_caption,
2737            has_legend,
2738        } = *meta;
2739        let x_axis_top = built
2740            .scales
2741            .get(&crate::aes::Aesthetic::X)
2742            .map(|s| s.axis_position_opposite())
2743            .unwrap_or(false);
2744
2745        // Auto-tune categorical axis labels so they stay legible. A flip swaps
2746        // which trained scale sits on each visual axis: the bottom (x) axis reads
2747        // the Y scale when flipped, the left (y) axis reads the X scale.
2748        let flipped = built.coord.is_flipped();
2749        let h_aes = if flipped {
2750            crate::aes::Aesthetic::Y
2751        } else {
2752            crate::aes::Aesthetic::X
2753        };
2754        let v_aes = if flipped {
2755            crate::aes::Aesthetic::X
2756        } else {
2757            crate::aes::Aesthetic::Y
2758        };
2759
2760        // The bottom-axis break labels that are actually drawn — a
2761        // `with_labels` override can blank some entries ("" ) to thin a
2762        // crowded discrete axis while keeping every position (sipemu/
2763        // ggplot-rs#162); a blanked entry is never drawn, so it never
2764        // competes for horizontal room or vertical drop below. Shared by
2765        // the auto-rotate decision and the rotated-label height reservation
2766        // just below, so both agree on what's actually on the canvas.
2767        let drawn_x_labels: Vec<(f64, String)> = built
2768            .scales
2769            .get(&h_aes)
2770            .map(|hs| {
2771                hs.breaks()
2772                    .into_iter()
2773                    .filter(|(_, label)| !label.is_empty())
2774                    .collect()
2775            })
2776            .unwrap_or_default();
2777        let h_is_discrete = built
2778            .scales
2779            .get(&h_aes)
2780            .map(|hs| hs.is_discrete())
2781            .unwrap_or(false);
2782
2783        // Crowded discrete bottom-axis labels overlap when drawn flat. Rotate
2784        // them — −45° if that de-collides, otherwise vertical (270°) — only
2785        // when no explicit angle was set (angle.abs() < 1.0 reads as
2786        // "unset"). Sized from the real, measured widths of the labels that
2787        // are actually drawn (`render::text::text_width`), both for the
2788        // label-width estimate and the crowding count `n` — #162's bug was
2789        // counting every break, blanked or not, which over-triggered the
2790        // rotation. −45° (not +45°) is ggplot2's own idiom (`angle = 45,
2791        // hjust = 1`, mirrored to this library's angle sign, sipemu/
2792        // ggplot-rs#163): it ends each label at its own tick and slants
2793        // down-left, rather than +45°'s down-right slant, which — anchored
2794        // the same way — would start under the *previous* category.
2795        if built.theme.axis_text_x.visible
2796            && built.theme.axis_text_x.angle.abs() < 1.0
2797            && h_is_discrete
2798            && !drawn_x_labels.is_empty()
2799        {
2800            let font = built.theme.axis_text_x.size;
2801            let max_w = drawn_x_labels
2802                .iter()
2803                .map(|(_, l)| crate::render::text::text_width(l, font))
2804                .fold(0.0_f64, f64::max);
2805            let n = drawn_x_labels.len() as f64;
2806            let spacing = 0.9 * w as f64 / n; // ~px between adjacent *drawn* labels
2807            if max_w > spacing {
2808                // 45° shrinks the horizontal footprint by cos45; if that
2809                // still doesn't fit, go vertical (270° = reads bottom-to-
2810                // top, hanging below the axis like the y-axis title).
2811                let vertical = max_w * std::f64::consts::FRAC_1_SQRT_2 > spacing;
2812                built.theme.axis_text_x.angle = if vertical { 270.0 } else { -45.0 };
2813            }
2814        }
2815
2816        // Whenever the bottom-axis labels end up rotated — by the
2817        // auto-rotate decision just above, or by an explicit
2818        // `axis_text_x_angle` call — reserve the real bottom space their
2819        // rotated bounding boxes need, so they never overlap the panel
2820        // (#162 also asked for this: an explicit angle reserved nothing).
2821        // `rotated_label_rise_fall` is the same helper `draw_x_axis` calls
2822        // to place its rotation anchor — `rise` must be reserved (as well
2823        // as placed) or a long enough label's far end swings right back up
2824        // over the panel regardless of how much height is reserved here;
2825        // `fall` just needs reserving so the label stays on-canvas. Both
2826        // here and in `draw_x_axis`, the anchor fraction must come from
2827        // `rotated_anchor_frac(angle, dir)` (sipemu/ggplot-rs#163) — not a
2828        // blind `1.0` — or the reservation is sized for the wrong end of
2829        // the label; `dir` matches `draw_x_axis`'s own tick direction.
2830        let x_label_height = if built.theme.axis_text_x.visible
2831            && built.theme.axis_text_x.angle.abs() > 10.0
2832            && !drawn_x_labels.is_empty()
2833        {
2834            let font = built.theme.axis_text_x.size;
2835            let angle = built.theme.axis_text_x.angle;
2836            let dir = if x_axis_top { -1.0 } else { 1.0 };
2837            let (rise, fall) = crate::render::text::rotated_label_rise_fall(
2838                drawn_x_labels.iter().map(|(_, l)| l.as_str()),
2839                font,
2840                angle,
2841                crate::render::text::rotated_anchor_frac(angle, dir),
2842            );
2843            Some(built.theme.legend_spacing / 2.0 + font / 2.0 + rise + fall)
2844        } else {
2845            None
2846        };
2847
2848        // Reserve the left-axis width from the actual label lengths so long
2849        // category names (a flipped bar chart's rows) aren't clipped. Never
2850        // shrinks below the default; grows up to 35% of the width.
2851        let y_label_width = if built.theme.axis_text_y.visible {
2852            built.scales.get(&v_aes).map(|vs| {
2853                let max_ch = vs
2854                    .breaks()
2855                    .iter()
2856                    .map(|(_, l)| l.chars().count())
2857                    .max()
2858                    .unwrap_or(0);
2859                let measured = max_ch as f64 * built.theme.axis_text_y.size * 0.6 + 4.0;
2860                let default = built.theme.axis_text_y.size * 3.5 + 4.0;
2861                measured.max(default).min(0.35 * w as f64)
2862            })
2863        } else {
2864            None
2865        };
2866
2867        // B3-1 (D6): reserve just enough left/right panel margin to keep
2868        // the outer x-axis labels on-canvas under `AxisLabelFit::Reserve`,
2869        // for a single (non-faceted) panel on a coord that supports it
2870        // (not polar/radar/sf, where a label bbox doesn't map to a simple
2871        // panel margin). A faceted plot skips the layout-level reservation
2872        // and relies on `draw_x_axis`'s own draw-time shift instead — so
2873        // does `AxisLabelFit::Shift` (the panel must keep its size) and
2874        // `Clip` (the 0.21 behaviour, no correction at all). A plot whose
2875        // outer labels already fit never grows `extras`, so it stays
2876        // byte-identical.
2877        let mut extras = crate::render::layout::LayoutExtras::default();
2878        let can_reserve = built.facet.is_none()
2879            && built.theme.axis_text_x.visible
2880            && built.theme.axis_text_x_fit == crate::theme::AxisLabelFit::Reserve
2881            && !built.coord.is_polar()
2882            && built.coord.supports_axis_reserve();
2883        if can_reserve {
2884            if let Some(hs) = built.scales.get(&h_aes) {
2885                let breaks = hs.breaks();
2886                if let (Some(first), Some(last)) = (breaks.first(), breaks.last()) {
2887                    let angle = built.theme.axis_text_x.angle;
2888                    let anchor = if angle.abs() > 10.0 {
2889                        let dir = if x_axis_top { -1.0 } else { 1.0 };
2890                        crate::render::text::rotated_anchor_frac(angle, dir)
2891                    } else {
2892                        0.5
2893                    };
2894                    let size = built.theme.axis_text_x.size;
2895                    const EDGE: f64 = 1.0;
2896                    for _ in 0..2 {
2897                        let layout = PlotLayout::compute_with_extras(
2898                            w as f64,
2899                            h as f64,
2900                            &built.theme,
2901                            has_title,
2902                            has_subtitle,
2903                            has_caption,
2904                            has_legend,
2905                            x_axis_top,
2906                            y_label_width,
2907                            x_label_height,
2908                            extras,
2909                        );
2910                        let area = &layout.plot_area;
2911                        let edge_y = area.y + area.height;
2912                        let to_point = |pos: f64| if flipped { (0.0, pos) } else { (pos, 0.0) };
2913                        let px_first = built.coord.transform(to_point(first.0), area).0;
2914                        let px_last = built.coord.transform(to_point(last.0), area).0;
2915                        let bbox_first = crate::render::text::label_bbox(
2916                            &first.1, size, px_first, edge_y, angle, anchor,
2917                        );
2918                        let bbox_last = crate::render::text::label_bbox(
2919                            &last.1, size, px_last, edge_y, angle, anchor,
2920                        );
2921                        let deficit_left = (EDGE - bbox_first[0]).max(0.0);
2922                        let deficit_right = (bbox_last[2] - (w as f64 - EDGE)).max(0.0);
2923                        if deficit_left <= 0.0 && deficit_right <= 0.0 {
2924                            break;
2925                        }
2926                        // Growing the left margin by `eL` (holding `eR` fixed)
2927                        // shifts a label at normalized position `p` right by
2928                        // `eL·(1 − p)`; growing the right margin by `eR`
2929                        // shifts it left by `eR·p` (the panel maps [0, 1] to
2930                        // [plot_x + eL, plot_x + width − eR]). Review #158
2931                        // finding 1: the original code divided by `p_first`
2932                        // and `1 − p_last` — inverted, which over-reserved
2933                        // by about `1/p_last` (≈20× at a typical p_last≈0.95).
2934                        let (p_first, p_last) = (first.0, last.0);
2935                        if deficit_left > 0.0 && deficit_right > 0.0 {
2936                            // Both ends overflow: solve the 2×2 system so
2937                            // each margin accounts for its effect on *both*
2938                            // labels, rather than correcting them
2939                            // independently and overshooting.
2940                            let (a, b, c, d) = (1.0 - p_first, p_first, 1.0 - p_last, p_last);
2941                            let det = a * d - b * c;
2942                            if det.abs() > 0.01 {
2943                                extras.left +=
2944                                    ((deficit_left * d + deficit_right * b) / det).max(0.0);
2945                                extras.right +=
2946                                    ((deficit_right * a + deficit_left * c) / det).max(0.0);
2947                            } else {
2948                                // Degenerate (first and last break nearly
2949                                // coincide in normalized space): fall back
2950                                // to the independent, clamped formulas.
2951                                extras.left += deficit_left / (1.0 - p_first).max(0.05);
2952                                extras.right += deficit_right / p_last.max(0.05);
2953                            }
2954                        } else if deficit_left > 0.0 {
2955                            extras.left += deficit_left / (1.0 - p_first).max(0.05);
2956                        } else if deficit_right > 0.0 {
2957                            extras.right += deficit_right / p_last.max(0.05);
2958                        }
2959                        // Cap the total reservation so the panel can never
2960                        // start beyond the canvas or be squeezed past the
2961                        // same minimum width `compute_with_extras` already
2962                        // floors it to (review #158's second re-review,
2963                        // LOW): a single, centred break whose label is
2964                        // wider than the whole canvas has `p_first ==
2965                        // p_last == 0.5`, so both `eL` and `eR` grow by
2966                        // roughly the same (large) deficit independently,
2967                        // and together they could place `plot_x` past `w`
2968                        // entirely. Past this cap, Reserve simply stops
2969                        // growing and `draw_x_axis`'s own draw-time shift
2970                        // — which always runs for Reserve, the same as for
2971                        // `Shift` — takes over for whatever's still
2972                        // outside, exactly as D6 already documents.
2973                        const MIN_PANEL_WIDTH: f64 = 50.0;
2974                        let cap = (w as f64 - MIN_PANEL_WIDTH).max(0.0);
2975                        if extras.left + extras.right > cap {
2976                            let scale = if extras.left + extras.right > 0.0 {
2977                                cap / (extras.left + extras.right)
2978                            } else {
2979                                0.0
2980                            };
2981                            extras.left *= scale;
2982                            extras.right *= scale;
2983                        }
2984                    }
2985                }
2986            }
2987        }
2988
2989        // B3-2 (D7): a discrete legend's `nrow`/`ncol` grid, or an
2990        // overflowing horizontal legend's auto-wrap, reserves extra legend
2991        // rows/width. Several simultaneous discrete guides (e.g. `colour`
2992        // and `shape` both mapped to discrete variables) each get their
2993        // own independent plan (sipemu/ggplot-rs#155) — previously this
2994        // only ran for exactly one discrete guide.
2995        if has_legend
2996            && !matches!(
2997                built.theme.legend_position,
2998                crate::theme::LegendPosition::None
2999            )
3000        {
3001            let legend_aes =
3002                crate::guide::legend::legend_scales_for(&built.scales, &built.suppressed_aes);
3003            // #236: a mapped `size` legend's largest key can need a bigger
3004            // box than the theme's flat defaults — independent of B3-2's
3005            // discrete-guide wrap planning below, so computed unconditionally.
3006            extras.legend_max_key_size = legend_aes
3007                .iter()
3008                .filter_map(|aes| {
3009                    let scale = built.scales.get(aes)?;
3010                    let breaks = scale.breaks();
3011                    Some(crate::guide::legend::size_key_diameter(
3012                        aes,
3013                        &built.scales,
3014                        scale,
3015                        &breaks,
3016                    ))
3017                })
3018                .fold(0.0_f64, f64::max);
3019            let has_discrete_guide = legend_aes.iter().any(|a| {
3020                built
3021                    .scales
3022                    .get(a)
3023                    .map(|s| s.is_discrete())
3024                    .unwrap_or(false)
3025            });
3026            if has_discrete_guide {
3027                let layout0 = PlotLayout::compute_with_extras(
3028                    w as f64,
3029                    h as f64,
3030                    &built.theme,
3031                    has_title,
3032                    has_subtitle,
3033                    has_caption,
3034                    has_legend,
3035                    x_axis_top,
3036                    y_label_width,
3037                    x_label_height,
3038                    extras,
3039                );
3040                // `y` is unused here (only `legend_x0`/`is_horizontal` feed
3041                // the avail-width check below), so #233's `Top` row-count
3042                // parameter can't matter — `extras.legend_extra_rows` isn't
3043                // even known yet at this point in the pass. Likewise #225's
3044                // `Inside` justification only matters when `legend_x0` is
3045                // actually used for an `Inside` position combined with an
3046                // explicit `nrow`/`ncol` grid (`Inside` otherwise never
3047                // reaches this discrete-guide-wrap check), too rare to
3048                // bother estimating a real size hint for here.
3049                let (legend_x0, _, mut is_horizontal) = crate::guide::legend::legend_position(
3050                    &built.theme,
3051                    &layout0.plot_area,
3052                    extras.legend_extra_rows,
3053                    (0.0, 0.0),
3054                    &layout0.total,
3055                );
3056                if let Some(dir) = built.theme.legend_direction {
3057                    is_horizontal = matches!(dir, crate::theme::LegendDirection::Horizontal);
3058                }
3059                let avail_width =
3060                    (layout0.plot_area.x + layout0.plot_area.width - legend_x0).max(0.0);
3061                let mut legend_warnings = Vec::new();
3062                let mut plans = crate::guide::legend::plan_all_legends(
3063                    &built.scales,
3064                    &built.guide_legend,
3065                    &built.theme,
3066                    &built.suppressed_aes,
3067                    avail_width,
3068                    layout0.plot_area.height,
3069                    is_horizontal,
3070                    &mut legend_warnings,
3071                );
3072                built.warnings.extend(legend_warnings);
3073
3074                if !plans.is_empty() {
3075                    // Guides laid out side by side (a horizontal legend
3076                    // position) share one row band — its height is set
3077                    // by whichever guide wrapped to the most rows.
3078                    // Guides stacked vertically each add their own full
3079                    // height, so their extra rows sum.
3080                    extras.legend_extra_rows = if is_horizontal {
3081                        plans
3082                            .values()
3083                            .map(|p| p.rows.saturating_sub(1))
3084                            .max()
3085                            .unwrap_or(0)
3086                    } else {
3087                        plans.values().map(|p| p.rows.saturating_sub(1)).sum()
3088                    };
3089                    // Review #158 finding 3: a vertical explicit grid
3090                    // with ncol > 1 needs extra legend band width for
3091                    // its later columns, or they run off the canvas
3092                    // (cols is always 1, col_pitch 0, for the horizontal
3093                    // auto-wrap path, so this is a no-op there). Several
3094                    // guides take the widest requirement among them.
3095                    extras.legend_extra_width = plans
3096                        .values()
3097                        .map(|p| p.cols.saturating_sub(1) as f64 * p.col_pitch)
3098                        .fold(0.0_f64, f64::max);
3099
3100                    // #159: a vertical multi-column grid that still runs
3101                    // off the canvas even after reserving the extra width
3102                    // above now (1) tries reducing its column count
3103                    // toward 1 — the first count that fits wins; (2) if
3104                    // even one column still overflows, truncates labels
3105                    // with an ellipsis (keeping the full label available
3106                    // via a `<title>`); and only (3) warns if it still
3107                    // doesn't fit after both. A grid that already fits is
3108                    // untouched — no reduction, no truncation, no warning
3109                    // (review #158's second re-review, LOW, is the
3110                    // original "warn only" version of this check).
3111                    if !is_horizontal {
3112                        let final_layout = PlotLayout::compute_with_extras(
3113                            w as f64,
3114                            h as f64,
3115                            &built.theme,
3116                            has_title,
3117                            has_subtitle,
3118                            has_caption,
3119                            has_legend,
3120                            x_axis_top,
3121                            y_label_width,
3122                            x_label_height,
3123                            extras,
3124                        );
3125                        // `y` is unused here too, but `extras` is final by
3126                        // this point, so pass its real `legend_extra_rows`
3127                        // for consistency with #233's `Top` formula. #225's
3128                        // `Inside` size hint is skipped here for the same
3129                        // reason as the pass above.
3130                        let (legend_x_final, _, _) = crate::guide::legend::legend_position(
3131                            &built.theme,
3132                            &final_layout.plot_area,
3133                            extras.legend_extra_rows,
3134                            (0.0, 0.0),
3135                            &final_layout.total,
3136                        );
3137                        let swatch_and_gap =
3138                            built.theme.legend_key_width + built.theme.legend_spacing;
3139                        let text_size = built.theme.legend_text.size;
3140                        // A plain fn, not a closure: #168's retightening
3141                        // pass below re-runs this same check at a second
3142                        // `legend_x` (the narrower reservation's), so
3143                        // `legend_x` has to be a parameter rather than
3144                        // captured.
3145                        fn right_edge_of(
3146                            legend_x: f64,
3147                            swatch_and_gap: f64,
3148                            text_size: f64,
3149                            plan: &crate::guide::legend::LegendPlan,
3150                            breaks: &[(f64, String)],
3151                        ) -> f64 {
3152                            plan.entries
3153                                .iter()
3154                                .filter_map(|e| breaks.get(e.break_idx).map(|(_, l)| (e, l)))
3155                                .map(|(e, label)| {
3156                                    let text =
3157                                        e.label_override.as_deref().unwrap_or(label.as_str());
3158                                    legend_x
3159                                        + e.x_offset
3160                                        + swatch_and_gap
3161                                        + crate::render::text::text_width(text, text_size)
3162                                })
3163                                .fold(0.0_f64, f64::max)
3164                        }
3165
3166                        let mut updates: Vec<(
3167                            crate::aes::Aesthetic,
3168                            crate::guide::legend::LegendPlan,
3169                        )> = Vec::new();
3170                        // #168: guides narrowed below by *column
3171                        // reduction* (not label truncation) are
3172                        // candidates for the retightening pass after this
3173                        // loop — a truncated guide already picked the
3174                        // narrowest column count, so there's no reserved
3175                        // width left to give back.
3176                        let mut reduced_aes: Vec<crate::aes::Aesthetic> = Vec::new();
3177                        for (aes, plan) in &plans {
3178                            if plan.cols <= 1 {
3179                                continue;
3180                            }
3181                            let Some(scale) = built.scales.get(aes) else {
3182                                continue;
3183                            };
3184                            let breaks = scale.breaks();
3185                            if right_edge_of(
3186                                legend_x_final,
3187                                swatch_and_gap,
3188                                text_size,
3189                                plan,
3190                                &breaks,
3191                            ) <= w as f64
3192                            {
3193                                continue; // already fits — untouched.
3194                            }
3195
3196                            // Step 1: reduce columns toward 1; the first
3197                            // count that fits wins.
3198                            let n = breaks.len();
3199                            let mut reduced: Vec<crate::guide::legend::LegendPlan> = Vec::new();
3200                            for c in (1..plan.cols).rev() {
3201                                let mut candidate_guide = built.guide_legend.clone();
3202                                candidate_guide.ncol = Some(c);
3203                                candidate_guide.nrow = None;
3204                                let mut tmp_warnings = Vec::new();
3205                                let candidate_guide =
3206                                    candidate_guide.validated(n, &mut tmp_warnings);
3207                                if let Some(cand) = crate::guide::legend::plan_legend(
3208                                    &breaks,
3209                                    &candidate_guide,
3210                                    &built.theme,
3211                                    avail_width,
3212                                    layout0.plot_area.height,
3213                                    false,
3214                                ) {
3215                                    reduced.push(cand);
3216                                }
3217                            }
3218                            if let Some(fits) = reduced.iter().find(|c| {
3219                                right_edge_of(legend_x_final, swatch_and_gap, text_size, c, &breaks)
3220                                    <= w as f64
3221                            }) {
3222                                extras.legend_extra_rows =
3223                                    extras.legend_extra_rows.max(fits.rows.saturating_sub(1));
3224                                updates.push((aes.clone(), fits.clone()));
3225                                reduced_aes.push(aes.clone());
3226                                continue;
3227                            }
3228
3229                            // Step 2: no column count fit — truncate
3230                            // labels (starting from the narrowest, 1-
3231                            // column, candidate) with an ellipsis, each
3232                            // to whatever room its own position leaves.
3233                            let base = reduced.last().cloned().unwrap_or_else(|| plan.clone());
3234                            extras.legend_extra_rows =
3235                                extras.legend_extra_rows.max(base.rows.saturating_sub(1));
3236                            let mut truncated = base.clone();
3237                            for e in &mut truncated.entries {
3238                                let Some((_, label)) = breaks.get(e.break_idx) else {
3239                                    continue;
3240                                };
3241                                let avail = (w as f64
3242                                    - (legend_x_final + e.x_offset + swatch_and_gap))
3243                                    .max(0.0);
3244                                if let Some(short) =
3245                                    crate::render::text::truncate_to_width(label, avail, text_size)
3246                                {
3247                                    e.label_override = Some(short);
3248                                }
3249                            }
3250
3251                            // Step 3: only warn if it still doesn't fit
3252                            // after both the reduction and the
3253                            // truncation above.
3254                            let truncated_edge = right_edge_of(
3255                                legend_x_final,
3256                                swatch_and_gap,
3257                                text_size,
3258                                &truncated,
3259                                &breaks,
3260                            );
3261                            if truncated_edge > w as f64 {
3262                                built.warnings.push(format!(
3263                                    "legend: the {}-column grid still doesn't fit the {w}px chart (needs about {} px)",
3264                                    truncated.cols,
3265                                    truncated_edge.ceil() as i64
3266                                ));
3267                            }
3268                            updates.push((aes.clone(), truncated));
3269                        }
3270                        for (aes, new_plan) in &updates {
3271                            plans.insert(aes.clone(), new_plan.clone());
3272                        }
3273
3274                        // #168: `extras.legend_extra_width` above still
3275                        // reserves the *original*, pre-reduction column
3276                        // count's width — that stale, wider reservation
3277                        // is an accident of ordering (reserve → layout →
3278                        // fit? → reduce), not a requirement, so the panel
3279                        // stays narrower than it needs to be once a guide
3280                        // above got fewer columns.
3281                        //
3282                        // Tighten it to the reduced guides' new column
3283                        // counts and re-check fit once more at the
3284                        // narrower reservation (a bounded retry, not an
3285                        // unbounded fixed point: a tighter reservation
3286                        // moves `legend_x_final` right — less headroom
3287                        // before the canvas edge — which could in
3288                        // principle call for yet another round of
3289                        // reduction; rather than chase reserve → layout →
3290                        // fit → reduce → reserve → … indefinitely, this
3291                        // keeps the tighter reservation only if every
3292                        // guide still fits under it, and falls back to
3293                        // the wider, already-proven-safe reservation
3294                        // otherwise — so retightening can only ever
3295                        // help, never regress a plot that fit before
3296                        // this pass existed).
3297                        //
3298                        // Review #167 finding 1 on #171 (HIGH, fixed):
3299                        // the retry must re-check *every* plan in
3300                        // `plans`, not just `reduced_aes`. `legend_x`
3301                        // moves for every guide sharing the row, not
3302                        // only the one(s) that got reduced — a second
3303                        // guide that already fit at the old, wider
3304                        // `legend_x_final` (its own width is a fixed
3305                        // `6·text_size` estimate, unrelated to the
3306                        // reduced guide's labels) or whose labels were
3307                        // truncated against that old `legend_x_final`
3308                        // shifts right by the same amount and was never
3309                        // re-checked, so it could run off the canvas
3310                        // with no warning (the warnings were computed
3311                        // against the old x too). `reduced_aes` is kept
3312                        // only to gate *entering* this block at all —
3313                        // nothing to reclaim if nothing was reduced.
3314                        if !reduced_aes.is_empty() {
3315                            let original_reserve = extras.legend_extra_width;
3316                            let tighter_reserve = plans
3317                                .values()
3318                                .map(|p| p.cols.saturating_sub(1) as f64 * p.col_pitch)
3319                                .fold(0.0_f64, f64::max);
3320                            if tighter_reserve < original_reserve {
3321                                let mut retry_extras = extras;
3322                                retry_extras.legend_extra_width = tighter_reserve;
3323                                let retry_layout = PlotLayout::compute_with_extras(
3324                                    w as f64,
3325                                    h as f64,
3326                                    &built.theme,
3327                                    has_title,
3328                                    has_subtitle,
3329                                    has_caption,
3330                                    has_legend,
3331                                    x_axis_top,
3332                                    y_label_width,
3333                                    x_label_height,
3334                                    retry_extras,
3335                                );
3336                                // `y` is unused here too; `retry_extras`
3337                                // only tightened `legend_extra_width`, so
3338                                // its `legend_extra_rows` is still the same
3339                                // real value. #225's `Inside` size hint is
3340                                // skipped for the same reason as above.
3341                                let (retry_x, _, _) = crate::guide::legend::legend_position(
3342                                    &built.theme,
3343                                    &retry_layout.plot_area,
3344                                    retry_extras.legend_extra_rows,
3345                                    (0.0, 0.0),
3346                                    &retry_layout.total,
3347                                );
3348                                let all_still_fit = plans.iter().all(|(aes, plan)| {
3349                                    let Some(scale) = built.scales.get(aes) else {
3350                                        return true;
3351                                    };
3352                                    let breaks = scale.breaks();
3353                                    right_edge_of(retry_x, swatch_and_gap, text_size, plan, &breaks)
3354                                        <= w as f64
3355                                });
3356                                if all_still_fit {
3357                                    extras.legend_extra_width = tighter_reserve;
3358                                }
3359                            }
3360                        }
3361                    }
3362                    for (aes, plan) in &plans {
3363                        if let Some(ov) = &plan.overflow {
3364                            let n = built
3365                                .scales
3366                                .get(aes)
3367                                .map(|s| s.breaks().len())
3368                                .unwrap_or(ov.dropped);
3369                            let shown = n.saturating_sub(ov.dropped);
3370                            built.warnings.push(format!(
3371                                "legend: {} of {n} entries don't fit the chart; the legend shows {shown} and \"+{} more\"",
3372                                ov.dropped, ov.dropped
3373                            ));
3374                        }
3375                    }
3376                }
3377                // Review #158 finding 2: store these exact plans so
3378                // `draw_legend_planned` draws them verbatim instead of
3379                // recomputing against the (shorter) post-reservation
3380                // panel height, which could warn "+7 more" while
3381                // drawing "+15 more".
3382                built.legend_plans = plans;
3383            }
3384        }
3385
3386        // #211: a continuous colourbar at `Top`/`Bottom` (or
3387        // `legend_direction(Horizontal)`) draws wide × short instead of
3388        // its usual tall × narrow bar (see `draw_continuous_legend_at`),
3389        // with its tick labels below rather than beside it — reserve the
3390        // extra band height that needs beyond the flat discrete-row
3391        // height `legend_band` already assumes. Independent of the
3392        // `has_discrete_guide` block above (a plot can have a continuous
3393        // colourbar with no discrete guide at all), so computed
3394        // unconditionally here, right before the layout that actually
3395        // matters reads `extras`.
3396        if has_legend
3397            && !matches!(
3398                built.theme.legend_position,
3399                crate::theme::LegendPosition::None
3400            )
3401        {
3402            let is_horizontal = match built.theme.legend_direction {
3403                Some(dir) => matches!(dir, crate::theme::LegendDirection::Horizontal),
3404                None => matches!(
3405                    built.theme.legend_position,
3406                    crate::theme::LegendPosition::Top | crate::theme::LegendPosition::Bottom
3407                ),
3408            };
3409            if is_horizontal {
3410                let legend_aes =
3411                    crate::guide::legend::legend_scales_for(&built.scales, &built.suppressed_aes);
3412                let continuous_colourbar = legend_aes.iter().find_map(|aes| {
3413                    if !matches!(
3414                        aes,
3415                        crate::aes::Aesthetic::Color | crate::aes::Aesthetic::Fill
3416                    ) {
3417                        return None;
3418                    }
3419                    let scale = built.scales.get(aes)?;
3420                    if scale.is_discrete() {
3421                        return None;
3422                    }
3423                    Some(scale)
3424                });
3425                if let Some(scale) = continuous_colourbar {
3426                    let title = built
3427                        .guide_legend
3428                        .title
3429                        .as_deref()
3430                        .unwrap_or_else(|| scale.name());
3431                    let bar_height = crate::guide::legend::continuous_horizontal_bar_height(
3432                        &built.theme,
3433                        !title.is_empty(),
3434                    );
3435                    let key_h = built
3436                        .theme
3437                        .legend_key_height
3438                        .max(extras.legend_max_key_size);
3439                    extras.continuous_horizontal_legend_extra_height =
3440                        (bar_height - key_h).max(0.0);
3441                }
3442            }
3443        }
3444
3445        PlotLayout::compute_with_extras(
3446            w as f64,
3447            h as f64,
3448            &built.theme,
3449            has_title,
3450            has_subtitle,
3451            has_caption,
3452            has_legend,
3453            x_axis_top,
3454            y_label_width,
3455            x_label_height,
3456            extras,
3457        )
3458    }
3459
3460    /// Fill the background, render the built plot, and flush — for any plotters
3461    /// backend. (Native only.)
3462    #[cfg(all(feature = "plotters", not(target_arch = "wasm32")))]
3463    fn render_into<DB>(
3464        area: plotters::drawing::DrawingArea<DB, plotters::coord::Shift>,
3465        built: &crate::build::BuiltPlot,
3466        layout: &PlotLayout,
3467    ) -> Result<(), GGError>
3468    where
3469        DB: plotters::prelude::DrawingBackend,
3470        DB::ErrorType: 'static,
3471    {
3472        area.fill(&plotters::prelude::WHITE)
3473            .map_err(|e| GGError::Render(RenderError::BackendError(format!("{:?}", e))))?;
3474        let mut adapter = PlottersAdapter::new(&area, layout.plot_area.clone());
3475        PlotRenderer::render(built, &mut adapter).map_err(GGError::Render)?;
3476        area.present()
3477            .map_err(|e| GGError::Render(RenderError::BackendError(format!("{:?}", e))))?;
3478        Ok(())
3479    }
3480
3481    /// Save with physical dimensions (inches) and DPI. (Feature `plotters`;
3482    /// native only.)
3483    #[cfg(all(feature = "plotters", not(target_arch = "wasm32")))]
3484    pub fn ggsave(
3485        self,
3486        path: &str,
3487        width_inches: f64,
3488        height_inches: f64,
3489        dpi: f64,
3490    ) -> Result<(), GGError> {
3491        let w = (width_inches * dpi) as u32;
3492        let h = (height_inches * dpi) as u32;
3493        self.save_with_size(path, w, h)
3494    }
3495
3496    /// Does this plot have a legend to make room for? (#201) A plot-level
3497    /// `.aes(...)` mapping always counts; so does a *layer's own*
3498    /// `.layer_aes(...)` mapping — e.g. `geom_hex`'s usual pattern of
3499    /// mapping `fill` to a raw column only on that one layer, via
3500    /// `StatIdentity` after pre-binning, rather than on the plot as a
3501    /// whole. Checking only `self.mapping` (as before) missed that case
3502    /// entirely: the legend still drew (the render path always draws
3503    /// whatever guides the trained scales produce, regardless of this
3504    /// flag), just with no panel margin ever reserved for it, so it ran
3505    /// straight off the canvas edge. A layer that opts out with
3506    /// `show_legend(false)` doesn't count towards its own mapping, mirroring
3507    /// `PlotBuilder::compute_suppressed_aes`'s later, more precise
3508    /// (post-build, cross-layer) suppression check — this one only needs to
3509    /// decide *whether to reserve space at all*, not exactly how much.
3510    fn has_legend_mapping(&self) -> bool {
3511        // #241: delegates to `guide::legend::is_legend_aesthetic` (the
3512        // same list `draw_legend_planned` itself draws from) rather than
3513        // keeping its own copy — a second, independently-maintained list
3514        // here had drifted out of sync, missing `Linewidth`.
3515        use crate::guide::legend::is_legend_aesthetic;
3516        let plot_level = self
3517            .mapping
3518            .mappings
3519            .iter()
3520            .any(|m| is_legend_aesthetic(&m.aesthetic));
3521        plot_level
3522            || self.layers.iter().any(|layer| {
3523                if layer.show_legend == Some(false) {
3524                    return false;
3525                }
3526                let effective = if layer.geom.inherit_aes() {
3527                    self.mapping.merge(&layer.mapping)
3528                } else {
3529                    layer.mapping.clone()
3530                };
3531                effective
3532                    .mappings
3533                    .iter()
3534                    .any(|m| is_legend_aesthetic(&m.aesthetic))
3535            })
3536    }
3537}
3538
3539/// Size-independent facts about a built plot that its layout needs.
3540#[derive(Clone, Copy, Debug)]
3541pub(crate) struct RenderMeta {
3542    pub(crate) has_title: bool,
3543    pub(crate) has_subtitle: bool,
3544    pub(crate) has_caption: bool,
3545    pub(crate) has_legend: bool,
3546}
3547
3548/// Top-level error type.
3549#[derive(Debug)]
3550#[non_exhaustive]
3551pub enum GGError {
3552    Render(RenderError),
3553    UnsupportedFormat(String),
3554    Io(std::io::Error),
3555    ValidationError(String),
3556}
3557
3558impl std::fmt::Display for GGError {
3559    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
3560        match self {
3561            GGError::Render(e) => write!(f, "Render error: {e}"),
3562            GGError::UnsupportedFormat(ext) => write!(f, "Unsupported output format: {ext}"),
3563            GGError::Io(e) => write!(f, "IO error: {e}"),
3564            GGError::ValidationError(msg) => write!(f, "Validation error: {msg}"),
3565        }
3566    }
3567}
3568
3569impl std::error::Error for GGError {}