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