Skip to main content

ggplot_rs/scale/
steps.rs

1use crate::aes::Aesthetic;
2use crate::data::Value;
3
4use super::Scale;
5
6/// Binned (stepped) continuous colour scale — R's `scale_*_steps` /
7/// `scale_*_stepsn` / `scale_*_fermenter`.
8///
9/// A continuous variable is bucketed into `n_bins` equal-width bins over the
10/// trained domain, and each bin is filled with a discrete colour sampled from
11/// the gradient. It reports as discrete, so the legend shows one stepped swatch
12/// per bin (a `guide_bins`/`guide_coloursteps`-style legend).
13#[derive(Clone)]
14pub struct ScaleColorSteps {
15    aesthetic: Aesthetic,
16    stops: Vec<(u8, u8, u8)>,
17    n_bins: usize,
18    name: String,
19    min: f64,
20    max: f64,
21    trained: bool,
22    /// `scale_*_steps2()` (#408): the value the middle stop sits at; the
23    /// stops then spread over `midpoint ± max |limit − midpoint|`.
24    midpoint: Option<f64>,
25}
26
27impl ScaleColorSteps {
28    /// Build from evenly-spaced gradient stops (`>= 1`) and a bin count.
29    pub fn new(aesthetic: Aesthetic, stops: Vec<(u8, u8, u8)>, n_bins: usize) -> Self {
30        ScaleColorSteps {
31            aesthetic,
32            stops: if stops.is_empty() {
33                vec![(50, 50, 200), (200, 50, 50)]
34            } else {
35                stops
36            },
37            n_bins: n_bins.max(1),
38            name: String::new(),
39            min: f64::INFINITY,
40            max: f64::NEG_INFINITY,
41            trained: false,
42            midpoint: None,
43        }
44    }
45
46    /// Diverging binned scale (ggplot2's `scale_*_steps2(low, mid, high,
47    /// midpoint)`, #408): `mid` sits at `midpoint`, not at the domain centre.
48    pub fn diverging(
49        aesthetic: Aesthetic,
50        low: (u8, u8, u8),
51        mid: (u8, u8, u8),
52        high: (u8, u8, u8),
53        midpoint: f64,
54        n_bins: usize,
55    ) -> Self {
56        let mut s = Self::new(aesthetic, vec![low, mid, high], n_bins);
57        s.midpoint = Some(midpoint);
58        s
59    }
60
61    /// Binned viridis (ggplot2's `scale_*_viridis_b(option)`, #408):
62    /// `option` 'A'–'H' as [`GeneratedPalette::Viridis`].
63    ///
64    /// [`GeneratedPalette::Viridis`]: super::color::GeneratedPalette::Viridis
65    pub fn viridis(aesthetic: Aesthetic, option: char, n_bins: usize) -> Self {
66        let stops = super::color::GeneratedPalette::Viridis {
67            option,
68            begin: 0.0,
69            end: 1.0,
70            direction: 1,
71        }
72        .colors(16)
73        .into_iter()
74        .map(|c| (c.r, c.g, c.b))
75        .collect();
76        Self::new(aesthetic, stops, n_bins)
77    }
78
79    /// Two-colour low→high binned scale.
80    pub fn two(aesthetic: Aesthetic, low: (u8, u8, u8), high: (u8, u8, u8), n_bins: usize) -> Self {
81        Self::new(aesthetic, vec![low, high], n_bins)
82    }
83
84    fn interp(&self, f: f64) -> (u8, u8, u8) {
85        let f = f.clamp(0.0, 1.0);
86        if self.stops.len() == 1 {
87            return self.stops[0];
88        }
89        let segs = self.stops.len() - 1;
90        let pos = f * segs as f64;
91        let i = (pos.floor() as usize).min(segs - 1);
92        let t = pos - i as f64;
93        let (r0, g0, b0) = self.stops[i];
94        let (r1, g1, b1) = self.stops[i + 1];
95        let lerp = |a: u8, b: u8| (a as f64 + (b as f64 - a as f64) * t).round() as u8;
96        (lerp(r0, r1), lerp(g0, g1), lerp(b0, b1))
97    }
98
99    fn bin_index(&self, v: f64) -> usize {
100        if !self.trained || self.max <= self.min {
101            return 0;
102        }
103        let frac = (v - self.min) / (self.max - self.min);
104        ((frac * self.n_bins as f64).floor() as isize).clamp(0, self.n_bins as isize - 1) as usize
105    }
106
107    fn bin_color(&self, i: usize) -> (u8, u8, u8) {
108        // Colour at the bin centre.
109        let f = (i as f64 + 0.5) / self.n_bins as f64;
110        match self.midpoint {
111            Some(mid) if self.trained && self.max > self.min => {
112                let v = self.min + f * (self.max - self.min);
113                let half = (self.max - mid).abs().max((self.min - mid).abs());
114                self.interp(0.5 + (v - mid) / (2.0 * half))
115            }
116            _ => self.interp(f),
117        }
118    }
119
120    fn bin_label(&self, i: usize) -> String {
121        if !self.trained || self.max <= self.min {
122            return format!("bin {i}");
123        }
124        let w = (self.max - self.min) / self.n_bins as f64;
125        let lo = self.min + i as f64 * w;
126        format!("[{:.2}, {:.2})", lo, lo + w)
127    }
128}
129
130impl Scale for ScaleColorSteps {
131    fn aesthetic(&self) -> Aesthetic {
132        self.aesthetic.clone()
133    }
134
135    fn train(&mut self, values: &[Value]) {
136        for v in values {
137            if let Some(f) = v.as_f64() {
138                if f.is_finite() {
139                    self.min = self.min.min(f);
140                    self.max = self.max.max(f);
141                    self.trained = true;
142                }
143            }
144        }
145    }
146
147    fn map(&self, _value: &Value) -> f64 {
148        0.0
149    }
150
151    fn breaks(&self) -> Vec<(f64, String)> {
152        (0..self.n_bins)
153            .map(|i| ((i as f64 + 0.5) / self.n_bins as f64, self.bin_label(i)))
154            .collect()
155    }
156
157    fn name(&self) -> &str {
158        &self.name
159    }
160
161    fn set_name(&mut self, name: &str) {
162        self.name = name.to_string();
163    }
164
165    fn is_discrete(&self) -> bool {
166        true
167    }
168
169    fn map_to_color(&self, value: &Value) -> Option<(u8, u8, u8)> {
170        if let Some(f) = value.as_f64() {
171            return Some(self.bin_color(self.bin_index(f)));
172        }
173        // Legend passes the bin label back as a string — match it to a bin.
174        if let Value::Str(s) = value {
175            for i in 0..self.n_bins {
176                if &self.bin_label(i) == s {
177                    return Some(self.bin_color(i));
178                }
179            }
180        }
181        None
182    }
183
184    fn domain(&self) -> Option<(f64, f64)> {
185        if self.trained {
186            Some((self.min, self.max))
187        } else {
188            None
189        }
190    }
191
192    fn clone_box(&self) -> Box<dyn Scale> {
193        Box::new(self.clone())
194    }
195
196    fn reset_training(&mut self) {
197        self.min = f64::INFINITY;
198        self.max = f64::NEG_INFINITY;
199        self.trained = false;
200    }
201}
202
203#[cfg(test)]
204mod tests {
205    use super::*;
206
207    fn trained(n: usize) -> ScaleColorSteps {
208        let mut s = ScaleColorSteps::two(Aesthetic::Color, (0, 0, 0), (255, 255, 255), n);
209        s.train(&[Value::Float(0.0), Value::Float(100.0)]);
210        s
211    }
212
213    #[test]
214    fn steps2_puts_mid_at_the_midpoint() {
215        let mut s = ScaleColorSteps::diverging(
216            Aesthetic::Fill,
217            (0, 0, 255),
218            (255, 255, 255),
219            (255, 0, 0),
220            0.0,
221            4,
222        );
223        s.train(&[Value::Float(-10.0), Value::Float(30.0)]);
224        // Bins of 10: [-10, 0) is below the midpoint → bluish; [20, 30) red.
225        let lo = s.map_to_color(&Value::Float(-5.0)).unwrap();
226        let hi = s.map_to_color(&Value::Float(25.0)).unwrap();
227        assert!(lo.2 > lo.0, "{lo:?}");
228        assert!(hi.0 > hi.2, "{hi:?}");
229        // The bin just above 0 is near-white (mid), not purple.
230        let near = s.map_to_color(&Value::Float(5.0)).unwrap();
231        assert!(near.1 > 150, "{near:?}");
232    }
233
234    #[test]
235    fn viridis_b_runs_dark_to_yellow() {
236        let mut s = ScaleColorSteps::viridis(Aesthetic::Color, 'D', 5);
237        s.train(&[Value::Float(0.0), Value::Float(1.0)]);
238        let lo = s.map_to_color(&Value::Float(0.0)).unwrap();
239        let hi = s.map_to_color(&Value::Float(1.0)).unwrap();
240        assert!(hi.0 as u32 + hi.1 as u32 > lo.0 as u32 + lo.1 as u32);
241    }
242
243    #[test]
244    fn bins_map_to_distinct_colors() {
245        let s = trained(4);
246        let low = s.map_to_color(&Value::Float(5.0)).unwrap();
247        let high = s.map_to_color(&Value::Float(95.0)).unwrap();
248        assert_ne!(low, high);
249        // Endpoints clamp within range.
250        assert_eq!(
251            s.map_to_color(&Value::Float(-10.0)),
252            s.map_to_color(&Value::Float(0.0))
253        );
254    }
255
256    #[test]
257    fn same_bin_same_color() {
258        let s = trained(4); // bins of width 25
259        let a = s.map_to_color(&Value::Float(1.0)).unwrap();
260        let b = s.map_to_color(&Value::Float(24.0)).unwrap();
261        assert_eq!(a, b, "values in the same bin share a colour");
262    }
263
264    #[test]
265    fn legend_label_roundtrips_to_bin_color() {
266        let s = trained(5);
267        let breaks = s.breaks();
268        assert_eq!(breaks.len(), 5);
269        // Each break label must map (as a string) to the same colour as the bin.
270        for (i, (_, label)) in breaks.iter().enumerate() {
271            let via_label = s.map_to_color(&Value::Str(label.clone())).unwrap();
272            assert_eq!(via_label, s.bin_color(i));
273        }
274    }
275}