Skip to main content

ggplot_rs/aes/
mod.rs

1pub mod expr;
2pub mod mapping;
3
4pub use mapping::{apply_after_stat, resolve_mappings};
5
6/// All supported aesthetic channels.
7#[derive(Clone, Debug, PartialEq, Eq, Hash)]
8pub enum Aesthetic {
9    X,
10    Y,
11    Color,
12    Fill,
13    Size,
14    Shape,
15    Alpha,
16    Linetype,
17    Group,
18    Ymin,
19    Ymax,
20    Xmin,
21    Xmax,
22    Label,
23    Weight,
24    Xend,
25    Yend,
26    Angle,
27    Radius,
28    /// Stroke width for segment/curve/path/line (R's `linewidth`).
29    Linewidth,
30    /// A custom `<title>` hover tooltip, replacing the geom's own. Routed
31    /// through [`crate::aes::TOOLTIP_INTERNAL_NAME`] in `resolve_mappings`,
32    /// the same way `DataAttr` is, so an unrelated raw column literally
33    /// named `"tooltip"` can never be mistaken for a mapped one.
34    Tooltip,
35    /// A custom `data-<name>` attribute on the mark. The `String` is the
36    /// full attribute name (`"data-<name>"`). [`Aes::data_attr`] validates
37    /// `name` against `[a-z0-9][a-z0-9-]*` (excluding reserved names) before
38    /// constructing this; a `DataAttr` built directly — bypassing that
39    /// builder — is re-validated in `resolve_mappings`
40    /// (`aes::validated_data_attr_internal_name`), the single authoritative
41    /// gate regardless of how the mapping was created.
42    DataAttr(String),
43    /// Financial OHLC aesthetics (`geom_candlestick` / `geom_ohlc`); they
44    /// share the y position scale.
45    Open,
46    High,
47    Low,
48    Close,
49    /// Reference-line aesthetics (`geom_hline` / `geom_vline` /
50    /// `geom_abline`). `xintercept`/`yintercept` share (and train) the x / y
51    /// position scale, as in ggplot2; `slope`/`intercept` get no scale.
52    Xintercept,
53    Yintercept,
54    Slope,
55    Intercept,
56}
57
58impl Aesthetic {
59    /// The canonical column name used in the working DataFrame after aes evaluation.
60    pub fn col_name(&self) -> &str {
61        match self {
62            Aesthetic::X => "x",
63            Aesthetic::Y => "y",
64            Aesthetic::Color => "color",
65            Aesthetic::Fill => "fill",
66            Aesthetic::Size => "size",
67            Aesthetic::Shape => "shape",
68            Aesthetic::Alpha => "alpha",
69            Aesthetic::Linetype => "linetype",
70            Aesthetic::Group => "group",
71            Aesthetic::Ymin => "ymin",
72            Aesthetic::Ymax => "ymax",
73            Aesthetic::Xmin => "xmin",
74            Aesthetic::Xmax => "xmax",
75            Aesthetic::Label => "label",
76            Aesthetic::Weight => "weight",
77            Aesthetic::Xend => "xend",
78            Aesthetic::Yend => "yend",
79            Aesthetic::Angle => "angle",
80            Aesthetic::Radius => "radius",
81            Aesthetic::Linewidth => "linewidth",
82            Aesthetic::Tooltip => "tooltip",
83            Aesthetic::DataAttr(name) => name,
84            Aesthetic::Open => "open",
85            Aesthetic::High => "high",
86            Aesthetic::Low => "low",
87            Aesthetic::Close => "close",
88            Aesthetic::Xintercept => "xintercept",
89            Aesthetic::Yintercept => "yintercept",
90            Aesthetic::Slope => "slope",
91            Aesthetic::Intercept => "intercept",
92        }
93    }
94}
95
96/// When an aesthetic mapping should be resolved.
97#[derive(Clone, Debug, PartialEq)]
98pub enum MappingStage {
99    /// Resolve before stat computation (default — maps from raw data columns).
100    BeforeStat,
101    /// Resolve after stat computation (maps from stat-computed columns like `density`, `count`).
102    AfterStat,
103}
104
105/// Maps a source column to an aesthetic channel.
106#[derive(Clone, Debug)]
107pub struct AesMapping {
108    pub column: String,
109    pub aesthetic: Aesthetic,
110    pub stage: MappingStage,
111}
112
113/// A post-scale aesthetic derivation (R's `after_scale`): the `target` color
114/// aesthetic is set to the `source` aesthetic's *mapped* color, adjusted in
115/// lightness (`+` toward white, `-` toward black, in `-1.0..=1.0`).
116#[derive(Clone, Debug)]
117pub struct AfterScaleSpec {
118    pub target: Aesthetic,
119    pub source: Aesthetic,
120    pub lightness: f64,
121}
122
123/// Builder for aesthetic mappings.
124#[derive(Clone, Debug, Default)]
125pub struct Aes {
126    pub mappings: Vec<AesMapping>,
127    /// Post-scale color derivations (`after_scale`).
128    pub after_scale: Vec<AfterScaleSpec>,
129    /// Non-fatal build warnings (e.g. an invalid [`data_attr`](Self::data_attr)
130    /// name), merged into [`GGPlot`](crate::plot::GGPlot)'s warnings by
131    /// `aes()`/`layer_aes()`.
132    pub(crate) warnings: Vec<String>,
133}
134
135impl Aes {
136    pub fn new() -> Self {
137        Self::default()
138    }
139
140    fn push(mut self, col: &str, aesthetic: Aesthetic) -> Self {
141        self.mappings.push(AesMapping {
142            column: col.to_string(),
143            aesthetic,
144            stage: MappingStage::BeforeStat,
145        });
146        self
147    }
148
149    fn push_after_stat(mut self, col: &str, aesthetic: Aesthetic) -> Self {
150        self.mappings.push(AesMapping {
151            column: col.to_string(),
152            aesthetic,
153            stage: MappingStage::AfterStat,
154        });
155        self
156    }
157
158    // ─── after_scale() — post-scale color derivation ────────────
159
160    /// Set `fill` to the mapped `color` with adjusted lightness (R's
161    /// `aes(fill = after_scale(...))`). `lightness` in `-1.0..=1.0`: positive
162    /// lightens toward white, negative darkens toward black.
163    pub fn after_scale_fill_from_color(mut self, lightness: f64) -> Self {
164        self.after_scale.push(AfterScaleSpec {
165            target: Aesthetic::Fill,
166            source: Aesthetic::Color,
167            lightness,
168        });
169        self
170    }
171
172    /// Set `color` to the mapped `fill` with adjusted lightness — useful for a
173    /// darker border around a filled shape (`color = after_scale(darken(fill))`).
174    pub fn after_scale_color_from_fill(mut self, lightness: f64) -> Self {
175        self.after_scale.push(AfterScaleSpec {
176            target: Aesthetic::Color,
177            source: Aesthetic::Fill,
178            lightness,
179        });
180        self
181    }
182
183    /// `stage(start, after_stat)`: map `aesthetic` from `start` before the stat
184    /// and re-map it from `after_stat` afterwards (R's `stage()`).
185    pub fn stage(mut self, aesthetic: Aesthetic, start: &str, after_stat: &str) -> Self {
186        self.mappings.push(AesMapping {
187            column: start.to_string(),
188            aesthetic: aesthetic.clone(),
189            stage: MappingStage::BeforeStat,
190        });
191        self.mappings.push(AesMapping {
192            column: after_stat.to_string(),
193            aesthetic,
194            stage: MappingStage::AfterStat,
195        });
196        self
197    }
198
199    pub fn x(self, col: &str) -> Self {
200        self.push(col, Aesthetic::X)
201    }
202    pub fn y(self, col: &str) -> Self {
203        self.push(col, Aesthetic::Y)
204    }
205    pub fn color(self, col: &str) -> Self {
206        self.push(col, Aesthetic::Color)
207    }
208    pub fn fill(self, col: &str) -> Self {
209        self.push(col, Aesthetic::Fill)
210    }
211    pub fn size(self, col: &str) -> Self {
212        self.push(col, Aesthetic::Size)
213    }
214    pub fn shape(self, col: &str) -> Self {
215        self.push(col, Aesthetic::Shape)
216    }
217    pub fn alpha(self, col: &str) -> Self {
218        self.push(col, Aesthetic::Alpha)
219    }
220    pub fn group(self, col: &str) -> Self {
221        self.push(col, Aesthetic::Group)
222    }
223    pub fn ymin(self, col: &str) -> Self {
224        self.push(col, Aesthetic::Ymin)
225    }
226    pub fn ymax(self, col: &str) -> Self {
227        self.push(col, Aesthetic::Ymax)
228    }
229    pub fn label(self, col: &str) -> Self {
230        self.push(col, Aesthetic::Label)
231    }
232    pub fn weight(self, col: &str) -> Self {
233        self.push(col, Aesthetic::Weight)
234    }
235    pub fn xend(self, col: &str) -> Self {
236        self.push(col, Aesthetic::Xend)
237    }
238    pub fn yend(self, col: &str) -> Self {
239        self.push(col, Aesthetic::Yend)
240    }
241    pub fn xmin(self, col: &str) -> Self {
242        self.push(col, Aesthetic::Xmin)
243    }
244    pub fn xmax(self, col: &str) -> Self {
245        self.push(col, Aesthetic::Xmax)
246    }
247    pub fn angle(self, col: &str) -> Self {
248        self.push(col, Aesthetic::Angle)
249    }
250    pub fn radius(self, col: &str) -> Self {
251        self.push(col, Aesthetic::Radius)
252    }
253    pub fn linetype(self, col: &str) -> Self {
254        self.push(col, Aesthetic::Linetype)
255    }
256    /// Stroke width for segment/curve/path/line (R's `linewidth`).
257    pub fn linewidth(self, col: &str) -> Self {
258        self.push(col, Aesthetic::Linewidth)
259    }
260
261    /// A custom `<title>` hover tooltip from `col`, replacing the geom's
262    /// own. `ensure_scale` skips it (no scale, no legend): computing stats
263    /// drop the column (identity-stat layers keep it), as `linetype`/`alpha`
264    /// do.
265    pub fn tooltip(self, col: &str) -> Self {
266        self.push(col, Aesthetic::Tooltip)
267    }
268
269    /// A custom `data-<name>` attribute on the mark, from `col`. `name` must
270    /// match `[a-z0-9][a-z0-9-]*` and not be a name ggplot-rs itself writes
271    /// via another mechanism (currently just `"part"` — see
272    /// `DrawBackend::set_mark_part`); an invalid or reserved name is skipped
273    /// with a build warning (surfaced via
274    /// [`GGPlot::aes`](crate::plot::GGPlot::aes) / `layer_aes`). A user attr
275    /// replaces a built-in of the same name (`data-x`, `data-series`,
276    /// `data-value`) — never duplicated.
277    pub fn data_attr(mut self, name: &str, col: &str) -> Self {
278        if !is_valid_data_attr_name(name) {
279            let reason = if RESERVED_DATA_ATTR_NAMES.contains(&name) {
280                "reserved (written by ggplot-rs itself)"
281            } else {
282                "invalid (must match [a-z0-9][a-z0-9-]*)"
283            };
284            self.warnings
285                .push(format!("aes: data_attr name {name:?} is {reason}; skipped"));
286            return self;
287        }
288        self.push(col, Aesthetic::DataAttr(format!("data-{name}")))
289    }
290    /// Opening price (`geom_candlestick` / `geom_ohlc`).
291    pub fn open(self, col: &str) -> Self {
292        self.push(col, Aesthetic::Open)
293    }
294    /// Period high (`geom_candlestick` / `geom_ohlc`).
295    pub fn high(self, col: &str) -> Self {
296        self.push(col, Aesthetic::High)
297    }
298    /// Period low (`geom_candlestick` / `geom_ohlc`).
299    pub fn low(self, col: &str) -> Self {
300        self.push(col, Aesthetic::Low)
301    }
302    /// Closing price (`geom_candlestick` / `geom_ohlc`).
303    pub fn close(self, col: &str) -> Self {
304        self.push(col, Aesthetic::Close)
305    }
306    /// Data-mapped `geom_vline` position (one line per row).
307    pub fn xintercept(self, col: &str) -> Self {
308        self.push(col, Aesthetic::Xintercept)
309    }
310    /// Data-mapped `geom_hline` position (one line per row).
311    pub fn yintercept(self, col: &str) -> Self {
312        self.push(col, Aesthetic::Yintercept)
313    }
314    /// Data-mapped `geom_abline` slope (one line per row).
315    pub fn slope(self, col: &str) -> Self {
316        self.push(col, Aesthetic::Slope)
317    }
318    /// Data-mapped `geom_abline` intercept (one line per row).
319    pub fn intercept(self, col: &str) -> Self {
320        self.push(col, Aesthetic::Intercept)
321    }
322
323    // ─── after_stat() mappings ──────────────────────────────────
324    // Map stat-computed columns (e.g., `density`, `count`, `ncount`, `ndensity`)
325    // to an aesthetic. These are resolved after the stat step in the build pipeline.
326
327    /// Map a stat-computed column to the y aesthetic (e.g., `after_stat_y("density")`).
328    pub fn after_stat_y(self, col: &str) -> Self {
329        self.push_after_stat(col, Aesthetic::Y)
330    }
331
332    /// Map a stat-computed column to the x aesthetic.
333    pub fn after_stat_x(self, col: &str) -> Self {
334        self.push_after_stat(col, Aesthetic::X)
335    }
336
337    /// Map a stat-computed column to the fill aesthetic.
338    pub fn after_stat_fill(self, col: &str) -> Self {
339        self.push_after_stat(col, Aesthetic::Fill)
340    }
341
342    /// Map a stat-computed column to the color aesthetic.
343    pub fn after_stat_color(self, col: &str) -> Self {
344        self.push_after_stat(col, Aesthetic::Color)
345    }
346
347    /// Map a stat-computed column to the size aesthetic.
348    pub fn after_stat_size(self, col: &str) -> Self {
349        self.push_after_stat(col, Aesthetic::Size)
350    }
351
352    /// Map a stat-computed column to the alpha aesthetic.
353    pub fn after_stat_alpha(self, col: &str) -> Self {
354        self.push_after_stat(col, Aesthetic::Alpha)
355    }
356
357    /// Map a stat-computed column to the linewidth aesthetic.
358    pub fn after_stat_linewidth(self, col: &str) -> Self {
359        self.push_after_stat(col, Aesthetic::Linewidth)
360    }
361
362    /// Get the column mapped to a specific aesthetic.
363    pub fn get_mapping(&self, aes: &Aesthetic) -> Option<&str> {
364        self.mappings
365            .iter()
366            .find(|m| m.aesthetic == *aes)
367            .map(|m| m.column.as_str())
368    }
369
370    /// Merge another Aes into this one. The other's mappings override on conflict.
371    pub fn merge(&self, other: &Aes) -> Aes {
372        let mut result = self.clone();
373        for m in &other.mappings {
374            // Remove existing mapping for same aesthetic
375            result
376                .mappings
377                .retain(|existing| existing.aesthetic != m.aesthetic);
378            result.mappings.push(m.clone());
379        }
380        result.warnings.extend(other.warnings.iter().cloned());
381        result
382    }
383}
384
385/// Names ggplot-rs itself writes via a mechanism other than `data_attr`'s
386/// `set_mark_attrs` dedup — reserved so a user mapping can never collide and
387/// produce a duplicate attribute. `"part"` is `DrawBackend::set_mark_part`
388/// (arrowheads: `data-part="arrow"`), written directly in `push_mark`, not
389/// through the built-ins list `set_mark_attrs` dedups against.
390const RESERVED_DATA_ATTR_NAMES: &[&str] = &["part"];
391
392/// `[a-z0-9][a-z0-9-]*`, excluding [`RESERVED_DATA_ATTR_NAMES`] — a valid,
393/// non-colliding HTML custom-data-attribute suffix.
394pub(crate) fn is_valid_data_attr_name(name: &str) -> bool {
395    if RESERVED_DATA_ATTR_NAMES.contains(&name) {
396        return false;
397    }
398    let mut chars = name.chars();
399    match chars.next() {
400        Some(c) if c.is_ascii_lowercase() || c.is_ascii_digit() => {}
401        _ => return false,
402    }
403    chars.all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-')
404}
405
406/// Internal storage namespace for validated interactive-aesthetic columns
407/// (`data_attr`, `tooltip`) in the built DataFrame. Starts with a NUL byte,
408/// which can't appear in any column name `resolve_mappings` carries over
409/// from a caller's raw source data: that loop explicitly drops a raw column
410/// whose name contains a NUL (or this prefix) rather than carrying it
411/// through unchanged — see its doc. So in practice no caller-supplied
412/// column collides with a name under this prefix, but the real guarantee is
413/// that drop, not merely that a legitimate column "wouldn't" be named this
414/// way (`GGPlot::new` accepts any `String`, including one with a NUL byte
415/// in it, deliberately crafted to collide).
416pub(crate) const INTERNAL_PREFIX: &str = "\u{0}ggplot-rs:";
417
418/// Internal storage prefix for validated `data_attr` columns — see
419/// [`INTERNAL_PREFIX`]. `geom::set_row_extras` looks for *only* this
420/// prefix instead of scanning for anything that merely looks like a
421/// `data-*` column, which would also match an unrelated raw source column
422/// a caller never mapped through `Aes::data_attr` (the injection this
423/// guards against: see `validated_data_attr_internal_name`).
424pub(crate) const DATA_ATTR_INTERNAL_PREFIX: &str = "\u{0}ggplot-rs:data-attr:";
425
426/// Internal storage name for a validated `Aes::tooltip` mapping — see
427/// [`INTERNAL_PREFIX`]. Without this, an unmapped raw column literally
428/// named `"tooltip"` would drive every mark's hover title (the same class
429/// of bug `DATA_ATTR_INTERNAL_PREFIX` fixes for `data_attr`); routing it
430/// through the same internal-prefix scheme closes that for tooltips too.
431pub(crate) const TOOLTIP_INTERNAL_NAME: &str = "\u{0}ggplot-rs:tooltip";
432
433/// Validate a `data_attr` mapping's target (`"data-<name>"`, as stored in
434/// [`Aesthetic::DataAttr`]) and, if valid, return the internal column name
435/// it should be written under. This is the single authoritative gate: it
436/// runs in `resolve_mappings` regardless of *how* the `DataAttr` mapping was
437/// created — the validated `Aes::data_attr` builder, or a `DataAttr`
438/// constructed directly and pushed into the public `Aes.mappings` field,
439/// bypassing the builder's own check.
440pub(crate) fn validated_data_attr_internal_name(data_attr_target: &str) -> Option<String> {
441    let suffix = data_attr_target.strip_prefix("data-")?;
442    if is_valid_data_attr_name(suffix) {
443        Some(format!("{DATA_ATTR_INTERNAL_PREFIX}{data_attr_target}"))
444    } else {
445        None
446    }
447}
448
449#[cfg(test)]
450mod data_attr_name_tests {
451    use super::{is_valid_data_attr_name, validated_data_attr_internal_name};
452
453    #[test]
454    fn accepts_lowercase_alnum_and_hyphen() {
455        for ok in ["x", "row", "data-ish", "a1", "0a", "fvalue"] {
456            assert!(is_valid_data_attr_name(ok), "{ok} should be valid");
457        }
458    }
459
460    #[test]
461    fn rejects_empty_uppercase_and_other_punctuation() {
462        for bad in ["", "-x", "X", "a_b", "a.b", "a b", "déjà"] {
463            assert!(!is_valid_data_attr_name(bad), "{bad} should be invalid");
464        }
465    }
466
467    #[test]
468    fn rejects_the_reserved_part_name() {
469        assert!(!is_valid_data_attr_name("part"));
470        assert_eq!(validated_data_attr_internal_name("data-part"), None);
471    }
472
473    #[test]
474    fn rejects_a_malformed_injection_attempt_even_with_the_data_prefix() {
475        // Space, `=`, parens: none of these are legal in the suffix.
476        assert_eq!(
477            validated_data_attr_internal_name("data-a onmouseover=alert(1) b"),
478            None
479        );
480    }
481
482    #[test]
483    fn valid_target_maps_to_the_internal_prefix_plus_itself() {
484        let internal = validated_data_attr_internal_name("data-orig").unwrap();
485        assert!(internal.starts_with(super::DATA_ATTR_INTERNAL_PREFIX));
486        assert!(internal.ends_with("data-orig"));
487    }
488}
489
490#[cfg(test)]
491mod aesthetic_and_aes_tests {
492    use super::{Aes, Aesthetic};
493
494    /// `col_name()` for `Tooltip`/`DataAttr` is never actually used for
495    /// storage (both are special-cased in `aes::mapping`), but it should
496    /// still return a sane, documented fallback for a direct caller.
497    #[test]
498    fn col_name_for_tooltip_and_data_attr() {
499        assert_eq!(Aesthetic::Tooltip.col_name(), "tooltip");
500        assert_eq!(
501            Aesthetic::DataAttr("data-orig".to_string()).col_name(),
502            "data-orig"
503        );
504    }
505
506    #[test]
507    fn data_attr_warning_text_distinguishes_invalid_from_reserved() {
508        let invalid = Aes::new().data_attr("Not Valid!", "col");
509        assert_eq!(invalid.warnings.len(), 1);
510        assert!(invalid.warnings[0].contains("invalid"));
511        assert!(!invalid.warnings[0].contains("reserved"));
512
513        let reserved = Aes::new().data_attr("part", "col");
514        assert_eq!(reserved.warnings.len(), 1);
515        assert!(reserved.warnings[0].contains("reserved"));
516    }
517
518    #[test]
519    fn after_stat_linewidth_maps_a_stat_computed_column() {
520        let aes = Aes::new().after_stat_linewidth("weight");
521        assert_eq!(aes.get_mapping(&Aesthetic::Linewidth), Some("weight"));
522    }
523}