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