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    pub fn geom_curve(self) -> Self {
831        self.add_geom(GeomCurve::default())
832    }
833
834    pub fn geom_curve_with(self, geom: GeomCurve) -> Self {
835        self.add_geom_with(geom)
836    }
837
838    pub fn geom_violin(self) -> Self {
839        self.add_geom(GeomViolin::default())
840    }
841
842    pub fn geom_violin_with(self, geom: GeomViolin) -> Self {
843        self.add_geom_with(geom)
844    }
845
846    pub fn geom_dotplot(self) -> Self {
847        self.add_geom(GeomDotplot::default())
848    }
849
850    pub fn geom_dotplot_with(self, geom: GeomDotplot) -> Self {
851        self.add_geom_with(geom)
852    }
853
854    pub fn geom_qq(self) -> Self {
855        self.add_geom(GeomQQ::default())
856    }
857
858    pub fn geom_qq_with(self, geom: GeomQQ) -> Self {
859        self.add_geom_with(geom)
860    }
861
862    pub fn geom_qq_line(self) -> Self {
863        self.add_geom(GeomQQLine::default())
864    }
865
866    pub fn geom_qq_line_with(self, geom: GeomQQLine) -> Self {
867        self.add_geom_with(geom)
868    }
869
870    pub fn geom_bin2d(self) -> Self {
871        self.add_geom(GeomBin2d::default())
872    }
873
874    pub fn geom_bin2d_with(self, geom: GeomBin2d) -> Self {
875        self.add_geom_with(geom)
876    }
877
878    pub fn geom_hex(self) -> Self {
879        self.add_geom(GeomHex::default())
880    }
881
882    pub fn geom_hex_with(self, geom: GeomHex) -> Self {
883        self.add_geom_with(geom)
884    }
885
886    pub fn geom_count(self) -> Self {
887        self.add_geom(GeomCount::default())
888    }
889
890    pub fn geom_count_with(self, geom: GeomCount) -> Self {
891        self.add_geom_with(geom)
892    }
893
894    pub fn geom_contour(self) -> Self {
895        self.add_geom(GeomContour::default())
896    }
897
898    pub fn geom_contour_with(self, geom: GeomContour) -> Self {
899        self.add_geom_with(geom)
900    }
901
902    /// Filled contour bands from gridded (x, y, z) data — draws polygons filled by
903    /// band level. Pair with a continuous fill scale (e.g. `scale_fill_viridis_c`).
904    pub fn geom_contour_filled(self) -> Self {
905        self.add_geom(GeomPolygon {
906            line_width: 0.0,
907            alpha: 1.0,
908            ..GeomPolygon::default()
909        })
910        .stat(crate::stat::contour_filled::StatContourFilled::default())
911    }
912
913    pub fn geom_density2d(self) -> Self {
914        self.add_geom(GeomDensity2d::default())
915    }
916
917    pub fn geom_density2d_with(self, geom: GeomDensity2d) -> Self {
918        self.add_geom_with(geom)
919    }
920
921    /// Candlestick chart: map `x` and `open`/`high`/`low`/`close`
922    /// (`Aes::new().x("date").open("o").high("h").low("l").close("c")`).
923    pub fn geom_candlestick(self) -> Self {
924        self.add_geom(crate::geom::candlestick::GeomCandlestick::default())
925    }
926
927    pub fn geom_candlestick_with(self, geom: crate::geom::candlestick::GeomCandlestick) -> Self {
928        self.add_geom_with(geom)
929    }
930
931    /// OHLC bar chart (high–low bar with open/close ticks); same aesthetics as
932    /// [`geom_candlestick`](Self::geom_candlestick).
933    pub fn geom_ohlc(self) -> Self {
934        self.add_geom(crate::geom::candlestick::GeomOhlc::default())
935    }
936
937    pub fn geom_ohlc_with(self, geom: crate::geom::candlestick::GeomOhlc) -> Self {
938        self.add_geom_with(geom)
939    }
940
941    /// Calendar heatmap (GitHub/ECharts style): the plot's `x` (a date —
942    /// `DateTime`, epoch seconds or `"YYYY-MM-DD"`) is laid out as week
943    /// columns × weekday rows (Sunday on top) via [`StatCalendar`], coloured
944    /// by `fill`, with month labels along x, Mon/Wed/Fri along y, month
945    /// boundary outlines and square cells. Spans over
946    /// [`MAX_CALENDAR_YEARS`] are clipped to the most recent years (with a
947    /// build warning). Uses the plot-level data and `x` mapping.
948    ///
949    /// [`StatCalendar`]: crate::stat::calendar::StatCalendar
950    /// [`MAX_CALENDAR_YEARS`]: crate::stat::calendar::MAX_CALENDAR_YEARS
951    pub fn geom_calendar(self) -> Self {
952        self.geom_calendar_with(
953            GeomTile {
954                color: (255, 255, 255),
955                line_width: 1.5,
956                ..Default::default()
957            },
958            false,
959        )
960    }
961
962    /// [`geom_calendar`](Self::geom_calendar) with a custom cell style and
963    /// optionally Monday-first weeks.
964    pub fn geom_calendar_with(mut self, tile: GeomTile, monday_first: bool) -> Self {
965        use crate::stat::calendar::{
966            day_number, month_boundaries, month_breaks, month_breaks_localized, weekday_labels,
967            CalendarGrid, StatCalendar, MAX_CALENDAR_YEARS,
968        };
969        let grid = self
970            .mapping
971            .get_mapping(&crate::aes::Aesthetic::X)
972            .and_then(|c| self.data.column(c))
973            .and_then(|col| {
974                CalendarGrid::from_days(col.iter().filter_map(day_number), monday_first)
975            });
976        let Some((grid, clipped)) = grid else {
977            // No dates: an empty calendar layer (renders an empty panel).
978            return self.add_geom_with(tile).stat(StatCalendar {
979                grid: None,
980                monday_first,
981            });
982        };
983        if clipped {
984            self.warnings.push(format!(
985                "geom_calendar: dates more than {MAX_CALENDAR_YEARS} years before the newest were dropped"
986            ));
987        }
988        let (xb, _) = month_breaks(&grid);
989        // Rows: y = 6 - weekday; label Mon/Wed/Fri.
990        let yb = if monday_first {
991            vec![6.0, 4.0, 2.0]
992        } else {
993            vec![5.0, 3.0, 1.0]
994        };
995        let segs = month_boundaries(&grid);
996        let col = |f: fn(&(f64, f64, f64, f64)) -> f64| -> Vec<Value> {
997            segs.iter().map(|s| Value::Float(f(s))).collect()
998        };
999        let boundaries = vec![
1000            ("x".to_string(), col(|s| s.0)),
1001            ("y".to_string(), col(|s| s.1)),
1002            ("xend".to_string(), col(|s| s.2)),
1003            ("yend".to_string(), col(|s| s.3)),
1004        ];
1005        self.default_aspect_ratio = Some(7.0 / grid.n_weeks().max(1) as f64);
1006        let plot = self
1007            .add_geom_with(tile)
1008            .stat(StatCalendar {
1009                grid: Some(grid),
1010                monday_first,
1011            })
1012            .scale_x_continuous(
1013                ScaleContinuous::new()
1014                    .with_breaks(xb)
1015                    // Month / weekday names follow the plot locale.
1016                    .with_localized_labels(move |l| month_breaks_localized(&grid, l).1)
1017                    .with_expand(0.0, 0.0),
1018            )
1019            .scale_y_continuous(
1020                ScaleContinuous::new()
1021                    .with_breaks(yb)
1022                    .with_localized_labels(weekday_labels)
1023                    .with_expand(0.0, 0.0),
1024            );
1025        if segs.is_empty() {
1026            return plot;
1027        }
1028        // Styled by the theme's `calendar_month_border` at draw time.
1029        plot.add_geom_with(crate::geom::segment::GeomCalendarBorder)
1030            .layer_data(boundaries)
1031            .layer_aes(Aes::new().x("x").y("y").xend("xend").yend("yend"))
1032            .show_legend(false)
1033    }
1034
1035    pub fn geom_blank(self) -> Self {
1036        self.add_geom(GeomBlank)
1037    }
1038
1039    /// Add a geom configured explicitly by the caller: its colours win over
1040    /// the theme's `primary_color`.
1041    fn add_geom_with(self, geom: impl Geom + 'static) -> Self {
1042        let mut plot = self.add_geom(geom);
1043        if let Some(layer) = plot.layers.last_mut() {
1044            layer.explicit_style = true;
1045        }
1046        plot
1047    }
1048
1049    fn add_geom(mut self, geom: impl Geom + 'static) -> Self {
1050        let stat = geom.default_stat();
1051        let position = geom.default_position();
1052        let params = geom.default_params();
1053        self.layers.push(Layer {
1054            data: None,
1055            mapping: Aes::default(),
1056            geom: Box::new(geom),
1057            stat,
1058            position,
1059            params,
1060            show_legend: None,
1061            explicit_style: false,
1062            tooltip_template: None,
1063        });
1064        self
1065    }
1066
1067    // ─── Layer-level overrides ──────────────────────────────────
1068
1069    /// Override the stat for the most recently added layer.
1070    pub fn stat(mut self, stat: impl Stat + 'static) -> Self {
1071        if let Some(layer) = self.layers.last_mut() {
1072            layer.stat = Box::new(stat);
1073        }
1074        self
1075    }
1076
1077    /// Override the position for the most recently added layer.
1078    pub fn position(mut self, pos: impl Position + 'static) -> Self {
1079        if let Some(layer) = self.layers.last_mut() {
1080            layer.position = Box::new(pos);
1081        }
1082        self
1083    }
1084
1085    /// Override the data for the most recently added layer.
1086    pub fn layer_data(mut self, data: impl GGData) -> Self {
1087        if let Some(layer) = self.layers.last_mut() {
1088            layer.data = Some(data.into_dataframe());
1089        }
1090        self
1091    }
1092
1093    /// Override the aesthetic mapping for the most recently added layer.
1094    pub fn layer_aes(mut self, mapping: Aes) -> Self {
1095        self.warnings.extend(mapping.warnings.iter().cloned());
1096        if let Some(layer) = self.layers.last_mut() {
1097            layer.mapping = mapping;
1098        }
1099        self
1100    }
1101
1102    /// A `{column}` tooltip template for the most recently added layer
1103    /// (B2-5), replacing its default `<title>`. Each placeholder is filled
1104    /// from the *built* layer data (so it can reference a stat-computed
1105    /// column, e.g. a hex's `count`) at that row, formatted through the
1106    /// column's scale when it has one
1107    /// ([`Scale::format_value`](crate::scale::Scale::format_value)) — the same
1108    /// `data-fvalue` logic, but written into the tooltip text. A column
1109    /// that doesn't exist on this layer renders as empty, not literally
1110    /// `{column}`. `{{`/`}}` are literal braces. The result is escaped the
1111    /// same way any other tooltip is — this is plain text, not markup.
1112    pub fn tooltip(mut self, template: &str) -> Self {
1113        if let Some(layer) = self.layers.last_mut() {
1114            layer.tooltip_template = Some(template.to_string());
1115        }
1116        self
1117    }
1118
1119    /// Control whether the most recently added layer contributes to the legend.
1120    /// `true` = always show, `false` = always hide, default (None) = auto.
1121    pub fn show_legend(mut self, show: bool) -> Self {
1122        if let Some(layer) = self.layers.last_mut() {
1123            layer.show_legend = Some(show);
1124        }
1125        self
1126    }
1127
1128    /// Mark the most recently added layer as purely decorative (#161): its
1129    /// drawn marks keep their own visual style but carry **no** per-mark
1130    /// metadata — no `<title>`, no `data-x`/`data-series`/`data-value`/
1131    /// `data-fvalue`, and no [`data_attr`](crate::aes::Aes::data_attr)
1132    /// attributes — so an SVG consumer that treats "has a non-empty
1133    /// `<title>`" as "is a selectable data point" doesn't pick it up. Meant
1134    /// for a layer that annotates another layer's mark rather than drawing
1135    /// data in its own right (e.g. a boxplot's own mean dot, drawn by a
1136    /// separate `geom_point` layer purely to mark the spot the box's own
1137    /// tooltip already names). Each mark also carries
1138    /// `data-part="decorative"` and `aria-hidden="true"` (`data-geom`
1139    /// still names the real geom), so a consumer can select decorative
1140    /// marks positively rather than only infer them from what they lack.
1141    /// Their class is `gg-mark gg-decorative`, not the geom's own
1142    /// (`gg-point`, `gg-line`, …).
1143    ///
1144    /// A `.tooltip(...)` template on a decorative layer has no visible
1145    /// effect — the geom's marks carry no tooltip regardless — and warns,
1146    /// like the existing has-no-effect warning for a geom that doesn't
1147    /// read tooltips at all.
1148    ///
1149    /// A decorative layer also defaults to hidden in the legend (it's
1150    /// annotating, not its own series), unless
1151    /// [`show_legend`](Self::show_legend)`(true)` is called — before or
1152    /// after `decorative()` — to opt back in; an explicit
1153    /// `show_legend(false)` is left untouched either way.
1154    pub fn decorative(mut self) -> Self {
1155        if let Some(layer) = self.layers.last_mut() {
1156            let geom = std::mem::replace(&mut layer.geom, Box::new(GeomBlank));
1157            layer.geom = Box::new(crate::geom::decorative::Decorative(geom));
1158            if layer.show_legend.is_none() {
1159                layer.show_legend = Some(false);
1160            }
1161        }
1162        self
1163    }
1164
1165    // ─── Scales ──────────────────────────────────────────────────
1166
1167    pub fn scale_x_continuous(mut self, s: ScaleContinuous) -> Self {
1168        let s = s.for_aesthetic(crate::aes::Aesthetic::X);
1169        self.scales.push(Box::new(s));
1170        self
1171    }
1172
1173    pub fn scale_y_continuous(mut self, s: ScaleContinuous) -> Self {
1174        let s = s.for_aesthetic(crate::aes::Aesthetic::Y);
1175        self.scales.push(Box::new(s));
1176        self
1177    }
1178
1179    pub fn scale_x_discrete(mut self, s: crate::scale::discrete::ScaleDiscrete) -> Self {
1180        let s = s.for_aesthetic(crate::aes::Aesthetic::X);
1181        self.scales.push(Box::new(s));
1182        self
1183    }
1184
1185    pub fn scale_y_discrete(mut self, s: crate::scale::discrete::ScaleDiscrete) -> Self {
1186        let s = s.for_aesthetic(crate::aes::Aesthetic::Y);
1187        self.scales.push(Box::new(s));
1188        self
1189    }
1190
1191    pub fn scale_color(mut self, s: impl Scale + 'static) -> Self {
1192        self.scales.push(Box::new(s));
1193        self
1194    }
1195
1196    pub fn scale_fill(mut self, s: impl Scale + 'static) -> Self {
1197        self.scales.push(Box::new(s));
1198        self
1199    }
1200
1201    pub fn scale_color_manual(self, values: Vec<(&str, crate::scale::color::RGBAColor)>) -> Self {
1202        let s = crate::scale::manual::ScaleManual::new(crate::aes::Aesthetic::Color, values);
1203        self.scale_color(s)
1204    }
1205
1206    pub fn scale_fill_manual(self, values: Vec<(&str, crate::scale::color::RGBAColor)>) -> Self {
1207        let s = crate::scale::manual::ScaleManual::new(crate::aes::Aesthetic::Fill, values);
1208        self.scale_fill(s)
1209    }
1210
1211    /// The `colour`/`color` column already holds `#RRGGBB`/`#RGB` hex
1212    /// strings, used as-is — no legend (R's `scale_colour_identity`).
1213    pub fn scale_colour_identity(self) -> Self {
1214        let s = crate::scale::identity::ScaleColorIdentity::new(crate::aes::Aesthetic::Color);
1215        self.scale_color(s)
1216    }
1217
1218    /// American-spelling alias for [`scale_colour_identity`](Self::scale_colour_identity).
1219    pub fn scale_color_identity(self) -> Self {
1220        self.scale_colour_identity()
1221    }
1222
1223    /// The `fill` column already holds `#RRGGBB`/`#RGB` hex strings, used
1224    /// as-is — no legend (R's `scale_fill_identity`).
1225    pub fn scale_fill_identity(self) -> Self {
1226        let s = crate::scale::identity::ScaleColorIdentity::new(crate::aes::Aesthetic::Fill);
1227        self.scale_fill(s)
1228    }
1229
1230    pub fn scale_color_viridis(self) -> Self {
1231        use crate::scale::color::ScaleColorDiscrete;
1232        use crate::scale::palettes::PaletteName;
1233        let s = ScaleColorDiscrete::new(crate::aes::Aesthetic::Color)
1234            .with_named_palette(&PaletteName::Viridis);
1235        self.scale_color(s)
1236    }
1237
1238    pub fn scale_color_brewer(self, name: crate::scale::palettes::PaletteName) -> Self {
1239        use crate::scale::color::ScaleColorDiscrete;
1240        let s = ScaleColorDiscrete::new(crate::aes::Aesthetic::Color).with_named_palette(&name);
1241        self.scale_color(s)
1242    }
1243
1244    pub fn scale_color_gradient(
1245        self,
1246        low: crate::scale::color::RGBAColor,
1247        high: crate::scale::color::RGBAColor,
1248    ) -> Self {
1249        use crate::scale::color::ScaleColorContinuous;
1250        let s = ScaleColorContinuous::new(crate::aes::Aesthetic::Color).with_colors(low, high);
1251        self.scale_color(s)
1252    }
1253
1254    pub fn scale_fill_gradient(
1255        self,
1256        low: crate::scale::color::RGBAColor,
1257        high: crate::scale::color::RGBAColor,
1258    ) -> Self {
1259        use crate::scale::color::ScaleColorContinuous;
1260        let s = ScaleColorContinuous::new(crate::aes::Aesthetic::Fill).with_colors(low, high);
1261        self.scale_fill(s)
1262    }
1263
1264    pub fn scale_color_gradient2(
1265        self,
1266        low: crate::scale::color::RGBAColor,
1267        mid: crate::scale::color::RGBAColor,
1268        high: crate::scale::color::RGBAColor,
1269    ) -> Self {
1270        use crate::scale::gradient::ScaleColorGradient2;
1271        let s = ScaleColorGradient2::new(crate::aes::Aesthetic::Color).with_colors(low, mid, high);
1272        self.scale_color(s)
1273    }
1274
1275    pub fn scale_fill_gradient2(
1276        self,
1277        low: crate::scale::color::RGBAColor,
1278        mid: crate::scale::color::RGBAColor,
1279        high: crate::scale::color::RGBAColor,
1280    ) -> Self {
1281        use crate::scale::gradient::ScaleColorGradient2;
1282        let s = ScaleColorGradient2::new(crate::aes::Aesthetic::Fill).with_colors(low, mid, high);
1283        self.scale_fill(s)
1284    }
1285
1286    pub fn scale_fill_viridis(self) -> Self {
1287        use crate::scale::color::ScaleColorDiscrete;
1288        use crate::scale::palettes::PaletteName;
1289        let s = ScaleColorDiscrete::new(crate::aes::Aesthetic::Fill)
1290            .with_named_palette(&PaletteName::Viridis);
1291        self.scale_fill(s)
1292    }
1293
1294    /// Continuous viridis color scale (for numeric data).
1295    pub fn scale_color_viridis_c(self) -> Self {
1296        use crate::scale::gradient_n::ScaleColorGradientN;
1297        let s = ScaleColorGradientN::viridis(crate::aes::Aesthetic::Color);
1298        self.scale_color(s)
1299    }
1300
1301    /// Continuous viridis fill scale (for numeric data).
1302    pub fn scale_fill_viridis_c(self) -> Self {
1303        use crate::scale::gradient_n::ScaleColorGradientN;
1304        let s = ScaleColorGradientN::viridis(crate::aes::Aesthetic::Fill);
1305        self.scale_fill(s)
1306    }
1307
1308    /// N-stop continuous color gradient.
1309    pub fn scale_color_gradientn(self, stops: Vec<(f64, crate::scale::color::RGBAColor)>) -> Self {
1310        use crate::scale::gradient_n::ScaleColorGradientN;
1311        let s = ScaleColorGradientN::new(crate::aes::Aesthetic::Color, stops);
1312        self.scale_color(s)
1313    }
1314
1315    /// N-stop continuous fill gradient.
1316    pub fn scale_fill_gradientn(self, stops: Vec<(f64, crate::scale::color::RGBAColor)>) -> Self {
1317        use crate::scale::gradient_n::ScaleColorGradientN;
1318        let s = ScaleColorGradientN::new(crate::aes::Aesthetic::Fill, stops);
1319        self.scale_fill(s)
1320    }
1321
1322    /// Binned (stepped) two-colour continuous colour scale — buckets the mapped
1323    /// variable into `n_bins` bins, each a discrete colour, with a stepped legend.
1324    pub fn scale_color_steps(
1325        self,
1326        low: crate::scale::color::RGBAColor,
1327        high: crate::scale::color::RGBAColor,
1328        n_bins: usize,
1329    ) -> Self {
1330        let s = crate::scale::steps::ScaleColorSteps::two(
1331            crate::aes::Aesthetic::Color,
1332            (low.r, low.g, low.b),
1333            (high.r, high.g, high.b),
1334            n_bins,
1335        );
1336        self.scale_color(s)
1337    }
1338
1339    /// Binned N-stop continuous colour scale.
1340    pub fn scale_color_stepsn(
1341        self,
1342        stops: Vec<crate::scale::color::RGBAColor>,
1343        n_bins: usize,
1344    ) -> Self {
1345        let s = crate::scale::steps::ScaleColorSteps::new(
1346            crate::aes::Aesthetic::Color,
1347            stops.iter().map(|c| (c.r, c.g, c.b)).collect(),
1348            n_bins,
1349        );
1350        self.scale_color(s)
1351    }
1352
1353    /// Binned ColorBrewer colour scale (R's `scale_color_fermenter`).
1354    pub fn scale_color_fermenter(
1355        self,
1356        name: crate::scale::palettes::PaletteName,
1357        n_bins: usize,
1358    ) -> Self {
1359        let stops = crate::scale::palettes::palette(&name)
1360            .iter()
1361            .map(|c| (c.r, c.g, c.b))
1362            .collect();
1363        let s =
1364            crate::scale::steps::ScaleColorSteps::new(crate::aes::Aesthetic::Color, stops, n_bins);
1365        self.scale_color(s)
1366    }
1367
1368    /// Binned (stepped) two-colour continuous fill scale.
1369    pub fn scale_fill_steps(
1370        self,
1371        low: crate::scale::color::RGBAColor,
1372        high: crate::scale::color::RGBAColor,
1373        n_bins: usize,
1374    ) -> Self {
1375        let s = crate::scale::steps::ScaleColorSteps::two(
1376            crate::aes::Aesthetic::Fill,
1377            (low.r, low.g, low.b),
1378            (high.r, high.g, high.b),
1379            n_bins,
1380        );
1381        self.scale_fill(s)
1382    }
1383
1384    /// Binned ColorBrewer fill scale.
1385    pub fn scale_fill_fermenter(
1386        self,
1387        name: crate::scale::palettes::PaletteName,
1388        n_bins: usize,
1389    ) -> Self {
1390        let stops = crate::scale::palettes::palette(&name)
1391            .iter()
1392            .map(|c| (c.r, c.g, c.b))
1393            .collect();
1394        let s =
1395            crate::scale::steps::ScaleColorSteps::new(crate::aes::Aesthetic::Fill, stops, n_bins);
1396        self.scale_fill(s)
1397    }
1398
1399    /// Default discrete fill palette with levels in sorted order, so a level
1400    /// keeps its colour across charts regardless of data order. For a custom
1401    /// palette: `scale_fill(ScaleColorDiscrete::new(Aesthetic::Fill)
1402    /// .with_palette(p).sorted())`.
1403    pub fn scale_fill_discrete_sorted(self) -> Self {
1404        use crate::scale::color::ScaleColorDiscrete;
1405        self.scale_fill(ScaleColorDiscrete::new(crate::aes::Aesthetic::Fill).sorted())
1406    }
1407
1408    /// Default discrete colour palette with levels in sorted order (see
1409    /// [`scale_fill_discrete_sorted`](Self::scale_fill_discrete_sorted)).
1410    pub fn scale_color_discrete_sorted(self) -> Self {
1411        use crate::scale::color::ScaleColorDiscrete;
1412        self.scale_color(ScaleColorDiscrete::new(crate::aes::Aesthetic::Color).sorted())
1413    }
1414
1415    pub fn scale_fill_brewer(self, name: crate::scale::palettes::PaletteName) -> Self {
1416        use crate::scale::color::ScaleColorDiscrete;
1417        let s = ScaleColorDiscrete::new(crate::aes::Aesthetic::Fill).with_named_palette(&name);
1418        self.scale_fill(s)
1419    }
1420
1421    pub fn scale_linetype_manual(
1422        self,
1423        values: Vec<(&str, crate::render::backend::Linetype)>,
1424    ) -> Self {
1425        let s = crate::scale::linetype_manual::ScaleLinetypeManual::new(values);
1426        self.scale_color(s)
1427    }
1428
1429    pub fn scale_shape_manual(
1430        self,
1431        values: Vec<(&str, crate::render::backend::PointShape)>,
1432    ) -> Self {
1433        let s = crate::scale::shape_manual::ScaleShapeManual::new(values);
1434        self.scale_color(s)
1435    }
1436
1437    pub fn scale_color_grey(self) -> Self {
1438        let s = crate::scale::grey::ScaleColorGrey::new(crate::aes::Aesthetic::Color);
1439        self.scale_color(s)
1440    }
1441
1442    pub fn scale_fill_grey(self) -> Self {
1443        let s = crate::scale::grey::ScaleColorGrey::new(crate::aes::Aesthetic::Fill);
1444        self.scale_fill(s)
1445    }
1446
1447    pub fn scale_color_grey_with(self, s: crate::scale::grey::ScaleColorGrey) -> Self {
1448        self.scale_color(s)
1449    }
1450
1451    pub fn scale_fill_grey_with(self, s: crate::scale::grey::ScaleColorGrey) -> Self {
1452        self.scale_fill(s)
1453    }
1454
1455    pub fn scale_x_reverse(self) -> Self {
1456        self.scale_x_continuous(ScaleContinuous::new().with_transform(ScaleTransform::Reverse))
1457    }
1458
1459    pub fn scale_y_reverse(self) -> Self {
1460        self.scale_y_continuous(ScaleContinuous::new().with_transform(ScaleTransform::Reverse))
1461    }
1462
1463    pub fn scale_x_datetime(mut self, s: crate::scale::datetime::ScaleDateTime) -> Self {
1464        let s = s.for_aesthetic(crate::aes::Aesthetic::X);
1465        self.scales.push(Box::new(s));
1466        self
1467    }
1468
1469    pub fn scale_y_datetime(mut self, s: crate::scale::datetime::ScaleDateTime) -> Self {
1470        let s = s.for_aesthetic(crate::aes::Aesthetic::Y);
1471        self.scales.push(Box::new(s));
1472        self
1473    }
1474
1475    pub fn scale_size(mut self, s: crate::scale::size::ScaleSizeContinuous) -> Self {
1476        self.scales.push(Box::new(s));
1477        self
1478    }
1479
1480    /// `scale_size_area(max_size)` (ggplot2, B3-6): size scale where
1481    /// *area*, not radius, is proportional to the value, and 0 maps to 0.
1482    /// See [`ScaleSizeContinuous::area`](crate::scale::size::ScaleSizeContinuous::area).
1483    pub fn scale_size_area(mut self, max_size: f64) -> Self {
1484        self.scales.push(Box::new(
1485            crate::scale::size::ScaleSizeContinuous::new().area(max_size),
1486        ));
1487        self
1488    }
1489
1490    pub fn scale_alpha(mut self, s: crate::scale::alpha::ScaleAlphaContinuous) -> Self {
1491        self.scales.push(Box::new(s));
1492        self
1493    }
1494
1495    /// Continuous linewidth scale for the `linewidth` aesthetic (segment,
1496    /// curve, path, line) — see
1497    /// [`ScaleLinewidthContinuous`](crate::scale::linewidth::ScaleLinewidthContinuous).
1498    pub fn scale_linewidth(mut self, s: crate::scale::linewidth::ScaleLinewidthContinuous) -> Self {
1499        self.scales.push(Box::new(s));
1500        self
1501    }
1502
1503    pub fn xlim(self, min: f64, max: f64) -> Self {
1504        self.scale_x_continuous(ScaleContinuous::new().with_limits(min, max))
1505    }
1506
1507    pub fn ylim(self, min: f64, max: f64) -> Self {
1508        self.scale_y_continuous(ScaleContinuous::new().with_limits(min, max))
1509    }
1510
1511    pub fn scale_x_log10(self) -> Self {
1512        self.scale_x_continuous(ScaleContinuous::new().with_transform(ScaleTransform::Log10))
1513    }
1514
1515    pub fn scale_y_log10(self) -> Self {
1516        self.scale_y_continuous(ScaleContinuous::new().with_transform(ScaleTransform::Log10))
1517    }
1518
1519    pub fn scale_x_sqrt(self) -> Self {
1520        self.scale_x_continuous(ScaleContinuous::new().with_transform(ScaleTransform::Sqrt))
1521    }
1522
1523    pub fn scale_y_sqrt(self) -> Self {
1524        self.scale_y_continuous(ScaleContinuous::new().with_transform(ScaleTransform::Sqrt))
1525    }
1526
1527    pub fn scale_x_log2(self) -> Self {
1528        self.scale_x_continuous(ScaleContinuous::new().with_transform(ScaleTransform::Log2))
1529    }
1530
1531    pub fn scale_y_log2(self) -> Self {
1532        self.scale_y_continuous(ScaleContinuous::new().with_transform(ScaleTransform::Log2))
1533    }
1534
1535    pub fn scale_x_ln(self) -> Self {
1536        self.scale_x_continuous(ScaleContinuous::new().with_transform(ScaleTransform::Ln))
1537    }
1538
1539    pub fn scale_y_ln(self) -> Self {
1540        self.scale_y_continuous(ScaleContinuous::new().with_transform(ScaleTransform::Ln))
1541    }
1542
1543    /// Logit-transformed x axis (for proportions in (0, 1)).
1544    pub fn scale_x_logit(self) -> Self {
1545        self.scale_x_continuous(ScaleContinuous::new().with_transform(ScaleTransform::Logit))
1546    }
1547
1548    pub fn scale_y_logit(self) -> Self {
1549        self.scale_y_continuous(ScaleContinuous::new().with_transform(ScaleTransform::Logit))
1550    }
1551
1552    /// Probit-transformed x axis (inverse normal CDF, for proportions in (0, 1)).
1553    pub fn scale_x_probit(self) -> Self {
1554        self.scale_x_continuous(ScaleContinuous::new().with_transform(ScaleTransform::Probit))
1555    }
1556
1557    pub fn scale_y_probit(self) -> Self {
1558        self.scale_y_continuous(ScaleContinuous::new().with_transform(ScaleTransform::Probit))
1559    }
1560
1561    /// Sign-preserving pseudo-log x axis (handles zero and negative values).
1562    pub fn scale_x_pseudo_log(self) -> Self {
1563        self.scale_x_continuous(ScaleContinuous::new().with_transform(ScaleTransform::PseudoLog))
1564    }
1565
1566    pub fn scale_y_pseudo_log(self) -> Self {
1567        self.scale_y_continuous(ScaleContinuous::new().with_transform(ScaleTransform::PseudoLog))
1568    }
1569
1570    /// Reciprocal (1/x) x axis.
1571    pub fn scale_x_reciprocal(self) -> Self {
1572        self.scale_x_continuous(ScaleContinuous::new().with_transform(ScaleTransform::Reciprocal))
1573    }
1574
1575    pub fn scale_y_reciprocal(self) -> Self {
1576        self.scale_y_continuous(ScaleContinuous::new().with_transform(ScaleTransform::Reciprocal))
1577    }
1578
1579    /// Exponential x axis (labels spaced logarithmically).
1580    pub fn scale_x_exp(self) -> Self {
1581        self.scale_x_continuous(ScaleContinuous::new().with_transform(ScaleTransform::Exp))
1582    }
1583
1584    pub fn scale_y_exp(self) -> Self {
1585        self.scale_y_continuous(ScaleContinuous::new().with_transform(ScaleTransform::Exp))
1586    }
1587
1588    /// Box–Cox x axis with the given lambda (x > 0).
1589    pub fn scale_x_boxcox(self, lambda: f64) -> Self {
1590        self.scale_x_continuous(
1591            ScaleContinuous::new().with_transform(ScaleTransform::BoxCox(lambda)),
1592        )
1593    }
1594
1595    pub fn scale_y_boxcox(self, lambda: f64) -> Self {
1596        self.scale_y_continuous(
1597            ScaleContinuous::new().with_transform(ScaleTransform::BoxCox(lambda)),
1598        )
1599    }
1600
1601    // ─── Faceting ─────────────────────────────────────────────────
1602
1603    pub fn facet_wrap(mut self, var: &str, ncol: Option<usize>) -> Self {
1604        self.facet = Facet::Wrap {
1605            var: var.to_string(),
1606            ncol,
1607            scales: FacetScales::Fixed,
1608            labeller: FacetLabeller::default(),
1609        };
1610        self
1611    }
1612
1613    pub fn facet_wrap_free(mut self, var: &str, ncol: Option<usize>, scales: FacetScales) -> Self {
1614        self.facet = Facet::Wrap {
1615            var: var.to_string(),
1616            ncol,
1617            scales,
1618            labeller: FacetLabeller::default(),
1619        };
1620        self
1621    }
1622
1623    pub fn facet_wrap_labeller(
1624        mut self,
1625        var: &str,
1626        ncol: Option<usize>,
1627        labeller: FacetLabeller,
1628    ) -> Self {
1629        self.facet = Facet::Wrap {
1630            var: var.to_string(),
1631            ncol,
1632            scales: FacetScales::Fixed,
1633            labeller,
1634        };
1635        self
1636    }
1637
1638    pub fn facet_grid(mut self, row: Option<&str>, col: Option<&str>) -> Self {
1639        self.facet = Facet::Grid {
1640            row_var: row.map(String::from),
1641            col_var: col.map(String::from),
1642            scales: FacetScales::Fixed,
1643            labeller: FacetLabeller::default(),
1644            space: FacetSpace::Fixed,
1645        };
1646        self
1647    }
1648
1649    pub fn facet_grid_free(
1650        mut self,
1651        row: Option<&str>,
1652        col: Option<&str>,
1653        scales: FacetScales,
1654    ) -> Self {
1655        self.facet = Facet::Grid {
1656            row_var: row.map(String::from),
1657            col_var: col.map(String::from),
1658            scales,
1659            labeller: FacetLabeller::default(),
1660            space: FacetSpace::Fixed,
1661        };
1662        self
1663    }
1664
1665    /// `facet_grid` over **multiple** column variables (R's `rows ~ b + c`):
1666    /// the columns become the combination of the given variables' values.
1667    /// Also accepts free scales + proportional `space` for full parity.
1668    pub fn facet_grid_multi(
1669        mut self,
1670        row: Option<&str>,
1671        cols: &[&str],
1672        scales: FacetScales,
1673        space: FacetSpace,
1674    ) -> Self {
1675        let col_var = if cols.len() > 1 {
1676            // Build a synthetic column that is the interaction of the col vars.
1677            let n = self.data.nrows();
1678            let mut combined = Vec::with_capacity(n);
1679            for i in 0..n {
1680                let parts: Vec<String> = cols
1681                    .iter()
1682                    .map(|c| {
1683                        self.data
1684                            .column(c)
1685                            .and_then(|col| col.get(i))
1686                            .map(|v| v.to_group_key())
1687                            .unwrap_or_default()
1688                    })
1689                    .collect();
1690                combined.push(crate::data::Value::Str(parts.join(" . ")));
1691            }
1692            let name = "__facet_cols__".to_string();
1693            self.data.add_column(name.clone(), combined);
1694            Some(name)
1695        } else {
1696            cols.first().map(|s| s.to_string())
1697        };
1698        self.facet = Facet::Grid {
1699            row_var: row.map(String::from),
1700            col_var,
1701            scales,
1702            labeller: FacetLabeller::default(),
1703            space,
1704        };
1705        self
1706    }
1707
1708    /// `facet_grid` with proportional panel sizing (R's `space =`): panels are
1709    /// sized to their data range. Typically paired with free scales.
1710    pub fn facet_grid_space(
1711        mut self,
1712        row: Option<&str>,
1713        col: Option<&str>,
1714        scales: FacetScales,
1715        space: FacetSpace,
1716    ) -> Self {
1717        self.facet = Facet::Grid {
1718            row_var: row.map(String::from),
1719            col_var: col.map(String::from),
1720            scales,
1721            labeller: FacetLabeller::default(),
1722            space,
1723        };
1724        self
1725    }
1726
1727    pub fn facet_grid_labeller(
1728        mut self,
1729        row: Option<&str>,
1730        col: Option<&str>,
1731        labeller: FacetLabeller,
1732    ) -> Self {
1733        self.facet = Facet::Grid {
1734            row_var: row.map(String::from),
1735            col_var: col.map(String::from),
1736            scales: FacetScales::Fixed,
1737            labeller,
1738            space: FacetSpace::Fixed,
1739        };
1740        self
1741    }
1742
1743    // ─── Coordinates ─────────────────────────────────────────────
1744
1745    pub fn coord_flip(mut self) -> Self {
1746        self.coord = Box::new(CoordFlip);
1747        self
1748    }
1749
1750    pub fn coord_fixed(mut self, ratio: f64) -> Self {
1751        self.coord = Box::new(CoordFixed::new(ratio));
1752        self
1753    }
1754
1755    /// Spatial coordinate system (feature `sf`): equal aspect derived from the
1756    /// data extent, so projected geometry keeps its shape. Pair with a `geom_sf`
1757    /// projection (e.g. Mercator) for a conformal map.
1758    #[cfg(feature = "sf")]
1759    pub fn coord_sf(mut self) -> Self {
1760        self.coord = Box::new(crate::coord::sf::CoordSf::new());
1761        self
1762    }
1763
1764    /// Spatial coordinate system with explicit settings (`xlim`/`ylim`/
1765    /// `expand`) — see [`CoordSf`](crate::coord::sf::CoordSf). Used by
1766    /// consumers overlaying a fragment on their own projected basemap: a
1767    /// fixed pixel window via `xlim`/`ylim` and `expand(false)`.
1768    #[cfg(feature = "sf")]
1769    pub fn coord_sf_with(mut self, coord: crate::coord::sf::CoordSf) -> Self {
1770        self.coord = Box::new(coord);
1771        self
1772    }
1773
1774    /// Whether the rendered root `<svg>` carries `data-plot`/`data-domain`/
1775    /// `data-xdomain`/… (see [`root_data_attrs`](crate::render::svg_backend::root_data_attrs)).
1776    /// Default `true`. A consumer that overlays a rendered fragment
1777    /// (`render_svg_native_at`) on its own basemap — which already has its
1778    /// own root attributes and window — turns this off so the fragment adds
1779    /// none of its own.
1780    pub fn svg_root_data(mut self, on: bool) -> Self {
1781        self.svg_root_data = on;
1782        self
1783    }
1784
1785    /// Add one extra attribute to the root `<svg>` element — a host
1786    /// identifier, an ARIA label, a CSS hook, and so on. Allowed names:
1787    /// `data-*`, `aria-*`, `role`, `id`, `class`, `lang`; anything else, or
1788    /// a name ggplot-rs already writes to the root (`data-plot`,
1789    /// `data-domain`, …, or `data-warnings` when
1790    /// [`svg_warnings_attr`](Self::svg_warnings_attr) is also on), is
1791    /// ignored with a build warning instead of being silently dropped or
1792    /// duplicated. Values are escaped on output. PlotGrid has the same API
1793    /// ([`PlotGrid::svg_attr`](crate::compose::PlotGrid::svg_attr)).
1794    pub fn svg_attr(mut self, name: impl Into<String>, value: impl Into<String>) -> Self {
1795        self.root_attrs.push((name.into(), value.into()));
1796        self
1797    }
1798
1799    /// [`svg_attr`](Self::svg_attr) for several attributes at once.
1800    pub fn svg_attrs(mut self, attrs: impl IntoIterator<Item = (String, String)>) -> Self {
1801        self.root_attrs.extend(attrs);
1802        self
1803    }
1804
1805    /// Emit a `data-warnings` attribute on the root `<svg>` — a JSON array
1806    /// of the build warnings (rows dropped for non-finite positions, an
1807    /// `svg_attr` name rejected, …), `[]` when there are none. Default off,
1808    /// and omitted entirely when off (byte-identical to not calling this).
1809    pub fn svg_warnings_attr(mut self, on: bool) -> Self {
1810        self.svg_warnings_attr = on;
1811        self
1812    }
1813
1814    /// Emit `data-fvalue`/`data-fx` on marks: the value aesthetic's raw
1815    /// data value (y for most geoms, fill for hex/tile) and the x value,
1816    /// each formatted through its scale (currency/locale numbers, ISO or
1817    /// locale dates — [`Scale::format_value`](crate::scale::Scale::format_value))
1818    /// rather than the plain display string `data-value`/`data-x` already
1819    /// carry. `data-fx` is omitted when it would just duplicate `data-x`.
1820    /// Default off, and omitted entirely when off (byte-identical to not
1821    /// calling this).
1822    pub fn svg_formatted_values(mut self, on: bool) -> Self {
1823        self.svg_formatted_values = on;
1824        self
1825    }
1826
1827    /// Transform the coordinate space at draw time (R's `coord_trans`) — stats are
1828    /// computed on raw data but drawn on non-linear axes. Pass a per-axis
1829    /// [`ScaleTransform`] (e.g. `Some(ScaleTransform::Log10)`), `None` to leave an
1830    /// axis linear.
1831    pub fn coord_trans(mut self, x: Option<ScaleTransform>, y: Option<ScaleTransform>) -> Self {
1832        self.coord = Box::new(crate::coord::trans::CoordTrans::new(x, y));
1833        self
1834    }
1835
1836    /// `coord_trans` on the y-axis only.
1837    pub fn coord_trans_y(self, y: ScaleTransform) -> Self {
1838        self.coord_trans(None, Some(y))
1839    }
1840
1841    /// `coord_trans` on the x-axis only.
1842    pub fn coord_trans_x(self, x: ScaleTransform) -> Self {
1843        self.coord_trans(Some(x), None)
1844    }
1845
1846    /// Zoom into a region without filtering data (unlike xlim/ylim which filter).
1847    pub fn coord_cartesian_zoom(
1848        mut self,
1849        xlim: Option<(f64, f64)>,
1850        ylim: Option<(f64, f64)>,
1851    ) -> Self {
1852        let mut c = CoordCartesian::new();
1853        if let Some((min, max)) = xlim {
1854            c = c.xlim(min, max);
1855        }
1856        if let Some((min, max)) = ylim {
1857            c = c.ylim(min, max);
1858        }
1859        self.coord = Box::new(c);
1860        self
1861    }
1862
1863    pub fn coord_polar(mut self) -> Self {
1864        self.coord = Box::new(CoordPolar::new());
1865        self
1866    }
1867
1868    /// Radar / spider chart coordinates: discrete `x` → spokes, `y` → distance
1869    /// from the centre, straight segments, ring/spoke guides. Pair with
1870    /// `geom_polygon` (closed series) and `geom_point`.
1871    pub fn coord_radar(mut self) -> Self {
1872        self.coord = Box::new(crate::coord::radar::CoordRadar::new());
1873        self
1874    }
1875
1876    pub fn coord_radar_with(mut self, coord: crate::coord::radar::CoordRadar) -> Self {
1877        self.coord = Box::new(coord);
1878        self
1879    }
1880
1881    pub fn coord_polar_with(mut self, coord: CoordPolar) -> Self {
1882        self.coord = Box::new(coord);
1883        self
1884    }
1885
1886    // ─── Theme ───────────────────────────────────────────────────
1887
1888    /// Replace the theme. A `primary_color` set earlier is kept unless the new
1889    /// theme sets its own, so preset/`primary_color` call order doesn't matter.
1890    pub fn theme(mut self, theme: Theme) -> Self {
1891        self.apply_preset(theme);
1892        self
1893    }
1894
1895    fn apply_preset(&mut self, theme: Theme) {
1896        let primary = self.theme.primary;
1897        self.theme = theme;
1898        if self.theme.primary.is_none() {
1899            self.theme.primary = primary;
1900        }
1901    }
1902
1903    /// Rotate the x-axis tick labels by `degrees` (R's
1904    /// `guides(x = guide_axis(angle = ...))` / `axis.text.x = element_text(angle)`).
1905    /// Useful for long category labels. Call after any `theme_*()` preset.
1906    pub fn axis_text_x_angle(mut self, degrees: f64) -> Self {
1907        self.theme.axis_text_x.angle = degrees;
1908        self
1909    }
1910
1911    /// Rotate the y-axis tick labels by `degrees`.
1912    pub fn axis_text_y_angle(mut self, degrees: f64) -> Self {
1913        self.theme.axis_text_y.angle = degrees;
1914        self
1915    }
1916
1917    /// Stagger x-axis tick labels across `n` rows to avoid overlap (R's
1918    /// `guides(x = guide_axis(n.dodge = n))`). `1` = no dodging.
1919    pub fn axis_text_x_dodge(mut self, n: usize) -> Self {
1920        self.theme.axis_text_x_dodge = n.max(1);
1921        self
1922    }
1923
1924    /// Fix the panel's height:width ratio (R's `aspect.ratio`). Call after any
1925    /// `theme_*()` preset.
1926    pub fn aspect_ratio(mut self, ratio: f64) -> Self {
1927        self.theme.aspect_ratio = Some(ratio);
1928        self
1929    }
1930
1931    /// Draw gridlines on top of the data layers (R's `panel.ontop`).
1932    pub fn panel_ontop(mut self) -> Self {
1933        self.theme.panel_ontop = true;
1934        self
1935    }
1936
1937    /// Draw minor tick marks between major ticks (R's `axis.minor.ticks`).
1938    pub fn axis_minor_ticks(mut self) -> Self {
1939        self.theme.axis_minor_ticks = true;
1940        self
1941    }
1942
1943    /// How an outer x-axis label that would cross the SVG edge is handled
1944    /// (B3-1, D6): [`AxisLabelFit::Reserve`](crate::theme::AxisLabelFit::Reserve) (default), `Shift`, or `Clip`
1945    /// (the 0.21 behaviour). Use `Shift` for panels that must stay
1946    /// pixel-aligned with other elements (e.g. a host's own aligned strip).
1947    pub fn axis_text_x_fit(mut self, fit: crate::theme::AxisLabelFit) -> Self {
1948        self.theme.axis_text_x_fit = fit;
1949        self
1950    }
1951
1952    /// Thin crowded x-axis labels in ggplot2's `check.overlap` priority
1953    /// order (B3-1, D5). Off by default.
1954    pub fn axis_text_x_check_overlap(mut self, on: bool) -> Self {
1955        self.theme.axis_text_x_check_overlap = on;
1956        self
1957    }
1958
1959    /// Align title/subtitle/caption to the panel or the whole plot width
1960    /// (R's `plot.title.position`).
1961    pub fn title_position(mut self, pos: crate::theme::TitlePosition) -> Self {
1962        self.theme.title_position = pos;
1963        self
1964    }
1965
1966    /// Corner for the `tag()` label (R's `plot.tag.position`).
1967    pub fn tag_position(mut self, pos: crate::theme::TagPosition) -> Self {
1968        self.theme.tag_position = pos;
1969        self
1970    }
1971
1972    /// Force the legend key layout direction (R's `legend.direction`).
1973    pub fn legend_direction(mut self, dir: crate::theme::LegendDirection) -> Self {
1974        self.theme.legend_direction = Some(dir);
1975        self
1976    }
1977
1978    /// Set the brand/primary color used as the default for single-series geoms
1979    /// that have no color/fill aesthetic mapped. Composes with any theme — one
1980    /// render process can serve different tenants' brands at render time.
1981    /// Place the legend inside the panel at panel-relative coordinates
1982    /// (0..1, 0..1) — `(0,0)` bottom-left, `(1,1)` top-right (R's
1983    /// `legend.position = c(x, y)`).
1984    pub fn legend_position_inside(mut self, x: f64, y: f64) -> Self {
1985        self.theme.legend_position = crate::theme::LegendPosition::Inside(x, y);
1986        self
1987    }
1988
1989    /// Set the legend position (R's `legend.position`, e.g. `Top`/`Bottom`).
1990    pub fn legend_position(mut self, pos: crate::theme::LegendPosition) -> Self {
1991        self.theme.legend_position = pos;
1992        self
1993    }
1994
1995    pub fn primary_color(mut self, color: (u8, u8, u8)) -> Self {
1996        self.theme.primary = Some(color);
1997        self
1998    }
1999
2000    pub fn theme_minimal(mut self) -> Self {
2001        self.apply_preset(crate::theme::presets::theme_minimal());
2002        self
2003    }
2004
2005    pub fn theme_bw(mut self) -> Self {
2006        self.apply_preset(crate::theme::presets::theme_bw());
2007        self
2008    }
2009
2010    pub fn theme_gray(mut self) -> Self {
2011        self.apply_preset(crate::theme::presets::theme_gray());
2012        self
2013    }
2014
2015    pub fn theme_classic(mut self) -> Self {
2016        self.apply_preset(crate::theme::presets::theme_classic());
2017        self
2018    }
2019
2020    /// Apply the publication-ready `theme_pubr()` (ggpubr style).
2021    pub fn theme_pubr(mut self) -> Self {
2022        self.apply_preset(crate::theme::presets::theme_pubr());
2023        self
2024    }
2025
2026    pub fn theme_linedraw(mut self) -> Self {
2027        self.apply_preset(crate::theme::presets::theme_linedraw());
2028        self
2029    }
2030
2031    pub fn theme_light(mut self) -> Self {
2032        self.apply_preset(crate::theme::presets::theme_light());
2033        self
2034    }
2035
2036    pub fn theme_dark(mut self) -> Self {
2037        self.apply_preset(crate::theme::presets::theme_dark());
2038        self
2039    }
2040
2041    pub fn theme_void(mut self) -> Self {
2042        self.apply_preset(crate::theme::presets::theme_void());
2043        self
2044    }
2045
2046    /// Apply incremental theme modifications on top of the current theme.
2047    /// Like R's `+ theme(axis.text.x = element_text(...))`.
2048    pub fn theme_update(mut self, update: crate::theme::ThemeUpdate) -> Self {
2049        self.theme = self.theme.update(update);
2050        self
2051    }
2052
2053    // ─── Guides ──────────────────────────────────────────────────
2054
2055    /// Configure legend guide (title, ncol, reverse).
2056    pub fn guides(mut self, guide: crate::guide::config::GuideLegend) -> Self {
2057        self.guide_legend = guide;
2058        self
2059    }
2060
2061    // ─── Labels ──────────────────────────────────────────────────
2062
2063    pub fn labs(mut self, labels: Labels) -> Self {
2064        if labels.title.is_some() {
2065            self.labels.title = labels.title;
2066        }
2067        if labels.subtitle.is_some() {
2068            self.labels.subtitle = labels.subtitle;
2069        }
2070        if labels.x.is_some() {
2071            self.labels.x = labels.x;
2072        }
2073        if labels.y.is_some() {
2074            self.labels.y = labels.y;
2075        }
2076        if labels.caption.is_some() {
2077            self.labels.caption = labels.caption;
2078        }
2079        if labels.tag.is_some() {
2080            self.labels.tag = labels.tag;
2081        }
2082        self
2083    }
2084
2085    pub fn title(mut self, title: &str) -> Self {
2086        self.labels.title = Some(title.to_string());
2087        self
2088    }
2089
2090    /// Corner tag label (R's `labs(tag = ...)`), drawn at the top-left — handy
2091    /// for labelling figure panels ("A", "B", …).
2092    pub fn tag(mut self, tag: &str) -> Self {
2093        self.labels.tag = Some(tag.to_string());
2094        self
2095    }
2096
2097    pub fn subtitle(mut self, subtitle: &str) -> Self {
2098        self.labels.subtitle = Some(subtitle.to_string());
2099        self
2100    }
2101
2102    pub fn xlab(mut self, label: &str) -> Self {
2103        self.labels.x = Some(label.to_string());
2104        self
2105    }
2106
2107    pub fn ylab(mut self, label: &str) -> Self {
2108        self.labels.y = Some(label.to_string());
2109        self
2110    }
2111
2112    pub fn caption(mut self, caption: &str) -> Self {
2113        self.labels.caption = Some(caption.to_string());
2114        self
2115    }
2116
2117    // ─── Annotations ──────────────────────────────────────────────
2118
2119    /// Add an annotation to the plot.
2120    pub fn annotate(mut self, annotation: Annotation) -> Self {
2121        self.annotations.push(annotation);
2122        self
2123    }
2124
2125    /// Add a text annotation at data coordinates.
2126    pub fn annotate_text(self, label: &str, x: f64, y: f64) -> Self {
2127        self.annotate(Annotation::text(label, x, y))
2128    }
2129
2130    /// Add a rectangle annotation at data coordinates.
2131    pub fn annotate_rect(self, xmin: f64, xmax: f64, ymin: f64, ymax: f64) -> Self {
2132        self.annotate(Annotation::rect(xmin, xmax, ymin, ymax))
2133    }
2134
2135    /// Add a segment annotation between data coordinates.
2136    pub fn annotate_segment(self, x: f64, y: f64, xend: f64, yend: f64) -> Self {
2137        self.annotate(Annotation::segment(x, y, xend, yend))
2138    }
2139
2140    /// Default [`Locale`](crate::locale::Locale) for the plot's axis and
2141    /// legend labels: every continuous position / colour / size / alpha scale
2142    /// labels its default breaks with the locale's decimal and thousands marks
2143    /// (`1.000`, `2,5` in de-DE), and date/datetime scales use its month names
2144    /// and date formats (`Okt. 2026`, `15. Okt. 2026`). A scale's own
2145    /// `with_locale` and custom `label_*` formatters take precedence.
2146    /// Without this call labels are unchanged (`.` decimal mark, no grouping,
2147    /// ISO dates).
2148    ///
2149    /// ```
2150    /// use ggplot_rs::prelude::*;
2151    /// let svg = GGPlot::new(vec![
2152    ///         ("x".to_string(), vec![Value::Float(0.0), Value::Float(3000.0)]),
2153    ///         ("y".to_string(), vec![Value::Float(0.5), Value::Float(2.5)]),
2154    ///     ])
2155    ///     .aes(Aes::new().x("x").y("y"))
2156    ///     .geom_point()
2157    ///     .locale(Locale::from_tag("de-DE"))
2158    ///     .render_svg_native()
2159    ///     .unwrap();
2160    /// assert!(svg.contains(">1.000<") && svg.contains(">1,5<"));
2161    /// ```
2162    pub fn locale(mut self, locale: crate::locale::Locale) -> Self {
2163        self.locale = Some(locale);
2164        self
2165    }
2166
2167    // ─── Build and Render ────────────────────────────────────────
2168
2169    /// Build the plot without rendering, returning errors on validation failure.
2170    pub fn try_build(self) -> Result<crate::build::BuiltPlot, GGError> {
2171        PlotBuilder::build(self)
2172    }
2173
2174    /// Build the plot without rendering (analogous to R's ggplot_build()).
2175    /// Returns the fully computed BuiltPlot with layer data ready for inspection.
2176    /// Panics on validation errors — use `try_build()` for error handling.
2177    pub fn build(self) -> crate::build::BuiltPlot {
2178        self.try_build().expect("plot build failed")
2179    }
2180
2181    /// Build and save the plot to a file. Format determined by extension.
2182    /// (Requires the `plotters` feature, on by default; native only — wasm has
2183    /// no filesystem/plotters backend.)
2184    #[cfg(all(feature = "plotters", not(target_arch = "wasm32")))]
2185    pub fn save(self, path: &str) -> Result<(), GGError> {
2186        self.save_with_size(path, 800, 600)
2187    }
2188
2189    /// Build and save with custom dimensions. (Feature `plotters`; native only.)
2190    #[cfg(all(feature = "plotters", not(target_arch = "wasm32")))]
2191    pub fn save_with_size(self, path: &str, w: u32, h: u32) -> Result<(), GGError> {
2192        let (built, layout) = self.prepare(w, h)?;
2193
2194        // Determine backend from file extension
2195        let ext = path.rsplit('.').next().unwrap_or("svg").to_lowercase();
2196
2197        match ext.as_str() {
2198            "svg" => {
2199                let backend = plotters::prelude::SVGBackend::new(path, (w, h));
2200                Self::render_into(backend.into_drawing_area(), &built, &layout)?;
2201            }
2202            "png" | "bmp" | "gif" | "jpeg" | "jpg" | "tiff" => {
2203                let backend = plotters::prelude::BitMapBackend::new(path, (w, h));
2204                Self::render_into(backend.into_drawing_area(), &built, &layout)?;
2205            }
2206            _ => {
2207                return Err(GGError::UnsupportedFormat(ext));
2208            }
2209        }
2210
2211        Ok(())
2212    }
2213
2214    /// Render the plot to an in-memory SVG document (default 800x600).
2215    ///
2216    /// Unlike [`save`](Self::save), this writes nothing to disk — handy for
2217    /// serving charts from a web/MCP service. Requires the `plotters` feature
2218    /// (on by default); without it use [`render_svg_native`](Self::render_svg_native).
2219    #[cfg(all(feature = "plotters", not(target_arch = "wasm32")))]
2220    pub fn render_svg(self) -> Result<String, GGError> {
2221        self.render_svg_with_size(800, 600)
2222    }
2223
2224    /// Render the plot to an in-memory SVG document with custom dimensions.
2225    /// (Feature `plotters`; native only — on wasm or without `plotters` use
2226    /// [`render_svg_native_with_size`](Self::render_svg_native_with_size).)
2227    #[cfg(all(feature = "plotters", not(target_arch = "wasm32")))]
2228    pub fn render_svg_with_size(self, w: u32, h: u32) -> Result<String, GGError> {
2229        let (built, layout) = self.prepare(w, h)?;
2230        let mut buf = String::new();
2231        {
2232            let backend = plotters::prelude::SVGBackend::with_string(&mut buf, (w, h));
2233            Self::render_into(backend.into_drawing_area(), &built, &layout)?;
2234        }
2235        Ok(buf)
2236    }
2237
2238    /// Render to SVG through the self-contained [`SvgBackend`](crate::render::svg_backend::SvgBackend)
2239    /// — a plotters-free path that drives the same `PlotRenderer`, proving the
2240    /// `DrawBackend` abstraction (and needing no glyph rasterization).
2241    pub fn render_svg_native(self) -> Result<String, GGError> {
2242        self.render_svg_native_with_size(800, 600)
2243    }
2244
2245    /// [`render_svg_native`](Self::render_svg_native) with an explicit size.
2246    ///
2247    /// The root `<svg>` carries `data-plot="x y w h"` (the panel rect) and the
2248    /// trained position domains (`data-domain`, `data-xdomain`, `data-ydomain`,
2249    /// `data-xlevels`, `data-ylevels` — see
2250    /// [`root_data_attrs`](crate::render::svg_backend::root_data_attrs)).
2251    /// Hoverable marks carry `data-x`, `data-series` and `data-value`.
2252    pub fn render_svg_native_with_size(self, w: u32, h: u32) -> Result<String, GGError> {
2253        Ok(self.render_native(w, h)?.0.finish())
2254    }
2255
2256    /// Like [`render_svg_native_with_size`](Self::render_svg_native_with_size),
2257    /// but also returns the build warnings (rows dropped for non-finite
2258    /// positions, layers whose stat produced no data, …) — see
2259    /// [`BuiltPlot::warnings`](crate::build::BuiltPlot::warnings).
2260    pub fn render_svg_native_with_warnings(
2261        self,
2262        w: u32,
2263        h: u32,
2264    ) -> Result<(String, Vec<String>), GGError> {
2265        let (backend, warnings, _) = self.render_native(w, h)?;
2266        Ok((backend.finish(), warnings))
2267    }
2268
2269    /// Render a `w`×`h` chart as a *nested* `<svg x=.. y=.. width=.. height=..
2270    /// viewBox="0 0 w h">` fragment for composition into a larger SVG (a
2271    /// dashboard): no `xmlns` (inherited from the parent), positioned at
2272    /// `(x, y)` in the parent's user space. No string surgery needed:
2273    ///
2274    /// ```ignore
2275    /// let mut page = String::from(r#"<svg xmlns="http://www.w3.org/2000/svg" width="900" height="400">"#);
2276    /// page += &plot_a.render_svg_native_at(0.0, 0.0, 450, 400)?;
2277    /// page += &plot_b.render_svg_native_at(450.0, 0.0, 450, 400)?;
2278    /// page += "</svg>";
2279    /// ```
2280    pub fn render_svg_native_at(self, x: f64, y: f64, w: u32, h: u32) -> Result<String, GGError> {
2281        Ok(self.render_native(w, h)?.0.finish_fragment(x, y))
2282    }
2283
2284    /// Like [`render_svg_native_with_size`](Self::render_svg_native_with_size),
2285    /// but also returns the panel rect in pixels `[x, y, w, h]` — enough to
2286    /// overlay a WebGL/canvas layer that draws marks in data coordinates.
2287    pub fn render_svg_area_with_size(self, w: u32, h: u32) -> Result<(String, [f64; 4]), GGError> {
2288        let (backend, _, pa) = self.render_native(w, h)?;
2289        Ok((backend.finish(), [pa.x, pa.y, pa.width, pa.height]))
2290    }
2291
2292    /// Shared native-SVG pipeline: build, lay out, render into an
2293    /// [`SvgBackend`](crate::render::svg_backend::SvgBackend) (not yet
2294    /// finished), returning it with the build warnings and the panel rect.
2295    fn render_native(
2296        self,
2297        w: u32,
2298        h: u32,
2299    ) -> Result<
2300        (
2301            crate::render::svg_backend::SvgBackend,
2302            Vec<String>,
2303            crate::render::Rect,
2304        ),
2305        GGError,
2306    > {
2307        let svg_root_data = self.svg_root_data;
2308        let root_attrs = self.root_attrs.clone();
2309        let svg_warnings_attr = self.svg_warnings_attr;
2310        let svg_formatted_values = self.svg_formatted_values;
2311        let (built, layout) = self.prepare(w, h)?;
2312        let pa = layout.plot_area.clone();
2313        let mut backend = crate::render::svg_backend::SvgBackend::new(w, h, pa.clone());
2314        backend.set_formatted_values(svg_formatted_values);
2315        let existing_root_attrs = if svg_root_data {
2316            let attrs = crate::render::svg_backend::root_data_attrs(&built);
2317            backend.set_root_attrs(attrs.clone());
2318            // `data-plot` itself is a literal in `root_open`, not part of
2319            // `root_data_attrs`'s output, but it is written whenever
2320            // `svg_root_data` is on — reserve it too.
2321            let mut reserved = attrs;
2322            reserved.push(("data-plot".to_string(), String::new()));
2323            reserved
2324        } else {
2325            backend.without_plot_attr();
2326            Vec::new()
2327        };
2328        PlotRenderer::render(&built, &mut backend).map_err(GGError::Render)?;
2329        let mut warnings = built.warnings;
2330        warnings.extend(backend.take_warnings());
2331
2332        let mut reserved = existing_root_attrs;
2333        if svg_warnings_attr {
2334            reserved.push(("data-warnings".to_string(), String::new()));
2335        }
2336        let extra = crate::render::svg_backend::validate_root_attrs(
2337            &root_attrs,
2338            &reserved,
2339            "plot",
2340            &mut warnings,
2341        );
2342        backend.extend_root_attrs(extra);
2343        if svg_warnings_attr {
2344            backend.extend_root_attrs(vec![(
2345                "data-warnings".to_string(),
2346                crate::render::svg_backend::warnings_json(&warnings),
2347            )]);
2348        }
2349        Ok((backend, warnings, pa))
2350    }
2351
2352    /// Render to a raw RGBA pixel buffer via the self-contained raster
2353    /// [`PixelBackend`](crate::render::pixel_backend::PixelBackend) (feature
2354    /// `canvas`) — fast for large-N and wasm-compatible. Returns `(w, h, rgba)`,
2355    /// ready for a `<canvas>` `putImageData`.
2356    #[cfg(feature = "canvas")]
2357    pub fn render_rgba_with_size(self, w: u32, h: u32) -> Result<(u32, u32, Vec<u8>), GGError> {
2358        let (built, layout) = self.prepare(w, h)?;
2359        let mut backend =
2360            crate::render::pixel_backend::PixelBackend::new(w, h, layout.plot_area.clone());
2361        PlotRenderer::render(&built, &mut backend).map_err(GGError::Render)?;
2362        let (ow, oh) = backend.dimensions();
2363        Ok((ow, oh, backend.into_rgba()))
2364    }
2365
2366    /// Like [`render_rgba_with_size`](Self::render_rgba_with_size), but also
2367    /// returns the panel rect in pixels `[x, y, w, h]` — enough to map a canvas
2368    /// pixel back to data coordinates for interactive hover.
2369    #[cfg(feature = "canvas")]
2370    pub fn render_rgba_area_with_size(
2371        self,
2372        w: u32,
2373        h: u32,
2374    ) -> Result<(Vec<u8>, [f64; 4]), GGError> {
2375        let (built, layout) = self.prepare(w, h)?;
2376        let pa = layout.plot_area.clone();
2377        let mut backend = crate::render::pixel_backend::PixelBackend::new(w, h, pa.clone());
2378        PlotRenderer::render(&built, &mut backend).map_err(GGError::Render)?;
2379        Ok((backend.into_rgba(), [pa.x, pa.y, pa.width, pa.height]))
2380    }
2381
2382    /// Render to PNG bytes via the raster [`PixelBackend`](crate::render::pixel_backend::PixelBackend)
2383    /// (feature `canvas`). Unlike [`render_png`](Self::render_png) this needs no
2384    /// plotters bitmap backend, so it also works on wasm.
2385    #[cfg(feature = "canvas")]
2386    pub fn render_png_raster_with_size(self, w: u32, h: u32) -> Result<Vec<u8>, GGError> {
2387        let (built, layout) = self.prepare(w, h)?;
2388        let mut backend =
2389            crate::render::pixel_backend::PixelBackend::new(w, h, layout.plot_area.clone());
2390        PlotRenderer::render(&built, &mut backend).map_err(GGError::Render)?;
2391        backend.into_png().map_err(GGError::Render)
2392    }
2393
2394    /// Render the plot to in-memory PNG bytes (default 800x600).
2395    ///
2396    /// Returns a fully-encoded PNG, ready to write to an HTTP response or
2397    /// embed as a data URI — no temp files involved. Requires the `plotters`
2398    /// feature (on by default); see also `render_png_raster_with_size`
2399    /// (feature `canvas`).
2400    #[cfg(all(feature = "plotters", not(target_arch = "wasm32")))]
2401    pub fn render_png(self) -> Result<Vec<u8>, GGError> {
2402        self.render_png_with_size(800, 600)
2403    }
2404
2405    /// Render the plot to in-memory PNG bytes with custom dimensions. (Feature
2406    /// `plotters`; native only — PNG needs the plotters bitmap backend.)
2407    #[cfg(all(feature = "plotters", not(target_arch = "wasm32")))]
2408    pub fn render_png_with_size(self, w: u32, h: u32) -> Result<Vec<u8>, GGError> {
2409        let (built, layout) = self.prepare(w, h)?;
2410
2411        // plotters' BitMapBackend draws into a raw RGB buffer; we then encode
2412        // that buffer to PNG via the `image` crate.
2413        let mut rgb = vec![0u8; (w as usize) * (h as usize) * 3];
2414        {
2415            let backend = plotters::prelude::BitMapBackend::with_buffer(&mut rgb, (w, h));
2416            Self::render_into(backend.into_drawing_area(), &built, &layout)?;
2417        }
2418
2419        let img = image::RgbImage::from_raw(w, h, rgb).ok_or_else(|| {
2420            GGError::Render(RenderError::BackendError(
2421                "PNG buffer size mismatch".to_string(),
2422            ))
2423        })?;
2424        let mut out = std::io::Cursor::new(Vec::new());
2425        img.write_to(&mut out, image::ImageOutputFormat::Png)
2426            .map_err(|e| GGError::Render(RenderError::BackendError(format!("{:?}", e))))?;
2427        Ok(out.into_inner())
2428    }
2429
2430    /// Shared pipeline: build the plot, apply label overrides, compute layout.
2431    pub(crate) fn prepare(
2432        self,
2433        w: u32,
2434        h: u32,
2435    ) -> Result<(crate::build::BuiltPlot, PlotLayout), GGError> {
2436        let (mut built, meta) = self.build_for_render()?;
2437        let layout = Self::layout_built(&mut built, &meta, w, h);
2438        Ok((built, layout))
2439    }
2440
2441    /// The size-independent half of [`prepare`](Self::prepare): build the plot,
2442    /// resolve theme inheritance and apply axis-label overrides.
2443    pub(crate) fn build_for_render(self) -> Result<(crate::build::BuiltPlot, RenderMeta), GGError> {
2444        let plot = self;
2445
2446        let has_title = plot.labels.title.is_some();
2447        let has_subtitle = plot.labels.subtitle.is_some();
2448        let has_caption = plot.labels.caption.is_some();
2449        let has_legend = plot.has_legend_mapping();
2450        let x_label = plot.labels.x.clone();
2451        let y_label = plot.labels.y.clone();
2452
2453        let mut built = PlotBuilder::build(plot)?;
2454
2455        // Resolve R-style theme inheritance (root `text` → child text elements).
2456        built.theme.resolve_inheritance();
2457
2458        // Apply user label overrides to scales
2459        if let Some(ref label) = x_label {
2460            if let Some(s) = built.scales.get_mut(&crate::aes::Aesthetic::X) {
2461                s.set_name(label);
2462            }
2463        }
2464        if let Some(ref label) = y_label {
2465            if let Some(s) = built.scales.get_mut(&crate::aes::Aesthetic::Y) {
2466                s.set_name(label);
2467            }
2468        }
2469
2470        Ok((
2471            built,
2472            RenderMeta {
2473                has_title,
2474                has_subtitle,
2475                has_caption,
2476                has_legend,
2477            },
2478        ))
2479    }
2480
2481    /// The size-dependent half of [`prepare`](Self::prepare): auto-tune axis
2482    /// labels for `w`×`h` and compute the layout.
2483    pub(crate) fn layout_built(
2484        built: &mut crate::build::BuiltPlot,
2485        meta: &RenderMeta,
2486        w: u32,
2487        h: u32,
2488    ) -> PlotLayout {
2489        let RenderMeta {
2490            has_title,
2491            has_subtitle,
2492            has_caption,
2493            has_legend,
2494        } = *meta;
2495        let x_axis_top = built
2496            .scales
2497            .get(&crate::aes::Aesthetic::X)
2498            .map(|s| s.axis_position_opposite())
2499            .unwrap_or(false);
2500
2501        // Auto-tune categorical axis labels so they stay legible. A flip swaps
2502        // which trained scale sits on each visual axis: the bottom (x) axis reads
2503        // the Y scale when flipped, the left (y) axis reads the X scale.
2504        let flipped = built.coord.is_flipped();
2505        let h_aes = if flipped {
2506            crate::aes::Aesthetic::Y
2507        } else {
2508            crate::aes::Aesthetic::X
2509        };
2510        let v_aes = if flipped {
2511            crate::aes::Aesthetic::X
2512        } else {
2513            crate::aes::Aesthetic::Y
2514        };
2515
2516        // The bottom-axis break labels that are actually drawn — a
2517        // `with_labels` override can blank some entries ("" ) to thin a
2518        // crowded discrete axis while keeping every position (sipemu/
2519        // ggplot-rs#162); a blanked entry is never drawn, so it never
2520        // competes for horizontal room or vertical drop below. Shared by
2521        // the auto-rotate decision and the rotated-label height reservation
2522        // just below, so both agree on what's actually on the canvas.
2523        let drawn_x_labels: Vec<(f64, String)> = built
2524            .scales
2525            .get(&h_aes)
2526            .map(|hs| {
2527                hs.breaks()
2528                    .into_iter()
2529                    .filter(|(_, label)| !label.is_empty())
2530                    .collect()
2531            })
2532            .unwrap_or_default();
2533        let h_is_discrete = built
2534            .scales
2535            .get(&h_aes)
2536            .map(|hs| hs.is_discrete())
2537            .unwrap_or(false);
2538
2539        // Crowded discrete bottom-axis labels overlap when drawn flat. Rotate
2540        // them — −45° if that de-collides, otherwise vertical (270°) — only
2541        // when no explicit angle was set (angle.abs() < 1.0 reads as
2542        // "unset"). Sized from the real, measured widths of the labels that
2543        // are actually drawn (`render::text::text_width`), both for the
2544        // label-width estimate and the crowding count `n` — #162's bug was
2545        // counting every break, blanked or not, which over-triggered the
2546        // rotation. −45° (not +45°) is ggplot2's own idiom (`angle = 45,
2547        // hjust = 1`, mirrored to this library's angle sign, sipemu/
2548        // ggplot-rs#163): it ends each label at its own tick and slants
2549        // down-left, rather than +45°'s down-right slant, which — anchored
2550        // the same way — would start under the *previous* category.
2551        if built.theme.axis_text_x.visible
2552            && built.theme.axis_text_x.angle.abs() < 1.0
2553            && h_is_discrete
2554            && !drawn_x_labels.is_empty()
2555        {
2556            let font = built.theme.axis_text_x.size;
2557            let max_w = drawn_x_labels
2558                .iter()
2559                .map(|(_, l)| crate::render::text::text_width(l, font))
2560                .fold(0.0_f64, f64::max);
2561            let n = drawn_x_labels.len() as f64;
2562            let spacing = 0.9 * w as f64 / n; // ~px between adjacent *drawn* labels
2563            if max_w > spacing {
2564                // 45° shrinks the horizontal footprint by cos45; if that
2565                // still doesn't fit, go vertical (270° = reads bottom-to-
2566                // top, hanging below the axis like the y-axis title).
2567                let vertical = max_w * std::f64::consts::FRAC_1_SQRT_2 > spacing;
2568                built.theme.axis_text_x.angle = if vertical { 270.0 } else { -45.0 };
2569            }
2570        }
2571
2572        // Whenever the bottom-axis labels end up rotated — by the
2573        // auto-rotate decision just above, or by an explicit
2574        // `axis_text_x_angle` call — reserve the real bottom space their
2575        // rotated bounding boxes need, so they never overlap the panel
2576        // (#162 also asked for this: an explicit angle reserved nothing).
2577        // `rotated_label_rise_fall` is the same helper `draw_x_axis` calls
2578        // to place its rotation anchor — `rise` must be reserved (as well
2579        // as placed) or a long enough label's far end swings right back up
2580        // over the panel regardless of how much height is reserved here;
2581        // `fall` just needs reserving so the label stays on-canvas. Both
2582        // here and in `draw_x_axis`, the anchor fraction must come from
2583        // `rotated_anchor_frac(angle, dir)` (sipemu/ggplot-rs#163) — not a
2584        // blind `1.0` — or the reservation is sized for the wrong end of
2585        // the label; `dir` matches `draw_x_axis`'s own tick direction.
2586        let x_label_height = if built.theme.axis_text_x.visible
2587            && built.theme.axis_text_x.angle.abs() > 10.0
2588            && !drawn_x_labels.is_empty()
2589        {
2590            let font = built.theme.axis_text_x.size;
2591            let angle = built.theme.axis_text_x.angle;
2592            let dir = if x_axis_top { -1.0 } else { 1.0 };
2593            let (rise, fall) = crate::render::text::rotated_label_rise_fall(
2594                drawn_x_labels.iter().map(|(_, l)| l.as_str()),
2595                font,
2596                angle,
2597                crate::render::text::rotated_anchor_frac(angle, dir),
2598            );
2599            Some(built.theme.legend_spacing / 2.0 + font / 2.0 + rise + fall)
2600        } else {
2601            None
2602        };
2603
2604        // Reserve the left-axis width from the actual label lengths so long
2605        // category names (a flipped bar chart's rows) aren't clipped. Never
2606        // shrinks below the default; grows up to 35% of the width.
2607        let y_label_width = if built.theme.axis_text_y.visible {
2608            built.scales.get(&v_aes).map(|vs| {
2609                let max_ch = vs
2610                    .breaks()
2611                    .iter()
2612                    .map(|(_, l)| l.chars().count())
2613                    .max()
2614                    .unwrap_or(0);
2615                let measured = max_ch as f64 * built.theme.axis_text_y.size * 0.6 + 4.0;
2616                let default = built.theme.axis_text_y.size * 3.5 + 4.0;
2617                measured.max(default).min(0.35 * w as f64)
2618            })
2619        } else {
2620            None
2621        };
2622
2623        // B3-1 (D6): reserve just enough left/right panel margin to keep
2624        // the outer x-axis labels on-canvas under `AxisLabelFit::Reserve`,
2625        // for a single (non-faceted) panel on a coord that supports it
2626        // (not polar/radar/sf, where a label bbox doesn't map to a simple
2627        // panel margin). A faceted plot skips the layout-level reservation
2628        // and relies on `draw_x_axis`'s own draw-time shift instead — so
2629        // does `AxisLabelFit::Shift` (the panel must keep its size) and
2630        // `Clip` (the 0.21 behaviour, no correction at all). A plot whose
2631        // outer labels already fit never grows `extras`, so it stays
2632        // byte-identical.
2633        let mut extras = crate::render::layout::LayoutExtras::default();
2634        let can_reserve = built.facet.is_none()
2635            && built.theme.axis_text_x.visible
2636            && built.theme.axis_text_x_fit == crate::theme::AxisLabelFit::Reserve
2637            && !built.coord.is_polar()
2638            && built.coord.supports_axis_reserve();
2639        if can_reserve {
2640            if let Some(hs) = built.scales.get(&h_aes) {
2641                let breaks = hs.breaks();
2642                if let (Some(first), Some(last)) = (breaks.first(), breaks.last()) {
2643                    let angle = built.theme.axis_text_x.angle;
2644                    let anchor = if angle.abs() > 10.0 {
2645                        let dir = if x_axis_top { -1.0 } else { 1.0 };
2646                        crate::render::text::rotated_anchor_frac(angle, dir)
2647                    } else {
2648                        0.5
2649                    };
2650                    let size = built.theme.axis_text_x.size;
2651                    const EDGE: f64 = 1.0;
2652                    for _ in 0..2 {
2653                        let layout = PlotLayout::compute_with_extras(
2654                            w as f64,
2655                            h as f64,
2656                            &built.theme,
2657                            has_title,
2658                            has_subtitle,
2659                            has_caption,
2660                            has_legend,
2661                            x_axis_top,
2662                            y_label_width,
2663                            x_label_height,
2664                            extras,
2665                        );
2666                        let area = &layout.plot_area;
2667                        let edge_y = area.y + area.height;
2668                        let to_point = |pos: f64| if flipped { (0.0, pos) } else { (pos, 0.0) };
2669                        let px_first = built.coord.transform(to_point(first.0), area).0;
2670                        let px_last = built.coord.transform(to_point(last.0), area).0;
2671                        let bbox_first = crate::render::text::label_bbox(
2672                            &first.1, size, px_first, edge_y, angle, anchor,
2673                        );
2674                        let bbox_last = crate::render::text::label_bbox(
2675                            &last.1, size, px_last, edge_y, angle, anchor,
2676                        );
2677                        let deficit_left = (EDGE - bbox_first[0]).max(0.0);
2678                        let deficit_right = (bbox_last[2] - (w as f64 - EDGE)).max(0.0);
2679                        if deficit_left <= 0.0 && deficit_right <= 0.0 {
2680                            break;
2681                        }
2682                        // Growing the left margin by `eL` (holding `eR` fixed)
2683                        // shifts a label at normalized position `p` right by
2684                        // `eL·(1 − p)`; growing the right margin by `eR`
2685                        // shifts it left by `eR·p` (the panel maps [0, 1] to
2686                        // [plot_x + eL, plot_x + width − eR]). Review #158
2687                        // finding 1: the original code divided by `p_first`
2688                        // and `1 − p_last` — inverted, which over-reserved
2689                        // by about `1/p_last` (≈20× at a typical p_last≈0.95).
2690                        let (p_first, p_last) = (first.0, last.0);
2691                        if deficit_left > 0.0 && deficit_right > 0.0 {
2692                            // Both ends overflow: solve the 2×2 system so
2693                            // each margin accounts for its effect on *both*
2694                            // labels, rather than correcting them
2695                            // independently and overshooting.
2696                            let (a, b, c, d) = (1.0 - p_first, p_first, 1.0 - p_last, p_last);
2697                            let det = a * d - b * c;
2698                            if det.abs() > 0.01 {
2699                                extras.left +=
2700                                    ((deficit_left * d + deficit_right * b) / det).max(0.0);
2701                                extras.right +=
2702                                    ((deficit_right * a + deficit_left * c) / det).max(0.0);
2703                            } else {
2704                                // Degenerate (first and last break nearly
2705                                // coincide in normalized space): fall back
2706                                // to the independent, clamped formulas.
2707                                extras.left += deficit_left / (1.0 - p_first).max(0.05);
2708                                extras.right += deficit_right / p_last.max(0.05);
2709                            }
2710                        } else if deficit_left > 0.0 {
2711                            extras.left += deficit_left / (1.0 - p_first).max(0.05);
2712                        } else if deficit_right > 0.0 {
2713                            extras.right += deficit_right / p_last.max(0.05);
2714                        }
2715                        // Cap the total reservation so the panel can never
2716                        // start beyond the canvas or be squeezed past the
2717                        // same minimum width `compute_with_extras` already
2718                        // floors it to (review #158's second re-review,
2719                        // LOW): a single, centred break whose label is
2720                        // wider than the whole canvas has `p_first ==
2721                        // p_last == 0.5`, so both `eL` and `eR` grow by
2722                        // roughly the same (large) deficit independently,
2723                        // and together they could place `plot_x` past `w`
2724                        // entirely. Past this cap, Reserve simply stops
2725                        // growing and `draw_x_axis`'s own draw-time shift
2726                        // — which always runs for Reserve, the same as for
2727                        // `Shift` — takes over for whatever's still
2728                        // outside, exactly as D6 already documents.
2729                        const MIN_PANEL_WIDTH: f64 = 50.0;
2730                        let cap = (w as f64 - MIN_PANEL_WIDTH).max(0.0);
2731                        if extras.left + extras.right > cap {
2732                            let scale = if extras.left + extras.right > 0.0 {
2733                                cap / (extras.left + extras.right)
2734                            } else {
2735                                0.0
2736                            };
2737                            extras.left *= scale;
2738                            extras.right *= scale;
2739                        }
2740                    }
2741                }
2742            }
2743        }
2744
2745        // B3-2 (D7): a discrete legend's `nrow`/`ncol` grid, or an
2746        // overflowing horizontal legend's auto-wrap, reserves extra legend
2747        // rows/width. Several simultaneous discrete guides (e.g. `colour`
2748        // and `shape` both mapped to discrete variables) each get their
2749        // own independent plan (sipemu/ggplot-rs#155) — previously this
2750        // only ran for exactly one discrete guide.
2751        if has_legend
2752            && !matches!(
2753                built.theme.legend_position,
2754                crate::theme::LegendPosition::None
2755            )
2756        {
2757            let legend_aes =
2758                crate::guide::legend::legend_scales_for(&built.scales, &built.suppressed_aes);
2759            let has_discrete_guide = legend_aes.iter().any(|a| {
2760                built
2761                    .scales
2762                    .get(a)
2763                    .map(|s| s.is_discrete())
2764                    .unwrap_or(false)
2765            });
2766            if has_discrete_guide {
2767                let layout0 = PlotLayout::compute_with_extras(
2768                    w as f64,
2769                    h as f64,
2770                    &built.theme,
2771                    has_title,
2772                    has_subtitle,
2773                    has_caption,
2774                    has_legend,
2775                    x_axis_top,
2776                    y_label_width,
2777                    x_label_height,
2778                    extras,
2779                );
2780                let (legend_x0, _, mut is_horizontal) =
2781                    crate::guide::legend::legend_position(&built.theme, &layout0.plot_area);
2782                if let Some(dir) = built.theme.legend_direction {
2783                    is_horizontal = matches!(dir, crate::theme::LegendDirection::Horizontal);
2784                }
2785                let avail_width =
2786                    (layout0.plot_area.x + layout0.plot_area.width - legend_x0).max(0.0);
2787                let mut legend_warnings = Vec::new();
2788                let mut plans = crate::guide::legend::plan_all_legends(
2789                    &built.scales,
2790                    &built.guide_legend,
2791                    &built.theme,
2792                    &built.suppressed_aes,
2793                    avail_width,
2794                    layout0.plot_area.height,
2795                    is_horizontal,
2796                    &mut legend_warnings,
2797                );
2798                built.warnings.extend(legend_warnings);
2799
2800                if !plans.is_empty() {
2801                    // Guides laid out side by side (a horizontal legend
2802                    // position) share one row band — its height is set
2803                    // by whichever guide wrapped to the most rows.
2804                    // Guides stacked vertically each add their own full
2805                    // height, so their extra rows sum.
2806                    extras.legend_extra_rows = if is_horizontal {
2807                        plans
2808                            .values()
2809                            .map(|p| p.rows.saturating_sub(1))
2810                            .max()
2811                            .unwrap_or(0)
2812                    } else {
2813                        plans.values().map(|p| p.rows.saturating_sub(1)).sum()
2814                    };
2815                    // Review #158 finding 3: a vertical explicit grid
2816                    // with ncol > 1 needs extra legend band width for
2817                    // its later columns, or they run off the canvas
2818                    // (cols is always 1, col_pitch 0, for the horizontal
2819                    // auto-wrap path, so this is a no-op there). Several
2820                    // guides take the widest requirement among them.
2821                    extras.legend_extra_width = plans
2822                        .values()
2823                        .map(|p| p.cols.saturating_sub(1) as f64 * p.col_pitch)
2824                        .fold(0.0_f64, f64::max);
2825
2826                    // #159: a vertical multi-column grid that still runs
2827                    // off the canvas even after reserving the extra width
2828                    // above now (1) tries reducing its column count
2829                    // toward 1 — the first count that fits wins; (2) if
2830                    // even one column still overflows, truncates labels
2831                    // with an ellipsis (keeping the full label available
2832                    // via a `<title>`); and only (3) warns if it still
2833                    // doesn't fit after both. A grid that already fits is
2834                    // untouched — no reduction, no truncation, no warning
2835                    // (review #158's second re-review, LOW, is the
2836                    // original "warn only" version of this check).
2837                    if !is_horizontal {
2838                        let final_layout = PlotLayout::compute_with_extras(
2839                            w as f64,
2840                            h as f64,
2841                            &built.theme,
2842                            has_title,
2843                            has_subtitle,
2844                            has_caption,
2845                            has_legend,
2846                            x_axis_top,
2847                            y_label_width,
2848                            x_label_height,
2849                            extras,
2850                        );
2851                        let (legend_x_final, _, _) = crate::guide::legend::legend_position(
2852                            &built.theme,
2853                            &final_layout.plot_area,
2854                        );
2855                        let swatch_and_gap =
2856                            built.theme.legend_key_width + built.theme.legend_spacing;
2857                        let text_size = built.theme.legend_text.size;
2858                        let right_edge_of =
2859                            |plan: &crate::guide::legend::LegendPlan, breaks: &[(f64, String)]| {
2860                                plan.entries
2861                                    .iter()
2862                                    .filter_map(|e| breaks.get(e.break_idx).map(|(_, l)| (e, l)))
2863                                    .map(|(e, label)| {
2864                                        let text =
2865                                            e.label_override.as_deref().unwrap_or(label.as_str());
2866                                        legend_x_final
2867                                            + e.x_offset
2868                                            + swatch_and_gap
2869                                            + crate::render::text::text_width(text, text_size)
2870                                    })
2871                                    .fold(0.0_f64, f64::max)
2872                            };
2873
2874                        let mut updates: Vec<(
2875                            crate::aes::Aesthetic,
2876                            crate::guide::legend::LegendPlan,
2877                        )> = Vec::new();
2878                        for (aes, plan) in &plans {
2879                            if plan.cols <= 1 {
2880                                continue;
2881                            }
2882                            let Some(scale) = built.scales.get(aes) else {
2883                                continue;
2884                            };
2885                            let breaks = scale.breaks();
2886                            if right_edge_of(plan, &breaks) <= w as f64 {
2887                                continue; // already fits — untouched.
2888                            }
2889
2890                            // Step 1: reduce columns toward 1; the first
2891                            // count that fits wins.
2892                            let n = breaks.len();
2893                            let mut reduced: Vec<crate::guide::legend::LegendPlan> = Vec::new();
2894                            for c in (1..plan.cols).rev() {
2895                                let mut candidate_guide = built.guide_legend.clone();
2896                                candidate_guide.ncol = Some(c);
2897                                candidate_guide.nrow = None;
2898                                let mut tmp_warnings = Vec::new();
2899                                let candidate_guide =
2900                                    candidate_guide.validated(n, &mut tmp_warnings);
2901                                if let Some(cand) = crate::guide::legend::plan_legend(
2902                                    &breaks,
2903                                    &candidate_guide,
2904                                    &built.theme,
2905                                    avail_width,
2906                                    layout0.plot_area.height,
2907                                    false,
2908                                ) {
2909                                    reduced.push(cand);
2910                                }
2911                            }
2912                            if let Some(fits) = reduced
2913                                .iter()
2914                                .find(|c| right_edge_of(c, &breaks) <= w as f64)
2915                            {
2916                                extras.legend_extra_rows =
2917                                    extras.legend_extra_rows.max(fits.rows.saturating_sub(1));
2918                                updates.push((aes.clone(), fits.clone()));
2919                                continue;
2920                            }
2921
2922                            // Step 2: no column count fit — truncate
2923                            // labels (starting from the narrowest, 1-
2924                            // column, candidate) with an ellipsis, each
2925                            // to whatever room its own position leaves.
2926                            let base = reduced.last().cloned().unwrap_or_else(|| plan.clone());
2927                            extras.legend_extra_rows =
2928                                extras.legend_extra_rows.max(base.rows.saturating_sub(1));
2929                            let mut truncated = base.clone();
2930                            for e in &mut truncated.entries {
2931                                let Some((_, label)) = breaks.get(e.break_idx) else {
2932                                    continue;
2933                                };
2934                                let avail = (w as f64
2935                                    - (legend_x_final + e.x_offset + swatch_and_gap))
2936                                    .max(0.0);
2937                                if let Some(short) =
2938                                    crate::render::text::truncate_to_width(label, avail, text_size)
2939                                {
2940                                    e.label_override = Some(short);
2941                                }
2942                            }
2943
2944                            // Step 3: only warn if it still doesn't fit
2945                            // after both the reduction and the
2946                            // truncation above.
2947                            if right_edge_of(&truncated, &breaks) > w as f64 {
2948                                built.warnings.push(format!(
2949                                    "legend: the {}-column grid still doesn't fit the {w}px chart (needs about {} px)",
2950                                    truncated.cols,
2951                                    right_edge_of(&truncated, &breaks).ceil() as i64
2952                                ));
2953                            }
2954                            updates.push((aes.clone(), truncated));
2955                        }
2956                        for (aes, new_plan) in updates {
2957                            plans.insert(aes, new_plan);
2958                        }
2959                        // Review #167 finding 5 (LOW, #168; left as-is — see
2960                        // below): `extras.legend_extra_width` (set above,
2961                        // before this reduction loop ran) is deliberately
2962                        // *not* recomputed from `plans`' now-possibly-
2963                        // reduced column count. It still reserves the
2964                        // original (pre-reduction) column count's width.
2965                        //
2966                        // That stale, wider reservation is why the
2967                        // reduced grid fits at all: it narrows
2968                        // `final_layout.plot_area` above, which pushes
2969                        // `legend_x_final` left and gives the reduced
2970                        // grid more room before the canvas edge — tried
2971                        // recomputing it here (narrower reservation once
2972                        // the column count drops), and the hash in
2973                        // `tests/default_output_023.rs`'s `ncol_reduces`
2974                        // case changed: the panel widens, the hash
2975                        // changes, and the hard-coded `right_edge_of(...)
2976                        // <= w` fit check two closures up re-runs against
2977                        // a still-stale `legend_x_final` either way — the
2978                        // reserved width, the fit decision and the final
2979                        // layout are mutually circular (reserve → layout
2980                        // → fits? → reduce columns → reserve again →
2981                        // …), so fixing this one spot in isolation isn't
2982                        // contained; it needs the whole #159 loop above
2983                        // to re-layout and re-check fit after each
2984                        // reservation change, which is out of scope for
2985                        // this review's LOW, non-blocking finding. Left
2986                        // as a known limitation: an explicit `ncol(1)`
2987                        // with the same labels can still run off the
2988                        // canvas (unlike the auto-reduced case, which
2989                        // benefits from the wider reservation here by
2990                        // accident) without a warning, since #159's
2991                        // "warn if it still doesn't fit" check is also
2992                        // measured against the same stale `w`-relative
2993                        // geometry. See the PR #167 review for the
2994                        // original repro (ncol(1), 139px panel at 300px).
2995                    }
2996                    for (aes, plan) in &plans {
2997                        if let Some(ov) = &plan.overflow {
2998                            let n = built
2999                                .scales
3000                                .get(aes)
3001                                .map(|s| s.breaks().len())
3002                                .unwrap_or(ov.dropped);
3003                            let shown = n.saturating_sub(ov.dropped);
3004                            built.warnings.push(format!(
3005                                "legend: {} of {n} entries don't fit the chart; the legend shows {shown} and \"+{} more\"",
3006                                ov.dropped, ov.dropped
3007                            ));
3008                        }
3009                    }
3010                }
3011                // Review #158 finding 2: store these exact plans so
3012                // `draw_legend_planned` draws them verbatim instead of
3013                // recomputing against the (shorter) post-reservation
3014                // panel height, which could warn "+7 more" while
3015                // drawing "+15 more".
3016                built.legend_plans = plans;
3017            }
3018        }
3019
3020        PlotLayout::compute_with_extras(
3021            w as f64,
3022            h as f64,
3023            &built.theme,
3024            has_title,
3025            has_subtitle,
3026            has_caption,
3027            has_legend,
3028            x_axis_top,
3029            y_label_width,
3030            x_label_height,
3031            extras,
3032        )
3033    }
3034
3035    /// Fill the background, render the built plot, and flush — for any plotters
3036    /// backend. (Native only.)
3037    #[cfg(all(feature = "plotters", not(target_arch = "wasm32")))]
3038    fn render_into<DB>(
3039        area: plotters::drawing::DrawingArea<DB, plotters::coord::Shift>,
3040        built: &crate::build::BuiltPlot,
3041        layout: &PlotLayout,
3042    ) -> Result<(), GGError>
3043    where
3044        DB: plotters::prelude::DrawingBackend,
3045        DB::ErrorType: 'static,
3046    {
3047        area.fill(&plotters::prelude::WHITE)
3048            .map_err(|e| GGError::Render(RenderError::BackendError(format!("{:?}", e))))?;
3049        let mut adapter = PlottersAdapter::new(&area, layout.plot_area.clone());
3050        PlotRenderer::render(built, &mut adapter).map_err(GGError::Render)?;
3051        area.present()
3052            .map_err(|e| GGError::Render(RenderError::BackendError(format!("{:?}", e))))?;
3053        Ok(())
3054    }
3055
3056    /// Save with physical dimensions (inches) and DPI. (Feature `plotters`;
3057    /// native only.)
3058    #[cfg(all(feature = "plotters", not(target_arch = "wasm32")))]
3059    pub fn ggsave(
3060        self,
3061        path: &str,
3062        width_inches: f64,
3063        height_inches: f64,
3064        dpi: f64,
3065    ) -> Result<(), GGError> {
3066        let w = (width_inches * dpi) as u32;
3067        let h = (height_inches * dpi) as u32;
3068        self.save_with_size(path, w, h)
3069    }
3070
3071    fn has_legend_mapping(&self) -> bool {
3072        self.mapping.mappings.iter().any(|m| {
3073            matches!(
3074                m.aesthetic,
3075                crate::aes::Aesthetic::Color
3076                    | crate::aes::Aesthetic::Fill
3077                    | crate::aes::Aesthetic::Shape
3078                    | crate::aes::Aesthetic::Linetype
3079                    | crate::aes::Aesthetic::Size
3080                    | crate::aes::Aesthetic::Alpha
3081            )
3082        })
3083    }
3084}
3085
3086/// Size-independent facts about a built plot that its layout needs.
3087#[derive(Clone, Copy, Debug)]
3088pub(crate) struct RenderMeta {
3089    pub(crate) has_title: bool,
3090    pub(crate) has_subtitle: bool,
3091    pub(crate) has_caption: bool,
3092    pub(crate) has_legend: bool,
3093}
3094
3095/// Top-level error type.
3096#[derive(Debug)]
3097pub enum GGError {
3098    Render(RenderError),
3099    UnsupportedFormat(String),
3100    Io(std::io::Error),
3101    ValidationError(String),
3102}
3103
3104impl std::fmt::Display for GGError {
3105    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
3106        match self {
3107            GGError::Render(e) => write!(f, "Render error: {e}"),
3108            GGError::UnsupportedFormat(ext) => write!(f, "Unsupported output format: {ext}"),
3109            GGError::Io(e) => write!(f, "IO error: {e}"),
3110            GGError::ValidationError(msg) => write!(f, "Validation error: {msg}"),
3111        }
3112    }
3113}
3114
3115impl std::error::Error for GGError {}