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