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