Skip to main content

ggplot_rs/
build.rs

1use crate::aes::{mapping::apply_after_stat, mapping::resolve_mappings, Aes, Aesthetic};
2use crate::annotate::Annotation;
3use crate::coord::Coord;
4use crate::data::DataFrame;
5use crate::facet::{Facet, FacetScales, Panel};
6use crate::geom::Geom;
7use crate::plot::{GGError, GGPlot, Labels, Layer};
8use crate::position::PositionParams;
9use crate::scale::ScaleSet;
10use crate::theme::Theme;
11
12/// A built layer ready for rendering.
13pub struct BuiltLayer {
14    pub data: DataFrame,
15    pub geom: Box<dyn Geom>,
16    pub show_legend: Option<bool>,
17    /// Legend key glyph (the layer's override, else the geom's).
18    pub key_glyph: Option<crate::guide::key::KeyGlyph>,
19    /// Per-aesthetic `show.legend` overrides.
20    pub show_legend_aes: Vec<(Aesthetic, bool)>,
21}
22
23impl BuiltLayer {
24    /// Whether this layer appears in `aes`'s legend.
25    pub(crate) fn shows_legend_for(&self, aes: &Aesthetic) -> Option<bool> {
26        self.show_legend_aes
27            .iter()
28            .find(|(a, _)| a == aes)
29            .map(|(_, s)| *s)
30            .or(self.show_legend)
31    }
32}
33
34/// A fully built plot ready for rendering.
35pub struct BuiltPlot {
36    pub layers: Vec<BuiltLayer>,
37    pub scales: ScaleSet,
38    pub coord: Box<dyn Coord>,
39    pub theme: Theme,
40    pub labels: Labels,
41    pub facet: Facet,
42    /// Which facet panels get axes (#412).
43    pub facet_axes: crate::facet::FacetAxes,
44    pub panels: Vec<Panel>,
45    /// Per-panel layer data. panels_data[panel_idx][layer_idx] = data for that panel+layer.
46    pub panels_data: Vec<Vec<DataFrame>>,
47    pub annotations: Vec<Annotation>,
48    pub guide_legend: crate::guide::config::GuideLegend,
49    /// Aesthetics suppressed from the legend (all layers with that aes set show_legend=false).
50    pub suppressed_aes: std::collections::HashSet<Aesthetic>,
51    /// Per-panel scale sets for free facets. Empty when FacetScales::Fixed.
52    pub panel_scales: Vec<ScaleSet>,
53    /// Non-fatal problems found while building (ggplot2's warnings), e.g.
54    /// "geom_point: removed 2 rows containing non-finite values". See
55    /// [`BuiltPlot::warnings`].
56    pub warnings: Vec<String>,
57    /// Every discrete legend's B3-2 layout plan (explicit `nrow`/`ncol`
58    /// grid, or auto-wrap), keyed by aesthetic, computed exactly once in
59    /// `layout_built` and reused as-is by `draw_legend_planned` — never
60    /// recomputed at draw time, so the warning (based on these same
61    /// plans) always matches what's drawn (review #158 finding 2:
62    /// computing it twice, once against the pre-reservation panel height
63    /// and once against the post-reservation one, could warn "+7 more"
64    /// while drawing "+15 more"). A guide missing from this map draws via
65    /// the 0.21 single-row/single-column path unchanged. Several
66    /// simultaneous discrete guides each get their own independent entry
67    /// (sipemu/ggplot-rs#155) — previously this only ever held at most
68    /// one plan, for a single discrete guide.
69    pub(crate) legend_plans: std::collections::HashMap<Aesthetic, crate::guide::legend::LegendPlan>,
70    /// Where `legend_inside_auto` put the legend (`inside-bottom-right`,
71    /// `top`, …), written as the root `data-legend-at` attribute (#332).
72    pub(crate) legend_at: Option<String>,
73    /// Clip the data layers to the panel (`clip = "on"`, #411).
74    pub clip: bool,
75    /// Semantic CSS classes on the SVG's non-data elements (#465).
76    pub svg_classes: bool,
77}
78
79impl BuiltPlot {
80    /// Non-fatal build warnings, in layer order: rows dropped for non-finite
81    /// positions, and layers skipped because their stat produced no data
82    /// (e.g. a density of a single value). Rendering still succeeds.
83    pub fn warnings(&self) -> &[String] {
84        &self.warnings
85    }
86}
87
88/// Columns holding *position* values: rows with non-finite values here are
89/// dropped before stats (and again after, for stat output).
90const POSITION_COLS: &[&str] = &[
91    "x",
92    "y",
93    "xmin",
94    "xmax",
95    "ymin",
96    "ymax",
97    "xend",
98    "yend",
99    "open",
100    "high",
101    "low",
102    "close",
103    "xintercept",
104    "yintercept",
105    "slope",
106    "intercept",
107    // Boxplot stat="identity" hinges (B3-5). `outliers` is a comma-list
108    // string, not a position value, so it's not listed here — a non-finite
109    // outlier is skipped individually with its own warning when parsed.
110    "lower",
111    "middle",
112    "upper",
113];
114
115/// Columns a position scale's transformation applies to (pre-stat).
116const X_FAMILY: &[&str] = &["x", "xmin", "xmax", "xend", "xintercept"];
117const Y_FAMILY: &[&str] = &[
118    "y",
119    "ymin",
120    "ymax",
121    "yend",
122    "open",
123    "high",
124    "low",
125    "close",
126    "yintercept",
127    "lower",
128    "middle",
129    "upper",
130];
131
132/// Reference-line intercept columns and the position scale they train
133/// (ggplot2: `xintercept`/`yintercept` are x/y aesthetics).
134const INTERCEPT_COLS: [(&str, Aesthetic); 2] =
135    [("xintercept", Aesthetic::X), ("yintercept", Aesthetic::Y)];
136
137/// Train `scale` on reference-line intercepts. A numeric intercept on a
138/// discrete axis is a position *between* categories (ggplot2), not a level.
139fn train_intercepts(scale: &mut Box<dyn crate::scale::Scale>, values: &[crate::data::Value]) {
140    if scale.is_discrete() && values.iter().all(|v| v.as_f64().is_some() || v.is_na()) {
141        return;
142    }
143    scale.train(values);
144}
145
146/// Map a canonical column name to the aesthetic whose scale formats it
147/// (B2-5 tooltip templates / B2-4 `data-fvalue`-style formatting). Columns
148/// outside this list (group, label, or a stat output like `count`) have no
149/// scale of their own: [`render_tooltip_template`] falls back to the plain
150/// display format for them.
151fn column_aesthetic(name: &str) -> Option<Aesthetic> {
152    match name {
153        "x" => Some(Aesthetic::X),
154        "y" => Some(Aesthetic::Y),
155        "fill" => Some(Aesthetic::Fill),
156        "color" | "colour" => Some(Aesthetic::Color),
157        "size" => Some(Aesthetic::Size),
158        "linewidth" => Some(Aesthetic::Linewidth),
159        "alpha" => Some(Aesthetic::Alpha),
160        // Position columns (B3-5, D9 allowed #7): formatted through the
161        // x/y scale, same as the bare `x`/`y` they're derived from.
162        "xmin" | "xmax" | "xend" => Some(Aesthetic::X),
163        "ymin" | "ymax" | "yend" | "lower" | "middle" | "upper" => Some(Aesthetic::Y),
164        _ => None,
165    }
166}
167
168/// Render one row of a `GGPlot::tooltip` template: `{column}` is filled
169/// from `data`'s row `row`, formatted through that column's scale when
170/// [`column_aesthetic`] names one and it has a
171/// [`Scale::format_value`](crate::scale::Scale::format_value) (else the
172/// plain display format); a column that doesn't exist on this layer is
173/// empty, not literally `{column}`. `{{`/`}}` are literal braces.
174fn render_tooltip_template(
175    template: &str,
176    data: &DataFrame,
177    scales: &ScaleSet,
178    row: usize,
179) -> String {
180    let mut out = String::new();
181    let mut chars = template.chars().peekable();
182    while let Some(c) = chars.next() {
183        match c {
184            '{' if chars.peek() == Some(&'{') => {
185                chars.next();
186                out.push('{');
187            }
188            '}' if chars.peek() == Some(&'}') => {
189                chars.next();
190                out.push('}');
191            }
192            '{' => {
193                let mut name = String::new();
194                let mut closed = false;
195                for nc in chars.by_ref() {
196                    if nc == '}' {
197                        closed = true;
198                        break;
199                    }
200                    name.push(nc);
201                }
202                if closed {
203                    out.push_str(&render_placeholder(&name, data, scales, row));
204                } else {
205                    // Unterminated `{…` to end of string: not a placeholder,
206                    // kept literally.
207                    out.push('{');
208                    out.push_str(&name);
209                }
210            }
211            c => out.push(c),
212        }
213    }
214    out
215}
216
217fn render_placeholder(name: &str, data: &DataFrame, scales: &ScaleSet, row: usize) -> String {
218    let Some(col) = data.column(name) else {
219        return String::new();
220    };
221    let Some(v) = col.get(row) else {
222        return String::new();
223    };
224    if v.is_na() {
225        return String::new();
226    }
227    if let Some(formatted) = column_aesthetic(name)
228        .and_then(|aes| scales.get(&aes))
229        .and_then(|s| s.format_value(v))
230    {
231        return formatted;
232    }
233    crate::geom::tip_value(v)
234}
235
236/// The grammar pipeline: transforms a GGPlot specification into render-ready data.
237pub struct PlotBuilder;
238
239impl PlotBuilder {
240    pub fn build(plot: GGPlot) -> Result<BuiltPlot, GGError> {
241        let GGPlot {
242            data: plot_data,
243            mapping: plot_mapping,
244            layers,
245            scales: user_scales,
246            mut coord,
247            theme,
248            labels,
249            facet,
250            facet_axes,
251            annotations,
252            guide_legend,
253            warnings: plot_warnings,
254            default_aspect_ratio,
255            hide_position_guides,
256            hide_y_guides,
257            legend_bottom_share: _,
258            clip,
259            svg_classes,
260            legend_auto: _,
261            locale,
262            svg_root_data: _,
263            root_attrs: _,
264            svg_warnings_attr: _,
265            svg_formatted_values: _,
266            layer_svg_attrs,
267            svg_title: _,
268            svg_desc: _,
269        } = plot;
270        let mut theme = theme;
271        if theme.aspect_ratio.is_none() {
272            theme.aspect_ratio = default_aspect_ratio;
273        }
274        if hide_position_guides {
275            theme.hide_position_guides();
276        }
277        if hide_y_guides {
278            theme.hide_y_guides();
279        }
280
281        // Malformed input (e.g. mismatched column lengths) is reported here as
282        // a validation error instead of panicking when the frame is assembled.
283        plot_data.validate()?;
284        for layer in &layers {
285            if let Some(d) = &layer.data {
286                d.validate()?;
287            }
288        }
289
290        // #412: `facet_grid(margins = TRUE)` — copy the rows into "(all)"
291        // levels before any stat runs, so the margin panels are computed on
292        // the pooled data (as ggplot2's `reshape_add_margins`).
293        let (plot_data, layers) = match &facet {
294            Facet::Grid {
295                margins: true,
296                row_var,
297                col_var,
298                ..
299            } => {
300                let add =
301                    |d: DataFrame| Self::add_margins(d, row_var.as_deref(), col_var.as_deref());
302                let layers = layers
303                    .into_iter()
304                    .map(|mut l| {
305                        l.data = l.data.take().map(add);
306                        l
307                    })
308                    .collect();
309                (add(plot_data), layers)
310            }
311            _ => (plot_data, layers),
312        };
313
314        let mut scale_set = ScaleSet::new();
315
316        // Add user-specified scales
317        for s in user_scales {
318            scale_set.add(s);
319        }
320
321        let mut built_layers = Vec::new();
322        let mut warnings: Vec<String> = plot_warnings;
323
324        // Faceting variables — used to group stat computation per panel so a
325        // computed stat (density/histogram) is estimated per panel, not pooled.
326        let facet_vars = Self::facet_vars(&facet);
327
328        for (li, mut layer) in layers.into_iter().enumerate() {
329            // #221: a layer with `layer_svg_attr`s draws through a wrapper
330            // that hands them to the backend around its own `draw`.
331            let attrs: Vec<(String, String)> = layer_svg_attrs
332                .iter()
333                .filter(|(i, _, _)| *i == li)
334                .map(|(_, n, v)| (n.clone(), v.clone()))
335                .collect();
336            if !attrs.is_empty() {
337                let geom =
338                    std::mem::replace(&mut layer.geom, Box::new(crate::geom::blank::GeomBlank));
339                layer.geom = Box::new(crate::geom::layer_attrs::LayerSvgAttrs(geom, attrs));
340            }
341            let built = Self::build_layer(
342                layer,
343                &plot_data,
344                &plot_mapping,
345                &mut scale_set,
346                &theme,
347                &facet_vars,
348                &mut warnings,
349            )?;
350            built_layers.push(built);
351        }
352
353        // Final scale training pass across all layers
354        for bl in &built_layers {
355            scale_set.train_layer(&bl.data);
356            for (col, aes) in &INTERCEPT_COLS {
357                // No layer created this position scale: the intercepts do.
358                if let (Some(values), None) = (bl.data.column(col), scale_set.get(aes)) {
359                    let mut frame = DataFrame::new();
360                    frame.add_column(aes.col_name().to_string(), values.to_vec());
361                    scale_set.ensure_scale(aes, &frame);
362                }
363                if let (Some(values), Some(scale)) = (bl.data.column(col), scale_set.get_mut(aes)) {
364                    train_intercepts(scale, values);
365                }
366            }
367        }
368
369        // Validate each scale's final configuration (e.g. a transform
370        // undefined at its own domain limits) now that training is done —
371        // `map`/`map_to_size` run per mark at draw time and have no
372        // warnings sink of their own.
373        for scale in scale_set.iter() {
374            scale.validate(&mut warnings);
375        }
376
377        // An empty plot (no layers, or only layers without data) still gets a
378        // panel with axes: make sure both position scales exist. They stay
379        // untrained, so they draw no breaks and map everything to the centre.
380        if built_layers.iter().all(|bl| bl.data.nrows() == 0) {
381            for aes in [Aesthetic::X, Aesthetic::Y] {
382                if scale_set.get(&aes).is_none() {
383                    scale_set.add(Box::new(
384                        crate::scale::continuous::ScaleContinuous::new().for_aesthetic(aes),
385                    ));
386                }
387            }
388        }
389
390        // Apply coord zoom limits (coord_cartesian xlim/ylim)
391        if let Some((min, max)) = coord.zoom_x() {
392            scale_set.set_limits(&Aesthetic::X, min, max);
393        }
394        if let Some((min, max)) = coord.zoom_y() {
395            scale_set.set_limits(&Aesthetic::Y, min, max);
396        }
397
398        // Let the coordinate system adjust/inspect the trained scales.
399        coord.train_scales(&mut scale_set);
400
401        // Supply trained axis spans to the coordinate system (used by coord_trans).
402        // pmin/pmax are the panel positions of the domain endpoints, so the coord
403        // can invert the scale's (linearly expanded) mapping exactly.
404        let axis_span = |aes: &Aesthetic| {
405            scale_set.get(aes).and_then(|s| {
406                s.domain().map(|(min, max)| crate::coord::AxisSpan {
407                    min,
408                    max,
409                    pmin: s.map(&crate::data::Value::Float(min)),
410                    pmax: s.map(&crate::data::Value::Float(max)),
411                })
412            })
413        };
414        let x_span = axis_span(&Aesthetic::X);
415        let y_span = axis_span(&Aesthetic::Y);
416        coord.set_domains(x_span, y_span);
417
418        // Apply after_scale() color derivations: copy the source aesthetic's
419        // column to the target and register a lightness-modified clone of the
420        // source scale, so the target aesthetic draws the mapped source color
421        // adjusted in lightness (e.g. a darker border derived from the fill).
422        for spec in &plot_mapping.after_scale {
423            if let Some(src_scale) = scale_set.get(&spec.source) {
424                let modified = crate::scale::modified::ScaleColorModified::new(
425                    src_scale.clone_box(),
426                    spec.target.clone(),
427                    spec.lightness,
428                );
429                let (src_col, tgt_col) = (spec.source.col_name(), spec.target.col_name());
430                for bl in &mut built_layers {
431                    if !bl.data.has_column(tgt_col) {
432                        if let Some(vals) = bl.data.column(src_col) {
433                            let vals = vals.to_vec();
434                            bl.data.add_column(tgt_col.to_string(), vals);
435                        }
436                    }
437                }
438                scale_set.add(Box::new(modified));
439            }
440        }
441
442        // Plot-level locale for default break labels (before panel scales are
443        // cloned from the base set, so free facets inherit it).
444        if let Some(locale) = &locale {
445            scale_set.set_default_locale(locale);
446        }
447
448        // Compute facet panels
449        let (panels, panels_data) = Self::compute_facets(&facet, &built_layers, &plot_data);
450
451        // Compute suppressed aesthetics from show_legend flags.
452        let mut suppressed_aes = Self::compute_suppressed_aes(&built_layers);
453        // #409: `guides(<aes> = "none")`.
454        suppressed_aes.extend(guide_legend.hidden.iter().cloned());
455        // #410: each layer's legend glyph and the legend aesthetics it shows.
456        let mut guide_legend = guide_legend;
457        guide_legend.layer_keys = built_layers
458            .iter()
459            .map(|bl| crate::guide::key::LayerKey {
460                glyph: bl.key_glyph,
461                fill: bl.geom.key_fill(),
462                aesthetics: crate::guide::legend::LEGEND_AESTHETICS
463                    .iter()
464                    .filter(|a| bl.data.has_column(a.col_name()))
465                    .filter(|a| bl.shows_legend_for(a) != Some(false))
466                    .cloned()
467                    .collect(),
468            })
469            .collect();
470
471        // Compute per-panel scales for free facets
472        let facet_scales_mode = match &facet {
473            Facet::Wrap { scales, .. } => scales.clone(),
474            Facet::Grid { scales, .. } => scales.clone(),
475            Facet::None => FacetScales::Fixed,
476        };
477        let panel_scales = Self::compute_panel_scales(&facet_scales_mode, &panels_data, &scale_set);
478
479        Ok(BuiltPlot {
480            layers: built_layers,
481            scales: scale_set,
482            coord,
483            theme,
484            labels,
485            facet,
486            facet_axes,
487            panels,
488            panels_data,
489            annotations,
490            guide_legend,
491            suppressed_aes,
492            panel_scales,
493            warnings,
494            legend_plans: std::collections::HashMap::new(),
495            legend_at: None,
496            clip,
497            svg_classes,
498        })
499    }
500
501    /// Keep only the rows whose `keep` flag is set.
502    fn retain_rows(data: &mut DataFrame, keep: &[bool]) {
503        if keep.iter().all(|&k| k) {
504            return;
505        }
506        let mut result = DataFrame::new();
507        for col_name in data.column_names() {
508            if let Some(src) = data.column(col_name) {
509                let vals: Vec<_> = src
510                    .iter()
511                    .zip(keep)
512                    .filter(|(_, &k)| k)
513                    .map(|(v, _)| v.clone())
514                    .collect();
515                result.add_column(col_name.to_string(), vals);
516            }
517        }
518        *data = result;
519    }
520
521    /// Drop rows with a non-finite value in any position column (`NaN` always;
522    /// `±Inf` unless the geom gives infinities meaning). Returns the number of
523    /// rows removed.
524    /// `keep_as_break`: for a connecting geom ([`Geom::keeps_na_as_break`]),
525    /// a bad row is counted (for the warning) but not deleted — it stays in
526    /// `data` as a non-finite pixel position, which `DrawBackend::draw_line`
527    /// splits the polyline on, instead of being spliced out and silently
528    /// joining the finite points across the gap.
529    /// `exclude`: column names to skip entirely this call — `lower`/
530    /// `middle`/`upper` present in the raw data but not mapped as that
531    /// aesthetic on this layer (review #158's final check: these must
532    /// never cost an unrelated geom its rows; see `raw_unmapped` at the
533    /// call site).
534    fn drop_non_finite(
535        data: &mut DataFrame,
536        allow_infinite: bool,
537        keep_as_break: bool,
538        exclude: &[&str],
539    ) -> usize {
540        let n = data.nrows();
541        let mut keep = vec![true; n];
542        for col in POSITION_COLS {
543            if exclude.contains(col) {
544                continue;
545            }
546            if let Some(values) = data.column(col) {
547                // Several position columns are legitimately discrete
548                // (`Value::Str`) in this codebase — not just `x`/`y`, e.g.
549                // `geom_bracket`'s `xmin`/`xmax` hold category names for a
550                // bracket spanning two discrete groups. `Value::Na` is
551                // unambiguously missing everywhere, though (review #158
552                // finding 4: a SQL NULL in a boxplot position column used
553                // to silently map to 0 instead of being dropped with the
554                // standard warning; only `Float(NaN)` was caught before).
555                // The new boxplot-only `lower`/`middle`/`upper` columns
556                // have no legitimate discrete use anywhere, so a
557                // non-numeric, non-`Na` value there (a stray string) is
558                // missing too.
559                let strictly_numeric = matches!(*col, "lower" | "middle" | "upper");
560                for (i, v) in values.iter().enumerate() {
561                    let bad = match v {
562                        crate::data::Value::Na => true,
563                        crate::data::Value::Float(f) => {
564                            f.is_nan() || (f.is_infinite() && !allow_infinite)
565                        }
566                        _ if strictly_numeric => v.as_f64().is_none(),
567                        _ => false,
568                    };
569                    if bad {
570                        keep[i] = false;
571                    }
572                }
573            }
574        }
575        let removed = keep.iter().filter(|&&k| !k).count();
576        if removed > 0 && !keep_as_break {
577            Self::retain_rows(data, &keep);
578        }
579        removed
580    }
581
582    /// The column name(s) a facet splits on, if any.
583    fn facet_vars(facet: &Facet) -> Vec<String> {
584        match facet {
585            Facet::None => vec![],
586            Facet::Wrap { var, .. } => vec![var.clone()],
587            Facet::Grid {
588                row_var, col_var, ..
589            } => row_var.iter().chain(col_var.iter()).cloned().collect(),
590        }
591    }
592
593    fn compute_facets(
594        facet: &Facet,
595        built_layers: &[BuiltLayer],
596        _plot_data: &DataFrame,
597    ) -> (Vec<Panel>, Vec<Vec<DataFrame>>) {
598        match facet {
599            Facet::None => (vec![], vec![]),
600            Facet::Wrap {
601                var,
602                ncol,
603                labeller,
604                levels: fixed,
605                vertical,
606                as_table,
607                ..
608            } => {
609                // Explicit levels (#329), else unique values across all
610                // layers in first-appearance order.
611                let mut levels: Vec<String> = fixed.clone().unwrap_or_default();
612                for bl in built_layers.iter().filter(|_| fixed.is_none()) {
613                    if let Some(col) = bl.data.column(var) {
614                        for v in col {
615                            let key = v.to_group_key();
616                            if !levels.contains(&key) {
617                                levels.push(key);
618                            }
619                        }
620                    }
621                }
622
623                // Panels will be positioned during rendering (depends on layout)
624                let panels: Vec<Panel> = levels
625                    .iter()
626                    .enumerate()
627                    .map(|(i, value)| {
628                        let ncols =
629                            ncol.unwrap_or_else(|| (levels.len() as f64).sqrt().ceil() as usize);
630                        let formatted = labeller.format(var, value);
631                        Panel {
632                            row: crate::facet::wrap::wrap_cell(
633                                i,
634                                levels.len(),
635                                ncols,
636                                *vertical,
637                                *as_table,
638                            )
639                            .0,
640                            col: crate::facet::wrap::wrap_cell(
641                                i,
642                                levels.len(),
643                                ncols,
644                                *vertical,
645                                *as_table,
646                            )
647                            .1,
648                            label: formatted.clone(),
649                            row_label: None,
650                            col_label: Some(formatted),
651                            rect: crate::render::Rect {
652                                x: 0.0,
653                                y: 0.0,
654                                width: 0.0,
655                                height: 0.0,
656                            },
657                        }
658                    })
659                    .collect();
660
661                // Split data per panel per layer
662                let panels_data: Vec<Vec<DataFrame>> = levels
663                    .iter()
664                    .map(|level| {
665                        built_layers
666                            .iter()
667                            .map(|bl| Self::filter_data_by_var(&bl.data, var, level))
668                            .collect()
669                    })
670                    .collect();
671
672                (panels, panels_data)
673            }
674            Facet::Grid {
675                row_var,
676                col_var,
677                labeller,
678                row_levels: fixed_rows,
679                col_levels: fixed_cols,
680                margins,
681                ..
682            } => {
683                // #412: fixed levels (+ the "(all)" margin) when given.
684                let with_margin = |l: &Vec<String>| {
685                    let mut l = l.clone();
686                    if *margins {
687                        l.push(Self::MARGIN_LEVEL.to_string());
688                    }
689                    l
690                };
691                let mut row_levels: Vec<String> =
692                    fixed_rows.as_ref().map(with_margin).unwrap_or_default();
693                let mut col_levels: Vec<String> =
694                    fixed_cols.as_ref().map(with_margin).unwrap_or_default();
695
696                for bl in built_layers {
697                    if let (Some(rv), None) = (row_var, fixed_rows) {
698                        if let Some(col) = bl.data.column(rv) {
699                            for v in col {
700                                let key = v.to_group_key();
701                                if !row_levels.contains(&key) {
702                                    row_levels.push(key);
703                                }
704                            }
705                        }
706                    }
707                    if let (Some(cv), None) = (col_var, fixed_cols) {
708                        if let Some(col) = bl.data.column(cv) {
709                            for v in col {
710                                let key = v.to_group_key();
711                                if !col_levels.contains(&key) {
712                                    col_levels.push(key);
713                                }
714                            }
715                        }
716                    }
717                }
718
719                if row_levels.is_empty() {
720                    row_levels.push("".to_string());
721                }
722                if col_levels.is_empty() {
723                    col_levels.push("".to_string());
724                }
725
726                let mut panels = Vec::new();
727                let mut panels_data = Vec::new();
728
729                for (ri, rl) in row_levels.iter().enumerate() {
730                    for (ci, cl) in col_levels.iter().enumerate() {
731                        let row_fmt = if rl.is_empty() {
732                            None
733                        } else {
734                            let rv = row_var.as_deref().unwrap_or("");
735                            Some(labeller.format(rv, rl))
736                        };
737                        let col_fmt = if cl.is_empty() {
738                            None
739                        } else {
740                            let cv = col_var.as_deref().unwrap_or("");
741                            Some(labeller.format(cv, cl))
742                        };
743                        let label = match (&row_fmt, &col_fmt) {
744                            (Some(r), Some(c)) => format!("{r} | {c}"),
745                            (Some(r), None) => r.clone(),
746                            (None, Some(c)) => c.clone(),
747                            (None, None) => String::new(),
748                        };
749                        panels.push(Panel {
750                            row: ri,
751                            col: ci,
752                            label,
753                            row_label: row_fmt,
754                            col_label: col_fmt,
755                            rect: crate::render::Rect {
756                                x: 0.0,
757                                y: 0.0,
758                                width: 0.0,
759                                height: 0.0,
760                            },
761                        });
762
763                        let layer_data: Vec<DataFrame> = built_layers
764                            .iter()
765                            .map(|bl| {
766                                let mut data = bl.data.clone();
767                                if let Some(rv) = row_var {
768                                    if !rl.is_empty() {
769                                        data = Self::filter_data_by_var(&data, rv, rl);
770                                    }
771                                }
772                                if let Some(cv) = col_var {
773                                    if !cl.is_empty() {
774                                        data = Self::filter_data_by_var(&data, cv, cl);
775                                    }
776                                }
777                                data
778                            })
779                            .collect();
780                        panels_data.push(layer_data);
781                    }
782                }
783
784                (panels, panels_data)
785            }
786        }
787    }
788
789    /// The "(all)" margin level of `facet_grid(margins = TRUE)` (#412).
790    pub(crate) const MARGIN_LEVEL: &'static str = "(all)";
791
792    /// `data` plus a copy with the row variable set to "(all)", then all of
793    /// that plus a copy with the column variable set to "(all)".
794    fn add_margins(data: DataFrame, row: Option<&str>, col: Option<&str>) -> DataFrame {
795        let mut out = data;
796        for var in [row, col].into_iter().flatten() {
797            if !out.has_column(var) || out.nrows() == 0 {
798                continue;
799            }
800            let mut copy = out.clone();
801            if let Some(c) = copy.column_mut(var) {
802                c.fill(crate::data::Value::Str(Self::MARGIN_LEVEL.to_string()));
803            }
804            out.vstack(&copy);
805        }
806        out
807    }
808
809    fn filter_data_by_var(data: &DataFrame, var: &str, level: &str) -> DataFrame {
810        if let Some(col) = data.column(var) {
811            let indices: Vec<usize> = col
812                .iter()
813                .enumerate()
814                .filter(|(_, v)| v.to_group_key() == level)
815                .map(|(i, _)| i)
816                .collect();
817
818            let mut result = DataFrame::new();
819            for col_name in data.column_names() {
820                if let Some(src) = data.column(col_name) {
821                    let vals: Vec<_> = indices.iter().map(|&i| src[i].clone()).collect();
822                    result.add_column(col_name.to_string(), vals);
823                }
824            }
825            result
826        } else {
827            data.clone()
828        }
829    }
830
831    fn build_layer(
832        layer: Layer,
833        plot_data: &DataFrame,
834        plot_mapping: &Aes,
835        scale_set: &mut ScaleSet,
836        theme: &Theme,
837        facet_vars: &[String],
838        warnings: &mut Vec<String>,
839    ) -> Result<BuiltLayer, GGError> {
840        let primary = theme.primary;
841        let geom_theme = &theme.geom;
842        let Layer {
843            data: layer_data,
844            mapping: layer_mapping,
845            mut geom,
846            stat,
847            position,
848            params: _,
849            show_legend,
850            explicit_style,
851            tooltip_template,
852            key_glyph,
853            show_legend_aes,
854        } = layer;
855        let key_glyph = key_glyph.or_else(|| geom.key_glyph());
856
857        // Step 1: Resolve data — use layer data if provided, else plot data
858        // Borrow the plot data when the layer has none of its own (no per-layer
859        // full-frame clone; `resolve_mappings` copies only the columns it needs).
860        let source_data = layer_data.as_ref().unwrap_or(plot_data);
861
862        // Step 2: Merge mappings — layer overrides plot-level
863        // (Reference lines don't inherit the plot mapping — ggplot2's
864        // `inherit.aes = FALSE`.)
865        let merged_mapping = if geom.inherit_aes() {
866            plot_mapping.merge(&layer_mapping)
867        } else {
868            layer_mapping.clone()
869        };
870
871        // B2-7: geom ink/paper/accent palette, applied before the brand
872        // colour below so a primary colour still wins when both are set.
873        // Skipped when the layer was configured explicitly
874        // (`geom_*_with(...)`) — the same `explicit_style` gate
875        // `set_series_color` uses below, and for the same reason: a
876        // field-value comparison against the hard-coded default (e.g.
877        // `color == (0, 0, 0)`) can't tell an explicitly-set black apart
878        // from a black that was simply never touched (review PR#141
879        // finding 4), but `explicit_style` can.
880        if !explicit_style {
881            geom.apply_element_geom(geom_theme);
882        }
883
884        // Brand/primary color: apply to a single-series geom only when the layer
885        // maps neither color nor fill (an explicit aesthetic always wins) and
886        // the geom wasn't configured explicitly via `geom_*_with(...)`.
887        if let Some(color) = primary.filter(|_| !explicit_style) {
888            let has_color = merged_mapping.get_mapping(&Aesthetic::Color).is_some();
889            let has_fill = merged_mapping.get_mapping(&Aesthetic::Fill).is_some();
890            if !has_color && !has_fill {
891                geom.set_series_color(color);
892            }
893        }
894
895        // Step 3: Evaluate aes — rename columns to canonical names
896        let mut working_data = resolve_mappings(source_data, &merged_mapping, warnings);
897
898        // D3 (B3-5): drop a "mapped-only" column (e.g. `lower`/`middle`/
899        // `upper`/`outliers` for the boxplot) unless the user actually
900        // mapped that aesthetic — so a raw column that merely happens to
901        // share the name (a SQL alias, say) is never mistaken for one, and
902        // so a raw `lower` is never drawn as a boxplot hinge on
903        // `geom_boxplot` itself.
904        //
905        // Review #158's *final check* found that making this drop
906        // universal (every geom, round 3's `MAPPED_ONLY_COLS`) was itself
907        // a regression: it removed the raw `lower`/`middle`/`upper`/
908        // `outliers` columns from a layer's data entirely, so a forecast
909        // chart's `tooltip("{y} [{lower}, {upper}]")` on an unmapped
910        // `geom_line` rendered `[, ]` instead of the actual bounds, and
911        // `facet_wrap("upper")` on an unmapped column silently rendered
912        // unfaceted. ggplot2 never drops a raw data column just because
913        // its name matches an unused extension aesthetic — it only ever
914        // special-cases `lower`/`middle`/`upper`/`outliers` for
915        // `geom_boxplot`'s own hinges, so this drop goes back to being
916        // scoped to `geom.mapped_only_columns()` alone (empty by default,
917        // `GeomBoxplot` returns the four names). The narrower position/
918        // non-finite/y-scale-training concerns from the *previous*
919        // re-review (the forecast y-domain-stretch/row-drop regression)
920        // are instead handled per layer below (`raw_unmapped`), without
921        // ever removing the columns from `working_data`.
922        for col in geom.mapped_only_columns() {
923            let is_mapped = merged_mapping
924                .mappings
925                .iter()
926                .any(|m| m.aesthetic.col_name() == *col);
927            if !is_mapped {
928                working_data.remove_column(col);
929            }
930        }
931
932        // `lower`/`middle`/`upper` present in the raw (pre-stat) data under
933        // their canonical name, but *not* actually mapped as that
934        // aesthetic on this layer — e.g. a forecast's confidence-bound
935        // columns, never mapped on a plain `geom_line`/`geom_point` layer.
936        // These stay in `working_data` (see above), but mustn't be treated
937        // as position data a geom never asked for: excluded below from the
938        // non-finite/NA position drop, the `Y_FAMILY` pre-stat scale
939        // transform, and the y-scale training.
940        //
941        // Computed once, right here (before the stat runs), so it reflects
942        // only what the *raw* data actually had under these names.
943        // `GeomBoxplot`'s own default (non-identity) stat produces *fresh*
944        // `lower`/`middle`/`upper` columns later (not present yet, so never
945        // in this set) — those stay subject to all three, unaffected; an
946        // identity-stat boxplot has them mapped (required by
947        // `validate_layer` below), so they're not in this set either.
948        let raw_unmapped: Vec<&'static str> = ["lower", "middle", "upper"]
949            .into_iter()
950            .filter(|col| {
951                working_data.has_column(col)
952                    && !merged_mapping
953                        .mappings
954                        .iter()
955                        .any(|m| m.aesthetic.col_name() == *col)
956            })
957            .collect();
958
959        // Remember which columns the user actually supplied (pre-stat). A required
960        // aesthetic is satisfied if it was present here OR is synthesized by the
961        // stat (checked after Step 6) — e.g. boxplot maps `y` then the stat turns
962        // it into ymin/ymax, while StatEcdf produces `y` that wasn't mapped.
963        let pre_stat_columns: Vec<String> = working_data
964            .column_names()
965            .iter()
966            .map(|s| s.to_string())
967            .collect();
968
969        // B3-5: stat-dependent validation beyond `required_aes` (e.g.
970        // `geom_boxplot` requires lower/middle/upper under stat="identity",
971        // else y) — before Step 4, so a bad mapping fails before any scale
972        // work happens.
973        if let Err(msg) = geom.validate_layer(&pre_stat_columns, stat.name()) {
974            return Err(GGError::ValidationError(msg));
975        }
976
977        // Step 4: Ensure scales exist for each mapped aesthetic. An
978        // `after_stat` mapping's column only exists after the stat, so its
979        // scale is chosen from the stat output (Step 6a below) — choosing it
980        // here would see no values and default a discrete fill to continuous.
981        for m in &merged_mapping.mappings {
982            if m.stage != crate::aes::MappingStage::AfterStat {
983                scale_set.ensure_scale(&m.aesthetic, &working_data);
984            }
985        }
986
987        // Step 5: Scale transformation (e.g., log10 before stats). A position
988        // scale transforms every column of its family (y also ymin/ymax/…).
989        for scale in scale_set.iter() {
990            let aes = scale.aesthetic();
991            let cols: Vec<&str> = match aes {
992                Aesthetic::X => X_FAMILY.to_vec(),
993                // `lower`/`middle`/`upper` present but unmapped (raw
994                // forecast-shaped passthrough columns, say) are not
995                // position data this layer's geom reads, so a log/sqrt/…
996                // y-scale transform must not touch them (review #158's
997                // final check).
998                Aesthetic::Y => Y_FAMILY
999                    .iter()
1000                    .copied()
1001                    .filter(|c| !raw_unmapped.contains(c))
1002                    .collect(),
1003                _ => vec![aes.col_name()],
1004            };
1005            for col_name in cols {
1006                let Some(col) = working_data.column(col_name) else {
1007                    continue;
1008                };
1009                // A transform that is undefined for a value (log of 0 or a
1010                // negative) yields NaN, so the non-finite filter below drops
1011                // the row with a warning instead of drawing it at 0.
1012                let transformed: Vec<_> = col
1013                    .iter()
1014                    .map(|v| match (scale.transform(v), v.as_f64()) {
1015                        (crate::data::Value::Na, Some(_)) => crate::data::Value::Float(f64::NAN),
1016                        (t, _) => t,
1017                    })
1018                    .collect();
1019                let any_changed = transformed.iter().zip(col.iter()).any(|(t, o)| {
1020                    match (t.as_f64(), o.as_f64()) {
1021                        (Some(a), Some(b)) => a.is_nan() || (a - b).abs() > f64::EPSILON,
1022                        _ => false,
1023                    }
1024                });
1025                if any_changed {
1026                    if let Some(col_mut) = working_data.column_mut(col_name) {
1027                        *col_mut = transformed;
1028                    }
1029                }
1030            }
1031        }
1032
1033        // Step 5a: a binned position scale moves each x / y to its bin's
1034        // midpoint before the stat (`scale_x_binned`, #408).
1035        for (aes, col) in [(Aesthetic::X, "x"), (Aesthetic::Y, "y")] {
1036            let Some(values) = working_data.column(col).map(|c| c.to_vec()) else {
1037                continue;
1038            };
1039            if let Some(binned) = scale_set
1040                .get_mut(&aes)
1041                .and_then(|s| s.bin_positions(&values))
1042            {
1043                if let Some(c) = working_data.column_mut(col) {
1044                    *c = binned;
1045                }
1046            }
1047        }
1048
1049        // Step 5b: Filter out-of-bounds data (xlim/ylim filter before stats)
1050        Self::filter_oob_data(&mut working_data, scale_set);
1051
1052        // Step 5c: Drop rows whose position is NaN (or ±Inf, unless the geom
1053        // reads infinities as "panel edge"), like ggplot2's
1054        // "Removed n rows containing non-finite values".
1055        let geom_label = format!("geom_{}", geom.name());
1056        let removed = Self::drop_non_finite(
1057            &mut working_data,
1058            geom.allows_infinite(),
1059            geom.keeps_na_as_break(),
1060            &raw_unmapped,
1061        );
1062        if removed > 0 {
1063            warnings.push(format!(
1064                "{geom_label}: removed {removed} row{} containing non-finite values",
1065                if removed == 1 { "" } else { "s" }
1066            ));
1067        }
1068        let input_rows = working_data.nrows();
1069
1070        // Validate the stat's own configuration once per layer (e.g.
1071        // `StatBinHex::binwidth` — a problem with fixed parameters, not
1072        // with the data), before it runs per-group below.
1073        stat.validate(warnings);
1074
1075        // Step 6: Compute statistics. Group by aesthetic groups AND the facet
1076        // variables, so a computed stat (density/histogram/…) is estimated per
1077        // panel rather than on pooled data; the facet column is then re-attached
1078        // to each group's output so faceting can split it back out.
1079        // Panelwise stats (e.g. stat_compare_means) see the whole panel at once,
1080        // so we group only by facet variables, not by aesthetic groups.
1081        let mut group_cols = if stat.panelwise() {
1082            Vec::new()
1083        } else {
1084            Self::detect_group_columns(&working_data)
1085        };
1086        for fv in facet_vars {
1087            if working_data.has_column(fv) && !group_cols.contains(fv) {
1088                group_cols.push(fv.clone());
1089            }
1090        }
1091
1092        working_data = if !group_cols.is_empty() {
1093            let groups =
1094                working_data.group_by(&group_cols.iter().map(|s| s.as_str()).collect::<Vec<_>>());
1095            let mut result = DataFrame::new();
1096            for group in groups {
1097                let mut computed = stat.compute_group(&group, scale_set);
1098                let n = computed.nrows();
1099                if n > 0 {
1100                    for fv in facet_vars {
1101                        if !computed.has_column(fv) {
1102                            if let Some(val) = group.column(fv).and_then(|c| c.first()).cloned() {
1103                                computed.add_column(fv.clone(), vec![val; n]);
1104                            }
1105                        }
1106                    }
1107                }
1108                result.vstack(&computed);
1109            }
1110            result
1111        } else {
1112            stat.compute_group(&working_data, scale_set)
1113        };
1114
1115        // Step 6a: Apply after_stat() mappings (rename stat-computed columns)
1116        // and give each one a scale detected from the values it now holds.
1117        apply_after_stat(&mut working_data, &merged_mapping, warnings);
1118        for m in &merged_mapping.mappings {
1119            if m.stage == crate::aes::MappingStage::AfterStat {
1120                scale_set.ensure_scale(&m.aesthetic, &working_data);
1121            }
1122        }
1123
1124        // Stat output can contain NaN (e.g. a fit on degenerate input): drop
1125        // those rows too, so nothing non-finite reaches a geom. `raw_unmapped`
1126        // was computed pre-stat, so a passthrough stat (e.g. `StatIdentity`
1127        // under `geom_line`/`geom_point`) still excludes the same raw,
1128        // unmapped `lower`/`middle`/`upper` here as it did above; a stat
1129        // that *produces* these fresh (`StatBoxplot`'s default, non-identity
1130        // path) was never in that set, since they didn't exist pre-stat.
1131        let removed = Self::drop_non_finite(
1132            &mut working_data,
1133            geom.allows_infinite(),
1134            geom.keeps_na_as_break(),
1135            &raw_unmapped,
1136        );
1137        if removed > 0 {
1138            warnings.push(format!(
1139                "{geom_label}: removed {removed} row{} containing missing values (stat_{} output)",
1140                if removed == 1 { "" } else { "s" },
1141                stat.name()
1142            ));
1143        }
1144
1145        // A layer with no data draws nothing — an empty input, or a stat that
1146        // cannot estimate anything (a density of one value, loess on two
1147        // points). It must not fail the whole plot. A stat's own required
1148        // aesthetics are still validated when there *was* input.
1149        if working_data.nrows() == 0 {
1150            if input_rows > 0 {
1151                for aes in &stat.required_aes() {
1152                    let col_name = aes.col_name();
1153                    if !pre_stat_columns.iter().any(|c| c == col_name) {
1154                        return Err(GGError::ValidationError(format!(
1155                            "stat_{} requires aesthetic '{}' but it was not provided",
1156                            stat.name(),
1157                            col_name
1158                        )));
1159                    }
1160                }
1161                warnings.push(format!(
1162                    "{geom_label}: stat_{} produced no data from {input_rows} row{} \
1163                     (too few or degenerate values); layer skipped",
1164                    stat.name(),
1165                    if input_rows == 1 { "" } else { "s" }
1166                ));
1167            }
1168            return Ok(BuiltLayer {
1169                data: DataFrame::new(),
1170                geom,
1171                show_legend,
1172                key_glyph,
1173                show_legend_aes,
1174            });
1175        }
1176
1177        // Step 6a-validate: A required aesthetic must have been supplied by the
1178        // user (pre-stat) or synthesized by the stat (post-stat). This lets
1179        // StatEcdf produce `y` for geom_step, while boxplot — which maps `y` then
1180        // consumes it into ymin/ymax — still validates. Empty input has the
1181        // column in neither place, so genuinely-missing aesthetics still error.
1182        for aes in &geom.required_aes() {
1183            let col_name = aes.col_name();
1184            let supplied = pre_stat_columns.iter().any(|c| c == col_name);
1185            if !supplied && !working_data.has_column(col_name) {
1186                return Err(GGError::ValidationError(format!(
1187                    "geom_{} requires aesthetic '{}' but it was not provided",
1188                    geom.name(),
1189                    col_name
1190                )));
1191            }
1192        }
1193
1194        // Step 6b: Ensure scales for stat-computed aesthetics (e.g. y from StatCount/StatBin)
1195        let stat_aes = [
1196            ("x", Aesthetic::X),
1197            ("y", Aesthetic::Y),
1198            ("xmin", Aesthetic::X),
1199            ("xmax", Aesthetic::X),
1200            ("ymin", Aesthetic::Y),
1201            ("ymax", Aesthetic::Y),
1202            // Segment / curve ends are positions too (ggplot2 trains them;
1203            // #411's clip audit found segments running off the panel).
1204            ("xend", Aesthetic::X),
1205            ("yend", Aesthetic::Y),
1206            // Boxplot stat="identity" hinges (B3-5): live on the y scale.
1207            ("lower", Aesthetic::Y),
1208            ("middle", Aesthetic::Y),
1209            ("upper", Aesthetic::Y),
1210            // Boxplot outlier extent (B3-5, allowed #6): `GeomBoxplot::
1211            // setup_data` adds these (under an internal-prefix name — see
1212            // review #158 finding 5 and `aes::BOXPLOT_YMIN_FINAL`'s doc,
1213            // so a raw column can never forge them) from the parsed
1214            // `outliers` column, so the y scale trains on them too (step
1215            // 8b below).
1216            (crate::aes::BOXPLOT_YMIN_FINAL, Aesthetic::Y),
1217            (crate::aes::BOXPLOT_YMAX_FINAL, Aesthetic::Y),
1218        ];
1219        // `raw_unmapped` (a raw, unmapped `lower`/`middle`/`upper` passthrough
1220        // column) never trains or gets a scale here — only a layer that
1221        // actually mapped (or whose stat computed) that name does (review
1222        // #158's final check).
1223        for (col, aes) in &stat_aes {
1224            if working_data.has_column(col) && !raw_unmapped.contains(col) {
1225                scale_set.ensure_scale(aes, &working_data);
1226            }
1227        }
1228
1229        // Step 6c: bars/histograms/area draw from a 0 baseline, so the Y scale
1230        // must include 0 — for stat-computed Y (count/bin) and for an explicitly
1231        // mapped Y alike (e.g. geom_col), matching ggplot2.
1232        let y_is_user_mapped = merged_mapping.get_mapping(&Aesthetic::Y).is_some();
1233        if (!y_is_user_mapped || geom.include_zero_baseline()) && working_data.has_column("y") {
1234            if let Some(y_scale) = scale_set.get_mut(&Aesthetic::Y) {
1235                y_scale.train(&[crate::data::Value::Float(0.0)]);
1236            }
1237        }
1238
1239        // Step 7: Position adjustment. `validate` runs once per layer (D2)
1240        // regardless of whether the position is per-panel.
1241        position.validate(warnings);
1242        // #428: dodge groups in the legend's (scale level) order, whatever
1243        // the row order — precomputed `stat = "identity"` rows included.
1244        let params = PositionParams {
1245            group_order: position.group_order(&working_data).map(|order| {
1246                crate::position::dodge::order_by_scale_levels(order, &working_data, scale_set)
1247            }),
1248            ..PositionParams::default()
1249        };
1250        if position.per_panel() {
1251            Self::compute_position_per_panel(
1252                position.as_ref(),
1253                &mut working_data,
1254                &params,
1255                facet_vars,
1256            );
1257        } else {
1258            position.compute(&mut working_data, &params);
1259        }
1260
1261        // Step 7b: Geom-specific setup (e.g. tile/candlestick extents), then
1262        // make sure position scales exist for any extent columns it added.
1263        geom.setup_data(&mut working_data, warnings);
1264        for (col, aes) in &stat_aes {
1265            if working_data.has_column(col) && !raw_unmapped.contains(col) {
1266                scale_set.ensure_scale(aes, &working_data);
1267            }
1268        }
1269
1270        // Step 8: Train scales on this layer's data
1271        scale_set.train_layer(&working_data);
1272        for (col, aes) in &INTERCEPT_COLS {
1273            if let (Some(values), Some(scale)) = (working_data.column(col), scale_set.get_mut(aes))
1274            {
1275                train_intercepts(scale, values);
1276            }
1277        }
1278
1279        // Step 8b: Positional scales also need to see stat-computed extent columns
1280        // (e.g. boxplot/errorbar/pointrange emit ymin/ymax but no "y"). Without
1281        // this the Y (or X) scale would never train on the range and collapse.
1282        // Same `raw_unmapped` exclusion as Step 6b/7b above: an unmapped
1283        // raw `lower`/`middle`/`upper` must never stretch the y domain.
1284        for (col, aes) in &stat_aes {
1285            if raw_unmapped.contains(col) {
1286                continue;
1287            }
1288            if let Some(values) = working_data.column(col) {
1289                if let Some(scale) = scale_set.get_mut(aes) {
1290                    scale.train(values);
1291                }
1292            }
1293        }
1294
1295        // Step 9 (B2-5): render a `tooltip()` template against the built
1296        // (post-stat) data, into the same internal column `Aes::tooltip`
1297        // uses — so it replaces the geom's default `<title>` the same way,
1298        // with no further geom-level plumbing.
1299        if let Some(template) = &tooltip_template {
1300            // Review PR#141 finding 6 (LOW): a template on a geom that
1301            // never reads that internal column (`set_row_extras`) did
1302            // nothing, silently — e.g. `geom_area`/`geom_boxplot`/
1303            // `geom_ribbon`.
1304            if !geom.consumes_tooltips() {
1305                warnings.push(format!(
1306                    "{geom_label}: tooltip(\"{template}\") has no effect — this geom doesn't read tooltips, or the layer is decorative()"
1307                ));
1308            }
1309            let rendered: Vec<crate::data::Value> = (0..working_data.nrows())
1310                .map(|i| {
1311                    crate::data::Value::Str(render_tooltip_template(
1312                        template,
1313                        &working_data,
1314                        scale_set,
1315                        i,
1316                    ))
1317                })
1318                .collect();
1319            working_data.add_column(crate::aes::TOOLTIP_INTERNAL_NAME.to_string(), rendered);
1320        }
1321
1322        // #221: sanitize `Aes::class` values once per layer — keep valid
1323        // tokens, drop the rest with one warning — so `set_row_extras`
1324        // hands the backend only clean tokens.
1325        if let Some(col) = working_data.column_mut(crate::aes::CLASS_INTERNAL_NAME) {
1326            let mut rejected: Vec<String> = Vec::new();
1327            for v in col.iter_mut() {
1328                if v.is_na() {
1329                    continue;
1330                }
1331                let (ok, bad) = crate::aes::split_class_tokens(&crate::geom::tip_value(v));
1332                for b in bad {
1333                    if !rejected.contains(&b) {
1334                        rejected.push(b);
1335                    }
1336                }
1337                *v = crate::data::Value::Str(ok.join(" "));
1338            }
1339            if !rejected.is_empty() {
1340                let shown: Vec<&String> = rejected.iter().take(5).collect();
1341                warnings.push(format!(
1342                    "{geom_label}: class: {} invalid class token(s) dropped (must match [A-Za-z][A-Za-z0-9_-]*), e.g. {shown:?}",
1343                    rejected.len()
1344                ));
1345            }
1346        }
1347
1348        Ok(BuiltLayer {
1349            data: working_data,
1350            geom,
1351            show_legend,
1352            key_glyph,
1353            show_legend_aes,
1354        })
1355    }
1356
1357    /// Remove rows where x or y falls outside scale limits set via xlim/ylim.
1358    fn filter_oob_data(data: &mut DataFrame, scale_set: &ScaleSet) {
1359        let x_limits = scale_set.get(&Aesthetic::X).and_then(|s| s.filter_limits());
1360        let y_limits = scale_set.get(&Aesthetic::Y).and_then(|s| s.filter_limits());
1361
1362        if x_limits.is_none() && y_limits.is_none() {
1363            return;
1364        }
1365
1366        let nrows = data.nrows();
1367        let mut keep = vec![true; nrows];
1368
1369        if let Some((min, max)) = x_limits {
1370            if let Some(col) = data.column("x") {
1371                for (i, v) in col.iter().enumerate() {
1372                    if let Some(f) = v.as_f64() {
1373                        if f < min || f > max {
1374                            keep[i] = false;
1375                        }
1376                    }
1377                }
1378            }
1379        }
1380
1381        if let Some((min, max)) = y_limits {
1382            if let Some(col) = data.column("y") {
1383                for (i, v) in col.iter().enumerate() {
1384                    if let Some(f) = v.as_f64() {
1385                        if f < min || f > max {
1386                            keep[i] = false;
1387                        }
1388                    }
1389                }
1390            }
1391        }
1392
1393        // If nothing was filtered, skip the rebuild
1394        if keep.iter().all(|&k| k) {
1395            return;
1396        }
1397
1398        let indices: Vec<usize> = keep
1399            .iter()
1400            .enumerate()
1401            .filter(|(_, &k)| k)
1402            .map(|(i, _)| i)
1403            .collect();
1404
1405        let mut result = DataFrame::new();
1406        for col_name in data.column_names() {
1407            if let Some(src) = data.column(col_name) {
1408                let vals: Vec<_> = indices.iter().map(|&i| src[i].clone()).collect();
1409                result.add_column(col_name.to_string(), vals);
1410            }
1411        }
1412        *data = result;
1413    }
1414
1415    /// Compute per-panel scale sets for free facet scales.
1416    /// For each panel, clones the base scale set, resets freed axes, and retrains on panel data.
1417    fn compute_panel_scales(
1418        facet_scales: &FacetScales,
1419        panels_data: &[Vec<DataFrame>],
1420        base_scales: &ScaleSet,
1421    ) -> Vec<ScaleSet> {
1422        if matches!(facet_scales, FacetScales::Fixed) || panels_data.is_empty() {
1423            return vec![];
1424        }
1425
1426        let free_x = matches!(facet_scales, FacetScales::FreeX | FacetScales::Free);
1427        let free_y = matches!(facet_scales, FacetScales::FreeY | FacetScales::Free);
1428
1429        panels_data
1430            .iter()
1431            .map(|panel_layers| {
1432                let mut panel_set = base_scales.clone();
1433
1434                // Reset freed axis scales
1435                if free_x {
1436                    if let Some(s) = panel_set.get_mut(&Aesthetic::X) {
1437                        s.reset_training();
1438                    }
1439                }
1440                if free_y {
1441                    if let Some(s) = panel_set.get_mut(&Aesthetic::Y) {
1442                        s.reset_training();
1443                    }
1444                }
1445
1446                // Retrain on this panel's data, including extent columns
1447                // (bars/tiles/ranges), so free scales cover whole marks.
1448                for layer_data in panel_layers {
1449                    panel_set.train_layer(layer_data);
1450                    for (cols, aes) in [
1451                        (["xmin", "xmax", "xend", "xintercept"], Aesthetic::X),
1452                        (["ymin", "ymax", "yend", "yintercept"], Aesthetic::Y),
1453                    ] {
1454                        let freed = match aes {
1455                            Aesthetic::X => free_x,
1456                            _ => free_y,
1457                        };
1458                        if !freed {
1459                            continue;
1460                        }
1461                        for c in cols {
1462                            if let (Some(vals), Some(s)) =
1463                                (layer_data.column(c), panel_set.get_mut(&aes))
1464                            {
1465                                if c.ends_with("intercept") {
1466                                    train_intercepts(s, vals);
1467                                } else {
1468                                    s.train(vals);
1469                                }
1470                            }
1471                        }
1472                    }
1473                }
1474
1475                panel_set
1476            })
1477            .collect()
1478    }
1479
1480    /// Compute which aesthetics should be suppressed from the legend.
1481    /// An aesthetic is suppressed if every layer that has the corresponding column
1482    /// sets show_legend=Some(false), and no layer has it as None or Some(true).
1483    fn compute_suppressed_aes(built_layers: &[BuiltLayer]) -> std::collections::HashSet<Aesthetic> {
1484        use std::collections::HashSet;
1485        let legend_aes = [
1486            Aesthetic::Color,
1487            Aesthetic::Fill,
1488            Aesthetic::Shape,
1489            Aesthetic::Linetype,
1490            Aesthetic::Size,
1491            Aesthetic::Alpha,
1492            Aesthetic::Linewidth,
1493        ];
1494        let mut suppressed = HashSet::new();
1495        for aes in &legend_aes {
1496            let col_name = aes.col_name();
1497            let mut any_has = false;
1498            let mut all_hidden = true;
1499            for bl in built_layers {
1500                if bl.data.has_column(col_name) {
1501                    any_has = true;
1502                    match bl.shows_legend_for(aes) {
1503                        Some(false) => {} // still hidden
1504                        _ => {
1505                            all_hidden = false;
1506                            break;
1507                        }
1508                    }
1509                }
1510            }
1511            if any_has && all_hidden {
1512                suppressed.insert(aes.clone());
1513            }
1514        }
1515        suppressed
1516    }
1517
1518    /// Run a per-panel [`Position`](crate::position::Position) (B3-3, D8):
1519    /// partition the rows by the facet variables' joined key, in
1520    /// first-seen order, run `compute` on each panel separately, then
1521    /// scatter the rows back into their original positions — NA-filling
1522    /// any column a panel's `compute` added that another panel's didn't
1523    /// (there currently are none in practice: `PositionStack`/
1524    /// `PositionFill` add the same `ymin` everywhere). Falls back to the
1525    /// plain pooled `compute` when there's no facet column, or only one
1526    /// distinct panel — both byte-identical to the pooled path, since
1527    /// there's nothing to partition.
1528    fn compute_position_per_panel(
1529        position: &dyn crate::position::Position,
1530        data: &mut DataFrame,
1531        params: &PositionParams,
1532        facet_vars: &[String],
1533    ) {
1534        let active_vars: Vec<&str> = facet_vars
1535            .iter()
1536            .map(|s| s.as_str())
1537            .filter(|v| data.has_column(v))
1538            .collect();
1539        if active_vars.is_empty() {
1540            position.compute(data, params);
1541            return;
1542        }
1543
1544        let n = data.nrows();
1545        let mut order: Vec<String> = Vec::new();
1546        let mut groups: indexmap::IndexMap<String, Vec<usize>> = indexmap::IndexMap::new();
1547        for i in 0..n {
1548            let key = active_vars
1549                .iter()
1550                .map(|v| data.column(v).unwrap()[i].key_str().into_owned())
1551                .collect::<Vec<_>>()
1552                .join("\u{1}");
1553            if !groups.contains_key(&key) {
1554                order.push(key.clone());
1555            }
1556            groups.entry(key).or_default().push(i);
1557        }
1558        if order.len() < 2 {
1559            position.compute(data, params);
1560            return;
1561        }
1562
1563        // Review #167 finding 2: a per-panel position's group *rank*
1564        // (e.g. `PositionDodge`) must come from the whole layer, computed
1565        // here against the still-pooled `data` — before it's split into
1566        // the panels below — so every panel ranks a given group the same
1567        // way regardless of which group its own rows happen to list
1568        // first (sipemu/ggplot-rs#153; matches 0.22.2 and ggplot2 4.0.3's
1569        // global `group` id). Positions that don't override `group_order`
1570        // (stack/fill/jitter) get `None` back, unchanged from before.
1571        let mut panel_params = params.clone();
1572        if panel_params.group_order.is_none() {
1573            panel_params.group_order = position.group_order(data);
1574        }
1575
1576        let col_names: Vec<String> = data.column_names().iter().map(|s| s.to_string()).collect();
1577        let mut panels: Vec<(Vec<usize>, DataFrame)> = Vec::with_capacity(order.len());
1578        for key in &order {
1579            let indices = &groups[key];
1580            let mut sub = DataFrame::new();
1581            for name in &col_names {
1582                let col = data.column(name).unwrap();
1583                let values: Vec<crate::data::Value> =
1584                    indices.iter().map(|&i| col[i].clone()).collect();
1585                sub.add_column(name.clone(), values);
1586            }
1587            position.compute(&mut sub, &panel_params);
1588            panels.push((indices.clone(), sub));
1589        }
1590
1591        // Union of every column present after `compute` (new columns like
1592        // `ymin` may have been added), so a panel that for some reason
1593        // didn't add one still gets NA there rather than a stale value.
1594        let mut all_cols = col_names.clone();
1595        for (_, sub) in &panels {
1596            for name in sub.column_names() {
1597                if !all_cols.iter().any(|c| c == name) {
1598                    all_cols.push(name.to_string());
1599                }
1600            }
1601        }
1602        for name in &all_cols {
1603            let mut full = vec![crate::data::Value::Na; n];
1604            for (indices, sub) in &panels {
1605                if let Some(col) = sub.column(name) {
1606                    for (j, &i) in indices.iter().enumerate() {
1607                        full[i] = col[j].clone();
1608                    }
1609                }
1610            }
1611            data.add_column(name.clone(), full);
1612        }
1613    }
1614
1615    /// Detect which columns to group by for statistics.
1616    /// Checks group/color/fill plus discrete x (like R's auto-grouping by discrete x).
1617    fn detect_group_columns(data: &DataFrame) -> Vec<String> {
1618        detect_group_columns(data)
1619    }
1620}
1621
1622/// The columns whose discrete values split a layer's rows into aesthetic
1623/// groups (`group`, `color`, `fill`, a discrete `x`). Also used by panelwise
1624/// stats that regroup inside a panel ([`crate::stat::align::StatAlign`]).
1625pub(crate) fn detect_group_columns(data: &DataFrame) -> Vec<String> {
1626    let candidates = ["group", "color", "fill", "x"];
1627    let mut group_cols = Vec::new();
1628    for &col in &candidates {
1629        if data.has_column(col) {
1630            if let Some(values) = data.column(col) {
1631                let is_discrete = values
1632                    .iter()
1633                    .any(|v| matches!(v, crate::data::Value::Str(_)));
1634                if is_discrete {
1635                    group_cols.push(col.to_string());
1636                }
1637            }
1638        }
1639    }
1640    group_cols
1641}