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