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