Skip to main content

ggplot_rs/scale/
gradient.rs

1use crate::aes::Aesthetic;
2use crate::data::Value;
3
4use super::color::RGBAColor;
5use super::util::nice_step;
6use super::Scale;
7
8/// Diverging gradient color scale (low → mid → high with midpoint).
9#[derive(Clone, Debug)]
10pub struct ScaleColorGradient2 {
11    aesthetic: Aesthetic,
12    name: String,
13    low: RGBAColor,
14    mid: RGBAColor,
15    high: RGBAColor,
16    midpoint: f64,
17    min: f64,
18    max: f64,
19    /// Locale for the default break labels (`None` = default marks).
20    locale: Option<crate::locale::Locale>,
21    /// Fixed `(lo, hi)` domain ([`with_limits`](Self::with_limits)):
22    /// `train` no longer widens it, and `map_to_color` maps against it
23    /// instead of the trained min/max.
24    limits: Option<(f64, f64)>,
25    /// Out-of-bounds policy for `limits` ([`with_oob`](Self::with_oob)).
26    /// Default [`Oob::Censor`](super::Oob::Censor).
27    oob: super::Oob,
28    /// Colour for a genuinely missing or out-of-bounds-and-censored value
29    /// ([`with_na_value`](Self::with_na_value)). Default ggplot2's
30    /// `"grey50"`, `(127, 127, 127)`.
31    na_value: (u8, u8, u8),
32}
33
34impl ScaleColorGradient2 {
35    pub fn new(aesthetic: Aesthetic) -> Self {
36        ScaleColorGradient2 {
37            aesthetic,
38            name: String::new(),
39            low: RGBAColor::new(0, 0, 255),
40            mid: RGBAColor::new(255, 255, 255),
41            high: RGBAColor::new(255, 0, 0),
42            midpoint: 0.0,
43            min: f64::INFINITY,
44            max: f64::NEG_INFINITY,
45            locale: None,
46            limits: None,
47            oob: super::Oob::Censor,
48            na_value: super::DEFAULT_NA_VALUE,
49        }
50    }
51
52    pub fn with_colors(mut self, low: RGBAColor, mid: RGBAColor, high: RGBAColor) -> Self {
53        self.low = low;
54        self.mid = mid;
55        self.high = high;
56        self
57    }
58
59    pub fn with_midpoint(mut self, midpoint: f64) -> Self {
60        self.midpoint = midpoint;
61        self
62    }
63
64    /// Fix the colour domain to `[lo, hi]` instead of whatever the data
65    /// happens to span. Out-of-range values follow
66    /// [`with_oob`](Self::with_oob) (default: `na_value`).
67    pub fn with_limits(mut self, lo: f64, hi: f64) -> Self {
68        self.limits = Some((lo, hi));
69        self
70    }
71
72    /// Out-of-bounds policy for [`with_limits`](Self::with_limits):
73    /// [`Oob::Censor`](super::Oob::Censor) (default) or
74    /// [`Oob::Squish`](super::Oob::Squish).
75    pub fn with_oob(mut self, oob: super::Oob) -> Self {
76        self.oob = oob;
77        self
78    }
79
80    /// Colour for a genuinely missing (`NA`) value, or one censored by
81    /// `with_limits`'s default [`Oob::Censor`]. Default ggplot2's
82    /// `"grey50"`, `(127, 127, 127)`.
83    pub fn with_na_value(mut self, color: (u8, u8, u8)) -> Self {
84        self.na_value = color;
85        self
86    }
87}
88
89impl Scale for ScaleColorGradient2 {
90    fn set_default_locale(&mut self, locale: &crate::locale::Locale) {
91        if self.locale.is_none() {
92            self.locale = Some(locale.clone());
93        }
94    }
95
96    fn locale(&self) -> Option<&crate::locale::Locale> {
97        self.locale.as_ref()
98    }
99
100    fn format_value(&self, value: &Value) -> Option<String> {
101        let f = value.as_f64()?;
102        if !f.is_finite() {
103            return None;
104        }
105        Some(super::util::format_number_opt(f, self.locale.as_ref()))
106    }
107
108    fn aesthetic(&self) -> Aesthetic {
109        self.aesthetic.clone()
110    }
111
112    fn train(&mut self, values: &[Value]) {
113        if self.limits.is_some() {
114            return;
115        }
116        for v in values {
117            if let Some(f) = v.as_f64() {
118                if f.is_finite() {
119                    if f < self.min {
120                        self.min = f;
121                    }
122                    if f > self.max {
123                        self.max = f;
124                    }
125                }
126            }
127        }
128    }
129
130    fn map(&self, value: &Value) -> f64 {
131        let f = match value.as_f64() {
132            Some(f) => f,
133            None => return 0.0,
134        };
135        let (lo, hi) = self.limits.unwrap_or((self.min, self.max));
136        let range = hi - lo;
137        if range.abs() < f64::EPSILON {
138            0.5
139        } else {
140            (f - lo) / range
141        }
142    }
143
144    fn breaks(&self) -> Vec<(f64, String)> {
145        let (lo, hi) = self.limits.unwrap_or((self.min, self.max));
146        if lo > hi || !lo.is_finite() || !hi.is_finite() {
147            return vec![];
148        }
149        let range = hi - lo;
150        if super::util::is_degenerate_range(lo, hi) {
151            return vec![(
152                0.5,
153                super::util::format_number_opt(lo, self.locale.as_ref()),
154            )];
155        }
156        let n_breaks = 5;
157        let raw_step = range / n_breaks as f64;
158        let step = nice_step(raw_step);
159        let start = (lo / step).ceil() * step;
160        let values = super::util::stepped_breaks(start, hi, step);
161        let labels = super::util::format_numbers_opt(&values, self.locale.as_ref());
162        values
163            .iter()
164            .zip(labels)
165            .map(|(&v, l)| (self.map(&Value::Float(v)), l))
166            .collect()
167    }
168
169    fn name(&self) -> &str {
170        &self.name
171    }
172
173    fn set_name(&mut self, name: &str) {
174        self.name = name.to_string();
175    }
176
177    fn map_to_color(&self, value: &Value) -> Option<(u8, u8, u8)> {
178        // A genuinely missing, non-numeric or non-finite value, and a
179        // value censored by `with_limits`, all render as `na_value`
180        // (ggplot2's own behaviour) instead of a plausible-looking colour
181        // or a bare `None` the geom silently papers over with its own
182        // unrelated default (review PR#141 finding 3).
183        if value.is_na() {
184            return Some(self.na_value);
185        }
186        let Some(f) = value.as_f64() else {
187            return Some(self.na_value);
188        };
189        if !f.is_finite() {
190            return Some(self.na_value);
191        }
192        let (lo, hi) = self.limits.unwrap_or((self.min, self.max));
193        let Some(f) = super::resolve_oob(f, self.limits, self.oob) else {
194            return Some(self.na_value);
195        };
196
197        // Map relative to midpoint
198        let c = if f <= self.midpoint {
199            let range = self.midpoint - lo;
200            let t = if range.abs() < f64::EPSILON {
201                0.0
202            } else {
203                (f - lo) / range
204            };
205            self.low.lerp(&self.mid, t.clamp(0.0, 1.0))
206        } else {
207            let range = hi - self.midpoint;
208            let t = if range.abs() < f64::EPSILON {
209                1.0
210            } else {
211                (f - self.midpoint) / range
212            };
213            self.mid.lerp(&self.high, t.clamp(0.0, 1.0))
214        };
215
216        Some((c.r, c.g, c.b))
217    }
218
219    fn domain(&self) -> Option<(f64, f64)> {
220        if let Some((lo, hi)) = self.limits {
221            return Some((lo, hi));
222        }
223        if self.min.is_finite() && self.max.is_finite() && self.min <= self.max {
224            Some((self.min, self.max))
225        } else {
226            None
227        }
228    }
229
230    fn clone_box(&self) -> Box<dyn Scale> {
231        Box::new(self.clone())
232    }
233
234    fn reset_training(&mut self) {
235        self.min = f64::INFINITY;
236        self.max = f64::NEG_INFINITY;
237    }
238
239    fn validate(&self, warnings: &mut Vec<String>) {
240        if let Some((lo, hi)) = self.limits {
241            if lo > hi {
242                warnings.push(format!(
243                    "scale_color/fill_gradient2: with_limits({lo}, {hi}) has lo > hi; every value maps to na_value"
244                ));
245            }
246        }
247    }
248}
249
250#[cfg(test)]
251mod tests {
252    use super::*;
253
254    #[test]
255    fn format_value_formats_a_finite_number_and_rejects_non_finite() {
256        let s = ScaleColorGradient2::new(Aesthetic::Color);
257        assert_eq!(s.format_value(&Value::Float(7.0)), Some("7".to_string()));
258        assert_eq!(s.format_value(&Value::Float(f64::NEG_INFINITY)), None);
259    }
260
261    #[test]
262    fn with_limits_fixes_the_domain_and_training_does_not_widen_it() {
263        let mut s = ScaleColorGradient2::new(Aesthetic::Fill).with_limits(-2.0, 2.0);
264        s.train(&[Value::Float(-50.0), Value::Float(50.0)]);
265        assert_eq!(s.domain(), Some((-2.0, 2.0)));
266    }
267}