Skip to main content

ggplot_rs/scale/
gradient_n.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/// N-stop continuous color gradient scale.
10/// Interpolates linearly between user-defined color stops.
11#[derive(Clone)]
12pub struct ScaleColorGradientN {
13    aesthetic: Aesthetic,
14    name: String,
15    /// Color stops as (position_0_to_1, color) pairs, sorted by position.
16    stops: Vec<(f64, RGBAColor)>,
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    /// Formatter for [`format_value`](super::Scale::format_value) (#164).
33    /// Falls back to the plain locale-formatted number when unset. See
34    /// [`UnwindSafeFormatter`] for why this isn't a plain `LabelFormatter`.
35    value_formatter: Option<UnwindSafeFormatter>,
36}
37
38// Manual `Debug`: `value_formatter` (an `Arc<dyn Fn>`) isn't `Debug`.
39impl std::fmt::Debug for ScaleColorGradientN {
40    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
41        f.debug_struct("ScaleColorGradientN")
42            .field("aesthetic", &self.aesthetic)
43            .field("name", &self.name)
44            .field("stops", &self.stops)
45            .field("min", &self.min)
46            .field("max", &self.max)
47            .field("locale", &self.locale)
48            .field("limits", &self.limits)
49            .field("oob", &self.oob)
50            .field("na_value", &self.na_value)
51            .field("value_formatter", &self.value_formatter.is_some())
52            .finish()
53    }
54}
55
56impl ScaleColorGradientN {
57    /// Create a new N-stop gradient for the given aesthetic.
58    /// Stops are `(position, color)` where position is in [0, 1].
59    pub fn new(aesthetic: Aesthetic, stops: Vec<(f64, RGBAColor)>) -> Self {
60        let mut stops = stops;
61        stops.sort_by(|a, b| a.0.total_cmp(&b.0));
62        ScaleColorGradientN {
63            aesthetic,
64            name: String::new(),
65            stops,
66            min: f64::INFINITY,
67            max: f64::NEG_INFINITY,
68            locale: None,
69            limits: None,
70            oob: super::Oob::Censor,
71            na_value: super::DEFAULT_NA_VALUE,
72            value_formatter: None,
73        }
74    }
75
76    /// Set a formatter for [`format_value`](super::Scale::format_value) —
77    /// a per-mark `data-fvalue`/tooltip-template value for this fill/colour
78    /// scale (#164). Falls back to the plain locale-formatted number when
79    /// unset (byte-identical default output).
80    pub fn with_value_formatter<F>(mut self, f: F) -> Self
81    where
82        F: Fn(f64) -> String + Send + Sync + 'static,
83    {
84        self.value_formatter = Some(UnwindSafeFormatter(std::sync::Arc::new(f)));
85        self
86    }
87
88    /// Fix the colour domain to `[lo, hi]` instead of whatever the data
89    /// happens to span. Out-of-range values follow
90    /// [`with_oob`](Self::with_oob) (default: `na_value`).
91    pub fn with_limits(mut self, lo: f64, hi: f64) -> Self {
92        self.limits = Some((lo, hi));
93        self
94    }
95
96    /// Out-of-bounds policy for [`with_limits`](Self::with_limits):
97    /// [`Oob::Censor`](super::Oob::Censor) (default) or
98    /// [`Oob::Squish`](super::Oob::Squish).
99    pub fn with_oob(mut self, oob: super::Oob) -> Self {
100        self.oob = oob;
101        self
102    }
103
104    /// Colour for a genuinely missing (`NA`) value, or one censored by
105    /// `with_limits`'s default [`Oob::Censor`]. Default ggplot2's
106    /// `"grey50"`, `(127, 127, 127)`.
107    pub fn with_na_value(mut self, color: (u8, u8, u8)) -> Self {
108        self.na_value = color;
109        self
110    }
111
112    /// Create a continuous viridis palette.
113    pub fn viridis(aesthetic: Aesthetic) -> Self {
114        Self::new(aesthetic, viridis_stops())
115    }
116
117    /// Create a continuous magma palette.
118    pub fn magma(aesthetic: Aesthetic) -> Self {
119        Self::new(aesthetic, magma_stops())
120    }
121
122    /// Create a continuous plasma palette.
123    pub fn plasma(aesthetic: Aesthetic) -> Self {
124        Self::new(aesthetic, plasma_stops())
125    }
126
127    /// Create a continuous inferno palette.
128    pub fn inferno(aesthetic: Aesthetic) -> Self {
129        Self::new(aesthetic, inferno_stops())
130    }
131
132    /// Interpolate the color at a normalized position t in [0, 1].
133    fn color_at(&self, t: f64) -> RGBAColor {
134        let t = t.clamp(0.0, 1.0);
135        if self.stops.is_empty() {
136            return RGBAColor::new(127, 127, 127);
137        }
138        if self.stops.len() == 1 {
139            return self.stops[0].1;
140        }
141        // Find the two surrounding stops
142        if t <= self.stops[0].0 {
143            return self.stops[0].1;
144        }
145        if t >= self.stops[self.stops.len() - 1].0 {
146            return self.stops[self.stops.len() - 1].1;
147        }
148        for i in 0..self.stops.len() - 1 {
149            let (p0, c0) = &self.stops[i];
150            let (p1, c1) = &self.stops[i + 1];
151            if t >= *p0 && t <= *p1 {
152                let range = p1 - p0;
153                let local_t = if range.abs() < f64::EPSILON {
154                    0.0
155                } else {
156                    (t - p0) / range
157                };
158                return c0.lerp(c1, local_t);
159            }
160        }
161        self.stops[self.stops.len() - 1].1
162    }
163}
164
165impl Scale for ScaleColorGradientN {
166    fn set_default_locale(&mut self, locale: &crate::locale::Locale) {
167        if self.locale.is_none() {
168            self.locale = Some(locale.clone());
169        }
170    }
171
172    fn locale(&self) -> Option<&crate::locale::Locale> {
173        self.locale.as_ref()
174    }
175
176    fn format_value(&self, value: &Value) -> Option<String> {
177        let f = value.as_f64()?;
178        if !f.is_finite() {
179            return None;
180        }
181        Some(match &self.value_formatter {
182            Some(fmt) => fmt.call(f),
183            None => super::util::format_number_opt(f, self.locale.as_ref()),
184        })
185    }
186
187    fn aesthetic(&self) -> Aesthetic {
188        self.aesthetic.clone()
189    }
190
191    fn train(&mut self, values: &[Value]) {
192        if self.limits.is_some() {
193            return;
194        }
195        for v in values {
196            if let Some(f) = v.as_f64() {
197                if f.is_finite() {
198                    if f < self.min {
199                        self.min = f;
200                    }
201                    if f > self.max {
202                        self.max = f;
203                    }
204                }
205            }
206        }
207    }
208
209    fn map(&self, value: &Value) -> f64 {
210        let f = match value.as_f64() {
211            Some(f) => f,
212            None => return 0.0,
213        };
214        let (lo, hi) = self.limits.unwrap_or((self.min, self.max));
215        let range = hi - lo;
216        if range.abs() < f64::EPSILON {
217            0.5
218        } else {
219            (f - lo) / range
220        }
221    }
222
223    fn breaks(&self) -> Vec<(f64, String)> {
224        let (lo, hi) = self.limits.unwrap_or((self.min, self.max));
225        if lo > hi || !lo.is_finite() || !hi.is_finite() {
226            return vec![];
227        }
228        let range = hi - lo;
229        if super::util::is_degenerate_range(lo, hi) {
230            return vec![(
231                0.5,
232                super::util::format_number_opt(lo, self.locale.as_ref()),
233            )];
234        }
235        let n_breaks = 5;
236        let raw_step = range / n_breaks as f64;
237        let step = nice_step(raw_step);
238        let start = (lo / step).ceil() * step;
239        let values = super::util::stepped_breaks(start, hi, step);
240        let labels = super::util::format_numbers_opt(&values, self.locale.as_ref());
241        values
242            .iter()
243            .zip(labels)
244            .map(|(&v, l)| (self.map(&Value::Float(v)), l))
245            .collect()
246    }
247
248    fn name(&self) -> &str {
249        &self.name
250    }
251
252    fn set_name(&mut self, name: &str) {
253        self.name = name.to_string();
254    }
255
256    fn map_to_color(&self, value: &Value) -> Option<(u8, u8, u8)> {
257        // A genuinely missing or non-finite value, and a value censored
258        // by `with_limits`, render as `na_value` (ggplot2's own
259        // behaviour) instead of a plausible-looking colour (review PR#141
260        // finding 3). A non-numeric-but-present value (e.g. the wrong
261        // column type) keeps the pre-existing contract of falling through
262        // to `map`'s own 0.0 fallback instead — this type's `map_to_color`
263        // always returned `Some` for it before `with_limits`/`Oob` existed.
264        if value.is_na() {
265            return Some(self.na_value);
266        }
267        if let Some(f) = value.as_f64() {
268            if !f.is_finite() {
269                return Some(self.na_value);
270            }
271            if super::resolve_oob(f, self.limits, self.oob).is_none() {
272                return Some(self.na_value);
273            }
274        }
275        let t = self.map(value);
276        let c = self.color_at(t);
277        Some((c.r, c.g, c.b))
278    }
279
280    fn domain(&self) -> Option<(f64, f64)> {
281        if let Some((lo, hi)) = self.limits {
282            return Some((lo, hi));
283        }
284        if self.min.is_finite() && self.max.is_finite() && self.min <= self.max {
285            Some((self.min, self.max))
286        } else {
287            None
288        }
289    }
290
291    fn clone_box(&self) -> Box<dyn Scale> {
292        Box::new(self.clone())
293    }
294
295    fn reset_training(&mut self) {
296        self.min = f64::INFINITY;
297        self.max = f64::NEG_INFINITY;
298    }
299
300    fn validate(&self, warnings: &mut Vec<String>) {
301        if let Some((lo, hi)) = self.limits {
302            if lo > hi {
303                warnings.push(format!(
304                    "scale_color/fill_gradientn: with_limits({lo}, {hi}) has lo > hi; every value maps to na_value"
305                ));
306            }
307        }
308    }
309}
310
311// ─── Continuous palette color stops ──────────────────────────────
312
313fn c(r: u8, g: u8, b: u8) -> RGBAColor {
314    RGBAColor::new(r, g, b)
315}
316
317fn viridis_stops() -> Vec<(f64, RGBAColor)> {
318    let colors = [
319        c(68, 1, 84),
320        c(72, 26, 108),
321        c(71, 47, 126),
322        c(65, 68, 135),
323        c(57, 86, 140),
324        c(47, 104, 142),
325        c(38, 121, 142),
326        c(31, 138, 141),
327        c(30, 155, 138),
328        c(42, 172, 130),
329        c(70, 188, 115),
330        c(109, 202, 93),
331        c(155, 213, 67),
332        c(200, 222, 39),
333        c(240, 229, 30),
334        c(253, 231, 37),
335    ];
336    evenly_spaced_stops(&colors)
337}
338
339fn magma_stops() -> Vec<(f64, RGBAColor)> {
340    let colors = [
341        c(0, 0, 4),
342        c(16, 12, 50),
343        c(41, 17, 90),
344        c(72, 12, 110),
345        c(101, 19, 110),
346        c(131, 29, 103),
347        c(160, 42, 93),
348        c(187, 55, 84),
349        c(213, 72, 72),
350        c(232, 99, 62),
351        c(247, 131, 57),
352        c(254, 167, 69),
353        c(254, 203, 99),
354        c(252, 235, 141),
355        c(252, 254, 188),
356        c(252, 253, 191),
357    ];
358    evenly_spaced_stops(&colors)
359}
360
361fn plasma_stops() -> Vec<(f64, RGBAColor)> {
362    let colors = [
363        c(13, 8, 135),
364        c(53, 5, 157),
365        c(82, 1, 163),
366        c(109, 1, 159),
367        c(133, 7, 147),
368        c(156, 23, 127),
369        c(175, 42, 106),
370        c(192, 61, 85),
371        c(206, 82, 66),
372        c(218, 105, 46),
373        c(228, 130, 24),
374        c(236, 157, 6),
375        c(240, 185, 11),
376        c(239, 213, 38),
377        c(232, 240, 73),
378        c(240, 249, 33),
379    ];
380    evenly_spaced_stops(&colors)
381}
382
383fn inferno_stops() -> Vec<(f64, RGBAColor)> {
384    let colors = [
385        c(0, 0, 4),
386        c(14, 11, 49),
387        c(39, 15, 90),
388        c(67, 10, 107),
389        c(95, 13, 106),
390        c(122, 21, 97),
391        c(149, 33, 81),
392        c(174, 49, 60),
393        c(196, 69, 38),
394        c(215, 95, 15),
395        c(231, 124, 3),
396        c(243, 155, 7),
397        c(250, 189, 28),
398        c(252, 222, 67),
399        c(247, 252, 118),
400        c(252, 255, 164),
401    ];
402    evenly_spaced_stops(&colors)
403}
404
405fn evenly_spaced_stops(colors: &[RGBAColor]) -> Vec<(f64, RGBAColor)> {
406    let n = colors.len();
407    if n == 0 {
408        return vec![];
409    }
410    if n == 1 {
411        return vec![(0.0, colors[0])];
412    }
413    colors
414        .iter()
415        .enumerate()
416        .map(|(i, c)| (i as f64 / (n - 1) as f64, *c))
417        .collect()
418}
419
420#[cfg(test)]
421mod tests {
422    use super::*;
423
424    #[test]
425    fn format_value_formats_a_finite_number_and_rejects_non_finite() {
426        let g = ScaleColorGradientN::new(Aesthetic::Color, vec![(0.0, RGBAColor::new(0, 0, 0))]);
427        assert_eq!(g.format_value(&Value::Float(42.0)), Some("42".to_string()));
428        assert_eq!(g.format_value(&Value::Float(f64::INFINITY)), None);
429    }
430
431    #[test]
432    fn with_value_formatter_overrides_format_value() {
433        let g = ScaleColorGradientN::new(Aesthetic::Color, vec![(0.0, RGBAColor::new(0, 0, 0))])
434            .with_value_formatter(|v| format!("#{v:.0}"));
435        assert_eq!(g.format_value(&Value::Float(42.0)), Some("#42".to_string()));
436        assert_eq!(g.format_value(&Value::Float(f64::INFINITY)), None);
437    }
438
439    #[test]
440    fn debug_reports_whether_a_formatter_is_set() {
441        let stops = vec![(0.0, RGBAColor::new(0, 0, 0))];
442        let without = format!(
443            "{:?}",
444            ScaleColorGradientN::new(Aesthetic::Color, stops.clone())
445        );
446        assert!(without.contains("ScaleColorGradientN"));
447        assert!(without.contains("value_formatter: false"));
448        let with = format!(
449            "{:?}",
450            ScaleColorGradientN::new(Aesthetic::Color, stops)
451                .with_value_formatter(|v| v.to_string())
452        );
453        assert!(with.contains("value_formatter: true"));
454    }
455
456    /// #164 review: see the matching test in `scale/color.rs`.
457    #[test]
458    fn scale_color_gradient_n_keeps_every_auto_trait() {
459        fn assert_traits<
460            T: Send + Sync + std::panic::UnwindSafe + std::panic::RefUnwindSafe + Clone,
461        >() {
462        }
463        assert_traits::<ScaleColorGradientN>();
464    }
465
466    #[test]
467    fn with_limits_fixes_the_domain_and_training_does_not_widen_it() {
468        let mut g = ScaleColorGradientN::new(
469            Aesthetic::Color,
470            vec![
471                (0.0, RGBAColor::new(0, 0, 0)),
472                (1.0, RGBAColor::new(255, 255, 255)),
473            ],
474        )
475        .with_limits(0.0, 10.0);
476        g.train(&[Value::Float(-1000.0), Value::Float(1000.0)]);
477        assert_eq!(g.domain(), Some((0.0, 10.0)));
478    }
479
480    #[test]
481    fn test_gradient_n_interpolation() {
482        let g = ScaleColorGradientN::new(
483            Aesthetic::Color,
484            vec![
485                (0.0, RGBAColor::new(0, 0, 0)),
486                (0.5, RGBAColor::new(255, 0, 0)),
487                (1.0, RGBAColor::new(255, 255, 255)),
488            ],
489        );
490        // At t=0 should be black
491        let c0 = g.color_at(0.0);
492        assert_eq!((c0.r, c0.g, c0.b), (0, 0, 0));
493        // At t=0.5 should be red
494        let c5 = g.color_at(0.5);
495        assert_eq!((c5.r, c5.g, c5.b), (255, 0, 0));
496        // At t=1.0 should be white
497        let c1 = g.color_at(1.0);
498        assert_eq!((c1.r, c1.g, c1.b), (255, 255, 255));
499        // At t=0.25 should be ~midpoint between black and red
500        let c25 = g.color_at(0.25);
501        assert_eq!(c25.r, 127); // roughly half of 255
502    }
503
504    #[test]
505    fn test_viridis_continuous_endpoints() {
506        let g = ScaleColorGradientN::viridis(Aesthetic::Fill);
507        let c0 = g.color_at(0.0);
508        assert_eq!((c0.r, c0.g, c0.b), (68, 1, 84));
509        let c1 = g.color_at(1.0);
510        assert_eq!((c1.r, c1.g, c1.b), (253, 231, 37));
511    }
512}