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    /// Financial OHLC aesthetics (`geom_candlestick` / `geom_ohlc`); they
29    /// share the y position scale.
30    Open,
31    High,
32    Low,
33    Close,
34    /// Reference-line aesthetics (`geom_hline` / `geom_vline` /
35    /// `geom_abline`). `xintercept`/`yintercept` share (and train) the x / y
36    /// position scale, as in ggplot2; `slope`/`intercept` get no scale.
37    Xintercept,
38    Yintercept,
39    Slope,
40    Intercept,
41}
42
43impl Aesthetic {
44    /// The canonical column name used in the working DataFrame after aes evaluation.
45    pub fn col_name(&self) -> &str {
46        match self {
47            Aesthetic::X => "x",
48            Aesthetic::Y => "y",
49            Aesthetic::Color => "color",
50            Aesthetic::Fill => "fill",
51            Aesthetic::Size => "size",
52            Aesthetic::Shape => "shape",
53            Aesthetic::Alpha => "alpha",
54            Aesthetic::Linetype => "linetype",
55            Aesthetic::Group => "group",
56            Aesthetic::Ymin => "ymin",
57            Aesthetic::Ymax => "ymax",
58            Aesthetic::Xmin => "xmin",
59            Aesthetic::Xmax => "xmax",
60            Aesthetic::Label => "label",
61            Aesthetic::Weight => "weight",
62            Aesthetic::Xend => "xend",
63            Aesthetic::Yend => "yend",
64            Aesthetic::Angle => "angle",
65            Aesthetic::Radius => "radius",
66            Aesthetic::Open => "open",
67            Aesthetic::High => "high",
68            Aesthetic::Low => "low",
69            Aesthetic::Close => "close",
70            Aesthetic::Xintercept => "xintercept",
71            Aesthetic::Yintercept => "yintercept",
72            Aesthetic::Slope => "slope",
73            Aesthetic::Intercept => "intercept",
74        }
75    }
76}
77
78/// When an aesthetic mapping should be resolved.
79#[derive(Clone, Debug, PartialEq)]
80pub enum MappingStage {
81    /// Resolve before stat computation (default — maps from raw data columns).
82    BeforeStat,
83    /// Resolve after stat computation (maps from stat-computed columns like `density`, `count`).
84    AfterStat,
85}
86
87/// Maps a source column to an aesthetic channel.
88#[derive(Clone, Debug)]
89pub struct AesMapping {
90    pub column: String,
91    pub aesthetic: Aesthetic,
92    pub stage: MappingStage,
93}
94
95/// A post-scale aesthetic derivation (R's `after_scale`): the `target` color
96/// aesthetic is set to the `source` aesthetic's *mapped* color, adjusted in
97/// lightness (`+` toward white, `-` toward black, in `-1.0..=1.0`).
98#[derive(Clone, Debug)]
99pub struct AfterScaleSpec {
100    pub target: Aesthetic,
101    pub source: Aesthetic,
102    pub lightness: f64,
103}
104
105/// Builder for aesthetic mappings.
106#[derive(Clone, Debug, Default)]
107pub struct Aes {
108    pub mappings: Vec<AesMapping>,
109    /// Post-scale color derivations (`after_scale`).
110    pub after_scale: Vec<AfterScaleSpec>,
111}
112
113impl Aes {
114    pub fn new() -> Self {
115        Self::default()
116    }
117
118    fn push(mut self, col: &str, aesthetic: Aesthetic) -> Self {
119        self.mappings.push(AesMapping {
120            column: col.to_string(),
121            aesthetic,
122            stage: MappingStage::BeforeStat,
123        });
124        self
125    }
126
127    fn push_after_stat(mut self, col: &str, aesthetic: Aesthetic) -> Self {
128        self.mappings.push(AesMapping {
129            column: col.to_string(),
130            aesthetic,
131            stage: MappingStage::AfterStat,
132        });
133        self
134    }
135
136    // ─── after_scale() — post-scale color derivation ────────────
137
138    /// Set `fill` to the mapped `color` with adjusted lightness (R's
139    /// `aes(fill = after_scale(...))`). `lightness` in `-1.0..=1.0`: positive
140    /// lightens toward white, negative darkens toward black.
141    pub fn after_scale_fill_from_color(mut self, lightness: f64) -> Self {
142        self.after_scale.push(AfterScaleSpec {
143            target: Aesthetic::Fill,
144            source: Aesthetic::Color,
145            lightness,
146        });
147        self
148    }
149
150    /// Set `color` to the mapped `fill` with adjusted lightness — useful for a
151    /// darker border around a filled shape (`color = after_scale(darken(fill))`).
152    pub fn after_scale_color_from_fill(mut self, lightness: f64) -> Self {
153        self.after_scale.push(AfterScaleSpec {
154            target: Aesthetic::Color,
155            source: Aesthetic::Fill,
156            lightness,
157        });
158        self
159    }
160
161    /// `stage(start, after_stat)`: map `aesthetic` from `start` before the stat
162    /// and re-map it from `after_stat` afterwards (R's `stage()`).
163    pub fn stage(mut self, aesthetic: Aesthetic, start: &str, after_stat: &str) -> Self {
164        self.mappings.push(AesMapping {
165            column: start.to_string(),
166            aesthetic: aesthetic.clone(),
167            stage: MappingStage::BeforeStat,
168        });
169        self.mappings.push(AesMapping {
170            column: after_stat.to_string(),
171            aesthetic,
172            stage: MappingStage::AfterStat,
173        });
174        self
175    }
176
177    pub fn x(self, col: &str) -> Self {
178        self.push(col, Aesthetic::X)
179    }
180    pub fn y(self, col: &str) -> Self {
181        self.push(col, Aesthetic::Y)
182    }
183    pub fn color(self, col: &str) -> Self {
184        self.push(col, Aesthetic::Color)
185    }
186    pub fn fill(self, col: &str) -> Self {
187        self.push(col, Aesthetic::Fill)
188    }
189    pub fn size(self, col: &str) -> Self {
190        self.push(col, Aesthetic::Size)
191    }
192    pub fn shape(self, col: &str) -> Self {
193        self.push(col, Aesthetic::Shape)
194    }
195    pub fn alpha(self, col: &str) -> Self {
196        self.push(col, Aesthetic::Alpha)
197    }
198    pub fn group(self, col: &str) -> Self {
199        self.push(col, Aesthetic::Group)
200    }
201    pub fn ymin(self, col: &str) -> Self {
202        self.push(col, Aesthetic::Ymin)
203    }
204    pub fn ymax(self, col: &str) -> Self {
205        self.push(col, Aesthetic::Ymax)
206    }
207    pub fn label(self, col: &str) -> Self {
208        self.push(col, Aesthetic::Label)
209    }
210    pub fn weight(self, col: &str) -> Self {
211        self.push(col, Aesthetic::Weight)
212    }
213    pub fn xend(self, col: &str) -> Self {
214        self.push(col, Aesthetic::Xend)
215    }
216    pub fn yend(self, col: &str) -> Self {
217        self.push(col, Aesthetic::Yend)
218    }
219    pub fn xmin(self, col: &str) -> Self {
220        self.push(col, Aesthetic::Xmin)
221    }
222    pub fn xmax(self, col: &str) -> Self {
223        self.push(col, Aesthetic::Xmax)
224    }
225    pub fn angle(self, col: &str) -> Self {
226        self.push(col, Aesthetic::Angle)
227    }
228    pub fn radius(self, col: &str) -> Self {
229        self.push(col, Aesthetic::Radius)
230    }
231    pub fn linetype(self, col: &str) -> Self {
232        self.push(col, Aesthetic::Linetype)
233    }
234    /// Opening price (`geom_candlestick` / `geom_ohlc`).
235    pub fn open(self, col: &str) -> Self {
236        self.push(col, Aesthetic::Open)
237    }
238    /// Period high (`geom_candlestick` / `geom_ohlc`).
239    pub fn high(self, col: &str) -> Self {
240        self.push(col, Aesthetic::High)
241    }
242    /// Period low (`geom_candlestick` / `geom_ohlc`).
243    pub fn low(self, col: &str) -> Self {
244        self.push(col, Aesthetic::Low)
245    }
246    /// Closing price (`geom_candlestick` / `geom_ohlc`).
247    pub fn close(self, col: &str) -> Self {
248        self.push(col, Aesthetic::Close)
249    }
250    /// Data-mapped `geom_vline` position (one line per row).
251    pub fn xintercept(self, col: &str) -> Self {
252        self.push(col, Aesthetic::Xintercept)
253    }
254    /// Data-mapped `geom_hline` position (one line per row).
255    pub fn yintercept(self, col: &str) -> Self {
256        self.push(col, Aesthetic::Yintercept)
257    }
258    /// Data-mapped `geom_abline` slope (one line per row).
259    pub fn slope(self, col: &str) -> Self {
260        self.push(col, Aesthetic::Slope)
261    }
262    /// Data-mapped `geom_abline` intercept (one line per row).
263    pub fn intercept(self, col: &str) -> Self {
264        self.push(col, Aesthetic::Intercept)
265    }
266
267    // ─── after_stat() mappings ──────────────────────────────────
268    // Map stat-computed columns (e.g., `density`, `count`, `ncount`, `ndensity`)
269    // to an aesthetic. These are resolved after the stat step in the build pipeline.
270
271    /// Map a stat-computed column to the y aesthetic (e.g., `after_stat_y("density")`).
272    pub fn after_stat_y(self, col: &str) -> Self {
273        self.push_after_stat(col, Aesthetic::Y)
274    }
275
276    /// Map a stat-computed column to the x aesthetic.
277    pub fn after_stat_x(self, col: &str) -> Self {
278        self.push_after_stat(col, Aesthetic::X)
279    }
280
281    /// Map a stat-computed column to the fill aesthetic.
282    pub fn after_stat_fill(self, col: &str) -> Self {
283        self.push_after_stat(col, Aesthetic::Fill)
284    }
285
286    /// Map a stat-computed column to the color aesthetic.
287    pub fn after_stat_color(self, col: &str) -> Self {
288        self.push_after_stat(col, Aesthetic::Color)
289    }
290
291    /// Map a stat-computed column to the size aesthetic.
292    pub fn after_stat_size(self, col: &str) -> Self {
293        self.push_after_stat(col, Aesthetic::Size)
294    }
295
296    /// Map a stat-computed column to the alpha aesthetic.
297    pub fn after_stat_alpha(self, col: &str) -> Self {
298        self.push_after_stat(col, Aesthetic::Alpha)
299    }
300
301    /// Get the column mapped to a specific aesthetic.
302    pub fn get_mapping(&self, aes: &Aesthetic) -> Option<&str> {
303        self.mappings
304            .iter()
305            .find(|m| m.aesthetic == *aes)
306            .map(|m| m.column.as_str())
307    }
308
309    /// Merge another Aes into this one. The other's mappings override on conflict.
310    pub fn merge(&self, other: &Aes) -> Aes {
311        let mut result = self.clone();
312        for m in &other.mappings {
313            // Remove existing mapping for same aesthetic
314            result
315                .mappings
316                .retain(|existing| existing.aesthetic != m.aesthetic);
317            result.mappings.push(m.clone());
318        }
319        result
320    }
321}