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