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