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::density_ridges::GeomDensityRidges;
27use crate::geom::dotplot::GeomDotplot;
28use crate::geom::errorbar::GeomErrorbar;
29use crate::geom::freqpoly::GeomFreqpoly;
30use crate::geom::hex::GeomHex;
31use crate::geom::histogram::GeomHistogram;
32use crate::geom::jitter::GeomJitter;
33use crate::geom::line::GeomLine;
34use crate::geom::linerange::GeomLinerange;
35use crate::geom::path::GeomPath;
36use crate::geom::point::GeomPoint;
37use crate::geom::pointrange::GeomPointrange;
38use crate::geom::polygon::GeomPolygon;
39use crate::geom::qq::{GeomQQ, GeomQQLine};
40use crate::geom::rect::GeomRect;
41use crate::geom::refline::{GeomAbline, GeomHline, GeomVline};
42use crate::geom::ribbon::GeomRibbon;
43use crate::geom::rug::GeomRug;
44use crate::geom::segment::GeomSegment;
45use crate::geom::smooth::GeomSmooth;
46use crate::geom::spoke::GeomSpoke;
47use crate::geom::step::GeomStep;
48use crate::geom::text::{GeomLabel, GeomText};
49use crate::geom::tile::GeomTile;
50use crate::geom::violin::GeomViolin;
51use crate::geom::{Geom, GeomParams};
52use crate::position::Position;
53use crate::render::layout::PlotLayout;
54#[cfg(all(feature = "plotters", not(target_arch = "wasm32")))]
55use crate::render::plotters_backend::PlottersAdapter;
56use crate::render::renderer::PlotRenderer;
57use crate::render::RenderError;
58use crate::scale::continuous::ScaleContinuous;
59use crate::scale::transform::ScaleTransform;
60use crate::scale::Scale;
61use crate::stat::Stat;
62use crate::theme::Theme;
63
64// Builder methods for the 0.17 statistical-graphics layers (QQ, step ribbons,
65// ECDF bands, Cook's contours, horizontal error bars).
66mod stat_layers;
67
68/// Labels for the plot.
69#[derive(Clone, Debug, Default)]
70pub struct Labels {
71    pub title: Option<String>,
72    pub subtitle: Option<String>,
73    pub x: Option<String>,
74    pub y: Option<String>,
75    pub caption: Option<String>,
76    /// Corner tag (R's `labs(tag = ...)`), e.g. "A" for figure panels.
77    pub tag: Option<String>,
78}
79
80/// A single layer in the plot.
81pub struct Layer {
82    pub data: Option<DataFrame>,
83    pub mapping: Aes,
84    pub geom: Box<dyn Geom>,
85    pub stat: Box<dyn Stat>,
86    pub position: Box<dyn Position>,
87    pub params: GeomParams,
88    pub show_legend: Option<bool>,
89    /// The geom was configured explicitly (`geom_*_with(...)`), so its colours
90    /// are deliberate and the theme's `primary_color` must not override them.
91    pub explicit_style: bool,
92    /// A `{column}` tooltip template ([`GGPlot::tooltip`]), rendered per row
93    /// from the *built* layer data (post-stat) once that's available.
94    pub tooltip_template: Option<String>,
95    /// Legend key override ([`GGPlot::key_glyph`]); `None` = the geom's.
96    pub key_glyph: Option<crate::guide::key::KeyGlyph>,
97    /// Per-aesthetic `show.legend` ([`GGPlot::show_legend_for`]).
98    pub show_legend_aes: Vec<(crate::aes::Aesthetic, bool)>,
99}
100
101/// The top-level plot specification — builder pattern.
102pub struct GGPlot {
103    pub(crate) data: DataFrame,
104    pub(crate) mapping: Aes,
105    pub(crate) layers: Vec<Layer>,
106    pub(crate) scales: Vec<Box<dyn Scale>>,
107    pub(crate) coord: Box<dyn Coord>,
108    pub(crate) theme: Theme,
109    pub(crate) labels: Labels,
110    pub(crate) facet: Facet,
111    /// Which facet panels get axes (#412).
112    pub(crate) facet_axes: crate::facet::FacetAxes,
113    pub(crate) annotations: Vec<Annotation>,
114    pub(crate) guide_legend: crate::guide::config::GuideLegend,
115    /// Warnings raised while specifying the plot (e.g. a clipped calendar
116    /// span); prepended to the build warnings.
117    pub(crate) warnings: Vec<String>,
118    /// [`GGPlot::legend_bottom_when_narrow`]'s share, if set (#287).
119    pub(crate) legend_bottom_share: Option<f64>,
120    /// Clip the data layers to the panel (ggplot2's `coord_*(clip = "on")`,
121    /// the default; #411).
122    pub(crate) clip: bool,
123    /// Tag axes, grid, legend, strips and titles with semantic CSS classes
124    /// in SVG output (#465).
125    pub(crate) svg_classes: bool,
126    /// [`GGPlot::legend_inside_auto`]'s settings, if set (#332).
127    pub(crate) legend_auto: Option<LegendAuto>,
128    /// Panel aspect ratio used when the theme sets none (helpers such as
129    /// `geom_calendar` need square cells; survives theme presets).
130    pub(crate) default_aspect_ratio: Option<f64>,
131    /// Hide axes and grid whatever theme is set (layout-only charts such as
132    /// `geom_sankey`, whose x/y carry no data meaning); survives presets.
133    pub(crate) hide_position_guides: bool,
134    /// Hide only the y guides whatever theme is set (`geom_timeline`).
135    pub(crate) hide_y_guides: bool,
136    /// Plot-wide default locale for axis / legend labels ([`GGPlot::locale`]).
137    pub(crate) locale: Option<crate::locale::Locale>,
138    /// Whether the rendered root `<svg>` carries `data-plot`/`data-domain`/…
139    /// ([`svg_root_data`](Self::svg_root_data)). Default `true`; a consumer
140    /// overlaying a fragment on its own basemap (`render_svg_native_at`)
141    /// turns it off so the fragment adds no root attributes of its own.
142    pub(crate) svg_root_data: bool,
143    /// Extra root `<svg>` attributes requested via [`svg_attr`](Self::svg_attr)/
144    /// [`svg_attrs`](Self::svg_attrs), validated and applied at render time.
145    pub(crate) root_attrs: Vec<(String, String)>,
146    /// Whether to emit a `data-warnings` JSON array of the build warnings
147    /// on the root `<svg>` ([`svg_warnings_attr`](Self::svg_warnings_attr)).
148    /// Default off; byte-identical output when off.
149    pub(crate) svg_warnings_attr: bool,
150    /// Whether to emit `data-fvalue`/`data-fx` on marks
151    /// ([`svg_formatted_values`](Self::svg_formatted_values)). Default off;
152    /// byte-identical output when off.
153    pub(crate) svg_formatted_values: bool,
154    /// #221: validated [`layer_svg_attr`](Self::layer_svg_attr)s as
155    /// `(layer index, name, value)`, applied by wrapping that layer's geom
156    /// at build time.
157    pub(crate) layer_svg_attrs: Vec<(usize, String, String)>,
158    /// #255: accessible name for the rendered root `<svg>`
159    /// ([`svg_title`](Self::svg_title)) — a `<title>` first child plus
160    /// `role="img"`/`aria-labelledby`. Default `None`; byte-identical
161    /// output when not set.
162    pub(crate) svg_title: Option<String>,
163    /// #255: accessible description for the rendered root `<svg>`
164    /// ([`svg_desc`](Self::svg_desc)) — a `<desc>` first child plus
165    /// `role="img"`/`aria-describedby`. Default `None`; byte-identical
166    /// output when not set.
167    pub(crate) svg_desc: Option<String>,
168}
169
170impl GGPlot {
171    /// Create a new plot with the given data source.
172    ///
173    /// Never panics on malformed input: e.g. column-oriented data whose columns
174    /// have different lengths is padded with `NA` and reported as a
175    /// [`GGError::ValidationError`] by [`try_build`](Self::try_build) and the
176    /// `render_*`/`save*` methods.
177    /// One-call quick plot (ggplot2's `qplot()` / `quickplot()`): points for
178    /// `x` + `y`; with only `x`, a histogram (numeric) or bar chart (text).
179    pub fn qplot(data: impl GGData, x: &str, y: Option<&str>) -> Self {
180        let plot = GGPlot::new(data);
181        match y {
182            Some(y) => plot.aes(Aes::new().x(x).y(y)).geom_point(),
183            None => {
184                let text = plot
185                    .data
186                    .column(x)
187                    .and_then(|c| c.iter().find(|v| !v.is_na()))
188                    .is_some_and(|v| matches!(v, Value::Str(_) | Value::Bool(_)));
189                let plot = plot.aes(Aes::new().x(x));
190                if text {
191                    plot.geom_bar()
192                } else {
193                    plot.geom_histogram()
194                }
195            }
196        }
197    }
198
199    pub fn new(data: impl GGData) -> Self {
200        GGPlot {
201            data: data.into_dataframe(),
202            mapping: Aes::default(),
203            layers: Vec::new(),
204            scales: Vec::new(),
205            coord: Box::new(CoordCartesian::new()),
206            theme: Theme::default(),
207            labels: Labels::default(),
208            facet: Facet::default(),
209            facet_axes: crate::facet::FacetAxes::default(),
210            annotations: Vec::new(),
211            guide_legend: crate::guide::config::GuideLegend::default(),
212            warnings: Vec::new(),
213            legend_bottom_share: None,
214            clip: true,
215            svg_classes: false,
216            legend_auto: None,
217            default_aspect_ratio: None,
218            hide_position_guides: false,
219            hide_y_guides: false,
220            locale: None,
221            svg_root_data: true,
222            root_attrs: Vec::new(),
223            svg_warnings_attr: false,
224            svg_formatted_values: false,
225            layer_svg_attrs: Vec::new(),
226            svg_title: None,
227            svg_desc: None,
228        }
229    }
230
231    /// Set the plot-level aesthetic mapping.
232    pub fn aes(mut self, mapping: Aes) -> Self {
233        self.warnings.extend(mapping.warnings.iter().cloned());
234        self.mapping = mapping;
235        self
236    }
237
238    // ─── Geom shortcuts ──────────────────────────────────────────
239
240    pub fn geom_point(self) -> Self {
241        self.add_geom(GeomPoint::default())
242    }
243
244    pub fn geom_point_with(self, geom: GeomPoint) -> Self {
245        self.add_geom_with(geom)
246    }
247
248    pub fn geom_line(self) -> Self {
249        self.add_geom(GeomLine::default())
250    }
251
252    pub fn geom_line_with(self, geom: GeomLine) -> Self {
253        self.add_geom_with(geom)
254    }
255
256    pub fn geom_bar(self) -> Self {
257        self.add_geom(GeomBar::default())
258    }
259
260    pub fn geom_bar_with(self, geom: GeomBar) -> Self {
261        self.add_geom_with(geom)
262    }
263
264    pub fn geom_histogram(self) -> Self {
265        self.add_geom(GeomHistogram::default())
266    }
267
268    pub fn geom_histogram_with(self, geom: GeomHistogram) -> Self {
269        self.add_geom_with(geom)
270    }
271
272    pub fn geom_boxplot(self) -> Self {
273        self.add_geom(GeomBoxplot::default())
274    }
275
276    pub fn geom_boxplot_with(self, geom: GeomBoxplot) -> Self {
277        self.add_geom_with(geom)
278    }
279
280    pub fn geom_smooth(self) -> Self {
281        self.add_geom(GeomSmooth::default())
282    }
283
284    pub fn geom_smooth_with(self, geom: GeomSmooth) -> Self {
285        self.add_geom_with(geom)
286    }
287
288    pub fn geom_col(self) -> Self {
289        self.add_geom(GeomCol::default())
290    }
291
292    pub fn geom_col_with(self, geom: GeomCol) -> Self {
293        self.add_geom_with(geom)
294    }
295
296    /// Horizontal reference line at a constant `yintercept`. Like ggplot2 the
297    /// intercept trains the y scale (the line is always visible) and the line
298    /// appears in every facet panel.
299    pub fn geom_hline(self, yintercept: f64) -> Self {
300        self.add_refline(
301            GeomHline::new(yintercept),
302            false,
303            &[("yintercept", yintercept)],
304        )
305    }
306
307    /// Add a horizontal reference line with custom styling (color/linetype/width).
308    pub fn geom_hline_with(self, geom: GeomHline) -> Self {
309        let y = geom.yintercept;
310        self.add_refline(geom, true, &[("yintercept", y)])
311    }
312
313    /// Data-mapped horizontal lines (ggplot2's `geom_hline(aes(yintercept =
314    /// …))`): one line per row of the layer data (the plot data unless
315    /// [`layer_data`](Self::layer_data) follows), per facet panel, styled by
316    /// any `color`/`linetype`/`alpha` in `mapping`. The plot-level mapping is
317    /// not inherited.
318    ///
319    /// ```
320    /// # use ggplot_rs::prelude::*;
321    /// let thresholds: Vec<(String, Vec<Value>)> = vec![
322    ///     ("bound".into(), vec![Value::Float(-0.2), Value::Float(0.2)]),
323    /// ];
324    /// let svg = GGPlot::new(vec![
325    ///         ("lag".to_string(), vec![Value::Float(1.0), Value::Float(2.0)]),
326    ///         ("acf".to_string(), vec![Value::Float(0.5), Value::Float(0.1)]),
327    ///     ])
328    ///     .aes(Aes::new().x("lag").y("acf"))
329    ///     .geom_col()
330    ///     .geom_hline_aes(Aes::new().yintercept("bound"))
331    ///     .layer_data(thresholds)
332    ///     .render_svg_native()
333    ///     .unwrap();
334    /// assert!(svg.contains("data-value=\"0.2\""));
335    /// ```
336    pub fn geom_hline_aes(self, mapping: Aes) -> Self {
337        self.add_geom(GeomHline::mapped()).layer_aes(mapping)
338    }
339
340    /// [`geom_hline_aes`](Self::geom_hline_aes) with custom default styling.
341    pub fn geom_hline_aes_with(self, geom: GeomHline, mapping: Aes) -> Self {
342        self.add_geom_with(geom).layer_aes(mapping)
343    }
344
345    /// Vertical reference line at a constant `xintercept` (trains the x
346    /// scale, appears in every panel).
347    pub fn geom_vline(self, xintercept: f64) -> Self {
348        self.add_refline(
349            GeomVline::new(xintercept),
350            false,
351            &[("xintercept", xintercept)],
352        )
353    }
354
355    /// Add a vertical reference line with custom styling (color/linetype/width).
356    pub fn geom_vline_with(self, geom: GeomVline) -> Self {
357        let x = geom.xintercept;
358        self.add_refline(geom, true, &[("xintercept", x)])
359    }
360
361    /// Data-mapped vertical lines (`aes(xintercept = …)`), one per row; see
362    /// [`geom_hline_aes`](Self::geom_hline_aes).
363    pub fn geom_vline_aes(self, mapping: Aes) -> Self {
364        self.add_geom(GeomVline::mapped()).layer_aes(mapping)
365    }
366
367    /// [`geom_vline_aes`](Self::geom_vline_aes) with custom default styling.
368    pub fn geom_vline_aes_with(self, geom: GeomVline, mapping: Aes) -> Self {
369        self.add_geom_with(geom).layer_aes(mapping)
370    }
371
372    /// Line `y = intercept + slope · x` in data space, clipped to the panel.
373    /// It does not train any scale (as in ggplot2).
374    pub fn geom_abline(self, slope: f64, intercept: f64) -> Self {
375        self.add_refline(
376            GeomAbline::new(slope, intercept),
377            false,
378            &[("slope", slope), ("intercept", intercept)],
379        )
380    }
381
382    /// Add a slope/intercept reference line with custom styling.
383    pub fn geom_abline_with(self, geom: GeomAbline) -> Self {
384        let (b, a) = (geom.slope, geom.intercept);
385        self.add_refline(geom, true, &[("slope", b), ("intercept", a)])
386    }
387
388    /// Data-mapped ablines (`aes(slope = …, intercept = …)`), one per row; a
389    /// missing aesthetic defaults to slope 1 / intercept 0.
390    pub fn geom_abline_aes(self, mapping: Aes) -> Self {
391        self.add_geom(GeomAbline::mapped()).layer_aes(mapping)
392    }
393
394    /// [`geom_abline_aes`](Self::geom_abline_aes) with custom default styling.
395    pub fn geom_abline_aes_with(self, geom: GeomAbline, mapping: Aes) -> Self {
396        self.add_geom_with(geom).layer_aes(mapping)
397    }
398
399    /// A constant reference line as ggplot2 builds it: a one-row layer frame
400    /// holding the constants, mapped to their aesthetics.
401    fn add_refline(
402        self,
403        geom: impl Geom + 'static,
404        explicit: bool,
405        values: &[(&str, f64)],
406    ) -> Self {
407        let mut data = DataFrame::new();
408        let mut mapping = Aes::new();
409        for (col, v) in values {
410            data.add_column(col.to_string(), vec![Value::Float(*v)]);
411            mapping = match *col {
412                "xintercept" => mapping.xintercept(col),
413                "yintercept" => mapping.yintercept(col),
414                "slope" => mapping.slope(col),
415                _ => mapping.intercept(col),
416            };
417        }
418        let plot = if explicit {
419            self.add_geom_with(geom)
420        } else {
421            self.add_geom(geom)
422        };
423        plot.layer_aes(mapping).layer_data(data)
424    }
425
426    pub fn geom_text(self) -> Self {
427        self.add_geom(GeomText::default())
428    }
429
430    pub fn geom_text_with(self, geom: GeomText) -> Self {
431        self.add_geom_with(geom)
432    }
433
434    /// Annotate with a correlation coefficient + p-value (`ggpubr::stat_cor()`).
435    /// Adds a text layer whose statistic computes Pearson correlation via
436    /// anofox-statistics; use [`GGPlot::stat_cor_with`] to pick Spearman or set
437    /// the label position.
438    #[cfg(feature = "ggpubr")]
439    pub fn stat_cor(self) -> Self {
440        self.geom_text().stat(crate::stat::cor::StatCor::default())
441    }
442
443    /// [`GGPlot::stat_cor`] with an explicit [`StatCor`](crate::stat::cor::StatCor)
444    /// (method / label position).
445    #[cfg(feature = "ggpubr")]
446    pub fn stat_cor_with(self, stat: crate::stat::cor::StatCor) -> Self {
447        self.geom_text().stat(stat)
448    }
449
450    /// Annotate grouped data with a group-comparison p-value
451    /// (`ggpubr::stat_compare_means()`). Adds a text layer whose statistic
452    /// compares y across the discrete x groups (Wilcoxon for two groups,
453    /// Kruskal-Wallis for more) via anofox-statistics.
454    #[cfg(feature = "ggpubr")]
455    pub fn stat_compare_means(self) -> Self {
456        self.geom_text()
457            .stat(crate::stat::compare_means::StatCompareMeans::default())
458    }
459
460    /// [`GGPlot::stat_compare_means`] with an explicit
461    /// [`StatCompareMeans`](crate::stat::compare_means::StatCompareMeans).
462    #[cfg(feature = "ggpubr")]
463    pub fn stat_compare_means_with(
464        self,
465        stat: crate::stat::compare_means::StatCompareMeans,
466    ) -> Self {
467        self.geom_text().stat(stat)
468    }
469
470    /// Auto-draw pairwise significance brackets (`ggpubr::stat_compare_means`
471    /// with `comparisons`). For each `(group_a, group_b)` pair it runs a
472    /// two-sample test (Wilcoxon by default) on the plot's y-values, formats the
473    /// p-value, and stacks a labelled [`geom_bracket`](GGPlot::geom_bracket)
474    /// above the data. Uses the plot's x/y mapping and data.
475    #[cfg(feature = "ggpubr")]
476    pub fn stat_compare_means_pairwise(self, comparisons: &[(&str, &str)]) -> Self {
477        self.stat_compare_means_pairwise_with(
478            crate::stat::compare_means::CompareMethod::Auto,
479            comparisons,
480        )
481    }
482
483    /// [`stat_compare_means_pairwise`](GGPlot::stat_compare_means_pairwise) with
484    /// an explicit test method.
485    #[cfg(feature = "ggpubr")]
486    pub fn stat_compare_means_pairwise_with(
487        self,
488        method: crate::stat::compare_means::CompareMethod,
489        comparisons: &[(&str, &str)],
490    ) -> Self {
491        // Resolve the x/y source columns from the plot mapping + data.
492        let col_for = |a: crate::aes::Aesthetic| {
493            self.mapping
494                .mappings
495                .iter()
496                .find(|m| m.aesthetic == a)
497                .map(|m| m.column.clone())
498        };
499        let (xcol, ycol) = match (
500            col_for(crate::aes::Aesthetic::X),
501            col_for(crate::aes::Aesthetic::Y),
502        ) {
503            (Some(x), Some(y)) => (x, y),
504            _ => return self,
505        };
506        let (xc, yc) = match (self.data.column(&xcol), self.data.column(&ycol)) {
507            (Some(x), Some(y)) => (x, y),
508            _ => return self,
509        };
510
511        // Bucket y by x category (first-seen order) and find the data range.
512        let mut groups: Vec<(String, Vec<f64>)> = Vec::new();
513        let mut ymax = f64::NEG_INFINITY;
514        let mut ymin = f64::INFINITY;
515        for (xv, yv) in xc.iter().zip(yc.iter()) {
516            let y = match yv.as_f64() {
517                Some(y) if y.is_finite() => y,
518                _ => continue,
519            };
520            ymax = ymax.max(y);
521            ymin = ymin.min(y);
522            let key = xv.to_group_key();
523            if let Some(g) = groups.iter_mut().find(|(k, _)| *k == key) {
524                g.1.push(y);
525            } else {
526                groups.push((key, vec![y]));
527            }
528        }
529        if groups.len() < 2 {
530            return self;
531        }
532        let step = if ymax > ymin {
533            (ymax - ymin) * 0.12
534        } else {
535            1.0
536        };
537
538        // One stacked bracket per resolvable comparison.
539        let (mut xmin_v, mut xmax_v, mut y_v, mut label_v) =
540            (Vec::new(), Vec::new(), Vec::new(), Vec::new());
541        let mut placed = 0usize;
542        for (a, b) in comparisons {
543            let ga = groups.iter().find(|(k, _)| k == a).map(|g| &g.1);
544            let gb = groups.iter().find(|(k, _)| k == b).map(|g| &g.1);
545            let (ga, gb) = match (ga, gb) {
546                (Some(x), Some(y)) if x.len() >= 2 && y.len() >= 2 => (x, y),
547                _ => continue,
548            };
549            let p = match crate::stat::compare_means::pairwise_p(method, ga, gb) {
550                Some(p) => p,
551                None => continue,
552            };
553            xmin_v.push(Value::Str((*a).to_string()));
554            xmax_v.push(Value::Str((*b).to_string()));
555            y_v.push(Value::Float(ymax + step * (placed as f64 + 1.0)));
556            label_v.push(Value::Str(crate::stat::cor::format_p_value(p)));
557            placed += 1;
558        }
559        if xmin_v.is_empty() {
560            return self;
561        }
562
563        let data = vec![
564            ("xmin".to_string(), xmin_v),
565            ("xmax".to_string(), xmax_v),
566            ("y".to_string(), y_v),
567            ("label".to_string(), label_v),
568        ];
569        self.add_geom(crate::geom::bracket::GeomBracket::default())
570            .layer_data(data)
571            .layer_aes(Aes::new().xmin("xmin").xmax("xmax").y("y").label("label"))
572    }
573
574    /// Draw a significance bracket over the plot (`ggpubr::geom_bracket`): a bar
575    /// spanning the categorical positions `xmin`..`xmax` at height `y`, captioned
576    /// with `label` (e.g. a p-value or significance stars). Pairs with a boxplot
577    /// and [`stat_compare_means`](GGPlot::stat_compare_means).
578    pub fn geom_bracket(self, xmin: &str, xmax: &str, y: f64, label: &str) -> Self {
579        self.geom_bracket_many(
580            crate::geom::bracket::GeomBracket::default(),
581            &[(xmin, xmax, y, label)],
582        )
583    }
584
585    /// Draw several significance brackets at once with a configured
586    /// [`GeomBracket`](crate::geom::bracket::GeomBracket). Each tuple is
587    /// `(xmin, xmax, y, label)`.
588    pub fn geom_bracket_many(
589        self,
590        geom: crate::geom::bracket::GeomBracket,
591        brackets: &[(&str, &str, f64, &str)],
592    ) -> Self {
593        let xmin = brackets
594            .iter()
595            .map(|b| Value::Str(b.0.to_string()))
596            .collect::<Vec<_>>();
597        let xmax = brackets
598            .iter()
599            .map(|b| Value::Str(b.1.to_string()))
600            .collect::<Vec<_>>();
601        let y = brackets
602            .iter()
603            .map(|b| Value::Float(b.2))
604            .collect::<Vec<_>>();
605        let label = brackets
606            .iter()
607            .map(|b| Value::Str(b.3.to_string()))
608            .collect::<Vec<_>>();
609        let data = vec![
610            ("xmin".to_string(), xmin),
611            ("xmax".to_string(), xmax),
612            ("y".to_string(), y),
613            ("label".to_string(), label),
614        ];
615        self.add_geom(geom)
616            .layer_data(data)
617            .layer_aes(Aes::new().xmin("xmin").xmax("xmax").y("y").label("label"))
618    }
619
620    /// Significance brackets from a precomputed test table (ggpubr's
621    /// `stat_pvalue_manual` / `geom_bracket(data = …)`) — e.g. the anofox
622    /// `test` contract output (`group1`, `group2`, `p_adj`/`p_value`, optional
623    /// `y_position`, `label`). No test is recomputed. See
624    /// [`BracketTable`](crate::geom::bracket::BracketTable) for the column
625    /// rules, label templates (`"p = {p_adj}"`, `"{p.signif}"`) and the
626    /// automatic stacking of rows without a `y_position`. Rows that cannot be
627    /// drawn are dropped with a build warning.
628    pub fn geom_bracket_table(
629        mut self,
630        table: impl GGData,
631        spec: crate::geom::bracket::BracketTable,
632    ) -> Self {
633        let table = table.into_dataframe();
634        let col_for = |a: crate::aes::Aesthetic| {
635            self.mapping
636                .mappings
637                .iter()
638                .find(|m| m.aesthetic == a)
639                .map(|m| m.column.clone())
640        };
641        // The plot's x categories (to reject unknown groups) and finite y
642        // range (to stack brackets above the data).
643        let x_levels: Vec<String> = col_for(crate::aes::Aesthetic::X)
644            .and_then(|c| self.data.column(&c))
645            .filter(|col| col.iter().any(|v| matches!(v, Value::Str(_))))
646            .map(|col| {
647                let mut seen = std::collections::HashSet::new();
648                col.iter()
649                    .filter(|v| !v.is_na())
650                    .map(|v| v.to_group_key())
651                    .filter(|k| seen.insert(k.clone()))
652                    .collect()
653            })
654            .unwrap_or_default();
655        let y_range = col_for(crate::aes::Aesthetic::Y)
656            .and_then(|c| self.data.column(&c))
657            .and_then(|col| {
658                let (lo, hi) = col
659                    .iter()
660                    .filter_map(|v| v.as_f64())
661                    .filter(|v| v.is_finite())
662                    .fold((f64::INFINITY, f64::NEG_INFINITY), |(lo, hi), v| {
663                        (lo.min(v), hi.max(v))
664                    });
665                (lo <= hi).then_some((lo, hi))
666            });
667        let mut warnings = Vec::new();
668        let resolved = spec.resolve(&table, &x_levels, y_range, &mut warnings);
669        self.warnings.extend(warnings);
670        match resolved {
671            Some(data) => self
672                .add_geom(crate::geom::bracket::GeomBracket::from(spec.geom))
673                .layer_data(data)
674                .layer_aes(Aes::new().xmin("xmin").xmax("xmax").y("y").label("label")),
675            None => self,
676        }
677    }
678
679    /// Text labels that repel each other and their points
680    /// (`ggrepel::geom_text_repel`) — deterministic (seeded) layout; see
681    /// [`GeomTextRepel`](crate::geom::repel::GeomTextRepel). Requires `x`,
682    /// `y` and `label`.
683    pub fn geom_text_repel(self) -> Self {
684        self.add_geom(crate::geom::repel::GeomTextRepel::default())
685    }
686
687    /// [`geom_text_repel`](Self::geom_text_repel) with a configured geom
688    /// (padding, nudge, `max_overlaps`, seed, …).
689    pub fn geom_text_repel_with(self, geom: crate::geom::repel::GeomTextRepel) -> Self {
690        self.add_geom_with(geom)
691    }
692
693    /// Boxed labels that repel each other and their points
694    /// (`ggrepel::geom_label_repel`).
695    pub fn geom_label_repel(self) -> Self {
696        self.add_geom(crate::geom::repel::GeomLabelRepel::default())
697    }
698
699    /// [`geom_label_repel`](Self::geom_label_repel) with a configured geom.
700    pub fn geom_label_repel_with(self, geom: crate::geom::repel::GeomLabelRepel) -> Self {
701        self.add_geom_with(geom)
702    }
703
704    pub fn geom_label(self) -> Self {
705        self.add_geom(GeomLabel::default())
706    }
707
708    pub fn geom_label_with(self, geom: GeomLabel) -> Self {
709        self.add_geom_with(geom)
710    }
711
712    pub fn geom_area(self) -> Self {
713        self.add_geom(GeomArea::default())
714    }
715
716    pub fn geom_area_with(self, geom: GeomArea) -> Self {
717        self.add_geom_with(geom)
718    }
719
720    pub fn geom_ribbon(self) -> Self {
721        self.add_geom(GeomRibbon::default())
722    }
723
724    pub fn geom_ribbon_with(self, geom: GeomRibbon) -> Self {
725        self.add_geom_with(geom)
726    }
727
728    pub fn geom_errorbar(self) -> Self {
729        self.add_geom(GeomErrorbar::default())
730    }
731
732    pub fn geom_errorbar_with(self, geom: GeomErrorbar) -> Self {
733        self.add_geom_with(geom)
734    }
735
736    pub fn geom_segment(self) -> Self {
737        self.add_geom(GeomSegment::default())
738    }
739
740    pub fn geom_segment_with(self, geom: GeomSegment) -> Self {
741        self.add_geom_with(geom)
742    }
743
744    pub fn geom_density(self) -> Self {
745        self.add_geom(GeomDensity::default())
746    }
747
748    pub fn geom_density_with(self, geom: GeomDensity) -> Self {
749        self.add_geom_with(geom)
750    }
751
752    pub fn geom_rug(self) -> Self {
753        self.add_geom(GeomRug::default())
754    }
755
756    pub fn geom_rug_with(self, geom: GeomRug) -> Self {
757        self.add_geom_with(geom)
758    }
759
760    pub fn geom_jitter(self) -> Self {
761        self.add_geom(GeomJitter::default())
762    }
763
764    pub fn geom_jitter_with(self, geom: GeomJitter) -> Self {
765        self.add_geom_with(geom)
766    }
767
768    pub fn geom_path(self) -> Self {
769        self.add_geom(GeomPath::default())
770    }
771
772    pub fn geom_path_with(self, geom: GeomPath) -> Self {
773        self.add_geom_with(geom)
774    }
775
776    /// Plot `fun(x)` as a line over the plot data's x range (ggplot2's
777    /// `geom_function()`); see [`geom_function_xlim`](Self::geom_function_xlim)
778    /// to plot without data.
779    pub fn geom_function(self, fun: impl Fn(f64) -> f64 + Send + Sync + 'static) -> Self {
780        self.geom_line()
781            .stat(crate::stat::function::StatFunction::new(fun))
782    }
783
784    /// Plot `fun(x)` over `[lo, hi]` without any data (`geom_function(xlim =)`).
785    pub fn geom_function_xlim(
786        self,
787        fun: impl Fn(f64) -> f64 + Send + Sync + 'static,
788        lo: f64,
789        hi: f64,
790    ) -> Self {
791        let range = vec![("x".to_string(), vec![Value::Float(lo), Value::Float(hi)])];
792        self.geom_line()
793            .layer_data(range)
794            .layer_aes(Aes::new().x("x"))
795            .stat(crate::stat::function::StatFunction::new(fun).with_xlim(lo, hi))
796    }
797
798    /// Join each group's points (in x order) with steps or straight lines
799    /// (ggplot2 4.0's `stat_connect(connection)`), drawn as a path.
800    pub fn stat_connect(self, connection: crate::stat::connect::Connection) -> Self {
801        self.geom_path()
802            .stat(crate::stat::connect::StatConnect::new(connection))
803    }
804
805    /// Points with duplicate rows dropped (`stat_unique()`); `.stat(StatUnique)`
806    /// applies it to any other layer.
807    pub fn stat_unique(self) -> Self {
808        self.geom_point().stat(crate::stat::unique::StatUnique)
809    }
810
811    /// Points from an arbitrary per-group transform (ggplot2 4.0's
812    /// `stat_manual(fun)`); `.stat(StatManual::new(f))` for other geoms.
813    pub fn stat_manual(
814        self,
815        fun: impl Fn(&crate::data::DataFrame) -> crate::data::DataFrame + Send + Sync + 'static,
816    ) -> Self {
817        self.geom_point()
818            .stat(crate::stat::manual::StatManual::new(fun))
819    }
820
821    /// Hexagons filled with a summary of the raw `z` column
822    /// (`stat_summary_hex(fun)`).
823    pub fn stat_summary_hex(self, fun: crate::stat::summary::SummaryFun) -> Self {
824        self.geom_hex()
825            .stat(crate::stat::binhex::StatSummaryHex::new(fun))
826    }
827
828    /// A point per WKT `geometry` (feature `sf`; `stat_sf_coordinates()`).
829    /// Use `.stat(StatSfCoordinates::default())` to put text, labels or any
830    /// other geom on a map.
831    #[cfg(feature = "sf")]
832    pub fn stat_sf_coordinates(self) -> Self {
833        self.geom_point()
834            .stat(crate::stat::sf_coordinates::StatSfCoordinates::default())
835    }
836
837    /// Add a confidence-ellipse layer (default 95%) as a path per group.
838    pub fn stat_ellipse(self) -> Self {
839        self.geom_path()
840            .stat(crate::stat::ellipse::StatEllipse::default())
841    }
842
843    /// Add a confidence-ellipse layer at the given level (0, 1).
844    pub fn stat_ellipse_level(self, level: f64) -> Self {
845        self.geom_path()
846            .stat(crate::stat::ellipse::StatEllipse::new(level))
847    }
848
849    /// Add a quantile-regression line for each `tau` as a separate path layer
850    /// (R's `stat_quantile`). Backed by anofox-regression (feature `regression`).
851    #[cfg(feature = "regression")]
852    pub fn stat_quantile(mut self, taus: &[f64]) -> Self {
853        for &tau in taus {
854            self = self
855                .geom_path()
856                .stat(crate::stat::quantile::StatQuantile::new(tau));
857        }
858        self
859    }
860
861    /// Quantile-regression lines at the quartiles (0.25, 0.5, 0.75).
862    #[cfg(feature = "regression")]
863    pub fn geom_quantile(self) -> Self {
864        self.stat_quantile(&[0.25, 0.5, 0.75])
865    }
866
867    pub fn geom_step(self) -> Self {
868        self.add_geom(GeomStep::default())
869    }
870
871    pub fn geom_step_with(self, geom: GeomStep) -> Self {
872        self.add_geom_with(geom)
873    }
874
875    pub fn geom_freqpoly(self) -> Self {
876        self.add_geom(GeomFreqpoly::default())
877    }
878
879    pub fn geom_freqpoly_with(self, geom: GeomFreqpoly) -> Self {
880        self.add_geom_with(geom)
881    }
882
883    pub fn geom_linerange(self) -> Self {
884        self.add_geom(GeomLinerange::default())
885    }
886
887    pub fn geom_linerange_with(self, geom: GeomLinerange) -> Self {
888        self.add_geom_with(geom)
889    }
890
891    pub fn geom_pointrange(self) -> Self {
892        self.add_geom(GeomPointrange::default())
893    }
894
895    pub fn geom_pointrange_with(self, geom: GeomPointrange) -> Self {
896        self.add_geom_with(geom)
897    }
898
899    pub fn geom_crossbar(self) -> Self {
900        self.add_geom(GeomCrossbar::default())
901    }
902
903    pub fn geom_crossbar_with(self, geom: GeomCrossbar) -> Self {
904        self.add_geom_with(geom)
905    }
906
907    pub fn geom_spoke(self) -> Self {
908        self.add_geom(GeomSpoke::default())
909    }
910
911    pub fn geom_spoke_with(self, geom: GeomSpoke) -> Self {
912        self.add_geom_with(geom)
913    }
914
915    pub fn geom_rect(self) -> Self {
916        self.add_geom(GeomRect::default())
917    }
918
919    pub fn geom_rect_with(self, geom: GeomRect) -> Self {
920        self.add_geom_with(geom)
921    }
922
923    pub fn geom_tile(self) -> Self {
924        self.add_geom(GeomTile::default())
925    }
926
927    pub fn geom_tile_with(self, geom: GeomTile) -> Self {
928        self.add_geom_with(geom)
929    }
930
931    /// Dense regular grid of filled cells (heatmap/raster) from x, y, fill.
932    pub fn geom_raster(self) -> Self {
933        self.add_geom(crate::geom::raster::GeomRaster::default())
934    }
935
936    pub fn geom_raster_with(self, geom: crate::geom::raster::GeomRaster) -> Self {
937        self.add_geom_with(geom)
938    }
939
940    pub fn geom_polygon(self) -> Self {
941        self.add_geom(GeomPolygon::default())
942    }
943
944    pub fn geom_polygon_with(self, geom: GeomPolygon) -> Self {
945        self.add_geom_with(geom)
946    }
947
948    /// Render simple-features geometry from a WKT `geometry` column (feature `sf`).
949    #[cfg(feature = "sf")]
950    pub fn geom_sf(self) -> Self {
951        self.add_geom(crate::geom::sf::GeomSf::default())
952    }
953
954    #[cfg(feature = "sf")]
955    pub fn geom_sf_with(self, geom: crate::geom::sf::GeomSf) -> Self {
956        self.add_geom_with(geom)
957    }
958
959    /// Labels placed inside `geom_sf` polygons (feature `sf`): `label`
960    /// aesthetic, `geometry` column — ggplot2's `geom_sf_text`.
961    #[cfg(feature = "sf")]
962    pub fn geom_sf_text(self) -> Self {
963        self.add_geom(crate::geom::sf_text::GeomSfText::default())
964    }
965
966    #[cfg(feature = "sf")]
967    pub fn geom_sf_text_with(self, geom: crate::geom::sf_text::GeomSfText) -> Self {
968        self.add_geom_with(geom)
969    }
970
971    /// Like [`geom_sf_text`](Self::geom_sf_text), on a background box —
972    /// ggplot2's `geom_sf_label`.
973    #[cfg(feature = "sf")]
974    pub fn geom_sf_label(self) -> Self {
975        self.add_geom(crate::geom::sf_text::GeomSfLabel::default())
976    }
977
978    #[cfg(feature = "sf")]
979    pub fn geom_sf_label_with(self, geom: crate::geom::sf_text::GeomSfLabel) -> Self {
980        self.add_geom_with(geom)
981    }
982
983    /// A metric scale bar in a panel corner (feature `sf`) — ggspatial's
984    /// `annotation_scale()`. Assumes the X/Y domain is in metres (any
985    /// `geom_sf` projection other than `SfProjection::PlateCarree`).
986    #[cfg(feature = "sf")]
987    pub fn annotation_scale(self) -> Self {
988        self.add_geom(crate::geom::annotation_scale::GeomAnnotationScale::default())
989    }
990
991    #[cfg(feature = "sf")]
992    pub fn annotation_scale_with(
993        self,
994        geom: crate::geom::annotation_scale::GeomAnnotationScale,
995    ) -> Self {
996        self.add_geom_with(geom)
997    }
998
999    /// A north arrow in a panel corner (feature `sf`) — see
1000    /// [`GeomNorthArrow`](crate::geom::annotation_scale::GeomNorthArrow).
1001    #[cfg(feature = "sf")]
1002    pub fn annotation_north_arrow_with(
1003        self,
1004        geom: crate::geom::annotation_scale::GeomNorthArrow,
1005    ) -> Self {
1006        self.add_geom_with(geom)
1007    }
1008
1009    /// A small, muted attribution text in a panel corner (feature `sf`) —
1010    /// e.g. `"© Natural Earth"`.
1011    #[cfg(feature = "sf")]
1012    pub fn annotation_attribution(self, text: impl Into<String>) -> Self {
1013        self.add_geom(crate::geom::annotation_scale::GeomAttribution::new(text))
1014    }
1015
1016    /// A raster basemap from an XYZ tile provider (feature `tiles`, #325),
1017    /// drawn under every other layer: tiles fetched through `fetcher` for
1018    /// the plot's Web-Mercator view (project `geom_sf` layers with
1019    /// `SfProjection::Mercator`, add `coord_sf()`), at a zoom picked from
1020    /// the panel size, plus the source's attribution in a corner. E.g.
1021    /// `annotation_map_tile(TileSource::xyz("https://tile.openstreetmap.org/{z}/{x}/{y}.png")
1022    /// .attribution("© OpenStreetMap contributors"), my_http_fetcher)`.
1023    #[cfg(feature = "tiles")]
1024    pub fn annotation_map_tile(
1025        self,
1026        source: crate::geom::map_tile::TileSource,
1027        fetcher: impl crate::geom::map_tile::TileFetcher + 'static,
1028    ) -> Self {
1029        self.annotation_map_tile_with(crate::geom::map_tile::GeomMapTile::new(source, fetcher))
1030    }
1031
1032    /// [`annotation_map_tile`](Self::annotation_map_tile) with a configured
1033    /// layer (fixed zoom, detail, opacity).
1034    #[cfg(feature = "tiles")]
1035    pub fn annotation_map_tile_with(self, geom: crate::geom::map_tile::GeomMapTile) -> Self {
1036        let attribution = geom.source.attribution.clone();
1037        let mut plot = self.add_geom_with(geom);
1038        // Under every other layer (per-layer settings shift up by one).
1039        if let Some(layer) = plot.layers.pop() {
1040            plot.layers.insert(0, layer);
1041            for (idx, ..) in plot.layer_svg_attrs.iter_mut() {
1042                *idx += 1;
1043            }
1044        }
1045        if attribution.is_empty() {
1046            plot
1047        } else {
1048            plot.annotation_attribution(attribution)
1049        }
1050    }
1051
1052    #[cfg(feature = "sf")]
1053    pub fn annotation_attribution_with(
1054        self,
1055        geom: crate::geom::annotation_scale::GeomAttribution,
1056    ) -> Self {
1057        self.add_geom_with(geom)
1058    }
1059
1060    /// The square `n × m` guide for a
1061    /// [`ScaleFillBivariate`](crate::scale::bivariate::ScaleFillBivariate)
1062    /// fill scale (feature `sf`): two axis captions, biscale's own legend
1063    /// shape — drawn as a furniture layer, not through the ordinary
1064    /// per-aesthetic legend (set `.legend_position(LegendPosition::None)`
1065    /// to hide that one). [`GeomBivariateLegend::from_scale`] builds this
1066    /// from the same scale used for the fill, so the swatches match.
1067    #[cfg(feature = "sf")]
1068    pub fn bivariate_legend(
1069        self,
1070        geom: crate::geom::bivariate_legend::GeomBivariateLegend,
1071    ) -> Self {
1072        self.add_geom(geom)
1073    }
1074
1075    /// Like [`Self::bivariate_legend`], but with each cell's row count
1076    /// drawn centred in its swatch — build with
1077    /// [`GeomBivariateLegend::counts`](crate::geom::bivariate_legend::GeomBivariateLegend::counts)
1078    /// (#259).
1079    #[cfg(feature = "sf")]
1080    pub fn bivariate_legend_counts(
1081        self,
1082        geom: crate::geom::bivariate_legend::GeomBivariateLegendCounts,
1083    ) -> Self {
1084        self.add_geom(geom)
1085    }
1086
1087    pub fn geom_curve(self) -> Self {
1088        self.add_geom(GeomCurve::default())
1089    }
1090
1091    pub fn geom_curve_with(self, geom: GeomCurve) -> Self {
1092        self.add_geom_with(geom)
1093    }
1094
1095    pub fn geom_violin(self) -> Self {
1096        self.add_geom(GeomViolin::default())
1097    }
1098
1099    pub fn geom_violin_with(self, geom: GeomViolin) -> Self {
1100        self.add_geom_with(geom)
1101    }
1102
1103    /// A ridgeline plot: one density of `x` per level of a discrete `y`,
1104    /// drawn upward from the level's baseline and overlapping the row above
1105    /// ([`GeomDensityRidges::scale`]) — distributions over time or groups.
1106    pub fn geom_density_ridges(self) -> Self {
1107        self.add_geom(GeomDensityRidges::default())
1108    }
1109
1110    pub fn geom_density_ridges_with(self, geom: GeomDensityRidges) -> Self {
1111        self.add_geom_with(geom)
1112    }
1113
1114    pub fn geom_dotplot(self) -> Self {
1115        self.add_geom(GeomDotplot::default())
1116    }
1117
1118    pub fn geom_dotplot_with(self, geom: GeomDotplot) -> Self {
1119        self.add_geom_with(geom)
1120    }
1121
1122    pub fn geom_qq(self) -> Self {
1123        self.add_geom(GeomQQ::default())
1124    }
1125
1126    pub fn geom_qq_with(self, geom: GeomQQ) -> Self {
1127        self.add_geom_with(geom)
1128    }
1129
1130    pub fn geom_qq_line(self) -> Self {
1131        self.add_geom(GeomQQLine::default())
1132    }
1133
1134    pub fn geom_qq_line_with(self, geom: GeomQQLine) -> Self {
1135        self.add_geom_with(geom)
1136    }
1137
1138    pub fn geom_bin2d(self) -> Self {
1139        self.add_geom(GeomBin2d::default())
1140    }
1141
1142    pub fn geom_bin2d_with(self, geom: GeomBin2d) -> Self {
1143        self.add_geom_with(geom)
1144    }
1145
1146    pub fn geom_hex(self) -> Self {
1147        self.add_geom(GeomHex::default())
1148    }
1149
1150    pub fn geom_hex_with(self, geom: GeomHex) -> Self {
1151        self.add_geom_with(geom)
1152    }
1153
1154    pub fn geom_count(self) -> Self {
1155        self.add_geom(GeomCount::default())
1156    }
1157
1158    pub fn geom_count_with(self, geom: GeomCount) -> Self {
1159        self.add_geom_with(geom)
1160    }
1161
1162    pub fn geom_contour(self) -> Self {
1163        self.add_geom(GeomContour::default())
1164    }
1165
1166    pub fn geom_contour_with(self, geom: GeomContour) -> Self {
1167        self.add_geom_with(geom)
1168    }
1169
1170    /// Filled contour bands from gridded (x, y, z) data — draws polygons filled by
1171    /// band level. Pair with a continuous fill scale (e.g. `scale_fill_viridis_c`).
1172    pub fn geom_contour_filled(self) -> Self {
1173        self.add_geom(GeomPolygon {
1174            line_width: 0.0,
1175            alpha: 1.0,
1176            ..GeomPolygon::default()
1177        })
1178        .stat(crate::stat::contour_filled::StatContourFilled::default())
1179    }
1180
1181    pub fn geom_density2d(self) -> Self {
1182        self.add_geom(GeomDensity2d::default())
1183    }
1184
1185    pub fn geom_density2d_with(self, geom: GeomDensity2d) -> Self {
1186        self.add_geom_with(geom)
1187    }
1188
1189    /// Waterfall (bridge) chart: map `x` (steps, in data order) and `y` (each
1190    /// step's change). Bars float from the running total and are filled by
1191    /// direction (Increase / Decrease / Total, with a legend); dashed
1192    /// connectors join consecutive bars. See [`geom_waterfall_with`](Self::geom_waterfall_with)
1193    /// for subtotals and a closing total.
1194    pub fn geom_waterfall(self) -> Self {
1195        self.default_styled(|p| {
1196            p.geom_waterfall_with(crate::geom::waterfall::GeomWaterfall::default())
1197        })
1198    }
1199
1200    /// [`geom_waterfall`](Self::geom_waterfall) with explicit colours,
1201    /// `totals` (subtotal x levels) and `final_total`. Unless `fill` is
1202    /// already mapped at plot level, the layer maps `after_stat(fill =
1203    /// direction)`; unless a fill scale is already set, a manual fill scale
1204    /// with the geom's `up`/`down`/`total` colours is added (a later
1205    /// `scale_fill_*` call replaces it).
1206    pub fn geom_waterfall_with(self, geom: crate::geom::waterfall::GeomWaterfall) -> Self {
1207        use crate::scale::color::RGBAColor;
1208        use crate::stat::waterfall::{WATERFALL_DECREASE, WATERFALL_INCREASE, WATERFALL_TOTAL};
1209        let rgba = |(r, g, b): (u8, u8, u8)| RGBAColor::new(r, g, b);
1210        let mut values = vec![
1211            (WATERFALL_INCREASE, rgba(geom.up)),
1212            (WATERFALL_DECREASE, rgba(geom.down)),
1213        ];
1214        if geom.has_totals() {
1215            values.push((WATERFALL_TOTAL, rgba(geom.total)));
1216        }
1217        let fill_mapped = self
1218            .mapping
1219            .get_mapping(&crate::aes::Aesthetic::Fill)
1220            .is_some();
1221        let has_fill_scale = self
1222            .scales
1223            .iter()
1224            .any(|s| s.aesthetic() == crate::aes::Aesthetic::Fill);
1225        let mut plot = self.add_geom_with(geom);
1226        if !fill_mapped {
1227            plot = plot.layer_aes(Aes::new().after_stat_fill("direction"));
1228            if !has_fill_scale {
1229                plot = plot.scale_fill_manual(values);
1230            }
1231        }
1232        plot
1233    }
1234
1235    /// Pareto chart: map `x` (categories) and `y` (values, summed per
1236    /// category). Bars are sorted descending and a cumulative line with
1237    /// points rises to the grand total, read against a 0–100 % right axis.
1238    pub fn geom_pareto(self) -> Self {
1239        self.add_pareto(crate::geom::pareto::GeomPareto::default(), false)
1240    }
1241
1242    /// [`geom_pareto`](Self::geom_pareto) with explicit styling, sorting or a
1243    /// threshold line. Unless a y scale is already set, a continuous y scale
1244    /// with a [`SecAxis::percent_of_max`](crate::scale::sec_axis::SecAxis::percent_of_max)
1245    /// secondary axis ("Cumulative %") is added; a later `scale_y_continuous`
1246    /// replaces it.
1247    pub fn geom_pareto_with(self, geom: crate::geom::pareto::GeomPareto) -> Self {
1248        self.add_pareto(geom, true)
1249    }
1250
1251    fn add_pareto(self, geom: crate::geom::pareto::GeomPareto, explicit: bool) -> Self {
1252        let has_y_scale = self
1253            .scales
1254            .iter()
1255            .any(|s| s.aesthetic() == crate::aes::Aesthetic::Y);
1256        let plot = if explicit {
1257            self.add_geom_with(geom)
1258        } else {
1259            self.add_geom(geom)
1260        };
1261        if has_y_scale {
1262            plot
1263        } else {
1264            plot.scale_y_continuous(ScaleContinuous::new().with_sec_axis(
1265                crate::scale::sec_axis::SecAxis::percent_of_max().with_name("Cumulative %"),
1266            ))
1267        }
1268    }
1269
1270    /// Sankey diagram from an edge list: map `from`, `to` and optionally
1271    /// `weight` (`Aes::new().from("src").to("dst").weight("flow")`). Nodes
1272    /// are placed in columns by their depth, sized by their flow and
1273    /// coloured by name through the fill scale (no legend — nodes are
1274    /// labelled); links take their source's colour. Axes and grid are
1275    /// hidden, whatever the theme.
1276    pub fn geom_sankey(self) -> Self {
1277        self.add_sankey(crate::geom::sankey::GeomSankey::default(), false)
1278    }
1279
1280    /// [`geom_sankey`](Self::geom_sankey) with explicit layout and styling.
1281    pub fn geom_sankey_with(self, geom: crate::geom::sankey::GeomSankey) -> Self {
1282        self.add_sankey(geom, true)
1283    }
1284
1285    fn add_sankey(self, geom: crate::geom::sankey::GeomSankey, explicit: bool) -> Self {
1286        let mut plot = if explicit {
1287            self.add_geom_with(geom)
1288        } else {
1289            self.add_geom(geom)
1290        };
1291        plot.hide_position_guides = true;
1292        plot.layer_aes(Aes::new().after_stat_fill("node"))
1293            .show_legend(false)
1294    }
1295
1296    /// Chord diagram / dependency wheel from an edge list: map `from`, `to`
1297    /// and optionally `weight`. Each node is an arc sized by its total flow
1298    /// (in + out); each link a ribbon through the centre, coloured by its
1299    /// source. Nodes are coloured by name through the fill scale (no legend
1300    /// — they are labelled); axes and grid are hidden and the panel is kept
1301    /// square so the circle stays round.
1302    pub fn geom_chord(self) -> Self {
1303        self.add_chord(crate::geom::chord::GeomChord::default(), false)
1304    }
1305
1306    /// [`geom_chord`](Self::geom_chord) with explicit padding and styling.
1307    pub fn geom_chord_with(self, geom: crate::geom::chord::GeomChord) -> Self {
1308        self.add_chord(geom, true)
1309    }
1310
1311    fn add_chord(self, geom: crate::geom::chord::GeomChord, explicit: bool) -> Self {
1312        let (ex, ey) = geom.extents();
1313        let mut plot = if explicit {
1314            self.add_geom_with(geom)
1315        } else {
1316            self.add_geom(geom)
1317        };
1318        plot.hide_position_guides = true;
1319        plot.default_aspect_ratio = Some(ey / ex);
1320        plot.layer_aes(Aes::new().after_stat_fill("node"))
1321            .show_legend(false)
1322    }
1323
1324    /// Arc diagram from an edge list: map `from`, `to` and optionally
1325    /// `weight`. Nodes sit on a line (sized by total flow), links are arcs
1326    /// whose width is their weight. Coloured by node name through the fill
1327    /// scale (no legend — nodes are labelled); axes and grid are hidden.
1328    pub fn geom_arcdiagram(self) -> Self {
1329        self.add_arcdiagram(crate::geom::arcdiagram::GeomArcdiagram::default(), false)
1330    }
1331
1332    /// [`geom_arcdiagram`](Self::geom_arcdiagram) with explicit ordering,
1333    /// direction and styling.
1334    pub fn geom_arcdiagram_with(self, geom: crate::geom::arcdiagram::GeomArcdiagram) -> Self {
1335        self.add_arcdiagram(geom, true)
1336    }
1337
1338    fn add_arcdiagram(self, geom: crate::geom::arcdiagram::GeomArcdiagram, explicit: bool) -> Self {
1339        let mut plot = if explicit {
1340            self.add_geom_with(geom)
1341        } else {
1342            self.add_geom(geom)
1343        };
1344        plot.hide_position_guides = true;
1345        plot.layer_aes(Aes::new().after_stat_fill("node"))
1346            .show_legend(false)
1347    }
1348
1349    /// Squarified treemap: map `weight` (tile area) and optionally
1350    /// `subgroup` (tiles nest inside group rectangles), `fill` and `label`
1351    /// (`Aes::new().weight("sales").subgroup("region").label("country")`).
1352    /// When `subgroup` is mapped at plot level and `fill` is not, tiles are
1353    /// filled by subgroup. The panel aspect matches the layout so tiles come
1354    /// out square; the position scales get no expansion and the axes and
1355    /// grid are hidden.
1356    pub fn geom_treemap(self) -> Self {
1357        self.add_treemap(crate::geom::treemap::GeomTreemap::default(), false)
1358    }
1359
1360    /// [`geom_treemap`](Self::geom_treemap) with explicit layout and styling.
1361    pub fn geom_treemap_with(self, geom: crate::geom::treemap::GeomTreemap) -> Self {
1362        self.add_treemap(geom, true)
1363    }
1364
1365    fn add_treemap(self, geom: crate::geom::treemap::GeomTreemap, explicit: bool) -> Self {
1366        use crate::aes::Aesthetic;
1367        let aspect = geom.aspect;
1368        let has = |plot: &Self, aes: Aesthetic| plot.scales.iter().any(|s| s.aesthetic() == aes);
1369        let (has_x, has_y) = (has(&self, Aesthetic::X), has(&self, Aesthetic::Y));
1370        let fill_by = match (
1371            self.mapping.get_mapping(&Aesthetic::Fill),
1372            self.mapping.get_mapping(&Aesthetic::Subgroup),
1373        ) {
1374            (None, Some(sub)) => Some(sub.to_string()),
1375            _ => None,
1376        };
1377        let mut plot = if explicit {
1378            self.add_geom_with(geom)
1379        } else {
1380            self.add_geom(geom)
1381        };
1382        plot.hide_position_guides = true;
1383        plot.default_aspect_ratio = Some(1.0 / aspect.max(1e-6));
1384        if let Some(sub) = fill_by {
1385            plot = plot.layer_aes(Aes::new().fill(&sub));
1386        }
1387        if !has_x {
1388            plot = plot.scale_x_continuous(ScaleContinuous::new().with_expand(0.0, 0.0));
1389        }
1390        if !has_y {
1391            plot = plot.scale_y_continuous(ScaleContinuous::new().with_expand(0.0, 0.0));
1392        }
1393        plot
1394    }
1395
1396    /// Sunburst over the hierarchy columns `levels` (outermost first),
1397    /// sized by `weight` (1 per row when unmapped): concentric rings, one per
1398    /// level, each node's angle ∝ its value and nested in its parent's.
1399    /// Sets `coord_polar`, colours each branch by its top-level ancestor
1400    /// (lighter further out; no legend — sectors are labelled), drops scale
1401    /// expansion and hides the position guides.
1402    pub fn geom_sunburst<S: Into<String>>(self, levels: impl IntoIterator<Item = S>) -> Self {
1403        let geom = crate::geom::partition::GeomPartition::sunburst(levels);
1404        // The hole is `hole` level-widths of the `levels.len() + hole` radius.
1405        let depth = geom.levels.len().max(1) as f64;
1406        let inner = geom.hole / (geom.hole + depth);
1407        let mut plot = self.default_styled(|p| p.geom_partition_with(geom));
1408        plot.coord = Box::new(CoordPolar::new().inner_radius(inner));
1409        plot
1410    }
1411
1412    /// Icicle chart over `levels`: the partition of
1413    /// [`geom_sunburst`](Self::geom_sunburst) in Cartesian coordinates, root
1414    /// level on top.
1415    pub fn geom_icicle<S: Into<String>>(self, levels: impl IntoIterator<Item = S>) -> Self {
1416        self.default_styled(|p| {
1417            p.geom_partition_with(crate::geom::partition::GeomPartition::icicle(levels))
1418        })
1419    }
1420
1421    /// A partition layer with explicit settings (sunburst under
1422    /// `coord_polar`, icicle otherwise); same defaults as the shortcuts
1423    /// except the coordinate system, which is left as is.
1424    pub fn geom_partition_with(self, geom: crate::geom::partition::GeomPartition) -> Self {
1425        use crate::aes::Aesthetic;
1426        let has = |plot: &Self, aes: Aesthetic| plot.scales.iter().any(|s| s.aesthetic() == aes);
1427        let (has_x, has_y) = (has(&self, Aesthetic::X), has(&self, Aesthetic::Y));
1428        let fill_mapped = self.mapping.get_mapping(&Aesthetic::Fill).is_some();
1429        let mut plot = self.add_geom_with(geom);
1430        plot.hide_position_guides = true;
1431        if !fill_mapped {
1432            plot = plot
1433                .layer_aes(Aes::new().after_stat_fill("root"))
1434                .show_legend(false);
1435        }
1436        if !has_x {
1437            plot = plot.scale_x_continuous(ScaleContinuous::new().with_expand(0.0, 0.0));
1438        }
1439        if !has_y {
1440            plot = plot.scale_y_continuous(ScaleContinuous::new().with_expand(0.0, 0.0));
1441        }
1442        plot
1443    }
1444
1445    /// Funnel chart: map `x` (stage, in data order; repeats are summed) and
1446    /// `y` (value). Equal-height stages whose width is proportional to the
1447    /// value, each tapering to the next; labels show the value and the
1448    /// conversion from the first stage. Stages are filled by name (no
1449    /// legend); axes and grid are hidden. See
1450    /// [`GeomFunnel::silhouette`](crate::geom::funnel::GeomFunnel::silhouette)
1451    /// for the Highcharts-style fixed shape.
1452    pub fn geom_funnel(self) -> Self {
1453        self.default_styled(|p| p.geom_funnel_with(crate::geom::funnel::GeomFunnel::default()))
1454    }
1455
1456    /// Pyramid chart: apex on top, stage heights proportional to the values.
1457    pub fn geom_pyramid(self) -> Self {
1458        self.default_styled(|p| p.geom_funnel_with(crate::geom::funnel::GeomFunnel::pyramid()))
1459    }
1460
1461    /// [`geom_funnel`](Self::geom_funnel) with an explicit shape and styling.
1462    pub fn geom_funnel_with(self, geom: crate::geom::funnel::GeomFunnel) -> Self {
1463        let fill_mapped = self
1464            .mapping
1465            .get_mapping(&crate::aes::Aesthetic::Fill)
1466            .is_some();
1467        let mut plot = self.add_geom_with(geom);
1468        plot.hide_position_guides = true;
1469        if !fill_mapped {
1470            plot = plot
1471                .layer_aes(Aes::new().after_stat_fill("stage"))
1472                .show_legend(false);
1473        }
1474        plot
1475    }
1476
1477    /// Streamgraph: [`geom_area`](Self::geom_area) stacked around a
1478    /// wiggle-minimising baseline ([`StackOffset::Wiggle`](crate::position::stack::StackOffset::Wiggle)).
1479    /// Map `x`, `y` (non-negative) and `fill` (the series). For a
1480    /// symmetric ThemeRiver use
1481    /// `.geom_area().position(position_stack().with_offset(StackOffset::Silhouette))`.
1482    pub fn geom_stream(self) -> Self {
1483        self.geom_area().position(
1484            crate::position::stack::position_stack()
1485                .with_offset(crate::position::stack::StackOffset::Wiggle),
1486        )
1487    }
1488
1489    /// Lollipop chart: a stem from 0 to `y` capped with a dot, per `x`
1490    /// (`coord_flip` for horizontal lollipops).
1491    pub fn geom_lollipop(self) -> Self {
1492        self.add_geom(crate::geom::lollipop::GeomLollipop::default())
1493    }
1494
1495    pub fn geom_lollipop_with(self, geom: crate::geom::lollipop::GeomLollipop) -> Self {
1496        self.add_geom_with(geom)
1497    }
1498
1499    /// Dumbbell chart: a segment from `ymin` to `ymax` per `x`, with a dot at
1500    /// each end (low / high colours); `coord_flip` for the usual horizontal
1501    /// layout.
1502    pub fn geom_dumbbell(self) -> Self {
1503        self.add_geom(crate::geom::lollipop::GeomDumbbell::default())
1504    }
1505
1506    pub fn geom_dumbbell_with(self, geom: crate::geom::lollipop::GeomDumbbell) -> Self {
1507        self.add_geom_with(geom)
1508    }
1509
1510    /// Bullet chart (horizontal): map `x` (KPI) and `y` (actual); configure
1511    /// the target column and range bands on the geom, e.g.
1512    /// `GeomBullet::default().with_target("goal").with_ranges([60.0, 80.0, 100.0])`.
1513    /// Sets `coord_flip`; use [`geom_bullet_vertical`](Self::geom_bullet_vertical)
1514    /// for upright bullets.
1515    pub fn geom_bullet(self, geom: crate::geom::bullet::GeomBullet) -> Self {
1516        self.add_geom_with(geom).coord_flip()
1517    }
1518
1519    /// [`geom_bullet`](Self::geom_bullet) without the flip (vertical bars).
1520    pub fn geom_bullet_vertical(self, geom: crate::geom::bullet::GeomBullet) -> Self {
1521        self.add_geom_with(geom)
1522    }
1523
1524    /// Waffle chart: map `fill` (category) and `weight` (units per row);
1525    /// units fill a grid `rows` high, one contiguous block per category.
1526    /// Axes and grid are hidden; units stay square at any panel aspect.
1527    pub fn geom_waffle(self, rows: usize) -> Self {
1528        self.default_styled(|p| p.geom_waffle_with(crate::geom::waffle::GeomWaffle::waffle(rows)))
1529    }
1530
1531    /// Parliament (hemicycle) chart: map `fill` (party) and `weight`
1532    /// (seats); seats on concentric arcs, parties left to right in data
1533    /// order. `rows = 0` picks the number of arcs from the seat count.
1534    pub fn geom_parliament(self, rows: usize) -> Self {
1535        self.default_styled(|p| {
1536            p.geom_waffle_with(crate::geom::waffle::GeomWaffle::parliament(rows))
1537        })
1538    }
1539
1540    /// A waffle / parliament layer with explicit settings.
1541    pub fn geom_waffle_with(self, geom: crate::geom::waffle::GeomWaffle) -> Self {
1542        let mut plot = self.add_geom_with(geom);
1543        plot.hide_position_guides = true;
1544        plot
1545    }
1546
1547    /// Packed bubble chart: circles with area ∝ `weight`, packed without
1548    /// overlap; with `subgroup` they cluster per group (outlined). Fills by
1549    /// subgroup when `subgroup` is mapped at plot level and `fill` is not.
1550    /// The panel is square and the axes and grid are hidden.
1551    pub fn geom_packed_bubble(self) -> Self {
1552        self.default_styled(|p| {
1553            p.geom_packed_bubble_with(crate::geom::pack::GeomPackedBubble::default())
1554        })
1555    }
1556
1557    /// [`geom_packed_bubble`](Self::geom_packed_bubble) with explicit styling.
1558    pub fn geom_packed_bubble_with(self, geom: crate::geom::pack::GeomPackedBubble) -> Self {
1559        use crate::aes::Aesthetic;
1560        let fill_by = match (
1561            self.mapping.get_mapping(&Aesthetic::Fill),
1562            self.mapping.get_mapping(&Aesthetic::Subgroup),
1563        ) {
1564            (None, Some(sub)) => Some(sub.to_string()),
1565            _ => None,
1566        };
1567        let mut plot = self.add_geom_with(geom);
1568        plot.hide_position_guides = true;
1569        plot.default_aspect_ratio = Some(1.0);
1570        if let Some(sub) = fill_by {
1571            plot = plot.layer_aes(Aes::new().fill(&sub));
1572        }
1573        plot
1574    }
1575
1576    /// Network graph from an edge list: map `from`, `to` and optionally
1577    /// `weight`. Force-directed layout (seeded, deterministic), edges with
1578    /// width ∝ weight, nodes sized by degree and labelled. Square panel,
1579    /// axes and grid hidden. See [`GeomNetwork`](crate::geom::network::GeomNetwork)
1580    /// for directed edges, other layouts and colouring by component.
1581    pub fn geom_network(self) -> Self {
1582        self.add_network(crate::geom::network::GeomNetwork::default(), false)
1583    }
1584
1585    /// [`geom_network`](Self::geom_network) with explicit settings.
1586    pub fn geom_network_with(self, geom: crate::geom::network::GeomNetwork) -> Self {
1587        self.add_network(geom, true)
1588    }
1589
1590    fn add_network(self, geom: crate::geom::network::GeomNetwork, explicit: bool) -> Self {
1591        let by_component = geom.color_components;
1592        let mut plot = if explicit {
1593            self.add_geom_with(geom)
1594        } else {
1595            self.add_geom(geom)
1596        };
1597        plot.hide_position_guides = true;
1598        plot.default_aspect_ratio = Some(1.0);
1599        if by_component {
1600            plot = plot
1601                .layer_aes(Aes::new().after_stat_fill("component"))
1602                .show_legend(false);
1603        }
1604        plot
1605    }
1606
1607    /// Tree diagram from parent → child links: map `from` (parent) and `to`
1608    /// (child). Tidy layout (leaves evenly spaced, parents centred over
1609    /// their children), top-down with curved links. See
1610    /// [`GeomTree::org_chart`](crate::geom::tree::GeomTree::org_chart) and
1611    /// other orientations via [`geom_tree_with`](Self::geom_tree_with).
1612    /// Axes and grid are hidden.
1613    pub fn geom_tree(self) -> Self {
1614        self.default_styled(|p| p.geom_tree_with(crate::geom::tree::GeomTree::default()))
1615    }
1616
1617    /// Dendrogram from [`dendrogram_links`](crate::stat::tree::dendrogram_links)
1618    /// output (`from`, `to`, `height`): step links at the merge heights,
1619    /// leaves labelled.
1620    pub fn geom_dendrogram(self) -> Self {
1621        self.default_styled(|p| p.geom_tree_with(crate::geom::tree::GeomTree::dendrogram()))
1622    }
1623
1624    /// A tree layer with explicit layout and styling.
1625    pub fn geom_tree_with(self, geom: crate::geom::tree::GeomTree) -> Self {
1626        let radial = geom.orientation == crate::stat::tree::TreeOrientation::Radial;
1627        let mut plot = self.add_geom_with(geom);
1628        plot.hide_position_guides = true;
1629        if radial {
1630            plot.default_aspect_ratio = Some(1.0);
1631        }
1632        plot
1633    }
1634
1635    /// Area-proportional Venn / Euler diagram: map `sets` (`"A"`, `"B"`,
1636    /// `"A&B"`, … — venn.js-style inclusive sizes) and `weight` (size).
1637    /// Exact for two and three sets' pairwise overlaps, approximate beyond.
1638    /// Circles are filled by set name (no legend — regions are labelled);
1639    /// square panel, axes and grid hidden.
1640    pub fn geom_venn(self) -> Self {
1641        self.default_styled(|p| p.geom_venn_with(crate::geom::venn::GeomVenn::default()))
1642    }
1643
1644    /// [`geom_venn`](Self::geom_venn) with explicit styling.
1645    pub fn geom_venn_with(self, geom: crate::geom::venn::GeomVenn) -> Self {
1646        let mut plot = self.add_geom_with(geom);
1647        plot.hide_position_guides = true;
1648        plot.default_aspect_ratio = Some(1.0);
1649        plot.layer_aes(Aes::new().after_stat_fill("set"))
1650            .show_legend(false)
1651    }
1652
1653    /// Word cloud: map `label` (word) and `weight` (frequency); optionally
1654    /// `color`. Largest words placed first along a spiral from the centre,
1655    /// no overlaps, deterministic; words that don't fit are dropped. Axes
1656    /// and grid are hidden.
1657    pub fn geom_wordcloud(self) -> Self {
1658        self.default_styled(|p| {
1659            p.geom_wordcloud_with(crate::geom::wordcloud::GeomWordcloud::default())
1660        })
1661    }
1662
1663    /// [`geom_wordcloud`](Self::geom_wordcloud) with explicit sizes,
1664    /// rotation share, seed and palette.
1665    pub fn geom_wordcloud_with(self, geom: crate::geom::wordcloud::GeomWordcloud) -> Self {
1666        let mut plot = self.add_geom_with(geom);
1667        plot.hide_position_guides = true;
1668        plot
1669    }
1670
1671    /// Variwide chart: map `x` (category), `y` (height) and `weight` (bar
1672    /// width). Unless an x scale is set, the x axis is continuous over the
1673    /// column layout with each category named under its column (computed
1674    /// from the plot data, so map `x`/`weight` at plot level).
1675    pub fn geom_variwide(self) -> Self {
1676        self.default_styled(|p| {
1677            p.geom_marimekko_with(crate::geom::marimekko::GeomMarimekko::variwide())
1678        })
1679    }
1680
1681    /// Marimekko (mosaic) chart: map `x` (column), `fill` (segment) and
1682    /// `weight` (value). Column widths are their totals' shares, segments
1683    /// their share of the column (y axis 0–100 %).
1684    pub fn geom_marimekko(self) -> Self {
1685        self.default_styled(|p| {
1686            p.geom_marimekko_with(crate::geom::marimekko::GeomMarimekko::marimekko())
1687        })
1688    }
1689
1690    /// A variwide / Marimekko layer with explicit settings.
1691    pub fn geom_marimekko_with(self, geom: crate::geom::marimekko::GeomMarimekko) -> Self {
1692        use crate::aes::Aesthetic;
1693        let has = |plot: &Self, aes: Aesthetic| plot.scales.iter().any(|s| s.aesthetic() == aes);
1694        let (has_x, has_y) = (has(&self, Aesthetic::X), has(&self, Aesthetic::Y));
1695        // Column centres and names from the plot data (same layout as the stat).
1696        let axis = self.mapping.get_mapping(&Aesthetic::X).and_then(|xc| {
1697            let x = self.data.column(xc)?;
1698            let w = self
1699                .mapping
1700                .get_mapping(&Aesthetic::Weight)
1701                .and_then(|wc| self.data.column(wc));
1702            let totals = crate::stat::marimekko::column_totals(x, w);
1703            let widths: Vec<f64> = totals.iter().map(|t| t.1).collect();
1704            let spans = crate::stat::marimekko::column_spans(&widths, geom.gap);
1705            Some((
1706                spans
1707                    .iter()
1708                    .map(|s| (s.0 + s.1) / 2.0)
1709                    .collect::<Vec<f64>>(),
1710                totals.into_iter().map(|t| t.0).collect::<Vec<String>>(),
1711            ))
1712        });
1713        let mosaic = geom.mosaic;
1714        let mut plot = self.add_geom_with(geom);
1715        if !has_x {
1716            let mut sx = ScaleContinuous::new().with_expand(0.0, 0.0);
1717            if let Some((breaks, labels)) = axis {
1718                sx = sx.with_breaks(breaks).with_labels(labels);
1719            }
1720            plot = plot.scale_x_continuous(sx);
1721        }
1722        if mosaic && !has_y {
1723            plot = plot.scale_y_continuous(
1724                ScaleContinuous::new()
1725                    .with_expand(0.0, 0.0)
1726                    .with_label_formatter(|v| format!("{}%", (v * 100.0).round())),
1727            );
1728        }
1729        plot
1730    }
1731
1732    /// Timeline: map `x` (event time, e.g. a date), `label`, and optionally
1733    /// `xend` (span end) and `color`. Events sit on a central baseline with
1734    /// boxed labels stacked on alternating levels so they never overlap;
1735    /// the y axis and horizontal grid are hidden.
1736    pub fn geom_timeline(self) -> Self {
1737        let mut plot = self.add_geom(crate::geom::timeline::GeomTimeline::default());
1738        plot.hide_y_guides = true;
1739        plot
1740    }
1741
1742    /// [`geom_timeline`](Self::geom_timeline) with explicit styling.
1743    pub fn geom_timeline_with(self, geom: crate::geom::timeline::GeomTimeline) -> Self {
1744        let mut plot = self.add_geom_with(geom);
1745        plot.hide_y_guides = true;
1746        plot
1747    }
1748
1749    /// X-range / Gantt chart: map `y` (task), `xmin` (start) and `xmax`
1750    /// (end), optionally `fill`. Zero-length tasks are milestones
1751    /// (diamonds). Unless a y scale is set, tasks read top to bottom in data
1752    /// order (from the plot data). See
1753    /// [`GeomXrange`](crate::geom::xrange::GeomXrange) for progress and
1754    /// dependency arrows.
1755    pub fn geom_xrange(self) -> Self {
1756        self.add_xrange(crate::geom::xrange::GeomXrange::default(), false)
1757    }
1758
1759    /// [`geom_xrange`](Self::geom_xrange) with progress / dependency columns.
1760    pub fn geom_xrange_with(self, geom: crate::geom::xrange::GeomXrange) -> Self {
1761        self.add_xrange(geom, true)
1762    }
1763
1764    fn add_xrange(self, geom: crate::geom::xrange::GeomXrange, explicit: bool) -> Self {
1765        use crate::aes::Aesthetic;
1766        let has_y = self.scales.iter().any(|s| s.aesthetic() == Aesthetic::Y);
1767        // First task on top: reverse the data order for the discrete axis.
1768        let levels: Option<Vec<String>> = self
1769            .mapping
1770            .get_mapping(&Aesthetic::Y)
1771            .and_then(|c| self.data.column(c))
1772            .map(|col| {
1773                let mut seen: indexmap::IndexSet<String> = indexmap::IndexSet::new();
1774                for v in col.iter().filter(|v| !v.is_na()) {
1775                    seen.insert(v.key_str().into_owned());
1776                }
1777                seen.into_iter().rev().collect()
1778            });
1779        let mut plot = if explicit {
1780            self.add_geom_with(geom)
1781        } else {
1782            self.add_geom(geom)
1783        };
1784        if let (false, Some(levels)) = (has_y, levels) {
1785            let refs: Vec<&str> = levels.iter().map(String::as_str).collect();
1786            plot = plot
1787                .scale_y_discrete(crate::scale::discrete::ScaleDiscrete::new().with_limits(refs));
1788        }
1789        plot
1790    }
1791
1792    /// Tile-grid cartogram: map `x` (grid column), `y` (grid row, 0 at the
1793    /// top), `fill` and `label`; one equal tile per row (see
1794    /// [`us_state_grid`](crate::geom::tilemap::us_state_grid) for a ready
1795    /// US layout). Axes and grid are hidden.
1796    pub fn geom_tilemap(self, shape: crate::geom::tilemap::TileShape) -> Self {
1797        self.default_styled(|p| {
1798            p.geom_tilemap_with(crate::geom::tilemap::GeomTilemap {
1799                shape,
1800                ..Default::default()
1801            })
1802        })
1803    }
1804
1805    /// [`geom_tilemap`](Self::geom_tilemap) with explicit styling.
1806    pub fn geom_tilemap_with(self, geom: crate::geom::tilemap::GeomTilemap) -> Self {
1807        let mut plot = self.add_geom_with(geom);
1808        plot.hide_position_guides = true;
1809        plot
1810    }
1811
1812    /// Wind barbs: map `x` (and optionally `y`), and name the speed (knots,
1813    /// or m/s via `.meters_per_second()`) and direction (degrees the wind
1814    /// comes from) columns:
1815    /// `geom_windbarb(GeomWindbarb::new("speed", "dir"))`.
1816    pub fn geom_windbarb(self, geom: crate::geom::windbarb::GeomWindbarb) -> Self {
1817        self.add_geom(geom)
1818    }
1819
1820    /// Variable-radius pie (Highcharts `variablepie`): map `y` (wedge
1821    /// angle), `radius` (wedge length) and `fill` (category), optionally
1822    /// `label`. Square panel, axes and grid hidden.
1823    pub fn geom_variable_pie(self) -> Self {
1824        let mut plot = self.add_geom(crate::geom::variablepie::GeomVariablePie::default());
1825        plot.hide_position_guides = true;
1826        plot.default_aspect_ratio = Some(1.0);
1827        plot
1828    }
1829
1830    /// [`geom_variable_pie`](Self::geom_variable_pie) with explicit settings
1831    /// (e.g. a donut `inner`).
1832    pub fn geom_variable_pie_with(self, geom: crate::geom::variablepie::GeomVariablePie) -> Self {
1833        let mut plot = self.add_geom_with(geom);
1834        plot.hide_position_guides = true;
1835        plot.default_aspect_ratio = Some(1.0);
1836        plot
1837    }
1838
1839    /// HLC bars: a high–low bar with a close tick (no `open` needed); map
1840    /// `x` and `high`/`low`/`close`.
1841    pub fn geom_hlc(self) -> Self {
1842        self.add_geom(crate::geom::candlestick::GeomOhlc::hlc())
1843    }
1844
1845    /// Heikin-Ashi candles: [`geom_candlestick`](Self::geom_candlestick)
1846    /// over the Heikin-Ashi transform of the OHLC values
1847    /// ([`StatHeikinAshi`](crate::stat::heikin_ashi::StatHeikinAshi)).
1848    pub fn geom_heikin_ashi(self) -> Self {
1849        self.add_geom(crate::geom::candlestick::GeomCandlestick::default())
1850            .stat(crate::stat::heikin_ashi::StatHeikinAshi)
1851    }
1852
1853    /// Renko chart: map `y` to the price (rows in time order); a brick of
1854    /// `box_size` per full move, reversals after two boxes. The x axis
1855    /// counts bricks.
1856    pub fn geom_renko(self, box_size: f64) -> Self {
1857        self.add_geom(crate::geom::renko::GeomRenko::renko(box_size))
1858            .xlab("Brick")
1859    }
1860
1861    /// Point-and-figure chart: map `y` to the price (rows in time order);
1862    /// X columns rise, O columns fall, a column reverses after `reversal`
1863    /// boxes of `box_size`. The x axis counts columns.
1864    pub fn geom_point_figure(self, box_size: f64, reversal: usize) -> Self {
1865        self.add_geom(crate::geom::renko::GeomRenko::point_figure(
1866            box_size, reversal,
1867        ))
1868        .xlab("Column")
1869    }
1870
1871    /// Event flags: map `x` and `label` (usually as a layer with its own
1872    /// `layer_data`); flags stand on the x axis, or on a series with
1873    /// [`GeomFlags::on_series`](crate::geom::flags::GeomFlags::on_series).
1874    /// Flags at the same x stack.
1875    pub fn geom_flags(self) -> Self {
1876        self.add_geom(crate::geom::flags::GeomFlags::default())
1877    }
1878
1879    pub fn geom_flags_with(self, geom: crate::geom::flags::GeomFlags) -> Self {
1880        self.add_geom_with(geom)
1881    }
1882
1883    /// Flow map (feature `sf`): map `x`/`y` (origin lon/lat), `xend`/`yend`
1884    /// (destination) and `weight`; curved bands with arrowheads, projected
1885    /// with the geom's projection (match your `geom_sf` layers), e.g.
1886    /// `geom_flow(GeomFlow::default().project(SfProjection::Mercator))`.
1887    #[cfg(feature = "sf")]
1888    pub fn geom_flow(self, geom: crate::geom::flow::GeomFlow) -> Self {
1889        self.add_geom(geom)
1890    }
1891
1892    /// Candlestick chart: map `x` and `open`/`high`/`low`/`close`
1893    /// (`Aes::new().x("date").open("o").high("h").low("l").close("c")`).
1894    pub fn geom_candlestick(self) -> Self {
1895        self.add_geom(crate::geom::candlestick::GeomCandlestick::default())
1896    }
1897
1898    pub fn geom_candlestick_with(self, geom: crate::geom::candlestick::GeomCandlestick) -> Self {
1899        self.add_geom_with(geom)
1900    }
1901
1902    /// OHLC bar chart (high–low bar with open/close ticks); same aesthetics as
1903    /// [`geom_candlestick`](Self::geom_candlestick).
1904    pub fn geom_ohlc(self) -> Self {
1905        self.add_geom(crate::geom::candlestick::GeomOhlc::default())
1906    }
1907
1908    pub fn geom_ohlc_with(self, geom: crate::geom::candlestick::GeomOhlc) -> Self {
1909        self.add_geom_with(geom)
1910    }
1911
1912    /// Calendar heatmap (GitHub/ECharts style): the plot's `x` (a date —
1913    /// `DateTime`, epoch seconds or `"YYYY-MM-DD"`) is laid out as week
1914    /// columns × weekday rows (Sunday on top) via [`StatCalendar`], coloured
1915    /// by `fill`, with month labels along x, Mon/Wed/Fri along y, month
1916    /// boundary outlines and square cells. Spans over
1917    /// [`MAX_CALENDAR_YEARS`] are clipped to the most recent years (with a
1918    /// build warning). Uses the plot-level data and `x` mapping.
1919    ///
1920    /// [`StatCalendar`]: crate::stat::calendar::StatCalendar
1921    /// [`MAX_CALENDAR_YEARS`]: crate::stat::calendar::MAX_CALENDAR_YEARS
1922    pub fn geom_calendar(self) -> Self {
1923        self.geom_calendar_with(
1924            GeomTile {
1925                color: (255, 255, 255),
1926                line_width: 1.5,
1927                ..Default::default()
1928            },
1929            false,
1930        )
1931    }
1932
1933    /// [`geom_calendar`](Self::geom_calendar) with a custom cell style and
1934    /// optionally Monday-first weeks.
1935    pub fn geom_calendar_with(mut self, tile: GeomTile, monday_first: bool) -> Self {
1936        use crate::stat::calendar::{
1937            day_number, month_boundaries, month_breaks, month_breaks_localized, weekday_labels,
1938            CalendarGrid, StatCalendar, MAX_CALENDAR_YEARS,
1939        };
1940        let grid = self
1941            .mapping
1942            .get_mapping(&crate::aes::Aesthetic::X)
1943            .and_then(|c| self.data.column(c))
1944            .and_then(|col| {
1945                CalendarGrid::from_days(col.iter().filter_map(day_number), monday_first)
1946            });
1947        let Some((grid, clipped)) = grid else {
1948            // No dates: an empty calendar layer (renders an empty panel).
1949            return self.add_geom_with(tile).stat(StatCalendar {
1950                grid: None,
1951                monday_first,
1952            });
1953        };
1954        if clipped {
1955            self.warnings.push(format!(
1956                "geom_calendar: dates more than {MAX_CALENDAR_YEARS} years before the newest were dropped"
1957            ));
1958        }
1959        let (xb, _) = month_breaks(&grid);
1960        // Rows: y = 6 - weekday; label Mon/Wed/Fri.
1961        let yb = if monday_first {
1962            vec![6.0, 4.0, 2.0]
1963        } else {
1964            vec![5.0, 3.0, 1.0]
1965        };
1966        let segs = month_boundaries(&grid);
1967        let col = |f: fn(&(f64, f64, f64, f64)) -> f64| -> Vec<Value> {
1968            segs.iter().map(|s| Value::Float(f(s))).collect()
1969        };
1970        let boundaries = vec![
1971            ("x".to_string(), col(|s| s.0)),
1972            ("y".to_string(), col(|s| s.1)),
1973            ("xend".to_string(), col(|s| s.2)),
1974            ("yend".to_string(), col(|s| s.3)),
1975        ];
1976        self.default_aspect_ratio = Some(7.0 / grid.n_weeks().max(1) as f64);
1977        let plot = self
1978            .add_geom_with(tile)
1979            .stat(StatCalendar {
1980                grid: Some(grid),
1981                monday_first,
1982            })
1983            .scale_x_continuous(
1984                ScaleContinuous::new()
1985                    .with_breaks(xb)
1986                    // Month / weekday names follow the plot locale.
1987                    .with_localized_labels(move |l| month_breaks_localized(&grid, l).1)
1988                    .with_expand(0.0, 0.0),
1989            )
1990            .scale_y_continuous(
1991                ScaleContinuous::new()
1992                    .with_breaks(yb)
1993                    .with_localized_labels(weekday_labels)
1994                    .with_expand(0.0, 0.0),
1995            );
1996        if segs.is_empty() {
1997            return plot;
1998        }
1999        // Styled by the theme's `calendar_month_border` at draw time.
2000        plot.add_geom_with(crate::geom::segment::GeomCalendarBorder)
2001            .layer_data(boundaries)
2002            .layer_aes(Aes::new().x("x").y("y").xend("xend").yend("yend"))
2003            .show_legend(false)
2004    }
2005
2006    pub fn geom_blank(self) -> Self {
2007        self.add_geom(GeomBlank)
2008    }
2009
2010    /// Add a geom configured explicitly by the caller: its colours win over
2011    /// the theme's `primary_color`.
2012    /// Run a `geom_*_with` builder for a shortcut's *default* geom, then
2013    /// clear the `explicit_style` mark it put on the layer, so the defaults
2014    /// still take theme ink/paper and `primary_color` like `geom_line()`
2015    /// does (#362).
2016    fn default_styled(self, build: impl FnOnce(Self) -> Self) -> Self {
2017        let i = self.layers.len();
2018        let mut plot = build(self);
2019        if let Some(layer) = plot.layers.get_mut(i) {
2020            layer.explicit_style = false;
2021        }
2022        plot
2023    }
2024
2025    fn add_geom_with(self, geom: impl Geom + 'static) -> Self {
2026        let mut plot = self.add_geom(geom);
2027        if let Some(layer) = plot.layers.last_mut() {
2028            layer.explicit_style = true;
2029        }
2030        plot
2031    }
2032
2033    fn add_geom(mut self, geom: impl Geom + 'static) -> Self {
2034        let stat = geom.default_stat();
2035        let position = geom.default_position();
2036        let params = geom.default_params();
2037        self.layers.push(Layer {
2038            data: None,
2039            mapping: Aes::default(),
2040            geom: Box::new(geom),
2041            stat,
2042            position,
2043            params,
2044            show_legend: None,
2045            explicit_style: false,
2046            tooltip_template: None,
2047            key_glyph: None,
2048            show_legend_aes: Vec::new(),
2049        });
2050        self
2051    }
2052
2053    // ─── Layer-level overrides ──────────────────────────────────
2054
2055    /// Override the stat for the most recently added layer.
2056    pub fn stat(mut self, stat: impl Stat + 'static) -> Self {
2057        if let Some(layer) = self.layers.last_mut() {
2058            layer.stat = Box::new(stat);
2059        }
2060        self
2061    }
2062
2063    /// Override the position for the most recently added layer.
2064    pub fn position(mut self, pos: impl Position + 'static) -> Self {
2065        if let Some(layer) = self.layers.last_mut() {
2066            layer.position = Box::new(pos);
2067        }
2068        self
2069    }
2070
2071    /// Override the data for the most recently added layer.
2072    pub fn layer_data(mut self, data: impl GGData) -> Self {
2073        if let Some(layer) = self.layers.last_mut() {
2074            layer.data = Some(data.into_dataframe());
2075        }
2076        self
2077    }
2078
2079    /// Override the aesthetic mapping for the most recently added layer.
2080    pub fn layer_aes(mut self, mapping: Aes) -> Self {
2081        self.warnings.extend(mapping.warnings.iter().cloned());
2082        if let Some(layer) = self.layers.last_mut() {
2083            layer.mapping = mapping;
2084        }
2085        self
2086    }
2087
2088    /// A `{column}` tooltip template for the most recently added layer
2089    /// (B2-5), replacing its default `<title>`. Each placeholder is filled
2090    /// from the *built* layer data (so it can reference a stat-computed
2091    /// column, e.g. a hex's `count`) at that row, formatted through the
2092    /// column's scale when it has one
2093    /// ([`Scale::format_value`](crate::scale::Scale::format_value)) — the same
2094    /// `data-fvalue` logic, but written into the tooltip text. A column
2095    /// that doesn't exist on this layer renders as empty, not literally
2096    /// `{column}`. `{{`/`}}` are literal braces. The result is escaped the
2097    /// same way any other tooltip is — this is plain text, not markup.
2098    pub fn tooltip(mut self, template: &str) -> Self {
2099        if let Some(layer) = self.layers.last_mut() {
2100            layer.tooltip_template = Some(template.to_string());
2101        }
2102        self
2103    }
2104
2105    /// Control whether the most recently added layer contributes to the legend.
2106    /// `true` = always show, `false` = always hide, default (None) = auto.
2107    pub fn show_legend(mut self, show: bool) -> Self {
2108        if let Some(layer) = self.layers.last_mut() {
2109            layer.show_legend = Some(show);
2110        }
2111        self
2112    }
2113
2114    /// Drop one aesthetic's legend (ggplot2's `guides(fill = "none")`, #409).
2115    pub fn guide_none(mut self, aes: crate::aes::Aesthetic) -> Self {
2116        if !self.guide_legend.hidden.contains(&aes) {
2117            self.guide_legend.hidden.push(aes);
2118        }
2119        self
2120    }
2121
2122    /// Title one aesthetic's legend (`guides(colour = guide_legend(title =
2123    /// "…"))` / `labs(colour = "…")`, #409).
2124    pub fn guide_title(mut self, aes: crate::aes::Aesthetic, title: &str) -> Self {
2125        self.guide_legend.titles.retain(|(a, _)| *a != aes);
2126        self.guide_legend.titles.push((aes, title.to_string()));
2127        self
2128    }
2129
2130    /// Options for a continuous colour / fill legend (ggplot2's
2131    /// `guides(fill = guide_colourbar(barwidth =, nbin =, display =, frame
2132    /// =, ticks =))`, or `guide_coloursteps()` with [`GuideColourbar::steps`];
2133    /// #409).
2134    ///
2135    /// [`GuideColourbar::steps`]: crate::guide::config::GuideColourbar::steps
2136    pub fn guide_colourbar(
2137        mut self,
2138        aes: crate::aes::Aesthetic,
2139        bar: crate::guide::config::GuideColourbar,
2140    ) -> Self {
2141        self.guide_legend.colourbars.retain(|(a, _)| *a != aes);
2142        self.guide_legend.colourbars.push((aes, bar));
2143        self
2144    }
2145
2146    /// Options for a binned scale's legend (ggplot2's `guides(size =
2147    /// guide_bins(show.limits =, axis =))`, #409 / #408). `guide_bins()` is
2148    /// already the default guide of the `scale_*_binned()` scales; this
2149    /// sets its options.
2150    pub fn guide_bins(
2151        mut self,
2152        aes: crate::aes::Aesthetic,
2153        bins: crate::guide::config::GuideBins,
2154    ) -> Self {
2155        self.guide_legend.plain_keys.retain(|a| *a != aes);
2156        self.guide_legend.bins.retain(|(a, _)| *a != aes);
2157        self.guide_legend.bins.push((aes, bins));
2158        self
2159    }
2160
2161    /// Draw one aesthetic's legend at its own side (ggplot2 3.5's
2162    /// `guides(colour = guide_legend(position = "bottom"))`, #409); the
2163    /// others stay at the theme's `legend_position`.
2164    pub fn guide_position(
2165        mut self,
2166        aes: crate::aes::Aesthetic,
2167        position: crate::theme::LegendPosition,
2168    ) -> Self {
2169        self.guide_legend.positions.retain(|(a, _)| *a != aes);
2170        self.guide_legend.positions.push((aes, position));
2171        self
2172    }
2173
2174    /// Add a legend entry drawn by your own code — ggplot2's
2175    /// `guide_custom()` (#409): see [`GuideCustom`](crate::guide::config::GuideCustom).
2176    pub fn guide_custom(mut self, guide: crate::guide::config::GuideCustom) -> Self {
2177        self.guide_legend.custom.push(guide);
2178        self
2179    }
2180
2181    /// Draw a binned scale's legend as plain keys, one labelled `lo–hi`
2182    /// per bin (`guides(size = "legend")`), instead of `guide_bins()`.
2183    pub fn guide_legend_keys(mut self, aes: crate::aes::Aesthetic) -> Self {
2184        self.guide_legend.bins.retain(|(a, _)| *a != aes);
2185        if !self.guide_legend.plain_keys.contains(&aes) {
2186            self.guide_legend.plain_keys.push(aes);
2187        }
2188        self
2189    }
2190
2191    /// ggplot2's `guide_coloursteps()` for `aes`: the colourbar drawn as one
2192    /// solid block per interval between breaks, evenly sized (#409).
2193    pub fn guide_coloursteps(self, aes: crate::aes::Aesthetic) -> Self {
2194        self.guide_colourbar(aes, crate::guide::config::GuideColourbar::steps())
2195    }
2196
2197    /// Reverse one aesthetic's legend keys (`guides(colour =
2198    /// guide_legend(reverse = TRUE))`, #409).
2199    pub fn guide_reverse(mut self, aes: crate::aes::Aesthetic, reverse: bool) -> Self {
2200        self.guide_legend.reversed.retain(|a| *a != aes);
2201        if reverse {
2202            self.guide_legend.reversed.push(aes);
2203        }
2204        self
2205    }
2206
2207    /// Draw one legend's keys with fixed values (ggplot2's `guides(colour =
2208    /// guide_legend(override.aes = list(size = 4, alpha = 1)))`, #409).
2209    pub fn guide_override(
2210        mut self,
2211        aes: crate::aes::Aesthetic,
2212        keys: crate::guide::config::KeyOverride,
2213    ) -> Self {
2214        self.guide_legend.overrides.retain(|(a, _)| *a != aes);
2215        self.guide_legend.overrides.push((aes, keys));
2216        self
2217    }
2218
2219    /// ggplot2's `guides(x = guide_axis_logticks())` (#409): log10 minor
2220    /// ticks on that axis (long at decades, mid at 5s, short otherwise).
2221    /// Call after `scale_x_log10()` / `scale_y_log10()`.
2222    pub fn guide_axis_logticks(mut self, aes: crate::aes::Aesthetic) -> Self {
2223        for s in self.scales.iter_mut().filter(|s| s.aesthetic() == aes) {
2224            s.set_log_ticks(true);
2225        }
2226        self
2227    }
2228
2229    /// Show or hide the most recent layer in one aesthetic's legend only
2230    /// (ggplot2's `show.legend = c(colour = TRUE, fill = FALSE)`; #410).
2231    pub fn show_legend_for(mut self, aes: crate::aes::Aesthetic, show: bool) -> Self {
2232        if let Some(layer) = self.layers.last_mut() {
2233            layer.show_legend_aes.retain(|(a, _)| *a != aes);
2234            layer.show_legend_aes.push((aes, show));
2235        }
2236        self
2237    }
2238
2239    /// Draw the most recent layer's legend keys as `glyph` (ggplot2's
2240    /// `key_glyph =`, e.g. `KeyGlyph::Timeseries` for a line; #410).
2241    pub fn key_glyph(mut self, glyph: crate::guide::key::KeyGlyph) -> Self {
2242        if let Some(layer) = self.layers.last_mut() {
2243            layer.key_glyph = Some(glyph);
2244        }
2245        self
2246    }
2247
2248    /// Mark the most recently added layer as purely decorative (#161): its
2249    /// drawn marks keep their own visual style but carry **no** per-mark
2250    /// metadata — no `<title>`, no `data-x`/`data-series`/`data-value`/
2251    /// `data-fvalue`, and no [`data_attr`](crate::aes::Aes::data_attr)
2252    /// attributes — so an SVG consumer that treats "has a non-empty
2253    /// `<title>`" as "is a selectable data point" doesn't pick it up. Meant
2254    /// for a layer that annotates another layer's mark rather than drawing
2255    /// data in its own right (e.g. a boxplot's own mean dot, drawn by a
2256    /// separate `geom_point` layer purely to mark the spot the box's own
2257    /// tooltip already names). Each mark also carries
2258    /// `data-part="decorative"` and `aria-hidden="true"` (`data-geom`
2259    /// still names the real geom), so a consumer can select decorative
2260    /// marks positively rather than only infer them from what they lack.
2261    /// Their class is `gg-mark gg-decorative`, not the geom's own
2262    /// (`gg-point`, `gg-line`, …).
2263    ///
2264    /// A `.tooltip(...)` template on a decorative layer has no visible
2265    /// effect — the geom's marks carry no tooltip regardless — and warns,
2266    /// like the existing has-no-effect warning for a geom that doesn't
2267    /// read tooltips at all.
2268    ///
2269    /// A decorative layer also defaults to hidden in the legend (it's
2270    /// annotating, not its own series), unless
2271    /// [`show_legend`](Self::show_legend)`(true)` is called — before or
2272    /// after `decorative()` — to opt back in; an explicit
2273    /// `show_legend(false)` is left untouched either way.
2274    pub fn decorative(mut self) -> Self {
2275        if let Some(layer) = self.layers.last_mut() {
2276            let geom = std::mem::replace(&mut layer.geom, Box::new(GeomBlank));
2277            layer.geom = Box::new(crate::geom::decorative::Decorative(geom));
2278            if layer.show_legend.is_none() {
2279                layer.show_legend = Some(false);
2280            }
2281        }
2282        self
2283    }
2284
2285    /// Add one SVG attribute to **every mark** of the most recently added
2286    /// layer (#221) — e.g. to make a label layer drawn over node boxes inert,
2287    /// so hover/click and screen readers pass through to the box below:
2288    ///
2289    /// ```
2290    /// # use ggplot_rs::{aes::Aes, data::Value, GGPlot};
2291    /// # let data = vec![("x".to_string(), vec![Value::Float(1.0)]),
2292    /// #   ("y".to_string(), vec![Value::Float(1.0)]),
2293    /// #   ("l".to_string(), vec![Value::Str("a".into())])];
2294    /// let svg = GGPlot::new(data)
2295    ///     .aes(Aes::new().x("x").y("y").label("l"))
2296    ///     .geom_text()
2297    ///     .layer_svg_attr("aria-hidden", "true")
2298    ///     .layer_svg_attr("pointer-events", "none")
2299    ///     .layer_svg_attr("class", "node-label")
2300    ///     .render_svg_native_with_size(200, 200)
2301    ///     .unwrap();
2302    /// assert!(svg.contains(r#"aria-hidden="true" pointer-events="none""#));
2303    /// ```
2304    ///
2305    /// The allow-list is deliberately small: `class` (space-separated
2306    /// tokens, each `[A-Za-z][A-Za-z0-9_-]*`, appended to the mark's own
2307    /// `gg-mark gg-<kind>` class; invalid tokens are dropped), `aria-hidden`
2308    /// (`"true"`/`"false"`), `pointer-events` (an SVG keyword such as
2309    /// `"none"`), and `data-*` (`Aes::data_attr`'s name rules; a row's own
2310    /// `data_attr` of the same name wins). Anything else — in particular
2311    /// `style` and `on*` handlers, so a strict CSP without `unsafe-inline`
2312    /// still holds — is ignored with a build warning, as is a bad value or a
2313    /// call before any layer. A later call with the same name replaces the
2314    /// earlier value. Values are escaped on output. For a per-row class use
2315    /// [`Aes::class`](crate::aes::Aes::class); to wrap several layers' marks
2316    /// per key in one `<g>`, [`Aes::group_wrap`](crate::aes::Aes::group_wrap).
2317    /// SVG backend only.
2318    pub fn layer_svg_attr(mut self, name: impl Into<String>, value: impl Into<String>) -> Self {
2319        let (name, value) = (name.into(), value.into());
2320        let Some(idx) = self.layers.len().checked_sub(1) else {
2321            self.warnings.push(format!(
2322                "layer_svg_attr(\"{name}\") called before any layer was added — ignored"
2323            ));
2324            return self;
2325        };
2326        if let Some((n, v)) =
2327            crate::render::svg_backend::validate_layer_svg_attr(&name, &value, &mut self.warnings)
2328        {
2329            self.layer_svg_attrs
2330                .retain(|(i, k, _)| !(*i == idx && *k == n));
2331            self.layer_svg_attrs.push((idx, n, v));
2332        }
2333        self
2334    }
2335
2336    /// [`layer_svg_attr`](Self::layer_svg_attr) for several attributes at once.
2337    pub fn layer_svg_attrs<N: Into<String>, V: Into<String>>(
2338        self,
2339        attrs: impl IntoIterator<Item = (N, V)>,
2340    ) -> Self {
2341        attrs
2342            .into_iter()
2343            .fold(self, |p, (n, v)| p.layer_svg_attr(n, v))
2344    }
2345
2346    // ─── Scales ──────────────────────────────────────────────────
2347
2348    pub fn scale_x_continuous(mut self, s: ScaleContinuous) -> Self {
2349        let s = s.for_aesthetic(crate::aes::Aesthetic::X);
2350        self.scales.push(Box::new(s));
2351        self
2352    }
2353
2354    pub fn scale_y_continuous(mut self, s: ScaleContinuous) -> Self {
2355        let s = s.for_aesthetic(crate::aes::Aesthetic::Y);
2356        self.scales.push(Box::new(s));
2357        self
2358    }
2359
2360    pub fn scale_x_discrete(mut self, s: crate::scale::discrete::ScaleDiscrete) -> Self {
2361        let s = s.for_aesthetic(crate::aes::Aesthetic::X);
2362        self.scales.push(Box::new(s));
2363        self
2364    }
2365
2366    pub fn scale_y_discrete(mut self, s: crate::scale::discrete::ScaleDiscrete) -> Self {
2367        let s = s.for_aesthetic(crate::aes::Aesthetic::Y);
2368        self.scales.push(Box::new(s));
2369        self
2370    }
2371
2372    pub fn scale_color(mut self, s: impl Scale + 'static) -> Self {
2373        self.scales.push(Box::new(s));
2374        self
2375    }
2376
2377    pub fn scale_fill(mut self, s: impl Scale + 'static) -> Self {
2378        self.scales.push(Box::new(s));
2379        self
2380    }
2381
2382    pub fn scale_color_manual(self, values: Vec<(&str, crate::scale::color::RGBAColor)>) -> Self {
2383        let s = crate::scale::manual::ScaleManual::new(crate::aes::Aesthetic::Color, values);
2384        self.scale_color(s)
2385    }
2386
2387    pub fn scale_fill_manual(self, values: Vec<(&str, crate::scale::color::RGBAColor)>) -> Self {
2388        let s = crate::scale::manual::ScaleManual::new(crate::aes::Aesthetic::Fill, values);
2389        self.scale_fill(s)
2390    }
2391
2392    /// The `colour`/`color` column already holds `#RRGGBB`/`#RGB` hex
2393    /// strings, used as-is — no legend (R's `scale_colour_identity`).
2394    pub fn scale_colour_identity(self) -> Self {
2395        let s = crate::scale::identity::ScaleColorIdentity::new(crate::aes::Aesthetic::Color);
2396        self.scale_color(s)
2397    }
2398
2399    /// American-spelling alias for [`scale_colour_identity`](Self::scale_colour_identity).
2400    pub fn scale_color_identity(self) -> Self {
2401        self.scale_colour_identity()
2402    }
2403
2404    /// The `fill` column already holds `#RRGGBB`/`#RGB` hex strings, used
2405    /// as-is — no legend (R's `scale_fill_identity`).
2406    pub fn scale_fill_identity(self) -> Self {
2407        let s = crate::scale::identity::ScaleColorIdentity::new(crate::aes::Aesthetic::Fill);
2408        self.scale_fill(s)
2409    }
2410
2411    pub fn scale_color_viridis(self) -> Self {
2412        use crate::scale::color::ScaleColorDiscrete;
2413        use crate::scale::palettes::PaletteName;
2414        let s = ScaleColorDiscrete::new(crate::aes::Aesthetic::Color)
2415            .with_named_palette(&PaletteName::Viridis);
2416        self.scale_color(s)
2417    }
2418
2419    pub fn scale_color_brewer(self, name: crate::scale::palettes::PaletteName) -> Self {
2420        use crate::scale::color::ScaleColorDiscrete;
2421        let s = ScaleColorDiscrete::new(crate::aes::Aesthetic::Color).with_named_palette(&name);
2422        self.scale_color(s)
2423    }
2424
2425    pub fn scale_color_gradient(
2426        self,
2427        low: crate::scale::color::RGBAColor,
2428        high: crate::scale::color::RGBAColor,
2429    ) -> Self {
2430        use crate::scale::color::ScaleColorContinuous;
2431        let s = ScaleColorContinuous::new(crate::aes::Aesthetic::Color).with_colors(low, high);
2432        self.scale_color(s)
2433    }
2434
2435    pub fn scale_fill_gradient(
2436        self,
2437        low: crate::scale::color::RGBAColor,
2438        high: crate::scale::color::RGBAColor,
2439    ) -> Self {
2440        use crate::scale::color::ScaleColorContinuous;
2441        let s = ScaleColorContinuous::new(crate::aes::Aesthetic::Fill).with_colors(low, high);
2442        self.scale_fill(s)
2443    }
2444
2445    pub fn scale_color_gradient2(
2446        self,
2447        low: crate::scale::color::RGBAColor,
2448        mid: crate::scale::color::RGBAColor,
2449        high: crate::scale::color::RGBAColor,
2450    ) -> Self {
2451        use crate::scale::gradient::ScaleColorGradient2;
2452        let s = ScaleColorGradient2::new(crate::aes::Aesthetic::Color).with_colors(low, mid, high);
2453        self.scale_color(s)
2454    }
2455
2456    pub fn scale_fill_gradient2(
2457        self,
2458        low: crate::scale::color::RGBAColor,
2459        mid: crate::scale::color::RGBAColor,
2460        high: crate::scale::color::RGBAColor,
2461    ) -> Self {
2462        use crate::scale::gradient::ScaleColorGradient2;
2463        let s = ScaleColorGradient2::new(crate::aes::Aesthetic::Fill).with_colors(low, mid, high);
2464        self.scale_fill(s)
2465    }
2466
2467    pub fn scale_fill_viridis(self) -> Self {
2468        use crate::scale::color::ScaleColorDiscrete;
2469        use crate::scale::palettes::PaletteName;
2470        let s = ScaleColorDiscrete::new(crate::aes::Aesthetic::Fill)
2471            .with_named_palette(&PaletteName::Viridis);
2472        self.scale_fill(s)
2473    }
2474
2475    /// Continuous viridis color scale (for numeric data).
2476    pub fn scale_color_viridis_c(self) -> Self {
2477        use crate::scale::gradient_n::ScaleColorGradientN;
2478        let s = ScaleColorGradientN::viridis(crate::aes::Aesthetic::Color);
2479        self.scale_color(s)
2480    }
2481
2482    /// Continuous viridis fill scale (for numeric data).
2483    pub fn scale_fill_viridis_c(self) -> Self {
2484        use crate::scale::gradient_n::ScaleColorGradientN;
2485        let s = ScaleColorGradientN::viridis(crate::aes::Aesthetic::Fill);
2486        self.scale_fill(s)
2487    }
2488
2489    /// N-stop continuous color gradient.
2490    pub fn scale_color_gradientn(self, stops: Vec<(f64, crate::scale::color::RGBAColor)>) -> Self {
2491        use crate::scale::gradient_n::ScaleColorGradientN;
2492        let s = ScaleColorGradientN::new(crate::aes::Aesthetic::Color, stops);
2493        self.scale_color(s)
2494    }
2495
2496    /// N-stop continuous fill gradient.
2497    pub fn scale_fill_gradientn(self, stops: Vec<(f64, crate::scale::color::RGBAColor)>) -> Self {
2498        use crate::scale::gradient_n::ScaleColorGradientN;
2499        let s = ScaleColorGradientN::new(crate::aes::Aesthetic::Fill, stops);
2500        self.scale_fill(s)
2501    }
2502
2503    /// Binned (stepped) two-colour continuous colour scale — buckets the mapped
2504    /// variable into `n_bins` bins, each a discrete colour, with a stepped legend.
2505    pub fn scale_color_steps(
2506        self,
2507        low: crate::scale::color::RGBAColor,
2508        high: crate::scale::color::RGBAColor,
2509        n_bins: usize,
2510    ) -> Self {
2511        let s = crate::scale::steps::ScaleColorSteps::two(
2512            crate::aes::Aesthetic::Color,
2513            (low.r, low.g, low.b),
2514            (high.r, high.g, high.b),
2515            n_bins,
2516        );
2517        self.scale_color(s)
2518    }
2519
2520    /// Binned N-stop continuous colour scale.
2521    pub fn scale_color_stepsn(
2522        self,
2523        stops: Vec<crate::scale::color::RGBAColor>,
2524        n_bins: usize,
2525    ) -> Self {
2526        let s = crate::scale::steps::ScaleColorSteps::new(
2527            crate::aes::Aesthetic::Color,
2528            stops.iter().map(|c| (c.r, c.g, c.b)).collect(),
2529            n_bins,
2530        );
2531        self.scale_color(s)
2532    }
2533
2534    /// Binned ColorBrewer colour scale (R's `scale_color_fermenter`).
2535    pub fn scale_color_fermenter(
2536        self,
2537        name: crate::scale::palettes::PaletteName,
2538        n_bins: usize,
2539    ) -> Self {
2540        let stops = crate::scale::palettes::palette(&name)
2541            .iter()
2542            .map(|c| (c.r, c.g, c.b))
2543            .collect();
2544        let s =
2545            crate::scale::steps::ScaleColorSteps::new(crate::aes::Aesthetic::Color, stops, n_bins);
2546        self.scale_color(s)
2547    }
2548
2549    /// Binned (stepped) two-colour continuous fill scale.
2550    /// `scale_colour_steps2(low, mid, high, midpoint)`: diverging binned
2551    /// colour scale (#408).
2552    pub fn scale_color_steps2(
2553        self,
2554        low: crate::scale::color::RGBAColor,
2555        mid: crate::scale::color::RGBAColor,
2556        high: crate::scale::color::RGBAColor,
2557        midpoint: f64,
2558        n_bins: usize,
2559    ) -> Self {
2560        self.scale_color(crate::scale::steps::ScaleColorSteps::diverging(
2561            crate::aes::Aesthetic::Color,
2562            (low.r, low.g, low.b),
2563            (mid.r, mid.g, mid.b),
2564            (high.r, high.g, high.b),
2565            midpoint,
2566            n_bins,
2567        ))
2568    }
2569
2570    /// `scale_fill_steps2(low, mid, high, midpoint)` — see
2571    /// [`scale_color_steps2`](Self::scale_color_steps2).
2572    pub fn scale_fill_steps2(
2573        self,
2574        low: crate::scale::color::RGBAColor,
2575        mid: crate::scale::color::RGBAColor,
2576        high: crate::scale::color::RGBAColor,
2577        midpoint: f64,
2578        n_bins: usize,
2579    ) -> Self {
2580        self.scale_fill(crate::scale::steps::ScaleColorSteps::diverging(
2581            crate::aes::Aesthetic::Fill,
2582            (low.r, low.g, low.b),
2583            (mid.r, mid.g, mid.b),
2584            (high.r, high.g, high.b),
2585            midpoint,
2586            n_bins,
2587        ))
2588    }
2589
2590    /// `scale_colour_viridis_b(option)`: binned viridis, `option` 'A'–'H'
2591    /// (#408).
2592    pub fn scale_color_viridis_b(self, option: char, n_bins: usize) -> Self {
2593        self.scale_color(crate::scale::steps::ScaleColorSteps::viridis(
2594            crate::aes::Aesthetic::Color,
2595            option,
2596            n_bins,
2597        ))
2598    }
2599
2600    /// `scale_fill_viridis_b(option)` — see
2601    /// [`scale_color_viridis_b`](Self::scale_color_viridis_b).
2602    pub fn scale_fill_viridis_b(self, option: char, n_bins: usize) -> Self {
2603        self.scale_fill(crate::scale::steps::ScaleColorSteps::viridis(
2604            crate::aes::Aesthetic::Fill,
2605            option,
2606            n_bins,
2607        ))
2608    }
2609
2610    pub fn scale_fill_steps(
2611        self,
2612        low: crate::scale::color::RGBAColor,
2613        high: crate::scale::color::RGBAColor,
2614        n_bins: usize,
2615    ) -> Self {
2616        let s = crate::scale::steps::ScaleColorSteps::two(
2617            crate::aes::Aesthetic::Fill,
2618            (low.r, low.g, low.b),
2619            (high.r, high.g, high.b),
2620            n_bins,
2621        );
2622        self.scale_fill(s)
2623    }
2624
2625    /// Binned fill scale with *explicit* class breaks (`n + 1` increasing
2626    /// edges, `n` colours interpolated `low`→`high`) — R's
2627    /// `scale_fill_steps(breaks = ...)`, backing a `::CLASSES` choropleth
2628    /// (sipemu/ggplot-rs#180). The legend shows each class's trained count
2629    /// (`"10–50 (12)"`) and, when any value is `Value::Na`, a "no data" key
2630    /// in `na_color` (default grey50) — see
2631    /// [`ScaleFillBinned`](crate::scale::binned::ScaleFillBinned) for the
2632    /// `.na_color(..)`/`.na_label(..)` builders. The break maths itself
2633    /// (quantile/Jenks/equal-width/custom edges) is the caller's job; this
2634    /// only bins against the edges it's given.
2635    pub fn scale_fill_binned(
2636        self,
2637        breaks: Vec<f64>,
2638        low: crate::scale::color::RGBAColor,
2639        high: crate::scale::color::RGBAColor,
2640    ) -> Self {
2641        let s = crate::scale::binned::ScaleFillBinned::two(
2642            crate::aes::Aesthetic::Fill,
2643            breaks,
2644            (low.r, low.g, low.b),
2645            (high.r, high.g, high.b),
2646        );
2647        self.scale_fill(s)
2648    }
2649
2650    /// Like [`scale_fill_binned`](Self::scale_fill_binned), with an
2651    /// explicit colour per bin (`colors.len()` should be
2652    /// `breaks.len() - 1`) instead of a two-colour interpolation.
2653    pub fn scale_fill_binned_with(
2654        self,
2655        breaks: Vec<f64>,
2656        colors: Vec<crate::scale::color::RGBAColor>,
2657    ) -> Self {
2658        let s = crate::scale::binned::ScaleFillBinned::new(
2659            crate::aes::Aesthetic::Fill,
2660            breaks,
2661            colors.iter().map(|c| (c.r, c.g, c.b)).collect(),
2662        );
2663        self.scale_fill(s)
2664    }
2665
2666    /// Binned ColorBrewer fill scale.
2667    pub fn scale_fill_fermenter(
2668        self,
2669        name: crate::scale::palettes::PaletteName,
2670        n_bins: usize,
2671    ) -> Self {
2672        let stops = crate::scale::palettes::palette(&name)
2673            .iter()
2674            .map(|c| (c.r, c.g, c.b))
2675            .collect();
2676        let s =
2677            crate::scale::steps::ScaleColorSteps::new(crate::aes::Aesthetic::Fill, stops, n_bins);
2678        self.scale_fill(s)
2679    }
2680
2681    /// Default discrete fill palette with levels in sorted order, so a level
2682    /// keeps its colour across charts regardless of data order. For a custom
2683    /// palette: `scale_fill(ScaleColorDiscrete::new(Aesthetic::Fill)
2684    /// .with_palette(p).sorted())`.
2685    pub fn scale_fill_discrete_sorted(self) -> Self {
2686        use crate::scale::color::ScaleColorDiscrete;
2687        self.scale_fill(ScaleColorDiscrete::new(crate::aes::Aesthetic::Fill).sorted())
2688    }
2689
2690    /// Default discrete colour palette with levels in sorted order (see
2691    /// [`scale_fill_discrete_sorted`](Self::scale_fill_discrete_sorted)).
2692    pub fn scale_color_discrete_sorted(self) -> Self {
2693        use crate::scale::color::ScaleColorDiscrete;
2694        self.scale_color(ScaleColorDiscrete::new(crate::aes::Aesthetic::Color).sorted())
2695    }
2696
2697    /// ggplot2's `scale_colour_hue(h, c, l, h.start, direction)`: hues evenly
2698    /// spaced for the number of levels — `HuePalette::default()` gives
2699    /// exactly ggplot2's default colours for any number of groups (#408).
2700    pub fn scale_color_hue(self, p: crate::scale::color::HuePalette) -> Self {
2701        self.scale_color(
2702            crate::scale::color::ScaleColorDiscrete::new(crate::aes::Aesthetic::Color)
2703                .with_generated(crate::scale::color::GeneratedPalette::Hue(p)),
2704        )
2705    }
2706
2707    /// `scale_fill_hue(...)` — see [`scale_color_hue`](Self::scale_color_hue).
2708    pub fn scale_fill_hue(self, p: crate::scale::color::HuePalette) -> Self {
2709        self.scale_fill(
2710            crate::scale::color::ScaleColorDiscrete::new(crate::aes::Aesthetic::Fill)
2711                .with_generated(crate::scale::color::GeneratedPalette::Hue(p)),
2712        )
2713    }
2714
2715    /// `scale_colour_viridis_d(option, begin, end, direction)` for a
2716    /// categorical mapping: `option` 'A' magma … 'D' viridis … 'H' turbo.
2717    pub fn scale_color_viridis_d(self, option: char, begin: f64, end: f64, direction: i32) -> Self {
2718        self.scale_color(
2719            crate::scale::color::ScaleColorDiscrete::new(crate::aes::Aesthetic::Color)
2720                .with_generated(crate::scale::color::GeneratedPalette::Viridis {
2721                    option,
2722                    begin,
2723                    end,
2724                    direction,
2725                }),
2726        )
2727    }
2728
2729    /// `scale_fill_viridis_d(...)` — see [`scale_color_viridis_d`](Self::scale_color_viridis_d).
2730    pub fn scale_fill_viridis_d(self, option: char, begin: f64, end: f64, direction: i32) -> Self {
2731        self.scale_fill(
2732            crate::scale::color::ScaleColorDiscrete::new(crate::aes::Aesthetic::Fill)
2733                .with_generated(crate::scale::color::GeneratedPalette::Viridis {
2734                    option,
2735                    begin,
2736                    end,
2737                    direction,
2738                }),
2739        )
2740    }
2741
2742    /// `scale_colour_distiller(palette, direction)`: a Brewer palette
2743    /// interpolated as a continuous gradient (ggplot2's default `direction
2744    /// = -1` puts the darkest colour at the low end — pass `-1` for that).
2745    pub fn scale_color_distiller(
2746        self,
2747        palette: crate::scale::palettes::PaletteName,
2748        direction: i32,
2749    ) -> Self {
2750        self.scale_color(distiller(crate::aes::Aesthetic::Color, &palette, direction))
2751    }
2752
2753    /// `scale_fill_distiller(...)` — see [`scale_color_distiller`](Self::scale_color_distiller).
2754    pub fn scale_fill_distiller(
2755        self,
2756        palette: crate::scale::palettes::PaletteName,
2757        direction: i32,
2758    ) -> Self {
2759        self.scale_fill(distiller(crate::aes::Aesthetic::Fill, &palette, direction))
2760    }
2761
2762    /// Force values into the x / y scales' ranges (ggplot2's
2763    /// `expand_limits(x = …, y = …)`), e.g. `expand_limits(&[0.0], &[])` to
2764    /// include 0. Adds an invisible `geom_blank` layer.
2765    pub fn expand_limits(self, x: &[f64], y: &[f64]) -> Self {
2766        let n = x.len().max(y.len());
2767        if n == 0 {
2768            return self;
2769        }
2770        let col =
2771            |v: &[f64]| -> Vec<Value> { (0..n).map(|i| Value::Float(v[i % v.len()])).collect() };
2772        let mut data = Vec::new();
2773        let mut aes = Aes::new();
2774        if !x.is_empty() {
2775            data.push(("x".to_string(), col(x)));
2776            aes = aes.x("x");
2777        }
2778        if !y.is_empty() {
2779            data.push(("y".to_string(), col(y)));
2780            aes = aes.y("y");
2781        }
2782        self.geom_blank().layer_data(data).layer_aes(aes)
2783    }
2784
2785    pub fn scale_fill_brewer(self, name: crate::scale::palettes::PaletteName) -> Self {
2786        use crate::scale::color::ScaleColorDiscrete;
2787        let s = ScaleColorDiscrete::new(crate::aes::Aesthetic::Fill).with_named_palette(&name);
2788        self.scale_fill(s)
2789    }
2790
2791    pub fn scale_linetype_manual(
2792        self,
2793        values: Vec<(&str, crate::render::backend::Linetype)>,
2794    ) -> Self {
2795        let s = crate::scale::linetype_manual::ScaleLinetypeManual::new(values);
2796        self.scale_color(s)
2797    }
2798
2799    pub fn scale_shape_manual(
2800        self,
2801        values: Vec<(&str, crate::render::backend::PointShape)>,
2802    ) -> Self {
2803        let s = crate::scale::shape_manual::ScaleShapeManual::new(values);
2804        self.scale_color(s)
2805    }
2806
2807    pub fn scale_color_grey(self) -> Self {
2808        let s = crate::scale::grey::ScaleColorGrey::new(crate::aes::Aesthetic::Color);
2809        self.scale_color(s)
2810    }
2811
2812    pub fn scale_fill_grey(self) -> Self {
2813        let s = crate::scale::grey::ScaleColorGrey::new(crate::aes::Aesthetic::Fill);
2814        self.scale_fill(s)
2815    }
2816
2817    pub fn scale_color_grey_with(self, s: crate::scale::grey::ScaleColorGrey) -> Self {
2818        self.scale_color(s)
2819    }
2820
2821    pub fn scale_fill_grey_with(self, s: crate::scale::grey::ScaleColorGrey) -> Self {
2822        self.scale_fill(s)
2823    }
2824
2825    pub fn scale_x_reverse(self) -> Self {
2826        self.scale_x_continuous(ScaleContinuous::new().with_transform(ScaleTransform::Reverse))
2827    }
2828
2829    pub fn scale_y_reverse(self) -> Self {
2830        self.scale_y_continuous(ScaleContinuous::new().with_transform(ScaleTransform::Reverse))
2831    }
2832
2833    pub fn scale_x_datetime(mut self, s: crate::scale::datetime::ScaleDateTime) -> Self {
2834        let s = s.for_aesthetic(crate::aes::Aesthetic::X);
2835        self.scales.push(Box::new(s));
2836        self
2837    }
2838
2839    pub fn scale_y_datetime(mut self, s: crate::scale::datetime::ScaleDateTime) -> Self {
2840        let s = s.for_aesthetic(crate::aes::Aesthetic::Y);
2841        self.scales.push(Box::new(s));
2842        self
2843    }
2844
2845    pub fn scale_size(mut self, s: crate::scale::size::ScaleSizeContinuous) -> Self {
2846        self.scales.push(Box::new(s));
2847        self
2848    }
2849
2850    /// `scale_size_area(max_size)` (ggplot2, B3-6): size scale where
2851    /// *area*, not radius, is proportional to the value, and 0 maps to 0.
2852    /// See [`ScaleSizeContinuous::area`](crate::scale::size::ScaleSizeContinuous::area).
2853    pub fn scale_size_area(mut self, max_size: f64) -> Self {
2854        self.scales.push(Box::new(
2855            crate::scale::size::ScaleSizeContinuous::new().area(max_size),
2856        ));
2857        self
2858    }
2859
2860    /// `scale_radius(range)` (ggplot2): point *radius*, not area,
2861    /// proportional to the value — ggplot-rs's default size mapping (#408).
2862    pub fn scale_radius(self, min: f64, max: f64) -> Self {
2863        self.scale_size(crate::scale::size::ScaleSizeContinuous::new().with_range(min, max))
2864    }
2865
2866    /// ggplot2's `scale_size_binned(n.breaks = n)` (#408): sizes by bin.
2867    pub fn scale_size_binned(self, n: usize) -> Self {
2868        self.scale_color(crate::scale::values::ScaleValueBinned::new(
2869            crate::aes::Aesthetic::Size,
2870            n,
2871        ))
2872    }
2873
2874    /// ggplot2's `scale_alpha_binned()` (#408).
2875    pub fn scale_alpha_binned(self, n: usize) -> Self {
2876        self.scale_color(crate::scale::values::ScaleValueBinned::new(
2877            crate::aes::Aesthetic::Alpha,
2878            n,
2879        ))
2880    }
2881
2882    /// ggplot2's `scale_linewidth_binned()` (#408).
2883    pub fn scale_linewidth_binned(self, n: usize) -> Self {
2884        self.scale_color(crate::scale::values::ScaleValueBinned::new(
2885            crate::aes::Aesthetic::Linewidth,
2886            n,
2887        ))
2888    }
2889
2890    /// ggplot2's `scale_shape_binned()` (#408): one shape per bin.
2891    pub fn scale_shape_binned(self, n: usize) -> Self {
2892        self.scale_color(crate::scale::values::ScaleValueBinned::new(
2893            crate::aes::Aesthetic::Shape,
2894            n,
2895        ))
2896    }
2897
2898    /// ggplot2's `scale_linetype_binned()` (#408): one linetype per bin.
2899    pub fn scale_linetype_binned(self, n: usize) -> Self {
2900        self.scale_color(crate::scale::values::ScaleValueBinned::new(
2901            crate::aes::Aesthetic::Linetype,
2902            n,
2903        ))
2904    }
2905
2906    /// `scale_size_manual(values)`: named levels → point sizes (#408).
2907    pub fn scale_size_manual(self, values: Vec<(&str, f64)>) -> Self {
2908        self.scale_color(crate::scale::values::ScaleValueManual::new(
2909            crate::aes::Aesthetic::Size,
2910            values,
2911        ))
2912    }
2913
2914    /// `scale_alpha_manual(values)`: named levels → opacities (#408).
2915    pub fn scale_alpha_manual(self, values: Vec<(&str, f64)>) -> Self {
2916        self.scale_color(crate::scale::values::ScaleValueManual::new(
2917            crate::aes::Aesthetic::Alpha,
2918            values,
2919        ))
2920    }
2921
2922    /// `scale_linewidth_manual(values)`: named levels → line widths (#408).
2923    pub fn scale_linewidth_manual(self, values: Vec<(&str, f64)>) -> Self {
2924        self.scale_color(crate::scale::values::ScaleValueManual::new(
2925            crate::aes::Aesthetic::Linewidth,
2926            values,
2927        ))
2928    }
2929
2930    /// `scale_size_identity()`: the column already holds point sizes (#408).
2931    pub fn scale_size_identity(self) -> Self {
2932        self.scale_color(crate::scale::values::ScaleValueIdentity::new(
2933            crate::aes::Aesthetic::Size,
2934        ))
2935    }
2936
2937    /// `scale_alpha_identity()`: the column already holds opacities (#408).
2938    pub fn scale_alpha_identity(self) -> Self {
2939        self.scale_color(crate::scale::values::ScaleValueIdentity::new(
2940            crate::aes::Aesthetic::Alpha,
2941        ))
2942    }
2943
2944    /// `scale_linewidth_identity()`: the column already holds line widths
2945    /// (#408).
2946    pub fn scale_linewidth_identity(self) -> Self {
2947        self.scale_color(crate::scale::values::ScaleValueIdentity::new(
2948            crate::aes::Aesthetic::Linewidth,
2949        ))
2950    }
2951
2952    /// `scale_shape_identity()`: the column holds ggplot2 `pch` codes or
2953    /// shape names (`"triangle"`), drawn as the nearest shape (#408).
2954    pub fn scale_shape_identity(self) -> Self {
2955        self.scale_color(crate::scale::values::ScaleValueIdentity::new(
2956            crate::aes::Aesthetic::Shape,
2957        ))
2958    }
2959
2960    pub fn scale_alpha(mut self, s: crate::scale::alpha::ScaleAlphaContinuous) -> Self {
2961        self.scales.push(Box::new(s));
2962        self
2963    }
2964
2965    /// Continuous linewidth scale for the `linewidth` aesthetic (segment,
2966    /// curve, path, line) — see
2967    /// [`ScaleLinewidthContinuous`](crate::scale::linewidth::ScaleLinewidthContinuous).
2968    pub fn scale_linewidth(mut self, s: crate::scale::linewidth::ScaleLinewidthContinuous) -> Self {
2969        self.scales.push(Box::new(s));
2970        self
2971    }
2972
2973    pub fn xlim(self, min: f64, max: f64) -> Self {
2974        self.scale_x_continuous(ScaleContinuous::new().with_limits(min, max))
2975    }
2976
2977    pub fn ylim(self, min: f64, max: f64) -> Self {
2978        self.scale_y_continuous(ScaleContinuous::new().with_limits(min, max))
2979    }
2980
2981    pub fn scale_x_log10(self) -> Self {
2982        self.scale_x_continuous(ScaleContinuous::new().with_transform(ScaleTransform::Log10))
2983    }
2984
2985    pub fn scale_y_log10(self) -> Self {
2986        self.scale_y_continuous(ScaleContinuous::new().with_transform(ScaleTransform::Log10))
2987    }
2988
2989    pub fn scale_x_sqrt(self) -> Self {
2990        self.scale_x_continuous(ScaleContinuous::new().with_transform(ScaleTransform::Sqrt))
2991    }
2992
2993    pub fn scale_y_sqrt(self) -> Self {
2994        self.scale_y_continuous(ScaleContinuous::new().with_transform(ScaleTransform::Sqrt))
2995    }
2996
2997    pub fn scale_x_log2(self) -> Self {
2998        self.scale_x_continuous(ScaleContinuous::new().with_transform(ScaleTransform::Log2))
2999    }
3000
3001    pub fn scale_y_log2(self) -> Self {
3002        self.scale_y_continuous(ScaleContinuous::new().with_transform(ScaleTransform::Log2))
3003    }
3004
3005    pub fn scale_x_ln(self) -> Self {
3006        self.scale_x_continuous(ScaleContinuous::new().with_transform(ScaleTransform::Ln))
3007    }
3008
3009    pub fn scale_y_ln(self) -> Self {
3010        self.scale_y_continuous(ScaleContinuous::new().with_transform(ScaleTransform::Ln))
3011    }
3012
3013    /// Logit-transformed x axis (for proportions in (0, 1)).
3014    pub fn scale_x_logit(self) -> Self {
3015        self.scale_x_continuous(ScaleContinuous::new().with_transform(ScaleTransform::Logit))
3016    }
3017
3018    pub fn scale_y_logit(self) -> Self {
3019        self.scale_y_continuous(ScaleContinuous::new().with_transform(ScaleTransform::Logit))
3020    }
3021
3022    /// Probit-transformed x axis (inverse normal CDF, for proportions in (0, 1)).
3023    pub fn scale_x_probit(self) -> Self {
3024        self.scale_x_continuous(ScaleContinuous::new().with_transform(ScaleTransform::Probit))
3025    }
3026
3027    pub fn scale_y_probit(self) -> Self {
3028        self.scale_y_continuous(ScaleContinuous::new().with_transform(ScaleTransform::Probit))
3029    }
3030
3031    /// Sign-preserving pseudo-log x axis (handles zero and negative values).
3032    pub fn scale_x_pseudo_log(self) -> Self {
3033        self.scale_x_continuous(ScaleContinuous::new().with_transform(ScaleTransform::PseudoLog))
3034    }
3035
3036    pub fn scale_y_pseudo_log(self) -> Self {
3037        self.scale_y_continuous(ScaleContinuous::new().with_transform(ScaleTransform::PseudoLog))
3038    }
3039
3040    /// Reciprocal (1/x) x axis.
3041    pub fn scale_x_reciprocal(self) -> Self {
3042        self.scale_x_continuous(ScaleContinuous::new().with_transform(ScaleTransform::Reciprocal))
3043    }
3044
3045    pub fn scale_y_reciprocal(self) -> Self {
3046        self.scale_y_continuous(ScaleContinuous::new().with_transform(ScaleTransform::Reciprocal))
3047    }
3048
3049    /// Exponential x axis (labels spaced logarithmically).
3050    pub fn scale_x_exp(self) -> Self {
3051        self.scale_x_continuous(ScaleContinuous::new().with_transform(ScaleTransform::Exp))
3052    }
3053
3054    pub fn scale_y_exp(self) -> Self {
3055        self.scale_y_continuous(ScaleContinuous::new().with_transform(ScaleTransform::Exp))
3056    }
3057
3058    /// Box–Cox x axis with the given lambda (x > 0).
3059    /// ggplot2's `scale_x_time()`: seconds of day on x, `HH:MM:SS` labels at
3060    /// round time steps (#408).
3061    pub fn scale_x_time(self) -> Self {
3062        self.scale_x_continuous(ScaleContinuous::time())
3063    }
3064
3065    /// ggplot2's `scale_y_time()` — see [`scale_x_time`](Self::scale_x_time).
3066    pub fn scale_y_time(self) -> Self {
3067        self.scale_y_continuous(ScaleContinuous::time())
3068    }
3069
3070    /// ggplot2's `scale_x_binned(n.breaks = n)` (#408): x cut into round
3071    /// bins between about `n` breaks, each value at its bin's midpoint before the stat —
3072    /// `geom_bar()` then draws a histogram, the axis breaks are the edges.
3073    pub fn scale_x_binned(self, n: usize) -> Self {
3074        self.scale_x_continuous(ScaleContinuous::binned(n))
3075    }
3076
3077    /// ggplot2's `scale_y_binned()` — see [`scale_x_binned`](Self::scale_x_binned).
3078    pub fn scale_y_binned(self, n: usize) -> Self {
3079        self.scale_y_continuous(ScaleContinuous::binned(n))
3080    }
3081
3082    pub fn scale_x_boxcox(self, lambda: f64) -> Self {
3083        self.scale_x_continuous(
3084            ScaleContinuous::new().with_transform(ScaleTransform::BoxCox(lambda)),
3085        )
3086    }
3087
3088    pub fn scale_y_boxcox(self, lambda: f64) -> Self {
3089        self.scale_y_continuous(
3090            ScaleContinuous::new().with_transform(ScaleTransform::BoxCox(lambda)),
3091        )
3092    }
3093
3094    // ─── Faceting ─────────────────────────────────────────────────
3095
3096    pub fn facet_wrap(mut self, var: &str, ncol: Option<usize>) -> Self {
3097        self.facet = Facet::Wrap {
3098            var: var.to_string(),
3099            ncol,
3100            scales: FacetScales::Fixed,
3101            labeller: FacetLabeller::default(),
3102            levels: None,
3103            vertical: false,
3104            as_table: true,
3105            strip_position: crate::facet::StripPosition::Top,
3106            space: FacetSpace::Fixed,
3107        };
3108        self
3109    }
3110
3111    pub fn facet_wrap_free(mut self, var: &str, ncol: Option<usize>, scales: FacetScales) -> Self {
3112        self.facet = Facet::Wrap {
3113            var: var.to_string(),
3114            ncol,
3115            scales,
3116            labeller: FacetLabeller::default(),
3117            levels: None,
3118            vertical: false,
3119            as_table: true,
3120            strip_position: crate::facet::StripPosition::Top,
3121            space: FacetSpace::Fixed,
3122        };
3123        self
3124    }
3125
3126    pub fn facet_wrap_labeller(
3127        mut self,
3128        var: &str,
3129        ncol: Option<usize>,
3130        labeller: FacetLabeller,
3131    ) -> Self {
3132        self.facet = Facet::Wrap {
3133            var: var.to_string(),
3134            ncol,
3135            scales: FacetScales::Fixed,
3136            labeller,
3137            levels: None,
3138            vertical: false,
3139            as_table: true,
3140            strip_position: crate::facet::StripPosition::Top,
3141            space: FacetSpace::Fixed,
3142        };
3143        self
3144    }
3145
3146    /// Fill `facet_wrap` panels column by column (`vertical`, ggplot2's
3147    /// `dir = "v"`) and / or bottom-up (`as_table = false`, ggplot2's
3148    /// `as.table = FALSE`). Call after `facet_wrap*` (#412).
3149    pub fn facet_wrap_dir(mut self, vertical: bool, as_table: bool) -> Self {
3150        if let Facet::Wrap {
3151            vertical: v,
3152            as_table: a,
3153            ..
3154        } = &mut self.facet
3155        {
3156            *v = vertical;
3157            *a = as_table;
3158        }
3159        self
3160    }
3161
3162    /// Draw axes on every facet panel, not just the outer ones (ggplot2's
3163    /// `facet_*(axes = "all" | "all_x" | "all_y", axis.labels = …)`; #412).
3164    pub fn facet_axes(mut self, axes: crate::facet::FacetAxes) -> Self {
3165        self.facet_axes = axes;
3166        self
3167    }
3168
3169    /// Set the strip labeller of the current `facet_wrap` / `facet_grid`
3170    /// (ggplot2's `labeller =`, #412), e.g. `FacetLabeller::Template("n =
3171    /// {value}".into())` or `FacetLabeller::per_var(..)`.
3172    pub fn facet_labeller(mut self, l: FacetLabeller) -> Self {
3173        match &mut self.facet {
3174            Facet::Wrap { labeller, .. } | Facet::Grid { labeller, .. } => *labeller = l,
3175            Facet::None => {}
3176        }
3177        self
3178    }
3179
3180    /// ggplot2 3.5's `facet_wrap(space = "free_x" | "free_y")` (#412):
3181    /// panel widths follow their x range in a single-row wrap (heights their
3182    /// y range in a single column); pair with free scales.
3183    pub fn facet_wrap_space(mut self, space: FacetSpace) -> Self {
3184        if let Facet::Wrap { space: s, .. } = &mut self.facet {
3185            *s = space;
3186        }
3187        self
3188    }
3189
3190    /// Put each `facet_wrap` panel's strip on another side (ggplot2's
3191    /// `strip.position = "bottom" | "left" | "right"`). Call after
3192    /// `facet_wrap*` (#412).
3193    pub fn facet_strip_position(mut self, position: crate::facet::StripPosition) -> Self {
3194        if let Facet::Wrap { strip_position, .. } = &mut self.facet {
3195            *strip_position = position;
3196        }
3197        self
3198    }
3199
3200    /// Fix a `facet_grid`'s row / column order and keep empty ones
3201    /// (ggplot2's factor levels with `drop = FALSE`, #412): `None` keeps the
3202    /// data's own levels for that direction. Call after `facet_grid*`.
3203    pub fn facet_grid_levels<S: AsRef<str>>(
3204        mut self,
3205        rows: Option<&[S]>,
3206        cols: Option<&[S]>,
3207    ) -> Self {
3208        let dedup = |l: &[S]| {
3209            let mut seen = Vec::<String>::new();
3210            for s in l {
3211                if !seen.iter().any(|x| x == s.as_ref()) {
3212                    seen.push(s.as_ref().to_string());
3213                }
3214            }
3215            seen
3216        };
3217        if let Facet::Grid {
3218            row_levels,
3219            col_levels,
3220            ..
3221        } = &mut self.facet
3222        {
3223            *row_levels = rows.map(dedup);
3224            *col_levels = cols.map(dedup);
3225        }
3226        self
3227    }
3228
3229    /// Add an "(all)" row and column pooling the others (ggplot2's
3230    /// `facet_grid(margins = TRUE)`, #412). Stats are computed on the pooled
3231    /// data. Call after `facet_grid*`.
3232    pub fn facet_margins(mut self, on: bool) -> Self {
3233        if let Facet::Grid { margins, .. } = &mut self.facet {
3234            *margins = on;
3235        }
3236        self
3237    }
3238
3239    /// Move `facet_grid` strips to the opposite side (ggplot2's `switch =
3240    /// "x" | "y" | "both"`): column strips below the grid, row strips on its
3241    /// left. Call after `facet_grid*` (#412).
3242    pub fn facet_switch(mut self, switch: crate::facet::FacetSwitch) -> Self {
3243        if let Facet::Grid { switch: s, .. } = &mut self.facet {
3244            *s = switch;
3245        }
3246        self
3247    }
3248
3249    /// Fix a [`facet_wrap`](Self::facet_wrap)'s panel order and keep empty
3250    /// panels (ggplot2's factor levels with `drop = FALSE`, #329): one panel
3251    /// per entry of `levels`, in that order, whether or not any layer has
3252    /// rows for it; rows whose facet value isn't listed are dropped. A value
3253    /// matches a level by its displayed form (`1` for the integer 1). Call
3254    /// after `facet_wrap*`; no effect on other facets.
3255    pub fn facet_levels<S: AsRef<str>>(mut self, levels: &[S]) -> Self {
3256        if let Facet::Wrap { levels: l, .. } = &mut self.facet {
3257            let mut seen = Vec::<String>::new();
3258            for s in levels {
3259                if !seen.iter().any(|x| x == s.as_ref()) {
3260                    seen.push(s.as_ref().to_string());
3261                }
3262            }
3263            *l = Some(seen);
3264        }
3265        self
3266    }
3267
3268    pub fn facet_grid(mut self, row: Option<&str>, col: Option<&str>) -> Self {
3269        self.facet = Facet::Grid {
3270            row_var: row.map(String::from),
3271            col_var: col.map(String::from),
3272            scales: FacetScales::Fixed,
3273            labeller: FacetLabeller::default(),
3274            space: FacetSpace::Fixed,
3275            switch: crate::facet::FacetSwitch::None,
3276            row_levels: None,
3277            col_levels: None,
3278            margins: false,
3279        };
3280        self
3281    }
3282
3283    pub fn facet_grid_free(
3284        mut self,
3285        row: Option<&str>,
3286        col: Option<&str>,
3287        scales: FacetScales,
3288    ) -> Self {
3289        self.facet = Facet::Grid {
3290            row_var: row.map(String::from),
3291            col_var: col.map(String::from),
3292            scales,
3293            labeller: FacetLabeller::default(),
3294            space: FacetSpace::Fixed,
3295            switch: crate::facet::FacetSwitch::None,
3296            row_levels: None,
3297            col_levels: None,
3298            margins: false,
3299        };
3300        self
3301    }
3302
3303    /// `facet_grid` over **multiple** column variables (R's `rows ~ b + c`):
3304    /// the columns become the combination of the given variables' values.
3305    /// Also accepts free scales + proportional `space` for full parity.
3306    pub fn facet_grid_multi(
3307        mut self,
3308        row: Option<&str>,
3309        cols: &[&str],
3310        scales: FacetScales,
3311        space: FacetSpace,
3312    ) -> Self {
3313        let col_var = if cols.len() > 1 {
3314            // Build a synthetic column that is the interaction of the col vars.
3315            let n = self.data.nrows();
3316            let mut combined = Vec::with_capacity(n);
3317            for i in 0..n {
3318                let parts: Vec<String> = cols
3319                    .iter()
3320                    .map(|c| {
3321                        self.data
3322                            .column(c)
3323                            .and_then(|col| col.get(i))
3324                            .map(|v| v.to_group_key())
3325                            .unwrap_or_default()
3326                    })
3327                    .collect();
3328                combined.push(crate::data::Value::Str(parts.join(" . ")));
3329            }
3330            let name = "__facet_cols__".to_string();
3331            self.data.add_column(name.clone(), combined);
3332            Some(name)
3333        } else {
3334            cols.first().map(|s| s.to_string())
3335        };
3336        self.facet = Facet::Grid {
3337            row_var: row.map(String::from),
3338            col_var,
3339            scales,
3340            labeller: FacetLabeller::default(),
3341            space,
3342            switch: crate::facet::FacetSwitch::None,
3343            row_levels: None,
3344            col_levels: None,
3345            margins: false,
3346        };
3347        self
3348    }
3349
3350    /// `facet_grid` with proportional panel sizing (R's `space =`): panels are
3351    /// sized to their data range. Typically paired with free scales.
3352    pub fn facet_grid_space(
3353        mut self,
3354        row: Option<&str>,
3355        col: Option<&str>,
3356        scales: FacetScales,
3357        space: FacetSpace,
3358    ) -> Self {
3359        self.facet = Facet::Grid {
3360            row_var: row.map(String::from),
3361            col_var: col.map(String::from),
3362            scales,
3363            labeller: FacetLabeller::default(),
3364            space,
3365            switch: crate::facet::FacetSwitch::None,
3366            row_levels: None,
3367            col_levels: None,
3368            margins: false,
3369        };
3370        self
3371    }
3372
3373    pub fn facet_grid_labeller(
3374        mut self,
3375        row: Option<&str>,
3376        col: Option<&str>,
3377        labeller: FacetLabeller,
3378    ) -> Self {
3379        self.facet = Facet::Grid {
3380            row_var: row.map(String::from),
3381            col_var: col.map(String::from),
3382            scales: FacetScales::Fixed,
3383            labeller,
3384            space: FacetSpace::Fixed,
3385            switch: crate::facet::FacetSwitch::None,
3386            row_levels: None,
3387            col_levels: None,
3388            margins: false,
3389        };
3390        self
3391    }
3392
3393    // ─── Coordinates ─────────────────────────────────────────────
3394
3395    pub fn coord_flip(mut self) -> Self {
3396        self.coord = Box::new(CoordFlip);
3397        self
3398    }
3399
3400    pub fn coord_fixed(mut self, ratio: f64) -> Self {
3401        self.coord = Box::new(CoordFixed::new(ratio));
3402        self
3403    }
3404
3405    /// Spatial coordinate system (feature `sf`): equal aspect derived from the
3406    /// data extent, so projected geometry keeps its shape. Pair with a `geom_sf`
3407    /// projection (e.g. Mercator) for a conformal map.
3408    #[cfg(feature = "sf")]
3409    pub fn coord_sf(mut self) -> Self {
3410        self.coord = Box::new(crate::coord::sf::CoordSf::new());
3411        self
3412    }
3413
3414    /// Spatial coordinate system with explicit settings (`xlim`/`ylim`/
3415    /// `expand`) — see [`CoordSf`](crate::coord::sf::CoordSf). Used by
3416    /// consumers overlaying a fragment on their own projected basemap: a
3417    /// fixed pixel window via `xlim`/`ylim` and `expand(false)`.
3418    #[cfg(feature = "sf")]
3419    pub fn coord_sf_with(mut self, coord: crate::coord::sf::CoordSf) -> Self {
3420        self.coord = Box::new(coord);
3421        self
3422    }
3423
3424    /// Whether the rendered root `<svg>` carries `data-plot`/`data-domain`/
3425    /// `data-xdomain`/… (see [`root_data_attrs`](crate::render::svg_backend::root_data_attrs)).
3426    /// Default `true`. A consumer that overlays a rendered fragment
3427    /// (`render_svg_native_at`) on its own basemap — which already has its
3428    /// own root attributes and window — turns this off so the fragment adds
3429    /// none of its own.
3430    pub fn svg_root_data(mut self, on: bool) -> Self {
3431        self.svg_root_data = on;
3432        self
3433    }
3434
3435    /// Add one extra attribute to the root `<svg>` element — a host
3436    /// identifier, an ARIA label, a CSS hook, and so on. Allowed names:
3437    /// `data-*`, `aria-*`, `role`, `id`, `class`, `lang`; anything else, or
3438    /// a name ggplot-rs already writes to the root (`data-plot`,
3439    /// `data-domain`, …, or `data-warnings` when
3440    /// [`svg_warnings_attr`](Self::svg_warnings_attr) is also on), is
3441    /// ignored with a build warning instead of being silently dropped or
3442    /// duplicated. Values are escaped on output. PlotGrid has the same API
3443    /// ([`PlotGrid::svg_attr`](crate::compose::PlotGrid::svg_attr)).
3444    pub fn svg_attr(mut self, name: impl Into<String>, value: impl Into<String>) -> Self {
3445        self.root_attrs.push((name.into(), value.into()));
3446        self
3447    }
3448
3449    /// [`svg_attr`](Self::svg_attr) for several attributes at once.
3450    pub fn svg_attrs(mut self, attrs: impl IntoIterator<Item = (String, String)>) -> Self {
3451        self.root_attrs.extend(attrs);
3452        self
3453    }
3454
3455    /// Emit a `data-warnings` attribute on the root `<svg>` — a JSON array
3456    /// of the build warnings (rows dropped for non-finite positions, an
3457    /// `svg_attr` name rejected, …), `[]` when there are none. Default off,
3458    /// and omitted entirely when off (byte-identical to not calling this).
3459    pub fn svg_warnings_attr(mut self, on: bool) -> Self {
3460        self.svg_warnings_attr = on;
3461        self
3462    }
3463
3464    /// Emit `data-fvalue`/`data-fx` on marks: the value aesthetic's raw
3465    /// data value (y for most geoms, fill for hex/tile) and the x value,
3466    /// each formatted through its scale (currency/locale numbers, ISO or
3467    /// locale dates — [`Scale::format_value`](crate::scale::Scale::format_value))
3468    /// rather than the plain display string `data-value`/`data-x` already
3469    /// carry. `data-fx` is omitted when it would just duplicate `data-x`.
3470    /// Default off, and omitted entirely when off (byte-identical to not
3471    /// calling this).
3472    /// Tag the SVG's non-data elements with stable semantic CSS classes
3473    /// (#465), so a page stylesheet can restyle them: `gg-plot-background`,
3474    /// `gg-panel-background`, `gg-panel-border`, `gg-grid` (+ `gg-grid-major`
3475    /// / `gg-grid-minor`), `gg-axis` (+ `gg-axis-x` / `gg-axis-y` and
3476    /// `gg-axis-line` / `gg-axis-tick` / `gg-axis-text`), `gg-axis-title`
3477    /// (+ `-x` / `-y`), `gg-legend` (+ `gg-legend-title` / `gg-legend-key`
3478    /// / `gg-legend-text` / `gg-legend-background`), `gg-strip` (+
3479    /// `gg-strip-background` / `gg-strip-text`), `gg-title`, `gg-subtitle`,
3480    /// `gg-caption`, `gg-tag`. Data marks always carry `gg-mark gg-<kind>`.
3481    /// Off by default (output unchanged).
3482    pub fn svg_semantic_classes(mut self, on: bool) -> Self {
3483        self.svg_classes = on;
3484        self
3485    }
3486
3487    pub fn svg_formatted_values(mut self, on: bool) -> Self {
3488        self.svg_formatted_values = on;
3489        self
3490    }
3491
3492    /// Give the rendered root `<svg>` an accessible name: a `<title>`
3493    /// written (escaped) as its first child, plus `role="img"` and
3494    /// `aria-labelledby` pointing at that `<title>`'s id — the SVG
3495    /// accessibility practice of naming an image via `role="img"` +
3496    /// `aria-labelledby`/`-describedby` rather than a bare `<title>`,
3497    /// which some screen readers treat as a tooltip instead of a name.
3498    /// Pairs with [`svg_desc`](Self::svg_desc) for a longer description;
3499    /// either may be set alone. Default off (`None`), so output stays
3500    /// byte-identical when this is never called.
3501    pub fn svg_title(mut self, title: impl Into<String>) -> Self {
3502        self.svg_title = Some(title.into());
3503        self
3504    }
3505
3506    /// Give the rendered root `<svg>` an accessible description: a
3507    /// `<desc>` written (escaped) as a first child (after `<title>`, if
3508    /// [`svg_title`](Self::svg_title) is also set), plus `role="img"` and
3509    /// `aria-describedby` pointing at that `<desc>`'s id — e.g. a data
3510    /// summary like "4 regions; highest Bavaria (5.2); 1 without data".
3511    /// Default off (`None`), so output stays byte-identical when this is
3512    /// never called.
3513    pub fn svg_desc(mut self, desc: impl Into<String>) -> Self {
3514        self.svg_desc = Some(desc.into());
3515        self
3516    }
3517
3518    /// Transform the coordinate space at draw time (R's `coord_trans`) — stats are
3519    /// computed on raw data but drawn on non-linear axes. Pass a per-axis
3520    /// [`ScaleTransform`] (e.g. `Some(ScaleTransform::Log10)`), `None` to leave an
3521    /// axis linear.
3522    pub fn coord_trans(mut self, x: Option<ScaleTransform>, y: Option<ScaleTransform>) -> Self {
3523        self.coord = Box::new(crate::coord::trans::CoordTrans::new(x, y));
3524        self
3525    }
3526
3527    /// ggplot2 4.0's name for [`coord_trans`](Self::coord_trans).
3528    pub fn coord_transform(self, x: Option<ScaleTransform>, y: Option<ScaleTransform>) -> Self {
3529        self.coord_trans(x, y)
3530    }
3531
3532    /// Plain longitude (`x`) / latitude (`y`) data with the aspect ratio of
3533    /// the middle latitude (ggplot2's `coord_quickmap()`).
3534    pub fn coord_quickmap(mut self) -> Self {
3535        self.coord = Box::new(crate::coord::map::CoordMap::new(
3536            crate::coord::map::MapProjection::Quick,
3537        ));
3538        self
3539    }
3540
3541    /// Plain longitude / latitude data on a Mercator map (ggplot2's
3542    /// `coord_map("mercator")`); use `geom_sf` + `coord_sf` for other
3543    /// projections.
3544    pub fn coord_map(mut self) -> Self {
3545        self.coord = Box::new(crate::coord::map::CoordMap::new(
3546            crate::coord::map::MapProjection::Mercator,
3547        ));
3548        self
3549    }
3550
3551    /// Use a configured [`CoordCartesian`] (zoom + reverse).
3552    pub fn coord_cartesian_with(mut self, coord: CoordCartesian) -> Self {
3553        self.coord = Box::new(coord);
3554        self
3555    }
3556
3557    /// Whether the data layers are clipped to the panel — ggplot2's
3558    /// `coord_*(clip = "on")` (the default) or `clip = "off"` (`false`),
3559    /// which lets marks and labels at the edge draw past it (#411).
3560    pub fn coord_clip(mut self, on: bool) -> Self {
3561        self.clip = on;
3562        self
3563    }
3564
3565    /// Mirror the panel in x and / or y (`coord_cartesian(reverse = …)`).
3566    pub fn coord_reverse(mut self, x: bool, y: bool) -> Self {
3567        self.coord = Box::new(CoordCartesian::new().reverse(x, y));
3568        self
3569    }
3570
3571    /// ggplot2's `coord_radial(start, end, inner.radius)`: a polar coord
3572    /// whose theta runs from `start` to `end` radians (clockwise from 12
3573    /// o'clock) with a hole of `inner_radius` (0–1) — partial circles and
3574    /// donuts.
3575    pub fn coord_radial(mut self, start: f64, end: f64, inner_radius: f64) -> Self {
3576        let mut c = CoordPolar::new();
3577        c.start = start;
3578        c.span = end - start;
3579        c.inner_radius = inner_radius.clamp(0.0, 0.99);
3580        self.coord = Box::new(c);
3581        self
3582    }
3583
3584    /// `coord_trans` on the y-axis only.
3585    pub fn coord_trans_y(self, y: ScaleTransform) -> Self {
3586        self.coord_trans(None, Some(y))
3587    }
3588
3589    /// `coord_trans` on the x-axis only.
3590    pub fn coord_trans_x(self, x: ScaleTransform) -> Self {
3591        self.coord_trans(Some(x), None)
3592    }
3593
3594    /// Zoom into a region without filtering data (unlike xlim/ylim which filter).
3595    pub fn coord_cartesian_zoom(
3596        mut self,
3597        xlim: Option<(f64, f64)>,
3598        ylim: Option<(f64, f64)>,
3599    ) -> Self {
3600        let mut c = CoordCartesian::new();
3601        if let Some((min, max)) = xlim {
3602            c = c.xlim(min, max);
3603        }
3604        if let Some((min, max)) = ylim {
3605            c = c.ylim(min, max);
3606        }
3607        self.coord = Box::new(c);
3608        self
3609    }
3610
3611    pub fn coord_polar(mut self) -> Self {
3612        self.coord = Box::new(CoordPolar::new());
3613        self
3614    }
3615
3616    /// Radar / spider chart coordinates: discrete `x` → spokes, `y` → distance
3617    /// from the centre, straight segments, ring/spoke guides. Pair with
3618    /// `geom_polygon` (closed series) and `geom_point`.
3619    pub fn coord_radar(mut self) -> Self {
3620        self.coord = Box::new(crate::coord::radar::CoordRadar::new());
3621        self
3622    }
3623
3624    pub fn coord_radar_with(mut self, coord: crate::coord::radar::CoordRadar) -> Self {
3625        self.coord = Box::new(coord);
3626        self
3627    }
3628
3629    pub fn coord_polar_with(mut self, coord: CoordPolar) -> Self {
3630        self.coord = Box::new(coord);
3631        self
3632    }
3633
3634    // ─── Theme ───────────────────────────────────────────────────
3635
3636    /// Replace the theme. A `primary_color` set earlier is kept unless the new
3637    /// theme sets its own, so preset/`primary_color` call order doesn't matter.
3638    pub fn theme(mut self, theme: Theme) -> Self {
3639        self.apply_preset(theme);
3640        self
3641    }
3642
3643    fn apply_preset(&mut self, theme: Theme) {
3644        let primary = self.theme.primary;
3645        self.theme = theme;
3646        if self.theme.primary.is_none() {
3647            self.theme.primary = primary;
3648        }
3649    }
3650
3651    /// Rotate the x-axis tick labels by `degrees` (R's
3652    /// `guides(x = guide_axis(angle = ...))` / `axis.text.x = element_text(angle)`).
3653    /// Useful for long category labels. Call after any `theme_*()` preset.
3654    pub fn axis_text_x_angle(mut self, degrees: f64) -> Self {
3655        self.theme.axis_text_x.angle = degrees;
3656        self
3657    }
3658
3659    /// Rotate the y-axis tick labels by `degrees`.
3660    pub fn axis_text_y_angle(mut self, degrees: f64) -> Self {
3661        self.theme.axis_text_y.angle = degrees;
3662        self
3663    }
3664
3665    /// Stagger x-axis tick labels across `n` rows to avoid overlap (R's
3666    /// `guides(x = guide_axis(n.dodge = n))`). `1` = no dodging.
3667    pub fn axis_text_x_dodge(mut self, n: usize) -> Self {
3668        self.theme.axis_text_x_dodge = n.max(1);
3669        self
3670    }
3671
3672    /// Fix the panel's height:width ratio (R's `aspect.ratio`). Call after any
3673    /// `theme_*()` preset.
3674    pub fn aspect_ratio(mut self, ratio: f64) -> Self {
3675        self.theme.aspect_ratio = Some(ratio);
3676        self
3677    }
3678
3679    /// Draw gridlines on top of the data layers (R's `panel.ontop`).
3680    pub fn panel_ontop(mut self) -> Self {
3681        self.theme.panel_ontop = true;
3682        self
3683    }
3684
3685    /// Draw minor tick marks between major ticks (R's `axis.minor.ticks`).
3686    pub fn axis_minor_ticks(mut self) -> Self {
3687        self.theme.axis_minor_ticks = true;
3688        self
3689    }
3690
3691    /// How an outer x-axis label that would cross the SVG edge is handled
3692    /// (B3-1, D6): [`AxisLabelFit::Reserve`](crate::theme::AxisLabelFit::Reserve) (default), `Shift`, or `Clip`
3693    /// (the 0.21 behaviour). Use `Shift` for panels that must stay
3694    /// pixel-aligned with other elements (e.g. a host's own aligned strip).
3695    pub fn axis_text_x_fit(mut self, fit: crate::theme::AxisLabelFit) -> Self {
3696        self.theme.axis_text_x_fit = fit;
3697        self
3698    }
3699
3700    /// Thin crowded x-axis labels in ggplot2's `check.overlap` priority
3701    /// order (B3-1, D5). Off by default.
3702    pub fn axis_text_x_check_overlap(mut self, on: bool) -> Self {
3703        self.theme.axis_text_x_check_overlap = on;
3704        self
3705    }
3706
3707    /// Align title/subtitle/caption to the panel or the whole plot width
3708    /// (R's `plot.title.position`).
3709    pub fn title_position(mut self, pos: crate::theme::TitlePosition) -> Self {
3710        self.theme.title_position = pos;
3711        self
3712    }
3713
3714    /// Corner for the `tag()` label (R's `plot.tag.position`).
3715    pub fn tag_position(mut self, pos: crate::theme::TagPosition) -> Self {
3716        self.theme.tag_position = pos;
3717        self
3718    }
3719
3720    /// Force the legend key layout direction (R's `legend.direction`).
3721    pub fn legend_direction(mut self, dir: crate::theme::LegendDirection) -> Self {
3722        self.theme.legend_direction = Some(dir);
3723        self
3724    }
3725
3726    /// Set the brand/primary color used as the default for single-series geoms
3727    /// that have no color/fill aesthetic mapped. Composes with any theme — one
3728    /// render process can serve different tenants' brands at render time.
3729    /// Place the legend inside the panel at panel-relative coordinates
3730    /// (0..1, 0..1) — `(0,0)` bottom-left, `(1,1)` top-right (R's
3731    /// `legend.position = c(x, y)`).
3732    pub fn legend_position_inside(mut self, x: f64, y: f64) -> Self {
3733        self.theme.legend_position = crate::theme::LegendPosition::Inside(x, y);
3734        self
3735    }
3736
3737    /// Set the legend position (R's `legend.position`, e.g. `Top`/`Bottom`).
3738    pub fn legend_position(mut self, pos: crate::theme::LegendPosition) -> Self {
3739        self.theme.legend_position = pos;
3740        self
3741    }
3742
3743    /// Move a [`Right`](crate::theme::LegendPosition::Right) legend to
3744    /// `Bottom` (drawn horizontally) when it would take more than `share`
3745    /// (0..1) of the panel's width — the width the panel has with no legend
3746    /// at all (#287). The band is roughly fixed in size, so on a narrow
3747    /// render it can take most of the panel; a render wide enough keeps
3748    /// `Right`, unchanged. Off by default; no effect on any other position.
3749    pub fn legend_bottom_when_narrow(mut self, share: f64) -> Self {
3750        self.legend_bottom_share = Some(share);
3751        self
3752    }
3753
3754    /// Put the legend inside the panel, in an empty corner (#332): after
3755    /// layout, each corner's real legend box is tested against the drawn
3756    /// marks (polygons, lines, points, labels, corner furniture such as a
3757    /// scale bar), and the first corner it overlaps nothing in wins, in the
3758    /// order bottom-right, bottom-left, top-right, top-left. If every
3759    /// corner is taken the legend goes outside, `Top` or `Right`, whichever
3760    /// leaves the data area (e.g. a `coord_sf` map) larger. The choice is
3761    /// written as the root `data-legend-at` attribute (`inside-top-left`,
3762    /// `top`, …). Single-panel plots only; a faceted plot keeps its legend
3763    /// position. See [`legend_inside_auto_with`](Self::legend_inside_auto_with).
3764    pub fn legend_inside_auto(self) -> Self {
3765        self.legend_inside_auto_with(&crate::theme::LegendCorner::ALL, None)
3766    }
3767
3768    /// [`legend_inside_auto`](Self::legend_inside_auto) with the candidate
3769    /// `corners` (in preference order) and an outside `fallback`
3770    /// (`Top`/`Right`/`Bottom`/`Left`; `None` = the larger of `Top` and
3771    /// `Right`).
3772    pub fn legend_inside_auto_with(
3773        mut self,
3774        corners: &[crate::theme::LegendCorner],
3775        fallback: Option<crate::theme::LegendPosition>,
3776    ) -> Self {
3777        use crate::theme::LegendPosition as P;
3778        let mut c = [None; 4];
3779        for (slot, corner) in c.iter_mut().zip(corners) {
3780            *slot = Some(*corner);
3781        }
3782        let fallback = match fallback {
3783            Some(P::Top) => Some(LegendSide::Top),
3784            Some(P::Right) => Some(LegendSide::Right),
3785            Some(P::Bottom) => Some(LegendSide::Bottom),
3786            Some(P::Left) => Some(LegendSide::Left),
3787            _ => None,
3788        };
3789        self.legend_auto = Some(LegendAuto {
3790            corners: c,
3791            fallback,
3792        });
3793        self
3794    }
3795
3796    pub fn primary_color(mut self, color: (u8, u8, u8)) -> Self {
3797        self.theme.primary = Some(color);
3798        self
3799    }
3800
3801    pub fn theme_minimal(mut self) -> Self {
3802        self.apply_preset(crate::theme::presets::theme_minimal());
3803        self
3804    }
3805
3806    /// ggplot2's `theme_test()`: `theme_bw` without gridlines (#413).
3807    pub fn theme_test(mut self) -> Self {
3808        self.apply_preset(crate::theme::presets::theme_test());
3809        self
3810    }
3811
3812    pub fn theme_bw(mut self) -> Self {
3813        self.apply_preset(crate::theme::presets::theme_bw());
3814        self
3815    }
3816
3817    pub fn theme_gray(mut self) -> Self {
3818        self.apply_preset(crate::theme::presets::theme_gray());
3819        self
3820    }
3821
3822    pub fn theme_classic(mut self) -> Self {
3823        self.apply_preset(crate::theme::presets::theme_classic());
3824        self
3825    }
3826
3827    /// Apply the publication-ready `theme_pubr()` (ggpubr style).
3828    pub fn theme_pubr(mut self) -> Self {
3829        self.apply_preset(crate::theme::presets::theme_pubr());
3830        self
3831    }
3832
3833    pub fn theme_linedraw(mut self) -> Self {
3834        self.apply_preset(crate::theme::presets::theme_linedraw());
3835        self
3836    }
3837
3838    pub fn theme_light(mut self) -> Self {
3839        self.apply_preset(crate::theme::presets::theme_light());
3840        self
3841    }
3842
3843    pub fn theme_dark(mut self) -> Self {
3844        self.apply_preset(crate::theme::presets::theme_dark());
3845        self
3846    }
3847
3848    pub fn theme_void(mut self) -> Self {
3849        self.apply_preset(crate::theme::presets::theme_void());
3850        self
3851    }
3852
3853    /// Apply incremental theme modifications on top of the current theme.
3854    /// Like R's `+ theme(axis.text.x = element_text(...))`.
3855    pub fn theme_update(mut self, update: crate::theme::ThemeUpdate) -> Self {
3856        self.theme = self.theme.update(update);
3857        self
3858    }
3859
3860    /// ggplot2's `%+replace%`: the elements `update` sets replace the
3861    /// current ones whole (#413) — the same as [`theme_update`](Self::theme_update),
3862    /// named for parity. For ggplot2's merging `+ theme(..)` (only the given
3863    /// properties change) use [`theme_with`](Self::theme_with).
3864    pub fn theme_replace(self, update: crate::theme::ThemeUpdate) -> Self {
3865        self.theme_update(update)
3866    }
3867
3868    /// Change single theme properties in place (ggplot2's merging `+ theme(
3869    /// axis.text.x = element_text(size = 14))`, #413), e.g.
3870    /// `.theme_with(|t| t.axis_text_x.size = 14.0)`.
3871    pub fn theme_with(mut self, f: impl FnOnce(&mut Theme)) -> Self {
3872        f(&mut self.theme);
3873        self
3874    }
3875
3876    // ─── Guides ──────────────────────────────────────────────────
3877
3878    /// Configure legend guide (title, ncol, reverse).
3879    pub fn guides(mut self, mut guide: crate::guide::config::GuideLegend) -> Self {
3880        // Keep per-aesthetic settings made earlier (`guide_none` /
3881        // `guide_title` / `guide_reverse` / `guide_colourbar`) unless the
3882        // new guide sets its own.
3883        if guide.hidden.is_empty() {
3884            guide.hidden = std::mem::take(&mut self.guide_legend.hidden);
3885        }
3886        if guide.titles.is_empty() {
3887            guide.titles = std::mem::take(&mut self.guide_legend.titles);
3888        }
3889        if guide.reversed.is_empty() {
3890            guide.reversed = std::mem::take(&mut self.guide_legend.reversed);
3891        }
3892        if guide.colourbars.is_empty() {
3893            guide.colourbars = std::mem::take(&mut self.guide_legend.colourbars);
3894        }
3895        if guide.overrides.is_empty() {
3896            guide.overrides = std::mem::take(&mut self.guide_legend.overrides);
3897        }
3898        if guide.bins.is_empty() {
3899            guide.bins = std::mem::take(&mut self.guide_legend.bins);
3900        }
3901        if guide.plain_keys.is_empty() {
3902            guide.plain_keys = std::mem::take(&mut self.guide_legend.plain_keys);
3903        }
3904        if guide.custom.is_empty() {
3905            guide.custom = std::mem::take(&mut self.guide_legend.custom);
3906        }
3907        if guide.positions.is_empty() {
3908            guide.positions = std::mem::take(&mut self.guide_legend.positions);
3909        }
3910        self.guide_legend = guide;
3911        self
3912    }
3913
3914    // ─── Labels ──────────────────────────────────────────────────
3915
3916    pub fn labs(mut self, labels: Labels) -> Self {
3917        if labels.title.is_some() {
3918            self.labels.title = labels.title;
3919        }
3920        if labels.subtitle.is_some() {
3921            self.labels.subtitle = labels.subtitle;
3922        }
3923        if labels.x.is_some() {
3924            self.labels.x = labels.x;
3925        }
3926        if labels.y.is_some() {
3927            self.labels.y = labels.y;
3928        }
3929        if labels.caption.is_some() {
3930            self.labels.caption = labels.caption;
3931        }
3932        if labels.tag.is_some() {
3933            self.labels.tag = labels.tag;
3934        }
3935        self
3936    }
3937
3938    pub fn title(mut self, title: &str) -> Self {
3939        self.labels.title = Some(title.to_string());
3940        self
3941    }
3942
3943    /// Corner tag label (R's `labs(tag = ...)`), drawn at the top-left — handy
3944    /// for labelling figure panels ("A", "B", …).
3945    pub fn tag(mut self, tag: &str) -> Self {
3946        self.labels.tag = Some(tag.to_string());
3947        self
3948    }
3949
3950    pub fn subtitle(mut self, subtitle: &str) -> Self {
3951        self.labels.subtitle = Some(subtitle.to_string());
3952        self
3953    }
3954
3955    pub fn xlab(mut self, label: &str) -> Self {
3956        self.labels.x = Some(label.to_string());
3957        self
3958    }
3959
3960    pub fn ylab(mut self, label: &str) -> Self {
3961        self.labels.y = Some(label.to_string());
3962        self
3963    }
3964
3965    pub fn caption(mut self, caption: &str) -> Self {
3966        self.labels.caption = Some(caption.to_string());
3967        self
3968    }
3969
3970    // ─── Annotations ──────────────────────────────────────────────
3971
3972    /// Add an annotation to the plot.
3973    pub fn annotate(mut self, annotation: Annotation) -> Self {
3974        self.annotations.push(annotation);
3975        self
3976    }
3977
3978    /// Add a text annotation at data coordinates.
3979    pub fn annotate_text(self, label: &str, x: f64, y: f64) -> Self {
3980        self.annotate(Annotation::text(label, x, y))
3981    }
3982
3983    /// Add a rectangle annotation at data coordinates.
3984    pub fn annotate_rect(self, xmin: f64, xmax: f64, ymin: f64, ymax: f64) -> Self {
3985        self.annotate(Annotation::rect(xmin, xmax, ymin, ymax))
3986    }
3987
3988    /// Add a segment annotation between data coordinates.
3989    pub fn annotate_segment(self, x: f64, y: f64, xend: f64, yend: f64) -> Self {
3990        self.annotate(Annotation::segment(x, y, xend, yend))
3991    }
3992
3993    /// Log-10 tick marks on the panel `sides` (`"bl"`, `"trbl"`, …) —
3994    /// ggplot2's `annotation_logticks()`; pair with `scale_x_log10()` etc.
3995    pub fn annotation_logticks(self, sides: &str) -> Self {
3996        self.annotate(Annotation::logticks(sides))
3997    }
3998
3999    /// An encoded PNG / JPEG image over a data rectangle (`annotation_raster`);
4000    /// `f64::INFINITY` / `NEG_INFINITY` bounds mean the panel edges.
4001    pub fn annotation_raster(
4002        self,
4003        image: Vec<u8>,
4004        xmin: f64,
4005        xmax: f64,
4006        ymin: f64,
4007        ymax: f64,
4008    ) -> Self {
4009        self.annotate(Annotation::raster(image, xmin, xmax, ymin, ymax))
4010    }
4011
4012    /// An RGBA pixel buffer (`width × height × 4`, row-major) over a data
4013    /// rectangle (`annotation_raster` with a colour matrix; feature `png`).
4014    #[cfg(feature = "png")]
4015    pub fn annotation_raster_rgba(
4016        self,
4017        (width, height): (u32, u32),
4018        rgba: &[u8],
4019        (xmin, xmax, ymin, ymax): (f64, f64, f64, f64),
4020    ) -> Self {
4021        use image::ImageEncoder;
4022        let mut png = Vec::new();
4023        let ok = image::codecs::png::PngEncoder::new(&mut png)
4024            .write_image(rgba, width, height, image::ColorType::Rgba8)
4025            .is_ok();
4026        if !ok {
4027            return self;
4028        }
4029        self.annotation_raster(png, xmin, xmax, ymin, ymax)
4030    }
4031
4032    /// Draw another plot as an inset into a data rectangle — ggplot2's
4033    /// `annotation_custom(ggplotGrob(p), xmin, xmax, ymin, ymax)`.
4034    /// `f64::INFINITY` / `NEG_INFINITY` bounds mean the panel edges.
4035    pub fn annotation_custom(
4036        self,
4037        inset: GGPlot,
4038        xmin: f64,
4039        xmax: f64,
4040        ymin: f64,
4041        ymax: f64,
4042    ) -> Self {
4043        self.annotate(Annotation::inset(inset, xmin, xmax, ymin, ymax))
4044    }
4045
4046    /// Default [`Locale`](crate::locale::Locale) for the plot's axis and
4047    /// legend labels: every continuous position / colour / size / alpha scale
4048    /// labels its default breaks with the locale's decimal and thousands marks
4049    /// (`1.000`, `2,5` in de-DE), and date/datetime scales use its month names
4050    /// and date formats (`Okt. 2026`, `15. Okt. 2026`). A scale's own
4051    /// `with_locale` and custom `label_*` formatters take precedence.
4052    /// Without this call labels are unchanged (`.` decimal mark, no grouping,
4053    /// ISO dates).
4054    ///
4055    /// ```
4056    /// use ggplot_rs::prelude::*;
4057    /// let svg = GGPlot::new(vec![
4058    ///         ("x".to_string(), vec![Value::Float(0.0), Value::Float(3000.0)]),
4059    ///         ("y".to_string(), vec![Value::Float(0.5), Value::Float(2.5)]),
4060    ///     ])
4061    ///     .aes(Aes::new().x("x").y("y"))
4062    ///     .geom_point()
4063    ///     .locale(Locale::from_tag("de-DE"))
4064    ///     .render_svg_native()
4065    ///     .unwrap();
4066    /// assert!(svg.contains(">1.000<") && svg.contains(">1,5<"));
4067    /// ```
4068    pub fn locale(mut self, locale: crate::locale::Locale) -> Self {
4069        self.locale = Some(locale);
4070        self
4071    }
4072
4073    // ─── Build and Render ────────────────────────────────────────
4074
4075    /// Build the plot without rendering, returning errors on validation failure.
4076    pub fn try_build(self) -> Result<crate::build::BuiltPlot, GGError> {
4077        PlotBuilder::build(self)
4078    }
4079
4080    /// Build the plot without rendering (analogous to R's ggplot_build()).
4081    /// Returns the fully computed BuiltPlot with layer data ready for inspection.
4082    /// Panics on validation errors — use `try_build()` for error handling.
4083    pub fn build(self) -> crate::build::BuiltPlot {
4084        self.try_build().expect("plot build failed")
4085    }
4086
4087    /// Build and save the plot to a file. Format determined by extension.
4088    /// (Requires the `plotters` feature, on by default; native only — wasm has
4089    /// no filesystem/plotters backend.)
4090    #[cfg(all(feature = "plotters", not(target_arch = "wasm32")))]
4091    pub fn save(self, path: &str) -> Result<(), GGError> {
4092        self.save_with_size(path, 800, 600)
4093    }
4094
4095    /// Build and save with custom dimensions. (Feature `plotters`; native only.)
4096    #[cfg(all(feature = "plotters", not(target_arch = "wasm32")))]
4097    pub fn save_with_size(self, path: &str, w: u32, h: u32) -> Result<(), GGError> {
4098        // Determine backend from file extension
4099        let ext = path.rsplit('.').next().unwrap_or("svg").to_lowercase();
4100        #[cfg(feature = "pdf")]
4101        if ext == "pdf" {
4102            let bytes = self.render_pdf_with_size(w, h)?;
4103            return std::fs::write(path, bytes).map_err(|e| GGError::Render(e.into()));
4104        }
4105        let (built, layout) = self.prepare(w, h)?;
4106
4107        match ext.as_str() {
4108            "svg" => {
4109                let backend = plotters::prelude::SVGBackend::new(path, (w, h));
4110                Self::render_into(backend.into_drawing_area(), &built, &layout)?;
4111            }
4112            "png" | "bmp" | "gif" | "jpeg" | "jpg" | "tiff" => {
4113                let backend = plotters::prelude::BitMapBackend::new(path, (w, h));
4114                Self::render_into(backend.into_drawing_area(), &built, &layout)?;
4115            }
4116            _ => {
4117                return Err(GGError::UnsupportedFormat(ext));
4118            }
4119        }
4120
4121        Ok(())
4122    }
4123
4124    /// Render the plot to an in-memory SVG document (default 800x600).
4125    ///
4126    /// Unlike [`save`](Self::save), this writes nothing to disk — handy for
4127    /// serving charts from a web/MCP service. Requires the `plotters` feature
4128    /// (on by default); without it use [`render_svg_native`](Self::render_svg_native).
4129    #[cfg(all(feature = "plotters", not(target_arch = "wasm32")))]
4130    pub fn render_svg(self) -> Result<String, GGError> {
4131        self.render_svg_with_size(800, 600)
4132    }
4133
4134    /// Render the plot to an in-memory SVG document with custom dimensions.
4135    /// (Feature `plotters`; native only — on wasm or without `plotters` use
4136    /// [`render_svg_native_with_size`](Self::render_svg_native_with_size).)
4137    #[cfg(all(feature = "plotters", not(target_arch = "wasm32")))]
4138    pub fn render_svg_with_size(self, w: u32, h: u32) -> Result<String, GGError> {
4139        let (built, layout) = self.prepare(w, h)?;
4140        let mut buf = String::new();
4141        {
4142            let backend = plotters::prelude::SVGBackend::with_string(&mut buf, (w, h));
4143            Self::render_into(backend.into_drawing_area(), &built, &layout)?;
4144        }
4145        Ok(buf)
4146    }
4147
4148    /// Render to SVG through the self-contained [`SvgBackend`](crate::render::svg_backend::SvgBackend)
4149    /// — a plotters-free path that drives the same `PlotRenderer`, proving the
4150    /// `DrawBackend` abstraction (and needing no glyph rasterization).
4151    pub fn render_svg_native(self) -> Result<String, GGError> {
4152        self.render_svg_native_with_size(800, 600)
4153    }
4154
4155    /// [`render_svg_native`](Self::render_svg_native) with an explicit size.
4156    ///
4157    /// The root `<svg>` carries `data-plot="x y w h"` (the panel rect) and the
4158    /// trained position domains (`data-domain`, `data-xdomain`, `data-ydomain`,
4159    /// `data-xlevels`, `data-ylevels` — see
4160    /// [`root_data_attrs`](crate::render::svg_backend::root_data_attrs)).
4161    /// Hoverable marks carry `data-x`, `data-series` and `data-value`.
4162    pub fn render_svg_native_with_size(self, w: u32, h: u32) -> Result<String, GGError> {
4163        Ok(self.render_native(w, h)?.0.finish())
4164    }
4165
4166    /// Like [`render_svg_native_with_size`](Self::render_svg_native_with_size),
4167    /// but also returns the build warnings (rows dropped for non-finite
4168    /// positions, layers whose stat produced no data, …) — see
4169    /// [`BuiltPlot::warnings`](crate::build::BuiltPlot::warnings).
4170    pub fn render_svg_native_with_warnings(
4171        self,
4172        w: u32,
4173        h: u32,
4174    ) -> Result<(String, Vec<String>), GGError> {
4175        let (backend, warnings, _) = self.render_native(w, h)?;
4176        Ok((backend.finish(), warnings))
4177    }
4178
4179    /// Render a `w`×`h` chart as a *nested* `<svg x=.. y=.. width=.. height=..
4180    /// viewBox="0 0 w h">` fragment for composition into a larger SVG (a
4181    /// dashboard): no `xmlns` (inherited from the parent), positioned at
4182    /// `(x, y)` in the parent's user space. No string surgery needed:
4183    ///
4184    /// ```ignore
4185    /// let mut page = String::from(r#"<svg xmlns="http://www.w3.org/2000/svg" width="900" height="400">"#);
4186    /// page += &plot_a.render_svg_native_at(0.0, 0.0, 450, 400)?;
4187    /// page += &plot_b.render_svg_native_at(450.0, 0.0, 450, 400)?;
4188    /// page += "</svg>";
4189    /// ```
4190    pub fn render_svg_native_at(self, x: f64, y: f64, w: u32, h: u32) -> Result<String, GGError> {
4191        Ok(self.render_native(w, h)?.0.finish_fragment(x, y))
4192    }
4193
4194    /// Like [`render_svg_native_with_size`](Self::render_svg_native_with_size),
4195    /// but also returns the panel rect in pixels `[x, y, w, h]` — enough to
4196    /// overlay a WebGL/canvas layer that draws marks in data coordinates.
4197    pub fn render_svg_area_with_size(self, w: u32, h: u32) -> Result<(String, [f64; 4]), GGError> {
4198        let (backend, _, pa) = self.render_native(w, h)?;
4199        Ok((backend.finish(), [pa.x, pa.y, pa.width, pa.height]))
4200    }
4201
4202    /// Shared native-SVG pipeline: build, lay out, render into an
4203    /// [`SvgBackend`](crate::render::svg_backend::SvgBackend) (not yet
4204    /// finished), returning it with the build warnings and the panel rect.
4205    fn render_native(
4206        self,
4207        w: u32,
4208        h: u32,
4209    ) -> Result<
4210        (
4211            crate::render::svg_backend::SvgBackend,
4212            Vec<String>,
4213            crate::render::Rect,
4214        ),
4215        GGError,
4216    > {
4217        let svg_root_data = self.svg_root_data;
4218        let root_attrs = self.root_attrs.clone();
4219        let svg_warnings_attr = self.svg_warnings_attr;
4220        let svg_formatted_values = self.svg_formatted_values;
4221        let svg_title = self.svg_title.clone();
4222        let svg_desc = self.svg_desc.clone();
4223        let (built, layout) = self.prepare(w, h)?;
4224        let pa = layout.plot_area.clone();
4225        let mut backend = crate::render::svg_backend::SvgBackend::new(w, h, pa.clone());
4226        backend.set_formatted_values(svg_formatted_values);
4227        backend.set_svg_title(svg_title.clone());
4228        backend.set_svg_desc(svg_desc.clone());
4229        let existing_root_attrs = if svg_root_data {
4230            let attrs = crate::render::svg_backend::root_data_attrs(&built);
4231            backend.set_root_attrs(attrs.clone());
4232            // `data-plot` itself is a literal in `root_open`, not part of
4233            // `root_data_attrs`'s output, but it is written whenever
4234            // `svg_root_data` is on — reserve it too.
4235            let mut reserved = attrs;
4236            // `data-viewport`/`data-panels` likewise come from the backend
4237            // (a letterboxing coord / fixed-aspect facets).
4238            for name in ["data-plot", "data-viewport", "data-panels"] {
4239                reserved.push((name.to_string(), String::new()));
4240            }
4241            reserved
4242        } else {
4243            backend.without_plot_attr();
4244            Vec::new()
4245        };
4246        PlotRenderer::render(&built, &mut backend).map_err(GGError::Render)?;
4247        let mut warnings = built.warnings;
4248        warnings.extend(backend.take_warnings());
4249
4250        let mut reserved = existing_root_attrs;
4251        if svg_warnings_attr {
4252            reserved.push(("data-warnings".to_string(), String::new()));
4253        }
4254        // #255: `role`/`aria-labelledby`/`aria-describedby` are written
4255        // directly by `SvgBackend::root_open` when a title/desc was set —
4256        // reserve them so a colliding `svg_attr` call is dropped with a
4257        // warning instead of duplicating the attribute.
4258        if svg_title.is_some() || svg_desc.is_some() {
4259            reserved.push(("role".to_string(), String::new()));
4260        }
4261        if svg_title.is_some() {
4262            reserved.push(("aria-labelledby".to_string(), String::new()));
4263        }
4264        if svg_desc.is_some() {
4265            reserved.push(("aria-describedby".to_string(), String::new()));
4266        }
4267        let extra = crate::render::svg_backend::validate_root_attrs(
4268            &root_attrs,
4269            &reserved,
4270            "plot",
4271            &mut warnings,
4272        );
4273        backend.extend_root_attrs(extra);
4274        if svg_warnings_attr {
4275            backend.extend_root_attrs(vec![(
4276                "data-warnings".to_string(),
4277                crate::render::svg_backend::warnings_json(&warnings),
4278            )]);
4279        }
4280        Ok((backend, warnings, pa))
4281    }
4282
4283    /// Render to a raw RGBA pixel buffer via the self-contained raster
4284    /// [`PixelBackend`](crate::render::pixel_backend::PixelBackend) (feature
4285    /// `canvas`) — fast for large-N and wasm-compatible. Returns `(w, h, rgba)`,
4286    /// ready for a `<canvas>` `putImageData`.
4287    #[cfg(feature = "canvas")]
4288    pub fn render_rgba_with_size(self, w: u32, h: u32) -> Result<(u32, u32, Vec<u8>), GGError> {
4289        let (built, layout) = self.prepare(w, h)?;
4290        let mut backend =
4291            crate::render::pixel_backend::PixelBackend::new(w, h, layout.plot_area.clone());
4292        PlotRenderer::render(&built, &mut backend).map_err(GGError::Render)?;
4293        let (ow, oh) = backend.dimensions();
4294        Ok((ow, oh, backend.into_rgba()))
4295    }
4296
4297    /// Render to a vector PDF (feature `pdf`, #467): one page of `w` × `h`
4298    /// points (1 px = 1 pt), real selectable text in the bundled DejaVu
4299    /// fonts (embedded as subsets).
4300    #[cfg(feature = "pdf")]
4301    pub fn render_pdf_with_size(self, w: u32, h: u32) -> Result<Vec<u8>, GGError> {
4302        let (built, layout) = self.prepare(w, h)?;
4303        let mut backend =
4304            crate::render::pdf_backend::PdfBackend::new(w, h, layout.plot_area.clone());
4305        PlotRenderer::render(&built, &mut backend).map_err(GGError::Render)?;
4306        backend.finish().map_err(GGError::Render)
4307    }
4308
4309    /// [`render_pdf_with_size`](Self::render_pdf_with_size) at 800 × 600.
4310    #[cfg(feature = "pdf")]
4311    pub fn render_pdf(self) -> Result<Vec<u8>, GGError> {
4312        self.render_pdf_with_size(800, 600)
4313    }
4314
4315    /// Like [`render_rgba_with_size`](Self::render_rgba_with_size), but also
4316    /// returns the panel rect in pixels `[x, y, w, h]` — enough to map a canvas
4317    /// pixel back to data coordinates for interactive hover.
4318    #[cfg(feature = "canvas")]
4319    pub fn render_rgba_area_with_size(
4320        self,
4321        w: u32,
4322        h: u32,
4323    ) -> Result<(Vec<u8>, [f64; 4]), GGError> {
4324        let (built, layout) = self.prepare(w, h)?;
4325        let pa = layout.plot_area.clone();
4326        let mut backend = crate::render::pixel_backend::PixelBackend::new(w, h, pa.clone());
4327        PlotRenderer::render(&built, &mut backend).map_err(GGError::Render)?;
4328        Ok((backend.into_rgba(), [pa.x, pa.y, pa.width, pa.height]))
4329    }
4330
4331    /// Render to PNG bytes via the raster [`PixelBackend`](crate::render::pixel_backend::PixelBackend)
4332    /// (feature `canvas`). Unlike [`render_png`](Self::render_png) this needs no
4333    /// plotters bitmap backend, so it also works on wasm.
4334    #[cfg(feature = "canvas")]
4335    pub fn render_png_raster_with_size(self, w: u32, h: u32) -> Result<Vec<u8>, GGError> {
4336        let (built, layout) = self.prepare(w, h)?;
4337        let mut backend =
4338            crate::render::pixel_backend::PixelBackend::new(w, h, layout.plot_area.clone());
4339        PlotRenderer::render(&built, &mut backend).map_err(GGError::Render)?;
4340        backend.into_png().map_err(GGError::Render)
4341    }
4342
4343    /// Render the plot to in-memory PNG bytes (default 800x600).
4344    ///
4345    /// Returns a fully-encoded PNG, ready to write to an HTTP response or
4346    /// embed as a data URI — no temp files involved. Requires the `plotters`
4347    /// feature (on by default); see also `render_png_raster_with_size`
4348    /// (feature `canvas`).
4349    #[cfg(all(feature = "plotters", not(target_arch = "wasm32")))]
4350    pub fn render_png(self) -> Result<Vec<u8>, GGError> {
4351        self.render_png_with_size(800, 600)
4352    }
4353
4354    /// Render the plot to in-memory PNG bytes with custom dimensions. (Feature
4355    /// `plotters`; native only — PNG needs the plotters bitmap backend.)
4356    #[cfg(all(feature = "plotters", not(target_arch = "wasm32")))]
4357    pub fn render_png_with_size(self, w: u32, h: u32) -> Result<Vec<u8>, GGError> {
4358        let (built, layout) = self.prepare(w, h)?;
4359
4360        // plotters' BitMapBackend draws into a raw RGB buffer; we then encode
4361        // that buffer to PNG via the `image` crate.
4362        let mut rgb = vec![0u8; (w as usize) * (h as usize) * 3];
4363        {
4364            let backend = plotters::prelude::BitMapBackend::with_buffer(&mut rgb, (w, h));
4365            Self::render_into(backend.into_drawing_area(), &built, &layout)?;
4366        }
4367
4368        let img = image::RgbImage::from_raw(w, h, rgb).ok_or_else(|| {
4369            GGError::Render(RenderError::BackendError(
4370                "PNG buffer size mismatch".to_string(),
4371            ))
4372        })?;
4373        let mut out = std::io::Cursor::new(Vec::new());
4374        img.write_to(&mut out, image::ImageOutputFormat::Png)
4375            .map_err(|e| GGError::Render(RenderError::BackendError(format!("{:?}", e))))?;
4376        Ok(out.into_inner())
4377    }
4378
4379    /// Shared pipeline: build the plot, apply label overrides, compute layout.
4380    pub(crate) fn prepare(
4381        self,
4382        w: u32,
4383        h: u32,
4384    ) -> Result<(crate::build::BuiltPlot, PlotLayout), GGError> {
4385        let (mut built, meta) = self.build_for_render()?;
4386        let layout = Self::layout_built(&mut built, &meta, w, h);
4387        Ok((built, layout))
4388    }
4389
4390    /// The size-independent half of [`prepare`](Self::prepare): build the plot,
4391    /// resolve theme inheritance and apply axis-label overrides.
4392    pub(crate) fn build_for_render(self) -> Result<(crate::build::BuiltPlot, RenderMeta), GGError> {
4393        let plot = self;
4394
4395        let has_title = plot.labels.title.is_some();
4396        let has_subtitle = plot.labels.subtitle.is_some();
4397        let has_caption = plot.labels.caption.is_some();
4398        let has_legend = plot.has_legend_mapping();
4399        let legend_bottom_share = plot.legend_bottom_share;
4400        let legend_auto = plot.legend_auto;
4401        let x_label = plot.labels.x.clone();
4402        let y_label = plot.labels.y.clone();
4403        let guide_titles = plot.guide_legend.titles.clone();
4404
4405        let mut built = PlotBuilder::build(plot)?;
4406
4407        // #409: per-aesthetic legend titles name their scales.
4408        for (aes, title) in &guide_titles {
4409            if let Some(s) = built.scales.get_mut(aes) {
4410                s.set_name(title);
4411            }
4412        }
4413
4414        // Resolve R-style theme inheritance (root `text` → child text elements).
4415        built.theme.resolve_inheritance();
4416
4417        // #411: a fixed-aspect coord (coord_fixed / coord_sf / coord_map /
4418        // coord_quickmap) shrinks the single panel to its aspect — the
4419        // background, grid and axes hug the data, as in ggplot2 — instead
4420        // of letterboxing the data inside a full-size panel.
4421        if built.facet.is_none() && built.theme.aspect_ratio.is_none() {
4422            if let Some(a) = built
4423                .coord
4424                .fixed_aspect()
4425                .filter(|a| a.is_finite() && *a > 0.0)
4426            {
4427                built.theme.aspect_ratio = Some(a);
4428            }
4429        }
4430
4431        // Apply user label overrides to scales
4432        if let Some(ref label) = x_label {
4433            if let Some(s) = built.scales.get_mut(&crate::aes::Aesthetic::X) {
4434                s.set_name(label);
4435            }
4436        }
4437        if let Some(ref label) = y_label {
4438            if let Some(s) = built.scales.get_mut(&crate::aes::Aesthetic::Y) {
4439                s.set_name(label);
4440            }
4441        }
4442
4443        Ok((
4444            built,
4445            RenderMeta {
4446                has_title,
4447                has_subtitle,
4448                has_caption,
4449                has_legend,
4450                legend_bottom_share,
4451                legend_auto,
4452            },
4453        ))
4454    }
4455
4456    /// The size-dependent half of [`prepare`](Self::prepare): auto-tune axis
4457    /// labels for `w`×`h` and compute the layout.
4458    pub(crate) fn layout_built(
4459        built: &mut crate::build::BuiltPlot,
4460        meta: &RenderMeta,
4461        w: u32,
4462        h: u32,
4463    ) -> PlotLayout {
4464        match meta.legend_auto {
4465            Some(auto) if meta.has_legend && built.panels.is_empty() => {
4466                Self::layout_legend_auto(built, meta, auto, w, h)
4467            }
4468            _ => Self::layout_built_inner(built, meta, w, h),
4469        }
4470    }
4471
4472    /// #332: try each candidate corner for an inside legend against the
4473    /// marks a dry render draws; fall back outside when none is free.
4474    fn layout_legend_auto(
4475        built: &mut crate::build::BuiltPlot,
4476        meta: &RenderMeta,
4477        auto: LegendAuto,
4478        w: u32,
4479        h: u32,
4480    ) -> PlotLayout {
4481        use crate::render::probe::Probe;
4482        use crate::theme::LegendPosition as P;
4483        let total = crate::render::Rect {
4484            x: 0.0,
4485            y: 0.0,
4486            width: w as f64,
4487            height: h as f64,
4488        };
4489        // `layout_built_inner` may rotate axis labels and push warnings:
4490        // every trial starts from the same state.
4491        let (theme0, n_warnings) = (built.theme.clone(), built.warnings.len());
4492        let reset = |built: &mut crate::build::BuiltPlot, pos: P| {
4493            built.theme = theme0.clone();
4494            built.theme.legend_position = pos;
4495            built.warnings.truncate(n_warnings);
4496        };
4497
4498        reset(built, P::Inside(1.0, 0.0));
4499        let layout = Self::layout_built_inner(built, meta, w, h);
4500        let pa = layout.plot_area.clone();
4501        let mut marks = Probe::new(pa.clone(), total.clone());
4502        for layer in built.layers.iter().filter(|l| l.data.nrows() > 0) {
4503            let _ = layer.geom.draw(
4504                &layer.data,
4505                built.coord.as_ref(),
4506                &built.scales,
4507                &built.theme,
4508                &mut marks,
4509            );
4510        }
4511        for corner in auto.corners.iter().flatten() {
4512            built.theme.legend_position = corner.inside();
4513            let mut legend = Probe::new(pa.clone(), total.clone());
4514            let drawn = crate::guide::legend::draw_legend_planned(
4515                &built.scales,
4516                &built.theme,
4517                &pa,
4518                pa.width,
4519                &mut legend,
4520                &built.guide_legend,
4521                &built.suppressed_aes,
4522                &built.legend_plans,
4523            );
4524            let Some((x0, y0, x1, y1)) = legend.extent().filter(|_| drawn.is_ok()) else {
4525                continue;
4526            };
4527            const PAD: f64 = 2.0;
4528            if !marks.hits((x0 - PAD, y0 - PAD, x1 + PAD, y1 + PAD)) {
4529                built.legend_at = Some(format!("inside-{}", corner.name()));
4530                return layout;
4531            }
4532        }
4533
4534        // Outside: the given side, else whichever of Top/Right leaves the
4535        // data area (a fixed-aspect map's letterboxed rect) larger.
4536        let sides = match auto.fallback {
4537            Some(side) => vec![side],
4538            None => vec![LegendSide::Top, LegendSide::Right],
4539        };
4540        let data_area = |built: &crate::build::BuiltPlot, layout: &PlotLayout| {
4541            let r = built
4542                .coord
4543                .data_rect(&layout.plot_area)
4544                .unwrap_or_else(|| layout.plot_area.clone());
4545            r.width.max(0.0) * r.height.max(0.0)
4546        };
4547        let mut best = (sides[0], f64::MIN);
4548        for &side in &sides {
4549            reset(built, side.position());
4550            let trial = Self::layout_built_inner(built, meta, w, h);
4551            let area = data_area(built, &trial);
4552            if area > best.1 {
4553                best = (side, area);
4554            }
4555        }
4556        reset(built, best.0.position());
4557        let layout = Self::layout_built_inner(built, meta, w, h);
4558        built.legend_at = Some(
4559            match built.theme.legend_position {
4560                P::Top => "top",
4561                P::Right => "right",
4562                P::Bottom => "bottom",
4563                _ => "left",
4564            }
4565            .to_string(),
4566        );
4567        layout
4568    }
4569
4570    fn layout_built_inner(
4571        built: &mut crate::build::BuiltPlot,
4572        meta: &RenderMeta,
4573        w: u32,
4574        h: u32,
4575    ) -> PlotLayout {
4576        let RenderMeta {
4577            has_title,
4578            has_subtitle,
4579            has_caption,
4580            has_legend,
4581            legend_bottom_share,
4582            legend_auto: _,
4583        } = *meta;
4584        let x_axis_top = built
4585            .scales
4586            .get(&crate::aes::Aesthetic::X)
4587            .map(|s| s.axis_position_opposite())
4588            .unwrap_or(false);
4589
4590        // #287: decided first, so every legend pass below plans for the
4591        // position actually drawn, on this render's own layout.
4592        if let Some(share) = legend_bottom_share {
4593            if has_legend
4594                && matches!(
4595                    built.theme.legend_position,
4596                    crate::theme::LegendPosition::Right
4597                )
4598            {
4599                let panel_width = |pos| {
4600                    let mut theme = built.theme.clone();
4601                    theme.legend_position = pos;
4602                    PlotLayout::compute_with_extras(
4603                        w as f64,
4604                        h as f64,
4605                        &theme,
4606                        has_title,
4607                        has_subtitle,
4608                        has_caption,
4609                        has_legend,
4610                        x_axis_top,
4611                        None,
4612                        None,
4613                        crate::render::layout::LayoutExtras::default(),
4614                    )
4615                    .plot_area
4616                    .width
4617                };
4618                // Against the panel it would have with no legend at all
4619                // (the clamped minimum panel width hides the real band).
4620                let free = panel_width(crate::theme::LegendPosition::None);
4621                if panel_width(crate::theme::LegendPosition::Right) < (1.0 - share) * free {
4622                    built.theme.legend_position = crate::theme::LegendPosition::Bottom;
4623                }
4624            }
4625        }
4626
4627        // Auto-tune categorical axis labels so they stay legible. A flip swaps
4628        // which trained scale sits on each visual axis: the bottom (x) axis reads
4629        // the Y scale when flipped, the left (y) axis reads the X scale.
4630        let flipped = built.coord.is_flipped();
4631        let h_aes = if flipped {
4632            crate::aes::Aesthetic::Y
4633        } else {
4634            crate::aes::Aesthetic::X
4635        };
4636        let v_aes = if flipped {
4637            crate::aes::Aesthetic::X
4638        } else {
4639            crate::aes::Aesthetic::Y
4640        };
4641
4642        // The bottom-axis break labels that are actually drawn — a
4643        // `with_labels` override can blank some entries ("" ) to thin a
4644        // crowded discrete axis while keeping every position (sipemu/
4645        // ggplot-rs#162); a blanked entry is never drawn, so it never
4646        // competes for horizontal room or vertical drop below. Shared by
4647        // the auto-rotate decision and the rotated-label height reservation
4648        // just below, so both agree on what's actually on the canvas.
4649        let drawn_x_labels: Vec<(f64, String)> = built
4650            .scales
4651            .get(&h_aes)
4652            .map(|hs| {
4653                hs.breaks()
4654                    .into_iter()
4655                    .filter(|(_, label)| !label.is_empty())
4656                    .collect()
4657            })
4658            .unwrap_or_default();
4659        let h_is_discrete = built
4660            .scales
4661            .get(&h_aes)
4662            .map(|hs| hs.is_discrete())
4663            .unwrap_or(false);
4664
4665        // Crowded discrete bottom-axis labels overlap when drawn flat. Rotate
4666        // them — −45° if that de-collides, otherwise vertical (270°) — only
4667        // when no explicit angle was set (angle.abs() < 1.0 reads as
4668        // "unset"). Sized from the real, measured widths of the labels that
4669        // are actually drawn (`render::text::text_width`), both for the
4670        // label-width estimate and the crowding count `n` — #162's bug was
4671        // counting every break, blanked or not, which over-triggered the
4672        // rotation. −45° (not +45°) is ggplot2's own idiom (`angle = 45,
4673        // hjust = 1`, mirrored to this library's angle sign, sipemu/
4674        // ggplot-rs#163): it ends each label at its own tick and slants
4675        // down-left, rather than +45°'s down-right slant, which — anchored
4676        // the same way — would start under the *previous* category.
4677        if built.theme.axis_text_x.visible
4678            && built.theme.axis_text_x.angle.abs() < 1.0
4679            && h_is_discrete
4680            && !drawn_x_labels.is_empty()
4681        {
4682            let font = built.theme.axis_text_x.size;
4683            let max_w = drawn_x_labels
4684                .iter()
4685                .map(|(_, l)| crate::render::text::text_width(l, font))
4686                .fold(0.0_f64, f64::max);
4687            let n = drawn_x_labels.len() as f64;
4688            let spacing = 0.9 * w as f64 / n; // ~px between adjacent *drawn* labels
4689            if max_w > spacing {
4690                // 45° shrinks the horizontal footprint by cos45; if that
4691                // still doesn't fit, go vertical (270° = reads bottom-to-
4692                // top, hanging below the axis like the y-axis title).
4693                let vertical = max_w * std::f64::consts::FRAC_1_SQRT_2 > spacing;
4694                built.theme.axis_text_x.angle = if vertical { 270.0 } else { -45.0 };
4695            }
4696        }
4697
4698        // Whenever the bottom-axis labels end up rotated — by the
4699        // auto-rotate decision just above, or by an explicit
4700        // `axis_text_x_angle` call — reserve the real bottom space their
4701        // rotated bounding boxes need, so they never overlap the panel
4702        // (#162 also asked for this: an explicit angle reserved nothing).
4703        // `rotated_label_rise_fall` is the same helper `draw_x_axis` calls
4704        // to place its rotation anchor — `rise` must be reserved (as well
4705        // as placed) or a long enough label's far end swings right back up
4706        // over the panel regardless of how much height is reserved here;
4707        // `fall` just needs reserving so the label stays on-canvas. Both
4708        // here and in `draw_x_axis`, the anchor fraction must come from
4709        // `rotated_anchor_frac(angle, dir)` (sipemu/ggplot-rs#163) — not a
4710        // blind `1.0` — or the reservation is sized for the wrong end of
4711        // the label; `dir` matches `draw_x_axis`'s own tick direction.
4712        let x_label_height = if built.theme.axis_text_x.visible
4713            && built.theme.axis_text_x.angle.abs() > 10.0
4714            && !drawn_x_labels.is_empty()
4715        {
4716            let font = built.theme.axis_text_x.size;
4717            let angle = built.theme.axis_text_x.angle;
4718            let dir = if x_axis_top { -1.0 } else { 1.0 };
4719            let (rise, fall) = crate::render::text::rotated_label_rise_fall(
4720                drawn_x_labels.iter().map(|(_, l)| l.as_str()),
4721                font,
4722                angle,
4723                crate::render::text::rotated_anchor_frac(angle, dir),
4724            );
4725            Some(built.theme.legend_spacing / 2.0 + font / 2.0 + rise + fall)
4726        } else {
4727            None
4728        };
4729
4730        // Reserve the left-axis width from the actual label lengths so long
4731        // category names (a flipped bar chart's rows) aren't clipped. Never
4732        // shrinks below the default; grows up to 35% of the width.
4733        let y_label_width = if built.theme.axis_text_y.visible {
4734            built.scales.get(&v_aes).map(|vs| {
4735                crate::guide::axis::y_label_reserve(&built.theme, &vs.breaks()).min(0.35 * w as f64)
4736            })
4737        } else {
4738            None
4739        };
4740
4741        // B3-1 (D6): reserve just enough left/right panel margin to keep
4742        // the outer x-axis labels on-canvas under `AxisLabelFit::Reserve`,
4743        // for a single (non-faceted) panel on a coord that supports it
4744        // (not polar/radar/sf, where a label bbox doesn't map to a simple
4745        // panel margin). A faceted plot skips the layout-level reservation
4746        // and relies on `draw_x_axis`'s own draw-time shift instead — so
4747        // does `AxisLabelFit::Shift` (the panel must keep its size) and
4748        // `Clip` (the 0.21 behaviour, no correction at all). A plot whose
4749        // outer labels already fit never grows `extras`, so it stays
4750        // byte-identical.
4751        // #409: legends moved to another side (`guide_position`) reserve
4752        // that side's band too.
4753        let mut extras = crate::render::layout::LayoutExtras {
4754            extra_legend_sides: crate::guide::legend::extra_legend_sides(
4755                &built.scales,
4756                &built.suppressed_aes,
4757                &built.guide_legend,
4758                &built.theme.legend_position,
4759            ),
4760            ..Default::default()
4761        };
4762        // A secondary y axis draws its ticks, labels and title right of the
4763        // panel: reserve that width so they stay on-canvas (and clear of a
4764        // right-hand legend).
4765        if let (Some(vs), Some(sec)) = (
4766            built.scales.get(&crate::aes::Aesthetic::Y),
4767            built.scales.sec_axis(&crate::aes::Aesthetic::Y),
4768        ) {
4769            let theme = &built.theme;
4770            let max_ch = match sec.own_ticks(vs) {
4771                Some(t) => t.iter().map(|(_, l)| l.chars().count()).max(),
4772                None => vs.breaks().iter().map(|(_, l)| l.chars().count() + 2).max(),
4773            }
4774            .unwrap_or(0);
4775            let text = if theme.axis_text_y.visible {
4776                (max_ch as f64 * theme.axis_text_y.size * 0.6).max(theme.axis_text_y.size * 3.5)
4777            } else {
4778                0.0
4779            };
4780            let title = if !sec.name.is_empty() && theme.axis_title_y.visible {
4781                theme.axis_title_y.size * 1.5
4782            } else {
4783                0.0
4784            };
4785            extras.right += theme.axis_ticks_length + theme.legend_spacing + text + title;
4786        }
4787        // …and a secondary x axis its height above the panel (#409).
4788        if let Some(sec) = built
4789            .scales
4790            .get(&h_aes)
4791            .filter(|s| !s.axis_position_opposite())
4792            .and_then(|s| s.sec_axis())
4793        {
4794            extras.top += crate::guide::axis::sec_x_axis_height(&built.theme, sec);
4795        }
4796        let can_reserve = built.facet.is_none()
4797            && built.theme.axis_text_x.visible
4798            && built.theme.axis_text_x_fit == crate::theme::AxisLabelFit::Reserve
4799            && !built.coord.is_polar()
4800            && built.coord.supports_axis_reserve();
4801        if can_reserve {
4802            if let Some(hs) = built.scales.get(&h_aes) {
4803                let breaks = hs.breaks();
4804                if let (Some(first), Some(last)) = (breaks.first(), breaks.last()) {
4805                    let angle = built.theme.axis_text_x.angle;
4806                    let anchor = if angle.abs() > 10.0 {
4807                        let dir = if x_axis_top { -1.0 } else { 1.0 };
4808                        crate::render::text::rotated_anchor_frac(angle, dir)
4809                    } else {
4810                        0.5
4811                    };
4812                    let size = built.theme.axis_text_x.size;
4813                    const EDGE: f64 = 1.0;
4814                    for _ in 0..2 {
4815                        let layout = PlotLayout::compute_with_extras(
4816                            w as f64,
4817                            h as f64,
4818                            &built.theme,
4819                            has_title,
4820                            has_subtitle,
4821                            has_caption,
4822                            has_legend,
4823                            x_axis_top,
4824                            y_label_width,
4825                            x_label_height,
4826                            extras,
4827                        );
4828                        let area = &layout.plot_area;
4829                        let edge_y = area.y + area.height;
4830                        let to_point = |pos: f64| if flipped { (0.0, pos) } else { (pos, 0.0) };
4831                        let px_first = built.coord.transform(to_point(first.0), area).0;
4832                        let px_last = built.coord.transform(to_point(last.0), area).0;
4833                        let bbox_first = crate::render::text::label_bbox(
4834                            &first.1, size, px_first, edge_y, angle, anchor,
4835                        );
4836                        let bbox_last = crate::render::text::label_bbox(
4837                            &last.1, size, px_last, edge_y, angle, anchor,
4838                        );
4839                        let deficit_left = (EDGE - bbox_first[0]).max(0.0);
4840                        let deficit_right = (bbox_last[2] - (w as f64 - EDGE)).max(0.0);
4841                        if deficit_left <= 0.0 && deficit_right <= 0.0 {
4842                            break;
4843                        }
4844                        // Growing the left margin by `eL` (holding `eR` fixed)
4845                        // shifts a label at normalized position `p` right by
4846                        // `eL·(1 − p)`; growing the right margin by `eR`
4847                        // shifts it left by `eR·p` (the panel maps [0, 1] to
4848                        // [plot_x + eL, plot_x + width − eR]). Review #158
4849                        // finding 1: the original code divided by `p_first`
4850                        // and `1 − p_last` — inverted, which over-reserved
4851                        // by about `1/p_last` (≈20× at a typical p_last≈0.95).
4852                        let (p_first, p_last) = (first.0, last.0);
4853                        if deficit_left > 0.0 && deficit_right > 0.0 {
4854                            // Both ends overflow: solve the 2×2 system so
4855                            // each margin accounts for its effect on *both*
4856                            // labels, rather than correcting them
4857                            // independently and overshooting.
4858                            let (a, b, c, d) = (1.0 - p_first, p_first, 1.0 - p_last, p_last);
4859                            let det = a * d - b * c;
4860                            if det.abs() > 0.01 {
4861                                extras.left +=
4862                                    ((deficit_left * d + deficit_right * b) / det).max(0.0);
4863                                extras.right +=
4864                                    ((deficit_right * a + deficit_left * c) / det).max(0.0);
4865                            } else {
4866                                // Degenerate (first and last break nearly
4867                                // coincide in normalized space): fall back
4868                                // to the independent, clamped formulas.
4869                                extras.left += deficit_left / (1.0 - p_first).max(0.05);
4870                                extras.right += deficit_right / p_last.max(0.05);
4871                            }
4872                        } else if deficit_left > 0.0 {
4873                            extras.left += deficit_left / (1.0 - p_first).max(0.05);
4874                        } else if deficit_right > 0.0 {
4875                            extras.right += deficit_right / p_last.max(0.05);
4876                        }
4877                        // Cap the total reservation so the panel can never
4878                        // start beyond the canvas or be squeezed past the
4879                        // same minimum width `compute_with_extras` already
4880                        // floors it to (review #158's second re-review,
4881                        // LOW): a single, centred break whose label is
4882                        // wider than the whole canvas has `p_first ==
4883                        // p_last == 0.5`, so both `eL` and `eR` grow by
4884                        // roughly the same (large) deficit independently,
4885                        // and together they could place `plot_x` past `w`
4886                        // entirely. Past this cap, Reserve simply stops
4887                        // growing and `draw_x_axis`'s own draw-time shift
4888                        // — which always runs for Reserve, the same as for
4889                        // `Shift` — takes over for whatever's still
4890                        // outside, exactly as D6 already documents.
4891                        const MIN_PANEL_WIDTH: f64 = 50.0;
4892                        let cap = (w as f64 - MIN_PANEL_WIDTH).max(0.0);
4893                        if extras.left + extras.right > cap {
4894                            let scale = if extras.left + extras.right > 0.0 {
4895                                cap / (extras.left + extras.right)
4896                            } else {
4897                                0.0
4898                            };
4899                            extras.left *= scale;
4900                            extras.right *= scale;
4901                        }
4902                    }
4903                }
4904            }
4905        }
4906
4907        // B3-2 (D7): a discrete legend's `nrow`/`ncol` grid, or an
4908        // overflowing horizontal legend's auto-wrap, reserves extra legend
4909        // rows/width. Several simultaneous discrete guides (e.g. `colour`
4910        // and `shape` both mapped to discrete variables) each get their
4911        // own independent plan (sipemu/ggplot-rs#155) — previously this
4912        // only ran for exactly one discrete guide.
4913        if has_legend
4914            && !matches!(
4915                built.theme.legend_position,
4916                crate::theme::LegendPosition::None
4917            )
4918        {
4919            let legend_aes =
4920                crate::guide::legend::legend_scales_for(&built.scales, &built.suppressed_aes);
4921            // #236: a mapped `size` legend's largest key can need a bigger
4922            // box than the theme's flat defaults — independent of B3-2's
4923            // discrete-guide wrap planning below, so computed unconditionally.
4924            extras.legend_max_key_size = legend_aes
4925                .iter()
4926                .filter_map(|aes| {
4927                    let scale = built.scales.get(aes)?;
4928                    let breaks = scale.breaks();
4929                    Some(crate::guide::legend::size_key_diameter(
4930                        aes,
4931                        &built.scales,
4932                        scale,
4933                        &breaks,
4934                    ))
4935                })
4936                .fold(0.0_f64, f64::max);
4937            // #545: a continuous, non-colourbar guide (`Size`/
4938            // `Linewidth`/… sampled breaks) needs the same B3-2 wrap/grid
4939            // reservation pass a discrete guide gets — only a true
4940            // colourbar (continuous `Color`/`Fill`) is exempt, since it
4941            // draws as a fixed-width bar instead of sampled keys.
4942            // Previously this gate only fired for a discrete guide, so a
4943            // plot mapping *only* a continuous `Size`/`Linewidth` (no
4944            // discrete guide alongside it) skipped the whole reservation
4945            // pass and its legend could run straight off the panel/canvas
4946            // with no wrap, shrink, or "+N more" to catch it.
4947            let has_wrap_guide = legend_aes.iter().any(|a| {
4948                let is_discrete = built
4949                    .scales
4950                    .get(a)
4951                    .map(|s| s.is_discrete())
4952                    .unwrap_or(false);
4953                is_discrete || !crate::guide::legend::is_colorbar_aesthetic(a)
4954            });
4955            if has_wrap_guide {
4956                let layout0 = PlotLayout::compute_with_extras(
4957                    w as f64,
4958                    h as f64,
4959                    &built.theme,
4960                    has_title,
4961                    has_subtitle,
4962                    has_caption,
4963                    has_legend,
4964                    x_axis_top,
4965                    y_label_width,
4966                    x_label_height,
4967                    extras,
4968                );
4969                // `y` is unused here (only `legend_x0`/`is_horizontal` feed
4970                // the avail-width check below), so #233's `Top` row-count
4971                // parameter can't matter — `extras.legend_extra_rows` isn't
4972                // even known yet at this point in the pass. Likewise #225's
4973                // `Inside` justification only matters when `legend_x0` is
4974                // actually used for an `Inside` position combined with an
4975                // explicit `nrow`/`ncol` grid (`Inside` otherwise never
4976                // reaches this discrete-guide-wrap check), too rare to
4977                // bother estimating a real size hint for here.
4978                let (legend_x0, _, mut is_horizontal) = crate::guide::legend::legend_position(
4979                    &built.theme,
4980                    &layout0.plot_area,
4981                    extras.legend_extra_rows,
4982                    (0.0, 0.0),
4983                    0.0,
4984                    &layout0.total,
4985                );
4986                if let Some(dir) = built.theme.legend_direction {
4987                    is_horizontal = matches!(dir, crate::theme::LegendDirection::Horizontal);
4988                }
4989                let avail_width =
4990                    (layout0.plot_area.x + layout0.plot_area.width - legend_x0).max(0.0);
4991                let mut legend_warnings = Vec::new();
4992                let mut plans = crate::guide::legend::plan_all_legends(
4993                    &built.scales,
4994                    &built.guide_legend,
4995                    &built.theme,
4996                    &built.suppressed_aes,
4997                    avail_width,
4998                    layout0.plot_area.height,
4999                    is_horizontal,
5000                    &mut legend_warnings,
5001                );
5002                built.warnings.extend(legend_warnings);
5003
5004                if !plans.is_empty() {
5005                    // Guides laid out side by side (a horizontal legend
5006                    // position) share one row band — its height is set
5007                    // by whichever guide wrapped to the most rows.
5008                    // Guides stacked vertically each add their own full
5009                    // height, so their extra rows sum.
5010                    extras.legend_extra_rows = if is_horizontal {
5011                        plans
5012                            .values()
5013                            .map(|p| p.rows.saturating_sub(1))
5014                            .max()
5015                            .unwrap_or(0)
5016                    } else {
5017                        plans.values().map(|p| p.rows.saturating_sub(1)).sum()
5018                    };
5019                    // Review #158 finding 3: a vertical explicit grid
5020                    // with ncol > 1 needs extra legend band width for
5021                    // its later columns, or they run off the canvas
5022                    // (cols is always 1, col_pitch 0, for the horizontal
5023                    // auto-wrap path, so this is a no-op there). Several
5024                    // guides take the widest requirement among them.
5025                    extras.legend_extra_width = plans
5026                        .values()
5027                        .map(|p| p.cols.saturating_sub(1) as f64 * p.col_pitch)
5028                        .fold(0.0_f64, f64::max);
5029
5030                    // #159: a vertical multi-column grid that still runs
5031                    // off the canvas even after reserving the extra width
5032                    // above now (1) tries reducing its column count
5033                    // toward 1 — the first count that fits wins; (2) if
5034                    // even one column still overflows, truncates labels
5035                    // with an ellipsis (keeping the full label available
5036                    // via a `<title>`); and only (3) warns if it still
5037                    // doesn't fit after both. A grid that already fits is
5038                    // untouched — no reduction, no truncation, no warning
5039                    // (review #158's second re-review, LOW, is the
5040                    // original "warn only" version of this check).
5041                    if !is_horizontal {
5042                        let final_layout = PlotLayout::compute_with_extras(
5043                            w as f64,
5044                            h as f64,
5045                            &built.theme,
5046                            has_title,
5047                            has_subtitle,
5048                            has_caption,
5049                            has_legend,
5050                            x_axis_top,
5051                            y_label_width,
5052                            x_label_height,
5053                            extras,
5054                        );
5055                        // `y` is unused here too, but `extras` is final by
5056                        // this point, so pass its real `legend_extra_rows`
5057                        // for consistency with #233's `Top` formula. #225's
5058                        // `Inside` size hint is skipped here for the same
5059                        // reason as the pass above.
5060                        let (legend_x_final, _, _) = crate::guide::legend::legend_position(
5061                            &built.theme,
5062                            &final_layout.plot_area,
5063                            extras.legend_extra_rows,
5064                            (0.0, 0.0),
5065                            0.0,
5066                            &final_layout.total,
5067                        );
5068                        let swatch_and_gap =
5069                            built.theme.legend_key_width + built.theme.legend_spacing;
5070                        let text_size = built.theme.legend_text.size;
5071                        // A plain fn, not a closure: #168's retightening
5072                        // pass below re-runs this same check at a second
5073                        // `legend_x` (the narrower reservation's), so
5074                        // `legend_x` has to be a parameter rather than
5075                        // captured.
5076                        fn right_edge_of(
5077                            legend_x: f64,
5078                            swatch_and_gap: f64,
5079                            text_size: f64,
5080                            plan: &crate::guide::legend::LegendPlan,
5081                            breaks: &[(f64, String)],
5082                        ) -> f64 {
5083                            plan.entries
5084                                .iter()
5085                                .filter_map(|e| breaks.get(e.break_idx).map(|(_, l)| (e, l)))
5086                                .map(|(e, label)| {
5087                                    let text =
5088                                        e.label_override.as_deref().unwrap_or(label.as_str());
5089                                    legend_x
5090                                        + e.x_offset
5091                                        + swatch_and_gap
5092                                        + crate::render::text::text_width(text, text_size)
5093                                })
5094                                .fold(0.0_f64, f64::max)
5095                        }
5096
5097                        let mut updates: Vec<(
5098                            crate::aes::Aesthetic,
5099                            crate::guide::legend::LegendPlan,
5100                        )> = Vec::new();
5101                        // #168: guides narrowed below by *column
5102                        // reduction* (not label truncation) are
5103                        // candidates for the retightening pass after this
5104                        // loop — a truncated guide already picked the
5105                        // narrowest column count, so there's no reserved
5106                        // width left to give back.
5107                        let mut reduced_aes: Vec<crate::aes::Aesthetic> = Vec::new();
5108                        for (aes, plan) in &plans {
5109                            if plan.cols <= 1 {
5110                                continue;
5111                            }
5112                            let Some(scale) = built.scales.get(aes) else {
5113                                continue;
5114                            };
5115                            let breaks = scale.breaks();
5116                            if right_edge_of(
5117                                legend_x_final,
5118                                swatch_and_gap,
5119                                text_size,
5120                                plan,
5121                                &breaks,
5122                            ) <= w as f64
5123                            {
5124                                continue; // already fits — untouched.
5125                            }
5126
5127                            // Step 1: reduce columns toward 1; the first
5128                            // count that fits wins.
5129                            let n = breaks.len();
5130                            let mut reduced: Vec<crate::guide::legend::LegendPlan> = Vec::new();
5131                            for c in (1..plan.cols).rev() {
5132                                let mut candidate_guide = built.guide_legend.clone();
5133                                candidate_guide.ncol = Some(c);
5134                                candidate_guide.nrow = None;
5135                                let mut tmp_warnings = Vec::new();
5136                                let candidate_guide =
5137                                    candidate_guide.validated(n, &mut tmp_warnings);
5138                                if let Some(cand) = crate::guide::legend::plan_legend(
5139                                    &breaks,
5140                                    &candidate_guide,
5141                                    &built.theme,
5142                                    avail_width,
5143                                    layout0.plot_area.height,
5144                                    false,
5145                                ) {
5146                                    reduced.push(cand);
5147                                }
5148                            }
5149                            if let Some(fits) = reduced.iter().find(|c| {
5150                                right_edge_of(legend_x_final, swatch_and_gap, text_size, c, &breaks)
5151                                    <= w as f64
5152                            }) {
5153                                extras.legend_extra_rows =
5154                                    extras.legend_extra_rows.max(fits.rows.saturating_sub(1));
5155                                updates.push((aes.clone(), fits.clone()));
5156                                reduced_aes.push(aes.clone());
5157                                continue;
5158                            }
5159
5160                            // Step 2: no column count fit — truncate
5161                            // labels (starting from the narrowest, 1-
5162                            // column, candidate) with an ellipsis, each
5163                            // to whatever room its own position leaves.
5164                            let base = reduced.last().cloned().unwrap_or_else(|| plan.clone());
5165                            extras.legend_extra_rows =
5166                                extras.legend_extra_rows.max(base.rows.saturating_sub(1));
5167                            let mut truncated = base.clone();
5168                            for e in &mut truncated.entries {
5169                                let Some((_, label)) = breaks.get(e.break_idx) else {
5170                                    continue;
5171                                };
5172                                let avail = (w as f64
5173                                    - (legend_x_final + e.x_offset + swatch_and_gap))
5174                                    .max(0.0);
5175                                if let Some(short) =
5176                                    crate::render::text::truncate_to_width(label, avail, text_size)
5177                                {
5178                                    e.label_override = Some(short);
5179                                }
5180                            }
5181
5182                            // Step 3: only warn if it still doesn't fit
5183                            // after both the reduction and the
5184                            // truncation above.
5185                            let truncated_edge = right_edge_of(
5186                                legend_x_final,
5187                                swatch_and_gap,
5188                                text_size,
5189                                &truncated,
5190                                &breaks,
5191                            );
5192                            if truncated_edge > w as f64 {
5193                                built.warnings.push(format!(
5194                                    "legend: the {}-column grid still doesn't fit the {w}px chart (needs about {} px)",
5195                                    truncated.cols,
5196                                    truncated_edge.ceil() as i64
5197                                ));
5198                            }
5199                            updates.push((aes.clone(), truncated));
5200                        }
5201                        for (aes, new_plan) in &updates {
5202                            plans.insert(aes.clone(), new_plan.clone());
5203                        }
5204
5205                        // #168: `extras.legend_extra_width` above still
5206                        // reserves the *original*, pre-reduction column
5207                        // count's width — that stale, wider reservation
5208                        // is an accident of ordering (reserve → layout →
5209                        // fit? → reduce), not a requirement, so the panel
5210                        // stays narrower than it needs to be once a guide
5211                        // above got fewer columns.
5212                        //
5213                        // Tighten it to the reduced guides' new column
5214                        // counts and re-check fit once more at the
5215                        // narrower reservation (a bounded retry, not an
5216                        // unbounded fixed point: a tighter reservation
5217                        // moves `legend_x_final` right — less headroom
5218                        // before the canvas edge — which could in
5219                        // principle call for yet another round of
5220                        // reduction; rather than chase reserve → layout →
5221                        // fit → reduce → reserve → … indefinitely, this
5222                        // keeps the tighter reservation only if every
5223                        // guide still fits under it, and falls back to
5224                        // the wider, already-proven-safe reservation
5225                        // otherwise — so retightening can only ever
5226                        // help, never regress a plot that fit before
5227                        // this pass existed).
5228                        //
5229                        // Review #167 finding 1 on #171 (HIGH, fixed):
5230                        // the retry must re-check *every* plan in
5231                        // `plans`, not just `reduced_aes`. `legend_x`
5232                        // moves for every guide sharing the row, not
5233                        // only the one(s) that got reduced — a second
5234                        // guide that already fit at the old, wider
5235                        // `legend_x_final` (its own width is a fixed
5236                        // `6·text_size` estimate, unrelated to the
5237                        // reduced guide's labels) or whose labels were
5238                        // truncated against that old `legend_x_final`
5239                        // shifts right by the same amount and was never
5240                        // re-checked, so it could run off the canvas
5241                        // with no warning (the warnings were computed
5242                        // against the old x too). `reduced_aes` is kept
5243                        // only to gate *entering* this block at all —
5244                        // nothing to reclaim if nothing was reduced.
5245                        if !reduced_aes.is_empty() {
5246                            let original_reserve = extras.legend_extra_width;
5247                            let tighter_reserve = plans
5248                                .values()
5249                                .map(|p| p.cols.saturating_sub(1) as f64 * p.col_pitch)
5250                                .fold(0.0_f64, f64::max);
5251                            if tighter_reserve < original_reserve {
5252                                let mut retry_extras = extras;
5253                                retry_extras.legend_extra_width = tighter_reserve;
5254                                let retry_layout = PlotLayout::compute_with_extras(
5255                                    w as f64,
5256                                    h as f64,
5257                                    &built.theme,
5258                                    has_title,
5259                                    has_subtitle,
5260                                    has_caption,
5261                                    has_legend,
5262                                    x_axis_top,
5263                                    y_label_width,
5264                                    x_label_height,
5265                                    retry_extras,
5266                                );
5267                                // `y` is unused here too; `retry_extras`
5268                                // only tightened `legend_extra_width`, so
5269                                // its `legend_extra_rows` is still the same
5270                                // real value. #225's `Inside` size hint is
5271                                // skipped for the same reason as above.
5272                                let (retry_x, _, _) = crate::guide::legend::legend_position(
5273                                    &built.theme,
5274                                    &retry_layout.plot_area,
5275                                    retry_extras.legend_extra_rows,
5276                                    (0.0, 0.0),
5277                                    0.0,
5278                                    &retry_layout.total,
5279                                );
5280                                let all_still_fit = plans.iter().all(|(aes, plan)| {
5281                                    let Some(scale) = built.scales.get(aes) else {
5282                                        return true;
5283                                    };
5284                                    let breaks = scale.breaks();
5285                                    right_edge_of(retry_x, swatch_and_gap, text_size, plan, &breaks)
5286                                        <= w as f64
5287                                });
5288                                if all_still_fit {
5289                                    extras.legend_extra_width = tighter_reserve;
5290                                }
5291                            }
5292                        }
5293                    }
5294                    for (aes, plan) in &plans {
5295                        if let Some(ov) = &plan.overflow {
5296                            let n = built
5297                                .scales
5298                                .get(aes)
5299                                .map(|s| s.breaks().len())
5300                                .unwrap_or(ov.dropped);
5301                            let shown = n.saturating_sub(ov.dropped);
5302                            built.warnings.push(format!(
5303                                "legend: {} of {n} entries don't fit the chart; the legend shows {shown} and \"+{} more\"",
5304                                ov.dropped, ov.dropped
5305                            ));
5306                        }
5307                    }
5308                }
5309                // Review #158 finding 2: store these exact plans so
5310                // `draw_legend_planned` draws them verbatim instead of
5311                // recomputing against the (shorter) post-reservation
5312                // panel height, which could warn "+7 more" while
5313                // drawing "+15 more".
5314                built.legend_plans = plans;
5315            }
5316        }
5317
5318        // #409: a `guide_colourbar(bar_width)` wider than a legend key
5319        // widens a side legend's reserved band by the difference.
5320        if has_legend
5321            && matches!(
5322                built.theme.legend_position,
5323                crate::theme::LegendPosition::Left | crate::theme::LegendPosition::Right
5324            )
5325            && !matches!(
5326                built.theme.legend_direction,
5327                Some(crate::theme::LegendDirection::Horizontal)
5328            )
5329        {
5330            let key_w = built.theme.legend_key_width.max(extras.legend_max_key_size);
5331            let extra =
5332                crate::guide::legend::legend_scales_for(&built.scales, &built.suppressed_aes)
5333                    .iter()
5334                    .filter(|aes| {
5335                        crate::guide::legend::is_colorbar_aesthetic(aes)
5336                            && built.scales.get(aes).is_some_and(|s| !s.is_discrete())
5337                    })
5338                    .filter_map(|aes| built.guide_legend.colourbar_for(aes).bar_width)
5339                    .map(|w| (w - key_w).max(0.0))
5340                    .fold(0.0_f64, f64::max);
5341            extras.legend_extra_width = extras.legend_extra_width.max(extra);
5342            // #409: a custom guide wider than a key + its label column.
5343            let text_col = key_w + built.theme.legend_spacing + built.theme.legend_text.size * 6.0;
5344            let custom = built
5345                .guide_legend
5346                .custom
5347                .iter()
5348                .map(|g| (g.width - text_col).max(0.0))
5349                .fold(0.0_f64, f64::max);
5350            extras.legend_extra_width = extras.legend_extra_width.max(custom);
5351        }
5352
5353        // #211: a continuous colourbar at `Top`/`Bottom` (or
5354        // `legend_direction(Horizontal)`) draws wide × short instead of
5355        // its usual tall × narrow bar (see `draw_continuous_legend_at`),
5356        // with its tick labels below rather than beside it — reserve the
5357        // extra band height that needs beyond the flat discrete-row
5358        // height `legend_band` already assumes. Independent of the
5359        // `has_wrap_guide` block above (a plot can have a continuous
5360        // colourbar with no other guide at all), so computed
5361        // unconditionally here, right before the layout that actually
5362        // matters reads `extras`.
5363        if has_legend
5364            && !matches!(
5365                built.theme.legend_position,
5366                crate::theme::LegendPosition::None
5367            )
5368        {
5369            let is_horizontal = match built.theme.legend_direction {
5370                Some(dir) => matches!(dir, crate::theme::LegendDirection::Horizontal),
5371                None => matches!(
5372                    built.theme.legend_position,
5373                    crate::theme::LegendPosition::Top | crate::theme::LegendPosition::Bottom
5374                ),
5375            };
5376            if is_horizontal {
5377                let legend_aes =
5378                    crate::guide::legend::legend_scales_for(&built.scales, &built.suppressed_aes);
5379                let continuous_colourbar = legend_aes.iter().find_map(|aes| {
5380                    if !matches!(
5381                        aes,
5382                        crate::aes::Aesthetic::Color | crate::aes::Aesthetic::Fill
5383                    ) {
5384                        return None;
5385                    }
5386                    let scale = built.scales.get(aes)?;
5387                    if scale.is_discrete() {
5388                        return None;
5389                    }
5390                    Some(scale)
5391                });
5392                if let Some(scale) = continuous_colourbar {
5393                    let title = built
5394                        .guide_legend
5395                        .title
5396                        .as_deref()
5397                        .unwrap_or_else(|| scale.name());
5398                    let bar_height = crate::guide::legend::continuous_horizontal_bar_height(
5399                        &built.theme,
5400                        !title.is_empty(),
5401                        &built.guide_legend.colourbar_for(&scale.aesthetic()),
5402                    );
5403                    let key_h = built
5404                        .theme
5405                        .legend_key_height
5406                        .max(extras.legend_max_key_size);
5407                    extras.continuous_horizontal_legend_extra_height =
5408                        (bar_height - key_h).max(0.0);
5409                }
5410                // #409: a custom guide taller than a key row.
5411                let key_h = built
5412                    .theme
5413                    .legend_key_height
5414                    .max(extras.legend_max_key_size);
5415                let custom = built
5416                    .guide_legend
5417                    .custom
5418                    .iter()
5419                    .map(|g| (g.title_offset(&built.theme) + g.height - key_h).max(0.0))
5420                    .fold(0.0_f64, f64::max);
5421                extras.continuous_horizontal_legend_extra_height =
5422                    extras.continuous_horizontal_legend_extra_height.max(custom);
5423            }
5424        }
5425
5426        PlotLayout::compute_with_extras(
5427            w as f64,
5428            h as f64,
5429            &built.theme,
5430            has_title,
5431            has_subtitle,
5432            has_caption,
5433            has_legend,
5434            x_axis_top,
5435            y_label_width,
5436            x_label_height,
5437            extras,
5438        )
5439    }
5440
5441    /// Fill the background, render the built plot, and flush — for any plotters
5442    /// backend. (Native only.)
5443    #[cfg(all(feature = "plotters", not(target_arch = "wasm32")))]
5444    fn render_into<DB>(
5445        area: plotters::drawing::DrawingArea<DB, plotters::coord::Shift>,
5446        built: &crate::build::BuiltPlot,
5447        layout: &PlotLayout,
5448    ) -> Result<(), GGError>
5449    where
5450        DB: plotters::prelude::DrawingBackend,
5451        DB::ErrorType: 'static,
5452    {
5453        area.fill(&plotters::prelude::WHITE)
5454            .map_err(|e| GGError::Render(RenderError::BackendError(format!("{:?}", e))))?;
5455        let mut adapter = PlottersAdapter::new(&area, layout.plot_area.clone());
5456        PlotRenderer::render(built, &mut adapter).map_err(GGError::Render)?;
5457        area.present()
5458            .map_err(|e| GGError::Render(RenderError::BackendError(format!("{:?}", e))))?;
5459        Ok(())
5460    }
5461
5462    /// Save with physical dimensions (inches) and DPI. (Feature `plotters`;
5463    /// native only.)
5464    #[cfg(all(feature = "plotters", not(target_arch = "wasm32")))]
5465    pub fn ggsave(
5466        self,
5467        path: &str,
5468        width_inches: f64,
5469        height_inches: f64,
5470        dpi: f64,
5471    ) -> Result<(), GGError> {
5472        let w = (width_inches * dpi) as u32;
5473        let h = (height_inches * dpi) as u32;
5474        self.save_with_size(path, w, h)
5475    }
5476
5477    /// Does this plot have a legend to make room for? (#201) A plot-level
5478    /// `.aes(...)` mapping always counts; so does a *layer's own*
5479    /// `.layer_aes(...)` mapping — e.g. `geom_hex`'s usual pattern of
5480    /// mapping `fill` to a raw column only on that one layer, via
5481    /// `StatIdentity` after pre-binning, rather than on the plot as a
5482    /// whole. Checking only `self.mapping` (as before) missed that case
5483    /// entirely: the legend still drew (the render path always draws
5484    /// whatever guides the trained scales produce, regardless of this
5485    /// flag), just with no panel margin ever reserved for it, so it ran
5486    /// straight off the canvas edge. A layer that opts out with
5487    /// `show_legend(false)` doesn't count towards its own mapping, mirroring
5488    /// `PlotBuilder::compute_suppressed_aes`'s later, more precise
5489    /// (post-build, cross-layer) suppression check — this one only needs to
5490    /// decide *whether to reserve space at all*, not exactly how much.
5491    fn has_legend_mapping(&self) -> bool {
5492        // #241: delegates to `guide::legend::is_legend_aesthetic` (the
5493        // same list `draw_legend_planned` itself draws from) rather than
5494        // keeping its own copy — a second, independently-maintained list
5495        // here had drifted out of sync, missing `Linewidth`.
5496        use crate::guide::legend::is_legend_aesthetic;
5497        let plot_level = self
5498            .mapping
5499            .mappings
5500            .iter()
5501            .any(|m| is_legend_aesthetic(&m.aesthetic));
5502        // #409: a custom guide needs the legend band even with no mapping.
5503        !self.guide_legend.custom.is_empty()
5504            || plot_level
5505            || self.layers.iter().any(|layer| {
5506                if layer.show_legend == Some(false) {
5507                    return false;
5508                }
5509                let effective = if layer.geom.inherit_aes() {
5510                    self.mapping.merge(&layer.mapping)
5511                } else {
5512                    layer.mapping.clone()
5513                };
5514                effective
5515                    .mappings
5516                    .iter()
5517                    .any(|m| is_legend_aesthetic(&m.aesthetic))
5518            })
5519    }
5520}
5521
5522/// Size-independent facts about a built plot that its layout needs.
5523#[derive(Clone, Copy, Debug)]
5524pub(crate) struct RenderMeta {
5525    pub(crate) has_title: bool,
5526    pub(crate) has_subtitle: bool,
5527    pub(crate) has_caption: bool,
5528    pub(crate) has_legend: bool,
5529    pub(crate) legend_bottom_share: Option<f64>,
5530    pub(crate) legend_auto: Option<LegendAuto>,
5531}
5532
5533/// [`GGPlot::legend_inside_auto_with`]'s settings (#332).
5534#[derive(Clone, Copy, Debug)]
5535pub(crate) struct LegendAuto {
5536    corners: [Option<crate::theme::LegendCorner>; 4],
5537    fallback: Option<LegendSide>,
5538}
5539
5540/// An outside legend side (a `Copy` subset of `LegendPosition`).
5541#[derive(Clone, Copy, Debug)]
5542enum LegendSide {
5543    Top,
5544    Right,
5545    Bottom,
5546    Left,
5547}
5548
5549impl LegendSide {
5550    fn position(self) -> crate::theme::LegendPosition {
5551        use crate::theme::LegendPosition as P;
5552        match self {
5553            LegendSide::Top => P::Top,
5554            LegendSide::Right => P::Right,
5555            LegendSide::Bottom => P::Bottom,
5556            LegendSide::Left => P::Left,
5557        }
5558    }
5559}
5560
5561/// Top-level error type.
5562#[derive(Debug)]
5563#[non_exhaustive]
5564pub enum GGError {
5565    Render(RenderError),
5566    UnsupportedFormat(String),
5567    Io(std::io::Error),
5568    ValidationError(String),
5569}
5570
5571impl std::fmt::Display for GGError {
5572    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
5573        match self {
5574            GGError::Render(e) => write!(f, "Render error: {e}"),
5575            GGError::UnsupportedFormat(ext) => write!(f, "Unsupported output format: {ext}"),
5576            GGError::Io(e) => write!(f, "IO error: {e}"),
5577            GGError::ValidationError(msg) => write!(f, "Validation error: {msg}"),
5578        }
5579    }
5580}
5581
5582impl std::error::Error for GGError {}
5583
5584/// A Brewer palette as a continuous gradient (`scale_*_distiller`).
5585fn distiller(
5586    aes: crate::aes::Aesthetic,
5587    palette: &crate::scale::palettes::PaletteName,
5588    direction: i32,
5589) -> crate::scale::gradient_n::ScaleColorGradientN {
5590    let mut colors = crate::scale::palettes::palette(palette).to_vec();
5591    if direction < 0 {
5592        colors.reverse();
5593    }
5594    let n = colors.len().max(2) - 1;
5595    let stops = colors
5596        .into_iter()
5597        .enumerate()
5598        .map(|(i, c)| (i as f64 / n as f64, c))
5599        .collect();
5600    crate::scale::gradient_n::ScaleColorGradientN::new(aes, stops)
5601}