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}
18
19/// A fully built plot ready for rendering.
20pub struct BuiltPlot {
21    pub layers: Vec<BuiltLayer>,
22    pub scales: ScaleSet,
23    pub coord: Box<dyn Coord>,
24    pub theme: Theme,
25    pub labels: Labels,
26    pub facet: Facet,
27    pub panels: Vec<Panel>,
28    /// Per-panel layer data. panels_data[panel_idx][layer_idx] = data for that panel+layer.
29    pub panels_data: Vec<Vec<DataFrame>>,
30    pub annotations: Vec<Annotation>,
31    pub guide_legend: crate::guide::config::GuideLegend,
32    /// Aesthetics suppressed from the legend (all layers with that aes set show_legend=false).
33    pub suppressed_aes: std::collections::HashSet<Aesthetic>,
34    /// Per-panel scale sets for free facets. Empty when FacetScales::Fixed.
35    pub panel_scales: Vec<ScaleSet>,
36    /// Non-fatal problems found while building (ggplot2's warnings), e.g.
37    /// "geom_point: removed 2 rows containing non-finite values". See
38    /// [`BuiltPlot::warnings`].
39    pub warnings: Vec<String>,
40}
41
42impl BuiltPlot {
43    /// Non-fatal build warnings, in layer order: rows dropped for non-finite
44    /// positions, and layers skipped because their stat produced no data
45    /// (e.g. a density of a single value). Rendering still succeeds.
46    pub fn warnings(&self) -> &[String] {
47        &self.warnings
48    }
49}
50
51/// Columns holding *position* values: rows with non-finite values here are
52/// dropped before stats (and again after, for stat output).
53const POSITION_COLS: &[&str] = &[
54    "x",
55    "y",
56    "xmin",
57    "xmax",
58    "ymin",
59    "ymax",
60    "xend",
61    "yend",
62    "open",
63    "high",
64    "low",
65    "close",
66    "xintercept",
67    "yintercept",
68    "slope",
69    "intercept",
70];
71
72/// Columns a position scale's transformation applies to (pre-stat).
73const X_FAMILY: &[&str] = &["x", "xmin", "xmax", "xend", "xintercept"];
74const Y_FAMILY: &[&str] = &[
75    "y",
76    "ymin",
77    "ymax",
78    "yend",
79    "open",
80    "high",
81    "low",
82    "close",
83    "yintercept",
84];
85
86/// Reference-line intercept columns and the position scale they train
87/// (ggplot2: `xintercept`/`yintercept` are x/y aesthetics).
88const INTERCEPT_COLS: [(&str, Aesthetic); 2] =
89    [("xintercept", Aesthetic::X), ("yintercept", Aesthetic::Y)];
90
91/// Train `scale` on reference-line intercepts. A numeric intercept on a
92/// discrete axis is a position *between* categories (ggplot2), not a level.
93fn train_intercepts(scale: &mut Box<dyn crate::scale::Scale>, values: &[crate::data::Value]) {
94    if scale.is_discrete() && values.iter().all(|v| v.as_f64().is_some() || v.is_na()) {
95        return;
96    }
97    scale.train(values);
98}
99
100/// The grammar pipeline: transforms a GGPlot specification into render-ready data.
101pub struct PlotBuilder;
102
103impl PlotBuilder {
104    pub fn build(plot: GGPlot) -> Result<BuiltPlot, GGError> {
105        let GGPlot {
106            data: plot_data,
107            mapping: plot_mapping,
108            layers,
109            scales: user_scales,
110            mut coord,
111            theme,
112            labels,
113            facet,
114            annotations,
115            guide_legend,
116            warnings: plot_warnings,
117            default_aspect_ratio,
118            locale,
119            svg_root_data: _,
120        } = plot;
121        let mut theme = theme;
122        if theme.aspect_ratio.is_none() {
123            theme.aspect_ratio = default_aspect_ratio;
124        }
125
126        // Malformed input (e.g. mismatched column lengths) is reported here as
127        // a validation error instead of panicking when the frame is assembled.
128        plot_data.validate()?;
129        for layer in &layers {
130            if let Some(d) = &layer.data {
131                d.validate()?;
132            }
133        }
134
135        let mut scale_set = ScaleSet::new();
136
137        // Add user-specified scales
138        for s in user_scales {
139            scale_set.add(s);
140        }
141
142        let mut built_layers = Vec::new();
143        let mut warnings: Vec<String> = plot_warnings;
144
145        // Faceting variables — used to group stat computation per panel so a
146        // computed stat (density/histogram) is estimated per panel, not pooled.
147        let facet_vars = Self::facet_vars(&facet);
148
149        for layer in layers {
150            let built = Self::build_layer(
151                layer,
152                &plot_data,
153                &plot_mapping,
154                &mut scale_set,
155                theme.primary,
156                &facet_vars,
157                &mut warnings,
158            )?;
159            built_layers.push(built);
160        }
161
162        // Final scale training pass across all layers
163        for bl in &built_layers {
164            scale_set.train_layer(&bl.data);
165            for (col, aes) in &INTERCEPT_COLS {
166                // No layer created this position scale: the intercepts do.
167                if let (Some(values), None) = (bl.data.column(col), scale_set.get(aes)) {
168                    let mut frame = DataFrame::new();
169                    frame.add_column(aes.col_name().to_string(), values.to_vec());
170                    scale_set.ensure_scale(aes, &frame);
171                }
172                if let (Some(values), Some(scale)) = (bl.data.column(col), scale_set.get_mut(aes)) {
173                    train_intercepts(scale, values);
174                }
175            }
176        }
177
178        // Validate each scale's final configuration (e.g. a transform
179        // undefined at its own domain limits) now that training is done —
180        // `map`/`map_to_size` run per mark at draw time and have no
181        // warnings sink of their own.
182        for scale in scale_set.iter() {
183            scale.validate(&mut warnings);
184        }
185
186        // An empty plot (no layers, or only layers without data) still gets a
187        // panel with axes: make sure both position scales exist. They stay
188        // untrained, so they draw no breaks and map everything to the centre.
189        if built_layers.iter().all(|bl| bl.data.nrows() == 0) {
190            for aes in [Aesthetic::X, Aesthetic::Y] {
191                if scale_set.get(&aes).is_none() {
192                    scale_set.add(Box::new(
193                        crate::scale::continuous::ScaleContinuous::new().for_aesthetic(aes),
194                    ));
195                }
196            }
197        }
198
199        // Apply coord zoom limits (coord_cartesian xlim/ylim)
200        if let Some((min, max)) = coord.zoom_x() {
201            scale_set.set_limits(&Aesthetic::X, min, max);
202        }
203        if let Some((min, max)) = coord.zoom_y() {
204            scale_set.set_limits(&Aesthetic::Y, min, max);
205        }
206
207        // Let the coordinate system adjust/inspect the trained scales.
208        coord.train_scales(&mut scale_set);
209
210        // Supply trained axis spans to the coordinate system (used by coord_trans).
211        // pmin/pmax are the panel positions of the domain endpoints, so the coord
212        // can invert the scale's (linearly expanded) mapping exactly.
213        let axis_span = |aes: &Aesthetic| {
214            scale_set.get(aes).and_then(|s| {
215                s.domain().map(|(min, max)| crate::coord::AxisSpan {
216                    min,
217                    max,
218                    pmin: s.map(&crate::data::Value::Float(min)),
219                    pmax: s.map(&crate::data::Value::Float(max)),
220                })
221            })
222        };
223        let x_span = axis_span(&Aesthetic::X);
224        let y_span = axis_span(&Aesthetic::Y);
225        coord.set_domains(x_span, y_span);
226
227        // Apply after_scale() color derivations: copy the source aesthetic's
228        // column to the target and register a lightness-modified clone of the
229        // source scale, so the target aesthetic draws the mapped source color
230        // adjusted in lightness (e.g. a darker border derived from the fill).
231        for spec in &plot_mapping.after_scale {
232            if let Some(src_scale) = scale_set.get(&spec.source) {
233                let modified = crate::scale::modified::ScaleColorModified::new(
234                    src_scale.clone_box(),
235                    spec.target.clone(),
236                    spec.lightness,
237                );
238                let (src_col, tgt_col) = (spec.source.col_name(), spec.target.col_name());
239                for bl in &mut built_layers {
240                    if !bl.data.has_column(tgt_col) {
241                        if let Some(vals) = bl.data.column(src_col) {
242                            let vals = vals.to_vec();
243                            bl.data.add_column(tgt_col.to_string(), vals);
244                        }
245                    }
246                }
247                scale_set.add(Box::new(modified));
248            }
249        }
250
251        // Plot-level locale for default break labels (before panel scales are
252        // cloned from the base set, so free facets inherit it).
253        if let Some(locale) = &locale {
254            scale_set.set_default_locale(locale);
255        }
256
257        // Compute facet panels
258        let (panels, panels_data) = Self::compute_facets(&facet, &built_layers, &plot_data);
259
260        // Compute suppressed aesthetics from show_legend flags.
261        let suppressed_aes = Self::compute_suppressed_aes(&built_layers);
262
263        // Compute per-panel scales for free facets
264        let facet_scales_mode = match &facet {
265            Facet::Wrap { scales, .. } => scales.clone(),
266            Facet::Grid { scales, .. } => scales.clone(),
267            Facet::None => FacetScales::Fixed,
268        };
269        let panel_scales = Self::compute_panel_scales(&facet_scales_mode, &panels_data, &scale_set);
270
271        Ok(BuiltPlot {
272            layers: built_layers,
273            scales: scale_set,
274            coord,
275            theme,
276            labels,
277            facet,
278            panels,
279            panels_data,
280            annotations,
281            guide_legend,
282            suppressed_aes,
283            panel_scales,
284            warnings,
285        })
286    }
287
288    /// Keep only the rows whose `keep` flag is set.
289    fn retain_rows(data: &mut DataFrame, keep: &[bool]) {
290        if keep.iter().all(|&k| k) {
291            return;
292        }
293        let mut result = DataFrame::new();
294        for col_name in data.column_names() {
295            if let Some(src) = data.column(col_name) {
296                let vals: Vec<_> = src
297                    .iter()
298                    .zip(keep)
299                    .filter(|(_, &k)| k)
300                    .map(|(v, _)| v.clone())
301                    .collect();
302                result.add_column(col_name.to_string(), vals);
303            }
304        }
305        *data = result;
306    }
307
308    /// Drop rows with a non-finite value in any position column (`NaN` always;
309    /// `±Inf` unless the geom gives infinities meaning). Returns the number of
310    /// rows removed.
311    fn drop_non_finite(data: &mut DataFrame, allow_infinite: bool) -> usize {
312        let n = data.nrows();
313        let mut keep = vec![true; n];
314        for col in POSITION_COLS {
315            if let Some(values) = data.column(col) {
316                for (i, v) in values.iter().enumerate() {
317                    if let crate::data::Value::Float(f) = v {
318                        let bad = f.is_nan() || (f.is_infinite() && !allow_infinite);
319                        if bad {
320                            keep[i] = false;
321                        }
322                    }
323                }
324            }
325        }
326        let removed = keep.iter().filter(|&&k| !k).count();
327        if removed > 0 {
328            Self::retain_rows(data, &keep);
329        }
330        removed
331    }
332
333    /// The column name(s) a facet splits on, if any.
334    fn facet_vars(facet: &Facet) -> Vec<String> {
335        match facet {
336            Facet::None => vec![],
337            Facet::Wrap { var, .. } => vec![var.clone()],
338            Facet::Grid {
339                row_var, col_var, ..
340            } => row_var.iter().chain(col_var.iter()).cloned().collect(),
341        }
342    }
343
344    fn compute_facets(
345        facet: &Facet,
346        built_layers: &[BuiltLayer],
347        _plot_data: &DataFrame,
348    ) -> (Vec<Panel>, Vec<Vec<DataFrame>>) {
349        match facet {
350            Facet::None => (vec![], vec![]),
351            Facet::Wrap {
352                var,
353                ncol,
354                labeller,
355                ..
356            } => {
357                // Collect unique levels from all layers' data
358                let mut levels: Vec<String> = Vec::new();
359                for bl in built_layers {
360                    if let Some(col) = bl.data.column(var) {
361                        for v in col {
362                            let key = v.to_group_key();
363                            if !levels.contains(&key) {
364                                levels.push(key);
365                            }
366                        }
367                    }
368                }
369
370                // Panels will be positioned during rendering (depends on layout)
371                let panels: Vec<Panel> = levels
372                    .iter()
373                    .enumerate()
374                    .map(|(i, value)| {
375                        let ncols =
376                            ncol.unwrap_or_else(|| (levels.len() as f64).sqrt().ceil() as usize);
377                        let formatted = labeller.format(var, value);
378                        Panel {
379                            row: i / ncols.max(1),
380                            col: i % ncols.max(1),
381                            label: formatted.clone(),
382                            row_label: None,
383                            col_label: Some(formatted),
384                            rect: crate::render::Rect {
385                                x: 0.0,
386                                y: 0.0,
387                                width: 0.0,
388                                height: 0.0,
389                            },
390                        }
391                    })
392                    .collect();
393
394                // Split data per panel per layer
395                let panels_data: Vec<Vec<DataFrame>> = levels
396                    .iter()
397                    .map(|level| {
398                        built_layers
399                            .iter()
400                            .map(|bl| Self::filter_data_by_var(&bl.data, var, level))
401                            .collect()
402                    })
403                    .collect();
404
405                (panels, panels_data)
406            }
407            Facet::Grid {
408                row_var,
409                col_var,
410                labeller,
411                ..
412            } => {
413                let mut row_levels: Vec<String> = Vec::new();
414                let mut col_levels: Vec<String> = Vec::new();
415
416                for bl in built_layers {
417                    if let Some(rv) = row_var {
418                        if let Some(col) = bl.data.column(rv) {
419                            for v in col {
420                                let key = v.to_group_key();
421                                if !row_levels.contains(&key) {
422                                    row_levels.push(key);
423                                }
424                            }
425                        }
426                    }
427                    if let Some(cv) = col_var {
428                        if let Some(col) = bl.data.column(cv) {
429                            for v in col {
430                                let key = v.to_group_key();
431                                if !col_levels.contains(&key) {
432                                    col_levels.push(key);
433                                }
434                            }
435                        }
436                    }
437                }
438
439                if row_levels.is_empty() {
440                    row_levels.push("".to_string());
441                }
442                if col_levels.is_empty() {
443                    col_levels.push("".to_string());
444                }
445
446                let mut panels = Vec::new();
447                let mut panels_data = Vec::new();
448
449                for (ri, rl) in row_levels.iter().enumerate() {
450                    for (ci, cl) in col_levels.iter().enumerate() {
451                        let row_fmt = if rl.is_empty() {
452                            None
453                        } else {
454                            let rv = row_var.as_deref().unwrap_or("");
455                            Some(labeller.format(rv, rl))
456                        };
457                        let col_fmt = if cl.is_empty() {
458                            None
459                        } else {
460                            let cv = col_var.as_deref().unwrap_or("");
461                            Some(labeller.format(cv, cl))
462                        };
463                        let label = match (&row_fmt, &col_fmt) {
464                            (Some(r), Some(c)) => format!("{r} | {c}"),
465                            (Some(r), None) => r.clone(),
466                            (None, Some(c)) => c.clone(),
467                            (None, None) => String::new(),
468                        };
469                        panels.push(Panel {
470                            row: ri,
471                            col: ci,
472                            label,
473                            row_label: row_fmt,
474                            col_label: col_fmt,
475                            rect: crate::render::Rect {
476                                x: 0.0,
477                                y: 0.0,
478                                width: 0.0,
479                                height: 0.0,
480                            },
481                        });
482
483                        let layer_data: Vec<DataFrame> = built_layers
484                            .iter()
485                            .map(|bl| {
486                                let mut data = bl.data.clone();
487                                if let Some(rv) = row_var {
488                                    if !rl.is_empty() {
489                                        data = Self::filter_data_by_var(&data, rv, rl);
490                                    }
491                                }
492                                if let Some(cv) = col_var {
493                                    if !cl.is_empty() {
494                                        data = Self::filter_data_by_var(&data, cv, cl);
495                                    }
496                                }
497                                data
498                            })
499                            .collect();
500                        panels_data.push(layer_data);
501                    }
502                }
503
504                (panels, panels_data)
505            }
506        }
507    }
508
509    fn filter_data_by_var(data: &DataFrame, var: &str, level: &str) -> DataFrame {
510        if let Some(col) = data.column(var) {
511            let indices: Vec<usize> = col
512                .iter()
513                .enumerate()
514                .filter(|(_, v)| v.to_group_key() == level)
515                .map(|(i, _)| i)
516                .collect();
517
518            let mut result = DataFrame::new();
519            for col_name in data.column_names() {
520                if let Some(src) = data.column(col_name) {
521                    let vals: Vec<_> = indices.iter().map(|&i| src[i].clone()).collect();
522                    result.add_column(col_name.to_string(), vals);
523                }
524            }
525            result
526        } else {
527            data.clone()
528        }
529    }
530
531    fn build_layer(
532        layer: Layer,
533        plot_data: &DataFrame,
534        plot_mapping: &Aes,
535        scale_set: &mut ScaleSet,
536        primary: Option<(u8, u8, u8)>,
537        facet_vars: &[String],
538        warnings: &mut Vec<String>,
539    ) -> Result<BuiltLayer, GGError> {
540        let Layer {
541            data: layer_data,
542            mapping: layer_mapping,
543            mut geom,
544            stat,
545            position,
546            params: _,
547            show_legend,
548            explicit_style,
549        } = layer;
550
551        // Step 1: Resolve data — use layer data if provided, else plot data
552        // Borrow the plot data when the layer has none of its own (no per-layer
553        // full-frame clone; `resolve_mappings` copies only the columns it needs).
554        let source_data = layer_data.as_ref().unwrap_or(plot_data);
555
556        // Step 2: Merge mappings — layer overrides plot-level
557        // (Reference lines don't inherit the plot mapping — ggplot2's
558        // `inherit.aes = FALSE`.)
559        let merged_mapping = if geom.inherit_aes() {
560            plot_mapping.merge(&layer_mapping)
561        } else {
562            layer_mapping.clone()
563        };
564
565        // Brand/primary color: apply to a single-series geom only when the layer
566        // maps neither color nor fill (an explicit aesthetic always wins) and
567        // the geom wasn't configured explicitly via `geom_*_with(...)`.
568        if let Some(color) = primary.filter(|_| !explicit_style) {
569            let has_color = merged_mapping.get_mapping(&Aesthetic::Color).is_some();
570            let has_fill = merged_mapping.get_mapping(&Aesthetic::Fill).is_some();
571            if !has_color && !has_fill {
572                geom.set_series_color(color);
573            }
574        }
575
576        // Step 3: Evaluate aes — rename columns to canonical names
577        let mut working_data = resolve_mappings(source_data, &merged_mapping, warnings);
578
579        // Remember which columns the user actually supplied (pre-stat). A required
580        // aesthetic is satisfied if it was present here OR is synthesized by the
581        // stat (checked after Step 6) — e.g. boxplot maps `y` then the stat turns
582        // it into ymin/ymax, while StatEcdf produces `y` that wasn't mapped.
583        let pre_stat_columns: Vec<String> = working_data
584            .column_names()
585            .iter()
586            .map(|s| s.to_string())
587            .collect();
588
589        // Step 4: Ensure scales exist for each mapped aesthetic
590        for m in &merged_mapping.mappings {
591            scale_set.ensure_scale(&m.aesthetic, &working_data);
592        }
593
594        // Step 5: Scale transformation (e.g., log10 before stats). A position
595        // scale transforms every column of its family (y also ymin/ymax/…).
596        for scale in scale_set.iter() {
597            let aes = scale.aesthetic();
598            let cols: Vec<&str> = match aes {
599                Aesthetic::X => X_FAMILY.to_vec(),
600                Aesthetic::Y => Y_FAMILY.to_vec(),
601                _ => vec![aes.col_name()],
602            };
603            for col_name in cols {
604                let Some(col) = working_data.column(col_name) else {
605                    continue;
606                };
607                // A transform that is undefined for a value (log of 0 or a
608                // negative) yields NaN, so the non-finite filter below drops
609                // the row with a warning instead of drawing it at 0.
610                let transformed: Vec<_> = col
611                    .iter()
612                    .map(|v| match (scale.transform(v), v.as_f64()) {
613                        (crate::data::Value::Na, Some(_)) => crate::data::Value::Float(f64::NAN),
614                        (t, _) => t,
615                    })
616                    .collect();
617                let any_changed = transformed.iter().zip(col.iter()).any(|(t, o)| {
618                    match (t.as_f64(), o.as_f64()) {
619                        (Some(a), Some(b)) => a.is_nan() || (a - b).abs() > f64::EPSILON,
620                        _ => false,
621                    }
622                });
623                if any_changed {
624                    if let Some(col_mut) = working_data.column_mut(col_name) {
625                        *col_mut = transformed;
626                    }
627                }
628            }
629        }
630
631        // Step 5b: Filter out-of-bounds data (xlim/ylim filter before stats)
632        Self::filter_oob_data(&mut working_data, scale_set);
633
634        // Step 5c: Drop rows whose position is NaN (or ±Inf, unless the geom
635        // reads infinities as "panel edge"), like ggplot2's
636        // "Removed n rows containing non-finite values".
637        let geom_label = format!("geom_{}", geom.name());
638        let removed = Self::drop_non_finite(&mut working_data, geom.allows_infinite());
639        if removed > 0 {
640            warnings.push(format!(
641                "{geom_label}: removed {removed} row{} containing non-finite values",
642                if removed == 1 { "" } else { "s" }
643            ));
644        }
645        let input_rows = working_data.nrows();
646
647        // Validate the stat's own configuration once per layer (e.g.
648        // `StatBinHex::binwidth` — a problem with fixed parameters, not
649        // with the data), before it runs per-group below.
650        stat.validate(warnings);
651
652        // Step 6: Compute statistics. Group by aesthetic groups AND the facet
653        // variables, so a computed stat (density/histogram/…) is estimated per
654        // panel rather than on pooled data; the facet column is then re-attached
655        // to each group's output so faceting can split it back out.
656        // Panelwise stats (e.g. stat_compare_means) see the whole panel at once,
657        // so we group only by facet variables, not by aesthetic groups.
658        let mut group_cols = if stat.panelwise() {
659            Vec::new()
660        } else {
661            Self::detect_group_columns(&working_data)
662        };
663        for fv in facet_vars {
664            if working_data.has_column(fv) && !group_cols.contains(fv) {
665                group_cols.push(fv.clone());
666            }
667        }
668
669        working_data = if !group_cols.is_empty() {
670            let groups =
671                working_data.group_by(&group_cols.iter().map(|s| s.as_str()).collect::<Vec<_>>());
672            let mut result = DataFrame::new();
673            for group in groups {
674                let mut computed = stat.compute_group(&group, scale_set);
675                let n = computed.nrows();
676                if n > 0 {
677                    for fv in facet_vars {
678                        if !computed.has_column(fv) {
679                            if let Some(val) = group.column(fv).and_then(|c| c.first()).cloned() {
680                                computed.add_column(fv.clone(), vec![val; n]);
681                            }
682                        }
683                    }
684                }
685                result.vstack(&computed);
686            }
687            result
688        } else {
689            stat.compute_group(&working_data, scale_set)
690        };
691
692        // Step 6a: Apply after_stat() mappings (rename stat-computed columns)
693        apply_after_stat(&mut working_data, &merged_mapping, warnings);
694
695        // Stat output can contain NaN (e.g. a fit on degenerate input): drop
696        // those rows too, so nothing non-finite reaches a geom.
697        let removed = Self::drop_non_finite(&mut working_data, geom.allows_infinite());
698        if removed > 0 {
699            warnings.push(format!(
700                "{geom_label}: removed {removed} row{} containing missing values (stat_{} output)",
701                if removed == 1 { "" } else { "s" },
702                stat.name()
703            ));
704        }
705
706        // A layer with no data draws nothing — an empty input, or a stat that
707        // cannot estimate anything (a density of one value, loess on two
708        // points). It must not fail the whole plot. A stat's own required
709        // aesthetics are still validated when there *was* input.
710        if working_data.nrows() == 0 {
711            if input_rows > 0 {
712                for aes in &stat.required_aes() {
713                    let col_name = aes.col_name();
714                    if !pre_stat_columns.iter().any(|c| c == col_name) {
715                        return Err(GGError::ValidationError(format!(
716                            "stat_{} requires aesthetic '{}' but it was not provided",
717                            stat.name(),
718                            col_name
719                        )));
720                    }
721                }
722                warnings.push(format!(
723                    "{geom_label}: stat_{} produced no data from {input_rows} row{} \
724                     (too few or degenerate values); layer skipped",
725                    stat.name(),
726                    if input_rows == 1 { "" } else { "s" }
727                ));
728            }
729            return Ok(BuiltLayer {
730                data: DataFrame::new(),
731                geom,
732                show_legend,
733            });
734        }
735
736        // Step 6a-validate: A required aesthetic must have been supplied by the
737        // user (pre-stat) or synthesized by the stat (post-stat). This lets
738        // StatEcdf produce `y` for geom_step, while boxplot — which maps `y` then
739        // consumes it into ymin/ymax — still validates. Empty input has the
740        // column in neither place, so genuinely-missing aesthetics still error.
741        for aes in &geom.required_aes() {
742            let col_name = aes.col_name();
743            let supplied = pre_stat_columns.iter().any(|c| c == col_name);
744            if !supplied && !working_data.has_column(col_name) {
745                return Err(GGError::ValidationError(format!(
746                    "geom_{} requires aesthetic '{}' but it was not provided",
747                    geom.name(),
748                    col_name
749                )));
750            }
751        }
752
753        // Step 6b: Ensure scales for stat-computed aesthetics (e.g. y from StatCount/StatBin)
754        let stat_aes = [
755            ("x", Aesthetic::X),
756            ("y", Aesthetic::Y),
757            ("xmin", Aesthetic::X),
758            ("xmax", Aesthetic::X),
759            ("ymin", Aesthetic::Y),
760            ("ymax", Aesthetic::Y),
761        ];
762        for (col, aes) in &stat_aes {
763            if working_data.has_column(col) {
764                scale_set.ensure_scale(aes, &working_data);
765            }
766        }
767
768        // Step 6c: bars/histograms/area draw from a 0 baseline, so the Y scale
769        // must include 0 — for stat-computed Y (count/bin) and for an explicitly
770        // mapped Y alike (e.g. geom_col), matching ggplot2.
771        let y_is_user_mapped = merged_mapping.get_mapping(&Aesthetic::Y).is_some();
772        if (!y_is_user_mapped || geom.include_zero_baseline()) && working_data.has_column("y") {
773            if let Some(y_scale) = scale_set.get_mut(&Aesthetic::Y) {
774                y_scale.train(&[crate::data::Value::Float(0.0)]);
775            }
776        }
777
778        // Step 7: Position adjustment
779        let params = PositionParams::default();
780        position.compute(&mut working_data, &params);
781
782        // Step 7b: Geom-specific setup (e.g. tile/candlestick extents), then
783        // make sure position scales exist for any extent columns it added.
784        geom.setup_data(&mut working_data);
785        for (col, aes) in &stat_aes {
786            if working_data.has_column(col) {
787                scale_set.ensure_scale(aes, &working_data);
788            }
789        }
790
791        // Step 8: Train scales on this layer's data
792        scale_set.train_layer(&working_data);
793        for (col, aes) in &INTERCEPT_COLS {
794            if let (Some(values), Some(scale)) = (working_data.column(col), scale_set.get_mut(aes))
795            {
796                train_intercepts(scale, values);
797            }
798        }
799
800        // Step 8b: Positional scales also need to see stat-computed extent columns
801        // (e.g. boxplot/errorbar/pointrange emit ymin/ymax but no "y"). Without
802        // this the Y (or X) scale would never train on the range and collapse.
803        for (col, aes) in &stat_aes {
804            if let Some(values) = working_data.column(col) {
805                if let Some(scale) = scale_set.get_mut(aes) {
806                    scale.train(values);
807                }
808            }
809        }
810
811        Ok(BuiltLayer {
812            data: working_data,
813            geom,
814            show_legend,
815        })
816    }
817
818    /// Remove rows where x or y falls outside scale limits set via xlim/ylim.
819    fn filter_oob_data(data: &mut DataFrame, scale_set: &ScaleSet) {
820        let x_limits = scale_set.get(&Aesthetic::X).and_then(|s| s.filter_limits());
821        let y_limits = scale_set.get(&Aesthetic::Y).and_then(|s| s.filter_limits());
822
823        if x_limits.is_none() && y_limits.is_none() {
824            return;
825        }
826
827        let nrows = data.nrows();
828        let mut keep = vec![true; nrows];
829
830        if let Some((min, max)) = x_limits {
831            if let Some(col) = data.column("x") {
832                for (i, v) in col.iter().enumerate() {
833                    if let Some(f) = v.as_f64() {
834                        if f < min || f > max {
835                            keep[i] = false;
836                        }
837                    }
838                }
839            }
840        }
841
842        if let Some((min, max)) = y_limits {
843            if let Some(col) = data.column("y") {
844                for (i, v) in col.iter().enumerate() {
845                    if let Some(f) = v.as_f64() {
846                        if f < min || f > max {
847                            keep[i] = false;
848                        }
849                    }
850                }
851            }
852        }
853
854        // If nothing was filtered, skip the rebuild
855        if keep.iter().all(|&k| k) {
856            return;
857        }
858
859        let indices: Vec<usize> = keep
860            .iter()
861            .enumerate()
862            .filter(|(_, &k)| k)
863            .map(|(i, _)| i)
864            .collect();
865
866        let mut result = DataFrame::new();
867        for col_name in data.column_names() {
868            if let Some(src) = data.column(col_name) {
869                let vals: Vec<_> = indices.iter().map(|&i| src[i].clone()).collect();
870                result.add_column(col_name.to_string(), vals);
871            }
872        }
873        *data = result;
874    }
875
876    /// Compute per-panel scale sets for free facet scales.
877    /// For each panel, clones the base scale set, resets freed axes, and retrains on panel data.
878    fn compute_panel_scales(
879        facet_scales: &FacetScales,
880        panels_data: &[Vec<DataFrame>],
881        base_scales: &ScaleSet,
882    ) -> Vec<ScaleSet> {
883        if matches!(facet_scales, FacetScales::Fixed) || panels_data.is_empty() {
884            return vec![];
885        }
886
887        let free_x = matches!(facet_scales, FacetScales::FreeX | FacetScales::Free);
888        let free_y = matches!(facet_scales, FacetScales::FreeY | FacetScales::Free);
889
890        panels_data
891            .iter()
892            .map(|panel_layers| {
893                let mut panel_set = base_scales.clone();
894
895                // Reset freed axis scales
896                if free_x {
897                    if let Some(s) = panel_set.get_mut(&Aesthetic::X) {
898                        s.reset_training();
899                    }
900                }
901                if free_y {
902                    if let Some(s) = panel_set.get_mut(&Aesthetic::Y) {
903                        s.reset_training();
904                    }
905                }
906
907                // Retrain on this panel's data, including extent columns
908                // (bars/tiles/ranges), so free scales cover whole marks.
909                for layer_data in panel_layers {
910                    panel_set.train_layer(layer_data);
911                    for (cols, aes) in [
912                        (["xmin", "xmax", "xintercept"], Aesthetic::X),
913                        (["ymin", "ymax", "yintercept"], Aesthetic::Y),
914                    ] {
915                        let freed = match aes {
916                            Aesthetic::X => free_x,
917                            _ => free_y,
918                        };
919                        if !freed {
920                            continue;
921                        }
922                        for c in cols {
923                            if let (Some(vals), Some(s)) =
924                                (layer_data.column(c), panel_set.get_mut(&aes))
925                            {
926                                if c.ends_with("intercept") {
927                                    train_intercepts(s, vals);
928                                } else {
929                                    s.train(vals);
930                                }
931                            }
932                        }
933                    }
934                }
935
936                panel_set
937            })
938            .collect()
939    }
940
941    /// Compute which aesthetics should be suppressed from the legend.
942    /// An aesthetic is suppressed if every layer that has the corresponding column
943    /// sets show_legend=Some(false), and no layer has it as None or Some(true).
944    fn compute_suppressed_aes(built_layers: &[BuiltLayer]) -> std::collections::HashSet<Aesthetic> {
945        use std::collections::HashSet;
946        let legend_aes = [
947            Aesthetic::Color,
948            Aesthetic::Fill,
949            Aesthetic::Shape,
950            Aesthetic::Linetype,
951            Aesthetic::Size,
952            Aesthetic::Alpha,
953            Aesthetic::Linewidth,
954        ];
955        let mut suppressed = HashSet::new();
956        for aes in &legend_aes {
957            let col_name = aes.col_name();
958            let mut any_has = false;
959            let mut all_hidden = true;
960            for bl in built_layers {
961                if bl.data.has_column(col_name) {
962                    any_has = true;
963                    match bl.show_legend {
964                        Some(false) => {} // still hidden
965                        _ => {
966                            all_hidden = false;
967                            break;
968                        }
969                    }
970                }
971            }
972            if any_has && all_hidden {
973                suppressed.insert(aes.clone());
974            }
975        }
976        suppressed
977    }
978
979    /// Detect which columns to group by for statistics.
980    /// Checks group/color/fill plus discrete x (like R's auto-grouping by discrete x).
981    fn detect_group_columns(data: &DataFrame) -> Vec<String> {
982        let candidates = ["group", "color", "fill", "x"];
983        let mut group_cols = Vec::new();
984        for &col in &candidates {
985            if data.has_column(col) {
986                if let Some(values) = data.column(col) {
987                    let is_discrete = values
988                        .iter()
989                        .any(|v| matches!(v, crate::data::Value::Str(_)));
990                    if is_discrete {
991                        group_cols.push(col.to_string());
992                    }
993                }
994            }
995        }
996        group_cols
997    }
998}