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