Skip to main content

ggplot_rs/
build.rs

1use crate::aes::{mapping::apply_after_stat, mapping::resolve_mappings, Aes, Aesthetic};
2use crate::annotate::Annotation;
3use crate::coord::Coord;
4use crate::data::DataFrame;
5use crate::facet::{Facet, FacetScales, Panel};
6use crate::geom::Geom;
7use crate::plot::{GGError, GGPlot, Labels, Layer};
8use crate::position::PositionParams;
9use crate::scale::ScaleSet;
10use crate::theme::Theme;
11
12/// A built layer ready for rendering.
13pub struct BuiltLayer {
14    pub data: DataFrame,
15    pub geom: Box<dyn Geom>,
16    pub show_legend: Option<bool>,
17}
18
19/// A fully built plot ready for rendering.
20pub struct BuiltPlot {
21    pub layers: Vec<BuiltLayer>,
22    pub scales: ScaleSet,
23    pub coord: Box<dyn Coord>,
24    pub theme: Theme,
25    pub labels: Labels,
26    pub facet: Facet,
27    pub panels: Vec<Panel>,
28    /// Per-panel layer data. panels_data[panel_idx][layer_idx] = data for that panel+layer.
29    pub panels_data: Vec<Vec<DataFrame>>,
30    pub annotations: Vec<Annotation>,
31    pub guide_legend: crate::guide::config::GuideLegend,
32    /// Aesthetics suppressed from the legend (all layers with that aes set show_legend=false).
33    pub suppressed_aes: std::collections::HashSet<Aesthetic>,
34    /// Per-panel scale sets for free facets. Empty when FacetScales::Fixed.
35    pub panel_scales: Vec<ScaleSet>,
36    /// Non-fatal problems found while building (ggplot2's warnings), e.g.
37    /// "geom_point: removed 2 rows containing non-finite values". See
38    /// [`BuiltPlot::warnings`].
39    pub warnings: Vec<String>,
40}
41
42impl BuiltPlot {
43    /// Non-fatal build warnings, in layer order: rows dropped for non-finite
44    /// positions, and layers skipped because their stat produced no data
45    /// (e.g. a density of a single value). Rendering still succeeds.
46    pub fn warnings(&self) -> &[String] {
47        &self.warnings
48    }
49}
50
51/// Columns holding *position* values: rows with non-finite values here are
52/// dropped before stats (and again after, for stat output).
53const POSITION_COLS: &[&str] = &[
54    "x",
55    "y",
56    "xmin",
57    "xmax",
58    "ymin",
59    "ymax",
60    "xend",
61    "yend",
62    "open",
63    "high",
64    "low",
65    "close",
66    "xintercept",
67    "yintercept",
68    "slope",
69    "intercept",
70];
71
72/// Columns a position scale's transformation applies to (pre-stat).
73const X_FAMILY: &[&str] = &["x", "xmin", "xmax", "xend", "xintercept"];
74const Y_FAMILY: &[&str] = &[
75    "y",
76    "ymin",
77    "ymax",
78    "yend",
79    "open",
80    "high",
81    "low",
82    "close",
83    "yintercept",
84];
85
86/// Reference-line intercept columns and the position scale they train
87/// (ggplot2: `xintercept`/`yintercept` are x/y aesthetics).
88const INTERCEPT_COLS: [(&str, Aesthetic); 2] =
89    [("xintercept", Aesthetic::X), ("yintercept", Aesthetic::Y)];
90
91/// Train `scale` on reference-line intercepts. A numeric intercept on a
92/// discrete axis is a position *between* categories (ggplot2), not a level.
93fn train_intercepts(scale: &mut Box<dyn crate::scale::Scale>, values: &[crate::data::Value]) {
94    if scale.is_discrete() && values.iter().all(|v| v.as_f64().is_some() || v.is_na()) {
95        return;
96    }
97    scale.train(values);
98}
99
100/// Map a canonical column name to the aesthetic whose scale formats it
101/// (B2-5 tooltip templates / B2-4 `data-fvalue`-style formatting). Columns
102/// outside this list (group, label, or a stat output like `count`) have no
103/// scale of their own: [`render_tooltip_template`] falls back to the plain
104/// display format for them.
105fn column_aesthetic(name: &str) -> Option<Aesthetic> {
106    match name {
107        "x" => Some(Aesthetic::X),
108        "y" => Some(Aesthetic::Y),
109        "fill" => Some(Aesthetic::Fill),
110        "color" | "colour" => Some(Aesthetic::Color),
111        "size" => Some(Aesthetic::Size),
112        "linewidth" => Some(Aesthetic::Linewidth),
113        "alpha" => Some(Aesthetic::Alpha),
114        _ => None,
115    }
116}
117
118/// Render one row of a `GGPlot::tooltip` template: `{column}` is filled
119/// from `data`'s row `row`, formatted through that column's scale when
120/// [`column_aesthetic`] names one and it has a
121/// [`Scale::format_value`](crate::scale::Scale::format_value) (else the
122/// plain display format); a column that doesn't exist on this layer is
123/// empty, not literally `{column}`. `{{`/`}}` are literal braces.
124fn render_tooltip_template(
125    template: &str,
126    data: &DataFrame,
127    scales: &ScaleSet,
128    row: usize,
129) -> String {
130    let mut out = String::new();
131    let mut chars = template.chars().peekable();
132    while let Some(c) = chars.next() {
133        match c {
134            '{' if chars.peek() == Some(&'{') => {
135                chars.next();
136                out.push('{');
137            }
138            '}' if chars.peek() == Some(&'}') => {
139                chars.next();
140                out.push('}');
141            }
142            '{' => {
143                let mut name = String::new();
144                let mut closed = false;
145                for nc in chars.by_ref() {
146                    if nc == '}' {
147                        closed = true;
148                        break;
149                    }
150                    name.push(nc);
151                }
152                if closed {
153                    out.push_str(&render_placeholder(&name, data, scales, row));
154                } else {
155                    // Unterminated `{…` to end of string: not a placeholder,
156                    // kept literally.
157                    out.push('{');
158                    out.push_str(&name);
159                }
160            }
161            c => out.push(c),
162        }
163    }
164    out
165}
166
167fn render_placeholder(name: &str, data: &DataFrame, scales: &ScaleSet, row: usize) -> String {
168    let Some(col) = data.column(name) else {
169        return String::new();
170    };
171    let Some(v) = col.get(row) else {
172        return String::new();
173    };
174    if v.is_na() {
175        return String::new();
176    }
177    if let Some(formatted) = column_aesthetic(name)
178        .and_then(|aes| scales.get(&aes))
179        .and_then(|s| s.format_value(v))
180    {
181        return formatted;
182    }
183    crate::geom::tip_value(v)
184}
185
186/// The grammar pipeline: transforms a GGPlot specification into render-ready data.
187pub struct PlotBuilder;
188
189impl PlotBuilder {
190    pub fn build(plot: GGPlot) -> Result<BuiltPlot, GGError> {
191        let GGPlot {
192            data: plot_data,
193            mapping: plot_mapping,
194            layers,
195            scales: user_scales,
196            mut coord,
197            theme,
198            labels,
199            facet,
200            annotations,
201            guide_legend,
202            warnings: plot_warnings,
203            default_aspect_ratio,
204            locale,
205            svg_root_data: _,
206            root_attrs: _,
207            svg_warnings_attr: _,
208            svg_formatted_values: _,
209        } = plot;
210        let mut theme = theme;
211        if theme.aspect_ratio.is_none() {
212            theme.aspect_ratio = default_aspect_ratio;
213        }
214
215        // Malformed input (e.g. mismatched column lengths) is reported here as
216        // a validation error instead of panicking when the frame is assembled.
217        plot_data.validate()?;
218        for layer in &layers {
219            if let Some(d) = &layer.data {
220                d.validate()?;
221            }
222        }
223
224        let mut scale_set = ScaleSet::new();
225
226        // Add user-specified scales
227        for s in user_scales {
228            scale_set.add(s);
229        }
230
231        let mut built_layers = Vec::new();
232        let mut warnings: Vec<String> = plot_warnings;
233
234        // Faceting variables — used to group stat computation per panel so a
235        // computed stat (density/histogram) is estimated per panel, not pooled.
236        let facet_vars = Self::facet_vars(&facet);
237
238        for layer in layers {
239            let built = Self::build_layer(
240                layer,
241                &plot_data,
242                &plot_mapping,
243                &mut scale_set,
244                &theme,
245                &facet_vars,
246                &mut warnings,
247            )?;
248            built_layers.push(built);
249        }
250
251        // Final scale training pass across all layers
252        for bl in &built_layers {
253            scale_set.train_layer(&bl.data);
254            for (col, aes) in &INTERCEPT_COLS {
255                // No layer created this position scale: the intercepts do.
256                if let (Some(values), None) = (bl.data.column(col), scale_set.get(aes)) {
257                    let mut frame = DataFrame::new();
258                    frame.add_column(aes.col_name().to_string(), values.to_vec());
259                    scale_set.ensure_scale(aes, &frame);
260                }
261                if let (Some(values), Some(scale)) = (bl.data.column(col), scale_set.get_mut(aes)) {
262                    train_intercepts(scale, values);
263                }
264            }
265        }
266
267        // Validate each scale's final configuration (e.g. a transform
268        // undefined at its own domain limits) now that training is done —
269        // `map`/`map_to_size` run per mark at draw time and have no
270        // warnings sink of their own.
271        for scale in scale_set.iter() {
272            scale.validate(&mut warnings);
273        }
274
275        // An empty plot (no layers, or only layers without data) still gets a
276        // panel with axes: make sure both position scales exist. They stay
277        // untrained, so they draw no breaks and map everything to the centre.
278        if built_layers.iter().all(|bl| bl.data.nrows() == 0) {
279            for aes in [Aesthetic::X, Aesthetic::Y] {
280                if scale_set.get(&aes).is_none() {
281                    scale_set.add(Box::new(
282                        crate::scale::continuous::ScaleContinuous::new().for_aesthetic(aes),
283                    ));
284                }
285            }
286        }
287
288        // Apply coord zoom limits (coord_cartesian xlim/ylim)
289        if let Some((min, max)) = coord.zoom_x() {
290            scale_set.set_limits(&Aesthetic::X, min, max);
291        }
292        if let Some((min, max)) = coord.zoom_y() {
293            scale_set.set_limits(&Aesthetic::Y, min, max);
294        }
295
296        // Let the coordinate system adjust/inspect the trained scales.
297        coord.train_scales(&mut scale_set);
298
299        // Supply trained axis spans to the coordinate system (used by coord_trans).
300        // pmin/pmax are the panel positions of the domain endpoints, so the coord
301        // can invert the scale's (linearly expanded) mapping exactly.
302        let axis_span = |aes: &Aesthetic| {
303            scale_set.get(aes).and_then(|s| {
304                s.domain().map(|(min, max)| crate::coord::AxisSpan {
305                    min,
306                    max,
307                    pmin: s.map(&crate::data::Value::Float(min)),
308                    pmax: s.map(&crate::data::Value::Float(max)),
309                })
310            })
311        };
312        let x_span = axis_span(&Aesthetic::X);
313        let y_span = axis_span(&Aesthetic::Y);
314        coord.set_domains(x_span, y_span);
315
316        // Apply after_scale() color derivations: copy the source aesthetic's
317        // column to the target and register a lightness-modified clone of the
318        // source scale, so the target aesthetic draws the mapped source color
319        // adjusted in lightness (e.g. a darker border derived from the fill).
320        for spec in &plot_mapping.after_scale {
321            if let Some(src_scale) = scale_set.get(&spec.source) {
322                let modified = crate::scale::modified::ScaleColorModified::new(
323                    src_scale.clone_box(),
324                    spec.target.clone(),
325                    spec.lightness,
326                );
327                let (src_col, tgt_col) = (spec.source.col_name(), spec.target.col_name());
328                for bl in &mut built_layers {
329                    if !bl.data.has_column(tgt_col) {
330                        if let Some(vals) = bl.data.column(src_col) {
331                            let vals = vals.to_vec();
332                            bl.data.add_column(tgt_col.to_string(), vals);
333                        }
334                    }
335                }
336                scale_set.add(Box::new(modified));
337            }
338        }
339
340        // Plot-level locale for default break labels (before panel scales are
341        // cloned from the base set, so free facets inherit it).
342        if let Some(locale) = &locale {
343            scale_set.set_default_locale(locale);
344        }
345
346        // Compute facet panels
347        let (panels, panels_data) = Self::compute_facets(&facet, &built_layers, &plot_data);
348
349        // Compute suppressed aesthetics from show_legend flags.
350        let suppressed_aes = Self::compute_suppressed_aes(&built_layers);
351
352        // Compute per-panel scales for free facets
353        let facet_scales_mode = match &facet {
354            Facet::Wrap { scales, .. } => scales.clone(),
355            Facet::Grid { scales, .. } => scales.clone(),
356            Facet::None => FacetScales::Fixed,
357        };
358        let panel_scales = Self::compute_panel_scales(&facet_scales_mode, &panels_data, &scale_set);
359
360        Ok(BuiltPlot {
361            layers: built_layers,
362            scales: scale_set,
363            coord,
364            theme,
365            labels,
366            facet,
367            panels,
368            panels_data,
369            annotations,
370            guide_legend,
371            suppressed_aes,
372            panel_scales,
373            warnings,
374        })
375    }
376
377    /// Keep only the rows whose `keep` flag is set.
378    fn retain_rows(data: &mut DataFrame, keep: &[bool]) {
379        if keep.iter().all(|&k| k) {
380            return;
381        }
382        let mut result = DataFrame::new();
383        for col_name in data.column_names() {
384            if let Some(src) = data.column(col_name) {
385                let vals: Vec<_> = src
386                    .iter()
387                    .zip(keep)
388                    .filter(|(_, &k)| k)
389                    .map(|(v, _)| v.clone())
390                    .collect();
391                result.add_column(col_name.to_string(), vals);
392            }
393        }
394        *data = result;
395    }
396
397    /// Drop rows with a non-finite value in any position column (`NaN` always;
398    /// `±Inf` unless the geom gives infinities meaning). Returns the number of
399    /// rows removed.
400    /// `keep_as_break`: for a connecting geom ([`Geom::keeps_na_as_break`]),
401    /// a bad row is counted (for the warning) but not deleted — it stays in
402    /// `data` as a non-finite pixel position, which `DrawBackend::draw_line`
403    /// splits the polyline on, instead of being spliced out and silently
404    /// joining the finite points across the gap.
405    fn drop_non_finite(data: &mut DataFrame, allow_infinite: bool, keep_as_break: bool) -> usize {
406        let n = data.nrows();
407        let mut keep = vec![true; n];
408        for col in POSITION_COLS {
409            if let Some(values) = data.column(col) {
410                for (i, v) in values.iter().enumerate() {
411                    if let crate::data::Value::Float(f) = v {
412                        let bad = f.is_nan() || (f.is_infinite() && !allow_infinite);
413                        if bad {
414                            keep[i] = false;
415                        }
416                    }
417                }
418            }
419        }
420        let removed = keep.iter().filter(|&&k| !k).count();
421        if removed > 0 && !keep_as_break {
422            Self::retain_rows(data, &keep);
423        }
424        removed
425    }
426
427    /// The column name(s) a facet splits on, if any.
428    fn facet_vars(facet: &Facet) -> Vec<String> {
429        match facet {
430            Facet::None => vec![],
431            Facet::Wrap { var, .. } => vec![var.clone()],
432            Facet::Grid {
433                row_var, col_var, ..
434            } => row_var.iter().chain(col_var.iter()).cloned().collect(),
435        }
436    }
437
438    fn compute_facets(
439        facet: &Facet,
440        built_layers: &[BuiltLayer],
441        _plot_data: &DataFrame,
442    ) -> (Vec<Panel>, Vec<Vec<DataFrame>>) {
443        match facet {
444            Facet::None => (vec![], vec![]),
445            Facet::Wrap {
446                var,
447                ncol,
448                labeller,
449                ..
450            } => {
451                // Collect unique levels from all layers' data
452                let mut levels: Vec<String> = Vec::new();
453                for bl in built_layers {
454                    if let Some(col) = bl.data.column(var) {
455                        for v in col {
456                            let key = v.to_group_key();
457                            if !levels.contains(&key) {
458                                levels.push(key);
459                            }
460                        }
461                    }
462                }
463
464                // Panels will be positioned during rendering (depends on layout)
465                let panels: Vec<Panel> = levels
466                    .iter()
467                    .enumerate()
468                    .map(|(i, value)| {
469                        let ncols =
470                            ncol.unwrap_or_else(|| (levels.len() as f64).sqrt().ceil() as usize);
471                        let formatted = labeller.format(var, value);
472                        Panel {
473                            row: i / ncols.max(1),
474                            col: i % ncols.max(1),
475                            label: formatted.clone(),
476                            row_label: None,
477                            col_label: Some(formatted),
478                            rect: crate::render::Rect {
479                                x: 0.0,
480                                y: 0.0,
481                                width: 0.0,
482                                height: 0.0,
483                            },
484                        }
485                    })
486                    .collect();
487
488                // Split data per panel per layer
489                let panels_data: Vec<Vec<DataFrame>> = levels
490                    .iter()
491                    .map(|level| {
492                        built_layers
493                            .iter()
494                            .map(|bl| Self::filter_data_by_var(&bl.data, var, level))
495                            .collect()
496                    })
497                    .collect();
498
499                (panels, panels_data)
500            }
501            Facet::Grid {
502                row_var,
503                col_var,
504                labeller,
505                ..
506            } => {
507                let mut row_levels: Vec<String> = Vec::new();
508                let mut col_levels: Vec<String> = Vec::new();
509
510                for bl in built_layers {
511                    if let Some(rv) = row_var {
512                        if let Some(col) = bl.data.column(rv) {
513                            for v in col {
514                                let key = v.to_group_key();
515                                if !row_levels.contains(&key) {
516                                    row_levels.push(key);
517                                }
518                            }
519                        }
520                    }
521                    if let Some(cv) = col_var {
522                        if let Some(col) = bl.data.column(cv) {
523                            for v in col {
524                                let key = v.to_group_key();
525                                if !col_levels.contains(&key) {
526                                    col_levels.push(key);
527                                }
528                            }
529                        }
530                    }
531                }
532
533                if row_levels.is_empty() {
534                    row_levels.push("".to_string());
535                }
536                if col_levels.is_empty() {
537                    col_levels.push("".to_string());
538                }
539
540                let mut panels = Vec::new();
541                let mut panels_data = Vec::new();
542
543                for (ri, rl) in row_levels.iter().enumerate() {
544                    for (ci, cl) in col_levels.iter().enumerate() {
545                        let row_fmt = if rl.is_empty() {
546                            None
547                        } else {
548                            let rv = row_var.as_deref().unwrap_or("");
549                            Some(labeller.format(rv, rl))
550                        };
551                        let col_fmt = if cl.is_empty() {
552                            None
553                        } else {
554                            let cv = col_var.as_deref().unwrap_or("");
555                            Some(labeller.format(cv, cl))
556                        };
557                        let label = match (&row_fmt, &col_fmt) {
558                            (Some(r), Some(c)) => format!("{r} | {c}"),
559                            (Some(r), None) => r.clone(),
560                            (None, Some(c)) => c.clone(),
561                            (None, None) => String::new(),
562                        };
563                        panels.push(Panel {
564                            row: ri,
565                            col: ci,
566                            label,
567                            row_label: row_fmt,
568                            col_label: col_fmt,
569                            rect: crate::render::Rect {
570                                x: 0.0,
571                                y: 0.0,
572                                width: 0.0,
573                                height: 0.0,
574                            },
575                        });
576
577                        let layer_data: Vec<DataFrame> = built_layers
578                            .iter()
579                            .map(|bl| {
580                                let mut data = bl.data.clone();
581                                if let Some(rv) = row_var {
582                                    if !rl.is_empty() {
583                                        data = Self::filter_data_by_var(&data, rv, rl);
584                                    }
585                                }
586                                if let Some(cv) = col_var {
587                                    if !cl.is_empty() {
588                                        data = Self::filter_data_by_var(&data, cv, cl);
589                                    }
590                                }
591                                data
592                            })
593                            .collect();
594                        panels_data.push(layer_data);
595                    }
596                }
597
598                (panels, panels_data)
599            }
600        }
601    }
602
603    fn filter_data_by_var(data: &DataFrame, var: &str, level: &str) -> DataFrame {
604        if let Some(col) = data.column(var) {
605            let indices: Vec<usize> = col
606                .iter()
607                .enumerate()
608                .filter(|(_, v)| v.to_group_key() == level)
609                .map(|(i, _)| i)
610                .collect();
611
612            let mut result = DataFrame::new();
613            for col_name in data.column_names() {
614                if let Some(src) = data.column(col_name) {
615                    let vals: Vec<_> = indices.iter().map(|&i| src[i].clone()).collect();
616                    result.add_column(col_name.to_string(), vals);
617                }
618            }
619            result
620        } else {
621            data.clone()
622        }
623    }
624
625    fn build_layer(
626        layer: Layer,
627        plot_data: &DataFrame,
628        plot_mapping: &Aes,
629        scale_set: &mut ScaleSet,
630        theme: &Theme,
631        facet_vars: &[String],
632        warnings: &mut Vec<String>,
633    ) -> Result<BuiltLayer, GGError> {
634        let primary = theme.primary;
635        let geom_theme = &theme.geom;
636        let Layer {
637            data: layer_data,
638            mapping: layer_mapping,
639            mut geom,
640            stat,
641            position,
642            params: _,
643            show_legend,
644            explicit_style,
645            tooltip_template,
646        } = layer;
647
648        // Step 1: Resolve data — use layer data if provided, else plot data
649        // Borrow the plot data when the layer has none of its own (no per-layer
650        // full-frame clone; `resolve_mappings` copies only the columns it needs).
651        let source_data = layer_data.as_ref().unwrap_or(plot_data);
652
653        // Step 2: Merge mappings — layer overrides plot-level
654        // (Reference lines don't inherit the plot mapping — ggplot2's
655        // `inherit.aes = FALSE`.)
656        let merged_mapping = if geom.inherit_aes() {
657            plot_mapping.merge(&layer_mapping)
658        } else {
659            layer_mapping.clone()
660        };
661
662        // B2-7: geom ink/paper/accent palette, applied before the brand
663        // colour below so a primary colour still wins when both are set.
664        // Skipped when the layer was configured explicitly
665        // (`geom_*_with(...)`) — the same `explicit_style` gate
666        // `set_series_color` uses below, and for the same reason: a
667        // field-value comparison against the hard-coded default (e.g.
668        // `color == (0, 0, 0)`) can't tell an explicitly-set black apart
669        // from a black that was simply never touched (review PR#141
670        // finding 4), but `explicit_style` can.
671        if !explicit_style {
672            geom.apply_element_geom(geom_theme);
673        }
674
675        // Brand/primary color: apply to a single-series geom only when the layer
676        // maps neither color nor fill (an explicit aesthetic always wins) and
677        // the geom wasn't configured explicitly via `geom_*_with(...)`.
678        if let Some(color) = primary.filter(|_| !explicit_style) {
679            let has_color = merged_mapping.get_mapping(&Aesthetic::Color).is_some();
680            let has_fill = merged_mapping.get_mapping(&Aesthetic::Fill).is_some();
681            if !has_color && !has_fill {
682                geom.set_series_color(color);
683            }
684        }
685
686        // Step 3: Evaluate aes — rename columns to canonical names
687        let mut working_data = resolve_mappings(source_data, &merged_mapping, warnings);
688
689        // Remember which columns the user actually supplied (pre-stat). A required
690        // aesthetic is satisfied if it was present here OR is synthesized by the
691        // stat (checked after Step 6) — e.g. boxplot maps `y` then the stat turns
692        // it into ymin/ymax, while StatEcdf produces `y` that wasn't mapped.
693        let pre_stat_columns: Vec<String> = working_data
694            .column_names()
695            .iter()
696            .map(|s| s.to_string())
697            .collect();
698
699        // Step 4: Ensure scales exist for each mapped aesthetic
700        for m in &merged_mapping.mappings {
701            scale_set.ensure_scale(&m.aesthetic, &working_data);
702        }
703
704        // Step 5: Scale transformation (e.g., log10 before stats). A position
705        // scale transforms every column of its family (y also ymin/ymax/…).
706        for scale in scale_set.iter() {
707            let aes = scale.aesthetic();
708            let cols: Vec<&str> = match aes {
709                Aesthetic::X => X_FAMILY.to_vec(),
710                Aesthetic::Y => Y_FAMILY.to_vec(),
711                _ => vec![aes.col_name()],
712            };
713            for col_name in cols {
714                let Some(col) = working_data.column(col_name) else {
715                    continue;
716                };
717                // A transform that is undefined for a value (log of 0 or a
718                // negative) yields NaN, so the non-finite filter below drops
719                // the row with a warning instead of drawing it at 0.
720                let transformed: Vec<_> = col
721                    .iter()
722                    .map(|v| match (scale.transform(v), v.as_f64()) {
723                        (crate::data::Value::Na, Some(_)) => crate::data::Value::Float(f64::NAN),
724                        (t, _) => t,
725                    })
726                    .collect();
727                let any_changed = transformed.iter().zip(col.iter()).any(|(t, o)| {
728                    match (t.as_f64(), o.as_f64()) {
729                        (Some(a), Some(b)) => a.is_nan() || (a - b).abs() > f64::EPSILON,
730                        _ => false,
731                    }
732                });
733                if any_changed {
734                    if let Some(col_mut) = working_data.column_mut(col_name) {
735                        *col_mut = transformed;
736                    }
737                }
738            }
739        }
740
741        // Step 5b: Filter out-of-bounds data (xlim/ylim filter before stats)
742        Self::filter_oob_data(&mut working_data, scale_set);
743
744        // Step 5c: Drop rows whose position is NaN (or ±Inf, unless the geom
745        // reads infinities as "panel edge"), like ggplot2's
746        // "Removed n rows containing non-finite values".
747        let geom_label = format!("geom_{}", geom.name());
748        let removed = Self::drop_non_finite(
749            &mut working_data,
750            geom.allows_infinite(),
751            geom.keeps_na_as_break(),
752        );
753        if removed > 0 {
754            warnings.push(format!(
755                "{geom_label}: removed {removed} row{} containing non-finite values",
756                if removed == 1 { "" } else { "s" }
757            ));
758        }
759        let input_rows = working_data.nrows();
760
761        // Validate the stat's own configuration once per layer (e.g.
762        // `StatBinHex::binwidth` — a problem with fixed parameters, not
763        // with the data), before it runs per-group below.
764        stat.validate(warnings);
765
766        // Step 6: Compute statistics. Group by aesthetic groups AND the facet
767        // variables, so a computed stat (density/histogram/…) is estimated per
768        // panel rather than on pooled data; the facet column is then re-attached
769        // to each group's output so faceting can split it back out.
770        // Panelwise stats (e.g. stat_compare_means) see the whole panel at once,
771        // so we group only by facet variables, not by aesthetic groups.
772        let mut group_cols = if stat.panelwise() {
773            Vec::new()
774        } else {
775            Self::detect_group_columns(&working_data)
776        };
777        for fv in facet_vars {
778            if working_data.has_column(fv) && !group_cols.contains(fv) {
779                group_cols.push(fv.clone());
780            }
781        }
782
783        working_data = if !group_cols.is_empty() {
784            let groups =
785                working_data.group_by(&group_cols.iter().map(|s| s.as_str()).collect::<Vec<_>>());
786            let mut result = DataFrame::new();
787            for group in groups {
788                let mut computed = stat.compute_group(&group, scale_set);
789                let n = computed.nrows();
790                if n > 0 {
791                    for fv in facet_vars {
792                        if !computed.has_column(fv) {
793                            if let Some(val) = group.column(fv).and_then(|c| c.first()).cloned() {
794                                computed.add_column(fv.clone(), vec![val; n]);
795                            }
796                        }
797                    }
798                }
799                result.vstack(&computed);
800            }
801            result
802        } else {
803            stat.compute_group(&working_data, scale_set)
804        };
805
806        // Step 6a: Apply after_stat() mappings (rename stat-computed columns)
807        apply_after_stat(&mut working_data, &merged_mapping, warnings);
808
809        // Stat output can contain NaN (e.g. a fit on degenerate input): drop
810        // those rows too, so nothing non-finite reaches a geom.
811        let removed = Self::drop_non_finite(
812            &mut working_data,
813            geom.allows_infinite(),
814            geom.keeps_na_as_break(),
815        );
816        if removed > 0 {
817            warnings.push(format!(
818                "{geom_label}: removed {removed} row{} containing missing values (stat_{} output)",
819                if removed == 1 { "" } else { "s" },
820                stat.name()
821            ));
822        }
823
824        // A layer with no data draws nothing — an empty input, or a stat that
825        // cannot estimate anything (a density of one value, loess on two
826        // points). It must not fail the whole plot. A stat's own required
827        // aesthetics are still validated when there *was* input.
828        if working_data.nrows() == 0 {
829            if input_rows > 0 {
830                for aes in &stat.required_aes() {
831                    let col_name = aes.col_name();
832                    if !pre_stat_columns.iter().any(|c| c == col_name) {
833                        return Err(GGError::ValidationError(format!(
834                            "stat_{} requires aesthetic '{}' but it was not provided",
835                            stat.name(),
836                            col_name
837                        )));
838                    }
839                }
840                warnings.push(format!(
841                    "{geom_label}: stat_{} produced no data from {input_rows} row{} \
842                     (too few or degenerate values); layer skipped",
843                    stat.name(),
844                    if input_rows == 1 { "" } else { "s" }
845                ));
846            }
847            return Ok(BuiltLayer {
848                data: DataFrame::new(),
849                geom,
850                show_legend,
851            });
852        }
853
854        // Step 6a-validate: A required aesthetic must have been supplied by the
855        // user (pre-stat) or synthesized by the stat (post-stat). This lets
856        // StatEcdf produce `y` for geom_step, while boxplot — which maps `y` then
857        // consumes it into ymin/ymax — still validates. Empty input has the
858        // column in neither place, so genuinely-missing aesthetics still error.
859        for aes in &geom.required_aes() {
860            let col_name = aes.col_name();
861            let supplied = pre_stat_columns.iter().any(|c| c == col_name);
862            if !supplied && !working_data.has_column(col_name) {
863                return Err(GGError::ValidationError(format!(
864                    "geom_{} requires aesthetic '{}' but it was not provided",
865                    geom.name(),
866                    col_name
867                )));
868            }
869        }
870
871        // Step 6b: Ensure scales for stat-computed aesthetics (e.g. y from StatCount/StatBin)
872        let stat_aes = [
873            ("x", Aesthetic::X),
874            ("y", Aesthetic::Y),
875            ("xmin", Aesthetic::X),
876            ("xmax", Aesthetic::X),
877            ("ymin", Aesthetic::Y),
878            ("ymax", Aesthetic::Y),
879        ];
880        for (col, aes) in &stat_aes {
881            if working_data.has_column(col) {
882                scale_set.ensure_scale(aes, &working_data);
883            }
884        }
885
886        // Step 6c: bars/histograms/area draw from a 0 baseline, so the Y scale
887        // must include 0 — for stat-computed Y (count/bin) and for an explicitly
888        // mapped Y alike (e.g. geom_col), matching ggplot2.
889        let y_is_user_mapped = merged_mapping.get_mapping(&Aesthetic::Y).is_some();
890        if (!y_is_user_mapped || geom.include_zero_baseline()) && working_data.has_column("y") {
891            if let Some(y_scale) = scale_set.get_mut(&Aesthetic::Y) {
892                y_scale.train(&[crate::data::Value::Float(0.0)]);
893            }
894        }
895
896        // Step 7: Position adjustment
897        let params = PositionParams::default();
898        position.compute(&mut working_data, &params);
899
900        // Step 7b: Geom-specific setup (e.g. tile/candlestick extents), then
901        // make sure position scales exist for any extent columns it added.
902        geom.setup_data(&mut working_data);
903        for (col, aes) in &stat_aes {
904            if working_data.has_column(col) {
905                scale_set.ensure_scale(aes, &working_data);
906            }
907        }
908
909        // Step 8: Train scales on this layer's data
910        scale_set.train_layer(&working_data);
911        for (col, aes) in &INTERCEPT_COLS {
912            if let (Some(values), Some(scale)) = (working_data.column(col), scale_set.get_mut(aes))
913            {
914                train_intercepts(scale, values);
915            }
916        }
917
918        // Step 8b: Positional scales also need to see stat-computed extent columns
919        // (e.g. boxplot/errorbar/pointrange emit ymin/ymax but no "y"). Without
920        // this the Y (or X) scale would never train on the range and collapse.
921        for (col, aes) in &stat_aes {
922            if let Some(values) = working_data.column(col) {
923                if let Some(scale) = scale_set.get_mut(aes) {
924                    scale.train(values);
925                }
926            }
927        }
928
929        // Step 9 (B2-5): render a `tooltip()` template against the built
930        // (post-stat) data, into the same internal column `Aes::tooltip`
931        // uses — so it replaces the geom's default `<title>` the same way,
932        // with no further geom-level plumbing.
933        if let Some(template) = &tooltip_template {
934            // Review PR#141 finding 6 (LOW): a template on a geom that
935            // never reads that internal column (`set_row_extras`) did
936            // nothing, silently — e.g. `geom_area`/`geom_boxplot`/
937            // `geom_ribbon`.
938            if !geom.consumes_tooltips() {
939                warnings.push(format!(
940                    "{geom_label}: tooltip(\"{template}\") has no effect — this geom doesn't read tooltips"
941                ));
942            }
943            let rendered: Vec<crate::data::Value> = (0..working_data.nrows())
944                .map(|i| {
945                    crate::data::Value::Str(render_tooltip_template(
946                        template,
947                        &working_data,
948                        scale_set,
949                        i,
950                    ))
951                })
952                .collect();
953            working_data.add_column(crate::aes::TOOLTIP_INTERNAL_NAME.to_string(), rendered);
954        }
955
956        Ok(BuiltLayer {
957            data: working_data,
958            geom,
959            show_legend,
960        })
961    }
962
963    /// Remove rows where x or y falls outside scale limits set via xlim/ylim.
964    fn filter_oob_data(data: &mut DataFrame, scale_set: &ScaleSet) {
965        let x_limits = scale_set.get(&Aesthetic::X).and_then(|s| s.filter_limits());
966        let y_limits = scale_set.get(&Aesthetic::Y).and_then(|s| s.filter_limits());
967
968        if x_limits.is_none() && y_limits.is_none() {
969            return;
970        }
971
972        let nrows = data.nrows();
973        let mut keep = vec![true; nrows];
974
975        if let Some((min, max)) = x_limits {
976            if let Some(col) = data.column("x") {
977                for (i, v) in col.iter().enumerate() {
978                    if let Some(f) = v.as_f64() {
979                        if f < min || f > max {
980                            keep[i] = false;
981                        }
982                    }
983                }
984            }
985        }
986
987        if let Some((min, max)) = y_limits {
988            if let Some(col) = data.column("y") {
989                for (i, v) in col.iter().enumerate() {
990                    if let Some(f) = v.as_f64() {
991                        if f < min || f > max {
992                            keep[i] = false;
993                        }
994                    }
995                }
996            }
997        }
998
999        // If nothing was filtered, skip the rebuild
1000        if keep.iter().all(|&k| k) {
1001            return;
1002        }
1003
1004        let indices: Vec<usize> = keep
1005            .iter()
1006            .enumerate()
1007            .filter(|(_, &k)| k)
1008            .map(|(i, _)| i)
1009            .collect();
1010
1011        let mut result = DataFrame::new();
1012        for col_name in data.column_names() {
1013            if let Some(src) = data.column(col_name) {
1014                let vals: Vec<_> = indices.iter().map(|&i| src[i].clone()).collect();
1015                result.add_column(col_name.to_string(), vals);
1016            }
1017        }
1018        *data = result;
1019    }
1020
1021    /// Compute per-panel scale sets for free facet scales.
1022    /// For each panel, clones the base scale set, resets freed axes, and retrains on panel data.
1023    fn compute_panel_scales(
1024        facet_scales: &FacetScales,
1025        panels_data: &[Vec<DataFrame>],
1026        base_scales: &ScaleSet,
1027    ) -> Vec<ScaleSet> {
1028        if matches!(facet_scales, FacetScales::Fixed) || panels_data.is_empty() {
1029            return vec![];
1030        }
1031
1032        let free_x = matches!(facet_scales, FacetScales::FreeX | FacetScales::Free);
1033        let free_y = matches!(facet_scales, FacetScales::FreeY | FacetScales::Free);
1034
1035        panels_data
1036            .iter()
1037            .map(|panel_layers| {
1038                let mut panel_set = base_scales.clone();
1039
1040                // Reset freed axis scales
1041                if free_x {
1042                    if let Some(s) = panel_set.get_mut(&Aesthetic::X) {
1043                        s.reset_training();
1044                    }
1045                }
1046                if free_y {
1047                    if let Some(s) = panel_set.get_mut(&Aesthetic::Y) {
1048                        s.reset_training();
1049                    }
1050                }
1051
1052                // Retrain on this panel's data, including extent columns
1053                // (bars/tiles/ranges), so free scales cover whole marks.
1054                for layer_data in panel_layers {
1055                    panel_set.train_layer(layer_data);
1056                    for (cols, aes) in [
1057                        (["xmin", "xmax", "xintercept"], Aesthetic::X),
1058                        (["ymin", "ymax", "yintercept"], Aesthetic::Y),
1059                    ] {
1060                        let freed = match aes {
1061                            Aesthetic::X => free_x,
1062                            _ => free_y,
1063                        };
1064                        if !freed {
1065                            continue;
1066                        }
1067                        for c in cols {
1068                            if let (Some(vals), Some(s)) =
1069                                (layer_data.column(c), panel_set.get_mut(&aes))
1070                            {
1071                                if c.ends_with("intercept") {
1072                                    train_intercepts(s, vals);
1073                                } else {
1074                                    s.train(vals);
1075                                }
1076                            }
1077                        }
1078                    }
1079                }
1080
1081                panel_set
1082            })
1083            .collect()
1084    }
1085
1086    /// Compute which aesthetics should be suppressed from the legend.
1087    /// An aesthetic is suppressed if every layer that has the corresponding column
1088    /// sets show_legend=Some(false), and no layer has it as None or Some(true).
1089    fn compute_suppressed_aes(built_layers: &[BuiltLayer]) -> std::collections::HashSet<Aesthetic> {
1090        use std::collections::HashSet;
1091        let legend_aes = [
1092            Aesthetic::Color,
1093            Aesthetic::Fill,
1094            Aesthetic::Shape,
1095            Aesthetic::Linetype,
1096            Aesthetic::Size,
1097            Aesthetic::Alpha,
1098            Aesthetic::Linewidth,
1099        ];
1100        let mut suppressed = HashSet::new();
1101        for aes in &legend_aes {
1102            let col_name = aes.col_name();
1103            let mut any_has = false;
1104            let mut all_hidden = true;
1105            for bl in built_layers {
1106                if bl.data.has_column(col_name) {
1107                    any_has = true;
1108                    match bl.show_legend {
1109                        Some(false) => {} // still hidden
1110                        _ => {
1111                            all_hidden = false;
1112                            break;
1113                        }
1114                    }
1115                }
1116            }
1117            if any_has && all_hidden {
1118                suppressed.insert(aes.clone());
1119            }
1120        }
1121        suppressed
1122    }
1123
1124    /// Detect which columns to group by for statistics.
1125    /// Checks group/color/fill plus discrete x (like R's auto-grouping by discrete x).
1126    fn detect_group_columns(data: &DataFrame) -> Vec<String> {
1127        let candidates = ["group", "color", "fill", "x"];
1128        let mut group_cols = Vec::new();
1129        for &col in &candidates {
1130            if data.has_column(col) {
1131                if let Some(values) = data.column(col) {
1132                    let is_discrete = values
1133                        .iter()
1134                        .any(|v| matches!(v, crate::data::Value::Str(_)));
1135                    if is_discrete {
1136                        group_cols.push(col.to_string());
1137                    }
1138                }
1139            }
1140        }
1141        group_cols
1142    }
1143}