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