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