Skip to main content

ggplot_rs/scale/
gradient.rs

1use crate::aes::Aesthetic;
2use crate::data::Value;
3
4use super::color::RGBAColor;
5use super::format::UnwindSafeFormatter;
6use super::util::nice_step;
7use super::Scale;
8
9/// Diverging gradient color scale (low → mid → high with midpoint).
10#[derive(Clone)]
11pub struct ScaleColorGradient2 {
12    aesthetic: Aesthetic,
13    name: String,
14    low: RGBAColor,
15    mid: RGBAColor,
16    high: RGBAColor,
17    midpoint: f64,
18    min: f64,
19    max: f64,
20    /// Locale for the default break labels (`None` = default marks).
21    locale: Option<crate::locale::Locale>,
22    /// Fixed `(lo, hi)` domain ([`with_limits`](Self::with_limits)):
23    /// `train` no longer widens it, and `map_to_color` maps against it
24    /// instead of the trained min/max.
25    limits: Option<(f64, f64)>,
26    /// Out-of-bounds policy for `limits` ([`with_oob`](Self::with_oob)).
27    /// Default [`Oob::Censor`](super::Oob::Censor).
28    oob: super::Oob,
29    /// Colour for a genuinely missing or out-of-bounds-and-censored value
30    /// ([`with_na_value`](Self::with_na_value)). Default ggplot2's
31    /// `"grey50"`, `(127, 127, 127)`.
32    na_value: (u8, u8, u8),
33    /// Formatter for [`format_value`](super::Scale::format_value) (#164).
34    /// Falls back to the plain locale-formatted number when unset. See
35    /// [`UnwindSafeFormatter`] for why this isn't a plain `LabelFormatter`.
36    value_formatter: Option<UnwindSafeFormatter>,
37}
38
39// Manual `Debug`: `value_formatter` (an `Arc<dyn Fn>`) isn't `Debug`.
40impl std::fmt::Debug for ScaleColorGradient2 {
41    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
42        f.debug_struct("ScaleColorGradient2")
43            .field("aesthetic", &self.aesthetic)
44            .field("name", &self.name)
45            .field("low", &self.low)
46            .field("mid", &self.mid)
47            .field("high", &self.high)
48            .field("midpoint", &self.midpoint)
49            .field("min", &self.min)
50            .field("max", &self.max)
51            .field("locale", &self.locale)
52            .field("limits", &self.limits)
53            .field("oob", &self.oob)
54            .field("na_value", &self.na_value)
55            .field("value_formatter", &self.value_formatter.is_some())
56            .finish()
57    }
58}
59
60impl ScaleColorGradient2 {
61    pub fn new(aesthetic: Aesthetic) -> Self {
62        ScaleColorGradient2 {
63            aesthetic,
64            name: String::new(),
65            low: RGBAColor::new(0, 0, 255),
66            mid: RGBAColor::new(255, 255, 255),
67            high: RGBAColor::new(255, 0, 0),
68            midpoint: 0.0,
69            min: f64::INFINITY,
70            max: f64::NEG_INFINITY,
71            locale: None,
72            limits: None,
73            oob: super::Oob::Censor,
74            na_value: super::DEFAULT_NA_VALUE,
75            value_formatter: None,
76        }
77    }
78
79    /// Set a formatter for [`format_value`](super::Scale::format_value) —
80    /// a per-mark `data-fvalue`/tooltip-template value for this fill/colour
81    /// scale (#164). Falls back to the plain locale-formatted number when
82    /// unset (byte-identical default output).
83    pub fn with_value_formatter<F>(mut self, f: F) -> Self
84    where
85        F: Fn(f64) -> String + Send + Sync + 'static,
86    {
87        self.value_formatter = Some(UnwindSafeFormatter(std::sync::Arc::new(f)));
88        self
89    }
90
91    pub fn with_colors(mut self, low: RGBAColor, mid: RGBAColor, high: RGBAColor) -> Self {
92        self.low = low;
93        self.mid = mid;
94        self.high = high;
95        self
96    }
97
98    pub fn with_midpoint(mut self, midpoint: f64) -> Self {
99        self.midpoint = midpoint;
100        self
101    }
102
103    /// Fix the colour domain to `[lo, hi]` instead of whatever the data
104    /// happens to span. Out-of-range values follow
105    /// [`with_oob`](Self::with_oob) (default: `na_value`).
106    pub fn with_limits(mut self, lo: f64, hi: f64) -> Self {
107        self.limits = Some((lo, hi));
108        self
109    }
110
111    /// Out-of-bounds policy for [`with_limits`](Self::with_limits):
112    /// [`Oob::Censor`](super::Oob::Censor) (default) or
113    /// [`Oob::Squish`](super::Oob::Squish).
114    pub fn with_oob(mut self, oob: super::Oob) -> Self {
115        self.oob = oob;
116        self
117    }
118
119    /// Colour for a genuinely missing (`NA`) value, or one censored by
120    /// `with_limits`'s default [`Oob::Censor`]. Default ggplot2's
121    /// `"grey50"`, `(127, 127, 127)`.
122    pub fn with_na_value(mut self, color: (u8, u8, u8)) -> Self {
123        self.na_value = color;
124        self
125    }
126}
127
128impl Scale for ScaleColorGradient2 {
129    fn set_default_locale(&mut self, locale: &crate::locale::Locale) {
130        if self.locale.is_none() {
131            self.locale = Some(locale.clone());
132        }
133    }
134
135    fn locale(&self) -> Option<&crate::locale::Locale> {
136        self.locale.as_ref()
137    }
138
139    fn format_value(&self, value: &Value) -> Option<String> {
140        let f = value.as_f64()?;
141        if !f.is_finite() {
142            return None;
143        }
144        Some(match &self.value_formatter {
145            Some(fmt) => fmt.call(f),
146            None => super::util::format_number_opt(f, self.locale.as_ref()),
147        })
148    }
149
150    fn aesthetic(&self) -> Aesthetic {
151        self.aesthetic.clone()
152    }
153
154    fn train(&mut self, values: &[Value]) {
155        if self.limits.is_some() {
156            return;
157        }
158        for v in values {
159            if let Some(f) = v.as_f64() {
160                if f.is_finite() {
161                    if f < self.min {
162                        self.min = f;
163                    }
164                    if f > self.max {
165                        self.max = f;
166                    }
167                }
168            }
169        }
170    }
171
172    fn map(&self, value: &Value) -> f64 {
173        let f = match value.as_f64() {
174            Some(f) => f,
175            None => return 0.0,
176        };
177        let (lo, hi) = self.limits.unwrap_or((self.min, self.max));
178        let range = hi - lo;
179        if range.abs() < f64::EPSILON {
180            0.5
181        } else {
182            (f - lo) / range
183        }
184    }
185
186    fn breaks(&self) -> Vec<(f64, String)> {
187        let (lo, hi) = self.limits.unwrap_or((self.min, self.max));
188        if lo > hi || !lo.is_finite() || !hi.is_finite() {
189            return vec![];
190        }
191        let range = hi - lo;
192        if super::util::is_degenerate_range(lo, hi) {
193            return vec![(
194                0.5,
195                super::util::format_number_opt(lo, self.locale.as_ref()),
196            )];
197        }
198        let n_breaks = 5;
199        let raw_step = range / n_breaks as f64;
200        let step = nice_step(raw_step);
201        let start = (lo / step).ceil() * step;
202        let values = super::util::stepped_breaks(start, hi, step);
203        let labels = super::util::format_numbers_opt(&values, self.locale.as_ref());
204        values
205            .iter()
206            .zip(labels)
207            .map(|(&v, l)| (self.map(&Value::Float(v)), l))
208            .collect()
209    }
210
211    fn name(&self) -> &str {
212        &self.name
213    }
214
215    fn set_name(&mut self, name: &str) {
216        self.name = name.to_string();
217    }
218
219    fn map_to_color(&self, value: &Value) -> Option<(u8, u8, u8)> {
220        // A genuinely missing, non-numeric or non-finite value, and a
221        // value censored by `with_limits`, all render as `na_value`
222        // (ggplot2's own behaviour) instead of a plausible-looking colour
223        // or a bare `None` the geom silently papers over with its own
224        // unrelated default (review PR#141 finding 3).
225        if value.is_na() {
226            return Some(self.na_value);
227        }
228        let Some(f) = value.as_f64() else {
229            return Some(self.na_value);
230        };
231        if !f.is_finite() {
232            return Some(self.na_value);
233        }
234        let (lo, hi) = self.limits.unwrap_or((self.min, self.max));
235        let Some(f) = super::resolve_oob(f, self.limits, self.oob) else {
236            return Some(self.na_value);
237        };
238
239        // Map relative to midpoint
240        let c = if f <= self.midpoint {
241            let range = self.midpoint - lo;
242            let t = if range.abs() < f64::EPSILON {
243                0.0
244            } else {
245                (f - lo) / range
246            };
247            self.low.lerp(&self.mid, t.clamp(0.0, 1.0))
248        } else {
249            let range = hi - self.midpoint;
250            let t = if range.abs() < f64::EPSILON {
251                1.0
252            } else {
253                (f - self.midpoint) / range
254            };
255            self.mid.lerp(&self.high, t.clamp(0.0, 1.0))
256        };
257
258        Some((c.r, c.g, c.b))
259    }
260
261    fn domain(&self) -> Option<(f64, f64)> {
262        if let Some((lo, hi)) = self.limits {
263            return Some((lo, hi));
264        }
265        if self.min.is_finite() && self.max.is_finite() && self.min <= self.max {
266            Some((self.min, self.max))
267        } else {
268            None
269        }
270    }
271
272    fn clone_box(&self) -> Box<dyn Scale> {
273        Box::new(self.clone())
274    }
275
276    fn reset_training(&mut self) {
277        self.min = f64::INFINITY;
278        self.max = f64::NEG_INFINITY;
279    }
280
281    fn validate(&self, warnings: &mut Vec<String>) {
282        if let Some((lo, hi)) = self.limits {
283            if lo > hi {
284                warnings.push(format!(
285                    "scale_color/fill_gradient2: with_limits({lo}, {hi}) has lo > hi; every value maps to na_value"
286                ));
287            }
288        }
289    }
290}
291
292#[cfg(test)]
293mod tests {
294    use super::*;
295
296    #[test]
297    fn format_value_formats_a_finite_number_and_rejects_non_finite() {
298        let s = ScaleColorGradient2::new(Aesthetic::Color);
299        assert_eq!(s.format_value(&Value::Float(7.0)), Some("7".to_string()));
300        assert_eq!(s.format_value(&Value::Float(f64::NEG_INFINITY)), None);
301    }
302
303    #[test]
304    fn with_value_formatter_overrides_format_value() {
305        let s =
306            ScaleColorGradient2::new(Aesthetic::Color).with_value_formatter(|v| format!("{v:.1}%"));
307        assert_eq!(s.format_value(&Value::Float(7.0)), Some("7.0%".to_string()));
308        assert_eq!(s.format_value(&Value::Float(f64::NEG_INFINITY)), None);
309    }
310
311    #[test]
312    fn debug_reports_whether_a_formatter_is_set() {
313        let without = format!("{:?}", ScaleColorGradient2::new(Aesthetic::Color));
314        assert!(without.contains("ScaleColorGradient2"));
315        assert!(without.contains("value_formatter: false"));
316        let with = format!(
317            "{:?}",
318            ScaleColorGradient2::new(Aesthetic::Color).with_value_formatter(|v| v.to_string())
319        );
320        assert!(with.contains("value_formatter: true"));
321    }
322
323    /// #164 review: see the matching test in `scale/color.rs`.
324    #[test]
325    fn scale_color_gradient2_keeps_every_auto_trait() {
326        fn assert_traits<
327            T: Send + Sync + std::panic::UnwindSafe + std::panic::RefUnwindSafe + Clone,
328        >() {
329        }
330        assert_traits::<ScaleColorGradient2>();
331    }
332
333    #[test]
334    fn with_limits_fixes_the_domain_and_training_does_not_widen_it() {
335        let mut s = ScaleColorGradient2::new(Aesthetic::Fill).with_limits(-2.0, 2.0);
336        s.train(&[Value::Float(-50.0), Value::Float(50.0)]);
337        assert_eq!(s.domain(), Some((-2.0, 2.0)));
338    }
339}