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