Skip to main content

ggplot_rs/
plot.rs

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