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    /// The single discrete legend's B3-2 layout plan (explicit `nrow`/
41    /// `ncol` grid, or auto-wrap), computed exactly once in `layout_built`
42    /// and reused as-is by `draw_legend_planned` — never recomputed at
43    /// draw time, so the warning (based on this same plan) always matches
44    /// what's drawn (review #158 finding 2: computing it twice, once
45    /// against the pre-reservation panel height and once against the
46    /// post-reservation one, could warn "+7 more" while drawing "+15
47    /// more"). `None` when there's no such plan (nothing changes from the
48    /// 0.21 single-row/single-column path).
49    pub(crate) legend_plan: Option<crate::guide::legend::LegendPlan>,
50}
51
52impl BuiltPlot {
53    /// Non-fatal build warnings, in layer order: rows dropped for non-finite
54    /// positions, and layers skipped because their stat produced no data
55    /// (e.g. a density of a single value). Rendering still succeeds.
56    pub fn warnings(&self) -> &[String] {
57        &self.warnings
58    }
59}
60
61/// Columns holding *position* values: rows with non-finite values here are
62/// dropped before stats (and again after, for stat output).
63const POSITION_COLS: &[&str] = &[
64    "x",
65    "y",
66    "xmin",
67    "xmax",
68    "ymin",
69    "ymax",
70    "xend",
71    "yend",
72    "open",
73    "high",
74    "low",
75    "close",
76    "xintercept",
77    "yintercept",
78    "slope",
79    "intercept",
80    // Boxplot stat="identity" hinges (B3-5). `outliers` is a comma-list
81    // string, not a position value, so it's not listed here — a non-finite
82    // outlier is skipped individually with its own warning when parsed.
83    "lower",
84    "middle",
85    "upper",
86];
87
88/// Columns a position scale's transformation applies to (pre-stat).
89const X_FAMILY: &[&str] = &["x", "xmin", "xmax", "xend", "xintercept"];
90const Y_FAMILY: &[&str] = &[
91    "y",
92    "ymin",
93    "ymax",
94    "yend",
95    "open",
96    "high",
97    "low",
98    "close",
99    "yintercept",
100    "lower",
101    "middle",
102    "upper",
103];
104
105/// Reference-line intercept columns and the position scale they train
106/// (ggplot2: `xintercept`/`yintercept` are x/y aesthetics).
107const INTERCEPT_COLS: [(&str, Aesthetic); 2] =
108    [("xintercept", Aesthetic::X), ("yintercept", Aesthetic::Y)];
109
110/// Train `scale` on reference-line intercepts. A numeric intercept on a
111/// discrete axis is a position *between* categories (ggplot2), not a level.
112fn train_intercepts(scale: &mut Box<dyn crate::scale::Scale>, values: &[crate::data::Value]) {
113    if scale.is_discrete() && values.iter().all(|v| v.as_f64().is_some() || v.is_na()) {
114        return;
115    }
116    scale.train(values);
117}
118
119/// Map a canonical column name to the aesthetic whose scale formats it
120/// (B2-5 tooltip templates / B2-4 `data-fvalue`-style formatting). Columns
121/// outside this list (group, label, or a stat output like `count`) have no
122/// scale of their own: [`render_tooltip_template`] falls back to the plain
123/// display format for them.
124fn column_aesthetic(name: &str) -> Option<Aesthetic> {
125    match name {
126        "x" => Some(Aesthetic::X),
127        "y" => Some(Aesthetic::Y),
128        "fill" => Some(Aesthetic::Fill),
129        "color" | "colour" => Some(Aesthetic::Color),
130        "size" => Some(Aesthetic::Size),
131        "linewidth" => Some(Aesthetic::Linewidth),
132        "alpha" => Some(Aesthetic::Alpha),
133        // Position columns (B3-5, D9 allowed #7): formatted through the
134        // x/y scale, same as the bare `x`/`y` they're derived from.
135        "xmin" | "xmax" | "xend" => Some(Aesthetic::X),
136        "ymin" | "ymax" | "yend" | "lower" | "middle" | "upper" => Some(Aesthetic::Y),
137        _ => None,
138    }
139}
140
141/// Render one row of a `GGPlot::tooltip` template: `{column}` is filled
142/// from `data`'s row `row`, formatted through that column's scale when
143/// [`column_aesthetic`] names one and it has a
144/// [`Scale::format_value`](crate::scale::Scale::format_value) (else the
145/// plain display format); a column that doesn't exist on this layer is
146/// empty, not literally `{column}`. `{{`/`}}` are literal braces.
147fn render_tooltip_template(
148    template: &str,
149    data: &DataFrame,
150    scales: &ScaleSet,
151    row: usize,
152) -> String {
153    let mut out = String::new();
154    let mut chars = template.chars().peekable();
155    while let Some(c) = chars.next() {
156        match c {
157            '{' if chars.peek() == Some(&'{') => {
158                chars.next();
159                out.push('{');
160            }
161            '}' if chars.peek() == Some(&'}') => {
162                chars.next();
163                out.push('}');
164            }
165            '{' => {
166                let mut name = String::new();
167                let mut closed = false;
168                for nc in chars.by_ref() {
169                    if nc == '}' {
170                        closed = true;
171                        break;
172                    }
173                    name.push(nc);
174                }
175                if closed {
176                    out.push_str(&render_placeholder(&name, data, scales, row));
177                } else {
178                    // Unterminated `{…` to end of string: not a placeholder,
179                    // kept literally.
180                    out.push('{');
181                    out.push_str(&name);
182                }
183            }
184            c => out.push(c),
185        }
186    }
187    out
188}
189
190fn render_placeholder(name: &str, data: &DataFrame, scales: &ScaleSet, row: usize) -> String {
191    let Some(col) = data.column(name) else {
192        return String::new();
193    };
194    let Some(v) = col.get(row) else {
195        return String::new();
196    };
197    if v.is_na() {
198        return String::new();
199    }
200    if let Some(formatted) = column_aesthetic(name)
201        .and_then(|aes| scales.get(&aes))
202        .and_then(|s| s.format_value(v))
203    {
204        return formatted;
205    }
206    crate::geom::tip_value(v)
207}
208
209/// The grammar pipeline: transforms a GGPlot specification into render-ready data.
210pub struct PlotBuilder;
211
212impl PlotBuilder {
213    pub fn build(plot: GGPlot) -> Result<BuiltPlot, GGError> {
214        let GGPlot {
215            data: plot_data,
216            mapping: plot_mapping,
217            layers,
218            scales: user_scales,
219            mut coord,
220            theme,
221            labels,
222            facet,
223            annotations,
224            guide_legend,
225            warnings: plot_warnings,
226            default_aspect_ratio,
227            locale,
228            svg_root_data: _,
229            root_attrs: _,
230            svg_warnings_attr: _,
231            svg_formatted_values: _,
232        } = plot;
233        let mut theme = theme;
234        if theme.aspect_ratio.is_none() {
235            theme.aspect_ratio = default_aspect_ratio;
236        }
237
238        // Malformed input (e.g. mismatched column lengths) is reported here as
239        // a validation error instead of panicking when the frame is assembled.
240        plot_data.validate()?;
241        for layer in &layers {
242            if let Some(d) = &layer.data {
243                d.validate()?;
244            }
245        }
246
247        let mut scale_set = ScaleSet::new();
248
249        // Add user-specified scales
250        for s in user_scales {
251            scale_set.add(s);
252        }
253
254        let mut built_layers = Vec::new();
255        let mut warnings: Vec<String> = plot_warnings;
256
257        // Faceting variables — used to group stat computation per panel so a
258        // computed stat (density/histogram) is estimated per panel, not pooled.
259        let facet_vars = Self::facet_vars(&facet);
260
261        for layer in layers {
262            let built = Self::build_layer(
263                layer,
264                &plot_data,
265                &plot_mapping,
266                &mut scale_set,
267                &theme,
268                &facet_vars,
269                &mut warnings,
270            )?;
271            built_layers.push(built);
272        }
273
274        // Final scale training pass across all layers
275        for bl in &built_layers {
276            scale_set.train_layer(&bl.data);
277            for (col, aes) in &INTERCEPT_COLS {
278                // No layer created this position scale: the intercepts do.
279                if let (Some(values), None) = (bl.data.column(col), scale_set.get(aes)) {
280                    let mut frame = DataFrame::new();
281                    frame.add_column(aes.col_name().to_string(), values.to_vec());
282                    scale_set.ensure_scale(aes, &frame);
283                }
284                if let (Some(values), Some(scale)) = (bl.data.column(col), scale_set.get_mut(aes)) {
285                    train_intercepts(scale, values);
286                }
287            }
288        }
289
290        // Validate each scale's final configuration (e.g. a transform
291        // undefined at its own domain limits) now that training is done —
292        // `map`/`map_to_size` run per mark at draw time and have no
293        // warnings sink of their own.
294        for scale in scale_set.iter() {
295            scale.validate(&mut warnings);
296        }
297
298        // An empty plot (no layers, or only layers without data) still gets a
299        // panel with axes: make sure both position scales exist. They stay
300        // untrained, so they draw no breaks and map everything to the centre.
301        if built_layers.iter().all(|bl| bl.data.nrows() == 0) {
302            for aes in [Aesthetic::X, Aesthetic::Y] {
303                if scale_set.get(&aes).is_none() {
304                    scale_set.add(Box::new(
305                        crate::scale::continuous::ScaleContinuous::new().for_aesthetic(aes),
306                    ));
307                }
308            }
309        }
310
311        // Apply coord zoom limits (coord_cartesian xlim/ylim)
312        if let Some((min, max)) = coord.zoom_x() {
313            scale_set.set_limits(&Aesthetic::X, min, max);
314        }
315        if let Some((min, max)) = coord.zoom_y() {
316            scale_set.set_limits(&Aesthetic::Y, min, max);
317        }
318
319        // Let the coordinate system adjust/inspect the trained scales.
320        coord.train_scales(&mut scale_set);
321
322        // Supply trained axis spans to the coordinate system (used by coord_trans).
323        // pmin/pmax are the panel positions of the domain endpoints, so the coord
324        // can invert the scale's (linearly expanded) mapping exactly.
325        let axis_span = |aes: &Aesthetic| {
326            scale_set.get(aes).and_then(|s| {
327                s.domain().map(|(min, max)| crate::coord::AxisSpan {
328                    min,
329                    max,
330                    pmin: s.map(&crate::data::Value::Float(min)),
331                    pmax: s.map(&crate::data::Value::Float(max)),
332                })
333            })
334        };
335        let x_span = axis_span(&Aesthetic::X);
336        let y_span = axis_span(&Aesthetic::Y);
337        coord.set_domains(x_span, y_span);
338
339        // Apply after_scale() color derivations: copy the source aesthetic's
340        // column to the target and register a lightness-modified clone of the
341        // source scale, so the target aesthetic draws the mapped source color
342        // adjusted in lightness (e.g. a darker border derived from the fill).
343        for spec in &plot_mapping.after_scale {
344            if let Some(src_scale) = scale_set.get(&spec.source) {
345                let modified = crate::scale::modified::ScaleColorModified::new(
346                    src_scale.clone_box(),
347                    spec.target.clone(),
348                    spec.lightness,
349                );
350                let (src_col, tgt_col) = (spec.source.col_name(), spec.target.col_name());
351                for bl in &mut built_layers {
352                    if !bl.data.has_column(tgt_col) {
353                        if let Some(vals) = bl.data.column(src_col) {
354                            let vals = vals.to_vec();
355                            bl.data.add_column(tgt_col.to_string(), vals);
356                        }
357                    }
358                }
359                scale_set.add(Box::new(modified));
360            }
361        }
362
363        // Plot-level locale for default break labels (before panel scales are
364        // cloned from the base set, so free facets inherit it).
365        if let Some(locale) = &locale {
366            scale_set.set_default_locale(locale);
367        }
368
369        // Compute facet panels
370        let (panels, panels_data) = Self::compute_facets(&facet, &built_layers, &plot_data);
371
372        // Compute suppressed aesthetics from show_legend flags.
373        let suppressed_aes = Self::compute_suppressed_aes(&built_layers);
374
375        // Compute per-panel scales for free facets
376        let facet_scales_mode = match &facet {
377            Facet::Wrap { scales, .. } => scales.clone(),
378            Facet::Grid { scales, .. } => scales.clone(),
379            Facet::None => FacetScales::Fixed,
380        };
381        let panel_scales = Self::compute_panel_scales(&facet_scales_mode, &panels_data, &scale_set);
382
383        Ok(BuiltPlot {
384            layers: built_layers,
385            scales: scale_set,
386            coord,
387            theme,
388            labels,
389            facet,
390            panels,
391            panels_data,
392            annotations,
393            guide_legend,
394            suppressed_aes,
395            panel_scales,
396            warnings,
397            legend_plan: None,
398        })
399    }
400
401    /// Keep only the rows whose `keep` flag is set.
402    fn retain_rows(data: &mut DataFrame, keep: &[bool]) {
403        if keep.iter().all(|&k| k) {
404            return;
405        }
406        let mut result = DataFrame::new();
407        for col_name in data.column_names() {
408            if let Some(src) = data.column(col_name) {
409                let vals: Vec<_> = src
410                    .iter()
411                    .zip(keep)
412                    .filter(|(_, &k)| k)
413                    .map(|(v, _)| v.clone())
414                    .collect();
415                result.add_column(col_name.to_string(), vals);
416            }
417        }
418        *data = result;
419    }
420
421    /// Drop rows with a non-finite value in any position column (`NaN` always;
422    /// `±Inf` unless the geom gives infinities meaning). Returns the number of
423    /// rows removed.
424    /// `keep_as_break`: for a connecting geom ([`Geom::keeps_na_as_break`]),
425    /// a bad row is counted (for the warning) but not deleted — it stays in
426    /// `data` as a non-finite pixel position, which `DrawBackend::draw_line`
427    /// splits the polyline on, instead of being spliced out and silently
428    /// joining the finite points across the gap.
429    /// `exclude`: column names to skip entirely this call — `lower`/
430    /// `middle`/`upper` present in the raw data but not mapped as that
431    /// aesthetic on this layer (review #158's final check: these must
432    /// never cost an unrelated geom its rows; see `raw_unmapped` at the
433    /// call site).
434    fn drop_non_finite(
435        data: &mut DataFrame,
436        allow_infinite: bool,
437        keep_as_break: bool,
438        exclude: &[&str],
439    ) -> usize {
440        let n = data.nrows();
441        let mut keep = vec![true; n];
442        for col in POSITION_COLS {
443            if exclude.contains(col) {
444                continue;
445            }
446            if let Some(values) = data.column(col) {
447                // Several position columns are legitimately discrete
448                // (`Value::Str`) in this codebase — not just `x`/`y`, e.g.
449                // `geom_bracket`'s `xmin`/`xmax` hold category names for a
450                // bracket spanning two discrete groups. `Value::Na` is
451                // unambiguously missing everywhere, though (review #158
452                // finding 4: a SQL NULL in a boxplot position column used
453                // to silently map to 0 instead of being dropped with the
454                // standard warning; only `Float(NaN)` was caught before).
455                // The new boxplot-only `lower`/`middle`/`upper` columns
456                // have no legitimate discrete use anywhere, so a
457                // non-numeric, non-`Na` value there (a stray string) is
458                // missing too.
459                let strictly_numeric = matches!(*col, "lower" | "middle" | "upper");
460                for (i, v) in values.iter().enumerate() {
461                    let bad = match v {
462                        crate::data::Value::Na => true,
463                        crate::data::Value::Float(f) => {
464                            f.is_nan() || (f.is_infinite() && !allow_infinite)
465                        }
466                        _ if strictly_numeric => v.as_f64().is_none(),
467                        _ => false,
468                    };
469                    if bad {
470                        keep[i] = false;
471                    }
472                }
473            }
474        }
475        let removed = keep.iter().filter(|&&k| !k).count();
476        if removed > 0 && !keep_as_break {
477            Self::retain_rows(data, &keep);
478        }
479        removed
480    }
481
482    /// The column name(s) a facet splits on, if any.
483    fn facet_vars(facet: &Facet) -> Vec<String> {
484        match facet {
485            Facet::None => vec![],
486            Facet::Wrap { var, .. } => vec![var.clone()],
487            Facet::Grid {
488                row_var, col_var, ..
489            } => row_var.iter().chain(col_var.iter()).cloned().collect(),
490        }
491    }
492
493    fn compute_facets(
494        facet: &Facet,
495        built_layers: &[BuiltLayer],
496        _plot_data: &DataFrame,
497    ) -> (Vec<Panel>, Vec<Vec<DataFrame>>) {
498        match facet {
499            Facet::None => (vec![], vec![]),
500            Facet::Wrap {
501                var,
502                ncol,
503                labeller,
504                ..
505            } => {
506                // Collect unique levels from all layers' data
507                let mut levels: Vec<String> = Vec::new();
508                for bl in built_layers {
509                    if let Some(col) = bl.data.column(var) {
510                        for v in col {
511                            let key = v.to_group_key();
512                            if !levels.contains(&key) {
513                                levels.push(key);
514                            }
515                        }
516                    }
517                }
518
519                // Panels will be positioned during rendering (depends on layout)
520                let panels: Vec<Panel> = levels
521                    .iter()
522                    .enumerate()
523                    .map(|(i, value)| {
524                        let ncols =
525                            ncol.unwrap_or_else(|| (levels.len() as f64).sqrt().ceil() as usize);
526                        let formatted = labeller.format(var, value);
527                        Panel {
528                            row: i / ncols.max(1),
529                            col: i % ncols.max(1),
530                            label: formatted.clone(),
531                            row_label: None,
532                            col_label: Some(formatted),
533                            rect: crate::render::Rect {
534                                x: 0.0,
535                                y: 0.0,
536                                width: 0.0,
537                                height: 0.0,
538                            },
539                        }
540                    })
541                    .collect();
542
543                // Split data per panel per layer
544                let panels_data: Vec<Vec<DataFrame>> = levels
545                    .iter()
546                    .map(|level| {
547                        built_layers
548                            .iter()
549                            .map(|bl| Self::filter_data_by_var(&bl.data, var, level))
550                            .collect()
551                    })
552                    .collect();
553
554                (panels, panels_data)
555            }
556            Facet::Grid {
557                row_var,
558                col_var,
559                labeller,
560                ..
561            } => {
562                let mut row_levels: Vec<String> = Vec::new();
563                let mut col_levels: Vec<String> = Vec::new();
564
565                for bl in built_layers {
566                    if let Some(rv) = row_var {
567                        if let Some(col) = bl.data.column(rv) {
568                            for v in col {
569                                let key = v.to_group_key();
570                                if !row_levels.contains(&key) {
571                                    row_levels.push(key);
572                                }
573                            }
574                        }
575                    }
576                    if let Some(cv) = col_var {
577                        if let Some(col) = bl.data.column(cv) {
578                            for v in col {
579                                let key = v.to_group_key();
580                                if !col_levels.contains(&key) {
581                                    col_levels.push(key);
582                                }
583                            }
584                        }
585                    }
586                }
587
588                if row_levels.is_empty() {
589                    row_levels.push("".to_string());
590                }
591                if col_levels.is_empty() {
592                    col_levels.push("".to_string());
593                }
594
595                let mut panels = Vec::new();
596                let mut panels_data = Vec::new();
597
598                for (ri, rl) in row_levels.iter().enumerate() {
599                    for (ci, cl) in col_levels.iter().enumerate() {
600                        let row_fmt = if rl.is_empty() {
601                            None
602                        } else {
603                            let rv = row_var.as_deref().unwrap_or("");
604                            Some(labeller.format(rv, rl))
605                        };
606                        let col_fmt = if cl.is_empty() {
607                            None
608                        } else {
609                            let cv = col_var.as_deref().unwrap_or("");
610                            Some(labeller.format(cv, cl))
611                        };
612                        let label = match (&row_fmt, &col_fmt) {
613                            (Some(r), Some(c)) => format!("{r} | {c}"),
614                            (Some(r), None) => r.clone(),
615                            (None, Some(c)) => c.clone(),
616                            (None, None) => String::new(),
617                        };
618                        panels.push(Panel {
619                            row: ri,
620                            col: ci,
621                            label,
622                            row_label: row_fmt,
623                            col_label: col_fmt,
624                            rect: crate::render::Rect {
625                                x: 0.0,
626                                y: 0.0,
627                                width: 0.0,
628                                height: 0.0,
629                            },
630                        });
631
632                        let layer_data: Vec<DataFrame> = built_layers
633                            .iter()
634                            .map(|bl| {
635                                let mut data = bl.data.clone();
636                                if let Some(rv) = row_var {
637                                    if !rl.is_empty() {
638                                        data = Self::filter_data_by_var(&data, rv, rl);
639                                    }
640                                }
641                                if let Some(cv) = col_var {
642                                    if !cl.is_empty() {
643                                        data = Self::filter_data_by_var(&data, cv, cl);
644                                    }
645                                }
646                                data
647                            })
648                            .collect();
649                        panels_data.push(layer_data);
650                    }
651                }
652
653                (panels, panels_data)
654            }
655        }
656    }
657
658    fn filter_data_by_var(data: &DataFrame, var: &str, level: &str) -> DataFrame {
659        if let Some(col) = data.column(var) {
660            let indices: Vec<usize> = col
661                .iter()
662                .enumerate()
663                .filter(|(_, v)| v.to_group_key() == level)
664                .map(|(i, _)| i)
665                .collect();
666
667            let mut result = DataFrame::new();
668            for col_name in data.column_names() {
669                if let Some(src) = data.column(col_name) {
670                    let vals: Vec<_> = indices.iter().map(|&i| src[i].clone()).collect();
671                    result.add_column(col_name.to_string(), vals);
672                }
673            }
674            result
675        } else {
676            data.clone()
677        }
678    }
679
680    fn build_layer(
681        layer: Layer,
682        plot_data: &DataFrame,
683        plot_mapping: &Aes,
684        scale_set: &mut ScaleSet,
685        theme: &Theme,
686        facet_vars: &[String],
687        warnings: &mut Vec<String>,
688    ) -> Result<BuiltLayer, GGError> {
689        let primary = theme.primary;
690        let geom_theme = &theme.geom;
691        let Layer {
692            data: layer_data,
693            mapping: layer_mapping,
694            mut geom,
695            stat,
696            position,
697            params: _,
698            show_legend,
699            explicit_style,
700            tooltip_template,
701        } = layer;
702
703        // Step 1: Resolve data — use layer data if provided, else plot data
704        // Borrow the plot data when the layer has none of its own (no per-layer
705        // full-frame clone; `resolve_mappings` copies only the columns it needs).
706        let source_data = layer_data.as_ref().unwrap_or(plot_data);
707
708        // Step 2: Merge mappings — layer overrides plot-level
709        // (Reference lines don't inherit the plot mapping — ggplot2's
710        // `inherit.aes = FALSE`.)
711        let merged_mapping = if geom.inherit_aes() {
712            plot_mapping.merge(&layer_mapping)
713        } else {
714            layer_mapping.clone()
715        };
716
717        // B2-7: geom ink/paper/accent palette, applied before the brand
718        // colour below so a primary colour still wins when both are set.
719        // Skipped when the layer was configured explicitly
720        // (`geom_*_with(...)`) — the same `explicit_style` gate
721        // `set_series_color` uses below, and for the same reason: a
722        // field-value comparison against the hard-coded default (e.g.
723        // `color == (0, 0, 0)`) can't tell an explicitly-set black apart
724        // from a black that was simply never touched (review PR#141
725        // finding 4), but `explicit_style` can.
726        if !explicit_style {
727            geom.apply_element_geom(geom_theme);
728        }
729
730        // Brand/primary color: apply to a single-series geom only when the layer
731        // maps neither color nor fill (an explicit aesthetic always wins) and
732        // the geom wasn't configured explicitly via `geom_*_with(...)`.
733        if let Some(color) = primary.filter(|_| !explicit_style) {
734            let has_color = merged_mapping.get_mapping(&Aesthetic::Color).is_some();
735            let has_fill = merged_mapping.get_mapping(&Aesthetic::Fill).is_some();
736            if !has_color && !has_fill {
737                geom.set_series_color(color);
738            }
739        }
740
741        // Step 3: Evaluate aes — rename columns to canonical names
742        let mut working_data = resolve_mappings(source_data, &merged_mapping, warnings);
743
744        // D3 (B3-5): drop a "mapped-only" column (e.g. `lower`/`middle`/
745        // `upper`/`outliers` for the boxplot) unless the user actually
746        // mapped that aesthetic — so a raw column that merely happens to
747        // share the name (a SQL alias, say) is never mistaken for one, and
748        // so a raw `lower` is never drawn as a boxplot hinge on
749        // `geom_boxplot` itself.
750        //
751        // Review #158's *final check* found that making this drop
752        // universal (every geom, round 3's `MAPPED_ONLY_COLS`) was itself
753        // a regression: it removed the raw `lower`/`middle`/`upper`/
754        // `outliers` columns from a layer's data entirely, so a forecast
755        // chart's `tooltip("{y} [{lower}, {upper}]")` on an unmapped
756        // `geom_line` rendered `[, ]` instead of the actual bounds, and
757        // `facet_wrap("upper")` on an unmapped column silently rendered
758        // unfaceted. ggplot2 never drops a raw data column just because
759        // its name matches an unused extension aesthetic — it only ever
760        // special-cases `lower`/`middle`/`upper`/`outliers` for
761        // `geom_boxplot`'s own hinges, so this drop goes back to being
762        // scoped to `geom.mapped_only_columns()` alone (empty by default,
763        // `GeomBoxplot` returns the four names). The narrower position/
764        // non-finite/y-scale-training concerns from the *previous*
765        // re-review (the forecast y-domain-stretch/row-drop regression)
766        // are instead handled per layer below (`raw_unmapped`), without
767        // ever removing the columns from `working_data`.
768        for col in geom.mapped_only_columns() {
769            let is_mapped = merged_mapping
770                .mappings
771                .iter()
772                .any(|m| m.aesthetic.col_name() == *col);
773            if !is_mapped {
774                working_data.remove_column(col);
775            }
776        }
777
778        // `lower`/`middle`/`upper` present in the raw (pre-stat) data under
779        // their canonical name, but *not* actually mapped as that
780        // aesthetic on this layer — e.g. a forecast's confidence-bound
781        // columns, never mapped on a plain `geom_line`/`geom_point` layer.
782        // These stay in `working_data` (see above), but mustn't be treated
783        // as position data a geom never asked for: excluded below from the
784        // non-finite/NA position drop, the `Y_FAMILY` pre-stat scale
785        // transform, and the y-scale training.
786        //
787        // Computed once, right here (before the stat runs), so it reflects
788        // only what the *raw* data actually had under these names.
789        // `GeomBoxplot`'s own default (non-identity) stat produces *fresh*
790        // `lower`/`middle`/`upper` columns later (not present yet, so never
791        // in this set) — those stay subject to all three, unaffected; an
792        // identity-stat boxplot has them mapped (required by
793        // `validate_layer` below), so they're not in this set either.
794        let raw_unmapped: Vec<&'static str> = ["lower", "middle", "upper"]
795            .into_iter()
796            .filter(|col| {
797                working_data.has_column(col)
798                    && !merged_mapping
799                        .mappings
800                        .iter()
801                        .any(|m| m.aesthetic.col_name() == *col)
802            })
803            .collect();
804
805        // Remember which columns the user actually supplied (pre-stat). A required
806        // aesthetic is satisfied if it was present here OR is synthesized by the
807        // stat (checked after Step 6) — e.g. boxplot maps `y` then the stat turns
808        // it into ymin/ymax, while StatEcdf produces `y` that wasn't mapped.
809        let pre_stat_columns: Vec<String> = working_data
810            .column_names()
811            .iter()
812            .map(|s| s.to_string())
813            .collect();
814
815        // B3-5: stat-dependent validation beyond `required_aes` (e.g.
816        // `geom_boxplot` requires lower/middle/upper under stat="identity",
817        // else y) — before Step 4, so a bad mapping fails before any scale
818        // work happens.
819        if let Err(msg) = geom.validate_layer(&pre_stat_columns, stat.name()) {
820            return Err(GGError::ValidationError(msg));
821        }
822
823        // Step 4: Ensure scales exist for each mapped aesthetic
824        for m in &merged_mapping.mappings {
825            scale_set.ensure_scale(&m.aesthetic, &working_data);
826        }
827
828        // Step 5: Scale transformation (e.g., log10 before stats). A position
829        // scale transforms every column of its family (y also ymin/ymax/…).
830        for scale in scale_set.iter() {
831            let aes = scale.aesthetic();
832            let cols: Vec<&str> = match aes {
833                Aesthetic::X => X_FAMILY.to_vec(),
834                // `lower`/`middle`/`upper` present but unmapped (raw
835                // forecast-shaped passthrough columns, say) are not
836                // position data this layer's geom reads, so a log/sqrt/…
837                // y-scale transform must not touch them (review #158's
838                // final check).
839                Aesthetic::Y => Y_FAMILY
840                    .iter()
841                    .copied()
842                    .filter(|c| !raw_unmapped.contains(c))
843                    .collect(),
844                _ => vec![aes.col_name()],
845            };
846            for col_name in cols {
847                let Some(col) = working_data.column(col_name) else {
848                    continue;
849                };
850                // A transform that is undefined for a value (log of 0 or a
851                // negative) yields NaN, so the non-finite filter below drops
852                // the row with a warning instead of drawing it at 0.
853                let transformed: Vec<_> = col
854                    .iter()
855                    .map(|v| match (scale.transform(v), v.as_f64()) {
856                        (crate::data::Value::Na, Some(_)) => crate::data::Value::Float(f64::NAN),
857                        (t, _) => t,
858                    })
859                    .collect();
860                let any_changed = transformed.iter().zip(col.iter()).any(|(t, o)| {
861                    match (t.as_f64(), o.as_f64()) {
862                        (Some(a), Some(b)) => a.is_nan() || (a - b).abs() > f64::EPSILON,
863                        _ => false,
864                    }
865                });
866                if any_changed {
867                    if let Some(col_mut) = working_data.column_mut(col_name) {
868                        *col_mut = transformed;
869                    }
870                }
871            }
872        }
873
874        // Step 5b: Filter out-of-bounds data (xlim/ylim filter before stats)
875        Self::filter_oob_data(&mut working_data, scale_set);
876
877        // Step 5c: Drop rows whose position is NaN (or ±Inf, unless the geom
878        // reads infinities as "panel edge"), like ggplot2's
879        // "Removed n rows containing non-finite values".
880        let geom_label = format!("geom_{}", geom.name());
881        let removed = Self::drop_non_finite(
882            &mut working_data,
883            geom.allows_infinite(),
884            geom.keeps_na_as_break(),
885            &raw_unmapped,
886        );
887        if removed > 0 {
888            warnings.push(format!(
889                "{geom_label}: removed {removed} row{} containing non-finite values",
890                if removed == 1 { "" } else { "s" }
891            ));
892        }
893        let input_rows = working_data.nrows();
894
895        // Validate the stat's own configuration once per layer (e.g.
896        // `StatBinHex::binwidth` — a problem with fixed parameters, not
897        // with the data), before it runs per-group below.
898        stat.validate(warnings);
899
900        // Step 6: Compute statistics. Group by aesthetic groups AND the facet
901        // variables, so a computed stat (density/histogram/…) is estimated per
902        // panel rather than on pooled data; the facet column is then re-attached
903        // to each group's output so faceting can split it back out.
904        // Panelwise stats (e.g. stat_compare_means) see the whole panel at once,
905        // so we group only by facet variables, not by aesthetic groups.
906        let mut group_cols = if stat.panelwise() {
907            Vec::new()
908        } else {
909            Self::detect_group_columns(&working_data)
910        };
911        for fv in facet_vars {
912            if working_data.has_column(fv) && !group_cols.contains(fv) {
913                group_cols.push(fv.clone());
914            }
915        }
916
917        working_data = if !group_cols.is_empty() {
918            let groups =
919                working_data.group_by(&group_cols.iter().map(|s| s.as_str()).collect::<Vec<_>>());
920            let mut result = DataFrame::new();
921            for group in groups {
922                let mut computed = stat.compute_group(&group, scale_set);
923                let n = computed.nrows();
924                if n > 0 {
925                    for fv in facet_vars {
926                        if !computed.has_column(fv) {
927                            if let Some(val) = group.column(fv).and_then(|c| c.first()).cloned() {
928                                computed.add_column(fv.clone(), vec![val; n]);
929                            }
930                        }
931                    }
932                }
933                result.vstack(&computed);
934            }
935            result
936        } else {
937            stat.compute_group(&working_data, scale_set)
938        };
939
940        // Step 6a: Apply after_stat() mappings (rename stat-computed columns)
941        apply_after_stat(&mut working_data, &merged_mapping, warnings);
942
943        // Stat output can contain NaN (e.g. a fit on degenerate input): drop
944        // those rows too, so nothing non-finite reaches a geom. `raw_unmapped`
945        // was computed pre-stat, so a passthrough stat (e.g. `StatIdentity`
946        // under `geom_line`/`geom_point`) still excludes the same raw,
947        // unmapped `lower`/`middle`/`upper` here as it did above; a stat
948        // that *produces* these fresh (`StatBoxplot`'s default, non-identity
949        // path) was never in that set, since they didn't exist pre-stat.
950        let removed = Self::drop_non_finite(
951            &mut working_data,
952            geom.allows_infinite(),
953            geom.keeps_na_as_break(),
954            &raw_unmapped,
955        );
956        if removed > 0 {
957            warnings.push(format!(
958                "{geom_label}: removed {removed} row{} containing missing values (stat_{} output)",
959                if removed == 1 { "" } else { "s" },
960                stat.name()
961            ));
962        }
963
964        // A layer with no data draws nothing — an empty input, or a stat that
965        // cannot estimate anything (a density of one value, loess on two
966        // points). It must not fail the whole plot. A stat's own required
967        // aesthetics are still validated when there *was* input.
968        if working_data.nrows() == 0 {
969            if input_rows > 0 {
970                for aes in &stat.required_aes() {
971                    let col_name = aes.col_name();
972                    if !pre_stat_columns.iter().any(|c| c == col_name) {
973                        return Err(GGError::ValidationError(format!(
974                            "stat_{} requires aesthetic '{}' but it was not provided",
975                            stat.name(),
976                            col_name
977                        )));
978                    }
979                }
980                warnings.push(format!(
981                    "{geom_label}: stat_{} produced no data from {input_rows} row{} \
982                     (too few or degenerate values); layer skipped",
983                    stat.name(),
984                    if input_rows == 1 { "" } else { "s" }
985                ));
986            }
987            return Ok(BuiltLayer {
988                data: DataFrame::new(),
989                geom,
990                show_legend,
991            });
992        }
993
994        // Step 6a-validate: A required aesthetic must have been supplied by the
995        // user (pre-stat) or synthesized by the stat (post-stat). This lets
996        // StatEcdf produce `y` for geom_step, while boxplot — which maps `y` then
997        // consumes it into ymin/ymax — still validates. Empty input has the
998        // column in neither place, so genuinely-missing aesthetics still error.
999        for aes in &geom.required_aes() {
1000            let col_name = aes.col_name();
1001            let supplied = pre_stat_columns.iter().any(|c| c == col_name);
1002            if !supplied && !working_data.has_column(col_name) {
1003                return Err(GGError::ValidationError(format!(
1004                    "geom_{} requires aesthetic '{}' but it was not provided",
1005                    geom.name(),
1006                    col_name
1007                )));
1008            }
1009        }
1010
1011        // Step 6b: Ensure scales for stat-computed aesthetics (e.g. y from StatCount/StatBin)
1012        let stat_aes = [
1013            ("x", Aesthetic::X),
1014            ("y", Aesthetic::Y),
1015            ("xmin", Aesthetic::X),
1016            ("xmax", Aesthetic::X),
1017            ("ymin", Aesthetic::Y),
1018            ("ymax", Aesthetic::Y),
1019            // Boxplot stat="identity" hinges (B3-5): live on the y scale.
1020            ("lower", Aesthetic::Y),
1021            ("middle", Aesthetic::Y),
1022            ("upper", Aesthetic::Y),
1023            // Boxplot outlier extent (B3-5, allowed #6): `GeomBoxplot::
1024            // setup_data` adds these (under an internal-prefix name — see
1025            // review #158 finding 5 and `aes::BOXPLOT_YMIN_FINAL`'s doc,
1026            // so a raw column can never forge them) from the parsed
1027            // `outliers` column, so the y scale trains on them too (step
1028            // 8b below).
1029            (crate::aes::BOXPLOT_YMIN_FINAL, Aesthetic::Y),
1030            (crate::aes::BOXPLOT_YMAX_FINAL, Aesthetic::Y),
1031        ];
1032        // `raw_unmapped` (a raw, unmapped `lower`/`middle`/`upper` passthrough
1033        // column) never trains or gets a scale here — only a layer that
1034        // actually mapped (or whose stat computed) that name does (review
1035        // #158's final check).
1036        for (col, aes) in &stat_aes {
1037            if working_data.has_column(col) && !raw_unmapped.contains(col) {
1038                scale_set.ensure_scale(aes, &working_data);
1039            }
1040        }
1041
1042        // Step 6c: bars/histograms/area draw from a 0 baseline, so the Y scale
1043        // must include 0 — for stat-computed Y (count/bin) and for an explicitly
1044        // mapped Y alike (e.g. geom_col), matching ggplot2.
1045        let y_is_user_mapped = merged_mapping.get_mapping(&Aesthetic::Y).is_some();
1046        if (!y_is_user_mapped || geom.include_zero_baseline()) && working_data.has_column("y") {
1047            if let Some(y_scale) = scale_set.get_mut(&Aesthetic::Y) {
1048                y_scale.train(&[crate::data::Value::Float(0.0)]);
1049            }
1050        }
1051
1052        // Step 7: Position adjustment. `validate` runs once per layer (D2)
1053        // regardless of whether the position is per-panel.
1054        position.validate(warnings);
1055        let params = PositionParams::default();
1056        if position.per_panel() {
1057            Self::compute_position_per_panel(
1058                position.as_ref(),
1059                &mut working_data,
1060                &params,
1061                facet_vars,
1062            );
1063        } else {
1064            position.compute(&mut working_data, &params);
1065        }
1066
1067        // Step 7b: Geom-specific setup (e.g. tile/candlestick extents), then
1068        // make sure position scales exist for any extent columns it added.
1069        geom.setup_data(&mut working_data, warnings);
1070        for (col, aes) in &stat_aes {
1071            if working_data.has_column(col) && !raw_unmapped.contains(col) {
1072                scale_set.ensure_scale(aes, &working_data);
1073            }
1074        }
1075
1076        // Step 8: Train scales on this layer's data
1077        scale_set.train_layer(&working_data);
1078        for (col, aes) in &INTERCEPT_COLS {
1079            if let (Some(values), Some(scale)) = (working_data.column(col), scale_set.get_mut(aes))
1080            {
1081                train_intercepts(scale, values);
1082            }
1083        }
1084
1085        // Step 8b: Positional scales also need to see stat-computed extent columns
1086        // (e.g. boxplot/errorbar/pointrange emit ymin/ymax but no "y"). Without
1087        // this the Y (or X) scale would never train on the range and collapse.
1088        // Same `raw_unmapped` exclusion as Step 6b/7b above: an unmapped
1089        // raw `lower`/`middle`/`upper` must never stretch the y domain.
1090        for (col, aes) in &stat_aes {
1091            if raw_unmapped.contains(col) {
1092                continue;
1093            }
1094            if let Some(values) = working_data.column(col) {
1095                if let Some(scale) = scale_set.get_mut(aes) {
1096                    scale.train(values);
1097                }
1098            }
1099        }
1100
1101        // Step 9 (B2-5): render a `tooltip()` template against the built
1102        // (post-stat) data, into the same internal column `Aes::tooltip`
1103        // uses — so it replaces the geom's default `<title>` the same way,
1104        // with no further geom-level plumbing.
1105        if let Some(template) = &tooltip_template {
1106            // Review PR#141 finding 6 (LOW): a template on a geom that
1107            // never reads that internal column (`set_row_extras`) did
1108            // nothing, silently — e.g. `geom_area`/`geom_boxplot`/
1109            // `geom_ribbon`.
1110            if !geom.consumes_tooltips() {
1111                warnings.push(format!(
1112                    "{geom_label}: tooltip(\"{template}\") has no effect — this geom doesn't read tooltips, or the layer is decorative()"
1113                ));
1114            }
1115            let rendered: Vec<crate::data::Value> = (0..working_data.nrows())
1116                .map(|i| {
1117                    crate::data::Value::Str(render_tooltip_template(
1118                        template,
1119                        &working_data,
1120                        scale_set,
1121                        i,
1122                    ))
1123                })
1124                .collect();
1125            working_data.add_column(crate::aes::TOOLTIP_INTERNAL_NAME.to_string(), rendered);
1126        }
1127
1128        Ok(BuiltLayer {
1129            data: working_data,
1130            geom,
1131            show_legend,
1132        })
1133    }
1134
1135    /// Remove rows where x or y falls outside scale limits set via xlim/ylim.
1136    fn filter_oob_data(data: &mut DataFrame, scale_set: &ScaleSet) {
1137        let x_limits = scale_set.get(&Aesthetic::X).and_then(|s| s.filter_limits());
1138        let y_limits = scale_set.get(&Aesthetic::Y).and_then(|s| s.filter_limits());
1139
1140        if x_limits.is_none() && y_limits.is_none() {
1141            return;
1142        }
1143
1144        let nrows = data.nrows();
1145        let mut keep = vec![true; nrows];
1146
1147        if let Some((min, max)) = x_limits {
1148            if let Some(col) = data.column("x") {
1149                for (i, v) in col.iter().enumerate() {
1150                    if let Some(f) = v.as_f64() {
1151                        if f < min || f > max {
1152                            keep[i] = false;
1153                        }
1154                    }
1155                }
1156            }
1157        }
1158
1159        if let Some((min, max)) = y_limits {
1160            if let Some(col) = data.column("y") {
1161                for (i, v) in col.iter().enumerate() {
1162                    if let Some(f) = v.as_f64() {
1163                        if f < min || f > max {
1164                            keep[i] = false;
1165                        }
1166                    }
1167                }
1168            }
1169        }
1170
1171        // If nothing was filtered, skip the rebuild
1172        if keep.iter().all(|&k| k) {
1173            return;
1174        }
1175
1176        let indices: Vec<usize> = keep
1177            .iter()
1178            .enumerate()
1179            .filter(|(_, &k)| k)
1180            .map(|(i, _)| i)
1181            .collect();
1182
1183        let mut result = DataFrame::new();
1184        for col_name in data.column_names() {
1185            if let Some(src) = data.column(col_name) {
1186                let vals: Vec<_> = indices.iter().map(|&i| src[i].clone()).collect();
1187                result.add_column(col_name.to_string(), vals);
1188            }
1189        }
1190        *data = result;
1191    }
1192
1193    /// Compute per-panel scale sets for free facet scales.
1194    /// For each panel, clones the base scale set, resets freed axes, and retrains on panel data.
1195    fn compute_panel_scales(
1196        facet_scales: &FacetScales,
1197        panels_data: &[Vec<DataFrame>],
1198        base_scales: &ScaleSet,
1199    ) -> Vec<ScaleSet> {
1200        if matches!(facet_scales, FacetScales::Fixed) || panels_data.is_empty() {
1201            return vec![];
1202        }
1203
1204        let free_x = matches!(facet_scales, FacetScales::FreeX | FacetScales::Free);
1205        let free_y = matches!(facet_scales, FacetScales::FreeY | FacetScales::Free);
1206
1207        panels_data
1208            .iter()
1209            .map(|panel_layers| {
1210                let mut panel_set = base_scales.clone();
1211
1212                // Reset freed axis scales
1213                if free_x {
1214                    if let Some(s) = panel_set.get_mut(&Aesthetic::X) {
1215                        s.reset_training();
1216                    }
1217                }
1218                if free_y {
1219                    if let Some(s) = panel_set.get_mut(&Aesthetic::Y) {
1220                        s.reset_training();
1221                    }
1222                }
1223
1224                // Retrain on this panel's data, including extent columns
1225                // (bars/tiles/ranges), so free scales cover whole marks.
1226                for layer_data in panel_layers {
1227                    panel_set.train_layer(layer_data);
1228                    for (cols, aes) in [
1229                        (["xmin", "xmax", "xintercept"], Aesthetic::X),
1230                        (["ymin", "ymax", "yintercept"], Aesthetic::Y),
1231                    ] {
1232                        let freed = match aes {
1233                            Aesthetic::X => free_x,
1234                            _ => free_y,
1235                        };
1236                        if !freed {
1237                            continue;
1238                        }
1239                        for c in cols {
1240                            if let (Some(vals), Some(s)) =
1241                                (layer_data.column(c), panel_set.get_mut(&aes))
1242                            {
1243                                if c.ends_with("intercept") {
1244                                    train_intercepts(s, vals);
1245                                } else {
1246                                    s.train(vals);
1247                                }
1248                            }
1249                        }
1250                    }
1251                }
1252
1253                panel_set
1254            })
1255            .collect()
1256    }
1257
1258    /// Compute which aesthetics should be suppressed from the legend.
1259    /// An aesthetic is suppressed if every layer that has the corresponding column
1260    /// sets show_legend=Some(false), and no layer has it as None or Some(true).
1261    fn compute_suppressed_aes(built_layers: &[BuiltLayer]) -> std::collections::HashSet<Aesthetic> {
1262        use std::collections::HashSet;
1263        let legend_aes = [
1264            Aesthetic::Color,
1265            Aesthetic::Fill,
1266            Aesthetic::Shape,
1267            Aesthetic::Linetype,
1268            Aesthetic::Size,
1269            Aesthetic::Alpha,
1270            Aesthetic::Linewidth,
1271        ];
1272        let mut suppressed = HashSet::new();
1273        for aes in &legend_aes {
1274            let col_name = aes.col_name();
1275            let mut any_has = false;
1276            let mut all_hidden = true;
1277            for bl in built_layers {
1278                if bl.data.has_column(col_name) {
1279                    any_has = true;
1280                    match bl.show_legend {
1281                        Some(false) => {} // still hidden
1282                        _ => {
1283                            all_hidden = false;
1284                            break;
1285                        }
1286                    }
1287                }
1288            }
1289            if any_has && all_hidden {
1290                suppressed.insert(aes.clone());
1291            }
1292        }
1293        suppressed
1294    }
1295
1296    /// Run a per-panel [`Position`](crate::position::Position) (B3-3, D8):
1297    /// partition the rows by the facet variables' joined key, in
1298    /// first-seen order, run `compute` on each panel separately, then
1299    /// scatter the rows back into their original positions — NA-filling
1300    /// any column a panel's `compute` added that another panel's didn't
1301    /// (there currently are none in practice: `PositionStack`/
1302    /// `PositionFill` add the same `ymin` everywhere). Falls back to the
1303    /// plain pooled `compute` when there's no facet column, or only one
1304    /// distinct panel — both byte-identical to the pooled path, since
1305    /// there's nothing to partition.
1306    fn compute_position_per_panel(
1307        position: &dyn crate::position::Position,
1308        data: &mut DataFrame,
1309        params: &PositionParams,
1310        facet_vars: &[String],
1311    ) {
1312        let active_vars: Vec<&str> = facet_vars
1313            .iter()
1314            .map(|s| s.as_str())
1315            .filter(|v| data.has_column(v))
1316            .collect();
1317        if active_vars.is_empty() {
1318            position.compute(data, params);
1319            return;
1320        }
1321
1322        let n = data.nrows();
1323        let mut order: Vec<String> = Vec::new();
1324        let mut groups: indexmap::IndexMap<String, Vec<usize>> = indexmap::IndexMap::new();
1325        for i in 0..n {
1326            let key = active_vars
1327                .iter()
1328                .map(|v| data.column(v).unwrap()[i].key_str().into_owned())
1329                .collect::<Vec<_>>()
1330                .join("\u{1}");
1331            if !groups.contains_key(&key) {
1332                order.push(key.clone());
1333            }
1334            groups.entry(key).or_default().push(i);
1335        }
1336        if order.len() < 2 {
1337            position.compute(data, params);
1338            return;
1339        }
1340
1341        let col_names: Vec<String> = data.column_names().iter().map(|s| s.to_string()).collect();
1342        let mut panels: Vec<(Vec<usize>, DataFrame)> = Vec::with_capacity(order.len());
1343        for key in &order {
1344            let indices = &groups[key];
1345            let mut sub = DataFrame::new();
1346            for name in &col_names {
1347                let col = data.column(name).unwrap();
1348                let values: Vec<crate::data::Value> =
1349                    indices.iter().map(|&i| col[i].clone()).collect();
1350                sub.add_column(name.clone(), values);
1351            }
1352            position.compute(&mut sub, params);
1353            panels.push((indices.clone(), sub));
1354        }
1355
1356        // Union of every column present after `compute` (new columns like
1357        // `ymin` may have been added), so a panel that for some reason
1358        // didn't add one still gets NA there rather than a stale value.
1359        let mut all_cols = col_names.clone();
1360        for (_, sub) in &panels {
1361            for name in sub.column_names() {
1362                if !all_cols.iter().any(|c| c == name) {
1363                    all_cols.push(name.to_string());
1364                }
1365            }
1366        }
1367        for name in &all_cols {
1368            let mut full = vec![crate::data::Value::Na; n];
1369            for (indices, sub) in &panels {
1370                if let Some(col) = sub.column(name) {
1371                    for (j, &i) in indices.iter().enumerate() {
1372                        full[i] = col[j].clone();
1373                    }
1374                }
1375            }
1376            data.add_column(name.clone(), full);
1377        }
1378    }
1379
1380    /// Detect which columns to group by for statistics.
1381    /// Checks group/color/fill plus discrete x (like R's auto-grouping by discrete x).
1382    fn detect_group_columns(data: &DataFrame) -> Vec<String> {
1383        let candidates = ["group", "color", "fill", "x"];
1384        let mut group_cols = Vec::new();
1385        for &col in &candidates {
1386            if data.has_column(col) {
1387                if let Some(values) = data.column(col) {
1388                    let is_discrete = values
1389                        .iter()
1390                        .any(|v| matches!(v, crate::data::Value::Str(_)));
1391                    if is_discrete {
1392                        group_cols.push(col.to_string());
1393                    }
1394                }
1395            }
1396        }
1397        group_cols
1398    }
1399}