Skip to main content

ggplot_rs/
plot.rs

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